包含多个会话的累积代码变更: - 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>
373 lines
12 KiB
Python
373 lines
12 KiB
Python
"""
|
||
备注服务
|
||
|
||
负责备注 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 线索整合(后续统一处理)。
|
||
返回 score(1-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=null),AI 评分完成后写入数据库
|
||
# 前端下次加载页面时自动获取最新 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()
|