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

这个提交包含在:
2026-02-13 00:25:04 +08:00
父节点 3b221a2202
当前提交 1412fead5d
共修改 30 个文件,包含 3988 行新增和 105 行删除
+2
查看文件
@@ -25,6 +25,8 @@ MAX_UPLOAD_SIZE_BYTES="10485760"
# ===== 单域托管 client/dist(试运行/生产建议)=====
# SERVE_CLIENT_DIST="true"
# CLIENT_DIST_DIR="../client/dist"
# OpenAPI 文档端点(建议仅非生产开启)
# OPENAPI_DOCS_ENABLED="false"
# ===== 安全基线(默认:production=true,其它环境=false,可显式覆盖)=====
# RATE_LIMIT_ENABLED="true"
+2184 -5
查看文件
文件差异内容过多而无法显示 加载差异
+10 -4
查看文件
@@ -8,7 +8,7 @@
"dev": "node src/index.js",
"start": "node src/index.js",
"worker": "node src/worker.js",
"test": "echo \"Error: no test specified\" && exit 1",
"test": "vitest run",
"prisma:format": "prisma format",
"prisma:validate": "prisma validate",
"prisma:generate": "prisma generate",
@@ -22,7 +22,9 @@
"job:ocr-once": "node src/scripts/run-ocr-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: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": [],
"author": "",
@@ -51,9 +53,13 @@
"prisma": "^7.3.0",
"tencentcloud-sdk-nodejs": "^4.1.14",
"zod": "^4.3.6",
"zxcvbn": "^4.4.2"
"zxcvbn": "^4.4.2",
"@fastify/swagger": "^9.5.2",
"@fastify/swagger-ui": "^5.2.3"
},
"devDependencies": {
"dotenv": "^16.4.5"
"dotenv": "^16.4.5",
"vitest": "^2.1.8",
"@vitest/coverage-v8": "^2.1.8"
}
}
+175
查看文件
@@ -0,0 +1,175 @@
#!/usr/bin/env bash
RR_ENABLED=0
RR_OUTPUT_PATH=""
RR_TMP_DIR=""
RR_STEPS_TSV=""
RR_ROOT_DIR=""
RR_SCHEMA_VERSION="1.0"
rr_now_iso() {
date -u +"%Y-%m-%dT%H:%M:%SZ"
}
rr_now_ms() {
if date +%s%3N >/dev/null 2>&1; then
date +%s%3N
else
echo "$(( $(date +%s) * 1000 ))"
fi
}
rr_sanitize() {
local value="${1//$'\t'/ }"
value="${value//$'\n'/ }"
printf '%s' "$value"
}
rr_prepare() {
local output_path="$1"
local root_dir="$2"
RR_ENABLED=1
RR_OUTPUT_PATH="$output_path"
RR_ROOT_DIR="$root_dir"
RR_TMP_DIR="$(mktemp -d)"
RR_STEPS_TSV="$RR_TMP_DIR/steps.tsv"
: > "$RR_STEPS_TSV"
}
rr_record_step() {
if [[ "$RR_ENABLED" != "1" ]]; then
return 0
fi
local step_id="$1"
local name="$2"
local enabled="$3"
local command="$4"
local status="$5"
local exit_code="$6"
local started_at="$7"
local ended_at="$8"
local duration_ms="$9"
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"$(rr_sanitize "$step_id")" \
"$(rr_sanitize "$name")" \
"$(rr_sanitize "$enabled")" \
"$(rr_sanitize "$command")" \
"$(rr_sanitize "$status")" \
"$(rr_sanitize "$exit_code")" \
"$(rr_sanitize "$started_at")" \
"$(rr_sanitize "$ended_at")" \
"$(rr_sanitize "$duration_ms")" \
>> "$RR_STEPS_TSV"
}
rr_git_sha() {
if command -v git >/dev/null 2>&1 && git -C "$RR_ROOT_DIR" rev-parse --short HEAD >/dev/null 2>&1; then
git -C "$RR_ROOT_DIR" rev-parse --short HEAD
else
echo "unknown"
fi
}
rr_git_branch() {
if command -v git >/dev/null 2>&1 && git -C "$RR_ROOT_DIR" rev-parse --abbrev-ref HEAD >/dev/null 2>&1; then
git -C "$RR_ROOT_DIR" rev-parse --abbrev-ref HEAD
else
echo "unknown"
fi
}
rr_finalize() {
if [[ "$RR_ENABLED" != "1" ]]; then
return 0
fi
local overall_status="$1"
local overall_duration_ms="$2"
local passed="$3"
local failed="$4"
local skipped="$5"
local total="$6"
local output_dir
output_dir="$(dirname "$RR_OUTPUT_PATH")"
mkdir -p "$output_dir"
local git_sha
local git_branch
local generated_at
git_sha="$(rr_git_sha)"
git_branch="$(rr_git_branch)"
generated_at="$(rr_now_iso)"
python3 - "$RR_STEPS_TSV" "$RR_OUTPUT_PATH" "$RR_SCHEMA_VERSION" "$generated_at" "$git_sha" "$git_branch" "$overall_status" "$overall_duration_ms" "$passed" "$failed" "$skipped" "$total" <<'PY'
import json
import sys
from pathlib import Path
steps_path = Path(sys.argv[1])
output_path = Path(sys.argv[2])
schema_version = sys.argv[3]
generated_at = sys.argv[4]
git_sha = sys.argv[5]
git_branch = sys.argv[6]
overall_status = sys.argv[7]
overall_duration_ms = int(sys.argv[8])
passed = int(sys.argv[9])
failed = int(sys.argv[10])
skipped = int(sys.argv[11])
total = int(sys.argv[12])
steps = []
if steps_path.exists():
for line in steps_path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
parts = line.split("\t")
if len(parts) != 9:
continue
step_id, name, enabled, command, status, exit_code, started_at, ended_at, duration_ms = parts
steps.append(
{
"id": step_id,
"name": name,
"enabled": enabled == "1",
"status": status,
"exit_code": int(exit_code),
"duration_ms": int(duration_ms),
"started_at": started_at,
"ended_at": ended_at,
"command": command,
}
)
payload = {
"schema_version": schema_version,
"generated_at": generated_at,
"git": {
"sha": git_sha,
"branch": git_branch,
},
"overall": {
"status": overall_status,
"duration_ms": overall_duration_ms,
"passed": passed,
"failed": failed,
"skipped": skipped,
"total": total,
},
"steps": steps,
}
output_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
PY
}
rr_cleanup() {
if [[ -n "$RR_TMP_DIR" && -d "$RR_TMP_DIR" ]]; then
rm -rf "$RR_TMP_DIR"
fi
}
+128 -82
查看文件
@@ -2,6 +2,11 @@
set -euo pipefail
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_OCR_REGRESSION="${RUN_OCR_REGRESSION:-1}"
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_FEATURE_FLAGS_REGRESSION="${RUN_FEATURE_FLAGS_REGRESSION:-0}"
echo "=== [1/7] Smoke Test ==="
"$SCRIPT_DIR/smoke-test.sh"
echo ""
JSON_REPORT_PATH=""
echo "=== [2/7] Dedup Regression ==="
"$SCRIPT_DIR/regression-dedup.sh"
echo ""
usage() {
cat <<'USAGE'
Usage: ./scripts/regression-all.sh [--json-report <path>]
echo "=== [3/7] Workflow Actions Regression ==="
"$SCRIPT_DIR/regression-workflow-actions.sh"
echo ""
Options:
--json-report <path> Write structured JSON report (schema_version=1.0).
USAGE
}
if [[ "$RUN_AUDIT_REGRESSION" == "1" ]]; then
echo "=== [4/7] Audit Regression ==="
"$SCRIPT_DIR/regression-audit.sh"
echo ""
else
echo "=== [4/7] Audit Regression (skipped, RUN_AUDIT_REGRESSION=$RUN_AUDIT_REGRESSION) ==="
echo ""
while [[ $# -gt 0 ]]; do
case "$1" in
--json-report)
if [[ $# -lt 2 || -z "${2:-}" ]]; then
echo "[ERROR] --json-report requires a file path" >&2
exit 2
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
if [[ "$RUN_OCR_REGRESSION" == "1" ]]; then
echo "=== [5/7] OCR Regression ==="
"$SCRIPT_DIR/regression-ocr.sh"
echo ""
else
echo "=== [5/7] OCR Regression (skipped, RUN_OCR_REGRESSION=$RUN_OCR_REGRESSION) ==="
echo ""
TOTAL_STEPS=12
PASSED_STEPS=0
FAILED_STEPS=0
SKIPPED_STEPS=0
RUN_STARTED_MS="$(rr_now_ms)"
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
if [[ "$RUN_ORG_SETTINGS_REGRESSION" == "1" ]]; then
echo "=== [6/7] Org + Settings Regression ==="
"$SCRIPT_DIR/regression-org-settings.sh"
echo ""
else
echo "=== [6/7] Org + Settings Regression (skipped, RUN_ORG_SETTINGS_REGRESSION=$RUN_ORG_SETTINGS_REGRESSION) ==="
echo ""
if [[ -n "$JSON_REPORT_PATH" ]]; then
rr_finalize "$OVERALL_STATUS" "$OVERALL_DURATION_MS" "$PASSED_STEPS" "$FAILED_STEPS" "$SKIPPED_STEPS" "$TOTAL_STEPS"
fi
if [[ "$RUN_AUTH_SECURITY_REGRESSION" == "1" ]]; then
echo "=== [7/7] Auth Security Regression ==="
"$SCRIPT_DIR/regression-auth-security.sh"
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 ""
if [[ "$FAILED_STEPS" -gt 0 ]]; then
echo "[ERROR] Regressions finished with failures (passed=$PASSED_STEPS failed=$FAILED_STEPS skipped=$SKIPPED_STEPS)" >&2
exit 1
fi
echo "[OK] All regressions passed"
+54 -5
查看文件
@@ -14,6 +14,7 @@ const staticPlugin = require("@fastify/static");
const { getConfig } = require("./config");
const authPlugin = require("./plugins/auth");
const prismaPlugin = require("./plugins/prisma");
const swaggerDocsPlugin = require("./plugins/swagger");
const authRoutes = require("./routes/auth");
const meRoutes = require("./routes/me");
const reimbursementRoutes = require("./routes/reimbursements");
@@ -38,6 +39,7 @@ const travelOrdersRoutes = require("./routes/travelOrders");
const { startJobs } = require("./jobs/jobSystem");
const { initMetrics } = require("./observability");
const { AppError } = require("./utils/AppError");
const { buildDataEnvelopeSchema, buildErrorEnvelopeSchema } = require("./utils/openapi");
async function buildApp() {
const config = getConfig();
@@ -180,11 +182,57 @@ async function buildApp() {
await app.register(prismaPlugin);
await app.register(authPlugin);
app.get("/health", async (request, reply) => {
return reply.send({ requestId: request.id, data: { ok: true } });
});
if (config.nodeEnv !== "production" && config.openapiDocsEnabled) {
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 {
await app.prisma.$queryRaw`SELECT 1`;
return reply.send({ requestId: request.id, data: { ok: true } });
@@ -197,7 +245,8 @@ async function buildApp() {
details: { db: "unreachable" },
});
}
});
},
);
if (config.metricsEnabled) {
initMetrics(app);
+1
查看文件
@@ -66,6 +66,7 @@ function getConfig() {
s3SecretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? "",
s3ForcePathStyle: process.env.S3_FORCE_PATH_STYLE === "true",
serveClientDist: process.env.SERVE_CLIENT_DIST === "true",
openapiDocsEnabled: process.env.OPENAPI_DOCS_ENABLED === "true",
clientDistDir: path.resolve(serverRoot, clientDistDir),
signedUrlTtlSeconds: Number(process.env.SIGNED_URL_TTL_SECONDS ?? 600),
maxUploadSizeBytes: Number(process.env.MAX_UPLOAD_SIZE_BYTES ?? 10 * 1024 * 1024),
+37
查看文件
@@ -0,0 +1,37 @@
const fp = require("fastify-plugin");
const fastifySwagger = require("@fastify/swagger");
const fastifySwaggerUi = require("@fastify/swagger-ui");
const pkg = require("../../package.json");
async function swaggerPlugin(fastify) {
await fastify.register(fastifySwagger, {
openapi: {
openapi: "3.0.3",
info: {
title: "hyb-oa API",
description: "hyb-oa 后端接口文档(治理链路优先)",
version: pkg.version,
},
components: {
securitySchemes: {
bearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
},
},
},
},
});
await fastify.register(fastifySwaggerUi, {
routePrefix: "/api/docs",
staticCSP: true,
uiConfig: {
docExpansion: "list",
deepLinking: false,
},
});
}
module.exports = fp(swaggerPlugin, { name: "swagger-docs" });
+130
查看文件
@@ -3,6 +3,10 @@ const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit");
const { sendData } = require("../utils/reply");
const {
buildDataEnvelopeSchema,
buildErrorEnvelopeSchema,
} = require("../utils/openapi");
const {
FEATURE_FLAG_KEY_LIST,
OVERRIDE_MODE_LIST,
@@ -24,6 +28,49 @@ const UPDATE_BODY_SCHEMA = z.object({
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) {
const parsed = KEY_PARAM_SCHEMA.safeParse(params ?? {});
if (!parsed.success) {
@@ -66,6 +113,31 @@ async function featureFlagsRoutes(fastify) {
"/system/feature-flags",
{
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) => {
const items = await listFeatureFlags(fastify.prisma, fastify.config);
@@ -77,6 +149,64 @@ async function featureFlagsRoutes(fastify) {
"/system/feature-flags/:key",
{
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) => {
const key = parseKey(request.params);
+63 -1
查看文件
@@ -3,16 +3,74 @@ const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const {
buildDataEnvelopeSchema,
buildErrorEnvelopeSchema,
} = require("../utils/openapi");
const { enqueueIntegrationEvent } = require("../services/integrations/outbox");
const { buildErpPayload } = require("../services/integrations/erp");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
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) {
fastify.post(
"/integrations/erp/sync/:reimbursementId",
{
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) => {
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) {
await runIntegrationOutboxOnce(fastify.prisma, fastify.config, fastify.log);
}
@@ -2,6 +2,7 @@ const { z } = require("zod");
const { AppError } = require("../../utils/AppError");
const { sendData } = require("../../utils/reply");
const { buildDataEnvelopeSchema, buildErrorEnvelopeSchema, UUID_PARAM_SCHEMA } = require("../../utils/openapi");
const { buildAuditLogData } = require("../../utils/audit");
const { getProvider, evaluateVerificationAccess } = require("../../services/verification");
@@ -9,6 +10,27 @@ const { mapVerificationRecord } = require("./shared/mappers");
const { parseVerificationListQuery } = require("./shared/schemas");
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) {
throw new AppError({
code: "VALID_001",
@@ -89,6 +111,31 @@ function registerInvoiceVerificationRoutes(fastify) {
"/invoice-pool/:id/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) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
@@ -319,6 +366,31 @@ function registerInvoiceVerificationRoutes(fastify) {
"/invoice-pool/:id/verification/retry",
{
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) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
+89
查看文件
@@ -0,0 +1,89 @@
const REQUEST_ID_SCHEMA = {
type: "string",
description: "服务端生成的请求追踪ID",
};
const ERROR_DETAILS_SCHEMA = {
type: "object",
additionalProperties: true,
};
const ERROR_BODY_SCHEMA = {
type: "object",
required: ["code", "message", "details"],
properties: {
code: { type: "string" },
message: { type: "string" },
details: ERROR_DETAILS_SCHEMA,
},
};
function buildDataEnvelopeSchema(dataSchema, description = "OK") {
return {
description,
type: "object",
required: ["requestId", "data"],
properties: {
requestId: REQUEST_ID_SCHEMA,
data: dataSchema,
},
};
}
function buildErrorEnvelopeSchema({
description = "业务错误",
codeExample = "VALID_001",
messageExample = "参数校验失败",
detailsExample,
} = {}) {
const schema = {
description,
type: "object",
required: ["requestId", "error"],
properties: {
requestId: REQUEST_ID_SCHEMA,
error: {
...ERROR_BODY_SCHEMA,
properties: {
...ERROR_BODY_SCHEMA.properties,
code: {
...ERROR_BODY_SCHEMA.properties.code,
example: codeExample,
},
message: {
...ERROR_BODY_SCHEMA.properties.message,
example: messageExample,
},
details: {
...ERROR_DETAILS_SCHEMA,
},
},
},
},
};
if (detailsExample !== undefined) {
schema.properties.error.properties.details.example = detailsExample;
}
return schema;
}
const UUID_PARAM_SCHEMA = {
type: "object",
required: ["id"],
properties: {
id: {
type: "string",
format: "uuid",
},
},
};
module.exports = {
REQUEST_ID_SCHEMA,
ERROR_BODY_SCHEMA,
buildDataEnvelopeSchema,
buildErrorEnvelopeSchema,
UUID_PARAM_SCHEMA,
};
@@ -0,0 +1,201 @@
const {
FEATURE_FLAG_KEYS,
evaluateFeatureFlag,
listFeatureFlags,
} = require("../../src/services/featureFlags");
function buildRecord({
flagKey,
overrideMode = "force_on",
rolloutType = "global",
departmentIds = [],
}) {
const now = new Date("2026-02-12T00:00:00.000Z");
return {
id: `flag-${flagKey}`,
flag_key: flagKey,
override_mode: overrideMode,
rollout_type: rolloutType,
department_ids: departmentIds,
note: "unit-test",
version: 1,
created_at: now,
created_by: null,
updated_at: now,
updated_by: null,
};
}
function buildPrisma(recordMap = {}) {
return {
featureFlag: {
findUnique: async ({ where }) => recordMap[where.flag_key] ?? null,
findMany: async () => Object.values(recordMap),
},
};
}
describe("evaluateFeatureFlag", () => {
it("returns env_hard_off when env gate is closed", async () => {
const result = await evaluateFeatureFlag({
prisma: buildPrisma(),
config: { erpSyncEnabled: false, invoiceVerificationEnabled: true },
flagKey: FEATURE_FLAG_KEYS.erpSync,
});
expect(result.enabled).toBe(false);
expect(result.reason).toBe("env_hard_off");
});
it("returns missing_flag_record when record does not exist", async () => {
const result = await evaluateFeatureFlag({
prisma: buildPrisma(),
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true },
flagKey: FEATURE_FLAG_KEYS.erpSync,
});
expect(result.enabled).toBe(false);
expect(result.reason).toBe("missing_flag_record");
});
it("returns flag_force_off when override mode is force_off", async () => {
const prisma = buildPrisma({
[FEATURE_FLAG_KEYS.erpSync]: buildRecord({
flagKey: FEATURE_FLAG_KEYS.erpSync,
overrideMode: "force_off",
}),
});
const result = await evaluateFeatureFlag({
prisma,
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true },
flagKey: FEATURE_FLAG_KEYS.erpSync,
});
expect(result.enabled).toBe(false);
expect(result.reason).toBe("flag_force_off");
});
it("returns global enabled when inherit + global", async () => {
const prisma = buildPrisma({
[FEATURE_FLAG_KEYS.erpSync]: buildRecord({
flagKey: FEATURE_FLAG_KEYS.erpSync,
overrideMode: "inherit",
rolloutType: "global",
}),
});
const result = await evaluateFeatureFlag({
prisma,
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true },
flagKey: FEATURE_FLAG_KEYS.erpSync,
});
expect(result.enabled).toBe(true);
expect(result.reason).toBe("env_inherit_global_on");
});
it("returns allowlist guards for department rollout", async () => {
const prisma = buildPrisma({
[FEATURE_FLAG_KEYS.erpSync]: buildRecord({
flagKey: FEATURE_FLAG_KEYS.erpSync,
rolloutType: "department_allowlist",
departmentIds: ["dept-a"],
}),
});
const missingDepartment = await evaluateFeatureFlag({
prisma,
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true },
flagKey: FEATURE_FLAG_KEYS.erpSync,
strictDepartment: true,
});
const blockedDepartment = await evaluateFeatureFlag({
prisma,
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true },
flagKey: FEATURE_FLAG_KEYS.erpSync,
departmentId: "dept-b",
strictDepartment: true,
});
const allowedDepartment = await evaluateFeatureFlag({
prisma,
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true },
flagKey: FEATURE_FLAG_KEYS.erpSync,
departmentId: "dept-a",
strictDepartment: true,
});
expect(missingDepartment.reason).toBe("department_missing");
expect(blockedDepartment.reason).toBe("department_not_allowed");
expect(allowedDepartment.reason).toBe("flag_force_on_department_allowed");
expect(allowedDepartment.enabled).toBe(true);
});
it("returns department_deferred when strictDepartment is false", async () => {
const prisma = buildPrisma({
[FEATURE_FLAG_KEYS.invoiceVerification]: buildRecord({
flagKey: FEATURE_FLAG_KEYS.invoiceVerification,
rolloutType: "department_allowlist",
departmentIds: ["dept-a"],
}),
});
const result = await evaluateFeatureFlag({
prisma,
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true },
flagKey: FEATURE_FLAG_KEYS.invoiceVerification,
strictDepartment: false,
});
expect(result.enabled).toBe(true);
expect(result.reason).toBe("department_deferred");
});
it("returns department_allowlist_empty when allowlist has no items", async () => {
const prisma = buildPrisma({
[FEATURE_FLAG_KEYS.invoiceVerification]: buildRecord({
flagKey: FEATURE_FLAG_KEYS.invoiceVerification,
rolloutType: "department_allowlist",
departmentIds: [],
}),
});
const result = await evaluateFeatureFlag({
prisma,
config: { erpSyncEnabled: true, invoiceVerificationEnabled: true },
flagKey: FEATURE_FLAG_KEYS.invoiceVerification,
strictDepartment: true,
});
expect(result.enabled).toBe(false);
expect(result.reason).toBe("department_allowlist_empty");
});
});
describe("listFeatureFlags", () => {
it("returns all known keys even if part of records are missing", async () => {
const prisma = buildPrisma({
[FEATURE_FLAG_KEYS.erpSync]: buildRecord({
flagKey: FEATURE_FLAG_KEYS.erpSync,
overrideMode: "inherit",
}),
});
const items = await listFeatureFlags(prisma, {
erpSyncEnabled: true,
invoiceVerificationEnabled: true,
});
expect(items).toHaveLength(2);
expect(items.map((item) => item.flagKey).sort()).toEqual([
FEATURE_FLAG_KEYS.erpSync,
FEATURE_FLAG_KEYS.invoiceVerification,
]);
const missingItem = items.find((item) => item.flagKey === FEATURE_FLAG_KEYS.invoiceVerification);
expect(missingItem.id).toBeNull();
expect(missingItem.effectiveEnabled).toBe(false);
});
});
@@ -0,0 +1,18 @@
const { sanitizeDepartmentIds } = require("../../src/services/featureFlags");
describe("sanitizeDepartmentIds", () => {
it("returns empty array when input is not array", () => {
expect(sanitizeDepartmentIds(null)).toEqual([]);
expect(sanitizeDepartmentIds("dept")).toEqual([]);
});
it("trims ids, removes empty values and deduplicates", () => {
const value = [" dept-a ", "dept-b", "", "dept-a", " "];
expect(sanitizeDepartmentIds(value)).toEqual(["dept-a", "dept-b"]);
});
it("filters non-string items", () => {
const value = ["dept-a", 1, true, { id: "dept-b" }, "dept-c"];
expect(sanitizeDepartmentIds(value)).toEqual(["dept-a", "dept-c"]);
});
});
@@ -0,0 +1,106 @@
const {
IntegrationDispatchError,
dispatchOutboxEvent,
} = require("../../src/services/integrations/dispatcher");
describe("dispatchOutboxEvent", () => {
it("handles mock ERP success", async () => {
const result = await dispatchOutboxEvent({
config: {
erpSyncEnabled: true,
erpSyncProvider: "mock",
},
eventType: "erp.reimbursement.paid",
payload: {
reimbursementId: "rb-1",
formNumber: "BX-001",
},
});
expect(result.channel).toBe("erp");
expect(result.code).toBe("mock_synced");
expect(result.response.externalId).toContain("BX-001");
});
it("maps ERP disabled as non-retryable dispatch error", async () => {
await expect(
dispatchOutboxEvent({
config: {
erpSyncEnabled: false,
erpSyncProvider: "mock",
},
eventType: "erp.reimbursement.paid",
payload: {
reimbursementId: "rb-2",
formNumber: "BX-002",
},
}),
).rejects.toMatchObject({
name: "IntegrationDispatchError",
code: "erp_sync_disabled",
retryable: false,
});
});
it("maps invalid ERP provider as non-retryable dispatch error", async () => {
await expect(
dispatchOutboxEvent({
config: {
erpSyncEnabled: true,
erpSyncProvider: "invalid-provider",
},
eventType: "erp.reimbursement.paid",
payload: {
reimbursementId: "rb-3",
formNumber: "BX-003",
},
}),
).rejects.toMatchObject({
name: "IntegrationDispatchError",
code: "erp_sync_provider_invalid",
retryable: false,
});
});
it("handles IM notification outbox", async () => {
const result = await dispatchOutboxEvent({
config: {},
eventType: "notification.im",
payload: {
channelType: "wecom",
},
});
expect(result.channel).toBe("im");
expect(result.code).toBe("mock_sent");
expect(result.response.channelType).toBe("wecom");
});
it("throws non-retryable error for unsupported event", async () => {
await expect(
dispatchOutboxEvent({
config: { erpSyncEnabled: true, erpSyncProvider: "mock" },
eventType: "unsupported.event",
payload: {},
}),
).rejects.toMatchObject({
name: "IntegrationDispatchError",
code: "unsupported_outbox_event",
retryable: false,
});
});
it("preserves error class semantics", () => {
const error = new IntegrationDispatchError({
code: "unit_test",
message: "unit test",
retryable: true,
statusCode: 503,
});
expect(error).toBeInstanceOf(Error);
expect(error.code).toBe("unit_test");
expect(error.retryable).toBe(true);
expect(error.statusCode).toBe(503);
});
});
@@ -0,0 +1,123 @@
const {
FEATURE_FLAG_KEYS,
} = require("../../src/services/featureFlags");
const {
evaluateVerificationAccess,
verifyInvoiceByProvider,
} = require("../../src/services/verification");
function buildFlagRecord({
overrideMode = "force_on",
rolloutType = "global",
departmentIds = [],
}) {
const now = new Date("2026-02-12T00:00:00.000Z");
return {
id: "verification-flag",
flag_key: FEATURE_FLAG_KEYS.invoiceVerification,
override_mode: overrideMode,
rollout_type: rolloutType,
department_ids: departmentIds,
note: "unit-test",
version: 1,
created_at: now,
created_by: null,
updated_at: now,
updated_by: null,
};
}
function buildPrisma(record) {
return {
featureFlag: {
findUnique: async () => record,
},
};
}
describe("evaluateVerificationAccess", () => {
it("returns env_hard_off when env gate is closed", async () => {
const result = await evaluateVerificationAccess({
prisma: buildPrisma(buildFlagRecord({})),
config: {
erpSyncEnabled: true,
invoiceVerificationEnabled: false,
},
departmentId: "dept-a",
strictDepartment: true,
});
expect(result.enabled).toBe(false);
expect(result.reason).toBe("env_hard_off");
});
it("returns allowlist decision for department rollout", async () => {
const prisma = buildPrisma(
buildFlagRecord({ rolloutType: "department_allowlist", departmentIds: ["dept-a"] }),
);
const blocked = await evaluateVerificationAccess({
prisma,
config: {
erpSyncEnabled: true,
invoiceVerificationEnabled: true,
},
departmentId: "dept-b",
strictDepartment: true,
});
const allowed = await evaluateVerificationAccess({
prisma,
config: {
erpSyncEnabled: true,
invoiceVerificationEnabled: true,
},
departmentId: "dept-a",
strictDepartment: true,
});
expect(blocked.enabled).toBe(false);
expect(blocked.reason).toBe("department_not_allowed");
expect(allowed.enabled).toBe(true);
expect(allowed.reason).toBe("flag_force_on_department_allowed");
});
});
describe("verifyInvoiceByProvider", () => {
const invoice = {
invoice_code: "INV-001",
invoice_number: "NO-001",
total_amount: "100.00",
remarks: "ok",
};
it("uses mock provider and returns passed", async () => {
const result = await verifyInvoiceByProvider({
config: { verificationProvider: "mock" },
invoice,
});
expect(result.status).toBe("passed");
expect(result.resultCode).toBe("verified");
});
it("uses tax provider and returns provider-not-configured", async () => {
const result = await verifyInvoiceByProvider({
config: { verificationProvider: "tax" },
invoice,
});
expect(result.status).toBe("error");
expect(result.resultCode).toBe("tax_provider_not_configured");
});
it("returns unsupported provider error", async () => {
const result = await verifyInvoiceByProvider({
config: { verificationProvider: "unknown" },
invoice,
});
expect(result.status).toBe("error");
expect(result.resultCode).toBe("provider_unsupported");
});
});
@@ -0,0 +1,12 @@
const { getProvider } = require("../../src/services/verification");
describe("getProvider", () => {
it("uses mock as default provider", () => {
expect(getProvider({})).toBe("mock");
});
it("normalizes case and whitespace", () => {
expect(getProvider({ verificationProvider: " TAX " })).toBe("tax");
expect(getProvider({ verificationProvider: "MoCk" })).toBe("mock");
});
});
+12
查看文件
@@ -0,0 +1,12 @@
module.exports = {
test: {
environment: "node",
globals: true,
include: ["tests/unit/**/*.test.js"],
coverage: {
provider: "v8",
reportsDirectory: "coverage",
reporter: ["text", "json-summary", "html"],
},
},
};