chore: harden production build flow
This commit is contained in:
parent
f569261359
commit
e653eaae0f
@ -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
|
||||
|
||||
@ -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
|
||||
```
|
||||
|
||||
@ -6,6 +6,8 @@ export const metadata: Metadata = {
|
||||
title: "流程白板",
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function FlowBoardPage() {
|
||||
const siteSettings = await getSiteSettings();
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
@ -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`
|
||||
|
||||
生产环境可按数据库规格调整,但不建议无上限放大连接数。
|
||||
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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
2
next-env.d.ts
vendored
@ -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.
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user