feat: P1-P3 全栈集成 — 数据库基础 + DWS 扩展 + 小程序鉴权 + 工程化体系

## P1 数据库基础
- zqyy_app: 创建 auth/biz schema、FDW 连接 etl_feiqiu
- etl_feiqiu: 创建 app schema RLS 视图、商品库存预警表
- 清理 assistant_abolish 残留数据

## P2 ETL/DWS 扩展
- 新增 DWS 助教订单贡献度表 (dws.assistant_order_contribution)
- 新增 assistant_order_contribution_task 任务及 RLS 视图
- member_consumption 增加充值字段、assistant_daily 增加处罚字段
- 更新 ODS/DWD/DWS 任务文档及业务规则文档
- 更新 consistency_checker、flow_runner、task_registry 等核心模块

## P3 小程序鉴权系统
- 新增 xcx_auth 路由/schema(微信登录 + JWT)
- 新增 wechat/role/matching/application 服务层
- zqyy_app 鉴权表迁移 + 角色权限种子数据
- auth/dependencies.py 支持小程序 JWT 鉴权

## 文档与审计
- 新增 DOCUMENTATION-MAP 文档导航
- 新增 7 份 BD_Manual 数据库变更文档
- 更新 DDL 基线快照(etl_feiqiu 6 schema + zqyy_app auth)
- 新增全栈集成审计记录、部署检查清单更新
- 新增 BACKLOG 路线图、FDW→Core 迁移计划

## Kiro 工程化
- 新增 5 个 Spec(P1/P2/P3/全栈集成/核心业务)
- 新增审计自动化脚本(agent_on_stop/build_audit_context/compliance_prescan)
- 新增 6 个 Hook(合规检查/会话日志/提交审计等)
- 新增 doc-map steering 文件

## 运维与测试
- 新增 ops 脚本:迁移验证/API 健康检查/ETL 监控/集成报告
- 新增属性测试:test_dws_contribution / test_auth_system
- 清理过期 export 报告文件
- 更新 .gitignore 排除规则
This commit is contained in:
Neo
2026-02-26 08:03:53 +08:00
parent fafc95e64c
commit b25308c3f4
224 changed files with 17660 additions and 32198 deletions

526
.kiro/.audit_context.json Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,74 @@
{
"needs_check": true,
"scanned_at": "2026-02-26T08:03:23.664569+08:00",
"new_migration_sql": [
"db/etl_feiqiu/migrations/2025-02-24__alter_assistant_daily_add_penalty_fields.sql",
"db/etl_feiqiu/migrations/2025-02-24__alter_member_consumption_add_recharge_fields.sql",
"db/etl_feiqiu/migrations/2025-02-24__create_dws_assistant_order_contribution.sql",
"db/etl_feiqiu/migrations/2025-02-24__create_rls_view_assistant_order_contribution.sql",
"db/etl_feiqiu/migrations/2026-02-24__add_goods_stock_warning_info.sql",
"db/etl_feiqiu/migrations/2026-02-24__cleanup_assistant_abolish_residual.sql",
"db/etl_feiqiu/migrations/2026-02-24__p1_create_app_schema_rls_views.sql",
"db/zqyy_app/migrations/2026-02-24__p1_create_auth_biz_schemas.sql",
"db/zqyy_app/migrations/2026-02-24__p1_setup_fdw_etl.sql",
"db/zqyy_app/migrations/2026-02-25__p3_create_auth_tables.sql",
"db/zqyy_app/migrations/2026-02-25__p3_seed_roles_permissions.sql"
],
"new_or_modified_sql": [
"db/_archived/ddl_baseline_2026-02-22/db/etl_feiqiu/schemas/dwd.sql",
"db/_archived/ddl_baseline_2026-02-22/db/etl_feiqiu/schemas/ods.sql",
"db/_archived/ddl_baseline_2026-02-22/db/zqyy_app/migrations/2025-02-24__add_fdw_dws_extensions.sql",
"db/etl_feiqiu/migrations/2025-02-24__alter_assistant_daily_add_penalty_fields.sql",
"db/etl_feiqiu/migrations/2025-02-24__alter_member_consumption_add_recharge_fields.sql",
"db/etl_feiqiu/migrations/2025-02-24__create_dws_assistant_order_contribution.sql",
"db/etl_feiqiu/migrations/2025-02-24__create_rls_view_assistant_order_contribution.sql",
"db/etl_feiqiu/migrations/2026-02-24__add_goods_stock_warning_info.sql",
"db/etl_feiqiu/migrations/2026-02-24__cleanup_assistant_abolish_residual.sql",
"db/etl_feiqiu/migrations/2026-02-24__p1_create_app_schema_rls_views.sql",
"db/etl_feiqiu/seeds/seed_ods_tasks.sql",
"db/etl_feiqiu/seeds/seed_scheduler_tasks.sql",
"db/zqyy_app/migrations/2026-02-24__p1_create_auth_biz_schemas.sql",
"db/zqyy_app/migrations/2026-02-24__p1_setup_fdw_etl.sql",
"db/zqyy_app/migrations/2026-02-25__p3_create_auth_tables.sql",
"db/zqyy_app/migrations/2026-02-25__p3_seed_roles_permissions.sql",
"docs/database/ddl/etl_feiqiu__app.sql",
"docs/database/ddl/etl_feiqiu__core.sql",
"docs/database/ddl/etl_feiqiu__dwd.sql",
"docs/database/ddl/etl_feiqiu__dws.sql",
"docs/database/ddl/etl_feiqiu__meta.sql",
"docs/database/ddl/etl_feiqiu__ods.sql",
"docs/database/ddl/fdw.sql",
"docs/database/ddl/zqyy_app__auth.sql",
"docs/database/ddl/zqyy_app__public.sql"
],
"code_without_docs": [
{
"file": "apps/etl/connectors/feiqiu/orchestration/flow_runner.py",
"expected_docs": [
"apps/etl/connectors/feiqiu/docs/architecture/"
]
},
{
"file": "apps/etl/connectors/feiqiu/orchestration/task_executor.py",
"expected_docs": [
"apps/etl/connectors/feiqiu/docs/architecture/"
]
},
{
"file": "apps/etl/connectors/feiqiu/orchestration/task_registry.py",
"expected_docs": [
"apps/etl/connectors/feiqiu/docs/architecture/"
]
},
{
"file": "apps/etl/connectors/feiqiu/orchestration/topological_sort.py",
"expected_docs": [
"apps/etl/connectors/feiqiu/docs/architecture/"
]
}
],
"new_files": [],
"has_bd_manual": true,
"has_audit_record": false,
"has_ddl_baseline": true
}

106
.kiro/.git_snapshot.json Normal file
View File

@@ -0,0 +1,106 @@
{
"files": [
".gitignore",
".kiro/.audit_context.json",
".kiro/.compliance_state.json",
".kiro/.git_snapshot.json",
".kiro/.last_prompt_id.json",
".kiro/agents/audit-writer.md",
".kiro/hooks/agent-on-stop.kiro.hook",
".kiro/hooks/audit-flagger.kiro.hook",
".kiro/hooks/audit-reminder.kiro.hook",
".kiro/hooks/change-compliance.kiro.hook",
".kiro/hooks/prompt-audit-log.kiro.hook",
".kiro/hooks/prompt-on-submit.kiro.hook",
".kiro/hooks/run-audit-writer.kiro.hook",
".kiro/hooks/session-log.kiro.hook",
".kiro/scripts/agent_on_stop.py",
".kiro/scripts/build_audit_context.py",
".kiro/scripts/change_compliance_prescan.py",
".kiro/scripts/prompt_on_submit.py",
".kiro/scripts/session_log.py",
".kiro/specs/01-miniapp-db-foundation/.config.kiro",
".kiro/specs/01-miniapp-db-foundation/design.md",
".kiro/specs/01-miniapp-db-foundation/requirements.md",
".kiro/specs/01-miniapp-db-foundation/tasks.md",
".kiro/specs/02-etl-dws-miniapp-extensions/.config.kiro",
".kiro/specs/02-etl-dws-miniapp-extensions/design.md",
".kiro/specs/02-etl-dws-miniapp-extensions/requirements.md",
".kiro/specs/02-etl-dws-miniapp-extensions/tasks.md",
".kiro/specs/03-miniapp-auth-system/.config.kiro",
".kiro/specs/03-miniapp-auth-system/design.md",
".kiro/specs/03-miniapp-auth-system/requirements.md",
".kiro/specs/03-miniapp-auth-system/tasks.md",
".kiro/specs/[ETL]-fullstack-integration/.config.kiro",
".kiro/specs/[ETL]-fullstack-integration/design.md",
".kiro/specs/[ETL]-fullstack-integration/requirements.md",
".kiro/specs/[ETL]-fullstack-integration/tasks.md",
".kiro/specs/etl-fullstack-integration/design.md",
".kiro/specs/etl-fullstack-integration/tasks.md",
".kiro/specs/miniapp-core-business/.config.kiro",
".kiro/specs/miniapp-core-business/requirements.md",
".kiro/specs/spi-spending-power-index/tasks.md",
".kiro/steering/doc-map.md",
"README.md",
"apps/admin-web/README.md",
"apps/backend/app/auth/dependencies.py",
"apps/backend/app/auth/jwt.py",
"apps/backend/app/main.py",
"apps/backend/app/routers/xcx_auth.py",
"apps/backend/app/schemas/xcx_auth.py",
"apps/backend/app/services/application.py",
"apps/backend/app/services/matching.py",
"apps/backend/app/services/role.py",
"apps/backend/app/services/task_registry.py",
"apps/backend/app/services/wechat.py",
"apps/backend/auth_only.txt",
"apps/backend/auth_only_results.txt",
"apps/backend/auth_test_results.txt",
"apps/backend/docs/API-REFERENCE.md",
"apps/backend/test_results.txt",
"apps/etl/connectors/feiqiu/docs/CHANGELOG.md",
"apps/etl/connectors/feiqiu/docs/README.md",
"apps/etl/connectors/feiqiu/docs/business-rules/dws_metrics.md",
"apps/etl/connectors/feiqiu/docs/business-rules/scd2_rules.md",
"apps/etl/connectors/feiqiu/docs/etl_tasks/README.md",
"apps/etl/connectors/feiqiu/docs/etl_tasks/base_task_mechanism.md",
"apps/etl/connectors/feiqiu/docs/etl_tasks/dws_tasks.md",
"apps/etl/connectors/feiqiu/docs/etl_tasks/index_tasks.md",
"apps/etl/connectors/feiqiu/docs/etl_tasks/ods_tasks.md",
"apps/etl/connectors/feiqiu/docs/operations/environment_setup.md",
"apps/etl/connectors/feiqiu/docs/operations/troubleshooting.md",
"apps/etl/connectors/feiqiu/orchestration/flow_runner.py",
"apps/etl/connectors/feiqiu/orchestration/task_executor.py",
"apps/etl/connectors/feiqiu/orchestration/task_registry.py",
"apps/etl/connectors/feiqiu/orchestration/topological_sort.py",
"apps/etl/connectors/feiqiu/quality/consistency_checker.py",
"apps/etl/connectors/feiqiu/scripts/verify_dws_extensions.py",
"apps/etl/connectors/feiqiu/tasks/dwd/dwd_load_task.py",
"apps/etl/connectors/feiqiu/tasks/dws/__init__.py",
"apps/etl/connectors/feiqiu/tasks/dws/assistant_daily_task.py",
"apps/etl/connectors/feiqiu/tasks/dws/assistant_order_contribution_task.py",
"apps/etl/connectors/feiqiu/tasks/dws/member_consumption_task.py",
"apps/etl/connectors/feiqiu/tasks/dws/member_visit_task.py",
"apps/etl/connectors/feiqiu/tasks/ods/ods_tasks.py",
"apps/etl/connectors/feiqiu/tests/unit/test_topological_sort.py",
"apps/miniprogram/README.md",
"db/README.md",
"db/_archived/ddl_baseline_2026-02-22/db/etl_feiqiu/schemas/dwd.sql",
"db/_archived/ddl_baseline_2026-02-22/db/etl_feiqiu/schemas/ods.sql",
"db/_archived/ddl_baseline_2026-02-22/db/zqyy_app/migrations/2025-02-24__add_fdw_dws_extensions.sql",
"db/etl_feiqiu/migrations/2025-02-24__alter_assistant_daily_add_penalty_fields.sql",
"db/etl_feiqiu/migrations/2025-02-24__alter_member_consumption_add_recharge_fields.sql",
"db/etl_feiqiu/migrations/2025-02-24__create_dws_assistant_order_contribution.sql",
"db/etl_feiqiu/migrations/2025-02-24__create_rls_view_assistant_order_contribution.sql",
"db/etl_feiqiu/migrations/2026-02-24__add_goods_stock_warning_info.sql",
"db/etl_feiqiu/migrations/2026-02-24__cleanup_assistant_abolish_residual.sql",
"db/etl_feiqiu/migrations/2026-02-24__p1_create_app_schema_rls_views.sql",
"db/etl_feiqiu/seeds/seed_ods_tasks.sql",
"db/etl_feiqiu/seeds/seed_scheduler_tasks.sql",
"db/zqyy_app/README.md",
"db/zqyy_app/migrations/2026-02-24__p1_create_auth_biz_schemas.sql",
"db/zqyy_app/migrations/2026-02-24__p1_setup_fdw_etl.sql"
],
"fingerprint": "96d0946e775eac6698780fe8290e7e73d762b201",
"taken_at": "2026-02-26T08:03:18.159857+08:00"
}

View File

