""" 任务管理服务 负责任务 CRUD、置顶、放弃、取消放弃等操作。 通过 FDW 读取客户信息和 RS 指数,计算爱心 icon 档位。 """ import json import logging from decimal import Decimal from fastapi import HTTPException from app.services.task_generator import compute_heart_icon 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 中记录变更。""" 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 _get_assistant_id(conn, user_id: int, site_id: int) -> int: """ 通过 user_assistant_binding 获取 assistant_id。 找不到绑定关系时抛出 403。 """ with conn.cursor() as cur: cur.execute( """ SELECT assistant_id FROM auth.user_assistant_binding WHERE user_id = %s AND site_id = %s AND assistant_id IS NOT NULL LIMIT 1 """, (user_id, site_id), ) row = cur.fetchone() if not row: raise HTTPException(status_code=403, detail="权限不足") return row[0] def _verify_task_ownership( conn, task_id: int, assistant_id: int, site_id: int, required_status: str | None = None ) -> dict: """ 验证任务归属并返回任务信息。 - 任务不存在 → 404 - 不属于当前助教 → 403 - required_status 不匹配 → 409 """ with conn.cursor() as cur: cur.execute( """ SELECT id, task_type, status, is_pinned, abandon_reason, assistant_id, site_id FROM biz.coach_tasks WHERE id = %s """, (task_id,), ) row = cur.fetchone() if not row: raise HTTPException(status_code=404, detail="资源不存在") task = { "id": row[0], "task_type": row[1], "status": row[2], "is_pinned": row[3], "abandon_reason": row[4], "assistant_id": row[5], "site_id": row[6], } if task["site_id"] != site_id or task["assistant_id"] != assistant_id: raise HTTPException(status_code=403, detail="权限不足") if required_status and task["status"] != required_status: raise HTTPException(status_code=409, detail="任务状态不允许此操作") return task async def get_task_list(user_id: int, site_id: int) -> list[dict]: """ 获取助教的活跃任务列表。 1. 通过 auth.user_assistant_binding 获取 assistant_id 2. 查询 biz.coach_tasks WHERE status='active' 3. 通过 FDW 读取客户基本信息(dim_member)和 RS 指数 4. 计算爱心 icon 档位 5. 排序:is_pinned DESC, priority_score DESC, created_at ASC FDW 查询需要 SET LOCAL app.current_site_id。 """ conn = _get_connection() try: assistant_id = _get_assistant_id(conn, user_id, site_id) # 查询活跃任务 with conn.cursor() as cur: cur.execute( """ SELECT id, task_type, status, priority_score, is_pinned, expires_at, created_at, member_id FROM biz.coach_tasks WHERE site_id = %s AND assistant_id = %s AND status = 'active' ORDER BY is_pinned DESC, priority_score DESC NULLS LAST, created_at ASC """, (site_id, assistant_id), ) tasks = cur.fetchall() conn.commit() if not tasks: return [] member_ids = list({t[7] for t in tasks}) # 通过 FDW 读取客户信息和 RS 指数(需要 SET LOCAL app.current_site_id) member_info_map: dict[int, dict] = {} rs_map: dict[int, Decimal] = {} with conn.cursor() as cur: cur.execute("BEGIN") cur.execute( "SET LOCAL app.current_site_id = %s", (str(site_id),) ) # 读取客户基本信息 cur.execute( """ SELECT member_id, member_name, member_phone FROM fdw_etl.v_dim_member WHERE member_id = ANY(%s) """, (member_ids,), ) for row in cur.fetchall(): member_info_map[row[0]] = { "member_name": row[1], "member_phone": row[2], } # 读取 RS 指数 cur.execute( """ SELECT member_id, COALESCE(rs_display, 0) FROM fdw_etl.v_dws_member_assistant_relation_index WHERE assistant_id = %s AND member_id = ANY(%s) """, (assistant_id, member_ids), ) for row in cur.fetchall(): rs_map[row[0]] = Decimal(str(row[1])) conn.commit() # 组装结果 result = [] for task_row in tasks: (task_id, task_type, status, priority_score, is_pinned, expires_at, created_at, member_id) = task_row info = member_info_map.get(member_id, {}) rs_score = rs_map.get(member_id, Decimal("0")) heart_icon = compute_heart_icon(rs_score) result.append({ "id": task_id, "task_type": task_type, "status": status, "priority_score": float(priority_score) if priority_score else None, "is_pinned": is_pinned, "expires_at": expires_at.isoformat() if expires_at else None, "created_at": created_at.isoformat() if created_at else None, "member_id": member_id, "member_name": info.get("member_name"), "member_phone": info.get("member_phone"), "rs_score": float(rs_score), "heart_icon": heart_icon, }) return result finally: conn.close() async def pin_task(task_id: int, user_id: int, site_id: int) -> dict: """ 置顶任务。 验证任务归属后设置 is_pinned=TRUE,记录 history。 """ conn = _get_connection() try: assistant_id = _get_assistant_id(conn, user_id, site_id) task = _verify_task_ownership( conn, task_id, assistant_id, site_id, required_status="active" ) with conn.cursor() as cur: cur.execute("BEGIN") cur.execute( """ UPDATE biz.coach_tasks SET is_pinned = TRUE, updated_at = NOW() WHERE id = %s """, (task_id,), ) _record_history( cur, task_id, action="pin", old_status="active", new_status="active", old_task_type=task["task_type"], new_task_type=task["task_type"], detail={"is_pinned": True}, ) conn.commit() return {"id": task_id, "is_pinned": True} finally: conn.close() async def unpin_task(task_id: int, user_id: int, site_id: int) -> dict: """ 取消置顶。 验证任务归属后设置 is_pinned=FALSE。 """ conn = _get_connection() try: assistant_id = _get_assistant_id(conn, user_id, site_id) task = _verify_task_ownership( conn, task_id, assistant_id, site_id, required_status="active" ) with conn.cursor() as cur: cur.execute("BEGIN") cur.execute( """ UPDATE biz.coach_tasks SET is_pinned = FALSE, updated_at = NOW() WHERE id = %s """, (task_id,), ) conn.commit() return {"id": task_id, "is_pinned": False} finally: conn.close() async def abandon_task( task_id: int, user_id: int, site_id: int, reason: str ) -> dict: """ 放弃任务。 1. 验证 reason 非空(空或纯空白 → 422) 2. 验证任务归属和 status='active' 3. 设置 status='abandoned', abandon_reason=reason 4. 记录 coach_task_history """ if not reason or not reason.strip(): raise HTTPException(status_code=422, detail="放弃原因不能为空") conn = _get_connection() try: assistant_id = _get_assistant_id(conn, user_id, site_id) task = _verify_task_ownership( conn, task_id, assistant_id, site_id, required_status="active" ) with conn.cursor() as cur: cur.execute("BEGIN") cur.execute( """ UPDATE biz.coach_tasks SET status = 'abandoned', abandon_reason = %s, updated_at = NOW() WHERE id = %s """, (reason, task_id), ) _record_history( cur, task_id, action="abandon", old_status="active", new_status="abandoned", old_task_type=task["task_type"], new_task_type=task["task_type"], detail={"abandon_reason": reason}, ) conn.commit() return {"id": task_id, "status": "abandoned"} finally: conn.close() async def cancel_abandon(task_id: int, user_id: int, site_id: int) -> dict: """ 取消放弃。 1. 验证任务归属和 status='abandoned' 2. 恢复 status='active', 清空 abandon_reason 3. 记录 coach_task_history """ conn = _get_connection() try: assistant_id = _get_assistant_id(conn, user_id, site_id) task = _verify_task_ownership( conn, task_id, assistant_id, site_id, required_status="abandoned" ) with conn.cursor() as cur: cur.execute("BEGIN") cur.execute( """ UPDATE biz.coach_tasks SET status = 'active', abandon_reason = NULL, updated_at = NOW() WHERE id = %s """, (task_id,), ) _record_history( cur, task_id, action="cancel_abandon", old_status="abandoned", new_status="active", old_task_type=task["task_type"], new_task_type=task["task_type"], ) conn.commit() return {"id": task_id, "status": "active"} finally: conn.close()