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_MAX_CONNECTIONS=10
POSTGRES_IDLE_TIMEOUT_SECONDS=20 POSTGRES_IDLE_TIMEOUT_SECONDS=20
POSTGRES_CONNECT_TIMEOUT_SECONDS=5 POSTGRES_CONNECT_TIMEOUT_SECONDS=5
POSTGRES_MIGRATION_TIMEOUT_SECONDS=120
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Object Storage # Object Storage

View File

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

View File

@ -6,6 +6,8 @@ export const metadata: Metadata = {
title: "流程白板", title: "流程白板",
}; };
export const dynamic = "force-dynamic";
export default async function FlowBoardPage() { export default async function FlowBoardPage() {
const siteSettings = await getSiteSettings(); 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 { isProductPlanningEnabled } from "@/lib/product-planning/server-feature";
import { getProductPlanningPageToken } from "@/lib/product-planning/page-auth"; import { getProductPlanningPageToken } from "@/lib/product-planning/page-auth";
export const dynamic = "force-dynamic";
export default async function ProductPlanningRoute() { export default async function ProductPlanningRoute() {
const enabled = await isProductPlanningEnabled(); const enabled = await isProductPlanningEnabled();
if (!enabled) { if (!enabled) {

View File

@ -7,17 +7,15 @@ import { ImageDeliveryOptimizer } from "@/components/image-delivery-optimizer";
import "./globals.css"; import "./globals.css";
import { SessionProvider } from "next-auth/react"; import { SessionProvider } from "next-auth/react";
const metadataBase = process.env.AUTH_URL const metadataBase = resolveMetadataBase();
? new URL(process.env.AUTH_URL)
: undefined;
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase, metadataBase,
title: { title: {
template: "%s | AI Chatbot", template: "%s | yedir-ai",
default: "AI Chatbot", default: "yedir-ai",
}, },
description: "AI Chatbot", description: "AI design workspace for Canvas, Agent workflows, image editing, 3D/CAD, and local AI operations.",
icons: { icons: {
icon: [ icon: [
{ url: "/api/favicon", sizes: "any" }, { 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 = { export const viewport = {
maximumScale: 1, // Disable auto-zoom on mobile Safari maximumScale: 1, // Disable auto-zoom on mobile Safari
}; };

View File

@ -20,6 +20,12 @@ pnpm build
pnpm start pnpm start
``` ```
`pnpm build` 只执行 Next.js 生产构建,不会隐式连接数据库。需要在单机部署脚本中把迁移和构建串起来时,可使用:
```bash
pnpm build:with-migrate
```
本地开发: 本地开发:
```bash ```bash
@ -41,6 +47,7 @@ Google、钉钉、邮箱验证等登录能力可以通过后台设置和环境
- `POSTGRES_MAX_CONNECTIONS=10` - `POSTGRES_MAX_CONNECTIONS=10`
- `POSTGRES_IDLE_TIMEOUT_SECONDS=20` - `POSTGRES_IDLE_TIMEOUT_SECONDS=20`
- `POSTGRES_CONNECT_TIMEOUT_SECONDS=5` - `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 { sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/postgres-js"; import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator"; import { migrate } from "drizzle-orm/postgres-js/migrator";
import postgres from "postgres";
import { readFileSync, readdirSync } from "node:fs"; import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import postgres from "postgres";
config({ config({
path: ".env.local", 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 () => { const runMigrate = async () => {
if (!process.env.POSTGRES_URL) { if (!process.env.POSTGRES_URL) {
throw new Error("POSTGRES_URL is not defined"); 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); const db = drizzle(connection);
console.log("⏳ Running migrations..."); console.log("⏳ Running migrations...");
const start = Date.now(); const start = Date.now();
assertMigrationJournalIsConsistent(); 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 migrate(db, { migrationsFolder: "./lib/db/migrations" });
await db.execute(sql` 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` await db.execute(sql`
ALTER TABLE "AiProvider" ALTER TABLE "AiProvider"
ADD CONSTRAINT "AiProvider_type_check" ADD CONSTRAINT "AiProvider_type_check"
CHECK (type IN ( CHECK (type IN (
'alibaba', 'openai', 'custom', 'local', 'gemini', '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' '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` await db.execute(sql`
ALTER TABLE "AiModel" ALTER TABLE "AiModel"
ADD COLUMN IF NOT EXISTS "excludeFromFailover" boolean NOT NULL DEFAULT false; ADD COLUMN IF NOT EXISTS "excludeFromFailover" boolean NOT NULL DEFAULT false;
`); `);
await db.execute(sql` await db.execute(sql`
ALTER TABLE "AiModel" ALTER TABLE "AiModel"
ADD COLUMN IF NOT EXISTS "excludeFromHealthBan" boolean NOT NULL DEFAULT false; ADD COLUMN IF NOT EXISTS "excludeFromHealthBan" boolean NOT NULL DEFAULT false;
`); `);
await db.execute(sql` await db.execute(sql`
ALTER TABLE "AiModel" ALTER TABLE "AiModel"
ADD COLUMN IF NOT EXISTS "isDefaultTextEditModel" boolean NOT NULL DEFAULT false; ADD COLUMN IF NOT EXISTS "isDefaultTextEditModel" boolean NOT NULL DEFAULT false;
`); `);
await db.execute(sql` await db.execute(sql`
ALTER TABLE "AiModel" ALTER TABLE "AiModel"
ADD COLUMN IF NOT EXISTS "healthRecoverySuccessStreak" integer NOT NULL DEFAULT 0; ADD COLUMN IF NOT EXISTS "healthRecoverySuccessStreak" integer NOT NULL DEFAULT 0;
`); `);
await db.execute(sql` await db.execute(sql`
ALTER TABLE "AiModel" ALTER TABLE "AiModel"
ADD COLUMN IF NOT EXISTS "healthObservationStartedAt" timestamp; ADD COLUMN IF NOT EXISTS "healthObservationStartedAt" timestamp;
`); `);
for (const item of providerBaseUrlDefaults) { for (const item of providerBaseUrlDefaults) {
await db.execute(sql` await db.execute(sql`
UPDATE "AiProvider" UPDATE "AiProvider"
@ -133,11 +183,7 @@ const runMigrate = async () => {
AND ("baseUrl" IS NULL OR BTRIM("baseUrl") = ''); 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) => { runMigrate().catch((err) => {
console.error("❌ Migration failed"); 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_CONFIG_KEY = "home_chat_animation_config";
export const HOME_CHAT_ANIMATION_MAX_BYTES = 1024 * 1024; 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 { 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 ""; return "";
} }

2
next-env.d.ts vendored
View File

@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <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 // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

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

File diff suppressed because it is too large Load Diff