@@ -1,4 +1,4 @@
{
"prompt_id": "P20260223-225610",
"at": "2026-02-23T22:56:10.365734+08:00"
{
"prompt_id": "P20260226-080318",
"at": "2026-02-26T08:03:18.159857+08:00"
}

View File

@@ -4,41 +4,95 @@ description: Run post-change audit + docs sync for NeoZQYY Monorepo; write audit
tools: ["read", "write", "shell"]
---
你是专职"审计收口/后处理写入"子代理。你的执行必须尽量不依赖主对话上下文优先使用本地仓库事实git、文件内容、prompt_log完成审计落盘。
你是专职"审计收口/后处理写入"子代理。
## 核心原则:从预构建上下文工作,禁止全盘扫描
你的唯一输入是 `.kiro/.audit_context.json`(由 `build_audit_context.py` 预构建)。
该文件已包含所有你需要的信息:
| 字段 | 来源 | 内容 |
|------|------|------|
| `changed_files` | audit-flagger | 全部变更文件列表 |
| `high_risk_files` | audit-flagger | 高风险文件子集 |
| `reasons` | audit-flagger | 风险分类标签 |
| `high_risk_diff` | git diff | 高风险文件的 diff已截断 |
| `diff_stat` | git diff --stat | 变更统计摘要 |
| `compliance.code_without_docs` | compliance-prescan | 缺少文档同步的代码文件及其应更新的文档 |
| `compliance.new_migration_sql` | compliance-prescan | 新增迁移 SQL 列表 |
| `compliance.has_bd_manual` | compliance-prescan | 是否已有 BD_Manual 文档 |
| `compliance.has_ddl_baseline` | compliance-prescan | 是否已更新 DDL 基线 |
| `external_files` | agent-on-stop | 非 Kiro 操作产生的变更文件CLI/脚本/手动编辑) |
| `prompt_id` / `latest_prompt_log` | prompt-audit-log | Prompt-ID 与原文(溯源用) |
**禁止操作**
- ❌ 运行 `git status --porcelain`(已有 `changed_files`
- ❌ 运行 `git diff` 全量(已有 `high_risk_diff` + `diff_stat`
- ❌ 遍历目录寻找变更文件(已有分类好的列表)
- ❌ 运行 `change_compliance_prescan.py`(已有 `compliance` 数据)
**允许操作**
- ✅ 读取具体文件内容(如需更新某个 README 时读取其当前内容)
- ✅ 对单个文件运行 `git diff HEAD -- <file>`(仅当 context 中 diff 被截断时)
- ✅ 连接测试库验证迁移执行状态(仅当 `new_migration_sql` 非空时)
## 审计产物路径(统一根目录)
- 变更审计记录:`docs/audit/changes/<YYYY-MM-DD>__<slug>.md`
- 审计一览表:`docs/audit/audit_dashboard.md`(自动生成,勿手动编辑)
- Prompt 日志:`docs/audit/prompt_logs/`
- 一览表刷新命令:`python scripts/audit/gen_audit_dashboard.py`
- 所有审计产物统一写入项目根目录 `docs/audit/`,不要写入子模块(如 `apps/etl/connectors/feiqiu/docs/audit/`内部
## 输入来源(不要询问主代理)
- 通过 `git status --porcelain``git diff` 获取本次未提交变更
- 通过 `docs/audit/prompt_logs/` 目录下的独立日志文件与 `.kiro/.last_prompt_id.json` 获取最新 Prompt-ID 与 prompt 原文(用于溯源)
- 通过项目实际文件内容判断是否"逻辑改动"
- 所有审计产物统一写入项目根目录 `docs/audit/`,不要写入子模块内部
## 何时需要做"重型后处理"
满足任一即执行审计收口(否则只输出"无逻辑改动/无需审计",并清除待审计标记)
- 改动文件命中 ETL Connector 高风险路径:`apps/etl/connectors/feiqiu/` 下的 `api/``cli/``config/``database/``loaders/``models/``orchestration/``scd/``tasks/``utils/``quality/`
- 改动文件命中后端 API`apps/backend/app/`
- 改动文件命中管理后台源码:`apps/admin-web/src/`
- 改动文件命中小程序源码:`apps/miniprogram/miniapp/``apps/miniprogram/miniprogram/`
- 改动文件命中共享包:`packages/shared/`
- 改动文件命中数据库定义:`db/` 下的 DDL / migration / seed 文件
- 根目录散文件(`pyproject.toml``.env*` 等)
- 发生 DB schema / migration / *.sql / *.prisma 变更
- 明确属于业务口径/资金精度舍入/API 契约/鉴权权限/调度游标 等逻辑改变
根据 `audit_context.json` 中的 `audit_required``reasons` 判断
- `audit_required: true` → 执行完整审计流程
- `audit_required: false` → 输出"无需审计",清除标记,退出
## 执行策略(尽量少写、但必须完整
1) 判断是否逻辑改动
2) 若是逻辑改动:
- 按需调用 skill
- steering-readme-maintainer同步 product/tech/structure-lite/README
- change-annotation-audit写 docs/audit/changes/... + AI_CHANGELOG + CHANGE 注释
- bd-manual-db-docs仅当 DB schema 变更)
3) 完成后把 `.kiro/.audit_state.json` `audit_required` 置为 false或清空 reasons/changed_files/last_reminded_at
4) 执行 `python scripts/audit/gen_audit_dashboard.py` 刷新审计一览表
## 执行策略(从 context 驱动,不做冗余扫描
### 步骤 1读取上下文
读取 `.kiro/.audit_context.json`,提取关键字段。
### 步骤 2审计落盘按需调用 skill
根据 `reasons` 判断需要哪些 skill
-`dir:backend` / `dir:etl` / `dir:shared` 等 → 调用 `steering-readme-maintainer`
- 含任意高风险标签 → 调用 `change-annotation-audit`(写 docs/audit/changes/ + AI_CHANGELOG + CHANGE 注释)
-`db-schema-change` → 调用 `bd-manual-db-docs`
`external_files` 非空,在审计记录(`docs/audit/changes/` 文件)中增加「外部变更」段落:
- 列出所有外部变更文件路径
- 标注来源为"非 Kiro 操作CLI/脚本/手动编辑)"
- 若外部变更涉及高风险路径,额外标注 ⚠️
### 步骤 3文档校对补齐
遍历 `compliance.code_without_docs`,对每个缺失项:
- 读取对应代码文件当前内容(不需要 diff直接读文件
- 更新对应文档:
| 代码路径前缀 | 应同步更新的文档 |
|---|---|
| `apps/backend/app/routers/` | `apps/backend/docs/API-REFERENCE.md` |
| `apps/backend/app/services/` | `apps/backend/docs/API-REFERENCE.md` + `apps/backend/README.md` |
| `apps/backend/app/auth/` | `apps/backend/docs/API-REFERENCE.md` + `apps/backend/README.md` |
| `apps/etl/connectors/feiqiu/tasks/` | `apps/etl/connectors/feiqiu/docs/etl_tasks/` |
| `apps/etl/connectors/feiqiu/loaders/` | `apps/etl/connectors/feiqiu/docs/etl_tasks/` |
| `apps/etl/connectors/feiqiu/scd/` | `apps/etl/connectors/feiqiu/docs/business-rules/scd2_rules.md` |
| `apps/etl/connectors/feiqiu/orchestration/` | `apps/etl/connectors/feiqiu/docs/architecture/` |
| `apps/admin-web/src/` | `apps/admin-web/README.md` |
| `apps/miniprogram/` | `apps/miniprogram/README.md` |
| `packages/shared/` | `packages/shared/README.md` |
| `db/*/migrations/*.sql` | `docs/database/BD_Manual_*.md` + `docs/database/ddl/` |
### 步骤 4DDL/迁移检查
-`compliance.new_migration_sql` 非空:
- 连接测试库验证迁移是否已执行
- 在审计记录中标注执行状态
-`compliance.new_migration_sql` 非空且 `compliance.has_ddl_baseline` 为 false
- 在审计记录中标注 ⚠️ DDL 基线待合并
### 步骤 5收尾
-`.kiro/.audit_state.json``audit_required` 置为 false清空 `reasons`/`changed_files`/`last_reminded_at`
- 执行 `python scripts/audit/gen_audit_dashboard.py` 刷新审计一览表
## 输出(强制极短回执)
你最终只允许输出 3 段信息:

View File

@@ -0,0 +1,15 @@
{
"enabled": true,
"name": "Agent On Stop (Merged)",
"description": "合并 hook对话结束时检测变更含非 Kiro 外部变更)、记录 session log、合规预扫描、构建审计上下文、审计提醒。无变更时跳过。纯 Shell零 Token。",
"version": "1",
"when": {
"type": "agentStop"
},
"then": {
"type": "runCommand",
"command": "python .kiro/scripts/agent_on_stop.py"
},
"workspaceFolderName": "NeoZQYY",
"shortName": "agent-on-stop"
}

View File

@@ -1,5 +1,5 @@
{
"enabled": true,
"enabled": false,
"name": "Audit Flagger (Prompt Submit)",
"description": "每次提交 prompt 时,基于 git status 判断是否存在高风险改动;若需要审计则写入 .kiro/.audit_state.json无 stdout。",
"version": "1",

View File

@@ -1,5 +1,5 @@
{
"enabled": true,
"enabled": false,
"name": "Audit Reminder (Agent Stop, 15min)",
"description": "若检测到高风险改动且未审计,则在 agentStop 以 stderr+非0 形式提醒15 分钟限频;不写 stdout。",
"version": "1",

View File

@@ -0,0 +1,15 @@
{
"enabled": false,
"name": "Change Compliance Check (Agent Stop)",
"description": "对话结束时审查本次变更的合规性DB 迁移是否已执行、DDL 是否合并至基线、新增文件是否遵循 doc-map、代码修改是否有对应文档/审计记录。先运行预扫描脚本过滤,无需审查时静默跳过以节省 Token。",
"version": "1",
"when": {
"type": "agentStop"
},
"then": {
"type": "askAgent",
"prompt": "先运行 `python .kiro/scripts/change_compliance_prescan.py` 获取预扫描结果。\n\n如果输出为 `NO_CHECK_NEEDED`,则回复「✅ 合规检查:无需审查项」,不做任何其他操作。\n\n如果输出为 JSON则根据以下清单逐项审查并输出简短结论每项一行用 ✅/⚠️ 标记):\n\n1. **DB 迁移执行**:检查 `new_migration_sql` 中的 SQL 文件连接测试库pg_etl_test / pg_app_test验证对应表/字段是否已存在。若未执行,标记 ⚠️ 并列出待执行文件。\n2. **DDL 基线合并**:若有迁移 SQL 但 `has_ddl_baseline` 为 false检查 `docs/database/ddl/` 下对应基线文件是否已更新。\n3. **目录规范**:检查变更文件列表中的新增文件路径是否符合 doc-map 规范(模块专属放模块内、项目级放根目录、审计产物放 docs/audit/)。\n4. **文档同步**:检查 `code_without_docs` 列表,列出缺少对应文档更新的代码文件及其应更新的文档路径。\n\n输出格式极简不超过 15 行。"
},
"workspaceFolderName": "NeoZQYY",
"shortName": "change-compliance"
}

View File

@@ -1,5 +1,5 @@
{
"enabled": true,
"enabled": false,
"name": "Prompt Audit Log (Shell)",
"description": "每次提交 prompt 时,用本地 Shell 在 docs/audit/prompt_logs/ 生成独立日志文件(按时间戳命名);不触发 LLM避免上下文膨胀。",
"version": "3",

View File

@@ -0,0 +1,15 @@
{
"enabled": true,
"name": "Prompt On Submit (Merged)",
"description": "合并 hook每次提交 prompt 时执行风险标记 + prompt 日志记录 + git 快照。纯 Shell零 Token。",
"version": "1",
"when": {
"type": "promptSubmit"
},
"then": {
"type": "runCommand",
"command": "python .kiro/scripts/prompt_on_submit.py"
},
"workspaceFolderName": "NeoZQYY",
"shortName": "prompt-on-submit"
}

View File

@@ -1,14 +1,14 @@
{
"enabled": true,
"name": "Manual: Run /audit (via audit-writer subagent)",
"description": "按需触发:启动 audit-writer 子代理执行变更影响审查+文档同步+审计落盘,完成后自动刷新审计一览表,并仅回传极短回执。",
"version": "2",
"description": "按需触发:读取 agent-on-stop 预构建的审计上下文,启动 audit-writer 子代理执行审计落盘+文档校对。上下文过期时自动重建。",
"version": "5",
"when": {
"type": "userTriggered"
},
"then": {
"type": "askAgent",
"prompt": "立刻启动名为 audit-writer 的子代理来执行「后处理写入/审计收口」流程。\n\n约束\n- 子代理自行使用 git status/diff 与 .kiro/.last_prompt_id.json 中最新 Prompt-ID 作为溯源;不要依赖主对话上下文。\n- 子代理必须按需调用 skillsteering-readme-maintainer、change-annotation-audit、bd-manual-db-docs仅在满足触发条件时。\n- 所有审计产物统一写入项目根目录 docs/audit/(变更记录写 docs/audit/changes/prompt 日志写 docs/audit/prompt_logs/,不写入子模块内部。\n- 子代理结束后,必须把 .kiro/.audit_state.json 中 audit_required 置为 false(或清空文件),以停止后续提醒。\n- 审计落盘完成后,必须执行 `python scripts/audit/gen_audit_dashboard.py` 刷新审计一览表docs/audit/audit_dashboard.md。\n- 你的最终回复必须是极短回执」,只包含:\n 1) 是否完成yes/no\n 2) 写了哪些文件(文件列表)\n 3) 如果失败下一步怎么做1~2 条)"
"prompt": "执行 /audit 审计流程:\n\n**前置检查**:读取 `.kiro/.audit_context.json`,检查 `built_at` 时间戳。若文件不存在或 `built_at` 超过 30 分钟,先运行 `python .kiro/scripts/agent_on_stop.py` 重建上下文,再重新读取。\n\n**主流程**:启动名为 audit-writer 的子代理,传入以下指令:\n\n> 读取 `.kiro/.audit_context.json` 作为唯一输入,不要自行运行 git status/diff/扫描文件。该文件已包含:变更文件列表、高风险文件 diff、合规检查清单文档缺失/迁移状态/DDL 基线、外部变更文件列表external_files、Prompt-ID 溯源。按 audit-writer.md 中定义的执行策略完成审计落盘+文档校对补齐。\n\n约束\n- 子代理禁止重复运行 git status --porcelain 或 git diff 全量扫描,所有信息已在 .audit_context.json 中预备好。\n- 子代理需要读取具体文件内容时(如更新文档),可以直接读取对应文件,但不要做全仓库遍历。\n- 子代理必须按需调用 skillsteering-readme-maintainer、change-annotation-audit、bd-manual-db-docs仅在满足触发条件时。\n- 子代理必须根据 compliance.code_without_docs 自动补齐缺失的文档同步。\n- 若 external_files 非空,在审计记录中增加「外部变更」段落,列出这些文件并标注来源为非 Kiro 操作。\n- 所有审计产物统一写入 docs/audit/,不写入子模块内部。\n- 完成后把 .kiro/.audit_state.json 中 audit_required 置为 false。\n- 执行 `python scripts/audit/gen_audit_dashboard.py` 刷新审计一览表。\n- 最终回复必须是极短回执done/files_written/next_step。"
},
"workspaceFolderName": "NeoZQYY",
"shortName": "audit"

View File

@@ -0,0 +1,15 @@
{
"enabled": false,
"name": "Session Log (Agent Stop)",
"description": "每次对话结束时记录本次对话的完整日志用户输入、agent 输出、变更文件、git diff stat到 docs/audit/session_logs/。纯 Shell 执行,不触发 LLM。",
"version": "1",
"when": {
"type": "agentStop"
},
"then": {
"type": "runCommand",
"command": "python .kiro/scripts/session_log.py"
},
"workspaceFolderName": "NeoZQYY",
"shortName": "session-log"
}

View File

@@ -0,0 +1,498 @@
#!/usr/bin/env python3
"""agent_on_stop — agentStop 合并 hook 脚本。
合并原 audit_reminder + session_log + change_compliance_prescan + build_audit_context
1. 检测变更(对比 promptSubmit 快照,识别非 Kiro 变更)
2. 若无任何文件变更 → 跳过所有审查,静默退出
3. 记录 session log → docs/audit/session_logs/
4. 合规预扫描 → .kiro/.compliance_state.json
5. 构建审计上下文 → .kiro/.audit_context.json
6. 审计提醒15 分钟限频)→ stderr
所有功能块用 try/except 隔离,单个失败不影响其他。
"""
import hashlib
import json
import os
import re
import subprocess
import sys
from datetime import datetime, timezone, timedelta
TZ_TAIPEI = timezone(timedelta(hours=8))
MIN_INTERVAL = timedelta(minutes=15)
# 路径常量
STATE_PATH = os.path.join(".kiro", ".audit_state.json")
SNAPSHOT_PATH = os.path.join(".kiro", ".git_snapshot.json")
COMPLIANCE_PATH = os.path.join(".kiro", ".compliance_state.json")
CONTEXT_PATH = os.path.join(".kiro", ".audit_context.json")
PROMPT_ID_PATH = os.path.join(".kiro", ".last_prompt_id.json")
SESSION_LOG_DIR = os.path.join("docs", "audit", "session_logs")
# 噪声路径
NOISE_PATTERNS = [
re.compile(r"^docs/audit/"),
re.compile(r"^\.kiro/"),
re.compile(r"^\.hypothesis/"),
re.compile(r"^tmp/"),
re.compile(r"\.png$"),
re.compile(r"\.jpg$"),
]
# 高风险路径
HIGH_RISK_PATTERNS = [
re.compile(r"^apps/etl/connectors/feiqiu/(api|cli|config|database|loaders|models|orchestration|scd|tasks|utils|quality)/"),
re.compile(r"^apps/backend/app/"),
re.compile(r"^apps/admin-web/src/"),
re.compile(r"^apps/miniprogram/"),
re.compile(r"^packages/shared/"),
re.compile(r"^db/"),
]
# 文档映射(合规检查用)
DOC_MAP = {
"apps/backend/app/routers/": ["apps/backend/docs/API-REFERENCE.md"],
"apps/backend/app/services/": ["apps/backend/docs/API-REFERENCE.md", "apps/backend/README.md"],
"apps/backend/app/auth/": ["apps/backend/docs/API-REFERENCE.md", "apps/backend/README.md"],
"apps/etl/connectors/feiqiu/tasks/": ["apps/etl/connectors/feiqiu/docs/etl_tasks/"],
"apps/etl/connectors/feiqiu/loaders/": ["apps/etl/connectors/feiqiu/docs/etl_tasks/"],
"apps/etl/connectors/feiqiu/scd/": ["apps/etl/connectors/feiqiu/docs/business-rules/scd2_rules.md"],
"apps/etl/connectors/feiqiu/orchestration/": ["apps/etl/connectors/feiqiu/docs/architecture/"],
"apps/admin-web/src/": ["apps/admin-web/README.md"],
"apps/miniprogram/": ["apps/miniprogram/README.md"],
"packages/shared/": ["packages/shared/README.md"],
}
MIGRATION_PATTERNS = [
re.compile(r"^db/etl_feiqiu/migrations/.*\.sql$"),
re.compile(r"^db/zqyy_app/migrations/.*\.sql$"),
re.compile(r"^db/fdw/.*\.sql$"),
]
BD_MANUAL_PATTERN = re.compile(r"^docs/database/BD_Manual_.*\.md$")
DDL_BASELINE_DIR = "docs/database/ddl/"
AUDIT_CHANGES_DIR = "docs/audit/changes/"
def now_taipei():
return datetime.now(TZ_TAIPEI)
def sha1hex(s: str) -> str:
return hashlib.sha1(s.encode("utf-8")).hexdigest()
def is_noise(f: str) -> bool:
return any(p.search(f) for p in NOISE_PATTERNS)
def safe_read_json(path):
if not os.path.isfile(path):
return {}
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return {}
def write_json(path, data):
os.makedirs(os.path.dirname(path) or ".kiro", exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
def get_changed_files() -> list[str]:
try:
r = subprocess.run(
["git", "status", "--porcelain"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10
)
if r.returncode != 0:
return []
except Exception:
return []
files = []
for line in r.stdout.splitlines():
if len(line) < 4:
continue
path = line[3:].strip()
if " -> " in path:
path = path.split(" -> ")[-1]
path = path.strip().strip('"').replace("\\", "/")
if path:
files.append(path)
return sorted(set(files))
def git_diff_stat():
try:
r = subprocess.run(
["git", "diff", "--stat", "HEAD"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15
)
return r.stdout.strip() if r.returncode == 0 else ""
except Exception:
return ""
def git_diff_files(files, max_total=30000):
if not files:
return ""
all_diff = []
total_len = 0
for f in files:
if total_len >= max_total:
all_diff.append(f"\n[TRUNCATED: diff exceeds {max_total // 1000}KB]")
break
try:
r = subprocess.run(
["git", "diff", "HEAD", "--", f],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10
)
if r.returncode == 0 and r.stdout.strip():
chunk = r.stdout.strip()
if len(chunk) > 5000:
chunk = chunk[:5000] + f"\n[TRUNCATED: {f} diff too long]"
all_diff.append(chunk)
total_len += len(chunk)
except Exception:
continue
return "\n".join(all_diff)
def get_latest_prompt_log():
log_dir = os.path.join("docs", "audit", "prompt_logs")
if not os.path.isdir(log_dir):
return ""
try:
files = sorted(
[f for f in os.listdir(log_dir) if f.startswith("prompt_log_")],
reverse=True
)
if not files:
return ""
with open(os.path.join(log_dir, files[0]), "r", encoding="utf-8") as f:
content = f.read()
return content[:3000] + "\n[TRUNCATED]" if len(content) > 3000 else content
except Exception:
return ""
# ── 步骤 1检测变更识别非 Kiro 变更 ──
def detect_changes(current_files):
"""对比 promptSubmit 快照,返回 (real_files, external_files, no_change)"""
snapshot = safe_read_json(SNAPSHOT_PATH)
snapshot_files = set(snapshot.get("files", []))
current_set = set(current_files)
# 排除噪声后的真实变更
real_files = sorted(f for f in current_files if not is_noise(f))
if not real_files:
return [], [], True
# 检测非 Kiro 变更:在 agentStop 时出现但 promptSubmit 快照中没有的文件
# 这些是对话期间由外部操作CLI、脚本等产生的变更
new_since_submit = current_set - snapshot_files
external_files = sorted(f for f in new_since_submit if not is_noise(f))
return real_files, external_files, False
# ── 步骤 2Session Log ──
def do_session_log(now, changed_files, external_files):
agent_output = os.environ.get("AGENT_OUTPUT", "")
user_prompt = os.environ.get("USER_PROMPT", "")
prompt_info = safe_read_json(PROMPT_ID_PATH)
audit_state = safe_read_json(STATE_PATH)
prompt_id = prompt_info.get("prompt_id", "unknown")
max_len = 50000
if len(agent_output) > max_len:
agent_output = agent_output[:max_len] + "\n\n[TRUNCATED: output exceeds 50KB]"
if len(user_prompt) > 10000:
user_prompt = user_prompt[:10000] + "\n\n[TRUNCATED: prompt exceeds 10KB]"
diff_stat = git_diff_stat()
git_status = ""
try:
r = subprocess.run(
["git", "status", "--short"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10
)
git_status = r.stdout.strip() if r.returncode == 0 else ""
except Exception:
pass
os.makedirs(SESSION_LOG_DIR, exist_ok=True)
filename = f"session_{now.strftime('%Y%m%d_%H%M%S')}.md"
# 外部变更标记
external_section = ""
if external_files:
ext_list = "\n".join(external_files[:30])
external_section = f"""
## External Changes (non-Kiro, {len(external_files)} files)
以下文件在本次对话期间由外部操作CLI/脚本/手动编辑)产生:
```
{ext_list}
```
"""
content = f"""# Session Log — {now.strftime('%Y-%m-%d %H:%M:%S %z')}
- Prompt-ID: `{prompt_id}`
- Audit Required: `{audit_state.get('audit_required', 'N/A')}`
- Reasons: {', '.join(audit_state.get('reasons', [])) or 'none'}
- External Changes: {len(external_files)} files
## User Input
```text
{user_prompt or '(not captured)'}
```
## Agent Output
```text
{agent_output or '(not captured)'}
```
## Changed Files ({len(changed_files)})
```
{chr(10).join(changed_files[:80]) if changed_files else '(none)'}
```
{external_section}
## Git Diff Stat
```
{diff_stat}
```
## Git Status
```
{git_status or '(clean)'}
```
"""
with open(os.path.join(SESSION_LOG_DIR, filename), "w", encoding="utf-8") as f:
f.write(content)
# ── 步骤 3合规预扫描 ──
def do_compliance_prescan(all_files):
result = {
"new_migration_sql": [],
"new_or_modified_sql": [],
"code_without_docs": [],
"new_files": [],
"has_bd_manual": False,
"has_audit_record": False,
"has_ddl_baseline": False,
}
code_files = []
doc_files = set()
for f in all_files:
if is_noise(f):
continue
for mp in MIGRATION_PATTERNS:
if mp.search(f):
result["new_migration_sql"].append(f)
break
if f.endswith(".sql"):
result["new_or_modified_sql"].append(f)
if BD_MANUAL_PATTERN.search(f):
result["has_bd_manual"] = True
if f.startswith(AUDIT_CHANGES_DIR):
result["has_audit_record"] = True
if f.startswith(DDL_BASELINE_DIR):
result["has_ddl_baseline"] = True
if f.endswith(".md") or "/docs/" in f:
doc_files.add(f)
if f.endswith((".py", ".ts", ".tsx", ".js", ".jsx")):
code_files.append(f)
for cf in code_files:
expected_docs = []
for prefix, docs in DOC_MAP.items():
if cf.startswith(prefix):
expected_docs.extend(docs)
if expected_docs:
has_doc = False
for ed in expected_docs:
if ed in doc_files:
has_doc = True
break
if ed.endswith("/") and any(d.startswith(ed) for d in doc_files):
has_doc = True
break
if not has_doc:
result["code_without_docs"].append({
"file": cf,
"expected_docs": expected_docs,
})
needs_check = bool(
result["new_migration_sql"]
or result["code_without_docs"]
)
now = now_taipei()
write_json(COMPLIANCE_PATH, {
"needs_check": needs_check,
"scanned_at": now.isoformat(),
**result,
})
return result
# ── 步骤 4构建审计上下文 ──
def do_build_audit_context(all_files, external_files, compliance):
now = now_taipei()
audit_state = safe_read_json(STATE_PATH)
prompt_info = safe_read_json(PROMPT_ID_PATH)
changed_files = audit_state.get("changed_files", all_files[:50])
high_risk_files = [
f for f in changed_files
if any(p.search(f) for p in HIGH_RISK_PATTERNS)
]
diff_stat = git_diff_stat()
high_risk_diff = git_diff_files(high_risk_files)
prompt_log = get_latest_prompt_log()
context = {
"built_at": now.isoformat(),
"prompt_id": prompt_info.get("prompt_id", "unknown"),
"prompt_at": prompt_info.get("at", ""),
"audit_required": audit_state.get("audit_required", False),
"db_docs_required": audit_state.get("db_docs_required", False),
"reasons": audit_state.get("reasons", []),
"changed_files": changed_files,
"high_risk_files": high_risk_files,
"external_files": external_files,
"compliance": {
"code_without_docs": compliance.get("code_without_docs", []),
"new_migration_sql": compliance.get("new_migration_sql", []),
"has_bd_manual": compliance.get("has_bd_manual", False),
"has_audit_record": compliance.get("has_audit_record", False),
"has_ddl_baseline": compliance.get("has_ddl_baseline", False),
},
"diff_stat": diff_stat,
"high_risk_diff": high_risk_diff,
"latest_prompt_log": prompt_log,
}
write_json(CONTEXT_PATH, context)
# ── 步骤 5审计提醒15 分钟限频) ──
def do_audit_reminder(real_files):
state = safe_read_json(STATE_PATH)
if not state.get("audit_required"):
return
# 工作树干净时清除
if not real_files:
state["audit_required"] = False
state["reasons"] = []
state["changed_files"] = []
state["last_reminded_at"] = None
write_json(STATE_PATH, state)
return
now = now_taipei()
last_str = state.get("last_reminded_at")
if last_str:
try:
last = datetime.fromisoformat(last_str)
if (now - last) < MIN_INTERVAL:
return
except Exception:
pass
state["last_reminded_at"] = now.isoformat()
write_json(STATE_PATH, state)
reasons = state.get("reasons", [])
reason_text = ", ".join(reasons) if reasons else "high-risk paths changed"
ext_note = ""
# 从 context 读取外部变更数量
ctx = safe_read_json(CONTEXT_PATH)
ext_count = len(ctx.get("external_files", []))
if ext_count:
ext_note = f" (includes {ext_count} external/non-Kiro changes)"
sys.stderr.write(
f"[AUDIT REMINDER] Pending audit ({reason_text}){ext_note}. "
f"Run /audit to sync. (15min rate limit)\n"
)
sys.exit(1)
def main():
# 非 git 仓库直接退出
try:
r = subprocess.run(
["git", "rev-parse", "--is-inside-work-tree"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5
)
if r.returncode != 0:
return
except Exception:
return
now = now_taipei()
current_files = get_changed_files()
# 步骤 1检测变更
real_files, external_files, no_change = detect_changes(current_files)
# 无任何文件变更 → 跳过所有审查
if no_change:
return
# 步骤 2Session Log始终记录包括外部变更
try:
do_session_log(now, real_files, external_files)
except Exception:
pass
# 步骤 3合规预扫描
compliance = {}
try:
compliance = do_compliance_prescan(current_files)
except Exception:
pass
# 步骤 4构建审计上下文预备 /audit 使用)
try:
do_build_audit_context(current_files, external_files, compliance)
except Exception:
pass
# 步骤 5审计提醒最后执行可能 sys.exit(1)
try:
do_audit_reminder(real_files)
except SystemExit:
raise
except Exception:
pass
if __name__ == "__main__":
try:
main()
except SystemExit as e:
sys.exit(e.code)
except Exception:
pass

View File

@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""build_audit_context — 合并所有前置 hook 产出,生成统一审计上下文快照。
读取:
- .kiro/.audit_state.jsonaudit-flagger 产出:风险判定、变更文件列表)
- .kiro/.compliance_state.jsonchange-compliance 产出:文档缺失、迁移状态)
- .kiro/.last_prompt_id.jsonprompt-audit-log 产出Prompt ID 溯源)
- git diff --stat HEAD变更统计摘要
- git diff HEAD仅高风险文件的 diff截断到合理长度
输出:.kiro/.audit_context.jsonaudit-writer 子代理的唯一输入)
"""
import json
import os
import re
import subprocess
import sys
from datetime import datetime, timezone, timedelta
TZ_TAIPEI = timezone(timedelta(hours=8))
CONTEXT_PATH = os.path.join(".kiro", ".audit_context.json")
# 高风险路径(只对这些文件取 diff避免 diff 过大)
HIGH_RISK_PATTERNS = [
re.compile(r"^apps/etl/connectors/feiqiu/(api|cli|config|database|loaders|models|orchestration|scd|tasks|utils|quality)/"),
re.compile(r"^apps/backend/app/"),
re.compile(r"^apps/admin-web/src/"),
re.compile(r"^apps/miniprogram/"),
re.compile(r"^packages/shared/"),
re.compile(r"^db/"),
]
def safe_read_json(path):
if not os.path.isfile(path):
return {}
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return {}
def git_diff_stat():
try:
r = subprocess.run(
["git", "diff", "--stat", "HEAD"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15
)
return r.stdout.strip() if r.returncode == 0 else ""
except Exception:
return ""
def git_diff_files(files, max_total=30000):
"""获取指定文件的 git diff截断到 max_total 字符"""
if not files:
return ""
# 分批取 diff避免命令行过长
all_diff = []
total_len = 0
for f in files:
if total_len >= max_total:
all_diff.append(f"\n[TRUNCATED: diff exceeds {max_total // 1000}KB limit]")
break
try:
r = subprocess.run(
["git", "diff", "HEAD", "--", f],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10
)
if r.returncode == 0 and r.stdout.strip():
chunk = r.stdout.strip()
# 单文件 diff 截断
if len(chunk) > 5000:
chunk = chunk[:5000] + f"\n[TRUNCATED: {f} diff too long]"
all_diff.append(chunk)
total_len += len(chunk)
except Exception:
continue
return "\n".join(all_diff)
def get_latest_prompt_log():
"""获取最新的 prompt log 文件内容(用于溯源)"""
log_dir = os.path.join("docs", "audit", "prompt_logs")
if not os.path.isdir(log_dir):
return ""
try:
files = sorted(
[f for f in os.listdir(log_dir) if f.startswith("prompt_log_")],
reverse=True
)
if not files:
return ""
latest = os.path.join(log_dir, files[0])
with open(latest, "r", encoding="utf-8") as f:
content = f.read()
# 截断过长内容
if len(content) > 3000:
content = content[:3000] + "\n[TRUNCATED]"
return content
except Exception:
return ""
def main():
now = datetime.now(TZ_TAIPEI)
# 读取前置 hook 产出
audit_state = safe_read_json(os.path.join(".kiro", ".audit_state.json"))
compliance = safe_read_json(os.path.join(".kiro", ".compliance_state.json"))
prompt_id_info = safe_read_json(os.path.join(".kiro", ".last_prompt_id.json"))
# 从 audit_state 提取高风险文件
changed_files = audit_state.get("changed_files", [])
high_risk_files = [
f for f in changed_files
if any(p.search(f) for p in HIGH_RISK_PATTERNS)
]
# 获取 diff仅高风险文件
diff_stat = git_diff_stat()
high_risk_diff = git_diff_files(high_risk_files)
# 获取最新 prompt log
prompt_log = get_latest_prompt_log()
# 构建统一上下文
context = {
"built_at": now.isoformat(),
"prompt_id": prompt_id_info.get("prompt_id", "unknown"),
"prompt_at": prompt_id_info.get("at", ""),
# 来自 audit-flagger
"audit_required": audit_state.get("audit_required", False),
"db_docs_required": audit_state.get("db_docs_required", False),
"reasons": audit_state.get("reasons", []),
"changed_files": changed_files,
"high_risk_files": high_risk_files,
# 来自 change-compliance-prescan
"compliance": {
"code_without_docs": compliance.get("code_without_docs", []),
"new_migration_sql": compliance.get("new_migration_sql", []),
"has_bd_manual": compliance.get("has_bd_manual", False),
"has_audit_record": compliance.get("has_audit_record", False),
"has_ddl_baseline": compliance.get("has_ddl_baseline", False),
},
# git 摘要
"diff_stat": diff_stat,
"high_risk_diff": high_risk_diff,
# prompt 溯源
"latest_prompt_log": prompt_log,
}
os.makedirs(".kiro", exist_ok=True)
with open(CONTEXT_PATH, "w", encoding="utf-8") as f:
json.dump(context, f, indent=2, ensure_ascii=False)
# 输出摘要到 stdout
print(f"audit_context built: {len(changed_files)} files, "
f"{len(high_risk_files)} high-risk, "
f"{len(compliance.get('code_without_docs', []))} docs missing")
if __name__ == "__main__":
try:
main()
except Exception as e:
sys.stderr.write(f"build_audit_context failed: {e}\n")
sys.exit(1)

View File

@@ -0,0 +1,243 @@
#!/usr/bin/env python3
"""change_compliance_prescan — 预扫描变更文件,输出需要合规审查的项目。
在 agentStop 时由 askAgent hook 调用,为 LLM 提供精简的审查清单,
避免 LLM 自行扫描文件浪费 Token。
输出到 stdout供 askAgent 读取):
- 若无需审查:输出 "NO_CHECK_NEEDED"
- 若需审查:输出结构化 JSON 清单
"""
import json
import os
import re
import subprocess
import sys
from datetime import datetime, timezone, timedelta
TZ_TAIPEI = timezone(timedelta(hours=8))
STATE_PATH = os.path.join(".kiro", ".audit_state.json")
# doc-map 中定义的文档对应关系
DOC_MAP = {
# 代码路径前缀 → 应同步更新的文档
"apps/backend/app/routers/": ["apps/backend/docs/API-REFERENCE.md"],
"apps/backend/app/services/": ["apps/backend/docs/API-REFERENCE.md", "apps/backend/README.md"],
"apps/backend/app/auth/": ["apps/backend/docs/API-REFERENCE.md", "apps/backend/README.md"],
"apps/etl/connectors/feiqiu/tasks/": ["apps/etl/connectors/feiqiu/docs/etl_tasks/"],
"apps/etl/connectors/feiqiu/loaders/": ["apps/etl/connectors/feiqiu/docs/etl_tasks/"],
"apps/etl/connectors/feiqiu/scd/": ["apps/etl/connectors/feiqiu/docs/business-rules/scd2_rules.md"],
"apps/etl/connectors/feiqiu/orchestration/": ["apps/etl/connectors/feiqiu/docs/architecture/"],
"apps/admin-web/src/": ["apps/admin-web/README.md"],
"apps/miniprogram/": ["apps/miniprogram/README.md"],
"packages/shared/": ["packages/shared/README.md"],
}
# DDL 基线文件doc-map 中定义)
DDL_BASELINE_DIR = "docs/database/ddl/"
# 迁移脚本路径
MIGRATION_PATTERNS = [
re.compile(r"^db/etl_feiqiu/migrations/.*\.sql$"),
re.compile(r"^db/zqyy_app/migrations/.*\.sql$"),
re.compile(r"^db/fdw/.*\.sql$"),
]
# DB 文档路径
BD_MANUAL_PATTERN = re.compile(r"^docs/database/BD_Manual_.*\.md$")
# 审计记录路径
AUDIT_CHANGES_DIR = "docs/audit/changes/"
# 噪声路径(不参与合规检查)
NOISE = [
re.compile(r"^docs/audit/"),
re.compile(r"^\.kiro/"),
re.compile(r"^\.hypothesis/"),
re.compile(r"^tmp/"),
re.compile(r"\.png$"),
re.compile(r"\.jpg$"),
]
def safe_read_json(path):
if not os.path.isfile(path):
return {}
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return {}
def get_changed_files():
"""从 audit_state 或 git status 获取变更文件"""
state = safe_read_json(STATE_PATH)
files = state.get("changed_files", [])
if files:
return files
# 回退到 git status
try:
r = subprocess.run(
["git", "status", "--porcelain"],
capture_output=True, text=True, timeout=10
)
if r.returncode != 0:
return []
result = []
for line in r.stdout.splitlines():
if len(line) < 4:
continue
path = line[3:].strip().strip('"').replace("\\", "/")
if " -> " in path:
path = path.split(" -> ")[-1]
if path:
result.append(path)
return sorted(set(result))
except Exception:
return []
def is_noise(f):
return any(p.search(f) for p in NOISE)
def classify_files(files):
"""将变更文件分类,输出审查清单"""
result = {
"new_migration_sql": [], # 新增的迁移 SQL
"new_or_modified_sql": [], # 所有 SQL 变更
"code_without_docs": [], # 有代码改动但缺少对应文档改动
"new_files": [], # 新增文件(需检查目录规范)
"has_bd_manual": False, # 是否有 BD_Manual 文档变更
"has_audit_record": False, # 是否有审计记录变更
"has_ddl_baseline": False, # 是否有 DDL 基线变更
}
code_files = []
doc_files = set()
for f in files:
if is_noise(f):
continue
# 迁移 SQL
for mp in MIGRATION_PATTERNS:
if mp.search(f):
result["new_migration_sql"].append(f)
break
# SQL 文件
if f.endswith(".sql"):
result["new_or_modified_sql"].append(f)
# BD_Manual
if BD_MANUAL_PATTERN.search(f):
result["has_bd_manual"] = True
# 审计记录
if f.startswith(AUDIT_CHANGES_DIR):
result["has_audit_record"] = True
# DDL 基线
if f.startswith(DDL_BASELINE_DIR):
result["has_ddl_baseline"] = True
# 文档文件
if f.endswith(".md") or "/docs/" in f:
doc_files.add(f)
# 代码文件(非文档、非配置)
if f.endswith((".py", ".ts", ".tsx", ".js", ".jsx")):
code_files.append(f)
# 检查代码文件是否有对应文档变更
for cf in code_files:
expected_docs = []
for prefix, docs in DOC_MAP.items():
if cf.startswith(prefix):
expected_docs.extend(docs)
if expected_docs:
# 检查是否有任一对应文档在变更列表中
has_doc = False
for ed in expected_docs:
if ed in doc_files:
has_doc = True
break
# 目录级匹配
if ed.endswith("/"):
if any(d.startswith(ed) for d in doc_files):
has_doc = True
break
if not has_doc:
result["code_without_docs"].append({
"file": cf,
"expected_docs": expected_docs,
})
return result
COMPLIANCE_STATE_PATH = os.path.join(".kiro", ".compliance_state.json")
def save_compliance_state(result, needs_check):
"""持久化合规检查结果,供 audit-writer 子代理读取"""
os.makedirs(".kiro", exist_ok=True)
now = datetime.now(TZ_TAIPEI)
state = {
"needs_check": needs_check,
"scanned_at": now.isoformat(),
**result,
}
with open(COMPLIANCE_STATE_PATH, "w", encoding="utf-8") as f:
json.dump(state, f, indent=2, ensure_ascii=False)
def main():
files = get_changed_files()
if not files:
save_compliance_state({"new_migration_sql": [], "new_or_modified_sql": [],
"code_without_docs": [], "new_files": [],
"has_bd_manual": False, "has_audit_record": False,
"has_ddl_baseline": False}, False)
print("NO_CHECK_NEEDED")
return
# 过滤噪声
real_files = [f for f in files if not is_noise(f)]
if not real_files:
save_compliance_state({"new_migration_sql": [], "new_or_modified_sql": [],
"code_without_docs": [], "new_files": [],
"has_bd_manual": False, "has_audit_record": False,
"has_ddl_baseline": False}, False)
print("NO_CHECK_NEEDED")
return
result = classify_files(files)
# 判断是否需要审查
needs_check = (
result["new_migration_sql"]
or result["code_without_docs"]
or (result["new_migration_sql"] and not result["has_ddl_baseline"])
)
# 始终持久化结果
save_compliance_state(result, needs_check)
if not needs_check:
print("NO_CHECK_NEEDED")
return
# 输出精简 JSON 供 LLM 审查
print(json.dumps(result, indent=2, ensure_ascii=False))
if __name__ == "__main__":
try:
main()
except Exception as e:
# 出错时不阻塞,输出无需检查
print("NO_CHECK_NEEDED")

View File

@@ -0,0 +1,233 @@
#!/usr/bin/env python3
"""prompt_on_submit — promptSubmit 合并 hook 脚本。
合并原 audit_flagger + prompt_audit_log 的功能:
1. git status → 风险判定 → 写 .kiro/.audit_state.json
2. 记录 prompt 日志 → docs/audit/prompt_logs/
3. 记录当前 git fingerprint 快照 → .kiro/.git_snapshot.json供 agentStop 对比)
所有功能块用 try/except 隔离,单个失败不影响其他。
"""
import hashlib
import json
import os
import re
import subprocess
import sys
from datetime import datetime, timezone, timedelta
TZ_TAIPEI = timezone(timedelta(hours=8))
# ── 风险规则(来自 audit_flagger ──
RISK_RULES = [
(re.compile(r"^apps/etl/connectors/feiqiu/(api|cli|config|database|loaders|models|orchestration|scd|tasks|utils|quality)/"), "etl"),
(re.compile(r"^apps/backend/app/"), "backend"),
(re.compile(r"^apps/admin-web/src/"), "admin-web"),
(re.compile(r"^apps/miniprogram/(miniapp|miniprogram)/"), "miniprogram"),
(re.compile(r"^packages/shared/"), "shared"),
(re.compile(r"^db/"), "db"),
]
NOISE_PATTERNS = [
re.compile(r"^docs/audit/"),
re.compile(r"^\.kiro/"),
re.compile(r"^tmp/"),
re.compile(r"^\.hypothesis/"),
]
DB_PATTERNS = [
re.compile(r"^db/"),
re.compile(r"/migrations/"),
re.compile(r"\.sql$"),
re.compile(r"\.prisma$"),
]
STATE_PATH = os.path.join(".kiro", ".audit_state.json")
SNAPSHOT_PATH = os.path.join(".kiro", ".git_snapshot.json")
PROMPT_ID_PATH = os.path.join(".kiro", ".last_prompt_id.json")
def now_taipei():
return datetime.now(TZ_TAIPEI)
def sha1hex(s: str) -> str:
return hashlib.sha1(s.encode("utf-8")).hexdigest()
def get_changed_files() -> list[str]:
try:
r = subprocess.run(
["git", "status", "--porcelain"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10
)
if r.returncode != 0:
return []
except Exception:
return []
files = []
for line in r.stdout.splitlines():
if len(line) < 4:
continue
path = line[3:].strip()
if " -> " in path:
path = path.split(" -> ")[-1]
path = path.strip().strip('"').replace("\\", "/")
if path:
files.append(path)
return files
def is_noise(f: str) -> bool:
return any(p.search(f) for p in NOISE_PATTERNS)
def safe_read_json(path):
if not os.path.isfile(path):
return {}
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return {}
def write_json(path, data):
os.makedirs(os.path.dirname(path) or ".kiro", exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
# ── 功能块 1风险标记audit_flagger ──
def do_audit_flag(all_files, now):
files = sorted(set(f for f in all_files if not is_noise(f)))
if not files:
write_json(STATE_PATH, {
"audit_required": False,
"db_docs_required": False,
"reasons": [],
"changed_files": [],
"change_fingerprint": "",
"marked_at": now.isoformat(),
"last_reminded_at": None,
})
return
reasons = []
audit_required = False
db_docs_required = False
for f in files:
for pattern, label in RISK_RULES:
if pattern.search(f):
audit_required = True
tag = f"dir:{label}"
if tag not in reasons:
reasons.append(tag)
if "/" not in f:
audit_required = True
if "root-file" not in reasons:
reasons.append("root-file")
if any(p.search(f) for p in DB_PATTERNS):
db_docs_required = True
if "db-schema-change" not in reasons:
reasons.append("db-schema-change")
fp = sha1hex("\n".join(files))
# 保留已有 last_reminded_at
last_reminded = None
existing = safe_read_json(STATE_PATH)
if existing.get("change_fingerprint") == fp:
last_reminded = existing.get("last_reminded_at")
write_json(STATE_PATH, {
"audit_required": audit_required,
"db_docs_required": db_docs_required,
"reasons": reasons,
"changed_files": files[:50],
"change_fingerprint": fp,
"marked_at": now.isoformat(),
"last_reminded_at": last_reminded,
})
# ── 功能块 2Prompt 日志 ──
def do_prompt_log(now):
prompt_id = f"P{now.strftime('%Y%m%d-%H%M%S')}"
prompt_raw = os.environ.get("USER_PROMPT", "")
if len(prompt_raw) > 20000:
prompt_raw = prompt_raw[:5000] + "\n[TRUNCATED: prompt too long]"
summary = " ".join(prompt_raw.split()).strip()
if len(summary) > 120:
summary = summary[:120] + ""
if not summary:
summary = "(empty prompt)"
log_dir = os.path.join("docs", "audit", "prompt_logs")
os.makedirs(log_dir, exist_ok=True)
filename = f"prompt_log_{now.strftime('%Y%m%d_%H%M%S')}.md"
entry = f"""- [{prompt_id}] {now.strftime('%Y-%m-%d %H:%M:%S %z')}
- summary: {summary}
- prompt:
```text
{prompt_raw}
```
"""
with open(os.path.join(log_dir, filename), "w", encoding="utf-8") as f:
f.write(entry)
write_json(PROMPT_ID_PATH, {"prompt_id": prompt_id, "at": now.isoformat()})
# ── 功能块 3Git 快照(供 agentStop 对比检测非 Kiro 变更) ──
def do_git_snapshot(all_files, now):
fp = sha1hex("\n".join(sorted(all_files))) if all_files else ""
write_json(SNAPSHOT_PATH, {
"files": sorted(all_files)[:100],
"fingerprint": fp,
"taken_at": now.isoformat(),
})
def main():
# 非 git 仓库直接退出
try:
r = subprocess.run(
["git", "rev-parse", "--is-inside-work-tree"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5
)
if r.returncode != 0:
return
except Exception:
return
now = now_taipei()
all_files = get_changed_files()
# 各功能块独立 try/except
try:
do_audit_flag(all_files, now)
except Exception:
pass
try:
do_prompt_log(now)
except Exception:
pass
try:
do_git_snapshot(all_files, now)
except Exception:
pass
if __name__ == "__main__":
try:
main()
except Exception:
pass

View File

@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""session_log — agentStop 时记录本次对话的完整日志。
收集来源:
- 环境变量 AGENT_OUTPUTKiro 注入的 agent 输出)
- 环境变量 USER_PROMPT最近一次用户输入
- .kiro/.last_prompt_id.jsonPrompt ID 溯源)
- .kiro/.audit_state.json变更文件列表
- git diff --stat变更统计
输出docs/audit/session_logs/session_<timestamp>.md
"""
import json
import os
import subprocess
import sys
from datetime import datetime, timezone, timedelta
TZ_TAIPEI = timezone(timedelta(hours=8))
LOG_DIR = os.path.join("docs", "audit", "session_logs")
STATE_PATH = os.path.join(".kiro", ".audit_state.json")
PROMPT_ID_PATH = os.path.join(".kiro", ".last_prompt_id.json")
def now_taipei():
return datetime.now(TZ_TAIPEI)
def safe_read_json(path):
if not os.path.isfile(path):
return {}
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return {}
def git_diff_stat():
try:
r = subprocess.run(
["git", "diff", "--stat", "HEAD"],
capture_output=True, text=True, timeout=10
)
return r.stdout.strip() if r.returncode == 0 else "(git diff failed)"
except Exception:
return "(git not available)"
def git_status_short():
try:
r = subprocess.run(
["git", "status", "--short"],
capture_output=True, text=True, timeout=10
)
return r.stdout.strip() if r.returncode == 0 else ""
except Exception:
return ""
def main():
now = now_taipei()
ts = now.strftime("%Y%m%d_%H%M%S")
timestamp_display = now.strftime("%Y-%m-%d %H:%M:%S %z")
# 收集数据
agent_output = os.environ.get("AGENT_OUTPUT", "")
user_prompt = os.environ.get("USER_PROMPT", "")
prompt_info = safe_read_json(PROMPT_ID_PATH)
audit_state = safe_read_json(STATE_PATH)
prompt_id = prompt_info.get("prompt_id", "unknown")
# 截断超长内容,避免日志文件过大
max_len = 50000
if len(agent_output) > max_len:
agent_output = agent_output[:max_len] + "\n\n[TRUNCATED: output exceeds 50KB]"
if len(user_prompt) > 10000:
user_prompt = user_prompt[:10000] + "\n\n[TRUNCATED: prompt exceeds 10KB]"
diff_stat = git_diff_stat()
status_short = git_status_short()
changed_files = audit_state.get("changed_files", [])
os.makedirs(LOG_DIR, exist_ok=True)
filename = f"session_{ts}.md"
filepath = os.path.join(LOG_DIR, filename)
content = f"""# Session Log — {timestamp_display}
- Prompt-ID: `{prompt_id}`
- Audit Required: `{audit_state.get('audit_required', 'N/A')}`
- Reasons: {', '.join(audit_state.get('reasons', [])) or 'none'}
## User Input
```text
{user_prompt or '(not captured)'}
```
## Agent Output
```text
{agent_output or '(not captured)'}
```
## Changed Files ({len(changed_files)})
```
{chr(10).join(changed_files[:80]) if changed_files else '(none)'}
```
## Git Diff Stat
```
{diff_stat}
```
## Git Status
```
{status_short or '(clean)'}
```
"""
with open(filepath, "w", encoding="utf-8") as f:
f.write(content)
if __name__ == "__main__":
try:
main()
except Exception:
pass

View File

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

View File

@@ -0,0 +1,431 @@
# 设计文档小程序数据库基础设施层miniapp-db-foundation
## 概述
本设计实现 P1 基础设施层的三大核心能力:
1. **业务库 Schema 划分**:在 `test_zqyy_app` 中创建 `auth`(认证)和 `biz`(业务)两个 Schema配合权限管理
2. **ETL 库 RLS 视图层**:在 `test_etl_feiqiu.app` Schema 中为 35 张 DWD/DWS 表创建行级安全视图,通过 `site_id` 隔离多门店数据
3. **FDW 跨库映射**:通过 `postgres_fdw` 将 ETL 库的 RLS 视图映射为业务库的只读外部表
**环境变量驱动**:所有数据库名称通过 `.env` 环境变量引用,不硬编码。迁移脚本中使用占位符,验证脚本从 `PG_DSN` / `APP_DB_DSN` 解析连接信息。
| 环境变量 | 用途 | 示例值 |
|---------|------|--------|
| `PG_DSN` | ETL 库连接字符串 | `postgresql://user:pass@host:5432/test_etl_feiqiu` |
| `APP_DB_DSN` | 业务库连接字符串 | `postgresql://user:pass@host:5432/test_zqyy_app` |
整体数据流向:
```
ETL 库PG_DSN 业务库APP_DB_DSN
┌─────────────────────┐ ┌─────────────────────┐
│ dwd.dim_member │ │ auth (用户认证) │
│ dwd.dim_assistant │ │ biz (业务数据) │
│ dws.dws_* │ │ public (系统管理) │
│ dws.cfg_* │ │ │
│ │ │ │ fdw_etl │
│ ▼ │ postgres_fdw │ ├ v_dim_member │
│ app.v_dim_member │ ◄──────────────► │ ├ v_dim_assistant │
│ app.v_dws_* │ IMPORT SCHEMA │ └ v_dws_* │
│ (RLS: site_id 过滤) │ │ (外部表,只读) │
└─────────────────────┘ └─────────────────────┘
```
## 架构
### 分层架构
```mermaid
graph TB
subgraph "业务库APP_DB_DSN"
AUTH["auth Schema<br/>用户认证、权限、映射"]
BIZ["biz Schema<br/>业务数据"]
PUBLIC["public Schema<br/>系统管理表(保留)"]
FDW["fdw_etl Schema<br/>FDW 外部表(只读)"]
end
subgraph "ETL 库PG_DSN"
APP["app Schema<br/>RLS 视图层"]
DWD["dwd Schema<br/>明细层11 张表)"]
DWS["dws Schema<br/>汇总层24 张表)"]
end
FDW -->|"postgres_fdw<br/>IMPORT FOREIGN SCHEMA"| APP
APP -->|"WHERE site_id = current_setting(...)"| DWD
APP -->|"WHERE site_id = current_setting(...)"| DWS
```
### 执行顺序
迁移脚本必须按以下顺序执行:
1. **ETL 库**(通过 `PG_DSN` 连接):创建 `app` Schema → 创建 `app_reader` 角色 → 创建 RLS 视图 → 授权
2. **业务库**(通过 `APP_DB_DSN` 连接):创建 `auth`/`biz` Schema → 安装 `postgres_fdw` → 创建外部服务器 → 用户映射 → 导入外部表 → 授权
## 组件与接口
### 组件 1Schema 管理(业务库)
**职责**:在业务库(`APP_DB_DSN`)中创建 `auth``biz` Schema配置权限。
**SQL 接口**
```sql
-- 创建 Schema
CREATE SCHEMA IF NOT EXISTS auth;
CREATE SCHEMA IF NOT EXISTS biz;
-- 授权 app_user
GRANT USAGE ON SCHEMA auth TO app_user;
GRANT USAGE ON SCHEMA biz TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA auth TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA biz TO app_user;
-- 未来新表自动授权
ALTER DEFAULT PRIVILEGES IN SCHEMA auth
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA biz
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;
```
### 组件 2RLS 视图层ETL 库)
**职责**:在 ETL 库(`PG_DSN`)的 `app` Schema 中为每张源表创建带 `site_id` 过滤的视图。
**视图命名规则**`app.v_<源表名>`,例如 `app.v_dim_member``app.v_dws_member_consumption_summary`
**视图模板**
```sql
CREATE OR REPLACE VIEW app.v_<> AS
SELECT * FROM <schema>.<>
WHERE site_id = current_setting('app.current_site_id')::bigint;
```
**DWD 层视图清单11 张)**
| 视图名 | 源表 |
|--------|------|
| `app.v_dim_member` | `dwd.dim_member` |
| `app.v_dim_assistant` | `dwd.dim_assistant` |
| `app.v_dim_member_card_account` | `dwd.dim_member_card_account` |
| `app.v_dim_table` | `dwd.dim_table` |
| `app.v_dwd_settlement_head` | `dwd.dwd_settlement_head` |
| `app.v_dwd_table_fee_log` | `dwd.dwd_table_fee_log` |
| `app.v_dwd_assistant_service_log` | `dwd.dwd_assistant_service_log` |
| `app.v_dwd_recharge_order` | `dwd.dwd_recharge_order` |
| `app.v_dwd_store_goods_sale` | `dwd.dwd_store_goods_sale` |
| `app.v_dim_staff` | `dwd.dim_staff` |
| `app.v_dim_staff_ex` | `dwd.dim_staff_ex` |
**DWS 层视图清单24 张)**
| 视图名 | 源表 |
|--------|------|
| `app.v_dws_member_consumption_summary` | `dws.dws_member_consumption_summary` |
| `app.v_dws_member_visit_detail` | `dws.dws_member_visit_detail` |
| `app.v_dws_member_winback_index` | `dws.dws_member_winback_index` |
| `app.v_dws_member_newconv_index` | `dws.dws_member_newconv_index` |
| `app.v_dws_member_recall_index` | `dws.dws_member_recall_index` |
| `app.v_dws_member_assistant_relation_index` | `dws.dws_member_assistant_relation_index` |
| `app.v_dws_member_assistant_intimacy` | `dws.dws_member_assistant_intimacy` |
| `app.v_dws_assistant_daily_detail` | `dws.dws_assistant_daily_detail` |
| `app.v_dws_assistant_monthly_summary` | `dws.dws_assistant_monthly_summary` |
| `app.v_dws_assistant_salary_calc` | `dws.dws_assistant_salary_calc` |
| `app.v_dws_assistant_customer_stats` | `dws.dws_assistant_customer_stats` |
| `app.v_dws_assistant_finance_analysis` | `dws.dws_assistant_finance_analysis` |
| `app.v_dws_finance_daily_summary` | `dws.dws_finance_daily_summary` |
| `app.v_dws_finance_income_structure` | `dws.dws_finance_income_structure` |
| `app.v_dws_finance_recharge_summary` | `dws.dws_finance_recharge_summary` |
| `app.v_dws_finance_discount_detail` | `dws.dws_finance_discount_detail` |
| `app.v_dws_finance_expense_summary` | `dws.dws_finance_expense_summary` |
| `app.v_dws_platform_settlement` | `dws.dws_platform_settlement` |
| `app.v_dws_assistant_recharge_commission` | `dws.dws_assistant_recharge_commission` |
| `app.v_cfg_performance_tier` | `dws.cfg_performance_tier` |
| `app.v_cfg_assistant_level_price` | `dws.cfg_assistant_level_price` |
| `app.v_cfg_bonus_rules` | `dws.cfg_bonus_rules` |
| `app.v_cfg_index_parameters` | `dws.cfg_index_parameters` |
| `app.v_dws_order_summary` | `dws.dws_order_summary` |
**P2 预留(注释标记,暂不创建)**
- `dws.dws_member_spending_power_index` → 待 P2 完成后补充
- `dws.dws_assistant_order_contribution` → 待 P2 完成后补充
**`cfg_*` 表特殊处理**:配置表(`cfg_performance_tier``cfg_assistant_level_price``cfg_bonus_rules``cfg_index_parameters`)可能不含 `site_id` 列。对于不含 `site_id` 的配置表,视图直接 `SELECT *` 不加过滤条件。
**权限配置**
```sql
-- 创建只读角色(如不存在)
DO $$ BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'app_reader') THEN
CREATE ROLE app_reader LOGIN;
END IF;
END $$;
GRANT USAGE ON SCHEMA app TO app_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA app TO app_reader;
ALTER DEFAULT PRIVILEGES IN SCHEMA app GRANT SELECT ON TABLES TO app_reader;
```
### 组件 3FDW 跨库映射(业务库)
**职责**:通过 `postgres_fdw` 将 ETL 库 `app` Schema 的视图映射为业务库 `fdw_etl` Schema 的外部表。
**实现方式**:使用 `IMPORT FOREIGN SCHEMA` 批量导入,而非逐表定义外部表。这与现有 `db/fdw/setup_fdw_test.sql` 的模式一致。
**环境感知**:迁移脚本中的 `host``dbname``port``password` 等连接参数使用占位符 `'***'`,部署时根据环境替换。项目已有 `db/fdw/setup_fdw_test.sql`(测试环境)和 `db/fdw/setup_fdw.sql`(生产环境)的分环境模式,本次迁移脚本遵循相同模式——提供测试环境和生产环境两个版本。
```sql
-- 安装扩展
CREATE EXTENSION IF NOT EXISTS postgres_fdw;
-- 创建外部服务器
-- host / dbname / port 按环境替换,从 PG_DSN 解析 ETL 库连接信息
CREATE SERVER IF NOT EXISTS etl_feiqiu_server
FOREIGN DATA WRAPPER postgres_fdw
OPTIONS (host '***', dbname '***', port '***');
-- 用户映射(密码按环境替换)
CREATE USER MAPPING IF NOT EXISTS FOR app_user
SERVER etl_feiqiu_server
OPTIONS (user 'app_reader', password '***');
-- 创建目标 Schema
CREATE SCHEMA IF NOT EXISTS fdw_etl;
-- 批量导入
IMPORT FOREIGN SCHEMA app
FROM SERVER etl_feiqiu_server
INTO fdw_etl;
-- 授权
GRANT USAGE ON SCHEMA fdw_etl TO app_user;
GRANT SELECT ON ALL TABLES IN SCHEMA fdw_etl TO app_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA fdw_etl GRANT SELECT ON TABLES TO app_user;
```
**设计决策**
1. 使用 `IMPORT FOREIGN SCHEMA` 而非逐表 `CREATE FOREIGN TABLE`——自动匹配列定义,避免手动维护列类型不一致的风险
2. 新增 RLS 视图后只需重新执行 `IMPORT` 即可同步
3. 与现有 `db/fdw/setup_fdw_test.sql` 保持一致
4. 服务器名使用通用名 `etl_feiqiu_server`(不含环境前缀),通过连接参数区分环境
### 组件 4验证脚本
**职责**:自动化检查所有数据库对象是否正确创建。
**文件位置**`scripts/ops/validate_p1_db_foundation.py`
**接口**
```python
def validate_p1_db_foundation() -> dict:
"""
返回验证结果字典:
{
"schemas": {"auth": bool, "biz": bool, "app": bool, "fdw_etl": bool},
"rls_views": {"app.v_dim_member": bool, ...},
"fdw_tables": {"fdw_etl.v_dim_member": bool, ...},
"rls_filtering": bool,
"permissions": {"app_user": bool, "app_reader": bool},
"errors": [str, ...]
}
"""
```
**环境变量依赖**(强制从 `.env` 加载,缺失时 `RuntimeError` 终止):
- `PG_DSN`ETL 库连接字符串(从中解析 host、port、dbname
- `APP_DB_DSN`:业务库连接字符串(从中解析 host、port、dbname
- 脚本通过 `load_dotenv()` 加载根 `.env`,禁止硬编码任何数据库名称或连接参数
## 数据模型
### Schema 拓扑
```mermaid
erDiagram
test_zqyy_app_auth {
string schema_name "auth"
string purpose "用户认证、权限、映射"
}
test_zqyy_app_biz {
string schema_name "biz"
string purpose "业务数据任务、备注、AI、Excel"
}
test_zqyy_app_fdw_etl {
string schema_name "fdw_etl"
string purpose "FDW 外部表(只读)"
}
test_zqyy_app_public {
string schema_name "public"
string purpose "系统管理表(保留)"
}
test_etl_feiqiu_app {
string schema_name "app"
string purpose "RLS 视图层"
}
test_etl_feiqiu_dwd {
string schema_name "dwd"
string purpose "明细层11 张表)"
}
test_etl_feiqiu_dws {
string schema_name "dws"
string purpose "汇总层24 张表)"
}
test_etl_feiqiu_app ||--o{ test_etl_feiqiu_dwd : "视图引用"
test_etl_feiqiu_app ||--o{ test_etl_feiqiu_dws : "视图引用"
test_zqyy_app_fdw_etl ||--|| test_etl_feiqiu_app : "postgres_fdw 映射"
```
### RLS 视图数据流
对于含 `site_id` 的表:
```
源表数据(全量)→ RLS 视图site_id 过滤)→ FDW 外部表(只读访问)
```
对于不含 `site_id` 的配置表(`cfg_*`
```
源表数据(全量)→ 直通视图(无过滤)→ FDW 外部表(只读访问)
```
### 迁移脚本清单
| 序号 | 目标库 | 文件名 | 内容 |
|------|--------|--------|------|
| 1 | ETL 库(`PG_DSN` | `YYYY-MM-DD__p1_create_app_schema_rls_views.sql` | 创建 app Schema + 全部 RLS 视图 + app_reader 权限 |
| 2 | 业务库(`APP_DB_DSN` | `YYYY-MM-DD__p1_create_auth_biz_schemas.sql` | 创建 auth/biz Schema + app_user 权限 |
| 3 | 业务库(`APP_DB_DSN` | `YYYY-MM-DD__p1_setup_fdw_etl.sql` | FDW 扩展 + 外部服务器 + 用户映射 + 导入外部表 |
## 正确性属性Correctness Properties
*属性是系统在所有有效执行中都应保持为真的特征或行为——本质上是关于系统应该做什么的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。*
### Property 1默认权限自动授予
*For any*`auth``biz` Schema 中新创建的表,`app_user` 角色都应自动获得 SELECT、INSERT、UPDATE、DELETE 权限,无需额外手动授权。
**Validates: Requirements 1.5**
### Property 2public Schema 不变量
*For any* 迁移脚本执行前后,`test_zqyy_app.public` Schema 中的表集合应保持不变——迁移不应删除、重命名或修改 `public` 中的现有表。
**Validates: Requirements 1.6**
### Property 3RLS 视图定义包含 site_id 过滤
*For any* `test_etl_feiqiu.app` Schema 中的 RLS 视图(含 `site_id` 列的源表对应的视图),其视图定义 SQL 中都应包含 `current_setting('app.current_site_id')` 过滤条件。
**Validates: Requirements 2.4**
### Property 4RLS 过滤正确性
*For any*`site_id` 列的 RLS 视图和任意有效的 `site_id` 值,设置 `app.current_site_id` 后查询该视图,返回结果中所有行的 `site_id` 都应等于设置的值。
**Validates: Requirements 2.5**
### Property 5未设置 site_id 时 RLS 视图拒绝访问
*For any*`site_id` 过滤的 RLS 视图,在未设置 `app.current_site_id` 的会话中执行查询,应抛出错误而非返回数据。
**Validates: Requirements 2.6**
### Property 6FDW 外部表完整性与数据一致性
*For any* `test_etl_feiqiu.app` Schema 中的视图,`test_zqyy_app.fdw_etl` Schema 中都应存在对应的可查询外部表,且在相同 `site_id` 条件下,外部表返回的数据与 RLS 视图返回的数据一致。
**Validates: Requirements 3.4, 3.5, 3.6**
### Property 7迁移脚本结构合规性
*For any* `db/etl_feiqiu/migrations/``db/zqyy_app/migrations/` 中本次新增的迁移脚本文件,文件名应匹配 `YYYY-MM-DD__*.sql` 模式,且文件内容中应包含回滚语句(以注释形式)。
**Validates: Requirements 4.3, 4.4**
### Property 8迁移脚本幂等性
*For any* 本次新增的迁移脚本,连续执行两次的结果应与执行一次相同——第二次执行不应产生错误。
**Validates: Requirements 4.5**
### Property 9环境变量缺失时验证脚本报错
*For any* 必需环境变量(`PG_DSN``APP_DB_DSN`)的缺失组合,验证脚本应立即抛出错误终止,而非静默使用默认值或空字符串。
**Validates: Requirements 5.8**
## 错误处理
### 迁移脚本错误处理
| 场景 | 处理方式 |
|------|---------|
| Schema 已存在 | `CREATE SCHEMA IF NOT EXISTS` 幂等跳过 |
| 视图已存在 | `CREATE OR REPLACE VIEW` 覆盖更新 |
| 角色不存在 | `DO $$ ... IF NOT EXISTS ... END $$` 条件创建 |
| 源表不存在P2 待建表) | 以注释形式预留,不创建视图 |
| FDW 服务器已存在 | `CREATE SERVER IF NOT EXISTS` 幂等跳过 |
| 用户映射已存在 | `CREATE USER MAPPING IF NOT EXISTS` 幂等跳过 |
| `IMPORT FOREIGN SCHEMA` 表已存在 | 先 `DROP SCHEMA fdw_etl CASCADE` 再重新导入(脚本中提供选项) |
### 验证脚本错误处理
| 场景 | 处理方式 |
|------|---------|
| 环境变量缺失 | `RuntimeError` 立即终止,输出缺失变量名 |
| 数据库连接失败 | 捕获 `psycopg2.OperationalError`,输出连接参数(脱敏)和错误信息 |
| Schema/视图/外部表不存在 | 记录为失败项,继续检查其余项目 |
| RLS 过滤验证无数据 | 标记为 SKIP无法验证不标记为失败 |
| 权限查询失败 | 记录具体错误,继续检查 |
### `current_setting` 未设置时的行为
PostgreSQL 中 `current_setting('app.current_site_id')` 在未设置时会抛出 `ERROR: unrecognized configuration parameter "app.current_site_id"`。这是期望行为(需求 2.6),确保不会意外返回全量数据。
如果需要更友好的错误信息,可以使用 `current_setting('app.current_site_id', true)` 返回 NULL然后在视图中用 `CASE` 处理。但当前设计选择让 PostgreSQL 原生报错,因为:
1. 更安全——不可能绕过
2. 后端代码必须显式设置 `SET app.current_site_id = ...`,这是一个强制约束
## 测试策略
### 属性测试Property-Based Testing
使用 Python `hypothesis` 框架,测试目录:`tests/`Monorepo 级属性测试目录)。
每个属性测试至少运行 100 次迭代。每个测试用注释标注对应的设计属性编号。
标注格式:`# Feature: miniapp-db-foundation, Property N: <属性标题>`
**属性测试清单**
| 属性 | 测试方法 | 生成器 |
|------|---------|--------|
| P1 默认权限 | 生成随机表名,在 auth/biz 中创建表,验证 app_user 权限 | `hypothesis.strategies.text` 生成合法 SQL 标识符 |
| P3 视图定义过滤 | 遍历所有 app schema 视图,检查定义 SQL | 无需生成器,遍历所有视图 |
| P4 RLS 过滤正确性 | 生成随机 site_id设置后查询视图验证结果 | `hypothesis.strategies.integers` 生成 site_id |
| P5 未设置 site_id 报错 | 遍历所有 RLS 视图,在新会话中查询 | 无需生成器,遍历所有视图 |
| P7 脚本结构合规 | 遍历所有新增迁移脚本,验证命名和内容 | 无需生成器,遍历文件 |
| P8 幂等性 | 对每个迁移脚本执行两次,验证无错误 | 无需生成器 |
| P9 环境变量缺失 | 生成环境变量缺失组合,验证报错 | `hypothesis.strategies.sampled_from` 生成缺失组合 |
**注意**P2public schema 不变量)和 P6FDW 数据一致性)需要真实数据库环境,作为集成测试在验证脚本中实现,而非 hypothesis 属性测试。
### 单元测试
单元测试聚焦于验证脚本的逻辑正确性:
- 验证脚本在 Schema 缺失时正确报告失败
- 验证脚本在权限不足时正确报告
- 验证脚本的输出格式正确JSON 结构)
- 环境变量缺失时的错误消息包含变量名
### 集成测试
集成测试通过验证脚本 `scripts/ops/validate_p1_db_foundation.py` 实现,覆盖:
- 全部 Schema 存在性检查
- 全部 RLS 视图存在性和过滤正确性
- 全部 FDW 外部表存在性和可查询性
- 权限配置完整性
- FDW 数据与 RLS 视图数据一致性

View File

@@ -0,0 +1,88 @@
# 需求文档小程序数据库基础设施层miniapp-db-foundation
## 简介
P1 基础设施层是整个小程序系统的第一个 SPEC无前置依赖是所有后续 SPEC 的硬依赖。本 SPEC 负责在业务库 `test_zqyy_app` 中建立清晰的 Schema 划分(`auth` + `biz`),在 ETL 库 `test_etl_feiqiu` 中为数据依赖矩阵列出的所有 DWD/DWS 表创建 RLS 视图(按 `site_id` 隔离),并通过 `postgres_fdw` 将 RLS 视图映射为业务库的外部表,使后端无需直连 ETL 库即可读取汇总/维度数据。
## 术语表
- **Schema_Manager**:负责在 PostgreSQL 数据库中创建和管理 Schema、权限配置的迁移脚本系统
- **RLS_View_Layer**:在 `test_etl_feiqiu.app` Schema 中创建的一组视图,通过 `current_setting('app.current_site_id')``site_id` 过滤数据,实现行级安全隔离
- **FDW_Bridge**:通过 `postgres_fdw` 扩展在 `test_zqyy_app.fdw_etl` Schema 中创建的外部表集合,只读映射 ETL 库 `app` Schema 的 RLS 视图
- **Migration_Script**:存放在 `db/zqyy_app/migrations/``db/etl_feiqiu/migrations/` 中的纯 SQL 迁移脚本,以日期前缀命名
- **Validation_Script**:用于验证数据库对象是否正确创建、权限是否配置正确、数据是否可查询的 Python 脚本
- **site_id**:门店标识符,类型为 `BIGINT`,用于多门店数据隔离
- **app_reader**ETL 库侧的只读角色,供 FDW 用户映射使用
- **app_user**:业务库侧的应用连接角色,通过 FDW 读取 ETL 数据
## 需求
### 需求 1业务库 Schema 划分与权限配置
**用户故事:** 作为后端开发者,我需要 `test_zqyy_app` 中有清晰的 Schema 划分(`auth` + `biz`),以便按功能组织业务表。
#### 验收标准
1. WHEN Migration_Script 执行完成, THE Schema_Manager SHALL 在 `test_zqyy_app` 中创建 `auth` Schema
2. WHEN Migration_Script 执行完成, THE Schema_Manager SHALL 在 `test_zqyy_app` 中创建 `biz` Schema
3. WHEN `auth` Schema 创建完成, THE Schema_Manager SHALL 授予 `app_user` 角色对 `auth` Schema 的 USAGE 权限和对其中所有表的 SELECT、INSERT、UPDATE、DELETE 权限
4. WHEN `biz` Schema 创建完成, THE Schema_Manager SHALL 授予 `app_user` 角色对 `biz` Schema 的 USAGE 权限和对其中所有表的 SELECT、INSERT、UPDATE、DELETE 权限
5. WHEN 新表在 `auth``biz` Schema 中创建, THE Schema_Manager SHALL 通过 ALTER DEFAULT PRIVILEGES 自动授予 `app_user` 角色相应权限
6. THE Migration_Script SHALL 保留 `public` Schema 中现有的系统管理表(`admin_users``roles``permissions` 等)不受影响
### 需求 2ETL 库 RLS 视图层创建
**用户故事:** 作为系统管理员,我需要 RLS 视图按 `site_id` 隔离数据,以便多门店数据安全。
#### 验收标准
1. WHEN Migration_Script 执行完成, THE Schema_Manager SHALL 在 `test_etl_feiqiu` 中创建 `app` Schema如不存在
2. WHEN `app` Schema 创建完成, THE RLS_View_Layer SHALL 为数据依赖矩阵中列出的每张 DWD 表创建对应的 RLS 视图(共 11 张:`dim_member``dim_assistant``dim_member_card_account``dim_table``dwd_settlement_head``dwd_table_fee_log``dwd_assistant_service_log``dwd_recharge_order``dwd_store_goods_sale``dim_staff``dim_staff_ex`
3. WHEN `app` Schema 创建完成, THE RLS_View_Layer SHALL 为数据依赖矩阵中列出的每张 DWS 表创建对应的 RLS 视图(共 24 张,包含 `dws_*``cfg_*` 表)
4. THE RLS_View_Layer 中每个视图 SHALL 包含 `WHERE site_id = current_setting('app.current_site_id')::bigint` 过滤条件
5. WHEN 设置 `app.current_site_id` 为某门店 ID 后查询 RLS 视图, THE RLS_View_Layer SHALL 仅返回该门店的数据
6. WHEN 未设置 `app.current_site_id` 时查询 RLS 视图, THE RLS_View_Layer SHALL 抛出错误而非返回全部数据
7. THE Schema_Manager SHALL 授予 `app_reader` 角色对 `app` Schema 的 USAGE 权限和对其中所有视图的 SELECT 权限
8. THE RLS_View_Layer SHALL 为 P2 待建表(`dws_member_spending_power_index``dws_assistant_order_contribution`)在迁移脚本中以注释形式预留位置
### 需求 3FDW 外部表映射
**用户故事:** 作为后端开发者,我需要通过 FDW 从 `test_zqyy_app` 读取 ETL 库的 DWS/DWD 数据,以便小程序页面展示 ETL 计算结果。
#### 验收标准
1. WHEN Migration_Script 执行完成, THE FDW_Bridge SHALL 在 `test_zqyy_app` 中安装 `postgres_fdw` 扩展
2. WHEN `postgres_fdw` 扩展安装完成, THE FDW_Bridge SHALL 创建指向 `test_etl_feiqiu` 的外部服务器 `test_etl_feiqiu_server`
3. WHEN 外部服务器创建完成, THE FDW_Bridge SHALL 创建 `app_user``app_reader` 的用户映射
4. WHEN 用户映射创建完成, THE FDW_Bridge SHALL 在 `fdw_etl` Schema 中通过 `IMPORT FOREIGN SCHEMA app` 导入所有外部表
5. WHEN 外部表导入完成, THE FDW_Bridge SHALL 对 `fdw_etl` Schema 中的每张外部表执行 `SELECT` 查询验证可读性
6. WHEN 外部表查询成功, THE FDW_Bridge 返回的数据 SHALL 与 ETL 库 `app` Schema 中对应 RLS 视图的数据一致
7. THE FDW_Bridge SHALL 授予 `app_user` 角色对 `fdw_etl` Schema 的 USAGE 权限和对其中所有外部表的 SELECT 权限
### 需求 4迁移脚本管理
**用户故事:** 作为后端开发者,我需要所有数据库变更都有对应的迁移脚本,以便变更可追溯、可重放、可回滚。
#### 验收标准
1. THE Migration_Script SHALL 将 ETL 库变更RLS 视图创建、`app` Schema 权限)存放在 `db/etl_feiqiu/migrations/` 目录中
2. THE Migration_Script SHALL 将业务库变更Schema 创建、FDW 配置)存放在 `db/zqyy_app/migrations/` 目录中
3. THE Migration_Script SHALL 使用日期前缀命名(格式:`YYYY-MM-DD__<描述>.sql`
4. THE Migration_Script SHALL 在每个脚本中包含回滚语句(以注释形式)
5. THE Migration_Script SHALL 使用 `IF NOT EXISTS` / `OR REPLACE` 等幂等语法,确保重复执行不会报错
6. THE Migration_Script SHALL 使用 UTF-8 编码,纯 SQL非 ORM
### 需求 5端到端验证
**用户故事:** 作为后端开发者,我需要一个自动化验证脚本,确认所有数据库对象正确创建且数据可访问。
#### 验收标准
1. WHEN Validation_Script 执行时, THE Validation_Script SHALL 检查 `test_zqyy_app``auth``biz` Schema 是否存在
2. WHEN Validation_Script 执行时, THE Validation_Script SHALL 检查 `test_etl_feiqiu.app` Schema 中所有 RLS 视图是否存在
3. WHEN Validation_Script 执行时, THE Validation_Script SHALL 检查 `test_zqyy_app.fdw_etl` 中所有外部表是否存在
4. WHEN Validation_Script 执行时, THE Validation_Script SHALL 对每张外部表执行 `SELECT count(*)` 验证可查询性
5. WHEN Validation_Script 执行时, THE Validation_Script SHALL 设置 `app.current_site_id` 后验证 RLS 视图正确过滤数据
6. WHEN Validation_Script 执行时, THE Validation_Script SHALL 验证 `app_user``app_reader` 角色的权限配置正确
7. WHEN 验证发现异常, THE Validation_Script SHALL 输出具体的失败项和错误信息
8. THE Validation_Script SHALL 从 `.env` 加载数据库连接参数(`PG_DSN``APP_DB_DSN`),缺失时立即报错终止

View File

@@ -0,0 +1,108 @@
# 实现计划小程序数据库基础设施层miniapp-db-foundation
## 概述
按照"ETL 库先行 → 业务库跟进 → FDW 桥接 → 验证收尾"的顺序,将设计拆分为可增量执行的编码任务。每个迁移脚本使用幂等语法,所有连接参数通过环境变量驱动。
## 任务
- [x] 1. ETL 库:创建 app Schema 与 RLS 视图
- [x] 1.1 编写迁移脚本 `db/etl_feiqiu/migrations/YYYY-MM-DD__p1_create_app_schema_rls_views.sql`
- 创建 `app` Schema`CREATE SCHEMA IF NOT EXISTS app`
- 创建 `app_reader` 角色(条件创建,`DO $$ ... IF NOT EXISTS ... END $$`
- 为 11 张 DWD 表创建 RLS 视图(`CREATE OR REPLACE VIEW app.v_<表名> AS SELECT * FROM dwd.<表名> WHERE site_id = current_setting('app.current_site_id')::bigint`
- 为 24 张 DWS 表创建 RLS 视图(同上模式;`cfg_*` 配置表若无 `site_id` 列则直接 `SELECT *` 不加过滤)
- 在脚本末尾以注释形式预留 P2 待建表位置(`dws_member_spending_power_index``dws_assistant_order_contribution`
- 授予 `app_reader``app` Schema 的 USAGE + SELECT 权限 + ALTER DEFAULT PRIVILEGES
- 包含回滚语句(注释形式)
- _Requirements: 2.1, 2.2, 2.3, 2.4, 2.7, 2.8, 4.1, 4.3, 4.4, 4.5, 4.6_
- [x] 1.2 编写属性测试RLS 视图定义包含 site_id 过滤
- **Property 3: RLS 视图定义包含 site_id 过滤**
- 遍历 `app` Schema 所有视图,查询 `pg_views.definition`,验证含 `site_id` 列的源表对应视图包含 `current_setting` 过滤条件
- **Validates: Requirements 2.4**
- [x] 1.3 编写属性测试:未设置 site_id 时 RLS 视图拒绝访问
- **Property 5: 未设置 site_id 时 RLS 视图拒绝访问**
- 遍历所有含 `site_id` 过滤的 RLS 视图,在新会话(未设置 `app.current_site_id`)中执行 `SELECT`,验证抛出错误
- **Validates: Requirements 2.6**
- [x] 2. 业务库:创建 auth/biz Schema 与权限
- [x] 2.1 编写迁移脚本 `db/zqyy_app/migrations/YYYY-MM-DD__p1_create_auth_biz_schemas.sql`
- 创建 `auth` Schema`CREATE SCHEMA IF NOT EXISTS auth`
- 创建 `biz` Schema`CREATE SCHEMA IF NOT EXISTS biz`
- 授予 `app_user``auth`/`biz` 的 USAGE + CRUD 权限
- 设置 ALTER DEFAULT PRIVILEGES 自动授权未来新表
- 包含回滚语句(注释形式)
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 4.2, 4.3, 4.4, 4.5, 4.6_
- [x] 2.2 编写属性测试:默认权限自动授予
- **Property 1: 默认权限自动授予**
- 使用 hypothesis 生成随机合法表名,在 `auth`/`biz` 中创建临时表,验证 `app_user` 自动获得 SELECT/INSERT/UPDATE/DELETE 权限,测试后清理
- **Validates: Requirements 1.5**
- [x] 3. 业务库:配置 FDW 跨库映射
- [x] 3.1 编写迁移脚本 `db/zqyy_app/migrations/YYYY-MM-DD__p1_setup_fdw_etl.sql`
- 安装 `postgres_fdw` 扩展
- 创建外部服务器 `etl_feiqiu_server`host/dbname/port 使用占位符 `'***'`,按环境替换)
- 创建 `app_user``app_reader` 用户映射
- 创建 `fdw_etl` Schema
- 执行 `IMPORT FOREIGN SCHEMA app FROM SERVER etl_feiqiu_server INTO fdw_etl`
- 授予 `app_user``fdw_etl` 的 USAGE + SELECT 权限 + ALTER DEFAULT PRIVILEGES
- 包含回滚语句(注释形式)
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.7, 4.2, 4.3, 4.4, 4.5, 4.6_
- [x] 3.2 编写属性测试FDW 外部表完整性与数据一致性
- **Property 6: FDW 外部表完整性与数据一致性**
- 遍历 ETL 库 `app` Schema 所有视图,验证 `fdw_etl` 中存在对应外部表且可查询;在相同 `site_id` 下对比数据一致性
- **Validates: Requirements 3.4, 3.5, 3.6**
- [x] 4. Checkpoint — 迁移脚本验证
- 确保三个迁移脚本均可在测试环境中成功执行按顺序ETL 库 → 业务库 Schema → 业务库 FDW
- 验证重复执行不报错(幂等性)
- 如有问题请告知用户
- [x] 5. 编写端到端验证脚本
- [x] 5.1 创建 `scripts/ops/validate_p1_db_foundation.py`
- 通过 `load_dotenv()` 加载根 `.env`,读取 `PG_DSN``APP_DB_DSN`,缺失时 `RuntimeError` 终止
- 检查业务库中 `auth``biz` Schema 存在性
- 检查 ETL 库中 `app` Schema 及所有 RLS 视图存在性(对照设计文档中的 35 张视图清单)
- 检查业务库中 `fdw_etl` Schema 及所有外部表存在性
- 对每张外部表执行 `SELECT count(*)` 验证可查询性
- 设置 `app.current_site_id` 后验证 RLS 视图过滤正确性
- 验证 `app_user``app_reader` 角色权限配置
- 输出结构化验证结果(通过/失败/跳过),失败项附带错误信息
- _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7, 5.8_
- [x] 5.2 编写属性测试RLS 过滤正确性
- **Property 4: RLS 过滤正确性**
- 使用 hypothesis 生成随机 `site_id`,设置 `app.current_site_id` 后查询含 `site_id` 的 RLS 视图,验证返回结果中所有行的 `site_id` 等于设置值
- **Validates: Requirements 2.5**
- [x] 5.3 编写属性测试:环境变量缺失报错
- **Property 9: 环境变量缺失时验证脚本报错**
- 使用 hypothesis 生成 `PG_DSN`/`APP_DB_DSN` 的缺失组合,验证脚本抛出 `RuntimeError`
- **Validates: Requirements 5.8**
- [x] 5.4 编写属性测试:迁移脚本结构合规性
- **Property 7: 迁移脚本结构合规性**
- 遍历本次新增的迁移脚本文件,验证文件名匹配 `YYYY-MM-DD__*.sql` 模式,且内容包含回滚语句注释
- **Validates: Requirements 4.3, 4.4**
- [x] 5.5 编写属性测试:迁移脚本幂等性
- **Property 8: 迁移脚本幂等性**
- 对每个迁移脚本连续执行两次,验证第二次执行无错误
- **Validates: Requirements 4.5**
- [x] 6. Final checkpoint — 全量验证
- 运行验证脚本 `python scripts/ops/validate_p1_db_foundation.py`,确认所有检查项通过
- 运行属性测试 `cd C:\NeoZQYY && pytest tests/ -v -k p1`,确认所有属性测试通过
- 如有问题请告知用户
## 说明
- 标记 `*` 的子任务为可选,可跳过以加速 MVP
- 每个任务引用具体的需求编号,确保可追溯
- Checkpoint 确保增量验证
- 属性测试使用 Python hypothesis 框架,测试文件放在根目录 `tests/`
- 迁移脚本中的数据库连接参数host/dbname/port/password均使用占位符按环境替换

View File

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

View File

@@ -0,0 +1,562 @@
# 设计文档ETL DWS 层扩展 — 小程序数据支撑
## 概述
本设计覆盖三个独立但相关的 DWS 层扩展模块:
1. **助教订单流水四项统计**:新建 `AssistantOrderContributionTask`,计算每名助教每日的订单总流水、订单净流水、时效贡献流水、时效净贡献。算法核心在于"时效贡献流水"的台费分摊和酒水食品均分逻辑。
2. **会员消费汇总扩展**:修改现有 `MemberConsumptionTask`,新增 30/60/90 天充值窗口统计和次均消费字段。
3. **定档折算惩罚**:修改现有 `AssistantDailyTask`,新增时间重叠检测和惩罚计算逻辑。
三个模块共享同一套 RLS 视图 + FDW 映射基础设施。
### 设计决策
1. **助教订单流水独立建表**:四项统计粒度为 `(site_id, assistant_id, stat_date)`,与现有 `dws_assistant_daily_detail` 粒度相同但语义不同daily_detail 聚焦服务时长/金额contribution 聚焦订单级流水分摊),独立建表避免字段膨胀。
2. **时效贡献流水计算为纯函数**:核心分摊算法(`compute_time_weighted_revenue`)设计为静态方法,输入为结构化的订单数据,输出为每名助教的贡献值。不依赖数据库,便于属性测试。
3. **惩罚检测在 transform 阶段完成**:定档折算惩罚的时间重叠检测和计算在 `AssistantDailyTask.transform` 中完成,不新建独立任务,因为惩罚字段与日度明细同粒度。
4. **充值统计复用现有 extract 模式**:在 `MemberConsumptionTask` 中新增一个 `_extract_recharge_stats` 方法,与现有的 `_extract_consumption_stats` 并行提取,在 transform 阶段合并。
## 架构
```mermaid
graph TD
subgraph 数据来源DWD
SH[dwd_settlement_head<br/>结算主表]
TF[dwd_table_fee_log<br/>台费明细]
ASL[dwd_assistant_service_log<br/>助教服务记录]
RO[dwd_recharge_order<br/>充值订单]
end
subgraph 新建任务
AOC[AssistantOrderContributionTask<br/>助教订单流水统计]
end
subgraph 修改任务
MCT[MemberConsumptionTask<br/>+充值窗口 +次均消费]
ADT[AssistantDailyTask<br/>+惩罚检测 +惩罚计算]
end
subgraph 输出DWS
T1[dws_assistant_order_contribution<br/>新建]
T2[dws_member_consumption_summary<br/>扩展字段]
T3[dws_assistant_daily_detail<br/>扩展字段]
end
subgraph 基础设施
RLS[app schema RLS 视图]
FDW[fdw_etl 外部表映射]
end
SH --> AOC
TF --> AOC
ASL --> AOC
AOC --> T1
RO --> MCT
SH --> MCT
MCT --> T2
ASL --> ADT
TF --> ADT
ADT --> T3
T1 --> RLS
T2 --> RLS
T3 --> RLS
RLS --> FDW
```
### 任务依赖关系
```
DWD_LOAD_FROM_ODS
├── DWS_ASSISTANT_DAILY (扩展:+惩罚检测计算)
├── DWS_MEMBER_CONSUMPTION (扩展:+充值窗口+次均消费)
└── DWS_ASSISTANT_ORDER_CONTRIBUTION (新建:四项统计)
```
`DWS_ASSISTANT_ORDER_CONTRIBUTION` 依赖 `DWD_LOAD_FROM_ODS`(需要最新的结算、台费、服务记录数据)。
## 组件与接口
### AssistantOrderContributionTask新建
继承 `BaseDwsTask`,实现四项统计计算:
```python
class AssistantOrderContributionTask(BaseDwsTask):
DATE_COL = "stat_date"
def get_task_code(self) -> str:
return "DWS_ASSISTANT_ORDER_CONTRIBUTION"
def get_target_table(self) -> str:
return "dws_assistant_order_contribution"
def get_primary_keys(self) -> List[str]:
return ["site_id", "assistant_id", "stat_date"]
# --- ETL 主流程 ---
def extract(self, context: TaskContext) -> Dict[str, Any]: ...
def transform(self, extracted, context) -> List[Dict[str, Any]]: ...
# load() 使用 BaseDwsTask 默认实现
# --- 数据提取 ---
def _extract_order_data(self, site_id, start_date, end_date) -> List[Dict]: ...
# --- 核心计算(纯函数,可独立测试) ---
@staticmethod
def compute_order_gross_revenue(order: OrderData) -> Decimal:
"""订单总流水 = 台费 + 酒水食品 + 所有助教服务费"""
...
@staticmethod
def compute_order_net_revenue(order: OrderData) -> Decimal:
"""订单净流水 = 订单总流水 - 所有助教服务分成"""
...
@staticmethod
def compute_time_weighted_revenue(
order: OrderData, assistant_id: int
) -> Decimal:
"""时效贡献流水 = 台费按时长分摊 + 个人服务费 + 酒水食品按时长比例"""
...
@staticmethod
def compute_time_weighted_net_revenue(
time_weighted_revenue: Decimal, assistant_commission: Decimal
) -> Decimal:
"""时效净贡献 = 时效贡献流水 - 个人服务分成"""
...
```
### 核心数据结构
```python
@dataclass
class TableUsage:
"""台桌使用信息"""
table_id: int
table_area: str # 区域名称A/B/C/S/TV/M1-M7 等)
usage_seconds: int # 台桌使用时长(秒)
table_fee: Decimal # 台费/房费
@dataclass
class AssistantService:
"""助教服务记录"""
assistant_id: int
table_id: int
service_seconds: int # 服务时长(秒)
ledger_amount: Decimal # 服务流水(助教收费)
commission: Decimal # 助教分成
skill_id: int
course_type: str # BASE / BONUS / ROOM
@dataclass
class OrderData:
"""订单聚合数据(一个结算单的完整信息)"""
order_settle_id: int
site_id: int
total_table_fee: Decimal # 台费总额
total_goods_amount: Decimal # 酒水食品总额
tables: List[TableUsage] # 台桌列表
assistants: List[AssistantService] # 助教服务列表
```
### 四项统计算法详解
#### 1. 订单总流水order_gross_revenue
```
order_gross_revenue = total_table_fee + total_goods_amount + SUM(所有助教的 ledger_amount)
```
每个参与助教获得相同的 order_gross_revenue 值。
#### 2. 订单净流水order_net_revenue
```
order_net_revenue = order_gross_revenue - SUM(所有助教的 commission)
```
每个参与助教获得相同的 order_net_revenue 值。
#### 3. 时效贡献流水time_weighted_revenue— 核心算法
这是最复杂的计算,按以下步骤进行:
**步骤 1确定每张台桌的有效计费时长**
```
对于每张台桌 t
助教总服务时长 = SUM(该台桌所有助教的 service_seconds)
有效计费时长 = MAX(助教总服务时长, 台桌使用时长)
每小时单价 = table_fee / (有效计费时长 / 3600)
```
**步骤 2按助教在台桌的服务时长分摊台费**
```
对于每个助教 a 在台桌 t
台费分摊 = 每小时单价 × (助教在该台桌的服务时长 / 3600)
特殊情况:当助教总服务时长 < 台桌使用时长时
按比例缩放:台费分摊 = (table_fee × 台桌使用时长对应比例) / 该台桌助教人数中按时长占比分配
即:台费分摊 = (table_fee / 台桌使用时长 × MIN(助教总服务时长, 台桌使用时长))
× (助教个人时长 / 助教总服务时长)
```
更精确的公式(统一处理两种情况):
```
对于台桌 t
billable_seconds = MAX(SUM(助教服务时长), 台桌使用时长)
对于助教 a
台费分摊_a = table_fee_t × (service_seconds_a / billable_seconds)
```
> 注意:当 `SUM(助教服务时长) > 台桌使用时长` 时,`billable_seconds = SUM(助教服务时长)`
> 此时各助教按自己的时长占比分摊台费,总和 = table_fee。
> 当 `SUM(助教服务时长) < 台桌使用时长` 时,`billable_seconds = 台桌使用时长`
> 此时各助教分摊的台费总和 < table_fee未被助教覆盖的时段不分配给任何人
**步骤 3助教个人服务费直接计入**
```
个人服务费 = 助教的 ledger_amount
```
**步骤 4酒水食品按助教总时长比例均分**
```
助教总时长 = SUM(所有助教在所有台桌的 service_seconds)
对于助教 a
酒水食品分摊 = total_goods_amount × (助教 a 的总服务时长 / 助教总时长)
```
**合成:**
```
time_weighted_revenue_a = SUM(各台桌台费分摊_a) + 个人服务费_a + 酒水食品分摊_a
```
#### 4. 时效净贡献time_weighted_net_revenue
```
time_weighted_net_revenue_a = time_weighted_revenue_a - commission_a
```
#### 5. 超休/打赏课特殊处理
当助教为超休/打赏课类型(`course_type = BONUS`)时,该助教不参与订单级分摊:
```
order_gross_revenue = ledger_amount个人服务流水
order_net_revenue = ledger_amount - commission
time_weighted_revenue = ledger_amount
time_weighted_net_revenue = ledger_amount - commission
```
### MemberConsumptionTask 扩展
在现有任务中新增:
```python
# extract 阶段新增
def _extract_recharge_stats(self, site_id: int, stat_date: date) -> Dict[int, Dict]:
"""从 dwd_recharge_order 提取 30/60/90 天充值统计"""
...
# transform 阶段新增字段
record['recharge_count_30d'] = recharge.get('count_30d', 0)
record['recharge_count_60d'] = recharge.get('count_60d', 0)
record['recharge_count_90d'] = recharge.get('count_90d', 0)
record['recharge_amount_30d'] = recharge.get('amount_30d', Decimal('0'))
record['recharge_amount_60d'] = recharge.get('amount_60d', Decimal('0'))
record['recharge_amount_90d'] = recharge.get('amount_90d', Decimal('0'))
record['avg_ticket_amount'] = (
record['total_consume_amount'] / max(record['total_visit_count'], 1)
)
```
### AssistantDailyTask 扩展 — 惩罚检测
在现有任务的 transform 阶段新增惩罚检测逻辑:
```python
# 惩罚检测核心逻辑
@staticmethod
def detect_overlap_violations(
service_records: List[Dict],
penalty_areas: Set[str]
) -> Dict[Tuple[int, date], List[Dict]]:
"""
检测同一台桌同一时间段超过 2 名助教挂台的违规。
penalty_areas: 指定区域集合,如 {'A','B','C','S','TV','M1','M2',...,'M7'}
返回: {(assistant_id, stat_date): [violation_info, ...]}
"""
...
@staticmethod
def compute_penalty_minutes(
actual_minutes: Decimal,
per_hour_contribution: Decimal,
threshold: Decimal = Decimal('24')
) -> Decimal:
"""
计算惩罚分钟数。
per_hour_contribution >= threshold: 返回 0
per_hour_contribution < threshold:
返回 actual_minutes × (1 - per_hour_contribution / threshold)
"""
...
```
**惩罚区域定义:**
- 大厅A、B、C、S、TV
- 麻将房M1、M2、M3、M4、M5、M6、M7
**时间重叠检测算法:**
1.`(台桌ID, 服务日期)` 分组所有服务记录
2. 对每组内的服务记录,检查时间段是否有重叠(任意两个助教的 `[start_time, end_time]` 有交集)
3. 若同一时间段内助教数 > 2标记为违规
4. 对违规记录计算 `per_hour_contribution = 台费每小时单价 / 该时段助教人数`
5. 根据 `per_hour_contribution` 与 24 元阈值比较,计算 `penalty_minutes`
## 数据模型
### dws.dws_assistant_order_contribution新建
```sql
CREATE TABLE dws.dws_assistant_order_contribution (
contribution_id BIGSERIAL PRIMARY KEY,
site_id INTEGER NOT NULL,
tenant_id INTEGER NOT NULL,
assistant_id BIGINT NOT NULL,
assistant_nickname VARCHAR(100),
stat_date DATE NOT NULL,
-- 四项统计
order_gross_revenue NUMERIC(14,2) DEFAULT 0,
order_net_revenue NUMERIC(14,2) DEFAULT 0,
time_weighted_revenue NUMERIC(14,2) DEFAULT 0,
time_weighted_net_revenue NUMERIC(14,2) DEFAULT 0,
-- 辅助字段
order_count INTEGER DEFAULT 0,
total_service_seconds INTEGER DEFAULT 0,
-- 元数据
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE UNIQUE INDEX idx_aoc_site_assistant_date
ON dws.dws_assistant_order_contribution (site_id, assistant_id, stat_date);
CREATE INDEX idx_aoc_stat_date
ON dws.dws_assistant_order_contribution (site_id, stat_date);
```
### dws_member_consumption_summary 扩展字段
```sql
ALTER TABLE dws.dws_member_consumption_summary
ADD COLUMN IF NOT EXISTS recharge_count_30d INTEGER DEFAULT 0,
ADD COLUMN IF NOT EXISTS recharge_count_60d INTEGER DEFAULT 0,
ADD COLUMN IF NOT EXISTS recharge_count_90d INTEGER DEFAULT 0,
ADD COLUMN IF NOT EXISTS recharge_amount_30d NUMERIC(14,2) DEFAULT 0,
ADD COLUMN IF NOT EXISTS recharge_amount_60d NUMERIC(14,2) DEFAULT 0,
ADD COLUMN IF NOT EXISTS recharge_amount_90d NUMERIC(14,2) DEFAULT 0,
ADD COLUMN IF NOT EXISTS avg_ticket_amount NUMERIC(14,2) DEFAULT 0;
```
### dws_assistant_daily_detail 扩展字段
```sql
ALTER TABLE dws.dws_assistant_daily_detail
ADD COLUMN IF NOT EXISTS penalty_minutes NUMERIC(10,2) DEFAULT 0,
ADD COLUMN IF NOT EXISTS penalty_reason TEXT,
ADD COLUMN IF NOT EXISTS is_exempt BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS per_hour_contribution NUMERIC(14,2);
```
### RLS 视图app schema
```sql
-- 新建:助教订单流水统计
CREATE OR REPLACE VIEW app.v_dws_assistant_order_contribution AS
SELECT * FROM dws.dws_assistant_order_contribution
WHERE site_id = current_setting('app.current_site_id')::bigint;
-- 已有视图无需修改dws_member_consumption_summary 和 dws_assistant_daily_detail
-- 的 RLS 视图使用 SELECT *,新增字段自动包含
```
### FDW 映射fdw_etl schema
`test_zqyy_app.fdw_etl` 中新建外部表:
```sql
CREATE FOREIGN TABLE fdw_etl.dws_assistant_order_contribution (
contribution_id BIGINT,
site_id INTEGER,
tenant_id INTEGER,
assistant_id BIGINT,
assistant_nickname VARCHAR(100),
stat_date DATE,
order_gross_revenue NUMERIC(14,2),
order_net_revenue NUMERIC(14,2),
time_weighted_revenue NUMERIC(14,2),
time_weighted_net_revenue NUMERIC(14,2),
order_count INTEGER,
total_service_seconds INTEGER,
created_at TIMESTAMP WITH TIME ZONE,
updated_at TIMESTAMP WITH TIME ZONE
) SERVER etl_server
OPTIONS (schema_name 'app', table_name 'v_dws_assistant_order_contribution');
```
对于扩展字段的表(`dws_member_consumption_summary``dws_assistant_daily_detail`),需要 `DROP` 并重建 FDW 外部表定义以包含新字段。
## 正确性属性
*正确性属性Correctness Property是系统在所有合法执行路径上都应成立的行为特征——本质上是对"系统应该做什么"的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。*
以下属性基于需求文档中的验收标准推导。四项统计的核心计算函数(`compute_order_gross_revenue``compute_time_weighted_revenue` 等)和惩罚计算函数(`compute_penalty_minutes`)设计为纯静态方法,不依赖数据库,可直接用于属性测试。
### Property 1: 订单级统计不变量 — gross/net 各助教相等
*For any* 订单数据(包含任意数量的台桌、助教服务和酒水食品),所有参与该订单的助教应获得相同的 `order_gross_revenue` 值,且获得相同的 `order_net_revenue` 值。
推导:`order_gross_revenue``order_net_revenue` 是订单级聚合值,不按助教个人拆分,因此所有参与助教共享同一个值。
**Validates: Requirements 2.2, 2.3, 10.1, 10.2**
### Property 2: 时效贡献流水之和约束
*For any* 订单数据,所有参与助教的 `time_weighted_revenue` 之和应满足:
- 当所有台桌的助教总服务时长 ≥ 台桌使用时长时,之和 = `order_gross_revenue`
- 当存在台桌的助教总服务时长 < 台桌使用时长时,之和 ≤ `order_gross_revenue`
且在所有情况下,之和 ≥ 0。
推导:台费按时长比例分摊,当助教完全覆盖台桌时长时分摊总和等于台费;酒水食品按时长比例均分总和等于酒水总额;助教服务费直接计入。因此总和 = 台费分摊总和 + 酒水分摊总和 + 服务费总和 ≤ order_gross_revenue。
**Validates: Requirements 2.4, 10.3**
### Property 3: 时效净贡献减法关系
*For any* 助教和订单数据,该助教的 `time_weighted_net_revenue` 应等于 `time_weighted_revenue - commission`(该助教个人的服务分成)。
推导:这是定义性等式,直接从需求 2.5 得出。
**Validates: Requirements 2.5, 10.4**
### Property 4: 惩罚分钟数分段公式
*For any* 非负的 `actual_minutes` 和非负的 `per_hour_contribution`
-`per_hour_contribution >= 24` 时,`penalty_minutes = 0`
-`per_hour_contribution < 24` 时,`penalty_minutes = actual_minutes × (1 - per_hour_contribution / 24)`
且在所有情况下,`0 ≤ penalty_minutes ≤ actual_minutes`
推导:直接从需求 6.3/6.4 的分段公式得出。上界 `actual_minutes``per_hour_contribution = 0` 时取到。
**Validates: Requirements 6.3, 6.4, 10.5, 10.6**
### Property 5: 次均消费公式
*For any* 非负的 `total_consume_amount` 和非负整数 `total_visit_count``avg_ticket_amount` 应等于 `total_consume_amount / MAX(total_visit_count, 1)`
推导:直接从需求 3.4 得出。`MAX(total_visit_count, 1)` 防止除零。
**Validates: Requirements 3.4, 10.7**
### Property 6: 重叠检测正确性
*For any* 一组助教服务记录,若在指定区域的同一台桌上存在 3 名或以上助教的服务时间段有重叠,则 `detect_overlap_violations` 应返回非空的违规列表。
推导:需求 6.1 要求检测"同一台桌同一时间段超过 2 名助教挂台"。我们可以生成随机的服务记录(包含时间段重叠和不重叠的情况),验证检测函数的正确性。
**Validates: Requirements 6.1**
## 错误处理
| 场景 | 处理方式 |
|------|----------|
| 订单无助教服务记录 | 跳过该订单,不生成统计记录 |
| 台桌使用时长为 0 | 台费分摊设为 0避免除零 |
| 助教总服务时长为 0 | 酒水食品分摊设为 0避免除零 |
| 会员无充值记录 | 充值次数/金额设为 0 |
| 会员无消费记录 | avg_ticket_amount 设为 0 |
| 助教当日无违规 | penalty_minutes = 0penalty_reason = NULL |
| 服务记录缺少时间段信息 | 跳过该记录的重叠检测,日志 WARNING |
| per_hour_contribution 为负数 | 视为 0 处理(防御性编程) |
| FDW 映射创建失败 | 事务回滚,报错终止 |
| 数据库写入失败 | 事务回滚,抛出异常由调度器处理 |
> **注意:所有数据库操作均在测试库(`test_etl_feiqiu` / `test_zqyy_app`)中进行。**
## 测试策略
### 属性测试hypothesis
属性测试位于 `tests/` 目录Monorepo 级),使用 `hypothesis` 库。
每个属性测试对应设计文档中的一个 Property最少运行 100 次迭代。
测试文件:`tests/test_dws_contribution_properties.py`
```python
# Feature: 02-etl-dws-miniapp-extensions, Property 1: 订单级统计不变量
@given(order_data=order_data_strategy())
@settings(max_examples=200)
def test_gross_net_equal_across_assistants(order_data):
"""所有参与助教的 order_gross_revenue 和 order_net_revenue 应分别相等"""
gross = AssistantOrderContributionTask.compute_order_gross_revenue(order_data)
net = AssistantOrderContributionTask.compute_order_net_revenue(order_data)
# 每个助教获得相同的 gross 和 net
for assistant in order_data.assistants:
assert assistant_gross == gross
assert assistant_net == net
```
```python
# Feature: 02-etl-dws-miniapp-extensions, Property 4: 惩罚分钟数分段公式
@given(
actual_minutes=st.decimals(min_value=0, max_value=600, places=2),
per_hour_contribution=st.decimals(min_value=0, max_value=200, places=2),
)
@settings(max_examples=200)
def test_penalty_minutes_formula(actual_minutes, per_hour_contribution):
"""惩罚分钟数应符合分段公式且在 [0, actual_minutes] 范围内"""
result = AssistantDailyTask.compute_penalty_minutes(
actual_minutes, per_hour_contribution
)
if per_hour_contribution >= 24:
assert result == 0
else:
expected = actual_minutes * (1 - per_hour_contribution / 24)
assert abs(result - expected) < Decimal('0.01')
assert 0 <= result <= actual_minutes
```
属性测试库:`hypothesis`(已在项目依赖中)
### 单元测试
单元测试位于 `apps/etl/connectors/feiqiu/tests/unit/`,使用 FakeDB/FakeAPI 工具。
重点覆盖:
- PRD 示例数据验算:使用 PRD 中的具体订单示例3 名助教、2 张台桌、酒水 600 元)验证四项统计的精确数值
- 超休/打赏课边界:验证超休助教的四项统计等于个人流水
- 零值边界:无台费、无酒水、无助教服务的极端情况
- 惩罚计算边界per_hour_contribution 恰好等于 24 元的临界值
- 充值窗口:验证 30/60/90 天窗口的正确切分
- 豁免逻辑is_exempt = TRUE 时跳过惩罚
### 测试配置
- 属性测试:`cd C:\NeoZQYY && pytest tests/test_dws_contribution_properties.py -v`
- 单元测试:`cd apps/etl/connectors/feiqiu && pytest tests/unit/test_assistant_order_contribution.py -v`
- 每个属性测试标注 `@settings(max_examples=200)`
- 每个属性测试注释引用设计文档 Property 编号

View File

@@ -0,0 +1,157 @@
# 需求文档ETL DWS 层扩展 — 小程序数据支撑
## 简介
本 Spec 覆盖 P2 任务中 T4T11 的 ETL DWS 层扩展,为小程序提供三类核心数据支撑:
1. 助教订单流水四项统计(`dws_assistant_order_contribution`
2. 会员消费汇总扩展(充值窗口 + 次均消费)
3. 定档折算惩罚检测与计算
同时包含新表的 RLS 视图创建、FDW 映射同步,以及影子跑数验证。
> SPI 消费力指数T1T3已在独立 Spec `.kiro/specs/spi-spending-power-index/` 中完成,本文档不再重复。
## 术语表
- **AssistantOrderContributionTask**:助教订单流水统计 ETL 任务,粒度 `(site_id, assistant_id, stat_date)`
- **MemberConsumptionTask**:会员消费汇总 ETL 任务,粒度 `(site_id, member_id, stat_date)`
- **AssistantDailyTask**:助教日度业绩明细 ETL 任务,粒度 `(site_id, assistant_id, stat_date)`
- **dws_assistant_order_contribution**:助教订单流水四项统计结果表
- **dws_member_consumption_summary**:会员消费汇总表(已有,需扩展字段)
- **dws_assistant_daily_detail**:助教日度业绩明细表(已有,需扩展字段)
- **order_gross_revenue**:订单总流水 — 助教参与订单的全部流水(台费 + 酒水食品 + 助教服务费)
- **order_net_revenue**:订单净流水 — 订单总流水 - 该订单所有助教的服务分成总额
- **time_weighted_revenue**:时效贡献流水 — 按助教个人服务时长折算的订单金额贡献
- **time_weighted_net_revenue**:时效净贡献 — 时效贡献流水 - 该助教个人的服务分成
- **penalty_minutes**:定档折算惩罚分钟数 — 因违规被扣减的定档业绩时长
- **per_hour_contribution**:单人每小时贡献流水 — 台费/房费每小时实收单价 ÷ 本次基础课助教人数
- **RLS 视图**:行级安全视图,位于 ETL 库 `app` schema`site_id` 隔离数据
- **FDW 映射**:外部数据包装器映射,将 ETL 库表映射到业务库 `fdw_etl` schema
- **settle_type**结算类型1=台桌结账3=商城订单5=充值订单
- **BaseDwsTask**DWS 层任务基类,提供 delete-before-insert 幂等机制
- **delete-before-insert**:幂等更新策略,先按条件删除旧记录再批量插入新记录
## 需求
### 需求 1助教订单流水统计表创建T4
**用户故事:** 作为 ETL 开发者,我需要创建助教订单流水四项统计表,以便存储每名助教每日的订单流水贡献数据。
#### 验收标准
1. THE 开发者 SHALL 创建 `dws.dws_assistant_order_contribution` 表,主键为 `(site_id, assistant_id, stat_date)`
2. THE dws_assistant_order_contribution 表 SHALL 包含四项统计字段:`order_gross_revenue`(订单总流水)、`order_net_revenue`(订单净流水)、`time_weighted_revenue`(时效贡献流水)、`time_weighted_net_revenue`(时效净贡献),精度为 `NUMERIC(14,2)`
3. THE dws_assistant_order_contribution 表 SHALL 包含辅助字段:`order_count`(参与订单数)、`total_service_seconds`(总服务时长秒数)、`assistant_nickname`(助教昵称)
4. THE dws_assistant_order_contribution 表 SHALL 包含元数据字段:`tenant_id``created_at``updated_at`
5. THE 开发者 SHALL 编写迁移脚本 `db/etl_feiqiu/migrations/<日期>__create_dws_assistant_order_contribution.sql`,在测试库 `test_etl_feiqiu` 中执行建表
6. WHEN DDL 在测试库执行成功后THE 开发者 SHALL 运行 `gen_consolidated_ddl.py` 导出最新 DDL
### 需求 2助教订单流水四项统计计算T5
**用户故事:** 作为产品经理,我需要助教订单流水四项统计(订单总流水/订单净流水/时效贡献流水/时效净贡献),以便评估助教个人能力。
#### 验收标准
1. THE AssistantOrderContributionTask SHALL 从 `dwd.dwd_settlement_head``dwd.dwd_table_fee_log``dwd.dwd_assistant_service_log` 提取订单、台费和助教服务数据
2. WHEN 计算 order_gross_revenue 时THE AssistantOrderContributionTask SHALL 将助教参与订单的全部流水(台费 + 酒水食品 + 所有助教服务费)累加,每个参与助教获得相同的订单总流水值
3. WHEN 计算 order_net_revenue 时THE AssistantOrderContributionTask SHALL 从订单总流水中减去该订单所有助教的服务分成总额,每个参与助教获得相同的订单净流水值
4. WHEN 计算 time_weighted_revenue 时THE AssistantOrderContributionTask SHALL 按以下步骤折算个人贡献:
- 确定每张台桌的有效计费时长:取 MAX(该台桌所有助教服务时长之和, 台桌使用时长)
- 按助教在该台桌的服务时长占比分摊台费
- 助教个人服务费直接计入
- 酒水食品按助教个人服务总时长占所有助教服务总时长的比例均分
5. WHEN 计算 time_weighted_net_revenue 时THE AssistantOrderContributionTask SHALL 从该助教的时效贡献流水中减去该助教个人的服务分成
6. WHEN 助教为超休/打赏课类型时THE AssistantOrderContributionTask SHALL 将四项统计均设为该助教个人的服务流水和分成(不参与订单级分摊)
7. THE AssistantOrderContributionTask SHALL 以任务代码 `DWS_ASSISTANT_ORDER_CONTRIBUTION` 注册到 task_registry
8. THE AssistantOrderContributionTask SHALL 采用 delete-before-insert 策略按日期窗口幂等更新
### 需求 3会员消费汇总扩展T6
**用户故事:** 作为产品经理,我需要客户 30/60/90 天充值次数和金额、次均消费,以便在客户看板中展示。
#### 验收标准
1. THE 开发者 SHALL 在 `dws.dws_member_consumption_summary` 表中新增以下字段:`recharge_count_30d``recharge_count_60d``recharge_count_90d`充值次数INTEGER`recharge_amount_30d``recharge_amount_60d``recharge_amount_90d`充值金额NUMERIC(14,2))、`avg_ticket_amount`次均消费额度NUMERIC(14,2)
2. THE 开发者 SHALL 编写 ALTER TABLE 迁移脚本在测试库 `test_etl_feiqiu` 中执行字段扩展
3. THE 充值数据 SHALL 从 `dwd.dwd_recharge_order` 提取,按 `member_id` 和时间窗口聚合
4. THE avg_ticket_amount SHALL 按公式 `total_consume_amount / MAX(total_visit_count, 1)` 计算
### 需求 4会员消费汇总任务修改T7
**用户故事:** 作为 ETL 开发者,我需要修改 MemberConsumptionTask 以填充新增的充值窗口和次均消费字段。
#### 验收标准
1. THE MemberConsumptionTask SHALL 在 extract 阶段新增充值订单提取逻辑,从 `dwd.dwd_recharge_order` 按 30/60/90 天窗口聚合充值次数和金额
2. THE MemberConsumptionTask SHALL 在 transform 阶段将充值统计和次均消费填充到输出记录中
3. WHEN 会员无充值记录时THE MemberConsumptionTask SHALL 将充值次数设为 0、充值金额设为 0.00
4. WHEN 会员无消费记录时THE MemberConsumptionTask SHALL 将 avg_ticket_amount 设为 0.00
### 需求 5助教日度明细表扩展 — 定档折算惩罚字段T8
**用户故事:** 作为 ETL 开发者,我需要在助教日度明细表中新增定档折算惩罚相关字段,以便存储惩罚检测和计算结果。
#### 验收标准
1. THE 开发者 SHALL 在 `dws.dws_assistant_daily_detail` 表中新增以下字段:`penalty_minutes` (NUMERIC(10,2))、`penalty_reason` (TEXT)、`is_exempt` (BOOLEAN DEFAULT FALSE)、`per_hour_contribution` (NUMERIC(14,2))
2. THE 开发者 SHALL 编写 ALTER TABLE 迁移脚本在测试库 `test_etl_feiqiu` 中执行字段扩展
3. WHEN 助教当日无惩罚时THE penalty_minutes SHALL 为 0penalty_reason SHALL 为 NULL
### 需求 6定档折算惩罚检测与计算逻辑T9
**用户故事:** 作为产品经理,我需要定档折算惩罚数据,以便在绩效页面展示折算详情,防止助教利用低价订单冲档位。
#### 验收标准
1. THE AssistantDailyTask SHALL 检测规则 2 违规:在指定区域(大厅 A/B/C/S/TV 和麻将房 M1M7同一台桌同一时间段超过 2 名助教挂台(课程时间段有重叠即算)
2. WHEN 检测到违规时THE AssistantDailyTask SHALL 计算单人每小时贡献流水:台费/房费每小时实收单价 ÷ 本次基础课助教人数
3. WHEN per_hour_contribution >= 24 元时THE AssistantDailyTask SHALL 按满额计入定档业绩时长penalty_minutes = 0
4. WHEN per_hour_contribution < 24 元时THE AssistantDailyTask SHALL 按比例折算:`penalty_minutes = 实际服务分钟数 × (1 - per_hour_contribution / 24)`
5. WHEN 订单标记为 is_exempt = TRUE 时THE AssistantDailyTask SHALL 跳过惩罚计算penalty_minutes 设为 0
6. THE 定档折算惩罚 SHALL 仅影响定档业绩时长统计,不影响实际工资时长
7. THE AssistantDailyTask SHALL 每日自动计算惩罚,计算频率与现有日度任务一致
### 需求 7RLS 视图创建T10
**用户故事:** 作为 ETL 开发者,我需要为新表创建 RLS 视图,以便通过 FDW 安全地向业务库暴露数据。
#### 验收标准
1. THE 开发者 SHALL 在 ETL 库 `app` schema 中为 `dws_assistant_order_contribution` 创建 RLS 视图,按 `site_id` 过滤
2. THE 开发者 SHALL 更新已有 RLS 视图以包含 `dws_member_consumption_summary``dws_assistant_daily_detail` 的新增字段
3. THE RLS 视图 SHALL 使用 `current_setting('app.current_site_id')::INTEGER` 进行行级过滤
### 需求 8FDW 映射同步T10
**用户故事:** 作为后端开发者,我需要在业务库中通过 FDW 访问新建和扩展的 ETL 表,以便小程序后端读取数据。
#### 验收标准
1. THE 开发者 SHALL 在 `test_zqyy_app.fdw_etl` schema 中创建 `dws_assistant_order_contribution` 的外部表映射
2. THE 开发者 SHALL 更新 `dws_member_consumption_summary``dws_assistant_daily_detail` 的 FDW 映射以包含新增字段
3. THE FDW 映射 SHALL 通过 `app` schema 的 RLS 视图访问数据,而非直接访问 `dws` schema
### 需求 9影子跑数验证T11
**用户故事:** 作为 ETL 开发者,我需要通过影子跑数验证新增统计的正确性,以便确保数据质量。
#### 验收标准
1. THE 开发者 SHALL 编写验证脚本,对照 PRD 示例数据验算四项统计的计算结果
2. THE 验证脚本 SHALL 检查 `dws_assistant_order_contribution` 中四项统计数值的一致性order_gross_revenue 各助教相等、order_net_revenue 各助教相等、time_weighted_revenue 各助教之和加上误差容忍度等于订单总流水
3. THE 验证脚本 SHALL 检查 `dws_member_consumption_summary` 新增字段有值且充值金额与 `dwd_recharge_order` 源数据一致
4. THE 验证脚本 SHALL 检查定档折算惩罚字段在符合惩罚条件的记录上正确填充
### 需求 10算法正确性测试
**用户故事:** 作为 ETL 开发者我需要通过属性测试hypothesis验证四项统计和惩罚计算的正确性。
#### 验收标准
1. THE 属性测试 SHALL 验证:对于任意订单,所有参与助教的 order_gross_revenue 值相等
2. THE 属性测试 SHALL 验证:对于任意订单,所有参与助教的 order_net_revenue 值相等
3. THE 属性测试 SHALL 验证:对于任意订单,所有参与助教的 time_weighted_revenue 之和应在订单总流水的合理误差范围内±0.01 元)
4. THE 属性测试 SHALL 验证对于任意助教time_weighted_net_revenue = time_weighted_revenue - 该助教个人服务分成
5. THE 属性测试 SHALL 验证:对于任意 per_hour_contribution >= 24 的记录penalty_minutes 为 0
6. THE 属性测试 SHALL 验证:对于任意 per_hour_contribution < 24 且 per_hour_contribution >= 0 的记录penalty_minutes = 实际分钟数 × (1 - per_hour_contribution / 24)
7. THE 属性测试 SHALL 验证对于任意会员avg_ticket_amount = total_consume_amount / MAX(total_visit_count, 1)

View File

@@ -0,0 +1,152 @@
# 实现计划ETL DWS 层扩展 — 小程序数据支撑
## 概述
基于设计文档将实现拆分为DDL 建表/扩展 → 助教订单流水统计任务 → 会员消费汇总扩展 → 定档折算惩罚 → RLS 视图 + FDW 映射 → 影子跑数验证 六个阶段。所有数据库操作在测试库(`test_etl_feiqiu` / `test_zqyy_app`)中进行。
## 任务
- [x] 1. DDL 建表与字段扩展
- [x] 1.1 编写迁移脚本创建 `dws.dws_assistant_order_contribution`
- 新建 `db/etl_feiqiu/migrations/<日期>__create_dws_assistant_order_contribution.sql`
- 包含表定义、唯一索引 `idx_aoc_site_assistant_date`、查询索引 `idx_aoc_stat_date`
- 字段参照设计文档数据模型章节
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5_
- [x] 1.2 编写迁移脚本扩展 `dws_member_consumption_summary` 字段
- 新建 `db/etl_feiqiu/migrations/<日期>__alter_member_consumption_add_recharge_fields.sql`
- ALTER TABLE 添加 `recharge_count_30d/60d/90d``recharge_amount_30d/60d/90d``avg_ticket_amount`
- _Requirements: 3.1, 3.2_
- [x] 1.3 编写迁移脚本扩展 `dws_assistant_daily_detail` 字段
- 新建 `db/etl_feiqiu/migrations/<日期>__alter_assistant_daily_add_penalty_fields.sql`
- ALTER TABLE 添加 `penalty_minutes``penalty_reason``is_exempt``per_hour_contribution`
- _Requirements: 5.1, 5.2_
- [x] 1.4 在测试库 `test_etl_feiqiu` 执行全部迁移脚本
- 通过 `PG_DSN`(指向测试库)连接执行 SQL
- _Requirements: 1.5, 3.2, 5.2_
- [x] 1.5 运行 `gen_consolidated_ddl.py` 导出最新 DDL
- 执行 `python scripts/ops/gen_consolidated_ddl.py`
- 验证 `docs/database/ddl/etl_feiqiu__dws.sql` 已包含新表和扩展字段
- _Requirements: 1.6_
- [x] 2. 实现助教订单流水统计任务
- [x] 2.1 创建数据结构和 `AssistantOrderContributionTask` 骨架
- 新建 `apps/etl/connectors/feiqiu/tasks/dws/assistant_order_contribution_task.py`
- 定义 `TableUsage``AssistantService``OrderData` dataclass
- 定义 `AssistantOrderContributionTask` 类继承 `BaseDwsTask`
- 实现 `get_task_code``get_target_table``get_primary_keys`
- _Requirements: 1.1, 2.7_
- [x] 2.2 实现四项统计核心计算(纯函数)
- 实现 `compute_order_gross_revenue` 静态方法
- 实现 `compute_order_net_revenue` 静态方法
- 实现 `compute_time_weighted_revenue` 静态方法(含台费分摊、酒水均分逻辑)
- 实现 `compute_time_weighted_net_revenue` 静态方法
- 处理超休/打赏课特殊情况
- _Requirements: 2.2, 2.3, 2.4, 2.5, 2.6_
- [x] 2.3 编写属性测试:订单级统计不变量
- **Property 1: 订单级统计不变量 — gross/net 各助教相等**
- **Validates: Requirements 2.2, 2.3, 10.1, 10.2**
- [x] 2.4 编写属性测试:时效贡献流水之和约束
- **Property 2: 时效贡献流水之和 ≤ order_gross_revenue**
- **Validates: Requirements 2.4, 10.3**
- [x] 2.5 编写属性测试:时效净贡献减法关系
- **Property 3: time_weighted_net_revenue = time_weighted_revenue - commission**
- **Validates: Requirements 2.5, 10.4**
- [x] 2.6 实现 `extract` 方法
-`dwd_settlement_head``dwd_table_fee_log``dwd_assistant_service_log` 提取数据
-`order_settle_id` 聚合为 `OrderData` 结构
- _Requirements: 2.1_
- [x] 2.7 实现 `transform` 方法
- 遍历订单,调用四项统计计算函数
-`(assistant_id, stat_date)` 聚合日度统计
- _Requirements: 2.2, 2.3, 2.4, 2.5, 2.6_
- [x] 2.8 注册任务到 task_registry 并导出模块
-`tasks/dws/__init__.py` 中导出 `AssistantOrderContributionTask`
-`orchestration/task_registry.py` 中注册 `DWS_ASSISTANT_ORDER_CONTRIBUTION``layer="DWS"``depends_on=["DWD_LOAD_FROM_ODS"]`
- _Requirements: 2.7, 2.8_
- [x] 3. 检查点 — 确保助教订单流水统计测试通过
- 运行属性测试:`cd C:\NeoZQYY && pytest tests/test_dws_contribution_properties.py -v`
- 确保所有属性测试通过,如有问题请询问用户。
- [x] 4. 扩展会员消费汇总任务
- [x] 4.1 在 `MemberConsumptionTask` 中新增充值统计提取
- 新增 `_extract_recharge_stats` 方法,从 `dwd.dwd_recharge_order` 按 30/60/90 天窗口聚合
-`extract` 方法中调用并返回充值统计数据
- _Requirements: 4.1, 3.3_
- [x] 4.2 在 `MemberConsumptionTask.transform` 中填充新字段
- 合并充值统计到输出记录
- 计算 `avg_ticket_amount = total_consume_amount / MAX(total_visit_count, 1)`
- 处理无充值/无消费的边界情况
- _Requirements: 4.2, 4.3, 4.4, 3.4_
- [x] 4.3 编写属性测试:次均消费公式
- **Property 5: avg_ticket_amount = total_consume_amount / MAX(total_visit_count, 1)**
- **Validates: Requirements 3.4, 10.7**
- [x] 5. 实现定档折算惩罚检测与计算
- [x] 5.1 实现时间重叠检测逻辑
-`AssistantDailyTask` 中新增 `detect_overlap_violations` 静态方法
- 定义惩罚区域集合(大厅 A/B/C/S/TV + 麻将房 M1M7
-`(table_id, service_date)` 分组,检测时间段重叠且助教数 > 2
- _Requirements: 6.1_
- [x] 5.2 实现惩罚分钟数计算
-`AssistantDailyTask` 中新增 `compute_penalty_minutes` 静态方法
- 计算 `per_hour_contribution = 台费每小时单价 / 助教人数`
- 按分段公式计算 `penalty_minutes`
- 处理 `is_exempt = TRUE` 豁免逻辑
- _Requirements: 6.2, 6.3, 6.4, 6.5_
- [x] 5.3 集成惩罚逻辑到 `AssistantDailyTask.transform`
- 在现有聚合逻辑后调用重叠检测和惩罚计算
-`penalty_minutes``penalty_reason``is_exempt``per_hour_contribution` 填充到输出记录
- _Requirements: 6.6, 6.7_
- [x] 5.4 编写属性测试:惩罚分钟数分段公式
- **Property 4: 惩罚分钟数符合分段公式且在 [0, actual_minutes] 范围内**
- **Validates: Requirements 6.3, 6.4, 10.5, 10.6**
- [x] 5.5 编写属性测试:重叠检测正确性
- **Property 6: 3+ 助教时间重叠时应检测到违规**
- **Validates: Requirements 6.1**
- [x] 6. 检查点 — 确保惩罚计算和消费汇总测试通过
- 运行属性测试:`cd C:\NeoZQYY && pytest tests/test_dws_contribution_properties.py -v`
- 运行单元测试:`cd apps/etl/connectors/feiqiu && pytest tests/unit/ -k "contribution or penalty or consumption" -v`
- 确保所有测试通过,如有问题请询问用户。
- [x] 7. RLS 视图与 FDW 映射
- [x] 7.1 创建 `dws_assistant_order_contribution` 的 RLS 视图
- 在测试库 `test_etl_feiqiu``app` schema 中创建 `v_dws_assistant_order_contribution` 视图
- 使用 `current_setting('app.current_site_id')::bigint` 过滤
- 授予 `app_reader` 角色 SELECT 权限
- _Requirements: 7.1, 7.3_
- [x] 7.2 验证已有 RLS 视图自动包含新增字段
- 确认 `app.v_dws_member_consumption_summary``app.v_dws_assistant_daily_detail` 使用 `SELECT *`,新增字段自动包含
- _Requirements: 7.2_
- [x] 7.3 创建/更新 FDW 外部表映射
- 在测试库 `test_zqyy_app``fdw_etl` schema 中创建 `dws_assistant_order_contribution` 外部表
- 重建 `dws_member_consumption_summary``dws_assistant_daily_detail` 的 FDW 外部表以包含新字段
- FDW 映射通过 `app` schema RLS 视图访问
- _Requirements: 8.1, 8.2, 8.3_
- [x] 8. 影子跑数验证
- [x] 8.1 编写验证脚本
- 新建 `apps/etl/connectors/feiqiu/scripts/verify_dws_extensions.py`
- 验证四项统计:对照 PRD 示例数据验算,检查 gross/net 各助教相等
- 验证充值窗口:检查新增字段有值,充值金额与源数据一致
- 验证惩罚字段:检查符合条件的记录正确填充
- _Requirements: 9.1, 9.2, 9.3, 9.4_
- [x] 8.2 编写数据库手册文档
- 新建 `docs/database/BD_Manual_dws_assistant_order_contribution.md`
- 包含表结构、字段说明、索引、验证 SQL至少 3 条)、兼容性说明、回滚策略
- 更新 `docs/database/``dws_member_consumption_summary``dws_assistant_daily_detail` 的文档
- _Requirements: 1.1_
- [x] 9. 最终检查点 — 确保所有测试通过
- 运行属性测试:`cd C:\NeoZQYY && pytest tests/test_dws_contribution_properties.py -v`
- 运行单元测试:`cd apps/etl/connectors/feiqiu && pytest tests/unit/ -k "contribution or penalty or consumption" -v`
- 确保所有测试通过,如有问题请询问用户。
## 备注
- 标记 `*` 的子任务为可选(属性测试),可跳过以加速 MVP
- 每个任务引用具体需求编号以确保可追溯
- 所有数据库操作在测试库(`test_etl_feiqiu` / `test_zqyy_app`)中进行
- 检查点确保增量验证
- 属性测试验证全称正确性属性,单元测试验证具体示例和边界情况

View File

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

View File

@@ -0,0 +1,859 @@
# 设计文档小程序用户认证系统miniapp-auth-system
## 概述
本设计在 P1miniapp-db-foundation已建立的 `auth` Schema 基础上,实现完整的小程序用户认证链路:
1. **微信登录**:小程序端发送 `code` → 后端调用微信 `code2Session` → 获取 `openid` → 创建/查找用户 → 签发 JWT
2. **用户申请**新用户填写球房ID + 手机号 + 申请身份 → 系统自动匹配助教/员工 → 管理员审核
3. **权限控制**:基于 `user_site_roles` + `role_permissions` 的 RBAC 模型,权限中间件拦截无权请求
4. **多店铺支持**:一个用户可关联多个 `site_id`,切换店铺时重新签发 JWT
**环境变量依赖**
| 环境变量 | 用途 | 来源 |
|---------|------|------|
| `APP_DB_DSN` / `DB_HOST` 等 | 业务库连接 | 根 `.env` |
| `PG_DSN` / `ETL_DB_HOST` 等 | ETL 库连接FDW 匹配) | 根 `.env` |
| `JWT_SECRET_KEY` | JWT 签名密钥 | `.env.local` |
| `WX_APPID` | 微信小程序 AppID | `.env.local` |
| `WX_SECRET` | 微信小程序 AppSecret | `.env.local` |
**整体认证流程**
```
小程序端 FastAPI 后端 微信服务器
│ │ │
│── wx.login() ──► │ │
│ 获取 code │ │
│ │ │
│── POST /api/xcx/login ──► │ │
│ {code} │── GET code2Session ──────────► │
│ │◄── {openid, session_key} ──── │
│ │ │
│ │── 查找/创建 auth.users ──► │
│ │── 签发 JWT ──► │
│◄── {jwt, status} ─────── │ │
│ │ │
│ [status=pending] │ │
│── POST /api/xcx/apply ──► │ │
│ {site_code, phone, ...} │── 创建 user_applications ──► │
│◄── {application_id} ───── │ │
```
## 架构
### 分层架构
```mermaid
graph TB
subgraph "小程序端"
MP["微信小程序<br/>wx.login() / wx.request()"]
end
subgraph "FastAPI 后端apps/backend/"
subgraph "路由层"
XCX_AUTH["routers/xcx_auth.py<br/>微信登录 + 申请"]
XCX_USER["routers/xcx_user.py<br/>用户状态 + 店铺切换"]
ADMIN_APP["routers/admin_applications.py<br/>管理端审核"]
end
subgraph "中间件层"
PERM_MW["middleware/permission.py<br/>权限中间件"]
end
subgraph "服务层"
WX_SVC["services/wechat.py<br/>code2Session 调用"]
APP_SVC["services/application.py<br/>申请 CRUD + 审核"]
MATCH_SVC["services/matching.py<br/>人员匹配"]
ROLE_SVC["services/role.py<br/>角色权限查询"]
end
subgraph "认证层(已有 + 扩展)"
JWT["auth/jwt.py<br/>JWT 签发/验证(扩展)"]
DEPS["auth/dependencies.py<br/>依赖注入(扩展)"]
end
DB["database.py<br/>数据库连接"]
end
subgraph "数据库"
AUTH_SCHEMA["auth Schema<br/>users / applications / roles / ..."]
FDW_ETL["fdw_etl Schema<br/>v_dim_assistant / v_dim_staff"]
end
subgraph "外部服务"
WX_API["微信 API<br/>code2Session"]
end
MP --> XCX_AUTH
MP --> XCX_USER
XCX_AUTH --> PERM_MW
XCX_USER --> PERM_MW
ADMIN_APP --> PERM_MW
PERM_MW --> JWT
PERM_MW --> DEPS
XCX_AUTH --> WX_SVC
XCX_AUTH --> APP_SVC
ADMIN_APP --> APP_SVC
ADMIN_APP --> MATCH_SVC
XCX_USER --> ROLE_SVC
WX_SVC --> WX_API
APP_SVC --> DB
MATCH_SVC --> DB
ROLE_SVC --> DB
DB --> AUTH_SCHEMA
DB --> FDW_ETL
```
### 请求处理流程
```mermaid
sequenceDiagram
participant MP as 小程序
participant MW as Permission Middleware
participant R as Router
participant S as Service
participant DB as PostgreSQL
MP->>R: POST /api/xcx/login {code}
R->>S: wechat.code2session(code)
S-->>R: openid
R->>DB: SELECT FROM auth.users WHERE wx_openid = ?
alt 用户不存在
R->>DB: INSERT INTO auth.users
end
R->>R: jwt.create_token_pair(user_id, site_id)
R-->>MP: {access_token, refresh_token, status}
Note over MP,DB: 后续请求(需认证)
MP->>MW: GET /api/xcx/... (Bearer token)
MW->>MW: decode_access_token(token)
MW->>DB: SELECT permissions FROM auth.user_site_roles JOIN ...
alt 权限不足
MW-->>MP: 403 Forbidden
else 权限通过
MW->>R: 放行
R->>S: 业务逻辑
S->>DB: 数据操作
R-->>MP: 200 OK
end
```
## 组件与接口
### 组件 1微信认证服务services/wechat.py
**职责**:封装微信 `code2Session` API 调用。
```python
import httpx
from app.config import get
WX_APPID: str = get("WX_APPID", "")
WX_SECRET: str = get("WX_SECRET", "")
CODE2SESSION_URL = "https://api.weixin.qq.com/sns/jscode2session"
async def code2session(code: str) -> dict:
"""
调用微信 code2Session 接口。
返回:
{"openid": str, "session_key": str, "unionid": str | None}
异常:
WeChatAuthError: 微信接口返回错误码时抛出
"""
...
class WeChatAuthError(Exception):
"""微信认证错误,包含 errcode 和 errmsg。"""
def __init__(self, errcode: int, errmsg: str): ...
```
**设计决策**
- 使用 `httpx.AsyncClient` 异步调用微信 API与 FastAPI 异步模型一致
- `WX_APPID` / `WX_SECRET` 从环境变量读取,缺失时在调用时报错(而非启动时,因为非所有端点都需要微信认证)
### 组件 2申请服务services/application.py
**职责**:处理用户申请的创建、查询、审核。
```python
async def create_application(
user_id: int,
site_code: str,
applied_role_text: str,
phone: str,
employee_number: str | None = None,
nickname: str | None = None,
) -> dict:
"""
创建用户申请。
1. 查找 site_code → site_id 映射
2. 检查是否有 pending 申请(有则 409
3. 插入 user_applications 记录
4. 更新 users.nickname如提供
"""
...
async def approve_application(
application_id: int,
reviewer_id: int,
role_id: int,
binding: dict | None = None, # {"assistant_id": ..., "staff_id": ..., "binding_type": ...}
review_note: str | None = None,
) -> dict:
"""
批准申请。
1. 检查申请状态为 pending否则 409
2. 更新 user_applications.status = 'approved'
3. 创建 user_site_roles 记录
4. 创建 user_assistant_binding 记录(如有 binding
5. 更新 users.status = 'approved'(如果是首次通过)
6. 记录 reviewer_id 和 reviewed_at
"""
...
async def reject_application(
application_id: int,
reviewer_id: int,
review_note: str,
) -> dict:
"""
拒绝申请。
1. 检查申请状态为 pending否则 409
2. 更新 user_applications.status = 'rejected'
3. 记录 reviewer_id、review_note、reviewed_at
"""
...
async def get_user_applications(user_id: int) -> list[dict]:
"""查询用户的所有申请记录。"""
...
```
### 组件 3人员匹配服务services/matching.py
**职责**:根据申请信息在 FDW 外部表中查找候选匹配。
```python
async def find_candidates(
site_id: int,
phone: str,
employee_number: str | None = None,
) -> list[dict]:
"""
在助教表和员工表中查找匹配候选。
查询逻辑:
1. fdw_etl.v_dim_assistant: WHERE site_id = ? AND mobile = ?
2. fdw_etl.v_dim_staff + v_dim_staff_ex: WHERE site_id = ? AND (mobile = ? OR job_num = ?)
3. 合并结果,每条包含 source_type / name / mobile / job_num
注意:查询 FDW 外部表前需设置 app.current_site_idRLS 隔离)。
但 fdw_etl 中的外部表映射的是 app schema 的 RLS 视图,
所以需要在 ETL 库连接上设置 site_id。
实际上,我们直接在业务库通过 fdw_etl 查询,
FDW 会透传 session 变量到远端。
返回:
[{"source_type": "assistant"|"staff", "id": int, "name": str, "mobile": str, "job_num": str | None}]
"""
...
```
**设计决策**
- FDW 查询需要在业务库连接上设置 `app.current_site_id`,因为 FDW 外部表映射的是 ETL 库 `app` Schema 的 RLS 视图
- 匹配查询使用业务库连接(`get_connection()`),通过 `SET LOCAL app.current_site_id` 设置隔离
- 如果 `site_code` 无法映射到 `site_id`,直接返回空列表
### 组件 4角色权限服务services/role.py
**职责**:查询用户在指定店铺下的角色和权限。
```python
async def get_user_permissions(user_id: int, site_id: int) -> list[str]:
"""
获取用户在指定 site_id 下的权限 code 列表。
SQL: SELECT DISTINCT p.code
FROM auth.user_site_roles usr
JOIN auth.role_permissions rp ON usr.role_id = rp.role_id
JOIN auth.permissions p ON rp.permission_id = p.id
WHERE usr.user_id = ? AND usr.site_id = ?
"""
...
async def get_user_sites(user_id: int) -> list[dict]:
"""
获取用户关联的所有店铺及对应角色。
返回: [{"site_id": int, "site_name": str, "roles": [{"code": str, "name": str}]}]
"""
...
async def check_user_has_site_role(user_id: int, site_id: int) -> bool:
"""检查用户在指定 site_id 下是否有任何角色绑定。"""
...
```
### 组件 5权限中间件middleware/permission.py
**职责**:基于 JWT 中的 `user_id` + `site_id` 检查用户权限。
```python
from functools import wraps
from fastapi import Depends, HTTPException, status
from app.auth.dependencies import get_current_user, CurrentUser
def require_permission(*permission_codes: str):
"""
权限装饰器/依赖,用于路由端点。
用法:
@router.get("/finance")
async def get_finance(
user: CurrentUser = Depends(require_permission("view_board_finance"))
):
...
逻辑:
1. 从 JWT 提取 user_id + site_id
2. 查询 auth.users.status非 approved 则 403
3. 查询 user_site_roles + role_permissions 获取权限列表
4. 检查是否包含所需权限,不包含则 403
"""
...
def require_approved():
"""
仅检查用户状态为 approved 的依赖(不检查具体权限)。
用于通用的已认证端点。
"""
...
```
**设计决策**
- 使用 FastAPI 依赖注入模式而非全局中间件,更灵活且可按端点配置
- `pending` 用户只能访问申请提交和状态查询端点,其他端点需要 `approved` 状态
- 权限检查结果可考虑短期缓存(当前版本不缓存,每次查库)
### 组件 6JWT 服务扩展auth/jwt.py 扩展)
**职责**:扩展现有 JWT 服务,支持微信登录场景。
**扩展内容**
```python
# 新增创建受限令牌pending 用户)
def create_limited_token_pair(user_id: int) -> dict[str, str]:
"""
为 pending 用户签发受限令牌。
payload 不含 site_id 和 roles仅包含 user_id + type + limited=True。
"""
...
# 扩展create_access_token payload 增加 roles 字段
def create_access_token(user_id: int, site_id: int, roles: list[str] | None = None) -> str:
"""
生成 access_token。
payload: sub=user_id, site_id, roles, type=access, exp
"""
...
```
**设计决策**
- 保持向后兼容:现有 `create_access_token(user_id, site_id)` 调用不受影响(`roles` 默认 `None`
- `pending` 用户的受限令牌通过 `limited=True` 标记区分,权限中间件据此拦截
### 组件 7路由端点
#### 7.1 小程序认证路由routers/xcx_auth.py
| 方法 | 路径 | 说明 | 认证要求 |
|------|------|------|---------|
| POST | `/api/xcx/login` | 微信登录 | 无(公开) |
| POST | `/api/xcx/apply` | 提交申请 | JWT含 pending |
| GET | `/api/xcx/me` | 查询自身状态 | JWT含 pending |
| GET | `/api/xcx/me/sites` | 查询关联店铺 | JWTapproved |
| POST | `/api/xcx/switch-site` | 切换店铺 | JWTapproved |
| POST | `/api/xcx/refresh` | 刷新令牌 | refresh_token |
#### 7.2 管理端审核路由routers/admin_applications.py
| 方法 | 路径 | 说明 | 认证要求 |
|------|------|------|---------|
| GET | `/api/admin/applications` | 查询申请列表 | JWT + site_admin/tenant_admin |
| GET | `/api/admin/applications/{id}` | 查询申请详情 + 候选匹配 | JWT + site_admin/tenant_admin |
| POST | `/api/admin/applications/{id}/approve` | 批准申请 | JWT + site_admin/tenant_admin |
| POST | `/api/admin/applications/{id}/reject` | 拒绝申请 | JWT + site_admin/tenant_admin |
### 组件 8Pydantic 模型schemas/xcx_auth.py
```python
from pydantic import BaseModel, Field
import re
class WxLoginRequest(BaseModel):
code: str = Field(..., min_length=1, description="微信临时登录凭证")
class WxLoginResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
user_status: str # pending / approved / rejected / disabled
user_id: int
class ApplicationRequest(BaseModel):
site_code: str = Field(..., pattern=r"^[A-Za-z]{2}\d{3}$", description="球房ID")
applied_role_text: str = Field(..., min_length=1, max_length=100)
phone: str = Field(..., pattern=r"^\d{11}$", description="手机号")
employee_number: str | None = Field(None, max_length=50)
nickname: str | None = Field(None, max_length=50)
class ApplicationResponse(BaseModel):
id: int
site_code: str
applied_role_text: str
status: str
review_note: str | None = None
created_at: str
reviewed_at: str | None = None
class UserStatusResponse(BaseModel):
user_id: int
status: str
nickname: str | None
applications: list[ApplicationResponse]
class SiteInfo(BaseModel):
site_id: int
site_name: str
roles: list[dict]
class SwitchSiteRequest(BaseModel):
site_id: int
class MatchCandidate(BaseModel):
source_type: str # assistant / staff
id: int
name: str
mobile: str | None
job_num: str | None
class ApproveRequest(BaseModel):
role_id: int
binding: dict | None = None # {"assistant_id": ..., "staff_id": ..., "binding_type": ...}
review_note: str | None = None
class RejectRequest(BaseModel):
review_note: str = Field(..., min_length=1)
```
## 数据模型
### ER 图
```mermaid
erDiagram
users {
serial id PK
varchar wx_openid UK
varchar wx_union_id
varchar wx_avatar_url
varchar nickname
varchar phone
varchar status "pending/approved/rejected/disabled"
timestamptz created_at
timestamptz updated_at
}
user_applications {
serial id PK
int user_id FK
varchar site_code
int site_id "可空,映射后填入"
varchar applied_role_text
varchar employee_number "可选"
varchar phone
varchar status "pending/approved/rejected"
int reviewer_id
text review_note
timestamptz created_at
timestamptz reviewed_at
}
site_code_mapping {
serial id PK
varchar site_code UK "2字母+3数字"
bigint site_id UK
varchar site_name
int tenant_id
timestamptz created_at
}
roles {
serial id PK
varchar code UK
varchar name
text description
timestamptz created_at
}
permissions {
serial id PK
varchar code UK
varchar name
text description
timestamptz created_at
}
role_permissions {
int role_id FK
int permission_id FK
}
user_site_roles {
serial id PK
int user_id FK
bigint site_id
int role_id FK
timestamptz created_at
}
user_assistant_binding {
serial id PK
int user_id FK
bigint site_id
bigint assistant_id "可空"
bigint staff_id "可空"
varchar binding_type "assistant/staff/manager"
timestamptz created_at
}
users ||--o{ user_applications : "提交申请"
users ||--o{ user_site_roles : "店铺角色"
users ||--o{ user_assistant_binding : "人员绑定"
roles ||--o{ user_site_roles : "角色分配"
roles ||--o{ role_permissions : "角色权限"
permissions ||--o{ role_permissions : "权限定义"
site_code_mapping ||--o{ user_applications : "球房映射"
```
### 表 DDL 概要
所有表在 `auth` Schema 下,迁移脚本位于 `db/zqyy_app/migrations/`
**关键约束**
- `users.wx_openid` UNIQUE — 一个微信用户对应一条记录
- `site_code_mapping.site_code` UNIQUE — 球房ID 唯一
- `site_code_mapping.site_id` UNIQUE — site_id 唯一映射
- `user_site_roles (user_id, site_id, role_id)` UNIQUE — 防止重复分配
- `role_permissions (role_id, permission_id)` 联合主键
**索引**
- `users`: `ix_users_wx_openid` (wx_openid)
- `user_applications`: `ix_user_applications_user_id` (user_id), `ix_user_applications_status` (status)
- `user_site_roles`: `ix_user_site_roles_user_site` (user_id, site_id)
- `site_code_mapping`: `ix_site_code_mapping_site_code` (site_code)
### 迁移脚本清单
| 序号 | 文件名 | 内容 |
|------|--------|------|
| 1 | `YYYY-MM-DD__p3_create_auth_tables.sql` | 创建 users / user_applications / site_code_mapping / roles / permissions / role_permissions / user_site_roles / user_assistant_binding |
| 2 | `YYYY-MM-DD__p3_seed_roles_permissions.sql` | 种子数据:权限列表 + 默认角色 + 角色权限映射 |
## 正确性属性Correctness Properties
*属性是系统在所有有效执行中都应保持为真的特征或行为——本质上是关于系统应该做什么的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。*
### Property 1迁移脚本幂等性
*For any* 本次新增的迁移脚本DDL + 种子数据),连续执行两次的结果应与执行一次相同——第二次执行不应产生错误,且数据库状态不变。
**Validates: Requirements 1.9, 2.4, 11.5**
### Property 2登录创建/查找用户正确性
*For any* 有效的微信 `openid`,调用登录逻辑后:若该 `openid` 已存在于 `auth.users` 中,应返回已有用户的 `user_id`若不存在应创建新用户status=`pending`)并返回新 `user_id`。无论哪种情况,返回的 JWT 中 `sub` 应等于该 `user_id`
**Validates: Requirements 3.2, 3.3**
### Property 3disabled 用户登录拒绝
*For any* `auth.users` 中 status 为 `disabled` 的用户,通过其 `openid` 登录时应返回 403 错误,不签发 JWT。
**Validates: Requirements 3.5**
### Property 4申请创建正确性
*For any* 有效的申请数据(合法 `site_code` 格式、11 位手机号、非空 `applied_role_text`),提交申请后 `auth.user_applications` 中应新增一条 status=`pending` 的记录。若 `site_code``site_code_mapping` 中有映射,记录的 `site_id` 应等于映射值;若无映射,`site_id` 为 NULL 但申请仍成功。若提供了 `nickname``auth.users` 中该用户的 `nickname` 应更新。
**Validates: Requirements 4.1, 4.2, 4.3, 4.4**
### Property 5手机号格式验证
*For any* 非 11 位纯数字的字符串作为 `phone` 提交申请,系统应返回 422 错误,`auth.user_applications` 中不应新增记录。
**Validates: Requirements 4.5**
### Property 6重复申请拒绝
*For any* 已有一条 status=`pending` 申请的用户,再次提交申请时应返回 409 错误,`auth.user_applications` 中不应新增记录。
**Validates: Requirements 4.6**
### Property 7人员匹配合并正确性
*For any* 有效的 `site_id``phone` 组合,匹配服务返回的候选列表应满足:(1) 每条候选的 `source_type``assistant``staff`(2) 助教来源的候选来自 `v_dim_assistant` 表中 `site_id``mobile` 匹配的记录;(3) 员工来源的候选来自 `v_dim_staff` 表中 `site_id``mobile`(或 `job_num`)匹配的记录;(4) 列表是两个来源结果的并集,无遗漏。
**Validates: Requirements 5.1, 5.2, 5.3, 5.4**
### Property 8审核操作正确性
*For any* status=`pending` 的申请:(1) 批准操作后,申请 status 变为 `approved``auth.user_site_roles` 中新增角色记录,`auth.users.status` 变为 `approved``reviewer_id``reviewed_at` 非空;(2) 若提供了 binding 信息,`auth.user_assistant_binding` 中新增绑定记录;(3) 拒绝操作后,申请 status 变为 `rejected``review_note` 非空,`reviewer_id``reviewed_at` 非空。
**Validates: Requirements 6.1, 6.2, 6.3, 6.4, 6.5**
### Property 9非 pending 申请审核拒绝
*For any* status 不是 `pending` 的申请(`approved` / `rejected`),对其执行批准或拒绝操作应返回 409 错误,申请状态不变。
**Validates: Requirements 6.6**
### Property 10用户状态查询完整性
*For any* 用户,查询自身状态应返回:(1) 用户的 `status` 字段;(2) 该用户所有申请记录的完整列表。若用户 status 为 `approved`,还应返回已关联的店铺列表和对应角色。
**Validates: Requirements 7.1, 7.2**
### Property 11多店铺角色独立分配
*For any* 用户和多个不同的 `site_id`,系统应允许为该用户在每个 `site_id` 下独立分配不同的角色,且 `auth.user_site_roles` 中的记录互不干扰。
**Validates: Requirements 8.1**
### Property 12店铺切换令牌正确性
*For any* 拥有多店铺绑定的 approved 用户,切换到目标 `site_id` 后签发的新 JWT 中 `site_id` 应等于目标值,`roles` 应等于该用户在目标 `site_id` 下的角色列表。若用户在目标 `site_id` 下无角色绑定,切换应失败。
**Validates: Requirements 8.2, 10.4**
### Property 13权限中间件拦截正确性
*For any* 用户、`site_id` 和所需权限 code 的组合:(1) 若用户 status 非 `approved`,返回 403(2) 若用户在该 `site_id` 下的权限列表不包含所需权限,返回 403(3) 若用户在该 `site_id` 下拥有所需权限且 status 为 `approved`,放行。
**Validates: Requirements 8.3, 9.1, 9.2, 9.3**
### Property 14JWT payload 结构与状态一致性
*For any* 通过登录签发的 JWT(1) 解码后应包含 `sub`user_id`type``exp` 字段;(2) 若用户 status 为 `approved`payload 应包含 `site_id``roles`(3) 若用户 status 为 `pending`payload 应包含 `limited=True`,不含 `site_id``roles`
**Validates: Requirements 10.1, 10.2, 10.3**
### Property 15JWT 过期/无效令牌拒绝
*For any* 过期的 JWT 或被篡改的 JWT 字符串,权限中间件应返回 401 错误,不放行请求。
**Validates: Requirements 9.4**
## 错误处理
### API 错误码规范
| HTTP 状态码 | 场景 | 响应体 |
|------------|------|--------|
| 401 | JWT 无效/过期、微信 code2Session 失败 | `{"detail": "具体错误描述"}` |
| 403 | 用户 disabled、权限不足、用户未 approved | `{"detail": "具体错误描述"}` |
| 404 | 申请不存在 | `{"detail": "申请不存在"}` |
| 409 | 重复提交 pending 申请、审核非 pending 申请 | `{"detail": "具体冲突描述"}` |
| 422 | 请求体校验失败手机号格式、site_code 格式等) | Pydantic 标准错误格式 |
| 500 | 数据库连接失败、微信 API 超时 | `{"detail": "服务器内部错误"}` |
### 微信 API 错误处理
| 微信 errcode | 含义 | 处理方式 |
|-------------|------|---------|
| 0 | 成功 | 正常流程 |
| 40029 | code 无效 | 返回 401提示"登录凭证无效,请重新登录" |
| 45011 | 频率限制 | 返回 429提示"请求过于频繁" |
| 40226 | 高风险用户 | 返回 403提示"账号存在风险" |
| 其他 | 未知错误 | 返回 401记录日志提示"微信登录失败" |
### 数据库错误处理
| 场景 | 处理方式 |
|------|---------|
| 连接失败 | 捕获 `psycopg2.OperationalError`,返回 500 |
| 唯一约束冲突wx_openid | 并发创建时捕获 `UniqueViolation`,改为查询已有记录 |
| 外键约束失败 | 返回 422提示具体的关联数据不存在 |
| FDW 查询失败 | 捕获异常,匹配服务返回空列表,记录日志 |
### 环境变量缺失处理
| 变量 | 缺失时行为 |
|------|-----------|
| `WX_APPID` / `WX_SECRET` | 微信登录端点返回 500日志记录"微信配置缺失" |
| `JWT_SECRET_KEY` | 应用启动时警告空字符串不安全JWT 签发/验证使用空密钥(仅开发环境) |
| `DB_HOST` 等数据库参数 | 数据库连接失败,返回 500 |
## 测试策略
### DDL 测试库落库与文档同步
DDL 变更必须经过以下流程:
1. **测试库执行**:在 `test_zqyy_app` 中执行迁移脚本,验证无错误
2. **幂等性验证**:连续执行两次,第二次无错误
3. **数据库手册更新**:创建/更新 `docs/database/BD_Manual_auth_tables.md`,格式参照现有 `BD_Manual_auth_biz_schemas.md`
4. **DDL 基线刷新**:运行 `python scripts/ops/gen_consolidated_ddl.py` 重新生成 `docs/database/ddl/zqyy_app__auth.sql`
### 小程序认证前端页面
#### 页面清单
| 页面 | 路径 | 说明 | H5 原型 |
|------|------|------|---------|
| login | `pages/login/login` | 微信登录页(自动调用 wx.login | `docs/h5_ui/pages/login.html` |
| apply | `pages/apply/apply` | 申请表单页球房ID + 手机号 + 身份 + 编号 + 昵称) | `docs/h5_ui/pages/apply.html` |
| reviewing | `pages/reviewing/reviewing` | 审核等待页(显示状态 + 申请摘要) | `docs/h5_ui/pages/reviewing.html` |
| no-permission | `pages/no-permission/no-permission` | 无权限/已禁用页 | `docs/h5_ui/pages/no-permission.html` |
#### 认证路由流程
```
app.ts onLaunch()
├── wx.login() → 获取 code
├── POST /api/xcx/login {code}
│ │
│ ├── 返回 user_status = "approved"
│ │ └── 跳转主页task-list 或 home
│ │
│ ├── 返回 user_status = "pending"
│ │ ├── 查询 /api/xcx/me → 有 pending 申请
│ │ │ └── 跳转 reviewing 页面
│ │ └── 查询 /api/xcx/me → 无 pending 申请
│ │ └── 跳转 apply 页面
│ │
│ ├── 返回 user_status = "rejected"
│ │ └── 跳转 reviewing 页面(显示拒绝原因 + 重新申请按钮)
│ │
│ └── 返回 403disabled
│ └── 跳转 no-permission 页面
└── 登录失败(网络错误等)
└── 显示错误提示,提供重试按钮
```
#### app.ts 全局状态管理
```typescript
// globalData 扩展
interface IAppOption {
globalData: {
userInfo?: {
userId: number;
status: string; // pending / approved / rejected / disabled
nickname?: string;
};
token?: string;
refreshToken?: string;
currentSiteId?: number;
sites?: Array<{ siteId: number; siteName: string; roles: string[] }>;
};
}
```
#### 请求封装utils/request.ts
```typescript
/**
* 统一请求封装:
* 1. 自动附加 Authorization: Bearer <token>
* 2. 401 时自动尝试 refresh_token 刷新
* 3. 刷新失败时跳转 login 页面
*/
function request(options: RequestOptions): Promise<any> { ... }
```
### 开发模式联调
#### Mock 登录端点
后端在 `WX_DEV_MODE=true` 时注册 `POST /api/xcx/dev-login`
```python
@router.post("/api/xcx/dev-login")
async def dev_login(openid: str, status: str = "approved"):
"""
开发模式 mock 登录。
直接根据 openid 查找/创建用户,跳过微信 code2Session。
可通过 status 参数模拟不同用户状态。
仅在 WX_DEV_MODE=true 时可用。
"""
...
```
#### 微信开发者工具联调步骤
联调指南文档位于 `apps/miniprogram/doc/auth-integration-guide.md`,包含:
1. 微信开发者工具项目导入配置appid、不校验合法域名
2. 后端启动命令(`cd apps/backend && uvicorn app.main:app --reload`
3. 小程序请求域名配置(开发环境指向 `http://localhost:8000`
4. 测试流程:登录 → 申请 → 管理端审核 → 重新登录验证
5. Mock 模式使用说明
### 属性测试Property-Based Testing
使用 Python `hypothesis` 框架,测试目录:`tests/`Monorepo 级属性测试目录)。
每个属性测试至少运行 100 次迭代。每个测试用注释标注对应的设计属性编号。
标注格式:`# Feature: miniapp-auth-system, Property N: <属性标题>`
**属性测试清单**
| 属性 | 测试文件 | 测试方法 | 生成器 |
|------|---------|---------|--------|
| P2 登录创建/查找用户 | `tests/test_auth_system_properties.py` | 生成随机 openid模拟登录验证用户创建/查找逻辑 | `hypothesis.strategies.text` 生成 openid |
| P4 申请创建正确性 | `tests/test_auth_system_properties.py` | 生成随机合法申请数据,验证申请记录创建 | 自定义 strategy 生成 site_code2字母+3数字、phone11位数字 |
| P5 手机号格式验证 | `tests/test_auth_system_properties.py` | 生成随机非法手机号,验证 422 拒绝 | `hypothesis.strategies.text` 过滤非 11 位数字 |
| P6 重复申请拒绝 | `tests/test_auth_system_properties.py` | 生成随机用户+申请,提交两次,验证第二次 409 | 复用申请数据生成器 |
| P7 人员匹配合并 | `tests/test_auth_system_properties.py` | 生成随机助教/员工数据,验证匹配结果合并 | 自定义 strategy 生成匹配数据 |
| P8 审核操作正确性 | `tests/test_auth_system_properties.py` | 生成随机 pending 申请,执行批准/拒绝,验证状态流转 | 自定义 strategy 生成审核数据 |
| P9 非 pending 审核拒绝 | `tests/test_auth_system_properties.py` | 生成随机非 pending 申请,验证 409 | `hypothesis.strategies.sampled_from(["approved", "rejected"])` |
| P11 多店铺角色独立 | `tests/test_auth_system_properties.py` | 生成随机用户+多个 site_id验证角色独立分配 | `hypothesis.strategies.lists` 生成 site_id 列表 |
| P12 店铺切换令牌 | `tests/test_auth_system_properties.py` | 生成多店铺用户,切换店铺,验证 JWT 内容 | 复用多店铺生成器 |
| P13 权限中间件拦截 | `tests/test_auth_system_properties.py` | 生成随机用户+权限组合,验证中间件判断 | 自定义 strategy 生成权限矩阵 |
| P14 JWT payload 结构 | `tests/test_auth_system_properties.py` | 生成随机用户(不同 status签发 JWT验证 payload | `hypothesis.strategies.sampled_from(["pending", "approved"])` |
| P15 JWT 过期/无效拒绝 | `tests/test_auth_system_properties.py` | 生成随机过期/篡改 JWT验证 401 | 自定义 strategy 生成无效 JWT |
**注意**P1迁移幂等性、P3disabled 登录拒绝、P10状态查询完整性作为集成测试在后端测试目录实现因为它们需要真实数据库环境或涉及具体的数据库状态验证。
### 单元测试
单元测试位于 `apps/backend/tests/`,聚焦于:
- `test_xcx_auth_router.py`微信登录路由测试mock 微信 API
- `test_application_service.py`:申请服务的边界情况
- `test_matching_service.py`匹配逻辑的边界情况空结果、FDW 异常)
- `test_permission_middleware.py`:权限中间件的各种组合
- `test_jwt_extended.py`:扩展 JWT 的 limited token 逻辑
### 集成测试
集成测试通过验证脚本实现,覆盖:
- 迁移脚本幂等性验证(执行两次无错误)
- 种子数据完整性验证(权限和角色数量正确)
- 完整认证流程:登录 → 申请 → 审核 → 权限验证

View File

@@ -0,0 +1,196 @@
# 需求文档小程序用户认证系统miniapp-auth-system
## 简介
本 SPEC 实现小程序用户认证系统,涵盖微信登录、用户申请审核、人员匹配、多店铺权限管理等完整认证链路。系统基于 P1miniapp-db-foundation已建立的 `auth` Schema 和 FDW 映射,在 `test_zqyy_app.auth` 中创建用户、申请、角色、绑定等业务表,并在 FastAPI 后端实现对应的 API 端点和权限中间件。
## 术语表
- **Auth_System**:小程序用户认证系统,负责微信登录、用户管理、申请审核、权限控制的完整后端服务
- **WeChat_Auth_Service**:微信认证服务模块,负责调用微信 `code2Session` 接口换取 `openid``session_key`
- **Application_Service**:用户申请服务模块,负责处理用户提交的入驻申请、状态流转和审核操作
- **Matching_Service**人员匹配服务模块负责根据球房ID和手机号/编号在助教表和员工表中查找候选匹配
- **Permission_Middleware**:权限中间件,基于用户的 `site_id` + `role` 拦截无权请求
- **JWT_Service**JWT 令牌服务,负责签发和刷新 access_token / refresh_token已有实现本 SPEC 扩展)
- **site_code**球房ID格式为 2 字母 + 3 数字(如 `AB123`),与 `site_id` 一一映射
- **site_id**:门店标识符,类型为 `BIGINT`,用于多门店数据隔离
- **user_status**:用户状态枚举,取值为 `pending`(审核中)/ `approved`(已通过)/ `rejected`(已拒绝)/ `disabled`(已禁用)
- **binding_type**:绑定类型枚举,取值为 `assistant`(助教)/ `staff`(员工)/ `manager`(管理员)
- **FDW**`postgres_fdw` 外部数据包装器,通过 `fdw_etl` Schema 读取 ETL 库数据
- **Migration_Script**:存放在 `db/zqyy_app/migrations/` 中的纯 SQL 迁移脚本,以日期前缀命名
- **BD_Manual**:数据库手册文档,存放在 `docs/database/` 中,记录表结构变更、兼容性影响、回滚策略和验证 SQL
- **DDL_Baseline**DDL 基线文件,存放在 `docs/database/ddl/` 中,由 `gen_consolidated_ddl.py` 自动生成
- **Miniprogram_Auth_Pages**:小程序认证相关前端页面,包括登录页、申请表单页、审核等待页、无权限页
- **Dev_Login**:开发模式下的 mock 登录端点,绕过微信 code2Session 调用,用于联调测试
## 需求
### 需求 1认证数据表创建
**用户故事:** 作为后端开发者,我需要在 `auth` Schema 中创建用户认证相关的数据表,以便支撑完整的认证和权限管理功能。
#### 验收标准
1. WHEN Migration_Script 执行完成, THE Auth_System SHALL 在 `auth` Schema 中创建 `users` 表,包含 `id`SERIAL PK`wx_openid`UNIQUE`wx_union_id``wx_avatar_url``nickname``phone``status`(默认 `pending`)、`created_at``updated_at` 字段
2. WHEN Migration_Script 执行完成, THE Auth_System SHALL 在 `auth` Schema 中创建 `user_applications` 表,包含 `id`SERIAL PK`user_id`FK → users`site_code``applied_role_text``employee_number`(可选)、`phone``status`(默认 `pending`)、`reviewer_id``review_note``created_at``reviewed_at` 字段
3. WHEN Migration_Script 执行完成, THE Auth_System SHALL 在 `auth` Schema 中创建 `site_code_mapping` 表,包含 `id`SERIAL PK`site_code`UNIQUE格式 2 字母 + 3 数字)、`site_id`BIGINT UNIQUE`site_name``tenant_id``created_at` 字段
4. WHEN Migration_Script 执行完成, THE Auth_System SHALL 在 `auth` Schema 中创建 `user_site_roles` 表,包含 `id`SERIAL PK`user_id`FK → users`site_id`BIGINT`role_id`FK → roles`created_at` 字段,并对 `(user_id, site_id, role_id)` 建立唯一约束
5. WHEN Migration_Script 执行完成, THE Auth_System SHALL 在 `auth` Schema 中创建 `user_assistant_binding` 表,包含 `id`SERIAL PK`user_id`FK → users`site_id`BIGINT`assistant_id`BIGINT可选`staff_id`BIGINT可选`binding_type``created_at` 字段
6. WHEN Migration_Script 执行完成, THE Auth_System SHALL 在 `auth` Schema 中创建 `roles` 表,包含 `id`SERIAL PK`code`UNIQUE`name``description``created_at` 字段
7. WHEN Migration_Script 执行完成, THE Auth_System SHALL 在 `auth` Schema 中创建 `permissions` 表,包含 `id`SERIAL PK`code`UNIQUE`name``description``created_at` 字段
8. WHEN Migration_Script 执行完成, THE Auth_System SHALL 在 `auth` Schema 中创建 `role_permissions` 表,包含 `role_id`FK → roles`permission_id`FK → permissions字段并以 `(role_id, permission_id)` 为联合主键
9. THE Migration_Script SHALL 使用 `IF NOT EXISTS` / `OR REPLACE` 等幂等语法,确保重复执行不会报错
10. THE Migration_Script SHALL 在脚本中包含回滚语句(以注释形式)
### 需求 2种子数据预置
**用户故事:** 作为系统管理员,我需要系统预置固定的权限列表和默认角色,以便审核时可直接分配。
#### 验收标准
1. WHEN 种子数据脚本执行完成, THE Auth_System SHALL 在 `auth.permissions` 表中插入 5 条固定权限记录:`view_tasks``view_board``view_board_finance``view_board_customer``view_board_coach`
2. WHEN 种子数据脚本执行完成, THE Auth_System SHALL 在 `auth.roles` 表中插入默认角色(至少包含 `coach`(助教)、`staff`(员工)、`site_admin`(店铺管理员)、`tenant_admin`(租户管理员))
3. WHEN 种子数据脚本执行完成, THE Auth_System SHALL 在 `auth.role_permissions` 表中为每个默认角色分配对应的权限组合
4. THE 种子数据脚本 SHALL 使用 `ON CONFLICT DO NOTHING` 语法,确保重复执行不会产生重复数据
### 需求 3微信登录
**用户故事:** 作为球房工作人员,我需要通过微信登录小程序,以便快速进入系统。
#### 验收标准
1. WHEN 小程序端发送微信临时登录凭证(`code`, THE WeChat_Auth_Service SHALL 调用微信 `code2Session` 接口换取 `openid``session_key`
2. WHEN `code2Session` 返回有效 `openid` 且该 `openid` 已存在于 `auth.users` 表中, THE Auth_System SHALL 返回该用户的 JWT 令牌对access_token + refresh_token和用户状态信息
3. WHEN `code2Session` 返回有效 `openid` 且该 `openid` 不存在于 `auth.users` 表中, THE Auth_System SHALL 创建新用户记录status 为 `pending`),返回 JWT 令牌对和 `pending` 状态标识
4. IF `code2Session` 接口调用失败或返回错误码, THEN THE WeChat_Auth_Service SHALL 返回 HTTP 401 错误,包含具体的错误描述
5. WHEN 用户状态为 `disabled`, THE Auth_System SHALL 返回 HTTP 403 错误,拒绝登录
### 需求 4用户申请提交
**用户故事:** 作为球房工作人员我需要在首次登录后填写申请表单球房ID、申请身份、手机号、编号、昵称以便管理员审核我的身份。
#### 验收标准
1. WHEN 用户提交申请表单(包含 `site_code``applied_role_text``phone`,可选 `employee_number`, THE Application_Service SHALL 在 `auth.user_applications` 表中创建一条 status 为 `pending` 的申请记录
2. WHEN 用户提交的 `site_code``auth.site_code_mapping` 中存在映射, THE Application_Service SHALL 将申请记录关联到对应的 `site_id`
3. WHEN 用户提交的 `site_code``auth.site_code_mapping` 中不存在映射, THE Application_Service SHALL 仍然接受申请,申请记录中保留 `site_code` 文本,管理端显示"未找到关联信息"
4. WHEN 用户提交申请时提供了 `nickname`, THE Auth_System SHALL 更新 `auth.users` 表中该用户的 `nickname` 字段
5. IF 用户提交的 `phone` 为空或格式无效(非 11 位数字), THEN THE Application_Service SHALL 返回 HTTP 422 错误,包含具体的校验失败信息
6. WHEN 用户已有一条 `pending` 状态的申请, THE Application_Service SHALL 拒绝重复提交,返回 HTTP 409 错误
### 需求 5人员匹配
**用户故事:** 作为系统我需要根据球房ID和手机号自动建议用户与助教/员工的对应关系,以便管理员快速审核。
#### 验收标准
1. WHEN 管理员查看某条申请详情时, THE Matching_Service SHALL 根据申请中的 `site_id``phone``fdw_etl.v_dim_assistant` 中按 `site_id` + `mobile` 匹配助教记录
2. WHEN 管理员查看某条申请详情时, THE Matching_Service SHALL 根据申请中的 `site_id``phone``fdw_etl.v_dim_staff``fdw_etl.v_dim_staff_ex` 中按 `site_id` + `mobile` 匹配员工记录
3. WHEN 申请中包含 `employee_number`, THE Matching_Service SHALL 额外按 `job_num` 字段匹配员工记录
4. THE Matching_Service SHALL 将助教匹配结果和员工匹配结果合并为统一的候选列表返回,每条候选包含来源类型(`assistant` / `staff`)、姓名、手机号、编号
5. WHEN 助教表和员工表均无匹配结果, THE Matching_Service SHALL 返回空候选列表,管理端显示"未找到关联信息"
6. WHEN 申请的 `site_code` 无法映射到 `site_id`, THE Matching_Service SHALL 跳过匹配,返回空候选列表
### 需求 6申请审核
**用户故事:** 作为租户管理员,我需要审核用户申请,将用户关联到对应的助教/员工,并分配身份权限。
#### 验收标准
1. WHEN 管理员批准申请并选择了候选匹配对象, THE Application_Service SHALL 将申请状态更新为 `approved`,在 `auth.user_assistant_binding` 中创建绑定记录,在 `auth.user_site_roles` 中分配角色
2. WHEN 管理员批准申请但无候选匹配(手动审核), THE Application_Service SHALL 将申请状态更新为 `approved`,仅在 `auth.user_site_roles` 中分配角色,不创建绑定记录
3. WHEN 管理员拒绝申请, THE Application_Service SHALL 将申请状态更新为 `rejected`,记录 `review_note`(拒绝原因)
4. WHEN 申请审核通过后, THE Auth_System SHALL 将 `auth.users` 表中该用户的 `status` 更新为 `approved`
5. WHEN 审核操作完成, THE Application_Service SHALL 记录 `reviewer_id``reviewed_at` 时间戳
6. IF 审核目标申请的状态不是 `pending`, THEN THE Application_Service SHALL 返回 HTTP 409 错误,拒绝重复审核
### 需求 7用户状态查询
**用户故事:** 作为用户,我需要看到自己的申请状态(审核中/通过/拒绝),以便了解审核进度。
#### 验收标准
1. WHEN 用户查询自身状态, THE Auth_System SHALL 返回用户的 `status`、所有申请记录列表(含每条申请的 `site_code``applied_role_text``status``review_note`
2. WHEN 用户状态为 `approved`, THE Auth_System SHALL 同时返回用户已关联的店铺列表和对应角色
### 需求 8多店铺支持与店铺切换
**用户故事:** 作为用户,我可以同时属于多个店铺(连锁场景),切换店铺后数据正确隔离。
#### 验收标准
1. THE Auth_System SHALL 允许一个用户通过多次申请关联到多个不同的 `site_id`,每个 `site_id` 独立分配角色
2. WHEN 用户切换当前店铺, THE JWT_Service SHALL 签发包含新 `site_id` 的 JWT 令牌对
3. WHEN 用户携带某 `site_id` 的 JWT 访问 API, THE Permission_Middleware SHALL 仅允许访问该 `site_id` 下用户拥有权限的资源
### 需求 9权限中间件
**用户故事:** 作为系统,我需要权限中间件正确拦截无权请求,确保数据安全。
#### 验收标准
1. WHEN 用户携带有效 JWT 访问受保护端点, THE Permission_Middleware SHALL 从 JWT 中提取 `user_id``site_id`,查询 `auth.user_site_roles``auth.role_permissions` 获取用户在该店铺下的权限列表
2. WHEN 用户的权限列表不包含端点所需的权限 code, THE Permission_Middleware SHALL 返回 HTTP 403 错误
3. WHEN 用户的 `status` 不是 `approved`, THE Permission_Middleware SHALL 返回 HTTP 403 错误,拒绝访问受保护端点
4. WHEN JWT 令牌过期或无效, THE Permission_Middleware SHALL 返回 HTTP 401 错误
### 需求 10JWT 令牌扩展
**用户故事:** 作为后端开发者,我需要扩展现有 JWT 服务以支持微信登录场景和多店铺切换。
#### 验收标准
1. THE JWT_Service SHALL 在 JWT payload 中包含 `user_id`sub`site_id``roles`(角色 code 列表)、`type`access/refresh`exp` 字段
2. WHEN 用户通过微信登录且状态为 `approved`, THE JWT_Service SHALL 使用用户默认店铺(第一个关联的 site_id签发令牌
3. WHEN 用户通过微信登录且状态为 `pending`, THE JWT_Service SHALL 签发不含 `site_id``roles` 的受限令牌,仅允许访问申请提交和状态查询端点
4. WHEN 用户请求切换店铺, THE JWT_Service SHALL 验证用户在目标 `site_id` 下有角色绑定后签发新令牌
### 需求 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 Auth_System SHALL 在 `test_zqyy_app` 测试库中执行迁移脚本,验证无错误
2. WHEN 迁移脚本执行成功, THE Auth_System SHALL 创建或更新 `docs/database/BD_Manual_auth_tables.md` 数据库手册,包含变更说明、兼容性影响、回滚策略、验证 SQL至少 3 条)
3. WHEN 迁移脚本执行成功, THE Auth_System SHALL 运行 `python scripts/ops/gen_consolidated_ddl.py` 重新生成 DDL 基线文件 `docs/database/ddl/zqyy_app__auth.sql`
4. WHEN 种子数据脚本执行成功, THE Auth_System SHALL 在数据库手册中记录种子数据内容(角色、权限、角色-权限映射)
### 需求 13小程序认证前端页面
**用户故事:** 作为球房工作人员,我需要在小程序中看到登录页、申请表单页、审核状态页,以便完成从微信登录到正式使用的完整流程。
#### 验收标准
1. WHEN 用户首次打开小程序, THE Auth_System SHALL 展示登录页面,调用 `wx.login()` 获取 code 并发送到后端 `/api/xcx/login`
2. WHEN 后端返回 `user_status=pending` 且用户无 pending 申请, THE Auth_System SHALL 跳转到申请表单页面包含球房ID`site_code`)、申请身份、手机号、编号(选填)、昵称输入框
3. WHEN 用户提交申请表单, THE Auth_System SHALL 调用 `/api/xcx/apply` 提交申请,成功后跳转到审核等待页面
4. WHEN 用户状态为 `pending` 且已有 pending 申请, THE Auth_System SHALL 展示审核等待页面,显示"审核中"状态和申请信息摘要
5. WHEN 用户状态为 `rejected`, THE Auth_System SHALL 在审核等待页面显示拒绝原因,并提供"重新申请"按钮
6. WHEN 用户状态为 `approved`, THE Auth_System SHALL 跳转到小程序主页(任务列表)
7. WHEN 用户状态为 `disabled`, THE Auth_System SHALL 展示无权限页面,提示账号已被禁用
8. THE Auth_System SHALL 在小程序 `app.ts``onLaunch` 中实现自动登录逻辑,根据用户状态路由到对应页面
9. WHEN 用户拥有多个店铺, THE Auth_System SHALL 在主页提供店铺切换入口
### 需求 14前后端联调验证
**用户故事:** 作为开发者,我需要在微信开发者工具中验证完整的认证流程(登录→申请→审核→进入主页),确保前后端接口对接正确。
#### 验收标准
1. THE Auth_System SHALL 提供联调验证脚本或文档,说明如何在微信开发者工具中测试完整认证流程
2. THE Auth_System SHALL 在后端提供开发模式下的 mock 登录端点(`POST /api/xcx/dev-login`),接受任意 openid 直接返回 JWT绕过微信 code2Session 调用
3. WHEN 开发模式启用时, THE Auth_System SHALL 允许通过环境变量 `WX_DEV_MODE=true` 切换到 mock 模式
4. THE Auth_System SHALL 在 `apps/miniprogram/doc/` 中提供联调指南文档,包含微信开发者工具配置、后端启动步骤、测试账号说明

View File

@@ -0,0 +1,262 @@
# 实现计划小程序用户认证系统miniapp-auth-system
## 概述
基于已批准的需求和设计文档,将小程序用户认证系统拆分为增量式编码任务。每个任务构建在前一个任务之上,最终完成完整的认证链路。后端使用 Python + FastAPI数据库使用 PostgreSQL 纯 SQL属性测试使用 hypothesis。
## 任务
- [x] 1. 创建认证数据表和种子数据
- [x] 1.1 创建迁移脚本 `db/zqyy_app/migrations/YYYY-MM-DD__p3_create_auth_tables.sql`
-`auth` Schema 下创建 `users``user_applications``site_code_mapping``roles``permissions``role_permissions``user_site_roles``user_assistant_binding` 共 8 张表
- 包含所有字段定义、约束、索引、外键
- 使用 `IF NOT EXISTS` 幂等语法
- 包含回滚语句(注释形式)
- _Requirements: 1.1-1.10_
- [x] 1.2 创建种子数据脚本 `db/zqyy_app/migrations/YYYY-MM-DD__p3_seed_roles_permissions.sql`
- 插入 5 条固定权限:`view_tasks``view_board``view_board_finance``view_board_customer``view_board_coach`
- 插入默认角色:`coach``staff``site_admin``tenant_admin`
- 插入角色-权限映射
- 使用 `ON CONFLICT DO NOTHING` 幂等语法
- _Requirements: 2.1-2.4_
- [x] 1.3 在测试库执行迁移脚本并验证
-`test_zqyy_app` 中执行建表脚本和种子数据脚本
- 验证幂等性:连续执行两次无错误
- 验证表结构、约束、索引正确
- 验证种子数据完整5 权限、4 角色、角色-权限映射)
- _Requirements: 12.1_
- [x] 1.4 更新数据库手册和 DDL 基线
- 创建 `docs/database/BD_Manual_auth_tables.md`,包含变更说明、兼容性影响、回滚策略、验证 SQL至少 3 条)
- 运行 `python scripts/ops/gen_consolidated_ddl.py` 刷新 DDL 基线
- 在数据库手册中记录种子数据内容
- _Requirements: 12.2, 12.3, 12.4_
- [x] 1.5 编写迁移脚本幂等性属性测试
- **Property 1: 迁移脚本幂等性**
- **Validates: Requirements 1.9, 2.4, 11.5**
- [x] 2. 扩展 JWT 服务和认证依赖
- [x] 2.1 扩展 `apps/backend/app/auth/jwt.py`
- 新增 `create_limited_token_pair(user_id)` 函数pending 用户受限令牌)
- 扩展 `create_access_token` 支持 `roles` 参数
- 保持向后兼容
- _Requirements: 10.1, 10.2, 10.3_
- [x] 2.2 扩展 `apps/backend/app/auth/dependencies.py`
- 扩展 `CurrentUser` 数据类,增加 `roles``status``limited` 字段
- 新增 `get_current_user_or_limited` 依赖(允许 pending 用户)
- _Requirements: 10.3, 9.1_
- [x] 2.3 编写 JWT payload 结构属性测试
- **Property 14: JWT payload 结构与状态一致性**
- **Validates: Requirements 10.1, 10.2, 10.3**
- [x] 2.4 编写 JWT 过期/无效拒绝属性测试
- **Property 15: JWT 过期/无效令牌拒绝**
- **Validates: Requirements 9.4**
- [x] 3. 检查点 - 确保所有测试通过
- 确保所有测试通过,如有问题请向用户确认。
- [x] 4. 实现微信认证服务
- [x] 4.1 创建 `apps/backend/app/services/wechat.py`
- 实现 `code2session(code)` 异步函数
- 使用 `httpx.AsyncClient` 调用微信 API
- 从环境变量读取 `WX_APPID` / `WX_SECRET`
- 定义 `WeChatAuthError` 异常类
- _Requirements: 3.1, 3.4_
- [x] 4.2 创建 Pydantic 模型 `apps/backend/app/schemas/xcx_auth.py`
- 定义 `WxLoginRequest``WxLoginResponse``ApplicationRequest``ApplicationResponse``UserStatusResponse``SiteInfo``SwitchSiteRequest``MatchCandidate``ApproveRequest``RejectRequest`
- `site_code` 使用正则校验 `^[A-Za-z]{2}\d{3}$`
- `phone` 使用正则校验 `^\d{11}$`
- _Requirements: 4.5_
- [x] 4.3 创建小程序认证路由 `apps/backend/app/routers/xcx_auth.py`
- 实现 `POST /api/xcx/login`:微信登录(查找/创建用户 + 签发 JWT
- 实现 `POST /api/xcx/apply`:提交申请
- 实现 `GET /api/xcx/me`:查询自身状态
- 实现 `GET /api/xcx/me/sites`:查询关联店铺
- 实现 `POST /api/xcx/switch-site`:切换店铺
- 实现 `POST /api/xcx/refresh`:刷新令牌
-`apps/backend/app/main.py` 中注册路由
- _Requirements: 3.2, 3.3, 3.5, 4.1-4.6, 7.1, 7.2, 8.2_
- [x] 4.4 编写登录创建/查找用户属性测试
- **Property 2: 登录创建/查找用户正确性**
- **Validates: Requirements 3.2, 3.3**
- [x] 4.5 编写申请创建正确性属性测试
- **Property 4: 申请创建正确性**
- **Validates: Requirements 4.1, 4.2, 4.3, 4.4**
- [x] 4.6 编写手机号格式验证属性测试
- **Property 5: 手机号格式验证**
- **Validates: Requirements 4.5**
- [x] 4.7 编写重复申请拒绝属性测试
- **Property 6: 重复申请拒绝**
- **Validates: Requirements 4.6**
- [x] 5. 实现申请服务和人员匹配
- [x] 5.1 创建申请服务 `apps/backend/app/services/application.py`
- 实现 `create_application()`:创建申请 + site_code 映射查找
- 实现 `approve_application()`:批准 + 创建绑定/角色
- 实现 `reject_application()`:拒绝 + 记录原因
- 实现 `get_user_applications()`:查询用户申请列表
- _Requirements: 4.1-4.4, 6.1-6.6_
- [x] 5.2 创建人员匹配服务 `apps/backend/app/services/matching.py`
- 实现 `find_candidates(site_id, phone, employee_number)`
- 通过 FDW 查询 `fdw_etl.v_dim_assistant``fdw_etl.v_dim_staff` / `v_dim_staff_ex`
- 设置 `app.current_site_id` 进行 RLS 隔离
- 合并助教和员工匹配结果
- _Requirements: 5.1-5.6_
- [x] 5.3 创建角色权限服务 `apps/backend/app/services/role.py`
- 实现 `get_user_permissions(user_id, site_id)`
- 实现 `get_user_sites(user_id)`
- 实现 `check_user_has_site_role(user_id, site_id)`
- _Requirements: 8.1, 9.1_
- [x] 5.4 编写人员匹配合并属性测试
- **Property 7: 人员匹配合并正确性**
- **Validates: Requirements 5.1, 5.2, 5.3, 5.4**
- [x] 5.5 编写审核操作正确性属性测试
- **Property 8: 审核操作正确性**
- **Validates: Requirements 6.1, 6.2, 6.3, 6.4, 6.5**
- [x] 5.6 编写非 pending 审核拒绝属性测试
- **Property 9: 非 pending 申请审核拒绝**
- **Validates: Requirements 6.6**
- [ ] 6. 检查点 - 确保所有测试通过
- 确保所有测试通过,如有问题请向用户确认。
- [ ] 7. 实现权限中间件和管理端路由
- [ ] 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`
- 实现 `GET /api/admin/applications`:查询申请列表
- 实现 `GET /api/admin/applications/{id}`:查询申请详情 + 候选匹配
- 实现 `POST /api/admin/applications/{id}/approve`:批准申请
- 实现 `POST /api/admin/applications/{id}/reject`:拒绝申请
-`apps/backend/app/main.py` 中注册路由
- _Requirements: 6.1-6.6, 5.1-5.6_
- [ ] 7.3 编写权限中间件拦截属性测试
- **Property 13: 权限中间件拦截正确性**
- **Validates: Requirements 8.3, 9.1, 9.2, 9.3**
- [ ] 7.4 编写多店铺角色独立分配属性测试
- **Property 11: 多店铺角色独立分配**
- **Validates: Requirements 8.1**
- [ ] 7.5 编写店铺切换令牌属性测试
- **Property 12: 店铺切换令牌正确性**
- **Validates: Requirements 8.2, 10.4**
- [ ] 8. 集成与端到端验证
- [ ] 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` 注册所有新路由
- 确保 `xcx_auth``admin_applications` 路由已注册
- 验证无路由冲突
- _Requirements: 全部_
- [ ] 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 编写用户状态查询完整性属性测试
- **Property 10: 用户状态查询完整性**
- **Validates: Requirements 7.1, 7.2**
- [ ] 8.5 编写 disabled 用户登录拒绝属性测试
- **Property 3: disabled 用户登录拒绝**
- **Validates: Requirements 3.5**
- [ ] 9. 小程序认证前端页面
- [ ] 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/`
- 调用 `wx.login()` 获取 code
- 发送 code 到 `POST /api/xcx/login`
- 根据返回的 `user_status` 路由到对应页面
- 存储 token 到 globalData 和 Storage
- 参考 H5 原型 `docs/h5_ui/pages/login.html`
- _Requirements: 13.1, 13.6, 13.7, 13.8_
- [ ] 9.3 实现申请表单页 `apps/miniprogram/miniprogram/pages/apply/`
- 表单字段球房IDsite_code、申请身份、手机号、编号选填、昵称
- 前端校验site_code 格式2字母+3数字、手机号11位数字
- 提交到 `POST /api/xcx/apply`
- 成功后跳转 reviewing 页面
- 参考 H5 原型 `docs/h5_ui/pages/apply.html`
- _Requirements: 13.2, 13.3_
- [ ] 9.4 实现审核等待页 `apps/miniprogram/miniprogram/pages/reviewing/`
- 显示当前申请状态(审核中/已拒绝)
- 显示申请信息摘要球房ID、申请身份、手机号
- 拒绝时显示拒绝原因 + "重新申请"按钮
- 支持下拉刷新查询最新状态
- 参考 H5 原型 `docs/h5_ui/pages/reviewing.html`
- _Requirements: 13.4, 13.5_
- [ ] 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`
-`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`
- 微信开发者工具项目导入配置说明
- 后端启动步骤(含 `WX_DEV_MODE=true` 配置)
- 测试流程mock 登录 → 申请 → 管理端审核 → 重新登录验证
- 常见问题排查
- _Requirements: 14.1, 14.4_
- [ ] 10.2 在微信开发者工具中执行联调验证
- 验证登录流程wx.login → 后端 → JWT 返回
- 验证申请流程:表单提交 → 后端创建申请 → 审核等待页展示
- 验证状态路由pending/approved/rejected/disabled 各状态正确跳转
- 验证 token 刷新access_token 过期后自动刷新
- _Requirements: 14.1_
- [ ] 11. 最终检查点 - 确保所有测试通过
- 确保所有测试通过,如有问题请向用户确认。
## 备注
- 标记 `*` 的任务为可选,可跳过以加速 MVP
- 每个任务引用了具体的需求编号,确保可追溯
- 检查点确保增量验证
- 属性测试验证通用正确性属性hypothesis最少 100 次迭代)
- 单元测试验证具体例子和边界情况
- 所有数据库操作在测试库 `test_zqyy_app` 进行
- 迁移脚本放在 `db/zqyy_app/migrations/` 目录
- 属性测试放在 `tests/` 目录Monorepo 级)

