From e653eaae0f1501d27f9c2e89fe281c6ede0a60df Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 27 May 2026 14:15:58 +0800 Subject: [PATCH] chore: harden production build flow --- .env.example | 1 + README.md | 2 + app/(chat)/flow-board/page.tsx | 2 + app/(chat)/product-planning/page.tsx | 2 + app/layout.tsx | 25 +- docs/deployment.md | 7 + lib/db/migrate.ts | 108 +- lib/home/home-animation-config.ts | 23 - next-env.d.ts | 2 +- package.json | 3 +- 动画效果.html | 1361 -------------------------- 11 files changed, 113 insertions(+), 1423 deletions(-) delete mode 100644 动画效果.html diff --git a/.env.example b/.env.example index 507e76952..943428edf 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 008a1c312..a3b02eecc 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/app/(chat)/flow-board/page.tsx b/app/(chat)/flow-board/page.tsx index 42d9f2d2c..a38bfd3ec 100644 --- a/app/(chat)/flow-board/page.tsx +++ b/app/(chat)/flow-board/page.tsx @@ -6,6 +6,8 @@ export const metadata: Metadata = { title: "流程白板", }; +export const dynamic = "force-dynamic"; + export default async function FlowBoardPage() { const siteSettings = await getSiteSettings(); diff --git a/app/(chat)/product-planning/page.tsx b/app/(chat)/product-planning/page.tsx index dc38279af..c5eef6abe 100644 --- a/app/(chat)/product-planning/page.tsx +++ b/app/(chat)/product-planning/page.tsx @@ -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) { diff --git a/app/layout.tsx b/app/layout.tsx index 02a43736b..07529a624 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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 }; diff --git a/docs/deployment.md b/docs/deployment.md index e877476a6..4eaa91730 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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` 生产环境可按数据库规格调整,但不建议无上限放大连接数。 diff --git a/lib/db/migrate.ts b/lib/db/migrate.ts index 37abedf6e..b7c4efc84 100644 --- a/lib/db/migrate.ts +++ b/lib/db/migrate.ts @@ -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(promise: Promise, timeoutMs: number, message: string) { + let timeout: NodeJS.Timeout | undefined; + + const timeoutPromise = new Promise((_, 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) { 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"); diff --git a/lib/home/home-animation-config.ts b/lib/home/home-animation-config.ts index 44d266847..8f26f7d26 100644 --- a/lib/home/home-animation-config.ts +++ b/lib/home/home-animation-config.ts @@ -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 ""; } diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818fb..9edff1c7c 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -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. diff --git a/package.json b/package.json index 711fdaf32..21b600f9a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/动画效果.html b/动画效果.html deleted file mode 100644 index 55376ee76..000000000 --- a/动画效果.html +++ /dev/null @@ -1,1361 +0,0 @@ - - - - - - 多人3D交互场景 - 空气墙系统 - - - -
-
-
正在进入场景
-
-
- -
- - 回车发送 | ESC取消 -
- - - - - - \ No newline at end of file