微信小程序页面迁移校验之前 P5任务处理之前
This commit is contained in:
@@ -98,6 +98,15 @@ async def create_application(
|
||||
(nickname, user_id),
|
||||
)
|
||||
|
||||
# 5. 更新用户状态为 pending(new → pending)
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE auth.users SET status = 'pending', updated_at = NOW()
|
||||
WHERE id = %s AND status IN ('new', 'rejected')
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -124,6 +124,16 @@ class CLIBuilder:
|
||||
if config.store_id is not None:
|
||||
cmd.extend(["--store-id", str(config.store_id)])
|
||||
|
||||
# -- Pipeline 调优参数 --
|
||||
if config.pipeline_workers is not None:
|
||||
cmd.extend(["--pipeline-workers", str(config.pipeline_workers)])
|
||||
if config.pipeline_batch_size is not None:
|
||||
cmd.extend(["--pipeline-batch-size", str(config.pipeline_batch_size)])
|
||||
if config.pipeline_rate_min is not None:
|
||||
cmd.extend(["--pipeline-rate-min", str(config.pipeline_rate_min)])
|
||||
if config.pipeline_rate_max is not None:
|
||||
cmd.extend(["--pipeline-rate-max", str(config.pipeline_rate_max)])
|
||||
|
||||
# -- 额外参数(只传递 CLI 支持的参数) --
|
||||
for key, value in config.extra_args.items():
|
||||
if value is not None and key in CLI_SUPPORTED_ARGS:
|
||||
|
||||
217
apps/backend/app/services/note_reclassifier.py
Normal file
217
apps/backend/app/services/note_reclassifier.py
Normal file
@@ -0,0 +1,217 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
备注回溯重分类器(Note Reclassifier)
|
||||
|
||||
召回完成后,回溯检查是否有普通备注需重分类为回访备注。
|
||||
查找 service_time 之后的第一条 normal 备注 → 更新为 follow_up →
|
||||
触发 AI 应用 6 接口(占位)→ 根据 ai_score 生成 follow_up_visit 任务。
|
||||
|
||||
由 trigger_jobs 中的 note_reclassify_backfill 配置驱动(event: recall_completed)。
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_connection():
|
||||
"""延迟导入 get_connection,避免模块级导入失败。"""
|
||||
from app.database import get_connection
|
||||
|
||||
return get_connection()
|
||||
|
||||
|
||||
def _insert_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 ai_analyze_note(note_id: int) -> int | None:
|
||||
"""
|
||||
AI 应用 6 备注分析接口(占位)。
|
||||
|
||||
P5 AI 集成层实现后替换此占位函数。
|
||||
当前返回 None 表示 AI 未就绪,跳过评分逻辑。
|
||||
"""
|
||||
return None
|
||||
|
||||
|
||||
def run(payload: dict | None = None) -> dict:
|
||||
"""
|
||||
备注回溯主流程。
|
||||
|
||||
payload 包含: {site_id, assistant_id, member_id, service_time}
|
||||
|
||||
1. 查找 biz.notes 中该 (site_id, target_type='member', target_id=member_id)
|
||||
在 service_time 之后提交的第一条 type='normal' 的备注
|
||||
2. 将该备注 type 从 'normal' 更新为 'follow_up'
|
||||
3. 触发 AI 应用 6 接口(P5 实现,本 SPEC 仅定义触发接口):
|
||||
- 调用 ai_analyze_note(note_id) → 返回 ai_score
|
||||
4. 若 ai_score >= 6:
|
||||
- 生成 follow_up_visit 任务,status='completed'(回溯完成)
|
||||
5. 若 ai_score < 6:
|
||||
- 生成 follow_up_visit 任务,status='active'(需助教重新备注)
|
||||
|
||||
返回: {"reclassified_count": int, "tasks_created": int}
|
||||
"""
|
||||
if not payload:
|
||||
return {"reclassified_count": 0, "tasks_created": 0}
|
||||
|
||||
site_id = payload.get("site_id")
|
||||
assistant_id = payload.get("assistant_id")
|
||||
member_id = payload.get("member_id")
|
||||
service_time = payload.get("service_time")
|
||||
|
||||
if not all([site_id, assistant_id, member_id, service_time]):
|
||||
logger.warning("备注回溯缺少必要参数: %s", payload)
|
||||
return {"reclassified_count": 0, "tasks_created": 0}
|
||||
|
||||
reclassified_count = 0
|
||||
tasks_created = 0
|
||||
|
||||
conn = _get_connection()
|
||||
try:
|
||||
# ── 1. 查找 service_time 之后的第一条 normal 备注 ──
|
||||
note_id = None
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id
|
||||
FROM biz.notes
|
||||
WHERE site_id = %s
|
||||
AND target_type = 'member'
|
||||
AND target_id = %s
|
||||
AND type = 'normal'
|
||||
AND created_at > %s
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
(site_id, member_id, service_time),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
note_id = row[0]
|
||||
conn.commit()
|
||||
|
||||
if note_id is None:
|
||||
logger.info(
|
||||
"未找到符合条件的 normal 备注: site_id=%s, member_id=%s",
|
||||
site_id, member_id,
|
||||
)
|
||||
return {"reclassified_count": 0, "tasks_created": 0}
|
||||
|
||||
# ── 2. 将备注 type 从 'normal' 更新为 'follow_up' ──
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.notes
|
||||
SET type = 'follow_up', updated_at = NOW()
|
||||
WHERE id = %s AND type = 'normal'
|
||||
""",
|
||||
(note_id,),
|
||||
)
|
||||
conn.commit()
|
||||
reclassified_count = 1
|
||||
|
||||
# ── 3. 触发 AI 应用 6 接口(占位,当前返回 None) ──
|
||||
ai_score = ai_analyze_note(note_id)
|
||||
|
||||
# ── 4/5. 根据 ai_score 生成 follow_up_visit 任务 ──
|
||||
if ai_score is not None:
|
||||
if ai_score >= 6:
|
||||
# 回溯完成:生成 completed 任务
|
||||
task_status = "completed"
|
||||
else:
|
||||
# 需助教重新备注:生成 active 任务
|
||||
task_status = "active"
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.coach_tasks
|
||||
(site_id, assistant_id, member_id, task_type,
|
||||
status, completed_at, completed_task_type)
|
||||
VALUES (
|
||||
%s, %s, %s, 'follow_up_visit',
|
||||
%s,
|
||||
CASE WHEN %s = 'completed' THEN NOW() ELSE NULL END,
|
||||
CASE WHEN %s = 'completed' THEN 'follow_up_visit' ELSE NULL END
|
||||
)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
site_id, assistant_id, member_id,
|
||||
task_status, task_status, task_status,
|
||||
),
|
||||
)
|
||||
new_task_row = cur.fetchone()
|
||||
new_task_id = new_task_row[0]
|
||||
|
||||
# 记录任务创建历史
|
||||
_insert_history(
|
||||
cur,
|
||||
new_task_id,
|
||||
action="created_by_reclassify",
|
||||
old_status=None,
|
||||
new_status=task_status,
|
||||
old_task_type=None,
|
||||
new_task_type="follow_up_visit",
|
||||
detail={
|
||||
"note_id": note_id,
|
||||
"ai_score": ai_score,
|
||||
"source": "note_reclassifier",
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
tasks_created = 1
|
||||
else:
|
||||
# AI 未就绪,跳过任务创建
|
||||
logger.info(
|
||||
"AI 接口未就绪,跳过任务创建: note_id=%s", note_id
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"备注回溯失败: site_id=%s, member_id=%s",
|
||||
site_id, member_id,
|
||||
)
|
||||
conn.rollback()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
logger.info(
|
||||
"备注回溯完成: reclassified_count=%d, tasks_created=%d",
|
||||
reclassified_count, tasks_created,
|
||||
)
|
||||
return {
|
||||
"reclassified_count": reclassified_count,
|
||||
"tasks_created": tasks_created,
|
||||
}
|
||||
326
apps/backend/app/services/note_service.py
Normal file
326
apps/backend/app/services/note_service.py
Normal file
@@ -0,0 +1,326 @@
|
||||
"""
|
||||
备注服务
|
||||
|
||||
负责备注 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()
|
||||
266
apps/backend/app/services/recall_detector.py
Normal file
266
apps/backend/app/services/recall_detector.py
Normal file
@@ -0,0 +1,266 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
召回完成检测器(Recall Completion Detector)
|
||||
|
||||
ETL 数据更新后,通过 FDW 读取助教服务记录,
|
||||
匹配活跃任务标记为 completed,记录 completed_at 和 completed_task_type 快照,
|
||||
触发 recall_completed 事件通知备注回溯重分类器。
|
||||
|
||||
由 trigger_jobs 中的 recall_completion_check 配置驱动(event: etl_data_updated)。
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_connection():
|
||||
"""延迟导入 get_connection,避免模块级导入失败。"""
|
||||
from app.database import get_connection
|
||||
|
||||
return get_connection()
|
||||
|
||||
|
||||
def _insert_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 run(payload: dict | None = None) -> dict:
|
||||
"""
|
||||
召回完成检测主流程。
|
||||
|
||||
1. 从 trigger_jobs 读取 last_run_at 作为增量过滤基准
|
||||
2. 获取所有 distinct site_id(从 active 任务中)
|
||||
3. 对每个 site_id,SET LOCAL app.current_site_id 后
|
||||
通过 FDW 读取 v_dwd_assistant_service_log 中 service_time > last_run_at 的新增服务记录
|
||||
4. 对每条服务记录,查找 biz.coach_tasks 中匹配的
|
||||
(site_id, assistant_id, member_id) 且 status='active' 的任务
|
||||
5. 将匹配任务标记为 completed:
|
||||
- status = 'completed'
|
||||
- completed_at = 服务时间
|
||||
- completed_task_type = 当前 task_type(快照)
|
||||
6. 记录 coach_task_history
|
||||
7. 触发 fire_event('recall_completed', {site_id, assistant_id, member_id, service_time})
|
||||
|
||||
返回: {"completed_count": int}
|
||||
"""
|
||||
completed_count = 0
|
||||
|
||||
conn = _get_connection()
|
||||
try:
|
||||
# ── 1. 读取 last_run_at ──
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT last_run_at
|
||||
FROM biz.trigger_jobs
|
||||
WHERE job_name = 'recall_completion_check'
|
||||
"""
|
||||
)
|
||||
row = cur.fetchone()
|
||||
last_run_at = row[0] if row else None
|
||||
conn.commit()
|
||||
|
||||
# ── 2. 获取所有有 active 任务的 distinct site_id ──
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT DISTINCT site_id
|
||||
FROM biz.coach_tasks
|
||||
WHERE status = 'active'
|
||||
"""
|
||||
)
|
||||
site_ids = [r[0] for r in cur.fetchall()]
|
||||
conn.commit()
|
||||
|
||||
# ── 3. 逐 site_id 读取新增服务记录 ──
|
||||
for site_id in site_ids:
|
||||
try:
|
||||
count = _process_site(conn, site_id, last_run_at)
|
||||
completed_count += count
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"处理门店召回检测失败: site_id=%s", site_id
|
||||
)
|
||||
conn.rollback()
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
logger.info("召回完成检测完成: completed_count=%d", completed_count)
|
||||
return {"completed_count": completed_count}
|
||||
|
||||
|
||||
def _process_site(conn, site_id: int, last_run_at) -> int:
|
||||
"""
|
||||
处理单个门店的召回完成检测。
|
||||
|
||||
通过 FDW 读取新增服务记录,匹配 active 任务并标记 completed。
|
||||
返回本门店完成的任务数。
|
||||
"""
|
||||
completed = 0
|
||||
|
||||
# 通过 FDW 读取新增服务记录(需要 SET LOCAL 启用 RLS)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"SET LOCAL app.current_site_id = %s", (str(site_id),)
|
||||
)
|
||||
|
||||
if last_run_at is not None:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT DISTINCT assistant_id, member_id, service_time
|
||||
FROM fdw_etl.v_dwd_assistant_service_log
|
||||
WHERE service_time > %s
|
||||
ORDER BY service_time ASC
|
||||
""",
|
||||
(last_run_at,),
|
||||
)
|
||||
else:
|
||||
# 首次运行,读取所有服务记录
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT DISTINCT assistant_id, member_id, service_time
|
||||
FROM fdw_etl.v_dwd_assistant_service_log
|
||||
ORDER BY service_time ASC
|
||||
"""
|
||||
)
|
||||
service_records = cur.fetchall()
|
||||
conn.commit()
|
||||
|
||||
# ── 4-7. 逐条服务记录匹配并处理 ──
|
||||
for assistant_id, member_id, service_time in service_records:
|
||||
try:
|
||||
count = _process_service_record(
|
||||
conn, site_id, assistant_id, member_id, service_time
|
||||
)
|
||||
completed += count
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"处理服务记录失败: site_id=%s, assistant_id=%s, member_id=%s",
|
||||
site_id,
|
||||
assistant_id,
|
||||
member_id,
|
||||
)
|
||||
conn.rollback()
|
||||
|
||||
return completed
|
||||
|
||||
|
||||
def _process_service_record(
|
||||
conn,
|
||||
site_id: int,
|
||||
assistant_id: int,
|
||||
member_id: int,
|
||||
service_time,
|
||||
) -> int:
|
||||
"""
|
||||
处理单条服务记录:匹配 active 任务并标记 completed。
|
||||
|
||||
每条服务记录独立事务,失败不影响其他。
|
||||
返回本次完成的任务数。
|
||||
"""
|
||||
completed = 0
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
|
||||
# 查找匹配的 active 任务
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, task_type
|
||||
FROM biz.coach_tasks
|
||||
WHERE site_id = %s
|
||||
AND assistant_id = %s
|
||||
AND member_id = %s
|
||||
AND status = 'active'
|
||||
""",
|
||||
(site_id, assistant_id, member_id),
|
||||
)
|
||||
active_tasks = cur.fetchall()
|
||||
|
||||
if not active_tasks:
|
||||
conn.commit()
|
||||
return 0
|
||||
|
||||
# 将所有匹配的 active 任务标记为 completed
|
||||
for task_id, task_type in active_tasks:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.coach_tasks
|
||||
SET status = 'completed',
|
||||
completed_at = %s,
|
||||
completed_task_type = %s,
|
||||
updated_at = NOW()
|
||||
WHERE id = %s AND status = 'active'
|
||||
""",
|
||||
(service_time, task_type, task_id),
|
||||
)
|
||||
_insert_history(
|
||||
cur,
|
||||
task_id,
|
||||
action="completed",
|
||||
old_status="active",
|
||||
new_status="completed",
|
||||
old_task_type=task_type,
|
||||
new_task_type=task_type,
|
||||
detail={
|
||||
"service_time": str(service_time),
|
||||
"completed_task_type": task_type,
|
||||
},
|
||||
)
|
||||
completed += 1
|
||||
|
||||
conn.commit()
|
||||
|
||||
# ── 7. 触发 recall_completed 事件 ──
|
||||
# 延迟导入 fire_event 避免循环依赖
|
||||
try:
|
||||
from app.services.trigger_scheduler import fire_event
|
||||
|
||||
fire_event(
|
||||
"recall_completed",
|
||||
{
|
||||
"site_id": site_id,
|
||||
"assistant_id": assistant_id,
|
||||
"member_id": member_id,
|
||||
"service_time": str(service_time),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"触发 recall_completed 事件失败: site_id=%s, assistant_id=%s, member_id=%s",
|
||||
site_id,
|
||||
assistant_id,
|
||||
member_id,
|
||||
)
|
||||
|
||||
return completed
|
||||
@@ -213,7 +213,7 @@ class Scheduler:
|
||||
|
||||
# 入队
|
||||
try:
|
||||
queue_id = task_queue.enqueue(config, site_id)
|
||||
queue_id = task_queue.enqueue(config, site_id, schedule_id=task_id)
|
||||
logger.info(
|
||||
"调度任务 [%s] 入队成功 → queue_id=%s site_id=%s",
|
||||
task_id, queue_id, site_id,
|
||||
|
||||
@@ -17,19 +17,24 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from ..config import ETL_PROJECT_PATH
|
||||
# CHANGE 2026-03-07 | 只保留模块引用,execute() 中实时读取属性值
|
||||
# 禁止 from ..config import ETL_PROJECT_PATH(值拷贝,reload 后过期)
|
||||
from .. import config as _config_module
|
||||
from ..database import get_connection
|
||||
from ..schemas.tasks import TaskConfigSchema
|
||||
from ..services.cli_builder import cli_builder
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 实例标识:用于区分多后端实例写入同一 DB 的记录
|
||||
import platform as _platform
|
||||
_INSTANCE_HOST = _platform.node() # hostname
|
||||
|
||||
|
||||
class TaskExecutor:
|
||||
"""管理 ETL CLI 子进程的生命周期"""
|
||||
@@ -112,21 +117,58 @@ class TaskExecutor:
|
||||
execution_id: str,
|
||||
queue_id: str | None = None,
|
||||
site_id: int | None = None,
|
||||
schedule_id: str | None = None,
|
||||
) -> None:
|
||||
"""以子进程方式调用 ETL CLI。
|
||||
|
||||
使用 subprocess.Popen + 线程读取,兼容 Windows(避免
|
||||
asyncio.create_subprocess_exec 在 Windows 上的 NotImplementedError)。
|
||||
"""
|
||||
# CHANGE 2026-03-07 | 实时从 config 模块读取,避免 import 时复制的值过期
|
||||
etl_path = _config_module.ETL_PROJECT_PATH
|
||||
etl_python = _config_module.ETL_PYTHON_EXECUTABLE
|
||||
|
||||
cmd = cli_builder.build_command(
|
||||
config, ETL_PROJECT_PATH, python_executable=sys.executable
|
||||
config, etl_path, python_executable=etl_python
|
||||
)
|
||||
command_str = " ".join(cmd)
|
||||
|
||||
# CHANGE 2026-03-07 | 运行时防护:拒绝执行包含非预期路径的命令
|
||||
# 检测两种异常:
|
||||
# 1. D 盘路径(junction 穿透)
|
||||
# 2. 多环境子目录(test/repo、prod/repo)
|
||||
_cmd_normalized = command_str.replace("/", "\\")
|
||||
_bad_patterns = []
|
||||
if "D:\\" in command_str or "D:/" in command_str:
|
||||
_bad_patterns.append("D盘路径")
|
||||
if "\\test\\repo" in _cmd_normalized or "\\prod\\repo" in _cmd_normalized:
|
||||
_bad_patterns.append("多环境子目录(test/repo或prod/repo)")
|
||||
|
||||
if _bad_patterns:
|
||||
_issues = " + ".join(_bad_patterns)
|
||||
logger.error(
|
||||
"路径防护触发:命令包含 %s,拒绝执行。"
|
||||
" command=%s | ETL_PY=%s | ETL_PATH=%s"
|
||||
" | NEOZQYY_ROOT=%s | config.__file__=%s",
|
||||
_issues, command_str, etl_python, etl_path,
|
||||
__import__('os').environ.get("NEOZQYY_ROOT", "<未设置>"),
|
||||
_config_module.__file__,
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"ETL 命令包含异常路径({_issues}),拒绝执行。"
|
||||
f" 请检查 .env 中 ETL_PYTHON_EXECUTABLE 和 ETL_PROJECT_PATH 配置。"
|
||||
f" 当前值: ETL_PY={etl_python}, ETL_PATH={etl_path}"
|
||||
)
|
||||
|
||||
effective_site_id = site_id or config.store_id
|
||||
|
||||
# CHANGE 2026-03-07 | 在 command 前缀中注入实例标识,
|
||||
# 便于在多后端实例共享同一 DB 时区分记录来源
|
||||
command_str_with_host = f"[{_INSTANCE_HOST}] {command_str}"
|
||||
|
||||
logger.info(
|
||||
"启动 ETL 子进程 [%s]: %s (cwd=%s)",
|
||||
execution_id, command_str, ETL_PROJECT_PATH,
|
||||
execution_id, command_str, etl_path,
|
||||
)
|
||||
|
||||
self._log_buffers[execution_id] = []
|
||||
@@ -140,7 +182,8 @@ class TaskExecutor:
|
||||
task_codes=config.tasks,
|
||||
status="running",
|
||||
started_at=started_at,
|
||||
command=command_str,
|
||||
command=command_str_with_host,
|
||||
schedule_id=schedule_id,
|
||||
)
|
||||
|
||||
exit_code: int | None = None
|
||||
@@ -226,7 +269,7 @@ class TaskExecutor:
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
cwd=ETL_PROJECT_PATH,
|
||||
cwd=_config_module.ETL_PROJECT_PATH,
|
||||
env=env,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
@@ -302,18 +345,30 @@ class TaskExecutor:
|
||||
status: str,
|
||||
started_at: datetime,
|
||||
command: str,
|
||||
schedule_id: str | None = None,
|
||||
) -> None:
|
||||
"""插入一条执行日志记录(running 状态)。"""
|
||||
try:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 如果调用方未传 schedule_id,尝试从 task_queue 回查
|
||||
effective_schedule_id = schedule_id
|
||||
if effective_schedule_id is None and queue_id is not None:
|
||||
cur.execute(
|
||||
"SELECT schedule_id FROM task_queue WHERE id = %s",
|
||||
(queue_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row and row[0]:
|
||||
effective_schedule_id = str(row[0])
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO task_execution_log
|
||||
(id, queue_id, site_id, task_codes, status,
|
||||
started_at, command)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
started_at, command, schedule_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
execution_id,
|
||||
@@ -323,6 +378,7 @@ class TaskExecutor:
|
||||
status,
|
||||
started_at,
|
||||
command,
|
||||
effective_schedule_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
117
apps/backend/app/services/task_expiry.py
Normal file
117
apps/backend/app/services/task_expiry.py
Normal file
@@ -0,0 +1,117 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
有效期轮询器(Task Expiry Checker)
|
||||
|
||||
每小时运行一次,检查 expires_at 不为 NULL 且已过期的 active 任务,
|
||||
将其标记为 inactive 并记录 history。
|
||||
|
||||
由 trigger_jobs 中的 task_expiry_check 配置驱动。
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_connection():
|
||||
"""延迟导入 get_connection,避免模块级导入失败。"""
|
||||
from app.database import get_connection
|
||||
|
||||
return get_connection()
|
||||
|
||||
|
||||
def _insert_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 run() -> dict:
|
||||
"""
|
||||
有效期轮询主流程。
|
||||
|
||||
1. SELECT id, task_type FROM biz.coach_tasks
|
||||
WHERE expires_at IS NOT NULL AND expires_at < NOW() AND status = 'active'
|
||||
2. 逐条 UPDATE status = 'inactive'
|
||||
3. INSERT coach_task_history (action='expired')
|
||||
|
||||
每条过期任务独立事务,失败不影响其他。
|
||||
|
||||
返回: {"expired_count": int}
|
||||
"""
|
||||
expired_count = 0
|
||||
|
||||
conn = _get_connection()
|
||||
try:
|
||||
# 查询所有已过期的 active 任务
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, task_type
|
||||
FROM biz.coach_tasks
|
||||
WHERE expires_at IS NOT NULL
|
||||
AND expires_at < NOW()
|
||||
AND status = 'active'
|
||||
"""
|
||||
)
|
||||
expired_tasks = cur.fetchall()
|
||||
conn.commit()
|
||||
|
||||
# 逐条处理,每条独立事务
|
||||
for task_id, task_type in expired_tasks:
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.coach_tasks
|
||||
SET status = 'inactive', updated_at = NOW()
|
||||
WHERE id = %s AND status = 'active'
|
||||
""",
|
||||
(task_id,),
|
||||
)
|
||||
_insert_history(
|
||||
cur,
|
||||
task_id,
|
||||
action="expired",
|
||||
old_status="active",
|
||||
new_status="inactive",
|
||||
old_task_type=task_type,
|
||||
new_task_type=task_type,
|
||||
)
|
||||
conn.commit()
|
||||
expired_count += 1
|
||||
except Exception:
|
||||
logger.exception("处理过期任务失败: task_id=%s", task_id)
|
||||
conn.rollback()
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
logger.info("有效期轮询完成: expired_count=%d", expired_count)
|
||||
return {"expired_count": expired_count}
|
||||
483
apps/backend/app/services/task_generator.py
Normal file
483
apps/backend/app/services/task_generator.py
Normal file
@@ -0,0 +1,483 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
任务生成器(Task Generator)
|
||||
|
||||
每日 4:00 运行,基于 WBI/NCI/RS 指数为每个助教生成/更新任务。
|
||||
|
||||
本模块包含:
|
||||
- TaskPriority 枚举:任务类型优先级定义
|
||||
- TASK_TYPE_PRIORITY 映射:task_type 字符串 → 优先级
|
||||
- IndexData 数据类:客户-助教对的指数数据
|
||||
- determine_task_type():根据指数确定任务类型(纯函数)
|
||||
- should_replace_task():判断是否应替换现有任务(纯函数)
|
||||
- compute_heart_icon():根据 RS 指数计算爱心 icon 档位(纯函数)
|
||||
"""
|
||||
from decimal import Decimal
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class TaskPriority(IntEnum):
|
||||
"""任务类型优先级,数值越小优先级越高。"""
|
||||
|
||||
HIGH_PRIORITY_RECALL = 0
|
||||
PRIORITY_RECALL = 0
|
||||
FOLLOW_UP_VISIT = 1
|
||||
RELATIONSHIP_BUILDING = 2
|
||||
|
||||
|
||||
TASK_TYPE_PRIORITY: dict[str, TaskPriority] = {
|
||||
"high_priority_recall": TaskPriority.HIGH_PRIORITY_RECALL,
|
||||
"priority_recall": TaskPriority.PRIORITY_RECALL,
|
||||
"follow_up_visit": TaskPriority.FOLLOW_UP_VISIT,
|
||||
"relationship_building": TaskPriority.RELATIONSHIP_BUILDING,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class IndexData:
|
||||
"""某客户-助教对的指数数据。"""
|
||||
|
||||
site_id: int
|
||||
assistant_id: int
|
||||
member_id: int
|
||||
wbi: Decimal # 流失回赢指数
|
||||
nci: Decimal # 新客转化指数
|
||||
rs: Decimal # 关系强度指数
|
||||
has_active_recall: bool # 是否有活跃召回任务
|
||||
has_follow_up_note: bool # 召回完成后是否有回访备注
|
||||
|
||||
|
||||
def determine_task_type(index_data: IndexData) -> str | None:
|
||||
"""
|
||||
根据指数数据确定应生成的任务类型。
|
||||
|
||||
优先级规则(高 → 低):
|
||||
1. max(WBI, NCI) > 7 → high_priority_recall
|
||||
2. max(WBI, NCI) > 5 → priority_recall
|
||||
3. RS < 6 → relationship_building
|
||||
4. 不满足任何条件 → None(不生成任务)
|
||||
|
||||
返回: task_type 字符串或 None
|
||||
"""
|
||||
priority_score = max(index_data.wbi, index_data.nci)
|
||||
|
||||
if priority_score > 7:
|
||||
return "high_priority_recall"
|
||||
if priority_score > 5:
|
||||
return "priority_recall"
|
||||
if index_data.rs < 6:
|
||||
return "relationship_building"
|
||||
return None
|
||||
|
||||
|
||||
def should_replace_task(existing_type: str, new_type: str) -> bool:
|
||||
"""
|
||||
判断新任务类型是否应替换现有任务类型。
|
||||
|
||||
规则:类型不同即替换,相同类型不替换。
|
||||
"""
|
||||
if existing_type == new_type:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def compute_heart_icon(rs_score: Decimal) -> str:
|
||||
"""
|
||||
根据 RS 指数计算爱心 icon 档位。
|
||||
|
||||
档位规则:
|
||||
- RS > 8.5 → 💖
|
||||
- 7 < RS ≤ 8.5 → 🧡
|
||||
- 5 < RS ≤ 7 → 💛
|
||||
- RS ≤ 5 → 💙
|
||||
"""
|
||||
if rs_score > Decimal("8.5"):
|
||||
return "💖"
|
||||
if rs_score > Decimal("7"):
|
||||
return "🧡"
|
||||
if rs_score > Decimal("5"):
|
||||
return "💛"
|
||||
return "💙"
|
||||
|
||||
|
||||
# ── run() 主流程 ──────────────────────────────────────────────
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_connection():
|
||||
"""延迟导入 get_connection,避免纯函数测试时触发模块级导入失败。"""
|
||||
from app.database import get_connection
|
||||
|
||||
return get_connection()
|
||||
|
||||
|
||||
def run() -> dict:
|
||||
"""
|
||||
任务生成器主流程。
|
||||
|
||||
1. 通过 auth.user_assistant_binding 获取所有已绑定助教
|
||||
2. 对每个助教-客户对,通过 FDW 读取 WBI/NCI/RS 指数
|
||||
3. 调用 determine_task_type() 确定任务类型
|
||||
4. 检查已存在的 active 任务:相同 task_type → 跳过;
|
||||
不同 task_type → 关闭旧任务 + 创建新任务 + 记录 history
|
||||
5. 处理 follow_up_visit 的 48 小时滞留机制(expires_at 填充)
|
||||
6. 更新 trigger_jobs 时间戳
|
||||
|
||||
返回: {"created": int, "replaced": int, "skipped": int}
|
||||
"""
|
||||
stats = {"created": 0, "replaced": 0, "skipped": 0}
|
||||
|
||||
conn = _get_connection()
|
||||
try:
|
||||
# ── 1. 获取所有已绑定助教 ──
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT DISTINCT site_id, assistant_id
|
||||
FROM auth.user_assistant_binding
|
||||
WHERE assistant_id IS NOT NULL
|
||||
"""
|
||||
)
|
||||
bindings = cur.fetchall()
|
||||
conn.commit()
|
||||
|
||||
# ── 2. 逐助教处理 ──
|
||||
for site_id, assistant_id in bindings:
|
||||
try:
|
||||
_process_assistant(conn, site_id, assistant_id, stats)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"处理助教失败: site_id=%s, assistant_id=%s",
|
||||
site_id,
|
||||
assistant_id,
|
||||
)
|
||||
conn.rollback()
|
||||
|
||||
# ── 6. 更新 trigger_jobs 时间戳 ──
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.trigger_jobs
|
||||
SET last_run_at = NOW()
|
||||
WHERE job_name = 'task_generator'
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
logger.info(
|
||||
"任务生成器完成: created=%d, replaced=%d, skipped=%d",
|
||||
stats["created"],
|
||||
stats["replaced"],
|
||||
stats["skipped"],
|
||||
)
|
||||
return stats
|
||||
|
||||
|
||||
def _process_assistant(
|
||||
conn, site_id: int, assistant_id: int, stats: dict
|
||||
) -> None:
|
||||
"""处理单个助教下所有客户-助教对的任务生成。"""
|
||||
|
||||
# 通过 FDW 读取该助教关联的客户指数数据
|
||||
# 需要 SET LOCAL app.current_site_id 以启用 RLS
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"SET LOCAL app.current_site_id = %s", (str(site_id),)
|
||||
)
|
||||
|
||||
# 读取 WBI(流失回赢指数)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT member_id, COALESCE(display_score, 0)
|
||||
FROM fdw_etl.v_dws_member_winback_index
|
||||
"""
|
||||
)
|
||||
wbi_map = {row[0]: Decimal(str(row[1])) for row in cur.fetchall()}
|
||||
|
||||
# 读取 NCI(新客转化指数)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT member_id, COALESCE(display_score, 0)
|
||||
FROM fdw_etl.v_dws_member_newconv_index
|
||||
"""
|
||||
)
|
||||
nci_map = {row[0]: Decimal(str(row[1])) for row in cur.fetchall()}
|
||||
|
||||
# 读取 RS(关系强度指数)— 按 assistant_id 过滤
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT member_id, COALESCE(rs_display, 0)
|
||||
FROM fdw_etl.v_dws_member_assistant_relation_index
|
||||
WHERE assistant_id = %s
|
||||
""",
|
||||
(assistant_id,),
|
||||
)
|
||||
rs_map = {row[0]: Decimal(str(row[1])) for row in cur.fetchall()}
|
||||
|
||||
conn.commit()
|
||||
|
||||
# 合并所有涉及的 member_id
|
||||
all_member_ids = set(wbi_map.keys()) | set(nci_map.keys()) | set(rs_map.keys())
|
||||
|
||||
# 逐客户处理,每对独立事务
|
||||
for member_id in all_member_ids:
|
||||
try:
|
||||
_process_member_task(
|
||||
conn,
|
||||
site_id,
|
||||
assistant_id,
|
||||
member_id,
|
||||
wbi_map.get(member_id, Decimal("0")),
|
||||
nci_map.get(member_id, Decimal("0")),
|
||||
rs_map.get(member_id, Decimal("0")),
|
||||
stats,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"处理客户任务失败: site_id=%s, assistant_id=%s, member_id=%s",
|
||||
site_id,
|
||||
assistant_id,
|
||||
member_id,
|
||||
)
|
||||
conn.rollback()
|
||||
|
||||
|
||||
def _process_member_task(
|
||||
conn,
|
||||
site_id: int,
|
||||
assistant_id: int,
|
||||
member_id: int,
|
||||
wbi: Decimal,
|
||||
nci: Decimal,
|
||||
rs: Decimal,
|
||||
stats: dict,
|
||||
) -> None:
|
||||
"""
|
||||
处理单个客户-助教对的任务生成/更新。
|
||||
|
||||
每对独立事务,失败不影响其他。
|
||||
"""
|
||||
index_data = IndexData(
|
||||
site_id=site_id,
|
||||
assistant_id=assistant_id,
|
||||
member_id=member_id,
|
||||
wbi=wbi,
|
||||
nci=nci,
|
||||
rs=rs,
|
||||
# follow_up_visit 条件由外部传入;当前简化:不自动生成 follow_up_visit
|
||||
# (follow_up_visit 由召回完成检测器触发,不在 task_generator 主动生成)
|
||||
has_active_recall=True,
|
||||
has_follow_up_note=True,
|
||||
)
|
||||
|
||||
new_task_type = determine_task_type(index_data)
|
||||
if new_task_type is None:
|
||||
# 不满足任何条件 → 检查是否有需要填充 expires_at 的 follow_up_visit
|
||||
_handle_no_task_condition(conn, site_id, assistant_id, member_id)
|
||||
stats["skipped"] += 1
|
||||
return
|
||||
|
||||
priority_score = max(wbi, nci)
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
|
||||
# ── 4. 检查已存在的 active 任务 ──
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, task_type, expires_at, created_at
|
||||
FROM biz.coach_tasks
|
||||
WHERE site_id = %s
|
||||
AND assistant_id = %s
|
||||
AND member_id = %s
|
||||
AND status = 'active'
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
(site_id, assistant_id, member_id),
|
||||
)
|
||||
existing_tasks = cur.fetchall()
|
||||
|
||||
# 检查是否已有相同 task_type 的 active 任务
|
||||
same_type_exists = any(row[1] == new_task_type for row in existing_tasks)
|
||||
if same_type_exists:
|
||||
# 相同 task_type → 跳过
|
||||
conn.commit()
|
||||
stats["skipped"] += 1
|
||||
return
|
||||
|
||||
# 不同 task_type 的 active 任务 → 关闭旧任务 + 创建新任务
|
||||
for task_id, old_type, old_expires_at, old_created_at in existing_tasks:
|
||||
if should_replace_task(old_type, new_task_type):
|
||||
# 特殊处理:旧任务是 follow_up_visit → 填充 expires_at 而非直接 inactive
|
||||
if old_type == "follow_up_visit" and old_expires_at is None:
|
||||
# 需求 5.2: follow_up_visit 被高优先级任务顶替时,
|
||||
# 填充 expires_at = created_at + 48h,保持 active
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.coach_tasks
|
||||
SET expires_at = created_at + INTERVAL '48 hours',
|
||||
updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(task_id,),
|
||||
)
|
||||
_insert_history(
|
||||
cur,
|
||||
task_id,
|
||||
action="expires_at_filled",
|
||||
old_status="active",
|
||||
new_status="active",
|
||||
old_task_type=old_type,
|
||||
new_task_type=old_type,
|
||||
detail={"reason": "higher_priority_task_created"},
|
||||
)
|
||||
else:
|
||||
# 关闭旧任务
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.coach_tasks
|
||||
SET status = 'inactive', updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(task_id,),
|
||||
)
|
||||
_insert_history(
|
||||
cur,
|
||||
task_id,
|
||||
action="type_change_close",
|
||||
old_status="active",
|
||||
new_status="inactive",
|
||||
old_task_type=old_type,
|
||||
new_task_type=new_task_type,
|
||||
)
|
||||
|
||||
stats["replaced"] += 1
|
||||
|
||||
# ── 创建新任务 ──
|
||||
# follow_up_visit 生成时 expires_at = NULL(需求 4.1)
|
||||
expires_at_val = None
|
||||
|
||||
# 需求 4.4: 若新任务是 follow_up_visit 且已存在有 expires_at 的旧 follow_up_visit
|
||||
# → 旧任务已在上面被标记为 inactive,新任务 expires_at = NULL
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.coach_tasks
|
||||
(site_id, assistant_id, member_id, task_type, status,
|
||||
priority_score, expires_at, parent_task_id)
|
||||
VALUES (%s, %s, %s, %s, 'active', %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
site_id,
|
||||
assistant_id,
|
||||
member_id,
|
||||
new_task_type,
|
||||
float(priority_score),
|
||||
expires_at_val,
|
||||
# parent_task_id: 关联最近被关闭的旧任务(如有)
|
||||
existing_tasks[0][0] if existing_tasks else None,
|
||||
),
|
||||
)
|
||||
new_task_id = cur.fetchone()[0]
|
||||
|
||||
_insert_history(
|
||||
cur,
|
||||
new_task_id,
|
||||
action="created",
|
||||
old_status=None,
|
||||
new_status="active",
|
||||
old_task_type=existing_tasks[0][1] if existing_tasks else None,
|
||||
new_task_type=new_task_type,
|
||||
)
|
||||
|
||||
stats["created"] += 1
|
||||
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _handle_no_task_condition(
|
||||
conn, site_id: int, assistant_id: int, member_id: int
|
||||
) -> None:
|
||||
"""
|
||||
当不满足任何任务生成条件时,检查是否有 follow_up_visit 需要填充 expires_at。
|
||||
|
||||
需求 4.2: 当 follow_up_visit 的触发条件不再满足时,
|
||||
填充 expires_at = created_at + 48h。
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, expires_at
|
||||
FROM biz.coach_tasks
|
||||
WHERE site_id = %s
|
||||
AND assistant_id = %s
|
||||
AND member_id = %s
|
||||
AND task_type = 'follow_up_visit'
|
||||
AND status = 'active'
|
||||
AND expires_at IS NULL
|
||||
""",
|
||||
(site_id, assistant_id, member_id),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
for task_id, _ in rows:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.coach_tasks
|
||||
SET expires_at = created_at + INTERVAL '48 hours',
|
||||
updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(task_id,),
|
||||
)
|
||||
_insert_history(
|
||||
cur,
|
||||
task_id,
|
||||
action="expires_at_filled",
|
||||
old_status="active",
|
||||
new_status="active",
|
||||
detail={"reason": "condition_no_longer_met"},
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _insert_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 中记录变更。"""
|
||||
import json
|
||||
|
||||
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,
|
||||
),
|
||||
)
|
||||
395
apps/backend/app/services/task_manager.py
Normal file
395
apps/backend/app/services/task_manager.py
Normal file
@@ -0,0 +1,395 @@
|
||||
"""
|
||||
任务管理服务
|
||||
|
||||
负责任务 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()
|
||||
@@ -16,6 +16,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import platform
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
@@ -25,6 +26,11 @@ from ..schemas.tasks import TaskConfigSchema
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# CHANGE 2026-03-07 | 实例标识:用于多后端实例共享同一 DB 时的任务隔离
|
||||
# 背景:发现有另一台机器(宿主机 D 盘)的后端也在消费同一个 task_queue,
|
||||
# 导致任务被错误实例执行。通过 enqueued_by 列实现"谁入队谁消费"。
|
||||
_INSTANCE_ID = platform.node()
|
||||
|
||||
# 后台循环轮询间隔(秒)
|
||||
POLL_INTERVAL_SECONDS = 2
|
||||
|
||||
@@ -43,6 +49,7 @@ class QueuedTask:
|
||||
finished_at: Any = None
|
||||
exit_code: int | None = None
|
||||
error_message: str | None = None
|
||||
schedule_id: str | None = None
|
||||
|
||||
|
||||
class TaskQueue:
|
||||
@@ -56,12 +63,13 @@ class TaskQueue:
|
||||
# 入队
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def enqueue(self, config: TaskConfigSchema, site_id: int) -> str:
|
||||
def enqueue(self, config: TaskConfigSchema, site_id: int, schedule_id: str | None = None) -> str:
|
||||
"""将任务配置入队,自动分配 position。
|
||||
|
||||
Args:
|
||||
config: 任务配置
|
||||
site_id: 门店 ID(门店隔离)
|
||||
schedule_id: 关联的调度任务 ID(可选)
|
||||
|
||||
Returns:
|
||||
新创建的队列任务 ID(UUID 字符串)
|
||||
@@ -84,18 +92,19 @@ class TaskQueue:
|
||||
max_pos = cur.fetchone()[0]
|
||||
new_pos = max_pos + 1
|
||||
|
||||
# CHANGE 2026-03-07 | 写入 enqueued_by 实现多实例任务隔离
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO task_queue (id, site_id, config, status, position)
|
||||
VALUES (%s, %s, %s, 'pending', %s)
|
||||
INSERT INTO task_queue (id, site_id, config, status, position, schedule_id, enqueued_by)
|
||||
VALUES (%s, %s, %s, 'pending', %s, %s, %s)
|
||||
""",
|
||||
(task_id, site_id, json.dumps(config_json), new_pos),
|
||||
(task_id, site_id, json.dumps(config_json), new_pos, schedule_id, _INSTANCE_ID),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
logger.info("任务入队 [%s] site_id=%s position=%s", task_id, site_id, new_pos)
|
||||
logger.info("任务入队 [%s] site_id=%s position=%s schedule_id=%s", task_id, site_id, new_pos, schedule_id)
|
||||
return task_id
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -114,19 +123,21 @@ class TaskQueue:
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 选取 position 最小的 pending 任务并锁定
|
||||
# CHANGE 2026-03-07 | 只消费本实例入队的任务(enqueued_by 匹配)
|
||||
# 背景:多后端实例共享同一 DB 时,防止 A 实例消费 B 实例入队的任务
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, site_id, config, status, position,
|
||||
created_at, started_at, finished_at,
|
||||
exit_code, error_message
|
||||
exit_code, error_message, schedule_id
|
||||
FROM task_queue
|
||||
WHERE site_id = %s AND status = 'pending'
|
||||
AND (enqueued_by = %s OR enqueued_by IS NULL)
|
||||
ORDER BY position ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
""",
|
||||
(site_id,),
|
||||
(site_id, _INSTANCE_ID),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
@@ -144,6 +155,7 @@ class TaskQueue:
|
||||
finished_at=row[7],
|
||||
exit_code=row[8],
|
||||
error_message=row[9],
|
||||
schedule_id=str(row[10]) if row[10] else None,
|
||||
)
|
||||
|
||||
# 更新状态为 running
|
||||
@@ -261,10 +273,11 @@ class TaskQueue:
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_pending(self, site_id: int) -> list[QueuedTask]:
|
||||
"""列出指定门店的所有 pending 任务,按 position 升序。"""
|
||||
"""列出指定门店的所有 pending 任务(仅限本实例入队的),按 position 升序。"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# CHANGE 2026-03-07 | 只列出本实例入队的 pending 任务
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, site_id, config, status, position,
|
||||
@@ -272,9 +285,10 @@ class TaskQueue:
|
||||
exit_code, error_message
|
||||
FROM task_queue
|
||||
WHERE site_id = %s AND status = 'pending'
|
||||
AND (enqueued_by = %s OR enqueued_by IS NULL)
|
||||
ORDER BY position ASC
|
||||
""",
|
||||
(site_id,),
|
||||
(site_id, _INSTANCE_ID),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
conn.commit()
|
||||
@@ -298,18 +312,20 @@ class TaskQueue:
|
||||
]
|
||||
|
||||
def has_running(self, site_id: int) -> bool:
|
||||
"""检查指定门店是否有 running 状态的任务。"""
|
||||
"""检查指定门店是否有本实例的 running 状态任务。"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# CHANGE 2026-03-07 | 只检查本实例的 running 任务
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM task_queue
|
||||
WHERE site_id = %s AND status = 'running'
|
||||
AND (enqueued_by = %s OR enqueued_by IS NULL)
|
||||
)
|
||||
""",
|
||||
(site_id,),
|
||||
(site_id, _INSTANCE_ID),
|
||||
)
|
||||
result = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
@@ -333,7 +349,10 @@ class TaskQueue:
|
||||
from .task_executor import task_executor
|
||||
|
||||
self._running = True
|
||||
logger.info("TaskQueue process_loop 启动")
|
||||
logger.info(
|
||||
"TaskQueue process_loop 启动 (instance_id=%s,仅消费本实例入队的任务)",
|
||||
_INSTANCE_ID,
|
||||
)
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
@@ -369,6 +388,7 @@ class TaskQueue:
|
||||
asyncio.create_task(
|
||||
self._execute_and_update(
|
||||
executor, config, execution_id, task.id, site_id,
|
||||
schedule_id=task.schedule_id,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -379,6 +399,7 @@ class TaskQueue:
|
||||
execution_id: str,
|
||||
queue_id: str,
|
||||
site_id: int,
|
||||
schedule_id: str | None = None,
|
||||
) -> None:
|
||||
"""执行任务并更新队列状态。"""
|
||||
try:
|
||||
@@ -387,6 +408,7 @@ class TaskQueue:
|
||||
execution_id=execution_id,
|
||||
queue_id=queue_id,
|
||||
site_id=site_id,
|
||||
schedule_id=schedule_id,
|
||||
)
|
||||
# 执行完成后根据 executor 的结果更新 task_queue 状态
|
||||
self._update_queue_status_from_log(queue_id)
|
||||
@@ -395,15 +417,18 @@ class TaskQueue:
|
||||
self._mark_failed(queue_id, "执行过程中发生未捕获异常")
|
||||
|
||||
def _get_pending_site_ids(self) -> list[int]:
|
||||
"""获取所有有 pending 任务的 site_id 列表。"""
|
||||
"""获取所有有 pending 任务的 site_id 列表(仅限本实例入队的)。"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# CHANGE 2026-03-07 | 只查本实例入队的 pending 任务
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT DISTINCT site_id FROM task_queue
|
||||
WHERE status = 'pending'
|
||||
"""
|
||||
AND (enqueued_by = %s OR enqueued_by IS NULL)
|
||||
""",
|
||||
(_INSTANCE_ID,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
conn.commit()
|
||||
|
||||
@@ -191,6 +191,8 @@ DWD_TABLES: list[DwdTableDefinition] = [
|
||||
DwdTableDefinition("dwd.dim_goods_category", "商品分类维度", "商品", "ods.stock_goods_category_tree", is_dimension=True),
|
||||
DwdTableDefinition("dwd.dim_groupbuy_package", "团购套餐维度", "团购", "ods.group_buy_packages", is_dimension=True),
|
||||
DwdTableDefinition("dwd.dim_groupbuy_package_ex", "团购套餐维度(扩展)", "团购", "ods.group_buy_packages", is_dimension=True),
|
||||
DwdTableDefinition("dwd.dim_staff", "员工维度", "人事", "ods.staff_info_master", is_dimension=True),
|
||||
DwdTableDefinition("dwd.dim_staff_ex", "员工维度(扩展)", "人事", "ods.staff_info_master", is_dimension=True),
|
||||
# 事实表
|
||||
DwdTableDefinition("dwd.dwd_settlement_head", "结算主表", "结算", "ods.settlement_records"),
|
||||
DwdTableDefinition("dwd.dwd_settlement_head_ex", "结算主表(扩展)", "结算", "ods.settlement_records"),
|
||||
|
||||
161
apps/backend/app/services/trigger_scheduler.py
Normal file
161
apps/backend/app/services/trigger_scheduler.py
Normal file
@@ -0,0 +1,161 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
触发器调度框架(Trigger Scheduler)
|
||||
|
||||
统一管理 cron/interval/event 三种触发方式,驱动后台任务执行。
|
||||
|
||||
- cron/interval 类型通过 check_scheduled_jobs() 轮询 next_run_at 触发
|
||||
- event 类型通过 fire_event() 方法直接触发
|
||||
- 每个 job 独立事务,失败不影响其他触发器
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_connection():
|
||||
"""延迟导入 get_connection,避免纯函数测试时触发模块级导入失败。"""
|
||||
from app.database import get_connection
|
||||
return get_connection()
|
||||
|
||||
# job_type → 执行函数的注册表
|
||||
_JOB_REGISTRY: dict[str, Callable] = {}
|
||||
|
||||
|
||||
def register_job(job_type: str, handler: Callable) -> None:
|
||||
"""注册 job_type 对应的执行函数。"""
|
||||
_JOB_REGISTRY[job_type] = handler
|
||||
|
||||
|
||||
def fire_event(event_name: str, payload: dict[str, Any] | None = None) -> int:
|
||||
"""
|
||||
触发事件驱动型任务。
|
||||
|
||||
查找 trigger_condition='event' 且 trigger_config.event_name 匹配的 enabled job,
|
||||
立即执行对应的 handler。
|
||||
|
||||
返回: 执行的 job 数量
|
||||
"""
|
||||
conn = _get_connection()
|
||||
executed = 0
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, job_type, job_name
|
||||
FROM biz.trigger_jobs
|
||||
WHERE status = 'enabled'
|
||||
AND trigger_condition = 'event'
|
||||
AND trigger_config->>'event_name' = %s
|
||||
""",
|
||||
(event_name,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
for job_id, job_type, job_name in rows:
|
||||
handler = _JOB_REGISTRY.get(job_type)
|
||||
if not handler:
|
||||
logger.warning(
|
||||
"未注册的 job_type: %s (job_name=%s)", job_type, job_name
|
||||
)
|
||||
continue
|
||||
try:
|
||||
handler(payload=payload)
|
||||
executed += 1
|
||||
# 更新 last_run_at
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE biz.trigger_jobs SET last_run_at = NOW() WHERE id = %s",
|
||||
(job_id,),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.exception("触发器 %s 执行失败", job_name)
|
||||
conn.rollback()
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
return executed
|
||||
|
||||
|
||||
def check_scheduled_jobs() -> int:
|
||||
"""
|
||||
检查 cron/interval 类型的到期 job 并执行。
|
||||
|
||||
由 Scheduler 后台循环调用。
|
||||
返回: 执行的 job 数量
|
||||
"""
|
||||
conn = _get_connection()
|
||||
executed = 0
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, job_type, job_name, trigger_condition, trigger_config
|
||||
FROM biz.trigger_jobs
|
||||
WHERE status = 'enabled'
|
||||
AND trigger_condition IN ('cron', 'interval')
|
||||
AND (next_run_at IS NULL OR next_run_at <= NOW())
|
||||
ORDER BY next_run_at ASC NULLS FIRST
|
||||
""",
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
for job_id, job_type, job_name, trigger_condition, trigger_config in rows:
|
||||
handler = _JOB_REGISTRY.get(job_type)
|
||||
if not handler:
|
||||
logger.warning("未注册的 job_type: %s", job_type)
|
||||
continue
|
||||
try:
|
||||
handler()
|
||||
executed += 1
|
||||
# 计算 next_run_at 并更新
|
||||
next_run = _calculate_next_run(trigger_condition, trigger_config)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.trigger_jobs
|
||||
SET last_run_at = NOW(), next_run_at = %s
|
||||
WHERE id = %s
|
||||
""",
|
||||
(next_run, job_id),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.exception("触发器 %s 执行失败", job_name)
|
||||
conn.rollback()
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
return executed
|
||||
|
||||
|
||||
def _calculate_next_run(
|
||||
trigger_condition: str, trigger_config: dict
|
||||
) -> datetime | None:
|
||||
"""
|
||||
根据触发条件和配置计算下次运行时间。
|
||||
|
||||
- interval: now + interval_seconds
|
||||
- cron: 复用 scheduler._parse_simple_cron
|
||||
- event: 返回 None(无 next_run_at)
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
if trigger_condition == "interval":
|
||||
seconds = trigger_config.get("interval_seconds", 3600)
|
||||
return now + timedelta(seconds=seconds)
|
||||
elif trigger_condition == "cron":
|
||||
# 延迟导入:支持从 monorepo 根目录和 apps/backend/ 两种路径导入
|
||||
try:
|
||||
from app.services.scheduler import _parse_simple_cron
|
||||
except ModuleNotFoundError:
|
||||
from apps.backend.app.services.scheduler import _parse_simple_cron
|
||||
|
||||
return _parse_simple_cron(
|
||||
trigger_config.get("cron_expression", "0 4 * * *"), now
|
||||
)
|
||||
return None # event 类型无 next_run_at
|
||||
@@ -12,7 +12,7 @@ import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import get
|
||||
from app.config import WX_APPID, WX_SECRET
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -59,8 +59,8 @@ async def code2session(code: str) -> dict:
|
||||
WeChatAuthError: 微信接口返回非零 errcode 时抛出
|
||||
RuntimeError: WX_APPID / WX_SECRET 环境变量缺失时抛出
|
||||
"""
|
||||
appid = get("WX_APPID", "")
|
||||
secret = get("WX_SECRET", "")
|
||||
appid = WX_APPID
|
||||
secret = WX_SECRET
|
||||
|
||||
if not appid or not secret:
|
||||
raise RuntimeError("微信配置缺失:WX_APPID 或 WX_SECRET 未设置")
|
||||
|
||||
Reference in New Issue
Block a user