feat(P11-P12): 落地治理开关与移动端MVP(G0-G6/H1/M1-M4)

- 新增 Feature Flag 模型、评估服务与管理 API(erp_sync/invoice_verification)\n- 完成 ERP 与验真灰度收口(路由/服务/作业统一判定)\n- 补齐 pay 与手动 sync 的 department_id 灰度上下文\n- 增加管理后台开关页、PWA 基础设施与移动拍照/审批页面\n- 增加回归脚本、安全基线与压测脚本,更新文档与执行合同\n\nIssue: G0,G1,G2,G3,G4,G5,G6,H1,M1,M2,M3,M4
这个提交包含在:
2026-02-11 20:33:30 +08:00
父节点 3813d1955e
当前提交 3b221a2202
共修改 44 个文件,包含 6760 行新增和 74 行删除
+2
查看文件
@@ -57,6 +57,7 @@ OCR_TIMEOUT_MS="5000"
# ===== 发票验真(P6)=====
INVOICE_VERIFICATION_ENABLED="false"
# INVOICE_VERIFICATION_ENABLED 作为Env硬闸;在线灰度由 /api/system/feature-flags 管理
VERIFICATION_PROVIDER="mock" # mock|tax
VERIFICATION_POLL_CRON="* * * * *"
VERIFICATION_MAX_ATTEMPTS="3"
@@ -73,6 +74,7 @@ TENCENT_OCR_REGION="ap-beijing"
# ===== 预算与集成(P7-P8)=====
BUDGET_CONTROL_MODE="warn" # warn|block
ERP_SYNC_ENABLED="false"
# ERP_SYNC_ENABLED 作为Env硬闸;在线灰度由 /api/system/feature-flags 管理
ERP_SYNC_PROVIDER="mock" # mock|http
ERP_SYNC_ENDPOINT=""
ERP_SYNC_TIMEOUT_MS="5000"
+10
查看文件
@@ -459,6 +459,14 @@ npm run job:ocr-once
- `TRAVEL_SYNC_ENABLED=false`
- `IM_NOTIFIER_ENABLED=false`
### 9.1.1 Feature Flag 治理(P11)
- 新增统一管理 API:
- `GET /api/system/feature-flags`
- `PUT /api/system/feature-flags/:key`
- 首期纳管键:`erp_sync`、`invoice_verification`。
- 优先级规则:`Env硬闸` > `DB在线灰度`。即使 DB 放开,若 Env 为 false 仍会关闭。
- 部门灰度模式:`rolloutType=department_allowlist` 时,仅白名单部门生效。
建议:先在测试组织灰度开启,再逐步扩大范围。
### 9.2 新增 API 摘要
@@ -489,6 +497,7 @@ cd /home/shunli/Projects/hyb-oa/server
./scripts/regression-dashboard.sh
./scripts/regression-budget.sh
./scripts/regression-integrations.sh
./scripts/regression-feature-flags.sh
```
可通过总回归开关启用:
@@ -498,6 +507,7 @@ RUN_VERIFICATION_REGRESSION=1 \
RUN_DASHBOARD_REGRESSION=1 \
RUN_BUDGET_REGRESSION=1 \
RUN_INTEGRATIONS_REGRESSION=1 \
RUN_FEATURE_FLAGS_REGRESSION=1 \
./scripts/regression-all.sh
```
@@ -0,0 +1,31 @@
CREATE TABLE "feature_flags" (
"id" UUID NOT NULL,
"flag_key" VARCHAR(64) NOT NULL,
"override_mode" VARCHAR(16) NOT NULL DEFAULT 'force_off',
"rollout_type" VARCHAR(32) NOT NULL DEFAULT 'global',
"department_ids" JSONB,
"note" TEXT,
"version" INTEGER NOT NULL DEFAULT 1,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID,
"updated_at" TIMESTAMP(3) NOT NULL,
"updated_by" UUID,
CONSTRAINT "feature_flags_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "feature_flags_flag_key_key" ON "feature_flags"("flag_key");
CREATE INDEX "idx_feature_flags_key" ON "feature_flags"("flag_key");
INSERT INTO "feature_flags" (
"id",
"flag_key",
"override_mode",
"rollout_type",
"department_ids",
"note",
"version",
"created_at",
"updated_at"
) VALUES
('6eb0ff4b-912a-4d19-8e4f-c2365604a200'::uuid, 'erp_sync', 'force_off', 'global', '[]'::jsonb, 'bootstrap default: force_off', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('6eb0ff4b-912a-4d19-8e4f-c2365604a201'::uuid, 'invoice_verification', 'force_off', 'global', '[]'::jsonb, 'bootstrap default: force_off', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
+18
查看文件
@@ -608,6 +608,24 @@ model BudgetLedgerEntry {
@@map("budget_ledger_entries")
}
model FeatureFlag {
id String @id @default(uuid()) @db.Uuid
flag_key String @unique @db.VarChar(64)
override_mode String @default("force_off") @db.VarChar(16)
rollout_type String @default("global") @db.VarChar(32)
department_ids Json?
note String?
version Int @default(1)
created_at DateTime @default(now())
created_by String? @db.Uuid
updated_at DateTime @updatedAt
updated_by String? @db.Uuid
@@index([flag_key], map: "idx_feature_flags_key")
@@map("feature_flags")
}
model IntegrationOutbox {
id String @id @default(uuid()) @db.Uuid
event_type String @db.VarChar(64)
+88
查看文件
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${BASE_URL:-http://127.0.0.1:3000}"
DEVICE_FINGERPRINT="${DEVICE_FINGERPRINT:-benchmark-dashboard}"
ADMIN_IDENTIFIER="${ADMIN_IDENTIFIER:-finance_mgr@hyinda.com}"
ADMIN_PASSWORD="${ADMIN_PASSWORD:-FinanceMgrPassword1234}"
ITERATIONS="${ITERATIONS:-10}"
if ! command -v curl >/dev/null 2>&1; then
echo "[ERROR] curl not found" >&2
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "[ERROR] jq not found" >&2
exit 1
fi
TMP_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
login() {
local out_file="$1"
local status
status="$(curl -sS -o "$out_file" -w "%{http_code}" \
-X POST "$BASE_URL/api/auth/login" \
-H "content-type: application/json" \
-H "x-device-fingerprint: $DEVICE_FINGERPRINT" \
-d "{\"identifier\":\"$ADMIN_IDENTIFIER\",\"password\":\"$ADMIN_PASSWORD\"}")"
if [[ "$status" != "200" ]]; then
echo "[ERROR] login failed: HTTP $status" >&2
cat "$out_file" >&2
exit 1
fi
jq -er '.data.accessToken' "$out_file"
}
benchmark_endpoint() {
local endpoint="$1"
local token="$2"
local total_ms=0
local max_ms=0
local min_ms=999999
for _ in $(seq 1 "$ITERATIONS"); do
local body_file="$TMP_DIR/$(basename "$endpoint").json"
local elapsed
elapsed="$(curl -sS -o "$body_file" -w "%{time_total}" \
-H "authorization: Bearer $token" \
"$BASE_URL$endpoint")"
local elapsed_ms
elapsed_ms="$(python3 - <<PY
value = float("$elapsed")
print(int(round(value * 1000)))
PY
)"
total_ms=$((total_ms + elapsed_ms))
if (( elapsed_ms > max_ms )); then
max_ms=$elapsed_ms
fi
if (( elapsed_ms < min_ms )); then
min_ms=$elapsed_ms
fi
done
local avg_ms=$((total_ms / ITERATIONS))
echo "$endpoint avg=${avg_ms}ms min=${min_ms}ms max=${max_ms}ms iterations=$ITERATIONS"
}
echo "[1/2] Login"
TOKEN="$(login "$TMP_DIR/login.json")"
echo "[2/2] Dashboard endpoint benchmarks"
benchmark_endpoint "/api/dashboard/expense-overview" "$TOKEN"
benchmark_endpoint "/api/dashboard/trends" "$TOKEN"
benchmark_endpoint "/api/dashboard/top-categories" "$TOKEN"
benchmark_endpoint "/api/dashboard/approval-efficiency" "$TOKEN"
echo "[OK] Dashboard benchmark completed"
+10
查看文件
@@ -10,6 +10,7 @@ RUN_VERIFICATION_REGRESSION="${RUN_VERIFICATION_REGRESSION:-0}"
RUN_DASHBOARD_REGRESSION="${RUN_DASHBOARD_REGRESSION:-0}"
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"
@@ -95,4 +96,13 @@ else
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
echo "[OK] All regressions passed"
+102
查看文件
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${BASE_URL:-http://127.0.0.1:3000}"
DEVICE_FINGERPRINT="${DEVICE_FINGERPRINT:-regression-feature-flags}"
FINANCE_MGR_IDENTIFIER="${FINANCE_MGR_IDENTIFIER:-finance_mgr@hyinda.com}"
FINANCE_MGR_PASSWORD="${FINANCE_MGR_PASSWORD:-FinanceMgrPassword1234}"
if ! command -v curl >/dev/null 2>&1; then
echo "[ERROR] curl not found" >&2
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "[ERROR] jq not found" >&2
exit 1
fi
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
TMP_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
assert_status() {
local actual="$1"
local expected="$2"
local name="$3"
local body_file="${4:-}"
if [[ "$actual" != "$expected" ]]; then
echo "[ERROR] $name expected HTTP $expected but got $actual" >&2
if [[ -n "$body_file" && -f "$body_file" ]]; then
cat "$body_file" >&2
fi
exit 1
fi
}
login() {
local identifier="$1"
local password="$2"
local out_file="$3"
local status
status="$(curl -sS -o "$out_file" -w "%{http_code}" \
-X POST "$BASE_URL/api/auth/login" \
-H "content-type: application/json" \
-H "x-device-fingerprint: $DEVICE_FINGERPRINT" \
-d "{\"identifier\":\"$identifier\",\"password\":\"$password\"}")"
assert_status "$status" "200" "login" "$out_file"
jq -er '.data.accessToken' "$out_file"
}
api_json() {
local method="$1"
local url="$2"
local token="$3"
local out_file="$4"
local body="${5:-}"
if [[ -n "$body" ]]; then
curl -sS -o "$out_file" -w "%{http_code}" \
-X "$method" "$url" \
-H "authorization: Bearer $token" \
-H "content-type: application/json" \
-d "$body"
else
curl -sS -o "$out_file" -w "%{http_code}" \
-X "$method" "$url" \
-H "authorization: Bearer $token"
fi
}
echo "[1/4] Login finance_mgr"
TOKEN="$(login "$FINANCE_MGR_IDENTIFIER" "$FINANCE_MGR_PASSWORD" "$TMP_DIR/login.json")"
echo "[2/4] List feature flags"
status="$(api_json GET "$BASE_URL/api/system/feature-flags" "$TOKEN" "$TMP_DIR/list.json")"
assert_status "$status" "200" "feature-flags list" "$TMP_DIR/list.json"
jq -e '.data.items | length >= 2' "$TMP_DIR/list.json" >/dev/null
ERP_VERSION="$(jq -er '.data.items[] | select(.flagKey=="erp_sync") | .version' "$TMP_DIR/list.json")"
echo "[3/4] Update erp_sync to force_off/global"
status="$(api_json PUT "$BASE_URL/api/system/feature-flags/erp_sync" "$TOKEN" "$TMP_DIR/update.json" "{\"overrideMode\":\"force_off\",\"rolloutType\":\"global\",\"departmentIds\":[],\"note\":\"regression-force-off\",\"expectedVersion\":$ERP_VERSION}")"
assert_status "$status" "200" "feature-flags update" "$TMP_DIR/update.json"
jq -e '.data.item.flagKey == "erp_sync"' "$TMP_DIR/update.json" >/dev/null
jq -e '.data.item.overrideMode == "force_off"' "$TMP_DIR/update.json" >/dev/null
echo "[4/4] Verify stale version conflict"
status="$(api_json PUT "$BASE_URL/api/system/feature-flags/erp_sync" "$TOKEN" "$TMP_DIR/conflict.json" "{\"overrideMode\":\"force_off\",\"rolloutType\":\"global\",\"departmentIds\":[],\"note\":\"stale-version\",\"expectedVersion\":$ERP_VERSION}")"
if [[ "$status" != "409" ]]; then
echo "[ERROR] expected version conflict 409 but got $status" >&2
cat "$TMP_DIR/conflict.json" >&2
exit 1
fi
echo "[OK] Feature flags regression passed"
+47
查看文件
@@ -292,6 +292,50 @@ run_outbox_once() {
npm run --silent job:integration-outbox-once >"$TMP_DIR/outbox-once.log"
}
set_erp_flag() {
local mode="$1"
DATABASE_URL="$DATABASE_URL" MODE="$mode" node <<'NODE'
require("dotenv/config");
const { createPrismaClient } = require("./src/prismaClient");
(async () => {
const mode = process.env.MODE;
const { prisma, pool } = createPrismaClient();
await prisma.$connect();
try {
await prisma.featureFlag.upsert({
where: { flag_key: "erp_sync" },
create: {
flag_key: "erp_sync",
override_mode: mode,
rollout_type: "global",
department_ids: [],
note: "regression-integrations",
},
update: {
override_mode: mode,
rollout_type: "global",
department_ids: [],
note: "regression-integrations",
version: { increment: 1 },
},
});
} finally {
await prisma.$disconnect();
await pool.end();
}
})().catch((error) => {
console.error(error);
process.exit(1);
});
NODE
}
echo "[0/6] Ensure ERP feature flag force_on"
set_erp_flag "force_on"
assert_mode_semantics() {
local mode="$1"
local state_json="$2"
@@ -373,3 +417,6 @@ run_mode_case "timeout"
echo "[6/6] Done"
echo "[OK] Integrations regression passed"
echo "[post] Reset ERP feature flag force_off"
set_erp_flag "force_off"
+47
查看文件
@@ -153,6 +153,50 @@ const { getConfig } = require('./src/config');
NODE
}
set_verification_flag() {
local mode="$1"
DATABASE_URL="$DATABASE_URL" MODE="$mode" node <<'NODE'
require("dotenv/config");
const { createPrismaClient } = require("./src/prismaClient");
(async () => {
const mode = process.env.MODE;
const { prisma, pool } = createPrismaClient();
await prisma.$connect();
try {
await prisma.featureFlag.upsert({
where: { flag_key: "invoice_verification" },
create: {
flag_key: "invoice_verification",
override_mode: mode,
rollout_type: "global",
department_ids: [],
note: "regression-verification",
},
update: {
override_mode: mode,
rollout_type: "global",
department_ids: [],
note: "regression-verification",
version: { increment: 1 },
},
});
} finally {
await prisma.$disconnect();
await pool.end();
}
})().catch((error) => {
console.error(error);
process.exit(1);
});
NODE
}
echo "[0/6] Ensure verification feature flag force_on"
set_verification_flag "force_on"
echo "[1/6] Login finance_mgr"
TOKEN="$(login "$FINANCE_MGR_IDENTIFIER" "$FINANCE_MGR_PASSWORD" "$TMP_DIR/login.json")"
@@ -184,3 +228,6 @@ if [[ "$status" != "409" ]]; then
fi
echo "[OK] Verification regression passed"
echo "[post] Reset verification feature flag force_off"
set_verification_flag "force_off"
+31
查看文件
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
echo "[1/3] Check critical env defaults"
if [[ "${JWT_SECRET:-}" == "change_me" ]]; then
echo "[WARN] JWT_SECRET should not be change_me in non-dev environments"
fi
if [[ "${ERP_SYNC_ENABLED:-false}" != "false" ]]; then
echo "[WARN] ERP_SYNC_ENABLED is not false; ensure feature-flag governance is enabled"
fi
if [[ "${INVOICE_VERIFICATION_ENABLED:-false}" != "false" ]]; then
echo "[WARN] INVOICE_VERIFICATION_ENABLED is not false; ensure rollout is controlled"
fi
echo "[2/3] Basic static hardening checks"
node --check src/routes/auth.js
node --check src/routes/integrations.js
node --check src/routes/invoicePool/verification.js
node --check src/services/featureFlags/index.js
echo "[3/3] Dependency audit (non-blocking)"
if command -v npm >/dev/null 2>&1; then
npm audit --omit=dev --audit-level=high || true
fi
echo "[OK] Security baseline script completed"
+2
查看文件
@@ -28,6 +28,7 @@ const employeesRoutes = require("./routes/employees");
const expenseTypesRoutes = require("./routes/expenseTypes");
const configReadRoutes = require("./routes/configRead");
const approvalRulesRoutes = require("./routes/approvalRules");
const featureFlagsRoutes = require("./routes/featureFlags");
const notificationsRoutes = require("./routes/notifications");
const exportsRoutes = require("./routes/exports");
const dashboardRoutes = require("./routes/dashboard");
@@ -229,6 +230,7 @@ async function buildApp() {
await app.register(expenseTypesRoutes, { prefix: "/api" });
await app.register(configReadRoutes, { prefix: "/api" });
await app.register(approvalRulesRoutes, { prefix: "/api" });
await app.register(featureFlagsRoutes, { prefix: "/api" });
await app.register(notificationsRoutes, { prefix: "/api" });
await app.register(dashboardRoutes, { prefix: "/api" });
await app.register(budgetsRoutes, { prefix: "/api" });
+2
查看文件
@@ -91,6 +91,7 @@ function getConfig() {
ocrTimeoutMs: Number(process.env.OCR_TIMEOUT_MS ?? 5000),
invoiceVerificationEnabled: process.env.INVOICE_VERIFICATION_ENABLED === "true",
invoiceVerificationFlagEnv: process.env.INVOICE_VERIFICATION_ENABLED ?? "false",
verificationProvider: process.env.VERIFICATION_PROVIDER ?? "mock",
verificationPollCron: process.env.VERIFICATION_POLL_CRON ?? "* * * * *",
verificationMaxAttempts: Number(process.env.VERIFICATION_MAX_ATTEMPTS ?? 3),
@@ -102,6 +103,7 @@ function getConfig() {
budgetControlMode: process.env.BUDGET_CONTROL_MODE ?? "warn",
erpSyncEnabled: process.env.ERP_SYNC_ENABLED === "true",
erpSyncFlagEnv: process.env.ERP_SYNC_ENABLED ?? "false",
erpSyncProvider: process.env.ERP_SYNC_PROVIDER ?? "mock",
erpSyncEndpoint: process.env.ERP_SYNC_ENDPOINT ?? "",
erpSyncTimeoutMs: Number(process.env.ERP_SYNC_TIMEOUT_MS ?? 5000),
+14 -2
查看文件
@@ -5,6 +5,10 @@ const { archiveUnusedInvoices } = require("./invoiceArchiveJob");
const { runOcrOnce } = require("./ocrJob");
const { runVerificationForRecord } = require("./verificationJob");
const { runIntegrationOutboxOnce } = require("./integrationOutboxJob");
const {
FEATURE_FLAG_KEYS,
evaluateFeatureFlag,
} = require("../services/featureFlags");
const JOB_OCR_TICK = "ocr.tick";
const JOB_DELEGATION_REVOKE = "delegation.revoke-expired";
@@ -267,7 +271,14 @@ async function startPgBossJobs(fastify) {
await boss.unschedule(JOB_OCR_TICK).catch(() => {});
}
if (fastify.config.invoiceVerificationEnabled) {
const verificationScheduling = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.invoiceVerification,
strictDepartment: false,
});
if (verificationScheduling.enabled) {
await boss.schedule(JOB_INVOICE_VERIFY, fastify.config.verificationPollCron, null, {
tz: fastify.config.appTimezone,
singletonKey: JOB_INVOICE_VERIFY,
@@ -288,7 +299,8 @@ async function startPgBossJobs(fastify) {
schema: fastify.config.pgBossSchema,
ocrProvider: fastify.config.ocrProvider,
ocrCron: fastify.config.ocrPollCron,
verificationEnabled: fastify.config.invoiceVerificationEnabled,
verificationEnabled: verificationScheduling.enabled,
verificationFlagReason: verificationScheduling.reason,
verificationCron: fastify.config.verificationPollCron,
delegationCron: fastify.config.delegationRevokeCron,
invoiceArchiveCron: fastify.config.invoiceArchiveCron,
+45 -1
查看文件
@@ -1,4 +1,7 @@
const { verifyInvoiceByProvider } = require("../services/verification");
const {
evaluateVerificationAccess,
verifyInvoiceByProvider,
} = require("../services/verification");
function toResultStatus(status) {
if (["passed", "failed", "error"].includes(status)) {
@@ -32,6 +35,16 @@ async function runVerificationForRecord(prisma, config, recordId) {
total_amount: true,
remarks: true,
status: true,
owner_employee: {
select: {
department_id: true,
},
},
linked_reimbursement: {
select: {
department_id: true,
},
},
},
},
},
@@ -54,6 +67,37 @@ async function runVerificationForRecord(prisma, config, recordId) {
return { skipped: true, reason: "status_not_pending" };
}
const departmentId =
record.invoice.linked_reimbursement?.department_id ??
record.invoice.owner_employee?.department_id ??
null;
const access = await evaluateVerificationAccess({
prisma: tx,
config,
departmentId,
strictDepartment: true,
});
if (!access.enabled) {
await tx.invoiceVerificationRecord.update({
where: { id: recordId },
data: {
status: "error",
error_code: "verification_disabled_by_flag",
error_message: access.reason,
completed_at: new Date(),
},
});
return {
skipped: false,
status: "error",
durationMs: 0,
resultCode: "verification_disabled_by_flag",
};
}
const startedAt = Date.now();
const verification = await verifyInvoiceByProvider({
config,
+171
查看文件
@@ -0,0 +1,171 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit");
const { sendData } = require("../utils/reply");
const {
FEATURE_FLAG_KEY_LIST,
OVERRIDE_MODE_LIST,
ROLLOUT_TYPE_LIST,
evaluateFeatureFlag,
listFeatureFlags,
updateFeatureFlag,
} = require("../services/featureFlags");
const KEY_PARAM_SCHEMA = z.object({
key: z.enum(FEATURE_FLAG_KEY_LIST),
});
const UPDATE_BODY_SCHEMA = z.object({
overrideMode: z.enum(OVERRIDE_MODE_LIST),
rolloutType: z.enum(ROLLOUT_TYPE_LIST),
departmentIds: z.array(z.string().uuid()).default([]),
note: z.string().trim().max(500).optional().default(""),
expectedVersion: z.coerce.number().int().min(1),
});
function parseKey(params) {
const parsed = KEY_PARAM_SCHEMA.safeParse(params ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
return parsed.data.key;
}
function parseUpdateBody(body) {
const parsed = UPDATE_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
if (parsed.data.rolloutType !== "department_allowlist" && parsed.data.departmentIds.length > 0) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { departmentIds: "rollout_type_global_disallows_department_ids" },
});
}
return parsed.data;
}
async function featureFlagsRoutes(fastify) {
fastify.get(
"/system/feature-flags",
{
preHandler: fastify.requirePermission("system.settings"),
},
async (request, reply) => {
const items = await listFeatureFlags(fastify.prisma, fastify.config);
return sendData(reply, request, { items });
},
);
fastify.put(
"/system/feature-flags/:key",
{
preHandler: fastify.requirePermission("system.settings"),
},
async (request, reply) => {
const key = parseKey(request.params);
const body = parseUpdateBody(request.body);
const result = await updateFeatureFlag(fastify.prisma, {
flagKey: key,
overrideMode: body.overrideMode,
rolloutType: body.rolloutType,
departmentIds: body.departmentIds,
note: body.note,
expectedVersion: body.expectedVersion,
actorId: request.employee.id,
});
if (result.status === "not_found" || !result.current) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
if (result.status === "version_conflict") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: {
featureFlag: "version_conflict",
expectedVersion: body.expectedVersion,
currentVersion: result.current.version,
},
});
}
if (result.status !== "updated" || !result.updated) {
throw new AppError({
code: "SYS_001",
message: "服务器内部错误",
statusCode: 500,
});
}
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "FEATURE_FLAG_UPDATED",
targetType: "feature_flag",
targetId: result.updated.id,
extra: {
flagKey: key,
from: {
overrideMode: result.current.overrideMode,
rolloutType: result.current.rolloutType,
departmentIds: result.current.departmentIds,
note: result.current.note,
version: result.current.version,
},
to: {
overrideMode: result.updated.overrideMode,
rolloutType: result.updated.rolloutType,
departmentIds: result.updated.departmentIds,
note: result.updated.note,
version: result.updated.version,
},
},
}),
});
const effective = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey: key,
strictDepartment: false,
});
return sendData(reply, request, {
item: {
...result.updated,
envEnabled: effective.envEnabled,
effectiveEnabled: effective.enabled,
effectiveReason: effective.reason,
matchedDepartmentId: effective.matchedDepartmentId,
requiresDepartment: effective.requiresDepartment,
},
});
},
);
}
module.exports = featureFlagsRoutes;
+26 -9
查看文件
@@ -5,6 +5,7 @@ const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
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");
async function integrationsRoutes(fastify) {
@@ -14,15 +15,6 @@ async function integrationsRoutes(fastify) {
preHandler: fastify.requirePermission("integration.manage"),
},
async (request, reply) => {
if (!fastify.config.erpSyncEnabled) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { integration: "erp_sync_disabled" },
});
}
const parsedId = z.string().uuid().safeParse(request.params?.reimbursementId);
if (!parsedId.success) {
throw new AppError({
@@ -42,6 +34,7 @@ async function integrationsRoutes(fastify) {
form_number: true,
owner_id: true,
submitter_id: true,
department_id: true,
status: true,
total_amount: true,
payment_voucher_no: true,
@@ -67,6 +60,27 @@ async function integrationsRoutes(fastify) {
});
}
const erpGate = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.erpSync,
departmentId: reimbursement.department_id ?? request.employee.department_id ?? null,
strictDepartment: true,
});
if (!erpGate.enabled) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: {
integration: "feature_flag_blocked",
featureFlag: FEATURE_FLAG_KEYS.erpSync,
reason: erpGate.reason,
},
});
}
const payload = buildErpPayload({ reimbursement });
const enqueued = await enqueueIntegrationEvent(fastify.prisma, {
eventType: "erp.reimbursement.paid",
@@ -85,6 +99,9 @@ async function integrationsRoutes(fastify) {
extra: {
outboxId: enqueued.id,
duplicated: enqueued.duplicated,
featureFlag: FEATURE_FLAG_KEYS.erpSync,
departmentId: reimbursement.department_id ?? null,
featureFlagReason: erpGate.reason,
},
}),
});
+100 -36
查看文件
@@ -3,12 +3,87 @@ const { z } = require("zod");
const { AppError } = require("../../utils/AppError");
const { sendData } = require("../../utils/reply");
const { buildAuditLogData } = require("../../utils/audit");
const { getProvider, isVerificationEnabled } = require("../../services/verification");
const { getProvider, evaluateVerificationAccess } = require("../../services/verification");
const { mapVerificationRecord } = require("./shared/mappers");
const { parseVerificationListQuery } = require("./shared/schemas");
const { triggerVerificationJob } = require("./shared/helpers");
function throwVerificationDisabled(reason) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "disabled", featureFlag: reason },
});
}
async function resolveVerificationDepartmentContext(tx, request, invoiceId) {
const invoice = await tx.invoicePool.findUnique({
where: { id: invoiceId },
select: {
id: true,
status: true,
invoice_code: true,
invoice_number: true,
total_amount: true,
remarks: true,
owner_employee: {
select: {
id: true,
department_id: true,
},
},
linked_reimbursement: {
select: {
id: true,
department_id: true,
owner_id: true,
submitter_id: true,
},
},
},
});
if (!invoice) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const departmentId =
invoice.linked_reimbursement?.department_id ??
invoice.owner_employee?.department_id ??
request.employee.department_id ??
null;
return {
invoice,
departmentId,
};
}
async function ensureVerificationEnabledWithContext({ tx, fastify, request, invoiceId }) {
const context = await resolveVerificationDepartmentContext(tx, request, invoiceId);
const evaluation = await evaluateVerificationAccess({
prisma: tx,
config: fastify.config,
departmentId: context.departmentId,
strictDepartment: true,
});
if (!evaluation.enabled) {
throwVerificationDisabled(evaluation.reason);
}
return {
...context,
evaluation,
};
}
function registerInvoiceVerificationRoutes(fastify) {
fastify.post(
"/invoice-pool/:id/verify",
@@ -16,15 +91,6 @@ function registerInvoiceVerificationRoutes(fastify) {
preHandler: fastify.requirePermission("invoice.verify"),
},
async (request, reply) => {
if (!isVerificationEnabled(fastify.config)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "disabled" },
});
}
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
if (!parsedInvoiceId.success) {
throw new AppError({
@@ -54,24 +120,12 @@ function registerInvoiceVerificationRoutes(fastify) {
});
}
const invoice = await tx.invoicePool.findUnique({
where: { id: invoiceId },
select: {
id: true,
status: true,
invoice_code: true,
invoice_number: true,
total_amount: true,
remarks: true,
},
const { invoice, departmentId, evaluation } = await ensureVerificationEnabledWithContext({
tx,
fastify,
request,
invoiceId,
});
if (!invoice) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const latest = await tx.invoiceVerificationRecord.findFirst({
where: { invoice_id: invoiceId },
@@ -116,6 +170,8 @@ function registerInvoiceVerificationRoutes(fastify) {
invoiceCode: invoice.invoice_code ?? null,
invoiceNumber: invoice.invoice_number ?? null,
totalAmount: invoice.total_amount?.toString?.() ?? null,
departmentId,
featureFlagReason: evaluation.reason,
},
requested_by: request.employee.id,
requested_at: new Date(now),
@@ -135,6 +191,8 @@ function registerInvoiceVerificationRoutes(fastify) {
extra: {
verificationId: created.id,
provider,
departmentId,
featureFlagReason: evaluation.reason,
},
}),
});
@@ -142,6 +200,8 @@ function registerInvoiceVerificationRoutes(fastify) {
return {
verificationId: created.id,
provider,
departmentId,
featureFlagReason: evaluation.reason,
};
});
@@ -157,6 +217,8 @@ function registerInvoiceVerificationRoutes(fastify) {
extra: {
verificationId: response.verificationId,
status: trigger.result?.status ?? null,
departmentId: response.departmentId,
featureFlagReason: response.featureFlagReason,
},
}),
});
@@ -259,15 +321,6 @@ function registerInvoiceVerificationRoutes(fastify) {
preHandler: fastify.requirePermission("invoice.verify"),
},
async (request, reply) => {
if (!isVerificationEnabled(fastify.config)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { verification: "disabled" },
});
}
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
if (!parsedInvoiceId.success) {
throw new AppError({
@@ -282,6 +335,13 @@ function registerInvoiceVerificationRoutes(fastify) {
const provider = getProvider(fastify.config);
const created = await fastify.prisma.$transaction(async (tx) => {
const { departmentId, evaluation } = await ensureVerificationEnabledWithContext({
tx,
fastify,
request,
invoiceId,
});
const latest = await tx.invoiceVerificationRecord.findFirst({
where: { invoice_id: invoiceId },
orderBy: { created_at: "desc" },
@@ -344,6 +404,8 @@ function registerInvoiceVerificationRoutes(fastify) {
requested_at: new Date(),
request_payload: {
retryOfId: latest.id,
departmentId,
featureFlagReason: evaluation.reason,
},
},
select: {
@@ -361,6 +423,8 @@ function registerInvoiceVerificationRoutes(fastify) {
verificationId: createdRecord.id,
retryOfId: latest.id,
attempts,
departmentId,
featureFlagReason: evaluation.reason,
},
}),
});
+36 -8
查看文件
@@ -11,6 +11,7 @@ const {
const { sendNotificationEmailSafe } = require("../services/email");
const { enqueueIntegrationEvent } = require("../services/integrations/outbox");
const { buildErpPayload } = require("../services/integrations/erp");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const {
buildWorkflowStepsFromApprovalRule,
resolveActionableStep,
@@ -1691,7 +1692,7 @@ async function reimbursementRoutes(fastify) {
await fastify.prisma.$transaction(async (tx) => {
const reimbursementRows = await tx.$queryRaw`
SELECT id, status, form_number, owner_id, submitter_id,
SELECT id, status, form_number, owner_id, submitter_id, department_id,
payment_voucher_no, payment_date, payment_channel, paid_at, paid_by
FROM "reimbursement_forms"
WHERE id = ${reimbursementId}::uuid
@@ -1772,6 +1773,7 @@ async function reimbursementRoutes(fastify) {
form_number: true,
owner_id: true,
submitter_id: true,
department_id: true,
total_amount: true,
payment_voucher_no: true,
payment_date: true,
@@ -1779,14 +1781,40 @@ async function reimbursementRoutes(fastify) {
},
});
if (refreshed && fastify.config.erpSyncEnabled) {
await enqueueIntegrationEvent(tx, {
eventType: "erp.reimbursement.paid",
dedupeKey: refreshed.id,
payload: buildErpPayload({ reimbursement: refreshed }),
maxAttempts: fastify.config.integrationOutboxMaxAttempts,
actorId: request.employee.id,
if (refreshed) {
const departmentId =
refreshed.department_id ?? reimbursement.department_id ?? request.employee.department_id ?? null;
const erpGate = await evaluateFeatureFlag({
prisma: tx,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.erpSync,
departmentId,
strictDepartment: true,
});
if (erpGate.enabled) {
await enqueueIntegrationEvent(tx, {
eventType: "erp.reimbursement.paid",
dedupeKey: refreshed.id,
payload: buildErpPayload({ reimbursement: refreshed }),
maxAttempts: fastify.config.integrationOutboxMaxAttempts,
actorId: request.employee.id,
});
} else {
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "ERP_SYNC_SKIPPED_BY_FEATURE_FLAG",
targetType: "reimbursement_form",
targetId: refreshed.id,
extra: {
featureFlag: FEATURE_FLAG_KEYS.erpSync,
departmentId,
reason: erpGate.reason,
},
}),
});
}
}
try {
+390
查看文件
@@ -0,0 +1,390 @@
const FEATURE_FLAG_KEYS = {
erpSync: "erp_sync",
invoiceVerification: "invoice_verification",
};
const FEATURE_FLAG_KEY_LIST = Object.freeze(Object.values(FEATURE_FLAG_KEYS));
const OVERRIDE_MODE_LIST = Object.freeze(["inherit", "force_on", "force_off"]);
const ROLLOUT_TYPE_LIST = Object.freeze(["global", "department_allowlist"]);
function isKnownFeatureFlagKey(flagKey) {
return FEATURE_FLAG_KEY_LIST.includes(flagKey);
}
function normalizeOverrideMode(value) {
const mode = String(value ?? "force_off").trim().toLowerCase();
if (OVERRIDE_MODE_LIST.includes(mode)) {
return mode;
}
return "force_off";
}
function normalizeRolloutType(value) {
const type = String(value ?? "global").trim().toLowerCase();
if (ROLLOUT_TYPE_LIST.includes(type)) {
return type;
}
return "global";
}
function sanitizeDepartmentIds(value) {
if (!Array.isArray(value)) {
return [];
}
const seen = new Set();
const items = [];
for (const id of value) {
if (typeof id !== "string") {
continue;
}
const trimmed = id.trim();
if (!trimmed) {
continue;
}
if (!seen.has(trimmed)) {
seen.add(trimmed);
items.push(trimmed);
}
}
return items;
}
function resolveEnvGate(config, flagKey) {
if (flagKey === FEATURE_FLAG_KEYS.erpSync) {
return Boolean(config.erpSyncEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.invoiceVerification) {
return Boolean(config.invoiceVerificationEnabled);
}
return false;
}
function mapFlagRecord(record) {
if (!record) {
return null;
}
const departmentIds = sanitizeDepartmentIds(record.department_ids);
const overrideMode = normalizeOverrideMode(record.override_mode);
const rolloutType = normalizeRolloutType(record.rollout_type);
return {
id: record.id,
flagKey: record.flag_key,
overrideMode,
rolloutType,
departmentIds,
note: record.note ?? "",
version: Number(record.version ?? 1),
createdAt: record.created_at?.toISOString?.() ?? null,
createdBy: record.created_by ?? null,
updatedAt: record.updated_at?.toISOString?.() ?? null,
updatedBy: record.updated_by ?? null,
};
}
function buildDefaultRecord(flagKey) {
return {
id: null,
flagKey,
overrideMode: "force_off",
rolloutType: "global",
departmentIds: [],
note: "",
version: 1,
createdAt: null,
createdBy: null,
updatedAt: null,
updatedBy: null,
};
}
function withEvaluation(base, evaluation) {
return {
...base,
envEnabled: evaluation.envEnabled,
effectiveEnabled: evaluation.enabled,
effectiveReason: evaluation.reason,
matchedDepartmentId: evaluation.matchedDepartmentId,
requiresDepartment: evaluation.requiresDepartment,
};
}
async function getFlagRecord(prisma, flagKey) {
const row = await prisma.featureFlag.findUnique({
where: { flag_key: flagKey },
});
return mapFlagRecord(row);
}
async function evaluateFeatureFlag({
prisma,
config,
flagKey,
departmentId = null,
strictDepartment = true,
}) {
const envEnabled = resolveEnvGate(config, flagKey);
const normalizedDepartmentId =
typeof departmentId === "string" && departmentId.trim() ? departmentId.trim() : null;
if (!envEnabled) {
return {
flagKey,
enabled: false,
reason: "env_hard_off",
envEnabled,
matchedDepartmentId: null,
requiresDepartment: false,
};
}
const record = await getFlagRecord(prisma, flagKey);
if (!record) {
return {
flagKey,
enabled: false,
reason: "missing_flag_record",
envEnabled,
matchedDepartmentId: null,
requiresDepartment: false,
};
}
if (!OVERRIDE_MODE_LIST.includes(record.overrideMode)) {
return {
flagKey,
enabled: false,
reason: "invalid_override_mode",
envEnabled,
matchedDepartmentId: null,
requiresDepartment: false,
};
}
if (!ROLLOUT_TYPE_LIST.includes(record.rolloutType)) {
return {
flagKey,
enabled: false,
reason: "invalid_rollout_type",
envEnabled,
matchedDepartmentId: null,
requiresDepartment: false,
};
}
if (record.overrideMode === "force_off") {
return {
flagKey,
enabled: false,
reason: "flag_force_off",
envEnabled,
matchedDepartmentId: null,
requiresDepartment: false,
};
}
const modeEnabled = record.overrideMode === "force_on" || record.overrideMode === "inherit";
if (!modeEnabled) {
return {
flagKey,
enabled: false,
reason: "mode_not_enabled",
envEnabled,
matchedDepartmentId: null,
requiresDepartment: false,
};
}
if (record.rolloutType === "global") {
return {
flagKey,
enabled: true,
reason: record.overrideMode === "inherit" ? "env_inherit_global_on" : "flag_force_on_global",
envEnabled,
matchedDepartmentId: null,
requiresDepartment: false,
};
}
const allowlist = record.departmentIds;
if (!allowlist.length) {
return {
flagKey,
enabled: false,
reason: "department_allowlist_empty",
envEnabled,
matchedDepartmentId: null,
requiresDepartment: true,
};
}
if (!normalizedDepartmentId) {
if (strictDepartment) {
return {
flagKey,
enabled: false,
reason: "department_missing",
envEnabled,
matchedDepartmentId: null,
requiresDepartment: true,
};
}
return {
flagKey,
enabled: true,
reason: "department_deferred",
envEnabled,
matchedDepartmentId: null,
requiresDepartment: true,
};
}
const matched = allowlist.includes(normalizedDepartmentId);
if (!matched) {
return {
flagKey,
enabled: false,
reason: "department_not_allowed",
envEnabled,
matchedDepartmentId: normalizedDepartmentId,
requiresDepartment: true,
};
}
return {
flagKey,
enabled: true,
reason:
record.overrideMode === "inherit"
? "env_inherit_department_allowed"
: "flag_force_on_department_allowed",
envEnabled,
matchedDepartmentId: normalizedDepartmentId,
requiresDepartment: true,
};
}
async function listFeatureFlags(prisma, config) {
const rows = await prisma.featureFlag.findMany({
where: {
flag_key: { in: FEATURE_FLAG_KEY_LIST },
},
orderBy: { flag_key: "asc" },
});
const recordMap = new Map(rows.map((row) => [row.flag_key, mapFlagRecord(row)]));
const items = [];
for (const flagKey of FEATURE_FLAG_KEY_LIST) {
const base = recordMap.get(flagKey) ?? buildDefaultRecord(flagKey);
const evaluation = await evaluateFeatureFlag({
prisma,
config,
flagKey,
departmentId: null,
strictDepartment: false,
});
items.push(withEvaluation(base, evaluation));
}
return items;
}
async function updateFeatureFlag(prisma, input) {
const flagKey = String(input.flagKey ?? "").trim();
if (!isKnownFeatureFlagKey(flagKey)) {
return {
status: "not_found",
current: null,
updated: null,
};
}
const overrideMode = normalizeOverrideMode(input.overrideMode);
const rolloutType = normalizeRolloutType(input.rolloutType);
const departmentIds =
rolloutType === "department_allowlist" ? sanitizeDepartmentIds(input.departmentIds) : [];
return prisma.$transaction(async (tx) => {
const currentRow = await tx.featureFlag.findUnique({
where: { flag_key: flagKey },
});
if (!currentRow) {
return {
status: "not_found",
current: null,
updated: null,
};
}
const current = mapFlagRecord(currentRow);
if (Number(input.expectedVersion) !== current.version) {
return {
status: "version_conflict",
current,
updated: null,
};
}
const updateResult = await tx.featureFlag.updateMany({
where: {
id: current.id,
version: current.version,
},
data: {
override_mode: overrideMode,
rollout_type: rolloutType,
department_ids: departmentIds,
note: input.note ?? "",
version: {
increment: 1,
},
updated_by: input.actorId ?? null,
},
});
if (updateResult.count !== 1) {
const latest = await tx.featureFlag.findUnique({
where: { flag_key: flagKey },
});
return {
status: "version_conflict",
current: mapFlagRecord(latest),
updated: null,
};
}
const updatedRow = await tx.featureFlag.findUnique({
where: { flag_key: flagKey },
});
return {
status: "updated",
current,
updated: mapFlagRecord(updatedRow),
};
});
}
module.exports = {
FEATURE_FLAG_KEYS,
FEATURE_FLAG_KEY_LIST,
OVERRIDE_MODE_LIST,
ROLLOUT_TYPE_LIST,
isKnownFeatureFlagKey,
sanitizeDepartmentIds,
evaluateFeatureFlag,
listFeatureFlags,
updateFeatureFlag,
};
+24
查看文件
@@ -1,5 +1,6 @@
const { runMockVerification } = require("./mock");
const { runTaxVerification } = require("./tax");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../featureFlags");
function getProvider(config) {
return String(config.verificationProvider ?? "mock").trim().toLowerCase();
@@ -9,6 +10,28 @@ function isVerificationEnabled(config) {
return Boolean(config.invoiceVerificationEnabled);
}
async function evaluateVerificationAccess({
prisma,
config,
departmentId = null,
strictDepartment = true,
}) {
const evaluation = await evaluateFeatureFlag({
prisma,
config,
flagKey: FEATURE_FLAG_KEYS.invoiceVerification,
departmentId,
strictDepartment,
});
return {
enabled: evaluation.enabled,
reason: evaluation.reason,
matchedDepartmentId: evaluation.matchedDepartmentId,
envEnabled: evaluation.envEnabled,
};
}
async function verifyInvoiceByProvider({ config, invoice }) {
const provider = getProvider(config);
@@ -33,5 +56,6 @@ async function verifyInvoiceByProvider({ config, invoice }) {
module.exports = {
getProvider,
isVerificationEnabled,
evaluateVerificationAccess,
verifyInvoiceByProvider,
};
-11
查看文件
@@ -1,15 +1,4 @@
async function runTaxVerification({ config, invoice }) {
if (!config.invoiceVerificationEnabled) {
return {
status: "error",
resultCode: "verification_disabled",
resultMessage: "invoice verification disabled",
payload: {
provider: "tax",
},
};
}
return {
status: "error",
resultCode: "tax_provider_not_configured",