fix: guard agent workflow config rollback
This commit is contained in:
parent
180ee864d9
commit
ba46f97f3d
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 {
|
||||
|
||||
23
lib/db/migrations/0085_add_system_setting_snapshots.sql
Normal file
23
lib/db/migrations/0085_add_system_setting_snapshots.sql
Normal 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);
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
66
lib/system-settings/snapshot.ts
Normal file
66
lib/system-settings/snapshot.ts
Normal 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"),
|
||||
};
|
||||
}
|
||||
@ -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",
|
||||
|
||||
83
scripts/system-settings/create-snapshot.ts
Normal file
83
scripts/system-settings/create-snapshot.ts
Normal 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);
|
||||
});
|
||||
103
scripts/system-settings/restore-snapshot.ts
Normal file
103
scripts/system-settings/restore-snapshot.ts
Normal 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);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user