This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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`
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
)
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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
@@ -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";
|
||||
};
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user