chore: harden production build flow

This commit is contained in:
Your Name 2026-05-27 14:15:58 +08:00
parent f569261359
commit e653eaae0f
11 changed files with 113 additions and 1423 deletions

View File

@ -43,6 +43,7 @@ POSTGRES_URL=postgres://user:password@localhost:5432/yedir_ai
POSTGRES_MAX_CONNECTIONS=10
POSTGRES_IDLE_TIMEOUT_SECONDS=20
POSTGRES_CONNECT_TIMEOUT_SECONDS=5
POSTGRES_MIGRATION_TIMEOUT_SECONDS=120
# -----------------------------------------------------------------------------
# Object Storage

View File

@ -85,6 +85,8 @@ pnpm audit:security
pnpm verify:release
pnpm exec tsc --noEmit --pretty false
pnpm test
pnpm build
pnpm build:with-migrate
pnpm db:migrate
pnpm db:generate
```

View File

@ -6,6 +6,8 @@ export const metadata: Metadata = {
title: "流程白板",
};
export const dynamic = "force-dynamic";
export default async function FlowBoardPage() {
const siteSettings = await getSiteSettings();

View File

@ -3,6 +3,8 @@ import { ProductPlanningPage } from "@/components/product-planning/product-plann
import { isProductPlanningEnabled } from "@/lib/product-planning/server-feature";
import { getProductPlanningPageToken } from "@/lib/product-planning/page-auth";
export const dynamic = "force-dynamic";
export default async function ProductPlanningRoute() {
const enabled = await isProductPlanningEnabled();
if (!enabled) {

View File

@ -7,17 +7,15 @@ import { ImageDeliveryOptimizer } from "@/components/image-delivery-optimizer";
import "./globals.css";
import { SessionProvider } from "next-auth/react";
const metadataBase = process.env.AUTH_URL
? new URL(process.env.AUTH_URL)
: undefined;
const metadataBase = resolveMetadataBase();
export const metadata: Metadata = {
metadataBase,
title: {
template: "%s | AI Chatbot",
default: "AI Chatbot",
template: "%s | yedir-ai",
default: "yedir-ai",
},
description: "AI Chatbot",
description: "AI design workspace for Canvas, Agent workflows, image editing, 3D/CAD, and local AI operations.",
icons: {
icon: [
{ url: "/api/favicon", sizes: "any" },
@ -28,6 +26,21 @@ export const metadata: Metadata = {
},
};
function resolveMetadataBase() {
const candidate =
process.env.NEXT_PUBLIC_APP_URL || process.env.AUTH_URL || process.env.NEXTAUTH_URL;
if (candidate) {
try {
return new URL(candidate);
} catch {
// Keep metadata generation resilient when local env files contain placeholders.
}
}
return new URL("http://localhost:3000");
}
export const viewport = {
maximumScale: 1, // Disable auto-zoom on mobile Safari
};

View File

@ -20,6 +20,12 @@ pnpm build
pnpm start
```
`pnpm build` 只执行 Next.js 生产构建,不会隐式连接数据库。需要在单机部署脚本中把迁移和构建串起来时,可使用:
```bash
pnpm build:with-migrate
```
本地开发:
```bash
@ -41,6 +47,7 @@ Google、钉钉、邮箱验证等登录能力可以通过后台设置和环境
- `POSTGRES_MAX_CONNECTIONS=10`
- `POSTGRES_IDLE_TIMEOUT_SECONDS=20`
- `POSTGRES_CONNECT_TIMEOUT_SECONDS=5`
- `POSTGRES_MIGRATION_TIMEOUT_SECONDS=120`
生产环境可按数据库规格调整,但不建议无上限放大连接数。

View File

@ -2,9 +2,9 @@ import { config } from "dotenv";
import { sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import postgres from "postgres";
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import postgres from "postgres";
config({
path: ".env.local",
@ -78,50 +78,100 @@ function assertMigrationJournalIsConsistent() {
);
}
function getEnvInt(name: string, fallback: number, min: number, max: number) {
const raw = process.env[name];
if (!raw) {
return fallback;
}
const value = Number.parseInt(raw, 10);
if (!Number.isFinite(value)) {
return fallback;
}
return Math.min(Math.max(value, min), max);
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string) {
let timeout: NodeJS.Timeout | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timeout = setTimeout(() => reject(new Error(message)), timeoutMs);
});
return Promise.race([promise, timeoutPromise]).finally(() => {
if (timeout) {
clearTimeout(timeout);
}
});
}
const runMigrate = async () => {
if (!process.env.POSTGRES_URL) {
throw new Error("POSTGRES_URL is not defined");
}
const connection = postgres(process.env.POSTGRES_URL, { max: 1 });
const migrationTimeoutSeconds = getEnvInt("POSTGRES_MIGRATION_TIMEOUT_SECONDS", 120, 5, 1800);
const connection = postgres(process.env.POSTGRES_URL, {
connect_timeout: getEnvInt("POSTGRES_CONNECT_TIMEOUT_SECONDS", 5, 1, 60),
idle_timeout: getEnvInt("POSTGRES_IDLE_TIMEOUT_SECONDS", 20, 1, 300),
max: 1,
prepare: false,
});
const db = drizzle(connection);
console.log("⏳ Running migrations...");
const start = Date.now();
assertMigrationJournalIsConsistent();
try {
await withTimeout(
runMigrationStatements(db),
migrationTimeoutSeconds * 1000,
`Migration timed out after ${migrationTimeoutSeconds}s. Check POSTGRES_URL, network access, and migration locks.`
);
} finally {
await connection.end({ timeout: 5 });
}
const end = Date.now();
console.log("✅ Migrations completed in", end - start, "ms");
process.exit(0);
};
async function runMigrationStatements(db: ReturnType<typeof drizzle>) {
await migrate(db, { migrationsFolder: "./lib/db/migrations" });
await db.execute(sql`
ALTER TABLE "AiProvider" DROP CONSTRAINT IF EXISTS "AiProvider_type_check";
`);
ALTER TABLE "AiProvider" DROP CONSTRAINT IF EXISTS "AiProvider_type_check";
`);
await db.execute(sql`
ALTER TABLE "AiProvider"
ADD CONSTRAINT "AiProvider_type_check"
CHECK (type IN (
'alibaba', 'openai', 'custom', 'local', 'gemini',
'grsai-gpt-image', 'grsai-video', 'huanke-gemini', 'qianwen-video', 'doubao-video', 'ruxa-video', 'volcano-vision', 'volcano-video', 'alibaba-image', 'bfl-image', 'ruxa-image', 'fal-image', 'openai-image', 'stepfun-image', 'midjourney', 'fal-video', 'zhipu', 'tripo-3d', 'worldlabs-3d', 'kittycad-cad', 'runway', 'krea'
));
`);
ALTER TABLE "AiProvider"
ADD CONSTRAINT "AiProvider_type_check"
CHECK (type IN (
'alibaba', 'openai', 'custom', 'local', 'gemini',
'grsai-gpt-image', 'grsai-video', 'huanke-gemini', 'qianwen-video', 'doubao-video', 'ruxa-video', 'volcano-vision', 'volcano-video', 'alibaba-image', 'bfl-image', 'ruxa-image', 'fal-image', 'openai-image', 'stepfun-image', 'midjourney', 'fal-video', 'zhipu', 'tripo-3d', 'worldlabs-3d', 'kittycad-cad', 'runway', 'krea'
));
`);
await db.execute(sql`
ALTER TABLE "AiModel"
ADD COLUMN IF NOT EXISTS "excludeFromFailover" boolean NOT NULL DEFAULT false;
`);
ALTER TABLE "AiModel"
ADD COLUMN IF NOT EXISTS "excludeFromFailover" boolean NOT NULL DEFAULT false;
`);
await db.execute(sql`
ALTER TABLE "AiModel"
ADD COLUMN IF NOT EXISTS "excludeFromHealthBan" boolean NOT NULL DEFAULT false;
`);
ALTER TABLE "AiModel"
ADD COLUMN IF NOT EXISTS "excludeFromHealthBan" boolean NOT NULL DEFAULT false;
`);
await db.execute(sql`
ALTER TABLE "AiModel"
ADD COLUMN IF NOT EXISTS "isDefaultTextEditModel" boolean NOT NULL DEFAULT false;
`);
ALTER TABLE "AiModel"
ADD COLUMN IF NOT EXISTS "isDefaultTextEditModel" boolean NOT NULL DEFAULT false;
`);
await db.execute(sql`
ALTER TABLE "AiModel"
ADD COLUMN IF NOT EXISTS "healthRecoverySuccessStreak" integer NOT NULL DEFAULT 0;
`);
ALTER TABLE "AiModel"
ADD COLUMN IF NOT EXISTS "healthRecoverySuccessStreak" integer NOT NULL DEFAULT 0;
`);
await db.execute(sql`
ALTER TABLE "AiModel"
ADD COLUMN IF NOT EXISTS "healthObservationStartedAt" timestamp;
`);
ALTER TABLE "AiModel"
ADD COLUMN IF NOT EXISTS "healthObservationStartedAt" timestamp;
`);
for (const item of providerBaseUrlDefaults) {
await db.execute(sql`
UPDATE "AiProvider"
@ -133,11 +183,7 @@ const runMigrate = async () => {
AND ("baseUrl" IS NULL OR BTRIM("baseUrl") = '');
`);
}
const end = Date.now();
console.log("✅ Migrations completed in", end - start, "ms");
process.exit(0);
};
}
runMigrate().catch((err) => {
console.error("❌ Migration failed");

View File

@ -1,6 +1,3 @@
import { existsSync, readFileSync } from "fs";
import { join } from "path";
export const HOME_CHAT_ANIMATION_CONFIG_KEY = "home_chat_animation_config";
export const HOME_CHAT_ANIMATION_MAX_BYTES = 1024 * 1024;
@ -47,27 +44,7 @@ export function normalizeHomeChatAnimationConfig(
};
}
let cachedDefaultHomeAnimationHtml: string | null | undefined;
export function getDefaultHomeChatAnimationHtml(): string {
if (cachedDefaultHomeAnimationHtml !== undefined) {
return cachedDefaultHomeAnimationHtml || "";
}
const candidatePaths = [join(process.cwd(), "动画效果.html")];
for (const candidatePath of candidatePaths) {
if (!existsSync(candidatePath)) {
continue;
}
cachedDefaultHomeAnimationHtml = normalizeHomeChatAnimationHtml(
readFileSync(candidatePath, "utf8"),
);
return cachedDefaultHomeAnimationHtml;
}
cachedDefaultHomeAnimationHtml = "";
return "";
}

2
next-env.d.ts vendored
View File

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@ -12,7 +12,8 @@
"dev": "next dev --turbo",
"postinstall": "node scripts/security/auto-enable-perf-guard.mjs",
"prebuild": "node scripts/security/auto-enable-perf-guard.mjs",
"build": "tsx lib/db/migrate && next build",
"build": "next build",
"build:with-migrate": "pnpm db:migrate && next build",
"postbuild": "find .next/static -type f -name '*.map' -delete 2>/dev/null || true",
"start": "next start",
"lint": "pnpm exec ultracite check",

File diff suppressed because it is too large Load Diff