- Feature Flag 变更历史查看与影响范围预览 - Feature Flag 批量操作(启用/禁用/继承) - Cutover 控制台页面增强(摘要卡片、时间线、延迟生效) - 单测覆盖 changelog、batch、权限边界
765 行
21 KiB
JavaScript
765 行
21 KiB
JavaScript
const { z } = require("zod");
|
|
|
|
const { AppError } = require("../utils/AppError");
|
|
const { buildAuditLogData } = require("../utils/audit");
|
|
const { sendData } = require("../utils/reply");
|
|
const {
|
|
buildDataEnvelopeSchema,
|
|
buildErrorEnvelopeSchema,
|
|
} = require("../utils/openapi");
|
|
const {
|
|
FEATURE_FLAG_KEY_LIST,
|
|
OVERRIDE_MODE_LIST,
|
|
ROLLOUT_TYPE_LIST,
|
|
evaluateFeatureFlag,
|
|
listFeatureFlags,
|
|
sanitizeDepartmentIds,
|
|
updateFeatureFlag,
|
|
} = require("../services/featureFlags");
|
|
const {
|
|
DEFAULT_MODULE_VISIBILITY,
|
|
NAVIGATION_REGISTRY,
|
|
} = require("../services/workspaceSettings");
|
|
|
|
const KEY_PARAM_SCHEMA = z.object({
|
|
key: z.enum(FEATURE_FLAG_KEY_LIST),
|
|
});
|
|
|
|
const PAGE_QUERY_SCHEMA = z.object({
|
|
page: z.coerce.number().int().min(1).optional(),
|
|
pageSize: z.coerce.number().int().min(1).max(100).optional(),
|
|
});
|
|
|
|
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),
|
|
});
|
|
|
|
const BATCH_BODY_SCHEMA = z
|
|
.object({
|
|
items: z
|
|
.array(
|
|
z.object({
|
|
flagKey: z.enum(FEATURE_FLAG_KEY_LIST),
|
|
overrideMode: z.enum(OVERRIDE_MODE_LIST),
|
|
expectedVersion: z.coerce.number().int().min(1),
|
|
}),
|
|
)
|
|
.min(1),
|
|
note: z.string().trim().min(1).max(500),
|
|
})
|
|
.superRefine((value, ctx) => {
|
|
const seen = new Set();
|
|
for (const [index, item] of value.items.entries()) {
|
|
if (seen.has(item.flagKey)) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ["items", index, "flagKey"],
|
|
message: "duplicate_flag_key",
|
|
});
|
|
}
|
|
seen.add(item.flagKey);
|
|
}
|
|
});
|
|
|
|
const FEATURE_FLAG_ITEM_SCHEMA = {
|
|
type: "object",
|
|
required: [
|
|
"id",
|
|
"flagKey",
|
|
"overrideMode",
|
|
"rolloutType",
|
|
"departmentIds",
|
|
"note",
|
|
"version",
|
|
"createdAt",
|
|
"createdBy",
|
|
"updatedAt",
|
|
"updatedBy",
|
|
"envEnabled",
|
|
"effectiveEnabled",
|
|
"effectiveReason",
|
|
"matchedDepartmentId",
|
|
"requiresDepartment",
|
|
],
|
|
properties: {
|
|
id: { anyOf: [{ type: "string", format: "uuid" }, { type: "null" }] },
|
|
flagKey: { type: "string", enum: FEATURE_FLAG_KEY_LIST },
|
|
overrideMode: { type: "string", enum: OVERRIDE_MODE_LIST },
|
|
rolloutType: { type: "string", enum: ROLLOUT_TYPE_LIST },
|
|
departmentIds: {
|
|
type: "array",
|
|
items: { type: "string", format: "uuid" },
|
|
},
|
|
note: { type: "string" },
|
|
version: { type: "integer" },
|
|
createdAt: { anyOf: [{ type: "string", format: "date-time" }, { type: "null" }] },
|
|
createdBy: { anyOf: [{ type: "string", format: "uuid" }, { type: "null" }] },
|
|
updatedAt: { anyOf: [{ type: "string", format: "date-time" }, { type: "null" }] },
|
|
updatedBy: { anyOf: [{ type: "string", format: "uuid" }, { type: "null" }] },
|
|
envEnabled: { type: "boolean" },
|
|
effectiveEnabled: { type: "boolean" },
|
|
effectiveReason: { type: "string" },
|
|
matchedDepartmentId: { anyOf: [{ type: "string", format: "uuid" }, { type: "null" }] },
|
|
requiresDepartment: { type: "boolean" },
|
|
},
|
|
};
|
|
|
|
const FEATURE_FLAG_SNAPSHOT_SCHEMA = {
|
|
type: "object",
|
|
required: ["overrideMode", "rolloutType", "departmentIds", "note", "version"],
|
|
properties: {
|
|
overrideMode: { type: "string", enum: OVERRIDE_MODE_LIST },
|
|
rolloutType: { type: "string", enum: ROLLOUT_TYPE_LIST },
|
|
departmentIds: {
|
|
type: "array",
|
|
items: { type: "string", format: "uuid" },
|
|
},
|
|
note: { type: "string" },
|
|
version: { type: "integer" },
|
|
},
|
|
};
|
|
|
|
const BACKUP_NAVIGATION_KEYS = new Set([
|
|
"purchaseRequests",
|
|
"travelRequests",
|
|
"budgets",
|
|
"paymentBatches",
|
|
"apOperations",
|
|
"invoicePool",
|
|
"inventoryChecks",
|
|
"cutover",
|
|
"erpIntegrations",
|
|
"approvalRules",
|
|
"userManagement",
|
|
"departments",
|
|
"masterData",
|
|
"notificationChannels",
|
|
"workflowTasks",
|
|
"dashboard",
|
|
]);
|
|
|
|
const FEATURE_FLAG_BACKEND_IMPACT = Object.freeze({
|
|
ai_assist: [
|
|
{
|
|
label: "AI 建议服务",
|
|
routes: ["/api/ai/suggestions"],
|
|
},
|
|
],
|
|
erp_sync: [
|
|
{
|
|
label: "ERP 集成控制台",
|
|
routes: [
|
|
"/api/integrations/erp/master-data/status",
|
|
"/api/integrations/erp/master-data/sync",
|
|
"/api/integrations/outbox",
|
|
"/api/integrations/bindings",
|
|
],
|
|
},
|
|
{
|
|
label: "业务 ERP 同步入口",
|
|
routes: [
|
|
"/api/integrations/erp/journals/reimbursements/:reimbursementId/preview",
|
|
"/api/integrations/erp/journals/reimbursements/:reimbursementId/export",
|
|
"/api/integrations/erp/procurement/purchase-requests/:purchaseRequestId/track",
|
|
"/api/integrations/erp/procurement/purchase-requests/:purchaseRequestId/requeue",
|
|
"/api/integrations/erp/assets/:assetId/track",
|
|
"/api/integrations/erp/assets/:assetId/export",
|
|
],
|
|
},
|
|
],
|
|
invoice_verification: [
|
|
{
|
|
label: "票据验真模块",
|
|
routes: ["/api/invoice-pool/verification/*"],
|
|
},
|
|
],
|
|
leave_holiday_calendar: [
|
|
{
|
|
label: "请假日历增强",
|
|
routes: ["/api/leave-requests", "/api/leave-requests/:id", "/api/leave-balance/summary"],
|
|
},
|
|
],
|
|
budget_precheck_v1: [
|
|
{
|
|
label: "报销预算预校验",
|
|
routes: ["/api/reimbursements", "/api/reimbursements/:id"],
|
|
},
|
|
],
|
|
payment_batch_v1: [
|
|
{
|
|
label: "支付批次与 AP 运营",
|
|
routes: ["/api/payment-batches", "/api/ap-operations", "/api/exports/ap-operations"],
|
|
},
|
|
],
|
|
portal_home_v1: [
|
|
{
|
|
label: "Portal 首页聚合",
|
|
routes: ["/api/portal/home"],
|
|
},
|
|
],
|
|
global_search_v1: [
|
|
{
|
|
label: "全局搜索",
|
|
routes: ["/api/search"],
|
|
},
|
|
],
|
|
announcements_v1: [
|
|
{
|
|
label: "公告中心",
|
|
routes: ["/api/announcements", "/api/portal/home"],
|
|
},
|
|
],
|
|
workflow_definition_admin_v1: [
|
|
{
|
|
label: "流程定义治理",
|
|
routes: ["/api/system/workflow-definitions"],
|
|
},
|
|
],
|
|
cutover_console_v1: [
|
|
{
|
|
label: "切换控制台",
|
|
routes: ["/api/cutover/scopes", "/api/migration-runs", "/api/migration-runs/dry-run"],
|
|
},
|
|
],
|
|
presence_board_v1: [
|
|
{
|
|
label: "团队状态板",
|
|
routes: ["/api/presence/team", "/api/portal/home"],
|
|
},
|
|
],
|
|
weekly_report_v1: [
|
|
{
|
|
label: "周报工作台",
|
|
routes: ["/api/weekly-reports", "/api/portal/home", "/api/search"],
|
|
},
|
|
],
|
|
employee_lifecycle_v1: [
|
|
{
|
|
label: "人员生命周期",
|
|
routes: ["/api/employees", "/api/performance/cycles", "/api/performance/reviews"],
|
|
},
|
|
],
|
|
todo_board_v1: [
|
|
{
|
|
label: "待办工作台",
|
|
routes: ["/api/todos", "/api/portal/home", "/api/search"],
|
|
},
|
|
],
|
|
wecom_entry_v1: [
|
|
{
|
|
label: "企业微信入口",
|
|
routes: ["/api/wecom/auth/login", "/api/wecom/auth/bind", "/api/wecom/auth/unbind"],
|
|
},
|
|
],
|
|
project_domain_v1: [
|
|
{
|
|
label: "项目主域",
|
|
routes: ["/api/projects"],
|
|
},
|
|
],
|
|
project_linking_v1: [
|
|
{
|
|
label: "项目联动与合同台账",
|
|
routes: [
|
|
"/api/projects/:projectId/contracts",
|
|
"/api/projects/:projectId/reimbursements",
|
|
"/api/projects/:projectId/travel-requests",
|
|
"/api/contracts-registry",
|
|
"/api/exports/contracts-registry",
|
|
],
|
|
},
|
|
],
|
|
project_communication_log_v1: [
|
|
{
|
|
label: "项目沟通回填",
|
|
routes: [
|
|
"/api/projects/:projectId/communication-logs",
|
|
"/api/projects/:projectId/communication-logs/:logId",
|
|
],
|
|
},
|
|
],
|
|
project_closure_checklist_v1: [
|
|
{
|
|
label: "项目收口清单",
|
|
routes: [
|
|
"/api/projects/:projectId/closure-checklist",
|
|
"/api/projects/:projectId/closure-checklist/:itemId",
|
|
],
|
|
},
|
|
],
|
|
confidential_doc_refs_v1: [
|
|
{
|
|
label: "涉密资料引用",
|
|
routes: ["/api/confidential-doc-refs", "/api/projects/:projectId/confidential-doc-refs", "/api/search"],
|
|
},
|
|
],
|
|
public_security_hardening_v1: [],
|
|
});
|
|
|
|
function buildValidationError(details, statusCode = 400) {
|
|
return new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode,
|
|
details,
|
|
});
|
|
}
|
|
|
|
function buildVersionConflictError(flagKey, expectedVersion, currentVersion) {
|
|
return new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 409,
|
|
details: {
|
|
featureFlag: "version_conflict",
|
|
flagKey,
|
|
expectedVersion,
|
|
currentVersion,
|
|
},
|
|
});
|
|
}
|
|
|
|
function parseKey(params) {
|
|
const parsed = KEY_PARAM_SCHEMA.safeParse(params ?? {});
|
|
if (!parsed.success) {
|
|
throw buildValidationError(parsed.error.flatten());
|
|
}
|
|
|
|
return parsed.data.key;
|
|
}
|
|
|
|
function parsePageQuery(query) {
|
|
const parsed = PAGE_QUERY_SCHEMA.safeParse(query ?? {});
|
|
if (!parsed.success) {
|
|
throw buildValidationError(parsed.error.flatten());
|
|
}
|
|
|
|
return {
|
|
page: parsed.data.page ?? 1,
|
|
pageSize: parsed.data.pageSize ?? 20,
|
|
};
|
|
}
|
|
|
|
function parseUpdateBody(body) {
|
|
const parsed = UPDATE_BODY_SCHEMA.safeParse(body ?? {});
|
|
if (!parsed.success) {
|
|
throw buildValidationError(parsed.error.flatten());
|
|
}
|
|
|
|
if (parsed.data.rolloutType !== "department_allowlist" && parsed.data.departmentIds.length > 0) {
|
|
throw buildValidationError({ departmentIds: "rollout_type_global_disallows_department_ids" });
|
|
}
|
|
|
|
return parsed.data;
|
|
}
|
|
|
|
function parseBatchBody(body) {
|
|
const parsed = BATCH_BODY_SCHEMA.safeParse(body ?? {});
|
|
if (!parsed.success) {
|
|
throw buildValidationError(parsed.error.flatten());
|
|
}
|
|
return parsed.data;
|
|
}
|
|
|
|
function normalizeOverrideMode(value) {
|
|
return OVERRIDE_MODE_LIST.includes(value) ? value : "force_off";
|
|
}
|
|
|
|
function normalizeRolloutType(value) {
|
|
return ROLLOUT_TYPE_LIST.includes(value) ? value : "global";
|
|
}
|
|
|
|
function mapFlagRecord(record) {
|
|
if (!record) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
id: record.id,
|
|
flagKey: record.flag_key,
|
|
overrideMode: normalizeOverrideMode(record.override_mode),
|
|
rolloutType: normalizeRolloutType(record.rollout_type),
|
|
departmentIds: sanitizeDepartmentIds(record.department_ids),
|
|
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 buildFeatureFlagSnapshot(record) {
|
|
return {
|
|
overrideMode: record.overrideMode,
|
|
rolloutType: record.rolloutType,
|
|
departmentIds: record.departmentIds,
|
|
note: record.note,
|
|
version: record.version,
|
|
};
|
|
}
|
|
|
|
function buildFeatureFlagAuditExtra(flagKey, current, updated) {
|
|
return {
|
|
flagKey,
|
|
from: buildFeatureFlagSnapshot(current),
|
|
to: buildFeatureFlagSnapshot(updated),
|
|
};
|
|
}
|
|
|
|
function resolveNavigationSection(moduleKey) {
|
|
if (BACKUP_NAVIGATION_KEYS.has(moduleKey)) {
|
|
return "backup";
|
|
}
|
|
|
|
const placement = DEFAULT_MODULE_VISIBILITY[moduleKey] ?? "hidden";
|
|
return placement === "admin" ? "admin" : "main";
|
|
}
|
|
|
|
function buildFeatureFlagImpact(flagKey) {
|
|
const navigationItems = Object.entries(NAVIGATION_REGISTRY)
|
|
.filter(([, item]) => item.featureFlagKey === flagKey)
|
|
.map(([moduleKey, item]) => ({
|
|
label: item.label,
|
|
path: item.href,
|
|
section: resolveNavigationSection(moduleKey),
|
|
}));
|
|
|
|
return {
|
|
flagKey,
|
|
navigationItems,
|
|
backendModules: FEATURE_FLAG_BACKEND_IMPACT[flagKey] ?? [],
|
|
};
|
|
}
|
|
|
|
async function featureFlagsRoutes(fastify) {
|
|
fastify.get(
|
|
"/system/feature-flags",
|
|
{
|
|
preHandler: fastify.requirePermission("system.settings"),
|
|
schema: {
|
|
tags: ["system", "feature-flags"],
|
|
summary: "获取功能开关列表",
|
|
security: [{ bearerAuth: [] }],
|
|
response: {
|
|
200: buildDataEnvelopeSchema(
|
|
{
|
|
type: "object",
|
|
required: ["items"],
|
|
properties: {
|
|
items: {
|
|
type: "array",
|
|
items: FEATURE_FLAG_ITEM_SCHEMA,
|
|
},
|
|
},
|
|
},
|
|
"功能开关列表",
|
|
),
|
|
403: buildErrorEnvelopeSchema({
|
|
description: "无权限",
|
|
codeExample: "PERM_001",
|
|
messageExample: "无权限",
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
async (request, reply) => {
|
|
const items = await listFeatureFlags(fastify.prisma, fastify.config);
|
|
return sendData(reply, request, { items });
|
|
},
|
|
);
|
|
|
|
fastify.get(
|
|
"/system/feature-flags/:key/changelog",
|
|
{
|
|
preHandler: fastify.requirePermission("system.settings"),
|
|
},
|
|
async (request, reply) => {
|
|
const key = parseKey(request.params);
|
|
const query = parsePageQuery(request.query);
|
|
const skip = (query.page - 1) * query.pageSize;
|
|
|
|
const where = {
|
|
action: "FEATURE_FLAG_UPDATED",
|
|
target_type: "feature_flag",
|
|
extra: {
|
|
path: ["flagKey"],
|
|
equals: key,
|
|
},
|
|
};
|
|
|
|
const [items, total] = await Promise.all([
|
|
fastify.prisma.auditLog.findMany({
|
|
where,
|
|
orderBy: { created_at: "desc" },
|
|
skip,
|
|
take: query.pageSize,
|
|
select: {
|
|
id: true,
|
|
actor_id: true,
|
|
created_at: true,
|
|
extra: true,
|
|
actor: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
fastify.prisma.auditLog.count({ where }),
|
|
]);
|
|
|
|
return sendData(reply, request, {
|
|
page: query.page,
|
|
pageSize: query.pageSize,
|
|
total,
|
|
items: items.map((item) => ({
|
|
id: item.id,
|
|
actorId: item.actor_id,
|
|
actorName: item.actor?.name ?? "",
|
|
createdAt: item.created_at?.toISOString?.() ?? null,
|
|
from: item.extra?.from ?? null,
|
|
to: item.extra?.to ?? null,
|
|
})),
|
|
});
|
|
},
|
|
);
|
|
|
|
fastify.get(
|
|
"/system/feature-flags/:key/impact",
|
|
{
|
|
preHandler: fastify.requirePermission("system.settings"),
|
|
},
|
|
async (request, reply) => {
|
|
const key = parseKey(request.params);
|
|
return sendData(reply, request, buildFeatureFlagImpact(key));
|
|
},
|
|
);
|
|
|
|
fastify.post(
|
|
"/system/feature-flags/batch",
|
|
{
|
|
preHandler: fastify.requirePermission("system.settings"),
|
|
},
|
|
async (request, reply) => {
|
|
const body = parseBatchBody(request.body);
|
|
|
|
const results = await fastify.prisma.$transaction(async (tx) => {
|
|
const updatedFlags = [];
|
|
|
|
for (const item of body.items) {
|
|
const currentRow = await tx.featureFlag.findUnique({
|
|
where: { flag_key: item.flagKey },
|
|
});
|
|
|
|
if (!currentRow) {
|
|
throw new AppError({
|
|
code: "SYS_404",
|
|
message: "Not Found",
|
|
statusCode: 404,
|
|
});
|
|
}
|
|
|
|
const current = mapFlagRecord(currentRow);
|
|
if (item.expectedVersion !== current.version) {
|
|
throw buildVersionConflictError(item.flagKey, item.expectedVersion, current.version);
|
|
}
|
|
|
|
const updateResult = await tx.featureFlag.updateMany({
|
|
where: {
|
|
id: current.id,
|
|
version: current.version,
|
|
},
|
|
data: {
|
|
override_mode: item.overrideMode,
|
|
rollout_type: current.rolloutType,
|
|
department_ids: current.departmentIds,
|
|
note: body.note,
|
|
version: {
|
|
increment: 1,
|
|
},
|
|
updated_by: request.employee.id,
|
|
},
|
|
});
|
|
|
|
if (updateResult.count !== 1) {
|
|
const latestRow = await tx.featureFlag.findUnique({
|
|
where: { flag_key: item.flagKey },
|
|
});
|
|
const latest = mapFlagRecord(latestRow);
|
|
throw buildVersionConflictError(
|
|
item.flagKey,
|
|
item.expectedVersion,
|
|
latest?.version ?? current.version,
|
|
);
|
|
}
|
|
|
|
const updatedRow = await tx.featureFlag.findUnique({
|
|
where: { flag_key: item.flagKey },
|
|
});
|
|
const updated = mapFlagRecord(updatedRow);
|
|
|
|
await tx.auditLog.create({
|
|
data: buildAuditLogData(request, {
|
|
actorId: request.employee.id,
|
|
action: "FEATURE_FLAG_UPDATED",
|
|
targetType: "feature_flag",
|
|
targetId: updated.id,
|
|
extra: buildFeatureFlagAuditExtra(item.flagKey, current, updated),
|
|
}),
|
|
});
|
|
|
|
updatedFlags.push(updated);
|
|
}
|
|
|
|
return updatedFlags;
|
|
});
|
|
|
|
return sendData(reply, request, { count: results.length });
|
|
},
|
|
);
|
|
|
|
fastify.put(
|
|
"/system/feature-flags/:key",
|
|
{
|
|
preHandler: fastify.requirePermission("system.settings"),
|
|
schema: {
|
|
tags: ["system", "feature-flags"],
|
|
summary: "更新功能开关",
|
|
security: [{ bearerAuth: [] }],
|
|
params: {
|
|
type: "object",
|
|
required: ["key"],
|
|
properties: {
|
|
key: { type: "string", enum: FEATURE_FLAG_KEY_LIST },
|
|
},
|
|
},
|
|
body: {
|
|
type: "object",
|
|
required: ["overrideMode", "rolloutType", "departmentIds", "expectedVersion"],
|
|
properties: {
|
|
overrideMode: { type: "string", enum: OVERRIDE_MODE_LIST },
|
|
rolloutType: { type: "string", enum: ROLLOUT_TYPE_LIST },
|
|
departmentIds: {
|
|
type: "array",
|
|
items: { type: "string", format: "uuid" },
|
|
},
|
|
note: { type: "string", maxLength: 500 },
|
|
expectedVersion: { type: "integer", minimum: 1 },
|
|
},
|
|
},
|
|
response: {
|
|
200: buildDataEnvelopeSchema(
|
|
{
|
|
type: "object",
|
|
required: ["item"],
|
|
properties: {
|
|
item: FEATURE_FLAG_ITEM_SCHEMA,
|
|
},
|
|
},
|
|
"更新后的功能开关",
|
|
),
|
|
400: buildErrorEnvelopeSchema({
|
|
description: "参数校验失败",
|
|
codeExample: "VALID_001",
|
|
messageExample: "参数校验失败",
|
|
}),
|
|
404: buildErrorEnvelopeSchema({
|
|
description: "开关不存在",
|
|
codeExample: "SYS_404",
|
|
messageExample: "Not Found",
|
|
}),
|
|
409: buildErrorEnvelopeSchema({
|
|
description: "版本冲突",
|
|
codeExample: "VALID_001",
|
|
messageExample: "参数校验失败",
|
|
detailsExample: {
|
|
featureFlag: "version_conflict",
|
|
expectedVersion: 2,
|
|
currentVersion: 3,
|
|
},
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
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: buildFeatureFlagAuditExtra(key, result.current, result.updated),
|
|
}),
|
|
});
|
|
|
|
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;
|