View File

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

View File

@@ -0,0 +1,252 @@
# 设计文档ETL 全流程前后端联调etl-fullstack-integration
## 概述
本 Spec 是一个运维联调任务,不涉及新功能开发。目标是验证 `admin-web-console` Spec 产出的前后端代码在真实环境下的端到端正确性,同时收集性能数据。
核心流程:
1. 启动后端 + 前端服务
2. 通过 API 登录获取 JWT
3. 提交全流程 ETL 任务api_full, full_window, force-full, 全选常用任务, 自定义窗口 2025-11-01~2026-02-20, 30天切分, 全部门店)
4. 实时监控执行过程,捕获错误/警告
4. 执行完成后进行黑盒数据一致性测试(全链路检查器 `scripts/ops/etl_consistency_check.py` + FlowRunner 内置 `ConsistencyChecker`
5. 生成综合报告(含性能数据和黑盒测试结果)
## 架构
```
联调脚本 (scripts/ops/)
├── 1. 启动服务
│ ├── uvicorn app.main:app (后端 :8000)
│ └── pnpm dev (前端 :5173)
├── 2. API 调用链
│ ├── POST /api/auth/login → JWT
│ ├── GET /api/tasks/registry → 任务列表
│ ├── GET /api/tasks/sync-check → 同步检查
│ ├── POST /api/tasks/validate → CLI 预览
│ └── POST /api/execution/run → 触发执行
├── 3. 监控循环
│ ├── GET /api/execution/queue → 状态轮询
│ ├── GET /api/execution/{id}/logs → 日志获取
│ └── 错误/警告检测
├── 4. 黑盒数据一致性测试
│ ├── 全链路检查器scripts/ops/etl_consistency_check.py
│ │ ├── API JSON vs ODS字段完整性 + 值采样比对
│ │ ├── ODS vs DWD行数 + 字段映射 + 值比对(含 EX 表合并)
│ │ ├── DWD vs DWS聚合表行数 + 数值列健全性检查
│ │ └── 白名单机制ETL_META_COLS / SCD2_COLS / 空字符串≡None
│ ├── FlowRunner 内置检查quality/consistency_checker.py自动执行
│ └── etl-data-consistency Hook可选手动触发
└── 5. 报告生成
└── 输出到 SYSTEM_LOG_ROOT
```
## 任务参数
根据用户需求,联调任务的具体参数:
```python
INTEGRATION_TASK_CONFIG = {
"flow": "api_full", # 全流程API → ODS → DWD → DWS → INDEX
"processing_mode": "full_window", # 全窗口处理
"window_mode": "custom", # 自定义时间范围
"window_start": "2025-11-01 00:00",
"window_end": "2026-02-20 00:00",
"window_split": "day", # 按天切分
"window_split_days": 30, # 30天一个切片
"force_full": True, # 强制全量
"dry_run": False,
"tasks": [ # 全选 is_common=True 的任务
# ODS 层22 个)
"ODS_ASSISTANT_ACCOUNT", "ODS_ASSISTANT_LEDGER", "ODS_ASSISTANT_ABOLISH",
"ODS_SETTLEMENT_RECORDS", "ODS_TABLE_USE", "ODS_TABLE_FEE_DISCOUNT",
"ODS_TABLES", "ODS_PAYMENT", "ODS_REFUND", "ODS_PLATFORM_COUPON",
"ODS_MEMBER", "ODS_MEMBER_CARD", "ODS_MEMBER_BALANCE", "ODS_RECHARGE_SETTLE",
"ODS_GROUP_PACKAGE", "ODS_GROUP_BUY_REDEMPTION",
"ODS_INVENTORY_STOCK", "ODS_INVENTORY_CHANGE",
"ODS_GOODS_CATEGORY", "ODS_STORE_GOODS", "ODS_STORE_GOODS_SALES", "ODS_TENANT_GOODS",
# DWD 层1 个常用)
"DWD_LOAD_FROM_ODS",
# DWS 层15 个常用,排除 DWS_MAINTENANCE
"DWS_BUILD_ORDER_SUMMARY", "DWS_ASSISTANT_DAILY", "DWS_ASSISTANT_MONTHLY",
"DWS_ASSISTANT_CUSTOMER", "DWS_ASSISTANT_SALARY", "DWS_ASSISTANT_FINANCE",
"DWS_MEMBER_CONSUMPTION", "DWS_MEMBER_VISIT",
"DWS_FINANCE_DAILY", "DWS_FINANCE_RECHARGE", "DWS_FINANCE_INCOME_STRUCTURE",
"DWS_FINANCE_DISCOUNT_DETAIL",
"DWS_GOODS_STOCK_DAILY", "DWS_GOODS_STOCK_WEEKLY", "DWS_GOODS_STOCK_MONTHLY",
# INDEX 层3 个常用,排除 DWS_ML_MANUAL_IMPORT
"DWS_WINBACK_INDEX", "DWS_NEWCONV_INDEX", "DWS_RELATION_INDEX",
],
# store_id 由后端从 JWT 注入(默认管理员 site_id=1
# 注意:用户要求"全部门店",但当前系统只有 site_id=1后续多门店需逐个执行
}
```
## 监控策略
- 轮询间隔30 秒
- 最长等待30 分钟(无新日志输出时)
- 错误检测:日志行匹配 `ERROR``CRITICAL``Traceback``Exception`
- 警告检测:日志行匹配 `WARNING``WARN`
- 计时解析:从日志中提取时间戳,计算阶段耗时
## 黑盒数据一致性测试
### 两套检查工具的定位
| 工具 | 路径 | 触发方式 | 覆盖范围 | 适用场景 |
|------|------|---------|---------|---------|
| 全链路检查器 | `scripts/ops/etl_consistency_check.py` | 手动运行 / `etl-data-consistency` Hook | API→ODS→DWD→DWS 四层全链路 | 联调后独立全面验证(本 Spec 主要使用) |
| FlowRunner 内置检查 | `apps/etl/connectors/feiqiu/quality/consistency_checker.py` | FlowRunner 自动调用 `_run_post_consistency_check()` | API→ODS 字段 + ODS→DWD 映射/值 | ETL 执行后自动轻量检查 |
联调场景下FlowRunner 在 ETL 执行完成后已自动运行内置检查并输出报告到 `ETL_REPORT_ROOT`。联调脚本额外运行全链路检查器,覆盖 DWD→DWS 聚合验证和更深入的值采样比对。
### etl-data-consistency Hook
`.kiro/hooks/etl-data-consistency.kiro.hook` 提供手动触发入口,执行 `scripts/ops/etl_consistency_check.py`。联调任务 5 也可通过此 Hook 替代手动运行脚本。
### 白名单机制
全链路检查器在值比对时使用三层白名单过滤,避免 ETL 框架自动填充的列和已知等价差异产生误报:
#### 1. ETL 元数据列白名单(`ETL_META_COLS`
不参与 API↔ODS 值比对的列:
```python
ETL_META_COLS = {"source_file", "source_endpoint", "fetched_at", "payload", "content_hash"}
```
这些列由 ETL 框架在 ODS 落库时自动填充API 源数据中不存在。
#### 2. SCD2 管理列白名单(`SCD2_COLS`
不参与 ODS↔DWD 值比对的列:
```python
SCD2_COLS = {
"valid_from", "valid_to", "is_current", "etl_loaded_at", "etl_batch_id",
"scd2_start_time", "scd2_end_time", "scd2_is_current", "scd2_version",
}
```
这些列由 DWD 层 SCD2 逻辑自动维护ODS 源数据中不存在。
#### 3. 空字符串 vs None 等价规则(`_values_differ()`
API 返回空字符串 `""` 而数据库存储为 `None` 时,视为等价(不算差异),但标记为 `whitelist`
```python
# API 空字符串 "" vs DB None → 白名单(等价但标记)
if api_val is not None and ods_val is None:
if isinstance(api_val, str) and api_val.strip() == "":
return False, "whitelist"
```
报告中白名单差异以折叠 `<details>` 块展示,不计入失败统计。
#### 4. FlowRunner 内置检查的白名单
`consistency_checker.py` 使用类似但略有不同的白名单:
- `ODS_META_COLUMNS`:与 `ETL_META_COLS` 相同,额外包含 `record_index`
- `KNOWN_NO_SOURCE`:按表配置的已知无源字段(如 `dwd.dim_member.update_time`),标记为已知无源而非报错
### 调用方式
联调脚本在 ETL 全流程执行完成后,运行全链路检查器:
```bash
cd C:\NeoZQYY
uv run python scripts/ops/etl_consistency_check.py
```
脚本自动完成:
1.`LOG_ROOT` 找到最近一次 ETL 日志,解析执行的任务列表
2.`FETCH_ROOT` 读取 API JSON 落盘文件
3. 连接数据库(`PG_DSN`),逐表逐字段比对:
- API JSON vs ODS字段完整性 + 值采样比对(随机 5 条)
- ODS vs DWD行数 + 字段映射 + 值比对(含 EX 表合并)
- DWD vs DWS聚合表行数 + 数值列健全性NULL 率、负值、min/max
4. 输出 Markdown 报告到 `ETL_REPORT_ROOT`
### 检查内容
| 检查类型 | 对比对象 | 检查项 | 白名单处理 |
|---------|---------|--------|-----------|
| API vs ODS | API JSON 缓存 vs ODS 表 | 字段完整性 + 值采样比对5 条记录) | `ETL_META_COLS` 排除空字符串≡None |
| ODS vs DWD | ODS 表 vs DWD 表(含 EX 表) | 行数对比 + 字段映射 + 值比对 | `SCD2_COLS` 排除空字符串≡None |
| DWD vs DWS | DWD 源表 vs DWS 聚合表 | 行数非空 + 数值列健全性NULL 率、负值、min/max | 无DWS 为聚合结果,不做逐行值比对) |
### 报告格式
全链路检查器输出 Markdown 报告,包含:
1. ETL 执行概览(任务列表、成功/失败/跳过统计)
2. API↔ODS 数据一致性(逐表逐字段值比对,白名单差异折叠展示)
3. ODS↔DWD 数据一致性(行数对比 + 映射验证 + 值采样,含字段级统计)
4. DWD↔DWS 数据一致性DWS 表概览 + 数值列健全性检查)
5. 异常汇总与建议
### 参考数据
`dataflow-field-completion` 的实际执行结果API vs ODS 22/22 通过ODS vs DWD 38/42 通过。本次联调执行 api_full 全流程后,预期结果应与此一致或更优(因为联调包含最新的字段补全)。
## 报告格式
报告输出为 Markdown 文件,路径:`{SYSTEM_LOG_ROOT}/{date}__etl_integration_report.md`
```markdown
# ETL 全流程联调报告
## 执行概要
- 任务参数:...
- 开始时间 / 结束时间 / 总时长
- 退出码 / 最终状态
## 性能报告
- 各窗口切片耗时对比表
- Top-5 耗时阶段
- 总体吞吐量估算
## 黑盒测试报告
- API vs ODSX/Y 张表通过(白名单差异 N 处)
- ODS vs DWDX/Y 张表通过(白名单差异 N 处)
- DWD vs DWSX 张表有数据 / Y 张总计,异常指标 N 个
- 失败表清单及差异明细
## DEBUG 报告(如有)
- 错误摘要
- 警告摘要
- 相关日志片段
- 可能的原因分析
```
## 正确性属性
本 Spec 为运维联调任务,不涉及新功能代码开发,因此不定义形式化的属性测试。验证通过以下方式进行:
- 服务健康检查通过
- 任务提交成功并开始执行
- 执行完成后退出码和日志符合预期
- 黑盒数据一致性测试通过(全链路检查器覆盖 API→ODS→DWD→DWS 四层,白名单差异不计入失败)
- 报告文件成功生成(含性能报告和黑盒测试报告)
## 测试策略
本 Spec 本身就是一次集成测试。不额外编写单元测试或属性测试。验证标准:
- 后端 API 响应正确
- ETL CLI 子进程正常启动和执行
- 日志正确捕获和推送
- 黑盒数据一致性测试通过(全链路检查器 API→ODS→DWD→DWS + FlowRunner 内置检查)
- 报告文件正确生成到 ETL_REPORT_ROOT全链路检查报告和 SYSTEM_LOG_ROOT联调综合报告
### 黑盒测试验证标准
- API vs ODS所有已采集的 ODS 表字段完整性和值采样检查通过(白名单差异不计入失败)
- ODS vs DWD所有已配置映射的表行数和值比对检查通过SCD2 列排除,白名单差异不计入失败)
- DWD vs DWS所有 DWS 聚合表行数非空,数值列无异常(高 NULL 率、金额负值等)
- 全链路检查报告 `consistency_check_<timestamp>.md` 成功生成到 ETL_REPORT_ROOT
- 综合联调报告中包含黑盒测试结果摘要(含白名单差异统计)

