微信小程序页面迁移校验之前 P5任务处理之前

This commit is contained in:
Neo
2026-03-09 01:19:21 +08:00
parent 263bf96035
commit 6e20987d2f
1112 changed files with 153824 additions and 219694 deletions

View File

@@ -98,6 +98,15 @@ async def create_application(
(nickname, user_id),
)
# 5. 更新用户状态为 pendingnew → 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()

View File

@@ -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:

View 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,
}

View 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()

View 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_idSET 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

View File

@@ -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,

View File

@@ -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()

View 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}

View 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,
),
)

View 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()

View File

@@ -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:
新创建的队列任务 IDUUID 字符串)
@@ -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()

View File

@@ -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"),

View 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

View File

@@ -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 未设置")