327 lines
10 KiB
Python
327 lines
10 KiB
Python
"""
|
||
备注服务
|
||
|
||
负责备注 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 应用 6 分析(P5 实现)
|
||
- 若 ai_score >= 6 且关联任务 status='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_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 >= 6 且关联任务 status='active' → 标记任务 completed
|
||
if ai_score >= 6 and 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()
|