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