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_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
|
||||||
|
|||||||
@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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`
|
||||||
|
|
||||||
生产环境可按数据库规格调整,但不建议无上限放大连接数。
|
生产环境可按数据库规格调整,但不建议无上限放大连接数。
|
||||||
|
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
@ -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
2
next-env.d.ts
vendored
@ -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.
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user