Files
Neo-ZQYY/apps/backend/app/services/note_service.py

327 lines
10 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
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()