Compare commits
156 Commits
e3d274cfd8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31e8da4259 | ||
|
|
7519c0c877 | ||
|
|
8e6a1e162a | ||
|
|
1cc44d64a4 | ||
|
|
98e0573359 | ||
|
|
a687958a9d | ||
|
|
fd18f2de68 | ||
|
|
e059d445e2 | ||
|
|
e9b1993209 | ||
|
|
0ab469fee9 | ||
|
|
2f4288dcf3 | ||
|
|
141cb08b36 | ||
|
|
6609a7dec2 | ||
|
|
567d264673 | ||
|
|
4bd360ef29 | ||
|
|
dd1118dfb8 | ||
|
|
e1ec52ab86 | ||
|
|
7ee5950cf6 | ||
|
|
f21c668d9e | ||
|
|
dd6b0079bb | ||
|
|
7efbb36783 | ||
|
|
b15843bbee | ||
|
|
48666447fb | ||
|
|
e0d606a6c1 | ||
|
|
fb5760cb36 | ||
|
|
eef35c8f10 | ||
|
|
f449afb956 | ||
|
|
26cb0ddbb3 | ||
|
|
e67bc062cf | ||
|
|
2dfbd7098e | ||
|
|
23c13f8274 | ||
|
|
f3d5135e0b | ||
|
|
3ba90c0933 | ||
|
|
a0c8c128a2 | ||
|
|
79f00aa8f2 | ||
|
|
4a00eb7ef5 | ||
|
|
9f41d2c87a | ||
|
|
fee527e1a3 | ||
|
|
9461531ff3 | ||
|
|
6cc30347a2 | ||
|
|
7eacfe9220 | ||
|
|
d9c1583c1b | ||
|
|
7f76b6224b | ||
|
|
db6d63e23a | ||
|
|
634106740a | ||
|
|
080f2e2b95 | ||
|
|
d2cb017a8c | ||
|
|
d7ebab45af | ||
|
|
5d50c72902 | ||
|
|
d8619fd9e6 | ||
|
|
afd8585882 | ||
|
|
705b54adfe | ||
|
|
b508d8df58 | ||
|
|
2137a4b23f | ||
|
|
4320a1499c | ||
|
|
ca2a009948 | ||
|
|
1554eda37f | ||
|
|
95a6f5fbb3 | ||
|
|
632c94be78 | ||
|
|
518c02f1ba | ||
|
|
8595cdc6a4 | ||
|
|
0ceabafb6d | ||
|
|
c45c78ac40 | ||
|
|
f87dab7284 | ||
|
|
6d6fdf286a | ||
|
|
d55cb5bf22 | ||
|
|
81501ade13 | ||
|
|
9966994935 | ||
|
|
2c7bd74bbe | ||
|
|
8bb839f6fb | ||
|
|
50daaf8fb2 | ||
|
|
fdee295098 | ||
|
|
80a3d3aac8 | ||
|
|
cea408fb5d | ||
|
|
57e9fd8459 | ||
|
|
8c7dbea597 | ||
|
|
5c5cb6c907 | ||
|
|
f06c475034 | ||
|
|
489c4c377a | ||
|
|
6650f5c6fc | ||
|
|
244439f71f | ||
|
|
997d5dd6e0 | ||
|
|
baa7bbc79b | ||
|
|
d5972ad14e | ||
|
|
b263c26ac0 | ||
|
|
2fcf9c9773 | ||
|
|
a2b2fb82ba | ||
|
|
813ffbfa8b | ||
|
|
54e6ab6750 | ||
|
|
4a1a309b4f | ||
|
|
bfc98c6a92 | ||
|
|
8430b771e1 | ||
|
|
ae6fd626b1 | ||
|
|
8bc18c6cd8 | ||
|
|
7b3235b218 | ||
|
|
015184bca7 | ||
|
|
6e0c75778e | ||
|
|
17a22f6953 | ||
|
|
fa74bac92f | ||
|
|
e4b636b85d | ||
|
|
33cc461cc8 | ||
|
|
11d98a6fc6 | ||
|
|
c52bfa98da | ||
|
|
19b3eb75cd | ||
|
|
4faace0191 | ||
|
|
901a9ce898 | ||
|
|
c674f79f07 | ||
|
|
618e58b04a | ||
|
|
493ae83d2d | ||
|
|
b9a8521d1b | ||
|
|
4d5ec0b6b5 | ||
|
|
c9d6915878 | ||
|
|
8ee86a970e | ||
|
|
5de0e462f0 | ||
|
|
1f721b62b1 | ||
|
|
46cca0d4e1 | ||
|
|
eb7e5fd97e | ||
|
|
5cbc0d213d | ||
|
|
9def9d8664 | ||
|
|
73134516a9 | ||
|
|
65b3fe1100 | ||
|
|
693fc7cae1 | ||
|
|
dc8bdcdec2 | ||
|
|
52c7c66cb3 | ||
|
|
5266462603 | ||
|
|
91cba60e5e | ||
|
|
1d76cca082 | ||
|
|
4ae03c8caf | ||
|
|
0a1bce06b0 | ||
|
|
96555bdf51 | ||
|
|
927faacc5f | ||
|
|
723d9832d5 | ||
|
|
6db64d5161 | ||
|
|
e3454c7fba | ||
|
|
0962b4f3fc | ||
|
|
ee9516c733 | ||
|
|
d398ec967f | ||
|
|
14b7b3afe6 | ||
|
|
ffde03bbbc | ||
|
|
a68c00ff93 | ||
|
|
e06fc806f1 | ||
|
|
adef2da1d9 | ||
|
|
ded16b8778 | ||
|
|
61e9fbd6d4 | ||
|
|
8ae28e030d | ||
|
|
70656562b1 | ||
|
|
1a27177f51 | ||
|
|
66c82fd1ee | ||
|
|
24be9c550b | ||
|
|
1a0607fe8d | ||
|
|
f2817ab8fd | ||
|
|
e072f219e4 | ||
|
|
8ae0f57488 | ||
|
|
24eab34305 | ||
|
|
c8f0c37cd1 | ||
|
|
234da90ac6 |
24
.env.example
24
.env.example
@@ -49,6 +49,10 @@ LOCAL_DB_SERVICE_ROLE_KEY=local-service-role-key
|
||||
# NEXT_PUBLIC_APP_URL=https://your-domain.com
|
||||
# APP_BASE_URL=https://your-domain.com
|
||||
|
||||
# ----- 第三方平台 iframe 嵌入白名单 (可选) -----
|
||||
# 留空时默认允许同源和 mozheAPI 域名嵌入。多个来源用逗号或空格分隔。
|
||||
# MIAOJING_FRAME_ANCESTORS=https://mozhevip.top https://*.mozhevip.top
|
||||
|
||||
# ----- 生产安全密钥 (生产环境必须设置) -----
|
||||
# 建议使用 openssl rand -hex 32 生成
|
||||
# DATA_ENCRYPTION_KEY=
|
||||
@@ -59,6 +63,24 @@ LOCAL_DB_SERVICE_ROLE_KEY=local-service-role-key
|
||||
# LOCAL_STORAGE_DIR=/var/lib/miaojingAI/storage
|
||||
# BACKUP_DIR=/var/lib/miaojingAI/backups
|
||||
|
||||
# ----- 对象存储 (S3/MinIO/OSS 等 S3 兼容服务,可选) -----
|
||||
# STORAGE_MODE=local # local=仅本地;dual=本地+对象存储双写;object=仅对象存储
|
||||
# OBJECT_STORAGE_BUCKET=
|
||||
# OBJECT_STORAGE_REGION=auto
|
||||
# OBJECT_STORAGE_ENDPOINT=
|
||||
# OBJECT_STORAGE_ACCESS_KEY_ID=
|
||||
# OBJECT_STORAGE_SECRET_ACCESS_KEY=
|
||||
# OBJECT_STORAGE_FORCE_PATH_STYLE=true
|
||||
# OBJECT_STORAGE_PREFIX=miaojing
|
||||
|
||||
# ----- 雨云 ROS 对象存储控制面 (可选,用于创建/核验桶并生成 OBJECT_STORAGE_* 配置) -----
|
||||
# RAINYUN_API_BASE_URL=https://api.v2.rainyun.com
|
||||
# RAINYUN_API_KEY=
|
||||
# RAINYUN_DEV_TOKEN=
|
||||
# RAINYUN_ROS_INSTANCE_ID=
|
||||
# RAINYUN_ROS_BUCKET_NAME=miaojing-prod
|
||||
# RAINYUN_ROS_OUTPUT_ENV=.env.rainyun-object.generated
|
||||
|
||||
# ----- 数据库连接池 (可选) -----
|
||||
# DB_POOL_MAX=20
|
||||
# DB_CONNECTION_TIMEOUT_MS=5000
|
||||
@@ -88,7 +110,7 @@ LOCAL_DB_SERVICE_ROLE_KEY=local-service-role-key
|
||||
# 2. 创建名为 miaojing 的数据库
|
||||
# 3. 运行 scripts/init-database.sql 初始化数据库结构
|
||||
# 4. 配置 LOCAL_DB_URL 等本地数据库环境变量
|
||||
# 5. 系统会自动使用本地存储替代 S3 存储
|
||||
# 5. 系统默认使用本地存储;配置对象存储后建议先用 STORAGE_MODE=dual 迁移和双写
|
||||
#
|
||||
# - Supabase 云端模式:
|
||||
# 1. 创建 Supabase 项目
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -23,9 +23,12 @@ coverage/
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.rainyun-object.generated
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
!src/app/api/admin/logs/
|
||||
!src/app/api/admin/logs/route.ts
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
|
||||
19
AGENTS.md
Normal file
19
AGENTS.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Codex Mandatory Project Instructions
|
||||
|
||||
This repository is the source code for the MiaoJing AI creation platform.
|
||||
|
||||
Before any Codex agent changes code, fixes bugs, reviews behavior, or answers implementation-location questions in this repository, it must first read:
|
||||
|
||||
1. `CODEX_MIAOJING_MEMORY.md`
|
||||
2. `docs/codex-miaojing/README.md`
|
||||
3. The specific index document linked from the memory file for the task area:
|
||||
- `docs/codex-miaojing/feature-code-index.md`
|
||||
- `docs/codex-miaojing/bug-location-guide.md`
|
||||
- `docs/codex-miaojing/api-reference.md`
|
||||
- `docs/codex-miaojing/architecture.md`
|
||||
|
||||
Do not start by broad-searching the repository when the memory and index documents already identify a likely file path. Use those documents as the first routing layer, then verify against the current source before editing.
|
||||
|
||||
Every code adjustment, configuration change, workflow change, API change, UI change, and bug fix must include a documentation check before commit. If the change affects how future agents should locate code, diagnose bugs, call APIs, understand architecture, deploy, or verify behavior, update the matching document in `docs/codex-miaojing/` and, if needed, update `CODEX_MIAOJING_MEMORY.md` in the same commit. Do not leave documentation updates for a later session.
|
||||
|
||||
All new development must remain compatible with the production admin upgrade path. Before finishing a change, decide whether it can be delivered as a hot update package or requires a cold update package through the admin console. Changes that touch `src`, server code, API routes, `package.json`, `pnpm-lock.yaml`, database schema, environment variables, PM2/runtime configuration, build scripts, backup/restore scripts, or generated server assets must be treated as cold-update candidates unless the upgrade runner explicitly supports them as safe hot-update payloads. Static/public asset-only changes may be hot-update candidates only after package preflight passes. Never finish deploy-facing work without considering backup, rollback, package preflight, and post-upgrade health checks.
|
||||
110
CODEX_MIAOJING_MEMORY.md
Normal file
110
CODEX_MIAOJING_MEMORY.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Codex MiaoJing Memory
|
||||
|
||||
Last source audit: 2026-05-12, based on git commit `8ee86a9` (`Add NewAPI image compatibility and style presets`).
|
||||
|
||||
This file is the required entry point for Codex work in this repository. Its job is to prevent repeated rediscovery. Every Codex session working on MiaoJing must read this file first, then jump to the referenced index document before editing.
|
||||
|
||||
## Required Reading Order
|
||||
|
||||
1. Read this file.
|
||||
2. Read `docs/codex-miaojing/README.md`.
|
||||
3. Pick the task-specific document:
|
||||
- Feature or UI location: `docs/codex-miaojing/feature-code-index.md`
|
||||
- Bug report or regression: `docs/codex-miaojing/bug-location-guide.md`
|
||||
- API contract, route, auth, request body: `docs/codex-miaojing/api-reference.md`
|
||||
- System boundaries, data flow, deployment: `docs/codex-miaojing/architecture.md`
|
||||
- Custom integration keyword such as `元界`, `Agnes`, `mozheAPI`, or `智能配置 API`: also read `docs/codex-miaojing/custom-integrations.md` and search long-term memory for the keyword before editing.
|
||||
4. Verify the file paths against current source with `rg` or direct file reads.
|
||||
5. Make the smallest scoped code change that fits the existing architecture.
|
||||
6. For every adjustment or modification, check whether the change affects any project knowledge document. If it changes code location, UI behavior, API behavior, data shape, schema expectation, deployment flow, verification method, bug-diagnosis path, or provider/platform-specific integration logic, update the corresponding document in the same commit.
|
||||
7. If a custom integration rule is durable across future sessions, write it to long-term memory instead of relying only on chat context.
|
||||
8. Before finishing new development, classify the production delivery path: admin-console hot update package or admin-console cold update package. Record upgrade, backup, rollback, and health-check implications when the change affects deployable behavior.
|
||||
|
||||
## Repository Identity
|
||||
|
||||
- Product: MiaoJing AI creation platform.
|
||||
- Stack: Next.js 16 App Router, React 19, TypeScript, PostgreSQL, local file storage, PM2.
|
||||
- Package manager: `pnpm` only.
|
||||
- Primary source directories:
|
||||
- Pages and API routes: `src/app`
|
||||
- UI and workflow components: `src/components`
|
||||
- Business logic and stores: `src/lib`
|
||||
- Console module entry points: `src/modules`
|
||||
- Database clients and schema snapshot: `src/storage`
|
||||
- Deployment and maintenance scripts: `scripts`
|
||||
|
||||
## Canonical Local Context
|
||||
|
||||
- This local clone was reset to `origin/main` before these docs were written.
|
||||
- Remote: `https://git.toplee.cn/fenglee/miaojingAI.git`
|
||||
- If server deployment is requested later, verify the active runtime tree and PM2 cwd before editing. Do not assume a production tree from memory.
|
||||
- Production access verified on 2026-05-14 used `ssh -p 5238 root@124.174.9.29`; PM2 still served the live tree from `/opt/miaojingAI` through Node under `/data/miaojingAI/node/node-v24.15.0-linux-x64/bin`, with web/API/console ports `8000/8100/8200`. `/root/miaojingAI` may coexist and must not be treated as live without PM2 confirmation.
|
||||
- When syncing source into `/opt/miaojingAI`, preserve production-only runtime files such as `.env.local`, `node_modules`, `.next`, `dist`, `backups`, local storage, and the production `ecosystem.config.cjs`. The repository copy may point at `/root/miaojingAI` and ports `5000/5100/5200`; overwriting production `ecosystem.config.cjs` breaks the live nginx upstream until restored. Manual source archives must exclude `ecosystem.config.cjs`, or the deploy must restore the production copy from the pre-sync backup before PM2 reload.
|
||||
- New-environment migrations must verify database table ownership as well as grants. If `LOCAL_DB_URL` uses the app user but restored tables are still owned by `postgres`, runtime compatibility checks can fail with `must be owner of table ...`, which can make `/api/model-config` return no `systemApis` even when backend default models exist.
|
||||
|
||||
## Fast Routing Map
|
||||
|
||||
Use this table before searching.
|
||||
|
||||
| Task | Start Here | Then Check |
|
||||
| --- | --- | --- |
|
||||
| Home page, shell, navigation, footer, announcement popup | `src/app/page.tsx`, `src/components/app-shell.tsx`, `src/components/navbar.tsx`, `src/components/site-footer.tsx`, `src/components/announcement-popup.tsx` | `src/lib/site-config.ts`, `src/app/api/site-config/route.ts`, `src/app/api/announcements/route.ts` |
|
||||
| Create center tabs | `src/app/create/page.tsx` | `src/components/create/*` |
|
||||
| Text/image generation | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/create/generation-task-list.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`, `src/lib/generation-job-*`, `src/lib/generation-credit-service.ts`, `src/app/api/style-presets/route.ts`, `src/lib/style-preset-store.ts`, `src/lib/layout-composition-skill.ts`. Image panels allow multiple active submissions and keep active job cards inside the results column while completed results remain visible. System default model credit deduction is server-side and tied to the selected `system_api_configs` pricing row. The optional 100 Layout Compositions skill is controlled from admin settings and injects composition guidance into image prompts only when enabled. |
|
||||
| Video generation | `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx`, `src/components/create/generation-task-list.tsx` | `src/app/api/generate/video/route.ts`. Video panels also allow multiple active submissions and keep active job cards inside the results column. |
|
||||
| Reverse prompt | `src/components/create/reverse-prompt-panel.tsx` | `src/app/api/generate/reverse-prompt/route.ts`, `src/app/api/generate/suggest-prompt/route.ts` |
|
||||
| Model/provider visibility | `src/lib/model-config.ts`, `src/lib/model-config-types.ts`, `src/lib/server-api-config.ts` | `src/app/api/model-config/route.ts`, `src/app/api/admin/system-apis/route.ts`, `src/app/api/admin/providers/route.ts`, `src/app/api/user-api-keys/route.ts` |
|
||||
| Custom integrations (`元界`, `Agnes`, `mozheAPI`, `智能配置 API`) | `docs/codex-miaojing/custom-integrations.md` first | Then use the feature/bug/API/architecture doc that matches the symptom. Search long-term memory for the exact keyword before changing code. Provider-specific built-in template management such as Agnes AI belongs in the `系统默认模型` flow, not in generic smart import. |
|
||||
| User auth/login/register/profile | `src/lib/session-auth.ts`, `src/lib/auth-store.ts` | `src/app/api/auth/*`, `src/app/api/profile/*` |
|
||||
| Admin console | `src/app/console/page.tsx`, `src/app/console/dashboard/page.tsx`, `src/modules/console/pages/*` | `src/components/admin/*`, `src/app/api/admin/*` |
|
||||
| Canvas (legacy, disabled in UI) | `src/app/canvas/page.tsx`, `src/components/canvas/infinite-canvas-workspace.tsx`, `src/components/canvas/react-flow-canvas.tsx` | `/canvas` intentionally returns 404 and navbar must not show `画布`; legacy source/API files remain only for future cleanup or explicit re-enable work. |
|
||||
| Gallery and creation history | `src/app/gallery/page.tsx`, `src/app/profile/page.tsx`, `src/components/profile/creation-history-tab.tsx`, `src/components/image-metadata-badge.tsx` | `src/lib/creation-history-store.ts`, `src/lib/media-storage.ts`, `src/lib/gallery-publish-media.ts`, `src/app/api/gallery/*`, `src/app/api/creation-history/route.ts`. Gallery is server-authoritative: do not merge browser localStorage published/history records into the public gallery feed and do not auto-sync historical local published records on gallery page load. The gallery page must not request the full gallery at once; it uses small `/api/gallery` pages, browser-visible lazy image loading, and an IntersectionObserver sentinel to append more works as the user scrolls. It keeps a bounded browser localStorage list cache for instant first paint, then revalidates page 0 in the background so new/deleted works replace cached rows quickly. Gallery/detail/history image previews show actual ratio and natural resolution in the upper-right badge and should render `thumbnailUrl || url`; fullscreen, download, copy, edit, share, and reuse actions keep using original `url`. Current thumbnails use the `m1280q86` WEBP profile, balancing smaller gallery payloads with clear detail previews, and fullscreen components should show thumbnail fallback while original object-storage images load. `/api/gallery/publish` must reuse stable `/api/local-storage/...` generated image/video originals instead of synchronously copying object-backed media during share; external URLs still copy into gallery storage before insertion. History also refreshes on `miaojing_auth_updated` after login/account switch. |
|
||||
| Local/object files/downloads | `src/lib/local-storage.ts`, `src/lib/media-storage.ts`, `src/lib/media-watermark*.ts`, `src/app/api/local-storage/[...path]/route.ts` | `src/app/api/download/route.ts`, `src/proxy.ts`, `scripts/storage-sync-to-object.mjs`, `scripts/rainyun-ros-prepare.mjs`. Public URLs stay `/api/local-storage/<key>` while the backend can be `STORAGE_MODE=local`, `dual`, or `object`; new image originals can be written object-only, while compressed high-quality WEBP thumbnails are local-only under `thumbnails/...` and must be served from local disk directly. Generated work media is watermarked server-side before display/download for normal users, using `public/watermark/miaojing-watermark-logo.png` plus `MIAOJING AI`; display requests for generated image originals may redirect to an existing local thumbnail first so pages do not synchronously watermark multi-megabyte object-backed originals, but downloads still use original media. Do not reintroduce raw object-storage redirects for generated images/videos unless the download route has verified an admin role or a user with `profiles.watermark_disabled=true`; admins can toggle that flag per user from `src/components/admin/user-management-tab.tsx` through `/api/admin/users`. Thumbnail filenames include the resize/quality profile and can be served with long immutable browser cache headers; `src/proxy.ts` must not override thumbnail or gallery cache headers with global `/api` no-store. Object-backed non-generated files may redirect to short-lived signed object-storage URLs. When syncing production source, exclude only repo-root `/local-storage/`, not broad `local-storage/`, or this source route can be skipped. Rainyun ROS API is a control-plane helper for bucket creation/config generation; runtime file IO still uses S3-compatible `OBJECT_STORAGE_*`. |
|
||||
| Email and policy pages | `src/lib/email-service.ts`, `src/components/site-policy-page.tsx` | `src/app/api/email/*`, `src/app/about/page.tsx`, `src/app/terms/page.tsx`, `src/app/privacy/page.tsx`, `src/app/help/page.tsx` |
|
||||
| Upgrade/deploy/backup | `scripts/*`, `ecosystem.config.cjs` | `src/app/api/admin/upgrade/route.ts`, `src/components/admin/system-upgrade-tab.tsx` |
|
||||
| Data backup/import/export | `src/components/admin/data-management-tab.tsx` | `src/app/api/admin/data-export/route.ts`, `src/app/api/admin/data-import/route.ts`, `src/lib/local-storage.ts`, `scripts/migration-integrity-check.mjs`. Export includes `_media` for storage assets; import restores media through the active storage adapter, remaps custom IDs, runs in a transaction, dedupes works by URL/source URL/media SHA only within the same `user_id`, and preserves password hashes, encrypted API keys, Manifest paths, and API pricing fields. |
|
||||
|
||||
## Current Known Source Warning
|
||||
|
||||
At the time this document was created, the remote source imports `@/lib/model-display` from:
|
||||
|
||||
- `src/components/create/text-to-image.tsx`
|
||||
- `src/components/create/image-to-image.tsx`
|
||||
|
||||
But `src/lib/model-display.ts` is not present after resetting to `origin/main`. If `pnpm run ts-check` fails on this import, treat it as a pre-existing source-state issue unless your task is specifically to repair it.
|
||||
|
||||
The documentation commit also attempted `corepack pnpm run ts-check`. It failed before any source edits with:
|
||||
|
||||
- missing module/type resolution for `ag-psd`
|
||||
- missing module/type resolution for `@xyflow/react`
|
||||
- missing `@/lib/model-display`
|
||||
- canvas callback implicit-`any` errors in `src/components/canvas/react-flow-canvas.tsx`
|
||||
|
||||
Before treating `ag-psd` or `@xyflow/react` as source bugs, run `corepack pnpm install --frozen-lockfile` in a clean environment; those may be local dependency-install state. The missing `@/lib/model-display` file is a source-tree issue at this audited commit.
|
||||
|
||||
## Required Change Discipline
|
||||
|
||||
- Do not bypass auth helpers. Use `getAuthenticatedUser`, `getAuthenticatedUserId`, `requireAdminUser`, or `requireAdmin`.
|
||||
- Do not write raw API keys to responses or logs. Use encryption helpers in `src/lib/server-crypto.ts` and safe mapping functions.
|
||||
- Do not create ad hoc storage paths. Use `src/lib/local-storage.ts`, preserve path traversal checks, and keep local/object storage migration compatible with stable `/api/local-storage/<key>` URLs.
|
||||
- Do not create a second generation flow without checking `src/lib/generation-job-client.ts`, `src/lib/generation-job-runner.ts`, and `src/lib/generation-job-worker.ts`.
|
||||
- Do not change admin upgrade behavior without checking both `src/app/api/admin/upgrade/route.ts`, `scripts/admin-upgrade-runner.mjs`, `scripts/backup-create.sh`, and `scripts/backup-restore.sh`.
|
||||
- Do not change backup/import/restore behavior without preserving transaction boundaries, media restore behavior, dedupe rules, and pre-restore safety backups.
|
||||
- Do not change public content rendering without checking both backend persistence (`site-config`, `announcements`) and frontend consumers (`site-config-sync`, policy pages, footer, popup).
|
||||
- Do not treat a feature as complete until its production update-package path is clear. Hot-update candidates should be limited to payloads the upgrade runner accepts without restart, usually static/public assets. Changes to source code, API routes, server code, dependencies, DB schema or compatibility migrations, environment variables, PM2/runtime config, build scripts, backup/restore scripts, or deployment scripts are cold-update candidates and must preserve package preflight, backup, rollback, rebuild, restart, and `/api/health` verification.
|
||||
|
||||
## Documentation Maintenance Rule
|
||||
|
||||
Every Codex change must include a documentation impact check. This applies to feature work, refactors, configuration changes, UI copy/behavior changes, API changes, deployment changes, and every bug fix.
|
||||
|
||||
If the code or behavior changed in a way that future Codex sessions need to know, update the matching documentation in the same commit. Do not postpone documentation updates.
|
||||
|
||||
Required mapping:
|
||||
|
||||
- New or changed route: update `docs/codex-miaojing/api-reference.md`.
|
||||
- Moved, renamed, or newly important code path: update `docs/codex-miaojing/feature-code-index.md`.
|
||||
- Newly discovered regression pattern or repeat bug: update `docs/codex-miaojing/bug-location-guide.md`.
|
||||
- New subsystem, persistence model, deployment path, or runtime boundary: update `docs/codex-miaojing/architecture.md`.
|
||||
- Changed required Codex workflow, canonical source, validation baseline, or cross-document rule: update `CODEX_MIAOJING_MEMORY.md` and usually `AGENTS.md`.
|
||||
|
||||
If a code change truly has no documentation impact, state that explicitly in the final response or commit notes. Otherwise, the documentation update is part of the required fix, not optional cleanup.
|
||||
613
README.md
613
README.md
@@ -1,2 +1,613 @@
|
||||
# miaojingAI
|
||||
# 妙境 AI 创作平台
|
||||
|
||||
妙境是一个面向个人创作者、内容团队和私有化部署场景的 AI 多模态创作平台。平台围绕“文生图、图生图、文生视频、图生视频、图片反推提示词”构建完整创作链路,提供用户体系、积分/会员、订单支付、作品历史、公开画廊、模型供应商管理、系统配置、数据备份和在线升级能力。
|
||||
|
||||
项目基于 Next.js App Router、React、PostgreSQL、本地文件存储和 PM2 运行,支持本地 PostgreSQL 部署,也兼容 Supabase 作为数据库/认证底座。AI 生成能力既支持系统默认供应商,也支持用户自定义 OpenAI/New API 兼容接口。
|
||||
|
||||
## 项目截图
|
||||
|
||||
以下截图来自开发服务器的真实页面,用于快速了解平台界面和核心工作流。
|
||||
|
||||
### 首页
|
||||
|
||||

|
||||
|
||||
### 创作中心
|
||||
|
||||

|
||||
|
||||
### 作品画廊
|
||||
|
||||

|
||||
|
||||
## 核心能力
|
||||
|
||||
### 创作能力
|
||||
|
||||
- 文生图:根据文本提示词生成图片,支持尺寸、比例、模型和提示词参数。
|
||||
- 图生图:上传参考图后进行风格迁移、场景变换、细节重绘和创意延展。
|
||||
- 文生视频:根据文字描述生成动态视频内容。
|
||||
- 图生视频:基于静态图片生成动态视频。
|
||||
- 图片反推提示词:从图片中提取提示词,支持普通提示词、复刻级像素提示词、像素级图生图、像素级文生图等模式。
|
||||
- 生成任务队列:生成任务写入 `generation_jobs`,前端可轮询任务状态并从历史记录中查看结果。
|
||||
- 作品管理:保存创作历史、生成参数、结果链接、尺寸、时长、消耗积分等信息。
|
||||
- 画廊发布:用户可将作品公开到画廊,支持点赞、复制提示词、全屏预览和下载。
|
||||
|
||||
### 管理后台
|
||||
|
||||
管理后台入口为 `/console`,管理员登录后可进入仪表盘和各类管理模块。
|
||||
|
||||
- 仪表盘:统计用户、作品、任务、订单、供应商、公告、日志和系统健康状态。
|
||||
- API 管理:配置系统供应商、模型推荐、系统 API、New API/OpenAI 兼容站点。
|
||||
- 用户管理:管理用户资料、角色、会员、积分、账号状态。
|
||||
- 价格设置:维护会员套餐、积分规则和付费能力。
|
||||
- 订单管理:查看订单、支付状态和收入统计。
|
||||
- 支付配置:配置微信、支付宝、Stripe 等支付方式的展示和密钥。
|
||||
- 公告管理:创建站点公告、弹窗公告、有效期和展示策略。
|
||||
- 数据管理:导出/导入业务数据,适合迁移和人工备份。
|
||||
- 系统升级:支持热更新和冷更新,自动备份、失败回滚、中文日志和历史记录。
|
||||
- 系统日志:查看登录、安全、运行、管理操作等平台日志。
|
||||
- 系统设置:维护站点名称、Logo、页脚、邮箱、通知和站点政策内容。
|
||||
|
||||
### 运维能力
|
||||
|
||||
- 一键部署/升级脚本:`scripts/deploy-or-upgrade.sh`
|
||||
- 构建脚本:`scripts/build.sh`
|
||||
- 数据备份:`scripts/backup-create.sh`
|
||||
- 数据恢复:`scripts/backup-restore.sh`
|
||||
- 备份列表:`scripts/backup-list.sh`
|
||||
- 数据库初始化:`scripts/init-database.sql`
|
||||
- 数据库补丁:`scripts/apply-database-patch.sh`
|
||||
- 管理后台在线升级 runner:`scripts/admin-upgrade-runner.mjs`
|
||||
- PM2 运行配置:`ecosystem.config.cjs`
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层级 | 技术 |
|
||||
| --- | --- |
|
||||
| 前端框架 | Next.js 16 App Router、React 19、TypeScript |
|
||||
| UI 组件 | Radix UI、Tailwind CSS、lucide-react、sonner |
|
||||
| 服务端 | Next.js Route Handlers、自定义 Node HTTP server、tsup |
|
||||
| 数据库 | PostgreSQL 14+,可接 Supabase |
|
||||
| 存储 | 本地文件存储,生产推荐 `LOCAL_STORAGE_DIR=/var/lib/miaojingAI/storage` |
|
||||
| 认证 | 本地 auth schema + session/JWT,兼容 Supabase 风格表结构 |
|
||||
| AI 调用 | coze-coding-dev-sdk、用户自定义 API、系统 API、New API/OpenAI compatible |
|
||||
| 进程管理 | PM2 |
|
||||
| 构建工具 | pnpm、Turbopack、tsup、TypeScript |
|
||||
|
||||
## 系统架构
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Browser │
|
||||
│ 首页 / 创作中心 / 画廊 / 个人中心 / 管理后台 Console │
|
||||
└──────────────────────────────┬──────────────────────────────┘
|
||||
│ HTTP
|
||||
┌──────────────────────────────▼──────────────────────────────┐
|
||||
│ Next.js App Router │
|
||||
│ app pages + route handlers + middleware + server components │
|
||||
└───────────────┬──────────────────────┬──────────────────────┘
|
||||
│ │
|
||||
┌───────────────▼──────────────┐ │
|
||||
│ API Route 层 │ │
|
||||
│ /api/generate/image │ │
|
||||
│ /api/generate/video │ │
|
||||
│ /api/generation-jobs │ │
|
||||
│ /api/admin/* │ │
|
||||
│ /api/local-storage/* │ │
|
||||
└───────────────┬──────────────┘ │
|
||||
│ │
|
||||
┌───────────────▼──────────────┐ │
|
||||
│ 业务服务层 │ │
|
||||
│ auth / model config / jobs │ │
|
||||
│ credits / orders / storage │ │
|
||||
│ platform logs / upgrade │ │
|
||||
└───────┬──────────────┬───────┘ │
|
||||
│ │ │
|
||||
┌───────▼──────┐ ┌─────▼────────┐ ┌────▼──────────────────┐
|
||||
│ PostgreSQL │ │ 本地文件存储 │ │ 上游 AI / New API 站点 │
|
||||
│ profiles │ │ images/videos│ │ OpenAI compatible │
|
||||
│ works │ │ avatars │ │ image/video providers │
|
||||
│ jobs/orders │ │ backups │ │ │
|
||||
└──────────────┘ └──────────────┘ └───────────────────────┘
|
||||
```
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
.
|
||||
├── assets/ # 项目内置图片资源
|
||||
├── docs/images/ # README 使用的项目截图
|
||||
├── public/ # favicon、logo、公开静态文件
|
||||
├── scripts/
|
||||
│ ├── admin-upgrade-runner.mjs # 管理后台热/冷更新执行器
|
||||
│ ├── apply-database-patch.sh # 执行数据库补丁
|
||||
│ ├── backup-create.sh # 创建数据库/存储/.env 备份
|
||||
│ ├── backup-list.sh # 查看备份列表
|
||||
│ ├── backup-restore.sh # 恢复备份
|
||||
│ ├── build.sh # Next.js + server 构建
|
||||
│ ├── deploy-or-upgrade.sh # 一键部署/升级
|
||||
│ ├── dev.sh # 本地开发启动脚本
|
||||
│ ├── init-database.sql # PostgreSQL 初始化脚本
|
||||
│ └── start.sh # 生产启动脚本
|
||||
├── src/
|
||||
│ ├── app/ # Next.js App Router 页面与 API
|
||||
│ ├── components/ # 页面组件、创作组件、管理后台组件、UI 组件
|
||||
│ ├── hooks/ # 前端 hooks
|
||||
│ ├── lib/ # 业务逻辑、认证、模型、存储、日志、支付等
|
||||
│ ├── modules/ # api / console / web 模块入口
|
||||
│ ├── server.ts # 自定义 Node HTTP server 入口
|
||||
│ └── storage/ # 数据库客户端和存储适配
|
||||
├── .env.example # 环境变量模板
|
||||
├── ecosystem.config.cjs # PM2 配置
|
||||
├── package.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 环境要求
|
||||
|
||||
### 基础环境
|
||||
|
||||
- Linux 服务器,推荐 Ubuntu 22.04+ / Debian 12+
|
||||
- Node.js 22 或 24,部署脚本默认安装/使用 Node.js 24 LTS
|
||||
- pnpm 9+
|
||||
- PostgreSQL 14+
|
||||
- PM2
|
||||
- curl、tar、rsync
|
||||
- PostgreSQL 客户端工具:`psql`、`pg_dump`、`pg_restore`
|
||||
|
||||
### 推荐生产目录
|
||||
|
||||
```text
|
||||
/opt/miaojingAI # 项目代码目录
|
||||
/var/lib/miaojingAI/storage # 上传文件、生成结果、本地存储
|
||||
/var/lib/miaojingAI/backups # 数据备份
|
||||
/var/lib/miaojingAI/upgrade # 管理后台升级状态和升级包
|
||||
/var/lib/miaojingAI/logs # 部署日志
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
复制模板:
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
常用变量:
|
||||
|
||||
| 变量 | 说明 |
|
||||
| --- | --- |
|
||||
| `LOCAL_DB_URL` | PostgreSQL 连接地址,例如 `postgresql://postgres:postgres@localhost:5432/miaojing` |
|
||||
| `LOCAL_DB_ANON_KEY` | 本地模式 anon key,可自定义 |
|
||||
| `LOCAL_DB_SERVICE_ROLE_KEY` | 本地模式 service role key,可自定义 |
|
||||
| `DATA_ENCRYPTION_KEY` | 生产环境必填,用于加密 API Key 等敏感数据 |
|
||||
| `JWT_SECRET` | 生产环境必填,用于会话/JWT 签名 |
|
||||
| `GENERATION_INTERNAL_SECRET` | 生成任务内部密钥 |
|
||||
| `LOCAL_STORAGE_DIR` | 本地文件存储路径 |
|
||||
| `BACKUP_DIR` | 备份目录 |
|
||||
| `DEPLOY_RUN_PORT` | 当前运行角色监听端口 |
|
||||
| `MIAOJING_API_PORT` | 后端 API 内部端口 |
|
||||
| `MIAOJING_CONSOLE_PORT` | 管理后台内部端口 |
|
||||
| `NEXT_PUBLIC_APP_URL` | 前端公开访问地址 |
|
||||
| `APP_BASE_URL` | 服务端使用的站点地址 |
|
||||
| `ADMIN_INVITE_CODE` | 管理员邀请码 |
|
||||
| `ENABLE_DANGER_ADMIN_CLEAR_USERS` | 危险清理功能开关,生产环境应保持 `false` |
|
||||
| `UPGRADE_STATE_DIR` | 管理后台升级状态目录,默认基于存储目录推导 |
|
||||
| `UPGRADE_HEALTH_URL` | 冷更新重启后的健康检查 URL |
|
||||
| `UPGRADE_RESTART_COMMAND` | 冷更新使用的重启命令 |
|
||||
|
||||
生成生产密钥示例:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
## 本地开发
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
corepack enable
|
||||
corepack pnpm install --frozen-lockfile
|
||||
```
|
||||
|
||||
### 2. 初始化数据库
|
||||
|
||||
创建数据库:
|
||||
|
||||
```bash
|
||||
createdb miaojing
|
||||
```
|
||||
|
||||
执行初始化脚本:
|
||||
|
||||
```bash
|
||||
psql "postgresql://postgres:postgres@localhost:5432/miaojing" -f scripts/init-database.sql
|
||||
```
|
||||
|
||||
### 3. 配置环境变量
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
至少填写:
|
||||
|
||||
```env
|
||||
LOCAL_DB_URL=postgresql://postgres:postgres@localhost:5432/miaojing
|
||||
LOCAL_DB_ANON_KEY=local-anon-key
|
||||
LOCAL_DB_SERVICE_ROLE_KEY=local-service-role-key
|
||||
LOCAL_STORAGE_DIR=./local-storage
|
||||
DATA_ENCRYPTION_KEY=替换为 openssl rand -hex 32 的结果
|
||||
JWT_SECRET=替换为 openssl rand -hex 32 的结果
|
||||
GENERATION_INTERNAL_SECRET=替换为 openssl rand -hex 32 的结果
|
||||
```
|
||||
|
||||
### 4. 启动开发服务
|
||||
|
||||
```bash
|
||||
corepack pnpm run dev
|
||||
```
|
||||
|
||||
默认开发端口由 `scripts/dev.sh` 控制,可传入端口:
|
||||
|
||||
```bash
|
||||
corepack pnpm run dev -- 5000
|
||||
```
|
||||
|
||||
### 5. 常用开发命令
|
||||
|
||||
```bash
|
||||
# TypeScript 检查
|
||||
corepack pnpm run ts-check
|
||||
|
||||
# ESLint
|
||||
corepack pnpm run lint
|
||||
|
||||
# 完整构建
|
||||
corepack pnpm run build
|
||||
|
||||
# 边界检查
|
||||
corepack pnpm run check:boundaries
|
||||
```
|
||||
|
||||
## 生产部署
|
||||
|
||||
### 方式一:一键部署/升级脚本
|
||||
|
||||
推荐使用:
|
||||
|
||||
```bash
|
||||
bash scripts/deploy-or-upgrade.sh
|
||||
```
|
||||
|
||||
脚本会提示填写:
|
||||
|
||||
- 项目部署目录
|
||||
- 数据存储目录
|
||||
- 前端访问端口
|
||||
- 后端 API 内部端口
|
||||
- 管理后台内部端口
|
||||
- 管理员账号/邮箱/密码
|
||||
- PostgreSQL 连接地址
|
||||
- 正式访问地址
|
||||
|
||||
首次部署时,脚本会:
|
||||
|
||||
1. 检查 tar、rsync、curl、psql、pg_dump 等依赖。
|
||||
2. 自动安装或切换 Node.js 22/24。
|
||||
3. 安装 pnpm 和 PM2。
|
||||
4. 准备 `.env.local`。
|
||||
5. 初始化或检查数据库。
|
||||
6. 构建 Next.js 和 Node server。
|
||||
7. 写入 PM2 配置并启动服务。
|
||||
8. 执行健康检查。
|
||||
|
||||
升级已有部署时,脚本会:
|
||||
|
||||
1. 检测已有部署目录。
|
||||
2. 创建升级前备份。
|
||||
3. 同步新代码。
|
||||
4. 构建并重启服务。
|
||||
5. 执行健康检查。
|
||||
6. 失败时输出备份路径,方便手动恢复。
|
||||
|
||||
### 方式二:手动部署
|
||||
|
||||
安装依赖:
|
||||
|
||||
```bash
|
||||
corepack enable
|
||||
corepack pnpm install --frozen-lockfile --prod=false
|
||||
```
|
||||
|
||||
构建:
|
||||
|
||||
```bash
|
||||
corepack pnpm run build
|
||||
```
|
||||
|
||||
启动 PM2:
|
||||
|
||||
```bash
|
||||
pm2 startOrReload ecosystem.config.cjs --update-env
|
||||
pm2 save
|
||||
```
|
||||
|
||||
健康检查:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:5100/api/health
|
||||
```
|
||||
|
||||
### PM2 服务说明
|
||||
|
||||
`ecosystem.config.cjs` 默认包含三个角色:
|
||||
|
||||
| PM2 名称 | 角色 | 默认端口 |
|
||||
| --- | --- | --- |
|
||||
| `miaojing-api` | 后端 API | `5100` |
|
||||
| `miaojing-web` | 前端站点 | `5000` |
|
||||
| `miaojing-console` | 管理后台 | `5200` |
|
||||
|
||||
在单进程开发部署中,也可以使用类似 `miaojing-dev` 的 PM2 进程直接运行完整服务。
|
||||
|
||||
## 数据库说明
|
||||
|
||||
数据库初始化脚本为:
|
||||
|
||||
```bash
|
||||
scripts/init-database.sql
|
||||
```
|
||||
|
||||
核心表包括:
|
||||
|
||||
| 表 | 说明 |
|
||||
| --- | --- |
|
||||
| `auth.users` | 本地认证用户 |
|
||||
| `profiles` | 用户资料、角色、会员、积分 |
|
||||
| `works` | 创作作品、提示词、结果 URL、尺寸、状态 |
|
||||
| `generation_jobs` | 生成任务队列和进度 |
|
||||
| `credit_transactions` | 积分流水 |
|
||||
| `orders` | 订单和支付状态 |
|
||||
| `user_api_keys` | 用户自定义 API 密钥 |
|
||||
| `system_api_configs` | 系统默认 API 配置 |
|
||||
| `api_providers` | 模型供应商 |
|
||||
| `model_recommendations` | 模型推荐配置 |
|
||||
| `announcements` | 公告 |
|
||||
| `site_config` | 站点配置 |
|
||||
| `platform_logs` | 平台日志 |
|
||||
|
||||
执行数据库补丁:
|
||||
|
||||
```bash
|
||||
corepack pnpm run db:patch
|
||||
```
|
||||
|
||||
## 备份与恢复
|
||||
|
||||
### 创建备份
|
||||
|
||||
```bash
|
||||
corepack pnpm run backup:create
|
||||
```
|
||||
|
||||
备份包包含:
|
||||
|
||||
- PostgreSQL dump:`database.dump`
|
||||
- 本地存储目录:`local-storage`
|
||||
- 环境变量:`.env.local`
|
||||
- `package.json`
|
||||
- 备份 manifest
|
||||
|
||||
默认只保留最近 10 个备份。
|
||||
|
||||
### 查看备份
|
||||
|
||||
```bash
|
||||
corepack pnpm run backup:list
|
||||
```
|
||||
|
||||
### 恢复备份
|
||||
|
||||
```bash
|
||||
corepack pnpm run backup:restore /path/to/miaojing-backup-YYYYMMDD-HHMMSS.tar.gz
|
||||
```
|
||||
|
||||
恢复动作会:
|
||||
|
||||
1. 使用 `pg_restore --clean --if-exists --no-owner` 恢复数据库。
|
||||
2. 替换本地存储目录。
|
||||
3. 恢复 `.env.local`。
|
||||
|
||||
生产环境恢复前建议先停服务,并额外复制当前数据目录。
|
||||
|
||||
## 管理后台在线升级
|
||||
|
||||
管理后台“系统升级”提供两类升级能力:
|
||||
|
||||
### 热更新
|
||||
|
||||
用于不影响运行时代码的小补丁,例如 `public/` 下的静态资源。
|
||||
|
||||
特点:
|
||||
|
||||
- 不重启平台。
|
||||
- 升级前自动创建数据库/存储/环境备份。
|
||||
- 升级前自动创建源码快照。
|
||||
- 失败自动回滚。
|
||||
- 升级界面实时显示中文日志。
|
||||
|
||||
### 冷更新
|
||||
|
||||
用于涉及源码、脚本、依赖、配置的大变更。
|
||||
|
||||
冷更新流程:
|
||||
|
||||
1. 上传升级包。
|
||||
2. 校验升级包路径,拒绝 `.env`、`.git`、`node_modules`、`backups`、`local-storage` 等危险路径。
|
||||
3. 创建数据库、存储、环境配置备份。
|
||||
4. 创建源码快照。
|
||||
5. 应用升级包文件。
|
||||
6. 如依赖变化,执行 `pnpm install --frozen-lockfile --prod=false`。
|
||||
7. 执行 `pnpm run ts-check`。
|
||||
8. 执行 `pnpm run build`。
|
||||
9. 重启平台。
|
||||
10. 调用 `/api/health` 做健康检查。
|
||||
11. 失败时自动恢复源码和数据。
|
||||
|
||||
升级日志会写入磁盘状态文件。即使冷更新过程中平台重启,管理后台恢复后也能续上日志和状态。升级完成后,当前页面刷新或切换页面会默认收起实时日志,但历史升级记录中可随时查看完整升级内容和日志。
|
||||
|
||||
升级包格式:
|
||||
|
||||
```text
|
||||
.tar
|
||||
.tar.gz
|
||||
.tgz
|
||||
```
|
||||
|
||||
推荐升级包结构:
|
||||
|
||||
```text
|
||||
upgrade-package/
|
||||
├── manifest.json
|
||||
├── src/...
|
||||
├── public/...
|
||||
├── scripts/...
|
||||
├── package.json
|
||||
└── pnpm-lock.yaml
|
||||
```
|
||||
|
||||
手动运行升级 runner 示例:
|
||||
|
||||
```bash
|
||||
UPGRADE_STATE_DIR=/var/lib/miaojingAI/upgrade \
|
||||
corepack pnpm run upgrade:run -- \
|
||||
--job-id manual-001 \
|
||||
--mode cold \
|
||||
--package /tmp/upgrade.tgz \
|
||||
--package-name upgrade.tgz \
|
||||
--project /opt/miaojingAI
|
||||
```
|
||||
|
||||
## AI 模型与供应商
|
||||
|
||||
妙境支持多来源模型配置:
|
||||
|
||||
- 系统默认供应商。
|
||||
- 用户自定义 API Key。
|
||||
- New API / OpenAI-compatible API 站点。
|
||||
- 图片模型、视频模型、文本模型分类型管理。
|
||||
|
||||
管理员可在管理后台配置供应商、默认模型、API 地址、模型推荐和启用状态。用户可在前端绑定自己的 API Key,平台会使用加密存储和尾号预览保护密钥。
|
||||
|
||||
## 存储与下载
|
||||
|
||||
生成结果会持久化到本地存储目录或配置的存储服务。推荐生产环境把存储放在项目目录之外:
|
||||
|
||||
```env
|
||||
LOCAL_STORAGE_DIR=/var/lib/miaojingAI/storage
|
||||
```
|
||||
|
||||
下载链路通过 `/api/download` 或本地存储路由读取原始文件,不应对图片进行二次压缩。作品历史、生成结果、画廊、全屏预览和下载应始终使用原始结果链接或持久化文件路径。
|
||||
|
||||
## 健康检查与运维命令
|
||||
|
||||
健康检查:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:5100/api/health
|
||||
```
|
||||
|
||||
查看 PM2:
|
||||
|
||||
```bash
|
||||
pm2 list
|
||||
pm2 logs miaojing-api --lines 100
|
||||
pm2 logs miaojing-web --lines 100
|
||||
pm2 logs miaojing-console --lines 100
|
||||
```
|
||||
|
||||
重启服务:
|
||||
|
||||
```bash
|
||||
corepack pnpm run pm2:restart
|
||||
```
|
||||
|
||||
保存 PM2 开机配置:
|
||||
|
||||
```bash
|
||||
corepack pnpm run pm2:save
|
||||
```
|
||||
|
||||
## 安全建议
|
||||
|
||||
- 生产环境必须设置高强度 `DATA_ENCRYPTION_KEY`、`JWT_SECRET`、`GENERATION_INTERNAL_SECRET`。
|
||||
- `.env.local` 不应提交到 git。
|
||||
- 生产环境不要开启 `ENABLE_DANGER_ADMIN_CLEAR_USERS`。
|
||||
- `LOCAL_STORAGE_DIR`、`BACKUP_DIR`、`UPGRADE_STATE_DIR` 建议放在项目目录外。
|
||||
- 升级包不要包含 `.env`、数据库 dump、用户上传文件或备份目录。
|
||||
- 对外暴露时建议在 Nginx/Caddy 后面启用 HTTPS。
|
||||
- 管理后台账号应使用强密码,并限制管理员数量。
|
||||
- 定期执行 `backup:create` 并把备份复制到异地。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. 构建时提示缺少 devDependencies
|
||||
|
||||
构建需要 devDependencies。部署或 CI 中不要只安装生产依赖,推荐:
|
||||
|
||||
```bash
|
||||
corepack pnpm install --frozen-lockfile --prod=false
|
||||
```
|
||||
|
||||
### 2. 管理后台修改后刷新丢失
|
||||
|
||||
优先检查对应接口是否成功写入数据库,再检查 `.env.local` 是否连接到了正确数据库。不要只看前端本地状态。
|
||||
|
||||
### 3. 图片下载尺寸不符合预期
|
||||
|
||||
排查顺序:
|
||||
|
||||
1. 检查上游请求参数是否为目标分辨率。
|
||||
2. 检查生成任务结果中保存的原始文件尺寸。
|
||||
3. 检查 `/api/download` 是否直接返回原始文件。
|
||||
4. 检查前端是否使用缩略图地址下载。
|
||||
|
||||
### 4. 冷更新后页面仍是旧版本
|
||||
|
||||
检查:
|
||||
|
||||
```bash
|
||||
pm2 list
|
||||
pm2 describe miaojing-api
|
||||
pm2 describe miaojing-web
|
||||
pm2 describe miaojing-console
|
||||
```
|
||||
|
||||
确认 PM2 的 `cwd` 是当前部署目录,并确认构建产物来自同一目录。
|
||||
|
||||
### 5. 数据恢复后作品图片丢失
|
||||
|
||||
确认备份包内是否包含 `local-storage`,并确认 `.env.local` 中的 `LOCAL_STORAGE_DIR` 指向恢复后的存储目录。
|
||||
|
||||
## 版本管理
|
||||
|
||||
推荐使用 `main` 作为稳定分支:
|
||||
|
||||
```bash
|
||||
git clone http://172.16.10.127:3000/fenglee/miaojingAI.git
|
||||
git checkout main
|
||||
```
|
||||
|
||||
提交前建议执行:
|
||||
|
||||
```bash
|
||||
corepack pnpm run ts-check
|
||||
corepack pnpm run build
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
当前项目为私有项目,未声明开源许可证。未经授权请勿公开分发、复制或商业使用。
|
||||
|
||||
36
docs/codex-miaojing/README.md
Normal file
36
docs/codex-miaojing/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# MiaoJing Codex Developer Index
|
||||
|
||||
Last source audit: 2026-05-12, based on git commit `8ee86a9`.
|
||||
|
||||
This folder contains the permanent Codex routing documents for MiaoJing development. They are intentionally source-control tracked so every future agent can start from the same map.
|
||||
|
||||
## Documents
|
||||
|
||||
| Document | Purpose |
|
||||
| --- | --- |
|
||||
| `feature-code-index.md` | Feature-to-file map for UI, workflows, stores, services, scripts, and database code. Use this first for implementation work. |
|
||||
| `bug-location-guide.md` | Symptom-to-code diagnostic manual. Use this first for bug reports and regressions. |
|
||||
| `api-reference.md` | Route Handler reference for `/api/**`: method, auth, payload, response, storage side effects. |
|
||||
| `architecture.md` | System architecture, runtime boundaries, data flow, persistence, deployment, and risk points. |
|
||||
| `custom-integrations.md` | Named rules for non-generic integrations such as 元界, mozheAPI, and 智能配置 API. Use this whenever a request includes a custom integration keyword. |
|
||||
|
||||
## Required Workflow For Codex Agents
|
||||
|
||||
1. Read `AGENTS.md` and `CODEX_MIAOJING_MEMORY.md` at repo root.
|
||||
2. Read the document in this folder that matches the task.
|
||||
3. If the request includes a custom integration keyword such as `元界`, `mozheAPI`, or `智能配置 API`, read `custom-integrations.md` and search long-term memory for that keyword before editing.
|
||||
4. Use direct file reads or `rg` only after choosing likely files from the index.
|
||||
5. For every adjustment, modification, or bug fix, check whether these docs need to change.
|
||||
6. Update the corresponding doc in the same commit whenever code location, diagnosis path, API behavior, architecture, deployment, or verification knowledge changes.
|
||||
|
||||
## Quick Commands
|
||||
|
||||
```bash
|
||||
pnpm run ts-check
|
||||
pnpm run build
|
||||
pnpm run lint
|
||||
pnpm run check:boundaries
|
||||
pnpm run pm2:restart
|
||||
```
|
||||
|
||||
Use `pnpm`; the project rejects other package managers through `preinstall`.
|
||||
193
docs/codex-miaojing/api-reference.md
Normal file
193
docs/codex-miaojing/api-reference.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# API Reference
|
||||
|
||||
Last source audit: 2026-05-12, based on git commit `8ee86a9`.
|
||||
|
||||
All routes are Next.js App Router route handlers under `src/app/api/**/route.ts`.
|
||||
|
||||
## Auth Conventions
|
||||
|
||||
- Bearer token: `Authorization: Bearer <token>`.
|
||||
- Token creation and verification: `src/lib/session-auth.ts`.
|
||||
- Authenticated user helpers:
|
||||
- `getAuthenticatedUser(request)`
|
||||
- `getAuthenticatedUserId(request)`
|
||||
- `requireAdminUser(request)`
|
||||
- `requireAdmin(request)` from `src/lib/admin-auth.ts`
|
||||
- Internal generation requests use header `x-miaojing-generation-internal`, generated by `src/lib/server-api-config.ts`.
|
||||
|
||||
## Response Conventions
|
||||
|
||||
- Success responses are JSON unless the route explicitly streams/downloads a file.
|
||||
- Error responses usually use `{ "error": "message" }` with an HTTP status.
|
||||
- Admin-only routes return 401 when not logged in and 403 when role is not `admin` or `enterprise_admin`.
|
||||
|
||||
## Public/System Routes
|
||||
|
||||
| Method | Path | Auth | Source | Purpose |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| GET | `/api/health` | Public | `src/app/api/health/route.ts` | Health check. |
|
||||
| GET | `/api/site-stats` | Public | `src/app/api/site-stats/route.ts` | Read total visits. |
|
||||
| POST | `/api/site-stats` | Public | `src/app/api/site-stats/route.ts` | Increment visits. |
|
||||
| GET | `/api/site-config` | Public | `src/app/api/site-config/route.ts` | Read site config, policy Markdown, filing, membership flag, and redeem-code mall URL. |
|
||||
| PUT | `/api/site-config` | Admin | `src/app/api/site-config/route.ts` | Update site config, redeem-code mall URL, and upload logo/favicon data URLs. |
|
||||
| GET | `/api/announcements` | Public | `src/app/api/announcements/route.ts` | List announcements. |
|
||||
| POST | `/api/announcements` | Admin | `src/app/api/announcements/route.ts` | Create announcement. Body: `title`, `content`, `startDate`, `endDate`, `enabled`. |
|
||||
| PUT | `/api/announcements` | Admin | `src/app/api/announcements/route.ts` | Update announcement. Body includes `id` and changed fields. |
|
||||
| DELETE | `/api/announcements?id=...` | Admin | `src/app/api/announcements/route.ts` | Delete announcement. |
|
||||
| GET | `/api/model-config` | Public, optional bearer token | `src/app/api/model-config/route.ts` | Read managed provider/model configuration for clients. System APIs are filtered to active platform-default models allowed for the current user's membership tier; anonymous users are treated as `free`. |
|
||||
| GET | `/api/style-presets` | Public | `src/app/api/style-presets/route.ts` | Returns active image style presets from `image_style_presets`, sorted by usage count. |
|
||||
| GET | `/api/local-storage/[...path]` | Public by URL | `src/app/api/local-storage/[...path]/route.ts` | Serve storage object by key. Generated work images/videos and their generated/gallery/work thumbnails are watermarked server-side before display, using `src/lib/media-watermark*.ts`; object-backed generated originals must not redirect raw to object storage. When a generated image original has an existing local `works.thumbnail_url`, display requests may 302 to that thumbnail first and then watermark the thumbnail, keeping page loads fast while preserving the stable original URL for fullscreen/download actions. Thumbnail keys under `thumbnails/...` are served from local disk with long immutable browser cache headers; non-generated object-backed originals can return a short-lived signed object-storage redirect when configured. Video frame thumbnails are WEBP files, while fallback SVG video thumbnails under `thumbnails/.../*.svg` may be rasterized when watermarked. The public URL shape remains stable across migration. |
|
||||
| GET | `/api/download?url=...&filename=...` | Public by URL, optional bearer/downloadToken | `src/app/api/download/route.ts` | Download proxy for remote, same-origin, and `/api/local-storage/*` URLs, including object-backed storage keys. Generated local-storage media returns watermarked bytes by default; raw generated media is allowed only after the route authenticates an admin role or a user whose `profiles.watermark_disabled=true`. Add `disposition=inline` or `inline=1` when the proxy is used as an image/video preview source instead of a forced download. |
|
||||
|
||||
## Auth And Account Routes
|
||||
|
||||
| Method | Path | Auth | Source | Request | Response |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| POST | `/api/auth/login` | Public | `src/app/api/auth/login/route.ts` | `account` or `email` or `phone`, `password`, optional `adminOnly` | User profile data and session token. |
|
||||
| POST | `/api/auth/register` | Public | `src/app/api/auth/register/route.ts` | `email`, `password`, `nickname` as login username, `phone`, `inviteCode`, `emailCode`, `acceptedTerms` | Created profile/session flow. New normal users receive a random Chinese display nickname and default 3D cartoon avatar. |
|
||||
| GET | `/api/auth/admin-exists` | Public | `src/app/api/auth/admin-exists/route.ts` | None | Whether an admin profile exists. |
|
||||
| POST | `/api/auth/test-api` | Public/auth context depends caller | `src/app/api/auth/test-api/route.ts` | Provider/API config | Tests upstream API. |
|
||||
| POST | `/api/auth/fetch-models` | Public/auth context depends caller | `src/app/api/auth/fetch-models/route.ts` | Endpoint/API key | Fetch model list from provider. |
|
||||
| GET | `/api/profile` | User | `src/app/api/profile/route.ts` | None | `{ profile }`, including `watermark_disabled` for the user's no-watermark download preference. |
|
||||
| PUT | `/api/profile` | User | `src/app/api/profile/route.ts` | `email`, `username`, `displayNickname`/`nickname`, `phone`, `avatarUrl`, optional `watermarkDisabled`, password fields | Updated profile. `username` remains usable for login; display nickname is returned as `nickname` for UI and gallery display. `watermarkDisabled=true` is accepted only for members/admins as a self-service preference; free users cannot self-enable it, and an accidental/old-client `false` payload must not clear an admin-granted per-user authorization. The flag controls download-original entitlement, not platform display. |
|
||||
| PUT | `/api/profile/theme` | User | `src/app/api/profile/theme/route.ts` | `theme` | `{ success, preferred_theme }`. |
|
||||
| GET | `/api/credit-transactions` | User | `src/app/api/credit-transactions/route.ts` | Optional `limit` | Latest user credit transaction records as `{ records }`, used by the profile credits tab. |
|
||||
| GET | `/api/invitations/me` | User | `src/app/api/invitations/me/route.ts` | None | Returns the current user's stable `inviteCode` plus invitee records for the profile credits tab. Creates `profiles.invite_code` if missing. |
|
||||
| POST | `/api/redeem-codes/redeem` | User | `src/app/api/redeem-codes/redeem/route.ts` | `{ code }` | Atomically redeems one active unused redeem code. Credit codes increment `profiles.credits_balance`, mark the code used, write a `credit_transactions` row of type `redeem`, and return the new credit balance. Membership codes update `profiles.membership_tier`, extend `membership_expires_at` by the configured day/month/year duration, mark the code used, and return the membership result. |
|
||||
| GET | `/api/user-api-keys` | User | `src/app/api/user-api-keys/route.ts` | None | `{ keys }`, with previews only. Rows may include `manifestPath`, which points to that key/model's independent API Manifest file. |
|
||||
| POST | `/api/user-api-keys` | User | `src/app/api/user-api-keys/route.ts` | Single key or `{ keys: [...] }`; fields `provider`, `supplierName`, `apiUrl`, `modelName`, `apiKey`, `type`, `isActive`, optional `manifestPath` | Saved keys. Updating an imported key preserves its existing `manifest_path` when omitted. |
|
||||
| PUT | `/api/user-api-keys` | User | `src/app/api/user-api-keys/route.ts` | Same as POST | Saved keys. |
|
||||
| DELETE | `/api/user-api-keys?id=...` | User | `src/app/api/user-api-keys/route.ts` | Query `id` | `{ success: true }`. |
|
||||
| POST | `/api/user-api-keys/smart-import` | User | `src/app/api/user-api-keys/smart-import/route.ts` | `{ configText }`, containing either `{ customProviders, profiles }` or a single provider Manifest | Creates one `user_api_keys` row per profile/model and writes a separate `user-api-manifests/<userId>/<keyId>.json` file. The imported row uses the Manifest provider name as the editable provider/supplier display value and resolves `apiUrl` from `profile.baseUrl + submit.path` for synchronous endpoints. The route rejects configs that do not contain enough data to resolve a relay API request URL. Optional `profile.capabilities` is returned to the client and filters selected-model image options. API Key is intentionally left blank with preview `待填写` until the user edits the row. |
|
||||
|
||||
## Email Routes
|
||||
|
||||
| Method | Path | Auth | Source | Purpose |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| POST | `/api/email/send-register-code` | Public | `src/app/api/email/send-register-code/route.ts` | Send registration verification code. |
|
||||
| POST | `/api/email/send-reset-code` | Public | `src/app/api/email/send-reset-code/route.ts` | Send password reset code. |
|
||||
| POST | `/api/email/reset-password` | Public | `src/app/api/email/reset-password/route.ts` | Reset password with verification code. |
|
||||
| POST | `/api/email/send-profile-code` | User | `src/app/api/email/send-profile-code/route.ts` | Send profile email-binding code. |
|
||||
| POST | `/api/email/verify-profile` | User | `src/app/api/email/verify-profile/route.ts` | Verify profile email code. |
|
||||
| POST | `/api/email/send-notification` | Internal/admin-oriented | `src/app/api/email/send-notification/route.ts` | Send notification email. |
|
||||
|
||||
All email sends route through `src/lib/email-service.ts`, which renders HTML and plain-text multipart messages and sends them via the configured SMTP server. Keep MIME body encoding standards-compliant: base64 body parts must be folded to safe line lengths and both text/plain and text/html parts should be non-empty, otherwise SMTP can accept the message while some mailbox clients render a blank email.
|
||||
|
||||
## Generation Routes
|
||||
|
||||
| Method | Path | Auth | Source | Request | Response/Side Effects |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| POST | `/api/generation-jobs` | User | `src/app/api/generation-jobs/route.ts` | `{ type: "image"|"video"|"reverse-prompt", payload: {...} }` | Inserts `generation_jobs`, starts worker, increments selected image `styleLabel` usage, returns `202` with `jobId`, `status`, `estimateSeconds`, `eta`. System-default image/video jobs preflight the selected `system_api_configs` price plus existing queued/running system-default jobs for the same user and return `402` when the available balance is insufficient. Reverse-prompt now runs through the same job queue but does not deduct credits. Duplicate active jobs are deduped semantically while ignoring top-level `clientRequestId`, but users may submit a different task while another task is running. |
|
||||
| GET | `/api/generation-jobs/[id]` | User/admin | `src/app/api/generation-jobs/[id]/route.ts` | Path UUID | Job status/result/error/progress. Owner or admin only. Status may be `queued`, `running`, `succeeded`, `failed`, or `cancelled`. The create pages use this endpoint to resume jobs after refresh, auth change, or a new tab; client-side pending job ids also use it to recover terminal results/errors that happened while the browser was closed. |
|
||||
| PATCH | `/api/generation-jobs/[id]` | User/admin | `src/app/api/generation-jobs/[id]/route.ts` | Path UUID plus `{ action: "cancel" }` | Owner or admin can cancel a `queued`/`running` job. The route marks the row `cancelled`, clears payload/result, writes a cancellation progress payload and `finished_at`, and returns the updated job row with `jobId`; if the job already settled it returns the existing row. Workers re-check that the job is still `running` before charging credits, persisting history, or writing success/failure so late upstream responses cannot resurrect a cancelled job. |
|
||||
|
||||
## Admin Invitation Routes
|
||||
|
||||
| Method | Path | Auth | Source | Request | Response |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| GET | `/api/admin/invitations` | Admin | `src/app/api/admin/invitations/route.ts` | Optional `search`, `page`, `pageSize` | Long-term invitation records joining inviter and invitee profile details. |
|
||||
| POST | `/api/generate/image` | Trusted internal or resolved user/system API context | `src/app/api/generate/image/route.ts` | Image generation payload; supports prompt, negative prompt, reference images (`image` plus `extraImages`), optional `referenceImageAnnotations`, model/system/custom API config, aspect/size/resolution/count/quality. | Calls SDK or OpenAI/New API-compatible endpoint, persists original images to object storage and local WEBP thumbnails to `thumbnails/generated/images`, returns `images` original URLs plus `thumbnails`, `thumbnailUrls`, and `dimensions` `{ [originalUrl]: { width, height } }`, updates job progress when headers include job ID. When `referenceImageAnnotations` is present, `src/lib/reference-image-prompt.ts` adds a model-readable `@参考图N` mapping block to the upstream prompt before style prompts and Manifest execution. |
|
||||
| POST | `/api/generate/video` | Trusted internal or resolved user/system API context | `src/app/api/generate/video/route.ts` | Video generation payload; supports prompt, reference images (`image`, `images`, `extraImages`), optional `referenceImageAnnotations`, model/system/custom API config, ratio/duration/fps-like params. | Calls SDK or Manifest/custom endpoint, polls async Manifest providers such as 元界 media tasks, then persists generated video media as object-backed `/api/local-storage/generated/videos/...` URLs when object storage is configured. When `referenceImageAnnotations` is present, `src/lib/reference-image-prompt.ts` adds a model-readable `@参考图N` mapping block to the upstream prompt before Manifest/custom/SDK execution. |
|
||||
| POST | `/api/generate/reverse-prompt` | Uses supplied/resolved API config; Bearer token required when resolving user custom or gated system API IDs | `src/app/api/generate/reverse-prompt/route.ts` | `image`, `outputMode`, `language`, optional `customApiConfig`/system/custom IDs | Returns prompt fields and may persist reference image. The create-panel caller must forward the stored access token in `Authorization` because server-side API resolution cannot read browser localStorage. When the input image is a data URL, the route persists it under `reverse-prompt/reference-images/...` and sends the public `/api/local-storage/...` URL upstream when available so the multimodal model sees a normal fetchable image URL instead of a raw upload blob. This route sends a multimodal `chat/completions` payload with `image_url`, so 524 errors here reflect multimodal upstream latency/capability rather than image-generation sync behavior. |
|
||||
| POST | `/api/generate/suggest-prompt` | Uses supplied/resolved API config | `src/app/api/generate/suggest-prompt/route.ts` | `prompt`, optional `customApiConfig`, `systemPrefix` | Returns optimized `prompt` and optional `negativePrompt`. This route also uses a multimodal `chat/completions` path, so 524 should be interpreted as a multimodal upstream timeout. |
|
||||
|
||||
Important generation helpers:
|
||||
|
||||
- `src/lib/generation-job-client.ts`: frontend create/poll helper.
|
||||
- `src/lib/generation-job-worker.ts`: queued job processor.
|
||||
- `src/lib/generation-job-runner.ts`: internal call into generate routes.
|
||||
- `src/lib/generation-job-estimates.ts`: ETA/progress schema and estimates.
|
||||
- `src/lib/server-api-config.ts`: resolves `customApiKeyId` and `systemApiId`.
|
||||
- `src/lib/custom-api-fetch.ts`: upstream request/retry/error parsing.
|
||||
- `src/lib/user-api-manifest.ts` and `src/lib/user-api-manifest-executor.ts`: parse/import per-key user and system API Manifests and execute the selected model's JSON/multipart/poll mapping before falling back to legacy custom API compatibility. User Manifest files are never merged per user; the chosen `customApiKeyId` controls the exact `manifest_path`. Admin system imports use separate `system-api-manifests/<systemApiId>.json` files and generation resolves them from the selected `systemApiId`.
|
||||
- `pnpm run migration:check` runs `scripts/migration-integrity-check.mjs` as a read-only production migration gate. It checks auth/profile parity, password hash presence, user-owned table references, API key preview metadata, same-user work dedupe state, required schema columns, and `/api/local-storage/*` URL availability without printing secret values. The default probe base URL is the production web port `http://127.0.0.1:8000`; override with `MIGRATION_CHECK_BASE_URL` when checking another runtime. Storage URL probes are bounded by timeout/concurrency helpers so one slow media URL is counted as a blocker instead of crashing the whole check.
|
||||
- `pnpm run rainyun:ros-prepare -- --create` runs `scripts/rainyun-ros-prepare.mjs` against Rainyun's ROS control-plane API (`POST https://api.v2.rainyun.com/product/ros/bucket`, body `{ bucket_name, instance_id }`, `x-api-key` header). It writes standard S3-compatible `OBJECT_STORAGE_*` values to `.env.rainyun-object.generated`; secrets are redacted from console output.
|
||||
|
||||
## Creation History And Gallery
|
||||
|
||||
| Method | Path | Auth | Source | Request | Response/Side Effects |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| GET | `/api/creation-history` | User | `src/app/api/creation-history/route.ts` | Optional query `mode=text2img|img2img|text2video|img2video|reverse-prompt`, `limit` up to 300 | Latest completed user works as `records`, including optional `thumbnailUrl`, `width`, `height`, `referenceImages`, `referenceImageThumbnails`, `published`, and `publishedAt`. Without query params it returns the latest 300 for the profile history tab. Create panels should pass their current mode plus a small limit so page navigation does not compete with multi-MB full-history responses. The mode filter checks stored work type, explicit mode params, and legacy reference-image inference. Missing image thumbnails and stale video thumbnails are lazily generated into local `thumbnails/works`. Video thumbnails prefer `ffmpeg-static` WEBP frame extraction and fall back to SVG only if extraction fails. |
|
||||
| POST | `/api/creation-history` | User | `src/app/api/creation-history/route.ts` | Single record or `{ records: [...] }`; image records may include `thumbnailUrl`, `width`, `height`, `referenceImage`, and `referenceImages` | Inserts/deduplicates completed works into `works`, storing `thumbnail_url` and dimensions when supplied or generating thumbnails for image works and video works without thumbnails. Data URL or remote reference images are persisted into stable `/api/local-storage/works/references/...` URLs with local thumbnails under `thumbnails/works/references`; the route writes `params.referenceImages` and `params.referenceImageThumbnails`, and can patch an existing same-URL row whose first insert came from the background worker before frontend reference metadata arrived. Imported/local records are only inserted as public when both `published` and `publishedAt` are present, so stale local published flags do not create or block gallery state. |
|
||||
| DELETE | `/api/creation-history?id=...` | User | `src/app/api/creation-history/route.ts` | Optional `id`; omit to delete all user history | Deletes user's private history rows by `id` and `user_id`. Creation detail deletion waits for this server delete before refreshing local history. |
|
||||
| GET | `/api/gallery` | Public | `src/app/api/gallery/route.ts`, `src/lib/gallery-response.ts` | Query `type=image|video`, `category=text2img|img2img|text2video|img2video`, `limit`, `offset`, `sort=newest|popular`, `q`/`search` | Public completed works with `thumbnailUrl`, `total`, `nextOffset`, and `hasMore`; missing public image thumbnails and stale video thumbnails are lazily generated into local `thumbnails/gallery`. Video thumbnails prefer `ffmpeg-static` WEBP frame extraction and fall back to SVG only if extraction fails; SVG fallback profiles such as `video-svg-v1` and `video-fallback-svg-v2` stay replaceable and do not count as current. Public list rows filter `data:` and oversized `publisherAvatarUrl` values to keep responses and browser caches small. Responses allow short private browser caching while the gallery page also keeps a bounded localStorage cache for instant first paint. |
|
||||
| DELETE | `/api/gallery` | Admin | `src/app/api/gallery/route.ts` | Query `id` or body `{ ids: [...] }` | Unpublishes up to 100 works by setting `is_public=false`. |
|
||||
| POST | `/api/gallery/publish` | User | `src/app/api/gallery/publish/route.ts`, `src/lib/gallery-publish-media.ts` | Work metadata, `resultUrl`, optional thumbnail/reference/model fields, optional `referenceImage`/`referenceImages` plus matching `params.referenceImage`/`params.referenceImages` | Reuses stable `/api/local-storage/...` image and video originals instead of synchronously copying object-backed generated media during share. External image/video URLs are still copied into object-backed gallery storage before insertion. For image-to-image and image-to-video shares, data URL or remote reference images are persisted under stable `gallery/references/...` local-storage URLs and stored in `works.params.referenceImage/referenceImages`; stable `/api/local-storage/...` references are reused as-is. The response includes `referenceImages` so clients can keep local published state aligned. Existing image thumbnails are reused; gallery/history reads can lazily backfill missing or stale thumbnails. Video publish thumbnails prefer WEBP frame extraction through `ffmpeg-static`; a client-provided thumbnail is copied only after frame extraction fails. If media preparation fails, the route returns an error instead of inserting a public row that `/api/gallery` will filter out. Clients should show success and mark local history as shared only after this route returns 2xx. |
|
||||
| GET | `/api/admin/gallery/works` | Admin | `src/app/api/admin/gallery/works/route.ts` | Query `q`, `type=all|image|video|text2img|img2img|text2video|img2video`, `page`, `pageSize`, legacy `limit`, `offset`, `sort` | Admin gallery-management list of public completed works with author email/nickname, prompt, media URL, thumbnail, `total`, `page`, `pageSize`, `totalPages`, legacy `nextOffset`, and `hasMore`. |
|
||||
| PUT | `/api/admin/gallery/prompt` | Admin | `src/app/api/admin/gallery/prompt/route.ts`, `src/lib/admin-gallery-prompt-service.ts` | `{ workId, prompt, emailSubject, emailBody, reasonKey }` | Sends the author notification email first, then updates `works.prompt` only after email success, and writes a platform log without storing full prompt text. Missing/invalid author email, unchanged prompt, non-public work, or email failure blocks the update. |
|
||||
|
||||
## Admin Routes
|
||||
|
||||
All routes in this section require admin unless noted.
|
||||
|
||||
| Method | Path | Source | Purpose |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/api/admin/dashboard` | `src/app/api/admin/dashboard/route.ts` | Console dashboard aggregate stats and recent activity. |
|
||||
| GET | `/api/admin/stats` | `src/app/api/admin/stats/route.ts` | Additional admin stats. |
|
||||
| GET/PUT/DELETE | `/api/admin/users` | `src/app/api/admin/users/route.ts` | List/update/delete users. GET returns `watermark_disabled`; PUT accepts profile fields, `watermarkDisabled`/`watermark_disabled`, and `newPassword`. `newPassword` is admin-only and upserts `auth.users.password_hash` with PostgreSQL `crypt(..., gen_salt('bf'))` so the user can immediately log in with the reset password. |
|
||||
| POST | `/api/admin/clear-users` | `src/app/api/admin/clear-users/route.ts` | Dangerous user cleanup controlled by env switch. |
|
||||
| GET/POST/PUT | `/api/admin/orders` | `src/app/api/admin/orders/route.ts` | List/create/update orders. |
|
||||
| GET/POST/PUT/DELETE | `/api/admin/redeem-codes` | `src/app/api/admin/redeem-codes/route.ts` | Admin redeem-code management. GET lists codes by status/search, POST generates 1-500 unique single-use credit or membership codes, PUT enables/disables unused codes, and DELETE removes unused codes. Membership-code payloads include `membershipTier`, `membershipDurationValue`, and `membershipDurationUnit` (`day`, `month`, `year`). The redeem-code management UI also saves the shared external mall URL through `/api/site-config` as `redeemCodeMallUrl`. |
|
||||
| GET/PUT | `/api/admin/payment-methods` | `src/app/api/admin/payment-methods/route.ts` | Payment config. |
|
||||
| GET/POST/PUT/DELETE | `/api/admin/providers` | `src/app/api/admin/providers/route.ts` | Provider registry CRUD. All methods require admin bearer auth. |
|
||||
| GET/POST/PUT/DELETE | `/api/admin/system-apis` | `src/app/api/admin/system-apis/route.ts` | System API config CRUD with encrypted keys, pricing metadata, platform-default visibility, allowed membership tiers, default-model polling fields `pollingMode`/`pollingOrder`, and video entry usage modes `videoUsageModes`. Successful system-default image/video generation jobs charge user credits from this selected row's pricing through `src/lib/generation-credit-service.ts`; queued/running system-default jobs are counted during new-job balance preflight, and failed jobs do not write consume transactions. |
|
||||
| POST | `/api/admin/system-apis/smart-import` | `src/app/api/admin/system-apis/smart-import/route.ts` | Admin-only intelligent Manifest import. Creates one global `system_api_configs` row per imported profile/model, resolves the visible API request URL from the Manifest profile/provider, rejects configs without a resolvable relay API request URL, writes `system-api-manifests/<systemApiId>.json`, and leaves API Key empty for admin review. Optional `profile.capabilities` is returned through system model config for selected-model image option filtering. Imported rows also carry platform-default visibility, membership-tier allowlist, and default polling fields. |
|
||||
| GET | `/api/admin/system-apis/agnes-capabilities` | `src/app/api/admin/system-apis/agnes-capabilities/route.ts` | Admin-only Agnes AI built-in template preview for the system-default-model flow. Returns `capabilitiesText`, image templates from `src/lib/agnes-model-templates.ts`, video templates, and text templates. It covers Agnes Image 2.1 Flash, Agnes Image 2.0 Flash, Agnes Video V2.0, Agnes 2.0 Flash, and Agnes 1.5 Flash without calling upstream. |
|
||||
| POST | `/api/admin/system-apis/agnes-capabilities` | `src/app/api/admin/system-apis/agnes-capabilities/route.ts` | Admin-only Agnes AI built-in installer. `{ syncImageModels, syncVideoModels, syncTextModels }` resets only matching `provider = 'Agnes AI'` rows by media type. Rows are installed as inactive free system default templates (`billing_mode = free`, 0 credits) with empty API Key. Image/video rows also get independent `system-api-manifests/<systemApiId>.json` files. Text rows use OpenAI-compatible `chat/completions` directly and do not need a Manifest. Admins must edit rows to fill the API Key and enable them before users can generate. |
|
||||
| GET | `/api/admin/system-apis/yuanjie-capabilities` | `src/app/api/admin/system-apis/yuanjie-capabilities/route.ts` | Admin-only 元界 AI built-in image/video template preview retained for the system-default-model template path, not for the `智能配置 API` UI. Returns `capabilitiesText`, image templates from `src/lib/yuanjie-image-model-templates.ts`, and video templates from `src/lib/yuanjie-video-model-templates.ts`; it does not call 元界 `/v1/skills` or `/v1/skills/guide`. |
|
||||
| POST | `/api/admin/system-apis/yuanjie-capabilities` | `src/app/api/admin/system-apis/yuanjie-capabilities/route.ts` | Admin-only 元界 AI built-in installer retained for system-default-model template management, not for the generic smart import UI. `{ syncModels: true }` resets only `provider = '元界 AI' AND type = 'image'` rows and installs 17 inactive image rows. `{ syncVideoModels: true }` resets only `provider = '元界 AI' AND type = 'video'` rows and installs inactive video rows with `videoUsageModes`. Rows have no API Key by default; admins must edit each model to set Key, pricing, visibility/member scope, polling, usage mode, and enable it before users can generate. |
|
||||
| GET/POST | `/api/admin/system-apis/yuanjie-pricing` | `src/app/api/admin/system-apis/yuanjie-pricing/route.ts` | Admin-only manual 元界 AI pricing sync. GET previews built-in pricing targets from `src/lib/yuanjie-pricing-sync.ts`. POST updates only existing 元界 image/video system API rows by `model_name`, matching compatible provider spellings such as `元界 AI`/`元界AI` plus `yuanjie-*` model groups, and synchronizes `billing_mode` plus the 元界计费同步 `price_note` while preserving administrator-entered numeric prices and non-元界 providers such as mozheAPI. Optional body `{ type: "image"|"video" }` limits the sync. |
|
||||
| GET/POST/PUT/DELETE | `/api/admin/model-recommendations` | `src/app/api/admin/model-recommendations/route.ts` | Managed model recommendations. All methods require admin bearer auth. |
|
||||
| GET/DELETE | `/api/admin/generation-jobs` | `src/app/api/admin/generation-jobs/route.ts` | Admin task listing and deletion. |
|
||||
| GET | `/api/admin/gallery/works` | `src/app/api/admin/gallery/works/route.ts` | Admin public gallery work listing for prompt moderation. |
|
||||
| PUT | `/api/admin/gallery/prompt` | `src/app/api/admin/gallery/prompt/route.ts` | Admin prompt moderation endpoint. Requires email notification success before updating `works.prompt`. |
|
||||
| GET | `/api/admin/data-export` | `src/app/api/admin/data-export/route.ts` | Export business data plus `_media` entries for storage assets referenced by works and site config. `_meta` reports media count/bytes/missing/skipped. |
|
||||
| POST | `/api/admin/data-import` | `src/app/api/admin/data-import/route.ts` | Import business data. Accepts optional `_media`; restores media to sha-based keys, remaps users/custom API keys/works, imports in a transaction with per-row savepoints, preserves password hashes/encrypted secrets, and dedupes works by URL/source URL/media SHA only inside the same `user_id`. |
|
||||
| GET/PUT/POST | `/api/admin/email-settings` | `src/app/api/admin/email-settings/route.ts` | Read/update/test email settings. |
|
||||
| GET | `/api/admin/email-recipients` | `src/app/api/admin/email-recipients/route.ts` | Email recipient list. |
|
||||
| POST | `/api/admin/send-email` | `src/app/api/admin/send-email/route.ts` | Send admin email. |
|
||||
| GET/PUT | `/api/admin/logs` | `src/app/api/admin/logs/route.ts` | Admin system log listing and retention settings. GET supports type, level, user, keyword, startTime, endTime, page, and pageSize filters and returns logs plus retention settings. PUT updates the retention days, cleans expired rows, and writes a platform log. |
|
||||
| GET/POST | `/api/admin/upgrade` | `src/app/api/admin/upgrade/route.ts` | Read upgrade runtime/status/history; upload/start dry run or upgrade package. New deployable work should be classified against this path: static/public asset-only payloads may be hot-update candidates after preflight, while source/API/server/dependency/schema/env/runtime/script changes are cold-update candidates. |
|
||||
|
||||
## Persistence Tables Mentioned By APIs
|
||||
|
||||
Primary SQL tables touched directly in API routes include:
|
||||
|
||||
- `profiles`
|
||||
- `auth.users`
|
||||
- `works`
|
||||
- `credit_transactions`
|
||||
- `redeem_codes`
|
||||
- `orders`
|
||||
- `user_api_keys`
|
||||
- `site_config`
|
||||
- `site_stats`
|
||||
- `announcements`
|
||||
- `generation_jobs`
|
||||
- `api_providers`
|
||||
- `system_api_configs`
|
||||
- `platform_logs`
|
||||
|
||||
`src/storage/database/shared/schema.ts` contains a Drizzle snapshot of core tables, while several runtime APIs add compatibility columns/tables with `CREATE TABLE IF NOT EXISTS` or `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`.
|
||||
|
||||
`user_api_keys.manifest_path` is an optional local-storage key for an imported JSON Manifest. The storage convention is `user-api-manifests/<userId>/<keyId>.json`, so even the same user can have multiple isolated request configs. Generation must load the manifest linked to the selected model/key row instead of looking up a user-level shared config.
|
||||
|
||||
`system_api_configs.polling_mode` and `system_api_configs.polling_order` control admin default-model supplier fallback for image generation. `system_api_configs.video_usage_modes` controls whether a video model appears in 文生视频, 图生视频, or both creation entries. `/api/model-config` deduplicates default system rows by media type plus admin display name (`system_api_configs.name`) for clients, while `/api/generate/image` expands the selected row back into allowed supplier candidates with the same media type and display name. System image candidates retry stream-timeout 524 responses once with `stream:false`, and shared custom API transport retries 502/503/504 once before surfacing a concise gateway error. `model_name` stays provider-specific and is used as the upstream request model value.
|
||||
|
||||
`site_config.image_composition_skill_enabled` controls the built-in 100 Layout Compositions image composition skill. `/api/site-config` returns and updates it as `imageCompositionSkillEnabled`, and `/api/generate/image` reads it through `src/lib/layout-composition-skill.ts` before calling SDK, Manifest, custom API, or system default polling providers. The skill source is `nevertoday/100-layout-compositions` under CC BY 4.0; prompt injection must preserve attribution internally and avoid adding literal text/logo/poster elements.
|
||||
|
||||
`redeem_codes` stores admin-generated single-use credit and membership redemption codes. Runtime code generation and redemption go through `src/lib/redeem-code-service.ts`; redemption must lock both the code row and profile row in one transaction before updating `profiles.credits_balance` for credit codes or `profiles.membership_tier`/`membership_expires_at` for membership codes. Credit-code redemptions also insert a `credit_transactions` record.
|
||||
|
||||
`src/lib/yuanjie-image-model-templates.ts` is the canonical source for built-in 元界 AI image model definitions. It maps each documented model to its Manifest request body and stores capability flags so the create page only shows the documented aspect ratio, resolution, image format, and quality controls for the selected model. For 元界 GPT Image 2 / GPT Image 2 官转 and other `size`-enum models, the create page hides the separate aspect-ratio control and shows the documented pixel size values as the resolution list. 元界媒体轮询 uses `is_final === true` as the final-state gate and `state` for success/failure, matching the documented media task contract.
|
||||
|
||||
`src/lib/yuanjie-video-model-templates.ts` is the canonical source for built-in 元界 AI video model definitions from the local video docs. It maps each model to a Manifest request body, records whether the model supports 文生视频 and/or 图生视频, and stores aspect ratio, resolution, duration, and quality/mode capability options for the selected system video model. Video submit bodies must follow 元界 media docs as `model`, `prompt`, and `params`; HappyHorse text-to-video maps UI ratio to `params.ratio`, resolution to `params.resolution`, duration to `params.duration`, and reads async task IDs from `output.task_id`. Video templates use the documented `is_final` plus `state` polling rule.
|
||||
|
||||
`src/lib/yuanjie-pricing-sync.ts` is the canonical source for manual 元界 AI pricing metadata sync. It derives billing modes from the built-in image/video templates and local docs: image models default to fixed per-use pricing, duration-sensitive video models sync to `duration`, Seedance token-billed video models sync to `token`, and special variable-cost video models sync to `ratio` with a warning note. The sync is manual from the admin system-default-model page and only updates existing 元界 rows, including legacy provider spellings such as `元界AI`; update SQL still includes a 元界 provider/model-group guard so mozheAPI rows cannot be touched by the sync.
|
||||
|
||||
Yuanjie Manifest references use `$inputImages.urls` for provider-facing JSON fields. For image-to-image, `/api/generate/image` reads the primary `image` plus `extraImages` and sends all references to `src/lib/user-api-manifest-executor.ts`; for image-to-video, `/api/generate/video` reads `image`, `images`, and `extraImages` before Manifest execution. The executor uploads data URL references into storage before rendering Yuanjie `params.images`, top-level `images`, `reference_urls`, or `base64Array`. `referenceImageAnnotations` is an API payload field rather than a Manifest variable; image/video routes use `src/lib/reference-image-prompt.ts` to merge `@参考图N` token mappings into the upstream prompt so existing Manifest templates receive the mapping through `$prompt`. Yuanjie video templates keep documented model-specific fields inside `src/lib/yuanjie-video-model-templates.ts`, including first/last reference fields and mode fields such as `input_reference`, `reference_urls`, `img_url`, `image_tail`, `ratio`, `size`, and `generation_mode`.
|
||||
|
||||
`src/lib/agnes-model-templates.ts` is the canonical source for Agnes AI built-in free templates. Agnes Video V2.0 uses Manifest `POST /v1/videos` plus `/agnesapi` polling, but duration must be sent as `num_frames` rather than `duration`. `/api/generate/video` maps the currently stable Agnes UI durations 3/5/10 seconds to 24fps frame counts 81/121/241 and sends `frame_rate: 24`; 18 seconds is intentionally hidden and rejected because production evidence showed the upstream task moves past creation but returns `failed` for that length. In image-to-video mode the top-level `image` is the provider's starting/first frame field, not a generic non-first-frame reference slot. The Manifest executor keeps Agnes-style total polling budgets separate from per-request submit/poll timeouts, so one slow or transiently failed poll request does not end the whole async video job before the full video budget expires.
|
||||
|
||||
`src/lib/yuanjie-system-manifest.ts` provides the runtime bridge for existing admin system API rows that were created before Manifest-backed Yuanjie templates. It exposes built-in capabilities to `/api/model-config` even when `manifest_path` is empty, and when a known 元界 system API is resolved directly or as a default-model polling candidate it writes missing or stale `system-api-manifests/<systemApiId>.json`, normalizes `api_url` back to the 元界 base URL, and preserves the encrypted API key and administrator pricing.
|
||||
|
||||
Profile naming convention: `profiles.nickname` is the stable login username; `profiles.display_nickname` is the public nickname shown in navbar/gallery/profile UI. APIs return `username` plus `nickname`/`display_nickname` so older clients can keep reading `nickname` as the display name.
|
||||
322
docs/codex-miaojing/architecture.md
Normal file
322
docs/codex-miaojing/architecture.md
Normal file
@@ -0,0 +1,322 @@
|
||||
# Architecture
|
||||
|
||||
Last source audit: 2026-05-12, based on git commit `8ee86a9`.
|
||||
|
||||
## System Overview
|
||||
|
||||
MiaoJing is a self-hostable AI multimodal creation platform built on:
|
||||
|
||||
- Next.js 16 App Router
|
||||
- React 19
|
||||
- TypeScript
|
||||
- PostgreSQL via `pg`
|
||||
- Local disk storage through `/api/local-storage/*`
|
||||
- PM2 process management
|
||||
- Optional upstream AI providers through SDK, custom API keys, and admin system API configs
|
||||
|
||||
The repository contains frontend pages, API route handlers, business services, database helpers, storage helpers, deployment scripts, and PM2 config in one app.
|
||||
|
||||
## Runtime Shape
|
||||
|
||||
```text
|
||||
Browser
|
||||
|
|
||||
| HTTP
|
||||
v
|
||||
Next.js App Router
|
||||
|
|
||||
+-- src/app pages
|
||||
+-- src/app/api route handlers
|
||||
|
|
||||
+-- src/components UI and workflow components
|
||||
|
|
||||
+-- src/lib business logic and stores
|
||||
|
|
||||
+-- src/storage database clients
|
||||
|
|
||||
+-- local disk storage via src/lib/local-storage.ts
|
||||
|
|
||||
+-- PostgreSQL via src/storage/database/local-db.ts
|
||||
|
|
||||
+-- Upstream AI providers via SDK/custom fetch
|
||||
```
|
||||
|
||||
Production is controlled by `ecosystem.config.cjs` and the scripts under `scripts/`. Always verify PM2 cwd and runtime environment before editing a live server.
|
||||
|
||||
## Directory Boundaries
|
||||
|
||||
| Directory | Responsibility |
|
||||
| --- | --- |
|
||||
| `src/app` | Next.js pages, layouts, and API route handlers. |
|
||||
| `src/components` | Reusable UI and product workflow components. |
|
||||
| `src/components/ui` | Radix/shadcn-style primitives. Keep generic. |
|
||||
| `src/components/create` | Create center panels and shared generation UI. |
|
||||
| `src/components/admin` | Admin console tab components. |
|
||||
| `src/components/profile` | Profile page tabs and API key manager. |
|
||||
| `src/lib` | Business logic, stores, model config, auth, storage, email, generation jobs, provider resolution. |
|
||||
| `src/modules` | Thin module export and console page wrappers. |
|
||||
| `src/storage` | Database client/schema compatibility. |
|
||||
| `scripts` | Build, deploy, backup, restore, DB patch, admin upgrade runner. |
|
||||
| `public` | Public static assets. |
|
||||
| `assets` | Project-owned source assets. |
|
||||
|
||||
## Frontend Architecture
|
||||
|
||||
The app is route-driven through `src/app`:
|
||||
|
||||
- Public shell: `src/app/layout.tsx` and `src/components/app-shell.tsx`.
|
||||
- Navigation and global UI: navbar, footer, announcement popup, site config sync, visit tracker.
|
||||
- Product workflows:
|
||||
- Create center: `src/app/create/page.tsx` plus `src/components/create/*`.
|
||||
- Gallery: `src/app/gallery/page.tsx`.
|
||||
- Profile: `src/app/profile/page.tsx` plus `src/components/profile/*`.
|
||||
- Admin console: `src/app/console/*`, `src/modules/console/pages/*`, `src/components/admin/*`.
|
||||
|
||||
Mobile adaptation is handled primarily through page-level structure classes plus `src/app/globals.css`. The create center uses `.create-chat-layout`, `.create-chat-thread`, and `.create-chat-composer` so phones behave like a modern AI chat client: the single mode switch is the sticky icon row, the page title and duplicate text mode strip are hidden, and text-to-image reads as a chronological conversation from oldest to newest so the latest work sits above the fixed composer. Text-to-image suppresses the empty result placeholder until the user submits a prompt, then renders the generating task as the newest prompt-plus-progress message. `src/components/create/mobile-creation-composer.tsx` is the fixed bottom input shell for text-to-image; it holds extra-compact labeled ratio/resolution/count controls and similar parameters, the optional style strip, the prompt input, and the right-side send button, and intentionally does not duplicate mode switching. Gallery masonry keeps at least two columns on phone widths. The admin console keeps the drawer navigation from `console-dashboard-page.tsx` and uses `console-mobile-*` shell rules to constrain cards while allowing dense admin tables to scroll horizontally instead of overflowing the viewport.
|
||||
|
||||
Client stores in `src/lib/*-store.ts` mediate API calls and local UI state. When fixing a UI persistence bug, inspect both the component and the matching store/API route.
|
||||
|
||||
Navigation performance is handled as part of the frontend architecture, not only by backend route timing. `src/components/navbar.tsx` defers its initial logged-in profile refresh so a fresh page load does not compete with the user's first navigation, but it should not eagerly prefetch every core route from the navbar because production web users can see that extra resource pressure as slower visible page switches. `src/components/visit-tracker.tsx` posts site statistics with `keepalive` after idle time because analytics should not block first paint. `src/app/create/page.tsx` keeps the primary creation workflow panels in the initial client bundle so switching between text/image/video/reverse-prompt modes does not wait on extra `ssr:false` chunks or show fallback flashes. The create panels must request lightweight scoped history through `useCreationHistory({ mode, limit })`; production users with large histories can otherwise trigger repeated multi-MB `/api/creation-history` responses that compete with navigation and image loads. `src/lib/creation-history-store.ts` owns short-lived in-flight request reuse and merges scoped responses into local history rather than replacing unrelated modes. `src/app/profile/page.tsx` keeps the parent account shell light and lets creation history, credit records, and orders mount their stores only inside their respective tab components. Keep `scripts/test-navigation-performance-policy.mjs` aligned with these constraints when changing the app shell, create center, profile page, or history store.
|
||||
|
||||
## API Architecture
|
||||
|
||||
All APIs are route handlers in `src/app/api`.
|
||||
|
||||
Major groups:
|
||||
|
||||
- `auth/*`: login, register, admin existence, provider API testing.
|
||||
- `profile/*`: user profile and theme.
|
||||
- `user-api-keys`: user-owned custom API credentials.
|
||||
- `model-config`: public model/provider config.
|
||||
- `style-presets`: public DB-backed image style presets ordered by usage count.
|
||||
- `generate/*`: direct generation, reverse prompt, prompt suggestion.
|
||||
- `generation-jobs/*`: queued generation job creation/status.
|
||||
- `creation-history`: user works/history.
|
||||
- `gallery/*`: public gallery and publishing.
|
||||
- `admin/*`: console dashboard, users, providers, system APIs, orders, payment, upgrade, data import/export, email settings.
|
||||
- `redeem-codes/*` and `credit-transactions`: user credit redemption and credit record APIs.
|
||||
- `site-config`, `site-stats`, `announcements`: public site content and counters.
|
||||
- `local-storage/*`, `download`: file serving and download proxy.
|
||||
|
||||
Auth is not implicit. Each route must call the correct helper:
|
||||
|
||||
- User route: `getAuthenticatedUserId` or `getAuthenticatedUser`.
|
||||
- Admin route: `requireAdmin` or `requireAdminUser`.
|
||||
- Internal generation route: `isTrustedInternalGenerationRequest`.
|
||||
|
||||
## Generation Flow
|
||||
|
||||
```text
|
||||
Create panel
|
||||
-> src/lib/generation-job-client.ts
|
||||
-> POST /api/generation-jobs
|
||||
-> system-default credit preflight counts selected job plus same-user queued/running jobs
|
||||
-> generation_jobs row inserted
|
||||
-> src/lib/generation-job-worker.ts
|
||||
-> src/lib/generation-job-runner.ts
|
||||
-> POST /api/generate/image or /api/generate/video
|
||||
-> SDK or custom/system API upstream call
|
||||
-> src/lib/local-storage.ts persists result
|
||||
-> src/lib/generation-credit-service.ts deducts selected system API credits only after success
|
||||
-> generation_jobs updated with result/error/progress
|
||||
-> client polls GET /api/generation-jobs/[id]
|
||||
-> create panels can recover queued/running jobs from GET /api/generation-jobs after refresh, auth change, or tab switch
|
||||
(anonymous recovery list polling is skipped, and same-token/type list requests are briefly deduped client-side)
|
||||
-> client-side pending job ids also query GET /api/generation-jobs/[id] so jobs that reached succeeded/failed/cancelled while the browser was closed still display their terminal state once before being cleared
|
||||
-> history/gallery persistence via works APIs
|
||||
```
|
||||
|
||||
Key source files:
|
||||
|
||||
- `src/components/create/text-to-image.tsx`
|
||||
- `src/components/create/image-to-image.tsx`
|
||||
- `src/components/create/text-to-video.tsx`
|
||||
- `src/components/create/image-to-video.tsx`
|
||||
- `src/lib/generation-job-client.ts`
|
||||
- `src/app/api/generation-jobs/route.ts`
|
||||
- `src/app/api/style-presets/route.ts`
|
||||
- `src/lib/style-preset-store.ts`
|
||||
- `src/lib/generation-job-worker.ts`
|
||||
- `src/lib/generation-job-runner.ts`
|
||||
- `src/app/api/generate/image/route.ts`
|
||||
- `src/app/api/generate/video/route.ts`
|
||||
|
||||
Do not bypass the job flow unless intentionally implementing synchronous/internal-only behavior.
|
||||
|
||||
## Provider Resolution
|
||||
|
||||
There are three provider sources:
|
||||
|
||||
1. Built-in model config: `src/lib/model-config.ts`.
|
||||
2. User custom API keys: `src/app/api/user-api-keys/route.ts`, `src/lib/custom-api-store.ts`.
|
||||
3. Admin system APIs: `src/app/api/admin/system-apis/route.ts`, `src/lib/server-api-config.ts`.
|
||||
|
||||
`src/lib/server-api-config.ts` resolves:
|
||||
|
||||
- `customApiKeyId` into a user-owned decrypted API config.
|
||||
- `systemApiId` into an active admin-managed decrypted API config after checking platform-default visibility and the requesting user's membership tier.
|
||||
- `systemApiId` polling candidates for admin default models by matching media type plus admin display name (`system_api_configs.name`) across active/default system API rows and ordering them by `polling_mode` plus `polling_order`/`sort_order`; each candidate still sends its own provider-specific `model_name` upstream.
|
||||
- direct `apiKey` passthrough for legacy/custom callers.
|
||||
|
||||
Secrets must be encrypted at rest with `src/lib/server-crypto.ts` and never returned in API responses.
|
||||
|
||||
User-level intelligent API imports add a fourth data artifact tied to source 2: a per-key JSON Manifest in local storage. `src/app/api/user-api-keys/smart-import/route.ts` parses either a full `{ customProviders, profiles }` bundle or one provider Manifest, creates a separate `user_api_keys` row for every profile/model, and writes `user-api-manifests/<userId>/<keyId>.json`. `user_api_keys.manifest_path` is the only runtime pointer. The imported row keeps a human-readable provider/supplier name for editing and derives the visible request URL from the Manifest profile/provider; incomplete configs without a resolvable relay API request URL are rejected. Optional `profile.capabilities` is stored in the Manifest and returned to the client so the selected model can constrain or hide image aspect ratio, resolution, image format, and quality choices. Manifest poll endpoints should put task IDs in `query: { task_id: "{task_id}" }` when the upstream documents a query string, so the executor sends a real query parameter instead of embedding `?task_id=` into the pathname. Even for the same user, different request configuration files must remain separate because generation dispatch is selected-model based, not user based.
|
||||
|
||||
At generation time, `src/lib/server-api-config.ts` returns `manifestPath` for user custom keys and admin system API keys. `src/app/api/generate/image/route.ts` and `src/app/api/generate/video/route.ts` call `src/lib/user-api-manifest-executor.ts` first when that path exists. The executor handles JSON, multipart file fields, `{task_id}` polling, `*` JSON-path extraction, and media persistence handoff. For image Manifest results, the route persists returned result URLs through `src/lib/media-storage.ts`; external result URL downloads use `src/lib/remote-fetch.ts` with browser-like headers and limited retry so provider/CDN-side 403, 429, 5xx, or timeout failures are distinguished from upstream generation failures. If the provider returned a result but MiaoJing cannot download or save the image media, the API should report a platform download/save failure instead of a resolution mismatch. Imported Manifest rows still need the user or admin to edit and save an API Key before they can generate.
|
||||
|
||||
Manifest template rendering exposes input images in two forms: `$inputImages.dataUrls` keeps the raw uploaded data for multipart/file manifests, while `$inputImages.urls` is normalized for providers that require URL references. The executor converts data URL references to storage-backed public URLs before rendering JSON templates, using object-storage signed URLs when available or the app public base URL plus `/api/local-storage/<key>` otherwise.
|
||||
|
||||
Admin system intelligent API imports live in `src/components/admin/api-management-tab.tsx` and `src/app/api/admin/system-apis/smart-import/route.ts`. The `智能配置 API` section is generic Manifest import only: each imported profile/model becomes one global `system_api_configs` row with its own `manifest_path`, backed by `system-api-manifests/<systemApiId>.json`, and the visible `api_url` is resolved from the Manifest profile/provider. Incomplete configs without a resolvable relay API request URL are rejected. Optional `profile.capabilities` can constrain or hide create-page image/video parameter choices for system models. Provider-specific built-in templates such as 元界 AI are not exposed in this smart import UI; 元界 definitions remain in `src/lib/yuanjie-image-model-templates.ts` and `src/lib/yuanjie-video-model-templates.ts` for the system-default-model management path, where admins configure each model row's Key, pricing/member visibility/polling, `video_usage_modes`, and enablement before it is available to users. 元界 price and billing metadata sync is also provider-specific and manual: `/api/admin/system-apis/yuanjie-pricing` uses `src/lib/yuanjie-pricing-sync.ts` to update only existing `provider = '元界 AI'` rows with derived billing mode and price notes, preserving API keys, Manifest paths, mozheAPI rows, and administrator-entered numeric prices. Admin Manifest files must remain separate from user-level files and must keep using the system pricing/credit deduction policy for the selected model. System API rows also own `is_default`, `allowed_membership_tiers`, `polling_mode`, and `polling_order`; `/api/model-config` returns only one active platform-default row per allowed media type plus admin display name so the create page shows a single default model label, and image generation expands the selected row back into all allowed supplier candidates with the same display name. The upstream `model_name` can differ between suppliers and is only used as that supplier's request model. Video model billing supports per-use count (`fixed`), per-second duration (`duration_price_per_second`), and token mode. Token billing prices shown in the admin console are credits per 1M tokens for both input and output; older storage/API field names containing `1k` remain compatibility names and must not be shown to admins as per-K pricing. If a system image supplier fails because a stream request idles until Cloudflare 524, `/api/generate/image` retries that candidate once with `stream:false`; 502/503/504 gateway responses are retried once by the shared transport. If every supplier still fails or returns no usable result, the route returns the last actionable upstream error when available, otherwise the generic model-busy message. This polling fallback is only for admin default system models and must not be applied to user custom API keys.
|
||||
|
||||
After production migration, app runtime tables in `public` should be owned by the app DB user from `LOCAL_DB_URL`. Runtime compatibility helpers use `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` and index creation; if restored tables remain owned by `postgres`, public routes such as `/api/model-config`, profile refresh, or generation jobs can fail with `must be owner of table ...`.
|
||||
|
||||
Admin console navigation state is intentionally short-lived. `src/modules/console/pages/console-dashboard-page.tsx` stores the active console view in `sessionStorage`: page refresh stays on the current admin page, logout clears the stored view, and a new browser tab/session opens the dashboard first.
|
||||
|
||||
## Storage Architecture
|
||||
|
||||
`src/lib/local-storage.ts` is the storage abstraction. It keeps the public URL shape stable while allowing the backend to move from local disk to object storage. Generated media delivery is layered through `src/lib/media-watermark-policy.ts` and `src/lib/media-watermark.ts` so compliance-visible watermarks are applied at the server boundary rather than as a frontend overlay.
|
||||
|
||||
- Public URL shape remains `/api/local-storage/<key>` for local and object-backed files. Existing DB rows and frontend image URLs do not need rewriting during migration.
|
||||
- `STORAGE_MODE=local`: read/write only `LOCAL_STORAGE_DIR`, or repo-local `local-storage` if unset.
|
||||
- `STORAGE_MODE=dual`: read object storage first, fall back to local disk, write every new file to local disk first, then mirror it to object storage. Object mirror failures are logged instead of breaking the user request. This is the recommended production migration mode because existing local backups and rollback stay useful while object storage is populated.
|
||||
- `STORAGE_MODE=object`: read/write only the configured S3-compatible bucket. Use this only after `scripts/storage-sync-to-object.mjs --verify-only` passes and rollback expectations are clear.
|
||||
- Object storage config uses `OBJECT_STORAGE_BUCKET`, `OBJECT_STORAGE_REGION`, optional `OBJECT_STORAGE_ENDPOINT`, access keys, `OBJECT_STORAGE_FORCE_PATH_STYLE`, and optional `OBJECT_STORAGE_PREFIX`.
|
||||
- Rainyun ROS is handled as a control-plane preparation step, not as a runtime storage backend. `scripts/rainyun-ros-prepare.mjs` calls `POST https://api.v2.rainyun.com/product/ros/bucket` with `x-api-key` and `{ bucket_name, instance_id }`, then writes `.env.rainyun-object.generated` with standard `OBJECT_STORAGE_*` values derived from `access_key`, `secret_key`, and `public_api_url`. Copy those values into production `.env.local` only after review, keep `STORAGE_MODE=dual`, and keep `.env.rainyun-object.generated` private.
|
||||
- File serving route: `src/app/api/local-storage/[...path]/route.ts`. Generated images/videos under generated/gallery/imported work media paths, plus generated/gallery/work thumbnails, are served as watermarked bytes using `public/watermark/miaojing-watermark-logo.png` and `MIAOJING AI` at 50% opacity; this route should not expose raw object-storage redirects for generated media because browser extensions or scripts can call the same URL. For generated image originals that already have a local `works.thumbnail_url`, display requests can redirect to the thumbnail and watermark that smaller file first; download requests still go through `/api/download` for the original media.
|
||||
- Download route: `src/app/api/download/route.ts`. Downloads also return watermarked files by default; a raw generated file is allowed only when the request authenticates an admin role or a user whose `profiles.watermark_disabled=true`. Frontend helpers pass the session via Authorization for fetch downloads and a same-origin `downloadToken` for anchor-triggered downloads.
|
||||
- Storage key validation prevents traversal through `normalizeKey`, `path.resolve`, and `..` checks.
|
||||
|
||||
Generation routes persist generated media through the storage adapter. Image originals and video originals are object-first when object storage is configured: images go through `src/lib/media-storage.ts`, while videos from `src/app/api/generate/video/route.ts` are stored with `uploadFileObjectOnly(...)` under `generated/videos`. Gallery publish uses `src/lib/gallery-publish-media.ts`: stable `/api/local-storage/...` image and video originals are reused rather than synchronously copying object-backed generated media again, while external media URLs are copied into gallery storage before insertion. Admin data export/import reads and restores through the same adapter, and import whitelists `manifest_path` plus system API pricing fields so intelligent API configurations survive server migration. Import preserves `auth.users.password_hash` and existing encrypted secret fields as encrypted values; production migrations must carry the same `DATA_ENCRYPTION_KEY`/JWT secret family or encrypted API/payment secrets and existing sessions cannot be decoded correctly. Work dedupe is scoped by `user_id` plus URL/source URL/media SHA to protect private data ownership when different users have identical media.
|
||||
|
||||
Image originals and previews have separate storage rules. `src/lib/media-storage.ts` persists new generated image originals through `localStorage.uploadFileObjectOnly(...)` so production originals live in the object bucket even while the app remains in `STORAGE_MODE=dual`. The same helper writes high-quality compressed WEBP thumbnails through `uploadFileLocalOnly(...)` under `thumbnails/...`; the current thumbnail profile uses 1280px max edge, WEBP quality 86, Lanczos resize, and light sharpening, with an `m1280q86` filename suffix so older thumbnail profiles can be replaced in the background. Create results, creation history, gallery cards, and gallery detail previews should render `thumbnailUrl`, while fullscreen preview, download, copy, edit, and share actions must continue to use the original `url`; the storage/download routes decide whether that original URL returns watermarked bytes or raw bytes. Sharing an already generated `/api/local-storage/...` image should reuse that original URL and existing thumbnail instead of copying the object into `gallery/images` or recompressing a gallery thumbnail synchronously; missing/stale thumbnails are backfilled later by `/api/gallery` reads. Legacy rows without current `works.thumbnail_url` are queued for background thumbnail backfill by `/api/creation-history` and `/api/gallery` when image works are read; list responses must not wait for thumbnail generation. Thumbnail backfill should read object-only originals through short-lived signed object URLs instead of slow SDK buffering. The `/api/local-storage/*` route reads `thumbnails/...` from local disk directly instead of probing object storage first and serves them with long immutable browser cache headers because thumbnail filenames include the profile/hash; generated image original display requests can also redirect to that existing thumbnail before watermarking to avoid first-view object GET and full-size raster work. `src/proxy.ts` must explicitly preserve cacheable thumbnail/gallery routes instead of applying the default `/api` no-store header. Object-backed generated originals should not return raw signed URLs from public display paths; non-generated object-backed originals can still return a short-lived 302 signed object-storage URL instead of buffering through Next.js.
|
||||
|
||||
Video originals and previews also have separate storage rules. Generated videos are stored as object-backed `/api/local-storage/generated/videos/...` URLs. Video thumbnails are local files under `thumbnails/works/videos` or `thumbnails/gallery/videos`, generated by `ensureLocalVideoThumbnail(...)` when history/gallery rows are written or read. The current preferred profile is a real video frame extracted by `ffmpeg-static` and stored as `video-frame-m1280q86-v1.webp`; lightweight SVG profiles such as `video-svg-v1` and `video-fallback-svg-v2` are only fallbacks when frame extraction fails. SVG fallback rows are treated as stale so gallery/history reads can backfill real frame thumbnails in the background; publish also tries frame extraction before copying any client-provided thumbnail. Object-backed videos are streamed from the storage adapter into a bounded temporary local file before ffmpeg extraction, with retry around transient object-stream termination; this avoids passing signed object-storage URLs directly to the bundled `ffmpeg-static` binary, which can crash or return no stderr for some remote inputs. Gallery video cards and detail overlays render the thumbnail first; the original video element is mounted only after the user clicks play, so opening the gallery detail does not immediately download the object-storage video. `/api/download` can redirect non-generated object-backed local-storage downloads to signed object URLs with content-disposition, but generated videos return watermarked bytes unless the authenticated member/admin no-watermark preference allows the raw file; video buttons use a normal anchor-triggered download and pass a same-origin download token.
|
||||
|
||||
Gallery detail metadata must not load original images just to compute size. `ImageMetadataBadge` accepts stored `width`/`height`; gallery detail passes those values with `loadMetadata={false}` so preview surfaces stay thumbnail-only and original requests are reserved for fullscreen, download, copy, edit, and share.
|
||||
|
||||
The public gallery page should use server gallery rows only. It must not merge `miaojing_published_gallery` or `miaojing_creation_history` from browser localStorage into the gallery feed, and it must not auto-sync historical local published records into Supabase on page load. `/api/gallery` is the authority for all gallery views, including all/category filters and search, and should only return stable platform media URLs under `/api/local-storage/...`; legacy external import URLs are not public gallery candidates. Client sharing flows must call `/api/gallery/publish` first and only then mark local history as shared with `publishedAt`; stale local `published=true` without that confirmation must not disable retry. To keep reopen latency low, `src/app/gallery/page.tsx` caches bounded page data in browser localStorage for instant first paint, uses cached rows up to the 7-day prune window while revalidating page 0 in the background, and shows a masonry skeleton instead of a blocking centered loading message when no cache exists. Public gallery serialization in `src/lib/gallery-response.ts` filters generated default `data:` avatars and oversized avatar URLs so repeated `publisherAvatarUrl` fields do not bloat `/api/gallery` responses or exceed localStorage quota. It should request small pages and append via IntersectionObserver as the user scrolls, not load the entire public gallery into the DOM.
|
||||
|
||||
Admin gallery moderation is separate from the public gallery page. `src/components/admin/gallery-management-tab.tsx` lists public completed works through `/api/admin/gallery/works` with page/pageSize pagination; `src/lib/admin-gallery-works-pagination.ts` keeps the route compatible with older limit/offset callers. Prompt edits go through `/api/admin/gallery/prompt` and `src/lib/admin-gallery-prompt-service.ts`. The service enforces the moderation rule that the author notification email must send successfully before `works.prompt` is updated. Platform logs record the admin, work, author, reason key, prompt length changes, and notification result, but must not store the full original or edited prompt text.
|
||||
|
||||
Fullscreen image overlays should accept a thumbnail fallback and display it immediately while the original object-storage image loads. If object storage is slow or the original fails, the user still sees the high-quality local preview and the fullscreen controls stay usable; copy/download/share actions still receive the original URL.
|
||||
|
||||
`/api/health` caches storage health briefly and bounds object bucket probing, so health checks do not block page monitoring on a slow object-storage HEAD request. Optional runtime schema checks cache success or non-owner skips; production migrations should still apply schema changes explicitly, but request paths should not repeatedly run DDL.
|
||||
|
||||
For a production move from local disk to cloud server plus object storage, use this order: create a full DB/file backup, run `pnpm run migration:check` against the source runtime, prepare Rainyun ROS with `pnpm run rainyun:ros-prepare -- --create` if a bucket still needs to be created, copy reviewed `OBJECT_STORAGE_*` values into `.env.local` with `STORAGE_MODE=dual`, run `pnpm run storage:sync-object -- --dry-run`, run `pnpm run storage:sync-object`, run `pnpm run storage:sync-object -- --verify-only`, deploy/reload, run `pnpm run migration:check` again, and verify `/api/health`, gallery/history images, downloads, login, and API generation. The migration checker defaults to `http://127.0.0.1:8000` and uses bounded storage URL probe helpers; override `MIGRATION_CHECK_BASE_URL`, timeout, or concurrency only when intentionally checking a different runtime. Only switch to `STORAGE_MODE=object` after the object bucket and migration integrity checks have passed and a rollback plan exists.
|
||||
|
||||
When syncing source into production, exclude the repo-root runtime storage directory as `/local-storage/` only. A broad `local-storage/` rsync exclude also skips `src/app/api/local-storage/[...path]/route.ts`, leaving production on stale file-serving code while the local repo appears fixed.
|
||||
|
||||
## Database Architecture
|
||||
|
||||
Main DB entry:
|
||||
|
||||
- `src/storage/database/local-db.ts`: PostgreSQL pool from `LOCAL_DB_URL`.
|
||||
|
||||
Schema sources:
|
||||
|
||||
- `src/storage/database/shared/schema.ts`: Drizzle snapshot for core tables.
|
||||
- `scripts/init-database.sql`: full initialization.
|
||||
- Runtime routes/services also ensure compatible columns/tables with `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`.
|
||||
|
||||
Core data areas:
|
||||
|
||||
- Users: `auth.users`, `profiles`. `profiles.watermark_disabled` is the per-user authorization for downloading raw generated media. Free users cannot enable it from their own profile, but admins can toggle it in user management without changing membership; platform display still stays watermarked.
|
||||
- Works: `works`.
|
||||
- Credits: `credit_transactions`, `redeem_codes`.
|
||||
- Orders: `orders`.
|
||||
- API credentials: `user_api_keys`, `system_api_configs`, `api_providers`.
|
||||
- Site content: `site_config`, `announcements`, `site_stats`.
|
||||
|
||||
`site_config.image_composition_skill_enabled` is a platform-wide feature switch for the built-in image composition skill. When enabled, image generation uses `src/lib/layout-composition-skill.ts` to deterministically select one of 100 `nevertoday/100-layout-compositions` references and append composition-only guidance to the prompt before any upstream image provider call. The source is CC BY 4.0, so admin UI and docs should keep attribution visible; the generation request should use it as layout guidance rather than as downloadable gallery/reference media.
|
||||
- Jobs/logs: `generation_jobs`, `platform_logs`.
|
||||
|
||||
Because several routes self-migrate compatibility columns, DB bugs often require checking both SQL scripts and route-level `ensure...Schema` functions.
|
||||
|
||||
Credit redemption uses `src/lib/redeem-code-service.ts`. Admin-generated codes are unique single-use rows in `redeem_codes`; user redemption locks the code row and profile row in one transaction and marks the code used. Credit codes increment `profiles.credits_balance` and write a `credit_transactions` record. Membership codes set/extend `profiles.membership_tier` and `membership_expires_at`; duration can be configured by day, month, or year. The external mall URL for obtaining codes/upgrading membership is stored as `site_config.redeem_code_mall_url`, edited from the admin redeem-code tab, returned publicly by `/api/site-config`, and preserved by data export/import. Invitation rewards use `src/lib/invitation-service.ts`: each profile owns a stable `invite_code`, `/auth/register?invite=...` stores the relationship in `invitation_referrals`, sets `profiles.referred_by_user_id`, and grants 50 credits to both users in the registration transaction. These tables and fields are included in admin data export/import and production migration checks so unused/used redemption state and invitation history survive server moves.
|
||||
|
||||
User display identity: `profiles.nickname` is retained as the login username so existing username/phone/email login and `works.user_id` ownership remain stable. Public display uses `profiles.display_nickname`, surfaced to clients as `nickname`; `src/lib/user-profile-defaults.ts` owns runtime schema creation plus random Chinese nickname/default 3D cartoon avatar generation. Existing users without `display_nickname` or `avatar_url` can be backfilled with `scripts/backfill-user-display-profile.mjs`.
|
||||
|
||||
## Admin Console Architecture
|
||||
|
||||
Admin console UI is split across:
|
||||
|
||||
- Page/wrapper: `src/app/console/page.tsx`, `src/app/console/dashboard/page.tsx`.
|
||||
- Module pages: `src/modules/console/pages/console-login-page.tsx`, `src/modules/console/pages/console-dashboard-page.tsx`.
|
||||
- Tabs: `src/components/admin/*`.
|
||||
- APIs: `src/app/api/admin/*`.
|
||||
|
||||
Admin auth flows through the same login endpoint with admin role checks. API routes should use `requireAdmin`.
|
||||
|
||||
Gallery prompt moderation uses `requireAdminUser` when the route needs the admin user ID for platform logs, and `requireAdmin` for read-only admin list APIs. The moderation endpoint should fail closed when the work is no longer public, the author email is missing/invalid, the prompt is unchanged, or SMTP sending fails.
|
||||
|
||||
## Upgrade And Deployment Architecture
|
||||
|
||||
Scripts:
|
||||
|
||||
- `scripts/build.sh`: builds Next.js and server.
|
||||
- `scripts/start.sh`: production start path.
|
||||
- `scripts/deploy-or-upgrade.sh`: deployment/upgrade automation.
|
||||
- `scripts/admin-upgrade-runner.mjs`: package extraction/build/restart runner.
|
||||
- `scripts/backup-create.sh`, `backup-list.sh`, `backup-restore.sh`: backups. Backup creation validates `pg_dump` output and tar integrity; restore validates archive/dump contents, creates a pre-restore safety dump/copy, uses a single DB transaction, and swaps local storage atomically.
|
||||
- `scripts/apply-database-patch.sh`: DB patch execution.
|
||||
|
||||
Runtime:
|
||||
|
||||
- `ecosystem.config.cjs` defines PM2 processes and environment roles.
|
||||
- `src/server.ts` is the custom Node server entry.
|
||||
- Admin upgrade API and UI:
|
||||
- `src/app/api/admin/upgrade/route.ts`
|
||||
- `src/components/admin/system-upgrade-tab.tsx`
|
||||
|
||||
Production note from the 2026-05-14 update: the reachable SSH endpoint was `root@124.174.9.29 -p 5238`, while PM2 still served `/opt/miaojingAI` with Node/PM2 under `/data/miaojingAI/node/node-v24.15.0-linux-x64/bin`. The live ports were `8000` for web, `8100` for API, and `8200` for console. Do not overwrite production `ecosystem.config.cjs` with a repository or dev-server copy during rsync-style source updates; it can switch PM2 back to `/root/miaojingAI` and ports `5000/5100/5200`.
|
||||
|
||||
When changing deploy/upgrade behavior, validate package limits, disk checks, backup creation, rollback paths, restore safety backups, PM2 restart command, and health checks.
|
||||
|
||||
All new development must be designed so the production server can be updated later through the admin console upgrade package flow. Classify every deployable change before handoff:
|
||||
|
||||
- Hot update candidate: static/public asset-only payloads that the upgrade runner accepts without restart. Preflight must reject source, dependency, script, lockfile, secret, backup, storage, and runtime paths.
|
||||
- Cold update candidate: any change involving `src`, API routes, server code, dependencies, `package.json`, `pnpm-lock.yaml`, DB schema or compatibility migration behavior, environment variables, PM2/runtime config, build scripts, backup/restore scripts, deployment scripts, or generated server assets.
|
||||
|
||||
Cold updates must preserve this safety chain: package preflight, disk checks, data backup, source snapshot, build/type verification, PM2 reload/restart, `/api/health`, and rollback through source restore plus backup restore when needed. When a feature introduces new persistent data, schema expectations, file-storage paths, background jobs, or environment variables, update the upgrade/package notes in this architecture document so future production packages can be prepared safely.
|
||||
|
||||
## Data Portability
|
||||
|
||||
Admin data export/import is a portability layer, separate from the full tar backup scripts:
|
||||
|
||||
- `src/app/api/admin/data-export/route.ts` exports database business tables and bundles local-storage media referenced by `works` and `site_config` under `_media`.
|
||||
- `src/app/api/admin/data-import/route.ts` accepts older DB-only exports and newer media-inclusive exports. Newer exports restore media files to stable sha-based local-storage keys before writing work rows.
|
||||
- Import runs one DB transaction with per-row savepoints, remaps user/work/custom API key IDs, preserves old source URL/media SHA markers in `works.params`, and merges repeated imports by URL/source URL/media SHA.
|
||||
- Older exports without `_media` can restore database rows but cannot recreate missing local files by themselves; copy `LOCAL_STORAGE_DIR` or use a newer export for full gallery/history image recovery.
|
||||
|
||||
## Security Boundaries
|
||||
|
||||
- API key storage: encrypted in DB, preview-only responses.
|
||||
- User auth: bearer session token from `src/lib/session-auth.ts`.
|
||||
- Admin auth: role must be `admin` or `enterprise_admin`.
|
||||
- Internal generation: protected by `x-miaojing-generation-internal`.
|
||||
- Local file serving: must preserve storage key normalization and path traversal guards.
|
||||
- Browser embedding: `src/proxy.ts` owns CSP security headers. `frame-ancestors` defaults to self plus mozheAPI origins and can be overridden with `MIAOJING_FRAME_ANCESTORS`; if an external ancestor is allowed, omit `X-Frame-Options` because `SAMEORIGIN` would still block the iframe.
|
||||
- Admin destructive actions: keep environment gates, admin checks, and limits.
|
||||
|
||||
## Verification Strategy
|
||||
|
||||
Use the narrowest useful check, then broaden as needed.
|
||||
|
||||
| Change Type | Minimum Verification |
|
||||
| --- | --- |
|
||||
| Docs only | Link/path sanity with `rg`, `git diff --check`. |
|
||||
| TypeScript source | `pnpm run ts-check`. |
|
||||
| API route | `pnpm run ts-check`, route smoke with `curl` where runtime exists. |
|
||||
| UI workflow | `pnpm run ts-check`, `pnpm run build`, browser/manual or Playwright check if visual. |
|
||||
| Generation path | `pnpm run ts-check`, `pnpm run build`, job create/poll route check, storage result check. |
|
||||
| Static hot-update candidate | Admin upgrade dry run/preflight, verify runner accepts the payload without restart, then route/static asset smoke check. |
|
||||
| Cold-update candidate | `pnpm run ts-check`, `pnpm run build`, admin upgrade dry run/preflight, backup/rollback readiness, PM2 reload/restart, health checks. |
|
||||
| Deployment/upgrade tooling | `pnpm run ts-check`, `pnpm run build`, package/upgrade dry run, PM2 reload, health checks. |
|
||||
|
||||
## Known Risk Points
|
||||
|
||||
- Some source files import `@/lib/model-display`, but `src/lib/model-display.ts` is absent at the audited commit. This can break `pnpm run ts-check` independent of documentation changes.
|
||||
- Generation routes are large and mix upstream adapter logic, persistence, and job progress. Prefer small surgical edits.
|
||||
- Several DB schema changes are applied lazily at runtime; verify migration behavior on fresh and upgraded DBs.
|
||||
- Production/dev servers can have different checkout paths. Always verify PM2 cwd before live edits.
|
||||
- Admin upgrade touches file system, build, backup, disk, and PM2 state. Treat it as high-risk.
|
||||
176
docs/codex-miaojing/bug-location-guide.md
Normal file
176
docs/codex-miaojing/bug-location-guide.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# Bug Location Guide
|
||||
|
||||
Last source audit: 2026-05-12, based on git commit `8ee86a9`.
|
||||
|
||||
Use this guide when the user reports behavior. Start from the symptom row, inspect the listed files, then verify with the current runtime.
|
||||
|
||||
## First Five Checks
|
||||
|
||||
1. Confirm whether the bug is frontend-only, API-only, persistence, generation worker, or deployment/runtime.
|
||||
2. Read the task row below and open the smallest likely file set.
|
||||
3. Search for the exact UI text, endpoint path, DB table, or log prefix with `rg`.
|
||||
4. Check auth state and request payload before changing business logic.
|
||||
5. After a fix, run the narrowest useful verification first, then `pnpm run ts-check` and `pnpm run build` when source changes affect TypeScript/runtime.
|
||||
|
||||
## Auth, Login, Profile
|
||||
|
||||
| Symptom | Check Files | What To Verify |
|
||||
| --- | --- | --- |
|
||||
| User cannot log in | `src/app/auth/login/page.tsx`, `src/lib/auth-store.ts`, `src/app/api/auth/login/route.ts`, `src/lib/session-auth.ts` | Request body fields (`account`, `email`, `phone`, `password`), password hash verification, bearer token storage, inactive profile. |
|
||||
| Admin console opens without login or redirects incorrectly | `src/app/console/page.tsx`, `src/modules/console/pages/console-login-page.tsx`, `src/modules/console/pages/console-dashboard-page.tsx`, `src/app/api/auth/login/route.ts` | `adminOnly` behavior, admin role check, route redirect logic. |
|
||||
| Registration fails or registration verification email arrives blank | `src/app/auth/register/page.tsx`, `src/app/api/auth/register/route.ts`, `src/app/api/email/send-register-code/route.ts`, `src/lib/email-service.ts` | `acceptedTerms`, email code, password strength, invite code, duplicate profile, SMTP settings, send logs, and MIME body encoding. Verification emails use HTML plus plain-text multipart content; base64 bodies must be folded to 76-character lines and multipart blank separators must be preserved. Do not use `.filter(Boolean)` on MIME message arrays because it removes required empty separator lines and can make mailbox clients render a blank email despite SMTP accepting it. |
|
||||
| Profile changes disappear after refresh | `src/app/profile/page.tsx`, `src/app/api/profile/route.ts`, `src/lib/auth-store.ts` | PUT writes both `profiles` and `auth.users` where needed; client refreshes returned profile. |
|
||||
| Navbar or gallery shows login username instead of public nickname | `src/lib/auth-store.ts`, `src/app/api/profile/route.ts`, `src/app/api/gallery/route.ts`, `src/lib/user-profile-defaults.ts` | `profiles.nickname` is login username; public UI should use returned `nickname` from `profiles.display_nickname`. Gallery SQL should select `display_nickname` first. |
|
||||
| Navbar user avatar is missing and only shows an initial | `src/components/navbar.tsx`, `src/lib/auth-store.ts`, `src/app/api/profile/route.ts` | The navbar user button should read `AuthUser.avatarUrl`; confirm `/api/profile` or login returns `avatar_url`, `parseApiUser` maps it, and `UserAvatar` only falls back to initials after image load failure. |
|
||||
| Existing users have blank/default avatar after display-profile migration | `src/lib/user-profile-defaults.ts`, `scripts/backfill-user-display-profile.mjs`, `src/app/api/auth/login/route.ts` | Run the backfill script with `LOCAL_DB_URL`; login also lazily fills missing `avatar_url` with a generated SVG data URL. |
|
||||
| Theme does not persist | `src/components/account-theme-sync.tsx`, `src/app/api/profile/theme/route.ts`, `src/lib/profile-preferences.ts` | `preferred_theme` schema, token auth, theme normalization. |
|
||||
|
||||
## Site Config, Footer, Policies, Announcements
|
||||
|
||||
| Symptom | Check Files | What To Verify |
|
||||
| --- | --- | --- |
|
||||
| Footer content missing or not Markdown-rendered | `src/components/site-footer.tsx`, `src/components/site-policy-page.tsx`, `src/lib/site-config.ts`, `src/app/api/site-config/route.ts` | Config response fields, Markdown renderer, fallback defaults, PUT persistence. |
|
||||
| Policy pages start mid-page after navigation | `src/components/site-policy-page.tsx`, `src/app/about/page.tsx`, `src/app/terms/page.tsx`, `src/app/privacy/page.tsx`, `src/app/help/page.tsx` | Scroll reset behavior and shared policy page wrapper. |
|
||||
| Site name/logo/favicon not updating | `src/components/site-config-sync.tsx`, `src/components/site-brand.tsx`, `src/app/api/site-config/route.ts`, `src/lib/local-storage.ts` | `site_config` row, base64 image save, generated `/api/local-storage/*` URL. |
|
||||
| Clicking navbar between home/create/gallery/profile feels slow while server-side route TTFB is normal | `src/lib/site-config.ts`, `src/app/api/site-config/route.ts`, `src/components/navbar.tsx`, `src/components/site-brand.tsx`, `src/components/site-footer.tsx`, `src/app/page.tsx`, `src/app/create/page.tsx`, `src/components/create/*`, `src/lib/creation-history-store.ts`, `src/app/api/creation-history/route.ts`, `src/app/profile/page.tsx`, `src/components/visit-tracker.tsx` | Compare production local route timings with real browser navigation and check OpenResty access logs for large competing API responses. If pages and `/api/site-config` are fast on the server but browser clicks still feel delayed, check whether multiple `useSiteConfig()` consumers are issuing duplicate concurrent config requests, and confirm the shared snapshot, 5-minute fresh-cache network skip, and in-flight request dedupe in `src/lib/site-config.ts` are present. Also confirm `/api/site-config` is not running schema/default compatibility DDL on every GET; it should cache that check once per server process and retry only after failure. Then verify `Navbar` is not doing eager all-route `router.prefetch(...)`, `VisitTracker` defers `/api/site-stats`, `/create` does not put the five primary creation panels behind `ssr:false` dynamic chunks, and the create panels call `useCreationHistory({ mode, limit })` instead of pulling full `/api/creation-history` responses. A production smell is repeated `/api/creation-history` responses of many MB from `/create`; those requests compete with visible navigation and image loading and should be scoped by `mode`/`limit` plus deduped in `src/lib/creation-history-store.ts`. `/profile` should not mount creation-history, credit-record, or order stores from the parent page. Home is naturally heavier than other public routes because its prerendered HTML/RSC is larger. Run `node --no-warnings ./scripts/test-navigation-performance-policy.mjs` after changing this area. |
|
||||
| Console reports CSP blocking `https://fonts.googleapis.cn/...` | `src/app/globals.css`, `src/proxy.ts` | `globals.css` imports Noto Serif SC from `fonts.googleapis.cn`; CSP must allow that stylesheet domain in `style-src` and the matching font CDN in `font-src`. |
|
||||
| Page content leaves large unused horizontal margins, or wide screens look like the UI was simply enlarged | `src/components/app-shell.tsx`, `src/components/navbar.tsx`, `src/components/site-footer.tsx`, page-level wrappers under `src/app/*/page.tsx`, `src/components/site-policy-page.tsx` | The viewport/background can be `w-full`, but product content should keep the original component scale and readable containers such as `max-w-7xl`, `max-w-4xl`, or `max-w-3xl`. Do not fix this by removing all max widths or scaling controls up on wide monitors. |
|
||||
| Scrollbars look native, stay visible when idle, or do not match glass UI in dialogs/pages | `src/app/globals.css`, `src/components/app-shell.tsx` | Global scrollbar styling is hidden by default and becomes visible only while wheel/touch scrolling through the `scrollbars-visible` class on `<html>`. `globals.css` owns both the hidden state and the rounded glass visible state for light/dark themes; `app-shell` owns the short-lived wheel/touch listener. Avoid adding one-off scrollbar styles to individual components unless there is a real exception. |
|
||||
| Announcement not popping up | `src/components/announcement-popup.tsx`, `src/app/api/announcements/route.ts`, `src/components/app-shell.tsx` | App shell includes popup, active date range, local/session dismissal behavior, GET payload shape. |
|
||||
| Announcement admin edit fails | `src/components/admin/announcement-tab.tsx`, `src/app/api/announcements/route.ts` | Admin token, required fields, `starts_at`/`expires_at` compatibility. |
|
||||
| Third-party platform iframe shows `miaojing.toplee.cn refused to connect` | `src/proxy.ts`, `.env.example`, reverse-proxy response headers | Check `Content-Security-Policy frame-ancestors` and `X-Frame-Options`. External iframe embedding requires the parent origin in `MIAOJING_FRAME_ANCESTORS` or the default mozheAPI allowlist, and `X-Frame-Options` must not be sent when third-party ancestors are allowed. Also verify the outer nginx/CDN is not adding its own stricter frame headers. |
|
||||
|
||||
## Creation And Generation
|
||||
|
||||
| Symptom | Check Files | What To Verify |
|
||||
| --- | --- | --- |
|
||||
| Create button does nothing | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx`, `src/lib/generation-job-client.ts` | Client validation, auth token, `/api/generation-jobs` POST response, UI disabled/loading state. |
|
||||
| Refreshing `/create` resets to the wrong creation tab | `src/app/create/page.tsx` | Active tab should persist in `miaojing:create-active-tab` and mirror to `/create?type=...`. Verify all creation tabs (`text2img`, `img2img`, `text2video`, `img2video`, `reversePrompt`) restore after refresh and query-param links still override storage. |
|
||||
| 手机端创作提示词输入框没有固定在底部,或固定后遮住提示词/参考图/任务状态 | `src/components/create/mobile-creation-composer.tsx`, `src/app/globals.css`, `src/components/navbar.tsx`, `scripts/test-mobile-create-ui-policy.mjs` | Mobile composer should be `position: fixed` and should use `ResizeObserver` to publish `--create-mobile-composer-height` to `.create-chat-layout`; `.create-chat-thread` must reserve that measured height through `padding-bottom`. The mobile bottom nav must be rendered outside the sticky header, because a sticky/backdrop-filter header can trap fixed children and make the nav appear near the top instead of the viewport bottom. Run `node --no-warnings ./scripts/test-mobile-create-ui-policy.mjs` and verify a mobile viewport such as 390x844. |
|
||||
| Create button is disabled while another task is still running, or active job cards overflow horizontally | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx`, `src/components/create/generation-task-list.tsx` | Create panels should keep submit enabled whenever models are available so users can start a different task while previous tasks run. Identical in-flight submissions are still blocked by `activeSubmissionSignaturesRef`. Active job cards should render inside the results column with wrapping vertical growth, not outside the result area. |
|
||||
| User cannot cancel a queued/running generation task, or a cancelled task still writes history | `src/components/create/generation-task-list.tsx`, `src/lib/generation-job-client.ts`, `src/app/api/generation-jobs/[id]/route.ts`, `src/lib/generation-job-worker.ts`, create panel component | Task cards should pass `onCancelTask`, the client should call `cancelGenerationJob`, and `PATCH /api/generation-jobs/[id]` should set `status='cancelled'`. The worker must check the job is still `running` before charging credits, persisting history, or updating success/failure so late upstream responses do not resurrect cancelled jobs. |
|
||||
| Earlier completed image tasks disappear while later tasks are still running | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/create/generation-task-list.tsx` | The results column must not be a single `generating ? taskList : results` branch. Render active task cards and completed result cards together, and append each task's images as soon as that task succeeds instead of waiting for all submitted tasks to settle. |
|
||||
| One submitted generation shows two running cards, or current results show the same media twice while refreshed history has one row | `src/app/api/generation-jobs/route.ts`, `src/components/create/use-generation-job-recovery.ts`, `src/components/create/generation-task-list.tsx`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx` | Check production `generation_jobs` first. If there are two queued/running jobs whose payload only differs by `clientRequestId`, inspect the API semantic dedupe query and each create panel's `activeSubmissionSignaturesRef`. If there is only one job and one result URL, the duplicate is frontend recovery state, not backend creation. Locally submitted tasks use temporary ids before the server job id is known; recovery must treat both `jobId` and `payload.clientRequestId` as the same task identity, and result appenders should filter duplicate URLs so a recovery poll cannot add the same completed media twice. |
|
||||
| Job remains queued | `src/app/api/generation-jobs/route.ts`, `src/lib/generation-job-worker.ts`, `src/lib/generation-job-runner.ts` | `processNextGenerationJob()` invoked, stale job handling, DB locks/status, internal base URL. |
|
||||
| Job remains running forever | `src/app/api/generation-jobs/[id]/route.ts`, `src/lib/generation-job-worker.ts`, `src/lib/generation-job-estimates.ts` | Stale timeout updates, `updated_at`, worker exceptions swallowed into error field. |
|
||||
| Image generation returns upstream error | `src/app/api/generate/image/route.ts`, `src/lib/custom-api-fetch.ts`, `src/lib/custom-image-fallback.ts`, `src/lib/server-api-config.ts` | Resolved custom/system API credentials, endpoint URL, New API normalization, timeout, stream/progress parser, and system-default stream timeout fallback. Gateway 502/503/504 errors are retried once; system default model failures should return the last actionable upstream timeout/gateway message instead of hiding everything behind the generic busy message. |
|
||||
| One submitted image task shows extra images, or the same generated URL appears twice in history | `src/app/api/generate/image/route.ts`, `src/app/api/creation-history/route.ts`, `src/lib/generation-job-worker.ts`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx` | First check production API logs for `count:1` with upstream messages such as `Got 2 images`, then query `generation_jobs.result.images` and `works` grouped by `user_id,result_url`. The image route should cap persisted response images to the requested count because some upstream/custom providers can return more images than `n`; creation-history POST should serialize same-user same-URL inserts before the existing lookup so concurrent completion/local persistence cannot insert duplicate `works` rows. |
|
||||
| User selects JPEG/WebP but the returned generated image is PNG | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/lightbox.tsx`, `src/components/creation-detail-dialog.tsx`, `src/app/gallery/page.tsx`, `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`, `src/lib/media-storage.ts`, `src/lib/utils.ts` | First check PM2 logs for `[Image Generation] Params` and upstream request logs to confirm `outputFormat`/`output_format` reached the server/provider. Then query `works.params->>'outputFormat'` with `result_url` and inspect the object-storage response `Content-Type`/file magic for a recent key. Some providers may ignore `output_format` and still return PNG, so generated-image persistence must normalize the downloaded bytes to the selected format before `persistOriginalImageWithThumbnail(...)` uploads the object and writes history. If the object headers/magic bytes are already JPEG/WebP but the downloaded file still appears as PNG, check frontend `downloadFile(...)` callers and ensure filenames use `getImageDownloadExtension(...)` instead of a hard-coded `.png`. |
|
||||
| Admin enables 100 Layout Compositions but generated images ignore composition guidance, or prompts show unwanted text/logo/poster layout | `src/components/admin/settings-tab.tsx`, `src/app/api/site-config/route.ts`, `src/lib/site-config.ts`, `src/lib/layout-composition-skill.ts`, `src/app/api/generate/image/route.ts` | Verify `/api/site-config` returns `imageCompositionSkillEnabled: true` and `site_config.image_composition_skill_enabled` exists. The image route should call `applyLayoutCompositionSkillToPrompt(...)` after `buildReferenceImagePrompt(...)` and before `mergeStylePrompt(...)`. The skill should append composition-only instructions referencing `nevertoday/100-layout-compositions` CC BY 4.0, but it must explicitly say not to add text, logos, brand marks, or literal poster elements. |
|
||||
| Video generation returns upstream error | `src/app/api/generate/video/route.ts`, `src/lib/custom-api-fetch.ts`, `src/lib/server-api-config.ts` | Reference image upload/compression, endpoint URL, response parser, persistence timeout. |
|
||||
| Wrong image size, aspect ratio, or custom API says returned resolution is lower than requested | `src/lib/model-config.ts`, `src/app/api/generate/image/route.ts` | `resolveImageSize`, `resolveCustomApiImageSize`, New API/DALL-E size normalization, prompt aspect hint, and custom API result qualification. Exact or larger generated images pass normally; lower-resolution images with matching aspect ratio and at least 60% of the requested dimensions are accepted as degraded upstream output instead of failing the job, while wrong-ratio or much smaller images are still rejected. |
|
||||
| Text-to-image or image-to-image says `请在提示词中写明画面比例` even after selecting a Yuanjie resolution such as `4K 竖版 (3:4)` | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/lib/yuanjie-image-model-templates.ts` | Some Yuanjie image templates set `supportsAspectRatio: false` and encode orientation in `resolution`/`size` options. Generation validation must derive the ratio from the selected resolution label or dimensions instead of requiring a separate aspect-ratio control. Image-to-image should also default count to `1` rather than requiring prompt inference for `生成数量`. |
|
||||
| Reference image upload too large or fails | `src/components/create/image-to-image.tsx`, `src/components/create/image-to-video.tsx`, `src/lib/browser-image-compression.ts`, `src/lib/server-image-compression.ts`, `src/app/api/generate/image/route.ts`, `src/app/api/generate/video/route.ts` | Browser compression, `MAX_UPSTREAM_REFERENCE_IMAGE_BYTES`, data URL conversion. Uploaded reference thumbnails should single-click into the no-container `BareImagePreview`; blank area closes it. |
|
||||
| 图生图/图生视频参考图或创作历史详情参考图加载慢,或详情不显示参考图 | `src/components/create/image-to-image.tsx`, `src/components/create/image-to-video.tsx`, `src/components/reference-preview-image.tsx`, `src/components/creation-detail-dialog.tsx`, `src/app/api/creation-history/route.ts`, `src/lib/reference-image-storage.ts`, `src/lib/generation-job-worker.ts` | Upload/reference cards should render `ReferencePreviewImage` so large uploaded data URLs or remote URLs are downsampled for the card while click/fullscreen still uses the original. `/api/creation-history` should persist data URL or remote reference images into stable `/api/local-storage/works/references/...` URLs, write `params.referenceImages` and `params.referenceImageThumbnails`, and patch existing same-URL rows that were first inserted by the background worker without references. The generation worker must pass data URL reference inputs through to creation-history persistence instead of filtering them out before the server can store them. Creation detail should prefer `referenceImageThumbnails[index]` for the small grid and should not expose reference-image downloads. |
|
||||
| Custom API image-to-image logs `Failed to download reference image from URL`, sends a 56-character `/api/local-storage/...` reference, or all URL-based strategies fail | `src/app/api/generate/image/route.ts`, `src/lib/local-storage.ts`, `src/lib/remote-fetch.ts` | Custom API img2img should read existing `/api/local-storage/...` references through `localStorage.readFileAsync(...)` for the FormData `images/edits` strategy instead of fetching back through public HTTP. When a data URL reference is uploaded for URL-based strategies, return `localStorage.generateObjectReadUrl(...)` when object storage is configured; only fall back to an absolute `APP_BASE_URL + /api/local-storage/...` URL, never a relative URL. |
|
||||
| Generated result previews but does not persist | `src/app/api/generate/image/route.ts`, `src/app/api/generate/video/route.ts`, `src/lib/local-storage.ts`, `src/app/api/creation-history/route.ts` | Media copied through the storage adapter, stable `/api/local-storage/<key>` URL returned, history POST called. In object storage mode, verify `STORAGE_MODE` and `OBJECT_STORAGE_*` health. |
|
||||
| Generated video is not in object storage or video download/share feels slow | `src/app/api/generate/video/route.ts`, `src/lib/local-storage.ts`, `src/app/api/download/route.ts`, `src/app/api/gallery/publish/route.ts`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx`, `src/app/gallery/page.tsx` | Video generation should persist remote/data video results via `uploadFileObjectOnly(...)` under `generated/videos`. `/api/download` should redirect object-backed `/api/local-storage/*` downloads to signed object URLs instead of buffering large videos, and video buttons should call `triggerDownloadFile(...)`. Gallery publish should reuse object-backed video URLs rather than copying large videos again; missing video thumbnails should be local WEBP frame thumbnails generated through `ffmpeg-static`, falling back to local SVG only if frame extraction fails. |
|
||||
| A single generated video appears twice in text-to-video or image-to-video history | `src/lib/creation-history-store.ts`, `src/app/api/creation-history/route.ts`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx` | The client creates an optimistic local history row, then the server returns a DB row with a different id. Local storage and `/api/creation-history` must de-duplicate by `url`/`result_url`, preserving only one visible record for the same generated video. |
|
||||
| Image preview cards load slowly, look blurry in detail, or fetch full originals | `src/lib/media-storage.ts`, `src/lib/local-storage.ts`, `src/lib/gallery-publish-media.ts`, `src/app/api/local-storage/[...path]/route.ts`, `src/app/api/generate/image/route.ts`, `src/app/api/creation-history/route.ts`, `src/app/api/gallery/route.ts`, `src/app/api/gallery/publish/route.ts`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/profile/creation-history-tab.tsx`, `src/app/gallery/page.tsx` | New generated image originals should be object-only, while WEBP thumbnails should be local-only under `thumbnails/...`. Current thumbnails should have the `m1280q86` suffix and come from the 1280px/Lanczos/sharpened profile. Cards and detail preview surfaces use `thumbnailUrl || url`; fullscreen, right-click copy/download/edit, and share must use original `url`. Detail metadata badges must use stored width/height with `loadMetadata={false}` rather than requesting original images. `GET /api/creation-history` and `GET /api/gallery` should queue missing or old-profile legacy thumbnails in the background, not block the list response. `/api/gallery/publish` should reuse stable `/api/local-storage/...` generated image originals and existing thumbnails instead of synchronously reading the object, copying it to `gallery/images`, or recompressing a gallery thumbnail. `/api/local-storage/thumbnails/...` must read local disk directly in dual mode instead of checking object storage first; original image keys should 302 to a short-lived signed object-storage URL so fullscreen does not wait for Next.js to buffer the full file. |
|
||||
| Gallery shows `加载中...` for seconds on every visit or loads too many images at once | `src/app/gallery/page.tsx`, `src/app/api/gallery/route.ts`, `src/lib/gallery-response.ts`, `src/lib/gallery-cache-policy.ts`, `src/app/api/local-storage/[...path]/route.ts`, `src/proxy.ts` | The page should show cached `miaojing:gallery:v3` rows immediately when available, even when older than the short freshness TTL, then revalidate page 0 in the background. It should show the masonry skeleton instead of the old centered `加载中...` when no cache exists, debounce search, request small `limit/offset` pages, and append more rows only through the scroll sentinel. Check `/api/gallery` response size with curl; generated default avatar `data:` URLs in `publisherAvatarUrl` can make every page hundreds of KB larger and can break localStorage caching, so public gallery serialization must filter `data:`/oversized avatars. Do not restore the old `limit=300` full-gallery request. Thumbnail URLs under `/api/local-storage/thumbnails/...` should return long immutable cache headers so browser image cache is actually used; if curl still shows `no-store`, check `src/proxy.ts` because the global `/api` cache header can override the route response. |
|
||||
| `/api/health` or page probes are slow after object migration | `src/app/api/health/route.ts`, `src/lib/local-storage.ts` | Health checks call `getStorageHealthStatus()`. Object bucket checks should be cached briefly and bounded with an abort timeout so a slow S3-compatible endpoint does not hold request threads for many seconds. |
|
||||
| Logs repeatedly show `must be owner of table ...` on normal requests | `src/lib/generation-job-estimates.ts`, `src/lib/email-service.ts`, `src/lib/profile-preferences.ts`, `src/lib/user-profile-defaults.ts`, `src/lib/server-api-config.ts` | Optional runtime schema checks can hit `42501` when the production app user is not the table owner. Treat existing-schema `42501` as a one-time warning and cache the skip; apply real schema migrations through deployment/DB owner operations rather than request-time DDL. |
|
||||
| Fullscreen/preview/download/right-click image actions broken | `src/components/fullscreen-preview.tsx`, `src/components/lightbox.tsx`, `src/components/creation-detail-dialog.tsx`, `src/components/image-actions-context-menu.tsx`, `src/components/image-metadata-badge.tsx`, `src/app/image-viewer/page.tsx`, `src/app/api/download/route.ts` | Dialog state, URL type, download proxy supports local/remote URL. Image result and history/detail previews should open on single click. Right-click copy, download, edit, and share actions must use the uncompressed original image URL, not a thumbnail, preview cache, or compressed reference blob. Fullscreen components should receive a thumbnail fallback so the preview appears immediately while the original object-storage image loads. Share links should open `/image-viewer?url=...` as a standalone original-image fullscreen page. Image result and history/detail previews should show upper-right actual aspect ratio and natural resolution via `ImageMetadataBadge`. |
|
||||
| Gallery video detail says `下载图片`, shows the generic play-card instead of a real thumbnail, or opens the original video too early | `src/app/gallery/page.tsx`, `src/app/api/gallery/route.ts`, `src/lib/media-storage.ts`, `src/app/api/gallery/publish/route.ts`, `package.json` | Use `isVideoWork(...)` for labels and filenames. Video cards/details should render `thumbnailUrl` first and mount the original `<video>` only after the user clicks play. If thumbnails are missing or still use `video-svg-v1` or `video-fallback-svg-v2`, `/api/gallery` and publish should backfill local WEBP frame thumbnails under `thumbnails/gallery/videos` via `ffmpeg-static`; only `video-frame-m1280q86-v1.webp` is the current video thumbnail profile. SVG is only the fallback when extraction fails and must remain replaceable later. If PM2 logs show `spawn /ROOT/node_modules/.../ffmpeg ENOENT`, check `src/lib/media-storage.ts` runtime cwd fallback for `ffmpeg-static`; bundled route contexts can resolve the package from a synthetic path even though `/opt/miaojingAI/node_modules/.../ffmpeg` exists. If ffmpeg exits with code `139`/`unknown` and no stderr for an object-backed video, verify the thumbnail path is streaming the object through `localStorage.openFileStreamAsync(...)` into a temporary local file before extraction, not passing a signed object URL directly to ffmpeg. If object-storage reads intermittently terminate mid-stream, `src/lib/media-storage.ts` should retry bounded temporary input writes before falling back to SVG. |
|
||||
| Gallery or history detail logs/requests original generated URLs while preview should use thumbnails | `src/app/gallery/page.tsx`, `src/components/creation-detail-dialog.tsx`, `src/components/image-metadata-badge.tsx` | Check the actual `<img>` `src` and `/api/gallery` or `/api/creation-history` response first. The console line/request can be caused by metadata probing rather than the preview image. Gallery and history detail should pass stored `width`/`height` to `ImageMetadataBadge` and set `loadMetadata={false}` so the badge does not trigger an original-image request just to calculate dimensions. |
|
||||
| Gallery shows many historical/imported works or thumbnail rules differ between all/category/search | `src/app/gallery/page.tsx`, `src/app/api/gallery/route.ts`, `src/lib/creation-history-store.ts` | The gallery page should render only `/api/gallery` rows, not merge browser localStorage published/history records and not call `syncPublishedToSupabase()` on load. `/api/gallery` should apply `category`/`q` filters server-side against public works with stable `/api/local-storage/...` result URLs so all, text-to-image, image-to-image, text-to-video, image-to-video, and search share the same thumbnail/original split. |
|
||||
| Generated image preview zooms but cannot be dragged | `src/components/lightbox.tsx`, `src/components/fullscreen-preview.tsx` | Result-card previews use `ImageLightbox`; after zooming above 100%, panning should be bound to the whole preview stage as well as the image so mouse drag remains available even when the transformed image extends beyond its original element box. Keep wheel zoom, double-click zoom/reset, right-click actions, and ESC close intact. |
|
||||
| Create page loads slowly and console shows CORS errors for historical `coze-codingproject.tos.coze.site` images | `src/components/create/cached-preview-image.tsx`, `src/components/create/text-to-image.tsx`, `src/app/api/download/route.ts` | Hidden mobile history must not mount desktop-side image effects, and cross-origin historical result images should render through the same-origin `/api/download?disposition=inline` proxy before canvas preview generation. Otherwise every hidden history image can issue a blocked browser request and slow the page. |
|
||||
| Creation detail, gallery one-click reuse, or inspiration reuse buttons do not fill create forms or switch tabs | `src/components/creation-detail-dialog.tsx`, `src/app/gallery/page.tsx`, `src/components/create/inspiration-gallery-dialog.tsx`, `src/lib/creation-reuse.ts`, `src/app/create/page.tsx`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx` | Reuse actions should write the shared draft key/event for `text2img`, `img2img`, `text2video`, or `img2video`, route to the matching `/create?type=...` when leaving gallery, and already-mounted create panels should react to the event. Creation detail's `复用配置` must also support video history: text-to-video records write `text2video`, image-to-video records write `img2video`. Image-to-image and image-to-video reuse should include stored reference images from the work; when intentionally using the generated output as the new reference, fall back to the original output `url`, never `thumbnailUrl`. |
|
||||
| Image generation count dropdown too wide, options missing, or manual count input unavailable | `src/components/create/image-count-combobox.tsx`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx` | Use the shared compact combobox instead of browser `datalist`; verify manual numeric entry and dropdown options in both text-to-image and image-to-image panels. |
|
||||
| Generated image result hover actions are unreadable in light theme | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx` | The result-card hover overlay owns the Preview/Share/Download buttons. These buttons should use fixed dark translucent backgrounds and white text/icons so light and dark themes have the same readable hover action style. |
|
||||
| Generated image is pushed down by a long prompt in the desktop result column | `src/components/create/text-to-image.tsx` | The result prompt above new images should be a compact two-line summary with the full prompt only in the title tooltip/history detail. Do not render the entire prompt as an unbounded paragraph in the live result column. |
|
||||
| Style presets are hardcoded, missing, or not ordered by usage | `src/components/create/style-preset-selector.tsx`, `src/lib/style-presets-client.ts`, `src/app/api/style-presets/route.ts`, `src/lib/style-preset-store.ts`, `src/app/api/generation-jobs/route.ts` | Presets should come from `image_style_presets`; `generation-jobs` increments `usage_count`; GET `/api/style-presets` should return active presets sorted by usage count. |
|
||||
| Reverse prompt option missing | `src/components/create/reverse-prompt-panel.tsx`, `src/app/api/generate/reverse-prompt/route.ts` | UI option list and server `outputMode` handling both updated, app rebuilt/restarted if deployed. |
|
||||
| Reverse prompt says `请先登录后再使用自定义 API` while the user is already logged in | `src/components/create/reverse-prompt-panel.tsx`, `src/lib/auth-store.ts`, `src/app/api/generate/reverse-prompt/route.ts`, `src/lib/server-api-config.ts` | The reverse-prompt fetch must send `Authorization: Bearer <accessToken>` from `readStoredAuth()`. The server resolves `customApiKeyId`/`systemApiId` through `getAuthenticatedUserId`, which reads the bearer token rather than browser localStorage. |
|
||||
| Reverse prompt keeps disappearing after refresh, relogin, or tab switch | `src/components/create/reverse-prompt-panel.tsx`, `src/lib/generation-job-client.ts`, `src/components/create/use-generation-job-recovery.ts`, `src/app/api/generation-jobs/route.ts`, `src/app/api/creation-history/route.ts` | Reverse prompt now uses the shared generation job queue and should recover queued/running jobs from `/api/generation-jobs`, keep the loading state alive until the worker actually finishes, and rely on the normal creation-history writeback when the worker completes. |
|
||||
| Reverse prompt reaches login successfully but then times out on upstream `chat/completions` | `src/app/api/generate/reverse-prompt/route.ts`, `src/lib/local-storage.ts`, `src/lib/custom-api-fetch.ts` | If the input is a data URL, persist it first and send the public `/api/local-storage/...` URL upstream instead of the raw blob. Reverse-prompt is a multimodal chat/completions request, so a 524 here means the upstream multimodal endpoint or its latency is the problem, not frontend auth or image-generation routing. |
|
||||
| Prompt optimization fails | `src/app/api/generate/suggest-prompt/route.ts`, `src/lib/server-api-config.ts`, `src/lib/custom-api-fetch.ts` | Text-capable system/custom API, chat response shape, JSON parsing fallback. This route also uses a multimodal chat/completions path, so 524 should be read as a multimodal upstream timeout rather than a synchronous image-generation failure. |
|
||||
|
||||
## Models, Providers, API Keys
|
||||
|
||||
| Symptom | Check Files | What To Verify |
|
||||
| --- | --- | --- |
|
||||
| Model list empty in create/profile | `src/app/api/model-config/route.ts`, `src/lib/model-config.ts`, `src/lib/managed-model-store.ts`, `src/lib/custom-api-store.ts` | Public model config response, admin recommendations, local client store mapping. |
|
||||
| Default model group shows raw API model name instead of the admin display name | `src/lib/model-display.ts`, `src/app/api/model-config/route.ts`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx` | Frontend system model labels should use `system_api_configs.name` first. `model_name` is the upstream request model identifier and should remain available for generation dispatch, but it should not override the admin-facing display name in the create-page default model group. |
|
||||
| Backend default models are configured but `/api/model-config` returns only `{"providers":[],"recommendations":[]}` or no `systemApis` | `src/app/api/model-config/route.ts`, `src/lib/server-api-config.ts`, production database owner/grants | Check PM2 logs for `must be owner of table system_api_configs`. After migration, runtime tables must be owned by the app DB user, or optional schema checks should not be allowed to empty the public model-config response. Fix ownership/grants first, then verify `/api/model-config` includes `systemApis`. |
|
||||
| System API saved but not used | `src/app/api/admin/system-apis/route.ts`, `src/lib/server-api-config.ts`, `src/app/api/generate/image/route.ts`, `src/app/api/generate/video/route.ts` | `systemApiId` in request payload, active config, decrypted key, type matches image/video/text, `is_default` is true, and `allowed_membership_tiers` includes the current user's normalized tier. For admin default image models, also verify same media type plus same admin display name (`system_api_configs.name`) polling candidates, `polling_mode`, and `polling_order`; `model_name` is only the upstream request model. User custom APIs should not enter this polling path. |
|
||||
| System default model generates successfully but user credits do not decrease | `src/app/api/generation-jobs/route.ts`, `src/lib/generation-job-worker.ts`, `src/lib/generation-credit-service.ts`, `src/lib/server-api-config.ts`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx` | Credit deduction must happen on the server after a successful system-default image/video generation, using the selected frontend `systemApiId` row in `system_api_configs` for pricing. Failed jobs must not deduct credits. New-job balance preflight should subtract queued/running system-default jobs for that user so rapid repeated submissions cannot overbook credits. Create buttons should not show predicted credits; completed result cards should show the `creditsCost` returned in the generation job result, and the profile balance should refresh from `creditsBalance`. |
|
||||
| User custom API saved but not used | `src/app/api/user-api-keys/route.ts`, `src/lib/custom-api-store.ts`, `src/lib/server-api-config.ts` | `customApiKeyId`, owner auth, encrypted key exists, `is_active`. |
|
||||
| Intelligent API dialog is too narrow, clipped, or shows only JSON | `src/components/profile/api-key-manager.tsx`, `src/components/ui/dialog.tsx` | Smart import dialogs must override the shared dialog's `sm:max-w-lg` with explicit wide sizing such as `w-[min(...)] max-w-none sm:max-w-none`, cap height to the viewport, and keep the JSON editor inside an internal scrollable/flexible area so title, actions, and footer remain visible. |
|
||||
| Intelligent API import creates wrong or mixed requests | `src/components/profile/api-key-manager.tsx`, `src/app/api/user-api-keys/smart-import/route.ts`, `src/lib/user-api-manifest.ts`, `src/lib/user-api-manifest-executor.ts`, `src/lib/server-api-config.ts`, `src/app/api/generate/image/route.ts`, `src/app/api/generate/video/route.ts` | Each imported profile/model must have its own `user_api_keys` row and `user-api-manifests/<userId>/<keyId>.json` file. Verify `manifest_path` on the selected `customApiKeyId`, not a user-level shared file. Imported edit forms should show a human-readable provider name and a non-empty API request URL derived from `profile.baseUrl + submit.path` only when the Manifest provides enough endpoint data; never invent an OpenAI default URL for a third-party relay document. Editing a key should preserve `manifest_path`; generation should execute the selected manifest before legacy custom API fallback. |
|
||||
| Create model dropdown shows many `导入的 API Key` entries | `src/lib/model-display.ts`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/profile/api-key-manager.tsx`, `src/app/api/user-api-keys/smart-import/route.ts` | These are user custom API key rows, not admin default models. Generic import placeholder notes must be ignored/cleared so labels show provider plus model or a real custom note plus model. Do not delete user custom API rows unless explicitly requested. |
|
||||
| Admin intelligent API import is missing or generated system models ignore Manifest | `src/components/admin/api-management-tab.tsx`, `src/app/api/admin/system-apis/smart-import/route.ts`, `src/app/api/admin/system-apis/route.ts`, `src/lib/server-api-config.ts`, `src/lib/user-api-manifest.ts`, `src/lib/user-api-manifest-executor.ts` | Admin imports must create one `system_api_configs` row per Manifest profile, write `system-api-manifests/<systemApiId>.json`, persist `manifest_path`, and resolve that path from the selected `systemApiId`. Imported rows still need API Key and pricing review before use. |
|
||||
| 元界 AI 同步后出现大量接口/参数名模型或模型行反复显示 Key | `src/app/api/admin/system-apis/yuanjie-capabilities/route.ts`, `src/lib/yuanjie-image-model-templates.ts`, `src/lib/yuanjie-video-model-templates.ts`, `src/lib/yuanjie-template-installer.ts`, `src/components/admin/api-management-tab.tsx` | 元界不应再从 `/v1/skills` 或 `/v1/skills/guide` 猜模型,也不应在 `智能配置 API` 页面暴露内置模板安装/同步入口。检查安装路由是否使用内置图片/视频模板、是否只删除当前媒体类型的 `provider = '元界 AI'` 行、是否创建 inactive rows and per-model Manifest files, and whether admins configure Key/pricing/usage modes/enablement per model through the system-default-model management flow. The admin list should not show repeated imported key placeholders, and the create page should show only documented controls from the selected template capabilities. |
|
||||
| 元界任务在元界后台成功但妙境报模型繁忙或接口路径不存在 | `src/lib/yuanjie-image-model-templates.ts`, `src/lib/yuanjie-video-model-templates.ts`, `src/lib/user-api-manifest-executor.ts`, `src/app/api/generate/image/route.ts`, `src/app/api/generate/video/route.ts` | Check whether the Manifest poll endpoint uses `path: "v1/media/status"` plus `query: { task_id: "{task_id}" }`. If the path is stored as `v1/media/status?task_id={task_id}`, the executor can encode the query string into the pathname and 元界 will return a not-found error even though the create request already produced a task. Also verify 元界 media templates use `finalPath: "is_final"`, `finalValues: [true]`, `statusPath: "state"`, `successValues: ["success"]`, and `failureValues: ["failed"]`; `status` / `status_group` are display fields only. |
|
||||
| 元界后台显示已生成图片但妙境任务失败,日志出现下载 403、timeout 或保存失败 | `src/app/api/generate/image/route.ts`, `src/lib/media-storage.ts`, `src/lib/remote-fetch.ts`, `src/lib/user-api-manifest-executor.ts` | 这通常不是元界提交或轮询失败,而是 Manifest 结果 URL 返回后,妙境下载外部图片或保存原图/缩略图失败。先查 PM2 日志中 `[User API Manifest Image] Failed to persist generated image`,区分 `下载图片失败: 403`、`fetch failed`、`Persist generated image media timed out`、对象存储/缩略图错误。外部生成图 URL 应通过 `fetchPublicHttpUrlWithRetry` 发送浏览器式 `User-Agent`/`Accept` 并有限重试;`/api/generate/image` 应返回“上游已返回生成结果,但平台下载或保存结果图片失败”,不要再误包装为“上游返回图片分辨率不符合”或泛化成模型繁忙。 |
|
||||
| Agnes 视频任务先显示 `in_progress` 后失败,错误为裸 `fetch failed` 或历史不写入 | `src/lib/agnes-model-templates.ts`, `src/lib/user-api-manifest-executor.ts`, `src/app/api/generate/video/route.ts`, `src/lib/generation-job-worker.ts` | 先区分提交、轮询、结果视频下载保存、历史写入四段。Agnes V2.0 使用 `POST /v1/videos` 和 `GET /agnesapi?video_id=...&model_name=agnes-video-v2.0`,`remixed_from_video_id` 在官方完成响应中是视频 URL。Manifest 执行器会把网络异常包装成“上游任务创建/轮询网络连接失败”;视频结果保存失败应显示“上游已返回视频地址,但平台下载或保存结果视频失败”。若 job 成功但 `works` 没有记录,查 `[generation-worker] creation history persistence failed` 中的内部 URL。Agnes 时长不要传 `duration`,当前仅开放稳定的 3/5/10 秒并映射为 `num_frames` 81/121/241,`frame_rate=24`;18 秒在生产中会进入轮询后返回上游 `failed`,应从能力列表隐藏并在后端拒绝旧请求。Agnes 视频是后台异步任务,`/api/generate/video` 给它单独 20 分钟轮询窗口,刷新/切页由 generation job 恢复链路继续显示状态。Manifest 总轮询预算要和单次请求超时分开,单次轮询 502/503/504、`fetch failed` 或网络超时应先更新任务进度并继续轮询,只有总预算耗尽才标记超时失败。 |
|
||||
| 元界图生图提交后妙境报 `Manifest 未能从 ... 读取任务 ID` or generic `模型繁忙` while 元界 may have accepted the job | `src/lib/server-api-config.ts`, `src/lib/user-api-manifest-executor.ts`, `src/lib/yuanjie-image-model-templates.ts`, `src/lib/yuanjie-video-model-templates.ts` | The submit response can put the task identifier inside nested `result` objects. The executor must normalize `task_id`, `taskId`, `id`, and nested `data/result/output` objects before polling. Template `taskIdPath` should include `result.task_id`, `result.taskId`, and `result.id` before the broad `result` fallback. For system default polling, `resolveSystemApiPollingCandidates(...)` must also run `ensureYuanjieSystemApiManifest(...)`; otherwise stale production `system-api-manifests/<id>.json` files can keep old `$inputImages.dataUrls` and old task-id paths even when source templates are fixed. |
|
||||
| 视频系统模型出现在错误入口或缺少参数选项 | `src/lib/server-api-config.ts`, `src/components/admin/api-management-tab.tsx`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx`, `src/lib/model-capabilities.ts` | Check `system_api_configs.video_usage_modes`. 文生视频 should only show rows including `text-to-video`; 图生视频 should only show rows including `image-to-video`. Selected system video models should read Manifest `capabilities` for aspect ratio, duration, and resolution controls. |
|
||||
| 管理后台刷新后跳回仪表盘 | `src/modules/console/pages/console-dashboard-page.tsx` | The active view should be restored from `sessionStorage` on refresh and removed on logout. If it jumps to dashboard after a plain refresh, inspect the session key `miaojing_console_active_view` and whether the view is still allowed by the current membership/admin config. |
|
||||
| 兑换码无法生成、重复、兑换后积分或会员不到账,或可重复兑换 | `src/components/admin/redeem-code-management-tab.tsx`, `src/app/api/admin/redeem-codes/route.ts`, `src/components/profile/credits-tab.tsx`, `src/app/api/redeem-codes/redeem/route.ts`, `src/lib/redeem-code-service.ts` | Codes should be generated server-side with unique `normalized_code`. Redemption must use a DB transaction with `FOR UPDATE` locks on `redeem_codes` and `profiles`, then mark `used_by/used_at`. Credit codes update `profiles.credits_balance` and insert a `credit_transactions` row. Membership codes update `profiles.membership_tier` plus `membership_expires_at`; duration units are `day`, `month`, and `year`. If the profile page is stale, inspect `/api/profile` refresh and `/api/credit-transactions`. |
|
||||
| 获取兑换码或会员升级不跳转商城 | `src/components/admin/redeem-code-management-tab.tsx`, `src/components/profile/credits-tab.tsx`, `src/app/profile/page.tsx`, `src/app/api/site-config/route.ts`, `src/lib/site-config.ts` | The admin redeem-code tab saves the shared mall URL as `site_config.redeem_code_mall_url` through `/api/site-config`. Frontend buttons read `siteConfig.redeemCodeMallUrl`; empty config should show a toast instead of navigating. Verify the DB column exists, `/api/site-config` returns `redeemCodeMallUrl`, and the URL starts with `http` or `https`. |
|
||||
| 邀请链接不生成、邀请注册未发积分、后台看不到邀请关系 | `src/components/profile/credits-tab.tsx`, `src/app/api/invitations/me/route.ts`, `src/app/auth/register/page.tsx`, `src/app/api/auth/register/route.ts`, `src/app/api/admin/invitations/route.ts`, `src/lib/invitation-service.ts` | `profiles.invite_code` must be unique and stable. Registration links use `/auth/register?invite=...`; successful invited registration writes `invitation_referrals`, sets `profiles.referred_by_user_id`, grants 50 credits to inviter and invitee, and writes `credit_transactions` rows in the registration transaction. |
|
||||
| New API image endpoint incompatible | `src/app/api/generate/image/route.ts`, `src/lib/custom-api-fetch.ts` | Provider is `newapi`/`new api`, endpoint normalization, model-specific size/count/quality handling. |
|
||||
| API key leaked in UI/API | `src/app/api/user-api-keys/route.ts`, `src/app/api/admin/system-apis/route.ts`, `src/lib/server-crypto.ts`, `src/lib/server-api-config.ts` | Response mapping must return preview/empty key only. |
|
||||
| Yuanjie GPT Image 2 image-to-image ignores reference images or behaves like text-to-image | `src/components/create/image-to-image.tsx`, `src/app/api/generate/image/route.ts`, `src/lib/user-api-manifest-executor.ts`, `src/lib/yuanjie-image-model-templates.ts` | Check whether the route reads `extraImages`, normalizes `image + extraImages` into Manifest `inputImages`, and whether Yuanjie templates use `$inputImages.urls` for `params.images`, top-level `images`, and `base64Array`. The executor should upload data URL references into storage and expose public references as `$inputImages.urls`; do not fix this by changing mozheAPI or generic OpenAI-compatible fallbacks. |
|
||||
|
||||
## Gallery And Creation History
|
||||
|
||||
| Symptom | Check Files | What To Verify |
|
||||
| --- | --- | --- |
|
||||
| History missing after generation or login/account switch | `src/lib/creation-history-store.ts`, `src/app/api/creation-history/route.ts`, `src/lib/generation-job-client.ts`, `src/components/create/use-generation-job-recovery.ts`, create panel component | History POST, `works` insert, URL not data URL except reverse prompt placeholder, and `miaojing_auth_updated` triggers a fresh server fetch. Create panels recover queued/running jobs from `/api/generation-jobs` so a refresh or re-login can reattach the live task before it finishes; they also store created job ids in per-user browser localStorage and query `/api/generation-jobs/[id]` for terminal `succeeded`/`failed`/`cancelled` jobs that finished while the browser was closed. Create-page history hooks intentionally request only the current mode and recent limit; if a create panel misses old records, check the API `mode` filter against `type`, `params.creationMode`, `params.workType`, `params.mode`, and legacy reference-image inference before raising the limit or reverting to full history. If the task card reappears after refresh but never turns into a result/error, inspect `src/components/create/use-generation-job-recovery.ts`; active-task state updates must not be part of the polling effect dependency list, or the recovery poller can be cancelled immediately after reattaching a job. |
|
||||
| Detail delete removes only local history, skips confirmation, or record reappears after refresh | `src/components/creation-detail-dialog.tsx`, `src/components/ui/alert-dialog.tsx`, `src/lib/creation-history-store.ts`, `src/app/api/creation-history/route.ts`, `src/components/profile/creation-history-tab.tsx` | The detail action is labeled `删除作品` and must open a confirmation dialog warning that deletion cannot be recovered. Logged-in deletion should call `DELETE /api/creation-history?id=...` first, then refresh local history from the server. Check bearer token availability and route ownership filter (`id` + `user_id`). |
|
||||
| Published work not in gallery or share to gallery is slow | `src/lib/creation-history-store.ts`, `src/lib/gallery-publish-media.ts`, `src/app/api/gallery/publish/route.ts`, `src/app/api/gallery/route.ts`, `src/app/gallery/page.tsx` | `is_public = true`, `status = completed`, stable `/api/local-storage/...` `result_url`, media copied/reused into gallery storage, and current filters. New generated `/api/local-storage/...` image/video URLs should use the publish fast path in `gallery-publish-media` and must not synchronously copy object-backed originals during share; external URLs still need copying and should fail the publish request if media preparation fails. Also check whether the browser marked the work shared before `/api/gallery/publish` returned success; local `published=true` without `publishedAt` is stale and should not block retry. For older incidents, inspect server logs/API status for publish failures that the previous frontend swallowed. |
|
||||
| 图生图/图生视频分享到画廊后看不到参考图,或复用/获取灵感没有带上参考图 | `src/components/create/image-to-image.tsx`, `src/components/create/image-to-video.tsx`, `src/app/api/gallery/publish/route.ts`, `src/lib/gallery-publish-media.ts`, `src/app/gallery/page.tsx`, `src/components/create/inspiration-gallery-dialog.tsx`, `src/lib/creation-reuse.ts` | The create panels should send `referenceImage`, `referenceImages`, `refImageCount`, and `referenceImageAnnotations` to `shareToGallery`. `/api/gallery/publish` should persist data URL or remote reference images into stable `/api/local-storage/gallery/references/...` URLs before storing them in `works.params`. Public gallery detail and inspiration detail may preview reference images but must not expose reference-image download actions; reuse drafts should prefer original `referenceImages` and only fall back to output media as reference when no references exist. |
|
||||
| Imported gallery images do not render after production data import | `src/app/api/admin/data-export/route.ts`, `src/app/api/admin/data-import/route.ts`, `src/lib/local-storage.ts`, `src/app/api/local-storage/[...path]/route.ts`, DB `works.result_url` | New exports should include `_media`; import should persist media through the active storage adapter. If using an older export without `_media`, DB rows alone cannot recreate missing `/api/local-storage/*` files. For object migration, run `pnpm run storage:sync-object -- --verify-only` before switching to `STORAGE_MODE=object`. |
|
||||
| Rainyun ROS bucket created but object storage still fails | `scripts/rainyun-ros-prepare.mjs`, `.env.local`, `src/lib/local-storage.ts`, `scripts/storage-sync-to-object.mjs`, `/api/health` | The Rainyun API link is control-plane bucket creation, not the media upload path. Verify `.env.local` has reviewed `OBJECT_STORAGE_BUCKET`, `OBJECT_STORAGE_ENDPOINT`, `OBJECT_STORAGE_ACCESS_KEY_ID`, `OBJECT_STORAGE_SECRET_ACCESS_KEY`, `OBJECT_STORAGE_FORCE_PATH_STYLE=true`, and `STORAGE_MODE=dual`; then run `/api/health` and `pnpm run storage:sync-object -- --dry-run`. |
|
||||
| Gallery delete does not remove public item | `src/app/api/gallery/route.ts`, admin UI route using it | DELETE unpublishes by setting `is_public = false`, not hard delete. |
|
||||
| Admin gallery prompt edit fails, sends no email, or prompt changes without audit trail | `src/components/admin/gallery-management-tab.tsx`, `src/app/api/admin/gallery/works/route.ts`, `src/app/api/admin/gallery/prompt/route.ts`, `src/lib/admin-gallery-prompt-service.ts`, `src/lib/email-service.ts`, `src/lib/platform-logs.ts` | Prompt moderation is console-only and requires a valid author email. `/api/admin/gallery/prompt` must send the email before updating `works.prompt`; SMTP failure, unchanged prompt, non-public work, or invalid author email should block the update. Platform logs should include reason key and prompt length metadata, not full prompt text. |
|
||||
| Admin gallery management pagination wrong or page buttons skip records | `src/components/admin/gallery-management-tab.tsx`, `src/app/api/admin/gallery/works/route.ts`, `src/lib/admin-gallery-works-pagination.ts` | The admin table uses `page` and `pageSize`; the route converts those to SQL `LIMIT/OFFSET` and still accepts legacy `limit/offset`. Verify `total`, `page`, `pageSize`, `totalPages`, `nextOffset`, and `hasMore` in the response, and reset page to 1 when search/type/page size changes. |
|
||||
| Search/filter/sort wrong | `src/app/api/gallery/route.ts`, `src/app/gallery/page.tsx` | Query params `type`, `category`, `limit`, `offset`, `sort`, `q/search`; SQL where/order, browser cache signature, and pagination append state. |
|
||||
| Gallery search box looks inconsistent with the rest of the UI | `src/app/gallery/page.tsx` | The search field is a custom glass panel with an inner focused input surface; avoid reverting it to a plain transparent input row. |
|
||||
| Gallery hover makes images muddy, covers the image with prompt text, shows only a single-color/static glow, has transparent gaps, does not match image colors, misses the card corners, moves too fast, looks too hard-edged, or action buttons disappear on dark/light images | `src/app/gallery/page.tsx`, `src/app/globals.css` | Gallery cards should not use a full-image dark hover overlay, center prompt text, transparent border gaps, generated unrelated colors, broad square glow under the card, or a separate outer halo layer. Keep hover feedback on the card container with scale plus a real `gallery-card-border-frame` wrapper using 3-5 sampled image colors in a single blurred 3px continuous clockwise border around the full work-card container, including all four corners and the prompt/footer area, and keep like/download buttons legible through sampled image brightness inversion. |
|
||||
|
||||
## Admin Console And Ops
|
||||
|
||||
| Symptom | Check Files | What To Verify |
|
||||
| --- | --- | --- |
|
||||
| Dashboard stats wrong | `src/modules/console/pages/console-dashboard-page.tsx`, `src/app/api/admin/dashboard/route.ts`, `src/app/api/admin/stats/route.ts` | SQL source tables, safe query fallbacks, admin auth. |
|
||||
| User management bug | `src/components/admin/user-management-tab.tsx`, `src/app/api/admin/users/route.ts`, `src/lib/admin-users-service.ts` | Role/tier mapping, active flag, admin auth, password reset, and row actions. Admin reset-password must be reachable directly from the user row, not only behind the edit modal, and should clear/close overlapping edit state before showing the reset form. `/api/admin/users` PUT with `newPassword` must upsert `auth.users.password_hash` with `crypt(..., gen_salt('bf'))` so missing auth rows cannot silently return success without a usable password. |
|
||||
| API/model management list opens for anonymous users or admin tab loads empty provider/recommendation rows | `src/components/admin/api-management-tab.tsx`, `src/app/api/admin/providers/route.ts`, `src/app/api/admin/model-recommendations/route.ts`, `src/lib/admin-auth.ts` | Provider and recommendation GET routes require admin auth just like mutations. The admin tab's initial `fetch('/api/admin/providers')` and `fetch('/api/admin/model-recommendations')` must include bearer auth headers; anonymous requests should return 401. |
|
||||
| Order/payment bug | `src/components/admin/order-management-tab.tsx`, `src/components/admin/payment-tab.tsx`, `src/app/api/admin/orders/route.ts`, `src/app/api/admin/payment-methods/route.ts`, `src/lib/server-payment-config.ts` | Payment config encryption, order status, request shape. |
|
||||
| Data import/export bug | `src/components/admin/data-management-tab.tsx`, `src/app/api/admin/data-export/route.ts`, `src/app/api/admin/data-import/route.ts`, `scripts/migration-integrity-check.mjs`, `scripts/migration-integrity-check-helpers.mjs` | JSON format, table coverage, admin auth, `_media` coverage, import transaction/savepoints, password hash/encrypted secret preservation, and work dedupe by URL/source URL/media SHA scoped to the same `user_id` so one user's private work cannot collapse into another user's row. Run `pnpm run migration:check` for the read-only migration gate. The checker should default to the web port 8000 and use bounded timeout/concurrency helpers for `/api/local-storage/*` probes so one slow or missing media URL is reported rather than crashing the whole script. |
|
||||
| Admin email send/settings bug, test email blank, or user notification email blank | `src/components/admin/settings-tab.tsx`, `src/app/api/admin/email-settings/route.ts`, `src/app/api/admin/send-email/route.ts`, `src/lib/email-service.ts` | SMTP config, template rendering, send logs, MIME multipart assembly, and body encoding. Keep both text/plain and text/html parts non-empty, preserve required MIME blank separator lines, and fold base64 body lines to MIME-safe lengths before the SMTP DATA terminator. |
|
||||
| Upgrade page stuck/fails | `src/components/admin/system-upgrade-tab.tsx`, `src/app/api/admin/upgrade/route.ts`, `scripts/admin-upgrade-runner.mjs`, `scripts/backup-create.sh`, `scripts/backup-restore.sh` | State dir, package limits, disk checks, backup validation, restore safety backup, PM2 restart command, stale status. |
|
||||
| Platform logs missing or system log page says loading failed | `src/components/admin/log-management-tab.tsx`, `src/app/api/admin/logs/route.ts`, `src/lib/platform-logs.ts`, routes that call `writePlatformLog` | The page loads `/api/admin/logs`; verify the route exists, admin bearer auth is valid, `ensurePlatformLogSchema()` runs before querying, log retention cleanup is not failing, and API calls actually write logs. |
|
||||
|
||||
## Storage, Download, Files
|
||||
|
||||
| Symptom | Check Files | What To Verify |
|
||||
| --- | --- | --- |
|
||||
| `/api/local-storage/...` 404 | `src/app/api/local-storage/[...path]`, `src/lib/local-storage.ts` | `STORAGE_MODE`, `LOCAL_STORAGE_DIR`, `OBJECT_STORAGE_*`, normalized key, and whether the object exists in local disk or bucket. Dual mode should fall back to local disk if object storage is missing a migrated key. |
|
||||
| Production `/api/local-storage/...` still buffers originals after deployment | `src/app/api/local-storage/[...path]/route.ts`, deploy rsync command | Confirm the live production route contains `generateObjectReadUrl(...)` and `NextResponse.redirect(...)`. If local code is correct but production is stale, check whether rsync used a broad `local-storage/` exclude; use `/local-storage/` for the repo-root runtime directory so `src/app/api/local-storage/[...path]/route.ts` is not skipped. |
|
||||
| Download says remote fetch failed | `src/app/api/download/route.ts`, `src/lib/remote-fetch.ts` | URL is http(s), same-origin, or local-storage; upstream reachable; timeout. |
|
||||
| Path traversal/security concern | `src/lib/local-storage.ts`, `src/app/api/download/route.ts`, `src/app/api/local-storage/[...path]/route.ts` | Keep `normalizeKey`, `path.resolve`, and `..` guards. |
|
||||
|
||||
## Deployment And Build
|
||||
|
||||
| Symptom | Check Files | What To Verify |
|
||||
| --- | --- | --- |
|
||||
| Build or type check fails | `package.json`, `scripts/build.sh`, `next.config.ts`, failing source file, `CODEX_MIAOJING_MEMORY.md` | First run `corepack pnpm install --frozen-lockfile` if dependency modules are missing. Current audited baseline already recorded failures for missing `@/lib/model-display` and canvas type errors. Distinguish pre-existing source errors from your docs/change. |
|
||||
| PM2 app not updated | `ecosystem.config.cjs`, `scripts/start.sh`, `scripts/deploy-or-upgrade.sh` | Process cwd, role ports, environment variables, `pm2 startOrReload ecosystem.config.cjs --update-env`. |
|
||||
| Production uses different checkout | `ecosystem.config.cjs`, PM2 process env/cwd | Always verify PM2 cwd before editing production. |
|
||||
| Production returns 502 after source sync or PM2 reload | `ecosystem.config.cjs`, `scripts/start.sh`, PM2 env, nginx upstream ports | Check whether production `ecosystem.config.cjs` was overwritten by a repo/dev copy. Live production on 2026-05-14 used `/opt/miaojingAI`, Node `/data/miaojingAI/node/node-v24.15.0-linux-x64/bin`, and ports `8000/8100/8200`; a repo copy pointing at `/root/miaojingAI` and `5000/5100/5200` breaks nginx until the production config is restored and PM2 reloaded. |
|
||||
| Upgrade package cleanup failed | `scripts/deploy-or-upgrade.sh`, `scripts/admin-upgrade-runner.mjs`, `src/app/api/admin/upgrade/route.ts` | Cleanup trap, backup paths, state dir, disk space guards. |
|
||||
| Unsure whether a change is safe for hot update or needs cold update | `scripts/admin-upgrade-runner.mjs`, `src/app/api/admin/upgrade/route.ts`, `src/components/admin/system-upgrade-tab.tsx`, `docs/codex-miaojing/architecture.md` | Treat source/API/server/dependency/schema/env/runtime/script changes as cold-update candidates. Hot updates should be static/public asset-only and must pass runner preflight without restart. Verify backup, rollback, package limits, disk checks, PM2 restart expectations, and `/api/health` before marking deploy-facing work complete. |
|
||||
|
||||
## Useful Search Patterns
|
||||
|
||||
```bash
|
||||
rg -n "export async function (GET|POST|PUT|DELETE)" src/app/api
|
||||
rg -n "writePlatformLog|console\\.error|console\\.warn" src
|
||||
rg -n "generation_jobs|works|profiles|site_config|system_api_configs" src scripts *.sql
|
||||
rg -n "localStorage|/api/local-storage|/api/download" src
|
||||
rg -n "requireAdmin|getAuthenticatedUser|getAuthenticatedUserId" src/app/api src/lib
|
||||
```
|
||||
54
docs/codex-miaojing/custom-integrations.md
Normal file
54
docs/codex-miaojing/custom-integrations.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Custom Integrations
|
||||
|
||||
Use this document before changing non-generic provider/platform behavior. If a user request includes a custom keyword such as `元界`, `mozheAPI`, or `智能配置 API`, first check long-term memory and the relevant rows in the Codex docs, then verify the current source/runtime before editing.
|
||||
|
||||
## Required Workflow
|
||||
|
||||
1. Search existing long-term memory and this docs folder for the exact custom keyword.
|
||||
2. Read the matching feature, bug, API, or architecture entry before touching code.
|
||||
3. Treat custom provider/platform behavior as a named integration boundary, not as generic fallback logic.
|
||||
4. Preserve the provider-specific contract unless the user explicitly changes that contract.
|
||||
5. When a fix reveals a new reusable rule, update this file or the matching Codex doc in the same change set.
|
||||
6. If the rule is durable across future sessions, write it to long-term memory instead of relying only on chat context.
|
||||
|
||||
## 元界 AI
|
||||
|
||||
- Start with `src/lib/yuanjie-image-model-templates.ts`, `src/lib/yuanjie-video-model-templates.ts`, `src/lib/yuanjie-template-installer.ts`, `src/lib/user-api-manifest-executor.ts`, and the selected create panel.
|
||||
- Built-in 元界 templates are not generic OpenAI-compatible models. Their manifests may map UI fields to provider-specific params such as `size`, `aspect_ratio`, `aspectRatio`, `imageSize`, `resolution`, `quality`, `images`, or task polling fields.
|
||||
- Built-in 元界 video media requests should stay in Manifest form and should not fall back to the generic OpenAI-compatible video parser. `/v1/media/generate` submit bodies use only `model`, `prompt`, and `params`; HappyHorse text-to-video specifically sends `params.resolution`, `params.ratio`, and `params.duration`, then reads `output.task_id` and polls `/v1/media/status?task_id=...`.
|
||||
- Some image models expose orientation through a `size`/`resolution` value instead of a separate aspect-ratio field. In those cases the create panel must derive the ratio from the selected option label or pixel dimensions, rather than requiring the user to write the ratio in the prompt.
|
||||
- 元界 media submit responses may return the task identifier under nested result objects such as `result.task_id`, `result.taskId`, or `result.id`. The Manifest executor must extract task IDs from those nested objects before polling `v1/media/status`.
|
||||
- If 元界后台 shows a successful image but MiaoJing marks the job failed, treat it as a result-media download/persistence issue before changing submit/poll config. Check `src/app/api/generate/image/route.ts`, `src/lib/media-storage.ts`, and `src/lib/remote-fetch.ts` for 403, timeout, object-storage, or thumbnail errors. Result URL fetches should use browser-like headers plus limited retry, and Manifest result persistence failures should be reported as platform download/save failures, not as image-resolution mismatch.
|
||||
- Do not add `自动` back to controls where the user explicitly asked for explicit manual choices. Image count should default to `1` when automatic inference is not part of the requested workflow.
|
||||
- Admin default models must use `system_api_configs.name` as the frontend display name, while `model_name` remains the upstream request model.
|
||||
- When 元界 is used as a system default model, credit deduction must still follow the selected `system_api_configs` row's pricing through the generation job backend. New-job balance preflight should include same-user queued/running system-default jobs. Image and video create UI should display only the completed job's returned `creditsCost` and refresh the profile balance from `creditsBalance`, not a separate predicted button cost. Failed jobs must not write consume transactions.
|
||||
|
||||
- Yuanjie image and video templates that accept reference media should render provider-facing references with `$inputImages.urls`, not `$inputImages.dataUrls`. The Manifest executor uploads data URL references into storage first, then exposes object/local-storage public URLs as `$inputImages.urls` while keeping raw data URLs available as `$inputImages.dataUrls` for multipart/file-upload manifests.
|
||||
- Yuanjie GPT Image 2 / GPT Image 2 official-transfer image-to-image must pass all frontend references. `src/components/create/image-to-image.tsx` sends the primary image as `image` and additional references as `extraImages`; `src/app/api/generate/image/route.ts` must normalize them into the Manifest `inputImages` array before calling `src/lib/user-api-manifest-executor.ts`.
|
||||
- Yuanjie video templates must stay in `src/lib/yuanjie-video-model-templates.ts` and map documented model-specific fields there instead of changing generic mozheAPI/custom-API request builders. Current documented special mappings include `sora-2.params.input_reference`, `wan2.6-cankaosheng.params.reference_urls`, `wan2.6-shouzheng.params.img_url`, `kling-v3-omni-shouweizhen.params.image/image_tail`, `happyhorse-r2v.params.ratio`, `grok-video-3.params.size`, and `veo3.1.params.generation_mode/enhance_prompt/enable_upsample`.
|
||||
- Existing admin-created 元界 system rows may have an empty `manifest_path` and a submit endpoint in `api_url`. `src/lib/yuanjie-system-manifest.ts` is responsible for exposing built-in frontend capabilities for those rows and repairing them at generation time by writing the missing Manifest and normalizing `api_url` to the 元界 base URL. This repair must remain scoped to 元界 provider/model-group checks and must not rewrite mozheAPI.
|
||||
- 元界价格/计费方式同步 is manual only. Admins trigger it from the `系统默认模型` provider view; the route is `/api/admin/system-apis/yuanjie-pricing` and the logic lives in `src/lib/yuanjie-pricing-sync.ts`. It updates only existing 元界 image/video rows, accepting provider spellings such as `元界 AI`/`元界AI` and `yuanjie-*` model groups, synchronizing `billing_mode` and a `元界计费同步` price note from local built-in template metadata. It must not delete, create, or rewrite mozheAPI rows, generic smart-import rows, API keys, Manifest paths, or administrator-entered numeric prices.
|
||||
|
||||
## Agnes AI
|
||||
|
||||
- Start with `src/lib/agnes-model-templates.ts`, `src/lib/agnes-template-installer.ts`, `src/app/api/admin/system-apis/agnes-capabilities/route.ts`, and `src/components/admin/api-management-tab.tsx`.
|
||||
- Agnes built-in templates belong to the `系统默认模型` management flow. Do not expose them as a generic `智能配置 API` import; keep one system API row per model and one independent `system-api-manifests/<systemApiId>.json` file for each image/video row.
|
||||
- The API base is `https://apihub.agnes-ai.com`. Image models `agnes-image-2.1-flash` and `agnes-image-2.0-flash` use `POST /v1/images/generations` with `model`, `prompt`, `size`, and optional top-level `image: string[]` for image-to-image. URL output must be requested as `extra_body.response_format = "url"`; do not put `response_format` at the top level. Read `data.*.url`, with `data.*.b64_json` as a fallback.
|
||||
- Video model `agnes-video-v2.0` uses `POST /v1/videos` to create an async task and `GET /agnesapi?video_id={video_id}&model_name=agnes-video-v2.0` to poll. Treat `video_id`, `task_id`, or `id` as the task identifier, `completed` as success, `failed` as failure, and read the final video from `remixed_from_video_id`, `video_url`, or `url`.
|
||||
- Agnes Video duration is controlled by `num_frames`, not a `duration` request field. The create route currently exposes only production-stable UI durations 3/5/10 seconds, maps them to the documented 24fps frame counts 81/121/241, and sends `frame_rate: 24`. Do not re-enable 18 seconds from stale Manifests or older docs until production evidence shows Agnes no longer returns upstream `failed` for that length; backend requests for 18 seconds should be rejected quickly with a clear user-facing message.
|
||||
- Agnes Video generation can spend minutes in the async task. Keep the Manifest total polling budget separate from per-request submit/poll timeouts, and treat single poll-side 502/503/504, `fetch failed`, or network timeouts as transient until the total budget expires.
|
||||
- For image-to-video, Agnes uses the top-level `image` field as the starting/first frame. Do not treat Agnes Video as a generic multi-reference video model unless Agnes adds a separate reference-image field.
|
||||
- Text/multimodal models `agnes-2.0-flash` and `agnes-1.5-flash` use OpenAI-compatible `POST /v1/chat/completions`; they do not need Manifest files and can be used by prompt optimization or reverse prompt through the existing system text API path.
|
||||
- The installer creates Agnes rows as inactive, 0-credit templates with empty API Key fields so admins can fill the Key in the existing system API edit form, review visibility/member scope, and then enable the model.
|
||||
|
||||
## mozheAPI
|
||||
|
||||
- Start with `src/proxy.ts` for iframe/embed failures before changing page components.
|
||||
- Third-party embedding depends on CSP `frame-ancestors` and `X-Frame-Options`; `SAMEORIGIN` blocks external parents even if app pages render correctly.
|
||||
- Keep the allowlist explicit. Do not globally weaken security headers for unrelated origins.
|
||||
|
||||
## 智能配置 API
|
||||
|
||||
- User-level manifests must stay one model/key row to one JSON Manifest file. Do not merge multiple request configs under one user-level shared file.
|
||||
- Admin global default manifests can be shared through system API configuration, but generation must still resolve the selected model row and charge according to that model's pricing rules.
|
||||
- Imported rows should preserve `manifest_path`, provider/model display metadata, and per-model request templates.
|
||||
173
docs/codex-miaojing/feature-code-index.md
Normal file
173
docs/codex-miaojing/feature-code-index.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# Feature Code Location Index
|
||||
|
||||
Last source audit: 2026-05-20, based on git commit `632c94b`.
|
||||
|
||||
Use this document to jump directly to code before broad searching.
|
||||
|
||||
## Global Application Shell
|
||||
|
||||
| Feature | Primary Files | Notes |
|
||||
| --- | --- | --- |
|
||||
| Root layout and providers | `src/app/layout.tsx`, `src/components/app-shell.tsx`, `src/app/globals.css` | App shell wires navbar, site config sync, visit tracking, theme/account sync, toaster, full-width page mounting, and transient scrollbar visibility. Keep product content at the original component scale; use centered responsive containers instead of stretching all content to viewport edges. Global scrollbars are hidden by default and briefly show the rounded glass style when wheel/touch scrolling adds `scrollbars-visible` on `<html>`. |
|
||||
| Home page | `src/app/page.tsx` | Landing/dashboard-like public entry. Check site config dependencies when changing brand text. |
|
||||
| Navbar | `src/components/navbar.tsx`, `src/components/site-brand.tsx` | Navigation, brand display, auth-aware links. User-facing nav should not include removed feature routes. Logged-in desktop/mobile user buttons should show `AuthUser.avatarUrl` first and fall back to the display nickname initial only when the avatar is missing or fails to load. Avoid eager all-route `router.prefetch(...)` from the navbar; on production web it can compete with visible route/resource loading. The initial logged-in profile refresh is idle and one-shot so it does not compete with the first route transition. Mobile bottom navigation must render as a sibling after the sticky header, not inside it, because header backdrop/filter contexts can trap `position: fixed` children away from the viewport bottom. |
|
||||
| Footer | `src/components/site-footer.tsx` | Uses site config for policy/help/about links and filing text; footer background spans browser width while inner content keeps the original `max-w-7xl` scale. |
|
||||
| Announcement popup | `src/components/announcement-popup.tsx`, `src/app/api/announcements/route.ts`, `src/app/globals.css` | Frontend popup behavior plus backend announcement CRUD. Desktop dialog is intentionally wide (`max-w-5xl`) for long Markdown notices; scrollbar styling is inherited from the global glass scrollbar rules. |
|
||||
| Site config sync | `src/components/site-config-sync.tsx`, `src/lib/site-config.ts`, `src/app/api/site-config/route.ts` | Site name, tab title, logo, favicon, policy Markdown, filing, membership switch. `useSiteConfig()` keeps a shared browser snapshot, skips network refresh while that snapshot is still within the 5-minute TTL, and reuses the in-flight `/api/site-config` refresh so global consumers such as navbar, footer, site-brand, and policy pages do not each create their own concurrent config request during navigation. The API route runs legacy schema/default compatibility checks once per server process and retries on failure. |
|
||||
| Visit tracking | `src/components/visit-tracker.tsx`, `src/app/api/site-stats/route.ts` | Public visit counter. The client posts with `keepalive` after browser idle time so statistics collection does not block first paint or navbar route transitions. |
|
||||
| Security headers and iframe embedding | `src/proxy.ts`, `.env.example` | CSP is set in the Next proxy. `frame-ancestors` controls which external platforms may embed MiaoJing in an iframe; `MIAOJING_FRAME_ANCESTORS` can override the default self + mozheAPI allowlist. When external ancestors are allowed, do not send `X-Frame-Options: SAMEORIGIN`, because it blocks third-party iframes. |
|
||||
|
||||
## Public Pages
|
||||
|
||||
| Route | Primary Files | Supporting Files |
|
||||
| --- | --- | --- |
|
||||
| `/` | `src/app/page.tsx` | `src/components/app-shell.tsx` |
|
||||
| `/create` | `src/app/create/page.tsx` | `src/components/create/*` |
|
||||
| `/gallery` | `src/app/gallery/page.tsx` | `src/lib/creation-history-store.ts`, `src/app/api/gallery/route.ts` |
|
||||
| `/image-viewer` | `src/app/image-viewer/page.tsx` | Fullscreen original-image share page opened from image right-click share links. |
|
||||
| `/profile` | `src/app/profile/page.tsx` | `src/components/profile/*`, `src/app/api/profile/route.ts` |
|
||||
| `/about` | `src/app/about/page.tsx` | `src/components/site-policy-page.tsx`, `src/lib/site-policy-defaults.ts` |
|
||||
| `/terms` | `src/app/terms/page.tsx` | `src/components/site-policy-page.tsx` |
|
||||
| `/privacy` | `src/app/privacy/page.tsx` | `src/components/site-policy-page.tsx` |
|
||||
| `/help` | `src/app/help/page.tsx` | `src/components/site-policy-page.tsx` |
|
||||
|
||||
## Auth And User Account
|
||||
|
||||
| Feature | Primary Files | Notes |
|
||||
| --- | --- | --- |
|
||||
| Login UI | `src/app/auth/login/page.tsx` | Calls `/api/auth/login`. |
|
||||
| Register UI | `src/app/auth/register/page.tsx`, `src/components/auth/registration-agreement-dialog.tsx` | Requires accepted terms and email code except admin invite path. |
|
||||
| Auth store | `src/lib/auth-store.ts` | Client auth state and token persistence. `AuthUser.username` is the login username from `profiles.nickname`; `AuthUser.nickname` is the public display nickname from `profiles.display_nickname`. |
|
||||
| Session tokens | `src/lib/session-auth.ts` | HMAC token format, bearer parsing, admin checks. |
|
||||
| Login API | `src/app/api/auth/login/route.ts` | Handles admin fallback and normal users. |
|
||||
| Register API | `src/app/api/auth/register/route.ts` | Creates `auth.users`, `profiles`, initial credits, random Chinese display nickname, and default 3D cartoon avatar. The submitted register `nickname` is treated as login username for compatibility. |
|
||||
| Admin exists | `src/app/api/auth/admin-exists/route.ts` | Admin setup checks. |
|
||||
| API test/model fetch | `src/app/api/auth/test-api/route.ts`, `src/app/api/auth/fetch-models/route.ts` | Used by provider/API configuration UI. |
|
||||
| Profile API | `src/app/api/profile/route.ts`, `src/app/api/profile/theme/route.ts` | Profile edits, password/email/theme. |
|
||||
|
||||
## Creation Center
|
||||
|
||||
| Feature | Primary Files | Server/API Files |
|
||||
| --- | --- | --- |
|
||||
| Tab container | `src/app/create/page.tsx` | Owns the five creation tabs. Active tab is persisted in localStorage and mirrored to `/create?type=...`, so refreshes and shared links stay on text-to-image, image-to-image, text-to-video, image-to-video, or reverse-prompt. Keep the five primary creation panels statically imported in this page: production users switch between these modes constantly, and `ssr:false` dynamic splitting adds visible chunk waits and fallback flashes on direct web access. On phones the mode switch is the single fixed icon row below the navbar; the page title and duplicate text mode strip are hidden. Mobile layout classes in this page and `src/app/globals.css` turn the create center into a chat-style flow for all five modes: history/status cards render above the fixed composer, hidden desktop result/history regions should not keep loading large media, and `src/components/create/mobile-creation-composer.tsx` owns the bottom send surface with compact params, optional styles/references, prompt or custom input, and right send button. The mobile thread reserves the measured composer height through `--create-mobile-composer-height` so fixed-bottom input does not cover prompts or previews. |
|
||||
| Text to image | `src/components/create/text-to-image.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`, `src/components/create/use-generation-job-recovery.ts`. The create button remains available while other active tasks run; duplicate in-flight submissions are still blocked by `activeSubmissionSignaturesRef`. Active jobs render through `src/components/create/generation-task-list.tsx` inside the results column and expose a cancel action that calls `PATCH /api/generation-jobs/[id]`. Model select items use `src/components/create/grouped-model-select-items.tsx` so admin global system models appear under `默认模型` and user-added keys appear under `自定义模型`. Selected model capabilities from `src/lib/model-capabilities.ts` can hide unsupported aspect ratio/resolution/format/quality controls as well as filter their options, which is required for built-in 元界 image templates such as GPT Image 2 where the docs expose `size` pixel values instead of a separate aspect-ratio control. It consumes reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery text-to-image works can fill prompt, negative prompt, model, ratio, resolution, format, quality, count, style, and guidance into the form. The create-panel history hook must stay scoped, e.g. `useCreationHistory({ mode: 'text2img', limit: 60 })`, so opening `/create` does not download the user's full history payload. The mobile conversation history should only mount on mobile viewports; CSS-hidden mobile history still runs image effects if mounted on desktop. |
|
||||
| Image to image | `src/components/create/image-to-image.tsx`, `src/components/create/reference-image-mention-controls.tsx`, `src/components/reference-preview-image.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`, `src/components/create/use-generation-job-recovery.ts`, `src/lib/reference-image-prompt.ts`, `src/lib/reference-image-storage.ts`. Reference thumbnails single-click into a bare image overlay and use lightweight local preview rendering instead of painting the full uploaded data URL in every card. Active jobs render through `src/components/create/generation-task-list.tsx`, and the create button remains available while active tasks exist; identical in-flight submissions are still deduped. Model select items use `src/components/create/grouped-model-select-items.tsx` for `默认模型` versus `自定义模型` grouping. Selected model capabilities from `src/lib/model-capabilities.ts` can hide unsupported aspect ratio/resolution/format/quality controls as well as filter their options, which is required for built-in 元界 image templates such as GPT Image 2 where the docs expose `size` pixel values instead of a separate aspect-ratio control. 图生图 removes `自动` from ratio/resolution/count controls, defaults count to `1`, and derives ratio from Yuanjie size labels or dimensions when the selected model hides the separate ratio control. It consumes reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery image-to-image works can place reference images and fill prompt, negative prompt, model, ratio, resolution, format, quality, count, style, and strength into the form. 多参考图会显示 `@参考图1` 等标签,提示词输入框输入 `@` 可选择参考图,提交时发送 `referenceImageAnnotations`,后端把 token 与上传顺序、文件名、尺寸写入上游 prompt;创作历史会把 data URL/远程参考图持久化到 `works/references` 并写入 `referenceImageThumbnails`,分享到画廊会携带所有参考图和标注。 Mobile uses the fixed composer with an upload/reference thumbnail strip, mention-aware prompt input, compact ratio/resolution/format/count controls, and a mobile-only status/history flow. |
|
||||
| Text to video | `src/components/create/text-to-video.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/video/route.ts`, `src/components/create/use-generation-job-recovery.ts`. The create button remains available while active tasks exist, active jobs render through `src/components/create/generation-task-list.tsx`, running tasks can be cancelled, and model select items use `src/components/create/grouped-model-select-items.tsx` for `默认模型` versus `自定义模型` grouping. It consumes video reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery text-to-video works can fill prompt, negative prompt, model, ratio, duration, camera movement, and style. Mobile uses the fixed composer with compact ratio/duration/resolution/camera controls, a horizontal style strip, and thumbnail/placeholder history cards instead of preloading hidden desktop videos. |
|
||||
| Image to video | `src/components/create/image-to-video.tsx`, `src/components/create/reference-image-mention-controls.tsx`, `src/components/reference-preview-image.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/video/route.ts`, `src/components/create/use-generation-job-recovery.ts`, `src/lib/reference-image-prompt.ts`, `src/lib/reference-image-storage.ts`. Uploaded reference thumbnails single-click into the same bare image overlay used by image-to-image and use lightweight local preview rendering. Active jobs render through `src/components/create/generation-task-list.tsx`, the create button remains available while active tasks exist, and running tasks can be cancelled. Model select items use `src/components/create/grouped-model-select-items.tsx` for `默认模型` versus `自定义模型` grouping. It consumes video reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery image-to-video works can place reference images and fill prompt, negative prompt, model, ratio, duration, and camera movement. 多参考图会显示 `@参考图1` 等标签,提示词输入框输入 `@` 可选择参考图,提交时发送 `referenceImageAnnotations`,后端把 token 与上传顺序、文件名、尺寸写入上游 prompt;创作历史会把 data URL/远程参考图持久化到 `works/references` 并写入 `referenceImageThumbnails`,分享到画廊会携带所有参考图和标注。 Mobile uses the fixed composer with an upload/reference thumbnail strip, mention-aware prompt input, compact video controls, and thumbnail/placeholder history cards instead of preloading hidden desktop videos. |
|
||||
| Reverse prompt | `src/components/create/reverse-prompt-panel.tsx` | `src/app/api/generate/reverse-prompt/route.ts`, `src/app/api/generate/suggest-prompt/route.ts`, `src/lib/generation-job-client.ts`, `src/components/create/use-generation-job-recovery.ts`. Reverse prompt now runs as a background job, survives refresh/auth change/tab switch, and writes the completed result back into the normal creation history flow instead of relying on an optimistic local-only row. Mobile uses the fixed composer custom input slot for upload/change-image, prompt mode, language controls, and a mobile status/result/history flow. |
|
||||
| Prompt textarea | `src/components/create/expandable-prompt-textarea.tsx` | Shared prompt input. |
|
||||
| Mobile creation composer | `src/components/create/mobile-creation-composer.tsx`, `src/app/globals.css` | Mobile-only fixed bottom composer used by all five creation panels to match chat-style clients: top parameter strip with compact dropdown buttons, optional reference/upload strip, optional style strip, default prompt input or a custom input slot, and right send button. It uses `ResizeObserver` to publish `--create-mobile-composer-height` on the nearest `.create-chat-layout`; `.create-chat-thread` must keep a matching bottom padding so the fixed composer stays at the bottom without covering prompts, references, or status cards. The mobile creation center uses one 16px UI font size across selected values, style chips, composer input, and conversation prompts. The mobile text/image parameter strips hide long labels and remove `自动` from compact ratio/resolution/count choices where the panel already defaults to explicit values. The mobile style strip shows only one horizontal row when collapsed and expands upward for search/more presets after tapping `展开`; video styles use a horizontal chip strip. Mode selection stays only in the sticky header tabs. Desktop creation forms remain the source for full advanced controls, while mobile result/history/status flows mount only on mobile viewports. |
|
||||
| Image count input/dropdown | `src/components/create/image-count-combobox.tsx` | Shared compact count control for manual image count entry and common dropdown options. |
|
||||
| Style presets | `src/components/create/style-preset-selector.tsx`, `src/lib/style-presets-client.ts`, `src/app/api/style-presets/route.ts`, `src/lib/style-preset-store.ts`, `src/lib/model-config.ts` | Style presets are stored in `image_style_presets`, seeded from defaults, sorted by `usage_count`, and incremented from image generation jobs. The selector exposes stable `.style-preset-selector` and `.style-preset-list` classes so mobile create CSS can show a one-row collapsed strip and an expanded list of at least several rows inside the bottom composer. |
|
||||
| Loading/error panels | `src/components/create/generation-loading-panel.tsx`, `src/components/create/generation-task-list.tsx`, `src/components/create/generation-error-panel.tsx` | Shared generation status UI. `generation-task-list` keeps multiple active job cards constrained to the results column, exposes cancel buttons for normal active tasks, and image/video create panels render active tasks plus completed result cards together so earlier finished jobs do not disappear while later jobs keep running. |
|
||||
| Creation reuse drafts | `src/lib/creation-reuse.ts`, `src/app/create/page.tsx`, `src/components/create/inspiration-gallery-dialog.tsx` | Shared localStorage/event bridge used by detail, reverse-prompt, gallery, and inspiration actions to prefill create panels. It supports `text2img`, `img2img`, `text2video`, and `img2video` draft keys/events; `/create?type=...` changes the active tab after navigation, so callers can route directly to the matching creation mode. If a reuse action intentionally uses a generated output as a new reference image, it must use the original `url` rather than `thumbnailUrl`; thumbnails are display-only and must not be sent back into image-to-image or image-to-video generation. The inspiration dialog filters to the current mode, keeps per-card mode labels hidden, and offers a fuzzy search box that animates leftward from the header search icon; empty searches auto-collapse after the pointer leaves the search control for 1 second, while non-empty searches stay open until the dialog closes. |
|
||||
| Lightbox/fullscreen/detail actions | `src/components/lightbox.tsx`, `src/components/fullscreen-preview.tsx`, `src/components/creation-detail-dialog.tsx`, `src/components/image-actions-context-menu.tsx`, `src/components/image-metadata-badge.tsx`, `src/app/image-viewer/page.tsx`, `src/components/create/cached-preview-image.tsx` | Image cards, detail images, reference thumbnails, and generation results should enter fullscreen preview on single click, not double-click. Detail and fullscreen images use the shared right-click image action menu for copy, download, edit-to-image-to-image, and share; these actions must receive the original image URL, not thumbnails or cached display blobs. Fullscreen/lightbox components can receive a thumbnail fallback to display immediately while the original object-storage URL loads. Share copies a `/image-viewer?url=...` full-display link for the original image. Delete work must use a confirmation dialog warning that deletion cannot be recovered before calling the server delete path. Image previews show actual natural resolution and computed aspect ratio in the upper-right metadata badge; detail dialogs must pass stored `width`/`height` with `loadMetadata={false}` so the badge does not fetch the original image just to compute size. `BareImagePreview` is the no-container overlay for uploaded reference image previews. `CachedPreviewImage` generates same-origin cached previews and proxies cross-origin historical URLs through `/api/download?disposition=inline` to avoid browser CORS failures. |
|
||||
|
||||
## Generation System
|
||||
|
||||
| Responsibility | Primary Files | Notes |
|
||||
| --- | --- | --- |
|
||||
| Client-side job polling | `src/lib/generation-job-client.ts` | Create/poll jobs from create panels. Active-job recovery skips anonymous list polling and reuses same-token, same-type list requests briefly, so refresh/auth-change recovery does not add duplicate `/api/generation-jobs` pressure while tasks keep polling individually until success/failure. Created job ids are also stored per logged-in user in browser localStorage until the job reaches `succeeded`, `failed`, or `cancelled`; `useGenerationJobRecovery` merges that pending list with server queued/running jobs and queries `/api/generation-jobs/[id]` so jobs that finish while the browser is closed can still reappear with their terminal result/error before being cleared. |
|
||||
| Job creation API | `src/app/api/generation-jobs/route.ts` | Inserts `generation_jobs`, starts worker, increments selected image style preset usage, and preflights system-default-model credit balance through `src/lib/generation-credit-service.ts`, including queued/running system-default jobs already waiting for the same user. Active queued/running jobs are semantically deduped while ignoring top-level `clientRequestId`, so a double-click or fast retry returns the existing job instead of creating a second one. |
|
||||
| Job status API | `src/app/api/generation-jobs/[id]/route.ts` | Owner/admin visibility, stale running job handling. |
|
||||
| Worker loop | `src/lib/generation-job-worker.ts` | Picks and processes queued jobs. After successful system default image/video generation, it calls `src/lib/generation-credit-service.ts` to deduct credits from `profiles.credits_balance`, insert `credit_transactions`, and add `creditsCost`/`creditsBalance` to the job result for frontend display. Failed generation jobs do not enter the charge path. |
|
||||
| Internal runner | `src/lib/generation-job-runner.ts` | Calls `/api/generate/image` or `/api/generate/video` with internal headers. |
|
||||
| ETA/progress | `src/lib/generation-job-estimates.ts` | Runtime schema, ETA samples, progress payload. |
|
||||
| Image route | `src/app/api/generate/image/route.ts`, `src/lib/reference-image-prompt.ts`, `src/lib/layout-composition-skill.ts` | SDK + custom/system API + New API image compatibility, persistence. New image originals persist through `src/lib/media-storage.ts` into object storage, while local WEBP thumbnails are returned as `thumbnails`/`thumbnailUrls` for preview rendering and `dimensions` maps each original URL to persisted width/height so history detail metadata can avoid loading originals. Generated image originals are normalized to the user-selected output format before upload, so providers that ignore `output_format` and return PNG still produce `.jpg`/`.webp` objects when JPEG/WebP was requested. When `site_config.image_composition_skill_enabled` is true, `src/lib/layout-composition-skill.ts` deterministically selects one of the 100 CC BY 4.0 `nevertoday/100-layout-compositions` references and appends composition guidance before style prompts and upstream requests; it should not add text, logos, or literal poster elements. For admin default system models, image generation resolves all same-type/same-display-name default API candidates, automatically retries stream-timeout failures once with `stream:false`, and returns actionable upstream timeout/gateway messages when all candidates fail. If a Manifest provider such as 元界 returns result URLs but MiaoJing cannot download or save them, the route reports a platform download/save failure instead of a resolution mismatch. User custom APIs remain single-config and do not use this polling fallback. For image-to-image, optional `referenceImageAnnotations` are merged into the model prompt so `@参考图N` maps to the corresponding uploaded reference image. |
|
||||
| Video route | `src/app/api/generate/video/route.ts`, `src/lib/reference-image-prompt.ts` | SDK + custom/system API video, persistence. Generated video data URLs and upstream video URLs are persisted through `localStorage.uploadFileObjectOnly(...)` under `generated/videos`, so production video originals live in object storage when configured. Video create panels must use backend returned `creditsCost`/`creditsBalance` after job success; they should not locally predict or deduct credits. For image-to-video, optional `referenceImageAnnotations` are merged into the model prompt so `@参考图N` maps to the corresponding uploaded reference image. |
|
||||
| Custom API transport | `src/lib/custom-api-fetch.ts`, `src/lib/custom-image-fallback.ts` | Headers, one retry for 502/503/504 gateway failures, progress JSON parsing, upstream error parsing, stream-to-sync fallback policy for system image APIs. |
|
||||
| Server API resolution | `src/lib/server-api-config.ts`, `src/lib/yuanjie-system-manifest.ts` | Resolves user custom API and admin system API IDs into decrypted credentials, enforces system API default visibility plus membership-tier allowlists before generation, and builds default-model polling candidates by media type plus admin display name (`system_api_configs.name`). For known 元界 system rows with missing or stale `manifest_path`, both direct system API resolution and default-model polling candidates can rewrite the built-in Manifest and normalize `api_url` to the 元界 base URL before generation. The upstream `model_name` remains the per-provider request model only. |
|
||||
| User API smart import | `src/components/profile/api-key-manager.tsx`, `src/app/api/user-api-keys/smart-import/route.ts`, `src/lib/user-api-manifest.ts`, `src/lib/user-api-manifest-executor.ts`, `src/lib/model-capabilities.ts`, `src/lib/model-display.ts` | The profile API settings page has an `智能配置 API` button next to `添加 API 密钥`. It opens a wide viewport-capped Manifest editor, can copy the LLM prompt, shows guidance under the prompt button explaining the copy-to-chat-AI and paste-and-import flow, can paste clipboard JSON without importing, and can paste-and-import in one action. The prompt instructs the LLM to stop and ask the user for the relay API Base URL when the docs do not contain it. Imports create each profile/model as an independent `user_api_keys` row plus a separate `user-api-manifests/<userId>/<keyId>.json` file and reject incomplete configs without a resolvable request URL. Imported rows should store a human-readable provider name in the editable provider/supplier fields and resolve the visible API request URL from `profile.baseUrl + submit.path` for synchronous endpoints. Generic placeholder notes such as `导入的 API Key` must not be used as model labels; creation/profile UI should prefer a real note plus model, or provider plus model. Optional `profile.capabilities` filters or hides create-page aspect ratio, resolution, image format, and quality controls for the selected model. Polling Manifest query values can include `{task_id}` so task IDs are sent as real query parameters rather than being embedded into pathname strings. Generation routes must use the selected model key's `manifest_path`; do not merge different request configs under one user-level file. |
|
||||
| Admin system API smart import | `src/components/admin/api-management-tab.tsx`, `src/app/api/admin/system-apis/smart-import/route.ts`, `src/app/api/admin/system-apis/route.ts`, `src/lib/server-api-config.ts`, `src/lib/user-api-manifest.ts`, `src/lib/user-api-manifest-executor.ts`, `src/lib/model-capabilities.ts` | The console API management page has a separate `智能配置 API` section for admins, but this section is generic Manifest import only. It supports copy-to-chat-AI and paste-and-import Manifest flow, then creates one independent system API row and `system-api-manifests/<systemApiId>.json` file per imported profile/model. Imported rows resolve the visible API request URL from the Manifest profile/provider before save, and optional `profile.capabilities` can constrain or hide create-page image/video parameter choices for the selected system model. Provider-specific built-in template management, including 元界 AI and Agnes AI, belongs in the `系统默认模型` management flow and should not be exposed in the smart import UI. 元界价格/计费方式手动同步 uses `src/app/api/admin/system-apis/yuanjie-pricing/route.ts` and `src/lib/yuanjie-pricing-sync.ts`; it updates only existing 元界 image/video rows, tolerates provider spellings such as `元界AI`, and leaves mozheAPI/global smart-import configs untouched. |
|
||||
| Admin console active page persistence | `src/modules/console/pages/console-dashboard-page.tsx` | The console active view is stored in `sessionStorage`, so browser refresh keeps the current admin page/tab. Logout clears the value, and closing/reopening the console starts from the dashboard because `sessionStorage` is tab-scoped. |
|
||||
| Manifest input image URLs | `src/lib/user-api-manifest-executor.ts`, `src/app/api/generate/image/route.ts`, `src/app/api/generate/video/route.ts`, `src/lib/reference-image-prompt.ts` | Manifest templates can use `$inputImages.dataUrls` for raw uploaded data and `$inputImages.urls` for provider-facing public references. The executor converts data URL input images into storage-backed URLs before rendering templates. Image-to-image and image-to-video generation normalize the primary `image` plus `extraImages`/`images` into Manifest `inputImages`, so multi-reference providers such as Yuanjie GPT Image 2 receive all references. `referenceImageAnnotations` are not a Manifest variable; routes fold them into `$prompt` before execution so existing templates inherit the mapping. |
|
||||
|
||||
## Models And Providers
|
||||
|
||||
| Feature | Files | Notes |
|
||||
| --- | --- | --- |
|
||||
| Built-in model options | `src/lib/model-config.ts`, `src/lib/model-config-types.ts` | Image/video model lists, ratios, sizes, inference helpers, and fallback style preset seed labels. Runtime style ordering comes from DB. |
|
||||
| Public model config API | `src/app/api/model-config/route.ts`, `src/app/api/style-presets/route.ts` | Returns model/provider config plus DB-backed image style presets for clients. |
|
||||
| User custom API keys | `src/lib/custom-api-store.ts`, `src/app/api/user-api-keys/route.ts`, `src/components/profile/api-key-manager.tsx` | User-owned encrypted API credentials. |
|
||||
| Admin provider presets | `src/app/api/admin/providers/route.ts`, `src/components/admin/api-management-tab.tsx` | Provider registry, default API URL/model/type. Reads and mutations require admin bearer auth; the admin tab must send `Authorization` for the initial list fetch too. |
|
||||
| Admin system API configs | `src/components/admin/api-management-tab.tsx`, `src/app/api/admin/system-apis/route.ts`, `src/app/api/admin/system-apis/agnes-capabilities/route.ts`, `src/lib/server-api-config.ts`, `src/lib/yuanjie-system-manifest.ts`, `src/lib/agnes-model-templates.ts`, `src/lib/agnes-template-installer.ts` | Encrypted shared system API credentials, pricing metadata, platform-default visibility, per-model membership-tier allowlists, and default-model polling fields (`polling_mode`, `polling_order`). The admin list browses system models by provider, then model type, then individual model rows for editing. When browsing 元界 AI, admins can manually click `同步元界价格` to call `/api/admin/system-apis/yuanjie-pricing`, which syncs billing mode and a 元界 pricing note from built-in template metadata without overwriting manually entered numeric prices; the sync matches 元界 provider variants such as `元界AI` and keeps a provider/model-group guard to avoid mozheAPI. The same system-default page can install Agnes AI free templates through `/api/admin/system-apis/agnes-capabilities`; Agnes rows are inactive `free` billing templates with 0 credits, image/video rows use isolated `system-api-manifests/<systemApiId>.json` files, and text rows use `chat/completions` directly while waiting for admin-entered API Keys before activation. For legacy 元界 rows without Manifest, built-in capabilities still drive frontend options and generation resolution can write the missing Manifest. Models can be free (`free`), priced by per-use count (`fixed`), per-second duration (`duration` using `duration_price_per_second`), ratio, or token mode. Token billing input/output prices are configured as credits per 1M tokens in the console UI; the `input_price_per_1k`/`output_price_per_1k` DB/API field names are legacy-compatible storage names only. |
|
||||
| Model recommendations | `src/app/api/admin/model-recommendations/route.ts`, `src/components/admin/api-management-tab.tsx` | Admin-controlled displayed/recommended model lists. Reads and mutations require admin bearer auth. |
|
||||
|
||||
## Profile, Credits, Orders
|
||||
|
||||
| Feature | Files |
|
||||
| --- | --- |
|
||||
| Profile page | `src/app/profile/page.tsx` | The profile shell should stay light on first mount: it reads only a local creation-history count for the top summary, while creation history, credit records, and orders mount their heavier stores inside the corresponding tab components. |
|
||||
| Profile API | `src/app/api/profile/route.ts`, `src/app/api/profile/theme/route.ts`, `src/lib/user-profile-defaults.ts`, `src/lib/profile-preferences.ts` | `profiles.watermark_disabled` stores no-watermark download authorization. Free users cannot enable it from their own profile, but admins can grant or revoke it per user from user management; platform display remains watermarked even when the flag is on. |
|
||||
| Creation history tab | `src/components/profile/creation-history-tab.tsx`, `src/lib/creation-history-store.ts`, `src/app/api/creation-history/route.ts` | User-private completed works. History storage and the API de-duplicate repeated rows by result URL so a single generated video does not appear twice after the local optimistic record is replaced by the server row. `GET /api/creation-history` supports optional `mode` and `limit` for lightweight create-panel history, while the profile history tab can still read the default latest 300. The client store reuses short-lived in-flight requests and merges scoped responses into local history instead of replacing unrelated modes. Video records without a thumbnail receive a local SVG thumbnail under `thumbnails/works/videos` for fast list/detail preview. |
|
||||
| Credits tab/store | `src/components/profile/credits-tab.tsx`, `src/lib/credit-records-store.ts`, `src/app/api/credit-transactions/route.ts`, `src/app/api/redeem-codes/redeem/route.ts`, `src/app/api/invitations/me/route.ts`, `src/lib/invitation-service.ts` | The credits tab includes redeem-code input, a `获取兑换码` button, and a per-user invite link. The get-code and recharge buttons open `site_config.redeem_code_mall_url` from `/api/site-config` when configured. Successful redemption calls the server transaction route, updates either `profiles.credits_balance` for credit codes or `profiles.membership_tier`/`membership_expires_at` for membership codes, refreshes the auth profile, and then reloads server credit records. Invite links use `profiles.invite_code`; registrations through `/auth/register?invite=...` create an `invitation_referrals` row and award 50 credits to both inviter and invitee. The credit record store is mounted by this tab itself, not by the profile page parent. |
|
||||
| Orders tab/store | `src/components/profile/orders-tab.tsx`, `src/lib/order-store.ts`, `src/app/api/admin/orders/route.ts` | The order store is mounted by this tab itself, not by the profile page parent, so account/profile navigation does not fetch orders until the user opens the order view. |
|
||||
| Billing guard | `src/components/billing-plan-guard.tsx`, `src/lib/admin-store.ts` |
|
||||
|
||||
## Gallery
|
||||
|
||||
| Feature | Files | Notes |
|
||||
| --- | --- | --- |
|
||||
| Public gallery page | `src/app/gallery/page.tsx`, `src/app/globals.css`, `src/lib/gallery-cache-policy.ts` | Lists public works, search/sort/filter, preview/download, and one-click reuse. It requests `/api/gallery` in small pages instead of fetching the full gallery, uses a bounded `miaojing:gallery:v3` browser localStorage cache for instant reopen, revalidates page 0 in the background, debounces search, and uses an IntersectionObserver sentinel to append the next page only when the user scrolls near it. Cached rows remain usable for instant first paint until the 7-day prune window; page 0 is refreshed in the background for freshness, and a masonry skeleton replaces the old centered `加载中...` state when no cache exists. Image cards and detail display use `thumbnailUrl || url`, while fullscreen, download, copy/share, and reuse actions use original `url`. Video gallery cards and detail surfaces render `thumbnailUrl` first; `src/lib/media-storage.ts` should provide a local WEBP video-frame thumbnail when `ffmpeg-static` can extract one and only fall back to SVG when extraction fails. The detail overlay only mounts `<video src=original>` after the user clicks play, so object-storage originals are not fetched during list browsing or detail open. Video detail/download labels must say `下载视频`, not `下载图片`. The search box is custom styled in-page to match the glass UI; gallery cards sample 3-5 distinct colors from the image and use a real `gallery-card-border-frame` wrapper with a single 3px blurred, continuous clockwise multicolor border around the full work-card container, including all four corners and the prompt/footer area. Avoid image-covering dark overlays, broad square glow blocks, or a separate outer halo layer. Hover like/download/reuse buttons invert against sampled image brightness. Gallery detail image previews use `ImageMetadataBadge` for actual ratio/resolution, and the detail footer writes a reuse draft before navigating to the matching `/create?type=...` mode. Mobile gallery must keep at least two masonry columns; `masonryColumnCount` bottoms out at 2 and `.gallery-masonry-grid`/card CSS trims spacing and metadata density on phones. |
|
||||
| Public gallery API | `src/app/api/gallery/route.ts`, `src/lib/gallery-response.ts` | GET public works with `thumbnailUrl`, `total`, `nextOffset`, and `hasMore`, queues missing or old-profile image thumbnails plus stale video SVG fallback thumbnails for background backfill without delaying the response, admin DELETE unpublishes. For videos, only `video-frame-m1280q86-v1.webp` counts as a current thumbnail; `video-svg-v1` and `video-fallback-svg-v2` are temporary fallback assets and should not block a later real-frame backfill. Gallery author names use `profiles.display_nickname` first and never expose login username unless no display nickname exists. Public list serialization filters `data:` and oversized `publisherAvatarUrl` values so generated default avatars do not bloat the gallery JSON payload or localStorage cache. |
|
||||
| Publish API | `src/app/api/gallery/publish/route.ts`, `src/lib/gallery-publish-media.ts` | Inserts public work after resolving gallery media. Stable `/api/local-storage/...` image and video originals are reused instead of synchronously copying object-backed generated media during share; external media is still copied into gallery storage first. Existing image thumbnails are reused so image sharing does not block on object-storage reads or thumbnail recompression; `/api/gallery` can lazily backfill missing/stale thumbnails. Video publishing first tries to generate a local WEBP frame preview under `thumbnails/gallery/videos` via `ffmpeg-static`, and only copies a client-provided thumbnail when real-frame extraction fails. Client code must treat `/api/gallery/publish` as authoritative and mark local works as shared only after a 2xx response. |
|
||||
| Admin gallery prompt moderation | `src/components/admin/gallery-management-tab.tsx`, `src/app/api/admin/gallery/works/route.ts`, `src/app/api/admin/gallery/prompt/route.ts`, `src/lib/admin-gallery-prompt-service.ts`, `src/lib/admin-gallery-works-pagination.ts`, `scripts/test-admin-gallery-prompt-service.mjs` | Console-only workflow for editing public gallery `works.prompt`. The management table uses page/pageSize pagination while the list API keeps limit/offset compatibility. Admins must send an email notification to the author; the service sends email before updating the prompt and logs metadata without storing full prompt text. |
|
||||
| History persistence | `src/app/api/creation-history/route.ts`, `src/lib/creation-history-store.ts` | User-private completed works, `thumbnailUrl`, stored width/height, and published state. Missing image thumbnails and old-profile video thumbnails are queued for background backfill instead of blocking the history response. Video history thumbnails use local WEBP frames when `ffmpeg-static` can extract one, with SVG as the failure fallback; fallback SVG files are not considered current thumbnails. `mode=text2img|img2img|text2video|img2video|reverse-prompt` plus `limit` should be used by create panels to avoid repeated multi-MB history payloads during navigation; the API mode filter also covers legacy rows by checking params and reference-image inference. Local sharing state requires `publishedAt`, which is set only after confirmed server publish, so stale `published=true` flags from older clients do not disable retrying a failed gallery share. Single-record deletion is server-first when logged in; detail dialogs call the same store path and then refresh local history. |
|
||||
|
||||
## Admin Console
|
||||
|
||||
| Feature | Frontend | API |
|
||||
| --- | --- | --- |
|
||||
| Console login | `src/app/console/page.tsx`, `src/modules/console/pages/console-login-page.tsx` | `src/app/api/auth/login/route.ts` with `adminOnly` |
|
||||
| Console dashboard | `src/app/console/dashboard/page.tsx`, `src/modules/console/pages/console-dashboard-page.tsx` | `src/app/api/admin/dashboard/route.ts`, `src/app/api/admin/stats/route.ts`. The dashboard page owns the mobile admin shell classes (`console-mobile-page`, `console-mobile-main`, `console-mobile-content`) used by `src/app/globals.css` to keep cards constrained and admin tables horizontally scrollable on phones. |
|
||||
| Users | `src/components/admin/user-management-tab.tsx` | `src/app/api/admin/users/route.ts`, `src/lib/admin-users-service.ts`, `src/app/api/admin/clear-users/route.ts`, `src/app/api/admin/invitations/route.ts`. The user-management UI has separate subpages for `用户列表` and `邀请注册记录`; invitation records have independent search, pagination, total count, inviter/invitee details, invite code, reward amounts, and creation time. Row actions include recharge, reset password, edit, and delete. Admin reset-password opens a separate reset form and `/api/admin/users` PUT upserts `auth.users.password_hash` for the target user. Admins can edit an individual user's `下载无水印` switch, which writes `profiles.watermark_disabled` through `/api/admin/users` without changing the membership tier. |
|
||||
| API/model management | `src/components/admin/api-management-tab.tsx` | `src/app/api/admin/providers/route.ts`, `src/app/api/admin/system-apis/route.ts`, `src/app/api/admin/system-apis/smart-import/route.ts`, `src/app/api/admin/model-recommendations/route.ts` |
|
||||
| Pricing | `src/components/admin/pricing-tab.tsx` | Admin store/site config related routes |
|
||||
| Redeem codes | `src/components/admin/redeem-code-management-tab.tsx` | `src/app/api/admin/redeem-codes/route.ts`, `src/lib/redeem-code-service.ts`, `src/app/api/site-config/route.ts`. Admins can generate one or many unique single-use redeem codes, choose credit-code or membership-code type, set credit amount or membership tier plus duration in days/months/years, copy generated codes, and manage unused code status. The same tab has a `商城链接配置` dialog that saves `site_config.redeem_code_mall_url`; frontend credit get-code buttons and membership upgrade buttons open that URL. |
|
||||
| Payment | `src/components/admin/payment-tab.tsx` | `src/app/api/admin/payment-methods/route.ts`, `src/lib/server-payment-config.ts` |
|
||||
| Orders | `src/components/admin/order-management-tab.tsx` | `src/app/api/admin/orders/route.ts` |
|
||||
| Announcements | `src/components/admin/announcement-tab.tsx` | `src/app/api/announcements/route.ts` |
|
||||
| Gallery management | `src/components/admin/gallery-management-tab.tsx` | `src/app/api/admin/gallery/works/route.ts`, `src/app/api/admin/gallery/prompt/route.ts`, `src/lib/admin-gallery-prompt-service.ts`, `src/lib/admin-gallery-works-pagination.ts`. Lists public works with admin page/pageSize pagination, edits prompt text, opens a required notification email dialog with built-in reason templates, and only completes the update after email send success. |
|
||||
| Data import/export | `src/components/admin/data-management-tab.tsx` | `src/app/api/admin/data-export/route.ts`, `src/app/api/admin/data-import/route.ts`, `scripts/migration-integrity-check.mjs`, `scripts/migration-integrity-check-helpers.mjs`. Export bundles storage URLs from works/site config into `_media`; import restores those files through `src/lib/local-storage.ts`, maps old IDs, merges duplicate works only within the same `user_id`, and runs DB writes in a transaction. Import preserves password hashes, encrypted API keys, `manifest_path`, system API pricing fields, and `redeem_codes` state so users, credentials, works, intelligent API configs, and unused/used redemption state survive migration. Run `pnpm run migration:check` before and after production migration; the checker defaults to port 8000 and counts bounded media probe failures instead of aborting on the first slow URL. |
|
||||
| System upgrade | `src/components/admin/system-upgrade-tab.tsx` | `src/app/api/admin/upgrade/route.ts`, `scripts/admin-upgrade-runner.mjs` |
|
||||
| Logs/tasks | `src/components/admin/log-management-tab.tsx`, `src/components/admin/task-management-tab.tsx` | `src/lib/platform-logs.ts`, `src/app/api/admin/logs/route.ts`, `src/app/api/admin/generation-jobs/route.ts` |
|
||||
| Settings | `src/components/admin/settings-tab.tsx` | `src/app/api/site-config/route.ts`, `src/app/api/admin/email-settings/route.ts`, `src/app/api/admin/send-email/route.ts`. The feature toggles section includes membership enablement and the 100 Layout Compositions image composition skill switch. |
|
||||
|
||||
## Storage And Downloads
|
||||
|
||||
| Feature | Files | Notes |
|
||||
| --- | --- | --- |
|
||||
| Storage adapter | `src/lib/local-storage.ts` | Uses stable `/api/local-storage/<key>` URLs while the backend can be `STORAGE_MODE=local`, `dual`, or `object`. Object mode uses S3-compatible `OBJECT_STORAGE_*` config; dual mode writes local disk first and mirrors to object storage for safe migration. |
|
||||
| Rainyun ROS object storage preparation | `scripts/rainyun-ros-prepare.mjs` | Uses the Rainyun control-plane API `POST /product/ros/bucket` to create a bucket from `RAINYUN_ROS_BUCKET_NAME` and `RAINYUN_ROS_INSTANCE_ID`, then writes a private `.env.rainyun-object.generated` file containing standard `OBJECT_STORAGE_*` variables. Do not use this control-plane API for runtime media reads/writes; runtime storage remains S3-compatible through `src/lib/local-storage.ts`. |
|
||||
| Local/object file API | `src/app/api/local-storage/[...path]/route.ts`, `src/lib/media-watermark-policy.ts`, `src/lib/media-watermark.ts`, `src/proxy.ts` | Serves storage objects by key without changing existing frontend URLs. Generated work media under `generated/`, `gallery/`, `imported/works`, and generated/gallery/work thumbnails is watermarked on the server before display, including object-backed originals, so page display and browser save-as cannot reach raw generated images/videos. Generated image original display requests should use an existing local `works.thumbnail_url` redirect before watermarking when available; downloads still use the original through `/api/download`. Thumbnail keys under `thumbnails/...` are read from local disk and use long immutable browser cache headers because the filename contains the thumbnail profile; `src/proxy.ts` must preserve those cache headers instead of applying global `/api` no-store. Non-generated originals can still redirect to short-lived object-storage signed URLs when configured. |
|
||||
| Download proxy | `src/app/api/download/route.ts` | Supports remote URL, same-origin URL, and `/api/local-storage/*`. Generated local-storage media returns watermarked bytes unless the request authenticates an admin role or a user whose `profiles.watermark_disabled` is true; normal users without that flag must not receive raw object-storage redirects for generated images/videos. The frontend passes the session through `downloadFile(...)` headers or `triggerDownloadFile(...)` download tokens. For non-generated object-backed local-storage files, it can redirect to a short-lived signed object URL with content-disposition instead of buffering large videos through Next.js. |
|
||||
| Remote fetch guard | `src/lib/remote-fetch.ts` | Use for server-side external fetches. It blocks private/local network targets, sends browser-like public-resource headers by default, and exposes `fetchPublicHttpUrlWithRetry` for generated image/result URL downloads that may transiently return 403, 429, 5xx, or timeout. |
|
||||
|
||||
## Database And Persistence
|
||||
|
||||
| Area | Files |
|
||||
| --- | --- |
|
||||
| Database connection pool | `src/storage/database/local-db.ts` |
|
||||
| Image style preset store | `src/lib/style-preset-store.ts`, `src/app/api/style-presets/route.ts` |
|
||||
| Schema snapshot | `src/storage/database/shared/schema.ts` |
|
||||
| Supabase compatibility client | `src/storage/database/supabase-client.ts` |
|
||||
| Init SQL | `scripts/init-database.sql` |
|
||||
| DB patch runner | `scripts/apply-database-patch.sh` |
|
||||
| Migration SQL files | `account-profile-migration.sql`, `model-config-migration.sql`, `persistence_migration.sql`, `scripts/database-optimization-patch.sql` |
|
||||
|
||||
## Deployment And Runtime
|
||||
|
||||
| Feature | Files |
|
||||
| --- | --- |
|
||||
| Custom Node server | `src/server.ts` |
|
||||
| PM2 config | `ecosystem.config.cjs` |
|
||||
| Build | `scripts/build.sh` |
|
||||
| Start | `scripts/start.sh` |
|
||||
| Dev | `scripts/dev.sh` |
|
||||
| Deploy/upgrade | `scripts/deploy-or-upgrade.sh` | Sync excludes must target repo-root runtime artifacts only. Use root-anchored `/local-storage` so the source route `src/app/api/local-storage/[...path]/route.ts` is still deployed. Production keeps a host-specific `ecosystem.config.cjs`; manual source archives or rsync payloads must not overwrite it, or must restore the production copy from the pre-sync backup before `pm2 startOrReload`. |
|
||||
| Backup | `scripts/backup-create.sh`, `scripts/backup-list.sh`, `scripts/backup-restore.sh`. Restore uses `pg_restore --single-transaction`, validates archive/dump contents, atomically swaps local storage, and keeps a pre-restore safety backup. |
|
||||
| Object storage migration | `scripts/storage-sync-to-object.mjs` | Copies existing `LOCAL_STORAGE_DIR` files into the configured S3-compatible bucket, supports `--dry-run` and `--verify-only`, and should be run before switching production from local-only to object-backed storage. |
|
||||
| Admin upgrade API/UI | `src/app/api/admin/upgrade/route.ts`, `src/components/admin/system-upgrade-tab.tsx` |
|
||||
| Admin upgrade runner | `scripts/admin-upgrade-runner.mjs`. Use this when deciding whether a change can be packaged as a hot update or must be a cold update. Source/API/server/dependency/schema/env/runtime/script changes should be treated as cold-update candidates; static/public asset-only packages are hot-update candidates only if runner preflight accepts them without restart. |
|
||||
| Boundary checks | `scripts/check-boundaries.sh` |
|
||||
| User display profile backfill | `scripts/backfill-user-display-profile.mjs` |
|
||||
BIN
docs/images/create.png
Normal file
BIN
docs/images/create.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
BIN
docs/images/gallery.png
Normal file
BIN
docs/images/gallery.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
BIN
docs/images/home.png
Normal file
BIN
docs/images/home.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 154 KiB |
@@ -0,0 +1,450 @@
|
||||
# Admin Gallery Prompt Notification Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build an admin console workflow that edits public gallery work prompts only after successfully emailing the work author.
|
||||
|
||||
**Architecture:** Put the moderation rule in a small service module with injected database/email/log dependencies, expose thin admin API routes, and add a dedicated admin console tab. The public gallery page remains unchanged except for shared data compatibility through the `works.prompt` field.
|
||||
|
||||
**Tech Stack:** Next.js route handlers, React client components, PostgreSQL via existing `getDbClient`, existing `sendTemplatedEmail`, existing `writePlatformLog`, TypeScript, and a lightweight Node test script.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Create `src/lib/admin-gallery-prompt-service.ts`: validates prompt edit requests, loads a public work and author, sends email first, updates `works.prompt`, writes moderation logs.
|
||||
- Create `src/app/api/admin/gallery/works/route.ts`: admin-only list API for public gallery works.
|
||||
- Create `src/app/api/admin/gallery/prompt/route.ts`: admin-only prompt update API that delegates to the service.
|
||||
- Create `src/components/admin/gallery-management-tab.tsx`: admin UI for listing public works and editing prompts with required email notification.
|
||||
- Modify `src/modules/console/pages/console-dashboard-page.tsx`: register the new `gallery` view in navigation and content routing.
|
||||
- Create `scripts/test-admin-gallery-prompt-service.mjs`: service-level TDD tests using an in-memory fake database adapter.
|
||||
- Modify `package.json`: add `test:admin-gallery-prompt`.
|
||||
- Update `docs/codex-miaojing/api-reference.md`: document new admin gallery APIs.
|
||||
- Update `docs/codex-miaojing/feature-code-index.md`: index the new feature files.
|
||||
- Update `docs/codex-miaojing/architecture.md` only if moderation workflow context is missing.
|
||||
- Update `docs/codex-miaojing/bug-location-guide.md` only if implementation reveals useful troubleshooting notes.
|
||||
|
||||
## Task 1: Service Tests
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/test-admin-gallery-prompt-service.mjs`
|
||||
- Modify: `package.json`
|
||||
|
||||
- [ ] **Step 1: Add a test script entry**
|
||||
|
||||
Add this script to `package.json`:
|
||||
|
||||
```json
|
||||
"test:admin-gallery-prompt": "tsx ./scripts/test-admin-gallery-prompt-service.mjs"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write failing service tests**
|
||||
|
||||
Create `scripts/test-admin-gallery-prompt-service.mjs` that imports `updateAdminGalleryPrompt` from `src/lib/admin-gallery-prompt-service.ts` and tests:
|
||||
|
||||
```js
|
||||
import assert from 'node:assert/strict';
|
||||
import { updateAdminGalleryPrompt } from '../src/lib/admin-gallery-prompt-service.ts';
|
||||
|
||||
function createServiceHarness({ work, emailFails = false } = {}) {
|
||||
const state = {
|
||||
work: work || {
|
||||
id: '11111111-1111-1111-1111-111111111111',
|
||||
user_id: '22222222-2222-2222-2222-222222222222',
|
||||
type: 'text2img',
|
||||
title: 'public work',
|
||||
prompt: 'old public prompt',
|
||||
negative_prompt: null,
|
||||
result_url: '/api/local-storage/gallery/image.webp',
|
||||
thumbnail_url: '/api/local-storage/thumbnails/gallery/image.webp',
|
||||
likes_count: 3,
|
||||
is_public: true,
|
||||
status: 'completed',
|
||||
created_at: '2026-05-20T00:00:00.000Z',
|
||||
author_email: 'author@example.com',
|
||||
author_nickname: 'Author',
|
||||
author_display_nickname: 'Author Display',
|
||||
author_avatar_url: null,
|
||||
},
|
||||
updates: [],
|
||||
emails: [],
|
||||
logs: [],
|
||||
};
|
||||
|
||||
return {
|
||||
state,
|
||||
deps: {
|
||||
loadWork: async (workId) => (workId === state.work.id ? state.work : null),
|
||||
updatePrompt: async (workId, prompt) => {
|
||||
state.updates.push({ workId, prompt });
|
||||
state.work = { ...state.work, prompt };
|
||||
return state.work;
|
||||
},
|
||||
sendEmail: async (message) => {
|
||||
state.emails.push(message);
|
||||
if (emailFails) throw new Error('SMTP down');
|
||||
},
|
||||
writeLog: async (entry) => {
|
||||
state.logs.push(entry);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runTest(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`PASS ${name}`);
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${name}`);
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const admin = { userId: '33333333-3333-3333-3333-333333333333', role: 'admin' };
|
||||
const baseInput = {
|
||||
workId: '11111111-1111-1111-1111-111111111111',
|
||||
prompt: 'new compliant prompt',
|
||||
emailSubject: '公开作品提示词已调整',
|
||||
emailBody: '你的公开作品提示词已根据平台规范调整。',
|
||||
reasonKey: 'remove_sensitive_words',
|
||||
};
|
||||
|
||||
await runTest('rejects non-public works', async () => {
|
||||
const { deps, state } = createServiceHarness({ work: { ...createServiceHarness().state.work, is_public: false } });
|
||||
await assert.rejects(() => updateAdminGalleryPrompt(baseInput, { admin, ...deps }), /作品不存在或不是公开作品/);
|
||||
assert.equal(state.updates.length, 0);
|
||||
assert.equal(state.emails.length, 0);
|
||||
});
|
||||
|
||||
await runTest('rejects missing author email', async () => {
|
||||
const { deps, state } = createServiceHarness({ work: { ...createServiceHarness().state.work, author_email: '' } });
|
||||
await assert.rejects(() => updateAdminGalleryPrompt(baseInput, { admin, ...deps }), /作者邮箱不可用/);
|
||||
assert.equal(state.updates.length, 0);
|
||||
assert.equal(state.emails.length, 0);
|
||||
});
|
||||
|
||||
await runTest('rejects unchanged prompt', async () => {
|
||||
const { deps, state } = createServiceHarness();
|
||||
await assert.rejects(
|
||||
() => updateAdminGalleryPrompt({ ...baseInput, prompt: 'old public prompt' }, { admin, ...deps }),
|
||||
/提示词没有变化/,
|
||||
);
|
||||
assert.equal(state.updates.length, 0);
|
||||
assert.equal(state.emails.length, 0);
|
||||
});
|
||||
|
||||
await runTest('does not update prompt when email sending fails', async () => {
|
||||
const { deps, state } = createServiceHarness({ emailFails: true });
|
||||
await assert.rejects(() => updateAdminGalleryPrompt(baseInput, { admin, ...deps }), /SMTP down/);
|
||||
assert.equal(state.updates.length, 0);
|
||||
assert.equal(state.emails.length, 1);
|
||||
});
|
||||
|
||||
await runTest('sends email before updating prompt', async () => {
|
||||
const { deps, state } = createServiceHarness();
|
||||
const result = await updateAdminGalleryPrompt(baseInput, { admin, ...deps });
|
||||
assert.equal(state.emails.length, 1);
|
||||
assert.equal(state.updates.length, 1);
|
||||
assert.equal(state.updates[0].prompt, 'new compliant prompt');
|
||||
assert.equal(result.work.prompt, 'new compliant prompt');
|
||||
});
|
||||
|
||||
await runTest('writes moderation log metadata without full prompt text', async () => {
|
||||
const { deps, state } = createServiceHarness();
|
||||
await updateAdminGalleryPrompt(baseInput, { admin, ...deps });
|
||||
assert.equal(state.logs.length, 1);
|
||||
const logText = JSON.stringify(state.logs[0]);
|
||||
assert.match(logText, /remove_sensitive_words/);
|
||||
assert.doesNotMatch(logText, /old public prompt/);
|
||||
assert.doesNotMatch(logText, /new compliant prompt/);
|
||||
});
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run tests and confirm RED**
|
||||
|
||||
Run: `pnpm run test:admin-gallery-prompt`
|
||||
|
||||
Expected: failure because `src/lib/admin-gallery-prompt-service.ts` does not exist or does not export `updateAdminGalleryPrompt`.
|
||||
|
||||
## Task 2: Service Implementation
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/admin-gallery-prompt-service.ts`
|
||||
- Test: `scripts/test-admin-gallery-prompt-service.mjs`
|
||||
|
||||
- [ ] **Step 1: Implement the tested service**
|
||||
|
||||
Create `src/lib/admin-gallery-prompt-service.ts` with:
|
||||
|
||||
```ts
|
||||
import type { AuthenticatedUser } from '@/lib/session-auth';
|
||||
import { isValidEmail, normalizeEmail } from '@/lib/email-service';
|
||||
|
||||
export type AdminGalleryPromptReasonKey =
|
||||
| 'remove_sensitive_words'
|
||||
| 'improve_wording'
|
||||
| 'remove_private_info'
|
||||
| 'platform_policy_adjustment'
|
||||
| 'custom';
|
||||
|
||||
export interface AdminGalleryPromptWorkRow {
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
type: string | null;
|
||||
title: string | null;
|
||||
prompt: string | null;
|
||||
negative_prompt?: string | null;
|
||||
result_url: string | null;
|
||||
thumbnail_url?: string | null;
|
||||
likes_count?: number | null;
|
||||
is_public: boolean | null;
|
||||
status: string | null;
|
||||
created_at: string | Date | null;
|
||||
author_email: string | null;
|
||||
author_nickname?: string | null;
|
||||
author_display_nickname?: string | null;
|
||||
author_avatar_url?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminGalleryPromptInput {
|
||||
workId: string;
|
||||
prompt: string;
|
||||
emailSubject: string;
|
||||
emailBody: string;
|
||||
reasonKey?: string;
|
||||
}
|
||||
|
||||
export interface AdminGalleryPromptDeps {
|
||||
admin: AuthenticatedUser;
|
||||
loadWork: (workId: string) => Promise<AdminGalleryPromptWorkRow | null>;
|
||||
updatePrompt: (workId: string, prompt: string) => Promise<AdminGalleryPromptWorkRow>;
|
||||
sendEmail: (message: { to: string; subject: string; body: string; work: AdminGalleryPromptWorkRow; reasonKey: AdminGalleryPromptReasonKey }) => Promise<void>;
|
||||
writeLog: (entry: Record<string, unknown>) => Promise<void>;
|
||||
}
|
||||
|
||||
export class AdminGalleryPromptError extends Error {
|
||||
constructor(message: string, public status = 400) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAdminGalleryPrompt(input: AdminGalleryPromptInput, deps: AdminGalleryPromptDeps) {
|
||||
const workId = normalizeUuid(input.workId, '缺少作品 ID');
|
||||
const prompt = normalizeRequiredText(input.prompt, '请填写新的提示词', 8000);
|
||||
const emailSubject = normalizeRequiredText(input.emailSubject, '请填写邮件标题', 120);
|
||||
const emailBody = normalizeRequiredText(input.emailBody, '请填写邮件正文', 5000);
|
||||
const reasonKey = normalizeReasonKey(input.reasonKey);
|
||||
|
||||
const work = await deps.loadWork(workId);
|
||||
if (!work || work.is_public !== true || work.status !== 'completed' || !work.result_url) {
|
||||
throw new AdminGalleryPromptError('作品不存在或不是公开作品', 404);
|
||||
}
|
||||
|
||||
const oldPrompt = String(work.prompt || '').trim();
|
||||
if (oldPrompt === prompt) throw new AdminGalleryPromptError('提示词没有变化', 400);
|
||||
|
||||
const authorEmail = normalizeEmail(work.author_email);
|
||||
if (!isValidEmail(authorEmail)) throw new AdminGalleryPromptError('作者邮箱不可用,无法完成邮件通知', 400);
|
||||
|
||||
await deps.sendEmail({ to: authorEmail, subject: emailSubject, body: emailBody, work, reasonKey });
|
||||
const updated = await deps.updatePrompt(workId, prompt);
|
||||
|
||||
await deps.writeLog({
|
||||
type: 'admin',
|
||||
level: 'info',
|
||||
action: 'admin_gallery_prompt_update',
|
||||
message: '管理员修改公开画廊作品提示词并发送邮件通知',
|
||||
userId: deps.admin.userId,
|
||||
targetType: 'work',
|
||||
targetId: workId,
|
||||
metadata: {
|
||||
workId,
|
||||
authorId: work.user_id,
|
||||
authorEmail,
|
||||
reasonKey,
|
||||
oldPromptLength: oldPrompt.length,
|
||||
newPromptLength: prompt.length,
|
||||
notificationSent: true,
|
||||
},
|
||||
});
|
||||
|
||||
return { work: toAdminGalleryPromptWork(updated, authorEmail), notificationSent: true };
|
||||
}
|
||||
|
||||
export function toAdminGalleryPromptWork(row: AdminGalleryPromptWorkRow, authorEmail = normalizeEmail(row.author_email)) {
|
||||
return {
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
title: row.title,
|
||||
prompt: row.prompt,
|
||||
negativePrompt: row.negative_prompt || null,
|
||||
url: row.result_url,
|
||||
thumbnailUrl: row.thumbnail_url || null,
|
||||
likes: Number(row.likes_count || 0),
|
||||
authorId: row.user_id,
|
||||
authorEmail,
|
||||
authorNickname: row.author_display_nickname || row.author_nickname || (authorEmail ? authorEmail.split('@')[0] : '匿名用户'),
|
||||
authorAvatarUrl: row.author_avatar_url || null,
|
||||
publishedAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeUuid(value: unknown, message: string) {
|
||||
const text = typeof value === 'string' ? value.trim() : '';
|
||||
if (!/^[0-9a-fA-F-]{36}$/.test(text)) throw new AdminGalleryPromptError(message, 400);
|
||||
return text;
|
||||
}
|
||||
|
||||
function normalizeRequiredText(value: unknown, message: string, maxLength: number) {
|
||||
const text = typeof value === 'string' ? value.trim() : '';
|
||||
if (!text) throw new AdminGalleryPromptError(message, 400);
|
||||
return text.slice(0, maxLength);
|
||||
}
|
||||
|
||||
function normalizeReasonKey(value: unknown): AdminGalleryPromptReasonKey {
|
||||
if (
|
||||
value === 'remove_sensitive_words'
|
||||
|| value === 'improve_wording'
|
||||
|| value === 'remove_private_info'
|
||||
|| value === 'platform_policy_adjustment'
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return 'custom';
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run service tests and confirm GREEN**
|
||||
|
||||
Run: `pnpm run test:admin-gallery-prompt`
|
||||
|
||||
Expected: all service tests print `PASS`.
|
||||
|
||||
## Task 3: Admin API Routes
|
||||
|
||||
**Files:**
|
||||
- Create: `src/app/api/admin/gallery/works/route.ts`
|
||||
- Create: `src/app/api/admin/gallery/prompt/route.ts`
|
||||
- Modify if needed: `src/lib/admin-gallery-prompt-service.ts`
|
||||
|
||||
- [ ] **Step 1: Implement `GET /api/admin/gallery/works`**
|
||||
|
||||
Route requirements:
|
||||
|
||||
- Authenticate with `requireAdmin`.
|
||||
- Query `works` joined with `profiles`.
|
||||
- Filter public, completed, result-backed works.
|
||||
- Support `q`, `type`, `limit`, `offset`, `sort`.
|
||||
- Return `works`, `total`, `nextOffset`, `hasMore`.
|
||||
|
||||
- [ ] **Step 2: Implement `PUT /api/admin/gallery/prompt`**
|
||||
|
||||
Route requirements:
|
||||
|
||||
- Authenticate with `requireAdminUser`.
|
||||
- Parse JSON request body.
|
||||
- Call `updateAdminGalleryPrompt`.
|
||||
- Implement real dependencies using `getDbClient`, `sendTemplatedEmail`, `getRequestBaseUrl`, and `writePlatformLog`.
|
||||
- Map `AdminGalleryPromptError.status` to JSON error status.
|
||||
|
||||
- [ ] **Step 3: Run service tests and type check**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
pnpm run test:admin-gallery-prompt
|
||||
pnpm run ts-check
|
||||
```
|
||||
|
||||
Expected: tests pass and TypeScript reports no errors.
|
||||
|
||||
## Task 4: Admin Console UI
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/admin/gallery-management-tab.tsx`
|
||||
- Modify: `src/modules/console/pages/console-dashboard-page.tsx`
|
||||
|
||||
- [ ] **Step 1: Add the admin gallery tab component**
|
||||
|
||||
Component requirements:
|
||||
|
||||
- Uses `useAuth()` for the bearer token.
|
||||
- Loads `/api/admin/gallery/works`.
|
||||
- Shows search, type filter, refresh, and load-more.
|
||||
- Shows compact public work rows with media preview, author, prompt summary, likes, and edit action.
|
||||
- Provides prompt edit dialog and required email notification dialog.
|
||||
- Provides the four reason templates and custom editing.
|
||||
- Calls `PUT /api/admin/gallery/prompt` only from the email dialog.
|
||||
- Updates the row prompt from the API response on success.
|
||||
|
||||
- [ ] **Step 2: Wire the console shell**
|
||||
|
||||
Modify `ConsoleView`, `CONSOLE_VIEWS`, `VIEW_TITLES`, nav groups, dynamic import list, and `ConsoleContent` to include `gallery`.
|
||||
|
||||
- [ ] **Step 3: Run TypeScript check**
|
||||
|
||||
Run: `pnpm run ts-check`
|
||||
|
||||
Expected: no TypeScript errors.
|
||||
|
||||
## Task 5: Project Documentation
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/codex-miaojing/api-reference.md`
|
||||
- Modify: `docs/codex-miaojing/feature-code-index.md`
|
||||
- Modify: `docs/codex-miaojing/architecture.md` if needed
|
||||
- Modify: `docs/codex-miaojing/bug-location-guide.md` if needed
|
||||
|
||||
- [ ] **Step 1: Document API changes**
|
||||
|
||||
Add `GET /api/admin/gallery/works` and `PUT /api/admin/gallery/prompt`, including auth, request, response, and failure semantics.
|
||||
|
||||
- [ ] **Step 2: Update feature index**
|
||||
|
||||
Add the new admin tab, API routes, service module, and test script to the Admin Console and Gallery sections.
|
||||
|
||||
- [ ] **Step 3: Update architecture/bug guide only for real new operational knowledge**
|
||||
|
||||
If the implementation creates notable operational failure modes, document them. Otherwise leave those files unchanged.
|
||||
|
||||
## Task 6: Final Verification
|
||||
|
||||
**Files:**
|
||||
- All touched files.
|
||||
|
||||
- [ ] **Step 1: Run focused tests**
|
||||
|
||||
Run: `pnpm run test:admin-gallery-prompt`
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
- [ ] **Step 2: Run type check**
|
||||
|
||||
Run: `pnpm run ts-check`
|
||||
|
||||
Expected: no TypeScript errors.
|
||||
|
||||
- [ ] **Step 3: Run build**
|
||||
|
||||
Run: `pnpm run build`
|
||||
|
||||
Expected: production build completes.
|
||||
|
||||
- [ ] **Step 4: Review git diff**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git diff --check
|
||||
git status --short
|
||||
```
|
||||
|
||||
Expected: no whitespace errors and only intended files changed.
|
||||
|
||||
- [ ] **Step 5: Final deployment note**
|
||||
|
||||
Final response must mention this is a cold-update candidate and production rollout needs backup, PM2 reload/restart, `/api/health`, `/console`, new gallery management smoke check, `/gallery`, and rollback readiness.
|
||||
@@ -0,0 +1,237 @@
|
||||
# Admin Gallery Prompt Notification Design
|
||||
|
||||
## Goal
|
||||
|
||||
Add an admin-only workflow for editing prompts on public gallery works and notifying the work author by email. The feature is for moderation tasks such as removing sensitive words, privacy details, or misleading public display text.
|
||||
|
||||
The confirmed product rule is: an admin prompt edit must send an email successfully before the prompt change is completed. There is no skip-notification path.
|
||||
|
||||
## Current Context
|
||||
|
||||
- Public gallery UI lives in `src/app/gallery/page.tsx`.
|
||||
- Public gallery API lives in `src/app/api/gallery/route.ts`; admin DELETE currently unpublishes works.
|
||||
- Admin console shell lives in `src/modules/console/pages/console-dashboard-page.tsx`.
|
||||
- Admin tabs live under `src/components/admin/*`.
|
||||
- Email sending is centralized in `src/lib/email-service.ts`.
|
||||
- Existing admin email APIs include `src/app/api/admin/send-email/route.ts` and `src/app/api/email/send-notification/route.ts`.
|
||||
- Admin auth helpers live in `src/lib/admin-auth.ts` and `src/lib/session-auth.ts`.
|
||||
- Platform logs are written through `src/lib/platform-logs.ts`.
|
||||
- The `works` table already stores `prompt`, `is_public`, `user_id`, `result_url`, `thumbnail_url`, and related work metadata.
|
||||
|
||||
## Recommended Approach
|
||||
|
||||
Add a dedicated admin console page instead of extending the public gallery page. This keeps moderation work out of the public browsing experience and avoids further growth in the already-large public gallery component.
|
||||
|
||||
The feature will add:
|
||||
|
||||
- `src/components/admin/gallery-management-tab.tsx`
|
||||
- `GET /api/admin/gallery/works`
|
||||
- `PUT /api/admin/gallery/prompt`
|
||||
- `src/lib/admin-gallery-prompt-service.ts`
|
||||
- A small TDD script for the service layer.
|
||||
|
||||
## Admin UI
|
||||
|
||||
Add a new `gallery` console view in `src/modules/console/pages/console-dashboard-page.tsx`.
|
||||
|
||||
Navigation:
|
||||
|
||||
- Group: creation/admin content group.
|
||||
- Label: `画廊管理`.
|
||||
- Icon: use a lucide gallery/image icon.
|
||||
|
||||
`src/components/admin/gallery-management-tab.tsx` will provide:
|
||||
|
||||
- Search input for prompt, author email/nickname, and work ID.
|
||||
- Type filter for all/image/video/text2img/img2img/text2video/img2video where practical.
|
||||
- Refresh button.
|
||||
- Paginated or load-more public works list.
|
||||
- Rows showing preview thumbnail, work type, author, public time, prompt summary, likes, and actions.
|
||||
- `编辑提示词` action.
|
||||
|
||||
Edit flow:
|
||||
|
||||
1. Admin opens the prompt editor for a public work.
|
||||
2. Dialog shows media preview, author email, original prompt, and editable new prompt.
|
||||
3. Admin clicks save.
|
||||
4. UI opens a required email notification dialog.
|
||||
5. Admin selects a reason template or writes custom email subject/body.
|
||||
6. UI submits one request to `PUT /api/admin/gallery/prompt`.
|
||||
7. On success, dialogs close, the row prompt updates, and a success toast is shown.
|
||||
8. On failure, the dialog stays open and shows the server error.
|
||||
|
||||
Built-in reason templates:
|
||||
|
||||
- `remove_sensitive_words`: 删除敏感词,确保公开展示合规
|
||||
- `improve_wording`: 优化提示词表述,避免误导或不适内容
|
||||
- `remove_private_info`: 移除个人信息或隐私相关描述
|
||||
- `platform_policy_adjustment`: 根据平台内容规范调整公开展示文案
|
||||
|
||||
Templates fill title/body quickly, but admins may manually edit or replace both fields.
|
||||
|
||||
## API Design
|
||||
|
||||
### `GET /api/admin/gallery/works`
|
||||
|
||||
Auth:
|
||||
|
||||
- Requires admin session via existing admin auth helpers.
|
||||
|
||||
Query params:
|
||||
|
||||
- `q`: optional search text.
|
||||
- `type`: optional work type/category filter.
|
||||
- `limit`: bounded page size.
|
||||
- `offset`: page offset.
|
||||
- `sort`: `newest` by default; optionally `popular`.
|
||||
|
||||
Selection rules:
|
||||
|
||||
- Only `works.is_public = true`.
|
||||
- Only completed works.
|
||||
- Only rows with a non-empty result URL.
|
||||
|
||||
Response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"works": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"type": "text2img",
|
||||
"title": null,
|
||||
"prompt": "current prompt",
|
||||
"negativePrompt": null,
|
||||
"url": "/api/local-storage/...",
|
||||
"thumbnailUrl": "/api/local-storage/thumbnails/...",
|
||||
"likes": 0,
|
||||
"authorId": "uuid",
|
||||
"authorEmail": "user@example.com",
|
||||
"authorNickname": "display name",
|
||||
"publishedAt": "2026-05-20T00:00:00.000Z"
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"nextOffset": 1,
|
||||
"hasMore": false
|
||||
}
|
||||
```
|
||||
|
||||
### `PUT /api/admin/gallery/prompt`
|
||||
|
||||
Auth:
|
||||
|
||||
- Uses `requireAdminUser(request)` so the handler gets the admin user ID for logging.
|
||||
|
||||
Request body:
|
||||
|
||||
```json
|
||||
{
|
||||
"workId": "uuid",
|
||||
"prompt": "new public prompt",
|
||||
"emailSubject": "邮件标题",
|
||||
"emailBody": "邮件正文",
|
||||
"reasonKey": "remove_sensitive_words"
|
||||
}
|
||||
```
|
||||
|
||||
Validation:
|
||||
|
||||
- `workId` must be a UUID-like string.
|
||||
- `prompt` must be non-empty after trim.
|
||||
- `emailSubject` and `emailBody` must be non-empty after trim.
|
||||
- The work must still be public.
|
||||
- The author must have a valid email address.
|
||||
- The new prompt must differ from the old prompt after trim.
|
||||
|
||||
Processing order:
|
||||
|
||||
1. Authenticate admin and parse input.
|
||||
2. Load work plus author profile.
|
||||
3. Validate public status and author email.
|
||||
4. Send email through `sendTemplatedEmail`.
|
||||
5. Only after email success, update `works.prompt`.
|
||||
6. Write a platform log with moderation metadata.
|
||||
7. Return the updated work summary.
|
||||
|
||||
The SMTP send should not be wrapped in a long database transaction. Consistency is enforced by order: email failure blocks prompt update. If email succeeds but the database update fails, return an error and write an error log; the admin can retry or inspect logs.
|
||||
|
||||
Platform log metadata should include work ID, author ID/email, reason key, old/new prompt lengths, and whether the notification was sent. It must not store the full original or new prompt.
|
||||
|
||||
## Service Boundary
|
||||
|
||||
Create `src/lib/admin-gallery-prompt-service.ts` for the core workflow. The API route should remain thin: auth, request parsing, service call, JSON response.
|
||||
|
||||
The service accepts injected dependencies for tests:
|
||||
|
||||
- query-capable database client or adapter.
|
||||
- email sender function.
|
||||
- log writer function.
|
||||
|
||||
This keeps the rule "email success before prompt update" testable without SMTP or a real database.
|
||||
|
||||
## Error Handling
|
||||
|
||||
Expected errors:
|
||||
|
||||
- 401/403 when not admin.
|
||||
- 400 for invalid request body.
|
||||
- 400 when prompt is unchanged.
|
||||
- 400 when author email is missing or invalid.
|
||||
- 404 when the work does not exist or is not public.
|
||||
- 502 or 400 when email sending fails, with prompt unchanged.
|
||||
- 500 when update/logging has unexpected failures.
|
||||
|
||||
Frontend behavior:
|
||||
|
||||
- Keep the email dialog open on failure.
|
||||
- Preserve typed subject/body so the admin can retry.
|
||||
- Refresh the row after success.
|
||||
|
||||
## Testing
|
||||
|
||||
The project currently has no first-party test script. Add a minimal service-level TDD script rather than introducing a large framework.
|
||||
|
||||
Add:
|
||||
|
||||
- `scripts/test-admin-gallery-prompt-service.mjs`
|
||||
- `package.json` script: `test:admin-gallery-prompt`
|
||||
|
||||
TDD sequence:
|
||||
|
||||
1. Write failing tests for `src/lib/admin-gallery-prompt-service.ts`.
|
||||
2. Implement the service until tests pass.
|
||||
3. Add API route and UI.
|
||||
4. Run `pnpm run test:admin-gallery-prompt`.
|
||||
5. Run `pnpm run ts-check`.
|
||||
6. Run `pnpm run build` if environment allows.
|
||||
|
||||
Service tests must cover:
|
||||
|
||||
- Non-public works cannot be modified.
|
||||
- Missing or invalid author email blocks modification.
|
||||
- Unchanged prompt is rejected.
|
||||
- Email failure leaves prompt unchanged.
|
||||
- Email success updates prompt.
|
||||
- Logs include metadata but not full prompt text.
|
||||
|
||||
## Documentation Updates
|
||||
|
||||
Because this is a source/API/UI change, update project docs after implementation:
|
||||
|
||||
- `docs/codex-miaojing/api-reference.md`
|
||||
- `docs/codex-miaojing/feature-code-index.md`
|
||||
- `docs/codex-miaojing/architecture.md` if the moderation workflow needs architecture context.
|
||||
- `docs/codex-miaojing/bug-location-guide.md` if implementation reveals useful troubleshooting notes.
|
||||
|
||||
## Deployment Notes
|
||||
|
||||
This is a cold-update candidate because it changes source, UI, and API routes. Final delivery must mention:
|
||||
|
||||
- Create backup before production upgrade.
|
||||
- Do not overwrite production `.env.local`, `ecosystem.config.cjs`, runtime storage, database, or secrets.
|
||||
- Run type/build checks before deploy.
|
||||
- Restart/reload PM2 as required by the production process.
|
||||
- Health check `/api/health`.
|
||||
- Smoke check `/console`, the new gallery management tab, and `/gallery`.
|
||||
- Roll back with previous code/build and PM2 config if admin API, email, or gallery display fails.
|
||||
19
package.json
19
package.json
@@ -8,13 +8,31 @@
|
||||
"backup:create": "bash ./scripts/backup-create.sh",
|
||||
"backup:list": "bash ./scripts/backup-list.sh",
|
||||
"backup:restore": "bash ./scripts/backup-restore.sh",
|
||||
"upgrade:run": "node ./scripts/admin-upgrade-runner.mjs",
|
||||
"db:patch": "bash ./scripts/apply-database-patch.sh",
|
||||
"dev": "bash ./scripts/dev.sh",
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"lint": "eslint",
|
||||
"start": "bash ./scripts/start.sh",
|
||||
"test:admin-gallery-prompt": "node --no-warnings ./scripts/test-admin-gallery-prompt-service.mjs",
|
||||
"test:custom-image-fallback": "tsx ./scripts/test-custom-image-fallback.mjs",
|
||||
"test:custom-img2img-reference-url": "node --no-warnings ./scripts/test-custom-img2img-reference-url.mjs",
|
||||
"test:generation-credit-policy": "tsx ./scripts/test-generation-credit-policy.mjs",
|
||||
"test:creation-thumbnail-policy": "tsx ./scripts/test-creation-thumbnail-policy.mjs",
|
||||
"test:video-object-storage-actions": "tsx ./scripts/test-video-object-storage-actions.mjs",
|
||||
"test:gallery-publish-fast-path": "tsx ./scripts/test-gallery-publish-fast-path.mjs",
|
||||
"test:gallery-response": "node --no-warnings ./scripts/test-gallery-response.mjs",
|
||||
"test:media-watermark-policy": "tsx ./scripts/test-media-watermark-policy.mjs",
|
||||
"test:reference-image-prompt-links": "tsx ./scripts/test-reference-image-prompt-links.mjs",
|
||||
"test:yuanjie-media-manifest-mapping": "tsx ./scripts/test-yuanjie-media-manifest-mapping.mjs",
|
||||
"test:yuanjie-image2-persistence": "tsx ./scripts/test-yuanjie-image2-persistence.mjs",
|
||||
"test:yuanjie-pricing-sync": "tsx ./scripts/test-yuanjie-pricing-sync.mjs",
|
||||
"test:ops-hardening": "node --no-warnings ./scripts/test-ops-hardening.mjs",
|
||||
"pm2:restart": "pm2 startOrReload ecosystem.config.cjs --update-env",
|
||||
"pm2:save": "pm2 save",
|
||||
"migration:check": "node ./scripts/migration-integrity-check.mjs",
|
||||
"rainyun:ros-prepare": "node ./scripts/rainyun-ros-prepare.mjs",
|
||||
"storage:sync-object": "node ./scripts/storage-sync-to-object.mjs",
|
||||
"ts-check": "tsc -p tsconfig.json",
|
||||
"check:boundaries": "bash ./scripts/check-boundaries.sh"
|
||||
},
|
||||
@@ -58,6 +76,7 @@
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"drizzle-zod": "^0.8.3",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next": "16.2.4",
|
||||
|
||||
104
pnpm-lock.yaml
generated
104
pnpm-lock.yaml
generated
@@ -132,6 +132,9 @@ importers:
|
||||
embla-carousel-react:
|
||||
specifier: ^8.6.0
|
||||
version: 8.6.0(react@19.2.3)
|
||||
ffmpeg-static:
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0
|
||||
input-otp:
|
||||
specifier: ^1.4.2
|
||||
version: 1.4.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
@@ -584,6 +587,10 @@ packages:
|
||||
'@date-fns/tz@1.4.1':
|
||||
resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==}
|
||||
|
||||
'@derhuerst/http-basic@8.2.4':
|
||||
resolution: {integrity: sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
'@dotenvx/dotenvx@1.52.0':
|
||||
resolution: {integrity: sha512-CaQcc8JvtzQhUSm9877b6V4Tb7HCotkcyud9X2YwdqtQKwgljkMRwU96fVYKnzN3V0Hj74oP7Es+vZ0mS+Aa1w==}
|
||||
hasBin: true
|
||||
@@ -1403,24 +1410,28 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-arm64-musl@16.2.4':
|
||||
resolution: {integrity: sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-linux-x64-gnu@16.2.4':
|
||||
resolution: {integrity: sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-x64-musl@16.2.4':
|
||||
resolution: {integrity: sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-win32-arm64-msvc@16.2.4':
|
||||
resolution: {integrity: sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==}
|
||||
@@ -2689,6 +2700,9 @@ packages:
|
||||
'@types/ms@2.1.0':
|
||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||
|
||||
'@types/node@10.17.60':
|
||||
resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==}
|
||||
|
||||
'@types/node@20.19.30':
|
||||
resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==}
|
||||
|
||||
@@ -2833,41 +2847,49 @@ packages:
|
||||
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
|
||||
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
|
||||
@@ -2969,6 +2991,10 @@ packages:
|
||||
resolution: {integrity: sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
|
||||
agent-base@6.0.2:
|
||||
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
|
||||
engines: {node: '>= 6.0.0'}
|
||||
|
||||
agent-base@7.1.4:
|
||||
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||
engines: {node: '>= 14'}
|
||||
@@ -3214,6 +3240,9 @@ packages:
|
||||
caniuse-lite@1.0.30001790:
|
||||
resolution: {integrity: sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==}
|
||||
|
||||
caseless@0.12.0:
|
||||
resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==}
|
||||
|
||||
ccount@2.0.1:
|
||||
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
||||
|
||||
@@ -3316,6 +3345,10 @@ packages:
|
||||
concat-map@0.0.1:
|
||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||
|
||||
concat-stream@2.0.0:
|
||||
resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==}
|
||||
engines: {'0': node >= 6.0}
|
||||
|
||||
confbox@0.1.8:
|
||||
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
|
||||
|
||||
@@ -4021,6 +4054,10 @@ packages:
|
||||
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
|
||||
engines: {node: ^12.20 || >= 14.13}
|
||||
|
||||
ffmpeg-static@5.3.0:
|
||||
resolution: {integrity: sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
figures@6.1.0:
|
||||
resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -4298,6 +4335,13 @@ packages:
|
||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
http-response-object@3.0.2:
|
||||
resolution: {integrity: sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==}
|
||||
|
||||
https-proxy-agent@5.0.1:
|
||||
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
https-proxy-agent@7.0.6:
|
||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||
engines: {node: '>= 14'}
|
||||
@@ -5249,6 +5293,9 @@ packages:
|
||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
parse-cache-control@1.0.1:
|
||||
resolution: {integrity: sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==}
|
||||
|
||||
parse-entities@4.0.2:
|
||||
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
|
||||
|
||||
@@ -5428,6 +5475,10 @@ packages:
|
||||
resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
progress@2.0.3:
|
||||
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
prompts@2.4.2:
|
||||
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -6111,6 +6162,9 @@ packages:
|
||||
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
typedarray@0.0.6:
|
||||
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
|
||||
|
||||
typescript-eslint@8.54.0:
|
||||
resolution: {integrity: sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -7124,6 +7178,13 @@ snapshots:
|
||||
|
||||
'@date-fns/tz@1.4.1': {}
|
||||
|
||||
'@derhuerst/http-basic@8.2.4':
|
||||
dependencies:
|
||||
caseless: 0.12.0
|
||||
concat-stream: 2.0.0
|
||||
http-response-object: 3.0.2
|
||||
parse-cache-control: 1.0.1
|
||||
|
||||
'@dotenvx/dotenvx@1.52.0':
|
||||
dependencies:
|
||||
commander: 11.1.0
|
||||
@@ -9088,6 +9149,8 @@ snapshots:
|
||||
|
||||
'@types/ms@2.1.0': {}
|
||||
|
||||
'@types/node@10.17.60': {}
|
||||
|
||||
'@types/node@20.19.30':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
@@ -9381,6 +9444,12 @@ snapshots:
|
||||
|
||||
address@1.2.2: {}
|
||||
|
||||
agent-base@6.0.2:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
agent-base@7.1.4: {}
|
||||
|
||||
ajv-formats@2.1.1(ajv@8.18.0):
|
||||
@@ -9647,6 +9716,8 @@ snapshots:
|
||||
|
||||
caniuse-lite@1.0.30001790: {}
|
||||
|
||||
caseless@0.12.0: {}
|
||||
|
||||
ccount@2.0.1: {}
|
||||
|
||||
chalk@4.1.2:
|
||||
@@ -9740,6 +9811,13 @@ snapshots:
|
||||
|
||||
concat-map@0.0.1: {}
|
||||
|
||||
concat-stream@2.0.0:
|
||||
dependencies:
|
||||
buffer-from: 1.1.2
|
||||
inherits: 2.0.4
|
||||
readable-stream: 3.6.2
|
||||
typedarray: 0.0.6
|
||||
|
||||
confbox@0.1.8: {}
|
||||
|
||||
consola@3.4.2: {}
|
||||
@@ -10576,6 +10654,15 @@ snapshots:
|
||||
node-domexception: 1.0.0
|
||||
web-streams-polyfill: 3.3.3
|
||||
|
||||
ffmpeg-static@5.3.0:
|
||||
dependencies:
|
||||
'@derhuerst/http-basic': 8.2.4
|
||||
env-paths: 2.2.1
|
||||
https-proxy-agent: 5.0.1
|
||||
progress: 2.0.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
figures@6.1.0:
|
||||
dependencies:
|
||||
is-unicode-supported: 2.1.0
|
||||
@@ -10878,6 +10965,17 @@ snapshots:
|
||||
statuses: 2.0.2
|
||||
toidentifier: 1.0.1
|
||||
|
||||
http-response-object@3.0.2:
|
||||
dependencies:
|
||||
'@types/node': 10.17.60
|
||||
|
||||
https-proxy-agent@5.0.1:
|
||||
dependencies:
|
||||
agent-base: 6.0.2
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
https-proxy-agent@7.0.6:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
@@ -11951,6 +12049,8 @@ snapshots:
|
||||
dependencies:
|
||||
callsites: 3.1.0
|
||||
|
||||
parse-cache-control@1.0.1: {}
|
||||
|
||||
parse-entities@4.0.2:
|
||||
dependencies:
|
||||
'@types/unist': 2.0.11
|
||||
@@ -12093,6 +12193,8 @@ snapshots:
|
||||
dependencies:
|
||||
parse-ms: 4.0.0
|
||||
|
||||
progress@2.0.3: {}
|
||||
|
||||
prompts@2.4.2:
|
||||
dependencies:
|
||||
kleur: 3.0.3
|
||||
@@ -13010,6 +13112,8 @@ snapshots:
|
||||
possible-typed-array-names: 1.1.0
|
||||
reflect.getprototypeof: 1.0.10
|
||||
|
||||
typedarray@0.0.6: {}
|
||||
|
||||
typescript-eslint@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
|
||||
BIN
public/watermark/miaojing-watermark-logo.png
Normal file
BIN
public/watermark/miaojing-watermark-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
720
scripts/admin-upgrade-runner.mjs
Executable file
720
scripts/admin-upgrade-runner.mjs
Executable file
@@ -0,0 +1,720 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const projectRoot = path.resolve(args.project || process.cwd());
|
||||
loadEnvFile(path.join(projectRoot, '.env.local'));
|
||||
|
||||
const stateRoot = path.resolve(
|
||||
process.env.UPGRADE_STATE_DIR ||
|
||||
(process.env.LOCAL_STORAGE_DIR ? path.join(path.dirname(process.env.LOCAL_STORAGE_DIR), 'upgrade') : path.join(projectRoot, 'upgrade-state')),
|
||||
);
|
||||
|
||||
const jobId = requireArg(args, 'job-id');
|
||||
const mode = requireArg(args, 'mode');
|
||||
const dryRun = args['dry-run'] === 'true';
|
||||
const packagePath = path.resolve(requireArg(args, 'package'));
|
||||
const packageName = args['package-name'] || path.basename(packagePath);
|
||||
const jobDir = path.join(stateRoot, 'jobs', jobId);
|
||||
const stateFile = path.join(jobDir, 'state.json');
|
||||
const extractDir = path.join(jobDir, 'extract');
|
||||
const sourceBackupFile = path.join(jobDir, `source-before-${jobId}.tar.gz`);
|
||||
|
||||
const HOT_ALLOWED_PREFIXES = ['public/'];
|
||||
const HOT_ALLOWED_FILES = new Set([
|
||||
'manifest.json',
|
||||
'robots.txt',
|
||||
'sitemap.xml',
|
||||
'favicon.ico',
|
||||
'icon.png',
|
||||
'apple-icon.png',
|
||||
]);
|
||||
|
||||
const COLD_ALLOWED_PREFIXES = ['src/', 'public/', 'scripts/', 'database/', 'docs/'];
|
||||
const COLD_ALLOWED_FILES = new Set([
|
||||
'manifest.json',
|
||||
'package.json',
|
||||
'pnpm-lock.yaml',
|
||||
'next.config.js',
|
||||
'next.config.mjs',
|
||||
'next.config.ts',
|
||||
'tsconfig.json',
|
||||
'postcss.config.mjs',
|
||||
'components.json',
|
||||
'ecosystem.config.cjs',
|
||||
]);
|
||||
|
||||
const BLOCKED_TOP_LEVEL_NAMES = new Set(['.git', 'node_modules', '.next', 'dist', 'backups', 'local-storage', 'upgrade-state']);
|
||||
const BLOCKED_ANYWHERE_NAMES = new Set(['.git', 'node_modules', '.next']);
|
||||
const MAX_EXTRACTED_FILES = Number(process.env.UPGRADE_MAX_EXTRACTED_FILES || 5000);
|
||||
const MAX_EXTRACTED_BYTES = Number(process.env.UPGRADE_MAX_EXTRACTED_BYTES || 500 * 1024 * 1024);
|
||||
const MAX_EXTRACTED_FILE_BYTES = Number(process.env.UPGRADE_MAX_EXTRACTED_FILE_BYTES || 200 * 1024 * 1024);
|
||||
const MIN_FREE_BYTES = Number(process.env.UPGRADE_MIN_FREE_BYTES || 1024 * 1024 * 1024);
|
||||
const BUILD_FREE_BYTES = Number(process.env.UPGRADE_BUILD_FREE_BYTES || 1024 * 1024 * 1024);
|
||||
const PAYLOAD_TOP_LEVEL_DIRECTORIES = new Set([
|
||||
...HOT_ALLOWED_PREFIXES.map(prefix => prefix.replace(/\/$/, '')),
|
||||
...COLD_ALLOWED_PREFIXES.map(prefix => prefix.replace(/\/$/, '')),
|
||||
...BLOCKED_TOP_LEVEL_NAMES,
|
||||
]);
|
||||
|
||||
let state = readState() || {
|
||||
id: jobId,
|
||||
mode,
|
||||
status: 'queued',
|
||||
step: 'queued',
|
||||
message: '升级任务已创建',
|
||||
progress: 0,
|
||||
packageName,
|
||||
startedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
logs: [],
|
||||
};
|
||||
|
||||
main().catch(error => {
|
||||
log(`fatal: ${error instanceof Error ? error.stack || error.message : String(error)}`);
|
||||
if (dryRun) {
|
||||
cleanupExtractDir();
|
||||
updateState({
|
||||
status: 'failed',
|
||||
step: 'preflight_failed',
|
||||
progress: 100,
|
||||
message: '升级包预检失败,请按错误信息调整升级包',
|
||||
error: error instanceof Error ? error.message : '升级包预检异常退出',
|
||||
finishedAt: new Date().toISOString(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
rollbackAfterFailure(error instanceof Error ? error.message : '升级任务异常退出').catch(rollbackError => {
|
||||
updateState({
|
||||
status: 'rollback_failed',
|
||||
step: 'rollback_failed',
|
||||
progress: 100,
|
||||
message: '升级失败,自动回滚也失败,请立即人工检查',
|
||||
error: `${error instanceof Error ? error.message : String(error)}; rollback: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
|
||||
finishedAt: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function main() {
|
||||
ensureDir(jobDir);
|
||||
updateState({
|
||||
status: 'running',
|
||||
step: 'preflight',
|
||||
progress: 5,
|
||||
message: '正在检查升级包与运行环境',
|
||||
startedAt: state.startedAt || new Date().toISOString(),
|
||||
});
|
||||
logStep('开始升级任务', `任务 ${jobId} 使用${mode === 'hot' ? '热更新' : '冷更新'}模式,升级包 ${packageName}${dryRun ? ',仅执行预检' : ''}`);
|
||||
|
||||
if (mode !== 'hot' && mode !== 'cold') {
|
||||
throw new Error('升级方式无效');
|
||||
}
|
||||
if (!fs.existsSync(packagePath)) {
|
||||
throw new Error(`升级包不存在: ${packagePath}`);
|
||||
}
|
||||
if (!isAllowedArchive(packageName) && !isAllowedArchive(packagePath)) {
|
||||
throw new Error('仅支持 .tar、.tar.gz、.tgz 升级包');
|
||||
}
|
||||
const packageBytes = fs.statSync(packagePath).size;
|
||||
const preExtractDiskChecks = validatePreExtractDiskSpace(packageBytes);
|
||||
updateState({ diskChecks: preExtractDiskChecks });
|
||||
|
||||
logStep('校验升级包', '正在读取压缩包目录并检查格式');
|
||||
run('tar', tarReadArgs('list', packagePath), { cwd: projectRoot, label: '检查升级包结构' });
|
||||
|
||||
resetDir(extractDir);
|
||||
run('tar', [...tarReadArgs('extract', packagePath), '-C', extractDir], { cwd: projectRoot, label: '解压升级包' });
|
||||
|
||||
const payloadRoot = resolvePayloadRoot(extractDir);
|
||||
const packageStats = collectPackageStats(payloadRoot);
|
||||
const files = packageStats.files;
|
||||
if (files.length === 0) {
|
||||
throw new Error('升级包为空');
|
||||
}
|
||||
validatePackageSize(packageStats);
|
||||
const diskChecks = validateUpgradeDiskSpace(packageStats, packageBytes);
|
||||
|
||||
const validation = validateFiles(files, mode);
|
||||
logStep('升级包内容', `校验通过,共 ${files.length} 个文件,解压后 ${formatBytes(packageStats.totalBytes)}:${files.slice(0, 20).join('、')}${files.length > 20 ? ` 等 ${files.length} 个文件` : ''}`);
|
||||
updateState({
|
||||
step: 'validated',
|
||||
progress: 14,
|
||||
message: `升级包校验通过,共 ${files.length} 个文件`,
|
||||
restartRequired: mode === 'cold' || validation.requiresRestart,
|
||||
packageHash: sha256(packagePath),
|
||||
changedFiles: files,
|
||||
extractedFileCount: packageStats.files.length,
|
||||
extractedBytes: packageStats.totalBytes,
|
||||
largestFileBytes: packageStats.largestFileBytes,
|
||||
diskChecks,
|
||||
dryRun,
|
||||
});
|
||||
|
||||
if (dryRun) {
|
||||
logStep('预检完成', `升级包可用于${mode === 'hot' ? '热更新' : '冷更新'},${mode === 'cold' || validation.requiresRestart ? '需要重启平台' : '无需重启平台'}`);
|
||||
cleanupExtractDir();
|
||||
updateState({
|
||||
status: 'succeeded',
|
||||
step: 'preflight_completed',
|
||||
progress: 100,
|
||||
message: `预检通过:共 ${files.length} 个文件,${mode === 'cold' || validation.requiresRestart ? '执行时需要重启平台' : '执行时无需重启平台'}`,
|
||||
finishedAt: new Date().toISOString(),
|
||||
restartRequired: mode === 'cold' || validation.requiresRestart,
|
||||
dryRun: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
updateState({ step: 'backup_data', progress: 22, message: '正在创建数据库、存储与环境配置备份' });
|
||||
logStep('创建数据备份', '开始备份数据库、存储目录和环境配置');
|
||||
const backupFile = runCapture('bash', ['./scripts/backup-create.sh'], {
|
||||
cwd: projectRoot,
|
||||
label: '创建数据备份',
|
||||
env: { BACKUP_DIR: path.join(stateRoot, 'data-backups'), COZE_WORKSPACE_PATH: projectRoot },
|
||||
}).trim().split('\n').pop();
|
||||
if (!backupFile || !fs.existsSync(backupFile)) {
|
||||
throw new Error('数据备份创建失败');
|
||||
}
|
||||
verifyTarArchive(backupFile, '校验数据备份');
|
||||
const backupHash = sha256(backupFile);
|
||||
updateState({ backupFile, backupHash });
|
||||
logStep('数据备份完成', `备份文件:${backupFile},SHA256:${backupHash}`);
|
||||
|
||||
updateState({ step: 'backup_source', progress: 30, message: '正在创建源码快照' });
|
||||
logStep('创建源码快照', '开始保存升级前源码状态');
|
||||
createSourceBackup(sourceBackupFile);
|
||||
verifyTarArchive(sourceBackupFile, '校验源码快照');
|
||||
const sourceBackupHash = sha256(sourceBackupFile);
|
||||
updateState({ sourceBackupFile, sourceBackupHash });
|
||||
logStep('源码快照完成', `快照文件:${sourceBackupFile},SHA256:${sourceBackupHash}`);
|
||||
|
||||
updateState({ step: 'apply', progress: 42, message: '正在应用升级包文件' });
|
||||
logStep('应用升级文件', '开始覆盖升级包中的文件');
|
||||
updateState({ preExistingFiles: files.filter(file => fs.existsSync(path.join(projectRoot, file))) });
|
||||
applyFiles(payloadRoot, files);
|
||||
logStep('升级文件应用完成', `已应用 ${files.filter(file => file !== 'manifest.json').length} 个文件`);
|
||||
|
||||
if (mode === 'hot') {
|
||||
updateState({ step: 'verify_hot', progress: 70, message: '正在验证热更新文件' });
|
||||
logStep('热更新验证', '正在执行 TypeScript 校验,确认补丁不会破坏现有代码');
|
||||
run('pnpm', ['run', 'ts-check'], { cwd: projectRoot, label: 'TypeScript 校验' });
|
||||
logStep('热更新完成', '升级成功,平台未重启,前端业务不中断');
|
||||
cleanupExtractDir();
|
||||
updateState({
|
||||
status: 'succeeded',
|
||||
step: 'completed',
|
||||
progress: 100,
|
||||
message: '热更新成功,平台未重启',
|
||||
finishedAt: new Date().toISOString(),
|
||||
restartRequired: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const dependencyChanged = files.some(file => file === 'package.json' || file === 'pnpm-lock.yaml');
|
||||
if (dependencyChanged) {
|
||||
updateState({ step: 'install', progress: 54, message: '依赖文件发生变化,正在安装依赖' });
|
||||
logStep('安装依赖', '检测到 package.json 或 pnpm-lock.yaml 变化,开始安装依赖');
|
||||
run('pnpm', ['install', '--frozen-lockfile', '--prod=false'], { cwd: projectRoot, label: '安装依赖' });
|
||||
logStep('依赖安装完成', '依赖安装已完成');
|
||||
}
|
||||
|
||||
updateState({ step: 'ts_check', progress: 64, message: '正在执行 TypeScript 校验' });
|
||||
logStep('代码校验', '开始执行 TypeScript 校验');
|
||||
run('pnpm', ['run', 'ts-check'], { cwd: projectRoot, label: 'TypeScript 校验' });
|
||||
logStep('代码校验完成', 'TypeScript 校验已通过');
|
||||
|
||||
updateState({ step: 'build', progress: 75, message: '正在构建平台' });
|
||||
logStep('平台构建', '开始构建生产版本');
|
||||
run('pnpm', ['run', 'build'], { cwd: projectRoot, label: '构建平台' });
|
||||
logStep('平台构建完成', '生产构建已完成');
|
||||
|
||||
updateState({ step: 'restart', progress: 94, message: '构建已完成,正在后台重启平台进程' });
|
||||
logStep('冷更新完成', '升级文件已应用并完成构建,将在后台重启平台进程');
|
||||
cleanupExtractDir();
|
||||
updateState({
|
||||
status: 'succeeded',
|
||||
step: 'completed',
|
||||
progress: 100,
|
||||
message: '冷更新成功,平台正在后台重启',
|
||||
finishedAt: new Date().toISOString(),
|
||||
restartRequired: true,
|
||||
});
|
||||
restartPlatform({ detached: true });
|
||||
}
|
||||
|
||||
async function rollbackAfterFailure(message) {
|
||||
const originalError = message;
|
||||
logStep('升级失败', `失败原因:${originalError}`);
|
||||
updateState({
|
||||
status: 'rolling_back',
|
||||
step: 'rolling_back',
|
||||
progress: 96,
|
||||
message: '升级失败,正在自动回滚到升级前状态',
|
||||
error: originalError,
|
||||
});
|
||||
|
||||
if (fs.existsSync(sourceBackupFile)) {
|
||||
logStep('回滚源码', '正在恢复升级前源码快照,并移除升级中新建的文件');
|
||||
restoreSourceBackup(sourceBackupFile);
|
||||
logStep('源码回滚完成', '源码已恢复到升级开始前状态');
|
||||
}
|
||||
|
||||
if (state.backupFile && fs.existsSync(state.backupFile)) {
|
||||
logStep('回滚数据', '正在恢复数据库、存储目录和环境配置备份');
|
||||
run('bash', ['./scripts/backup-restore.sh', state.backupFile], {
|
||||
cwd: projectRoot,
|
||||
label: '恢复数据备份',
|
||||
env: {
|
||||
COZE_WORKSPACE_PATH: projectRoot,
|
||||
RESTORE_SAFETY_DIR: path.join(stateRoot, 'restore-safety'),
|
||||
},
|
||||
});
|
||||
logStep('数据回滚完成', '数据库、存储目录和环境配置已恢复');
|
||||
}
|
||||
|
||||
if (mode === 'cold') {
|
||||
try {
|
||||
logStep('回滚后重建', '冷更新失败后正在重新构建回滚版本');
|
||||
run('pnpm', ['run', 'build'], { cwd: projectRoot, label: '回滚后重新构建' });
|
||||
logStep('回滚后重启', '将后台重启回滚后的平台版本');
|
||||
} catch (error) {
|
||||
throw new Error(`回滚后平台恢复检查失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
logStep('自动回滚完成', '升级失败,但已自动恢复到升级开始前状态');
|
||||
cleanupExtractDir();
|
||||
updateState({
|
||||
status: 'rolled_back',
|
||||
step: 'rolled_back',
|
||||
progress: 100,
|
||||
message: '升级失败,已自动回滚到升级开始前状态',
|
||||
error: originalError,
|
||||
finishedAt: new Date().toISOString(),
|
||||
});
|
||||
if (mode === 'cold') {
|
||||
restartPlatform({ detached: true });
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const parsed = {};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const item = argv[index];
|
||||
if (!item.startsWith('--')) continue;
|
||||
const key = item.slice(2);
|
||||
const next = argv[index + 1];
|
||||
if (!next || next.startsWith('--')) {
|
||||
parsed[key] = 'true';
|
||||
} else {
|
||||
parsed[key] = next;
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function requireArg(parsed, key) {
|
||||
const value = parsed[key];
|
||||
if (!value) throw new Error(`missing --${key}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function loadEnvFile(file) {
|
||||
if (!fs.existsSync(file)) return;
|
||||
const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#') || !trimmed.includes('=')) continue;
|
||||
const index = trimmed.indexOf('=');
|
||||
const key = trimmed.slice(0, index).trim();
|
||||
let value = trimmed.slice(index + 1).trim();
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if (!process.env[key]) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function readState() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateState(patch) {
|
||||
state = {
|
||||
...state,
|
||||
...patch,
|
||||
updatedAt: new Date().toISOString(),
|
||||
logs: patch.logs || state.logs || [],
|
||||
};
|
||||
ensureDir(path.dirname(stateFile));
|
||||
const tempFile = `${stateFile}.tmp`;
|
||||
fs.writeFileSync(tempFile, `${JSON.stringify(state, null, 2)}\n`);
|
||||
fs.renameSync(tempFile, stateFile);
|
||||
}
|
||||
|
||||
function log(line) {
|
||||
const timestamped = `[${new Date().toISOString()}] ${line}`;
|
||||
const logs = [...(state.logs || []), timestamped].slice(-1000);
|
||||
updateState({ logs });
|
||||
}
|
||||
|
||||
function logStep(title, detail = '') {
|
||||
log(detail ? `${title}:${detail}` : title);
|
||||
}
|
||||
|
||||
function ensureDir(dir) {
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
function resetDir(dir) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
ensureDir(dir);
|
||||
}
|
||||
|
||||
function cleanupExtractDir() {
|
||||
if (!fs.existsSync(extractDir)) return;
|
||||
fs.rmSync(extractDir, { recursive: true, force: true });
|
||||
logStep('清理解压目录', `已删除临时目录:${extractDir}`);
|
||||
}
|
||||
|
||||
function run(command, commandArgs, options = {}) {
|
||||
runCapture(command, commandArgs, options);
|
||||
}
|
||||
|
||||
function runCapture(command, commandArgs, options = {}) {
|
||||
const label = options.label || command;
|
||||
logStep(label, `执行命令 ${command} ${commandArgs.join(' ')}`);
|
||||
const result = spawnSync(command, commandArgs, {
|
||||
cwd: options.cwd || projectRoot,
|
||||
env: { ...process.env, COREPACK_HOME: process.env.COREPACK_HOME || '/tmp/corepack', ...(options.env || {}) },
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 20 * 1024 * 1024,
|
||||
});
|
||||
const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
|
||||
if (output) {
|
||||
for (const line of output.split(/\r?\n/).slice(-180)) log(`${label}输出:${line}`);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${label}失败,退出码 ${result.status ?? 'unknown'}`);
|
||||
}
|
||||
return result.stdout || '';
|
||||
}
|
||||
|
||||
function isAllowedArchive(file) {
|
||||
return file.endsWith('.tar') || file.endsWith('.tar.gz') || file.endsWith('.tgz');
|
||||
}
|
||||
|
||||
function resolvePayloadRoot(root) {
|
||||
const entries = fs.readdirSync(root, { withFileTypes: true }).filter(entry => entry.name !== '__MACOSX');
|
||||
if (entries.length === 1 && entries[0].isDirectory() && !PAYLOAD_TOP_LEVEL_DIRECTORIES.has(entries[0].name)) {
|
||||
return path.join(root, entries[0].name);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
function collectPackageStats(root) {
|
||||
const files = [];
|
||||
let totalBytes = 0;
|
||||
let largestFileBytes = 0;
|
||||
walk(root, '');
|
||||
return { files: files.sort(), totalBytes, largestFileBytes };
|
||||
|
||||
function walk(currentRoot, relativeRoot) {
|
||||
for (const entry of fs.readdirSync(currentRoot, { withFileTypes: true })) {
|
||||
if (entry.name === '.DS_Store') continue;
|
||||
const relative = toPosix(path.join(relativeRoot, entry.name));
|
||||
const absolute = path.join(currentRoot, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(absolute, relative);
|
||||
} else if (entry.isFile()) {
|
||||
const stat = fs.statSync(absolute);
|
||||
totalBytes += stat.size;
|
||||
largestFileBytes = Math.max(largestFileBytes, stat.size);
|
||||
files.push(relative);
|
||||
} else {
|
||||
throw new Error(`升级包包含不支持的文件类型: ${relative}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validatePackageSize(stats) {
|
||||
if (Number.isFinite(MAX_EXTRACTED_FILES) && stats.files.length > MAX_EXTRACTED_FILES) {
|
||||
throw new Error(`升级包文件数量过多:${stats.files.length} 个,最多允许 ${MAX_EXTRACTED_FILES} 个`);
|
||||
}
|
||||
if (Number.isFinite(MAX_EXTRACTED_BYTES) && stats.totalBytes > MAX_EXTRACTED_BYTES) {
|
||||
throw new Error(`升级包解压后过大:${formatBytes(stats.totalBytes)},最多允许 ${formatBytes(MAX_EXTRACTED_BYTES)}`);
|
||||
}
|
||||
if (Number.isFinite(MAX_EXTRACTED_FILE_BYTES) && stats.largestFileBytes > MAX_EXTRACTED_FILE_BYTES) {
|
||||
throw new Error(`升级包包含过大的单个文件:${formatBytes(stats.largestFileBytes)},最多允许 ${formatBytes(MAX_EXTRACTED_FILE_BYTES)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validatePreExtractDiskSpace(packageBytes) {
|
||||
const stateCheck = buildDiskCheck({
|
||||
label: '升级状态目录',
|
||||
targetPath: stateRoot,
|
||||
requiredBytes: packageBytes + MAX_EXTRACTED_BYTES + MIN_FREE_BYTES,
|
||||
});
|
||||
assertDiskSpace(stateCheck);
|
||||
logDiskCheck(stateCheck);
|
||||
return [stateCheck];
|
||||
}
|
||||
|
||||
function validateUpgradeDiskSpace(stats, packageBytes) {
|
||||
const checks = [
|
||||
buildDiskCheck({
|
||||
label: '升级状态目录',
|
||||
targetPath: stateRoot,
|
||||
requiredBytes: packageBytes + stats.totalBytes + MIN_FREE_BYTES,
|
||||
}),
|
||||
buildDiskCheck({
|
||||
label: '项目目录',
|
||||
targetPath: projectRoot,
|
||||
requiredBytes: stats.totalBytes + (mode === 'cold' ? BUILD_FREE_BYTES : MIN_FREE_BYTES),
|
||||
}),
|
||||
];
|
||||
for (const check of checks) {
|
||||
assertDiskSpace(check);
|
||||
logDiskCheck(check);
|
||||
}
|
||||
return checks;
|
||||
}
|
||||
|
||||
function buildDiskCheck({ label, targetPath, requiredBytes }) {
|
||||
const usage = readDiskUsage(targetPath);
|
||||
return {
|
||||
label,
|
||||
path: path.resolve(targetPath),
|
||||
mountPath: usage.mountPath,
|
||||
totalBytes: usage.totalBytes,
|
||||
availableBytes: usage.availableBytes,
|
||||
requiredBytes,
|
||||
usedPercent: usage.usedPercent,
|
||||
};
|
||||
}
|
||||
|
||||
function readDiskUsage(targetPath) {
|
||||
ensureDir(targetPath);
|
||||
const result = spawnSync('df', ['-Pk', targetPath], { encoding: 'utf8', timeout: 5000 });
|
||||
if (result.status !== 0 || !result.stdout) {
|
||||
const detail = `${result.stderr || result.stdout || ''}`.trim();
|
||||
throw new Error(`读取磁盘空间失败:${targetPath}${detail ? `;${detail}` : ''}`);
|
||||
}
|
||||
const lines = result.stdout.trim().split(/\r?\n/);
|
||||
const row = lines[lines.length - 1]?.trim().split(/\s+/);
|
||||
if (!row || row.length < 6) {
|
||||
throw new Error(`读取磁盘空间失败:${targetPath}`);
|
||||
}
|
||||
const totalBytes = Number(row[1]) * 1024;
|
||||
const availableBytes = Number(row[3]) * 1024;
|
||||
const usedPercent = Number(row[4].replace('%', ''));
|
||||
if (!Number.isFinite(totalBytes) || !Number.isFinite(availableBytes)) {
|
||||
throw new Error(`读取磁盘空间失败:${targetPath}`);
|
||||
}
|
||||
return {
|
||||
mountPath: row.slice(5).join(' ') || targetPath,
|
||||
totalBytes,
|
||||
availableBytes,
|
||||
usedPercent: Number.isFinite(usedPercent) ? usedPercent : null,
|
||||
};
|
||||
}
|
||||
|
||||
function assertDiskSpace(check) {
|
||||
if (!Number.isFinite(check.requiredBytes) || check.requiredBytes <= 0) return;
|
||||
if (check.availableBytes >= check.requiredBytes) return;
|
||||
throw new Error(`升级前磁盘空间不足:${check.label} ${check.path} 可用 ${formatBytes(check.availableBytes)},需要至少 ${formatBytes(check.requiredBytes)}`);
|
||||
}
|
||||
|
||||
function logDiskCheck(check) {
|
||||
const mountDetail = check.mountPath && check.mountPath !== check.path ? `(挂载点 ${check.mountPath})` : '';
|
||||
logStep('磁盘空间检查', `${check.label} ${check.path}${mountDetail} 可用 ${formatBytes(check.availableBytes)},需要 ${formatBytes(check.requiredBytes)}`);
|
||||
}
|
||||
|
||||
function validateFiles(files, updateMode) {
|
||||
for (const file of files) {
|
||||
assertSafeRelativePath(file);
|
||||
if (isBlockedPackagePath(file)) {
|
||||
throw new Error(`升级包包含禁止覆盖的路径: ${file}`);
|
||||
}
|
||||
if (updateMode === 'hot' && !isHotAllowed(file)) {
|
||||
throw new Error(`热更新只能包含 public 等无需重启的静态资源;${file} 需要使用冷更新`);
|
||||
}
|
||||
if (updateMode === 'cold' && !isColdAllowed(file)) {
|
||||
throw new Error(`冷更新包包含未授权路径: ${file}`);
|
||||
}
|
||||
}
|
||||
return { requiresRestart: files.some(file => !isHotAllowed(file)) };
|
||||
}
|
||||
|
||||
function isBlockedPackagePath(file) {
|
||||
const parts = file.split('/');
|
||||
return (
|
||||
parts.some(part => part.startsWith('.env')) ||
|
||||
BLOCKED_TOP_LEVEL_NAMES.has(parts[0]) ||
|
||||
parts.some(part => BLOCKED_ANYWHERE_NAMES.has(part))
|
||||
);
|
||||
}
|
||||
|
||||
function assertSafeRelativePath(file) {
|
||||
if (!file || file.startsWith('/') || file.startsWith('\\') || file.includes('\\')) {
|
||||
throw new Error(`升级包包含非法路径: ${file}`);
|
||||
}
|
||||
const normalized = path.posix.normalize(file);
|
||||
if (normalized !== file || normalized === '.' || normalized.startsWith('../') || normalized.includes('/../')) {
|
||||
throw new Error(`升级包包含目录穿越路径: ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isHotAllowed(file) {
|
||||
return HOT_ALLOWED_FILES.has(file) || HOT_ALLOWED_PREFIXES.some(prefix => file.startsWith(prefix));
|
||||
}
|
||||
|
||||
function isColdAllowed(file) {
|
||||
return COLD_ALLOWED_FILES.has(file) || COLD_ALLOWED_PREFIXES.some(prefix => file.startsWith(prefix));
|
||||
}
|
||||
|
||||
function applyFiles(root, files) {
|
||||
for (const file of files) {
|
||||
if (file === 'manifest.json') continue;
|
||||
const source = path.join(root, file);
|
||||
const target = path.join(projectRoot, file);
|
||||
ensureDir(path.dirname(target));
|
||||
fs.copyFileSync(source, target);
|
||||
}
|
||||
}
|
||||
|
||||
function createSourceBackup(target) {
|
||||
ensureDir(path.dirname(target));
|
||||
run('tar', [
|
||||
'-czf',
|
||||
target,
|
||||
'--exclude=.git',
|
||||
'--exclude=node_modules',
|
||||
'--exclude=.next',
|
||||
'--exclude=dist',
|
||||
'--exclude=backups',
|
||||
'--exclude=./local-storage',
|
||||
'--exclude=upgrade-state',
|
||||
'--exclude=tsconfig.tsbuildinfo',
|
||||
'-C',
|
||||
projectRoot,
|
||||
'.',
|
||||
], { cwd: projectRoot, label: '创建源码快照' });
|
||||
}
|
||||
|
||||
function restoreSourceBackup(source) {
|
||||
log(`恢复源码快照: ${source}`);
|
||||
const preExistingFiles = new Set(Array.isArray(state.preExistingFiles) ? state.preExistingFiles : []);
|
||||
const changedFiles = Array.isArray(state.changedFiles) ? state.changedFiles : [];
|
||||
for (const file of changedFiles) {
|
||||
if (file === 'manifest.json' || preExistingFiles.has(file)) continue;
|
||||
const target = path.join(projectRoot, file);
|
||||
if (target.startsWith(projectRoot)) {
|
||||
fs.rmSync(target, { force: true });
|
||||
}
|
||||
}
|
||||
run('tar', [
|
||||
'-xzf',
|
||||
source,
|
||||
'--exclude=.git',
|
||||
'--exclude=node_modules',
|
||||
'--exclude=.next',
|
||||
'--exclude=dist',
|
||||
'-C',
|
||||
projectRoot,
|
||||
], { cwd: projectRoot, label: '恢复源码快照' });
|
||||
}
|
||||
|
||||
function restartPlatform(options = {}) {
|
||||
const restartCommand = process.env.UPGRADE_RESTART_COMMAND || detectRestartCommand();
|
||||
if (options.detached) {
|
||||
const logFile = path.join(jobDir, 'restart.log');
|
||||
const detachedCommand = `nohup bash -lc ${JSON.stringify(restartCommand)} >> ${JSON.stringify(logFile)} 2>&1 &`;
|
||||
spawnSync('bash', ['-lc', detachedCommand], {
|
||||
cwd: projectRoot,
|
||||
env: { ...process.env, COREPACK_HOME: process.env.COREPACK_HOME || '/tmp/corepack' },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
logStep('后台重启平台', `已触发后台重启命令,日志:${logFile}`);
|
||||
return;
|
||||
}
|
||||
run('bash', ['-lc', restartCommand], { cwd: projectRoot, label: '重启平台' });
|
||||
}
|
||||
|
||||
function detectRestartCommand() {
|
||||
const pm2Names = runCapture('bash', ['-lc', 'command -v pm2 >/dev/null 2>&1 && pm2 jlist || true'], {
|
||||
cwd: projectRoot,
|
||||
label: '检测 PM2 进程',
|
||||
});
|
||||
if (pm2Names.includes('"name":"miaojing-dev"')) return 'pm2 restart miaojing-dev --update-env';
|
||||
if (fs.existsSync(path.join(projectRoot, 'ecosystem.config.cjs'))) return 'pm2 startOrReload ecosystem.config.cjs --update-env && pm2 save';
|
||||
return 'pm2 restart miaojing-dev --update-env';
|
||||
}
|
||||
|
||||
function tarReadArgs(action, archivePath) {
|
||||
const flag = action === 'list' ? '-tf' : '-xf';
|
||||
const gzipFlag = action === 'list' ? '-tzf' : '-xzf';
|
||||
return archivePath.endsWith('.tar') ? [flag, archivePath] : [gzipFlag, archivePath];
|
||||
}
|
||||
|
||||
function verifyTarArchive(archivePath, label) {
|
||||
if (!fs.existsSync(archivePath)) {
|
||||
throw new Error(`${label}失败,文件不存在: ${archivePath}`);
|
||||
}
|
||||
run('tar', tarReadArgs('list', archivePath), { cwd: projectRoot, label });
|
||||
}
|
||||
|
||||
function waitForHealth() {
|
||||
const healthUrl = process.env.UPGRADE_HEALTH_URL || process.env.APP_HEALTH_URL || 'http://127.0.0.1:5100/api/health';
|
||||
const timeoutMs = Number(process.env.UPGRADE_HEALTH_TIMEOUT_MS || 90000);
|
||||
const startedAt = Date.now();
|
||||
let lastError = '';
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const result = spawnSync('curl', ['-fsS', healthUrl], { encoding: 'utf8', timeout: 8000 });
|
||||
if (result.status === 0) {
|
||||
log(`健康检查通过: ${healthUrl}`);
|
||||
return;
|
||||
}
|
||||
lastError = `${result.stderr || result.stdout || `exit ${result.status}`}`.trim();
|
||||
sleep(3000);
|
||||
}
|
||||
throw new Error(`健康检查超时: ${healthUrl}; ${lastError}`);
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
||||
}
|
||||
|
||||
function sha256(file) {
|
||||
const hash = createHash('sha256');
|
||||
hash.update(fs.readFileSync(file));
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!Number.isFinite(bytes)) return 'unknown';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ['KB', 'MB', 'GB'];
|
||||
let value = bytes / 1024;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function toPosix(file) {
|
||||
return file.split(path.sep).join('/');
|
||||
}
|
||||
116
scripts/backfill-user-display-profile.mjs
Normal file
116
scripts/backfill-user-display-profile.mjs
Normal file
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env node
|
||||
import pg from 'pg';
|
||||
|
||||
const { Client } = pg;
|
||||
|
||||
const adjectives = ['云朵', '星河', '松风', '月白', '晴川', '青柚', '琥珀', '小满', '竹影', '橘光', '海盐', '霁蓝'];
|
||||
const nouns = ['画师', '旅人', '造梦家', '观察员', '收藏家', '调色师', '冒险家', '灵感师', '策展人', '星愿者', '小导演', '光影客'];
|
||||
const kinds = ['person', 'cat', 'bear', 'bunny', 'fox'];
|
||||
const palettes = [
|
||||
['#7dd3fc', '#c084fc', '#fdf2f8', '#0f172a'],
|
||||
['#fbbf24', '#fb7185', '#fff7ed', '#3b1d0f'],
|
||||
['#86efac', '#38bdf8', '#f0fdf4', '#052e2b'],
|
||||
['#f9a8d4', '#a78bfa', '#fdf4ff', '#312e81'],
|
||||
['#fdba74', '#60a5fa', '#eff6ff', '#1e3a8a'],
|
||||
];
|
||||
|
||||
function hashString(value) {
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
hash ^= value.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function pick(items, seed, offset = 0) {
|
||||
return items[(seed + offset) % items.length];
|
||||
}
|
||||
|
||||
function escapeXml(value) {
|
||||
return value.replace(/[&<>"']/g, char => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
})[char] || char);
|
||||
}
|
||||
|
||||
function generateChineseNickname(seedValue) {
|
||||
const seed = hashString(seedValue);
|
||||
return `${pick(adjectives, seed)}${pick(nouns, seed >>> 5)}${String(seed % 1000).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
function generateDefaultAvatarDataUrl(seedValue, labelValue) {
|
||||
const seed = hashString(seedValue);
|
||||
const [primary, secondary, surface, ink] = pick(palettes, seed);
|
||||
const kind = pick(kinds, seed >>> 3);
|
||||
const label = escapeXml(String(labelValue || '').trim().slice(0, 1) || '妙');
|
||||
const blush = seed % 2 === 0 ? '#fb7185' : '#f472b6';
|
||||
const earLeft = kind === 'cat'
|
||||
? '<path d="M76 92 L110 44 L126 108 Z" fill="url(#face)" stroke="rgba(255,255,255,.6)" stroke-width="5"/>'
|
||||
: kind === 'bunny'
|
||||
? '<ellipse cx="105" cy="54" rx="19" ry="48" fill="url(#face)" transform="rotate(-16 105 54)"/>'
|
||||
: kind === 'bear' || kind === 'fox'
|
||||
? '<circle cx="103" cy="86" r="26" fill="url(#face)" stroke="rgba(255,255,255,.58)" stroke-width="5"/>'
|
||||
: '';
|
||||
const earRight = kind === 'cat'
|
||||
? '<path d="M180 108 L196 44 L232 92 Z" fill="url(#face)" stroke="rgba(255,255,255,.6)" stroke-width="5"/>'
|
||||
: kind === 'bunny'
|
||||
? '<ellipse cx="205" cy="54" rx="19" ry="48" fill="url(#face)" transform="rotate(16 205 54)"/>'
|
||||
: kind === 'bear' || kind === 'fox'
|
||||
? '<circle cx="213" cy="86" r="26" fill="url(#face)" stroke="rgba(255,255,255,.58)" stroke-width="5"/>'
|
||||
: '';
|
||||
const nose = kind === 'person'
|
||||
? `<path d="M160 147 c-7 10 -1 18 10 16" fill="none" stroke="${ink}" stroke-width="5" stroke-linecap="round" opacity=".44"/>`
|
||||
: `<path d="M151 151 q9 -8 18 0 q-9 11 -18 0Z" fill="${ink}" opacity=".72"/>`;
|
||||
const hair = kind === 'person'
|
||||
? `<path d="M90 133 c16 -58 58 -83 112 -57 c29 14 40 44 35 70 c-23 -20 -42 -17 -66 -36 c-26 26 -52 28 -81 23Z" fill="${secondary}" opacity=".92"/>`
|
||||
: '';
|
||||
const muzzle = kind === 'person' ? '' : '<ellipse cx="160" cy="165" rx="39" ry="26" fill="rgba(255,255,255,.54)"/>';
|
||||
const whiskers = kind === 'cat' || kind === 'fox'
|
||||
? `<path d="M101 155 h36 M101 174 h36 M183 155 h36 M183 174 h36" stroke="${ink}" stroke-width="4" stroke-linecap="round" opacity=".38"/>`
|
||||
: '';
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320"><defs><radialGradient id="bg" cx="34%" cy="25%" r="78%"><stop offset="0%" stop-color="${surface}"/><stop offset="48%" stop-color="${primary}"/><stop offset="100%" stop-color="${secondary}"/></radialGradient><linearGradient id="face" x1="72" y1="64" x2="236" y2="246" gradientUnits="userSpaceOnUse"><stop stop-color="#fff8f0"/><stop offset=".58" stop-color="#ffd7b5"/><stop offset="1" stop-color="#f8a978"/></linearGradient><filter id="soft" x="-30%" y="-30%" width="160%" height="160%"><feDropShadow dx="0" dy="18" stdDeviation="16" flood-color="#111827" flood-opacity=".22"/></filter></defs><rect width="320" height="320" rx="80" fill="url(#bg)"/><circle cx="254" cy="58" r="34" fill="rgba(255,255,255,.34)"/><circle cx="68" cy="250" r="44" fill="rgba(255,255,255,.20)"/><g filter="url(#soft)">${earLeft}${earRight}<circle cx="160" cy="153" r="83" fill="url(#face)" stroke="rgba(255,255,255,.68)" stroke-width="6"/>${hair}<circle cx="128" cy="144" r="9" fill="${ink}"/><circle cx="192" cy="144" r="9" fill="${ink}"/><circle cx="125" cy="142" r="3" fill="#fff"/><circle cx="189" cy="142" r="3" fill="#fff"/>${muzzle}${nose}${whiskers}<path d="M137 184 q23 18 46 0" fill="none" stroke="${ink}" stroke-width="6" stroke-linecap="round" opacity=".62"/><circle cx="105" cy="169" r="13" fill="${blush}" opacity=".30"/><circle cx="215" cy="169" r="13" fill="${blush}" opacity=".30"/></g><g transform="translate(218 222)"><circle cx="34" cy="34" r="30" fill="rgba(255,255,255,.78)" stroke="rgba(255,255,255,.86)" stroke-width="3"/><text x="34" y="45" text-anchor="middle" font-size="30" font-weight="800" font-family="Arial, sans-serif" fill="${ink}">${label}</text></g></svg>`;
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const connectionString = process.env.LOCAL_DB_URL || process.env.DATABASE_URL;
|
||||
if (!connectionString) throw new Error('LOCAL_DB_URL or DATABASE_URL is required');
|
||||
|
||||
const client = new Client({ connectionString });
|
||||
await client.connect();
|
||||
try {
|
||||
await client.query('ALTER TABLE profiles ADD COLUMN IF NOT EXISTS display_nickname VARCHAR(128)');
|
||||
const result = await client.query(`
|
||||
SELECT id, email, nickname, display_nickname, avatar_url
|
||||
FROM profiles
|
||||
WHERE display_nickname IS NULL OR display_nickname = ''
|
||||
OR avatar_url IS NULL OR avatar_url = ''
|
||||
ORDER BY created_at ASC
|
||||
`);
|
||||
|
||||
let nicknameCount = 0;
|
||||
let avatarCount = 0;
|
||||
for (const row of result.rows) {
|
||||
const displayNickname = row.display_nickname || row.nickname || generateChineseNickname(`${row.id}:${row.email}`);
|
||||
const avatarUrl = row.avatar_url || generateDefaultAvatarDataUrl(`${row.id}:${row.email}`, displayNickname);
|
||||
if (!row.display_nickname) nicknameCount += 1;
|
||||
if (!row.avatar_url) avatarCount += 1;
|
||||
await client.query(
|
||||
`UPDATE profiles
|
||||
SET display_nickname = COALESCE(NULLIF(display_nickname, ''), $2),
|
||||
avatar_url = COALESCE(NULLIF(avatar_url, ''), $3),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[row.id, displayNickname, avatarUrl],
|
||||
);
|
||||
}
|
||||
console.log(JSON.stringify({ scanned: result.rowCount, displayNicknamesBackfilled: nicknameCount, avatarsBackfilled: avatarCount }));
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
178
scripts/backfill-work-reference-images.mjs
Normal file
178
scripts/backfill-work-reference-images.mjs
Normal file
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import pg from 'pg';
|
||||
|
||||
loadEnvFile(path.join(process.cwd(), '.env.local'));
|
||||
|
||||
const { Client } = pg;
|
||||
const args = new Set(process.argv.slice(2));
|
||||
const dryRun = args.has('--dry-run');
|
||||
const referenceImageStorage = await import('../src/lib/reference-image-storage.ts');
|
||||
const persistReferenceImages = referenceImageStorage.persistReferenceImages
|
||||
|| referenceImageStorage.default?.persistReferenceImages;
|
||||
const verbose = args.has('--verbose');
|
||||
|
||||
if (args.has('--check-import')) {
|
||||
if (typeof persistReferenceImages !== 'function') {
|
||||
throw new Error('persistReferenceImages import failed');
|
||||
}
|
||||
console.log(JSON.stringify({ ok: true, import: 'persistReferenceImages' }));
|
||||
process.exit(0);
|
||||
}
|
||||
const limitArg = [...args].find(arg => arg.startsWith('--limit='));
|
||||
const limit = Math.max(1, Math.min(5000, Number(limitArg?.split('=')[1] || 500)));
|
||||
const timeoutArg = [...args].find(arg => arg.startsWith('--item-timeout-ms='));
|
||||
const itemTimeoutMs = Math.max(5_000, Math.min(300_000, Number(timeoutArg?.split('=')[1] || 90_000)));
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
||||
if (!match) continue;
|
||||
const [, key, rawValue] = match;
|
||||
if (process.env[key] !== undefined) continue;
|
||||
process.env[key] = rawValue.replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeString(value) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function getReferenceInputs(params) {
|
||||
const values = [
|
||||
params.referenceImage,
|
||||
...(Array.isArray(params.referenceImages) ? params.referenceImages : []),
|
||||
params.image,
|
||||
...(Array.isArray(params.images) ? params.images : []),
|
||||
...(Array.isArray(params.extraImages) ? params.extraImages : []),
|
||||
params.sourceImage,
|
||||
params.source_image,
|
||||
params.inputImage,
|
||||
params.input_image,
|
||||
];
|
||||
return [...new Set(values.map(normalizeString).filter(value => value && !value.startsWith('[')))];
|
||||
}
|
||||
|
||||
function shouldBackfill(params) {
|
||||
const references = getReferenceInputs(params);
|
||||
const hasThumbnails = Array.isArray(params.referenceImageThumbnails) && params.referenceImageThumbnails.length > 0;
|
||||
return references.some(value => value.startsWith('data:image/') || /^https?:\/\//i.test(value) || value.startsWith('/api/local-storage/'))
|
||||
&& (!Array.isArray(params.referenceImages) || params.referenceImages.length === 0 || !hasThumbnails || references.some(value => value.startsWith('data:image/')));
|
||||
}
|
||||
|
||||
function withTimeout(promise, timeoutMs, label) {
|
||||
let timeoutId;
|
||||
const timeout = new Promise((_, reject) => {
|
||||
timeoutId = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
|
||||
});
|
||||
return Promise.race([promise, timeout]).finally(() => clearTimeout(timeoutId));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const connectionString = process.env.LOCAL_DB_URL || process.env.DATABASE_URL;
|
||||
if (!connectionString) throw new Error('LOCAL_DB_URL or DATABASE_URL is required');
|
||||
const client = new Client({ connectionString });
|
||||
await client.connect();
|
||||
try {
|
||||
const result = await client.query(
|
||||
`SELECT id, params
|
||||
FROM works
|
||||
WHERE status = 'completed'
|
||||
AND (
|
||||
type IN ('img2img', 'img2video')
|
||||
OR params->>'creationMode' IN ('img2img', 'img2video')
|
||||
OR params->>'workType' IN ('img2img', 'img2video')
|
||||
OR params->>'referenceImage' IS NOT NULL
|
||||
OR jsonb_typeof(params->'referenceImages') = 'array'
|
||||
)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1`,
|
||||
[limit],
|
||||
);
|
||||
|
||||
let candidates = 0;
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
for (const row of result.rows) {
|
||||
const params = row.params || {};
|
||||
if (!shouldBackfill(params)) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
candidates += 1;
|
||||
if (dryRun) continue;
|
||||
const references = getReferenceInputs(params);
|
||||
if (verbose) {
|
||||
console.log(JSON.stringify({
|
||||
event: 'backfill-reference-images:start',
|
||||
id: row.id,
|
||||
index: candidates,
|
||||
references: references.length,
|
||||
}));
|
||||
}
|
||||
let persisted;
|
||||
try {
|
||||
persisted = await withTimeout(
|
||||
persistReferenceImages(references),
|
||||
itemTimeoutMs,
|
||||
`work ${row.id}`,
|
||||
);
|
||||
} catch (error) {
|
||||
skipped += 1;
|
||||
console.warn('[backfill-work-reference-images] skipped row:', row.id, error instanceof Error ? error.message : error);
|
||||
continue;
|
||||
}
|
||||
if (persisted.length === 0) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
const referenceImages = persisted.map(item => item.url);
|
||||
const referenceImageThumbnails = persisted.map(item => item.thumbnailUrl || item.url);
|
||||
await client.query(
|
||||
`UPDATE works
|
||||
SET params = $2::jsonb
|
||||
WHERE id = $1`,
|
||||
[
|
||||
row.id,
|
||||
JSON.stringify({
|
||||
...params,
|
||||
referenceImage: referenceImages[0],
|
||||
referenceImages,
|
||||
referenceImageThumbnails,
|
||||
refImageCount: Math.max(Number(params.refImageCount || 0), referenceImages.length),
|
||||
}),
|
||||
],
|
||||
);
|
||||
updated += 1;
|
||||
if (verbose) {
|
||||
console.log(JSON.stringify({
|
||||
event: 'backfill-reference-images:updated',
|
||||
id: row.id,
|
||||
index: candidates,
|
||||
persisted: persisted.length,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
dryRun,
|
||||
scanned: result.rowCount,
|
||||
candidates,
|
||||
updated,
|
||||
skipped,
|
||||
limit,
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -38,8 +38,13 @@ command -v pg_dump >/dev/null 2>&1 || {
|
||||
echo "pg_dump is required to create backups." >&2
|
||||
exit 1
|
||||
}
|
||||
command -v pg_restore >/dev/null 2>&1 || {
|
||||
echo "pg_restore is required to verify backups." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
pg_dump "${LOCAL_DB_URL}" --format=custom --file "${TMP_DIR}/database.dump"
|
||||
pg_restore --list "${TMP_DIR}/database.dump" >/dev/null
|
||||
|
||||
STORAGE_SOURCE="${LOCAL_STORAGE_DIR:-${COZE_WORKSPACE_PATH}/local-storage}"
|
||||
if [ -d "${STORAGE_SOURCE}" ]; then
|
||||
@@ -57,13 +62,16 @@ fi
|
||||
cat > "${TMP_DIR}/manifest.json" <<EOF
|
||||
{
|
||||
"app": "miaojingAI",
|
||||
"formatVersion": 2,
|
||||
"createdAt": "$(date -Iseconds)",
|
||||
"hostname": "$(hostname)",
|
||||
"storagePath": "${STORAGE_SOURCE}",
|
||||
"includes": ["database.dump", "local-storage", ".env.local", "package.json"]
|
||||
}
|
||||
EOF
|
||||
|
||||
tar -czf "${BACKUP_FILE}" -C "${TMP_DIR}" .
|
||||
tar -tzf "${BACKUP_FILE}" >/dev/null
|
||||
chmod 600 "${BACKUP_FILE}"
|
||||
|
||||
find "${BACKUP_DIR}" -maxdepth 1 -name 'miaojing-backup-*.tar.gz' -type f \
|
||||
|
||||
@@ -3,7 +3,9 @@ set -Eeuo pipefail
|
||||
|
||||
COZE_WORKSPACE_PATH="${COZE_WORKSPACE_PATH:-$(pwd)}"
|
||||
BACKUP_FILE="${1:-}"
|
||||
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
RESTORE_SAFETY_DIR="${RESTORE_SAFETY_DIR:-}"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "${TMP_DIR}"
|
||||
@@ -40,26 +42,72 @@ command -v pg_restore >/dev/null 2>&1 || {
|
||||
echo "pg_restore is required to restore backups." >&2
|
||||
exit 1
|
||||
}
|
||||
command -v pg_dump >/dev/null 2>&1 || {
|
||||
echo "pg_dump is required to create restore safety backups." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
tar -tzf "${BACKUP_FILE}" >/dev/null
|
||||
tar -xzf "${BACKUP_FILE}" -C "${TMP_DIR}"
|
||||
|
||||
if [ ! -f "${TMP_DIR}/database.dump" ]; then
|
||||
echo "Invalid backup: missing database.dump." >&2
|
||||
exit 2
|
||||
fi
|
||||
pg_restore --list "${TMP_DIR}/database.dump" >/dev/null
|
||||
|
||||
pg_restore --clean --if-exists --no-owner --dbname "${LOCAL_DB_URL}" "${TMP_DIR}/database.dump"
|
||||
SAFETY_ROOT="${RESTORE_SAFETY_DIR:-${COZE_WORKSPACE_PATH}/backups/restore-safety}"
|
||||
SAFETY_DIR="${SAFETY_ROOT}/pre-restore-${TIMESTAMP}"
|
||||
mkdir -p "${SAFETY_DIR}"
|
||||
chmod 700 "${SAFETY_ROOT}" "${SAFETY_DIR}"
|
||||
|
||||
pg_dump "${LOCAL_DB_URL}" --format=custom --file "${SAFETY_DIR}/database-before-restore.dump"
|
||||
pg_restore --list "${SAFETY_DIR}/database-before-restore.dump" >/dev/null
|
||||
|
||||
STORAGE_TARGET="${LOCAL_STORAGE_DIR:-${COZE_WORKSPACE_PATH}/local-storage}"
|
||||
if [ -e "${STORAGE_TARGET}" ]; then
|
||||
mkdir -p "${SAFETY_DIR}/storage-parent"
|
||||
cp -a "${STORAGE_TARGET}" "${SAFETY_DIR}/storage-parent/$(basename "${STORAGE_TARGET}")"
|
||||
fi
|
||||
STORAGE_PARENT="$(dirname "${STORAGE_TARGET}")"
|
||||
STORAGE_NAME="$(basename "${STORAGE_TARGET}")"
|
||||
PREVIOUS_STORAGE="${SAFETY_DIR}/${STORAGE_NAME}.previous"
|
||||
STAGED_STORAGE="${TMP_DIR}/${STORAGE_NAME}.staged"
|
||||
if [ -d "${TMP_DIR}/local-storage" ]; then
|
||||
rm -rf "${STAGED_STORAGE}"
|
||||
cp -a "${TMP_DIR}/local-storage" "${STAGED_STORAGE}"
|
||||
fi
|
||||
|
||||
if [ -f ".env.local" ]; then
|
||||
cp ".env.local" "${SAFETY_DIR}/.env.local.before-restore"
|
||||
chmod 600 "${SAFETY_DIR}/.env.local.before-restore"
|
||||
fi
|
||||
|
||||
pg_restore --clean --if-exists --no-owner --single-transaction --dbname "${LOCAL_DB_URL}" "${TMP_DIR}/database.dump"
|
||||
|
||||
if [ -d "${TMP_DIR}/local-storage" ]; then
|
||||
STORAGE_TARGET="${LOCAL_STORAGE_DIR:-${COZE_WORKSPACE_PATH}/local-storage}"
|
||||
rm -rf "${STORAGE_TARGET}"
|
||||
mkdir -p "$(dirname "${STORAGE_TARGET}")"
|
||||
cp -a "${TMP_DIR}/local-storage" "${STORAGE_TARGET}"
|
||||
mkdir -p "${STORAGE_PARENT}"
|
||||
if [ -e "${STORAGE_TARGET}" ]; then
|
||||
mv "${STORAGE_TARGET}" "${PREVIOUS_STORAGE}"
|
||||
fi
|
||||
if ! mv "${STAGED_STORAGE}" "${STORAGE_PARENT}/${STORAGE_NAME}"; then
|
||||
rm -rf "${STORAGE_PARENT:?}/${STORAGE_NAME}"
|
||||
if [ -e "${PREVIOUS_STORAGE}" ]; then
|
||||
mv "${PREVIOUS_STORAGE}" "${STORAGE_PARENT}/${STORAGE_NAME}"
|
||||
fi
|
||||
echo "Storage restore failed; previous storage was restored." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f "${TMP_DIR}/.env.local" ]; then
|
||||
cp "${TMP_DIR}/.env.local" ".env.local"
|
||||
cp "${TMP_DIR}/.env.local" ".env.local.restore-next"
|
||||
mv ".env.local.restore-next" ".env.local"
|
||||
chmod 600 ".env.local"
|
||||
fi
|
||||
|
||||
find "${SAFETY_ROOT}" -maxdepth 1 -type d -name 'pre-restore-*' \
|
||||
-printf '%T@ %p\n' | sort -rn | awk 'NR>10 {print $2}' | xargs -r rm -rf
|
||||
|
||||
echo "Restore completed from ${BACKUP_FILE}"
|
||||
echo "Pre-restore safety backup: ${SAFETY_DIR}"
|
||||
|
||||
@@ -535,7 +535,7 @@ install_dependencies_with_mirrors() {
|
||||
for mirror in "${MIRRORS[@]}"; do
|
||||
log "尝试使用依赖镜像源:${mirror}"
|
||||
pnpm config set registry "${mirror}" >/dev/null 2>&1 || true
|
||||
if pnpm install --frozen-lockfile --reporter=append-only 2>&1 | log_pipe; then
|
||||
if pnpm install --frozen-lockfile --prod=false --reporter=append-only 2>&1 | log_pipe; then
|
||||
log "依赖安装成功,使用源:${mirror}"
|
||||
return 0
|
||||
fi
|
||||
@@ -558,7 +558,7 @@ sync_project_files() {
|
||||
--exclude ".next" \
|
||||
--exclude "dist" \
|
||||
--exclude "backups" \
|
||||
--exclude "local-storage" \
|
||||
--exclude "/local-storage" \
|
||||
--exclude ".env.local" \
|
||||
--exclude ".codex_tmp" \
|
||||
"${SOURCE_DIR}/" "${PROJECT_DIR}/" 2>&1 | log_pipe
|
||||
@@ -785,11 +785,17 @@ BEGIN
|
||||
END $$;
|
||||
|
||||
ALTER TABLE profiles
|
||||
ADD COLUMN IF NOT EXISTS display_nickname VARCHAR(128),
|
||||
ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS email_bound_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS email_sender_domain VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS preferred_theme VARCHAR(16) NOT NULL DEFAULT 'dark';
|
||||
ADD COLUMN IF NOT EXISTS preferred_theme VARCHAR(16) NOT NULL DEFAULT 'dark',
|
||||
ADD COLUMN IF NOT EXISTS watermark_disabled BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
UPDATE profiles
|
||||
SET display_nickname = COALESCE(NULLIF(display_nickname, ''), NULLIF(nickname, ''), split_part(email, '@', 1))
|
||||
WHERE display_nickname IS NULL OR display_nickname = '';
|
||||
|
||||
UPDATE profiles
|
||||
SET preferred_theme = 'dark'
|
||||
@@ -821,6 +827,48 @@ ALTER TABLE user_api_keys ADD COLUMN IF NOT EXISTS supplier_name VARCHAR(128);
|
||||
ALTER TABLE user_api_keys ADD COLUMN IF NOT EXISTS note TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE user_api_keys ADD COLUMN IF NOT EXISTS type VARCHAR(16) NOT NULL DEFAULT 'image';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS redeem_codes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code VARCHAR(64) NOT NULL UNIQUE,
|
||||
normalized_code VARCHAR(64) NOT NULL UNIQUE,
|
||||
code_type VARCHAR(16) NOT NULL DEFAULT 'credits',
|
||||
credits_amount INTEGER NOT NULL DEFAULT 0,
|
||||
membership_tier VARCHAR(32),
|
||||
membership_duration_value INTEGER,
|
||||
membership_duration_unit VARCHAR(16),
|
||||
batch_id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
note VARCHAR(255) NOT NULL DEFAULT '',
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_by UUID,
|
||||
used_by UUID,
|
||||
used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ
|
||||
);
|
||||
ALTER TABLE redeem_codes ADD COLUMN IF NOT EXISTS code_type VARCHAR(16) NOT NULL DEFAULT 'credits';
|
||||
ALTER TABLE redeem_codes ADD COLUMN IF NOT EXISTS membership_tier VARCHAR(32);
|
||||
ALTER TABLE redeem_codes ADD COLUMN IF NOT EXISTS membership_duration_value INTEGER;
|
||||
ALTER TABLE redeem_codes ADD COLUMN IF NOT EXISTS membership_duration_unit VARCHAR(16);
|
||||
ALTER TABLE redeem_codes ALTER COLUMN credits_amount SET DEFAULT 0;
|
||||
ALTER TABLE redeem_codes DROP CONSTRAINT IF EXISTS redeem_codes_credits_amount_check;
|
||||
ALTER TABLE redeem_codes DROP CONSTRAINT IF EXISTS redeem_codes_payload_check;
|
||||
ALTER TABLE redeem_codes
|
||||
ADD CONSTRAINT redeem_codes_payload_check CHECK (
|
||||
(code_type = 'credits' AND credits_amount > 0)
|
||||
OR (
|
||||
code_type = 'membership'
|
||||
AND credits_amount >= 0
|
||||
AND membership_tier IN ('pro', 'max', 'ultra', 'enterprise')
|
||||
AND membership_duration_value > 0
|
||||
AND membership_duration_unit IN ('day', 'month', 'year')
|
||||
)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS redeem_codes_created_at_idx ON redeem_codes (created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS redeem_codes_batch_id_idx ON redeem_codes (batch_id);
|
||||
CREATE INDEX IF NOT EXISTS redeem_codes_used_by_idx ON redeem_codes (used_by);
|
||||
CREATE INDEX IF NOT EXISTS redeem_codes_status_idx ON redeem_codes (is_active, used_at);
|
||||
CREATE INDEX IF NOT EXISTS redeem_codes_type_idx ON redeem_codes (code_type);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS system_api_configs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
provider VARCHAR(128),
|
||||
@@ -828,6 +876,11 @@ CREATE TABLE IF NOT EXISTS system_api_configs (
|
||||
api_url TEXT NOT NULL DEFAULT '',
|
||||
model_name VARCHAR(255) NOT NULL,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
manifest_path TEXT NOT NULL DEFAULT '',
|
||||
is_default BOOLEAN NOT NULL DEFAULT true,
|
||||
allowed_membership_tiers JSONB NOT NULL DEFAULT '["free","pro","max","ultra"]'::jsonb,
|
||||
polling_mode VARCHAR(16) NOT NULL DEFAULT 'sequential',
|
||||
polling_order INTEGER NOT NULL DEFAULT 0,
|
||||
api_key_encrypted TEXT NOT NULL DEFAULT '',
|
||||
api_key_preview VARCHAR(64) NOT NULL DEFAULT '',
|
||||
type VARCHAR(16) NOT NULL DEFAULT 'image',
|
||||
@@ -838,6 +891,8 @@ CREATE TABLE IF NOT EXISTS system_api_configs (
|
||||
updated_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS system_api_configs_active_type_sort_idx ON system_api_configs (is_active, type, sort_order);
|
||||
CREATE INDEX IF NOT EXISTS system_api_configs_default_sort_idx ON system_api_configs (is_default, is_active, sort_order);
|
||||
CREATE INDEX IF NOT EXISTS system_api_configs_polling_idx ON system_api_configs (type, model_name, is_default, is_active, polling_order, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payment_methods (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
@@ -866,6 +921,7 @@ CREATE INDEX IF NOT EXISTS generation_jobs_user_created_idx ON generation_jobs (
|
||||
CREATE INDEX IF NOT EXISTS generation_jobs_provider_model_created_idx ON generation_jobs (type, provider, model_name, created_at DESC);
|
||||
|
||||
ALTER TABLE site_config ADD COLUMN IF NOT EXISTS log_retention_days INTEGER NOT NULL DEFAULT 30;
|
||||
ALTER TABLE site_config ADD COLUMN IF NOT EXISTS image_composition_skill_enabled BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
UPDATE site_config SET log_retention_days = LEAST(90, GREATEST(1, log_retention_days));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS platform_log_settings (
|
||||
@@ -1009,17 +1065,18 @@ BEGIN
|
||||
SELECT id INTO v_admin_id FROM auth.users WHERE lower(email) = lower(r.email) LIMIT 1;
|
||||
|
||||
INSERT INTO profiles (
|
||||
id, email, nickname, role, membership_tier, credits_balance,
|
||||
id, email, nickname, display_nickname, role, membership_tier, credits_balance,
|
||||
daily_quota_limit, daily_quota_used, is_active,
|
||||
email_verified, email_verified_at, email_bound_at, email_sender_domain
|
||||
)
|
||||
VALUES (
|
||||
v_admin_id, r.email, r.account, 'admin', 'enterprise',
|
||||
v_admin_id, r.email, r.account, r.account, 'admin', 'enterprise',
|
||||
9999, 999, 0, true, true, NOW(), NOW(), split_part(r.email, '@', 2)
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
nickname = EXCLUDED.nickname,
|
||||
display_nickname = COALESCE(NULLIF(profiles.display_nickname, ''), EXCLUDED.display_nickname),
|
||||
role = 'admin',
|
||||
membership_tier = 'enterprise',
|
||||
credits_balance = GREATEST(profiles.credits_balance, 9999),
|
||||
|
||||
@@ -48,12 +48,15 @@ CREATE TABLE IF NOT EXISTS profiles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
nickname VARCHAR(128),
|
||||
display_nickname VARCHAR(128),
|
||||
avatar_url TEXT,
|
||||
phone VARCHAR(20),
|
||||
role VARCHAR(32) NOT NULL DEFAULT 'user', -- guest, user, vip, enterprise_admin, enterprise_member, admin
|
||||
membership_tier VARCHAR(32) NOT NULL DEFAULT 'free', -- free, basic, pro, enterprise
|
||||
membership_expires_at TIMESTAMPTZ,
|
||||
credits_balance INTEGER NOT NULL DEFAULT 0,
|
||||
invite_code VARCHAR(32),
|
||||
referred_by_user_id UUID,
|
||||
daily_quota_used INTEGER NOT NULL DEFAULT 0,
|
||||
daily_quota_limit INTEGER NOT NULL DEFAULT 5,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
@@ -62,12 +65,15 @@ CREATE TABLE IF NOT EXISTS profiles (
|
||||
email_bound_at TIMESTAMPTZ,
|
||||
email_sender_domain VARCHAR(255),
|
||||
preferred_theme VARCHAR(16) NOT NULL DEFAULT 'dark',
|
||||
watermark_disabled BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS profiles_email_idx ON profiles (email);
|
||||
CREATE INDEX IF NOT EXISTS profiles_role_idx ON profiles (role);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS profiles_invite_code_unique_idx ON profiles (invite_code) WHERE invite_code IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS profiles_referred_by_user_id_idx ON profiles (referred_by_user_id);
|
||||
|
||||
-- ============================================================
|
||||
-- 2. 创作作品表 (works)
|
||||
@@ -119,7 +125,66 @@ CREATE INDEX IF NOT EXISTS credit_transactions_type_idx ON credit_transactions (
|
||||
CREATE INDEX IF NOT EXISTS credit_transactions_created_at_idx ON credit_transactions (created_at);
|
||||
|
||||
-- ============================================================
|
||||
-- 4. 订单表 (orders)
|
||||
-- 4. 邀请注册记录表 (invitation_referrals)
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS invitation_referrals (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
invite_code VARCHAR(32) NOT NULL,
|
||||
inviter_user_id UUID NOT NULL,
|
||||
invitee_user_id UUID NOT NULL UNIQUE,
|
||||
inviter_bonus_credits INTEGER NOT NULL DEFAULT 50,
|
||||
invitee_bonus_credits INTEGER NOT NULL DEFAULT 50,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS invitation_referrals_inviter_idx ON invitation_referrals (inviter_user_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS invitation_referrals_invitee_idx ON invitation_referrals (invitee_user_id);
|
||||
CREATE INDEX IF NOT EXISTS invitation_referrals_created_at_idx ON invitation_referrals (created_at DESC);
|
||||
|
||||
-- ============================================================
|
||||
-- 5. 兑换码表 (redeem_codes)
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS redeem_codes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code VARCHAR(64) NOT NULL UNIQUE,
|
||||
normalized_code VARCHAR(64) NOT NULL UNIQUE,
|
||||
code_type VARCHAR(16) NOT NULL DEFAULT 'credits',
|
||||
credits_amount INTEGER NOT NULL DEFAULT 0,
|
||||
membership_tier VARCHAR(32),
|
||||
membership_duration_value INTEGER,
|
||||
membership_duration_unit VARCHAR(16),
|
||||
batch_id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
note VARCHAR(255) NOT NULL DEFAULT '',
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_by UUID,
|
||||
used_by UUID,
|
||||
used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
ALTER TABLE redeem_codes DROP CONSTRAINT IF EXISTS redeem_codes_credits_amount_check;
|
||||
ALTER TABLE redeem_codes DROP CONSTRAINT IF EXISTS redeem_codes_payload_check;
|
||||
ALTER TABLE redeem_codes
|
||||
ADD CONSTRAINT redeem_codes_payload_check CHECK (
|
||||
(code_type = 'credits' AND credits_amount > 0)
|
||||
OR (
|
||||
code_type = 'membership'
|
||||
AND credits_amount >= 0
|
||||
AND membership_tier IN ('pro', 'max', 'ultra', 'enterprise')
|
||||
AND membership_duration_value > 0
|
||||
AND membership_duration_unit IN ('day', 'month', 'year')
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS redeem_codes_created_at_idx ON redeem_codes (created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS redeem_codes_batch_id_idx ON redeem_codes (batch_id);
|
||||
CREATE INDEX IF NOT EXISTS redeem_codes_used_by_idx ON redeem_codes (used_by);
|
||||
CREATE INDEX IF NOT EXISTS redeem_codes_status_idx ON redeem_codes (is_active, used_at);
|
||||
CREATE INDEX IF NOT EXISTS redeem_codes_type_idx ON redeem_codes (code_type);
|
||||
|
||||
-- ============================================================
|
||||
-- 5. 订单表 (orders)
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -182,6 +247,7 @@ CREATE TABLE IF NOT EXISTS user_api_keys (
|
||||
api_key_preview VARCHAR(20), -- Key 尾号 (如 sk-...4f3e)
|
||||
supplier_name VARCHAR(128),
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
manifest_path TEXT,
|
||||
type VARCHAR(16) NOT NULL DEFAULT 'image',
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
@@ -228,7 +294,9 @@ CREATE TABLE IF NOT EXISTS site_config (
|
||||
filing_url TEXT NOT NULL DEFAULT '',
|
||||
public_security_filing_info TEXT NOT NULL DEFAULT '',
|
||||
public_security_filing_url TEXT NOT NULL DEFAULT '',
|
||||
redeem_code_mall_url TEXT NOT NULL DEFAULT '',
|
||||
log_retention_days INTEGER NOT NULL DEFAULT 30,
|
||||
image_composition_skill_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ
|
||||
);
|
||||
@@ -335,10 +403,24 @@ CREATE INDEX IF NOT EXISTS api_providers_active_sort_idx ON api_providers (is_ac
|
||||
CREATE INDEX IF NOT EXISTS model_recommendations_active_type_sort_idx ON model_recommendations (is_active, type, sort_order);
|
||||
CREATE INDEX IF NOT EXISTS model_recommendations_provider_idx ON model_recommendations (provider_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS image_style_presets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
label VARCHAR(128) NOT NULL UNIQUE,
|
||||
prompt TEXT NOT NULL,
|
||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS image_style_presets_active_usage_idx ON image_style_presets (is_active, usage_count DESC, sort_order ASC);
|
||||
|
||||
INSERT INTO api_providers (name, default_api_url, default_model, type, website, is_active, sort_order)
|
||||
VALUES
|
||||
('硅基流动', 'https://api.siliconflow.cn/v1/images/generations', 'black-forest-labs/FLUX.1-schnell', 'image', 'https://cloud.siliconflow.cn', true, 10),
|
||||
('mozheAPI', 'https://openai.mozhevip.top', '', 'image', 'https://openai.mozhevip.top', true, 20),
|
||||
('New API', 'https://your-newapi-domain.com/v1/images/generations', 'gpt-image-1', 'image', 'https://docs.newapi.pro', true, 25),
|
||||
('OpenAI', 'https://api.openai.com/v1/images/generations', 'dall-e-3', 'image', NULL, true, 30),
|
||||
('Stability AI', 'https://api.stability.ai/v1/generation/stable-diffusion-xl/text-to-image', 'stable-diffusion-xl', 'image', NULL, true, 40),
|
||||
('Midjourney', '', 'midjourney-v6', 'image', NULL, true, 50),
|
||||
@@ -361,11 +443,17 @@ WHERE NOT EXISTS (
|
||||
-- 兼容旧版本库结构的幂等补丁
|
||||
-- ============================================================
|
||||
ALTER TABLE profiles
|
||||
ADD COLUMN IF NOT EXISTS display_nickname VARCHAR(128),
|
||||
ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS email_bound_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS email_sender_domain VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS preferred_theme VARCHAR(16) NOT NULL DEFAULT 'dark';
|
||||
ADD COLUMN IF NOT EXISTS preferred_theme VARCHAR(16) NOT NULL DEFAULT 'dark',
|
||||
ADD COLUMN IF NOT EXISTS watermark_disabled BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
UPDATE profiles
|
||||
SET display_nickname = COALESCE(NULLIF(display_nickname, ''), NULLIF(nickname, ''), split_part(email, '@', 1))
|
||||
WHERE display_nickname IS NULL OR display_nickname = '';
|
||||
|
||||
UPDATE profiles
|
||||
SET preferred_theme = 'dark'
|
||||
@@ -379,6 +467,7 @@ ALTER TABLE works
|
||||
ALTER TABLE user_api_keys
|
||||
ADD COLUMN IF NOT EXISTS supplier_name VARCHAR(128),
|
||||
ADD COLUMN IF NOT EXISTS note TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS manifest_path TEXT,
|
||||
ADD COLUMN IF NOT EXISTS type VARCHAR(16) NOT NULL DEFAULT 'image';
|
||||
|
||||
ALTER TABLE site_config
|
||||
@@ -394,7 +483,9 @@ ALTER TABLE site_config
|
||||
ADD COLUMN IF NOT EXISTS filing_url TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS public_security_filing_info TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS public_security_filing_url TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS log_retention_days INTEGER NOT NULL DEFAULT 30;
|
||||
ADD COLUMN IF NOT EXISTS redeem_code_mall_url TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS log_retention_days INTEGER NOT NULL DEFAULT 30,
|
||||
ADD COLUMN IF NOT EXISTS image_composition_skill_enabled BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
ALTER TABLE generation_jobs
|
||||
ADD COLUMN IF NOT EXISTS user_id UUID,
|
||||
@@ -413,10 +504,24 @@ CREATE TABLE IF NOT EXISTS system_api_configs (
|
||||
api_url TEXT NOT NULL DEFAULT '',
|
||||
model_name VARCHAR(255) NOT NULL,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
manifest_path TEXT NOT NULL DEFAULT '',
|
||||
is_default BOOLEAN NOT NULL DEFAULT true,
|
||||
allowed_membership_tiers JSONB NOT NULL DEFAULT '["free","pro","max","ultra"]'::jsonb,
|
||||
polling_mode VARCHAR(16) NOT NULL DEFAULT 'sequential',
|
||||
polling_order INTEGER NOT NULL DEFAULT 0,
|
||||
api_key_encrypted TEXT NOT NULL DEFAULT '',
|
||||
api_key_preview VARCHAR(64) NOT NULL DEFAULT '',
|
||||
type VARCHAR(16) NOT NULL DEFAULT 'image',
|
||||
credits_per_use INTEGER NOT NULL DEFAULT 10,
|
||||
billing_mode VARCHAR(24) NOT NULL DEFAULT 'fixed',
|
||||
fixed_price NUMERIC(12, 4) NOT NULL DEFAULT 0,
|
||||
duration_price_per_second NUMERIC(12, 6) NOT NULL DEFAULT 0,
|
||||
input_price_per_1k NUMERIC(12, 6) NOT NULL DEFAULT 0,
|
||||
output_price_per_1k NUMERIC(12, 6) NOT NULL DEFAULT 0,
|
||||
model_ratio NUMERIC(12, 6) NOT NULL DEFAULT 1,
|
||||
completion_ratio NUMERIC(12, 6) NOT NULL DEFAULT 1,
|
||||
group_ratio NUMERIC(12, 6) NOT NULL DEFAULT 1,
|
||||
price_note TEXT NOT NULL DEFAULT '',
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
@@ -424,6 +529,8 @@ CREATE TABLE IF NOT EXISTS system_api_configs (
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS system_api_configs_active_type_sort_idx ON system_api_configs (is_active, type, sort_order);
|
||||
CREATE INDEX IF NOT EXISTS system_api_configs_default_sort_idx ON system_api_configs (is_default, is_active, sort_order);
|
||||
CREATE INDEX IF NOT EXISTS system_api_configs_polling_idx ON system_api_configs (type, model_name, is_default, is_active, polling_order, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payment_methods (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
@@ -465,6 +572,7 @@ ALTER TABLE payment_methods ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE works ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE credit_transactions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE redeem_codes ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE user_api_keys ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE work_likes ENABLE ROW LEVEL SECURITY;
|
||||
@@ -482,6 +590,7 @@ DROP POLICY IF EXISTS "works_delete_own" ON works;
|
||||
DROP POLICY IF EXISTS "works_admin_all" ON works;
|
||||
DROP POLICY IF EXISTS "credit_transactions_read_own" ON credit_transactions;
|
||||
DROP POLICY IF EXISTS "credit_transactions_admin_all" ON credit_transactions;
|
||||
DROP POLICY IF EXISTS "redeem_codes_admin_all" ON redeem_codes;
|
||||
DROP POLICY IF EXISTS "orders_read_own" ON orders;
|
||||
DROP POLICY IF EXISTS "orders_insert_own" ON orders;
|
||||
DROP POLICY IF EXISTS "orders_admin_all" ON orders;
|
||||
@@ -529,6 +638,13 @@ CREATE POLICY "credit_transactions_admin_all" ON credit_transactions FOR ALL USI
|
||||
EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
|
||||
);
|
||||
|
||||
-- redeem_codes: 只有管理员可直接管理,用户兑换走后端服务事务
|
||||
CREATE POLICY "redeem_codes_admin_all" ON redeem_codes FOR ALL USING (
|
||||
EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
|
||||
) WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
|
||||
);
|
||||
|
||||
-- orders: 用户可读自己的订单
|
||||
CREATE POLICY "orders_read_own" ON orders FOR SELECT USING (auth.uid() = user_id);
|
||||
CREATE POLICY "orders_insert_own" ON orders FOR INSERT WITH CHECK (auth.uid() = user_id);
|
||||
@@ -611,11 +727,13 @@ CREATE TRIGGER announcements_updated_at BEFORE UPDATE ON announcements FOR EACH
|
||||
CREATE OR REPLACE FUNCTION handle_new_user()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
INSERT INTO profiles (id, email, nickname, role, membership_tier, credits_balance, daily_quota_limit)
|
||||
INSERT INTO profiles (id, email, nickname, display_nickname, avatar_url, role, membership_tier, credits_balance, daily_quota_limit)
|
||||
VALUES (
|
||||
NEW.id,
|
||||
NEW.email,
|
||||
COALESCE(NEW.raw_user_meta_data->>'nickname', split_part(NEW.email, '@', 1)),
|
||||
COALESCE(NEW.raw_user_meta_data->>'display_nickname', NEW.raw_user_meta_data->>'nickname', split_part(NEW.email, '@', 1)),
|
||||
NEW.raw_user_meta_data->>'avatar_url',
|
||||
'user',
|
||||
'free',
|
||||
10, -- 新用户赠送 10 积分
|
||||
|
||||
62
scripts/migration-integrity-check-helpers.mjs
Normal file
62
scripts/migration-integrity-check-helpers.mjs
Normal file
@@ -0,0 +1,62 @@
|
||||
export function getMigrationCheckBaseUrl(env = process.env) {
|
||||
const explicit = String(env.MIGRATION_CHECK_BASE_URL || env.APP_BASE_URL || '').trim();
|
||||
if (explicit) return explicit.replace(/\/+$/, '');
|
||||
const port = String(env.MIGRATION_CHECK_WEB_PORT || env.WEB_PORT || env.PORT || '8000').trim();
|
||||
return `http://127.0.0.1:${port}`;
|
||||
}
|
||||
|
||||
export function getMigrationStorageUrlTimeoutMs(env = process.env) {
|
||||
const parsed = Number(env.MIGRATION_CHECK_STORAGE_URL_TIMEOUT_MS || 10_000);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.min(Math.floor(parsed), 60_000) : 10_000;
|
||||
}
|
||||
|
||||
export function getMigrationStorageUrlConcurrency(env = process.env) {
|
||||
const parsed = Number(env.MIGRATION_CHECK_STORAGE_URL_CONCURRENCY || 8);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.min(Math.floor(parsed), 20) : 8;
|
||||
}
|
||||
|
||||
export async function checkStorageUrl(baseUrl, storageUrl, options = {}) {
|
||||
const timeoutMs = Number(options.timeoutMs || 10_000);
|
||||
const fetchImpl = options.fetchImpl || fetch;
|
||||
const targetUrl = `${baseUrl}${storageUrl}`;
|
||||
|
||||
try {
|
||||
const response = await fetchImpl(targetUrl, {
|
||||
method: 'HEAD',
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
await response.body?.cancel?.();
|
||||
|
||||
if (isReachableStorageResponse(response)) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
if (response.status !== 405) {
|
||||
return { ok: false, error: `HTTP ${response.status}` };
|
||||
}
|
||||
|
||||
const fallback = await fetchImpl(targetUrl, {
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
await fallback.body?.cancel?.();
|
||||
if (!isReachableStorageResponse(fallback)) {
|
||||
return { ok: false, error: `HTTP ${fallback.status}` };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function isReachableStorageResponse(response) {
|
||||
if (response.ok) return true;
|
||||
return response.status >= 300
|
||||
&& response.status < 400
|
||||
&& Boolean(response.headers?.get?.('location'));
|
||||
}
|
||||
160
scripts/migration-integrity-check.mjs
Normal file
160
scripts/migration-integrity-check.mjs
Normal file
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'fs';
|
||||
import { Pool } from 'pg';
|
||||
import {
|
||||
checkStorageUrl,
|
||||
getMigrationCheckBaseUrl,
|
||||
getMigrationStorageUrlConcurrency,
|
||||
getMigrationStorageUrlTimeoutMs,
|
||||
} from './migration-integrity-check-helpers.mjs';
|
||||
|
||||
loadEnvFile('.env.local');
|
||||
|
||||
const connectionString = process.env.LOCAL_DB_URL;
|
||||
if (!connectionString) {
|
||||
console.error('LOCAL_DB_URL is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const baseUrl = getMigrationCheckBaseUrl();
|
||||
const maxStorageUrls = Number(process.env.MIGRATION_CHECK_STORAGE_URL_LIMIT || 200);
|
||||
const storageUrlTimeoutMs = getMigrationStorageUrlTimeoutMs();
|
||||
const storageUrlConcurrency = getMigrationStorageUrlConcurrency();
|
||||
const pool = new Pool({ connectionString, max: 2 });
|
||||
const checks = [];
|
||||
|
||||
try {
|
||||
await collectChecks();
|
||||
const blockers = checks.filter(check => check.severity === 'blocker' && check.value > 0);
|
||||
const warnings = checks.filter(check => check.severity === 'warning' && check.value > 0);
|
||||
console.log(JSON.stringify({
|
||||
ok: blockers.length === 0,
|
||||
baseUrl,
|
||||
checkedAt: new Date().toISOString(),
|
||||
blockers,
|
||||
warnings,
|
||||
checks,
|
||||
}, null, 2));
|
||||
process.exit(blockers.length === 0 ? 0 : 1);
|
||||
} finally {
|
||||
await pool.end().catch(() => undefined);
|
||||
}
|
||||
|
||||
async function collectChecks() {
|
||||
await scalar('profiles_total', 'info', 'select count(*) from profiles');
|
||||
await scalar('auth_users_total', 'info', 'select count(*) from auth.users');
|
||||
await scalar('works_total', 'info', 'select count(*) from works');
|
||||
await scalar('private_works_total', 'info', 'select count(*) from works where is_public = false');
|
||||
|
||||
await scalar('profiles_without_auth', 'blocker', 'select count(*) from profiles p left join auth.users au on au.id = p.id where au.id is null');
|
||||
await scalar('auth_without_profile', 'blocker', 'select count(*) from auth.users au left join profiles p on p.id = au.id where p.id is null');
|
||||
await scalar('missing_password_hash', 'blocker', "select count(*) from auth.users where coalesce(password_hash, '') = ''");
|
||||
|
||||
await scalar('works_missing_profile', 'blocker', 'select count(*) from works w left join profiles p on p.id = w.user_id where w.user_id is not null and p.id is null');
|
||||
await scalar('works_missing_user_id', 'blocker', 'select count(*) from works where user_id is null');
|
||||
await scalar('credit_tx_missing_profile', 'blocker', 'select count(*) from credit_transactions ct left join profiles p on p.id = ct.user_id where ct.user_id is not null and p.id is null');
|
||||
await scalar('credit_tx_missing_work', 'blocker', 'select count(*) from credit_transactions ct left join works w on w.id = ct.related_work_id where ct.related_work_id is not null and w.id is null');
|
||||
await scalar('credit_tx_user_work_mismatch', 'blocker', 'select count(*) from credit_transactions ct join works w on w.id = ct.related_work_id where ct.user_id is not null and w.user_id is not null and ct.user_id <> w.user_id');
|
||||
await scalar('orders_missing_profile', 'blocker', 'select count(*) from orders o left join profiles p on p.id = o.user_id where o.user_id is not null and p.id is null');
|
||||
await scalar('redeem_codes_created_by_missing_profile', 'blocker', "select case when to_regclass('public.redeem_codes') is null then 0 else (select count(*) from redeem_codes rc left join profiles p on p.id = rc.created_by where rc.created_by is not null and p.id is null) end");
|
||||
await scalar('redeem_codes_used_by_missing_profile', 'blocker', "select case when to_regclass('public.redeem_codes') is null then 0 else (select count(*) from redeem_codes rc left join profiles p on p.id = rc.used_by where rc.used_by is not null and p.id is null) end");
|
||||
await scalar('invitation_referrals_missing_inviter', 'blocker', "select case when to_regclass('public.invitation_referrals') is null then 0 else (select count(*) from invitation_referrals ir left join profiles p on p.id = ir.inviter_user_id where p.id is null) end");
|
||||
await scalar('invitation_referrals_missing_invitee', 'blocker', "select case when to_regclass('public.invitation_referrals') is null then 0 else (select count(*) from invitation_referrals ir left join profiles p on p.id = ir.invitee_user_id where p.id is null) end");
|
||||
await scalar('user_api_keys_missing_profile', 'blocker', 'select count(*) from user_api_keys k left join profiles p on p.id = k.user_id where k.user_id is not null and p.id is null');
|
||||
await scalar('user_api_keys_missing_preview', 'blocker', "select count(*) from user_api_keys where coalesce(api_key_encrypted, '') <> '' and coalesce(api_key_preview, '') = ''");
|
||||
await scalar('system_api_missing_preview', 'blocker', "select count(*) from system_api_configs where coalesce(api_key_encrypted, '') <> '' and coalesce(api_key_preview, '') = ''");
|
||||
await scalar('work_likes_missing_profile', 'blocker', 'select count(*) from work_likes wl left join profiles p on p.id = wl.user_id where wl.user_id is not null and p.id is null');
|
||||
await scalar('work_likes_missing_work', 'blocker', 'select count(*) from work_likes wl left join works w on w.id = wl.work_id where wl.work_id is not null and w.id is null');
|
||||
await scalar('generation_jobs_missing_profile', 'blocker', 'select count(*) from generation_jobs gj left join profiles p on p.id = gj.user_id where gj.user_id is not null and p.id is null');
|
||||
|
||||
await scalar('same_url_different_users', 'info', "select count(*) from (select result_url from works where coalesce(result_url, '') <> '' group by result_url having count(distinct user_id) > 1) t");
|
||||
await scalar('duplicate_url_same_user', 'warning', "select count(*) from (select user_id, result_url from works where coalesce(result_url, '') <> '' group by user_id, result_url having count(*) > 1) t");
|
||||
|
||||
for (const [table, column] of [
|
||||
['user_api_keys', 'manifest_path'],
|
||||
['system_api_configs', 'manifest_path'],
|
||||
['system_api_configs', 'billing_mode'],
|
||||
['system_api_configs', 'fixed_price'],
|
||||
['system_api_configs', 'duration_price_per_second'],
|
||||
['system_api_configs', 'input_price_per_1k'],
|
||||
['system_api_configs', 'output_price_per_1k'],
|
||||
['system_api_configs', 'is_default'],
|
||||
['system_api_configs', 'allowed_membership_tiers'],
|
||||
['system_api_configs', 'polling_mode'],
|
||||
['system_api_configs', 'polling_order'],
|
||||
['profiles', 'invite_code'],
|
||||
['profiles', 'referred_by_user_id'],
|
||||
['invitation_referrals', 'invite_code'],
|
||||
['invitation_referrals', 'inviter_user_id'],
|
||||
['invitation_referrals', 'invitee_user_id'],
|
||||
]) {
|
||||
await requiredColumn(table, column);
|
||||
}
|
||||
|
||||
await checkLocalStorageUrls();
|
||||
}
|
||||
|
||||
async function scalar(name, severity, sql, params = []) {
|
||||
const res = await pool.query(sql, params);
|
||||
checks.push({
|
||||
name,
|
||||
severity,
|
||||
value: Number(res.rows[0]?.count ?? res.rows[0]?.value ?? 0),
|
||||
});
|
||||
}
|
||||
|
||||
async function requiredColumn(table, column) {
|
||||
const [schema, tableName] = table.includes('.') ? table.split('.', 2) : ['public', table];
|
||||
const res = await pool.query(
|
||||
'select count(*)::int as count from information_schema.columns where table_schema = $1 and table_name = $2 and column_name = $3',
|
||||
[schema, tableName, column],
|
||||
);
|
||||
checks.push({
|
||||
name: `column_${table}_${column}`,
|
||||
severity: 'blocker',
|
||||
value: Number(res.rows[0]?.count || 0) === 1 ? 0 : 1,
|
||||
});
|
||||
}
|
||||
|
||||
async function checkLocalStorageUrls() {
|
||||
const res = await pool.query(`
|
||||
with urls as (
|
||||
select result_url as url from works where result_url like '/api/local-storage/%'
|
||||
union select thumbnail_url as url from works where thumbnail_url like '/api/local-storage/%'
|
||||
union select logo_url as url from site_config where logo_url like '/api/local-storage/%'
|
||||
union select favicon_url as url from site_config where favicon_url like '/api/local-storage/%'
|
||||
)
|
||||
select url from urls where url is not null limit $1
|
||||
`, [Number.isFinite(maxStorageUrls) && maxStorageUrls > 0 ? maxStorageUrls : 200]);
|
||||
|
||||
let missing = 0;
|
||||
let checked = 0;
|
||||
let cursor = 0;
|
||||
const workers = Array.from({ length: Math.min(storageUrlConcurrency, Math.max(1, res.rows.length)) }, async () => {
|
||||
while (cursor < res.rows.length) {
|
||||
const row = res.rows[cursor++];
|
||||
const result = await checkStorageUrl(baseUrl, row.url, { timeoutMs: storageUrlTimeoutMs });
|
||||
checked += 1;
|
||||
if (!result.ok) missing += 1;
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
|
||||
checks.push({ name: 'local_storage_urls_checked', severity: 'info', value: res.rows.length });
|
||||
checks.push({ name: 'local_storage_urls_probe_completed', severity: 'info', value: checked });
|
||||
checks.push({ name: 'local_storage_urls_missing', severity: 'blocker', value: missing });
|
||||
}
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
||||
if (!match) continue;
|
||||
const [, key, value] = match;
|
||||
if (process.env[key] !== undefined) continue;
|
||||
process.env[key] = value.replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
}
|
||||
199
scripts/rainyun-ros-prepare.mjs
Normal file
199
scripts/rainyun-ros-prepare.mjs
Normal file
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
loadEnvFile(path.join(process.cwd(), '.env.local'));
|
||||
|
||||
const args = new Set(process.argv.slice(2));
|
||||
const create = args.has('--create');
|
||||
const list = args.has('--list');
|
||||
const printEnv = args.has('--print-env');
|
||||
const apiBaseUrl = trimTrailingSlash(process.env.RAINYUN_API_BASE_URL || 'https://api.v2.rainyun.com');
|
||||
const apiKey = process.env.RAINYUN_API_KEY?.trim() || '';
|
||||
const devToken = process.env.RAINYUN_DEV_TOKEN?.trim();
|
||||
const bucketName = process.env.RAINYUN_ROS_BUCKET_NAME?.trim() || process.env.OBJECT_STORAGE_BUCKET?.trim();
|
||||
const instanceId = Number(process.env.RAINYUN_ROS_INSTANCE_ID || 0);
|
||||
const outputEnvPath = process.env.RAINYUN_ROS_OUTPUT_ENV || '.env.rainyun-object.generated';
|
||||
|
||||
if (!create && !list) {
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
dryRun: true,
|
||||
message: 'No network request was sent. Pass --list to list ROS buckets or --create to create one.',
|
||||
apiBaseUrl,
|
||||
createEndpoint: `${apiBaseUrl}/product/ros/bucket`,
|
||||
requiredEnv: ['RAINYUN_API_KEY', 'RAINYUN_ROS_BUCKET_NAME', 'RAINYUN_ROS_INSTANCE_ID'],
|
||||
outputEnvPath,
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (list) {
|
||||
const buckets = await rainyunRequest('/product/ros/bucket', { method: 'GET' });
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
action: 'list',
|
||||
buckets: sanitizeForLog(buckets),
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
if (create) {
|
||||
if (!bucketName) throw new Error('RAINYUN_ROS_BUCKET_NAME or OBJECT_STORAGE_BUCKET is required');
|
||||
if (!Number.isInteger(instanceId) || instanceId <= 0) throw new Error('RAINYUN_ROS_INSTANCE_ID must be a positive integer');
|
||||
|
||||
const bucket = await rainyunRequest('/product/ros/bucket', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
bucket_name: bucketName,
|
||||
instance_id: instanceId,
|
||||
},
|
||||
});
|
||||
const env = buildObjectStorageEnv(bucket);
|
||||
if (!env.OBJECT_STORAGE_ENDPOINT) {
|
||||
throw new Error('Rainyun response did not include public_api_url; set OBJECT_STORAGE_ENDPOINT manually before migration');
|
||||
}
|
||||
writeEnvFile(outputEnvPath, env);
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
action: 'create',
|
||||
bucket: sanitizeForLog(bucket),
|
||||
outputEnvPath,
|
||||
printedEnv: printEnv ? redactEnv(env) : undefined,
|
||||
nextSteps: [
|
||||
`Review ${outputEnvPath} and copy the OBJECT_STORAGE_* values into production .env.local`,
|
||||
'Set STORAGE_MODE=dual first, not object',
|
||||
'Run pnpm run migration:check before migration',
|
||||
'Run pnpm run storage:sync-object -- --dry-run',
|
||||
'Run pnpm run storage:sync-object',
|
||||
'Run pnpm run storage:sync-object -- --verify-only',
|
||||
'Reload PM2 and run pnpm run migration:check again',
|
||||
],
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
async function rainyunRequest(endpoint, options = {}) {
|
||||
if (!apiKey) throw new Error('RAINYUN_API_KEY is required');
|
||||
const response = await fetch(`${apiBaseUrl}${endpoint}`, {
|
||||
method: options.method || 'GET',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
...(devToken ? { 'rain-dev-token': devToken } : {}),
|
||||
},
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
const text = await response.text();
|
||||
let parsed;
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
parsed = text;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Rainyun API ${response.status}: ${typeof parsed === 'string' ? parsed.slice(0, 500) : JSON.stringify(sanitizeForLog(parsed))}`);
|
||||
}
|
||||
return unwrapRainyunData(parsed);
|
||||
}
|
||||
|
||||
function unwrapRainyunData(value) {
|
||||
if (value && typeof value === 'object') {
|
||||
if ('data' in value && value.data && typeof value.data === 'object') return value.data;
|
||||
if ('Data' in value && value.Data && typeof value.Data === 'object') return value.Data;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function buildObjectStorageEnv(bucket) {
|
||||
const source = bucket && typeof bucket === 'object' ? bucket : {};
|
||||
const instance = source.instance && typeof source.instance === 'object' ? source.instance : {};
|
||||
const endpoint = normalizeEndpoint(
|
||||
firstString(source.public_api_url, instance.public_api_url, process.env.OBJECT_STORAGE_ENDPOINT),
|
||||
);
|
||||
return {
|
||||
STORAGE_MODE: 'dual',
|
||||
OBJECT_STORAGE_BUCKET: firstString(source.name, source.bucket_name, bucketName),
|
||||
OBJECT_STORAGE_REGION: process.env.OBJECT_STORAGE_REGION || 'auto',
|
||||
OBJECT_STORAGE_ENDPOINT: endpoint,
|
||||
OBJECT_STORAGE_ACCESS_KEY_ID: firstString(source.access_key, instance.access_key, process.env.OBJECT_STORAGE_ACCESS_KEY_ID),
|
||||
OBJECT_STORAGE_SECRET_ACCESS_KEY: firstString(source.secret_key, instance.secret_key, process.env.OBJECT_STORAGE_SECRET_ACCESS_KEY),
|
||||
OBJECT_STORAGE_FORCE_PATH_STYLE: process.env.OBJECT_STORAGE_FORCE_PATH_STYLE || 'true',
|
||||
OBJECT_STORAGE_PREFIX: process.env.OBJECT_STORAGE_PREFIX || 'miaojing',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEndpoint(value) {
|
||||
const raw = firstString(value);
|
||||
if (!raw) return '';
|
||||
const withProtocol = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
|
||||
try {
|
||||
const url = new URL(withProtocol);
|
||||
url.pathname = url.pathname.replace(/\/+$/, '');
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
return url.toString().replace(/\/$/, '');
|
||||
} catch {
|
||||
return withProtocol.replace(/\/+$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
function writeEnvFile(filePath, env) {
|
||||
const lines = [
|
||||
'# Generated by scripts/rainyun-ros-prepare.mjs',
|
||||
'# Keep this file private. It contains object storage credentials.',
|
||||
...Object.entries(env).map(([key, value]) => `${key}=${quoteEnvValue(value)}`),
|
||||
'',
|
||||
];
|
||||
fs.writeFileSync(filePath, lines.join('\n'), { mode: 0o600 });
|
||||
}
|
||||
|
||||
function redactEnv(env) {
|
||||
return Object.fromEntries(Object.entries(env).map(([key, value]) => [
|
||||
key,
|
||||
/SECRET|KEY/i.test(key) ? redact(value) : value,
|
||||
]));
|
||||
}
|
||||
|
||||
function sanitizeForLog(value) {
|
||||
if (Array.isArray(value)) return value.map(sanitizeForLog);
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
return Object.fromEntries(Object.entries(value).map(([key, nested]) => [
|
||||
key,
|
||||
/secret|access_key|api[_-]?key|token/i.test(key) ? redact(String(nested || '')) : sanitizeForLog(nested),
|
||||
]));
|
||||
}
|
||||
|
||||
function redact(value) {
|
||||
if (!value) return '';
|
||||
return `${value.slice(0, 4)}...${value.slice(-4)}`;
|
||||
}
|
||||
|
||||
function firstString(...values) {
|
||||
for (const value of values) {
|
||||
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function quoteEnvValue(value) {
|
||||
const text = String(value ?? '');
|
||||
if (/^[A-Za-z0-9._~:/@-]*$/.test(text)) return text;
|
||||
return JSON.stringify(text);
|
||||
}
|
||||
|
||||
function trimTrailingSlash(value) {
|
||||
return value.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
||||
if (!match) continue;
|
||||
const [, key, value] = match;
|
||||
if (process.env[key] !== undefined) continue;
|
||||
process.env[key] = value.replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
}
|
||||
164
scripts/storage-sync-to-object.mjs
Normal file
164
scripts/storage-sync-to-object.mjs
Normal file
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import {
|
||||
HeadObjectCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
|
||||
loadEnvFile(path.join(process.cwd(), '.env.local'));
|
||||
|
||||
const args = new Set(process.argv.slice(2));
|
||||
const dryRun = args.has('--dry-run');
|
||||
const verifyOnly = args.has('--verify-only');
|
||||
const localRoot = path.resolve(process.env.LOCAL_STORAGE_DIR || path.join(process.cwd(), 'local-storage'));
|
||||
const bucket = requiredEnv('OBJECT_STORAGE_BUCKET');
|
||||
const region = process.env.OBJECT_STORAGE_REGION || 'auto';
|
||||
const endpoint = process.env.OBJECT_STORAGE_ENDPOINT || undefined;
|
||||
const prefix = normalizePrefix(process.env.OBJECT_STORAGE_PREFIX || '');
|
||||
const forcePathStyle = booleanEnv(process.env.OBJECT_STORAGE_FORCE_PATH_STYLE, true);
|
||||
|
||||
const client = new S3Client({
|
||||
region,
|
||||
endpoint,
|
||||
forcePathStyle,
|
||||
credentials: process.env.OBJECT_STORAGE_ACCESS_KEY_ID && process.env.OBJECT_STORAGE_SECRET_ACCESS_KEY
|
||||
? {
|
||||
accessKeyId: process.env.OBJECT_STORAGE_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.OBJECT_STORAGE_SECRET_ACCESS_KEY,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (!fs.existsSync(localRoot) || !fs.statSync(localRoot).isDirectory()) {
|
||||
console.error(`Local storage directory does not exist: ${localRoot}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const files = walk(localRoot);
|
||||
let uploaded = 0;
|
||||
let skipped = 0;
|
||||
let verified = 0;
|
||||
const failures = [];
|
||||
|
||||
for (const filePath of files) {
|
||||
const key = toObjectKey(path.relative(localRoot, filePath));
|
||||
const stat = fs.statSync(filePath);
|
||||
try {
|
||||
const existing = await headObject(key);
|
||||
if (existing && Number(existing.ContentLength || 0) === stat.size) {
|
||||
skipped++;
|
||||
verified++;
|
||||
continue;
|
||||
}
|
||||
if (verifyOnly) {
|
||||
failures.push(`${key}: missing or size mismatch`);
|
||||
continue;
|
||||
}
|
||||
if (!dryRun) {
|
||||
await client.send(new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: fs.createReadStream(filePath),
|
||||
ContentType: getContentType(key),
|
||||
}));
|
||||
const after = await headObject(key);
|
||||
if (!after || Number(after.ContentLength || 0) !== stat.size) {
|
||||
failures.push(`${key}: uploaded size mismatch`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
uploaded++;
|
||||
verified++;
|
||||
} catch (error) {
|
||||
failures.push(`${key}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
dryRun,
|
||||
verifyOnly,
|
||||
localRoot,
|
||||
bucket,
|
||||
endpoint,
|
||||
prefix,
|
||||
totalFiles: files.length,
|
||||
uploaded,
|
||||
skipped,
|
||||
verified,
|
||||
failures,
|
||||
}, null, 2));
|
||||
|
||||
if (failures.length > 0) process.exit(1);
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
||||
if (!match) continue;
|
||||
const [, key, value] = match;
|
||||
if (process.env[key] !== undefined) continue;
|
||||
process.env[key] = value.replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
function requiredEnv(key) {
|
||||
const value = process.env[key]?.trim();
|
||||
if (!value) {
|
||||
console.error(`${key} is required`);
|
||||
process.exit(1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function booleanEnv(value, fallback) {
|
||||
if (value == null || value === '') return fallback;
|
||||
return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
function normalizePrefix(value) {
|
||||
const normalized = path.posix.normalize(value.replace(/\\/g, '/')).replace(/^\/+|\/+$/g, '');
|
||||
if (!normalized || normalized === '.') return '';
|
||||
if (normalized.startsWith('../') || normalized.includes('/../') || normalized.includes('\0')) {
|
||||
throw new Error('Invalid OBJECT_STORAGE_PREFIX');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function toObjectKey(relativePath) {
|
||||
const key = relativePath.split(path.sep).join('/');
|
||||
return prefix ? `${prefix}/${key}` : key;
|
||||
}
|
||||
|
||||
function walk(dir) {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
return entries.flatMap(entry => {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) return walk(fullPath);
|
||||
return entry.isFile() ? [fullPath] : [];
|
||||
});
|
||||
}
|
||||
|
||||
async function headObject(key) {
|
||||
try {
|
||||
return await client.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getContentType(key) {
|
||||
const ext = key.split('.').pop()?.toLowerCase();
|
||||
if (ext === 'jpg' || ext === 'jpeg') return 'image/jpeg';
|
||||
if (ext === 'png') return 'image/png';
|
||||
if (ext === 'webp') return 'image/webp';
|
||||
if (ext === 'gif') return 'image/gif';
|
||||
if (ext === 'mp4') return 'video/mp4';
|
||||
if (ext === 'webm') return 'video/webm';
|
||||
if (ext === 'json') return 'application/json';
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
160
scripts/test-admin-gallery-prompt-service.mjs
Normal file
160
scripts/test-admin-gallery-prompt-service.mjs
Normal file
@@ -0,0 +1,160 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { updateAdminGalleryPrompt } from '../src/lib/admin-gallery-prompt-service.ts';
|
||||
import {
|
||||
buildAdminGalleryWorksPaginationMeta,
|
||||
parseAdminGalleryWorksPagination,
|
||||
} from '../src/lib/admin-gallery-works-pagination.ts';
|
||||
|
||||
function createWork(overrides = {}) {
|
||||
return {
|
||||
id: '11111111-1111-1111-1111-111111111111',
|
||||
user_id: '22222222-2222-2222-2222-222222222222',
|
||||
type: 'text2img',
|
||||
title: 'public work',
|
||||
prompt: 'old public prompt',
|
||||
negative_prompt: null,
|
||||
result_url: '/api/local-storage/gallery/image.webp',
|
||||
thumbnail_url: '/api/local-storage/thumbnails/gallery/image.webp',
|
||||
likes_count: 3,
|
||||
is_public: true,
|
||||
status: 'completed',
|
||||
created_at: '2026-05-20T00:00:00.000Z',
|
||||
author_email: 'author@example.com',
|
||||
author_nickname: 'Author',
|
||||
author_display_nickname: 'Author Display',
|
||||
author_avatar_url: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createServiceHarness({ work, emailFails = false } = {}) {
|
||||
const state = {
|
||||
work: work || createWork(),
|
||||
updates: [],
|
||||
emails: [],
|
||||
logs: [],
|
||||
};
|
||||
|
||||
return {
|
||||
state,
|
||||
deps: {
|
||||
loadWork: async (workId) => (workId === state.work.id ? state.work : null),
|
||||
updatePrompt: async (workId, prompt) => {
|
||||
state.updates.push({ workId, prompt });
|
||||
state.work = { ...state.work, prompt };
|
||||
return state.work;
|
||||
},
|
||||
sendEmail: async (message) => {
|
||||
state.emails.push(message);
|
||||
if (emailFails) throw new Error('SMTP down');
|
||||
},
|
||||
writeLog: async (entry) => {
|
||||
state.logs.push(entry);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runTest(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`PASS ${name}`);
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${name}`);
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const admin = { userId: '33333333-3333-3333-3333-333333333333', role: 'admin' };
|
||||
const baseInput = {
|
||||
workId: '11111111-1111-1111-1111-111111111111',
|
||||
prompt: 'new compliant prompt',
|
||||
emailSubject: '公开作品提示词已调整',
|
||||
emailBody: '你的公开作品提示词已根据平台规范调整。',
|
||||
reasonKey: 'remove_sensitive_words',
|
||||
};
|
||||
|
||||
await runTest('rejects non-public works', async () => {
|
||||
const { deps, state } = createServiceHarness({ work: createWork({ is_public: false }) });
|
||||
await assert.rejects(() => updateAdminGalleryPrompt(baseInput, { admin, ...deps }), /作品不存在或不是公开作品/);
|
||||
assert.equal(state.updates.length, 0);
|
||||
assert.equal(state.emails.length, 0);
|
||||
});
|
||||
|
||||
await runTest('rejects missing author email', async () => {
|
||||
const { deps, state } = createServiceHarness({ work: createWork({ author_email: '' }) });
|
||||
await assert.rejects(() => updateAdminGalleryPrompt(baseInput, { admin, ...deps }), /作者邮箱不可用/);
|
||||
assert.equal(state.updates.length, 0);
|
||||
assert.equal(state.emails.length, 0);
|
||||
});
|
||||
|
||||
await runTest('rejects unchanged prompt', async () => {
|
||||
const { deps, state } = createServiceHarness();
|
||||
await assert.rejects(
|
||||
() => updateAdminGalleryPrompt({ ...baseInput, prompt: 'old public prompt' }, { admin, ...deps }),
|
||||
/提示词没有变化/,
|
||||
);
|
||||
assert.equal(state.updates.length, 0);
|
||||
assert.equal(state.emails.length, 0);
|
||||
});
|
||||
|
||||
await runTest('does not update prompt when email sending fails', async () => {
|
||||
const { deps, state } = createServiceHarness({ emailFails: true });
|
||||
await assert.rejects(() => updateAdminGalleryPrompt(baseInput, { admin, ...deps }), /SMTP down/);
|
||||
assert.equal(state.updates.length, 0);
|
||||
assert.equal(state.emails.length, 1);
|
||||
});
|
||||
|
||||
await runTest('sends email before updating prompt', async () => {
|
||||
const { deps, state } = createServiceHarness();
|
||||
const result = await updateAdminGalleryPrompt(baseInput, { admin, ...deps });
|
||||
assert.equal(state.emails.length, 1);
|
||||
assert.equal(state.updates.length, 1);
|
||||
assert.equal(state.updates[0].prompt, 'new compliant prompt');
|
||||
assert.equal(result.work.prompt, 'new compliant prompt');
|
||||
});
|
||||
|
||||
await runTest('writes moderation log metadata without full prompt text', async () => {
|
||||
const { deps, state } = createServiceHarness();
|
||||
await updateAdminGalleryPrompt(baseInput, { admin, ...deps });
|
||||
assert.equal(state.logs.length, 1);
|
||||
const logText = JSON.stringify(state.logs[0]);
|
||||
assert.match(logText, /remove_sensitive_words/);
|
||||
assert.doesNotMatch(logText, /old public prompt/);
|
||||
assert.doesNotMatch(logText, /new compliant prompt/);
|
||||
});
|
||||
|
||||
await runTest('parses admin gallery page and pageSize into limit and offset', async () => {
|
||||
const pagination = parseAdminGalleryWorksPagination(new URLSearchParams('page=3&pageSize=50'));
|
||||
assert.deepEqual(pagination, {
|
||||
page: 3,
|
||||
pageSize: 50,
|
||||
limit: 50,
|
||||
offset: 100,
|
||||
});
|
||||
});
|
||||
|
||||
await runTest('keeps limit and offset compatibility for admin gallery works', async () => {
|
||||
const pagination = parseAdminGalleryWorksPagination(new URLSearchParams('limit=15&offset=30'));
|
||||
assert.deepEqual(pagination, {
|
||||
page: 3,
|
||||
pageSize: 15,
|
||||
limit: 15,
|
||||
offset: 30,
|
||||
});
|
||||
});
|
||||
|
||||
await runTest('builds admin gallery pagination metadata', async () => {
|
||||
const meta = buildAdminGalleryWorksPaginationMeta({ total: 46, page: 2, pageSize: 20, resultCount: 20 });
|
||||
assert.deepEqual(meta, {
|
||||
total: 46,
|
||||
page: 2,
|
||||
pageSize: 20,
|
||||
totalPages: 3,
|
||||
nextOffset: 40,
|
||||
hasMore: true,
|
||||
});
|
||||
});
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
67
scripts/test-admin-password-and-video-reuse.mjs
Normal file
67
scripts/test-admin-password-and-video-reuse.mjs
Normal file
@@ -0,0 +1,67 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, '..');
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
async function runTest(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`PASS ${name}`);
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${name}`);
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
await runTest('admin user management exposes reset password without hiding it behind edit modal', () => {
|
||||
const source = read('src/components/admin/user-management-tab.tsx');
|
||||
|
||||
assert.match(source, /const startResetPassword = \(user: ManagedUser\) => \{/);
|
||||
assert.match(source, /setResetPwUser\(user\)/);
|
||||
assert.match(source, /setNewPassword\(''\)/);
|
||||
assert.match(source, /setEditingUser\(null\)/);
|
||||
assert.match(source, /onClick=\{\(\) => startResetPassword\(user\)\}/);
|
||||
assert.match(source, /<KeyRound className="h-3\.5 w-3\.5" \/>重置密码/);
|
||||
assert.match(source, /onClick=\{\(\) => startResetPassword\(editingUser\)\}/);
|
||||
});
|
||||
|
||||
await runTest('admin reset password form is rendered as an overlay dialog', () => {
|
||||
const source = read('src/components/admin/user-management-tab.tsx');
|
||||
const resetSection = source.slice(source.indexOf('{resetPwUser && ('), source.indexOf('{editingUser && ('));
|
||||
|
||||
assert.match(resetSection, /fixed inset-0 z-50/);
|
||||
assert.match(resetSection, /max-h-\[90vh\] overflow-y-auto/);
|
||||
assert.doesNotMatch(resetSection, /\{resetPwUser && \(\s*<Card className="border-primary\/30">/);
|
||||
assert.match(source, /setRechargeUser\(null\)/);
|
||||
assert.match(source, /setShowAddForm\(false\)/);
|
||||
});
|
||||
|
||||
await runTest('admin password reset upserts auth credentials instead of silently updating zero rows', () => {
|
||||
const source = read('src/lib/admin-users-service.ts');
|
||||
|
||||
assert.match(source, /INSERT INTO auth\.users \(id, email, password_hash, created_at\)/);
|
||||
assert.match(source, /VALUES \(\$1, \$2, crypt\(\$3, gen_salt\('bf'\)\), NOW\(\)\)/);
|
||||
assert.match(source, /ON CONFLICT \(id\) DO UPDATE SET password_hash = crypt\(\$3, gen_salt\('bf'\)\)/);
|
||||
assert.match(source, /\[userId,\s*currentResult\.rows\[0\]\.email,\s*newPassword\]/);
|
||||
});
|
||||
|
||||
await runTest('creation detail reuse supports text-to-video and image-to-video history records', () => {
|
||||
const source = read('src/components/creation-detail-dialog.tsx');
|
||||
|
||||
assert.match(source, /buildCreationReuseDraft,\s*writeCreationReuseDraft/);
|
||||
assert.match(source, /function getReuseTarget\(record: CreationRecord\)/);
|
||||
assert.match(source, /return mode === 'img2video' \? 'img2video' : 'text2video'/);
|
||||
assert.match(source, /const target = getReuseTarget\(record\)/);
|
||||
assert.match(source, /writeCreationReuseDraft\(target,\s*draft\)/);
|
||||
assert.match(source, /router\.push\(`\/create\?type=\$\{target\}&reuse=\$\{encodeURIComponent\(record\.id\)\}`\)/);
|
||||
assert.doesNotMatch(source, /disabled=\{record\.type !== 'image'\}/);
|
||||
assert.doesNotMatch(source, /当前仅支持将图片创作配置复用到文生图/);
|
||||
});
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
221
scripts/test-agnes-system-model-templates.mjs
Normal file
221
scripts/test-agnes-system-model-templates.mjs
Normal file
@@ -0,0 +1,221 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, '..');
|
||||
|
||||
const {
|
||||
AGNES_BASE_URL,
|
||||
AGNES_PROVIDER_NAME,
|
||||
AGNES_IMAGE_MODEL_GROUP,
|
||||
AGNES_VIDEO_MODEL_GROUP,
|
||||
AGNES_TEXT_MODEL_GROUP,
|
||||
AGNES_IMAGE_MODEL_TEMPLATES,
|
||||
AGNES_VIDEO_MODEL_TEMPLATES,
|
||||
AGNES_TEXT_MODEL_TEMPLATES,
|
||||
AGNES_VIDEO_FRAME_RATE,
|
||||
normalizeAgnesVideoDuration,
|
||||
getAgnesVideoNumFrames,
|
||||
getAgnesModelCapabilities,
|
||||
buildAgnesImageManifestBundle,
|
||||
buildAgnesVideoManifestBundle,
|
||||
buildAgnesCapabilitiesText,
|
||||
} = await import('../src/lib/agnes-model-templates.ts');
|
||||
|
||||
async function runTest(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`PASS ${name}`);
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${name}`);
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
await runTest('Agnes templates cover documented image, video, and text models', () => {
|
||||
assert.equal(AGNES_BASE_URL, 'https://apihub.agnes-ai.com');
|
||||
assert.equal(AGNES_PROVIDER_NAME, 'Agnes AI');
|
||||
assert.equal(AGNES_IMAGE_MODEL_GROUP, 'agnes-image');
|
||||
assert.equal(AGNES_VIDEO_MODEL_GROUP, 'agnes-video');
|
||||
assert.equal(AGNES_TEXT_MODEL_GROUP, 'agnes-text');
|
||||
|
||||
assert.deepEqual(AGNES_IMAGE_MODEL_TEMPLATES.map(item => item.modelName), [
|
||||
'agnes-image-2.1-flash',
|
||||
'agnes-image-2.0-flash',
|
||||
]);
|
||||
assert.deepEqual(AGNES_VIDEO_MODEL_TEMPLATES.map(item => item.modelName), ['agnes-video-v2.0']);
|
||||
assert.deepEqual(AGNES_TEXT_MODEL_TEMPLATES.map(item => item.modelName), ['agnes-2.0-flash', 'agnes-1.5-flash']);
|
||||
});
|
||||
|
||||
await runTest('Agnes image Manifest maps documented OpenAI-compatible image fields', () => {
|
||||
const template = AGNES_IMAGE_MODEL_TEMPLATES.find(item => item.modelName === 'agnes-image-2.1-flash');
|
||||
assert.ok(template, 'missing Agnes Image 2.1 Flash template');
|
||||
const bundle = buildAgnesImageManifestBundle(template);
|
||||
const provider = bundle.customProviders[0];
|
||||
const profile = bundle.profiles[0];
|
||||
|
||||
assert.equal(profile.baseUrl, AGNES_BASE_URL);
|
||||
assert.equal(profile.apiMode, 'images');
|
||||
assert.equal(profile.capabilities?.supportsAspectRatio, false);
|
||||
assert.deepEqual(profile.capabilities?.resolutions?.map(item => item.value), [
|
||||
'1024x768',
|
||||
'1024x1024',
|
||||
'768x1024',
|
||||
'1152x768',
|
||||
'768x1152',
|
||||
]);
|
||||
assert.equal(provider.submit?.path, 'v1/images/generations');
|
||||
assert.equal(provider.submit?.method, 'POST');
|
||||
assert.equal(provider.submit?.contentType, 'json');
|
||||
assert.equal(provider.submit?.body?.model, '$profile.model');
|
||||
assert.equal(provider.submit?.body?.prompt, '$prompt');
|
||||
assert.equal(provider.submit?.body?.size, '$params.size');
|
||||
assert.equal(provider.submit?.body?.image, '$inputImages.urls');
|
||||
assert.deepEqual(provider.submit?.body?.extra_body, { response_format: 'url' });
|
||||
assert.equal(provider.submit?.body?.response_format, undefined);
|
||||
assert.deepEqual(provider.submit?.result?.imageUrlPaths, ['data.*.url']);
|
||||
assert.deepEqual(provider.submit?.result?.b64JsonPaths, ['data.*.b64_json']);
|
||||
});
|
||||
|
||||
await runTest('Agnes video Manifest creates async task and polls by video_id', () => {
|
||||
const template = AGNES_VIDEO_MODEL_TEMPLATES[0];
|
||||
const bundle = buildAgnesVideoManifestBundle(template);
|
||||
const provider = bundle.customProviders[0];
|
||||
|
||||
assert.equal(bundle.profiles[0].baseUrl, AGNES_BASE_URL);
|
||||
assert.equal(bundle.profiles[0].apiMode, 'videos');
|
||||
assert.equal(provider.submit?.path, 'v1/videos');
|
||||
assert.equal(provider.submit?.body?.model, '$profile.model');
|
||||
assert.equal(provider.submit?.body?.prompt, '$prompt');
|
||||
assert.equal(provider.submit?.body?.image, '$inputImages.urls.0');
|
||||
assert.equal(provider.submit?.body?.num_frames, '$params.num_frames');
|
||||
assert.equal(provider.submit?.body?.negative_prompt, '$params.negative_prompt');
|
||||
assert.equal(provider.submit?.body?.frame_rate, '$params.fps');
|
||||
assert.equal(provider.submit?.body?.width, '$params.width');
|
||||
assert.equal(provider.submit?.body?.height, '$params.height');
|
||||
assert.match(provider.submit?.taskIdPath || '', /video_id/);
|
||||
assert.equal(provider.poll?.path, 'agnesapi');
|
||||
assert.deepEqual(provider.poll?.query, {
|
||||
video_id: '{task_id}',
|
||||
model_name: '$profile.model',
|
||||
});
|
||||
assert.equal(provider.poll?.statusPath, 'status');
|
||||
assert.deepEqual(provider.poll?.successValues, ['completed']);
|
||||
assert.deepEqual(provider.poll?.failureValues, ['failed']);
|
||||
assert.deepEqual(provider.poll?.result?.videoUrlPaths, ['remixed_from_video_id', 'video_url', 'url']);
|
||||
});
|
||||
|
||||
await runTest('Agnes video duration options map to documented frame counts at 24fps', () => {
|
||||
assert.equal(AGNES_VIDEO_FRAME_RATE, 24);
|
||||
assert.deepEqual(AGNES_VIDEO_MODEL_TEMPLATES[0].capabilities.durations?.map(item => item.value), ['3', '5', '10']);
|
||||
assert.equal(normalizeAgnesVideoDuration(18), null);
|
||||
assert.equal(getAgnesVideoNumFrames(3), 81);
|
||||
assert.equal(getAgnesVideoNumFrames(5), 121);
|
||||
assert.equal(getAgnesVideoNumFrames(10), 241);
|
||||
assert.deepEqual(getAgnesModelCapabilities('agnes-video-v2.0')?.durations?.map(item => item.value), ['3', '5', '10']);
|
||||
|
||||
const videoRoute = read('src/app/api/generate/video/route.ts');
|
||||
assert.match(videoRoute, /normalizeAgnesVideoDuration\(duration\)/);
|
||||
assert.match(videoRoute, /Agnes Video V2\.0 当前仅开放 3、5、10 秒/);
|
||||
assert.match(videoRoute, /const useAgnesVideoParams = isAgnesVideoApi\(resolvedCustomApiConfig\)/);
|
||||
assert.match(videoRoute, /getAgnesVideoNumFrames\(resolvedAgnesDuration\)/);
|
||||
assert.match(videoRoute, /fps:\s*useAgnesVideoParams\s*\?\s*AGNES_VIDEO_FRAME_RATE\s*:\s*fps/);
|
||||
assert.match(videoRoute, /num_frames:\s*useAgnesVideoParams\s*\?\s*getAgnesVideoNumFrames\(resolvedAgnesDuration\)\s*:\s*undefined/);
|
||||
assert.match(videoRoute, /timeoutMs:\s*useAgnesVideoParams\s*\?\s*AGNES_VIDEO_GENERATION_TIMEOUT\s*:\s*GENERATION_TIMEOUT/);
|
||||
});
|
||||
|
||||
await runTest('Agnes video failures are reported by stage instead of raw fetch failed', () => {
|
||||
const executor = read('src/lib/user-api-manifest-executor.ts');
|
||||
const videoRoute = read('src/app/api/generate/video/route.ts');
|
||||
const worker = read('src/lib/generation-job-worker.ts');
|
||||
const runner = read('src/lib/generation-job-runner.ts');
|
||||
|
||||
assert.match(executor, /const stage = method === 'GET' \? '上游任务轮询' : '上游任务创建'/);
|
||||
assert.match(executor, /网络连接失败,请稍后重试/);
|
||||
assert.match(videoRoute, /上游已返回视频地址,但平台下载或保存结果视频失败/);
|
||||
assert.match(worker, /creation history persistence failed:/);
|
||||
assert.match(worker, /\(\$\{url\}\)/);
|
||||
assert.match(runner, /内部生成请求网络连接失败/);
|
||||
assert.match(runner, /requestInternalGenerationJson/);
|
||||
});
|
||||
|
||||
await runTest('Agnes video polling progress is forwarded into generation job status', () => {
|
||||
const executor = read('src/lib/user-api-manifest-executor.ts');
|
||||
|
||||
assert.match(executor, /function getManifestProgress/);
|
||||
assert.match(executor, /getPathValue\(raw,\s*'progress'\)/);
|
||||
assert.match(executor, /remainingSeconds/);
|
||||
assert.match(executor, /上游任务创建中/);
|
||||
assert.match(executor, /上游任务已创建,等待生成结果/);
|
||||
assert.match(executor, /await input\.onProgress\?\.\(getManifestProgress\(raw,\s*status\)\)/);
|
||||
});
|
||||
|
||||
await runTest('Agnes video manifest splits per-request timeout from total polling budget', () => {
|
||||
const executor = read('src/lib/user-api-manifest-executor.ts');
|
||||
|
||||
assert.match(executor, /function getManifestRequestTimeoutMs/);
|
||||
assert.match(executor, /USER_API_MANIFEST_SUBMIT_TIMEOUT_MS/);
|
||||
assert.match(executor, /USER_API_MANIFEST_POLL_REQUEST_TIMEOUT_MS/);
|
||||
assert.match(executor, /AGNES_VIDEO_MANIFEST_SUBMIT_TIMEOUT_MS/);
|
||||
assert.match(executor, /function isAgnesVideoManifestRequest/);
|
||||
assert.match(executor, /getManifestRequestTimeoutMs\(input\.timeoutMs,\s*method,\s*input\)/);
|
||||
assert.match(executor, /while \(Date\.now\(\) < deadline\)/);
|
||||
assert.match(executor, /isTransientPollError/);
|
||||
});
|
||||
|
||||
await runTest('Agnes installer source creates free inactive rows with empty API key and per-row Manifest files', () => {
|
||||
const installer = read('src/lib/agnes-template-installer.ts');
|
||||
|
||||
assert.match(installer, /encryptApiKeyForStorage\(''\)/);
|
||||
assert.match(installer, /credits_per_use/);
|
||||
assert.match(installer, /billingMode:\s*'free'/);
|
||||
assert.match(installer, /is_active,\s*sort_order/);
|
||||
assert.match(installer, /false,\s*input\.sortOffset/s);
|
||||
assert.match(installer, /attachManifest\(client,\s*row,\s*bundle,\s*saveManifestFile\)/);
|
||||
assert.match(installer, /syncImageModels/);
|
||||
assert.match(installer, /syncVideoModels/);
|
||||
assert.match(installer, /syncTextModels/);
|
||||
assert.match(installer, /`\$\{AGNES_BASE_URL\}\/v1\/images\/generations`/);
|
||||
assert.match(installer, /`\$\{AGNES_BASE_URL\}\/v1\/chat\/completions`/);
|
||||
assert.match(installer, /const apiUrl = resolveImportedProfileApiUrl\(bundle,\s*profile\) \|\| AGNES_BASE_URL/);
|
||||
assert.match(installer, /saveSystemApiManifestFile/);
|
||||
assert.match(installer, /Agnes 免费模型/);
|
||||
});
|
||||
|
||||
await runTest('Agnes system model capabilities use built-in fallback so stale manifests do not expose unstable 18s', () => {
|
||||
const serverConfig = read('src/lib/server-api-config.ts');
|
||||
|
||||
assert.match(serverConfig, /getAgnesModelCapabilities/);
|
||||
assert.match(serverConfig, /getAgnesSystemApiCapabilitiesFallback/);
|
||||
assert.match(serverConfig, /getAgnesSystemApiCapabilitiesFallback\(row\)\s*\|\|\s*readManifestCapabilities/);
|
||||
});
|
||||
|
||||
await runTest('admin UI and docs expose Agnes as system-default built-in templates, not smart import', () => {
|
||||
const adminTab = read('src/components/admin/api-management-tab.tsx');
|
||||
const apiReference = read('docs/codex-miaojing/api-reference.md');
|
||||
const customIntegrations = read('docs/codex-miaojing/custom-integrations.md');
|
||||
const featureIndex = read('docs/codex-miaojing/feature-code-index.md');
|
||||
|
||||
assert.match(adminTab, /agnes-capabilities/);
|
||||
assert.match(adminTab, /安装 Agnes 免费模型/);
|
||||
assert.match(adminTab, /免费模型/);
|
||||
assert.match(apiReference, /\/api\/admin\/system-apis\/agnes-capabilities/);
|
||||
assert.match(customIntegrations, /Agnes AI/);
|
||||
assert.match(featureIndex, /agnes-model-templates/);
|
||||
});
|
||||
|
||||
await runTest('Agnes capabilities text summarizes documented modules', () => {
|
||||
const text = buildAgnesCapabilitiesText();
|
||||
assert.match(text, /Agnes Image 2\.1 Flash/);
|
||||
assert.match(text, /Agnes Image 2\.0 Flash/);
|
||||
assert.match(text, /Agnes Video V2\.0/);
|
||||
assert.match(text, /Agnes 2\.0 Flash/);
|
||||
assert.match(text, /https:\/\/apihub\.agnes-ai\.com/);
|
||||
});
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
84
scripts/test-creation-thumbnail-policy.mjs
Normal file
84
scripts/test-creation-thumbnail-policy.mjs
Normal file
@@ -0,0 +1,84 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, '..');
|
||||
|
||||
const {
|
||||
buildCreationReuseDraft,
|
||||
} = await import('../src/lib/creation-reuse.ts');
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
async function runTest(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`PASS ${name}`);
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${name}`);
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
await runTest('creation detail renders thumbnail while fullscreen and actions keep original image', () => {
|
||||
const source = read('src/components/creation-detail-dialog.tsx');
|
||||
|
||||
assert.match(source, /src=\{record\.thumbnailUrl \|\| record\.url\}/);
|
||||
assert.match(source, /openFullscreenPreview\(record\.url,\s*record\.thumbnailUrl\)/);
|
||||
assert.match(source, /openImageMenu\(event,\s*record\.url\)/);
|
||||
assert.match(source, /downloadFile\(url,\s*filename\)/);
|
||||
});
|
||||
|
||||
await runTest('creation detail metadata badge does not load original image for dimensions', () => {
|
||||
const source = read('src/components/creation-detail-dialog.tsx');
|
||||
|
||||
assert.match(source, /<ImageMetadataBadge[\s\S]*?src=\{record\.url\}[\s\S]*?width=\{record\.width\}[\s\S]*?height=\{record\.height\}[\s\S]*?loadMetadata=\{false\}/);
|
||||
});
|
||||
|
||||
await runTest('creation history API preserves stored image dimensions for detail metadata', () => {
|
||||
const source = read('src/app/api/creation-history/route.ts');
|
||||
|
||||
assert.match(source, /width:\s*row\.width/);
|
||||
assert.match(source, /height:\s*row\.height/);
|
||||
assert.match(source, /SELECT[\s\S]*\bwidth,\s*height[\s\S]*FROM works/);
|
||||
assert.match(source, /INSERT INTO works[\s\S]*width,\s*height/);
|
||||
});
|
||||
|
||||
await runTest('image generation response exposes persisted dimensions for history records', () => {
|
||||
const routeSource = read('src/app/api/generate/image/route.ts');
|
||||
const textSource = read('src/components/create/text-to-image.tsx');
|
||||
const imageSource = read('src/components/create/image-to-image.tsx');
|
||||
|
||||
assert.match(routeSource, /dimensions:\s*Object\.fromEntries\(images\.map\(image => \[image\.url,\s*\{\s*width:\s*image\.width,\s*height:\s*image\.height\s*\}\]\)\)/);
|
||||
assert.match(textSource, /dimensions\?:\s*Record<string,\s*\{\s*width:\s*number;\s*height:\s*number\s*\}>/);
|
||||
assert.match(textSource, /width:\s*data\.dimensions\?\.\[url\]\?\.width/);
|
||||
assert.match(textSource, /height:\s*data\.dimensions\?\.\[url\]\?\.height/);
|
||||
assert.match(imageSource, /dimensions\?:\s*Record<string,\s*\{\s*width:\s*number;\s*height:\s*number\s*\}>/);
|
||||
assert.match(imageSource, /width:\s*data\.dimensions\?\.\[url\]\?\.width/);
|
||||
assert.match(imageSource, /height:\s*data\.dimensions\?\.\[url\]\?\.height/);
|
||||
});
|
||||
|
||||
await runTest('reuse drafts use original output as generated-reference fallback, never thumbnail', () => {
|
||||
const record = {
|
||||
id: 'work-1',
|
||||
url: '/api/local-storage/generated/images/original.webp',
|
||||
thumbnailUrl: '/api/local-storage/thumbnails/works/thumb.webp',
|
||||
prompt: 'prompt',
|
||||
negativePrompt: '',
|
||||
model: 'model',
|
||||
params: {},
|
||||
};
|
||||
|
||||
const imageDraft = buildCreationReuseDraft(record, 'img2img', { source: 'creation-detail', useOutputAsReference: true });
|
||||
const videoDraft = buildCreationReuseDraft(record, 'img2video', { source: 'gallery', useOutputAsReference: true });
|
||||
|
||||
assert.deepEqual(imageDraft.referenceImages, [record.url]);
|
||||
assert.equal(imageDraft.referenceImage, record.url);
|
||||
assert.deepEqual(videoDraft.referenceImages, [record.url]);
|
||||
assert.equal(videoDraft.referenceImage, record.url);
|
||||
});
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
95
scripts/test-custom-image-fallback.mjs
Normal file
95
scripts/test-custom-image-fallback.mjs
Normal file
@@ -0,0 +1,95 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
const {
|
||||
parseCustomApiError,
|
||||
} = await import('../src/lib/custom-api-fetch.ts');
|
||||
const {
|
||||
buildSynchronousImageRequestBody,
|
||||
getSystemPollingFailureMessage,
|
||||
shouldRetryImageRequestWithoutStream,
|
||||
STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX,
|
||||
} = await import('../src/lib/custom-image-fallback.ts');
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, '..');
|
||||
|
||||
async function runTest(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`PASS ${name}`);
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${name}`);
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
await runTest('detects stream timeout confirmation errors for synchronous fallback', () => {
|
||||
assert.equal(
|
||||
shouldRetryImageRequestWithoutStream(
|
||||
{ model: 'gpt-image-2', prompt: 'test', stream: true },
|
||||
`${STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX}上游流式生图没有持续返回数据`,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRetryImageRequestWithoutStream(
|
||||
{ model: 'gpt-image-2', prompt: 'test', stream: false },
|
||||
`${STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX}上游流式生图没有持续返回数据`,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
await runTest('builds a synchronous retry body without mutating the original request', () => {
|
||||
const original = { model: 'gpt-image-2', prompt: 'test', n: 1, stream: true };
|
||||
const next = buildSynchronousImageRequestBody(original);
|
||||
|
||||
assert.deepEqual(next, { model: 'gpt-image-2', prompt: 'test', n: 1, stream: false });
|
||||
assert.equal(original.stream, true);
|
||||
});
|
||||
|
||||
await runTest('system polling exposes actionable upstream errors instead of generic busy message', () => {
|
||||
assert.equal(
|
||||
getSystemPollingFailureMessage('上游 API 同步生图请求超时(Cloudflare 524)。请降低分辨率后重试。'),
|
||||
'上游 API 同步生图请求超时(Cloudflare 524)。请降低分辨率后重试。',
|
||||
);
|
||||
assert.equal(
|
||||
getSystemPollingFailureMessage(`${STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX}上游流式生图没有持续返回数据`),
|
||||
'上游流式生图没有持续返回数据',
|
||||
);
|
||||
assert.equal(
|
||||
getSystemPollingFailureMessage(''),
|
||||
'因使用人数较多,模型繁忙,请稍后再试',
|
||||
);
|
||||
});
|
||||
|
||||
await runTest('Cloudflare gateway errors are shown as concise retryable upstream messages', () => {
|
||||
const message = parseCustomApiError(502, '<!DOCTYPE html><title>mozhevip.top | 502: Bad gateway</title>');
|
||||
|
||||
assert.equal(message.includes('<!DOCTYPE html>'), false);
|
||||
assert.match(message, /上游网关/);
|
||||
assert.match(message, /502/);
|
||||
});
|
||||
|
||||
await runTest('text-to-image custom fetch enables one retry for 502 503 504 gateway failures', () => {
|
||||
const source = read('src/app/api/generate/image/route.ts');
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/fetchWithRetry\(\s*endpoint,\s*\{ method: 'POST', headers: buildCustomApiHeaders\(apiKey\), body: JSON\.stringify\(requestBody\) \},\s*GENERATION_TIMEOUT,\s*1,\s*\)/s,
|
||||
);
|
||||
});
|
||||
|
||||
await runTest('multimodal 524 errors do not reuse image-generation timeout wording', () => {
|
||||
const message = parseCustomApiError(524, '<!DOCTYPE html><title>mozhevip.top | 524: A timeout occurred</title>', 'multimodal');
|
||||
|
||||
assert.match(message, /多模态模型同步请求超时/);
|
||||
assert.equal(message.includes('生图请求超时'), false);
|
||||
});
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
30
scripts/test-custom-img2img-reference-url.mjs
Normal file
30
scripts/test-custom-img2img-reference-url.mjs
Normal file
@@ -0,0 +1,30 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const routeSource = fs.readFileSync('src/app/api/generate/image/route.ts', 'utf8');
|
||||
|
||||
assert.match(
|
||||
routeSource,
|
||||
/generateObjectReadUrl\(fileKey,\s*3600\)/,
|
||||
'img2img uploaded reference images should expose object signed URLs to upstream providers',
|
||||
);
|
||||
|
||||
assert.match(
|
||||
routeSource,
|
||||
/toAbsolutePublicUrl\(publicUrl\)/,
|
||||
'img2img fallback public URLs should be absolute when object signed URLs are unavailable',
|
||||
);
|
||||
|
||||
assert.match(
|
||||
routeSource,
|
||||
/localStorage\.getKeyFromPublicUrl\(image\)/,
|
||||
'img2img should detect stored /api/local-storage reference images before fetching over HTTP',
|
||||
);
|
||||
|
||||
assert.match(
|
||||
routeSource,
|
||||
/localStorage\.readFileAsync\(storedReferenceKey\)/,
|
||||
'img2img should read stored reference images through the storage adapter for FormData uploads',
|
||||
);
|
||||
|
||||
console.log('custom img2img reference URL policy ok');
|
||||
103
scripts/test-gallery-publish-fast-path.mjs
Normal file
103
scripts/test-gallery-publish-fast-path.mjs
Normal file
@@ -0,0 +1,103 @@
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const galleryPublishMediaModule = await import('../src/lib/gallery-publish-media.ts');
|
||||
const { resolveGalleryPublishMedia } = galleryPublishMediaModule.default || galleryPublishMediaModule;
|
||||
|
||||
function createDeps() {
|
||||
const calls = {
|
||||
copy: [],
|
||||
imageThumbnail: [],
|
||||
videoThumbnail: [],
|
||||
};
|
||||
return {
|
||||
calls,
|
||||
deps: {
|
||||
copyPublicUrlToFolder: async (url, folder, options) => {
|
||||
calls.copy.push({ url, folder, options });
|
||||
return `/api/local-storage/${folder}/copied.png`;
|
||||
},
|
||||
ensureLocalImageThumbnail: async (url, prefix) => {
|
||||
calls.imageThumbnail.push({ url, prefix });
|
||||
return `/api/local-storage/${prefix}/generated-m1280q86.webp`;
|
||||
},
|
||||
ensureLocalVideoThumbnail: async (url, prefix) => {
|
||||
calls.videoThumbnail.push({ url, prefix });
|
||||
return `/api/local-storage/${prefix}/frame-video-frame-m1280q86-v1.webp`;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runTest(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`PASS ${name}`);
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${name}`);
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
await runTest('new object-backed image publishes reuse the existing local-storage URL and current thumbnail', async () => {
|
||||
const { calls, deps } = createDeps();
|
||||
const result = await resolveGalleryPublishMedia({
|
||||
type: 'image',
|
||||
resultUrl: '/api/local-storage/generated/images/source.png',
|
||||
thumbnailUrl: '/api/local-storage/thumbnails/generated/images/source-m1280q86.webp',
|
||||
prompt: 'image prompt',
|
||||
}, deps);
|
||||
|
||||
assert.equal(result.resultUrl, '/api/local-storage/generated/images/source.png');
|
||||
assert.equal(result.thumbnailUrl, '/api/local-storage/thumbnails/generated/images/source-m1280q86.webp');
|
||||
assert.deepEqual(calls.copy, []);
|
||||
assert.deepEqual(calls.imageThumbnail, []);
|
||||
});
|
||||
|
||||
await runTest('external image publishes still copy into gallery storage before thumbnailing', async () => {
|
||||
const { calls, deps } = createDeps();
|
||||
const result = await resolveGalleryPublishMedia({
|
||||
type: 'image',
|
||||
resultUrl: 'https://example.com/source.png',
|
||||
thumbnailUrl: null,
|
||||
prompt: 'image prompt',
|
||||
}, deps);
|
||||
|
||||
assert.equal(result.resultUrl, '/api/local-storage/gallery/images/copied.png');
|
||||
assert.equal(result.thumbnailUrl, '/api/local-storage/thumbnails/gallery/generated-m1280q86.webp');
|
||||
assert.deepEqual(calls.copy, [
|
||||
{
|
||||
url: 'https://example.com/source.png',
|
||||
folder: 'gallery/images',
|
||||
options: { storageTarget: 'object' },
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(calls.imageThumbnail, [
|
||||
{
|
||||
url: '/api/local-storage/gallery/images/copied.png',
|
||||
prefix: 'thumbnails/gallery',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
await runTest('object-backed video publishes keep reusing the existing local-storage URL', async () => {
|
||||
const { calls, deps } = createDeps();
|
||||
const result = await resolveGalleryPublishMedia({
|
||||
type: 'video',
|
||||
resultUrl: '/api/local-storage/generated/videos/source.mp4',
|
||||
thumbnailUrl: null,
|
||||
prompt: 'video prompt',
|
||||
}, deps);
|
||||
|
||||
assert.equal(result.resultUrl, '/api/local-storage/generated/videos/source.mp4');
|
||||
assert.equal(result.thumbnailUrl, '/api/local-storage/thumbnails/gallery/videos/frame-video-frame-m1280q86-v1.webp');
|
||||
assert.deepEqual(calls.copy, []);
|
||||
assert.deepEqual(calls.videoThumbnail, [
|
||||
{
|
||||
url: '/api/local-storage/generated/videos/source.mp4',
|
||||
prefix: 'thumbnails/gallery/videos',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
101
scripts/test-gallery-response.mjs
Normal file
101
scripts/test-gallery-response.mjs
Normal file
@@ -0,0 +1,101 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
getPublicGalleryAvatarUrl,
|
||||
toPublicGalleryWork,
|
||||
} from '../src/lib/gallery-response.ts';
|
||||
import {
|
||||
GALLERY_CACHE_MAX_AGE_MS,
|
||||
GALLERY_CACHE_TTL_MS,
|
||||
isGalleryCacheEntryFresh,
|
||||
isGalleryCacheEntryUsable,
|
||||
} from '../src/lib/gallery-cache-policy.ts';
|
||||
|
||||
function createGalleryRow(overrides = {}) {
|
||||
return {
|
||||
id: '11111111-1111-1111-1111-111111111111',
|
||||
type: 'text2img',
|
||||
title: 'public work',
|
||||
prompt: 'prompt',
|
||||
negative_prompt: null,
|
||||
result_url: '/api/local-storage/gallery/image.webp',
|
||||
thumbnail_url: '/api/local-storage/thumbnails/gallery/image.webp',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
duration: null,
|
||||
likes_count: 7,
|
||||
credits_cost: 2,
|
||||
params: {
|
||||
creationMode: 'text2img',
|
||||
referenceImages: ['/api/local-storage/reference.webp', ''],
|
||||
referenceImageThumbnails: ['/api/local-storage/thumbnails/reference.webp', ''],
|
||||
},
|
||||
user_id: '22222222-2222-2222-2222-222222222222',
|
||||
nickname: 'login-name',
|
||||
display_nickname: '公开昵称',
|
||||
email: 'user@example.com',
|
||||
avatar_url: null,
|
||||
created_at: '2026-05-20T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function runTest(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`PASS ${name}`);
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${name}`);
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
await runTest('filters data URL avatars from public gallery rows', () => {
|
||||
const dataAvatar = `data:image/svg+xml;base64,${'a'.repeat(40000)}`;
|
||||
const work = toPublicGalleryWork(createGalleryRow({ avatar_url: dataAvatar }));
|
||||
|
||||
assert.equal(work.publisherAvatarUrl, null);
|
||||
assert.equal(JSON.stringify(work).includes(dataAvatar), false);
|
||||
});
|
||||
|
||||
await runTest('keeps short URL avatars for public gallery rows', () => {
|
||||
assert.equal(
|
||||
getPublicGalleryAvatarUrl('/api/local-storage/avatars/user.webp'),
|
||||
'/api/local-storage/avatars/user.webp',
|
||||
);
|
||||
assert.equal(
|
||||
getPublicGalleryAvatarUrl('https://example.com/avatar.webp'),
|
||||
'https://example.com/avatar.webp',
|
||||
);
|
||||
});
|
||||
|
||||
await runTest('uses display nickname before login nickname', () => {
|
||||
const work = toPublicGalleryWork(createGalleryRow());
|
||||
|
||||
assert.equal(work.publisherNickname, '公开昵称');
|
||||
});
|
||||
|
||||
await runTest('maps reference images without blank entries', () => {
|
||||
const work = toPublicGalleryWork(createGalleryRow());
|
||||
|
||||
assert.deepEqual(work.referenceImages, ['/api/local-storage/reference.webp']);
|
||||
assert.equal(work.referenceImage, '/api/local-storage/reference.webp');
|
||||
assert.deepEqual(work.referenceImageThumbnails, ['/api/local-storage/thumbnails/reference.webp']);
|
||||
});
|
||||
|
||||
await runTest('allows stale gallery cache rows for instant first paint', () => {
|
||||
const now = Date.UTC(2026, 4, 20, 12, 0, 0);
|
||||
const staleButUsable = now - GALLERY_CACHE_TTL_MS - 1;
|
||||
|
||||
assert.equal(isGalleryCacheEntryFresh(staleButUsable, now), false);
|
||||
assert.equal(isGalleryCacheEntryUsable(staleButUsable, now), true);
|
||||
});
|
||||
|
||||
await runTest('rejects gallery cache rows older than max age', () => {
|
||||
const now = Date.UTC(2026, 4, 20, 12, 0, 0);
|
||||
const expired = now - GALLERY_CACHE_MAX_AGE_MS - 1;
|
||||
|
||||
assert.equal(isGalleryCacheEntryUsable(expired, now), false);
|
||||
});
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
227
scripts/test-generation-credit-policy.mjs
Normal file
227
scripts/test-generation-credit-policy.mjs
Normal file
@@ -0,0 +1,227 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
const {
|
||||
chargeGenerationCredits,
|
||||
ensureGenerationCreditsAvailable,
|
||||
resolveGenerationCreditCost,
|
||||
} = await import('../src/lib/generation-credit-service.ts');
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, '..');
|
||||
const SYSTEM_API_ID = '11111111-1111-1111-1111-111111111111';
|
||||
const USER_ID = '22222222-2222-2222-2222-222222222222';
|
||||
|
||||
async function runTest(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`PASS ${name}`);
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${name}`);
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
function createFakeClient({ apiRow, creditsBalance = 100, pendingJobs = [] } = {}) {
|
||||
const calls = [];
|
||||
const client = {
|
||||
calls,
|
||||
async query(sql, params = []) {
|
||||
const text = String(sql);
|
||||
calls.push({ sql: text, params });
|
||||
if (text.includes('FROM generation_jobs')) {
|
||||
return { rows: pendingJobs };
|
||||
}
|
||||
if (text.includes('FROM system_api_configs')) {
|
||||
return { rows: apiRow ? [apiRow] : [] };
|
||||
}
|
||||
if (text.includes('SELECT credits_balance FROM profiles') && text.includes('FOR UPDATE')) {
|
||||
return { rows: [{ credits_balance: creditsBalance }] };
|
||||
}
|
||||
if (text.includes('SELECT credits_balance FROM profiles')) {
|
||||
return { rows: [{ credits_balance: creditsBalance }] };
|
||||
}
|
||||
if (text.includes('UPDATE profiles SET credits_balance')) {
|
||||
return { rows: [], rowCount: 1 };
|
||||
}
|
||||
if (text.includes('INSERT INTO credit_transactions')) {
|
||||
return { rows: [], rowCount: 1 };
|
||||
}
|
||||
return { rows: [], rowCount: 0 };
|
||||
},
|
||||
};
|
||||
return client;
|
||||
}
|
||||
|
||||
await runTest('calculates fixed system image credits from backend system_api_configs pricing', async () => {
|
||||
const client = createFakeClient({
|
||||
apiRow: {
|
||||
id: SYSTEM_API_ID,
|
||||
provider: 'mozheAPI',
|
||||
name: 'gpt-image-2(主)',
|
||||
model_name: 'gpt-image-2',
|
||||
type: 'image',
|
||||
credits_per_use: 3,
|
||||
billing_mode: 'fixed',
|
||||
fixed_price: '3.0000',
|
||||
},
|
||||
});
|
||||
|
||||
const cost = await resolveGenerationCreditCost(client, {
|
||||
type: 'image',
|
||||
payload: { customApiConfig: { systemApiId: SYSTEM_API_ID } },
|
||||
result: { images: ['a', 'b'] },
|
||||
});
|
||||
|
||||
assert.equal(cost?.creditsCost, 6);
|
||||
assert.equal(cost?.description, '图片生成 - gpt-image-2(主)(mozheAPI)');
|
||||
});
|
||||
|
||||
await runTest('calculates duration video credits from backend system_api_configs pricing', async () => {
|
||||
const client = createFakeClient({
|
||||
apiRow: {
|
||||
id: SYSTEM_API_ID,
|
||||
provider: '元界AI',
|
||||
name: '视频模型',
|
||||
model_name: 'video-model',
|
||||
type: 'video',
|
||||
credits_per_use: 0,
|
||||
billing_mode: 'duration',
|
||||
fixed_price: '0',
|
||||
duration_price_per_second: '2.5',
|
||||
},
|
||||
});
|
||||
|
||||
const cost = await resolveGenerationCreditCost(client, {
|
||||
type: 'video',
|
||||
payload: { duration: '6', customApiConfig: { systemApiId: SYSTEM_API_ID } },
|
||||
result: { videos: ['v'] },
|
||||
});
|
||||
|
||||
assert.equal(cost?.creditsCost, 15);
|
||||
assert.equal(cost?.description, '视频生成 - 视频模型(元界AI)');
|
||||
});
|
||||
|
||||
await runTest('does not charge user custom or platform SDK generation without systemApiId', async () => {
|
||||
const client = createFakeClient();
|
||||
|
||||
const charge = await chargeGenerationCredits(client, {
|
||||
userId: USER_ID,
|
||||
type: 'image',
|
||||
payload: { customApiConfig: { customApiKeyId: '33333333-3333-3333-3333-333333333333' } },
|
||||
result: { images: ['a'] },
|
||||
});
|
||||
|
||||
assert.equal(charge, null);
|
||||
assert.equal(client.calls.some(call => call.sql.includes('UPDATE profiles SET credits_balance')), false);
|
||||
assert.equal(client.calls.some(call => call.sql.includes('INSERT INTO credit_transactions')), false);
|
||||
});
|
||||
|
||||
await runTest('blocks queued system generation before running when credits are insufficient', async () => {
|
||||
const client = createFakeClient({
|
||||
creditsBalance: 2,
|
||||
apiRow: {
|
||||
id: SYSTEM_API_ID,
|
||||
provider: 'mozheAPI',
|
||||
name: 'gpt-image-2(主)',
|
||||
model_name: 'gpt-image-2',
|
||||
type: 'image',
|
||||
credits_per_use: 3,
|
||||
billing_mode: 'fixed',
|
||||
fixed_price: '3.0000',
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => ensureGenerationCreditsAvailable(client, USER_ID, {
|
||||
type: 'image',
|
||||
payload: { count: 1, customApiConfig: { systemApiId: SYSTEM_API_ID } },
|
||||
}),
|
||||
/积分不足/,
|
||||
);
|
||||
});
|
||||
|
||||
await runTest('counts queued and running system generation cost before accepting a new job', async () => {
|
||||
const apiRow = {
|
||||
id: SYSTEM_API_ID,
|
||||
provider: 'mozheAPI',
|
||||
name: 'gpt-image-2(主)',
|
||||
model_name: 'gpt-image-2',
|
||||
type: 'image',
|
||||
credits_per_use: 3,
|
||||
billing_mode: 'fixed',
|
||||
fixed_price: '3.0000',
|
||||
};
|
||||
const client = createFakeClient({
|
||||
creditsBalance: 5,
|
||||
apiRow,
|
||||
pendingJobs: [
|
||||
{
|
||||
type: 'image',
|
||||
payload: {
|
||||
prompt: 'pending image',
|
||||
count: 1,
|
||||
customApiConfig: { systemApiId: SYSTEM_API_ID },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => ensureGenerationCreditsAvailable(client, USER_ID, {
|
||||
type: 'image',
|
||||
payload: {
|
||||
prompt: 'new image',
|
||||
count: 1,
|
||||
customApiConfig: { systemApiId: SYSTEM_API_ID },
|
||||
},
|
||||
}),
|
||||
/积分不足/,
|
||||
);
|
||||
});
|
||||
|
||||
await runTest('job creation keeps credit preflight and insertion in one database transaction', () => {
|
||||
const source = read('src/app/api/generation-jobs/route.ts');
|
||||
const begin = source.indexOf("await client.query('BEGIN')");
|
||||
const preflight = source.indexOf('await ensureGenerationCreditsAvailable');
|
||||
const insert = source.indexOf('INSERT INTO generation_jobs');
|
||||
const commit = source.lastIndexOf("await client.query('COMMIT')");
|
||||
const rollback = source.indexOf("await client.query('ROLLBACK')");
|
||||
|
||||
assert.ok(begin > -1, 'job creation should start a transaction');
|
||||
assert.ok(preflight > begin, 'credit preflight should run inside the transaction');
|
||||
assert.ok(insert > preflight, 'job insertion should happen after credit preflight');
|
||||
assert.ok(commit > insert, 'job creation should commit after insertion');
|
||||
assert.ok(rollback > -1, 'job creation should rollback failed transactions');
|
||||
});
|
||||
|
||||
await runTest('worker charges credits only after upstream generation returns a successful result', () => {
|
||||
const source = read('src/lib/generation-job-worker.ts');
|
||||
const successPath = source.indexOf('const result = await runGenerationPayload');
|
||||
const chargePath = source.indexOf('const creditCharge = await settleJobCredits');
|
||||
const failurePath = source.indexOf("status: 'failed'");
|
||||
|
||||
assert.ok(successPath > -1, 'worker should call upstream generation');
|
||||
assert.ok(chargePath > successPath, 'credit charge must happen after successful upstream result');
|
||||
assert.ok(failurePath > chargePath, 'failure handler must be outside the success charge path');
|
||||
});
|
||||
|
||||
await runTest('video panels use backend returned creditsCost and creditsBalance instead of local predicted deduction', () => {
|
||||
for (const relativePath of [
|
||||
'src/components/create/text-to-video.tsx',
|
||||
'src/components/create/image-to-video.tsx',
|
||||
]) {
|
||||
const source = read(relativePath);
|
||||
assert.match(source, /creditsCost\?: number; creditsBalance\?: number/, relativePath);
|
||||
assert.match(source, /const creditsCost = Math\.max\(0, Number\(data\.creditsCost \|\| 0\)\)/, relativePath);
|
||||
assert.match(source, /updateProfile\(\{ creditsBalance: data\.creditsBalance \}\)/, relativePath);
|
||||
assert.doesNotMatch(source, /addCreditRecord\(/, relativePath);
|
||||
assert.doesNotMatch(source, /balanceAfter: Math\.max\(0, currentCredits - credits\)/, relativePath);
|
||||
}
|
||||
});
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
196
scripts/test-generation-job-persistence.mjs
Normal file
196
scripts/test-generation-job-persistence.mjs
Normal file
@@ -0,0 +1,196 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, '..');
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
async function runTest(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`PASS ${name}`);
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${name}`);
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
await runTest('generation job runner can dispatch reverse-prompt payloads to the reverse prompt route', () => {
|
||||
const source = read('src/lib/generation-job-runner.ts');
|
||||
assert.match(source, /type GenerationJobType = 'image' \| 'video' \| 'reverse-prompt';/);
|
||||
assert.match(source, /const endpoint = type === 'image' \? '\/api\/generate\/image' : type === 'video' \? '\/api\/generate\/video' : '\/api\/generate\/reverse-prompt';/);
|
||||
});
|
||||
|
||||
await runTest('generation job runner uses long-lived internal HTTP requests for slow video jobs', () => {
|
||||
const source = read('src/lib/generation-job-runner.ts');
|
||||
assert.match(source, /requestInternalGenerationJson/);
|
||||
assert.match(source, /GENERATION_INTERNAL_REQUEST_TIMEOUT_MS/);
|
||||
assert.match(source, /25 \* 60_000/);
|
||||
assert.match(source, /req\.setTimeout\(timeoutMs/);
|
||||
assert.doesNotMatch(source, /await fetch\(`\$\{baseUrl\}\$\{endpoint\}`/);
|
||||
});
|
||||
|
||||
await runTest('generation jobs route can list active jobs and accept reverse-prompt submissions', () => {
|
||||
const source = read('src/app/api/generation-jobs/route.ts');
|
||||
assert.match(source, /export async function GET\(request: NextRequest\)/);
|
||||
assert.match(source, /status IN \('queued', 'running'\)/);
|
||||
assert.match(source, /type !== 'image' && type !== 'video' && type !== 'reverse-prompt'/);
|
||||
});
|
||||
|
||||
await runTest('creation history post accepts trusted internal generation requests', () => {
|
||||
const source = read('src/app/api/creation-history/route.ts');
|
||||
assert.match(source, /isTrustedInternalGenerationRequest/);
|
||||
assert.match(source, /x-miaojing-generation-user-id/);
|
||||
assert.match(source, /if \(!userId\) return NextResponse\.json\(\{ error: '请先登录' \}, \{ status: 401 \}\);/);
|
||||
});
|
||||
|
||||
await runTest('generation worker persists completed jobs back into creation history', () => {
|
||||
const source = read('src/lib/generation-job-worker.ts');
|
||||
assert.match(source, /\/api\/creation-history/);
|
||||
assert.match(source, /persistGenerationHistoryRecord|saveGenerationHistoryRecord|creation history/i);
|
||||
assert.match(source, /status: 'succeeded'/);
|
||||
});
|
||||
|
||||
await runTest('image generation caps persisted images to the requested count', () => {
|
||||
const source = read('src/app/api/generate/image/route.ts');
|
||||
assert.match(source, /function capPersistedImagesToRequestedCount/);
|
||||
assert.match(source, /imageResponsePayload\([^,\n]+,\s*n\)/);
|
||||
assert.match(source, /persistQualifiedImageUrls\([^)]*requestedCount/s);
|
||||
});
|
||||
|
||||
await runTest('creation history serializes same-user same-url inserts to prevent duplicate rows', () => {
|
||||
const source = read('src/app/api/creation-history/route.ts');
|
||||
assert.match(source, /pg_advisory_xact_lock/);
|
||||
assert.match(source, /historyRecordDedupeLockKey/);
|
||||
assert.match(source, /WHERE user_id = \$1 AND result_url = \$2/);
|
||||
});
|
||||
|
||||
await runTest('create panels restore active jobs from the server after reload or auth change', () => {
|
||||
for (const relativePath of [
|
||||
'src/components/create/text-to-image.tsx',
|
||||
'src/components/create/image-to-image.tsx',
|
||||
'src/components/create/text-to-video.tsx',
|
||||
'src/components/create/image-to-video.tsx',
|
||||
'src/components/create/reverse-prompt-panel.tsx',
|
||||
]) {
|
||||
const source = read(relativePath);
|
||||
assert.match(source, /useGenerationJobRecovery|fetchActiveGenerationJobs|\/api\/generation-jobs\?status=queued%2Crunning|\/api\/generation-jobs\?status=queued,running/);
|
||||
}
|
||||
});
|
||||
|
||||
await runTest('recovered job polling is not cancelled by active task state updates', () => {
|
||||
const source = read('src/components/create/use-generation-job-recovery.ts');
|
||||
assert.match(source, /knownJobIdsRef/);
|
||||
const effectMatches = [...source.matchAll(/useEffect\(\(\) => \{[\s\S]*?void recover\(\);[\s\S]*?\}, \[([^\]]*)\]\);/g)];
|
||||
assert.ok(effectMatches.length > 0, 'expected to find the recovery polling effect');
|
||||
const dependencies = effectMatches.at(-1)?.[1] || '';
|
||||
assert.doesNotMatch(dependencies, /\btypes\b/);
|
||||
assert.doesNotMatch(dependencies, /\bnormalizedKnownJobIds\b/);
|
||||
});
|
||||
|
||||
await runTest('active generation job recovery avoids anonymous polling and dedupes short-lived list requests', () => {
|
||||
const source = read('src/lib/generation-job-client.ts');
|
||||
assert.match(source, /const ACTIVE_JOBS_REQUEST_TTL_MS = \d+;/);
|
||||
assert.match(source, /activeJobsRequestCache/);
|
||||
assert.match(source, /if \(!authToken\) return \[\];/);
|
||||
assert.match(source, /getActiveJobsRequestKey\(normalizedTypes, authToken\)/);
|
||||
});
|
||||
|
||||
await runTest('generation jobs stay recoverable after the browser closes before the result is consumed', () => {
|
||||
const clientSource = read('src/lib/generation-job-client.ts');
|
||||
const recoverySource = read('src/components/create/use-generation-job-recovery.ts');
|
||||
|
||||
assert.match(clientSource, /PENDING_GENERATION_JOBS_STORAGE_PREFIX/);
|
||||
assert.match(clientSource, /rememberPendingGenerationJob/);
|
||||
assert.match(clientSource, /forgetPendingGenerationJob/);
|
||||
assert.match(clientSource, /fetchGenerationJobStatus/);
|
||||
assert.match(clientSource, /fetchRecoverableGenerationJobs/);
|
||||
assert.match(clientSource, /rememberPendingGenerationJob\(type,\s*createData\.jobId/);
|
||||
assert.match(recoverySource, /fetchRecoverableGenerationJobs/);
|
||||
assert.doesNotMatch(recoverySource, /const jobs = await fetchActiveGenerationJobs\(typesRef\.current\);/);
|
||||
});
|
||||
|
||||
await runTest('terminal recovered generation jobs clear pending browser recovery state', () => {
|
||||
const clientSource = read('src/lib/generation-job-client.ts');
|
||||
const recoverySource = read('src/components/create/use-generation-job-recovery.ts');
|
||||
|
||||
assert.match(clientSource, /statusData\.status === 'succeeded'[\s\S]*forgetPendingGenerationJob/);
|
||||
assert.match(clientSource, /statusData\.status === 'failed'[\s\S]*forgetPendingGenerationJob/);
|
||||
assert.match(clientSource, /statusData\.status === 'cancelled'[\s\S]*forgetPendingGenerationJob/);
|
||||
assert.match(clientSource, /cancelGenerationJob[\s\S]*forgetPendingGenerationJob/);
|
||||
assert.match(recoverySource, /forgetPendingGenerationJob/);
|
||||
});
|
||||
|
||||
await runTest('active job recovery dedupes locally submitted tasks by client request id', () => {
|
||||
const taskListSource = read('src/components/create/generation-task-list.tsx');
|
||||
const recoverySource = read('src/components/create/use-generation-job-recovery.ts');
|
||||
const textToImageSource = read('src/components/create/text-to-image.tsx');
|
||||
|
||||
assert.match(taskListSource, /clientRequestId\?: string;/);
|
||||
assert.match(recoverySource, /payload\?\.clientRequestId/);
|
||||
assert.match(recoverySource, /getJobIdentityIds/);
|
||||
assert.match(recoverySource, /identityIds\.some\(id => activeJobIdsRef\.current\.has\(id\) \|\| knownJobIdsRef\.current\.has\(id\)\)/);
|
||||
assert.match(textToImageSource, /clientRequestId: taskId/);
|
||||
assert.match(textToImageSource, /task\.clientRequestId/);
|
||||
});
|
||||
|
||||
await runTest('generation job API dedupes active jobs by semantic payload without client request id', () => {
|
||||
const source = read('src/app/api/generation-jobs/route.ts');
|
||||
assert.match(source, /payload - 'clientRequestId'/);
|
||||
assert.match(source, /\$3::jsonb - 'clientRequestId'/);
|
||||
assert.match(source, /deduplicated: true/);
|
||||
});
|
||||
|
||||
await runTest('create panels do not prepend duplicate completed media urls', () => {
|
||||
for (const relativePath of [
|
||||
'src/components/create/text-to-image.tsx',
|
||||
'src/components/create/image-to-image.tsx',
|
||||
'src/components/create/text-to-video.tsx',
|
||||
'src/components/create/image-to-video.tsx',
|
||||
]) {
|
||||
const source = read(relativePath);
|
||||
assert.match(source, /filter\(url => !prev\.includes\(url\)\)/, `${relativePath} should filter duplicate result URLs before prepending`);
|
||||
}
|
||||
});
|
||||
|
||||
await runTest('create panels block duplicate in-flight submissions before creating another job', () => {
|
||||
for (const relativePath of [
|
||||
'src/components/create/text-to-image.tsx',
|
||||
'src/components/create/image-to-image.tsx',
|
||||
'src/components/create/text-to-video.tsx',
|
||||
'src/components/create/image-to-video.tsx',
|
||||
]) {
|
||||
const source = read(relativePath);
|
||||
assert.match(source, /activeSubmissionSignaturesRef = useRef\(new Set<string>\(\)\)/, `${relativePath} should keep in-flight submission signatures`);
|
||||
assert.match(source, /activeSubmissionSignaturesRef\.current\.has\(submissionSignature\)/, `${relativePath} should check an in-flight duplicate signature`);
|
||||
assert.match(source, /activeSubmissionSignaturesRef\.current\.add\(submissionSignature\)/, `${relativePath} should mark the signature before creating the job`);
|
||||
assert.match(source, /activeSubmissionSignaturesRef\.current\.delete\(submissionSignature\)/, `${relativePath} should clear the signature after the job settles`);
|
||||
assert.match(source, /相同任务正在生成中,请勿重复提交/, `${relativePath} should explain duplicate submit prevention`);
|
||||
}
|
||||
});
|
||||
|
||||
await runTest('create panels do not label active generation as another submit action', () => {
|
||||
for (const relativePath of [
|
||||
'src/components/create/text-to-image.tsx',
|
||||
'src/components/create/image-to-image.tsx',
|
||||
'src/components/create/text-to-video.tsx',
|
||||
'src/components/create/image-to-video.tsx',
|
||||
'src/components/create/mobile-creation-composer.tsx',
|
||||
]) {
|
||||
const source = read(relativePath);
|
||||
assert.doesNotMatch(source, /继续提交任务/, `${relativePath} should not invite duplicate submits while a task is running`);
|
||||
}
|
||||
});
|
||||
|
||||
await runTest('generation job client builds auth headers from one parsed token per request', () => {
|
||||
const source = read('src/lib/generation-job-client.ts');
|
||||
assert.match(source, /function getAuthHeaders\(authToken = getAuthToken\(\)\)/);
|
||||
assert.match(source, /const authHeaders = getAuthHeaders\(\);/);
|
||||
assert.doesNotMatch(source, /\.\.\.\(getAuthToken\(\) \? \{ Authorization: `Bearer \$\{getAuthToken\(\)\}` \} : \{\}\)/);
|
||||
});
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
107
scripts/test-generation-task-cancel-and-gallery-references.mjs
Normal file
107
scripts/test-generation-task-cancel-and-gallery-references.mjs
Normal file
@@ -0,0 +1,107 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, '..');
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
async function runTest(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`PASS ${name}`);
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${name}`);
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const createPanels = [
|
||||
'src/components/create/text-to-image.tsx',
|
||||
'src/components/create/image-to-image.tsx',
|
||||
'src/components/create/text-to-video.tsx',
|
||||
'src/components/create/image-to-video.tsx',
|
||||
];
|
||||
|
||||
await runTest('create panels allow a new different submission while another task is active', () => {
|
||||
for (const relativePath of createPanels) {
|
||||
const source = read(relativePath);
|
||||
assert.doesNotMatch(source, /disabled=\{!hasModels \|\| generating\}/, `${relativePath} should not disable submit only because active tasks exist`);
|
||||
assert.doesNotMatch(source, /任务生成中/, `${relativePath} should keep the submit action available while tasks are running`);
|
||||
assert.match(source, /activeSubmissionSignaturesRef\.current\.has\(submissionSignature\)/, `${relativePath} should still block the same in-flight submission`);
|
||||
}
|
||||
});
|
||||
|
||||
await runTest('generation job status supports user cancellation end to end', () => {
|
||||
assert.match(read('src/lib/generation-job-client.ts'), /'cancelled'/);
|
||||
assert.match(read('src/lib/generation-job-client.ts'), /cancelGenerationJob/);
|
||||
assert.match(read('src/components/create/generation-task-list.tsx'), /onCancelTask/);
|
||||
assert.match(read('src/components/create/generation-task-list.tsx'), /取消任务/);
|
||||
|
||||
const statusRoute = read('src/app/api/generation-jobs/[id]/route.ts');
|
||||
assert.match(statusRoute, /export async function (PATCH|DELETE)/);
|
||||
assert.match(statusRoute, /status = 'cancelled'/);
|
||||
|
||||
const worker = read('src/lib/generation-job-worker.ts');
|
||||
assert.match(worker, /isJobStillRunning/);
|
||||
assert.match(worker, /cancelled/);
|
||||
assert.match(worker, /skip/i);
|
||||
});
|
||||
|
||||
await runTest('image-to-image and image-to-video share reference images to gallery', () => {
|
||||
for (const relativePath of [
|
||||
'src/components/create/image-to-image.tsx',
|
||||
'src/components/create/image-to-video.tsx',
|
||||
]) {
|
||||
const source = read(relativePath);
|
||||
assert.match(source, /referenceImage:\s*refImages\[0\]\?\.dataUrl/, `${relativePath} should share the primary reference`);
|
||||
assert.match(source, /referenceImages:\s*refImages\.map\(img => img\.dataUrl\)/, `${relativePath} should share all references`);
|
||||
}
|
||||
});
|
||||
|
||||
await runTest('gallery publish persists reference images as stable local-storage URLs', () => {
|
||||
const publishRoute = read('src/app/api/gallery/publish/route.ts');
|
||||
const mediaHelper = read('src/lib/gallery-publish-media.ts');
|
||||
assert.match(mediaHelper, /resolveGalleryReferenceImages/);
|
||||
assert.match(mediaHelper, /gallery\/references/);
|
||||
assert.match(publishRoute, /resolveGalleryReferenceImages/);
|
||||
assert.match(publishRoute, /galleryReferenceImages/);
|
||||
});
|
||||
|
||||
await runTest('gallery detail shows reference images but does not expose reference downloads', () => {
|
||||
const source = read('src/app/gallery/page.tsx');
|
||||
assert.match(source, /getWorkReferenceImages/);
|
||||
assert.match(source, /getWorkReferenceImageThumbnails/);
|
||||
assert.match(source, /ReferencePreviewImage/);
|
||||
assert.match(source, /thumbnailSrc=\{selectedReferenceImageThumbnails\[index\]\}/);
|
||||
assert.match(source, /参考图/);
|
||||
assert.match(source, /referencePreviewSrc/);
|
||||
assert.match(source, /disableContextMenu/);
|
||||
assert.match(source, /onContextMenu=\{[^}]*preventDefault/s);
|
||||
assert.doesNotMatch(source, /handleDownload\([^)]*reference/i);
|
||||
});
|
||||
|
||||
await runTest('gallery api merges reference metadata from duplicate result rows', () => {
|
||||
const route = read('src/app/api/gallery/route.ts');
|
||||
assert.match(route, /mergeGalleryRowMetadata/);
|
||||
assert.match(route, /dedupeGalleryRowsByResultUrl/);
|
||||
assert.match(route, /referenceImageThumbnails/);
|
||||
});
|
||||
|
||||
await runTest('inspiration reuse preserves original reference images when available', () => {
|
||||
const reuseSource = read('src/lib/creation-reuse.ts');
|
||||
assert.match(reuseSource, /explicitReferences/);
|
||||
assert.match(reuseSource, /useOutputAsReference/);
|
||||
|
||||
const inspirationSource = read('src/components/create/inspiration-gallery-dialog.tsx');
|
||||
assert.match(inspirationSource, /referenceImages/);
|
||||
assert.match(inspirationSource, /referencePreviewSrc/);
|
||||
assert.match(inspirationSource, /disableContextMenu/);
|
||||
assert.doesNotMatch(inspirationSource, /window\.open/);
|
||||
assert.match(inspirationSource, /buildCreationReuseDraft/);
|
||||
});
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
75
scripts/test-image-output-format-persistence.mjs
Normal file
75
scripts/test-image-output-format-persistence.mjs
Normal file
@@ -0,0 +1,75 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const { normalizeImageBufferForOutputFormat } = await import(`../src/lib/media-storage.ts?test=${Date.now()}`);
|
||||
const repoRoot = path.resolve(import.meta.dirname, '..');
|
||||
|
||||
async function runTest(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`PASS ${name}`);
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${name}`);
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
await runTest('converts upstream PNG bytes to JPEG when JPEG is requested', async () => {
|
||||
const upstreamPng = await sharp({
|
||||
create: {
|
||||
width: 12,
|
||||
height: 8,
|
||||
channels: 4,
|
||||
background: { r: 64, g: 128, b: 192, alpha: 1 },
|
||||
},
|
||||
}).png().toBuffer();
|
||||
|
||||
const converted = await normalizeImageBufferForOutputFormat({
|
||||
buffer: upstreamPng,
|
||||
mimeType: 'image/png',
|
||||
ext: 'png',
|
||||
}, 'jpeg', 'high');
|
||||
|
||||
assert.equal(converted.mimeType, 'image/jpeg');
|
||||
assert.equal(converted.ext, 'jpg');
|
||||
assert.deepEqual([...converted.buffer.subarray(0, 3)], [0xff, 0xd8, 0xff]);
|
||||
|
||||
const metadata = await sharp(converted.buffer).metadata();
|
||||
assert.equal(metadata.format, 'jpeg');
|
||||
assert.equal(metadata.width, 12);
|
||||
assert.equal(metadata.height, 8);
|
||||
});
|
||||
|
||||
await runTest('image generation persistence passes the requested output format to storage', () => {
|
||||
const source = fs.readFileSync(path.join(repoRoot, 'src/app/api/generate/image/route.ts'), 'utf8');
|
||||
|
||||
assert.match(source, /persistImageWithMetadata\(url,\s*prefix,\s*outputFormat,\s*imageQuality\)/);
|
||||
assert.match(source, /requestQualifiedCustomImages\([\s\S]*resolvedOutputFormat,\s*resolvedImageQuality,\s*handleUpstreamProgress/);
|
||||
assert.match(source, /User API Manifest Image'[\s\S]*resolvedOutputFormat,\s*resolvedImageQuality/);
|
||||
assert.match(source, /Custom API img2img strategy1'[\s\S]*outputFormat,\s*imageQuality/);
|
||||
assert.match(source, /Custom API img2img strategy2'[\s\S]*outputFormat,\s*imageQuality/);
|
||||
assert.match(source, /Custom API img2img strategy3'[\s\S]*outputFormat,\s*imageQuality/);
|
||||
assert.match(source, /SDK Image'[\s\S]*resolvedOutputFormat,\s*resolvedImageQuality/);
|
||||
});
|
||||
|
||||
await runTest('image downloads derive filename extension from URL or selected output format', () => {
|
||||
const utils = fs.readFileSync(path.join(repoRoot, 'src/lib/utils.ts'), 'utf8');
|
||||
const textToImage = fs.readFileSync(path.join(repoRoot, 'src/components/create/text-to-image.tsx'), 'utf8');
|
||||
const imageToImage = fs.readFileSync(path.join(repoRoot, 'src/components/create/image-to-image.tsx'), 'utf8');
|
||||
const detail = fs.readFileSync(path.join(repoRoot, 'src/components/creation-detail-dialog.tsx'), 'utf8');
|
||||
const lightbox = fs.readFileSync(path.join(repoRoot, 'src/components/lightbox.tsx'), 'utf8');
|
||||
|
||||
assert.match(utils, /export function getImageDownloadExtension\(/);
|
||||
assert.match(utils, /jpeg['"]?\s*\)\s*return ['"]jpg['"]/);
|
||||
assert.doesNotMatch(textToImage, /downloadFile\(url,\s*`miaojing-\$\{Date\.now\(\)\}-\$\{index\}\.png`\)/);
|
||||
assert.doesNotMatch(imageToImage, /downloadFile\(url,\s*`miaojing-img2img-\$\{Date\.now\(\)\}-\$\{index\}\.png`\)/);
|
||||
assert.match(textToImage, /getImageDownloadExtension\(url,\s*outputFormat\)/);
|
||||
assert.match(imageToImage, /getImageDownloadExtension\(url,\s*outputFormat\)/);
|
||||
assert.match(detail, /getImageDownloadExtension\(\s*url,[\s\S]*record\.params\?\.outputFormat/);
|
||||
assert.match(lightbox, /getImageDownloadExtension\(src\)/);
|
||||
});
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
51
scripts/test-layout-composition-skill.mjs
Normal file
51
scripts/test-layout-composition-skill.mjs
Normal file
@@ -0,0 +1,51 @@
|
||||
import assert from 'node:assert';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
function read(path) {
|
||||
return readFileSync(new URL(`../${path}`, import.meta.url), 'utf8');
|
||||
}
|
||||
|
||||
async function runTest(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`PASS ${name}`);
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${name}`);
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
await runTest('built-in layout composition skill records source, license, and 100 layout references', () => {
|
||||
const source = read('src/lib/layout-composition-skill.ts');
|
||||
|
||||
assert.match(source, /100-layout-compositions/);
|
||||
assert.match(source, /CC BY 4\.0/);
|
||||
assert.match(source, /TOTAL_LAYOUT_COMPOSITION_COUNT\s*=\s*100/);
|
||||
assert.match(source, /layoutNumber\.toString\(\)\.padStart\(3,\s*'0'\)/);
|
||||
assert.match(source, /images\/\$\{id\}\.png/);
|
||||
assert.match(source, /thumbnails\/\$\{id\}\.jpg/);
|
||||
assert.match(source, /不要添加文字、Logo、品牌标识或海报排版/);
|
||||
});
|
||||
|
||||
await runTest('site config exposes an admin-controlled image composition skill toggle', () => {
|
||||
const route = read('src/app/api/site-config/route.ts');
|
||||
const client = read('src/lib/site-config.ts');
|
||||
const settings = read('src/components/admin/settings-tab.tsx');
|
||||
|
||||
assert.match(route, /image_composition_skill_enabled BOOLEAN NOT NULL DEFAULT FALSE/);
|
||||
assert.match(route, /imageCompositionSkillEnabled/);
|
||||
assert.match(client, /imageCompositionSkillEnabled: boolean/);
|
||||
assert.match(settings, /100 Layout Compositions/);
|
||||
assert.match(settings, /handleImageCompositionSkillToggle/);
|
||||
});
|
||||
|
||||
await runTest('image generation route applies the layout composition skill before upstream requests', () => {
|
||||
const route = read('src/app/api/generate/image/route.ts');
|
||||
|
||||
assert.match(route, /applyLayoutCompositionSkillToPrompt/);
|
||||
assert.match(route, /promptWithCompositionSkill/);
|
||||
assert.match(route, /promptForGeneration = mergeStylePrompt\(promptWithCompositionSkill, stylePrompt\)/);
|
||||
});
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
215
scripts/test-media-watermark-policy.mjs
Normal file
215
scripts/test-media-watermark-policy.mjs
Normal file
@@ -0,0 +1,215 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, '..');
|
||||
|
||||
const policyModule = await import('../src/lib/media-watermark-policy.ts');
|
||||
const watermarkModule = await import('../src/lib/media-watermark.ts');
|
||||
|
||||
const {
|
||||
canAccessOriginalMedia,
|
||||
getWatermarkedStorageKey,
|
||||
isWatermarkableStorageKey,
|
||||
shouldWatermarkStorageResponse,
|
||||
shouldWatermarkDownloadResponse,
|
||||
} = policyModule.default || policyModule;
|
||||
const { applyImageWatermark } = watermarkModule.default || watermarkModule;
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
async function runTest(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`PASS ${name}`);
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${name}`);
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
await runTest('watermark policy targets generated work media without touching site assets or avatars', () => {
|
||||
assert.equal(isWatermarkableStorageKey('generated/images/work.png'), true);
|
||||
assert.equal(isWatermarkableStorageKey('generated/videos/work.mp4'), true);
|
||||
assert.equal(isWatermarkableStorageKey('gallery/images/work.webp'), true);
|
||||
assert.equal(isWatermarkableStorageKey('gallery/videos/work.mp4'), true);
|
||||
assert.equal(isWatermarkableStorageKey('thumbnails/generated/images/work-m1280q86.webp'), true);
|
||||
assert.equal(isWatermarkableStorageKey('thumbnails/works/videos/frame-video-frame-m1280q86-v1.webp'), true);
|
||||
assert.equal(isWatermarkableStorageKey('imported/works/results/images/imported.jpg'), true);
|
||||
assert.equal(isWatermarkableStorageKey('site-assets/logo.png'), false);
|
||||
assert.equal(isWatermarkableStorageKey('avatars/user.webp'), false);
|
||||
assert.equal(isWatermarkableStorageKey('user-api-manifests/user/key.json'), false);
|
||||
assert.equal(isWatermarkableStorageKey('reverse-prompt/reference-images/input.png'), false);
|
||||
});
|
||||
|
||||
await runTest('admin-authorized users can access original media while others receive watermarked downloads', () => {
|
||||
assert.equal(canAccessOriginalMedia(null), false);
|
||||
assert.equal(canAccessOriginalMedia({ role: 'user', membershipTier: 'free', watermarkDisabled: true }), true);
|
||||
assert.equal(canAccessOriginalMedia({ role: 'user', membershipTier: 'free', watermarkDisabled: false }), false);
|
||||
assert.equal(canAccessOriginalMedia({ role: 'vip', membershipTier: 'pro', watermarkDisabled: false }), false);
|
||||
assert.equal(canAccessOriginalMedia({ role: 'vip', membershipTier: 'pro', watermarkDisabled: true }), true);
|
||||
assert.equal(canAccessOriginalMedia({ role: 'admin', membershipTier: 'free', watermarkDisabled: false }), true);
|
||||
});
|
||||
|
||||
await runTest('storage responses default to watermarked generated media', () => {
|
||||
assert.equal(shouldWatermarkStorageResponse('generated/images/work.png', 'image/png', null), true);
|
||||
assert.equal(
|
||||
shouldWatermarkStorageResponse('generated/images/work.png', 'image/png', {
|
||||
role: 'vip',
|
||||
membershipTier: 'pro',
|
||||
watermarkDisabled: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(shouldWatermarkStorageResponse('site-assets/logo.png', 'image/png', null), false);
|
||||
});
|
||||
|
||||
await runTest('download responses only skip watermark for privileged users who disabled it', () => {
|
||||
assert.equal(shouldWatermarkDownloadResponse('generated/images/work.png', 'image/png', null), true);
|
||||
assert.equal(shouldWatermarkDownloadResponse('generated/images/work.png', 'image/png', {
|
||||
role: 'vip',
|
||||
membershipTier: 'pro',
|
||||
watermarkDisabled: false,
|
||||
}), true);
|
||||
assert.equal(shouldWatermarkDownloadResponse('generated/images/work.png', 'image/png', {
|
||||
role: 'vip',
|
||||
membershipTier: 'pro',
|
||||
watermarkDisabled: true,
|
||||
}), false);
|
||||
assert.equal(shouldWatermarkDownloadResponse('site-assets/logo.png', 'image/png', null), false);
|
||||
});
|
||||
|
||||
await runTest('watermarked cache keys are deterministic and separated by media kind', () => {
|
||||
assert.match(getWatermarkedStorageKey('generated/images/work.png', 'image/png'), /^watermarked\/images\/[a-f0-9]{64}\.png$/);
|
||||
assert.match(getWatermarkedStorageKey('gallery/images/work.webp', 'image/webp'), /^watermarked\/images\/[a-f0-9]{64}\.webp$/);
|
||||
assert.match(getWatermarkedStorageKey('generated/videos/work.mp4', 'video/mp4'), /^watermarked\/videos\/[a-f0-9]{64}\.mp4$/);
|
||||
});
|
||||
|
||||
await runTest('image watermark renderer visibly changes raster media', async () => {
|
||||
const input = await sharp({
|
||||
create: {
|
||||
width: 640,
|
||||
height: 360,
|
||||
channels: 4,
|
||||
background: { r: 36, g: 50, b: 72, alpha: 1 },
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const output = await applyImageWatermark(input, {
|
||||
key: 'generated/images/work.png',
|
||||
contentType: 'image/png',
|
||||
});
|
||||
|
||||
assert.notDeepEqual(output, input);
|
||||
const metadata = await sharp(output).metadata();
|
||||
assert.equal(metadata.width, 640);
|
||||
assert.equal(metadata.height, 360);
|
||||
});
|
||||
|
||||
await runTest('watermark renderer dedupes concurrent generation for the same media', () => {
|
||||
const source = read('src/lib/media-watermark.ts');
|
||||
|
||||
assert.match(source, /inflightWatermarkJobs/);
|
||||
assert.match(source, /inflightWatermarkJobs\.get\(outputKey\)/);
|
||||
assert.match(source, /inflightWatermarkJobs\.delete\(outputKey\)/);
|
||||
});
|
||||
|
||||
await runTest('local storage route uses watermark access instead of exposing raw object URLs by default', () => {
|
||||
const source = read('src/app/api/local-storage/[...path]/route.ts');
|
||||
|
||||
assert.match(source, /shouldWatermarkStorageResponse\(/);
|
||||
assert.match(source, /serveWatermarkedStorageFile\(/);
|
||||
assert.match(source, /getStoredThumbnailResponse\(/);
|
||||
assert.match(source, /thumbnailResponse/);
|
||||
const thumbnailResponseFunction = source.slice(
|
||||
source.indexOf('async function getStoredThumbnailResponse'),
|
||||
source.indexOf('function normalizeStoragePath'),
|
||||
);
|
||||
assert.doesNotMatch(thumbnailResponseFunction, /NextResponse\.redirect/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/shouldWatermarkStorageResponse[\s\S]+?fileExistsAsync\(/,
|
||||
'storage display route should not require a slow object HEAD before watermark rendering',
|
||||
);
|
||||
});
|
||||
|
||||
await runTest('download route applies watermark and checks authenticated no-watermark entitlement', () => {
|
||||
const source = read('src/app/api/download/route.ts');
|
||||
|
||||
assert.match(source, /resolveMediaWatermarkAccess\(request\)/);
|
||||
assert.match(source, /serveWatermarkedDownloadFile\(/);
|
||||
assert.match(source, /canAccessOriginalMedia\(/);
|
||||
});
|
||||
|
||||
await runTest('profile API and auth store carry the member no-watermark preference', () => {
|
||||
const preferenceSource = read('src/lib/profile-preferences.ts');
|
||||
const profileRouteSource = read('src/app/api/profile/route.ts');
|
||||
const authStoreSource = read('src/lib/auth-store.ts');
|
||||
|
||||
assert.match(preferenceSource, /watermark_disabled BOOLEAN NOT NULL DEFAULT false/);
|
||||
assert.match(profileRouteSource, /watermark_disabled/);
|
||||
assert.match(profileRouteSource, /watermarkDisabled/);
|
||||
assert.match(profileRouteSource, /COALESCE\(watermark_disabled,\s*false\) AS watermark_disabled/);
|
||||
assert.match(authStoreSource, /watermarkDisabled:\s*boolean/);
|
||||
assert.match(authStoreSource, /watermark_disabled === true/);
|
||||
});
|
||||
|
||||
await runTest('profile page exposes a VIP-only no-watermark download switch', () => {
|
||||
const source = read('src/app/profile/page.tsx');
|
||||
|
||||
assert.match(source, /import \{ Switch \} from '@\/components\/ui\/switch'/);
|
||||
assert.match(source, /watermarkDisabled/);
|
||||
assert.match(source, /checked=\{accountForm\.watermarkDisabled\}/);
|
||||
assert.match(source, /disabled=\{!canDisableWatermark/);
|
||||
assert.doesNotMatch(source, /watermarkDisabled:\s*canDisableWatermark && accountForm\.watermarkDisabled/);
|
||||
assert.match(source, /if \(canDisableWatermark\) \{/);
|
||||
assert.match(source, /payload\.watermarkDisabled = accountForm\.watermarkDisabled === true/);
|
||||
assert.match(source, /下载无水印/);
|
||||
});
|
||||
|
||||
await runTest('profile API preserves admin-granted no-watermark access for free users', () => {
|
||||
const source = read('src/app/api/profile/route.ts');
|
||||
|
||||
assert.match(source, /const canManageOwnWatermark = canDisableWatermarkForProfile/);
|
||||
assert.match(source, /if \(hasWatermarkDisabled && watermarkDisabled && !canManageOwnWatermark\) \{/);
|
||||
assert.match(source, /watermarkDisabled && !canManageOwnWatermark/);
|
||||
assert.match(source, /const shouldUpdateWatermark = hasWatermarkDisabled && canManageOwnWatermark/);
|
||||
assert.match(source, /shouldUpdateWatermark,\s*watermarkDisabled,\s*tokenUserId/s);
|
||||
assert.doesNotMatch(source, /if \(hasWatermarkDisabled && watermarkDisabled && !canDisableWatermarkForProfile/);
|
||||
});
|
||||
|
||||
await runTest('admin users API and UI can toggle no-watermark downloads per user', () => {
|
||||
const serviceSource = read('src/lib/admin-users-service.ts');
|
||||
const uiSource = read('src/components/admin/user-management-tab.tsx');
|
||||
const adminStoreSource = read('src/lib/admin-store.ts');
|
||||
|
||||
assert.match(serviceSource, /ensureProfilePreferenceSchema/);
|
||||
assert.match(serviceSource, /COALESCE\(p\.watermark_disabled,\s*false\) AS watermark_disabled/);
|
||||
assert.match(serviceSource, /updates\.watermarkDisabled/);
|
||||
assert.match(serviceSource, /watermark_disabled = \$\$\{paramIdx\+\+\}/);
|
||||
assert.match(adminStoreSource, /watermarkDisabled\??:\s*boolean/);
|
||||
assert.match(uiSource, /import \{ Switch \} from '@\/components\/ui\/switch'/);
|
||||
assert.match(uiSource, /watermark_disabled:\s*boolean/);
|
||||
assert.match(uiSource, /watermarkDisabled:\s*u\.watermark_disabled === true/);
|
||||
assert.match(uiSource, /setEditWatermarkDisabled\(user\.watermarkDisabled === true\)/);
|
||||
assert.match(uiSource, /watermarkDisabled:\s*editWatermarkDisabled/);
|
||||
assert.match(uiSource, /checked=\{editWatermarkDisabled\}/);
|
||||
assert.match(uiSource, /下载无水印/);
|
||||
});
|
||||
|
||||
await runTest('download helpers forward the current session to the download API', () => {
|
||||
const source = read('src/lib/utils.ts');
|
||||
|
||||
assert.match(source, /function getStoredAccessTokenForDownload\(/);
|
||||
assert.match(source, /Authorization: `Bearer \$\{token\}`/);
|
||||
assert.match(source, /downloadToken/);
|
||||
assert.match(source, /includeDownloadToken: false/);
|
||||
});
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
96
scripts/test-mobile-create-ui-policy.mjs
Normal file
96
scripts/test-mobile-create-ui-policy.mjs
Normal file
@@ -0,0 +1,96 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, '..');
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
async function runTest(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`PASS ${name}`);
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${name}`);
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const panelFiles = [
|
||||
'src/components/create/text-to-image.tsx',
|
||||
'src/components/create/image-to-image.tsx',
|
||||
'src/components/create/text-to-video.tsx',
|
||||
'src/components/create/image-to-video.tsx',
|
||||
'src/components/create/reverse-prompt-panel.tsx',
|
||||
];
|
||||
|
||||
await runTest('mobile composer supports custom input content for non-text-only creation modes', () => {
|
||||
const source = read('src/components/create/mobile-creation-composer.tsx');
|
||||
assert.match(source, /input\?: ReactNode/, 'MobileCreationComposer should expose an input slot');
|
||||
assert.match(source, /\{input \?\? \(/, 'MobileCreationComposer should render the custom input instead of the default textarea');
|
||||
});
|
||||
|
||||
await runTest('all creation panels render the mobile composer', () => {
|
||||
for (const relativePath of panelFiles) {
|
||||
const source = read(relativePath);
|
||||
assert.match(source, /MobileCreationComposer/, `${relativePath} should import and render MobileCreationComposer`);
|
||||
}
|
||||
});
|
||||
|
||||
await runTest('non-text-to-image panels keep mobile conversation status flows', () => {
|
||||
for (const relativePath of panelFiles.slice(1)) {
|
||||
const source = read(relativePath);
|
||||
assert.match(source, /create-mobile-history-flow/, `${relativePath} should render a mobile history/status flow above the fixed composer`);
|
||||
assert.match(source, /useIsMobile/, `${relativePath} should only mount the mobile flow on mobile viewports`);
|
||||
}
|
||||
});
|
||||
|
||||
await runTest('mobile image reference modes preserve mention-aware prompt input', () => {
|
||||
for (const relativePath of [
|
||||
'src/components/create/image-to-image.tsx',
|
||||
'src/components/create/image-to-video.tsx',
|
||||
]) {
|
||||
const source = read(relativePath);
|
||||
assert.match(source, /ReferenceImageMentionControls/, `${relativePath} should still use @ reference controls`);
|
||||
assert.match(source, /create-mobile-reference-strip/, `${relativePath} should show uploaded references in the mobile composer`);
|
||||
assert.match(source, /input=\{\(/, `${relativePath} should pass the mention-aware input into MobileCreationComposer`);
|
||||
}
|
||||
});
|
||||
|
||||
await runTest('reverse prompt mobile composer keeps upload and mode controls reachable', () => {
|
||||
const source = read('src/components/create/reverse-prompt-panel.tsx');
|
||||
assert.match(source, /create-mobile-reverse-upload/, 'reverse prompt should expose mobile upload/change-image controls');
|
||||
assert.match(source, /create-mobile-reverse-controls/, 'reverse prompt should expose mobile mode/language controls');
|
||||
assert.match(source, /MobileCreationComposer/, 'reverse prompt should render MobileCreationComposer');
|
||||
});
|
||||
|
||||
await runTest('mobile conversation is separated from the bottom composer instead of being covered by it', () => {
|
||||
const css = read('src/app/globals.css');
|
||||
const composerSource = read('src/components/create/mobile-creation-composer.tsx');
|
||||
assert.match(css, /\.create-mobile-shell\s*\{[^}]*height:\s*calc\(/, 'mobile create shell should own the available viewport-height region');
|
||||
assert.match(css, /\.create-chat-layout\s*\{[^}]*height:\s*100%/, 'mobile create layout should fill the shell instead of growing under the composer');
|
||||
assert.match(css, /\.create-chat-layout\s*\{[^}]*overflow:\s*hidden/, 'mobile create layout should clip children to the conversation/composer split');
|
||||
assert.match(css, /\.create-chat-thread\s*\{[^}]*overflow-y:\s*auto/, 'mobile conversation thread should scroll independently above the composer');
|
||||
assert.match(css, /\.create-chat-thread\s*\{[^}]*padding-bottom:\s*calc\(var\(--create-mobile-composer-height/, 'mobile conversation thread should reserve the measured composer height');
|
||||
assert.match(css, /\.create-mobile-dialog-composer\s*\{[^}]*position:\s*fixed/, 'mobile composer should stay fixed to the bottom like a chat input');
|
||||
assert.doesNotMatch(css, /\.create-mobile-dialog-composer\s*\{[^}]*position:\s*sticky/, 'mobile composer should not drift inside the thread layout');
|
||||
assert.match(composerSource, /ResizeObserver/, 'mobile composer should measure its height when params, styles, or references change');
|
||||
assert.match(composerSource, /--create-mobile-composer-height/, 'mobile composer should publish its measured height to the layout');
|
||||
assert.doesNotMatch(css, /\.create-mobile-dialog-composer::before/, 'mobile composer should not render the user screenshot annotation as a red divider');
|
||||
assert.doesNotMatch(css, /rgb\(219 73 50/, 'mobile create UI should not include a hard-coded red annotation line');
|
||||
});
|
||||
|
||||
await runTest('mobile bottom navigation is not trapped by the sticky header', () => {
|
||||
const source = read('src/components/navbar.tsx');
|
||||
assert.match(source, /return\s*\(\s*<>/, 'Navbar should wrap the sticky header and fixed mobile nav as siblings');
|
||||
assert.match(
|
||||
source,
|
||||
/<\/header>\s*<nav className="fixed inset-x-0 bottom-0/,
|
||||
'fixed mobile bottom navigation should be rendered outside the sticky header backdrop context',
|
||||
);
|
||||
});
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
80
scripts/test-navigation-performance-policy.mjs
Normal file
80
scripts/test-navigation-performance-policy.mjs
Normal file
@@ -0,0 +1,80 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, '..');
|
||||
|
||||
async function runTest(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`PASS ${name}`);
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${name}`);
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
await runTest('create page keeps primary creation panels in the initial client bundle for instant mode switches', () => {
|
||||
const source = read('src/app/create/page.tsx');
|
||||
assert.match(source, /import\s+\{\s*TextToImagePanel\s*\}\s+from\s+'@\/components\/create\/text-to-image'/);
|
||||
assert.match(source, /import\s+\{\s*ImageToImagePanel\s*\}\s+from\s+'@\/components\/create\/image-to-image'/);
|
||||
assert.match(source, /import\s+\{\s*TextToVideoPanel\s*\}\s+from\s+'@\/components\/create\/text-to-video'/);
|
||||
assert.match(source, /import\s+\{\s*ImageToVideoPanel\s*\}\s+from\s+'@\/components\/create\/image-to-video'/);
|
||||
assert.match(source, /import\s+ReversePromptPanel\s+from\s+'@\/components\/create\/reverse-prompt-panel'/);
|
||||
assert.doesNotMatch(source, /ssr:\s*false/);
|
||||
});
|
||||
|
||||
await runTest('create page avoids server/client tab hydration mismatch on type links', () => {
|
||||
const source = read('src/app/create/page.tsx');
|
||||
assert.match(source, /const \[activeTab, setActiveTab\] = useState\(DEFAULT_CREATE_TAB\)/);
|
||||
assert.doesNotMatch(source, /useState\(\(\) => normalizeCreateTab\(typeParam\)/);
|
||||
assert.match(source, /useEffect\(\(\) => \{\s*const nextTab = normalizeCreateTab\(typeParam\)/s);
|
||||
});
|
||||
|
||||
await runTest('primary navigation avoids eager all-route prefetch pressure', () => {
|
||||
const source = read('src/components/navbar.tsx');
|
||||
assert.doesNotMatch(source, /router\.prefetch\('/);
|
||||
assert.doesNotMatch(source, /prefetch=\{true\}/);
|
||||
});
|
||||
|
||||
await runTest('non-critical visit tracking waits for browser idle time', () => {
|
||||
const source = read('src/components/visit-tracker.tsx');
|
||||
assert.match(source, /requestIdleCallback/);
|
||||
assert.match(source, /keepalive:\s*true/);
|
||||
});
|
||||
|
||||
await runTest('profile account page does not eagerly mount heavy record stores for inactive tabs', () => {
|
||||
const source = read('src/app/profile/page.tsx');
|
||||
assert.doesNotMatch(source, /useCreationHistory\(/);
|
||||
assert.doesNotMatch(source, /useCreditRecords\(/);
|
||||
assert.doesNotMatch(source, /useUserOrders\(/);
|
||||
assert.match(source, /getCreationRecordCount\(/);
|
||||
assert.doesNotMatch(source, /<CreditsTab[^>]*creditRecords=/);
|
||||
assert.doesNotMatch(source, /<OrdersTab[^>]*orders=/);
|
||||
});
|
||||
|
||||
await runTest('creation panels request scoped lightweight history instead of repeated full history payloads', () => {
|
||||
const expectations = [
|
||||
['src/components/create/text-to-image.tsx', "useCreationHistory({ mode: 'text2img', limit: 60 })"],
|
||||
['src/components/create/image-to-image.tsx', "useCreationHistory({ mode: 'img2img', limit: 60 })"],
|
||||
['src/components/create/text-to-video.tsx', "useCreationHistory({ mode: 'text2video', limit: 60 })"],
|
||||
['src/components/create/image-to-video.tsx', "useCreationHistory({ mode: 'img2video', limit: 60 })"],
|
||||
['src/components/create/reverse-prompt-panel.tsx', "useCreationHistory({ mode: 'reverse-prompt', limit: 60 })"],
|
||||
];
|
||||
for (const [file, expected] of expectations) {
|
||||
assert.ok(read(file).includes(expected), `${file} should use scoped creation history`);
|
||||
}
|
||||
|
||||
const storeSource = read('src/lib/creation-history-store.ts');
|
||||
assert.match(storeSource, /inflightHistoryRequests/);
|
||||
assert.match(storeSource, /buildHistoryUrl\(scope/);
|
||||
|
||||
const routeSource = read('src/app/api/creation-history/route.ts');
|
||||
assert.match(routeSource, /searchParams\.get\('limit'\)/);
|
||||
assert.match(routeSource, /searchParams\.get\('mode'\)/);
|
||||
});
|
||||
101
scripts/test-ops-hardening.mjs
Normal file
101
scripts/test-ops-hardening.mjs
Normal file
@@ -0,0 +1,101 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
checkStorageUrl,
|
||||
getMigrationCheckBaseUrl,
|
||||
} from './migration-integrity-check-helpers.mjs';
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, '..');
|
||||
|
||||
async function runTest(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`PASS ${name}`);
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${name}`);
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
await runTest('local-storage route serves thumbnails from local disk and redirects object originals', () => {
|
||||
const source = read('src/app/api/local-storage/[...path]/route.ts');
|
||||
|
||||
assert.match(source, /filePath\.startsWith\('thumbnails\/'\)/);
|
||||
assert.match(source, /localStorage\.fileExists\(filePath\)/);
|
||||
assert.match(source, /localStorage\.readFile\(filePath\)/);
|
||||
assert.match(source, /localStorage\.generateObjectReadUrl\(filePath,\s*300\)/);
|
||||
assert.match(source, /NextResponse\.redirect\(objectUrl/);
|
||||
});
|
||||
|
||||
await runTest('admin provider and recommendation reads require admin auth', () => {
|
||||
for (const relativePath of [
|
||||
'src/app/api/admin/providers/route.ts',
|
||||
'src/app/api/admin/model-recommendations/route.ts',
|
||||
]) {
|
||||
const source = read(relativePath);
|
||||
assert.match(source, /export async function GET\(request: NextRequest\)/, relativePath);
|
||||
assert.match(source, /const authError = await requireAdmin\(request\)/, relativePath);
|
||||
assert.match(source, /if \(authError\) return authError;/, relativePath);
|
||||
}
|
||||
|
||||
const tabSource = read('src/components/admin/api-management-tab.tsx');
|
||||
assert.match(tabSource, /fetch\('\/api\/admin\/providers', \{ headers: authHeaders\(accessToken\) \}\)/);
|
||||
assert.match(tabSource, /fetch\('\/api\/admin\/model-recommendations', \{ headers: authHeaders\(accessToken\) \}\)/);
|
||||
});
|
||||
|
||||
await runTest('migration check defaults to production web port unless overridden', () => {
|
||||
assert.equal(getMigrationCheckBaseUrl({}), 'http://127.0.0.1:8000');
|
||||
assert.equal(
|
||||
getMigrationCheckBaseUrl({ MIGRATION_CHECK_BASE_URL: 'http://127.0.0.1:5000' }),
|
||||
'http://127.0.0.1:5000',
|
||||
);
|
||||
});
|
||||
|
||||
await runTest('migration storage URL check records fetch failures instead of throwing', async () => {
|
||||
const result = await checkStorageUrl('http://127.0.0.1:8000', '/api/local-storage/missing.webp', {
|
||||
timeoutMs: 10,
|
||||
fetchImpl: async () => {
|
||||
throw new Error('connect timeout');
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
ok: false,
|
||||
error: 'connect timeout',
|
||||
});
|
||||
});
|
||||
|
||||
await runTest('migration storage URL check treats local-storage redirects as reachable', async () => {
|
||||
const calls = [];
|
||||
const result = await checkStorageUrl('http://127.0.0.1:8000', '/api/local-storage/gallery/images/work.png', {
|
||||
timeoutMs: 10,
|
||||
fetchImpl: async (_url, init) => {
|
||||
calls.push(init);
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: 'https://object-storage.example/work.png' },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { ok: true });
|
||||
assert.equal(calls[0].method, 'HEAD');
|
||||
assert.equal(calls[0].redirect, 'manual');
|
||||
});
|
||||
|
||||
await runTest('migration integrity script uses resilient storage URL helpers', () => {
|
||||
const source = read('scripts/migration-integrity-check.mjs');
|
||||
|
||||
assert.match(source, /getMigrationCheckBaseUrl\(\)/);
|
||||
assert.match(source, /getMigrationStorageUrlTimeoutMs\(\)/);
|
||||
assert.match(source, /getMigrationStorageUrlConcurrency\(\)/);
|
||||
assert.match(source, /checkStorageUrl\(baseUrl, row\.url/);
|
||||
});
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
74
scripts/test-reference-image-history-preview.mjs
Normal file
74
scripts/test-reference-image-history-preview.mjs
Normal file
@@ -0,0 +1,74 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, '..');
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
async function runTest(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`PASS ${name}`);
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${name}`);
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
await runTest('creation history persists and backfills reference image URLs and thumbnails', () => {
|
||||
const route = read('src/app/api/creation-history/route.ts');
|
||||
|
||||
assert.match(route, /persistReferenceImages/);
|
||||
assert.match(route, /getReferenceImageInputs/);
|
||||
assert.match(route, /referenceImageThumbnails/);
|
||||
assert.match(route, /shouldPatchReferences/);
|
||||
assert.match(route, /mergeWorkRowMetadata/);
|
||||
assert.match(route, /hasReferenceMetadata/);
|
||||
assert.match(route, /params = \$4::jsonb/);
|
||||
});
|
||||
|
||||
await runTest('reference image backfill script can persist old data-url history rows', () => {
|
||||
const script = read('scripts/backfill-work-reference-images.mjs');
|
||||
|
||||
assert.match(script, /--dry-run/);
|
||||
assert.match(script, /persistReferenceImages/);
|
||||
assert.match(script, /referenceImageThumbnails/);
|
||||
assert.match(script, /params->>'creationMode' IN \('img2img', 'img2video'\)/);
|
||||
});
|
||||
|
||||
await runTest('generation worker keeps data-url reference inputs for server-side persistence', () => {
|
||||
const worker = read('src/lib/generation-job-worker.ts');
|
||||
|
||||
assert.match(worker, /function safeReferenceInput/);
|
||||
assert.match(worker, /function getReferenceInputs/);
|
||||
assert.match(worker, /const references = getReferenceInputs\(payload\)/);
|
||||
assert.doesNotMatch(worker, /const references = getSafeReferenceImages\(payload\);\n return \{/);
|
||||
});
|
||||
|
||||
await runTest('reference previews use lightweight thumbnails and do not expose downloads in detail', () => {
|
||||
const detail = read('src/components/creation-detail-dialog.tsx');
|
||||
const imageToImage = read('src/components/create/image-to-image.tsx');
|
||||
const imageToVideo = read('src/components/create/image-to-video.tsx');
|
||||
const preview = read('src/components/reference-preview-image.tsx');
|
||||
|
||||
assert.match(detail, /ReferencePreviewImage/);
|
||||
assert.match(detail, /thumbnailSrc=\{referenceImageThumbnails\[index\]\}/);
|
||||
assert.doesNotMatch(detail, /miaojing-reference-\$\{record\.id\}/);
|
||||
assert.match(imageToImage, /<ReferencePreviewImage src=\{img\.dataUrl\}/);
|
||||
assert.match(imageToVideo, /<ReferencePreviewImage src=\{img\.dataUrl\}/);
|
||||
assert.match(preview, /const MAX_EDGE = 360/);
|
||||
assert.match(preview, /canvas\.toDataURL\('image\/webp', QUALITY\)/);
|
||||
});
|
||||
|
||||
await runTest('image-to-video history cards avoid eager original video metadata loads', () => {
|
||||
const source = read('src/components/create/image-to-video.tsx');
|
||||
|
||||
assert.match(source, /record\.thumbnailUrl/);
|
||||
assert.doesNotMatch(source, /<video src=\{record\.url\} className="w-full h-full object-cover" preload="metadata" \/>/);
|
||||
});
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
68
scripts/test-reference-image-prompt-links.mjs
Normal file
68
scripts/test-reference-image-prompt-links.mjs
Normal file
@@ -0,0 +1,68 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, '..');
|
||||
|
||||
const {
|
||||
buildReferenceImagePrompt,
|
||||
normalizeReferenceImageAnnotations,
|
||||
} = await import('../src/lib/reference-image-prompt.ts');
|
||||
|
||||
async function runTest(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`PASS ${name}`);
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${name}`);
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
await runTest('adds model-readable mappings for referenced uploaded images', () => {
|
||||
const prompt = '让 @参考图2 的外套穿到 @参考图1 的人物身上,保持 @参考图1 的脸部特征';
|
||||
const result = buildReferenceImagePrompt(prompt, 2, [
|
||||
{ index: 1, token: '@参考图1', name: 'person.jpg', width: 1024, height: 1536 },
|
||||
{ index: 2, token: '@参考图2', name: 'coat.png', width: 800, height: 800 },
|
||||
]);
|
||||
|
||||
assert.ok(result.startsWith(prompt));
|
||||
assert.match(result, /参考图标注说明/);
|
||||
assert.match(result, /@参考图1 对应上传的第1张参考图/);
|
||||
assert.match(result, /文件名:person\.jpg/);
|
||||
assert.match(result, /尺寸:1024x1536/);
|
||||
assert.match(result, /@参考图2 对应上传的第2张参考图/);
|
||||
assert.match(result, /文件名:coat\.png/);
|
||||
assert.match(result, /尺寸:800x800/);
|
||||
assert.match(result, /当提示词提到 @参考图2 时/);
|
||||
});
|
||||
|
||||
await runTest('normalizes annotations and ignores impossible image indexes', () => {
|
||||
const annotations = normalizeReferenceImageAnnotations([
|
||||
{ index: 2, token: '@衣服', name: 'coat.png' },
|
||||
{ index: 9, token: '@不存在', name: 'missing.png' },
|
||||
{ index: 1, token: '人物', name: 'person.jpg', width: 'bad', height: 1024 },
|
||||
], 2);
|
||||
|
||||
assert.deepEqual(annotations, [
|
||||
{ index: 2, token: '@衣服', name: 'coat.png' },
|
||||
{ index: 1, token: '@参考图1', name: 'person.jpg', height: 1024 },
|
||||
]);
|
||||
});
|
||||
|
||||
await runTest('does not alter prompts without reference images', () => {
|
||||
assert.equal(buildReferenceImagePrompt('一只杯子', 0, []), '一只杯子');
|
||||
assert.equal(buildReferenceImagePrompt('', 2, []), '');
|
||||
});
|
||||
|
||||
await runTest('image-to-image and image-to-video send reference annotations from the @ picker', () => {
|
||||
const imageToImageSource = fs.readFileSync(path.join(repoRoot, 'src/components/create/image-to-image.tsx'), 'utf8');
|
||||
const imageToVideoSource = fs.readFileSync(path.join(repoRoot, 'src/components/create/image-to-video.tsx'), 'utf8');
|
||||
|
||||
for (const source of [imageToImageSource, imageToVideoSource]) {
|
||||
assert.match(source, /ReferenceImageMentionControls/);
|
||||
assert.match(source, /referenceImageAnnotations/);
|
||||
assert.match(source, /buildReferenceImageAnnotations/);
|
||||
}
|
||||
});
|
||||
21
scripts/test-reverse-prompt-upstream-image-url.mjs
Normal file
21
scripts/test-reverse-prompt-upstream-image-url.mjs
Normal file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const routePath = path.join(process.cwd(), 'src/app/api/generate/reverse-prompt/route.ts');
|
||||
const source = fs.readFileSync(routePath, 'utf8');
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/const upstreamImage\s*=\s*toPublicImageUrl\(persistedReferenceImage,\s*request\)\s*\|\|\s*image/,
|
||||
'reverse-prompt should prefer the public persisted platform URL for upstream image_url payloads',
|
||||
);
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/image_url:\s*\{\s*url:\s*upstreamImage\s*\}/,
|
||||
'reverse-prompt upstream chat payload should send upstreamImage instead of the raw upload data URL',
|
||||
);
|
||||
|
||||
console.log('reverse-prompt upstream image URL policy ok');
|
||||
56
scripts/test-site-config-client-cache.mjs
Normal file
56
scripts/test-site-config-client-cache.mjs
Normal file
@@ -0,0 +1,56 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, '..');
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
async function runTest(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`PASS ${name}`);
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${name}`);
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
await runTest('site config hook shares in-flight refresh requests across global components', () => {
|
||||
const source = read('src/lib/site-config.ts');
|
||||
assert.match(source, /let siteConfigSnapshot: SiteConfig \| null = null;/);
|
||||
assert.match(source, /let inFlightSiteConfigRequest: Promise<SiteConfig \| null> \| null = null;/);
|
||||
assert.match(source, /function fetchFreshSiteConfig\(\): Promise<SiteConfig \| null>/);
|
||||
assert.match(source, /if \(inFlightSiteConfigRequest\) return inFlightSiteConfigRequest;/);
|
||||
assert.match(source, /inFlightSiteConfigRequest = null;/);
|
||||
});
|
||||
|
||||
await runTest('site config hook uses a shared snapshot for instant repeated mounts', () => {
|
||||
const source = read('src/lib/site-config.ts');
|
||||
assert.match(source, /function getInitialSiteConfig\(\): \{ config: SiteConfig; loaded: boolean \}/);
|
||||
assert.match(source, /if \(siteConfigSnapshot\) return \{ config: siteConfigSnapshot, loaded: true \};/);
|
||||
assert.match(source, /siteConfigSnapshot = config;/);
|
||||
});
|
||||
|
||||
await runTest('site config hook skips fresh-cache network refreshes on route remounts', () => {
|
||||
const source = read('src/lib/site-config.ts');
|
||||
assert.match(source, /let siteConfigSnapshotTimestamp = 0;/);
|
||||
assert.match(source, /function isSiteConfigSnapshotFresh\(\): boolean/);
|
||||
assert.match(source, /if \(isSiteConfigSnapshotFresh\(\)\) \{/);
|
||||
assert.match(source, /siteConfigSnapshotTimestamp = Date\.now\(\);/);
|
||||
});
|
||||
|
||||
await runTest('site config API caches schema compatibility checks after startup', () => {
|
||||
const source = read('src/app/api/site-config/route.ts');
|
||||
assert.match(source, /let siteConfigColumnsReady = false;/);
|
||||
assert.match(source, /let siteConfigColumnsPromise: Promise<void> \| null = null;/);
|
||||
assert.match(source, /async function ensureSiteConfigColumnsOnce/);
|
||||
assert.match(source, /if \(siteConfigColumnsReady\) return;/);
|
||||
assert.match(source, /siteConfigColumnsReady = true;/);
|
||||
assert.match(source, /await ensureSiteConfigColumnsOnce\(client\);/);
|
||||
});
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
169
scripts/test-video-object-storage-actions.mjs
Normal file
169
scripts/test-video-object-storage-actions.mjs
Normal file
@@ -0,0 +1,169 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, '..');
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
async function runTest(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`PASS ${name}`);
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${name}`);
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
await runTest('video generation persists generated videos as object-backed media under generated/videos', () => {
|
||||
const source = read('src/app/api/generate/video/route.ts');
|
||||
|
||||
assert.match(source, /uploadFileObjectOnly\(/);
|
||||
assert.match(source, /fileName:\s*`\$\{prefix\}\/\$\{suffix\}\.\$\{ext \|\| 'mp4'\}`/);
|
||||
assert.doesNotMatch(source, /uploadFromUrl\(\{\s*url,\s*timeout:\s*60000\s*\}\)/);
|
||||
});
|
||||
|
||||
await runTest('download route can redirect object-backed local-storage downloads without buffering full videos', () => {
|
||||
const source = read('src/app/api/download/route.ts');
|
||||
|
||||
assert.match(source, /objectFileExistsAsync\(key\)/);
|
||||
assert.match(source, /generateObjectReadUrl\(key,\s*300,/);
|
||||
assert.match(source, /NextResponse\.redirect\(objectUrl,\s*302\)/);
|
||||
});
|
||||
|
||||
await runTest('video result download buttons trigger a streaming browser download instead of fetching a blob first', () => {
|
||||
const utilsSource = read('src/lib/utils.ts');
|
||||
const textVideoSource = read('src/components/create/text-to-video.tsx');
|
||||
const imageVideoSource = read('src/components/create/image-to-video.tsx');
|
||||
|
||||
assert.match(utilsSource, /export function triggerDownloadFile\(/);
|
||||
assert.match(utilsSource, /link\.href = proxyUrl/);
|
||||
assert.doesNotMatch(utilsSource, /triggerDownloadFile[\s\S]*?response\.blob\(\)/);
|
||||
assert.match(textVideoSource, /triggerDownloadFile\(url,/);
|
||||
assert.match(imageVideoSource, /triggerDownloadFile\(url,/);
|
||||
});
|
||||
|
||||
await runTest('gallery publish reuses object-backed video URLs instead of synchronously copying large videos', () => {
|
||||
const routeSource = read('src/app/api/gallery/publish/route.ts');
|
||||
const source = read('src/lib/gallery-publish-media.ts');
|
||||
|
||||
assert.match(routeSource, /resolveGalleryPublishMedia\(\{/);
|
||||
assert.match(source, /if \(input\.type === 'video'\) \{/);
|
||||
assert.match(source, /if \(!isStableLocalStorageUrl\(input\.resultUrl\)\) \{[\s\S]*?copyPublicUrlToFolder\(input\.resultUrl,\s*'gallery\/videos',\s*\{\s*storageTarget:\s*'object'\s*\}/);
|
||||
assert.match(source, /let galleryResultUrl = input\.resultUrl/);
|
||||
});
|
||||
|
||||
await runTest('gallery publish prefers real video frame thumbnails over stale client SVG thumbnails', () => {
|
||||
const source = read('src/lib/gallery-publish-media.ts');
|
||||
|
||||
const videoThumbnailIndex = source.indexOf("type === 'video'");
|
||||
const ensureIndex = source.indexOf('ensureLocalVideoThumbnail(');
|
||||
const copyProvidedIndex = source.indexOf("copyPublicUrlToFolder(input.thumbnailUrl, 'gallery/thumbnails'");
|
||||
|
||||
assert.notEqual(ensureIndex, -1);
|
||||
assert.notEqual(copyProvidedIndex, -1);
|
||||
assert.ok(videoThumbnailIndex < ensureIndex);
|
||||
assert.ok(ensureIndex < copyProvidedIndex);
|
||||
assert.match(source, /thumbnailUrl: generatedVideoThumbnailUrl \|\| copiedVideoThumbnailUrl \|\| galleryThumbnailUrl/);
|
||||
});
|
||||
|
||||
await runTest('share to gallery surfaces server publish failures before marking a work as published', () => {
|
||||
const source = read('src/lib/creation-history-store.ts');
|
||||
|
||||
assert.match(source, /if \(!res\.ok\) \{/);
|
||||
assert.match(source, /throw new Error\(typeof data\.error === 'string' \? data\.error : '分享失败,请重试'\)/);
|
||||
assert.doesNotMatch(source, /catch \{\s*\/\/ Non-critical/);
|
||||
|
||||
const fetchIndex = source.indexOf("fetch('/api/gallery/publish'");
|
||||
const markIndex = source.indexOf('markRecordAsPublished(options.url)');
|
||||
assert.notEqual(fetchIndex, -1);
|
||||
assert.notEqual(markIndex, -1);
|
||||
assert.ok(fetchIndex < markIndex);
|
||||
});
|
||||
|
||||
await runTest('share buttons wait for confirmed server publish and ignore stale local published flags', () => {
|
||||
const storeSource = read('src/lib/creation-history-store.ts');
|
||||
const detailSource = read('src/components/creation-detail-dialog.tsx');
|
||||
const createSources = [
|
||||
read('src/components/create/text-to-image.tsx'),
|
||||
read('src/components/create/image-to-image.tsx'),
|
||||
read('src/components/create/text-to-video.tsx'),
|
||||
read('src/components/create/image-to-video.tsx'),
|
||||
];
|
||||
|
||||
assert.match(storeSource, /publishedAt\?: string/);
|
||||
assert.match(storeSource, /r\.url === url && r\.published && r\.publishedAt/);
|
||||
assert.doesNotMatch(detailSource, /record\.published \|\| isUrlPublished\(record\.url\)/);
|
||||
|
||||
for (const source of createSources) {
|
||||
assert.match(source, /const handleShareToGallery = useCallback\(async \(url: string\) => \{/);
|
||||
assert.match(source, /await shareToGallery\(\{/);
|
||||
assert.match(source, /catch \(error\) \{/);
|
||||
}
|
||||
});
|
||||
|
||||
await runTest('gallery video cards and detail use thumbnails until the user starts playback', () => {
|
||||
const source = read('src/app/gallery/page.tsx');
|
||||
|
||||
assert.match(source, /isVideoWork\(work\)/);
|
||||
assert.match(source, /const mediaPreviewUrl = work\.thumbnailUrl \|\| \(isVideoWork\(work\) \? getVideoFallbackThumbnail\(work\) : ''\)/);
|
||||
assert.match(source, /isVideoWork\(selectedWork\)/);
|
||||
assert.match(source, /activeVideoWorkId !== selectedWork\.id/);
|
||||
assert.match(source, /setActiveVideoWorkId\(selectedWork\.id\)/);
|
||||
assert.match(source, /下载\{isVideoWork\(selectedWork\) \? '视频' : '图片'\}/);
|
||||
});
|
||||
|
||||
await runTest('video thumbnails extract a real video frame before falling back to SVG', () => {
|
||||
const source = read('src/lib/media-storage.ts');
|
||||
|
||||
assert.match(source, /ffmpeg-static/);
|
||||
assert.match(source, /extractVideoFrameThumbnail\(/);
|
||||
assert.match(source, /VIDEO_FRAME_THUMBNAIL_PROFILE/);
|
||||
assert.match(source, /contentType:\s*'image\/webp'/);
|
||||
assert.match(source, /VIDEO_FALLBACK_THUMBNAIL_PROFILE/);
|
||||
assert.doesNotMatch(source, /const VIDEO_THUMBNAIL_PROFILE = 'video-svg-v1'/);
|
||||
});
|
||||
|
||||
await runTest('object-backed video thumbnails stream to a temporary local file before ffmpeg extraction', () => {
|
||||
const source = read('src/lib/media-storage.ts');
|
||||
const resolveStart = source.indexOf('async function resolveVideoThumbnailInput(');
|
||||
const resolveEnd = source.indexOf('async function fetchTemporaryVideoInput(', resolveStart);
|
||||
const resolveSource = source.slice(resolveStart, resolveEnd);
|
||||
|
||||
assert.notEqual(resolveStart, -1);
|
||||
assert.notEqual(resolveEnd, -1);
|
||||
assert.match(resolveSource, /writeStoredTemporaryVideoInput\(existingKey,\s*sourceKey\)/);
|
||||
assert.match(resolveSource, /generateObjectReadUrl\(existingKey,\s*300\)/);
|
||||
assert.match(resolveSource, /fetchTemporaryVideoInput\(objectReadUrl,\s*sourceKey\)/);
|
||||
assert.doesNotMatch(resolveSource, /fileExistsAsync\(existingKey\)[\s\S]*?openFileStreamAsync\(existingKey\)/);
|
||||
assert.match(source, /const VIDEO_THUMBNAIL_INPUT_ATTEMPTS/);
|
||||
assert.match(source, /openFileStreamAsync\(existingKey\)/);
|
||||
assert.match(source, /writeTemporaryVideoInputFromStream\(storedFile\.body/);
|
||||
assert.match(source, /VIDEO_THUMBNAIL_MAX_INPUT_BYTES/);
|
||||
assert.doesNotMatch(source, /return \{ input: objectReadUrl \}/);
|
||||
});
|
||||
|
||||
await runTest('ffmpeg path resolution falls back to the runtime cwd when bundled route context is synthetic', () => {
|
||||
const source = read('src/lib/media-storage.ts');
|
||||
|
||||
assert.match(source, /existsSync\(/);
|
||||
assert.match(source, /createRequire\(path\.join\(process\.cwd\(\), 'package\.json'\)\)/);
|
||||
assert.match(source, /getExistingFfmpegPath\(cwdRequire\('ffmpeg-static'\)\)/);
|
||||
assert.doesNotMatch(source, /return typeof binaryPath === 'string' && binaryPath \? binaryPath : null/);
|
||||
});
|
||||
|
||||
await runTest('creation history de-duplicates repeated video records by URL', () => {
|
||||
const storeSource = read('src/lib/creation-history-store.ts');
|
||||
const routeSource = read('src/app/api/creation-history/route.ts');
|
||||
|
||||
assert.match(storeSource, /function dedupeCreationRecordsByUrl\(/);
|
||||
assert.match(storeSource, /dedupeCreationRecordsByUrl\(records\.slice\(0, MAX_RECORDS\)\)/);
|
||||
assert.match(routeSource, /function dedupeRowsByResultUrl\(/);
|
||||
assert.match(routeSource, /dedupeRowsByResultUrl\(result\.rows\)/);
|
||||
});
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
83
scripts/test-yuanjie-image2-persistence.mjs
Normal file
83
scripts/test-yuanjie-image2-persistence.mjs
Normal file
@@ -0,0 +1,83 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import dns from 'node:dns/promises';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, '..');
|
||||
|
||||
async function runTest(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`PASS ${name}`);
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${name}`);
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
await runTest('元界 GPT Image 2 Manifest keeps task_id as poll query parameter', () => {
|
||||
const source = read('src/lib/yuanjie-image-model-templates.ts');
|
||||
const pollPath = source.indexOf("path: 'v1/media/status'");
|
||||
const pollQuery = source.indexOf("task_id: '{task_id}'");
|
||||
const legacyPath = source.indexOf("path: 'v1/media/status?task_id={task_id}'");
|
||||
|
||||
assert.ok(pollPath > -1, '元界轮询 path 必须是纯路径 v1/media/status');
|
||||
assert.ok(pollQuery > pollPath, 'task_id 必须通过 poll.query 传入');
|
||||
assert.equal(legacyPath, -1, '不能把 task_id 拼进 path,避免被编码成错误路径');
|
||||
});
|
||||
|
||||
await runTest('public image fetch sends browser-like headers and retries transient image download failures', async () => {
|
||||
const originalLookup = dns.lookup;
|
||||
const originalFetch = globalThis.fetch;
|
||||
const calls = [];
|
||||
|
||||
dns.lookup = async () => [{ address: '93.184.216.34', family: 4 }];
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
calls.push({ url: String(url), headers: new Headers(init.headers) });
|
||||
if (calls.length === 1) return new Response('forbidden once', { status: 403 });
|
||||
return new Response('ok', { status: 200, headers: { 'content-type': 'image/png' } });
|
||||
};
|
||||
|
||||
try {
|
||||
const { fetchPublicHttpUrlWithRetry } = await import(`../src/lib/remote-fetch.ts?test=${Date.now()}`);
|
||||
const response = await fetchPublicHttpUrlWithRetry('https://example.com/generated.png', {}, {
|
||||
attempts: 2,
|
||||
retryDelayMs: 0,
|
||||
timeoutMs: 1_000,
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(calls.length, 2);
|
||||
assert.match(calls[0].headers.get('accept') || '', /image\/\*/);
|
||||
assert.match(calls[0].headers.get('user-agent') || '', /Mozilla\/5\.0/);
|
||||
} finally {
|
||||
dns.lookup = originalLookup;
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
await runTest('manifest result persistence failures are reported as download or storage failures, not low resolution', () => {
|
||||
const source = read('src/app/api/generate/image/route.ts');
|
||||
const manifestBlockStart = source.indexOf("'User API Manifest Image'");
|
||||
const manifestFailure = source.indexOf('generatedImagePersistenceError(persisted)', manifestBlockStart);
|
||||
const oldLowResolution = source.indexOf('lowResolutionError(targetSize, persisted.rejected)', manifestBlockStart);
|
||||
|
||||
assert.ok(manifestBlockStart > -1, '应保留 Manifest 图片持久化上下文');
|
||||
assert.ok(manifestFailure > manifestBlockStart, 'Manifest 结果持久化失败应走专门错误文案');
|
||||
assert.equal(oldLowResolution, -1, 'Manifest 结果下载/保存失败不能再包装成分辨率不符合');
|
||||
assert.match(source, /上游已返回生成结果,但平台下载或保存结果图片失败/);
|
||||
});
|
||||
|
||||
await runTest('media storage uses retrying public fetch for external generated image URLs', () => {
|
||||
const source = read('src/lib/media-storage.ts');
|
||||
|
||||
assert.match(source, /import \{ fetchPublicHttpUrl,\s*fetchPublicHttpUrlWithRetry \} from '@\/lib\/remote-fetch';/);
|
||||
assert.match(source, /fetchPublicHttpUrlWithRetry\(url,\s*\{\},\s*\{\s*attempts:\s*3,/s);
|
||||
});
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
133
scripts/test-yuanjie-media-manifest-mapping.mjs
Normal file
133
scripts/test-yuanjie-media-manifest-mapping.mjs
Normal file
@@ -0,0 +1,133 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, '..');
|
||||
|
||||
const {
|
||||
YUANJIE_IMAGE_MODEL_TEMPLATES,
|
||||
buildYuanjieSubmit,
|
||||
} = await import('../src/lib/yuanjie-image-model-templates.ts');
|
||||
const {
|
||||
YUANJIE_VIDEO_MODEL_TEMPLATES,
|
||||
buildYuanjieVideoSubmit,
|
||||
} = await import('../src/lib/yuanjie-video-model-templates.ts');
|
||||
const {
|
||||
getYuanjieSystemApiCapabilitiesFallback,
|
||||
} = await import('../src/lib/yuanjie-system-manifest.ts');
|
||||
|
||||
async function runTest(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`PASS ${name}`);
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${name}`);
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
function imageTemplate(modelName) {
|
||||
const template = YUANJIE_IMAGE_MODEL_TEMPLATES.find(item => item.modelName === modelName);
|
||||
assert.ok(template, `missing image template ${modelName}`);
|
||||
return buildYuanjieSubmit(template);
|
||||
}
|
||||
|
||||
function videoTemplate(modelName) {
|
||||
const template = YUANJIE_VIDEO_MODEL_TEMPLATES.find(item => item.modelName === modelName);
|
||||
assert.ok(template, `missing video template ${modelName}`);
|
||||
return buildYuanjieVideoSubmit(template);
|
||||
}
|
||||
|
||||
await runTest('image-to-image route passes all reference images into Manifest execution', () => {
|
||||
const source = read('src/app/api/generate/image/route.ts');
|
||||
|
||||
assert.match(source, /extraImages/);
|
||||
assert.match(source, /const referenceImages = normalizeReferenceImages\(image,\s*undefined,\s*extraImages\)/s);
|
||||
assert.match(source, /inputImages:\s*referenceImages,/);
|
||||
assert.doesNotMatch(source, /inputImages:\s*image \? \[image\] : \[\]/);
|
||||
});
|
||||
|
||||
await runTest('yuanjie GPT Image 2 uses public image reference URLs for edit/image-to-image', () => {
|
||||
const submit = imageTemplate('gpt-image-2');
|
||||
assert.equal(submit.body?.params?.images, '$inputImages.urls');
|
||||
assert.equal(submit.body?.images, '$inputImages.urls');
|
||||
assert.equal(submit.body?.base64Array, '$inputImages.urls');
|
||||
});
|
||||
|
||||
await runTest('manifest executor exposes normalized public input image URLs to templates', () => {
|
||||
const source = read('src/lib/user-api-manifest-executor.ts');
|
||||
|
||||
assert.match(source, /inputImageUrls\?: string\[\]/);
|
||||
assert.match(source, /inputImages:\s*\{\s*dataUrls:\s*input\.inputImages \|\| \[\],\s*urls:\s*input\.inputImageUrls \|\| input\.inputImages \|\| \[\],/s);
|
||||
assert.match(source, /resolveManifestInputImageReferences\(input\.inputImages \|\| \[\]\)/);
|
||||
});
|
||||
|
||||
await runTest('yuanjie video templates map documented reference fields and mode fields', () => {
|
||||
assert.equal(videoTemplate('sora-2').body?.params?.input_reference, '$inputImages.urls.0');
|
||||
assert.equal(videoTemplate('wan2.6-cankaosheng').body?.params?.reference_urls, '$inputImages.urls');
|
||||
assert.equal(videoTemplate('wan2.6-shouzheng').body?.params?.img_url, '$inputImages.urls.0');
|
||||
assert.equal(videoTemplate('kling-v3-omni-shouweizhen').body?.params?.image, '$inputImages.urls.0');
|
||||
assert.equal(videoTemplate('kling-v3-omni-shouweizhen').body?.params?.image_tail, '$inputImages.urls.1');
|
||||
assert.equal(videoTemplate('happyhorse-r2v').body?.params?.ratio, '$params.aspect_ratio');
|
||||
assert.equal(videoTemplate('grok-video-3').body?.params?.size, '$params.resolution');
|
||||
assert.equal(videoTemplate('veo3.1').body?.params?.generation_mode, '$params.quality');
|
||||
assert.equal(videoTemplate('veo3.1').body?.params?.enhance_prompt, true);
|
||||
assert.equal(videoTemplate('veo3.1').body?.params?.enable_upsample, false);
|
||||
});
|
||||
|
||||
await runTest('yuanjie HappyHorse text-to-video uses documented media params and output task id path', () => {
|
||||
const submit = videoTemplate('happyhorse-t2v');
|
||||
const bodyKeys = Object.keys(submit.body || {}).sort();
|
||||
assert.deepEqual(bodyKeys, ['model', 'params', 'prompt']);
|
||||
assert.equal(submit.body?.model, '$profile.model');
|
||||
assert.equal(submit.body?.prompt, '$prompt');
|
||||
assert.equal(submit.body?.params?.resolution, '$params.resolution');
|
||||
assert.equal(submit.body?.params?.ratio, '$params.aspect_ratio');
|
||||
assert.equal(submit.body?.params?.duration, '$params.duration');
|
||||
assert.equal(submit.body?.params?.aspect_ratio, undefined);
|
||||
assert.deepEqual(Object.keys(submit.body?.params || {}).sort(), ['duration', 'ratio', 'resolution']);
|
||||
assert.match(submit.taskIdPath || '', /output\.task_id/);
|
||||
});
|
||||
|
||||
await runTest('yuanjie system API rows without manifest still expose built-in video capabilities', () => {
|
||||
const capabilities = getYuanjieSystemApiCapabilitiesFallback({
|
||||
provider: '元界 AI',
|
||||
type: 'video',
|
||||
model_name: 'happyhorse-t2v',
|
||||
model_group: 'default',
|
||||
});
|
||||
assert.ok(capabilities, 'expected built-in capabilities for HappyHorse text-to-video');
|
||||
assert.deepEqual(capabilities.resolutions?.map(item => item.value), ['720P', '1080P']);
|
||||
assert.deepEqual(capabilities.aspectRatios?.map(item => item.value), ['16:9', '9:16', '1:1', '4:3', '3:4']);
|
||||
assert.deepEqual(capabilities.durations?.map(item => item.value), ['3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15']);
|
||||
|
||||
const mozheCapabilities = getYuanjieSystemApiCapabilitiesFallback({
|
||||
provider: 'mozheAPI',
|
||||
type: 'video',
|
||||
model_name: 'happyhorse-t2v',
|
||||
model_group: 'default',
|
||||
});
|
||||
assert.equal(mozheCapabilities, undefined);
|
||||
});
|
||||
|
||||
await runTest('system default polling candidates repair stale yuanjie manifests before generation', () => {
|
||||
const source = read('src/lib/server-api-config.ts');
|
||||
const pollingFunction = source.match(/export async function resolveSystemApiPollingCandidates[\s\S]*?^}/m)?.[0] || '';
|
||||
|
||||
assert.match(pollingFunction, /ensureYuanjieSystemApiManifest\(client,\s*row\)/);
|
||||
assert.match(pollingFunction, /yuanjieManifest\?\.manifestPath/);
|
||||
assert.match(pollingFunction, /yuanjieManifest\?\.apiUrl/);
|
||||
});
|
||||
|
||||
await runTest('video route passes negative prompt through Manifest params for providers that document it', () => {
|
||||
const source = read('src/app/api/generate/video/route.ts');
|
||||
|
||||
assert.match(source, /negative_prompt:\s*negativePrompt,/);
|
||||
});
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
166
scripts/test-yuanjie-pricing-sync.mjs
Normal file
166
scripts/test-yuanjie-pricing-sync.mjs
Normal file
@@ -0,0 +1,166 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, '..');
|
||||
|
||||
const {
|
||||
YUANJIE_PROVIDER_NAME,
|
||||
} = await import('../src/lib/yuanjie-image-model-templates.ts');
|
||||
const {
|
||||
getYuanjiePricingSyncTargets,
|
||||
mergeYuanjiePricingNote,
|
||||
syncYuanjiePricingMetadata,
|
||||
} = await import('../src/lib/yuanjie-pricing-sync.ts');
|
||||
|
||||
async function runTest(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`PASS ${name}`);
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${name}`);
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
function createFakeClient(rows) {
|
||||
const calls = [];
|
||||
const client = {
|
||||
calls,
|
||||
async query(sql, params = []) {
|
||||
const text = String(sql);
|
||||
calls.push({ sql: text, params });
|
||||
if (text.includes('FROM system_api_configs')) {
|
||||
if (text.includes('provider = $1')) {
|
||||
const provider = params[0];
|
||||
const types = Array.isArray(params[1]) ? params[1] : [];
|
||||
return {
|
||||
rows: rows.filter(row => row.provider === provider && types.includes(row.type)),
|
||||
};
|
||||
}
|
||||
return { rows };
|
||||
}
|
||||
if (text.includes('UPDATE system_api_configs')) {
|
||||
return { rows: [{ id: params.at(-2) || params.at(-1) }], rowCount: 1 };
|
||||
}
|
||||
return { rows: [], rowCount: 0 };
|
||||
},
|
||||
};
|
||||
return client;
|
||||
}
|
||||
|
||||
await runTest('builds Yuanjie pricing sync targets from built-in image and video templates', () => {
|
||||
const targets = getYuanjiePricingSyncTargets();
|
||||
assert.ok(targets.length >= 40, 'expected image and video templates to be represented');
|
||||
|
||||
const gptImage2 = targets.find(item => item.modelName === 'gpt-image-2');
|
||||
assert.ok(gptImage2, 'missing GPT Image 2 pricing target');
|
||||
assert.equal(gptImage2.type, 'image');
|
||||
assert.equal(gptImage2.billingMode, 'fixed');
|
||||
assert.match(gptImage2.priceNote, /元界计费同步/);
|
||||
assert.match(gptImage2.priceNote, /cost/);
|
||||
|
||||
const seedanceToken = targets.find(item => item.modelName === 'kwvideo-v2-ref');
|
||||
assert.ok(seedanceToken, 'missing Seedance token pricing target');
|
||||
assert.equal(seedanceToken.billingMode, 'token');
|
||||
assert.match(seedanceToken.priceNote, /Token/);
|
||||
|
||||
const happyhorseDuration = targets.find(item => item.modelName === 'happyhorse-t2v');
|
||||
assert.ok(happyhorseDuration, 'missing HappyHorse duration pricing target');
|
||||
assert.equal(happyhorseDuration.billingMode, 'duration');
|
||||
assert.match(happyhorseDuration.priceNote, /按秒/);
|
||||
});
|
||||
|
||||
await runTest('merges Yuanjie pricing note without deleting admin custom note', () => {
|
||||
const target = getYuanjiePricingSyncTargets().find(item => item.modelName === 'gpt-image-2');
|
||||
assert.ok(target);
|
||||
|
||||
const merged = mergeYuanjiePricingNote('管理员自定义:高峰期加价', target);
|
||||
assert.match(merged, /管理员自定义/);
|
||||
assert.match(merged, /元界计费同步/);
|
||||
|
||||
const replaced = mergeYuanjiePricingNote(merged, target);
|
||||
assert.equal((replaced.match(/元界计费同步/g) || []).length, 1);
|
||||
});
|
||||
|
||||
await runTest('sync updates only Yuanjie system API rows and leaves mozheAPI untouched', async () => {
|
||||
const client = createFakeClient([
|
||||
{
|
||||
id: '11111111-1111-1111-1111-111111111111',
|
||||
provider: YUANJIE_PROVIDER_NAME,
|
||||
model_name: 'gpt-image-2',
|
||||
type: 'image',
|
||||
price_note: '管理员自定义:保留',
|
||||
fixed_price: '12',
|
||||
credits_per_use: 12,
|
||||
},
|
||||
{
|
||||
id: '22222222-2222-2222-2222-222222222222',
|
||||
provider: 'mozheAPI',
|
||||
model_name: 'gpt-image-2',
|
||||
type: 'image',
|
||||
price_note: 'mozhe should not change',
|
||||
fixed_price: '99',
|
||||
credits_per_use: 99,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await syncYuanjiePricingMetadata(client);
|
||||
assert.equal(result.updated, 1);
|
||||
assert.equal(result.skipped, 0);
|
||||
assert.equal(result.unmatched.length, 0);
|
||||
|
||||
const selectCall = client.calls.find(call => call.sql.includes('FROM system_api_configs'));
|
||||
assert.ok(selectCall, 'expected a system_api_configs select');
|
||||
assert.match(selectCall.sql, /type\s*=\s*ANY\(\$1::text\[\]\)/);
|
||||
assert.match(selectCall.sql, /replace\(lower\(provider\)/);
|
||||
assert.match(selectCall.sql, /model_group/);
|
||||
assert.deepEqual(selectCall.params[0], ['image', 'video']);
|
||||
assert.ok(Array.isArray(selectCall.params[1]));
|
||||
assert.ok(selectCall.params[1].includes(String(YUANJIE_PROVIDER_NAME).replace(/\s+/g, '').toLowerCase()));
|
||||
|
||||
const updateCalls = client.calls.filter(call => call.sql.includes('UPDATE system_api_configs'));
|
||||
assert.equal(updateCalls.length, 1);
|
||||
assert.match(updateCalls[0].sql, /replace\(lower\(provider\)/);
|
||||
assert.match(updateCalls[0].sql, /model_group/);
|
||||
assert.equal(updateCalls[0].params.includes('22222222-2222-2222-2222-222222222222'), false);
|
||||
});
|
||||
|
||||
await runTest('sync matches Yuanjie provider name variants used by existing image configs', async () => {
|
||||
const client = createFakeClient([
|
||||
{
|
||||
id: '33333333-3333-3333-3333-333333333333',
|
||||
provider: '元界AI',
|
||||
model_group: 'default',
|
||||
model_name: 'gpt-image-2',
|
||||
type: 'image',
|
||||
price_note: '',
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await syncYuanjiePricingMetadata(client, { type: 'image' });
|
||||
assert.equal(result.matched, 1);
|
||||
assert.equal(result.updated, 1);
|
||||
assert.deepEqual(result.unmatched, []);
|
||||
});
|
||||
|
||||
await runTest('admin page exposes a manual Yuanjie pricing sync button', () => {
|
||||
const source = read('src/components/admin/api-management-tab.tsx');
|
||||
assert.match(source, /syncYuanjiePricing/);
|
||||
assert.match(source, /\/api\/admin\/system-apis\/yuanjie-pricing/);
|
||||
assert.match(source, /同步元界价格/);
|
||||
});
|
||||
|
||||
await runTest('admin route is documented and registered separately from generic smart import', () => {
|
||||
const apiReference = read('docs/codex-miaojing/api-reference.md');
|
||||
const customIntegrations = read('docs/codex-miaojing/custom-integrations.md');
|
||||
assert.match(apiReference, /\/api\/admin\/system-apis\/yuanjie-pricing/);
|
||||
assert.match(customIntegrations, /元界价格/);
|
||||
});
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import type { PoolClient, QueryResult } from 'pg';
|
||||
import { requireAdmin } from '@/lib/admin-auth';
|
||||
import { getStorageHealthStatus } from '@/lib/local-storage';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
|
||||
type DbRow = Record<string, unknown>;
|
||||
@@ -176,6 +177,7 @@ export async function GET(request: NextRequest) {
|
||||
const recommendations = firstRow(recommendationResult);
|
||||
const userApiKeys = firstRow(userApiKeyResult);
|
||||
const announcements = firstRow(announcementResult);
|
||||
const storageStatus = await getStorageHealthStatus();
|
||||
const taskRows = taskStatusResult.rows;
|
||||
const orderRows = orderStatusResult.rows;
|
||||
|
||||
@@ -267,8 +269,9 @@ export async function GET(request: NextRequest) {
|
||||
system: {
|
||||
apiHealth: true,
|
||||
databaseHealth: true,
|
||||
storageHealth: Boolean(process.env.LOCAL_STORAGE_DIR),
|
||||
storageDirConfigured: Boolean(process.env.LOCAL_STORAGE_DIR),
|
||||
storageHealth: storageStatus.ok,
|
||||
storageDirConfigured: storageStatus.local.ok || storageStatus.object.configured,
|
||||
storageBackend: storageStatus.mode,
|
||||
worksPersisted: numberValue(storage.persisted),
|
||||
worksTotal: numberValue(storage.total),
|
||||
logsTotal: numberValue(logs.total),
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdmin } from '@/lib/admin-auth';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import { localStorage } from '@/lib/local-storage';
|
||||
import crypto from 'crypto';
|
||||
|
||||
type ExportMediaEntry = {
|
||||
contentType: string;
|
||||
encoding: 'base64';
|
||||
data: string;
|
||||
size: number;
|
||||
sha256: string;
|
||||
};
|
||||
|
||||
const MAX_EXPORT_MEDIA_BYTES = 800 * 1024 * 1024;
|
||||
const MAX_EXPORT_SINGLE_MEDIA_BYTES = 100 * 1024 * 1024;
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
@@ -15,12 +28,19 @@ export async function GET(request: NextRequest) {
|
||||
'profiles',
|
||||
'works',
|
||||
'credit_transactions',
|
||||
'invitation_referrals',
|
||||
'redeem_codes',
|
||||
'orders',
|
||||
'user_api_keys',
|
||||
'system_api_configs',
|
||||
'api_providers',
|
||||
'model_recommendations',
|
||||
'payment_methods',
|
||||
'image_style_presets',
|
||||
'work_likes',
|
||||
'announcements',
|
||||
'generation_jobs',
|
||||
'platform_logs',
|
||||
];
|
||||
|
||||
for (const table of tables) {
|
||||
@@ -47,15 +67,22 @@ export async function GET(request: NextRequest) {
|
||||
data.auth_users = result.rows || [];
|
||||
} catch { data.auth_users = []; }
|
||||
|
||||
const mediaExport = await collectExportMedia(data);
|
||||
|
||||
const exportData = {
|
||||
_meta: {
|
||||
version: '1.0',
|
||||
version: '1.1',
|
||||
platform: 'miaojing',
|
||||
exported_at: new Date().toISOString(),
|
||||
tables: Object.keys(data),
|
||||
counts: Object.fromEntries(Object.entries(data).map(([k, v]) => [k, v.length])),
|
||||
media_files: Object.keys(mediaExport.media).length,
|
||||
media_bytes: mediaExport.bytes,
|
||||
media_missing: mediaExport.missing,
|
||||
media_skipped: mediaExport.skipped,
|
||||
},
|
||||
data,
|
||||
_media: mediaExport.media,
|
||||
};
|
||||
|
||||
return NextResponse.json(exportData);
|
||||
@@ -67,3 +94,112 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: err instanceof Error ? err.message : '导出失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
async function collectExportMedia(data: Record<string, unknown[]>): Promise<{
|
||||
media: Record<string, ExportMediaEntry>;
|
||||
bytes: number;
|
||||
missing: string[];
|
||||
skipped: string[];
|
||||
}> {
|
||||
const urls = new Set<string>();
|
||||
for (const row of data.works || []) {
|
||||
collectExportableMediaUrls(row, urls);
|
||||
}
|
||||
for (const row of data.site_config || []) {
|
||||
collectExportableMediaUrls(row, urls);
|
||||
}
|
||||
for (const row of data.generation_jobs || []) {
|
||||
collectExportableMediaUrls(row, urls);
|
||||
}
|
||||
|
||||
const media: Record<string, ExportMediaEntry> = {};
|
||||
const missing: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
let bytes = 0;
|
||||
|
||||
for (const url of urls) {
|
||||
const payload = await readExportMedia(url);
|
||||
if (!payload) {
|
||||
missing.push(url);
|
||||
continue;
|
||||
}
|
||||
const { buffer, contentType } = payload;
|
||||
if (buffer.byteLength > MAX_EXPORT_SINGLE_MEDIA_BYTES) {
|
||||
skipped.push(url);
|
||||
continue;
|
||||
}
|
||||
if (bytes + buffer.byteLength > MAX_EXPORT_MEDIA_BYTES) {
|
||||
skipped.push(url);
|
||||
continue;
|
||||
}
|
||||
bytes += buffer.byteLength;
|
||||
media[url] = {
|
||||
contentType,
|
||||
encoding: 'base64',
|
||||
data: buffer.toString('base64'),
|
||||
size: buffer.byteLength,
|
||||
sha256: crypto.createHash('sha256').update(buffer).digest('hex'),
|
||||
};
|
||||
}
|
||||
|
||||
return { media, bytes, missing, skipped };
|
||||
}
|
||||
|
||||
async function readExportMedia(url: string): Promise<{ buffer: Buffer; contentType: string } | null> {
|
||||
const key = localStorage.getKeyFromPublicUrl(url);
|
||||
if (key && await localStorage.fileExistsAsync(key)) {
|
||||
return { buffer: await localStorage.readFileAsync(key), contentType: getContentTypeFromKey(key) };
|
||||
}
|
||||
|
||||
if (!/^https?:\/\//i.test(url)) return null;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(20000) });
|
||||
if (!response.ok) return null;
|
||||
|
||||
const contentLength = Number(response.headers.get('content-length') || 0);
|
||||
if (contentLength > MAX_EXPORT_SINGLE_MEDIA_BYTES) return null;
|
||||
|
||||
const contentType = response.headers.get('content-type') || getContentTypeFromUrl(url);
|
||||
if (!isSupportedMediaType(contentType)) return null;
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
return { buffer, contentType };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function collectExportableMediaUrls(value: unknown, output: Set<string>): void {
|
||||
if (typeof value === 'string') {
|
||||
if (localStorage.getKeyFromPublicUrl(value) || /^https?:\/\//i.test(value)) output.add(value);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(item => collectExportableMediaUrls(item, output));
|
||||
return;
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
Object.values(value as Record<string, unknown>).forEach(item => collectExportableMediaUrls(item, output));
|
||||
}
|
||||
}
|
||||
|
||||
function isSupportedMediaType(contentType: string): boolean {
|
||||
return /^(image|video)\//i.test(contentType.split(';')[0] || '');
|
||||
}
|
||||
|
||||
function getContentTypeFromUrl(url: string): string {
|
||||
const path = url.split('?')[0] || '';
|
||||
return getContentTypeFromKey(path);
|
||||
}
|
||||
|
||||
function getContentTypeFromKey(key: string): string {
|
||||
const ext = key.split('.').pop()?.toLowerCase();
|
||||
if (ext === 'jpg' || ext === 'jpeg') return 'image/jpeg';
|
||||
if (ext === 'png') return 'image/png';
|
||||
if (ext === 'webp') return 'image/webp';
|
||||
if (ext === 'gif') return 'image/gif';
|
||||
if (ext === 'mp4') return 'video/mp4';
|
||||
if (ext === 'webm') return 'video/webm';
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdmin } from '@/lib/admin-auth';
|
||||
import { localStorage } from '@/lib/local-storage';
|
||||
import { ensureRedeemCodeSchema } from '@/lib/redeem-code-service';
|
||||
import { ensureInvitationSchema } from '@/lib/invitation-service';
|
||||
import { encryptSecret, previewSecret } from '@/lib/server-crypto';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import crypto from 'crypto';
|
||||
|
||||
interface ImportMeta {
|
||||
version: string;
|
||||
@@ -15,11 +18,21 @@ interface ImportMeta {
|
||||
interface ImportPayload {
|
||||
_meta: ImportMeta;
|
||||
data: Record<string, unknown[]>;
|
||||
_media?: Record<string, ImportMediaEntry>;
|
||||
options?: {
|
||||
skipAuth?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
type ImportMediaEntry = {
|
||||
contentType?: string;
|
||||
encoding?: 'base64';
|
||||
data?: string;
|
||||
dataUrl?: string;
|
||||
size?: number;
|
||||
sha256?: string;
|
||||
};
|
||||
|
||||
const MAX_ROWS_PER_TABLE = 5000;
|
||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const UUID_ID_TABLES = new Set([
|
||||
@@ -28,22 +41,36 @@ const UUID_ID_TABLES = new Set([
|
||||
'announcements',
|
||||
'works',
|
||||
'credit_transactions',
|
||||
'invitation_referrals',
|
||||
'redeem_codes',
|
||||
'orders',
|
||||
'user_api_keys',
|
||||
'system_api_configs',
|
||||
'api_providers',
|
||||
'model_recommendations',
|
||||
'image_style_presets',
|
||||
'work_likes',
|
||||
'generation_jobs',
|
||||
'platform_logs',
|
||||
]);
|
||||
|
||||
const TABLE_COLUMNS: Record<string, string[]> = {
|
||||
profiles: ['id', 'email', 'nickname', 'avatar_url', 'phone', 'role', 'membership_tier', 'membership_expires_at', 'credits_balance', 'daily_quota_used', 'daily_quota_limit', 'is_active', 'preferred_theme', 'created_at', 'updated_at'],
|
||||
site_config: ['id', 'site_name', 'site_tab_title', 'site_description', 'site_keywords', 'logo_url', 'favicon_url', 'announcement', 'membership_enabled', 'terms_of_service', 'privacy_policy', 'about_us', 'help_center', 'filing_info', 'filing_url', 'public_security_filing_info', 'public_security_filing_url', 'updated_at'],
|
||||
profiles: ['id', 'email', 'nickname', 'display_nickname', 'avatar_url', 'phone', 'role', 'membership_tier', 'membership_expires_at', 'credits_balance', 'daily_quota_used', 'daily_quota_limit', 'is_active', 'preferred_theme', 'watermark_disabled', 'invite_code', 'referred_by_user_id', 'created_at', 'updated_at'],
|
||||
site_config: ['id', 'site_name', 'site_tab_title', 'site_description', 'site_keywords', 'logo_url', 'favicon_url', 'announcement', 'membership_enabled', 'terms_of_service', 'privacy_policy', 'about_us', 'help_center', 'filing_info', 'filing_url', 'public_security_filing_info', 'public_security_filing_url', 'redeem_code_mall_url', 'log_retention_days', 'image_composition_skill_enabled', 'updated_at'],
|
||||
site_stats: ['id', 'total_visits', 'total_users', 'total_generations', 'updated_at'],
|
||||
announcements: ['id', 'title', 'content', 'type', 'is_active', 'starts_at', 'expires_at', 'created_at', 'updated_at'],
|
||||
works: ['id', 'user_id', 'title', 'type', 'prompt', 'negative_prompt', 'params', 'result_url', 'thumbnail_url', 'width', 'height', 'duration', 'status', 'is_public', 'likes_count', 'views_count', 'created_at', 'updated_at'],
|
||||
works: ['id', 'user_id', 'title', 'type', 'prompt', 'negative_prompt', 'params', 'result_url', 'thumbnail_url', 'width', 'height', 'duration', 'status', 'is_public', 'likes_count', 'views_count', 'credits_cost', 'created_at', 'updated_at'],
|
||||
credit_transactions: ['id', 'user_id', 'amount', 'balance_after', 'type', 'description', 'related_work_id', 'created_at'],
|
||||
invitation_referrals: ['id', 'invite_code', 'inviter_user_id', 'invitee_user_id', 'inviter_bonus_credits', 'invitee_bonus_credits', 'created_at'],
|
||||
redeem_codes: ['id', 'code', 'normalized_code', 'code_type', 'credits_amount', 'membership_tier', 'membership_duration_value', 'membership_duration_unit', 'batch_id', 'note', 'is_active', 'created_by', 'used_by', 'used_at', 'created_at', 'updated_at'],
|
||||
orders: ['id', 'user_id', 'order_no', 'product_type', 'product_name', 'amount', 'credits_amount', 'status', 'payment_method', 'paid_at', 'created_at', 'updated_at'],
|
||||
user_api_keys: ['id', 'user_id', 'provider', 'supplier_name', 'api_url', 'model_name', 'note', 'api_key_encrypted', 'api_key_preview', 'type', 'is_active', 'created_at', 'updated_at'],
|
||||
system_api_configs: ['id', 'provider', 'name', 'api_url', 'model_name', 'note', 'api_key_encrypted', 'api_key_preview', 'type', 'credits_per_use', 'is_active', 'sort_order', 'created_at', 'updated_at'],
|
||||
user_api_keys: ['id', 'user_id', 'provider', 'supplier_name', 'api_url', 'model_name', 'note', 'manifest_path', 'api_key_encrypted', 'api_key_preview', 'type', 'is_active', 'created_at', 'updated_at'],
|
||||
system_api_configs: ['id', 'provider', 'name', 'api_url', 'model_name', 'model_group', 'note', 'manifest_path', 'is_default', 'allowed_membership_tiers', 'polling_mode', 'polling_order', 'api_key_encrypted', 'api_key_preview', 'type', 'credits_per_use', 'billing_mode', 'fixed_price', 'duration_price_per_second', 'input_price_per_1k', 'output_price_per_1k', 'model_ratio', 'completion_ratio', 'group_ratio', 'price_note', 'is_active', 'sort_order', 'created_at', 'updated_at'],
|
||||
api_providers: ['id', 'name', 'default_api_url', 'default_model', 'type', 'website', 'is_active', 'sort_order', 'created_at', 'updated_at'],
|
||||
model_recommendations: ['id', 'model_name', 'display_name', 'type', 'provider_id', 'is_active', 'sort_order', 'created_at', 'updated_at'],
|
||||
generation_jobs: ['id', 'type', 'status', 'payload', 'result', 'error', 'user_id', 'provider', 'model_name', 'api_url', 'progress', 'created_at', 'started_at', 'finished_at', 'updated_at'],
|
||||
platform_logs: ['id', 'type', 'level', 'action', 'message', 'user_id', 'user_name', 'user_email', 'target_type', 'target_id', 'ip_address', 'user_agent', 'metadata', 'created_at'],
|
||||
image_style_presets: ['id', 'label', 'prompt', 'usage_count', 'is_active', 'sort_order', 'created_at', 'updated_at'],
|
||||
payment_methods: ['id', 'type', 'name', 'is_active', 'public_config', 'secret_config_encrypted', 'secret_config_preview', 'created_at', 'updated_at'],
|
||||
work_likes: ['id', 'user_id', 'work_id', 'created_at'],
|
||||
};
|
||||
@@ -59,10 +86,17 @@ const CONFLICT_COLUMNS: Record<string, string[]> = {
|
||||
announcements: ['id'],
|
||||
works: ['id'],
|
||||
credit_transactions: ['id'],
|
||||
invitation_referrals: ['id'],
|
||||
redeem_codes: ['id'],
|
||||
orders: ['id'],
|
||||
user_api_keys: ['id'],
|
||||
system_api_configs: ['id'],
|
||||
api_providers: ['id'],
|
||||
model_recommendations: ['id'],
|
||||
image_style_presets: ['id'],
|
||||
payment_methods: ['id'],
|
||||
generation_jobs: ['id'],
|
||||
platform_logs: ['id'],
|
||||
work_likes: ['id'],
|
||||
};
|
||||
|
||||
@@ -74,7 +108,9 @@ type ImportContext = {
|
||||
emailUserIdMap: Map<string, string>;
|
||||
apiKeyIdMap: Map<string, string>;
|
||||
apiKeyOwnerIdMap: Map<string, string>;
|
||||
media: Record<string, ImportMediaEntry>;
|
||||
columnCache: Map<string, Set<string>>;
|
||||
defaultableColumnCache: Map<string, Set<string>>;
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
@@ -94,7 +130,10 @@ export async function POST(request: NextRequest) {
|
||||
const result: Record<string, ImportResult> = {};
|
||||
|
||||
try {
|
||||
const context = await buildImportContext(client, data);
|
||||
const context = await buildImportContext(client, data, body._media || {});
|
||||
await ensureRedeemCodeSchema(client);
|
||||
await ensureInvitationSchema(client);
|
||||
await client.query('BEGIN');
|
||||
|
||||
if (!skipAuth && Array.isArray(data.auth_users)) {
|
||||
result.auth_users = await importRows(client, 'auth.users', AUTH_USER_COLUMNS, data.auth_users, context);
|
||||
@@ -111,7 +150,13 @@ export async function POST(request: NextRequest) {
|
||||
result[table] = await importRows(client, table, allowedColumns, Array.isArray(rows) ? rows : [], context);
|
||||
}
|
||||
|
||||
result.dedupe_works = await dedupeWorks(client);
|
||||
await client.query('COMMIT');
|
||||
|
||||
return NextResponse.json({ success: true, message: '数据导入完成', details: result, meta: _meta });
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK').catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
@@ -136,18 +181,26 @@ async function importRows(
|
||||
let skipped = 0;
|
||||
const errors: string[] = [];
|
||||
const existingColumns = await getExistingColumns(client, table, context);
|
||||
const defaultableColumns = await getDefaultableColumns(client, table, context);
|
||||
const effectiveAllowedColumns = allowedColumns.filter(col => existingColumns.has(col));
|
||||
|
||||
for (const rawRow of rows) {
|
||||
const row = await normalizeImportRow(table, rawRow as Record<string, unknown>, context);
|
||||
const cols = Object.keys(row).filter(col => effectiveAllowedColumns.includes(col));
|
||||
if (!cols.includes('id') || cols.length === 0) {
|
||||
skipped++;
|
||||
errors.push(`${table}: 缺少 id 或没有允许导入的字段`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
|
||||
const savepoint = `import_row_${table.replace(/[^a-zA-Z0-9_]/g, '_')}_${rowIndex}`;
|
||||
try {
|
||||
await client.query(`SAVEPOINT ${savepoint}`);
|
||||
const rawRow = rows[rowIndex];
|
||||
const row = await normalizeImportRow(table, rawRow as Record<string, unknown>, context);
|
||||
const cols = Object.keys(row).filter(col => (
|
||||
effectiveAllowedColumns.includes(col)
|
||||
&& !(row[col] == null && defaultableColumns.has(col))
|
||||
));
|
||||
if (!cols.includes('id') || cols.length === 0) {
|
||||
skipped++;
|
||||
errors.push(`${table}: 缺少 id 或没有允许导入的字段`);
|
||||
await client.query(`RELEASE SAVEPOINT ${savepoint}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const vals = cols.map(col => row[col]);
|
||||
const placeholders = cols.map((_, i) => `$${i + 1}`).join(', ');
|
||||
const conflictCols = CONFLICT_COLUMNS[table] || ['id'];
|
||||
@@ -166,7 +219,10 @@ async function importRows(
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
await client.query(`RELEASE SAVEPOINT ${savepoint}`);
|
||||
} catch (e) {
|
||||
await client.query(`ROLLBACK TO SAVEPOINT ${savepoint}`).catch(() => undefined);
|
||||
await client.query(`RELEASE SAVEPOINT ${savepoint}`).catch(() => undefined);
|
||||
skipped++;
|
||||
errors.push(`${table}: ${e instanceof Error ? e.message : 'unknown error'}`);
|
||||
}
|
||||
@@ -178,6 +234,7 @@ async function importRows(
|
||||
async function buildImportContext(
|
||||
client: Awaited<ReturnType<typeof getDbClient>>,
|
||||
data: Record<string, unknown[]>,
|
||||
media: Record<string, ImportMediaEntry>,
|
||||
): Promise<ImportContext> {
|
||||
const userIdMap = new Map<string, string>();
|
||||
const workIdMap = new Map<string, string>();
|
||||
@@ -235,7 +292,16 @@ async function buildImportContext(
|
||||
apiKeyIdMap.set(oldId, isUuid(oldId) ? oldId : crypto.randomUUID());
|
||||
}
|
||||
const ownerId = findImportedWorkUserId(row);
|
||||
const ownerByEmail = findUserIdByEmail(row, { userIdMap, workIdMap, emailUserIdMap, apiKeyIdMap, apiKeyOwnerIdMap, columnCache: new Map() });
|
||||
const ownerByEmail = findUserIdByEmail(row, {
|
||||
userIdMap,
|
||||
workIdMap,
|
||||
emailUserIdMap,
|
||||
apiKeyIdMap,
|
||||
apiKeyOwnerIdMap,
|
||||
media,
|
||||
columnCache: new Map(),
|
||||
defaultableColumnCache: new Map(),
|
||||
});
|
||||
const mappedOwnerId = ownerId
|
||||
? (userIdMap.get(ownerId) || ownerId)
|
||||
: ownerByEmail;
|
||||
@@ -246,27 +312,75 @@ async function buildImportContext(
|
||||
|
||||
const works = Array.isArray(data.works) ? data.works : [];
|
||||
const workUrls = new Map<string, string>();
|
||||
const workMediaShas = new Map<string, string>();
|
||||
const partialContext: ImportContext = {
|
||||
userIdMap,
|
||||
workIdMap,
|
||||
emailUserIdMap,
|
||||
apiKeyIdMap,
|
||||
apiKeyOwnerIdMap,
|
||||
media,
|
||||
columnCache: new Map(),
|
||||
defaultableColumnCache: new Map(),
|
||||
};
|
||||
for (const raw of works) {
|
||||
const row = raw as Record<string, unknown>;
|
||||
seedUuidMap(workIdMap, row.id);
|
||||
if (typeof row.id === 'string' && typeof row.result_url === 'string' && row.result_url.trim() && !isDataUrl(row.result_url)) {
|
||||
workUrls.set(row.result_url.trim(), row.id);
|
||||
const ownerId = findImportedWorkUserId(row) || findUserIdByEmail(row, partialContext) || findUserIdByCustomModel(row, partialContext);
|
||||
const mappedOwnerId = ownerId ? (userIdMap.get(ownerId) || ownerId) : '';
|
||||
if (mappedOwnerId && typeof row.id === 'string' && typeof row.result_url === 'string' && row.result_url.trim()) {
|
||||
if (!isDataUrl(row.result_url)) {
|
||||
workUrls.set(workDedupeKey(mappedOwnerId, row.result_url.trim()), row.id);
|
||||
}
|
||||
const mediaSha = getImportMediaSha256(row.result_url, media);
|
||||
if (mediaSha) {
|
||||
workMediaShas.set(workDedupeKey(mappedOwnerId, mediaSha), row.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (workUrls.size > 0) {
|
||||
if (workUrls.size > 0 || workMediaShas.size > 0) {
|
||||
const urls = [...new Set([...workUrls.keys()].map(splitWorkDedupeKeyValue))];
|
||||
const shas = [...new Set([...workMediaShas.keys()].map(splitWorkDedupeKeyValue))];
|
||||
const existing = await client.query(
|
||||
'SELECT id, result_url FROM works WHERE result_url = ANY($1)',
|
||||
[[...workUrls.keys()]],
|
||||
`SELECT id, user_id, result_url, params
|
||||
FROM works
|
||||
WHERE result_url = ANY($1)
|
||||
OR params->>'importSourceUrl' = ANY($1)
|
||||
OR params->>'resultMediaSha256' = ANY($2)`,
|
||||
[urls, shas],
|
||||
);
|
||||
for (const row of existing.rows) {
|
||||
const importedId = workUrls.get(row.result_url);
|
||||
const existingOwnerId = row.user_id ? String(row.user_id) : '';
|
||||
const importedId = existingOwnerId && row.result_url
|
||||
? workUrls.get(workDedupeKey(existingOwnerId, String(row.result_url)))
|
||||
: undefined;
|
||||
if (importedId && importedId !== row.id) {
|
||||
workIdMap.set(importedId, row.id);
|
||||
}
|
||||
const params = (row.params || {}) as Record<string, unknown>;
|
||||
const sourceUrl = typeof params.importSourceUrl === 'string' ? params.importSourceUrl : '';
|
||||
const sourceMatchId = existingOwnerId && sourceUrl ? workUrls.get(workDedupeKey(existingOwnerId, sourceUrl)) : undefined;
|
||||
if (sourceMatchId && sourceMatchId !== row.id) {
|
||||
workIdMap.set(sourceMatchId, row.id);
|
||||
}
|
||||
const sha = typeof params.resultMediaSha256 === 'string' ? params.resultMediaSha256 : '';
|
||||
const shaMatchId = existingOwnerId && sha ? workMediaShas.get(workDedupeKey(existingOwnerId, sha)) : undefined;
|
||||
if (shaMatchId && shaMatchId !== row.id) {
|
||||
workIdMap.set(shaMatchId, row.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { userIdMap, workIdMap, emailUserIdMap, apiKeyIdMap, apiKeyOwnerIdMap, columnCache: new Map() };
|
||||
return {
|
||||
userIdMap,
|
||||
workIdMap,
|
||||
emailUserIdMap,
|
||||
apiKeyIdMap,
|
||||
apiKeyOwnerIdMap,
|
||||
media,
|
||||
columnCache: new Map(),
|
||||
defaultableColumnCache: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
async function normalizeImportRow(table: string, row: Record<string, unknown>, context: ImportContext): Promise<Record<string, unknown>> {
|
||||
@@ -290,6 +404,18 @@ async function normalizeImportRow(table: string, row: Record<string, unknown>, c
|
||||
if (currentId && context.userIdMap.has(currentId)) {
|
||||
next.id = context.userIdMap.get(currentId);
|
||||
}
|
||||
if (table === 'profiles' && typeof next.referred_by_user_id === 'string' && context.userIdMap.has(next.referred_by_user_id)) {
|
||||
next.referred_by_user_id = context.userIdMap.get(next.referred_by_user_id);
|
||||
}
|
||||
}
|
||||
|
||||
if (table === 'invitation_referrals') {
|
||||
if (typeof next.inviter_user_id === 'string' && context.userIdMap.has(next.inviter_user_id)) {
|
||||
next.inviter_user_id = context.userIdMap.get(next.inviter_user_id);
|
||||
}
|
||||
if (typeof next.invitee_user_id === 'string' && context.userIdMap.has(next.invitee_user_id)) {
|
||||
next.invitee_user_id = context.userIdMap.get(next.invitee_user_id);
|
||||
}
|
||||
}
|
||||
|
||||
if (table === 'user_api_keys') {
|
||||
@@ -315,33 +441,80 @@ async function normalizeImportRow(table: string, row: Record<string, unknown>, c
|
||||
if (importedUserId) {
|
||||
next.user_id = context.userIdMap.get(importedUserId) || importedUserId;
|
||||
}
|
||||
if (typeof next.result_url === 'string') {
|
||||
next.result_url = await persistImportMedia(next.result_url, getWorkMediaFolder(next.type, 'results'));
|
||||
}
|
||||
if (typeof next.thumbnail_url === 'string') {
|
||||
next.thumbnail_url = await persistImportMedia(next.thumbnail_url, 'imported/works/thumbnails');
|
||||
}
|
||||
if (next.params && typeof next.params === 'object') {
|
||||
next.params = await sanitizeImportMedia(next.params, 'imported/works/references');
|
||||
next.params = { ...(next.params as Record<string, unknown>) };
|
||||
remapCustomModelId(next.params as Record<string, unknown>, context);
|
||||
if ((!next.user_id || next.user_id === SYSTEM_USER_ID) && findUserIdByCustomModel(next, context)) {
|
||||
next.user_id = findUserIdByCustomModel(next, context);
|
||||
}
|
||||
} else {
|
||||
next.params = {};
|
||||
}
|
||||
if (typeof next.result_url === 'string') {
|
||||
const originalResultUrl = next.result_url;
|
||||
const mediaSha = getImportMediaSha256(originalResultUrl, context.media) || getDataUrlSha256(originalResultUrl);
|
||||
next.result_url = await persistImportMedia(originalResultUrl, getWorkMediaFolder(next.type, 'results'), context);
|
||||
if (mediaSha && next.params && typeof next.params === 'object') {
|
||||
(next.params as Record<string, unknown>).importSourceUrl = originalResultUrl;
|
||||
(next.params as Record<string, unknown>).resultMediaSha256 = mediaSha;
|
||||
}
|
||||
}
|
||||
if (typeof next.thumbnail_url === 'string') {
|
||||
next.thumbnail_url = await persistImportMedia(next.thumbnail_url, 'imported/works/thumbnails', context);
|
||||
}
|
||||
if (next.params && typeof next.params === 'object') {
|
||||
next.params = await sanitizeImportMedia(next.params, 'imported/works/references', context);
|
||||
}
|
||||
}
|
||||
|
||||
if (table === 'generation_jobs') {
|
||||
if (next.payload && typeof next.payload === 'object') {
|
||||
next.payload = await sanitizeImportMedia(next.payload, 'imported/jobs/payload', context);
|
||||
}
|
||||
if (next.result && typeof next.result === 'object') {
|
||||
next.result = await sanitizeImportMedia(next.result, 'imported/jobs/results', context);
|
||||
}
|
||||
}
|
||||
|
||||
if (table === 'user_api_keys') {
|
||||
if (typeof next.note !== 'string' || next.note.trim() === '') {
|
||||
next.note = '导入的 API Key';
|
||||
}
|
||||
if (typeof next.type !== 'string' || next.type.trim() === '') {
|
||||
next.type = 'image';
|
||||
}
|
||||
const rawEncrypted = typeof next.api_key_encrypted === 'string' ? next.api_key_encrypted.trim() : '';
|
||||
const rawApiKey = typeof next.apiKey === 'string' ? next.apiKey.trim() : '';
|
||||
const secret = rawApiKey || rawEncrypted;
|
||||
if (secret) {
|
||||
next.api_key_encrypted = encryptSecret(secret);
|
||||
if (rawApiKey) {
|
||||
next.api_key_encrypted = encryptSecret(rawApiKey);
|
||||
next.api_key_preview = typeof next.api_key_preview === 'string' && next.api_key_preview
|
||||
? next.api_key_preview
|
||||
: previewSecret(secret);
|
||||
: previewSecret(rawApiKey);
|
||||
} else if (rawEncrypted) {
|
||||
next.api_key_encrypted = rawEncrypted;
|
||||
next.api_key_preview = typeof next.api_key_preview === 'string' ? next.api_key_preview : '';
|
||||
}
|
||||
}
|
||||
|
||||
if (table === 'redeem_codes') {
|
||||
if (typeof next.created_by === 'string' && context.userIdMap.has(next.created_by)) {
|
||||
next.created_by = context.userIdMap.get(next.created_by);
|
||||
}
|
||||
if (typeof next.used_by === 'string' && context.userIdMap.has(next.used_by)) {
|
||||
next.used_by = context.userIdMap.get(next.used_by);
|
||||
}
|
||||
if (typeof next.normalized_code !== 'string' || next.normalized_code.trim() === '') {
|
||||
next.normalized_code = typeof next.code === 'string'
|
||||
? next.code.replace(/[^a-z0-9]/gi, '').toUpperCase()
|
||||
: '';
|
||||
}
|
||||
if (next.code_type !== 'membership') next.code_type = 'credits';
|
||||
if (next.code_type === 'credits' && typeof next.credits_amount !== 'number') {
|
||||
next.credits_amount = Number(next.credits_amount || 0);
|
||||
}
|
||||
if (typeof next.note !== 'string') next.note = '';
|
||||
}
|
||||
|
||||
if (UUID_ID_TABLES.has(table)) {
|
||||
const currentId = typeof next.id === 'string' ? next.id : '';
|
||||
if (!isUuid(currentId)) {
|
||||
@@ -430,6 +603,7 @@ function getMergeAssignments(table: string, cols: string[]): string[] {
|
||||
if (table === 'profiles') {
|
||||
if (has('email')) assignments.push(`email = COALESCE(NULLIF(target.email, ''), EXCLUDED.email)`);
|
||||
if (has('nickname')) assignments.push(`nickname = COALESCE(NULLIF(target.nickname, ''), EXCLUDED.nickname)`);
|
||||
if (has('display_nickname')) assignments.push(`display_nickname = COALESCE(NULLIF(target.display_nickname, ''), EXCLUDED.display_nickname)`);
|
||||
if (has('avatar_url')) assignments.push(`avatar_url = COALESCE(NULLIF(target.avatar_url, ''), EXCLUDED.avatar_url)`);
|
||||
if (has('phone')) assignments.push(`phone = COALESCE(NULLIF(target.phone, ''), EXCLUDED.phone)`);
|
||||
if (has('role')) assignments.push(`role = CASE WHEN target.role = 'admin' THEN target.role ELSE COALESCE(NULLIF(target.role, ''), EXCLUDED.role) END`);
|
||||
@@ -439,6 +613,9 @@ function getMergeAssignments(table: string, cols: string[]): string[] {
|
||||
if (has('daily_quota_limit')) assignments.push(`daily_quota_limit = COALESCE(target.daily_quota_limit, EXCLUDED.daily_quota_limit)`);
|
||||
if (has('is_active')) assignments.push(`is_active = COALESCE(target.is_active, EXCLUDED.is_active)`);
|
||||
if (has('preferred_theme')) assignments.push(`preferred_theme = CASE WHEN EXCLUDED.preferred_theme IN ('dark', 'light') THEN EXCLUDED.preferred_theme ELSE target.preferred_theme END`);
|
||||
if (has('watermark_disabled')) assignments.push(`watermark_disabled = COALESCE(EXCLUDED.watermark_disabled, target.watermark_disabled)`);
|
||||
if (has('invite_code')) assignments.push(`invite_code = COALESCE(NULLIF(target.invite_code, ''), EXCLUDED.invite_code)`);
|
||||
if (has('referred_by_user_id')) assignments.push(`referred_by_user_id = COALESCE(target.referred_by_user_id, EXCLUDED.referred_by_user_id)`);
|
||||
if (has('updated_at')) assignments.push(`updated_at = GREATEST(COALESCE(target.updated_at, EXCLUDED.updated_at), COALESCE(EXCLUDED.updated_at, target.updated_at))`);
|
||||
return assignments;
|
||||
}
|
||||
@@ -452,6 +629,7 @@ function getMergeAssignments(table: string, cols: string[]): string[] {
|
||||
if (has('width')) assignments.push(`width = COALESCE(target.width, EXCLUDED.width)`);
|
||||
if (has('height')) assignments.push(`height = COALESCE(target.height, EXCLUDED.height)`);
|
||||
if (has('duration')) assignments.push(`duration = COALESCE(target.duration, EXCLUDED.duration)`);
|
||||
if (has('credits_cost')) assignments.push(`credits_cost = COALESCE(target.credits_cost, EXCLUDED.credits_cost)`);
|
||||
if (has('updated_at')) assignments.push(`updated_at = GREATEST(COALESCE(target.updated_at, EXCLUDED.updated_at), COALESCE(EXCLUDED.updated_at, target.updated_at))`);
|
||||
return assignments;
|
||||
}
|
||||
@@ -463,6 +641,7 @@ function getMergeAssignments(table: string, cols: string[]): string[] {
|
||||
if (has('api_url')) assignments.push(`api_url = COALESCE(NULLIF(target.api_url, ''), EXCLUDED.api_url)`);
|
||||
if (has('model_name')) assignments.push(`model_name = COALESCE(NULLIF(target.model_name, ''), EXCLUDED.model_name)`);
|
||||
if (has('note')) assignments.push(`note = COALESCE(NULLIF(target.note, ''), EXCLUDED.note)`);
|
||||
if (has('manifest_path')) assignments.push(`manifest_path = COALESCE(NULLIF(target.manifest_path, ''), EXCLUDED.manifest_path)`);
|
||||
if (has('api_key_encrypted')) assignments.push(`api_key_encrypted = COALESCE(NULLIF(target.api_key_encrypted, ''), EXCLUDED.api_key_encrypted)`);
|
||||
if (has('api_key_preview')) assignments.push(`api_key_preview = COALESCE(NULLIF(target.api_key_preview, ''), EXCLUDED.api_key_preview)`);
|
||||
if (has('type')) assignments.push(`type = COALESCE(NULLIF(target.type, ''), EXCLUDED.type)`);
|
||||
@@ -476,11 +655,26 @@ function getMergeAssignments(table: string, cols: string[]): string[] {
|
||||
if (has('name')) assignments.push(`name = COALESCE(NULLIF(target.name, ''), EXCLUDED.name)`);
|
||||
if (has('api_url')) assignments.push(`api_url = COALESCE(NULLIF(target.api_url, ''), EXCLUDED.api_url)`);
|
||||
if (has('model_name')) assignments.push(`model_name = COALESCE(NULLIF(target.model_name, ''), EXCLUDED.model_name)`);
|
||||
if (has('model_group')) assignments.push(`model_group = COALESCE(NULLIF(target.model_group, ''), EXCLUDED.model_group)`);
|
||||
if (has('note')) assignments.push(`note = COALESCE(NULLIF(target.note, ''), EXCLUDED.note)`);
|
||||
if (has('manifest_path')) assignments.push(`manifest_path = COALESCE(NULLIF(target.manifest_path, ''), EXCLUDED.manifest_path)`);
|
||||
if (has('is_default')) assignments.push(`is_default = COALESCE(target.is_default, EXCLUDED.is_default)`);
|
||||
if (has('allowed_membership_tiers')) assignments.push(`allowed_membership_tiers = COALESCE(target.allowed_membership_tiers, EXCLUDED.allowed_membership_tiers)`);
|
||||
if (has('polling_mode')) assignments.push(`polling_mode = COALESCE(target.polling_mode, EXCLUDED.polling_mode)`);
|
||||
if (has('polling_order')) assignments.push(`polling_order = COALESCE(target.polling_order, EXCLUDED.polling_order)`);
|
||||
if (has('api_key_encrypted')) assignments.push(`api_key_encrypted = COALESCE(NULLIF(target.api_key_encrypted, ''), EXCLUDED.api_key_encrypted)`);
|
||||
if (has('api_key_preview')) assignments.push(`api_key_preview = COALESCE(NULLIF(target.api_key_preview, ''), EXCLUDED.api_key_preview)`);
|
||||
if (has('type')) assignments.push(`type = COALESCE(NULLIF(target.type, ''), EXCLUDED.type)`);
|
||||
if (has('credits_per_use')) assignments.push(`credits_per_use = COALESCE(target.credits_per_use, EXCLUDED.credits_per_use)`);
|
||||
if (has('billing_mode')) assignments.push(`billing_mode = COALESCE(NULLIF(target.billing_mode, ''), EXCLUDED.billing_mode)`);
|
||||
if (has('fixed_price')) assignments.push(`fixed_price = COALESCE(target.fixed_price, EXCLUDED.fixed_price)`);
|
||||
if (has('duration_price_per_second')) assignments.push(`duration_price_per_second = COALESCE(target.duration_price_per_second, EXCLUDED.duration_price_per_second)`);
|
||||
if (has('input_price_per_1k')) assignments.push(`input_price_per_1k = COALESCE(target.input_price_per_1k, EXCLUDED.input_price_per_1k)`);
|
||||
if (has('output_price_per_1k')) assignments.push(`output_price_per_1k = COALESCE(target.output_price_per_1k, EXCLUDED.output_price_per_1k)`);
|
||||
if (has('model_ratio')) assignments.push(`model_ratio = COALESCE(target.model_ratio, EXCLUDED.model_ratio)`);
|
||||
if (has('completion_ratio')) assignments.push(`completion_ratio = COALESCE(target.completion_ratio, EXCLUDED.completion_ratio)`);
|
||||
if (has('group_ratio')) assignments.push(`group_ratio = COALESCE(target.group_ratio, EXCLUDED.group_ratio)`);
|
||||
if (has('price_note')) assignments.push(`price_note = COALESCE(NULLIF(target.price_note, ''), EXCLUDED.price_note)`);
|
||||
if (has('is_active')) assignments.push(`is_active = COALESCE(target.is_active, EXCLUDED.is_active)`);
|
||||
if (has('sort_order')) assignments.push(`sort_order = COALESCE(target.sort_order, EXCLUDED.sort_order)`);
|
||||
if (has('updated_at')) assignments.push(`updated_at = GREATEST(COALESCE(target.updated_at, EXCLUDED.updated_at), COALESCE(EXCLUDED.updated_at, target.updated_at))`);
|
||||
@@ -498,6 +692,21 @@ function getMergeAssignments(table: string, cols: string[]): string[] {
|
||||
return assignments;
|
||||
}
|
||||
|
||||
if (table === 'redeem_codes') {
|
||||
if (has('code_type')) assignments.push(`code_type = COALESCE(NULLIF(target.code_type, ''), EXCLUDED.code_type)`);
|
||||
if (has('credits_amount')) assignments.push(`credits_amount = COALESCE(target.credits_amount, EXCLUDED.credits_amount)`);
|
||||
if (has('membership_tier')) assignments.push(`membership_tier = COALESCE(target.membership_tier, EXCLUDED.membership_tier)`);
|
||||
if (has('membership_duration_value')) assignments.push(`membership_duration_value = COALESCE(target.membership_duration_value, EXCLUDED.membership_duration_value)`);
|
||||
if (has('membership_duration_unit')) assignments.push(`membership_duration_unit = COALESCE(target.membership_duration_unit, EXCLUDED.membership_duration_unit)`);
|
||||
if (has('note')) assignments.push(`note = COALESCE(NULLIF(target.note, ''), EXCLUDED.note)`);
|
||||
if (has('is_active')) assignments.push(`is_active = CASE WHEN target.used_at IS NOT NULL THEN target.is_active ELSE COALESCE(target.is_active, EXCLUDED.is_active) END`);
|
||||
if (has('created_by')) assignments.push(`created_by = COALESCE(target.created_by, EXCLUDED.created_by)`);
|
||||
if (has('used_by')) assignments.push(`used_by = COALESCE(target.used_by, EXCLUDED.used_by)`);
|
||||
if (has('used_at')) assignments.push(`used_at = COALESCE(target.used_at, EXCLUDED.used_at)`);
|
||||
if (has('updated_at')) assignments.push(`updated_at = GREATEST(COALESCE(target.updated_at, EXCLUDED.updated_at), COALESCE(EXCLUDED.updated_at, target.updated_at))`);
|
||||
return assignments;
|
||||
}
|
||||
|
||||
return assignments;
|
||||
}
|
||||
|
||||
@@ -519,6 +728,29 @@ async function getExistingColumns(
|
||||
return columns;
|
||||
}
|
||||
|
||||
async function getDefaultableColumns(
|
||||
client: Awaited<ReturnType<typeof getDbClient>>,
|
||||
table: string,
|
||||
context: ImportContext,
|
||||
): Promise<Set<string>> {
|
||||
const cached = context.defaultableColumnCache.get(table);
|
||||
if (cached) return cached;
|
||||
|
||||
const [schemaName, tableName] = table.includes('.') ? table.split('.', 2) : ['public', table];
|
||||
const result = await client.query(
|
||||
`SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = $1
|
||||
AND table_name = $2
|
||||
AND is_nullable = 'NO'
|
||||
AND column_default IS NOT NULL`,
|
||||
[schemaName, tableName],
|
||||
);
|
||||
const columns = new Set((result.rows || []).map((row: Record<string, unknown>) => String(row.column_name)));
|
||||
context.defaultableColumnCache.set(table, columns);
|
||||
return columns;
|
||||
}
|
||||
|
||||
function seedUuidMap(map: Map<string, string>, value: unknown): void {
|
||||
if (typeof value === 'string' && value && !isUuid(value) && !map.has(value)) {
|
||||
map.set(value, crypto.randomUUID());
|
||||
@@ -533,6 +765,14 @@ function isDataUrl(value: unknown): boolean {
|
||||
return typeof value === 'string' && /^data:[^,]+,/i.test(value);
|
||||
}
|
||||
|
||||
function workDedupeKey(userId: string, value: string): string {
|
||||
return `${userId}\u0000${value}`;
|
||||
}
|
||||
|
||||
function splitWorkDedupeKeyValue(key: string): string {
|
||||
return key.slice(key.indexOf('\u0000') + 1);
|
||||
}
|
||||
|
||||
function getWorkMediaFolder(type: unknown, kind: string): string {
|
||||
const text = typeof type === 'string' ? type.toLowerCase() : '';
|
||||
const media = text.includes('video') ? 'videos' : 'images';
|
||||
@@ -550,35 +790,176 @@ function extensionFromMime(mime: string): string {
|
||||
return 'bin';
|
||||
}
|
||||
|
||||
async function persistImportMedia(value: string, folder: string): Promise<string> {
|
||||
if (!isDataUrl(value)) return value;
|
||||
function getImportMediaSha256(value: string, media: Record<string, ImportMediaEntry>): string | null {
|
||||
const entry = media[value];
|
||||
if (!entry) return null;
|
||||
if (typeof entry.sha256 === 'string' && /^[a-f0-9]{64}$/i.test(entry.sha256)) {
|
||||
return entry.sha256.toLowerCase();
|
||||
}
|
||||
const decoded = decodeImportMediaEntry(entry);
|
||||
return decoded ? decoded.sha256 : null;
|
||||
}
|
||||
|
||||
function getDataUrlSha256(value: string): string | null {
|
||||
if (!isDataUrl(value)) return null;
|
||||
const decoded = decodeDataUrl(value);
|
||||
return decoded ? decoded.sha256 : null;
|
||||
}
|
||||
|
||||
function decodeDataUrl(value: string): { buffer: Buffer; mime: string; sha256: string } | null {
|
||||
const match = value.match(/^data:([^;,]+)?(;base64)?,([\s\S]*)$/i);
|
||||
if (!match) return value;
|
||||
if (!match) return null;
|
||||
|
||||
const mime = match[1] || 'application/octet-stream';
|
||||
const isBase64 = Boolean(match[2]);
|
||||
const payload = match[3] || '';
|
||||
const buffer = isBase64 ? Buffer.from(payload, 'base64') : Buffer.from(decodeURIComponent(payload));
|
||||
return {
|
||||
buffer,
|
||||
mime,
|
||||
sha256: crypto.createHash('sha256').update(buffer).digest('hex'),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeImportMediaEntry(entry: ImportMediaEntry): { buffer: Buffer; mime: string; sha256: string } | null {
|
||||
if (typeof entry.dataUrl === 'string' && entry.dataUrl.trim()) {
|
||||
return decodeDataUrl(entry.dataUrl.trim());
|
||||
}
|
||||
if (entry.encoding === 'base64' && typeof entry.data === 'string') {
|
||||
const buffer = Buffer.from(entry.data, 'base64');
|
||||
return {
|
||||
buffer,
|
||||
mime: entry.contentType || 'application/octet-stream',
|
||||
sha256: crypto.createHash('sha256').update(buffer).digest('hex'),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function persistImportMedia(value: string, folder: string, context?: ImportContext): Promise<string> {
|
||||
const entry = context?.media[value];
|
||||
const decoded = entry ? decodeImportMediaEntry(entry) : decodeDataUrl(value);
|
||||
if (!decoded) return value;
|
||||
|
||||
const { buffer, mime, sha256 } = decoded;
|
||||
const ext = extensionFromMime(mime);
|
||||
const key = `${folder}/${Date.now()}-${crypto.randomUUID()}.${ext}`;
|
||||
const key = `${folder}/${sha256}.${ext}`;
|
||||
const savedKey = await localStorage.uploadFile({ fileContent: buffer, fileName: key, contentType: mime });
|
||||
return localStorage.generatePresignedUrl({ key: savedKey, expireTime: 2592000 });
|
||||
}
|
||||
|
||||
async function sanitizeImportMedia(value: unknown, folder: string): Promise<unknown> {
|
||||
async function sanitizeImportMedia(value: unknown, folder: string, context: ImportContext): Promise<unknown> {
|
||||
if (typeof value === 'string') {
|
||||
return persistImportMedia(value, folder);
|
||||
return persistImportMedia(value, folder, context);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return Promise.all(value.map(item => sanitizeImportMedia(item, folder)));
|
||||
return Promise.all(value.map(item => sanitizeImportMedia(item, folder, context)));
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
|
||||
output[key] = await sanitizeImportMedia(nested, folder);
|
||||
output[key] = await sanitizeImportMedia(nested, folder, context);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function dedupeWorks(client: Awaited<ReturnType<typeof getDbClient>>): Promise<ImportResult> {
|
||||
const errors: string[] = [];
|
||||
let removed = 0;
|
||||
|
||||
for (const expression of [
|
||||
"NULLIF(result_url, '')",
|
||||
"NULLIF(params->>'importSourceUrl', '')",
|
||||
"NULLIF(params->>'resultMediaSha256', '')",
|
||||
]) {
|
||||
try {
|
||||
await client.query(`
|
||||
WITH ranked AS (
|
||||
SELECT id,
|
||||
FIRST_VALUE(id) OVER (
|
||||
PARTITION BY user_id, ${expression}
|
||||
ORDER BY is_public DESC, updated_at DESC NULLS LAST, created_at DESC NULLS LAST, id DESC
|
||||
) AS keep_id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY user_id, ${expression}
|
||||
ORDER BY is_public DESC, updated_at DESC NULLS LAST, created_at DESC NULLS LAST, id DESC
|
||||
) AS rn
|
||||
FROM works
|
||||
WHERE ${expression} IS NOT NULL
|
||||
),
|
||||
duplicates AS (
|
||||
SELECT id, keep_id FROM ranked WHERE rn > 1
|
||||
)
|
||||
DELETE FROM work_likes wl
|
||||
USING duplicates d, work_likes kept
|
||||
WHERE wl.work_id = d.id
|
||||
AND kept.work_id = d.keep_id
|
||||
AND kept.user_id = wl.user_id
|
||||
`);
|
||||
await client.query(`
|
||||
WITH ranked AS (
|
||||
SELECT id,
|
||||
FIRST_VALUE(id) OVER (
|
||||
PARTITION BY user_id, ${expression}
|
||||
ORDER BY is_public DESC, updated_at DESC NULLS LAST, created_at DESC NULLS LAST, id DESC
|
||||
) AS keep_id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY user_id, ${expression}
|
||||
ORDER BY is_public DESC, updated_at DESC NULLS LAST, created_at DESC NULLS LAST, id DESC
|
||||
) AS rn
|
||||
FROM works
|
||||
WHERE ${expression} IS NOT NULL
|
||||
),
|
||||
duplicates AS (
|
||||
SELECT id, keep_id FROM ranked WHERE rn > 1
|
||||
)
|
||||
UPDATE work_likes wl
|
||||
SET work_id = d.keep_id
|
||||
FROM duplicates d
|
||||
WHERE wl.work_id = d.id
|
||||
`);
|
||||
await client.query(`
|
||||
WITH ranked AS (
|
||||
SELECT id,
|
||||
FIRST_VALUE(id) OVER (
|
||||
PARTITION BY user_id, ${expression}
|
||||
ORDER BY is_public DESC, updated_at DESC NULLS LAST, created_at DESC NULLS LAST, id DESC
|
||||
) AS keep_id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY ${expression}
|
||||
ORDER BY is_public DESC, updated_at DESC NULLS LAST, created_at DESC NULLS LAST, id DESC
|
||||
) AS rn
|
||||
FROM works
|
||||
WHERE ${expression} IS NOT NULL
|
||||
),
|
||||
duplicates AS (
|
||||
SELECT id, keep_id FROM ranked WHERE rn > 1
|
||||
)
|
||||
UPDATE credit_transactions ct
|
||||
SET related_work_id = d.keep_id
|
||||
FROM duplicates d
|
||||
WHERE ct.related_work_id = d.id
|
||||
`);
|
||||
const result = await client.query(`
|
||||
WITH ranked AS (
|
||||
SELECT id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY ${expression}
|
||||
ORDER BY is_public DESC, updated_at DESC NULLS LAST, created_at DESC NULLS LAST, id DESC
|
||||
) AS rn
|
||||
FROM works
|
||||
WHERE ${expression} IS NOT NULL
|
||||
)
|
||||
DELETE FROM works
|
||||
WHERE id IN (SELECT id FROM ranked WHERE rn > 1)
|
||||
`);
|
||||
removed += result.rowCount || 0;
|
||||
} catch (error) {
|
||||
errors.push(error instanceof Error ? error.message : '作品去重失败');
|
||||
}
|
||||
}
|
||||
|
||||
return { imported: 0, skipped: removed, errors };
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ function mapRecipient(row: Record<string, unknown>) {
|
||||
return {
|
||||
id: String(row.id),
|
||||
email,
|
||||
nickname: typeof row.nickname === 'string' && row.nickname.trim() ? row.nickname.trim() : email.split('@')[0],
|
||||
nickname: typeof row.display_nickname === 'string' && row.display_nickname.trim()
|
||||
? row.display_nickname.trim()
|
||||
: typeof row.nickname === 'string' && row.nickname.trim() ? row.nickname.trim() : email.split('@')[0],
|
||||
phone: typeof row.phone === 'string' ? row.phone : null,
|
||||
avatarUrl: typeof row.avatar_url === 'string' ? row.avatar_url : null,
|
||||
emailVerified: row.email_verified === true,
|
||||
@@ -40,6 +42,7 @@ export async function GET(request: NextRequest) {
|
||||
filter += `
|
||||
AND (
|
||||
LOWER(email) LIKE $${params.length}
|
||||
OR LOWER(COALESCE(display_nickname, '')) LIKE $${params.length}
|
||||
OR LOWER(COALESCE(nickname, '')) LIKE $${params.length}
|
||||
OR COALESCE(phone, '') LIKE $${params.length}
|
||||
)
|
||||
@@ -47,7 +50,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
const result = await client.query(
|
||||
`SELECT id, email, nickname, phone, avatar_url, email_verified
|
||||
`SELECT id, email, nickname, display_nickname, phone, avatar_url, email_verified
|
||||
FROM profiles
|
||||
${filter}
|
||||
ORDER BY created_at DESC
|
||||
|
||||
102
src/app/api/admin/gallery/prompt/route.ts
Normal file
102
src/app/api/admin/gallery/prompt/route.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import type { PoolClient } from 'pg';
|
||||
import {
|
||||
AdminGalleryPromptError,
|
||||
updateAdminGalleryPrompt,
|
||||
type AdminGalleryPromptEmailMessage,
|
||||
type AdminGalleryPromptWorkRow,
|
||||
} from '@/lib/admin-gallery-prompt-service';
|
||||
import { getRequestBaseUrl, sendTemplatedEmail } from '@/lib/email-service';
|
||||
import { writePlatformLog } from '@/lib/platform-logs';
|
||||
import { requireAdminUser } from '@/lib/session-auth';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
async function loadPublicGalleryWork(client: PoolClient, workId: string): Promise<AdminGalleryPromptWorkRow | null> {
|
||||
const result = await client.query(
|
||||
`SELECT w.id, w.user_id, w.type, w.title, w.prompt, w.negative_prompt,
|
||||
w.result_url, w.thumbnail_url, w.likes_count, w.is_public, w.status, w.created_at,
|
||||
p.email AS author_email,
|
||||
p.nickname AS author_nickname,
|
||||
p.display_nickname AS author_display_nickname,
|
||||
p.avatar_url AS author_avatar_url
|
||||
FROM works w
|
||||
LEFT JOIN profiles p ON p.id = w.user_id
|
||||
WHERE w.id = $1
|
||||
LIMIT 1`,
|
||||
[workId],
|
||||
);
|
||||
return (result.rows[0] as AdminGalleryPromptWorkRow | undefined) || null;
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const admin = await requireAdminUser(request);
|
||||
if (admin instanceof NextResponse) return admin;
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const assetBaseUrl = getRequestBaseUrl(request) || undefined;
|
||||
const result = await updateAdminGalleryPrompt(body, {
|
||||
admin,
|
||||
loadWork: workId => loadPublicGalleryWork(client, workId),
|
||||
updatePrompt: async (workId, prompt) => {
|
||||
const updateResult = await client.query(
|
||||
'UPDATE works SET prompt = $2, updated_at = NOW() WHERE id = $1 RETURNING id',
|
||||
[workId, prompt],
|
||||
);
|
||||
if ((updateResult.rowCount || 0) === 0) {
|
||||
throw new AdminGalleryPromptError('作品更新失败', 500);
|
||||
}
|
||||
const updated = await loadPublicGalleryWork(client, workId);
|
||||
if (!updated) throw new AdminGalleryPromptError('作品更新后读取失败', 500);
|
||||
return updated;
|
||||
},
|
||||
sendEmail: async (message: AdminGalleryPromptEmailMessage) => {
|
||||
try {
|
||||
await sendTemplatedEmail(client, {
|
||||
to: message.to,
|
||||
type: 'business',
|
||||
subject: message.subject,
|
||||
title: message.subject,
|
||||
body: message.body,
|
||||
note: '这是一封公开作品内容调整通知,请勿直接回复。',
|
||||
templateKind: 'admin',
|
||||
ipAddress: 'admin-gallery-prompt',
|
||||
assetBaseUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
const text = error instanceof Error ? error.message : String(error);
|
||||
throw new AdminGalleryPromptError(`邮件发送失败:${text}`, 502);
|
||||
}
|
||||
},
|
||||
writeLog: async entry => {
|
||||
await writePlatformLog({
|
||||
type: entry.type === 'admin' ? 'admin' : 'admin',
|
||||
level: entry.level === 'warning' || entry.level === 'error' ? entry.level : 'info',
|
||||
action: String(entry.action || 'admin_gallery_prompt_update'),
|
||||
message: String(entry.message || '管理员修改公开画廊作品提示词并发送邮件通知'),
|
||||
userId: typeof entry.userId === 'string' ? entry.userId : admin.userId,
|
||||
targetType: typeof entry.targetType === 'string' ? entry.targetType : 'work',
|
||||
targetId: typeof entry.targetId === 'string' ? entry.targetId : null,
|
||||
metadata: (entry.metadata && typeof entry.metadata === 'object' ? entry.metadata : {}) as Record<string, unknown>,
|
||||
request,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
work: result.work,
|
||||
notificationSent: result.notificationSent,
|
||||
});
|
||||
} catch (error) {
|
||||
const status = error instanceof AdminGalleryPromptError ? error.status : 500;
|
||||
const message = error instanceof Error ? error.message : '修改画廊作品提示词失败';
|
||||
if (status >= 500) console.error('[admin/gallery/prompt] PUT error:', error);
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
105
src/app/api/admin/gallery/works/route.ts
Normal file
105
src/app/api/admin/gallery/works/route.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdmin } from '@/lib/admin-auth';
|
||||
import { toAdminGalleryPromptWork, type AdminGalleryPromptWorkRow } from '@/lib/admin-gallery-prompt-service';
|
||||
import {
|
||||
buildAdminGalleryWorksPaginationMeta,
|
||||
parseAdminGalleryWorksPagination,
|
||||
} from '@/lib/admin-gallery-works-pagination';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const WORK_TYPES = new Set(['text2img', 'img2img', 'text2video', 'img2video']);
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const q = (searchParams.get('q') || searchParams.get('search') || '').trim().toLowerCase();
|
||||
const type = searchParams.get('type') || 'all';
|
||||
const sort = searchParams.get('sort') || 'newest';
|
||||
const pagination = parseAdminGalleryWorksPagination(searchParams);
|
||||
|
||||
const where: string[] = [
|
||||
'w.is_public = true',
|
||||
"w.status = 'completed'",
|
||||
"COALESCE(w.result_url, '') <> ''",
|
||||
];
|
||||
const params: unknown[] = [];
|
||||
|
||||
if (type === 'image') {
|
||||
params.push('text2img', 'img2img');
|
||||
where.push(`w.type IN ($${params.length - 1}, $${params.length})`);
|
||||
} else if (type === 'video') {
|
||||
params.push('text2video', 'img2video');
|
||||
where.push(`w.type IN ($${params.length - 1}, $${params.length})`);
|
||||
} else if (WORK_TYPES.has(type)) {
|
||||
params.push(type);
|
||||
where.push(`w.type = $${params.length}`);
|
||||
} else if (type !== 'all') {
|
||||
return NextResponse.json({ error: '作品类型无效' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (q) {
|
||||
params.push(`%${q}%`);
|
||||
where.push(`(
|
||||
LOWER(w.id::text) LIKE $${params.length}
|
||||
OR LOWER(COALESCE(w.title, '')) LIKE $${params.length}
|
||||
OR LOWER(COALESCE(w.prompt, '')) LIKE $${params.length}
|
||||
OR LOWER(COALESCE(w.negative_prompt, '')) LIKE $${params.length}
|
||||
OR LOWER(COALESCE(p.email, '')) LIKE $${params.length}
|
||||
OR LOWER(COALESCE(p.display_nickname, p.nickname, '')) LIKE $${params.length}
|
||||
OR LOWER(COALESCE(p.nickname, '')) LIKE $${params.length}
|
||||
)`);
|
||||
}
|
||||
|
||||
const whereSql = `WHERE ${where.join(' AND ')}`;
|
||||
const orderSql = sort === 'popular'
|
||||
? 'ORDER BY w.likes_count DESC, w.created_at DESC'
|
||||
: 'ORDER BY w.created_at DESC';
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const countResult = await client.query(
|
||||
`SELECT COUNT(*)::int AS total
|
||||
FROM works w
|
||||
LEFT JOIN profiles p ON p.id = w.user_id
|
||||
${whereSql}`,
|
||||
params,
|
||||
);
|
||||
const result = await client.query(
|
||||
`SELECT w.id, w.user_id, w.type, w.title, w.prompt, w.negative_prompt,
|
||||
w.result_url, w.thumbnail_url, w.likes_count, w.is_public, w.status, w.created_at,
|
||||
p.email AS author_email,
|
||||
p.nickname AS author_nickname,
|
||||
p.display_nickname AS author_display_nickname,
|
||||
p.avatar_url AS author_avatar_url
|
||||
FROM works w
|
||||
LEFT JOIN profiles p ON p.id = w.user_id
|
||||
${whereSql}
|
||||
${orderSql}
|
||||
LIMIT $${params.length + 1}
|
||||
OFFSET $${params.length + 2}`,
|
||||
[...params, pagination.limit, pagination.offset],
|
||||
);
|
||||
|
||||
const works = (result.rows as AdminGalleryPromptWorkRow[]).map(row => toAdminGalleryPromptWork(row));
|
||||
const total = Number(countResult.rows[0]?.total || 0);
|
||||
return NextResponse.json({
|
||||
works,
|
||||
...buildAdminGalleryWorksPaginationMeta({
|
||||
total,
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
resultCount: works.length,
|
||||
offset: pagination.offset,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[admin/gallery/works] GET error:', error);
|
||||
return NextResponse.json({ error: '加载画廊作品失败' }, { status: 500 });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import { markStaleRunningJobs } from '@/lib/generation-job-worker';
|
||||
import { ensureGenerationJobRuntimeSchema } from '@/lib/generation-job-estimates';
|
||||
import { writePlatformLog } from '@/lib/platform-logs';
|
||||
|
||||
const STATUSES = new Set(['queued', 'running', 'succeeded', 'failed']);
|
||||
const CLEANUP_STATUSES = new Set(['failed', 'succeeded']);
|
||||
const STATUSES = new Set(['queued', 'running', 'succeeded', 'failed', 'cancelled']);
|
||||
const CLEANUP_STATUSES = new Set(['failed', 'succeeded', 'cancelled']);
|
||||
|
||||
function intParam(value: string | null, fallback: number, min: number, max: number) {
|
||||
const parsed = Number.parseInt(value || '', 10);
|
||||
@@ -45,6 +45,7 @@ export async function GET(request: NextRequest) {
|
||||
whereClauses.push(`(
|
||||
j.user_id::text LIKE $${params.length}
|
||||
OR LOWER(COALESCE(p.email, '')) LIKE $${params.length}
|
||||
OR LOWER(COALESCE(p.display_nickname, '')) LIKE $${params.length}
|
||||
OR LOWER(COALESCE(p.nickname, '')) LIKE $${params.length}
|
||||
)`);
|
||||
}
|
||||
@@ -57,7 +58,7 @@ export async function GET(request: NextRequest) {
|
||||
params,
|
||||
);
|
||||
const rowsResult = await client.query(
|
||||
`SELECT j.id, j.user_id, p.email AS user_email, p.nickname AS user_nickname,
|
||||
`SELECT j.id, j.user_id, p.email AS user_email, COALESCE(NULLIF(p.display_nickname, ''), p.nickname) AS user_nickname,
|
||||
j.type, j.status, j.error, j.created_at, j.started_at, j.finished_at, j.updated_at
|
||||
FROM generation_jobs j
|
||||
LEFT JOIN profiles p ON p.id = j.user_id
|
||||
@@ -91,7 +92,7 @@ export async function DELETE(request: NextRequest) {
|
||||
|
||||
if (!CLEANUP_STATUSES.has(status)) {
|
||||
return NextResponse.json(
|
||||
{ error: '只允许清理失败或已完成任务' },
|
||||
{ error: '只允许清理失败、已完成或已取消任务' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
@@ -108,7 +109,7 @@ export async function DELETE(request: NextRequest) {
|
||||
type: 'admin',
|
||||
level: 'warning',
|
||||
action: 'generation_jobs_cleanup',
|
||||
message: `管理员清理了${status === 'failed' ? '失败' : '已完成'}生成任务`,
|
||||
message: `管理员清理了${status === 'failed' ? '失败' : status === 'cancelled' ? '已取消' : '已完成'}生成任务`,
|
||||
targetType: 'generation_jobs',
|
||||
metadata: { status, olderThanDays, deleted: result.rowCount || 0 },
|
||||
request,
|
||||
|
||||
22
src/app/api/admin/invitations/route.ts
Normal file
22
src/app/api/admin/invitations/route.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdmin } from '@/lib/admin-auth';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import { listInvitationReferrals } from '@/lib/invitation-service';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const params = request.nextUrl.searchParams;
|
||||
const result = await listInvitationReferrals(client, {
|
||||
search: params.get('search') || params.get('q') || '',
|
||||
page: Number(params.get('page') || '1'),
|
||||
pageSize: Number(params.get('pageSize') || params.get('limit') || '20'),
|
||||
});
|
||||
return NextResponse.json(result);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
156
src/app/api/admin/logs/route.ts
Normal file
156
src/app/api/admin/logs/route.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdminUser } from '@/lib/session-auth';
|
||||
import {
|
||||
cleanupExpiredPlatformLogs,
|
||||
ensurePlatformLogSchema,
|
||||
getPlatformLogRetentionDays,
|
||||
setPlatformLogRetentionDays,
|
||||
writePlatformLog,
|
||||
} from '@/lib/platform-logs';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
|
||||
const LOG_TYPES = new Set(['auth', 'generation', 'admin', 'database', 'storage', 'security', 'system']);
|
||||
const LOG_LEVELS = new Set(['info', 'warning', 'error']);
|
||||
|
||||
function intParam(value: string | null, fallback: number, min: number, max: number) {
|
||||
const parsed = Number.parseInt(value || '', 10);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
return Math.min(max, Math.max(min, parsed));
|
||||
}
|
||||
|
||||
function dateParam(value: string | null): Date | null {
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const user = await requireAdminUser(request);
|
||||
if (user instanceof NextResponse) return user;
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const type = searchParams.get('type') || '';
|
||||
const level = searchParams.get('level') || '';
|
||||
const userSearch = (searchParams.get('user') || '').trim();
|
||||
const keyword = (searchParams.get('keyword') || '').trim();
|
||||
const startTime = dateParam(searchParams.get('startTime'));
|
||||
const endTime = dateParam(searchParams.get('endTime'));
|
||||
const page = intParam(searchParams.get('page'), 1, 1, 100000);
|
||||
const pageSize = intParam(searchParams.get('pageSize'), 20, 1, 100);
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
if (type && !LOG_TYPES.has(type)) {
|
||||
return NextResponse.json({ error: '日志类型无效' }, { status: 400 });
|
||||
}
|
||||
if (level && !LOG_LEVELS.has(level)) {
|
||||
return NextResponse.json({ error: '日志级别无效' }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
await ensurePlatformLogSchema(client);
|
||||
await cleanupExpiredPlatformLogs(client);
|
||||
|
||||
const whereClauses: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
if (type) {
|
||||
params.push(type);
|
||||
whereClauses.push(`type = $${params.length}`);
|
||||
}
|
||||
if (level) {
|
||||
params.push(level);
|
||||
whereClauses.push(`level = $${params.length}`);
|
||||
}
|
||||
if (userSearch) {
|
||||
params.push(`%${userSearch.toLowerCase()}%`);
|
||||
whereClauses.push(`(
|
||||
user_id::text ILIKE $${params.length}
|
||||
OR LOWER(COALESCE(user_name, '')) LIKE $${params.length}
|
||||
OR LOWER(COALESCE(user_email, '')) LIKE $${params.length}
|
||||
)`);
|
||||
}
|
||||
if (keyword) {
|
||||
params.push(`%${keyword.toLowerCase()}%`);
|
||||
whereClauses.push(`(
|
||||
LOWER(action) LIKE $${params.length}
|
||||
OR LOWER(message) LIKE $${params.length}
|
||||
OR LOWER(COALESCE(target_type, '')) LIKE $${params.length}
|
||||
OR LOWER(COALESCE(target_id, '')) LIKE $${params.length}
|
||||
OR LOWER(metadata::text) LIKE $${params.length}
|
||||
)`);
|
||||
}
|
||||
if (startTime) {
|
||||
params.push(startTime.toISOString());
|
||||
whereClauses.push(`created_at >= $${params.length}::timestamptz`);
|
||||
}
|
||||
if (endTime) {
|
||||
params.push(endTime.toISOString());
|
||||
whereClauses.push(`created_at <= $${params.length}::timestamptz`);
|
||||
}
|
||||
|
||||
const whereSql = whereClauses.length ? `WHERE ${whereClauses.join(' AND ')}` : '';
|
||||
const countResult = await client.query(
|
||||
`SELECT COUNT(*)::int AS total FROM platform_logs ${whereSql}`,
|
||||
params,
|
||||
);
|
||||
const rowsResult = await client.query(
|
||||
`SELECT id, type, level, action, message, user_id, user_name, user_email,
|
||||
target_type, target_id, ip_address, metadata, created_at
|
||||
FROM platform_logs
|
||||
${whereSql}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $${params.length + 1}
|
||||
OFFSET $${params.length + 2}`,
|
||||
[...params, pageSize, offset],
|
||||
);
|
||||
const retentionDays = await getPlatformLogRetentionDays(client);
|
||||
const total = countResult.rows[0]?.total || 0;
|
||||
|
||||
return NextResponse.json({
|
||||
logs: rowsResult.rows,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||
settings: { retentionDays },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[admin/logs] GET error:', error);
|
||||
return NextResponse.json({ error: '加载日志失败' }, { status: 500 });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const user = await requireAdminUser(request);
|
||||
if (user instanceof NextResponse) return user;
|
||||
|
||||
const payload = await request.json().catch(() => ({}));
|
||||
const retentionDays = intParam(String(payload.retentionDays ?? ''), 30, 1, 90);
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const savedRetentionDays = await setPlatformLogRetentionDays(client, retentionDays);
|
||||
const deleted = await cleanupExpiredPlatformLogs(client);
|
||||
void writePlatformLog({
|
||||
type: 'admin',
|
||||
level: 'info',
|
||||
action: 'platform_log_retention_updated',
|
||||
message: `管理员将系统日志保存时间设置为 ${savedRetentionDays} 天`,
|
||||
userId: user.userId,
|
||||
targetType: 'platform_log_settings',
|
||||
metadata: { retentionDays: savedRetentionDays, deleted },
|
||||
request,
|
||||
});
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
settings: { retentionDays: savedRetentionDays },
|
||||
deleted,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[admin/logs] PUT error:', error);
|
||||
return NextResponse.json({ error: '保存日志设置失败' }, { status: 500 });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,10 @@ async function readBody(request: NextRequest) {
|
||||
return request.json().catch(() => ({}));
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const result = await client.query(
|
||||
|
||||
@@ -19,7 +19,10 @@ async function readBody(request: NextRequest) {
|
||||
return request.json().catch(() => ({}));
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const result = await client.query(
|
||||
|
||||
119
src/app/api/admin/redeem-codes/route.ts
Normal file
119
src/app/api/admin/redeem-codes/route.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdminUser } from '@/lib/session-auth';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import {
|
||||
createRedeemCodes,
|
||||
deleteUnusedRedeemCode,
|
||||
listRedeemCodes,
|
||||
setRedeemCodeActive,
|
||||
} from '@/lib/redeem-code-service';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const admin = await requireAdminUser(request);
|
||||
if (admin instanceof NextResponse) return admin;
|
||||
|
||||
try {
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const params = request.nextUrl.searchParams;
|
||||
const status = params.get('status') || 'all';
|
||||
const codes = await listRedeemCodes(client, {
|
||||
search: params.get('search') || '',
|
||||
status: status === 'unused' || status === 'used' || status === 'inactive' ? status : 'all',
|
||||
limit: Number(params.get('limit') || 100),
|
||||
});
|
||||
return NextResponse.json({ codes });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[admin/redeem-codes] GET error:', error);
|
||||
return NextResponse.json({ error: '获取兑换码失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const admin = await requireAdminUser(request);
|
||||
if (admin instanceof NextResponse) return admin;
|
||||
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const count = Number(body.count || 1);
|
||||
const codeType = body.codeType === 'membership' || body.code_type === 'membership' ? 'membership' : 'credits';
|
||||
const creditsAmount = Number(body.creditsAmount || body.credits_amount || 0);
|
||||
const membershipTier = typeof body.membershipTier === 'string' ? body.membershipTier : body.membership_tier;
|
||||
const membershipDurationValue = Number(body.membershipDurationValue || body.membership_duration_value || 0);
|
||||
const membershipDurationUnit = typeof body.membershipDurationUnit === 'string'
|
||||
? body.membershipDurationUnit
|
||||
: body.membership_duration_unit;
|
||||
const note = typeof body.note === 'string' ? body.note : '';
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const result = await createRedeemCodes(client, {
|
||||
count,
|
||||
codeType,
|
||||
creditsAmount,
|
||||
membershipTier,
|
||||
membershipDurationValue,
|
||||
membershipDurationUnit,
|
||||
note,
|
||||
createdBy: admin.userId,
|
||||
});
|
||||
return NextResponse.json(result, { status: 201 });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '生成兑换码失败';
|
||||
console.error('[admin/redeem-codes] POST error:', message);
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const admin = await requireAdminUser(request);
|
||||
if (admin instanceof NextResponse) return admin;
|
||||
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const id = typeof body.id === 'string' ? body.id : '';
|
||||
const isActive = body.isActive === true || body.is_active === true;
|
||||
if (!id) return NextResponse.json({ error: '缺少兑换码ID' }, { status: 400 });
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const code = await setRedeemCodeActive(client, id, isActive);
|
||||
if (!code) return NextResponse.json({ error: '兑换码不存在' }, { status: 404 });
|
||||
return NextResponse.json({ code });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[admin/redeem-codes] PUT error:', error);
|
||||
return NextResponse.json({ error: '更新兑换码失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const admin = await requireAdminUser(request);
|
||||
if (admin instanceof NextResponse) return admin;
|
||||
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const id = String(body.id || request.nextUrl.searchParams.get('id') || '');
|
||||
if (!id) return NextResponse.json({ error: '缺少兑换码ID' }, { status: 400 });
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const deleted = await deleteUnusedRedeemCode(client, id);
|
||||
if (!deleted) return NextResponse.json({ error: '兑换码不存在或已被使用,不能删除' }, { status: 400 });
|
||||
return NextResponse.json({ success: true });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[admin/redeem-codes] DELETE error:', error);
|
||||
return NextResponse.json({ error: '删除兑换码失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
51
src/app/api/admin/system-apis/agnes-capabilities/route.ts
Normal file
51
src/app/api/admin/system-apis/agnes-capabilities/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdmin } from '@/lib/admin-auth';
|
||||
import {
|
||||
AGNES_IMAGE_MODEL_TEMPLATES,
|
||||
AGNES_TEXT_MODEL_TEMPLATES,
|
||||
AGNES_VIDEO_MODEL_TEMPLATES,
|
||||
buildAgnesCapabilitiesText,
|
||||
} from '@/lib/agnes-model-templates';
|
||||
import { installAgnesTemplates } from '@/lib/agnes-template-installer';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
capabilitiesText: buildAgnesCapabilitiesText(),
|
||||
imageTemplates: AGNES_IMAGE_MODEL_TEMPLATES,
|
||||
videoTemplates: AGNES_VIDEO_MODEL_TEMPLATES,
|
||||
textTemplates: AGNES_TEXT_MODEL_TEMPLATES,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
try {
|
||||
const importedApis = await installAgnesTemplates({
|
||||
syncImageModels: body.syncImageModels === true,
|
||||
syncVideoModels: body.syncVideoModels === true,
|
||||
syncTextModels: body.syncTextModels === true,
|
||||
allowedMembershipTiers: body.allowedMembershipTiers,
|
||||
isDefault: body.isDefault,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
capabilitiesText: buildAgnesCapabilitiesText(),
|
||||
imageTemplates: AGNES_IMAGE_MODEL_TEMPLATES,
|
||||
videoTemplates: AGNES_VIDEO_MODEL_TEMPLATES,
|
||||
textTemplates: AGNES_TEXT_MODEL_TEMPLATES,
|
||||
importedApis,
|
||||
message: `已安装 ${importedApis.length} 个 Agnes AI 内置免费模型模板。请逐个编辑模型填写 API Key,然后启用给用户使用。`,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
error: error instanceof Error ? error.message : '安装 Agnes AI 内置模型失败',
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
ensureSystemApiSchema,
|
||||
isUuid,
|
||||
listSystemApis,
|
||||
normalizeAllowedMembershipTiers,
|
||||
normalizeSystemApiPollingMode,
|
||||
normalizeVideoUsageModes,
|
||||
toSafeSystemApi,
|
||||
} from '@/lib/server-api-config';
|
||||
|
||||
@@ -17,6 +20,15 @@ function normalizeType(value: unknown): 'image' | 'video' | 'text' {
|
||||
return value === 'video' || value === 'text' ? value : 'image';
|
||||
}
|
||||
|
||||
function normalizeBillingMode(value: unknown): 'free' | 'fixed' | 'ratio' | 'token' | 'duration' {
|
||||
return value === 'free' || value === 'ratio' || value === 'token' || value === 'duration' ? value : 'fixed';
|
||||
}
|
||||
|
||||
function numberOrDefault(value: unknown, fallback: number): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
@@ -34,28 +46,53 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const secret = encryptApiKeyForStorage(String(body.apiKey || ''));
|
||||
const billingMode = normalizeBillingMode(body.billingMode);
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
await ensureSystemApiSchema(client);
|
||||
const result = await client.query(
|
||||
`INSERT INTO system_api_configs (
|
||||
provider, name, api_url, model_name, note, api_key_encrypted,
|
||||
api_key_preview, type, credits_per_use, is_active, sort_order
|
||||
provider, name, api_url, model_name, model_group, note, api_key_encrypted,
|
||||
api_key_preview, type, credits_per_use, billing_mode, fixed_price,
|
||||
duration_price_per_second, input_price_per_1k, output_price_per_1k, model_ratio, completion_ratio,
|
||||
group_ratio, price_note, manifest_path, is_default, allowed_membership_tiers,
|
||||
polling_mode, polling_order,
|
||||
video_usage_modes, is_active, sort_order
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
|
||||
COALESCE((SELECT MAX(sort_order) + 1 FROM system_api_configs), 0))
|
||||
RETURNING id, provider, name, api_url, model_name, note, api_key_preview,
|
||||
type, credits_per_use, is_active, sort_order, created_at, updated_at`,
|
||||
$11, $12, $13, $14, $15, $16, $17, $18, $19, $20,
|
||||
$21, $22::jsonb, $23, $24, $25::jsonb, $26, COALESCE((SELECT MAX(sort_order) + 1 FROM system_api_configs), 0))
|
||||
RETURNING id, provider, name, api_url, model_name, model_group, note, api_key_preview,
|
||||
type, credits_per_use, billing_mode, fixed_price, duration_price_per_second, input_price_per_1k,
|
||||
output_price_per_1k, model_ratio, completion_ratio, group_ratio,
|
||||
price_note, manifest_path, is_default, allowed_membership_tiers,
|
||||
polling_mode, polling_order, video_usage_modes, is_active, sort_order, created_at, updated_at`,
|
||||
[
|
||||
String(body.provider || '').trim(),
|
||||
String(body.name).trim(),
|
||||
String(body.apiUrl || '').trim(),
|
||||
String(body.modelName).trim(),
|
||||
String(body.modelGroup || 'default').trim() || 'default',
|
||||
String(body.note || '').trim(),
|
||||
secret.encrypted,
|
||||
secret.preview,
|
||||
normalizeType(body.type),
|
||||
Number(body.creditsPerUse || 10),
|
||||
billingMode === 'free' ? 0 : numberOrDefault(body.creditsPerUse, 10),
|
||||
billingMode,
|
||||
billingMode === 'free' ? 0 : numberOrDefault(body.fixedPrice, 0),
|
||||
billingMode === 'free' ? 0 : numberOrDefault(body.durationPricePerSecond, 0),
|
||||
billingMode === 'free' ? 0 : numberOrDefault(body.inputPricePer1K, 0),
|
||||
billingMode === 'free' ? 0 : numberOrDefault(body.outputPricePer1K, 0),
|
||||
numberOrDefault(body.modelRatio, 1),
|
||||
numberOrDefault(body.completionRatio, 1),
|
||||
numberOrDefault(body.groupRatio, 1),
|
||||
String(body.priceNote || '').trim(),
|
||||
String(body.manifestPath || '').trim(),
|
||||
body.isDefault !== false,
|
||||
JSON.stringify(normalizeAllowedMembershipTiers(body.allowedMembershipTiers)),
|
||||
normalizeSystemApiPollingMode(body.pollingMode),
|
||||
numberOrDefault(body.pollingOrder, 0),
|
||||
JSON.stringify(normalizeVideoUsageModes(body.videoUsageModes)),
|
||||
body.isActive !== false,
|
||||
],
|
||||
);
|
||||
@@ -77,6 +114,7 @@ export async function PUT(request: NextRequest) {
|
||||
const updates: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let idx = 1;
|
||||
const billingMode = normalizeBillingMode(body.billingMode);
|
||||
const add = (column: string, value: unknown) => {
|
||||
updates.push(`${column} = $${idx++}`);
|
||||
params.push(value);
|
||||
@@ -86,9 +124,26 @@ export async function PUT(request: NextRequest) {
|
||||
add('name', String(body.name).trim());
|
||||
add('api_url', String(body.apiUrl || '').trim());
|
||||
add('model_name', String(body.modelName).trim());
|
||||
add('model_group', String(body.modelGroup || 'default').trim() || 'default');
|
||||
add('note', String(body.note || '').trim());
|
||||
add('type', normalizeType(body.type));
|
||||
add('credits_per_use', Number(body.creditsPerUse || 10));
|
||||
add('credits_per_use', billingMode === 'free' ? 0 : numberOrDefault(body.creditsPerUse, 10));
|
||||
add('billing_mode', billingMode);
|
||||
add('fixed_price', billingMode === 'free' ? 0 : numberOrDefault(body.fixedPrice, 0));
|
||||
add('duration_price_per_second', billingMode === 'free' ? 0 : numberOrDefault(body.durationPricePerSecond, 0));
|
||||
add('input_price_per_1k', billingMode === 'free' ? 0 : numberOrDefault(body.inputPricePer1K, 0));
|
||||
add('output_price_per_1k', billingMode === 'free' ? 0 : numberOrDefault(body.outputPricePer1K, 0));
|
||||
add('model_ratio', numberOrDefault(body.modelRatio, 1));
|
||||
add('completion_ratio', numberOrDefault(body.completionRatio, 1));
|
||||
add('group_ratio', numberOrDefault(body.groupRatio, 1));
|
||||
add('price_note', String(body.priceNote || '').trim());
|
||||
add('is_default', body.isDefault !== false);
|
||||
updates.push(`allowed_membership_tiers = $${idx++}::jsonb`);
|
||||
params.push(JSON.stringify(normalizeAllowedMembershipTiers(body.allowedMembershipTiers)));
|
||||
add('polling_mode', normalizeSystemApiPollingMode(body.pollingMode));
|
||||
add('polling_order', numberOrDefault(body.pollingOrder, 0));
|
||||
updates.push(`video_usage_modes = $${idx++}::jsonb`);
|
||||
params.push(JSON.stringify(normalizeVideoUsageModes(body.videoUsageModes)));
|
||||
add('is_active', body.isActive !== false);
|
||||
if (body.sortOrder !== undefined) add('sort_order', Number(body.sortOrder || 0));
|
||||
|
||||
@@ -111,13 +166,22 @@ export async function PUT(request: NextRequest) {
|
||||
`UPDATE system_api_configs
|
||||
SET ${updates.join(', ')}
|
||||
WHERE id = $${idx}
|
||||
RETURNING id, provider, name, api_url, model_name, note, api_key_preview,
|
||||
type, credits_per_use, is_active, sort_order, created_at, updated_at`,
|
||||
RETURNING id, provider, name, api_url, model_name, model_group, note, api_key_preview,
|
||||
type, credits_per_use, billing_mode, fixed_price, duration_price_per_second, input_price_per_1k,
|
||||
output_price_per_1k, model_ratio, completion_ratio, group_ratio,
|
||||
price_note, manifest_path, is_default, allowed_membership_tiers,
|
||||
polling_mode, polling_order, video_usage_modes, is_active, sort_order, created_at, updated_at`,
|
||||
params,
|
||||
);
|
||||
if (result.rows.length === 0) {
|
||||
return NextResponse.json({ error: '系统 API 不存在' }, { status: 404 });
|
||||
}
|
||||
await client.query(
|
||||
`UPDATE system_api_configs
|
||||
SET polling_mode = $1
|
||||
WHERE type = $2 AND model_name = $3`,
|
||||
[result.rows[0].polling_mode, result.rows[0].type, result.rows[0].model_name],
|
||||
);
|
||||
return NextResponse.json({ api: toSafeSystemApi(result.rows[0]) });
|
||||
} finally {
|
||||
client.release();
|
||||
|
||||
119
src/app/api/admin/system-apis/smart-import/route.ts
Normal file
119
src/app/api/admin/system-apis/smart-import/route.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdmin } from '@/lib/admin-auth';
|
||||
import { encryptApiKeyForStorage, ensureSystemApiSchema, normalizeAllowedMembershipTiers, toSafeSystemApi } from '@/lib/server-api-config';
|
||||
import { parseImportedManifestBundle, resolveImportedProfileApiUrl, saveSystemApiManifestFile } from '@/lib/user-api-manifest';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
|
||||
function normalizeType(value: unknown): 'image' | 'video' | 'text' {
|
||||
return value === 'videos' || value === 'video'
|
||||
? 'video'
|
||||
: value === 'text'
|
||||
? 'text'
|
||||
: 'image';
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const rawText = typeof body.configText === 'string' ? body.configText : '';
|
||||
let bundle;
|
||||
try {
|
||||
bundle = parseImportedManifestBundle(rawText);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error instanceof Error ? error.message : '配置 JSON 解析失败' }, { status: 400 });
|
||||
}
|
||||
for (const profile of bundle.profiles) {
|
||||
const apiUrl = resolveImportedProfileApiUrl(bundle, profile);
|
||||
if (!apiUrl) {
|
||||
return NextResponse.json({
|
||||
error: `${profile.name || profile.model || '当前配置'} 缺少中转 API 请求地址,请先在文档中找到 API Base URL 或完整请求端点后再导入`,
|
||||
}, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const secret = encryptApiKeyForStorage('');
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await ensureSystemApiSchema(client);
|
||||
|
||||
const saved = [];
|
||||
const allowedMembershipTiers = normalizeAllowedMembershipTiers(body.allowedMembershipTiers);
|
||||
for (const profile of bundle.profiles) {
|
||||
const provider = bundle.customProviders.find(item => item.id === profile.provider)
|
||||
|| bundle.customProviders.find(item => item.name === profile.provider)
|
||||
|| bundle.customProviders[0];
|
||||
const providerName = provider?.name || profile.provider || '智能配置服务商';
|
||||
const profileName = profile.name || providerName;
|
||||
const apiUrl = resolveImportedProfileApiUrl(bundle, profile);
|
||||
const result = await client.query(
|
||||
`INSERT INTO system_api_configs (
|
||||
provider, name, api_url, model_name, model_group, note,
|
||||
api_key_encrypted, api_key_preview, type, credits_per_use,
|
||||
billing_mode, fixed_price, input_price_per_1k, output_price_per_1k,
|
||||
model_ratio, completion_ratio, group_ratio, price_note,
|
||||
manifest_path, is_default, allowed_membership_tiers,
|
||||
is_active, sort_order, created_at, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, 'default', $5,
|
||||
$6, '待填写', $7, $8,
|
||||
'fixed', $9, 0, 0,
|
||||
1, 1, 1, $10,
|
||||
'', $11, $12::jsonb, true,
|
||||
COALESCE((SELECT MAX(sort_order) + 1 FROM system_api_configs), 0), NOW(), NOW())
|
||||
RETURNING id, provider, name, api_url, model_name, model_group, note, api_key_preview,
|
||||
type, credits_per_use, billing_mode, fixed_price, input_price_per_1k,
|
||||
output_price_per_1k, model_ratio, completion_ratio, group_ratio,
|
||||
price_note, manifest_path, is_default, allowed_membership_tiers,
|
||||
is_active, sort_order, created_at, updated_at`,
|
||||
[
|
||||
providerName,
|
||||
profileName,
|
||||
apiUrl,
|
||||
profile.model || 'gpt-image-2',
|
||||
profileName,
|
||||
secret.encrypted,
|
||||
normalizeType(profile.apiMode),
|
||||
Number(body.creditsPerUse) || 10,
|
||||
Number(body.fixedPrice) || 10,
|
||||
'智能配置 API 导入,API Key 需编辑后填写',
|
||||
body.isDefault !== false,
|
||||
JSON.stringify(allowedMembershipTiers),
|
||||
],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
const manifestPath = await saveSystemApiManifestFile({
|
||||
keyId: String(row.id),
|
||||
bundle,
|
||||
profile,
|
||||
});
|
||||
const updated = await client.query(
|
||||
`UPDATE system_api_configs
|
||||
SET manifest_path = $1,
|
||||
updated_at = NOW()
|
||||
WHERE id = $2
|
||||
RETURNING id, provider, name, api_url, model_name, model_group, note, api_key_preview,
|
||||
type, credits_per_use, billing_mode, fixed_price, input_price_per_1k,
|
||||
output_price_per_1k, model_ratio, completion_ratio, group_ratio,
|
||||
price_note, manifest_path, is_default, allowed_membership_tiers,
|
||||
is_active, sort_order, created_at, updated_at`,
|
||||
[manifestPath, row.id],
|
||||
);
|
||||
saved.push(toSafeSystemApi(updated.rows[0]));
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
apis: saved,
|
||||
message: `已导入 ${saved.length} 个系统 API 配置,请编辑后填写 API Key 和定价`,
|
||||
});
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
return NextResponse.json({ error: error instanceof Error ? error.message : '导入配置失败' }, { status: 500 });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
71
src/app/api/admin/system-apis/yuanjie-capabilities/route.ts
Normal file
71
src/app/api/admin/system-apis/yuanjie-capabilities/route.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdmin } from '@/lib/admin-auth';
|
||||
import {
|
||||
buildYuanjieCapabilitiesText,
|
||||
YUANJIE_IMAGE_MODEL_TEMPLATES,
|
||||
} from '@/lib/yuanjie-image-model-templates';
|
||||
import {
|
||||
buildYuanjieVideoCapabilitiesText,
|
||||
YUANJIE_VIDEO_MODEL_TEMPLATES,
|
||||
} from '@/lib/yuanjie-video-model-templates';
|
||||
import { installYuanjieImageTemplates, installYuanjieVideoTemplates } from '@/lib/yuanjie-template-installer';
|
||||
|
||||
function buildCombinedCapabilitiesText(): string {
|
||||
return [
|
||||
buildYuanjieCapabilitiesText(),
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
buildYuanjieVideoCapabilitiesText(),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
capabilitiesText: buildCombinedCapabilitiesText(),
|
||||
templates: YUANJIE_IMAGE_MODEL_TEMPLATES,
|
||||
videoTemplates: YUANJIE_VIDEO_MODEL_TEMPLATES,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
try {
|
||||
const syncModels = body.syncModels === true;
|
||||
const syncVideoModels = body.syncVideoModels === true;
|
||||
const importedImageApis = syncModels
|
||||
? await installYuanjieImageTemplates({
|
||||
allowedMembershipTiers: body.allowedMembershipTiers,
|
||||
isDefault: body.isDefault,
|
||||
})
|
||||
: [];
|
||||
const importedVideoApis = syncVideoModels
|
||||
? await installYuanjieVideoTemplates({
|
||||
allowedMembershipTiers: body.allowedMembershipTiers,
|
||||
isDefault: body.isDefault,
|
||||
})
|
||||
: [];
|
||||
const importedApis = [...importedImageApis, ...importedVideoApis];
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
capabilitiesText: buildCombinedCapabilitiesText(),
|
||||
templates: YUANJIE_IMAGE_MODEL_TEMPLATES,
|
||||
videoTemplates: YUANJIE_VIDEO_MODEL_TEMPLATES,
|
||||
importedApis,
|
||||
message: syncModels || syncVideoModels
|
||||
? `已安装 ${importedImageApis.length} 个内置图片模型模板、${importedVideoApis.length} 个内置视频模型模板。请逐个编辑模型填写 Key、设置定价并启用。`
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
error: error instanceof Error ? error.message : '同步元界 AI 内置模型失败',
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
47
src/app/api/admin/system-apis/yuanjie-pricing/route.ts
Normal file
47
src/app/api/admin/system-apis/yuanjie-pricing/route.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdmin } from '@/lib/admin-auth';
|
||||
import { ensureSystemApiSchema } from '@/lib/server-api-config';
|
||||
import {
|
||||
getYuanjiePricingSyncTargets,
|
||||
syncYuanjiePricingMetadata,
|
||||
} from '@/lib/yuanjie-pricing-sync';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
|
||||
function normalizeType(value: unknown): 'image' | 'video' | null {
|
||||
return value === 'image' || value === 'video' ? value : null;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
const type = normalizeType(request.nextUrl.searchParams.get('type'));
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
pricingTargets: getYuanjiePricingSyncTargets(type),
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const type = normalizeType(body.type);
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
await ensureSystemApiSchema(client);
|
||||
const result = await syncYuanjiePricingMetadata(client, { type });
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
...result,
|
||||
pricingTargets: getYuanjiePricingSyncTargets(type),
|
||||
message: `已同步 ${result.updated} 个元界模型的计费方式和价格备注。`,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
error: error instanceof Error ? error.message : '同步元界价格和计费方式失败',
|
||||
}, { status: 500 });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
394
src/app/api/admin/upgrade/route.ts
Normal file
394
src/app/api/admin/upgrade/route.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import fsSync from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdmin } from '@/lib/admin-auth';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type UpgradeMode = 'hot' | 'cold';
|
||||
type UpgradeStatus =
|
||||
| 'queued'
|
||||
| 'running'
|
||||
| 'rolling_back'
|
||||
| 'succeeded'
|
||||
| 'failed'
|
||||
| 'rolled_back'
|
||||
| 'rollback_failed';
|
||||
|
||||
type UpgradeJobState = {
|
||||
id: string;
|
||||
mode: UpgradeMode;
|
||||
status: UpgradeStatus;
|
||||
step: string;
|
||||
message: string;
|
||||
progress: number;
|
||||
packageName: string;
|
||||
packageHash?: string;
|
||||
backupFile?: string;
|
||||
backupHash?: string;
|
||||
sourceBackupFile?: string;
|
||||
sourceBackupHash?: string;
|
||||
restartRequired?: boolean;
|
||||
changedFiles?: string[];
|
||||
extractedFileCount?: number;
|
||||
extractedBytes?: number;
|
||||
largestFileBytes?: number;
|
||||
diskChecks?: DiskCheck[];
|
||||
preExistingFiles?: string[];
|
||||
error?: string;
|
||||
startedAt: string;
|
||||
updatedAt: string;
|
||||
finishedAt?: string;
|
||||
logs: string[];
|
||||
dryRun?: boolean;
|
||||
stale?: boolean;
|
||||
staleAt?: string;
|
||||
};
|
||||
|
||||
type DiskCheck = {
|
||||
label: string;
|
||||
path: string;
|
||||
mountPath?: string;
|
||||
totalBytes: number;
|
||||
availableBytes: number;
|
||||
requiredBytes?: number;
|
||||
usedPercent: number | null;
|
||||
};
|
||||
|
||||
type RuntimeStatus = {
|
||||
projectRoot: string;
|
||||
stateDir: string;
|
||||
nodeVersion: string;
|
||||
pm2Enabled: boolean;
|
||||
pm2SystemdEnabled: string | null;
|
||||
disks: DiskCheck[];
|
||||
processes: Array<{ name: string; status: string; uptime?: number; restarts?: number }>;
|
||||
};
|
||||
|
||||
const MAX_PACKAGE_BYTES = 300 * 1024 * 1024;
|
||||
const RUNNING_STATUSES = new Set<UpgradeStatus>(['queued', 'running', 'rolling_back']);
|
||||
const STALE_TIMEOUTS_MS: Record<string, number> = {
|
||||
queued: Number(process.env.UPGRADE_STALE_QUEUED_MS || 10 * 60 * 1000),
|
||||
running: Number(process.env.UPGRADE_STALE_RUNNING_MS || 2 * 60 * 60 * 1000),
|
||||
rolling_back: Number(process.env.UPGRADE_STALE_ROLLBACK_MS || 30 * 60 * 1000),
|
||||
};
|
||||
const HISTORY_LIMIT = Number(process.env.UPGRADE_HISTORY_LIMIT || 50);
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const states = await readStates();
|
||||
const latestUpgrade = states.find(job => !job.dryRun) || null;
|
||||
const latestPreflight = states.find(job => job.dryRun) || null;
|
||||
return NextResponse.json({
|
||||
latest: latestUpgrade || latestPreflight,
|
||||
latestUpgrade,
|
||||
latestPreflight,
|
||||
history: states,
|
||||
stateDir: getUpgradeStateRoot(),
|
||||
historyLimit: HISTORY_LIMIT,
|
||||
running: states.some(job => RUNNING_STATUSES.has(job.status)),
|
||||
runtime: getRuntimeStatus(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[admin/upgrade] failed to read state:', error);
|
||||
return NextResponse.json({ error: '读取升级状态失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
function getRuntimeStatus(): RuntimeStatus {
|
||||
return {
|
||||
projectRoot: process.cwd(),
|
||||
stateDir: getUpgradeStateRoot(),
|
||||
nodeVersion: process.version,
|
||||
pm2Enabled: commandExists('pm2'),
|
||||
pm2SystemdEnabled: getCommandOutput('systemctl', ['is-enabled', 'pm2-root']),
|
||||
disks: getRuntimeDisks(process.cwd(), getUpgradeStateRoot()),
|
||||
processes: getPm2Processes(),
|
||||
};
|
||||
}
|
||||
|
||||
function getRuntimeDisks(projectRoot: string, stateDir: string): DiskCheck[] {
|
||||
return [
|
||||
readDiskUsage('项目目录', projectRoot),
|
||||
readDiskUsage('升级状态目录', stateDir),
|
||||
].filter((check): check is DiskCheck => Boolean(check));
|
||||
}
|
||||
|
||||
function readDiskUsage(label: string, targetPath: string): DiskCheck | null {
|
||||
try {
|
||||
fsSync.mkdirSync(targetPath, { recursive: true, mode: 0o700 });
|
||||
const result = spawnSync('df', ['-Pk', targetPath], { encoding: 'utf8', timeout: 5000 });
|
||||
if (result.status !== 0 || !result.stdout) return null;
|
||||
const lines = result.stdout.trim().split(/\r?\n/);
|
||||
const row = lines[lines.length - 1]?.trim().split(/\s+/);
|
||||
if (!row || row.length < 6) return null;
|
||||
const totalBytes = Number(row[1]) * 1024;
|
||||
const availableBytes = Number(row[3]) * 1024;
|
||||
const usedPercent = Number(row[4].replace('%', ''));
|
||||
if (!Number.isFinite(totalBytes) || !Number.isFinite(availableBytes)) return null;
|
||||
return {
|
||||
label,
|
||||
path: path.resolve(targetPath),
|
||||
mountPath: row.slice(5).join(' ') || targetPath,
|
||||
totalBytes,
|
||||
availableBytes,
|
||||
usedPercent: Number.isFinite(usedPercent) ? usedPercent : null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function commandExists(command: string): boolean {
|
||||
return spawnSync('bash', ['-lc', `command -v ${command} >/dev/null 2>&1`], { encoding: 'utf8' }).status === 0;
|
||||
}
|
||||
|
||||
function getCommandOutput(command: string, commandArgs: string[]): string | null {
|
||||
const result = spawnSync(command, commandArgs, { encoding: 'utf8', timeout: 3000 });
|
||||
const output = `${result.stdout || result.stderr || ''}`.trim();
|
||||
return output || null;
|
||||
}
|
||||
|
||||
function getPm2Processes(): RuntimeStatus['processes'] {
|
||||
if (!commandExists('pm2')) return [];
|
||||
const result = spawnSync('pm2', ['jlist'], { encoding: 'utf8', timeout: 5000, maxBuffer: 5 * 1024 * 1024 });
|
||||
if (result.status !== 0 || !result.stdout) return [];
|
||||
try {
|
||||
const processes = JSON.parse(result.stdout) as Array<{
|
||||
name?: string;
|
||||
pm2_env?: { status?: string; pm_uptime?: number; restart_time?: number };
|
||||
}>;
|
||||
return processes.map(process => ({
|
||||
name: process.name || 'unknown',
|
||||
status: process.pm2_env?.status || 'unknown',
|
||||
uptime: process.pm2_env?.pm_uptime,
|
||||
restarts: process.pm2_env?.restart_time,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const states = await readStates();
|
||||
const runningJob = states.find(job => RUNNING_STATUSES.has(job.status));
|
||||
if (runningJob) {
|
||||
return NextResponse.json({ error: `已有升级任务正在执行:${runningJob.id}` }, { status: 409 });
|
||||
}
|
||||
|
||||
const form = await request.formData();
|
||||
const modeValue = String(form.get('mode') || '');
|
||||
const mode = modeValue === 'hot' || modeValue === 'cold' ? modeValue : null;
|
||||
const dryRun = String(form.get('dryRun') || '') === 'true';
|
||||
if (!mode) {
|
||||
return NextResponse.json({ error: '请选择热更新或冷更新' }, { status: 400 });
|
||||
}
|
||||
|
||||
const file = form.get('package');
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ error: '请上传升级包' }, { status: 400 });
|
||||
}
|
||||
if (file.size <= 0) {
|
||||
return NextResponse.json({ error: '升级包为空' }, { status: 400 });
|
||||
}
|
||||
if (file.size > MAX_PACKAGE_BYTES) {
|
||||
return NextResponse.json({ error: '升级包不能超过 300MB' }, { status: 400 });
|
||||
}
|
||||
if (!isAllowedArchiveName(file.name)) {
|
||||
return NextResponse.json({ error: '仅支持 .tar、.tar.gz、.tgz 升级包' }, { status: 400 });
|
||||
}
|
||||
|
||||
const stateRoot = getUpgradeStateRoot();
|
||||
const jobId = `${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}-${randomUUID().slice(0, 8)}`;
|
||||
const jobDir = path.join(stateRoot, 'jobs', jobId);
|
||||
const uploadDir = path.join(jobDir, 'upload');
|
||||
await fs.mkdir(uploadDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
const safeName = sanitizeFileName(file.name);
|
||||
const packagePath = path.join(uploadDir, safeName);
|
||||
const bytes = Buffer.from(await file.arrayBuffer());
|
||||
await fs.writeFile(packagePath, bytes, { mode: 0o600 });
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const initialState: UpgradeJobState = {
|
||||
id: jobId,
|
||||
mode,
|
||||
status: 'queued',
|
||||
step: 'queued',
|
||||
message: '升级包已上传,等待执行',
|
||||
progress: 0,
|
||||
packageName: file.name,
|
||||
packageHash: createHash('sha256').update(bytes).digest('hex'),
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
logs: [`[${now}] 上传升级包 ${file.name} (${file.size} bytes)`],
|
||||
dryRun,
|
||||
};
|
||||
if (dryRun) {
|
||||
initialState.message = '升级包已上传,正在执行预检';
|
||||
}
|
||||
await writeState(jobDir, initialState);
|
||||
|
||||
const runnerArgs = [
|
||||
path.join(process.cwd(), 'scripts/admin-upgrade-runner.mjs'),
|
||||
'--job-id',
|
||||
jobId,
|
||||
'--mode',
|
||||
mode,
|
||||
'--package',
|
||||
packagePath,
|
||||
'--package-name',
|
||||
file.name,
|
||||
'--project',
|
||||
process.cwd(),
|
||||
];
|
||||
if (dryRun) runnerArgs.push('--dry-run', 'true');
|
||||
|
||||
const child = spawn(process.execPath, runnerArgs, {
|
||||
cwd: process.cwd(),
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env: {
|
||||
...process.env,
|
||||
UPGRADE_STATE_DIR: stateRoot,
|
||||
COREPACK_HOME: process.env.COREPACK_HOME || '/tmp/corepack',
|
||||
},
|
||||
});
|
||||
child.unref();
|
||||
|
||||
return NextResponse.json({ success: true, dryRun, job: initialState });
|
||||
} catch (error) {
|
||||
console.error('[admin/upgrade] failed to start upgrade:', error);
|
||||
return NextResponse.json({ error: error instanceof Error ? error.message : '创建升级任务失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
function getUpgradeStateRoot(): string {
|
||||
const configured = process.env.UPGRADE_STATE_DIR;
|
||||
if (configured) return path.resolve(configured);
|
||||
if (process.env.LOCAL_STORAGE_DIR) return path.join(path.dirname(process.env.LOCAL_STORAGE_DIR), 'upgrade');
|
||||
return path.join(process.cwd(), 'upgrade-state');
|
||||
}
|
||||
|
||||
async function readStates(): Promise<UpgradeJobState[]> {
|
||||
const jobsRoot = path.join(getUpgradeStateRoot(), 'jobs');
|
||||
let jobNames: string[] = [];
|
||||
try {
|
||||
jobNames = await fs.readdir(jobsRoot);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const loadedStates = await Promise.all(
|
||||
jobNames.map(async jobName => {
|
||||
try {
|
||||
const statePath = path.join(jobsRoot, jobName, 'state.json');
|
||||
const raw = await fs.readFile(statePath, 'utf8');
|
||||
return {
|
||||
jobName,
|
||||
state: await normalizeStaleState(JSON.parse(raw) as UpgradeJobState, statePath),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const entries = loadedStates
|
||||
.filter((entry): entry is { jobName: string; state: UpgradeJobState } => Boolean(entry))
|
||||
.sort((a, b) => new Date(b.state.updatedAt).getTime() - new Date(a.state.updatedAt).getTime());
|
||||
const prunedJobNames = await pruneFinishedJobs(jobsRoot, entries);
|
||||
|
||||
return entries
|
||||
.filter(entry => !prunedJobNames.has(entry.jobName))
|
||||
.map(entry => entry.state)
|
||||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||
}
|
||||
|
||||
async function pruneFinishedJobs(
|
||||
jobsRoot: string,
|
||||
entries: Array<{ jobName: string; state: UpgradeJobState }>,
|
||||
): Promise<Set<string>> {
|
||||
const prunedJobNames = new Set<string>();
|
||||
if (!Number.isFinite(HISTORY_LIMIT) || HISTORY_LIMIT < 1) return prunedJobNames;
|
||||
const finished = entries.filter(entry => !RUNNING_STATUSES.has(entry.state.status));
|
||||
const staleFinished = finished.slice(HISTORY_LIMIT);
|
||||
if (staleFinished.length === 0) return prunedJobNames;
|
||||
|
||||
await Promise.all(staleFinished.map(async entry => {
|
||||
const targetDir = path.join(jobsRoot, entry.jobName);
|
||||
const resolvedRoot = path.resolve(jobsRoot);
|
||||
const resolvedTarget = path.resolve(targetDir);
|
||||
if (!resolvedTarget.startsWith(`${resolvedRoot}${path.sep}`)) return;
|
||||
await fs.rm(resolvedTarget, { recursive: true, force: true });
|
||||
prunedJobNames.add(entry.jobName);
|
||||
}));
|
||||
return prunedJobNames;
|
||||
}
|
||||
|
||||
async function normalizeStaleState(state: UpgradeJobState, statePath: string): Promise<UpgradeJobState> {
|
||||
if (!RUNNING_STATUSES.has(state.status)) return state;
|
||||
|
||||
const updatedAtMs = new Date(state.updatedAt || state.startedAt).getTime();
|
||||
if (!Number.isFinite(updatedAtMs)) return state;
|
||||
|
||||
const timeoutMs = STALE_TIMEOUTS_MS[state.status] || STALE_TIMEOUTS_MS.running;
|
||||
if (Date.now() - updatedAtMs < timeoutMs) return state;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const isRollback = state.status === 'rolling_back';
|
||||
const error = isRollback
|
||||
? `升级任务在回滚阶段超过 ${formatDuration(timeoutMs)} 没有状态更新,可能 runner 已退出或服务器曾重启,请人工检查备份与运行状态`
|
||||
: `升级任务超过 ${formatDuration(timeoutMs)} 没有状态更新,可能 runner 已退出或服务器曾重启,已自动解除升级锁`;
|
||||
const next: UpgradeJobState = {
|
||||
...state,
|
||||
status: isRollback ? 'rollback_failed' : 'failed',
|
||||
step: isRollback ? 'rollback_stale' : 'stale',
|
||||
progress: 100,
|
||||
message: isRollback ? '升级回滚长时间无更新,请人工检查' : '升级任务长时间无更新,已解除升级锁',
|
||||
error,
|
||||
stale: true,
|
||||
staleAt: now,
|
||||
finishedAt: now,
|
||||
updatedAt: now,
|
||||
logs: [
|
||||
...(state.logs || []),
|
||||
`[${now}] ${error}`,
|
||||
].slice(-1000),
|
||||
};
|
||||
|
||||
await fs.writeFile(statePath, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
|
||||
return next;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const minutes = Math.round(ms / 60000);
|
||||
if (minutes < 60) return `${minutes} 分钟`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
return `${hours} 小时`;
|
||||
}
|
||||
|
||||
async function writeState(jobDir: string, state: UpgradeJobState): Promise<void> {
|
||||
await fs.mkdir(jobDir, { recursive: true, mode: 0o700 });
|
||||
await fs.writeFile(path.join(jobDir, 'state.json'), `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
||||
}
|
||||
|
||||
function isAllowedArchiveName(name: string): boolean {
|
||||
return name.endsWith('.tar') || name.endsWith('.tar.gz') || name.endsWith('.tgz');
|
||||
}
|
||||
|
||||
function sanitizeFileName(name: string): string {
|
||||
const baseName = path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
return baseName || 'upgrade-package.tar.gz';
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { createSessionToken } from '@/lib/session-auth';
|
||||
import { getRequiredProductionSecret } from '@/lib/runtime-env';
|
||||
import { writePlatformLog } from '@/lib/platform-logs';
|
||||
import { ensureProfilePreferenceSchema, normalizePreferredTheme } from '@/lib/profile-preferences';
|
||||
import { ensureUserDisplayProfileSchema, generateChineseNickname, generateDefaultAvatarDataUrl } from '@/lib/user-profile-defaults';
|
||||
|
||||
function normalizeRoleForTier(role: string | null | undefined, tier: string | null | undefined): string {
|
||||
const currentRole = role || 'user';
|
||||
@@ -35,9 +36,11 @@ export async function POST(request: NextRequest) {
|
||||
try {
|
||||
await ensureEmailSchema(client);
|
||||
await ensureProfilePreferenceSchema(client);
|
||||
await ensureUserDisplayProfileSchema(client);
|
||||
let loginEmail = identifier;
|
||||
let userId = '';
|
||||
let userRole = 'user';
|
||||
let username = '';
|
||||
let userNickname = '';
|
||||
let userMembershipTier = 'free';
|
||||
let userCreditsBalance = 0;
|
||||
@@ -56,38 +59,41 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
if (!isEmailFormat) {
|
||||
const adminLookup = await client.query(
|
||||
"SELECT id, email, nickname, role FROM profiles WHERE (nickname = $1 OR phone = $1) AND role = 'admin' LIMIT 1",
|
||||
"SELECT id, email, nickname, COALESCE(NULLIF(display_nickname, ''), nickname) AS display_nickname, role FROM profiles WHERE (nickname = $1 OR phone = $1) AND role = 'admin' LIMIT 1",
|
||||
[identifier]
|
||||
);
|
||||
if (adminLookup.rows.length > 0) {
|
||||
isAdminAccount = true;
|
||||
adminProfileId = adminLookup.rows[0].id;
|
||||
loginEmail = adminLookup.rows[0].email;
|
||||
userNickname = adminLookup.rows[0].nickname || '';
|
||||
username = adminLookup.rows[0].nickname || '';
|
||||
userNickname = adminLookup.rows[0].display_nickname || username;
|
||||
} else {
|
||||
const nicknameLower = String(identifier).toLowerCase();
|
||||
if (nicknameLower === 'admin' || nicknameLower.startsWith('admin')) {
|
||||
const anyLookup = await client.query(
|
||||
"SELECT id, email, nickname, role FROM profiles WHERE role = 'admin' ORDER BY created_at ASC LIMIT 1"
|
||||
"SELECT id, email, nickname, COALESCE(NULLIF(display_nickname, ''), nickname) AS display_nickname, role FROM profiles WHERE role = 'admin' ORDER BY created_at ASC LIMIT 1"
|
||||
);
|
||||
if (anyLookup.rows.length > 0) {
|
||||
isAdminAccount = true;
|
||||
adminProfileId = anyLookup.rows[0].id;
|
||||
loginEmail = anyLookup.rows[0].email;
|
||||
userNickname = anyLookup.rows[0].nickname || '';
|
||||
username = anyLookup.rows[0].nickname || '';
|
||||
userNickname = anyLookup.rows[0].display_nickname || username;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const adminLookup = await client.query(
|
||||
"SELECT id, email, nickname, role FROM profiles WHERE email = $1 AND role = 'admin' LIMIT 1",
|
||||
"SELECT id, email, nickname, COALESCE(NULLIF(display_nickname, ''), nickname) AS display_nickname, role FROM profiles WHERE email = $1 AND role = 'admin' LIMIT 1",
|
||||
[identifier]
|
||||
);
|
||||
if (adminLookup.rows.length > 0) {
|
||||
isAdminAccount = true;
|
||||
adminProfileId = adminLookup.rows[0].id;
|
||||
loginEmail = identifier;
|
||||
userNickname = adminLookup.rows[0].nickname || '';
|
||||
username = adminLookup.rows[0].nickname || '';
|
||||
userNickname = adminLookup.rows[0].display_nickname || username;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +116,8 @@ export async function POST(request: NextRequest) {
|
||||
userMembershipTier = 'enterprise';
|
||||
userCreditsBalance = 9999;
|
||||
userDailyQuotaLimit = 999;
|
||||
userNickname = userNickname || '管理员';
|
||||
username = username || 'admin';
|
||||
userNickname = userNickname || username || '管理员';
|
||||
userEmailVerified = true;
|
||||
userEmailVerifiedAt = new Date().toISOString();
|
||||
|
||||
@@ -134,19 +141,20 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO profiles (id, email, nickname, role, membership_tier, credits_balance, daily_quota_limit, daily_quota_used, is_active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
`INSERT INTO profiles (id, email, nickname, display_nickname, role, membership_tier, credits_balance, daily_quota_limit, daily_quota_used, is_active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
role = $4,
|
||||
membership_tier = $5,
|
||||
credits_balance = $6,
|
||||
daily_quota_limit = $7,
|
||||
role = $5,
|
||||
membership_tier = $6,
|
||||
credits_balance = $7,
|
||||
daily_quota_limit = $8,
|
||||
nickname = $3,
|
||||
display_nickname = COALESCE(NULLIF(profiles.display_nickname, ''), $4),
|
||||
is_active = true,
|
||||
email_verified = true,
|
||||
email_verified_at = COALESCE(profiles.email_verified_at, NOW()),
|
||||
email_bound_at = COALESCE(profiles.email_bound_at, NOW())`,
|
||||
[userId, loginEmail, userNickname, 'admin', 'enterprise', 9999, 999, 0, true]
|
||||
[userId, loginEmail, username, userNickname, 'admin', 'enterprise', 9999, 999, 0, true]
|
||||
);
|
||||
|
||||
const adminThemeResult = await client.query(
|
||||
@@ -164,7 +172,7 @@ export async function POST(request: NextRequest) {
|
||||
} else {
|
||||
if (!isEmailFormat) {
|
||||
const profileResult = await client.query(
|
||||
'SELECT id, email, nickname, phone, role FROM profiles WHERE nickname = $1 OR phone = $1 LIMIT 1',
|
||||
'SELECT id, email, nickname, COALESCE(NULLIF(display_nickname, \'\'), nickname) AS display_nickname, phone, role FROM profiles WHERE nickname = $1 OR phone = $1 LIMIT 1',
|
||||
[identifier]
|
||||
);
|
||||
|
||||
@@ -173,7 +181,8 @@ export async function POST(request: NextRequest) {
|
||||
loginEmail = profile.email;
|
||||
userId = profile.id;
|
||||
userRole = profile.role || 'user';
|
||||
userNickname = profile.nickname;
|
||||
username = profile.nickname || '';
|
||||
userNickname = profile.display_nickname || profile.nickname;
|
||||
userPhone = profile.phone;
|
||||
} else {
|
||||
return NextResponse.json({ error: 'Account does not exist' }, { status: 401 });
|
||||
@@ -203,19 +212,24 @@ export async function POST(request: NextRequest) {
|
||||
userCreatedAt = authUser.created_at;
|
||||
|
||||
const profileResult = await client.query(
|
||||
'SELECT nickname, role, membership_tier, credits_balance, daily_quota_used, daily_quota_limit, avatar_url, phone, email_verified, email_verified_at, preferred_theme FROM profiles WHERE id = $1',
|
||||
'SELECT nickname, COALESCE(NULLIF(display_nickname, \'\'), nickname) AS display_nickname, role, membership_tier, credits_balance, daily_quota_used, daily_quota_limit, avatar_url, phone, email_verified, email_verified_at, preferred_theme FROM profiles WHERE id = $1',
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (profileResult.rows.length > 0) {
|
||||
const profile = profileResult.rows[0];
|
||||
userNickname = profile.nickname || loginEmail.split('@')[0];
|
||||
username = profile.nickname || loginEmail.split('@')[0];
|
||||
userNickname = profile.display_nickname || username;
|
||||
userMembershipTier = profile.membership_tier || 'free';
|
||||
userRole = normalizeRoleForTier(profile.role, userMembershipTier);
|
||||
userCreditsBalance = profile.credits_balance || 0;
|
||||
userDailyQuotaUsed = profile.daily_quota_used || 0;
|
||||
userDailyQuotaLimit = profile.daily_quota_limit || 5;
|
||||
userAvatarUrl = profile.avatar_url || null;
|
||||
if (!userAvatarUrl) {
|
||||
userAvatarUrl = generateDefaultAvatarDataUrl(`${userId}:${loginEmail}`, userNickname);
|
||||
await client.query('UPDATE profiles SET avatar_url = $1, updated_at = NOW() WHERE id = $2', [userAvatarUrl, userId]);
|
||||
}
|
||||
userPhone = profile.phone || null;
|
||||
userEmailVerified = profile.email_verified === true;
|
||||
userEmailVerifiedAt = profile.email_verified_at || null;
|
||||
@@ -224,12 +238,14 @@ export async function POST(request: NextRequest) {
|
||||
await client.query('UPDATE profiles SET role = $1, updated_at = NOW() WHERE id = $2', [userRole, userId]);
|
||||
}
|
||||
} else {
|
||||
userNickname = loginEmail.split('@')[0];
|
||||
username = loginEmail.split('@')[0];
|
||||
userNickname = generateChineseNickname(`${userId}:${loginEmail}`);
|
||||
userAvatarUrl = generateDefaultAvatarDataUrl(`${userId}:${loginEmail}`, userNickname);
|
||||
await client.query(
|
||||
`INSERT INTO profiles (id, email, nickname, role, membership_tier, credits_balance, daily_quota_used, daily_quota_limit)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (id) DO UPDATE SET email = $2, nickname = $3, email_verified = false, email_verified_at = NULL`,
|
||||
[userId, loginEmail, userNickname, userRole, userMembershipTier, userCreditsBalance, userDailyQuotaUsed, userDailyQuotaLimit]
|
||||
`INSERT INTO profiles (id, email, nickname, display_nickname, avatar_url, role, membership_tier, credits_balance, daily_quota_used, daily_quota_limit)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (id) DO UPDATE SET email = $2, nickname = $3, display_nickname = COALESCE(NULLIF(profiles.display_nickname, ''), $4), avatar_url = COALESCE(NULLIF(profiles.avatar_url, ''), $5), email_verified = false, email_verified_at = NULL`,
|
||||
[userId, loginEmail, username, userNickname, userAvatarUrl, userRole, userMembershipTier, userCreditsBalance, userDailyQuotaUsed, userDailyQuotaLimit]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -264,7 +280,9 @@ export async function POST(request: NextRequest) {
|
||||
user: {
|
||||
id: userId,
|
||||
email: loginEmail,
|
||||
username,
|
||||
nickname: userNickname,
|
||||
display_nickname: userNickname,
|
||||
role: userRole,
|
||||
membership_tier: userMembershipTier,
|
||||
credits_balance: userCreditsBalance,
|
||||
|
||||
@@ -3,6 +3,16 @@ import { getDbClient } from '@/storage/database/local-db';
|
||||
import { ensureEmailSchema, getRequestBaseUrl, normalizeEmail, sendTemplatedEmail, verifyEmailCode } from '@/lib/email-service';
|
||||
import { getRequiredProductionSecret } from '@/lib/runtime-env';
|
||||
import { ensureProfilePreferenceSchema } from '@/lib/profile-preferences';
|
||||
import { ensureUserDisplayProfileSchema, generateChineseNickname, generateDefaultAvatarDataUrl, normalizeUsername } from '@/lib/user-profile-defaults';
|
||||
import { createSessionToken } from '@/lib/session-auth';
|
||||
import {
|
||||
INVITATION_BONUS_CREDITS,
|
||||
applyInvitationReward,
|
||||
ensureInvitationSchema,
|
||||
findInviterByCode,
|
||||
getOrCreateInviteCode,
|
||||
normalizeInviteCode,
|
||||
} from '@/lib/invitation-service';
|
||||
|
||||
function isStrongPassword(password: string): boolean {
|
||||
return password.length >= 8 && /[A-Za-z]/.test(password) && /\d/.test(password);
|
||||
@@ -10,8 +20,9 @@ function isStrongPassword(password: string): boolean {
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { email, password, nickname, phone, inviteCode, emailCode, acceptedTerms } = await request.json();
|
||||
const { email, password, nickname, phone, inviteCode, referralCode, emailCode, acceptedTerms } = await request.json();
|
||||
const normalizedEmail = normalizeEmail(email);
|
||||
const normalizedReferralCode = normalizeInviteCode(referralCode || inviteCode);
|
||||
|
||||
if (!normalizedEmail || !password) {
|
||||
return NextResponse.json({ error: 'Please enter email and password' }, { status: 400 });
|
||||
@@ -32,6 +43,8 @@ export async function POST(request: NextRequest) {
|
||||
try {
|
||||
await ensureEmailSchema(client);
|
||||
await ensureProfilePreferenceSchema(client);
|
||||
await ensureUserDisplayProfileSchema(client);
|
||||
await ensureInvitationSchema(client);
|
||||
if (isAdminRegistration) {
|
||||
const existingAdminResult = await client.query(
|
||||
'SELECT id FROM profiles WHERE role = $1',
|
||||
@@ -59,6 +72,19 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const userId = crypto.randomUUID();
|
||||
const username = normalizeUsername(nickname, normalizedEmail.split('@')[0]);
|
||||
|
||||
const existingUsernameResult = await client.query(
|
||||
'SELECT id FROM profiles WHERE LOWER(nickname) = LOWER($1) LIMIT 1',
|
||||
[username]
|
||||
);
|
||||
|
||||
if (existingUsernameResult.rows.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Username is already registered' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAdminRegistration) {
|
||||
if (typeof emailCode !== 'string' || !/^[a-z0-9]{4,10}$/i.test(emailCode)) {
|
||||
@@ -78,62 +104,92 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO auth.users (id, email, password_hash, created_at)
|
||||
VALUES ($1, $2, crypt($3, gen_salt('bf')), NOW())`,
|
||||
[userId, normalizedEmail, password]
|
||||
);
|
||||
const inviter = !isAdminRegistration && normalizedReferralCode
|
||||
? await findInviterByCode(client, normalizedReferralCode)
|
||||
: null;
|
||||
if (normalizedReferralCode && !isAdminRegistration && !inviter) {
|
||||
return NextResponse.json({ error: '邀请链接无效或邀请人账号不可用' }, { status: 400 });
|
||||
}
|
||||
|
||||
const role = isAdminRegistration ? 'admin' : 'user';
|
||||
const membershipTier = isAdminRegistration ? 'enterprise' : 'free';
|
||||
const creditsBalance = isAdminRegistration ? 9999 : 10;
|
||||
let finalCreditsBalance = creditsBalance;
|
||||
const dailyQuotaLimit = isAdminRegistration ? 999 : 5;
|
||||
const displayName = nickname || normalizedEmail.split('@')[0];
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO profiles (
|
||||
id, email, nickname, phone, role, membership_tier, credits_balance,
|
||||
daily_quota_limit, daily_quota_used, is_active, email_verified,
|
||||
email_verified_at, email_bound_at, email_sender_domain
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, CASE WHEN $11 THEN NOW() ELSE NULL END, CASE WHEN $11 THEN NOW() ELSE NULL END, $12)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
nickname = EXCLUDED.nickname,
|
||||
phone = EXCLUDED.phone,
|
||||
role = EXCLUDED.role,
|
||||
membership_tier = EXCLUDED.membership_tier,
|
||||
credits_balance = EXCLUDED.credits_balance,
|
||||
daily_quota_limit = EXCLUDED.daily_quota_limit,
|
||||
daily_quota_used = EXCLUDED.daily_quota_used,
|
||||
is_active = EXCLUDED.is_active,
|
||||
email_verified = EXCLUDED.email_verified,
|
||||
email_verified_at = EXCLUDED.email_verified_at,
|
||||
email_bound_at = EXCLUDED.email_bound_at,
|
||||
email_sender_domain = EXCLUDED.email_sender_domain`,
|
||||
[
|
||||
userId,
|
||||
normalizedEmail,
|
||||
displayName,
|
||||
phone || null,
|
||||
role,
|
||||
membershipTier,
|
||||
creditsBalance,
|
||||
dailyQuotaLimit,
|
||||
0,
|
||||
true,
|
||||
true,
|
||||
normalizedEmail.split('@')[1] || null,
|
||||
]
|
||||
);
|
||||
const displayNickname = isAdminRegistration ? username : generateChineseNickname(`${userId}:${normalizedEmail}`);
|
||||
const avatarUrl = generateDefaultAvatarDataUrl(`${userId}:${normalizedEmail}`, displayNickname);
|
||||
|
||||
await client.query('BEGIN');
|
||||
try {
|
||||
await client.query(
|
||||
`INSERT INTO auth.users (id, email, password_hash, created_at)
|
||||
VALUES ($1, $2, crypt($3, gen_salt('bf')), NOW())`,
|
||||
[userId, normalizedEmail, password]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO profiles (
|
||||
id, email, nickname, display_nickname, avatar_url, phone, role, membership_tier, credits_balance,
|
||||
daily_quota_limit, daily_quota_used, is_active, email_verified,
|
||||
email_verified_at, email_bound_at, email_sender_domain, invite_code, referred_by_user_id
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, CASE WHEN $13 THEN NOW() ELSE NULL END, CASE WHEN $13 THEN NOW() ELSE NULL END, $14, $15, $16)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
nickname = EXCLUDED.nickname,
|
||||
display_nickname = EXCLUDED.display_nickname,
|
||||
avatar_url = EXCLUDED.avatar_url,
|
||||
phone = EXCLUDED.phone,
|
||||
role = EXCLUDED.role,
|
||||
membership_tier = EXCLUDED.membership_tier,
|
||||
credits_balance = EXCLUDED.credits_balance,
|
||||
daily_quota_limit = EXCLUDED.daily_quota_limit,
|
||||
daily_quota_used = EXCLUDED.daily_quota_used,
|
||||
is_active = EXCLUDED.is_active,
|
||||
email_verified = EXCLUDED.email_verified,
|
||||
email_verified_at = EXCLUDED.email_verified_at,
|
||||
email_bound_at = EXCLUDED.email_bound_at,
|
||||
email_sender_domain = EXCLUDED.email_sender_domain,
|
||||
invite_code = COALESCE(profiles.invite_code, EXCLUDED.invite_code),
|
||||
referred_by_user_id = COALESCE(profiles.referred_by_user_id, EXCLUDED.referred_by_user_id)`,
|
||||
[
|
||||
userId,
|
||||
normalizedEmail,
|
||||
username,
|
||||
displayNickname,
|
||||
avatarUrl,
|
||||
phone || null,
|
||||
role,
|
||||
membershipTier,
|
||||
creditsBalance,
|
||||
dailyQuotaLimit,
|
||||
0,
|
||||
true,
|
||||
true,
|
||||
normalizedEmail.split('@')[1] || null,
|
||||
null,
|
||||
inviter?.id || null,
|
||||
]
|
||||
);
|
||||
await getOrCreateInviteCode(client, userId);
|
||||
|
||||
await client.query(
|
||||
'INSERT INTO credit_transactions (user_id, amount, balance_after, type, description) VALUES ($1, $2, $3, $4, $5)',
|
||||
[userId, creditsBalance, creditsBalance, 'gift', isAdminRegistration ? 'Admin initial credits' : 'New user registration bonus']
|
||||
);
|
||||
} catch {
|
||||
// Ignore credit transaction errors.
|
||||
).catch(() => undefined);
|
||||
|
||||
if (inviter?.id && String(inviter.id) !== userId) {
|
||||
await applyInvitationReward(client, {
|
||||
inviterUserId: String(inviter.id),
|
||||
inviteeUserId: userId,
|
||||
inviteCode: normalizedReferralCode,
|
||||
});
|
||||
finalCreditsBalance += INVITATION_BONUS_CREDITS;
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
|
||||
await sendTemplatedEmail(client, {
|
||||
@@ -146,23 +202,31 @@ export async function POST(request: NextRequest) {
|
||||
assetBaseUrl: getRequestBaseUrl(request) || undefined,
|
||||
}).catch(() => undefined);
|
||||
|
||||
const accessToken = createSessionToken(userId, role);
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: userId,
|
||||
email: normalizedEmail,
|
||||
nickname: displayName,
|
||||
username,
|
||||
nickname: displayNickname,
|
||||
display_nickname: displayNickname,
|
||||
role,
|
||||
membership_tier: membershipTier,
|
||||
credits_balance: creditsBalance,
|
||||
credits_balance: finalCreditsBalance,
|
||||
daily_quota_used: 0,
|
||||
daily_quota_limit: dailyQuotaLimit,
|
||||
avatar_url: null,
|
||||
avatar_url: avatarUrl,
|
||||
phone: phone || null,
|
||||
email_verified: true,
|
||||
email_verified_at: new Date().toISOString(),
|
||||
preferred_theme: 'dark',
|
||||
},
|
||||
message: isAdminRegistration ? 'Admin account registered' : 'Registration successful',
|
||||
session: { access_token: accessToken },
|
||||
message: isAdminRegistration
|
||||
? 'Admin account registered'
|
||||
: inviter
|
||||
? `Registration successful, invitation bonus ${INVITATION_BONUS_CREDITS} credits added`
|
||||
: 'Registration successful',
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import { getAuthenticatedUserId } from '@/lib/session-auth';
|
||||
import { isTrustedInternalGenerationRequest, isUuid } from '@/lib/server-api-config';
|
||||
import {
|
||||
ensureLocalImageThumbnail,
|
||||
ensureLocalVideoThumbnail,
|
||||
isCurrentLocalImageThumbnail,
|
||||
isCurrentLocalVideoThumbnail,
|
||||
} from '@/lib/media-storage';
|
||||
import {
|
||||
getReferenceImageInputs,
|
||||
getReferenceThumbnailInputs,
|
||||
persistReferenceImages,
|
||||
} from '@/lib/reference-image-storage';
|
||||
|
||||
const workThumbnailQueue = new Map<string, Record<string, unknown>>();
|
||||
let workThumbnailProcessing = false;
|
||||
const DEFAULT_HISTORY_LIMIT = 300;
|
||||
const MAX_HISTORY_LIMIT = 300;
|
||||
const HISTORY_MODE_VALUES = new Set(['text2img', 'img2img', 'text2video', 'img2video', 'reverse-prompt']);
|
||||
|
||||
function toWorkType(type: string, params: Record<string, unknown>): string {
|
||||
const explicitMode = params.creationMode || params.workType || params.mode;
|
||||
@@ -20,50 +38,233 @@ function fromWorkType(type: string): 'image' | 'video' | 'reverse-prompt' {
|
||||
return type.includes('video') ? 'video' : 'image';
|
||||
}
|
||||
|
||||
function isVideoWorkType(type: string): boolean {
|
||||
return type === 'text2video' || type === 'img2video' || type === 'video';
|
||||
}
|
||||
|
||||
function mapWork(row: Record<string, unknown>) {
|
||||
const params = (row.params || {}) as Record<string, unknown>;
|
||||
const referenceImages = Array.isArray(params.referenceImages)
|
||||
? params.referenceImages.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
||||
: typeof params.referenceImage === 'string' && params.referenceImage.trim()
|
||||
? [params.referenceImage]
|
||||
: undefined;
|
||||
const referenceImageThumbnails = getReferenceThumbnailInputs(params);
|
||||
return {
|
||||
id: row.id,
|
||||
type: fromWorkType(String(row.type || 'text2img')),
|
||||
url: row.result_url,
|
||||
thumbnailUrl: row.thumbnail_url || undefined,
|
||||
width: row.width || undefined,
|
||||
height: row.height || undefined,
|
||||
prompt: row.prompt || '',
|
||||
negativePrompt: row.negative_prompt || undefined,
|
||||
model: params.model || '',
|
||||
modelLabel: params.modelLabel || params.model || '',
|
||||
isCustomModel: Boolean(params.isCustomModel),
|
||||
params,
|
||||
referenceImage: params.referenceImage,
|
||||
referenceImages: Array.isArray(params.referenceImages)
|
||||
? params.referenceImages
|
||||
: params.referenceImage
|
||||
? [params.referenceImage]
|
||||
: undefined,
|
||||
referenceImage: referenceImages?.[0],
|
||||
referenceImages,
|
||||
referenceImageThumbnails: referenceImageThumbnails.length > 0 ? referenceImageThumbnails : undefined,
|
||||
creditsCost: Number(row.credits_cost || 0),
|
||||
published: row.is_public === true,
|
||||
publishedAt: row.is_public === true ? row.created_at : undefined,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
function getPositiveInteger(value: unknown): number | null {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) && number > 0 ? Math.round(number) : null;
|
||||
}
|
||||
|
||||
function getHistoryLimit(value: string | null): number {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number) || number <= 0) return DEFAULT_HISTORY_LIMIT;
|
||||
return Math.min(MAX_HISTORY_LIMIT, Math.max(1, Math.round(number)));
|
||||
}
|
||||
|
||||
function getHistoryMode(value: string | null): string | null {
|
||||
if (!value) return null;
|
||||
if (value === 'reversePrompt') return 'reverse-prompt';
|
||||
return HISTORY_MODE_VALUES.has(value) ? value : null;
|
||||
}
|
||||
|
||||
function getHistoryModeCondition(): string {
|
||||
const hasReferenceCondition = `(
|
||||
NULLIF(params->>'referenceImage', '') IS NOT NULL
|
||||
OR (CASE WHEN jsonb_typeof(params->'referenceImages') = 'array' THEN jsonb_array_length(params->'referenceImages') ELSE 0 END) > 0
|
||||
OR (CASE WHEN (params->>'refImageCount') ~ '^[0-9]+$' THEN (params->>'refImageCount')::int ELSE 0 END) > 0
|
||||
)`;
|
||||
return `AND (
|
||||
type = $2
|
||||
OR params->>'creationMode' = $2
|
||||
OR params->>'workType' = $2
|
||||
OR params->>'mode' = $2
|
||||
OR (
|
||||
$2 IN ('text2img', 'img2img')
|
||||
AND type = 'image'
|
||||
AND (
|
||||
($2 = 'img2img' AND ${hasReferenceCondition})
|
||||
OR ($2 = 'text2img' AND NOT ${hasReferenceCondition})
|
||||
)
|
||||
)
|
||||
OR (
|
||||
$2 IN ('text2video', 'img2video')
|
||||
AND type = 'video'
|
||||
AND (
|
||||
($2 = 'img2video' AND ${hasReferenceCondition})
|
||||
OR ($2 = 'text2video' AND NOT ${hasReferenceCondition})
|
||||
)
|
||||
)
|
||||
)`;
|
||||
}
|
||||
|
||||
async function ensureWorkThumbnail(client: Awaited<ReturnType<typeof getDbClient>>, row: Record<string, unknown>) {
|
||||
const type = String(row.type || '');
|
||||
if (typeof row.result_url !== 'string') return row;
|
||||
if (isVideoWorkType(type)) {
|
||||
if (isCurrentLocalVideoThumbnail(row.thumbnail_url)) return row;
|
||||
try {
|
||||
const thumbnailUrl = await ensureLocalVideoThumbnail(row.result_url, 'thumbnails/works/videos', String(row.prompt || 'Video'));
|
||||
if (!thumbnailUrl) return row;
|
||||
await client.query('UPDATE works SET thumbnail_url = $1 WHERE id = $2', [thumbnailUrl, row.id]);
|
||||
return { ...row, thumbnail_url: thumbnailUrl };
|
||||
} catch (error) {
|
||||
console.warn('[creation-history] video thumbnail generation failed:', error instanceof Error ? error.message : error);
|
||||
return row;
|
||||
}
|
||||
}
|
||||
if (isCurrentLocalImageThumbnail(row.thumbnail_url)) return row;
|
||||
if (type !== 'text2img' && type !== 'img2img') return row;
|
||||
try {
|
||||
const thumbnailUrl = await ensureLocalImageThumbnail(row.result_url, 'thumbnails/works');
|
||||
if (!thumbnailUrl) return row;
|
||||
await client.query('UPDATE works SET thumbnail_url = $1 WHERE id = $2', [thumbnailUrl, row.id]);
|
||||
return { ...row, thumbnail_url: thumbnailUrl };
|
||||
} catch (error) {
|
||||
console.warn('[creation-history] thumbnail generation failed:', error instanceof Error ? error.message : error);
|
||||
return row;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleWorkThumbnail(row: Record<string, unknown>) {
|
||||
const type = String(row.type || '');
|
||||
if (typeof row.result_url !== 'string') return;
|
||||
if (isVideoWorkType(type)) {
|
||||
if (isCurrentLocalVideoThumbnail(row.thumbnail_url)) return;
|
||||
} else {
|
||||
if (isCurrentLocalImageThumbnail(row.thumbnail_url) || (type !== 'text2img' && type !== 'img2img')) return;
|
||||
}
|
||||
const id = String(row.id || row.result_url);
|
||||
workThumbnailQueue.set(id, row);
|
||||
if (workThumbnailProcessing) return;
|
||||
workThumbnailProcessing = true;
|
||||
void (async () => {
|
||||
try {
|
||||
while (workThumbnailQueue.size > 0) {
|
||||
const [nextId, nextRow] = workThumbnailQueue.entries().next().value as [string, Record<string, unknown>];
|
||||
workThumbnailQueue.delete(nextId);
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
await ensureWorkThumbnail(client, nextRow);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[creation-history] scheduled thumbnail generation failed:', error instanceof Error ? error.message : error);
|
||||
} finally {
|
||||
workThumbnailProcessing = false;
|
||||
if (workThumbnailQueue.size > 0) scheduleWorkThumbnail(workThumbnailQueue.values().next().value as Record<string, unknown>);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
function dedupeRowsByResultUrl(rows: Record<string, unknown>[]) {
|
||||
const seen = new Set<string>();
|
||||
const deduped: Record<string, unknown>[] = [];
|
||||
for (const row of rows) {
|
||||
const key = typeof row.result_url === 'string' && row.result_url.trim()
|
||||
? row.result_url
|
||||
: String(row.id || '');
|
||||
if (seen.has(key)) {
|
||||
const target = deduped.find(item => (
|
||||
typeof item.result_url === 'string' && item.result_url.trim()
|
||||
? item.result_url
|
||||
: String(item.id || '')
|
||||
) === key);
|
||||
if (target) mergeWorkRowMetadata(target, row);
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
for (const candidate of rows) {
|
||||
const candidateKey = typeof candidate.result_url === 'string' && candidate.result_url.trim()
|
||||
? candidate.result_url
|
||||
: String(candidate.id || '');
|
||||
if (candidateKey === key && candidate !== row) mergeWorkRowMetadata(row, candidate);
|
||||
}
|
||||
deduped.push(row);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function hasReferenceMetadata(params: Record<string, unknown>): boolean {
|
||||
return typeof params.referenceImage === 'string' && params.referenceImage.trim().length > 0
|
||||
|| (Array.isArray(params.referenceImages) && params.referenceImages.length > 0);
|
||||
}
|
||||
|
||||
function mergeWorkRowMetadata(target: Record<string, unknown>, source: Record<string, unknown>) {
|
||||
const targetParams = (target.params || {}) as Record<string, unknown>;
|
||||
const sourceParams = (source.params || {}) as Record<string, unknown>;
|
||||
if (!target.thumbnail_url && source.thumbnail_url) target.thumbnail_url = source.thumbnail_url;
|
||||
if (!target.width && source.width) target.width = source.width;
|
||||
if (!target.height && source.height) target.height = source.height;
|
||||
if (!hasReferenceMetadata(targetParams) && hasReferenceMetadata(sourceParams)) {
|
||||
target.params = {
|
||||
...targetParams,
|
||||
referenceImage: sourceParams.referenceImage,
|
||||
referenceImages: sourceParams.referenceImages,
|
||||
referenceImageThumbnails: sourceParams.referenceImageThumbnails,
|
||||
refImageCount: sourceParams.refImageCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function historyRecordDedupeLockKey(userId: string, url: string): string {
|
||||
return `${userId}:${url}`;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const userId = await getAuthenticatedUserId(request);
|
||||
if (!userId) return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
||||
const limit = getHistoryLimit(request.nextUrl.searchParams.get('limit'));
|
||||
const mode = getHistoryMode(request.nextUrl.searchParams.get('mode'));
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const result = await client.query(
|
||||
`SELECT id, type, prompt, negative_prompt, params, result_url, is_public, status, created_at
|
||||
const sql = `SELECT id, type, prompt, negative_prompt, params, result_url, thumbnail_url, width, height, is_public, status, credits_cost, created_at
|
||||
FROM works
|
||||
WHERE user_id = $1 AND status = 'completed'
|
||||
${mode ? getHistoryModeCondition() : ''}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 300`,
|
||||
[userId],
|
||||
);
|
||||
return NextResponse.json({ records: result.rows.map(mapWork) });
|
||||
LIMIT $${mode ? 3 : 2}`;
|
||||
const result = await client.query(sql, mode ? [userId, mode, limit] : [userId, limit]);
|
||||
const rows = dedupeRowsByResultUrl(result.rows);
|
||||
for (const row of rows) scheduleWorkThumbnail(row);
|
||||
return NextResponse.json({ records: rows.map(mapWork) });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const userId = await getAuthenticatedUserId(request);
|
||||
const trustedInternalRequest = isTrustedInternalGenerationRequest(request);
|
||||
const trustedUserId = trustedInternalRequest
|
||||
? request.headers.get('x-miaojing-generation-user-id')
|
||||
: null;
|
||||
const userId = isUuid(trustedUserId)
|
||||
? trustedUserId
|
||||
: await getAuthenticatedUserId(request);
|
||||
if (!userId) return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
||||
const body = await request.json();
|
||||
const records = Array.isArray(body.records) ? body.records : [body];
|
||||
@@ -72,7 +273,7 @@ export async function POST(request: NextRequest) {
|
||||
await client.query('BEGIN');
|
||||
const saved = [];
|
||||
for (const record of records) {
|
||||
const params = {
|
||||
const initialParams = {
|
||||
...(record.params || {}),
|
||||
model: record.model || (record.params || {}).model,
|
||||
modelLabel: record.modelLabel || (record.params || {}).modelLabel,
|
||||
@@ -80,27 +281,90 @@ export async function POST(request: NextRequest) {
|
||||
referenceImage: record.referenceImage || (record.params || {}).referenceImage,
|
||||
referenceImages: record.referenceImages || (record.params || {}).referenceImages,
|
||||
};
|
||||
const persistedReferences = await persistReferenceImages(getReferenceImageInputs({
|
||||
referenceImage: record.referenceImage,
|
||||
referenceImages: record.referenceImages,
|
||||
params: initialParams,
|
||||
}));
|
||||
const referenceImages = persistedReferences.map(item => item.url);
|
||||
const referenceImageThumbnails = persistedReferences.map(item => item.thumbnailUrl || item.url);
|
||||
const params = {
|
||||
...initialParams,
|
||||
referenceImage: referenceImages[0] || undefined,
|
||||
referenceImages: referenceImages.length > 0 ? referenceImages : undefined,
|
||||
referenceImageThumbnails: referenceImageThumbnails.length > 0 ? referenceImageThumbnails : undefined,
|
||||
};
|
||||
const workType = toWorkType(String(record.type || 'image'), params);
|
||||
let url = String(record.url || '').trim();
|
||||
let thumbnailUrl = String(record.thumbnailUrl || '').trim() || null;
|
||||
const width = getPositiveInteger(record.width || (record.params || {}).width);
|
||||
const height = getPositiveInteger(record.height || (record.params || {}).height);
|
||||
if (workType === 'reverse-prompt') {
|
||||
url = url && !url.startsWith('data:') ? url : `[reverse-prompt:${record.id || Date.now()}]`;
|
||||
}
|
||||
if (!url || url.startsWith('data:')) continue;
|
||||
await client.query('SELECT pg_advisory_xact_lock(hashtextextended($1, 0))', [
|
||||
historyRecordDedupeLockKey(userId, url),
|
||||
]);
|
||||
if (!thumbnailUrl && isVideoWorkType(workType)) {
|
||||
try {
|
||||
thumbnailUrl = await ensureLocalVideoThumbnail(url, 'thumbnails/works/videos', String(record.prompt || 'Video'));
|
||||
} catch (error) {
|
||||
console.warn('[creation-history] video thumbnail generation failed:', error instanceof Error ? error.message : error);
|
||||
}
|
||||
}
|
||||
if (!thumbnailUrl && (workType === 'text2img' || workType === 'img2img')) {
|
||||
try {
|
||||
thumbnailUrl = await ensureLocalImageThumbnail(url, 'thumbnails/works');
|
||||
} catch (error) {
|
||||
console.warn('[creation-history] thumbnail generation failed:', error instanceof Error ? error.message : error);
|
||||
}
|
||||
}
|
||||
const existing = await client.query(
|
||||
`SELECT id, type, prompt, negative_prompt, params, result_url, is_public, status, created_at
|
||||
`SELECT id, type, prompt, negative_prompt, params, result_url, thumbnail_url, width, height, is_public, status, credits_cost, created_at
|
||||
FROM works
|
||||
WHERE user_id = $1 AND result_url = $2
|
||||
LIMIT 1`,
|
||||
[userId, url],
|
||||
);
|
||||
if (existing.rows[0]) {
|
||||
saved.push(mapWork(existing.rows[0]));
|
||||
const existingRow = existing.rows[0];
|
||||
const existingParams = (existingRow.params || {}) as Record<string, unknown>;
|
||||
const shouldPatchReferences = referenceImages.length > 0 && (
|
||||
!Array.isArray(existingParams.referenceImages) ||
|
||||
existingParams.referenceImages.length === 0
|
||||
);
|
||||
if ((thumbnailUrl && !existingRow.thumbnail_url) || (width && !existingRow.width) || (height && !existingRow.height) || shouldPatchReferences) {
|
||||
const nextParams = shouldPatchReferences
|
||||
? {
|
||||
...existingParams,
|
||||
referenceImage: referenceImages[0],
|
||||
referenceImages,
|
||||
referenceImageThumbnails,
|
||||
refImageCount: Math.max(Number(existingParams.refImageCount || 0), referenceImages.length),
|
||||
}
|
||||
: existingParams;
|
||||
await client.query(
|
||||
`UPDATE works
|
||||
SET thumbnail_url = COALESCE(thumbnail_url, $1),
|
||||
width = COALESCE(width, $2),
|
||||
height = COALESCE(height, $3),
|
||||
params = $4::jsonb
|
||||
WHERE id = $5`,
|
||||
[thumbnailUrl, width, height, JSON.stringify(nextParams), existingRow.id],
|
||||
);
|
||||
existingRow.thumbnail_url = existingRow.thumbnail_url || thumbnailUrl;
|
||||
existingRow.width = existingRow.width || width;
|
||||
existingRow.height = existingRow.height || height;
|
||||
existingRow.params = nextParams;
|
||||
}
|
||||
saved.push(mapWork(existingRow));
|
||||
continue;
|
||||
}
|
||||
const result = await client.query(
|
||||
`INSERT INTO works (user_id, type, prompt, negative_prompt, params, result_url, is_public, status, credits_cost, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7, 'completed', $8, COALESCE($9::timestamptz, NOW()))
|
||||
RETURNING id, type, prompt, negative_prompt, params, result_url, is_public, status, created_at`,
|
||||
`INSERT INTO works (user_id, type, prompt, negative_prompt, params, result_url, thumbnail_url, width, height, is_public, status, credits_cost, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7, $8, $9, $10, 'completed', $11, COALESCE($12::timestamptz, NOW()))
|
||||
RETURNING id, type, prompt, negative_prompt, params, result_url, thumbnail_url, width, height, is_public, status, credits_cost, created_at`,
|
||||
[
|
||||
userId,
|
||||
workType,
|
||||
@@ -108,7 +372,10 @@ export async function POST(request: NextRequest) {
|
||||
record.negativePrompt || null,
|
||||
JSON.stringify(params),
|
||||
url,
|
||||
Boolean(record.published),
|
||||
thumbnailUrl,
|
||||
width,
|
||||
height,
|
||||
Boolean(record.published && record.publishedAt),
|
||||
Number(record.creditsCost || 0),
|
||||
record.createdAt || null,
|
||||
],
|
||||
|
||||
40
src/app/api/credit-transactions/route.ts
Normal file
40
src/app/api/credit-transactions/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAuthenticatedUserId } from '@/lib/session-auth';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const userId = await getAuthenticatedUserId(request);
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const limit = Math.min(Math.max(Number(request.nextUrl.searchParams.get('limit') || 100), 20), 300);
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const result = await client.query(
|
||||
`SELECT id, amount, balance_after, type, description, created_at
|
||||
FROM credit_transactions
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2`,
|
||||
[userId, limit],
|
||||
);
|
||||
return NextResponse.json({
|
||||
records: result.rows.map(row => ({
|
||||
id: row.id,
|
||||
amount: Number(row.amount || 0),
|
||||
balanceAfter: Number(row.balance_after || 0),
|
||||
type: row.type || '',
|
||||
description: row.description || '',
|
||||
createdAt: row.created_at ? new Date(row.created_at).toISOString() : '',
|
||||
})),
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[credit-transactions] GET error:', error);
|
||||
return NextResponse.json({ error: '获取积分记录失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import path from 'path';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { localStorage } from '@/lib/local-storage';
|
||||
import { serveWatermarkedDownloadFile } from '@/lib/media-watermark';
|
||||
import {
|
||||
canAccessOriginalMedia,
|
||||
resolveMediaWatermarkAccess,
|
||||
shouldWatermarkDownloadResponse,
|
||||
} from '@/lib/media-watermark-policy';
|
||||
import { fetchPublicHttpUrl } from '@/lib/remote-fetch';
|
||||
|
||||
/**
|
||||
@@ -16,15 +22,20 @@ export async function GET(request: NextRequest) {
|
||||
const filename = sanitizeFilename(
|
||||
request.nextUrl.searchParams.get('filename') || 'download',
|
||||
);
|
||||
const disposition = request.nextUrl.searchParams.get('disposition') === 'inline'
|
||||
|| request.nextUrl.searchParams.get('inline') === '1'
|
||||
? 'inline'
|
||||
: 'attachment';
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json({ error: '缺少 url 参数' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const watermarkAccess = await resolveMediaWatermarkAccess(request);
|
||||
const localKey = getLocalStorageKey(url);
|
||||
if (localKey) {
|
||||
return downloadLocalStorageFile(localKey, filename);
|
||||
return await downloadLocalStorageFile(localKey, filename, disposition, watermarkAccess);
|
||||
}
|
||||
|
||||
const targetUrl = resolveDownloadUrl(url, request.nextUrl.origin);
|
||||
@@ -54,6 +65,7 @@ export async function GET(request: NextRequest) {
|
||||
contentType,
|
||||
filename,
|
||||
body.byteLength,
|
||||
disposition,
|
||||
);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : '下载失败';
|
||||
@@ -99,13 +111,52 @@ function resolveDownloadUrl(url: string, origin: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function downloadLocalStorageFile(key: string, filename: string) {
|
||||
if (!localStorage.fileExists(key)) {
|
||||
async function downloadLocalStorageFile(
|
||||
key: string,
|
||||
filename: string,
|
||||
disposition: 'attachment' | 'inline',
|
||||
watermarkAccess: Awaited<ReturnType<typeof resolveMediaWatermarkAccess>>,
|
||||
) {
|
||||
const contentType = getContentType(key);
|
||||
const shouldWatermark = shouldWatermarkDownloadResponse(key, contentType, watermarkAccess);
|
||||
const mayAccessOriginal = canAccessOriginalMedia(watermarkAccess);
|
||||
|
||||
if (shouldWatermark) {
|
||||
try {
|
||||
const watermarked = await serveWatermarkedDownloadFile(key, contentType);
|
||||
return buildDownloadResponse(
|
||||
watermarked.buffer.buffer.slice(
|
||||
watermarked.buffer.byteOffset,
|
||||
watermarked.buffer.byteOffset + watermarked.buffer.byteLength,
|
||||
) as ArrayBuffer,
|
||||
watermarked.contentType,
|
||||
filename,
|
||||
watermarked.buffer.byteLength,
|
||||
disposition,
|
||||
);
|
||||
} catch {
|
||||
return NextResponse.json({ error: '文件不存在' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
const shouldTryObjectRedirect = contentType.startsWith('video/') || !localStorage.fileExists(key);
|
||||
if ((mayAccessOriginal || !shouldWatermark) && shouldTryObjectRedirect && await localStorage.objectFileExistsAsync(key)) {
|
||||
const objectUrl = localStorage.generateObjectReadUrl(key, 300, {
|
||||
contentDisposition: buildContentDisposition(disposition, filename),
|
||||
contentType,
|
||||
});
|
||||
if (objectUrl) {
|
||||
const response = NextResponse.redirect(objectUrl, 302);
|
||||
response.headers.set('Cache-Control', disposition === 'inline' ? 'private, max-age=60' : 'no-cache');
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
if (!await localStorage.fileExistsAsync(key)) {
|
||||
return NextResponse.json({ error: '文件不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
const fileBuffer = localStorage.readFile(key);
|
||||
const contentType = getContentType(key);
|
||||
const fileBuffer = await localStorage.readFileAsync(key);
|
||||
|
||||
return buildDownloadResponse(
|
||||
fileBuffer.buffer.slice(
|
||||
@@ -115,6 +166,7 @@ function downloadLocalStorageFile(key: string, filename: string) {
|
||||
contentType,
|
||||
filename,
|
||||
fileBuffer.byteLength,
|
||||
disposition,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,14 +175,17 @@ function buildDownloadResponse(
|
||||
contentType: string,
|
||||
filename: string,
|
||||
length: number,
|
||||
disposition: 'attachment' | 'inline',
|
||||
) {
|
||||
return new NextResponse(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
'Content-Disposition': buildContentDisposition(disposition, filename),
|
||||
'Content-Length': String(length),
|
||||
'Cache-Control': 'no-cache',
|
||||
'Cache-Control': disposition === 'inline'
|
||||
? 'public, max-age=86400, stale-while-revalidate=604800'
|
||||
: 'no-cache',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -139,6 +194,10 @@ function sanitizeFilename(filename: string): string {
|
||||
return path.basename(filename).replace(/[\r\n"]/g, '_') || 'download';
|
||||
}
|
||||
|
||||
function buildContentDisposition(disposition: 'attachment' | 'inline', filename: string): string {
|
||||
return `${disposition}; filename="${filename}"`;
|
||||
}
|
||||
|
||||
function getContentType(filePath: string): string {
|
||||
const extension = filePath.split('.').pop()?.toLowerCase();
|
||||
const contentTypeMap: Record<string, string> = {
|
||||
@@ -147,6 +206,7 @@ function getContentType(filePath: string): string {
|
||||
png: 'image/png',
|
||||
webp: 'image/webp',
|
||||
gif: 'image/gif',
|
||||
svg: 'image/svg+xml',
|
||||
mp4: 'video/mp4',
|
||||
avi: 'video/x-msvideo',
|
||||
mov: 'video/quicktime',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import { localStorage } from '@/lib/local-storage';
|
||||
import { getAuthenticatedUserId } from '@/lib/session-auth';
|
||||
import { resolveGalleryPublishMedia, resolveGalleryReferenceImages } from '@/lib/gallery-publish-media';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -42,10 +42,15 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: '发布用户不存在或已停用' }, { status: 403 });
|
||||
}
|
||||
|
||||
const hasReference = Boolean(body.referenceImage)
|
||||
|| (Array.isArray(body.referenceImages) && body.referenceImages.length > 0)
|
||||
|| (Array.isArray((params as Record<string, unknown> | undefined)?.referenceImages) && ((params as Record<string, unknown>).referenceImages as unknown[]).length > 0);
|
||||
const explicitMode = (params as Record<string, unknown> | undefined)?.creationMode || body.creationMode;
|
||||
const paramsRecord = (params as Record<string, unknown> | undefined) || {};
|
||||
const referenceInput = [
|
||||
body.referenceImage,
|
||||
...(Array.isArray(body.referenceImages) ? body.referenceImages : []),
|
||||
paramsRecord.referenceImage,
|
||||
...(Array.isArray(paramsRecord.referenceImages) ? paramsRecord.referenceImages : []),
|
||||
].filter((item): item is string => typeof item === 'string' && item.trim().length > 0);
|
||||
const hasReference = referenceInput.length > 0;
|
||||
const explicitMode = paramsRecord.creationMode || body.creationMode;
|
||||
const workType = explicitMode === 'text2img' || explicitMode === 'img2img' || explicitMode === 'text2video' || explicitMode === 'img2video'
|
||||
? explicitMode
|
||||
: type === 'video' ? (hasReference ? 'img2video' : 'text2video')
|
||||
@@ -58,13 +63,24 @@ export async function POST(request: NextRequest) {
|
||||
let galleryResultUrl = resultUrl;
|
||||
let galleryThumbnailUrl = thumbnailUrl || null;
|
||||
try {
|
||||
const folder = type === 'video' ? 'gallery/videos' : 'gallery/images';
|
||||
galleryResultUrl = await localStorage.copyPublicUrlToFolder(resultUrl, folder);
|
||||
if (thumbnailUrl) {
|
||||
galleryThumbnailUrl = await localStorage.copyPublicUrlToFolder(thumbnailUrl, 'gallery/thumbnails');
|
||||
}
|
||||
const media = await resolveGalleryPublishMedia({
|
||||
type,
|
||||
resultUrl,
|
||||
thumbnailUrl,
|
||||
prompt,
|
||||
});
|
||||
galleryResultUrl = media.resultUrl;
|
||||
galleryThumbnailUrl = media.thumbnailUrl;
|
||||
} catch (copyError) {
|
||||
console.warn('[gallery/publish] copy to gallery folder failed, using original URL:', copyError);
|
||||
console.warn('[gallery/publish] prepare gallery media failed:', copyError);
|
||||
return NextResponse.json({ error: '发布作品媒体处理失败,请重试' }, { status: 502 });
|
||||
}
|
||||
let galleryReferenceImages: string[] = [];
|
||||
try {
|
||||
galleryReferenceImages = await resolveGalleryReferenceImages(referenceInput);
|
||||
} catch (referenceError) {
|
||||
console.warn('[gallery/publish] prepare gallery reference images failed:', referenceError);
|
||||
return NextResponse.json({ error: '发布参考图处理失败,请重试' }, { status: 502 });
|
||||
}
|
||||
|
||||
await client.query(
|
||||
@@ -84,16 +100,16 @@ export async function POST(request: NextRequest) {
|
||||
duration || null,
|
||||
creditsCost || 0,
|
||||
JSON.stringify({
|
||||
...((params as Record<string, unknown>) || {}),
|
||||
...paramsRecord,
|
||||
model,
|
||||
modelLabel,
|
||||
referenceImage: body.referenceImage || undefined,
|
||||
referenceImages: body.referenceImages || undefined,
|
||||
referenceImage: galleryReferenceImages[0],
|
||||
referenceImages: galleryReferenceImages.length > 0 ? galleryReferenceImages : undefined,
|
||||
}),
|
||||
]
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true, workId: id, resultUrl: galleryResultUrl });
|
||||
return NextResponse.json({ success: true, workId: id, resultUrl: galleryResultUrl, referenceImages: galleryReferenceImages });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
@@ -1,22 +1,129 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdmin } from '@/lib/admin-auth';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import {
|
||||
ensureLocalImageThumbnail,
|
||||
ensureLocalVideoThumbnail,
|
||||
isCurrentLocalImageThumbnail,
|
||||
isCurrentLocalVideoThumbnail,
|
||||
} from '@/lib/media-storage';
|
||||
import { MAX_PUBLIC_GALLERY_AVATAR_URL_LENGTH, toPublicGalleryWork } from '@/lib/gallery-response';
|
||||
|
||||
function getReferenceImages(params: Record<string, unknown>) {
|
||||
const referenceImages = Array.isArray(params.referenceImages)
|
||||
? params.referenceImages.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
||||
: [];
|
||||
const referenceImage = typeof params.referenceImage === 'string' && params.referenceImage.trim()
|
||||
? params.referenceImage
|
||||
: referenceImages[0];
|
||||
return { referenceImage, referenceImages };
|
||||
const galleryThumbnailQueue = new Map<string, Record<string, unknown>>();
|
||||
let galleryThumbnailProcessing = false;
|
||||
|
||||
function hasGalleryReferenceMetadata(params: Record<string, unknown>) {
|
||||
return typeof params.referenceImage === 'string' && params.referenceImage.trim().length > 0
|
||||
|| (Array.isArray(params.referenceImages) && params.referenceImages.length > 0);
|
||||
}
|
||||
|
||||
function mergeGalleryRowMetadata(target: Record<string, unknown>, source: Record<string, unknown>) {
|
||||
const targetParams = (target.params || {}) as Record<string, unknown>;
|
||||
const sourceParams = (source.params || {}) as Record<string, unknown>;
|
||||
if (!target.thumbnail_url && source.thumbnail_url) target.thumbnail_url = source.thumbnail_url;
|
||||
if (!target.width && source.width) target.width = source.width;
|
||||
if (!target.height && source.height) target.height = source.height;
|
||||
if (!hasGalleryReferenceMetadata(targetParams) && hasGalleryReferenceMetadata(sourceParams)) {
|
||||
target.params = {
|
||||
...targetParams,
|
||||
referenceImage: sourceParams.referenceImage,
|
||||
referenceImages: sourceParams.referenceImages,
|
||||
referenceImageThumbnails: sourceParams.referenceImageThumbnails,
|
||||
refImageCount: sourceParams.refImageCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function dedupeGalleryRowsByResultUrl(rows: Record<string, unknown>[], metadataRows: Record<string, unknown>[] = []) {
|
||||
const byUrl = new Map<string, Record<string, unknown>[]>();
|
||||
for (const row of [...rows, ...metadataRows]) {
|
||||
if (typeof row.result_url !== 'string' || !row.result_url.trim()) continue;
|
||||
const group = byUrl.get(row.result_url) || [];
|
||||
group.push(row);
|
||||
byUrl.set(row.result_url, group);
|
||||
}
|
||||
return rows.map(row => {
|
||||
if (typeof row.result_url !== 'string' || !row.result_url.trim()) return row;
|
||||
const group = byUrl.get(row.result_url) || [];
|
||||
for (const candidate of group) {
|
||||
if (candidate.user_id && row.user_id && candidate.user_id !== row.user_id) continue;
|
||||
if (candidate !== row) mergeGalleryRowMetadata(row, candidate);
|
||||
}
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureGalleryThumbnail(client: Awaited<ReturnType<typeof getDbClient>>, row: Record<string, unknown>) {
|
||||
const type = String(row.type || '');
|
||||
if (typeof row.result_url !== 'string') return row;
|
||||
if (type === 'text2video' || type === 'img2video') {
|
||||
if (isCurrentLocalVideoThumbnail(row.thumbnail_url)) return row;
|
||||
try {
|
||||
const thumbnailUrl = await ensureLocalVideoThumbnail(row.result_url, 'thumbnails/gallery/videos', String(row.prompt || 'Video'));
|
||||
if (!thumbnailUrl) return row;
|
||||
await client.query('UPDATE works SET thumbnail_url = $1 WHERE id = $2', [thumbnailUrl, row.id]);
|
||||
return { ...row, thumbnail_url: thumbnailUrl };
|
||||
} catch (error) {
|
||||
console.warn('[gallery] video thumbnail generation failed:', error instanceof Error ? error.message : error);
|
||||
return row;
|
||||
}
|
||||
}
|
||||
if (isCurrentLocalImageThumbnail(row.thumbnail_url)) return row;
|
||||
if (type !== 'text2img' && type !== 'img2img') return row;
|
||||
try {
|
||||
const thumbnailUrl = await ensureLocalImageThumbnail(row.result_url, 'thumbnails/gallery');
|
||||
if (!thumbnailUrl) return row;
|
||||
await client.query('UPDATE works SET thumbnail_url = $1 WHERE id = $2', [thumbnailUrl, row.id]);
|
||||
return { ...row, thumbnail_url: thumbnailUrl };
|
||||
} catch (error) {
|
||||
console.warn('[gallery] thumbnail generation failed:', error instanceof Error ? error.message : error);
|
||||
return row;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleGalleryThumbnail(row: Record<string, unknown>) {
|
||||
const type = String(row.type || '');
|
||||
if (typeof row.result_url !== 'string') return;
|
||||
if (type === 'text2video' || type === 'img2video') {
|
||||
if (isCurrentLocalVideoThumbnail(row.thumbnail_url)) return;
|
||||
} else {
|
||||
if (isCurrentLocalImageThumbnail(row.thumbnail_url) || (type !== 'text2img' && type !== 'img2img')) return;
|
||||
}
|
||||
const id = String(row.id || row.result_url);
|
||||
galleryThumbnailQueue.set(id, row);
|
||||
if (galleryThumbnailProcessing) return;
|
||||
galleryThumbnailProcessing = true;
|
||||
void (async () => {
|
||||
try {
|
||||
while (galleryThumbnailQueue.size > 0) {
|
||||
const [nextId, nextRow] = galleryThumbnailQueue.entries().next().value as [string, Record<string, unknown>];
|
||||
galleryThumbnailQueue.delete(nextId);
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
await ensureGalleryThumbnail(client, nextRow);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[gallery] scheduled thumbnail generation failed:', error instanceof Error ? error.message : error);
|
||||
} finally {
|
||||
galleryThumbnailProcessing = false;
|
||||
if (galleryThumbnailQueue.size > 0) {
|
||||
scheduleGalleryThumbnail(galleryThumbnailQueue.values().next().value as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const url = request.nextUrl.searchParams;
|
||||
const type = url.get('type');
|
||||
const limit = Math.min(parseInt(url.get('limit') || '50', 10), 300);
|
||||
const offset = parseInt(url.get('offset') || '0', 10);
|
||||
const category = url.get('category');
|
||||
const requestedLimit = parseInt(url.get('limit') || '50', 10);
|
||||
const requestedOffset = parseInt(url.get('offset') || '0', 10);
|
||||
const limit = Number.isFinite(requestedLimit) ? Math.max(1, Math.min(requestedLimit, 300)) : 50;
|
||||
const offset = Number.isFinite(requestedOffset) ? Math.max(0, requestedOffset) : 0;
|
||||
const sort = url.get('sort') || 'newest';
|
||||
const search = (url.get('q') || url.get('search') || '').trim().toLowerCase();
|
||||
|
||||
@@ -24,7 +131,11 @@ export async function GET(request: NextRequest) {
|
||||
const client = await getDbClient();
|
||||
|
||||
try {
|
||||
const where: string[] = ['w.is_public = true', 'w.status = $1'];
|
||||
const where: string[] = [
|
||||
'w.is_public = true',
|
||||
'w.status = $1',
|
||||
"w.result_url LIKE '/api/local-storage/%'",
|
||||
];
|
||||
const params: unknown[] = ['completed'];
|
||||
|
||||
if (type === 'image') {
|
||||
@@ -35,6 +146,15 @@ export async function GET(request: NextRequest) {
|
||||
where.push(`w.type IN ($${params.length - 1}, $${params.length})`);
|
||||
}
|
||||
|
||||
if (category === 'text2img' || category === 'img2img' || category === 'text2video' || category === 'img2video') {
|
||||
params.push(category);
|
||||
const idx = params.length;
|
||||
where.push(`(
|
||||
w.type = $${idx}
|
||||
OR COALESCE(w.params->>'creationMode', w.params->>'workType', w.params->>'mode') = $${idx}
|
||||
)`);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
params.push(`%${search}%`);
|
||||
const idx = params.length;
|
||||
@@ -42,6 +162,7 @@ export async function GET(request: NextRequest) {
|
||||
LOWER(COALESCE(w.title, '')) LIKE $${idx}
|
||||
OR LOWER(COALESCE(w.prompt, '')) LIKE $${idx}
|
||||
OR LOWER(COALESCE(w.negative_prompt, '')) LIKE $${idx}
|
||||
OR LOWER(COALESCE(p.display_nickname, p.nickname, '')) LIKE $${idx}
|
||||
OR LOWER(COALESCE(p.nickname, '')) LIKE $${idx}
|
||||
OR LOWER(COALESCE(p.email, '')) LIKE $${idx}
|
||||
OR LOWER(COALESCE(w.params::text, '')) LIKE $${idx}
|
||||
@@ -52,7 +173,12 @@ export async function GET(request: NextRequest) {
|
||||
SELECT w.id, w.type, w.title, w.prompt, w.negative_prompt, w.result_url, w.thumbnail_url,
|
||||
w.width, w.height, w.duration, w.is_public, w.likes_count, w.credits_cost,
|
||||
w.status, w.created_at, w.user_id, w.params,
|
||||
p.nickname, p.email, p.avatar_url
|
||||
p.nickname, p.display_nickname, p.email,
|
||||
CASE
|
||||
WHEN p.avatar_url IS NULL OR p.avatar_url = '' THEN NULL
|
||||
WHEN p.avatar_url LIKE 'data:%' OR length(p.avatar_url) > ${MAX_PUBLIC_GALLERY_AVATAR_URL_LENGTH} THEN NULL
|
||||
ELSE p.avatar_url
|
||||
END AS avatar_url
|
||||
FROM works w
|
||||
LEFT JOIN profiles p ON p.id = w.user_id
|
||||
WHERE ${where.join(' AND ')}
|
||||
@@ -75,33 +201,41 @@ export async function GET(request: NextRequest) {
|
||||
params,
|
||||
);
|
||||
|
||||
const works = (result.rows || []).map((w: Record<string, unknown>) => {
|
||||
const workParams = (w.params || {}) as Record<string, unknown>;
|
||||
const references = getReferenceImages(workParams);
|
||||
return {
|
||||
id: w.id,
|
||||
type: w.type,
|
||||
title: w.title,
|
||||
prompt: w.prompt,
|
||||
negativePrompt: w.negative_prompt,
|
||||
url: w.result_url,
|
||||
thumbnailUrl: w.thumbnail_url,
|
||||
width: w.width,
|
||||
height: w.height,
|
||||
duration: w.duration,
|
||||
likes: w.likes_count || 0,
|
||||
creditsCost: w.credits_cost || 0,
|
||||
params: workParams,
|
||||
referenceImage: references.referenceImage,
|
||||
referenceImages: references.referenceImages,
|
||||
publisherId: w.user_id,
|
||||
publisherNickname: (w.nickname as string) || ((w.email as string) || '').split('@')[0] || '匿名用户',
|
||||
publisherAvatarUrl: (w.avatar_url as string | null) || null,
|
||||
publishedAt: w.created_at,
|
||||
};
|
||||
});
|
||||
for (const row of result.rows || []) scheduleGalleryThumbnail(row);
|
||||
const resultRows = result.rows || [];
|
||||
const resultUrls = [...new Set(resultRows
|
||||
.map((row: Record<string, unknown>) => typeof row.result_url === 'string' ? row.result_url.trim() : '')
|
||||
.filter(Boolean))];
|
||||
let metadataRows: Record<string, unknown>[] = [];
|
||||
if (resultUrls.length > 0) {
|
||||
const metadataResult = await client.query(
|
||||
`SELECT id, result_url, thumbnail_url, width, height, user_id, params
|
||||
FROM works
|
||||
WHERE status = $1
|
||||
AND result_url = ANY($2::text[])`,
|
||||
['completed', resultUrls],
|
||||
);
|
||||
metadataRows = metadataResult.rows || [];
|
||||
}
|
||||
const rows = dedupeGalleryRowsByResultUrl(resultRows, metadataRows);
|
||||
const works = rows.map((row: Record<string, unknown>) => toPublicGalleryWork(row));
|
||||
|
||||
return NextResponse.json({ works, total: parseInt(countResult.rows[0]?.total || '0', 10) });
|
||||
const total = parseInt(countResult.rows[0]?.total || '0', 10);
|
||||
const nextOffset = offset + works.length;
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
works,
|
||||
total,
|
||||
nextOffset,
|
||||
hasMore: nextOffset < total,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': 'private, max-age=30, stale-while-revalidate=120',
|
||||
},
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { buildCustomApiHeaders, fetchWithRetry, parseCustomApiError } from '@/lib/custom-api-fetch';
|
||||
import { localStorage } from '@/lib/local-storage';
|
||||
import { resolveServerApiConfig } from '@/lib/server-api-config';
|
||||
import { isTrustedInternalGenerationRequest, isUuid, resolveServerApiConfig } from '@/lib/server-api-config';
|
||||
import { updateGenerationJobProgress } from '@/lib/generation-job-estimates';
|
||||
|
||||
interface CustomApiConfig {
|
||||
apiUrl: string;
|
||||
@@ -71,6 +72,20 @@ async function persistReferenceImage(image: string): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getPublicAppBaseUrl(request: NextRequest): string {
|
||||
return (process.env.APP_BASE_URL || process.env.NEXT_PUBLIC_APP_URL || request.nextUrl.origin)
|
||||
.trim()
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function toPublicImageUrl(imageUrl: string | null, request: NextRequest): string | null {
|
||||
const value = imageUrl?.trim();
|
||||
if (!value) return null;
|
||||
if (/^https?:\/\//i.test(value)) return value;
|
||||
if (value.startsWith('/')) return `${getPublicAppBaseUrl(request)}${value}`;
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseReversePrompt(content: string): ReversePromptResult {
|
||||
const trimmed = content.trim();
|
||||
const jsonMatch = trimmed.match(/\{[\s\S]*\}/);
|
||||
@@ -194,12 +209,49 @@ export async function POST(request: NextRequest) {
|
||||
if (isDataImage && image.length > MAX_IMAGE_DATA_URL_LENGTH) {
|
||||
return NextResponse.json({ error: '图片过大,请压缩后再上传' }, { status: 400 });
|
||||
}
|
||||
const resolvedCustomApiConfig = await resolveServerApiConfig(request, customApiConfig);
|
||||
const trustedInternalRequest = isTrustedInternalGenerationRequest(request);
|
||||
const trustedUserId = trustedInternalRequest
|
||||
? request.headers.get('x-miaojing-generation-user-id')
|
||||
: null;
|
||||
const generationJobId = trustedInternalRequest
|
||||
? request.headers.get('x-miaojing-generation-job-id')
|
||||
: null;
|
||||
const handleUpstreamProgress = (progress: Record<string, unknown>) => updateGenerationJobProgress(
|
||||
isUuid(generationJobId) ? generationJobId : null,
|
||||
progress,
|
||||
);
|
||||
await handleUpstreamProgress({
|
||||
percent: 10,
|
||||
message: '正在解析参考图片并准备反推提示词',
|
||||
});
|
||||
|
||||
const resolvedCustomApiConfig = await resolveServerApiConfig(
|
||||
request,
|
||||
customApiConfig,
|
||||
isUuid(trustedUserId) ? trustedUserId : null,
|
||||
);
|
||||
if (!resolvedCustomApiConfig?.apiKey || !resolvedCustomApiConfig.apiUrl || !resolvedCustomApiConfig.modelName) {
|
||||
return NextResponse.json({ error: '未配置可用的多模态模型,请先在 API 设置中添加支持图片理解的多模态模型' }, { status: 400 });
|
||||
}
|
||||
console.log(
|
||||
'[Reverse Prompt] Using multimodal model:',
|
||||
resolvedCustomApiConfig.modelName,
|
||||
'| provider:',
|
||||
resolvedCustomApiConfig.provider || 'unknown',
|
||||
'| customApiKeyId:',
|
||||
resolvedCustomApiConfig.customApiKeyId || '',
|
||||
'| systemApiId:',
|
||||
resolvedCustomApiConfig.systemApiId || '',
|
||||
'| apiUrl:',
|
||||
resolvedCustomApiConfig.apiUrl,
|
||||
);
|
||||
const resolvedApiKey = resolvedCustomApiConfig.apiKey;
|
||||
const persistedReferenceImage = await persistReferenceImage(image);
|
||||
const upstreamImage = toPublicImageUrl(persistedReferenceImage, request) || image;
|
||||
await handleUpstreamProgress({
|
||||
percent: 30,
|
||||
message: '已准备图片,正在请求多模态模型',
|
||||
});
|
||||
|
||||
const chatBody = {
|
||||
model: resolvedCustomApiConfig.modelName,
|
||||
@@ -215,7 +267,7 @@ export async function POST(request: NextRequest) {
|
||||
},
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: image },
|
||||
image_url: { url: upstreamImage },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -236,12 +288,16 @@ export async function POST(request: NextRequest) {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return NextResponse.json(
|
||||
{ error: parseCustomApiError(response.status, errorText) },
|
||||
{ error: parseCustomApiError(response.status, errorText, 'multimodal') },
|
||||
{ status: response.status >= 500 ? 502 : response.status },
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
await handleUpstreamProgress({
|
||||
percent: 85,
|
||||
message: '模型已返回,正在整理提示词',
|
||||
});
|
||||
const choices = (data as Record<string, unknown>).choices as Array<Record<string, unknown>> | undefined;
|
||||
const message = choices?.[0]?.message as Record<string, unknown> | undefined;
|
||||
const content = message?.content;
|
||||
|
||||
@@ -118,7 +118,7 @@ export async function POST(request: NextRequest) {
|
||||
const errorText = await response.text();
|
||||
console.error('[Suggest Prompt] API error:', response.status, errorText.slice(0, 200));
|
||||
return NextResponse.json(
|
||||
{ error: parseCustomApiError(response.status, errorText) },
|
||||
{ error: parseCustomApiError(response.status, errorText, 'multimodal') },
|
||||
{ status: response.status >= 500 ? 502 : response.status }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
dataUrlToImageBuffer,
|
||||
imageBufferToDataUrl,
|
||||
} from '@/lib/server-image-compression';
|
||||
import { executeUserApiManifest } from '@/lib/user-api-manifest-executor';
|
||||
import { buildReferenceImagePrompt } from '@/lib/reference-image-prompt';
|
||||
import { fetchPublicHttpUrlWithRetry } from '@/lib/remote-fetch';
|
||||
import { AGNES_PROVIDER_NAME, AGNES_VIDEO_FRAME_RATE, getAgnesVideoNumFrames, normalizeAgnesVideoDuration } from '@/lib/agnes-model-templates';
|
||||
|
||||
interface CustomApiConfig {
|
||||
apiUrl: string;
|
||||
@@ -18,10 +22,12 @@ interface CustomApiConfig {
|
||||
provider: string;
|
||||
customApiKeyId?: string;
|
||||
systemApiId?: string;
|
||||
manifestPath?: string;
|
||||
}
|
||||
|
||||
const GENERATION_TIMEOUT = 180_000;
|
||||
const MAX_UPSTREAM_REFERENCE_IMAGE_BYTES = Number(process.env.MAX_UPSTREAM_REFERENCE_IMAGE_BYTES || 700 * 1024);
|
||||
const AGNES_VIDEO_GENERATION_TIMEOUT = 20 * 60_000;
|
||||
const MAX_UPSTREAM_REFERENCE_IMAGE_BYTES = Number(process.env.MAX_UPSTREAM_REFERENCE_IMAGE_BYTES || 1536 * 1024);
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -32,69 +38,34 @@ export const runtime = 'nodejs';
|
||||
async function persistMediaToStorage(dataUrl: string, prefix: string): Promise<string> {
|
||||
if (!dataUrl.startsWith('data:')) return dataUrl;
|
||||
|
||||
try {
|
||||
const match = dataUrl.match(/^data:((?:image|video)\/[^;]+);base64,(.+)$/);
|
||||
if (!match) return dataUrl;
|
||||
const [, mimeType, base64Data] = match;
|
||||
const ext = mimeType.split('/')[1] || 'mp4';
|
||||
const buffer = Buffer.from(base64Data, 'base64');
|
||||
const fileName = `${prefix}/${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
||||
|
||||
const fileKey = await withTimeout(
|
||||
localStorage.uploadFile({ fileContent: buffer, fileName, contentType: mimeType }),
|
||||
45_000,
|
||||
'Local uploadFile (video)',
|
||||
);
|
||||
|
||||
if (!fileKey) {
|
||||
console.error('[Persist Video Media] uploadFile returned empty key');
|
||||
return dataUrl;
|
||||
}
|
||||
|
||||
const presignedUrl = await withTimeout(
|
||||
localStorage.generatePresignedUrl({ key: fileKey, expireTime: 2592000 }),
|
||||
10_000,
|
||||
'Local generatePresignedUrl (video)',
|
||||
);
|
||||
|
||||
if (presignedUrl) {
|
||||
console.log('[Persist Video Media] Success, key:', fileKey, 'size:', buffer.length, 'bytes');
|
||||
return presignedUrl;
|
||||
}
|
||||
|
||||
return dataUrl;
|
||||
} catch (err) {
|
||||
console.error('[Persist Video Media Error]', err instanceof Error ? err.message : err);
|
||||
return dataUrl;
|
||||
}
|
||||
const match = dataUrl.match(/^data:((?:image|video)\/[^;]+);base64,(.+)$/);
|
||||
if (!match) throw new Error('Invalid generated video data URL');
|
||||
const [, mimeType, base64Data] = match;
|
||||
const ext = getVideoExtension(mimeType);
|
||||
return persistVideoBufferToObjectStorage(Buffer.from(base64Data, 'base64'), mimeType, ext, prefix);
|
||||
}
|
||||
|
||||
async function persistRemoteUrlToStorage(url: string, prefix: string): Promise<string> {
|
||||
if (!url.startsWith('http')) return url;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
const fileKey = await withTimeout(
|
||||
localStorage.uploadFromUrl({ url, timeout: 60000 }),
|
||||
60_000,
|
||||
'Local uploadFromUrl (video)',
|
||||
response = await fetchPublicHttpUrlWithRetry(
|
||||
url,
|
||||
{ headers: { Accept: 'video/mp4,video/webm,video/quicktime,video/*,*/*;q=0.8' } },
|
||||
{ attempts: 3, retryDelayMs: 800, timeoutMs: 90_000 },
|
||||
);
|
||||
if (!fileKey) return url;
|
||||
|
||||
const presignedUrl = await withTimeout(
|
||||
localStorage.generatePresignedUrl({ key: fileKey, expireTime: 2592000 }),
|
||||
10_000,
|
||||
'Local generatePresignedUrl (video remote)',
|
||||
);
|
||||
|
||||
if (presignedUrl) {
|
||||
console.log('[Persist Remote Video URL] Success, key:', fileKey);
|
||||
return presignedUrl;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error || '');
|
||||
if (/fetch failed|network|timeout|aborted|ECONNRESET|ETIMEDOUT|EAI_AGAIN|ENOTFOUND/i.test(message)) {
|
||||
throw new Error('上游已返回视频地址,但平台下载或保存结果视频失败:网络连接失败,请稍后重试');
|
||||
}
|
||||
return url;
|
||||
} catch (err) {
|
||||
console.warn('[Persist Remote Video URL] Failed, using original URL:', err instanceof Error ? err.message : err);
|
||||
return url;
|
||||
throw new Error(`上游已返回视频地址,但平台下载或保存结果视频失败:${message || '未知错误'}`);
|
||||
}
|
||||
if (!response.ok) throw new Error(`Failed to fetch generated video: ${response.status}`);
|
||||
const mimeType = response.headers.get('content-type')?.split(';')[0] || getVideoMimeType(url);
|
||||
const ext = getVideoExtension(mimeType, url);
|
||||
return persistVideoBufferToObjectStorage(Buffer.from(await response.arrayBuffer()), mimeType, ext, prefix);
|
||||
}
|
||||
|
||||
/** Helper: wrap a promise with a timeout that rejects with a descriptive message */
|
||||
@@ -111,26 +82,66 @@ function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise
|
||||
async function persistAllMediaUrls(urls: string[], prefix: string): Promise<string[]> {
|
||||
const MAX_DATA_URL_SIZE = 10 * 1024 * 1024; // 10MB limit for video data URLs
|
||||
const results = await Promise.all(
|
||||
urls.map(async (url) => {
|
||||
try {
|
||||
if (url.startsWith('data:')) {
|
||||
const result = await persistMediaToStorage(url, prefix);
|
||||
if (result.startsWith('data:') && result.length > MAX_DATA_URL_SIZE) {
|
||||
console.warn('[Persist Video] Data URL too large (' + Math.round(result.length / 1024 / 1024) + 'MB), skipping');
|
||||
return null;
|
||||
}
|
||||
return result;
|
||||
uniqueStrings(urls).map(async (url) => {
|
||||
if (url.startsWith('data:')) {
|
||||
if (url.length > MAX_DATA_URL_SIZE) {
|
||||
throw new Error('Generated video data URL is too large to persist');
|
||||
}
|
||||
if (url.startsWith('http')) return persistRemoteUrlToStorage(url, prefix);
|
||||
return url;
|
||||
} catch (err) {
|
||||
console.error('[persistAllMediaUrls video] Error:', err instanceof Error ? err.message : err);
|
||||
if (url.startsWith('data:') && url.length > MAX_DATA_URL_SIZE) return null;
|
||||
return url;
|
||||
return persistMediaToStorage(url, prefix);
|
||||
}
|
||||
if (url.startsWith('http')) return persistRemoteUrlToStorage(url, prefix);
|
||||
if (url.startsWith('/api/local-storage/')) return url;
|
||||
throw new Error('Generated video did not return a persistable URL');
|
||||
}),
|
||||
);
|
||||
return results.filter((u): u is string => u !== null);
|
||||
return uniqueStrings(results);
|
||||
}
|
||||
|
||||
async function persistVideoBufferToObjectStorage(buffer: Buffer, mimeType: string, ext: string, prefix: string): Promise<string> {
|
||||
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const fileKey = await withTimeout(
|
||||
localStorage.uploadFileObjectOnly({
|
||||
fileContent: buffer,
|
||||
fileName: `${prefix}/${suffix}.${ext || 'mp4'}`,
|
||||
contentType: mimeType || 'video/mp4',
|
||||
}),
|
||||
90_000,
|
||||
'Local uploadFileObjectOnly (video)',
|
||||
);
|
||||
const publicUrl = await withTimeout(
|
||||
localStorage.generatePresignedUrl({ key: fileKey, expireTime: 2592000 }),
|
||||
10_000,
|
||||
'Local generatePresignedUrl (video)',
|
||||
);
|
||||
console.log('[Persist Video Media] Success, key:', fileKey, 'size:', buffer.length, 'bytes');
|
||||
return publicUrl;
|
||||
}
|
||||
|
||||
function getVideoMimeType(url: string): string {
|
||||
const ext = getVideoExtension('', url);
|
||||
if (ext === 'webm') return 'video/webm';
|
||||
if (ext === 'mov') return 'video/quicktime';
|
||||
if (ext === 'avi') return 'video/x-msvideo';
|
||||
return 'video/mp4';
|
||||
}
|
||||
|
||||
function getVideoExtension(mimeType: string, url = ''): string {
|
||||
const normalizedMime = mimeType.split(';')[0]?.trim().toLowerCase();
|
||||
if (normalizedMime === 'video/webm') return 'webm';
|
||||
if (normalizedMime === 'video/quicktime') return 'mov';
|
||||
if (normalizedMime === 'video/x-msvideo') return 'avi';
|
||||
if (normalizedMime === 'video/mp4') return 'mp4';
|
||||
const match = url.split('?')[0]?.match(/\.([a-z0-9]+)$/i);
|
||||
const ext = match?.[1]?.toLowerCase();
|
||||
return ext && /^(mp4|webm|mov|avi|m4v)$/i.test(ext) ? ext : 'mp4';
|
||||
}
|
||||
|
||||
function isAgnesVideoApi(config: { provider?: string; modelName?: string }): boolean {
|
||||
const provider = (config.provider || '').toLowerCase().replace(/\s+/g, '');
|
||||
const modelName = (config.modelName || '').toLowerCase();
|
||||
return provider === AGNES_PROVIDER_NAME.toLowerCase().replace(/\s+/g, '')
|
||||
|| provider.includes('agnes')
|
||||
|| modelName.startsWith('agnes-video-');
|
||||
}
|
||||
|
||||
async function uploadDataUrlAndGetPublicUrl(dataUrl: string): Promise<string | null> {
|
||||
@@ -492,24 +503,35 @@ export async function POST(request: NextRequest) {
|
||||
model = 'doubao-seedance-1-5-pro-251215',
|
||||
aspectRatio = '16:9',
|
||||
duration = 5,
|
||||
resolution = '720p',
|
||||
quality,
|
||||
mode,
|
||||
fps = 30,
|
||||
image,
|
||||
images,
|
||||
extraImages,
|
||||
referenceImageAnnotations,
|
||||
customApiConfig,
|
||||
} = body as {
|
||||
prompt?: string;
|
||||
negativePrompt?: string;
|
||||
model?: string;
|
||||
aspectRatio?: string;
|
||||
duration?: number;
|
||||
duration?: number | string;
|
||||
resolution?: string;
|
||||
quality?: string;
|
||||
mode?: string;
|
||||
fps?: number;
|
||||
image?: string;
|
||||
images?: string[];
|
||||
extraImages?: string[];
|
||||
referenceImageAnnotations?: unknown;
|
||||
customApiConfig?: CustomApiConfig;
|
||||
};
|
||||
const referenceImages = normalizeReferenceImages(image, images, extraImages);
|
||||
const promptForGeneration = buildReferenceImagePrompt(prompt || '', referenceImages.length, referenceImageAnnotations);
|
||||
const numericDuration = Number(duration);
|
||||
const sdkDuration = Number.isFinite(numericDuration) ? numericDuration : 5;
|
||||
|
||||
if (!prompt && referenceImages.length === 0) {
|
||||
return NextResponse.json({ error: '请提供视频描述或上传图片' }, { status: 400 });
|
||||
@@ -535,15 +557,66 @@ export async function POST(request: NextRequest) {
|
||||
if (resolvedCustomApiConfig && resolvedCustomApiConfig.apiKey) {
|
||||
const resolvedApiKey = resolvedCustomApiConfig.apiKey;
|
||||
try {
|
||||
if (resolvedCustomApiConfig.manifestPath) {
|
||||
const useAgnesVideoParams = isAgnesVideoApi(resolvedCustomApiConfig);
|
||||
const agnesVideoDuration = useAgnesVideoParams ? normalizeAgnesVideoDuration(duration) : null;
|
||||
if (useAgnesVideoParams && agnesVideoDuration === null) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Agnes Video V2.0 当前仅开放 3、5、10 秒,18 秒上游生成不稳定,请改选 10 秒后重试' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const resolvedAgnesDuration = agnesVideoDuration ?? undefined;
|
||||
const manifestResult = await executeUserApiManifest({
|
||||
manifestPath: resolvedCustomApiConfig.manifestPath,
|
||||
apiUrl: resolvedCustomApiConfig.apiUrl,
|
||||
apiKey: resolvedApiKey,
|
||||
modelName: resolvedCustomApiConfig.modelName,
|
||||
prompt: promptForGeneration,
|
||||
params: {
|
||||
n: 1,
|
||||
aspect_ratio: aspectRatio,
|
||||
duration: useAgnesVideoParams ? resolvedAgnesDuration : duration,
|
||||
resolution,
|
||||
quality: quality || mode,
|
||||
mode: mode || quality,
|
||||
fps: useAgnesVideoParams ? AGNES_VIDEO_FRAME_RATE : fps,
|
||||
num_frames: useAgnesVideoParams ? getAgnesVideoNumFrames(resolvedAgnesDuration) : undefined,
|
||||
negative_prompt: negativePrompt,
|
||||
},
|
||||
inputImages: referenceImages,
|
||||
preferEdit: referenceImages.length > 0,
|
||||
timeoutMs: useAgnesVideoParams ? AGNES_VIDEO_GENERATION_TIMEOUT : GENERATION_TIMEOUT,
|
||||
onProgress: handleUpstreamProgress,
|
||||
});
|
||||
if (manifestResult) {
|
||||
const media = manifestResult.videos.length > 0 ? manifestResult.videos : manifestResult.images;
|
||||
if (media.length === 0) {
|
||||
return NextResponse.json({ error: '自定义 Manifest 未返回有效视频数据' }, { status: 502 });
|
||||
}
|
||||
let persistedVideos: string[];
|
||||
try {
|
||||
persistedVideos = await persistAllMediaUrls(media, 'generated/videos');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error || '');
|
||||
return NextResponse.json(
|
||||
{ error: message || '上游已返回视频结果,但平台下载或保存结果视频失败' },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ videos: persistedVideos });
|
||||
}
|
||||
}
|
||||
|
||||
if (referenceImages.length > 0) {
|
||||
return await customApiImageToVideo(
|
||||
resolvedCustomApiConfig as CustomApiConfig,
|
||||
prompt,
|
||||
promptForGeneration,
|
||||
negativePrompt,
|
||||
referenceImages[0],
|
||||
referenceImages,
|
||||
aspectRatio,
|
||||
duration,
|
||||
sdkDuration,
|
||||
fps,
|
||||
handleUpstreamProgress,
|
||||
);
|
||||
@@ -556,7 +629,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Augment prompt with aspect ratio hint as fallback
|
||||
const ratioHint = aspectRatio ? getAspectRatioPromptHint(aspectRatio) : '';
|
||||
const augmentedPrompt = ratioHint ? `${prompt || ''}\n\n[${ratioHint}]` : (prompt || '');
|
||||
const augmentedPrompt = ratioHint ? `${promptForGeneration}\n\n[${ratioHint}]` : promptForGeneration;
|
||||
|
||||
const requestBody: Record<string, unknown> = {
|
||||
model: resolvedCustomApiConfig.modelName,
|
||||
@@ -569,6 +642,7 @@ export async function POST(request: NextRequest) {
|
||||
// Pass creation parameters for APIs that support them
|
||||
if (aspectRatio) requestBody.aspect_ratio = aspectRatio;
|
||||
if (duration) requestBody.duration = duration;
|
||||
if (resolution) requestBody.resolution = resolution;
|
||||
if (fps) requestBody.fps = fps;
|
||||
|
||||
console.log('[Custom API Video] Text-to-video, sending to:', endpoint, '| model:', requestBody.model);
|
||||
@@ -620,8 +694,8 @@ export async function POST(request: NextRequest) {
|
||||
referenceImages.forEach((url, index) => {
|
||||
contentItems.push({ type: 'image_url', image_url: { url }, role: index === 0 ? 'first_frame' : 'reference' });
|
||||
});
|
||||
if (prompt) {
|
||||
contentItems.push({ type: 'text', text: prompt });
|
||||
if (promptForGeneration) {
|
||||
contentItems.push({ type: 'text', text: promptForGeneration });
|
||||
}
|
||||
|
||||
const ratioMap: Record<string, '16:9' | '9:16' | '1:1' | '4:3' | '3:4'> = {
|
||||
@@ -630,7 +704,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const response = await client.videoGeneration(contentItems as Parameters<typeof client.videoGeneration>[0], {
|
||||
model,
|
||||
duration: Math.min(Math.max(duration, 4), 12),
|
||||
duration: Math.min(Math.max(sdkDuration, 4), 12),
|
||||
ratio: ratioMap[aspectRatio] || '16:9',
|
||||
resolution: '720p',
|
||||
generateAudio: true,
|
||||
|
||||
@@ -108,3 +108,94 @@ export async function GET(
|
||||
return NextResponse.json({ error: '查询生成任务失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const user = await getAuthenticatedUser(request);
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
if (!UUID_REGEX.test(id)) {
|
||||
return NextResponse.json({ error: '任务ID格式无效' }, { status: 400 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
if (body.action && body.action !== 'cancel') {
|
||||
return NextResponse.json({ error: '不支持的任务操作' }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
await ensureGenerationJobRuntimeSchema(client);
|
||||
const result = await client.query(
|
||||
`UPDATE generation_jobs
|
||||
SET status = 'cancelled',
|
||||
error = '用户已取消任务',
|
||||
payload = '{}'::jsonb,
|
||||
progress = COALESCE(progress, '{}'::jsonb) || $4::jsonb,
|
||||
finished_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
AND (user_id = $2 OR $3 = true)
|
||||
AND status IN ('queued', 'running')
|
||||
RETURNING id, type, status, result, error, provider, model_name, api_url, progress,
|
||||
created_at, started_at, finished_at, updated_at,
|
||||
CASE
|
||||
WHEN started_at IS NOT NULL
|
||||
THEN FLOOR(EXTRACT(EPOCH FROM (COALESCE(finished_at, NOW()) - started_at)))::int
|
||||
ELSE 0
|
||||
END AS elapsed_seconds`,
|
||||
[
|
||||
id,
|
||||
user.userId,
|
||||
user.role === 'admin' || user.role === 'enterprise_admin',
|
||||
JSON.stringify({
|
||||
percent: 100,
|
||||
message: '任务已取消',
|
||||
cancelled: true,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
const existing = await client.query(
|
||||
`SELECT id, type, status, result, error, provider, model_name, api_url, progress,
|
||||
created_at, started_at, finished_at, updated_at,
|
||||
CASE
|
||||
WHEN started_at IS NOT NULL
|
||||
THEN FLOOR(EXTRACT(EPOCH FROM (COALESCE(finished_at, NOW()) - started_at)))::int
|
||||
ELSE 0
|
||||
END AS elapsed_seconds
|
||||
FROM generation_jobs
|
||||
WHERE id = $1
|
||||
AND (user_id = $2 OR $3 = true)
|
||||
LIMIT 1`,
|
||||
[id, user.userId, user.role === 'admin' || user.role === 'enterprise_admin'],
|
||||
);
|
||||
if (existing.rows.length === 0) {
|
||||
return NextResponse.json({ error: '任务不存在' }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({
|
||||
...existing.rows[0],
|
||||
jobId: existing.rows[0].id,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
...result.rows[0],
|
||||
jobId: result.rows[0].id,
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[generation-jobs] PATCH error:', err);
|
||||
return NextResponse.json({ error: '取消生成任务失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,91 @@ import {
|
||||
resolveGenerationJobIdentity,
|
||||
} from '@/lib/generation-job-estimates';
|
||||
import { writePlatformLog } from '@/lib/platform-logs';
|
||||
import { incrementImageStylePresetUsage } from '@/lib/style-preset-store';
|
||||
import { ensureGenerationCreditsAvailable } from '@/lib/generation-credit-service';
|
||||
|
||||
const ACTIVE_JOB_STATUSES = new Set(['queued', 'running']);
|
||||
|
||||
function parseStatusFilter(value: string | null): string[] {
|
||||
if (!value) return ['queued', 'running'];
|
||||
const statuses = value
|
||||
.split(',')
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean)
|
||||
.filter(item => ACTIVE_JOB_STATUSES.has(item));
|
||||
return statuses.length > 0 ? statuses : ['queued', 'running'];
|
||||
}
|
||||
|
||||
function parseTypeFilter(value: string | null): GenerationJobType[] {
|
||||
if (!value) return [];
|
||||
return value
|
||||
.split(',')
|
||||
.map(item => item.trim())
|
||||
.filter((item): item is GenerationJobType => item === 'image' || item === 'video' || item === 'reverse-prompt');
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
void markStaleRunningJobs();
|
||||
const userId = await getAuthenticatedUserId(request);
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
const statuses = parseStatusFilter(request.nextUrl.searchParams.get('status'));
|
||||
const types = parseTypeFilter(request.nextUrl.searchParams.get('type'));
|
||||
const limitParam = Number(request.nextUrl.searchParams.get('limit') || 30);
|
||||
const limit = Number.isFinite(limitParam) ? Math.min(100, Math.max(1, Math.floor(limitParam))) : 30;
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
await ensureGenerationJobRuntimeSchema(client);
|
||||
const params: unknown[] = [userId, statuses, limit];
|
||||
let typeClause = '';
|
||||
if (types.length > 0) {
|
||||
params.push(types);
|
||||
typeClause = `AND type = ANY($${params.length}::text[])`;
|
||||
}
|
||||
const result = await client.query(
|
||||
`SELECT id, type, status, result, error, payload, provider, model_name, api_url, progress,
|
||||
created_at, started_at, finished_at, updated_at,
|
||||
CASE
|
||||
WHEN started_at IS NOT NULL
|
||||
THEN FLOOR(EXTRACT(EPOCH FROM (COALESCE(finished_at, NOW()) - started_at)))::int
|
||||
ELSE 0
|
||||
END AS elapsed_seconds
|
||||
FROM generation_jobs
|
||||
WHERE user_id = $1
|
||||
AND status = ANY($2::text[])
|
||||
${typeClause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $3`,
|
||||
params,
|
||||
);
|
||||
const jobs = result.rows.map(row => {
|
||||
const progress = row.progress && typeof row.progress === 'object' ? row.progress : {};
|
||||
const estimateSeconds = Number(progress.estimateSeconds || progress.etaSeconds || 0)
|
||||
|| (row.type === 'video' ? 300 : row.type === 'reverse-prompt' ? 60 : 90);
|
||||
return {
|
||||
...row,
|
||||
jobId: row.id,
|
||||
estimateSeconds,
|
||||
eta: {
|
||||
estimateSeconds,
|
||||
source: typeof progress.source === 'string' ? progress.source : 'default',
|
||||
sampleCount: Number(progress.sampleCount || 0),
|
||||
windowDays: progress.windowDays ?? null,
|
||||
},
|
||||
};
|
||||
});
|
||||
return NextResponse.json({ jobs });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[generation-jobs] GET error:', err);
|
||||
return NextResponse.json({ error: '查询生成任务失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -26,7 +111,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: '请先登录后再创建生成任务' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (type !== 'image' && type !== 'video') {
|
||||
if (type !== 'image' && type !== 'video' && type !== 'reverse-prompt') {
|
||||
return NextResponse.json({ error: '不支持的任务类型' }, { status: 400 });
|
||||
}
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
@@ -40,22 +125,65 @@ export async function POST(request: NextRequest) {
|
||||
let etaSampleCount = 0;
|
||||
let etaWindowDays: number | null = null;
|
||||
let jobIdentity = { provider: '', modelName: '', apiUrl: '' };
|
||||
let transactionStarted = false;
|
||||
try {
|
||||
await ensureGenerationJobRuntimeSchema(client);
|
||||
await client.query('BEGIN');
|
||||
transactionStarted = true;
|
||||
const identity = await resolveGenerationJobIdentity(client, userId, payload);
|
||||
jobIdentity = identity;
|
||||
try {
|
||||
if (type === 'image' || type === 'video') {
|
||||
await ensureGenerationCreditsAvailable(client, userId, { type, payload });
|
||||
}
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK').catch(() => undefined);
|
||||
transactionStarted = false;
|
||||
const message = error instanceof Error ? error.message : '积分不足';
|
||||
return NextResponse.json({ error: message }, { status: 402 });
|
||||
}
|
||||
const estimate = await getGenerationJobEstimate(client, type, identity.provider, identity.modelName);
|
||||
estimateSeconds = estimate.estimateSeconds;
|
||||
etaSource = estimate.source;
|
||||
etaSampleCount = estimate.sampleCount;
|
||||
etaWindowDays = estimate.windowDays;
|
||||
const payloadJson = JSON.stringify(payload);
|
||||
const existing = await client.query(
|
||||
`SELECT id, status, progress
|
||||
FROM generation_jobs
|
||||
WHERE user_id = $1
|
||||
AND type = $2
|
||||
AND status IN ('queued', 'running')
|
||||
AND payload - 'clientRequestId' = $3::jsonb - 'clientRequestId'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1`,
|
||||
[userId, type, payloadJson],
|
||||
);
|
||||
if (existing.rows.length > 0) {
|
||||
const row = existing.rows[0];
|
||||
await client.query('COMMIT');
|
||||
transactionStarted = false;
|
||||
return NextResponse.json({
|
||||
jobId: row.id,
|
||||
status: row.status,
|
||||
estimateSeconds,
|
||||
progress: row.progress || {},
|
||||
eta: {
|
||||
estimateSeconds,
|
||||
source: etaSource,
|
||||
sampleCount: etaSampleCount,
|
||||
windowDays: etaWindowDays,
|
||||
},
|
||||
deduplicated: true,
|
||||
}, { status: 202 });
|
||||
}
|
||||
const result = await client.query(
|
||||
`INSERT INTO generation_jobs (type, status, payload, user_id, provider, model_name, api_url, progress)
|
||||
VALUES ($1, 'queued', $2::jsonb, $3, $4, $5, $6, $7::jsonb)
|
||||
RETURNING id`,
|
||||
[
|
||||
type,
|
||||
JSON.stringify(payload),
|
||||
payloadJson,
|
||||
userId,
|
||||
identity.provider,
|
||||
identity.modelName,
|
||||
@@ -64,6 +192,18 @@ export async function POST(request: NextRequest) {
|
||||
],
|
||||
);
|
||||
jobId = result.rows[0].id as string;
|
||||
await client.query('COMMIT');
|
||||
transactionStarted = false;
|
||||
if (type === 'image' && typeof payload.styleLabel === 'string') {
|
||||
await incrementImageStylePresetUsage(client, payload.styleLabel).catch(error => {
|
||||
console.warn('[generation-jobs] style preset usage update failed:', error);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (transactionStarted) {
|
||||
await client.query('ROLLBACK').catch(() => undefined);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
@@ -73,7 +213,7 @@ export async function POST(request: NextRequest) {
|
||||
type: 'generation',
|
||||
level: 'info',
|
||||
action: 'generation_job_created',
|
||||
message: `用户创建${type === 'image' ? '图片' : '视频'}生成任务`,
|
||||
message: `用户创建${type === 'image' ? '图片' : type === 'video' ? '视频' : '反推提示词'}生成任务`,
|
||||
userId,
|
||||
targetType: 'generation_job',
|
||||
targetId: jobId,
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { getStorageHealthStatus } from '@/lib/local-storage';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
|
||||
export async function GET() {
|
||||
const storageDir = process.env.LOCAL_STORAGE_DIR || path.join(process.cwd(), 'local-storage');
|
||||
const checks: Record<string, { ok: boolean; message?: string }> = {
|
||||
database: { ok: false },
|
||||
storage: { ok: false },
|
||||
@@ -28,12 +26,10 @@ export async function GET() {
|
||||
client?.release();
|
||||
}
|
||||
|
||||
try {
|
||||
fs.mkdirSync(storageDir, { recursive: true });
|
||||
fs.accessSync(storageDir, fs.constants.R_OK | fs.constants.W_OK);
|
||||
checks.storage.ok = true;
|
||||
} catch (error) {
|
||||
checks.storage.message = error instanceof Error ? error.message : 'storage check failed';
|
||||
const storageStatus = await getStorageHealthStatus();
|
||||
checks.storage.ok = storageStatus.ok;
|
||||
if (!storageStatus.ok) {
|
||||
checks.storage.message = storageStatus.object.message || storageStatus.local.message || 'storage check failed';
|
||||
}
|
||||
|
||||
const ok = Object.values(checks).every(check => check.ok);
|
||||
@@ -44,6 +40,7 @@ export async function GET() {
|
||||
role: process.env.APP_RUNTIME_ROLE || 'full',
|
||||
timestamp: new Date().toISOString(),
|
||||
checks,
|
||||
storage: storageStatus,
|
||||
},
|
||||
{ status: ok ? 200 : 503 },
|
||||
);
|
||||
|
||||
22
src/app/api/invitations/me/route.ts
Normal file
22
src/app/api/invitations/me/route.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAuthenticatedUserId } from '@/lib/session-auth';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import { getOrCreateInviteCode, listInvitationReferrals } from '@/lib/invitation-service';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const userId = await getAuthenticatedUserId(request);
|
||||
if (!userId) return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const inviteCode = await getOrCreateInviteCode(client, userId);
|
||||
const referrals = await listInvitationReferrals(client, { inviterUserId: userId, page: 1, pageSize: 100 });
|
||||
return NextResponse.json({
|
||||
inviteCode,
|
||||
referrals: referrals.referrals,
|
||||
referralCount: referrals.total,
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { localStorage } from '@/lib/local-storage';
|
||||
import { serveWatermarkedStorageFile } from '@/lib/media-watermark';
|
||||
import { shouldWatermarkStorageResponse } from '@/lib/media-watermark-policy';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import path from 'path';
|
||||
|
||||
const THUMBNAIL_CACHE_CONTROL = 'public, max-age=31536000, immutable';
|
||||
const LOCAL_CACHE_CONTROL = 'private, max-age=300';
|
||||
const WATERMARK_CACHE_CONTROL = 'public, max-age=86400, stale-while-revalidate=604800';
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||
try {
|
||||
const { path: pathSegments } = await params;
|
||||
@@ -10,25 +17,78 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Invalid file path' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!localStorage.fileExists(filePath)) {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 });
|
||||
const contentType = getContentType(filePath);
|
||||
if (shouldWatermarkStorageResponse(filePath, contentType, null)) {
|
||||
const thumbnailResponse = await getStoredThumbnailResponse(filePath, contentType);
|
||||
if (thumbnailResponse) return thumbnailResponse;
|
||||
|
||||
try {
|
||||
const watermarked = await serveWatermarkedStorageFile(filePath, contentType);
|
||||
return serveLocalBuffer(watermarked.key, watermarked.buffer, WATERMARK_CACHE_CONTROL, watermarked.contentType);
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
const fileBuffer = localStorage.readFile(filePath);
|
||||
const contentType = getContentType(filePath);
|
||||
if (filePath.startsWith('thumbnails/')) {
|
||||
if (!localStorage.fileExists(filePath)) {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 });
|
||||
}
|
||||
return serveLocalBuffer(filePath, localStorage.readFile(filePath), THUMBNAIL_CACHE_CONTROL, contentType);
|
||||
}
|
||||
|
||||
return new NextResponse(new Uint8Array(fileBuffer), {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition': `inline; filename="${path.basename(filePath)}"`,
|
||||
},
|
||||
});
|
||||
if (localStorage.fileExists(filePath)) {
|
||||
return serveLocalBuffer(filePath, localStorage.readFile(filePath), LOCAL_CACHE_CONTROL, contentType);
|
||||
}
|
||||
|
||||
const objectUrl = localStorage.generateObjectReadUrl(filePath, 300);
|
||||
if (objectUrl) {
|
||||
const response = NextResponse.redirect(objectUrl, 302);
|
||||
response.headers.set('Cache-Control', 'private, max-age=60');
|
||||
return response;
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 });
|
||||
} catch (error) {
|
||||
console.error('[Local Storage API] Error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
function serveLocalBuffer(filePath: string, fileBuffer: Buffer, cacheControl: string, contentType = getContentType(filePath)): NextResponse {
|
||||
return new NextResponse(new Uint8Array(fileBuffer), {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition': `inline; filename="${path.basename(filePath)}"`,
|
||||
'Cache-Control': cacheControl,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function getStoredThumbnailResponse(filePath: string, contentType: string): Promise<NextResponse | null> {
|
||||
if (!contentType.startsWith('image/') || filePath.startsWith('thumbnails/')) return null;
|
||||
const publicUrl = `/api/local-storage/${encodeURI(filePath)}`;
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const result = await client.query(
|
||||
`SELECT thumbnail_url
|
||||
FROM works
|
||||
WHERE result_url = $1
|
||||
AND thumbnail_url LIKE '/api/local-storage/thumbnails/%'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1`,
|
||||
[publicUrl],
|
||||
);
|
||||
const thumbnailUrl = typeof result.rows[0]?.thumbnail_url === 'string' ? result.rows[0].thumbnail_url : '';
|
||||
const thumbnailKey = thumbnailUrl ? localStorage.getKeyFromPublicUrl(thumbnailUrl) : null;
|
||||
if (!thumbnailKey || !localStorage.localFileExistsOnly(thumbnailKey)) return null;
|
||||
const watermarked = await serveWatermarkedStorageFile(thumbnailKey, getContentType(thumbnailKey));
|
||||
return serveLocalBuffer(watermarked.key, watermarked.buffer, WATERMARK_CACHE_CONTROL, watermarked.contentType);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeStoragePath(value: string): string | null {
|
||||
try {
|
||||
const decoded = decodeURIComponent(value);
|
||||
@@ -51,6 +111,7 @@ function getContentType(filePath: string): string {
|
||||
'png': 'image/png',
|
||||
'webp': 'image/webp',
|
||||
'gif': 'image/gif',
|
||||
'svg': 'image/svg+xml',
|
||||
'mp4': 'video/mp4',
|
||||
'avi': 'video/x-msvideo',
|
||||
'mov': 'video/quicktime',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import { listSystemApis } from '@/lib/server-api-config';
|
||||
import { getUserMembershipTier, listSystemApis } from '@/lib/server-api-config';
|
||||
import { getAuthenticatedUserId } from '@/lib/session-auth';
|
||||
|
||||
function mapProvider(row: Record<string, unknown>) {
|
||||
return {
|
||||
@@ -27,8 +28,10 @@ function mapRecommendation(row: Record<string, unknown>) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const userId = await getAuthenticatedUserId(request);
|
||||
const membershipTier = userId ? await getUserMembershipTier(userId) : 'free';
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const providers = await client.query(
|
||||
@@ -47,7 +50,7 @@ export async function GET() {
|
||||
return NextResponse.json({
|
||||
providers: providers.rows.map(mapProvider),
|
||||
recommendations: recommendations.rows.map(mapRecommendation),
|
||||
systemApis: await listSystemApis(false),
|
||||
systemApis: await listSystemApis(false, { defaultOnly: true, userTier: membershipTier, collapseDefaultModels: true }),
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ensureEmailSchema } from '@/lib/email-service';
|
||||
import { getAuthenticatedUserId } from '@/lib/session-auth';
|
||||
import { getRequiredProductionSecret } from '@/lib/runtime-env';
|
||||
import { ensureProfilePreferenceSchema } from '@/lib/profile-preferences';
|
||||
import { ensureUserDisplayProfileSchema, generateDefaultAvatarDataUrl } from '@/lib/user-profile-defaults';
|
||||
|
||||
function normalizeRoleForTier(role: string | null | undefined, tier: string | null | undefined): string {
|
||||
const currentRole = role || 'user';
|
||||
@@ -11,6 +12,11 @@ function normalizeRoleForTier(role: string | null | undefined, tier: string | nu
|
||||
return tier && tier !== 'free' ? 'vip' : currentRole === 'vip' ? 'user' : currentRole;
|
||||
}
|
||||
|
||||
function canDisableWatermarkForProfile(role: string | null | undefined, tier: string | null | undefined): boolean {
|
||||
if (role === 'admin' || role === 'enterprise_admin') return true;
|
||||
return Boolean(tier && tier !== 'free');
|
||||
}
|
||||
|
||||
function isEmail(value: string): boolean {
|
||||
return value.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
||||
}
|
||||
@@ -19,6 +25,7 @@ function isSafeAvatarUrl(value: string): boolean {
|
||||
if (!value) return true;
|
||||
if (value.length > 1_000_000) return false;
|
||||
if (/^data:image\/(png|jpe?g|webp|gif);base64,[a-z0-9+/=]+$/i.test(value)) return true;
|
||||
if (/^data:image\/svg\+xml;charset=utf-8,/i.test(value)) return true;
|
||||
if (/^https?:\/\/[^\s"'<>]+$/i.test(value)) return true;
|
||||
if (/^\/api\/local-storage\/[^\s"'<>]+$/i.test(value)) return true;
|
||||
return false;
|
||||
@@ -49,8 +56,14 @@ export async function GET(request: NextRequest) {
|
||||
try {
|
||||
await ensureEmailSchema(client);
|
||||
await ensureProfilePreferenceSchema(client);
|
||||
await ensureUserDisplayProfileSchema(client);
|
||||
const result = await client.query(
|
||||
'SELECT id, email, nickname, phone, role, membership_tier, credits_balance, daily_quota_used, daily_quota_limit, avatar_url, created_at, email_verified, email_verified_at, email_bound_at, preferred_theme FROM profiles WHERE id = $1',
|
||||
`SELECT id, email, nickname AS username, COALESCE(NULLIF(display_nickname, ''), nickname) AS nickname,
|
||||
COALESCE(NULLIF(display_nickname, ''), nickname) AS display_nickname,
|
||||
phone, role, membership_tier, credits_balance, daily_quota_used, daily_quota_limit,
|
||||
avatar_url, created_at, email_verified, email_verified_at, email_bound_at, preferred_theme,
|
||||
COALESCE(watermark_disabled, false) AS watermark_disabled
|
||||
FROM profiles WHERE id = $1`,
|
||||
[tokenUserId],
|
||||
);
|
||||
|
||||
@@ -88,6 +101,8 @@ export async function PUT(request: NextRequest) {
|
||||
const hasNickname = Object.prototype.hasOwnProperty.call(body, 'nickname');
|
||||
const hasPhone = Object.prototype.hasOwnProperty.call(body, 'phone');
|
||||
const hasAvatarUrl = Object.prototype.hasOwnProperty.call(body, 'avatarUrl');
|
||||
const hasWatermarkDisabled = Object.prototype.hasOwnProperty.call(body, 'watermarkDisabled');
|
||||
const watermarkDisabled = body.watermarkDisabled === true;
|
||||
const currentPassword = typeof body.currentPassword === 'string' ? body.currentPassword : '';
|
||||
const newPassword = typeof body.newPassword === 'string' ? body.newPassword : '';
|
||||
const email = hasEmail && typeof body.email === 'string' ? body.email.trim() : undefined;
|
||||
@@ -120,10 +135,16 @@ export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
await ensureEmailSchema(client);
|
||||
await ensureProfilePreferenceSchema(client);
|
||||
await ensureUserDisplayProfileSchema(client);
|
||||
await client.query('BEGIN');
|
||||
|
||||
const profileResult = await client.query(
|
||||
'SELECT id, email, nickname, phone, role, membership_tier, credits_balance, daily_quota_used, daily_quota_limit, avatar_url, created_at, email_verified, email_verified_at, email_bound_at, preferred_theme FROM profiles WHERE id = $1 FOR UPDATE',
|
||||
`SELECT id, email, nickname AS username, COALESCE(NULLIF(display_nickname, ''), nickname) AS nickname,
|
||||
COALESCE(NULLIF(display_nickname, ''), nickname) AS display_nickname,
|
||||
phone, role, membership_tier, credits_balance, daily_quota_used, daily_quota_limit,
|
||||
avatar_url, created_at, email_verified, email_verified_at, email_bound_at, preferred_theme,
|
||||
COALESCE(watermark_disabled, false) AS watermark_disabled
|
||||
FROM profiles WHERE id = $1 FOR UPDATE`,
|
||||
[tokenUserId]
|
||||
);
|
||||
|
||||
@@ -139,6 +160,51 @@ export async function PUT(request: NextRequest) {
|
||||
);
|
||||
const authUser = authResult.rows[0] || null;
|
||||
|
||||
const hasUsername = Object.prototype.hasOwnProperty.call(body, 'username');
|
||||
const username = hasUsername && typeof body.username === 'string' ? body.username.trim() : undefined;
|
||||
const hasDisplayNickname = Object.prototype.hasOwnProperty.call(body, 'displayNickname') || hasNickname;
|
||||
const displayNickname = Object.prototype.hasOwnProperty.call(body, 'displayNickname') && typeof body.displayNickname === 'string'
|
||||
? body.displayNickname.trim()
|
||||
: nickname;
|
||||
|
||||
if (!isSafeProfileText(username, 50)) {
|
||||
await client.query('ROLLBACK');
|
||||
return NextResponse.json({ error: 'Username is too long or contains invalid characters' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (username !== undefined && !username) {
|
||||
await client.query('ROLLBACK');
|
||||
return NextResponse.json({ error: 'Username cannot be empty' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (displayNickname !== undefined && !displayNickname) {
|
||||
await client.query('ROLLBACK');
|
||||
return NextResponse.json({ error: 'Display nickname cannot be empty' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!isSafeProfileText(displayNickname, 50)) {
|
||||
await client.query('ROLLBACK');
|
||||
return NextResponse.json({ error: 'Display nickname is too long or contains invalid characters' }, { status: 400 });
|
||||
}
|
||||
|
||||
const canManageOwnWatermark = canDisableWatermarkForProfile(currentProfile.role, currentProfile.membership_tier);
|
||||
if (hasWatermarkDisabled && watermarkDisabled && !canManageOwnWatermark) {
|
||||
await client.query('ROLLBACK');
|
||||
return NextResponse.json({ error: '仅会员可关闭下载水印' }, { status: 403 });
|
||||
}
|
||||
const shouldUpdateWatermark = hasWatermarkDisabled && canManageOwnWatermark;
|
||||
|
||||
if (username !== undefined && username !== currentProfile.username) {
|
||||
const duplicateUsername = await client.query(
|
||||
'SELECT id FROM profiles WHERE LOWER(nickname) = LOWER($1) AND id <> $2 LIMIT 1',
|
||||
[username, tokenUserId]
|
||||
);
|
||||
if (duplicateUsername.rows.length > 0) {
|
||||
await client.query('ROLLBACK');
|
||||
return NextResponse.json({ error: 'Username is already in use' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (email !== undefined && email !== currentProfile.email) {
|
||||
const duplicateProfile = await client.query(
|
||||
'SELECT id FROM profiles WHERE email = $1 AND id <> $2 LIMIT 1',
|
||||
@@ -188,26 +254,44 @@ export async function PUT(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const nextDisplayNickname = displayNickname !== undefined
|
||||
? displayNickname
|
||||
: currentProfile.display_nickname || currentProfile.nickname || currentProfile.username || currentProfile.email.split('@')[0];
|
||||
const nextAvatarUrl = avatarUrl !== undefined
|
||||
? avatarUrl
|
||||
: currentProfile.avatar_url || generateDefaultAvatarDataUrl(`${tokenUserId}:${currentProfile.email}`, nextDisplayNickname);
|
||||
|
||||
const updateResult = await client.query(
|
||||
`UPDATE profiles
|
||||
SET email = CASE WHEN $1::boolean THEN $2 ELSE email END,
|
||||
email_verified = CASE WHEN $1::boolean AND LOWER($2) <> LOWER(email) THEN false ELSE email_verified END,
|
||||
email_verified_at = CASE WHEN $1::boolean AND LOWER($2) <> LOWER(email) THEN NULL ELSE email_verified_at END,
|
||||
nickname = CASE WHEN $3::boolean THEN NULLIF($4, '') ELSE nickname END,
|
||||
phone = CASE WHEN $5::boolean THEN NULLIF($6, '') ELSE phone END,
|
||||
avatar_url = CASE WHEN $7::boolean THEN NULLIF($8, '') ELSE avatar_url END,
|
||||
display_nickname = CASE WHEN $5::boolean THEN NULLIF($6, '') ELSE COALESCE(NULLIF(display_nickname, ''), nickname) END,
|
||||
phone = CASE WHEN $7::boolean THEN NULLIF($8, '') ELSE phone END,
|
||||
avatar_url = CASE WHEN $9::boolean THEN NULLIF($10, '') ELSE COALESCE(NULLIF(avatar_url, ''), $11) END,
|
||||
watermark_disabled = CASE WHEN $12::boolean THEN $13 ELSE watermark_disabled END,
|
||||
updated_at = NOW()
|
||||
WHERE id = $9
|
||||
RETURNING id, email, nickname, phone, role, membership_tier, credits_balance, daily_quota_used, daily_quota_limit, avatar_url, created_at, email_verified, email_verified_at, email_bound_at, preferred_theme`,
|
||||
WHERE id = $14
|
||||
RETURNING id, email, nickname AS username, COALESCE(NULLIF(display_nickname, ''), nickname) AS nickname,
|
||||
COALESCE(NULLIF(display_nickname, ''), nickname) AS display_nickname,
|
||||
phone, role, membership_tier, credits_balance, daily_quota_used, daily_quota_limit,
|
||||
avatar_url, created_at, email_verified, email_verified_at, email_bound_at, preferred_theme,
|
||||
COALESCE(watermark_disabled, false) AS watermark_disabled`,
|
||||
[
|
||||
email !== undefined,
|
||||
email || null,
|
||||
nickname !== undefined,
|
||||
nickname || '',
|
||||
username !== undefined,
|
||||
username || '',
|
||||
hasDisplayNickname,
|
||||
nextDisplayNickname,
|
||||
phone !== undefined,
|
||||
phone || '',
|
||||
avatarUrl !== undefined,
|
||||
avatarUrl || '',
|
||||
nextAvatarUrl,
|
||||
shouldUpdateWatermark,
|
||||
watermarkDisabled,
|
||||
tokenUserId,
|
||||
]
|
||||
);
|
||||
|
||||
37
src/app/api/redeem-codes/redeem/route.ts
Normal file
37
src/app/api/redeem-codes/redeem/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAuthenticatedUserId } from '@/lib/session-auth';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import { redeemCodeForUser } from '@/lib/redeem-code-service';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const userId = await getAuthenticatedUserId(request);
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: '请先登录后再兑换' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const code = typeof body.code === 'string' ? body.code : '';
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const result = await redeemCodeForUser(client, { code, userId });
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
code: result.code,
|
||||
codeType: result.codeType,
|
||||
creditsAmount: result.creditsAmount,
|
||||
creditsBalance: result.creditsBalance,
|
||||
membershipTier: result.membershipTier,
|
||||
membershipExpiresAt: result.membershipExpiresAt,
|
||||
membershipDurationValue: result.membershipDurationValue,
|
||||
membershipDurationUnit: result.membershipDurationUnit,
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '兑换失败';
|
||||
console.error('[redeem-codes/redeem] POST error:', message);
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { localStorage } from '@/lib/local-storage';
|
||||
import { requireAdmin } from '@/lib/admin-auth';
|
||||
import { DEFAULT_ABOUT_US, DEFAULT_HELP_CENTER, DEFAULT_PRIVACY_POLICY, DEFAULT_TERMS_OF_SERVICE } from '@/lib/site-policy-defaults';
|
||||
import { cleanupExpiredPlatformLogs, setPlatformLogRetentionDays, writePlatformLog } from '@/lib/platform-logs';
|
||||
import { clearLayoutCompositionSkillCache } from '@/lib/layout-composition-skill';
|
||||
|
||||
const DEFAULT_RESPONSE = {
|
||||
siteName: '妙境',
|
||||
@@ -19,7 +20,9 @@ const DEFAULT_RESPONSE = {
|
||||
filingUrl: '',
|
||||
publicSecurityFilingInfo: '',
|
||||
publicSecurityFilingUrl: '',
|
||||
redeemCodeMallUrl: '',
|
||||
logRetentionDays: 30,
|
||||
imageCompositionSkillEnabled: false,
|
||||
};
|
||||
|
||||
type SiteConfigRow = {
|
||||
@@ -36,9 +39,14 @@ type SiteConfigRow = {
|
||||
filing_url?: string | null;
|
||||
public_security_filing_info?: string | null;
|
||||
public_security_filing_url?: string | null;
|
||||
redeem_code_mall_url?: string | null;
|
||||
log_retention_days?: number | null;
|
||||
image_composition_skill_enabled?: boolean | null;
|
||||
};
|
||||
|
||||
let siteConfigColumnsReady = false;
|
||||
let siteConfigColumnsPromise: Promise<void> | null = null;
|
||||
|
||||
async function ensureSiteConfigColumns(client: Awaited<ReturnType<typeof getDbClient>>) {
|
||||
await client.query('ALTER TABLE site_config ADD COLUMN IF NOT EXISTS membership_enabled BOOLEAN NOT NULL DEFAULT TRUE');
|
||||
await client.query("ALTER TABLE site_config ADD COLUMN IF NOT EXISTS terms_of_service TEXT NOT NULL DEFAULT ''");
|
||||
@@ -49,7 +57,9 @@ async function ensureSiteConfigColumns(client: Awaited<ReturnType<typeof getDbCl
|
||||
await client.query("ALTER TABLE site_config ADD COLUMN IF NOT EXISTS filing_url TEXT NOT NULL DEFAULT ''");
|
||||
await client.query("ALTER TABLE site_config ADD COLUMN IF NOT EXISTS public_security_filing_info TEXT NOT NULL DEFAULT ''");
|
||||
await client.query("ALTER TABLE site_config ADD COLUMN IF NOT EXISTS public_security_filing_url TEXT NOT NULL DEFAULT ''");
|
||||
await client.query("ALTER TABLE site_config ADD COLUMN IF NOT EXISTS redeem_code_mall_url TEXT NOT NULL DEFAULT ''");
|
||||
await client.query('ALTER TABLE site_config ADD COLUMN IF NOT EXISTS log_retention_days INTEGER NOT NULL DEFAULT 30');
|
||||
await client.query('ALTER TABLE site_config ADD COLUMN IF NOT EXISTS image_composition_skill_enabled BOOLEAN NOT NULL DEFAULT FALSE');
|
||||
await client.query('UPDATE site_config SET log_retention_days = LEAST(90, GREATEST(1, log_retention_days))');
|
||||
await client.query("UPDATE site_config SET terms_of_service = $1 WHERE terms_of_service = ''", [DEFAULT_TERMS_OF_SERVICE]);
|
||||
await client.query("UPDATE site_config SET privacy_policy = $1 WHERE privacy_policy = ''", [DEFAULT_PRIVACY_POLICY]);
|
||||
@@ -57,6 +67,20 @@ async function ensureSiteConfigColumns(client: Awaited<ReturnType<typeof getDbCl
|
||||
await client.query("UPDATE site_config SET help_center = $1 WHERE help_center = ''", [DEFAULT_HELP_CENTER]);
|
||||
}
|
||||
|
||||
async function ensureSiteConfigColumnsOnce(client: Awaited<ReturnType<typeof getDbClient>>) {
|
||||
if (siteConfigColumnsReady) return;
|
||||
if (!siteConfigColumnsPromise) {
|
||||
siteConfigColumnsPromise = ensureSiteConfigColumns(client)
|
||||
.then(() => {
|
||||
siteConfigColumnsReady = true;
|
||||
})
|
||||
.finally(() => {
|
||||
siteConfigColumnsPromise = null;
|
||||
});
|
||||
}
|
||||
await siteConfigColumnsPromise;
|
||||
}
|
||||
|
||||
function normalizeResponse(data?: SiteConfigRow | null) {
|
||||
return {
|
||||
siteName: data?.site_name || DEFAULT_RESPONSE.siteName,
|
||||
@@ -72,10 +96,25 @@ function normalizeResponse(data?: SiteConfigRow | null) {
|
||||
filingUrl: data?.filing_url?.trim() || '',
|
||||
publicSecurityFilingInfo: data?.public_security_filing_info?.trim() || '',
|
||||
publicSecurityFilingUrl: data?.public_security_filing_url?.trim() || '',
|
||||
redeemCodeMallUrl: data?.redeem_code_mall_url?.trim() || '',
|
||||
logRetentionDays: Math.min(90, Math.max(1, Number(data?.log_retention_days || 30))),
|
||||
imageCompositionSkillEnabled: data?.image_composition_skill_enabled === true,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeExternalUrl(value: unknown) {
|
||||
if (typeof value !== 'string') return '';
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return '';
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return '';
|
||||
return url.toString();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function decodeDataImage(value: unknown): { buffer: Buffer; ext: string; contentType: string } | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const match = value.match(/^data:image\/(png|jpe?g|webp|gif|svg\+xml);base64,([a-z0-9+/=]+)$/i);
|
||||
@@ -103,9 +142,9 @@ export async function GET() {
|
||||
try {
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
await ensureSiteConfigColumns(client);
|
||||
await ensureSiteConfigColumnsOnce(client);
|
||||
const result = await client.query(
|
||||
'SELECT site_name, site_tab_title, logo_url, favicon_url, membership_enabled, terms_of_service, privacy_policy, about_us, help_center, filing_info, filing_url, public_security_filing_info, public_security_filing_url, log_retention_days FROM site_config WHERE id = 1'
|
||||
'SELECT site_name, site_tab_title, logo_url, favicon_url, membership_enabled, terms_of_service, privacy_policy, about_us, help_center, filing_info, filing_url, public_security_filing_info, public_security_filing_url, redeem_code_mall_url, log_retention_days, image_composition_skill_enabled FROM site_config WHERE id = 1'
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
@@ -131,7 +170,7 @@ export async function PUT(request: NextRequest) {
|
||||
return NextResponse.json({ error: '无效的请求体' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { siteName, siteTabTitle, membershipEnabled, logoBase64, faviconBase64, termsOfService, privacyPolicy, aboutUs, helpCenter, filingInfo, filingUrl, publicSecurityFilingInfo, publicSecurityFilingUrl, logRetentionDays } = body as {
|
||||
const { siteName, siteTabTitle, membershipEnabled, logoBase64, faviconBase64, termsOfService, privacyPolicy, aboutUs, helpCenter, filingInfo, filingUrl, publicSecurityFilingInfo, publicSecurityFilingUrl, redeemCodeMallUrl, logRetentionDays, imageCompositionSkillEnabled } = body as {
|
||||
siteName?: string;
|
||||
siteTabTitle?: string;
|
||||
membershipEnabled?: boolean;
|
||||
@@ -145,12 +184,14 @@ export async function PUT(request: NextRequest) {
|
||||
filingUrl?: string;
|
||||
publicSecurityFilingInfo?: string;
|
||||
publicSecurityFilingUrl?: string;
|
||||
redeemCodeMallUrl?: string;
|
||||
logRetentionDays?: number;
|
||||
imageCompositionSkillEnabled?: boolean;
|
||||
};
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
await ensureSiteConfigColumns(client);
|
||||
await ensureSiteConfigColumnsOnce(client);
|
||||
const updates: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let paramIdx = 1;
|
||||
@@ -166,6 +207,14 @@ export async function PUT(request: NextRequest) {
|
||||
if (typeof filingUrl === 'string') { updates.push(`filing_url = $${paramIdx++}`); params.push(filingUrl.trim()); }
|
||||
if (typeof publicSecurityFilingInfo === 'string') { updates.push(`public_security_filing_info = $${paramIdx++}`); params.push(publicSecurityFilingInfo.trim()); }
|
||||
if (typeof publicSecurityFilingUrl === 'string') { updates.push(`public_security_filing_url = $${paramIdx++}`); params.push(publicSecurityFilingUrl.trim()); }
|
||||
if (typeof redeemCodeMallUrl === 'string') {
|
||||
const normalizedMallUrl = normalizeExternalUrl(redeemCodeMallUrl);
|
||||
if (redeemCodeMallUrl.trim() && !normalizedMallUrl) {
|
||||
return NextResponse.json({ error: '商城链接必须是 http 或 https 开头的有效链接' }, { status: 400 });
|
||||
}
|
||||
updates.push(`redeem_code_mall_url = $${paramIdx++}`);
|
||||
params.push(normalizedMallUrl);
|
||||
}
|
||||
if (typeof logRetentionDays === 'number') {
|
||||
const safeLogRetentionDays = Math.min(90, Math.max(1, Math.floor(logRetentionDays)));
|
||||
updates.push(`log_retention_days = $${paramIdx++}`);
|
||||
@@ -173,6 +222,11 @@ export async function PUT(request: NextRequest) {
|
||||
await setPlatformLogRetentionDays(client, safeLogRetentionDays);
|
||||
await cleanupExpiredPlatformLogs(client);
|
||||
}
|
||||
if (typeof imageCompositionSkillEnabled === 'boolean') {
|
||||
updates.push(`image_composition_skill_enabled = $${paramIdx++}`);
|
||||
params.push(imageCompositionSkillEnabled);
|
||||
clearLayoutCompositionSkillCache();
|
||||
}
|
||||
const logoUrl = await saveImageDataUrl(logoBase64, 'logo');
|
||||
const faviconUrl = await saveImageDataUrl(faviconBase64, 'favicon');
|
||||
if (logoUrl) { updates.push(`logo_url = $${paramIdx++}`); params.push(logoUrl); }
|
||||
@@ -190,7 +244,7 @@ export async function PUT(request: NextRequest) {
|
||||
}
|
||||
|
||||
const result = await client.query(
|
||||
'SELECT site_name, site_tab_title, logo_url, favicon_url, membership_enabled, terms_of_service, privacy_policy, about_us, help_center, filing_info, filing_url, public_security_filing_info, public_security_filing_url, log_retention_days FROM site_config WHERE id = 1'
|
||||
'SELECT site_name, site_tab_title, logo_url, favicon_url, membership_enabled, terms_of_service, privacy_policy, about_us, help_center, filing_info, filing_url, public_security_filing_info, public_security_filing_url, redeem_code_mall_url, log_retention_days, image_composition_skill_enabled FROM site_config WHERE id = 1'
|
||||
);
|
||||
|
||||
void writePlatformLog({
|
||||
|
||||
13
src/app/api/style-presets/route.ts
Normal file
13
src/app/api/style-presets/route.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { STYLE_PRESETS } from '@/lib/model-config';
|
||||
import { listImageStylePresets } from '@/lib/style-preset-store';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const presets = await listImageStylePresets();
|
||||
return NextResponse.json({ presets });
|
||||
} catch (err) {
|
||||
console.error('[style-presets] GET error:', err);
|
||||
return NextResponse.json({ presets: STYLE_PRESETS });
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import { decryptSecret, encryptSecret, previewSecret } from '@/lib/server-crypto';
|
||||
import { getAuthenticatedUserId } from '@/lib/session-auth';
|
||||
import { readManifestCapabilities } from '@/lib/user-api-manifest';
|
||||
|
||||
function normalizeType(value: unknown): 'image' | 'video' | 'text' {
|
||||
return value === 'video' || value === 'text' ? value : 'image';
|
||||
@@ -15,6 +16,9 @@ function mapKey(row: Record<string, unknown>) {
|
||||
supplierName: row.supplier_name || row.provider || '',
|
||||
apiUrl: row.api_url || '',
|
||||
modelName: row.model_name || '',
|
||||
note: row.note || '',
|
||||
manifestPath: row.manifest_path || '',
|
||||
capabilities: readManifestCapabilities(row.manifest_path as string | null | undefined),
|
||||
apiKey: '',
|
||||
apiKeyPreview: row.api_key_preview || previewSecret(apiKey),
|
||||
type: normalizeType(row.type),
|
||||
@@ -32,9 +36,11 @@ export async function GET(request: NextRequest) {
|
||||
await client.query(`
|
||||
ALTER TABLE user_api_keys ADD COLUMN IF NOT EXISTS supplier_name VARCHAR(128);
|
||||
ALTER TABLE user_api_keys ADD COLUMN IF NOT EXISTS type VARCHAR(16) NOT NULL DEFAULT 'image';
|
||||
ALTER TABLE user_api_keys ADD COLUMN IF NOT EXISTS note TEXT;
|
||||
ALTER TABLE user_api_keys ADD COLUMN IF NOT EXISTS manifest_path TEXT;
|
||||
`);
|
||||
const result = await client.query(
|
||||
`SELECT id, provider, supplier_name, api_url, model_name, api_key_encrypted, api_key_preview, type, is_active, created_at
|
||||
`SELECT id, provider, supplier_name, api_url, model_name, note, manifest_path, api_key_encrypted, api_key_preview, type, is_active, created_at
|
||||
FROM user_api_keys
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC`,
|
||||
@@ -58,6 +64,8 @@ export async function POST(request: NextRequest) {
|
||||
await client.query(`
|
||||
ALTER TABLE user_api_keys ADD COLUMN IF NOT EXISTS supplier_name VARCHAR(128);
|
||||
ALTER TABLE user_api_keys ADD COLUMN IF NOT EXISTS type VARCHAR(16) NOT NULL DEFAULT 'image';
|
||||
ALTER TABLE user_api_keys ADD COLUMN IF NOT EXISTS note TEXT;
|
||||
ALTER TABLE user_api_keys ADD COLUMN IF NOT EXISTS manifest_path TEXT;
|
||||
`);
|
||||
const saved = [];
|
||||
for (const item of keys) {
|
||||
@@ -70,6 +78,8 @@ export async function POST(request: NextRequest) {
|
||||
String(item.supplierName || item.provider || '').trim(),
|
||||
String(item.apiUrl || '').trim(),
|
||||
String(item.modelName || '').trim(),
|
||||
String(item.note || '').trim(),
|
||||
String(item.manifestPath || '').trim(),
|
||||
apiKey ? encryptSecret(apiKey) : null,
|
||||
apiKey ? previewSecret(apiKey) : null,
|
||||
normalizeType(item.type),
|
||||
@@ -82,15 +92,17 @@ export async function POST(request: NextRequest) {
|
||||
supplier_name = $3,
|
||||
api_url = $4,
|
||||
model_name = $5,
|
||||
api_key_encrypted = COALESCE($6, api_key_encrypted),
|
||||
api_key_preview = COALESCE($7, api_key_preview),
|
||||
type = $8,
|
||||
is_active = $9,
|
||||
note = $6,
|
||||
manifest_path = CASE WHEN $7::text <> '' THEN $7 ELSE manifest_path END,
|
||||
api_key_encrypted = COALESCE($8, api_key_encrypted),
|
||||
api_key_preview = COALESCE($9, api_key_preview),
|
||||
type = $10,
|
||||
is_active = $11,
|
||||
updated_at = NOW()
|
||||
WHERE id = $10 AND user_id = $1
|
||||
WHERE id = $12 AND user_id = $1
|
||||
RETURNING *`
|
||||
: `INSERT INTO user_api_keys (user_id, provider, supplier_name, api_url, model_name, api_key_encrypted, api_key_preview, type, is_active, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())
|
||||
: `INSERT INTO user_api_keys (user_id, provider, supplier_name, api_url, model_name, note, manifest_path, api_key_encrypted, api_key_preview, type, is_active, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, COALESCE($8, ''), COALESCE($9, ''), $10, $11, NOW(), NOW())
|
||||
RETURNING *`,
|
||||
id ? [...values, id] : values,
|
||||
);
|
||||
|
||||
124
src/app/api/user-api-keys/smart-import/route.ts
Normal file
124
src/app/api/user-api-keys/smart-import/route.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAuthenticatedUserId } from '@/lib/session-auth';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import { parseImportedManifestBundle, readManifestCapabilities, resolveImportedProfileApiUrl, saveUserApiManifestFile } from '@/lib/user-api-manifest';
|
||||
import { isGenericApiKeyNote } from '@/lib/model-display';
|
||||
|
||||
function normalizeType(value: unknown): 'image' | 'video' | 'text' {
|
||||
return value === 'videos' || value === 'video'
|
||||
? 'video'
|
||||
: value === 'text'
|
||||
? 'text'
|
||||
: 'image';
|
||||
}
|
||||
|
||||
function mapKey(row: Record<string, unknown>) {
|
||||
return {
|
||||
id: row.id,
|
||||
provider: row.provider || '',
|
||||
supplierName: row.supplier_name || row.provider || '',
|
||||
apiUrl: row.api_url || '',
|
||||
modelName: row.model_name || '',
|
||||
note: row.note || '',
|
||||
manifestPath: row.manifest_path || '',
|
||||
capabilities: readManifestCapabilities(row.manifest_path as string | null | undefined),
|
||||
apiKey: '',
|
||||
apiKeyPreview: row.api_key_preview || '待填写',
|
||||
type: normalizeType(row.type),
|
||||
isActive: row.is_active !== false,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const userId = await getAuthenticatedUserId(request);
|
||||
if (!userId) return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const rawText = typeof body.configText === 'string' ? body.configText : '';
|
||||
let bundle;
|
||||
try {
|
||||
bundle = parseImportedManifestBundle(rawText);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error instanceof Error ? error.message : '配置 JSON 解析失败' }, { status: 400 });
|
||||
}
|
||||
for (const profile of bundle.profiles) {
|
||||
const apiUrl = resolveImportedProfileApiUrl(bundle, profile);
|
||||
if (!apiUrl) {
|
||||
return NextResponse.json({
|
||||
error: `${profile.name || profile.model || '当前配置'} 缺少中转 API 请求地址,请先在文档中找到 API Base URL 或完整请求端点后再导入`,
|
||||
}, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query(`
|
||||
ALTER TABLE user_api_keys ADD COLUMN IF NOT EXISTS supplier_name VARCHAR(128);
|
||||
ALTER TABLE user_api_keys ADD COLUMN IF NOT EXISTS type VARCHAR(16) NOT NULL DEFAULT 'image';
|
||||
ALTER TABLE user_api_keys ADD COLUMN IF NOT EXISTS note TEXT;
|
||||
ALTER TABLE user_api_keys ADD COLUMN IF NOT EXISTS manifest_path TEXT;
|
||||
`);
|
||||
|
||||
const saved = [];
|
||||
for (const profile of bundle.profiles) {
|
||||
const provider = bundle.customProviders.find(item => item.id === profile.provider)
|
||||
|| bundle.customProviders.find(item => item.name === profile.provider)
|
||||
|| bundle.customProviders[0];
|
||||
const profileName = profile.name || provider?.name || '自定义服务商';
|
||||
const providerName = provider?.name || profileName;
|
||||
const apiUrl = resolveImportedProfileApiUrl(bundle, profile);
|
||||
const modelName = String(profile.model || '').trim();
|
||||
const note = profileName && !isGenericApiKeyNote(profileName) && profileName !== modelName
|
||||
? profileName
|
||||
: '';
|
||||
const result = await client.query(
|
||||
`INSERT INTO user_api_keys (
|
||||
user_id, provider, supplier_name, api_url, model_name, note,
|
||||
manifest_path, api_key_encrypted, api_key_preview, type, is_active,
|
||||
created_at, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, '', '', '待填写', $7, true, NOW(), NOW())
|
||||
RETURNING *`,
|
||||
[
|
||||
userId,
|
||||
providerName,
|
||||
providerName,
|
||||
apiUrl,
|
||||
modelName,
|
||||
note,
|
||||
normalizeType(profile.apiMode),
|
||||
],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
const manifestPath = await saveUserApiManifestFile({
|
||||
userId,
|
||||
keyId: String(row.id),
|
||||
bundle,
|
||||
profile,
|
||||
});
|
||||
const updated = await client.query(
|
||||
`UPDATE user_api_keys
|
||||
SET manifest_path = $1,
|
||||
updated_at = NOW()
|
||||
WHERE id = $2 AND user_id = $3
|
||||
RETURNING *`,
|
||||
[manifestPath, row.id, userId],
|
||||
);
|
||||
saved.push(mapKey(updated.rows[0]));
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
keys: saved,
|
||||
message: `已导入 ${saved.length} 个 API 配置,请编辑后填写 API Key`,
|
||||
});
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
return NextResponse.json({ error: error instanceof Error ? error.message : '导入配置失败' }, { status: 500 });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
@@ -358,17 +358,18 @@ export default function AuthPage() {
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reg-nickname">昵称</Label>
|
||||
<Label htmlFor="reg-nickname">用户名</Label>
|
||||
<div className="relative">
|
||||
<User className={authInputIconClass} />
|
||||
<Input
|
||||
id="reg-nickname"
|
||||
placeholder="你的昵称"
|
||||
placeholder="用于登录的用户名"
|
||||
value={regNickname}
|
||||
onChange={(e) => setRegNickname(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">系统会自动生成一个中文昵称和默认头像,注册后可在个人资料中修改。</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reg-phone">手机号 (选填)</Label>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Brush, Mail, Lock, User, Phone, Eye, EyeOff, Loader2 } from 'lucide-react';
|
||||
import { Brush, Mail, Lock, User, Phone, Eye, EyeOff, Gift, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { addCreditRecord } from '@/lib/credit-records-store';
|
||||
import { RegistrationAgreementDialog } from '@/components/auth/registration-agreement-dialog';
|
||||
@@ -35,13 +35,20 @@ export default function RegisterPage() {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [nickname, setNickname] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [emailCode, setEmailCode] = useState('');
|
||||
const [referralCode, setReferralCode] = useState('');
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||
const [showAgreement, setShowAgreement] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = (params.get('invite') || params.get('ref') || '').replace(/[^a-z0-9]/gi, '').toUpperCase().slice(0, 32);
|
||||
if (code) setReferralCode(code);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (codeCooldown <= 0) return;
|
||||
const timer = window.setInterval(() => setCodeCooldown(prev => Math.max(0, prev - 1)), 1000);
|
||||
@@ -70,12 +77,13 @@ export default function RegisterPage() {
|
||||
const res = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password, nickname, phone, emailCode, acceptedTerms: true }),
|
||||
body: JSON.stringify({ email, password, nickname: username, phone, emailCode, referralCode, acceptedTerms: true }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || '注册失败');
|
||||
toast.success('注册成功,赠送10积分体验金');
|
||||
addCreditRecord({ type: 'gift', amount: 10, balanceAfter: 10, description: '新用户注册奖励' });
|
||||
const bonus = referralCode ? 60 : 10;
|
||||
toast.success(referralCode ? '注册成功,已获得10积分体验金和50积分邀请奖励' : '注册成功,赠送10积分体验金');
|
||||
addCreditRecord({ type: 'gift', amount: bonus, balanceAfter: bonus, description: referralCode ? '注册和邀请奖励' : '新用户注册奖励' });
|
||||
router.push('/create');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '注册失败');
|
||||
@@ -134,9 +142,18 @@ export default function RegisterPage() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="font-serif text-xl">创建账号</CardTitle>
|
||||
<CardDescription>注册即可获得10积分体验金</CardDescription>
|
||||
<CardDescription>{referralCode ? '通过邀请注册可额外获得50积分' : '注册即可获得10积分体验金'}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{referralCode && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-primary/20 bg-primary/5 p-3 text-sm">
|
||||
<Gift className="mt-0.5 h-4 w-4 text-primary" />
|
||||
<div>
|
||||
<p className="font-medium">已识别邀请链接</p>
|
||||
<p className="text-xs text-muted-foreground">注册成功后,你和邀请人各获得50积分。</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">邮箱 *</Label>
|
||||
<div className="relative">
|
||||
@@ -167,11 +184,12 @@ export default function RegisterPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nickname">昵称</Label>
|
||||
<Label htmlFor="nickname">用户名</Label>
|
||||
<div className="relative">
|
||||
<User className={authInputIconClass} />
|
||||
<Input id="nickname" placeholder="你的昵称" value={nickname} onChange={(e) => setNickname(e.target.value)} className="pl-10" />
|
||||
<Input id="nickname" placeholder="用于登录的用户名" value={username} onChange={(e) => setUsername(e.target.value)} className="pl-10" />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">系统会自动生成一个中文昵称和默认头像,注册后可在个人资料中修改。</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">手机号</Label>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useState } from 'react';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { TextToImagePanel } from '@/components/create/text-to-image';
|
||||
@@ -10,61 +10,119 @@ import { ImageToVideoPanel } from '@/components/create/image-to-video';
|
||||
import ReversePromptPanel from '@/components/create/reverse-prompt-panel';
|
||||
import { Brush, ImagePlus, Video, Film, Loader2, FileSearch } from 'lucide-react';
|
||||
|
||||
const TYPE_MAP: Record<string, string> = {
|
||||
text2img: 'text2img',
|
||||
img2img: 'img2img',
|
||||
text2video: 'text2video',
|
||||
img2video: 'img2video',
|
||||
reversePrompt: 'reversePrompt',
|
||||
'reverse-prompt': 'reversePrompt',
|
||||
};
|
||||
const DEFAULT_CREATE_TAB = 'text2img';
|
||||
const CREATE_TAB_STORAGE_KEY = 'miaojing:create-active-tab';
|
||||
const CREATE_TAB_VALUES = new Set(Object.values(TYPE_MAP));
|
||||
|
||||
function normalizeCreateTab(value: string | null): string | null {
|
||||
if (!value) return null;
|
||||
return TYPE_MAP[value] || null;
|
||||
}
|
||||
|
||||
function getStoredCreateTab(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const stored = window.localStorage.getItem(CREATE_TAB_STORAGE_KEY);
|
||||
return stored && CREATE_TAB_VALUES.has(stored) ? stored : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function persistCreateTab(value: string) {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.localStorage.setItem(CREATE_TAB_STORAGE_KEY, value);
|
||||
} catch {
|
||||
// Ignore storage failures so tab switching remains usable in private modes.
|
||||
}
|
||||
}
|
||||
|
||||
function replaceCreateTabUrl(value: string) {
|
||||
if (typeof window === 'undefined') return;
|
||||
const url = new URL(window.location.href);
|
||||
if (url.searchParams.get('type') === value) return;
|
||||
url.searchParams.set('type', value);
|
||||
window.history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`);
|
||||
}
|
||||
|
||||
function CreateContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const typeParam = searchParams.get('type') || 'text2img';
|
||||
const typeParam = searchParams.get('type');
|
||||
|
||||
const typeMap: Record<string, string> = {
|
||||
text2img: 'text2img',
|
||||
img2img: 'img2img',
|
||||
text2video: 'text2video',
|
||||
img2video: 'img2video',
|
||||
reversePrompt: 'reversePrompt',
|
||||
const [activeTab, setActiveTab] = useState(DEFAULT_CREATE_TAB);
|
||||
|
||||
useEffect(() => {
|
||||
const nextTab = normalizeCreateTab(typeParam) || getStoredCreateTab() || DEFAULT_CREATE_TAB;
|
||||
setActiveTab(nextTab);
|
||||
persistCreateTab(nextTab);
|
||||
if (!typeParam) {
|
||||
replaceCreateTabUrl(nextTab);
|
||||
}
|
||||
}, [typeParam]);
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
if (!CREATE_TAB_VALUES.has(value)) return;
|
||||
setActiveTab(value);
|
||||
persistCreateTab(value);
|
||||
replaceCreateTabUrl(value);
|
||||
};
|
||||
|
||||
const [activeTab, setActiveTab] = useState(typeMap[typeParam] || 'text2img');
|
||||
const renderModeTriggers = (mobile = false) => (
|
||||
<>
|
||||
<TabsTrigger value="text2img" className="gap-2">
|
||||
<Brush className="h-4 w-4" />
|
||||
<span className={mobile ? 'inline' : 'hidden sm:inline'}>文生图</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="img2img" className="gap-2">
|
||||
<ImagePlus className="h-4 w-4" />
|
||||
<span className={mobile ? 'inline' : 'hidden sm:inline'}>图生图</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="text2video" className="gap-2">
|
||||
<Video className="h-4 w-4" />
|
||||
<span className={mobile ? 'inline' : 'hidden sm:inline'}>文生视频</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="img2video" className="gap-2">
|
||||
<Film className="h-4 w-4" />
|
||||
<span className={mobile ? 'inline' : 'hidden sm:inline'}>图生视频</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="reversePrompt" className="gap-2">
|
||||
<FileSearch className="h-4 w-4" />
|
||||
<span className={mobile ? 'inline' : 'hidden sm:inline'}>图片反推</span>
|
||||
</TabsTrigger>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-5 max-w-4xl">
|
||||
<TabsTrigger value="text2img" className="gap-2">
|
||||
<Brush className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">文生图</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="img2img" className="gap-2">
|
||||
<ImagePlus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">图生图</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="text2video" className="gap-2">
|
||||
<Video className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">文生视频</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="img2video" className="gap-2">
|
||||
<Film className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">图生视频</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="reversePrompt" className="gap-2">
|
||||
<FileSearch className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">图片反推</span>
|
||||
</TabsTrigger>
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="create-mobile-tabs-root space-y-6">
|
||||
<TabsList className="create-mode-tabs create-mode-tabs-desktop grid w-full grid-cols-5 max-w-4xl">
|
||||
{renderModeTriggers()}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="text2img">
|
||||
<TabsContent value="text2img" className="create-tab-content">
|
||||
<TextToImagePanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="img2img">
|
||||
<TabsContent value="img2img" className="create-tab-content">
|
||||
<ImageToImagePanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="text2video">
|
||||
<TabsContent value="text2video" className="create-tab-content">
|
||||
<TextToVideoPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="img2video">
|
||||
<TabsContent value="img2video" className="create-tab-content">
|
||||
<ImageToVideoPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="reversePrompt">
|
||||
<TabsContent value="reversePrompt" className="create-tab-content">
|
||||
<ReversePromptPanel
|
||||
onUseForTextToImage={() => setActiveTab('text2img')}
|
||||
onUseForImageToImage={() => setActiveTab('img2img')}
|
||||
onUseForTextToImage={() => handleTabChange('text2img')}
|
||||
onUseForImageToImage={() => handleTabChange('img2img')}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
@@ -73,9 +131,9 @@ function CreateContent() {
|
||||
|
||||
export default function CreatePage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 py-8">
|
||||
<div className="mb-8">
|
||||
<div className="create-mobile-page min-h-screen bg-background">
|
||||
<div className="create-mobile-shell mx-auto max-w-7xl px-4 sm:px-6 py-8">
|
||||
<div className="create-mobile-heading mb-8">
|
||||
<h1 className="font-serif text-3xl font-bold">创作中心</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
选择创作模式,释放你的想象力
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user