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 行删除
+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",