feat: 2026-04-15~05-02 累积变更基线 — AI 重构 + Runtime Context + DWS 修复

涵盖(每条对应已存的审计记录):
- AI 模块拆分:apps/backend/app/ai/apps -> prompts/(8 个 APP + app2a 派生)
  audit: 2026-04-20__ai-module-complete.md
- admin-web AI 管理套件:AIDashboard / AIOperations / AIRunLogs / AITriggers / TriggerManager
  audit: 2026-04-21__admin-web-ai-management-suite.md
- App2 财务洞察 prompt v3 -> v5.1 + 小程序 AI 接入(chat / board-finance)
  audit: 2026-04-22__app2_prompt_v5_1_and_miniprogram_ai_insight.md
- App2 prewarm 全过滤器 + AI 触发器 cron reschedule
  audit: 2026-04-21__app2-finance-prewarm-all-filters.md
  migration: 20260420_ai_trigger_jobs_and_app2_prewarm.sql / 20260421_app2_prewarm_cron_reschedule.sql
- AppType 联合类型对齐 + adminAiAppTypes.test.ts
  audit: 2026-04-30__admin_web_ai_app_type_alignment.md
- DashScope tokens_used 提取修复
  audit: 2026-04-30__backend_dashscope_tokens_used_extraction.md
- App3 线索完整详情 prompt
  audit: 2026-05-01__backend_app3_full_detail_prompt.md
- Runtime Context 沙箱(5-1~5-2 主线):
  - 后端 schema/service + admin_runtime_context / xcx_runtime_clock 两个 router
  - admin-web RuntimeContext.tsx + miniprogram runtime-clock.ts
  - migration: 20260501__runtime_context_sandbox.sql
  - tools/db/verify_admin_web_sandbox.py + verify_sandbox_end_to_end.py
  - database/changes: 7 份 sandbox_* 验证报告
- 飞球 DWS 修复:finance_area_daily 区域汇总 + task_engine 调整
  + RLS 视图业务日上界(migration 20260502 + scripts/ops/gen_rls_business_date_migration.py)

合规:
- .gitignore 启用 tmp/ 排除
- 不入仓:apps/etl/connectors/feiqiu/.env(API_TOKEN secret,本地修改保留)

待验证清单:
- docs/audit/changes/2026-05-04__cumulative_baseline_pending_verification.md
  每个主题的功能完整性 / 上线验证几乎都未收口,按优先级 P0~P3 逐一处理
This commit is contained in:
Neo
2026-05-04 02:30:19 +08:00
parent 2010034840
commit caf179a5da
130 changed files with 14543 additions and 2717 deletions

View File

