fix: guard agent workflow config rollback

This commit is contained in:
Your Name 2026-05-29 16:52:40 +08:00
parent 180ee864d9
commit ba46f97f3d
10 changed files with 658 additions and 29 deletions

View File

@ -1811,6 +1811,52 @@ interface AgentWorkflowSettingsPayload {
agentPsdTemplateSecondaryCategoryConfig?: AgentPsdTemplateSecondaryCategoryConfig;
}
interface AgentWorkflowDestructiveChangePayload {
totalBeforeOptionCount: number;
totalAfterOptionCount: number;
removedOptionCount: number;
removedEnabledOptionCount: number;
promptClearedCount: number;
previewClearedCount: number;
removedOptionSamples: string[];
promptClearedSamples: string[];
previewClearedSamples: string[];
approvalToken: string;
}
function formatAgentWorkflowDestructiveChangeConfirmText(
payload: AgentWorkflowDestructiveChangePayload
) {
const lines = [
"这次保存会对风格化工作流做大规模删改,系统已拦截。",
"",
`删除节点:${payload.removedOptionCount}(其中启用中 ${payload.removedEnabledOptionCount})`,
`清空提示词:${payload.promptClearedCount}`,
`清空预览图:${payload.previewClearedCount}`,
"",
];
const sampleGroups: Array<[string, string[]]> = [
["将删除的分支示例", payload.removedOptionSamples],
["将清空提示词的示例", payload.promptClearedSamples],
["将清空预览图的示例", payload.previewClearedSamples],
];
for (const [label, samples] of sampleGroups) {
if (samples.length === 0) {
continue;
}
lines.push(`${label}:`);
for (const sample of samples.slice(0, 5)) {
lines.push(`- ${sample}`);
}
lines.push("");
}
lines.push("如果这是你确认要执行的结构性删除,请点击“确定”再次保存。");
return lines.join("\n");
}
interface AdminAiModelOption {
id: string;
modelId?: string | null;
@ -3899,39 +3945,77 @@ export function AgentWorkflowSettingsClient({
changedPayload.agentWorkflowSettingsBaseSignatures[field] = baseSignatures[field];
}
const response = await fetchAgentWorkflowSettingsWithTimeout(
"/api/site-settings",
{
const submitChangedPayload = async (payload: Record<string, unknown>) =>
fetchAgentWorkflowSettingsWithTimeout("/api/site-settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(changedPayload),
body: JSON.stringify(payload),
});
let response = await submitChangedPayload(changedPayload as Record<string, unknown>);
let responsePayload = (await response.json().catch(() => null)) as
| {
error?: string;
details?: Array<{ message?: string; path?: Array<string | number> }>;
requiresDestructiveChangeApproval?: boolean;
destructiveChange?: AgentWorkflowDestructiveChangePayload;
}
| null;
if (
!response.ok &&
responsePayload?.requiresDestructiveChangeApproval &&
responsePayload?.destructiveChange?.approvalToken
) {
const shouldContinue = window.confirm(
formatAgentWorkflowDestructiveChangeConfirmText(
responsePayload.destructiveChange
)
);
if (!shouldContinue) {
toast.info("已取消本次高风险保存");
return;
}
);
response = await submitChangedPayload({
...changedPayload,
agentWorkflowDestructiveChangeApproval: {
token: responsePayload.destructiveChange.approvalToken,
},
});
responsePayload = (await response.json().catch(() => null)) as
| {
error?: string;
details?: Array<{ message?: string; path?: Array<string | number> }>;
}
| null;
}
if (!response.ok) {
let message = "保存失败";
try {
const payload = await response.json();
if (Array.isArray(payload?.details) && payload.details.length > 0) {
const firstIssue = payload.details[0] as {
message?: string;
path?: Array<string | number>;
};
const pathText =
Array.isArray(firstIssue.path) && firstIssue.path.length > 0
? firstIssue.path
.filter((segment) => segment !== undefined && segment !== null && segment !== "")
.join(" / ")
: "";
const reasonText = String(firstIssue.message || "").trim();
if (Array.isArray(responsePayload?.details) && responsePayload.details.length > 0) {
const firstIssue = responsePayload.details[0] as {
message?: string;
path?: Array<string | number>;
};
const pathText =
Array.isArray(firstIssue.path) && firstIssue.path.length > 0
? firstIssue.path
.filter((segment) => segment !== undefined && segment !== null && segment !== "")
.join(" / ")
: "";
const reasonText = String(firstIssue.message || "").trim();
message = [payload?.error, pathText, reasonText].filter(Boolean).join(":");
} else if (typeof payload?.error === "string" && payload.error.trim()) {
message = payload.error.trim();
}
} catch {
// Ignore JSON parsing issues and keep the generic message.
message = [responsePayload?.error, pathText, reasonText]
.filter(Boolean)
.join(":");
} else if (
typeof responsePayload?.error === "string" &&
responsePayload.error.trim()
) {
message = responsePayload.error.trim();
}
throw new Error(message);

View File

@ -1,7 +1,14 @@
import { NextRequest, NextResponse } from "next/server";
import { createHash } from "node:crypto";
import { z } from "zod";
import { db } from "@/lib/db";
import { aiProvider, siteSettings, systemSettingHistory, systemSettings } from "@/lib/db/schema";
import {
aiProvider,
siteSettings,
systemSettingHistory,
systemSettingSnapshot,
systemSettings,
} from "@/lib/db/schema";
import { and, eq, inArray } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth";
import { isAgentWorkflowAdminType, isSiteAdminType } from "@/lib/middleware/admin";
@ -35,8 +42,10 @@ import {
AGENT_WORKFLOW_UI_CONFIG_KEY,
AGENT_WORKFLOW_PROMPT_FRAGMENT_MAX_LENGTH,
defaultAgentWorkflowUiConfig,
isAgentWorkflowDestructiveChangeSignificant,
normalizeAgentWorkflowStepOrdering,
normalizeAgentWorkflowUiConfig,
summarizeAgentWorkflowDestructiveChange,
} from "@/lib/agents/workflow-ui-config";
import {
AGENT_WORKFLOW_BRAND_MODEL_CONFIG_KEY,
@ -105,6 +114,11 @@ import {
PUBLIC_SITE_SETTINGS_CACHE_TTL_MS,
setScopedSiteSettingsCache,
} from "@/lib/site-settings/public-cache";
import {
AGENT_WORKFLOW_SNAPSHOT_SCOPE,
createSystemSettingSnapshotData,
summarizeSystemSettingSnapshotData,
} from "@/lib/system-settings/snapshot";
import {
getHomeChatAnimationHtmlBytes,
HOME_CHAT_ANIMATION_CONFIG_KEY,
@ -1612,6 +1626,11 @@ const updateSchema = z.object({
agentWorkflowSettingsBaseSignatures: z
.record(z.string().trim().min(1).max(128))
.optional(),
agentWorkflowDestructiveChangeApproval: z
.object({
token: z.string().trim().min(16).max(128),
})
.optional(),
agentWorkflowUiConfig: z
.object({
inputOptions: z
@ -1965,6 +1984,7 @@ export async function PUT(request: NextRequest) {
"agentPresetLibraryConfig",
"agentWorkflowSettingsBaseSignature",
"agentWorkflowSettingsBaseSignatures",
"agentWorkflowDestructiveChangeApproval",
"agentWorkflowUiConfig",
"agentWorkflowMaterialApiConfig",
"agentWorkflowGoodsInfoApiConfig",
@ -1992,6 +2012,7 @@ export async function PUT(request: NextRequest) {
agentSecondaryTypeConfig,
agentWorkflowSettingsBaseSignature,
agentWorkflowSettingsBaseSignatures,
agentWorkflowDestructiveChangeApproval,
agentWorkflowUiConfig,
agentWorkflowMaterialApiConfig,
agentWorkflowGoodsInfoApiConfig,
@ -2033,6 +2054,29 @@ export async function PUT(request: NextRequest) {
.map(([field]) => field);
const isUpdatingAgentWorkflowSettings = updatedAgentWorkflowFields.length > 0;
let currentWorkflowValues = new Map<string, unknown>();
let currentWorkflowSectionSignatures:
| ReturnType<typeof createAgentWorkflowSettingsSectionSignatures>
| null = null;
let workflowDestructiveChangeSummary:
| ReturnType<typeof summarizeAgentWorkflowDestructiveChange>
| null = null;
let agentWorkflowSnapshotCreated = false;
const createAgentWorkflowDestructiveChangeApprovalToken = (params: {
currentSignature: string;
nextConfig: unknown;
summary: ReturnType<typeof summarizeAgentWorkflowDestructiveChange>;
}) =>
createHash("sha256")
.update(
JSON.stringify({
currentSignature: params.currentSignature,
nextConfig: params.nextConfig,
summary: params.summary,
})
)
.digest("hex");
if (isUpdatingAgentWorkflowSettings) {
if (!agentWorkflowSettingsBaseSignatures && !agentWorkflowSettingsBaseSignature) {
@ -2052,10 +2096,10 @@ export async function PUT(request: NextRequest) {
})
.from(systemSettings)
.where(inArray(systemSettings.key, [...AGENT_WORKFLOW_SETTINGS_CONFLICT_KEYS]));
const currentWorkflowValues = new Map<string, unknown>(
currentWorkflowValues = new Map<string, unknown>(
currentWorkflowRows.map((row) => [row.key, row.value])
);
const currentSectionSignatures =
currentWorkflowSectionSignatures =
createAgentWorkflowSettingsSectionSignatures(currentWorkflowValues);
const currentWorkflowSignature =
agentWorkflowSettingsBaseSignature && !agentWorkflowSettingsBaseSignatures
@ -2064,7 +2108,8 @@ export async function PUT(request: NextRequest) {
const hasWorkflowConflict = agentWorkflowSettingsBaseSignatures
? updatedAgentWorkflowFields.some(
(field) =>
currentSectionSignatures[field] !== agentWorkflowSettingsBaseSignatures[field]
currentWorkflowSectionSignatures![field] !==
agentWorkflowSettingsBaseSignatures[field]
)
: currentWorkflowSignature !== agentWorkflowSettingsBaseSignature;
@ -2077,6 +2122,44 @@ export async function PUT(request: NextRequest) {
{ status: 409 }
);
}
if (agentWorkflowUiConfig !== undefined) {
const currentWorkflowUiConfig = normalizeAgentWorkflowUiConfig(
currentWorkflowValues.get(AGENT_WORKFLOW_UI_CONFIG_KEY)
);
const nextWorkflowUiConfig = normalizeAgentWorkflowUiConfig(agentWorkflowUiConfig);
workflowDestructiveChangeSummary = summarizeAgentWorkflowDestructiveChange(
currentWorkflowUiConfig,
nextWorkflowUiConfig
);
if (isAgentWorkflowDestructiveChangeSignificant(workflowDestructiveChangeSummary)) {
const approvalToken = createAgentWorkflowDestructiveChangeApprovalToken({
currentSignature:
currentWorkflowSectionSignatures.agentWorkflowUiConfig ||
createAgentWorkflowSettingsSignature(
new Map([[AGENT_WORKFLOW_UI_CONFIG_KEY, currentWorkflowUiConfig]])
),
nextConfig: nextWorkflowUiConfig,
summary: workflowDestructiveChangeSummary,
});
if (agentWorkflowDestructiveChangeApproval?.token !== approvalToken) {
return NextResponse.json(
{
error:
"本次保存会删除大量风格化工作流节点或清空关键内容,已被系统拦截。请确认后再次提交。",
requiresDestructiveChangeApproval: true,
destructiveChange: {
...workflowDestructiveChangeSummary,
approvalToken,
},
},
{ status: 409 }
);
}
}
}
}
const persistSystemSettingWithHistory = async (
@ -2136,6 +2219,37 @@ export async function PUT(request: NextRequest) {
});
};
const createAgentWorkflowScopeSnapshot = async () => {
if (!isUpdatingAgentWorkflowSettings || agentWorkflowSnapshotCreated) {
return null;
}
const snapshotData = createSystemSettingSnapshotData(
AGENT_WORKFLOW_SNAPSHOT_SCOPE,
currentWorkflowValues
);
const [snapshot] = await db
.insert(systemSettingSnapshot)
.values({
scope: AGENT_WORKFLOW_SNAPSHOT_SCOPE,
label: workflowDestructiveChangeSummary
? "before-destructive-agent-workflow-save"
: "before-agent-workflow-save",
summary: {
...summarizeSystemSettingSnapshotData(snapshotData),
updatedFields: updatedAgentWorkflowFields,
destructiveChange: workflowDestructiveChangeSummary,
},
data: snapshotData,
createdAt: new Date(),
createdBy: session.user.id,
})
.returning({ id: systemSettingSnapshot.id });
agentWorkflowSnapshotCreated = true;
return snapshot?.id ?? null;
};
const persistHomeAnimationConfig = async () => {
if (homeAnimationConfig === undefined) {
return;
@ -2175,6 +2289,8 @@ export async function PUT(request: NextRequest) {
},
});
};
await createAgentWorkflowScopeSnapshot();
// 查找现有设置
const [existing] = await db.select().from(siteSettings).limit(1);

View File

@ -798,6 +798,139 @@ export function countAgentWorkflowOptionTree(
return flattenAgentWorkflowOptionTree(options).length;
}
export interface AgentWorkflowDestructiveChangeSummary {
totalBeforeOptionCount: number;
totalAfterOptionCount: number;
removedOptionCount: number;
removedEnabledOptionCount: number;
promptClearedCount: number;
previewClearedCount: number;
removedOptionSamples: string[];
promptClearedSamples: string[];
previewClearedSamples: string[];
}
const AGENT_WORKFLOW_DESTRUCTIVE_CHANGE_SAMPLE_LIMIT = 12;
function collectAgentWorkflowAuditEntries(
steps: AgentWorkflowStepUiConfig[],
stepKeyPath: string[] = [],
stepLabelPath: string[] = [],
output: Array<{
identityPath: string;
displayPath: string;
enabled: boolean;
hasPrompt: boolean;
hasPreview: boolean;
}> = []
) {
for (const step of [...(steps || [])].sort((left, right) => left.sortOrder - right.sortOrder)) {
const nextStepKeyPath = [...stepKeyPath, `s:${String(step.key || "").trim()}`];
const nextStepLabelPath = [
...stepLabelPath,
String(step.title || step.key || "未命名模块").trim() || "未命名模块",
];
const visitOptions = (
options: AgentWorkflowOptionUiConfig[],
parentKeyPath: string[],
parentLabelPath: string[]
) => {
for (const option of [...(options || [])].sort((left, right) => left.sortOrder - right.sortOrder)) {
const optionKey = String(option.key || "").trim() || "option";
const optionLabel =
String(option.buttonName || option.key || "未命名卡片").trim() || "未命名卡片";
const nextKeyPath = [...parentKeyPath, `o:${optionKey}`];
const nextLabelPath = [...parentLabelPath, optionLabel];
output.push({
identityPath: nextKeyPath.join(">"),
displayPath: nextLabelPath.join(" > "),
enabled: option.enabled !== false,
hasPrompt: Boolean(String(option.promptFragment || "").trim()),
hasPreview: hasAgentWorkflowOptionOwnPreview(option),
});
visitOptions(option.children || [], nextKeyPath, nextLabelPath);
}
};
visitOptions(step.options || [], nextStepKeyPath, nextStepLabelPath);
collectAgentWorkflowAuditEntries(
step.children || [],
nextStepKeyPath,
nextStepLabelPath,
output
);
}
return output;
}
export function summarizeAgentWorkflowDestructiveChange(
beforeConfig: AgentWorkflowUiConfig,
afterConfig: AgentWorkflowUiConfig
): AgentWorkflowDestructiveChangeSummary {
const beforeEntries = collectAgentWorkflowAuditEntries(beforeConfig.steps || []);
const afterEntries = collectAgentWorkflowAuditEntries(afterConfig.steps || []);
const afterByIdentityPath = new Map(
afterEntries.map((entry) => [entry.identityPath, entry])
);
const removedEntries = beforeEntries.filter(
(entry) => !afterByIdentityPath.has(entry.identityPath)
);
const promptClearedEntries = beforeEntries.filter((entry) => {
const nextEntry = afterByIdentityPath.get(entry.identityPath);
return Boolean(nextEntry && entry.hasPrompt && !nextEntry.hasPrompt);
});
const previewClearedEntries = beforeEntries.filter((entry) => {
const nextEntry = afterByIdentityPath.get(entry.identityPath);
return Boolean(nextEntry && entry.hasPreview && !nextEntry.hasPreview);
});
return {
totalBeforeOptionCount: beforeEntries.length,
totalAfterOptionCount: afterEntries.length,
removedOptionCount: removedEntries.length,
removedEnabledOptionCount: removedEntries.filter((entry) => entry.enabled).length,
promptClearedCount: promptClearedEntries.length,
previewClearedCount: previewClearedEntries.length,
removedOptionSamples: removedEntries
.slice(0, AGENT_WORKFLOW_DESTRUCTIVE_CHANGE_SAMPLE_LIMIT)
.map((entry) => entry.displayPath),
promptClearedSamples: promptClearedEntries
.slice(0, AGENT_WORKFLOW_DESTRUCTIVE_CHANGE_SAMPLE_LIMIT)
.map((entry) => entry.displayPath),
previewClearedSamples: previewClearedEntries
.slice(0, AGENT_WORKFLOW_DESTRUCTIVE_CHANGE_SAMPLE_LIMIT)
.map((entry) => entry.displayPath),
};
}
export function isAgentWorkflowDestructiveChangeSignificant(
summary: AgentWorkflowDestructiveChangeSummary
) {
if (summary.removedEnabledOptionCount >= 10) {
return true;
}
if (summary.removedOptionCount >= 20) {
return true;
}
if (
summary.totalBeforeOptionCount > 0 &&
summary.removedOptionCount / summary.totalBeforeOptionCount >= 0.03
) {
return true;
}
if (summary.promptClearedCount >= 10) {
return true;
}
return summary.previewClearedCount >= 5;
}
export function hasAgentWorkflowOptionOwnPreview(
option: AgentWorkflowOptionUiConfig
): boolean {

View File

@ -0,0 +1,23 @@
CREATE TABLE IF NOT EXISTS "SystemSettingSnapshot" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"scope" varchar(64) NOT NULL,
"label" varchar(128),
"summary" jsonb,
"data" jsonb NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"createdBy" uuid
);
DO $$
BEGIN
ALTER TABLE "SystemSettingSnapshot"
ADD CONSTRAINT "SystemSettingSnapshot_createdBy_User_id_fk"
FOREIGN KEY ("createdBy") REFERENCES "public"."User"("id")
ON DELETE NO ACTION
ON UPDATE NO ACTION;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
CREATE INDEX IF NOT EXISTS "SystemSettingSnapshot_scope_createdAt_idx"
ON "SystemSettingSnapshot" ("scope", "createdAt" DESC);

View File

@ -498,6 +498,13 @@
"when": 1779797137842,
"tag": "0084_optimize_featured_home_query",
"breakpoints": true
},
{
"idx": 71,
"version": "7",
"when": 1780075200000,
"tag": "0085_add_system_setting_snapshots",
"breakpoints": true
}
]
}

View File

@ -260,6 +260,18 @@ export const systemSettingHistory = pgTable("SystemSettingHistory", {
export type SystemSettingHistory = InferSelectModel<typeof systemSettingHistory>;
export const systemSettingSnapshot = pgTable("SystemSettingSnapshot", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
scope: varchar("scope", { length: 64 }).notNull(),
label: varchar("label", { length: 128 }),
summary: jsonb("summary"),
data: jsonb("data").notNull(),
createdAt: timestamp("createdAt").notNull().defaultNow(),
createdBy: uuid("createdBy").references(() => user.id),
});
export type SystemSettingSnapshot = InferSelectModel<typeof systemSettingSnapshot>;
export const dingTalkNotificationTask = pgTable("DingTalkNotificationTask", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
title: varchar("title", { length: 100 }).notNull(),

View File

@ -0,0 +1,66 @@
import {
AGENT_PRESET_LIBRARY_CONFIG_KEY,
} from "@/lib/agents/preset-library-config";
import {
AGENT_PSD_TEMPLATE_SECONDARY_CATEGORY_CONFIG_KEY,
} from "@/lib/agents/psd-template-secondary-category-config";
import {
AGENT_PSD_TEXT_COLOR_CONFIG_KEY,
} from "@/lib/agents/psd-text-color-config";
import {
AGENT_WORKFLOW_BRAND_MODEL_CONFIG_KEY,
} from "@/lib/agents/workflow-brand-model-config";
import {
AGENT_WORKFLOW_GOODS_INFO_API_CONFIG_KEY,
} from "@/lib/agents/workflow-goods-info-config";
import {
AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
} from "@/lib/agents/workflow-material-config";
import {
AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY,
} from "@/lib/agents/workflow-showcase-config";
import {
AGENT_WORKFLOW_UI_CONFIG_KEY,
} from "@/lib/agents/workflow-ui-config";
export const AGENT_WORKFLOW_SNAPSHOT_SCOPE = "agent-workflow";
export const SYSTEM_SETTING_SNAPSHOT_SCOPES = {
[AGENT_WORKFLOW_SNAPSHOT_SCOPE]: [
AGENT_PRESET_LIBRARY_CONFIG_KEY,
AGENT_WORKFLOW_UI_CONFIG_KEY,
AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
AGENT_WORKFLOW_GOODS_INFO_API_CONFIG_KEY,
AGENT_WORKFLOW_BRAND_MODEL_CONFIG_KEY,
AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY,
AGENT_PSD_TEXT_COLOR_CONFIG_KEY,
AGENT_PSD_TEMPLATE_SECONDARY_CATEGORY_CONFIG_KEY,
],
} as const;
export type SystemSettingSnapshotScope = keyof typeof SYSTEM_SETTING_SNAPSHOT_SCOPES;
export function getSystemSettingSnapshotScopeKeys(
scope: SystemSettingSnapshotScope
) {
return SYSTEM_SETTING_SNAPSHOT_SCOPES[scope];
}
export function createSystemSettingSnapshotData(
scope: SystemSettingSnapshotScope,
valueByKey: ReadonlyMap<string, unknown>
) {
const keys = getSystemSettingSnapshotScopeKeys(scope);
return Object.fromEntries(keys.map((key) => [key, valueByKey.get(key) ?? null]));
}
export function summarizeSystemSettingSnapshotData(
data: Record<string, unknown>
) {
const entries = Object.entries(data);
return {
keyCount: entries.length,
nonNullKeyCount: entries.filter(([, value]) => value !== null && value !== undefined).length,
byteLength: Buffer.byteLength(JSON.stringify(data), "utf8"),
};
}

View File

@ -43,6 +43,8 @@
"db:pull": "drizzle-kit pull",
"db:check": "drizzle-kit check",
"db:up": "drizzle-kit up",
"db:snapshot:system-settings": "pnpm exec tsx scripts/system-settings/create-snapshot.ts",
"db:restore:system-settings-snapshot": "pnpm exec tsx scripts/system-settings/restore-snapshot.ts",
"poc:nanobanana": "pnpm exec tsx scripts/nanobanana-browser-poc.ts",
"test": "export PLAYWRIGHT=True && pnpm exec playwright test",
"storage:configure-upload-cors": "pnpm exec tsx scripts/storage/configure-direct-upload-cors.ts",

View File

@ -0,0 +1,83 @@
import { config as loadEnv } from "dotenv";
import { inArray } from "drizzle-orm";
loadEnv({ path: ".env.local" });
function readArg(name: string) {
const prefix = `${name}=`;
const match = process.argv.find((arg) => arg.startsWith(prefix));
return match ? match.slice(prefix.length) : null;
}
async function main() {
const scopeArg = readArg("--scope");
const label = readArg("--label");
if (!scopeArg) {
throw new Error("Usage: pnpm tsx scripts/system-settings/create-snapshot.ts --scope=<scope> [--label=<label>]");
}
const dbModule = await import("../../lib/db");
const schemaModule = await import("../../lib/db/schema");
const snapshotModule = await import("../../lib/system-settings/snapshot");
const { db } = dbModule;
const { systemSettingSnapshot, systemSettings } = schemaModule;
const {
createSystemSettingSnapshotData,
getSystemSettingSnapshotScopeKeys,
summarizeSystemSettingSnapshotData,
} = snapshotModule;
const keys = getSystemSettingSnapshotScopeKeys(
scopeArg as keyof typeof snapshotModule.SYSTEM_SETTING_SNAPSHOT_SCOPES
);
const rows = await db
.select({
key: systemSettings.key,
value: systemSettings.value,
})
.from(systemSettings)
.where(inArray(systemSettings.key, [...keys]));
const valueByKey = new Map(rows.map((row) => [row.key, row.value]));
const snapshotData = createSystemSettingSnapshotData(
scopeArg as keyof typeof snapshotModule.SYSTEM_SETTING_SNAPSHOT_SCOPES,
valueByKey
);
const [snapshot] = await db
.insert(systemSettingSnapshot)
.values({
scope: scopeArg,
label: label || `manual-${scopeArg}-snapshot`,
summary: summarizeSystemSettingSnapshotData(snapshotData),
data: snapshotData,
createdAt: new Date(),
createdBy: null,
})
.returning({
id: systemSettingSnapshot.id,
scope: systemSettingSnapshot.scope,
label: systemSettingSnapshot.label,
createdAt: systemSettingSnapshot.createdAt,
});
console.log(
JSON.stringify(
{
snapshotId: snapshot?.id ?? null,
scope: snapshot?.scope ?? scopeArg,
label: snapshot?.label ?? label ?? null,
createdAt: snapshot?.createdAt ?? null,
keyCount: keys.length,
},
null,
2
)
);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@ -0,0 +1,103 @@
import { config as loadEnv } from "dotenv";
import { eq } from "drizzle-orm";
loadEnv({ path: ".env.local" });
function readArg(name: string) {
const prefix = `${name}=`;
const match = process.argv.find((arg) => arg.startsWith(prefix));
return match ? match.slice(prefix.length) : null;
}
async function main() {
const snapshotId = readArg("--snapshot-id");
const reason = readArg("--reason") || "manual-snapshot-restore";
if (!snapshotId) {
throw new Error(
"Usage: pnpm tsx scripts/system-settings/restore-snapshot.ts --snapshot-id=<id> [--reason=<reason>]"
);
}
const dbModule = await import("../../lib/db");
const schemaModule = await import("../../lib/db/schema");
const { db } = dbModule;
const { systemSettingHistory, systemSettingSnapshot, systemSettings } = schemaModule;
const [snapshot] = await db
.select({
id: systemSettingSnapshot.id,
scope: systemSettingSnapshot.scope,
data: systemSettingSnapshot.data,
})
.from(systemSettingSnapshot)
.where(eq(systemSettingSnapshot.id, snapshotId))
.limit(1);
if (!snapshot) {
throw new Error(`Snapshot not found: ${snapshotId}`);
}
const data =
snapshot.data && typeof snapshot.data === "object" && !Array.isArray(snapshot.data)
? (snapshot.data as Record<string, unknown>)
: {};
const keys = Object.keys(data);
await db.transaction(async (tx) => {
for (const key of keys) {
const [existing] = await tx
.select({ value: systemSettings.value })
.from(systemSettings)
.where(eq(systemSettings.key, key))
.limit(1);
const nextValue = data[key] ?? null;
await tx.insert(systemSettingHistory).values({
key,
previousValue: existing?.value ?? null,
nextValue,
reason,
changedAt: new Date(),
changedBy: null,
});
await tx
.insert(systemSettings)
.values({
key,
value: nextValue,
updatedAt: new Date(),
updatedBy: null,
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value: nextValue,
updatedAt: new Date(),
updatedBy: null,
},
});
}
});
console.log(
JSON.stringify(
{
snapshotId,
scope: snapshot.scope,
restoredKeyCount: keys.length,
reason,
},
null,
2
)
);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});