feat: 完成P13治理收尾并创建P14执行合同(Issue: P13-1,P13-2,P13-3,P13-4,P13-5,P13-6,P13-7,P13-8,P14-0)
这个提交包含在:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 { 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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户