@@ -35,9 +35,21 @@ class AdminAIService:
# ── Dashboard ─────────────────────────────────────────
async def get_dashboard(self, site_id: int | None = None) -> dict:
"""聚合所有 Dashboard 数据。"""
today_stats = await self._get_today_stats(site_id)
async def get_dashboard(
self,
site_id: int | None = None,
range_days: int | None = None,
date_from: str | None = None,
date_to: str | None = None,
) -> dict:
"""聚合所有 Dashboard 数据。
时间范围优先级:
1. 若 date_from / date_to 同时给出(指定日期)→ 闭区间 [from, to]
2. 若 range_days=N → [CURRENT_DATE - (N-1) days, 现在]
3. 默认 range_days=1今日
"""
today_stats = await self._get_range_stats(site_id, range_days, date_from, date_to)
trend_7d = await self._get_7d_trend(site_id)
app_dist = await self._get_app_distribution(site_id)
app_health = await self._get_app_health(site_id)
@@ -52,9 +64,32 @@ class AdminAIService:
"app_health": app_health,
}
async def _get_today_stats(self, site_id: int | None) -> dict:
"""今日调用次数、成功率、Token 消耗、平均延迟。"""
site_clause, params = _site_filter(site_id)
async def _get_range_stats(
self,
site_id: int | None,
range_days: int | None,
date_from: str | None,
date_to: str | None,
) -> dict:
"""指定时间段内的调用次数、成功率、Token 消耗、平均延迟。
字段名沿用 today_* 前缀以兼容前端 DashboardResponse schema。
"""
site_clause, site_params = _site_filter(site_id)
if date_from and date_to:
time_clause = "created_at >= %s::date AND created_at < (%s::date + INTERVAL '1 day')"
time_params: tuple = (date_from, date_to)
else:
days = range_days if range_days and range_days > 0 else 1
time_clause = (
"created_at >= CURRENT_DATE - (%s::int - 1) * INTERVAL '1 day' "
"AND created_at < CURRENT_DATE + INTERVAL '1 day'"
)
time_params = (days,)
params = time_params + site_params
conn = get_connection()
try:
with conn.cursor() as cur:
@@ -67,8 +102,7 @@ class AdminAIService:
COALESCE(AVG(latency_ms) FILTER (WHERE latency_ms IS NOT NULL), 0)
AS avg_latency
FROM biz.ai_run_logs
WHERE created_at >= CURRENT_DATE
AND created_at < CURRENT_DATE + INTERVAL '1 day'
WHERE {time_clause}
{site_clause}
""",
params,
@@ -466,6 +500,22 @@ class AdminAIService:
finally:
conn.close()
# Phase 1.4:广播 cache_invalidated 事件admin-web / 小程序可实时刷新
if affected > 0:
try:
from app.ai.event_bus import AIEvent, get_event_bus
get_event_bus().publish(AIEvent(
type="cache_invalidated",
site_id=site_id,
payload={
"cache_type": app_type,
"member_id": member_id,
"affected": affected,
},
))
except Exception:
logger.debug("cache_invalidated 事件广播失败", exc_info=True)
return affected
# ── Token 预算 ────────────────────────────────────────
@@ -699,6 +749,140 @@ class AdminAIService:
return "ignored"
# ── 触发器管理biz.trigger_jobs───────────────────────
async def list_triggers(self) -> list[dict]:
"""列出所有 AI 相关触发器job_type 以 ai_ 开头 + task_generator
返回字段id / job_name / job_type / trigger_condition / trigger_config /
status / description / last_run_at / next_run_at / last_error
"""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, job_name, job_type, trigger_condition,
trigger_config, status, description,
last_run_at, next_run_at, last_error
FROM biz.trigger_jobs
WHERE job_type LIKE 'ai_%' OR job_name = 'task_generator'
ORDER BY trigger_condition DESC, job_name
"""
)
cols = [d[0] for d in cur.description]
rows = cur.fetchall()
conn.commit()
finally:
conn.close()
return [_row_to_dict(cols, r) for r in rows]
async def update_trigger(
self, trigger_id: int,
status_new: str | None = None,
cron_expression: str | None = None,
description: str | None = None,
) -> dict:
"""更新触发器:启用/禁用、修改 cron、改描述。
仅允许修改 ai_ 前缀或 task_generator 的触发器。
"""
if status_new is not None and status_new not in ("enabled", "disabled"):
raise ValueError(f"非法 status: {status_new}")
sets: list[str] = []
params: list = []
if status_new is not None:
sets.append("status = %s")
params.append(status_new)
if cron_expression is not None:
sets.append("trigger_config = jsonb_set(trigger_config, '{cron_expression}', to_jsonb(%s::text))")
params.append(cron_expression)
if description is not None:
sets.append("description = %s")
params.append(description)
if not sets:
raise ValueError("至少修改一个字段")
params.append(trigger_id)
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
f"""
UPDATE biz.trigger_jobs
SET {", ".join(sets)}
WHERE id = %s
AND (job_type LIKE 'ai_%%' OR job_name = 'task_generator')
RETURNING id, job_name, job_type, trigger_condition,
trigger_config, status, description,
last_run_at, next_run_at, last_error
""",
params,
)
row = cur.fetchone()
if row is None:
conn.rollback()
raise ValueError("触发器不存在或不可修改")
cols = [d[0] for d in cur.description]
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
return _row_to_dict(cols, row)
# ── 预热进度app2_finance 72 组合)──────────────────────
async def get_prewarm_progress(self, site_id: int) -> dict:
"""查询 app2_finance 72 组合缓存进度。
返回total=72, done=N, missing=[{time_dimension, area}], last_updated
"""
time_dims = (
"this_month", "last_month", "this_week", "last_week",
"this_quarter", "last_quarter", "last_3_months", "last_6_months",
)
areas = (
"all", "hall", "hallA", "hallB", "hallC",
"vip", "snooker", "mahjong", "ktv",
)
expected = {f"{t}__{a}" for t in time_dims for a in areas}
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT target_id, max(created_at) AS last_updated
FROM biz.ai_cache
WHERE cache_type = 'app2_finance'
AND site_id = %s
AND target_id LIKE %s ESCAPE '\\'
GROUP BY target_id
""",
(site_id, r'%\_\_%'),
)
rows = cur.fetchall()
conn.commit()
finally:
conn.close()
done_map = {r[0]: r[1] for r in rows}
missing = sorted(expected - set(done_map.keys()))
last = max(done_map.values()) if done_map else None
return {
"total": len(expected),
"done": len(expected & set(done_map.keys())),
"missing": [
{"target_id": m, "time_dimension": m.split("__")[0], "area": m.split("__")[1]}
for m in missing
],
"last_updated": last.isoformat() if last else None,
}
# ── 工具函数 ──────────────────────────────────────────────

View File

@@ -195,6 +195,7 @@ from typing import Any
from fastapi import HTTPException
from app.services import fdw_queries
from app.services.runtime_context import get_runtime_context, task_runtime_filter
logger = logging.getLogger(__name__)
@@ -260,7 +261,8 @@ async def get_coach_board(
detail="最近6个月不支持客源储值排序",
)
start_date, end_date = _calc_date_range(time)
runtime_ctx = get_runtime_context(site_id)
start_date, end_date = _calc_date_range(time, ref_date=runtime_ctx.business_date)
start_str = str(start_date)
end_str = str(end_date)
@@ -415,20 +417,22 @@ def _query_coach_tasks(
result: dict[int, dict] = {}
try:
runtime_clause, runtime_params = task_runtime_filter(site_id, conn=conn)
with conn.cursor() as cur:
# 狭义召回+回访完成数:均从 coach_tasks 统计status='completed' 表示助教亲自完成
cur.execute(
"""
f"""
SELECT assistant_id, task_type, COUNT(*) AS cnt
FROM biz.coach_tasks
WHERE assistant_id = ANY(%s)
AND site_id = %s
{runtime_clause}
AND completed_at >= %s::date
AND completed_at < (%s::date + INTERVAL '1 day')::timestamptz
AND status = 'completed'
GROUP BY assistant_id, task_type
""",
(assistant_ids, site_id, start_date, end_date),
[assistant_ids, site_id, *runtime_params, start_date, end_date],
)
for row in cur.fetchall():
aid, task_type, cnt = row[0], row[1], row[2] or 0
@@ -470,13 +474,27 @@ def _batch_ideal_days(conn: Any, site_id: int, member_ids: list[int]) -> dict[in
return result
def _batch_coach_details(conn: Any, site_id: int, member_ids: list[int]) -> dict[int, list[dict]]:
"""批量查询客户-助教服务明细loyal 维度 coachDetails 用)。每个客户前 5 个。"""
def _batch_coach_details(
conn: Any,
site_id: int,
member_ids: list[int],
*,
ref_date: date | None = None,
) -> dict[int, list[dict]]:
"""批量查询客户-助教服务明细loyal 维度 coachDetails 用)。每个客户前 5 个。
ref_date 默认从 RuntimeContext 取业务日,用于把 60 天消费窗口的上界落到 ``ref_date`` 上,
避免 sandbox 模式下读到 sandbox_date 之后的真实消费。
"""
from app.services.fdw_queries import _fdw_context
from app.services.runtime_context import as_runtime_today_param
ref = ref_date or as_runtime_today_param(site_id, conn=conn)
result: dict[int, list[dict]] = {mid: [] for mid in member_ids}
try:
with _fdw_context(conn, site_id) as cur:
# CHANGE 2026-03-29 | coach_spend 改为从 dwd_assistant_service_log 聚合 60 天消费
# CHANGE 2026-05-02 | 用 ref_date业务日替代 CURRENT_DATE沙箱不读「未来」
cur.execute(
"""
SELECT ri.member_id,
@@ -493,7 +511,8 @@ def _batch_coach_details(conn: Any, site_id: int, member_ids: list[int]) -> dict
SUM(ledger_amount) AS spend_60d
FROM app.v_dwd_assistant_service_log
WHERE is_delete = 0
AND create_time >= CURRENT_DATE - INTERVAL '60 days'
AND create_time >= (%s::date - INTERVAL '60 days')
AND create_time < (%s::date + INTERVAL '1 day')
AND tenant_member_id = ANY(%s)
GROUP BY tenant_member_id, site_assistant_id
) s60 ON ri.member_id = s60.tenant_member_id
@@ -502,7 +521,7 @@ def _batch_coach_details(conn: Any, site_id: int, member_ids: list[int]) -> dict
AND (da.leave_status IS NULL OR da.leave_status = 0)
ORDER BY ri.member_id, ri.rs_display DESC
""",
(member_ids, member_ids),
(ref, ref, member_ids, member_ids),
)
for row in cur.fetchall():
mid = row[0]
@@ -690,7 +709,8 @@ async def get_finance_board(
- area≠all 时 overview 覆盖逻辑保留
- compare=1 时对上期执行同样缓存/日粒度逻辑
"""
start_date, end_date = _calc_date_range(time)
runtime_ctx = get_runtime_context(site_id)
start_date, end_date = _calc_date_range(time, ref_date=runtime_ctx.business_date)
start_str = str(start_date)
end_str = str(end_date)

View File

@@ -234,23 +234,14 @@ class ChatService:
INSERT INTO biz.ai_conversations
(user_id, nickname, app_id, site_id, context_type, context_id)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id, EXTRACT(EPOCH FROM created_at)::bigint
RETURNING id
""",
(str(user_id), nickname, APP_ID, site_id, context_type, context_id),
)
result = cur.fetchone()
new_id = result[0]
created_ts = result[1]
# 生成 session_id 并回写格式conv_{id}_{timestamp}
session_id = f"conv_{new_id}_{created_ts}"
cur.execute(
"""
UPDATE biz.ai_conversations SET session_id = %s WHERE id = %s
""",
(session_id, new_id),
)
new_id = cur.fetchone()[0]
# session_id 初始保持 NULL首次对话由百炼返回后再回写。
# 参见 P14 spec §2.3:后端不再自生 session_id交由百炼云端管理。
conn.commit()
return new_id
except Exception:
@@ -274,6 +265,34 @@ class ChatService:
finally:
conn.close()
@trace_service("保存百炼 session_id", "Save bailian session ID")
def save_session_id(self, chat_id: int, session_id: str) -> None:
"""流式回复完成后,将百炼返回的 session_id 回写 ai_conversations。
multi-turn 启用:
- 首次对话 session_id=NULL → 百炼分配新 session → 这里回写
- 下次对话 get_session_id 返回该值 → 传给百炼关联历史上下文
幂等:同一对话多次调用覆盖最新 session_id通常保持稳定
"""
if not session_id:
return
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"UPDATE biz.ai_conversations SET session_id = %s WHERE id = %s",
(session_id, chat_id),
)
conn.commit()
except Exception:
conn.rollback()
logger.warning(
"保存 session_id 失败: chat_id=%s", chat_id, exc_info=True,
)
finally:
conn.close()
# ------------------------------------------------------------------
# CHAT-2: 消息列表
# ------------------------------------------------------------------
@@ -662,7 +681,10 @@ class ChatService:
"""查询客户近 30 天消费金额items_sum 口径)。
⚠️ DWD-DOC 规则 1: 使用 ledger_amountitems_sum 口径),禁用 consume_money。
CHANGE 2026-05-02 | 用 business_date 替代 CURRENT_DATE沙箱不读「未来」消费。
"""
from app.services.runtime_context import as_runtime_today_param
ref = as_runtime_today_param(site_id, conn=conn)
with fdw_queries._fdw_context(conn, site_id) as cur:
cur.execute(
"""
@@ -670,16 +692,22 @@ class ChatService:
FROM app.v_dwd_assistant_service_log
WHERE tenant_member_id = %s
AND is_delete = 0
AND create_time >= (CURRENT_DATE - INTERVAL '30 days')::timestamptz
AND create_time >= (%s::date - INTERVAL '30 days')::timestamptz
AND create_time < (%s::date + INTERVAL '1 day')::timestamptz
""",
(member_id,),
(member_id, ref, ref),
)
row = cur.fetchone()
return Decimal(str(row[0])) if row and row[0] is not None else None
@staticmethod
def _get_visit_count_30d(conn: Any, site_id: int, member_id: int) -> int | None:
"""查询客户近 30 天到店次数。"""
"""查询客户近 30 天到店次数。
CHANGE 2026-05-02 | 用 business_date 替代 CURRENT_DATE沙箱不读「未来」到店。
"""
from app.services.runtime_context import as_runtime_today_param
ref = as_runtime_today_param(site_id, conn=conn)
with fdw_queries._fdw_context(conn, site_id) as cur:
cur.execute(
"""
@@ -687,9 +715,10 @@ class ChatService:
FROM app.v_dwd_assistant_service_log
WHERE tenant_member_id = %s
AND is_delete = 0
AND create_time >= (CURRENT_DATE - INTERVAL '30 days')::timestamptz
AND create_time >= (%s::date - INTERVAL '30 days')::timestamptz
AND create_time < (%s::date + INTERVAL '1 day')::timestamptz
""",
(member_id,),
(member_id, ref, ref),
)
row = cur.fetchone()
return int(row[0]) if row and row[0] is not None else None

View File

