feat(storage): add aliyun oss auto migration and cleanup pipeline
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-05 12:05:24 +08:00
parent 82c9173467
commit bd418a3fdb
10 changed files with 1339 additions and 27 deletions

View File

@ -235,6 +235,7 @@ type SiteSettings = {
lovartPromptTagVisibility: LovartPromptTagVisibility;
contactConfig: ContactConfig;
storageFailoverConfig: StorageFailoverConfig;
ossImageMigrationConfig: OssImageMigrationConfig;
pointsSystemConfig: PointsSystemConfig;
pointsPackages: PointsPackage[];
dulupayConfig: DulupayConfig;
@ -267,6 +268,15 @@ type StorageFailoverConfig = {
backupStorageConfigId: string;
};
type OssImageMigrationConfig = {
enabled: boolean;
sourceStorageConfigId: string;
targetStorageConfigId: string;
migrateAfterHours: number;
cleanupAfterHours: number;
batchSize: number;
};
const defaultLovartPromptTagVisibility: LovartPromptTagVisibility = {
nanoBananaPro: true,
design: true,
@ -286,6 +296,15 @@ const defaultStorageFailoverConfig: StorageFailoverConfig = {
backupStorageConfigId: "",
};
const defaultOssImageMigrationConfig: OssImageMigrationConfig = {
enabled: true,
sourceStorageConfigId: "",
targetStorageConfigId: "",
migrateAfterHours: 48,
cleanupAfterHours: 72,
batchSize: 20,
};
const lovartPromptTagOptions: Array<{
key: keyof LovartPromptTagVisibility;
label: string;
@ -582,6 +601,7 @@ export function AdminSettingsClient() {
lovartPromptTagVisibility: defaultLovartPromptTagVisibility,
contactConfig: defaultContactConfig,
storageFailoverConfig: defaultStorageFailoverConfig,
ossImageMigrationConfig: defaultOssImageMigrationConfig,
pointsSystemConfig: defaultPointsSystemConfig,
pointsPackages: defaultPointsPackages,
dulupayConfig: defaultDulupayConfig,
@ -675,6 +695,39 @@ export function AdminSettingsClient() {
(siteSettingsData as any).storageFailoverConfig?.backupStorageConfigId || ""
).trim(),
},
ossImageMigrationConfig: {
enabled: (siteSettingsData as any).ossImageMigrationConfig?.enabled !== false,
sourceStorageConfigId: String(
(siteSettingsData as any).ossImageMigrationConfig?.sourceStorageConfigId || ""
).trim(),
targetStorageConfigId: String(
(siteSettingsData as any).ossImageMigrationConfig?.targetStorageConfigId || ""
).trim(),
migrateAfterHours: Math.max(
1,
Math.min(
720,
Number((siteSettingsData as any).ossImageMigrationConfig?.migrateAfterHours) ||
defaultOssImageMigrationConfig.migrateAfterHours
)
),
cleanupAfterHours: Math.max(
2,
Math.min(
1440,
Number((siteSettingsData as any).ossImageMigrationConfig?.cleanupAfterHours) ||
defaultOssImageMigrationConfig.cleanupAfterHours
)
),
batchSize: Math.max(
1,
Math.min(
100,
Number((siteSettingsData as any).ossImageMigrationConfig?.batchSize) ||
defaultOssImageMigrationConfig.batchSize
)
),
},
pointsSystemConfig: normalizedPointsSystemConfig,
pointsPackages: normalizedPointsPackages,
dulupayConfig: normalizedDulupayConfig,
@ -1524,6 +1577,38 @@ export function AdminSettingsClient() {
return;
}
if (siteSettings.ossImageMigrationConfig.enabled) {
if (
siteSettings.ossImageMigrationConfig.cleanupAfterHours <=
siteSettings.ossImageMigrationConfig.migrateAfterHours
) {
toast.error("OSS 清理时间必须晚于转存时间");
return;
}
if (
siteSettings.ossImageMigrationConfig.sourceStorageConfigId &&
siteSettings.ossImageMigrationConfig.targetStorageConfigId &&
siteSettings.ossImageMigrationConfig.sourceStorageConfigId ===
siteSettings.ossImageMigrationConfig.targetStorageConfigId
) {
toast.error("OSS 源桶和目标桶不能相同");
return;
}
const targetConfig = (storageData?.configs || []).find(
(config) => config.id === siteSettings.ossImageMigrationConfig.targetStorageConfigId
);
if (
targetConfig &&
targetConfig.type === "s3" &&
targetConfig.endpoint?.includes("aliyuncs.com")
) {
toast.error("OSS 转存目标桶不能是阿里云 OSS");
return;
}
}
const parsedMembershipConfig = normalizeMembershipConfig(siteSettings.membershipConfig);
const parsedMembershipOverrides = normalizeMembershipUserOverrides(
siteSettings.membershipUserOverrides
@ -7307,6 +7392,185 @@ export function AdminSettingsClient() {
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>阿里云 OSS 自动转存</CardTitle>
<CardDescription>
若已配置阿里云 OSS,上传优先进入 OSS;到期后自动转存到目标默认桶并替换数据库 URL。
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1">
<Label className="text-sm font-medium">启用 OSS 自动转存</Label>
<p className="text-xs text-muted-foreground">
开启后执行“先 OSS、后迁移、再清理”的生命周期策略(不依赖 CDN)。
</p>
</div>
<Switch
checked={siteSettings.ossImageMigrationConfig.enabled}
onCheckedChange={(checked) =>
setSiteSettings((prev) => ({
...prev,
ossImageMigrationConfig: {
...prev.ossImageMigrationConfig,
enabled: checked,
},
}))
}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>OSS 源桶(可选指定)</Label>
<Select
value={siteSettings.ossImageMigrationConfig.sourceStorageConfigId || "auto"}
onValueChange={(value) =>
setSiteSettings((prev) => ({
...prev,
ossImageMigrationConfig: {
...prev.ossImageMigrationConfig,
sourceStorageConfigId: value === "auto" ? "" : value,
},
}))
}
>
<SelectTrigger>
<SelectValue placeholder="自动选择阿里云 OSS(默认优先)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">自动选择阿里云 OSS</SelectItem>
{(storageData?.configs || [])
.filter(
(config) =>
config.type === "s3" &&
config.isEnabled &&
config.endpoint?.includes("aliyuncs.com")
)
.map((config) => (
<SelectItem key={config.id} value={config.id}>
{config.name}
{config.isDefault ? "(默认)" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>目标转存桶(可选指定)</Label>
<Select
value={siteSettings.ossImageMigrationConfig.targetStorageConfigId || "default"}
onValueChange={(value) =>
setSiteSettings((prev) => ({
...prev,
ossImageMigrationConfig: {
...prev.ossImageMigrationConfig,
targetStorageConfigId: value === "default" ? "" : value,
},
}))
}
>
<SelectTrigger>
<SelectValue placeholder="默认存储桶(建议非阿里云)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">使用当前默认存储桶</SelectItem>
{(storageData?.configs || [])
.filter((config) => config.type === "s3" && config.isEnabled)
.map((config) => (
<SelectItem key={config.id} value={config.id}>
{config.name}
{config.endpoint?.includes("aliyuncs.com")
? "(阿里云 OSS,不建议)"
: ""}
{config.isDefault ? "(默认)" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label>转存时间(小时)</Label>
<Input
type="number"
min={1}
max={720}
value={siteSettings.ossImageMigrationConfig.migrateAfterHours}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
ossImageMigrationConfig: {
...prev.ossImageMigrationConfig,
migrateAfterHours: Math.max(
1,
Math.min(720, Number(e.target.value) || 48)
),
},
}))
}
/>
</div>
<div className="space-y-2">
<Label>清理时间(小时)</Label>
<Input
type="number"
min={2}
max={1440}
value={siteSettings.ossImageMigrationConfig.cleanupAfterHours}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
ossImageMigrationConfig: {
...prev.ossImageMigrationConfig,
cleanupAfterHours: Math.max(
2,
Math.min(1440, Number(e.target.value) || 72)
),
},
}))
}
/>
</div>
<div className="space-y-2">
<Label>单次批量任务</Label>
<Input
type="number"
min={1}
max={100}
value={siteSettings.ossImageMigrationConfig.batchSize}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
ossImageMigrationConfig: {
...prev.ossImageMigrationConfig,
batchSize: Math.max(
1,
Math.min(100, Number(e.target.value) || 20)
),
},
}))
}
/>
</div>
</div>
<div className="rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-700">
推荐值:转存 48 小时,清理 72 小时。并配置定时调用
<code className="mx-1">/api/jobs/migrate-oss-images</code>
执行迁移任务。
</div>
<div className="flex justify-end">
<Button onClick={handleSaveSiteSettings}>保存 OSS 自动转存设置</Button>
</div>
</CardContent>
</Card>
</TabsContent>
{/* 本地AI设置 */}

View File

@ -0,0 +1,71 @@
import { NextResponse } from "next/server";
import {
getOssImageMigrationJobStats,
runOssImageMigrationJob,
} from "@/lib/jobs/oss-image-migration-job";
function verifyAuthToken(request: Request): boolean {
const authHeader = request.headers.get("authorization");
const token = authHeader?.replace("Bearer ", "");
const cronSecret = process.env.CRON_SECRET;
if (!cronSecret) {
console.warn("[OSSMigrationAPI] ⚠️ CRON_SECRET 未配置,允许所有请求");
return true;
}
return token === cronSecret;
}
export async function POST(request: Request) {
try {
if (!verifyAuthToken(request)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const result = await runOssImageMigrationJob();
return NextResponse.json({
success: result.success,
message: result.message,
migrate: result.migrate,
cleanup: result.cleanup,
skippedReason: result.skippedReason || null,
timestamp: new Date().toISOString(),
});
} catch (error: any) {
console.error("[OSSMigrationAPI] ❌ Job execution failed:", error);
return NextResponse.json(
{
success: false,
error: error?.message || "Job failed",
timestamp: new Date().toISOString(),
},
{ status: 500 }
);
}
}
export async function GET(request: Request) {
try {
if (!verifyAuthToken(request)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const data = await getOssImageMigrationJobStats();
return NextResponse.json({
...data,
timestamp: new Date().toISOString(),
});
} catch (error: any) {
console.error("[OSSMigrationAPI] ❌ Query failed:", error);
return NextResponse.json(
{
success: false,
error: error?.message || "Query failed",
timestamp: new Date().toISOString(),
},
{ status: 500 }
);
}
}

View File

@ -24,6 +24,11 @@ import {
normalizeDulupayConfig,
normalizeDulupayForPublic,
} from "@/lib/payment/dulupay-config";
import {
defaultOssImageMigrationConfig,
normalizeOssImageMigrationConfig,
OSS_MIGRATION_CONFIG_KEY,
} from "@/lib/storage/migration-config";
const HOME_VARIANT_KEY = "home_variant";
// Historically stored as "home_surprise_categories" but used for the featured gallery categories.
@ -173,6 +178,7 @@ export async function GET() {
dulupayConfigSetting,
realtimeCanvasConfigSetting,
storageFailoverConfigSetting,
ossImageMigrationConfigSetting,
] = await Promise.all([
db.select().from(siteSettings).limit(1).then(rows => rows[0]),
db
@ -265,6 +271,12 @@ export async function GET() {
.where(eq(systemSettings.key, STORAGE_FAILOVER_CONFIG_KEY))
.limit(1)
.then(rows => rows[0]),
db
.select()
.from(systemSettings)
.where(eq(systemSettings.key, OSS_MIGRATION_CONFIG_KEY))
.limit(1)
.then(rows => rows[0]),
]);
const notification = notificationSetting?.value || { enabled: false, title: "", content: "" };
@ -297,6 +309,9 @@ export async function GET() {
const storageFailoverConfig = normalizeStorageFailoverConfig(
storageFailoverConfigSetting?.value
);
const ossImageMigrationConfig = normalizeOssImageMigrationConfig(
ossImageMigrationConfigSetting?.value
);
// 如果没有设置记录,返回默认值
const baseSettings = settings || {
@ -324,6 +339,7 @@ export async function GET() {
realtimeCanvasEnabled: true,
realtimeCanvasServiceEnabled: true,
storageFailoverConfig,
ossImageMigrationConfig,
canvasDefaultModelId: null,
canvasAutoModeDefaultModelId: null,
canvasDetailDefaultModelId: null,
@ -368,6 +384,7 @@ export async function GET() {
dulupayConfig,
realtimeCanvasServiceEnabled,
storageFailoverConfig,
ossImageMigrationConfig,
});
// 禁用缓存,确保总是获取最新设置
@ -447,6 +464,16 @@ const updateSchema = z.object({
backupStorageConfigId: z.string().max(128).optional(),
})
.optional(),
ossImageMigrationConfig: z
.object({
enabled: z.boolean().optional(),
sourceStorageConfigId: z.string().max(128).optional(),
targetStorageConfigId: z.string().max(128).optional(),
migrateAfterHours: z.number().min(1).max(720).optional(),
cleanupAfterHours: z.number().min(1).max(1440).optional(),
batchSize: z.number().min(1).max(100).optional(),
})
.optional(),
});
// PUT /api/site-settings - 更新网站设置(仅管理员)
@ -478,6 +505,7 @@ export async function PUT(request: NextRequest) {
pointsPackages,
dulupayConfig,
storageFailoverConfig,
ossImageMigrationConfig,
...siteSettingsPayload
} = data;
@ -754,6 +782,26 @@ export async function PUT(request: NextRequest) {
});
}
if (ossImageMigrationConfig !== undefined) {
const normalized = normalizeOssImageMigrationConfig(ossImageMigrationConfig);
await db
.insert(systemSettings)
.values({
key: OSS_MIGRATION_CONFIG_KEY,
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
},
});
}
return NextResponse.json({
...updated,
homeVariant: homeVariant ?? normalizeHomeVariant(undefined),
@ -802,6 +850,10 @@ export async function PUT(request: NextRequest) {
storageFailoverConfig !== undefined
? normalizeStorageFailoverConfig(storageFailoverConfig)
: normalizeStorageFailoverConfig(undefined),
ossImageMigrationConfig:
ossImageMigrationConfig !== undefined
? normalizeOssImageMigrationConfig(ossImageMigrationConfig)
: defaultOssImageMigrationConfig,
});
} else {
// 创建新设置
@ -1101,6 +1153,26 @@ export async function PUT(request: NextRequest) {
});
}
if (ossImageMigrationConfig !== undefined) {
const normalized = normalizeOssImageMigrationConfig(ossImageMigrationConfig);
await db
.insert(systemSettings)
.values({
key: OSS_MIGRATION_CONFIG_KEY,
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
},
});
}
return NextResponse.json({
...created,
homeVariant: homeVariant ?? normalizeHomeVariant(undefined),
@ -1149,6 +1221,10 @@ export async function PUT(request: NextRequest) {
storageFailoverConfig !== undefined
? normalizeStorageFailoverConfig(storageFailoverConfig)
: normalizeStorageFailoverConfig(undefined),
ossImageMigrationConfig:
ossImageMigrationConfig !== undefined
? normalizeOssImageMigrationConfig(ossImageMigrationConfig)
: defaultOssImageMigrationConfig,
});
}
} catch (error) {

View File

@ -0,0 +1,23 @@
CREATE TABLE IF NOT EXISTS "StorageMigrationTask" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"sourceUrl" text NOT NULL UNIQUE,
"sourcePath" text NOT NULL,
"sourceStorageConfigId" uuid REFERENCES "StorageConfig"("id"),
"targetStorageConfigId" uuid REFERENCES "StorageConfig"("id"),
"targetUrl" text,
"status" varchar(32) NOT NULL DEFAULT 'pending',
"attemptCount" integer NOT NULL DEFAULT 0,
"lastError" text,
"migrateAfterAt" timestamp NOT NULL,
"cleanupAfterAt" timestamp NOT NULL,
"migratedAt" timestamp,
"cleanedAt" timestamp,
"createdAt" timestamp NOT NULL DEFAULT now(),
"updatedAt" timestamp NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS "StorageMigrationTask_status_migrate_idx"
ON "StorageMigrationTask"("status", "migrateAfterAt");
CREATE INDEX IF NOT EXISTS "StorageMigrationTask_status_cleanup_idx"
ON "StorageMigrationTask"("status", "cleanupAfterAt");

View File

@ -232,6 +232,13 @@
"when": 1772517600000,
"tag": "0046_add_canvas_detail_generation_mode",
"breakpoints": true
},
{
"idx": 33,
"version": "7",
"when": 1772604000000,
"tag": "0047_add_storage_migration_tasks",
"breakpoints": true
}
]
}

