feat: 完成P13治理收尾并创建P14执行合同(Issue: P13-1,P13-2,P13-3,P13-4,P13-5,P13-6,P13-7,P13-8,P14-0)

这个提交包含在:
2026-02-13 00:25:04 +08:00
父节点 3b221a2202
当前提交 1412fead5d
共修改 30 个文件,包含 3988 行新增和 105 行删除
+11 -1
查看文件
@@ -35,6 +35,7 @@ jobs:
OCR_PROVIDER: mock OCR_PROVIDER: mock
OCR_MAX_ATTEMPTS: 3 OCR_MAX_ATTEMPTS: 3
DELEGATION_REVOKE_CRON: "0 * * * *" DELEGATION_REVOKE_CRON: "0 * * * *"
REGRESSION_REPORT_DIR: /tmp/hyb-oa-regression
steps: steps:
- name: Checkout - name: Checkout
@@ -79,7 +80,16 @@ jobs:
- name: Run full regression suite - name: Run full regression suite
working-directory: server working-directory: server
run: | run: |
RUN_AUDIT_REGRESSION=1 RUN_OCR_REGRESSION=1 ./scripts/regression-all.sh mkdir -p "$REGRESSION_REPORT_DIR"
RUN_AUDIT_REGRESSION=1 RUN_OCR_REGRESSION=1 ./scripts/regression-all.sh --json-report "$REGRESSION_REPORT_DIR/report.json"
- name: Upload regression report artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: regression-report-${{ github.run_id }}-${{ github.sha }}
path: /tmp/hyb-oa-regression/report.json
if-no-files-found: warn
- name: Stop server - name: Stop server
if: always() if: always()
+4
查看文件
@@ -17,3 +17,7 @@ deploy/trialrun/backups
# Playwright artifacts # Playwright artifacts
client/test-results client/test-results
client/playwright-report client/playwright-report
# Vitest coverage artifacts
server/coverage
server/.vitest
+47
查看文件
@@ -0,0 +1,47 @@
groups:
- name: hyb-oa-slo
rules:
- alert: HybOAApiAvailabilityLow
expr: |
(
sum(rate(http_server_requests_total{route=~"/api/.*",status!~"5.."}[5m]))
/
clamp_min(sum(rate(http_server_requests_total{route=~"/api/.*"}[5m])), 0.001)
) < 0.999
for: 10m
labels:
severity: warning
slo: availability
annotations:
summary: "hyb-oa API availability below SLO"
description: "API availability fell below 99.9% for 10m. Check recent releases and feature-flag changes."
- alert: HybOADashboardP99High
expr: |
histogram_quantile(
0.99,
sum by (le, route) (
rate(http_server_request_duration_seconds_bucket{route=~"/api/dashboard/.*"}[5m])
)
) > 0.5
for: 15m
labels:
severity: warning
slo: latency
annotations:
summary: "hyb-oa dashboard P99 latency above SLO"
description: "Dashboard API P99 exceeded 500ms for 15m. Investigate DB load and aggregation queries."
- alert: HybOAApi5xxSpike
expr: |
sum(rate(http_server_requests_total{route=~"/api/.*",status=~"5.."}[5m]))
/
clamp_min(sum(rate(http_server_requests_total{route=~"/api/.*"}[5m])), 0.001)
> 0.01
for: 5m
labels:
severity: critical
slo: availability
annotations:
summary: "hyb-oa API 5xx ratio spike"
description: "API 5xx ratio is above 1% for 5m. Consider force_off for high-risk feature flags."
+14 -4
查看文件
@@ -1,8 +1,8 @@
# hyb-oa 实现状态矩阵(P5-P12) # hyb-oa 实现状态矩阵(P5-P13)
**快照日期**:2026-02-11 **快照日期**:2026-02-12
**范围**:`F1-F42`(含 `F19b/F19c/F19d`)+ `R1-S2` + `P9A1-P9D1` + `P10E1-P10G1` + `G0-G6` + `H1` + `M1-M4`,共 92 项。 **范围**:`F1-F42`(含 `F19b/F19c/F19d`)+ `R1-S2` + `P9A1-P9D1` + `P10E1-P10G1` + `G0-G6` + `H1` + `M1-M4` + `P13-0-P13-8`,共 101 项。
**事实源**:`issues/2026-02-09_13-39-02-p5-blueprint-v3.csv` + `issues/2026-02-09_23-30-00-p6-p8-remaining-work-master.csv` + `issues/2026-02-10_09-42-31-p9-maintenance-performance-matrix.csv` + `issues/2026-02-10_10-35-30-p10-production-hardening.csv` + `issues/2026-02-11-p11-p12-governance-mobile.csv`(执行合同) **事实源**:`issues/2026-02-09_13-39-02-p5-blueprint-v3.csv` + `issues/2026-02-09_23-30-00-p6-p8-remaining-work-master.csv` + `issues/2026-02-10_09-42-31-p9-maintenance-performance-matrix.csv` + `issues/2026-02-10_10-35-30-p10-production-hardening.csv` + `issues/2026-02-11-p11-p12-governance-mobile.csv` + `issues/2026-02-12_01-09-41-p13-l5-governance.csv`(执行合同)
**说明**: **说明**:
- 本矩阵用于全局读图(审计/交接/回归覆盖追踪),不替代 `issues/*.csv` 的执行合同职责。 - 本矩阵用于全局读图(审计/交接/回归覆盖追踪),不替代 `issues/*.csv` 的执行合同职责。
@@ -105,3 +105,13 @@
| M2 | 移动拍照与离线上传队列 | §16(移动端治理) | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11 | DONE | manual + client:typecheck/build | iOS前台恢复重放 | | M2 | 移动拍照与离线上传队列 | §16(移动端治理) | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11 | DONE | manual + client:typecheck/build | iOS前台恢复重放 |
| M3 | 移动审批快捷处理 | §16(移动端治理) | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11 | DONE | manual + client:typecheck/build | none | | M3 | 移动审批快捷处理 | §16(移动端治理) | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11 | DONE | manual + client:typecheck/build | none |
| M4 | P12验收与收口 | §16(状态治理) | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11 | DONE | docs/mobile/pwa-regression-checklist.md | none | | M4 | P12验收与收口 | §16(状态治理) | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11-p11-p12-governance-mobile.csv | 2026-02-11 | DONE | docs/mobile/pwa-regression-checklist.md | none |
| P13-0 | P13执行合同建档 | §16(执行合同治理) | 2026-02-12_01-09-41-p13-l5-governance.csv | 2026-02-12_01-09-41-p13-l5-governance.csv | 2026-02-12 | DONE | manual | none |
| P13-1 | 后端单元测试框架引入 | §16(测试工程化) | 2026-02-12_01-09-41-p13-l5-governance.csv | 2026-02-12_01-09-41-p13-l5-governance.csv | 2026-02-12 | DONE | cd server && npm test | Vitest底座落地 |
| P13-2 | 核心服务单元测试补齐 | §16(测试工程化) | 2026-02-12_01-09-41-p13-l5-governance.csv | 2026-02-12_01-09-41-p13-l5-governance.csv | 2026-02-12 | DONE | cd server && npm run test:coverage | 覆盖 featureFlags/verification/integrations dispatcher |
| P13-3 | OpenAPI自动文档接入 | §16(治理可观测性) | 2026-02-12_01-09-41-p13-l5-governance.csv | 2026-02-12_01-09-41-p13-l5-governance.csv | 2026-02-12 | DONE | /api/docs + /api/docs/json(非生产) | 双闸条件:非生产+开关开启 |
| P13-4 | SLI-SLO与错误预算落地 | §16(非功能治理) | 2026-02-12_01-09-41-p13-l5-governance.csv | 2026-02-12_01-09-41-p13-l5-governance.csv | 2026-02-12 | DONE | docs/ops/slo.md + deploy/prometheus/alerts-slo.yml | none |
| P13-5 | 回归脚本结构化报告输出 | §16(回归工程化) | 2026-02-12_01-09-41-p13-l5-governance.csv | 2026-02-12_01-09-41-p13-l5-governance.csv | 2026-02-12 | DONE | server/scripts/regression-all.sh --json-report | 报告 schema_version=1.0 |
| P13-6 | CI回归报告归档 | §16(CI治理) | 2026-02-12_01-09-41-p13-l5-governance.csv | 2026-02-12_01-09-41-p13-l5-governance.csv | 2026-02-12 | DONE | .github/workflows/ci-regression.yml artifact | run_id+sha 可追溯 |
| P13-7 | 变更失败率统计脚本 | §16(DORA 指标治理) | 2026-02-12_01-09-41-p13-l5-governance.csv | 2026-02-12_01-09-41-p13-l5-governance.csv | 2026-02-12 | DONE | python3 scripts/cfr_report.py --window 7d,30d | failed_commit=回归失败或24h热修 |
| P13-8 | P13验收与矩阵收口 | §16(状态治理) | 2026-02-12_01-09-41-p13-l5-governance.csv | 2026-02-12_01-09-41-p13-l5-governance.csv | 2026-02-12 | DONE | check-minimal + regression-all + validate-feature-matrix | none |
+38
查看文件
@@ -0,0 +1,38 @@
# CI 回归报告归档说明(P13-6)
更新时间:2026-02-12
## 目标
在 `ci-regression` 流水线中产出并归档结构化回归报告,确保每次回归可追溯到具体 `run_id` 与 `commit SHA`。
## 产物
- 回归脚本输出:`/tmp/hyb-oa-regression/report.json`
- Artifact 名称:`regression-report-<run_id>-<sha>`
- JSON 契约:`schema_version = "1.0"`
## 字段约定
报告包含以下关键字段:
- `schema_version`
- `generated_at`
- `git.sha`
- `git.branch`
- `overall.status | duration_ms | passed | failed | skipped | total`
- `steps[]`:`id | name | enabled | status | exit_code | duration_ms | started_at | ended_at | command`
## 回归命令
在 CI 中使用:
```bash
RUN_AUDIT_REGRESSION=1 RUN_OCR_REGRESSION=1 ./scripts/regression-all.sh --json-report /tmp/hyb-oa-regression/report.json
```
## 排障建议
- 回归失败时优先下载 artifact,检查 `overall.failed` 与 `steps[].status="failed"`。
- 若报告未生成,检查回归步骤是否在脚本启动前失败(例如服务未就绪)。
- 若报告字段缺失,优先比对 `server/scripts/lib/regression-report.sh` 与 schema 版本。
+49
查看文件
@@ -0,0 +1,49 @@
# DORA 指标(CFR)落地说明(P13-7)
更新时间:2026-02-12
## 指标口径
- `run_cfr = failed_runs / total_runs`
- `commit_cfr = failed_commits / total_commits`
- `failed_commit` 判定:
1. 对应回归 run 失败;或
2. 该 commit 在回归时间后 24 小时内出现热修记录。
## 数据输入
- 回归 JSON:来自 `server/scripts/regression-all.sh --json-report`
- 可选热修 CSV:`commit_sha,occurred_at`
## 生成命令
```bash
python3 scripts/cfr_report.py \
--input-dir artifacts/regression \
--window 7d,30d \
--output-json /tmp/cfr.json \
--output-md /tmp/cfr.md
```
带热修记录:
```bash
python3 scripts/cfr_report.py \
--input-dir artifacts/regression \
--window 30d \
--hotfix-csv data/hotfix-events.csv \
--output-json /tmp/cfr.json
```
## 输出结构(JSON)
- `generated_at`
- `input_dir`
- `reports_scanned`
- `hotfix_events`
- `windows.<Nd>.{total_runs,failed_runs,run_cfr,run_cfr_pct,total_commits,failed_commits,commit_cfr,commit_cfr_pct}`
## 使用建议
- 周报使用 `7d`,月报使用 `30d`。
- 当 `commit_cfr_pct` 持续高于阈值(例如 15%)时,暂停高风险灰度并补回归样本。
+10 -2
查看文件
@@ -1,6 +1,6 @@
# 非功能基线(P10.5) # 非功能基线(P10.5/P13)
更新时间:2026-02-11 更新时间:2026-02-12
## 安全基线 ## 安全基线
- 使用 `server/scripts/security-baseline.sh` 检查关键安全配置与核心路由语法。 - 使用 `server/scripts/security-baseline.sh` 检查关键安全配置与核心路由语法。
@@ -20,6 +20,14 @@
- 平均响应(avg)< 500ms - 平均响应(avg)< 500ms
- 最大响应(max)< 1500ms - 最大响应(max)< 1500ms
## SLI/SLO 与错误预算(P13)
- 详细定义见:`docs/ops/slo.md`
- 首版目标:
- 可用性(30天)>= 99.9%
- 核心 Dashboard API P99 < 500ms
- Feature Flag 更新成功率 >= 99.5%
- 告警规则文件:`deploy/prometheus/alerts-slo.yml`
## 回滚原则 ## 回滚原则
- 若非功能指标异常,先执行配置回滚而非代码回滚: - 若非功能指标异常,先执行配置回滚而非代码回滚:
1. 将 `erp_sync` 和 `invoice_verification` 设为 `force_off`。 1. 将 `erp_sync` 和 `invoice_verification` 设为 `force_off`。
+101
查看文件
@@ -0,0 +1,101 @@
# SLI / SLO 与错误预算(P13)
更新时间:2026-02-12
## 1. 范围与目标
本版聚焦 P11/P13 治理链路(Feature Flags、ERP 同步、验真开关)和核心可观测接口,建立可执行的 SLI/SLO 与错误预算基线。
- 环境范围:生产环境(`NODE_ENV=production`)
- 统计窗口:滚动 30 天
- 目标用途:发布评审、变更放行、故障复盘
## 2. SLI 定义
### SLI-1:API 可用性(Availability)
- 指标定义:
- 分子:`http_server_requests_total{status!~"5.."}`
- 分母:`http_server_requests_total`
- 统计口径:按所有 `/api/*` 请求聚合
- 采样周期:5 分钟
### SLI-2:核心接口延迟(Latency P99)
- 指标定义:`histogram_quantile(0.99, rate(http_server_request_duration_seconds_bucket[5m]))`
- 核心接口:
- `/api/dashboard/expense-overview`
- `/api/dashboard/trends`
- `/api/dashboard/top-categories`
- `/api/dashboard/approval-efficiency`
### SLI-3:治理开关变更成功率(Feature Flag Update Success)
- 指标定义:
- 分子:`/api/system/feature-flags/:key` 返回 2xx 的次数
- 分母:`/api/system/feature-flags/:key` 总调用次数
- 目标:验证治理入口稳定性,避免灰度期管理面不稳定
## 3. SLO 目标
### SLO-A(可用性)
- 目标:30 天可用性 >= 99.90%
- 对应最大不可用预算:0.10%
- 换算:
- 每 30 天预算约 43 分钟
- 每 7 天预算约 10 分钟
### SLO-B(延迟)
- 目标:核心 Dashboard 接口 P99 < 500ms
- 兜底上限:P99 不得持续超过 1500ms 超过 15 分钟
### SLO-C(治理变更稳定性)
- 目标:Feature Flag 更新成功率 >= 99.5%
- 说明:若低于目标,暂停高风险灰度变更(ERP/验真)
## 4. 错误预算策略
### 预算分级
- 正常区:剩余预算 > 50%
- 允许常规迭代与灰度
- 预警区:剩余预算 20% ~ 50%
- 限制非必要发布频率,增加回归样本
- 保护区:剩余预算 < 20%
- 冻结非紧急变更,仅允许故障修复
### 预算消耗触发动作
- 可用性预算触发:
1. 立即复核最近 24h 发布与配置变更
2. 执行 `erp_sync` 与 `invoice_verification` 的 `force_off` 预案
- 延迟预算触发:
1. 优先检查 Dashboard 聚合查询与数据库负载
2. 降级非关键统计刷新频率
## 5. 监控与告警接入
- Prometheus 告警规则:`deploy/prometheus/alerts-slo.yml`
- 非功能脚本:
- `server/scripts/benchmark-dashboard.sh`
- `server/scripts/security-baseline.sh`
- 发布前最小检查:
- `./scripts/check-minimal.sh`
- `cd server && ./scripts/regression-feature-flags.sh`
## 6. 回滚与变更约束
- 一级回滚(业务可用优先):
- 将 `erp_sync`、`invoice_verification` 置 `force_off`
- 二级回滚(系统保护):
- env 硬闸:`ERP_SYNC_ENABLED=false`、`INVOICE_VERIFICATION_ENABLED=false`
- 说明:保留审计与历史开关记录,不做 destructive 数据回滚
## 7. 后续演进(P13-5+)
- 将 `regression-all.sh` 结构化结果纳入 SLO 评审输入
- 形成 Change Failure Rate 周报/月报(DORA 指标)
- 将 SLO 预算状态纳入发布门禁
@@ -0,0 +1,10 @@
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
P13-0,P13执行合同建档,创建并冻结P13治理增强Issue合同及依赖关系,CSV通过校验且字段规范完整,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-02-12_01-09-41-p13-l5-governance.csv,manual,DONE,DONE,DONE,issues/2026-02-12_01-09-41-p13-l5-governance.csv,[],none
P13-1,后端单元测试框架引入,在server引入可执行单测框架与基础脚手架并设置npm test可运行,`cd server && npm test`可执行且至少包含1个示例测试通过,cd server && npm test,manual,DONE,DONE,DONE,server/package.json | server/vitest.config.* | server/tests/**,"[""P13-0""]",新增依赖: vitest@^2 + @vitest/coverage-v8@^2;回滚: 移除test脚本与依赖;已验收: npm test/test:coverage
P13-2,核心服务单元测试补齐,为高风险治理链路补单测: featureFlags/verification/integration dispatcher,核心链路覆盖并包含开关阻断/灰度/重试异常等关键分支,cd server && npm test -- --coverage,manual,DONE,DONE,DONE,server/src/services/featureFlags/index.js | server/src/services/verification/index.js | server/src/services/integrations/dispatcher.js | server/tests/unit/**,"[""P13-1""]",已补齐featureFlags/verification/dispatcher核心分支单测;验收通过: cd server && npm test && npm run test:coverage;回滚: 删除新增单测文件并恢复Issue状态
P13-3,OpenAPI自动文档接入,接入Fastify Swagger并产出可访问或可导出的OpenAPI文档,非生产且OPENAPI_DOCS_ENABLED=true时,/api/docs 与 /api/docs/json 可访问,NODE_ENV=development OPENAPI_DOCS_ENABLED=true JOBS_ENABLED=false cd server && npm run start & sleep 3 && curl -sf http://127.0.0.1:3000/api/docs >/dev/null && curl -sf http://127.0.0.1:3000/api/docs/json | jq '.openapi' >/dev/null,manual,DONE,DONE,DONE,server/src/app.js | server/src/routes/featureFlags.js | server/src/routes/integrations.js | server/src/routes/invoicePool/verification.js | server/src/plugins/swagger.js | server/src/utils/openapi.js | server/src/config.js | server/.env.example | server/package.json,"[""P13-0""]","新增依赖: @fastify/swagger + @fastify/swagger-ui;回滚: OPENAPI_DOCS_ENABLED=false 或移除插件注册;已验收: /api/docs dev可用, prod隐藏"
P13-4,SLI-SLO与错误预算落地,将现有非功能基线文档升级为可执行SLO并提供告警规则草案,形成明确SLI/SLO/错误预算与Prometheus规则文件并可检查,test -f docs/ops/slo.md && test -f deploy/prometheus/alerts-slo.yml,manual,DONE,DONE,DONE,docs/ops/non-functional-baseline.md | docs/ops/slo.md | deploy/prometheus/alerts-slo.yml,"[""P13-0""]",已新增SLO文档与Prometheus告警规则;验收通过: test -f docs/ops/slo.md && test -f deploy/prometheus/alerts-slo.yml;回滚: 删除新增文档与规则并恢复Issue状态
P13-5,回归脚本结构化报告输出,为regression-all增加JSON报告能力并标准化结果字段,可输出commit/时间戳/脚本项结果并可被CI消费,cd server && ./scripts/regression-all.sh --json-report /tmp/regression-report.json && jq . /tmp/regression-report.json >/dev/null,manual,DONE,DONE,DONE,server/scripts/regression-all.sh | server/scripts/lib/regression-report.sh,"[""P13-0""]",已支持--json-report并输出schema_version=1.0;支持enabled/failed/skipped结构化步骤;回滚: 不使用--json-report保持旧行为
P13-6,CI回归报告归档,在GitHub Actions中上传回归JSON报告并关联commit SHA,CI产物可下载且报告包含失败项定位信息,gh run view --log || manual: 在Actions页面确认artifact存在,manual,DONE,DONE,DONE,.github/workflows/ci-regression.yml | docs/ops/ci-regression-reporting.md,"[""P13-5""]",ci-regression已输出/tmp/hyb-oa-regression/report.json并通过actions/upload-artifact归档;回滚: 移除artifact步骤
P13-7,变更失败率统计脚本,基于回归JSON生成按时间窗口统计的Change Failure Rate报表,生成周/月维度失败率报表并可复算,"python3 scripts/cfr_report.py --input-dir artifacts/regression --window 7d,30d --output-json /tmp/cfr.json && jq -e '.windows[""30d""].run_cfr_pct >= 0' /tmp/cfr.json >/dev/null",manual,DONE,DONE,DONE,scripts/cfr_report.py | docs/ops/dora-metrics.md | docs/feature-matrix.md,"[""P13-6""]",新增CFR统计脚本,支持7d/30d窗口与可选hotfix-csv;failed_commit=回归失败或24h热修
P13-8,P13验收与矩阵收口,执行P13相关校验并同步feature matrix与Issue状态,check-minimal+关键回归通过且矩阵一致性通过,./scripts/check-minimal.sh && cd server && RUN_AUDIT_REGRESSION=1 RUN_OCR_REGRESSION=1 RUN_FEATURE_FLAGS_REGRESSION=1 ./scripts/regression-all.sh --json-report /tmp/regression-report.json && cd /home/shunli/Projects/hyb-oa && python3 scripts/validate-feature-matrix.py,manual,DONE,DONE,DONE,docs/feature-matrix.md | scripts/validate-feature-matrix.py | issues/2026-02-12_01-09-41-p13-l5-governance.csv,"[""P13-2"",""P13-3"",""P13-4"",""P13-7""]",已纳入P13事实源并补齐P13-0~P13-8矩阵行;完成后统一三状态DONE
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 P13-0 P13执行合同建档 创建并冻结P13治理增强Issue合同及依赖关系 CSV通过校验且字段规范完整 python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-02-12_01-09-41-p13-l5-governance.csv manual DONE DONE DONE issues/2026-02-12_01-09-41-p13-l5-governance.csv [] none
3 P13-1 后端单元测试框架引入 在server引入可执行单测框架与基础脚手架并设置npm test可运行 `cd server && npm test`可执行且至少包含1个示例测试通过 cd server && npm test manual DONE DONE DONE server/package.json | server/vitest.config.* | server/tests/** ["P13-0"] 新增依赖: vitest@^2 + @vitest/coverage-v8@^2;回滚: 移除test脚本与依赖;已验收: npm test/test:coverage
4 P13-2 核心服务单元测试补齐 为高风险治理链路补单测: featureFlags/verification/integration dispatcher 核心链路覆盖并包含开关阻断/灰度/重试异常等关键分支 cd server && npm test -- --coverage manual DONE DONE DONE server/src/services/featureFlags/index.js | server/src/services/verification/index.js | server/src/services/integrations/dispatcher.js | server/tests/unit/** ["P13-1"] 已补齐featureFlags/verification/dispatcher核心分支单测;验收通过: cd server && npm test && npm run test:coverage;回滚: 删除新增单测文件并恢复Issue状态
5 P13-3 OpenAPI自动文档接入 接入Fastify Swagger并产出可访问或可导出的OpenAPI文档 非生产且OPENAPI_DOCS_ENABLED=true时,/api/docs 与 /api/docs/json 可访问 NODE_ENV=development OPENAPI_DOCS_ENABLED=true JOBS_ENABLED=false cd server && npm run start & sleep 3 && curl -sf http://127.0.0.1:3000/api/docs >/dev/null && curl -sf http://127.0.0.1:3000/api/docs/json | jq '.openapi' >/dev/null manual DONE DONE DONE server/src/app.js | server/src/routes/featureFlags.js | server/src/routes/integrations.js | server/src/routes/invoicePool/verification.js | server/src/plugins/swagger.js | server/src/utils/openapi.js | server/src/config.js | server/.env.example | server/package.json ["P13-0"] 新增依赖: @fastify/swagger + @fastify/swagger-ui;回滚: OPENAPI_DOCS_ENABLED=false 或移除插件注册;已验收: /api/docs dev可用, prod隐藏
6 P13-4 SLI-SLO与错误预算落地 将现有非功能基线文档升级为可执行SLO并提供告警规则草案 形成明确SLI/SLO/错误预算与Prometheus规则文件并可检查 test -f docs/ops/slo.md && test -f deploy/prometheus/alerts-slo.yml manual DONE DONE DONE docs/ops/non-functional-baseline.md | docs/ops/slo.md | deploy/prometheus/alerts-slo.yml ["P13-0"] 已新增SLO文档与Prometheus告警规则;验收通过: test -f docs/ops/slo.md && test -f deploy/prometheus/alerts-slo.yml;回滚: 删除新增文档与规则并恢复Issue状态
7 P13-5 回归脚本结构化报告输出 为regression-all增加JSON报告能力并标准化结果字段 可输出commit/时间戳/脚本项结果并可被CI消费 cd server && ./scripts/regression-all.sh --json-report /tmp/regression-report.json && jq . /tmp/regression-report.json >/dev/null manual DONE DONE DONE server/scripts/regression-all.sh | server/scripts/lib/regression-report.sh ["P13-0"] 已支持--json-report并输出schema_version=1.0;支持enabled/failed/skipped结构化步骤;回滚: 不使用--json-report保持旧行为
8 P13-6 CI回归报告归档 在GitHub Actions中上传回归JSON报告并关联commit SHA CI产物可下载且报告包含失败项定位信息 gh run view --log || manual: 在Actions页面确认artifact存在 manual DONE DONE DONE .github/workflows/ci-regression.yml | docs/ops/ci-regression-reporting.md ["P13-5"] ci-regression已输出/tmp/hyb-oa-regression/report.json并通过actions/upload-artifact归档;回滚: 移除artifact步骤
9 P13-7 变更失败率统计脚本 基于回归JSON生成按时间窗口统计的Change Failure Rate报表 生成周/月维度失败率报表并可复算 python3 scripts/cfr_report.py --input-dir artifacts/regression --window 7d,30d --output-json /tmp/cfr.json && jq -e '.windows["30d"].run_cfr_pct >= 0' /tmp/cfr.json >/dev/null manual DONE DONE DONE scripts/cfr_report.py | docs/ops/dora-metrics.md | docs/feature-matrix.md ["P13-6"] 新增CFR统计脚本,支持7d/30d窗口与可选hotfix-csv;failed_commit=回归失败或24h热修
10 P13-8 P13验收与矩阵收口 执行P13相关校验并同步feature matrix与Issue状态 check-minimal+关键回归通过且矩阵一致性通过 ./scripts/check-minimal.sh && cd server && RUN_AUDIT_REGRESSION=1 RUN_OCR_REGRESSION=1 RUN_FEATURE_FLAGS_REGRESSION=1 ./scripts/regression-all.sh --json-report /tmp/regression-report.json && cd /home/shunli/Projects/hyb-oa && python3 scripts/validate-feature-matrix.py manual DONE DONE DONE docs/feature-matrix.md | scripts/validate-feature-matrix.py | issues/2026-02-12_01-09-41-p13-l5-governance.csv ["P13-2","P13-3","P13-4","P13-7"] 已纳入P13事实源并补齐P13-0~P13-8矩阵行;完成后统一三状态DONE
@@ -0,0 +1,10 @@
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
P14-0,P14执行合同建档,创建并校验P14执行合同CSV,冻结依赖关系与字段规范,CSV通过校验且字段完整,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-02-13_00-11-56-p14-l5-operationalization.csv,manual,DONE,DONE,DONE,issues/2026-02-13_00-11-56-p14-l5-operationalization.csv,[],none
P14-1,回归报告Schema固化,为regression JSON报告补schema校验器并固化schema_version=1.0,报告缺字段或类型不匹配时校验失败,cd server && ./scripts/regression-all.sh --json-report /tmp/r.json && python3 ../scripts/validate_regression_report.py /tmp/r.json,manual,TODO,TODO,TODO,server/scripts/lib/regression-report.sh | server/scripts/regression-all.sh | scripts/validate_regression_report.py | docs/ops/ci-regression-reporting.md,"[""P14-0""]",schema_version固定1.0;回滚: 仅保留基础report字段
P14-2,CI回归摘要发布,在ci-regression输出Job Summary并保留artifact定位失败步骤,Actions Summary可直接查看失败步骤与报告下载链接,manual: 触发ci-regression并在Actions页面核验summary与artifact,manual,TODO,TODO,TODO,.github/workflows/ci-regression.yml | docs/ops/ci-regression-reporting.md,"[""P14-1""]",不引入外部SaaS
P14-3,CFR自动周/月报,将CFR计算纳入自动化并产出7d/30d报表,可稳定产出JSON与Markdown双格式报表并可复算,"python3 scripts/cfr_report.py --input-dir artifacts/regression --window 7d,30d --output-json /tmp/cfr.json --output-md /tmp/cfr.md",manual,TODO,TODO,TODO,scripts/cfr_report.py | docs/ops/dora-metrics.md | .github/workflows/ci-regression.yml,"[""P14-2""]",热修通过--hotfix-csv合并口径
P14-4,高风险服务单测扩容,补齐featureFlags/verification/integrations/outbox/workflow util关键分支单测,新增至少20个测试并提升关键模块覆盖率,cd server && npm test && npm run test:coverage,manual,TODO,TODO,TODO,server/tests/unit/**/*.test.js | server/src/services/featureFlags/index.js | server/src/services/verification/index.js | server/src/services/integrations/dispatcher.js | server/src/services/integrations/outbox.js | server/src/services/integrations/erp.js | server/src/utils/workflow.js,"[""P14-0""]",仅纯单测,不连DB
P14-5,覆盖率门禁渐进,建立coverage gate策略:PR告警、main阻断,main分支低于阈值失败;PR仅提示不阻断,cd server && npm run test:coverage && node ../scripts/coverage_gate.js --mode=ci,manual,TODO,TODO,TODO,server/vitest.config.cjs | scripts/coverage_gate.js | .github/workflows/ci-minimal.yml | docs/ops/testing-strategy.md,"[""P14-4""]",初始阈值仅关键模块lines>=70%,全局先告警
P14-6,OpenAPI覆盖扩展与漂移检查,扩展高频路由OpenAPI schema并增加导出与对比检查,/api/docs/json包含新增路径且导出结果可做diff,NODE_ENV=development OPENAPI_DOCS_ENABLED=true cd server && node scripts/export_openapi.js --out /tmp/openapi.json && jq '.paths|keys' /tmp/openapi.json,manual,TODO,TODO,TODO,server/src/routes/reimbursements.js | server/src/routes/workflow.js | server/src/routes/invoicePool/read.js | server/scripts/export_openapi.js | scripts/openapi_diff_check.py,"[""P14-0""]",不改业务语义,仅补schema
P14-7,分布式追踪最小落地,引入OTel trace基础链路与环境硬闸,默认关闭,开关开启可导出trace,关闭无行为变化,cd server && node --check src/observability/tracing.js && manual: 验证traceparent透传,manual,TODO,TODO,TODO,server/src/observability/tracing.js | server/src/app.js | server/src/index.js | server/src/config.js | server/.env.example | docs/ops/tracing.md,"[""P14-0""]",默认OTEL_TRACES_ENABLED=false,fail-safe
P14-8,P14验收与矩阵收口,执行P14全量校验并同步feature-matrix与执行合同状态,check-minimal+regression-all+matrix校验全部通过,./scripts/check-minimal.sh && cd server && ./scripts/regression-all.sh --json-report /tmp/regression-report.json && cd /home/shunli/Projects/hyb-oa && python3 scripts/validate-feature-matrix.py,manual,TODO,TODO,TODO,docs/feature-matrix.md | scripts/validate-feature-matrix.py | issues/*.csv,"[""P14-3"",""P14-5"",""P14-6"",""P14-7""]",三状态全DONE后关闭
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 P14-0 P14执行合同建档 创建并校验P14执行合同CSV,冻结依赖关系与字段规范 CSV通过校验且字段完整 python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-02-13_00-11-56-p14-l5-operationalization.csv manual DONE DONE DONE issues/2026-02-13_00-11-56-p14-l5-operationalization.csv [] none
3 P14-1 回归报告Schema固化 为regression JSON报告补schema校验器并固化schema_version=1.0 报告缺字段或类型不匹配时校验失败 cd server && ./scripts/regression-all.sh --json-report /tmp/r.json && python3 ../scripts/validate_regression_report.py /tmp/r.json manual TODO TODO TODO server/scripts/lib/regression-report.sh | server/scripts/regression-all.sh | scripts/validate_regression_report.py | docs/ops/ci-regression-reporting.md ["P14-0"] schema_version固定1.0;回滚: 仅保留基础report字段
4 P14-2 CI回归摘要发布 在ci-regression输出Job Summary并保留artifact定位失败步骤 Actions Summary可直接查看失败步骤与报告下载链接 manual: 触发ci-regression并在Actions页面核验summary与artifact manual TODO TODO TODO .github/workflows/ci-regression.yml | docs/ops/ci-regression-reporting.md ["P14-1"] 不引入外部SaaS
5 P14-3 CFR自动周/月报 将CFR计算纳入自动化并产出7d/30d报表 可稳定产出JSON与Markdown双格式报表并可复算 python3 scripts/cfr_report.py --input-dir artifacts/regression --window 7d,30d --output-json /tmp/cfr.json --output-md /tmp/cfr.md manual TODO TODO TODO scripts/cfr_report.py | docs/ops/dora-metrics.md | .github/workflows/ci-regression.yml ["P14-2"] 热修通过--hotfix-csv合并口径
6 P14-4 高风险服务单测扩容 补齐featureFlags/verification/integrations/outbox/workflow util关键分支单测 新增至少20个测试并提升关键模块覆盖率 cd server && npm test && npm run test:coverage manual TODO TODO TODO server/tests/unit/**/*.test.js | server/src/services/featureFlags/index.js | server/src/services/verification/index.js | server/src/services/integrations/dispatcher.js | server/src/services/integrations/outbox.js | server/src/services/integrations/erp.js | server/src/utils/workflow.js ["P14-0"] 仅纯单测,不连DB
7 P14-5 覆盖率门禁渐进 建立coverage gate策略:PR告警、main阻断 main分支低于阈值失败;PR仅提示不阻断 cd server && npm run test:coverage && node ../scripts/coverage_gate.js --mode=ci manual TODO TODO TODO server/vitest.config.cjs | scripts/coverage_gate.js | .github/workflows/ci-minimal.yml | docs/ops/testing-strategy.md ["P14-4"] 初始阈值仅关键模块lines>=70%,全局先告警
8 P14-6 OpenAPI覆盖扩展与漂移检查 扩展高频路由OpenAPI schema并增加导出与对比检查 /api/docs/json包含新增路径且导出结果可做diff NODE_ENV=development OPENAPI_DOCS_ENABLED=true cd server && node scripts/export_openapi.js --out /tmp/openapi.json && jq '.paths|keys' /tmp/openapi.json manual TODO TODO TODO server/src/routes/reimbursements.js | server/src/routes/workflow.js | server/src/routes/invoicePool/read.js | server/scripts/export_openapi.js | scripts/openapi_diff_check.py ["P14-0"] 不改业务语义,仅补schema
9 P14-7 分布式追踪最小落地 引入OTel trace基础链路与环境硬闸,默认关闭 开关开启可导出trace,关闭无行为变化 cd server && node --check src/observability/tracing.js && manual: 验证traceparent透传 manual TODO TODO TODO server/src/observability/tracing.js | server/src/app.js | server/src/index.js | server/src/config.js | server/.env.example | docs/ops/tracing.md ["P14-0"] 默认OTEL_TRACES_ENABLED=false,fail-safe
10 P14-8 P14验收与矩阵收口 执行P14全量校验并同步feature-matrix与执行合同状态 check-minimal+regression-all+matrix校验全部通过 ./scripts/check-minimal.sh && cd server && ./scripts/regression-all.sh --json-report /tmp/regression-report.json && cd /home/shunli/Projects/hyb-oa && python3 scripts/validate-feature-matrix.py manual TODO TODO TODO docs/feature-matrix.md | scripts/validate-feature-matrix.py | issues/*.csv ["P14-3","P14-5","P14-6","P14-7"] 三状态全DONE后关闭
可执行文件
+275
查看文件
@@ -0,0 +1,275 @@
#!/usr/bin/env python3
"""Generate Change Failure Rate reports from regression JSON artifacts."""
from __future__ import annotations
import argparse
import csv
import json
import re
import sys
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Iterable
WINDOW_PATTERN = re.compile(r"^(\d+)d$")
@dataclass
class RegressionRun:
timestamp: datetime
commit_sha: str
failed: bool
report_file: Path
@dataclass
class HotfixEvent:
commit_sha: str
occurred_at: datetime
def parse_iso_datetime(value: str) -> datetime:
normalized = value.strip()
if normalized.endswith("Z"):
normalized = normalized[:-1] + "+00:00"
parsed = datetime.fromisoformat(normalized)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def parse_windows(value: str) -> list[int]:
windows: list[int] = []
for chunk in value.split(","):
item = chunk.strip()
if not item:
continue
matched = WINDOW_PATTERN.match(item)
if not matched:
raise ValueError(f"invalid window '{item}', expected like 7d/30d/90d")
days = int(matched.group(1))
if days <= 0:
raise ValueError(f"window days must be >0: {item}")
windows.append(days)
if not windows:
windows.append(30)
return sorted(set(windows))
def load_regression_runs(input_dir: Path) -> list[RegressionRun]:
runs: list[RegressionRun] = []
for path in sorted(input_dir.rglob("*.json")):
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except Exception:
continue
schema_version = str(payload.get("schema_version", "")).strip()
if schema_version != "1.0":
continue
generated_at_raw = payload.get("generated_at")
overall = payload.get("overall") if isinstance(payload.get("overall"), dict) else {}
git = payload.get("git") if isinstance(payload.get("git"), dict) else {}
if not generated_at_raw or not isinstance(generated_at_raw, str):
continue
commit_sha = str(git.get("sha", "unknown")).strip() or "unknown"
status = str(overall.get("status", "")).strip().lower()
try:
generated_at = parse_iso_datetime(generated_at_raw)
except Exception:
continue
runs.append(
RegressionRun(
timestamp=generated_at,
commit_sha=commit_sha,
failed=status != "passed",
report_file=path,
)
)
return sorted(runs, key=lambda x: x.timestamp)
def load_hotfix_events(path: Path | None) -> list[HotfixEvent]:
if path is None:
return []
if not path.exists():
raise FileNotFoundError(f"hotfix csv not found: {path}")
events: list[HotfixEvent] = []
with path.open(encoding="utf-8", newline="") as fp:
reader = csv.DictReader(fp)
for row in reader:
commit_sha = (row.get("commit_sha") or "").strip()
occurred_at_raw = (row.get("occurred_at") or "").strip()
if not commit_sha or not occurred_at_raw:
continue
try:
occurred_at = parse_iso_datetime(occurred_at_raw)
except Exception:
continue
events.append(HotfixEvent(commit_sha=commit_sha, occurred_at=occurred_at))
return sorted(events, key=lambda x: x.occurred_at)
def ratio(numerator: int, denominator: int) -> float:
if denominator == 0:
return 0.0
return numerator / denominator
def has_hotfix_hit_within_24h(run_times: Iterable[datetime], events: list[HotfixEvent]) -> bool:
event_times = [event.occurred_at for event in events]
for run_time in run_times:
for event_time in event_times:
seconds = (event_time - run_time).total_seconds()
if 0 <= seconds <= 24 * 3600:
return True
return False
def build_window_metrics(
runs: list[RegressionRun],
hotfix_events: list[HotfixEvent],
now: datetime,
window_days: int,
) -> dict[str, object]:
cutoff = now - timedelta(days=window_days)
window_runs = [run for run in runs if run.timestamp >= cutoff]
failed_runs = [run for run in window_runs if run.failed]
commits: dict[str, list[RegressionRun]] = {}
for run in window_runs:
commits.setdefault(run.commit_sha, []).append(run)
hotfix_by_commit: dict[str, list[HotfixEvent]] = {}
for event in hotfix_events:
if event.occurred_at >= cutoff:
hotfix_by_commit.setdefault(event.commit_sha, []).append(event)
failed_commit_count = 0
for commit_sha, commit_runs in commits.items():
run_failed = any(run.failed for run in commit_runs)
hotfix_hit = has_hotfix_hit_within_24h(
(run.timestamp for run in commit_runs),
hotfix_by_commit.get(commit_sha, []),
)
if run_failed or hotfix_hit:
failed_commit_count += 1
total_runs = len(window_runs)
total_commits = len(commits)
failed_runs_count = len(failed_runs)
run_cfr = ratio(failed_runs_count, total_runs)
commit_cfr = ratio(failed_commit_count, total_commits)
return {
"window": f"{window_days}d",
"cutoff": cutoff.isoformat().replace("+00:00", "Z"),
"total_runs": total_runs,
"failed_runs": failed_runs_count,
"run_cfr": round(run_cfr, 6),
"run_cfr_pct": round(run_cfr * 100, 2),
"total_commits": total_commits,
"failed_commits": failed_commit_count,
"commit_cfr": round(commit_cfr, 6),
"commit_cfr_pct": round(commit_cfr * 100, 2),
}
def render_markdown(result: dict[str, object]) -> str:
lines = [
"# Change Failure Rate 报告",
"",
f"- 生成时间: {result['generated_at']}",
f"- 输入目录: `{result['input_dir']}`",
f"- 报告总数: {result['reports_scanned']}",
"",
"| Window | Total Runs | Failed Runs | Run CFR | Total Commits | Failed Commits | Commit CFR |",
"| --- | ---: | ---: | ---: | ---: | ---: | ---: |",
]
windows: dict[str, dict[str, object]] = result["windows"] # type: ignore[assignment]
for key in sorted(windows.keys(), key=lambda x: int(x[:-1])):
item = windows[key]
lines.append(
f"| {key} | {item['total_runs']} | {item['failed_runs']} | {item['run_cfr_pct']}% | "
f"{item['total_commits']} | {item['failed_commits']} | {item['commit_cfr_pct']}% |"
)
return "\n".join(lines) + "\n"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Generate Change Failure Rate report")
parser.add_argument("--input-dir", required=True, help="Directory that stores regression JSON artifacts")
parser.add_argument("--window", default="30d", help="Comma separated windows, e.g. 7d,30d,90d")
parser.add_argument("--output-json", help="Optional output JSON file path")
parser.add_argument("--output-md", help="Optional output markdown file path")
parser.add_argument("--hotfix-csv", help="Optional hotfix CSV path (columns: commit_sha,occurred_at)")
return parser.parse_args()
def main() -> int:
args = parse_args()
input_dir = Path(args.input_dir).resolve()
if not input_dir.exists():
print(f"[ERR] input dir not found: {input_dir}", file=sys.stderr)
return 1
try:
windows = parse_windows(args.window)
except ValueError as exc:
print(f"[ERR] {exc}", file=sys.stderr)
return 1
try:
hotfix_events = load_hotfix_events(Path(args.hotfix_csv).resolve() if args.hotfix_csv else None)
except Exception as exc:
print(f"[ERR] {exc}", file=sys.stderr)
return 1
runs = load_regression_runs(input_dir)
now = datetime.now(timezone.utc)
result: dict[str, object] = {
"generated_at": now.isoformat().replace("+00:00", "Z"),
"input_dir": str(input_dir),
"reports_scanned": len(runs),
"hotfix_events": len(hotfix_events),
"windows": {},
}
for days in windows:
result["windows"][f"{days}d"] = build_window_metrics(runs, hotfix_events, now, days) # type: ignore[index]
rendered_json = json.dumps(result, ensure_ascii=False, indent=2) + "\n"
if args.output_json:
output_json = Path(args.output_json)
output_json.parent.mkdir(parents=True, exist_ok=True)
output_json.write_text(rendered_json, encoding="utf-8")
if args.output_md:
output_md = Path(args.output_md)
output_md.parent.mkdir(parents=True, exist_ok=True)
output_md.write_text(render_markdown(result), encoding="utf-8")
if not args.output_json and not args.output_md:
print(rendered_json, end="")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+2 -1
查看文件
@@ -24,9 +24,10 @@ CSV_FACT_SOURCES = [
ROOT / "issues" / "2026-02-10_09-42-31-p9-maintenance-performance-matrix.csv", ROOT / "issues" / "2026-02-10_09-42-31-p9-maintenance-performance-matrix.csv",
ROOT / "issues" / "2026-02-10_10-35-30-p10-production-hardening.csv", ROOT / "issues" / "2026-02-10_10-35-30-p10-production-hardening.csv",
ROOT / "issues" / "2026-02-11-p11-p12-governance-mobile.csv", ROOT / "issues" / "2026-02-11-p11-p12-governance-mobile.csv",
ROOT / "issues" / "2026-02-12_01-09-41-p13-l5-governance.csv",
] ]
ID_PATTERN = re.compile(r"^[A-Z][0-9][0-9A-Za-z]*$") ID_PATTERN = re.compile(r"^[A-Z][0-9][0-9A-Za-z-]*$")
STATUS_INDEX = 6 STATUS_INDEX = 6
+2
查看文件
@@ -25,6 +25,8 @@ MAX_UPLOAD_SIZE_BYTES="10485760"
# ===== 单域托管 client/dist(试运行/生产建议)===== # ===== 单域托管 client/dist(试运行/生产建议)=====
# SERVE_CLIENT_DIST="true" # SERVE_CLIENT_DIST="true"
# CLIENT_DIST_DIR="../client/dist" # CLIENT_DIST_DIR="../client/dist"
# OpenAPI 文档端点(建议仅非生产开启)
# OPENAPI_DOCS_ENABLED="false"
# ===== 安全基线(默认:production=true,其它环境=false,可显式覆盖)===== # ===== 安全基线(默认:production=true,其它环境=false,可显式覆盖)=====
# RATE_LIMIT_ENABLED="true" # RATE_LIMIT_ENABLED="true"
+2184 -5
查看文件
文件差异内容过多而无法显示 加载差异
+10 -4
查看文件
@@ -8,7 +8,7 @@
"dev": "node src/index.js", "dev": "node src/index.js",
"start": "node src/index.js", "start": "node src/index.js",
"worker": "node src/worker.js", "worker": "node src/worker.js",
"test": "echo \"Error: no test specified\" && exit 1", "test": "vitest run",
"prisma:format": "prisma format", "prisma:format": "prisma format",
"prisma:validate": "prisma validate", "prisma:validate": "prisma validate",
"prisma:generate": "prisma generate", "prisma:generate": "prisma generate",
@@ -22,7 +22,9 @@
"job:ocr-once": "node src/scripts/run-ocr-once.js", "job:ocr-once": "node src/scripts/run-ocr-once.js",
"job:archive-unused-invoices": "node src/scripts/run-invoice-archive-once.js", "job:archive-unused-invoices": "node src/scripts/run-invoice-archive-once.js",
"job:integration-outbox-once": "node src/scripts/run-integration-outbox-once.js", "job:integration-outbox-once": "node src/scripts/run-integration-outbox-once.js",
"job:verification-once": "node src/scripts/run-verification-once.js" "job:verification-once": "node src/scripts/run-verification-once.js",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
@@ -51,9 +53,13 @@
"prisma": "^7.3.0", "prisma": "^7.3.0",
"tencentcloud-sdk-nodejs": "^4.1.14", "tencentcloud-sdk-nodejs": "^4.1.14",
"zod": "^4.3.6", "zod": "^4.3.6",
"zxcvbn": "^4.4.2" "zxcvbn": "^4.4.2",
"@fastify/swagger": "^9.5.2",
"@fastify/swagger-ui": "^5.2.3"
}, },
"devDependencies": { "devDependencies": {
"dotenv": "^16.4.5" "dotenv": "^16.4.5",
"vitest": "^2.1.8",
"@vitest/coverage-v8": "^2.1.8"
} }
} }
+175
查看文件
@@ -0,0 +1,175 @@
#!/usr/bin/env bash
RR_ENABLED=0
RR_OUTPUT_PATH=""
RR_TMP_DIR=""
RR_STEPS_TSV=""
RR_ROOT_DIR=""
RR_SCHEMA_VERSION="1.0"
rr_now_iso() {
date -u +"%Y-%m-%dT%H:%M:%SZ"
}
rr_now_ms() {
if date +%s%3N >/dev/null 2>&1; then
date +%s%3N
else
echo "$(( $(date +%s) * 1000 ))"
fi
}
rr_sanitize() {
local value="${1//$'\t'/ }"
value="${value//$'\n'/ }"
printf '%s' "$value"
}
rr_prepare() {
local output_path="$1"
local root_dir="$2"
RR_ENABLED=1
RR_OUTPUT_PATH="$output_path"
RR_ROOT_DIR="$root_dir"
RR_TMP_DIR="$(mktemp -d)"
RR_STEPS_TSV="$RR_TMP_DIR/steps.tsv"
: > "$RR_STEPS_TSV"
}
rr_record_step() {
if [[ "$RR_ENABLED" != "1" ]]; then
return 0
fi
local step_id="$1"
local name="$2"
local enabled="$3"
local command="$4"
local status="$5"
local exit_code="$6"
local started_at="$7"
local ended_at="$8"
local duration_ms="$9"
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"$(rr_sanitize "$step_id")" \
"$(rr_sanitize "$name")" \
"$(rr_sanitize "$enabled")" \
"$(rr_sanitize "$command")" \
"$(rr_sanitize "$status")" \
"$(rr_sanitize "$exit_code")" \
"$(rr_sanitize "$started_at")" \
"$(rr_sanitize "$ended_at")" \
"$(rr_sanitize "$duration_ms")" \
>> "$RR_STEPS_TSV"
}
rr_git_sha() {
if command -v git >/dev/null 2>&1 && git -C "$RR_ROOT_DIR" rev-parse --short HEAD >/dev/null 2>&1; then
git -C "$RR_ROOT_DIR" rev-parse --short HEAD
else
echo "unknown"
fi
}
rr_git_branch() {
if command -v git >/dev/null 2>&1 && git -C "$RR_ROOT_DIR" rev-parse --abbrev-ref HEAD >/dev/null 2>&1; then
git -C "$RR_ROOT_DIR" rev-parse --abbrev-ref HEAD
else
echo "unknown"
fi
}
rr_finalize() {
if [[ "$RR_ENABLED" != "1" ]]; then
return 0
fi
local overall_status="$1"
local overall_duration_ms="$2"
local passed="$3"
local failed="$4"
local skipped="$5"
local total="$6"
local output_dir
output_dir="$(dirname "$RR_OUTPUT_PATH")"
mkdir -p "$output_dir"
local git_sha
local git_branch
local generated_at
git_sha="$(rr_git_sha)"
git_branch="$(rr_git_branch)"
generated_at="$(rr_now_iso)"
python3 - "$RR_STEPS_TSV" "$RR_OUTPUT_PATH" "$RR_SCHEMA_VERSION" "$generated_at" "$git_sha" "$git_branch" "$overall_status" "$overall_duration_ms" "$passed" "$failed" "$skipped" "$total" <<'PY'
import json
import sys
from pathlib import Path
steps_path = Path(sys.argv[1])
output_path = Path(sys.argv[2])
schema_version = sys.argv[3]
generated_at = sys.argv[4]
git_sha = sys.argv[5]
git_branch = sys.argv[6]
overall_status = sys.argv[7]
overall_duration_ms = int(sys.argv[8])
passed = int(sys.argv[9])
failed = int(sys.argv[10])
skipped = int(sys.argv[11])
total = int(sys.argv[12])
steps = []
if steps_path.exists():
for line in steps_path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
parts = line.split("\t")
if len(parts) != 9:
continue
step_id, name, enabled, command, status, exit_code, started_at, ended_at, duration_ms = parts
steps.append(
{
"id": step_id,
"name": name,
"enabled": enabled == "1",
"status": status,
"exit_code": int(exit_code),
"duration_ms": int(duration_ms),
"started_at": started_at,
"ended_at": ended_at,
"command": command,
}
)
payload = {
"schema_version": schema_version,
"generated_at": generated_at,
"git": {
"sha": git_sha,
"branch": git_branch,
},
"overall": {
"status": overall_status,
"duration_ms": overall_duration_ms,
"passed": passed,
"failed": failed,
"skipped": skipped,
"total": total,
},
"steps": steps,
}
output_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
PY
}
rr_cleanup() {
if [[ -n "$RR_TMP_DIR" && -d "$RR_TMP_DIR" ]]; then
rm -rf "$RR_TMP_DIR"
fi
}
+128 -82
查看文件
@@ -2,6 +2,11 @@
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
# shellcheck source=scripts/lib/regression-report.sh
source "$SCRIPT_DIR/lib/regression-report.sh"
RUN_AUDIT_REGRESSION="${RUN_AUDIT_REGRESSION:-1}" RUN_AUDIT_REGRESSION="${RUN_AUDIT_REGRESSION:-1}"
RUN_OCR_REGRESSION="${RUN_OCR_REGRESSION:-1}" RUN_OCR_REGRESSION="${RUN_OCR_REGRESSION:-1}"
RUN_ORG_SETTINGS_REGRESSION="${RUN_ORG_SETTINGS_REGRESSION:-0}" RUN_ORG_SETTINGS_REGRESSION="${RUN_ORG_SETTINGS_REGRESSION:-0}"
@@ -12,97 +17,138 @@ RUN_BUDGET_REGRESSION="${RUN_BUDGET_REGRESSION:-0}"
RUN_INTEGRATIONS_REGRESSION="${RUN_INTEGRATIONS_REGRESSION:-0}" RUN_INTEGRATIONS_REGRESSION="${RUN_INTEGRATIONS_REGRESSION:-0}"
RUN_FEATURE_FLAGS_REGRESSION="${RUN_FEATURE_FLAGS_REGRESSION:-0}" RUN_FEATURE_FLAGS_REGRESSION="${RUN_FEATURE_FLAGS_REGRESSION:-0}"
echo "=== [1/7] Smoke Test ===" JSON_REPORT_PATH=""
"$SCRIPT_DIR/smoke-test.sh"
echo ""
echo "=== [2/7] Dedup Regression ===" usage() {
"$SCRIPT_DIR/regression-dedup.sh" cat <<'USAGE'
echo "" Usage: ./scripts/regression-all.sh [--json-report <path>]
echo "=== [3/7] Workflow Actions Regression ===" Options:
"$SCRIPT_DIR/regression-workflow-actions.sh" --json-report <path> Write structured JSON report (schema_version=1.0).
echo "" USAGE
}
if [[ "$RUN_AUDIT_REGRESSION" == "1" ]]; then while [[ $# -gt 0 ]]; do
echo "=== [4/7] Audit Regression ===" case "$1" in
"$SCRIPT_DIR/regression-audit.sh" --json-report)
echo "" if [[ $# -lt 2 || -z "${2:-}" ]]; then
else echo "[ERROR] --json-report requires a file path" >&2
echo "=== [4/7] Audit Regression (skipped, RUN_AUDIT_REGRESSION=$RUN_AUDIT_REGRESSION) ===" exit 2
echo "" fi
JSON_REPORT_PATH="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "[ERROR] Unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done
FAIL_FAST="1"
if [[ -n "$JSON_REPORT_PATH" ]]; then
FAIL_FAST="0"
rr_prepare "$JSON_REPORT_PATH" "$ROOT_DIR"
trap rr_cleanup EXIT
fi fi
if [[ "$RUN_OCR_REGRESSION" == "1" ]]; then TOTAL_STEPS=12
echo "=== [5/7] OCR Regression ===" PASSED_STEPS=0
"$SCRIPT_DIR/regression-ocr.sh" FAILED_STEPS=0
echo "" SKIPPED_STEPS=0
else RUN_STARTED_MS="$(rr_now_ms)"
echo "=== [5/7] OCR Regression (skipped, RUN_OCR_REGRESSION=$RUN_OCR_REGRESSION) ==="
echo "" run_step() {
local index="$1"
local step_id="$2"
local step_name="$3"
local enabled="$4"
local command="$5"
local skip_reason="${6:-}"
local step_started_ms
local step_ended_ms
local step_duration_ms
local step_started_at
local step_ended_at
local status
local exit_code
step_started_ms="$(rr_now_ms)"
step_started_at="$(rr_now_iso)"
if [[ "$enabled" != "1" ]]; then
echo "=== [$index/$TOTAL_STEPS] $step_name (skipped, $skip_reason) ==="
echo ""
status="skipped"
exit_code=0
SKIPPED_STEPS=$((SKIPPED_STEPS + 1))
else
echo "=== [$index/$TOTAL_STEPS] $step_name ==="
set +e
eval "$command"
exit_code=$?
set -e
if [[ "$exit_code" -eq 0 ]]; then
status="passed"
PASSED_STEPS=$((PASSED_STEPS + 1))
else
status="failed"
FAILED_STEPS=$((FAILED_STEPS + 1))
echo "[ERROR] $step_name failed (exit=$exit_code)" >&2
fi
echo ""
fi
step_ended_ms="$(rr_now_ms)"
step_ended_at="$(rr_now_iso)"
step_duration_ms="$((step_ended_ms - step_started_ms))"
rr_record_step "$step_id" "$step_name" "$enabled" "$command" "$status" "$exit_code" "$step_started_at" "$step_ended_at" "$step_duration_ms"
if [[ "$status" == "failed" && "$FAIL_FAST" == "1" ]]; then
return "$exit_code"
fi
return 0
}
run_step 1 "smoke" "Smoke Test" "1" '"$SCRIPT_DIR"/smoke-test.sh'
run_step 2 "dedup" "Dedup Regression" "1" '"$SCRIPT_DIR"/regression-dedup.sh'
run_step 3 "workflow_actions" "Workflow Actions Regression" "1" '"$SCRIPT_DIR"/regression-workflow-actions.sh'
run_step 4 "audit" "Audit Regression" "$RUN_AUDIT_REGRESSION" '"$SCRIPT_DIR"/regression-audit.sh' "RUN_AUDIT_REGRESSION=$RUN_AUDIT_REGRESSION"
run_step 5 "ocr" "OCR Regression" "$RUN_OCR_REGRESSION" '"$SCRIPT_DIR"/regression-ocr.sh' "RUN_OCR_REGRESSION=$RUN_OCR_REGRESSION"
run_step 6 "org_settings" "Org + Settings Regression" "$RUN_ORG_SETTINGS_REGRESSION" '"$SCRIPT_DIR"/regression-org-settings.sh' "RUN_ORG_SETTINGS_REGRESSION=$RUN_ORG_SETTINGS_REGRESSION"
run_step 7 "auth_security" "Auth Security Regression" "$RUN_AUTH_SECURITY_REGRESSION" '"$SCRIPT_DIR"/regression-auth-security.sh' "RUN_AUTH_SECURITY_REGRESSION=$RUN_AUTH_SECURITY_REGRESSION"
run_step 8 "verification" "Verification Regression" "$RUN_VERIFICATION_REGRESSION" '"$SCRIPT_DIR"/regression-verification.sh' "RUN_VERIFICATION_REGRESSION=$RUN_VERIFICATION_REGRESSION"
run_step 9 "dashboard" "Dashboard Regression" "$RUN_DASHBOARD_REGRESSION" '"$SCRIPT_DIR"/regression-dashboard.sh' "RUN_DASHBOARD_REGRESSION=$RUN_DASHBOARD_REGRESSION"
run_step 10 "budget" "Budget Regression" "$RUN_BUDGET_REGRESSION" '"$SCRIPT_DIR"/regression-budget.sh' "RUN_BUDGET_REGRESSION=$RUN_BUDGET_REGRESSION"
run_step 11 "integrations" "Integrations Regression" "$RUN_INTEGRATIONS_REGRESSION" '"$SCRIPT_DIR"/regression-integrations.sh' "RUN_INTEGRATIONS_REGRESSION=$RUN_INTEGRATIONS_REGRESSION"
run_step 12 "feature_flags" "Feature Flags Regression" "$RUN_FEATURE_FLAGS_REGRESSION" '"$SCRIPT_DIR"/regression-feature-flags.sh' "RUN_FEATURE_FLAGS_REGRESSION=$RUN_FEATURE_FLAGS_REGRESSION"
RUN_ENDED_MS="$(rr_now_ms)"
OVERALL_DURATION_MS="$((RUN_ENDED_MS - RUN_STARTED_MS))"
OVERALL_STATUS="passed"
if [[ "$FAILED_STEPS" -gt 0 ]]; then
OVERALL_STATUS="failed"
fi fi
if [[ "$RUN_ORG_SETTINGS_REGRESSION" == "1" ]]; then if [[ -n "$JSON_REPORT_PATH" ]]; then
echo "=== [6/7] Org + Settings Regression ===" rr_finalize "$OVERALL_STATUS" "$OVERALL_DURATION_MS" "$PASSED_STEPS" "$FAILED_STEPS" "$SKIPPED_STEPS" "$TOTAL_STEPS"
"$SCRIPT_DIR/regression-org-settings.sh"
echo ""
else
echo "=== [6/7] Org + Settings Regression (skipped, RUN_ORG_SETTINGS_REGRESSION=$RUN_ORG_SETTINGS_REGRESSION) ==="
echo ""
fi fi
if [[ "$RUN_AUTH_SECURITY_REGRESSION" == "1" ]]; then if [[ "$FAILED_STEPS" -gt 0 ]]; then
echo "=== [7/7] Auth Security Regression ===" echo "[ERROR] Regressions finished with failures (passed=$PASSED_STEPS failed=$FAILED_STEPS skipped=$SKIPPED_STEPS)" >&2
"$SCRIPT_DIR/regression-auth-security.sh" exit 1
echo ""
else
echo "=== [7/7] Auth Security Regression (skipped, RUN_AUTH_SECURITY_REGRESSION=$RUN_AUTH_SECURITY_REGRESSION) ==="
echo ""
fi
if [[ "$RUN_VERIFICATION_REGRESSION" == "1" ]]; then
echo "=== [8/11] Verification Regression ==="
"$SCRIPT_DIR/regression-verification.sh"
echo ""
else
echo "=== [8/11] Verification Regression (skipped, RUN_VERIFICATION_REGRESSION=$RUN_VERIFICATION_REGRESSION) ==="
echo ""
fi
if [[ "$RUN_DASHBOARD_REGRESSION" == "1" ]]; then
echo "=== [9/11] Dashboard Regression ==="
"$SCRIPT_DIR/regression-dashboard.sh"
echo ""
else
echo "=== [9/11] Dashboard Regression (skipped, RUN_DASHBOARD_REGRESSION=$RUN_DASHBOARD_REGRESSION) ==="
echo ""
fi
if [[ "$RUN_BUDGET_REGRESSION" == "1" ]]; then
echo "=== [10/11] Budget Regression ==="
"$SCRIPT_DIR/regression-budget.sh"
echo ""
else
echo "=== [10/11] Budget Regression (skipped, RUN_BUDGET_REGRESSION=$RUN_BUDGET_REGRESSION) ==="
echo ""
fi
if [[ "$RUN_INTEGRATIONS_REGRESSION" == "1" ]]; then
echo "=== [11/11] Integrations Regression ==="
"$SCRIPT_DIR/regression-integrations.sh"
echo ""
else
echo "=== [11/11] Integrations Regression (skipped, RUN_INTEGRATIONS_REGRESSION=$RUN_INTEGRATIONS_REGRESSION) ==="
echo ""
fi
if [[ "$RUN_FEATURE_FLAGS_REGRESSION" == "1" ]]; then
echo "=== [12/12] Feature Flags Regression ==="
"$SCRIPT_DIR/regression-feature-flags.sh"
echo ""
else
echo "=== [12/12] Feature Flags Regression (skipped, RUN_FEATURE_FLAGS_REGRESSION=$RUN_FEATURE_FLAGS_REGRESSION) ==="
echo ""
fi fi
echo "[OK] All regressions passed" echo "[OK] All regressions passed"
+54 -5
查看文件
@@ -14,6 +14,7 @@ const staticPlugin = require("@fastify/static");
const { getConfig } = require("./config"); const { getConfig } = require("./config");
const authPlugin = require("./plugins/auth"); const authPlugin = require("./plugins/auth");
const prismaPlugin = require("./plugins/prisma"); const prismaPlugin = require("./plugins/prisma");
const swaggerDocsPlugin = require("./plugins/swagger");
const authRoutes = require("./routes/auth"); const authRoutes = require("./routes/auth");
const meRoutes = require("./routes/me"); const meRoutes = require("./routes/me");
const reimbursementRoutes = require("./routes/reimbursements"); const reimbursementRoutes = require("./routes/reimbursements");
@@ -38,6 +39,7 @@ const travelOrdersRoutes = require("./routes/travelOrders");
const { startJobs } = require("./jobs/jobSystem"); const { startJobs } = require("./jobs/jobSystem");
const { initMetrics } = require("./observability"); const { initMetrics } = require("./observability");
const { AppError } = require("./utils/AppError"); const { AppError } = require("./utils/AppError");
const { buildDataEnvelopeSchema, buildErrorEnvelopeSchema } = require("./utils/openapi");
async function buildApp() { async function buildApp() {
const config = getConfig(); const config = getConfig();
@@ -180,11 +182,57 @@ async function buildApp() {
await app.register(prismaPlugin); await app.register(prismaPlugin);
await app.register(authPlugin); await app.register(authPlugin);
app.get("/health", async (request, reply) => { if (config.nodeEnv !== "production" && config.openapiDocsEnabled) {
return reply.send({ requestId: request.id, data: { ok: true } }); await app.register(swaggerDocsPlugin);
}); app.log.info("OpenAPI docs enabled at /api/docs");
}
app.get("/ready", async (request, reply) => { app.get(
"/health",
{
schema: {
summary: "Health check",
tags: ["system"],
response: {
200: buildDataEnvelopeSchema({
type: "object",
required: ["ok"],
properties: {
ok: { type: "boolean" },
},
}),
},
},
},
async (request, reply) => {
return reply.send({ requestId: request.id, data: { ok: true } });
},
);
app.get(
"/ready",
{
schema: {
summary: "Readiness check",
tags: ["system"],
response: {
200: buildDataEnvelopeSchema({
type: "object",
required: ["ok"],
properties: {
ok: { type: "boolean" },
},
}),
503: buildErrorEnvelopeSchema({
description: "数据库不可用",
codeExample: "SYS_503",
messageExample: "Service Unavailable",
detailsExample: { db: "unreachable" },
}),
},
},
},
async (request, reply) => {
try { try {
await app.prisma.$queryRaw`SELECT 1`; await app.prisma.$queryRaw`SELECT 1`;
return reply.send({ requestId: request.id, data: { ok: true } }); return reply.send({ requestId: request.id, data: { ok: true } });
@@ -197,7 +245,8 @@ async function buildApp() {
details: { db: "unreachable" }, details: { db: "unreachable" },
}); });
} }
}); },
);
if (config.metricsEnabled) { if (config.metricsEnabled) {
initMetrics(app); initMetrics(app);
+1
查看文件
@@ -66,6 +66,7 @@ function getConfig() {
s3SecretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? "", s3SecretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? "",
s3ForcePathStyle: process.env.S3_FORCE_PATH_STYLE === "true", s3ForcePathStyle: process.env.S3_FORCE_PATH_STYLE === "true",
serveClientDist: process.env.SERVE_CLIENT_DIST === "true", serveClientDist: process.env.SERVE_CLIENT_DIST === "true",
openapiDocsEnabled: process.env.OPENAPI_DOCS_ENABLED === "true",
clientDistDir: path.resolve(serverRoot, clientDistDir), clientDistDir: path.resolve(serverRoot, clientDistDir),
signedUrlTtlSeconds: Number(process.env.SIGNED_URL_TTL_SECONDS ?? 600), signedUrlTtlSeconds: Number(process.env.SIGNED_URL_TTL_SECONDS ?? 600),
maxUploadSizeBytes: Number(process.env.MAX_UPLOAD_SIZE_BYTES ?? 10 * 1024 * 1024), maxUploadSizeBytes: Number(process.env.MAX_UPLOAD_SIZE_BYTES ?? 10 * 1024 * 1024),
+37
查看文件
@@ -0,0 +1,37 @@
const fp = require("fastify-plugin");
const fastifySwagger = require("@fastify/swagger");
const fastifySwaggerUi = require("@fastify/swagger-ui");
const pkg = require("../../package.json");
async function swaggerPlugin(fastify) {
await fastify.register(fastifySwagger, {
openapi: {
openapi: "3.0.3",
info: {
title: "hyb-oa API",
description: "hyb-oa 后端接口文档(治理链路优先)",
version: pkg.version,
},
components: {
securitySchemes: {
bearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
},
},
},
},
});
await fastify.register(fastifySwaggerUi, {
routePrefix: "/api/docs",
staticCSP: true,
uiConfig: {
docExpansion: "list",
deepLinking: false,
},
});
}
module.exports = fp(swaggerPlugin, { name: "swagger-docs" });
+130
查看文件
@@ -3,6 +3,10 @@ const { z } = require("zod");
const { AppError } = require("../utils/AppError"); const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit"); const { buildAuditLogData } = require("../utils/audit");
const { sendData } = require("../utils/reply"); const { sendData } = require("../utils/reply");
const {
buildDataEnvelopeSchema,
buildErrorEnvelopeSchema,
} = require("../utils/openapi");
const { const {
FEATURE_FLAG_KEY_LIST, FEATURE_FLAG_KEY_LIST,
OVERRIDE_MODE_LIST, OVERRIDE_MODE_LIST,
@@ -24,6 +28,49 @@ const UPDATE_BODY_SCHEMA = z.object({
expectedVersion: z.coerce.number().int().min(1), expectedVersion: z.coerce.number().int().min(1),
}); });
const FEATURE_FLAG_ITEM_SCHEMA = {
type: "object",
required: [
"id",
"flagKey",
"overrideMode",
"rolloutType",
"departmentIds",
"note",
"version",
"createdAt",
"createdBy",
"updatedAt",
"updatedBy",
"envEnabled",
"effectiveEnabled",
"effectiveReason",
"matchedDepartmentId",
"requiresDepartment",
],
properties: {
id: { anyOf: [{ type: "string", format: "uuid" }, { type: "null" }] },
flagKey: { type: "string", enum: FEATURE_FLAG_KEY_LIST },
overrideMode: { type: "string", enum: OVERRIDE_MODE_LIST },
rolloutType: { type: "string", enum: ROLLOUT_TYPE_LIST },
departmentIds: {
type: "array",
items: { type: "string", format: "uuid" },
},
note: { type: "string" },
version: { type: "integer" },
createdAt: { anyOf: [{ type: "string", format: "date-time" }, { type: "null" }] },
createdBy: { anyOf: [{ type: "string", format: "uuid" }, { type: "null" }] },
updatedAt: { anyOf: [{ type: "string", format: "date-time" }, { type: "null" }] },
updatedBy: { anyOf: [{ type: "string", format: "uuid" }, { type: "null" }] },
envEnabled: { type: "boolean" },
effectiveEnabled: { type: "boolean" },
effectiveReason: { type: "string" },
matchedDepartmentId: { anyOf: [{ type: "string", format: "uuid" }, { type: "null" }] },
requiresDepartment: { type: "boolean" },
},
};
function parseKey(params) { function parseKey(params) {
const parsed = KEY_PARAM_SCHEMA.safeParse(params ?? {}); const parsed = KEY_PARAM_SCHEMA.safeParse(params ?? {});
if (!parsed.success) { if (!parsed.success) {
@@ -66,6 +113,31 @@ async function featureFlagsRoutes(fastify) {
"/system/feature-flags", "/system/feature-flags",
{ {
preHandler: fastify.requirePermission("system.settings"), preHandler: fastify.requirePermission("system.settings"),
schema: {
tags: ["system", "feature-flags"],
summary: "获取功能开关列表",
security: [{ bearerAuth: [] }],
response: {
200: buildDataEnvelopeSchema(
{
type: "object",
required: ["items"],
properties: {
items: {
type: "array",
items: FEATURE_FLAG_ITEM_SCHEMA,
},
},
},
"功能开关列表",
),
403: buildErrorEnvelopeSchema({
description: "无权限",
codeExample: "PERM_001",
messageExample: "无权限",
}),
},
},
}, },
async (request, reply) => { async (request, reply) => {
const items = await listFeatureFlags(fastify.prisma, fastify.config); const items = await listFeatureFlags(fastify.prisma, fastify.config);
@@ -77,6 +149,64 @@ async function featureFlagsRoutes(fastify) {
"/system/feature-flags/:key", "/system/feature-flags/:key",
{ {
preHandler: fastify.requirePermission("system.settings"), preHandler: fastify.requirePermission("system.settings"),
schema: {
tags: ["system", "feature-flags"],
summary: "更新功能开关",
security: [{ bearerAuth: [] }],
params: {
type: "object",
required: ["key"],
properties: {
key: { type: "string", enum: FEATURE_FLAG_KEY_LIST },
},
},
body: {
type: "object",
required: ["overrideMode", "rolloutType", "departmentIds", "expectedVersion"],
properties: {
overrideMode: { type: "string", enum: OVERRIDE_MODE_LIST },
rolloutType: { type: "string", enum: ROLLOUT_TYPE_LIST },
departmentIds: {
type: "array",
items: { type: "string", format: "uuid" },
},
note: { type: "string", maxLength: 500 },
expectedVersion: { type: "integer", minimum: 1 },
},
},
response: {
200: buildDataEnvelopeSchema(
{
type: "object",
required: ["item"],
properties: {
item: FEATURE_FLAG_ITEM_SCHEMA,
},
},
"更新后的功能开关",
),
400: buildErrorEnvelopeSchema({
description: "参数校验失败",
codeExample: "VALID_001",
messageExample: "参数校验失败",
}),
404: buildErrorEnvelopeSchema({
description: "开关不存在",
codeExample: "SYS_404",
messageExample: "Not Found",
}),
409: buildErrorEnvelopeSchema({
description: "版本冲突",
codeExample: "VALID_001",
messageExample: "参数校验失败",
detailsExample: {
featureFlag: "version_conflict",
expectedVersion: 2,
currentVersion: 3,
},
}),
},
},
}, },
async (request, reply) => { async (request, reply) => {
const key = parseKey(request.params); const key = parseKey(request.params);
+63 -1
查看文件
@@ -3,16 +3,74 @@ const { z } = require("zod");
const { AppError } = require("../utils/AppError"); const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply"); const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit"); const { buildAuditLogData } = require("../utils/audit");
const {
buildDataEnvelopeSchema,
buildErrorEnvelopeSchema,
} = require("../utils/openapi");
const { enqueueIntegrationEvent } = require("../services/integrations/outbox"); const { enqueueIntegrationEvent } = require("../services/integrations/outbox");
const { buildErpPayload } = require("../services/integrations/erp"); const { buildErpPayload } = require("../services/integrations/erp");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { runIntegrationOutboxOnce } = require("../jobs/integrationOutboxJob"); const { runIntegrationOutboxOnce } = require("../jobs/integrationOutboxJob");
const ERP_SYNC_RESPONSE_SCHEMA = {
type: "object",
required: ["outboxId", "status", "duplicated"],
properties: {
outboxId: { type: "string", format: "uuid" },
status: { type: "string" },
duplicated: { type: "boolean" },
},
};
async function integrationsRoutes(fastify) { async function integrationsRoutes(fastify) {
fastify.post( fastify.post(
"/integrations/erp/sync/:reimbursementId", "/integrations/erp/sync/:reimbursementId",
{ {
preHandler: fastify.requirePermission("integration.manage"), preHandler: fastify.requirePermission("integration.manage"),
schema: {
tags: ["integrations"],
summary: "手动触发 ERP 同步",
security: [{ bearerAuth: [] }],
params: {
type: "object",
required: ["reimbursementId"],
properties: {
reimbursementId: { type: "string", format: "uuid" },
},
},
querystring: {
type: "object",
properties: {
runNow: {
anyOf: [{ type: "string", enum: ["1", "true"] }, { type: "boolean" }],
},
},
additionalProperties: true,
},
response: {
200: buildDataEnvelopeSchema(ERP_SYNC_RESPONSE_SCHEMA, "ERP 同步任务已入队"),
400: buildErrorEnvelopeSchema({
description: "参数校验失败",
codeExample: "VALID_001",
messageExample: "参数校验失败",
}),
404: buildErrorEnvelopeSchema({
description: "报销单不存在",
codeExample: "SYS_404",
messageExample: "Not Found",
}),
409: buildErrorEnvelopeSchema({
description: "状态不满足或开关阻断",
codeExample: "VALID_001",
messageExample: "参数校验失败",
detailsExample: {
integration: "feature_flag_blocked",
featureFlag: "erp_sync",
reason: "department_not_allowed",
},
}),
},
},
}, },
async (request, reply) => { async (request, reply) => {
const parsedId = z.string().uuid().safeParse(request.params?.reimbursementId); const parsedId = z.string().uuid().safeParse(request.params?.reimbursementId);
@@ -106,7 +164,11 @@ async function integrationsRoutes(fastify) {
}), }),
}); });
const runNow = request.query?.runNow === "1" || request.query?.runNow === 1 || request.query?.runNow === true || request.query?.runNow === "true"; const runNow =
request.query?.runNow === "1" ||
request.query?.runNow === 1 ||
request.query?.runNow === true ||
request.query?.runNow === "true";
if (runNow) { if (runNow) {
await runIntegrationOutboxOnce(fastify.prisma, fastify.config, fastify.log); await runIntegrationOutboxOnce(fastify.prisma, fastify.config, fastify.log);
} }
@@ -2,6 +2,7 @@ const { z } = require("zod");
const { AppError } = require("../../utils/AppError"); const { AppError } = require("../../utils/AppError");
const { sendData } = require("../../utils/reply"); const { sendData } = require("../../utils/reply");
const { buildDataEnvelopeSchema, buildErrorEnvelopeSchema, UUID_PARAM_SCHEMA } = require("../../utils/openapi");
const { buildAuditLogData } = require("../../utils/audit"); const { buildAuditLogData } = require("../../utils/audit");
const { getProvider, evaluateVerificationAccess } = require("../../services/verification"); const { getProvider, evaluateVerificationAccess } = require("../../services/verification");
@@ -9,6 +10,27 @@ const { mapVerificationRecord } = require("./shared/mappers");
const { parseVerificationListQuery } = require("./shared/schemas"); const { parseVerificationListQuery } = require("./shared/schemas");
const { triggerVerificationJob } = require("./shared/helpers"); const { triggerVerificationJob } = require("./shared/helpers");
const VERIFY_RESPONSE_SCHEMA = {
type: "object",
required: ["invoiceId", "verificationId", "provider", "triggerMode"],
properties: {
invoiceId: { type: "string", format: "uuid" },
verificationId: { type: "string", format: "uuid" },
provider: { type: "string" },
triggerMode: { type: "string" },
},
};
const RETRY_RESPONSE_SCHEMA = {
type: "object",
required: ["invoiceId", "verificationId", "status"],
properties: {
invoiceId: { type: "string", format: "uuid" },
verificationId: { type: "string", format: "uuid" },
status: { type: "string", enum: ["pending"] },
},
};
function throwVerificationDisabled(reason) { function throwVerificationDisabled(reason) {
throw new AppError({ throw new AppError({
code: "VALID_001", code: "VALID_001",
@@ -89,6 +111,31 @@ function registerInvoiceVerificationRoutes(fastify) {
"/invoice-pool/:id/verify", "/invoice-pool/:id/verify",
{ {
preHandler: fastify.requirePermission("invoice.verify"), preHandler: fastify.requirePermission("invoice.verify"),
schema: {
tags: ["invoice-verification"],
summary: "触发发票验真",
security: [{ bearerAuth: [] }],
params: UUID_PARAM_SCHEMA,
response: {
200: buildDataEnvelopeSchema(VERIFY_RESPONSE_SCHEMA, "验真任务已创建"),
400: buildErrorEnvelopeSchema({
description: "参数校验失败",
codeExample: "VALID_001",
messageExample: "参数校验失败",
}),
404: buildErrorEnvelopeSchema({
description: "票据不存在",
codeExample: "SYS_404",
messageExample: "Not Found",
}),
409: buildErrorEnvelopeSchema({
description: "触发受限(开关/频控/并发)",
codeExample: "VALID_001",
messageExample: "参数校验失败",
detailsExample: { verification: "disabled", featureFlag: "env_hard_off" },
}),
},
},
}, },
async (request, reply) => { async (request, reply) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id); const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
@@ -319,6 +366,31 @@ function registerInvoiceVerificationRoutes(fastify) {
"/invoice-pool/:id/verification/retry", "/invoice-pool/:id/verification/retry",
{ {
preHandler: fastify.requirePermission("invoice.verify"), preHandler: fastify.requirePermission("invoice.verify"),
schema: {
tags: ["invoice-verification"],
summary: "重试发票验真",
security: [{ bearerAuth: [] }],
params: UUID_PARAM_SCHEMA,
response: {
200: buildDataEnvelopeSchema(RETRY_RESPONSE_SCHEMA, "验真重试任务已创建"),
400: buildErrorEnvelopeSchema({
description: "参数校验失败",
codeExample: "VALID_001",
messageExample: "参数校验失败",
}),
404: buildErrorEnvelopeSchema({
description: "票据不存在",
codeExample: "SYS_404",
messageExample: "Not Found",
}),
409: buildErrorEnvelopeSchema({
description: "重试受限(开关/频控/重试次数)",
codeExample: "VALID_001",
messageExample: "参数校验失败",
detailsExample: { verification: "too_frequent" },
}),
},
},
}, },
async (request, reply) => { async (request, reply) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id); const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
+89
查看文件
@@ -0,0 +1,89 @@
const REQUEST_ID_SCHEMA = {
type: "string",
description: "服务端生成的请求追踪ID",
};
const ERROR_DETAILS_SCHEMA = {
type: "object",
additionalProperties: true,
};
const ERROR_BODY_SCHEMA = {
type: "object",
required: ["code", "message", "details"],
properties: {
code: { type: "string" },
message: { type: "string" },
details: ERROR_DETAILS_SCHEMA,
},
};
function buildDataEnvelopeSchema(dataSchema, description = "OK") {
return {
description,
type: "object",
required: ["requestId", "data"],
properties: {
requestId: REQUEST_ID_SCHEMA,
data: dataSchema,
},
};
}
function buildErrorEnvelopeSchema({
description = "业务错误",
codeExample = "VALID_001",
messageExample = "参数校验失败",
detailsExample,
} = {}) {
const schema = {
description,
type: "object",
required: ["requestId", "error"],
properties: {
requestId: REQUEST_ID_SCHEMA,
error: {
...ERROR_BODY_SCHEMA,
properties: {
...ERROR_BODY_SCHEMA.properties,
code: {
...ERROR_BODY_SCHEMA.properties.code,
example: codeExample,
},
message: {
...ERROR_BODY_SCHEMA.properties.message,
example: messageExample,
},
details: {
...ERROR_DETAILS_SCHEMA,
},
},
},
},
};
if (detailsExample !== undefined) {
schema.properties.error.properties.details.example = detailsExample;
}
return schema;
}
const UUID_PARAM_SCHEMA = {
type: "object",
required: ["id"],
properties: {
id: {
type: "string",
format: "uuid",
},
},
};
module.exports = {
REQUEST_ID_SCHEMA,
ERROR_BODY_SCHEMA,
buildDataEnvelopeSchema,
buildErrorEnvelopeSchema,
UUID_PARAM_SCHEMA,
};
@@ -0,0 +1,201 @@
const {
FEATURE_FLAG_KEYS,
evaluateFeatureFlag,
listFeatureFlags,
} = require("../../src/services/featureFlags");
function buildRecord({
flagKey,
overrideMode = "force_on",
rolloutType = "global",
departmentIds = [],
}) {
const now = new Date("2026-02-12T00:00:00.000Z");
return {
id: `flag-${flagKey}`,
flag_key: flagKey,
override_mode: overrideMode,
rollout_type: rolloutType,
department_ids: departmentIds,
note: "unit-test",
version: 1,
created_at: now,
created_by: null,
updated_at: now,
updated_by: null,
};
}
function buildPrisma(recordMap = {}) {
return {
featureFlag: {
findUnique: async ({ where }) => recordMap[where.flag_key] ?? null,
findMany: async () => Object.values(recordMap),
},
};
}
describe("evaluateFeatureFlag", () => {
it("returns env_hard_off when env gate is closed", async () => {
const result = await evaluateFeatureFlag({
prisma: buildPrisma(),
config: { erpSyncEnabled: false, invoiceVerificationEnabled: true },
flagKey: FEATURE_FLAG_KEYS.erpSync,
});
expect(result.enabled).toBe(false);
expect(result.reason).toBe("env_hard_off");
});
it("returns missing_flag_record when record does not exist", async () => {
const result = await evaluateFeatureFlag({
prisma: buildPrisma(),
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true },
flagKey: FEATURE_FLAG_KEYS.erpSync,
});
expect(result.enabled).toBe(false);
expect(result.reason).toBe("missing_flag_record");
});
it("returns flag_force_off when override mode is force_off", async () => {
const prisma = buildPrisma({
[FEATURE_FLAG_KEYS.erpSync]: buildRecord({
flagKey: FEATURE_FLAG_KEYS.erpSync,
overrideMode: "force_off",
}),
});
const result = await evaluateFeatureFlag({
prisma,
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true },
flagKey: FEATURE_FLAG_KEYS.erpSync,
});
expect(result.enabled).toBe(false);
expect(result.reason).toBe("flag_force_off");
});
it("returns global enabled when inherit + global", async () => {
const prisma = buildPrisma({
[FEATURE_FLAG_KEYS.erpSync]: buildRecord({
flagKey: FEATURE_FLAG_KEYS.erpSync,
overrideMode: "inherit",
rolloutType: "global",
}),
});
const result = await evaluateFeatureFlag({
prisma,
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true },
flagKey: FEATURE_FLAG_KEYS.erpSync,
});
expect(result.enabled).toBe(true);
expect(result.reason).toBe("env_inherit_global_on");
});
it("returns allowlist guards for department rollout", async () => {
const prisma = buildPrisma({
[FEATURE_FLAG_KEYS.erpSync]: buildRecord({
flagKey: FEATURE_FLAG_KEYS.erpSync,
rolloutType: "department_allowlist",
departmentIds: ["dept-a"],
}),
});
const missingDepartment = await evaluateFeatureFlag({
prisma,
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true },
flagKey: FEATURE_FLAG_KEYS.erpSync,
strictDepartment: true,
});
const blockedDepartment = await evaluateFeatureFlag({
prisma,
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true },
flagKey: FEATURE_FLAG_KEYS.erpSync,
departmentId: "dept-b",
strictDepartment: true,
});
const allowedDepartment = await evaluateFeatureFlag({
prisma,
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true },
flagKey: FEATURE_FLAG_KEYS.erpSync,
departmentId: "dept-a",
strictDepartment: true,
});
expect(missingDepartment.reason).toBe("department_missing");
expect(blockedDepartment.reason).toBe("department_not_allowed");
expect(allowedDepartment.reason).toBe("flag_force_on_department_allowed");
expect(allowedDepartment.enabled).toBe(true);
});
it("returns department_deferred when strictDepartment is false", async () => {
const prisma = buildPrisma({
[FEATURE_FLAG_KEYS.invoiceVerification]: buildRecord({
flagKey: FEATURE_FLAG_KEYS.invoiceVerification,
rolloutType: "department_allowlist",
departmentIds: ["dept-a"],
}),
});
const result = await evaluateFeatureFlag({
prisma,
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true },
flagKey: FEATURE_FLAG_KEYS.invoiceVerification,
strictDepartment: false,
});
expect(result.enabled).toBe(true);
expect(result.reason).toBe("department_deferred");
});
it("returns department_allowlist_empty when allowlist has no items", async () => {
const prisma = buildPrisma({
[FEATURE_FLAG_KEYS.invoiceVerification]: buildRecord({
flagKey: FEATURE_FLAG_KEYS.invoiceVerification,
rolloutType: "department_allowlist",
departmentIds: [],
}),
});
const result = await evaluateFeatureFlag({
prisma,
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true },
flagKey: FEATURE_FLAG_KEYS.invoiceVerification,
strictDepartment: true,
});
expect(result.enabled).toBe(false);
expect(result.reason).toBe("department_allowlist_empty");
});
});
describe("listFeatureFlags", () => {
it("returns all known keys even if part of records are missing", async () => {
const prisma = buildPrisma({
[FEATURE_FLAG_KEYS.erpSync]: buildRecord({
flagKey: FEATURE_FLAG_KEYS.erpSync,
overrideMode: "inherit",
}),
});
const items = await listFeatureFlags(prisma, {
erpSyncEnabled: true,
invoiceVerificationEnabled: true,
});
expect(items).toHaveLength(2);
expect(items.map((item) => item.flagKey).sort()).toEqual([
FEATURE_FLAG_KEYS.erpSync,
FEATURE_FLAG_KEYS.invoiceVerification,
]);
const missingItem = items.find((item) => item.flagKey === FEATURE_FLAG_KEYS.invoiceVerification);
expect(missingItem.id).toBeNull();
expect(missingItem.effectiveEnabled).toBe(false);
});
});
@@ -0,0 +1,18 @@
const { sanitizeDepartmentIds } = require("../../src/services/featureFlags");
describe("sanitizeDepartmentIds", () => {
it("returns empty array when input is not array", () => {
expect(sanitizeDepartmentIds(null)).toEqual([]);
expect(sanitizeDepartmentIds("dept")).toEqual([]);
});
it("trims ids, removes empty values and deduplicates", () => {
const value = [" dept-a ", "dept-b", "", "dept-a", " "];
expect(sanitizeDepartmentIds(value)).toEqual(["dept-a", "dept-b"]);
});
it("filters non-string items", () => {
const value = ["dept-a", 1, true, { id: "dept-b" }, "dept-c"];
expect(sanitizeDepartmentIds(value)).toEqual(["dept-a", "dept-c"]);
});
});
@@ -0,0 +1,106 @@
const {
IntegrationDispatchError,
dispatchOutboxEvent,
} = require("../../src/services/integrations/dispatcher");
describe("dispatchOutboxEvent", () => {
it("handles mock ERP success", async () => {
const result = await dispatchOutboxEvent({
config: {
erpSyncEnabled: true,
erpSyncProvider: "mock",
},
eventType: "erp.reimbursement.paid",
payload: {
reimbursementId: "rb-1",
formNumber: "BX-001",
},
});
expect(result.channel).toBe("erp");
expect(result.code).toBe("mock_synced");
expect(result.response.externalId).toContain("BX-001");
});
it("maps ERP disabled as non-retryable dispatch error", async () => {
await expect(
dispatchOutboxEvent({
config: {
erpSyncEnabled: false,
erpSyncProvider: "mock",
},
eventType: "erp.reimbursement.paid",
payload: {
reimbursementId: "rb-2",
formNumber: "BX-002",
},
}),
).rejects.toMatchObject({
name: "IntegrationDispatchError",
code: "erp_sync_disabled",
retryable: false,
});
});
it("maps invalid ERP provider as non-retryable dispatch error", async () => {
await expect(
dispatchOutboxEvent({
config: {
erpSyncEnabled: true,
erpSyncProvider: "invalid-provider",
},
eventType: "erp.reimbursement.paid",
payload: {
reimbursementId: "rb-3",
formNumber: "BX-003",
},
}),
).rejects.toMatchObject({
name: "IntegrationDispatchError",
code: "erp_sync_provider_invalid",
retryable: false,
});
});
it("handles IM notification outbox", async () => {
const result = await dispatchOutboxEvent({
config: {},
eventType: "notification.im",
payload: {
channelType: "wecom",
},
});
expect(result.channel).toBe("im");
expect(result.code).toBe("mock_sent");
expect(result.response.channelType).toBe("wecom");
});
it("throws non-retryable error for unsupported event", async () => {
await expect(
dispatchOutboxEvent({
config: { erpSyncEnabled: true, erpSyncProvider: "mock" },
eventType: "unsupported.event",
payload: {},
}),
).rejects.toMatchObject({
name: "IntegrationDispatchError",
code: "unsupported_outbox_event",
retryable: false,
});
});
it("preserves error class semantics", () => {
const error = new IntegrationDispatchError({
code: "unit_test",
message: "unit test",
retryable: true,
statusCode: 503,
});
expect(error).toBeInstanceOf(Error);
expect(error.code).toBe("unit_test");
expect(error.retryable).toBe(true);
expect(error.statusCode).toBe(503);
});
});
@@ -0,0 +1,123 @@
const {
FEATURE_FLAG_KEYS,
} = require("../../src/services/featureFlags");
const {
evaluateVerificationAccess,
verifyInvoiceByProvider,
} = require("../../src/services/verification");
function buildFlagRecord({
overrideMode = "force_on",
rolloutType = "global",
departmentIds = [],
}) {
const now = new Date("2026-02-12T00:00:00.000Z");
return {
id: "verification-flag",
flag_key: FEATURE_FLAG_KEYS.invoiceVerification,
override_mode: overrideMode,
rollout_type: rolloutType,
department_ids: departmentIds,
note: "unit-test",
version: 1,
created_at: now,
created_by: null,
updated_at: now,
updated_by: null,
};
}
function buildPrisma(record) {
return {
featureFlag: {
findUnique: async () => record,
},
};
}
describe("evaluateVerificationAccess", () => {
it("returns env_hard_off when env gate is closed", async () => {
const result = await evaluateVerificationAccess({
prisma: buildPrisma(buildFlagRecord({})),
config: {
erpSyncEnabled: true,
invoiceVerificationEnabled: false,
},
departmentId: "dept-a",
strictDepartment: true,
});
expect(result.enabled).toBe(false);
expect(result.reason).toBe("env_hard_off");
});
it("returns allowlist decision for department rollout", async () => {
const prisma = buildPrisma(
buildFlagRecord({ rolloutType: "department_allowlist", departmentIds: ["dept-a"] }),
);
const blocked = await evaluateVerificationAccess({
prisma,
config: {
erpSyncEnabled: true,
invoiceVerificationEnabled: true,
},
departmentId: "dept-b",
strictDepartment: true,
});
const allowed = await evaluateVerificationAccess({
prisma,
config: {
erpSyncEnabled: true,
invoiceVerificationEnabled: true,
},
departmentId: "dept-a",
strictDepartment: true,
});
expect(blocked.enabled).toBe(false);
expect(blocked.reason).toBe("department_not_allowed");
expect(allowed.enabled).toBe(true);
expect(allowed.reason).toBe("flag_force_on_department_allowed");
});
});
describe("verifyInvoiceByProvider", () => {
const invoice = {
invoice_code: "INV-001",
invoice_number: "NO-001",
total_amount: "100.00",
remarks: "ok",
};
it("uses mock provider and returns passed", async () => {
const result = await verifyInvoiceByProvider({
config: { verificationProvider: "mock" },
invoice,
});
expect(result.status).toBe("passed");
expect(result.resultCode).toBe("verified");
});
it("uses tax provider and returns provider-not-configured", async () => {
const result = await verifyInvoiceByProvider({
config: { verificationProvider: "tax" },
invoice,
});
expect(result.status).toBe("error");
expect(result.resultCode).toBe("tax_provider_not_configured");
});
it("returns unsupported provider error", async () => {
const result = await verifyInvoiceByProvider({
config: { verificationProvider: "unknown" },
invoice,
});
expect(result.status).toBe("error");
expect(result.resultCode).toBe("provider_unsupported");
});
});
@@ -0,0 +1,12 @@
const { getProvider } = require("../../src/services/verification");
describe("getProvider", () => {
it("uses mock as default provider", () => {
expect(getProvider({})).toBe("mock");
});
it("normalizes case and whitespace", () => {
expect(getProvider({ verificationProvider: " TAX " })).toBe("tax");
expect(getProvider({ verificationProvider: "MoCk" })).toBe("mock");
});
});
+12
查看文件
@@ -0,0 +1,12 @@
module.exports = {
test: {
environment: "node",
globals: true,
include: ["tests/unit/**/*.test.js"],
coverage: {
provider: "v8",
reportsDirectory: "coverage",
reporter: ["text", "json-summary", "html"],
},
},
};