feat: 完成P13治理收尾并创建P14执行合同(Issue: P13-1,P13-2,P13-3,P13-4,P13-5,P13-6,P13-7,P13-8,P14-0)
这个提交包含在:
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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 版本。
|
||||||
@@ -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%)时,暂停高风险灰度并补回归样本。
|
||||||
@@ -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
|
||||||
|
@@ -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后关闭
|
||||||
|
可执行文件
+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())
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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" });
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
在新工单中引用
屏蔽一个用户