diff --git a/apps/backend/app/services/customer_service.py b/apps/backend/app/services/customer_service.py index d3e2238..a1cb7f6 100644 --- a/apps/backend/app/services/customer_service.py +++ b/apps/backend/app/services/customer_service.py @@ -24,6 +24,7 @@ from fastapi import HTTPException from decimal import Decimal from app.services import fdw_queries +from app.services.runtime_context import as_runtime_today_param from app.services.task_generator import compute_heart_icon from app.trace.decorators import trace_service @@ -458,7 +459,12 @@ def _build_coach_tasks( 构建 coachTasks 模块。 CHANGE 2026-03-29 | 性能优化:所有助教信息改为批量查询,消除 N+1 + CHANGE 2026-05-05 | F1-5b MP-3: lastService (updated_at) 加 business_date 上界,沙箱不读未来 """ + # 业务日:sandbox 取 sandbox_business_date,live 取 CURRENT_DATE。 + # 该值同时用于第一条 SQL(coach_tasks 上界)和后续 60 天统计(ref_date)。 + ref_date = as_runtime_today_param(site_id, conn=conn) + with conn.cursor() as cur: cur.execute( """ @@ -466,9 +472,10 @@ def _build_coach_tasks( FROM biz.coach_tasks WHERE member_id = %s AND status IN ('active', 'inactive') + AND updated_at < (%s::date + INTERVAL '1 day')::timestamptz ORDER BY created_at DESC """, - (customer_id,), + (customer_id, ref_date), ) rows = cur.fetchall() @@ -502,8 +509,7 @@ def _build_coach_tasks( # 批量查询 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) + # ref_date 已在方法开头取得,此处直接复用 stats_map: dict = {} try: with fdw_queries._fdw_context(conn, site_id) as cur: diff --git a/apps/miniprogram/miniprogram/pages/coach-service-records/coach-service-records.ts b/apps/miniprogram/miniprogram/pages/coach-service-records/coach-service-records.ts index 6c6f6c2..56d1853 100644 --- a/apps/miniprogram/miniprogram/pages/coach-service-records/coach-service-records.ts +++ b/apps/miniprogram/miniprogram/pages/coach-service-records/coach-service-records.ts @@ -12,6 +12,8 @@ * - 后端 /api/xcx/performance/records?coach_id=xxx,权限码 view_board_coach */ import { checkPageAccess } from '../../utils/auth-guard' +// CHANGE 2026-05-05 | F1-5b MP-5: 业务时钟接入,sandbox 模式按 sandbox_date 判断"当前月/预估" +import { getBusinessClock } from '../../utils/runtime-clock' import { fetchPerformanceRecords, fetchCoachBanner } from '../../services/api' import { nameToAvatarColor } from '../../utils/avatar-color' import { formatMoney, formatCount } from '../../utils/money' @@ -61,13 +63,18 @@ Page({ /** Banner 主标题:用助教名生成"<助教名>的业绩" */ pageTitle: '业绩明细', - /** 月份切换 */ + /** 月份切换(onLoad 内会用 getBusinessClock 覆盖) */ currentYear: new Date().getFullYear(), currentMonth: new Date().getMonth() + 1, monthLabel: '', canGoPrev: true, canGoNext: false, + /** 业务时钟缓存(onLoad 内由 getBusinessClock 写入,sandbox 模式取 sandbox_date) */ + clockYear: new Date().getFullYear(), + clockMonth: new Date().getMonth() + 1, + clockDay: new Date().getDate(), + /** 当月预估判断 */ isCurrentMonth: false, @@ -86,7 +93,7 @@ Page({ hasMore: false, }, - onLoad(options: Record) { + async onLoad(options: Record) { const coachIdNum = Number(options?.coachId) const coachId = Number.isFinite(coachIdNum) && coachIdNum > 0 ? coachIdNum : 0 if (coachId === 0) { @@ -95,12 +102,20 @@ Page({ setTimeout(() => wx.navigateBack({ fail: () => wx.switchTab({ url: '/pages/board-finance/board-finance' }) }), 1000) return } - const now = new Date() + // CHANGE 2026-05-05 | 业务时钟取代 new Date(),sandbox 模式下显示 sandbox_date 所在月 + const clock = await getBusinessClock() + const clockYear = clock.business_year + const clockMonth = clock.business_month + // business_date 形如 "2026-04-20",取 day 用于"当月前 5 日预估"判断 + const clockDay = Number(clock.business_date.slice(8, 10)) || new Date().getDate() this.setData({ coachId, - currentYear: now.getFullYear(), - currentMonth: now.getMonth() + 1, - monthLabel: `${now.getFullYear()}年${now.getMonth() + 1}月`, + currentYear: clockYear, + currentMonth: clockMonth, + monthLabel: `${clockYear}年${clockMonth}月`, + clockYear, + clockMonth, + clockDay, }) this.loadBanner() this.loadData() @@ -149,11 +164,11 @@ Page({ } wx.showLoading({ title: '加载中...', mask: true }) - const now = new Date() - const { currentYear, currentMonth } = this.data - const isCurrentMonth = currentYear === now.getFullYear() - && currentMonth === now.getMonth() + 1 - && now.getDate() <= 5 + // CHANGE 2026-05-05 | 业务时钟替代 new Date(),sandbox 模式下"当月预估"按 sandbox_date 判断 + const { currentYear, currentMonth, clockYear, clockMonth, clockDay } = this.data + const isCurrentMonth = currentYear === clockYear + && currentMonth === clockMonth + && clockDay <= 5 try { const res = await fetchPerformanceRecords({ @@ -253,11 +268,10 @@ Page({ if (currentMonth > 12) { currentMonth = 1; currentYear++ } } - const now = new Date() - const nowYear = now.getFullYear() - const nowMonth = now.getMonth() + 1 - const canGoNext = currentYear < nowYear || (currentYear === nowYear && currentMonth < nowMonth) - const isCurrentMonth = currentYear === nowYear && currentMonth === nowMonth && now.getDate() <= 5 + // CHANGE 2026-05-05 | 业务时钟替代 new Date(),sandbox 模式下"未来月"按 sandbox_date 判断 + const { clockYear, clockMonth, clockDay } = this.data + const canGoNext = currentYear < clockYear || (currentYear === clockYear && currentMonth < clockMonth) + const isCurrentMonth = currentYear === clockYear && currentMonth === clockMonth && clockDay <= 5 this.setData({ currentYear, diff --git a/docs/audit/changes/2026-05-05__wave1_f1_5b_mp3_lastservice_upper_bound.md b/docs/audit/changes/2026-05-05__wave1_f1_5b_mp3_lastservice_upper_bound.md new file mode 100644 index 0000000..976ba47 --- /dev/null +++ b/docs/audit/changes/2026-05-05__wave1_f1_5b_mp3_lastservice_upper_bound.md @@ -0,0 +1,87 @@ +# 2026-05-05 · F1-5b MP-3 customer-detail lastService 业务日上界裁剪 + +> Wave 1 / F1-5b Wave A 第 4 项任务(详见 `docs/_overview/wave1-findings/F1-5b-tasks.md` §4.2) +> +> 工作量评估 M / 2h(实际 ~ 1h),按 §3 五步流程完成。 + +## 背景 + +- 路径:小程序 `pages/customer-detail/customer-detail` → 后端 `_build_coach_tasks` → `biz.coach_tasks` +- 问题:F1-5b 排查中发现该方法第一条 SQL(直查 `biz.coach_tasks`)无任何时间过滤,sandbox 业务日下会读到"未来"更新的助教任务,违反 P20 沙箱契约"沙箱不读未来"。 +- 现象:sandbox=2026-04-20 时,coachTasks lastService 仍能展示 2026-05-01 的助教任务更新时间(实测有 2 条 task_id=8348/8347 落在 04-21 之后)。 + +## 改动清单 + +### Step 1 调研(Explore agent) + +确认目标方法:`apps/backend/app/services/customer_service.py:454-599 _build_coach_tasks`,模块级函数,被 `get_customer_detail()` 调用。第二条 SQL(60 天统计)已正确使用 `as_runtime_today_param`,但第一条 SQL 完全无时间过滤。 + +### Step 2 TDD 红 + +新增 `test_build_coach_tasks_business_date_upper_bound`(模块顶部 import + mock + 断言 SQL + 断言 ref_date 出现在 params),首次运行 RED:`AttributeError: module 'app.services.customer_service' does not have the attribute 'as_runtime_today_param'`。 + +### Step 3 实施 + +`apps/backend/app/services/customer_service.py`: + +1. **import 提升**:`from app.services.runtime_context import as_runtime_today_param` 从方法内 late import 提至模块顶部。 +2. **ref_date 提前**:`ref_date = as_runtime_today_param(site_id, conn=conn)` 移至方法第一行(供两段 SQL 共用)。 +3. **第一条 SQL 加上界**:`AND updated_at < (%s::date + INTERVAL '1 day')::timestamptz`,params 加 `ref_date`。 +4. 删除原 505-506 行的方法内 import 与重复 ref_date 调用。 + +### Step 4 双口径验证 + +**目标 member**: `2799207406946053`(15 条 active/inactive 任务,updated_at 跨 04-13 ~ 05-01)。 + +| 维度 | 4a live (today=05-05) | 4b sandbox=2026-04-20 | +|------|----------------------|----------------------| +| SQL probe 行数 | 15 | 13(裁剪 2 条) | +| SQL probe latest updated_at | 2026-05-01 01:12 | 2026-04-19 16:36 | +| UI coachTasks 数(RSI 过滤后) | 3 | 7 | +| UI lastService 最大值 | 04-19 16:36 | 04-19 16:36 | +| 是否出现 05-01 | 否(被 RSI 过滤) | 否(被 SQL 上界裁剪) | + +**关键证据**:5-01 的两条 task(8348/8347)在 sandbox=4-20 模式下完全消失于结果集。验证脚本: + +- `_DEL/walkthrough_f1_5b/step_mp3_4a_probe.py` — 数据探针 +- `_DEL/walkthrough_f1_5b/step_mp3_4a_4b_sql_verify.py` — SQL 双口径 +- `_DEL/walkthrough_f1_5b/step_mp3_4b_switch_sandbox.py` — sandbox 切换 +- `_DEL/walkthrough_f1_5b/step_mp3_4c_restore_live.py` — 切回 live + +### Step 5 审计 + +本文件。 + +## 影响范围 + +| 端 | 影响 | 验证 | +|----|------|------| +| 后端 `customer_service` | _build_coach_tasks 第一条 SQL 加 1 个 SQL 参数 | unit test + Playwright 走查 | +| 小程序 customer-detail | coachTasks 列表 / lastService 显示 | weixin-devtools-mcp 实地走查 PASS | +| FDW 链路 | 无变化(依然用既有 _fdw_context) | — | +| 性能 | ref_date 调用提前 1 次(get_runtime_context 无缓存,每请求都查 biz.site_runtime_context),影响可忽略 | — | +| admin-web | 无影响 | — | + +## 测试 + +- 新增:`apps/backend/tests/tests/unit/test_customer_detail.py::test_build_coach_tasks_business_date_upper_bound` PASS +- 既有:`test_build_coach_tasks_metrics` PASS(回归) +- 全文件 9 passed,1 failed (`test_build_consumption_records_nesting` 预先存在的 coaches 嵌套 0 != 2,与 MP-3 无关,本次不修) + +## 风险与未覆盖 + +- **预存 bug**:`test_build_consumption_records_nesting` 失败,登记到 Wave B 范围排查。 +- **同类待排查**:`_build_favorite_coaches` 中 `total_service_count`(全历史累计)在 sandbox 下也未裁未来,Wave B 一并处理(不在本任务范围)。 +- **RSI 视图**:`app.v_dws_member_assistant_relation_index` 在 sandbox 下取值会随 GUC `app.current_business_date` 变化,这正是 sandbox 任务数 7 ≠ live 任务数 3 的原因(由 `_fdw_context` 注入,非本次改动)。 + +## 回滚策略 + +```bash +git revert +``` + +回滚后第一条 SQL 恢复无时间过滤;不涉及 DB schema / 配置,无副作用。 + +## Co-Authored-By + +Claude Opus 4.7 (1M context) diff --git a/docs/audit/changes/2026-05-05__wave1_f1_5b_mp5_coach_service_records_clock.md b/docs/audit/changes/2026-05-05__wave1_f1_5b_mp5_coach_service_records_clock.md new file mode 100644 index 0000000..c489132 --- /dev/null +++ b/docs/audit/changes/2026-05-05__wave1_f1_5b_mp5_coach_service_records_clock.md @@ -0,0 +1,99 @@ +# 2026-05-05 · F1-5b MP-5 coach-service-records 接入业务时钟 + +> Wave 1 / F1-5b Wave A 第 5 项任务(详见 `docs/_overview/wave1-findings/F1-5b-tasks.md` §4.2) +> +> 工作量评估 M / 2h(实际 ~ 1h),按 §3 五步流程完成。 + +## 背景 + +- 路径:小程序 `pages/coach-service-records/coach-service-records`(管理者视角的助教业绩明细) +- 问题:页面以 `new Date()` 取"当前年月",sandbox 模式下: + - 默认月份是真实 today 而不是 sandbox_date 所在月,展示"未来月"业绩(空) + - `canGoNext` 防翻规则按真实 today 计算,允许跳到 sandbox business_date 之后的月份(违反"沙箱不读未来") + - "当月预估金额规则"(每月前 5 日)按真实日期判断,sandbox=4-20 时仍按 5-05 触发,逻辑错乱 + +## 改动清单 + +### Step 1 调研 + +确认目标:`coach-service-records.ts` 共 4 处 `new Date()` 调用(L65/89/152/256),分别用于 data 默认值、onLoad 初始化、loadData "当月预估"判断、switchMonth 边界判断。 + +确认对照模板:`customer-service-records.ts` 已在 2026-05-02 接入 getBusinessClock,采用 `async onLoad` + `business_year/business_month` 字段模式。 + +### Step 2 TDD + +小程序无单测框架,跳过 unit test,以 weixin-devtools-mcp 端到端走查作为验证主线。 + +### Step 3 实施 + +`apps/miniprogram/miniprogram/pages/coach-service-records/coach-service-records.ts`: + +1. **import getBusinessClock**:`from '../../utils/runtime-clock'`,标注 `CHANGE 2026-05-05 | F1-5b MP-5`。 +2. **data 新增 3 字段**:`clockYear / clockMonth / clockDay`,默认 fallback 为 `new Date()` 各部分。 +3. **onLoad 改 async**:`await getBusinessClock()`,从 `business_year` / `business_month` / `business_date` 解析 day,写入 data。`clockDay` 取 `business_date.slice(8, 10)` 解析。 +4. **loadData**:`isCurrentMonth` 改用 `clockYear/clockMonth/clockDay`,删除 `new Date()`。 +5. **switchMonth**:`canGoNext / isCurrentMonth` 同上改造。 +6. 仅保留 5 处 `new Date()` 在 data 默认值与 onLoad fallback 路径,作为 getBusinessClock 失败时的兜底。 + +### Step 4 双口径验证 + +**目标 coach**:assistant_id=3148987180059141(喵喵,4 月 42 条记录 latest=04-23) + +| 维度 | 4a live (today=2026-05-05) | 4b sandbox=2026-04-20 | +|------|---------------------------|----------------------| +| clockYear/Month/Day | 2026 / 5 / 5 | 2026 / 4 / 20 | +| monthLabel | 2026年5月 | 2026年4月 | +| canGoPrev | true | true | +| canGoNext | false(已是当月) | **false(防翻 5 月,关键)** | +| isCurrentMonth | true(05-05 ≤ 5) | false(20 > 5) | +| pageState | empty(5 月无数据) | normal | +| 总记录数 | -- | 35 笔 / ¥4,657 | +| first_group date | — | 2026-04-20 | + +**关键证据**: +- sandbox=4-20 时,first dateGroup 是 04-20,后续 04-18 / 04-17(逆序),无任何 04-21 之后的记录(后端 SQL 上界裁剪正确返回) +- `canGoNext=false` 阻止用户切到 5 月,防"读未来" + +**走查脚本**: +- `_DEL/walkthrough_f1_5b/step_mp5_probe_coach.py` — 找有 4 月数据的 coach +- 复用 `step_mp3_4b_switch_sandbox.py` 切 sandbox +- 复用 `step_mp3_4c_restore_live.py` 切回 live + +**注意事项**:`getBusinessClock` 有 60s 内存缓存,sandbox 切换后必须 relaunch 小程序才能让缓存失效,否则页面仍显示 live 时钟。这是 utils 层面的设计(由 `clearBusinessClockCache()` 提供主动清理),非本任务问题。 + +### Step 5 审计 + +本文件。 + +## 影响范围 + +| 端 | 影响 | 验证 | +|----|------|------| +| 小程序 coach-service-records | onLoad 改 async,4 处 new Date() 替换为 clockYear/Month/Day | weixin-devtools-mcp 双口径 PASS | +| 后端 | 无改动(后端已在 F1-5b T1/A1 完成上界裁剪) | — | +| getBusinessClock util | 无改动,沿用既有 60s 缓存 | — | +| admin-web | 无影响 | — | +| 其他小程序页面 | 无影响 | — | + +## 测试 + +- 小程序无单测框架,以端到端走查作为验证主线 +- 端到端 4a/4b 双口径全部 PASS + +## 风险与未覆盖 + +- **缓存切换体验**:用户在小程序中切 sandbox 后,需在小程序内显式 relaunch 或等 60 秒缓存过期。Wave B UI-3(AIDashboard sandbox 提示)若引入"切 sandbox 即刷新所有页面"机制,可联动调用 `clearBusinessClockCache()` 改善体验,本任务暂不处理。 +- **fallback 路径未实测**:getBusinessClock 失败时降级到 `new Date()`(localFallback),其行为继承既有 utils,本任务未单独验证。 +- **同类页面**:`performance-records.ts`(任务 tab 下助教个人页)已在 2026-05-02 接入 getBusinessClock,本次 MP-5 不涉及。 + +## 回滚策略 + +```bash +git revert +``` + +回滚后 4 处 new Date() 恢复;不涉及后端 / DB / 配置,无副作用。 + +## Co-Authored-By + +Claude Opus 4.7 (1M context)