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;