@@ -147,7 +147,9 @@ async def get_coach_detail(coach_id: int, site_id: int) -> dict:
if not assistant_info:
raise HTTPException(status_code=404, detail="助教不存在")
now = datetime.date.today()
# 业务时间锚sandbox 模式下用 business_date避免读到 sandbox_date 之后真实绩效
from app.services.runtime_context import as_runtime_today_param
now = as_runtime_today_param(site_id, conn=conn)
# 门店名称(用于小程序 banner 展示,跟随被查看助教所在门店)
# 必须在所有 fdw 查询前执行:后续任意 fdw 查询失败会污染事务
@@ -713,7 +715,9 @@ def _build_history_months(
4. 本月 estimated=True历史月份 estimated=False
5. 格式化customers→"22人"hours→"87.5h"salary→"¥6,950"
"""
now = datetime.date.today()
# 业务时间锚sandbox 模式下用 business_date 计算最近 6 个月
from app.services.runtime_context import as_runtime_today_param
now = as_runtime_today_param(site_id, conn=conn)
# 生成最近 6 个月的月份列表(含本月)
months: list[str] = []

View File

@@ -501,6 +501,9 @@ def _build_coach_tasks(
logger.warning("批量查询助教信息失败", exc_info=True)
# 批量查询 60 天统计(一次 FDW 查询)
# CHANGE 2026-05-02 | 用 business_date 替代 CURRENT_DATE沙箱不读「未来」
from app.services.runtime_context import as_runtime_today_param
ref_date = as_runtime_today_param(site_id, conn=conn)
stats_map: dict = {}
try:
with fdw_queries._fdw_context(conn, site_id) as cur:
@@ -513,10 +516,11 @@ def _build_coach_tasks(
WHERE tenant_member_id = %s
AND site_assistant_id = ANY(%s)
AND is_delete = 0
AND create_time >= CURRENT_DATE - INTERVAL '60 days'
AND create_time >= (%s::date - INTERVAL '60 days')::timestamptz
AND create_time < (%s::date + INTERVAL '1 day')::timestamptz
GROUP BY site_assistant_id
""",
(customer_id, assistant_ids),
(customer_id, assistant_ids, ref_date, ref_date),
)
for row in cur.fetchall():
svc = int(row[1]) if row[1] else 0

View File

