Files
Neo-ZQYY/apps/backend/app/services/note_service.py
Neo 6f8f12314f feat: 累积功能变更 — 聊天集成、租户管理、小程序更新、ETL 增强、迁移脚本
包含多个会话的累积代码变更:
- backend: AI 聊天服务、触发器调度、认证增强、WebSocket、调度器最小间隔
- admin-web: ETL 状态页、任务管理、调度配置、登录优化
- miniprogram: 看板页面、聊天集成、UI 组件、导航更新
- etl: DWS 新任务(finance_area_daily/board_cache)、连接器增强
- tenant-admin: 项目初始化
- db: 19 个迁移脚本(etl_feiqiu 11 + zqyy_app 8)
- packages/shared: 枚举和工具函数更新
- tools: 数据库工具、报表生成、健康检查
- docs: PRD/架构/部署/合约文档更新

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 00:03:48 +08:00

373 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
备注服务
负责备注 CRUD、星星评分存储与读取。
备注类型根据关联任务自动确定follow_up_visit 任务 → follow_up否则 normal。
星星评分不参与回访完成判定,不参与 AI 分析,仅存储。
"""
import json
import logging
from fastapi import HTTPException
from app.trace.decorators import trace_service
logger = logging.getLogger(__name__)
def _get_connection():
"""延迟导入 get_connection避免纯函数测试时触发模块级导入失败。"""
from app.database import get_connection
return get_connection()
def _record_history(
cur,
task_id: int,
action: str,
old_status: str | None = None,
new_status: str | None = None,
old_task_type: str | None = None,
new_task_type: str | None = None,
detail: dict | None = None,
) -> None:
"""在 coach_task_history 中记录变更(复用 task_manager 的模式)。"""
cur.execute(
"""
INSERT INTO biz.coach_task_history
(task_id, action, old_status, new_status,
old_task_type, new_task_type, detail)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""",
(
task_id,
action,
old_status,
new_status,
old_task_type,
new_task_type,
json.dumps(detail) if detail else None,
),
)
@trace_service(description_zh="ai_analyze_note", description_en="Ai Analyze Note")
async def ai_analyze_note(note_id: int, site_id: int, member_id: int, content: str, user_name: str = "") -> int | None:
"""
AI 应用 6 备注分析:调用百炼 Application API 获取评分。
CHANGE 2026-03-27 | 打通 AI 应用 6 调用链
仅执行 App6 评分,不触发 App8 线索整合(后续统一处理)。
返回 score1-10失败返回 None。
"""
try:
from app.ai.config import AIConfig
from app.ai.dashscope_client import DashScopeClient
import json
config = AIConfig.from_env()
client = DashScopeClient(api_key=config.api_key, workspace_id=config.workspace_id)
# 构建 prompt简化版直接传给百炼应用
prompt = json.dumps({
"site_id": site_id,
"member_id": member_id,
"note_content": content,
"noted_by_name": user_name,
}, ensure_ascii=False)
result, tokens_used, _ = await client.call_app(config.app_id_6_note, prompt)
score = result.get("score") if isinstance(result, dict) else None
if score is not None:
score = max(1, min(10, int(score)))
logger.info("App6 备注评分完成: note_id=%d score=%d tokens=%d", note_id, score, tokens_used)
return score
except Exception:
logger.warning("App6 备注评分失败: note_id=%d", note_id, exc_info=True)
return None
async def _async_ai_score(note_id: int, site_id: int, member_id: int, content: str) -> None:
"""后台异步执行 AI 评分,不阻塞 API 响应。"""
try:
ai_score_val = await ai_analyze_note(
note_id=note_id, site_id=site_id, member_id=member_id, content=content,
)
if ai_score_val is not None:
conn = _get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"UPDATE biz.notes SET ai_score = %s, updated_at = NOW() WHERE id = %s",
(ai_score_val, note_id),
)
conn.commit()
logger.info("AI 评分已写入: note_id=%d ai_score=%d", note_id, ai_score_val)
finally:
conn.close()
except Exception:
logger.warning("后台 AI 评分失败: note_id=%d", note_id, exc_info=True)
@trace_service("创建备注", "Create note")
async def create_note(
site_id: int,
user_id: int,
target_type: str,
target_id: int,
content: str,
task_id: int | None = None,
rating_service_willingness: int | None = None,
rating_revisit_likelihood: int | None = None,
score: int | None = None,
) -> dict:
"""
创建备注。
1. 验证评分范围1-5 或 NULL不合法则 422
2. 确定 note type
- 若 task_id 关联的任务 task_type='follow_up_visit' → type='follow_up'
- 否则 → type='normal'
3. INSERT INTO biz.notes
4. 若 type='follow_up'
- 保留 AI 占位调用P5 接入时调用链不变),返回值仅更新 ai_score
- 不论 ai_score 如何,有备注即标记关联 active 回访任务 completed
5. 返回创建的备注记录
注意:星星评分不参与回访完成判定,不参与 AI 分析,仅存储。
"""
# 验证评分范围
for label, val in [
("再次服务意愿评分", rating_service_willingness),
("再来店可能性评分", rating_revisit_likelihood),
("备注星星评分", score),
]:
if val is not None and (val < 1 or val > 5):
raise HTTPException(
status_code=422, detail=f"{label}必须在 1-5 范围内"
)
conn = _get_connection()
try:
# 确定 note type
note_type = "normal"
task_info = None
if task_id is not None:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, task_type, status, site_id
FROM biz.coach_tasks
WHERE id = %s
""",
(task_id,),
)
row = cur.fetchone()
if row is None:
raise HTTPException(status_code=404, detail="关联任务不存在")
task_info = {
"id": row[0],
"task_type": row[1],
"status": row[2],
"site_id": row[3],
}
if task_info["site_id"] != site_id:
raise HTTPException(status_code=403, detail="权限不足")
if task_info["task_type"] == "follow_up_visit":
note_type = "follow_up"
# INSERT 备注
with conn.cursor() as cur:
cur.execute("BEGIN")
cur.execute(
"""
INSERT INTO biz.notes
(site_id, user_id, target_type, target_id, type,
content, rating_service_willingness,
rating_revisit_likelihood, task_id, score)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id, site_id, user_id, target_type, target_id,
type, content, rating_service_willingness,
rating_revisit_likelihood, task_id,
ai_score, ai_analysis, created_at, updated_at, score
""",
(
site_id, user_id, target_type, target_id, note_type,
content, rating_service_willingness,
rating_revisit_likelihood, task_id, score,
),
)
row = cur.fetchone()
note = {
"id": row[0],
"site_id": row[1],
"user_id": row[2],
"target_type": row[3],
"target_id": row[4],
"type": row[5],
"content": row[6],
"rating_service_willingness": row[7],
"rating_revisit_likelihood": row[8],
"task_id": row[9],
"ai_score": row[10],
"ai_analysis": row[11],
"created_at": row[12].isoformat() if row[12] else None,
"updated_at": row[13].isoformat() if row[13] else None,
"score": row[14],
}
# 若 type='follow_up',标记回访任务完成(不依赖 AI 评分)
if note_type == "follow_up" and task_id is not None:
if task_info and task_info["status"] == "active":
cur.execute(
"""
UPDATE biz.coach_tasks
SET status = 'completed',
completed_at = NOW(),
completed_task_type = task_type,
updated_at = NOW()
WHERE id = %s AND status = 'active'
""",
(task_id,),
)
_record_history(
cur,
task_id,
action="completed_by_note",
old_status="active",
new_status="completed",
old_task_type=task_info["task_type"],
new_task_type=task_info["task_type"],
detail={"note_id": note["id"]},
)
conn.commit()
# CHANGE 2026-03-27 | AI 评分:后台异步执行,不阻塞 API 响应
# 备注先返回给前端aiScore=nullAI 评分完成后写入数据库
# 前端下次加载页面时自动获取最新 aiScore
import asyncio
asyncio.create_task(_async_ai_score(note["id"], site_id, target_id, content))
return note
except HTTPException:
conn.rollback()
raise
except Exception:
conn.rollback()
raise
finally:
conn.close()
@trace_service("查询备注列表", "Get notes")
async def get_notes(
site_id: int, target_type: str, target_id: int
) -> list[dict]:
"""
查询某目标的备注列表。
按 created_at DESC 排序,包含星星评分和 AI 评分。
"""
conn = _get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, site_id, user_id, target_type, target_id,
type, content, rating_service_willingness,
rating_revisit_likelihood, task_id,
ai_score, ai_analysis, created_at, updated_at
FROM biz.notes
WHERE site_id = %s
AND target_type = %s
AND target_id = %s
ORDER BY created_at DESC
""",
(site_id, target_type, target_id),
)
rows = cur.fetchall()
conn.commit()
return [
{
"id": r[0],
"site_id": r[1],
"user_id": r[2],
"target_type": r[3],
"target_id": r[4],
"type": r[5],
"content": r[6],
"rating_service_willingness": r[7],
"rating_revisit_likelihood": r[8],
"task_id": r[9],
"ai_score": r[10],
"ai_analysis": r[11],
"created_at": r[12].isoformat() if r[12] else None,
"updated_at": r[13].isoformat() if r[13] else None,
}
for r in rows
]
finally:
conn.close()
@trace_service("删除备注", "Delete note")
async def delete_note(note_id: int, user_id: int, site_id: int) -> dict:
"""
删除备注。
验证备注归属user_id + site_id后执行硬删除。
- 不存在 → 404
- 不属于当前用户 → 403
"""
conn = _get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, user_id, site_id
FROM biz.notes
WHERE id = %s
""",
(note_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="资源不存在")
if row[2] != site_id or row[1] != user_id:
raise HTTPException(status_code=403, detail="权限不足")
with conn.cursor() as cur:
cur.execute("BEGIN")
cur.execute(
"DELETE FROM biz.notes WHERE id = %s",
(note_id,),
)
conn.commit()
return {"id": note_id, "deleted": True}
except HTTPException:
conn.rollback()
raise
except Exception:
conn.rollback()
raise
finally:
conn.close()