View File

@ -324,6 +324,27 @@ export const storageConfig = pgTable("StorageConfig", {
export type StorageConfig = InferSelectModel<typeof storageConfig>;
// 阿里云 OSS 图片迁移任务表
export const storageMigrationTask = pgTable("StorageMigrationTask", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
sourceUrl: text("sourceUrl").notNull().unique(),
sourcePath: text("sourcePath").notNull(),
sourceStorageConfigId: uuid("sourceStorageConfigId").references(() => storageConfig.id),
targetStorageConfigId: uuid("targetStorageConfigId").references(() => storageConfig.id),
targetUrl: text("targetUrl"),
status: varchar("status", { length: 32 }).notNull().default("pending"),
attemptCount: integer("attemptCount").notNull().default(0),
lastError: text("lastError"),
migrateAfterAt: timestamp("migrateAfterAt").notNull(),
cleanupAfterAt: timestamp("cleanupAfterAt").notNull(),
migratedAt: timestamp("migratedAt"),
cleanedAt: timestamp("cleanedAt"),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
});
export type StorageMigrationTask = InferSelectModel<typeof storageMigrationTask>;
// 网站设置表
export const siteSettings = pgTable("SiteSettings", {
id: uuid("id").primaryKey().notNull().defaultRandom(),

View File

@ -0,0 +1,410 @@
import "server-only";
import { and, eq, like, or, sql } from "drizzle-orm";
import { db } from "@/lib/db";
import {
canvasDetailArchive,
canvasProject,
generatedImage,
message,
messageDeprecated,
} from "@/lib/db/schema";
import { getDefaultStorageConfig, getSystemSetting } from "@/lib/db/queries";
import {
getStorageConfigByIdForMigration,
getStorageMigrationTaskStats,
getStorageTasksToCleanup,
getStorageTasksToMigrate,
isAliyunStorageConfig,
markStorageTaskCleaned,
markStorageTaskCleanupFailed,
markStorageTaskMigrated,
markStorageTaskMigrating,
markStorageTaskMigrationFailed,
} from "@/lib/storage/migration-db";
import {
normalizeOssImageMigrationConfig,
OSS_MIGRATION_CONFIG_KEY,
} from "@/lib/storage/migration-config";
import {
deleteS3ObjectByPath,
toDbS3UploadConfig,
uploadFileWithS3Config,
} from "@/lib/storage/service";
type JobResult = {
success: boolean;
message: string;
migrate: { total: number; succeeded: number; failed: number };
cleanup: { total: number; succeeded: number; failed: number };
skippedReason?: string;
};
function toArrayBuffer(buffer: Buffer): ArrayBuffer {
return buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
}
function deriveFilenameFromPath(pathname: string): string {
const cleaned = String(pathname || "").split("?")[0].replace(/^\/+/, "");
const tail = cleaned.split("/").pop() || "";
return tail || `image-${Date.now()}.png`;
}
async function downloadImageAsBuffer(url: string, retries = 2): Promise<Buffer> {
for (let attempt = 0; attempt <= retries; attempt += 1) {
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(30_000),
headers: {
"User-Agent": "AI-Chatbot-OSS-Migrator/1.0",
},
});
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
if (!arrayBuffer || arrayBuffer.byteLength === 0) {
throw new Error("Downloaded file is empty");
}
return Buffer.from(arrayBuffer);
} catch (error) {
if (attempt >= retries) {
throw error;
}
await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1)));
}
}
throw new Error("Download failed");
}
function replaceDeepUrls(
value: unknown,
sourceUrl: string,
targetUrl: string
): { value: unknown; changed: boolean } {
if (typeof value === "string") {
if (!value.includes(sourceUrl)) {
return { value, changed: false };
}
return { value: value.split(sourceUrl).join(targetUrl), changed: true };
}
if (Array.isArray(value)) {
let changed = false;
const next = value.map((item) => {
const result = replaceDeepUrls(item, sourceUrl, targetUrl);
if (result.changed) changed = true;
return result.value;
});
return { value: changed ? next : value, changed };
}
if (value && typeof value === "object") {
let changed = false;
const next: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
const result = replaceDeepUrls(item, sourceUrl, targetUrl);
next[key] = result.value;
if (result.changed) changed = true;
}
return {
value: changed ? next : value,
changed,
};
}
return { value, changed: false };
}
async function replaceImageUrlInDatabase(sourceUrl: string, targetUrl: string) {
if (!sourceUrl || !targetUrl || sourceUrl === targetUrl) return;
await db
.update(generatedImage)
.set({ thumbnailUrl: targetUrl })
.where(eq(generatedImage.thumbnailUrl, sourceUrl));
await db
.update(generatedImage)
.set({ originalUrl: targetUrl })
.where(eq(generatedImage.originalUrl, sourceUrl));
const messages = await db
.select({
id: message.id,
parts: message.parts,
attachments: message.attachments,
})
.from(message)
.where(
or(
sql`${message.parts}::text like ${`%${sourceUrl}%`}`,
sql`${message.attachments}::text like ${`%${sourceUrl}%`}`
)
)
.limit(500);
for (const item of messages) {
const partsResult = replaceDeepUrls(item.parts, sourceUrl, targetUrl);
const attachmentsResult = replaceDeepUrls(item.attachments, sourceUrl, targetUrl);
if (!partsResult.changed && !attachmentsResult.changed) continue;
await db
.update(message)
.set({
parts: (partsResult.value ?? item.parts) as any,
attachments: (attachmentsResult.value ?? item.attachments) as any,
})
.where(eq(message.id, item.id));
}
const legacyMessages = await db
.select({
id: messageDeprecated.id,
content: messageDeprecated.content,
})
.from(messageDeprecated)
.where(sql`${messageDeprecated.content}::text like ${`%${sourceUrl}%`}`)
.limit(500);
for (const item of legacyMessages) {
const replaced = replaceDeepUrls(item.content, sourceUrl, targetUrl);
if (!replaced.changed) continue;
await db
.update(messageDeprecated)
.set({
content: replaced.value as any,
})
.where(eq(messageDeprecated.id, item.id));
}
const projects = await db
.select({
id: canvasProject.id,
canvasData: canvasProject.canvasData,
})
.from(canvasProject)
.where(sql`${canvasProject.canvasData}::text like ${`%${sourceUrl}%`}`)
.limit(200);
for (const item of projects) {
const replaced = replaceDeepUrls(item.canvasData, sourceUrl, targetUrl);
if (!replaced.changed) continue;
await db
.update(canvasProject)
.set({
canvasData: replaced.value as any,
updatedAt: new Date(),
})
.where(eq(canvasProject.id, item.id));
}
const detailArchives = await db
.select({
id: canvasDetailArchive.id,
coverUrl: canvasDetailArchive.coverUrl,
brief: canvasDetailArchive.brief,
slots: canvasDetailArchive.slots,
})
.from(canvasDetailArchive)
.where(
or(
like(canvasDetailArchive.coverUrl, `%${sourceUrl}%`),
sql`${canvasDetailArchive.brief}::text like ${`%${sourceUrl}%`}`,
sql`${canvasDetailArchive.slots}::text like ${`%${sourceUrl}%`}`
)
)
.limit(200);
for (const item of detailArchives) {
const cover = replaceDeepUrls(item.coverUrl, sourceUrl, targetUrl);
const brief = replaceDeepUrls(item.brief, sourceUrl, targetUrl);
const slots = replaceDeepUrls(item.slots, sourceUrl, targetUrl);
if (!cover.changed && !brief.changed && !slots.changed) continue;
await db
.update(canvasDetailArchive)
.set({
coverUrl: (cover.value as string | null | undefined) ?? item.coverUrl,
brief: (brief.value ?? item.brief) as any,
slots: (slots.value ?? item.slots) as any,
updatedAt: new Date(),
})
.where(eq(canvasDetailArchive.id, item.id));
}
}
async function resolveMigrationConfigs() {
const setting = await getSystemSetting({
key: OSS_MIGRATION_CONFIG_KEY,
}).catch(() => undefined);
const config = normalizeOssImageMigrationConfig(setting?.value);
if (!config.enabled) {
return {
config,
targetConfig: undefined,
skipReason: "OSS migration is disabled",
};
}
const targetConfig =
(await getStorageConfigByIdForMigration({
id: config.targetStorageConfigId || undefined,
})) || (await getDefaultStorageConfig());
if (!targetConfig) {
return {
config,
targetConfig: undefined,
skipReason: "No target storage config found",
};
}
if (isAliyunStorageConfig(targetConfig)) {
return {
config,
targetConfig: undefined,
skipReason: "Target storage config cannot be Aliyun OSS",
};
}
return { config, targetConfig, skipReason: undefined };
}
export async function runOssImageMigrationJob(): Promise<JobResult> {
console.log("[OSSMigrationJob] ⏰ Start running OSS migration job...");
const { config, targetConfig, skipReason } = await resolveMigrationConfigs();
if (!targetConfig) {
return {
success: true,
message: "OSS migration skipped",
migrate: { total: 0, succeeded: 0, failed: 0 },
cleanup: { total: 0, succeeded: 0, failed: 0 },
skippedReason: skipReason,
};
}
const targetS3Config = toDbS3UploadConfig(targetConfig);
if (!targetS3Config) {
return {
success: false,
message: "Target storage config is invalid",
migrate: { total: 0, succeeded: 0, failed: 0 },
cleanup: { total: 0, succeeded: 0, failed: 0 },
};
}
const tasks = await getStorageTasksToMigrate({ limit: config.batchSize });
let migrateSucceeded = 0;
let migrateFailed = 0;
for (const task of tasks) {
await markStorageTaskMigrating(task.id);
try {
const sourceConfig = await getStorageConfigByIdForMigration({
id: task.sourceStorageConfigId,
});
if (!sourceConfig || !isAliyunStorageConfig(sourceConfig)) {
throw new Error("Source Aliyun OSS config is missing or disabled");
}
if (sourceConfig.id === targetConfig.id) {
throw new Error("Source and target storage configs are identical");
}
const filename = deriveFilenameFromPath(task.sourcePath || task.sourceUrl);
const downloaded = await downloadImageAsBuffer(task.sourceUrl);
const uploaded = await uploadFileWithS3Config(
filename,
toArrayBuffer(downloaded),
targetS3Config
);
await replaceImageUrlInDatabase(task.sourceUrl, uploaded.url);
await markStorageTaskMigrated({
id: task.id,
targetUrl: uploaded.url,
targetStorageConfigId: targetConfig.id,
});
migrateSucceeded += 1;
} catch (error) {
console.error(`[OSSMigrationJob] migrate failed (${task.id}):`, error);
await markStorageTaskMigrationFailed({ id: task.id, error });
migrateFailed += 1;
}
}
const cleanupTasks = await getStorageTasksToCleanup({ limit: config.batchSize });
let cleanupSucceeded = 0;
let cleanupFailed = 0;
for (const task of cleanupTasks) {
try {
const sourceConfig = await getStorageConfigByIdForMigration({
id: task.sourceStorageConfigId,
});
if (!sourceConfig || !isAliyunStorageConfig(sourceConfig)) {
throw new Error("Source Aliyun OSS config unavailable for cleanup");
}
const sourceS3Config = toDbS3UploadConfig(sourceConfig);
if (!sourceS3Config) {
throw new Error("Source Aliyun OSS config is invalid");
}
await deleteS3ObjectByPath(task.sourcePath, sourceS3Config);
await markStorageTaskCleaned(task.id);
cleanupSucceeded += 1;
} catch (error) {
console.error(`[OSSMigrationJob] cleanup failed (${task.id}):`, error);
await markStorageTaskCleanupFailed({ id: task.id, error });
cleanupFailed += 1;
}
}
return {
success: true,
message: "OSS migration job completed",
migrate: {
total: tasks.length,
succeeded: migrateSucceeded,
failed: migrateFailed,
},
cleanup: {
total: cleanupTasks.length,
succeeded: cleanupSucceeded,
failed: cleanupFailed,
},
};
}
export async function getOssImageMigrationJobStats() {
const { config, targetConfig, skipReason } = await resolveMigrationConfigs();
const stats = await getStorageMigrationTaskStats();
return {
config,
targetStorageConfig: targetConfig
? {
id: targetConfig.id,
name: targetConfig.name,
bucket: targetConfig.bucket,
endpoint: targetConfig.endpoint,
}
: null,
skipReason: skipReason || null,
stats,
};
}