@@ -80,7 +80,7 @@ def _get_etl_connection(site_id: int):
@contextmanager
def _fdw_context(conn: Any, site_id: int, *, etl_conn: Any = None):
"""
上下文管理器:直连 ETL 库 + SET LOCAL app.current_site_id。
上下文管理器:直连 ETL 库 + SET LOCAL app.current_site_id + app.current_business_date
⚠️ 不使用 zqyy_app 的 fdw_etl.* foreign table而是直连 ETL 库
查询 app.v_* RLS 视图。原因postgres_fdw 不传递自定义 GUC 参数
@@ -91,7 +91,31 @@ def _fdw_context(conn: Any, site_id: int, *, etl_conn: Any = None):
CHANGE 2026-03-26 | ETL 连接复用:传入 etl_conn 时复用已有连接(不关闭),
不传时新建连接并在 yield 后自动关闭。避免同一请求内多次新建连接(每次 ~2.6s)。
CHANGE 2026-05-02 | 同时设置 app.current_business_date / app.current_runtime_mode
供 RLS 视图层C 方案做日期上界裁剪。conn=None 时降级 live。
"""
from app.services.runtime_context import (
MODE_LIVE,
MODE_SANDBOX,
get_runtime_context,
)
# 业务日:优先从 zqyy_app 业务库的 RuntimeContext 读取conn 不可用时降级为系统今天
bd_str = ""
rt_mode = MODE_LIVE
try:
if conn is not None:
ctx = get_runtime_context(site_id, conn=conn)
bd_str = ctx.business_date.isoformat()
rt_mode = MODE_SANDBOX if ctx.is_sandbox else MODE_LIVE
else:
from datetime import date as _date
bd_str = _date.today().isoformat()
except Exception:
from datetime import date as _date
bd_str = _date.today().isoformat()
rt_mode = MODE_LIVE
owned = etl_conn is None
if owned:
etl_conn = _get_etl_connection(site_id)
@@ -99,6 +123,8 @@ def _fdw_context(conn: Any, site_id: int, *, etl_conn: Any = None):
with etl_conn.cursor() as cur:
cur.execute("BEGIN")
cur.execute("SET LOCAL app.current_site_id = %s", (str(site_id),))
cur.execute("SET LOCAL app.current_business_date = %s", (bd_str,))
cur.execute("SET LOCAL app.current_runtime_mode = %s", (rt_mode,))
yield cur
etl_conn.commit()
finally:
@@ -180,33 +206,53 @@ def get_last_visit_days(
"""
批量查询客户距上次到店天数。
来源: app.v_dws_member_consumption_summary.days_since_last基于结算单
FIX: 原查 v_dwd_assistant_service_log 导致无助教服务的客户缺失到店记录。
来源: app.v_dws_member_consumption_summary。
consumption_summary 按 stat_date 有多行快照,取最新一行。
CHANGE 2026-05-02 | 修复客户看板「最近到店」数据不准的问题:
- 旧版直接用 days_since_lastETL 在 stat_date 那天预计算的快照值)。
若 ETL 没跑、跑得迟、或 sandbox_date 与 stat_date 不一致,结果就会严重失真。
- 新版改为实时计算:``business_date - last_consume_date``
仅取 ``stat_date <= business_date`` 的快照行,沙箱模式下也能拿到一致结果。
返回 {member_id: days_since_visit} 映射,无记录的会员不在结果中。
"""
if not member_ids:
return {}
from app.services.runtime_context import as_runtime_today_param
ref_date = as_runtime_today_param(site_id, conn=conn)
result: dict[int, int | None] = {}
with _fdw_context(conn, site_id, etl_conn=etl_conn) as cur:
cur.execute(
"""
SELECT member_id, days_since_last
SELECT DISTINCT ON (member_id)
member_id,
last_consume_date,
stat_date
FROM app.v_dws_member_consumption_summary
WHERE member_id = ANY(%s)
AND days_since_last IS NOT NULL
AND stat_date <= %s
ORDER BY member_id, stat_date DESC
""",
(member_ids,),
(member_ids, ref_date),
)
seen: set[int] = set()
for row in cur.fetchall():
mid = row[0]
if mid not in seen:
seen.add(mid)
result[mid] = row[1]
last_consume = row[1]
if last_consume is None:
result[mid] = None
continue
try:
# last_consume_date 在 DWS 中是 date少数实现可能给 timestamp统一裁剪
if hasattr(last_consume, "date"):
last_consume = last_consume.date()
days = (ref_date - last_consume).days
result[mid] = max(days, 0)
except Exception:
result[mid] = None
return result
@@ -420,22 +466,33 @@ def batch_query_for_task_list(
# 3. 最后到店天数(基于消费汇总表,口径=结算单)
# FIX: 原查 v_dwd_assistant_service_log 导致无助教服务的客户缺失到店记录
# CHANGE 2026-05-02 | 实时按 business_date - last_consume_date 计算,
# 不再依赖 ETL 预计算的 days_since_last解决看板显示偏差
from app.services.runtime_context import as_runtime_today_param
_ref_date = as_runtime_today_param(site_id, conn=conn)
cur.execute(
"""
SELECT member_id, days_since_last
SELECT DISTINCT ON (member_id)
member_id, last_consume_date, stat_date
FROM app.v_dws_member_consumption_summary
WHERE member_id = ANY(%s)
AND days_since_last IS NOT NULL
AND stat_date <= %s
ORDER BY member_id, stat_date DESC
""",
(member_ids,),
(member_ids, _ref_date),
)
seen_members: set[int] = set()
for row in cur.fetchall():
mid = row[0]
if mid not in seen_members:
seen_members.add(mid)
last_visit_map[mid] = row[1]
last_consume = row[1]
if last_consume is None:
last_visit_map[mid] = None
continue
try:
if hasattr(last_consume, "date"):
last_consume = last_consume.date()
last_visit_map[mid] = max((_ref_date - last_consume).days, 0)
except Exception:
last_visit_map[mid] = None
# 4. RS 指数
cur.execute(
@@ -486,10 +543,11 @@ def batch_query_for_task_list(
WHERE sl.site_assistant_id = %s
AND sl.tenant_member_id = ANY(%s)
AND sl.is_delete = 0
AND sl.create_time >= (CURRENT_DATE - INTERVAL '60 days')::timestamptz
AND sl.create_time >= (%s::date - INTERVAL '60 days')::timestamptz
AND sl.create_time < (%s::date + INTERVAL '1 day')::timestamptz
GROUP BY sl.tenant_member_id
""",
(assistant_id, member_ids),
(assistant_id, member_ids, _ref_date, _ref_date),
)
for row in cur.fetchall():
recent60d_map[row[0]] = {
@@ -559,15 +617,19 @@ def batch_query_for_task_list(
# 8. 绩效档位配置(用于构建 tier_nodes + bonus_money 计算)
# CHANGE 2026-03-24 | 增加 bonus_deduction_ratio 用于打赏课抽成差额计算
# CHANGE 2026-05-02 | 用 business_date 替代 CURRENT_DATE沙箱按当时生效档位
from app.services.runtime_context import as_runtime_today_param as _rt_today
_ref_date = _rt_today(site_id, conn=conn)
cur.execute(
"""
SELECT tier_id, tier_code, tier_name, tier_level,
min_hours, max_hours, base_deduction, bonus_deduction_ratio
FROM app.v_cfg_performance_tier
WHERE effective_from <= CURRENT_DATE
AND effective_to >= CURRENT_DATE
WHERE effective_from <= %s::date
AND effective_to >= %s::date
ORDER BY tier_level
"""
""",
(_ref_date, _ref_date),
)
tier_rows = cur.fetchall()
performance_tiers = [
@@ -640,17 +702,21 @@ def get_performance_tiers(
返回 [{tier_id, tier_code, tier_name, tier_level, min_hours, max_hours,
base_deduction, bonus_deduction_ratio}, ...]。
CHANGE 2026-05-02 | 用 business_date 替代 CURRENT_DATE沙箱按当时生效档位
"""
from app.services.runtime_context import as_runtime_today_param as _rt_today
ref_date = _rt_today(site_id, conn=conn)
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT tier_id, tier_code, tier_name, tier_level,
min_hours, max_hours, base_deduction, bonus_deduction_ratio
FROM app.v_cfg_performance_tier
WHERE effective_from <= CURRENT_DATE
AND effective_to >= CURRENT_DATE
WHERE effective_from <= %s::date
AND effective_to >= %s::date
ORDER BY tier_level
"""
""",
(ref_date, ref_date),
)
rows = cur.fetchall()
@@ -680,15 +746,18 @@ def get_level_map(conn: Any, site_id: int) -> dict[int, str]:
查询失败时返回空 dict调用方应优雅降级
"""
try:
from app.services.runtime_context import as_runtime_today_param as _rt_today
ref_date = _rt_today(site_id, conn=conn)
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
SELECT DISTINCT level_code, level_name
FROM app.v_cfg_assistant_level_price
WHERE effective_from <= CURRENT_DATE
AND effective_to >= CURRENT_DATE
WHERE effective_from <= %s::date
AND effective_to >= %s::date
ORDER BY level_code
"""
""",
(ref_date, ref_date),
)
return {row[0]: row[1] for row in cur.fetchall()}
except Exception:
@@ -1198,8 +1267,11 @@ def get_coach_60d_stats(
来源: app.v_dwd_assistant_service_log。
⚠️ 废单排除: is_delete = 0。
CHANGE 2026-05-02 | 用 business_date 替代 CURRENT_DATE沙箱不读「未来」60 天。
返回 {service_count, total_hours, avg_hours}。
"""
from app.services.runtime_context import as_runtime_today_param
ref_date = as_runtime_today_param(site_id, conn=conn)
with _fdw_context(conn, site_id) as cur:
cur.execute(
"""
@@ -1212,9 +1284,10 @@ def get_coach_60d_stats(
WHERE site_assistant_id = %s
AND tenant_member_id = %s
AND is_delete = 0
AND create_time >= (CURRENT_DATE - INTERVAL '60 days')::timestamptz
AND create_time >= (%s::date - INTERVAL '60 days')::timestamptz
AND create_time < (%s::date + INTERVAL '1 day')::timestamptz
""",
(assistant_id, member_id),
(assistant_id, member_id, ref_date, ref_date),
)
row = cur.fetchone()
if not row:
@@ -1917,14 +1990,17 @@ def get_customer_board_recall(
total = cur.fetchone()[0]
# 分页数据
# CHANGE 2026-05-02 | elapsed_days/overdue_days 用 business_date 替代 CURRENT_DATE
from app.services.runtime_context import as_runtime_today_param as _rt_today
ref_date = _rt_today(site_id, conn=conn)
offset = (page - 1) * page_size
cur.execute(
f"""
SELECT wi.member_id,
dm.nickname,
wi.ideal_interval_days,
CURRENT_DATE - wi.last_visit_time::date AS elapsed_days,
(CURRENT_DATE - wi.last_visit_time::date) - COALESCE(wi.ideal_interval_days, 0) AS overdue_days,
%s::date - wi.last_visit_time::date AS elapsed_days,
(%s::date - wi.last_visit_time::date) - COALESCE(wi.ideal_interval_days, 0) AS overdue_days,
wi.visits_30d,
wi.display_score,
COALESCE(ca.balance, 0) AS balance
@@ -1937,11 +2013,11 @@ def get_customer_board_recall(
WHERE scd2_is_current = 1
GROUP BY tenant_member_id
) ca ON wi.member_id = ca.tenant_member_id
WHERE 1=1 {proj_clause}
WHERE wi.last_visit_time <= %s::date + INTERVAL '1 day' {proj_clause}
ORDER BY wi.display_score DESC, wi.member_id
LIMIT %s OFFSET %s
""",
(*proj_params, page_size, offset),
(ref_date, ref_date, ref_date, *proj_params, page_size, offset),
)
items = []
for row in cur.fetchall():
@@ -2165,6 +2241,10 @@ def get_customer_board_recharge(
)
total = cur.fetchone()[0]
# CHANGE 2026-05-02 | 60 天充值窗口、stat_date、pay_time 全部按 business_date 截断
from app.services.runtime_context import as_runtime_today_param
ref_date = as_runtime_today_param(site_id, conn=conn)
offset = (page - 1) * page_size
cur.execute(
f"""
@@ -2173,7 +2253,8 @@ def get_customer_board_recharge(
MAX(ro.pay_time::date) AS last_recharge_date,
SUM(ro.pay_amount) AS recharge_amount,
COUNT(*) FILTER (
WHERE ro.pay_time >= CURRENT_DATE - INTERVAL '60 days'
WHERE ro.pay_time >= %s::date - INTERVAL '60 days'
AND ro.pay_time < %s::date + INTERVAL '1 day'
) AS recharges_60d,
COALESCE(ca_agg.balance, 0) AS current_balance,
cs.days_since_last
@@ -2190,15 +2271,16 @@ def get_customer_board_recharge(
SELECT cs2.days_since_last
FROM app.v_dws_member_consumption_summary cs2
WHERE cs2.member_id = ro.member_id
AND cs2.stat_date <= %s
ORDER BY cs2.stat_date DESC
LIMIT 1
) cs ON true
WHERE 1=1 {proj_clause}
WHERE ro.pay_time <= %s::date + INTERVAL '1 day' {proj_clause}
GROUP BY ro.member_id, dm.nickname, ca_agg.balance, cs.days_since_last
ORDER BY MAX(ro.pay_time::date) DESC, ro.member_id
LIMIT %s OFFSET %s
""",
(*proj_params, page_size, offset),
(ref_date, ref_date, ref_date, ref_date, *proj_params, page_size, offset),
)
items = []
for row in cur.fetchall():
@@ -2228,6 +2310,13 @@ def get_customer_board_recent(
不再硬编码为 0。来源: v_dws_member_visit_detail + v_dim_member + v_dws_member_winback_index。
按 last_visit_date 降序。
"""
# CHANGE 2026-05-02 | 客户看板「最近到店」修复:
# 1) WHERE/COUNT 中的 30/60 天窗口按 business_date 计算,沙箱不读「未来」到店;
# 2) days_ago 用 business_date - last_visit_date与窗口对齐。
from app.services.runtime_context import as_runtime_today_param
ref_date = as_runtime_today_param(site_id, conn=conn)
proj_clause, proj_params = _project_filter_clause(project, "vd.member_id")
with _fdw_context(conn, site_id) as cur:
@@ -2235,9 +2324,9 @@ def get_customer_board_recent(
f"""
SELECT COUNT(DISTINCT vd.member_id)
FROM app.v_dws_member_visit_detail vd
WHERE 1=1 {proj_clause}
WHERE vd.visit_date <= %s {proj_clause}
""",
proj_params,
(ref_date, *proj_params),
)
total = cur.fetchone()[0]
@@ -2248,11 +2337,11 @@ def get_customer_board_recent(
SELECT vd.member_id,
MAX(vd.visit_date) AS last_visit_date,
COUNT(*) AS total_visits,
COUNT(*) FILTER (WHERE vd.visit_date >= CURRENT_DATE - INTERVAL '30 days') AS visits_30d,
COUNT(*) FILTER (WHERE vd.visit_date >= CURRENT_DATE - INTERVAL '60 days') AS visits_60d,
COUNT(*) FILTER (WHERE vd.visit_date >= %s::date - INTERVAL '30 days') AS visits_30d,
COUNT(*) FILTER (WHERE vd.visit_date >= %s::date - INTERVAL '60 days') AS visits_60d,
AVG(vd.total_consume) AS avg_spend
FROM app.v_dws_member_visit_detail vd
WHERE 1=1 {proj_clause}
WHERE vd.visit_date <= %s {proj_clause}
GROUP BY vd.member_id
)
SELECT ma.member_id,
@@ -2271,14 +2360,13 @@ def get_customer_board_recent(
ORDER BY ma.last_visit_date DESC, ma.member_id
LIMIT %s OFFSET %s
""",
(*proj_params, page_size, offset),
(ref_date, ref_date, ref_date, *proj_params, page_size, offset),
)
items = []
for row in cur.fetchall():
last_visit = row[2]
# CHANGE 2026-03-29 | 补充 days_ago距今天数和 visits_60d
from datetime import date as _date
days_ago = (_date.today() - last_visit).days if last_visit else None
# CHANGE 2026-05-02 | days_ago 按 business_date 计算,沙箱与窗口对齐
days_ago = (ref_date - last_visit).days if last_visit else None
items.append({
"member_id": row[0],
"name": row[1] or "",
@@ -2378,6 +2466,10 @@ def get_customer_board_freq60(
按 visit_count_60d 降序。
CHANGE 2026-04-08 | Fix同 spend60DISTINCT ON 取最新快照。
"""
# CHANGE 2026-05-02 | freq60 全链路按 business_date 截断stat_date <= ref_date + 8 周窗口)
from app.services.runtime_context import as_runtime_today_param
ref_date = as_runtime_today_param(site_id, conn=conn)
proj_clause, proj_params = _project_filter_clause(project, "cs.member_id")
with _fdw_context(conn, site_id) as cur:
@@ -2387,11 +2479,11 @@ def get_customer_board_freq60(
FROM (
SELECT DISTINCT ON (cs.member_id) cs.member_id
FROM app.v_dws_member_consumption_summary cs
WHERE 1=1 {proj_clause}
WHERE cs.stat_date <= %s {proj_clause}
ORDER BY cs.member_id, cs.stat_date DESC
) sub
""",
proj_params,
(ref_date, *proj_params),
)
total = cur.fetchone()[0]
@@ -2402,7 +2494,7 @@ def get_customer_board_freq60(
SELECT DISTINCT ON (cs.member_id)
cs.member_id, cs.visit_count_60d, cs.consume_amount_60d
FROM app.v_dws_member_consumption_summary cs
WHERE 1=1 {proj_clause}
WHERE cs.stat_date <= %s {proj_clause}
ORDER BY cs.member_id, cs.stat_date DESC
)
SELECT cs.member_id,
@@ -2415,7 +2507,7 @@ def get_customer_board_freq60(
ORDER BY cs.visit_count_60d DESC, cs.member_id
LIMIT %s OFFSET %s
""",
(*proj_params, page_size, offset),
(ref_date, *proj_params, page_size, offset),
)
items = []
member_ids = []
@@ -2436,21 +2528,31 @@ def get_customer_board_freq60(
# 批量查询 8 周到店数据
if member_ids:
weekly_map = _get_weekly_visits_batch(cur, member_ids)
weekly_map = _get_weekly_visits_batch(cur, member_ids, ref_date=ref_date)
for item in items:
item["weekly_visits"] = weekly_map.get(item["member_id"], _empty_weekly())
return {"items": items, "total": total, "page": page, "page_size": page_size}
def _get_weekly_visits_batch(cur: Any, member_ids: list[int]) -> dict[int, list[dict]]:
def _get_weekly_visits_batch(
cur: Any, member_ids: list[int], *, ref_date: Any = None,
) -> dict[int, list[dict]]:
"""
批量查询客户最近 8 周的到店次数(用于 freq60 维度柱状图)。
CHANGE 2026-04-07 | Fix-5数据源从 v_dwd_assistant_service_log 改为
v_dwd_settlement_headsettle_type IN (1,3)),与汇总维度口径一致。
CHANGE 2026-05-02 | 8 周窗口锚定 ref_date业务日沙箱不读「未来」。
返回 {member_id: [{val: int, pct: int}, ...]},固定 8 个元素。
"""
from datetime import date as _date, timedelta as _timedelta
if ref_date is None:
ref_date = _date.today()
elif hasattr(ref_date, "date") and not isinstance(ref_date, _date):
ref_date = ref_date.date()
cur.execute(
"""
WITH weekly AS (
@@ -2460,14 +2562,15 @@ def _get_weekly_visits_batch(cur: Any, member_ids: list[int]) -> dict[int, list[
FROM app.v_dwd_settlement_head
WHERE member_id = ANY(%s)
AND settle_type IN (1, 3)
AND pay_time >= CURRENT_DATE - INTERVAL '56 days'
AND pay_time >= %s::date - INTERVAL '56 days'
AND pay_time < %s::date + INTERVAL '1 day'
GROUP BY member_id, DATE_TRUNC('week', pay_time::date)
)
SELECT member_id, week_start, cnt
FROM weekly
ORDER BY member_id, week_start
""",
(member_ids,),
(member_ids, ref_date, ref_date),
)
from collections import defaultdict
@@ -2477,11 +2580,9 @@ def _get_weekly_visits_batch(cur: Any, member_ids: list[int]) -> dict[int, list[
week_key = row[1].date() if hasattr(row[1], 'date') else row[1]
raw[row[0]][str(week_key)] = row[2]
# 生成最近 8 周的周一日期
from datetime import date, timedelta
today = date.today()
this_monday = today - timedelta(days=today.weekday())
weeks = [this_monday - timedelta(weeks=i) for i in range(7, -1, -1)]
# 生成最近 8 周的周一日期,以业务日为锚
this_monday = ref_date - _timedelta(days=ref_date.weekday())
weeks = [this_monday - _timedelta(weeks=i) for i in range(7, -1, -1)]
result: dict[int, list[dict]] = {}
for mid in member_ids:

View File

@@ -259,6 +259,28 @@ async def create_note(
import asyncio
asyncio.create_task(_async_ai_score(note["id"], site_id, target_id, content))
# 触发 AI 备注分析链App6 → App8
# target_type='member' 时 target_id 即 member_id'assistant' 时不触发AI 只分析会员备注)
if target_type == "member":
try:
from app.services.trigger_scheduler import fire_event
fire_event(
"ai_note_created",
{
"site_id": site_id,
"member_id": target_id,
"note_content": content,
"noted_by_name": note.get("recorded_by_name")
or note.get("user_nickname") or "",
"noted_by_created_at": note.get("created_at") or "",
},
)
except Exception:
logger.exception(
"触发 ai_note_created 事件失败: note_id=%s member_id=%s",
note["id"], target_id,
)
return note
except HTTPException:

View File

@@ -22,6 +22,13 @@ import json
import logging
from datetime import timedelta
from app.services.runtime_context import (
LIVE_INSTANCE_ID,
MODE_LIVE,
MODE_SANDBOX,
get_runtime_context,
task_runtime_filter,
)
from app.trace.decorators import trace_service
logger = logging.getLogger(__name__)
@@ -141,6 +148,10 @@ def _process_site(conn, site_id: int) -> dict:
resolved = 0
from app.services.fdw_queries import _fdw_context
runtime_ctx = get_runtime_context(site_id, conn=conn)
runtime_now = runtime_ctx.business_now
runtime_mode = MODE_SANDBOX if runtime_ctx.is_sandbox else MODE_LIVE
sandbox_instance_id = runtime_ctx.sandbox_instance_id if runtime_ctx.is_sandbox else LIVE_INSTANCE_ID
# ── 1. 获取本门店所有 MAIN 关系对 ──
with _fdw_context(conn, site_id) as cur:
@@ -173,13 +184,14 @@ def _process_site(conn, site_id: int) -> dict:
ON sl.order_settle_id = sh.order_settle_id
AND sl.is_delete = 0
WHERE sh.member_id = ANY(%s)
AND sh.pay_time <= %s
AND (
sh.settle_type = 1
OR (sh.settle_type = 3 AND sl.order_assistant_type = 2)
)
GROUP BY sl.site_assistant_id, sh.member_id
""",
(member_ids,),
(member_ids, runtime_now),
)
for row in cur.fetchall():
settlement_map[(row[0], row[1])] = row[2]
@@ -190,6 +202,7 @@ def _process_site(conn, site_id: int) -> dict:
SELECT sh.member_id, MAX(sh.pay_time) AS latest_pay_time
FROM app.v_dwd_settlement_head sh
WHERE sh.member_id = ANY(%s)
AND sh.pay_time <= %s
AND (
sh.settle_type = 1
OR (sh.settle_type = 3 AND EXISTS (
@@ -201,7 +214,7 @@ def _process_site(conn, site_id: int) -> dict:
)
GROUP BY sh.member_id
""",
(member_ids,),
(member_ids, runtime_now),
)
member_visited_map = {}
for row in cur.fetchall():
@@ -209,16 +222,18 @@ def _process_site(conn, site_id: int) -> dict:
# ── 3. 获取本门店所有 active 的召回/回访任务(用于匹配) ──
active_tasks_map: dict[tuple[int, int], list] = {} # (assistant_id, member_id) → [(id, task_type, created_at)]
runtime_clause, runtime_params = task_runtime_filter(site_id, conn=conn)
with conn.cursor() as cur:
cur.execute(
"""
f"""
SELECT id, assistant_id, member_id, task_type, created_at
FROM biz.coach_tasks
WHERE site_id = %s
{runtime_clause}
AND status = 'active'
AND task_type IN ('high_priority_recall', 'priority_recall', 'follow_up_visit')
""",
(site_id,),
[site_id, *runtime_params],
)
for row in cur.fetchall():
key = (row[1], row[2])
@@ -238,7 +253,7 @@ def _process_site(conn, site_id: int) -> dict:
try:
result = _process_pair(
conn, site_id, assistant_id, member_id,
latest_pay, active_tasks,
latest_pay, active_tasks, runtime_now, runtime_mode, sandbox_instance_id,
)
completed += result["completed"]
events += result["events"]
@@ -257,25 +272,26 @@ def _process_site(conn, site_id: int) -> dict:
with conn.cursor() as cur:
cur.execute("BEGIN")
cur.execute(
"""
f"""
SELECT id, assistant_id, task_type, created_at
FROM biz.coach_tasks
WHERE site_id = %s AND member_id = %s
{runtime_clause}
AND status = 'active'
AND task_type IN ('high_priority_recall', 'priority_recall')
AND created_at < %s
""",
(site_id, member_id, pay_time),
[site_id, member_id, *runtime_params, pay_time],
)
remaining = cur.fetchall()
for task_id, aid, task_type, _ in remaining:
cur.execute(
"""
UPDATE biz.coach_tasks
SET status = 'resolved', updated_at = NOW()
SET status = 'resolved', updated_at = %s
WHERE id = %s AND status = 'active'
""",
(task_id,),
(runtime_now, task_id),
)
_insert_history(
cur, task_id,
@@ -308,6 +324,9 @@ def _process_pair(
member_id: int,
latest_pay_time,
active_tasks: list[dict],
runtime_now,
runtime_mode: str,
sandbox_instance_id: str,
) -> dict:
"""
处理单个 MAIN 关系对的召回检测。
@@ -339,14 +358,16 @@ def _process_pair(
cur.execute(
"""
INSERT INTO biz.recall_events
(site_id, assistant_id, member_id, pay_time, task_id, task_type)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (site_id, assistant_id, member_id, (date_trunc('day', pay_time AT TIME ZONE 'Asia/Shanghai')))
(site_id, assistant_id, member_id, pay_time, task_id, task_type,
created_at, runtime_mode, sandbox_instance_id)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (site_id, assistant_id, member_id, runtime_mode, sandbox_instance_id,
(date_trunc('day', pay_time AT TIME ZONE 'Asia/Shanghai')))
DO NOTHING
RETURNING id
""",
(site_id, assistant_id, member_id, latest_pay_time,
event_task_id, event_task_type),
event_task_id, event_task_type, runtime_now, runtime_mode, sandbox_instance_id),
)
inserted = cur.fetchone()
if inserted is None:
@@ -367,10 +388,10 @@ def _process_pair(
completed_at = %s,
completed_task_type = %s,
completion_type = 'auto',
updated_at = NOW()
updated_at = %s
WHERE id = %s AND status = 'active'
""",
(latest_pay_time, task["task_type"], task["id"]),
(latest_pay_time, task["task_type"], runtime_now, task["id"]),
)
_insert_history(
cur,
@@ -393,18 +414,19 @@ def _process_pair(
SELECT id 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 runtime_mode = %s AND sandbox_instance_id = %s
""",
(site_id, assistant_id, member_id),
(site_id, assistant_id, member_id, runtime_mode, sandbox_instance_id),
)
old_follow_ups = cur.fetchall()
for (old_id,) in old_follow_ups:
cur.execute(
"""
UPDATE biz.coach_tasks
SET status = 'inactive', updated_at = NOW()
SET status = 'inactive', updated_at = %s
WHERE id = %s
""",
(old_id,),
(runtime_now, old_id),
)
_insert_history(
cur, old_id,
@@ -423,11 +445,14 @@ def _process_pair(
"""
INSERT INTO biz.coach_tasks
(site_id, assistant_id, member_id, task_type, status,
expires_at, created_at, updated_at)
VALUES (%s, %s, %s, 'follow_up_visit', 'active', %s, NOW(), NOW())
expires_at, created_at, updated_at, runtime_mode, sandbox_instance_id)
VALUES (%s, %s, %s, 'follow_up_visit', 'active', %s, %s, %s, %s, %s)
RETURNING id
""",
(site_id, assistant_id, member_id, expires_at),
(
site_id, assistant_id, member_id, expires_at, runtime_now,
runtime_now, runtime_mode, sandbox_instance_id,
),
)
new_follow_up_id = cur.fetchone()[0]
_insert_history(

View File

@@ -0,0 +1,263 @@
# -*- coding: utf-8 -*-
"""业务运行上下文与业务时钟服务。
该模块是开发/测试沙箱的统一控制层:
- live 模式:沿用真实系统日期和正式数据。
- sandbox 模式:业务上假设今天是配置的历史日期,并用 sandbox_instance_id 隔离写入。
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from datetime import date, datetime, time, timedelta, timezone
from typing import Any
from app import config
_LOCAL_TZ = timezone(timedelta(hours=8))
MODE_LIVE = "live"
MODE_SANDBOX = "sandbox"
AI_MODE_LIVE = "live"
LIVE_INSTANCE_ID = "live"
@dataclass(frozen=True)
class RuntimeContext:
"""单门店当前业务运行上下文。"""
site_id: int
mode: str = MODE_LIVE
business_day_start_hour: int = config.BUSINESS_DAY_START_HOUR
sandbox_date: date | None = None
sandbox_instance_id: str | None = None
ai_mode: str = AI_MODE_LIVE
status: str = "active"
@property
def is_sandbox(self) -> bool:
return self.mode == MODE_SANDBOX and self.sandbox_date is not None
@property
def business_date(self) -> date:
if self.is_sandbox and self.sandbox_date is not None:
return self.sandbox_date
now = datetime.now(_LOCAL_TZ)
today = now.date()
if now.hour < self.business_day_start_hour:
return today - timedelta(days=1)
return today
@property
def business_now(self) -> datetime:
if not self.is_sandbox:
return datetime.now(_LOCAL_TZ)
now = datetime.now(_LOCAL_TZ)
return datetime.combine(self.business_date, now.timetz(), tzinfo=_LOCAL_TZ)
@property
def active_sandbox_instance_id(self) -> str | None:
if not self.is_sandbox:
return None
return self.sandbox_instance_id
def to_dict(self) -> dict[str, Any]:
return {
"site_id": self.site_id,
"mode": self.mode,
"business_day_start_hour": self.business_day_start_hour,
"business_date": self.business_date.isoformat(),
"business_now": self.business_now.isoformat(),
"sandbox_date": self.sandbox_date.isoformat() if self.sandbox_date else None,
"sandbox_instance_id": self.sandbox_instance_id,
"ai_mode": self.ai_mode,
"status": self.status,
"is_sandbox": self.is_sandbox,
}
def new_sandbox_instance_id() -> str:
"""生成新的沙箱实例 ID。"""
return f"sbx_{uuid.uuid4().hex[:24]}"
def _default_context(site_id: int) -> RuntimeContext:
return RuntimeContext(site_id=site_id)
def get_runtime_context(site_id: int, conn: Any | None = None) -> RuntimeContext:
"""读取门店运行上下文。
表不存在或未配置时降级为 live保证迁移前不影响正式链路。
"""
own_conn = conn is None
if own_conn:
from app.database import get_connection
conn = get_connection()
try:
with conn.cursor() as cur:
try:
cur.execute(
"""
SELECT mode, sandbox_date, sandbox_instance_id, ai_mode, status
FROM biz.site_runtime_context
WHERE site_id = %s
""",
(site_id,),
)
except Exception:
if own_conn:
conn.rollback()
return _default_context(site_id)
row = cur.fetchone()
if own_conn:
conn.commit()
finally:
if own_conn:
conn.close()
if not row:
return _default_context(site_id)
mode, sandbox_date, sandbox_instance_id, ai_mode, status = row
if mode not in (MODE_LIVE, MODE_SANDBOX):
mode = MODE_LIVE
if mode == MODE_SANDBOX and (sandbox_date is None or not sandbox_instance_id):
mode = MODE_LIVE
return RuntimeContext(
site_id=site_id,
mode=mode,
sandbox_date=sandbox_date,
sandbox_instance_id=sandbox_instance_id,
ai_mode=ai_mode or AI_MODE_LIVE,
status=status or "active",
)
def namespace_ai_target_id(site_id: int, target_id: str, conn: Any | None = None) -> str:
"""按当前上下文转换 AI cache target_id。
前端和调用方继续使用原始 target_id沙箱命名空间在后端统一处理。
"""
ctx = get_runtime_context(site_id, conn=conn)
if not ctx.is_sandbox or not ctx.sandbox_instance_id:
return target_id
return f"{ctx.sandbox_instance_id}:{target_id}"
def task_runtime_filter(
site_id: int,
*,
alias: str = "",
conn: Any | None = None,
) -> tuple[str, list[Any]]:
"""返回 coach_tasks 等表的运行上下文过滤条件。"""
ctx = get_runtime_context(site_id, conn=conn)
prefix = f"{alias}." if alias else ""
if ctx.is_sandbox and ctx.sandbox_instance_id:
return (
f" AND {prefix}runtime_mode = %s AND {prefix}sandbox_instance_id = %s",
[MODE_SANDBOX, ctx.sandbox_instance_id],
)
return (
f" AND COALESCE({prefix}runtime_mode, 'live') = %s "
f"AND COALESCE({prefix}sandbox_instance_id, %s) = %s",
[MODE_LIVE, LIVE_INSTANCE_ID, LIVE_INSTANCE_ID],
)
def runtime_insert_columns(site_id: int, conn: Any | None = None) -> tuple[str, str, list[Any]]:
"""返回 INSERT SQL 片段:列名、占位符和值。"""
ctx = get_runtime_context(site_id, conn=conn)
return (
"runtime_mode, sandbox_instance_id",
"%s, %s",
[
MODE_SANDBOX if ctx.is_sandbox else MODE_LIVE,
ctx.sandbox_instance_id if ctx.is_sandbox else LIVE_INSTANCE_ID,
],
)
def runtime_update_assignments(site_id: int, conn: Any | None = None) -> tuple[str, list[Any]]:
"""返回 UPDATE SQL 片段,用于把运行上下文写回已有记录。"""
ctx = get_runtime_context(site_id, conn=conn)
return (
"runtime_mode = %s, sandbox_instance_id = %s",
[
MODE_SANDBOX if ctx.is_sandbox else MODE_LIVE,
ctx.sandbox_instance_id if ctx.is_sandbox else LIVE_INSTANCE_ID,
],
)
def as_runtime_now_param(site_id: int, conn: Any | None = None) -> datetime:
"""返回可传给 SQL 的业务当前时间。"""
return get_runtime_context(site_id, conn=conn).business_now
def as_runtime_today_param(site_id: int, conn: Any | None = None) -> date:
"""返回可传给 SQL 的业务当前日期。"""
return get_runtime_context(site_id, conn=conn).business_date
def as_runtime_year_month_param(site_id: int, conn: Any | None = None) -> str:
"""返回 'YYYY-MM' 形式的业务年月,用于 performance 等月度查询。"""
bd = get_runtime_context(site_id, conn=conn).business_date
return f"{bd.year:04d}-{bd.month:02d}"
def as_runtime_business_now_str(site_id: int, conn: Any | None = None, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
"""返回业务当前时间的格式化字符串,用于 AI prompts 中的 current_time。"""
return get_runtime_context(site_id, conn=conn).business_now.strftime(fmt)
def business_date_upper_bound_sql(
site_id: int,
*,
column: str,
alias: str = "",
cast: str | None = None,
conn: Any | None = None,
) -> tuple[str, list[Any]]:
"""返回业务日上界 SQL 片段。
sandbox 模式下,强制把 ``column`` 限制在业务日及之前(避免读到「未来」数据)。
live 模式下返回空片段,不影响任何逻辑。
cast 用于把 timestamp/timestamptz 列裁剪成日期再比较,例如 ``cast='date'``。
"""
ctx = get_runtime_context(site_id, conn=conn)
if not ctx.is_sandbox:
return ("", [])
prefix = f"{alias}." if alias else ""
expr = f"{prefix}{column}"
if cast:
expr = f"({expr})::{cast}"
return (f" AND {expr} <= %s", [ctx.business_date])
def apply_runtime_session_vars(conn: Any, ctx: RuntimeContext | None = None, *, site_id: int | None = None) -> None:
"""在已有数据库连接上设置 ``app.current_business_date`` 等 GUC 变量。
供 RLS 视图层C 方案)使用:视图通过 ``current_setting('app.current_business_date', true)``
读取业务日,再对事实/维度表做日期上界裁剪。
无论 live / sandbox 都设置该变量live 下视图仍按真实 ``CURRENT_DATE`` 行为。
"""
if ctx is None:
if site_id is None:
raise ValueError("apply_runtime_session_vars 需要 ctx 或 site_id 之一")
ctx = get_runtime_context(site_id, conn=conn)
bd = ctx.business_date.isoformat()
mode = MODE_SANDBOX if ctx.is_sandbox else MODE_LIVE
with conn.cursor() as cur:
cur.execute(
"SELECT set_config('app.current_business_date', %s, true), "
"set_config('app.current_runtime_mode', %s, true)",
(bd, mode),
)

View File

@@ -11,6 +11,7 @@
import json
import logging
from app.services.runtime_context import as_runtime_now_param, task_runtime_filter
from app.trace.decorators import trace_service
logger = logging.getLogger(__name__)
@@ -71,32 +72,42 @@ def run() -> dict:
conn = _get_connection()
try:
# 查询所有已过期的 active 任务
# 查询所有已过期的 active 任务。沙箱模式按业务时间判断,并只处理当前运行实例。
expired_tasks = []
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()
cur.execute("SELECT site_id FROM biz.sites WHERE is_active = true")
site_ids = [row[0] for row in cur.fetchall()]
for site_id in site_ids:
runtime_now = as_runtime_now_param(site_id, conn=conn)
runtime_clause, runtime_params = task_runtime_filter(site_id, conn=conn)
cur.execute(
f"""
SELECT id, task_type, site_id
FROM biz.coach_tasks
WHERE site_id = %s
{runtime_clause}
AND expires_at IS NOT NULL
AND expires_at < %s
AND status = 'active'
""",
[site_id, *runtime_params, runtime_now],
)
expired_tasks.extend(cur.fetchall())
conn.commit()
# 逐条处理,每条独立事务
for task_id, task_type in expired_tasks:
for task_id, task_type, site_id in expired_tasks:
try:
runtime_now = as_runtime_now_param(site_id, conn=conn)
with conn.cursor() as cur:
cur.execute("BEGIN")
cur.execute(
"""
UPDATE biz.coach_tasks
SET status = 'inactive', updated_at = NOW()
SET status = 'inactive', updated_at = %s
WHERE id = %s AND status = 'active'
""",
(task_id,),
(runtime_now, task_id),
)
_insert_history(
cur,

View File

@@ -41,6 +41,13 @@ from dataclasses import dataclass
from enum import IntEnum
from app.trace.decorators import trace_service
from app.services.runtime_context import (
LIVE_INSTANCE_ID,
MODE_LIVE,
MODE_SANDBOX,
get_runtime_context,
task_runtime_filter,
)
class TaskPriority(IntEnum):
@@ -189,6 +196,14 @@ import logging
logger = logging.getLogger(__name__)
def _runtime_values(conn, site_id: int):
"""返回当前门店任务写入所需的运行上下文值。"""
ctx = get_runtime_context(site_id, conn=conn)
mode = MODE_SANDBOX if ctx.is_sandbox else MODE_LIVE
instance_id = ctx.sandbox_instance_id if ctx.is_sandbox else LIVE_INSTANCE_ID
return ctx, mode, instance_id, ctx.business_now
def _get_connection():
"""延迟导入 get_connection避免纯函数测试时触发模块级导入失败。"""
from app.database import get_connection
@@ -210,7 +225,10 @@ def run() -> dict:
返回: {"created": int, "replaced": int, "skipped": int, "transferred": int}
"""
from datetime import datetime, timezone
stats = {"created": 0, "replaced": 0, "skipped": 0, "transferred": 0}
run_started_at = datetime.now(timezone.utc)
conn = _get_connection()
try:
@@ -265,6 +283,14 @@ def run() -> dict:
)
conn.commit()
# ── 6. 触发 AI 消费事件 — 对本次 run 新建的任务逐个触发 ai_consumption_settled
# 仅按 created_at >= run_started_at 过滤(精确锁定本次新建),避免误触发历史任务。
# dispatcher 内部按 (event, member_id, site_id, date) 去重,重复触发无害。
try:
_fire_ai_consumption_events(conn, run_started_at)
except Exception:
logger.exception("ai_consumption_settled 事件触发失败(不影响任务生成主流程)")
finally:
conn.close()
@@ -278,6 +304,54 @@ def run() -> dict:
return stats
def _fire_ai_consumption_events(conn, run_started_at) -> None:
"""查询本次 run 新建的任务,对每条 (site_id, member_id, assistant_id) 触发 ai_consumption_settled。
has_assistant 恒为 True任务必然绑定助教
dispatcher 去重确保每 member 每天 AI 链路至多跑一次。
"""
from app.services.trigger_scheduler import fire_event
with conn.cursor() as cur:
cur.execute(
"""
SELECT DISTINCT site_id, member_id, assistant_id
FROM biz.coach_tasks
WHERE created_at >= %s
AND member_id IS NOT NULL
AND assistant_id IS NOT NULL
""",
(run_started_at,),
)
pairs = cur.fetchall()
conn.commit()
triggered = 0
for row in pairs:
site_id, member_id, assistant_id = row[0], row[1], row[2]
try:
fire_event(
"ai_consumption_settled",
{
"site_id": site_id,
"member_id": member_id,
"assistant_id": assistant_id,
"has_assistant": True,
},
)
triggered += 1
except Exception:
logger.exception(
"触发 ai_consumption_settled 失败: site_id=%s member_id=%s",
site_id, member_id,
)
logger.info(
"ai_consumption_settled 触发完成: 新建任务去重后 %d 个 member成功触发 %d",
len(pairs), triggered,
)
def _run_for_site(conn, site_id: int, stats: dict) -> None:
"""
单门店处理流程。
@@ -766,9 +840,10 @@ def _run_transfer_check(
w_ms = params["transfer_score_w_ms"]
w_ml = params["transfer_score_w_ml"]
from datetime import datetime, timezone
from app.services.runtime_context import as_runtime_now_param
now = datetime.now(timezone.utc)
# 业务时间锚sandbox 模式下用 business_now避免按真实时间把已转移很久的任务再算成候选
now = as_runtime_now_param(site_id, conn=conn)
for task_id, from_assistant_id, member_id, task_type, transfer_count, created_at in candidates:
# CHANGE 2026-03-29 | 用升级倍数判定是否触发转移
@@ -805,9 +880,7 @@ def _run_transfer_check(
)
entry_dates = {r[0]: r[1] for r in cur.fetchall()}
from datetime import datetime, timezone, timedelta
now = datetime.now(timezone.utc)
# 沿用上方 business_now避免「真实今天」的入驻时间保护
eligible = []
for a in pool:
aid = a["assistant_id"]

View File

@@ -37,6 +37,7 @@ from decimal import Decimal
from fastapi import HTTPException
from app.services import fdw_queries
from app.services.runtime_context import get_runtime_context, task_runtime_filter
from app.services.task_generator import compute_heart_icon
from app.trace.decorators import trace_service
@@ -114,15 +115,17 @@ def _verify_task_ownership(
- 不属于当前助教 → 403
- required_status 不匹配 → 409
"""
runtime_clause, runtime_params = task_runtime_filter(site_id, conn=conn)
with conn.cursor() as cur:
cur.execute(
"""
f"""
SELECT id, task_type, status, is_pinned, abandon_reason,
assistant_id, site_id
FROM biz.coach_tasks
WHERE id = %s
{runtime_clause}
""",
(task_id,),
[task_id, *runtime_params],
)
row = cur.fetchone()
@@ -166,22 +169,24 @@ async def get_task_list(user_id: int, site_id: int) -> list[dict]:
assistant_id = _get_assistant_id(conn, user_id, site_id)
# 查询有效 + 已放弃任务abandoned 排最后)
runtime_clause, runtime_params = task_runtime_filter(site_id, conn=conn)
with conn.cursor() as cur:
cur.execute(
"""
f"""
SELECT id, task_type, status, priority_score, is_pinned,
expires_at, created_at, member_id, abandon_reason
FROM biz.coach_tasks
WHERE site_id = %s
AND assistant_id = %s
AND status IN ('active', 'abandoned')
{runtime_clause}
ORDER BY
CASE WHEN status = 'abandoned' THEN 1 ELSE 0 END ASC,
is_pinned DESC,
priority_score DESC NULLS LAST,
created_at ASC
""",
(site_id, assistant_id),
[site_id, assistant_id, *runtime_params],
)
tasks = cur.fetchall()
conn.commit()
@@ -605,8 +610,9 @@ async def get_task_list_v2(
# 构建排除条件relationship_building + member_id 不在 RS 范围内
# 当排除列表为空时不加额外条件
exclude_clause = ""
query_params_count: list = [site_id, assistant_id, db_status]
query_params_page: list = [site_id, assistant_id, db_status]
runtime_clause, runtime_params = task_runtime_filter(site_id, conn=conn)
query_params_count: list = [site_id, assistant_id, db_status, *runtime_params]
query_params_page: list = [site_id, assistant_id, db_status, *runtime_params]
if rb_exclude_member_ids:
exclude_clause = (
" AND NOT (task_type = 'relationship_building' AND member_id = ANY(%s))"
@@ -621,6 +627,7 @@ async def get_task_list_v2(
SELECT COUNT(*)
FROM biz.coach_tasks
WHERE site_id = %s AND assistant_id = %s AND status = %s
{runtime_clause}
{exclude_clause}
""",
query_params_count,
@@ -636,6 +643,7 @@ async def get_task_list_v2(
expires_at, created_at, member_id, abandon_reason
FROM biz.coach_tasks
WHERE site_id = %s AND assistant_id = %s AND status = %s
{runtime_clause}
{exclude_clause}
ORDER BY is_pinned DESC,
priority_score DESC NULLS LAST,
@@ -669,9 +677,11 @@ async def get_task_list_v2(
recent60d_map: dict[int, dict] = {}
batch_data: dict | None = None
try:
from app.services.runtime_context import as_runtime_today_param
_ref_date = as_runtime_today_param(site_id, conn=conn)
batch_data = fdw_queries.batch_query_for_task_list(
conn, site_id, assistant_id, member_ids,
datetime.now().year, datetime.now().month,
_ref_date.year, _ref_date.month,
)
member_info_map = batch_data["member_info"]
balance_map = batch_data["balance"]
@@ -685,7 +695,11 @@ async def get_task_list_v2(
# ── 6. 查询 ai_cache 获取 aiSuggestion优雅降级 ──
ai_suggestion_map: dict[int, str] = {}
try:
member_id_strs = [str(mid) for mid in member_ids]
runtime_ctx = get_runtime_context(site_id, conn=conn)
if runtime_ctx.is_sandbox and runtime_ctx.sandbox_instance_id:
member_id_strs = [f"{runtime_ctx.sandbox_instance_id}:{mid}" for mid in member_ids]
else:
member_id_strs = [str(mid) for mid in member_ids]
with conn.cursor() as cur:
cur.execute(
"""
@@ -706,7 +720,8 @@ async def get_task_list_v2(
result = row[1] if isinstance(row[1], dict) else {}
summary = result.get("summary", "")
if summary:
ai_suggestion_map[int(target_id_str)] = summary
raw_target = target_id_str.split(":", 1)[-1]
ai_suggestion_map[int(raw_target)] = summary
conn.commit()
except Exception:
logger.warning("查询 ai_cache aiSuggestion 失败", exc_info=True)
@@ -802,8 +817,11 @@ def build_performance_summary(
当 batch_data 为 None 时(如无任务的空列表场景),回退到独立查询。
课时/档位/客户数从 monthly_summary每日更新取实时数据
不再依赖月初结算的 salary_calc。收入仍从 salary_calc 取(如有)。
CHANGE 2026-05-02 | now 改用 RuntimeContext.business_date沙箱不读「未来」月份。
"""
now = datetime.now()
from app.services.runtime_context import as_runtime_today_param
now = as_runtime_today_param(site_id, conn=conn)
year, month = now.year, now.month
if batch_data:
@@ -971,15 +989,17 @@ async def get_task_by_member(
try:
assistant_id = _get_assistant_id(conn, user_id, site_id)
runtime_clause, runtime_params = task_runtime_filter(site_id, conn=conn)
with conn.cursor() as cur:
cur.execute(
"""
f"""
SELECT id, task_type
FROM biz.coach_tasks
WHERE site_id = %s AND assistant_id = %s AND member_id = %s
AND status = 'active'
{runtime_clause}
""",
(site_id, assistant_id, member_id),
[site_id, assistant_id, member_id, *runtime_params],
)
rows = cur.fetchall()
@@ -1020,16 +1040,18 @@ async def get_task_detail(
assistant_id = _get_assistant_id(conn, user_id, site_id)
# ── 1. 查询任务基础信息 ──
runtime_clause, runtime_params = task_runtime_filter(site_id, conn=conn)
with conn.cursor() as cur:
cur.execute(
"""
f"""
SELECT id, task_type, status, priority_score, is_pinned,
expires_at, created_at, member_id, abandon_reason,
assistant_id, site_id
FROM biz.coach_tasks
WHERE id = %s
{runtime_clause}
""",
(task_id,),
[task_id, *runtime_params],
)
row = cur.fetchone()
@@ -1090,6 +1112,12 @@ async def get_task_detail(
# ── 3. 查询维客线索 ──
retention_clues = []
try:
runtime_ctx = get_runtime_context(site_id, conn=conn)
member_target_id = (
f"{runtime_ctx.sandbox_instance_id}:{member_id}"
if runtime_ctx.is_sandbox and runtime_ctx.sandbox_instance_id
else str(member_id)
)
with conn.cursor() as cur:
cur.execute(
"""
@@ -1136,7 +1164,7 @@ async def get_task_detail(
AND cache_type IN ('app4_analysis', 'app5_talking_points')
ORDER BY created_at DESC
""",
(str(member_id), site_id),
(member_target_id, site_id),
)
seen_types: set[str] = set()
for cache_row in cur.fetchall():
@@ -1173,8 +1201,10 @@ async def get_task_detail(
# CHANGE 2026-03-25 | 统计范围近60天列表不限
# 预估规则:当月且日期 ≤ 5号
from datetime import date, timedelta
today = date.today()
# CHANGE 2026-05-02 | today 改用 business_date沙箱不读「未来」60 天
from datetime import timedelta
from app.services.runtime_context import as_runtime_today_param
today = as_runtime_today_param(site_id, conn=conn)
cutoff_60d = today - timedelta(days=60)
is_estimate_month = today.day <= 5

View File

@@ -10,7 +10,10 @@
"""
from __future__ import annotations
import asyncio
import inspect
import logging
import threading
from datetime import datetime, timedelta, timezone
from typing import Any, Callable
@@ -19,6 +22,34 @@ from app.trace.decorators import trace_service
logger = logging.getLogger(__name__)
def _invoke_handler(handler: Callable, **kwargs: Any) -> Any:
"""统一调用 handler自动识别 sync / async。
- sync handler直接返回结果
- async handler
- 当前线程有 running loop → loop.create_task(coro),后台异步执行
- 当前线程无 running loop → 新起 daemon 线程跑 asyncio.run(coro),不阻塞调用方
说明fire_event / check_scheduled_jobs 是 sync 函数,但部分 handler
(如 dispatcher 注册的 AI 事件 handler是 async def本包装器保证正确调度。
"""
result = handler(**kwargs)
if not inspect.iscoroutine(result):
return result
try:
loop = asyncio.get_running_loop()
loop.create_task(result)
return None
except RuntimeError:
# 同步线程(无 running loop用后台线程异步执行 coroutine不阻塞调用方
threading.Thread(
target=lambda coro=result: asyncio.run(coro),
daemon=True,
).start()
return None
def _get_connection():
"""延迟导入 get_connection避免纯函数测试时触发模块级导入失败。"""
from app.database import get_connection
@@ -89,7 +120,8 @@ def fire_event(event_name: str, payload: dict[str, Any] | None = None) -> int:
continue
try:
# 将 job_id 传入 handlerhandler 在最终 commit 前更新 last_run_at
handler(payload=payload, job_id=job_id)
# async handler 经 _invoke_handler 自动调度
_invoke_handler(handler, payload=payload, job_id=job_id)
executed += 1
except Exception:
logger.exception("触发器 %s 执行失败", job_name)
@@ -136,7 +168,8 @@ def check_scheduled_jobs() -> int:
continue
try:
# cron/interval handler 接受 conn + job_id在最终 commit 前更新时间戳
handler(conn=conn, job_id=job_id)
# async handler 经 _invoke_handler 自动调度
_invoke_handler(handler, conn=conn, job_id=job_id)
# 计算 next_run_at 并更新(在 handler commit 后的新事务中)
next_run = _calculate_next_run(trigger_condition, trigger_config)
with conn.cursor() as cur:
@@ -276,7 +309,7 @@ def run_job_by_id(job_id: int) -> dict:
return {"success": False, "message": f"任务 {job_name} 未注册处理器"}
try:
handler()
_invoke_handler(handler)
# 更新 last_run_at 和 next_run_at
next_run = _calculate_next_run(trigger_condition, trigger_config)
with conn.cursor() as cur: