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
查看文件
@@ -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",