""" 备注服务 负责备注 CRUD、星星评分存储与读取。 备注类型根据关联任务自动确定:follow_up_visit 任务 → follow_up,否则 normal。 星星评分不参与回访完成判定,不参与 AI 分析,仅存储。 """ import json import logging from fastapi import HTTPException 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, ), ) def ai_analyze_note(note_id: int) -> int | None: """ AI 应用 6 备注分析接口(占位)。 P5 AI 集成层实现后替换此占位函数。 当前返回 None 表示 AI 未就绪,跳过评分逻辑。 """ return None 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, ) -> 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), ]: 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) VALUES (%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 """, ( site_id, user_id, target_type, target_id, note_type, content, rating_service_willingness, rating_revisit_likelihood, task_id, ), ) 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, } # 若 type='follow_up',触发 AI 分析并标记回访任务完成 if note_type == "follow_up" and task_id is not None: # 保留 AI 占位调用(P5 接入时调用链不变) ai_score = ai_analyze_note(note["id"]) if ai_score is not None: # 更新备注的 ai_score cur.execute( """ UPDATE biz.notes SET ai_score = %s, updated_at = NOW() WHERE id = %s """, (ai_score, note["id"]), ) note["ai_score"] = ai_score # 不论 ai_score 如何,有备注即标记回访任务完成(T4) 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"], "ai_score": ai_score, }, ) conn.commit() return note except HTTPException: conn.rollback() raise except Exception: conn.rollback() raise finally: conn.close() 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() 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()