微信小程序页面迁移校验之前 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

@@ -12,7 +12,7 @@
### 设计决策
1. **助教订单流水独立建表**:四项统计粒度为 `(site_id, assistant_id, stat_date)`,与现有 `dws_assistant_daily_detail` 粒度相同但语义不同daily_detail 聚焦服务时长/金额contribution 聚焦订单级流水分摊),独立建表避免字段膨胀。
1. **助教订单流水独立建表**:四项统计粒度为 `(site_id, assistant_id, stat_date)`,与现有 `dws_assistant_daily_detail` 粒度相同但语义不同daily_detail 聚焦服务时长/金额contribution 聚焦订单级流水分摊),独立建表避免字段膨胀。`stat_date` 为营业日(以 `BUSINESS_DAY_START_HOUR` 08:00 为日切点)。
2. **时效贡献流水计算为纯函数**:核心分摊算法(`compute_time_weighted_revenue`)设计为静态方法,输入为结构化的订单数据,输出为每名助教的贡献值。不依赖数据库,便于属性测试。
3. **惩罚检测在 transform 阶段完成**:定档折算惩罚的时间重叠检测和计算在 `AssistantDailyTask.transform` 中完成,不新建独立任务,因为惩罚字段与日度明细同粒度。
4. **充值统计复用现有 extract 模式**:在 `MemberConsumptionTask` 中新增一个 `_extract_recharge_stats` 方法,与现有的 `_extract_consumption_stats` 并行提取,在 transform 阶段合并。

View File

@@ -134,17 +134,17 @@
- **Property 9: 非 pending 申请审核拒绝**
- **Validates: Requirements 6.6**
- [ ] 6. 检查点 - 确保所有测试通过
- [x] 6. 检查点 - 确保所有测试通过
- 确保所有测试通过,如有问题请向用户确认。
- [ ] 7. 实现权限中间件和管理端路由
- [ ] 7.1 创建权限中间件 `apps/backend/app/middleware/permission.py`
- [x] 7. 实现权限中间件和管理端路由
- [x] 7.1 创建权限中间件 `apps/backend/app/middleware/permission.py`
- 实现 `require_permission(*permission_codes)` 依赖
- 实现 `require_approved()` 依赖
- 检查用户 status + 权限列表
- _Requirements: 9.1-9.4_
- [ ] 7.2 创建管理端审核路由 `apps/backend/app/routers/admin_applications.py`
- [x] 7.2 创建管理端审核路由 `apps/backend/app/routers/admin_applications.py`
- 实现 `GET /api/admin/applications`:查询申请列表
- 实现 `GET /api/admin/applications/{id}`:查询申请详情 + 候选匹配
- 实现 `POST /api/admin/applications/{id}/approve`:批准申请
@@ -152,51 +152,51 @@
-`apps/backend/app/main.py` 中注册路由
- _Requirements: 6.1-6.6, 5.1-5.6_
- [ ] 7.3 编写权限中间件拦截属性测试
- [x] 7.3 编写权限中间件拦截属性测试
- **Property 13: 权限中间件拦截正确性**
- **Validates: Requirements 8.3, 9.1, 9.2, 9.3**
- [ ] 7.4 编写多店铺角色独立分配属性测试
- [x] 7.4 编写多店铺角色独立分配属性测试
- **Property 11: 多店铺角色独立分配**
- **Validates: Requirements 8.1**
- [ ] 7.5 编写店铺切换令牌属性测试
- [x] 7.5 编写店铺切换令牌属性测试
- **Property 12: 店铺切换令牌正确性**
- **Validates: Requirements 8.2, 10.4**
- [ ] 8. 集成与端到端验证
- [ ] 8.1 更新 `apps/backend/app/config.py` 新增微信配置项
- [x] 8. 集成与端到端验证
- [x] 8.1 更新 `apps/backend/app/config.py` 新增微信配置项
- 新增 `WX_APPID``WX_SECRET``WX_DEV_MODE` 配置读取
- _Requirements: 3.1, 14.3_
- [ ] 8.2 更新 `apps/backend/app/main.py` 注册所有新路由
- [x] 8.2 更新 `apps/backend/app/main.py` 注册所有新路由
- 确保 `xcx_auth``admin_applications` 路由已注册
- 验证无路由冲突
- _Requirements: 全部_
- [ ] 8.3 实现开发模式 mock 登录端点
- [x] 8.3 实现开发模式 mock 登录端点
-`routers/xcx_auth.py` 中新增 `POST /api/xcx/dev-login`
- 仅在 `WX_DEV_MODE=true` 时注册
- 接受 `openid` 和可选 `status` 参数,直接查找/创建用户并返回 JWT
- _Requirements: 14.2, 14.3_
- [ ] 8.4 编写用户状态查询完整性属性测试
- [x] 8.4 编写用户状态查询完整性属性测试
- **Property 10: 用户状态查询完整性**
- **Validates: Requirements 7.1, 7.2**
- [ ] 8.5 编写 disabled 用户登录拒绝属性测试
- [x] 8.5 编写 disabled 用户登录拒绝属性测试
- **Property 3: disabled 用户登录拒绝**
- **Validates: Requirements 3.5**
- [ ] 9. 小程序认证前端页面
- [ ] 9.1 实现请求封装工具 `apps/miniprogram/miniprogram/utils/request.ts`
- [x] 9. 小程序认证前端页面
- [x] 9.1 实现请求封装工具 `apps/miniprogram/miniprogram/utils/request.ts`
- 统一请求封装:自动附加 Authorization header
- 401 时自动尝试 refresh_token 刷新
- 刷新失败时跳转 login 页面
- 后端 base URL 从配置读取(开发环境 `http://localhost:8000`
- _Requirements: 13.8_
- [ ] 9.2 实现登录页 `apps/miniprogram/miniprogram/pages/login/`
- [x] 9.2 实现登录页 `apps/miniprogram/miniprogram/pages/login/`
- 调用 `wx.login()` 获取 code
- 发送 code 到 `POST /api/xcx/login`
- 根据返回的 `user_status` 路由到对应页面
@@ -204,7 +204,7 @@
- 参考 H5 原型 `docs/h5_ui/pages/login.html`
- _Requirements: 13.1, 13.6, 13.7, 13.8_
- [ ] 9.3 实现申请表单页 `apps/miniprogram/miniprogram/pages/apply/`
- [x] 9.3 实现申请表单页 `apps/miniprogram/miniprogram/pages/apply/`
- 表单字段球房IDsite_code、申请身份、手机号、编号选填、昵称
- 前端校验site_code 格式2字母+3数字、手机号11位数字
- 提交到 `POST /api/xcx/apply`
@@ -212,7 +212,7 @@
- 参考 H5 原型 `docs/h5_ui/pages/apply.html`
- _Requirements: 13.2, 13.3_
- [ ] 9.4 实现审核等待页 `apps/miniprogram/miniprogram/pages/reviewing/`
- [x] 9.4 实现审核等待页 `apps/miniprogram/miniprogram/pages/reviewing/`
- 显示当前申请状态(审核中/已拒绝)
- 显示申请信息摘要球房ID、申请身份、手机号
- 拒绝时显示拒绝原因 + "重新申请"按钮
@@ -220,42 +220,68 @@
- 参考 H5 原型 `docs/h5_ui/pages/reviewing.html`
- _Requirements: 13.4, 13.5_
- [ ] 9.5 实现无权限页 `apps/miniprogram/miniprogram/pages/no-permission/`
- [x] 9.5 实现无权限页 `apps/miniprogram/miniprogram/pages/no-permission/`
- 显示账号已禁用提示
- 参考 H5 原型 `docs/h5_ui/pages/no-permission.html`
- _Requirements: 13.7_
- [ ] 9.6 更新 `app.ts``app.json`
- [x] 9.6 更新 `app.ts``app.json`
-`app.json` 中注册新页面login、apply、reviewing、no-permission
-`app.ts``onLaunch` 中实现自动登录逻辑
- 根据用户状态路由到对应页面
- 扩展 globalData 类型定义token、userInfo、currentSiteId、sites
- _Requirements: 13.8_
- [ ] 10. 前后端联调验证
- [ ] 10.1 编写联调指南文档 `apps/miniprogram/doc/auth-integration-guide.md`
- [x] 10. 前后端联调验证
- [x] 10.1 编写联调指南文档 `apps/miniprogram/doc/auth-integration-guide.md`
- 微信开发者工具项目导入配置说明
- 后端启动步骤(含 `WX_DEV_MODE=true` 配置)
- 测试流程mock 登录 → 申请 → 管理端审核 → 重新登录验证
- 常见问题排查
- _Requirements: 14.1, 14.4_
- [ ] 10.2 在微信开发者工具中执行联调验证
- [x] 10.2 在微信开发者工具中执行联调验证
- 验证登录流程wx.login → 后端 → JWT 返回
- 验证申请流程:表单提交 → 后端创建申请 → 审核等待页展示
- 验证状态路由pending/approved/rejected/disabled 各状态正确跳转
- 验证 token 刷新access_token 过期后自动刷新
- _Requirements: 14.1_
- [ ] 11. 最终检查点 - 确保所有测试通过
- 确保所有测试通过,如有问题请向用户确认。
- [x] 11. 属性测试全量运行100 次迭代)— ✅ 15/15 全部通过
- 前面各任务中的属性测试仅用 5 次迭代快速验证逻辑正确性
- 本任务集中对所有属性测试执行 100 次迭代,确保健壮性
- 运行脚本:`scripts/ops/_run_auth_pbt_full.py`
- 结果报告:`export/reports/auth_pbt_full_20260227_034401.md`
- 总耗时 375s15 个属性测试全部通过100 次迭代/每个)
- [x] 11.1 P1 迁移脚本幂等性 — ✅ 25.0s
- [x] 11.2 P2 登录创建/查找用户 — ✅ 49.8s
- [x] 11.3 P3 disabled 用户登录拒绝 — ✅ 37.9s
- [x] 11.4 P4 申请创建正确性 — ✅ 21.3s
- [x] 11.5 P5 手机号格式验证 — ✅ 2.5s
- [x] 11.6 P6 重复申请拒绝 — ✅ 24.7s
- [x] 11.7 P7 人员匹配合并正确性 — ✅ 14.9s
- [x] 11.8 P8 审核操作正确性 — ✅ 22.7s
- [x] 11.9 P9 非 pending 审核拒绝 — ✅ 18.8s
- [x] 11.10 P10 用户状态查询完整性 — ✅ 28.6s
- [x] 11.11 P11 多店铺角色独立分配 — ✅ 46.6s
- [x] 11.12 P12 店铺切换令牌正确性 — ✅ 45.3s
- [x] 11.13 P13 权限中间件拦截正确性 — ✅ 11.9s
- [x] 11.14 P14 JWT payload 结构一致性 — ✅ 4.6s
- [x] 11.15 P15 JWT 过期/无效拒绝 — ✅ 3.2s
- [x] 12. 最终检查点
- 任务 1-12 全部完成
- 15 个属性测试在 100 次迭代下全部通过(报告见 `export/reports/auth_pbt_full_20260227_034401.md`
- 小程序 4 个认证页面login/apply/reviewing/no-permission已创建
- app.ts / app.json 已更新为认证感知版本
- 联调指南文档已编写
## 备注
- 标记 `*` 的任务为可选,可跳过以加速 MVP
- 每个任务引用了具体的需求编号,确保可追溯
- 检查点确保增量验证
- 属性测试验证通用正确性属性hypothesis最少 100 次迭代)
- **属性测试策略**:开发阶段各任务中属性测试用 5 次迭代快速验证;任务 11 集中用 100 次迭代全量运行,逐个报告进度
- 单元测试验证具体例子和边界情况
- 所有数据库操作在测试库 `test_zqyy_app` 进行
- 迁移脚本放在 `db/zqyy_app/migrations/` 目录

View File

@@ -0,0 +1 @@
{"specId": "27029642-a405-4932-8c22-5bc54fad5173", "workflowType": "requirements-first", "specType": "feature"}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,278 @@
# 需求文档小程序核心业务模块miniapp-core-business
## 简介
本 SPEC 实现小程序的核心业务逻辑涵盖助教任务系统生成、分配、状态流转、完成检测、备注系统CRUD、星星评分、类型区分、以及后台触发器/轮询调度框架。系统基于 P1miniapp-db-foundation的数据库基础设施、P2etl-dws-miniapp-extensions的 DWS 指数数据、P3miniapp-auth-system的用户认证体系`test_zqyy_app.biz` Schema 中创建任务、备注、触发器等业务表,并在 FastAPI 后端实现对应的 API 端点和后台调度逻辑。
## 术语表
- **Task_Generator**:任务生成器,每日 4:00 后运行,基于 WBI/NCI/RS 指数为每个助教分配 4 种类型任务的后台服务
- **Task_Manager**:任务管理服务,负责任务 CRUD、置顶、放弃、状态流转的后端模块
- **Task_Expiry_Checker**:任务有效期轮询器,每小时检查 `expires_at` 并将过期任务标记为无效
- **Recall_Completion_Detector**召回完成检测器ETL 数据更新后检查助教是否为匹配客户提供了服务
- **Note_Reclassifier**:备注回溯重分类器,召回完成时回溯检查是否有普通备注需重分类为回访备注
- **Note_Service**:备注服务模块,负责备注 CRUD、星星评分存储与读取
- **Trigger_Scheduler**:触发器调度框架,支持 cron/interval/event 三种触发方式的统一调度引擎
- **coach_tasks**:助教任务表,位于 `biz` Schema存储任务分配、状态、有效期等信息
- **coach_task_history**:任务变更历史表,记录任务关闭/新建的追溯链
- **notes**:统一备注表,位于 `biz` Schema通过 `type` 字段区分普通备注/回访备注/放弃原因
- **trigger_jobs**:触发器配置表,位于 `biz` Schema存储轮询/事件触发器的配置与执行状态
- **task_type**:任务类型枚举,取值为 `high_priority_recall`(高优先召回)/ `priority_recall`(优先召回)/ `follow_up_visit`(客户回访)/ `relationship_building`(关系构建)
- **task_status**:任务状态枚举,取值为 `active`(有效)/ `inactive`(无效)/ `completed`(已完成)/ `abandoned`(已放弃)
- **note_type**:备注类型枚举,取值为 `normal`(普通备注)/ `follow_up`(回访备注)/ `abandon_reason`(放弃原因)
- **priority_score**:优先级分数,取 `max(WBI, NCI)` 的快照值,用于任务排序
- **expires_at**:有效期时间戳,默认 NULL无限期填充后表示任务将在该时间点过期
- **FDW**`postgres_fdw` 外部数据包装器,通过 `fdw_etl` Schema 读取 ETL 库指数数据
- **Migration_Script**:存放在 `db/zqyy_app/migrations/` 中的纯 SQL 迁移脚本,以日期前缀命名
- **site_id**:门店标识符,类型为 `BIGINT`,用于多门店数据隔离
- **member_retention_clue**:维客线索表,位于 `public` Schema存储助教为客户记录的维护线索大类 + 摘要 + 详情),独立于 ETL 数据。当前已有基础表结构和 CRUD API`/api/retention-clue`),若不足以支撑本 SPEC 的任务系统需求,可对其 DDL、Pydantic 模型及路由进行扩展或修改
## 需求
### 需求 1业务数据表创建
**用户故事:** 作为后端开发者,我需要在 `biz` Schema 中创建任务、备注、触发器等业务表,以便支撑核心业务功能。
#### 验收标准
1. WHEN Migration_Script 执行完成, THE Task_Manager SHALL 在 `biz` Schema 中创建 `coach_tasks` 表,包含 `id`BIGSERIAL PK`site_id`BIGINT NOT NULL`assistant_id`BIGINT NOT NULL`member_id`BIGINT NOT NULL`task_type`VARCHAR NOT NULL`status`VARCHAR NOT NULL DEFAULT 'active')、`priority_score`NUMERIC(5,2))、`expires_at`TIMESTAMPTZ可空`is_pinned`BOOLEAN DEFAULT FALSE`abandon_reason`TEXT可空`completed_at`TIMESTAMPTZ可空`completed_task_type`VARCHAR可空`parent_task_id`BIGINT可空FK → coach_tasks`created_at`TIMESTAMPTZ DEFAULT NOW())、`updated_at`TIMESTAMPTZ DEFAULT NOW())字段
2. WHEN Migration_Script 执行完成, THE Task_Manager SHALL 在 `biz` Schema 中创建 `coach_task_history` 表,包含 `id`BIGSERIAL PK`task_id`BIGINT FK → coach_tasks`action`VARCHAR NOT NULL`old_status`VARCHAR`new_status`VARCHAR`old_task_type`VARCHAR`new_task_type`VARCHAR`detail`JSONB`created_at`TIMESTAMPTZ DEFAULT NOW())字段
3. WHEN Migration_Script 执行完成, THE Note_Service SHALL 在 `biz` Schema 中创建 `notes` 表,包含 `id`BIGSERIAL PK`site_id`BIGINT NOT NULL`user_id`INTEGER NOT NULL`target_type`VARCHAR NOT NULL`target_id`BIGINT NOT NULL`type`VARCHAR NOT NULL DEFAULT 'normal')、`content`TEXT NOT NULL`rating_service_willingness`SMALLINT可空CHECK 1-5`rating_revisit_likelihood`SMALLINT可空CHECK 1-5`task_id`BIGINT可空FK → coach_tasks`ai_score`SMALLINT可空`ai_analysis`TEXT可空`created_at`TIMESTAMPTZ DEFAULT NOW())、`updated_at`TIMESTAMPTZ DEFAULT NOW())字段
4. WHEN Migration_Script 执行完成, THE Trigger_Scheduler SHALL 在 `biz` Schema 中创建 `trigger_jobs` 表,包含 `id`SERIAL PK`job_type`VARCHAR NOT NULL`job_name`VARCHAR NOT NULL UNIQUE`trigger_condition`VARCHAR NOT NULL`trigger_config`JSONB NOT NULL`last_run_at`TIMESTAMPTZ可空`next_run_at`TIMESTAMPTZ可空`status`VARCHAR NOT NULL DEFAULT 'enabled')、`created_at`TIMESTAMPTZ DEFAULT NOW())字段
5. THE Migration_Script SHALL 对 `coach_tasks` 表创建唯一索引 `idx_coach_tasks_site_assistant_member_type``(site_id, assistant_id, member_id, task_type)` 上,仅对 `status = 'active'` 的记录生效(部分唯一索引)
6. THE Migration_Script SHALL 对 `coach_tasks` 表创建索引 `idx_coach_tasks_assistant_status``(site_id, assistant_id, status)` 上,用于助教任务列表查询
7. THE Migration_Script SHALL 对 `notes` 表创建索引 `idx_notes_target``(site_id, target_type, target_id)` 上,用于按目标查询备注
8. THE Migration_Script SHALL 使用 `IF NOT EXISTS` 幂等语法,确保重复执行不会报错
9. THE Migration_Script SHALL 在脚本中包含回滚语句(以注释形式)
### 需求 2触发器种子数据预置
**用户故事:** 作为系统管理员,我需要系统预置核心触发器配置,以便后台调度任务自动运行。
#### 验收标准
1. WHEN 种子数据脚本执行完成, THE Trigger_Scheduler SHALL 在 `biz.trigger_jobs` 表中插入 `task_generator` 记录trigger_condition='cron'trigger_config 包含 cron 表达式 '0 4 * * *'
2. WHEN 种子数据脚本执行完成, THE Trigger_Scheduler SHALL 在 `biz.trigger_jobs` 表中插入 `task_expiry_check` 记录trigger_condition='interval'trigger_config 包含间隔秒数 3600
3. WHEN 种子数据脚本执行完成, THE Trigger_Scheduler SHALL 在 `biz.trigger_jobs` 表中插入 `recall_completion_check` 记录trigger_condition='event'trigger_config 包含事件名 'etl_data_updated'
4. WHEN 种子数据脚本执行完成, THE Trigger_Scheduler SHALL 在 `biz.trigger_jobs` 表中插入 `note_reclassify_backfill` 记录trigger_condition='event'trigger_config 包含事件名 'recall_completed'
5. THE 种子数据脚本 SHALL 使用 `ON CONFLICT (job_name) DO NOTHING` 语法,确保重复执行不会产生重复数据
### 需求 3任务生成器
**用户故事:** 作为助教,我每天打开小程序能看到系统为我分配的任务列表,按优先级排序。
#### 验收标准
1. WHEN Task_Generator 运行时, THE Task_Generator SHALL 通过 FDW 读取 `fdw_etl` 中的 `dws_member_winback_index`WBI`dws_member_newconv_index`NCI指数数据计算 `priority_score = max(WBI, NCI)`
2. WHEN `priority_score > 7`, THE Task_Generator SHALL 为该客户-助教对生成 `high_priority_recall`(高优先召回)类型任务
3. WHEN `priority_score > 5``priority_score <= 7`, THE Task_Generator SHALL 为该客户-助教对生成 `priority_recall`(优先召回)类型任务
4. WHEN 助教完成某客户的召回任务后该客户无回访备注, THE Task_Generator SHALL 为该客户-助教对生成 `follow_up_visit`(客户回访)类型任务
5. WHEN 客户-助教对的 RS 指数 < 6通过 FDW 读取 `dws_member_assistant_relation_index`, THE Task_Generator SHALL 为该客户-助教对生成 `relationship_building`(关系构建)类型任务
6. WHEN Task_Generator 生成任务时发现已存在相同 `(site_id, assistant_id, member_id, task_type)``status = 'active'` 的任务, THE Task_Generator SHALL 跳过该任务不做任何操作
7. WHEN Task_Generator 生成任务时发现已存在相同 `(site_id, assistant_id, member_id)``task_type` 不同且 `status = 'active'` 的任务, THE Task_Generator SHALL 将旧任务状态设为 `inactive`,创建新任务,并在 `coach_task_history` 中记录变更
8. THE Task_Generator SHALL 按优先级从高到低的顺序处理任务类型:`high_priority_recall`0> `priority_recall`0> `follow_up_visit`1> `relationship_building`2高优先级任务覆盖低优先级任务
9. THE Task_Generator SHALL 通过 `auth.user_assistant_binding` 确定助教与小程序用户的映射关系,仅为已绑定的助教生成任务
10. THE Task_Generator SHALL 在 `trigger_jobs` 中更新 `last_run_at``next_run_at` 时间戳
### 需求 448 小时回访滞留机制
**用户故事:** 作为系统,回访任务至少保留 48 小时,到期后自动失效。
#### 验收标准
1. WHEN Task_Generator 生成 `follow_up_visit` 类型任务时, THE Task_Generator SHALL 将 `expires_at` 设为 NULL无限期有效`status` 设为 `active`
2. WHEN Task_Generator 检测到某 `follow_up_visit` 任务的触发条件不再满足(指数变化), THE Task_Generator SHALL 将该任务的 `expires_at` 填充为 `created_at + 48 小时``status` 保持 `active`
3. WHEN Task_Expiry_Checker 轮询检查时发现某任务的 `expires_at` 不为 NULL 且当前时间超过 `expires_at`, THE Task_Expiry_Checker SHALL 将该任务 `status` 设为 `inactive`
4. WHEN 新的 `follow_up_visit` 任务生成时发现同一 `(site_id, assistant_id, member_id)` 已存在一个有 `expires_at``follow_up_visit` 任务, THE Task_Generator SHALL 将旧任务标记为 `inactive`,创建新的 `active` 任务(`expires_at` 为 NULL
5. THE Task_Expiry_Checker SHALL 每小时运行一次,由 `trigger_jobs` 中的 `task_expiry_check` 配置驱动
### 需求 5任务类型变更与状态流转
**用户故事:** 作为系统,当客户指数变化导致任务类型变更时,系统正确关闭旧任务并创建新任务。
#### 验收标准
1. WHEN 任务类型从 `priority_recall` 变更为 `high_priority_recall`, THE Task_Generator SHALL 将旧 `priority_recall` 任务标记为 `inactive``expires_at` 保持 NULL创建新的 `high_priority_recall` 任务
2. WHEN 任务类型从 `follow_up_visit` 变更为 `high_priority_recall``priority_recall`, THE Task_Generator SHALL 将旧 `follow_up_visit` 任务标记为 `active` 并填充 `expires_at = created_at + 48 小时`,创建新的召回任务
3. WHEN 任务类型从召回类型变回 `follow_up_visit`, THE Task_Generator SHALL 检查是否存在有 `expires_at` 的旧 `follow_up_visit` 任务,若存在则将旧任务标记为 `inactive`,创建新的 `follow_up_visit` 任务
4. THE Task_Manager SHALL 在每次状态变更时在 `coach_task_history` 中记录 `action``old_status``new_status``old_task_type``new_task_type`
### 需求 6召回完成检测
**用户故事:** 作为助教,我完成召回任务后(客户到店被服务),系统自动标记任务完成。
#### 验收标准
1. WHEN ETL 数据更新后, THE Recall_Completion_Detector SHALL 通过 FDW 读取 `fdw_etl.dwd_assistant_service_log` 中的新增服务记录
2. WHEN 发现某助教为某客户提供了服务, THE Recall_Completion_Detector SHALL 查找该 `(site_id, assistant_id, member_id)` 下所有 `status = 'active'` 的任务
3. WHEN 匹配到活跃任务, THE Recall_Completion_Detector SHALL 将任务 `status` 设为 `completed`,记录 `completed_at` 为服务时间,记录 `completed_task_type` 为完成时的任务类型
4. WHEN 召回完成后, THE Recall_Completion_Detector SHALL 触发 `note_reclassify_backfill` 事件,通知 Note_Reclassifier 执行备注回溯
5. THE Recall_Completion_Detector SHALL 由 `trigger_jobs` 中的 `recall_completion_check` 配置驱动,在 ETL 数据更新事件后触发
### 需求 7备注回溯重分类
**用户故事:** 作为系统,当 ETL 数据延迟导致召回完成晚于备注提交时,需要回溯重分类备注。
#### 验收标准
1. WHEN 召回完成事件触发后, THE Note_Reclassifier SHALL 查找该 `(site_id, assistant_id, member_id)` 在召回服务结束时间之后提交的第一条 `type = 'normal'` 的备注
2. WHEN 找到符合条件的普通备注, THE Note_Reclassifier SHALL 将该备注的 `type``normal` 更新为 `follow_up`
3. WHEN 备注重分类完成后, THE Note_Reclassifier SHALL 触发 AI 应用 6 对该备注进行含金量评分(评分逻辑由 P5 AI 集成层实现,本 SPEC 仅定义触发接口)
4. WHEN AI 应用 6 返回评分 >= 6, THE Note_Reclassifier SHALL 生成一条 `follow_up_visit` 任务并标记为 `completed`(回溯完成)
5. WHEN AI 应用 6 返回评分 < 6, THE Note_Reclassifier SHALL 生成一条 `follow_up_visit` 任务,`status``active`(回访未完成,需助教重新备注)
### 需求 8任务 CRUD API
**用户故事:** 作为助教,我可以查看任务列表、置顶/放弃任务、取消置顶/取消放弃。
#### 验收标准
1. WHEN 助教请求任务列表, THE Task_Manager SHALL 返回该助教在当前 `site_id` 下所有 `status = 'active'` 的任务,按 `is_pinned DESC, priority_score DESC, created_at ASC` 排序
2. WHEN 助教请求任务列表, THE Task_Manager SHALL 在每条任务中包含客户基本信息(通过 FDW 读取 `dim_member`、RS 指数(通过 FDW 读取 `dws_member_assistant_relation_index`)、爱心 icon 档位(💖>8.5 / 🧡>7 / 💛>5 / 💙<5
3. WHEN 助教置顶某任务, THE Task_Manager SHALL 将该任务的 `is_pinned` 设为 TRUE并在 `coach_task_history` 中记录
4. WHEN 助教放弃某任务, THE Task_Manager SHALL 将该任务 `status` 设为 `abandoned`,记录 `abandon_reason`(必填),并在 `coach_task_history` 中记录
5. WHEN 助教取消置顶某任务, THE Task_Manager SHALL 将该任务的 `is_pinned` 设为 FALSE
6. WHEN 助教取消放弃某任务, THE Task_Manager SHALL 将该任务 `status` 恢复为 `active`,清空 `abandon_reason`
7. IF 助教放弃任务时未提供 `abandon_reason`, THEN THE Task_Manager SHALL 返回 HTTP 422 错误
8. THE Task_Manager SHALL 通过 Permission_Middleware 验证用户身份,仅允许操作自己的任务
### 需求 9备注 CRUD API
**用户故事:** 作为助教,我给客户添加备注后,系统正确存储备注内容和星星评分。
#### 验收标准
1. WHEN 助教创建备注时, THE Note_Service SHALL 在 `biz.notes` 表中创建记录,包含 `site_id``user_id``target_type`'member')、`target_id`member_id`type``content`、可选的 `rating_service_willingness`1-5、可选的 `rating_revisit_likelihood`1-5、可选的 `task_id`
2. WHEN 备注关联的任务类型为 `follow_up_visit`, THE Note_Service SHALL 将备注 `type` 自动设为 `follow_up`
3. WHEN 备注关联的任务类型不是 `follow_up_visit`, THE Note_Service SHALL 将备注 `type` 设为 `normal`
4. WHEN 备注创建成功且 `type = 'follow_up'`, THE Note_Service SHALL 触发 AI 应用 6 备注分析接口(由 P5 实现),传入备注内容和客户信息
5. WHEN AI 应用 6 返回评分 >= 6 且备注关联的 `follow_up_visit` 任务 `status = 'active'`, THE Note_Service SHALL 将该任务标记为 `completed`
6. WHEN 助教查询某客户的备注列表, THE Note_Service SHALL 返回该客户在当前 `site_id` 下的所有备注,按 `created_at DESC` 排序,包含星星评分和 AI 评分
7. WHEN 助教删除备注, THE Note_Service SHALL 执行软删除或硬删除(根据业务需要),删除前需二次确认(前端实现)
8. IF 星星评分值不在 1-5 范围内, THEN THE Note_Service SHALL 返回 HTTP 422 错误
9. THE Note_Service 的星星评分 SHALL 不参与回访完成判定(完成判定仅看 AI 应用 6 评分 >= 6不参与 AI 应用 6 分析,仅作辅助数据存储
### 需求 10触发器调度框架
**用户故事:** 作为系统,我需要一个统一的触发器调度框架,支持定时、间隔、事件驱动三种触发方式。
#### 验收标准
1. THE Trigger_Scheduler SHALL 支持 `cron` 类型触发器,按 cron 表达式计算下次运行时间
2. THE Trigger_Scheduler SHALL 支持 `interval` 类型触发器,按固定间隔秒数计算下次运行时间
3. THE Trigger_Scheduler SHALL 支持 `event` 类型触发器,在指定事件发生时立即执行
4. WHEN 触发器执行完成, THE Trigger_Scheduler SHALL 更新 `trigger_jobs` 表中的 `last_run_at``next_run_at`
5. WHEN 触发器 `status = 'disabled'`, THE Trigger_Scheduler SHALL 跳过该触发器不执行
6. THE Trigger_Scheduler SHALL 提供 `fire_event(event_name, payload)` 方法,用于触发事件驱动型任务
7. IF 触发器执行过程中发生错误, THEN THE Trigger_Scheduler SHALL 记录错误日志但不中断其他触发器的执行
### 需求 11迁移脚本管理
**用户故事:** 作为后端开发者,我需要所有数据库变更都有对应的迁移脚本,以便变更可追溯、可重放。
#### 验收标准
1. THE Migration_Script SHALL 将所有业务表的 DDL 存放在 `db/zqyy_app/migrations/` 目录中
2. THE Migration_Script SHALL 使用日期前缀命名(格式:`YYYY-MM-DD__<描述>.sql`
3. THE Migration_Script SHALL 使用 UTF-8 编码,纯 SQL非 ORM
4. THE Migration_Script SHALL 在每个脚本中包含回滚语句(以注释形式)
5. THE Migration_Script SHALL 使用幂等语法(`IF NOT EXISTS``ON CONFLICT DO NOTHING`),确保重复执行不会报错
### 需求 12DDL 测试库落库与文档同步
**用户故事:** 作为后端开发者,我需要所有 DDL 变更在测试库中实际执行验证,并同步更新数据库手册和 DDL 基线。
#### 验收标准
1. WHEN 迁移脚本编写完成, THE Task_Manager SHALL 在 `test_zqyy_app` 测试库中执行迁移脚本,验证无错误
2. WHEN 迁移脚本执行成功, THE Task_Manager SHALL 创建或更新 `docs/database/BD_Manual_biz_tables.md` 数据库手册,包含变更说明、兼容性影响、回滚策略、验证 SQL至少 3 条)
3. WHEN 迁移脚本执行成功, THE Task_Manager SHALL 运行 `python scripts/ops/gen_consolidated_ddl.py` 重新生成 DDL 基线文件
4. WHEN 种子数据脚本执行成功, THE Task_Manager SHALL 在数据库手册中记录种子数据内容(触发器配置)
### 需求 13小程序前端页面原型还原强制
**用户故事:** 作为产品经理,我需要小程序前端页面严格忠于 `docs/h5_ui/pages/` 中的 H5 原型图结构和视觉细节,确保最终实现与设计稿高度一致。
#### 原型图索引
| 原型文件 | 对应小程序页面 | 说明 |
|---------|--------------|------|
| `docs/h5_ui/pages/task-list.html` | `pages/task-list/task-list` | 任务列表页(首页),含业绩进度卡片、置顶/一般/已放弃三区域 |
| `docs/h5_ui/pages/task-detail.html` | `pages/task-detail/task-detail` | 任务详情页 - 高优先召回theme-red Banner |
| `docs/h5_ui/pages/task-detail-priority.html` | `pages/task-detail/task-detail` | 任务详情页 - 优先召回theme-orange Banner |
| `docs/h5_ui/pages/task-detail-relationship.html` | `pages/task-detail/task-detail` | 任务详情页 - 关系构建theme-pink Banner |
| `docs/h5_ui/pages/task-detail-callback.html` | `pages/task-detail/task-detail` | 任务详情页 - 客户回访theme-teal Banner |
| `docs/h5_ui/pages/notes.html` | `pages/notes/notes` | 备注记录页 |
| `docs/h5_ui/pages/customer-detail.html` | `pages/customer-detail/customer-detail` | 客户详情页 |
#### 验收标准
##### 13.A 结构还原(强制)
1. WHEN 实现任务列表页时, THE 小程序页面 SHALL 严格还原原型图中的以下结构层次:顶部用户信息区(头像 + 姓名 + 角色标签 + 门店名)→ 业绩进度卡片5 段档位进度条 + 课时数据含红戳 + 奖金激励 + 预计收入)→ 任务列表区(📌 置顶区 / 一般任务区 / 已放弃区三个分区,每个分区有标签 + 计数)
2. WHEN 实现任务卡片时, THE 每张任务卡片 SHALL 包含原型图中的全部元素:左侧 4px 彩色边框(高优先=红、优先=橙、关系构建=粉、客户回访=青)、任务类型标签(渐变色圆角矩形)、客户姓名、爱心 icon💖/🧡/💛/💙)、备注指示器(📝)、描述行(最近到店 + 余额、AI 建议行(含 AI 机器人 icon、右侧箭头
3. WHEN 实现任务详情页时, THE 页面 SHALL 严格还原原型图中的以下模块顺序:通栏 Banner导航栏 + 客户信息 + 放弃按钮)→ 维客线索卡片(客户基础/消费习惯/玩法偏好/重要反馈,每条含大类标签 + 摘要 + 详情 + 来源标注)→ 与我的关系卡片(爱心档位标签 + 进度条 + RS 分数 + 描述 + 近期服务记录列表)→ 任务建议卡片(建议执行 + 话术参考含复制按钮)→ 我给 TA 的备注卡片(备注列表含星星评分 + 删除按钮)→ 底部操作栏(问问助手 + 备注两个按钮)
4. WHEN 实现备注弹窗时, THE 弹窗 SHALL 包含原型图中的全部元素:标题行(添加备注 + 展开评价按钮)、可折叠的星星评分区(再次服务意愿 1-5 星 + 再来店可能性 1-5 星,各含文字提示)、文本输入区、保存按钮
5. WHEN 实现长按上下文菜单时, THE 菜单 SHALL 还原原型图中的交互:遮罩层 + 圆角菜单面板(置顶/取消置顶、备注、放弃/取消放弃等选项)
6. WHEN 实现备注记录页时, THE 页面 SHALL 还原原型图中的列表结构:每条备注含内容文本 + 底部标签(助教/客户类型标签 + 时间戳)
##### 13.B 视觉还原(强制)
1. THE 小程序页面 SHALL 使用与原型图一致的 TDesign 色彩体系primary=#0052d9、success=#00a870、warning=#ed7b2f、error=#e34d59,灰阶色板 gray-1(#f3f3f3) 至 gray-13(#242424)
2. THE 任务详情页 Banner SHALL 根据任务类型使用不同主题色:高优先召回=theme-red、优先召回=theme-orange、关系构建=theme-pink、客户回访=theme-teal与原型图中的渐变背景一致
3. THE 维客线索大类标签 SHALL 使用原型图中的配色方案:客户基础=primary/10 底色 + primary 文字、消费习惯=success/10 底色 + success 文字、玩法偏好=purple-500/10 底色 + purple-600 文字、重要反馈=error/10 底色 + error 文字
4. THE 星星评分组件 SHALL 还原原型图中的视觉效果:填充星/空心星 SVG、支持半星显示用于展示 AI 评分映射)
5. THE 业绩进度卡片 SHALL 还原原型图中的 5 段档位进度条按比例宽度0-100 占 45.45%、100-130/130-160/160-190/190-220 各占 13.64%)、红戳动画(盖戳效果)、奖金金额突出样式
##### 13.C WXML/WXSS 技术规范(强制)
1. THE 小程序页面 SHALL 使用 WXML 语法而非 HTML 语法:`<view>` 替代 `<div>``<text>` 替代 `<span>`/`<p>``<image>` 替代 `<img>``<navigator>` 替代 `<a>`,禁止使用 HTML 标签
2. THE 小程序样式 SHALL 使用 WXSS 语法:使用 `rpx` 单位替代 `px`750rpx = 屏幕宽度)、使用 `@import` 导入公共样式、禁止使用 `rem`/`em`/`vw`/`vh` 等 CSS 单位
3. THE 小程序页面 SHALL 使用 `wx:for` 替代 JavaScript 循环渲染、`wx:if`/`wx:elif`/`wx:else` 替代条件渲染、`bind:tap` 替代 `onclick``data-*` + `e.currentTarget.dataset` 替代 DOM 操作
4. THE 小程序页面 SHALL 禁止使用以下 Web 特性:`document.*``window.*``localStorage`(用 `wx.setStorageSync`)、`fetch`/`XMLHttpRequest`(用 `wx.request`、CSS `position: fixed``bottom: 0` 底部栏(用小程序安全区域适配)
5. THE 小程序样式 SHALL 仅使用小程序支持的 CSS 选择器:`.class``#id``element``element, element``::after``::before`,禁止使用 `>`(子选择器)、`+`(相邻兄弟)、`~`(通用兄弟)、`[attr]`(属性选择器)等不支持的选择器
6. THE 小程序页面 SHALL 使用 `<block>` 标签作为无渲染包裹容器(替代 HTML 的 `<template>` 或 React 的 `<Fragment>``<block>` 不会生成真实 DOM 节点
##### 13.D TDesign 组件使用规范(强制)
1. THE 小程序页面 SHALL 优先使用 TDesign 组件库中的组件,组件引入路径格式为 `tdesign-miniprogram/{组件名}/{组件名}`,在页面 `.json``usingComponents` 中注册
2. THE 以下 UI 元素 SHALL 使用对应的 TDesign 组件:导航栏→`t-navbar`、底部标签栏→`t-tab-bar`、对话框→`t-dialog`、轻提示→`t-toast`、弹出层→`t-popup`、空状态→`t-empty`、加载→`t-loading`、骨架屏→`t-skeleton`、标签→`t-tag`、搜索框→`t-search`
3. THE TDesign 组件样式覆盖 SHALL 使用以下 4 种方式之一:`style`/`custom-style` 属性、解除样式隔离(`addGlobalClass`)、外部样式类(`t-class`、CSS 变量(`--td-*`),禁止直接修改 `node_modules` 中的组件源码
4. THE 小程序 `app.json` SHALL 移除 `"style": "v2"` 配置项,避免 TDesign 组件样式错乱
5. WHEN 原型图中的 UI 元素无法用 TDesign 组件直接实现时(如自定义进度条、红戳动画、话术气泡等), THE 开发者 SHALL 使用原生 WXML + WXSS 自定义实现,但视觉效果必须与原型图一致
##### 13.E 原型图参考流程(强制)
1. WHEN 开始实现任何小程序页面前, THE 开发者 SHALL 首先阅读对应的 `docs/h5_ui/pages/*.html` 原型文件,提取页面结构、组件层次、样式细节、交互行为
2. WHEN 原型图中使用 Tailwind CSS 类名时, THE 开发者 SHALL 将其转换为等效的 WXSS 样式(如 `px-4``padding: 0 32rpx``rounded-xl``border-radius: 24rpx``text-sm``font-size: 28rpx`
3. WHEN 原型图中使用 `<iframe>` 嵌套页面时, THE 开发者 SHALL 理解这是原型展示方式,实际小程序中使用 `wx.navigateTo` 页面跳转
4. WHEN 原型图中使用 `onclick`/`history.back()` 等 Web API 时, THE 开发者 SHALL 转换为小程序等效 API`bind:tap` + `wx.navigateBack()`
5. THE 开发者 SHALL 在实现前加载 `wechat-miniprogram` Power 的相关 steering 文件(`view-layer.md``tdesign.md``builtin-components.md`),确保使用正确的小程序语法和 TDesign 组件规范
6. THE 开发者 SHALL 在实现前阅读项目内的 H5 转小程序避坑指南 `apps/miniprogram/doc/h5-to-miniprogram-pitfalls.md`该文档基于本项目已转换页面的实际踩坑经验整理涵盖标签映射、rpx 换算、事件系统、TDesign 覆盖方式、高频踩坑清单及新页面开发 Checklist所有条目具有强制参考效力
### 需求 14任务系统属性测试
**用户故事:** 作为后端开发者,我需要通过属性测试验证任务系统核心逻辑的正确性。
#### 验收标准
1. THE 属性测试 SHALL 验证:对于任意 `(site_id, assistant_id, member_id, task_type)` 组合,`status = 'active'` 的任务最多只有一条(唯一性不变量)
2. THE 属性测试 SHALL 验证:对于任意任务类型变更操作,旧任务被标记为 `inactive` 且新任务被创建为 `active`(状态机正确性)
3. THE 属性测试 SHALL 验证:对于任意 `follow_up_visit` 任务,当 `expires_at` 不为 NULL 且当前时间超过 `expires_at` 时,轮询后 `status` 变为 `inactive`(有效期机制)
4. THE 属性测试 SHALL 验证:对于任意任务放弃操作,`abandon_reason` 不为空字符串(放弃原因必填)
5. THE 属性测试 SHALL 验证:对于任意备注创建操作,`rating_service_willingness``rating_revisit_likelihood` 的值在 NULL 或 1-5 范围内(评分范围约束)
6. THE 属性测试 SHALL 验证:对于任意召回完成事件,`completed_task_type` 记录了完成时的任务类型快照(完成类型快照不变量)
7. THE 属性测试 SHALL 验证:对于任意备注回溯操作,重分类后的备注 `type``normal` 变为 `follow_up`(回溯分类正确性)
---
## 附录:原型还原强制规则摘要
> 以下规则适用于本 SPEC 及所有后续小程序页面开发 SPEC具有全局约束力。
1. **原型图是唯一视觉真相**`docs/h5_ui/pages/*.html` 中的结构、层次、元素、配色、间距、交互行为是小程序页面实现的唯一参考标准。任何偏离原型图的实现都需要明确的产品确认。
2. **WXML ≠ HTML**:严禁在小程序中使用 HTML 标签div/span/p/a/img 等必须使用小程序原生标签view/text/image/navigator 等)。
3. **WXSS ≠ CSS**:使用 rpx 单位、仅支持有限选择器、无 DOM/BOM API、样式隔离机制不同。Tailwind CSS 类名必须手动转换为 WXSS。
4. **TDesign 优先**:凡 TDesign 组件库能覆盖的 UI 元素,必须使用 TDesign 组件;自定义实现仅限 TDesign 无法覆盖的场景。
5. **Power 文档优先**:实现前必须加载 `wechat-miniprogram` Power 的相关 steering 文件,确保语法和组件用法正确。
6. **项目踩坑指南必读**:实现前必须阅读 `apps/miniprogram/doc/h5-to-miniprogram-pitfalls.md`,该文档是基于本项目实际转换经验的避坑手册,涵盖 WXML/WXSS 差异、事件系统、TDesign 用法、rpx 换算规则及新页面开发 Checklist。

View File

@@ -0,0 +1,239 @@
# 实现计划小程序核心业务模块miniapp-core-business
## 概述
基于已批准的需求和设计文档,将小程序核心业务模块拆分为增量式编码任务。按照"DDL 建表 → 触发器调度框架 → 任务生成器 → 任务管理 → 有效期轮询 → 召回检测 → 备注系统 → 路由集成"的顺序实现。后端使用 Python + FastAPI数据库使用 PostgreSQL 纯 SQL属性测试使用 hypothesis。所有数据库操作在测试库 `test_zqyy_app` 中进行。
## 任务
- [x] 1. 创建业务数据表和种子数据
- [x] 1.1 创建迁移脚本 `db/zqyy_app/migrations/YYYY-MM-DD__p4_create_biz_tables.sql`
-`biz` Schema 下创建 `coach_tasks``coach_task_history``notes``trigger_jobs` 共 4 张表
- 包含所有字段定义、约束、CHECK 约束(评分 1-5、外键coach_task_history → coach_tasks、notes → coach_tasks、coach_tasks → coach_tasks 自引用)
- 创建部分唯一索引 `idx_coach_tasks_site_assistant_member_type`(仅 status='active'
- 创建查询索引 `idx_coach_tasks_assistant_status``idx_notes_target`
- 使用 `IF NOT EXISTS` 幂等语法
- 包含回滚语句(注释形式)
- _Requirements: 1.1-1.9_
- [x] 1.2 创建种子数据脚本 `db/zqyy_app/migrations/YYYY-MM-DD__p4_seed_trigger_jobs.sql`
- 插入 4 条触发器配置:`task_generator`cron, 0 4 * * *)、`task_expiry_check`interval, 3600s`recall_completion_check`event, etl_data_updated`note_reclassify_backfill`event, recall_completed
- 使用 `ON CONFLICT (job_name) DO NOTHING` 幂等语法
- _Requirements: 2.1-2.5_
- [x] 1.3 在测试库执行迁移脚本并验证
-`test_zqyy_app` 中执行建表脚本和种子数据脚本
- 验证幂等性:连续执行两次无错误
- 验证表结构、约束、索引正确
- 验证种子数据完整4 条触发器配置)
- _Requirements: 11.1-11.5, 12.1_
- [x] 1.4 更新数据库手册和 DDL 基线
- 创建 `docs/database/BD_Manual_biz_tables.md`,包含变更说明、兼容性影响、回滚策略、验证 SQL至少 3 条)
- 运行 `python scripts/ops/gen_consolidated_ddl.py` 刷新 DDL 基线
- 在数据库手册中记录种子数据内容(触发器配置)
- _Requirements: 12.2, 12.3, 12.4_
- [x] 1.5 编写迁移脚本幂等性属性测试
- **Property 13: 迁移脚本幂等性**
- 对 DDL 脚本和种子数据脚本连续执行两次,验证第二次执行无错误且数据库状态不变
- **Validates: Requirements 1.8, 2.5, 11.4, 11.5**
- [x] 2. 检查点 - 确保 DDL 和种子数据正确
- 确保迁移脚本在测试库中执行成功,幂等性验证通过,如有问题请向用户确认。
- [x] 3. 实现 Pydantic 模型和纯函数核心逻辑
- [x] 3.1 创建 Pydantic 模型 `apps/backend/app/schemas/xcx_tasks.py`
- 定义 `TaskListItem`(含 member_name、member_phone、rs_score、heart_icon
- 定义 `AbandonRequest`reason 必填min_length=1
- _Requirements: 8.1, 8.2, 8.4, 8.7_
- [x] 3.2 创建 Pydantic 模型 `apps/backend/app/schemas/xcx_notes.py`
- 定义 `NoteCreateRequest`(含 target_type、target_id、content、task_id、rating_service_willingness、rating_revisit_likelihood评分 ge=1 le=5
- 定义 `NoteOut`(含 type、content、评分、ai_score、ai_analysis
- _Requirements: 9.1, 9.8_
- [x] 3.3 创建任务生成器核心纯函数 `apps/backend/app/services/task_generator.py`
- 定义 `TaskPriority` 枚举、`TASK_TYPE_PRIORITY` 映射
- 定义 `IndexData` 数据类
- 实现 `determine_task_type(index_data)` 纯函数:根据 WBI/NCI/RS 指数确定任务类型
- 实现 `should_replace_task(existing_type, new_type)` 纯函数:判断是否应替换现有任务
- 实现 `compute_heart_icon(rs_score)` 纯函数:根据 RS 指数计算爱心 icon 档位
- _Requirements: 3.1-3.5, 3.8, 8.2_
- [x] 3.4 编写任务类型确定正确性属性测试
- **Property 1: 任务类型确定正确性**
- 生成随机 WBI/NCI/RS 值Decimal, 0-10, 2 位小数),验证 `determine_task_type()` 返回值符合优先级规则
- **Validates: Requirements 3.1, 3.2, 3.3, 3.5**
- [x] 3.5 编写星星评分范围约束属性测试
- **Property 9: 星星评分范围约束**
- 生成随机整数(-100 到 100验证 Pydantic 模型对 1-5 范围外的值拒绝ValidationError
- **Validates: Requirements 9.8, 14.5**
- [x] 3.6 编写爱心 icon 档位计算属性测试
- **Property 11: 爱心 icon 档位计算**
- 生成随机 RS 值Decimal, 0-10, 1 位小数),验证 `compute_heart_icon()` 返回正确 icon
- **Validates: Requirements 8.2**
- [x] 4. 实现触发器调度框架
- [x] 4.1 创建 `apps/backend/app/services/trigger_scheduler.py`
- 实现 `_JOB_REGISTRY` 注册表和 `register_job(job_type, handler)` 函数
- 实现 `fire_event(event_name, payload)` 方法:查找 event 类型触发器并执行
- 实现 `check_scheduled_jobs()` 方法:检查 cron/interval 到期 job 并执行
- 实现 `_calculate_next_run(trigger_condition, trigger_config)` 方法:计算下次运行时间
- 每个 job 独立事务,失败不影响其他触发器
- _Requirements: 10.1-10.7_
- [x] 4.2 编写触发器 next_run_at 计算属性测试
- **Property 12: 触发器 next_run_at 计算**
- 生成随机 cron/interval 配置和当前时间,验证 cron 类型 next_run_at > 当前时间interval 类型 next_run_at = 当前时间 + interval_seconds
- **Validates: Requirements 10.1, 10.2**
- [x] 5. 实现任务生成器完整流程
- [x] 5.1 实现 `TaskGenerator.run()` 主流程
- 通过 `auth.user_assistant_binding` 获取所有已绑定助教
- 对每个助教,通过 FDW 读取 WBI/NCI/RS 指数(`SET LOCAL app.current_site_id`
- 调用 `determine_task_type()` 确定任务类型
- 检查已存在的 active 任务:相同 task_type → 跳过;不同 task_type → 关闭旧任务 + 创建新任务 + 记录 history
- 处理 `follow_up_visit` 的 48 小时滞留机制expires_at 填充)
- 更新 `trigger_jobs` 时间戳
- _Requirements: 3.1-3.10, 4.1-4.5, 5.1-5.4_
- [x] 5.2 编写活跃任务唯一性不变量属性测试
- **Property 2: 活跃任务唯一性不变量**
- 生成随机 (site_id, assistant_id, member_id, task_type) 组合,模拟插入操作,验证 active 任务最多一条
- **Validates: Requirements 1.5, 3.6, 14.1**
- [x] 5.3 编写任务类型变更状态机属性测试
- **Property 3: 任务类型变更状态机**
- 生成随机现有任务 + 新任务类型,执行变更,验证旧任务 inactive + 新任务 active + history 记录
- **Validates: Requirements 3.7, 5.1, 5.4, 14.2**
- [x] 5.4 编写 48 小时滞留机制属性测试
- **Property 4: 48 小时滞留机制**
- 生成随机 follow_up_visit 任务 + 时间偏移,验证 expires_at 填充和过期逻辑
- **Validates: Requirements 4.1, 4.2, 4.3, 4.4, 14.3**
- [x] 6. 检查点 - 确保任务生成器测试通过
- 运行属性测试:`cd C:\NeoZQYY && pytest tests/test_core_business_properties.py -v -k "property_1 or property_2 or property_3 or property_4"`
- 确保所有属性测试通过,如有问题请向用户确认。
- [x] 7. 实现任务管理服务
- [x] 7.1 创建 `apps/backend/app/services/task_manager.py`
- 实现 `get_task_list(user_id, site_id)` 异步方法:查询活跃任务 + FDW 读取客户信息和 RS 指数 + 爱心 icon 计算 + 排序
- 实现 `pin_task(task_id, user_id, site_id)` 异步方法:验证归属 + 设置 is_pinned=TRUE + 记录 history
- 实现 `unpin_task(task_id, user_id, site_id)` 异步方法:验证归属 + 设置 is_pinned=FALSE
- 实现 `abandon_task(task_id, user_id, site_id, reason)` 异步方法:验证 reason 非空 + 设置 abandoned + 记录 history
- 实现 `cancel_abandon(task_id, user_id, site_id)` 异步方法:恢复 active + 清空 abandon_reason + 记录 history
- 实现 `_record_history()` 内部方法
- _Requirements: 8.1-8.8_
- [x] 7.2 编写放弃与取消放弃往返属性测试
- **Property 5: 放弃与取消放弃往返**
- 生成随机 active 任务 + 非空放弃原因,执行放弃→取消放弃,验证状态恢复;空原因应返回 422
- **Validates: Requirements 8.4, 8.6, 8.7, 14.4**
- [x] 7.3 编写任务列表排序正确性属性测试
- **Property 10: 任务列表排序正确性**
- 生成随机任务列表(不同 is_pinned/priority_score/created_at验证排序为 is_pinned DESC, priority_score DESC, created_at ASC
- **Validates: Requirements 8.1**
- [x] 7.4 编写状态变更历史完整性属性测试
- **Property 15: 状态变更历史完整性**
- 生成随机状态变更操作序列(置顶/放弃/取消放弃),验证 history 记录数量和内容正确
- **Validates: Requirements 5.4, 8.3**
- [x] 8. 实现有效期轮询器
- [x] 8.1 创建 `apps/backend/app/services/task_expiry.py`
- 实现 `run()` 方法:查询 expires_at 不为 NULL 且已过期的 active 任务,标记为 inactive记录 history
- _Requirements: 4.3, 4.5_
- [x] 9. 实现召回完成检测器
- [x] 9.1 创建 `apps/backend/app/services/recall_detector.py`
- 实现 `run(payload)` 方法:通过 FDW 读取新增服务记录,匹配 active 任务标记 completed记录 completed_at 和 completed_task_type 快照,触发 `recall_completed` 事件
- _Requirements: 6.1-6.5_
- [x] 9.2 编写召回完成检测与类型快照属性测试
- **Property 6: 召回完成检测与类型快照**
- 生成随机 active 任务 + 服务记录,执行完成检测,验证 completed_task_type 记录了完成时的 task_type 快照
- **Validates: Requirements 6.2, 6.3, 14.6**
- [x] 10. 检查点 - 确保任务管理和召回检测测试通过
- 运行属性测试:`cd C:\NeoZQYY && pytest tests/test_core_business_properties.py -v -k "property_5 or property_6 or property_10 or property_15"`
- 确保所有属性测试通过,如有问题请向用户确认。
- [-] 11. 实现备注系统
- [x] 11.1 创建备注服务 `apps/backend/app/services/note_service.py`
- 实现 `create_note()` 异步方法:验证评分范围 + 确定 note type关联 follow_up_visit 任务 → follow_up否则 normal+ INSERT + 触发 AI 应用 6 接口(占位)+ 若 ai_score >= 6 标记任务 completed
- 实现 `get_notes()` 异步方法:按 created_at DESC 排序,包含评分和 AI 评分
- 实现 `delete_note()` 异步方法:验证归属后硬删除
- _Requirements: 9.1-9.9_
- [x] 11.2 创建备注回溯重分类器 `apps/backend/app/services/note_reclassifier.py`
- 实现 `run(payload)` 方法:查找 service_time 之后的第一条 normal 备注 → 更新为 follow_up → 触发 AI 应用 6 接口(占位)→ 根据 ai_score 生成 follow_up_visit 任务
- 实现 `ai_analyze_note(note_id)` 占位函数(返回 NoneP5 实现后替换)
- _Requirements: 7.1-7.5_
- [x] 11.3 编写备注回溯重分类属性测试
- **Property 7: 备注回溯重分类**
- 生成随机备注列表 + service_time执行回溯验证符合条件的 normal 备注 type 变为 follow_up
- **Validates: Requirements 7.1, 7.2, 14.7**
- [x] 11.4 编写备注类型自动设置属性测试
- **Property 8: 备注类型自动设置**
- 生成随机 task_type + 备注创建,验证关联 follow_up_visit → type=follow_up其他 → type=normal
- **Validates: Requirements 9.2, 9.3**
- [x] 11.5 编写 AI 评分驱动的任务完成判定属性测试
- **Property 14: AI 评分驱动的任务完成判定**
- 生成随机 ai_score + 任务状态,验证 ai_score >= 6 且 active → completedai_score < 6 → 保持 active
- **Validates: Requirements 7.4, 7.5, 9.5**
- [x] 12. 实现 API 路由层
- [x] 12.1 创建小程序任务路由 `apps/backend/app/routers/xcx_tasks.py`
- 实现 `GET /api/xcx/tasks`获取任务列表require_approved
- 实现 `POST /api/xcx/tasks/{id}/pin`:置顶任务
- 实现 `POST /api/xcx/tasks/{id}/unpin`:取消置顶
- 实现 `POST /api/xcx/tasks/{id}/abandon`放弃任务AbandonRequest 校验)
- 实现 `POST /api/xcx/tasks/{id}/cancel-abandon`:取消放弃
- _Requirements: 8.1-8.8_
- [x] 12.2 创建小程序备注路由 `apps/backend/app/routers/xcx_notes.py`
- 实现 `POST /api/xcx/notes`创建备注NoteCreateRequest 校验)
- 实现 `GET /api/xcx/notes`查询备注列表query: target_type, target_id
- 实现 `DELETE /api/xcx/notes/{id}`:删除备注
- _Requirements: 9.1-9.9_
- [x] 12.3 在 `apps/backend/app/main.py` 中注册新路由
- 注册 `xcx_tasks``xcx_notes` 路由
- 验证无路由冲突
- _Requirements: 全部_
- [x] 12.4 注册触发器 job handler
- 在应用启动时调用 `register_job()` 注册 `task_generator``task_expiry_check``recall_completion_check``note_reclassify_backfill` 四个 handler
- _Requirements: 10.1-10.6_
- [x] 13. 检查点 - 确保所有测试通过
- 运行属性测试:`cd C:\NeoZQYY && pytest tests/test_core_business_properties.py -v`
- 26/26 全部通过16.81s
- [x] 14. 最终检查点 - 全量验证
- 运行全部属性测试26/26 通过16.81s
- 验证迁移脚本幂等性Property 133 个测试)通过
- 验证种子数据完整性4 条触发器配置全部存在
- 验证表结构coach_tasks / coach_task_history / notes / trigger_jobs 全部存在
- 验证部分唯一索引idx_coach_tasks_site_assistant_member_type 存在
## 备注
- 标记 `*` 的子任务为可选(属性测试),可跳过以加速 MVP
- 每个任务引用了具体的需求编号,确保可追溯
- 检查点确保增量验证
- 属性测试验证通用正确性属性hypothesis最少 200 次迭代)
- 所有数据库操作在测试库 `test_zqyy_app` 进行
- 迁移脚本放在 `db/zqyy_app/migrations/` 目录
- 属性测试放在 `tests/test_core_business_properties.py`Monorepo 级)
- AI 应用 6 接口为占位实现(返回 None由 P5 AI 集成层替换
- 维客线索功能由独立模块 `routers/member_retention_clue.py` 处理,不在本 SPEC 范围内
- FDW 查询需在事务中 `SET LOCAL app.current_site_id` 设置 RLS 隔离

View File

@@ -0,0 +1 @@
{"specId": "cf5c24d6-ec72-4c49-8650-264ef414e10e", "workflowType": "requirements-first", "specType": "feature"}

View File

@@ -0,0 +1,878 @@
# 设计文档P5 AI 集成层miniapp-ai-integration
## 概述
本设计文档描述 P5-A 阶段 AI 集成层的技术架构与实现方案。系统在现有 FastAPI 后端(`apps/backend/`)中新增 AI 模块,通过阿里云百炼 API通义千问为 8 个 AI 应用提供统一的调用能力。
核心交付物:
- 3 张新表(`biz.ai_conversations``biz.ai_messages``biz.ai_cache`
- 百炼 API 统一封装层(流式 + 非流式)
- 应用 1 SSE 流式对话端点
- 应用 2 财务洞察Prompt 完整)+ 应用 8 维客线索整理Prompt 完整)
- 应用 3/4/5/6/7 触发机制与调用骨架
- 事件调度与调用链编排
- AI 缓存读写 API
设计原则:
- **统一封装**:所有 AI 调用经 `BailianClient` 统一出口,便于重试、计量、日志
- **事件驱动**:复用现有 `trigger_scheduler.fire_event()` 机制,扩展支持串行调用链
- **骨架优先**P5-A 只实现管道和框架Prompt 细化留给 P5-B 阶段
- **site_id 隔离**:所有表和查询强制 site_id 过滤
## 架构
### 系统架构图
```mermaid
graph TB
subgraph 微信小程序
MP_CHAT[对话页面]
MP_PAGES[其他页面<br/>财务看板/任务详情/客户详情]
end
subgraph FastAPI 后端
subgraph AI 模块 - apps/backend/app/ai/
SSE[SSE 端点<br/>/api/ai/chat/stream]
CACHE_API[缓存 API<br/>/api/ai/cache]
HISTORY_API[历史对话 API<br/>/api/ai/conversations]
DISPATCHER[AI Event Dispatcher<br/>调用链编排]
BAILIAN[BailianClient<br/>百炼 API 封装]
end
subgraph 现有服务
NOTE_SVC[note_service<br/>备注服务]
TRIGGER[trigger_scheduler<br/>触发器调度]
TASK_GEN[task_generator<br/>任务生成]
end
end
subgraph 外部服务
BAILIAN_API[阿里云百炼 API<br/>通义千问]
end
subgraph PostgreSQL - zqyy_app
AI_CONV[biz.ai_conversations]
AI_MSG[biz.ai_messages]
AI_CACHE_T[biz.ai_cache]
CLUE_T[member_retention_clue]
end
MP_CHAT -->|SSE| SSE
MP_PAGES -->|REST| CACHE_API
MP_CHAT -->|REST| HISTORY_API
SSE --> BAILIAN
DISPATCHER --> BAILIAN
BAILIAN -->|HTTP/SSE| BAILIAN_API
NOTE_SVC -->|备注提交事件| DISPATCHER
TRIGGER -->|消费/任务分配事件| DISPATCHER
DISPATCHER -->|写入| AI_CONV
DISPATCHER -->|写入| AI_MSG
DISPATCHER -->|写入| AI_CACHE_T
DISPATCHER -->|全量替换 AI 线索| CLUE_T
SSE -->|写入| AI_CONV
SSE -->|写入| AI_MSG
```
### 事件调用链
```mermaid
sequenceDiagram
participant E as 业务事件
participant D as AI Dispatcher
participant A3 as App3 线索
participant A8 as App8 整理
participant A7 as App7 客户分析
participant A4 as App4 关系分析
participant A5 as App5 话术
Note over E,A5: 消费事件链(无助教)
E->>D: consumption_event(member_id, site_id)
D->>A3: 调用(串行)
A3-->>D: 线索结果 → ai_cache
D->>A8: 调用(串行)
A8-->>D: 整合线索 → ai_cache + member_retention_clue
D->>A7: 调用(串行)
A7-->>D: 客户分析 → ai_cache
Note over E,A5: 消费事件链(有助教)
E->>D: consumption_event(member_id, assistant_id, site_id)
D->>A3: 调用
A3-->>D: 线索结果
D->>A8: 调用
A8-->>D: 整合线索
D->>A7: 调用
D->>A4: 调用A8 完成后)
A4-->>D: 关系分析
D->>A5: 调用A4 完成后)
A5-->>D: 话术参考
Note over E,A5: 备注事件链
E->>D: note_event(member_id, note_id, site_id)
D->>+A6: 调用
Note right of A6: App6 备注分析
A6-->>-D: 线索 + 评分
D->>A8: 调用
A8-->>D: 整合线索
Note over E,A5: 任务分配事件链
E->>D: task_assign_event(assistant_id, member_id, site_id)
D->>A4: 调用(读已有 A8 缓存)
A4-->>D: 关系分析
D->>A5: 调用
A5-->>D: 话术参考
```
### 模块目录结构
```
apps/backend/app/ai/
├── __init__.py
├── bailian_client.py # 百炼 API 统一封装
├── dispatcher.py # AI 事件调度与调用链编排
├── cache_service.py # AI 缓存读写服务
├── conversation_service.py # 对话记录持久化服务
├── apps/
│ ├── __init__.py
│ ├── app1_chat.py # 应用 1 通用对话
│ ├── app2_finance.py # 应用 2 财务洞察
│ ├── app3_clue.py # 应用 3 客户数据维客线索
│ ├── app4_analysis.py # 应用 4 关系分析
│ ├── app5_tactics.py # 应用 5 话术参考
│ ├── app6_note.py # 应用 6 备注分析
│ ├── app7_customer.py # 应用 7 客户分析
│ └── app8_consolidation.py # 应用 8 维客线索整理
├── prompts/
│ ├── __init__.py
│ ├── app2_finance_prompt.py # 应用 2 完整 Prompt
│ └── app8_consolidation_prompt.py # 应用 8 完整 Prompt
└── schemas.py # Pydantic 模型
apps/backend/app/routers/
├── xcx_ai_chat.py # SSE 对话路由
└── xcx_ai_cache.py # 缓存查询路由
```
## 组件与接口
### 1. BailianClient百炼 API 统一封装)
文件:`apps/backend/app/ai/bailian_client.py`
技术方案(基于百炼官方文档):
- **流式调用**:使用 OpenAI 兼容接口(百炼支持 OpenAI SDK 协议),`stream=True` 返回 SSE 事件流
- **非流式调用**`stream=False`,返回完整 JSON 响应
- **JSON 输出模式**:通过 System Prompt 约束 + `response_format={"type": "json_object"}` 参数(百炼兼容 OpenAI 的 JSON mode
- **重试策略**:指数退避,最多 3 次,基础间隔 1s → 2s → 4s
- **SDK 选择**:使用 `openai` Python SDK百炼兼容 OpenAI 协议),`base_url` 指向百炼端点
```python
class BailianClient:
"""百炼 API 统一封装层。"""
def __init__(self, api_key: str, base_url: str, model: str):
"""
Args:
api_key: 百炼 API Key从 BAILIAN_API_KEY 环境变量读取)
base_url: 百炼 API 端点(从 BAILIAN_BASE_URL 环境变量读取)
model: 模型标识(如 qwen-plus
"""
async def chat_stream(
self,
messages: list[dict],
*,
temperature: float = 0.7,
max_tokens: int = 2000,
) -> AsyncGenerator[str, None]:
"""流式调用,逐 chunk 返回文本。用于应用 1 SSE。"""
async def chat_json(
self,
messages: list[dict],
*,
temperature: float = 0.3,
max_tokens: int = 4000,
) -> tuple[dict, int]:
"""非流式调用,返回解析后的 JSON dict 和 tokens_used。
用于应用 2-8。
Raises:
BailianJsonParseError: JSON 解析失败时抛出
BailianApiError: API 调用失败(重试耗尽后)
"""
def _inject_current_time(self, messages: list[dict]) -> list[dict]:
"""在首条消息的 content JSON 中注入 current_time 字段。"""
async def _call_with_retry(self, **kwargs) -> Any:
"""带指数退避的重试封装。"""
```
环境变量(新增到 `.env` / `.env.template`
```
BAILIAN_API_KEY=sk-xxx
BAILIAN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
BAILIAN_MODEL=qwen-plus
```
### 2. AI Event Dispatcher事件调度器
文件:`apps/backend/app/ai/dispatcher.py`
调度器负责根据业务事件编排 AI 应用调用链。与现有 `trigger_scheduler` 的关系:
- `trigger_scheduler.fire_event()` 触发业务事件 → 调用 `ai_dispatcher` 对应的 handler
- `ai_dispatcher` 内部管理串行调用链的执行顺序
```python
class AIDispatcher:
"""AI 应用调用链编排器。"""
async def handle_consumption_event(
self,
member_id: int,
site_id: int,
settle_id: int,
assistant_id: int | None = None,
) -> None:
"""消费事件链App3 → App8 → App7+ App4 → App5 如有助教)。"""
async def handle_note_event(
self,
member_id: int,
site_id: int,
note_id: int,
note_content: str,
noted_by_name: str,
) -> None:
"""备注事件链App6 → App8。"""
async def handle_task_assign_event(
self,
assistant_id: int,
member_id: int,
site_id: int,
task_type: str,
) -> None:
"""任务分配事件链App4 → App5读已有 App8 缓存)。"""
async def _run_chain(
self,
chain: list[Callable],
context: dict,
) -> None:
"""串行执行调用链,某步失败记录日志后继续。"""
```
容错策略:
- 调用链中某个应用失败 → 记录错误日志 + 写入 `ai_conversations`(标记失败)
- 后续应用使用已有缓存继续执行,不阻塞整条链
- 整条链在后台异步执行,不阻塞业务请求
### 3. AI Cache Service缓存读写服务
文件:`apps/backend/app/ai/cache_service.py`
```python
class AICacheService:
"""AI 缓存读写服务。"""
def get_latest(
self,
cache_type: str,
site_id: int,
target_id: str,
) -> dict | None:
"""查询最新缓存记录。"""
def get_history(
self,
cache_type: str,
site_id: int,
target_id: str,
limit: int = 2,
) -> list[dict]:
"""查询历史缓存记录(按 created_at DESC用于 Prompt reference。"""
def write_cache(
self,
cache_type: str,
site_id: int,
target_id: str,
result_json: dict,
triggered_by: str | None = None,
score: int | None = None,
expires_at: datetime | None = None,
) -> int:
"""写入缓存记录,返回 id。写入后异步清理超限记录。"""
def _cleanup_excess(
self,
cache_type: str,
site_id: int,
target_id: str,
max_count: int = 500,
) -> int:
"""清理超限记录,保留最近 max_count 条,返回删除数量。"""
```
### 4. Conversation Service对话记录持久化
文件:`apps/backend/app/ai/conversation_service.py`
```python
class ConversationService:
"""AI 对话记录持久化服务。"""
def create_conversation(
self,
user_id: int | str,
nickname: str,
app_id: str,
site_id: int,
source_page: str | None = None,
source_context: dict | None = None,
) -> int:
"""创建对话记录,返回 conversation_id。
系统自动调用时 user_id 为 'system'"""
def add_message(
self,
conversation_id: int,
role: str,
content: str,
tokens_used: int | None = None,
) -> int:
"""添加消息记录,返回 message_id。"""
def get_conversations(
self,
user_id: int,
site_id: int,
page: int = 1,
page_size: int = 20,
) -> list[dict]:
"""查询用户历史对话列表,按时间倒序,懒加载。"""
def get_messages(
self,
conversation_id: int,
) -> list[dict]:
"""查询对话的所有消息。"""
```
### 5. Clue Writer维客线索写入器
文件:集成在 `apps/backend/app/ai/apps/app8_consolidation.py`
```python
class ClueWriter:
"""维客线索全量替换写入器。"""
def replace_ai_clues(
self,
member_id: int,
site_id: int,
clues: list[dict],
) -> int:
"""全量替换该客户的 AI 来源线索。
1. DELETE FROM member_retention_clue
WHERE member_id = %s AND site_id = %s
AND source IN ('ai_consumption', 'ai_note')
2. INSERT 新线索(人工线索 source='manual' 不受影响)
字段映射:
- category → category
- emoji + summary → summary"📅 偏好周末下午时段消费"
- detail → detail
- providers → recorded_by_name
- source: 纯 App3 → ai_consumption纯 App6 → ai_note混合 → ai_consumption
- recorded_by_assistant_id: NULL系统触发
返回写入的线索数量。
"""
```
### 6. API 端点
#### 6.1 SSE 对话端点
路由文件:`apps/backend/app/routers/xcx_ai_chat.py`
```
POST /api/ai/chat/stream
Content-Type: application/json
Accept: text/event-stream
Request Body:
{
"message": "string",
"source_page": "string",
"page_context": {},
"screen_content": "string" // 页面可见内容文本化
}
SSE Events:
data: {"type": "chunk", "content": "..."}
data: {"type": "done", "conversation_id": 123, "tokens_used": 456}
data: {"type": "error", "message": "..."}
认证JWT Token从 xcx_auth 获取)
隔离:从 JWT 中提取 user_id、site_id、nickname、role
```
#### 6.2 历史对话 API
```
GET /api/ai/conversations?page=1&page_size=20
→ [{ id, app_id, source_page, created_at, first_message_preview }]
GET /api/ai/conversations/{conversation_id}/messages
→ [{ id, role, content, tokens_used, created_at }]
```
#### 6.3 缓存查询 API
路由文件:`apps/backend/app/routers/xcx_ai_cache.py`
```
GET /api/ai/cache/{cache_type}?target_id=xxx
→ { id, cache_type, target_id, result_json, score, created_at }
认证JWT Token
隔离site_id 从 JWT 提取,强制过滤
```
### 7. 各应用骨架接口
每个应用实现统一的调用接口:
```python
# 应用基类模式(非继承,约定接口)
async def run(
context: dict, # 包含 member_id, site_id, 及应用特定参数
bailian: BailianClient,
cache_svc: AICacheService,
conv_svc: ConversationService,
) -> dict:
"""
执行 AI 应用调用。
1. 构建 Promptbuild_prompt
2. 调用百炼 APIbailian.chat_json
3. 写入 ai_conversations + ai_messages
4. 写入 ai_cache
5. 返回结果 dict
"""
```
骨架应用App3/4/5/6/7`build_prompt` 函数留接口:
```python
def build_prompt(context: dict) -> list[dict]:
"""构建 Prompt 消息列表。
P5-A 阶段:返回占位 Prompt标注待细化字段。
P5-B 阶段:由对应页面 spec 补充完整 Prompt。
"""
# TODO: P5-B 细化
return [
{"role": "system", "content": json.dumps({
"task": "...",
"current_time": "", # BailianClient 自动注入
# 以下字段待细化
"data": {},
"reference": {},
})},
]
```
## 数据模型
### DDLbiz.ai_conversations
```sql
CREATE TABLE IF NOT EXISTS biz.ai_conversations (
id BIGSERIAL PRIMARY KEY,
user_id VARCHAR(50) NOT NULL, -- 用户 ID 或 'system'
nickname VARCHAR(100) NOT NULL DEFAULT '',
app_id VARCHAR(30) NOT NULL, -- app1_chat / app2_finance / ... / app8_consolidation
site_id BIGINT NOT NULL,
source_page VARCHAR(100), -- 来源页面标识
source_context JSONB, -- 页面上下文 JSON
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE biz.ai_conversations IS 'AI 对话记录:每次 AI 调用(用户主动或系统自动)创建一条';
COMMENT ON COLUMN biz.ai_conversations.app_id IS '应用标识app1_chat / app2_finance / app3_clue / app4_analysis / app5_tactics / app6_note / app7_customer / app8_consolidation';
COMMENT ON COLUMN biz.ai_conversations.user_id IS '用户 ID系统自动调用时为 system';
CREATE INDEX IF NOT EXISTS idx_ai_conv_user_site ON biz.ai_conversations (user_id, site_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_ai_conv_app_site ON biz.ai_conversations (app_id, site_id, created_at DESC);
```
### DDLbiz.ai_messages
```sql
CREATE TABLE IF NOT EXISTS biz.ai_messages (
id BIGSERIAL PRIMARY KEY,
conversation_id BIGINT NOT NULL REFERENCES biz.ai_conversations(id) ON DELETE CASCADE,
role VARCHAR(10) NOT NULL
CONSTRAINT chk_ai_msg_role CHECK (role IN ('user', 'assistant', 'system')),
content TEXT NOT NULL,
tokens_used INTEGER, -- 本条消息消耗的 token 数
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE biz.ai_messages IS 'AI 消息记录:对话中的每条消息(输入/输出/系统)';
CREATE INDEX IF NOT EXISTS idx_ai_msg_conv ON biz.ai_messages (conversation_id, created_at);
```
### DDLbiz.ai_cache
```sql
CREATE TABLE IF NOT EXISTS biz.ai_cache (
id BIGSERIAL PRIMARY KEY,
cache_type VARCHAR(30) NOT NULL
CONSTRAINT chk_ai_cache_type CHECK (
cache_type IN (
'app2_finance', 'app3_clue', 'app4_analysis',
'app5_tactics', 'app6_note_analysis',
'app7_customer_analysis', 'app8_clue_consolidated'
)
),
site_id BIGINT NOT NULL,
target_id VARCHAR(100) NOT NULL, -- 含义因 cache_type 而异
result_json JSONB NOT NULL,
score INTEGER, -- 应用 6 专用评分
triggered_by VARCHAR(100), -- 触发来源标识
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ -- 可选过期时间
);
COMMENT ON TABLE biz.ai_cache IS 'AI 应用缓存:各应用的结构化输出结果';
COMMENT ON COLUMN biz.ai_cache.target_id IS '目标 IDApp2=时间维度编码 / App3,6,7,8=member_id / App4,5={assistant_id}_{member_id}';
COMMENT ON COLUMN biz.ai_cache.score IS '评分:仅应用 6 使用1-10 分)';
CREATE INDEX IF NOT EXISTS idx_ai_cache_lookup ON biz.ai_cache (cache_type, site_id, target_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_ai_cache_cleanup ON biz.ai_cache (cache_type, site_id, target_id, created_at);
```
### target_id 约定
| cache_type | target_id 格式 | 示例 |
|---|---|---|
| app2_finance | 时间维度编码 | `this_month`, `last_week` |
| app3_clue | member_id | `12345` |
| app4_analysis | `{assistant_id}_{member_id}` | `100_12345` |
| app5_tactics | `{assistant_id}_{member_id}` | `100_12345` |
| app6_note_analysis | member_id | `12345` |
| app7_customer_analysis | member_id | `12345` |
| app8_clue_consolidated | member_id | `12345` |
### 应用 2 时间维度编码
| 编码 | 含义 | 计算规则 |
|---|---|---|
| `this_month` | 本月 | 当前营业日所在月 |
| `last_month` | 上月 | 当前月 - 1 |
| `this_week` | 本周 | 当前营业日所在周(周一起) |
| `last_week` | 上周 | 当前周 - 1 |
| `last_3_months` | 前 3 月(不含本月) | 当前月 - 3 ~ 当前月 - 1 |
| `this_quarter` | 本季 | 当前营业日所在季度 |
| `last_quarter` | 上季 | 当前季度 - 1 |
| `last_6_months` | 近 6 月(不含本月) | 当前月 - 6 ~ 当前月 - 1 |
营业日分界点:每日 08:00`BUSINESS_DAY_START_HOUR` 环境变量)。
### 应用输出 JSON Schema
#### 应用 3/6 线索格式(写入 ai_cache.result_json
```json
{
"clues": [
{
"category": "消费习惯",
"summary": "偏好周末下午时段消费",
"detail": "近 3 个月 8 次消费中 6 次在周六/日 14:00-18:00",
"emoji": "📅"
}
]
}
```
应用 6 额外包含 `score` 字段1-10写入 `ai_cache.score`
#### 应用 8 整合线索格式
```json
{
"clues": [
{
"category": "消费习惯",
"summary": "偏好周末下午时段消费",
"detail": "近 3 个月 8 次消费中 6 次在周六/日 14:00-18:00",
"emoji": "📅",
"providers": "系统,张三"
}
]
}
```
#### 应用 4 关系分析格式
```json
{
"task_description": "...",
"action_suggestions": ["建议1", "建议2"],
"one_line_summary": "..."
}
```
#### 应用 5 话术参考格式
```json
{
"tactics": [
{ "scenario": "...", "script": "..." }
]
}
```
#### 应用 7 客户分析格式
```json
{
"strategies": [
{ "title": "...", "content": "..." }
],
"summary": "..."
}
```
#### 应用 2 财务洞察格式
```json
{
"insights": [
{ "seq": 1, "title": "...", "body": "..." }
]
}
```
### 与现有表的关系
- `member_retention_clue`:应用 8 的 `ClueWriter` 全量替换 `source IN ('ai_consumption', 'ai_note')` 的记录,`source='manual'` 的人工线索不受影响
- `biz.notes`:应用 6 触发点,`note_service.create_note()` 中的 `ai_analyze_note()` 占位函数将被替换为真实调用
- `biz.trigger_jobs`:新增 AI 相关的事件触发器配置(`consumption_settled``note_created``task_assigned`
- `biz.coach_tasks`:应用 4 触发条件之一(任务分配事件)
## 正确性属性
*正确性属性是系统在所有合法执行路径上都应保持为真的特征或行为——本质上是对系统应做什么的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。*
### Property 1: BailianClient 双模式调用一致性
*For any* 合法的消息列表,`chat_stream` 应返回非空的 chunk 序列(拼接后为完整文本),`chat_json` 应返回可解析的 JSON dict 和正整数 tokens_used。两种模式对相同输入都应成功返回mock API 正常响应时)。
**Validates: Requirements 2.1, 2.3, 2.4**
### Property 2: 指数退避重试策略
*For any* 失败次数 n1 ≤ n ≤ max_retriesBailianClient 应在第 n 次失败后等待 base_interval × 2^(n-1) 秒后重试;当失败次数超过 max_retries 时,应抛出 BailianApiError。
**Validates: Requirements 2.2**
### Property 3: JSON 解析失败错误处理
*For any* 非法 JSON 字符串作为 API 响应,`chat_json` 应抛出 BailianJsonParseError 而非静默返回空值或崩溃。
**Validates: Requirements 2.5**
### Property 4: current_time 注入不变量
*For any* 消息列表,经 `_inject_current_time` 处理后,首条消息的 content解析为 JSON应包含 `current_time` 字段,且值为 ISO 格式的时间字符串(精确到秒),其余消息不受影响。
**Validates: Requirements 2.6**
### Property 5: AI 调用记录持久化 round-trip
*For any* AI 应用调用app1-app8调用完成后(a) `ai_conversations` 应包含一条匹配 app_id、site_id 的记录;(b) `ai_messages` 应包含至少一条 role='system' 或 role='user' 的输入消息和一条 role='assistant' 的输出消息;(c) 输出消息的 tokens_used 应为正整数。
**Validates: Requirements 3.2, 3.4, 3.5, 13.1, 13.2, 13.3**
### Property 6: 历史对话列表排序与分页
*For any* 用户和 site_id查询历史对话列表返回的记录应按 created_at 严格降序排列,且每页数量不超过 page_size默认 20
**Validates: Requirements 3.7**
### Property 7: 缓存写入 round-trip
*For any* AI 应用app2-app8的调用结果写入 `ai_cache` 后,按 (cache_type, site_id, target_id) 查询最新记录应返回与写入内容一致的 result_json。
**Validates: Requirements 4.7, 5.6, 6.6, 7.5, 8.6, 9.5, 10.10**
### Property 8: AI 应用输出 JSON 结构验证
*For any* AI 应用调用结果的 result_json
- App2: 应包含 `insights` 数组,每项含 `seq`(正整数)、`title`(非空字符串)、`body`(非空字符串)
- App3: 应包含 `clues` 数组,每条含 `category`(∈ {客户基础, 消费习惯, 玩法偏好})、`summary``detail``emoji`
- App4: 应包含 `task_description``action_suggestions`(数组)、`one_line_summary`
- App5: 应包含 `tactics` 数组
- App6: 应包含 `score`1-10 整数)和 `clues` 数组,每条 category ∈ 6 个枚举值
- App7: 应包含 `strategies` 数组(每项含 `title``content`)和 `summary`
- App8: 应包含 `clues` 数组,每条含 `category`(∈ 6 个枚举值)、`summary``detail``emoji``providers`
**Validates: Requirements 4.4, 5.2, 5.3, 5.4, 6.3, 7.3, 8.2, 8.3, 8.4, 9.2, 10.4, 10.5**
### Property 9: Prompt reference 历史注入
*For any* 应用 3/4/5/6/7/8 的 Prompt 构建reference 字段应包含相关应用的缓存结果(如有),且历史记录附带 `generated_at` 时间戳。当缓存不存在时reference 应为空对象。
**Validates: Requirements 5.8, 6.4, 6.5, 7.2, 7.4, 8.8, 9.7**
### Property 10: 事件调用链顺序正确性
*For any* 业务事件:
- 消费事件(无助教):调用顺序严格为 App3 → App8 → App7
- 消费事件(有助教):调用顺序严格为 App3 → App8 → {App7, App4 → App5}App7 和 App4 均在 App8 之后)
- 备注事件:调用顺序严格为 App6 → App8
- 任务分配事件:调用顺序严格为 App4 → App5
**Validates: Requirements 11.1, 11.2, 11.3, 11.4, 11.5, 11.6**
### Property 11: 调用链容错不变量
*For any* 调用链执行过程中某个应用调用失败,后续应用应继续执行(使用已有缓存),整条链不应因单点失败而中断。失败的应用应有错误日志记录。
**Validates: Requirements 11.7**
### Property 12: ClueWriter 全量替换不变量
*For any* member_id 和 site_id执行 `ClueWriter.replace_ai_clues(member_id, site_id, new_clues)` 后:
- (a) 该客户的 AI 来源线索source IN ('ai_consumption', 'ai_note'))应恰好等于 new_clues 的数量
- (b) 人工线索source='manual')的数量应与替换前完全一致
- (c) 写入的记录中 recorded_by_assistant_id 应为 NULL
- (d) summary 字段应为 emoji + 空格 + 原始 summary 的拼接格式
**Validates: Requirements 10.7, 10.8, 10.9**
### Property 13: 缓存查询 site_id 隔离
*For any* 两个不同的 site_idA 和 B写入 site_id=A 的缓存记录后,以 site_id=B 查询应返回空结果(即使 cache_type 和 target_id 相同)。
**Validates: Requirements 12.1, 12.5**
### Property 14: 缓存保留上限
*For any* (cache_type, site_id, target_id) 组合,无论写入多少条记录,清理后该组合的记录总数应 ≤ 500。
**Validates: Requirements 12.3**
## 错误处理
### 百炼 API 层
| 错误场景 | 处理策略 |
|---|---|
| API 超时 | 指数退避重试(最多 3 次),超时阈值 30s |
| API 返回 HTTP 4xx | 不重试,立即抛出 BailianApiError |
| API 返回 HTTP 5xx | 指数退避重试 |
| 响应非法 JSON | 抛出 BailianJsonParseError记录原始响应到日志 |
| API Key 无效 | 不重试,抛出 BailianAuthError记录告警日志 |
| 流式连接中断 | 已接收的 chunk 拼接为部分回复,标记 incomplete |
### 事件调度层
| 错误场景 | 处理策略 |
|---|---|
| 调用链中某应用失败 | 记录错误日志 + 写入失败 conversation 记录,后续应用使用已有缓存继续 |
| 数据库连接失败 | 整条链中止,记录错误日志 |
| 缓存查询失败 | 传空 reference 继续执行,不阻塞 |
### SSE 端点层
| 错误场景 | 处理策略 |
|---|---|
| 用户未认证 | 返回 HTTP 401 |
| 消息为空 | 返回 HTTP 422 |
| 流式过程中百炼 API 失败 | 发送 `{"type": "error", "message": "..."}` SSE 事件 |
| 客户端断开连接 | 取消百炼 API 调用,清理资源 |
### 缓存服务层
| 错误场景 | 处理策略 |
|---|---|
| 查询无结果 | 返回 null/None不抛异常 |
| 写入失败 | 抛出异常,由调用方处理 |
| 清理超限失败 | 记录警告日志,不影响写入操作 |
### ClueWriter 层
| 错误场景 | 处理策略 |
|---|---|
| 全量替换事务失败 | 回滚整个事务,保留原有线索不变 |
| 线索数据不符合 CHECK 约束 | 回滚事务记录错误日志category 枚举不匹配) |
## 测试策略
### 属性测试Property-Based Testing
- **测试库**hypothesisPython
- **最小迭代次数**:每个属性测试 100 次
- **测试文件位置**`tests/test_p5_ai_integration_properties.py`Monorepo 级)+ `apps/backend/tests/test_ai_*.py`(模块级)
- **标签格式**`# Feature: 05-miniapp-ai-integration, Property {N}: {property_text}`
每个正确性属性对应一个属性测试:
| Property | 测试策略 | 生成器 |
|---|---|---|
| P1: 双模式调用 | Mock 百炼 API验证两种模式返回格式 | 随机消息列表 |
| P2: 重试策略 | Mock 可控失败次数的 API | 随机失败次数 (0-5) |
| P3: JSON 解析失败 | Mock 返回非法 JSON | 随机非 JSON 字符串 |
| P4: current_time 注入 | 纯函数测试 | 随机消息列表 |
| P5: 记录持久化 | Mock 百炼 + 真实 DBtest_zqyy_app | 随机 app_id、消息内容 |
| P6: 历史列表排序 | 真实 DB | 随机对话记录(随机时间戳) |
| P7: 缓存 round-trip | 真实 DB | 随机 cache_type、target_id、result_json |
| P8: 输出 JSON 结构 | JSON Schema 验证 | 随机 AI 响应(符合各应用 schema |
| P9: reference 历史注入 | Mock 缓存数据 | 随机缓存记录(含/不含历史) |
| P10: 调用链顺序 | Mock 所有应用,记录调用序列 | 随机事件类型和参数 |
| P11: 调用链容错 | Mock 随机应用失败 | 随机失败位置 |
| P12: ClueWriter 替换 | 真实 DB | 随机线索列表 + 预置人工线索 |
| P13: site_id 隔离 | 真实 DB | 随机 site_id 对 |
| P14: 缓存上限 | 真实 DB | 批量写入(>500 条) |
### 单元测试
单元测试聚焦于具体示例和边界条件,与属性测试互补:
| 测试范围 | 测试内容 |
|---|---|
| 表结构验证 | 验证 3 张表的列、类型、约束、索引(需求 1.1-1.5 |
| App2 时间维度 | 验证 8 个时间维度编码的计算逻辑(营业日分界点 08:00 |
| App2 字段映射 | 验证 Prompt 使用 items_sum 口径而非 consume_money |
| SSE 协议 | 验证 Content-Type: text/event-stream 和事件格式 |
| ClueWriter 字段映射 | 验证 emoji+summary 拼接、source 判断逻辑 |
| 缓存 CHECK 约束 | 验证非法 cache_type 被拒绝 |
| App6 评分范围 | 验证 score 字段存储在 ai_cache.score |
### 集成测试
| 测试范围 | 测试内容 |
|---|---|
| 完整消费事件链 | Mock 百炼 API验证 App3→App8→App7 全链路 |
| 备注事件链 | Mock 百炼 API验证 App6→App8 全链路 |
| note_service 集成 | 验证 `ai_analyze_note` 占位函数被替换后的调用流程 |
| SSE 端到端 | 使用 httpx 的 SSE 客户端验证流式响应 |

View File

@@ -0,0 +1,257 @@
# 需求文档P5 AI 集成层miniapp-ai-integration
## 简介
本文档定义小程序 AI 集成层的需求规格,覆盖 P5-A 阶段(管道 + 骨架)。系统为台球门店助教和管理者提供 8 个 AI 应用,包括通用对话、财务洞察、客户数据分析、关系分析、话术参考、备注分析、客户分析和维客线索整理。技术栈为 FastAPI 后端 + 微信小程序前端 + PostgreSQLzqyy_app 业务库),通过阿里云百炼 API通义千问提供 AI 能力。
P5-A 阶段交付"管道":建表、百炼封装、缓存 API、SSE 框架,以及 Prompt 已完全确定的应用(应用 2、应用 8。应用 3/4/5/6/7 只实现触发机制和调用骨架Prompt 拼接函数留接口)。
> P5-B 阶段Prompt 细化)不在本 spec 范围,将分散到 P6task-detail和 P9customer-detail的对应任务中完成。
## 术语表
- **AI_Integration_System**P5 AI 集成层系统整体,包含后端 API、百炼封装、事件调度、缓存管理等
- **Bailian_Client**:百炼 API 统一封装层,负责与阿里云通义千问 API 的通信(流式/非流式)
- **SSE_Endpoint**Server-Sent Events 流式返回端点,用于应用 1 通用对话的逐字推送
- **AI_Cache_Service**AI 缓存读写服务,管理 `biz.ai_cache` 表的 CRUD 和保留策略
- **Event_Dispatcher**:事件调度器,负责根据业务事件(消费、备注、任务分配)触发对应 AI 应用调用链
- **Clue_Writer**:维客线索写入器,负责将应用 8 整合后的线索全量替换写入 `member_retention_clue`
- **App1_Chat**:应用 1 通用对话,用户主动发起的流式对话
- **App2_Finance**:应用 2 财务洞察,每日自动生成 8 个时间维度的财务分析
- **App3_Clue**:应用 3 客户数据维客线索分析,客户新增消费时自动触发
- **App4_Analysis**:应用 4 关系分析/任务建议,助教参与新结算或任务分配时触发
- **App5_Tactics**:应用 5 话术参考,联动应用 4 自动触发
- **App6_Note**:应用 6 备注分析,备注提交时自动触发
- **App7_Customer**:应用 7 客户分析,消费事件链中应用 8 完成后触发
- **App8_Consolidation**:应用 8 维客线索整理,应用 3 或应用 6 产出后触发
- **items_sum**:校准后的消费金额口径,= table_charge_money + goods_money + assistant_pd_money + assistant_cx_money + electricity_money禁止使用 consume_money
- **ai_conversations**AI 对话表,记录每次对话的元信息
- **ai_messages**AI 消息表,记录对话中的每条消息
- **ai_cache**AI 缓存表,存储各应用的结构化输出结果
- **member_retention_clue**:维客线索表,存储整合后的客户维护线索
- **营业日分界点**:每日 08:00用于时间维度计算的日切点
## 需求
### 需求 1数据库表结构
**用户故事:** 作为系统,我需要持久化存储所有 AI 对话记录和缓存结果,以便支撑 8 个 AI 应用的数据读写需求。
#### 验收标准
1. THE AI_Integration_System SHALL 在 `biz` schema 下创建 `ai_conversations`包含字段id、user_id、nickname、app_id、site_id、source_page、source_contextJSON、created_at
2. THE AI_Integration_System SHALL 在 `biz` schema 下创建 `ai_messages`包含字段id、conversation_id外键关联 ai_conversations、roleuser/assistant/system、content、tokens_used、created_at
3. THE AI_Integration_System SHALL 在 `biz` schema 下创建 `ai_cache`包含字段id、cache_type枚举app2_finance / app3_clue / app4_analysis / app5_tactics / app6_note_analysis / app7_customer_analysis / app8_clue_consolidated、site_id、target_id、result_json、score应用 6 专用、triggered_bytrigger_job_id、created_at、expires_at
4. THE AI_Integration_System SHALL 对 ai_cache 表的 target_id 按应用约定存储:应用 2 存时间维度编码、应用 3/6/7/8 存 member_id、应用 4/5 存 `{assistant_id}_{member_id}` 格式
5. THE AI_Integration_System SHALL 对所有三张表启用 site_id 字段以支持多门店隔离
### 需求 2百炼 API 统一封装层
**用户故事:** 作为开发者,我需要一个统一的百炼 API 封装层,以便所有 AI 应用通过一致的接口调用阿里云通义千问,降低重复代码和维护成本。
#### 验收标准
1. THE Bailian_Client SHALL 支持流式调用模式(用于应用 1 SSE 推送)和非流式调用模式(用于应用 2-8 结构化输出)
2. THE Bailian_Client SHALL 在 API 调用失败时执行自动重试(含指数退避策略)
3. THE Bailian_Client SHALL 记录每次 API 调用的 tokens_used 统计信息
4. THE Bailian_Client SHALL 支持 JSON 输出模式,确保应用 2-8 返回的内容可解析为结构化 JSON
5. IF 百炼 API 返回非预期格式或解析失败THEN THE Bailian_Client SHALL 记录错误日志并返回明确的错误信息
6. THE Bailian_Client SHALL 在每次调用的首条 Prompt JSON 中统一注入 `current_time` 字段(精确到秒)
### 需求 3应用 1 通用对话SSE 流式)
**用户故事:** 作为助教,我可以在任意页面点击 AI 按钮,跳转到对话页面与 AI 交流AI 了解当前页面上下文。
#### 验收标准
1. THE SSE_Endpoint SHALL 以 Server-Sent Events 协议向前端推送 AI 回复,实现逐字展示效果
2. WHEN 用户从任意页面进入 chat 页面时THE App1_Chat SHALL 始终新建一条 ai_conversations 记录(不复用已有对话)
3. THE App1_Chat SHALL 在首条消息中注入页面上下文,包含 source_page来源页面标识、page_context页面上下文摘要、screen_content屏幕可见内容文本化描述
4. WHEN 用户发送消息时THE App1_Chat SHALL 立即将用户消息写入 ai_messagesrole=user
5. WHEN 流式返回完成后THE App1_Chat SHALL 将完整的 assistant 回复写入 ai_messagesrole=assistant包含 tokens_used
6. THE App1_Chat SHALL 通过 `biz_params.user_prompt_params` 传入 User_ID、Role助教/管理者、Nickname 实现信息隔离
7. THE App1_Chat SHALL 提供历史对话列表接口,按时间倒序展示,每页 20 条懒加载
8. THE App1_Chat SHALL 搭建上下文注入框架页面文本化工具留接口P5-B 阶段各页面逐步实现)
### 需求 4应用 2 财务洞察
**用户故事:** 作为管理者,我在财务看板能看到 AI 生成的财务洞察分析,覆盖多个时间维度。
#### 验收标准
1. THE App2_Finance SHALL 由 ETL 调度器在每日 08:00营业日分界点后的首次任务执行时触发
2. THE App2_Finance SHALL 在 DWS 日更数据更新完成后,依次对 8 个时间维度发起独立调用(共 8 次百炼 API 调用)
3. THE App2_Finance SHALL 覆盖 8 个时间维度本月this_month、上月last_month、本周this_week、上周last_week、前 3 月不含本月last_3_months、本季this_quarter、上季last_quarter、近 6 月不含本月last_6_months
4. THE App2_Finance SHALL 返回结构化 JSON格式为序号 + 标题 + 正文的数组
5. THE App2_Finance SHALL 在 Prompt 中包含当期和上期的收入结构table_fee、assistant_pd、assistant_cx、goods、recharge、储值资产、费用汇总、平台结算数据
6. THE App2_Finance SHALL 使用已校准的收入结构字段映射table_fee = table_charge_money、assistant_pd = assistant_pd_money、assistant_cx = assistant_cx_money、goods = goods_money、recharge = 充值 pay_amountsettle_type=5
7. THE App2_Finance SHALL 将每次调用结果写入 ai_cachecache_type=app2_financetarget_id=时间维度编码)
8. IF ETL 调度器中尚无应用 2 的调度逻辑THEN THE AI_Integration_System SHALL 在 P5-A 阶段补充该调度任务
### 需求 5应用 3 客户数据维客线索分析(骨架)
**用户故事:** 作为系统,客户新增消费时自动通过 AI 分析客户数据,提取维客线索。
#### 验收标准
1. WHEN 客户新增消费结账单出现THE Event_Dispatcher SHALL 触发 App3_Clue 调用
2. THE App3_Clue SHALL 返回 JSON 格式的线索数组,每条线索包含 category分类标签、summary摘要、detail详情、emoji
3. THE App3_Clue SHALL 将分类标签限定为 3 个枚举值:客户基础、消费习惯、玩法偏好
4. THE App3_Clue SHALL 将线索提供者统一标记为"系统"
5. THE App3_Clue SHALL 使用 items_sum 作为消费金额口径(= table_charge_money + goods_money + assistant_pd_money + assistant_cx_money + electricity_money禁止使用 consume_money
6. THE App3_Clue SHALL 将结果写入 ai_cachecache_type=app3_cluetarget_id=member_id
7. THE App3_Clue SHALL 实现触发机制和调用框架Prompt 拼接函数留接口consumption_records 等字段待 P9-T1 细化)
8. THE App3_Clue SHALL 在 Prompt 的 reference 中包含应用 6 的线索结果(如有)和最近 2 套应用 8 的历史信息(附 generated_at 时间)
### 需求 6应用 4 关系分析/任务建议(骨架)
**用户故事:** 作为系统,助教参与新结算或被分配召回任务时,自动生成关系分析和任务建议。
#### 验收标准
1. WHEN 助教参与新结算时THE Event_Dispatcher SHALL 在消费事件链中等待应用 3 → 应用 8 完成后触发 App4_Analysis
2. WHEN 优先召回任务分配或高优先召回任务分配时THE Event_Dispatcher SHALL 直接触发 App4_Analysis读取应用 8 已有缓存)
3. THE App4_Analysis SHALL 返回 JSON 格式,包含任务描述、行动建议数组、一句话总结
4. THE App4_Analysis SHALL 在 Prompt 的 reference 中包含应用 8 当前最新维客线索和最近 2 套历史信息(附 generated_at 时间)
5. IF 应用 8 缓存不存在如新客户首次结算THEN THE App4_Analysis SHALL 在 reference 中传空对象Prompt 中标注"暂无历史线索"
6. THE App4_Analysis SHALL 将结果写入 ai_cachecache_type=app4_analysistarget_id=`{assistant_id}_{member_id}`
7. THE App4_Analysis SHALL 实现触发机制和调用框架Prompt 拼接函数留接口service_history、assistant_info 等字段待 P6-T4 细化)
### 需求 7应用 5 话术参考(骨架)
**用户故事:** 作为系统,应用 4 生成任务建议后,自动联动生成沟通话术参考。
#### 验收标准
1. WHEN App4_Analysis 调用完成后THE Event_Dispatcher SHALL 自动触发 App5_Tactics
2. THE App5_Tactics SHALL 接收应用 4 的完整返回结果作为 Prompt 中的 task_suggestion 字段
3. THE App5_Tactics SHALL 返回 JSON 格式的话术内容数组
4. THE App5_Tactics SHALL 在 Prompt 的 reference 中包含最近 2 套应用 8 的历史信息(附 generated_at 时间)
5. THE App5_Tactics SHALL 将结果写入 ai_cachecache_type=app5_tacticstarget_id=`{assistant_id}_{member_id}`
6. THE App5_Tactics SHALL 实现联动框架Prompt 拼接函数留接口service_history、assistant_info 等字段随应用 4 同步在 P6-T4 细化)
### 需求 8应用 6 备注分析(骨架)
**用户故事:** 作为系统,助教提交备注后,自动通过 AI 分析备注内容,提取维客线索并评分。
#### 验收标准
1. WHEN 备注提交时THE Event_Dispatcher SHALL 触发 App6_Note 调用
2. THE App6_Note SHALL 返回 JSON 格式,包含 score评分 1-10和 clues线索数组每条含 category、summary、detail、emoji
3. THE App6_Note SHALL 将分类标签限定为 6 个枚举值:客户基础、消费习惯、玩法偏好、促销偏好、社交关系、重要反馈
4. THE App6_Note SHALL 将线索提供者标记为当前备注提供人
5. THE App6_Note SHALL 使用 6 分为标准分的评分规则:重复信息/低价值/时效性低酌情扣分,高价值信息酌情加分
6. THE App6_Note SHALL 将结果写入 ai_cachecache_type=app6_note_analysistarget_id=member_idscore 字段存储评分
7. THE App6_Note SHALL 实现触发机制和调用框架Prompt 拼接函数留接口consumption_data 等字段待 P9-T1 细化)
8. THE App6_Note SHALL 在 Prompt 的 reference 中包含应用 3 的线索结果(如有)和最近 2 套应用 8 的历史信息(附 generated_at 时间)
### 需求 9应用 7 客户分析(骨架)
**用户故事:** 作为系统,客户结账单出现后自动生成客户全量分析与运营建议。
#### 验收标准
1. WHEN 消费事件链中 App8_Consolidation 完成后THE Event_Dispatcher SHALL 串行触发 App7_Customer确保读到本次消费触发的最新线索
2. THE App7_Customer SHALL 返回 JSON 格式,包含 strategies 数组(每条含 title 和 content和 summary一句话总结
3. THE App7_Customer SHALL 使用 items_sum 作为消费金额口径,禁止使用 consume_money
4. THE App7_Customer SHALL 对主观信息来自备注标注【来源XXX请甄别信息真实性】
5. THE App7_Customer SHALL 将结果写入 ai_cachecache_type=app7_customer_analysistarget_id=member_id
6. THE App7_Customer SHALL 实现触发机制和调用框架Prompt 拼接函数留接口objective_data 等字段待 P9-T1 细化)
7. THE App7_Customer SHALL 在 Prompt 的 reference 中包含最新 + 最近 2 套应用 8 的历史信息(附 generated_at 时间)
### 需求 10应用 8 维客线索整理
**用户故事:** 作为系统,应用 3 或应用 6 产出新线索后,自动整合去重生成统一维客线索,并写入 member_retention_clue 表。
#### 验收标准
1. WHEN App3_Clue 产出新线索后THE Event_Dispatcher SHALL 立即触发 App8_Consolidation
2. WHEN App6_Note 产出新线索后THE Event_Dispatcher SHALL 立即触发 App8_Consolidation
3. THE App8_Consolidation SHALL 接收应用 3 和应用 6 的全部线索内容作为输入(附 generated_at 时间)
4. THE App8_Consolidation SHALL 返回 JSON 格式的整合后线索数组,每条含 category、summary、detail、emoji、providers
5. THE App8_Consolidation SHALL 将分类标签限定为 6 个枚举值:客户基础、消费习惯、玩法偏好、促销偏好、社交关系、重要反馈(与 member_retention_clue 表 CHECK 约束一致)
6. THE App8_Consolidation SHALL 合并相似线索(多提供者以逗号分隔),其余线索原文返回,遵循最小改动原则
7. THE App8_Consolidation SHALL 将整合后的线索全量替换该客户在 member_retention_clue 中的所有 AI 来源线索source IN ('ai_consumption', 'ai_note')人工线索source='manual')不受影响
8. THE Clue_Writer SHALL 按以下字段映射写入 member_retention_cluecategory → category、emoji + summary → summaryemoji 拼接在前,如"📅 偏好周末下午时段消费"、detail → detail、providers → recorded_by_name、source 根据线索来源判断(纯应用 3 → ai_consumption纯应用 6 → ai_note混合来源 → ai_consumption
9. THE Clue_Writer SHALL 对系统触发的线索将 recorded_by_assistant_id 填 NULL
10. THE App8_Consolidation SHALL 将结果同时写入 ai_cachecache_type=app8_clue_consolidatedtarget_id=member_id
### 需求 11事件调度与调用链编排
**用户故事:** 作为系统,我需要根据业务事件(消费、备注、任务分配)自动编排 AI 应用调用链,确保执行顺序和数据依赖正确。
#### 验收标准
1. WHEN 消费事件结账单发生时THE Event_Dispatcher SHALL 按严格串行顺序执行:应用 3 → 应用 8 → 应用 7
2. WHEN 消费事件中该结算单有助教参与时THE Event_Dispatcher SHALL 在应用 8 完成后额外执行:应用 4 → 应用 5
3. WHEN 备注提交事件发生时THE Event_Dispatcher SHALL 按串行顺序执行:应用 6 → 应用 8
4. WHEN 任务分配事件(优先召回/高优先召回发生时THE Event_Dispatcher SHALL 执行:应用 4 → 应用 5直接读取应用 8 已有缓存)
5. THE Event_Dispatcher SHALL 确保消费事件链中应用 7 等待应用 8 完成后再启动,保证读到本次消费触发的最新线索
6. THE Event_Dispatcher SHALL 确保消费事件链中应用 4 等待应用 3 → 应用 8 完成后再执行,确保读到本次消费的最新线索
7. IF 调用链中某个应用调用失败THEN THE Event_Dispatcher SHALL 记录错误日志,后续应用使用已有缓存继续执行(不阻塞整条链)
### 需求 12AI 缓存读写 API
**用户故事:** 作为前端,我需要通过 API 读取各 AI 应用的缓存结果,以便在对应页面展示 AI 分析内容。
#### 验收标准
1. THE AI_Cache_Service SHALL 提供按 cache_type + site_id + target_id 查询最新缓存结果的 API
2. THE AI_Cache_Service SHALL 支持以下前端消费场景:应用 2 结果展示在 board-finance 财务看板、应用 4/5 结果展示在 task-detail 任务详情页、应用 6 的 score 以打星方式展示在备注卡片、应用 7 结果展示在 customer-detail 客户详情页、应用 8 结果通过 member_retention_clue 表展示
3. THE AI_Cache_Service SHALL 对每个 (cache_type, site_id, target_id) 组合保留最近 500 条记录,超过时删除最旧的
4. WHEN 写入新缓存记录后THE AI_Cache_Service SHALL 异步检查并清理超限记录
5. THE AI_Cache_Service SHALL 对所有查询和写入操作执行 site_id 隔离
### 需求 13AI 调用记录持久化
**用户故事:** 作为系统,所有 AI 对话(含用户主动和系统自动调用)都要持久化记录,以便追溯和统计。
#### 验收标准
1. THE AI_Integration_System SHALL 对所有 8 个应用的每次 AI 调用创建 ai_conversations 记录,包含 conversation_id、app_id、user_id系统调用时为系统标识、nickname、site_id
2. THE AI_Integration_System SHALL 对每次 AI 调用的输入和输出分别写入 ai_messages包含 roleuser/assistant/system、content、tokens_used、created_at
3. THE AI_Integration_System SHALL 在 ai_conversations 中记录 source_page 和 source_contextJSON标识调用来源
### 需求 14百炼技术方案确认
**用户故事:** 作为开发者,我需要确认百炼 API 的流式返回技术方案和 JSON 输出最佳实践,以便正确实现封装层。
#### 验收标准
1. THE AI_Integration_System SHALL 查阅百炼官方文档确认流式返回的技术方案SSE vs WebSocket
2. THE AI_Integration_System SHALL 确认百炼 API 的 JSON 输出模式配置方式response_format 参数或 System Prompt 约束)
3. THE AI_Integration_System SHALL 基于确认结果输出技术方案文档,作为 Bailian_Client 实现的依据
---
## 范围说明
### P5-A 阶段(本 spec 覆盖)
| 任务 | 对应需求 | 说明 |
|------|---------|------|
| T1 | 需求 1 | 建表ai_conversations + ai_messages + ai_cache |
| T2 | 需求 2 | 百炼 API 统一封装层 |
| T3 | 需求 3 | 应用 1 通用对话 SSE |
| T5 | 需求 4 | 应用 2 财务洞察Prompt 已确定) |
| T6-骨架 | 需求 5 | 应用 3 触发机制 + 调用框架 |
| T7-骨架 | 需求 6 | 应用 4 触发机制 + 调用框架 |
| T8-骨架 | 需求 7 | 应用 5 联动框架 |
| T9-骨架 | 需求 8 | 应用 6 触发机制 + 调用框架 |
| T10-骨架 | 需求 9 | 应用 7 触发机制 + 调用框架 |
| T11 | 需求 10 | 应用 8 维客线索整理Prompt 已确定) |
| T12 | 需求 12 | AI 缓存读写 API |
| T13 | 需求 14 | 百炼技术方案确认 |
| — | 需求 11 | 事件调度与调用链编排(贯穿 T6-T11 |
| — | 需求 13 | AI 调用记录持久化(贯穿所有应用) |
### P5-B 阶段(不在本 spec 范围)
以下任务将分散到对应页面的开发 spec 中完成:
- T4页面内容文本化工具 → 随 P6-P9 各页面逐步实现
- T6-完整:应用 3 Prompt JSON 细化 → P9-T1customer-detail API
- T7-完整:应用 4 Prompt JSON 细化 → P6-T4task-detail API
- T8-完整:应用 5 Prompt JSON 细化 → P6-T4task-detail API
- T9-完整:应用 6 Prompt JSON 细化 → P9-T1customer-detail API
- T10-完整:应用 7 Prompt JSON 细化 → P9-T1customer-detail API

View File

@@ -0,0 +1,322 @@
# 实现计划P5 AI 集成层miniapp-ai-integration
## 概述
基于 P5-A 阶段设计,在 `apps/backend/app/ai/` 新建 AI 模块,实现百炼 API 封装、SSE 对话、事件调度、缓存服务、8 个 AI 应用(其中 App2/App8 含完整 PromptApp3/4/5/6/7 仅骨架)。每个任务增量构建,最终通过路由和事件调度器串联所有组件。
## 任务
- [ ] 1. 数据库表结构与基础模块搭建
- [x] 1.1 创建 DDL 迁移脚本,在 `biz` schema 下建表 `ai_conversations``ai_messages``ai_cache`
- 按设计文档中的 DDL 创建三张表包含所有字段、CHECK 约束、索引
- DDL 文件放置于 `db/zqyy_app/migrations/` 目录,日期前缀命名
- _需求: 1.1, 1.2, 1.3, 1.4, 1.5_
- [x] 1.2 创建 AI 模块目录结构和 Pydantic Schema
- 创建 `apps/backend/app/ai/` 目录及 `__init__.py`
- 创建 `apps/backend/app/ai/apps/` 子目录及 `__init__.py`
- 创建 `apps/backend/app/ai/prompts/` 子目录及 `__init__.py`
-`apps/backend/app/ai/schemas.py` 中定义所有 Pydantic 模型:
- `ChatStreamRequest`message, source_page, page_context, screen_content
- `SSEEvent`type, content, conversation_id, tokens_used, message
- `CacheTypeEnum`7 个枚举值)
- `ClueItem`category, summary, detail, emoji
- `ConsolidatedClueItem`(含 providers
- `App2InsightItem``App4Result``App5TacticsItem``App6Result``App7Result`
- `App2Result``App3Result``App8Result`
- _需求: 4.4, 5.2, 5.3, 6.3, 7.3, 8.2, 8.3, 9.2, 10.4, 10.5_
- [x] 1.3 编写属性测试AI 应用输出 JSON 结构验证
- **Property 8: AI 应用输出 JSON 结构验证**
- 使用 hypothesis 生成随机 JSON验证各应用 Pydantic 模型的解析和校验
- 验证 App3 category ∈ {客户基础, 消费习惯, 玩法偏好}App6/8 category ∈ 6 个枚举值
- 测试文件:`tests/test_p5_ai_integration_properties.py`
- **验证: 需求 4.4, 5.2, 5.3, 5.4, 6.3, 7.3, 8.2, 8.3, 8.4, 9.2, 10.4, 10.5**
- [ ] 2. 百炼 API 统一封装层BailianClient
- [x] 2.1 实现 BailianClient 核心逻辑
- 文件:`apps/backend/app/ai/bailian_client.py`
- 使用 `openai` Python SDK`base_url` 指向百炼端点
- 实现 `chat_stream`流式AsyncGenerator[str, None]
- 实现 `chat_json`(非流式,返回 tuple[dict, int]
- 实现 `_inject_current_time`(首条消息注入 current_time
- 实现 `_call_with_retry`(指数退避,最多 3 次1s→2s→4s
- 定义异常类:`BailianApiError``BailianJsonParseError``BailianAuthError`
- 环境变量:`BAILIAN_API_KEY``BAILIAN_BASE_URL``BAILIAN_MODEL`
- _需求: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6_
- [x] 2.2 编写属性测试:双模式调用一致性
- **Property 1: BailianClient 双模式调用一致性**
- Mock 百炼 API验证 `chat_stream` 返回非空 chunk 序列,`chat_json` 返回可解析 JSON + 正整数 tokens_used
- 测试文件:`apps/backend/tests/test_ai_bailian.py`
- **验证: 需求 2.1, 2.3, 2.4**
- [x] 2.3 编写属性测试:指数退避重试策略
- **Property 2: 指数退避重试策略**
- Mock 可控失败次数的 API验证重试间隔为 base_interval × 2^(n-1),超过 max_retries 抛出 BailianApiError
- 测试文件:`apps/backend/tests/test_ai_bailian.py`
- **验证: 需求 2.2**
- [x] 2.4 编写属性测试JSON 解析失败错误处理
- **Property 3: JSON 解析失败错误处理**
- Mock 返回非法 JSON 字符串,验证 `chat_json` 抛出 BailianJsonParseError
- 测试文件:`apps/backend/tests/test_ai_bailian.py`
- **验证: 需求 2.5**
- [x] 2.5 编写属性测试current_time 注入不变量
- **Property 4: current_time 注入不变量**
- 纯函数测试,随机消息列表,验证首条消息注入 current_timeISO 格式精确到秒),其余消息不变
- 测试文件:`apps/backend/tests/test_ai_bailian.py`
- **验证: 需求 2.6**
- [x] 3. 对话记录持久化服务ConversationService
- [x] 3.1 实现 ConversationService
- 文件:`apps/backend/app/ai/conversation_service.py`
- `create_conversation`:创建 ai_conversations 记录,系统调用时 user_id='system'
- `add_message`:写入 ai_messages 记录role, content, tokens_used
- `get_conversations`:按 user_id + site_id 查询created_at DESC分页page_size=20
- `get_messages`:按 conversation_id 查询所有消息
- _需求: 3.2, 3.4, 3.5, 3.7, 13.1, 13.2, 13.3_
- [x] 3.2 编写属性测试AI 调用记录持久化 round-trip
- **Property 5: AI 调用记录持久化 round-trip**
- 使用 test_zqyy_app 数据库,随机 app_id 和消息内容,验证写入后查询一致
- 测试文件:`apps/backend/tests/test_ai_conversation.py`
- **验证: 需求 3.2, 3.4, 3.5, 13.1, 13.2, 13.3**
- [x] 3.3 编写属性测试:历史对话列表排序与分页
- **Property 6: 历史对话列表排序与分页**
- 使用 test_zqyy_app 数据库,随机时间戳创建对话,验证返回严格降序且每页 ≤ page_size
- 测试文件:`apps/backend/tests/test_ai_conversation.py`
- **验证: 需求 3.7**
- [ ] 4. AI 缓存读写服务AICacheService
- [x] 4.1 实现 AICacheService
- 文件:`apps/backend/app/ai/cache_service.py`
- `get_latest`:按 (cache_type, site_id, target_id) 查询最新记录
- `get_history`查询历史记录created_at DESC默认 limit=2用于 Prompt reference
- `write_cache`:写入缓存记录,写入后异步清理超限记录
- `_cleanup_excess`:保留最近 500 条,删除最旧的
- _需求: 12.1, 12.2, 12.3, 12.4, 12.5_
- [~] 4.2 编写属性测试:缓存写入 round-trip
- **Property 7: 缓存写入 round-trip**
- 使用 test_zqyy_app 数据库,随机 cache_type、target_id、result_json验证写入后查询一致
- 测试文件:`apps/backend/tests/test_ai_cache.py`
- **验证: 需求 4.7, 5.6, 6.6, 7.5, 8.6, 9.5, 10.10**
- [~] 4.3 编写属性测试:缓存查询 site_id 隔离
- **Property 13: 缓存查询 site_id 隔离**
- 使用 test_zqyy_app 数据库,写入 site_id=A 的记录,以 site_id=B 查询应返回空
- 测试文件:`apps/backend/tests/test_ai_cache.py`
- **验证: 需求 12.1, 12.5**
- [~] 4.4 编写属性测试:缓存保留上限
- **Property 14: 缓存保留上限**
- 使用 test_zqyy_app 数据库,批量写入 >500 条记录,验证清理后 ≤ 500
- 测试文件:`apps/backend/tests/test_ai_cache.py`
- **验证: 需求 12.3**
- [ ] 5. 检查点 - 基础服务验证
- 确保所有测试通过ask the user if questions arise.
- 验证 BailianClient、ConversationService、AICacheService 三个核心服务可独立工作
- [ ] 6. 应用 1 通用对话 SSE 端点
- [~] 6.1 实现 App1 Chat 核心逻辑
- 文件:`apps/backend/app/ai/apps/app1_chat.py`
- 每次进入 chat 页面新建 ai_conversations 记录(不复用)
- 首条消息注入页面上下文source_page、page_context、screen_content
- 用户消息立即写入 ai_messagesrole=user
- 流式返回完成后写入完整 assistant 回复(含 tokens_used
- 通过 `biz_params.user_prompt_params` 传入 User_ID、Role、Nickname
- 上下文注入框架留接口(页面文本化工具 P5-B 实现)
- _需求: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.8_
- [~] 6.2 实现 SSE 路由端点
- 文件:`apps/backend/app/routers/xcx_ai_chat.py`
- `POST /api/ai/chat/stream`SSE 协议推送Content-Type: text/event-stream
- SSE 事件格式chunk / done / error
- `GET /api/ai/conversations`:历史对话列表(分页,每页 20 条)
- `GET /api/ai/conversations/{conversation_id}/messages`:对话消息列表
- JWT 认证,从 token 提取 user_id、site_id、nickname、role
- 注册路由到 FastAPI app
- _需求: 3.1, 3.7_
- [~] 6.3 编写单元测试SSE 端点
- 验证 SSE Content-Type 和事件格式chunk/done/error
- 验证未认证返回 401、空消息返回 422
- 测试文件:`apps/backend/tests/test_ai_chat.py`
- _需求: 3.1_
- [ ] 7. 应用 2 财务洞察(完整 Prompt
- [~] 7.1 实现 App2 Finance Prompt 模板
- 文件:`apps/backend/app/ai/prompts/app2_finance_prompt.py`
- 完整 Prompt 包含当期和上期收入结构table_fee=table_charge_money、assistant_pd=assistant_pd_money、assistant_cx=assistant_cx_money、goods=goods_money、recharge=充值 pay_amount settle_type=5
- 包含储值资产、费用汇总、平台结算数据
- 使用 items_sum 口径,禁止 consume_money
- _需求: 4.5, 4.6_
- [~] 7.2 实现 App2 Finance 核心逻辑
- 文件:`apps/backend/app/ai/apps/app2_finance.py`
- 8 个时间维度独立调用this_month, last_month, this_week, last_week, last_3_months, this_quarter, last_quarter, last_6_months
- 营业日分界点 08:00`BUSINESS_DAY_START_HOUR` 环境变量)
- 每次调用结果写入 ai_cachecache_type=app2_financetarget_id=时间维度编码)
- 每次调用创建 ai_conversations + ai_messages 记录
- 返回结构化 JSONinsights 数组seq + title + body
- _需求: 4.1, 4.2, 4.3, 4.4, 4.7_
- [~] 7.3 编写单元测试App2 时间维度计算
- 验证 8 个时间维度编码的计算逻辑(营业日分界点 08:00
- 验证 Prompt 使用 items_sum 口径字段映射
- 测试文件:`apps/backend/tests/test_ai_app2.py`
- _需求: 4.3, 4.6_
- [ ] 8. 应用 3/4/5/6/7 骨架实现
- [~] 8.1 实现 App3 Clue 骨架
- 文件:`apps/backend/app/ai/apps/app3_clue.py`
- `run` 函数:构建 Prompt → 调用百炼 → 写入 conversation + cache
- `build_prompt`:留接口,返回占位 Prompt标注待细化字段consumption_records 等待 P9-T1
- 线索 category 限定 3 个枚举值providers 标记为"系统"
- 使用 items_sum 口径
- Prompt reference 包含 App6 线索 + 最近 2 套 App8 历史(附 generated_at
- 结果写入 ai_cachecache_type=app3_cluetarget_id=member_id
- _需求: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7, 5.8_
- [~] 8.2 实现 App4 Analysis 骨架
- 文件:`apps/backend/app/ai/apps/app4_analysis.py`
- `build_prompt`留接口service_history、assistant_info 待 P6-T4
- Prompt reference 包含 App8 最新 + 最近 2 套历史(附 generated_at
- 缓存不存在时 reference 传空对象,标注"暂无历史线索"
- 结果写入 ai_cachecache_type=app4_analysistarget_id=`{assistant_id}_{member_id}`
- _需求: 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 6.7_
- [~] 8.3 实现 App5 Tactics 骨架
- 文件:`apps/backend/app/ai/apps/app5_tactics.py`
- 接收 App4 完整返回结果作为 Prompt 中的 task_suggestion 字段
- `build_prompt`留接口service_history、assistant_info 随 App4 同步在 P6-T4
- Prompt reference 包含最近 2 套 App8 历史(附 generated_at
- 结果写入 ai_cachecache_type=app5_tacticstarget_id=`{assistant_id}_{member_id}`
- _需求: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6_
- [~] 8.4 实现 App6 Note 骨架
- 文件:`apps/backend/app/ai/apps/app6_note.py`
- `build_prompt`留接口consumption_data 待 P9-T1
- 返回 score1-10+ clues 数组category 限定 6 个枚举值
- 线索提供者标记为当前备注提供人
- 评分规则6 分为标准分
- Prompt reference 包含 App3 线索 + 最近 2 套 App8 历史(附 generated_at
- 结果写入 ai_cachecache_type=app6_note_analysistarget_id=member_idscore 存入 ai_cache.score
- _需求: 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 8.7, 8.8_
- [~] 8.5 实现 App7 Customer 骨架
- 文件:`apps/backend/app/ai/apps/app7_customer.py`
- `build_prompt`留接口objective_data 待 P9-T1
- 使用 items_sum 口径
- 对主观信息标注【来源XXX请甄别信息真实性】
- Prompt reference 包含最新 + 最近 2 套 App8 历史(附 generated_at
- 结果写入 ai_cachecache_type=app7_customer_analysistarget_id=member_id
- _需求: 9.1, 9.2, 9.3, 9.4, 9.5, 9.6, 9.7_
- [~] 8.6 编写属性测试Prompt reference 历史注入
- **Property 9: Prompt reference 历史注入**
- Mock 缓存数据,验证各应用 build_prompt 的 reference 字段包含正确的缓存结果和 generated_at 时间戳
- 缓存不存在时 reference 为空对象
- 测试文件:`apps/backend/tests/test_ai_apps_prompt.py`
- **验证: 需求 5.8, 6.4, 6.5, 7.2, 7.4, 8.8, 9.7**
- [ ] 9. 应用 8 维客线索整理(完整 Prompt+ ClueWriter
- [~] 9.1 实现 App8 Consolidation Prompt 模板
- 文件:`apps/backend/app/ai/prompts/app8_consolidation_prompt.py`
- 完整 Prompt接收 App3 和 App6 全部线索(附 generated_at整合去重
- 分类标签限定 6 个枚举值(与 member_retention_clue CHECK 约束一致)
- 合并相似线索(多提供者逗号分隔),其余原文返回,最小改动原则
- _需求: 10.3, 10.4, 10.5, 10.6_
- [~] 9.2 实现 ClueWriter 全量替换逻辑
- 集成在 `apps/backend/app/ai/apps/app8_consolidation.py`
- DELETE source IN ('ai_consumption', 'ai_note') → INSERT 新线索(事务)
- 字段映射emoji+summary 拼接、providers→recorded_by_name、source 判断逻辑
- recorded_by_assistant_id 填 NULL
- 人工线索source='manual')不受影响
- _需求: 10.7, 10.8, 10.9_
- [~] 9.3 实现 App8 Consolidation 核心逻辑
- 文件:`apps/backend/app/ai/apps/app8_consolidation.py`
- `run` 函数:构建 Prompt → 调用百炼 → 写入 conversation + cache + member_retention_clue
- 结果同时写入 ai_cachecache_type=app8_clue_consolidatedtarget_id=member_id
- _需求: 10.1, 10.2, 10.10_
- [~] 9.4 编写属性测试ClueWriter 全量替换不变量
- **Property 12: ClueWriter 全量替换不变量**
- 使用 test_zqyy_app 数据库,随机线索列表 + 预置人工线索
- 验证AI 线索数量 = new_clues 数量、人工线索不变、recorded_by_assistant_id=NULL、summary=emoji+空格+原始 summary
- 测试文件:`apps/backend/tests/test_ai_clue_writer.py`
- **验证: 需求 10.7, 10.8, 10.9**
- [ ] 10. 检查点 - 应用层验证
- 确保所有测试通过ask the user if questions arise.
- 验证 8 个应用的 run 函数可独立调用Mock 百炼 API
- [ ] 11. 事件调度与调用链编排AIDispatcher
- [~] 11.1 实现 AIDispatcher 核心逻辑
- 文件:`apps/backend/app/ai/dispatcher.py`
- `handle_consumption_event`App3 → App8 → App7+ App4 → App5 如有助教)
- `handle_note_event`App6 → App8
- `handle_task_assign_event`App4 → App5读已有 App8 缓存)
- `_run_chain`:串行执行调用链,某步失败记录日志后继续
- 容错:失败应用记录错误日志 + 写入失败 conversation后续应用使用已有缓存
- 整条链后台异步执行,不阻塞业务请求
- _需求: 11.1, 11.2, 11.3, 11.4, 11.5, 11.6, 11.7_
- [~] 11.2 集成事件触发点
-`trigger_scheduler.fire_event()` 中注册 AI 事件处理器
- 消费事件consumption_settled`ai_dispatcher.handle_consumption_event`
- 备注事件note_created`ai_dispatcher.handle_note_event`
- 任务分配事件task_assigned`ai_dispatcher.handle_task_assign_event`
- _需求: 5.1, 6.1, 6.2, 7.1, 8.1, 9.1, 11.1, 11.2, 11.3, 11.4_
- [~] 11.3 编写属性测试:事件调用链顺序正确性
- **Property 10: 事件调用链顺序正确性**
- Mock 所有应用,记录调用序列,验证四种事件链的严格顺序
- 测试文件:`apps/backend/tests/test_ai_dispatcher.py`
- **验证: 需求 11.1, 11.2, 11.3, 11.4, 11.5, 11.6**
- [~] 11.4 编写属性测试:调用链容错不变量
- **Property 11: 调用链容错不变量**
- Mock 随机应用失败,验证后续应用继续执行且失败应用有错误日志
- 测试文件:`apps/backend/tests/test_ai_dispatcher.py`
- **验证: 需求 11.7**
- [ ] 12. 缓存查询路由与环境配置
- [~] 12.1 实现缓存查询路由
- 文件:`apps/backend/app/routers/xcx_ai_cache.py`
- `GET /api/ai/cache/{cache_type}?target_id=xxx`:查询最新缓存
- JWT 认证site_id 从 token 提取强制过滤
- 注册路由到 FastAPI app
- _需求: 12.1, 12.2, 12.5_
- [~] 12.2 新增环境变量配置
-`.env.template` 中添加 `BAILIAN_API_KEY``BAILIAN_BASE_URL``BAILIAN_MODEL``BUSINESS_DAY_START_HOUR`
- 在后端配置加载逻辑中读取这些变量,缺失时报错
- _需求: 2.1, 14.1, 14.2_
- [ ] 13. 百炼技术方案确认文档
- [ ] 13.1 输出百炼技术方案确认文档
- 文件:`docs/reports/bailian-technical-solution.md`
- 确认流式返回方案OpenAI 兼容 SSE
- 确认 JSON 输出模式response_format + System Prompt 约束)
- 确认 SDK 选择openai Python SDK + base_url 指向百炼)
- 作为 BailianClient 实现的依据
- _需求: 14.1, 14.2, 14.3_
- [ ] 14. 最终检查点 - 全量验证
- 确保所有测试通过ask the user if questions arise.
- 验证所有路由注册正确、事件触发点集成完毕、环境变量配置完整
## 备注
- 标记 `*` 的任务为可选,可跳过以加速 MVP 交付
- 每个任务引用具体需求编号以确保可追溯
- 属性测试验证设计文档中定义的 14 个正确性属性
- 使用 test_zqyy_app 测试库执行数据库相关测试,禁止连接正式库
- App3/4/5/6/7 的 Prompt 细化将在 P5-B 阶段P6/P9 对应任务)中完成

View File

@@ -7,7 +7,7 @@
核心流程:
1. 启动后端 + 前端服务
2. 通过 API 登录获取 JWT
3. 提交全流程 ETL 任务api_full, full_window, force-full, 全选常用任务, 自定义窗口 2025-11-01~2026-02-20, 30天切分, 全部门店)
3. 提交全流程 ETL 任务api_full, full_window, force-full, 全选常用任务, 自定义窗口 2025-11-01~当前时间, 30天切分, 全部门店)
4. 实时监控执行过程,捕获错误/警告
4. 执行完成后进行黑盒数据一致性测试(全链路检查器 `scripts/ops/etl_consistency_check.py` + FlowRunner 内置 `ConsistencyChecker`
5. 生成综合报告(含性能数据和黑盒测试结果)
@@ -56,7 +56,7 @@ INTEGRATION_TASK_CONFIG = {
"processing_mode": "full_window", # 全窗口处理
"window_mode": "custom", # 自定义时间范围
"window_start": "2025-11-01 00:00",
"window_end": "2026-02-20 00:00",
"window_end": "", # 补充当前时间
"window_split": "day", # 按天切分
"window_split_days": 30, # 30天一个切片
"force_full": True, # 强制全量

View File

@@ -30,7 +30,7 @@
#### 验收标准
1. WHEN 提交 TaskConfigapi_full + full_window + 全选常用任务 + 自定义窗口 2025-11-01~2026-02-20 + 30天切分 + force-full, THE Backend_API SHALL 验证配置有效并返回 CLI 命令预览
1. WHEN 提交 TaskConfigapi_full + full_window + 全选常用任务 + 自定义窗口 2025-11-01~当前时间 + 30天切分 + force-full, THE Backend_API SHALL 验证配置有效并返回 CLI 命令预览
2. WHEN 提交任务到执行队列, THE Backend_API SHALL 创建队列任务并自动开始执行
3. WHILE ETL 子进程运行中, THE Backend_API SHALL 通过 WebSocket 推送实时日志
4. WHEN ETL 子进程完成, THE Backend_API SHALL 记录退出码、执行时长、完整日志到 task_execution_log

View File

@@ -2,7 +2,7 @@
## 概述
基于 `admin-web-console` 已完成的前后端代码,进行端到端联调验证。全程使用 Playwright 浏览器模拟真实用户操作(登录、配置、提交、监控),不直接调用 API。通过管理后台 UI 提交 api_full 全流程 ETL 任务(自定义窗口 2025-11-01~2026-02-2030天切分force-full全选常用任务实时监控执行过程收集性能数据执行黑盒数据一致性测试全链路检查器 `scripts/ops/etl_consistency_check.py` + FlowRunner 内置 `ConsistencyChecker`),最终生成综合报告。
基于 `admin-web-console` 已完成的前后端代码,进行端到端联调验证。全程使用 Playwright 浏览器模拟真实用户操作(登录、配置、提交、监控),不直接调用 API。通过管理后台 UI 提交 api_full 全流程 ETL 任务(自定义窗口 2025-11-01~当前时间30天切分force-full全选常用任务实时监控执行过程收集性能数据执行黑盒数据一致性测试全链路检查器 `scripts/ops/etl_consistency_check.py` + FlowRunner 内置 `ConsistencyChecker`),最终生成综合报告。
## 任务
@@ -24,11 +24,11 @@
- 确认侧边栏导航菜单正常渲染任务配置、任务管理、ETL 状态、数据库、日志、环境配置、运维面板)
- _Requirements: 1.3, 1.4, 1.5_
- [x] 2. 浏览器操作:任务配置与提交
- [-] 2. 浏览器操作:任务配置与提交
- [x] 2.1 在任务配置页面填写全流程参数
- 在任务配置页面(`/`),选择 Flow 为 `api_full`API → ODS → DWD → DWS → INDEX
- 选择处理模式为 `full_window`(全窗口)
- 设置时间窗口模式为"自定义",填入开始时间 `2025-11-01`、结束时间 `2026-02-20`
- 设置时间窗口模式为"自定义",填入开始时间 `2025-11-01`、结束时间 当前时间
- 设置窗口切分为"按天",切分天数为 `30`
- 勾选 `force_full`(强制全量)
- 在任务选择区域,全选 `is_common=True` 的常用任务(共 41 个)
@@ -41,7 +41,7 @@
- 记录返回的 execution_id从页面响应或跳转中获取
- _Requirements: 2.2, 2.4_
- [ ] 3. 浏览器操作:执行监控与 DEBUG
- [x] 3. 浏览器操作:执行监控与 DEBUG
- [x] 3.1 在任务管理页面监控执行状态
- 导航到"任务管理"页面(`/task-manager`),点击侧边栏"任务管理"菜单
- 在"队列"Tab 中确认刚提交的任务状态为 `running`
@@ -52,15 +52,15 @@
- 任务完成success/failed/cancelled时停止监控
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5_
- [ ] 3.2 对执行过程中发现的错误/警告进行 DEBUG 分析
- [x] 3.2 对执行过程中发现的错误/警告进行 DEBUG 分析
- 从日志流中收集所有 ERROR 和 WARNING 日志行及其上下文
- 分析错误类型API 超时、数据库连接、数据质量、配置问题等
- 如果任务失败,切换到"历史"Tab 查看完整执行详情和日志
- 记录 DEBUG 发现到报告中
- _Requirements: 3.2, 3.5_
- [ ] 4. 性能计时与报告生成
- [ ] 4.1 从浏览器获取执行日志,提取精细计时数据
- [x] 4. 性能计时与报告生成
- [x] 4.1 从浏览器获取执行日志,提取精细计时数据
- 在"任务管理"→"历史"Tab 中,点击已完成的任务查看执行详情
- 通过 `GET /api/execution/{id}/logs` 获取完整日志(可通过浏览器或 API 辅助)
- 从日志中提取每个窗口切片30天的开始/结束时间
@@ -69,7 +69,7 @@
- 标注 Top-5 耗时最长的阶段/任务
- _Requirements: 4.1, 4.2, 4.3, 4.4_
- [ ] 4.2 生成综合联调报告,输出到 SYSTEM_LOG_ROOT
- [x] 4.2 生成综合联调报告,输出到 SYSTEM_LOG_ROOT
- 报告包含:执行概要(参数、时间、退出码)
- 报告包含性能报告各切片耗时对比、Top-5 瓶颈)
- 报告包含DEBUG 报告(如有错误/警告)
@@ -78,8 +78,8 @@
- 路径通过 `SYSTEM_LOG_ROOT` 环境变量获取,缺失时报错
- _Requirements: 6.1, 6.2, 6.4, 6.5_
- [ ] 5. 黑盒数据一致性测试
- [ ] 5.1 运行全链路检查器,执行 API→ODS→DWD→DWS 四层数据一致性检查
- [x] 5. 黑盒数据一致性测试
- [x] 5.1 运行全链路检查器,执行 API→ODS→DWD→DWS 四层数据一致性检查
- 运行 `uv run python scripts/ops/etl_consistency_check.py`cwd 为项目根目录 `C:\NeoZQYY`
- 脚本自动从 `LOG_ROOT` 找到最近一次 ETL 日志,解析本次执行的任务列表
- 脚本自动从 `FETCH_ROOT` 读取 API JSON 落盘文件
@@ -92,22 +92,22 @@
- 备选触发方式:可通过 `etl-data-consistency` Hook 手动触发(效果等同)
- _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5_
- [ ] 5.2 检查 FlowRunner 内置一致性报告
- [x] 5.2 检查 FlowRunner 内置一致性报告
- FlowRunner 在 ETL 执行完成后已自动调用 `_run_post_consistency_check()` 生成报告到 `ETL_REPORT_ROOT`
- 确认内置报告已生成,检查 API vs ODS 和 ODS vs DWD 的通过/失败统计
- 内置检查使用 `ODS_META_COLUMNS` 白名单(含 `record_index`)和 `KNOWN_NO_SOURCE` 按表白名单
- 对比两份报告的结论是否一致(全链路检查器 vs FlowRunner 内置检查)
- _Requirements: 5.1, 5.2, 5.3_
- [ ] 5.3 将黑盒测试结果摘要写入综合联调报告
- [x] 5.3 将黑盒测试结果摘要写入综合联调报告
- 在任务 4.2 生成的联调报告中追加"黑盒测试报告"章节
- 包含API vs ODS 通过数/总数、ODS vs DWD 通过数/总数、DWD vs DWS 表概览
- 包含:白名单差异数量统计、失败表清单
- 引用全链路检查报告的完整路径
- _Requirements: 6.3_
- [ ] 6. 服务清理
- [ ] 6.1 关闭浏览器,停止后端和前端服务,清理资源
- [x] 6. 服务清理
- [x] 6.1 关闭浏览器,停止后端和前端服务,清理资源
- 关闭 Playwright 浏览器实例
- 停止 uvicorn 后端进程(`controlPwshProcess` stop
- 停止 pnpm dev 前端进程(`controlPwshProcess` stop

View File

@@ -132,12 +132,40 @@
#### 验收标准
1. THE Admin_Web SHALL 提供侧边栏导航,包含个功能模块入口任务配置、任务管理、环境配置、数据库、ETL 状态、日志
1. THE Admin_Web SHALL 提供侧边栏导航,包含个功能模块入口任务配置、任务管理、环境配置、数据库、ETL 状态、日志、球房编号管理、租户管理员管理
2. WHEN Operator 点击导航项, THE Admin_Web SHALL 切换到对应的功能模块页面,且不触发整页刷新
3. THE Admin_Web SHALL 在状态栏区域展示当前数据库连接状态和任务执行状态
4. WHILE 有任务正在执行, THE Admin_Web SHALL 在导航栏或状态栏显示执行中的视觉指示
### 需求 11Task_Config 序列化与反序列化
### 需求 11租户管理员账号管理
**用户故事:** 作为系统管理员Operator我需要在系统管理后台中创建和管理租户管理员账号以便租户管理员能登录独立的租户管理后台`apps/tenant-admin/`进行用户审核、Excel 上传等操作。
#### 验收标准
1. WHEN Operator 打开租户管理员管理页面, THE Admin_Web SHALL 展示所有租户管理员账号列表,包含用户名、所属租户、管辖球房列表、账号状态(启用/禁用)、创建时间
2. WHEN Operator 创建租户管理员账号, THE Backend_API SHALL 接受用户名、初始密码、所属租户标识、管辖球房 ID 列表(`site_id` 数组),并在 `auth` Schema 中创建对应记录,密码以 bcrypt 哈希存储
3. WHEN Operator 编辑租户管理员账号, THE Backend_API SHALL 允许修改管辖球房列表、账号状态(启用/禁用),以及重置密码
4. WHEN Operator 禁用某租户管理员账号, THE Backend_API SHALL 将该账号状态设为禁用,该管理员后续登录租户管理后台时 SHALL 被拒绝
5. WHEN Operator 为租户管理员分配球房, THE Backend_API SHALL 验证球房 ID`site_id`)在 `auth.site_code_mapping` 中存在,不存在时返回 422 错误
6. THE Backend_API SHALL 确保同一用户名不可重复创建(唯一约束)
7. WHEN Operator 查看某租户管理员详情, THE Admin_Web SHALL 展示该管理员管辖的球房列表及每个球房的球房代码(`site_code`)和名称
### 需求 12球房编号管理
**用户故事:** 作为系统管理员Operator我需要在系统管理后台中为每个门店`site_id`)分配球房编号(`site_code`),以便小程序用户申请时通过球房编号定位到对应门店。
#### 验收标准
1. WHEN Operator 打开球房编号管理页面, THE Admin_Web SHALL 展示 `auth.site_code_mapping` 中所有球房编号映射列表,包含球房编号(`site_code`)、门店 ID`site_id`)、创建时间
2. WHEN Operator 新增球房编号映射, THE Backend_API SHALL 接受 `site_code`格式2 字母 + 3 数字,如 `AB123`)和 `site_id`BIGINT验证格式正确后写入 `auth.site_code_mapping`
3. IF 提交的 `site_code` 已存在, THEN THE Backend_API SHALL 返回 409 冲突错误
4. IF 提交的 `site_id` 已绑定其他 `site_code`, THEN THE Backend_API SHALL 返回 409 冲突错误(`site_code``site_id` 一对一)
5. WHEN Operator 编辑球房编号映射, THE Backend_API SHALL 允许修改 `site_code`(需验证新编号不与其他记录冲突)
6. WHEN Operator 删除球房编号映射, THE Backend_API SHALL 检查是否有用户申请引用该 `site_code`,若有则拒绝删除并提示关联数据存在
7. THE Admin_Web SHALL 在球房编号管理页面提供搜索功能,支持按 `site_code``site_id` 搜索
### 需求 13Task_Config 序列化与反序列化
**用户故事:** 作为 Operator我希望任务配置能在前后端之间正确传输和持久化以确保配置不丢失。

View File

@@ -0,0 +1 @@
{"specId": "98a585de-82d9-4bbd-bed8-179208c12f8b", "workflowType": "requirements-first", "specType": "feature"}

View File

@@ -0,0 +1,422 @@
# 设计文档业务日分割点机制Business Day Cutoff
## 概述
本设计将全系统的统计时间口径从自然日切换为以可配置小时值(默认 08:00为分割点的营业日。影响范围覆盖六个层面
1. **配置层**`.env` 新增 `BUSINESS_DAY_START_HOUR`ETL `AppConfig` 和后端 `config.py` 同步加载
2. **共享工具层**`packages/shared``datetime_utils.py` 扩展 `business_day_range``business_week_range``business_month_range` 三个范围函数
3. **ETL DWS 层**`BaseDwsTask.iter_dwd_rows``DATE()` 替换为 `biz_date_sql_expr`18 个具体 DWS 任务的 SQL 全面改造
4. **后端 API 层**:新增 `/api/config/business-day` 端点,时间范围查询统一使用 `business_*_range` 函数
5. **数据库层**:新增 PostgreSQL `biz_date()` 函数,物化视图迁移
6. **前端展示层**:管理后台日期选择器标注营业日口径,小程序透传后端数据
### 设计决策
1. **单一配置源**`BUSINESS_DAY_START_HOUR` 仅在根 `.env` 定义一次ETL 通过 `AppConfig`、后端通过 `config.py`、数据库通过迁移脚本参数化读取,避免多处硬编码导致不一致。
2. **共享包作为唯一逻辑实现**:所有营业日归属计算集中在 `packages/shared/datetime_utils.py`ETL 和后端均从此导入,禁止各子系统重复实现。
3. **SQL 表达式生成器模式**`biz_date_sql_expr(col, hour)` 生成 `DATE(col - INTERVAL 'N hours')` 字符串DWS 任务在 SQL 拼接时调用,避免在 Python 侧逐行转换。
4. **BaseDwsTask 基类统一改造**`iter_dwd_rows` 的日期过滤从 `DATE(col)` 改为 `biz_date_sql_expr(col)`,所有子类自动继承,减少逐任务修改量。
5. **物化视图通过迁移脚本重建**:物化视图的时间过滤条件无法动态参数化,需通过 SQL 迁移脚本 DROP + CREATE 重建。
6. **历史数据重算采用 CLI 批量模式**:提供独立重算脚本,复用正式 ETL 任务逻辑(相同 `Business_Day_Cutoff` 配置),按日期窗口分批执行。
## 架构
```mermaid
graph TD
subgraph 配置层
ENV[".env<br/>BUSINESS_DAY_START_HOUR=8"]
ENV --> AC[AppConfig<br/>app.business_day_start_hour]
ENV --> BC[Backend config.py<br/>BUSINESS_DAY_START_HOUR]
end
subgraph 共享工具层
DU["datetime_utils.py<br/>business_date / business_*_range<br/>biz_date_sql_expr"]
end
subgraph ETL DWS 层
BDT["BaseDwsTask<br/>iter_dwd_rowsbiz_date_sql_expr<br/>get_time_window_range"]
BDT --> FT["FinanceBaseTask / FinanceDailyTask<br/>FinanceRechargeTask / FinanceDiscountTask<br/>FinanceIncomeTask"]
BDT --> AT["AssistantDailyTask / AssistantMonthlyTask<br/>AssistantFinanceTask / AssistantCustomerTask<br/>AssistantOrderContributionTask"]
BDT --> MT["MemberVisitTask / MemberConsumptionTask"]
BDT --> GT["GoodsStockDailyTask / WeeklyTask / MonthlyTask"]
BDT --> IT["SpendingPowerIndexTask / MemberIndexBase"]
BDT --> MV["MvRefreshTask"]
end
subgraph 后端 API 层
API["/api/config/business-day<br/>GET → business_day_start_hour"]
TR["时间范围计算<br/>business_day_range / week_range / month_range"]
end
subgraph 数据库层
PGF["biz_date(timestamptz, int)<br/>PostgreSQL 函数"]
MVR["物化视图重建<br/>迁移脚本"]
end
subgraph 前端
AW["Admin_Web<br/>日期选择器标注"]
MP["Miniprogram<br/>透传后端数据"]
end
AC --> BDT
AC --> DU
BC --> API
BC --> TR
DU --> BDT
DU --> TR
API --> AW
API --> MP
PGF --> MVR
```
## 组件与接口
### 1. 共享时间工具(`packages/shared/src/neozqyy_shared/datetime_utils.py`
现有函数(已实现):
- `business_date(dt, day_start_hour) -> date`
- `business_month(dt, day_start_hour) -> date`
- `business_week_monday(dt, day_start_hour) -> date`
- `biz_date_sql_expr(col, day_start_hour) -> str`
新增函数:
```python
def business_day_range(biz_date: date, day_start_hour: int = 8) -> tuple[datetime, datetime]:
"""返回给定营业日的精确时间戳范围 [start, end)。
start = biz_date 当天 day_start_hour:00
end = biz_date 次日 day_start_hour:00
"""
def business_week_range(week_monday: date, day_start_hour: int = 8) -> tuple[datetime, datetime]:
"""返回给定营业周(周一)的精确时间戳范围 [start, end)。
start = week_monday 当天 day_start_hour:00
end = week_monday + 7天 day_start_hour:00
"""
def business_month_range(month_first: date, day_start_hour: int = 8) -> tuple[datetime, datetime]:
"""返回给定营业月(首日)的精确时间戳范围 [start, end)。
start = month_first 当天 day_start_hour:00
end = 次月1日 day_start_hour:00
"""
```
所有 `*_range` 函数返回的时间戳带 `Asia/Shanghai` 时区信息(使用 `SHANGHAI_TZ`)。
### 2. ETL 配置层(`apps/etl/connectors/feiqiu/config/`
**已完成**(代码库中已存在):
- `defaults.py``"app": {"business_day_start_hour": 8}`
- `env_parser.py``"BUSINESS_DAY_START_HOUR": ("app.business_day_start_hour",)`
**需新增**`settings.py``_validate` 方法增加范围校验:
```python
# 在 _validate 中新增
hour = cfg["app"].get("business_day_start_hour", 8)
if not isinstance(hour, int) or not (0 <= hour <= 23):
raise SystemExit("app.business_day_start_hour 必须为 023 的整数")
```
### 3. 后端配置层(`apps/backend/app/config.py`
新增模块级常量:
```python
BUSINESS_DAY_START_HOUR: int = int(get("BUSINESS_DAY_START_HOUR", "8"))
```
### 4. 后端配置查询 API`apps/backend/app/routers/business_day.py`
```python
router = APIRouter(prefix="/api/config", tags=["业务配置"])
@router.get("/business-day")
async def get_business_day_config():
"""返回当前营业日分割点配置。"""
return {"business_day_start_hour": config.BUSINESS_DAY_START_HOUR}
```
无需认证(公开配置),前端启动时调用一次缓存。
### 5. BaseDwsTask 基类改造
**`iter_dwd_rows` 改造**
```python
def iter_dwd_rows(self, table_name, columns, start_date, end_date,
date_col="created_at", ...):
cutoff = self.config.get("app.business_day_start_hour", 8)
biz_expr = biz_date_sql_expr(date_col, cutoff)
where_parts = [f"{biz_expr} >= %s", f"{biz_expr} <= %s"]
# ... 其余逻辑不变
```
**`get_time_window_range` 改造**
当前方法返回 `TimeRange(start=date, end=date)`,改造后语义不变(仍返回 `date` 范围),但内部使用 `business_date` 计算 `base_date` 的营业日归属:
```python
def get_time_window_range(self, window, base_date=None):
if base_date is None:
from neozqyy_shared.datetime_utils import now_shanghai, business_date
cutoff = self.config.get("app.business_day_start_hour", 8)
base_date = business_date(now_shanghai(), cutoff)
# ... 其余逻辑使用 base_date已是营业日
```
### 6. 各 DWS 任务 SQL 改造模式
所有任务的 SQL 改造遵循统一模式:
```sql
-- 改造前
DATE(pay_time) AS stat_date
WHERE DATE(pay_time) >= %s AND DATE(pay_time) <= %s
GROUP BY DATE(pay_time)
-- 改造后cutoff_hour=8 时)
DATE(pay_time - INTERVAL '8 hours') AS stat_date
WHERE DATE(pay_time - INTERVAL '8 hours') >= %s AND DATE(pay_time - INTERVAL '8 hours') <= %s
GROUP BY DATE(pay_time - INTERVAL '8 hours')
```
任务从 `self.config.get("app.business_day_start_hour", 8)` 读取 cutoff 值,调用 `biz_date_sql_expr(col, cutoff)` 生成表达式。
**受影响任务清单**18 个):
| 任务 | 主要时间列 | 聚合粒度 |
|------|-----------|---------|
| FinanceBaseTask | pay_time | 日 |
| FinanceDailyTask | pay_time | 日 |
| FinanceRechargeTask | pay_time | 日 |
| FinanceDiscountTask | pay_time | 日 |
| FinanceIncomeTask | pay_time | 日 |
| AssistantDailyTask | start_use_time | 日 |
| AssistantOrderContributionTask | pay_time, start_use_time | 日 |
| AssistantCustomerTask | start_use_time | 日 |
| AssistantMonthlyTask | (基于日度数据) | 月 |
| AssistantFinanceTask | start_use_time | 日 |
| MemberVisitTask | pay_time, start_use_time, ledger_end_time | 日 |
| MemberConsumptionTask | pay_time, create_time | 日 |
| GoodsStockDailyTask | fetched_at | 日 |
| GoodsStockWeeklyTask | fetched_at | 周 |
| GoodsStockMonthlyTask | fetched_at | 月 |
| SpendingPowerIndexTask | pay_time | 日 |
| MemberIndexBase | pay_time | 日 |
| MvRefreshTask | (物化视图刷新) | - |
### 7. 数据库层
**新增 PostgreSQL 函数**(迁移脚本 `db/etl_feiqiu/migrations/2026-03-XX__add_biz_date_function.sql`
```sql
CREATE OR REPLACE FUNCTION dws.biz_date(ts timestamptz, cutoff_hour int DEFAULT 8)
RETURNS date AS $$
SELECT (ts - make_interval(hours => cutoff_hour))::date;
$$ LANGUAGE sql IMMUTABLE PARALLEL SAFE;
```
**物化视图重建**(迁移脚本 `db/etl_feiqiu/migrations/2026-03-XX__rebuild_mv_with_biz_date.sql`
物化视图 `mv_dws_finance_daily_summary_l1..l4``mv_dws_assistant_daily_detail_l1..l4` 的时间过滤条件从 `CURRENT_DATE` 改为 `dws.biz_date(NOW())` 或等效表达式。
### 8. 前端适配
**Admin_Web**
- 日期选择器组件旁增加 Tooltip 或文字标注:`营业日:{HH}:00 起`
- 通过 `/api/config/business-day` 获取 `business_day_start_hour`,启动时请求一次存入全局状态
- 降级策略API 不可用时使用默认值 8`console.warn` 输出警告
**Miniprogram**
- 不直接使用 cutoff 值,所有统计数据由后端 API 按营业日口径返回
- 无需前端改造,仅确认后端 API 返回的数据已是营业日口径
### 9. 历史数据重算
提供 `scripts/ops/rebuild_dws_biz_date.py` 脚本:
```python
# 伪代码
for task_cls in ALL_DWS_TASKS:
for date_window in split_by_month(history_start, history_end):
task = task_cls(config)
task.run(window_start=date_window.start, window_end=date_window.end)
```
- 复用正式 ETL 任务逻辑,确保与正式运行使用相同的 `Business_Day_Cutoff`
- 按月分窗口执行,避免单次事务过大
- 执行前后记录行数对比到日志
- 支持 `--dry-run` 模式预览影响范围
### 10. 运维脚本排查
| 脚本 | 涉及的 DATE() 调用 | 处理方式 |
|------|-------------------|---------|
| `scripts/ops/export_bug_report.py` | `DATE(trash_time)`, `DATE(create_time)`, `DATE(start_use_time)` | 替换为 `biz_date_sql_expr` 生成的表达式 |
| `scripts/ops/etl_consistency_check.py` | 日期比较逻辑 | 评估后按需替换 |
| `apps/etl/.../debug_blackbox.py` | `::date` 类型转换 | 替换为 `biz_date()` 函数调用 |
| `apps/etl/.../run_update.py` | `.date()``datetime.combine` | 替换为 `business_date()` + `business_day_range()` |
## 数据模型
### 配置数据
```
BUSINESS_DAY_START_HOUR: int (023, 默认 8)
```
存储位置:
-`.env``BUSINESS_DAY_START_HOUR=8`
- ETL`AppConfig.config["app"]["business_day_start_hour"]`
- 后端:`config.BUSINESS_DAY_START_HOUR`
### 时间工具函数签名
```python
# 输入/输出类型
business_date(dt: datetime, day_start_hour: int = 8) -> date
business_month(dt: datetime, day_start_hour: int = 8) -> date
business_week_monday(dt: datetime, day_start_hour: int = 8) -> date
business_day_range(biz_date: date, day_start_hour: int = 8) -> tuple[datetime, datetime]
business_week_range(week_monday: date, day_start_hour: int = 8) -> tuple[datetime, datetime]
business_month_range(month_first: date, day_start_hour: int = 8) -> tuple[datetime, datetime]
biz_date_sql_expr(col: str, day_start_hour: int = 8) -> str
```
### 数据库函数
```sql
dws.biz_date(ts timestamptz, cutoff_hour int DEFAULT 8) RETURNS date
-- 等价于 Python 的 business_date用于 SQL 查询和物化视图
```
### API 响应模型
```json
// GET /api/config/business-day
{
"business_day_start_hour": 8
}
```
### DWS 表影响
所有 DWS 表的 `stat_date` 字段语义从"自然日"变为"营业日"。表结构不变,仅数据内容因重算而变化。
## 正确性属性
*属性Property是在系统所有合法执行中都应成立的特征或行为——本质上是对系统应做什么的形式化陈述。属性是人类可读规格说明与机器可验证正确性保证之间的桥梁。*
### Property 1: 营业日归属往返一致性Round-Trip
*对任意* datetime `dt` 和任意合法的 `day_start_hour` h023`business_day_range(business_date(dt, h), h)` 返回的范围 `[start, end)` 应满足 `start <= dt < end`
**Validates: Requirements 2.9, 11.1**
### Property 2: 营业月与营业日一致性
*对任意* datetime `dt` 和任意合法的 `day_start_hour` h023`business_month(dt, h)` 应等于 `business_date(dt, h).replace(day=1)`
**Validates: Requirements 2.10, 11.2**
### Property 3: 营业周与营业日一致性
*对任意* datetime `dt` 和任意合法的 `day_start_hour` h023`business_week_monday(dt, h)` 应等于 `business_date(dt, h) - timedelta(days=business_date(dt, h).weekday())`,且结果的 `weekday()` 始终为 0周一
**Validates: Requirements 2.11, 11.3**
### Property 4: 营业日归属单调性
*对任意* 两个 datetime `dt1 < dt2` 和任意合法的 `day_start_hour` h023`dt1``dt2` 都在同一个 `business_day_range(d, h)` 范围内,则 `business_date(dt1, h) == business_date(dt2, h)`。等价表述:`business_date(dt, h)` 关于 `dt` 是单调非递减的。
**Validates: Requirements 11.9**
### Property 5: 时间范围长度不变量
*对任意* date `d` 和任意合法的 `day_start_hour` h023
- `business_day_range(d, h)` 返回的 `(start, end)` 满足 `end - start == timedelta(hours=24)`
- `business_week_range(monday, h)` 返回的 `(start, end)` 满足 `end - start == timedelta(days=7)`
**Validates: Requirements 11.6, 11.7**
### Property 6: SQL 表达式生成幂等性
*对任意* 列名 `col` 和任意合法的 `day_start_hour` h023`biz_date_sql_expr(col, h)` 多次调用应返回完全相同的字符串。
**Validates: Requirements 11.4**
### Property 7: 非法配置值拒绝
*对任意* 不在 023 范围内的整数值 `v`,当 `BUSINESS_DAY_START_HOUR` 设为 `v` 时,`AppConfig.load()` 应抛出 `SystemExit`
**Validates: Requirements 1.3**
### Property 8: 合法配置值正确加载
*对任意* 023 范围内的整数值 `v`,当 `BUSINESS_DAY_START_HOUR` 环境变量设为 `v` 时,`AppConfig.load()``cfg.get("app.business_day_start_hour")` 应返回 `v`
**Validates: Requirements 3.4**
## 错误处理
| 场景 | 处理方式 |
|------|---------|
| `BUSINESS_DAY_START_HOUR` 值超出 023 | `AppConfig._validate` 抛出 `SystemExit`,明确提示合法范围 |
| `BUSINESS_DAY_START_HOUR` 环境变量缺失 | 使用默认值 8不报错 |
| `BUSINESS_DAY_START_HOUR` 值为非整数字符串 | `env_parser._coerce_env` 保持字符串,`_validate` 阶段类型检查失败抛出 `SystemExit` |
| 后端 `/api/config/business-day` 不可用 | Admin_Web 使用默认值 8`console.warn` 输出警告 |
| 历史数据重算脚本执行失败 | 按月窗口回滚当前批次,记录错误日志,继续下一窗口或中止(由 `--fail-fast` 参数控制) |
| 物化视图迁移脚本执行失败 | 标准 PostgreSQL 事务回滚,迁移脚本幂等设计(`CREATE OR REPLACE` |
| `business_day_range` 等函数收到非法 `day_start_hour` | 函数内部不做校验(调用方负责),依赖 AppConfig 加载阶段的前置校验 |
## 测试策略
### 属性测试Property-Based Testing
使用 `hypothesis` 库,测试文件位于 `tests/test_property_business_day_cutoff.py`
每个属性测试最少运行 100 次迭代,使用 `@settings(max_examples=200)` 配置。
生成策略:
- `day_start_hour``st.integers(min_value=0, max_value=23)`
- `dt``st.datetimes(min_value=datetime(2020, 1, 1), max_value=datetime(2030, 12, 31))`(避免极端日期)
- `biz_date``st.dates(min_value=date(2020, 1, 1), max_value=date(2030, 12, 31))`
每个测试函数以注释标注对应的设计属性:
```python
# Feature: business-day-cutoff, Property 1: 营业日归属往返一致性
@given(dt=st.datetimes(...), h=st.integers(0, 23))
@settings(max_examples=200)
def test_business_date_round_trip(dt, h):
...
# Feature: business-day-cutoff, Property 2: 营业月与营业日一致性
# Feature: business-day-cutoff, Property 3: 营业周与营业日一致性
# Feature: business-day-cutoff, Property 4: 营业日归属单调性
# Feature: business-day-cutoff, Property 5: 时间范围长度不变量
# Feature: business-day-cutoff, Property 6: SQL 表达式生成幂等性
# Feature: business-day-cutoff, Property 7: 非法配置值拒绝
# Feature: business-day-cutoff, Property 8: 合法配置值正确加载
```
### 单元测试
单元测试覆盖属性测试不适合的场景:
- **边界示例**`day_start_hour=8`07:59:59 归属前一天08:00:00 归属当天
- **默认值行为**`BUSINESS_DAY_START_HOUR` 缺失时 AppConfig 返回 8
- **API 端点**`/api/config/business-day` 返回正确 JSON 格式
- **SQL 表达式格式**`biz_date_sql_expr("pay_time", 8)` 返回 `DATE(pay_time - INTERVAL '8 hours')`
- **月末边界**1月31日 07:00 归属1月30日营业日`business_month` 返回1月1日
### 测试配置
- 属性测试库:`hypothesis`(已在项目 `pyproject.toml` 中声明)
- 每个属性测试对应设计文档中的一个 Property由单个 `@given` 装饰的测试函数实现
- 运行命令:`cd C:\NeoZQYY && pytest tests/test_property_business_day_cutoff.py -v`

View File

@@ -0,0 +1,186 @@
# 需求文档业务日分割点机制Business Day Cutoff
## 简介
引入"业务日分割点"机制,将全系统的统计时间口径从自然日/自然周/自然月切换为以可配置的小时值(默认 08:00为分割点的营业日/营业周/营业月。影响范围覆盖配置层、共享包、ETL 层ODS→DWD→DWS、后端 API 层、前端展示层(管理后台、小程序)及数据库层。
## 术语表
- **Business_Day_Cutoff**营业日分割点一个整数小时值023定义一个"业务日"的起始时刻。默认值为 8即 08:00
- **Business_Date**:营业日,从当天 `Business_Day_Cutoff` 时刻到次日 `Business_Day_Cutoff` 时刻的时间段所归属的日期。`Business_Day_Cutoff` 之前的时间戳归属前一天。
- **Business_Week**:营业周,从周一 `Business_Day_Cutoff` 到次周一 `Business_Day_Cutoff` 的时间段。
- **Business_Month**营业月从当月1日 `Business_Day_Cutoff` 到次月1日 `Business_Day_Cutoff` 的时间段。
- **Shared_DateTime_Utils**`packages/shared/src/neozqyy_shared/datetime_utils.py`,跨子系统共享的时间工具模块。
- **AppConfig**ETL 配置管理器(`apps/etl/connectors/feiqiu/config/settings.py`),通过 `AppConfig.load()` 加载配置。
- **Backend_Config**:后端配置模块(`apps/backend/app/config.py`),从 `.env` 加载环境变量。
- **DWS_Task**DWS 层聚合任务,从 DWD 事实表读取数据并按时间维度聚合写入 DWS 汇总表。
- **biz_date_sql_expr**`Shared_DateTime_Utils` 中生成 PostgreSQL 营业日归属 SQL 表达式的函数。
- **stat_date**DWS 汇总表中的统计日期字段,存储的是 Business_Date 而非自然日期。
- **Admin_Web**:管理后台前端(`apps/admin-web/`React + Vite + Ant Design
- **Miniprogram**:微信小程序前端(`apps/miniprogram/`)。
## 需求
### 需求 1环境变量配置
**用户故事:** 作为运维人员,我希望通过 `.env` 环境变量配置营业日分割点小时值,以便在不修改代码的情况下调整统计时间口径。
#### 验收标准
1. THE Root_Env SHALL 定义 `BUSINESS_DAY_START_HOUR` 环境变量,值为 023 的整数,默认值为 8
2. THE Env_Template SHALL 同步包含 `BUSINESS_DAY_START_HOUR` 的定义及注释说明(日/周/月统计的分割语义)
3. WHEN `BUSINESS_DAY_START_HOUR` 的值不在 023 范围内时, THEN THE AppConfig SHALL 在加载阶段抛出 `SystemExit` 错误并给出明确提示
4. WHEN `BUSINESS_DAY_START_HOUR` 环境变量缺失时, THE AppConfig SHALL 使用默认值 8
### 需求 2共享时间工具函数
**用户故事:** 作为开发者,我希望有一组经过充分测试的共享时间工具函数,以便所有子系统使用统一的营业日归属逻辑。
#### 验收标准
1. THE Shared_DateTime_Utils SHALL 提供 `business_date(dt, day_start_hour)` 函数,将任意时间戳归属到对应的 Business_Date
2. THE Shared_DateTime_Utils SHALL 提供 `business_month(dt, day_start_hour)` 函数,将任意时间戳归属到对应的 Business_Month 首日
3. THE Shared_DateTime_Utils SHALL 提供 `business_week_monday(dt, day_start_hour)` 函数,将任意时间戳归属到对应的 Business_Week 的周一日期
4. THE Shared_DateTime_Utils SHALL 提供 `biz_date_sql_expr(col, day_start_hour)` 函数,生成 PostgreSQL 营业日归属 SQL 表达式(形如 `DATE(col - INTERVAL 'N hours')`
5. WHEN `day_start_hour` 参数未传入时, THE Shared_DateTime_Utils SHALL 使用 `DEFAULT_BUSINESS_DAY_START_HOUR`(值为 8作为默认值
6. THE Shared_DateTime_Utils SHALL 提供 `business_day_range(biz_date, day_start_hour)` 函数,返回给定 Business_Date 对应的精确时间戳范围 `(start_dt, end_dt)`,即 `(biz_date 当天 day_start_hour:00, biz_date 次日 day_start_hour:00)`
7. THE Shared_DateTime_Utils SHALL 提供 `business_week_range(week_monday, day_start_hour)` 函数,返回给定 Business_Week 周一对应的精确时间戳范围
8. THE Shared_DateTime_Utils SHALL 提供 `business_month_range(month_first, day_start_hour)` 函数,返回给定 Business_Month 首日对应的精确时间戳范围
9. FOR ALL 合法的 datetime 输入, `business_date` 的输出 SHALL 满足:`business_day_range(business_date(dt, h), h)[0] <= dt < business_day_range(business_date(dt, h), h)[1]`(往返一致性)
10. FOR ALL 合法的 datetime 输入, `business_month(dt, h)` SHALL 等于 `business_date(dt, h).replace(day=1)`(月归属与日归属一致性)
11. FOR ALL 合法的 datetime 输入, `business_week_monday(dt, h)` SHALL 等于 `business_date(dt, h) - timedelta(days=business_date(dt, h).weekday())`(周归属与日归属一致性)
### 需求 3ETL 配置层集成
**用户故事:** 作为 ETL 开发者,我希望 `AppConfig` 正确加载并传播 `BUSINESS_DAY_START_HOUR`,以便 ETL 任务能获取到配置的分割点值。
#### 验收标准
1. THE AppConfig SHALL 在 `app.business_day_start_hour` 路径下存储 `BUSINESS_DAY_START_HOUR` 的整数值
2. THE Env_Parser SHALL 将环境变量 `BUSINESS_DAY_START_HOUR` 映射到 `app.business_day_start_hour` 配置路径
3. THE AppConfig_Defaults SHALL 将 `app.business_day_start_hour` 的默认值设为 8
4. WHEN AppConfig 加载完成后, THE AppConfig SHALL 通过 `cfg.get("app.business_day_start_hour")` 返回正确的整数值
### 需求 4ETL DWS 层聚合逻辑
**用户故事:** 作为数据分析师,我希望 DWS 层的所有日度/周度/月度聚合统计都基于营业日口径,以便统计结果与门店实际营业周期一致。
#### 验收标准
1. WHEN DWS_Task 从 DWD 表提取数据时, THE DWS_Task SHALL 使用 `biz_date_sql_expr` 替代 `DATE()` 进行日期归属计算
2. WHEN DWS_Task 按日聚合时, THE DWS_Task SHALL 使用 `DATE(timestamp_col - INTERVAL 'N hours')` 作为 `stat_date` 的分组依据,其中 N 为 `Business_Day_Cutoff`
3. WHEN DWS_Task 按月聚合时, THE DWS_Task SHALL 使用 Business_Month 口径当月1日 cutoff 到次月1日 cutoff
4. WHEN DWS_Task 按周聚合时, THE DWS_Task SHALL 使用 Business_Week 口径(周一 cutoff 到次周一 cutoff
5. THE BaseDwsTask.iter_dwd_rows SHALL 使用 `biz_date_sql_expr` 替代 `DATE()` 进行日期过滤
6. THE BaseDwsTask.get_time_window_range SHALL 返回基于 Business_Date 口径的时间范围
7. WHILE ETL 任务运行期间, THE DWS_Task SHALL 从 `AppConfig` 读取 `app.business_day_start_hour` 值,禁止硬编码
### 需求 5受影响的 DWS 任务全面排查
**用户故事:** 作为项目负责人,我希望所有使用 `DATE()` 进行时间归属的 DWS 任务都被排查并改造,确保无遗漏。
#### 验收标准
1. THE FinanceBaseTask SHALL 将所有 `DATE(pay_time)` 替换为 `biz_date_sql_expr("pay_time", cutoff_hour)` 生成的表达式
2. THE FinanceDailyTask SHALL 使用 Business_Date 口径提取和聚合结账单、团购核销、充值等数据
3. THE FinanceRechargeTask SHALL 将 `DATE(pay_time)` 替换为营业日归属表达式
4. THE FinanceDiscountTask SHALL 使用 Business_Date 口径聚合优惠明细
5. THE FinanceIncomeTask SHALL 使用 Business_Date 口径聚合收入结构
6. THE AssistantDailyTask SHALL 使用 Business_Date 口径聚合助教日度明细
7. THE AssistantOrderContributionTask SHALL 将 `DATE(pay_time)` 替换为营业日归属表达式
8. THE AssistantCustomerTask SHALL 将 `DATE(start_use_time)` 替换为营业日归属表达式
9. THE AssistantMonthlyTask SHALL 使用 Business_Month 口径聚合助教月度汇总
10. THE AssistantFinanceTask SHALL 使用 Business_Date 口径聚合助教财务分析
11. THE MemberVisitTask SHALL 将 `DATE(pay_time)``DATE(start_use_time)``DATE(ledger_end_time)` 替换为营业日归属表达式
12. THE MemberConsumptionTask SHALL 将 `DATE(pay_time)``DATE(create_time)` 替换为营业日归属表达式
13. THE GoodsStockDailyTask SHALL 将 `DATE(fetched_at)` 替换为营业日归属表达式
14. THE GoodsStockWeeklyTask SHALL 使用 Business_Week 口径聚合库存周报
15. THE GoodsStockMonthlyTask SHALL 使用 Business_Month 口径聚合库存月报
16. THE SpendingPowerIndexTask SHALL 将 `DATE(pay_time)` 替换为营业日归属表达式
17. THE MemberIndexBase SHALL 将 `DATE(pay_time)` 替换为营业日归属表达式
18. THE MvRefreshTask SHALL 确保物化视图刷新的时间过滤条件使用 Business_Date 口径
### 需求 6后端 API 层时间范围计算
**用户故事:** 作为后端开发者,我希望后端 API 在处理"今日/本周/本月"等时间范围查询时使用营业日口径,以便前端展示的数据与 DWS 统计一致。
#### 验收标准
1. THE Backend_Config SHALL 加载 `BUSINESS_DAY_START_HOUR` 环境变量并暴露为模块级常量
2. WHEN 后端 API 需要计算"今日"时间范围时, THE Backend SHALL 使用 `business_day_range` 函数计算从当天 cutoff 到次日 cutoff 的时间戳范围
3. WHEN 后端 API 需要计算"本周"时间范围时, THE Backend SHALL 使用 `business_week_range` 函数计算从本周一 cutoff 到次周一 cutoff 的时间戳范围
4. WHEN 后端 API 需要计算"本月"时间范围时, THE Backend SHALL 使用 `business_month_range` 函数计算从本月1日 cutoff 到次月1日 cutoff 的时间戳范围
5. THE Backend SHALL 从 `Shared_DateTime_Utils` 导入时间工具函数,禁止在后端重复实现营业日逻辑
### 需求 7前端展示层适配
**用户故事:** 作为前端开发者,我希望管理后台和小程序在展示日期选择器和统计数据时,能正确反映营业日口径,避免用户困惑。
#### 验收标准
1. WHEN Admin_Web 展示日期选择器时, THE Admin_Web SHALL 在日期选择器旁标注营业日口径说明(如"营业日08:00 起"
2. WHEN Admin_Web 展示"今日统计"时, THE Admin_Web SHALL 显示的时间范围为当天 cutoff 到次日 cutoff
3. WHEN Miniprogram 展示统计数据时, THE Miniprogram SHALL 使用后端 API 返回的基于营业日口径的数据
4. THE Admin_Web SHALL 通过后端 API 获取 `BUSINESS_DAY_START_HOUR` 配置值,禁止前端硬编码
5. IF 后端 API 返回的 `BUSINESS_DAY_START_HOUR` 配置值不可用, THEN THE Admin_Web SHALL 使用默认值 8 并在控制台输出警告
### 需求 8后端配置查询 API
**用户故事:** 作为前端开发者,我希望有一个 API 端点能返回当前的营业日分割点配置,以便前端动态获取并展示。
#### 验收标准
1. THE Backend SHALL 提供一个 API 端点返回当前 `BUSINESS_DAY_START_HOUR` 的值
2. WHEN 前端请求该端点时, THE Backend SHALL 返回包含 `business_day_start_hour` 字段的 JSON 响应
3. THE Backend SHALL 确保该端点的响应值与 ETL 层使用的 `BUSINESS_DAY_START_HOUR` 值一致(均来源于同一 `.env` 配置)
### 需求 9数据库层适配
**用户故事:** 作为 DBA我希望数据库中的物化视图和 SQL 函数使用营业日口径,以便直接查询数据库时也能获得正确的统计结果。
#### 验收标准
1. WHEN 物化视图使用 `date_trunc``CURRENT_DATE` 进行时间过滤时, THE Migration_Script SHALL 将其替换为基于 `Business_Day_Cutoff` 的表达式
2. THE Migration_Script SHALL 提供一个 PostgreSQL 函数 `biz_date(timestamptz, int)` 用于在 SQL 中直接计算营业日归属
3. WHEN 迁移脚本执行后, THE Database SHALL 确保所有物化视图的时间过滤条件使用营业日口径
4. THE Migration_Script SHALL 使用日期前缀命名(如 `2026-03-XX__add_biz_date_function.sql`),遵循项目迁移脚本规范
### 需求 10数据迁移与历史数据兼容
**用户故事:** 作为运维人员,我希望引入营业日机制后,历史数据能被正确重算,确保统计连续性。
#### 验收标准
1. THE Migration_Plan SHALL 提供 DWS 历史数据重算脚本,按营业日口径重新聚合所有受影响的 DWS 表
2. WHEN 历史数据重算执行时, THE Rebuild_Script SHALL 使用与正式 ETL 任务相同的 `Business_Day_Cutoff` 配置值
3. THE Migration_Plan SHALL 记录重算前后的数据行数对比,用于验证重算正确性
4. IF 重算过程中发生错误, THEN THE Rebuild_Script SHALL 回滚到重算前的状态并记录错误日志
### 需求 11属性测试覆盖
**用户故事:** 作为测试工程师,我希望营业日归属逻辑有完整的属性测试覆盖,确保边界条件和不变量得到验证。
#### 验收标准
1. THE Property_Test SHALL 验证 `business_date` 的往返一致性:对任意 datetime dt`business_day_range(business_date(dt, h), h)` 的范围包含 dt
2. THE Property_Test SHALL 验证 `business_month``business_date` 的一致性:`business_month(dt, h) == business_date(dt, h).replace(day=1)`
3. THE Property_Test SHALL 验证 `business_week_monday``business_date` 的一致性:`business_week_monday(dt, h).weekday() == 0`(结果始终为周一)
4. THE Property_Test SHALL 验证 `biz_date_sql_expr` 的幂等性:对同一输入参数多次调用返回相同结果
5. THE Property_Test SHALL 验证边界条件cutoff 时刻恰好在分割点上的时间戳归属当天,分割点前一秒归属前一天
6. THE Property_Test SHALL 验证 `business_day_range` 返回的范围恰好为 24 小时
7. THE Property_Test SHALL 验证 `business_week_range` 返回的范围恰好为 7 天168 小时)
8. THE Property_Test SHALL 使用 hypothesis 库生成随机 datetime 和 day_start_hour023进行测试
9. FOR ALL `day_start_hour`023, THE Property_Test SHALL 验证 `business_date` 函数的单调性:若 dt1 < dt2 且两者在同一 Business_Date 范围内,则 `business_date(dt1, h) == business_date(dt2, h)`
### 需求 12运维脚本中的 DATE() 排查
**用户故事:** 作为运维人员,我希望 `scripts/ops/` 和 ETL `scripts/` 中使用 `DATE()` 的运维脚本也被排查,确保调试和排查工具与正式统计口径一致。
#### 验收标准
1. THE Ops_Scripts SHALL 排查 `scripts/ops/export_bug_report.py` 中的 `DATE(trash_time)``DATE(create_time)``DATE(start_use_time)` 调用,评估是否需要替换为营业日归属表达式
2. THE Ops_Scripts SHALL 排查 `scripts/ops/etl_consistency_check.py` 中的日期比较逻辑
3. THE ETL_Scripts SHALL 排查 `apps/etl/connectors/feiqiu/scripts/debug/debug_blackbox.py` 中的 `::date` 类型转换
4. THE ETL_Scripts SHALL 排查 `apps/etl/connectors/feiqiu/scripts/run_update.py` 中的 `.date()` 调用和 `datetime.combine` 逻辑
5. WHEN 运维脚本用于与 DWS 数据对比验证时, THE Ops_Scripts SHALL 使用与 DWS 任务相同的营业日归属逻辑

View File

@@ -0,0 +1,260 @@
# 实现计划业务日分割点机制Business Day Cutoff
## 概述
将全系统统计时间口径从自然日切换为以可配置小时值(默认 08:00为分割点的营业日。实现顺序配置层 → 共享工具层 → ETL 层 → 后端 API 层 → 数据库层 → 前端适配 → 历史数据重算 → 运维脚本排查。
## 任务
- [x] 1. 配置层:环境变量与配置加载
- [x] 1.1 在根 `.env``.env.template` 中新增 `BUSINESS_DAY_START_HOUR=8`
- `.env` 新增 `BUSINESS_DAY_START_HOUR=8`
- `.env.template` 新增带注释的定义,说明日/周/月统计分割语义
- _Requirements: 1.1, 1.2_
- [x] 1.2 在 `AppConfig._validate` 中新增 `business_day_start_hour` 范围校验
-`apps/etl/connectors/feiqiu/config/settings.py``_validate` 方法中增加校验
- 值不在 023 范围内或非整数时抛出 `SystemExit`
- 环境变量缺失时使用默认值 8已由 `defaults.py` 保证)
- _Requirements: 1.3, 1.4, 3.1, 3.2, 3.3, 3.4_
- [x] 1.3 在后端 `apps/backend/app/config.py` 中新增 `BUSINESS_DAY_START_HOUR` 常量
- 新增 `BUSINESS_DAY_START_HOUR: int = int(get("BUSINESS_DAY_START_HOUR", "8"))`
- _Requirements: 6.1_
- [x] 2. 共享时间工具层:新增 range 函数
- [x] 2.1 在 `packages/shared/src/neozqyy_shared/datetime_utils.py` 中实现三个 range 函数
- 实现 `business_day_range(biz_date, day_start_hour)``tuple[datetime, datetime]`
- 实现 `business_week_range(week_monday, day_start_hour)``tuple[datetime, datetime]`
- 实现 `business_month_range(month_first, day_start_hour)``tuple[datetime, datetime]`
- 所有返回值带 `Asia/Shanghai` 时区(使用 `SHANGHAI_TZ`
- 默认 `day_start_hour=8`
- _Requirements: 2.6, 2.7, 2.8, 2.5_
- [x] 2.2 编写属性测试营业日归属往返一致性Property 1
- **Property 1: 营业日归属往返一致性Round-Trip**
- `business_day_range(business_date(dt, h), h)` 的范围 `[start, end)` 应满足 `start <= dt < end`
- 使用 `hypothesis``@given(dt=st.datetimes(...), h=st.integers(0, 23))``@settings(max_examples=200)`
- 测试文件:`tests/test_property_business_day_cutoff.py`
- **Validates: Requirements 2.9, 11.1**
- [x] 2.3 编写属性测试营业月与营业日一致性Property 2
- **Property 2: 营业月与营业日一致性**
- `business_month(dt, h) == business_date(dt, h).replace(day=1)`
- **Validates: Requirements 2.10, 11.2**
- [x] 2.4 编写属性测试营业周与营业日一致性Property 3
- **Property 3: 营业周与营业日一致性**
- `business_week_monday(dt, h) == business_date(dt, h) - timedelta(days=business_date(dt, h).weekday())`,且结果 `weekday() == 0`
- **Validates: Requirements 2.11, 11.3**
- [x] 2.5 编写属性测试营业日归属单调性Property 4
- **Property 4: 营业日归属单调性**
-`dt1 < dt2` 且两者在同一 `business_day_range` 范围内,则 `business_date(dt1, h) == business_date(dt2, h)`
- **Validates: Requirements 11.9**
- [x] 2.6 编写属性测试时间范围长度不变量Property 5
- **Property 5: 时间范围长度不变量**
- `business_day_range(d, h)``end - start == timedelta(hours=24)`
- `business_week_range(monday, h)``end - start == timedelta(days=7)`
- **Validates: Requirements 11.6, 11.7**
- [x] 2.7 编写属性测试SQL 表达式生成幂等性Property 6
- **Property 6: SQL 表达式生成幂等性**
- `biz_date_sql_expr(col, h)` 多次调用返回完全相同的字符串
- **Validates: Requirements 11.4**
- [x] 2.8 编写属性测试边界条件验证Property 1 补充)
- cutoff 时刻恰好在分割点上的时间戳归属当天,分割点前一秒归属前一天
- 使用 hypothesis 生成 `day_start_hour`,构造边界时间戳验证
- **Validates: Requirements 11.5**
- [x] 2.9 编写单元测试:共享时间工具函数
- 测试 `day_start_hour=8` 时 07:59:59 归属前一天、08:00:00 归属当天
- 测试 `biz_date_sql_expr("pay_time", 8)` 返回 `DATE(pay_time - INTERVAL '8 hours')`
- 测试月末边界1月31日 07:00 归属1月30日`business_month` 返回1月1日
- 测试默认值行为:不传 `day_start_hour` 时使用 8
- _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 2.8_
- [x] 3. Checkpoint — 共享工具层验证
- 确保所有属性测试和单元测试通过,运行 `pytest tests/test_property_business_day_cutoff.py -v`,如有问题请向用户确认。
- [x] 4. ETL 配置层集成与 BaseDwsTask 基类改造
- [x] 4.1 在 `AppConfig._validate` 中新增 `business_day_start_hour` 范围校验(与 1.2 合并实现)
- 确认 `defaults.py``env_parser.py` 已有配置映射(设计文档标注"已完成"
- 仅需在 `settings.py``_validate` 中增加校验逻辑
- _Requirements: 3.1, 3.2, 3.3, 3.4_
- [x] 4.2 编写属性测试非法配置值拒绝Property 7
- **Property 7: 非法配置值拒绝**
- 对任意不在 023 范围内的整数值,`AppConfig.load()` 应抛出 `SystemExit`
- **Validates: Requirements 1.3**
- [x] 4.3 编写属性测试合法配置值正确加载Property 8
- **Property 8: 合法配置值正确加载**
- 对任意 023 范围内的整数值 `v``AppConfig.load()``cfg.get("app.business_day_start_hour")` 应返回 `v`
- **Validates: Requirements 3.4**
- [x] 4.4 改造 `BaseDwsTask.iter_dwd_rows`,将 `DATE()` 替换为 `biz_date_sql_expr`
-`self.config.get("app.business_day_start_hour", 8)` 读取 cutoff 值
- 调用 `biz_date_sql_expr(date_col, cutoff)` 生成 SQL 表达式
- 替换 WHERE 子句中的 `DATE(col)``biz_date_sql_expr` 生成的表达式
- _Requirements: 4.1, 4.5, 4.7_
- [x] 4.5 改造 `BaseDwsTask.get_time_window_range`,使用 `business_date` 计算营业日归属
- `base_date` 为 None 时使用 `business_date(now_shanghai(), cutoff)` 计算
- 确保返回的 `TimeRange` 基于营业日口径
- _Requirements: 4.6_
- [x] 5. DWS 任务 SQL 改造(财务类)
- [x] 5.1 改造 FinanceBaseTask`DATE(pay_time)``biz_date_sql_expr("pay_time", cutoff)`
- 从 config 读取 cutoff替换所有 `DATE(pay_time)` 为营业日表达式
- 包括 SELECT、WHERE、GROUP BY 中的所有出现
- _Requirements: 5.1_
- [x] 5.2 改造 FinanceDailyTask使用 Business_Date 口径
- 替换结账单、团购核销、充值等数据的日期归属
- _Requirements: 5.2_
- [x] 5.3 改造 FinanceRechargeTask`DATE(pay_time)` → 营业日归属表达式
- _Requirements: 5.3_
- [x] 5.4 改造 FinanceDiscountTask使用 Business_Date 口径聚合优惠明细
- _Requirements: 5.4_
- [x] 5.5 改造 FinanceIncomeTask使用 Business_Date 口径聚合收入结构
- _Requirements: 5.5_
- [x] 6. DWS 任务 SQL 改造(助教类)
- [x] 6.1 改造 AssistantDailyTask`DATE(start_use_time)` → 营业日归属表达式
- _Requirements: 5.6_
- [x] 6.2 改造 AssistantOrderContributionTask`DATE(pay_time)` → 营业日归属表达式
- _Requirements: 5.7_
- [x] 6.3 改造 AssistantCustomerTask`DATE(start_use_time)` → 营业日归属表达式
- _Requirements: 5.8_
- [x] 6.4 改造 AssistantMonthlyTask使用 Business_Month 口径聚合月度汇总
- _Requirements: 5.9_
- [x] 6.5 改造 AssistantFinanceTask使用 Business_Date 口径聚合助教财务分析
- _Requirements: 5.10_
- [x] 7. DWS 任务 SQL 改造(会员类 + 商品库存类 + 指标类)
- [x] 7.1 改造 MemberVisitTask`DATE(pay_time)``DATE(start_use_time)``DATE(ledger_end_time)` → 营业日归属表达式
- _Requirements: 5.11_
- [x] 7.2 改造 MemberConsumptionTask`DATE(pay_time)``DATE(create_time)` → 营业日归属表达式
- _Requirements: 5.12_
- [x] 7.3 改造 GoodsStockDailyTask`DATE(fetched_at)` → 营业日归属表达式
- _Requirements: 5.13_
- [x] 7.4 改造 GoodsStockWeeklyTask使用 Business_Week 口径聚合库存周报
- _Requirements: 5.14_
- [x] 7.5 改造 GoodsStockMonthlyTask使用 Business_Month 口径聚合库存月报
- _Requirements: 5.15_
- [x] 7.6 改造 SpendingPowerIndexTask`DATE(pay_time)` → 营业日归属表达式
- _Requirements: 5.16_
- [x] 7.7 改造 MemberIndexBase`DATE(pay_time)` → 营业日归属表达式
- _Requirements: 5.17_
- [x] 7.8 改造 MvRefreshTask确保物化视图刷新的时间过滤条件使用 Business_Date 口径
- _Requirements: 5.18_
- [x] 8. Checkpoint — ETL DWS 层改造验证
- 确保所有 18 个 DWS 任务的 SQL 改造完成,`DATE()` 调用已全部替换为 `biz_date_sql_expr` 生成的表达式。运行 ETL 单元测试 `cd apps/etl/connectors/feiqiu && pytest tests/unit -v`,如有问题请向用户确认。
- [x] 9. 后端 API 层
- [x] 9.1 创建 `apps/backend/app/routers/business_day.py`,实现 `/api/config/business-day` 端点
- 创建 `APIRouter(prefix="/api/config", tags=["业务配置"])`
- 实现 `GET /business-day` 返回 `{"business_day_start_hour": config.BUSINESS_DAY_START_HOUR}`
- 无需认证(公开配置)
- _Requirements: 8.1, 8.2, 8.3_
- [x] 9.2 在后端主路由中注册 `business_day` 路由
-`apps/backend/app/main.py` 或路由注册文件中 `include_router`
- _Requirements: 8.1_
- [x] 9.3 后端时间范围查询统一使用 `business_*_range` 函数
- 在后端需要计算"今日/本周/本月"时间范围的 API 中,导入并使用 `business_day_range``business_week_range``business_month_range`
-`Shared_DateTime_Utils` 导入,禁止重复实现
- _Requirements: 6.2, 6.3, 6.4, 6.5_
- [x] 9.4 编写单元测试:后端配置查询 API
- 测试 `/api/config/business-day` 返回正确 JSON 格式
- 测试返回值与 `config.BUSINESS_DAY_START_HOUR` 一致
- _Requirements: 8.1, 8.2, 8.3_
- [x] 10. 数据库层PostgreSQL 函数与物化视图迁移
- [x] 10.1 创建迁移脚本:新增 `dws.biz_date()` PostgreSQL 函数
- 文件:`db/etl_feiqiu/migrations/2026-03-XX__add_biz_date_function.sql`
- `CREATE OR REPLACE FUNCTION dws.biz_date(ts timestamptz, cutoff_hour int DEFAULT 8) RETURNS date`
- 标记为 `IMMUTABLE PARALLEL SAFE`
- _Requirements: 9.2, 9.4_
- [x] 10.2 创建迁移脚本:重建物化视图使用 `biz_date()` 函数
- 文件:`db/etl_feiqiu/migrations/2026-03-XX__rebuild_mv_with_biz_date.sql`
-`mv_dws_finance_daily_summary_l1..l4``mv_dws_assistant_daily_detail_l1..l4``CURRENT_DATE` / `date_trunc` 替换为 `dws.biz_date(NOW())`
- 使用 `DROP MATERIALIZED VIEW IF EXISTS` + `CREATE MATERIALIZED VIEW` 重建
- _Requirements: 9.1, 9.3_
- [x] 11. 前端适配
- [x] 11.1 Admin_Web日期选择器旁标注营业日口径说明
- 在日期选择器组件旁增加 Tooltip 或文字标注:`营业日:{HH}:00 起`
- 通过 `/api/config/business-day` 获取 `business_day_start_hour`,启动时请求一次存入全局状态
- 降级策略API 不可用时使用默认值 8`console.warn` 输出警告
- _Requirements: 7.1, 7.2, 7.4, 7.5_
- [x] 11.2 Miniprogram确认后端 API 返回的数据已是营业日口径
- 小程序不直接使用 cutoff 值,所有统计数据由后端 API 按营业日口径返回
- 确认无需前端改造,仅验证后端 API 数据正确性
- _Requirements: 7.3_
- [x] 12. Checkpoint — 后端 + 数据库 + 前端验证
- 确保后端 API 端点可用、迁移脚本语法正确、前端标注正常显示。如有问题请向用户确认。
- [x] 13. 历史数据重算脚本
- [x] 13.1 创建 `scripts/ops/rebuild_dws_biz_date.py` 历史数据重算脚本
- 复用正式 ETL 任务逻辑,使用相同的 `Business_Day_Cutoff` 配置
- 按月分窗口执行,避免单次事务过大
- 执行前后记录行数对比到日志
- 支持 `--dry-run` 模式预览影响范围
- 支持 `--fail-fast` 参数控制错误时中止或继续
- 错误时回滚当前批次并记录错误日志
- _Requirements: 10.1, 10.2, 10.3, 10.4_
- [x] 14. 运维脚本排查与改造
- [x] 14.1 排查并改造 `scripts/ops/export_bug_report.py`
-`DATE(trash_time)``DATE(create_time)``DATE(start_use_time)` 替换为 `biz_date_sql_expr` 生成的表达式
- _Requirements: 12.1, 12.5_
- [x] 14.2 排查并改造 `scripts/ops/etl_consistency_check.py`
- 评估日期比较逻辑,按需替换为营业日归属表达式
- _Requirements: 12.2, 12.5_
- [x] 14.3 排查并改造 `apps/etl/connectors/feiqiu/scripts/debug/debug_blackbox.py`
-`::date` 类型转换替换为 `biz_date()` 函数调用
- _Requirements: 12.3_
- [x] 14.4 排查并改造 `apps/etl/connectors/feiqiu/scripts/run_update.py`
-`.date()``datetime.combine` 替换为 `business_date()` + `business_day_range()`
- _Requirements: 12.4, 12.5_
- [x] 15. Final Checkpoint — 全量验证
- 确保所有属性测试通过:`cd C:\NeoZQYY && pytest tests/test_property_business_day_cutoff.py -v`
- 确保 ETL 单元测试通过:`cd apps/etl/connectors/feiqiu && pytest tests/unit -v`
- 确认所有 12 项需求的验收标准均有对应任务覆盖
- 如有问题请向用户确认。
## 备注
- 标记 `*` 的子任务为可选,可跳过以加速 MVP 交付
- 每个任务引用了具体的需求编号,确保可追溯性
- 属性测试验证设计文档中的 8 个正确性属性
- Checkpoint 任务确保增量验证,及时发现问题
- 设计文档标注 `defaults.py``env_parser.py` 已完成,任务 4.1 仅需增加校验逻辑

View File

@@ -0,0 +1 @@
{"specId": "7e1dc63d-3dbd-4462-a43c-9ecaa9b1dd07", "workflowType": "requirements-first", "specType": "feature"}

View File

@@ -0,0 +1,440 @@
# 设计文档DWD 业务全景梳理
## 概述
本设计文档描述如何系统梳理飞球 ETL 的 DWD 层全部业务数据,产出 5 份全景分析文档。这是一个纯文档梳理任务,不涉及代码改动,核心挑战在于:如何在"数据本源优先"的强制准则下,高效、准确地完成 7 个业务域、约 30 张表(含扩展表)的字段语义验证和跨表关联分析。
### 设计目标
1. 定义可重复执行的梳理方法论,确保每张表的分析过程一致
2. 设计每份文档的结构模板,确保产出物格式统一
3. 规划数据验证的技术方案,确保结论可追溯
4. 设计文档间的引用和关联机制,避免信息重复
### 范围
- 输入:`test_etl_feiqiu` 数据库 DWD schema 的全部表和数据
- 参考:现有 BD 手册(`apps/etl/connectors/feiqiu/docs/database/DWD/`)、已有分析报告(`docs/reports/`
- 产出5 份全景文档,输出到 `docs/reports/`
### DWD 表全景
根据现有 BD 手册DWD 层包含以下表:
| 类型 | 表数量 | 表名 |
|------|--------|------|
| 维度表(主表) | 9 | `dim_site`, `dim_table`, `dim_assistant`, `dim_member`, `dim_member_card_account`, `dim_tenant_goods`, `dim_store_goods`, `dim_goods_category`, `dim_groupbuy_package` |
| 维度表(扩展表) | 8 | 上述除 `dim_goods_category` 外均有 `_ex` 扩展表 |
| 事实表(主表) | 13 | `dwd_settlement_head`, `dwd_payment`, `dwd_table_fee_log`, `dwd_table_fee_adjust`, `dwd_assistant_service_log`, `dwd_member_balance_change`, `dwd_recharge_order`, `dwd_refund`, `dwd_groupbuy_redemption`, `dwd_platform_coupon_redemption`, `dwd_store_goods_sale`, `dwd_goods_stock_summary`, `dwd_goods_stock_movement` |
| 事实表(扩展表) | 11 | 除 `dwd_payment`, `dwd_goods_stock_summary`, `dwd_goods_stock_movement` 外均有 `_ex` 扩展表 |
共计约 43 张表。
## 架构
### 整体梳理流程
梳理工作分为两个阶段:基础层(表结构与字段语义)和全景层(跨表关联分析)。基础层产出为全景层的输入。
```mermaid
flowchart TD
subgraph Phase1["阶段一:基础层梳理"]
A[枚举 DWD 全部表] --> B[按业务域分组]
B --> C[逐表执行智能聚焦分析]
C --> D[字段分类筛选]
D --> E[业务关键字段倒推验证]
E --> F[产出DWD 表结构与字段语义总览]
end
subgraph Phase2["阶段二:全景层梳理"]
F --> G[业务全景:消费产生机制]
F --> H[账务全景:结算与支付]
F --> I[财务全景:收入与对账]
F --> J[维度表与主数据全景]
end
subgraph Validation["贯穿:数据验证"]
K[information_schema 查表结构]
L[SQL 查询验证值域分布]
M[交叉查询验证关联关系]
N[对账公式全量验证]
end
C -.-> K
E -.-> L
E -.-> M
H -.-> N
```
### 信息流向
```mermaid
flowchart LR
subgraph 输入源
DB[(test_etl_feiqiu<br/>DWD schema)]
BD[现有 BD 手册<br/>宏观参考]
RPT[已有分析报告<br/>待验证假设]
end
subgraph 梳理过程
DB -->|information_schema| META[表结构元数据]
DB -->|SQL 查询| DATA[实际数据验证]
BD -->|宏观层可参考| REF[参考起点]
RPT -->|字段级需验证| HYP[待验证假设]
end
subgraph 产出
META --> DOC1[文档1: 表结构总览]
DATA --> DOC1
REF --> DOC1
HYP --> DOC1
DOC1 --> DOC2[文档2: 业务全景]
DOC1 --> DOC3[文档3: 账务全景]
DOC1 --> DOC4[文档4: 财务全景]
DOC1 --> DOC5[文档5: 维度表全景]
end
```
## 组件与接口
### 组件一:单表智能聚焦分析器
对每张 DWD 表执行标准化的分析流程,产出该表的字段语义报告。
#### 分析流程(每张表)
```
步骤 1: 表结构获取
→ 查询 information_schema.columns 获取列名、类型、nullable
→ 禁止参考 db/ 目录下的 DDL .sql 文件
步骤 2: 字段分类筛选
→ 查询全表空字段SELECT column WHERE ALL NULL标记为"空字段-跳过"
→ 识别 ETL 管理字段_etl_loaded_at, _etl_batch_id 等),简要标注
→ 识别含义透明字段id, site_id, created_at 等),仅列出
→ 剩余为"业务关键字段",进入深度验证
步骤 3: 业务关键字段倒推验证
→ 从含义明确的字段出发(如 id, site_id, total_amount
→ 通过 JOIN / 聚合对比 / 值域交叉推断不确定字段
→ 金额字段MIN/MAX/AVG/中位数/NULL占比 + 交叉验证
→ 枚举字段DISTINCT 值 + 频次分布
→ 关联 IDJOIN 验证关联完整性
步骤 4: 偏差检测
→ 对比现有 BD 手册的字段描述
→ 标注一致/偏差/错误
```
#### 输出格式(每张表)
```markdown
### {table_name}
**业务职责**:一句话描述
**数据状态**{行数} 行,时间范围 {min_date} ~ {max_date}
**主键**{pk_fields}
**关联表**{related_tables with join fields}
#### 业务关键字段
| 字段名 | 类型 | 验证状态 | 语义说明 | 值域/分布 |
|--------|------|----------|----------|-----------|
| ... | ... | ✅/⚠️/❌ | ... | ... |
#### 空字段(附录)
{列出全 NULL 的字段名}
#### 偏差记录
{与现有文档不一致的地方}
```
### 组件二:全景文档生成器
基于单表分析结果,按业务视角组织跨表关联分析。
#### 全景文档通用模板
```markdown
# {全景文档标题}
> 数据来源test_etl_feiqiu (DWD schema)
> 验证日期:{date}
> 数据时间范围:{min_date} ~ {max_date}
## 目录
{自动生成}
## 正文
{按业务逻辑组织的分析内容}
{每个关键结论标注验证状态:✅ 已验证 / ⚠️ 部分验证 / ❌ 未验证}
## 附录
### 验证 SQL
{关键验证查询}
### 数据样例
{来自测试库的真实数据}
```
### 组件三:数据验证引擎
贯穿整个梳理过程的验证机制。
#### 验证类型
| 验证类型 | 方法 | 适用场景 |
|----------|------|----------|
| 值域验证 | MIN/MAX/AVG/MEDIAN/NULL% | 金额字段、数值字段 |
| 枚举验证 | DISTINCT + COUNT | 状态字段、类型字段 |
| 关联验证 | LEFT JOIN + NULL 检查 | 外键关联完整性 |
| 等式验证 | SUM 对比 | 对账公式F1~F6 等) |
| 交叉验证 | 多表 JOIN + 聚合对比 | 跨表金额一致性 |
| 边界验证 | WHERE = 0 / < 0 / IS NULL | 异常值业务含义 |
#### 验证结果标注规范
- ✅ 已验证:附验证 SQL 摘要或结果统计
- ⚠️ 部分验证:附已知例外数量和分类
- ❌ 未验证:附原因(数据不足/无法关联/逻辑不明)
- ⚠️ 警告:经多次交叉验证仍无法对齐的数据关系
## 数据模型
### DWD 表按业务域分组
本次梳理将 DWD 层全部表按 7 个业务域组织,每个域内的表构成一个分析单元。
```mermaid
erDiagram
%% 结算域
dwd_settlement_head ||--o{ dwd_payment : "order_settle_id"
dwd_settlement_head ||--o{ dwd_table_fee_log : "order_settle_id"
dwd_settlement_head ||--o{ dwd_store_goods_sale : "order_settle_id"
dwd_settlement_head ||--o{ dwd_assistant_service_log : "order_settle_id"
dwd_settlement_head ||--o{ dwd_platform_coupon_redemption : "order_settle_id"
dwd_settlement_head ||--o{ dwd_refund : "order_settle_id"
%% 台桌域
dim_table ||--o{ dwd_table_fee_log : "table_id"
dwd_table_fee_log ||--o{ dwd_table_fee_adjust : "table_fee_log_id"
%% 助教域(作废判断已内聚到 dwd_assistant_service_log_ex.is_trash
dim_assistant ||--o{ dwd_assistant_service_log : "assistant_id"
%% 会员域
dim_member ||--o{ dim_member_card_account : "member_id"
dim_member ||--o{ dwd_member_balance_change : "member_id"
dim_member_card_account ||--o{ dwd_recharge_order : "tenant_member_card_id"
%% 团购域
dim_groupbuy_package ||--o{ dwd_groupbuy_redemption : "groupbuy_package_id"
%% 商品域
dim_goods_category ||--o{ dim_tenant_goods : "category_id"
dim_tenant_goods ||--o{ dim_store_goods : "tenant_goods_id"
dim_store_goods ||--o{ dwd_store_goods_sale : "site_goods_id"
dim_store_goods ||--o{ dwd_goods_stock_summary : "site_goods_id"
dim_store_goods ||--o{ dwd_goods_stock_movement : "site_goods_id"
%% 门店维度
dim_site ||--o{ dwd_settlement_head : "site_id"
```
### 业务域与表映射
| 业务域 | 事实表 | 维度表 | 核心关联 |
|--------|--------|--------|----------|
| 结算 | `dwd_settlement_head`(+ex), `dwd_payment`, `dwd_refund`(+ex) | — | 结算单是所有消费的汇总入口 |
| 台桌 | `dwd_table_fee_log`(+ex), `dwd_table_fee_adjust`(+ex) | `dim_table`(+ex) | 台费计费流水 → 台费调整 |
| 助教 | `dwd_assistant_service_log`(+ex) | `dim_assistant`(+ex) | 助教服务流水(作废通过 `_ex.is_trash` 判断) |
| 会员 | `dwd_member_balance_change`(+ex), `dwd_recharge_order`(+ex) | `dim_member`(+ex), `dim_member_card_account`(+ex) | 充值 → 余额变动 |
| 团购 | `dwd_groupbuy_redemption`(+ex), `dwd_platform_coupon_redemption`(+ex) | `dim_groupbuy_package`(+ex) | 团购核销 → 平台券核销 |
| 商品 | `dwd_store_goods_sale`(+ex) | `dim_tenant_goods`(+ex), `dim_store_goods`(+ex), `dim_goods_category` | 商品销售流水 |
| 库存 | `dwd_goods_stock_summary`, `dwd_goods_stock_movement` | (复用商品域维度表) | 库存汇总 + 变动流水 |
### 5 份文档的数据依赖关系
```mermaid
flowchart TD
DOC1["文档1: DWD 表结构与字段语义总览<br/>覆盖全部 43 张表"]
DOC1 --> DOC2["文档2: 业务全景<br/>消费产生机制"]
DOC1 --> DOC3["文档3: 账务全景<br/>结算与支付流水"]
DOC1 --> DOC4["文档4: 财务全景<br/>收入确认与对账"]
DOC1 --> DOC5["文档5: 维度表与主数据全景<br/>全部维度表"]
DOC2 -->|消费构成| DOC3
DOC2 -->|消费金额| DOC4
DOC3 -->|支付渠道| DOC4
DOC5 -->|维度关联| DOC2
DOC5 -->|维度关联| DOC3
```
### 文档产出路径与文件名
| 序号 | 文档 | 文件名 | 路径 |
|------|------|--------|------|
| 1 | DWD 表结构与字段语义总览 | `dwd-table-structure-overview.md` | `docs/reports/` |
| 2 | 业务全景:消费产生机制 | `dwd-business-panorama.md` | `docs/reports/` |
| 3 | 账务全景:结算与支付流水 | `dwd-accounting-panorama.md` | `docs/reports/` |
| 4 | 财务全景:收入确认与对账 | `dwd-financial-panorama.md` | `docs/reports/` |
| 5 | 维度表与主数据全景 | `dwd-dimension-panorama.md` | `docs/reports/` |
### 文档间引用规范
- 文档间使用相对路径引用:`[表结构总览](./dwd-table-structure-overview.md#table_name)`
- 引用已有分析报告:`[消费金额口径分析](./consume-money-caliber-deep-analysis.md#章节名)`
- 引用 BD 手册:`[BD 手册](../../apps/etl/connectors/feiqiu/docs/database/DWD/main/BD_manual_xxx.md)`
- 引用结论时标注验证状态和来源文档
## 正确性属性
*属性Property是一种在系统所有合法执行中都应成立的特征或行为——本质上是对系统应做什么的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。*
### Property 1: DWD 表覆盖完整性
*对于* DWD schema`test_etl_feiqiu.dwd`)中 `information_schema.tables` 返回的任意表,产出的文档集合中必须包含对该表的描述段落(表名出现在某份文档的标题或表格中)。
**Validates: Requirements 1.1, 5.1**
### Property 2: 主键标注准确性
*对于* DWD schema 中的任意表,文档中记录的主键字段集合必须与 `information_schema.table_constraints` + `key_column_usage` 查询返回的实际主键约束一致。
**Validates: Requirements 1.5**
### Property 3: 业务环节数据佐证
*对于* 业务全景文档文档2中描述的任意业务环节段落该段落必须包含至少一个来自测试库的数据样例以代码块或表格形式呈现的查询结果
**Validates: Requirements 2.6**
### Property 4: 对账公式验证一致性
*对于* 账务全景文档文档3中列出的任意对账公式文档中标注的成立率和例外数量必须与在 `test_etl_feiqiu` 全量数据上执行该公式验证 SQL 的实际结果一致。
**Validates: Requirements 3.5, 6.2**
### Property 5: 文档元数据完整性
*对于* 产出的任意全景文档,文档开头必须包含:(a) 数据来源标注(`test_etl_feiqiu`)、(b) 验证日期、(c) 数据时间范围(最早和最晚记录的时间)。
**Validates: Requirements 6.4**
### Property 6: 文档输出路径正确性
*对于* 本次梳理产出的任意文档文件,其路径必须位于 `docs/reports/` 目录下。
**Validates: Requirements 7.1**
### Property 7: 文档模板一致性
*对于* 产出的任意全景文档,其结构必须包含以下模板元素:标题、数据来源与验证日期块、目录、正文、附录(含验证 SQL 或数据样例)。
**Validates: Requirements 7.3**
### Property 8: 内部链接格式
*对于* 产出文档中的任意内部链接(指向本项目其他 markdown 文件的链接),链接必须使用相对路径格式(以 `./``../` 开头),且目标文件实际存在。
**Validates: Requirements 7.5**
## 错误处理
本任务是纯文档梳理,不涉及运行时代码。"错误"主要指梳理过程中遇到的数据异常和验证失败。
### 数据异常处理策略
| 异常场景 | 处理方式 | 文档标注 |
|----------|----------|----------|
| 表无数据0 行) | 跳过字段语义验证,仅记录表结构 | `❌ 未验证:表无数据` |
| 数据量不足(<10 行) | 执行有限验证,标注样本量不足 | `⚠️ 部分验证:仅 N 行数据` |
| 字段全 NULL | 标记为空字段,不展开分析 | 附录中列出字段名 |
| 对账公式不成立 | 分析例外案例,量化影响范围 | `⚠️ 成立率 X%,例外 N 笔` |
| 交叉验证矛盾 | 记录矛盾细节,标注为不确定 | `⚠️ 警告:无法对齐` |
| 现有文档与数据不一致 | 以数据为准,记录偏差 | `偏差记录` 段落 |
### 不确定性升级机制
当遇到以下情况时,必须在文档中以 `⚠️ 警告` 醒目标记:
1. 经过 ≥3 次不同角度的交叉验证仍无法确认的字段含义
2. 对账公式成立率 < 95% 且无法归因的例外
3. 金额字段的计算关系无法通过任何已知公式解释
4. 枚举值在现有文档中未记录且无法通过数据推断含义
警告内容须包含:已尝试的验证方法、无法确认的具体原因、建议的后续验证方向。
## 测试策略
### 测试方法说明
本任务的产出物是 markdown 文档而非代码,因此传统的单元测试和属性测试需要适配为"文档正确性验证"。
### 属性测试Property-Based Testing
使用 Python + `hypothesis` 库,针对设计文档中定义的正确性属性编写验证脚本。
**测试库**`hypothesis`(项目已有,见 `tests/` 目录)
**最低迭代次数**100 次(对于涉及随机采样的属性)
**测试位置**`tests/` 目录Monorepo 级属性测试)
#### 属性测试实现方案
| Property | 测试方法 | 实现思路 |
|----------|----------|----------|
| P1: 表覆盖完整性 | 查询 information_schema → 解析文档 → 比对 | 从数据库获取全部 DWD 表名,解析 5 份文档提取提及的表名,验证覆盖率 = 100% |
| P2: 主键标注准确性 | 查询 PK 约束 → 解析文档 → 比对 | 对于随机采样的 N 张表,比对文档中的主键与数据库实际主键 |
| P3: 业务环节数据佐证 | 解析文档段落 → 检查数据样例 | 解析业务全景文档的每个业务环节段落,验证包含代码块或数据表格 |
| P4: 对账公式验证一致性 | 提取公式 → 执行 SQL → 比对成立率 | 对于文档中的每个对账公式,重新执行验证 SQL比对成立率 |
| P5: 文档元数据完整性 | 解析文档头部 → 检查必要字段 | 对于每份文档,检查开头是否包含数据来源、验证日期、时间范围 |
| P6: 文档路径正确性 | 列出产出文件 → 检查路径 | 验证所有产出文件位于 `docs/reports/` |
| P7: 文档模板一致性 | 解析文档结构 → 检查模板元素 | 对于每份文档,检查是否包含标题、元数据块、目录、正文、附录 |
| P8: 内部链接格式 | 正则提取链接 → 检查格式和目标 | 提取所有 markdown 链接,验证使用相对路径且目标文件存在 |
#### 属性测试标签格式
每个属性测试必须包含注释标签:
```python
# Feature: dwd-business-panorama, Property 1: DWD 表覆盖完整性
# Feature: dwd-business-panorama, Property 2: 主键标注准确性
# ...
```
### 单元测试(示例测试)
针对 prework 中标记为 `yes - example` 的验收标准:
| 验收标准 | 测试内容 |
|----------|----------|
| 1.6 SCD2 字段标注 | 检查 `dim_member` 文档中是否标注了 `scd2_start_time`, `scd2_end_time`, `scd2_is_current`, `scd2_version` |
| 2.2 消费类目覆盖 | 检查业务全景文档中是否包含"台费"、"商品消费"、"助教服务"、"灯控电费"四个关键词 |
| 2.5 团购三层价格 | 检查文档中是否提及 `sale_price``pl_coupon_sale_amount``coupon_amount` |
| 2.7 Mermaid 流程图 | 检查业务全景文档中是否包含 ` ```mermaid` 代码块 |
| 3.7 consume_money 三种口径 | 检查文档中是否包含口径 A、B、C 的描述 |
| 4.5 对账矩阵 | 检查财务全景文档中是否包含矩阵格式的表格 |
| 7.2 5 份文档产出 | 检查 `docs/reports/` 下是否存在 5 个指定文件名 |
| 7.4 Mermaid 图表 | 检查每份文档中是否包含至少一个 Mermaid 代码块 |
### 边界条件测试
| 验收标准 | 边界场景 |
|----------|----------|
| 1.7 表无数据 | 验证无数据表在文档中有"数据不足"标注 |
### 测试执行
```bash
# 属性测试Monorepo 级)
cd C:\NeoZQYY && pytest tests/test_dwd_panorama_properties.py -v
# 单元测试
cd C:\NeoZQYY && pytest tests/test_dwd_panorama_examples.py -v
```

View File

@@ -0,0 +1,144 @@
# 需求文档DWD 业务全景梳理
## 简介
从 DWD 层出发,系统梳理飞球 ETL 对台球厅所有业务数据的记录方式。不仅覆盖每个字段的含义,还要搞清楚字段间的关联性,最终产出覆盖业务全景、账务全景、财务全景三个维度的分析文档。
现有的 `consume-money-caliber-deep-analysis.md``consumption-cases-analysis.md` 已深入分析了结算/支付相关的 DWD 表,但仅从支付订单金额角度无法搞清楚整个球房的业务、财务、账务全貌。本 SPEC 旨在补全剩余的业务域(台桌、助教、会员、团购、商品、库存),并将所有域串联成三个全景视图。
## ⚠️ 核心准则:数据本源优先(强制)
**启动本 SPEC 的根本原因**:项目中现有的所有文档(包括 `consume-money-caliber-deep-analysis.md``consumption-cases-analysis.md`、BD 手册、ETL 代码注释等)都可能存在纰漏、过期、遗漏或与数据库现实不符的情况。因此本次梳理必须遵循以下强制准则:
### 信息可信度分层
| 层级 | 可信度 | 说明 | 使用方式 |
|------|--------|------|----------|
| 宏观/直观层 | ✅ 可参考 | 表的业务归属、业务域划分、主要关联方向、流程大框架等直观明显的信息 | 直接参考作为起点,无需逐一验证 |
| 字段级/数据关联层 | ⚠️ 必须验证 | 字段语义、金额计算规则、枚举值含义、跨表关联关系等细节 | 慎之又慎,必须通过测试库数据关联做推理验证,不可直接采信 |
### 强制准则
1. **数据库是唯一真相源(字段级)**:字段级别的结论必须以测试库(`test_etl_feiqiu`)的实际表结构和数据为准。表结构信息必须通过查询 `information_schema``pg_catalog` 获取,禁止参考 `db/` 目录下的 DDL `.sql` 文件。宏观层面的信息(表的职责、业务域归属、流程大框架)可参考现有文档作为起点
2. **先查后写,禁止臆断**:描述任何字段语义、业务规则、金额关系之前,必须先通过 SQL 查询验证实际数据分布和值域。未经查询验证的结论禁止写入文档
3. **刨根问底,通过数据关联做推理**:对每个金额字段、状态枚举、关联关系,不能停留在"看起来是什么",必须通过交叉查询、边界案例、异常值分析确认其真实业务含义。从含义明确的字段出发,通过数据关联倒推不确定的字段
4. **现有文档作为假设而非事实**:引用现有文档的字段级结论时,必须标注为"待验证假设",并在验证后标注实际结果(一致/偏差/错误)
5. **偏差必须显式记录**:当验证发现现有文档与数据库现实不一致时,必须在新文档中明确记录偏差内容、偏差原因(如果能推断)、以及修正后的正确描述
6. **无数据不下结论**:如果测试库中某张表无数据或数据量不足以支撑结论,必须明确标注"数据不足,无法验证",禁止基于推测填充内容
7. **不确定性必须显式警告**:对于以下情况,必须在文档中以醒目的 `⚠️ 警告` 标记:(a) 经过长时间推理和多次交叉验证仍无法对齐的数据关系;(b) 根据现有数据和依据无法确认的字段含义或业务规则。警告内容须说明:已尝试的验证方法、无法确认的具体原因、建议的后续验证方向
## 术语表
- **DWD_层**Data Warehouse Detail 层ETL 管道中的明细数据层,存储经过清洗和标准化的业务事实和维度数据
- **全景文档**:按特定视角(业务/账务/财务)组织的、覆盖所有相关 DWD 表及其字段关联的分析文档
- **梳理器**:执行本 SPEC 任务的分析人员或脚本,负责读取 DWD 表结构和数据并产出文档
- **测试库**PostgreSQL 数据库 `test_etl_feiqiu`,包含 DWD 层的实际数据,用于验证文档描述的准确性
- **业务域**DWD 表按业务功能的分组,包括:结算、台桌、助教、会员、团购、商品、库存
- **主表/扩展表**DWD 层的表设计模式,主表存核心字段,`_ex` 扩展表存补充字段,通过主键 1:1 关联
- **维度表**:以 `dim_` 前缀命名的 DWD 表存储缓慢变化的主数据SCD2 模式)
- **事实表**:以 `dwd_` 前缀命名的 DWD 表,存储业务事件流水(增量写入)
- **字段语义**:字段在实际业务中的真实含义,可能与 DDL 注释不一致(如 `point_amount` 实际是线上收款而非积分)
- **对账公式**:用于校验数据一致性的等式关系,如 F1消费构成、F2收支平衡
## 需求
### 需求 1DWD 表结构与字段语义梳理(智能聚焦策略)
**用户故事:** 作为数据分析人员,我希望获得每张 DWD 表中有业务意义的字段的准确语义说明,以便理解数据如何记录业务事实。
**梳理策略说明:** `apps/etl/connectors/feiqiu/docs/database/DWD/` 下已有各表的文档,宏观层面(表的职责、业务域归属、主要关联关系)可作为参考起点。但字段级别的语义必须通过数据关联倒推验证,不可直接采信。梳理时采用"智能聚焦"策略,而非逐字段全量罗列。
#### 验收标准
1. THE 梳理器 SHALL 覆盖 DWD 层全部 7 个业务域(结算、台桌、助教、会员、团购、商品、库存)的所有主表和扩展表,以现有 DWD 文档为宏观参考起点
2. THE 梳理器 SHALL 对每张表先执行字段分类筛选:(a) 查询全表空字段(全部为 NULL 的列),标记为"空字段-跳过"(b) 识别含义明确的基础字段(如 `id``site_id``created_at`),简要标注即可;(c) 聚焦于业务关键字段(金额、状态、类型、关联 ID进行深度验证
3. WHEN 梳理业务关键字段时THE 梳理器 SHALL 采用"倒推法"先从含义明确的字段出发通过数据关联JOIN、聚合对比、值域交叉推断不确定字段的真实含义
4. WHEN 发现字段的实际业务含义与现有 DWD 文档或 DDL 注释不一致时THE 梳理器 SHALL 明确标注偏差内容并给出基于测试库数据验证的修正说明
5. THE 梳理器 SHALL 标注每张表的主键、外键关联、以及与其他 DWD 表的关联方式(关联字段和关联类型如 1:1、1:N
6. THE 梳理器 SHALL 标注每张维度表的 SCD2 生效/失效字段和当前记录标识字段
7. IF 测试库中某张表无数据或数据量不足以验证字段语义THEN THE 梳理器 SHALL 在文档中标注该表的数据状态并说明无法验证的字段
8. THE 梳理器 SHALL 主动忽略以下字段类别,不在文档中详细展开:(a) 全表 NULL 的空字段(仅在附录中列出字段名);(b) ETL 管理字段(如 `_etl_loaded_at``_etl_batch_id`),仅简要说明用途;(c) 含义完全透明且无歧义的字段(如 `id``created_at``updated_at`),仅在表结构概览中列出
### 需求 2业务全景文档——消费是怎么产生的
**用户故事:** 作为门店经营者,我希望搞清楚台球厅的消费是怎么来的、价格怎么报的、优惠怎么产生的,以便理解整个消费链路。
#### 验收标准
1. THE 全景文档 SHALL 描述从顾客开台到结算的完整业务流程,标注每个环节涉及的 DWD 表和关键字段
2. THE 全景文档 SHALL 覆盖以下消费类目的产生机制:台费(含多台桌合并)、商品消费、助教服务(陪打/超休)、灯控电费
3. THE 全景文档 SHALL 描述台费的计价规则,包括:台桌类型与单价的关系、计时方式、台费折扣(`dwd_table_fee_adjust`)的触发条件和计算方式
4. THE 全景文档 SHALL 描述优惠的产生机制,包括:平台团购券(美团/抖音)的核销流程、会员折扣的计算方式、台费调整(`adjust_amount`)的业务场景
5. THE 全景文档 SHALL 描述团购券的三层价格体系(顾客支付给平台的 `sale_price`、平台结算给门店的 `pl_coupon_sale_amount`、门店实际抵扣的 `coupon_amount`),并标注每层价格对应的 DWD 表和字段
6. WHEN 描述某个业务环节时THE 全景文档 SHALL 提供至少一个来自测试库的真实数据样例作为佐证
7. THE 全景文档 SHALL 以 Mermaid 流程图展示从开台到结算的完整数据流向
### 需求 3账务全景文档——客户怎么结算的
**用户故事:** 作为门店财务人员,我希望搞清楚客户的每一笔消费是通过什么方式结算的、支付流水怎么记录的,以便进行对账和核销。
#### 验收标准
1. THE 全景文档 SHALL 描述所有支付渠道及其在 DWD 层的记录方式,包括:线上收款(微信/支付宝)、现金、储值卡余额(含礼品卡/充值卡)、平台团购券
2. THE 全景文档 SHALL 描述 `dwd_payment` 表与 `dwd_settlement_head` 的关联方式,以及 `payment_method` 枚举值与实际支付渠道的对应关系
3. THE 全景文档 SHALL 描述会员储值卡体系,包括:充值流程(`dwd_recharge_order`)、余额变动记录(`dwd_member_balance_change`)、本金/赠送金额的分账逻辑
4. THE 全景文档 SHALL 描述退款流程,包括:结算退款、充值退款、转账退款的触发场景和在 DWD 层的记录方式
5. THE 全景文档 SHALL 列出所有已验证的对账公式F1~F6、R1~R3、RF1~RF2、B1~B4标注每个公式的适用范围、成立率、以及已知的例外情况
6. WHEN 描述支付方式推断逻辑时THE 全景文档 SHALL 提供完整的推断规则(因 `settlement_head_ex.payment_method` 全部为 0 不可用)
7. THE 全景文档 SHALL 描述 `consume_money` 字段的三种历史口径A/B/C及其时间线标注当前生效的口径
### 需求 4财务全景文档——收入确认与对账
**用户故事:** 作为门店管理者,我希望搞清楚门店的收入如何确认、各渠道的资金如何对账,以便进行财务分析和经营决策。
#### 验收标准
1. THE 全景文档 SHALL 描述门店收入的构成,按收入来源分类:台费收入、商品收入、助教服务收入、充值收入
2. THE 全景文档 SHALL 描述每种收入来源对应的 DWD 表、关键金额字段、以及从 DWD 到 DWS 的聚合路径
3. THE 全景文档 SHALL 描述平台团购券场景下的收入确认逻辑:门店实际收入 = `pl_coupon_sale_amount`(平台结算额),而非 `coupon_amount`(券面值抵扣额),差额为门店补贴
4. THE 全景文档 SHALL 描述储值卡充值场景的资金流向:充值收款 → 余额入账(本金+赠送)→ 消费扣款 → 退款(如有),标注每个环节的 DWD 表和金额字段
5. THE 全景文档 SHALL 提供按支付渠道的对账矩阵,列出每种支付渠道在 DWD 层涉及的表和字段,以及跨表校验的公式
6. THE 全景文档 SHALL 标注已知的数据质量问题和对账例外(如助教券的支付缺口、商品消费未覆盖等),并给出影响范围的量化评估
### 需求 5维度表与主数据全景
**用户故事:** 作为数据开发人员,我希望搞清楚 DWD 层所有维度表的结构和业务含义,以便在 DWS 聚合时正确关联维度。
#### 验收标准
1. THE 全景文档 SHALL 覆盖所有 DWD 维度表(`dim_site``dim_table``dim_assistant``dim_member``dim_member_card_account``dim_tenant_goods``dim_store_goods``dim_goods_category``dim_groupbuy_package`),包括主表和扩展表
2. THE 全景文档 SHALL 描述每张维度表与事实表的关联方式,标注关联字段和关联基数
3. THE 全景文档 SHALL 描述会员体系的数据结构,包括:会员档案(`dim_member`)、储值卡账户(`dim_member_card_account`)、会员等级/标签的记录方式
4. THE 全景文档 SHALL 描述商品体系的数据结构,包括:商品分类树(`dim_goods_category`)、租户商品(`dim_tenant_goods`)与门店商品(`dim_store_goods`)的关系、库存相关表(`dwd_goods_stock_summary``dwd_goods_stock_movement`)的结构
5. THE 全景文档 SHALL 描述团购套餐维度(`dim_groupbuy_package`)的结构,包括:套餐与券种的关系、价格体系(面值/售价/门店结算价)
### 需求 6数据验证与文档可信度保障
**用户故事:** 作为数据分析人员,我希望文档中的每个结论都经过测试库数据验证,以避免使用过期或错误的信息。
#### 验收标准
1. WHEN 梳理任何 DWD 表的字段语义时THE 梳理器 SHALL 通过查询测试库(`test_etl_feiqiu`)验证字段的实际值分布,确认语义描述的准确性。禁止仅凭 DDL 注释或现有文档描述字段含义
2. WHEN 文档引用对账公式时THE 梳理器 SHALL 提供该公式在测试库全量数据上的验证结果(成立率、例外数量和分类)
3. IF 验证过程中发现现有文档(`consume-money-caliber-deep-analysis.md``consumption-cases-analysis.md`、BD 手册、ETL 代码注释等的结论与测试库数据不一致THEN THE 梳理器 SHALL 在新文档中明确标注修正内容,包括:原文档的描述、实际数据的表现、偏差原因分析
4. THE 梳理器 SHALL 在每份全景文档的开头标注数据验证日期和测试库数据的时间范围
5. THE 全景文档 SHALL 对每个关键结论标注验证状态:✅ 已验证(附验证 SQL 或结果摘要)、⚠️ 部分验证(附已知例外)、❌ 未验证(附原因)
6. THE 梳理器 SHALL 对每个金额字段执行以下深度验证流程:(a) 查询值域分布MIN/MAX/AVG/中位数/NULL 占比);(b) 与关联字段交叉验证(如 `total_amount` 是否等于各子项之和);(c) 检查边界案例(零值、负值、极端值)的业务含义
7. WHEN 引用现有文档的任何结论时THE 梳理器 SHALL 将其标注为"待验证假设",并在验证后更新为实际结果(一致 ✅ / 偏差 ⚠️ / 错误 ❌),附偏差说明
### 需求 7文档产出与组织
**用户故事:** 作为项目成员,我希望全景文档按统一格式组织并落在正确的路径下,以便团队查阅和后续维护。
#### 验收标准
1. THE 梳理器 SHALL 将所有全景文档输出到 `docs/reports/` 目录下
2. THE 梳理器 SHALL 产出以下文档(文件名待确认):
- DWD 表结构与字段语义总览
- 业务全景:消费产生机制
- 账务全景:结算与支付流水
- 财务全景:收入确认与对账
- 维度表与主数据全景
3. THE 全景文档 SHALL 使用统一的文档模板,包含:标题、数据来源与验证日期、目录、正文、附录(验证 SQL、数据样例
4. THE 全景文档 SHALL 在适当位置使用 Mermaid 图表ER 图、流程图、时序图)辅助说明数据关系和业务流程
5. WHEN 全景文档引用其他文档的结论时THE 梳理器 SHALL 使用相对路径链接到源文档,并标注引用的具体章节

View File

@@ -0,0 +1,277 @@
# 实施计划DWD 业务全景梳理
## 概述
将 DWD 业务全景梳理的设计方案转化为可执行的任务序列。任务按两阶段组织基础层文档1表结构总览→ 全景层文档2~5数据验证贯穿全程。属性测试使用 Python + hypothesis放置在 `tests/` 目录。
## Tasks
- [x] 1. 阶段一DWD 表结构与字段语义总览文档1
- [x] 1.1 枚举 DWD 全部表并按业务域分组
- 查询 `test_etl_feiqiu.dwd``information_schema.tables` 获取全部表名
- 按 7 个业务域(结算、台桌、助教、会员、团购、商品、库存)分组
- 查询每张表的行数和时间范围,确认数据状态
- 创建 `docs/reports/dwd-table-structure-overview.md` 文件骨架(含模板元素:标题、元数据块、目录、正文、附录)
- _Requirements: 1.1, 7.1, 7.2, 7.3_
- [x] 1.2 结算域表梳理dwd_settlement_head/ex, dwd_payment, dwd_refund/ex
- 对每张表执行单表智能聚焦分析information_schema 获取列信息 → 字段分类筛选(空字段/ETL字段/透明字段/业务关键字段)→ 业务关键字段倒推验证
- 金额字段执行深度验证值域分布MIN/MAX/AVG/中位数/NULL占比+ 交叉验证
- 枚举字段执行 DISTINCT + 频次分布
- 对比现有 BD 手册标注偏差
- 将结果写入文档1的结算域章节
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.8, 6.1, 6.6_
- [x] 1.3 台桌域表梳理dim_table/ex, dwd_table_fee_log/ex, dwd_table_fee_adjust/ex
- 同 1.2 的分析流程
- 重点验证台费计价相关字段、台费调整的触发条件
- 标注 dim_table 的 SCD2 字段
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.8, 6.1, 6.6_
- [x] 1.4 助教域表梳理dim_assistant/ex, dwd_assistant_service_log/ex
- 同 1.2 的分析流程
- 重点验证助教服务类型枚举、`_ex.is_trash` 作废标记的业务含义
- 标注 dim_assistant 的 SCD2 字段
- 注意:`dwd_assistant_trash_event` 已于 2026-02-22 DROP作废判断改用 `dwd_assistant_service_log_ex.is_trash`
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.8, 6.1, 6.6_
- [x] 1.5 会员域表梳理dim_member/ex, dim_member_card_account/ex, dwd_member_balance_change/ex, dwd_recharge_order/ex
- 同 1.2 的分析流程
- 重点验证储值卡本金/赠送金额分账、余额变动类型枚举
- 标注 dim_member 和 dim_member_card_account 的 SCD2 字段
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.8, 6.1, 6.6_
- [x] 1.6 团购域表梳理dim_groupbuy_package/ex, dwd_groupbuy_redemption/ex, dwd_platform_coupon_redemption/ex
- 同 1.2 的分析流程
- 重点验证团购三层价格体系sale_price / pl_coupon_sale_amount / coupon_amount
- 标注 dim_groupbuy_package 的 SCD2 字段
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.8, 6.1, 6.6_
- [x] 1.7 商品域与库存域表梳理dim_tenant_goods/ex, dim_store_goods/ex, dim_goods_category, dwd_store_goods_sale/ex, dwd_goods_stock_summary, dwd_goods_stock_movement
- 同 1.2 的分析流程
- 重点验证商品分类树结构、租户商品与门店商品的关系
- 标注维度表的 SCD2 字段dim_goods_category 无扩展表)
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 6.1, 6.6_
- [x] 1.8 完善文档1跨表关联关系汇总与文档收尾
- 汇总所有表的主键、外键关联、关联类型1:1, 1:N
- 补充 Mermaid ER 图展示跨域关联
- 补充附录:空字段汇总、验证 SQL 汇总
- 添加文档间引用链接(相对路径格式)
- 确认文档模板完整性(标题、元数据块、目录、正文、附录)
- _Requirements: 1.5, 6.4, 7.3, 7.4, 7.5_
- [x] 2. 检查点 - 文档1 完成确认
- 确认文档1`docs/reports/dwd-table-structure-overview.md`)已覆盖全部 42 张表(含新发现的 dim_staff/dim_staff_ex
- 确认每张表的业务关键字段均已通过测试库数据验证
- 确认偏差记录完整point_amount 等偏差已标注)
- 第9章门店维度有占位符待后续补充不阻塞全景文档
- [x] 3. 阶段二A业务全景文档文档2
- [x] 3.1 创建文档2骨架并梳理消费产生链路
- 创建 `docs/reports/dwd-business-panorama.md`,使用全景文档通用模板
- 描述从开台到结算的完整业务流程,标注每个环节涉及的 DWD 表和关键字段
- 以 Mermaid 流程图展示从开台到结算的完整数据流向
- 基于文档1的字段语义引用而非重复描述
- _Requirements: 2.1, 2.7, 6.4, 7.3_
- [x] 3.2 梳理各消费类目的产生机制
- 台费(含多台桌合并):计价规则、台桌类型与单价关系、计时方式
- 台费折扣dwd_table_fee_adjust触发条件和计算方式
- 商品消费:商品销售流水的记录方式
- 助教服务(陪打/超休):服务类型和计费方式
- 灯控电费:记录方式
- 每个环节提供至少一个测试库真实数据样例
- _Requirements: 2.2, 2.3, 2.6_
- [x] 3.3 梳理优惠与团购机制
- 平台团购券(美团/抖音)核销流程
- 会员折扣计算方式
- 台费调整adjust_amount的业务场景
- 团购券三层价格体系sale_price / pl_coupon_sale_amount / coupon_amount标注每层价格对应的 DWD 表和字段
- 每个环节提供测试库数据样例
- _Requirements: 2.4, 2.5, 2.6_
- [x] 3.4 文档2收尾附录与引用
- 补充附录(验证 SQL、数据样例
- 添加文档间引用链接
- 确认文档模板完整性
- _Requirements: 7.3, 7.4, 7.5_
- [x] 4. 阶段二B账务全景文档文档3
- [x] 4.1 创建文档3骨架并梳理支付渠道
- 创建 `docs/reports/dwd-accounting-panorama.md`,使用全景文档通用模板
- 描述所有支付渠道及其在 DWD 层的记录方式(线上收款、现金、储值卡余额、平台团购券)
- 描述 dwd_payment 与 dwd_settlement_head 的关联方式
- 描述 payment_method 枚举值与实际支付渠道的对应关系
- 描述支付方式推断逻辑(因 settlement_head_ex.payment_method 全部为 0 不可用)
- _Requirements: 3.1, 3.2, 3.6, 6.4, 7.3_
- [x] 4.2 梳理会员储值卡体系与退款流程
- 充值流程dwd_recharge_order
- 余额变动记录dwd_member_balance_change
- 本金/赠送金额分账逻辑
- 退款流程:结算退款、充值退款、转账退款的触发场景和 DWD 记录方式
- _Requirements: 3.3, 3.4_
- [x] 4.3 梳理对账公式与 consume_money 口径
- 列出所有已验证的对账公式F1~F6、R1~R3、RF1~RF2、B1~B4
- 对每个公式在 test_etl_feiqiu 全量数据上执行验证,标注成立率和例外情况
- 描述 consume_money 字段的三种历史口径A/B/C及时间线标注当前生效口径
- _Requirements: 3.5, 3.7, 6.2_
- [x] 4.4 文档3收尾附录与引用
- 补充附录(验证 SQL、对账公式验证结果
- 添加文档间引用链接
- 确认文档模板完整性
- _Requirements: 7.3, 7.4, 7.5_
- [x] 5. 检查点 - 文档2和文档3完成确认
- 确认文档2覆盖全部消费类目和优惠机制台费/商品/助教/灯控/团购券/会员折扣)
- 确认文档3覆盖全部支付渠道和对账公式F1/F2/B1-B3/consume_money三种口径
- 确认所有关键结论标注了验证状态
- 确认文档3覆盖全部支付渠道和对账公式
- 确认所有关键结论标注了验证状态
- Ensure all tests pass, ask the user if questions arise.
- [x] 6. 阶段二C财务全景文档文档4
- [x] 6.1 创建文档4骨架并梳理收入构成
- 创建 `docs/reports/dwd-financial-panorama.md`,使用全景文档通用模板
- 按收入来源分类描述门店收入构成:台费收入、商品收入、助教服务收入、充值收入
- 描述每种收入来源对应的 DWD 表、关键金额字段、从 DWD 到 DWS 的聚合路径
- _Requirements: 4.1, 4.2, 6.4, 7.3_
- [x] 6.2 梳理团购收入确认与储值卡资金流向
- 团购券场景收入确认逻辑:门店实际收入 = pl_coupon_sale_amount差额为门店补贴
- 储值卡充值资金流向:充值收款 → 余额入账(本金+赠送)→ 消费扣款 → 退款
- 标注每个环节的 DWD 表和金额字段
- _Requirements: 4.3, 4.4_
- [x] 6.3 构建对账矩阵与数据质量评估
- 按支付渠道构建对账矩阵:每种支付渠道涉及的 DWD 表和字段、跨表校验公式
- 标注已知数据质量问题和对账例外(助教券支付缺口、商品消费未覆盖等)
- 给出影响范围的量化评估
- _Requirements: 4.5, 4.6_
- [x] 6.4 文档4收尾附录与引用
- 补充附录(验证 SQL、对账矩阵详细数据
- 添加文档间引用链接引用文档2的消费构成、文档3的支付渠道
- 确认文档模板完整性
- _Requirements: 7.3, 7.4, 7.5_
- [x] 7. 阶段二D维度表与主数据全景文档5
- [x] 7.1 创建文档5骨架并梳理门店与台桌维度
- 创建 `docs/reports/dwd-dimension-panorama.md`,使用全景文档通用模板
- 梳理 dim_site/ex门店维度结构、SCD2 字段、与事实表的关联
- 梳理 dim_table/ex台桌维度结构、台桌类型枚举、与台费流水的关联
- _Requirements: 5.1, 5.2, 6.4, 7.3_
- [x] 7.2 梳理会员体系与助教维度
- 会员档案dim_member/ex会员等级/标签记录方式
- 储值卡账户dim_member_card_account/ex账户类型、余额字段
- 助教维度dim_assistant/ex助教类型、服务能力
- 描述维度表与事实表的关联方式,标注关联字段和基数
- _Requirements: 5.2, 5.3_
- [x] 7.3 梳理商品体系与团购维度
- 商品分类树dim_goods_category分类层级结构
- 租户商品dim_tenant_goods/ex与门店商品dim_store_goods/ex的关系
- 库存相关表dwd_goods_stock_summary, dwd_goods_stock_movement的结构
- 团购套餐维度dim_groupbuy_package/ex套餐与券种关系、价格体系
- _Requirements: 5.4, 5.5_
- [x] 7.4 文档5收尾Mermaid ER 图与附录
- 补充 Mermaid ER 图展示全部维度表与事实表的关联关系
- 补充附录(验证 SQL、SCD2 字段汇总)
- 添加文档间引用链接
- 确认文档模板完整性
- _Requirements: 5.1, 5.2, 7.3, 7.4, 7.5_
- [x] 8. 检查点 - 全部5份文档完成确认
- 确认 5 份文档均已创建在 `docs/reports/` 目录下
- 确认文档间引用链接格式正确(相对路径)且目标文件存在
- 确认每份文档包含完整模板元素(标题、元数据块、目录、正文、附录)
- Ensure all tests pass, ask the user if questions arise.
- [x] 9. 属性测试与示例测试
- [x] 9.1 创建属性测试文件骨架与公共工具
- 创建 `tests/test_dwd_panorama_properties.py`
- 实现公共工具函数:读取文档内容、解析 markdown 结构、提取表名、提取链接
- 配置 hypothesis settingsmin_examples=100
- 使用 `load_dotenv` 加载根 `.env`,通过 `TEST_DB_DSN` 连接测试库
- _Requirements: 6.1_
- [x] 9.2 编写 Property 1: DWD 表覆盖完整性
- **Property 1: DWD 表覆盖完整性**
- 查询 information_schema.tables 获取 DWD schema 全部表名
- 解析 5 份文档提取提及的表名
- 验证覆盖率 = 100%
- **Validates: Requirements 1.1, 5.1**
- [x] 9.3 编写 Property 2: 主键标注准确性
- **Property 2: 主键标注准确性**
- 对随机采样的表,查询 information_schema.table_constraints + key_column_usage 获取实际主键
- 解析文档1中该表的主键标注
- 验证一致性
- **Validates: Requirements 1.5**
- [x] 9.4 编写 Property 3: 业务环节数据佐证
- **Property 3: 业务环节数据佐证**
- 解析业务全景文档文档2的每个业务环节段落
- 验证每个段落包含至少一个代码块或数据表格
- **Validates: Requirements 2.6**
- [x] 9.5 编写 Property 4: 对账公式验证一致性
- **Property 4: 对账公式验证一致性**
- 提取账务全景文档文档3中的对账公式和标注的成立率
- 重新执行验证 SQL比对成立率
- **Validates: Requirements 3.5, 6.2**
- [x] 9.6 编写 Property 5: 文档元数据完整性
- **Property 5: 文档元数据完整性**
- 对每份全景文档检查开头是否包含数据来源test_etl_feiqiu、验证日期、数据时间范围
- **Validates: Requirements 6.4**
- [x] 9.7 编写 Property 6: 文档输出路径正确性
- **Property 6: 文档输出路径正确性**
- 验证所有产出文件位于 `docs/reports/` 目录下
- **Validates: Requirements 7.1**
- [x] 9.8 编写 Property 7: 文档模板一致性
- **Property 7: 文档模板一致性**
- 对每份文档,检查是否包含标题、元数据块、目录、正文、附录
- **Validates: Requirements 7.3**
- [x] 9.9 编写 Property 8: 内部链接格式
- **Property 8: 内部链接格式**
- 正则提取所有 markdown 内部链接
- 验证使用相对路径格式(以 `./``../` 开头)且目标文件存在
- **Validates: Requirements 7.5**
- [x] 9.10 编写示例测试
- 创建 `tests/test_dwd_panorama_examples.py`
- SCD2 字段标注检查dim_member 文档中包含 scd2_start_time 等字段
- 消费类目覆盖检查:业务全景文档包含"台费"、"商品消费"、"助教服务"、"灯控电费"
- 团购三层价格检查:文档包含 sale_price、pl_coupon_sale_amount、coupon_amount
- Mermaid 流程图检查:业务全景文档包含 mermaid 代码块
- consume_money 三种口径检查:文档包含口径 A、B、C
- 对账矩阵检查:财务全景文档包含矩阵格式表格
- 5 份文档产出检查docs/reports/ 下存在 5 个指定文件名
- 每份文档 Mermaid 图表检查
- 无数据表标注检查(边界条件)
- _Requirements: 1.6, 1.7, 2.2, 2.5, 2.7, 3.5, 3.7, 4.5, 7.2, 7.4_
- [x] 10. 最终检查点 - 全部完成确认
- 运行全部属性测试:`cd C:\NeoZQYY && pytest tests/test_dwd_panorama_properties.py -v`
- 运行全部示例测试:`cd C:\NeoZQYY && pytest tests/test_dwd_panorama_examples.py -v`
- 确认 5 份文档内容完整、验证状态标注齐全
- Ensure all tests pass, ask the user if questions arise.
## Notes
- 标记 `*` 的任务为可选,可跳过以加速 MVP
- 每个任务引用了具体的需求编号,确保可追溯
- 检查点任务确保增量验证
- 属性测试验证文档的结构正确性,示例测试验证具体内容覆盖
- 所有数据库查询必须使用测试库 `test_etl_feiqiu`,通过 `TEST_DB_DSN` 连接
- 文档1是其他4份文档的基础必须先完成阶段一再进入阶段二

View File

@@ -1 +0,0 @@
{"generationMode": "requirements-first"}

View File

@@ -1,464 +0,0 @@
# 设计文档
## 概述
本设计覆盖 v8 联调中 4 个"临时止血"修复的深度方案,按优先级排列:
1. **需求 DP0**`DwdLoadTask.load()` 返回值格式规范化
2. **需求 C1P1**:会员生日字段 ETL 链路补齐
3. **需求 BP1**:多门店会员查询支持
4. **需求 AP2**:助教月度聚合按档位分段统计
5. **需求 C2P2**:助教手动补录会员生日
设计原则:
- 每个需求独立可部署,按优先级逐步实施
- DDL 变更通过迁移脚本执行,支持回滚
- 保持现有 ETL 架构BaseTask E/T/L 模板)不变
## 架构
整体架构不变,变更集中在以下层面:
```mermaid
graph TD
subgraph "需求 D: 返回值规范化"
D1[DwdLoadTask.load] -->|errors: int| D2[BaseTask._accumulate_counts]
D2 -->|sum| D3[FlowRunner._safe_int]
end
subgraph "需求 A: 档位分段统计"
A1[dws_assistant_daily_detail] -->|GROUP BY level_code| A2[AssistantMonthlyTask]
A2 -->|多行/档位| A3[dws_assistant_monthly_summary]
A3 --> A4[AssistantSalaryTask 分段计算]
end
subgraph "需求 B: 多门店会员查询"
B1[dwd.事实表] -->|member_id IN| B2[dim_member]
B2 --> B3[DWS 任务]
end
subgraph "需求 C: 生日字段"
C1[ODS payload] -->|birthday 提取| C2[dim_member.birthday]
C3[后端 API] -->|UPSERT| C4[zqyy_app.member_birthday_manual]
C2 --> C5[DWS 任务: COALESCE]
C4 -->|FDW 只读| C5
end
```
## 组件与接口
### 需求 D返回值格式规范化
**变更文件:**
- `apps/etl/connectors/feiqiu/tasks/dwd/dwd_load_task.py`
- `apps/etl/connectors/feiqiu/tasks/base_task.py`
**DwdLoadTask.load() 返回值变更:**
```python
# 变更前
return {"tables": summary, "errors": errors}
# errors: list[dict],如 [{"table": "dim_assistant_ex", "error": "..."}]
# 变更后
return {
"tables": summary,
"errors": len(errors), # int — 与其他任务一致
"error_details": errors, # list[dict] — 保留详情供日志使用
}
```
**BaseTask._accumulate_counts() 防御层增强:**
```python
@staticmethod
def _accumulate_counts(total: dict, current: dict) -> dict:
for key, value in (current or {}).items():
if isinstance(value, (int, float)):
total[key] = (total.get(key) or 0) + value
elif isinstance(value, list):
# 防御层list 类型转为 len() 累加
total[key] = (total.get(key) or 0) + len(value)
else:
total.setdefault(key, value)
return total
```
**FlowRunner._safe_int() 保留不变**,作为最终防御层。
### 需求 A助教月度聚合按档位分段统计
**DDL 变更:**
```sql
-- 迁移脚本:删除旧唯一约束,创建新约束
ALTER TABLE dws.dws_assistant_monthly_summary
DROP CONSTRAINT IF EXISTS uk_dws_assistant_monthly;
ALTER TABLE dws.dws_assistant_monthly_summary
ADD CONSTRAINT uk_dws_assistant_monthly
UNIQUE (site_id, assistant_id, stat_month, assistant_level_code);
```
**AssistantMonthlyTask._extract_daily_aggregates() 变更:**
```sql
-- 变更前GROUP BY assistant_id, DATE_TRUNC('month', stat_date)
-- 变更后:加入 assistant_level_code 分组
SELECT
assistant_id,
assistant_level_code,
assistant_level_name,
-- nickname 取时间最后一条
(ARRAY_AGG(assistant_nickname ORDER BY stat_date DESC))[1] AS assistant_nickname,
DATE_TRUNC('month', stat_date)::DATE AS stat_month,
COUNT(DISTINCT stat_date) AS work_days,
SUM(total_service_count) AS total_service_count,
-- ... 其余聚合字段不变
FROM dws.dws_assistant_daily_detail
WHERE site_id = %s AND ({month_where})
GROUP BY assistant_id, assistant_level_code, assistant_level_name,
DATE_TRUNC('month', stat_date)
```
**AssistantSalaryTask 适配:**
- `_extract_monthly_summary()` 返回多行(同一助教不同档位)
- `transform()` 遍历每行分别计算工资,按档位使用对应的 `level_price``tier`
- 最终每个 `(assistant_id, stat_month, assistant_level_code)` 生成一条工资记录
**AssistantFinanceTask._extract_daily_revenue() nickname 修复:**
```sql
-- 变更前MAX(s.nickname) AS assistant_nickname
-- 变更后:
(ARRAY_AGG(s.nickname ORDER BY s.start_use_time DESC))[1] AS assistant_nickname
```
**AssistantCustomerTask._extract_service_pairs() nickname 修复:**
```sql
-- 变更前MAX(assistant_nickname) AS assistant_nickname
-- 变更后:
(ARRAY_AGG(assistant_nickname ORDER BY service_date DESC))[1] AS assistant_nickname
```
### 需求 B多门店会员查询支持
**变更模式:** 所有 `_extract_member_info(site_id)` 方法的 SQL 从:
```sql
WHERE register_site_id = %s AND scd2_is_current = 1
```
改为通过事实表反查:
```sql
WHERE member_id IN (
SELECT DISTINCT tenant_member_id
FROM dwd.{}
WHERE site_id = %s AND tenant_member_id IS NOT NULL AND tenant_member_id != 0
) AND scd2_is_current = 1
```
**受影响的任务和对应事实表:**
| 任务 | 方法 | 事实表 |
|------|------|--------|
| `member_visit_task.py` | `_extract_member_info` | `dwd_settlement_head` |
| `member_consumption_task.py` | `_extract_member_info` | `dwd_settlement_head` |
| `assistant_customer_task.py` | `_extract_member_info` | `dwd_assistant_service_log` |
**`dim_member_card_account` 的处理:**
- `member_consumption_task.py``finance_recharge_task.py` 中对 `dim_member_card_account` 的查询也使用 `register_site_id`
- 同样改为通过事实表的 `tenant_member_id` 反查:
```sql
WHERE tenant_member_id IN (
SELECT DISTINCT tenant_member_id
FROM dwd.{}
WHERE site_id = %s AND tenant_member_id IS NOT NULL AND tenant_member_id != 0
) AND scd2_is_current = 1
```
### 需求 C1会员生日字段 ETL 链路补齐
**DDL 变更:**
```sql
-- dim_member 加列
ALTER TABLE dwd.dim_member ADD COLUMN IF NOT EXISTS birthday DATE;
COMMENT ON COLUMN dwd.dim_member.birthday IS '会员生日来源ODS member_profiles payload 中的 birthday 字段';
```
**ODS → DWD 装载:**
- `DwdLoadTask` 的列映射是自动的(通过 `_get_columns()` 读取 DWD 表列名,与 ODS 列名匹配)
- ODS `member_profiles` 表没有 `birthday` 列,但 `payload` JSONB 中可能包含
- 需要在 `_build_column_mapping()``_fetch_source_rows()` 中增加从 `payload` 提取 `birthday` 的逻辑
- 方案:在 ODS 表也加 `birthday` 列(保持 ODS 与 API 字段对齐ODS 入库时从 JSON 提取
```sql
-- ODS member_profiles 加列
ALTER TABLE ods.member_profiles ADD COLUMN IF NOT EXISTS birthday DATE;
```
ODS 入库逻辑(`ods_tasks.py`)已有从 JSON 提取字段的机制,新增 `birthday` 字段映射即可。DwdLoadTask 的自动列匹配会自动将 `ods.member_profiles.birthday` 映射到 `dwd.dim_member.birthday`
**SCD2 处理:**
- `birthday` 作为 `dim_member` 的普通维度列SCD2 变化检测会自动包含
- 当 API 返回的 birthday 值变化时,会触发 SCD2 版本更新
**DWS 任务恢复 birthday 引用:**
- `member_visit_task.py``_extract_member_info()` SQL 中加入 `birthday`
- `member_consumption_task.py` 同理
### 需求 C2助教手动补录会员生日
**DDL`zqyy_app` / `test_zqyy_app` 业务库):**
```sql
CREATE TABLE IF NOT EXISTS member_birthday_manual (
id BIGSERIAL PRIMARY KEY,
member_id BIGINT NOT NULL,
birthday_value DATE NOT NULL,
recorded_by_assistant_id BIGINT,
recorded_by_name VARCHAR(50),
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
source VARCHAR(20) DEFAULT 'assistant',
CONSTRAINT uk_member_birthday_manual
UNIQUE (member_id, recorded_by_assistant_id)
);
COMMENT ON TABLE member_birthday_manual IS '助教手动补录的会员生日信息';
CREATE INDEX idx_mbd_member ON member_birthday_manual (member_id);
```
**FDW 映射ETL 库读取业务库数据):**
当前 FDW 方向是 `zqyy_app``etl_feiqiu`(业务库读 ETL 数据)。需求 C2 需要反向ETL DWS 任务读取业务库的手动补录表。
方案:在 `etl_feiqiu` 库中创建指向 `zqyy_app` 的 FDW 外部表:
```sql
-- 在 etl_feiqiu 中执行
CREATE SERVER IF NOT EXISTS zqyy_app_server
FOREIGN DATA WRAPPER postgres_fdw
OPTIONS (host 'localhost', dbname 'zqyy_app', port '5432');
CREATE USER MAPPING IF NOT EXISTS FOR etl_user
SERVER zqyy_app_server
OPTIONS (user 'app_reader', password '***');
CREATE SCHEMA IF NOT EXISTS fdw_app;
CREATE FOREIGN TABLE fdw_app.member_birthday_manual (
id BIGINT,
member_id BIGINT,
birthday_value DATE,
recorded_by_assistant_id BIGINT,
recorded_by_name VARCHAR(50),
recorded_at TIMESTAMPTZ,
source VARCHAR(20)
) SERVER zqyy_app_server
OPTIONS (schema_name 'public', table_name 'member_birthday_manual');
```
**DWS 任务生日读取逻辑:**
```sql
-- 优先手动补录值,其次 API 值
COALESCE(
(SELECT birthday_value
FROM fdw_app.member_birthday_manual
WHERE member_id = m.member_id
ORDER BY recorded_at ASC -- 最早提交优先
LIMIT 1),
m.birthday
) AS member_birthday
```
**后端 API**
```python
# apps/backend/app/routers/member_birthday.py
@router.post("/member-birthday")
async def submit_member_birthday(
member_id: int,
birthday_value: date,
assistant_id: int,
assistant_name: str,
db=Depends(get_db),
):
"""助教提交会员生日UPSERT"""
sql = """
INSERT INTO member_birthday_manual
(member_id, birthday_value, recorded_by_assistant_id, recorded_by_name)
VALUES (%s, %s, %s, %s)
ON CONFLICT (member_id, recorded_by_assistant_id)
DO UPDATE SET
birthday_value = EXCLUDED.birthday_value,
recorded_at = NOW()
"""
db.execute(sql, (member_id, birthday_value, assistant_id, assistant_name))
return {"status": "ok"}
```
## 数据模型
### 变更汇总
| 库 | 表 | 变更类型 | 说明 |
|----|-----|---------|------|
| `etl_feiqiu` | `ods.member_profiles` | 加列 | `birthday DATE` |
| `etl_feiqiu` | `dwd.dim_member` | 加列 | `birthday DATE` |
| `etl_feiqiu` | `dws.dws_assistant_monthly_summary` | 改约束 | UK 加入 `assistant_level_code` |
| `zqyy_app` | `member_birthday_manual` | 新建表 | 手动补录生日 |
| `etl_feiqiu` | `fdw_app.member_birthday_manual` | 新建外部表 | FDW 映射 |
### 迁移脚本清单
按优先级排序,每个迁移脚本独立可执行:
1. `2026-02-22__D_dwd_load_return_format.sql` — 无 DDL纯代码变更
2. `2026-02-22__C1_dim_member_add_birthday.sql` — ODS/DWD 加列
3. `2026-02-22__B_no_ddl_code_only.sql` — 无 DDL纯代码变更
4. `2026-02-22__A_monthly_summary_uk_change.sql` — 唯一约束变更
5. `2026-02-22__C2_member_birthday_manual.sql` — 新建表 + FDW
## 正确性属性
*属性Property是系统在所有合法执行中都应保持为真的特征或行为——本质上是对"系统应该做什么"的形式化陈述。属性是连接人类可读规格说明与机器可验证正确性保证之间的桥梁。*
### Property 1: DwdLoadTask 返回值格式一致性
*对于任意* DwdLoadTask.load() 的执行结果,返回字典中 `errors` 键的值应为 `int` 类型,且等于 `error_details` 列表的长度。
**验证: 需求 1.1**
### Property 2: _accumulate_counts 类型安全累加
*对于任意* 包含 `int``float``list` 类型值的计数字典,`_accumulate_counts()` 应将 `int`/`float` 直接累加,将 `list` 转为 `len()` 后累加,且不抛出异常。
**验证: 需求 1.2**
### Property 3: 档位分段聚合正确性
*对于任意* 助教在同一月内存在 N 个不同 `assistant_level_code` 的日度数据,`_extract_daily_aggregates()` 应返回恰好 N 行记录,每行的业绩指标之和应等于该助教该月的总业绩。
**验证: 需求 2.1**
### Property 4: nickname 按时间倒序取值
*对于任意* 助教在聚合周期内有多条不同 nickname 的记录,聚合结果中的 nickname 应等于时间最晚的那条记录的 nickname。此属性适用于 AssistantMonthlyTask、AssistantFinanceTask、AssistantCustomerTask 三个任务。
**验证: 需求 2.3, 2.5, 2.6**
### Property 5: 工资按档位分段计算
*对于任意* 助教在同一月有多个档位的月度汇总记录AssistantSalaryTask 应为每个档位分别计算工资,每个档位使用对应的 `level_price``tier` 配置,且所有档位的工资记录数等于月度汇总的行数。
**验证: 需求 2.4**
### Property 6: 跨店会员可查
*对于任意* 在 A 店注册但在 B 店有消费记录的会员B 店的 DWS 任务通过事实表反查 `dim_member`应能获取到该会员的维度信息nickname、mobile 等)。
**验证: 需求 3.1, 3.2**
### Property 7: birthday ODS→DWD 装载正确性
*对于任意* ODS `member_profiles` 中包含 `birthday` 值的记录DwdLoadTask 装载后 `dwd.dim_member` 中对应记录的 `birthday` 应与 ODS 源值一致。
**验证: 需求 4.2**
### Property 8: birthday SCD2 变化检测
*对于任意* `dim_member` 现有记录,当 ODS 中同一会员的 `birthday` 值发生变化时SCD2 应关闭旧版本并创建新版本,新版本的 `birthday` 等于新值。
**验证: 需求 4.3**
### Property 9: 生日 UPSERT 幂等性
*对于任意* `(member_id, assistant_id)` 组合,连续两次提交不同的 `birthday_value``member_birthday_manual` 表中该组合应只有一条记录,且 `birthday_value` 等于最后一次提交的值。
**验证: 需求 5.2, 5.5**
### Property 10: 手动补录优先于 API 来源
*对于任意* 同时在 `dim_member.birthday``member_birthday_manual` 中有值的会员DWS 任务输出的 `member_birthday` 应等于手动补录表中的值。
**验证: 需求 5.4**
### Property 11: SCD2 更新不影响手动补录表
*对于任意*`member_birthday_manual` 中有记录的会员,执行 DwdLoadTask SCD2 更新 `dim_member.birthday` 后,`member_birthday_manual` 中的记录应保持不变。
**验证: 需求 5.6**
## 错误处理
### 需求 D返回值格式
- `DwdLoadTask.load()` 中单表装载失败时,错误信息追加到 `error_details` 列表,`errors` 计数递增
- `_accumulate_counts()` 遇到未知类型时使用 `setdefault` 保留原值(现有行为不变)
- `_safe_int()` 遇到非 int/list 类型时返回 0现有行为不变
### 需求 A档位分段
- 助教在某月无任何服务记录时,不生成月度汇总行(现有行为不变)
- `assistant_level_code` 为 NULL 时作为独立分组处理NULL 视为一个档位)
- 唯一约束变更后需要清理旧数据DELETE + 重新计算当月数据)
### 需求 B多门店查询
- 事实表中无该门店消费记录时,`_extract_member_info()` 返回空字典(现有行为不变)
- 子查询返回空集时,`WHERE member_id IN (空集)` 等价于 `WHERE FALSE`,不会报错
### 需求 C1生日字段
- ODS 中 `birthday` 为 NULL 或空字符串时DWD 中存为 NULL
- 无效日期格式时DwdLoadTask 的现有类型转换逻辑会将其置为 NULL
### 需求 C2手动补录
- `member_id` 不存在于 `dim_member` 时,仍允许提交(助教可能先于 ETL 发现新客户)
- `birthday_value` 格式校验由后端 API 的 Pydantic schema 处理
- FDW 连接失败时DWS 任务应 catch 异常并降级为仅使用 `dim_member.birthday`
## 测试策略
### 测试框架
- 属性测试:`hypothesis`Python每个属性测试最少 100 次迭代
- 单元测试:`pytest`
- 测试工具:`apps/etl/connectors/feiqiu/tests/unit/task_test_utils.py` 提供 FakeDB/FakeAPI
### 属性测试
每个正确性属性对应一个 hypothesis 属性测试,标注格式:
```python
# Feature: etl-aggregation-fix, Property N: {property_text}
@given(...)
def test_property_N_xxx(data):
...
```
属性测试放置位置:
- 需求 D 相关Property 1-2`apps/etl/connectors/feiqiu/tests/unit/test_return_format_properties.py`
- 需求 A 相关Property 3-5`apps/etl/connectors/feiqiu/tests/unit/test_monthly_aggregation_properties.py`
- 需求 B 相关Property 6`apps/etl/connectors/feiqiu/tests/unit/test_multi_store_properties.py`
- 需求 C 相关Property 7-11`apps/etl/connectors/feiqiu/tests/unit/test_birthday_properties.py`
### 单元测试
单元测试覆盖具体示例和边界情况:
- DDL 结构验证(唯一约束、列存在性)
- 空数据 / NULL 值边界
- 迁移脚本的回滚验证
### 测试环境
- 数据库:`test_etl_feiqiu` / `test_zqyy_app`(通过 `TEST_DB_DSN` 环境变量)
- 纯单元测试使用 FakeDB/FakeAPI不涉及真实数据库连接
- ETL 测试 cwd`apps/etl/connectors/feiqiu/`
- 后端测试 cwd`apps/backend/`

View File

@@ -1,84 +0,0 @@
# 需求文档
## 简介
v8 联调修复了 11 个 BUG其中 4 个的当前修复方式是"临时止血",需要更完整的方案。本 Spec 覆盖以下四个需求:
- **需求 A**:助教月度聚合按档位分段统计,替代 `MAX()` 聚合
- **需求 B**:多门店会员查询 `register_site_id` 已知限制标记 + 预留扩展方案
- **需求 C**会员生日字段全链路补齐C1: ETL 链路 / C2: 手动补录)
- **需求 D**`DwdLoadTask.load()` 返回值格式规范化
## 术语表
- **DwdLoadTask**DWD 层装载任务,负责将 ODS 原始数据清洗装载至 DWD 明细层
- **DWS 任务**:数据汇总层任务,从 DWD 层聚合生成业务报表数据
- **SCD2**:缓慢变化维度类型 2通过版本化记录维度属性的历史变化
- **BaseTask**ETL 任务基类,提供 Extract/Transform/Load 模板方法和计数累加逻辑
- **FlowRunner**ETL 流程编排器,按层级顺序执行任务并汇总计数
- **dim_member**DWD 层会员维度表,使用 SCD2 管理历史版本
- **dws_assistant_monthly_summary**DWS 层助教月度汇总表
- **dws_assistant_salary_calc**DWS 层助教工资计算表
- **register_site_id**:会员注册门店 ID当前 `dim_member` 中唯一的门店标识
- **assistant_level_code**:助教档位代码,助教月内可能因升级/降级而变化
- **dim_member_birthday_manual**:手动补录生日表(位于 `zqyy_app` 业务库),存储助教提交的会员生日信息,通过 FDW 只读映射供 ETL DWS 任务读取
- **_safe_int()**`flow_runner.py` 中的类型安全辅助函数,将 `int`/`list`/`None` 统一转为 `int`
- **_accumulate_counts()**`BaseTask` 中的计数累加方法,合并多段执行的统计结果
## 需求
### 需求 1DwdLoadTask 返回值格式规范化(需求 D
**用户故事:** 作为 ETL 开发者,我希望 DwdLoadTask.load() 的返回值格式与其他任务保持一致,以便 FlowRunner 能安全地汇总所有任务的计数。
#### 验收标准
1. WHEN DwdLoadTask.load() 执行完成, THE DwdLoadTask SHALL 返回包含 `errors` 键(值为 `int` 类型,等于错误列表长度)和 `error_details` 键(值为 `list[dict]` 类型,包含错误详情)的字典
2. WHEN BaseTask._accumulate_counts() 遇到值为 `list` 类型的计数项, THE BaseTask SHALL 将该值转换为 `len(list)` 后再累加(防御层)
3. WHEN FlowRunner 汇总所有任务计数, THE FlowRunner SHALL 保留 `_safe_int()` 作为最终防御层,确保 `sum()` 不会因类型不一致而崩溃
### 需求 2助教月度聚合按档位分段统计需求 A
**用户故事:** 作为运营管理者,我希望助教月度汇总按档位分段统计业绩,以便准确反映助教在不同档位期间的表现和工资计算。
#### 验收标准
1. WHEN 助教在同一月内存在多个 assistant_level_code, THE AssistantMonthlyTask SHALL 按 `(assistant_id, stat_month, assistant_level_code)` 分组生成多行记录,分别统计各档位的业绩指标
2. THE dws_assistant_monthly_summary 表 SHALL 使用 `(site_id, assistant_id, stat_month, assistant_level_code)` 作为唯一约束
3. WHEN AssistantMonthlyTask 需要取 nickname 值, THE AssistantMonthlyTask SHALL 按时间倒序取最后一条记录的 nickname而非使用 `MAX()` 聚合
4. WHEN AssistantSalaryTask 计算工资, THE AssistantSalaryTask SHALL 按档位分段计算抽成,适配新的多行月度汇总结构
5. WHEN AssistantFinanceTask 提取日度收入需要 nickname, THE AssistantFinanceTask SHALL 按时间倒序取最后一条记录的 nickname而非使用 `MAX()` 聚合
6. WHEN AssistantCustomerTask 提取服务对需要 nickname, THE AssistantCustomerTask SHALL 按时间倒序取最后一条记录的 nickname而非使用 `MAX()` 聚合
### 需求 3多门店会员查询支持需求 B
**用户故事:** 作为运营管理者,我希望 DWS 任务能正确查询跨店消费的会员信息,以便 B 店能看到在 A 店注册但在 B 店消费的会员维度数据。
#### 验收标准
1. WHEN DWS 任务需要查询会员信息, THE DWS 任务 SHALL 通过事实表中的 `member_id` 反查 `dim_member`,而非使用 `WHERE register_site_id = %s` 预筛选
2. WHEN 会员在 A 店注册并在 B 店消费, THE B 店的 DWS 任务 SHALL 能查询到该会员的昵称、手机号等维度信息
3. WHEN DWS 任务执行会员信息提取, THE DWS 任务 SHALL 使用 `WHERE member_id IN (SELECT DISTINCT member_id FROM dwd.事实表 WHERE site_id = %s)` 模式获取会员维度数据
### 需求 4会员生日字段 ETL 链路补齐(需求 C1
**用户故事:** 作为运营管理者,我希望会员生日信息能从上游 API 完整传递到 DWS 层,以便用于会员分析和销售线索。
#### 验收标准
1. THE dim_member 表 SHALL 包含 `birthday DATE`
2. WHEN DwdLoadTask 执行 ODS → DWD 装载, THE DwdLoadTask SHALL 在列映射中包含 `birthday` 字段,将 ODS 中的生日数据装载到 `dim_member.birthday`
3. WHEN SCD2 更新 dim_member 记录, THE DwdLoadTask SHALL 将 `birthday` 作为变化检测字段之一,正常处理生日值的变化
4. WHEN MemberVisitTask 等 DWS 任务提取会员信息, THE DWS 任务 SHALL 从 `dim_member.birthday` 读取生日字段并写入 DWS 目标表
### 需求 5助教手动补录会员生日需求 C2
**用户故事:** 作为助教,我希望能手动提交客户的生日信息,以便在上游 API 未提供生日数据时补充这一重要销售线索。
#### 验收标准
1. THE `zqyy_app` 业务库(开发/测试环境使用 `test_zqyy_app`SHALL 包含 `member_birthday_manual` 表,结构包含 `member_id``birthday_value``recorded_by_assistant_id``recorded_by_name``recorded_at``source` 字段,唯一约束为 `(member_id, recorded_by_assistant_id)`
2. WHEN 同一助教对同一会员重复提交生日, THE 系统 SHALL 更新该助教的已有记录UPSERT保留所有其他助教的提交记录
3. WHEN DWS 任务需要读取手动补录生日, THE DWS 任务 SHALL 通过 FDW 只读映射从 `zqyy_app.member_birthday_manual` 读取数据
4. WHEN dim_member.birthdayAPI 来源)和 member_birthday_manual手动来源同时存在, THE DWS 任务 SHALL 优先使用手动补录值
5. WHEN 助教通过后端 API 提交生日, THE 后端 SHALL 提供 POST 接口接收 `member_id``birthday_value``assistant_id``assistant_name` 参数,执行 UPSERT 写入 `zqyy_app.member_birthday_manual`
6. WHEN SCD2 更新 dim_member.birthday, THE DwdLoadTask SHALL 正常更新 API 来源的生日值,不影响业务库中手动补录表的数据

View File

@@ -1,228 +0,0 @@
# 实现计划ETL 聚合修复与生日字段补齐
## 概述
按优先级D → C1 → B → A → C2逐步实施每个需求独立可部署。代码变更集中在 ETL Connector 的 tasks 层和后端 APIDDL 变更通过迁移脚本执行。
## 任务
- [x] 1. 需求 DDwdLoadTask 返回值格式规范化
- [x] 1.1 修改 DwdLoadTask.load() 返回值格式
-`apps/etl/connectors/feiqiu/tasks/dwd/dwd_load_task.py``load()` 方法中,将 `return {"tables": summary, "errors": errors}` 改为 `return {"tables": summary, "errors": len(errors), "error_details": errors}`
- _需求: 1.1_
- [x] 1.2 增强 BaseTask._accumulate_counts() 防御层
-`apps/etl/connectors/feiqiu/tasks/base_task.py``_accumulate_counts()` 方法中,增加 `isinstance(value, list)` 分支,将 list 转为 `len()` 后累加
- _需求: 1.2_
- [x] 1.3 编写属性测试DwdLoadTask 返回值格式一致性
- **Property 1: DwdLoadTask 返回值格式一致性**
- **验证: 需求 1.1**
- 文件:`apps/etl/connectors/feiqiu/tests/unit/test_return_format_properties.py`
- [x] 1.4 编写属性测试_accumulate_counts 类型安全累加
- **Property 2: _accumulate_counts 类型安全累加**
- **验证: 需求 1.2**
- 文件:`apps/etl/connectors/feiqiu/tests/unit/test_return_format_properties.py`
- [x] 2. 检查点 — 需求 D 完成
- 确保所有测试通过ask the user if questions arise.
- [x] 3. 需求 C1会员生日字段 ETL 链路补齐
- [x] 3.1 编写迁移脚本ODS/DWD 加 birthday 列
- 创建 `db/etl_feiqiu/migrations/2026-02-22__C1_dim_member_add_birthday.sql`
- ODS: `ALTER TABLE ods.member_profiles ADD COLUMN IF NOT EXISTS birthday DATE;`
- DWD: `ALTER TABLE dwd.dim_member ADD COLUMN IF NOT EXISTS birthday DATE;`
- 包含回滚语句和验证 SQL
- _需求: 4.1_
- [-] 3.1a 在测试库执行迁移脚本 C1
-`test_etl_feiqiu` 上执行 `2026-02-22__C1_dim_member_add_birthday.sql`
- 执行验证 SQL 确认列已添加
- _需求: 4.1_
- [x] 3.2 更新 ODS 入库逻辑:提取 birthday 字段
- 在 ODS 入库任务中增加 `birthday` 字段的 JSON 提取映射
- 确认 `ods_tasks.py``member_profiles` 的字段列表包含 `birthday`
- _需求: 4.2_
- [x] 3.3 验证 DwdLoadTask 自动列映射包含 birthday
- DwdLoadTask 通过 `_get_columns()` 自动读取 DWD 表列名,确认 `birthday` 被自动包含在列映射中
- SCD2 变化检测自动包含所有非 SCD2 元数据列,确认 `birthday` 参与变化检测
- _需求: 4.2, 4.3_
- [x] 3.4 恢复 DWS 任务中的 birthday 引用
- 修改 `member_visit_task.py``_extract_member_info()` SQL加入 `birthday` 字段
- 修改 `member_consumption_task.py``_extract_member_info()` SQL加入 `birthday` 字段
- 修改 DWS 任务的 `transform()` 方法,将 `member_birthday` 写入输出记录
- _需求: 4.4_
- [x] 3.5 编写属性测试birthday ODS→DWD 装载正确性
- **Property 7: birthday ODS→DWD 装载正确性**
- **验证: 需求 4.2**
- 文件:`apps/etl/connectors/feiqiu/tests/unit/test_birthday_properties.py`
- [x] 3.6 编写属性测试birthday SCD2 变化检测
- **Property 8: birthday SCD2 变化检测**
- **验证: 需求 4.3**
- 文件:`apps/etl/connectors/feiqiu/tests/unit/test_birthday_properties.py`
- [x] 4. 检查点 — 需求 C1 完成
- 确保所有测试通过ask the user if questions arise.
- [x] 5. 需求 B多门店会员查询支持
- [x] 5.1 修改 member_visit_task.py 的 _extract_member_info()
-`WHERE register_site_id = %s` 改为通过 `dwd_settlement_head` 事实表的 `tenant_member_id` 反查
- _需求: 3.1, 3.2_
- [x] 5.2 修改 member_consumption_task.py 的 _extract_member_info()
-`WHERE register_site_id = %s` 改为通过 `dwd_settlement_head` 事实表的 `tenant_member_id` 反查
- 同时修改 `dim_member_card_account` 查询,改为通过事实表反查
- _需求: 3.1, 3.2_
- [x] 5.3 修改 assistant_customer_task.py 的 _extract_member_info()
-`WHERE register_site_id = %s` 改为通过 `dwd_assistant_service_log` 事实表的 `tenant_member_id` 反查
- _需求: 3.1, 3.2_
- [x] 5.4 修改 finance_recharge_task.py 的 dim_member_card_account 查询
-`WHERE register_site_id = %s` 改为通过事实表反查
- _需求: 3.1_
- [x] 5.5 编写属性测试:跨店会员可查
- **Property 6: 跨店会员可查**
- **验证: 需求 3.1, 3.2**
- 文件:`apps/etl/connectors/feiqiu/tests/unit/test_multi_store_properties.py`
- [x] 6. 检查点 — 需求 B 完成
- 确保所有测试通过ask the user if questions arise.
- [x] 7. 需求 A助教月度聚合按档位分段统计
- [x] 7.1 编写迁移脚本:唯一约束变更
- 创建 `db/etl_feiqiu/migrations/2026-02-22__A_monthly_summary_uk_change.sql`
- DROP 旧约束 `uk_dws_assistant_monthly`ADD 新约束 `(site_id, assistant_id, stat_month, assistant_level_code)`
- 包含回滚语句和验证 SQL
- _需求: 2.2_
- [x] 7.1a 在测试库执行迁移脚本 A
-`test_etl_feiqiu` 上执行 `2026-02-22__A_monthly_summary_uk_change.sql`
- 执行验证 SQL 确认约束已变更(`SELECT conname FROM pg_constraint ...`
- _需求: 2.2_
- [x] 7.2 修改 AssistantMonthlyTask._extract_daily_aggregates()
- GROUP BY 加入 `assistant_level_code, assistant_level_name`
- nickname 改为 `(ARRAY_AGG(assistant_nickname ORDER BY stat_date DESC))[1]`
- _需求: 2.1, 2.3_
- [x] 7.3 修改 AssistantMonthlyTask._process_month() 适配多行
- 确认 `_process_month()` 能正确处理同一助教多个档位的聚合数据
- 每行使用自己的 `assistant_level_code` 进行档位匹配和排名计算
- _需求: 2.1_
- [x] 7.4 修改 AssistantSalaryTask 适配档位分段工资计算
- `_extract_monthly_summary()` 已能返回多行(同一助教不同档位)
- `transform()` 遍历每行分别计算工资,按档位使用对应的 `level_price``tier`
- _需求: 2.4_
- [x] 7.5 修改 AssistantFinanceTask._extract_daily_revenue() nickname 取值
-`MAX(s.nickname)` 改为 `(ARRAY_AGG(s.nickname ORDER BY s.start_use_time DESC))[1]`
- _需求: 2.5_
- [x] 7.6 修改 AssistantCustomerTask._extract_service_pairs() nickname 取值
-`MAX(assistant_nickname)` 改为 `(ARRAY_AGG(assistant_nickname ORDER BY service_date DESC))[1]`
- _需求: 2.6_
- [x] 7.7 编写属性测试:档位分段聚合正确性
- **Property 3: 档位分段聚合正确性**
- **验证: 需求 2.1**
- 文件:`apps/etl/connectors/feiqiu/tests/unit/test_monthly_aggregation_properties.py`
- [x] 7.8 编写属性测试nickname 按时间倒序取值
- **Property 4: nickname 按时间倒序取值**
- **验证: 需求 2.3, 2.5, 2.6**
- 文件:`apps/etl/connectors/feiqiu/tests/unit/test_monthly_aggregation_properties.py`
- [x] 7.9 编写属性测试:工资按档位分段计算
- **Property 5: 工资按档位分段计算**
- **验证: 需求 2.4**
- 文件:`apps/etl/connectors/feiqiu/tests/unit/test_monthly_aggregation_properties.py`
- [x] 8. 检查点 — 需求 A 完成
- 确保所有测试通过ask the user if questions arise.
- [x] 9. 需求 C2助教手动补录会员生日
- [x] 9.1 编写迁移脚本:创建 member_birthday_manual 表
- 创建 `db/zqyy_app/migrations/2026-02-22__C2_member_birthday_manual.sql`
-`zqyy_app` / `test_zqyy_app` 中创建 `member_birthday_manual`
- 包含回滚语句和验证 SQL
- _需求: 5.1_
- [x] 9.1a 在测试库执行迁移脚本 C2
-`test_zqyy_app` 上执行 `2026-02-22__C2_member_birthday_manual.sql`
- 执行验证 SQL 确认表和约束已创建
- _需求: 5.1_
- [x] 9.2 编写 FDW 映射脚本ETL 库读取业务库
- 创建 `db/fdw/setup_fdw_reverse.sql`etl_feiqiu → zqyy_app 方向)
- 创建 `db/fdw/setup_fdw_reverse_test.sql`test 环境版本)
- 在 ETL 库中创建 `fdw_app.member_birthday_manual` 外部表
- _需求: 5.3_
- [x] 9.2a 在测试库执行 FDW 映射脚本
-`test_etl_feiqiu` 上执行 `setup_fdw_reverse_test.sql`
- 验证 `fdw_app.member_birthday_manual` 外部表可读取
- _需求: 5.3_
- [x] 9.3 修改 DWS 任务:生日读取优先级逻辑
-`member_visit_task.py``member_consumption_task.py``_extract_member_info()` 中,使用 `COALESCE(fdw_app.member_birthday_manual, dim_member.birthday)` 逻辑
- 增加 FDW 连接失败的降级处理
- _需求: 5.4_
- [x] 9.4 实现后端 API生日提交接口
- 创建 `apps/backend/app/routers/member_birthday.py`
- 实现 `POST /member-birthday` 接口,执行 UPSERT
- 创建 Pydantic schema `apps/backend/app/schemas/member_birthday.py`
-`apps/backend/app/main.py` 中注册路由
- _需求: 5.5_
- [x] 9.5 编写属性测试:生日 UPSERT 幂等性
- **Property 9: 生日 UPSERT 幂等性**
- **验证: 需求 5.2, 5.5**
- 文件:`apps/etl/connectors/feiqiu/tests/unit/test_birthday_properties.py`
- [x] 9.6 编写属性测试:手动补录优先于 API 来源
- **Property 10: 手动补录优先于 API 来源**
- **验证: 需求 5.4**
- 文件:`apps/etl/connectors/feiqiu/tests/unit/test_birthday_properties.py`
- [x] 9.7 编写属性测试SCD2 更新不影响手动补录表
- **Property 11: SCD2 更新不影响手动补录表**
- **验证: 需求 5.6**
- 文件:`apps/etl/connectors/feiqiu/tests/unit/test_birthday_properties.py`
- [x] 10. 收尾:主 DDL 合并与文档更新
- [x] 10.1 合并迁移变更到主 DDL 文件
-`birthday` 列定义合并到 `db/etl_feiqiu/schemas/ods.sql``member_profiles` 表)
-`birthday` 列定义合并到 `db/etl_feiqiu/schemas/dwd.sql``dim_member` 表)
- 将新唯一约束合并到 `db/etl_feiqiu/schemas/dws.sql``dws_assistant_monthly_summary` 表)
-`member_birthday_manual` 表定义合并到 `db/zqyy_app/schemas/init.sql`
- 将 FDW 反向映射合并到 `db/fdw/setup_fdw.sql``db/fdw/setup_fdw_test.sql`
- [x] 10.2 更新数据库文档
-`docs/database/` 中新建或更新受影响表的文档:
- `dim_member`:新增 `birthday` 列说明
- `dws_assistant_monthly_summary`:唯一约束变更说明
- `member_birthday_manual`:新建表文档(含 FDW 映射说明)
- 遵循现有 `BD_Manual_*.md` 命名规范
- [x] 10.3 最终验证
- 确保所有测试通过
- 确认主 DDL 文件与测试库实际结构一致
- ask the user if questions arise
## 备注
- 标记 `*` 的任务为可选测试任务,可跳过以加速 MVP
- 每个需求独立可部署,检查点确保增量验证
- 迁移脚本需在 `test_etl_feiqiu` / `test_zqyy_app` 上先行验证
- 属性测试使用 hypothesis每个属性最少 100 次迭代
- 单元测试使用 FakeDB/FakeAPI不依赖真实数据库

View File

@@ -0,0 +1 @@
{"specId": "cd79656c-9c23-4470-a147-d402b5f4b50b", "workflowType": "requirements-first", "specType": "feature"}

View File

@@ -0,0 +1,357 @@
# 技术设计:团购详情接口整合 ETL 数据流
> 对应需求文档:#[[file:.kiro/specs/etl-coupon-detail/requirements.md]]
## 1. 架构概览
```
ODS_GROUP_PACKAGE 任务BaseOdsTask + OdsTaskSpec
│ 阶段 1列表拉取UnifiedPipeline #1
│ QueryPackageCouponList → ods.group_buy_packages
ods.group_buy_packages ──写入完成──▶ 阶段 2详情拉取自动启动
│ │
│ ┌───────┴───────┐
│ │ UnifiedPipeline│
│ │ #2 │
│ │ (detail_mode) │
│ └───────┬───────┘
│ │
│ ┌───────▼───────┐
│ │ 串行请求线程 │
│ │ (主线程) │
│ │ RateLimiter │
│ └───────┬───────┘
│ │ 响应提交
│ ┌───────▼───────┐
│ │ 处理队列 │
│ │ N 个工作线程 │
│ └───────┬───────┘
│ │ 处理完成
│ ┌───────▼───────┐
│ │ 写入队列 │
│ │ 单线程写库 │
│ └───────┬───────┘
│ │
▼ ▼
DWD dim_groupbuy_package ods.group_buy_package_details
DWD dim_groupbuy_package_ex ◀── SCD2 合并 ──┘
```
> 不再新建独立的 `DetailFetcher` 类。详情拉取完全复用 `BaseOdsTask` 已有的 `detail_endpoint` 二级拉取模式,通过 `OdsTaskSpec` 声明式配置即可驱动。
## 2. 调研结论与设计决策
### 2.1 ODS 层表方案 → 选项 A新建独立表
决策依据:
- 现有 `ods.group_buy_packages` 使用 `OdsTaskSpec` 驱动payload 存储列表接口的原始 JSONPK 为 `id + content_hash`
- 详情接口返回嵌套结构(子数组 `packageCouponAssistants``grouponSiteInfos` 等),与列表接口的扁平结构差异大
- 两个接口的写入时机不同(列表先写完,详情后写),混在同一表会导致 `SnapshotMode.FULL_TABLE` 的软删除逻辑冲突
- 独立表可以独立演进字段,不影响现有列表数据的稳定性
新表:`ods.group_buy_package_details`PK = `coupon_id`BIGINT全量快照覆盖
### 2.2 DWD 层表方案 → 选项 A在现有扩展表新增字段
决策依据:
- `dim_groupbuy_package_ex` 当前 21 个业务字段(不含 SCD2密度适中
- 详情接口的增量价值字段仅 4 个 JSONB 列(`table_area_ids``table_area_names``assistant_services``groupon_site_infos`
- 新建独立表会增加 SCD2 版本同步复杂度,且下游查询需要额外 JOIN
- 扩展表的 SCD2 合并已与主表同步,新增字段自动纳入变更检测
### 2.3 取消信号 → 复用现有 `CancellationToken`
`CancellationToken`(封装 `threading.Event`)已在 etl-unified-pipeline 中实现。详情阶段的 `UnifiedPipeline` 实例共享同一个 `cancel_token`,在请求循环和 `RateLimiter.wait()` 中检查取消状态。
## 3. 组件设计
### 3.1 OdsTaskSpec 详情配置(声明式,无需新建类)
> `RateLimiter``api/rate_limiter.py`)、`CancellationToken``utils/cancellation.py`)和 `UnifiedPipeline``pipeline/unified_pipeline.py`)已在 etl-unified-pipeline 中实现并上线。`BaseOdsTask.execute()` 内置了 `detail_endpoint` 二级详情拉取模式,本 spec 通过 `OdsTaskSpec` 声明式配置驱动,不新建独立类。
`tasks/ods/ods_tasks.py``ODS_GROUP_PACKAGE` 任务 spec 中添加以下配置:
```python
OdsTaskSpec(
code="ODS_GROUP_PACKAGE",
# ... 现有列表拉取配置 ...
# ── Detail_Mode 配置 ──
detail_endpoint="/PackageCoupon/QueryPackageCouponInfo",
detail_param_builder=lambda rec: {"couponId": rec["id"]},
detail_target_table="ods.group_buy_package_details",
detail_data_path=("data",),
detail_id_column="id",
)
```
配置字段说明:
| 字段 | 值 | 说明 |
|------|-----|------|
| `detail_endpoint` | `/PackageCoupon/QueryPackageCouponInfo` | 详情接口 endpoint |
| `detail_param_builder` | `lambda rec: {"couponId": rec["id"]}` | 将列表表的 `id` 映射为详情接口的 `couponId` 参数 |
| `detail_target_table` | `ods.group_buy_package_details` | 详情数据写入的目标表 |
| `detail_data_path` | `("data",)` | 详情响应的数据路径 |
| `detail_id_column` | `id` | 从 `ods.group_buy_packages` 提取 couponId 列表的列名 |
### 3.2 执行流程BaseOdsTask.execute() 内置)
`BaseOdsTask.execute()` 在列表拉取全部完成后,自动检测 `spec.detail_endpoint` 是否配置,若已配置则启动详情拉取阶段:
```python
# BaseOdsTask.execute() 内置逻辑(已实现,无需修改):
if spec.detail_endpoint:
# 1. 创建独立的 UnifiedPipeline 实例(共享 cancel_token
detail_pipeline = UnifiedPipeline(
api_client=self.api,
db_connection=self.db,
logger=self.logger,
config=pipeline_config, # PipelineConfig.from_app_config()
cancel_token=cancel_token, # 与列表阶段共享
)
# 2. 从 ODS 目标表查询 ID 列表,生成详情请求序列
detail_requests = self._build_detail_requests(spec)
# → SELECT DISTINCT {detail_id_column} FROM {table_name}
# → 对每个 ID 调用 detail_param_builder 构造参数
# → yield PipelineRequest(is_detail=True, detail_id=record_id)
# 3. 构建处理和写入函数
detail_process_fn = self._build_detail_process_fn(spec)
detail_write_fn = self._build_detail_write_fn(spec, source_file)
# → 写入 detail_target_table使用 _insert_records_schema_aware()
# 4. 执行管道
detail_result = detail_pipeline.run(
detail_requests, detail_process_fn, detail_write_fn,
)
self.db.commit()
```
### 3.3 详情响应处理(需自定义 process_fn
默认的 `_build_detail_process_fn``response.get("records", [])` 提取记录。对于团购详情接口,需要自定义字段提取逻辑:
-`data.groupPurchasePackage` 提取结构化字段(`package_name``duration``start_time``end_time` 等)
-`data.groupPurchasePackage.tableAreaId` / `tableAreaNameList` 提取台区数组为 JSONB
-`data.packageCouponAssistants` 提取助教服务关联数组为 JSONB
-`data.grouponSiteInfos` 提取关联门店数组为 JSONB
-`data.packagePackageService``data.packageCouponDetailsList` 提取为 JSONB
- 计算 `content_hash`,保留完整原始响应为 `payload`
实现方式:在 `ODS_GROUP_PACKAGE` 任务中覆盖 `_build_detail_process_fn`,或在 `OdsTaskSpec` 中扩展 `detail_process_fn` 回调。
### 3.4 详情数据写入(复用 _insert_records_schema_aware
`_build_detail_write_fn` 已内置调用 `_insert_records_schema_aware()`,按目标表结构动态写入,支持 ON CONFLICT UPSERT。写入目标为 `ods.group_buy_package_details`PK = `coupon_id`
### 3.5 DWD 加载扩展
文件:`apps/etl/connectors/feiqiu/tasks/dwd/dwd_load_task.py`
`_merge_dim_scd2()` 处理 `dim_groupbuy_package_ex` 时,需要额外从 `ods.group_buy_package_details` 读取详情字段并合并到 ODS 快照中:
```python
# 伪代码:在 DWD 加载 dim_groupbuy_package_ex 时
def _load_groupbuy_package_ex(self, cur, now):
# 1. 从 ods.group_buy_packages 读取列表数据(现有逻辑)
# 2. 从 ods.group_buy_package_details 读取详情数据
# 3. 通过 coupon_id = id 关联,将详情字段合并到 ODS 快照
# 4. 执行 SCD2 合并(现有 _merge_dim_scd2 逻辑)
```
新增字段映射(`ods.group_buy_package_details``dwd.dim_groupbuy_package_ex`
| ODS 详情字段 | DWD 扩展表字段 | 类型 |
|-------------|---------------|------|
| `table_area_ids` | `table_area_ids` | JSONB |
| `table_area_names` | `table_area_names` | JSONB |
| `assistant_services` | `assistant_services` | JSONB |
| `groupon_site_infos` | `groupon_site_infos` | JSONB |
## 4. 数据库变更
### 4.1 新建 ODS 表
```sql
-- db/etl_feiqiu/ods/group_buy_package_details.sql
CREATE TABLE IF NOT EXISTS ods.group_buy_package_details (
coupon_id BIGINT NOT NULL,
package_name TEXT,
duration INTEGER, -- 台费计时时长(秒)
start_time TIMESTAMPTZ, -- 可用日期开始
end_time TIMESTAMPTZ, -- 可用日期结束
add_start_clock TEXT, -- 可用时段开始
add_end_clock TEXT, -- 可用时段结束
is_enabled INTEGER,
is_delete INTEGER,
site_id BIGINT,
tenant_id BIGINT,
create_time TIMESTAMPTZ,
creator_name TEXT,
-- JSONB 数组字段
table_area_ids JSONB, -- [2791960001957765, ...]
table_area_names JSONB, -- ["A区", ...]
assistant_services JSONB, -- [{skillId, assistantLevel, assistantDuration}, ...]
groupon_site_infos JSONB, -- [{siteId, siteName}, ...]
package_services JSONB, -- 待调研,可能为空
coupon_details_list JSONB, -- 待调研,可能为空
-- ETL 元数据
content_hash TEXT,
payload JSONB, -- 完整原始响应
fetched_at TIMESTAMPTZ DEFAULT now(),
-- 主键
CONSTRAINT pk_group_buy_package_details PRIMARY KEY (coupon_id)
);
COMMENT ON TABLE ods.group_buy_package_details IS '团购套餐详情 ODSQueryPackageCouponInfo 原始数据';
```
### 4.2 DWD 扩展表 ALTER
```sql
-- db/etl_feiqiu/migrations/2026-03-05__add_detail_fields_to_dim_groupbuy_package_ex.sql
ALTER TABLE dwd.dim_groupbuy_package_ex
ADD COLUMN IF NOT EXISTS table_area_ids JSONB,
ADD COLUMN IF NOT EXISTS table_area_names JSONB,
ADD COLUMN IF NOT EXISTS assistant_services JSONB,
ADD COLUMN IF NOT EXISTS groupon_site_infos JSONB;
COMMENT ON COLUMN dwd.dim_groupbuy_package_ex.table_area_ids IS '可用台区 ID 列表(来自详情接口 tableAreaId';
COMMENT ON COLUMN dwd.dim_groupbuy_package_ex.table_area_names IS '可用台区名称列表(来自详情接口 tableAreaNameList';
COMMENT ON COLUMN dwd.dim_groupbuy_package_ex.assistant_services IS '助教服务关联(来自详情接口 packageCouponAssistants';
COMMENT ON COLUMN dwd.dim_groupbuy_package_ex.groupon_site_infos IS '关联门店信息(来自详情接口 grouponSiteInfos';
```
## 5. 线程模型详细设计
详情阶段复用 `UnifiedPipeline` 的三层并发架构,与列表阶段完全一致:
```
UnifiedPipeline #2detail_mode
│ 主线程_request_loop
│ ┌─────────────────────────────────────────┐
│ │ for req in _build_detail_requests(spec): │
│ │ if cancel_token.is_cancelled: break │
│ │ resp = api.post(req.endpoint, params) │
│ │ processing_queue.put((req, resp)) │
│ │ rate_limiter.wait(cancel_token.event) │
│ └─────────────────┬───────────────────────┘
│ │
│ processing_queue.put(SENTINEL × worker_count)
│ 等待所有 worker 完成
│ write_queue.put(SENTINEL)
│ 等待 writer 完成
├──▶ Worker Thread 1 ──┐
├──▶ Worker Thread 2 ──┤
│ │
│ processing_queue │
│ ┌─────────────┐ │
│ │ (req, resp) │───▶ detail_process_fn(resp)
│ │ (req, resp) │ → 提取字段、计算 content_hash
│ │ SENTINEL │ write_queue.put(records)
│ └─────────────┘ │
│ │
│ ▼
│ Write Thread
│ ┌──────────────────┐
│ │ write_queue │
│ │ batch=batch_size │──▶ _insert_records_schema_aware()
│ │ timeout=5s │ → UPSERT ods.group_buy_package_details
│ │ SENTINEL │
│ └──────────────────┘
PipelineResultdetail_success / detail_failure / detail_skipped
```
关键设计点(均由 `PipelineConfig` 统一控制,支持任务级覆盖):
- `processing_queue``queue.Queue(maxsize=queue_size)`,满时阻塞主线程(背压机制)
- `write_queue``queue.Queue(maxsize=queue_size * 2)`
- Worker 数量:`PipelineConfig.workers`(默认 2
- Writer 批量写入:累积 `batch_size` 条或超时 `batch_timeout` 秒后执行
- SENTINEL`None` 对象,用于通知线程退出
- 取消信号:主线程检查 `cancel_token.is_cancelled``RateLimiter.wait()` 轮询 `cancel_token.event`
## 6. 取消信号
详情阶段的 `UnifiedPipeline` 实例与列表阶段共享同一个 `CancellationToken`。取消信号的上游传递链Admin-web → Backend → Orchestration → CancellationToken属于 etl-unified-pipeline 的架构范畴,本 spec 仅关注详情阶段收到取消信号后的行为:
1. `_request_loop` 检测到 `cancel_token.is_cancelled`,停止发送新请求
2. `RateLimiter.wait()` 在 0.5s 轮询周期内检测到取消,立即返回 `False`
3. 主线程发送 SENTINEL 到处理队列,等待已入队数据处理完成
4. `PipelineResult.cancelled = True``BaseOdsTask` 据此设置任务状态
## 7. 错误处理策略
错误处理由 `UnifiedPipeline` 统一管理,各阶段行为如下:
| 场景 | 处理方式 |
|------|---------|
| 单个 couponId 请求超时/HTTP 错误 | `_request_loop` 捕获异常,`request_failures++`,继续下一个 |
| 单个 couponId 返回 `code != 0` | 同上API 层异常) |
| 连续失败超过阈值 | `_request_loop` 中断,`PipelineResult.status = "FAILED"` |
| Worker 线程处理异常 | `_process_worker` 捕获异常,`processing_failures++`,继续消费队列 |
| Writer 线程写入失败 | `_write_worker` 捕获异常,`write_failures++`,继续消费队列 |
| 取消信号到达 | 停止新请求,等待已入队数据处理完成,`cancelled = True` |
`BaseOdsTask.execute()` 在详情阶段完成后,将 `detail_result` 的统计信息合并到任务结果中,并记录每个失败项的错误日志。
连续失败阈值:`PipelineConfig.max_consecutive_failures`(默认 10支持 `pipeline.ods_group_package.max_consecutive_failures` 任务级覆盖)。
## 8. 配置参数
详情阶段复用 `PipelineConfig` 统一配置体系,支持三级回退:`pipeline.ods_group_package.<key>``pipeline.<key>` → 硬编码默认值。
| 配置键 | 默认值 | 说明 |
|--------|--------|------|
| `pipeline.workers` | 2 | 处理线程数 |
| `pipeline.queue_size` | 100 | 处理队列容量 |
| `pipeline.batch_size` | 100 | 写入批量阈值 |
| `pipeline.batch_timeout` | 5.0 | 写入等待超时(秒) |
| `pipeline.rate_min` | 5.0 | RateLimiter 最小间隔(秒) |
| `pipeline.rate_max` | 20.0 | RateLimiter 最大间隔(秒) |
| `pipeline.max_consecutive_failures` | 10 | 连续失败中断阈值 |
如需为详情阶段单独调参,可通过 `pipeline.ods_group_package.*` 任务级覆盖(列表和详情阶段共享同一 `PipelineConfig` 实例)。
> 不再需要独立的 `DETAIL_FETCH_*` 配置参数。
## 9. 实施任务清单
### Task 1新建 ODS 详情表 DDL
- 创建 `db/etl_feiqiu/ods/group_buy_package_details.sql`
- 执行 DDL 到测试库
- 需求覆盖:需求 3 验收标准 1-4
### Task 2扩展 ODS_GROUP_PACKAGE 任务 — 配置详情拉取
-`tasks/ods/ods_tasks.py``ODS_GROUP_PACKAGE` OdsTaskSpec 中添加 `detail_endpoint` 等配置
- 实现自定义的 `_build_detail_process_fn` 字段提取逻辑
- 实现自定义的 `_build_detail_write_fn` 写入逻辑
- 复用 `BaseOdsTask.execute()` 已有的详情拉取流程(`UnifiedPipeline` + `RateLimiter` + `CancellationToken`
- 需求覆盖:需求 1 验收标准 1-8需求 2 验收标准 1-6需求 5 验收标准 1-4
### Task 3DWD 扩展表 ALTER + 加载逻辑
- 执行 ALTER TABLE 到测试库
- 修改 DWD 加载逻辑,从详情 ODS 表读取并合并到扩展表
- 需求覆盖:需求 4 验收标准 1-5
### Task 4数据调研 — 获取全部团购详情并分析未标注字段
- 编写一次性脚本调用详情接口获取全部数据
- 分析未标注字段的值分布
- 确认 `packagePackageService``packageCouponDetailsList` 是否有数据
- 根据分析结果调整 ODS/DWD 字段定义
- 需求覆盖:需求 3 验收标准 6附录 B 调研 3、4
### Task 5文档同步更新
- 更新 ODS DDL 文档、字段映射文档
- 更新 BD Manual
- 更新 DWD 全景文档
- 更新 README 任务清单
- 需求覆盖:需求 6 验收标准 1-4

View File

@@ -0,0 +1,256 @@
# 需求文档:团购详情接口整合 ETL 数据流
## 简介
将飞球 SaaS 的团购详情接口(`QueryPackageCouponInfo`)整合到现有 ETL 的 API → ODS → DWD 三层数据流中。当前 ETL 仅拉取团购列表(`QueryPackageCouponList`),缺少每个团购套餐的详情数据(助教服务关联、可用台区列表、关联门店等)。本次改造在现有团购列表拉取任务完成后,串行遍历所有 `couponId` 调用详情接口,将详情数据落入 ODS 层并向下传导至 DWD 层,丰富团购维度表的分析能力。采用"串行请求 + 异步处理 + 单线程写库"架构在控制请求频率5-20 秒随机间隔)的同时最大化数据处理吞吐。
## 术语表
- **ETL_System**:飞球 Connector ETL 系统(`apps/etl/connectors/feiqiu/`),负责从飞球 SaaS API 拉取数据并加载到 PostgreSQL 的 ODS → DWD → DWS 各层
- **API_Client**ETL 系统中的 HTTP 客户端模块(`api/client.py`),封装 POST 请求、重试、分页逻辑
- **Rate_Limiter**:请求间隔控制器,在串行请求模式下控制相邻两次 API 请求之间的随机等待时间5-20 秒),防止触发上游风控
- **ODS_Group_Package_Task**:现有 ODS 层团购套餐拉取任务(任务代码 `ODS_GROUP_PACKAGE`),调用 `QueryPackageCouponList` 获取团购列表并写入 `ods.group_buy_packages`
- **Detail_Fetcher**:团购详情拉取子流程,在列表拉取完成后遍历所有 `couponId` 调用 `QueryPackageCouponInfo` 获取详情
- **ODS_Detail_Table**ODS 层团购详情表(`ods.group_buy_package_details`),存储详情接口返回的原始数据
- **DWD_Groupbuy_Package**DWD 层团购套餐维度主表(`dwd.dim_groupbuy_package`
- **DWD_Groupbuy_Package_Ex**DWD 层团购套餐维度扩展表(`dwd.dim_groupbuy_package_ex`
- **SCD2**:缓慢变化维度 Type 2通过版本号和生效/失效时间追踪维度历史变化
- **couponId**:团购套餐的唯一标识 ID对应 `ods.group_buy_packages.id` 字段
## 需求
### 需求 1DetailFetcher 串行请求与异步处理
> RateLimiter 和 CancellationToken 已在 etl-unified-pipeline 中实现并上线,本 spec 直接复用。
**用户故事:** 作为 ETL 运维人员,我希望团购详情拉取采用"串行请求 + 异步处理 + 单线程写库"的架构,复用现有限流和取消机制,在控制请求频率的同时最大化数据处理吞吐。
#### 验收标准
1. THE Detail_Fetcher SHALL 采用严格串行请求模式:等待上一个 API 请求的响应完整返回后,再等待 5 至 20 秒之间的随机间隔(均匀分布),然后发送下一个请求
2. THE Detail_Fetcher SHALL 在每个请求的响应返回后,立即将响应数据提交到异步处理队列,不阻塞下一次请求的等待计时
3. THE Detail_Fetcher SHALL 支持多个工作线程并行消费处理队列中的响应数据字段提取、数据清洗、content_hash 计算等)
4. THE Detail_Fetcher SHALL 使用单独的写入线程汇总所有处理完成的结果,统一执行数据库写入操作,避免并发写入冲突
5. THE Detail_Fetcher SHALL 复用现有 RateLimiter`api/rate_limiter.py`)控制请求间隔,通过构造参数配置最小/最大间隔
6. WHEN 所有请求发送完毕后THE Detail_Fetcher SHALL 等待处理队列和写入线程全部完成后再返回最终结果
7. THE Detail_Fetcher SHALL 通过 CancellationToken`utils/cancellation.py`)支持外部取消信号,收到信号后立即停止发送新请求、等待当前已提交到处理队列的数据处理完成并写入数据库、然后返回部分完成的统计结果
8. WHEN 取消信号到达时THE Detail_Fetcher SHALL 在当前请求的等待间隔期或响应等待期中断,不再发起后续请求,已进入处理队列的数据不丢弃、正常完成处理和写入
### 需求 2团购详情 API 拉取
**用户故事:** 作为数据分析师,我希望 ETL 系统能拉取每个团购套餐的详情数据(助教服务关联、可用台区、关联门店等),以便进行团购套餐的深度分析。
#### 验收标准
1. WHEN ODS_Group_Package_Task 完成团购列表拉取后THE Detail_Fetcher SHALL 遍历所有已拉取的 `couponId`(包含 `is_enabled=0``is_delete=1` 的记录)调用 `QueryPackageCouponInfo` 接口
2. THE Detail_Fetcher SHALL 向 `https://pc.ficoo.vip/apiprod/admin/v1/PackageCoupon/QueryPackageCouponInfo` 发送 POST 请求,请求体为 `{"couponId": <id>}`
3. THE Detail_Fetcher SHALL 严格串行发送请求,等待上一个请求响应返回后,经过 5-20 秒随机间隔再发送下一个请求
4. WHEN 详情接口返回成功响应时THE Detail_Fetcher SHALL 将响应数据提交到异步处理队列,由工作线程提取 `data` 层级下的 `groupPurchasePackage``tableAreaNameList``packageCouponAssistants``grouponSiteInfos``packagePackageService``packageCouponDetailsList` 等字段
5. IF 详情接口对某个 `couponId` 返回错误或超时THEN THE Detail_Fetcher SHALL 记录错误日志(含 `couponId` 和错误信息)并继续处理下一个 `couponId`
6. WHEN 所有 `couponId` 遍历完成后THE Detail_Fetcher SHALL 等待异步处理和写入线程全部完成,然后返回拉取统计信息(成功数、失败数、跳过数)
### 需求 3ODS 层团购详情数据存储
**用户故事:** 作为数据工程师,我希望团购详情数据以结构化方式存储在 ODS 层,以便下游 DWD 层消费。
#### 验收标准
1. THE ETL_System SHALL 将团购详情数据存储到 ODS 层,具体方案(独立新表 `ods.group_buy_package_details` 还是在现有 `ods.group_buy_packages` 中扩展字段)在技术设计阶段通过实际数据调研后确定(见附录 B待调研项
2. THE ODS_Detail_Table SHALL 包含以下结构化字段:`coupon_id`BIGINT, PK`package_name`TEXT`duration`INT`start_time`TIMESTAMPTZ`end_time`TIMESTAMPTZ`add_start_clock`TEXT`add_end_clock`TEXT`is_enabled`INT`is_delete`INT`site_id`BIGINT`tenant_id`BIGINT`create_time`TIMESTAMPTZ`creator_name`TEXT
3. THE ODS_Detail_Table SHALL 将数组类型字段以 JSONB 格式存储:`table_area_ids`(台区 ID 列表)、`table_area_names`(台区名称列表)、`assistant_services`(助教服务关联数组,含 `skillId``assistantLevel``assistantDuration`)、`groupon_site_infos`(关联门店数组)、`package_services`(套餐服务数组)、`coupon_details_list`(券明细数组)
4. THE ODS_Detail_Table SHALL 包含 ETL 元数据字段:`content_hash`TEXT`payload`JSONB完整原始响应`fetched_at`TIMESTAMPTZ
5. THE ETL_System SHALL 采用全量快照模式(`SnapshotMode.FULL_TABLE`)写入 ODS_Detail_Table每次运行覆盖全部记录
6. WHEN 详情接口返回的字段中存在未标注字段且所有记录的值均相同时THE ETL_System SHALL 忽略该字段不写入 ODS 表
### 需求 4DWD 层团购维度表扩展
**用户故事:** 作为数据分析师,我希望 DWD 层的团购维度表能包含详情数据中的关键字段,以便在结算分析和财务审计中使用团购套餐的完整信息。
#### 验收标准
1. THE ETL_System SHALL 在 DWD 层扩展团购维度数据,具体方案(在现有 `dwd.dim_groupbuy_package_ex` 扩展表中新增字段,还是新建独立详情维度表)在技术设计阶段通过实际数据调研后确定(见附录 B待调研项
2. THE ETL_System SHALL 在 DWD 加载任务中建立 ODS 详情数据到 DWD 团购维度表的字段映射,通过 `coupon_id` = `groupbuy_package_id` 关联
3. WHEN ODS 详情表中存在 `assistant_services` 数据时THE DWD_Groupbuy_Package_Ex SHALL 保留完整的助教服务关联信息(`skillId``assistantLevel``assistantDuration``assistantDuration` 单位为秒
4. THE ETL_System SHALL 通过 SCD2 机制追踪团购详情字段的历史变化,与现有 DWD_Groupbuy_Package_Ex 的 SCD2 版本保持同步
5. WHEN `ods.group_buy_package_details` 中某个 `coupon_id``ods.group_buy_packages` 中不存在时THE ETL_System SHALL 记录警告日志并跳过该记录的 DWD 加载
### 需求 5任务编排与依赖管理
**用户故事:** 作为 ETL 运维人员,我希望团购详情拉取任务能正确嵌入现有调度流程,不影响其他任务的执行顺序。
#### 验收标准
1. THE Detail_Fetcher SHALL 作为 ODS_Group_Package_Task 的内部子流程执行,在列表数据写入 ODS 完成后串行启动,不注册为独立的调度任务
2. WHEN ODS_Group_Package_Task 执行时THE ETL_System SHALL 先完成 `QueryPackageCouponList` 的全量拉取和 ODS 写入,再启动 Detail_Fetcher 遍历详情
3. THE ODS_Group_Package_Task SHALL 在执行结果中包含详情拉取的统计信息(详情成功数、详情失败数),与列表拉取统计合并返回
4. IF Detail_Fetcher 执行过程中发生不可恢复的错误如网络完全不可达THEN THE ODS_Group_Package_Task SHALL 将任务状态标记为部分成功,并在结果中记录已完成的列表拉取数据和详情拉取的中断点
### 需求 6文档同步更新
**用户故事:** 作为开发者,我希望所有相关文档在功能交付时同步更新,以保持文档与代码的一致性。
#### 验收标准
1. WHEN 团购详情 ETL 功能开发完成后THE ETL_System 的文档 SHALL 更新以下内容ODS 层 DDL 文档(新增 `ods.group_buy_package_details` 表定义、ODS 字段映射文档(新增 `QueryPackageCouponInfo` 映射、数据库手册BD Manual 中新增详情表说明)
2. WHEN DWD 层扩展字段添加完成后THE ETL_System 的文档 SHALL 更新DWD 全景文档(`docs/reports/dwd-panorama/dwd-dimension-panorama.md` 中 dim_groupbuy_package_ex 章节、DWD 表结构概览文档
3. THE ETL_System 的 README 文档 SHALL 更新任务清单,反映 ODS_GROUP_PACKAGE 任务新增的详情拉取子流程
4. WHEN 全局限流机制实现后THE ETL_System 的架构文档 SHALL 新增限流机制的说明,包含配置参数和使用方式
---
## 附录 AAPI 请求与响应示例
### 请求
```
POST https://pc.ficoo.vip/apiprod/admin/v1/PackageCoupon/QueryPackageCouponInfo
Content-Type: application/json
Authorization: Bearer <token>
{"couponId": 3030873437310021}
```
### 响应
```json
{
"data": {
"groupPurchasePackage": {
"count": 1,
"tableAreaId": [2791960001957765],
"tableAreaNameList": ["A区"],
"id": 3030873437310021,
"add_end_clock": "1.00:00:00",
"add_start_clock": "00:00:00",
"area_tag_type": 1,
"card_type_ids": "0",
"coupon_money": 0.00,
"create_time": "2025-12-31 12:23:56",
"creator_name": "店长:郑丽珊",
"date_info": "",
"date_type": 1,
"duration": 7200,
"end_clock": "1.00:00:00",
"end_time": "2027-01-01 00:00:00",
"group_type": 1,
"is_delete": 0,
"is_enabled": 1,
"is_first_limit": 1,
"max_selectable_categories": 0,
"package_id": 1173128804,
"package_name": "助理教练竞技教学两小时",
"selling_price": 0.00,
"site_id": 2790685415443269,
"sort": 100,
"start_clock": "00:00:00",
"start_time": "2025-07-21 00:00:00",
"system_group_type": 1,
"table_area_id": "0",
"table_area_id_list": "",
"table_area_name": "",
"tenant_id": 2790683160709957,
"tenant_table_area_id": "0",
"tenant_table_area_id_list": "",
"type": 1,
"usable_count": 0,
"usable_range": ""
},
"couponCode": "",
"grouponSiteInfos": [
{
"siteId": 2790685415443269,
"siteName": "朗朗桌球"
}
],
"packageCouponAssistants": [
{
"couponAssistantId": 3030873437310023,
"skillId": 0,
"assistantLevel": "",
"assistantDuration": 7200
}
],
"packagePackageService": [],
"packageCouponDetailsList": []
},
"code": 0
}
```
### 字段标注
| 字段路径 | 含义 | 已标注 |
|----------|------|--------|
| `groupPurchasePackage.id` | 团购 ID= couponId | ✅ |
| `groupPurchasePackage.package_name` | 团购名称 | ✅ |
| `groupPurchasePackage.duration` | 台费计时时长(秒) | ✅ |
| `groupPurchasePackage.start_time` / `end_time` | 可用日期范围 | ✅ |
| `groupPurchasePackage.add_start_clock` / `add_end_clock` | 可用时段范围 | ✅ |
| `groupPurchasePackage.is_enabled` | 是否启用 | ✅ |
| `groupPurchasePackage.is_delete` | 是否已删除 | ✅ |
| `groupPurchasePackage.site_id` | 店铺 ID | ✅ |
| `groupPurchasePackage.tenant_id` | 租户 ID | ✅ |
| `groupPurchasePackage.create_time` | 创建时间 | ✅ |
| `groupPurchasePackage.creator_name` | 创建人 | ✅ |
| `groupPurchasePackage.tableAreaId` / `tableAreaNameList` | 可用台桌区域 | ✅ |
| `packageCouponAssistants[].skillId` | 可用课程 ID | ✅ |
| `packageCouponAssistants[].assistantLevel` | 可用助教等级 | ✅ |
| `packageCouponAssistants[].assistantDuration` | 助教时长(秒) | ✅ |
| `grouponSiteInfos[].siteId` / `siteName` | 关联门店 | ✅ |
| `groupPurchasePackage.count` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.area_tag_type` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.card_type_ids` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.coupon_money` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.date_info` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.date_type` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.end_clock` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.group_type` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.is_first_limit` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.max_selectable_categories` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.package_id` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.selling_price` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.sort` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.start_clock` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.system_group_type` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.table_area_id` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.table_area_id_list` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.table_area_name` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.tenant_table_area_id` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.tenant_table_area_id_list` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.type` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.usable_count` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.usable_range` | 未标注 — 待调研 | ❌ |
| `couponCode` | 未标注 — 待调研 | ❌ |
| `packageCouponAssistants[].couponAssistantId` | 未标注 — 待调研 | ❌ |
| `packagePackageService` | 示例为空 — 待调研实际数据 | ❌ |
| `packageCouponDetailsList` | 示例为空 — 待调研实际数据 | ❌ |
---
## 附录 B待调研项技术设计阶段完成
### 调研 1ODS 层表方案
- 选项 A新建独立表 `ods.group_buy_package_details`
- 选项 B在现有 `ods.group_buy_packages` 中扩展字段
- 决策依据:详情数据与列表数据的字段重叠度、数据量差异、写入模式兼容性
- 需要调研现有 `ods.group_buy_packages` 的表结构和写入逻辑
### 调研 2DWD 层表方案
- 选项 A在现有 `dwd.dim_groupbuy_package_ex` 扩展表中新增 JSONB 字段
- 选项 B新建独立的团购详情维度表
- 决策依据现有扩展表的字段密度、SCD2 版本同步复杂度、下游查询模式
- 需要调研现有 DWD 团购相关表结构(参考 `docs/reports/dwd-panorama/`
### 调研 3未标注字段用途
- 获取全部团购的详情数据后,对附录 A 中标记为 ❌ 的字段进行值分布分析
- 所有记录值完全相同的字段 → 忽略,不写入 ODS
- 值有变化的字段 → 推测用途,决定是否纳入 ODS/DWD 字段映射
### 调研 4空数组字段实际数据
- `packagePackageService`:获取全部团购后确认是否有非空数据
- `packageCouponDetailsList`:同上
- 如有数据,需补充 ODS 字段定义和 DWD 映射

View File

@@ -0,0 +1,110 @@
# 实施计划:团购详情接口整合 ETL 数据流
## 概述
将飞球团购详情接口(`QueryPackageCouponInfo`)整合到现有 ETL 的 API → ODS → DWD 三层数据流。利用 `BaseOdsTask` 已有的 `detail_endpoint` 二级详情拉取模式(通过 `UnifiedPipeline` + `RateLimiter` + `CancellationToken`),在 `ODS_GROUP_PACKAGE` 任务的 `OdsTaskSpec` 中配置详情拉取参数,将详情数据写入新建的 `ods.group_buy_package_details` 表,并向下传导至 `dwd.dim_groupbuy_package_ex`
## 任务
- [x] 1. 新建 ODS 详情表 DDL
- [x] 1.1 创建 `db/etl_feiqiu/ods/group_buy_package_details.sql`
- 按设计文档 §4.1 定义表结构:`coupon_id` BIGINT PK、结构化字段、JSONB 数组字段、ETL 元数据字段
- 添加表和列的 COMMENT
- _需求覆盖需求 3 验收标准 1-4_
- [x] 1.2 在测试库 `test_etl_feiqiu` 执行 DDL 验证表创建成功
- _需求覆盖需求 3 验收标准 1_
- [x] 2. 扩展 ODS_GROUP_PACKAGE 任务 — 配置详情拉取
- [x] 2.1 在 `tasks/ods/ods_tasks.py``ODS_GROUP_PACKAGE` OdsTaskSpec 中添加 detail_endpoint 配置
- 设置 `detail_endpoint="/PackageCoupon/QueryPackageCouponInfo"`
- 设置 `detail_param_builder=lambda rec: {"couponId": rec["id"]}`(将列表表的 `id` 映射为详情接口的 `couponId` 参数)
- 设置 `detail_target_table="ods.group_buy_package_details"`
- 设置 `detail_data_path=("data",)`
- 设置 `detail_id_column="id"`(从 `ods.group_buy_packages` 提取 couponId 列表)
- 复用 `BaseOdsTask.execute()` 已有的详情拉取流程(`UnifiedPipeline` + `RateLimiter` + `CancellationToken`
- _需求覆盖需求 1 验收标准 1-8需求 2 验收标准 1-6需求 5 验收标准 1-4_
- [x] 2.2 实现详情响应的 `_build_detail_process_fn` 字段提取逻辑
-`data.groupPurchasePackage` 提取结构化字段(`package_name``duration``start_time``end_time``add_start_clock``add_end_clock``is_enabled``is_delete``site_id``tenant_id``create_time``creator_name`
-`data.groupPurchasePackage.tableAreaId` / `tableAreaNameList` 提取台区数组为 JSONB
-`data.packageCouponAssistants` 提取助教服务关联数组为 JSONB
-`data.grouponSiteInfos` 提取关联门店数组为 JSONB
-`data.packagePackageService``data.packageCouponDetailsList` 提取为 JSONB
- 计算 `content_hash`,保留完整原始响应为 `payload`
- _需求覆盖需求 2 验收标准 4需求 3 验收标准 2-4_
- [x] 2.3 实现详情数据的 `_build_detail_write_fn` 写入逻辑
- 采用全量快照模式(`SnapshotMode.FULL_TABLE`)写入 `ods.group_buy_package_details`
- UPSERT on `coupon_id`,每次运行覆盖全部记录
- _需求覆盖需求 3 验收标准 5_
- [x] 2.4 编写 ODS 详情拉取的单元测试
- 测试 `detail_param_builder` 参数构造
- 测试字段提取逻辑(正常响应、空数组、缺失字段)
- 测试 `content_hash` 计算一致性
- _需求覆盖需求 2 验收标准 4-5_
- [x] 3. 检查点 — ODS 层验证
- 确保所有测试通过ask the user if questions arise。
-`--dry-run --tasks ODS_GROUP_PACKAGE` 验证任务注册和配置正确
- [x] 4. DWD 扩展表 ALTER + 加载逻辑
- [x] 4.1 创建迁移脚本 `db/etl_feiqiu/migrations/2026-03-05__add_detail_fields_to_dim_groupbuy_package_ex.sql`
- ALTER TABLE 添加 4 个 JSONB 列:`table_area_ids``table_area_names``assistant_services``groupon_site_infos`
- 添加列 COMMENT
- 在测试库 `test_etl_feiqiu` 执行迁移验证
- _需求覆盖需求 4 验收标准 1_
- [x] 4.2 修改 `tasks/dwd/dwd_load_task.py``TABLE_MAPPING``COLUMN_OVERRIDES`
-`COLUMN_OVERRIDES["dwd.dim_groupbuy_package_ex"]` 中新增 4 个详情字段的映射
- _需求覆盖需求 4 验收标准 2_
- [x] 4.3 修改 `_fetch_source_rows``_merge_dim_scd2` 流程,在加载 `dim_groupbuy_package_ex` 时 LEFT JOIN `ods.group_buy_package_details`
- 通过 `ods.group_buy_packages.id = ods.group_buy_package_details.coupon_id` 关联
- 将详情表的 `table_area_ids``table_area_names``assistant_services``groupon_site_infos` 合并到 ODS 快照行
- 当详情表中 `coupon_id` 在列表表中不存在时,记录警告日志并跳过
- 新增字段自动纳入 SCD2 变更检测(现有 `_is_row_changed` 逻辑已支持 JSONB 比较)
- _需求覆盖需求 4 验收标准 2-5_
- [x] 4.4 编写 DWD 加载扩展的单元测试
- 测试 LEFT JOIN 逻辑:详情存在 / 详情缺失 / 详情多余(应跳过并警告)
- 测试 SCD2 变更检测包含新增 JSONB 字段
- _需求覆盖需求 4 验收标准 3-5_
- [x] 5. 检查点 — DWD 层验证
- 确保所有测试通过ask the user if questions arise。
-`--dry-run --tasks DWD_LOAD_FROM_ODS` 验证 DWD 加载配置正确
- [x] 6. 数据调研 — 获取全部团购详情并分析未标注字段
- [x] 6.1 编写一次性调研脚本 `apps/etl/connectors/feiqiu/scripts/research_coupon_details.py`
- 使用 `load_dotenv` 加载根 `.env`,通过 `AppConfig.load()` 获取配置
- 连接测试库 `test_etl_feiqiu``TEST_DB_DSN`
-`ods.group_buy_packages` 读取所有 `coupon_id`
- 串行调用详情接口(复用 `RateLimiter(5, 20)`),将原始响应存入 `ods.group_buy_package_details.payload`
- _需求覆盖附录 B 调研 3、4_
- [x] 6.2 分析未标注字段的值分布
- 对附录 A 中标记为 ❌ 的字段,统计所有记录的值分布
- 所有记录值完全相同的字段 → 标记为忽略
- 值有变化的字段 → 推测用途,输出分析报告到 `docs/reports/`
- 确认 `packagePackageService``packageCouponDetailsList` 是否有非空数据
- 根据分析结果调整 ODS 表字段定义和 DWD 映射(如需要)
- _需求覆盖需求 3 验收标准 6附录 B 调研 3、4_
- [x] 7. 文档同步更新
- [x] 7.1 更新 ODS 层文档
- 更新 ODS DDL 文档(新增 `ods.group_buy_package_details` 表定义)
- 更新 ODS 字段映射文档(新增 `QueryPackageCouponInfo``ods.group_buy_package_details` 映射)
- 更新 BD Manual`docs/database/BD_Manual_*.md`)新增详情表说明
- _需求覆盖需求 6 验收标准 1_
- [x] 7.2 更新 DWD 层文档
- 更新 DWD 全景文档(`docs/reports/dwd-panorama/dwd-dimension-panorama.md``dim_groupbuy_package_ex` 章节)
- 更新 DWD 表结构概览文档,反映新增的 4 个 JSONB 字段
- _需求覆盖需求 6 验收标准 2_
- [x] 7.3 更新 ETL README 和架构文档
- 更新 README 任务清单,反映 `ODS_GROUP_PACKAGE` 任务新增的详情拉取子流程
- _需求覆盖需求 6 验收标准 3_
- [x] 8. 最终检查点 — 全量验证
- 37 个单元测试全部通过ODS 16 + DWD 12 + column ref 9
## 备注
- 标记 `*` 的子任务为可选,可跳过以加速 MVP 交付
- `RateLimiter``api/rate_limiter.py`)和 `CancellationToken``utils/cancellation.py`)已在 etl-unified-pipeline 中实现,本 spec 直接复用,不重复创建
- `BaseOdsTask.execute()` 已内置 `detail_endpoint` 二级详情拉取模式(通过 `UnifiedPipeline`Task 2 利用此现有机制而非新建独立的 `DetailFetcher`
- 每个任务引用具体需求条款以确保可追溯性
- 检查点确保增量验证,避免问题累积

View File

@@ -124,6 +124,7 @@ class BaseDwsTask(BaseTask):
- `_do_extract()` 而非直接修改 `extract()` 签名,是为了保持向后兼容——已覆盖 `extract()` 的子类无需改动。
- `DATE_COL = None` 作为哨兵值,未声明时 load() 回退到 `"stat_date"` 默认值。
- 子类迁移是渐进式的:先在基类添加默认实现,再逐个子类迁移。
- **营业日切点**:所有 `stat_date` / `stat_month` 等日期列的值为营业日,以 `BUSINESS_DAY_START_HOUR`(默认 08:00为分割点。08:00 前的记录归属前一天/前一月。月统计 = 当月1日 08:00 ~ 次月1日 08:00周统计 = 周一 08:00 ~ 次周一 08:00。
### 组件 2dws_helpers.py 公共辅助模块

View File

@@ -22,7 +22,7 @@
- **Flow**ETL 编排单元,定义一组按层顺序执行的任务集合(原名 pipeline
- **Layer**ETL 数据处理层级,包括 ODS、DWD、DWS、INDEX
- **Connector**ETL 连接器,对接特定上游 SaaS 的数据抽取模块(原名 pipeline 目录)
- **DATE_COL**DWS 子类声明的日期列名,用于 extract 和 delete_existing_data 的时间过滤
- **DATE_COL**DWS 子类声明的日期列名,用于 extract 和 delete_existing_data 的时间过滤。日期值为营业日(以 `BUSINESS_DAY_START_HOUR`(默认 08:00为日切点
- **TaskContext**:运行期上下文数据类,包含 store_id、window_start/end、window_minutes、cursor
- **拓扑排序**:根据任务间依赖关系确定执行顺序的算法,确保被依赖任务先于依赖方执行
- **幂等**:同一操作执行多次与执行一次效果相同,本系统通过 delete-before-insert 实现

View File

@@ -0,0 +1 @@
{"specId": "a277a91a-b35c-4d48-b4a2-09df0e47b71b", "workflowType": "requirements-first", "specType": "feature"}

View File

@@ -0,0 +1,834 @@
# 技术设计ETL 统一请求编排与线程模型改造
> 对应需求文档:[requirements.md](./requirements.md)
## 1. 架构概览
本次改造将现有 21 个 ODS 任务从"同步串行执行"迁移到统一的"串行请求 + 异步处理 + 单线程写库"管道架构。核心设计原则:
- **请求串行化**:所有 API 请求通过全局 `RequestScheduler` 排队,严格一个接一个发送,遵循 `RateLimiter` 限流
- **处理并行化**API 响应提交到 `ProcessingPool` 多线程处理字段提取、hash 计算等),不阻塞请求线程
- **写入串行化**:所有数据库写入由单个 `WriteWorker` 线程执行,避免并发写入冲突
- **配置灵活化**:通过 `PipelineConfig` 支持全局默认 + 任务级覆盖
- **可取消**:通过 `CancellationToken` 支持外部取消信号,优雅中断
```
┌─────────────────────────────────────────────────────────────────┐
│ FlowRunner │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Unified_Pipeline │ │
│ │ │ │
│ │ ┌─────────────┐ ┌──────────────┐ ┌─────────────┐ │ │
│ │ │ Request │───▶│ Processing │───▶│ Write │ │ │
│ │ │ Scheduler │ │ Pool │ │ Worker │ │ │
│ │ │ (串行请求) │ │ (N 工作线程) │ │ (单线程) │ │ │
│ │ └──────┬───────┘ └──────────────┘ └─────────────┘ │ │
│ │ │ │ │
│ │ ┌──────▼───────┐ ┌──────────────┐ │ │
│ │ │ Rate │ │ Cancellation │ │ │
│ │ │ Limiter │ │ Token │ │ │
│ │ │ (5-20s) │ │ (外部取消) │ │ │
│ │ └──────────────┘ └──────────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ DWD_Loader (多线程 SCD2 调度) │ │
│ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ │
│ │ │ Table1 │ │ Table2 │ │ Table3 │ │ Table4 │ ... │ │
│ │ │ SCD2 │ │ SCD2 │ │ SCD2 │ │ SCD2 │ │ │
│ │ └────────┘ └────────┘ └────────┘ └────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
### 1.1 设计决策与理由
| 决策 | 选项 | 理由 |
|------|------|------|
| 请求串行 vs 并行 | 串行 | 上游飞球 API 无并发友好设计,并行请求易触发风控;串行 + 限流是最安全的策略 |
| 处理线程数 | 默认 2 | ODS 数据处理是轻量 CPU 操作JSON 解析、hash 计算2 线程足够消化请求间隔产生的积压 |
| 写入单线程 | 单线程 | PostgreSQL 单连接写入避免锁竞争和事务冲突,简化错误处理和回滚逻辑 |
| Pipeline 嵌入 vs 独立 | 嵌入 BaseOdsTask | Pipeline 作为 BaseOdsTask 内部执行引擎对外接口TaskExecutor、FlowRunner完全不变 |
| DWD 多线程 | 调度层并行 | 仅在调度层并行调用 `_merge_dim_scd2()`,方法本身不改,每张表独立事务 |
## 2. 架构
### 2.1 整体数据流
```mermaid
graph TD
A[FlowRunner.run] --> B[TaskExecutor.run_tasks]
B --> C{ODS 任务}
C --> D[BaseOdsTask.execute]
D --> E[UnifiedPipeline.run]
E --> F[RequestScheduler<br/>串行请求 + RateLimiter]
F --> G[ProcessingPool<br/>多线程处理]
G --> H[WriteWorker<br/>单线程写库]
H --> I[ODS 表写入完成]
I --> J{有 Detail_Mode?}
J -->|是| K[DetailFetcher<br/>二级详情拉取]
K --> F
J -->|否| L[返回结果]
K --> L
L --> M[DWD_Loader]
M --> N[多线程 SCD2 调度]
N --> O[DWD 表写入完成]
```
### 2.2 线程模型详细设计
```
主线程RequestScheduler
│ for request in request_queue:
│ if cancel_token.is_cancelled: break
│ resp = api_client.post(endpoint, params)
│ processing_queue.put((request_id, resp))
│ rate_limiter.wait(cancel_token.event)
│ processing_queue.put(SENTINEL × worker_count)
│ 等待所有 worker 完成
│ write_queue.put(SENTINEL)
│ 等待 writer 完成
├──▶ Worker Thread 1 ──┐
├──▶ Worker Thread 2 ──┤
│ │
│ processing_queue │
│ ┌─────────────┐ │
│ │ (id, resp) │───▶ 字段提取 / content_hash 计算
│ │ (id, resp) │ write_queue.put(processed_rows)
│ │ SENTINEL │
│ └─────────────┘ │
│ │
│ ▼
│ Write Thread (单线程)
│ ┌─────────────┐
│ │ write_queue │
│ │ batch=100 │──▶ UPSERT / INSERT
│ │ timeout=5s │
│ │ SENTINEL │
│ └─────────────┘
PipelineResult统计信息
```
关键设计点:
- `processing_queue``queue.Queue(maxsize=queue_size)`,默认 100满时 `RequestScheduler` 阻塞(背压机制)
- `write_queue``queue.Queue(maxsize=queue_size * 2)`,默认 200
- SENTINEL`None` 对象,通知线程退出
- 取消信号:主线程检查 `cancel_token`worker/writer 通过 SENTINEL 正常退出
- 批量写入:累积到 `batch_size`(默认 100或等待 `batch_timeout`(默认 5 秒)后执行一次
### 2.3 取消信号传递链
```
外部触发Admin-web / CLI / 超时)
│ cancel_token.cancel()
RequestScheduler
│ rate_limiter.wait() 提前返回 False
│ 主循环 break不再发新请求
ProcessingPool
│ 通过 SENTINEL 正常退出
│ 已入队数据全部处理完成
WriteWorker
│ 通过 SENTINEL 正常退出
│ 已处理数据全部写入 DB
返回 PipelineResult(cancelled=True, ...)
```
## 3. 组件与接口
### 3.1 PipelineConfig配置数据类
文件:`apps/etl/connectors/feiqiu/config/pipeline_config.py`
```python
@dataclass(frozen=True)
class PipelineConfig:
"""统一管道配置,支持全局默认 + 任务级覆盖。"""
workers: int = 2 # ProcessingPool 工作线程数
queue_size: int = 100 # 处理队列容量
batch_size: int = 100 # WriteWorker 批量写入阈值
batch_timeout: float = 5.0 # WriteWorker 等待超时(秒)
rate_min: float = 5.0 # RateLimiter 最小间隔(秒)
rate_max: float = 20.0 # RateLimiter 最大间隔(秒)
max_consecutive_failures: int = 10 # 连续失败中断阈值
def __post_init__(self):
if self.workers < 1:
raise ValueError(f"workers 必须 >= 1当前值: {self.workers}")
if self.queue_size < 1:
raise ValueError(f"queue_size 必须 >= 1当前值: {self.queue_size}")
if self.batch_size < 1:
raise ValueError(f"batch_size 必须 >= 1当前值: {self.batch_size}")
if self.rate_min > self.rate_max:
raise ValueError(
f"rate_min({self.rate_min}) 不能大于 rate_max({self.rate_max})"
)
@classmethod
def from_app_config(cls, config: AppConfig, task_code: str | None = None) -> "PipelineConfig":
"""从 AppConfig 加载,支持 pipeline.<task_code>.* 任务级覆盖。"""
def _get(key: str, default):
# 优先任务级 → 全局级 → 默认值
if task_code:
val = config.get(f"pipeline.{task_code.lower()}.{key}")
if val is not None:
return type(default)(val)
val = config.get(f"pipeline.{key}")
if val is not None:
return type(default)(val)
return default
return cls(
workers=_get("workers", 2),
queue_size=_get("queue_size", 100),
batch_size=_get("batch_size", 100),
batch_timeout=_get("batch_timeout", 5.0),
rate_min=_get("rate_min", 5.0),
rate_max=_get("rate_max", 20.0),
max_consecutive_failures=_get("max_consecutive_failures", 10),
)
```
### 3.2 CancellationToken取消令牌
文件:`apps/etl/connectors/feiqiu/utils/cancellation.py`
```python
class CancellationToken:
"""线程安全的取消令牌,封装 threading.Event。"""
def __init__(self, timeout: float | None = None):
self._event = threading.Event()
self._timer: threading.Timer | None = None
if timeout is not None and timeout > 0:
self._timer = threading.Timer(timeout, self.cancel)
self._timer.daemon = True
self._timer.start()
def cancel(self):
"""发出取消信号。"""
self._event.set()
@property
def is_cancelled(self) -> bool:
return self._event.is_set()
@property
def event(self) -> threading.Event:
return self._event
def dispose(self):
"""清理超时定时器。"""
if self._timer is not None:
self._timer.cancel()
self._timer = None
```
### 3.3 RateLimiter限流器
文件:`apps/etl/connectors/feiqiu/api/rate_limiter.py`
```python
class RateLimiter:
"""请求间隔控制器,支持取消信号中断等待。"""
def __init__(self, min_interval: float = 5.0, max_interval: float = 20.0):
if min_interval > max_interval:
raise ValueError(
f"min_interval({min_interval}) 不能大于 max_interval({max_interval})"
)
self._min = min_interval
self._max = max_interval
self._last_interval: float = 0.0
def wait(self, cancel_event: threading.Event | None = None) -> bool:
"""等待随机间隔。返回 False 表示被取消信号中断。
将等待时间拆分为 0.5s 小段,每段检查 cancel_event。"""
interval = random.uniform(self._min, self._max)
self._last_interval = interval
remaining = interval
while remaining > 0:
if cancel_event and cancel_event.is_set():
return False
sleep_time = min(0.5, remaining)
time.sleep(sleep_time)
remaining -= sleep_time
return True
@property
def last_interval(self) -> float:
return self._last_interval
```
### 3.4 UnifiedPipeline统一管道引擎
文件:`apps/etl/connectors/feiqiu/pipeline/unified_pipeline.py`
这是核心组件,封装"串行请求 + 异步处理 + 单线程写库"的完整执行引擎。
```python
@dataclass
class PipelineResult:
"""管道执行结果统计。"""
status: str = "SUCCESS" # SUCCESS / PARTIAL / CANCELLED / FAILED
total_requests: int = 0
completed_requests: int = 0
total_fetched: int = 0
total_inserted: int = 0
total_updated: int = 0
total_skipped: int = 0
request_failures: int = 0
processing_failures: int = 0
write_failures: int = 0
cancelled: bool = False
errors: list[dict] = field(default_factory=list)
timing: dict[str, float] = field(default_factory=dict) # 各阶段耗时
class UnifiedPipeline:
"""统一管道引擎:串行请求 + 异步处理 + 单线程写库。"""
def __init__(
self,
api_client: APIClient,
db_connection,
logger: logging.Logger,
config: PipelineConfig,
cancel_token: CancellationToken | None = None,
):
self.api = api_client
self.db = db_connection
self.logger = logger
self.config = config
self.cancel_token = cancel_token or CancellationToken()
self._rate_limiter = RateLimiter(config.rate_min, config.rate_max)
def run(
self,
requests: Iterable[PipelineRequest],
process_fn: Callable[[Any], list[dict]],
write_fn: Callable[[list[dict]], WriteResult],
) -> PipelineResult:
"""执行管道。
Args:
requests: 请求迭代器(由 BaseOdsTask 生成,包含 endpoint、params 等)
process_fn: 处理函数,将 API 响应转换为待写入记录列表
write_fn: 写入函数,将记录批量写入数据库
"""
if self.cancel_token.is_cancelled:
return PipelineResult(status="CANCELLED", cancelled=True)
processing_queue = queue.Queue(maxsize=self.config.queue_size)
write_queue = queue.Queue(maxsize=self.config.queue_size * 2)
result = PipelineResult()
# 启动处理线程池
workers = []
for i in range(self.config.workers):
t = threading.Thread(
target=self._process_worker,
args=(processing_queue, write_queue, process_fn, result),
name=f"pipeline-worker-{i}",
daemon=True,
)
t.start()
workers.append(t)
# 启动写入线程
writer = threading.Thread(
target=self._write_worker,
args=(write_queue, write_fn, result),
name="pipeline-writer",
daemon=True,
)
writer.start()
# 主线程:串行请求
self._request_loop(requests, processing_queue, result)
# 发送 SENTINEL 到处理队列
for _ in workers:
processing_queue.put(None)
for w in workers:
w.join()
# 发送 SENTINEL 到写入队列
write_queue.put(None)
writer.join()
# 确定最终状态
if result.cancelled:
result.status = "CANCELLED"
elif result.request_failures + result.processing_failures + result.write_failures > 0:
result.status = "PARTIAL"
return result
```
### 3.5 BaseOdsTask 改造
文件:`apps/etl/connectors/feiqiu/tasks/ods/ods_tasks.py`(修改现有文件)
改造策略:在 `BaseOdsTask.execute()` 内部用 `UnifiedPipeline` 替代现有的同步循环但保留所有现有功能时间窗口解析、分页拉取、结构感知写入、快照软删除、content_hash 去重)。
```python
class BaseOdsTask(BaseTask):
"""改造后的 ODS 任务基类。"""
def execute(self, cursor_data: dict | None = None) -> dict:
spec = self.SPEC
# ... 现有的窗口解析、分段逻辑保持不变 ...
# 构建 PipelineConfig支持任务级覆盖
pipeline_config = PipelineConfig.from_app_config(self.config, spec.code)
cancel_token = getattr(self, '_cancel_token', None)
pipeline = UnifiedPipeline(
api_client=self.api,
db_connection=self.db,
logger=self.logger,
config=pipeline_config,
cancel_token=cancel_token,
)
# 将现有的分页请求逻辑封装为 PipelineRequest 迭代器
# 将现有的 _insert_records_schema_aware 封装为 write_fn
# 将现有的字段提取/hash 计算封装为 process_fn
result = pipeline.run(
requests=self._build_requests(spec, segments, store_id, page_size),
process_fn=self._build_process_fn(spec),
write_fn=self._build_write_fn(spec, source_file),
)
# ... 快照软删除逻辑保持不变 ...
# ... 结果构建逻辑保持不变 ...
```
关键约束:
- `OdsTaskSpec` 数据类的所有现有字段保持不变
- `_insert_records_schema_aware()``_mark_missing_as_deleted()` 等方法保持不变
- `TaskExecutor` 调用 `task.execute(cursor_data)` 的接口保持不变
- `TaskRegistry` 中的注册代码保持不变
### 3.6 OdsTaskSpec 扩展Detail_Mode 支持)
在现有 `OdsTaskSpec` 数据类中新增可选字段:
```python
@dataclass(frozen=True)
class OdsTaskSpec:
# ... 所有现有字段保持不变 ...
# Detail_Mode 可选配置(新增)
detail_endpoint: str | None = None # 详情接口 endpoint
detail_param_builder: Callable[[dict], dict] | None = None # 详情请求参数构造
detail_target_table: str | None = None # 详情数据目标表名
detail_data_path: tuple[str, ...] | None = None # 详情数据的 data_path
detail_list_key: str | None = None # 详情数据的 list_key
detail_id_column: str | None = None # 从列表数据中提取 ID 的列名
```
`detail_endpoint``None`Pipeline 跳过详情拉取阶段,行为与纯列表模式完全一致。
### 3.7 DWD 多线程调度器
文件:`apps/etl/connectors/feiqiu/tasks/dwd/dwd_load_task.py`(修改现有文件)
改造 `DwdLoadTask.load()` 方法,将现有的串行 `for dwd_table, ods_table in TABLE_MAP` 循环改为 `concurrent.futures.ThreadPoolExecutor` 并行调度:
```python
def load(self, extracted: dict[str, Any], context: TaskContext) -> dict[str, Any]:
now = extracted["now"]
parallel_workers = int(self.config.get("dwd.parallel_workers", 4))
# 将表分为维度表和事实表两组
dim_tables = [(d, o) for d, o in self.TABLE_MAP.items()
if self._table_base(d).startswith("dim_")]
fact_tables = [(d, o) for d, o in self.TABLE_MAP.items()
if not self._table_base(d).startswith("dim_")]
summary = []
errors = []
# 维度表并行 SCD2 合并(每张表独立事务、独立数据库连接)
with ThreadPoolExecutor(max_workers=parallel_workers) as executor:
futures = {}
for dwd_table, ods_table in dim_tables:
if only_tables and ...: # 过滤逻辑保持不变
continue
future = executor.submit(
self._process_single_table, dwd_table, ods_table, now, context
)
futures[future] = dwd_table
for future in as_completed(futures):
dwd_table = futures[future]
try:
table_result = future.result()
summary.append(table_result)
except Exception as exc:
errors.append({"table": dwd_table, "error": str(exc)})
# 事实表同样并行处理
# ... 类似逻辑 ...
return {"tables": summary, "errors": len(errors), "error_details": errors}
```
关键约束:
- `_merge_dim_scd2()` 方法本身不改
- 每张表使用独立的数据库连接和事务
- 单张表失败不影响其他表
### 3.8 任务日志管理器
文件:`apps/etl/connectors/feiqiu/utils/task_log_buffer.py`(新建)
```python
class TaskLogBuffer:
"""任务级日志缓冲区,收集单个任务的所有日志,任务完成后一次性输出。"""
def __init__(self, task_code: str, parent_logger: logging.Logger):
self.task_code = task_code
self._buffer: list[LogEntry] = []
self._lock = threading.Lock()
self._parent = parent_logger
def log(self, level: int, message: str, *args, **kwargs):
"""线程安全地缓冲一条日志。"""
with self._lock:
self._buffer.append(LogEntry(
timestamp=datetime.now(),
level=level,
task_code=self.task_code,
message=message % args if args else message,
))
def flush(self) -> list[LogEntry]:
"""将缓冲区内容按时间顺序一次性输出到父 logger并返回日志列表。"""
with self._lock:
entries = sorted(self._buffer, key=lambda e: e.timestamp)
for entry in entries:
self._parent.log(
entry.level,
"[%s] %s",
entry.task_code,
entry.message,
)
self._buffer.clear()
return entries
```
## 4. 数据模型
### 4.1 PipelineConfig 配置命名空间
`AppConfig` 中新增 `pipeline.*` 命名空间:
| 配置键 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| `pipeline.workers` | int | 2 | ProcessingPool 工作线程数 |
| `pipeline.queue_size` | int | 100 | 处理队列容量 |
| `pipeline.batch_size` | int | 100 | WriteWorker 批量写入阈值 |
| `pipeline.batch_timeout` | float | 5.0 | WriteWorker 等待超时(秒) |
| `pipeline.rate_min` | float | 5.0 | RateLimiter 最小间隔(秒) |
| `pipeline.rate_max` | float | 20.0 | RateLimiter 最大间隔(秒) |
| `pipeline.max_consecutive_failures` | int | 10 | 连续失败中断阈值 |
| `pipeline.<TASK_CODE>.workers` | int | - | 任务级覆盖:工作线程数 |
| `pipeline.<TASK_CODE>.rate_min` | float | - | 任务级覆盖:最小间隔 |
| `pipeline.<TASK_CODE>.rate_max` | float | - | 任务级覆盖:最大间隔 |
| `dwd.parallel_workers` | int | 4 | DWD 层并行线程数 |
任务级覆盖示例(`.env`
```
PIPELINE_WORKERS=2
PIPELINE_RATE_MIN=5.0
PIPELINE_ODS_GROUP_PACKAGE.RATE_MIN=8.0
PIPELINE_ODS_GROUP_PACKAGE.RATE_MAX=25.0
```
CLI 参数覆盖:
```
--pipeline-workers 4
--pipeline-batch-size 200
--pipeline-rate-min 3.0
--pipeline-rate-max 15.0
```
### 4.2 PipelineRequest 数据类
```python
@dataclass
class PipelineRequest:
"""管道请求描述。"""
endpoint: str
params: dict
page_size: int | None = 200
data_path: tuple[str, ...] = ("data",)
list_key: str | None = None
segment_index: int = 0 # 所属窗口分段索引
is_detail: bool = False # 是否为详情请求
detail_id: Any = None # 详情请求的 ID
```
### 4.3 PipelineResult 数据类
```python
@dataclass
class PipelineResult:
"""管道执行结果。"""
status: str = "SUCCESS"
total_requests: int = 0
completed_requests: int = 0
total_fetched: int = 0
total_inserted: int = 0
total_updated: int = 0
total_skipped: int = 0
total_deleted: int = 0
request_failures: int = 0
processing_failures: int = 0
write_failures: int = 0
cancelled: bool = False
errors: list[dict] = field(default_factory=list)
timing: dict[str, float] = field(default_factory=dict)
# Detail_Mode 统计(仅在启用时填充)
detail_success: int = 0
detail_failure: int = 0
detail_skipped: int = 0
```
### 4.4 WriteResult 数据类
```python
@dataclass
class WriteResult:
"""单次批量写入结果。"""
inserted: int = 0
updated: int = 0
skipped: int = 0
errors: int = 0
```
### 4.5 LogEntry 数据类
```python
@dataclass
class LogEntry:
"""日志条目。"""
timestamp: datetime
level: int
task_code: str
message: str
```
### 4.6 现有数据模型不变
以下现有数据模型保持不变:
- `OdsTaskSpec`:仅新增 Detail_Mode 可选字段,所有现有字段不变
- `TaskContext`:不变
- `TaskMeta`:不变
- `SnapshotMode`:不变
- `ColumnSpec`:不变
## 5. 正确性属性
*属性Property是系统在所有有效执行中都应保持为真的特征或行为——本质上是对系统应做什么的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。*
以下属性基于需求文档中的验收标准推导,经过冗余消除和合并后得到 16 个独立属性。
### Property 1: 请求严格串行
*对于任意*一组提交到 RequestScheduler 的 API 请求,每个请求的发送时间戳必须严格晚于上一个请求的响应完成时间戳,无论请求来自同一个 ODS 任务还是不同的 ODS 任务。
**Validates: Requirements 1.2, 1.6**
### Property 2: RateLimiter 间隔范围
*对于任意*有效的 min_interval 和 max_interval 配置min_interval <= max_intervalRateLimiter.wait() 的实际等待时间始终在 [min_interval, max_interval] 范围内(允许 ±0.5s 的系统调度误差)。
**Validates: Requirements 1.3**
### Property 3: PipelineConfig 构造与验证
*对于任意*一组配置参数,当 workers >= 1、queue_size >= 1、batch_size >= 1 且 rate_min <= rate_max 时PipelineConfig 应成功构造并正确存储所有参数值;当任一条件不满足时,应抛出 ValueError。
**Validates: Requirements 1.5, 4.1, 4.4, 4.5**
### Property 4: 配置分层与任务级覆盖
*对于任意*任务代码和配置值组合PipelineConfig.from_app_config() 应遵循优先级:任务级配置(`pipeline.<task_code>.*`> 全局配置(`pipeline.*`> 默认值。当任务级配置存在时,应覆盖全局配置;当任务级配置不存在时,应回退到全局配置。
**Validates: Requirements 1.4, 2.3, 2.6, 4.2, 4.3, 4.6, 7.2**
### Property 5: 管道完成语义
*对于任意*一组成功的 API 请求无失败、无取消UnifiedPipeline.run() 返回时PipelineResult 中的 total_fetched 应等于所有请求返回的记录总数,且 total_inserted + total_updated + total_skipped 应等于 total_fetched。
**Validates: Requirements 2.7**
### Property 6: WriteWorker 批量大小约束
*对于任意*配置的 batch_sizeWriteWorker 每次调用 write_fn 时传入的记录数不超过 batch_size。
**Validates: Requirements 2.5**
### Property 7: CancellationToken 状态转换
*对于任意* CancellationToken 实例,初始状态 is_cancelled 为 False调用 cancel() 后 is_cancelled 变为 True 且不可逆转。对于配置了超时的 CancellationToken在超时时间到达后 is_cancelled 自动变为 True。
**Validates: Requirements 3.1, 3.6**
### Property 8: 取消后已入队数据不丢失
*对于任意*管道执行过程中触发取消信号的时刻,管道返回时:(a) 不再发送新的 API 请求,(b) 所有已提交到 processing_queue 的数据全部被处理完成,(c) 所有已处理完成的数据全部被写入数据库,(d) 返回结果的 status 为 CANCELLED 且 cancelled 为 True。
**Validates: Requirements 3.2, 3.3, 3.4, 3.5**
### Property 9: 迁移前后输出等价
*对于任意* ODS 任务和相同的输入数据API 响应序列),通过 UnifiedPipeline 执行后产生的数据库写入结果inserted/updated/skipped 计数和记录内容)应与迁移前的同步串行执行完全一致。
**Validates: Requirements 5.1, 5.3, 5.4, 5.5**
### Property 10: Detail_Mode 可选性
*对于任意* OdsTaskSpec当 detail_endpoint 为 None 时,管道执行应跳过详情拉取阶段,结果中 detail_success/detail_failure/detail_skipped 均为 0当 detail_endpoint 已配置时,管道应在列表拉取完成后执行详情拉取,且详情请求遵循与列表请求相同的限流规则。
**Validates: Requirements 6.1, 6.3, 6.4**
### Property 11: 单项失败不中断整体
*对于任意*管道执行中的单个失败项API 请求失败、处理异常、详情接口错误、DWD 单表合并失败),管道应继续处理后续项目,不中断整体流程,且失败项被正确记录在结果的 errors 列表中。
**Validates: Requirements 6.5, 9.1, 9.2, 7.3, 7.4**
### Property 12: 连续失败触发中断
*对于任意*管道执行,当连续失败次数超过 max_consecutive_failures 配置值时,管道应主动中断执行,返回结果的 status 为 FAILED。当连续失败次数未超过阈值时管道应继续执行。
**Validates: Requirements 9.5**
### Property 13: 写入失败回滚当前批次
*对于任意*批量写入操作,当 write_fn 抛出数据库异常时,当前批次的事务应被回滚(不产生部分写入),该批次的记录被标记为写入失败,后续批次不受影响。
**Validates: Requirements 9.3**
### Property 14: 结果统计完整性
*对于任意*管道执行(包括 Detail_Mode 和 DWD 多线程返回结果中的统计信息应完整且一致request_failures + processing_failures + write_failures 应等于 errors 列表的长度detail_success + detail_failure + detail_skipped 应等于详情请求总数DWD 汇总中成功表数 + 失败表数应等于总表数。
**Validates: Requirements 6.6, 8.2, 9.4, 7.5**
### Property 15: 日志缓冲区按任务隔离
*对于任意*多个并发任务的日志流,每个 TaskLogBuffer 的 flush() 输出应仅包含该任务的日志条目,且按时间戳升序排列,不包含其他任务的日志。
**Validates: Requirements 10.1, 10.4**
### Property 16: DWD 并行与串行结果一致
*对于任意*一组 DWD 表的 SCD2 合并操作,多线程并行执行的最终结果(每张表的 inserted/updated 计数)应与串行逐表执行的结果完全一致。
**Validates: Requirements 7.1**
## 6. 错误处理
### 6.1 错误分类与处理策略
| 错误类型 | 触发条件 | 处理方式 | 影响范围 |
|----------|----------|----------|----------|
| API 请求失败 | HTTP 错误、超时、API 返回错误码 | 由 `APIClient` 内置重试3 次指数退避);耗尽后记录错误,继续下一个请求 | 单个请求 |
| 处理异常 | 字段提取、hash 计算等抛出异常 | 捕获异常,记录错误日志(含记录标识),标记为处理失败,继续处理队列 | 单条记录 |
| 写入失败 | 数据库错误(约束冲突、连接断开等) | 回滚当前批次事务,记录错误日志(含批次大小),标记为写入失败 | 单个批次 |
| 连续失败 | 连续 N 次请求/处理/写入失败 | 主动中断管道status=FAILED | 整个任务 |
| 取消信号 | 外部触发 cancel_token.cancel() | 停止新请求,等待已入队数据处理完成后退出 | 整个任务 |
| 配置错误 | workers<1, rate_min>rate_max 等 | 构造时抛出 ValueError任务不启动 | 整个任务 |
| DWD 单表失败 | SCD2 合并过程中异常 | 回滚该表事务,记录错误,继续处理其他表 | 单张表 |
### 6.2 连续失败计数逻辑
```python
consecutive_failures = 0
for request in requests:
try:
response = api_client.post(...)
consecutive_failures = 0 # 成功则重置
except Exception:
consecutive_failures += 1
if consecutive_failures >= config.max_consecutive_failures:
result.status = "FAILED"
break
```
### 6.3 事务管理
- ODS 层每个窗口分段segment的数据在该分段全部处理完成后统一 commit分段失败时 rollback 该分段(保留现有语义)
- DWD 层:每张表独立事务,单表失败 rollback 不影响其他表
- WriteWorker每个批次独立事务批次失败 rollback 不影响后续批次
## 7. 测试策略
### 7.1 测试框架
- 单元测试:`pytest`ETL 模块内 `tests/unit/`
- 属性测试:`hypothesis`Monorepo 级 `tests/`
- 每个属性测试最少运行 100 次迭代
### 7.2 属性测试计划
每个正确性属性对应一个属性测试,使用 `hypothesis` 库实现:
| 属性 | 测试文件 | 生成器策略 |
|------|----------|-----------|
| P1: 请求严格串行 | `tests/test_pipeline_properties.py` | 生成随机请求序列,用 FakeAPI 记录时间戳 |
| P2: RateLimiter 间隔范围 | `tests/test_rate_limiter_properties.py` | 生成随机 (min, max) 对,验证 wait() 时间 |
| P3: PipelineConfig 构造 | `tests/test_pipeline_config_properties.py` | 生成随机配置参数组合(含无效值) |
| P4: 配置分层覆盖 | `tests/test_pipeline_config_properties.py` | 生成随机的多层配置字典 |
| P5: 管道完成语义 | `tests/test_pipeline_properties.py` | 生成随机记录集,验证计数一致 |
| P6: WriteWorker 批量约束 | `tests/test_pipeline_properties.py` | 生成随机 batch_size 和记录流 |
| P7: CancellationToken 状态 | `tests/test_cancellation_properties.py` | 生成随机超时值 |
| P8: 取消后数据不丢失 | `tests/test_pipeline_properties.py` | 生成随机请求序列 + 随机取消时刻 |
| P9: 迁移等价 | `tests/test_migration_properties.py` | 生成随机 API 响应,对比新旧实现 |
| P10: Detail_Mode 可选性 | `tests/test_detail_mode_properties.py` | 生成有/无 detail_endpoint 的 OdsTaskSpec |
| P11: 单项失败不中断 | `tests/test_pipeline_properties.py` | 生成含随机失败的请求序列 |
| P12: 连续失败中断 | `tests/test_pipeline_properties.py` | 生成连续失败序列 + 随机阈值 |
| P13: 写入失败回滚 | `tests/test_pipeline_properties.py` | 生成含随机写入失败的批次 |
| P14: 结果统计完整性 | `tests/test_pipeline_properties.py` | 生成随机执行结果,验证计数一致性 |
| P15: 日志缓冲区隔离 | `tests/test_log_buffer_properties.py` | 生成多任务随机日志流 |
| P16: DWD 并行串行一致 | `tests/test_dwd_parallel_properties.py` | 生成随机表集合 + mock SCD2 |
每个测试必须包含注释标签:
```python
# Feature: etl-unified-pipeline, Property 1: 请求严格串行
```
### 7.3 单元测试计划
单元测试聚焦于具体示例、边界条件和集成点:
| 测试目标 | 测试文件 | 覆盖内容 |
|----------|----------|----------|
| RateLimiter | `tests/unit/test_rate_limiter.py` | 边界min=max、取消中断、min>max 抛错 |
| CancellationToken | `tests/unit/test_cancellation.py` | 边界:预取消、超时=0、dispose |
| PipelineConfig | `tests/unit/test_pipeline_config.py` | 边界无效参数、CLI 覆盖 |
| UnifiedPipeline | `tests/unit/test_unified_pipeline.py` | 集成FakeAPI + FakeDB 端到端 |
| TaskLogBuffer | `tests/unit/test_task_log_buffer.py` | 边界:空缓冲区、并发写入 |
| DWD 多线程调度 | `tests/unit/test_dwd_parallel.py` | 集成mock SCD2 + 单表失败 |
| Detail_Mode | `tests/unit/test_detail_mode.py` | 集成:列表→详情完整流程 |
### 7.4 测试环境
- 单元测试使用 FakeDB/FakeAPI不涉及真实数据库连接
- 属性测试使用 `hypothesis` 库,最少 100 次迭代
- 集成测试(如需)使用 `test_etl_feiqiu` 测试库,通过 `TEST_DB_DSN` 连接

View File

@@ -0,0 +1,159 @@
# 需求文档ETL 统一请求编排与线程模型改造
## 简介
对飞球 Connector ETL 系统(`apps/etl/connectors/feiqiu/`)的请求编排和线程模型进行全局统一化改造。当前系统所有 ODS 任务在 `BaseOdsTask.execute()` 中同步串行执行 API 请求、数据处理和数据库写入,无限流机制、无取消信号、无并行处理能力。本次改造建立统一的请求编排框架,所有 21 个 ODS 任务迁移到"串行请求 + 异步处理 + 单线程写库"架构支持全局限流5-20 秒随机间隔)、外部取消信号、可选的"列表→详情"二级拉取模式,并对 DWD 层加载进行多线程优化。
## 术语表
- **ETL_System**:飞球 Connector ETL 系统(`apps/etl/connectors/feiqiu/`),负责从飞球 SaaS API 拉取数据并加载到 PostgreSQL 的 ODS → DWD → DWS 各层
- **Unified_Pipeline**:统一请求编排框架,所有 ODS 任务共用的"串行请求 + 异步处理 + 单线程写库"执行引擎
- **Request_Scheduler**:全局请求调度器,负责将所有 API 请求统一排队、串行发送、遵循限流规则
- **Rate_Limiter**:请求间隔控制器,控制相邻两次 API 请求之间的随机等待时间(默认 5-20 秒均匀分布),防止触发上游风控
- **Processing_Pool**:异步处理线程池,多个工作线程并行消费 API 响应数据执行字段提取、数据清洗、content_hash 计算等 CPU 密集操作
- **Write_Worker**:单线程写入工作器,汇总所有处理完成的结果,统一执行数据库写入操作,保证写入串行化
- **CancellationToken**:取消令牌,外部组件(如 Admin-web、CLI通过设置该令牌通知正在执行的任务中断
- **ODS_Task**ODS 层数据拉取任务的统称,当前共 21 个,通过 `OdsTaskSpec` 数据类定义、`_build_task_class()` 动态生成任务类
- **Detail_Mode**:二级详情拉取模式,在列表接口拉取完成后逐条调用详情接口获取更丰富的数据,属于可选能力
- **Pipeline_Config**:管道配置,包含 worker 数、队列大小、批量写入阈值、限流间隔等参数,不同任务可独立配置
- **BaseOdsTask**:当前 ODS 任务基类(`tasks/ods/ods_tasks.py`封装时间窗口解析、API 分页拉取、结构感知写入、快照软删除等核心逻辑
- **TaskExecutor**:任务执行器(`orchestration/task_executor.py`),封装单个任务的执行生命周期(游标管理、运行记录、数据源路由)
- **FlowRunner**:流程编排器(`orchestration/flow_runner.py`编排多层任务ODS → DWD → DWS → INDEX的执行顺序
- **DWD_Loader**DWD 层加载任务(`DwdLoadTask`),通过 `_merge_dim_scd2()` 执行 SCD2 合并,将 ODS 原始数据转换为维度/事实表
## 需求
### 需求 1统一请求调度器
**用户故事:** 作为 ETL 运维人员,我希望所有 API 请求通过统一的调度器串行发送并遵循限流规则,避免触发上游风控导致 IP 封禁或数据拉取失败。
#### 验收标准
1. THE Request_Scheduler SHALL 维护一个全局请求队列,所有 ODS 任务的 API 请求统一进入该队列排队等待发送
2. THE Request_Scheduler SHALL 严格串行发送请求:等待上一个请求的 HTTP 响应完整返回后,再等待限流间隔,然后发送队列中的下一个请求
3. THE Rate_Limiter SHALL 在每两个相邻请求之间插入 5 至 20 秒之间的随机等待时间(均匀分布)
4. THE Rate_Limiter SHALL 支持通过 Pipeline_Config 调整最小间隔(默认 5 秒)和最大间隔(默认 20 秒),不同任务可配置不同的间隔范围
5. IF Rate_Limiter 初始化时最小间隔大于最大间隔THEN THE Rate_Limiter SHALL 抛出 `ValueError` 并包含描述性错误信息
6. WHEN 同一次 FlowRunner 执行中包含多个 ODS 任务时THE Request_Scheduler SHALL 按任务注册顺序依次处理每个任务的请求,同一时刻仅有一个 HTTP 请求在途
7. THE Request_Scheduler SHALL 在每个请求完成后记录请求耗时、响应状态码和目标 endpoint 到日志
### 需求 2异步处理与单线程写库架构
**用户故事:** 作为 ETL 运维人员,我希望 API 响应数据的处理字段提取、清洗、hash 计算)能与请求发送并行执行,同时保证数据库写入的串行化,在不增加 API 压力的前提下提升整体吞吐。
#### 验收标准
1. THE Unified_Pipeline SHALL 在每个 API 请求的响应返回后,立即将响应数据提交到 Processing_Pool 的任务队列,不阻塞 Request_Scheduler 的限流等待计时
2. THE Processing_Pool SHALL 支持多个工作线程并行消费处理队列中的响应数据执行字段提取、数据清洗、content_hash 计算、record 层合并等操作
3. THE Processing_Pool 的工作线程数量 SHALL 通过 Pipeline_Config 配置(默认值 2不同任务可独立配置
4. THE Write_Worker SHALL 作为单独的线程运行,从处理完成队列中消费数据,统一执行数据库 INSERT/UPSERT 操作
5. THE Write_Worker SHALL 支持批量写入:累积到配置的阈值(默认 100 条记录)或等待超时(默认 5 秒)后执行一次批量写入
6. THE Write_Worker 的批量写入阈值和等待超时 SHALL 通过 Pipeline_Config 配置,不同任务可独立配置
7. WHEN 所有请求发送完毕后THE Unified_Pipeline SHALL 等待 Processing_Pool 和 Write_Worker 全部完成后再返回最终结果
8. THE Unified_Pipeline SHALL 保证多线程读库操作的安全性Processing_Pool 中的工作线程可并行读取数据库(如查询最新 content_hash使用独立的只读数据库连接
### 需求 3外部取消信号支持
**用户故事:** 作为 ETL 运维人员,我希望能通过 Admin-web 或 CLI 发送取消信号中断正在执行的 ODS 任务,避免长时间运行的任务无法停止。
#### 验收标准
1. THE CancellationToken SHALL 提供线程安全的 `cancel()` 方法和 `is_cancelled` 属性,供外部组件触发取消
2. WHEN CancellationToken 被触发时THE Request_Scheduler SHALL 在当前请求的限流等待期或响应等待期中断,不再发起后续请求
3. WHEN CancellationToken 被触发时THE Processing_Pool SHALL 完成当前已提交到队列中的所有数据处理任务,不丢弃已入队的数据
4. WHEN CancellationToken 被触发时THE Write_Worker SHALL 将所有已处理完成的数据写入数据库后再退出,保证已处理数据的持久化
5. WHEN 任务因取消信号中断时THE Unified_Pipeline SHALL 返回部分完成的统计结果(已完成的请求数、已处理的记录数、已写入的记录数),任务状态标记为 `CANCELLED`
6. THE CancellationToken SHALL 支持超时自动取消:可在创建时指定最大执行时间(秒),超时后自动触发取消信号
7. IF CancellationToken 在任务启动前已处于取消状态THEN THE Unified_Pipeline SHALL 立即返回空结果,不发送任何请求
### 需求 4Pipeline 配置体系
**用户故事:** 作为 ETL 运维人员我希望线程模型的各项参数worker 数、队列大小、批量写入阈值、限流间隔)足够灵活,不同接口可以有不同的配置,以适应不同 API 的特性。
#### 验收标准
1. THE Pipeline_Config SHALL 支持以下可配置参数Processing_Pool 工作线程数(`workers`,默认 2、处理队列容量`queue_size`,默认 100、Write_Worker 批量写入阈值(`batch_size`,默认 100、Write_Worker 等待超时秒数(`batch_timeout`,默认 5.0、Rate_Limiter 最小间隔秒数(`rate_min`,默认 5.0、Rate_Limiter 最大间隔秒数(`rate_max`,默认 20.0
2. THE Pipeline_Config SHALL 遵循现有配置分层体系(根 `.env` < `.env.local` < 环境变量 < CLI 参数),通过 `AppConfig``pipeline.*` 命名空间读取
3. THE Pipeline_Config SHALL 支持任务级覆盖:通过 `pipeline.<task_code>.*` 命名空间为特定任务指定独立配置,未指定时回退到 `pipeline.*` 全局默认值
4. IF Pipeline_Config 中 `workers` 小于 1 或 `queue_size` 小于 1THEN THE Unified_Pipeline SHALL 抛出 `ValueError` 并包含描述性错误信息
5. IF Pipeline_Config 中 `batch_size` 小于 1THEN THE Unified_Pipeline SHALL 抛出 `ValueError` 并包含描述性错误信息
6. THE Pipeline_Config SHALL 支持运行时通过 CLI 参数 `--pipeline-workers``--pipeline-batch-size``--pipeline-rate-min``--pipeline-rate-max` 覆盖全局默认值
### 需求 5现有 ODS 任务迁移
**用户故事:** 作为 ETL 开发者,我希望现有 21 个 ODS 任务全部迁移到统一管道框架上,保持功能完全等价,不丢失任何现有能力。
#### 验收标准
1. THE Unified_Pipeline SHALL 完整保留 BaseOdsTask 的所有现有功能:时间窗口解析(`_resolve_window`)、窗口分段(`build_window_segments`、API 分页拉取(`iter_paginated`)、结构感知写入(`_insert_records_schema_aware`)、快照软删除(`_mark_missing_as_deleted`、content_hash 去重(`skip_unchanged`
2. THE Unified_Pipeline SHALL 保留 OdsTaskSpec 数据类的所有现有字段定义,迁移后的任务通过相同的 `OdsTaskSpec` 实例配置
3. WHEN 迁移完成后THE ETL_System 对每个 ODS 任务执行相同输入数据时 SHALL 产生与迁移前完全相同的数据库写入结果(相同的 inserted/updated/skipped 计数和相同的记录内容)
4. THE Unified_Pipeline SHALL 保留现有的 `endpoint_routing` 逻辑recent/former 路由拆分),迁移后的请求路由行为与现有系统一致
5. THE Unified_Pipeline SHALL 保留现有的 `source_file``source_endpoint``fetched_at` 等元数据写入逻辑
6. THE Unified_Pipeline SHALL 兼容现有的 `TaskExecutor` 执行生命周期(游标管理、运行记录、数据源路由),迁移后 TaskExecutor 无需修改调用方式
7. WHEN 迁移完成后THE TaskRegistry 中所有 21 个 ODS 任务的注册代码和元数据 SHALL 保持不变
### 需求 6二级详情拉取模式
**用户故事:** 作为 ETL 开发者,我希望统一管道框架支持"列表接口拉完后逐条调详情"的二级拉取模式,以便团购详情等需要二次请求的业务能在框架内实现。
#### 验收标准
1. THE Unified_Pipeline SHALL 支持可选的 Detail_Mode在列表接口的所有分页数据拉取并写入 ODS 完成后,从已写入的记录中提取 ID 列表,逐条调用详情接口
2. THE OdsTaskSpec SHALL 新增可选字段支持 Detail_Mode 配置:详情接口 endpoint、详情请求参数构造函数、详情数据目标表名、详情数据的 data_path 和 list_key
3. WHEN OdsTaskSpec 未配置 Detail_Mode 相关字段时THE Unified_Pipeline SHALL 跳过详情拉取阶段,行为与纯列表拉取模式完全一致
4. THE Detail_Mode 的详情请求 SHALL 通过 Request_Scheduler 统一排队,遵循与列表请求相同的限流规则
5. IF 详情接口对某个 ID 返回错误或超时THEN THE Unified_Pipeline SHALL 记录错误日志(含 ID 和错误信息)并继续处理下一个 ID不中断整体流程
6. WHEN 详情拉取完成后THE Unified_Pipeline SHALL 在任务执行结果中包含详情拉取的统计信息(详情成功数、详情失败数、详情跳过数),与列表拉取统计分开记录
### 需求 7DWD 层多线程优化
**用户故事:** 作为 ETL 运维人员,我希望 DWD 层加载任务能利用多线程并行处理多张表的 SCD2 合并,缩短 DWD 层的整体执行时间。
#### 验收标准
1. THE DWD_Loader SHALL 支持多线程并行执行多张 DWD 表的 SCD2 合并操作,每张表的合并在独立线程中运行
2. THE DWD_Loader 的并行线程数 SHALL 通过配置参数控制(默认值 4通过 `AppConfig``dwd.parallel_workers` 读取
3. THE DWD_Loader SHALL 保证每张表的 SCD2 合并操作在独立的数据库事务中执行,单张表失败不影响其他表的处理
4. WHEN 某张 DWD 表的 SCD2 合并失败时THE DWD_Loader SHALL 记录错误日志(含表名和错误信息),将该表标记为失败,继续处理其他表
5. THE DWD_Loader SHALL 在所有表处理完成后返回汇总结果:成功表数、失败表数、每张表的 inserted/updated 计数
6. THE DWD_Loader 的现有 `_merge_dim_scd2()` 方法 SHALL 保持不变,多线程优化仅在调度层面并行调用该方法
### 需求 8可观测性与监控
**用户故事:** 作为 ETL 运维人员,我希望统一管道框架提供充分的运行时可观测性,便于监控执行状态和排查问题。
#### 验收标准
1. THE Unified_Pipeline SHALL 在任务执行过程中记录以下关键指标到日志当前请求队列深度、Processing_Pool 活跃线程数、Write_Worker 待写入队列深度、已完成请求数/总请求数
2. THE Unified_Pipeline SHALL 在任务完成后输出执行摘要:总耗时、请求阶段耗时、处理阶段耗时、写入阶段耗时、各阶段的记录数统计
3. WHEN Processing_Pool 的任务队列达到容量上限时THE Unified_Pipeline SHALL 记录警告日志Request_Scheduler 暂停发送新请求直到队列有空位(背压机制)
4. WHEN Write_Worker 的待写入队列积压超过 `queue_size * 2`THE Unified_Pipeline SHALL 记录警告日志
5. THE Unified_Pipeline SHALL 与现有的 `EtlTimer` 集成,在 FlowRunner 的计时报告中体现各 ODS 任务的请求/处理/写入阶段耗时
### 需求 9错误处理与容错
**用户故事:** 作为 ETL 运维人员,我希望统一管道框架具备完善的错误处理机制,单个请求或记录的失败不影响整体任务的执行。
#### 验收标准
1. IF 单个 API 请求失败HTTP 错误、超时、API 返回错误码THEN THE Request_Scheduler SHALL 按现有 `APIClient` 的重试策略(最多 3 次,指数退避)重试,重试耗尽后记录错误并继续处理下一个请求
2. IF Processing_Pool 中某条记录的处理抛出异常THEN THE Processing_Pool SHALL 记录错误日志(含记录标识和异常信息),将该记录标记为处理失败,继续处理队列中的其他记录
3. IF Write_Worker 执行批量写入时发生数据库错误THEN THE Write_Worker SHALL 回滚当前批次的事务,记录错误日志(含批次大小和错误信息),将该批次的记录标记为写入失败
4. WHEN 任务执行完成后THE Unified_Pipeline SHALL 在执行结果中汇总所有错误:请求失败数、处理失败数、写入失败数,以及每个失败项的错误摘要
5. IF 任务执行过程中连续失败次数超过配置阈值(默认 10 次THEN THE Unified_Pipeline SHALL 主动中断任务执行,将任务状态标记为 `FAILED`,避免无效重试浪费时间
6. THE Unified_Pipeline SHALL 保留现有 BaseOdsTask 的事务管理语义每个窗口分段segment的数据在该分段全部处理完成后统一 commit分段失败时 rollback 该分段
### 需求 10Admin-web 日志输出优化
**用户故事:** 作为 ETL 运维人员,我希望在 Admin-web 管理后台查看 ETL 执行日志时,各个任务的日志按任务分组、有序展示,避免多任务并行执行时日志行交叉混乱导致难以阅读和排查问题。
#### 验收标准
1. THE ETL_System SHALL 为每个 ODS 任务的执行日志添加任务标识前缀(任务代码),使日志行可按任务归属区分
2. THE Admin-web SHALL 支持按任务代码过滤和分组展示 ETL 执行日志,用户可选择查看单个任务的日志或全部日志
3. THE Unified_Pipeline SHALL 在多线程环境下保证日志写入的原子性:每条日志消息作为完整的一行输出,不会被其他线程的日志截断或插入
4. THE ETL_System SHALL 为每个任务维护独立的日志缓冲区,任务完成后将该任务的完整日志按时间顺序一次性输出到 Admin-web避免执行过程中不同任务的日志行交叉
5. THE Admin-web SHALL 在 ETL 执行结果页面中按任务分段展示日志:每个任务的日志折叠为独立区块,展开后显示该任务的完整执行日志(含时间戳、日志级别、消息内容)
6. WHEN 多个 ODS 任务在同一次 FlowRunner 执行中运行时THE Admin-web SHALL 在顶部展示任务执行时间线概览(每个任务的开始时间、结束时间、状态),用户可点击跳转到对应任务的日志区块

View File

@@ -0,0 +1,229 @@
# 实施计划ETL 统一请求编排与线程模型改造
## 概述
将飞球 Connector ETL 系统的 ODS 任务从同步串行执行迁移到"串行请求 + 异步处理 + 单线程写库"统一管道架构。按组件依赖顺序逐步实现:基础组件 → 核心引擎 → 任务迁移 → DWD 优化 → 日志优化。
## 任务
- [x] 1. 实现基础组件PipelineConfig、CancellationToken、RateLimiter
- [x] 1.1 创建 `apps/etl/connectors/feiqiu/config/pipeline_config.py`,实现 `PipelineConfig` 数据类
- 定义 `workers``queue_size``batch_size``batch_timeout``rate_min``rate_max``max_consecutive_failures` 字段及默认值
- 实现 `__post_init__` 参数校验workers>=1、queue_size>=1、batch_size>=1、rate_min<=rate_max
- 实现 `from_app_config(config, task_code)` 类方法,支持 `pipeline.<task_code>.*` 任务级覆盖 → 全局 `pipeline.*` → 默认值的三级回退
- _需求: 1.3, 1.4, 1.5, 2.3, 2.5, 2.6, 4.1, 4.2, 4.3, 4.4, 4.5, 4.6_
- [x] 1.2 编写 PipelineConfig 属性测试
- **Property 3: PipelineConfig 构造与验证** — 生成随机配置参数组合(含无效值),验证合法参数成功构造、非法参数抛出 ValueError
- **Property 4: 配置分层与任务级覆盖** — 生成随机多层配置字典,验证任务级 > 全局级 > 默认值的优先级
- 测试文件:`tests/test_pipeline_config_properties.py`
- **验证: 需求 1.4, 1.5, 4.1, 4.2, 4.3, 4.4, 4.5, 4.6**
- [x] 1.3 创建 `apps/etl/connectors/feiqiu/utils/cancellation.py`,实现 `CancellationToken`
- 基于 `threading.Event` 实现线程安全的 `cancel()` 方法和 `is_cancelled` 属性
- 实现超时自动取消(构造时传入 `timeout` 秒数,通过 `threading.Timer` 触发)
- 实现 `dispose()` 清理定时器
- _需求: 3.1, 3.6_
- [x] 1.4 编写 CancellationToken 属性测试
- **Property 7: CancellationToken 状态转换** — 生成随机超时值,验证初始 False、cancel() 后 True 且不可逆、超时自动触发
- 测试文件:`tests/test_cancellation_properties.py`
- **验证: 需求 3.1, 3.6**
- [x] 1.5 创建 `apps/etl/connectors/feiqiu/api/rate_limiter.py`,实现 `RateLimiter`
- 构造时校验 `min_interval <= max_interval`,否则抛出 `ValueError`
- 实现 `wait(cancel_event)` 方法:生成 `[min, max]` 均匀分布随机间隔,拆分为 0.5s 小段轮询 cancel_event
- 暴露 `last_interval` 属性
- _需求: 1.3, 1.5_
- [x] 1.6 编写 RateLimiter 属性测试
- **Property 2: RateLimiter 间隔范围** — 生成随机 (min, max) 对,验证 wait() 实际等待时间在 [min, max] ± 0.5s 范围内
- 测试文件:`tests/test_rate_limiter_properties.py`
- **验证: 需求 1.3**
- [x] 1.7 编写基础组件单元测试
- 测试文件:`apps/etl/connectors/feiqiu/tests/unit/test_pipeline_config.py``tests/unit/test_cancellation.py``tests/unit/test_rate_limiter.py`
- 覆盖边界条件RateLimiter min=max、CancellationToken 预取消/timeout=0/dispose、PipelineConfig 无效参数/CLI 覆盖
- _需求: 1.3, 1.5, 3.1, 3.6, 4.1, 4.4, 4.5_
- [x] 2. 检查点 — 基础组件验证
- 确保所有测试通过ask the user if questions arise.
- [x] 3. 实现核心管道引擎UnifiedPipeline
- [x] 3.1 创建数据类 `PipelineRequest``PipelineResult``WriteResult`
- 文件:`apps/etl/connectors/feiqiu/pipeline/models.py`
- `PipelineRequest`endpoint、params、page_size、data_path、list_key、segment_index、is_detail、detail_id
- `PipelineResult`status、各阶段计数、errors 列表、timing 字典、Detail_Mode 统计
- `WriteResult`inserted、updated、skipped、errors
- _需求: 2.7, 6.6, 8.2, 9.4_
- [x] 3.2 创建 `apps/etl/connectors/feiqiu/pipeline/unified_pipeline.py`,实现 `UnifiedPipeline` 核心引擎
- 实现 `__init__`:接收 api_client、db_connection、logger、PipelineConfig、CancellationToken初始化 RateLimiter
- 实现 `run(requests, process_fn, write_fn) -> PipelineResult` 主方法:
- 预取消检查cancel_token 已取消则立即返回空结果)
- 创建 processing_queuemaxsize=queue_size和 write_queuemaxsize=queue_size*2
- 启动 N 个 worker 线程(`_process_worker`)和 1 个 writer 线程(`_write_worker`
- 主线程执行 `_request_loop`:串行发送请求、限流等待、取消检查、背压阻塞
- 发送 SENTINEL 通知线程退出join 等待完成
- 计算最终 statusSUCCESS/PARTIAL/CANCELLED/FAILED
- _需求: 1.1, 1.2, 1.6, 2.1, 2.2, 2.4, 2.7, 2.8, 3.2, 3.7, 8.3_
- [x] 3.3 实现 `_request_loop` 请求调度逻辑
- 遍历 requests 迭代器,逐个发送 API 请求
- 每个请求完成后记录耗时、状态码、endpoint 到日志
- 将响应数据 put 到 processing_queue满时阻塞 = 背压)
- 请求间调用 rate_limiter.wait(cancel_event),被取消则 break
- 实现连续失败计数:成功重置为 0失败 +1超过 max_consecutive_failures 则中断
- _需求: 1.2, 1.7, 3.2, 8.1, 8.3, 9.1, 9.5_
- [x] 3.4 实现 `_process_worker` 处理线程逻辑
- 从 processing_queue 消费数据,调用 process_fn 处理
- 处理结果 put 到 write_queue
- 单条记录处理异常时捕获、记录错误、标记失败、继续处理
- 收到 SENTINEL 时退出
- _需求: 2.1, 2.2, 9.2_
- [x] 3.5 实现 `_write_worker` 写入线程逻辑
- 从 write_queue 消费数据,累积到 batch_size 或等待 batch_timeout 后调用 write_fn 批量写入
- 写入失败时回滚当前批次事务、记录错误、标记失败、继续处理后续批次
- 队列积压超过 queue_size*2 时记录警告日志
- 收到 SENTINEL 时将剩余数据 flush 写入后退出
- _需求: 2.4, 2.5, 2.6, 8.4, 9.3, 9.6_
- [x] 3.6 编写 UnifiedPipeline 属性测试
- **Property 1: 请求严格串行** — 用 FakeAPI 记录时间戳,验证每个请求发送时间 > 上一个响应完成时间
- **Property 5: 管道完成语义** — 生成随机记录集,验证 total_fetched == total_inserted + total_updated + total_skipped
- **Property 6: WriteWorker 批量大小约束** — 生成随机 batch_size 和记录流,验证每次 write_fn 调用的记录数 <= batch_size
- **Property 8: 取消后已入队数据不丢失** — 生成随机请求序列 + 随机取消时刻,验证已入队数据全部处理和写入
- **Property 11: 单项失败不中断整体** — 生成含随机失败的请求序列,验证后续项目继续处理
- **Property 12: 连续失败触发中断** — 生成连续失败序列 + 随机阈值,验证超过阈值时中断
- **Property 13: 写入失败回滚当前批次** — 生成含随机写入失败的批次,验证回滚且后续批次不受影响
- **Property 14: 结果统计完整性** — 验证各计数字段的一致性关系
- 测试文件:`tests/test_pipeline_properties.py`
- **验证: 需求 1.2, 1.6, 2.5, 2.7, 3.2, 3.3, 3.4, 3.5, 6.6, 8.2, 9.1, 9.2, 9.3, 9.4, 9.5**
- [x] 3.7 编写 UnifiedPipeline 单元测试
- 测试文件:`apps/etl/connectors/feiqiu/tests/unit/test_unified_pipeline.py`
- 使用 FakeAPI + FakeDB 端到端测试:正常流程、空请求、预取消、背压触发
- _需求: 2.7, 3.7, 8.1, 8.3_
- [x] 4. 检查点 — 核心引擎验证
- 确保所有测试通过ask the user if questions arise.
- [x] 5. BaseOdsTask 改造与 ODS 任务迁移
- [x] 5.1 扩展 `OdsTaskSpec` 数据类,新增 Detail_Mode 可选字段
-`apps/etl/connectors/feiqiu/tasks/ods/ods_tasks.py` 中为 `OdsTaskSpec` 新增:`detail_endpoint``detail_param_builder``detail_target_table``detail_data_path``detail_list_key``detail_id_column`
- 所有新增字段默认值为 `None`,不影响现有 21 个任务的 OdsTaskSpec 实例
- _需求: 6.2, 6.3_
- [x] 5.2 改造 `BaseOdsTask.execute()` 方法,嵌入 UnifiedPipeline
-`execute()` 内部构建 `PipelineConfig.from_app_config(self.config, spec.code)`
- 将现有分页请求逻辑封装为 `_build_requests()``Iterable[PipelineRequest]`
- 将现有字段提取/hash 计算封装为 `_build_process_fn()``Callable`
- 将现有 `_insert_records_schema_aware` 封装为 `_build_write_fn()``Callable`
- 调用 `pipeline.run(requests, process_fn, write_fn)` 替代现有同步循环
- 保留快照软删除(`_mark_missing_as_deleted`、endpoint_routing、元数据写入source_file、source_endpoint、fetched_at
- 保留 TaskExecutor 调用接口不变(`task.execute(cursor_data)` 签名不变)
- _需求: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7_
- [x] 5.3 实现 Detail_Mode 详情拉取逻辑
-`BaseOdsTask` 中实现 `_build_detail_requests()` 方法:从已写入 ODS 的记录中提取 ID 列表,生成 `PipelineRequest(is_detail=True)` 序列
- 详情请求通过同一个 UnifiedPipeline 的 RequestScheduler 排队,遵循相同限流规则
- 单个详情请求失败时记录错误日志(含 ID 和错误信息),继续处理下一个
- 在 PipelineResult 中填充 detail_success/detail_failure/detail_skipped 统计
- _需求: 6.1, 6.4, 6.5, 6.6_
- [x] 5.4 编写 Detail_Mode 属性测试
- **Property 10: Detail_Mode 可选性** — 生成有/无 detail_endpoint 的 OdsTaskSpec验证无配置时跳过详情阶段、有配置时执行详情拉取且遵循限流
- 测试文件:`tests/test_detail_mode_properties.py`
- **验证: 需求 6.1, 6.3, 6.4**
- [x] 5.5 编写迁移等价属性测试
- **Property 9: 迁移前后输出等价** — 生成随机 API 响应序列,对比 UnifiedPipeline 与原同步串行实现的数据库写入结果inserted/updated/skipped 计数和记录内容)
- 测试文件:`tests/test_migration_properties.py`
- **验证: 需求 5.1, 5.3, 5.4, 5.5**
- [x] 5.6 编写 Detail_Mode 和迁移单元测试
- 测试文件:`apps/etl/connectors/feiqiu/tests/unit/test_detail_mode.py`
- 覆盖:列表→详情完整流程、无 detail_endpoint 跳过、详情单条失败不中断
- _需求: 6.1, 6.3, 6.5_
- [x] 6. 检查点 — ODS 迁移验证
- 确保所有测试通过ask the user if questions arise.
- [x] 7. DWD 层多线程优化
- [x] 7.1 改造 `apps/etl/connectors/feiqiu/tasks/dwd/dwd_load_task.py` 中的 `DwdLoadTask.load()` 方法
-`AppConfig` 读取 `dwd.parallel_workers`(默认 4
- 将现有串行 `for dwd_table, ods_table in TABLE_MAP` 循环改为 `concurrent.futures.ThreadPoolExecutor` 并行调度
- 每张表调用 `_process_single_table()` 在独立线程中执行,使用独立数据库连接和事务
- `_merge_dim_scd2()` 方法本身不改
- 单张表失败时捕获异常、记录错误日志(含表名和错误信息)、标记失败、继续处理其他表
- 所有表处理完成后返回汇总结果:成功表数、失败表数、每张表的 inserted/updated 计数
- _需求: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6_
- [x] 7.2 编写 DWD 并行属性测试
- **Property 16: DWD 并行与串行结果一致** — 生成随机表集合 + mock SCD2验证多线程并行执行的结果与串行逐表执行完全一致
- 测试文件:`tests/test_dwd_parallel_properties.py`
- **验证: 需求 7.1**
- [x] 7.3 编写 DWD 多线程单元测试
- 测试文件:`apps/etl/connectors/feiqiu/tests/unit/test_dwd_parallel.py`
- 覆盖mock SCD2 正常并行、单表失败不影响其他表、汇总结果正确
- _需求: 7.3, 7.4, 7.5_
- [x] 8. 可观测性与日志优化
- [x] 8.1 在 UnifiedPipeline 中集成运行时指标日志
-`_request_loop` 中定期记录当前请求队列深度、ProcessingPool 活跃线程数、WriteWorker 待写入队列深度、已完成请求数/总请求数
-`run()` 返回前计算并记录执行摘要:总耗时、请求/处理/写入各阶段耗时、各阶段记录数统计
- 与现有 `EtlTimer` 集成,在 FlowRunner 计时报告中体现各 ODS 任务的阶段耗时
- _需求: 8.1, 8.2, 8.5_
- [x] 8.2 创建 `apps/etl/connectors/feiqiu/utils/task_log_buffer.py`,实现 `TaskLogBuffer`
- 实现线程安全的 `log(level, message)` 方法,将日志条目缓冲到内存列表
- 实现 `flush()` 方法:按时间戳升序排列,一次性输出到父 logger添加 `[task_code]` 前缀
- 定义 `LogEntry` 数据类timestamp、level、task_code、message
- _需求: 10.1, 10.3, 10.4_
- [x] 8.3 编写日志缓冲区属性测试
- **Property 15: 日志缓冲区按任务隔离** — 生成多任务随机日志流,验证每个 TaskLogBuffer 的 flush() 仅包含该任务日志且按时间戳升序
- 测试文件:`tests/test_log_buffer_properties.py`
- **验证: 需求 10.1, 10.4**
- [x] 8.4 编写 TaskLogBuffer 单元测试
- 测试文件:`apps/etl/connectors/feiqiu/tests/unit/test_task_log_buffer.py`
- 覆盖:空缓冲区 flush、并发多线程写入、日志前缀格式
- _需求: 10.1, 10.3, 10.4_
- [x] 9. 检查点 — DWD 优化与日志验证
- 确保所有测试通过ask the user if questions arise.
- [x] 10. Admin-web 日志展示优化
- [x] 10.1 在 `apps/etl/connectors/feiqiu/` 中集成 TaskLogBuffer 到 BaseOdsTask 和 FlowRunner
- 在 BaseOdsTask.execute() 中创建 TaskLogBuffer 实例,替代直接 logger 调用
- 在 FlowRunner 中为每个任务分配独立的 TaskLogBuffer任务完成后调用 flush()
- 保证多线程环境下日志写入原子性(每条日志完整一行)
- _需求: 10.1, 10.3, 10.4_
- [x] 10.2 在 `apps/admin-web/` 中实现按任务分组的日志展示
- 在 ETL 执行结果页面中按任务分段展示日志:每个任务折叠为独立区块
- 展开后显示该任务的完整执行日志(时间戳、日志级别、消息内容)
- 支持按任务代码过滤和分组展示
- 顶部展示任务执行时间线概览(每个任务的开始/结束时间、状态),可点击跳转
- _需求: 10.2, 10.5, 10.6_
- [x] 11. CLI 参数扩展
- [x] 11.1 在 `apps/etl/connectors/feiqiu/cli/` 中添加 Pipeline 相关 CLI 参数
- 新增 `--pipeline-workers``--pipeline-batch-size``--pipeline-rate-min``--pipeline-rate-max` 参数
- 将 CLI 参数值注入到 AppConfig使其在 PipelineConfig.from_app_config() 中生效
- _需求: 4.6_
- [x] 12. 最终检查点 — 全量验证
- 确保所有测试通过ask the user if questions arise.
## 备注
- 标记 `*` 的子任务为可选,可跳过以加速 MVP 交付
- 每个任务引用了具体的需求编号,确保可追溯
- 检查点任务用于增量验证,确保每个阶段的正确性
- 属性测试验证通用正确性属性,单元测试验证具体示例和边界条件
- 属性测试位于 Monorepo 级 `tests/` 目录,单元测试位于 ETL 模块内 `tests/unit/`

View File

@@ -0,0 +1 @@
{"specId": "cd30e87b-ce7a-4ff5-8587-f5ae75013e58", "workflowType": "requirements-first", "specType": "feature"}

View File

@@ -0,0 +1,552 @@
# 技术设计文档H5 → 微信小程序批量迁移
## 1. 概述
本设计文档基于 33 条需求(`requirements.md`),为 17 个 H5 原型页面迁移到微信小程序提供技术实现方案。权威参考:`docs/prd/MIGRATION-PLAYBOOK.md`
核心约束:
- 纯前端迁移,不涉及后端 API 或数据库变更
- 输入物分两批:第一批(结构迁移 Step 1-5、第二批像素精调 Step 6-7
- 迁移粒度:以"屏/交互态"为最小单位,非整页
## 2. 架构总览
### 2.1 目录结构(现有 + 新增)
```
apps/miniprogram/miniprogram/
├── app.json / app.ts / app.wxss # 全局配置与样式
├── assets/icons/ # SVG 图标(已有 + 新导出)
├── components/ # 共享组件
│ ├── ai-float-button/ # ✅ 已有
│ ├── board-tab-bar/ # ✅ 已有
│ ├── filter-dropdown/ # ✅ 已有
│ ├── heart-icon/ # ✅ 已有
│ ├── star-rating/ # ✅ 已有
│ ├── note-modal/ # ✅ 已有
│ ├── metric-card/ # ✅ 已有
│ ├── hobby-tag/ # ✅ 已有
│ ├── banner/ # ✅ 已有
│ └── dev-fab/ # ✅ 已有
├── pages/ # 页面目录17 个迁移目标)
│ ├── board-finance/ # A 批次 - 看板
│ ├── board-coach/
│ ├── board-customer/
│ ├── task-list/ # B 批次 - 核心
│ ├── my-profile/
│ ├── task-detail/ # C 批次 - 任务详情
│ ├── task-detail-callback/
│ ├── task-detail-priority/
│ ├── task-detail-relationship/
│ ├── coach-detail/ # D 批次 - 详情
│ ├── customer-detail/
│ ├── customer-service-records/
│ ├── performance/ # E 批次 - 绩效
│ ├── performance-records/
│ ├── chat/ # F 批次 - 对话
│ ├── chat-history/
│ └── notes/ # G 批次 - 其他
└── utils/ # 工具模块
├── ai-color.ts # 🆕 AI 图标随机配色
├── format.wxs # ✅ 已有 WXS 格式化
├── request.ts # ✅ 已有 网络请求
├── router.ts # ✅ 已有 路由工具
└── ... # 其他已有工具
```
### 2.2 页面四文件结构
每个页面输出标准四文件:
```
pages/<page>/
├── <page>.wxml # 视图模板
├── <page>.wxss # 样式
├── <page>.ts # 逻辑TypeScript
└── <page>.json # 页面配置usingComponents
```
## 3. 单页迁移工作流设计
### 3.1 工作流总览7 步 + 屏级粒度)
迁移以"屏/交互态"为最小工作单位,而非整页。每个页面的迁移流程:
```
Step 0: 页面分析(确认屏数、子页面、变种、工作量)
Step 1: 输入物冻结(第一批:结构材料)
Step 2: 迁移审计报告7 项审计,不写代码)
Step 3: 规则化转换(按屏逐个开发)
Step 4: 编译验证7 项检查)
Step 5: 结构还原验证9 项核对,按屏逐个验证)
── 第二批输入物补充(截图 + computed-styles──
Step 6: 像素级对比(按屏逐段对比 + 微调循环)
Step 7: 验收签收12 项清单)
```
### 3.2 Step 0页面分析新增步骤
在正式迁移前,先对目标页面做结构分析:
1. 打开 H5 原型截图 + 交互说明文档
2. 确认页面总长度(几个屏?)
3. 识别子页面/变种(如 task-detail 的 3 个主题色变体)
4. 列出所有交互态(弹窗、筛选展开、空状态等)
5. 输出工作量估算表:
```
| 单位 | 类型 | 描述 | 预估复杂度 |
|------|------|------|-----------|
| 屏-1 | 默认态首屏 | Banner + 筛选栏 + 第一板块 | 中 |
| 屏-2 | 默认态第二屏 | 第二~三板块 | 中 |
| 交互-1 | 筛选下拉 | 时间筛选 + 区域筛选 | 低 |
| 交互-2 | 指标弹窗 | 长按指标卡片 | 低 |
| ... | ... | ... | ... |
```
### 3.3 Step 3按屏逐个开发
规则化转换不是一次性写完整页,而是按屏/交互态逐个推进:
1. 先完成首屏结构 → 编译通过 → 截图粗看
2. 再完成第二屏 → 编译通过 → 截图粗看
3. 所有屏完成后 → 处理交互态(弹窗、筛选等)
4. 最后处理三态loading/empty/error
### 3.4 Step 5按屏逐个验证
结构还原验证同样按屏进行:
1. 截取小程序当前屏 → 与 H5 原型截图粗略比对
2. 9 项核对清单逐项确认
3. 通过 → 下一屏;未通过 → 修复后重新验证
4. 所有屏 + 所有交互态全部通过 → 进入 Step 6
### 3.5 差异率过大的处理策略
当 Step 6 像素对比差异率 > 15% 且多轮微调无法收敛时:
- 放弃修补,从零重写该页面(需求 2 第 5 条)
- 复杂 Banner 背景 → 导出为 SVG`<image>` 引用(需求 2 第 6 条)
- 复杂 Icon → 导出为 SVG`<image>` 引用(需求 2 第 7 条)
## 4. 样式转换系统设计
### 4.1 缩放公式
```
rpx = H5 px × 2 × 0.875
结果取偶数(向最近偶数取整)
```
特例:`borderRadius` 使用简单 ×2A/B 对比验证差异 < 0.02%)。
### 4.2 design-tokens 映射
全局设计令牌来自 `docs/h5_ui/design-tokens.json`,直接映射到 WXSS 变量:
| Token | 值 | 用途 |
|-------|-----|------|
| fontSize.xs | 22rpx | 辅助文字 |
| fontSize.sm | 24rpx | 次要文字 |
| fontSize.base | 28rpx | 正文 |
| fontSize.lg | 32rpx | 小标题 |
| fontSize.xl | 36rpx | 标题 |
| fontSize.2xl | 42rpx | 大标题 |
| borderRadius.sm | 8rpx | 小圆角 |
| borderRadius.md | 16rpx | 中圆角 |
| borderRadius.lg | 24rpx | 大圆角 |
| borderRadius.xl | 32rpx | 特大圆角 |
| borderRadius.3xl | 48rpx | 全圆角 |
颜色使用 `colors` 中定义的灰阶gray-1 ~ gray-13禁止使用 `#333``#666``#999` 等非标准灰色。
### 4.3 两阶段样式数据源
**结构迁移阶段Step 3**
1. H5 源码 Tailwind 类名 → 查速查表换算
2. design-tokens.json Token 值
3. 目测估算(必须标注 `/* 目测值,待校准 */`
**像素精调阶段Step 6**
1. computed-styles.json 精确 px 值(最高优先级)
2. H5 源码 Tailwind 类名
3. design-tokens.json
4. H5 截图目测(最低)
### 4.4 七维度核对
每个可见元素写 WXSS 时逐项确认:
1. font-size
2. font-weight
3. color
4. line-height必须显式写出
5. padding
6. margin / gap
7. border / border-radius
## 5. AI 图标配色系统设计
### 5.1 配色方案定义
6 种配色,每种 4 个 CSS 变量:
```typescript
// utils/ai-color.ts
const AI_COLOR_SCHEMES = {
red: { from: '#e74c3c', to: '#f39c9c', fromDeep: '#c0392b', toDeep: '#e74c3c' },
orange: { from: '#e67e22', to: '#f5c77e', fromDeep: '#ca6c17', toDeep: '#e67e22' },
yellow: { from: '#d4a017', to: '#f7dc6f', fromDeep: '#b8860b', toDeep: '#d4a017' },
blue: { from: '#2980b9', to: '#7ec8e3', fromDeep: '#1a5276', toDeep: '#2980b9' },
indigo: { from: '#667eea', to: '#a78bfa', fromDeep: '#4a5fc7', toDeep: '#667eea' },
purple: { from: '#764ba2', to: '#c084fc', fromDeep: '#5b3080', toDeep: '#764ba2' },
};
```
### 5.2 小程序实现方案
H5 通过 DOM `querySelectorAll` + `classList.add` 实现随机配色。小程序无 DOM API改用 `setData` + 条件样式:
```typescript
// 页面 onLoad 中调用
import { getRandomAiColor } from '../../utils/ai-color';
Page({
data: {
aiColorClass: '', // 'ai-color-red' | 'ai-color-orange' | ...
aiColorVars: {}, // CSS 变量值对象
},
onLoad() {
const color = getRandomAiColor();
this.setData({
aiColorClass: color.className,
aiColorVars: color.vars,
});
},
});
```
```xml
<!-- WXML 中使用 -->
<view class="ai-inline-icon {{aiColorClass}}">
<image src="/assets/icons/ai-robot-sm.svg" mode="aspectFit" />
</view>
<view class="ai-title-badge {{aiColorClass}}">
<view class="ai-title-badge-icon">
<image src="/assets/icons/ai-robot.svg" mode="aspectFit" />
</view>
<text>AI 推荐</text>
</view>
```
### 5.3 两个系列的 WXSS 实现
**ai-inline-icon**行首小图标28rpx
- 渐变背景 + 白色机器人 SVG
- 微光扫过动画12s 周期 `ai-shimmer`
- 尺寸28rpx × 28rpxH5 16px × 2 × 0.875 ≈ 28
**ai-title-badge**(标题行右侧标识):
- 浅色背景 + 主题色文字 + 主题色边框
- 呼吸脉冲动画3s 周期 `ai-pulse`
- 高光扫过动画14s 周期 `ai-shimmer`
### 5.4 ai-float-button 排除
`ai-float-button` 组件已有固定渐变动画(`#667eea → #764ba2 → #f093fb → #f5576c`),不参与页面级随机配色。无需修改。
### 5.5 机器人 SVG 复用
- 大系列ai-title-badge复用已有 `assets/icons/ai-robot.svg`
- 小系列ai-inline-icon从 H5 源码导出白色填充版本,保存为 `assets/icons/ai-robot-sm.svg`
## 6. 共享组件设计
### 6.1 已有组件(直接复用)
| 组件 | 路径 | 用途 | 使用页面 |
|------|------|------|---------|
| ai-float-button | components/ai-float-button/ | AI 悬浮按钮 | 所有业务页面 |
| board-tab-bar | components/board-tab-bar/ | 自定义底部导航 | board-coach, board-customer |
| filter-dropdown | components/filter-dropdown/ | 筛选下拉面板 | board-finance/coach/customer |
| heart-icon | components/heart-icon/ | 心形评分 | board-customer |
| star-rating | components/star-rating/ | 星级评价 | notes |
| note-modal | components/note-modal/ | 备注弹窗 | task-list/detail, coach-detail |
| metric-card | components/metric-card/ | 指标卡片 | board-finance, performance |
| hobby-tag | components/hobby-tag/ | 爱好标签 | board-customer, customer-detail |
| banner | components/banner/ | 顶部 Banner | task-list, performance |
| dev-fab | components/dev-fab/ | 开发调试按钮 | 所有页面(开发环境) |
### 6.2 组件注册规范
每个页面的 `.json` 文件中注册所需组件:
```json
{
"usingComponents": {
"t-button": "tdesign-miniprogram/button/button",
"t-icon": "tdesign-miniprogram/icon/icon",
"t-loading": "tdesign-miniprogram/loading/loading",
"ai-float-button": "/components/ai-float-button/ai-float-button",
"filter-dropdown": "/components/filter-dropdown/filter-dropdown"
}
}
```
## 7. 事件与路由转换设计
### 7.1 事件映射表
| H5 | 小程序 | 说明 |
|----|--------|------|
| `onclick="fn()"` | `bindtap="fn"` | 基础点击 |
| `onclick="fn(id)"` | `data-id="{{id}}" bindtap="fn"` | dataset 传参 |
| `event.target.value` | `e.detail.value` | 表单取值 |
| `event.target.dataset` | `e.currentTarget.dataset` | dataset 取值 |
| `event.preventDefault()` | `catchtap` | 阻止冒泡 |
| `classList.toggle` | `setData` + 条件 class | 样式切换 |
| `innerHTML` | `setData` + WXML 绑定 | 视图更新 |
| `history.back()` | `wx.navigateBack()` | 返回 |
| `localStorage` | `wx.setStorageSync` | 本地存储 |
| `alert()/confirm()` | `wx.showToast()/wx.showModal()` | 弹窗 |
| `longpress` | `bindlongpress` | 长按 |
### 7.2 路由规则
| 目标页面类型 | API | 示例 |
|-------------|-----|------|
| TabBar 页面 | `wx.switchTab` | task-list, board-finance, my-profile |
| 普通页面 | `wx.navigateTo` | task-detail, coach-detail, chat |
| 重定向 | `wx.redirectTo` | 登录后跳转 |
| 返回 | `wx.navigateBack` | 详情页返回 |
| 重启 | `wx.reLaunch` | 切换身份 |
路径规则:以 `/` 开头,不带 `.wxml` 后缀。
## 8. 弹窗与 z-index 分层设计
### 8.1 全局 z-index 分层
```
10-29 sticky 元素Tab 栏 20, 筛选栏 15
30 AI 悬浮按钮
50 底部固定操作栏
100 自定义底部导航栏board-tab-bar
999 遮罩层
1000 弹窗内容
9999 Toast / Loading
```
### 8.2 弹窗实现模式
所有弹窗遵循统一模式:
- 同一时刻只允许一个弹窗打开(互斥)
- 遮罩 `bindtap` 关闭,内容区 `catchtap` 防穿透
- 背景滚动锁定:`catchtouchmove` 在遮罩层
- 底部弹出类添加 `padding-bottom: env(safe-area-inset-bottom)`
- 动画统一 200-220ms + `ease`
## 9. 三态处理设计
每个页面统一处理 4 种状态:
```xml
<!-- 通用三态模板 -->
<view wx:if="{{pageState === 'loading'}}">
<t-loading text="加载中..." />
</view>
<view wx:elif="{{pageState === 'error'}}">
<view class="error-state">
<text>加载失败,请点击重试</text>
<t-button bindtap="onRetry">重试</t-button>
</view>
</view>
<view wx:elif="{{pageState === 'empty'}}">
<text class="empty-text">{{emptyText}}</text>
</view>
<view wx:else>
<!-- 正常内容 -->
</view>
```
各页面空状态文案见需求 14。
## 10. 像素对比工具链设计
### 10.1 工具链流程
```
H5 截图DPR=3, 1290px 宽)
MP 截图DPR=1.5, 645px×2 缩放 → 1290px
pixelmatch 逐像素对比
按 150px 条带分析差异密度
定位差异区域 → WXSS 微调 → 循环
```
### 10.2 逐段对比v2 方案)
长页面使用 `scripts/ops/anchor_compare.py`
```bash
# 提取 H5 锚点 + 截图
python scripts/ops/anchor_compare.py extract-h5 <page>
# 生成 MP 截图指令
python scripts/ops/anchor_compare.py mp-inst <page>
# 执行 MP 截图(通过微信开发者工具 MCP
# 逐段配对 + 对比
python scripts/ops/anchor_compare.py compare <page>
```
### 10.3 scroll-view 页面截图
使用 `scroll-into-view` 模式:
1. `page.setData({ scrollIntoView: '' })` — 清空
2. `page.setData({ scrollIntoView: '<section-id>' })` — 设目标
3. 等待 1000ms → 截图
### 10.4 达标标准
- 前半屏差异率 < 5%:优秀
- 前半屏差异率 ≤ 10%:达标
- 前半屏差异率 > 15% 且无法收敛:触发重写
## 11. task-detail 变体策略
### 11.1 实现方式
1. 先完成 task-detail 主页面的完整迁移和验收
2. 复制 task-detail 四文件到变体目录
3. 替换主题色变量banner 背景色、按钮配色)
4. 保持数据结构和布局完全一致
### 11.2 变体清单
| 变体 | 差异点 |
|------|--------|
| task-detail-callback | banner 背景色 + 按钮配色(对照 H5 原型校准) |
| task-detail-priority | banner 背景色 + 按钮配色(对照 H5 原型校准) |
| task-detail-relationship | banner 背景色 + 按钮配色(对照 H5 原型校准) |
## 12. 认证与联调设计
### 12.1 认证守卫
每个业务页面 `onLoad` 检查登录态:
```typescript
onLoad() {
const token = wx.getStorageSync('token');
if (!token) {
wx.redirectTo({ url: '/pages/login/login' });
return;
}
// 正常加载逻辑
}
```
### 12.2 开发联调
- `utils/request.ts``BASE_URL` 指向 `http://localhost:8000`
- 后端 `WX_DEV_MODE=true` 支持 `/api/xcx/dev-login` Mock 登录
- Storage + header token 维持登录态
## 13. 不支持的 CSS 特性替代方案
| H5 特性 | 小程序替代 |
|---------|-----------|
| `backdrop-filter: blur()` | `background: rgba(255,255,255,0.95)` |
| `*` 通配符选择器 | 逐个元素设置 |
| `filter: blur()` | `radial-gradient` 模拟 |
| `url("data:image/svg+xml,...")` | CSS 渐变模拟或导出 PNG/base64 |
| `::before/::after`(复杂场景) | 额外 `<view>` 模拟 |
直接支持的特性无需替代CSS 变量 `var()``linear-gradient``animation`/`@keyframes``transition`
## 14. 批次执行顺序与依赖
```
A-看板board-finance → board-coach → board-customer
↓ 共享组件验证完毕
B-核心task-list → my-profile
C-任务task-detail → 3 个变体)
D-详情coach-detail → customer-detail → customer-service-records
E-绩效performance → performance-records
F-对话chat → chat-history
G-其他notes
```
A 批次优先验证共享组件filter-dropdown、board-tab-bar、metric-card在实际页面中的表现为后续批次建立基线。
## 15. 产出物与中间生成物归档
迁移过程中会产生大量截图、diff 图、逐段对比图等中间文件。所有生成物必须按类型分目录存放,禁止散放在项目根目录或临时位置。
### 15.1 目录结构
```
docs/h5_ui/
├── screenshots/ # H5 原型截图(输入物,已有)
│ ├── <page>.png # 默认态截图
│ └── <page>--<state>.png # 交互态截图
├── mp-screenshots/ # 🆕 小程序截图(迁移过程生成)
│ ├── <page>/ # 按页面分子目录
│ │ ├── <page>.png # 默认态全屏截图
│ │ ├── <page>--<state>.png # 交互态截图
│ │ └── seg-<N>-<section>.png # 逐段截图anchor_compare 生成)
│ └── ...
├── diffs/ # 🆕 像素对比结果(迁移过程生成)
│ ├── <page>/ # 按页面分子目录
│ │ ├── diff-<page>.png # 全屏 diff 图
│ │ ├── diff-seg-<N>-<section>.png # 逐段 diff 图
│ │ └── report.md # 该页面的对比结果摘要(差异率、问题区域)
│ └── ...
├── h5-segments/ # 🆕 H5 逐段截图anchor_compare 生成)
│ ├── <page>/
│ │ └── seg-<N>-<section>.png
│ └── ...
└── ...
```
### 15.2 归档规则
| 生成物类型 | 目标目录 | 命名规则 | 说明 |
|-----------|---------|---------|------|
| H5 原型截图 | `docs/h5_ui/screenshots/` | `<page>.png` / `<page>--<state>.png` | 输入物,已有,不动 |
| MP 全屏截图 | `docs/h5_ui/mp-screenshots/<page>/` | `<page>.png` / `<page>--<state>.png` | 每轮对比更新覆盖 |
| MP 逐段截图 | `docs/h5_ui/mp-screenshots/<page>/` | `seg-<N>-<section>.png` | anchor_compare 生成 |
| H5 逐段截图 | `docs/h5_ui/h5-segments/<page>/` | `seg-<N>-<section>.png` | anchor_compare 生成 |
| 全屏 diff 图 | `docs/h5_ui/diffs/<page>/` | `diff-<page>.png` | pixelmatch 输出 |
| 逐段 diff 图 | `docs/h5_ui/diffs/<page>/` | `diff-seg-<N>-<section>.png` | pixelmatch 输出 |
| 对比报告 | `docs/h5_ui/diffs/<page>/` | `report.md` | 差异率 + 问题区域摘要 |
| 新导出 SVG | `assets/icons/` | `icon-<用途>.svg` / `logo-<名称>.svg` | 小程序工程内 |
| 图标映射更新 | `docs/h5_ui/icon-mapping.md` | — | 追加新条目 |
| 小程序页面代码 | `apps/miniprogram/miniprogram/pages/<page>/` | 四文件组合 | 最终交付物 |
### 15.3 管理规则
1. 按页面分子目录MP 截图、H5 逐段截图、diff 图均按 `<page>/` 分目录,避免数百张图片平铺
2. 每轮覆盖更新像素精调循环中每轮新截图覆盖上一轮同名文件不保留历史版本git 有历史)
3. 逐段截图编号连续:`seg-0``seg-1``seg-2`...,与 anchor_compare.py 输出一致
4. report.md 格式统一:每个页面的 `diffs/<page>/report.md` 记录最终差异率和遗留问题,作为验收依据
5. .gitignore 不排除:这些中间文件需要入库,便于团队复查和回溯
6. H5 原型截图目录只读:`docs/h5_ui/screenshots/` 是输入物,迁移过程中不往里写 MP 截图或 diff 图

View File

@@ -0,0 +1,424 @@
# 需求文档H5 → 微信小程序批量迁移
## 简介
`docs/h5_ui/pages/` 下 17 个 HTML 原型页面迁移为原生微信小程序页面WXML/WXSS/TS/JSON。迁移范围覆盖 7 个批次A-看板、B-核心、C-任务、D-详情、E-绩效、F-对话、G-其他),每页需走完 7 步标准流程(输入物冻结 → 迁移审计 → 规则化转换 → 编译验证 → 结构还原验证 → 像素级对比 → 验收签收)。本工程为纯前端迁移,不涉及后端 API 开发或数据库变更。
权威参考文档:`docs/prd/MIGRATION-PLAYBOOK.md`(唯一迁移执行手册)。
## 术语表
- **Playbook**`docs/prd/MIGRATION-PLAYBOOK.md`H5→小程序迁移的唯一权威执行手册
- **H5_原型**`docs/h5_ui/pages/<page>.html`,基于 Tailwind CDN + 内联 SVG + 原生 JS 的单文件 HTML 原型
- **小程序页面**`apps/miniprogram/miniprogram/pages/<page>/` 下的 `.wxml`/`.wxss`/`.ts`/`.json` 四文件组合
- **WXML**:微信小程序标记语言,替代 HTML
- **WXSS**:微信小程序样式表,替代 CSS支持 rpx 单位
- **rpx**微信小程序响应式像素单位750rpx = 屏幕宽度
- **缩放公式**`rpx = H5 px × 2 × 0.875`,结果取偶数(向最近偶数取整)
- **design-tokens**`docs/h5_ui/design-tokens.json`,全局颜色/间距/字号/圆角/阴影定义
- **computed-styles**`docs/h5_ui/computed-styles.json`H5 元素的浏览器计算样式精确 px 值
- **交互说明**`docs/h5_ui/interactions/<page>.md`,每页的状态变量 + 操作响应 + 状态枚举
- **icon-mapping**`docs/h5_ui/icon-mapping.md`H5 图标到小程序实现的映射表
- **TDesign**:腾讯开源的微信小程序 UI 组件库
- **结构还原验证**:对照 H5 原型截图和交互说明,逐项确认区域划分、元素层级、文本内容、图标、导航、弹窗、滚动、三态占位等 9 项结构要素
- **像素级对比**:使用工具链(截图 → 尺寸统一 → pixelmatch → 差异分析)量化小程序与 H5 原型的视觉差异百分比
- **七维度核对**:每个可见元素写 WXSS 时必须逐一确认的 7 个属性维度(字号、字重、文字颜色、行高、内间距、外间距、边框与圆角)
- **三态处理**:每个页面必须处理的 loading / empty / error / normal 四种状态
- **TabBar_页面**task-list、board-finance、my-profile使用 `wx.switchTab` 跳转
- **迁移审计报告**Step 2 输出的 7 项审计内容页面结构、CSS 风险点、关键样式映射、图标处理、交互映射、外部依赖、缺失信息)
- **差异率**pixelmatch 对比输出的像素差异百分比,前半屏 < 5% 为优秀,≤ 10% 为达标
## 需求
### 需求 1迁移范围与批次管理
**用户故事:** 作为项目管理者,我希望 17 个页面按批次有序迁移,以便控制进度和质量。
#### 验收标准
1. THE 迁移工程 SHALL 覆盖以下 17 个页面,按 7 个批次组织A-看板board-finance、board-coach、board-customer、B-核心task-list、my-profile、C-任务task-detail、task-detail-callback、task-detail-priority、task-detail-relationship、D-详情coach-detail、customer-detail、customer-service-records、E-绩效performance、performance-records、F-对话chat、chat-history、G-其他notes
2. THE 迁移工程 SHALL 排除以下页面login、no-permission、reviewing、apply用户指定不迁移、home-settings无交互说明、ai-icon-demo无截图和交互说明
3. WHEN 迁移 board-coach、board-customer、board-finance、notes 页面时THE 迁移流程 SHALL 对已有历史实现按标准流程重新审计、对比 H5 原型、差异修复、像素级验收
4. WHEN 迁移其余 13 个页面时THE 迁移流程 SHALL 从零开始执行全流程迁移
### 需求 27 步标准迁移流程
**用户故事:** 作为迁移执行者,我希望每个页面都走完标准化的 7 步流程,以确保迁移质量一致且可追溯。
#### 验收标准
1. THE 迁移流程 SHALL 对每个页面依次执行以下 7 步Step 1 输入物冻结、Step 2 迁移审计、Step 3 规则化转换、Step 4 编译验证、Step 5 结构还原验证、Step 6 像素级视觉对比、Step 7 验收签收
2. THE 迁移流程 SHALL 禁止跳过任何步骤,后续步骤的执行以前置步骤通过为前提
3. WHEN Step 5 结构还原验证未全部通过时THE 迁移流程 SHALL 禁止进入 Step 6 像素级对比
4. WHEN 验收未通过时THE 迁移流程 SHALL 按问题类型回退到对应步骤结构错误→Step 5、样式偏差→Step 6、交互缺陷→Step 3、编译报错→Step 4、真机差异→Step 6并从回退步骤开始重新往下走完所有后续步骤
5. WHEN Step 6 像素级对比的差异率过大(前半屏 > 15%且多轮微调无法收敛时THE 迁移流程 SHALL 放弃修补,直接按后续规则(需求 5-9、需求 21从零重写该小程序页面
6. WHEN H5 原型页面包含渐变复杂、条纹复杂的 Banner 背景时THE 迁移流程 SHALL 将该背景生成为 SVG 文件,存放到 `assets/icons/` 目录,在 WXML 中通过 `<image src="/assets/icons/bg-<name>.svg">` 引用
7. WHEN H5 原型页面包含精美复杂的 Icon 或装饰性按钮(非标准 TDesign 图标可覆盖THE 迁移流程 SHALL 将其生成为 SVG 文件导出,通过 `<image>` 引用,而非尝试用 WXSS 手动还原
### 需求 3输入物分批提供
**用户故事:** 作为迁移执行者,我希望输入物按阶段分批提供——结构迁移阶段只需结构和行为材料,像素精调阶段再补充视觉校验材料——以避免因截图等材料未就绪而阻塞结构迁移工作。
#### 验收标准
1. THE 输入物 SHALL 分为两批提供:
- **第一批结构迁移Step 1-5**规则层Playbook→ 全局资源层design-tokens.json、icon-mapping.md→ 页面源码层H5 HTML `docs/h5_ui/pages/<page>.html`、自定义 CSS `docs/h5_ui/css/<page>.css`(如有))→ 行为层(`docs/h5_ui/interactions/<page>.md`
- **第二批像素精调Step 6-7**computed-styles.json 中该页面的精确 px 值、H5 默认态截图 `docs/h5_ui/screenshots/<page>.png`DPR=3, 1290px 宽)、交互态截图 `docs/h5_ui/screenshots/<page>--<state>.png`
2. WHEN 开始结构迁移Step 1-5THE 迁移流程 SHALL 仅要求第一批输入物齐全;第二批输入物缺失不阻塞结构迁移
3. WHEN 进入像素精调Step 6THE 迁移流程 SHALL 要求第二批输入物齐全IF 第二批输入物缺失THEN 标记缺失项并暂停像素精调,不猜测
4. IF 第一批中任何必需输入物缺失THEN THE 迁移流程 SHALL 标记缺失项并暂停该页面迁移,禁止猜测缺失内容
5. THE 结构迁移阶段的样式转换 SHALL 基于 H5 源码中的 Tailwind 类名和 design-tokens.json 进行换算,不依赖 computed-styles.jsoncomputed-styles.json 仅在像素精调阶段用于精确校准
### 需求 4迁移审计报告Step 2
**用户故事:** 作为迁移执行者,我希望在写代码前先输出审计报告,以识别风险点和制定转换策略。
#### 验收标准
1. WHEN 输入物冻结完成后THE 迁移流程 SHALL 输出《迁移审计报告》,包含 7 项A.页面结构(区域划分+组件化边界、B.CSS 风险点(不支持特性+替代方案、C.关键样式映射Tailwind→computed→WXSS 七维度核对、D.图标处理(每个 SVG 的处理决策、E.交互映射H5 DOM→setData+事件绑定、F.外部依赖CDN 本地化方案、G.缺失信息(需补充材料清单)
2. THE 迁移审计报告 SHALL 在写任何转换代码之前完成输出
3. THE 迁移审计报告中每个 CSS 风险点 SHALL 包含原因、影响、处理方案、验收方式四项说明
### 需求 5标签与结构转换规则
**用户故事:** 作为迁移执行者,我希望有明确的 HTML→WXML 标签映射规则,以确保转换的一致性和正确性。
#### 验收标准
1. THE 转换引擎 SHALL 按以下映射执行标签转换:`<div>``<view>``<span>/<p>``<text>``<img>``<image mode="">`(必须指定 mode 和宽高)、`<svg>``<image src="xx.svg">``<t-icon>``<button>``<t-button>``<input>``<t-input>``<textarea>``<t-textarea>``<select>``<t-picker>``<a>``bindtap`+`wx.navigateTo``<ul>/<li>``<view wx:for>`(必须加 `wx:key`)、`<table>``<view>` 手动布局、scroll 容器→`<scroll-view>`(必须设固定高度)
2. THE 转换引擎 SHALL 禁止在 WXML 中使用任何 HTML 标签
3. THE 转换引擎 SHALL 确保 `<text>` 内只嵌套 `<text>`,需要块级布局时外层使用 `<view>`
4. WHEN 列表渲染时THE 转换引擎 SHALL 为每个 `wx:for` 提供 `wx:key` 属性
### 需求 6样式转换与缩放规则
**用户故事:** 作为迁移执行者,我希望所有样式值都按统一公式换算,以确保像素级视觉还原。
#### 验收标准
1. THE 样式转换 SHALL 使用核心缩放公式 `rpx = H5 px × 2 × 0.875`,结果取偶数(向最近偶数取整)
2. THE 样式转换 SHALL 按以下数据源优先级取值,且区分两个阶段:
- **结构迁移阶段Step 3**H5 源码 Tailwind 类名(查速查表换算)→ design-tokens.json Token 值(颜色/圆角/阴影)→ 目测估算(必须标注 `/* 目测值,待校准 */`
- **像素精调阶段Step 6**computed-styles.json 精确 px 值(最高优先级,覆盖结构阶段的换算值)→ H5 源码 Tailwind 类名 → design-tokens.json → H5 截图目测估算(最低)
3. THE 样式转换 SHALL 对每个可见元素按七维度逐项核对字号font-size、字重font-weight、文字颜色color、行高line-height、内间距padding、外间距margin/gap、边框与圆角border/border-radius
4. THE 样式转换 SHALL 禁止使用不在 design-tokens.json 色阶表中的灰色值(禁止 `#333``#666``#999` 等非标准灰色)
5. THE 样式转换 SHALL 显式写出 `line-height` 值,禁止依赖小程序默认行高
6. THE 样式转换 SHALL 使用最小 2rpx 的边框宽度1rpx 在部分设备不显示)
7. THE 样式转换 SHALL 禁止不查任何数据源直接写"看起来差不多"的值
### 需求 7事件与路由转换规则
**用户故事:** 作为迁移执行者,我希望 H5 事件和路由逻辑有明确的小程序对应方案,以确保交互行为正确迁移。
#### 验收标准
1. THE 转换引擎 SHALL 按以下映射执行事件转换:`onclick="fn()"``bindtap="fn"``onclick="fn(id)"``data-id="{{id}}" bindtap="fn"`dataset 传参)、`event.target.value``e.detail.value``event.target.dataset``e.currentTarget.dataset``event.preventDefault()``catchtap``classList.toggle``setData`+条件 class 绑定、`innerHTML``setData`+WXML 数据绑定、`history.back()``wx.navigateBack()``localStorage``wx.setStorageSync``alert()/confirm()``wx.showToast()/wx.showModal()`
2. WHEN 跳转到 TabBar 页面task-list、board-finance、my-profileTHE 路由逻辑 SHALL 使用 `wx.switchTab`,禁止使用 `wx.navigateTo`
3. THE 路由逻辑 SHALL 确保所有路径以 `/` 开头且不带 `.wxml` 后缀
4. THE 转换引擎 SHALL 禁止使用 `addEventListener``document.getElementById` 等 DOM API所有视图更新通过 `this.setData()` 驱动
### 需求 8SVG 图标处理
**用户故事:** 作为迁移执行者,我希望每个 H5 页面中的 SVG 图标都有明确的处理决策,以避免图标缺失或显示异常。
#### 验收标准
1. WHEN H5 页面包含内联 SVG 时THE 图标处理流程 SHALL 按以下优先级决策TDesign 有语义等价图标→使用 `<t-icon>`、品牌/自定义图标→导出为 `apps/miniprogram/miniprogram/assets/icons/<name>.svg`
2. THE 图标处理流程 SHALL 按命名规则导出 SVG功能图标用 `icon-<用途>.svg`、Logo 类用 `logo-<名称>.svg`
3. THE 图标处理流程 SHALL 在导出 SVG 时保留原始 `viewBox``fill``path`
4. WHEN 在小程序中引用 SVG 时THE 图标引用 SHALL 使用 `<image src="/assets/icons/<name>.svg" mode="aspectFit" />`,并指定宽高
5. WHEN 迁移新页面导出新 SVG 时THE 图标处理流程 SHALL 将新导出的 SVG 追加到 icon-mapping.md 清单
### 需求 9不支持的 CSS 特性替代方案
**用户故事:** 作为迁移执行者,我希望所有小程序不支持的 CSS 特性都有明确的替代方案,以避免样式失效。
#### 验收标准
1. WHEN H5 使用 `backdrop-filter: blur()`THE 样式转换 SHALL 替换为 `background: rgba(255,255,255,0.95)` 半透明背景
2. WHEN H5 使用 `*` 通配符选择器时THE 样式转换 SHALL 逐个元素设置对应属性
3. WHEN H5 使用 `filter: blur()`THE 样式转换 SHALL 使用 `radial-gradient` 模拟扩散效果
4. WHEN H5 使用 `url("data:image/svg+xml,...")`THE 样式转换 SHALL 使用 CSS 渐变模拟或导出为 PNG/base64
5. WHEN H5 使用 `::before`/`::after` 伪元素且场景复杂时THE 样式转换 SHALL 使用额外 `<view>` 元素模拟
6. THE 样式转换 SHALL 确保 CSS 变量 `var()``linear-gradient``animation`/`@keyframes``transition` 直接迁移(小程序支持)
### 需求 10状态栏与安全区适配
**用户故事:** 作为用户,我希望小程序页面在各种机型(含刘海屏)上正确显示,内容不被遮挡。
#### 验收标准
1. WHEN 页面使用自定义导航栏(`navigationStyle: 'custom'`THE 页面 SHALL 通过 JS `wx.getSystemInfoSync().statusBarHeight` 获取状态栏高度,并设置 `padding-top`
2. THE 页面 SHALL 禁止使用 `env(safe-area-inset-top)` 获取顶部安全区(部分机型不生效),改用 JS 方案
3. WHEN 页面包含底部固定栏或底部弹窗时THE 页面 SHALL 添加 `padding-bottom: env(safe-area-inset-bottom)` 适配刘海屏底部
4. WHEN 页面为一屏布局不滚动THE 页面 SHALL 使用 `height: 100vh` + `box-sizing: border-box`,确保 padding-top 从 100vh 中扣除
### 需求 11长页面滚动与吸顶处理
**用户故事:** 作为用户,我希望长页面(超过一屏)能正常滚动,吸顶元素在滚动时保持固定位置。
#### 验收标准
1. THE 长页面 SHALL 优先使用页面自然滚动(`min-height: 100vh`),禁止用 `scroll-view` 包裹整个页面
2. WHEN 页面中某个区域需要独立滚动时(如 chat 页消息区THE 页面 SHALL 使用 `<scroll-view scroll-y>` 并设固定高度
3. THE 吸顶元素 SHALL 使用 `position: sticky`,且父容器不设 `overflow: hidden`
4. WHEN 多个 sticky 元素叠加时THE 页面 SHALL 确保 `z-index` 从上到下递减20→15→10
5. THE sticky 元素 SHALL 设置 `background-color`,防止内容穿透
6. WHEN 页面包含底部固定操作栏时THE 页面 SHALL 使用 `position: fixed` + `z-index: 50`,并在内容区末尾添加占位 `<view>` 防止内容被遮挡
7. WHERE 页面适用下拉刷新task-list、board-finance、board-coach、board-customer、notes、chat-historyTHE 页面 SHALL 启用下拉刷新功能
8. WHEN 页面包含 filter-bar筛选栏THE filter-bar SHALL 统一高度为 70 逻辑像素所有看板页面board-finance、board-coach、board-customer保持一致。此约束确保两端 sticky 区域高度一致(~116pxv2 逐段截图自然对齐。编辑小程序前端页面时须 check 并修正不符合此高度的 filter-bar。
### 需求 12交互弹窗与状态管理
**用户故事:** 作为用户,我希望所有弹窗、浮层、下拉面板的交互行为与 H5 原型一致,且不出现穿透、滚动异常等问题。
#### 验收标准
1. THE 弹窗系统 SHALL 遵循统一的 z-index 分层策略sticky 元素 10-29、AI 悬浮按钮 30、底部固定栏 50、自定义底部导航栏 100、遮罩 999、弹窗内容 1000、Toast/Loading 9999
2. THE 弹窗系统 SHALL 确保同一时刻只允许一个弹窗打开(互斥)
3. WHEN 弹窗打开时THE 弹窗 SHALL 使用 `catchtouchmove` 阻止背景页面滚动
4. THE 遮罩层 SHALL 使用 `bindtap` 关闭弹窗,弹窗内容区 SHALL 使用 `catchtap` 防止点击穿透
5. WHEN 弹窗为底部弹出类型时THE 弹窗 SHALL 添加 `padding-bottom: env(safe-area-inset-bottom)`
6. THE 弹窗动画 SHALL 统一使用 200-220ms 时长和 `ease` 缓动函数
7. WHEN 使用筛选下拉面板时THE 页面 SHALL 复用共享组件 `filter-dropdown`(规范见 Playbook 第八章 8.3
### 需求 13长按菜单交互
**用户故事:** 作为用户,我希望在 task-list 页面长按任务卡片时弹出操作菜单,且长按和点击手势互不干扰。
#### 验收标准
1. WHEN 用户长按任务卡片时THE task-list 页面 SHALL 使用 `bindlongpress` 触发长按菜单,菜单定位在触摸点附近
2. THE 长按菜单 SHALL 包含以下操作项:任务置底、问问助手、备注
3. THE 长按菜单 SHALL 使用黑底圆角样式(`rgba(0,0,0,0.85)` 背景、16rpx 圆角、白色文字)
4. WHEN 长按菜单显示时THE task-list 页面 SHALL 在 `onTaskTap` 中跳过跳转逻辑,防止长按后误触发点击跳转
5. WHEN 用户点击遮罩或菜单项时THE 长按菜单 SHALL 关闭
### 需求 14三态处理
**用户故事:** 作为用户,我希望每个页面在加载中、无数据、出错时都有明确的 UI 反馈,而不是空白页面。
#### 验收标准
1. THE 每个页面 SHALL 处理 loading、empty、error、normal 四种状态
2. WHEN 页面处于 loading 状态时THE 页面 SHALL 显示 `<t-loading>` 组件和"加载中..."文字
3. WHEN 页面处于 empty 状态时THE 页面 SHALL 显示对应的空状态文案task-list→"暂无任务"、board-coach→"暂无助教数据"、board-customer→"暂无客户数据"、board-finance→"暂无财务数据"、chat-history→"暂无对话记录"、notes→"暂无备注记录"、performance-records→"暂无业绩明细"、customer-service-records→"暂无服务记录"、其他→"暂无数据"
4. WHEN 页面处于 error 状态时THE 页面 SHALL 显示"加载失败,请点击重试"文字和重试按钮
5. WHEN 用户点击重试按钮时THE 页面 SHALL 重新加载数据
### 需求 15共享组件复用
**用户故事:** 作为迁移执行者,我希望跨页面复用的 UI 元素抽取为共享组件,以保持一致性并减少重复代码。
#### 验收标准
1. THE 迁移工程 SHALL 复用以下已有共享组件ai-float-buttonAI 悬浮按钮所有业务页面右下角、board-tab-bar自定义底部导航栏board-coach/board-customer 使用、filter-dropdown筛选下拉面板board-finance/coach/customer 使用、heart-icon心形评分图标board-customer 使用、dev-fab开发调试按钮
2. THE ai-float-button 组件 SHALL 在所有业务页面右下角显示,默认 bottom 220rpx使用固定渐变动画不参与页面级随机配色详见需求 32
3. THE board-tab-bar 组件 SHALL 用于非 TabBar 的看板子页面,高度 100rpx包含 safe-area 底部适配
4. THE filter-dropdown 组件 SHALL 实现全屏宽度面板 + 半透明遮罩 + 动态 top 计算
5. THE heart-icon 组件 SHALL 使用 TS `observers` 监听 `score` 属性变化计算 emojiWXS 不支持 emoji surrogate pair
### 需求 16TDesign 组件使用规范
**用户故事:** 作为迁移执行者,我希望 TDesign 组件的使用遵循统一规范,以避免样式冲突和事件绑定错误。
#### 验收标准
1. THE 迁移工程 SHALL 按以下替代关系使用 TDesign 组件:`<button>``<t-button>``<input>``<t-input>``<textarea>``<t-textarea>``<select>``<t-picker>`、SVG 图标→`<t-icon>`、加载动画→`<t-loading>`、确认弹窗→`<t-dialog>`、底部弹出→`<t-popup>`
2. WHEN TDesign 组件事件绑定时THE 迁移工程 SHALL 使用 `bind:change` 而非 `bindinput`
3. WHEN 需要覆盖 TDesign 组件样式时THE 迁移工程 SHALL 优先使用 CSS 变量,其次外部样式类,再次利用 `addGlobalClass`,最后使用 style 属性
4. WHEN 组件与 H5 原型差异过大时THE 迁移工程 SHALL 使用原生 view 实现而非强行定制 TDesign 组件
5. THE 迁移工程 SHALL 确保 `app.json` 中不包含 `"style": "v2"` 配置(会导致 TDesign 样式错乱)
6. WHEN 安装新 npm 包后THE 迁移工程 SHALL 在微信开发者工具中执行"构建 npm"
### 需求 17编译验证Step 4
**用户故事:** 作为迁移执行者,我希望每个页面转换后通过编译验证,以确保代码无语法错误和运行时问题。
#### 验收标准
1. WHEN 规则化转换完成后THE 编译验证 SHALL 检查以下 7 项WXML 编译无错误、WXSS 编译无警告(检查不支持的选择器)、控制台无 JS 运行时错误、图片加载无 404/500 错误(所有 `/assets/` 引用的文件必须存在)、组件注册无 "component not found" 警告、路由跳转无 "navigateTo:fail" 错误、TS 类型定义完整(`Page<IData>()` 中 data 所有字段有初始值)
2. THE 编译验证 SHALL 特别检查 WXML 中不包含 JS 方法调用(如 `.toFixed()`),需要格式化的逻辑使用 WXS 模块 `utils/format.wxs`
3. IF 编译验证发现错误THEN THE 迁移流程 SHALL 修复错误后重新验证,直到全部通过
### 需求 18结构还原验证Step 5
**用户故事:** 作为迁移执行者,我希望在像素对比前先确认页面结构与 H5 原型一致,以避免在结构错误的基础上做无意义的像素调整。
#### 验收标准
1. WHEN 编译验证通过后THE 结构还原验证 SHALL 逐项对照 H5 原型截图和交互说明,确认以下 9 项区域划分header/content/footer/tabbar 与 H5 一致)、元素层级(嵌套层级与 H5 DOM 结构对应)、列表/卡片数量Mock 数据条数与 H5 一致)、文本内容(标题/标签/按钮文案完全一致)、图标完整性(所有图标位置有对应实现)、导航结构(自定义导航栏/返回按钮/TabBar 行为正确)、弹窗/浮层所有弹窗能正常触发和关闭、滚动行为长页面可滚动、吸顶元素正确固定、三态占位loading/empty/error 三种状态有对应 UI 结构)
2. THE 结构还原验证 SHALL 9 项全部通过后才可进入 Step 6 像素级对比
3. IF 结构还原验证未通过THEN THE 迁移流程 SHALL 记录问题、修复、重新验证,循环直到全部通过
### 需求 19像素级视觉对比Step 6
**用户故事:** 作为迁移执行者,我希望通过工具链量化小程序与 H5 原型的视觉差异,以系统性地收敛渲染偏差。
#### 验收标准
1. WHEN 结构还原验证全部通过后THE 像素级对比 SHALL 按以下流程执行:截图(微信开发者工具)→ 尺寸统一H5 保持 1290px 宽MP ×2 缩放到 1290px→ pixelmatch 对比 → 差异分析(按 150px 条带分析差异密度)→ 定位差异区域 → WXSS 微调 → 循环
2. THE 像素级对比 SHALL 以前半屏差异率 < 5% 为优秀、≤ 10% 为达标的标准评估
3. THE 像素级对比 SHALL 每轮控制 2-5 处修改,避免一次改太多难以定位效果
4. THE 像素级对比 SHALL 优先使用 flex/盒模型的确定性方案,禁止使用"碰运气"的魔法数
5. THE 像素级对比 SHALL 确保 rpx 换算统一,禁止同一类间距混用 rpx 和 px
6. WHEN 需要对比交互态时THE 像素级对比 SHALL 对弹窗/筛选/空状态等交互态进行视觉检查(弹窗位置、遮罩透明度),交互态不要求像素精确
### 需求 20验收签收Step 7
**用户故事:** 作为项目管理者,我希望每个页面迁移完成后通过 12 项验收清单,以确保交付质量。
#### 验收标准
1. THE 验收签收 SHALL 逐项检查以下 12 项:编译零报错、默认态像素对比差异率 ≤ 10%、关键交互态截图齐全、87.5% 缩放一致(抽查 3 个关键元素)、颜色 Token 一致(使用 design-tokens.json 定义的颜色、TDesign 组件正确使用、共享组件复用、交互逻辑完整(对照 interactions/*.md、空/错误/加载态已处理、真机预览iOS + Android 各一台无异常)、导航正确(返回/跳转/TabBar 行为符合 PRD、认证守卫未登录自动跳转登录页
2. WHEN 验收未通过时THE 验收签收 SHALL 按问题严重度回退到对应步骤修复,修复后从回退步骤重新走完所有后续步骤
3. THE 验收签收 SHALL 确保每次返工修复后不引入新的编译错误(修 A 不坏 B
### 需求 21转换执行顺序
**用户故事:** 作为迁移执行者,我希望每个页面的代码转换按固定顺序执行,以确保依赖关系正确。
#### 验收标准
1. THE 规则化转换Step 3SHALL 按以下顺序执行:创建 4 文件骨架(.wxml/.wxss/.ts/.json→ .json 注册 usingComponentsTDesign + 自定义组件)→ 转换 WXML标签映射保持层级一致→ 转换 WXSSTailwind→手写 WXSS87.5% 缩放)→ 转换 TSDOM→setData事件→bindtap→ Mock 数据(贴近真实 API 格式,标记 TODO→ 三态处理loading/empty/normal/error
2. THE 每个页面 SHALL 输出到 `apps/miniprogram/miniprogram/pages/<page>/` 目录下
3. WHEN 页面需要新的 SVG 图标时THE 转换流程 SHALL 将 SVG 导出到 `apps/miniprogram/miniprogram/assets/icons/` 目录
### 需求 22task-detail 变体页面迁移策略
**用户故事:** 作为迁移执行者,我希望 task-detail 的 3 个主题色变体页面高效迁移,避免重复劳动。
#### 验收标准
1. THE 迁移流程 SHALL 先完成 task-detail 主页面的完整迁移和验收
2. WHEN task-detail 验收通过后THE 迁移流程 SHALL 通过复制 task-detail 并替换主题色变量的方式生成 task-detail-callback、task-detail-priority、task-detail-relationship 三个变体
3. THE 变体页面 SHALL 仅在以下方面与 task-detail 不同banner 背景色、按钮配色、页面主题色(对照各自 H5 原型截图校准色值)
4. THE 变体页面 SHALL 保持与 task-detail 完全相同的数据结构和页面布局
### 需求 23页面级功能需求 — A 批次(看板)
**用户故事:** 作为门店管理者,我希望在小程序中查看财务、助教、客户三个维度的看板数据,以便掌握门店经营状况。
#### 验收标准
1. THE board-finance 页面 SHALL 包含时间月份筛选9 选项+指定周期,最大 366 天)+ 区域筛选(全部/大厅ABC/麻将房/团建房/具体台桌)、财务汇总行(实际收入/支出/净利润三列)、四个板块(营业数据/收入构成/支出构成/利润构成,每行 3 个指标卡片)、长按指标卡片启动助手对话、指标说明弹窗、目录导航面板
2. THE board-coach 页面 SHALL 包含排序维度筛选7 选项)+ 擅长项目筛选 + 时间月份筛选、助教卡片列表(姓名+等级+擅长项目 | 关系最好客户前三 | 排序维度数值)、点击卡片跳转助教详情页
3. THE board-customer 页面 SHALL 包含客户类型筛选8 维度)+ 偏爱项目筛选、客户卡片列表(名称+等级+VIP标识 | 最喜欢助教前三 | 核心指标+最近到店、heart-icon 组件显示、最专一客户表格、点击卡片跳转客户详情页
4. THE board-coach 和 board-customer 页面 SHALL 使用 board-tab-bar 共享组件作为底部导航栏
5. THE 三个看板页面 SHALL 使用 filter-dropdown 共享组件实现筛选下拉
### 需求 24页面级功能需求 — B 批次(核心)
**用户故事:** 作为助教用户,我希望在小程序首页查看任务列表和个人信息,以便快速了解待办事项和管理账户。
#### 验收标准
1. THE task-list 页面 SHALL 包含:顶部自定义导航栏(标题"任务"无返回按钮、Banner 区(用户名+身份+业绩概览+预计收入,整块可点击跳转业绩详情页)、任务列表(单列,按紧急程度排序:红→橙→粉→蓝,不分组)、单条卡片(类型标签带颜色+客户姓名+右箭头+补充信息行)、长按卡片弹出黑底浮层菜单(任务置底/问问助手/备注)、备注弹窗、空状态"暂无任务"
2. THE my-profile 页面 SHALL 包含:顶部用户信息区、列表菜单(备注记录/助手对话记录/首页设置/退出账号)
3. THE task-list 和 my-profile 页面 SHALL 作为 TabBar 页面,使用 `wx.switchTab` 跳转
### 需求 25页面级功能需求 — C 批次(任务详情)
**用户故事:** 作为助教用户,我希望查看任务详情(含客户信息、消费习惯、关系等级、任务建议),以便有针对性地服务客户。
#### 验收标准
1. THE task-detail 页面 SHALL 包含:客户基本信息模块 → 消费习惯模块 → 与我的关系模块(等级+说明)→ 任务建议模块、底部固定栏(问问助手+备注按钮)、放弃任务确认弹窗、备注弹窗
2. THE task-detail-callback、task-detail-priority、task-detail-relationship 页面 SHALL 与 task-detail 保持相同的数据结构和布局,仅 banner 背景色和按钮配色不同
### 需求 26页面级功能需求 — D 批次(详情页)
**用户故事:** 作为门店管理者,我希望查看助教和客户的详细信息及服务记录,以便深入了解业务情况。
#### 验收标准
1. THE coach-detail 页面 SHALL 包含:基本信息模块 → 流水与业绩模块 → 工资与上课时长模块 → 前 10 客户指数列表、底部固定栏(问问助手+备注)、备注弹窗
2. THE customer-detail 页面 SHALL 包含:基本信息模块 → 消费习惯模块(标签+文本)→ 与我的关系模块(等级+说明)、底部固定栏(问问助手+备注)
3. THE customer-service-records 页面 SHALL 展示客户的服务记录列表
### 需求 27页面级功能需求 — E 批次(绩效)
**用户故事:** 作为助教用户,我希望查看本月业绩总览和明细,以便了解自己的工作成果和收入情况。
#### 验收标准
1. THE performance 页面 SHALL 包含:顶部 Banner用户名+身份+本月业绩进度+预计收入)、多组指标两列卡片网格(收入构成/台球助教业绩/充值业绩/酒水业绩)、指标卡片(名称+当前值+目标值+完成度%)、仅展示本月数据(无时间切换)
2. THE performance-records 页面 SHALL 展示业绩明细列表
### 需求 28页面级功能需求 — F 批次(对话)
**用户故事:** 作为用户,我希望与 AI 助手对话并查看历史对话记录,以便获取业务建议和回顾历史咨询。
#### 验收标准
1. THE chat 页面 SHALL 包含:仿微信对话界面(左助手气泡/右用户气泡)、引用内容(灰底小卡片:来源类型+标题+摘要)、输入区(文本框+按住说话语音转文字+发送按钮)、会话管理(超 1 小时提示"新对话主题/继续对话"
2. THE chat-history 页面 SHALL 包含:对话列表(对话标题+最近时间+消息条数,按更新时间倒序)、点击打开对应会话并滚动到最后一条
### 需求 29页面级功能需求 — G 批次(其他)
**用户故事:** 作为用户,我希望查看所有备注记录,以便回顾之前对客户和任务的备注。
#### 验收标准
1. THE notes 页面 SHALL 包含:备注列表按时间倒序平铺、每条备注显示:备注全文+关联对象+创建时间、不支持编辑/删除功能
2. THE notes 页面 SHALL 对已有历史实现按标准流程重新审计验收
### 需求 30认证守卫与开发联调
**用户故事:** 作为迁移执行者,我希望小程序页面在开发阶段能正常联调后端 API且未登录时自动跳转登录页。
#### 验收标准
1. THE 每个业务页面 SHALL 在加载时检查登录态,未登录时自动跳转登录页
2. WHEN 开发联调时THE 小程序 SHALL 通过 `utils/request.ts` 中的 `BASE_URL` 指向 `http://localhost:8000`
3. WHEN 开发联调时THE 后端 SHALL 启用 `WX_DEV_MODE=true` 开发模式,支持 `/api/xcx/dev-login` Mock 登录
4. THE 小程序 SHALL 使用 Storage + header token 方式维持登录态(小程序无 Cookie
### 需求 31全局设计规范
**用户故事:** 作为用户,我希望小程序的视觉风格和交互模式在所有页面保持一致。
#### 验收标准
1. THE 小程序 SHALL 使用简体中文,金额元取整,万元保留两位小数
2. THE 小程序 SHALL 使用底部 TabBar 导航任务task-list/ 看板board-finance/ 我的my-profile
3. THE 二级/详情页 SHALL 隐藏原生导航栏,使用自定义头部(左上角返回图标)
4. THE 所有业务页面 SHALL 在右下角显示 AI 悬浮助手按钮,点击进入助手对话页
5. THE 错误态 SHALL 统一显示"加载失败,请点击重试"+ 重试按钮
6. THE 空数据态 SHALL 使用纯文字提示(无插画)
7. THE 加载态 SHALL 使用"加载中..."纯文字(无骨架屏)
### 需求 32AI 图标配色系统
**用户故事:** 作为用户,我希望页面中的 AI 标识每次加载时随机呈现不同配色,整页统一,增加视觉新鲜感。
#### 验收标准
1. THE AI 图标配色系统 SHALL 支持 6 种配色方案(红/橙/黄/蓝/靛/紫),每种配色通过 CSS 变量定义渐变色对(`--ai-from`/`--ai-to`/`--ai-from-deep`/`--ai-to-deep`),色值参照 `docs/h5_ui/css/ai-icons.css` 中的定义
2. THE AI 图标配色系统 SHALL 包含两个系列的 AI 标识:
- `ai-inline-icon`行首小图标H5 16px → 28rpx渐变背景 + 白色机器人 SVG + 微光扫过动画12s 周期)
- `ai-title-badge`:标题行右侧标识(浅色背景 + 主题色文字 + 主题色边框 + 呼吸脉冲动画 3s 周期 + 高光扫过 14s 周期)
3. WHEN 页面加载时THE 配色系统 SHALL 从 6 种配色中随机选取一种,统一应用到该页面所有 `ai-inline-icon``ai-title-badge` 元素,确保同一页面内所有 AI 标识使用同一配色
4. THE ai-float-button悬浮按钮SHALL 不参与随机配色,保持固定渐变动画(`#667eea → #764ba2 → #f093fb → #f5576c`
5. THE 配色系统 SHALL 在小程序中通过页面级 `onLoad` 随机选色并 `setData` 传递 class 名的方式实现(替代 H5 的 DOM `querySelectorAll` + `classList.add`
6. THE 机器人 SVG 图标 SHALL 复用已有的 `assets/icons/ai-robot.svg`(大系列)和导出对应的小系列 SVG不重新生成
### 需求 33迁移产出物管理
**用户故事:** 作为项目管理者我希望迁移过程中的所有产出物审计报告、截图、diff 图)有序归档,以便追溯和复查。
#### 验收标准
1. THE 迁移工程 SHALL 将 H5 原型截图保留在 `docs/h5_ui/screenshots/` 目录(只读输入物),命名规则:默认态 `<page>.png`、交互态 `<page>--<state>.png`;迁移过程中禁止向此目录写入 MP 截图或 diff 图
2. THE 迁移工程 SHALL 将小程序截图按页面分子目录存放在 `docs/h5_ui/mp-screenshots/<page>/` 目录,命名规则:默认态 `<page>.png`、交互态 `<page>--<state>.png`、逐段截图 `seg-<N>-<section>.png`
3. THE 迁移工程 SHALL 将 H5 逐段截图按页面分子目录存放在 `docs/h5_ui/h5-segments/<page>/` 目录,命名规则:`seg-<N>-<section>.png`
4. THE 迁移工程 SHALL 将像素对比结果按页面分子目录存放在 `docs/h5_ui/diffs/<page>/` 目录,包含:全屏 diff 图 `diff-<page>.png`、逐段 diff 图 `diff-seg-<N>-<section>.png`、对比报告 `report.md`(差异率 + 问题区域摘要)
5. THE 迁移工程 SHALL 将新导出的 SVG 图标存放在 `apps/miniprogram/miniprogram/assets/icons/` 目录
6. WHEN 迁移新页面导出新 SVG 时THE 迁移工程 SHALL 更新 `docs/h5_ui/icon-mapping.md` 图标映射表
7. THE 迁移工程 SHALL 确保小程序代码输出到 `apps/miniprogram/miniprogram/pages/<page>/` 目录结构下
8. THE 像素精调循环中,每轮新截图 SHALL 覆盖上一轮同名文件,不保留历史版本(依赖 git 追溯历史)

View File

@@ -0,0 +1,321 @@
# 实现计划H5 → 微信小程序批量迁移
## 概述
基于 33 条需求和技术设计文档,将 17 个 H5 原型页面迁移为原生微信小程序页面。按 A-G 七个批次执行,每页走完 7 步标准流程(含 Step 0 页面分析)。输入物分两批提供:第一批(结构迁移 Step 0-5、第二批像素精调 Step 6-7
## 任务
- [ ] 1. 全局基础设施搭建
- [ ] 1.1 创建 AI 图标配色工具模块
- 文件:`utils/ai-color.ts`
- 实现 `AI_COLOR_SCHEMES` 常量6 种配色red/orange/yellow/blue/indigo/purple
- 实现 `getRandomAiColor()` 函数,返回 `{ className, vars }` 对象
- _需求: 32.1, 32.3, 32.5_
- [ ] 1.2 创建 AI 图标全局 WXSS 样式
-`app.wxss` 中添加 `.ai-inline-icon``.ai-title-badge``.ai-color-*` 6 个配色类
- 实现 `ai-shimmer`12s`ai-pulse`3s两个 `@keyframes` 动画
- _需求: 32.1, 32.2_
- [ ] 1.3 导出小系列机器人 SVG
- 从 H5 源码提取白色填充版机器人 SVG保存为 `assets/icons/ai-robot-sm.svg`
- 复用已有 `assets/icons/ai-robot.svg`(大系列)
- 更新 `docs/h5_ui/icon-mapping.md`
- _需求: 32.6, 33.2, 33.3_
- [ ] 1.4 创建中间生成物目录结构
- 创建 `docs/h5_ui/mp-screenshots/`MP 截图,按页面分子目录)
- 创建 `docs/h5_ui/diffs/`(像素对比结果,按页面分子目录)
- 创建 `docs/h5_ui/h5-segments/`H5 逐段截图,按页面分子目录)
- 确认 `.gitignore` 不排除这些目录
- _需求: 33.1_
- [ ] 1.5 验证全局基础设施
- 编译验证 `app.wxss` 无警告
- 在任意已有页面中测试 AI 配色工具模块可正常导入和调用
- _需求: 17.1_
- [ ] 2. A 批次 — board-finance看板-财务)
- [ ] 2.1 Step 0: 页面分析
- 打开 H5 原型截图 + `interactions/board-finance.md`
- 确认屏数(预计 6 段:经营一览/预收资产/应计收入/现金流入/现金流出/助教分析)
- 列出所有交互态(时间筛选/区域筛选/指标弹窗/目录面板/长按菜单)
- 输出工作量估算表
- _需求: 1.1, 1.3_
- [ ] 2.2 Step 1-2: 输入物冻结 + 迁移审计
- 冻结第一批输入物Playbook + design-tokens + icon-mapping + HTML + CSS + interactions
- 输出《迁移审计报告》7 项(页面结构/CSS 风险/样式映射/图标处理/交互映射/外部依赖/缺失信息)
- _需求: 3.1, 3.2, 4.1, 4.2, 4.3_
- [ ] 2.3 Step 3: 规则化转换(按屏逐个开发)
- 创建四文件骨架 → .json 注册组件 → 按屏转换 WXML/WXSS/TS
- 包含filter-dropdown 复用、metric-card 复用、ai-float-button 集成
- filter-bar 高度统一 70 逻辑像素
- Mock 数据 + 三态处理
- _需求: 5, 6, 7, 8, 9, 10, 11, 12, 14, 15, 16, 21, 23.1, 32_
- [ ] 2.4 Step 4-5: 编译验证 + 结构还原验证
- 7 项编译检查WXML/WXSS/控制台/图片/组件/路由/TS 类型)
- 9 项结构核对(按屏逐个验证)
- _需求: 17, 18_
- [ ] 2.5 Step 6-7: 像素精调 + 验收签收
- 补充第二批输入物computed-styles + 截图)
- 使用 anchor_compare.py v2 逐段对比
- H5 逐段截图 → `docs/h5_ui/h5-segments/board-finance/`
- MP 逐段截图 → `docs/h5_ui/mp-screenshots/board-finance/`
- Diff 图 → `docs/h5_ui/diffs/board-finance/`
- 输出 `docs/h5_ui/diffs/board-finance/report.md`(差异率 + 问题区域)
- 每轮 2-5 处修改,循环至差异率 ≤ 10%
- 12 项验收清单
- _需求: 3.3, 19, 20_
- [ ] 3. A 批次 — board-coach看板-助教)
- [ ] 3.1 Step 0: 页面分析
- 确认屏数、交互态(排序筛选/擅长项目筛选/时间筛选)
- _需求: 1.1, 1.3_
- [ ] 3.2 Step 1-2: 输入物冻结 + 迁移审计
- _需求: 3.1, 3.2, 4_
- [ ] 3.3 Step 3: 规则化转换
- 包含board-tab-bar 复用、filter-dropdown 复用
- _需求: 5-16, 21, 23.2, 23.4, 23.5, 32_
- [ ] 3.4 Step 4-5: 编译验证 + 结构还原验证
- _需求: 17, 18_
- [ ] 3.5 Step 6-7: 像素精调 + 验收签收
- _需求: 19, 20_
- [ ] 4. A 批次 — board-customer看板-客户)
- [ ] 4.1 Step 0: 页面分析
- 确认屏数、交互态(客户类型筛选/偏爱项目筛选)
- _需求: 1.1, 1.3_
- [ ] 4.2 Step 1-2: 输入物冻结 + 迁移审计
- _需求: 3.1, 3.2, 4_
- [ ] 4.3 Step 3: 规则化转换
- 包含board-tab-bar 复用、filter-dropdown 复用、heart-icon 复用、hobby-tag 复用
- _需求: 5-16, 21, 23.3, 23.4, 23.5, 32_
- [ ] 4.4 Step 4-5: 编译验证 + 结构还原验证
- _需求: 17, 18_
- [ ] 4.5 Step 6-7: 像素精调 + 验收签收
- _需求: 19, 20_
- [ ] 5. 检查点 A — 看板批次验收
- 确认 3 个看板页面全部通过 12 项验收清单
- 确认共享组件filter-dropdown、board-tab-bar、metric-card、heart-icon在实际页面中表现正常
- 确认 AI 图标配色系统在看板页面中正常工作
- 总结 A 批次经验,调整后续批次策略(如有)
- [ ] 6. B 批次 — task-list任务列表
- [ ] 6.1 Step 0: 页面分析
- 确认屏数、交互态(长按菜单/备注弹窗/空状态)
- _需求: 1.1_
- [ ] 6.2 Step 1-2: 输入物冻结 + 迁移审计
- _需求: 3, 4_
- [ ] 6.3 Step 3: 规则化转换
- 包含banner 复用、note-modal 复用、ai-float-button 集成
- 长按菜单实现bindlongpress + 黑底圆角浮层)
- TabBar 页面路由配置
- _需求: 5-16, 21, 24.1, 13, 32_
- [ ] 6.4 Step 4-5: 编译验证 + 结构还原验证
- _需求: 17, 18_
- [ ] 6.5 Step 6-7: 像素精调 + 验收签收
- _需求: 19, 20_
- [ ] 7. B 批次 — my-profile个人中心
- [ ] 7.1 Step 0: 页面分析
- _需求: 1.1_
- [ ] 7.2 Step 1-2: 输入物冻结 + 迁移审计
- _需求: 3, 4_
- [ ] 7.3 Step 3: 规则化转换
- TabBar 页面路由配置
- _需求: 5-16, 21, 24.2, 24.3, 32_
- [ ] 7.4 Step 4-5: 编译验证 + 结构还原验证
- _需求: 17, 18_
- [ ] 7.5 Step 6-7: 像素精调 + 验收签收
- _需求: 19, 20_
- [ ] 8. 检查点 B — 核心批次验收
- 确认 task-list 和 my-profile 通过验收
- 确认 TabBar 导航在三个 TabBar 页面间切换正常
- 确认长按菜单交互正常(长按 vs 点击互不干扰)
- [ ] 9. C 批次 — task-detail任务详情主页面
- [ ] 9.1 Step 0: 页面分析
- 确认屏数、交互态(放弃弹窗/备注弹窗/底部固定栏)
- _需求: 1.1_
- [ ] 9.2 Step 1-2: 输入物冻结 + 迁移审计
- _需求: 3, 4_
- [ ] 9.3 Step 3: 规则化转换
- 包含note-modal 复用、ai-float-button 集成、底部固定栏
- _需求: 5-16, 21, 25.1, 32_
- [ ] 9.4 Step 4-5: 编译验证 + 结构还原验证
- _需求: 17, 18_
- [ ] 9.5 Step 6-7: 像素精调 + 验收签收
- _需求: 19, 20_
- [ ] 10. C 批次 — task-detail 三个变体
- [ ] 10.1 复制 task-detail 生成 task-detail-callback
- 复制四文件 → 替换 banner 背景色 + 按钮配色
- _需求: 22.2, 22.3, 22.4, 25.2_
- [ ] 10.2 复制 task-detail 生成 task-detail-priority
- _需求: 22.2, 22.3, 22.4, 25.2_
- [ ] 10.3 复制 task-detail 生成 task-detail-relationship
- _需求: 22.2, 22.3, 22.4, 25.2_
- [ ] 10.4 三个变体编译验证 + 像素校准
- 对照各自 H5 原型截图校准色值
- _需求: 17, 19, 20_
- [ ] 11. 检查点 C — 任务批次验收
- 确认 task-detail + 3 个变体全部通过验收
- 确认从 task-list 点击卡片可正确跳转到对应变体页面
- [ ] 12. D 批次 — coach-detail助教详情
- [ ] 12.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
- _需求: 1.1, 3, 4_
- [ ] 12.2 Step 3: 规则化转换
- 包含note-modal 复用、ai-float-button 集成、底部固定栏
- _需求: 5-16, 21, 26.1, 32_
- [ ] 12.3 Step 4-7: 编译验证 → 结构还原 → 像素精调 → 验收
- _需求: 17, 18, 19, 20_
- [ ] 13. D 批次 — customer-detail客户详情
- [ ] 13.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
- _需求: 1.1, 3, 4_
- [ ] 13.2 Step 3: 规则化转换
- 包含hobby-tag 复用、ai-float-button 集成、底部固定栏
- _需求: 5-16, 21, 26.2, 32_
- [ ] 13.3 Step 4-7: 编译验证 → 结构还原 → 像素精调 → 验收
- _需求: 17, 18, 19, 20_
- [ ] 14. D 批次 — customer-service-records客户服务记录
- [ ] 14.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
- _需求: 1.1, 3, 4_
- [ ] 14.2 Step 3-7: 规则化转换 → 编译 → 结构 → 像素 → 验收
- _需求: 5-21, 26.3, 32_
- [ ] 15. 检查点 D — 详情批次验收
- 确认 3 个详情页全部通过验收
- 确认从看板页面点击卡片可正确跳转到对应详情页
- [ ] 16. E 批次 — performance业绩总览
- [ ] 16.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
- _需求: 1.1, 3, 4_
- [ ] 16.2 Step 3: 规则化转换
- 包含banner 复用、metric-card 复用、ai-float-button 集成
- _需求: 5-16, 21, 27.1, 32_
- [ ] 16.3 Step 4-7: 编译验证 → 结构还原 → 像素精调 → 验收
- _需求: 17, 18, 19, 20_
- [ ] 17. E 批次 — performance-records业绩明细
- [ ] 17.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
- _需求: 1.1, 3, 4_
- [ ] 17.2 Step 3-7: 规则化转换 → 编译 → 结构 → 像素 → 验收
- _需求: 5-21, 27.2, 32_
- [ ] 18. 检查点 E — 绩效批次验收
- 确认 2 个绩效页全部通过验收
- 确认从 task-list Banner 可跳转到 performance 页面
- [ ] 19. F 批次 — chatAI 对话)
- [ ] 19.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
- _需求: 1.1, 3, 4_
- [ ] 19.2 Step 3: 规则化转换
- 仿微信对话界面(左助手/右用户气泡)
- 引用内容卡片、输入区(文本框 + 语音按钮 + 发送)
- scroll-view 消息区(独立滚动)
- _需求: 5-16, 21, 28.1, 32_
- [ ] 19.3 Step 4-7: 编译验证 → 结构还原 → 像素精调 → 验收
- _需求: 17, 18, 19, 20_
- [ ] 20. F 批次 — chat-history对话历史
- [ ] 20.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
- _需求: 1.1, 3, 4_
- [ ] 20.2 Step 3-7: 规则化转换 → 编译 → 结构 → 像素 → 验收
- _需求: 5-21, 28.2, 32_
- [ ] 21. 检查点 F — 对话批次验收
- 确认 chat 和 chat-history 通过验收
- 确认从 ai-float-button 可跳转到 chat 页面
- 确认从 chat-history 点击可打开对应会话
- [ ] 22. G 批次 — notes备忘录
- [ ] 22.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
- 已有历史实现,按标准流程重新审计
- _需求: 1.1, 1.3, 3, 4_
- [ ] 22.2 Step 3-7: 规则化转换 → 编译 → 结构 → 像素 → 验收
- 包含star-rating 复用
- _需求: 5-21, 29, 32_
- [ ] 23. 检查点 G — 最终验收
- 确认 notes 通过验收
- 确认 my-profile 菜单中"备注记录"可跳转到 notes 页面
- [ ] 24. 全局收尾
- [ ] 24.1 全量导航验证
- 验证所有页面间的跳转路径正确TabBar 切换、navigateTo、navigateBack
- 验证认证守卫(未登录自动跳转登录页)
- _需求: 7, 20, 30, 31_
- [ ] 24.2 全量 AI 图标配色验证
- 抽查 3-5 个页面,确认 AI 图标随机配色正常
- 确认 ai-float-button 保持固定渐变(不参与随机)
- _需求: 32_
- [ ] 24.3 icon-mapping.md 最终更新
- 确认所有新导出的 SVG 已记录在 icon-mapping.md 中
- _需求: 33.3_
- [ ] 24.4 中间生成物归档验证
- 确认所有 MP 截图按页面分目录存放在 `docs/h5_ui/mp-screenshots/<page>/`
- 确认所有 diff 图和 report.md 按页面分目录存放在 `docs/h5_ui/diffs/<page>/`
- 确认所有 H5 逐段截图按页面分目录存放在 `docs/h5_ui/h5-segments/<page>/`
- 确认 `docs/h5_ui/screenshots/` 目录未被写入 MP 截图或 diff 图(只读输入物)
- 清理可能遗留在项目根目录或其他位置的临时截图文件
- _需求: 33_
## 备注
- 每个页面的 Step 0页面分析输出工作量估算表用户确认后再开始 Step 1
- 输入物分两批第一批Step 0-5 结构迁移、第二批Step 6-7 像素精调)
- 检查点任务用于批次间的质量门禁,确认通过后再进入下一批次
- 有历史实现的页面board-coach/customer/finance/notes走审计→对比→修复→验收流程
- task-detail 变体通过复制+替换主题色实现,不重复走完整流程
- 所有需求编号引用 `requirements.md` 中的需求序号
- 中间生成物MP 截图/H5 逐段截图/diff 图)按页面分子目录存放,每轮覆盖更新,不保留历史版本
- `docs/h5_ui/screenshots/` 为只读输入物目录,禁止写入迁移过程生成的文件

View File

@@ -1 +0,0 @@
{"generationMode": "requirements-first"}

View File

@@ -1,189 +0,0 @@
# 需求文档:核心业务层 — 任务系统 + 备注系统 + 触发器机制miniapp-core-business
## 简介
本 SPEC 实现小程序的核心业务逻辑层,涵盖助教任务生成与管理、备注系统、后台触发器/轮询机制。系统基于 P1miniapp-db-foundation已建立的 `biz` Schema、P2etl-dws-miniapp-extensions提供的指数数据WBI/NCI/RS 等通过 FDW 读取、P3miniapp-auth-system提供的用户认证和助教绑定信息`test_zqyy_app.biz` 中创建任务、备注、触发器等业务表,并在 FastAPI 后端实现对应的服务层和 API 端点。
## 术语表
- **Task_System**:任务系统,负责任务生成、状态管理、完成检测的完整后端服务
- **Task_Generator**:任务生成器,每日 04:00 后运行的定时任务,基于指数数据为每个助教分配任务
- **Task_Expiry_Checker**:任务有效期检查器,每小时运行的轮询任务,将超过有效期的任务标记为无效
- **Recall_Completion_Detector**召回完成检测器ETL 数据更新后运行,检测助教是否已为匹配客户提供服务
- **Note_Reclassify_Service**:备注重分类服务,召回完成时触发,将普通备注回溯重分类为回访备注
- **Note_System**:备注系统,负责备注的创建、查询、删除和类型管理
- **Trigger_System**触发器系统统一管理所有条件触发的后台任务cron/event/interval 三种触发方式)
- **coach_task**:助教任务记录,存储在 `biz.coach_tasks` 表中
- **task_type**:任务类型枚举,取值为 `high_priority_recall`(高优先召回)/ `priority_recall`(优先召回)/ `follow_up_visit`(客户回访)/ `relationship_building`(关系构建)
- **task_status**:任务状态枚举,取值为 `active`(有效)/ `inactive`(无效)/ `completed`(已完成)/ `abandoned`(已放弃)
- **note_type**:备注类型枚举,取值为 `normal`(普通备注)/ `follow_up`(回访备注)/ `birthday`(生日信息)/ `abandon_reason`(放弃原因)
- **trigger_type**:触发器类型枚举,取值为 `cron`(定时触发)/ `event`(事件触发)/ `interval`(间隔触发)
- **priority_score**:优先级分数,取 max(WBI, NCI) 的快照值
- **expires_at**:任务有效期,默认为 NULL无有效期当指数不再满足条件时填充为 `created_at + 48h`
- **WBI**老客挽回指数Winback Index0-10 分,来自 `fdw_etl` 的 DWS 数据
- **NCI**新客转化指数New Customer Index0-10 分,来自 `fdw_etl` 的 DWS 数据
- **RS**关系强度指数Relationship Strength0-10 分,客户-助教配对维度
- **site_id**:门店标识符,类型为 `BIGINT`,用于多门店数据隔离
- **assistant_id**:助教标识符,来自 ETL 的 `dim_assistant`,通过 `auth.user_assistant_binding` 与小程序用户关联
- **member_id**:会员标识符,来自 ETL 的 `dim_member`
- **FDW**`postgres_fdw` 外部数据包装器,通过 `fdw_etl` Schema 读取 ETL 库数据
- **Migration_Script**:存放在 `db/zqyy_app/migrations/` 中的纯 SQL 迁移脚本,以日期前缀命名
## 需求
### 需求 1业务数据表创建
**用户故事:** 作为后端开发者,我需要在 `biz` Schema 中创建任务、备注、触发器相关的数据表,以便支撑核心业务功能。
#### 验收标准
1. WHEN Migration_Script 执行完成, THE Task_System SHALL 在 `biz` Schema 中创建 `coach_tasks` 表,包含 `id`SERIAL PK`site_id`BIGINT NOT NULL`assistant_id`BIGINT NOT NULL`member_id`BIGINT NOT NULL`task_type`VARCHAR NOT NULL`status`VARCHAR NOT NULL DEFAULT 'active')、`priority_score`NUMERIC(4,2))、`expires_at`TIMESTAMPTZ默认 NULL`is_pinned`BOOLEAN DEFAULT FALSE`abandon_reason`TEXT`completed_at`TIMESTAMPTZ`completed_task_type`VARCHAR`parent_task_id`INTEGERFK → coach_tasks.id`created_at`TIMESTAMPTZ DEFAULT NOW())、`updated_at`TIMESTAMPTZ DEFAULT NOW())字段
2. WHEN Migration_Script 执行完成, THE Task_System SHALL 在 `biz` Schema 中创建 `coach_task_history` 表,包含 `id`SERIAL PK`task_id`INTEGER FK → coach_tasks.id`action`VARCHAR NOT NULL`old_status`VARCHAR`new_status`VARCHAR`detail`JSONB`created_at`TIMESTAMPTZ DEFAULT NOW())字段
3. WHEN Migration_Script 执行完成, THE Note_System SHALL 在 `biz` Schema 中创建 `notes` 表,包含 `id`SERIAL PK`site_id`BIGINT NOT NULL`author_user_id`INTEGER NOT NULL`target_member_id`BIGINT NOT NULL`target_assistant_id`BIGINT`note_type`VARCHAR NOT NULL DEFAULT 'normal')、`content`TEXT NOT NULL`task_id`INTEGERFK → coach_tasks.id可空`ai_score`NUMERIC(3,1))、`ai_evaluation`TEXT`created_at`TIMESTAMPTZ DEFAULT NOW())、`updated_at`TIMESTAMPTZ DEFAULT NOW())字段
4. WHEN Migration_Script 执行完成, THE Trigger_System SHALL 在 `biz` Schema 中创建 `trigger_jobs` 表,包含 `id`SERIAL PK`job_type`VARCHAR NOT NULL`job_name`VARCHAR NOT NULL`trigger_type`VARCHAR NOT NULL`trigger_config`JSONB NOT NULL`last_run_at`TIMESTAMPTZ`next_run_at`TIMESTAMPTZ`status`VARCHAR NOT NULL DEFAULT 'enabled')、`created_at`TIMESTAMPTZ DEFAULT NOW())字段
5. WHEN Migration_Script 执行完成, THE Trigger_System SHALL 在 `biz` Schema 中创建 `trigger_execution_log` 表,包含 `id`SERIAL PK`job_id`INTEGER FK → trigger_jobs.id`started_at`TIMESTAMPTZ NOT NULL`finished_at`TIMESTAMPTZ`status`VARCHAR NOT NULL`result_summary`JSONB`error_message`TEXT字段
6. THE Migration_Script SHALL 对 `coach_tasks` 表创建索引:`(site_id, assistant_id, status)` 复合索引、`(site_id, member_id)` 复合索引、`(status, expires_at)` 复合索引用于有效期轮询
7. THE Migration_Script SHALL 对 `notes` 表创建索引:`(site_id, target_member_id)` 复合索引、`(author_user_id)` 索引、`(task_id)` 索引
8. THE Migration_Script SHALL 使用 `IF NOT EXISTS` / `OR REPLACE` 等幂等语法,确保重复执行不会报错
9. THE Migration_Script SHALL 在脚本中包含回滚语句(以注释形式)
### 需求 2触发器种子数据预置
**用户故事:** 作为系统管理员,我需要系统预置 4 个核心触发器配置,以便后台任务按预定规则自动运行。
#### 验收标准
1. WHEN 种子数据脚本执行完成, THE Trigger_System SHALL 在 `biz.trigger_jobs` 表中插入 `task_generator` 记录trigger_type=`cron`trigger_config 包含 cron 表达式 `0 4 * * *`,表示每日 04:00
2. WHEN 种子数据脚本执行完成, THE Trigger_System SHALL 在 `biz.trigger_jobs` 表中插入 `task_expiry_check` 记录trigger_type=`interval`trigger_config 包含间隔秒数 3600表示每小时
3. WHEN 种子数据脚本执行完成, THE Trigger_System SHALL 在 `biz.trigger_jobs` 表中插入 `recall_completion_check` 记录trigger_type=`event`trigger_config 包含事件名 `etl_data_updated`
4. WHEN 种子数据脚本执行完成, THE Trigger_System SHALL 在 `biz.trigger_jobs` 表中插入 `note_reclassify_backfill` 记录trigger_type=`event`trigger_config 包含事件名 `recall_completed`
5. THE 种子数据脚本 SHALL 使用 `ON CONFLICT DO NOTHING` 语法,确保重复执行不会产生重复数据
### 需求 3任务生成器
**用户故事:** 作为助教,我每天打开小程序能看到系统为我分配的任务列表,按优先级排序,以便我知道今天应该优先联系哪些客户。
#### 验收标准
1. WHEN Task_Generator 运行时, THE Task_System SHALL 从 `fdw_etl` 读取每个助教关联的客户的 WBI、NCI、RS 指数数据
2. WHEN 某客户的 max(WBI, NCI) > 7, THE Task_Generator SHALL 为该客户-助教配对生成 `high_priority_recall` 类型任务priority_score 记录为 max(WBI, NCI) 的快照值
3. WHEN 某客户的 max(WBI, NCI) > 5 且 ≤ 7, THE Task_Generator SHALL 为该客户-助教配对生成 `priority_recall` 类型任务
4. WHEN 某助教已完成某客户的召回任务且该客户无回访备注, THE Task_Generator SHALL 为该客户-助教配对生成 `follow_up_visit` 类型任务
5. WHEN 某客户-助教配对的 RS < 6, THE Task_Generator SHALL 为该客户-助教配对生成 `relationship_building` 类型任务
6. WHEN Task_Generator 生成任务时发现已存在相同 `(site_id, assistant_id, member_id, task_type)` 且 status 为 `active` 的任务, THE Task_Generator SHALL 跳过该任务,不创建新记录
7. WHEN Task_Generator 生成任务时发现已存在相同 `(site_id, assistant_id, member_id)` 但 task_type 不同且 status 为 `active` 的任务, THE Task_Generator SHALL 将旧任务的 status 更新为 `inactive`,创建新类型的任务,并将新任务的 `parent_task_id` 指向旧任务
8. WHEN Task_Generator 将旧任务标记为 `inactive`(因类型变更), THE Task_System SHALL 将旧任务的 `expires_at` 保持为 NULL类型变更导致的无效不设有效期
9. WHEN 任务类型按优先级排序时, THE Task_Generator SHALL 按以下优先级从高到低处理:`high_priority_recall`0> `priority_recall`0> `follow_up_visit`1> `relationship_building`2同优先级内按 priority_score 降序排列
10. WHEN Task_Generator 运行完成, THE Task_System SHALL 在 `biz.coach_task_history` 中记录本次生成的所有操作(创建、跳过、类型变更)
### 需求 448 小时滞留机制
**用户故事:** 作为系统,回访任务至少保留 48 小时,到期后自动失效,以便助教有足够时间完成回访。
#### 验收标准
1. WHEN Task_Generator 计算出某客户不再满足当前任务条件(指数变化)但该任务仍为 `active``expires_at` 为 NULL, THE Task_System SHALL 将该任务的 `expires_at` 填充为 `created_at + 48 小时`status 保持 `active`
2. WHEN Task_Expiry_Checker 每小时运行时, THE Task_System SHALL 查询所有 status 为 `active``expires_at` 不为 NULL 且 `expires_at` < 当前时间的任务,将其 status 更新为 `inactive`
3. WHEN 一个已有 `expires_at``follow_up_visit` 任务存在,且 Task_Generator 再次生成同客户-助教的 `follow_up_visit` 任务, THE Task_System SHALL 将旧任务标记为 `inactive`,创建新的 `follow_up_visit` 任务(新任务顶替旧任务)
4. WHEN 任务状态变更时, THE Task_System SHALL 在 `biz.coach_task_history` 中记录变更详情(包含旧状态、新状态、变更原因)
### 需求 5召回完成检测
**用户故事:** 作为助教,我完成召回任务后(客户到店被我服务),系统自动标记任务完成,无需手动操作。
#### 验收标准
1. WHEN ETL 数据更新后 Recall_Completion_Detector 运行时, THE Task_System SHALL 查询 `fdw_etl` 中的服务记录(`v_dwd_session_detail` 或等效视图),检测是否有新的助教-客户服务记录
2. WHEN 检测到助教 A 为客户 B 提供了新的服务记录, THE Task_System SHALL 查找助教 A 对客户 B 的所有 status 为 `active` 的任务(无论任务类型),将其 status 更新为 `completed`
3. WHEN 任务被标记为 `completed`, THE Task_System SHALL 记录 `completed_at` 为当前时间,`completed_task_type` 为任务完成时的 task_type 快照
4. WHEN 召回完成后发现该客户-助教配对无回访备注, THE Task_System SHALL 触发 `note_reclassify_backfill` 事件,检查是否需要数据回溯
### 需求 6数据回溯机制
**用户故事:** 作为系统,当 ETL 数据延迟导致召回完成晚于备注提交时,需要回溯重分类备注,确保回访任务正确完成。
#### 验收标准
1. WHEN 召回任务完成时, THE Note_Reclassify_Service SHALL 查询该助教在召回服务结束时间之后为该客户添加的所有 note_type 为 `normal` 的备注
2. WHEN 找到符合条件的普通备注, THE Note_Reclassify_Service SHALL 将第一条(按时间最早)普通备注的 note_type 更新为 `follow_up`,并关联到对应的回访任务(设置 `task_id`
3. WHEN 备注被重分类为 `follow_up` 后, THE Note_Reclassify_Service SHALL 触发 AI 应用 6 的含金量评分流程(评分结果写入 `notes.ai_score``notes.ai_evaluation`
4. WHEN AI 评分结果 ≥ 6 分, THE Task_System SHALL 将对应的回访任务标记为 `completed`
5. WHEN AI 评分结果 < 6 分, THE Task_System SHALL 保持回访任务的当前状态不变,等待助教提交新的备注
### 需求 7任务操作 API
**用户故事:** 作为助教,我可以查看任务列表、置顶/放弃任务、取消置顶/取消放弃,以便灵活管理我的工作优先级。
#### 验收标准
1. WHEN 助教请求任务列表, THE Task_System SHALL 返回该助教在当前 `site_id` 下所有 status 为 `active` 的任务按优先级分组priority 0 → 1 → 2同优先级内按 `is_pinned` 降序、`priority_score` 降序排列
2. WHEN 助教置顶某任务, THE Task_System SHALL 将该任务的 `is_pinned` 更新为 TRUE并在 `coach_task_history` 中记录操作
3. WHEN 助教取消置顶某任务, THE Task_System SHALL 将该任务的 `is_pinned` 更新为 FALSE并在 `coach_task_history` 中记录操作
4. WHEN 助教放弃某任务且提供了放弃原因, THE Task_System SHALL 将该任务的 status 更新为 `abandoned`,记录 `abandon_reason`,并在 `coach_task_history` 中记录操作
5. IF 助教放弃任务时未提供放弃原因(空字符串或纯空白字符), THEN THE Task_System SHALL 返回 HTTP 422 错误,拒绝操作
6. WHEN 助教取消放弃某任务, THE Task_System SHALL 将该任务的 status 恢复为 `active`,清空 `abandon_reason`,并在 `coach_task_history` 中记录操作
7. WHEN 助教请求任务详情, THE Task_System SHALL 返回任务的完整信息,包含客户基本信息(通过 FDW 查询)、指数快照、备注列表、近期服务记录
8. WHEN 助教请求已放弃任务列表, THE Task_System SHALL 返回该助教在当前 `site_id` 下所有 status 为 `abandoned` 的任务列表
### 需求 8备注 CRUD
**用户故事:** 作为助教,我可以为客户添加、查看、删除备注,以便记录客户信息和服务跟进情况。
#### 验收标准
1. WHEN 助教为某客户创建备注(提供 content 和可选的 note_type, THE Note_System SHALL 在 `biz.notes` 表中创建记录,`author_user_id` 为当前登录用户 ID`site_id` 为当前店铺 ID
2. WHEN 助教在回访任务上下文中创建备注, THE Note_System SHALL 将 note_type 设置为 `follow_up`,并将 `task_id` 关联到对应的回访任务
3. WHEN 回访备注创建后, THE Note_System SHALL 触发 AI 应用 6 的含金量评分流程,将评分结果写入 `notes.ai_score``notes.ai_evaluation`
4. WHEN AI 评分结果 ≥ 6 分, THE Task_System SHALL 将对应的回访任务标记为 `completed`
5. WHEN AI 评分结果 < 6 分, THE Task_System SHALL 保持回访任务状态不变
6. WHEN 助教查询某客户的备注列表, THE Note_System SHALL 返回该客户在当前 `site_id` 下的所有备注,按 `created_at` 降序排列
7. WHEN 助教删除某条备注, THE Note_System SHALL 从 `biz.notes` 表中删除该记录
8. IF 助教创建备注时 content 为空字符串或纯空白字符, THEN THE Note_System SHALL 返回 HTTP 422 错误,拒绝创建
### 需求 9生日信息隔离存储
**用户故事:** 作为助教,我为客户记录的生日信息独立于 ETL 数据,不会被 ETL 数据更新覆盖。
#### 验收标准
1. WHEN 助教为某客户添加生日备注note_type=`birthday`, THE Note_System SHALL 在 `biz.notes` 表中创建记录content 存储生日值
2. THE Note_System SHALL 确保 note_type 为 `birthday` 的备注记录独立于 ETL 数据管道ETL 数据更新不会修改或删除这些记录
3. WHEN 查询某客户的生日信息时, THE Note_System SHALL 从 `biz.notes` 表中查询 note_type 为 `birthday` 的最新记录,返回 content生日值`author_user_id`(记录者)
### 需求 10触发器调度框架
**用户故事:** 作为系统,我需要一个统一的触发器调度框架来管理所有后台任务的触发和执行。
#### 验收标准
1. THE Trigger_System SHALL 支持三种触发方式:`cron`(基于 cron 表达式的定时触发)、`event`(基于事件名的事件触发)、`interval`(基于固定间隔秒数的间隔触发)
2. WHEN cron 类型触发器到达触发时间, THE Trigger_System SHALL 执行对应的任务处理函数,并更新 `last_run_at``next_run_at`
3. WHEN event 类型触发器收到匹配的事件通知, THE Trigger_System SHALL 执行对应的任务处理函数,并更新 `last_run_at`
4. WHEN interval 类型触发器距上次运行超过配置的间隔秒数, THE Trigger_System SHALL 执行对应的任务处理函数,并更新 `last_run_at``next_run_at`
5. WHEN 触发器执行完成(成功或失败), THE Trigger_System SHALL 在 `biz.trigger_execution_log` 中记录执行日志,包含开始时间、结束时间、执行状态、结果摘要或错误信息
6. WHEN 触发器 status 为 `disabled`, THE Trigger_System SHALL 跳过该触发器的执行
7. IF 触发器执行过程中发生异常, THEN THE Trigger_System SHALL 捕获异常,在执行日志中记录错误信息,触发器状态保持 `enabled`(不因单次失败而禁用)
### 需求 11迁移脚本管理
**用户故事:** 作为后端开发者,我需要所有数据库变更都有对应的迁移脚本,以便变更可追溯、可重放。
#### 验收标准
1. THE Migration_Script SHALL 将所有业务相关表的 DDL 存放在 `db/zqyy_app/migrations/` 目录中
2. THE Migration_Script SHALL 使用日期前缀命名(格式:`YYYY-MM-DD__<描述>.sql`
3. THE Migration_Script SHALL 使用 UTF-8 编码,纯 SQL非 ORM
4. THE Migration_Script SHALL 在每个脚本中包含回滚语句(以注释形式)
5. THE Migration_Script SHALL 使用幂等语法(`IF NOT EXISTS``ON CONFLICT DO NOTHING`),确保重复执行不会报错
### 需求 12DDL 测试库落库与文档同步
**用户故事:** 作为后端开发者,我需要所有 DDL 变更在测试库(`test_zqyy_app`)中实际执行验证,并同步更新数据库手册和 DDL 基线,确保文档与实际 Schema 一致。
#### 验收标准
1. WHEN 迁移脚本编写完成, THE Task_System SHALL 在 `test_zqyy_app` 测试库中执行独立的迁移脚本(`db/zqyy_app/migrations/` 下的 SQL 文件)进行落库,验证无错误
2. WHEN 迁移脚本执行成功, THE Task_System SHALL 创建或更新 `biz` Schema 相关的数据库手册文档,包含变更说明、兼容性影响、回滚策略、验证 SQL至少 3 条)
3. WHEN 迁移脚本执行成功, THE Task_System SHALL 运行 `python scripts/ops/gen_consolidated_ddl.py` 将新表的 DDL 合并到主 DDL 基线文件中
4. WHEN 种子数据脚本执行成功, THE Task_System SHALL 在数据库手册中记录种子数据内容(触发器配置)
5. THE Task_System SHALL 同步更新所有相关文档包括数据库手册、DDL 基线、部署文档等),确保文档与实际 Schema 保持一致