View File

@ -0,0 +1,61 @@
export const OSS_MIGRATION_CONFIG_KEY = "oss_image_migration_config";
export type OssImageMigrationConfig = {
enabled: boolean;
sourceStorageConfigId: string;
targetStorageConfigId: string;
migrateAfterHours: number;
cleanupAfterHours: number;
batchSize: number;
};
export const defaultOssImageMigrationConfig: OssImageMigrationConfig = {
enabled: true,
sourceStorageConfigId: "",
targetStorageConfigId: "",
migrateAfterHours: 48,
cleanupAfterHours: 72,
batchSize: 20,
};
export function normalizeOssImageMigrationConfig(
value: unknown
): OssImageMigrationConfig {
const raw =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
const migrateAfterHours = Number(raw.migrateAfterHours);
const cleanupAfterHours = Number(raw.cleanupAfterHours);
const batchSize = Number(raw.batchSize);
const normalizedMigrateAfterHours =
Number.isFinite(migrateAfterHours) && migrateAfterHours >= 1
? Math.min(720, Math.round(migrateAfterHours))
: defaultOssImageMigrationConfig.migrateAfterHours;
const normalizedCleanupAfterHours =
Number.isFinite(cleanupAfterHours) &&
cleanupAfterHours > normalizedMigrateAfterHours
? Math.min(1440, Math.round(cleanupAfterHours))
: Math.max(
defaultOssImageMigrationConfig.cleanupAfterHours,
normalizedMigrateAfterHours + 1
);
const normalizedBatchSize =
Number.isFinite(batchSize) && batchSize >= 1
? Math.min(100, Math.round(batchSize))
: defaultOssImageMigrationConfig.batchSize;
return {
enabled: raw.enabled !== false,
sourceStorageConfigId: String(raw.sourceStorageConfigId || "").trim(),
targetStorageConfigId: String(raw.targetStorageConfigId || "").trim(),
migrateAfterHours: normalizedMigrateAfterHours,
cleanupAfterHours: normalizedCleanupAfterHours,
batchSize: normalizedBatchSize,
};
}

