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,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;
|
||||
在新工单中引用
屏蔽一个用户