feat: Add Docker support
aiartstudio-deploy / deploy (push) Failing after 2s

This commit is contained in:
2026-04-28 18:39:42 +08:00
parent be78d17a26
commit 3f31a2a40d
18 changed files with 530 additions and 116 deletions
+21
View File
@@ -0,0 +1,21 @@
.git
.gitea
.nuxt
.output
.nitro
.cache
.data
dist
node_modules
logs
*.log
.nuxt-dev.err.log
.nuxt-dev.out.log
.env
.env.*
!.env.example
.DS_Store
.fleet
.idea
typecheck_output.txt
app/generated/prisma
+29
View File
@@ -0,0 +1,29 @@
# Nuxt / SEO
NUXT_OG_IMAGE_SECRET=change-me
# Database
DATABASE_URL=mysql://user:password@localhost:3306/aiartstudio?connection_limit=5&pool_timeout=30&connect_timeout=10
# NewAPI upstream
NEWAPI_BASE_URL=https://api.example.com
IMAGE_GENERATION_MODEL=gpt-image-2
NEWAPI_TOKEN_NAME=AIArtStudio
# Lsky image hosting
LSKY_BASE_URL=https://lsky.example.com
LSKY_TOKEN=change-me
LSKY_STORAGE_ID=1
# Image generation runtime tuning
IMAGE_GENERATION_MAX_BATCH_SIZE=3
IMAGE_GENERATION_MAX_RETRIES=3
IMAGE_STREAM_MAX_SSE_LINE_BYTES=1048576
# Image archive runtime tuning
IMAGE_ARCHIVE_MAX_BYTES=20971520
IMAGE_ARCHIVE_TIMEOUT_MS=60000
IMAGE_ARCHIVE_CONCURRENCY=2
IMAGE_ARCHIVE_MAX_ATTEMPTS=3
IMAGE_ARCHIVE_LOCK_TTL_MS=600000
IMAGE_ARCHIVE_POLL_MS=15000
IMAGE_ARCHIVE_RETRY_DELAYS_MS=30000,120000,600000
+192
View File
@@ -0,0 +1,192 @@
name: aiartstudio-deploy
run-name: "${{ gitea.actor }} deploying AI Art Studio"
on:
- push
env:
IMAGE_NAME: aiartstudio
CONTAINER_NAME: aiartstudio
CONTAINER_PORT: "3000"
IMAGE_TAG: "${{ gitea.sha }}"
jobs:
deploy:
runs-on:
- self-hosted
steps:
- name: Check runner
env:
HOST_PORT: "${{ secrets.HOST_PORT }}"
DATABASE_URL: "${{ secrets.DATABASE_URL }}"
NEWAPI_BASE_URL: "${{ secrets.NEWAPI_BASE_URL }}"
IMAGE_GENERATION_MODEL: "${{ secrets.IMAGE_GENERATION_MODEL }}"
NEWAPI_TOKEN_NAME: "${{ secrets.NEWAPI_TOKEN_NAME }}"
LSKY_BASE_URL: "${{ secrets.LSKY_BASE_URL }}"
LSKY_TOKEN: "${{ secrets.LSKY_TOKEN }}"
LSKY_STORAGE_ID: "${{ secrets.LSKY_STORAGE_ID }}"
NUXT_OG_IMAGE_SECRET: "${{ secrets.NUXT_OG_IMAGE_SECRET }}"
run: |
set -eu
if ! command -v docker >/dev/null 2>&1; then
echo "Docker is not installed on this self-hosted runner."
echo "Install Docker or mount the host Docker socket before running this workflow."
exit 1
fi
if ! docker info >/dev/null 2>&1; then
echo "Docker daemon is not reachable from this self-hosted runner."
echo "Check runner permissions or Docker socket mounting."
exit 1
fi
MISSING=""
for NAME in \
HOST_PORT \
DATABASE_URL \
NEWAPI_BASE_URL \
IMAGE_GENERATION_MODEL \
NEWAPI_TOKEN_NAME \
LSKY_BASE_URL \
LSKY_TOKEN \
LSKY_STORAGE_ID \
NUXT_OG_IMAGE_SECRET
do
VALUE="$(printenv "$NAME" || true)"
if [ -z "$VALUE" ]; then
MISSING="$MISSING $NAME"
fi
done
if [ -n "$MISSING" ]; then
echo "Missing required secrets:$MISSING"
exit 1
fi
case "$HOST_PORT" in
''|*[!0-9]*)
echo "HOST_PORT must be a numeric TCP port."
exit 1
;;
esac
docker --version
- name: Checkout
uses: actions/checkout@v4
- name: Build image
run: |
set -eu
IMAGE_TAG="${IMAGE_TAG:-$(git rev-parse HEAD)}"
SHORT_SHA="$(printf '%s' "$IMAGE_TAG" | cut -c1-12)"
echo "Building $IMAGE_NAME:$SHORT_SHA and $IMAGE_NAME:latest"
docker build \
-t "$IMAGE_NAME:$IMAGE_TAG" \
-t "$IMAGE_NAME:latest" \
.
docker images "$IMAGE_NAME:latest" --format "Image size: {{.Size}}"
- name: Check database migrations
env:
DATABASE_URL: "${{ secrets.DATABASE_URL }}"
run: |
set -eu
IMAGE_TAG="${IMAGE_TAG:-$(git rev-parse HEAD)}"
docker run --rm \
-e DATABASE_URL \
"$IMAGE_NAME:$IMAGE_TAG" \
./node_modules/.bin/prisma migrate status
- name: Deploy container
env:
HOST_PORT: "${{ secrets.HOST_PORT }}"
DATABASE_URL: "${{ secrets.DATABASE_URL }}"
NEWAPI_BASE_URL: "${{ secrets.NEWAPI_BASE_URL }}"
IMAGE_GENERATION_MODEL: "${{ secrets.IMAGE_GENERATION_MODEL }}"
NEWAPI_TOKEN_NAME: "${{ secrets.NEWAPI_TOKEN_NAME }}"
LSKY_BASE_URL: "${{ secrets.LSKY_BASE_URL }}"
LSKY_TOKEN: "${{ secrets.LSKY_TOKEN }}"
LSKY_STORAGE_ID: "${{ secrets.LSKY_STORAGE_ID }}"
NUXT_OG_IMAGE_SECRET: "${{ secrets.NUXT_OG_IMAGE_SECRET }}"
IMAGE_GENERATION_MAX_BATCH_SIZE: "${{ secrets.IMAGE_GENERATION_MAX_BATCH_SIZE }}"
IMAGE_GENERATION_MAX_RETRIES: "${{ secrets.IMAGE_GENERATION_MAX_RETRIES }}"
IMAGE_STREAM_MAX_SSE_LINE_BYTES: "${{ secrets.IMAGE_STREAM_MAX_SSE_LINE_BYTES }}"
IMAGE_ARCHIVE_MAX_BYTES: "${{ secrets.IMAGE_ARCHIVE_MAX_BYTES }}"
IMAGE_ARCHIVE_TIMEOUT_MS: "${{ secrets.IMAGE_ARCHIVE_TIMEOUT_MS }}"
IMAGE_ARCHIVE_CONCURRENCY: "${{ secrets.IMAGE_ARCHIVE_CONCURRENCY }}"
IMAGE_ARCHIVE_MAX_ATTEMPTS: "${{ secrets.IMAGE_ARCHIVE_MAX_ATTEMPTS }}"
IMAGE_ARCHIVE_LOCK_TTL_MS: "${{ secrets.IMAGE_ARCHIVE_LOCK_TTL_MS }}"
IMAGE_ARCHIVE_POLL_MS: "${{ secrets.IMAGE_ARCHIVE_POLL_MS }}"
IMAGE_ARCHIVE_RETRY_DELAYS_MS: "${{ secrets.IMAGE_ARCHIVE_RETRY_DELAYS_MS }}"
run: |
set -eu
IMAGE_TAG="${IMAGE_TAG:-$(git rev-parse HEAD)}"
echo "Stopping old container if it exists..."
if docker ps -a --filter "name=^/${CONTAINER_NAME}$" -q | grep -q .; then
docker stop --timeout 15 "$CONTAINER_NAME" || true
docker rm "$CONTAINER_NAME" || true
else
echo "No existing container to remove."
fi
echo "Starting new container..."
docker run -d \
--name "$CONTAINER_NAME" \
-p "$HOST_PORT:$CONTAINER_PORT" \
-e NODE_ENV=production \
-e HOST=0.0.0.0 \
-e PORT="$CONTAINER_PORT" \
-e DATABASE_URL \
-e NEWAPI_BASE_URL \
-e IMAGE_GENERATION_MODEL \
-e NEWAPI_TOKEN_NAME \
-e LSKY_BASE_URL \
-e LSKY_TOKEN \
-e LSKY_STORAGE_ID \
-e NUXT_OG_IMAGE_SECRET \
-e IMAGE_GENERATION_MAX_BATCH_SIZE \
-e IMAGE_GENERATION_MAX_RETRIES \
-e IMAGE_STREAM_MAX_SSE_LINE_BYTES \
-e IMAGE_ARCHIVE_MAX_BYTES \
-e IMAGE_ARCHIVE_TIMEOUT_MS \
-e IMAGE_ARCHIVE_CONCURRENCY \
-e IMAGE_ARCHIVE_MAX_ATTEMPTS \
-e IMAGE_ARCHIVE_LOCK_TTL_MS \
-e IMAGE_ARCHIVE_POLL_MS \
-e IMAGE_ARCHIVE_RETRY_DELAYS_MS \
--restart unless-stopped \
"$IMAGE_NAME:$IMAGE_TAG"
- name: Verify container
run: |
set -eu
MAX_WAIT=60
WAITED=0
while [ "$WAITED" -lt "$MAX_WAIT" ]; do
RUNNING_ID="$(docker ps --filter "name=^/${CONTAINER_NAME}$" --filter "status=running" -q)"
if [ -n "$RUNNING_ID" ]; then
HEALTH="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$CONTAINER_NAME" 2>/dev/null || echo unknown)"
if [ "$HEALTH" = "healthy" ] || [ "$HEALTH" = "none" ]; then
echo "Container is running. Health: $HEALTH"
docker ps --filter "name=^/${CONTAINER_NAME}$" --format "Status: {{.Status}}"
docker logs --tail 20 "$CONTAINER_NAME"
exit 0
fi
fi
sleep 2
WAITED=$((WAITED + 2))
done
echo "Container failed to start."
docker inspect "$CONTAINER_NAME" --format 'ExitCode={{.State.ExitCode}} Error={{.State.Error}} StartedAt={{.State.StartedAt}} FinishedAt={{.State.FinishedAt}}' 2>/dev/null || true
docker logs --tail 50 "$CONTAINER_NAME" 2>/dev/null || true
exit 1
+5 -5
View File
@@ -35,9 +35,9 @@
- 登录成功后,后端从 NewAPI `Set-Cookie` 中提取 `session`,写入本站 httpOnly cookie,同时保存 `newapi_user_id`
- 前端通过 `/api/auth/me` 恢复登录状态,通过 `/api/auth/logout` 清理登录状态。
- 登录成功后前端可调用 `/api/auth/ready`,用于确认当前 NewAPI 用户是否具备可用的 `AIArtStudio` Token。
- 登录成功后前端可调用 `/api/auth/ready`,用于确认当前 NewAPI 用户是否具备可用的 `NEWAPI_TOKEN_NAME` 对应 Token。
- `/api/auth/ready` 只检查或创建环境:
- 只认 `name === "AIArtStudio"``status === 1`、未软删除的 Token。
- 只认 `name === process.env.NEWAPI_TOKEN_NAME``status === 1`、未软删除的 Token。
- 没有可用 Token 时,后端按固定参数创建。
- 不调用完整 key 获取接口,不向前端返回 key。
- 返回文案使用“环境已准备 / 环境初始化完成”等业务表达,不暴露 API Key 细节。
@@ -50,8 +50,8 @@
- `newApiFetchRaw`:需要读取上游响应头时使用,目前主要用于登录提取 `Set-Cookie`
- `newApiAuthedFetch`:需要 NewAPI 登录态时使用,由服务端从 httpOnly cookie 读取 session/userId 并补齐请求头。
- 生图模型调用集中在 `server/utils/openai.ts`
- 当前生图上游使用 `POST https://api.qflink.xyz/v1/chat/completions`,参数固定为
- `model: "gpt-image-2"`
- 当前生图上游使用 `POST ${NEWAPI_BASE_URL}/v1/chat/completions`,参数从环境变量读取
- `model: process.env.IMAGE_GENERATION_MODEL`
- `stream: true`
- `messages: [{ role: "user", content: prompt }]`
- 生图通过 `askImgStream` 读取 SSE 流,累积 `choices[].delta.content`,从 Markdown 图片或普通 URL 中提取图片地址。
@@ -73,7 +73,7 @@
- 配置从 `LSKY_BASE_URL``LSKY_TOKEN``LSKY_STORAGE_ID` 读取。
- 上传时先下载上游图片,再用 `multipart/form-data` 调 Lsky `/upload`
- 文件名格式为 `userId_username_timestamp_recordId.ext`
- tags 固定包含 `AIArtStudio``user:{userId}``record:{recordId}``model:gpt-image-2`
- tags 固定包含 `NEWAPI_TOKEN_NAME``user:{userId}``record:{recordId}``image:{imageId}``model:{IMAGE_GENERATION_MODEL}`
- 图床上传失败不影响本次生图成功:接口仍立即返回 `imageUrl`,数据库记录归档失败提示;前端可见文案只能说“图片归档失败”等本地描述。
- 数据库不保存图片 base64;不要恢复 `image_base64` 或类似大文本存图方案。
- 历史列表和详情都不向前端返回完整上游响应、图床响应或内部错误详情;这些内容可以入库供服务端排查,但 API 响应应固定隐藏或置空。
+54
View File
@@ -0,0 +1,54 @@
FROM node:22-alpine AS deps
WORKDIR /app
RUN apk add --no-cache openssl \
&& corepack enable \
&& corepack prepare pnpm@10.33.0 --activate
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
FROM deps AS builder
WORKDIR /app
COPY . .
ENV NODE_ENV=production
ENV DATABASE_URL=mysql://user:password@localhost:3306/aiartstudio
ENV NEWAPI_BASE_URL=https://api.example.com
ENV IMAGE_GENERATION_MODEL=gpt-image-2
ENV NEWAPI_TOKEN_NAME=AIArtStudio
ENV LSKY_BASE_URL=https://lsky.example.com
ENV LSKY_TOKEN=build-placeholder
ENV LSKY_STORAGE_ID=1
ENV NUXT_OG_IMAGE_SECRET=build-placeholder
RUN ./node_modules/.bin/prisma generate \
&& pnpm typecheck \
&& pnpm build
FROM node:22-alpine AS runner
WORKDIR /app
RUN apk add --no-cache openssl \
&& corepack enable \
&& corepack prepare pnpm@10.33.0 --activate
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3000
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/pnpm-lock.yaml ./pnpm-lock.yaml
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/.output ./.output
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
COPY --from=builder /app/app/generated/prisma ./app/generated/prisma
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]
+27
View File
@@ -38,3 +38,30 @@ aiartstudio/ # 项目根目录
├── modules/ # 本地模块
└── layers/ # 可复用层(Layer
```
## 环境变量
部署时由 CI/CD 注入真实环境变量,本地开发可参考 `.env.example` 创建 `.env`
`.env` 被 Git 忽略,不能提交真实密钥。
必填配置:
- `DATABASE_URL`MariaDB 连接串,Prisma MariaDB adapter 会从查询参数读取连接池配置。
- `NEWAPI_BASE_URL`NewAPI 上游根地址,例如 `https://api.example.com`
- `IMAGE_GENERATION_MODEL`:生图模型名,例如 `gpt-image-2`
- `NEWAPI_TOKEN_NAME`:当前站点在 NewAPI 中维护的 token 名称。
- `LSKY_BASE_URL``LSKY_TOKEN``LSKY_STORAGE_ID`Lsky 图床归档配置。
- `NUXT_OG_IMAGE_SECRET`Nuxt SEO/OG image 使用的 secret。
可调配置未设置或非法时会使用代码默认值:
- `IMAGE_GENERATION_MAX_BATCH_SIZE`
- `IMAGE_GENERATION_MAX_RETRIES`
- `IMAGE_STREAM_MAX_SSE_LINE_BYTES`
- `IMAGE_ARCHIVE_MAX_BYTES`
- `IMAGE_ARCHIVE_TIMEOUT_MS`
- `IMAGE_ARCHIVE_CONCURRENCY`
- `IMAGE_ARCHIVE_MAX_ATTEMPTS`
- `IMAGE_ARCHIVE_LOCK_TTL_MS`
- `IMAGE_ARCHIVE_POLL_MS`
- `IMAGE_ARCHIVE_RETRY_DELAYS_MS`
+2 -2
View File
@@ -1,4 +1,4 @@
// server/api/auth/ready.get.ts - 登录后环境准备接口:确保当前用户已有可用的 AIArtStudio Token。
// server/api/auth/ready.get.ts - 登录后环境准备接口:确保当前用户已有可用的站点生图 Token。
import type { IUserReadyData } from "#shared/types";
import {
clearNewApiAuthCookies,
@@ -16,7 +16,7 @@ import {
*
* 流程:
* 1. 读取当前登录用户的 NewAPI session。
* 2. 检查是否存在启用且未删除的 AIArtStudio Token。
* 2. 检查是否存在启用且未删除的站点生图 Token。
* 3. 不存在时按固定参数创建 Token。
* 4. 只返回环境是否准备完成,不返回完整 key 或脱敏 key。
* 5. 登录态失效时清理本地 cookie 并返回 401。
+7 -12
View File
@@ -4,15 +4,15 @@ import type {
IImageGenerateRequest,
IImageGenerateResultImage
} from "#shared/types/openai";
import { serverEnv } from "~~/server/utils/env";
import {
isImageUrlNotFoundError,
isInsufficientQuotaError,
wakeImageArchiveWorker
} from "~~/server/utils";
/** 请求未显式传 batch_size 时默认只生成 1 张,避免用户误触造成多图请求。 */
const DEFAULT_BATCH_SIZE = 1;
const DEFAULT_MAX_BATCH_SIZE = 3;
const IMAGE_GENERATION_MAX_RETRIES = 3;
/**
* POST /api/images/generate
@@ -21,7 +21,7 @@ const IMAGE_GENERATION_MAX_RETRIES = 3;
* 1. 校验请求体、prompt 与 batch_size;空 prompt 和非法数量不创建数据库记录。
* 2. 从 httpOnly cookie 读取当前 NewAPI 用户 ID。
* 3. 创建 RUNNING 生图批次记录和对应数量的 RUNNING 图片子记录,并递增全局请求/进行中统计。
* 4. 在服务端确保并读取 AIArtStudio 完整 key,完整 key 不返回前端。
* 4. 在服务端确保并读取站点生图完整 key,完整 key 不返回前端。
* 5. 对每张图片并行调用 Chat Completions 流式生图接口,每张图独立落库成功或失败。
* 6. 至少一张成功时批次记为 SUCCEEDED,并按成功图片数递增 totalImages;全部失败时批次记为 FAILED。
* 7. 成功图片进入图床归档队列;归档失败只补写图片子记录和日志,不影响本次响应。
@@ -218,7 +218,7 @@ const generateOneImage = async ({
for (
let attempt = 1;
attempt <= IMAGE_GENERATION_MAX_RETRIES + 1;
attempt <= serverEnv.imageGenerationMaxRetries + 1;
attempt++
) {
try {
@@ -249,7 +249,7 @@ const generateOneImage = async ({
index,
attempt,
nextAttempt: attempt + 1,
maxRetries: IMAGE_GENERATION_MAX_RETRIES,
maxRetries: serverEnv.imageGenerationMaxRetries,
error: toSafeLogError(error)
});
continue;
@@ -269,7 +269,7 @@ const generateOneImage = async ({
// 只对“未找到图片地址”的解析失败做最多 3 次重试;余额不足等明确业务错误不能重试,避免浪费请求。
const shouldRetryImageGeneration = (error: unknown, attempt: number) => {
return (
attempt <= IMAGE_GENERATION_MAX_RETRIES &&
attempt <= serverEnv.imageGenerationMaxRetries &&
isImageUrlNotFoundError(error) &&
!isInsufficientQuotaError(error)
);
@@ -324,10 +324,5 @@ const normalizeBatchSize = (
// 从环境变量读取允许的最大批次大小;解析失败或未配置时回落到内置默认值,不抛出异常。
const getMaxBatchSize = () => {
const value = Number.parseInt(
process.env.IMAGE_GENERATION_MAX_BATCH_SIZE ?? "",
10
);
return Number.isInteger(value) && value > 0 ? value : DEFAULT_MAX_BATCH_SIZE;
return serverEnv.imageGenerationMaxBatchSize;
};
+148
View File
@@ -0,0 +1,148 @@
// server/utils/env.ts - 服务端环境变量配置:集中读取部署配置并为可调运行参数提供默认值。
/** 单次生图默认最多 3 张,避免默认配置下并发请求过多影响主链路稳定性。 */
const DEFAULT_IMAGE_GENERATION_MAX_BATCH_SIZE = 3;
/** 单张图片解析不到地址时默认最多重试 3 次,覆盖短暂的上游流式输出异常。 */
const DEFAULT_IMAGE_GENERATION_MAX_RETRIES = 3;
/** 单行 SSE data 默认最大 1MB,防止异常长行持续堆积占用内存。 */
const DEFAULT_IMAGE_STREAM_MAX_SSE_LINE_BYTES = 1024 * 1024;
/** 单张归档图片默认最大 20MB,避免图床归档阶段把图片二进制堆满进程内存。 */
const DEFAULT_IMAGE_ARCHIVE_MAX_BYTES = 20 * 1024 * 1024;
/** 单次图床下载或上传默认 60 秒超时,避免慢连接长期占用归档槽位。 */
const DEFAULT_IMAGE_ARCHIVE_TIMEOUT_MS = 60 * 1000;
/** 默认只开 2 个归档槽位,控制下载、上传带来的网络和内存峰值。 */
const DEFAULT_IMAGE_ARCHIVE_CONCURRENCY = 2;
/** 默认最多归档重试 3 次,兼顾短暂网络抖动和坏任务收敛。 */
const DEFAULT_IMAGE_ARCHIVE_MAX_ATTEMPTS = 3;
/** 默认锁超时 10 分钟,进程崩溃或重启后任务可以被其他 worker 自动恢复。 */
const DEFAULT_IMAGE_ARCHIVE_LOCK_TTL_MS = 10 * 60 * 1000;
/** 默认空队列每 15 秒扫描一次;新任务入队仍会主动唤醒,不依赖纯轮询。 */
const DEFAULT_IMAGE_ARCHIVE_POLL_MS = 15 * 1000;
/** 默认归档失败退避节奏:30 秒、2 分钟、10 分钟,超过后沿用最后一档。 */
const DEFAULT_IMAGE_ARCHIVE_RETRY_DELAYS_MS = [
30 * 1000,
2 * 60 * 1000,
10 * 60 * 1000
];
/** 读取必填字符串配置;关键部署项缺失时直接失败,避免 CI/CD 漏注入后静默跑错环境。 */
const requiredStringEnv = (name: string): string => {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`${name} is required`);
}
return value;
};
/** 读取必填 URL 配置,并去掉末尾斜杠,保证后续拼接路径不会出现双斜杠。 */
const requiredUrlEnv = (name: string): string => {
const value = requiredStringEnv(name).replace(/\/+$/, "");
try {
new URL(value);
} catch {
throw new Error(`${name} must be a valid URL`);
}
return value;
};
/** 读取必填正整数配置;用于没有合理代码兜底的标识类数值,例如 Lsky storage_id。 */
const requiredPositiveIntegerEnv = (name: string): number => {
const value = Number.parseInt(process.env[name] ?? "", 10);
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer`);
}
return value;
};
/** 读取可选正整数配置;非法或缺失时回退默认值,保证运行阈值可配置但不脆弱。 */
const positiveIntegerEnv = (name: string, fallback: number): number => {
const value = Number.parseInt(process.env[name] ?? "", 10);
return Number.isInteger(value) && value > 0 ? value : fallback;
};
/** 读取逗号分隔的正整数列表;任一项非法即整体回退,避免半截退避策略造成不可预期行为。 */
const positiveIntegerListEnv = (name: string, fallback: number[]): number[] => {
const rawValue = process.env[name]?.trim();
if (!rawValue) return fallback;
const values = rawValue
.split(",")
.map((part) => Number.parseInt(part.trim(), 10));
return values.length > 0 &&
values.every((value) => Number.isInteger(value) && value > 0)
? values
: fallback;
};
/** 服务端统一配置对象;业务代码只使用这里已经校验或带默认值的配置,不再分散读取 process.env。 */
export const serverEnv = {
/** MariaDB 连接串;Prisma MariaDB adapter 会进一步解析连接池参数。 */
databaseUrl: requiredStringEnv("DATABASE_URL"),
/** NewAPI 上游根地址;认证代理、生图请求都会基于这个地址拼接具体路径。 */
newApiBaseUrl: requiredUrlEnv("NEWAPI_BASE_URL"),
/** 当前生图模型名;用于上游请求、历史记录入库和图床归档标签。 */
imageGenerationModel: requiredStringEnv("IMAGE_GENERATION_MODEL"),
/** 当前站点在 NewAPI 中维护的 token 名称;用于 ready 检查和按需创建 token。 */
newApiTokenName: requiredStringEnv("NEWAPI_TOKEN_NAME"),
/** Lsky 图床根地址;上传接口会在此基础上调用 `/upload`。 */
lskyBaseUrl: requiredUrlEnv("LSKY_BASE_URL"),
/** Lsky Bearer token;只允许服务端使用,不写入响应或日志。 */
lskyToken: requiredStringEnv("LSKY_TOKEN"),
/** Lsky 存储策略 ID;必须由部署环境显式提供。 */
lskyStorageId: requiredPositiveIntegerEnv("LSKY_STORAGE_ID"),
/** 单次生图允许的最大张数;非法配置回退到当前业务默认值。 */
imageGenerationMaxBatchSize: positiveIntegerEnv(
"IMAGE_GENERATION_MAX_BATCH_SIZE",
DEFAULT_IMAGE_GENERATION_MAX_BATCH_SIZE
),
/** 单张图片解析失败时的最大重试次数;只用于可安全重试的解析失败。 */
imageGenerationMaxRetries: positiveIntegerEnv(
"IMAGE_GENERATION_MAX_RETRIES",
DEFAULT_IMAGE_GENERATION_MAX_RETRIES
),
/** Chat Completions SSE 单行 data 最大字节数;用于阻断异常大流式片段。 */
imageStreamMaxSseLineBytes: positiveIntegerEnv(
"IMAGE_STREAM_MAX_SSE_LINE_BYTES",
DEFAULT_IMAGE_STREAM_MAX_SSE_LINE_BYTES
),
/** 归档前允许下载的单张图片最大字节数;同时检查 content-length 和实际字节数。 */
imageArchiveMaxBytes: positiveIntegerEnv(
"IMAGE_ARCHIVE_MAX_BYTES",
DEFAULT_IMAGE_ARCHIVE_MAX_BYTES
),
/** 图床下载和上传共用的超时时间,避免 worker 槽位被慢请求长期占用。 */
imageArchiveTimeoutMs: positiveIntegerEnv(
"IMAGE_ARCHIVE_TIMEOUT_MS",
DEFAULT_IMAGE_ARCHIVE_TIMEOUT_MS
),
/** 单进程归档并发数;多实例部署时每个实例独立应用这个上限。 */
imageArchiveConcurrency: positiveIntegerEnv(
"IMAGE_ARCHIVE_CONCURRENCY",
DEFAULT_IMAGE_ARCHIVE_CONCURRENCY
),
/** 单张图片归档最大尝试次数;达到后写入最终失败状态。 */
imageArchiveMaxAttempts: positiveIntegerEnv(
"IMAGE_ARCHIVE_MAX_ATTEMPTS",
DEFAULT_IMAGE_ARCHIVE_MAX_ATTEMPTS
),
/** RUNNING 归档任务锁的过期时间;超时后可被其他 worker 重新领取。 */
imageArchiveLockTtlMs: positiveIntegerEnv(
"IMAGE_ARCHIVE_LOCK_TTL_MS",
DEFAULT_IMAGE_ARCHIVE_LOCK_TTL_MS
),
/** 空队列轮询间隔;有新任务时仍会主动唤醒扫描。 */
imageArchivePollMs: positiveIntegerEnv(
"IMAGE_ARCHIVE_POLL_MS",
DEFAULT_IMAGE_ARCHIVE_POLL_MS
),
/** 归档失败后的退避延迟列表;按尝试次数选择,超过长度后使用最后一项。 */
imageArchiveRetryDelaysMs: positiveIntegerListEnv(
"IMAGE_ARCHIVE_RETRY_DELAYS_MS",
DEFAULT_IMAGE_ARCHIVE_RETRY_DELAYS_MS
)
};
+2 -1
View File
@@ -5,9 +5,10 @@ import {
NEWAPI_SESSION_COOKIE,
NEWAPI_USER_ID_COOKIE
} from "~~/server/utils/newApiAuthCookies";
import { serverEnv } from "~~/server/utils/env";
/** 上游 NewAPI 服务的根地址,只在这里维护一次 */
const BASE_URL = "https://api.qflink.xyz";
const BASE_URL = serverEnv.newApiBaseUrl;
type FetchOptions = Parameters<typeof $fetch>[1];
type FetchHeaders = HeadersInit | undefined;
+9 -29
View File
@@ -10,6 +10,7 @@ import {
scheduleImageGenerationArchiveRetry,
type IImageArchiveTask
} from "~~/server/utils/imageGenerationRecords";
import { serverEnv } from "~~/server/utils/env";
import { uploadImageFromUrl } from "~~/server/utils/lsky";
interface IImageArchiveWorkerConfig {
@@ -36,16 +37,6 @@ interface IImageArchiveWorkerState {
workerId: string;
}
/** 默认只开 2 个归档槽位,避免多用户同时归档时挤占生图主链路资源 */
const DEFAULT_CONCURRENCY = 2;
/** 默认最多重试 3 次,覆盖短暂网络抖动,同时避免坏任务长期循环 */
const DEFAULT_MAX_ATTEMPTS = 3;
/** 锁超时 10 分钟,进程崩溃或重启后任务可以自动恢复 */
const DEFAULT_LOCK_TTL_MS = 10 * 60 * 1000;
/** 空队列每 15 秒扫一次;生图成功会主动唤醒,不依赖纯轮询 */
const DEFAULT_POLL_MS = 15 * 1000;
/** 失败退避节奏:第一次 30 秒,第二次 2 分钟,第三次及以后 10 分钟 */
const RETRY_DELAYS_MS = [30 * 1000, 2 * 60 * 1000, 10 * 60 * 1000];
// Nuxt 开发热更新可能重复加载模块;把状态挂到 globalThis,避免重复 worker 抢同一批任务。
const globalForArchiveWorker = globalThis as unknown as {
@@ -87,7 +78,7 @@ const drainImageArchiveQueue = async () => {
if (workerState.draining) return;
workerState.draining = true;
let nextDelayMs = DEFAULT_POLL_MS;
let nextDelayMs = serverEnv.imageArchivePollMs;
try {
const config = getImageArchiveWorkerConfig();
@@ -239,34 +230,23 @@ const scheduleDrain = (delayMs: number) => {
/** 从环境变量读取 worker 配置,非法值统一回退默认值 */
const getImageArchiveWorkerConfig = (): IImageArchiveWorkerConfig => {
return {
concurrency: getPositiveIntegerEnv(
"IMAGE_ARCHIVE_CONCURRENCY",
DEFAULT_CONCURRENCY
),
maxAttempts: getPositiveIntegerEnv(
"IMAGE_ARCHIVE_MAX_ATTEMPTS",
DEFAULT_MAX_ATTEMPTS
),
lockTtlMs: DEFAULT_LOCK_TTL_MS,
pollMs: DEFAULT_POLL_MS
concurrency: serverEnv.imageArchiveConcurrency,
maxAttempts: serverEnv.imageArchiveMaxAttempts,
lockTtlMs: serverEnv.imageArchiveLockTtlMs,
pollMs: serverEnv.imageArchivePollMs
};
};
/** 根据当前尝试次数选择退避时间,超过表长时沿用最后一个退避值 */
const getRetryDelayMs = (attempt: number) => {
const lastDelayMs = RETRY_DELAYS_MS[RETRY_DELAYS_MS.length - 1] ?? 0;
const retryDelaysMs = serverEnv.imageArchiveRetryDelaysMs;
const lastDelayMs = retryDelaysMs[retryDelaysMs.length - 1] ?? 0;
return (
RETRY_DELAYS_MS[Math.min(attempt - 1, RETRY_DELAYS_MS.length - 1)] ??
retryDelaysMs[Math.min(attempt - 1, retryDelaysMs.length - 1)] ??
lastDelayMs
);
};
/** 读取正整数环境变量,避免 0、负数、NaN 破坏限流和重试策略 */
const getPositiveIntegerEnv = (name: string, fallback: number) => {
const value = Number.parseInt(process.env[name] ?? "", 10);
return Number.isInteger(value) && value > 0 ? value : fallback;
};
/** 保存到数据库的失败摘要只取可读 message,不保存复杂错误对象 */
const getArchiveErrorMessage = (error: unknown) => {
if (error instanceof Error && error.message) return error.message;
+4 -1
View File
@@ -14,10 +14,13 @@ import {
PlazaPostStatus,
Prisma
} from "~~/app/generated/prisma/client";
import { serverEnv } from "~~/server/utils/env";
import { prisma } from "~~/server/utils/prisma";
/** 全局统计表只有一行,使用固定 ID 方便 upsert 和后续扩展。 */
const GLOBAL_STATS_ID = "global";
const DEFAULT_IMAGE_MODEL = "gpt-image-2";
/** 新建生图记录时写入的模型名,跟真实上游调用保持同一份部署配置。 */
const DEFAULT_IMAGE_MODEL = serverEnv.imageGenerationModel;
interface IFinishImageGenerationImageSuccessInput {
/** NewAPI 上游返回的生成图片 URL */
+1
View File
@@ -1,5 +1,6 @@
// server/utils/index.ts - 服务端工具统一导出口,方便 API handler 从同一入口导入。
export * from "./createApiResponse";
export * from "./env";
export * from "./fetch";
export * from "./imageArchiveQueue";
export * from "./imageGenerationRecords";
+9 -39
View File
@@ -1,5 +1,6 @@
// server/utils/lsky.ts - Lsky 图床上传工具:下载上游图片、重命名、打标签并上传归档。
import { createError } from "h3";
import { serverEnv } from "~~/server/utils/env";
interface ILskyUploadInput {
imageUrl: string;
@@ -32,10 +33,6 @@ export interface ILskyUploadedImage {
response: ILskyUploadResponse;
}
/** 单张归档图片默认最大 20MB,避免多用户并发时把图片二进制堆满进程内存 */
const DEFAULT_ARCHIVE_MAX_BYTES = 20 * 1024 * 1024;
/** 单次下载或上传默认 60 秒超时,避免慢连接长期占用归档 worker 槽位 */
const DEFAULT_ARCHIVE_TIMEOUT_MS = 60 * 1000;
/**
* 从远程图片 URL 下载图片并上传到 Lsky。
@@ -73,7 +70,7 @@ export const uploadImageFromUrl = async (
formData.append("is_remove_exif", "true");
formData.append(
"intro",
`AIArtStudio image generation ${input.recordId} image ${input.imageId}`
`${serverEnv.newApiTokenName} image generation ${input.recordId} image ${input.imageId}`
);
for (const tag of buildArchiveTags(
@@ -110,40 +107,18 @@ export const uploadImageFromUrl = async (
/** 读取并校验 Lsky 环境变量配置 */
const getLskyConfig = () => {
const baseUrl = process.env.LSKY_BASE_URL?.replace(/\/+$/, "");
const token = process.env.LSKY_TOKEN;
const storageId = Number.parseInt(process.env.LSKY_STORAGE_ID ?? "", 10);
if (!baseUrl) {
throw new Error("LSKY_BASE_URL 未配置");
}
if (!token) {
throw new Error("LSKY_TOKEN 未配置");
}
if (!Number.isInteger(storageId) || storageId <= 0) {
throw new Error("LSKY_STORAGE_ID 未配置或不是有效数字");
}
return {
baseUrl,
token,
storageId
baseUrl: serverEnv.lskyBaseUrl,
token: serverEnv.lskyToken,
storageId: serverEnv.lskyStorageId
};
};
/** 读取归档保护参数,限制单次下载/上传的大小和耗时 */
const getArchiveProtectionConfig = () => {
return {
maxBytes: getPositiveIntegerEnv(
"IMAGE_ARCHIVE_MAX_BYTES",
DEFAULT_ARCHIVE_MAX_BYTES
),
timeoutMs: getPositiveIntegerEnv(
"IMAGE_ARCHIVE_TIMEOUT_MS",
DEFAULT_ARCHIVE_TIMEOUT_MS
)
maxBytes: serverEnv.imageArchiveMaxBytes,
timeoutMs: serverEnv.imageArchiveTimeoutMs
};
};
@@ -263,11 +238,11 @@ const buildArchiveTags = (
imageId: bigint
) => {
return [
"AIArtStudio",
serverEnv.newApiTokenName,
`user:${userId}`,
`record:${recordId.toString()}`,
`image:${imageId.toString()}`,
"model:gpt-image-2"
`model:${serverEnv.imageGenerationModel}`
];
};
@@ -305,11 +280,6 @@ const isSuccessStatus = (status: unknown) => {
return status === true || status === "success" || status === "ok";
};
const getPositiveIntegerEnv = (name: string, fallback: number) => {
const value = Number.parseInt(process.env[name] ?? "", 10);
return Number.isInteger(value) && value > 0 ? value : fallback;
};
const isAbortError = (error: unknown) => {
return error instanceof Error && error.name === "AbortError";
};
+10 -6
View File
@@ -1,10 +1,13 @@
// server/utils/newApiTokens.ts - NewAPI Token 工具:准备 AIArtStudio Token 并在服务端读取完整 key。
// server/utils/newApiTokens.ts - NewAPI Token 工具:准备站点生图 Token 并在服务端读取完整 key。
import type { H3Event } from "h3";
import { createError } from "h3";
import type { IUserReadyData } from "#shared/types";
import { serverEnv } from "~~/server/utils/env";
import { newApiAuthedFetch } from "~~/server/utils/fetch";
const AI_ART_STUDIO_TOKEN_NAME = "AIArtStudio";
/** 当前站点在 NewAPI 中维护的 token 名称,来自部署环境,避免不同环境共用写死名称。 */
const AI_ART_STUDIO_TOKEN_NAME = serverEnv.newApiTokenName;
/** Token 列表每页读取数量;保持较大页大小以减少 ready 检查时的上游请求次数。 */
const TOKEN_PAGE_SIZE = 100;
interface INewApiTokenItem {
@@ -47,6 +50,7 @@ interface INewApiTokenKeyData {
key: string;
}
/** 创建站点生图 token 的固定 payload;只放 NewAPI 创建接口需要的白名单字段。 */
const AI_ART_STUDIO_TOKEN_PAYLOAD = {
remain_quota: 0,
remain_amount: 0,
@@ -60,7 +64,7 @@ const AI_ART_STUDIO_TOKEN_PAYLOAD = {
allow_ips: ""
};
/** 检查当前登录用户是否已有可用 AIArtStudio Key,没有则创建一个 */
/** 检查当前登录用户是否已有可用站点生图 Key,没有则创建一个 */
export const ensureAiArtStudioToken = async (
event: H3Event
): Promise<IUserReadyData> => {
@@ -80,7 +84,7 @@ export const ensureAiArtStudioToken = async (
};
};
/** 确保 AIArtStudio Token 可用,并获取完整 key,完整 key 不允许透出到前端 */
/** 确保站点生图 Token 可用,并获取完整 key,完整 key 不允许透出到前端 */
export const getAiArtStudioTokenKey = async (
event: H3Event
): Promise<string> => {
@@ -115,7 +119,7 @@ export const getAiArtStudioTokenKey = async (
return data.key;
};
/** 分页读取 Token 列表,只认名称为 AIArtStudio 且状态启用的未删除 Token */
/** 分页读取 Token 列表,只认配置名称且状态启用的未删除 Token */
const findActiveAiArtStudioToken = async (
event: H3Event
): Promise<INewApiTokenItem | null> => {
@@ -142,7 +146,7 @@ const findActiveAiArtStudioToken = async (
return null;
};
/** 创建默认 AIArtStudio Token,参数固定,避免 handler 内重复维护 */
/** 创建默认站点生图 Token,参数固定,避免 handler 内重复维护 */
const createAiArtStudioToken = async (event: H3Event) => {
const response = await newApiAuthedFetch<INewApiWrappedResponse<null>>(
event,
+5 -13
View File
@@ -1,12 +1,12 @@
// server/utils/openai.ts - OpenAI/NewAPI 调用工具:文本、视觉、Responses 流式和 Chat Completions 流式生图。
import OpenAI from "openai";
import type { BaseOptions } from "#shared/types/openai";
import { serverEnv } from "~~/server/utils/env";
/** 图像生成改走 Chat Completions 流式接口,通常能更快拿到中转平台返回的图片地址 */
const CHAT_COMPLETIONS_URL = "https://api.qflink.xyz/v1/chat/completions";
const IMAGE_GENERATION_MODEL = "gpt-image-2";
const DEFAULT_MAX_SSE_DATA_LINE_BYTES = 1024 * 1024;
const CHAT_COMPLETIONS_URL = `${serverEnv.newApiBaseUrl}/v1/chat/completions`;
const IMAGE_URL_NOT_FOUND_ERROR_MESSAGE = "图片生成失败:未找到图片地址";
/** 复用单个 TextEncoder 计算 SSE data 字节长度,避免在流式解析循环里反复创建对象。 */
const textEncoder = new TextEncoder();
/** Chat Completions SSE 每个 data chunk 的最小结构 */
@@ -142,7 +142,7 @@ export const askImgStream = async ({
"Content-Type": "application/json"
},
body: JSON.stringify({
model: IMAGE_GENERATION_MODEL,
model: serverEnv.imageGenerationModel,
stream: true,
messages: [
{
@@ -230,10 +230,7 @@ const readChatCompletionStream = async (
): Promise<IImageStreamUpstreamResponse> => {
const reader = stream.getReader();
const decoder = new TextDecoder();
const maxDataLineBytes = getPositiveIntegerEnv(
"IMAGE_STREAM_MAX_SSE_LINE_BYTES",
DEFAULT_MAX_SSE_DATA_LINE_BYTES
);
const maxDataLineBytes = serverEnv.imageStreamMaxSseLineBytes;
let buffer = "";
let content = "";
let chunkCount = 0;
@@ -363,8 +360,3 @@ const assertSseBufferSize = (buffer: string, maxDataLineBytes: number) => {
throw new Error("图片生成失败:流式响应过大");
}
};
const getPositiveIntegerEnv = (name: string, fallback: number) => {
const value = Number.parseInt(process.env[name] ?? "", 10);
return Number.isInteger(value) && value > 0 ? value : fallback;
};
+2 -5
View File
@@ -1,16 +1,13 @@
// server/utils/prisma.ts - Prisma 客户端初始化:使用 MariaDB adapter 并在开发热更新中复用连接池。
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import { PrismaClient } from "~~/app/generated/prisma/client";
import { serverEnv } from "~~/server/utils/env";
const globalForPrisma = globalThis as unknown as {
prisma?: PrismaClient;
};
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error("DATABASE_URL is required to initialize PrismaClient");
}
const databaseUrl = serverEnv.databaseUrl;
type MariaDbPoolConfig = Exclude<
ConstructorParameters<typeof PrismaMariaDb>[0],
+3 -3
View File
@@ -63,12 +63,12 @@ export type IUserLoginData = IUserBasicData;
export type IUserMeData = IUserBasicData;
/**
* 登录后准备 AIArtStudio Key 的结果。
* 登录后准备站点生图 Key 的结果。
*/
export interface IUserReadyData {
/** 是否已经确认当前账号具备可用的 AIArtStudio Key */
/** 是否已经确认当前账号具备可用的站点生图 Key */
ready: boolean;
/** 本次检查是否创建了新的 AIArtStudio Key */
/** 本次检查是否创建了新的站点生图 Key */
created: boolean;
}