View File

@@ -58,13 +58,28 @@
3. THE 报告 SHALL 标注耗时最长的 Top-5 阶段/任务
4. THE 报告 SHALL 包含每个窗口切片30天的独立耗时对比
### 需求 5联调报告输出
### 需求 5黑盒数据一致性测试
**用户故事:** 作为开发者,我希望联调完成后获得一份综合报告,包含执行情况、性能数据和可能的 DEBUG 信息
**用户故事:** 作为开发者,我希望在 ETL 全流程执行完成后,以黑盒视角对比数据上下游的字段差异和值差异,验证数据从 API 到 ODS 再到 DWD 再到 DWS 的完整性和正确性
#### 验收标准
1. WHEN ETL 全流程执行完成后, THE 联调脚本 SHALL 运行全链路检查器 `scripts/ops/etl_consistency_check.py`,执行 API→ODS→DWD→DWS 四层数据一致性检查
2. WHEN 执行 API vs ODS 检查时, THE 检查器 SHALL 对比 API JSON 落盘数据与 ODS 落库数据的字段完整性和值采样(随机 5 条记录的关键字段),覆盖所有已采集的 ODS 表
3. WHEN 执行 ODS vs DWD 检查时, THE 检查器 SHALL 对比 ODS 数据与 DWD 落库数据的行数、字段映射正确性和值一致性(含 EX 表合并比对)
4. WHEN 执行 DWD vs DWS 检查时, THE 检查器 SHALL 验证 DWS 聚合表的行数非空、数值列健全性NULL 率、负值、min/max标注异常指标
5. WHEN 值比对遇到白名单场景时, THE 检查器 SHALL 将 ETL 元数据列(`source_file``source_endpoint``fetched_at``payload``content_hash`)和 SCD2 管理列排除在值比对之外,并将 API 空字符串 `""` vs DB `None` 视为等价(标记为 whitelist
6. WHEN 黑盒测试完成后, THE 检查器 SHALL 输出 Markdown 报告到 `ETL_REPORT_ROOT` 环境变量指定的目录
7. WHEN 黑盒测试报告生成后, THE 报告 SHALL 包含每张表的检查结果、差异明细(含白名单差异折叠展示)、通过/失败状态、字段级统计、以及汇总统计
### 需求 6联调报告输出
**用户故事:** 作为开发者,我希望联调完成后获得一份综合报告,包含执行情况、性能数据、黑盒测试结果和可能的 DEBUG 信息。
#### 验收标准
1. THE 报告 SHALL 包含:执行概要(任务参数、开始/结束时间、总时长、退出码)
2. THE 报告 SHALL 包含性能报告各阶段耗时、窗口切片耗时对比、Top-5 瓶颈)
3. IF 执行过程中出现错误或警告, THEN THE 报告 SHALL 包含 DEBUG 报告(错误摘要、相关日志片段、可能的原因分析
4. THE 报告 SHALL 输出到 `SYSTEM_LOG_ROOT` 环境变量指定的目录
3. THE 报告 SHALL 包含黑盒测试结果摘要API vs ODS 通过数/总数、ODS vs DWD 通过数/总数、DWD vs DWS 表概览、失败表清单、白名单差异数量
4. IF 执行过程中出现错误或警告, THEN THE 报告 SHALL 包含 DEBUG 报告(错误摘要、相关日志片段、可能的原因分析)
5. THE 报告 SHALL 输出到 `SYSTEM_LOG_ROOT` 环境变量指定的目录

View File

@@ -0,0 +1,131 @@
# 实现计划ETL 全流程前后端联调etl-fullstack-integration
## 概述
基于 `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`),最终生成综合报告。
## 任务
- [x] 1. 服务启动与健康检查
- [x] 1.1 启动后端服务(`apps/backend/`uvicorn :8000确认 API 可达
- 使用 `controlPwshProcess` 启动 `uvicorn app.main:app --host 0.0.0.0 --port 8000`cwd 为 `apps/backend/`
- 等待服务就绪,验证 `http://localhost:8000/docs` 可访问
- _Requirements: 1.1_
- [x] 1.2 启动前端服务(`apps/admin-web/`pnpm dev :5173确认页面可访问
- 使用 `controlPwshProcess` 启动 `pnpm dev`cwd 为 `apps/admin-web/`
- 等待 Vite 就绪,验证 `http://localhost:5173` 可访问
- _Requirements: 1.2_
- [x] 1.3 浏览器登录与健康检查
- 使用 Playwright 打开 `http://localhost:5173`,应自动跳转到 `/login`
- 在登录表单中输入用户名 `admin`、密码 `admin123`,点击登录按钮
- 验证登录成功后跳转到任务配置页面(`/`
- 确认侧边栏导航菜单正常渲染任务配置、任务管理、ETL 状态、数据库、日志、环境配置、运维面板)
- _Requirements: 1.3, 1.4, 1.5_
- [x] 2. 浏览器操作:任务配置与提交
- [x] 2.1 在任务配置页面填写全流程参数
- 在任务配置页面(`/`),选择 Flow 为 `api_full`API → ODS → DWD → DWS → INDEX
- 选择处理模式为 `full_window`(全窗口)
- 设置时间窗口模式为"自定义",填入开始时间 `2025-11-01`、结束时间 `2026-02-20`
- 设置窗口切分为"按天",切分天数为 `30`
- 勾选 `force_full`(强制全量)
- 在任务选择区域,全选 `is_common=True` 的常用任务(共 41 个)
- 确认 CLI 命令预览区域显示完整参数(--flow api_full --processing-mode full_window --window-start ... --window-end ... --window-split day --window-split-days 30 --force-full --tasks ...
- _Requirements: 2.1_
- [x] 2.2 通过浏览器提交任务执行
- 点击"直接执行"按钮SendOutlined 图标),触发 `POST /api/execution/run`
- 确认页面显示任务提交成功的提示消息
- 记录返回的 execution_id从页面响应或跳转中获取
- _Requirements: 2.2, 2.4_
- [ ] 3. 浏览器操作:执行监控与 DEBUG
- [x] 3.1 在任务管理页面监控执行状态
- 导航到"任务管理"页面(`/task-manager`),点击侧边栏"任务管理"菜单
- 在"队列"Tab 中确认刚提交的任务状态为 `running`
- 点击 running 状态的任务行,打开 WebSocket 实时日志流抽屉
- 持续观察日志输出,每 30 秒检查一次页面状态
- 检测日志中的 ERROR / CRITICAL / Traceback / Exception / WARNING 关键字
- 如果连续 30 分钟无新日志输出,报告超时警告
- 任务完成success/failed/cancelled时停止监控
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5_
- [ ] 3.2 对执行过程中发现的错误/警告进行 DEBUG 分析
- 从日志流中收集所有 ERROR 和 WARNING 日志行及其上下文
- 分析错误类型API 超时、数据库连接、数据质量、配置问题等
- 如果任务失败,切换到"历史"Tab 查看完整执行详情和日志
- 记录 DEBUG 发现到报告中
- _Requirements: 3.2, 3.5_
- [ ] 4. 性能计时与报告生成
- [ ] 4.1 从浏览器获取执行日志,提取精细计时数据
- 在"任务管理"→"历史"Tab 中,点击已完成的任务查看执行详情
- 通过 `GET /api/execution/{id}/logs` 获取完整日志(可通过浏览器或 API 辅助)
- 从日志中提取每个窗口切片30天的开始/结束时间
- 计算每个切片的耗时
- 识别 ODS / DWD / DWS / INDEX 各阶段的耗时
- 标注 Top-5 耗时最长的阶段/任务
- _Requirements: 4.1, 4.2, 4.3, 4.4_
- [ ] 4.2 生成综合联调报告,输出到 SYSTEM_LOG_ROOT
- 报告包含:执行概要(参数、时间、退出码)
- 报告包含性能报告各切片耗时对比、Top-5 瓶颈)
- 报告包含DEBUG 报告(如有错误/警告)
- 黑盒测试结果摘要将在任务 5.3 中追加
- 输出路径:`{SYSTEM_LOG_ROOT}/{date}__etl_integration_report.md`
- 路径通过 `SYSTEM_LOG_ROOT` 环境变量获取,缺失时报错
- _Requirements: 6.1, 6.2, 6.4, 6.5_
- [ ] 5. 黑盒数据一致性测试
- [ ] 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 落盘文件
- 脚本连接数据库(`PG_DSN`),逐表逐字段比对:
- API JSON vs ODS字段完整性 + 值采样比对(随机 5 条记录),`ETL_META_COLS` 白名单列排除
- ODS vs DWD行数对比 + 字段映射 + 值比对(含 EX 表合并),`SCD2_COLS` 白名单列排除
- DWD vs DWS聚合表行数非空检查 + 数值列健全性NULL 率、负值、min/max
- 白名单处理API 空字符串 `""` vs DB `None` 视为等价,标记为 whitelist不计入失败
- 报告自动输出到 `ETL_REPORT_ROOT`(环境变量,缺失时报错)
- 备选触发方式:可通过 `etl-data-consistency` Hook 手动触发(效果等同)
- _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5_
- [ ] 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 将黑盒测试结果摘要写入综合联调报告
- 在任务 4.2 生成的联调报告中追加"黑盒测试报告"章节
- 包含API vs ODS 通过数/总数、ODS vs DWD 通过数/总数、DWD vs DWS 表概览
- 包含:白名单差异数量统计、失败表清单
- 引用全链路检查报告的完整路径
- _Requirements: 6.3_
- [ ] 6. 服务清理
- [ ] 6.1 关闭浏览器,停止后端和前端服务,清理资源
- 关闭 Playwright 浏览器实例
- 停止 uvicorn 后端进程(`controlPwshProcess` stop
- 停止 pnpm dev 前端进程(`controlPwshProcess` stop
- 报告联调完成状态
## 说明
- 本 Spec 为运维联调任务,不涉及新功能代码开发
- 不编写属性测试或单元测试,联调本身即为集成验证
- **全程使用 Playwright 浏览器模拟真实用户操作**:登录、页面导航、表单填写、按钮点击、日志查看等均通过浏览器完成
- **黑盒测试使用两套工具**
- 全链路检查器 `scripts/ops/etl_consistency_check.py`:覆盖 API→ODS→DWD→DWS 四层,联调主要使用
- FlowRunner 内置 `ConsistencyChecker``quality/consistency_checker.py`ETL 执行后自动运行,覆盖 API→ODS + ODS→DWD
- **白名单机制**`ETL_META_COLS`ODS 元数据列)、`SCD2_COLS`SCD2 管理列排除在值比对之外API 空字符串 `""` vs DB `None` 视为等价
- **`etl-data-consistency` Hook**`.kiro/hooks/etl-data-consistency.kiro.hook`)可作为手动触发全链路检查的替代方式
- 黑盒测试在 ETL 全流程执行完成后、服务清理前执行,确保数据库中有最新数据可供对比
- 全选常用任务 = 任务注册表中 `is_common=True` 的所有任务(共 41 个)
- "全部门店":当前系统仅有 site_id=1默认管理员绑定如需多门店需逐个执行
- 监控允许空闲等待,最长 30 分钟无新日志才报超时
- 报告输出路径遵循 export-paths 规范:全链路检查报告输出到 `ETL_REPORT_ROOT`,联调综合报告输出到 `SYSTEM_LOG_ROOT`
- 全链路检查器需要 `PG_DSN``FETCH_ROOT``LOG_ROOT``ETL_REPORT_ROOT` 环境变量,缺失时报错

View File

@@ -1,126 +0,0 @@
# 设计文档ETL 全流程前后端联调etl-fullstack-integration
## 概述
本 Spec 是一个运维联调任务,不涉及新功能开发。目标是验证 `admin-web-console` Spec 产出的前后端代码在真实环境下的端到端正确性,同时收集性能数据。
核心流程:
1. 启动后端 + 前端服务
2. 通过 API 登录获取 JWT
3. 提交全流程 ETL 任务api_full, full_window, force-full, 全选常用任务, 自定义窗口 2025-11-01~2026-02-20, 30天切分, 全部门店)
4. 实时监控执行过程,捕获错误/警告
5. 执行完成后生成综合报告
## 架构
```
联调脚本 (scripts/ops/)
├── 1. 启动服务
│ ├── uvicorn app.main:app (后端 :8000)
│ └── pnpm dev (前端 :5173)
├── 2. API 调用链
│ ├── POST /api/auth/login → JWT
│ ├── GET /api/tasks/registry → 任务列表
│ ├── GET /api/tasks/sync-check → 同步检查
│ ├── POST /api/tasks/validate → CLI 预览
│ └── POST /api/execution/run → 触发执行
├── 3. 监控循环
│ ├── GET /api/execution/queue → 状态轮询
│ ├── GET /api/execution/{id}/logs → 日志获取
│ └── 错误/警告检测
└── 4. 报告生成
└── 输出到 SYSTEM_LOG_ROOT
```
## 任务参数
根据用户需求,联调任务的具体参数:
```python
INTEGRATION_TASK_CONFIG = {
"flow": "api_full", # 全流程API → ODS → DWD → DWS → INDEX
"processing_mode": "full_window", # 全窗口处理
"window_mode": "custom", # 自定义时间范围
"window_start": "2025-11-01 00:00",
"window_end": "2026-02-20 00:00",
"window_split": "day", # 按天切分
"window_split_days": 30, # 30天一个切片
"force_full": True, # 强制全量
"dry_run": False,
"tasks": [ # 全选 is_common=True 的任务
# ODS 层22 个)
"ODS_ASSISTANT_ACCOUNT", "ODS_ASSISTANT_LEDGER", "ODS_ASSISTANT_ABOLISH",
"ODS_SETTLEMENT_RECORDS", "ODS_TABLE_USE", "ODS_TABLE_FEE_DISCOUNT",
"ODS_TABLES", "ODS_PAYMENT", "ODS_REFUND", "ODS_PLATFORM_COUPON",
"ODS_MEMBER", "ODS_MEMBER_CARD", "ODS_MEMBER_BALANCE", "ODS_RECHARGE_SETTLE",
"ODS_GROUP_PACKAGE", "ODS_GROUP_BUY_REDEMPTION",
"ODS_INVENTORY_STOCK", "ODS_INVENTORY_CHANGE",
"ODS_GOODS_CATEGORY", "ODS_STORE_GOODS", "ODS_STORE_GOODS_SALES", "ODS_TENANT_GOODS",
# DWD 层1 个常用)
"DWD_LOAD_FROM_ODS",
# DWS 层15 个常用,排除 DWS_MAINTENANCE
"DWS_BUILD_ORDER_SUMMARY", "DWS_ASSISTANT_DAILY", "DWS_ASSISTANT_MONTHLY",
"DWS_ASSISTANT_CUSTOMER", "DWS_ASSISTANT_SALARY", "DWS_ASSISTANT_FINANCE",
"DWS_MEMBER_CONSUMPTION", "DWS_MEMBER_VISIT",
"DWS_FINANCE_DAILY", "DWS_FINANCE_RECHARGE", "DWS_FINANCE_INCOME_STRUCTURE",
"DWS_FINANCE_DISCOUNT_DETAIL",
"DWS_GOODS_STOCK_DAILY", "DWS_GOODS_STOCK_WEEKLY", "DWS_GOODS_STOCK_MONTHLY",
# INDEX 层3 个常用,排除 DWS_ML_MANUAL_IMPORT
"DWS_WINBACK_INDEX", "DWS_NEWCONV_INDEX", "DWS_RELATION_INDEX",
],
# store_id 由后端从 JWT 注入(默认管理员 site_id=1
# 注意:用户要求"全部门店",但当前系统只有 site_id=1后续多门店需逐个执行
}
```
## 监控策略
- 轮询间隔30 秒
- 最长等待30 分钟(无新日志输出时)
- 错误检测:日志行匹配 `ERROR``CRITICAL``Traceback``Exception`
- 警告检测:日志行匹配 `WARNING``WARN`
- 计时解析:从日志中提取时间戳,计算阶段耗时
## 报告格式
报告输出为 Markdown 文件,路径:`{SYSTEM_LOG_ROOT}/{date}__etl_integration_report.md`
```markdown
# ETL 全流程联调报告
## 执行概要
- 任务参数:...
- 开始时间 / 结束时间 / 总时长
- 退出码 / 最终状态
## 性能报告
- 各窗口切片耗时对比表
- Top-5 耗时阶段
- 总体吞吐量估算
## DEBUG 报告(如有)
- 错误摘要
- 警告摘要
- 相关日志片段
- 可能的原因分析
```
## 正确性属性
本 Spec 为运维联调任务,不涉及新功能代码开发,因此不定义形式化的属性测试。验证通过以下方式进行:
- 服务健康检查通过
- 任务提交成功并开始执行
- 执行完成后退出码和日志符合预期
- 报告文件成功生成
## 测试策略
本 Spec 本身就是一次集成测试。不额外编写单元测试或属性测试。验证标准:
- 后端 API 响应正确
- ETL CLI 子进程正常启动和执行
- 日志正确捕获和推送
- 报告文件正确生成到 SYSTEM_LOG_ROOT

