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
这个提交包含在:
@@ -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,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户