R54 A1-A6:Cutover 控制台与特性开关增强
- Feature Flag 变更历史查看与影响范围预览 - Feature Flag 批量操作(启用/禁用/继承) - Cutover 控制台页面增强(摘要卡片、时间线、延迟生效) - 单测覆盖 changelog、batch、权限边界
这个提交包含在:
@@ -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();
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户