View File

@@ -1,86 +0,0 @@
# 实现计划ETL 全流程前后端联调etl-fullstack-integration
## 概述
基于 `admin-web-console` 已完成的前后端代码,进行端到端联调验证。通过后端 API 提交 api_full 全流程 ETL 任务(自定义窗口 2025-11-01~2026-02-2030天切分force-full全选常用任务实时监控执行过程收集性能数据最终生成综合报告。
## 任务
- [ ] 1. 服务启动与健康检查
- [ ] 1.1 启动后端服务(`apps/backend/`uvicorn :8000确认 API 可达
- 启动 `uvicorn app.main:app --host 0.0.0.0 --port 8000`cwd 为 `apps/backend/`
- 验证 `GET /api/tasks/flows` 返回 200
- _Requirements: 1.1_
- [ ] 1.2 启动前端服务(`apps/admin-web/`pnpm dev :5173确认页面可访问
- 启动 `pnpm dev`cwd 为 `apps/admin-web/`
- 验证 `http://localhost:5173` 可达
- _Requirements: 1.2_
- [ ] 1.3 API 健康检查:登录获取 JWT验证任务注册表执行 sync-check
- `POST /api/auth/login` 使用默认管理员账号admin / admin123获取 JWT
- `GET /api/tasks/registry` 确认返回非空任务列表
- `GET /api/tasks/sync-check` 确认 `in_sync=true`(后端注册表与 ETL 真实注册表一致)
- 如果 sync-check 不一致,记录差异并向用户报告
- _Requirements: 1.3, 1.4, 1.5_
- [ ] 2. 全流程任务提交
- [ ] 2.1 构建 TaskConfig 并调用 validate 预览 CLI 命令
- 构建 TaskConfigflow=api_full, processing_mode=full_window, window_mode=custom, window_start="2025-11-01 00:00", window_end="2026-02-20 00:00", window_split=day, window_split_days=30, force_full=True, tasks=全选 is_common=True 的任务
- 调用 `POST /api/tasks/validate` 验证配置有效
- 记录生成的 CLI 命令预览,确认参数完整(--flow api_full --processing-mode full_window --window-start ... --window-end ... --window-split day --window-split-days 30 --force-full --store-id 1 --tasks ...
- _Requirements: 2.1_
- [ ] 2.2 提交任务执行(`POST /api/execution/run`),记录 execution_id
- 调用 `POST /api/execution/run` 提交任务
- 记录返回的 execution_id
- 确认任务状态变为 running
- _Requirements: 2.2, 2.4_
- [ ] 3. 执行监控与 DEBUG
- [ ] 3.1 启动监控循环:每 30 秒轮询状态和日志,检测错误/警告,最长等待 30 分钟
- 轮询 `GET /api/execution/queue` 检查任务状态
- 轮询 `GET /api/execution/{id}/logs` 获取增量日志
- 检测日志中的 ERROR / CRITICAL / Traceback / Exception / WARNING
- 记录每次轮询的时间戳和日志增量行数
- 如果连续 30 分钟无新日志输出,报告超时警告
- 任务完成success/failed/cancelled时停止轮询
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5_
- [ ] 3.2 对执行过程中发现的错误/警告进行 DEBUG 分析
- 收集所有 ERROR 和 WARNING 日志行及其上下文(前后各 5 行)
- 分析错误类型API 超时、数据库连接、数据质量、配置问题等
- 如果任务失败,获取完整 stderr 并分析根因
- 记录 DEBUG 发现到报告中
- _Requirements: 3.2, 3.5_
- [ ] 4. 性能计时与报告生成
- [ ] 4.1 解析执行日志,提取精细计时数据
- 从日志中提取每个窗口切片30天的开始/结束时间
- 计算每个切片的耗时
- 识别 ODS / DWD / DWS / INDEX 各阶段的耗时
- 标注 Top-5 耗时最长的阶段/任务
- _Requirements: 4.1, 4.2, 4.3, 4.4_
- [ ] 4.2 生成综合联调报告,输出到 SYSTEM_LOG_ROOT
- 报告包含:执行概要(参数、时间、退出码)
- 报告包含性能报告各切片耗时对比、Top-5 瓶颈)
- 报告包含DEBUG 报告(如有错误/警告)
- 输出路径:`{SYSTEM_LOG_ROOT}/{date}__etl_integration_report.md`
- 路径通过 `SYSTEM_LOG_ROOT` 环境变量获取,缺失时报错
- _Requirements: 5.1, 5.2, 5.3, 5.4_
- [ ] 5. 服务清理
- [ ] 5.1 停止后端和前端服务,清理资源
- 停止 uvicorn 进程
- 停止 pnpm dev 进程
- 报告联调完成状态
## 说明
- 本 Spec 为运维联调任务,不涉及新功能代码开发
- 不编写属性测试或单元测试,联调本身即为集成验证
- 全选常用任务 = 任务注册表中 `is_common=True` 的所有任务(共 41 个)
- "全部门店":当前系统仅有 site_id=1默认管理员绑定如需多门店需逐个执行
- 监控允许空闲等待,最长 30 分钟无新日志才报超时
- 报告输出路径遵循 export-paths 规范,通过 SYSTEM_LOG_ROOT 环境变量获取

View File

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

View File

@@ -0,0 +1,189 @@
# 需求文档:核心业务层 — 任务系统 + 备注系统 + 触发器机制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 保持一致

View File

@@ -96,29 +96,29 @@
- `default_registry.register("DWS_SPENDING_POWER_INDEX", SpendingPowerIndexTask, requires_db_config=False, layer="INDEX", depends_on=["DWS_MEMBER_CONSUMPTION"])`
- _Requirements: 9.1, 9.2_
- [-] 6. 配置种子数据
- [x] 6. 配置种子数据
- [x] 6.1 在 `db/etl_feiqiu/seeds/seed_index_parameters.sql` 中追加 SPI 参数
- 插入 `index_type='SPI'` 的全部参数行(窗口、基数、权重、映射、稳定性)
- 金额压缩基数使用合理初始默认值
- _Requirements: 7.1, 7.4, 8.5_
- [~] 6.2 在测试库执行种子数据脚本
- [x] 6.2 在测试库执行种子数据脚本
- 通过 TEST_DB_DSN 连接测试库执行 INSERT
- _Requirements: 7.1_
- [~] 7. 检查点 - 确保单元测试和集成验证通过
- [x] 7. 检查点 - 确保单元测试和集成验证通过
- 运行 `cd apps/etl/connectors/feiqiu && pytest tests/unit/test_spi_task.py -v`
- 确保所有测试通过,如有问题请询问用户。
- [ ] 8. 文档更新
- [~] 8.1 编写数据库手册 `docs/database/BD_Manual_dws_member_spending_power_index.md`
- [x] 8. 文档更新
- [x] 8.1 编写数据库手册 `docs/database/BD_Manual_dws_member_spending_power_index.md`
- 包含表结构、字段说明、索引、验证 SQL至少 3 条)、兼容性说明、回滚策略
- _Requirements: 11.1_
- [~] 8.2 更新 ETL 任务文档 `apps/etl/connectors/feiqiu/docs/etl_tasks/index_tasks.md`
- [x] 8.2 更新 ETL 任务文档 `apps/etl/connectors/feiqiu/docs/etl_tasks/index_tasks.md`
- 新增 DWS_SPENDING_POWER_INDEX 章节,包含算法公式、参数清单、数据来源、计算流程
- 更新概述表格和继承体系图
- _Requirements: 11.2, 11.3_
- [~] 9. 最终检查点 - 确保所有测试通过
- [x] 9. 最终检查点 - 确保所有测试通过
- 运行属性测试:`cd C:\NeoZQYY && pytest tests/test_spi_properties.py -v`
- 运行单元测试:`cd apps/etl/connectors/feiqiu && pytest tests/unit/test_spi_task.py -v`
- 确保所有测试通过,如有问题请询问用户。

35
.kiro/steering/doc-map.md Normal file
View File

@@ -0,0 +1,35 @@
---
inclusion: manual
name: doc-map
description: 项目文档地图索引。需要定位文档、理解项目结构、查找规范时手动加载。
---
# 文档地图索引
完整文档地图:`#[[file:docs/DOCUMENTATION-MAP.md]]`
## 快速定位
| 需要什么 | 去哪里找 |
|---------|---------|
| DB 变更审计模板 | `docs/database/BD_Manual_*.md` |
| API 端点参考 | `apps/backend/docs/API-REFERENCE.md` |
| ETL 任务说明 | `apps/etl/connectors/feiqiu/docs/etl_tasks/` |
| ETL 业务规则 | `apps/etl/connectors/feiqiu/docs/business-rules/` |
| 输出路径规范 | `docs/deployment/EXPORT-PATHS.md` |
| 上线检查清单 | `docs/deployment/LAUNCH-CHECKLIST.md` |
| 变更审计记录 | `docs/audit/changes/` |
| PRD / Spec 拆分 | `docs/prd/specs/` |
| 小程序 UI 原型 | `docs/h5_ui/pages/` |
| 迁移脚本 | `db/etl_feiqiu/migrations/` + `db/zqyy_app/migrations/` |
| DDL 基线 | `docs/database/ddl/` |
| 模块 README | 各 `apps/*/README.md` + `packages/shared/README.md` + `db/README.md` |
| Kiro Spec | `.kiro/specs/<spec-name>/` (requirements + design + tasks) |
## 行为规范提示
- 新增 DB 表/字段 → 必须写 `BD_Manual_*.md`(见 `db-docs.md` steering
- 新增输出路径 → 先加 `.env` 变量,再更新 `EXPORT-PATHS.md`(见 `export-paths.md` steering
- 逻辑改动 → 检查是否命中审计条件(见 `governance.md` steering
- 新增/修改 API → 同步更新 `API-REFERENCE.md`
- 新增 ETL 任务 → 同步更新 `docs/etl_tasks/` 对应文档