250
lib/storage/migration-db.ts Normal file
View File

@ -0,0 +1,250 @@
import "server-only";
import { and, asc, desc, eq, inArray, lte, sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { storageConfig, storageMigrationTask, type StorageConfig } from "@/lib/db/schema";
export type StorageMigrationTaskRecord = typeof storageMigrationTask.$inferSelect;
export function isAliyunStorageConfig(config: Pick<StorageConfig, "type" | "endpoint"> | null | undefined) {
if (!config) return false;
return config.type === "s3" && /aliyuncs\.com/i.test(String(config.endpoint || ""));
}
function isUsableS3Config(config: StorageConfig | null | undefined): config is StorageConfig {
return Boolean(
config &&
config.type === "s3" &&
config.isEnabled &&
String(config.endpoint || "").trim() &&
String(config.bucket || "").trim() &&
String(config.accessKeyId || "").trim() &&
String(config.secretAccessKey || "").trim()
);
}
export async function getEnabledS3StorageConfigs(): Promise<StorageConfig[]> {
const configs = await db
.select()
.from(storageConfig)
.where(and(eq(storageConfig.type, "s3"), eq(storageConfig.isEnabled, true)))
.orderBy(desc(storageConfig.isDefault), asc(storageConfig.createdAt));
return configs.filter(isUsableS3Config);
}
export async function getPreferredAliyunStorageConfig({
preferredId,
}: {
preferredId?: string;
} = {}): Promise<StorageConfig | undefined> {
const configs = await getEnabledS3StorageConfigs();
const aliyunConfigs = configs.filter((config) => isAliyunStorageConfig(config));
if (aliyunConfigs.length === 0) return undefined;
if (preferredId) {
const preferred = aliyunConfigs.find((item) => item.id === preferredId);
if (preferred) return preferred;
}
const defaultAliyun = aliyunConfigs.find((item) => item.isDefault);
return defaultAliyun || aliyunConfigs[0];
}
export async function getStorageConfigByIdForMigration({
id,
}: {
id?: string | null;
}): Promise<StorageConfig | undefined> {
if (!id) return undefined;
const [config] = await db
.select()
.from(storageConfig)
.where(eq(storageConfig.id, id))
.limit(1);
return isUsableS3Config(config) ? config : undefined;
}
export async function enqueueStorageMigrationTask({
sourceUrl,
sourcePath,
sourceStorageConfigId,
migrateAfterAt,
cleanupAfterAt,
}: {
sourceUrl: string;
sourcePath: string;
sourceStorageConfigId: string;
migrateAfterAt: Date;
cleanupAfterAt: Date;
}) {
if (!sourceUrl || !sourcePath || !sourceStorageConfigId) return;
await db
.insert(storageMigrationTask)
.values({
sourceUrl,
sourcePath,
sourceStorageConfigId,
migrateAfterAt,
cleanupAfterAt,
status: "pending",
updatedAt: new Date(),
})
.onConflictDoNothing({
target: storageMigrationTask.sourceUrl,
});
}
export async function getStorageTasksToMigrate({
limit,
}: {
limit: number;
}) {
const now = new Date();
return await db
.select()
.from(storageMigrationTask)
.where(
and(
inArray(storageMigrationTask.status, ["pending", "failed"]),
lte(storageMigrationTask.migrateAfterAt, now)
)
)
.orderBy(asc(storageMigrationTask.migrateAfterAt))
.limit(Math.max(1, Math.min(100, limit)));
}
export async function markStorageTaskMigrating(id: string) {
await db
.update(storageMigrationTask)
.set({
status: "migrating",
attemptCount: sql`${storageMigrationTask.attemptCount} + 1`,
updatedAt: new Date(),
})
.where(eq(storageMigrationTask.id, id));
}
export async function markStorageTaskMigrated({
id,
targetUrl,
targetStorageConfigId,
}: {
id: string;
targetUrl: string;
targetStorageConfigId: string;
}) {
await db
.update(storageMigrationTask)
.set({
status: "migrated",
targetUrl,
targetStorageConfigId,
migratedAt: new Date(),
lastError: null,
updatedAt: new Date(),
})
.where(eq(storageMigrationTask.id, id));
}
export async function markStorageTaskMigrationFailed({
id,
error,
}: {
id: string;
error: unknown;
}) {
const message = String(error instanceof Error ? error.message : error || "Unknown error").slice(
0,
2000
);
await db
.update(storageMigrationTask)
.set({
status: "failed",
lastError: message,
updatedAt: new Date(),
})
.where(eq(storageMigrationTask.id, id));
}
export async function getStorageTasksToCleanup({
limit,
}: {
limit: number;
}) {
const now = new Date();
return await db
.select()
.from(storageMigrationTask)
.where(
and(
eq(storageMigrationTask.status, "migrated"),
lte(storageMigrationTask.cleanupAfterAt, now)
)
)
.orderBy(asc(storageMigrationTask.cleanupAfterAt))
.limit(Math.max(1, Math.min(100, limit)));
}
export async function markStorageTaskCleaned(id: string) {
await db
.update(storageMigrationTask)
.set({
status: "deleted",
cleanedAt: new Date(),
lastError: null,
updatedAt: new Date(),
})
.where(eq(storageMigrationTask.id, id));
}
export async function markStorageTaskCleanupFailed({
id,
error,
}: {
id: string;
error: unknown;
}) {
const message = String(error instanceof Error ? error.message : error || "Unknown error").slice(
0,
2000
);
await db
.update(storageMigrationTask)
.set({
lastError: message,
updatedAt: new Date(),
})
.where(eq(storageMigrationTask.id, id));
}
export async function getStorageMigrationTaskStats() {
const grouped = await db
.select({
status: storageMigrationTask.status,
count: sql<number>`count(*)::int`,
})
.from(storageMigrationTask)
.groupBy(storageMigrationTask.status);
const pending = grouped
.filter((item) => item.status === "pending" || item.status === "failed")
.reduce((sum, item) => sum + (item.count || 0), 0);
const cleanupPending = grouped
.filter((item) => item.status === "migrated")
.reduce((sum, item) => sum + (item.count || 0), 0);
return {
grouped,
pending,
cleanupPending,
};
}

View File

@ -1,14 +1,26 @@
import "server-only";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { DeleteObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { put } from "@vercel/blob";
import {
getDefaultStorageConfig,
getStorageConfigById,
getSystemSetting,
} from "@/lib/db/queries";
import {
enqueueStorageMigrationTask,
getPreferredAliyunStorageConfig,
isAliyunStorageConfig,
} from "@/lib/storage/migration-db";
import {
defaultOssImageMigrationConfig,
normalizeOssImageMigrationConfig,
OSS_MIGRATION_CONFIG_KEY,
type OssImageMigrationConfig,
} from "@/lib/storage/migration-config";
import type { StorageConfig } from "@/lib/db/schema";
type S3UploadConfig = {
export type S3UploadConfig = {
endpoint: string;
region: string;
bucket: string;
@ -59,6 +71,24 @@ function getContentType(filename: string): string {
return types[ext ?? ""] ?? "application/octet-stream";
}
function createS3Client(config: S3UploadConfig): S3Client {
const normalizedEndpoint = normalizeEndpoint(config.endpoint);
const lowerEndpoint = normalizedEndpoint.toLowerCase();
const isCloudS3 =
lowerEndpoint.includes("aliyuncs.com") ||
lowerEndpoint.includes("amazonaws.com");
return new S3Client({
endpoint: normalizedEndpoint,
region: config.region || "auto",
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
forcePathStyle: !isCloudS3,
});
}
// S3/MinIO 上传函数(使用 AWS SDK)
async function uploadToS3(
filename: string,
@ -85,23 +115,13 @@ async function uploadToS3(
const contentType = getContentType(filename);
try {
// 创建 S3 客户端(支持 MinIO/Aliyun OSS/AWS S3)
// 自动检测是否为云厂商S3(阿里云/AWS),如果是则使用 Virtual Hosted Style,否则默认为 Path Style (MinIO)
const lowerEndpoint = normalizedEndpoint.toLowerCase();
const isCloudS3 =
lowerEndpoint.includes("aliyuncs.com") ||
lowerEndpoint.includes("amazonaws.com");
const s3Client = new S3Client({
const s3Client = createS3Client({
endpoint: normalizedEndpoint,
region: region || "auto",
credentials: {
accessKeyId: accessKeyId,
secretAccessKey: secretAccessKey,
},
// MinIO 需要使用 path-style URL (true)
// 阿里云 OSS/AWS S3 推荐使用 virtual-hosted-style URL (false)
forcePathStyle: !isCloudS3,
region,
bucket,
accessKeyId,
secretAccessKey,
publicUrl: normalizedPublicUrl,
});
console.log(`[S3 Upload] Uploading to: ${normalizedEndpoint}/${bucket}/${key}`);
@ -144,7 +164,28 @@ async function uploadToS3(
}
}
function toDbS3UploadConfig(raw: unknown): S3UploadConfig | null {
export async function uploadFileWithS3Config(
filename: string,
buffer: ArrayBuffer,
config: S3UploadConfig
) {
return uploadToS3(filename, buffer, config);
}
export async function deleteS3ObjectByPath(pathname: string, config: S3UploadConfig) {
const key = String(pathname || "").replace(/^\/+/, "");
if (!key) return;
const s3Client = createS3Client(config);
await s3Client.send(
new DeleteObjectCommand({
Bucket: config.bucket,
Key: key,
})
);
}
export function toDbS3UploadConfig(raw: unknown): S3UploadConfig | null {
const value = raw as Record<string, unknown> | null | undefined;
const endpoint = value?.endpoint ? normalizeEndpoint(String(value.endpoint)) : "";
const bucket = value?.bucket ? String(value.bucket).trim() : "";
@ -210,11 +251,68 @@ function normalizeStorageFailoverConfig(value: unknown): {
};
}
async function resolvePreferredUploadStorageConfig(
migrationConfig: OssImageMigrationConfig
): Promise<StorageConfig | undefined> {
const preferredAliyunConfig = await getPreferredAliyunStorageConfig({
preferredId: migrationConfig.sourceStorageConfigId || undefined,
});
if (preferredAliyunConfig) {
return preferredAliyunConfig;
}
return await getDefaultStorageConfig();
}
async function maybeEnqueueAliyunMigrationTask(params: {
uploaded: { url: string; pathname: string };
storageConfig?: StorageConfig;
migrationConfig: OssImageMigrationConfig;
}) {
const { uploaded, storageConfig, migrationConfig } = params;
if (!migrationConfig.enabled) return;
if (!storageConfig || !isAliyunStorageConfig(storageConfig)) return;
if (!uploaded.url || !uploaded.pathname) return;
const targetStorageConfig =
(migrationConfig.targetStorageConfigId
? await getStorageConfigById({ id: migrationConfig.targetStorageConfigId }).catch(() => undefined)
: undefined) || (await getDefaultStorageConfig().catch(() => undefined));
if (!targetStorageConfig) return;
if (targetStorageConfig.id === storageConfig.id) return;
if (isAliyunStorageConfig(targetStorageConfig)) return;
if (!toDbS3UploadConfig(targetStorageConfig)) return;
const now = Date.now();
const migrateAfterAt = new Date(
now + migrationConfig.migrateAfterHours * 60 * 60 * 1000
);
const cleanupAfterAt = new Date(
now + migrationConfig.cleanupAfterHours * 60 * 60 * 1000
);
await enqueueStorageMigrationTask({
sourceUrl: uploaded.url,
sourcePath: uploaded.pathname,
sourceStorageConfigId: storageConfig.id,
migrateAfterAt,
cleanupAfterAt,
});
}
async function uploadWithBackupStorageConfig(
filename: string,
buffer: ArrayBuffer,
primaryStorageConfigId?: string
): Promise<{ url: string; pathname: string; contentType: string } | null> {
): Promise<
| {
uploaded: { url: string; pathname: string; contentType: string };
storageConfig?: StorageConfig;
}
| null
> {
try {
const setting = await getSystemSetting({ key: STORAGE_FAILOVER_CONFIG_KEY });
const failover = normalizeStorageFailoverConfig(setting?.value);
@ -251,7 +349,10 @@ async function uploadWithBackupStorageConfig(
const uploaded = await uploadToS3(filename, buffer, backupS3Config);
lastKnownGoodDbS3Config = backupS3Config;
return uploaded;
return {
uploaded,
storageConfig: backupStorageConfig,
};
} catch (error) {
console.error("[Upload] Backup storage config retry failed:", error);
return null;
@ -267,16 +368,30 @@ export async function uploadFile(
let dbS3Config: S3UploadConfig | null = null;
let primaryStorageConfigId: string | undefined;
let selectedStorageConfig: StorageConfig | undefined;
let migrationConfig = defaultOssImageMigrationConfig;
try {
const storageConfig = await getDefaultStorageConfig();
primaryStorageConfigId = storageConfig?.id;
dbS3Config = toDbS3UploadConfig(storageConfig);
const migrationSetting = await getSystemSetting({
key: OSS_MIGRATION_CONFIG_KEY,
}).catch(() => undefined);
migrationConfig = normalizeOssImageMigrationConfig(migrationSetting?.value);
selectedStorageConfig = await resolvePreferredUploadStorageConfig(migrationConfig);
primaryStorageConfigId = selectedStorageConfig?.id;
dbS3Config = toDbS3UploadConfig(selectedStorageConfig);
if (dbS3Config) {
console.log(`[Upload] Using database S3 config: ${storageConfig?.name}`);
console.log(`[Upload] Using database S3 config: ${selectedStorageConfig?.name}`);
console.log(`[Upload] Endpoint: ${dbS3Config.endpoint}`);
console.log(`[Upload] Bucket: ${dbS3Config.bucket}`);
const uploaded = await uploadToS3(filename, buffer, dbS3Config);
lastKnownGoodDbS3Config = dbS3Config;
await maybeEnqueueAliyunMigrationTask({
uploaded,
storageConfig: selectedStorageConfig,
migrationConfig,
}).catch((error) => {
console.error("[Upload] Failed to enqueue OSS migration task:", error);
});
return uploaded;
}
console.warn("[Upload] No valid S3 config in database");
@ -289,7 +404,14 @@ export async function uploadFile(
primaryStorageConfigId
);
if (uploadedFromBackup) {
return uploadedFromBackup;
await maybeEnqueueAliyunMigrationTask({
uploaded: uploadedFromBackup.uploaded,
storageConfig: uploadedFromBackup.storageConfig,
migrationConfig,
}).catch((enqueueError) => {
console.error("[Upload] Failed to enqueue backup OSS migration task:", enqueueError);
});
return uploadedFromBackup.uploaded;
}
if (lastKnownGoodDbS3Config) {
@ -309,7 +431,14 @@ export async function uploadFile(
primaryStorageConfigId
);
if (uploadedFromBackup) {
return uploadedFromBackup;
await maybeEnqueueAliyunMigrationTask({
uploaded: uploadedFromBackup.uploaded,
storageConfig: uploadedFromBackup.storageConfig,
migrationConfig,
}).catch((enqueueError) => {
console.error("[Upload] Failed to enqueue backup OSS migration task:", enqueueError);
});
return uploadedFromBackup.uploaded;
}
}