R54 A1-A6:Cutover 控制台与特性开关增强
- Feature Flag 变更历史查看与影响范围预览 - Feature Flag 批量操作(启用/禁用/继承) - Cutover 控制台页面增强(摘要卡片、时间线、延迟生效) - 单测覆盖 changelog、batch、权限边界
这个提交包含在:
+498
-35
@@ -13,13 +13,23 @@ const {
|
||||
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),
|
||||
@@ -28,6 +38,33 @@ const UPDATE_BODY_SCHEMA = z.object({
|
||||
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: [
|
||||
@@ -71,43 +108,334 @@ const FEATURE_FLAG_ITEM_SCHEMA = {
|
||||
},
|
||||
};
|
||||
|
||||
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 new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
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 new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
throw buildValidationError(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" },
|
||||
});
|
||||
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",
|
||||
@@ -145,6 +473,157 @@ async function featureFlagsRoutes(fastify) {
|
||||
},
|
||||
);
|
||||
|
||||
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",
|
||||
{
|
||||
@@ -257,23 +736,7 @@ async function featureFlagsRoutes(fastify) {
|
||||
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,
|
||||
},
|
||||
},
|
||||
extra: buildFeatureFlagAuditExtra(key, result.current, result.updated),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
const Fastify = require("fastify");
|
||||
|
||||
const featureFlagsRoutes = require("../../../src/routes/featureFlags");
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
|
||||
const ACTOR_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
|
||||
function buildFlagRecord(flagKey, overrides = {}) {
|
||||
return {
|
||||
id:
|
||||
flagKey === "payment_batch_v1"
|
||||
? "11111111-1111-4111-8111-111111111111"
|
||||
: "22222222-2222-4222-8222-222222222222",
|
||||
flag_key: flagKey,
|
||||
override_mode: "force_off",
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
note: "before batch",
|
||||
version: flagKey === "payment_batch_v1" ? 2 : 4,
|
||||
created_at: new Date("2026-04-07T08:00:00.000Z"),
|
||||
created_by: null,
|
||||
updated_at: new Date("2026-04-07T08:00:00.000Z"),
|
||||
updated_by: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneRecord(record) {
|
||||
return {
|
||||
...record,
|
||||
department_ids: Array.isArray(record.department_ids) ? [...record.department_ids] : record.department_ids,
|
||||
created_at: new Date(record.created_at),
|
||||
updated_at: new Date(record.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
async function buildApp(options = {}) {
|
||||
const app = Fastify();
|
||||
const actor = options.actor ?? {
|
||||
id: ACTOR_ID,
|
||||
permissionCodes: ["system.settings"],
|
||||
};
|
||||
const state = {
|
||||
flags:
|
||||
options.flags?.map((item) => cloneRecord(item)) ?? [
|
||||
buildFlagRecord("payment_batch_v1"),
|
||||
buildFlagRecord("cutover_console_v1"),
|
||||
],
|
||||
auditLogs: [],
|
||||
};
|
||||
|
||||
const topLevelFindUnique = vi.fn(async ({ where }) => {
|
||||
if (where?.flag_key) {
|
||||
return state.flags.find((item) => item.flag_key === where.flag_key) ?? null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const prisma = {
|
||||
auditLog: {
|
||||
findMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
featureFlag: {
|
||||
findUnique: topLevelFindUnique,
|
||||
},
|
||||
$transaction: vi.fn(async (callback) => {
|
||||
const txState = {
|
||||
flags: state.flags.map((item) => cloneRecord(item)),
|
||||
auditLogs: state.auditLogs.map((item) => ({ ...item })),
|
||||
};
|
||||
const tx = {
|
||||
featureFlag: {
|
||||
findUnique: vi.fn(async ({ where }) => {
|
||||
if (where?.flag_key) {
|
||||
return txState.flags.find((item) => item.flag_key === where.flag_key) ?? null;
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
updateMany: vi.fn(async ({ where, data }) => {
|
||||
const target = txState.flags.find(
|
||||
(item) => item.id === where.id && item.version === where.version,
|
||||
);
|
||||
if (!target) {
|
||||
return { count: 0 };
|
||||
}
|
||||
|
||||
target.override_mode = data.override_mode;
|
||||
target.rollout_type = data.rollout_type;
|
||||
target.department_ids = Array.isArray(data.department_ids)
|
||||
? [...data.department_ids]
|
||||
: data.department_ids;
|
||||
target.note = data.note;
|
||||
target.version += Number(data.version?.increment ?? 0);
|
||||
target.updated_by = data.updated_by;
|
||||
target.updated_at = new Date("2026-04-07T09:00:00.000Z");
|
||||
|
||||
return { count: 1 };
|
||||
}),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn(async ({ data }) => {
|
||||
txState.auditLogs.push(data);
|
||||
return { id: `audit-${txState.auditLogs.length}` };
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await callback(tx);
|
||||
state.flags = txState.flags;
|
||||
state.auditLogs = txState.auditLogs;
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
app.decorate("authenticate", async (request) => {
|
||||
request.employee = actor;
|
||||
});
|
||||
app.decorate("requirePermission", (permissionCode) => async (request) => {
|
||||
request.employee = actor;
|
||||
const permissionCodes = actor.permissionCodes ?? [];
|
||||
if (!permissionCodes.includes("*") && !permissionCodes.includes(permissionCode)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
});
|
||||
app.decorate("config", {});
|
||||
app.decorate("prisma", prisma);
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
reply.status(error.statusCode || 500).send({
|
||||
requestId: request.id,
|
||||
error: {
|
||||
code: error.code || "SYS_001",
|
||||
message: error.message,
|
||||
details: error.details ?? null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(featureFlagsRoutes, { prefix: "/api" });
|
||||
return { app, state, prisma };
|
||||
}
|
||||
|
||||
describe("feature flag batch route", () => {
|
||||
it("updates multiple flags in one transaction and writes one audit per flag", async () => {
|
||||
const { app, state, prisma } = await buildApp();
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/system/feature-flags/batch",
|
||||
payload: {
|
||||
items: [
|
||||
{
|
||||
flagKey: "payment_batch_v1",
|
||||
overrideMode: "force_on",
|
||||
expectedVersion: 2,
|
||||
},
|
||||
{
|
||||
flagKey: "cutover_console_v1",
|
||||
overrideMode: "inherit",
|
||||
expectedVersion: 4,
|
||||
},
|
||||
],
|
||||
note: "R54 批量切换",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.count).toBe(2);
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(state.flags).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
flag_key: "payment_batch_v1",
|
||||
override_mode: "force_on",
|
||||
note: "R54 批量切换",
|
||||
version: 3,
|
||||
updated_by: ACTOR_ID,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
flag_key: "cutover_console_v1",
|
||||
override_mode: "inherit",
|
||||
note: "R54 批量切换",
|
||||
version: 5,
|
||||
updated_by: ACTOR_ID,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(state.auditLogs).toHaveLength(2);
|
||||
expect(state.auditLogs[0]).toMatchObject({
|
||||
action: "FEATURE_FLAG_UPDATED",
|
||||
target_type: "feature_flag",
|
||||
actor_id: ACTOR_ID,
|
||||
extra: expect.objectContaining({
|
||||
flagKey: "payment_batch_v1",
|
||||
}),
|
||||
});
|
||||
expect(state.auditLogs[1]).toMatchObject({
|
||||
action: "FEATURE_FLAG_UPDATED",
|
||||
extra: expect.objectContaining({
|
||||
flagKey: "cutover_console_v1",
|
||||
}),
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rolls back the whole batch when any flag hits a version conflict", async () => {
|
||||
const { app, state } = await buildApp();
|
||||
const beforeFlags = state.flags.map((item) => ({
|
||||
flag_key: item.flag_key,
|
||||
override_mode: item.override_mode,
|
||||
note: item.note,
|
||||
version: item.version,
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/system/feature-flags/batch",
|
||||
payload: {
|
||||
items: [
|
||||
{
|
||||
flagKey: "payment_batch_v1",
|
||||
overrideMode: "force_on",
|
||||
expectedVersion: 2,
|
||||
},
|
||||
{
|
||||
flagKey: "cutover_console_v1",
|
||||
overrideMode: "force_on",
|
||||
expectedVersion: 99,
|
||||
},
|
||||
],
|
||||
note: "should rollback",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().error.details).toMatchObject({
|
||||
featureFlag: "version_conflict",
|
||||
flagKey: "cutover_console_v1",
|
||||
expectedVersion: 99,
|
||||
currentVersion: 4,
|
||||
});
|
||||
expect(
|
||||
state.flags.map((item) => ({
|
||||
flag_key: item.flag_key,
|
||||
override_mode: item.override_mode,
|
||||
note: item.note,
|
||||
version: item.version,
|
||||
})),
|
||||
).toEqual(beforeFlags);
|
||||
expect(state.auditLogs).toEqual([]);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects batch updates without system.settings permission", async () => {
|
||||
const { app, state } = await buildApp({
|
||||
actor: {
|
||||
id: ACTOR_ID,
|
||||
permissionCodes: [],
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/system/feature-flags/batch",
|
||||
payload: {
|
||||
items: [
|
||||
{
|
||||
flagKey: "payment_batch_v1",
|
||||
overrideMode: "force_on",
|
||||
expectedVersion: 2,
|
||||
},
|
||||
],
|
||||
note: "should fail",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(state.auditLogs).toEqual([]);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
const Fastify = require("fastify");
|
||||
|
||||
const featureFlagsRoutes = require("../../../src/routes/featureFlags");
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
|
||||
const ACTOR_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
|
||||
function buildAuditLog(overrides = {}) {
|
||||
return {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
actor_id: ACTOR_ID,
|
||||
created_at: new Date("2026-04-07T10:00:00.000Z"),
|
||||
extra: {
|
||||
flagKey: "payment_batch_v1",
|
||||
from: {
|
||||
overrideMode: "force_off",
|
||||
rolloutType: "global",
|
||||
departmentIds: [],
|
||||
note: "old note",
|
||||
version: 1,
|
||||
},
|
||||
to: {
|
||||
overrideMode: "force_on",
|
||||
rolloutType: "global",
|
||||
departmentIds: [],
|
||||
note: "new note",
|
||||
version: 2,
|
||||
},
|
||||
},
|
||||
actor: {
|
||||
id: ACTOR_ID,
|
||||
name: "系统管理员",
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildApp(options = {}) {
|
||||
const app = Fastify();
|
||||
const actor = options.actor ?? {
|
||||
id: ACTOR_ID,
|
||||
permissionCodes: ["system.settings"],
|
||||
};
|
||||
const findMany = options.findMany ?? vi.fn().mockResolvedValue([buildAuditLog()]);
|
||||
const count = options.count ?? vi.fn().mockResolvedValue(1);
|
||||
|
||||
app.decorate("authenticate", async (request) => {
|
||||
request.employee = actor;
|
||||
});
|
||||
app.decorate("requirePermission", (permissionCode) => async (request) => {
|
||||
request.employee = actor;
|
||||
const permissionCodes = actor.permissionCodes ?? [];
|
||||
if (!permissionCodes.includes("*") && !permissionCodes.includes(permissionCode)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
});
|
||||
app.decorate("config", {});
|
||||
app.decorate("prisma", {
|
||||
auditLog: {
|
||||
findMany,
|
||||
count,
|
||||
create: vi.fn(),
|
||||
},
|
||||
featureFlag: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
});
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
reply.status(error.statusCode || 500).send({
|
||||
requestId: request.id,
|
||||
error: {
|
||||
code: error.code || "SYS_001",
|
||||
message: error.message,
|
||||
details: error.details ?? null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(featureFlagsRoutes, { prefix: "/api" });
|
||||
return { app, findMany, count };
|
||||
}
|
||||
|
||||
describe("feature flag changelog route", () => {
|
||||
it("lists paginated changelog records for a specific flag", async () => {
|
||||
const { app, findMany, count } = await buildApp();
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/system/feature-flags/payment_batch_v1/changelog?page=2&pageSize=10",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
action: "FEATURE_FLAG_UPDATED",
|
||||
target_type: "feature_flag",
|
||||
extra: {
|
||||
path: ["flagKey"],
|
||||
equals: "payment_batch_v1",
|
||||
},
|
||||
},
|
||||
orderBy: { created_at: "desc" },
|
||||
skip: 10,
|
||||
take: 10,
|
||||
}),
|
||||
);
|
||||
expect(count).toHaveBeenCalledWith({
|
||||
where: {
|
||||
action: "FEATURE_FLAG_UPDATED",
|
||||
target_type: "feature_flag",
|
||||
extra: {
|
||||
path: ["flagKey"],
|
||||
equals: "payment_batch_v1",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const payload = response.json();
|
||||
expect(payload.data.page).toBe(2);
|
||||
expect(payload.data.pageSize).toBe(10);
|
||||
expect(payload.data.total).toBe(1);
|
||||
expect(payload.data.items[0]).toMatchObject({
|
||||
actorId: ACTOR_ID,
|
||||
actorName: "系统管理员",
|
||||
from: {
|
||||
overrideMode: "force_off",
|
||||
},
|
||||
to: {
|
||||
overrideMode: "force_on",
|
||||
},
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns an empty list when no changelog records exist", async () => {
|
||||
const { app } = await buildApp({
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/system/feature-flags/cutover_console_v1/changelog",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data).toEqual({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
items: [],
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects requests without system.settings permission", async () => {
|
||||
const { app, findMany, count } = await buildApp({
|
||||
actor: {
|
||||
id: ACTOR_ID,
|
||||
permissionCodes: [],
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/system/feature-flags/payment_batch_v1/changelog",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(findMany).not.toHaveBeenCalled();
|
||||
expect(count).not.toHaveBeenCalled();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户