From 606caaf047acc894b5af0767cbe15556857824c6 Mon Sep 17 00:00:00 2001 From: chiyi Date: Thu, 25 Jun 2026 22:53:03 +0800 Subject: [PATCH] Initial open-source release (MIT): image2api AI gateway Full Go backend + Vue 3 frontend, OpenAI-compatible API, multi-provider account pools, billing/admin, Docker one-command deploy with auto HTTPS. Co-Authored-By: Claude Opus 4.8 --- .env.docker.example | 28 + .gitignore | 60 + LICENSE | 21 + README.en.md | 271 ++ README.md | 271 ++ backend/.dockerignore | 6 + backend/.env.example | 20 + backend/.gitignore | 9 + backend/Dockerfile | 24 + backend/README.md | 55 + backend/cmd/api/main.go | 50 + backend/cmd/marklabel/main.go | 45 + backend/go.mod | 71 + backend/go.sum | 178 ++ backend/internal/bootstrap/app.go | 174 ++ backend/internal/bootstrap/seed.go | 51 + backend/internal/config/config.go | 174 ++ backend/internal/http/handler/admin_read.go | 234 ++ backend/internal/http/handler/admin_write.go | 241 ++ backend/internal/http/handler/app_settings.go | 191 ++ backend/internal/http/handler/auth.go | 350 +++ backend/internal/http/handler/cdk.go | 104 + backend/internal/http/handler/health.go | 13 + backend/internal/http/handler/images.go | 88 + .../internal/http/handler/provider_admin.go | 338 +++ backend/internal/http/handler/showcase.go | 45 + backend/internal/http/handler/site.go | 25 + .../internal/http/handler/site_settings.go | 52 + .../internal/http/handler/user_generation.go | 603 +++++ backend/internal/http/handler/user_tools.go | 92 + backend/internal/http/handler/v1.go | 373 +++ backend/internal/http/middleware/auth.go | 70 + .../internal/http/middleware/request_id.go | 20 + backend/internal/http/router/router.go | 166 ++ backend/internal/model/models.go | 190 ++ backend/internal/provider/adobe.zip | Bin 0 -> 11843 bytes backend/internal/provider/adobe/auth.go | 39 + backend/internal/provider/adobe/client.go | 875 ++++++ backend/internal/provider/adobe/payloads.go | 466 ++++ backend/internal/provider/adobe/util.go | 144 + backend/internal/provider/chatgpt/client.go | 1158 ++++++++ backend/internal/provider/chatgpt/pow.go | 151 ++ .../internal/provider/chatgpt/turnstile.go | 182 ++ backend/internal/provider/chatgpt/util.go | 125 + backend/internal/provider/imagine/client.go | 441 +++ backend/internal/provider/imagine/image.go | 242 ++ backend/internal/provider/krea/client.go | 488 ++++ backend/internal/provider/krea/image.go | 308 +++ backend/internal/provider/leonardo/client.go | 396 +++ backend/internal/provider/leonardo/image.go | 326 +++ backend/internal/provider/runway/client.go | 254 ++ backend/internal/provider/runway/video.go | 391 +++ backend/internal/repo/api_key_repo.go | 53 + backend/internal/repo/cdk_repo.go | 134 + backend/internal/repo/event_repo.go | 650 +++++ backend/internal/repo/model_repo.go | 69 + backend/internal/repo/refresh_profile_repo.go | 73 + backend/internal/repo/showcase_repo.go | 99 + backend/internal/repo/site_setting_repo.go | 96 + backend/internal/repo/token_repo.go | 390 +++ backend/internal/repo/user_repo.go | 627 +++++ backend/internal/service/admin_read.go | 568 ++++ backend/internal/service/admin_write.go | 635 +++++ backend/internal/service/alphabet.go | 20 + backend/internal/service/api_key.go | 130 + backend/internal/service/app_settings.go | 438 +++ backend/internal/service/auth.go | 584 ++++ backend/internal/service/cdk.go | 167 ++ backend/internal/service/email_code.go | 126 + backend/internal/service/image_access.go | 48 + backend/internal/service/login_guard.go | 169 ++ backend/internal/service/maintenance.go | 282 ++ backend/internal/service/password.go | 46 + backend/internal/service/rate_limit.go | 79 + backend/internal/service/refresh_profiles.go | 185 ++ backend/internal/service/session.go | 103 + backend/internal/service/showcase.go | 20 + backend/internal/service/site.go | 81 + backend/internal/service/smtp.go | 117 + backend/internal/service/tokens.go | 1668 ++++++++++++ backend/internal/service/user_generation.go | 200 ++ backend/internal/service/v1.go | 2384 +++++++++++++++++ backend/internal/service/validation.go | 161 ++ backend/internal/storage/rustfs.go | 294 ++ docker-compose.yml | 165 ++ docs/screenshots/accounts.png | Bin 0 -> 43234 bytes docs/screenshots/dashboard.png | Bin 0 -> 222559 bytes docs/screenshots/logs.png | Bin 0 -> 194002 bytes docs/screenshots/playground.png | Bin 0 -> 627919 bytes frontend/.dockerignore | 4 + frontend/.gitignore | 5 + frontend/Dockerfile | 23 + frontend/README.md | 36 + frontend/default.conf.template | 74 + frontend/docker-entrypoint.d/10-selfsigned.sh | 14 + frontend/docker-entrypoint.d/30-cert-watch.sh | 20 + frontend/index.html | 13 + frontend/package-lock.json | 1986 ++++++++++++++ frontend/package.json | 21 + frontend/public/favicon.svg | 17 + frontend/src/App.vue | 22 + frontend/src/api.js | 53 + frontend/src/auth.js | 85 + frontend/src/components/Icon.vue | 32 + frontend/src/components/ImportModal.vue | 134 + frontend/src/components/LoginModal.vue | 404 +++ frontend/src/components/Logo.vue | 39 + frontend/src/components/MediaLightbox.vue | 59 + frontend/src/components/ModelFormModal.vue | 342 +++ frontend/src/components/SelectMenu.vue | 109 + frontend/src/components/TagInput.vue | 95 + frontend/src/components/TestModal.vue | 314 +++ frontend/src/credits.js | 12 + frontend/src/layouts/AdminLayout.vue | 116 + frontend/src/layouts/PublicLayout.vue | 208 ++ frontend/src/main.js | 119 + frontend/src/playground.js | 26 + frontend/src/site.js | 35 + frontend/src/style.css | 416 +++ frontend/src/theme.js | 46 + frontend/src/utils/format.js | 107 + frontend/src/utils/import.js | 115 + frontend/src/views/AboutView.vue | 72 + frontend/src/views/AccountsView.vue | 651 +++++ frontend/src/views/CdksView.vue | 383 +++ frontend/src/views/ConfigView.vue | 509 ++++ frontend/src/views/DocsView.vue | 372 +++ frontend/src/views/HomeView.vue | 402 +++ frontend/src/views/ImagesView.vue | 272 ++ frontend/src/views/InviteView.vue | 229 ++ frontend/src/views/InvitesAdminView.vue | 102 + frontend/src/views/LogsView.vue | 438 +++ frontend/src/views/ModelsView.vue | 399 +++ frontend/src/views/OverviewView.vue | 518 ++++ frontend/src/views/PlaygroundView.vue | 648 +++++ frontend/src/views/SettingsView.vue | 364 +++ frontend/src/views/ShowcaseView.vue | 417 +++ frontend/src/views/UserLogsTableView.vue | 302 +++ frontend/src/views/UserLogsView.vue | 269 ++ frontend/src/views/UsersView.vue | 569 ++++ frontend/vite.config.js | 39 + install.sh | 42 + 142 files changed, 33648 insertions(+) create mode 100644 .env.docker.example create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.en.md create mode 100644 README.md create mode 100644 backend/.dockerignore create mode 100644 backend/.env.example create mode 100644 backend/.gitignore create mode 100644 backend/Dockerfile create mode 100644 backend/README.md create mode 100644 backend/cmd/api/main.go create mode 100644 backend/cmd/marklabel/main.go create mode 100644 backend/go.mod create mode 100644 backend/go.sum create mode 100644 backend/internal/bootstrap/app.go create mode 100644 backend/internal/bootstrap/seed.go create mode 100644 backend/internal/config/config.go create mode 100644 backend/internal/http/handler/admin_read.go create mode 100644 backend/internal/http/handler/admin_write.go create mode 100644 backend/internal/http/handler/app_settings.go create mode 100644 backend/internal/http/handler/auth.go create mode 100644 backend/internal/http/handler/cdk.go create mode 100644 backend/internal/http/handler/health.go create mode 100644 backend/internal/http/handler/images.go create mode 100644 backend/internal/http/handler/provider_admin.go create mode 100644 backend/internal/http/handler/showcase.go create mode 100644 backend/internal/http/handler/site.go create mode 100644 backend/internal/http/handler/site_settings.go create mode 100644 backend/internal/http/handler/user_generation.go create mode 100644 backend/internal/http/handler/user_tools.go create mode 100644 backend/internal/http/handler/v1.go create mode 100644 backend/internal/http/middleware/auth.go create mode 100644 backend/internal/http/middleware/request_id.go create mode 100644 backend/internal/http/router/router.go create mode 100644 backend/internal/model/models.go create mode 100644 backend/internal/provider/adobe.zip create mode 100644 backend/internal/provider/adobe/auth.go create mode 100644 backend/internal/provider/adobe/client.go create mode 100644 backend/internal/provider/adobe/payloads.go create mode 100644 backend/internal/provider/adobe/util.go create mode 100644 backend/internal/provider/chatgpt/client.go create mode 100644 backend/internal/provider/chatgpt/pow.go create mode 100644 backend/internal/provider/chatgpt/turnstile.go create mode 100644 backend/internal/provider/chatgpt/util.go create mode 100644 backend/internal/provider/imagine/client.go create mode 100644 backend/internal/provider/imagine/image.go create mode 100644 backend/internal/provider/krea/client.go create mode 100644 backend/internal/provider/krea/image.go create mode 100644 backend/internal/provider/leonardo/client.go create mode 100644 backend/internal/provider/leonardo/image.go create mode 100644 backend/internal/provider/runway/client.go create mode 100644 backend/internal/provider/runway/video.go create mode 100644 backend/internal/repo/api_key_repo.go create mode 100644 backend/internal/repo/cdk_repo.go create mode 100644 backend/internal/repo/event_repo.go create mode 100644 backend/internal/repo/model_repo.go create mode 100644 backend/internal/repo/refresh_profile_repo.go create mode 100644 backend/internal/repo/showcase_repo.go create mode 100644 backend/internal/repo/site_setting_repo.go create mode 100644 backend/internal/repo/token_repo.go create mode 100644 backend/internal/repo/user_repo.go create mode 100644 backend/internal/service/admin_read.go create mode 100644 backend/internal/service/admin_write.go create mode 100644 backend/internal/service/alphabet.go create mode 100644 backend/internal/service/api_key.go create mode 100644 backend/internal/service/app_settings.go create mode 100644 backend/internal/service/auth.go create mode 100644 backend/internal/service/cdk.go create mode 100644 backend/internal/service/email_code.go create mode 100644 backend/internal/service/image_access.go create mode 100644 backend/internal/service/login_guard.go create mode 100644 backend/internal/service/maintenance.go create mode 100644 backend/internal/service/password.go create mode 100644 backend/internal/service/rate_limit.go create mode 100644 backend/internal/service/refresh_profiles.go create mode 100644 backend/internal/service/session.go create mode 100644 backend/internal/service/showcase.go create mode 100644 backend/internal/service/site.go create mode 100644 backend/internal/service/smtp.go create mode 100644 backend/internal/service/tokens.go create mode 100644 backend/internal/service/user_generation.go create mode 100644 backend/internal/service/v1.go create mode 100644 backend/internal/service/validation.go create mode 100644 backend/internal/storage/rustfs.go create mode 100644 docker-compose.yml create mode 100644 docs/screenshots/accounts.png create mode 100644 docs/screenshots/dashboard.png create mode 100644 docs/screenshots/logs.png create mode 100644 docs/screenshots/playground.png create mode 100644 frontend/.dockerignore create mode 100644 frontend/.gitignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/README.md create mode 100644 frontend/default.conf.template create mode 100644 frontend/docker-entrypoint.d/10-selfsigned.sh create mode 100644 frontend/docker-entrypoint.d/30-cert-watch.sh create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api.js create mode 100644 frontend/src/auth.js create mode 100644 frontend/src/components/Icon.vue create mode 100644 frontend/src/components/ImportModal.vue create mode 100644 frontend/src/components/LoginModal.vue create mode 100644 frontend/src/components/Logo.vue create mode 100644 frontend/src/components/MediaLightbox.vue create mode 100644 frontend/src/components/ModelFormModal.vue create mode 100644 frontend/src/components/SelectMenu.vue create mode 100644 frontend/src/components/TagInput.vue create mode 100644 frontend/src/components/TestModal.vue create mode 100644 frontend/src/credits.js create mode 100644 frontend/src/layouts/AdminLayout.vue create mode 100644 frontend/src/layouts/PublicLayout.vue create mode 100644 frontend/src/main.js create mode 100644 frontend/src/playground.js create mode 100644 frontend/src/site.js create mode 100644 frontend/src/style.css create mode 100644 frontend/src/theme.js create mode 100644 frontend/src/utils/format.js create mode 100644 frontend/src/utils/import.js create mode 100644 frontend/src/views/AboutView.vue create mode 100644 frontend/src/views/AccountsView.vue create mode 100644 frontend/src/views/CdksView.vue create mode 100644 frontend/src/views/ConfigView.vue create mode 100644 frontend/src/views/DocsView.vue create mode 100644 frontend/src/views/HomeView.vue create mode 100644 frontend/src/views/ImagesView.vue create mode 100644 frontend/src/views/InviteView.vue create mode 100644 frontend/src/views/InvitesAdminView.vue create mode 100644 frontend/src/views/LogsView.vue create mode 100644 frontend/src/views/ModelsView.vue create mode 100644 frontend/src/views/OverviewView.vue create mode 100644 frontend/src/views/PlaygroundView.vue create mode 100644 frontend/src/views/SettingsView.vue create mode 100644 frontend/src/views/ShowcaseView.vue create mode 100644 frontend/src/views/UserLogsTableView.vue create mode 100644 frontend/src/views/UserLogsView.vue create mode 100644 frontend/src/views/UsersView.vue create mode 100644 frontend/vite.config.js create mode 100644 install.sh diff --git a/.env.docker.example b/.env.docker.example new file mode 100644 index 0000000..546ddb1 --- /dev/null +++ b/.env.docker.example @@ -0,0 +1,28 @@ +# Copy to .env (next to docker-compose.yml), edit, then: +# docker compose up -d --build + +# Domain nginx binds (server_name) and acme.sh issues the cert for. Must resolve +# to this host with ports 80 + 443 reachable for the Let's Encrypt http-01 check. +# Use "localhost" for local testing — then ACME is skipped and a self-signed cert +# is used (443 still works, browser will warn). +DOMAIN=vividai.run + +# Email for the Let's Encrypt / ACME account (renewal notices). +ACME_EMAIL=admin@example.com + +# Host ports nginx listens on (80 = ACME challenge + redirect, 443 = TLS). +HTTP_PORT=80 +HTTPS_PORT=443 + +# Session cookie Secure flag — keep true when serving over HTTPS (the default). +COOKIE_SECURE=true + +APP_TITLE=image2api + +# --- Postgres --- +POSTGRES_PASSWORD=change-me-postgres + +# --- S3 / object storage (RustFS) --- +S3_BUCKET=vivid-ai +S3_ACCESS_KEY=vividai +S3_SECRET_KEY=change-me-s3-secret diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fabb045 --- /dev/null +++ b/.gitignore @@ -0,0 +1,60 @@ +# ====================================================================== +# Backend is OPEN-SOURCE — ship the Go source. Only secrets / data / +# build artifacts under backend/ stay ignored. +# ====================================================================== +/backend/.env +/backend/.env.local +/backend/.env.*.local +/backend/data/ +/backend/bin/ + +# ====================================================================== +# Private — not shipped to users (internal deploy scripts + secrets). +# deploy/ holds the maintainer's SSH/deploy + test scripts with hardcoded +# server host, root password and API keys. NEVER commit. +# ====================================================================== +/deploy/ +.env +.env.local +.env.*.local +/backend/.env +api_test_video.py # hardcoded API key + +# ====================================================================== +# Secrets / captures / clutter — MUST NOT be published +# ====================================================================== +# browser HAR captures contain real cookies/tokens +*.har +# prebuilt binaries (backend is built from source now) +/dist/ +# stray debug screenshots pasted into the repo root +/6cfa6628d28a895f234fc2a4488a0d43.png +/QQ_*.png + +# ====================================================================== +# Dependencies / build output +# ====================================================================== +node_modules/ +/frontend/dist/ +/frontend/.vite/ + +# Go build/module caches (regenerated) +.gocache/ +.gomodcache/ +*.exe +*.exe~ +*.out +*.test + +# ====================================================================== +# Runtime / logs / OS / editor +# ====================================================================== +*.log +*.tmp +/backend/data/ +__pycache__/ +*.pyc +.DS_Store +Thumbs.db +.idea/ +.vscode/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ee783c2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 chiyi (Vivid AI · image2api) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000..2be952c --- /dev/null +++ b/README.en.md @@ -0,0 +1,271 @@ +
+ +

image2api

+ +**Multi-provider AI image / video generation gateway — one OpenAI-compatible API, six platforms aggregated, a ready-to-run operations system** + +Live instance (brand): [Vivid AI · vividai.run](https://vividai.run) + +[简体中文](README.md) | **English** + +[![Online Demo](https://img.shields.io/badge/Live%20Demo-vividai.run-7c3aed?style=for-the-badge)](https://vividai.run) + +[![Go](https://img.shields.io/badge/Go-1.26-00ADD8?logo=go&logoColor=white)](https://go.dev) +[![Vue 3](https://img.shields.io/badge/Vue-3-42b883?logo=vuedotjs&logoColor=white)](https://vuejs.org) +[![Docker](https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white)](#-deployment) +[![OpenAI Compatible](https://img.shields.io/badge/OpenAI-compatible-412991?logo=openai&logoColor=white)](#-openai-compatible-api) +[![HTTPS](https://img.shields.io/badge/HTTPS-acme.sh%20auto--issue-success)](#option-1-docker-one-command-recommended) +[![Providers](https://img.shields.io/badge/providers-6-orange)](#-supported-models--providers) +[![Self-hosted](https://img.shields.io/badge/self--hosted-yes-success)](#-deployment) +[![License](https://img.shields.io/badge/license-MIT-blue)](#-license) + +[Live Demo](https://vividai.run) · [Features](#-features) · [Deploy](#-deployment) · [API](#-openai-compatible-api) · [Community](#-community--contact) + +
+ +image2api — playground + +
+ +--- + +## 📖 Table of Contents + +- [Overview](#-overview) +- [Screenshots](#-screenshots) +- [Features](#-features) +- [Supported Models / Providers](#-supported-models--providers) +- [OpenAI-Compatible API](#-openai-compatible-api) +- [Deployment](#-deployment) +- [Tech Stack](#-tech-stack) +- [Repository Layout](#-repository-layout) +- [Roadmap](#-roadmap) +- [Community / Contact](#-community--contact) +- [License](#-license) + +## ✨ Overview + +**image2api** wraps the image / video capabilities of Adobe Firefly, OpenAI, Runway, Leonardo, Krea and Imagine into **a single OpenAI-compatible API**. Behind it, multi-account pools are scheduled automatically — out of quota → switch account, auth expired → refresh or kill, transient errors → retry, tokens proactively renewed before they expire — to deliver a stable service. + +It's more than an API proxy: it ships with **credit billing, CDK top-ups, referral rewards, a user system, an admin console, and a modern generation frontend**, so a single command turns it into a fully operational AI generation site — the author's live instance **[Vivid AI · vividai.run](https://vividai.run)** (brand) is built on this project. + +> 💡 Both frontend and backend are **fully open-source** (MIT) — Go + Vue 3, free to fork and self-host. + +**At a glance** 🔌 OpenAI-compatible · 🤖 6 platforms, 10+ models · 🔁 auto failover / token keep-alive · 💳 credits + agent pricing · 🎨 generation frontend + admin console · 🐳 one-command deploy + auto HTTPS + +## 🖼️ Screenshots + +| Playground | Dashboard | +|:---:|:---:| +| ![Playground](docs/screenshots/playground.png) | ![Dashboard](docs/screenshots/dashboard.png) | +| **Accounts** | **Logs** | +| ![Accounts](docs/screenshots/accounts.png) | ![Logs](docs/screenshots/logs.png) | + +## 🚀 Features + + + + + + +
+ +### 🎨 Generation +- Images + videos in one place, with **image-to-image / reference frames** (first frame, last frame, style reference) +- Multiple resolutions (1K / 2K / 4K), aspect ratios and video durations — configured and priced per model +- 6 providers, 10+ models, **enable / disable / re-price from the admin console**, no code changes + +### 🔌 OpenAI Compatible +- Text-to-image `/v1/images/generations` · image-to-image `/v1/images/edits` (multipart ref upload) · video `/v1/videos` (Sora-style async: create → poll → `/content`) · `/v1/models` +- **Strict OpenAI params**: `size` sets the aspect ratio, `quality` the resolution tier — just swap `base_url` + `api_key` into an existing OpenAI SDK +- Image results returned **inline as base64** — nothing stored server-side, privacy-friendly + +### 🔁 Account Pools + Smart Failover +- Round-robin scheduling across the pool; one bad account doesn't break the whole +- **Out of quota → switch** · **auth expired → refresh & retry / kill** · **transient → retry same account ×3** · **bad params → fail fast** +- **Pre-deducted credits**: atomic debit before generation, auto-refunded on failure, no over-spend under concurrency + + + +### 🔐 Automatic Token Keep-alive +- Single-use rotating tokens (Krea / Imagine) are **renewed proactively 10 minutes before expiry**; new tokens persisted automatically +- Adobe cookies exchanged for fresh tokens on a schedule; bare JWTs killed on expiry +- Daily quota recovered at each provider's reset time, then re-probed for the real balance + +### 💳 Billing & Operations +- Credit-based (**pre-deduct + refund on failure**), priced per model / resolution / duration +- **Agent pricing**: a user can be set as an "agent" role and models can carry agent prices; agent users (including their API key calls) are billed at the agent price, falling back to the normal price when unset +- **CDK redeem codes** · **referral rewards** · email sign-up / verification code / password reset +- Three roles: regular user / agent / admin (single) + +### 🖥️ User Frontend (Vue 3) +- Playground · creations gallery · generation logs (with failure reasons / source tags) +- API docs · API key management · referral · about, light / dark theme + +### 🛠️ Admin Console +- Overview dashboard (trends / DAU / top failures / top spenders) +- Model management (normal + agent price) · account management (bulk import / dedup / quota) · site-wide logs · user management (set as agent) · CDK · showcase · site config + +
+ +**🧰 Engineering highlights**: tls-client (Chrome JA3/JA4 fingerprint) reliably passes Cloudflare · media stored in S3/RustFS, served through an authenticated proxy with retention cleanup · self-healing maintenance loop (quota recovery / credential refresh / orphan-job cleanup with refunds) · one-command Docker deploy with acme.sh auto HTTPS. + +## 🤖 Supported Models / Providers + +| Provider | Models (examples) | Type | +|---|---|---| +| **Adobe Firefly** | firefly-image-5 · firefly-gpt-image-2 · nano-banana-2 · flux-kontext-max · firefly-video · firefly-ray · gemini-veo31 | Image / Video | +| **OpenAI** | gpt-image-2 | Image | +| **Runway** | runway-gen4-turbo | Video | +| **Leonardo.ai** | seedream-4.5 | Image | +| **Krea.ai** | flux-klein-2 | Image | +| **Imagine.art** | imagine-1.5 · imagine-1.5pro | Image | + +> Models are enabled and priced dynamically from the admin console — add or remove anytime. + +## 🔌 OpenAI-Compatible API + +```bash +# Text-to-image — pure OpenAI params: size→aspect ratio, quality→tier (low/medium/high→1K/2K/4K) +curl https://your-domain/v1/images/generations \ + -H "Authorization: Bearer sk-xxxx" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-image-2", + "prompt": "a cute cat on a desk, studio lighting", + "size": "1024x1024", + "quality": "high" + }' + +# Image-to-image — multipart reference upload (multiple via image[]) +curl https://your-domain/v1/images/edits \ + -H "Authorization: Bearer sk-xxxx" \ + -F model="seedream-4.5" -F prompt="make it cyberpunk" -F image=@input.png +``` + +Images return OpenAI-style `{ "created": ..., "data": [{ "b64_json": "..." }] }` (raw base64, no `data:` prefix, nothing stored server-side). **Video** is async: `POST /v1/videos` → poll `GET /v1/videos/{id}` until `completed` → `GET /v1/videos/{id}/content` for the mp4. Full parameters are documented on the in-app **/docs** page. + +## 🚀 Deployment + +Both frontend and backend are open-source. Docker one-command is recommended; you can also build from source with **Go 1.26+**. + +> Prerequisite: a domain A-record pointing to this host, with **ports 80 / 443 open to the internet** (required for Let's Encrypt verification). + +### Option 1: Docker, one command (recommended) + +Requires Docker + Docker Compose. A single command brings up Postgres + Redis + RustFS + backend + frontend, and **auto-issues / renews the HTTPS certificate** (built-in acme.sh). + +```bash +cp .env.docker.example .env # fill in DOMAIN / ACME_EMAIL / POSTGRES_PASSWORD / S3_SECRET_KEY +sh install.sh # = docker compose up -d --build +``` + +Open `https:///`; watch cert progress with `docker compose logs -f acme`. With `DOMAIN=localhost` a self-signed cert is used (local testing). + +
+Option 2: Manual install — bring your own PostgreSQL / Redis / RustFS / Nginx (click to expand) + +
+ +Provide your own **PostgreSQL · Redis · RustFS (or any S3) · Nginx**, **Go 1.26+** for the backend and **Node 18+** for the frontend. + +```bash +# 1. Create an empty database (the backend auto-migrates on start) +createdb vivid_ai + +# 2. Configure and build the backend from source +cat > backend/.env <<'EOF' +APP_ENV=production +HTTP_ADDR=127.0.0.1:6666 +POSTGRES_DSN=host=127.0.0.1 user=postgres password=YOUR_PASSWORD dbname=vivid_ai port=5432 sslmode=disable TimeZone=Asia/Shanghai +REDIS_ADDR=127.0.0.1:6379 +RUSTFS_ENDPOINT=http://127.0.0.1:9000 +RUSTFS_BUCKET=vivid-ai +RUSTFS_ACCESS_KEY=YOUR_AK +RUSTFS_SECRET_KEY=YOUR_SK +CORS_ORIGINS=https://your-domain +COOKIE_SECURE=true +EOF +cd backend && go build -o bin/api ./cmd/api && ./bin/api # listens on 127.0.0.1:6666 + +# 3. Build the frontend (output in frontend/dist) +cd frontend && npm install && npm run build +``` + +Nginx reverse proxy (issue the certificate yourself with certbot / acme.sh): + +```nginx +server { + listen 443 ssl; + server_name your-domain; + ssl_certificate /path/fullchain.pem; + ssl_certificate_key /path/privkey.pem; + root /path/to/frontend/dist; + index index.html; + client_max_body_size 50m; + proxy_read_timeout 600s; # video generation can take a while + + location /assets/ { expires 1y; add_header Cache-Control "public, max-age=31536000, immutable"; } + location / { try_files $uri $uri/ /index.html; add_header Cache-Control "no-cache"; } + location ^~ /admin/api/ { proxy_pass http://127.0.0.1:6666; } + location ^~ /images/ { proxy_pass http://127.0.0.1:6666; } + location = /health { proxy_pass http://127.0.0.1:6666; } + location ^~ /v1/ { proxy_pass http://127.0.0.1:6666; add_header Cache-Control "no-store" always; } +} +``` + +> See `backend/.env.example` for the full set of environment variables. + +
+ +## 🧱 Tech Stack + +| Layer | Technology | +|---|---| +| Backend | Go · gin · gorm (PostgreSQL) · go-redis · tls-client (Chrome fingerprint) | +| Frontend | Vue 3 · Vue Router · Vite · Tailwind CSS v4 | +| Infrastructure | PostgreSQL · Redis · RustFS (S3-compatible) · Nginx · acme.sh | + +## 📦 Repository Layout + +``` +frontend/ Frontend source (Vue 3) +backend/ Backend source (Go, entry point at cmd/api) +docker-compose.yml Docker orchestration (Postgres/Redis/RustFS/backend/frontend/acme) +install.sh One-command deploy script +.env.docker.example Environment variable template +``` + +## 🗺️ Roadmap + +- [ ] More upstream providers +- [ ] Usage analytics / export +- [ ] Multi-language UI (i18n) +- [ ] Webhook / async callbacks + +## 💬 Community / Contact + +| | | +|---|---| +| 🌐 Website | **[vividai.run](https://vividai.run)** | +| 👥 QQ Group | **1106849765** · [Join](https://qm.qq.com/q/976LeMFoHu) | +| 🐧 QQ | **1114639355** · [Add](https://qm.qq.com/q/ItgCcNA7ac) | +| 🛒 Shop | **[pay.ldxp.cn/shop/chiyi](https://pay.ldxp.cn/shop/chiyi)** | +| ✉️ Email | vividairun@gmail.com | + +## ⭐ Star History + + + + +If you find this useful, give it a ⭐ — uncomment the line above after creating the repo to show the Star History chart. + +## 📄 License + +This project (frontend + backend) is open-source under the [MIT](LICENSE) license — free to use, modify, commercialize and redistribute. + +
+ +If this project helps you, a ⭐ Star is much appreciated! + +
diff --git a/README.md b/README.md new file mode 100644 index 0000000..e7bda0e --- /dev/null +++ b/README.md @@ -0,0 +1,271 @@ +
+ +

image2api

+ +**多供应商 AI 生图 / 生视频网关 —— 一套 OpenAI 兼容 API,聚合六大平台,开箱即用的运营系统** + +线上实例(品牌):[Vivid AI · vividai.run](https://vividai.run) + +**简体中文** | [English](README.en.md) + +[![Online Demo](https://img.shields.io/badge/在线体验-vividai.run-7c3aed?style=for-the-badge)](https://vividai.run) + +[![Go](https://img.shields.io/badge/Go-1.26-00ADD8?logo=go&logoColor=white)](https://go.dev) +[![Vue 3](https://img.shields.io/badge/Vue-3-42b883?logo=vuedotjs&logoColor=white)](https://vuejs.org) +[![Docker](https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white)](#-部署) +[![OpenAI Compatible](https://img.shields.io/badge/OpenAI-compatible-412991?logo=openai&logoColor=white)](#-openai-兼容-api) +[![HTTPS](https://img.shields.io/badge/HTTPS-acme.sh%20自动签发-success)](#方式一docker-一键推荐) +[![Providers](https://img.shields.io/badge/供应商-6%20平台-orange)](#-支持的模型--供应商) +[![Self-hosted](https://img.shields.io/badge/self--hosted-yes-success)](#-部署) +[![License](https://img.shields.io/badge/license-MIT-blue)](#-license) + +[在线体验](https://vividai.run) · [功能](#-核心功能) · [部署](#-部署) · [API 文档](#-openai-兼容-api) · [交流群](#-交流--联系) + +
+ +image2api — 画图台 + +
+ +--- + +## 📖 目录 + +- [简介](#-简介) +- [界面预览](#-界面预览) +- [核心功能](#-核心功能) +- [支持的模型 / 供应商](#-支持的模型--供应商) +- [OpenAI 兼容 API](#-openai-兼容-api) +- [部署](#-部署) +- [技术栈](#-技术栈) +- [仓库结构](#-仓库结构) +- [Roadmap](#-roadmap) +- [交流 / 联系](#-交流--联系) +- [License](#-license) + +## ✨ 简介 + +**image2api** 把 Adobe Firefly、OpenAI、Runway、Leonardo、Krea、Imagine 等平台的图像 / 视频能力,统一封装成**一套 OpenAI 兼容的 API**;背后用多账号池自动调度 —— 额度耗尽自动换号、认证失效自动刷新或判死、临时错误自动重试、token 到期前主动续期 —— 对外提供稳定服务。 + +它不只是 API 代理:自带**积分计费、CDK 充值、邀请奖励、用户体系、管理后台、现代化画图前端**,一条命令即可跑成一个对外运营的 AI 生成站点 —— 作者的线上实例 **[Vivid AI · vividai.run](https://vividai.run)**(品牌)即基于本项目搭建。 + +> 💡 前后端**完全开源**(MIT),Go + Vue 3,可自由二开 / 自部署。 + +**一句话亮点** 🔌 OpenAI 兼容 · 🤖 6 平台十余模型 · 🔁 自动换号 / Token 保活 · 💳 积分 + 代理价计费 · 🎨 画图前端 + 管理后台 · 🐳 一键部署 + 自动 HTTPS + +## 🖼️ 界面预览 + +| 画图台 | 概览看板 | +|:---:|:---:| +| ![画图台](docs/screenshots/playground.png) | ![概览看板](docs/screenshots/dashboard.png) | +| **账号管理** | **日志** | +| ![账号管理](docs/screenshots/accounts.png) | ![日志](docs/screenshots/logs.png) | + +## 🚀 核心功能 + + + + + + +
+ +### 🎨 生成能力 +- 生图 + 生视频一站式,支持**图生图 / 参考图**(首帧、末帧、风格参考) +- 多分辨率(1K / 2K / 4K)、多宽高比、视频多时长,按模型独立配置与定价 +- 6 大供应商、十余模型,后台**动态启用 / 下架 / 改价**,无需改代码 + +### 🔌 OpenAI 兼容 +- 文生图 `/v1/images/generations` · 图生图 `/v1/images/edits`(multipart 上传参考图) · 视频 `/v1/videos`(Sora 式异步:创建→轮询→`/content` 下载) · `/v1/models` +- **严格 OpenAI 入参**:`size` 决定比例、`quality` 决定画质档,改个 `base_url` + `api_key` 即接现有 OpenAI SDK +- 图片结果 **base64 直返**,服务端不留存文件,隐私友好 + +### 🔁 多账号池 + 智能故障转移 +- 账号池轮询调度,单账号出错不影响整体 +- **额度耗尽→换号** · **认证失效→刷新重试 / 判死** · **临时错误→同号重试 ×3** · **参数错→直接报错** +- **预扣额度**:生成前原子扣减,失败自动退回,杜绝并发超额 + + + +### 🔐 Token 自动保活 +- 一次性轮换 token(Krea / Imagine)**到期前 10 分钟主动续期**,新 token 自动落库 +- Adobe cookie 定时换 token;纯 JWT 到期自动判死 +- 每日额度按平台重置时间自动恢复 + 重新探测真实余额 + +### 💳 计费与运营 +- 积分制(**预扣 + 失败退款**),按模型 / 分辨率 / 时长精细定价 +- **代理价体系**:用户可设为「代理」角色,模型可设代理价;代理用户(含其 API Key 调用)自动按代理价计费,未设代理价则回退普通价 +- **CDK 兑换码**充值 · **邀请奖励** · 邮箱注册 / 验证码 / 找回密码 +- 三级角色:普通用户 / 代理 / 管理员(唯一) + +### 🖥️ 用户前台(Vue 3) +- 画图台 · 创作记录画廊 · 生成日志(含失败原因 / 来源标签) +- API 文档 · API Key 管理 · 邀请 · 关于,亮 / 暗主题 + +### 🛠️ 管理后台 +- 概览看板(趋势 / DAU / 失败 Top / 消费榜) +- 模型管理(普通价 + 代理价) · 账号管理(批量导入 / 去重 / 额度) · 全站日志 · 用户管理(设为代理) · CDK · 展示位 · 站点配置 + +
+ +**🧰 工程亮点**:tls-client(Chrome JA3/JA4 指纹)稳定穿透 Cloudflare · 媒体存 S3/RustFS 经鉴权代理分发 + 保留期清理 · 自愈式维护轮询(恢复额度 / 刷新凭据 / 清理僵死任务并退款) · 一条命令 Docker 部署 + acme.sh 自动 HTTPS。 + +## 🤖 支持的模型 / 供应商 + +| 供应商 | 模型(示例) | 类型 | +|---|---|---| +| **Adobe Firefly** | firefly-image-5 · firefly-gpt-image-2 · nano-banana-2 · flux-kontext-max · firefly-video · firefly-ray · gemini-veo31 | 图像 / 视频 | +| **OpenAI** | gpt-image-2 | 图像 | +| **Runway** | runway-gen4-turbo | 视频 | +| **Leonardo.ai** | seedream-4.5 | 图像 | +| **Krea.ai** | flux-klein-2 | 图像 | +| **Imagine.art** | imagine-1.5 · imagine-1.5pro | 图像 | + +> 模型由管理后台动态启用并定价,可随时增删。 + +## 🔌 OpenAI 兼容 API + +```bash +# 文生图 —— 纯 OpenAI 参数:size→比例,quality→画质档(low/medium/high→1K/2K/4K) +curl https://你的域名/v1/images/generations \ + -H "Authorization: Bearer sk-xxxx" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-image-2", + "prompt": "a cute cat on a desk, studio lighting", + "size": "1024x1024", + "quality": "high" + }' + +# 图生图 —— multipart 上传参考图(可多张 image[]) +curl https://你的域名/v1/images/edits \ + -H "Authorization: Bearer sk-xxxx" \ + -F model="seedream-4.5" -F prompt="改成赛博朋克风格" -F image=@input.png +``` + +图片返回 OpenAI 风格 `{ "created": ..., "data": [{ "b64_json": "..." }] }`(原始 base64,无 `data:` 前缀,服务端不留存)。**视频**走异步:`POST /v1/videos` 建任务 → 轮询 `GET /v1/videos/{id}` 至 `completed` → `GET /v1/videos/{id}/content` 取 mp4。完整参数见站内 **/docs** 文档页。 + +## 🚀 部署 + +前后端均开源。推荐 Docker 一键;也可用 **Go 1.26+** 从源码构建。 + +> 前置:域名 A 记录指向本机,**80 / 443 对公网开放**(Let's Encrypt 验证需要)。 + +### 方式一:Docker 一键(推荐) + +需要 Docker + Docker Compose。一条命令拉起 Postgres + Redis + RustFS + 后端 + 前端,并**自动签发 / 续期 HTTPS 证书**(内置 acme.sh)。 + +```bash +cp .env.docker.example .env # 填 DOMAIN / ACME_EMAIL / POSTGRES_PASSWORD / S3_SECRET_KEY +sh install.sh # = docker compose up -d --build +``` + +打开 `https://<你的域名>/`;证书进度 `docker compose logs -f acme`。`DOMAIN=localhost` 时用自签证书(本地测试)。 + +
+方式二:手动安装 — 自建 PostgreSQL / Redis / RustFS / Nginx(点击展开) + +
+ +自备 **PostgreSQL · Redis · RustFS(或任意 S3)· Nginx**,后端用 **Go 1.26+**,前端用 **Node 18+**。 + +```bash +# 1. 创建空库(后端启动自动建表) +createdb vivid_ai + +# 2. 配置并从源码构建后端 +cat > backend/.env <<'EOF' +APP_ENV=production +HTTP_ADDR=127.0.0.1:6666 +POSTGRES_DSN=host=127.0.0.1 user=postgres password=你的密码 dbname=vivid_ai port=5432 sslmode=disable TimeZone=Asia/Shanghai +REDIS_ADDR=127.0.0.1:6379 +RUSTFS_ENDPOINT=http://127.0.0.1:9000 +RUSTFS_BUCKET=vivid-ai +RUSTFS_ACCESS_KEY=你的AK +RUSTFS_SECRET_KEY=你的SK +CORS_ORIGINS=https://你的域名 +COOKIE_SECURE=true +EOF +cd backend && go build -o bin/api ./cmd/api && ./bin/api # 监听 127.0.0.1:6666 + +# 3. 构建前端(产物 frontend/dist) +cd frontend && npm install && npm run build +``` + +Nginx 反代(证书自行用 certbot / acme.sh): + +```nginx +server { + listen 443 ssl; + server_name 你的域名; + ssl_certificate /path/fullchain.pem; + ssl_certificate_key /path/privkey.pem; + root /path/to/frontend/dist; + index index.html; + client_max_body_size 50m; + proxy_read_timeout 600s; # 视频生成耗时长 + + location /assets/ { expires 1y; add_header Cache-Control "public, max-age=31536000, immutable"; } + location / { try_files $uri $uri/ /index.html; add_header Cache-Control "no-cache"; } + location ^~ /admin/api/ { proxy_pass http://127.0.0.1:6666; } + location ^~ /images/ { proxy_pass http://127.0.0.1:6666; } + location = /health { proxy_pass http://127.0.0.1:6666; } + location ^~ /v1/ { proxy_pass http://127.0.0.1:6666; add_header Cache-Control "no-store" always; } +} +``` + +> 完整环境变量见 `backend/.env.example`。 + +
+ +## 🧱 技术栈 + +| 层 | 技术 | +|---|---| +| 后端 | Go · gin · gorm(PostgreSQL)· go-redis · tls-client(Chrome 指纹) | +| 前端 | Vue 3 · Vue Router · Vite · Tailwind CSS v4 | +| 基础设施 | PostgreSQL · Redis · RustFS(S3 兼容)· Nginx · acme.sh | + +## 📦 仓库结构 + +``` +frontend/ 前端源码(Vue 3) +backend/ 后端源码(Go,cmd/api 为入口) +docker-compose.yml Docker 编排(Postgres/Redis/RustFS/后端/前端/acme) +install.sh 一键部署脚本 +.env.docker.example 环境变量模板 +``` + +## 🗺️ Roadmap + +- [ ] 更多上游供应商接入 +- [ ] 用量统计 / 导出 +- [ ] 多语言界面(i18n) +- [ ] Webhook / 异步回调 + +## 💬 交流 / 联系 + +| | | +|---|---| +| 🌐 官网 | **[vividai.run](https://vividai.run)** | +| 👥 QQ 交流群 | **1106849765** · [点击加群](https://qm.qq.com/q/976LeMFoHu) | +| 🐧 QQ | **1114639355** · [加好友](https://qm.qq.com/q/ItgCcNA7ac) | +| 🛒 小店 | **[pay.ldxp.cn/shop/chiyi](https://pay.ldxp.cn/shop/chiyi)** | +| ✉️ 邮箱 | vividairun@gmail.com | + +## ⭐ Star History + + + + +觉得有用就点个 ⭐ 吧 —— 建好仓库后取消上方注释即可展示 Star 趋势图。 + +## 📄 License + +本项目(前端 + 后端)基于 [MIT](LICENSE) 协议开源,可自由使用、修改、商用与二次分发。 + +
+ +如果这个项目对你有帮助,欢迎 ⭐ Star 支持! + +
diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..7138481 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,6 @@ +# Keep secrets, local data and build artifacts out of the Docker build context. +.env +.env.* +bin/ +data/ +*.log diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..d6754a6 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,20 @@ +# Vivid AI backend config template. Copy to .env and fill in real values. +# Real environment variables override .env. + +APP_ENV=development +APP_TITLE=Vivid AI +HTTP_ADDR=:6666 + +# PostgreSQL (database must already exist; tables auto-migrate on boot) +POSTGRES_DSN=host=127.0.0.1 user=postgres password=YOUR_PASSWORD dbname=vivid_ai port=5432 sslmode=disable TimeZone=Asia/Shanghai + +# Redis +REDIS_ADDR=127.0.0.1:6379 +REDIS_PASSWORD= +REDIS_DB=0 + +# Frontend dev origins allowed for CORS +CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 + +# Optional: override generated media root (defaults to ../../ai-gateway/data/generated) +# GENERATED_ROOT= diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..1d7131a --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,9 @@ +.gocache/ +.gomodcache/ +backend.exe +*.exe +*.out +*.test +.env +backend.out.log +backend.err.log diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..a460589 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,24 @@ +# syntax=docker/dockerfile:1 + +# --- Stage 1: build the Go binary from source --- +FROM golang:1.26-alpine AS build +WORKDIR /src +RUN apk add --no-cache git +# Cache deps first for faster rebuilds. +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api + +# --- Stage 2: minimal runtime image --- +FROM alpine:3.20 +# ca-certificates: outbound HTTPS to the AI providers. tzdata: POSTGRES_DSN sets +# TimeZone=Asia/Shanghai. wget: container healthcheck. +RUN apk add --no-cache ca-certificates tzdata wget +WORKDIR /app +COPY --from=build /out/api /app/api +# Local fallback for generated media / reference uploads (RustFS/S3 is primary). +RUN mkdir -p /app/data/generated && chmod +x /app/api +ENV HTTP_ADDR=0.0.0.0:6666 +EXPOSE 6666 +ENTRYPOINT ["/app/api"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..1ff4a22 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,55 @@ +# Vivid AI Backend + +Go backend for `vivid-ai`, using: + +- Gin +- GORM +- PostgreSQL +- Redis + +## Current scope + +This is an in-progress rewrite. The current skeleton already includes: + +- app bootstrap +- PostgreSQL and Redis initialization +- GORM auto-migrations +- session storage in Redis +- image access control for `/images/:user/:name` +- public site endpoint: `/admin/api/site` +- public showcase endpoint: `/admin/api/showcase` +- session-based auth endpoint: `/admin/api/auth/me` + +## Environment + +Set these before running: + +```powershell +$env:POSTGRES_DSN="host=127.0.0.1 user=postgres password=postgres dbname=vivid_ai port=5432 sslmode=disable TimeZone=Asia/Shanghai" +$env:REDIS_ADDR="127.0.0.1:6379" +$env:HTTP_ADDR=":6061" +``` + +Optional: + +```powershell +$env:APP_ENV="development" +$env:APP_TITLE="Vivid AI" +$env:SESSION_COOKIE_NAME="vivid_session" +$env:CORS_ORIGINS="http://localhost:5173,http://127.0.0.1:5173" +``` + +## Run + +```powershell +go run ./cmd/api +``` + +## Notes + +- Generated media defaults to `../../ai-gateway/data/generated` relative to the backend working directory. +- Private images require either: + - session cookie + - bearer session token + - bearer API key +- Showcase images are public. diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go new file mode 100644 index 0000000..d3f8802 --- /dev/null +++ b/backend/cmd/api/main.go @@ -0,0 +1,50 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "backend/internal/bootstrap" +) + +func main() { + ctx := context.Background() + + app, err := bootstrap.NewApp(ctx) + if err != nil { + log.Fatalf("bootstrap app: %v", err) + } + + srv := &http.Server{ + Addr: app.Config.HTTPAddr, + Handler: app.Engine, + ReadHeaderTimeout: 5 * time.Second, + } + + go func() { + log.Printf("backend listening on %s", app.Config.HTTPAddr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("listen and serve: %v", err) + } + }() + + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + <-stop + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := srv.Shutdown(shutdownCtx); err != nil { + log.Printf("shutdown http server: %v", err) + } + + if err := app.Close(); err != nil { + log.Printf("close app: %v", err) + } +} diff --git a/backend/cmd/marklabel/main.go b/backend/cmd/marklabel/main.go new file mode 100644 index 0000000..bb30b1c --- /dev/null +++ b/backend/cmd/marklabel/main.go @@ -0,0 +1,45 @@ +package main + +import ( + "fmt" + "os" + + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +type row struct { + ID string + Pool string + Status string + AccountEmail string + ImageLimited bool + VideoLimited bool +} + +func main() { + dsn := os.Getenv("POSTGRES_DSN") + if dsn == "" { + fmt.Println("POSTGRES_DSN env is required, e.g. host=127.0.0.1 user=postgres password=... dbname=vivid_ai port=5432 sslmode=disable TimeZone=Asia/Shanghai") + os.Exit(1) + } + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) + if err != nil { + fmt.Println("open err:", err) + os.Exit(1) + } + + var pick row + db.Raw(`SELECT id, pool, status, account_email, image_limited, video_limited FROM token_accounts WHERE pool='adobe' ORDER BY id LIMIT 1`).Scan(&pick) + fmt.Printf("picked: id=%s email=%s status=%s image_limited=%v video_limited=%v\n", pick.ID, pick.AccountEmail, pick.Status, pick.ImageLimited, pick.VideoLimited) + + if err := db.Exec(`UPDATE token_accounts SET video_limited=true, updated_at=now() WHERE id=?`, pick.ID).Error; err != nil { + fmt.Println("update err:", err) + os.Exit(1) + } + fmt.Println("-> set video_limited=true") + + var after row + db.Raw(`SELECT id, pool, status, account_email, image_limited, video_limited FROM token_accounts WHERE id=?`, pick.ID).Scan(&after) + fmt.Printf("after: id=%s status=%s image_limited=%v video_limited=%v\n", after.ID, after.Status, after.ImageLimited, after.VideoLimited) +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..21e71dd --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,71 @@ +module backend + +go 1.26.0 + +replace github.com/quic-go/qpack => github.com/quic-go/qpack v0.5.1 + +require ( + github.com/bogdanfinn/fhttp v0.6.8 + github.com/bogdanfinn/tls-client v1.11.2 + github.com/gin-contrib/cors v1.7.6 + github.com/gin-gonic/gin v1.11.0 + github.com/google/uuid v1.6.0 + github.com/matoous/go-nanoid/v2 v2.1.0 + github.com/redis/go-redis/v9 v9.16.0 + golang.org/x/crypto v0.46.0 + gorm.io/datatypes v1.2.7 + gorm.io/driver/postgres v1.6.0 + gorm.io/gorm v1.31.0 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/bdandy/go-errors v1.2.2 // indirect + github.com/bdandy/go-socks4 v1.2.3 // indirect + github.com/bogdanfinn/quic-go-utls v1.0.4-utls // indirect + github.com/bogdanfinn/utls v1.7.7-barnius // indirect + github.com/bogdanfinn/websocket v1.5.5-barnius // indirect + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/gabriel-vasile/mimetype v1.4.9 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/go-sql-driver/mysql v1.8.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.6.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.2 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.54.0 // indirect + github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + go.uber.org/mock v0.5.2 // indirect + golang.org/x/arch v0.20.0 // indirect + golang.org/x/mod v0.30.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect + golang.org/x/tools v0.39.0 // indirect + google.golang.org/protobuf v1.36.9 // indirect + gorm.io/driver/mysql v1.5.6 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..2b759f4 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,178 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q= +github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM= +github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic= +github.com/bdandy/go-socks4 v1.2.3/go.mod h1:98kiVFgpdogR8aIGLWLvjDVZ8XcKPsSI/ypGrO+bqHI= +github.com/bogdanfinn/fhttp v0.6.8 h1:LiQyHOY3i0QoxxNB7nq27/nGNNbtPj0fuBPozhR7Ws4= +github.com/bogdanfinn/fhttp v0.6.8/go.mod h1:A+EKDzMx2hb4IUbMx4TlkoHnaJEiLl8r/1Ss1Y+5e5M= +github.com/bogdanfinn/quic-go-utls v1.0.4-utls h1:zPjusVVNeJFA2ORMAP0rjnrZrBkV4Dnia4e6ToOfUDA= +github.com/bogdanfinn/quic-go-utls v1.0.4-utls/go.mod h1:UONJOaHGWho08kZtkkgH7GjktEPjMemGxjTcNpVPZVA= +github.com/bogdanfinn/quic-go-utls v1.0.9-utls h1:tV6eDEiRbRCcepALSzxR94JUVD3N3ACIiRLgyc2Ep8s= +github.com/bogdanfinn/quic-go-utls v1.0.9-utls/go.mod h1:aHph9B9H9yPOt5xnhWKSOum27DJAqpiHzwX+gjvaXcg= +github.com/bogdanfinn/tls-client v1.11.2 h1:o6qX0L1cEi+4MaBqujxqOeK254VZM20t3QR+A34/V6I= +github.com/bogdanfinn/tls-client v1.11.2/go.mod h1:qQIsVGe35NdxYEozNh9JuDZ+aOaOEq2tKAsu2iYEGZg= +github.com/bogdanfinn/tls-client v1.15.1 h1:KiFAlED55DJ8Fcocn+/1nX6PrDFcttIHAf/GDkV6KN8= +github.com/bogdanfinn/tls-client v1.15.1/go.mod h1:LsU6mXVn8MOFDwTkyRfI7V1BZM1p0wf2ZfZsICW/1fM= +github.com/bogdanfinn/utls v1.7.7-barnius h1:OuJ497cc7F3yKNVHRsYPQdGggmk5x6+V5ZlrCR7fOLU= +github.com/bogdanfinn/utls v1.7.7-barnius/go.mod h1:aAK1VZQlpKZClF1WEQeq6kyclbkPq4hz6xTbB5xSlmg= +github.com/bogdanfinn/websocket v1.5.5-barnius h1:bY+qnxpai1qe7Jmjx+Sds/cmOSpuuLoR8x61rWltjOI= +github.com/bogdanfinn/websocket v1.5.5-barnius/go.mod h1:gvvEw6pTKHb7yOiFvIfAFTStQWyrm25BMVCTj5wRSsI= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= +github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= +github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= +github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE= +github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA= +github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= +github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/redis/go-redis/v9 v9.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERSEP4= +github.com/redis/go-redis/v9 v9.16.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 h1:YqAladjX7xpA6BM04leXMWAEjS0mTZ5kUU9KRBriQJc= +github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5/go.mod h1:2JjD2zLQYH5HO74y5+aE3remJQvl6q4Sn6aWA2wD1Ng= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/net v0.0.0-20211104170005-ce137452f963/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/datatypes v1.2.7 h1:ww9GAhF1aGXZY3EB3cJPJ7//JiuQo7DlQA7NNlVaTdk= +gorm.io/datatypes v1.2.7/go.mod h1:M2iO+6S3hhi4nAyYe444Pcb0dcIiOMJ7QHaUXxyiNZY= +gorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8= +gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/driver/sqlserver v1.6.0 h1:VZOBQVsVhkHU/NzNhRJKoANt5pZGQAS1Bwc6m6dgfnc= +gorm.io/driver/sqlserver v1.6.0/go.mod h1:WQzt4IJo/WHKnckU9jXBLMJIVNMVeTu25dnOzehntWw= +gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY= +gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/backend/internal/bootstrap/app.go b/backend/internal/bootstrap/app.go new file mode 100644 index 0000000..33be38a --- /dev/null +++ b/backend/internal/bootstrap/app.go @@ -0,0 +1,174 @@ +package bootstrap + +import ( + "context" + "fmt" + "os" + "time" + + "backend/internal/config" + "backend/internal/http/handler" + "backend/internal/http/router" + "backend/internal/model" + "backend/internal/provider/adobe" + "backend/internal/provider/chatgpt" + "backend/internal/provider/imagine" + "backend/internal/provider/krea" + "backend/internal/provider/leonardo" + "backend/internal/provider/runway" + "backend/internal/repo" + "backend/internal/service" + "backend/internal/storage" + + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +type App struct { + Config *config.Config + DB *gorm.DB + Redis *redis.Client + Engine *gin.Engine + maintenanceCancel context.CancelFunc +} + +func NewApp(ctx context.Context) (*App, error) { + cfg, err := config.Load() + if err != nil { + return nil, err + } + + // Ensure the media root (generated outputs + uploaded reference images) + // exists from the first request — don't rely on lazy per-file MkdirAll. + if err := os.MkdirAll(cfg.GeneratedRoot, 0o755); err != nil { + return nil, fmt.Errorf("create generated root %s: %w", cfg.GeneratedRoot, err) + } + + // TranslateError: 把驱动层错误(如 Postgres 23505 唯一冲突)翻译成 gorm.ErrDuplicatedKey, + // 否则各 import-*(krea/adobe/leonardo/runway)里的 errors.Is(err, gorm.ErrDuplicatedKey) + // 兜底命不中,重复导入会直接抛原始错误 → 400,而不是按预期 Update 已有行。 + db, err := gorm.Open(postgres.Open(cfg.PostgresDSN), &gorm.Config{TranslateError: true}) + if err != nil { + return nil, fmt.Errorf("open postgres: %w", err) + } + + sqlDB, err := db.DB() + if err != nil { + return nil, fmt.Errorf("sql db: %w", err) + } + sqlDB.SetMaxIdleConns(5) + sqlDB.SetMaxOpenConns(20) + sqlDB.SetConnMaxLifetime(30 * time.Minute) + + if err := db.WithContext(ctx).AutoMigrate(model.AutoMigrateModels()...); err != nil { + return nil, fmt.Errorf("auto migrate: %w", err) + } + // Hard backstop for "one marketing code per user per batch": a partial unique + // index. Concurrent double-redeems that slip past the in-tx count check still + // fail here. AutoMigrate can't express partial indexes, so do it raw. + if err := db.WithContext(ctx).Exec(`CREATE UNIQUE INDEX IF NOT EXISTS uniq_cdk_marketing_batch_user ` + + `ON cdk_codes (batch_id, redeemed_by) WHERE type = 'marketing' AND redeemed_by IS NOT NULL`).Error; err != nil { + return nil, fmt.Errorf("cdk marketing index: %w", err) + } + if err := seedDefaults(ctx, db); err != nil { + return nil, fmt.Errorf("seed defaults: %w", err) + } + + rdb := redis.NewClient(&redis.Options{ + Addr: cfg.RedisAddr, + Password: cfg.RedisPassword, + DB: cfg.RedisDB, + }) + if err := rdb.Ping(ctx).Err(); err != nil { + return nil, fmt.Errorf("ping redis: %w", err) + } + + userRepo := repo.NewUserRepository(db) + showcaseRepo := repo.NewShowcaseRepository(db) + siteRepo := repo.NewSiteSettingRepository(db, rdb) + modelRepo := repo.NewModelRepository(db) + eventRepo := repo.NewEventRepository(db) + cdkRepo := repo.NewCDKRepository(db) + apiKeyRepo := repo.NewAPIKeyRepository(db) + tokenRepo := repo.NewTokenRepository(db) + refreshRepo := repo.NewRefreshProfileRepository(db) + sessionSvc := service.NewSessionService(rdb, cfg.SessionTTL, cfg.SessionSlideAfter) + emailCodeSvc := service.NewEmailCodeService(rdb) + smtpSvc := service.NewSMTPService() + rateLimitSvc := service.NewRateLimitService(rdb) + rustfsClient := storage.New(cfg.RustFSEndpoint, cfg.RustFSBucket, cfg.RustFSAccessKey, cfg.RustFSSecretKey) + authSvc := service.NewAuthService(userRepo, siteRepo, sessionSvc, emailCodeSvc, smtpSvc) + appSettingsSvc := service.NewAppSettingsService(siteRepo, eventRepo, smtpSvc, rustfsClient) + imageAccessSvc := service.NewImageAccessService(cfg.GeneratedRoot, showcaseRepo, authSvc) + adobeClient := adobe.NewClient("clio-playground-web", "") + chatGPTClient := chatgpt.NewClient("") + runwayClient := runway.NewClient("") + leonardoClient := leonardo.NewClient("") + kreaClient := krea.NewClient("") + imagineClient := imagine.NewClient("") + v1Svc := service.NewV1Service(cfg, modelRepo, userRepo, eventRepo, tokenRepo, siteRepo, adobeClient, chatGPTClient, runwayClient, leonardoClient, kreaClient, imagineClient, rustfsClient) + siteSvc := service.NewSiteService(siteRepo, cfg.AppTitle) + showcaseSvc := service.NewShowcaseService(showcaseRepo) + adminReadSvc := service.NewAdminReadService(cfg, userRepo, modelRepo, eventRepo, siteRepo, tokenRepo, cdkRepo, rustfsClient) + adminWriteSvc := service.NewAdminWriteService(userRepo, showcaseRepo, modelRepo, eventRepo, apiKeyRepo) + cdkSvc := service.NewCDKService(cdkRepo, userRepo) + apiKeySvc := service.NewAPIKeyService(apiKeyRepo) + tokenSvc := service.NewTokenService(tokenRepo, refreshRepo, eventRepo, siteRepo, adobeClient, chatGPTClient, runwayClient, leonardoClient, kreaClient, imagineClient) + refreshSvc := service.NewRefreshProfileService(refreshRepo, tokenRepo, adobeClient) + // Enable refresh-then-retry on a mid-request Adobe 401 (re-mint access token + // from the cookie). Wired post-construction to avoid a ctor init cycle. + v1Svc.SetRefresh(refreshSvc) + userGenSvc := service.NewUserGenerationService(v1Svc, eventRepo, userRepo, modelRepo) + + engine := router.New(cfg, authSvc, router.Handlers{ + Health: handler.NewHealthHandler(), + Images: handler.NewImageHandler(cfg, imageAccessSvc, rustfsClient), + V1: handler.NewV1Handler(v1Svc), + Site: handler.NewSiteHandler(siteSvc), + Showcase: handler.NewShowcaseHandler(showcaseSvc), + Auth: handler.NewAuthHandler(cfg, authSvc, rateLimitSvc), + SiteSettings: handler.NewSiteSettingsHandler(siteSvc), + AppSettings: handler.NewAppSettingsHandler(appSettingsSvc), + AdminRead: handler.NewAdminReadHandler(adminReadSvc), + AdminWrite: handler.NewAdminWriteHandler(adminWriteSvc), + CDK: handler.NewCDKHandler(cdkSvc), + UserTools: handler.NewUserToolsHandler(apiKeySvc, cdkSvc), + UserGen: handler.NewUserGenerationHandler(userGenSvc, adminReadSvc), + ProviderAdmin: handler.NewProviderAdminHandler(tokenSvc, refreshSvc), + }) + + // Background self-healing sweep (quota recovery, cookie refresh, stale-pending + // cleanup, log retention) — the Go equivalent of the Python daemon thread. + maintenanceSvc := service.NewMaintenanceService(tokenRepo, tokenSvc, eventRepo, userRepo, refreshSvc, siteRepo, rustfsClient, v1Svc.Inflight(), showcaseRepo) + loopCtx, loopCancel := context.WithCancel(context.Background()) + go maintenanceSvc.Run(loopCtx) + + return &App{ + Config: cfg, + DB: db, + Redis: rdb, + Engine: engine, + maintenanceCancel: loopCancel, + }, nil +} + +func (a *App) Close() error { + if a.maintenanceCancel != nil { + a.maintenanceCancel() + } + if a.Redis != nil { + if err := a.Redis.Close(); err != nil { + return err + } + } + if a.DB != nil { + sqlDB, err := a.DB.DB() + if err != nil { + return err + } + return sqlDB.Close() + } + return nil +} diff --git a/backend/internal/bootstrap/seed.go b/backend/internal/bootstrap/seed.go new file mode 100644 index 0000000..ba87a4c --- /dev/null +++ b/backend/internal/bootstrap/seed.go @@ -0,0 +1,51 @@ +package bootstrap + +import ( + "context" + + "backend/internal/model" + "gorm.io/gorm" +) + +func seedDefaults(ctx context.Context, db *gorm.DB) error { + defaults := []model.SiteSetting{ + {Key: "site.title", Value: "Vivid"}, + {Key: "contact.qq", Value: "1114639355"}, + {Key: "contact.qq_link", Value: "https://qm.qq.com/q/ItgCcNA7ac"}, + {Key: "contact.qq_group", Value: "1106849765"}, + {Key: "contact.qq_group_link", Value: "https://qm.qq.com/q/976LeMFoHu"}, + {Key: "contact.email", Value: "vividairun@gmail.com"}, + {Key: "contact.shop", Value: "https://pay.ldxp.cn/shop/chiyi"}, + {Key: "auth.open", Value: "true"}, + {Key: "auth.email_code", Value: "false"}, + {Key: "auth.allow_password_reset", Value: "false"}, + {Key: "auth.allowed_email_domains", Value: ""}, + {Key: "auth.code_ttl_seconds", Value: "600"}, + {Key: "smtp.host", Value: ""}, + {Key: "smtp.port", Value: "587"}, + {Key: "smtp.username", Value: ""}, + {Key: "smtp.password", Value: ""}, + {Key: "smtp.from_addr", Value: ""}, + {Key: "smtp.use_tls", Value: "true"}, + {Key: "proxy.url", Value: ""}, + {Key: "credits.checkin_enabled", Value: "true"}, + {Key: "credits.checkin_reward", Value: "3"}, + {Key: "credits.invite_enabled", Value: "true"}, + {Key: "credits.invite_reward", Value: "3"}, + {Key: "logs.retention_days", Value: "30"}, + {Key: "media.retention_days", Value: "30"}, + } + for _, item := range defaults { + var count int64 + if err := db.WithContext(ctx).Model(&model.SiteSetting{}).Where("key = ?", item.Key).Count(&count).Error; err != nil { + return err + } + if count > 0 { + continue + } + if err := db.WithContext(ctx).Create(&item).Error; err != nil { + return err + } + } + return nil +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..4ef2ef3 --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,174 @@ +package config + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +type Config struct { + AppEnv string + HTTPAddr string + AppTitle string + PostgresDSN string + RedisAddr string + RedisPassword string + RedisDB int + SessionCookieName string + CookieSecure bool + SessionTTL time.Duration + SessionSlideAfter time.Duration + CORSOrigins []string + GeneratedRoot string + RustFSEndpoint string + RustFSBucket string + RustFSAccessKey string + RustFSSecretKey string +} + +func Load() (*Config, error) { + loadDotEnv() + + wd, err := os.Getwd() + if err != nil { + return nil, err + } + + cfg := &Config{ + AppEnv: envString("APP_ENV", "development"), + HTTPAddr: envString("HTTP_ADDR", ":6061"), + AppTitle: envString("APP_TITLE", "Vivid AI"), + PostgresDSN: envString("POSTGRES_DSN", "host=127.0.0.1 user=postgres password=postgres dbname=vivid_ai port=5432 sslmode=disable TimeZone=Asia/Shanghai"), + RedisAddr: envString("REDIS_ADDR", "127.0.0.1:6379"), + RedisPassword: envString("REDIS_PASSWORD", ""), + RedisDB: envInt("REDIS_DB", 0), + SessionCookieName: envString("SESSION_COOKIE_NAME", "vivid_session"), + CookieSecure: envBool("COOKIE_SECURE", false), + SessionTTL: time.Duration(envInt("SESSION_TTL_HOURS", 24)) * time.Hour, + SessionSlideAfter: time.Duration(envInt("SESSION_SLIDE_AFTER_HOURS", 22)) * time.Hour, + CORSOrigins: envList("CORS_ORIGINS", []string{"http://localhost:5173", "http://127.0.0.1:5173"}), + GeneratedRoot: filepath.Clean(envString( + "GENERATED_ROOT", + // vivid-ai's own data dir (backend/data/generated) — NOT the Python + // original's tree. Generated outputs and user-uploaded reference + // images both live here and are served (cookie-authed) via /images. + filepath.Join(wd, "data", "generated"), + )), + RustFSEndpoint: envString("RUSTFS_ENDPOINT", ""), + RustFSBucket: envString("RUSTFS_BUCKET", ""), + RustFSAccessKey: envString("RUSTFS_ACCESS_KEY", ""), + RustFSSecretKey: envString("RUSTFS_SECRET_KEY", ""), + } + + return cfg, nil +} + +// loadDotEnv loads a .env file (KEY=VALUE per line) into the process environment +// before config is read. Real environment variables always win — .env only fills +// keys that aren't already set. Searches ENV_FILE, then walks up from the working +// directory so it works whether the binary runs from backend/ or the repo root. +func loadDotEnv() { + for _, path := range dotEnvCandidates() { + data, err := os.ReadFile(path) + if err != nil { + continue + } + applyDotEnv(string(data)) + return + } +} + +func dotEnvCandidates() []string { + var out []string + if v := strings.TrimSpace(os.Getenv("ENV_FILE")); v != "" { + out = append(out, v) + } + wd, err := os.Getwd() + if err != nil { + return out + } + dir := wd + for i := 0; i < 4; i++ { + out = append(out, filepath.Join(dir, ".env")) + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return out +} + +func applyDotEnv(content string) { + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "export ")) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + eq := strings.IndexByte(line, '=') + if eq < 0 { + continue + } + key := strings.TrimSpace(line[:eq]) + val := strings.TrimSpace(line[eq+1:]) + if len(val) >= 2 { + if (val[0] == '"' && val[len(val)-1] == '"') || (val[0] == '\'' && val[len(val)-1] == '\'') { + val = val[1 : len(val)-1] + } + } + if key == "" { + continue + } + // Real env wins: only set keys that aren't already present. + if _, ok := os.LookupEnv(key); !ok { + _ = os.Setenv(key, val) + } + } +} + +func envString(key, fallback string) string { + if v, ok := os.LookupEnv(key); ok && strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + return fallback +} + +func envInt(key string, fallback int) int { + if v, ok := os.LookupEnv(key); ok && strings.TrimSpace(v) != "" { + if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil { + return n + } + } + return fallback +} + +func envList(key string, fallback []string) []string { + if v, ok := os.LookupEnv(key); ok && strings.TrimSpace(v) != "" { + parts := strings.Split(v, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + s := strings.TrimSpace(part) + if s != "" { + out = append(out, s) + } + } + if len(out) > 0 { + return out + } + } + return fallback +} + +func envBool(key string, fallback bool) bool { + if v, ok := os.LookupEnv(key); ok && strings.TrimSpace(v) != "" { + switch strings.ToLower(strings.TrimSpace(v)) { + case "1", "true", "yes", "on": + return true + case "0", "false", "no", "off": + return false + } + } + return fallback +} diff --git a/backend/internal/http/handler/admin_read.go b/backend/internal/http/handler/admin_read.go new file mode 100644 index 0000000..16761c2 --- /dev/null +++ b/backend/internal/http/handler/admin_read.go @@ -0,0 +1,234 @@ +package handler + +import ( + "net/http" + "strconv" + "time" + + "backend/internal/model" + "backend/internal/service" + "github.com/gin-gonic/gin" +) + +type AdminReadHandler struct { + admin *service.AdminReadService +} + +func NewAdminReadHandler(admin *service.AdminReadService) *AdminReadHandler { + return &AdminReadHandler{admin: admin} +} + +func (h *AdminReadHandler) Users(c *gin.Context) { + users, stats, err := h.admin.Users(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load users"}) + return + } + + out := make([]gin.H, 0, len(users)) + generationCounts := map[string]int64{} + if raw, ok := stats["generation_counts"].(map[string]int64); ok { + generationCounts = raw + } + for _, user := range users { + row := userPublic(user) + row["generation_count"] = generationCounts[user.ID] + out = append(out, row) + } + delete(stats, "generation_counts") + c.JSON(http.StatusOK, gin.H{"data": out, "stats": stats}) +} + +func (h *AdminReadHandler) Models(c *gin.Context) { + items, err := h.admin.ModelsView(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load models"}) + return + } + c.JSON(http.StatusOK, gin.H{"data": items}) +} + +func (h *AdminReadHandler) Logs(c *gin.Context) { + limit := parseInt(c.Query("limit"), 50) + offset := parseInt(c.Query("offset"), 0) + kind := c.Query("kind") + status := c.Query("status") + var since *time.Time + if raw := c.Query("since"); raw != "" { + if f, err := strconv.ParseFloat(raw, 64); err == nil { + t := time.Unix(int64(f), 0) + since = &t + } + } + + items, total, stats, err := h.admin.Logs(c.Request.Context(), limit, offset, kind, status, since, "", "", c.Query("source"), false) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"}) + return + } + // Resolve user_id -> display name once for the page (mirrors admin.py). + nameByID, err := h.admin.UserNameMap(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"}) + return + } + out := make([]gin.H, 0, len(items)) + for _, item := range items { + var userName any + if item.UserID == "" { + userName = "匿名" + } else if name, ok := nameByID[item.UserID]; ok { + userName = name + } else { + userName = item.UserID + } + out = append(out, gin.H{ + "id": item.ID, + "ts": item.TS.Unix(), + "kind": item.Kind, + "status": item.Status, + "model": item.Model, + "provider": item.Provider, + "prompt": item.Prompt, + "ratio": item.Ratio, + "resolution": item.Resolution, + "duration": item.Duration, + "refs": item.Refs, + "source": item.Source, + "user_id": emptyStringNil(item.UserID), + "user_name": userName, + "cost": item.Cost, + "elapsed_ms": item.ElapsedMS, + "file": emptyStringNil(item.File), + "error": emptyStringNil(item.Error), + "created_at": unixSec(item.CreatedAt), + "updated_at": unixSec(item.UpdatedAt), + }) + } + c.JSON(http.StatusOK, gin.H{ + "data": out, + "total": total, + "limit": limit, + "offset": offset, + "stats": stats, + }) +} + +func (h *AdminReadHandler) Stats(c *gin.Context) { + stats, err := h.admin.Stats(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load stats"}) + return + } + c.JSON(http.StatusOK, stats) +} + +func (h *AdminReadHandler) Dashboard(c *gin.Context) { + data, err := h.admin.Dashboard(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load dashboard"}) + return + } + c.JSON(http.StatusOK, data) +} + +func (h *AdminReadHandler) Invites(c *gin.Context) { + items, stats, err := h.admin.Invites(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load invites"}) + return + } + c.JSON(http.StatusOK, gin.H{"data": items, "stats": stats}) +} + +func (h *AdminReadHandler) Providers(c *gin.Context) { + items, err := h.admin.Providers(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load providers"}) + return + } + c.JSON(http.StatusOK, gin.H{"data": items}) +} + +func (h *AdminReadHandler) Images(c *gin.Context) { + limit := parseInt(c.Query("limit"), 30) + offset := parseInt(c.Query("offset"), 0) + kind := c.Query("kind") + items, total, stats, err := h.admin.Images(c.Request.Context(), limit, offset, kind) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load images"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "data": items, + "total": total, + "limit": limit, + "offset": offset, + "stats": stats, + }) +} + +func userPublic(user model.User) gin.H { + keys := make([]gin.H, 0, len(user.APIKeys)) + for _, key := range user.APIKeys { + keys = append(keys, gin.H{ + "id": key.ID, + "name": key.Name, + "key_preview": key.KeyPreview, + "created_at": unixSec(key.CreatedAt), + "last_used_at": unixSecPtr(key.LastUsedAt), + }) + } + return gin.H{ + "id": user.ID, + "email": user.Email, + "name": user.Name, + "role": user.Role, + "status": user.Status, + "credits": user.Credits, + "notes": user.Notes, + "created_at": unixSec(user.CreatedAt), + "last_login_at": unixSecPtr(user.LastLoginAt), + "last_login_ip": user.LastLoginIP, + "invite_code": user.InviteCode, + "invited_by": user.InvitedBy, + "checkin_last": user.CheckinLast, + "checkin_streak": user.CheckinStreak, + "api_keys": keys, + "has_password": user.PasswordHash != "", + } +} + +// unixSec / unixSecPtr render timestamps as unix SECONDS — the frontend's +// fmtTs/fmtRelative expect seconds (matching the Python reference's time.time()), +// not the RFC3339 string Go marshals a time.Time into (which parses to NaN → "—"). +func unixSec(t time.Time) any { + if t.IsZero() { + return nil + } + return t.Unix() +} + +func unixSecPtr(t *time.Time) any { + if t == nil || t.IsZero() { + return nil + } + return t.Unix() +} + +func parseInt(raw string, fallback int) int { + if raw == "" { + return fallback + } + if n, err := strconv.Atoi(raw); err == nil { + return n + } + return fallback +} + +func emptyStringNil(v string) any { + if v == "" { + return nil + } + return v +} diff --git a/backend/internal/http/handler/admin_write.go b/backend/internal/http/handler/admin_write.go new file mode 100644 index 0000000..3c1744f --- /dev/null +++ b/backend/internal/http/handler/admin_write.go @@ -0,0 +1,241 @@ +package handler + +import ( + "errors" + "net/http" + + "backend/internal/model" + "backend/internal/service" + "github.com/gin-gonic/gin" +) + +type AdminWriteHandler struct { + admin *service.AdminWriteService +} + +func NewAdminWriteHandler(admin *service.AdminWriteService) *AdminWriteHandler { + return &AdminWriteHandler{admin: admin} +} + +func (h *AdminWriteHandler) CreateUser(c *gin.Context) { + var body map[string]any + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + user, err := h.admin.CreateUser(c.Request.Context(), body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "data": userPublic(*user)}) +} + +func (h *AdminWriteHandler) UpdateUser(c *gin.Context) { + var body map[string]any + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + user, err := h.admin.UpdateUser(c.Request.Context(), c.Param("user_id"), body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "data": userPublic(*user)}) +} + +func (h *AdminWriteHandler) DeleteUser(c *gin.Context) { + if err := h.admin.DeleteUser(c.Request.Context(), c.Param("user_id")); err != nil { + if errors.Is(err, service.ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"detail": "user not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to delete user"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// DeleteUsersBulk removes multiple users in one call (multi-select). +func (h *AdminWriteHandler) DeleteUsersBulk(c *gin.Context) { + var body struct { + IDs []string `json:"ids"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + if len(body.IDs) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"detail": "未选择任何用户"}) + return + } + n, err := h.admin.DeleteUsers(c.Request.Context(), body.IDs) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to delete users"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "deleted": n}) +} + +func (h *AdminWriteHandler) AdjustUserCredits(c *gin.Context) { + var body struct { + Delta float64 `json:"delta"` + Set *float64 `json:"set"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + var ( + user *model.User + err error + ) + if body.Set != nil { + // Absolute set takes precedence over delta (matches Python admin.py). + user, err = h.admin.SetUserCredits(c.Request.Context(), c.Param("user_id"), *body.Set) + } else { + user, err = h.admin.AdjustUserCredits(c.Request.Context(), c.Param("user_id"), body.Delta) + } + if err != nil { + if errors.Is(err, service.ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"detail": "user not found"}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "data": userPublic(*user)}) +} + +func (h *AdminWriteHandler) CreateUserAPIKey(c *gin.Context) { + var body struct { + Name string `json:"name"` + } + if err := c.ShouldBindJSON(&body); err != nil && err.Error() != "EOF" { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + key, plain, err := h.admin.CreateUserAPIKey(c.Request.Context(), c.Param("user_id"), body.Name) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "ok": true, + "key": plain, + "data": gin.H{ + "id": key.ID, + "name": key.Name, + "key_preview": key.KeyPreview, + "created_at": key.CreatedAt, + "last_used_at": key.LastUsedAt, + }, + }) +} + +func (h *AdminWriteHandler) DeleteUserAPIKey(c *gin.Context) { + if err := h.admin.DeleteUserAPIKey(c.Request.Context(), c.Param("user_id"), c.Param("key_id")); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +func (h *AdminWriteHandler) CreateShowcase(c *gin.Context) { + var body map[string]any + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + item, err := h.admin.CreateShowcase(c.Request.Context(), body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "data": item}) +} + +func (h *AdminWriteHandler) UpdateShowcase(c *gin.Context) { + var body map[string]any + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + item, err := h.admin.UpdateShowcase(c.Request.Context(), c.Param("entry_id"), body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "data": item}) +} + +func (h *AdminWriteHandler) DeleteShowcase(c *gin.Context) { + if err := h.admin.DeleteShowcase(c.Request.Context(), c.Param("entry_id")); err != nil { + if errors.Is(err, service.ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"detail": "showcase not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to delete showcase"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +func (h *AdminWriteHandler) CreateModel(c *gin.Context) { + var body map[string]any + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + item, err := h.admin.CreateModel(c.Request.Context(), body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "data": item}) +} + +func (h *AdminWriteHandler) UpdateModel(c *gin.Context) { + var body map[string]any + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + item, err := h.admin.UpdateModel(c.Request.Context(), c.Param("model_id"), body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "data": item}) +} + +func (h *AdminWriteHandler) DeleteModel(c *gin.Context) { + if err := h.admin.DeleteModel(c.Request.Context(), c.Param("model_id")); err != nil { + if errors.Is(err, service.ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"detail": "model not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to delete model"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +func (h *AdminWriteHandler) ClearLogs(c *gin.Context) { + removed, err := h.admin.ClearLogs(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to clear logs"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "removed": removed}) +} + +func (h *AdminWriteHandler) ClearPendingLogs(c *gin.Context) { + removed, err := h.admin.ClearPendingLogs(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to clear pending logs"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "removed": removed}) +} diff --git a/backend/internal/http/handler/app_settings.go b/backend/internal/http/handler/app_settings.go new file mode 100644 index 0000000..5bcb36e --- /dev/null +++ b/backend/internal/http/handler/app_settings.go @@ -0,0 +1,191 @@ +package handler + +import ( + "net/http" + + "backend/internal/service" + "github.com/gin-gonic/gin" +) + +type AppSettingsHandler struct { + settings *service.AppSettingsService +} + +func NewAppSettingsHandler(settings *service.AppSettingsService) *AppSettingsHandler { + return &AppSettingsHandler{settings: settings} +} + +func (h *AppSettingsHandler) RegistrationGet(c *gin.Context) { + data, err := h.settings.Registration(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load registration settings"}) + return + } + c.JSON(http.StatusOK, data) +} + +func (h *AppSettingsHandler) RegistrationPut(c *gin.Context) { + var body service.RegistrationSettings + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + data, err := h.settings.SaveRegistration(c.Request.Context(), body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "data": data}) +} + +func (h *AppSettingsHandler) SMTPGet(c *gin.Context) { + data, err := h.settings.SMTP(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load smtp settings"}) + return + } + c.JSON(http.StatusOK, data) +} + +func (h *AppSettingsHandler) SMTPPut(c *gin.Context) { + var body service.SMTPSettings + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + data, err := h.settings.SaveSMTP(c.Request.Context(), body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "data": data}) +} + +func (h *AppSettingsHandler) SMTPTest(c *gin.Context) { + var body struct { + Email string `json:"email"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + if err := h.settings.TestSMTP(c.Request.Context(), body.Email); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "detail": "测试邮件已发送"}) +} + +func (h *AppSettingsHandler) ProxyGet(c *gin.Context) { + data, err := h.settings.Proxy(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load proxy settings"}) + return + } + c.JSON(http.StatusOK, data) +} + +func (h *AppSettingsHandler) ProxyPut(c *gin.Context) { + var body struct { + Proxy string `json:"proxy"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + data, err := h.settings.SaveProxy(c.Request.Context(), body.Proxy) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "data": data}) +} + +func (h *AppSettingsHandler) ProxyTest(c *gin.Context) { + var body struct { + Proxy string `json:"proxy"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + data, err := h.settings.TestProxy(c.Request.Context(), body.Proxy) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "data": data}) +} + +func (h *AppSettingsHandler) CreditsGet(c *gin.Context) { + data, err := h.settings.Credits(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load credit settings"}) + return + } + c.JSON(http.StatusOK, data) +} + +func (h *AppSettingsHandler) CreditsPut(c *gin.Context) { + var body service.CreditSettings + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + data, err := h.settings.SaveCredits(c.Request.Context(), body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "data": data}) +} + +func (h *AppSettingsHandler) LogsGet(c *gin.Context) { + data, err := h.settings.Logs(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load log settings"}) + return + } + c.JSON(http.StatusOK, data) +} + +func (h *AppSettingsHandler) LogsPut(c *gin.Context) { + var body struct { + RetentionDays int `json:"retention_days"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + data, err := h.settings.SaveLogs(c.Request.Context(), body.RetentionDays) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "data": data}) +} + +func (h *AppSettingsHandler) MediaGet(c *gin.Context) { + data, err := h.settings.Media(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load media settings"}) + return + } + c.JSON(http.StatusOK, data) +} + +func (h *AppSettingsHandler) MediaPut(c *gin.Context) { + var body struct { + RetentionDays int `json:"retention_days"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + data, err := h.settings.SaveMedia(c.Request.Context(), body.RetentionDays) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "data": data.Settings, "removed": data.Removed, "freed_bytes": data.FreedBytes}) +} diff --git a/backend/internal/http/handler/auth.go b/backend/internal/http/handler/auth.go new file mode 100644 index 0000000..2522363 --- /dev/null +++ b/backend/internal/http/handler/auth.go @@ -0,0 +1,350 @@ +package handler + +import ( + "errors" + "net/http" + "strconv" + "strings" + "time" + + "backend/internal/config" + "backend/internal/model" + "backend/internal/service" + "github.com/gin-gonic/gin" +) + +type AuthHandler struct { + cfg *config.Config + auth *service.AuthService + limiter *service.RateLimitService +} + +func NewAuthHandler(cfg *config.Config, auth *service.AuthService, limiter *service.RateLimitService) *AuthHandler { + return &AuthHandler{ + cfg: cfg, + auth: auth, + limiter: limiter, + } +} + +func (h *AuthHandler) Config(c *gin.Context) { + data, err := h.auth.AuthConfig(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load auth config"}) + return + } + c.JSON(http.StatusOK, data) +} + +func (h *AuthHandler) SendCode(c *gin.Context) { + var body struct { + Email string `json:"email"` + Purpose string `json:"purpose"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + ip := clientIP(c) + if err := h.enforceRateLimit(c, "auth:send-code:ip:"+ip, 5, time.Hour); err != nil { + return + } + if email, err := service.ValidateEmail(body.Email); err == nil { + if err := h.enforceRateLimit(c, "auth:send-code:email:"+email, 3, 10*time.Minute); err != nil { + return + } + } + if err := h.auth.SendCode(c.Request.Context(), body.Email, body.Purpose); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +func (h *AuthHandler) Register(c *gin.Context) { + var body struct { + Email string `json:"email"` + Username string `json:"username"` + Name string `json:"name"` + Password string `json:"password"` + InviteCode string `json:"invite_code"` + EmailCode string `json:"email_code"` + Code string `json:"code"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + username := strings.TrimSpace(body.Username) + if username == "" { + username = strings.TrimSpace(body.Name) + } + if err := h.enforceRateLimit(c, "auth:register:ip:"+clientIP(c), 10, time.Hour); err != nil { + return + } + emailCode := strings.TrimSpace(body.EmailCode) + if emailCode == "" { + emailCode = strings.TrimSpace(body.Code) + } + user, token, session, err := h.auth.Register( + c.Request.Context(), + body.Email, + username, + body.Password, + body.InviteCode, + emailCode, + clientIP(c), + ) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + h.writeSession(c, token, session, user) +} + +func (h *AuthHandler) Login(c *gin.Context) { + var body struct { + Identifier string `json:"identifier"` + Email string `json:"email"` + Username string `json:"username"` + Password string `json:"password"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + + identifier := strings.TrimSpace(body.Identifier) + if identifier == "" { + if strings.TrimSpace(body.Email) != "" { + identifier = strings.TrimSpace(body.Email) + } else { + identifier = strings.TrimSpace(body.Username) + } + } + if identifier == "" || body.Password == "" { + c.JSON(http.StatusBadRequest, gin.H{"detail": "账号或密码不能为空"}) + return + } + ip := clientIP(c) + if err := h.enforceRateLimit(c, "auth:login:ip:"+ip, 20, 15*time.Minute); err != nil { + return + } + if normalized, err := service.ValidateLoginIdentifier(identifier); err == nil { + if err := h.enforceRateLimit(c, "auth:login:target:"+ip+":"+strings.ToLower(normalized), 8, 15*time.Minute); err != nil { + return + } + } + + user, token, session, err := h.auth.Login(c.Request.Context(), identifier, body.Password, ip) + if err != nil { + if writeLoginLocked(c, err) { + return + } + if err == service.ErrAuthFailed { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "账号或密码错误"}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + + h.writeSession(c, token, session, user) +} + +func (h *AuthHandler) ResetPassword(c *gin.Context) { + var body struct { + Email string `json:"email"` + Password string `json:"password"` + EmailCode string `json:"email_code"` + Code string `json:"code"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + ip := clientIP(c) + if err := h.enforceRateLimit(c, "auth:reset:ip:"+ip, 5, time.Hour); err != nil { + return + } + if email, err := service.ValidateEmail(body.Email); err == nil { + if err := h.enforceRateLimit(c, "auth:reset:email:"+email, 5, time.Hour); err != nil { + return + } + } + emailCode := strings.TrimSpace(body.EmailCode) + if emailCode == "" { + emailCode = strings.TrimSpace(body.Code) + } + if err := h.auth.ResetPassword(c.Request.Context(), body.Email, body.Password, emailCode, ip); err != nil { + if writeLoginLocked(c, err) { + return + } + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +func (h *AuthHandler) ChangePassword(c *gin.Context) { + user := currentUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"}) + return + } + if err := h.enforceRateLimit(c, "auth:change-password:user:"+user.ID, 10, 30*time.Minute); err != nil { + return + } + var body struct { + CurrentPassword string `json:"current_password"` + Current string `json:"current"` + NewPassword string `json:"new_password"` + Password string `json:"password"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + current := strings.TrimSpace(body.CurrentPassword) + if current == "" { + current = strings.TrimSpace(body.Current) + } + next := strings.TrimSpace(body.NewPassword) + if next == "" { + next = body.Password + } + if err := h.auth.ChangePassword(c.Request.Context(), user.ID, current, next); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +func (h *AuthHandler) Checkin(c *gin.Context) { + user := currentUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"}) + return + } + result, err := h.auth.Checkin(c.Request.Context(), user.ID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "ok": true, + "already": result.Already, + "awarded": result.Awarded, + "streak": result.Streak, + "credits": result.Credits, + }) +} + +func (h *AuthHandler) Invites(c *gin.Context) { + user := currentUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"}) + return + } + items, err := h.auth.InviteList(c.Request.Context(), user.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load invites"}) + return + } + c.JSON(http.StatusOK, gin.H{"data": items, "reward": h.auth.InviteReward(c.Request.Context())}) +} + +func (h *AuthHandler) Logout(c *gin.Context) { + token := service.ParseBearer(c.GetHeader("Authorization")) + if token == "" { + token = readCookie(c, h.cfg.SessionCookieName) + } + _ = h.auth.Logout(c.Request.Context(), token) + c.SetCookie(h.cfg.SessionCookieName, "", -1, "/", "", h.cfg.CookieSecure, true) + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +func (h *AuthHandler) Me(c *gin.Context) { + userValue, ok := c.Get("current_user") + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"}) + return + } + sessionValue, ok := c.Get("current_session") + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"}) + return + } + + user, _ := userValue.(*model.User) + session, _ := sessionValue.(*service.SessionPayload) + if user == nil || session == nil { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "账号或密码错误"}) + return + } + publicUser, err := h.auth.PublicUser(c.Request.Context(), user) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load user profile"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "ok": true, + "expires_at": session.ExpiresAt, + "user": publicUser, + }) +} + +// writeLoginLocked maps a LoginGuard lockout error to HTTP 429 with a +// Retry-After header (mirrors Python api/auth.py:226-237). Returns true when it +// handled the error so the caller stops processing. +func writeLoginLocked(c *gin.Context, err error) bool { + var locked *service.LoginLockedError + if errors.As(err, &locked) { + c.Header("Retry-After", strconv.Itoa(locked.RetryAfter)) + c.JSON(http.StatusTooManyRequests, gin.H{"detail": locked.Error()}) + return true + } + return false +} + +func clientIP(c *gin.Context) string { + if fwd := strings.TrimSpace(c.GetHeader("X-Forwarded-For")); fwd != "" { + parts := strings.Split(fwd, ",") + return strings.TrimSpace(parts[0]) + } + if real := strings.TrimSpace(c.GetHeader("X-Real-Ip")); real != "" { + return real + } + return c.ClientIP() +} + +func (h *AuthHandler) enforceRateLimit(c *gin.Context, bucket string, limit int64, window time.Duration) error { + if h.limiter == nil { + return nil + } + if err := h.limiter.Enforce(c.Request.Context(), bucket, limit, window); err != nil { + if errors.Is(err, service.ErrRateLimited) { + c.JSON(http.StatusTooManyRequests, gin.H{"detail": err.Error()}) + return err + } + c.JSON(http.StatusInternalServerError, gin.H{"detail": "rate limiter unavailable"}) + return err + } + return nil +} + +func (h *AuthHandler) writeSession(c *gin.Context, token string, session *service.SessionPayload, user *model.User) { + c.SetSameSite(http.SameSiteLaxMode) + c.SetCookie(h.cfg.SessionCookieName, token, int(h.cfg.SessionTTL.Seconds()), "/", "", h.cfg.CookieSecure, true) + publicUser, err := h.auth.PublicUser(c.Request.Context(), user) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load user profile"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "ok": true, + "token": token, + "expires_at": session.ExpiresAt, + "user": publicUser, + }) +} diff --git a/backend/internal/http/handler/cdk.go b/backend/internal/http/handler/cdk.go new file mode 100644 index 0000000..3f651e1 --- /dev/null +++ b/backend/internal/http/handler/cdk.go @@ -0,0 +1,104 @@ +package handler + +import ( + "errors" + "net/http" + + "backend/internal/model" + "backend/internal/service" + "github.com/gin-gonic/gin" +) + +type CDKHandler struct { + cdks *service.CDKService +} + +func NewCDKHandler(cdks *service.CDKService) *CDKHandler { + return &CDKHandler{cdks: cdks} +} + +func (h *CDKHandler) List(c *gin.Context) { + items, stats, names, err := h.cdks.List(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load cdks"}) + return + } + c.JSON(http.StatusOK, gin.H{"data": cdkPublic(items, names), "stats": stats}) +} + +func (h *CDKHandler) Create(c *gin.Context) { + var body struct { + Amount int `json:"amount"` + Count int `json:"count"` + Note string `json:"note"` + Type string `json:"type"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + items, err := h.cdks.Generate(c.Request.Context(), body.Amount, body.Count, body.Note, body.Type) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "created": cdkPublic(items, nil)}) +} + +func (h *CDKHandler) Delete(c *gin.Context) { + if err := h.cdks.Delete(c.Request.Context(), c.Param("code")); err != nil { + if errors.Is(err, service.ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"detail": "cdk not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to delete cdk"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// DeleteBulk removes multiple CDK codes in one call (multi-select). +func (h *CDKHandler) DeleteBulk(c *gin.Context) { + var body struct { + Codes []string `json:"codes"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + if len(body.Codes) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"detail": "未选择任何兑换码"}) + return + } + n, err := h.cdks.DeleteBulk(c.Request.Context(), body.Codes) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to delete cdks"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "deleted": n}) +} + +func cdkPublic(items []model.CDKCode, nameByID map[string]string) []gin.H { + out := make([]gin.H, 0, len(items)) + for _, item := range items { + var redeemedByName any + if item.RedeemedBy != nil && *item.RedeemedBy != "" { + if name, ok := nameByID[*item.RedeemedBy]; ok { + redeemedByName = name + } + } + out = append(out, gin.H{ + "code": item.Code, + "amount": item.Amount, + "status": item.Status, + "type": item.Type, + "batch_id": item.BatchID, + "note": item.Note, + "redeemed_by": item.RedeemedBy, + "redeemed_by_name": redeemedByName, + "redeemed_at": unixSecPtr(item.RedeemedAt), + "created_at": unixSec(item.CreatedAt), + }) + } + return out +} diff --git a/backend/internal/http/handler/health.go b/backend/internal/http/handler/health.go new file mode 100644 index 0000000..e55b55e --- /dev/null +++ b/backend/internal/http/handler/health.go @@ -0,0 +1,13 @@ +package handler + +import "github.com/gin-gonic/gin" + +type HealthHandler struct{} + +func NewHealthHandler() *HealthHandler { + return &HealthHandler{} +} + +func (h *HealthHandler) Handle(c *gin.Context) { + c.JSON(200, gin.H{"ok": true}) +} diff --git a/backend/internal/http/handler/images.go b/backend/internal/http/handler/images.go new file mode 100644 index 0000000..59b2c17 --- /dev/null +++ b/backend/internal/http/handler/images.go @@ -0,0 +1,88 @@ +package handler + +import ( + "io" + "net/http" + + "backend/internal/config" + "backend/internal/service" + "backend/internal/storage" + "github.com/gin-gonic/gin" +) + +type ImageHandler struct { + cfg *config.Config + imageAccess *service.ImageAccessService + store *storage.Client +} + +func NewImageHandler(cfg *config.Config, imageAccess *service.ImageAccessService, store *storage.Client) *ImageHandler { + return &ImageHandler{ + cfg: cfg, + imageAccess: imageAccess, + store: store, + } +} + +// Serve gates access (public showcase images, or a logged-in cookie — a regular +// user only their own images, an admin anyone's) and then PROXIES the object +// from RustFS. Nothing is read from local disk; the RustFS endpoint is never +// exposed to the client. +func (h *ImageHandler) Serve(c *gin.Context) { + user := c.Param("user") + name := c.Param("name") + + rel, err := h.imageAccess.Resolve(user, name) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid path"}) + return + } + + public, err := h.imageAccess.IsPublic(c.Request.Context(), rel) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to authorize image"}) + return + } + if !public { + authorized, err := h.imageAccess.IsAuthorized( + c.Request.Context(), + readCookie(c, h.cfg.SessionCookieName), + user, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to authorize image"}) + return + } + if !authorized { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "需要登录后访问"}) + return + } + } + + // Forward Range so the browser can seek within videos. + resp, err := h.store.Get(c.Request.Context(), rel, c.GetHeader("Range")) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"detail": "failed to fetch object"}) + return + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + c.JSON(http.StatusNotFound, gin.H{"detail": "not found"}) + return + } + for _, hdr := range []string{"Content-Type", "Content-Length", "Accept-Ranges", "Content-Range", "Last-Modified", "ETag", "Cache-Control"} { + if v := resp.Header.Get(hdr); v != "" { + c.Header(hdr, v) + } + } + c.Status(resp.StatusCode) + _, _ = io.Copy(c.Writer, resp.Body) +} + +func readCookie(c *gin.Context, name string) string { + v, err := c.Cookie(name) + if err != nil { + return "" + } + return v +} diff --git a/backend/internal/http/handler/provider_admin.go b/backend/internal/http/handler/provider_admin.go new file mode 100644 index 0000000..7550f8c --- /dev/null +++ b/backend/internal/http/handler/provider_admin.go @@ -0,0 +1,338 @@ +package handler + +import ( + "errors" + "net/http" + + "backend/internal/service" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type ProviderAdminHandler struct { + tokens *service.TokenService + refresh *service.RefreshProfileService +} + +func NewProviderAdminHandler(tokens *service.TokenService, refresh *service.RefreshProfileService) *ProviderAdminHandler { + return &ProviderAdminHandler{ + tokens: tokens, + refresh: refresh, + } +} + +func (h *ProviderAdminHandler) TokensList(c *gin.Context) { + data, err := h.tokens.List(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load tokens"}) + return + } + c.JSON(http.StatusOK, gin.H{"data": data}) +} + +func (h *ProviderAdminHandler) TokensCreate(c *gin.Context) { + var body struct { + Pool string `json:"pool"` + Value string `json:"value"` + ID string `json:"id"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + item, err := h.tokens.Add(c.Request.Context(), body.Pool, body.Value, body.ID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID}) +} + +func (h *ProviderAdminHandler) ImportChatGPTToken(c *gin.Context) { + var body struct { + AccessToken string `json:"access_token"` + Value string `json:"value"` + Name string `json:"name"` + ID string `json:"id"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + token := body.AccessToken + if token == "" { + token = body.Value + } + name := body.Name + if name == "" { + name = body.ID + } + item, err := h.tokens.ImportChatGPTToken(c.Request.Context(), token, name) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status, "pending": item.Status == "pending"}) +} + +func (h *ProviderAdminHandler) ImportRunwayToken(c *gin.Context) { + var body struct { + AccessToken string `json:"access_token"` + Value string `json:"value"` + Name string `json:"name"` + ID string `json:"id"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + token := body.AccessToken + if token == "" { + token = body.Value + } + name := body.Name + if name == "" { + name = body.ID + } + item, err := h.tokens.ImportRunwayToken(c.Request.Context(), token, name) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status, "pending": item.Status == "pending"}) +} + +func (h *ProviderAdminHandler) ImportKreaCookie(c *gin.Context) { + var body struct { + Cookie string `json:"cookie"` + Value string `json:"value"` + Name string `json:"name"` + ID string `json:"id"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + cookie := body.Cookie + if cookie == "" { + cookie = body.Value + } + name := body.Name + if name == "" { + name = body.ID + } + item, err := h.tokens.ImportKreaCookie(c.Request.Context(), cookie, name) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status, "pending": item.Status == "pending"}) +} + +func (h *ProviderAdminHandler) ImportImagineToken(c *gin.Context) { + var body struct { + Cookie string `json:"cookie"` + Value string `json:"value"` + Name string `json:"name"` + ID string `json:"id"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + cred := body.Cookie + if cred == "" { + cred = body.Value + } + name := body.Name + if name == "" { + name = body.ID + } + item, err := h.tokens.ImportImagineToken(c.Request.Context(), cred, name) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status, "pending": item.Status == "pending"}) +} + +func (h *ProviderAdminHandler) ImportLeonardoCookie(c *gin.Context) { + var body struct { + Cookie string `json:"cookie"` + Value string `json:"value"` + Name string `json:"name"` + ID string `json:"id"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + cookie := body.Cookie + if cookie == "" { + cookie = body.Value + } + name := body.Name + if name == "" { + name = body.ID + } + item, err := h.tokens.ImportLeonardoCookie(c.Request.Context(), cookie, name) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "id": item.ID, "status": item.Status, "pending": item.Status == "pending"}) +} + +func (h *ProviderAdminHandler) ImportAdobeCookie(c *gin.Context) { + var body struct { + Cookie string `json:"cookie"` + Value string `json:"value"` + Name string `json:"name"` + ID string `json:"id"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + cookie := body.Cookie + if cookie == "" { + cookie = body.Value + } + name := body.Name + if name == "" { + name = body.ID + } + item, profile, err := h.tokens.ImportAdobeCookie(c.Request.Context(), cookie, name) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "ok": true, + "profile_id": profile.ID, + "id": item.ID, + "status": item.Status, + "pending": item.Status == "pending", + }) +} + +func (h *ProviderAdminHandler) TokenUpdate(c *gin.Context) { + var body map[string]any + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + item, err := h.tokens.Update(c.Request.Context(), c.Param("pool"), c.Param("id"), body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "data": item}) +} + +func (h *ProviderAdminHandler) TokenDelete(c *gin.Context) { + if err := h.tokens.Delete(c.Request.Context(), c.Param("pool"), c.Param("id")); err != nil { + if errors.Is(err, service.ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"detail": "token not found"}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// TokenDeleteBulk removes multiple accounts in one call (account multi-select). +func (h *ProviderAdminHandler) TokenDeleteBulk(c *gin.Context) { + var body struct { + IDs []string `json:"ids"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + if len(body.IDs) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"detail": "未选择任何账号"}) + return + } + n, err := h.tokens.DeleteBulk(c.Request.Context(), body.IDs) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "deleted": n}) +} + +func (h *ProviderAdminHandler) AccountsList(c *gin.Context) { + data, err := h.tokens.Accounts(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load accounts"}) + return + } + c.JSON(http.StatusOK, gin.H{"data": data}) +} + +func (h *ProviderAdminHandler) AccountQuota(c *gin.Context) { + data, err := h.tokens.Quota(c.Request.Context(), c.Param("pool"), c.Param("id")) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"detail": "account not found"}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, data) +} + +func (h *ProviderAdminHandler) AccountEmail(c *gin.Context) { + data, err := h.tokens.Email(c.Request.Context(), c.Param("pool"), c.Param("id")) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"detail": "account not found"}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, data) +} + +func (h *ProviderAdminHandler) RefreshProfiles(c *gin.Context) { + items, err := h.refresh.List(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load refresh profiles"}) + return + } + c.JSON(http.StatusOK, gin.H{"data": items}) +} + +func (h *ProviderAdminHandler) RefreshNow(c *gin.Context) { + if err := h.refresh.RefreshNow(c.Request.Context(), c.Param("profile_id")); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +func (h *ProviderAdminHandler) RefreshUpdate(c *gin.Context) { + var body map[string]any + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + item, err := h.refresh.Update(c.Request.Context(), c.Param("profile_id"), body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "data": item}) +} + +func (h *ProviderAdminHandler) RefreshDelete(c *gin.Context) { + if err := h.refresh.Delete(c.Request.Context(), c.Param("profile_id")); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} diff --git a/backend/internal/http/handler/showcase.go b/backend/internal/http/handler/showcase.go new file mode 100644 index 0000000..174539d --- /dev/null +++ b/backend/internal/http/handler/showcase.go @@ -0,0 +1,45 @@ +package handler + +import ( + "net/http" + + "backend/internal/service" + "github.com/gin-gonic/gin" +) + +type ShowcaseHandler struct { + showcase *service.ShowcaseService +} + +func NewShowcaseHandler(showcase *service.ShowcaseService) *ShowcaseHandler { + return &ShowcaseHandler{showcase: showcase} +} + +func (h *ShowcaseHandler) List(c *gin.Context) { + grouped, err := h.showcase.Grouped(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load showcase"}) + return + } + out := gin.H{} + for kind, items := range grouped { + rows := make([]gin.H, 0, len(items)) + for _, item := range items { + rows = append(rows, gin.H{ + "id": item.ID, + "kind": item.Kind, + "title": item.Title, + "subtitle": item.Subtitle, + "prompt": item.Prompt, + "gradient": item.Gradient, + "span": item.Span, + "image": item.Image, + "weight": item.Weight, + "created_at": item.CreatedAt, + "updated_at": item.UpdatedAt, + }) + } + out[kind] = rows + } + c.JSON(http.StatusOK, gin.H{"data": out}) +} diff --git a/backend/internal/http/handler/site.go b/backend/internal/http/handler/site.go new file mode 100644 index 0000000..9766f48 --- /dev/null +++ b/backend/internal/http/handler/site.go @@ -0,0 +1,25 @@ +package handler + +import ( + "net/http" + + "backend/internal/service" + "github.com/gin-gonic/gin" +) + +type SiteHandler struct { + site *service.SiteService +} + +func NewSiteHandler(site *service.SiteService) *SiteHandler { + return &SiteHandler{site: site} +} + +func (h *SiteHandler) Public(c *gin.Context) { + title, err := h.site.Title(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load site"}) + return + } + c.JSON(http.StatusOK, gin.H{"title": title, "contact": h.site.Contact(c.Request.Context())}) +} diff --git a/backend/internal/http/handler/site_settings.go b/backend/internal/http/handler/site_settings.go new file mode 100644 index 0000000..eecbd98 --- /dev/null +++ b/backend/internal/http/handler/site_settings.go @@ -0,0 +1,52 @@ +package handler + +import ( + "net/http" + "strings" + + "backend/internal/service" + "github.com/gin-gonic/gin" +) + +type SiteSettingsHandler struct { + site *service.SiteService +} + +func NewSiteSettingsHandler(site *service.SiteService) *SiteSettingsHandler { + return &SiteSettingsHandler{site: site} +} + +func (h *SiteSettingsHandler) Get(c *gin.Context) { + title, err := h.site.Title(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load site settings"}) + return + } + c.JSON(http.StatusOK, gin.H{"title": title, "contact": h.site.Contact(c.Request.Context())}) +} + +func (h *SiteSettingsHandler) Put(c *gin.Context) { + var body struct { + Title string `json:"title"` + Contact service.Contact `json:"contact"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + title := strings.TrimSpace(body.Title) + if title == "" { + c.JSON(http.StatusBadRequest, gin.H{"detail": "网页主标题不能为空"}) + return + } + updated, err := h.site.SetTitle(c.Request.Context(), title) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to save site settings"}) + return + } + if err := h.site.SetContact(c.Request.Context(), body.Contact); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to save contact info"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "data": gin.H{"title": updated, "contact": h.site.Contact(c.Request.Context())}}) +} diff --git a/backend/internal/http/handler/user_generation.go b/backend/internal/http/handler/user_generation.go new file mode 100644 index 0000000..210dc1e --- /dev/null +++ b/backend/internal/http/handler/user_generation.go @@ -0,0 +1,603 @@ +package handler + +import ( + "errors" + "net/http" + + "backend/internal/service" + "github.com/gin-gonic/gin" +) + +type UserGenerationHandler struct { + userGen *service.UserGenerationService + admin *service.AdminReadService +} + +func NewUserGenerationHandler(userGen *service.UserGenerationService, admin *service.AdminReadService) *UserGenerationHandler { + return &UserGenerationHandler{ + userGen: userGen, + admin: admin, + } +} + +// MyImages returns the current user's own recently generated images (scoped to +// their owner directory) — used by the showcase "选择已生成" picker so an admin +// only sees their own images, not everyone's. +func (h *UserGenerationHandler) MyImages(c *gin.Context) { + user := currentUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"}) + return + } + items, err := h.admin.RecentImagesOwned(c.Request.Context(), service.OwnerDir(user), 60) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load images"}) + return + } + c.JSON(http.StatusOK, gin.H{"data": items}) +} + +func (h *UserGenerationHandler) Generate(c *gin.Context) { + user := currentUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"}) + return + } + + var body struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + Ratio string `json:"ratio"` + Resolution string `json:"resolution"` + Duration string `json:"duration"` + ReferenceImages []string `json:"reference_images"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + + resp, err := h.userGen.Generate(c.Request.Context(), user, service.UserGenerateRequest{ + Model: body.Model, + Prompt: body.Prompt, + Ratio: body.Ratio, + Resolution: body.Resolution, + Duration: body.Duration, + ReferenceImages: body.ReferenceImages, + }) + if err != nil { + switch { + case errors.Is(err, service.ErrUnknownModel): + c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()}) + case errors.Is(err, service.ErrUnsupportedParams): + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + case errors.Is(err, service.ErrInsufficientFunds): + c.JSON(http.StatusPaymentRequired, gin.H{"detail": "积分不足"}) + case errors.Is(err, service.ErrNoProviderAccount): + c.JSON(http.StatusServiceUnavailable, gin.H{"detail": err.Error()}) + case errors.Is(err, service.ErrProviderAuth), errors.Is(err, service.ErrProviderTemporary): + c.JSON(http.StatusServiceUnavailable, gin.H{"detail": err.Error()}) + case errors.Is(err, service.ErrProviderQuota): + c.JSON(http.StatusTooManyRequests, gin.H{"detail": err.Error()}) + case errors.Is(err, service.ErrConcurrencyFull): + c.JSON(http.StatusTooManyRequests, gin.H{"detail": err.Error()}) + case errors.Is(err, service.ErrProviderExecution): + c.JSON(http.StatusBadGateway, gin.H{"detail": err.Error()}) + default: + if err.Error() == "已有正在生成的任务,请稍候" { + c.JSON(http.StatusConflict, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + } + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *UserGenerationHandler) Test(c *gin.Context) { + user := currentUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"}) + return + } + if user.Role != "admin" { + c.JSON(http.StatusForbidden, gin.H{"detail": "需要管理员权限"}) + return + } + + var body struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + Ratio string `json:"ratio"` + Resolution string `json:"resolution"` + Duration string `json:"duration"` + ReferenceImages []string `json:"reference_images"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + + resp, err := h.userGen.AdminTest(c.Request.Context(), user, service.UserGenerateRequest{ + Model: body.Model, + Prompt: body.Prompt, + Ratio: body.Ratio, + Resolution: body.Resolution, + Duration: body.Duration, + ReferenceImages: body.ReferenceImages, + }) + if err != nil { + switch { + case errors.Is(err, service.ErrUnknownModel): + c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()}) + case errors.Is(err, service.ErrUnsupportedParams): + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + case errors.Is(err, service.ErrProviderQuota): + c.JSON(http.StatusTooManyRequests, gin.H{"detail": err.Error()}) + case errors.Is(err, service.ErrConcurrencyFull): + c.JSON(http.StatusTooManyRequests, gin.H{"detail": err.Error()}) + case errors.Is(err, service.ErrNoProviderAccount): + c.JSON(http.StatusServiceUnavailable, gin.H{"detail": err.Error()}) + case errors.Is(err, service.ErrProviderAuth), errors.Is(err, service.ErrProviderTemporary): + c.JSON(http.StatusServiceUnavailable, gin.H{"detail": err.Error()}) + case errors.Is(err, service.ErrProviderExecution): + c.JSON(http.StatusBadGateway, gin.H{"detail": err.Error()}) + default: + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + } + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *UserGenerationHandler) MyJobs(c *gin.Context) { + user := currentUser(c) + if user == nil { + c.JSON(http.StatusOK, gin.H{"pending": nil, "latest": nil}) + return + } + data, err := h.userGen.MyJobs(c.Request.Context(), user, c.Query("source")) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load jobs"}) + return + } + c.JSON(http.StatusOK, data) +} + +func (h *UserGenerationHandler) Logs(c *gin.Context) { + user := currentUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"}) + return + } + + limit := parseInt(c.Query("limit"), 50) + offset := parseInt(c.Query("offset"), 0) + kind := c.Query("kind") + status := c.Query("status") + // Secure-by-default: always scope to the caller's OWN records. This endpoint + // serves the front-end 日志 / 创作记录 pages, so an admin viewing their personal + // records must NOT see other users' work. Only an admin who explicitly opts + // into the full view (?scope=all — the admin 日志 page) sees everyone's logs. + // API-key ("v1") usage IS included for the caller's own records so the user + // can audit their key's calls on /mylogs; the image-only 创作记录 gallery still + // hides them client-side (they have no stored file). + userID := user.ID + excludeSource := "" + if user.Role == "admin" && c.Query("scope") == "all" { + userID = "" + } + // 来源筛选: "v1" = API key, "user" = 前台画图, "admin" = 测试模型. 始终生效 —— + // 普通用户已被 userID 限定为本人记录,按来源服务端筛选 + 分页(/mylogs 翻全部历史)。 + source := c.Query("source") + // 创作记录 gallery passes has_file=1 so server-side pagination counts only + // rows with real media (success + stored file), not failed/pending events. + hasFile := c.Query("has_file") == "1" || c.Query("has_file") == "true" + + items, total, stats, err := h.admin.Logs(c.Request.Context(), limit, offset, kind, status, nil, userID, excludeSource, source, hasFile) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"}) + return + } + // Resolve user_id -> display name (mirrors admin.py / AdminReadHandler.Logs). + // Without this the log table showed every row as "匿名". + nameByID, err := h.admin.UserNameMap(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"}) + return + } + + out := make([]gin.H, 0, len(items)) + for _, item := range items { + var userName any + if item.UserID == "" { + userName = "匿名" + } else if name, ok := nameByID[item.UserID]; ok { + userName = name + } else { + userName = item.UserID + } + out = append(out, gin.H{ + "id": item.ID, + "ts": item.TS.Unix(), + "kind": item.Kind, + "status": item.Status, + "model": item.Model, + "provider": item.Provider, + "prompt": item.Prompt, + "ratio": item.Ratio, + "resolution": item.Resolution, + "duration": item.Duration, + "refs": item.Refs, + "source": emptyStringNil(item.Source), + "user_id": emptyStringNil(item.UserID), + "user_name": userName, + "cost": item.Cost, + "elapsed_ms": item.ElapsedMS, + "file": emptyStringNil(item.File), + "error": emptyStringNil(item.Error), + "created_at": unixSec(item.CreatedAt), + "updated_at": unixSec(item.UpdatedAt), + }) + } + + c.JSON(http.StatusOK, gin.H{ + "data": out, + "total": total, + "limit": limit, + "offset": offset, + "stats": stats, + }) +} + +func (h *UserGenerationHandler) VideoPresets(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "data": []gin.H{ + { + "key": "gemini-veo31", + "label": "Veo31", + "type": "video", + "provider": "adobe", + "durations": []string{"4s", "6s", "8s"}, + "ratios": []string{"16x9", "9x16"}, + "resolutions": []string{"720p", "1080p"}, + "max_reference_images": 2, + "reference_mode": "frame", + }, + { + "key": "firefly-ray", + "label": "Luma Ray", + "type": "video", + "provider": "adobe", + "durations": []string{"5s", "10s"}, + "ratios": []string{"21:9", "16:9", "4:3", "1:1", "3:4", "9:16", "9:21"}, + "resolutions": []string{"720p"}, + "max_reference_images": 2, + "reference_mode": "frame", + }, + { + "key": "firefly-video", + "label": "Firefly Video", + "type": "video", + "provider": "adobe", + "durations": []string{"5s"}, + "ratios": []string{"16:9", "1:1", "9:16"}, + "resolutions": []string{"540p", "720p", "1080p"}, + "max_reference_images": 2, + "reference_mode": "frame", + }, + { + "key": "runway-gen4-turbo", + "label": "Runway Gen-4 Turbo", + "type": "video", + "provider": "runway", + "durations": []string{"5s", "10s"}, + "ratios": []string{"16:9", "9:16", "1:1", "4:3", "3:4", "21:9"}, + "resolutions": []string{"2K"}, + "max_reference_images": 1, + "reference_mode": "frame", + // Runway is strictly image-to-video — a first-frame image is required + // (no text2video), so the UI must block submit without one. + "requires_reference": true, + }, + }, + }) +} + +func (h *UserGenerationHandler) Catalog(c *gin.Context) { + items, err := h.catalogEntries(c) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load catalog"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "data": items, + }) +} + +func (h *UserGenerationHandler) Models(c *gin.Context) { + items, err := h.publicModels() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load models"}) + return + } + c.JSON(http.StatusOK, gin.H{"data": items}) +} + +func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error) { + items := []gin.H{ + { + "id": "gpt-image-2", + "provider": "chatgpt", + "type": "image", + // ChatGPT web backend only reliably produces 1K and honors a limited + // ratio set; size params are advisory prompt hints. Mirrors the Python + // reference (providers/chatgpt/provider.py) — do not offer 2K/4K. + "ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"}, + "resolutions": []string{"1K"}, + "image_to_image": true, + "max_reference_images": 3, + "description": "ChatGPT image generation", + }, + { + "id": "firefly-gpt-image-2", + "provider": "adobe", + "type": "image", + "ratios": []string{"1:1", "5:4", "9:16", "21:9", "16:9", "4:3", "3:2", "4:5", "3:4", "2:3"}, + "resolutions": []string{"1K", "2K", "4K"}, + "image_to_image": true, + "max_reference_images": 6, + "description": "Adobe Firefly GPT Image", + }, + { + "id": "firefly-image-5", + "provider": "adobe", + "type": "image", + "ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"}, + "resolutions": []string{"1K", "2K"}, + "image_to_image": true, + "description": "Adobe Firefly Image 5", + }, + { + "id": "flux-kontext-max", + "provider": "adobe", + "type": "image", + "ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"}, + "resolutions": []string{"1K"}, + "image_to_image": true, + "max_reference_images": 4, + "description": "Adobe Flux Kontext Max", + }, + { + "id": "nano-banana-2", + "provider": "adobe", + "type": "image", + "ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"}, + "resolutions": []string{"1K"}, + "image_to_image": true, + "description": "Adobe Gemini Flash Nano Banana", + }, + { + "id": "gemini-veo31", + "provider": "adobe", + "type": "video", + "ratios": []string{"16x9", "9x16"}, + "resolutions": []string{"720p", "1080p"}, + "durations": []string{"4s", "6s", "8s"}, + "max_reference_images": 2, + "reference_mode": "frame", + "description": "Veo31 video", + }, + { + "id": "firefly-ray", + "provider": "adobe", + "type": "video", + "ratios": []string{"21:9", "16:9", "4:3", "1:1", "3:4", "9:16", "9:21"}, + "resolutions": []string{"720p"}, + "durations": []string{"5s", "10s"}, + "max_reference_images": 2, + "reference_mode": "frame", + "description": "Luma Ray video", + }, + { + "id": "firefly-video", + "provider": "adobe", + "type": "video", + "ratios": []string{"16:9", "1:1", "9:16"}, + "resolutions": []string{"540p", "720p", "1080p"}, + "durations": []string{"5s"}, + "max_reference_images": 2, + "reference_mode": "frame", + "description": "Adobe Firefly Video", + }, + { + "id": "runway-gen4-turbo", + "provider": "runway", + "type": "video", + "ratios": []string{"16:9", "9:16", "1:1", "4:3", "3:4", "21:9"}, + "resolutions": []string{"2K"}, + "durations": []string{"5s", "10s"}, + "max_reference_images": 1, + "reference_mode": "frame", + "description": "Runway Gen-4 Turbo video (图生视频)", + }, + { + "id": "seedream-4.5", + "provider": "leonardo", + "type": "image", + "ratios": []string{"2:3", "1:1", "16:9", "4:3", "4:5", "9:16", "2:1"}, + "resolutions": []string{"2K", "4K"}, + "image_to_image": true, + "max_reference_images": 6, + "description": "Leonardo Seedream 4.5 (生图 / 图生图)", + }, + { + "id": "flux-klein-2", + "provider": "krea", + "type": "image", + "ratios": []string{"1:1", "4:3", "3:4", "16:9", "9:16"}, + "resolutions": []string{"1K", "2K"}, + "image_to_image": true, + "max_reference_images": 4, + "description": "Krea Flux Klein (生图 / 图生图)", + }, + { + "id": "imagine-1.5", + "provider": "imagine", + "type": "image", + "ratios": []string{"1:3", "9:16", "2:3", "3:4", "1:1", "4:3", "3:2", "16:9", "3:1"}, + "resolutions": []string{"2K"}, + "max_reference_images": 0, + "description": "Imagine 1.5 (文生图)", + }, + { + "id": "imagine-1.5pro", + "provider": "imagine", + "type": "image", + "ratios": []string{"1:3", "9:16", "2:3", "3:4", "1:1", "4:3", "3:2", "16:9", "3:1"}, + "resolutions": []string{"4K"}, + "max_reference_images": 0, + "description": "Imagine 1.5 Pro (文生图)", + }, + } + existing := map[string]bool{} + if h.admin != nil { + models, err := h.admin.Models(c.Request.Context()) + if err != nil { + return nil, err + } + for _, item := range models { + existing[item.ID] = true + } + } + for i := range items { + items[i]["added"] = existing[items[i]["id"].(string)] + } + return items, nil +} + +func (h *UserGenerationHandler) publicModels() ([]gin.H, error) { + items := []gin.H{ + { + "id": "gpt-image-2", + "provider": "chatgpt", + "kind": "image", + // See catalogEntries — ChatGPT only reliably does 1K and a limited + // ratio set; matches the Python reference. Keep both lists in sync. + "ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"}, + "resolutions": []string{"1K"}, + "description": "ChatGPT image generation", + "stub": false, + }, + { + "id": "firefly-gpt-image-2", + "provider": "adobe", + "kind": "image", + "ratios": []string{"1:1", "5:4", "9:16", "21:9", "16:9", "4:3", "3:2", "4:5", "3:4", "2:3"}, + "resolutions": []string{"1K", "2K", "4K"}, + "description": "Adobe Firefly GPT Image", + "stub": false, + }, + { + "id": "firefly-image-5", + "provider": "adobe", + "kind": "image", + "ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"}, + "resolutions": []string{"1K", "2K"}, + "description": "Adobe Firefly Image 5", + "stub": false, + }, + { + "id": "flux-kontext-max", + "provider": "adobe", + "kind": "image", + "ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"}, + "resolutions": []string{"1K"}, + "description": "Adobe Flux Kontext Max", + "stub": false, + }, + { + "id": "nano-banana-2", + "provider": "adobe", + "kind": "image", + "ratios": []string{"1:1", "16:9", "9:16", "4:3", "3:4"}, + "resolutions": []string{"1K"}, + "description": "Adobe Gemini Flash Nano Banana", + "stub": false, + }, + { + "id": "gemini-veo31", + "provider": "adobe", + "kind": "video", + "ratios": []string{"16x9", "9x16"}, + "resolutions": []string{"720p", "1080p"}, + "description": "Veo31 video", + "stub": false, + }, + { + "id": "firefly-ray", + "provider": "adobe", + "kind": "video", + "ratios": []string{"21:9", "16:9", "4:3", "1:1", "3:4", "9:16", "9:21"}, + "resolutions": []string{"720p"}, + "description": "Luma Ray video", + "stub": false, + }, + { + "id": "firefly-video", + "provider": "adobe", + "kind": "video", + "ratios": []string{"16:9", "1:1", "9:16"}, + "resolutions": []string{"540p", "720p", "1080p"}, + "description": "Adobe Firefly Video", + "stub": false, + }, + { + "id": "runway-gen4-turbo", + "provider": "runway", + "kind": "video", + "ratios": []string{"16:9", "9:16", "1:1", "4:3", "3:4", "21:9"}, + "resolutions": []string{"2K"}, + "description": "Runway Gen-4 Turbo video", + "stub": false, + }, + { + "id": "seedream-4.5", + "provider": "leonardo", + "kind": "image", + "ratios": []string{"2:3", "1:1", "16:9", "4:3", "4:5", "9:16", "2:1"}, + "resolutions": []string{"2K", "4K"}, + "description": "Leonardo Seedream 4.5", + "stub": false, + }, + { + "id": "flux-klein-2", + "provider": "krea", + "kind": "image", + "ratios": []string{"1:1", "4:3", "3:4", "16:9", "9:16"}, + "resolutions": []string{"1K", "2K"}, + "description": "Krea Flux Klein", + "stub": false, + }, + { + "id": "imagine-1.5", + "provider": "imagine", + "kind": "image", + "ratios": []string{"1:3", "9:16", "2:3", "3:4", "1:1", "4:3", "3:2", "16:9", "3:1"}, + "resolutions": []string{"2K"}, + "description": "Imagine 1.5", + "stub": false, + }, + { + "id": "imagine-1.5pro", + "provider": "imagine", + "kind": "image", + "ratios": []string{"1:3", "9:16", "2:3", "3:4", "1:1", "4:3", "3:2", "16:9", "3:1"}, + "resolutions": []string{"4K"}, + "description": "Imagine 1.5 Pro", + "stub": false, + }, + } + return items, nil +} diff --git a/backend/internal/http/handler/user_tools.go b/backend/internal/http/handler/user_tools.go new file mode 100644 index 0000000..06f9ac9 --- /dev/null +++ b/backend/internal/http/handler/user_tools.go @@ -0,0 +1,92 @@ +package handler + +import ( + "net/http" + + "backend/internal/model" + "backend/internal/service" + "github.com/gin-gonic/gin" +) + +type UserToolsHandler struct { + keys *service.APIKeyService + cdks *service.CDKService +} + +func NewUserToolsHandler(keys *service.APIKeyService, cdks *service.CDKService) *UserToolsHandler { + return &UserToolsHandler{ + keys: keys, + cdks: cdks, + } +} + +func (h *UserToolsHandler) APIKeyGet(c *gin.Context) { + user := currentUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"}) + return + } + data, err := h.keys.Current(c.Request.Context(), user.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load api key"}) + return + } + c.JSON(http.StatusOK, data) +} + +func (h *UserToolsHandler) APIKeyMint(c *gin.Context) { + user := currentUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"}) + return + } + data, err := h.keys.Mint(c.Request.Context(), user.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to mint api key"}) + return + } + c.JSON(http.StatusOK, data) +} + +func (h *UserToolsHandler) APIKeyDelete(c *gin.Context) { + user := currentUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"}) + return + } + if err := h.keys.Revoke(c.Request.Context(), user.ID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to revoke api key"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +func (h *UserToolsHandler) RedeemCDK(c *gin.Context) { + user := currentUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"}) + return + } + var body struct { + Code string `json:"code"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + data, err := h.cdks.Redeem(c.Request.Context(), user.ID, body.Code) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "amount": data["amount"], "credits": data["credits"]}) +} + +func currentUser(c *gin.Context) *model.User { + value, ok := c.Get("current_user") + if !ok { + return nil + } + user, _ := value.(*model.User) + return user +} diff --git a/backend/internal/http/handler/v1.go b/backend/internal/http/handler/v1.go new file mode 100644 index 0000000..bd7e039 --- /dev/null +++ b/backend/internal/http/handler/v1.go @@ -0,0 +1,373 @@ +package handler + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + + "backend/internal/service" + "github.com/gin-gonic/gin" +) + +type V1Handler struct { + v1 *service.V1Service +} + +func NewV1Handler(v1 *service.V1Service) *V1Handler { + return &V1Handler{v1: v1} +} + +func (h *V1Handler) Models(c *gin.Context) { + principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization")) + if err != nil { + h.writeAuthError(c, err) + return + } + _ = principal + + items, err := h.v1.ListModels(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load models"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "object": "list", + "data": items, + }) +} + +// ImageGenerations — OpenAI POST /v1/images/generations (text-to-image only). +// Accepts exactly OpenAI's fields; size→aspect ratio and quality→resolution tier +// are mapped server-side. Returns {created, data:[{b64_json}]}. +func (h *V1Handler) ImageGenerations(c *gin.Context) { + principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization")) + if err != nil { + h.writeAuthError(c, err) + return + } + + var body struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + N int `json:"n"` + Size string `json:"size"` + Quality string `json:"quality"` + ResponseFormat string `json:"response_format"` + Background string `json:"background"` + OutputFormat string `json:"output_format"` + User string `json:"user"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + + resp, err := h.v1.PrepareImageRequest(c.Request.Context(), principal, service.V1ImageRequest{ + Model: body.Model, + Prompt: body.Prompt, + N: body.N, + Size: body.Size, + Quality: body.Quality, + BaseURL: requestBaseURL(c), + }) + if err != nil { + h.writeV1Error(c, err, resp) + return + } + c.JSON(http.StatusOK, openaiImageResponse(resp)) +} + +// ImageEdits — OpenAI POST /v1/images/edits (image-to-image). multipart/form-data +// only: image / image[] file uploads (+ optional mask), prompt, model, n, size, +// quality. Files become reference images. Returns {created, data:[{b64_json}]}. +func (h *V1Handler) ImageEdits(c *gin.Context) { + principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization")) + if err != nil { + h.writeAuthError(c, err) + return + } + if !strings.HasPrefix(c.GetHeader("Content-Type"), "multipart/form-data") { + c.JSON(http.StatusBadRequest, gin.H{"detail": "images/edits requires multipart/form-data"}) + return + } + if err := c.Request.ParseMultipartForm(64 << 20); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid multipart form"}) + return + } + refs := readMultipartImages(c, "image", "image[]") + if len(refs) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"detail": "images/edits requires at least one image file"}) + return + } + n, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("n"))) + resp, err := h.v1.PrepareImageRequest(c.Request.Context(), principal, service.V1ImageRequest{ + Model: c.PostForm("model"), + Prompt: c.PostForm("prompt"), + N: n, + Size: c.PostForm("size"), + Quality: c.PostForm("quality"), + ReferenceImages: refs, + BaseURL: requestBaseURL(c), + }) + if err != nil { + h.writeV1Error(c, err, resp) + return + } + c.JSON(http.StatusOK, openaiImageResponse(resp)) +} + +// CreateVideo — OpenAI POST /v1/videos. Creates an async job and returns the +// video object immediately ({id, status:"queued"}). Accepts JSON {model, prompt, +// seconds, size} or multipart (with an input_reference file). size→ratio+ +// resolution, seconds→duration. +func (h *V1Handler) CreateVideo(c *gin.Context) { + principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization")) + if err != nil { + h.writeAuthError(c, err) + return + } + var modelID, prompt, seconds, size string + var refs []string + if strings.HasPrefix(c.GetHeader("Content-Type"), "multipart/form-data") { + if err := c.Request.ParseMultipartForm(64 << 20); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid multipart form"}) + return + } + modelID = c.PostForm("model") + prompt = c.PostForm("prompt") + seconds = c.PostForm("seconds") + size = c.PostForm("size") + refs = readMultipartImages(c, "input_reference", "input_reference[]") + } else { + var body struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + Seconds json.RawMessage `json:"seconds"` + Size string `json:"size"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + modelID, prompt, size = body.Model, body.Prompt, body.Size + seconds = rawToString(body.Seconds) + } + duration := strings.TrimSpace(seconds) + if duration != "" && !strings.HasSuffix(duration, "s") { + duration += "s" + } + aspect, resolution := videoSizeToInternal(size) + resp, err := h.v1.StartVideoJob(c.Request.Context(), principal, service.V1VideoRequest{ + Model: modelID, + Prompt: prompt, + Duration: duration, + AspectRatio: aspect, + Resolution: resolution, + ReferenceImages: refs, + BaseURL: requestBaseURL(c), + }) + if err != nil { + h.writeV1Error(c, err, nil) + return + } + c.JSON(http.StatusOK, resp) +} + +// GetVideo — OpenAI GET /v1/videos/{id}. Returns the job status object. +func (h *V1Handler) GetVideo(c *gin.Context) { + principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization")) + if err != nil { + h.writeAuthError(c, err) + return + } + resp, err := h.v1.VideoJob(c.Request.Context(), principal, c.Param("id")) + if err != nil { + h.writeV1Error(c, err, nil) + return + } + c.JSON(http.StatusOK, resp) +} + +// GetVideoContent — OpenAI GET /v1/videos/{id}/content. Streams the rendered mp4 +// by proxying the stored upstream URL (downloaded on demand, never persisted). +func (h *V1Handler) GetVideoContent(c *gin.Context) { + principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization")) + if err != nil { + h.writeAuthError(c, err) + return + } + body, contentType, err := h.v1.OpenVideoContent(c.Request.Context(), principal, c.Param("id")) + if err != nil { + h.writeV1Error(c, err, nil) + return + } + defer body.Close() + c.Header("Content-Type", contentType) + c.Status(http.StatusOK) + _, _ = io.Copy(c.Writer, body) +} + +// readMultipartImages reads the given file fields and returns each as base64. +func readMultipartImages(c *gin.Context, keys ...string) []string { + var out []string + form := c.Request.MultipartForm + if form == nil { + return out + } + for _, key := range keys { + for _, fh := range form.File[key] { + f, e := fh.Open() + if e != nil { + continue + } + b, _ := io.ReadAll(io.LimitReader(f, 8<<20+1)) + f.Close() + if len(b) > 0 { + out = append(out, base64.StdEncoding.EncodeToString(b)) + } + } + } + return out +} + +// rawToString accepts OpenAI's `seconds` whether sent as a JSON string or number. +func rawToString(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var s string + if json.Unmarshal(raw, &s) == nil { + return s + } + var n json.Number + if json.Unmarshal(raw, &n) == nil { + return n.String() + } + return strings.Trim(string(raw), `"`) +} + +// videoSizeToInternal maps OpenAI's "WxH" size to our aspect ratio + resolution +// tier (height ≥1080 → 1080p, else 720p). +func videoSizeToInternal(size string) (ratio, resolution string) { + var w, h int + if s := strings.TrimSpace(strings.ToLower(size)); s != "" { + _, _ = fmt.Sscanf(s, "%dx%d", &w, &h) + } + if w == 0 || h == 0 { + return "16:9", "720p" + } + long := w + if h > long { + long = h + } + resolution = "720p" + if long >= 1080 { + resolution = "1080p" + } + return guessRatioWH(w, h), resolution +} + +func guessRatioWH(w, h int) string { + if w == h { + return "1:1" + } + r := float64(w) / float64(h) + cands := []struct { + name string + v float64 + }{{"16:9", 16.0 / 9}, {"9:16", 9.0 / 16}, {"4:3", 4.0 / 3}, {"3:4", 3.0 / 4}, {"1:1", 1}} + best, bestD := "16:9", 1e9 + for _, cd := range cands { + d := r - cd.v + if d < 0 { + d = -d + } + if d < bestD { + best, bestD = cd.name, d + } + } + return best +} + +// openaiImageResponse strips our rich internal map down to OpenAI's image shape. +func openaiImageResponse(m map[string]any) gin.H { + out := gin.H{"created": m["created"]} + if d, ok := m["data"]; ok && d != nil { + out["data"] = d + } else { + out["data"] = []any{} + } + return out +} + +func (h *V1Handler) writeAuthError(c *gin.Context, err error) { + switch { + case errors.Is(err, service.ErrMissingAPIKey): + c.JSON(http.StatusUnauthorized, gin.H{"detail": err.Error()}) + case errors.Is(err, service.ErrInvalidAPIKey): + c.JSON(http.StatusUnauthorized, gin.H{"detail": err.Error()}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to validate api key"}) + } +} + +func (h *V1Handler) writeV1Error(c *gin.Context, err error, payload map[string]any) { + switch { + case errors.Is(err, service.ErrUnknownModel): + c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()}) + case errors.Is(err, service.ErrUnsupportedParams): + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + case errors.Is(err, service.ErrInsufficientFunds): + c.JSON(http.StatusPaymentRequired, gin.H{"detail": err.Error()}) + case errors.Is(err, service.ErrReferenceTooLarge): + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"detail": err.Error()}) + case errors.Is(err, service.ErrNoProviderAccount): + c.JSON(http.StatusServiceUnavailable, gin.H{"detail": err.Error()}) + case errors.Is(err, service.ErrProviderAuth): + c.JSON(http.StatusServiceUnavailable, gin.H{"detail": err.Error()}) + case errors.Is(err, service.ErrProviderQuota): + // Match the Python contract: provider quota exhaustion maps to 401 + // (QuotaExhaustedError is handled alongside AuthError in routes.py). + c.JSON(http.StatusUnauthorized, gin.H{"detail": err.Error()}) + case errors.Is(err, service.ErrProviderTemporary): + c.JSON(http.StatusServiceUnavailable, gin.H{"detail": err.Error()}) + case errors.Is(err, service.ErrConcurrencyFull): + c.JSON(http.StatusTooManyRequests, gin.H{"detail": err.Error()}) + case errors.Is(err, service.ErrVideoJobNotFound): + c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()}) + case errors.Is(err, service.ErrVideoNotReady): + c.JSON(http.StatusConflict, gin.H{"detail": err.Error()}) + case errors.Is(err, service.ErrProviderUnsupported): + c.JSON(http.StatusNotImplemented, gin.H{"detail": err.Error()}) + case errors.Is(err, service.ErrProviderExecution): + c.JSON(http.StatusBadGateway, gin.H{"detail": err.Error()}) + case errors.Is(err, service.ErrGenerationPending): + c.JSON(http.StatusNotImplemented, payload) + default: + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + } +} + +// requestBaseURL derives the scheme+host of the inbound request so the service +// layer can build absolute, directly-downloadable output URLs. Honors +// X-Forwarded-Proto (reverse-proxy / TLS termination) before falling back to +// the connection's TLS state. Returns "" when the host is unknown, which makes +// the service fall back to a relative path. +func requestBaseURL(c *gin.Context) string { + host := c.Request.Host + if host == "" { + return "" + } + scheme := "http" + if proto := strings.TrimSpace(c.GetHeader("X-Forwarded-Proto")); proto != "" { + scheme = strings.ToLower(strings.Split(proto, ",")[0]) + } else if c.Request.TLS != nil { + scheme = "https" + } + return scheme + "://" + host +} diff --git a/backend/internal/http/middleware/auth.go b/backend/internal/http/middleware/auth.go new file mode 100644 index 0000000..c13c10b --- /dev/null +++ b/backend/internal/http/middleware/auth.go @@ -0,0 +1,70 @@ +package middleware + +import ( + "net/http" + + "backend/internal/service" + "github.com/gin-gonic/gin" +) + +const currentUserKey = "current_user" +const currentSessionKey = "current_session" + +func RequireSession(auth *service.AuthService) gin.HandlerFunc { + return func(c *gin.Context) { + user, session, err := auth.CurrentUserFromRequest( + c.Request.Context(), + c.GetHeader("Authorization"), + readCookie(c, "vivid_session"), + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to validate session"}) + c.Abort() + return + } + if user == nil || session == nil { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"}) + c.Abort() + return + } + c.Set(currentUserKey, user) + c.Set(currentSessionKey, session) + c.Next() + } +} + +func RequireAdminSession(auth *service.AuthService) gin.HandlerFunc { + return func(c *gin.Context) { + user, session, err := auth.CurrentUserFromRequest( + c.Request.Context(), + c.GetHeader("Authorization"), + readCookie(c, "vivid_session"), + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to validate session"}) + c.Abort() + return + } + if user == nil || session == nil { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"}) + c.Abort() + return + } + if user.Role != "admin" { + c.JSON(http.StatusForbidden, gin.H{"detail": "需要管理员权限"}) + c.Abort() + return + } + c.Set(currentUserKey, user) + c.Set(currentSessionKey, session) + c.Next() + } +} + +func readCookie(c *gin.Context, name string) string { + v, err := c.Cookie(name) + if err != nil { + return "" + } + return v +} diff --git a/backend/internal/http/middleware/request_id.go b/backend/internal/http/middleware/request_id.go new file mode 100644 index 0000000..4ce270d --- /dev/null +++ b/backend/internal/http/middleware/request_id.go @@ -0,0 +1,20 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +const requestIDKey = "request_id" + +func RequestID() gin.HandlerFunc { + return func(c *gin.Context) { + reqID := c.GetHeader("X-Request-Id") + if reqID == "" { + reqID = uuid.NewString() + } + c.Set(requestIDKey, reqID) + c.Writer.Header().Set("X-Request-Id", reqID) + c.Next() + } +} diff --git a/backend/internal/http/router/router.go b/backend/internal/http/router/router.go new file mode 100644 index 0000000..06bc50e --- /dev/null +++ b/backend/internal/http/router/router.go @@ -0,0 +1,166 @@ +package router + +import ( + "backend/internal/config" + "backend/internal/http/handler" + "backend/internal/http/middleware" + "backend/internal/service" + + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" +) + +type Handlers struct { + Health *handler.HealthHandler + Images *handler.ImageHandler + V1 *handler.V1Handler + Site *handler.SiteHandler + Showcase *handler.ShowcaseHandler + Auth *handler.AuthHandler + SiteSettings *handler.SiteSettingsHandler + AppSettings *handler.AppSettingsHandler + AdminRead *handler.AdminReadHandler + AdminWrite *handler.AdminWriteHandler + CDK *handler.CDKHandler + UserTools *handler.UserToolsHandler + UserGen *handler.UserGenerationHandler + ProviderAdmin *handler.ProviderAdminHandler +} + +func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.Engine { + if cfg.AppEnv != "development" { + gin.SetMode(gin.ReleaseMode) + } + + engine := gin.New() + engine.Use(gin.Recovery()) + engine.Use(middleware.RequestID()) + engine.Use(cors.New(cors.Config{ + AllowOrigins: cfg.CORSOrigins, + AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, + AllowHeaders: []string{"Authorization", "Content-Type", "X-Request-Id"}, + AllowCredentials: true, + })) + + engine.GET("/health", handlers.Health.Handle) + engine.GET("/images/:user/:name", handlers.Images.Serve) + engine.GET("/v1/models", handlers.V1.Models) + engine.POST("/v1/images/generations", handlers.V1.ImageGenerations) + engine.POST("/v1/images/edits", handlers.V1.ImageEdits) + // OpenAI Sora-style async video: create job → poll → stream content. + engine.POST("/v1/videos", handlers.V1.CreateVideo) + engine.GET("/v1/videos/:id", handlers.V1.GetVideo) + engine.GET("/v1/videos/:id/content", handlers.V1.GetVideoContent) + + publicAdmin := engine.Group("/admin/api") + { + publicAdmin.GET("/site", handlers.Site.Public) + publicAdmin.GET("/showcase", handlers.Showcase.List) + publicAdmin.GET("/managed-models", handlers.AdminRead.Models) + publicAdmin.GET("/stats", handlers.AdminRead.Stats) + publicAdmin.GET("/video-presets", handlers.UserGen.VideoPresets) + publicAdmin.GET("/catalog", handlers.UserGen.Catalog) + publicAdmin.GET("/models", handlers.UserGen.Models) + } + + authGroup := engine.Group("/admin/api/auth") + { + authGroup.GET("/config", handlers.Auth.Config) + authGroup.POST("/send-code", handlers.Auth.SendCode) + authGroup.POST("/register", handlers.Auth.Register) + authGroup.POST("/login", handlers.Auth.Login) + authGroup.POST("/logout", handlers.Auth.Logout) + authGroup.POST("/reset-password", handlers.Auth.ResetPassword) + } + + userAuthed := engine.Group("/admin/api") + userAuthed.Use(middleware.RequireSession(auth)) + { + userAuthed.GET("/logs", handlers.UserGen.Logs) + userAuthed.POST("/generate", handlers.UserGen.Generate) + userAuthed.POST("/test", handlers.UserGen.Test) + userAuthed.GET("/jobs/mine", handlers.UserGen.MyJobs) + userAuthed.GET("/my-images", handlers.UserGen.MyImages) + } + + authed := engine.Group("/admin/api") + authed.Use(middleware.RequireAdminSession(auth)) + { + authed.GET("/dashboard", handlers.AdminRead.Dashboard) + authed.GET("/users", handlers.AdminRead.Users) + authed.GET("/invites", handlers.AdminRead.Invites) + authed.POST("/users", handlers.AdminWrite.CreateUser) + authed.POST("/users/delete-bulk", handlers.AdminWrite.DeleteUsersBulk) + authed.PATCH("/users/:user_id", handlers.AdminWrite.UpdateUser) + authed.DELETE("/users/:user_id", handlers.AdminWrite.DeleteUser) + authed.POST("/users/:user_id/credits", handlers.AdminWrite.AdjustUserCredits) + authed.POST("/users/:user_id/api-keys", handlers.AdminWrite.CreateUserAPIKey) + authed.DELETE("/users/:user_id/api-keys/:key_id", handlers.AdminWrite.DeleteUserAPIKey) + authed.GET("/cdks", handlers.CDK.List) + authed.POST("/cdks", handlers.CDK.Create) + authed.POST("/cdks/delete-bulk", handlers.CDK.DeleteBulk) + authed.DELETE("/cdks/:code", handlers.CDK.Delete) + authed.GET("/tokens", handlers.ProviderAdmin.TokensList) + authed.POST("/tokens", handlers.ProviderAdmin.TokensCreate) + authed.POST("/tokens/import-chatgpt-token", handlers.ProviderAdmin.ImportChatGPTToken) + authed.POST("/tokens/import-adobe-cookie", handlers.ProviderAdmin.ImportAdobeCookie) + authed.POST("/tokens/import-runway-token", handlers.ProviderAdmin.ImportRunwayToken) + authed.POST("/tokens/import-leonardo-cookie", handlers.ProviderAdmin.ImportLeonardoCookie) + authed.POST("/tokens/import-krea-cookie", handlers.ProviderAdmin.ImportKreaCookie) + authed.POST("/tokens/import-imagine-token", handlers.ProviderAdmin.ImportImagineToken) + authed.POST("/tokens/delete-bulk", handlers.ProviderAdmin.TokenDeleteBulk) + authed.PATCH("/tokens/:pool/:id", handlers.ProviderAdmin.TokenUpdate) + authed.DELETE("/tokens/:pool/:id", handlers.ProviderAdmin.TokenDelete) + authed.GET("/accounts", handlers.ProviderAdmin.AccountsList) + authed.GET("/accounts/:pool/:id/quota", handlers.ProviderAdmin.AccountQuota) + authed.GET("/accounts/:pool/:id/email", handlers.ProviderAdmin.AccountEmail) + authed.GET("/providers", handlers.AdminRead.Providers) + authed.GET("/images", handlers.AdminRead.Images) + authed.GET("/refresh/profiles", handlers.ProviderAdmin.RefreshProfiles) + authed.POST("/refresh/profiles/:profile_id/refresh-now", handlers.ProviderAdmin.RefreshNow) + authed.PATCH("/refresh/profiles/:profile_id", handlers.ProviderAdmin.RefreshUpdate) + authed.DELETE("/refresh/profiles/:profile_id", handlers.ProviderAdmin.RefreshDelete) + authed.POST("/managed-models", handlers.AdminWrite.CreateModel) + authed.PATCH("/managed-models/:model_id", handlers.AdminWrite.UpdateModel) + authed.DELETE("/managed-models/:model_id", handlers.AdminWrite.DeleteModel) + authed.DELETE("/logs", handlers.AdminWrite.ClearLogs) + authed.DELETE("/logs/pending", handlers.AdminWrite.ClearPendingLogs) + authed.POST("/showcase", handlers.AdminWrite.CreateShowcase) + authed.PATCH("/showcase/:entry_id", handlers.AdminWrite.UpdateShowcase) + authed.DELETE("/showcase/:entry_id", handlers.AdminWrite.DeleteShowcase) + + settings := authed.Group("/settings") + { + settings.GET("/site", handlers.SiteSettings.Get) + settings.PUT("/site", handlers.SiteSettings.Put) + settings.GET("/registration", handlers.AppSettings.RegistrationGet) + settings.PUT("/registration", handlers.AppSettings.RegistrationPut) + settings.GET("/smtp", handlers.AppSettings.SMTPGet) + settings.PUT("/smtp", handlers.AppSettings.SMTPPut) + settings.POST("/smtp/test", handlers.AppSettings.SMTPTest) + settings.GET("/proxy", handlers.AppSettings.ProxyGet) + settings.PUT("/proxy", handlers.AppSettings.ProxyPut) + settings.POST("/proxy/test", handlers.AppSettings.ProxyTest) + settings.GET("/credits", handlers.AppSettings.CreditsGet) + settings.PUT("/credits", handlers.AppSettings.CreditsPut) + settings.GET("/logs", handlers.AppSettings.LogsGet) + settings.PUT("/logs", handlers.AppSettings.LogsPut) + settings.GET("/media", handlers.AppSettings.MediaGet) + settings.PUT("/media", handlers.AppSettings.MediaPut) + } + } + + authGroup.Use(middleware.RequireSession(auth)) + { + authGroup.GET("/me", handlers.Auth.Me) + authGroup.GET("/invites", handlers.Auth.Invites) + authGroup.POST("/checkin", handlers.Auth.Checkin) + authGroup.POST("/change-password", handlers.Auth.ChangePassword) + authGroup.GET("/api-key", handlers.UserTools.APIKeyGet) + authGroup.POST("/api-key", handlers.UserTools.APIKeyMint) + authGroup.DELETE("/api-key", handlers.UserTools.APIKeyDelete) + authGroup.POST("/redeem-cdk", handlers.UserTools.RedeemCDK) + } + + return engine +} diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go new file mode 100644 index 0000000..597d0e2 --- /dev/null +++ b/backend/internal/model/models.go @@ -0,0 +1,190 @@ +package model + +import ( + "time" + + "gorm.io/datatypes" +) + +type User struct { + ID string `gorm:"primaryKey;size:32"` + Email string `gorm:"size:255;uniqueIndex;not null"` + Name string `gorm:"size:255"` + PasswordHash string `gorm:"size:255"` + Role string `gorm:"size:32;index;not null"` + Status string `gorm:"size:32;index;not null"` + Credits float64 `gorm:"not null;default:0"` + Notes string `gorm:"type:text"` + InviteCode string `gorm:"size:32;uniqueIndex"` + InvitedBy *string `gorm:"size:32;index"` + InviteRewardDone bool `gorm:"not null;default:false"` + InviteRewardAt *time.Time + CheckinLast string `gorm:"size:32"` + CheckinStreak int `gorm:"not null;default:0"` + LastLoginAt *time.Time + LastLoginIP string `gorm:"size:128"` + CreatedAt time.Time + UpdatedAt time.Time + APIKeys []APIKey `gorm:"foreignKey:UserID"` +} + +type APIKey struct { + ID string `gorm:"primaryKey;size:32"` + UserID string `gorm:"size:32;index;not null"` + Name string `gorm:"size:100;not null"` + KeyPreview string `gorm:"size:32;not null"` + KeyHash string `gorm:"size:255;uniqueIndex;not null"` + CreatedAt time.Time + LastUsedAt *time.Time +} + +type ShowcaseItem struct { + ID string `gorm:"primaryKey;size:32"` + Kind string `gorm:"size:32;index;not null"` + Title string `gorm:"size:255"` + Subtitle string `gorm:"size:255"` + Prompt string `gorm:"type:text"` + Gradient string `gorm:"type:text"` + Span string `gorm:"size:100"` + Image string `gorm:"size:500;index"` + Weight int `gorm:"not null;default:0"` + CreatedAt time.Time + UpdatedAt time.Time +} + +type EventLog struct { + ID string `gorm:"primaryKey;size:32"` + TS time.Time `gorm:"index;not null"` + Kind string `gorm:"size:32;index;not null"` + Status string `gorm:"size:32;index;not null"` + Model string `gorm:"size:255;index"` + Provider string `gorm:"size:100;index"` + Prompt string `gorm:"type:text"` + Ratio string `gorm:"size:32"` + Resolution string `gorm:"size:32"` + Duration string `gorm:"size:32"` + Refs int `gorm:"not null;default:0"` + RefFiles datatypes.JSON `gorm:"type:jsonb"` // relative paths of saved reference images, for回显 on reload + Source string `gorm:"size:32;index"` + // AccountID is the provider token/account chosen to fulfil this generation, + // stamped when the upstream call begins. Drives the accounts view's live + // in-flight count (pending events per account) and lets an abandoned-event + // purge attribute the failure back to the account it was using. + AccountID string `gorm:"size:64;index"` + UserID string `gorm:"size:32;index"` + Cost float64 `gorm:"not null;default:0"` + // Refunded marks that this event's up-front charge has already been credited + // back, so the normal failure path and the abandoned-purge sweep can never + // double-refund the same generation. + Refunded bool `gorm:"not null;default:false"` + ElapsedMS int `gorm:"not null;default:0"` + File string `gorm:"size:500;index"` + Error string `gorm:"type:text"` + CreatedAt time.Time + UpdatedAt time.Time +} + +type ModelConfig struct { + ID string `gorm:"primaryKey;size:255"` + Type string `gorm:"size:32;index;not null"` + Name string `gorm:"size:255;not null"` + Provider string `gorm:"size:100;index;not null"` + Enabled bool `gorm:"not null;default:true"` + Ratios datatypes.JSON `gorm:"type:jsonb"` + Prices datatypes.JSONMap `gorm:"type:jsonb"` + Resolutions datatypes.JSON `gorm:"type:jsonb"` + ImageToImage bool `gorm:"not null;default:false"` + DurationPrices datatypes.JSONMap `gorm:"type:jsonb"` + // Agent (代理) pricing — optional overlay over Prices/DurationPrices. A tier + // left unset here means agent users pay the normal price for that tier; the + // set of *supported* tiers is always driven by Prices, not these. + PricesAgent datatypes.JSONMap `gorm:"type:jsonb;column:prices_agent"` + DurationPricesAgent datatypes.JSONMap `gorm:"type:jsonb;column:duration_prices_agent"` + Durations datatypes.JSON `gorm:"type:jsonb"` + MaxReferenceImages int `gorm:"not null;default:0"` + ReferenceMode string `gorm:"size:32;not null;default:'none'"` + // Weight controls display order in the model dropdown / admin list: higher + // weight floats to the top (matches ShowcaseItem.Weight semantics). Ties fall + // back to created_at desc. Default 0. + Weight int `gorm:"not null;default:0;index"` + CreatedAt time.Time + UpdatedAt time.Time +} + +type CDKCode struct { + Code string `gorm:"primaryKey;size:32"` + Amount int `gorm:"not null"` + Status string `gorm:"size:32;index;not null"` + Type string `gorm:"size:16;not null;default:normal;index"` // normal | marketing + BatchID string `gorm:"size:32;index"` // groups one generate call + Note string `gorm:"type:text"` + RedeemedBy *string `gorm:"size:32;index"` + RedeemedAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +type TokenAccount struct { + ID string `gorm:"primaryKey;size:64"` + Pool string `gorm:"size:64;index;not null"` + Value string `gorm:"type:text"` + Status string `gorm:"size:32;index;not null"` + Fails int `gorm:"not null;default:0"` + FailTotal int `gorm:"not null;default:0"` + SuccessTotal int `gorm:"not null;default:0"` + Dead bool `gorm:"not null;default:false"` + Meta datatypes.JSONMap `gorm:"type:jsonb"` + AddedAt *time.Time + LastUsedAt *time.Time + CachedQuotaResetAfter string `gorm:"size:128"` + QuotaRecoverAt *time.Time + // Adobe quota is tracked separately for image vs video. An account only + // enters the shared "quota" waiting status when BOTH are limited; a single + // limit leaves the account usable for the other kind. Recovery time is shared + // (QuotaRecoverAt / CachedQuotaResetAfter) since Adobe resets both at once. + ImageLimited bool `gorm:"not null;default:false"` + VideoLimited bool `gorm:"not null;default:false"` + AccountEmail string `gorm:"size:255"` + AccountDisplayName string `gorm:"size:255"` + CreatedAt time.Time + UpdatedAt time.Time +} + +type RefreshProfile struct { + ID string `gorm:"primaryKey;size:64"` + Name string `gorm:"size:255;not null"` + Pool string `gorm:"size:64;index;not null"` + Kind string `gorm:"size:64;index;not null"` + Cookie string `gorm:"type:text"` + Enabled bool `gorm:"not null;default:true"` + IntervalSeconds int `gorm:"not null;default:54000"` + ImportedAt *time.Time + LastAttemptAt *time.Time + LastSuccessAt *time.Time + LastError string `gorm:"type:text"` + NextRetryAt *time.Time + ConsecutiveFailures int `gorm:"not null;default:0"` + CreatedAt time.Time + UpdatedAt time.Time +} + +type SiteSetting struct { + Key string `gorm:"primaryKey;size:100"` + Value string `gorm:"type:text"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func AutoMigrateModels() []any { + return []any{ + &User{}, + &APIKey{}, + &ShowcaseItem{}, + &EventLog{}, + &ModelConfig{}, + &CDKCode{}, + &TokenAccount{}, + &RefreshProfile{}, + &SiteSetting{}, + } +} diff --git a/backend/internal/provider/adobe.zip b/backend/internal/provider/adobe.zip new file mode 100644 index 0000000000000000000000000000000000000000..5ef5be12df1df018bbbd6cc983c7ea022510ba4e GIT binary patch literal 11843 zcmaL71#lg`)}?*S%*@Qpbj%bpGc&}@j4|6WGsVmtGjr^iIc8=!X2&?mo%!zE``5gG zca@~>s(wmRZEdwyEoC_fNKC-r>!E8!|6h&&Q$Pbq0Vd{-rWP#jUI+j%$iJ`uRcNRq z0boR)1#GYDfwl$S?6%7PV!#4m0mrGc|HVN1H^ao;&6?TD@&AS5^kui*{XZyoH5H{b zR@8v6fnn)okR@>SHd*u<7~sWZ1xTW!#Hi7+t_lhmYmK?Z7Q4MyggkviOA!x#j~kwk z8wOl^1ey>QsXhQPiKCT;t1 zyw#h@`-xltuRzwKH0b&%L)aLi3ypMR7_X!Uvu8n`$4-l2amM%+*|WOhxB0TYO7x-k6;eS&+~by0T<^+jh-eCRvm zGHAHNh>E7##)BUxae_zUo9s~#(|+I7OD$D_Z21mbXn2bNA3kZ(+I8p?DgoG0jfViC*owtFQ8=X5Ifc45yb!)w;hN32mslnv z4xK@+m0D-a<+!k8NAz4L{d|LG!C5CrO8zZd!piQ1HY75lsM=p5WlBHq3;>%P?G)2r zs`yr#_0r@?tzs%+X0m^bPu-pxN>ZafTfXbhE{=dQd z|6Kqm{|;U=I~xlJw|@xVzboNy`2MXg1V5{-{YcBdt~*xIK&M+yBf~58$T(}4gl#wB zG0}!iMq!R-kh8BQm5V0EO_E!Dk9-Szk$cFQM-jA-`BLoQQNV8OneW>1;`Zn*haqrT z;@Y3NCyB9n_WPDsmfiA!J9bQAL=8fyO(o;{ELMn`#~B-GKJ(V@1;=i?fWSMzi!JB+ zZ2S?$22)i$Ndhi0b1Lc!$<9@R)gTZKWR^u0Btn`gd?Z!Z%J#D{0}m&F`zK8c!DK}8 z@qp_<1xffIod>uRcCo|uEvp_hEC2#5xOllAagQ?V8LW2Y^xk-k&Y5hI##LBAFktZ5 zY6y`~Xaf(qEMTCle{$Q~PN|H$VfvwHXR&GL)@yq@xa?oo|K{?oXFgQ6Bd%YWl}DL*Z+vmN}#H8A_|V%$XttDc2@;l7#ak@$BT~mqdR` zma@2|x^vovR6I@tdB!8Zhi;+1%MdG~U+VW9CC`U4@%LychD3M@%Xql11dUHeMlsls z+=SW87*B|nS3rzzaWD?NOasSr-p(?U%kUo4cKju1N3b%6{6|$HdB-Mg5IwU6ZZ+5S z-7Vqu0bpqD;NBF$8g$CI`Ea7wc+9TOR;s);dLb5~ZaWcf{RKJ(R>p<1EGDSMS7@5! z$qZWu75iQchxT>p8$&r4a4HaLKX!5k33FCPkx*Sv?E!OKb`h`e+Vem+JVq_jEby*r z$Q(e^ikb}eF%|G(+sI}(AsdBBC$Emmm)a03U|<4$YRv2=K`qWjrsbBreV(| z*$4GdQ)%37u?<^b=NFn-#-evWsFo>9N-nAeeZAMiO3|zTvG92As5IMvqeAFh@EWI7 z(cg#lwgRA8J_-ywco?=pbtT(f81RsqXcq7BdOVb9#752)F6qgaV!VSGQa=Au_a*#H z%f5?!`0*PKye_NJurAm1gF8H*OHg`;f1-}tn0Wh0&vAGhaejg3;?`FGY{>60CnvdA z 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%0AX{5Szv( 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 dDJ}m&{!Poua!}BJ=N; 0 { + if first, ok := images[0].(map[string]any); ok { + if id := strings.TrimSpace(stringValue(first["id"])); id != "" { + return id, nil + } + } + } + if id := strings.TrimSpace(stringValue(payload["id"])); id != "" { + return id, nil + } + return "", errors.New("adobe upload missing blob id") +} + +func (c *Client) GenerateImage(ctx context.Context, token, modelID, prompt, aspectRatio, resolution string, blobIDs []string) ([]byte, map[string]any, error) { + client, err := c.newTLSClient() + if err != nil { + return nil, nil, err + } + + var lastBody []byte + var lastErr error + // Firefly Image 5 uses a different endpoint + request schema (modelVersion + // "image5", resolutionLevel, top-level aspectRatio label, no modelId/size). + endpoint := submitURL + var candidates []map[string]any + if modelID == "firefly-image-5" { + endpoint = image5SubmitURL + candidates = []map[string]any{buildImage5Payload(prompt, aspectRatio, resolution, blobIDs)} + } else { + candidates = BuildImagePayloadCandidates(modelID, prompt, aspectRatio, resolution, blobIDs) + } + for _, payload := range candidates { + respBody, pollURL, err := c.submitImage(ctx, client, token, prompt, endpoint, payload) + if err == nil { + meta, data, pollErr := c.pollImage(ctx, client, token, pollURL) + if pollErr != nil { + return nil, nil, pollErr + } + return data, meta, nil + } + lastBody = respBody + lastErr = err + if errors.Is(err, ErrAuth) || errors.Is(err, ErrQuotaExhausted) { + return nil, nil, err + } + } + // Preserve the temporary classification so the pool retries (overload / 5xx / + // rate-limit) instead of failing the request outright. + if errors.Is(lastErr, ErrTemporaryUpstream) { + return nil, nil, fmt.Errorf("%w: adobe submit: %s", ErrTemporaryUpstream, clip(lastBody, 300)) + } + return nil, nil, fmt.Errorf("adobe submit failed: %s", clip(lastBody, 300)) +} + +// GenerateVideo renders the clip and (when downloadResult) downloads the MP4. +// With downloadResult=false it returns nil bytes and the upstream presigned URL +// in meta["video_url"] — used by the async /v1/videos job, which proxies that URL +// on /content instead of persisting the file. +func (c *Client) GenerateVideo(ctx context.Context, token, engine, prompt, aspectRatio string, durationSeconds int, resolution, referenceMode, upstreamModel string, blobIDs []string, downloadResult bool) ([]byte, map[string]any, error) { + client, err := c.newTLSClient() + if err != nil { + return nil, nil, err + } + + payload := BuildVideoPayload(engine, prompt, aspectRatio, durationSeconds, resolution, referenceMode, upstreamModel, blobIDs) + endpoint := videoSubmitURL + if engine == "firefly-video" { + endpoint = fireflyVideoSubmitURL + } + respBody, pollURL, err := c.submitVideo(ctx, client, token, endpoint, payload) + if err != nil { + return nil, nil, err + } + _ = respBody + meta, data, pollErr := c.pollVideo(ctx, client, token, pollURL, downloadResult) + if pollErr != nil { + return nil, nil, pollErr + } + return data, meta, nil +} + +func (c *Client) FetchAccountProfile(ctx context.Context, token string) (map[string]any, error) { + token = strings.TrimSpace(token) + if token == "" { + return map[string]any{}, nil + } + client, err := c.newTLSClient() + if err != nil { + return nil, err + } + + for _, rawURL := range profileURLs { + req, err := http.NewRequest(http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + req.Header = http.Header{ + "authorization": {"Bearer " + token}, + "accept": {"application/json"}, + "user-agent": {defaultUserAgent}, + http.HeaderOrderKey: { + "authorization", + "accept", + "user-agent", + }, + } + + resp, err := client.Do(req) + if err != nil { + continue + } + body, readErr := io.ReadAll(resp.Body) + resp.Body.Close() + if readErr != nil || resp.StatusCode != 200 { + continue + } + + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + continue + } + email := strings.TrimSpace(stringValue(payload["email"])) + displayName := strings.TrimSpace(stringValue(payload["displayName"])) + if displayName == "" { + displayName = strings.TrimSpace(stringValue(payload["name"])) + } + if displayName == "" { + displayName = strings.TrimSpace(stringValue(payload["fullName"])) + } + userID := strings.TrimSpace(stringValue(payload["userId"])) + if userID == "" { + userID = strings.TrimSpace(stringValue(payload["authId"])) + } + if email != "" || displayName != "" || userID != "" { + return map[string]any{ + "email": emptyStringNil(email), + "display_name": emptyStringNil(displayName), + "user_id": emptyStringNil(userID), + }, nil + } + } + + return map[string]any{}, nil +} + +func (c *Client) FetchCreditsBalance(ctx context.Context, token string) (map[string]any, error) { + token = strings.TrimSpace(token) + if token == "" { + return map[string]any{ + "remaining": nil, + "used": nil, + "total": nil, + "available_until": nil, + "unknown": true, + "error": "empty token", + }, nil + } + + accountID := ExtractAccountID(token) + if accountID == "" { + return map[string]any{ + "remaining": nil, + "used": nil, + "total": nil, + "available_until": nil, + "unknown": true, + "error": "no account id", + }, nil + } + + client, err := c.newTLSClient() + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodGet, creditsURL, nil) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + req.Header = http.Header{ + "authorization": {"Bearer " + token}, + "x-api-key": {creditsAPIKey}, + "x-account-id": {accountID}, + "accept": {"application/json"}, + "content-type": {"application/json"}, + "user-agent": {defaultUserAgent}, + http.HeaderOrderKey: { + "authorization", + "x-api-key", + "x-account-id", + "accept", + "content-type", + "user-agent", + }, + } + + resp, err := client.Do(req) + if err != nil { + return map[string]any{ + "remaining": nil, + "used": nil, + "total": nil, + "available_until": nil, + "unknown": true, + "error": "network: " + err.Error(), + }, nil + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode == 401 { + return nil, ErrAuth + } + if resp.StatusCode != 200 { + return map[string]any{ + "remaining": nil, + "used": nil, + "total": nil, + "available_until": nil, + "unknown": true, + "error": fmt.Sprintf("http %d: %s", resp.StatusCode, clip(body, 160)), + }, nil + } + + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return map[string]any{ + "remaining": nil, + "used": nil, + "total": nil, + "available_until": nil, + "unknown": true, + "error": "non-json", + }, nil + } + + totalInfo, _ := payload["total"].(map[string]any) + quota, _ := totalInfo["quota"].(map[string]any) + return map[string]any{ + "remaining": intOrNil(quota["available"]), + "used": intOrNil(quota["used"]), + "total": intOrNil(quota["total"]), + "available_until": emptyStringNil(strings.TrimSpace(stringValue(totalInfo["availableUntil"]))), + "unknown": false, + "error": nil, + }, nil +} + +func (c *Client) submitImage(ctx context.Context, client tlsclient.HttpClient, token, prompt, endpoint string, payload map[string]any) ([]byte, string, error) { + body, err := json.Marshal(payload) + if err != nil { + return nil, "", err + } + req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return nil, "", err + } + req = req.WithContext(ctx) + req.Header = http.Header{ + "authorization": {"Bearer " + strings.TrimSpace(token)}, + "x-api-key": {c.apiKey}, + "content-type": {"application/json"}, + "accept": {"*/*"}, + "origin": {"https://firefly.adobe.com"}, + "referer": {"https://firefly.adobe.com/"}, + "accept-language": {"en-US,en;q=0.9"}, + "sec-ch-ua": {defaultSecCHUA}, + "sec-ch-ua-mobile": {"?0"}, + "sec-ch-ua-platform": {`"Windows"`}, + "sec-fetch-site": {"same-site"}, + "sec-fetch-mode": {"cors"}, + "sec-fetch-dest": {"empty"}, + "user-agent": {defaultUserAgent}, + "x-arp-session-id": {buildARPSessionID()}, + http.HeaderOrderKey: { + "authorization", + "x-api-key", + "content-type", + "accept", + "origin", + "referer", + "accept-language", + "sec-ch-ua", + "sec-ch-ua-mobile", + "sec-ch-ua-platform", + "sec-fetch-site", + "sec-fetch-mode", + "sec-fetch-dest", + "user-agent", + "x-nonce", + "x-arp-session-id", + }, + } + if nonce := buildSubmitNonce(token, prompt); nonce != "" { + req.Header.Set("x-nonce", nonce) + } + + resp, err := client.Do(req) + if err != nil { + return nil, "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err) + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", err + } + if resp.StatusCode == 401 || resp.StatusCode == 403 { + if strings.EqualFold(resp.Header.Get("x-access-error"), "taste_exhausted") { + return respBody, "", ErrQuotaExhausted + } + return respBody, "", fmt.Errorf("%w (submit %d %s: %s)", ErrAuth, resp.StatusCode, resp.Header.Get("x-access-error"), clip(respBody, 300)) + } + if resp.StatusCode == 429 || resp.StatusCode == 451 || resp.StatusCode >= 500 { + return respBody, "", ErrTemporaryUpstream + } + // "system under load" / timeout_error = adobe rate-limit/overload (can come on a + // non-5xx) — treat as temporary so the pool retries instead of failing. + if b := string(respBody); strings.Contains(b, "system under load") || strings.Contains(b, "timeout_error") { + return respBody, "", ErrTemporaryUpstream + } + if resp.StatusCode != 200 { + return respBody, "", errors.New("submit rejected") + } + + var payloadResp map[string]any + if err := json.Unmarshal(respBody, &payloadResp); err != nil { + return respBody, "", err + } + if override := strings.TrimSpace(resp.Header.Get("x-override-status-link")); override != "" { + return respBody, override, nil + } + if links, ok := payloadResp["links"].(map[string]any); ok { + if result, ok := links["result"].(map[string]any); ok { + if href := strings.TrimSpace(stringValue(result["href"])); href != "" { + return respBody, href, nil + } + } + if href := strings.TrimSpace(stringValue(links["result"])); href != "" { + return respBody, href, nil + } + } + return respBody, "", errors.New("submit ok but no poll url") +} + +func (c *Client) pollImage(ctx context.Context, client tlsclient.HttpClient, token, pollURL string) (map[string]any, []byte, error) { + start := time.Now() + for { + if time.Since(start) > 3*time.Minute { + return nil, nil, errors.New("adobe generation timed out") + } + + req, err := http.NewRequest(http.MethodGet, pollURL, nil) + if err != nil { + return nil, nil, err + } + req = req.WithContext(ctx) + req.Header = http.Header{ + "authorization": {"Bearer " + strings.TrimSpace(token)}, + "accept": {"*/*"}, + "origin": {"https://firefly.adobe.com"}, + "referer": {"https://firefly.adobe.com/"}, + "user-agent": {defaultUserAgent}, + http.HeaderOrderKey: { + "authorization", + "accept", + "origin", + "referer", + "user-agent", + }, + } + + resp, err := client.Do(req) + if err != nil { + return nil, nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err) + } + body, readErr := io.ReadAll(resp.Body) + resp.Body.Close() + if readErr != nil { + return nil, nil, readErr + } + if resp.StatusCode == 429 || resp.StatusCode == 451 || resp.StatusCode >= 500 { + return nil, nil, ErrTemporaryUpstream + } + if resp.StatusCode != 200 { + return nil, nil, fmt.Errorf("adobe poll failed: %d %s", resp.StatusCode, clip(body, 300)) + } + + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return nil, nil, err + } + if outputs, ok := payload["outputs"].([]any); ok && len(outputs) > 0 { + if first, ok := outputs[0].(map[string]any); ok { + if image, ok := first["image"].(map[string]any); ok { + if url := strings.TrimSpace(stringValue(image["presignedUrl"])); url != "" { + data, err := c.download(ctx, client, url) + if err != nil { + return nil, nil, err + } + return payload, data, nil + } + } + } + } + + status := strings.ToUpper(strings.TrimSpace(stringValue(payload["status"]))) + if status == "FAILED" || status == "CANCELLED" || status == "ERROR" { + return nil, nil, fmt.Errorf("adobe job failed: %s", clip(body, 300)) + } + time.Sleep(3 * time.Second) + } +} + +func (c *Client) submitVideo(ctx context.Context, client tlsclient.HttpClient, token, endpoint string, payload map[string]any) ([]byte, string, error) { + body, err := json.Marshal(payload) + if err != nil { + return nil, "", err + } + req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return nil, "", err + } + req = req.WithContext(ctx) + req.Header = http.Header{ + "authorization": {"Bearer " + strings.TrimSpace(token)}, + "x-api-key": {c.apiKey}, + "content-type": {"application/json"}, + "accept": {"*/*"}, + "origin": {"https://firefly.adobe.com"}, + "referer": {"https://firefly.adobe.com/"}, + "accept-language": {"en-US,en;q=0.9"}, + "sec-ch-ua": {defaultSecCHUA}, + "sec-ch-ua-mobile": {"?0"}, + "sec-ch-ua-platform": {`"Windows"`}, + "sec-fetch-site": {"same-site"}, + "sec-fetch-mode": {"cors"}, + "sec-fetch-dest": {"empty"}, + "user-agent": {defaultUserAgent}, + "x-arp-session-id": {buildARPSessionID()}, + http.HeaderOrderKey: { + "authorization", + "x-api-key", + "content-type", + "accept", + "origin", + "referer", + "accept-language", + "sec-ch-ua", + "sec-ch-ua-mobile", + "sec-ch-ua-platform", + "sec-fetch-site", + "sec-fetch-mode", + "sec-fetch-dest", + "user-agent", + "x-nonce", + "x-arp-session-id", + }, + } + // The working video submit (HAR) carries x-nonce just like the image submit. + if prompt, _ := payload["prompt"].(string); prompt != "" { + if nonce := buildSubmitNonce(token, prompt); nonce != "" { + req.Header.Set("x-nonce", nonce) + } + } + + resp, err := client.Do(req) + if err != nil { + return nil, "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", err + } + if resp.StatusCode == 401 || resp.StatusCode == 403 { + if strings.EqualFold(resp.Header.Get("x-access-error"), "taste_exhausted") { + return respBody, "", ErrQuotaExhausted + } + // Surface Adobe's response body — "adobe auth failed" alone hides whether + // it's a bad token, a missing scope, or a WAF/fingerprint block. + return respBody, "", fmt.Errorf("%w (%d %s: %s)", ErrAuth, resp.StatusCode, resp.Header.Get("x-access-error"), clip(respBody, 300)) + } + if resp.StatusCode == 429 || resp.StatusCode == 451 || resp.StatusCode >= 500 { + return respBody, "", ErrTemporaryUpstream + } + if resp.StatusCode != 200 { + return respBody, "", fmt.Errorf("video submit rejected: %d %s", resp.StatusCode, clip(respBody, 300)) + } + + var payloadResp map[string]any + if err := json.Unmarshal(respBody, &payloadResp); err != nil { + return respBody, "", err + } + if override := strings.TrimSpace(resp.Header.Get("x-override-status-link")); override != "" { + return respBody, normalizeVideoPollURL(override), nil + } + if links, ok := payloadResp["links"].(map[string]any); ok { + if result, ok := links["result"].(map[string]any); ok { + if href := strings.TrimSpace(stringValue(result["href"])); href != "" { + return respBody, normalizeVideoPollURL(href), nil + } + } + if href := strings.TrimSpace(stringValue(links["result"])); href != "" { + return respBody, normalizeVideoPollURL(href), nil + } + } + return respBody, "", errors.New("video submit ok but no poll url") +} + +func (c *Client) pollVideo(ctx context.Context, client tlsclient.HttpClient, token, pollURL string, downloadResult bool) (map[string]any, []byte, error) { + start := time.Now() + for { + if time.Since(start) > 10*time.Minute { + return nil, nil, errors.New("adobe video generation timed out") + } + + req, err := http.NewRequest(http.MethodGet, pollURL, nil) + if err != nil { + return nil, nil, err + } + req = req.WithContext(ctx) + req.Header = http.Header{ + "authorization": {"Bearer " + strings.TrimSpace(token)}, + "accept": {"*/*"}, + "origin": {"https://firefly.adobe.com"}, + "referer": {"https://firefly.adobe.com/"}, + "user-agent": {defaultUserAgent}, + http.HeaderOrderKey: { + "authorization", + "accept", + "origin", + "referer", + "user-agent", + }, + } + + resp, err := client.Do(req) + if err != nil { + return nil, nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err) + } + body, readErr := io.ReadAll(resp.Body) + resp.Body.Close() + if readErr != nil { + return nil, nil, readErr + } + if resp.StatusCode == 401 || resp.StatusCode == 403 { + return nil, nil, fmt.Errorf("%w (%d %s: %s)", ErrAuth, resp.StatusCode, resp.Header.Get("x-access-error"), clip(body, 300)) + } + if resp.StatusCode == 429 || resp.StatusCode == 451 || resp.StatusCode >= 500 { + return nil, nil, ErrTemporaryUpstream + } + if resp.StatusCode != 200 { + return nil, nil, fmt.Errorf("adobe video poll failed: %d %s", resp.StatusCode, clip(body, 300)) + } + + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return nil, nil, err + } + if outputs, ok := payload["outputs"].([]any); ok && len(outputs) > 0 { + if first, ok := outputs[0].(map[string]any); ok { + if video, ok := first["video"].(map[string]any); ok { + if raw := strings.TrimSpace(stringValue(video["presignedUrl"])); raw != "" { + payload["video_url"] = raw + if !downloadResult { + return payload, nil, nil + } + data, err := c.download(ctx, client, raw) + if err != nil { + return nil, nil, err + } + return payload, data, nil + } + } + } + } + + status := strings.ToUpper(strings.TrimSpace(stringValue(payload["status"]))) + if status == "FAILED" || status == "CANCELLED" || status == "ERROR" { + return nil, nil, fmt.Errorf("adobe video job failed: %s", clip(body, 300)) + } + time.Sleep(3 * time.Second) + } +} + +func (c *Client) download(ctx context.Context, client tlsclient.HttpClient, url string) ([]byte, error) { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + req.Header = http.Header{ + "accept": {"*/*"}, + "user-agent": {defaultUserAgent}, + http.HeaderOrderKey: { + "accept", + "user-agent", + }, + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("adobe download failed: %d %s", resp.StatusCode, clip(body, 200)) + } + return io.ReadAll(resp.Body) +} + +func (c *Client) newTLSClient() (tlsclient.HttpClient, error) { + options := []tlsclient.HttpClientOption{ + tlsclient.WithTimeoutSeconds(60), + tlsclient.WithClientProfile(profiles.Chrome_133), + tlsclient.WithNotFollowRedirects(), + tlsclient.WithRandomTLSExtensionOrder(), + } + if c.proxy != "" { + options = append(options, tlsclient.WithProxyUrl(c.proxy)) + } + return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...) +} + +func exchangeCookieWithTLSClient(ctx context.Context, client tlsclient.HttpClient, cookie string) (*CookieExchangeResult, error) { + cookie = normalizeCookie(cookie) + if cookie == "" { + return nil, ErrAdobeCookieEmpty + } + + body := "client_id=" + clientID + "&guest_allowed=true&scope=" + strings.ReplaceAll(scopeValue, ",", "%2C") + req, err := http.NewRequest(http.MethodPost, refreshURL, strings.NewReader(body)) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + req.Header = http.Header{ + "accept": {"*/*"}, + "accept-language": {"zh-CN,zh;q=0.9"}, + "content-type": {"application/x-www-form-urlencoded;charset=UTF-8"}, + "cookie": {cookie}, + "origin": {"https://firefly.adobe.com"}, + "referer": {"https://firefly.adobe.com/"}, + "user-agent": {defaultUserAgent}, + http.HeaderOrderKey: { + "accept", + "accept-language", + "content-type", + "cookie", + "origin", + "referer", + "user-agent", + }, + } + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("adobe cookie exchange network error: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("adobe cookie exchange upstream %d: %s", resp.StatusCode, clip(respBody, 200)) + } + var payload map[string]any + if err := json.Unmarshal(respBody, &payload); err != nil { + return nil, fmt.Errorf("adobe cookie exchange invalid json: %w", err) + } + token := strings.TrimSpace(stringValue(payload["access_token"])) + if token == "" { + return nil, errors.New("adobe cookie exchange missing access_token") + } + return &CookieExchangeResult{ + AccessToken: token, + ExpiresIn: intValue(payload["expires_in"]), + Raw: payload, + }, nil +} + +func buildSubmitNonce(token, prompt string) string { + claims := decodeJWTPayload(token) + userID := strings.TrimSpace(stringValue(claims["user_id"])) + if userID == "" { + userID = strings.TrimSpace(stringValue(claims["aa_id"])) + } + if userID == "" { + userID = strings.TrimSpace(stringValue(claims["sub"])) + } + prompt = strings.TrimSpace(prompt) + if userID == "" || prompt == "" { + return "" + } + if len(prompt) > 256 { + prompt = prompt[:256] + } + sum := sha256.Sum256([]byte(userID + "-" + prompt)) + return hex.EncodeToString(sum[:]) +} + +func ExtractAccountID(token string) string { + claims := decodeJWTPayload(token) + userID := strings.TrimSpace(stringValue(claims["user_id"])) + if userID == "" { + userID = strings.TrimSpace(stringValue(claims["aa_id"])) + } + if userID == "" { + userID = strings.TrimSpace(stringValue(claims["sub"])) + } + return userID +} + +func normalizeVideoPollURL(raw string) string { + if strings.TrimSpace(raw) == "" { + return raw + } + parsed, err := url.Parse(raw) + if err != nil { + return raw + } + host := parsed.Hostname() + if !strings.HasPrefix(host, "firefly-epo") { + return raw + } + parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") + if len(parts) == 0 { + return raw + } + jobID := strings.TrimSpace(parts[len(parts)-1]) + hostSuffix := strings.TrimPrefix(host, "firefly-epo") + hostSuffix = strings.SplitN(hostSuffix, ".", 2)[0] + if len(hostSuffix) != 4 { + return raw + } + for _, ch := range hostSuffix { + if ch < '0' || ch > '9' { + return raw + } + } + return "https://bks-epo" + hostSuffix + ".adobe.io/v2/jobs/result/" + jobID + "?host=" + parsed.Host + "/" +} + +func clip(v []byte, n int) string { + s := strings.TrimSpace(string(v)) + if len(s) <= n { + return s + } + return s[:n] +} diff --git a/backend/internal/provider/adobe/payloads.go b/backend/internal/provider/adobe/payloads.go new file mode 100644 index 0000000..ff884cb --- /dev/null +++ b/backend/internal/provider/adobe/payloads.go @@ -0,0 +1,466 @@ +package adobe + +import ( + "encoding/json" + "strings" + "time" +) + +type modelSpec struct { + UpstreamModelID string + UpstreamModelVersion string +} + +var lumaSize = map[string]map[string][2]int{ + "720p": { + "21:9": {1280, 548}, "16:9": {1280, 720}, "4:3": {960, 720}, + "1:1": {720, 720}, "3:4": {720, 960}, "9:16": {720, 1280}, "9:21": {548, 1280}, + }, + "1080p": { + "21:9": {1920, 822}, "16:9": {1920, 1080}, "4:3": {1440, 1080}, + "1:1": {1080, 1080}, "3:4": {1080, 1440}, "9:16": {1080, 1920}, "9:21": {822, 1920}, + }, + "4k": { + "21:9": {3840, 1646}, "16:9": {3840, 2160}, "4:3": {2880, 2160}, + "1:1": {2160, 2160}, "3:4": {2160, 2880}, "9:16": {2160, 3840}, "9:21": {1646, 3840}, + }, +} + +var gptImageSize = map[string]map[string][2]int{ + "1K": {"1:1": {1024, 1024}, "5:4": {1120, 896}, "9:16": {720, 1280}, "21:9": {1456, 624}, "16:9": {1280, 720}, "4:3": {1152, 864}, "3:2": {1248, 832}, "4:5": {896, 1120}, "3:4": {864, 1152}, "2:3": {832, 1248}}, + "2K": {"1:1": {2048, 2048}, "5:4": {2240, 1792}, "9:16": {1440, 2560}, "21:9": {3024, 1296}, "16:9": {2560, 1440}, "4:3": {2304, 1728}, "3:2": {2496, 1664}, "4:5": {1792, 2240}, "3:4": {1728, 2304}, "2:3": {1664, 2496}}, + "4K": {"1:1": {2880, 2880}, "5:4": {3200, 2560}, "9:16": {2160, 3840}, "21:9": {3696, 1584}, "16:9": {3840, 2160}, "4:3": {3264, 2448}, "3:2": {3504, 2336}, "4:5": {2560, 3200}, "3:4": {2448, 3264}, "2:3": {2336, 3504}}, +} + +var fluxSize = map[string][2]int{ + "1:1": {1024, 1024}, + "16:9": {1408, 768}, + "9:16": {768, 1408}, + "4:3": {1280, 896}, + "3:4": {896, 1280}, +} + +var defaultSize = map[string]map[string][2]int{ + "1K": {"1:1": {1024, 1024}, "1:8": {384, 3072}, "1:4": {512, 2048}, "16:9": {1360, 768}, "9:16": {768, 1360}, "4:1": {2048, 512}, "4:3": {1152, 864}, "3:4": {864, 1152}, "8:1": {3072, 384}}, + "2K": {"1:1": {2048, 2048}, "1:8": {768, 6144}, "1:4": {1024, 4096}, "16:9": {2752, 1536}, "9:16": {1536, 2752}, "4:1": {4096, 1024}, "4:3": {2048, 1536}, "3:4": {1536, 2048}, "8:1": {6144, 768}}, + "4K": {"1:1": {4096, 4096}, "1:8": {1536, 12288}, "1:4": {2048, 8192}, "16:9": {5504, 3072}, "9:16": {3072, 5504}, "4:1": {8192, 2048}, "4:3": {4096, 3072}, "3:4": {3072, 4096}, "8:1": {12288, 1536}}, +} + +func ResolveModelSpec(modelID string) modelSpec { + switch modelID { + case "firefly-gpt-image", "firefly-gpt-image-2": + return modelSpec{UpstreamModelID: "gpt-image", UpstreamModelVersion: "2"} + case "flux-kontext-max": + return modelSpec{UpstreamModelID: "flux", UpstreamModelVersion: "fluxKontextMax"} + default: + return modelSpec{UpstreamModelID: "gemini-flash", UpstreamModelVersion: "nano-banana-3"} + } +} + +// buildImage5Payload builds the Adobe Firefly Image 5 request. It uses a distinct +// schema from the firefly-3p models: NO modelId/size, a top-level aspectRatio +// string label and a resolutionLevel (1K→1MP, 2K→4MP). Mirrors a captured +// working image-v5.ff.adobe.io request. +func buildImage5Payload(prompt, aspectRatio, resolution string, blobIDs []string) map[string]any { + p := map[string]any{ + "n": 1, + "seeds": []int{int(time.Now().Unix()) % 999999}, + "output": map[string]any{"storeInputs": true}, + "prompt": prompt, + "referenceBlobs": []any{}, + "modelSpecificPayload": map[string]any{"locale": "en-US", "prompt_reasoner": "quality"}, + "modelVersion": "image5", + "resolutionLevel": image5ResolutionLevel(resolution), + "generationMetadata": map[string]any{"module": "text2image", "submodule": "ff-image-generate"}, + } + if len(blobIDs) > 0 { + // Instruct-edit: aspect ratio is derived from the reference image; sending + // aspectRatio is rejected with a validation_error. + p["referenceBlobs"] = blobRefs(blobIDs, "general") + } else { + p["aspectRatio"] = defaultString(aspectRatio, "1:1") + } + return p +} + +// image5ResolutionLevel maps the UI resolution tier to Image 5's megapixel level. +func image5ResolutionLevel(resolution string) string { + switch strings.ToUpper(strings.TrimSpace(resolution)) { + case "1K": + return "1MP" + case "2K": + return "4MP" + default: + return "4MP" + } +} + +func BuildImagePayloadCandidates(modelID, prompt, aspectRatio, outputResolution string, blobIDs []string) []map[string]any { + spec := ResolveModelSpec(modelID) + ratio := defaultString(aspectRatio, "1:1") + resolution := defaultString(outputResolution, "2K") + + switch spec.UpstreamModelID { + case "gpt-image": + return buildGPTImagePayloads(spec, prompt, ratio, resolution, blobIDs) + case "flux": + return buildFluxPayloads(spec, prompt, ratio, blobIDs) + default: + return buildDefaultPayloads(spec, prompt, ratio, resolution, blobIDs) + } +} + +func buildGPTImagePayloads(spec modelSpec, prompt, ratio, resolution string, blobIDs []string) []map[string]any { + size := getSize(gptImageSize, resolution, ratio, "1:1") + // Mirrors the captured working gpt-image request shape: modelSpecificPayload.size, + // generationSettings.detailLevel 3, and NO top-level size / outputResolution + // (sending those got 403). Keeps the chosen size via modelSpecificPayload.size + // ("WxH") rather than "auto". + base := map[string]any{ + "modelId": spec.UpstreamModelID, + "modelVersion": spec.UpstreamModelVersion, + "n": 1, + "prompt": prompt, + "seeds": []int{int(time.Now().Unix()) % 999999}, + "output": map[string]any{"storeInputs": true}, + "referenceBlobs": []any{}, + "generationMetadata": map[string]any{"module": "text2image", "submodule": "ff-image-generate"}, + "modelSpecificPayload": map[string]any{"size": sizeString(size)}, + "generationSettings": map[string]any{"detailLevel": 3}, + } + if len(blobIDs) == 0 { + return []map[string]any{base} + } + subject := cloneMap(base) + subject["referenceBlobs"] = blobRefs(blobIDs, "subject") + return []map[string]any{subject} +} + +func buildFluxPayloads(spec modelSpec, prompt, ratio string, blobIDs []string) []map[string]any { + size := fluxSize[ratio] + if size == [2]int{} { + size = fluxSize["1:1"] + } + base := map[string]any{ + "modelId": spec.UpstreamModelID, + "modelVersion": spec.UpstreamModelVersion, + "n": 1, + "prompt": prompt, + "size": map[string]any{"width": size[0], "height": size[1]}, + "seeds": []int{int(time.Now().Unix()) % 999999}, + "output": map[string]any{"storeInputs": true}, + "referenceBlobs": []any{}, + "modelSpecificPayload": map[string]any{ + "prompt_upsampling": true, + "safety_tolerance": 2, + "aspect_ratio": ratio, + }, + "generationMetadata": map[string]any{"module": "text2image", "submodule": "ff-image-generate"}, + } + if len(blobIDs) == 0 { + return []map[string]any{base} + } + edited := cloneMap(base) + edited["generationMetadata"] = map[string]any{"module": "image2image", "submodule": "ff-image-generate"} + edited["referenceBlobs"] = blobRefs(blobIDs, "general") + return []map[string]any{edited} +} + +func buildDefaultPayloads(spec modelSpec, prompt, ratio, resolution string, blobIDs []string) []map[string]any { + size := getSize(defaultSize, resolution, ratio, "16:9") + // Shape mirrors a captured working firefly.adobe.com request exactly: top-level + // size object, modelSpecificPayload only {parameters:{addWatermark:false}}, + // groundSearch:false, module "text2image" (even with a reference blob). NO + // skipCai and NO modelSpecificPayload.aspectRatio — sending those got 403. + base := map[string]any{ + "modelId": spec.UpstreamModelID, + "modelVersion": spec.UpstreamModelVersion, + "n": 1, + "prompt": prompt, + "size": map[string]any{"width": size[0], "height": size[1]}, + "seeds": []int{int(time.Now().Unix()) % 999999}, + "groundSearch": false, + "output": map[string]any{"storeInputs": true}, + "generationMetadata": map[string]any{ + "module": "text2image", + "submodule": "ff-image-generate", + }, + "modelSpecificPayload": map[string]any{ + "parameters": map[string]any{"addWatermark": false}, + }, + } + if len(blobIDs) == 0 { + base["referenceBlobs"] = []any{} + return []map[string]any{base} + } + edited := cloneMap(base) + edited["referenceBlobs"] = blobRefs(blobIDs, "general") + return []map[string]any{edited} +} + +func getSize(table map[string]map[string][2]int, resolution, ratio, fallbackRatio string) [2]int { + level := defaultString(resolution, "2K") + levelTable, ok := table[level] + if !ok { + levelTable = table["2K"] + } + size, ok := levelTable[ratio] + if !ok { + size = levelTable[fallbackRatio] + } + return size +} + +func sizeString(size [2]int) string { + return itoa(size[0]) + "x" + itoa(size[1]) +} + +func blobRefs(ids []string, usage string) []any { + out := make([]any, 0, len(ids)) + for _, id := range ids { + out = append(out, map[string]any{"id": id, "usage": usage}) + } + return out +} + +func referenceImagesByID(ids []string) []any { + out := make([]any, 0, len(ids)) + for _, id := range ids { + out = append(out, map[string]any{"id": id}) + } + return out +} + +func referenceImagesByLocal(ids []string) []any { + out := make([]any, 0, len(ids)) + for _, id := range ids { + out = append(out, map[string]any{"localBlobRef": id}) + } + return out +} + +func cloneMap(in map[string]any) map[string]any { + out := make(map[string]any, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +func BuildVideoPayload(engine, prompt, aspectRatio string, durationSeconds int, resolution, referenceMode, upstreamModel string, blobIDs []string) map[string]any { + seedVal := int(time.Now().Unix()) % 999999 + engine = defaultString(engine, "sora2") + resolution = defaultString(resolution, "720p") + aspectRatio = defaultString(aspectRatio, "16:9") + if durationSeconds <= 0 { + durationSeconds = 5 + } + + switch engine { + case "firefly-video": + // Firefly-native video model — a distinct schema (mirrors a captured + // working video-v1.ff.adobe.io request): sizes[] carries width/height + + // numFrames (numFrames encodes duration, ~25.6fps so 5s = 128), and + // reference frames go under image.conditions with placement.start + // (0 = first frame / 首帧, 1 = last frame / 末帧). NO modelId / version / + // engine / duration / referenceBlobs fields. + w, h, frames := fireflyVideoSize(aspectRatio, resolution, durationSeconds) + payload := map[string]any{ + "addOnTransparentBackground": false, + "prompt": prompt, + "seeds": []int{seedVal}, + "sizes": []any{map[string]any{"width": w, "height": h, "numFrames": frames}}, + "videoSettings": map[string]any{}, + "locale": "en-US", + "generationMetadata": map[string]any{"module": "text2video", "submodule": "ff-video-generate"}, + "output": map[string]any{"storeInputs": true}, + } + if len(blobIDs) > 0 { + conds := make([]any, 0, 2) + conds = append(conds, map[string]any{ + "source": map[string]any{"id": blobIDs[0]}, + "placement": map[string]any{"start": 0}, + }) + if len(blobIDs) > 1 { + conds = append(conds, map[string]any{ + "source": map[string]any{"id": blobIDs[1]}, + "placement": map[string]any{"start": 1}, + }) + } + payload["image"] = map[string]any{"conditions": conds} + } + return payload + case "veo31-fast", "veo31-standard": + modelVersion := "3.1-fast-generate" + if engine == "veo31-standard" { + modelVersion = "3.1-generate" + } + // Shape mirrors a captured working firefly.adobe.com video request: flat + // top-level duration / negativePrompt / generateAudio, submodule set, and + // NO `n` / NO modelSpecificPayload (sending those got 403). + payload := map[string]any{ + "modelId": "veo", + "modelVersion": modelVersion, + "size": videoSize(aspectRatio, resolution), + "seeds": []int{seedVal}, + "prompt": prompt, + "negativePrompt": "", + "duration": durationSeconds, + "generateAudio": false, + "generationMetadata": map[string]any{ + "module": "text2video", + "submodule": "ff-video-generate", + }, + "output": map[string]any{"storeInputs": true}, + "referenceBlobs": []any{}, + } + if len(blobIDs) > 0 { + payload["generationMetadata"] = map[string]any{"module": "image2video", "submodule": "ff-video-generate"} + refs := make([]any, 0, min(len(blobIDs), 2)) + for idx, id := range blobIDs[:min(len(blobIDs), 2)] { + refs = append(refs, map[string]any{"id": id, "usage": "general", "promptReference": idx + 1}) + } + payload["referenceBlobs"] = refs + } + return payload + case "luma": + payload := map[string]any{ + "modelId": "luma", + "modelVersion": "3.14-ray", + "size": lumaVideoSize(aspectRatio, resolution), + "mode": "flex_2", + "prompt": prompt, + "negativePrompt": "", + "duration": durationSeconds, + "generationMetadata": map[string]any{ + "module": "text2video", + "submodule": "ff-video-generate", + }, + "modelSpecificPayload": map[string]any{ + "resolution": strings.ToLower(resolution), + "aspect_ratio": aspectRatio, + }, + "output": map[string]any{"storeInputs": true}, + } + if len(blobIDs) > 0 { + payload["generationMetadata"] = map[string]any{ + "module": "image2video", + "submodule": "ff-video-generate", + } + refs := make([]any, 0, min(len(blobIDs), 2)) + for idx, id := range blobIDs[:min(len(blobIDs), 2)] { + refs = append(refs, map[string]any{"id": id, "usage": "frame", "order": idx + 1}) + } + payload["referenceBlobs"] = refs + } + return payload + default: + upstream := defaultString(upstreamModel, "openai:firefly:colligo:sora2") + payload := map[string]any{ + "n": 1, + "seeds": []int{seedVal}, + "modelId": "sora", + "modelVersion": "sora-2", + "size": videoSize(aspectRatio, resolution), + "duration": durationSeconds, + "fps": 24, + "prompt": buildVideoPromptJSON(prompt, durationSeconds), + "generationMetadata": map[string]any{"module": "text2video"}, + "model": upstream, + "generateAudio": true, + "generateLoop": false, + "transparentBackground": false, + "seed": itoa(seedVal), + "locale": "en-US", + "camera": map[string]any{"angle": "none", "shotSize": "none", "motion": nil, "promptStyle": nil}, + "negativePrompt": "", + "jobMode": "standard", + "debugGenerationEndpoint": "", + "referenceBlobs": []any{}, + "referenceFrames": []any{}, + "referenceVideo": nil, + "cameraMotionReferenceVideo": nil, + "characterReference": nil, + "editReferenceVideo": nil, + "output": map[string]any{"storeInputs": true}, + } + if len(blobIDs) > 0 { + firstID := blobIDs[0] + payload["generationMetadata"] = map[string]any{"module": "image2video"} + payload["referenceBlobs"] = []any{ + map[string]any{"id": firstID, "usage": "general", "promptReference": 1}, + } + payload["referenceFrames"] = []any{map[string]any{"localBlobRef": firstID}, nil} + } + return payload + } +} + +// fireflyVideoSizeTable maps the firefly-video resolution tier + aspect ratio to +// pixel dimensions. Only 1080p 9:16 (1080x1920) is HAR-confirmed; the rest follow +// the standard 540p/720p/1080p grid for each ratio. +var fireflyVideoSizeTable = map[string]map[string][2]int{ + "540p": {"16:9": {960, 540}, "1:1": {540, 540}, "9:16": {540, 960}}, + "720p": {"16:9": {1280, 720}, "1:1": {720, 720}, "9:16": {720, 1280}}, + "1080p": {"16:9": {1920, 1080}, "1:1": {1080, 1080}, "9:16": {1080, 1920}}, +} + +// fireflyVideoSize returns width, height and numFrames. numFrames encodes the +// clip length (~25.6fps; 5s = 128 frames, HAR-confirmed). +func fireflyVideoSize(aspectRatio, resolution string, durationSeconds int) (int, int, int) { + table, ok := fireflyVideoSizeTable[strings.ToLower(defaultString(resolution, "1080p"))] + if !ok { + table = fireflyVideoSizeTable["1080p"] + } + wh, ok := table[defaultString(aspectRatio, "9:16")] + if !ok { + wh = table["9:16"] + } + frames := durationSeconds * 128 / 5 + if frames <= 0 { + frames = 128 + } + return wh[0], wh[1], frames +} + +func videoSize(aspectRatio, resolution string) map[string]any { + if strings.EqualFold(resolution, "1080p") { + if aspectRatio == "16:9" { + return map[string]any{"width": 1920, "height": 1080} + } + return map[string]any{"width": 1080, "height": 1920} + } + if aspectRatio == "16:9" { + return map[string]any{"width": 1280, "height": 720} + } + return map[string]any{"width": 720, "height": 1280} +} + +func lumaVideoSize(aspectRatio, resolution string) map[string]any { + table, ok := lumaSize[strings.ToLower(defaultString(resolution, "720p"))] + if !ok { + table = lumaSize["720p"] + } + size, ok := table[defaultString(aspectRatio, "16:9")] + if !ok { + size = table["16:9"] + } + return map[string]any{"width": size[0], "height": size[1]} +} + +func buildVideoPromptJSON(prompt string, durationSeconds int) string { + payload := map[string]any{ + "id": 1, + "duration_sec": durationSeconds, + "prompt_text": prompt, + } + b, _ := json.Marshal(payload) + return string(b) +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/backend/internal/provider/adobe/util.go b/backend/internal/provider/adobe/util.go new file mode 100644 index 0000000..273cf9d --- /dev/null +++ b/backend/internal/provider/adobe/util.go @@ -0,0 +1,144 @@ +package adobe + +import ( + "encoding/base64" + "encoding/hex" + "encoding/json" + "os" + "strconv" + "strings" + "time" + + "github.com/google/uuid" +) + +const ( + defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36" + defaultSecCHUA = `"Not:A-Brand";v="99", "Google Chrome";v="145", "Chromium";v="145"` +) + +func stringValue(v any) string { + switch x := v.(type) { + case string: + return x + case nil: + return "" + default: + return strings.TrimSpace(strings.ReplaceAll(toJSONScalar(x), "\n", " ")) + } +} + +func toJSONScalar(v any) string { + b, err := json.Marshal(v) + if err != nil { + return "" + } + return string(b) +} + +func intValue(v any) int { + switch x := v.(type) { + case int: + return x + case int64: + return int(x) + case float64: + return int(x) + case float32: + return int(x) + case json.Number: + n, _ := x.Int64() + return int(n) + case string: + n, _ := strconv.Atoi(strings.TrimSpace(x)) + return n + default: + return 0 + } +} + +func defaultString(v, fallback string) string { + v = strings.TrimSpace(v) + if v == "" { + return fallback + } + return v +} + +func itoa(v int) string { + return strconv.Itoa(v) +} + +func decodeJWTPayload(token string) map[string]any { + parts := strings.Split(strings.TrimSpace(token), ".") + if len(parts) < 2 { + return map[string]any{} + } + raw, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return map[string]any{} + } + var out map[string]any + if err := json.Unmarshal(raw, &out); err != nil { + return map[string]any{} + } + return out +} + +func buildARPSessionID() string { + raw := map[string]any{ + "sid": uuid.NewString(), + "ftr": randomHex(16) + "_" + strconv.FormatInt(time.Now().UnixMilli(), 10) + "_" + strconv.Itoa(os.Getpid()) + "_dUAL43-mnts-ants-d4_31ck__tt", + } + b, _ := json.Marshal(raw) + return base64.StdEncoding.EncodeToString(b) +} + +func randomHex(n int) string { + if n <= 0 { + return "" + } + buf := make([]byte, n) + now := time.Now().UnixNano() + for i := range buf { + buf[i] = byte(now >> ((i % 8) * 8)) + } + return hex.EncodeToString(buf) +} + +func intOrNil(v any) any { + switch x := v.(type) { + case nil: + return nil + case int: + return x + case int64: + return int(x) + case float64: + return int(x) + case float32: + return int(x) + case json.Number: + n, err := x.Int64() + if err != nil { + return nil + } + return int(n) + case string: + n, err := strconv.Atoi(strings.TrimSpace(x)) + if err != nil { + return nil + } + return n + default: + return nil + } +} + +func emptyStringNil(v string) any { + v = strings.TrimSpace(v) + if v == "" { + return nil + } + return v +} diff --git a/backend/internal/provider/chatgpt/client.go b/backend/internal/provider/chatgpt/client.go new file mode 100644 index 0000000..97961c2 --- /dev/null +++ b/backend/internal/provider/chatgpt/client.go @@ -0,0 +1,1158 @@ +package chatgpt + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" + stdhttp "net/http" + "net/url" + "path/filepath" + "strconv" + "strings" + "time" + + http "github.com/bogdanfinn/fhttp" + tlsclient "github.com/bogdanfinn/tls-client" + "github.com/bogdanfinn/tls-client/profiles" +) + +var ( + ErrAuth = errors.New("chatgpt auth failed") + ErrQuotaExhausted = errors.New("chatgpt quota exhausted") + ErrTemporaryUpstream = errors.New("chatgpt upstream temporary error") +) + +type Client struct { + proxy string + deviceID string + sessionID string +} + +type fileEntry struct { + FileID string + UploadURL string +} + +type uploadedReference struct { + FileID string + LibraryFileID string + FileName string + MimeType string + SizeBytes int + Width int + Height int +} + +func NewClient(proxy string) *Client { + return &Client{ + proxy: strings.TrimSpace(proxy), + deviceID: newUUID(), + sessionID: newUUID(), + } +} + +func (c *Client) SetProxy(proxy string) { + c.proxy = strings.TrimSpace(proxy) +} + +func (c *Client) GenerateImage(ctx context.Context, accessToken, prompt, model, aspectRatio, resolution string, refs [][]byte) ([]byte, map[string]any, error) { + session, err := c.newSession(accessToken) + if err != nil { + return nil, nil, err + } + + scriptSources, dataBuild, err := c.bootstrap(ctx, session) + if err != nil { + return nil, nil, err + } + reqs, err := c.getChatRequirements(ctx, session, accessToken, scriptSources, dataBuild) + if err != nil { + return nil, nil, err + } + effectivePrompt := injectSizeHint(prompt, aspectRatio, resolution) + uploadedRefs, err := c.uploadReferenceImages(ctx, session, accessToken, refs) + if err != nil { + return nil, nil, err + } + conduitToken, err := c.prepareImageConversation(ctx, session, accessToken, effectivePrompt, reqs, model, uploadedRefs) + if err != nil { + return nil, nil, err + } + conversationID, fileIDs, sedimentIDs, err := c.startImageGeneration(ctx, session, accessToken, effectivePrompt, reqs, conduitToken, model, uploadedRefs) + if err != nil { + return nil, nil, err + } + session, err = c.newSession(accessToken) + if err != nil { + return nil, nil, err + } + fileIDs, sedimentIDs, err = c.pollForImage(ctx, session, accessToken, conversationID, fileIDs, sedimentIDs, 180*time.Second) + if err != nil { + return nil, nil, err + } + urls, err := c.resolveImageURLs(ctx, session, accessToken, conversationID, fileIDs, sedimentIDs) + if err != nil { + return nil, nil, err + } + if len(urls) == 0 { + return nil, nil, errors.New("no image urls resolved") + } + images, err := c.downloadBytes(ctx, session, accessToken, urls) + if err != nil { + return nil, nil, err + } + if len(images) == 0 { + return nil, nil, errors.New("download produced no bytes") + } + return images[0], map[string]any{ + "provider": "chatgpt", + "model": model, + "conversation_id": conversationID, + }, nil +} + +func ExtractAccountInfo(token string) map[string]any { + claims := decodeJWTPayload(token) + profile, _ := claims["https://api.openai.com/profile"].(map[string]any) + auth, _ := claims["https://api.openai.com/auth"].(map[string]any) + return map[string]any{ + "email": emptyStringNil(strings.TrimSpace(stringValue(profile["email"]))), + "email_verified": profile["email_verified"] == true, + "plan_type": emptyStringNil(strings.TrimSpace(stringValue(auth["chatgpt_plan_type"]))), + "user_id": emptyStringNil(strings.TrimSpace(stringValue(auth["chatgpt_user_id"]))), + "issued_at": claims["iat"], + "expires_at": claims["exp"], + } +} + +func (c *Client) FetchImageQuota(ctx context.Context, accessToken string) (map[string]any, error) { + session, err := c.newSession(accessToken) + if err != nil { + return nil, err + } + path := "/backend-api/conversation/init" + body, _ := json.Marshal(map[string]any{ + "gizmo_id": nil, + "requested_default_model": nil, + "conversation_id": nil, + }) + req, err := http.NewRequest(http.MethodPost, baseURL+path, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + req.Header = c.headers(accessToken, path, map[string]string{ + "accept": "application/json", + "content-type": "application/json", + }) + resp, err := session.Do(req) + if err != nil { + return map[string]any{"remaining": nil, "reset_after": nil, "unknown": true, "error": "network: " + err.Error()}, nil + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode == 401 { + return map[string]any{"remaining": nil, "reset_after": nil, "unknown": true, "error": "token invalid", "auth_failed": true}, nil + } + if resp.StatusCode != 200 { + return map[string]any{"remaining": nil, "reset_after": nil, "unknown": true, "error": fmt.Sprintf("http %d: %s", resp.StatusCode, clip(respBody, 160))}, nil + } + var payload map[string]any + if err := json.Unmarshal(respBody, &payload); err != nil { + return map[string]any{"remaining": nil, "reset_after": nil, "unknown": true, "error": "non-json response"}, nil + } + limits, _ := payload["limits_progress"].([]any) + for _, raw := range limits { + item, _ := raw.(map[string]any) + if strings.TrimSpace(stringValue(item["feature_name"])) != "image_gen" { + continue + } + return map[string]any{ + "remaining": intOrNil(item["remaining"]), + "reset_after": emptyStringNil(strings.TrimSpace(stringValue(item["reset_after"]))), + "unknown": false, + "error": nil, + }, nil + } + return map[string]any{"remaining": nil, "reset_after": nil, "unknown": true, "error": nil}, nil +} + +type chatRequirements struct { + Token string + ProofToken string + TurnstileToken string +} + +func (c *Client) newSession(accessToken string) (tlsclient.HttpClient, error) { + options := []tlsclient.HttpClientOption{ + tlsclient.WithTimeoutSeconds(600), + // Match the Python reference (curl_cffi impersonate="chrome110"): the + // Chrome_133 JA3/JA4 was tripping Cloudflare on the bootstrap GET (403). + tlsclient.WithClientProfile(profiles.Chrome_110), + tlsclient.WithRandomTLSExtensionOrder(), + } + if c.proxy != "" { + options = append(options, tlsclient.WithProxyUrl(c.proxy)) + } + client, err := tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...) + if err != nil { + return nil, err + } + client.SetCookies(&url.URL{Scheme: "https", Host: "chatgpt.com"}, nil) + return client, nil +} + +func (c *Client) baseHeaders(accessToken string) http.Header { + return http.Header{ + "accept-language": {"zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6"}, + "oai-client-build-number": {defaultClientBuildNumber}, + "oai-client-version": {defaultClientVersion}, + "oai-device-id": {c.deviceID}, + "oai-language": {"zh-CN"}, + "oai-session-id": {c.sessionID}, + "origin": {baseURL}, + "priority": {"u=1, i"}, + "referer": {baseURL + "/"}, + "sec-ch-ua": {`"Microsoft Edge";v="149", "Chromium";v="149", "Not)A;Brand";v="24"`}, + "sec-ch-ua-arch": {`"x86"`}, + "sec-ch-ua-bitness": {`"64"`}, + "sec-ch-ua-full-version": {`"149.0.4022.69"`}, + "sec-ch-ua-full-version-list": {`"Microsoft Edge";v="149.0.4022.69", "Chromium";v="149.0.7827.115", "Not)A;Brand";v="24.0.0.0"`}, + "sec-ch-ua-mobile": {"?0"}, + "sec-ch-ua-model": {`""`}, + "sec-ch-ua-platform": {`"Windows"`}, + "sec-ch-ua-platform-version": {`"19.0.0"`}, + "sec-fetch-dest": {"empty"}, + "sec-fetch-mode": {"cors"}, + "sec-fetch-site": {"same-origin"}, + "user-agent": {defaultUserAgent}, + "authorization": {"Bearer " + strings.TrimSpace(accessToken)}, + } +} + +func (c *Client) headers(accessToken, path string, extra map[string]string) http.Header { + h := http.Header{ + "x-openai-target-path": {path}, + "x-openai-target-route": {path}, + } + for k, values := range c.baseHeaders(accessToken) { + h[k] = append([]string{}, values...) + } + for k, v := range extra { + h.Set(k, v) + } + return h +} + +func (c *Client) bootstrap(ctx context.Context, session tlsclient.HttpClient) ([]string, string, error) { + req, err := http.NewRequest(http.MethodGet, baseURL+"/", nil) + if err != nil { + return nil, "", err + } + req = req.WithContext(ctx) + req.Header = http.Header{ + "user-agent": {defaultUserAgent}, + "accept": {"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"}, + "accept-language": {"zh-CN,zh;q=0.9,en;q=0.8"}, + "sec-ch-ua": {`"Microsoft Edge";v="143", "Chromium";v="143", "Not A(Brand";v="24"`}, + "sec-ch-ua-mobile": {"?0"}, + "sec-ch-ua-platform": {`"Windows"`}, + "sec-fetch-dest": {"document"}, + "sec-fetch-mode": {"navigate"}, + "sec-fetch-site": {"none"}, + "upgrade-insecure-requests": {"1"}, + } + resp, err := session.Do(req) + if err != nil { + return nil, "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", err + } + if err := ensureOK(resp.StatusCode, body, "bootstrap"); err != nil { + return nil, "", err + } + sources, dataBuild := parsePOWResources(string(body)) + return sources, dataBuild, nil +} + +func (c *Client) getChatRequirements(ctx context.Context, session tlsclient.HttpClient, accessToken string, scriptSources []string, dataBuild string) (*chatRequirements, error) { + pToken := buildLegacyRequirementsToken(defaultUserAgent, scriptSources, dataBuild) + path := "/backend-api/sentinel/chat-requirements/prepare" + reqBody, _ := json.Marshal(map[string]any{"p": pToken}) + req, err := http.NewRequest(http.MethodPost, baseURL+path, bytes.NewReader(reqBody)) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + req.Header = c.headers(accessToken, path, map[string]string{ + "content-type": "application/json", + }) + resp, err := session.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err) + } + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return nil, err + } + if err := ensureOK(resp.StatusCode, body, "chat_requirements_prepare"); err != nil { + return nil, err + } + var prepare map[string]any + if err := json.Unmarshal(body, &prepare); err != nil { + return nil, err + } + if arkose, _ := prepare["arkose"].(map[string]any); arkose["required"] == true { + return nil, errors.New("chat-requirements requires arkose token") + } + proofToken := "" + if powInfo, _ := prepare["proofofwork"].(map[string]any); powInfo["required"] == true { + proofToken, err = buildProofToken(strings.TrimSpace(stringValue(powInfo["seed"])), strings.TrimSpace(stringValue(powInfo["difficulty"])), defaultUserAgent, scriptSources, dataBuild) + if err != nil { + return nil, err + } + } + turnstileToken := "" + if tsInfo, _ := prepare["turnstile"].(map[string]any); tsInfo["required"] == true { + turnstileToken = solveTurnstileToken(strings.TrimSpace(stringValue(tsInfo["dx"])), pToken) + } + + path = "/backend-api/sentinel/chat-requirements/finalize" + finalizeBody, _ := json.Marshal(map[string]any{ + "prepare_token": stringValue(prepare["prepare_token"]), + "proof_token": proofToken, + "turnstile_token": turnstileToken, + }) + req, err = http.NewRequest(http.MethodPost, baseURL+path, bytes.NewReader(finalizeBody)) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + req.Header = c.headers(accessToken, path, map[string]string{ + "content-type": "application/json", + }) + resp, err = session.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err) + } + body, err = io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return nil, err + } + if err := ensureOK(resp.StatusCode, body, "chat_requirements_finalize"); err != nil { + return nil, err + } + var data map[string]any + if err := json.Unmarshal(body, &data); err != nil { + return nil, err + } + token := strings.TrimSpace(stringValue(data["token"])) + if token == "" { + return nil, errors.New("chat-requirements missing token") + } + return &chatRequirements{Token: token, ProofToken: proofToken, TurnstileToken: turnstileToken}, nil +} + +func (c *Client) imageHeaders(accessToken, path string, reqs *chatRequirements, conduitToken, accept string) http.Header { + h := c.headers(accessToken, path, map[string]string{ + "content-type": "application/json", + "accept": accept, + "openai-sentinel-chat-requirements-token": reqs.Token, + }) + if reqs.ProofToken != "" { + h.Set("openai-sentinel-proof-token", reqs.ProofToken) + } + // The real browser also sends the turnstile token on the conversation call + // (HAR confirms openai-sentinel-turnstile-token). We already compute it in + // chat-requirements/finalize but were dropping it here — send it so the + // request matches the browser and isn't extra-challenged by sentinel. + if reqs.TurnstileToken != "" { + h.Set("openai-sentinel-turnstile-token", reqs.TurnstileToken) + } + if conduitToken != "" { + h.Set("x-conduit-token", conduitToken) + } + if accept == "text/event-stream" { + h.Set("x-oai-turn-trace-id", newUUID()) + h.Set("oai-telemetry", "[1,null]") + } + return h +} + +func (c *Client) uploadReferenceImages(ctx context.Context, session tlsclient.HttpClient, accessToken string, refs [][]byte) ([]uploadedReference, error) { + if len(refs) == 0 { + return nil, nil + } + out := make([]uploadedReference, 0, len(refs)) + for i, ref := range refs { + meta, err := inspectReferenceImage(ref, i) + if err != nil { + return nil, err + } + entry, err := c.createFileEntry(ctx, session, accessToken, meta) + if err != nil { + return nil, err + } + if err := c.uploadRawFile(ctx, session, entry.UploadURL, meta.MimeType, ref); err != nil { + return nil, err + } + libraryFileID, err := c.processUploadStream(ctx, session, accessToken, entry.FileID, meta.FileName) + if err != nil { + return nil, err + } + meta.FileID = entry.FileID + meta.LibraryFileID = libraryFileID + out = append(out, meta) + } + return out, nil +} + +func imageModelSlug(model string) string { + if strings.EqualFold(strings.TrimSpace(model), "gpt-image-2") { + return "gpt-5-3" + } + return "auto" +} + +func inspectReferenceImage(data []byte, index int) (uploadedReference, error) { + if len(data) == 0 { + return uploadedReference{}, errors.New("empty reference image") + } + mimeType := normalizeImageMime(stdhttp.DetectContentType(data)) + if mimeType == "" { + return uploadedReference{}, errors.New("unsupported reference image type") + } + cfg, _, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return uploadedReference{}, errors.New("failed to decode reference image") + } + ext := extensionForMime(mimeType) + fileName := fmt.Sprintf("reference_%s_%02d%s", time.Now().UTC().Format("20060102_150405"), index+1, ext) + return uploadedReference{ + FileName: fileName, + MimeType: mimeType, + SizeBytes: len(data), + Width: cfg.Width, + Height: cfg.Height, + }, nil +} + +func normalizeImageMime(v string) string { + v = strings.ToLower(strings.TrimSpace(strings.Split(v, ";")[0])) + switch v { + case "image/jpeg", "image/jpg": + return "image/jpeg" + case "image/png": + return "image/png" + case "image/gif": + return "image/gif" + default: + return "" + } +} + +func extensionForMime(mimeType string) string { + switch strings.ToLower(strings.TrimSpace(mimeType)) { + case "image/jpeg": + return ".jpg" + case "image/png": + return ".png" + case "image/gif": + return ".gif" + default: + return filepath.Ext(mimeType) + } +} + +func (c *Client) createFileEntry(ctx context.Context, session tlsclient.HttpClient, accessToken string, meta uploadedReference) (*fileEntry, error) { + path := "/backend-api/files" + payload := map[string]any{ + "file_name": meta.FileName, + "file_size": meta.SizeBytes, + "use_case": "multimodal", + "timezone_offset_min": -480, + "reset_rate_limits": false, + "mime_type": meta.MimeType, + "entry_surface": "chat_composer", + "selection_method": "file_picker", + "client_resolved_mime_type": meta.MimeType, + "mime_resolution_source": "filename_extension", + "store_in_library": true, + "library_persistence_mode": "opportunistic", + } + body, _ := json.Marshal(payload) + req, err := http.NewRequest(http.MethodPost, baseURL+path, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + req.Header = c.headers(accessToken, path, map[string]string{ + "accept": "application/json", + "content-type": "application/json", + }) + resp, err := session.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err) + } + respBody, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return nil, err + } + if err := ensureOK(resp.StatusCode, respBody, "file_create"); err != nil { + return nil, err + } + var data map[string]any + if err := json.Unmarshal(respBody, &data); err != nil { + return nil, err + } + entry := &fileEntry{ + FileID: strings.TrimSpace(stringValue(data["file_id"])), + UploadURL: strings.TrimSpace(stringValue(data["upload_url"])), + } + if entry.FileID == "" || entry.UploadURL == "" { + return nil, errors.New("file_create missing upload payload") + } + return entry, nil +} + +func (c *Client) uploadRawFile(ctx context.Context, session tlsclient.HttpClient, uploadURL, mimeType string, data []byte) error { + req, err := http.NewRequest(http.MethodPut, uploadURL, bytes.NewReader(data)) + if err != nil { + return err + } + req = req.WithContext(ctx) + req.Header = http.Header{ + "accept": {"application/json, text/plain, */*"}, + "accept-language": {"zh-CN,zh;q=0.9,en;q=0.8"}, + "content-type": {mimeType}, + "origin": {baseURL}, + "referer": {baseURL + "/"}, + "sec-ch-ua": {`"Microsoft Edge";v="143", "Chromium";v="143", "Not A(Brand";v="24"`}, + "sec-ch-ua-mobile": {"?0"}, + "sec-ch-ua-platform": {`"Windows"`}, + "sec-fetch-dest": {"empty"}, + "sec-fetch-mode": {"cors"}, + "sec-fetch-site": {"cross-site"}, + "user-agent": {defaultUserAgent}, + "x-ms-blob-type": {"BlockBlob"}, + "x-ms-version": {"2020-04-08"}, + } + resp, err := session.Do(req) + if err != nil { + return fmt.Errorf("%w: %v", ErrTemporaryUpstream, err) + } + body, readErr := io.ReadAll(resp.Body) + resp.Body.Close() + if readErr != nil { + return readErr + } + if err := ensureOK(resp.StatusCode, body, "file_upload"); err != nil { + return err + } + return nil +} + +func (c *Client) processUploadStream(ctx context.Context, session tlsclient.HttpClient, accessToken, fileID, fileName string) (string, error) { + path := "/backend-api/files/process_upload_stream" + payload := map[string]any{ + "file_id": fileID, + "use_case": "multimodal", + "index_for_retrieval": false, + "file_name": fileName, + "library_persistence_mode": "opportunistic", + "entry_surface": "chat_composer", + "metadata": map[string]any{ + "store_in_library": true, + "is_temporary_chat": false, + "library_eligibility_reason": "eligible", + "is_project_thread": false, + }, + } + body, _ := json.Marshal(payload) + req, err := http.NewRequest(http.MethodPost, baseURL+path, bytes.NewReader(body)) + if err != nil { + return "", err + } + req = req.WithContext(ctx) + req.Header = c.headers(accessToken, path, map[string]string{ + "accept": "text/event-stream", + "content-type": "application/json", + }) + resp, err := session.Do(req) + if err != nil { + return "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if err := ensureOK(resp.StatusCode, respBody, "file_process_upload"); err != nil { + return "", err + } + } + defer resp.Body.Close() + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 0, 1024*32), 1024*1024) + libraryFileID := "" + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var item map[string]any + if err := json.Unmarshal([]byte(line), &item); err != nil { + continue + } + if extra, _ := item["extra"].(map[string]any); extra != nil { + if v := strings.TrimSpace(stringValue(extra["metadata_object_id"])); v != "" { + libraryFileID = v + } + } + if strings.TrimSpace(stringValue(item["event"])) == "file.processing.completed" { + break + } + } + if err := scanner.Err(); err != nil { + return "", err + } + return libraryFileID, nil +} + +func attachmentMimeTypes(refs []uploadedReference) []string { + if len(refs) == 0 { + return nil + } + out := make([]string, 0, len(refs)) + for _, ref := range refs { + if ref.MimeType != "" && !containsString(out, ref.MimeType) { + out = append(out, ref.MimeType) + } + } + return out +} + +func buildAttachmentMetadata(refs []uploadedReference) []map[string]any { + out := make([]map[string]any, 0, len(refs)) + for _, ref := range refs { + out = append(out, map[string]any{ + "id": ref.FileID, + "size": ref.SizeBytes, + "name": ref.FileName, + "mime_type": ref.MimeType, + "width": ref.Width, + "height": ref.Height, + "source": "local", + "library_file_id": emptyStringNil(ref.LibraryFileID), + "is_big_paste": false, + }) + } + return out +} + +func buildMultimodalParts(refs []uploadedReference, prompt string) []any { + out := make([]any, 0, len(refs)+1) + for _, ref := range refs { + out = append(out, map[string]any{ + "content_type": "image_asset_pointer", + "asset_pointer": "sediment://" + ref.FileID, + "size_bytes": ref.SizeBytes, + "width": ref.Width, + "height": ref.Height, + }) + } + out = append(out, prompt) + return out +} + +func (c *Client) prepareImageConversation(ctx context.Context, session tlsclient.HttpClient, accessToken, prompt string, reqs *chatRequirements, model string, refs []uploadedReference) (string, error) { + path := "/backend-api/f/conversation/prepare" + payload := map[string]any{ + "action": "next", + "parent_message_id": "client-created-root", + "model": imageModelSlug(model), + "timezone_offset_min": -480, + "timezone": "Asia/Shanghai", + "conversation_mode": map[string]any{"kind": "primary_assistant"}, + "system_hints": []string{"picture_v2"}, + "supports_buffering": true, + "supported_encodings": []string{"v1"}, + "client_contextual_info": map[string]any{"app_name": "chatgpt.com"}, + } + if len(refs) > 0 { + payload["client_prepare_state"] = "none" + payload["attachment_mime_types"] = attachmentMimeTypes(refs) + } else { + payload["client_prepare_state"] = "success" + payload["partial_query"] = map[string]any{ + "id": newUUID(), + "author": map[string]any{"role": "user"}, + "content": map[string]any{"content_type": "text", "parts": []string{prompt}}, + } + } + body, _ := json.Marshal(payload) + req, err := http.NewRequest(http.MethodPost, baseURL+path, bytes.NewReader(body)) + if err != nil { + return "", err + } + req = req.WithContext(ctx) + req.Header = c.imageHeaders(accessToken, path, reqs, "no-token", "application/json") + resp, err := session.Do(req) + if err != nil { + return "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err) + } + respBody, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return "", err + } + if err := ensureOK(resp.StatusCode, respBody, "image_prepare"); err != nil { + return "", err + } + var data map[string]any + if err := json.Unmarshal(respBody, &data); err != nil { + return "", err + } + return strings.TrimSpace(stringValue(data["conduit_token"])), nil +} + +func (c *Client) startImageGeneration(ctx context.Context, session tlsclient.HttpClient, accessToken, prompt string, reqs *chatRequirements, conduitToken, model string, refs []uploadedReference) (string, []string, []string, error) { + path := "/backend-api/f/conversation" + content := map[string]any{"content_type": "text", "parts": []string{prompt}} + metadata := map[string]any{ + "selected_github_repos": []any{}, + "selected_all_github_repos": false, + "system_hints": []string{"picture_v2"}, + "serialization_metadata": map[string]any{"custom_symbol_offsets": []any{}}, + } + if len(refs) > 0 { + content = map[string]any{ + "content_type": "multimodal_text", + "parts": buildMultimodalParts(refs, prompt), + } + metadata["attachments"] = buildAttachmentMetadata(refs) + } + payload := map[string]any{ + "action": "next", + "messages": []map[string]any{{ + "id": newUUID(), + "author": map[string]any{"role": "user"}, + "create_time": float64(time.Now().Unix()), + "content": content, + "metadata": metadata, + }}, + "parent_message_id": "client-created-root", + "model": imageModelSlug(model), + "client_prepare_state": "success", + "timezone_offset_min": -480, + "timezone": "Asia/Shanghai", + "conversation_mode": map[string]any{"kind": "primary_assistant"}, + "enable_message_followups": true, + "system_hints": []string{"picture_v2"}, + "supports_buffering": true, + "supported_encodings": []string{"v1"}, + "client_contextual_info": map[string]any{"is_dark_mode": false, "time_since_loaded": 1200, "page_height": 1072, "page_width": 1724, "pixel_ratio": 1.2, "screen_height": 1440, "screen_width": 2560, "app_name": "chatgpt.com"}, + "paragen_cot_summary_display_override": "allow", + "force_parallel_switch": "auto", + } + body, _ := json.Marshal(payload) + req, err := http.NewRequest(http.MethodPost, baseURL+path, bytes.NewReader(body)) + if err != nil { + return "", nil, nil, err + } + req = req.WithContext(ctx) + req.Header = c.imageHeaders(accessToken, path, reqs, conduitToken, "text/event-stream") + resp, err := session.Do(req) + if err != nil { + return "", nil, nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if err := ensureOK(resp.StatusCode, respBody, "image_start"); err != nil { + return "", nil, nil, err + } + } + defer resp.Body.Close() + + conversationID := "" + var fileIDs, sedimentIDs []string + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 0, 1024*1024), 8*1024*1024) + var chunks []string + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "data:") { + continue + } + payload := strings.TrimSpace(line[5:]) + if payload == "" { + continue + } + if payload == "[DONE]" { + break + } + chunks = append(chunks, payload) + if conversationID == "" { + if match := conversationIDRE.FindStringSubmatch(payload); len(match) >= 2 { + conversationID = match[1] + } + } + newFiles, newSeds := scanForIDs(payload) + fileIDs = mergeStrings(fileIDs, newFiles) + sedimentIDs = mergeStrings(sedimentIDs, newSeds) + } + if conversationID == "" { + joined := strings.Join(chunks, "\n") + if match := conversationIDRE.FindStringSubmatch(joined); len(match) >= 2 { + conversationID = match[1] + } + } + if conversationID == "" { + return "", nil, nil, errors.New("chatgpt SSE closed without conversation_id") + } + return conversationID, fileIDs, sedimentIDs, nil +} + +func (c *Client) getConversation(ctx context.Context, session tlsclient.HttpClient, accessToken, conversationID string) (map[string]any, error) { + path := "/backend-api/conversation/" + conversationID + req, err := http.NewRequest(http.MethodGet, baseURL+path, nil) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + req.Header = c.headers(accessToken, path, map[string]string{"accept": "application/json"}) + resp, err := session.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err) + } + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return nil, err + } + if err := ensureOK(resp.StatusCode, body, "conversation_get"); err != nil { + return nil, err + } + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return nil, err + } + return payload, nil +} + +func (c *Client) pollForImage(ctx context.Context, session tlsclient.HttpClient, accessToken, conversationID string, initialFileIDs, initialSedimentIDs []string, timeout time.Duration) ([]string, []string, error) { + start := time.Now() + fileIDs := append([]string{}, initialFileIDs...) + sedimentIDs := append([]string{}, initialSedimentIDs...) + if len(fileIDs) == 0 { + time.Sleep(8 * time.Second) + } else { + time.Sleep(2 * time.Second) + } + attempt := 0 + for time.Since(start) < timeout { + // Bail out immediately if the caller's context is already done — without + // this, a cancelled request spins here re-issuing doomed upstream calls + // (each fails instantly with "operation was canceled") until `timeout`. + if err := ctx.Err(); err != nil { + return nil, nil, err + } + attempt++ + conv, err := c.getConversation(ctx, session, accessToken, conversationID) + if err != nil { + if errors.Is(err, ErrTemporaryUpstream) { + time.Sleep(time.Duration(minInt(1< 0 || len(sedimentIDs) > 0 { + time.Sleep(2 * time.Second) + conv, err = c.getConversation(ctx, session, accessToken, conversationID) + if err == nil { + finalFiles, finalSeds := extractImageIDs(conv) + fileIDs = mergeStrings(fileIDs, finalFiles) + sedimentIDs = mergeStrings(sedimentIDs, finalSeds) + } + return fileIDs, sedimentIDs, nil + } + time.Sleep(5 * time.Second) + } + return nil, nil, errors.New("image poll timeout") +} + +func (c *Client) getFileDownloadURL(ctx context.Context, session tlsclient.HttpClient, accessToken, conversationID, fileID string, inline bool) (string, error) { + // Python parity (_get_file_download_url): GET /backend-api/files/{id}/download + // with NO query params. The old /files/download/{id}?conversation_id&inline + // form returned an inline stream URL that 403s with "File stream access denied". + _ = conversationID + _ = inline + path := "/backend-api/files/" + fileID + "/download" + req, err := http.NewRequest(http.MethodGet, baseURL+path, nil) + if err != nil { + return "", err + } + req = req.WithContext(ctx) + req.Header = c.headers(accessToken, path, map[string]string{"accept": "application/json"}) + resp, err := session.Do(req) + if err != nil { + return "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err) + } + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return "", err + } + if err := ensureOK(resp.StatusCode, body, "file_download_url"); err != nil { + return "", err + } + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return "", err + } + rawURL := strings.TrimSpace(stringValue(payload["download_url"])) + if rawURL == "" { + rawURL = strings.TrimSpace(stringValue(payload["url"])) + } + return rawURL, nil +} + +func (c *Client) resolveImageURLs(ctx context.Context, session tlsclient.HttpClient, accessToken, conversationID string, fileIDs, sedimentIDs []string) ([]string, error) { + var urls []string + for _, fileID := range fileIDs { + if fileID == "file_upload" { + continue + } + rawURL, err := c.getFileDownloadURL(ctx, session, accessToken, conversationID, fileID, false) + if err != nil { + continue + } + if rawURL != "" && !containsString(urls, rawURL) { + urls = append(urls, rawURL) + } + } + for _, sedimentID := range sedimentIDs { + path := "/backend-api/conversation/" + conversationID + "/attachment/" + sedimentID + "/download" + req, err := http.NewRequest(http.MethodGet, baseURL+path, nil) + if err != nil { + continue + } + req = req.WithContext(ctx) + req.Header = c.headers(accessToken, path, map[string]string{"accept": "application/json"}) + resp, err := session.Do(req) + if err != nil { + continue + } + body, readErr := io.ReadAll(resp.Body) + resp.Body.Close() + if readErr != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 { + continue + } + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + continue + } + rawURL := strings.TrimSpace(stringValue(payload["download_url"])) + if rawURL == "" { + rawURL = strings.TrimSpace(stringValue(payload["url"])) + } + if rawURL != "" && !containsString(urls, rawURL) { + urls = append(urls, rawURL) + } + } + return urls, nil +} + +func (c *Client) downloadBytes(ctx context.Context, session tlsclient.HttpClient, accessToken string, urls []string) ([][]byte, error) { + out := make([][]byte, 0, len(urls)) + for _, rawURL := range urls { + req, err := http.NewRequest(http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + // Mirror Python's session.get(url): the resolved download_url is a + // backend-api stream that requires the same default headers as every + // other call — crucially Authorization. Without it the fetch 403s with + // {"detail":"File stream access denied."}. + req.Header = c.baseHeaders(accessToken) + req.Header.Set("accept", "*/*") + resp, err := session.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err) + } + body, readErr := io.ReadAll(resp.Body) + resp.Body.Close() + if readErr != nil { + return nil, readErr + } + if err := ensureOK(resp.StatusCode, body, "image_download"); err != nil { + return nil, err + } + if len(body) > 0 { + out = append(out, body) + } + } + return out, nil +} + +func ensureOK(statusCode int, body []byte, context string) error { + if statusCode >= 200 && statusCode < 300 { + return nil + } + switch statusCode { + case 401, 403: + return fmt.Errorf("%w: %s %d %s", ErrAuth, context, statusCode, clip(body, 400)) + case 429: + return fmt.Errorf("%w: %s 429 %s", ErrQuotaExhausted, context, clip(body, 400)) + case 500, 502, 503, 504: + return fmt.Errorf("%w: %s %d %s", ErrTemporaryUpstream, context, statusCode, clip(body, 400)) + default: + return fmt.Errorf("%s: %d %s", context, statusCode, clip(body, 400)) + } +} + +func injectSizeHint(prompt, aspectRatio, resolution string) string { + _ = resolution + ratio := strings.TrimSpace(aspectRatio) + if ratio == "" || strings.EqualFold(ratio, "auto") { + return strings.TrimSpace(prompt) + } + return strings.TrimSpace(prompt) + "\n\n将宽高比设为 " + ratio +} + +func scanForIDs(text string) ([]string, []string) { + var fileIDs []string + for _, id := range fileServiceIDPattern.FindAllStringSubmatch(text, -1) { + if len(id) >= 2 && id[1] != "file_upload" && !containsString(fileIDs, id[1]) { + fileIDs = append(fileIDs, id[1]) + } + } + for _, id := range realImageIDPattern.FindAllString(text, -1) { + if !containsString(fileIDs, id) { + fileIDs = append(fileIDs, id) + } + } + var sedimentIDs []string + for _, id := range sedimentIDPattern.FindAllStringSubmatch(text, -1) { + if len(id) >= 2 && !containsString(sedimentIDs, id[1]) { + sedimentIDs = append(sedimentIDs, id[1]) + } + } + return fileIDs, sedimentIDs +} + +func extractImageIDs(conversation map[string]any) ([]string, []string) { + var fileIDs, sedimentIDs []string + mapping, _ := conversation["mapping"].(map[string]any) + for _, rawNode := range mapping { + node, _ := rawNode.(map[string]any) + message, _ := node["message"].(map[string]any) + author, _ := message["author"].(map[string]any) + role := strings.ToLower(strings.TrimSpace(stringValue(author["role"]))) + if role != "tool" && role != "assistant" { + continue + } + walkForIDs(message["content"], &fileIDs, &sedimentIDs) + walkForIDs(message["metadata"], &fileIDs, &sedimentIDs) + } + return fileIDs, sedimentIDs +} + +func walkForIDs(value any, fileIDs, sedimentIDs *[]string) { + switch x := value.(type) { + case string: + newFiles, newSeds := scanForIDs(x) + *fileIDs = mergeStrings(*fileIDs, newFiles) + *sedimentIDs = mergeStrings(*sedimentIDs, newSeds) + case map[string]any: + for _, item := range x { + walkForIDs(item, fileIDs, sedimentIDs) + } + case []any: + for _, item := range x { + walkForIDs(item, fileIDs, sedimentIDs) + } + } +} + +func mergeStrings(dst, src []string) []string { + for _, item := range src { + if !containsString(dst, item) { + dst = append(dst, item) + } + } + return dst +} + +func containsString(items []string, value string) bool { + for _, item := range items { + if item == value { + return true + } + } + return false +} + +func intOrNil(v any) any { + switch x := v.(type) { + case nil: + return nil + case int: + return x + case int64: + return int(x) + case float64: + return int(x) + case float32: + return int(x) + case json.Number: + n, err := x.Int64() + if err != nil { + return nil + } + return int(n) + case string: + n, err := strconv.Atoi(strings.TrimSpace(x)) + if err != nil { + return nil + } + return n + default: + return nil + } +} + +func emptyStringNil(v string) any { + v = strings.TrimSpace(v) + if v == "" { + return nil + } + return v +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/backend/internal/provider/chatgpt/pow.go b/backend/internal/provider/chatgpt/pow.go new file mode 100644 index 0000000..0f387f7 --- /dev/null +++ b/backend/internal/provider/chatgpt/pow.go @@ -0,0 +1,151 @@ +package chatgpt + +import ( + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "math/rand" + "strconv" + "strings" + "time" + + "golang.org/x/crypto/sha3" +) + +var ( + cores = []int{8, 16, 24, 32} + documentKeys = []string{"__reactContainer$fzelfjyxej8", "_reactListening5dehydibo78", "location"} + screenResolutions = [][2]int{{1920, 1080}, {1440, 900}, {2560, 1440}, {3840, 2160}} + navKeys = []string{ + "registerProtocolHandler−function registerProtocolHandler() { [native code] }", + "storage−[object StorageManager]", + "locks−[object LockManager]", + "appCodeName−Mozilla", + "permissions−[object Permissions]", + "share−function share() { [native code] }", + "webdriver−false", + "vendor−Google Inc.", + "mediaDevices−[object MediaDevices]", + "cookieEnabled−true", + "onLine−true", + "mimeTypes−[object MimeTypeArray]", + "credentials−[object CredentialsContainer]", + "serviceWorker−[object ServiceWorkerContainer]", + "keyboard−[object Keyboard]", + "gpu−[object GPU]", + "doNotTrack", + "language−zh-CN", + "geolocation−[object Geolocation]", + "hardwareConcurrency−32", + } + winKeys = []string{ + "0", "window", "self", "document", "name", "location", "history", + "navigation", "innerWidth", "innerHeight", "screen", "chrome", + "navigator", "performance", "crypto", "indexedDB", "sessionStorage", + "localStorage", "fetch", "matchMedia", "postMessage", "setTimeout", + "caches", "__NEXT_DATA__", + } +) + +func buildLegacyRequirementsToken(userAgent string, scriptSources []string, dataBuild string) string { + cfg := buildPOWConfig(userAgent, scriptSources, dataBuild) + body, _ := json.Marshal(cfg) + return "gAAAAAC" + base64.StdEncoding.EncodeToString(body) +} + +func buildProofToken(seed, difficulty, userAgent string, scriptSources []string, dataBuild string) (string, error) { + cfg := buildPOWConfig(userAgent, scriptSources, dataBuild) + answer, solved := powGenerate(seed, difficulty, cfg, 500000) + if !solved { + return "", errors.New("failed to solve proof token") + } + return "gAAAAAB" + answer, nil +} + +func buildPOWConfig(userAgent string, scriptSources []string, dataBuild string) []any { + // scriptSources/dataBuild are no longer part of the sentinel config array + // (the current chatgpt.com client dropped them); kept in the signature for + // call-site compatibility. + _ = scriptSources + _ = dataBuild + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + screen := screenResolutions[rng.Intn(len(screenResolutions))] + loc := time.FixedZone("GMT+0800", 8*3600) + nowLocal := time.Now().In(loc).Format("Mon Jan 02 2006 15:04:05") + " GMT+0800 (中国标准时间)" + perf := float64(time.Now().UnixNano()%1_000_000_000) / 1_000_000 + return []any{ + screen[0] + screen[1], // [0] + nowLocal, // [1] local time, JS Date.toString() shape + 4395630592, // [2] + 1, // [3] overwritten by powGenerate counter + userAgent, // [4] + nil, // [5] (was script source; now null) + defaultClientVersion, // [6] oai-client-version, must match header + "zh-CN", // [7] matches oai-language + "zh-CN,en,en-GB,en-US", // [8] + rng.Float64(), // [9] overwritten by powGenerate counter + navKeys[rng.Intn(len(navKeys))], + documentKeys[rng.Intn(len(documentKeys))], + winKeys[rng.Intn(len(winKeys))], + perf, // [13] + newUUID(), // [14] + "", // [15] + cores[rng.Intn(len(cores))], // [16] + float64(timeMillis()) - perf, // [17] + 0, 0, 0, 0, 0, 0, + 0, + } +} + +func powGenerate(seed, difficulty string, cfg []any, limit int) (string, bool) { + target, err := hex.DecodeString(strings.TrimSpace(difficulty)) + if err != nil { + return "", false + } + diffLen := len(strings.TrimSpace(difficulty)) / 2 + seedBytes := []byte(seed) + head1, _ := json.Marshal(cfg[:3]) + head2, _ := json.Marshal(cfg[4:9]) + head3, _ := json.Marshal(cfg[10:]) + static1 := []byte(string(head1[:len(head1)-1]) + ",") + static2 := []byte("," + string(head2[1:len(head2)-1]) + ",") + static3 := []byte("," + string(head3[1:])) + + for i := 0; i < limit; i++ { + finalJSON := append([]byte{}, static1...) + finalJSON = append(finalJSON, []byte(strconvItoa(i))...) + finalJSON = append(finalJSON, static2...) + finalJSON = append(finalJSON, []byte(strconvItoa(i>>1))...) + finalJSON = append(finalJSON, static3...) + encoded := base64.StdEncoding.EncodeToString(finalJSON) + sum := sha3.Sum512(append(seedBytes, []byte(encoded)...)) + if bytesCompare(sum[:diffLen], target) <= 0 { + return encoded, true + } + } + fallback := "wQ8Lk5FbGpA2NcR9dShT6gYjU7VxZ4D" + base64.StdEncoding.EncodeToString([]byte(`"`+seed+`"`)) + return fallback, false +} + +func bytesCompare(a, b []byte) int { + for i := 0; i < len(a) && i < len(b); i++ { + if a[i] < b[i] { + return -1 + } + if a[i] > b[i] { + return 1 + } + } + if len(a) < len(b) { + return -1 + } + if len(a) > len(b) { + return 1 + } + return 0 +} + +func strconvItoa(v int) string { + return strconv.Itoa(v) +} diff --git a/backend/internal/provider/chatgpt/turnstile.go b/backend/internal/provider/chatgpt/turnstile.go new file mode 100644 index 0000000..db16e3e --- /dev/null +++ b/backend/internal/provider/chatgpt/turnstile.go @@ -0,0 +1,182 @@ +package chatgpt + +import ( + "encoding/base64" + "encoding/json" + "math/rand" + "strings" + "time" +) + +type orderedMap struct { + keys []string + values map[string]any +} + +func newOrderedMap() *orderedMap { + return &orderedMap{values: map[string]any{}} +} + +func (m *orderedMap) add(key string, value any) { + if _, ok := m.values[key]; !ok { + m.keys = append(m.keys, key) + } + m.values[key] = value +} + +func solveTurnstileToken(dx, p string) string { + decoded, err := base64.StdEncoding.DecodeString(dx) + if err != nil { + return "" + } + var tokenList [][]any + if err := json.Unmarshal([]byte(xorString(string(decoded), p)), &tokenList); err != nil { + return "" + } + + processMap := map[int]any{16: p} + start := time.Now() + result := "" + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + + toStr := func(value any) string { + if value == nil { + return "undefined" + } + if s, ok := value.(string); ok { + special := map[string]string{ + "window.Math": "[object Math]", + "window.Reflect": "[object Reflect]", + "window.performance": "[object Performance]", + "window.localStorage": "[object Storage]", + "window.Object": "function Object() { [native code] }", + "window.Reflect.set": "function set() { [native code] }", + "window.performance.now": "function () { [native code] }", + "window.Object.create": "function create() { [native code] }", + "window.Object.keys": "function keys() { [native code] }", + "window.Math.random": "function random() { [native code] }", + } + if specialValue, ok := special[s]; ok { + return specialValue + } + return s + } + if list, ok := value.([]string); ok { + return strings.Join(list, ",") + } + return stringValue(value) + } + + for _, token := range tokenList { + if len(token) == 0 { + continue + } + op := intValue(token[0]) + switch op { + case 2: + if len(token) >= 3 { + processMap[intValue(token[1])] = token[2] + } + case 3: + if len(token) >= 2 { + result = base64.StdEncoding.EncodeToString([]byte(toStr(processMap[intValue(token[1])]))) + } + case 5: + if len(token) >= 3 { + e := intValue(token[1]) + t := intValue(token[2]) + cur := processMap[e] + inc := processMap[t] + if list, ok := cur.([]any); ok { + processMap[e] = append(list, inc) + } else if _, ok := cur.(string); ok { + processMap[e] = toStr(cur) + toStr(inc) + } else { + processMap[e] = "NaN" + } + } + case 6, 24: + if len(token) >= 4 { + e := intValue(token[1]) + t := toStr(processMap[intValue(token[2])]) + n := toStr(processMap[intValue(token[3])]) + v := t + "." + n + if op == 6 && v == "window.document.location" { + v = "https://chatgpt.com/" + } + processMap[e] = v + } + case 8: + if len(token) >= 3 { + processMap[intValue(token[1])] = processMap[intValue(token[2])] + } + case 14: + if len(token) >= 3 { + var parsed any + if err := json.Unmarshal([]byte(toStr(processMap[intValue(token[2])])), &parsed); err == nil { + processMap[intValue(token[1])] = parsed + } + } + case 15: + if len(token) >= 3 { + b, _ := json.Marshal(processMap[intValue(token[2])]) + processMap[intValue(token[1])] = string(b) + } + case 17: + if len(token) >= 3 { + e := intValue(token[1]) + target := toStr(processMap[intValue(token[2])]) + switch target { + case "window.performance.now": + processMap[e] = float64(time.Since(start).Nanoseconds())/1e6 + rng.Float64() + case "window.Object.create": + processMap[e] = newOrderedMap() + case "window.Object.keys": + processMap[e] = []string{ + "STATSIG_LOCAL_STORAGE_INTERNAL_STORE_V4", + "STATSIG_LOCAL_STORAGE_STABLE_ID", + "client-correlated-secret", + "oai/apps/capExpiresAt", + "oai-did", + "STATSIG_LOCAL_STORAGE_LOGGING_REQUEST", + "UiState.isNavigationCollapsed.1", + } + case "window.Math.random": + processMap[e] = rng.Float64() + } + } + case 18: + if len(token) >= 2 { + raw, err := base64.StdEncoding.DecodeString(toStr(processMap[intValue(token[1])])) + if err == nil { + processMap[intValue(token[1])] = string(raw) + } + } + case 19: + if len(token) >= 2 { + processMap[intValue(token[1])] = base64.StdEncoding.EncodeToString([]byte(toStr(processMap[intValue(token[1])]))) + } + case 20: + if len(token) >= 4 { + if toStr(processMap[intValue(token[1])]) == toStr(processMap[intValue(token[2])]) { + if intValue(token[3]) == 3 && len(token) >= 5 { + result = base64.StdEncoding.EncodeToString([]byte(toStr(processMap[intValue(token[4])]))) + } + } + } + } + } + return result +} + +func xorString(text, key string) string { + if key == "" { + return text + } + out := make([]rune, 0, len(text)) + keyRunes := []rune(key) + for i, ch := range text { + out = append(out, ch^keyRunes[i%len(keyRunes)]) + } + return string(out) +} diff --git a/backend/internal/provider/chatgpt/util.go b/backend/internal/provider/chatgpt/util.go new file mode 100644 index 0000000..6a4ef7d --- /dev/null +++ b/backend/internal/provider/chatgpt/util.go @@ -0,0 +1,125 @@ +package chatgpt + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "regexp" + "strconv" + "strings" + "time" + + "github.com/google/uuid" +) + +const ( + baseURL = "https://chatgpt.com" + defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36 Edg/149.0.0.0" + defaultClientVersion = "prod-ab8a6348980a3e1d771c463b9f4f3e4e584f2769" + defaultClientBuildNumber = "7624276" + defaultPOWScript = "https://chatgpt.com/backend-api/sentinel/sdk.js" +) + +var ( + fileServiceIDPattern = regexp.MustCompile(`file-service://([A-Za-z0-9_-]+)`) + sedimentIDPattern = regexp.MustCompile(`sediment://([A-Za-z0-9_-]+)`) + realImageIDPattern = regexp.MustCompile(`\bfile_00000000[a-f0-9]{24}\b`) + conversationIDRE = regexp.MustCompile(`"conversation_id"\s*:\s*"([^"]+)"`) + scriptSrcRE = regexp.MustCompile(`]+src="([^"]+)"`) + dataBuildPathRE = regexp.MustCompile(`c/[^/]*/_`) + htmlDataBuildRE = regexp.MustCompile(`]*data-build="([^"]*)"`) +) + +func stringValue(v any) string { + switch x := v.(type) { + case string: + return x + case nil: + return "" + default: + return fmt.Sprint(v) + } +} + +func intValue(v any) int { + switch x := v.(type) { + case int: + return x + case int64: + return int(x) + case float64: + return int(x) + case float32: + return int(x) + case json.Number: + n, _ := x.Int64() + return int(n) + case string: + n, _ := strconv.Atoi(strings.TrimSpace(x)) + return n + default: + return 0 + } +} + +func decodeJWTPayload(token string) map[string]any { + parts := strings.Split(strings.TrimSpace(token), ".") + if len(parts) < 2 { + return map[string]any{} + } + raw, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return map[string]any{} + } + var out map[string]any + if err := json.Unmarshal(raw, &out); err != nil { + return map[string]any{} + } + return out +} + +func newUUID() string { + return uuid.NewString() +} + +func clip(v []byte, n int) string { + s := strings.TrimSpace(string(v)) + if len(s) <= n { + return s + } + return s[:n] +} + +func parsePOWResources(html string) ([]string, string) { + matches := scriptSrcRE.FindAllStringSubmatch(html, -1) + sources := make([]string, 0, len(matches)) + dataBuild := "" + for _, match := range matches { + if len(match) < 2 { + continue + } + src := strings.TrimSpace(match[1]) + if src == "" { + continue + } + sources = append(sources, src) + if dataBuild == "" { + if path := dataBuildPathRE.FindString(src); path != "" { + dataBuild = path + } + } + } + if dataBuild == "" { + if match := htmlDataBuildRE.FindStringSubmatch(html); len(match) >= 2 { + dataBuild = strings.TrimSpace(match[1]) + } + } + if len(sources) == 0 { + sources = []string{defaultPOWScript} + } + return sources, dataBuild +} + +func timeMillis() int64 { + return time.Now().UnixMilli() +} diff --git a/backend/internal/provider/imagine/client.go b/backend/internal/provider/imagine/client.go new file mode 100644 index 0000000..7f95880 --- /dev/null +++ b/backend/internal/provider/imagine/client.go @@ -0,0 +1,441 @@ +// Package imagine implements the Imagine.art (vyro.ai) provider client. The +// durable credential is a JSON blob {"token","refreshToken"}: `token` is a ~6h +// access JWT used as Authorization: Bearer for the API, and `refreshToken` is a +// ~7d JWT that mints a fresh pair via /apis/v1/auth/other/refresh/web when the +// access token expires. Both rotate on refresh, so the new pair MUST be saved. +// tls-client gives a Chrome JA3/JA4 so vyro's edge doesn't flag the requests. +package imagine + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "strconv" + "strings" + "sync" + "time" + + http "github.com/bogdanfinn/fhttp" + tlsclient "github.com/bogdanfinn/tls-client" + "github.com/bogdanfinn/tls-client/profiles" +) + +const ( + apiBase = "https://imagine.vyro.ai" + teamsBase = "https://teams-imagine.vyro.ai" + authBase = "https://auth.vyro.ai" + webOrigin = "https://www.imagine.art" + userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" +) + +var ( + ErrAuth = errors.New("imagine auth failed") + ErrQuotaExhausted = errors.New("imagine quota exhausted") + ErrTemporaryUpstream = errors.New("imagine upstream temporary error") +) + +// refreshLeadSeconds renews the access token this many seconds BEFORE it expires +// (proactive, not lazy at expiry) — the maintenance sweep keeps tokens fresh so a +// dormant account's rotating refreshToken never lapses. +const refreshLeadSeconds = 600 // 10 minutes + +type Client struct { + proxy string + // freshest credential per account (key: user id) + a per-account refresh lock, + // so concurrent callers don't each spend the rotating refresh_token — the first + // refreshes, the rest reuse the cached fresh credential. + mu sync.Mutex + creds map[string]string + locks map[string]*sync.Mutex +} + +func NewClient(proxy string) *Client { + return &Client{proxy: strings.TrimSpace(proxy), creds: map[string]string{}, locks: map[string]*sync.Mutex{}} +} + +func (c *Client) SetProxy(proxy string) { + c.proxy = strings.TrimSpace(proxy) +} + +func (c *Client) userLock(userID string) *sync.Mutex { + c.mu.Lock() + defer c.mu.Unlock() + m, ok := c.locks[userID] + if !ok { + m = &sync.Mutex{} + c.locks[userID] = m + } + return m +} + +// --------------------------------------------------------------------------- +// Credential helpers +// --------------------------------------------------------------------------- + +type credential struct { + Token string `json:"token"` + RefreshToken string `json:"refreshToken"` + // Email is the real account email — supplied at import, used for display and + // (pool,email) dedup. It is NOT in the JWT (which only carries userId), so it + // must be carried across refreshes (the refresh response omits it). + Email string `json:"email,omitempty"` + // ParentID is a canvas node the account OWNS, used as the generation's + // parent_id. Imagine rejects any parent the account doesn't own ("user does + // not have access to parent asset") and silently orphans a parent-less + // generation (charged but never produced) — so it's supplied at import and + // carried across refreshes. + ParentID string `json:"parentId,omitempty"` +} + +func parseCred(s string) (credential, bool) { + var cr credential + if json.Unmarshal([]byte(strings.TrimSpace(s)), &cr) != nil { + return cr, false + } + if strings.TrimSpace(cr.Token) == "" || strings.TrimSpace(cr.RefreshToken) == "" { + return cr, false + } + return cr, true +} + +func buildCred(token, refresh, email, parentID string) string { + b, _ := json.Marshal(credential{ + Token: strings.TrimSpace(token), + RefreshToken: strings.TrimSpace(refresh), + Email: strings.TrimSpace(email), + ParentID: strings.TrimSpace(parentID), + }) + return string(b) +} + +// ParentIDFromCred returns the canvas parent node id supplied at import. +func ParentIDFromCred(cred string) string { + cr, ok := parseCred(cred) + if !ok { + return "" + } + return strings.TrimSpace(cr.ParentID) +} + +func looksLikeJWT(s string) bool { + return len(strings.Split(strings.TrimSpace(s), ".")) == 3 +} + +// IsImagineToken reports whether a pasted credential is an Imagine.art account: +// a JSON object carrying a non-empty token + refreshToken that both look like +// JWTs. Distinguishes it from adobe/leonardo/krea cookies. +func IsImagineToken(value string) bool { + cr, ok := parseCred(value) + if !ok { + return false + } + return looksLikeJWT(cr.Token) && looksLikeJWT(cr.RefreshToken) +} + +// jwtClaims base64url-decodes the JWT payload (segment 1) into a claims map. +func jwtClaims(token string) map[string]any { + parts := strings.Split(strings.TrimSpace(token), ".") + if len(parts) < 2 { + return nil + } + raw, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + // tolerate padded variants + if raw, err = base64.URLEncoding.DecodeString(parts[1]); err != nil { + return nil + } + } + var m map[string]any + if json.Unmarshal(raw, &m) != nil { + return nil + } + return m +} + +func userIDFromToken(token string) string { + claims := jwtClaims(token) + if claims == nil { + return "" + } + if v := strings.TrimSpace(stringValue(claims["userId"])); v != "" { + return v + } + return strings.TrimSpace(stringValue(claims["sub"])) +} + +func tokenExp(token string) int64 { + claims := jwtClaims(token) + if claims == nil { + return 0 + } + return toInt64(claims["exp"]) +} + +// EmailFromCred returns the real account email supplied at import; if absent it +// falls back to the JWT userId so (pool,email) dedup still has a stable key. +func EmailFromCred(cred string) string { + cr, ok := parseCred(cred) + if !ok { + return "" + } + if e := strings.TrimSpace(cr.Email); e != "" { + return e + } + return userIDFromToken(cr.Token) +} + +// UserIDFromCred returns the JWT userId (== org_id used for credit/generation). +func UserIDFromCred(cred string) string { + cr, ok := parseCred(cred) + if !ok { + return "" + } + return userIDFromToken(cr.Token) +} + +// --------------------------------------------------------------------------- +// Refresh +// --------------------------------------------------------------------------- + +// RefreshIfNeeded returns a credential whose access token is still valid: if the +// stored one is (near) expired it spends the refreshToken to mint a fresh pair +// and rebuilds the credential. Returns (cred, changed, err); changed=true means +// the caller must persist the new credential (both tokens rotate). ErrAuth means +// the refreshToken is dead → the account is gone. +func (c *Client) RefreshIfNeeded(ctx context.Context, cred string) (string, bool, error) { + cr, ok := parseCred(cred) + if !ok { + return cred, false, nil // unparseable — let the downstream call surface the error + } + userID := userIDFromToken(cr.Token) + now := time.Now().Unix() + + lk := c.userLock(userID) + lk.Lock() + defer lk.Unlock() + + // A concurrent caller may already have refreshed this account. + if userID != "" { + c.mu.Lock() + cached := c.creds[userID] + c.mu.Unlock() + if cc, ok := parseCred(cached); ok && tokenExp(cc.Token)-refreshLeadSeconds > now { + return cached, cached != cred, nil + } + } + if tokenExp(cr.Token)-refreshLeadSeconds > now { + return cred, false, nil // still valid + } + + respBody, status, err := c.refreshPost(ctx, cr.RefreshToken) + if err != nil { + return "", false, fmt.Errorf("%w: refresh: %s", ErrTemporaryUpstream, err.Error()) + } + if status == 400 || status == 401 || status == 403 { + return "", false, ErrAuth + } + if status != 200 { + return "", false, fmt.Errorf("%w: refresh http %d: %s", ErrTemporaryUpstream, status, clip(respBody, 120)) + } + var rb struct { + Result struct { + SessionToken string `json:"sessionToken"` + RefreshToken string `json:"refreshToken"` + } `json:"result"` + } + if json.Unmarshal(respBody, &rb) != nil || strings.TrimSpace(rb.Result.SessionToken) == "" { + return "", false, ErrAuth + } + newRefresh := rb.Result.RefreshToken + if strings.TrimSpace(newRefresh) == "" { + newRefresh = cr.RefreshToken // some responses may omit it — keep the old one + } + newCred := buildCred(rb.Result.SessionToken, newRefresh, cr.Email, cr.ParentID) + if userID != "" { + c.mu.Lock() + c.creds[userID] = newCred + c.mu.Unlock() + } + return newCred, true, nil +} + +func (c *Client) refreshPost(ctx context.Context, refreshToken string) ([]byte, int, error) { + client, err := c.newTLSClient() + if err != nil { + return nil, 0, err + } + req, err := http.NewRequest(http.MethodPost, authBase+"/apis/v1/auth/other/refresh/web", nil) + if err != nil { + return nil, 0, err + } + req = req.WithContext(ctx) + req.Header = http.Header{ + "accept": {"application/json, text/plain, */*"}, + "authorization": {"Bearer " + refreshToken}, + "origin": {webOrigin}, + "referer": {webOrigin + "/"}, + "user-agent": {userAgent}, + http.HeaderOrderKey: { + "accept", "authorization", "origin", "referer", "user-agent", + }, + } + resp, err := client.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + return b, resp.StatusCode, err +} + +// --------------------------------------------------------------------------- +// Credits +// --------------------------------------------------------------------------- + +// FetchCreditsBalance reads the account's credit balance via /v1/credit. +// remaining is the `total` field. 401/403 → ErrAuth (token dead). Returns the +// normalized map shared by all providers. +func (c *Client) FetchCreditsBalance(ctx context.Context, cred string) (map[string]any, error) { + cr, ok := parseCred(cred) + if !ok { + return unknownBalance("bad credential"), nil + } + userID := userIDFromToken(cr.Token) + body, status, err := c.apiGet(ctx, cr.Token, apiBase+"/v1/credit?org_id="+userID) + if err != nil { + return unknownBalance("network: " + err.Error()), nil + } + if status == 401 || status == 403 { + return nil, ErrAuth + } + if status != 200 { + return unknownBalance(fmt.Sprintf("http %d: %s", status, clip(body, 160))), nil + } + var cb struct { + Status string `json:"status"` + Total int `json:"total"` + } + if err := json.Unmarshal(body, &cb); err != nil { + return unknownBalance("non-json"), nil + } + return map[string]any{ + "remaining": cb.Total, + "used": nil, + "total": nil, + "unknown": false, + "error": nil, + "email": emptyStringNil(EmailFromCred(cred)), + }, nil +} + +// --------------------------------------------------------------------------- +// HTTP helpers +// --------------------------------------------------------------------------- + +func (c *Client) apiGet(ctx context.Context, token, url string) ([]byte, int, error) { + client, err := c.newTLSClient() + if err != nil { + return nil, 0, err + } + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, 0, err + } + req = req.WithContext(ctx) + req.Header = http.Header{ + "accept": {"application/json, text/plain, */*"}, + "authorization": {"Bearer " + token}, + "origin": {webOrigin}, + "referer": {webOrigin + "/"}, + "user-agent": {userAgent}, + http.HeaderOrderKey: { + "accept", "authorization", "origin", "referer", "user-agent", + }, + } + resp, err := client.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + return b, resp.StatusCode, err +} + +func (c *Client) newTLSClient() (tlsclient.HttpClient, error) { + options := []tlsclient.HttpClientOption{ + tlsclient.WithTimeoutSeconds(60), + tlsclient.WithClientProfile(profiles.Chrome_120), + } + if c.proxy != "" { + options = append(options, tlsclient.WithProxyUrl(c.proxy)) + } + return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...) +} + +// --------------------------------------------------------------------------- +// Small util +// --------------------------------------------------------------------------- + +func uuid4() string { + var b [16]byte + _, _ = rand.Read(b[:]) + b[6] = (b[6] & 0x0f) | 0x40 + b[8] = (b[8] & 0x3f) | 0x80 + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} + +func unknownBalance(reason string) map[string]any { + return map[string]any{ + "remaining": nil, "used": nil, "total": nil, "unknown": true, "error": reason, + } +} + +func stringValue(v any) string { + switch x := v.(type) { + case string: + return x + case nil: + return "" + default: + b, _ := json.Marshal(x) + return strings.TrimSpace(string(b)) + } +} + +func toInt64(v any) int64 { + switch x := v.(type) { + case float64: + return int64(x) + case int64: + return x + case int: + return int64(x) + case json.Number: + n, _ := x.Int64() + return n + case string: + n, _ := strconv.ParseInt(strings.TrimSpace(x), 10, 64) + return n + default: + return 0 + } +} + +func emptyStringNil(v string) any { + if strings.TrimSpace(v) == "" { + return nil + } + return v +} + +func clip(b []byte, n int) string { + s := strings.TrimSpace(string(b)) + if len(s) > n { + return s[:n] + } + return s +} diff --git a/backend/internal/provider/imagine/image.go b/backend/internal/provider/imagine/image.go new file mode 100644 index 0000000..db5e364 --- /dev/null +++ b/backend/internal/provider/imagine/image.go @@ -0,0 +1,242 @@ +package imagine + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "strconv" + "strings" + "time" + + http "github.com/bogdanfinn/fhttp" +) + +// GenerateImage runs the full Imagine.art pipeline: submit the txt2img job, poll +// the org objects feed until the batch finishes, then download the produced +// image. styleID picks the model (41001 = 1.5 / 2K, 41004 = 1.5pro / 4K). +// HTTP 402 → ErrQuotaExhausted. These models are pure text2img (no refs). +func (c *Client) GenerateImage(ctx context.Context, cred string, styleID int, resolution, aspectRatio, prompt string) ([]byte, map[string]any, error) { + cr, ok := parseCred(cred) + if !ok { + return nil, nil, ErrAuth + } + userID := userIDFromToken(cr.Token) + + metadata, _ := json.Marshal(map[string]any{ + "placeholderUuid": uuid4(), + "promptWithoutManipulation": prompt, + "modeId": 0, + }) + // parent_id MUST be a canvas node this account owns — the server rejects a + // foreign id ("user does not have access to parent asset") and silently + // orphans a parent-less generation (charged but never produced). It's supplied + // at import (credential.parentId). + fields := map[string]string{ + "style_id": strconv.Itoa(styleID), + "aspect_ratio": aspectRatio, + "resolution": resolution, + "variation": "txt2img", + "prompt": prompt, + "is_enhance": "0", + "count": "1", + "clientVersion": "1", + "org_id": userID, + "use_plugin": "false", + "metadata": string(metadata), + } + if pid := strings.TrimSpace(cr.ParentID); pid != "" { + fields["parent_id"] = pid + } + + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + for k, v := range fields { + _ = w.WriteField(k, v) + } + _ = w.Close() + + body, status, err := c.apiPost(ctx, cr.Token, apiBase+"/v1/image/generations/upload", w.FormDataContentType(), buf.Bytes()) + if err != nil { + return nil, nil, fmt.Errorf("%w: submit: %s", ErrTemporaryUpstream, err.Error()) + } + if status == 401 || status == 403 { + return nil, nil, ErrAuth + } + if status == 402 { + return nil, nil, ErrQuotaExhausted + } + if status != 200 && status != 201 { + return nil, nil, fmt.Errorf("%w: submit http %d: %s", ErrTemporaryUpstream, status, clip(body, 200)) + } + var jobs []struct { + BatchID string `json:"batchId"` + ID string `json:"id"` + Status string `json:"status"` + } + if err := json.Unmarshal(body, &jobs); err != nil || len(jobs) == 0 || jobs[0].BatchID == "" { + return nil, nil, fmt.Errorf("%w: no batch id: %s", ErrTemporaryUpstream, clip(body, 200)) + } + batchID := jobs[0].BatchID + + imageURL, err := c.pollImage(ctx, cr.Token, userID, batchID) + if err != nil { + return nil, nil, err + } + data, err := c.download(ctx, imageURL) + if err != nil { + return nil, nil, err + } + return data, map[string]any{"batch_id": batchID, "image_url": imageURL, "org_id": userID}, nil +} + +// pollImage polls the org objects feed until the entry for our batch finishes, +// then extracts its asset URL (image_url is a JSON-encoded array string). +func (c *Client) pollImage(ctx context.Context, token, userID, batchID string) (string, error) { + ticker := time.NewTicker(3 * time.Second) + defer ticker.Stop() + deadline := time.Now().Add(4 * time.Minute) + + url := teamsBase + "/v1/org/" + userID + "/objects?batch=true&limit=50&service=image,chat-image" + for { + body, status, err := c.apiGet(ctx, token, url) + if err == nil && status == 200 { + var resp struct { + Data []struct { + BatchID string `json:"batch_id"` + Status string `json:"status"` + Code int `json:"code"` + // Current shape: the produced asset lives at url.generation[0]. + URL struct { + Generation []string `json:"generation"` + } `json:"url"` + ImageURL string `json:"image_url"` // legacy fallback + } `json:"data"` + } + if json.Unmarshal(body, &resp) == nil { + for _, o := range resp.Data { + if o.BatchID != batchID { + continue + } + st := strings.ToLower(strings.TrimSpace(o.Status)) + switch { + case st == "finished" || o.Code == 2: + if u := firstNonEmpty(o.URL.Generation); u != "" { + return u, nil + } + if u := firstImageURL(o.ImageURL); u != "" { + return u, nil + } + case st == "failed" || st == "error": + return "", fmt.Errorf("%w: job %s", ErrTemporaryUpstream, st) + } + } + } + } else if status == 401 || status == 403 { + return "", ErrAuth + } + if time.Now().After(deadline) { + return "", fmt.Errorf("%w: generation timed out", ErrTemporaryUpstream) + } + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-ticker.C: + } + } +} + +// firstNonEmpty returns the first non-blank string in a slice. +func firstNonEmpty(ss []string) string { + for _, s := range ss { + if strings.TrimSpace(s) != "" { + return strings.TrimSpace(s) + } + } + return "" +} + +// firstImageURL parses the image_url field — a JSON-encoded array of URLs — and +// returns the first one. Tolerates a bare string too. +func firstImageURL(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + var urls []string + if json.Unmarshal([]byte(raw), &urls) == nil { + for _, u := range urls { + if strings.TrimSpace(u) != "" { + return strings.TrimSpace(u) + } + } + return "" + } + if strings.HasPrefix(raw, "http") { + return raw + } + return "" +} + +func (c *Client) download(ctx context.Context, url string) ([]byte, error) { + client, err := c.newTLSClient() + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + req.Header = http.Header{ + "accept": {"image/avif,image/webp,image/png,image/*,*/*;q=0.8"}, + "user-agent": {userAgent}, + "referer": {webOrigin + "/"}, + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("%w: image download http %d", ErrTemporaryUpstream, resp.StatusCode) + } + return b, nil +} + +// apiPost issues a POST with a raw body + content-type, carrying the bearer token. +func (c *Client) apiPost(ctx context.Context, token, url, contentType string, body []byte) ([]byte, int, error) { + client, err := c.newTLSClient() + if err != nil { + return nil, 0, err + } + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, 0, err + } + req = req.WithContext(ctx) + req.Header = http.Header{ + "accept": {"application/json, text/plain, */*"}, + "authorization": {"Bearer " + token}, + "content-type": {contentType}, + "origin": {webOrigin}, + "referer": {webOrigin + "/"}, + "user-agent": {userAgent}, + http.HeaderOrderKey: { + "accept", "authorization", "content-type", "origin", "referer", "user-agent", + }, + } + resp, err := client.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + return b, resp.StatusCode, err +} diff --git a/backend/internal/provider/krea/client.go b/backend/internal/provider/krea/client.go new file mode 100644 index 0000000..f4a845f --- /dev/null +++ b/backend/internal/provider/krea/client.go @@ -0,0 +1,488 @@ +// Package krea implements the Krea.ai (krea.ai) provider client. The durable +// credential is the browser cookie (Supabase "sb-superb-auth-token"); Krea's own +// Next.js backend reads it directly, so quota and generation just forward the +// cookie — there's no separate token-exchange step. tls-client gives a Chrome +// JA3/JA4 so Krea's Cloudflare edge doesn't flag the requests. +package krea + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "strconv" + "strings" + "sync" + "time" + + http "github.com/bogdanfinn/fhttp" + tlsclient "github.com/bogdanfinn/tls-client" + "github.com/bogdanfinn/tls-client/profiles" +) + +// kreaAnonKey is Krea's public Supabase anon key (fixed, embedded in their +// frontend) — required as the apikey/bearer when refreshing a session token. +const kreaAnonKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzc1Mjc4ODU3LCJleHAiOjE5MzI5NTg4NTd9.NUiqEOd__QsCCMjo3D1zrCAda5dLV2F5p6Kf584sZKc" + +const ( + apiBase = "https://www.krea.ai" + userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" +) + +var ( + ErrAuth = errors.New("krea auth failed") + ErrQuotaExhausted = errors.New("krea quota exhausted") + ErrTemporaryUpstream = errors.New("krea upstream temporary error") +) + +// refreshLeadSeconds renews the access token this many seconds BEFORE it expires +// (not lazily at expiry). The maintenance sweep refreshes proactively, so a +// dormant account's token never lapses — once the rotating refresh_token is gone +// (expired/consumed) the account can't recover, so we keep it perpetually fresh. +const refreshLeadSeconds = 600 // 10 minutes + +type Client struct { + proxy string + // freshest cookie per account (key: user id) + a per-account refresh lock, so + // concurrent callers don't each spend the single-use (rotating) refresh_token — + // the first refreshes, the rest reuse the cached fresh cookie. + mu sync.Mutex + cookies map[string]string + locks map[string]*sync.Mutex + // actAt = last /app activation time per account (key: user id); actLocks gives + // a per-account lock so concurrent generations wait for the first to finish the + // (once-per-daily-reset) activation instead of each loading /app. + actAt map[string]int64 + actLocks map[string]*sync.Mutex +} + +func NewClient(proxy string) *Client { + return &Client{ + proxy: strings.TrimSpace(proxy), + cookies: map[string]string{}, + locks: map[string]*sync.Mutex{}, + actAt: map[string]int64{}, + actLocks: map[string]*sync.Mutex{}, + } +} + +func (c *Client) userLock(userID string) *sync.Mutex { + c.mu.Lock() + defer c.mu.Unlock() + m, ok := c.locks[userID] + if !ok { + m = &sync.Mutex{} + c.locks[userID] = m + } + return m +} + +// RefreshIfNeeded returns a cookie whose access_token is still valid: if the +// stored one is (near) expired it spends the refresh_token to mint a new session +// and rebuilds the cookie. Returns (cookie, changed, err); changed=true means the +// caller must persist the new cookie (the refresh_token rotated). ErrAuth means +// the refresh_token is dead → the account is gone. +func (c *Client) RefreshIfNeeded(ctx context.Context, cookie string) (string, bool, error) { + authVal := authCookieValue(cookie) + sess, ok := decodeSession(authVal) + if !ok { + return cookie, false, nil // unparseable — let the downstream call surface the error + } + userID := nestedStr(sess, "user", "id") + now := time.Now().Unix() + + lk := c.userLock(userID) + lk.Lock() + defer lk.Unlock() + + // A concurrent caller may already have refreshed this account. + if userID != "" { + c.mu.Lock() + cached := c.cookies[userID] + c.mu.Unlock() + if cs, ok := decodeSession(authCookieValue(cached)); ok && toInt64(cs["expires_at"])-refreshLeadSeconds > now { + return cached, cached != cookie, nil + } + } + if toInt64(sess["expires_at"])-refreshLeadSeconds > now { + return cookie, false, nil // still valid + } + refreshTok := strings.TrimSpace(stringValue(sess["refresh_token"])) + if refreshTok == "" { + return "", false, ErrAuth + } + + respBody, status, err := c.refreshPost(ctx, refreshTok) + if err != nil { + return "", false, fmt.Errorf("%w: refresh: %s", ErrTemporaryUpstream, err.Error()) + } + if status == 400 || status == 401 || status == 403 { + return "", false, ErrAuth + } + if status != 200 { + return "", false, fmt.Errorf("%w: refresh http %d: %s", ErrTemporaryUpstream, status, clip(respBody, 120)) + } + var ns map[string]any + if json.Unmarshal(respBody, &ns) != nil || strings.TrimSpace(stringValue(ns["access_token"])) == "" { + return "", false, ErrAuth + } + newCookie := replaceAuthCookie(cookie, "base64-"+base64.StdEncoding.EncodeToString(respBody)) + if userID != "" { + c.mu.Lock() + c.cookies[userID] = newCookie + c.mu.Unlock() + } + return newCookie, true, nil +} + +func (c *Client) refreshPost(ctx context.Context, refreshToken string) ([]byte, int, error) { + client, err := c.newTLSClient() + if err != nil { + return nil, 0, err + } + body, _ := json.Marshal(map[string]string{"refresh_token": refreshToken}) + req, err := http.NewRequest(http.MethodPost, apiBase+"/auth/v1/token?grant_type=refresh_token", bytes.NewReader(body)) + if err != nil { + return nil, 0, err + } + req = req.WithContext(ctx) + req.Header = http.Header{ + "accept": {"*/*"}, + "content-type": {"application/json;charset=UTF-8"}, + "apikey": {kreaAnonKey}, + "authorization": {"Bearer " + kreaAnonKey}, + "x-client-info": {"supabase-ssr/0.6.1 createBrowserClient"}, + "x-supabase-api-version": {"2024-01-01"}, + "origin": {apiBase}, + "referer": {apiBase + "/"}, + "user-agent": {userAgent}, + http.HeaderOrderKey: { + "accept", "content-type", "apikey", "authorization", "x-client-info", + "x-supabase-api-version", "origin", "referer", "user-agent", + }, + } + resp, err := client.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + return b, resp.StatusCode, err +} + +// chunkSize is supabase-ssr's per-cookie chunk limit; larger sessions (e.g. +// Google-OAuth accounts) are split into sb-superb-auth-token.0/.1/... +const chunkSize = 3600 + +func cookieVal(cookie, name string) string { + for _, p := range strings.Split(cookie, ";") { + p = strings.TrimSpace(p) + if v, ok := strings.CutPrefix(p, name+"="); ok { + return v + } + } + return "" +} + +// authCookieValue returns the full auth value, transparently reassembling a +// chunked cookie (sb-superb-auth-token.0 + .1 + ...) or returning the single one. +func authCookieValue(cookie string) string { + if v := cookieVal(cookie, "sb-superb-auth-token"); v != "" { + return v + } + var b strings.Builder + for i := 0; ; i++ { + v := cookieVal(cookie, fmt.Sprintf("sb-superb-auth-token.%d", i)) + if v == "" { + break + } + b.WriteString(v) + } + return b.String() +} + +// replaceAuthCookie drops every sb-superb-auth-token[.N] cookie and re-adds the +// new value (chunked the same way supabase-ssr would if it's large), preserving +// all other cookies (krea-workspace-id, etc.). +func replaceAuthCookie(cookie, newValue string) string { + var out []string + for _, p := range strings.Split(cookie, ";") { + t := strings.TrimSpace(p) + if t == "" || strings.HasPrefix(t, "sb-superb-auth-token=") || strings.HasPrefix(t, "sb-superb-auth-token.") { + continue + } + out = append(out, t) + } + if len(newValue) <= chunkSize { + out = append(out, "sb-superb-auth-token="+newValue) + } else { + for i, off := 0, 0; off < len(newValue); i++ { + end := off + chunkSize + if end > len(newValue) { + end = len(newValue) + } + out = append(out, fmt.Sprintf("sb-superb-auth-token.%d=%s", i, newValue[off:end])) + off = end + } + } + return strings.Join(out, "; ") +} + +// decodeSession base64-decodes the auth cookie value into the session JSON map. +func decodeSession(authValue string) (map[string]any, bool) { + v := strings.TrimPrefix(strings.TrimSpace(authValue), "base64-") + if v == "" { + return nil, false + } + raw, err := base64.StdEncoding.DecodeString(v) + if err != nil { + raw, err = base64.RawURLEncoding.DecodeString(v) + if err != nil { + return nil, false + } + } + var m map[string]any + if json.Unmarshal(raw, &m) != nil { + return nil, false + } + return m, true +} + +func nestedStr(m map[string]any, k1, k2 string) string { + if sub, ok := m[k1].(map[string]any); ok { + return strings.TrimSpace(stringValue(sub[k2])) + } + return "" +} + +func toInt64(v any) int64 { + switch x := v.(type) { + case float64: + return int64(x) + case int64: + return x + case int: + return int64(x) + case json.Number: + n, _ := x.Int64() + return n + case string: + n, _ := strconv.ParseInt(strings.TrimSpace(x), 10, 64) + return n + default: + return 0 + } +} + +func (c *Client) SetProxy(proxy string) { + c.proxy = strings.TrimSpace(proxy) +} + +// IsKreaCookie reports whether a pasted credential is a Krea cookie: it carries +// the Supabase auth cookie. Distinguishes it from adobe/leonardo cookies. +func IsKreaCookie(value string) bool { + return strings.Contains(value, "sb-superb-auth-token") +} + +// EmailFromCookie decodes the account email straight out of the cookie's embedded +// Supabase session (no network), handling chunked cookies too. +func EmailFromCookie(cookie string) string { + sess, ok := decodeSession(authCookieValue(cookie)) + if !ok { + return "" + } + return nestedStr(sess, "user", "email") +} + +// FetchCreditsBalance reads the account's free-credit balance via /api/billing-data. +// remaining is the integer floor of balance.free (per spec: 17.94 → 17). 401 → +// ErrAuth (cookie dead). Returns the normalized map shared by all providers. +func (c *Client) FetchCreditsBalance(ctx context.Context, cookie string) (map[string]any, error) { + // Detach from the request ctx so a page refresh can't cancel the probe + // mid-flight (which left accounts stuck at "—"). + probeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) + defer cancel() + // NOTE: no /app here — the heavy SSR activation is done separately (on recovery + // and at generation via Activate). This probe just reads the current balance. + body, status, err := c.apiGet(probeCtx, cookie, "/api/billing-data") + if err != nil { + return unknownBalance("network: " + err.Error()), nil + } + if status == 401 || status == 403 { + return nil, ErrAuth + } + if status != 200 { + return unknownBalance(fmt.Sprintf("http %d: %s", status, clip(body, 160))), nil + } + // 余额在 balance.free(真实剩余,小数,随用量递减)。krea 的这个字段一直都在, + // 只是排在很长的 entitlements 之后 —— 只读它,不用套餐配额兜底。 + var bd struct { + Balance struct { + Free float64 `json:"free"` + Total float64 `json:"total"` + } `json:"balance"` + } + if err := json.Unmarshal(body, &bd); err != nil { + return unknownBalance("non-json"), nil + } + remaining := int(bd.Balance.Free) // floor + return map[string]any{ + "remaining": remaining, + "used": nil, + "total": int(bd.Balance.Total), + "unknown": false, + "error": nil, + "email": emptyStringNil(EmailFromCookie(cookie)), + }, nil +} + +// Activate loads the authenticated SSR app page (/app), which is what makes krea +// grant the account's DAILY free balance — a cold API-only call (billing-data / +// generate) otherwise sees balance.free=0 and 402s. Done at most ONCE per account +// per daily reset, under a per-account lock: the first caller loads /app while +// concurrent callers wait, then everyone proceeds (no redundant /app). Called +// before each generation and by the daily activation sweep. Best-effort. +func (c *Client) Activate(ctx context.Context, cookie string) { + key := accountKey(cookie) + lastReset := (time.Now().Unix() / 86400) * 86400 + c.mu.Lock() + doneToday := key != "" && c.actAt[key] >= lastReset + c.mu.Unlock() + if doneToday { + return + } + lk := c.actLock(key) + lk.Lock() + defer lk.Unlock() + // Re-check after acquiring the lock — another caller may have just activated. + c.mu.Lock() + doneToday = key != "" && c.actAt[key] >= lastReset + c.mu.Unlock() + if doneToday { + return + } + actCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 60*time.Second) + defer cancel() + _, _, _ = c.apiGet(actCtx, cookie, "/app") + c.mu.Lock() + c.actAt[key] = time.Now().Unix() + c.mu.Unlock() +} + +func (c *Client) actLock(key string) *sync.Mutex { + c.mu.Lock() + defer c.mu.Unlock() + m, ok := c.actLocks[key] + if !ok { + m = &sync.Mutex{} + c.actLocks[key] = m + } + return m +} + +// accountKey is the stable per-account id (the Supabase user id from the session +// cookie) used to key activation state — survives cookie rotation. +func accountKey(cookie string) string { + if sess, ok := decodeSession(authCookieValue(cookie)); ok { + return nestedStr(sess, "user", "id") + } + return "" +} + +// apiGet issues a GET to a krea.ai API path carrying the account cookie. +func (c *Client) apiGet(ctx context.Context, cookie, path string) ([]byte, int, error) { + client, err := c.newTLSClient() + if err != nil { + return nil, 0, err + } + req, err := http.NewRequest(http.MethodGet, apiBase+path, nil) + if err != nil { + return nil, 0, err + } + req = req.WithContext(ctx) + req.Header = http.Header{ + "accept": {"*/*"}, + "accept-language": {"en-US,en;q=0.9"}, + "cookie": {cookie}, + "referer": {apiBase + "/"}, + "user-agent": {userAgent}, + "sec-fetch-dest": {"empty"}, + "sec-fetch-mode": {"cors"}, + "sec-fetch-site": {"same-origin"}, + http.HeaderOrderKey: { + "accept", "accept-language", "cookie", "referer", "user-agent", + "sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site", + }, + } + resp, err := client.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + return b, resp.StatusCode, err +} + +func (c *Client) newTLSClient() (tlsclient.HttpClient, error) { + options := []tlsclient.HttpClientOption{ + tlsclient.WithTimeoutSeconds(60), + tlsclient.WithClientProfile(profiles.Chrome_120), + } + if c.proxy != "" { + options = append(options, tlsclient.WithProxyUrl(c.proxy)) + } + return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...) +} + +func unknownBalance(reason string) map[string]any { + return map[string]any{ + "remaining": nil, "used": nil, "total": nil, "unknown": true, "error": reason, + } +} + +func stringValue(v any) string { + switch x := v.(type) { + case string: + return x + case nil: + return "" + default: + b, _ := json.Marshal(x) + return strings.TrimSpace(string(b)) + } +} + +func intValue(v any) int { + switch x := v.(type) { + case int: + return x + case float64: + return int(x) + case json.Number: + n, _ := x.Int64() + return int(n) + case string: + n, _ := strconv.Atoi(strings.TrimSpace(x)) + return n + default: + return 0 + } +} + +func emptyStringNil(v string) any { + if strings.TrimSpace(v) == "" { + return nil + } + return v +} + +func clip(b []byte, n int) string { + s := strings.TrimSpace(string(b)) + if len(s) > n { + return s[:n] + } + return s +} diff --git a/backend/internal/provider/krea/image.go b/backend/internal/provider/krea/image.go new file mode 100644 index 0000000..714fc91 --- /dev/null +++ b/backend/internal/provider/krea/image.go @@ -0,0 +1,308 @@ +package krea + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "strings" + "time" + + http "github.com/bogdanfinn/fhttp" +) + +const ( + genModel = "flux2-klein4b" // the single exposed Krea model + genEndpoint = "/api/jobs/v2/new/fluxKlein4b" + refStrength = 0.4 +) + +// ensureProject returns a flux project id for the account: the first existing +// project, or a freshly created one. Generation requires a project. +func (c *Client) ensureProject(ctx context.Context, cookie string) (string, error) { + body, status, err := c.apiGet(ctx, cookie, "/api/flux-projects") + if err != nil { + return "", fmt.Errorf("%w: list projects: %s", ErrTemporaryUpstream, err.Error()) + } + if status == 401 || status == 403 { + return "", ErrAuth + } + if status == 200 { + var projs []struct { + ID string `json:"id"` + } + if json.Unmarshal(body, &projs) == nil { + for _, p := range projs { + if strings.TrimSpace(p.ID) != "" { + return p.ID, nil + } + } + } + } + // No project on this account (e.g. brand-new). Try to create one; if that + // fails, fall back to generating WITHOUT a project (Krea assigns a default). + cb, cs, cerr := c.apiPostJSON(ctx, cookie, "/api/flux-projects", map[string]any{"title": "vivid"}) + if cerr == nil && (cs == 200 || cs == 201) { + var pr struct { + ID string `json:"id"` + } + if json.Unmarshal(cb, &pr) == nil && pr.ID != "" { + return pr.ID, nil + } + } + return "", nil // generate without an explicit project +} + +// uploadImage uploads a reference image (i2i) and returns its app-uploads URL. +func (c *Client) uploadImage(ctx context.Context, cookie string, img []byte) (string, error) { + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + fw, err := w.CreateFormFile("file", "image.png") + if err != nil { + return "", err + } + if _, err := fw.Write(img); err != nil { + return "", err + } + _ = w.Close() + body, status, err := c.apiPost(ctx, cookie, "/api/upload?", w.FormDataContentType(), buf.Bytes()) + if err != nil { + return "", fmt.Errorf("%w: upload: %s", ErrTemporaryUpstream, err.Error()) + } + if status == 401 || status == 403 { + return "", ErrAuth + } + if status != 200 { + return "", fmt.Errorf("%w: upload http %d: %s", ErrTemporaryUpstream, status, clip(body, 160)) + } + var ur struct { + ImageURL string `json:"imageUrl"` + } + if json.Unmarshal(body, &ur) != nil || ur.ImageURL == "" { + return "", fmt.Errorf("%w: no imageUrl", ErrTemporaryUpstream) + } + return ur.ImageURL, nil +} + +// GenerateImage runs the full Krea image pipeline: ensure a project, (for i2i) +// upload reference images, submit the job, poll until done, then resolve and +// download the produced image. 402 INSUFFICIENT_BALANCE → ErrQuotaExhausted. +func (c *Client) GenerateImage(ctx context.Context, cookie, prompt string, width, height int, refImages [][]byte) ([]byte, map[string]any, error) { + // Ensure the daily free balance is granted (load /app) before generating, so a + // not-yet-activated account doesn't 402 INSUFFICIENT_BALANCE. Lock-guarded and + // once-per-daily-reset — concurrent gens wait for the first activation, already + // activated ones skip straight through. + c.Activate(ctx, cookie) + + projectID, err := c.ensureProject(ctx, cookie) + if err != nil { + return nil, nil, err + } + + var styleImages []map[string]any + for _, img := range refImages { + if len(img) == 0 { + continue + } + url, upErr := c.uploadImage(ctx, cookie, img) + if upErr != nil { + return nil, nil, upErr + } + styleImages = append(styleImages, map[string]any{"url": url, "strength": refStrength, "source": "upload"}) + } + + payload := map[string]any{ + "provider": genModel, + "prompt": prompt, + "width": width, + "height": height, + "strength": 1, + "steps": 28, + "guidance_scale_flux": 3.5, + "presetStyles": []any{}, + "batchSize": 2, + "guidance": 3.5, + } + if projectID != "" { + payload["project"] = projectID + } + if len(styleImages) > 0 { + payload["styleImages"] = styleImages + } + payloadJSON, _ := json.Marshal(payload) + + // Submit (multipart with a single "payload" field). + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + _ = w.WriteField("payload", string(payloadJSON)) + _ = w.Close() + body, status, err := c.apiPost(ctx, cookie, genEndpoint, w.FormDataContentType(), buf.Bytes()) + if err != nil { + return nil, nil, fmt.Errorf("%w: submit: %s", ErrTemporaryUpstream, err.Error()) + } + if status == 401 || status == 403 { + return nil, nil, ErrAuth + } + if status == 402 || strings.Contains(string(body), "INSUFFICIENT_BALANCE") { + return nil, nil, ErrQuotaExhausted + } + if status != 200 && status != 201 { + return nil, nil, fmt.Errorf("%w: submit http %d: %s", ErrTemporaryUpstream, status, clip(body, 200)) + } + var jobs []struct { + JobID string `json:"job_id"` + } + if err := json.Unmarshal(body, &jobs); err != nil || len(jobs) == 0 || jobs[0].JobID == "" { + return nil, nil, fmt.Errorf("%w: no job id: %s", ErrTemporaryUpstream, clip(body, 200)) + } + // batchSize=2 returns two jobs; keep the SECOND image and discard the first + // (per spec). Fall back to the first if only one came back. + jobID := jobs[0].JobID + if len(jobs) >= 2 && jobs[1].JobID != "" { + jobID = jobs[1].JobID + } + + // Poll until terminal, then resolve the produced image. + imageURL, err := c.pollImage(ctx, cookie, jobID) + if err != nil { + return nil, nil, err + } + data, err := c.download(ctx, imageURL) + if err != nil { + return nil, nil, err + } + return data, map[string]any{"job_id": jobID, "image_url": imageURL, "project": projectID}, nil +} + +// pollImage polls job-status until the job leaves the queue, then matches the +// produced asset by generation_job_id and returns its image URL. +func (c *Client) pollImage(ctx context.Context, cookie, jobID string) (string, error) { + ticker := time.NewTicker(3 * time.Second) + defer ticker.Stop() + deadline := time.Now().Add(4 * time.Minute) + + for { + body, status, err := c.apiGet(ctx, cookie, "/api/job-status?id="+jobID) + if err == nil && status == 200 { + var js struct { + Status string `json:"status"` + } + if json.Unmarshal(body, &js) == nil { + switch strings.ToLower(js.Status) { + case "complete", "completed", "succeeded", "success", "done", "finished": + if url, e := c.assetForJob(ctx, cookie, jobID); e == nil && url != "" { + return url, nil + } + case "failed", "error", "cancelled", "canceled": + return "", fmt.Errorf("%w: job %s", ErrTemporaryUpstream, js.Status) + } + } + } else if status == 401 || status == 403 { + return "", ErrAuth + } + if time.Now().After(deadline) { + return "", fmt.Errorf("%w: generation timed out", ErrTemporaryUpstream) + } + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-ticker.C: + } + } +} + +// assetForJob finds the generated asset produced by a job and returns its URL. +func (c *Client) assetForJob(ctx context.Context, cookie, jobID string) (string, error) { + body, status, err := c.apiGet(ctx, cookie, "/api/assets?filter=generated&offset=0") + if err != nil || status != 200 { + return "", fmt.Errorf("assets http %d", status) + } + var assets []struct { + ImageURL string `json:"image_url"` + Metadata struct { + GenerationJobID string `json:"generation_job_id"` + } `json:"metadata"` + } + if json.Unmarshal(body, &assets) != nil { + return "", fmt.Errorf("assets non-json") + } + for _, a := range assets { + if a.Metadata.GenerationJobID == jobID && strings.TrimSpace(a.ImageURL) != "" { + return a.ImageURL, nil + } + } + return "", fmt.Errorf("asset not found yet") +} + +func (c *Client) download(ctx context.Context, url string) ([]byte, error) { + client, err := c.newTLSClient() + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + req.Header = http.Header{ + "accept": {"image/avif,image/webp,image/png,image/*,*/*;q=0.8"}, + "user-agent": {userAgent}, + "referer": {apiBase + "/"}, + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("%w: image download http %d", ErrTemporaryUpstream, resp.StatusCode) + } + return b, nil +} + +// apiPost issues a POST with a raw body + content-type, carrying the cookie. +func (c *Client) apiPost(ctx context.Context, cookie, path, contentType string, body []byte) ([]byte, int, error) { + client, err := c.newTLSClient() + if err != nil { + return nil, 0, err + } + req, err := http.NewRequest(http.MethodPost, apiBase+path, bytes.NewReader(body)) + if err != nil { + return nil, 0, err + } + req = req.WithContext(ctx) + req.Header = http.Header{ + "accept": {"*/*"}, + "accept-language": {"en-US,en;q=0.9"}, + "content-type": {contentType}, + "cookie": {cookie}, + "origin": {apiBase}, + "referer": {apiBase + "/"}, + "user-agent": {userAgent}, + "sec-fetch-dest": {"empty"}, + "sec-fetch-mode": {"cors"}, + "sec-fetch-site": {"same-origin"}, + http.HeaderOrderKey: { + "accept", "accept-language", "content-type", "cookie", "origin", + "referer", "user-agent", "sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site", + }, + } + resp, err := client.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + return b, resp.StatusCode, err +} + +func (c *Client) apiPostJSON(ctx context.Context, cookie, path string, payload any) ([]byte, int, error) { + b, _ := json.Marshal(payload) + return c.apiPost(ctx, cookie, path, "application/json", b) +} diff --git a/backend/internal/provider/leonardo/client.go b/backend/internal/provider/leonardo/client.go new file mode 100644 index 0000000..83c3bff --- /dev/null +++ b/backend/internal/provider/leonardo/client.go @@ -0,0 +1,396 @@ +// Package leonardo implements the Leonardo.ai (app.leonardo.ai) provider client. +// Unlike chatgpt/runway (whose JWT IS the stored credential), Leonardo's durable +// credential is the browser COOKIE (better-auth session): the bearer access token +// it mints lives only ~1h. So every call here takes the cookie and derives a +// fresh JWT on the fly via /api/auth/get-session — there is no long-lived token to +// store or a separate refresh profile to maintain. tls-client gives a Chrome +// JA3/JA4 fingerprint so the requests aren't flagged. +package leonardo + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "strconv" + "strings" + "sync" + "time" + + http "github.com/bogdanfinn/fhttp" + tlsclient "github.com/bogdanfinn/tls-client" + "github.com/bogdanfinn/tls-client/profiles" +) + +const ( + appBase = "https://app.leonardo.ai" + graphqlURL = "https://api.leonardo.ai/v1/graphql" + schemaVersion = "1.187.0" + userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36" +) + +var ( + ErrAuth = errors.New("leonardo auth failed") + ErrQuotaExhausted = errors.New("leonardo quota exhausted") + ErrTemporaryUpstream = errors.New("leonardo upstream temporary error") +) + +type Client struct { + proxy string + // sessions caches the short-lived access token per cookie so we don't hit + // /api/auth/get-session on every call — Leonardo rate-limits that endpoint + // (429) hard, so re-using the ~1h JWT is essential. + mu sync.Mutex + sessions map[string]*Session +} + +func NewClient(proxy string) *Client { + return &Client{proxy: strings.TrimSpace(proxy), sessions: map[string]*Session{}} +} + +func (c *Client) SetProxy(proxy string) { + c.proxy = strings.TrimSpace(proxy) +} + +// IsLeonardoCookie reports whether a pasted credential is a Leonardo cookie: it +// carries the better-auth session cookie name. This is what disambiguates it from +// an Adobe cookie at import time. +func IsLeonardoCookie(value string) bool { + return strings.Contains(value, "__Secure-better-auth.session_token") || + strings.Contains(value, "better-auth.session_data") +} + +// Session is the result of /api/auth/get-session: the short-lived bearer plus the +// ids the GraphQL API needs (cognitoSub for the quota query, userId for the feed +// and the CDN image path) and the human-facing account fields. +type Session struct { + AccessToken string + CognitoSub string + UserID string + Email string + Name string + ExpiresAt int64 +} + +// GetSession exchanges the cookie for a fresh access token + account ids. A 401/403 +// (or a response with no access token) means the cookie/session is dead → ErrAuth. +func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error) { + cookie = strings.TrimSpace(cookie) + if cookie == "" { + return nil, ErrAuth + } + // Re-use a cached, still-valid access token (keep a 60s safety margin) instead + // of hitting the heavily rate-limited get-session endpoint again. + c.mu.Lock() + if cs, ok := c.sessions[cookie]; ok && cs.ExpiresAt-60 > time.Now().Unix() { + c.mu.Unlock() + return cs, nil + } + c.mu.Unlock() + + client, err := c.newTLSClient() + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodGet, appBase+"/api/auth/get-session", nil) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + req.Header = http.Header{ + "accept": {"*/*"}, + "accept-language": {"en-US,en;q=0.9"}, + "cookie": {cookie}, + "origin": {appBase}, + "referer": {appBase + "/"}, + "user-agent": {userAgent}, + "sec-fetch-dest": {"empty"}, + "sec-fetch-mode": {"cors"}, + "sec-fetch-site": {"same-origin"}, + http.HeaderOrderKey: { + "accept", "accept-language", "cookie", "origin", "referer", + "user-agent", "sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site", + }, + } + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, err.Error()) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode == 401 || resp.StatusCode == 403 { + return nil, ErrAuth + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("%w: get-session http %d: %s", ErrTemporaryUpstream, resp.StatusCode, clip(body, 160)) + } + var raw struct { + Session struct { + AccessToken string `json:"accessToken"` + CognitoSub string `json:"cognitoSub"` + UserID string `json:"userId"` + HasuraUserID string `json:"hasuraUserId"` + TokenExpiry int64 `json:"accessTokenExpiry"` + } `json:"session"` + User struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + } `json:"user"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("%w: get-session non-json", ErrTemporaryUpstream) + } + if strings.TrimSpace(raw.Session.AccessToken) == "" { + // No bearer despite 200 → the cookie no longer authenticates. + return nil, ErrAuth + } + uid := raw.Session.UserID + if uid == "" { + uid = raw.Session.HasuraUserID + } + if uid == "" { + uid = raw.User.ID + } + sess := &Session{ + AccessToken: raw.Session.AccessToken, + CognitoSub: raw.Session.CognitoSub, + UserID: uid, + Email: strings.TrimSpace(raw.User.Email), + Name: strings.TrimSpace(raw.User.Name), + ExpiresAt: raw.Session.TokenExpiry, + } + if sess.ExpiresAt > time.Now().Unix() { + c.mu.Lock() + c.sessions[cookie] = sess + c.mu.Unlock() + } + return sess, nil +} + +const qGetTokens = `query GetUserTokensFromSub($sub: String) { + user_details(where: {cognitoId: {_eq: $sub}}) { + id + plan + subscriptionTokens + paidTokens + rolloverTokens + tokenRenewalDate + __typename + } +}` + +// FetchCreditsBalance derives a JWT from the cookie then reads the account's image +// token balance. Returns a normalized map mirroring the other providers so the +// TokenService quota plumbing is uniform. remaining = subscription+paid+rollover +// (the spendable image tokens); available_until carries the daily renewal time so +// the maintenance sweep can auto-recover a 限额 account. +func (c *Client) FetchCreditsBalance(ctx context.Context, cookie string) (map[string]any, error) { + sess, err := c.GetSession(ctx, cookie) + if err != nil { + if errors.Is(err, ErrAuth) { + return nil, ErrAuth + } + return unknownBalance(err.Error()), nil + } + if sess.CognitoSub == "" { + return unknownBalance("no cognitoSub"), nil + } + + payload, _ := json.Marshal(map[string]any{ + "operationName": "GetUserTokensFromSub", + "variables": map[string]any{"sub": sess.CognitoSub}, + "query": qGetTokens, + }) + body, status, err := c.graphql(ctx, sess.AccessToken, payload) + if err != nil { + return unknownBalance("network: " + err.Error()), nil + } + if status == 401 || status == 403 { + return nil, ErrAuth + } + if status != 200 { + return unknownBalance(fmt.Sprintf("http %d: %s", status, clip(body, 160))), nil + } + var result struct { + Data struct { + UserDetails []struct { + Plan string `json:"plan"` + SubscriptionTokens int `json:"subscriptionTokens"` + PaidTokens int `json:"paidTokens"` + RolloverTokens int `json:"rolloverTokens"` + TokenRenewalDate string `json:"tokenRenewalDate"` + } `json:"user_details"` + } `json:"data"` + } + if err := json.Unmarshal(body, &result); err != nil { + return unknownBalance("non-json"), nil + } + if len(result.Data.UserDetails) == 0 { + return unknownBalance("no user_details"), nil + } + ud := result.Data.UserDetails[0] + remaining := ud.SubscriptionTokens + ud.PaidTokens + ud.RolloverTokens + return map[string]any{ + "remaining": remaining, + "used": nil, + "total": nil, + "unknown": false, + "error": nil, + "plan": ud.Plan, + "available_until": strings.TrimSpace(ud.TokenRenewalDate), + "email": emptyStringNil(sess.Email), + "display_name": emptyStringNil(sess.Name), + "user_id": emptyStringNil(sess.UserID), + }, nil +} + +// graphql POSTs a GraphQL body to the Leonardo API with the bearer + schema header, +// returning the raw response body and status. +func (c *Client) graphql(ctx context.Context, accessToken string, payload []byte) ([]byte, int, error) { + client, err := c.newTLSClient() + if err != nil { + return nil, 0, err + } + req, err := http.NewRequest(http.MethodPost, graphqlURL, bytes.NewReader(payload)) + if err != nil { + return nil, 0, err + } + req = req.WithContext(ctx) + req.Header = http.Header{ + "content-type": {"application/json"}, + "accept": {"*/*"}, + "accept-language": {"en-US,en;q=0.9"}, + "origin": {appBase}, + "referer": {appBase + "/"}, + "user-agent": {userAgent}, + "authorization": {"Bearer " + accessToken}, + "x-leo-schema-version": {schemaVersion}, + "sec-fetch-dest": {"empty"}, + "sec-fetch-mode": {"cors"}, + "sec-fetch-site": {"same-site"}, + http.HeaderOrderKey: { + "content-type", "accept", "accept-language", "origin", "referer", + "user-agent", "authorization", "x-leo-schema-version", + "sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site", + }, + } + resp, err := client.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, resp.StatusCode, err + } + return body, resp.StatusCode, nil +} + +func unknownBalance(reason string) map[string]any { + return map[string]any{ + "remaining": nil, + "used": nil, + "total": nil, + "unknown": true, + "error": reason, + } +} + +func (c *Client) newTLSClient() (tlsclient.HttpClient, error) { + // Match the fingerprint proven to work against Leonardo's Cloudflare edge: + // Chrome_120, fixed extension order. A randomized JA3 (Chrome_133 + + // WithRandomTLSExtensionOrder) gets flagged and 429'd at get-session. + options := []tlsclient.HttpClientOption{ + tlsclient.WithTimeoutSeconds(60), + tlsclient.WithClientProfile(profiles.Chrome_120), + } + if c.proxy != "" { + options = append(options, tlsclient.WithProxyUrl(c.proxy)) + } + return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...) +} + +// downloadImage fetches a generated image (cdn.leonardo.ai) and returns the bytes. +func (c *Client) downloadImage(ctx context.Context, imageURL string) ([]byte, error) { + if _, err := url.Parse(imageURL); err != nil { + return nil, err + } + client, err := c.newTLSClient() + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodGet, imageURL, nil) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + req.Header = http.Header{ + "accept": {"image/avif,image/webp,image/png,image/*,*/*;q=0.8"}, + "user-agent": {userAgent}, + "referer": {appBase + "/"}, + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("%w: image download http %d", ErrTemporaryUpstream, resp.StatusCode) + } + return body, nil +} + +func stringValue(v any) string { + switch x := v.(type) { + case string: + return x + case nil: + return "" + default: + b, _ := json.Marshal(x) + return strings.TrimSpace(string(b)) + } +} + +func intValue(v any) int { + switch x := v.(type) { + case int: + return x + case int64: + return int(x) + case float64: + return int(x) + case json.Number: + n, _ := x.Int64() + return int(n) + case string: + n, _ := strconv.Atoi(strings.TrimSpace(x)) + return n + default: + return 0 + } +} + +func emptyStringNil(v string) any { + v = strings.TrimSpace(v) + if v == "" { + return nil + } + return v +} + +func clip(b []byte, n int) string { + s := strings.TrimSpace(string(b)) + if len(s) > n { + return s[:n] + } + return s +} diff --git a/backend/internal/provider/leonardo/image.go b/backend/internal/provider/leonardo/image.go new file mode 100644 index 0000000..35f4bd9 --- /dev/null +++ b/backend/internal/provider/leonardo/image.go @@ -0,0 +1,326 @@ +package leonardo + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "mime/multipart" + "strings" + "time" + + http "github.com/bogdanfinn/fhttp" +) + +// defaultStyleID is the "Dynamic" style applied when the caller doesn't specify +// one — Leonardo's Generate mutation expects a style_ids entry. +const defaultStyleID = "111dc692-d470-4eec-b791-3475abac4c46" + +const mGenerate = `mutation Generate($request: CreateGenerationRequest!) { + generate(request: $request) { + apiCreditCost + generationId + __typename + } +}` + +// qGenerationImages polls one generation's status AND its produced images in a +// single round-trip (where: id _in [genId]). +const qGenerationImages = `query GenerationImages($where: generations_bool_exp = {}) { + generations(where: $where) { + id + status + generated_images { + id + url + __typename + } + __typename + } +}` + +const mUploadImage = `mutation UploadImage($uploadImageInput: UploadImageInput!) { + uploadImage(arg1: $uploadImageInput) { + uploadId + url + fields + __typename + } +}` + +// uploadInitImage uploads a reference (init) image for image-to-image: it asks +// Leonardo for a presigned S3 POST, uploads the bytes, and returns the upload id +// to reference in the Generate request's image_reference guidance. +func (c *Client) uploadInitImage(ctx context.Context, accessToken string, img []byte) (string, error) { + payload, _ := json.Marshal(map[string]any{ + "operationName": "UploadImage", + "query": mUploadImage, + "variables": map[string]any{"uploadImageInput": map[string]any{"uploadType": "INIT", "extension": "png"}}, + }) + body, status, err := c.graphql(ctx, accessToken, payload) + if err != nil { + return "", fmt.Errorf("%w: upload-init: %s", ErrTemporaryUpstream, err.Error()) + } + if status == 401 || status == 403 { + return "", ErrAuth + } + if status != 200 { + return "", fmt.Errorf("%w: upload-init http %d: %s", ErrTemporaryUpstream, status, clip(body, 160)) + } + if e := graphqlError(body); e != nil { + return "", e + } + var ur struct { + Data struct { + UploadImage struct { + UploadID string `json:"uploadId"` + URL string `json:"url"` + Fields string `json:"fields"` + } `json:"uploadImage"` + } `json:"data"` + } + if err := json.Unmarshal(body, &ur); err != nil { + return "", fmt.Errorf("%w: upload-init non-json", ErrTemporaryUpstream) + } + up := ur.Data.UploadImage + if up.UploadID == "" || up.URL == "" { + return "", fmt.Errorf("%w: no upload url", ErrTemporaryUpstream) + } + var fields map[string]string + if err := json.Unmarshal([]byte(up.Fields), &fields); err != nil { + return "", fmt.Errorf("%w: bad upload fields", ErrTemporaryUpstream) + } + + // Presigned S3 POST: all policy fields first, the file part LAST. + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + for k, v := range fields { + _ = w.WriteField(k, v) + } + fw, err := w.CreateFormFile("file", "image.png") + if err != nil { + return "", err + } + if _, err := fw.Write(img); err != nil { + return "", err + } + _ = w.Close() + + client, err := c.newTLSClient() + if err != nil { + return "", err + } + req, err := http.NewRequest(http.MethodPost, up.URL, &buf) + if err != nil { + return "", err + } + req = req.WithContext(ctx) + req.Header = http.Header{ + "content-type": {w.FormDataContentType()}, + "user-agent": {userAgent}, + "origin": {appBase}, + "referer": {appBase + "/"}, + } + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("%w: s3 upload: %s", ErrTemporaryUpstream, err.Error()) + } + defer resp.Body.Close() + if resp.StatusCode != 204 && resp.StatusCode != 200 && resp.StatusCode != 201 { + return "", fmt.Errorf("%w: s3 upload http %d", ErrTemporaryUpstream, resp.StatusCode) + } + return up.UploadID, nil +} + +// GenerateImage runs the full Leonardo image pipeline against one account cookie: +// mint a JWT, (for image-to-image) upload each reference image, submit the +// Generate mutation, poll until COMPLETE, then download the first produced image. +// Returns the image bytes, an info map, and a classified error. +func (c *Client) GenerateImage(ctx context.Context, cookie, model, prompt string, width, height int, styleIDs []string, refImages [][]byte) ([]byte, map[string]any, error) { + sess, err := c.GetSession(ctx, cookie) + if err != nil { + return nil, nil, err + } + if len(styleIDs) == 0 { + styleIDs = []string{defaultStyleID} + } + if strings.TrimSpace(model) == "" { + model = "seedream-4.5" + } + + // Image-to-image: upload each reference and collect its guidance entry. + var imageRefs []map[string]any + for _, img := range refImages { + if len(img) == 0 { + continue + } + uploadID, upErr := c.uploadInitImage(ctx, sess.AccessToken, img) + if upErr != nil { + return nil, nil, upErr + } + imageRefs = append(imageRefs, map[string]any{ + "image": map[string]any{"id": uploadID, "type": "UPLOADED"}, + "strength": "MID", + }) + } + + promptEnhance := "AUTO" + parameters := map[string]any{ + "height": height, + "width": width, + "prompt_enhance": promptEnhance, + "quantity": 1, + "style_ids": styleIDs, + "prompt": prompt, + } + if len(imageRefs) > 0 { + // Preserve the reference when image-guided (matches the web app). + parameters["prompt_enhance"] = "OFF" + parameters["guidances"] = map[string]any{"image_reference": imageRefs} + } + + // 1. submit + genReq := map[string]any{ + "operationName": "Generate", + "query": mGenerate, + "variables": map[string]any{ + "request": map[string]any{ + "model": model, + "public": true, + "parameters": parameters, + }, + }, + } + payload, _ := json.Marshal(genReq) + body, status, err := c.graphql(ctx, sess.AccessToken, payload) + if err != nil { + return nil, nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, err.Error()) + } + if status == 401 || status == 403 { + return nil, nil, ErrAuth + } + if status != 200 { + return nil, nil, fmt.Errorf("%w: generate http %d: %s", ErrTemporaryUpstream, status, clip(body, 200)) + } + if e := graphqlError(body); e != nil { + return nil, nil, e + } + var genResp struct { + Data struct { + Generate struct { + GenerationID string `json:"generationId"` + } `json:"generate"` + } `json:"data"` + } + if err := json.Unmarshal(body, &genResp); err != nil { + return nil, nil, fmt.Errorf("%w: generate non-json", ErrTemporaryUpstream) + } + genID := strings.TrimSpace(genResp.Data.Generate.GenerationID) + if genID == "" { + return nil, nil, fmt.Errorf("%w: no generationId: %s", ErrTemporaryUpstream, clip(body, 200)) + } + + // 2. poll until COMPLETE, then read the image url. + imageURL, err := c.pollImage(ctx, sess.AccessToken, genID) + if err != nil { + return nil, nil, err + } + + // 3. download bytes + data, err := c.downloadImage(ctx, imageURL) + if err != nil { + return nil, nil, err + } + info := map[string]any{ + "generation_id": genID, + "image_url": imageURL, + "user_id": sess.UserID, + } + return data, info, nil +} + +// pollImage polls one generation until it reports COMPLETE (returning the first +// image url) or FAILED (error). Honors ctx cancellation / deadline. +func (c *Client) pollImage(ctx context.Context, accessToken, genID string) (string, error) { + payload, _ := json.Marshal(map[string]any{ + "operationName": "GenerationImages", + "query": qGenerationImages, + "variables": map[string]any{ + "where": map[string]any{"id": map[string]any{"_in": []string{genID}}}, + }, + }) + + ticker := time.NewTicker(3 * time.Second) + defer ticker.Stop() + // Cap the wait independent of the parent deadline so a stuck job can't hang. + deadline := time.Now().Add(5 * time.Minute) + + for { + body, status, err := c.graphql(ctx, accessToken, payload) + if err != nil { + return "", fmt.Errorf("%w: poll: %s", ErrTemporaryUpstream, err.Error()) + } + if status == 401 || status == 403 { + return "", ErrAuth + } + if status == 200 { + var pr struct { + Data struct { + Generations []struct { + Status string `json:"status"` + GeneratedImages []struct { + URL string `json:"url"` + } `json:"generated_images"` + } `json:"generations"` + } `json:"data"` + } + if err := json.Unmarshal(body, &pr); err == nil && len(pr.Data.Generations) > 0 { + g := pr.Data.Generations[0] + switch strings.ToUpper(g.Status) { + case "COMPLETE": + for _, img := range g.GeneratedImages { + if u := strings.TrimSpace(img.URL); u != "" { + return u, nil + } + } + return "", fmt.Errorf("%w: complete but no image url", ErrTemporaryUpstream) + case "FAILED": + return "", fmt.Errorf("%w: generation failed", ErrTemporaryUpstream) + } + } + } + + if time.Now().After(deadline) { + return "", fmt.Errorf("%w: generation timed out", ErrTemporaryUpstream) + } + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-ticker.C: + } + } +} + +// graphqlError inspects a GraphQL response body for an "errors" array and maps the +// first message to a classified sentinel (auth / quota / temporary). Returns nil +// when there are no errors. +func graphqlError(body []byte) error { + var env struct { + Errors []struct { + Message string `json:"message"` + } `json:"errors"` + } + if err := json.Unmarshal(body, &env); err != nil || len(env.Errors) == 0 { + return nil + } + msg := strings.TrimSpace(env.Errors[0].Message) + low := strings.ToLower(msg) + switch { + case strings.Contains(low, "unauthor") || strings.Contains(low, "jwt") || strings.Contains(low, "token is") || strings.Contains(low, "forbidden"): + return ErrAuth + case strings.Contains(low, "token") || strings.Contains(low, "credit") || strings.Contains(low, "quota") || strings.Contains(low, "insufficient") || strings.Contains(low, "not enough"): + return ErrQuotaExhausted + default: + return fmt.Errorf("leonardo: %s", clip([]byte(msg), 200)) + } +} diff --git a/backend/internal/provider/runway/client.go b/backend/internal/provider/runway/client.go new file mode 100644 index 0000000..fd1b0e2 --- /dev/null +++ b/backend/internal/provider/runway/client.go @@ -0,0 +1,254 @@ +// Package runway implements the Runway (runwayml.com) provider client. For now +// it only covers account management — JWT detection, workspace/team id +// extraction and credit-balance probing — mirroring the curl_cffi reference in +// query_credits.py with tls-client so the JA3/JA4 fingerprint matches Chrome. +package runway + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "strconv" + "strings" + + http "github.com/bogdanfinn/fhttp" + tlsclient "github.com/bogdanfinn/tls-client" + "github.com/bogdanfinn/tls-client/profiles" +) + +const ( + apiBase = "https://api.runwayml.com" + origin = "https://app.runwayml.com" +) + +var ( + ErrAuth = errors.New("runway auth failed") + ErrQuotaExhausted = errors.New("runway quota exhausted") + ErrTemporaryUpstream = errors.New("runway upstream temporary error") +) + +type Client struct { + proxy string +} + +func NewClient(proxy string) *Client { + return &Client{proxy: strings.TrimSpace(proxy)} +} + +func (c *Client) SetProxy(proxy string) { + c.proxy = strings.TrimSpace(proxy) +} + +// IsRunwayToken reports whether a JWT looks like a Runway access token: a +// top-level numeric "id" plus an "sso" claim, and crucially NO OpenAI +// (https://api.openai.com/*) claims — that's what disambiguates it from a +// ChatGPT token, which is otherwise also an opaque three-part JWT. +func IsRunwayToken(token string) bool { + claims := decodeJWTPayload(token) + if len(claims) == 0 { + return false + } + for k := range claims { + if strings.HasPrefix(k, "https://api.openai.com/") { + return false + } + } + _, hasSSO := claims["sso"] + return hasSSO && claims["id"] != nil +} + +// TeamIDFromToken returns the Runway workspace/team id, which equals the JWT +// "id" claim (query_credits.py / gen_video.py both derive teamId this way). +func TeamIDFromToken(token string) string { + claims := decodeJWTPayload(token) + switch v := claims["id"].(type) { + case float64: + return strconv.FormatInt(int64(v), 10) + case json.Number: + return v.String() + case string: + return strings.TrimSpace(v) + default: + return "" + } +} + +// ExtractAccountInfo decodes the free (no-network) JWT claims for the accounts +// view: email, team id and expiry. +func ExtractAccountInfo(token string) map[string]any { + claims := decodeJWTPayload(token) + return map[string]any{ + "email": emptyStringNil(strings.TrimSpace(stringValue(claims["email"]))), + "team_id": emptyStringNil(TeamIDFromToken(token)), + "expires_at": claims["exp"], + } +} + +// FetchCreditsBalance probes the account's plan credits via /v1/profile/features +// (query_credits.py). Returns a normalized map mirroring the Adobe client so the +// TokenService quota plumbing can treat all providers uniformly. A 401/403 maps +// to ErrAuth (token dead); any other failure is reported as unknown without +// killing the account. +func (c *Client) FetchCreditsBalance(ctx context.Context, token string) (map[string]any, error) { + token = strings.TrimSpace(token) + if token == "" { + return unknownBalance("empty token"), nil + } + teamID := TeamIDFromToken(token) + if teamID == "" { + return unknownBalance("no team id"), nil + } + + client, err := c.newTLSClient() + if err != nil { + return nil, err + } + url := apiBase + "/v1/profile/features?asTeamId=" + teamID + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + req.Header = http.Header{ + "accept": {"application/json"}, + "content-type": {"application/json"}, + "origin": {origin}, + "referer": {origin + "/"}, + "authorization": {"Bearer " + token}, + "x-runway-workspace": {teamID}, + http.HeaderOrderKey: { + "accept", + "content-type", + "origin", + "referer", + "authorization", + "x-runway-workspace", + }, + } + + resp, err := client.Do(req) + if err != nil { + return unknownBalance("network: " + err.Error()), nil + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode == 401 || resp.StatusCode == 403 { + return nil, ErrAuth + } + if resp.StatusCode != 200 { + return unknownBalance(fmt.Sprintf("http %d: %s", resp.StatusCode, clip(body, 160))), nil + } + + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return unknownBalance("non-json"), nil + } + features, _ := payload["features"].(map[string]any) + permitted, _ := features["permitted"].(map[string]any) + used, _ := features["used"].(map[string]any) + total := intValue(permitted["numPlanCredits"]) + spent := intValue(used["numPlanCredits"]) + remaining := total - spent + if remaining < 0 { + remaining = 0 + } + return map[string]any{ + "remaining": remaining, + "used": spent, + "total": total, + "unknown": false, + "error": nil, + }, nil +} + +func unknownBalance(reason string) map[string]any { + return map[string]any{ + "remaining": nil, + "used": nil, + "total": nil, + "unknown": true, + "error": reason, + } +} + +func (c *Client) newTLSClient() (tlsclient.HttpClient, error) { + options := []tlsclient.HttpClientOption{ + tlsclient.WithTimeoutSeconds(30), + tlsclient.WithClientProfile(profiles.Chrome_133), + tlsclient.WithRandomTLSExtensionOrder(), + } + if c.proxy != "" { + options = append(options, tlsclient.WithProxyUrl(c.proxy)) + } + return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...) +} + +func decodeJWTPayload(token string) map[string]any { + parts := strings.Split(strings.TrimSpace(strings.TrimPrefix(token, "Bearer ")), ".") + if len(parts) < 2 { + return map[string]any{} + } + raw, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return map[string]any{} + } + var out map[string]any + if err := json.Unmarshal(raw, &out); err != nil { + return map[string]any{} + } + return out +} + +func stringValue(v any) string { + switch x := v.(type) { + case string: + return x + case nil: + return "" + default: + b, _ := json.Marshal(x) + return strings.TrimSpace(string(b)) + } +} + +func intValue(v any) int { + switch x := v.(type) { + case int: + return x + case int64: + return int(x) + case float64: + return int(x) + case json.Number: + n, _ := x.Int64() + return int(n) + case string: + n, _ := strconv.Atoi(strings.TrimSpace(x)) + return n + default: + return 0 + } +} + +func emptyStringNil(v string) any { + v = strings.TrimSpace(v) + if v == "" { + return nil + } + return v +} + +func clip(b []byte, n int) string { + s := strings.TrimSpace(string(b)) + if len(s) > n { + return s[:n] + } + return s +} diff --git a/backend/internal/provider/runway/video.go b/backend/internal/provider/runway/video.go new file mode 100644 index 0000000..3571945 --- /dev/null +++ b/backend/internal/provider/runway/video.go @@ -0,0 +1,391 @@ +package runway + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" + mrand "math/rand/v2" + "strings" + "time" + + http "github.com/bogdanfinn/fhttp" + tlsclient "github.com/bogdanfinn/tls-client" + "github.com/google/uuid" +) + +// ratioDimensions maps an aspect ratio to the Gen-4 Turbo native output size. +// These are the only dimensions gen4_turbo accepts; "2K" is a UI label over this +// native tier (see runway-video-gen-spec). Unknown ratios fall back to 16:9. +func ratioDimensions(aspectRatio string) (int, int) { + switch strings.TrimSpace(strings.ReplaceAll(aspectRatio, "x", ":")) { + case "16:9": + return 1280, 720 + case "9:16": + return 720, 1280 + case "1:1": + return 960, 960 + case "4:3": + return 1104, 832 + case "3:4": + return 832, 1104 + case "21:9": + return 1584, 672 + default: + return 1280, 720 + } +} + +// GenerateVideo runs the full i2v pipeline (gen_video.py): upload the first-frame +// image (preview + dataset), create a dataset, create a gen4_turbo task and poll +// it to completion, then download the rendered MP4. teamID is the workspace id +// (meta["team_id"]); if empty it's derived from the token. seconds must be 5 or +// 10; aspectRatio picks the native output size. +// GenerateVideo renders the clip and (when downloadResult) downloads the MP4. +// With downloadResult=false it returns nil bytes and the upstream artifact URL in +// meta["video_url"] — used by the async /v1/videos job, which proxies that URL on +// /content instead of persisting the file. +func (c *Client) GenerateVideo(ctx context.Context, token, teamID, prompt, aspectRatio string, seconds int, frame []byte, downloadResult bool) ([]byte, map[string]any, error) { + token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer ")) + if token == "" { + return nil, nil, ErrAuth + } + if teamID == "" { + teamID = TeamIDFromToken(token) + } + if teamID == "" { + return nil, nil, errors.New("runway: no team id") + } + if len(frame) == 0 { + return nil, nil, errors.New("runway: first-frame image required") + } + cfg, _, err := image.DecodeConfig(bytes.NewReader(frame)) + if err != nil { + return nil, nil, errors.New("runway: failed to decode first-frame image") + } + + client, err := c.newTLSClient() + if err != nil { + return nil, nil, err + } + + filename := "frame_" + time.Now().UTC().Format("20060102_150405") + ".png" + previewUploadID, _, err := c.uploadFile(ctx, client, token, teamID, filename, "DATASET_PREVIEW", frame) + if err != nil { + return nil, nil, err + } + datasetUploadID, _, err := c.uploadFile(ctx, client, token, teamID, filename, "DATASET", frame) + if err != nil { + return nil, nil, err + } + + assetID, imageURL, err := c.createDataset(ctx, client, token, teamID, filename, datasetUploadID, previewUploadID, cfg.Width, cfg.Height) + if err != nil { + return nil, nil, err + } + assetGroupID, _ := c.assetGroupID(ctx, client, token, teamID) // best-effort + + taskID, err := c.createTask(ctx, client, token, teamID, prompt, imageURL, assetID, assetGroupID, aspectRatio, seconds) + if err != nil { + return nil, nil, err + } + + artifactURL, err := c.pollTask(ctx, client, token, teamID, taskID) + if err != nil { + return nil, nil, err + } + meta := map[string]any{ + "provider": "runway", + "task_id": taskID, + "team_id": teamID, + "video_url": artifactURL, + } + if !downloadResult { + return nil, meta, nil + } + data, err := c.download(ctx, client, artifactURL) + if err != nil { + return nil, nil, err + } + return data, meta, nil +} + +// uploadFile mirrors gen_video.upload_file: register the upload, PUT the bytes to +// the returned S3 URL, then complete. Returns the upload id and final url. +func (c *Client) uploadFile(ctx context.Context, client tlsclient.HttpClient, token, teamID, filename, uploadType string, data []byte) (string, string, error) { + info, err := c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/uploads", map[string]any{ + "filename": filename, + "numberOfParts": 1, + "type": uploadType, + }) + if err != nil { + return "", "", err + } + uploadID := strings.TrimSpace(stringValue(info["id"])) + urls, _ := info["uploadUrls"].([]any) + if uploadID == "" || len(urls) == 0 { + return "", "", fmt.Errorf("%w: upload register missing fields", ErrTemporaryUpstream) + } + putURL := strings.TrimSpace(stringValue(urls[0])) + contentType := "application/octet-stream" + if hdrs, ok := info["uploadHeaders"].(map[string]any); ok { + if ct := strings.TrimSpace(stringValue(hdrs["Content-Type"])); ct != "" { + contentType = ct + } + } + + etag, err := c.putBytes(ctx, client, putURL, contentType, data) + if err != nil { + return "", "", err + } + + res, err := c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/uploads/"+uploadID+"/complete", map[string]any{ + "parts": []map[string]any{{"PartNumber": 1, "ETag": etag}}, + }) + if err != nil { + return "", "", err + } + return uploadID, strings.TrimSpace(stringValue(res["url"])), nil +} + +func (c *Client) createDataset(ctx context.Context, client tlsclient.HttpClient, token, teamID, filename, datasetUploadID, previewUploadID string, w, h int) (string, string, error) { + teamIDNum := jsonNumberOrString(teamID) + res, err := c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/datasets", map[string]any{ + "fileCount": 1, + "name": filename, + "uploadId": datasetUploadID, + "previewUploadIds": []string{previewUploadID}, + "metadata": map[string]any{"size": map[string]any{"width": w, "height": h}}, + "type": map[string]any{"name": "image", "type": "image", "isDirectory": false}, + "asTeamId": teamIDNum, + "privateInTeam": true, + }) + if err != nil { + return "", "", err + } + ds, _ := res["dataset"].(map[string]any) + id := strings.TrimSpace(stringValue(ds["id"])) + url := strings.TrimSpace(stringValue(ds["url"])) + if id == "" || url == "" { + return "", "", fmt.Errorf("%w: dataset missing fields", ErrTemporaryUpstream) + } + return id, url, nil +} + +func (c *Client) assetGroupID(ctx context.Context, client tlsclient.HttpClient, token, teamID string) (string, error) { + res, err := c.apiJSON(ctx, client, token, teamID, http.MethodGet, + "/v1/asset_groups/by_name?name=Generations&asTeamId="+teamID+"&privateInTeam=true", nil) + if err != nil { + return "", err + } + ag, _ := res["assetGroup"].(map[string]any) + return strings.TrimSpace(stringValue(ag["id"])), nil +} + +func (c *Client) createTask(ctx context.Context, client tlsclient.HttpClient, token, teamID, prompt, imageURL, assetID, assetGroupID, aspectRatio string, seconds int) (string, error) { + w, h := ratioDimensions(aspectRatio) + opts := map[string]any{ + "route": "i2v", + "name": "Gen-4 Turbo - " + prompt, + "text_prompt": prompt, + "seconds": seconds, + "width": w, + "height": h, + "init_image": imageURL, + "imageAssetId": assetID, + "exploreMode": false, + "creationSource": "tool-mode", + "seed": mrand.IntN(999999999) + 1, + "watermark": true, + } + if assetGroupID != "" { + opts["assetGroupId"] = assetGroupID + } + res, err := c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/tasks", map[string]any{ + "taskType": "gen4_turbo", + "options": opts, + "asTeamId": jsonNumberOrString(teamID), + "sessionId": uuid.NewString(), + }) + if err != nil { + return "", err + } + task, _ := res["task"].(map[string]any) + id := strings.TrimSpace(stringValue(task["id"])) + if id == "" { + return "", fmt.Errorf("%w: task missing id", ErrTemporaryUpstream) + } + return id, nil +} + +func (c *Client) pollTask(ctx context.Context, client tlsclient.HttpClient, token, teamID, taskID string) (string, error) { + for { + if err := ctx.Err(); err != nil { + return "", err + } + res, err := c.apiJSON(ctx, client, token, teamID, http.MethodGet, "/v1/tasks/"+taskID+"?asTeamId="+teamID, nil) + if err != nil { + // A transient blip shouldn't kill a render that may still succeed. + if errors.Is(err, ErrTemporaryUpstream) { + if sleepCtx(ctx, 5*time.Second) != nil { + return "", ctx.Err() + } + continue + } + return "", err + } + task, _ := res["task"].(map[string]any) + status := strings.ToUpper(strings.TrimSpace(stringValue(task["status"]))) + switch status { + case "SUCCEEDED": + arts, _ := task["artifacts"].([]any) + for _, raw := range arts { + art, _ := raw.(map[string]any) + if url := strings.TrimSpace(stringValue(art["url"])); url != "" { + return url, nil + } + } + return "", errors.New("runway: task succeeded with no artifact url") + case "FAILED", "CANCELED": + reason := strings.TrimSpace(stringValue(task["error"])) + if isCreditError(reason) { + return "", fmt.Errorf("%w: %s", ErrQuotaExhausted, reason) + } + return "", fmt.Errorf("runway: task %s: %s", status, reason) + } + if sleepCtx(ctx, 5*time.Second) != nil { + return "", ctx.Err() + } + } +} + +// apiJSON performs an authed JSON request against the Runway API and returns the +// parsed body, mapping status codes to the shared provider error sentinels. +func (c *Client) apiJSON(ctx context.Context, client tlsclient.HttpClient, token, teamID, method, path string, body any) (map[string]any, error) { + var reader io.Reader + if body != nil { + raw, _ := json.Marshal(body) + reader = bytes.NewReader(raw) + } + req, err := http.NewRequest(method, apiBase+path, reader) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + req.Header = http.Header{ + "accept": {"application/json"}, + "content-type": {"application/json"}, + "origin": {origin}, + "referer": {origin + "/"}, + "authorization": {"Bearer " + token}, + "x-runway-workspace": {teamID}, + http.HeaderOrderKey: { + "accept", "content-type", "origin", "referer", "authorization", "x-runway-workspace", + }, + } + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err) + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + switch { + case resp.StatusCode == 401 || resp.StatusCode == 403: + return nil, fmt.Errorf("%w: %s %d %s", ErrAuth, path, resp.StatusCode, clip(raw, 200)) + case resp.StatusCode == 429: + return nil, fmt.Errorf("%w: %s 429 %s", ErrQuotaExhausted, path, clip(raw, 200)) + case resp.StatusCode >= 500: + return nil, fmt.Errorf("%w: %s %d %s", ErrTemporaryUpstream, path, resp.StatusCode, clip(raw, 200)) + case resp.StatusCode < 200 || resp.StatusCode >= 300: + if isCreditError(string(raw)) { + return nil, fmt.Errorf("%w: %s", ErrQuotaExhausted, clip(raw, 200)) + } + return nil, fmt.Errorf("runway: %s %d %s", path, resp.StatusCode, clip(raw, 200)) + } + var out map[string]any + if len(raw) == 0 { + return map[string]any{}, nil + } + if err := json.Unmarshal(raw, &out); err != nil { + return nil, fmt.Errorf("%w: %s non-json: %s", ErrTemporaryUpstream, path, clip(raw, 120)) + } + return out, nil +} + +// putBytes uploads raw bytes to a presigned S3 URL (no auth) and returns the +// ETag, mirroring the plain requests.Session().put in gen_video.py. +func (c *Client) putBytes(ctx context.Context, client tlsclient.HttpClient, url, contentType string, data []byte) (string, error) { + req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(data)) + if err != nil { + return "", err + } + req = req.WithContext(ctx) + req.Header = http.Header{"content-type": {contentType}} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err) + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("%w: s3 put %d", ErrTemporaryUpstream, resp.StatusCode) + } + return strings.Trim(resp.Header.Get("ETag"), `"`), nil +} + +func (c *Client) download(ctx context.Context, client tlsclient.HttpClient, url string) ([]byte, error) { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("%w: download %d", ErrTemporaryUpstream, resp.StatusCode) + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if len(data) == 0 { + return nil, errors.New("runway: empty artifact download") + } + return data, nil +} + +// jsonNumberOrString returns the team id as a JSON number when it's purely +// numeric (Runway's asTeamId is an integer in the reference payloads), else the +// raw string. +func jsonNumberOrString(teamID string) any { + return json.Number(strings.TrimSpace(teamID)) +} + +func isCreditError(s string) bool { + s = strings.ToLower(s) + return strings.Contains(s, "credit") || strings.Contains(s, "insufficient") || strings.Contains(s, "quota") +} + +// sleepCtx sleeps for d or until ctx is done; returns ctx.Err() if cancelled. +func sleepCtx(ctx context.Context, d time.Duration) error { + t := time.NewTimer(d) + defer t.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + return nil + } +} diff --git a/backend/internal/repo/api_key_repo.go b/backend/internal/repo/api_key_repo.go new file mode 100644 index 0000000..e4a172f --- /dev/null +++ b/backend/internal/repo/api_key_repo.go @@ -0,0 +1,53 @@ +package repo + +import ( + "context" + "time" + + "backend/internal/model" + "gorm.io/gorm" +) + +type APIKeyRepository struct { + db *gorm.DB +} + +func NewAPIKeyRepository(db *gorm.DB) *APIKeyRepository { + return &APIKeyRepository{db: db} +} + +func (r *APIKeyRepository) ListByUserID(ctx context.Context, userID string) ([]model.APIKey, error) { + var keys []model.APIKey + if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Order("created_at asc").Find(&keys).Error; err != nil { + return nil, err + } + return keys, nil +} + +func (r *APIKeyRepository) ReplaceForUser(ctx context.Context, userID string, key *model.APIKey) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("user_id = ?", userID).Delete(&model.APIKey{}).Error; err != nil { + return err + } + return tx.Create(key).Error + }) +} + +func (r *APIKeyRepository) DeleteByUserID(ctx context.Context, userID string) error { + return r.db.WithContext(ctx).Where("user_id = ?", userID).Delete(&model.APIKey{}).Error +} + +func (r *APIKeyRepository) DeleteByID(ctx context.Context, userID, keyID string) error { + return r.db.WithContext(ctx). + Where("user_id = ? AND id = ?", userID, keyID). + Delete(&model.APIKey{}).Error +} + +func (r *APIKeyRepository) Create(ctx context.Context, key *model.APIKey) error { + return r.db.WithContext(ctx).Create(key).Error +} + +func (r *APIKeyRepository) TouchUsage(ctx context.Context, keyHash string) error { + now := time.Now() + return r.db.WithContext(ctx).Model(&model.APIKey{}).Where("key_hash = ?", keyHash).Update("last_used_at", now).Error +} diff --git a/backend/internal/repo/cdk_repo.go b/backend/internal/repo/cdk_repo.go new file mode 100644 index 0000000..fddba3c --- /dev/null +++ b/backend/internal/repo/cdk_repo.go @@ -0,0 +1,134 @@ +package repo + +import ( + "context" + "errors" + "time" + + "backend/internal/model" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// ErrCDKBatchLimit is returned when a user tries to redeem a second code from +// the same marketing batch (one per user per batch). +var ErrCDKBatchLimit = errors.New("cdk marketing batch already redeemed by this user") + +type CDKRepository struct { + db *gorm.DB +} + +func NewCDKRepository(db *gorm.DB) *CDKRepository { + return &CDKRepository{db: db} +} + +func (r *CDKRepository) List(ctx context.Context) ([]model.CDKCode, error) { + var items []model.CDKCode + if err := r.db.WithContext(ctx).Order("created_at desc").Find(&items).Error; err != nil { + return nil, err + } + return items, nil +} + +func (r *CDKRepository) Stats(ctx context.Context) (map[string]any, error) { + var total, active, redeemed int64 + if err := r.db.WithContext(ctx).Model(&model.CDKCode{}).Count(&total).Error; err != nil { + return nil, err + } + if err := r.db.WithContext(ctx).Model(&model.CDKCode{}).Where("status = ?", "active").Count(&active).Error; err != nil { + return nil, err + } + if err := r.db.WithContext(ctx).Model(&model.CDKCode{}).Where("status = ?", "redeemed").Count(&redeemed).Error; err != nil { + return nil, err + } + + type sumRow struct { + Total *float64 `gorm:"column:total"` + } + var activeAmount, redeemedAmount sumRow + if err := r.db.WithContext(ctx). + Model(&model.CDKCode{}). + Select("SUM(amount) AS total"). + Where("status = ?", "active"). + Scan(&activeAmount).Error; err != nil { + return nil, err + } + if err := r.db.WithContext(ctx). + Model(&model.CDKCode{}). + Select("SUM(amount) AS total"). + Where("status = ?", "redeemed"). + Scan(&redeemedAmount).Error; err != nil { + return nil, err + } + + activeAmt := 0.0 + if activeAmount.Total != nil { + activeAmt = *activeAmount.Total + } + redeemedAmt := 0.0 + if redeemedAmount.Total != nil { + redeemedAmt = *redeemedAmount.Total + } + + return map[string]any{ + "total": total, + "active": active, + "redeemed": redeemed, + "active_amount": activeAmt, + "redeemed_amount": redeemedAmt, + }, nil +} + +func (r *CDKRepository) CreateBatch(ctx context.Context, items []model.CDKCode) error { + return r.db.WithContext(ctx).Create(&items).Error +} + +func (r *CDKRepository) Delete(ctx context.Context, code string) (int64, error) { + res := r.db.WithContext(ctx).Delete(&model.CDKCode{}, "code = ?", code) + return res.RowsAffected, res.Error +} + +func (r *CDKRepository) DeleteByCodes(ctx context.Context, codes []string) (int64, error) { + if len(codes) == 0 { + return 0, nil + } + res := r.db.WithContext(ctx).Delete(&model.CDKCode{}, "code IN ?", codes) + return res.RowsAffected, res.Error +} + +func (r *CDKRepository) Redeem(ctx context.Context, code, userID string) (*model.CDKCode, error) { + var out *model.CDKCode + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var item model.CDKCode + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&item, "code = ?", code).Error; err != nil { + return err + } + if item.Status == "redeemed" { + return gorm.ErrDuplicatedKey + } + // Marketing codes: a user may redeem only ONE code per batch. The partial + // unique index (batch_id, redeemed_by) is the hard backstop against + // concurrent double-redeems; this check gives a friendly error first. + if item.Type == "marketing" && item.BatchID != "" { + var cnt int64 + if err := tx.Model(&model.CDKCode{}). + Where("batch_id = ? AND type = 'marketing' AND redeemed_by = ?", item.BatchID, userID). + Count(&cnt).Error; err != nil { + return err + } + if cnt > 0 { + return ErrCDKBatchLimit + } + } + now := time.Now() + item.Status = "redeemed" + item.RedeemedBy = &userID + item.RedeemedAt = &now + if err := tx.Save(&item).Error; err != nil { + return err + } + out = &item + return nil + }) + return out, err +} diff --git a/backend/internal/repo/event_repo.go b/backend/internal/repo/event_repo.go new file mode 100644 index 0000000..0ec5090 --- /dev/null +++ b/backend/internal/repo/event_repo.go @@ -0,0 +1,650 @@ +package repo + +import ( + "context" + "errors" + "strings" + "time" + + "backend/internal/model" + "gorm.io/gorm" +) + +type EventRepository struct { + db *gorm.DB +} + +type EventListFilter struct { + Limit int + Offset int + Kind string + Status string + Since *time.Time + UserID string + ExcludeSource string // when set, omit rows with this source (e.g. hide API-key "v1" usage from the customer logs page) + Source string // when set, keep ONLY rows with this source (admin 来源 filter): "v1" (API key) / "user" (前台) / "admin" (测试模型) + HasFile bool // when true, keep ONLY rows with a non-empty file (the 创作记录 gallery — paginates over real media) +} + +type EventStats struct { + Total int64 `json:"total"` + Success int64 `json:"success"` + Failed int64 `json:"failed"` + Pending int64 `json:"pending"` + AvgElapsedMS *int `json:"avg_elapsed_ms"` + AvgElapsedMS24 *int `json:"avg_elapsed_ms_24h"` +} + +func NewEventRepository(db *gorm.DB) *EventRepository { + return &EventRepository{db: db} +} + +func (r *EventRepository) List(ctx context.Context, filter EventListFilter) ([]model.EventLog, int64, error) { + q := r.db.WithContext(ctx).Model(&model.EventLog{}) + if filter.Kind != "" { + q = q.Where("kind = ?", filter.Kind) + } + if filter.Status != "" { + q = q.Where("status = ?", filter.Status) + } + if filter.Since != nil { + q = q.Where("ts > ?", *filter.Since) + } + if filter.UserID != "" { + q = q.Where("user_id = ?", filter.UserID) + } + if filter.ExcludeSource != "" { + q = q.Where("(source IS NULL OR source <> ?)", filter.ExcludeSource) + } + if filter.Source != "" { + q = q.Where("source = ?", filter.Source) + } + if filter.HasFile { + q = q.Where("file <> ''") + } + + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + + var items []model.EventLog + if err := q.Order("ts desc"). + Limit(filter.Limit). + Offset(filter.Offset). + Find(&items).Error; err != nil { + return nil, 0, err + } + return items, total, nil +} + +func (r *EventRepository) Stats(ctx context.Context) (*EventStats, error) { + stats := &EventStats{} + if err := r.db.WithContext(ctx).Model(&model.EventLog{}).Count(&stats.Total).Error; err != nil { + return nil, err + } + if err := r.db.WithContext(ctx).Model(&model.EventLog{}).Where("status = ?", "success").Count(&stats.Success).Error; err != nil { + return nil, err + } + if err := r.db.WithContext(ctx).Model(&model.EventLog{}).Where("status = ?", "failed").Count(&stats.Failed).Error; err != nil { + return nil, err + } + if err := r.db.WithContext(ctx).Model(&model.EventLog{}).Where("status = ?", "pending").Count(&stats.Pending).Error; err != nil { + return nil, err + } + + type avgRow struct { + Avg *float64 `gorm:"column:avg"` + } + var all avgRow + if err := r.db.WithContext(ctx). + Model(&model.EventLog{}). + Select("AVG(elapsed_ms) AS avg"). + Where("status = ? AND elapsed_ms > 0", "success"). + Scan(&all).Error; err != nil { + return nil, err + } + if all.Avg != nil { + v := int(*all.Avg + 0.5) + stats.AvgElapsedMS = &v + } + + var recent avgRow + cutoff := time.Now().Add(-24 * time.Hour) + if err := r.db.WithContext(ctx). + Model(&model.EventLog{}). + Select("AVG(elapsed_ms) AS avg"). + Where("status = ? AND elapsed_ms > 0 AND ts >= ?", "success", cutoff). + Scan(&recent).Error; err != nil { + return nil, err + } + if recent.Avg != nil { + v := int(*recent.Avg + 0.5) + stats.AvgElapsedMS24 = &v + } + + return stats, nil +} + +// StatsByUser returns total / success / failed / pending counts scoped to a +// single user — for the customer-facing 生成日志 (/mylogs) KPI strip, so it +// reflects the caller's own history, not the whole site. +func (r *EventRepository) StatsByUser(ctx context.Context, userID string) (*EventStats, error) { + stats := &EventStats{} + q := func() *gorm.DB { + return r.db.WithContext(ctx).Model(&model.EventLog{}).Where("user_id = ?", userID) + } + if err := q().Count(&stats.Total).Error; err != nil { + return nil, err + } + if err := q().Where("status = ?", "success").Count(&stats.Success).Error; err != nil { + return nil, err + } + if err := q().Where("status = ?", "failed").Count(&stats.Failed).Error; err != nil { + return nil, err + } + if err := q().Where("status = ?", "pending").Count(&stats.Pending).Error; err != nil { + return nil, err + } + return stats, nil +} + +// --------------------------------------------------------------------------- +// Dashboard aggregates — server-side GROUP BY / FILTER so the admin overview +// no longer derives 7-day / DAU / trend / top-N numbers client-side from the +// last 200 logs (which silently undercounts once volume passes that window). +// --------------------------------------------------------------------------- + +// DashboardWindow is a single time-window aggregate (e.g. last 24h / 7d). +type DashboardWindow struct { + Total int64 `json:"total"` + Success int64 `json:"success"` + Failed int64 `json:"failed"` + Pending int64 `json:"pending"` + Image int64 `json:"image"` + Video int64 `json:"video"` + API int64 `json:"api"` // source = 'v1' (OpenAI-compatible key) + Web int64 `json:"web"` // everything else (web / playground) + Spent float64 `json:"spent"` +} + +type ModelUsage struct { + Model string `json:"model"` + Count int64 `json:"count"` + AvgMS *int `json:"avg_ms"` +} + +type FailureReason struct { + Reason string `json:"reason"` + Count int64 `json:"count"` +} + +type UserSpend struct { + UserID string `json:"user_id"` + Name string `json:"name"` // resolved by the service from user_id + Count int64 `json:"count"` + Spent float64 `json:"spent"` +} + +type HourBucket struct { + Image int64 `json:"image"` + Video int64 `json:"video"` +} + +// WindowStats rolls up counts + spend over a single window in one query. +func (r *EventRepository) WindowStats(ctx context.Context, since time.Time) (*DashboardWindow, error) { + type row struct { + Total int64 `gorm:"column:total"` + Success int64 `gorm:"column:success"` + Failed int64 `gorm:"column:failed"` + Pending int64 `gorm:"column:pending"` + Image int64 `gorm:"column:image"` + Video int64 `gorm:"column:video"` + API int64 `gorm:"column:api"` + Spent float64 `gorm:"column:spent"` + } + var out row + if err := r.db.WithContext(ctx). + Model(&model.EventLog{}). + Select(` + COUNT(*) AS total, + COUNT(*) FILTER (WHERE status = 'success') AS success, + COUNT(*) FILTER (WHERE status = 'failed') AS failed, + COUNT(*) FILTER (WHERE status = 'pending') AS pending, + COUNT(*) FILTER (WHERE kind = 'image') AS image, + COUNT(*) FILTER (WHERE kind = 'video') AS video, + COUNT(*) FILTER (WHERE source = 'v1') AS api, + COALESCE(SUM(cost) FILTER (WHERE status = 'success'), 0) AS spent`). + Where("ts >= ?", since). + Scan(&out).Error; err != nil { + return nil, err + } + return &DashboardWindow{ + Total: out.Total, Success: out.Success, Failed: out.Failed, Pending: out.Pending, + Image: out.Image, Video: out.Video, API: out.API, Web: out.Total - out.API, + Spent: out.Spent, + }, nil +} + +// CountBetween counts events in (start, end] — used for the prev-24h delta. +func (r *EventRepository) CountBetween(ctx context.Context, start, end time.Time) (int64, error) { + var n int64 + err := r.db.WithContext(ctx).Model(&model.EventLog{}). + Where("ts > ? AND ts <= ?", start, end).Count(&n).Error + return n, err +} + +// DistinctUsersSince counts distinct (non-empty) user_ids active since `since`. +func (r *EventRepository) DistinctUsersSince(ctx context.Context, since time.Time) (int64, error) { + var n int64 + err := r.db.WithContext(ctx).Model(&model.EventLog{}). + Where("ts >= ? AND user_id <> ''", since). + Distinct("user_id").Count(&n).Error + return n, err +} + +// HourlyBuckets returns 24 oldest→newest buckets (image/video split) for the +// last 24h trend chart. +func (r *EventRepository) HourlyBuckets(ctx context.Context) ([24]HourBucket, error) { + var out [24]HourBucket + type hourRow struct { + HoursAgo int `gorm:"column:hours_ago"` + Image int64 `gorm:"column:image"` + Video int64 `gorm:"column:video"` + } + var rows []hourRow + if err := r.db.WithContext(ctx). + Model(&model.EventLog{}). + Select(` + FLOOR(EXTRACT(EPOCH FROM (NOW() - ts)) / 3600)::int AS hours_ago, + COUNT(*) FILTER (WHERE kind = 'video') AS video, + COUNT(*) FILTER (WHERE kind <> 'video') AS image`). + Where("ts >= NOW() - INTERVAL '24 hours'"). + Group("hours_ago"). + Scan(&rows).Error; err != nil { + return out, err + } + for _, hr := range rows { + if hr.HoursAgo < 0 || hr.HoursAgo >= 24 { + continue + } + out[23-hr.HoursAgo] = HourBucket{Image: hr.Image, Video: hr.Video} + } + return out, nil +} + +// ModelUsageSince returns the top models by volume since `since`, with the +// success-only average latency. +func (r *EventRepository) ModelUsageSince(ctx context.Context, since time.Time, limit int) ([]ModelUsage, error) { + if limit <= 0 { + limit = 6 + } + type row struct { + Model string `gorm:"column:model"` + Count int64 `gorm:"column:count"` + Avg *float64 `gorm:"column:avg_ms"` + } + var rows []row + if err := r.db.WithContext(ctx). + Model(&model.EventLog{}). + Select(` + model, + COUNT(*) AS count, + AVG(elapsed_ms) FILTER (WHERE status = 'success' AND elapsed_ms > 0) AS avg_ms`). + Where("ts >= ? AND model <> ''", since). + Group("model"). + Order("count DESC"). + Limit(limit). + Scan(&rows).Error; err != nil { + return nil, err + } + out := make([]ModelUsage, 0, len(rows)) + for _, item := range rows { + var avg *int + if item.Avg != nil { + v := int(*item.Avg + 0.5) + avg = &v + } + out = append(out, ModelUsage{Model: item.Model, Count: item.Count, AvgMS: avg}) + } + return out, nil +} + +// TopFailures groups failed events by (truncated) error reason since `since`. +func (r *EventRepository) TopFailures(ctx context.Context, since time.Time, limit int) ([]FailureReason, error) { + if limit <= 0 { + limit = 5 + } + var out []FailureReason + if err := r.db.WithContext(ctx). + Model(&model.EventLog{}). + Select(` + LEFT(COALESCE(NULLIF(error, ''), '未知错误'), 60) AS reason, + COUNT(*) AS count`). + Where("ts >= ? AND status = 'failed'", since). + Group("reason"). + Order("count DESC"). + Limit(limit). + Scan(&out).Error; err != nil { + return nil, err + } + return out, nil +} + +// TopUserSpend ranks users by credits spent on SUCCESSFUL generations since +// `since`. Names are resolved by the caller (UserID -> display name). +func (r *EventRepository) TopUserSpend(ctx context.Context, since time.Time, limit int) ([]UserSpend, error) { + if limit <= 0 { + limit = 6 + } + var out []UserSpend + if err := r.db.WithContext(ctx). + Model(&model.EventLog{}). + Select(` + user_id, + COUNT(*) AS count, + COALESCE(SUM(cost), 0) AS spent`). + Where("ts >= ? AND status = 'success'", since). + Group("user_id"). + Order("spent DESC"). + Limit(limit). + Scan(&out).Error; err != nil { + return nil, err + } + return out, nil +} + +func (r *EventRepository) PurgeOlderThan(ctx context.Context, maxAge time.Duration) (int64, error) { + if maxAge <= 0 { + return 0, nil + } + cutoff := time.Now().Add(-maxAge) + result := r.db.WithContext(ctx).Where("ts < ?", cutoff).Delete(&model.EventLog{}) + if result.Error != nil { + return 0, result.Error + } + return result.RowsAffected, nil +} + +// ClearFiles blanks the `file` column on any event rows that point at one of the +// given relative paths. Called after media retention deletes the files on disk so +// the log views don't dangle a 404 image — an emptied `file` reads as "no preview" +// ("—" in admin logs; hidden in the customer records page). +func (r *EventRepository) ClearFiles(ctx context.Context, relPaths []string) (int64, error) { + if len(relPaths) == 0 { + return 0, nil + } + result := r.db.WithContext(ctx). + Model(&model.EventLog{}). + Where("file IN ?", relPaths). + Updates(map[string]any{"file": "", "updated_at": time.Now()}) + if result.Error != nil { + return 0, result.Error + } + return result.RowsAffected, nil +} + +// ClearRefFiles blanks the ref_files paths on one event (called after a +// successful generation once the reference images are deleted from storage, so no +// dangling reference_urls remain). The `refs` COUNT is kept for the log record. +func (r *EventRepository) ClearRefFiles(ctx context.Context, eventID string) error { + return r.db.WithContext(ctx). + Model(&model.EventLog{}). + Where("id = ?", eventID). + Update("ref_files", nil).Error +} + +// StaleEvent identifies a purged pending event so the caller can refund the +// credits debited up-front AND attribute the failure to the account the +// (now-abandoned) generation was using. +type StaleEvent struct { + ID string `gorm:"column:id"` + UserID string `gorm:"column:user_id"` + AccountID string `gorm:"column:account_id"` + Cost float64 `gorm:"column:cost"` +} + +// PurgeStale marks long-pending entries as failed/abandoned and RETURNS them so +// the caller can refund their up-front charge. A stuck pending row otherwise +// blocks the per-user generation gate (PendingByUser) forever AND silently eats +// the user's credits (the charge happens at submit; the normal failure-refund +// path never runs for a process-restart orphan). Mirrors Python purge_stale. +func (r *EventRepository) PurgeStale(ctx context.Context, maxAge time.Duration) ([]StaleEvent, error) { + if maxAge <= 0 { + maxAge = 600 * time.Second + } + cutoff := time.Now().Add(-maxAge) + var stale []StaleEvent + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // Snapshot who/what to refund BEFORE flipping status, so a concurrent + // sweep can't double-count (the UPDATE in the same tx removes them from + // the pending set). + if err := tx.Model(&model.EventLog{}). + Where("status = ? AND ts < ?", "pending", cutoff). + Select("id", "user_id", "account_id", "cost"). + Scan(&stale).Error; err != nil { + return err + } + if len(stale) == 0 { + return nil + } + return tx.Model(&model.EventLog{}). + Where("status = ? AND ts < ?", "pending", cutoff). + Updates(map[string]any{ + "status": "failed", + "error": gorm.Expr("COALESCE(NULLIF(error, ''), ?)", "abandoned (process restarted or request interrupted)"), + "updated_at": time.Now(), + }).Error + }) + if err != nil { + return nil, err + } + return stale, nil +} + +func (r *EventRepository) Create(ctx context.Context, item *model.EventLog) error { + return r.db.WithContext(ctx).Create(item).Error +} + +// GetByID fetches a single event (nil, nil when not found). Used by the async +// /v1/videos job to look up status / the stored upstream URL. +func (r *EventRepository) GetByID(ctx context.Context, id string) (*model.EventLog, error) { + var e model.EventLog + if err := r.db.WithContext(ctx).First(&e, "id = ?", id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &e, nil +} + +// MarkVideoReady completes an async video job: status=success, file=upstream URL +// (proxied on /content — never persisted), elapsed. +func (r *EventRepository) MarkVideoReady(ctx context.Context, eventID, fileURL string, elapsedMS int) error { + return r.db.WithContext(ctx). + Model(&model.EventLog{}). + Where("id = ?", eventID). + Updates(map[string]any{ + "status": "success", + "file": fileURL, + "error": "", + "elapsed_ms": elapsedMS, + "updated_at": time.Now(), + }).Error +} + +func (r *EventRepository) UpdateStatus(ctx context.Context, eventID, status, errMsg string, elapsedMS int) error { + patch := map[string]any{ + "status": status, + "elapsed_ms": elapsedMS, + "updated_at": time.Now(), + } + if strings.TrimSpace(errMsg) != "" { + patch["error"] = strings.TrimSpace(errMsg) + } else if status == "success" { + // A late-completing generation (one the maintenance sweep had already + // stamped "abandoned") must shed that stale error, or the row reads as + // "成功 + abandoned" at once. + patch["error"] = "" + } + return r.db.WithContext(ctx). + Model(&model.EventLog{}). + Where("id = ?", eventID). + Updates(patch).Error +} + +// MarkRefunded atomically claims the right to refund this event exactly once: +// it flips refunded false→true and returns true ONLY for the caller that won the +// race. Both the normal failure path and the abandoned-purge sweep call this +// before crediting, so a generation can never be refunded twice. +func (r *EventRepository) MarkRefunded(ctx context.Context, eventID string) (bool, error) { + res := r.db.WithContext(ctx). + Model(&model.EventLog{}). + Where("id = ? AND refunded = ?", eventID, false). + Updates(map[string]any{"refunded": true, "updated_at": time.Now()}) + if res.Error != nil { + return false, res.Error + } + return res.RowsAffected == 1, nil +} + +// SetAccount stamps which provider account is fulfilling an in-flight event. +// Called when generation commits to a token, so the accounts view can count +// pending events per account and an abandoned-event purge can attribute back. +func (r *EventRepository) SetAccount(ctx context.Context, eventID, accountID string) error { + return r.db.WithContext(ctx). + Model(&model.EventLog{}). + Where("id = ?", eventID). + Update("account_id", accountID).Error +} + +// InFlightByAccount counts pending (in-flight) events grouped by account_id, for +// the accounts view's live "in-flight" column. +func (r *EventRepository) InFlightByAccount(ctx context.Context) (map[string]int64, error) { + type row struct { + AccountID string `gorm:"column:account_id"` + Count int64 `gorm:"column:count"` + } + var rows []row + if err := r.db.WithContext(ctx). + Model(&model.EventLog{}). + Select("account_id, COUNT(*) AS count"). + Where("status = ? AND account_id <> ''", "pending"). + Group("account_id"). + Scan(&rows).Error; err != nil { + return nil, err + } + out := make(map[string]int64, len(rows)) + for _, item := range rows { + out[item.AccountID] = item.Count + } + return out, nil +} + +func (r *EventRepository) RecentByFile(ctx context.Context, limit int) ([]model.EventLog, error) { + if limit <= 0 { + limit = 1000 + } + var items []model.EventLog + if err := r.db.WithContext(ctx). + Where("file <> ''"). + Order("ts desc"). + Limit(limit). + Find(&items).Error; err != nil { + return nil, err + } + return items, nil +} + +func (r *EventRepository) ModelSuccessCounts(ctx context.Context) (map[string]int64, error) { + type row struct { + Model string `gorm:"column:model"` + Count int64 `gorm:"column:count"` + } + var rows []row + if err := r.db.WithContext(ctx). + Model(&model.EventLog{}). + Select("model, COUNT(*) AS count"). + Where("status = ? AND model <> ''", "success"). + Group("model"). + Scan(&rows).Error; err != nil { + return nil, err + } + out := make(map[string]int64, len(rows)) + for _, item := range rows { + out[item.Model] = item.Count + } + return out, nil +} + +func (r *EventRepository) UserSuccessCounts(ctx context.Context) (map[string]int64, error) { + type row struct { + UserID string `gorm:"column:user_id"` + Count int64 `gorm:"column:count"` + } + var rows []row + if err := r.db.WithContext(ctx). + Model(&model.EventLog{}). + Select("user_id, COUNT(*) AS count"). + Where("status = ? AND user_id <> ''", "success"). + Group("user_id"). + Scan(&rows).Error; err != nil { + return nil, err + } + out := make(map[string]int64, len(rows)) + for _, item := range rows { + out[item.UserID] = item.Count + } + return out, nil +} + +func (r *EventRepository) DeleteAll(ctx context.Context) (int64, error) { + result := r.db.WithContext(ctx).Where("1 = 1").Delete(&model.EventLog{}) + if result.Error != nil { + return 0, result.Error + } + return result.RowsAffected, nil +} + +func (r *EventRepository) DeletePending(ctx context.Context) (int64, error) { + result := r.db.WithContext(ctx).Where("status = ?", "pending").Delete(&model.EventLog{}) + if result.Error != nil { + return 0, result.Error + } + return result.RowsAffected, nil +} + +// LatestByUser / PendingByUser take onlySource: when non-empty they match ONLY +// that source. The playground passes "user" so it echoes ONLY the user's own +// web generations — never admin model-tests ("admin") or API-key calls ("v1"). +func (r *EventRepository) LatestByUser(ctx context.Context, userID, onlySource string) (*model.EventLog, error) { + var item model.EventLog + q := r.db.WithContext(ctx).Model(&model.EventLog{}).Where("user_id = ?", userID) + if strings.TrimSpace(onlySource) != "" { + q = q.Where("source = ?", strings.TrimSpace(onlySource)) + } + if err := q.Order("ts desc").First(&item).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &item, nil +} + +func (r *EventRepository) PendingByUser(ctx context.Context, userID, onlySource string) (*model.EventLog, error) { + var item model.EventLog + q := r.db.WithContext(ctx).Model(&model.EventLog{}). + Where("user_id = ? AND status = ?", userID, "pending") + if strings.TrimSpace(onlySource) != "" { + q = q.Where("source = ?", strings.TrimSpace(onlySource)) + } + if err := q.Order("ts desc").First(&item).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &item, nil +} diff --git a/backend/internal/repo/model_repo.go b/backend/internal/repo/model_repo.go new file mode 100644 index 0000000..c24bd35 --- /dev/null +++ b/backend/internal/repo/model_repo.go @@ -0,0 +1,69 @@ +package repo + +import ( + "context" + "encoding/json" + "time" + + "backend/internal/model" + "gorm.io/datatypes" + "gorm.io/gorm" +) + +type ModelRepository struct { + db *gorm.DB +} + +func NewModelRepository(db *gorm.DB) *ModelRepository { + return &ModelRepository{db: db} +} + +func (r *ModelRepository) List(ctx context.Context) ([]model.ModelConfig, error) { + var items []model.ModelConfig + // Higher weight floats to the top of the dropdown / admin list; ties fall + // back to newest-first so order stays stable for equal-weight models. + if err := r.db.WithContext(ctx).Order("weight desc, created_at desc").Find(&items).Error; err != nil { + return nil, err + } + return items, nil +} + +func (r *ModelRepository) Get(ctx context.Context, modelID string) (*model.ModelConfig, error) { + var item model.ModelConfig + if err := r.db.WithContext(ctx).First(&item, "id = ?", modelID).Error; err != nil { + return nil, err + } + return &item, nil +} + +func JSONStrings(v datatypes.JSON) []string { + if len(v) == 0 { + return []string{} + } + var out []string + if err := json.Unmarshal([]byte(v), &out); err == nil { + return out + } + return []string{} +} + +func (r *ModelRepository) Create(ctx context.Context, item *model.ModelConfig) error { + return r.db.WithContext(ctx).Create(item).Error +} + +func (r *ModelRepository) Update(ctx context.Context, modelID string, patch map[string]any) (*model.ModelConfig, error) { + patch["updated_at"] = time.Now() + if err := r.db.WithContext(ctx).Model(&model.ModelConfig{}).Where("id = ?", modelID).Updates(patch).Error; err != nil { + return nil, err + } + var item model.ModelConfig + if err := r.db.WithContext(ctx).First(&item, "id = ?", modelID).Error; err != nil { + return nil, err + } + return &item, nil +} + +func (r *ModelRepository) Delete(ctx context.Context, modelID string) (int64, error) { + res := r.db.WithContext(ctx).Delete(&model.ModelConfig{}, "id = ?", modelID) + return res.RowsAffected, res.Error +} diff --git a/backend/internal/repo/refresh_profile_repo.go b/backend/internal/repo/refresh_profile_repo.go new file mode 100644 index 0000000..532b9cd --- /dev/null +++ b/backend/internal/repo/refresh_profile_repo.go @@ -0,0 +1,73 @@ +package repo + +import ( + "context" + "time" + + "backend/internal/model" + "gorm.io/gorm" +) + +type RefreshProfileRepository struct { + db *gorm.DB +} + +func NewRefreshProfileRepository(db *gorm.DB) *RefreshProfileRepository { + return &RefreshProfileRepository{db: db} +} + +func (r *RefreshProfileRepository) List(ctx context.Context) ([]model.RefreshProfile, error) { + var items []model.RefreshProfile + if err := r.db.WithContext(ctx). + Order("created_at desc"). + Find(&items).Error; err != nil { + return nil, err + } + return items, nil +} + +func (r *RefreshProfileRepository) Get(ctx context.Context, id string) (*model.RefreshProfile, error) { + var item model.RefreshProfile + if err := r.db.WithContext(ctx).First(&item, "id = ?", id).Error; err != nil { + return nil, err + } + return &item, nil +} + +func (r *RefreshProfileRepository) Create(ctx context.Context, item *model.RefreshProfile) error { + return r.db.WithContext(ctx).Create(item).Error +} + +func (r *RefreshProfileRepository) Update(ctx context.Context, id string, patch map[string]any) (*model.RefreshProfile, error) { + patch["updated_at"] = time.Now() + if err := r.db.WithContext(ctx). + Model(&model.RefreshProfile{}). + Where("id = ?", id). + Updates(patch).Error; err != nil { + return nil, err + } + return r.Get(ctx, id) +} + +func (r *RefreshProfileRepository) Delete(ctx context.Context, id string) error { + return r.db.WithContext(ctx).Delete(&model.RefreshProfile{}, "id = ?", id).Error +} + +func (r *RefreshProfileRepository) DeleteByIDs(ctx context.Context, ids []string) error { + if len(ids) == 0 { + return nil + } + return r.db.WithContext(ctx).Delete(&model.RefreshProfile{}, "id IN ?", ids).Error +} + +// ListDue returns enabled profiles whose next_retry_at has passed (or is unset, +// e.g. freshly imported). The background maintenance loop refreshes these. +func (r *RefreshProfileRepository) ListDue(ctx context.Context, now time.Time) ([]model.RefreshProfile, error) { + var items []model.RefreshProfile + if err := r.db.WithContext(ctx). + Where("enabled = ? AND (next_retry_at IS NULL OR next_retry_at <= ?)", true, now). + Find(&items).Error; err != nil { + return nil, err + } + return items, nil +} diff --git a/backend/internal/repo/showcase_repo.go b/backend/internal/repo/showcase_repo.go new file mode 100644 index 0000000..a03ba17 --- /dev/null +++ b/backend/internal/repo/showcase_repo.go @@ -0,0 +1,99 @@ +package repo + +import ( + "context" + "sort" + "strings" + "time" + + "backend/internal/model" + "gorm.io/gorm" +) + +type ShowcaseRepository struct { + db *gorm.DB +} + +func NewShowcaseRepository(db *gorm.DB) *ShowcaseRepository { + return &ShowcaseRepository{db: db} +} + +func (r *ShowcaseRepository) IsPublicFile(ctx context.Context, rel string) (bool, error) { + normalized := strings.TrimLeft(strings.TrimSpace(rel), "/") + if normalized == "" { + return false, nil + } + + var count int64 + if err := r.db.WithContext(ctx). + Model(&model.ShowcaseItem{}). + Where("image = ? OR image = ?", normalized, "/"+normalized). + Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + +// PublicFileSet returns the set of image keys referenced by any showcase item +// (normalized, no leading slash). The media-prune sweep uses it to never delete +// a file the homepage still shows, regardless of how old the file is. +func (r *ShowcaseRepository) PublicFileSet(ctx context.Context) (map[string]struct{}, error) { + var images []string + if err := r.db.WithContext(ctx). + Model(&model.ShowcaseItem{}). + Where("image <> ''"). + Pluck("image", &images).Error; err != nil { + return nil, err + } + set := make(map[string]struct{}, len(images)) + for _, img := range images { + n := strings.TrimLeft(strings.TrimSpace(img), "/") + if n != "" { + set[n] = struct{}{} + } + } + return set, nil +} + +func (r *ShowcaseRepository) Grouped(ctx context.Context) (map[string][]model.ShowcaseItem, error) { + var items []model.ShowcaseItem + if err := r.db.WithContext(ctx).Find(&items).Error; err != nil { + return nil, err + } + + grouped := map[string][]model.ShowcaseItem{ + "hero": {}, + "bento": {}, + "work": {}, + } + for _, item := range items { + grouped[item.Kind] = append(grouped[item.Kind], item) + } + for kind := range grouped { + sort.Slice(grouped[kind], func(i, j int) bool { + return grouped[kind][i].Weight > grouped[kind][j].Weight + }) + } + return grouped, nil +} + +func (r *ShowcaseRepository) Create(ctx context.Context, item *model.ShowcaseItem) error { + return r.db.WithContext(ctx).Create(item).Error +} + +func (r *ShowcaseRepository) Update(ctx context.Context, entryID string, patch map[string]any) (*model.ShowcaseItem, error) { + patch["updated_at"] = time.Now() + if err := r.db.WithContext(ctx).Model(&model.ShowcaseItem{}).Where("id = ?", entryID).Updates(patch).Error; err != nil { + return nil, err + } + var item model.ShowcaseItem + if err := r.db.WithContext(ctx).First(&item, "id = ?", entryID).Error; err != nil { + return nil, err + } + return &item, nil +} + +func (r *ShowcaseRepository) Delete(ctx context.Context, entryID string) (int64, error) { + res := r.db.WithContext(ctx).Delete(&model.ShowcaseItem{}, "id = ?", entryID) + return res.RowsAffected, res.Error +} diff --git a/backend/internal/repo/site_setting_repo.go b/backend/internal/repo/site_setting_repo.go new file mode 100644 index 0000000..f4a6d8a --- /dev/null +++ b/backend/internal/repo/site_setting_repo.go @@ -0,0 +1,96 @@ +package repo + +import ( + "context" + "time" + + "backend/internal/model" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +const siteSettingCachePrefix = "setting:" + +// siteSettingCacheTTL is a safety-net expiry; writes invalidate eagerly, so this +// only bounds staleness if an invalidation is ever missed (e.g. Redis blip). +const siteSettingCacheTTL = 5 * time.Minute + +type SiteSettingRepository struct { + db *gorm.DB + cache *redis.Client +} + +// NewSiteSettingRepository wires the config KV store. cache may be nil, in which +// case the repository transparently falls back to DB-only access. +func NewSiteSettingRepository(db *gorm.DB, cache *redis.Client) *SiteSettingRepository { + return &SiteSettingRepository{db: db, cache: cache} +} + +func (r *SiteSettingRepository) cacheKey(key string) string { + return siteSettingCachePrefix + key +} + +func (r *SiteSettingRepository) GetValue(ctx context.Context, key string) (string, error) { + if r.cache != nil { + if v, err := r.cache.Get(ctx, r.cacheKey(key)).Result(); err == nil { + return v, nil + } + // redis.Nil (miss) or any transient cache error -> fall through to DB. + } + + value := "" + var setting model.SiteSetting + if err := r.db.WithContext(ctx).First(&setting, "key = ?", key).Error; err != nil { + if err != gorm.ErrRecordNotFound { + return "", err + } + // Not found stays as "" — still cached below to absorb repeated misses. + } else { + value = setting.Value + } + + if r.cache != nil { + _ = r.cache.Set(ctx, r.cacheKey(key), value, siteSettingCacheTTL).Err() + } + return value, nil +} + +func (r *SiteSettingRepository) UpsertValue(ctx context.Context, key, value string) error { + if err := r.db.WithContext(ctx).Save(&model.SiteSetting{ + Key: key, + Value: value, + }).Error; err != nil { + return err + } + r.invalidate(ctx, key) + return nil +} + +func (r *SiteSettingRepository) UpsertValues(ctx context.Context, values map[string]string) error { + if err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + for key, value := range values { + if err := tx.Save(&model.SiteSetting{ + Key: key, + Value: value, + }).Error; err != nil { + return err + } + } + return nil + }); err != nil { + return err + } + for key := range values { + r.invalidate(ctx, key) + } + return nil +} + +// invalidate drops the cached entry so the next read repopulates from the DB. +// Deleting (rather than overwriting) keeps writes simple and race-tolerant. +func (r *SiteSettingRepository) invalidate(ctx context.Context, key string) { + if r.cache == nil { + return + } + _ = r.cache.Del(ctx, r.cacheKey(key)).Err() +} diff --git a/backend/internal/repo/token_repo.go b/backend/internal/repo/token_repo.go new file mode 100644 index 0000000..8b3af46 --- /dev/null +++ b/backend/internal/repo/token_repo.go @@ -0,0 +1,390 @@ +package repo + +import ( + "context" + "encoding/json" + "errors" + "strconv" + "strings" + "time" + + "backend/internal/model" + "gorm.io/datatypes" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type TokenRepository struct { + db *gorm.DB +} + +func NewTokenRepository(db *gorm.DB) *TokenRepository { + return &TokenRepository{db: db} +} + +func (r *TokenRepository) List(ctx context.Context) ([]model.TokenAccount, error) { + var items []model.TokenAccount + if err := r.db.WithContext(ctx). + Order("pool asc, created_at desc"). + Find(&items).Error; err != nil { + return nil, err + } + return items, nil +} + +func (r *TokenRepository) ListByPool(ctx context.Context, pool string) ([]model.TokenAccount, error) { + var items []model.TokenAccount + if err := r.db.WithContext(ctx). + Where("pool = ?", pool). + Order("created_at desc"). + Find(&items).Error; err != nil { + return nil, err + } + return items, nil +} + +func (r *TokenRepository) Get(ctx context.Context, pool, id string) (*model.TokenAccount, error) { + var item model.TokenAccount + if err := r.db.WithContext(ctx). + First(&item, "pool = ? AND id = ?", pool, id).Error; err != nil { + return nil, err + } + return &item, nil +} + +// GetByPoolEmail finds an account in a pool by its account_email (the logical +// identity for import dedup). Returns (nil, nil) when none / email is blank. +func (r *TokenRepository) GetByPoolEmail(ctx context.Context, pool, email string) (*model.TokenAccount, error) { + email = strings.TrimSpace(email) + if email == "" { + return nil, nil + } + var item model.TokenAccount + err := r.db.WithContext(ctx). + Where("pool = ? AND account_email = ?", pool, email). + First(&item).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &item, nil +} + +func (r *TokenRepository) Create(ctx context.Context, item *model.TokenAccount) error { + return r.db.WithContext(ctx).Create(item).Error +} + +func (r *TokenRepository) Update(ctx context.Context, pool, id string, patch map[string]any) (*model.TokenAccount, error) { + patch["updated_at"] = time.Now() + if err := r.db.WithContext(ctx). + Model(&model.TokenAccount{}). + Where("pool = ? AND id = ?", pool, id). + Updates(patch).Error; err != nil { + return nil, err + } + return r.Get(ctx, pool, id) +} + +// ReserveQuota atomically pre-deducts `amount` from an account's cached image +// token balance under a row lock, so concurrent picks of the same near-empty +// account can never over-commit it. Returns: +// - allowed=true, deducted=true: balance was known and ≥ amount → decremented. +// - allowed=true, deducted=false: balance unknown → allowed without a hold +// (benefit of the doubt; a post-render reconcile writes the real value). +// - allowed=false: balance known and < amount → caller should fail over. +// RefundQuota releases a hold made with deducted=true when the render fails. +func (r *TokenRepository) ReserveQuota(ctx context.Context, pool, id string, amount int) (allowed, deducted bool, err error) { + err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var item model.TokenAccount + if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + First(&item, "pool = ? AND id = ?", pool, id).Error; e != nil { + return e + } + rem, known := metaInt(item.Meta, "cached_quota_remaining") + if !known { + allowed, deducted = true, false + return nil + } + if rem < amount { + allowed, deducted = false, false + return nil + } + meta := cloneMeta(item.Meta) + meta["cached_quota_remaining"] = rem - amount + if e := tx.Model(&model.TokenAccount{}). + Where("pool = ? AND id = ?", pool, id). + Updates(map[string]any{"meta": meta, "updated_at": time.Now()}).Error; e != nil { + return e + } + allowed, deducted = true, true + return nil + }) + return allowed, deducted, err +} + +// RefundQuota atomically adds `amount` back to cached_quota_remaining (releasing a +// hold from a reservation whose render then failed). No-op if the balance is +// unknown. Row-locked like ReserveQuota. +func (r *TokenRepository) RefundQuota(ctx context.Context, pool, id string, amount int) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var item model.TokenAccount + if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + First(&item, "pool = ? AND id = ?", pool, id).Error; e != nil { + return e + } + rem, known := metaInt(item.Meta, "cached_quota_remaining") + if !known { + return nil + } + meta := cloneMeta(item.Meta) + meta["cached_quota_remaining"] = rem + amount + return tx.Model(&model.TokenAccount{}). + Where("pool = ? AND id = ?", pool, id). + Updates(map[string]any{"meta": meta, "updated_at": time.Now()}).Error + }) +} + +func cloneMeta(m datatypes.JSONMap) datatypes.JSONMap { + out := datatypes.JSONMap{} + for k, v := range m { + out[k] = v + } + return out +} + +func metaInt(m datatypes.JSONMap, key string) (int, bool) { + if m == nil { + return 0, false + } + v, ok := m[key] + if !ok || v == nil { + return 0, false + } + switch x := v.(type) { + case int: + return x, true + case int64: + return int(x), true + case float64: + return int(x), true + case json.Number: + n, e := x.Int64() + if e != nil { + return 0, false + } + return int(n), true + case string: + n, e := strconv.Atoi(strings.TrimSpace(x)) + if e != nil { + return 0, false + } + return n, true + default: + return 0, false + } +} + +// TouchLastUsed stamps last_used_at at the moment a token is SELECTED, so the +// accounts view reflects an accurate "last used" time. Rotation order is driven +// by the in-memory strict round-robin cursor in the service layer (see +// V1Service.rotateRoundRobin), not by this timestamp. +func (r *TokenRepository) TouchLastUsed(ctx context.Context, id string) error { + return r.db.WithContext(ctx). + Model(&model.TokenAccount{}). + Where("id = ?", id). + Update("last_used_at", time.Now()).Error +} + +// IncrementFail bumps an account's failure counters by one. Used to attribute +// an abandoned (purged) generation's failure back to the account it was using, +// since that generation never reached the normal markTokenFailure path. +func (r *TokenRepository) IncrementFail(ctx context.Context, id string) error { + return r.db.WithContext(ctx). + Model(&model.TokenAccount{}). + Where("id = ?", id). + Updates(map[string]any{ + "fail_total": gorm.Expr("fail_total + 1"), + "fails": gorm.Expr("fails + 1"), + "updated_at": time.Now(), + }).Error +} + +func (r *TokenRepository) Delete(ctx context.Context, pool, id string) (int64, error) { + res := r.db.WithContext(ctx). + Delete(&model.TokenAccount{}, "pool = ? AND id = ?", pool, id) + return res.RowsAffected, res.Error +} + +// DeleteByIDs removes accounts by id across pools (ids are globally unique), +// for bulk delete. Returns the number of rows removed. +func (r *TokenRepository) DeleteByIDs(ctx context.Context, ids []string) (int64, error) { + if len(ids) == 0 { + return 0, nil + } + res := r.db.WithContext(ctx).Delete(&model.TokenAccount{}, "id IN ?", ids) + return res.RowsAffected, res.Error +} + +// leonardoDailyTokens is the free-tier daily allowance restored at each reset. +// A paid account's true balance is reconciled on its next successful render. +const leonardoDailyTokens = 150 + +// RecoverQuota reactivates quota-exhausted tokens whose reset time has passed. +// Reset source: cached_quota_reset_after (upstream marker) first, else the +// quota_recover_at fallback stamped when the token was marked quota-exhausted. +// Mirrors Python TokenPool.recover_quota; returns the count reactivated. +// RecoverQuota reactivates quota-exhausted tokens whose reset time has passed and +// returns the accounts it recovered, so the caller can re-sync their real balance +// (the providers only sync quota when accessed). +func (r *TokenRepository) RecoverQuota(ctx context.Context) ([]model.TokenAccount, error) { + // Also pick up accounts that are only single-kind limited (image_limited / + // video_limited) — those keep status "active" and would otherwise never have + // their per-kind flag cleared. Adobe resets both kinds at once, so the shared + // reset time gates recovery for all of them. + var items []model.TokenAccount + if err := r.db.WithContext(ctx). + Where("status = ? OR image_limited = ? OR video_limited = ?", "quota", true, true). + Find(&items).Error; err != nil { + return nil, err + } + now := time.Now() + var recovered []model.TokenAccount + for i := range items { + t := &items[i] + // Runway's reset marker is the JWT expiry, not a quota-refresh time, and + // there's no way to refresh a bare JWT — so a runway account is never + // "recovered"; it's expired-to-dead by ExpireByReset instead. + if t.Pool == "runway" { + continue + } + reset := parseResetMarker(t.CachedQuotaResetAfter) + if reset == nil { + reset = t.QuotaRecoverAt + } + if reset == nil || now.Before(*reset) { + continue + } + patch := map[string]any{ + "fails": 0, + "quota_recover_at": nil, + "image_limited": false, + "video_limited": false, + } + // Only flip status back to active if it was sunk to "quota" (both kinds + // limited); a single-kind limit left status untouched. + if t.Status == "quota" { + patch["status"] = "active" + } + // Leonardo's free tokens fully renew at each daily reset — restore the + // balance and advance the reset marker to the next 08:00 Beijing (== next + // UTC midnight), so the account is immediately usable instead of stuck at a + // stale 0. A paid account's real balance is corrected on its next render. + if t.Pool == "leonardo" || t.Pool == "krea" || t.Pool == "imagine" { + meta := cloneMeta(t.Meta) + if t.Pool == "leonardo" { + meta["cached_quota_remaining"] = leonardoDailyTokens + } else { + // Krea/Imagine balances re-sync from upstream (billing-data / v1/credit) + // on next probe — drop the stale value so the account isn't shown as + // empty after reset. + delete(meta, "cached_quota_remaining") + } + meta["cached_quota_at"] = int(now.Unix()) + patch["meta"] = meta + patch["cached_quota_reset_after"] = time.Unix((now.Unix()/86400+1)*86400, 0).UTC().Format(time.RFC3339) + } + if _, err := r.Update(ctx, t.Pool, t.ID, patch); err != nil { + return recovered, err + } + recovered = append(recovered, *t) + } + return recovered, nil +} + +// RollResetMarkers advances a stale (past) daily-reset marker to its next future +// occurrence — same time-of-day, +N whole days — for ACTIVE accounts of the given +// daily-reset pools, so the 恢复时间 column always shows the upcoming reset rather +// than yesterday's. Only active accounts are rolled: a 限额 account must keep its +// past marker so RecoverQuota can recover it (rolling it forward early would +// prevent recovery). Returns the number advanced. +func (r *TokenRepository) RollResetMarkers(ctx context.Context, pools []string) (int, error) { + var items []model.TokenAccount + if err := r.db.WithContext(ctx). + Where("pool IN ? AND dead = ? AND status = ? AND image_limited = ? AND video_limited = ? AND cached_quota_reset_after <> ''", + pools, false, "active", false, false). + Find(&items).Error; err != nil { + return 0, err + } + now := time.Now() + n := 0 + for i := range items { + t := &items[i] + reset := parseResetMarker(t.CachedQuotaResetAfter) + if reset == nil || !reset.Before(now) { + continue // unparseable or already in the future + } + next := *reset + for !next.After(now) { + next = next.Add(24 * time.Hour) + } + if _, err := r.Update(ctx, t.Pool, t.ID, map[string]any{ + "cached_quota_reset_after": next.UTC().Format(time.RFC3339), + }); err != nil { + return n, err + } + n++ + } + return n, nil +} + +// ExpireByReset marks accounts of a pool dead once their reset marker has passed. +// For runway the marker IS the JWT expiry and there's no refresh, so an expired +// token can only 401 — we proactively flip it to disabled+dead (the same end +// state a 401 would produce) instead of leaving a doomed account "active". +func (r *TokenRepository) ExpireByReset(ctx context.Context, pool string) (int, error) { + var items []model.TokenAccount + if err := r.db.WithContext(ctx). + Where("pool = ? AND dead = ?", pool, false). + Find(&items).Error; err != nil { + return 0, err + } + now := time.Now() + expired := 0 + for i := range items { + t := &items[i] + reset := parseResetMarker(t.CachedQuotaResetAfter) + if reset == nil || now.Before(*reset) { + continue + } + if _, err := r.Update(ctx, t.Pool, t.ID, map[string]any{ + "status": "disabled", + "dead": true, + }); err != nil { + return expired, err + } + expired++ + } + return expired, nil +} + +// parseResetMarker best-effort parses a quota reset marker into a time. Accepts +// epoch seconds (numeric string) or ISO-8601 (e.g. Adobe's available_until +// "2026-06-16T23:59:59.999Z"). Returns nil if unparseable. +func parseResetMarker(v string) *time.Time { + v = strings.TrimSpace(v) + if v == "" { + return nil + } + if f, err := strconv.ParseFloat(v, 64); err == nil && f > 946684800 { + t := time.Unix(int64(f), 0) + return &t + } + for _, layout := range []string{time.RFC3339, "2006-01-02T15:04:05.999Z07:00", "2006-01-02T15:04:05Z07:00"} { + if t, err := time.Parse(layout, v); err == nil { + return &t + } + } + return nil +} diff --git a/backend/internal/repo/user_repo.go b/backend/internal/repo/user_repo.go new file mode 100644 index 0000000..a8348c7 --- /dev/null +++ b/backend/internal/repo/user_repo.go @@ -0,0 +1,627 @@ +package repo + +import ( + "context" + "errors" + "strings" + "time" + + "backend/internal/model" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type UserRepository struct { + db *gorm.DB +} + +var ErrAlreadyCheckedInToday = errors.New("already checked in today") + +type InviteStats struct { + InviteCount int64 `json:"invite_count"` + InviteEarned int `json:"invite_earned"` +} + +type InviteRecord struct { + Name string `json:"name,omitempty"` + Inviter string `json:"inviter,omitempty"` + Invitee string `json:"invitee,omitempty"` + Reward int `json:"reward"` + RegisteredAt time.Time `json:"registered_at"` + CompletedAt *time.Time `json:"completed_at"` + Status string `json:"status"` +} + +type InviteLogStats struct { + Total int64 `json:"total"` + Completed int64 `json:"completed"` + Pending int64 `json:"pending"` + RewardPaid int64 `json:"reward_paid"` +} + +type CheckinResult struct { + Already bool `json:"already"` + Awarded int `json:"awarded"` + Streak int `json:"streak"` + Credits float64 `json:"credits"` +} + +func NewUserRepository(db *gorm.DB) *UserRepository { + return &UserRepository{db: db} +} + +func (r *UserRepository) GetByID(ctx context.Context, userID string) (*model.User, error) { + var user model.User + if err := r.db.WithContext(ctx).Preload("APIKeys").First(&user, "id = ?", userID).Error; err != nil { + return nil, err + } + return &user, nil +} + +func (r *UserRepository) GetByIdentifier(ctx context.Context, identifier string) (*model.User, error) { + ident := strings.TrimSpace(identifier) + if ident == "" { + return nil, gorm.ErrRecordNotFound + } + + var user model.User + q := r.db.WithContext(ctx).Preload("APIKeys") + if strings.Contains(ident, "@") { + if err := q.First(&user, "email = ?", strings.ToLower(ident)).Error; err != nil { + return nil, err + } + return &user, nil + } + + if err := q.First(&user, "LOWER(name) = ?", strings.ToLower(ident)).Error; err != nil { + return nil, err + } + return &user, nil +} + +func (r *UserRepository) GetByInviteCode(ctx context.Context, code string) (*model.User, error) { + var user model.User + if err := r.db.WithContext(ctx).First(&user, "invite_code = ?", strings.ToUpper(strings.TrimSpace(code))).Error; err != nil { + return nil, err + } + return &user, nil +} + +func (r *UserRepository) List(ctx context.Context) ([]model.User, error) { + var users []model.User + if err := r.db.WithContext(ctx).Preload("APIKeys").Order("created_at desc").Find(&users).Error; err != nil { + return nil, err + } + return users, nil +} + +func (r *UserRepository) ExistsEmail(ctx context.Context, email, excludeUserID string) (bool, error) { + var count int64 + q := r.db.WithContext(ctx).Model(&model.User{}).Where("email = ?", strings.ToLower(strings.TrimSpace(email))) + if strings.TrimSpace(excludeUserID) != "" { + q = q.Where("id <> ?", strings.TrimSpace(excludeUserID)) + } + if err := q.Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + +func (r *UserRepository) ExistsName(ctx context.Context, name, excludeUserID string) (bool, error) { + var count int64 + q := r.db.WithContext(ctx).Model(&model.User{}).Where("LOWER(name) = ?", strings.ToLower(strings.TrimSpace(name))) + if strings.TrimSpace(excludeUserID) != "" { + q = q.Where("id <> ?", strings.TrimSpace(excludeUserID)) + } + if err := q.Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + +func (r *UserRepository) GetByAPIKeyHash(ctx context.Context, keyHash string) (*model.User, error) { + var apiKey model.APIKey + if err := r.db.WithContext(ctx).First(&apiKey, "key_hash = ?", keyHash).Error; err != nil { + return nil, err + } + + var user model.User + if err := r.db.WithContext(ctx).Preload("APIKeys").First(&user, "id = ?", apiKey.UserID).Error; err != nil { + return nil, err + } + return &user, nil +} + +func (r *UserRepository) TouchLogin(ctx context.Context, userID, ip string) error { + now := time.Now() + return r.db.WithContext(ctx). + Model(&model.User{}). + Where("id = ?", userID). + Updates(map[string]any{ + "last_login_at": now, + "last_login_ip": ip, + }).Error +} + +func (r *UserRepository) HasAdmin(ctx context.Context) (bool, error) { + var count int64 + if err := r.db.WithContext(ctx). + Model(&model.User{}). + Where("role = ?", "admin"). + Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + +func (r *UserRepository) Stats(ctx context.Context) (map[string]any, error) { + var total, active, disabled, admins int64 + if err := r.db.WithContext(ctx).Model(&model.User{}).Count(&total).Error; err != nil { + return nil, err + } + if err := r.db.WithContext(ctx).Model(&model.User{}).Where("status = ?", "active").Count(&active).Error; err != nil { + return nil, err + } + if err := r.db.WithContext(ctx).Model(&model.User{}).Where("status = ?", "disabled").Count(&disabled).Error; err != nil { + return nil, err + } + if err := r.db.WithContext(ctx).Model(&model.User{}).Where("role = ?", "admin").Count(&admins).Error; err != nil { + return nil, err + } + + type sumRow struct { + Total *float64 `gorm:"column:total"` + } + var credits sumRow + if err := r.db.WithContext(ctx). + Model(&model.User{}). + Select("SUM(credits) AS total"). + Scan(&credits).Error; err != nil { + return nil, err + } + + now := time.Now() + dayCut := now.Add(-24 * time.Hour) + weekCut := now.Add(-7 * 24 * time.Hour) + var new24h, new7d, active24h int64 + if err := r.db.WithContext(ctx).Model(&model.User{}).Where("created_at >= ?", dayCut).Count(&new24h).Error; err != nil { + return nil, err + } + if err := r.db.WithContext(ctx).Model(&model.User{}).Where("created_at >= ?", weekCut).Count(&new7d).Error; err != nil { + return nil, err + } + if err := r.db.WithContext(ctx).Model(&model.User{}).Where("last_login_at >= ?", dayCut).Count(&active24h).Error; err != nil { + return nil, err + } + + creditsTotal := 0.0 + if credits.Total != nil { + creditsTotal = *credits.Total + } + + return map[string]any{ + "total": total, + "active": active, + "disabled": disabled, + "admins": admins, + "credits_total": creditsTotal, + "new_24h": new24h, + "new_7d": new7d, + "active_24h": active24h, + }, nil +} + +type CheckinStats struct { + TodayCount int64 `json:"today_count"` + MaxStreak int64 `json:"max_streak"` +} + +// CheckinStats counts users who checked in today and the longest active streak — +// a single-query summary for the admin dashboard's 签到 card. +func (r *UserRepository) CheckinStats(ctx context.Context) (*CheckinStats, error) { + today := time.Now().Format("2006-01-02") + type row struct { + TodayCount int64 `gorm:"column:today_count"` + MaxStreak int64 `gorm:"column:max_streak"` + } + var out row + if err := r.db.WithContext(ctx). + Model(&model.User{}). + Select("COUNT(*) FILTER (WHERE checkin_last = ?) AS today_count, COALESCE(MAX(checkin_streak), 0) AS max_streak", today). + Scan(&out).Error; err != nil { + return nil, err + } + return &CheckinStats{TodayCount: out.TodayCount, MaxStreak: out.MaxStreak}, nil +} + +type InviteSummary struct { + Total int64 `json:"total"` + Completed int64 `json:"completed"` +} + +// InviteSummary is a lightweight count of invited users (and how many have had +// their reward granted). Cheaper than AllInvites — no JOIN, no record list — +// for the dashboard which polls frequently. +func (r *UserRepository) InviteSummary(ctx context.Context) (*InviteSummary, error) { + type row struct { + Total int64 `gorm:"column:total"` + Completed int64 `gorm:"column:completed"` + } + var out row + if err := r.db.WithContext(ctx). + Model(&model.User{}). + Select("COUNT(*) AS total, COUNT(*) FILTER (WHERE invite_reward_done) AS completed"). + Where("invited_by IS NOT NULL AND invited_by <> ''"). + Scan(&out).Error; err != nil { + return nil, err + } + return &InviteSummary{Total: out.Total, Completed: out.Completed}, nil +} + +func (r *UserRepository) Create(ctx context.Context, user *model.User) error { + return r.db.WithContext(ctx).Create(user).Error +} + +func (r *UserRepository) Update(ctx context.Context, userID string, patch map[string]any) (*model.User, error) { + patch["updated_at"] = time.Now() + if err := r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).Updates(patch).Error; err != nil { + return nil, err + } + return r.GetByID(ctx, userID) +} + +func (r *UserRepository) Delete(ctx context.Context, userID string) (int64, error) { + res := r.db.WithContext(ctx).Delete(&model.User{}, "id = ?", userID) + return res.RowsAffected, res.Error +} + +func (r *UserRepository) DeleteByIDs(ctx context.Context, ids []string) (int64, error) { + if len(ids) == 0 { + return 0, nil + } + res := r.db.WithContext(ctx).Delete(&model.User{}, "id IN ?", ids) + return res.RowsAffected, res.Error +} + +func (r *UserRepository) SetPasswordByEmail(ctx context.Context, email, passwordHash string) (*model.User, error) { + if err := r.db.WithContext(ctx). + Model(&model.User{}). + Where("email = ?", strings.ToLower(strings.TrimSpace(email))). + Updates(map[string]any{ + "password_hash": passwordHash, + "updated_at": time.Now(), + }).Error; err != nil { + return nil, err + } + var user model.User + if err := r.db.WithContext(ctx).Preload("APIKeys").First(&user, "email = ?", strings.ToLower(strings.TrimSpace(email))).Error; err != nil { + return nil, err + } + return &user, nil +} + +func (r *UserRepository) TouchAPIKeyUsage(ctx context.Context, keyHash string) error { + now := time.Now() + return r.db.WithContext(ctx). + Model(&model.APIKey{}). + Where("key_hash = ?", keyHash). + Update("last_used_at", now).Error +} + +func (r *UserRepository) InviteStats(ctx context.Context, userID string, reward int) (*InviteStats, error) { + var inviteCount int64 + if err := r.db.WithContext(ctx). + Model(&model.User{}). + Where("invited_by = ?", userID). + Count(&inviteCount).Error; err != nil { + return nil, err + } + + var rewardedCount int64 + if err := r.db.WithContext(ctx). + Model(&model.User{}). + Where("invited_by = ? AND invite_reward_done = ?", userID, true). + Count(&rewardedCount).Error; err != nil { + return nil, err + } + + return &InviteStats{ + InviteCount: inviteCount, + InviteEarned: int(rewardedCount) * reward, + }, nil +} + +func (r *UserRepository) InviteList(ctx context.Context, userID string, reward int) ([]InviteRecord, error) { + type row struct { + Name string + CreatedAt time.Time + InviteRewardDone bool + InviteRewardAt *time.Time + } + + var rows []row + if err := r.db.WithContext(ctx). + Model(&model.User{}). + Select("name, created_at, invite_reward_done, invite_reward_at"). + Where("invited_by = ?", userID). + Order("created_at desc"). + Find(&rows).Error; err != nil { + return nil, err + } + + out := make([]InviteRecord, 0, len(rows)) + for _, item := range rows { + status := "pending" + rewardValue := 0 + if item.InviteRewardDone { + status = "completed" + rewardValue = reward + } + name := strings.TrimSpace(item.Name) + if name == "" { + name = "—" + } + out = append(out, InviteRecord{ + Name: name, + Reward: rewardValue, + RegisteredAt: item.CreatedAt, + CompletedAt: item.InviteRewardAt, + Status: status, + }) + } + return out, nil +} + +func (r *UserRepository) AllInvites(ctx context.Context, reward int) ([]InviteRecord, *InviteLogStats, error) { + type row struct { + InviterName string `gorm:"column:inviter_name"` + InviterEmail string `gorm:"column:inviter_email"` + InviteeName string `gorm:"column:invitee_name"` + InviteeEmail string `gorm:"column:invitee_email"` + CreatedAt time.Time `gorm:"column:created_at"` + InviteRewardDone bool `gorm:"column:invite_reward_done"` + InviteRewardAt *time.Time `gorm:"column:invite_reward_at"` + } + + var rows []row + if err := r.db.WithContext(ctx). + Table("users AS invitee"). + Select(` + inviter.name AS inviter_name, + inviter.email AS inviter_email, + invitee.name AS invitee_name, + invitee.email AS invitee_email, + invitee.created_at, + invitee.invite_reward_done, + invitee.invite_reward_at + `). + Joins("JOIN users AS inviter ON inviter.id = invitee.invited_by"). + Order("invitee.created_at desc"). + Scan(&rows).Error; err != nil { + return nil, nil, err + } + + out := make([]InviteRecord, 0, len(rows)) + stats := &InviteLogStats{} + for _, item := range rows { + stats.Total++ + status := "pending" + rewardValue := 0 + if item.InviteRewardDone { + status = "completed" + rewardValue = reward + stats.Completed++ + stats.RewardPaid += int64(reward) + } else { + stats.Pending++ + } + + inviter := strings.TrimSpace(item.InviterName) + if inviter == "" { + inviter = strings.TrimSpace(item.InviterEmail) + } + invitee := strings.TrimSpace(item.InviteeName) + if invitee == "" { + invitee = strings.TrimSpace(item.InviteeEmail) + } + + out = append(out, InviteRecord{ + Inviter: inviter, + Invitee: invitee, + Reward: rewardValue, + RegisteredAt: item.CreatedAt, + CompletedAt: item.InviteRewardAt, + Status: status, + }) + } + return out, stats, nil +} + +func (r *UserRepository) DailyCheckin(ctx context.Context, userID string, reward int) (*CheckinResult, error) { + today := time.Now().Format("2006-01-02") + yesterday := time.Now().Add(-24 * time.Hour).Format("2006-01-02") + + var result *CheckinResult + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var user model.User + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, "id = ?", userID).Error; err != nil { + return err + } + if user.CheckinLast == today { + result = &CheckinResult{ + Already: true, + Awarded: 0, + Streak: user.CheckinStreak, + Credits: user.Credits, + } + return ErrAlreadyCheckedInToday + } + + streak := 1 + if user.CheckinLast == yesterday { + streak = user.CheckinStreak + 1 + } + credits := user.Credits + float64(reward) + + if err := tx.Model(&model.User{}). + Where("id = ?", userID). + Updates(map[string]any{ + "credits": credits, + "checkin_last": today, + "checkin_streak": streak, + "updated_at": time.Now(), + }).Error; err != nil { + return err + } + + result = &CheckinResult{ + Already: false, + Awarded: reward, + Streak: streak, + Credits: credits, + } + return nil + }) + if err != nil { + if errors.Is(err, ErrAlreadyCheckedInToday) { + return result, nil + } + return nil, err + } + return result, nil +} + +func (r *UserRepository) AdjustCredits(ctx context.Context, userID string, delta float64) (*model.User, error) { + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var user model.User + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, "id = ?", userID).Error; err != nil { + return err + } + nextCredits := user.Credits + delta + if nextCredits < 0 { + nextCredits = 0 + } + return tx.Model(&model.User{}). + Where("id = ?", userID). + Updates(map[string]any{ + "credits": nextCredits, + "updated_at": time.Now(), + }).Error + }) + if err != nil { + return nil, err + } + return r.GetByID(ctx, userID) +} + +// SetCredits sets a user's credit balance to an absolute (non-negative) value. +// The row is locked for the duration of the transaction so it stays consistent +// with concurrent AdjustCredits/TryDebitCredits operations. +func (r *UserRepository) SetCredits(ctx context.Context, userID string, value float64) (*model.User, error) { + if value < 0 { + value = 0 + } + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var user model.User + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, "id = ?", userID).Error; err != nil { + return err + } + return tx.Model(&model.User{}). + Where("id = ?", userID). + Updates(map[string]any{ + "credits": value, + "updated_at": time.Now(), + }).Error + }) + if err != nil { + return nil, err + } + return r.GetByID(ctx, userID) +} + +func (r *UserRepository) TryDebitCredits(ctx context.Context, userID string, amount float64) (*model.User, bool, error) { + if amount <= 0 { + user, err := r.GetByID(ctx, userID) + return user, user != nil, err + } + + var result *model.User + debited := false + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var user model.User + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Preload("APIKeys").First(&user, "id = ?", userID).Error; err != nil { + return err + } + if user.Credits < amount { + result = &user + return nil + } + nextCredits := user.Credits - amount + if err := tx.Model(&model.User{}). + Where("id = ?", userID). + Updates(map[string]any{ + "credits": nextCredits, + "updated_at": time.Now(), + }).Error; err != nil { + return err + } + user.Credits = nextCredits + user.UpdatedAt = time.Now() + result = &user + debited = true + return nil + }) + if err != nil { + return nil, false, err + } + return result, debited, nil +} + +func (r *UserRepository) GrantInviteReward(ctx context.Context, inviteeUserID string, reward int) (bool, error) { + if reward <= 0 { + return false, nil + } + + granted := false + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var invitee model.User + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&invitee, "id = ?", inviteeUserID).Error; err != nil { + return err + } + if invitee.InvitedBy == nil || *invitee.InvitedBy == "" || invitee.InviteRewardDone { + return nil + } + + var inviter model.User + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&inviter, "id = ?", *invitee.InvitedBy).Error; err != nil { + return err + } + + now := time.Now() + if err := tx.Model(&model.User{}). + Where("id = ?", invitee.ID). + Updates(map[string]any{ + "invite_reward_done": true, + "invite_reward_at": now, + "updated_at": now, + }).Error; err != nil { + return err + } + + if err := tx.Model(&model.User{}). + Where("id = ?", inviter.ID). + Updates(map[string]any{ + "credits": inviter.Credits + float64(reward), + "updated_at": now, + }).Error; err != nil { + return err + } + + granted = true + return nil + }) + if err != nil { + return false, err + } + return granted, nil +} diff --git a/backend/internal/service/admin_read.go b/backend/internal/service/admin_read.go new file mode 100644 index 0000000..40070b4 --- /dev/null +++ b/backend/internal/service/admin_read.go @@ -0,0 +1,568 @@ +package service + +import ( + "context" + "sort" + "strings" + "time" + + "backend/internal/config" + "backend/internal/model" + "backend/internal/repo" + "backend/internal/storage" +) + +type AdminReadService struct { + cfg *config.Config + users *repo.UserRepository + models *repo.ModelRepository + events *repo.EventRepository + settings *repo.SiteSettingRepository + tokens *repo.TokenRepository + cdks *repo.CDKRepository + store *storage.Client +} + +func NewAdminReadService(cfg *config.Config, users *repo.UserRepository, models *repo.ModelRepository, events *repo.EventRepository, settings *repo.SiteSettingRepository, tokens *repo.TokenRepository, cdks *repo.CDKRepository, store *storage.Client) *AdminReadService { + return &AdminReadService{ + cfg: cfg, + users: users, + models: models, + events: events, + settings: settings, + tokens: tokens, + cdks: cdks, + store: store, + } +} + +func (s *AdminReadService) Users(ctx context.Context) ([]model.User, map[string]any, error) { + users, err := s.users.List(ctx) + if err != nil { + return nil, nil, err + } + counts, err := s.events.UserSuccessCounts(ctx) + if err != nil { + return nil, nil, err + } + for i := range users { + meta := users[i].Notes + _ = meta + } + stats, err := s.users.Stats(ctx) + if err != nil { + return nil, nil, err + } + stats["generation_counts"] = counts + return users, stats, nil +} + +func (s *AdminReadService) Models(ctx context.Context) ([]model.ModelConfig, error) { + return s.models.List(ctx) +} + +func (s *AdminReadService) ModelsView(ctx context.Context) ([]map[string]any, error) { + items, err := s.models.List(ctx) + if err != nil { + return nil, err + } + counts, err := s.events.ModelSuccessCounts(ctx) + if err != nil { + return nil, err + } + out := make([]map[string]any, 0, len(items)) + for _, item := range items { + out = append(out, map[string]any{ + "id": item.ID, + "type": item.Type, + "name": item.Name, + "provider": item.Provider, + "enabled": item.Enabled, + "ratios": repo.JSONStrings(item.Ratios), + "prices": map[string]any(item.Prices), + "resolutions": repo.JSONStrings(item.Resolutions), + "image_to_image": item.ImageToImage, + "duration_prices": map[string]any(item.DurationPrices), + "prices_agent": map[string]any(item.PricesAgent), + "duration_prices_agent": map[string]any(item.DurationPricesAgent), + "durations": repo.JSONStrings(item.Durations), + "max_reference_images": item.MaxReferenceImages, + "reference_mode": item.ReferenceMode, + "weight": item.Weight, + "generation_count": counts[item.ID], + "created_at": item.CreatedAt, + "updated_at": item.UpdatedAt, + }) + } + return out, nil +} + +func (s *AdminReadService) Logs(ctx context.Context, limit, offset int, kind, status string, since *time.Time, userID, excludeSource, source string, hasFile bool) ([]model.EventLog, int64, *repo.EventStats, error) { + items, total, err := s.events.List(ctx, repo.EventListFilter{ + Limit: limit, + Offset: offset, + Kind: kind, + Status: status, + Since: since, + UserID: userID, + ExcludeSource: excludeSource, + Source: source, + HasFile: hasFile, + }) + if err != nil { + return nil, 0, nil, err + } + // 用户自己的日志(userID 非空)→ 按本人统计;管理员全站视图 → 全站统计。 + var stats *repo.EventStats + if userID != "" { + stats, err = s.events.StatsByUser(ctx, userID) + } else { + stats, err = s.events.Stats(ctx) + } + if err != nil { + return nil, 0, nil, err + } + return items, total, stats, nil +} + +// UserNameMap builds an id -> display name lookup (name, else email, else id) +// used to annotate admin log rows with user_name (mirrors admin.py:584-596). +func (s *AdminReadService) UserNameMap(ctx context.Context) (map[string]string, error) { + users, err := s.users.List(ctx) + if err != nil { + return nil, err + } + out := make(map[string]string, len(users)) + for _, u := range users { + name := strings.TrimSpace(u.Name) + if name == "" { + name = strings.TrimSpace(u.Email) + } + if name == "" { + name = u.ID + } + out[u.ID] = name + } + return out, nil +} + +func (s *AdminReadService) Stats(ctx context.Context) (map[string]any, error) { + stats, err := s.events.Stats(ctx) + if err != nil { + return nil, err + } + recentFiles, _ := s.RecentImages(ctx, 24) + files, fileStats, _ := s.scanGeneratedFiles(ctx) + var size int64 + if v, ok := fileStats["size_bytes"].(int64); ok { + size = v + } + return map[string]any{ + "generated_count": len(files), + "generated_size_bytes": size, + "recent": recentFiles, + "avg_elapsed_ms": stats.AvgElapsedMS, + "avg_elapsed_ms_24h": stats.AvgElapsedMS24, + }, nil +} + +// Dashboard assembles the admin overview's analytics entirely server-side +// (event windows, hourly trend, top models/failures/spenders) plus CDK / invite +// / checkin summaries. This replaces the old client-side math over the last 200 +// logs, which silently undercounted week/DAU/trend once volume grew. +func (s *AdminReadService) Dashboard(ctx context.Context) (map[string]any, error) { + now := time.Now() + dayCut := now.Add(-24 * time.Hour) + weekCut := now.Add(-7 * 24 * time.Hour) + + day, err := s.events.WindowStats(ctx, dayCut) + if err != nil { + return nil, err + } + week, err := s.events.WindowStats(ctx, weekCut) + if err != nil { + return nil, err + } + prevDay, err := s.events.CountBetween(ctx, now.Add(-48*time.Hour), dayCut) + if err != nil { + return nil, err + } + dau, err := s.events.DistinctUsersSince(ctx, dayCut) + if err != nil { + return nil, err + } + wau, err := s.events.DistinctUsersSince(ctx, weekCut) + if err != nil { + return nil, err + } + hourly, err := s.events.HourlyBuckets(ctx) + if err != nil { + return nil, err + } + + // Per-window top-N analytics so the frontend can toggle 24h / 7d without a + // re-fetch (the lists are small — top 6 / top 5). + nameByID, err := s.UserNameMap(ctx) + if err != nil { + return nil, err + } + analytics := func(since time.Time) (map[string]any, error) { + models, err := s.events.ModelUsageSince(ctx, since, 6) + if err != nil { + return nil, err + } + failures, err := s.events.TopFailures(ctx, since, 5) + if err != nil { + return nil, err + } + users, err := s.events.TopUserSpend(ctx, since, 6) + if err != nil { + return nil, err + } + for i := range users { + if users[i].UserID == "" { + users[i].Name = "匿名" + } else if name, ok := nameByID[users[i].UserID]; ok { + users[i].Name = name + } else { + users[i].Name = users[i].UserID + } + } + return map[string]any{"models": models, "failures": failures, "top_users": users}, nil + } + dayAnalytics, err := analytics(dayCut) + if err != nil { + return nil, err + } + weekAnalytics, err := analytics(weekCut) + if err != nil { + return nil, err + } + + cdkStats, err := s.cdks.Stats(ctx) + if err != nil { + return nil, err + } + + inviteReward := parseIntSetting(s.mustSetting(ctx, "credits.invite_reward"), 3) + inviteSummary, err := s.users.InviteSummary(ctx) + if err != nil { + return nil, err + } + + checkinReward := parseIntSetting(s.mustSetting(ctx, "credits.checkin_reward"), 3) + checkin, err := s.users.CheckinStats(ctx) + if err != nil { + return nil, err + } + + return map[string]any{ + "day": day, + "week": week, + "prev_day_total": prevDay, + "dau": dau, + "wau": wau, + "hourly": hourly, + "analytics": map[string]any{ + "day": dayAnalytics, + "week": weekAnalytics, + }, + "cdk": cdkStats, + "invites": map[string]any{ + "total": inviteSummary.Total, + "completed": inviteSummary.Completed, + "reward": inviteReward, + "reward_paid": inviteSummary.Completed * int64(inviteReward), + }, + "checkin": map[string]any{ + "today": checkin.TodayCount, + "max_streak": checkin.MaxStreak, + "reward": checkinReward, + "awarded_today": checkin.TodayCount * int64(checkinReward), + }, + }, nil +} + +// mustSetting reads a site setting value, returning "" on error so the caller's +// parseIntSetting default kicks in (a missing reward setting shouldn't 500 the +// whole dashboard). +func (s *AdminReadService) mustSetting(ctx context.Context, key string) string { + v, err := s.settings.GetValue(ctx, key) + if err != nil { + return "" + } + return v +} + +func (s *AdminReadService) Invites(ctx context.Context) ([]repo.InviteRecord, *repo.InviteLogStats, error) { + rewardRaw, err := s.settings.GetValue(ctx, "credits.invite_reward") + if err != nil { + return nil, nil, err + } + reward := parseIntSetting(rewardRaw, 3) + return s.users.AllInvites(ctx, reward) +} + +func (s *AdminReadService) Providers(ctx context.Context) ([]map[string]any, error) { + models, err := s.models.List(ctx) + if err != nil { + return nil, err + } + tokens, err := s.tokens.List(ctx) + if err != nil { + return nil, err + } + modelCounts := map[string]int{} + for _, item := range models { + modelCounts[item.Provider]++ + } + type aggregate struct { + active int + disabled int + quota int + } + tokenCounts := map[string]*aggregate{} + for _, item := range tokens { + if _, ok := tokenCounts[item.Pool]; !ok { + tokenCounts[item.Pool] = &aggregate{} + } + switch item.Status { + case "active": + tokenCounts[item.Pool].active++ + case "quota": + tokenCounts[item.Pool].quota++ + default: + tokenCounts[item.Pool].disabled++ + } + } + providers := []struct { + Name string + Pool string + Type string + }{ + {Name: "chatgpt", Pool: "chatgpt", Type: "openai"}, + {Name: "adobe", Pool: "adobe", Type: "adobe"}, + } + out := make([]map[string]any, 0, len(providers)) + for _, item := range providers { + count := tokenCounts[item.Pool] + if count == nil { + count = &aggregate{} + } + out = append(out, map[string]any{ + "name": item.Name, + "token_pool": item.Pool, + "type": item.Type, + "model_count": modelCounts[item.Name], + "tokens_total": count.active + count.disabled + count.quota, + "tokens_active": count.active, + "tokens_disabled": count.disabled, + "tokens_quota": count.quota, + }) + } + return out, nil +} + +func (s *AdminReadService) Images(ctx context.Context, limit, offset int, kind string) ([]map[string]any, int, map[string]any, error) { + if limit <= 0 { + limit = 30 + } + if limit > 200 { + limit = 200 + } + if offset < 0 { + offset = 0 + } + allFiles, stats, err := s.scanGeneratedFiles(ctx) + if err != nil { + return nil, 0, nil, err + } + filtered := make([]generatedFile, 0, len(allFiles)) + for _, item := range allFiles { + if kind == "" || item.Kind == kind { + filtered = append(filtered, item) + } + } + sort.SliceStable(filtered, func(i, j int) bool { + return filtered[i].MTime > filtered[j].MTime + }) + total := len(filtered) + if offset > total { + offset = total + } + end := offset + limit + if end > total { + end = total + } + page := filtered[offset:end] + index, err := s.eventIndexByFile(ctx) + if err != nil { + return nil, 0, nil, err + } + out := make([]map[string]any, 0, len(page)) + for _, item := range page { + row := map[string]any{ + "name": item.Name, + "size": item.Size, + "mtime": item.MTime, + "kind": item.Kind, + "prompt": "", + "model": "", + "resolution": "", + "ratio": "", + "duration": "", + } + if event, ok := index[item.Name]; ok { + row["prompt"] = event.Prompt + row["model"] = event.Model + row["resolution"] = event.Resolution + row["ratio"] = event.Ratio + row["duration"] = event.Duration + } + out = append(out, row) + } + return out, total, stats, nil +} + +func (s *AdminReadService) RecentImages(ctx context.Context, limit int) ([]map[string]any, error) { + if limit <= 0 { + limit = 24 + } + allFiles, _, err := s.scanGeneratedFiles(ctx) + if err != nil { + return nil, err + } + sort.SliceStable(allFiles, func(i, j int) bool { + return allFiles[i].MTime > allFiles[j].MTime + }) + if len(allFiles) > limit { + allFiles = allFiles[:limit] + } + out := make([]map[string]any, 0, len(allFiles)) + for _, item := range allFiles { + out = append(out, map[string]any{ + "name": item.Name, + "size": item.Size, + "mtime": item.MTime, + "kind": item.Kind, + }) + } + return out, nil +} + +// RecentImagesOwned lists the most-recent generated images under a single owner +// directory (used by the showcase picker so an admin sees only their OWN images). +func (s *AdminReadService) RecentImagesOwned(ctx context.Context, owner string, limit int) ([]map[string]any, error) { + if limit <= 0 { + limit = 24 + } + owner = strings.TrimSpace(owner) + if owner == "" || s.store == nil || !s.store.Configured() { + return []map[string]any{}, nil + } + objs, err := s.store.List(ctx, owner+"/") + if err != nil { + return nil, err + } + files := make([]generatedFile, 0, len(objs)) + for _, o := range objs { + if isReferenceFile(o.Key) { + continue + } + kind := mediaKind(o.Key) + if kind == "" { + continue + } + files = append(files, generatedFile{Name: o.Key, Size: o.Size, MTime: o.LastModified.Unix(), Kind: kind}) + } + sort.SliceStable(files, func(i, j int) bool { return files[i].MTime > files[j].MTime }) + if len(files) > limit { + files = files[:limit] + } + out := make([]map[string]any, 0, len(files)) + for _, f := range files { + out = append(out, map[string]any{"name": f.Name, "size": f.Size, "mtime": f.MTime, "kind": f.Kind}) + } + return out, nil +} + +func (s *AdminReadService) eventIndexByFile(ctx context.Context) (map[string]model.EventLog, error) { + items, err := s.events.RecentByFile(ctx, 10000) + if err != nil { + return nil, err + } + out := make(map[string]model.EventLog, len(items)) + for _, item := range items { + if item.File == "" { + continue + } + if _, ok := out[item.File]; ok { + continue + } + out[item.File] = item + } + return out, nil +} + +type generatedFile struct { + Name string + Size int64 + MTime int64 + Kind string +} + +// mediaKind classifies an object key by extension (image / video / "" = skip). +func mediaKind(name string) string { + i := strings.LastIndex(name, ".") + if i < 0 { + return "" + } + switch strings.ToLower(name[i+1:]) { + case "png", "jpg", "jpeg", "webp", "gif": + return "image" + case "mp4", "webm", "mov": + return "video" + default: + return "" + } +} + +// isReferenceFile reports whether a key is an uploaded reference image (named +// "...-ref-..."), so the gallery / picker can skip them — only generated outputs +// are listed. +func isReferenceFile(name string) bool { + return strings.Contains(name, "-ref-") +} + +// scanGeneratedFiles lists media objects from RustFS (replacing the old local +// directory walk). Keys ARE the relative paths the rest of the app expects. +func (s *AdminReadService) scanGeneratedFiles(ctx context.Context) ([]generatedFile, map[string]any, error) { + stats := map[string]any{"total": 0, "image": 0, "video": 0, "size_bytes": int64(0)} + if s.store == nil || !s.store.Configured() { + return nil, stats, nil + } + objs, err := s.store.List(ctx, "") + if err != nil { + return nil, nil, err + } + out := make([]generatedFile, 0, len(objs)) + for _, o := range objs { + if isReferenceFile(o.Key) { + continue // reference uploads are not generated outputs — hide from gallery + } + kind := mediaKind(o.Key) + if kind == "" { + continue + } + stats[kind] = stats[kind].(int) + 1 + stats["total"] = stats["total"].(int) + 1 + stats["size_bytes"] = stats["size_bytes"].(int64) + o.Size + out = append(out, generatedFile{ + Name: o.Key, + Size: o.Size, + MTime: o.LastModified.Unix(), + Kind: kind, + }) + } + return out, stats, nil +} diff --git a/backend/internal/service/admin_write.go b/backend/internal/service/admin_write.go new file mode 100644 index 0000000..c38b8ca --- /dev/null +++ b/backend/internal/service/admin_write.go @@ -0,0 +1,635 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "backend/internal/model" + "backend/internal/repo" + + "github.com/google/uuid" + "gorm.io/datatypes" + "gorm.io/gorm" +) + +// ErrNotFound is returned by delete/adjust service methods when the target +// row does not exist, so handlers can translate it into a 404 (GORM's Delete +// does not error on a zero-row delete). +var ErrNotFound = errors.New("not found") + +type AdminWriteService struct { + users *repo.UserRepository + showcase *repo.ShowcaseRepository + models *repo.ModelRepository + events *repo.EventRepository + apiKeys *repo.APIKeyRepository +} + +func NewAdminWriteService(users *repo.UserRepository, showcase *repo.ShowcaseRepository, models *repo.ModelRepository, events *repo.EventRepository, apiKeys *repo.APIKeyRepository) *AdminWriteService { + return &AdminWriteService{ + users: users, + showcase: showcase, + models: models, + events: events, + apiKeys: apiKeys, + } +} + +func (s *AdminWriteService) CreateUser(ctx context.Context, body map[string]any) (*model.User, error) { + email, err := ValidateEmail(stringValue(body["email"])) + if err != nil { + return nil, err + } + name := strings.TrimSpace(stringValue(body["name"])) + if name != "" { + name, err = ValidateUsername(name) + if err != nil { + return nil, err + } + } + password := stringValue(body["password"]) + role := normalizedRole(stringValue(body["role"])) + // 管理员唯一:不能通过用户管理创建新的 admin(只能是 user / agent)。 + if role == "admin" { + role = "user" + } + status := normalizedStatus(stringValue(body["status"])) + credits := maxFloat(0, floatValue(body["credits"])) + notes := strings.TrimSpace(stringValue(body["notes"])) + + exists, err := s.users.ExistsEmail(ctx, email, "") + if err != nil { + return nil, err + } + if exists { + return nil, errors.New("邮箱已存在") + } + if name != "" { + exists, err = s.users.ExistsName(ctx, name, "") + if err != nil { + return nil, err + } + if exists { + return nil, errors.New("用户名已存在") + } + } + + passwordHash := "" + if strings.TrimSpace(password) != "" { + if err := ValidatePassword(password); err != nil { + return nil, err + } + h, err := HashPassword(password) + if err != nil { + return nil, err + } + passwordHash = h + } + + user := &model.User{ + ID: "u-" + uuid.NewString()[:10], + Email: email, + Name: name, + PasswordHash: passwordHash, + Role: role, + Status: status, + Credits: credits, + Notes: notes, + InviteCode: randomInviteCode(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := s.users.Create(ctx, user); err != nil { + return nil, err + } + return s.users.GetByID(ctx, user.ID) +} + +func (s *AdminWriteService) UpdateUser(ctx context.Context, userID string, body map[string]any) (*model.User, error) { + patch := map[string]any{} + if _, ok := body["email"]; ok { + email, err := ValidateEmail(stringValue(body["email"])) + if err != nil { + return nil, err + } + exists, err := s.users.ExistsEmail(ctx, email, userID) + if err != nil { + return nil, err + } + if exists { + return nil, errors.New("邮箱已存在") + } + patch["email"] = email + } + if _, ok := body["name"]; ok { + name := strings.TrimSpace(stringValue(body["name"])) + if name != "" { + var err error + name, err = ValidateUsername(name) + if err != nil { + return nil, err + } + exists, err := s.users.ExistsName(ctx, name, userID) + if err != nil { + return nil, err + } + if exists { + return nil, errors.New("用户名已存在") + } + } + patch["name"] = name + } + if _, ok := body["role"]; ok { + newRole := normalizedRole(stringValue(body["role"])) + // 管理员唯一:不能把任何人提升为 admin;也绝不改动现有 admin 的角色 + // (防止把唯一管理员误降级导致后台失去管理员)。 + cur, _ := s.users.GetByID(ctx, userID) + if newRole != "admin" && (cur == nil || cur.Role != "admin") { + patch["role"] = newRole + } + } + if _, ok := body["status"]; ok { + patch["status"] = normalizedStatus(stringValue(body["status"])) + } + if _, ok := body["credits"]; ok { + patch["credits"] = maxFloat(0, floatValue(body["credits"])) + } + if _, ok := body["notes"]; ok { + patch["notes"] = strings.TrimSpace(stringValue(body["notes"])) + } + if _, ok := body["password"]; ok && strings.TrimSpace(stringValue(body["password"])) != "" { + if err := ValidatePassword(stringValue(body["password"])); err != nil { + return nil, err + } + h, err := HashPassword(stringValue(body["password"])) + if err != nil { + return nil, err + } + patch["password_hash"] = h + } + return s.users.Update(ctx, userID, patch) +} + +func (s *AdminWriteService) DeleteUser(ctx context.Context, userID string) error { + rows, err := s.users.Delete(ctx, userID) + if err != nil { + return err + } + if rows == 0 { + return ErrNotFound + } + return nil +} + +// DeleteUsers removes many users in one call (multi-select). Returns the count +// removed. +func (s *AdminWriteService) DeleteUsers(ctx context.Context, ids []string) (int, error) { + seen := make(map[string]struct{}, len(ids)) + clean := make([]string, 0, len(ids)) + for _, id := range ids { + id = strings.TrimSpace(id) + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + clean = append(clean, id) + } + if len(clean) == 0 { + return 0, nil + } + rows, err := s.users.DeleteByIDs(ctx, clean) + return int(rows), err +} + +func (s *AdminWriteService) AdjustUserCredits(ctx context.Context, userID string, delta float64) (*model.User, error) { + user, err := s.users.AdjustCredits(ctx, userID, delta) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + return nil, err + } + return user, nil +} + +// SetUserCredits sets a user's credit balance to an absolute value (non-negative). +// Mirrors Python users_store.adjust_credits set_to mode; the update runs inside a +// transaction with a row lock so concurrent adjustments stay consistent. +func (s *AdminWriteService) SetUserCredits(ctx context.Context, userID string, value float64) (*model.User, error) { + if value < 0 { + value = 0 + } + user, err := s.users.SetCredits(ctx, userID, value) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + return nil, err + } + return user, nil +} + +func (s *AdminWriteService) CreateUserAPIKey(ctx context.Context, userID, name string) (*model.APIKey, string, error) { + plain, err := generatePlainAPIKey() + if err != nil { + return nil, "", err + } + name = strings.TrimSpace(name) + if name == "" { + name = "admin" + } + key := &model.APIKey{ + ID: "k-" + time.Now().Format("150405") + randomSuffix(2), + UserID: userID, + Name: name, + KeyPreview: previewAPIKey(plain), + KeyHash: hashAPIKey(plain), + CreatedAt: time.Now(), + } + if err := s.apiKeys.Create(ctx, key); err != nil { + return nil, "", err + } + return key, plain, nil +} + +func (s *AdminWriteService) DeleteUserAPIKey(ctx context.Context, userID, keyID string) error { + if strings.TrimSpace(keyID) == "" { + return errors.New("key id required") + } + return s.apiKeys.DeleteByID(ctx, userID, keyID) +} + +func (s *AdminWriteService) CreateShowcase(ctx context.Context, body map[string]any) (*model.ShowcaseItem, error) { + kind := normalizedShowcaseKind(stringValue(body["kind"])) + if kind == "" { + return nil, errors.New("kind must be hero, bento or work") + } + image := strings.TrimSpace(stringValue(body["image"])) + if image == "" { + return nil, errors.New("请选择底图") + } + title := strings.TrimSpace(stringValue(body["title"])) + prompt := strings.TrimSpace(stringValue(body["prompt"])) + if kind != "work" { + if title == "" { + return nil, errors.New("请填写标题") + } + if prompt == "" { + return nil, errors.New("请填写提示词") + } + } + + item := &model.ShowcaseItem{ + ID: "sc-" + uuid.NewString()[:10], + Kind: kind, + Title: title, + Subtitle: strings.TrimSpace(stringValue(body["subtitle"])), + Prompt: prompt, + Gradient: strings.TrimSpace(stringValue(body["gradient"])), + Span: strings.TrimSpace(stringValue(body["span"])), + Image: image, + Weight: intValue(body["weight"]), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := s.showcase.Create(ctx, item); err != nil { + return nil, err + } + return item, nil +} + +func (s *AdminWriteService) UpdateShowcase(ctx context.Context, entryID string, body map[string]any) (*model.ShowcaseItem, error) { + patch := map[string]any{} + if _, ok := body["kind"]; ok { + kind := normalizedShowcaseKind(stringValue(body["kind"])) + if kind == "" { + return nil, errors.New("kind must be hero, bento or work") + } + patch["kind"] = kind + } + for _, field := range []string{"title", "subtitle", "prompt", "gradient", "span", "image"} { + if _, ok := body[field]; ok { + patch[field] = strings.TrimSpace(stringValue(body[field])) + } + } + if _, ok := body["weight"]; ok { + patch["weight"] = intValue(body["weight"]) + } + return s.showcase.Update(ctx, entryID, patch) +} + +func (s *AdminWriteService) DeleteShowcase(ctx context.Context, entryID string) error { + rows, err := s.showcase.Delete(ctx, entryID) + if err != nil { + return err + } + if rows == 0 { + return ErrNotFound + } + return nil +} + +func (s *AdminWriteService) CreateModel(ctx context.Context, body map[string]any) (*model.ModelConfig, error) { + modelID := strings.TrimSpace(stringValue(body["id"])) + modelType := normalizedModelType(stringValue(body["type"])) + provider := strings.TrimSpace(stringValue(body["provider"])) + if modelID == "" { + return nil, errors.New("id required") + } + if modelType == "" { + return nil, errors.New("type must be image or video") + } + if provider == "" { + return nil, errors.New("provider required") + } + + prices := jsonMap(body["prices"]) + // image: tiers derive from the price keys (form omits resolutions); + // video: resolutions come straight from the form (720p/1080p…). Python parity. + resolutions := jsonArray(body["resolutions"]) + if modelType != "video" { + resolutions = resolutionsFromPrices(prices) + } + + item := &model.ModelConfig{ + ID: modelID, + Type: modelType, + Name: defaultString(strings.TrimSpace(stringValue(body["name"])), modelID), + Provider: provider, + Enabled: boolValueWithDefault(body["enabled"], true), + Ratios: jsonArray(body["ratios"]), + Prices: prices, + Resolutions: resolutions, + ImageToImage: boolValueWithDefault(body["image_to_image"], false), + DurationPrices: jsonMap(body["duration_prices"]), + PricesAgent: jsonMap(body["prices_agent"]), + DurationPricesAgent: jsonMap(body["duration_prices_agent"]), + Durations: jsonArray(body["durations"]), + MaxReferenceImages: intValue(body["max_reference_images"]), + ReferenceMode: defaultString(strings.TrimSpace(stringValue(body["reference_mode"])), "none"), + Weight: intValue(body["weight"]), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := s.models.Create(ctx, item); err != nil { + return nil, err + } + return item, nil +} + +func (s *AdminWriteService) UpdateModel(ctx context.Context, modelID string, body map[string]any) (*model.ModelConfig, error) { + patch := map[string]any{} + if _, ok := body["type"]; ok { + modelType := normalizedModelType(stringValue(body["type"])) + if modelType == "" { + return nil, errors.New("type must be image or video") + } + patch["type"] = modelType + } + if _, ok := body["name"]; ok { + patch["name"] = strings.TrimSpace(stringValue(body["name"])) + } + if _, ok := body["provider"]; ok { + provider := strings.TrimSpace(stringValue(body["provider"])) + if provider == "" { + return nil, errors.New("provider required") + } + patch["provider"] = provider + } + // Only touch `enabled` when the caller explicitly sends a non-null value; + // mirrors Python models_store.update ("enabled" in fields and is not None). + // Without this guard a PATCH that omits the field would default it to false + // and silently disable the model. + if raw, ok := body["enabled"]; ok && raw != nil { + patch["enabled"] = boolValueWithDefault(raw, true) + } + if _, ok := body["ratios"]; ok { + patch["ratios"] = jsonArray(body["ratios"]) + } + if _, ok := body["prices"]; ok { + prices := jsonMap(body["prices"]) + patch["prices"] = prices + // Python parity (models_store.update): recompute resolutions from the new + // price keys. An explicit `resolutions` field below (video) overrides this. + patch["resolutions"] = resolutionsFromPrices(prices) + } + if _, ok := body["resolutions"]; ok { + patch["resolutions"] = jsonArray(body["resolutions"]) + } + if _, ok := body["image_to_image"]; ok { + patch["image_to_image"] = boolValueWithDefault(body["image_to_image"], false) + } + if _, ok := body["duration_prices"]; ok { + patch["duration_prices"] = jsonMap(body["duration_prices"]) + } + if _, ok := body["prices_agent"]; ok { + patch["prices_agent"] = jsonMap(body["prices_agent"]) + } + if _, ok := body["duration_prices_agent"]; ok { + patch["duration_prices_agent"] = jsonMap(body["duration_prices_agent"]) + } + if _, ok := body["durations"]; ok { + patch["durations"] = jsonArray(body["durations"]) + } + if _, ok := body["max_reference_images"]; ok { + patch["max_reference_images"] = intValue(body["max_reference_images"]) + } + if _, ok := body["reference_mode"]; ok { + patch["reference_mode"] = defaultString(strings.TrimSpace(stringValue(body["reference_mode"])), "none") + } + if _, ok := body["weight"]; ok { + patch["weight"] = intValue(body["weight"]) + } + return s.models.Update(ctx, modelID, patch) +} + +func (s *AdminWriteService) DeleteModel(ctx context.Context, modelID string) error { + rows, err := s.models.Delete(ctx, modelID) + if err != nil { + return err + } + if rows == 0 { + return ErrNotFound + } + return nil +} + +func (s *AdminWriteService) ClearLogs(ctx context.Context) (int64, error) { + return s.events.DeleteAll(ctx) +} + +func (s *AdminWriteService) ClearPendingLogs(ctx context.Context) (int64, error) { + return s.events.DeletePending(ctx) +} + +func HashPassword(password string) (string, error) { + hash, err := GeneratePasswordHash(password) + if err != nil { + return "", err + } + return "bcrypt$" + hash, nil +} + +func normalizedRole(role string) string { + switch strings.TrimSpace(role) { + case "admin": + return "admin" + case "agent": + return "agent" + default: + return "user" + } +} + +func normalizedStatus(status string) string { + if strings.TrimSpace(status) == "disabled" { + return "disabled" + } + return "active" +} + +func normalizedShowcaseKind(kind string) string { + switch strings.TrimSpace(kind) { + case "hero", "bento", "work": + return strings.TrimSpace(kind) + default: + return "" + } +} + +func normalizedModelType(v string) string { + switch strings.TrimSpace(v) { + case "image", "video": + return strings.TrimSpace(v) + default: + return "" + } +} + +func stringValue(v any) string { + if v == nil { + return "" + } + switch x := v.(type) { + case string: + return x + default: + return fmt.Sprint(v) + } +} + +func floatValue(v any) float64 { + switch x := v.(type) { + case float64: + return x + case float32: + return float64(x) + case int: + return float64(x) + case int64: + return float64(x) + case json.Number: + f, _ := x.Float64() + return f + case string: + var f float64 + _, _ = fmt.Sscanf(strings.TrimSpace(x), "%f", &f) + return f + default: + return 0 + } +} + +func intValue(v any) int { + return int(floatValue(v)) +} + +func boolValueWithDefault(v any, fallback bool) bool { + if v == nil { + return fallback + } + switch x := v.(type) { + case bool: + return x + case string: + switch strings.ToLower(strings.TrimSpace(x)) { + case "1", "true", "yes", "on": + return true + case "0", "false", "no", "off": + return false + } + } + return fallback +} + +// resolutionsFromPrices mirrors Python models_store._resolutions_from_prices: +// an image model's quality tiers ARE its price keys (the admin form never sends +// `resolutions` for images), returned in canonical 1K/2K/4K order. gpt-image-2, +// for example, only ever has a "1K" price, so it resolves to exactly ["1K"]. +func resolutionsFromPrices(prices datatypes.JSONMap) datatypes.JSON { + out := []string{} + for _, r := range []string{"1K", "2K", "4K"} { + if _, ok := prices[r]; ok { + out = append(out, r) + } + } + return jsonArray(out) +} + +func jsonArray(v any) datatypes.JSON { + if v == nil { + return datatypes.JSON([]byte("[]")) + } + b, err := json.Marshal(v) + if err != nil { + return datatypes.JSON([]byte("[]")) + } + return datatypes.JSON(b) +} + +func jsonMap(v any) datatypes.JSONMap { + if v == nil { + return datatypes.JSONMap{} + } + switch m := v.(type) { + case map[string]any: + return datatypes.JSONMap(m) + default: + b, err := json.Marshal(v) + if err != nil { + return datatypes.JSONMap{} + } + var out map[string]any + if err := json.Unmarshal(b, &out); err != nil { + return datatypes.JSONMap{} + } + return datatypes.JSONMap(out) + } +} + +func defaultString(v, fallback string) string { + if strings.TrimSpace(v) == "" { + return fallback + } + return v +} + +func maxFloat(min, v float64) float64 { + if v < min { + return min + } + return v +} + +func randomInviteCode() string { + return randomUpper(8) +} + +var _ = gorm.ErrRecordNotFound diff --git a/backend/internal/service/alphabet.go b/backend/internal/service/alphabet.go new file mode 100644 index 0000000..5a72bca --- /dev/null +++ b/backend/internal/service/alphabet.go @@ -0,0 +1,20 @@ +package service + +import nanoid "github.com/matoous/go-nanoid/v2" + +const UpperAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + +func randomUpper(n int) string { + v, err := nanoid.Generate(UpperAlphabet, n) + if err != nil { + if n <= 0 { + return "" + } + out := make([]byte, n) + for i := range out { + out[i] = 'A' + } + return string(out) + } + return v +} diff --git a/backend/internal/service/api_key.go b/backend/internal/service/api_key.go new file mode 100644 index 0000000..1912e5b --- /dev/null +++ b/backend/internal/service/api_key.go @@ -0,0 +1,130 @@ +package service + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "strings" + "time" + + "backend/internal/model" + "backend/internal/repo" +) + +type APIKeyService struct { + keys *repo.APIKeyRepository +} + +func NewAPIKeyService(keys *repo.APIKeyRepository) *APIKeyService { + return &APIKeyService{keys: keys} +} + +func (s *APIKeyService) Current(ctx context.Context, userID string) (map[string]any, error) { + keys, err := s.keys.ListByUserID(ctx, userID) + if err != nil { + return nil, err + } + if len(keys) == 0 { + return map[string]any{"key": nil}, nil + } + key := keys[0] + return map[string]any{ + "key": map[string]any{ + "id": key.ID, + "name": key.Name, + "key_preview": key.KeyPreview, + "created_at": key.CreatedAt, + "last_used_at": key.LastUsedAt, + }, + }, nil +} + +func (s *APIKeyService) Mint(ctx context.Context, userID string) (map[string]any, error) { + plain, err := generatePlainAPIKey() + if err != nil { + return nil, err + } + key := &model.APIKey{ + ID: "k-" + time.Now().Format("150405") + randomSuffix(2), + UserID: userID, + Name: "default", + KeyPreview: previewAPIKey(plain), + KeyHash: hashAPIKey(plain), + CreatedAt: time.Now(), + } + if err := s.keys.ReplaceForUser(ctx, userID, key); err != nil { + return nil, err + } + return map[string]any{ + "ok": true, + "key": plain, + "preview": key.KeyPreview, + }, nil +} + +func (s *APIKeyService) Revoke(ctx context.Context, userID string) error { + return s.keys.DeleteByUserID(ctx, userID) +} + +func (s *APIKeyService) MintNamed(ctx context.Context, userID, name string, replace bool) (map[string]any, error) { + name = strings.TrimSpace(name) + if name == "" { + name = "default" + } + plain, err := generatePlainAPIKey() + if err != nil { + return nil, err + } + key := &model.APIKey{ + ID: "k-" + time.Now().Format("150405") + randomSuffix(2), + UserID: userID, + Name: name, + KeyPreview: previewAPIKey(plain), + KeyHash: hashAPIKey(plain), + CreatedAt: time.Now(), + } + if replace { + if err := s.keys.ReplaceForUser(ctx, userID, key); err != nil { + return nil, err + } + } else { + if err := s.keys.Create(ctx, key); err != nil { + return nil, err + } + } + return map[string]any{ + "ok": true, + "key": plain, + "preview": key.KeyPreview, + "id": key.ID, + "name": key.Name, + }, nil +} + +func (s *APIKeyService) DeleteOne(ctx context.Context, userID, keyID string) error { + if strings.TrimSpace(keyID) == "" { + return errors.New("key id required") + } + return s.keys.DeleteByID(ctx, userID, keyID) +} + +func generatePlainAPIKey() (string, error) { + return "sk-" + randomUpper(38), nil +} + +func previewAPIKey(plain string) string { + if len(plain) <= 4 { + return strings.Repeat("•", len(plain)) + } + return "…" + plain[len(plain)-4:] +} + +func hashAPIKey(plain string) string { + sum := sha256.Sum256([]byte(plain)) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +func randomSuffix(n int) string { + return randomUpper(n) +} diff --git a/backend/internal/service/app_settings.go b/backend/internal/service/app_settings.go new file mode 100644 index 0000000..4f837fa --- /dev/null +++ b/backend/internal/service/app_settings.go @@ -0,0 +1,438 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "backend/internal/repo" + "backend/internal/storage" +) + +type AppSettingsService struct { + settings *repo.SiteSettingRepository + events *repo.EventRepository + smtp *SMTPService + store *storage.Client +} + +type RegistrationSettings struct { + Open bool `json:"open"` + EmailCode bool `json:"email_code"` + AllowPasswordReset bool `json:"allow_password_reset"` + AllowedDomains []string `json:"allowed_email_domains"` + CodeTTLSeconds int `json:"code_ttl_seconds"` +} + +type SMTPSettings struct { + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password"` + FromAddr string `json:"from_addr"` + UseTLS bool `json:"use_tls"` +} + +type CreditSettings struct { + CheckinEnabled bool `json:"checkin_enabled"` + CheckinReward int `json:"checkin_reward"` + InviteEnabled bool `json:"invite_enabled"` + InviteReward int `json:"invite_reward"` +} + +type ProxySettings struct { + Proxy string `json:"proxy"` +} + +type RetentionSettings struct { + RetentionDays int `json:"retention_days"` +} + +type MediaRetentionResult struct { + Settings *RetentionSettings + Removed int `json:"removed"` + FreedBytes int64 `json:"freed_bytes"` +} + +func NewAppSettingsService(settings *repo.SiteSettingRepository, events *repo.EventRepository, smtp *SMTPService, store *storage.Client) *AppSettingsService { + return &AppSettingsService{ + settings: settings, + events: events, + smtp: smtp, + store: store, + } +} + +func (s *AppSettingsService) Registration(ctx context.Context) (*RegistrationSettings, error) { + openRaw, err := s.settings.GetValue(ctx, "auth.open") + if err != nil { + return nil, err + } + emailCodeRaw, err := s.settings.GetValue(ctx, "auth.email_code") + if err != nil { + return nil, err + } + resetRaw, err := s.settings.GetValue(ctx, "auth.allow_password_reset") + if err != nil { + return nil, err + } + domainsRaw, err := s.settings.GetValue(ctx, "auth.allowed_email_domains") + if err != nil { + return nil, err + } + ttlRaw, err := s.settings.GetValue(ctx, "auth.code_ttl_seconds") + if err != nil { + return nil, err + } + ttl, _ := strconv.Atoi(strings.TrimSpace(ttlRaw)) + if ttl < 60 { + ttl = 600 + } + return &RegistrationSettings{ + Open: parseBoolSetting(openRaw, true), + EmailCode: parseBoolSetting(emailCodeRaw, false), + AllowPasswordReset: parseBoolSetting(resetRaw, false), + AllowedDomains: parseCSVSetting(domainsRaw), + CodeTTLSeconds: ttl, + }, nil +} + +func (s *AppSettingsService) SaveRegistration(ctx context.Context, in RegistrationSettings) (*RegistrationSettings, error) { + // Empty list is allowed and means "no domain restriction": EmailDomainAllowed + // returns true for everyone when the whitelist is empty. + domains := ValidateAllowedEmailDomains(in.AllowedDomains) + if in.CodeTTLSeconds < 60 { + in.CodeTTLSeconds = 600 + } + if err := s.settings.UpsertValues(ctx, map[string]string{ + "auth.open": strconv.FormatBool(in.Open), + "auth.email_code": strconv.FormatBool(in.EmailCode), + "auth.allow_password_reset": strconv.FormatBool(in.AllowPasswordReset), + "auth.allowed_email_domains": strings.Join(domains, ","), + "auth.code_ttl_seconds": strconv.Itoa(in.CodeTTLSeconds), + }); err != nil { + return nil, err + } + return s.Registration(ctx) +} + +func (s *AppSettingsService) SMTP(ctx context.Context) (*SMTPSettings, error) { + host, err := s.settings.GetValue(ctx, "smtp.host") + if err != nil { + return nil, err + } + portRaw, err := s.settings.GetValue(ctx, "smtp.port") + if err != nil { + return nil, err + } + username, err := s.settings.GetValue(ctx, "smtp.username") + if err != nil { + return nil, err + } + password, err := s.settings.GetValue(ctx, "smtp.password") + if err != nil { + return nil, err + } + fromAddr, err := s.settings.GetValue(ctx, "smtp.from_addr") + if err != nil { + return nil, err + } + useTLSRaw, err := s.settings.GetValue(ctx, "smtp.use_tls") + if err != nil { + return nil, err + } + + port, _ := strconv.Atoi(strings.TrimSpace(portRaw)) + if port <= 0 { + port = 587 + } + return &SMTPSettings{ + Host: strings.TrimSpace(host), + Port: port, + Username: strings.TrimSpace(username), + Password: maskedSecret(password), + FromAddr: strings.TrimSpace(fromAddr), + UseTLS: parseBoolSetting(useTLSRaw, true), + }, nil +} + +func (s *AppSettingsService) SaveSMTP(ctx context.Context, in SMTPSettings) (*SMTPSettings, error) { + host := strings.TrimSpace(in.Host) + username := strings.TrimSpace(in.Username) + fromAddr := strings.TrimSpace(in.FromAddr) + if host == "" || username == "" || fromAddr == "" { + return nil, errors.New("请填写 主机 / 用户名 / 发件地址") + } + if _, err := ValidateEmail(fromAddr); err != nil { + return nil, err + } + if in.Port <= 0 { + return nil, errors.New("port 必须是正整数") + } + + updates := map[string]string{ + "smtp.host": host, + "smtp.port": strconv.Itoa(in.Port), + "smtp.username": username, + "smtp.from_addr": fromAddr, + "smtp.use_tls": strconv.FormatBool(in.UseTLS), + } + if strings.TrimSpace(in.Password) != "" && strings.TrimSpace(in.Password) != "***" { + updates["smtp.password"] = in.Password + } + if err := s.settings.UpsertValues(ctx, updates); err != nil { + return nil, err + } + return s.SMTP(ctx) +} + +func (s *AppSettingsService) TestSMTP(ctx context.Context, to string) error { + to, err := ValidateEmail(to) + if err != nil { + return err + } + cfg, err := s.loadSMTPConfig(ctx) + if err != nil { + return err + } + return s.smtp.SendCode(ctx, cfg, to, "123456", "register") +} + +func (s *AppSettingsService) Proxy(ctx context.Context) (*ProxySettings, error) { + proxy, err := s.settings.GetValue(ctx, "proxy.url") + if err != nil { + return nil, err + } + return &ProxySettings{Proxy: strings.TrimSpace(proxy)}, nil +} + +func (s *AppSettingsService) SaveProxy(ctx context.Context, proxy string) (*ProxySettings, error) { + proxy = strings.TrimSpace(proxy) + if err := s.settings.UpsertValue(ctx, "proxy.url", proxy); err != nil { + return nil, err + } + return &ProxySettings{Proxy: proxy}, nil +} + +// TestProxy routes a probe request through the given proxy to an IP-echo service +// and reports the egress IP + latency. Tests the value passed in (so the admin +// can verify before saving). Mirrors how generation calls go out — same HTTP +// CONNECT through the proxy — so a green result means upstream calls will route. +func (s *AppSettingsService) TestProxy(ctx context.Context, proxy string) (map[string]any, error) { + proxy = strings.TrimSpace(proxy) + if proxy == "" { + return nil, errors.New("代理地址为空,请先填写") + } + parsed, err := url.Parse(proxy) + if err != nil || parsed.Host == "" { + return nil, fmt.Errorf("代理地址格式不正确(应形如 http://user:pass@host:port)") + } + + transport := &http.Transport{Proxy: http.ProxyURL(parsed)} + defer transport.CloseIdleConnections() + client := &http.Client{Transport: transport, Timeout: 12 * time.Second} + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.ipify.org?format=json", nil) + if err != nil { + return nil, err + } + start := time.Now() + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("通过代理请求失败:%v", err) + } + defer resp.Body.Close() + elapsed := int(time.Since(start).Milliseconds()) + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("代理已连接,但探测返回 HTTP %d", resp.StatusCode) + } + var echo struct { + IP string `json:"ip"` + } + _ = json.Unmarshal(body, &echo) + return map[string]any{ + "exit_ip": echo.IP, + "elapsed_ms": elapsed, + }, nil +} + +func (s *AppSettingsService) Credits(ctx context.Context) (*CreditSettings, error) { + checkinEnabledRaw, err := s.settings.GetValue(ctx, "credits.checkin_enabled") + if err != nil { + return nil, err + } + checkinRewardRaw, err := s.settings.GetValue(ctx, "credits.checkin_reward") + if err != nil { + return nil, err + } + inviteEnabledRaw, err := s.settings.GetValue(ctx, "credits.invite_enabled") + if err != nil { + return nil, err + } + inviteRewardRaw, err := s.settings.GetValue(ctx, "credits.invite_reward") + if err != nil { + return nil, err + } + return &CreditSettings{ + CheckinEnabled: parseBoolSetting(checkinEnabledRaw, true), + CheckinReward: parseIntSetting(checkinRewardRaw, 3), + InviteEnabled: parseBoolSetting(inviteEnabledRaw, true), + InviteReward: parseIntSetting(inviteRewardRaw, 3), + }, nil +} + +func (s *AppSettingsService) SaveCredits(ctx context.Context, in CreditSettings) (*CreditSettings, error) { + if in.CheckinReward < 0 { + in.CheckinReward = 0 + } + if in.InviteReward < 0 { + in.InviteReward = 0 + } + if err := s.settings.UpsertValues(ctx, map[string]string{ + "credits.checkin_enabled": strconv.FormatBool(in.CheckinEnabled), + "credits.checkin_reward": strconv.Itoa(in.CheckinReward), + "credits.invite_enabled": strconv.FormatBool(in.InviteEnabled), + "credits.invite_reward": strconv.Itoa(in.InviteReward), + }); err != nil { + return nil, err + } + return s.Credits(ctx) +} + +func (s *AppSettingsService) Logs(ctx context.Context) (*RetentionSettings, error) { + return s.retention(ctx, "logs.retention_days") +} + +func (s *AppSettingsService) SaveLogs(ctx context.Context, days int) (*RetentionSettings, error) { + days, err := normalizeRetentionDays(days) + if err != nil { + return nil, err + } + if err := s.settings.UpsertValue(ctx, "logs.retention_days", strconv.Itoa(days)); err != nil { + return nil, err + } + if s.events != nil { + _, _ = s.events.PurgeOlderThan(ctx, time.Duration(days)*24*time.Hour) + } + return s.Logs(ctx) +} + +func (s *AppSettingsService) Media(ctx context.Context) (*RetentionSettings, error) { + return s.retention(ctx, "media.retention_days") +} + +func (s *AppSettingsService) SaveMedia(ctx context.Context, days int) (*MediaRetentionResult, error) { + days, err := normalizeRetentionDays(days) + if err != nil { + return nil, err + } + if err := s.settings.UpsertValue(ctx, "media.retention_days", strconv.Itoa(days)); err != nil { + return nil, err + } + removed, freed := s.pruneGeneratedFiles(ctx, time.Duration(days)*24*time.Hour) + settings, err := s.Media(ctx) + if err != nil { + return nil, err + } + return &MediaRetentionResult{ + Settings: settings, + Removed: removed, + FreedBytes: freed, + }, nil +} + +func (s *AppSettingsService) loadSMTPConfig(ctx context.Context) (SMTPConfig, error) { + current, err := s.SMTP(ctx) + if err != nil { + return SMTPConfig{}, err + } + password, err := s.settings.GetValue(ctx, "smtp.password") + if err != nil { + return SMTPConfig{}, err + } + return SMTPConfig{ + Host: current.Host, + Port: current.Port, + Username: current.Username, + Password: password, + FromAddr: current.FromAddr, + UseTLS: current.UseTLS, + }, nil +} + +func maskedSecret(v string) string { + if strings.TrimSpace(v) == "" { + return "" + } + return "***" +} + +func parseIntSetting(v string, fallback int) int { + n, err := strconv.Atoi(strings.TrimSpace(v)) + if err != nil { + return fallback + } + return n +} + +func (s *AppSettingsService) retention(ctx context.Context, key string) (*RetentionSettings, error) { + raw, err := s.settings.GetValue(ctx, key) + if err != nil { + return nil, err + } + days := parseIntSetting(raw, 30) + if days < 1 { + days = 30 + } + return &RetentionSettings{RetentionDays: days}, nil +} + +func normalizeRetentionDays(days int) (int, error) { + if days < 1 { + return 0, errors.New("留存天数至少为 1 天") + } + if days > 365 { + return 0, errors.New("留存天数最多 365 天") + } + return days, nil +} + +// pruneGeneratedFiles deletes RustFS objects older than maxAge and blanks the +// matching event_log.file refs. Returns how many were removed and bytes freed. +// (The maintenance loop does the same automatically every 60s; this gives the +// admin an immediate result when they shorten the media retention window.) +func (s *AppSettingsService) pruneGeneratedFiles(ctx context.Context, maxAge time.Duration) (int, int64) { + if s.store == nil || !s.store.Configured() || maxAge <= 0 { + return 0, 0 + } + objs, err := s.store.List(ctx, "") + if err != nil { + return 0, 0 + } + cutoff := time.Now().Add(-maxAge) + removed := 0 + var freed int64 + var clearedKeys []string + for _, o := range objs { + if !o.LastModified.Before(cutoff) { + continue + } + if err := s.store.Delete(ctx, o.Key); err == nil { + removed++ + freed += o.Size + clearedKeys = append(clearedKeys, o.Key) + } + } + if len(clearedKeys) > 0 { + _, _ = s.events.ClearFiles(ctx, clearedKeys) + } + return removed, freed +} diff --git a/backend/internal/service/auth.go b/backend/internal/service/auth.go new file mode 100644 index 0000000..bb2aaf3 --- /dev/null +++ b/backend/internal/service/auth.go @@ -0,0 +1,584 @@ +package service + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "strconv" + "strings" + "time" + + "backend/internal/model" + "backend/internal/repo" + + "gorm.io/gorm" +) + +var ErrAuthFailed = errors.New("auth failed") + +type AuthService struct { + users *repo.UserRepository + settings *repo.SiteSettingRepository + sessions *SessionService + codes *EmailCodeService + smtp *SMTPService + loginGuard *LoginGuard +} + +type AuthSettings struct { + Open bool + EmailCode bool + AllowPasswordReset bool + AllowedDomains []string +} + +func NewAuthService( + users *repo.UserRepository, + settings *repo.SiteSettingRepository, + sessions *SessionService, + codes *EmailCodeService, + smtp *SMTPService, +) *AuthService { + return &AuthService{ + users: users, + settings: settings, + sessions: sessions, + codes: codes, + smtp: smtp, + loginGuard: NewLoginGuard(codes.Redis()), + } +} + +func (s *AuthService) IsAuthorizedForPrivateImage(ctx context.Context, sessionCookie, owner string) (bool, error) { + // Private images are viewable ONLY via a logged-in session cookie (no Bearer + // token / API key). A regular user may view only their OWN images; an admin + // may view anyone's. `owner` is the /images//... path segment. + if sessionCookie == "" { + return false, nil + } + payload, err := s.sessions.Validate(ctx, sessionCookie) + if err != nil { + return false, err + } + if payload == nil { + return false, nil + } + user, err := s.users.GetByID(ctx, payload.UserID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return false, nil + } + return false, err + } + if user.Role == "admin" { + return true, nil + } + return ownsImageDir(user, owner), nil +} + +// ownsImageDir reports whether `owner` (the /images//... directory) is one +// of the names this user's outputs are stored under. Mirrors the candidates +// V1Service.userDir picks from: sanitized name → sanitized email-local → id. +func ownsImageDir(user *model.User, owner string) bool { + owner = strings.TrimSpace(owner) + if owner == "" || user == nil { + return false + } + if owner == user.ID { + return true + } + if d := sanitizeOwnerName(user.Name); d != "" && d == owner { + return true + } + if d := sanitizeOwnerName(strings.Split(user.Email, "@")[0]); d != "" && d == owner { + return true + } + return false +} + +func (s *AuthService) CurrentUserFromBearer(ctx context.Context, authHeader string) (*model.User, *SessionPayload, error) { + token := ParseBearer(authHeader) + return s.currentUserFromToken(ctx, token) +} + +func (s *AuthService) CurrentUserFromRequest(ctx context.Context, authHeader, cookieToken string) (*model.User, *SessionPayload, error) { + if user, session, err := s.CurrentUserFromBearer(ctx, authHeader); err != nil || user != nil || session != nil { + return user, session, err + } + return s.currentUserFromToken(ctx, cookieToken) +} + +func (s *AuthService) CurrentUserFromToken(ctx context.Context, token string) (*model.User, *SessionPayload, error) { + return s.currentUserFromToken(ctx, token) +} + +func (s *AuthService) currentUserFromToken(ctx context.Context, token string) (*model.User, *SessionPayload, error) { + if token == "" { + return nil, nil, nil + } + + payload, err := s.sessions.Validate(ctx, token) + if err != nil { + return nil, nil, err + } + if payload == nil { + return nil, nil, nil + } + + user, err := s.users.GetByID(ctx, payload.UserID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil, nil + } + return nil, nil, err + } + if user.Status != "active" { + return nil, nil, nil + } + return user, payload, nil +} + +func (s *AuthService) Login(ctx context.Context, identifier, password, ip string) (*model.User, string, *SessionPayload, error) { + normalizedIdentifier, err := ValidateLoginIdentifier(identifier) + if err != nil { + return nil, "", nil, err + } + if strings.TrimSpace(password) == "" { + return nil, "", nil, errors.New("密码不能为空") + } + + // Exponential-backoff lockout per (ip, account) + per-ip spray (Python + // api/auth.py:226-237 via core/login_guard.py). + if err := s.loginGuard.Check(ctx, ip, normalizedIdentifier); err != nil { + return nil, "", nil, err + } + + user, err := s.users.GetByIdentifier(ctx, normalizedIdentifier) + if err != nil { + if err == gorm.ErrRecordNotFound { + if rerr := s.loginGuard.RecordFailure(ctx, ip, normalizedIdentifier); rerr != nil { + return nil, "", nil, rerr + } + return nil, "", nil, ErrAuthFailed + } + return nil, "", nil, err + } + if user.Status != "active" || !VerifyPassword(password, user.PasswordHash) { + if rerr := s.loginGuard.RecordFailure(ctx, ip, normalizedIdentifier); rerr != nil { + return nil, "", nil, rerr + } + return nil, "", nil, ErrAuthFailed + } + if err := s.loginGuard.RecordSuccess(ctx, ip, normalizedIdentifier); err != nil { + return nil, "", nil, err + } + + if err := s.users.TouchLogin(ctx, user.ID, ip); err != nil { + return nil, "", nil, err + } + token, payload, err := s.sessions.Create(ctx, user.ID) + if err != nil { + return nil, "", nil, err + } + return user, token, payload, nil +} + +func (s *AuthService) SendCode(ctx context.Context, email, purpose string) error { + cfg, err := s.loadAuthSettings(ctx) + if err != nil { + return err + } + if !cfg.EmailCode { + return errors.New("未开启邮箱验证码") + } + + normalizedEmail, err := ValidateEmail(email) + if err != nil { + return err + } + purpose = strings.ToLower(strings.TrimSpace(purpose)) + switch purpose { + case "register", "reset": + default: + return errors.New("验证码用途不正确") + } + + if purpose == "register" && !EmailDomainAllowed(normalizedEmail, cfg.AllowedDomains) { + return errors.New("该邮箱后缀不允许注册") + } + + code, err := s.codes.Issue(ctx, normalizedEmail, purpose) + if err != nil { + return err + } + return s.smtp.SendCode(ctx, s.loadSMTPSettings(ctx), normalizedEmail, code, purpose) +} + +func (s *AuthService) Register(ctx context.Context, email, username, password, inviteCode, emailCode, ip string) (*model.User, string, *SessionPayload, error) { + normalizedEmail, err := ValidateEmail(email) + if err != nil { + return nil, "", nil, err + } + normalizedUsername, err := ValidateUsername(username) + if err != nil { + return nil, "", nil, err + } + if err := ValidatePassword(password); err != nil { + return nil, "", nil, err + } + + settings, err := s.loadAuthSettings(ctx) + if err != nil { + return nil, "", nil, err + } + hasAdmin, err := s.users.HasAdmin(ctx) + if err != nil { + return nil, "", nil, err + } + // The very first account ever bootstraps the admin and skips the open + // toggle, the email-domain whitelist, and the email-code gate (Python + // api/auth.py:195-204). All three are only enforced once an admin exists. + if hasAdmin && !settings.Open { + return nil, "", nil, errors.New("当前未开放注册") + } + if hasAdmin && !EmailDomainAllowed(normalizedEmail, settings.AllowedDomains) { + return nil, "", nil, errors.New("该邮箱后缀不允许注册") + } + if hasAdmin && settings.EmailCode { + ok, err := s.codes.Verify(ctx, normalizedEmail, "register", emailCode) + if err != nil { + return nil, "", nil, err + } + if !ok { + return nil, "", nil, errors.New("邮箱验证码错误或已过期") + } + } + + exists, err := s.users.ExistsEmail(ctx, normalizedEmail, "") + if err != nil { + return nil, "", nil, err + } + if exists { + return nil, "", nil, errors.New("邮箱已存在") + } + exists, err = s.users.ExistsName(ctx, normalizedUsername, "") + if err != nil { + return nil, "", nil, err + } + if exists { + return nil, "", nil, errors.New("用户名已存在") + } + + passwordHash, err := HashPassword(password) + if err != nil { + return nil, "", nil, err + } + role := "user" + if !hasAdmin { + role = "admin" + } + + var invitedBy *string + if strings.TrimSpace(inviteCode) != "" { + inviter, err := s.users.GetByInviteCode(ctx, inviteCode) + if err == nil { + invitedBy = &inviter.ID + } + } + + now := time.Now() + user := &model.User{ + ID: "u-" + randomUpper(10), + Email: normalizedEmail, + Name: normalizedUsername, + PasswordHash: passwordHash, + Role: role, + Status: "active", + InviteCode: randomInviteCode(), + InvitedBy: invitedBy, + CreatedAt: now, + UpdatedAt: now, + } + if err := s.users.Create(ctx, user); err != nil { + return nil, "", nil, err + } + if err := s.users.TouchLogin(ctx, user.ID, ip); err != nil { + return nil, "", nil, err + } + token, payload, err := s.sessions.Create(ctx, user.ID) + if err != nil { + return nil, "", nil, err + } + created, err := s.users.GetByID(ctx, user.ID) + if err != nil { + return nil, "", nil, err + } + return created, token, payload, nil +} + +func (s *AuthService) ResetPassword(ctx context.Context, email, password, emailCode, ip string) error { + settings, err := s.loadAuthSettings(ctx) + if err != nil { + return err + } + if !settings.EmailCode || !settings.AllowPasswordReset { + return errors.New("未开放找回密码") + } + normalizedEmail, err := ValidateEmail(email) + if err != nil { + return err + } + if err := ValidatePassword(password); err != nil { + return err + } + // Rate-limit reset attempts per IP+email so the 6-digit code can't be ground + // down even with the single-use + wrong-guess cap (Python api/auth.py:257-268). + guardID := "reset:" + normalizedEmail + if err := s.loginGuard.Check(ctx, ip, guardID); err != nil { + return err + } + ok, err := s.codes.Verify(ctx, normalizedEmail, "reset", emailCode) + if err != nil { + return err + } + if !ok { + if rerr := s.loginGuard.RecordFailure(ctx, ip, guardID); rerr != nil { + return rerr + } + return errors.New("邮箱验证码错误或已过期") + } + if err := s.loginGuard.RecordSuccess(ctx, ip, guardID); err != nil { + return err + } + passwordHash, err := HashPassword(password) + if err != nil { + return err + } + _, err = s.users.SetPasswordByEmail(ctx, normalizedEmail, passwordHash) + return err +} + +func (s *AuthService) ChangePassword(ctx context.Context, userID, currentPassword, newPassword string) error { + if strings.TrimSpace(currentPassword) == "" { + return errors.New("当前密码不能为空") + } + if err := ValidatePassword(newPassword); err != nil { + return err + } + user, err := s.users.GetByID(ctx, userID) + if err != nil { + return err + } + if !VerifyPassword(currentPassword, user.PasswordHash) { + return errors.New("当前密码错误") + } + passwordHash, err := HashPassword(newPassword) + if err != nil { + return err + } + _, err = s.users.Update(ctx, userID, map[string]any{ + "password_hash": passwordHash, + }) + return err +} + +func (s *AuthService) Logout(ctx context.Context, token string) error { + return s.sessions.Destroy(ctx, token) +} + +func (s *AuthService) AuthConfig(ctx context.Context) (map[string]any, error) { + hasAdmin, err := s.users.HasAdmin(ctx) + if err != nil { + return nil, err + } + settings, err := s.loadAuthSettings(ctx) + if err != nil { + return nil, err + } + credits, err := s.loadCreditSettings(ctx) + if err != nil { + return nil, err + } + return map[string]any{ + "open": settings.Open, + "email_code": settings.EmailCode, + "allow_password_reset": settings.AllowPasswordReset, + "allowed_email_domains": settings.AllowedDomains, + "has_admin": hasAdmin, + "checkin_enabled": credits.CheckinEnabled, + "checkin_reward": credits.CheckinReward, + "invite_enabled": credits.InviteEnabled, + "invite_reward": credits.InviteReward, + "server_time": time.Now().Unix(), + }, nil +} + +func (s *AuthService) PublicUser(ctx context.Context, user *model.User) (map[string]any, error) { + if user == nil { + return nil, nil + } + credits, err := s.loadCreditSettings(ctx) + if err != nil { + return nil, err + } + stats, err := s.users.InviteStats(ctx, user.ID, credits.InviteReward) + if err != nil { + return nil, err + } + return map[string]any{ + "id": user.ID, + "email": user.Email, + "name": user.Name, + "role": user.Role, + "status": user.Status, + "credits": user.Credits, + "checkin_last": user.CheckinLast, + "checkin_streak": user.CheckinStreak, + "checkin_today": user.CheckinLast == time.Now().Format("2006-01-02"), + "invite_code": user.InviteCode, + "invite_count": stats.InviteCount, + "invite_earned": stats.InviteEarned, + }, nil +} + +func (s *AuthService) Checkin(ctx context.Context, userID string) (*repo.CheckinResult, error) { + credits, err := s.loadCreditSettings(ctx) + if err != nil { + return nil, err + } + if !credits.CheckinEnabled { + return nil, errors.New("签到功能未开启") + } + return s.users.DailyCheckin(ctx, userID, credits.CheckinReward) +} + +func (s *AuthService) InviteList(ctx context.Context, userID string) ([]repo.InviteRecord, error) { + credits, err := s.loadCreditSettings(ctx) + if err != nil { + return nil, err + } + return s.users.InviteList(ctx, userID, credits.InviteReward) +} + +func ParseBearer(header string) string { + if header == "" { + return "" + } + lower := strings.ToLower(header) + if !strings.HasPrefix(lower, "bearer ") { + return "" + } + return strings.TrimSpace(header[7:]) +} + +func HashAPIKey(plaintext string) string { + sum := sha256.Sum256([]byte(plaintext)) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +func (s *AuthService) loadAuthSettings(ctx context.Context) (*AuthSettings, error) { + openRaw, err := s.settings.GetValue(ctx, "auth.open") + if err != nil { + return nil, err + } + emailCodeRaw, err := s.settings.GetValue(ctx, "auth.email_code") + if err != nil { + return nil, err + } + resetRaw, err := s.settings.GetValue(ctx, "auth.allow_password_reset") + if err != nil { + return nil, err + } + domainsRaw, err := s.settings.GetValue(ctx, "auth.allowed_email_domains") + if err != nil { + return nil, err + } + return &AuthSettings{ + Open: parseBoolSetting(openRaw, true), + EmailCode: parseBoolSetting(emailCodeRaw, false), + AllowPasswordReset: parseBoolSetting(resetRaw, false), + AllowedDomains: parseCSVSetting(domainsRaw), + }, nil +} + +func (s *AuthService) loadSMTPSettings(ctx context.Context) SMTPConfig { + host, _ := s.settings.GetValue(ctx, "smtp.host") + portRaw, _ := s.settings.GetValue(ctx, "smtp.port") + username, _ := s.settings.GetValue(ctx, "smtp.username") + password, _ := s.settings.GetValue(ctx, "smtp.password") + fromAddr, _ := s.settings.GetValue(ctx, "smtp.from_addr") + useTLSRaw, _ := s.settings.GetValue(ctx, "smtp.use_tls") + + port, _ := strconv.Atoi(strings.TrimSpace(portRaw)) + if port <= 0 { + port = 587 + } + // Fall back to username when from_addr is unset (Python core/email_codes.py:92). + from := strings.TrimSpace(fromAddr) + if from == "" { + from = strings.TrimSpace(username) + } + return SMTPConfig{ + Host: strings.TrimSpace(host), + Port: port, + Username: strings.TrimSpace(username), + Password: password, + FromAddr: from, + // use_tls defaults to true to match Python (core/email_codes.py:93). + UseTLS: parseBoolSetting(useTLSRaw, true), + } +} + +func parseBoolSetting(v string, fallback bool) bool { + switch strings.ToLower(strings.TrimSpace(v)) { + case "1", "true", "yes", "on": + return true + case "0", "false", "no", "off": + return false + default: + return fallback + } +} + +func parseCSVSetting(v string) []string { + if strings.TrimSpace(v) == "" { + return []string{} + } + return ValidateAllowedEmailDomains(strings.Split(v, ",")) +} + +// InviteReward returns the admin-configured 积分 awarded per completed invite +// (falls back to 3). Exposed so the invite page shows the real number. +func (s *AuthService) InviteReward(ctx context.Context) int { + cs, err := s.loadCreditSettings(ctx) + if err != nil { + return 3 + } + return cs.InviteReward +} + +func (s *AuthService) loadCreditSettings(ctx context.Context) (*CreditSettings, error) { + checkinEnabledRaw, err := s.settings.GetValue(ctx, "credits.checkin_enabled") + if err != nil { + return nil, err + } + checkinRewardRaw, err := s.settings.GetValue(ctx, "credits.checkin_reward") + if err != nil { + return nil, err + } + inviteEnabledRaw, err := s.settings.GetValue(ctx, "credits.invite_enabled") + if err != nil { + return nil, err + } + inviteRewardRaw, err := s.settings.GetValue(ctx, "credits.invite_reward") + if err != nil { + return nil, err + } + return &CreditSettings{ + CheckinEnabled: parseBoolSetting(checkinEnabledRaw, true), + CheckinReward: parseIntSetting(checkinRewardRaw, 3), + InviteEnabled: parseBoolSetting(inviteEnabledRaw, true), + InviteReward: parseIntSetting(inviteRewardRaw, 3), + }, nil +} diff --git a/backend/internal/service/cdk.go b/backend/internal/service/cdk.go new file mode 100644 index 0000000..adb2531 --- /dev/null +++ b/backend/internal/service/cdk.go @@ -0,0 +1,167 @@ +package service + +import ( + "context" + "errors" + "strings" + "time" + + "backend/internal/model" + "backend/internal/repo" + "gorm.io/gorm" +) + +type CDKService struct { + cdks *repo.CDKRepository + users *repo.UserRepository +} + +func NewCDKService(cdks *repo.CDKRepository, users *repo.UserRepository) *CDKService { + return &CDKService{ + cdks: cdks, + users: users, + } +} + +func (s *CDKService) List(ctx context.Context) ([]model.CDKCode, map[string]any, map[string]string, error) { + items, err := s.cdks.List(ctx) + if err != nil { + return nil, nil, nil, err + } + stats, err := s.cdks.Stats(ctx) + if err != nil { + return nil, nil, nil, err + } + // Build an id -> display name map (name, else email, else id) so the handler + // can annotate redeemed codes with redeemed_by_name (mirrors admin.py). + users, err := s.users.List(ctx) + if err != nil { + return nil, nil, nil, err + } + nameByID := make(map[string]string, len(users)) + for _, u := range users { + name := strings.TrimSpace(u.Name) + if name == "" { + name = strings.TrimSpace(u.Email) + } + if name == "" { + name = u.ID + } + nameByID[u.ID] = name + } + return items, stats, nameByID, nil +} + +func normalizeCDKType(t string) string { + if strings.EqualFold(strings.TrimSpace(t), "marketing") { + return "marketing" + } + return "normal" +} + +func (s *CDKService) Generate(ctx context.Context, amount, count int, note, cdkType string) ([]model.CDKCode, error) { + if amount <= 0 { + return nil, errors.New("金额必须大于 0") + } + if count < 1 { + count = 1 + } + if count > 500 { + count = 500 + } + + cdkType = normalizeCDKType(cdkType) + // One batch id per generate call — marketing codes are "one per user per + // batch", so codes created together must share it. + batchID := randomUpper(20) + items := make([]model.CDKCode, 0, count) + for i := 0; i < count; i++ { + items = append(items, model.CDKCode{ + Code: randomCDK(), + Amount: amount, + Status: "active", + Type: cdkType, + BatchID: batchID, + Note: strings.TrimSpace(note), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }) + } + if err := s.cdks.CreateBatch(ctx, items); err != nil { + return nil, err + } + return items, nil +} + +func (s *CDKService) Delete(ctx context.Context, code string) error { + rows, err := s.cdks.Delete(ctx, strings.TrimSpace(strings.ToUpper(code))) + if err != nil { + return err + } + if rows == 0 { + return ErrNotFound + } + return nil +} + +// DeleteBulk removes many CDK codes in one call (multi-select). +func (s *CDKService) DeleteBulk(ctx context.Context, codes []string) (int, error) { + seen := make(map[string]struct{}, len(codes)) + clean := make([]string, 0, len(codes)) + for _, code := range codes { + code = strings.TrimSpace(strings.ToUpper(code)) + if code == "" { + continue + } + if _, ok := seen[code]; ok { + continue + } + seen[code] = struct{}{} + clean = append(clean, code) + } + if len(clean) == 0 { + return 0, nil + } + rows, err := s.cdks.DeleteByCodes(ctx, clean) + return int(rows), err +} + +func (s *CDKService) Redeem(ctx context.Context, userID, code string) (map[string]any, error) { + code = strings.TrimSpace(strings.ToUpper(code)) + if code == "" { + return nil, errors.New("请输入兑换码") + } + + item, err := s.cdks.Redeem(ctx, code, userID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New("兑换码无效") + } + if errors.Is(err, repo.ErrCDKBatchLimit) { + return nil, errors.New("该营销活动每人限兑一次,你已兑换过本批次的兑换码") + } + if err == gorm.ErrDuplicatedKey { + return nil, errors.New("兑换码已被使用") + } + return nil, err + } + + // Atomic, row-locked credit grant — never read-modify-write the balance, or a + // concurrent debit/redeem would clobber it (lost update). + updated, err := s.users.AdjustCredits(ctx, userID, float64(item.Amount)) + if err != nil { + return nil, err + } + + return map[string]any{ + "amount": item.Amount, + "credits": updated.Credits, + }, nil +} + +func randomCDK() string { + seg := func() string { + return randomUpper(4) + } + return seg() + "-" + seg() + "-" + seg() + "-" + seg() +} diff --git a/backend/internal/service/email_code.go b/backend/internal/service/email_code.go new file mode 100644 index 0000000..206d727 --- /dev/null +++ b/backend/internal/service/email_code.go @@ -0,0 +1,126 @@ +package service + +import ( + "context" + "crypto/rand" + "fmt" + "strings" + "time" + + "github.com/redis/go-redis/v9" +) + +// maxCodeAttempts caps wrong guesses per issued code before it's burned. With a +// single 6-digit code (1e6 space) and only this many tries per send — and sends +// throttled by the cooldown — brute force is infeasible. Mirrors the Python +// EmailCodeStore.MAX_ATTEMPTS. +const maxCodeAttempts = 5 + +type EmailCodeService struct { + redis *redis.Client + codeTTL time.Duration + resendCooldown time.Duration +} + +func NewEmailCodeService(redis *redis.Client) *EmailCodeService { + return &EmailCodeService{ + redis: redis, + codeTTL: 6 * time.Minute, // CODE_TTL_SECONDS=360 + resendCooldown: 120 * time.Second, // CODE_COOLDOWN_SECONDS=120 + } +} + +// Redis exposes the underlying client so collaborators (e.g. LoginGuard) can be +// built without threading the client through every constructor. +func (s *EmailCodeService) Redis() *redis.Client { + return s.redis +} + +func (s *EmailCodeService) Issue(ctx context.Context, email, purpose string) (string, error) { + email = strings.ToLower(strings.TrimSpace(email)) + purpose = strings.ToLower(strings.TrimSpace(purpose)) + + ok, err := s.redis.SetNX(ctx, s.cooldownKey(email, purpose), "1", s.resendCooldown).Result() + if err != nil { + return "", err + } + if !ok { + return "", fmt.Errorf("请稍后再试") + } + + code, err := randomDigits(6) + if err != nil { + return "", err + } + if err := s.redis.Set(ctx, s.codeKey(email, purpose), code, s.codeTTL).Err(); err != nil { + return "", err + } + // Reset the wrong-guess counter for this fresh code (same TTL as the code). + if err := s.redis.Set(ctx, s.attemptsKey(email, purpose), "0", s.codeTTL).Err(); err != nil { + return "", err + } + return code, nil +} + +func (s *EmailCodeService) Verify(ctx context.Context, email, purpose, code string) (bool, error) { + normalizedCode, err := ValidateEmailCode(code) + if err != nil { + return false, err + } + email = strings.ToLower(strings.TrimSpace(email)) + purpose = strings.ToLower(strings.TrimSpace(purpose)) + + stored, err := s.redis.Get(ctx, s.codeKey(email, purpose)).Result() + if err != nil { + if err == redis.Nil { + return false, nil + } + return false, err + } + + // Count the attempt first; burn the code (and its counter) once the cap is + // hit so the attacker must request a new one and wait out the send cooldown. + attempts, err := s.redis.Incr(ctx, s.attemptsKey(email, purpose)).Result() + if err != nil { + return false, err + } + if attempts > maxCodeAttempts { + if err := s.redis.Del(ctx, s.codeKey(email, purpose), s.attemptsKey(email, purpose)).Err(); err != nil { + return false, err + } + return false, nil + } + + if stored != normalizedCode { + return false, nil + } + // Correct code: one-time use, clear both the code and its attempt counter. + if err := s.redis.Del(ctx, s.codeKey(email, purpose), s.attemptsKey(email, purpose)).Err(); err != nil { + return false, err + } + return true, nil +} + +func (s *EmailCodeService) codeKey(email, purpose string) string { + return "email_code:" + purpose + ":" + email +} + +func (s *EmailCodeService) attemptsKey(email, purpose string) string { + return "email_code_attempts:" + purpose + ":" + email +} + +func (s *EmailCodeService) cooldownKey(email, purpose string) string { + return "email_code_cooldown:" + purpose + ":" + email +} + +func randomDigits(n int) (string, error) { + buf := make([]byte, n) + src := make([]byte, n) + if _, err := rand.Read(src); err != nil { + return "", err + } + for i := range src { + buf[i] = byte('0' + (src[i] % 10)) + } + return string(buf), nil +} diff --git a/backend/internal/service/image_access.go b/backend/internal/service/image_access.go new file mode 100644 index 0000000..6b9976b --- /dev/null +++ b/backend/internal/service/image_access.go @@ -0,0 +1,48 @@ +package service + +import ( + "context" + "errors" + "strings" + + "backend/internal/repo" +) + +type ImageAccessService struct { + generatedRoot string + showcase *repo.ShowcaseRepository + auth *AuthService +} + +func NewImageAccessService(generatedRoot string, showcase *repo.ShowcaseRepository, auth *AuthService) *ImageAccessService { + return &ImageAccessService{ + generatedRoot: generatedRoot, + showcase: showcase, + auth: auth, + } +} + +// Resolve validates the path params and returns the object key (user/name). +// Existence isn't checked here — that's the storage GET's job (404 if missing). +func (s *ImageAccessService) Resolve(user, name string) (string, error) { + user = strings.TrimSpace(user) + name = strings.TrimSpace(name) + if user == "" || name == "" { + return "", errors.New("missing path params") + } + // :user and :name are single path segments (gin won't match "/"); guard + // against traversal tokens anyway. + if strings.Contains(user, "..") || strings.Contains(name, "..") || + strings.ContainsAny(user, `/\`) || strings.ContainsAny(name, `/\`) { + return "", errors.New("invalid image path") + } + return user + "/" + name, nil +} + +func (s *ImageAccessService) IsPublic(ctx context.Context, rel string) (bool, error) { + return s.showcase.IsPublicFile(ctx, rel) +} + +func (s *ImageAccessService) IsAuthorized(ctx context.Context, sessionCookie, owner string) (bool, error) { + return s.auth.IsAuthorizedForPrivateImage(ctx, sessionCookie, owner) +} diff --git a/backend/internal/service/login_guard.go b/backend/internal/service/login_guard.go new file mode 100644 index 0000000..fcfbec3 --- /dev/null +++ b/backend/internal/service/login_guard.go @@ -0,0 +1,169 @@ +package service + +import ( + "context" + "errors" + "strconv" + "strings" + "time" + + "github.com/redis/go-redis/v9" +) + +// ErrLoginLocked is returned when a login/reset attempt is currently locked out +// by the LoginGuard. The wait window (in seconds) is carried by LoginLockedError. +var ErrLoginLocked = errors.New("login locked") + +// LoginLockedError signals that the caller must wait RetryAfter seconds before +// retrying. Handlers map this to HTTP 429 with a Retry-After header. +type LoginLockedError struct { + RetryAfter int +} + +func (e *LoginLockedError) Error() string { + return "尝试过于频繁,请 " + strconv.Itoa(e.RetryAfter) + " 秒后再试" +} + +func (e *LoginLockedError) Is(target error) bool { + return target == ErrLoginLocked +} + +// LoginGuard implements a Redis-backed login throttle mirroring the Python +// core.login_guard: two independent counters per attempt, exponential backoff +// lockout after a small number of free failures, and decay after a quiet period. +// +// id:| — targeted guessing of one account from one IP (5 free). +// ip: — spraying many accounts from one IP (20 free). +// +// Either counter being locked rejects the attempt. +type LoginGuard struct { + redis *redis.Client + + freeAttempts int // per (ip, account) before lockout kicks in + ipFreeAttempts int // coarser per-ip spray threshold + baseLock time.Duration // first lock duration + maxLock time.Duration // lock cap + decay time.Duration // forget a counter after this quiet period +} + +func NewLoginGuard(rdb *redis.Client) *LoginGuard { + return &LoginGuard{ + redis: rdb, + freeAttempts: 5, + ipFreeAttempts: 20, + baseLock: 15 * time.Second, + maxLock: 900 * time.Second, + decay: 1800 * time.Second, + } +} + +func (g *LoginGuard) keys(ip, identifier string) (ipKey, idKey string) { + ident := strings.ToLower(strings.TrimSpace(identifier)) + return "login_guard:ip:" + ip, "login_guard:id:" + ip + "|" + ident +} + +// remaining returns the seconds the given counter is still locked for (0 = free). +// Counters are stored with TTL = decay so quiet entries expire on their own, +// matching the Python decay semantics. +func (g *LoginGuard) remaining(ctx context.Context, key string, now int64) (int, error) { + lockedRaw, err := g.redis.HGet(ctx, key, "locked_until").Result() + if err != nil { + if err == redis.Nil { + return 0, nil + } + return 0, err + } + lockedUntil, _ := strconv.ParseInt(strings.TrimSpace(lockedRaw), 10, 64) + if lockedUntil <= now { + return 0, nil + } + return int(lockedUntil - now), nil +} + +// RetryAfter reports how many seconds the caller must wait (0 = allowed). +func (g *LoginGuard) RetryAfter(ctx context.Context, ip, identifier string) (int, error) { + if g == nil || g.redis == nil { + return 0, nil + } + now := time.Now().Unix() + ipKey, idKey := g.keys(ip, identifier) + ipWait, err := g.remaining(ctx, ipKey, now) + if err != nil { + return 0, err + } + idWait, err := g.remaining(ctx, idKey, now) + if err != nil { + return 0, err + } + if ipWait > idWait { + return ipWait, nil + } + return idWait, nil +} + +// Check returns a *LoginLockedError when the attempt is currently locked out. +func (g *LoginGuard) Check(ctx context.Context, ip, identifier string) error { + wait, err := g.RetryAfter(ctx, ip, identifier) + if err != nil { + return err + } + if wait > 0 { + return &LoginLockedError{RetryAfter: wait} + } + return nil +} + +// RecordFailure increments both counters and, once a counter passes its free +// allowance, arms an exponentially growing lockout window (capped at maxLock). +func (g *LoginGuard) RecordFailure(ctx context.Context, ip, identifier string) error { + if g == nil || g.redis == nil { + return nil + } + now := time.Now().Unix() + ipKey, idKey := g.keys(ip, identifier) + for _, kf := range []struct { + key string + free int + }{ + {ipKey, g.ipFreeAttempts}, + {idKey, g.freeAttempts}, + } { + count, err := g.redis.HIncrBy(ctx, kf.key, "count", 1).Result() + if err != nil { + return err + } + if count >= int64(kf.free) { + over := count - int64(kf.free) + lock := g.baseLock + for i := int64(0); i < over; i++ { + lock *= 2 + if lock >= g.maxLock { + lock = g.maxLock + break + } + } + if lock > g.maxLock { + lock = g.maxLock + } + lockedUntil := now + int64(lock.Seconds()) + if err := g.redis.HSet(ctx, kf.key, "locked_until", lockedUntil).Err(); err != nil { + return err + } + } + // Refresh decay TTL on every failure (quiet counters expire on their own). + if err := g.redis.Expire(ctx, kf.key, g.decay).Err(); err != nil { + return err + } + } + return nil +} + +// RecordSuccess clears the targeted (id) counter on a genuine login; the coarse +// per-ip counter is left to decay so one valid account can't reset spray tracking. +func (g *LoginGuard) RecordSuccess(ctx context.Context, ip, identifier string) error { + if g == nil || g.redis == nil { + return nil + } + _, idKey := g.keys(ip, identifier) + return g.redis.Del(ctx, idKey).Err() +} diff --git a/backend/internal/service/maintenance.go b/backend/internal/service/maintenance.go new file mode 100644 index 0000000..0a4d736 --- /dev/null +++ b/backend/internal/service/maintenance.go @@ -0,0 +1,282 @@ +package service + +import ( + "context" + "log" + "strconv" + "strings" + "sync" + "time" + + "backend/internal/model" + "backend/internal/repo" + "backend/internal/storage" +) + +// MaintenanceService runs the periodic self-healing sweep that the Python +// original did via a 60s daemon thread plus read-time lazy cleanup. Without it +// the Go token pool only ever loses capacity: tokens never re-activate after a +// quota reset, cookies never auto-renew, stale pending events permanently block +// a user's generation gate, and old media/logs accumulate unbounded. +type MaintenanceService struct { + tokens *repo.TokenRepository + tokenSvc *TokenService + events *repo.EventRepository + users *repo.UserRepository + refresh *RefreshProfileService + settings *repo.SiteSettingRepository + store *storage.Client + inflight *InflightRegistry + showcase *repo.ShowcaseRepository + interval time.Duration + stalePending time.Duration + mediaPruneEvery time.Duration + lastMediaPrune time.Time +} + +func NewMaintenanceService(tokens *repo.TokenRepository, tokenSvc *TokenService, events *repo.EventRepository, users *repo.UserRepository, refresh *RefreshProfileService, settings *repo.SiteSettingRepository, store *storage.Client, inflight *InflightRegistry, showcase *repo.ShowcaseRepository) *MaintenanceService { + return &MaintenanceService{ + tokens: tokens, + tokenSvc: tokenSvc, + events: events, + users: users, + refresh: refresh, + settings: settings, + store: store, + inflight: inflight, + showcase: showcase, + interval: 60 * time.Second, + stalePending: 600 * time.Second, + mediaPruneEvery: 60 * time.Second, + } +} + +// Run drives the sweep every interval until ctx is cancelled. It runs one sweep +// immediately on startup so a freshly restarted process heals stuck state right +// away rather than after the first tick. +func (m *MaintenanceService) Run(ctx context.Context) { + ticker := time.NewTicker(m.interval) + defer ticker.Stop() + m.tick(ctx) + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + m.tick(ctx) + } + } +} + +// syncRecoveredQuota re-probes each just-recovered account so its displayed +// balance reflects the post-reset value (these providers only sync quota when +// accessed). krea additionally needs /app (Activate) to actually grant the daily +// free balance before billing-data reports it. Bounded concurrency avoids a +// thundering herd at the daily reset. +func (m *MaintenanceService) syncRecoveredQuota(accs []model.TokenAccount) { + sem := make(chan struct{}, 4) + var wg sync.WaitGroup + for _, acc := range accs { + switch acc.Pool { + case "chatgpt", "leonardo", "krea", "imagine": + default: + continue + } + wg.Add(1) + sem <- struct{}{} + go func(a model.TokenAccount) { + defer wg.Done() + defer func() { <-sem }() + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + if a.Pool == "krea" && m.tokenSvc.krea != nil { + m.tokenSvc.krea.Activate(ctx, a.Value) + } + _, _ = m.tokenSvc.Quota(ctx, a.Pool, a.ID) + }(acc) + } + wg.Wait() +} + +func (m *MaintenanceService) tick(ctx context.Context) { + // 1. Re-activate quota-exhausted tokens whose reset time has passed, then + // auto-sync their real balance — these providers only refresh quota when + // accessed, so recovery alone would leave a stale 0/—. For krea the sync + // must first load /app (Activate) to grant the daily free balance. + if recovered, err := m.tokens.RecoverQuota(ctx); err != nil { + log.Printf("maintenance: recover_quota: %v", err) + } else if len(recovered) > 0 { + log.Printf("maintenance: recovered %d quota token(s)", len(recovered)) + if m.tokenSvc != nil { + go m.syncRecoveredQuota(recovered) + } + } + + // 1a. Roll the 恢复时间 marker of ACTIVE daily-reset accounts forward to the next + // future reset (same time-of-day, +1 day) so the column never shows a stale + // past time. Limited accounts are intentionally skipped (RecoverQuota owns + // their marker). adobe/leonardo/krea/imagine all renew daily. + if _, err := m.tokens.RollResetMarkers(ctx, []string{"adobe", "leonardo", "krea", "imagine"}); err != nil { + log.Printf("maintenance: roll_reset: %v", err) + } + + // 1b. Runway tokens have no refresh — once the JWT expiry (its reset marker) + // passes, mark them dead directly instead of letting them 401 on next use. + if n, err := m.tokens.ExpireByReset(ctx, "runway"); err != nil { + log.Printf("maintenance: expire_runway: %v", err) + } else if n > 0 { + log.Printf("maintenance: expired %d runway token(s)", n) + } + + // 1c. Proactively renew krea/imagine sessions ~10min before expiry so a + // dormant account's rotating refresh_token never lapses (a dead token + // can't be recovered and, for krea, blocks the daily free-credit meter + // from being re-created). Only near-expiry accounts hit the network. + if m.tokenSvc != nil { + m.tokenSvc.RefreshExpiringTokens(ctx) + // 1d. Once-per-day krea /app activation for accounts not yet synced since the + // daily reset — krea only grants the free balance after /app loads, so an + // always-active account (never went 限额) would otherwise read 0 / 402 + // after each reset. Self-guarded + background; no-op once all are done. + m.tokenSvc.ActivateKreaDue(ctx) + } + + // 2. Auto-renew Adobe cookies whose refresh interval has elapsed. + if m.refresh != nil { + if n, err := m.refresh.RefreshDue(ctx); err != nil { + log.Printf("maintenance: refresh_due: %v", err) + } else if n > 0 { + log.Printf("maintenance: refreshed %d cookie profile(s)", n) + } + } + + // 3. Fail long-pending events so they stop blocking the per-user gate, and + // refund the credits debited up-front for each abandoned generation (the + // normal failure-refund path never ran for a process-restart orphan). + if purged, err := m.events.PurgeStale(ctx, m.stalePending); err != nil { + log.Printf("maintenance: purge_stale: %v", err) + } else if len(purged) > 0 { + refunded := 0 + cancelled := 0 + for _, e := range purged { + // Stop the generation goroutine if it's still running, so it doesn't + // keep grinding for minutes and surface a late "success" on this + // just-abandoned event. + if m.inflight != nil && m.inflight.Cancel(e.ID) { + cancelled++ + } + // Attribute the abandoned failure back to the account it was using + // (the normal markTokenFailure path never ran for an orphaned job). + if e.AccountID != "" { + if err := m.tokens.IncrementFail(ctx, e.AccountID); err != nil { + log.Printf("maintenance: fail-count abandoned event %s (account %s): %v", e.ID, e.AccountID, err) + } + } + if e.UserID == "" || e.Cost <= 0 { + continue + } + // Exactly-once: only refund if we win the claim (the in-flight request + // may have already refunded itself on its own failure path). + claimed, err := m.events.MarkRefunded(ctx, e.ID) + if err != nil { + log.Printf("maintenance: claim refund %s: %v", e.ID, err) + continue + } + if !claimed { + continue + } + if _, err := m.users.AdjustCredits(ctx, e.UserID, e.Cost); err != nil { + log.Printf("maintenance: refund abandoned event %s (user %s, %.0f): %v", e.ID, e.UserID, e.Cost, err) + } else { + refunded++ + } + } + log.Printf("maintenance: marked %d stale pending event(s) failed, refunded %d, cancelled %d in-flight", len(purged), refunded, cancelled) + } + + // 4. Enforce the admin-configured log retention window. + m.pruneLogs(ctx) + + // 5. Enforce the media retention window. Runs every 60s like the log prune; + // mediaPruneEvery still gates it in case the interval is ever shortened. + if time.Since(m.lastMediaPrune) >= m.mediaPruneEvery { + m.pruneMedia(ctx) + m.lastMediaPrune = time.Now() + } +} + +func (m *MaintenanceService) pruneLogs(ctx context.Context) { + days := m.retentionDays(ctx, "logs.retention_days") + if days <= 0 { + return + } + if _, err := m.events.PurgeOlderThan(ctx, time.Duration(days)*24*time.Hour); err != nil { + log.Printf("maintenance: purge_older_than: %v", err) + } +} + +func (m *MaintenanceService) pruneMedia(ctx context.Context) { + if m.store == nil || !m.store.Configured() { + return + } + days := m.retentionDays(ctx, "media.retention_days") + if days <= 0 { + return + } + cutoff := time.Now().Add(-time.Duration(days) * 24 * time.Hour) + objs, err := m.store.List(ctx, "") + if err != nil { + log.Printf("maintenance: list media: %v", err) + return + } + // Files referenced by the homepage showcase are kept forever, no matter how + // old — deleting them would break the public landing page. + var pinned map[string]struct{} + if m.showcase != nil { + if pinned, err = m.showcase.PublicFileSet(ctx); err != nil { + log.Printf("maintenance: showcase file set: %v", err) + pinned = nil + } + } + removed, skipped := 0, 0 + var clearedKeys []string + for _, o := range objs { + if !o.LastModified.Before(cutoff) { + continue + } + if _, ok := pinned[strings.TrimLeft(o.Key, "/")]; ok { + skipped++ + continue + } + if err := m.store.Delete(ctx, o.Key); err != nil { + log.Printf("maintenance: delete %s: %v", o.Key, err) + continue + } + removed++ + // event_log.file stores the same key — blank those rows so the log views + // don't dangle a 404 preview. + clearedKeys = append(clearedKeys, o.Key) + } + if removed > 0 || skipped > 0 { + log.Printf("maintenance: pruned %d expired media object(s), kept %d showcase-pinned", removed, skipped) + } + if len(clearedKeys) > 0 { + if n, err := m.events.ClearFiles(ctx, clearedKeys); err != nil { + log.Printf("maintenance: clear_files: %v", err) + } else if n > 0 { + log.Printf("maintenance: cleared file ref on %d log row(s)", n) + } + } +} + +func (m *MaintenanceService) retentionDays(ctx context.Context, key string) int { + raw, err := m.settings.GetValue(ctx, key) + if err != nil { + return 0 + } + days, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || days <= 0 { + return 0 + } + return days +} diff --git a/backend/internal/service/password.go b/backend/internal/service/password.go new file mode 100644 index 0000000..b2a6f77 --- /dev/null +++ b/backend/internal/service/password.go @@ -0,0 +1,46 @@ +package service + +import ( + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "strings" + + "golang.org/x/crypto/bcrypt" +) + +func GeneratePasswordHash(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword(bcryptPrehash(password), 12) + if err != nil { + return "", err + } + return string(hash), nil +} + +func VerifyPassword(password, stored string) bool { + stored = strings.TrimSpace(stored) + if stored == "" { + return false + } + + if strings.HasPrefix(stored, "bcrypt$") { + hash := stored[len("bcrypt$"):] + return bcrypt.CompareHashAndPassword([]byte(hash), bcryptPrehash(password)) == nil + } + + parts := strings.SplitN(stored, "$", 3) + if len(parts) != 3 || parts[0] != "sha256" { + return false + } + expected := sha256.Sum256([]byte(parts[1] + password)) + expectedHex := hex.EncodeToString(expected[:]) + return subtle.ConstantTimeCompare([]byte(expectedHex), []byte(parts[2])) == 1 +} + +func bcryptPrehash(password string) []byte { + sum := sha256.Sum256([]byte(password)) + dst := make([]byte, base64.StdEncoding.EncodedLen(len(sum))) + base64.StdEncoding.Encode(dst, sum[:]) + return dst +} diff --git a/backend/internal/service/rate_limit.go b/backend/internal/service/rate_limit.go new file mode 100644 index 0000000..9384c55 --- /dev/null +++ b/backend/internal/service/rate_limit.go @@ -0,0 +1,79 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/redis/go-redis/v9" +) + +var ErrRateLimited = errors.New("rate limited") + +type RateLimitService struct { + redis *redis.Client + prefix string +} + +type RateLimitResult struct { + Allowed bool + Count int64 + Limit int64 + RetryAfter time.Duration +} + +func NewRateLimitService(redis *redis.Client) *RateLimitService { + return &RateLimitService{ + redis: redis, + prefix: "rl:", + } +} + +func (s *RateLimitService) Allow(ctx context.Context, bucket string, limit int64, window time.Duration) (*RateLimitResult, error) { + if limit <= 0 || window <= 0 { + return &RateLimitResult{Allowed: true, Limit: limit}, nil + } + + key := s.prefix + strings.TrimSpace(bucket) + count, err := s.redis.Incr(ctx, key).Result() + if err != nil { + return nil, err + } + if count == 1 { + if err := s.redis.Expire(ctx, key, window).Err(); err != nil { + return nil, err + } + } + + ttl, err := s.redis.TTL(ctx, key).Result() + if err != nil { + return nil, err + } + if ttl < 0 { + ttl = window + } + + return &RateLimitResult{ + Allowed: count <= limit, + Count: count, + Limit: limit, + RetryAfter: ttl, + }, nil +} + +func (s *RateLimitService) Enforce(ctx context.Context, bucket string, limit int64, window time.Duration) error { + result, err := s.Allow(ctx, bucket, limit, window) + if err != nil { + return err + } + if result.Allowed { + return nil + } + retry := int(result.RetryAfter.Seconds()) + if retry < 1 { + retry = 1 + } + return fmt.Errorf("%w: 请稍后再试(%d 秒后)", ErrRateLimited, retry) +} diff --git a/backend/internal/service/refresh_profiles.go b/backend/internal/service/refresh_profiles.go new file mode 100644 index 0000000..f167c84 --- /dev/null +++ b/backend/internal/service/refresh_profiles.go @@ -0,0 +1,185 @@ +package service + +import ( + "context" + "errors" + "strings" + "time" + + "backend/internal/model" + "backend/internal/provider/adobe" + "backend/internal/repo" + "gorm.io/datatypes" +) + +type RefreshProfileService struct { + profiles *repo.RefreshProfileRepository + tokens *repo.TokenRepository + adobe *adobe.Client +} + +func NewRefreshProfileService(profiles *repo.RefreshProfileRepository, tokens *repo.TokenRepository, adobeClient *adobe.Client) *RefreshProfileService { + return &RefreshProfileService{ + profiles: profiles, + tokens: tokens, + adobe: adobeClient, + } +} + +func (s *RefreshProfileService) List(ctx context.Context) ([]model.RefreshProfile, error) { + return s.profiles.List(ctx) +} + +func (s *RefreshProfileService) RefreshNow(ctx context.Context, id string) error { + if s.adobe == nil || s.tokens == nil { + return errors.New("refresh client not configured") + } + profile, err := s.profiles.Get(ctx, id) + if err != nil { + return err + } + if profile.Pool != "adobe" || profile.Kind != "adobe_cookie" { + return errors.New("unsupported refresh profile") + } + + now := time.Now() + _, _ = s.profiles.Update(ctx, id, map[string]any{ + "last_attempt_at": now, + }) + + result, err := s.adobe.ExchangeCookie(ctx, profile.Cookie) + if err != nil { + failures := profile.ConsecutiveFailures + 1 + // Exponential backoff: 60s per consecutive failure, capped at 1h. + secs := 60 * failures + if secs > 3600 { + secs = 3600 + } + msg := err.Error() + if len(msg) > 300 { + msg = msg[:300] + } + _, _ = s.profiles.Update(ctx, id, map[string]any{ + "last_error": msg, + "consecutive_failures": failures, + "next_retry_at": now.Add(time.Duration(secs) * time.Second), + }) + // After repeated failures the cookie can no longer mint a token — it's + // genuinely dead (expired/revoked). Lock the pool token (disabled+dead) + // so the UI flags it red. A single failure may be a transient blip, so + // only escalate after a few in a row (mirrors Python RefreshManager). + if failures >= 3 { + _, _ = s.tokens.Update(ctx, profile.Pool, id, map[string]any{ + "status": "disabled", + "dead": true, + }) + } + return err + } + + tokenPatch := map[string]any{ + "value": result.AccessToken, + "status": "active", + "dead": false, + "fails": 0, + "updated_at": now, + } + email, exp := parseJWTEmailExpiry(result.AccessToken) + if email != "" { + tokenPatch["account_email"] = email + } + if exp != nil { + tokenPatch["cached_quota_reset_after"] = exp.Format(time.RFC3339) + } + if profileData, profileErr := s.adobe.FetchAccountProfile(ctx, result.AccessToken); profileErr == nil { + if email := strings.TrimSpace(stringValue(profileData["email"])); email != "" { + tokenPatch["account_email"] = email + } + if displayName := strings.TrimSpace(stringValue(profileData["display_name"])); displayName != "" { + tokenPatch["account_display_name"] = displayName + } + } + if quotaData, quotaErr := s.adobe.FetchCreditsBalance(ctx, result.AccessToken); quotaErr == nil { + meta := datatypes.JSONMap{ + "cached_quota_at": int(time.Now().Unix()), + } + if remaining, ok := quotaData["remaining"].(int); ok { + meta["cached_quota_remaining"] = remaining + } + if used, ok := quotaData["used"].(int); ok { + meta["cached_quota_used"] = used + } + if total, ok := quotaData["total"].(int); ok { + meta["cached_quota_total"] = total + } + tokenPatch["meta"] = meta + if resetAfter := strings.TrimSpace(stringValue(quotaData["available_until"])); resetAfter != "" { + tokenPatch["cached_quota_reset_after"] = resetAfter + } + } + if _, err := s.tokens.Update(ctx, "adobe", id, tokenPatch); err != nil { + return err + } + + interval := profile.IntervalSeconds + if interval <= 0 { + interval = 54000 + } + _, err = s.profiles.Update(ctx, id, map[string]any{ + "last_success_at": now, + "next_retry_at": now.Add(time.Duration(interval) * time.Second), + "last_error": "", + "consecutive_failures": 0, + }) + return err +} + +// RefreshDue refreshes every enabled profile whose next_retry_at has passed. +// Driven by the background maintenance loop so Adobe cookies auto-renew without +// an admin clicking "refresh". Individual failures are recorded on the profile +// (backoff + dead escalation) and don't abort the sweep. +func (s *RefreshProfileService) RefreshDue(ctx context.Context) (int, error) { + if s.adobe == nil || s.tokens == nil { + return 0, nil + } + due, err := s.profiles.ListDue(ctx, time.Now()) + if err != nil { + return 0, err + } + refreshed := 0 + for _, p := range due { + if p.Pool != "adobe" || p.Kind != "adobe_cookie" { + continue + } + if err := s.RefreshNow(ctx, p.ID); err != nil { + continue + } + refreshed++ + } + return refreshed, nil +} + +func (s *RefreshProfileService) Update(ctx context.Context, id string, body map[string]any) (*model.RefreshProfile, error) { + patch := map[string]any{} + if raw, ok := body["enabled"]; ok { + patch["enabled"] = boolValueWithDefault(raw, false) + } + if raw, ok := body["name"]; ok { + patch["name"] = stringValue(raw) + } + if raw, ok := body["interval_seconds"]; ok { + n := intValue(raw) + if n <= 0 { + return nil, errors.New("interval_seconds must be positive") + } + patch["interval_seconds"] = n + } + if len(patch) == 0 { + return s.profiles.Get(ctx, id) + } + return s.profiles.Update(ctx, id, patch) +} + +func (s *RefreshProfileService) Delete(ctx context.Context, id string) error { + return s.profiles.Delete(ctx, id) +} diff --git a/backend/internal/service/session.go b/backend/internal/service/session.go new file mode 100644 index 0000000..6e34ea4 --- /dev/null +++ b/backend/internal/service/session.go @@ -0,0 +1,103 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/redis/go-redis/v9" +) + +type SessionPayload struct { + UserID string `json:"user_id"` + ExpiresAt int64 `json:"expires_at"` +} + +type SessionService struct { + client *redis.Client + prefix string + ttl time.Duration + slideAfter time.Duration + slideTo time.Duration +} + +func NewSessionService(client *redis.Client, ttl, slideAfter time.Duration) *SessionService { + return &SessionService{ + client: client, + prefix: "session:", + ttl: ttl, + slideAfter: slideAfter, + slideTo: ttl, + } +} + +func (s *SessionService) Create(ctx context.Context, userID string) (string, *SessionPayload, error) { + token := randomUpper(48) + + payload := &SessionPayload{ + UserID: userID, + ExpiresAt: time.Now().Add(s.ttl).Unix(), + } + + raw, err := json.Marshal(payload) + if err != nil { + return "", nil, err + } + + if err := s.client.Set(ctx, s.key(token), raw, s.ttl).Err(); err != nil { + return "", nil, err + } + return token, payload, nil +} + +func (s *SessionService) Validate(ctx context.Context, token string) (*SessionPayload, error) { + if token == "" { + return nil, nil + } + + raw, err := s.client.Get(ctx, s.key(token)).Bytes() + if err != nil { + if errors.Is(err, redis.Nil) { + return nil, nil + } + return nil, err + } + + var payload SessionPayload + if err := json.Unmarshal(raw, &payload); err != nil { + return nil, err + } + + ttl, err := s.client.TTL(ctx, s.key(token)).Result() + if err == nil && ttl > 0 && ttl < s.slideAfter { + // Slide the expiry, but only update the in-memory payload after Redis + // has actually persisted it — otherwise a failed Set would leave the + // returned ExpiresAt out of sync with what's stored. + renewed := payload + renewed.ExpiresAt = time.Now().Add(s.slideTo).Unix() + if updated, marshalErr := json.Marshal(&renewed); marshalErr == nil { + if setErr := s.client.Set(ctx, s.key(token), updated, s.slideTo).Err(); setErr == nil { + payload.ExpiresAt = renewed.ExpiresAt + } + } + } + + if payload.ExpiresAt <= time.Now().Unix() { + _ = s.Destroy(ctx, token) + return nil, nil + } + + return &payload, nil +} + +func (s *SessionService) Destroy(ctx context.Context, token string) error { + if token == "" { + return nil + } + return s.client.Del(ctx, s.key(token)).Err() +} + +func (s *SessionService) key(token string) string { + return s.prefix + token +} diff --git a/backend/internal/service/showcase.go b/backend/internal/service/showcase.go new file mode 100644 index 0000000..bf45a74 --- /dev/null +++ b/backend/internal/service/showcase.go @@ -0,0 +1,20 @@ +package service + +import ( + "context" + + "backend/internal/model" + "backend/internal/repo" +) + +type ShowcaseService struct { + repo *repo.ShowcaseRepository +} + +func NewShowcaseService(repo *repo.ShowcaseRepository) *ShowcaseService { + return &ShowcaseService{repo: repo} +} + +func (s *ShowcaseService) Grouped(ctx context.Context) (map[string][]model.ShowcaseItem, error) { + return s.repo.Grouped(ctx) +} diff --git a/backend/internal/service/site.go b/backend/internal/service/site.go new file mode 100644 index 0000000..3e41fee --- /dev/null +++ b/backend/internal/service/site.go @@ -0,0 +1,81 @@ +package service + +import ( + "context" + "strings" + + "backend/internal/repo" +) + +type SiteService struct { + settings *repo.SiteSettingRepository + fallback string +} + +func NewSiteService(settings *repo.SiteSettingRepository, fallback string) *SiteService { + return &SiteService{ + settings: settings, + fallback: fallback, + } +} + +func (s *SiteService) Title(ctx context.Context) (string, error) { + v, err := s.settings.GetValue(ctx, "site.title") + if err != nil { + return "", err + } + v = strings.TrimSpace(v) + if v == "" { + return s.fallback, nil + } + return v, nil +} + +func (s *SiteService) SetTitle(ctx context.Context, title string) (string, error) { + title = strings.TrimSpace(title) + if title == "" { + return "", nil + } + if err := s.settings.UpsertValue(ctx, "site.title", title); err != nil { + return "", err + } + return title, nil +} + +// Contact is the admin-editable "联系我们" info shown in the public 关于 section. +type Contact struct { + QQ string `json:"qq"` + QQLink string `json:"qq_link"` + QQGroup string `json:"qq_group"` + QQGroupLink string `json:"qq_group_link"` + Email string `json:"email"` + Shop string `json:"shop"` +} + +func (s *SiteService) Contact(ctx context.Context) Contact { + get := func(k string) string { v, _ := s.settings.GetValue(ctx, k); return strings.TrimSpace(v) } + return Contact{ + QQ: get("contact.qq"), + QQLink: get("contact.qq_link"), + QQGroup: get("contact.qq_group"), + QQGroupLink: get("contact.qq_group_link"), + Email: get("contact.email"), + Shop: get("contact.shop"), + } +} + +func (s *SiteService) SetContact(ctx context.Context, c Contact) error { + for k, v := range map[string]string{ + "contact.qq": strings.TrimSpace(c.QQ), + "contact.qq_link": strings.TrimSpace(c.QQLink), + "contact.qq_group": strings.TrimSpace(c.QQGroup), + "contact.qq_group_link": strings.TrimSpace(c.QQGroupLink), + "contact.email": strings.TrimSpace(c.Email), + "contact.shop": strings.TrimSpace(c.Shop), + } { + if err := s.settings.UpsertValue(ctx, k, v); err != nil { + return err + } + } + return nil +} diff --git a/backend/internal/service/smtp.go b/backend/internal/service/smtp.go new file mode 100644 index 0000000..cacad0a --- /dev/null +++ b/backend/internal/service/smtp.go @@ -0,0 +1,117 @@ +package service + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net" + "net/smtp" + "strconv" + "strings" +) + +type SMTPConfig struct { + Host string + Port int + Username string + Password string + FromAddr string + UseTLS bool +} + +type SMTPService struct{} + +func NewSMTPService() *SMTPService { + return &SMTPService{} +} + +func (s *SMTPService) SendCode(ctx context.Context, cfg SMTPConfig, to, code, purpose string) error { + _ = ctx + if strings.TrimSpace(cfg.Host) == "" || cfg.Port <= 0 || strings.TrimSpace(cfg.FromAddr) == "" { + return errors.New("SMTP 未配置") + } + action := "注册" + if purpose == "reset" { + action = "找回密码" + } + subject := "Vivid AI 邮箱验证码" + body := fmt.Sprintf("你正在进行%s,验证码为:%s\n\n验证码 6 分钟内有效。", action, code) + msg := buildSMTPMessage(cfg.FromAddr, to, subject, body) + addr := net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)) + + if cfg.UseTLS || cfg.Port == 465 { + return sendMailTLS(addr, cfg, to, msg) + } + return sendMailSTARTTLS(addr, cfg, to, msg) +} + +func buildSMTPMessage(from, to, subject, body string) []byte { + lines := []string{ + "From: " + from, + "To: " + to, + "Subject: " + subject, + "MIME-Version: 1.0", + "Content-Type: text/plain; charset=UTF-8", + "", + body, + } + return []byte(strings.Join(lines, "\r\n")) +} + +func sendMailTLS(addr string, cfg SMTPConfig, to string, msg []byte) error { + conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: cfg.Host}) + if err != nil { + return err + } + defer conn.Close() + + client, err := smtp.NewClient(conn, cfg.Host) + if err != nil { + return err + } + defer client.Close() + return doSMTP(client, cfg, to, msg) +} + +func sendMailSTARTTLS(addr string, cfg SMTPConfig, to string, msg []byte) error { + client, err := smtp.Dial(addr) + if err != nil { + return err + } + defer client.Close() + + if ok, _ := client.Extension("STARTTLS"); ok { + if err := client.StartTLS(&tls.Config{ServerName: cfg.Host}); err != nil { + return err + } + } + return doSMTP(client, cfg, to, msg) +} + +func doSMTP(client *smtp.Client, cfg SMTPConfig, to string, msg []byte) error { + if strings.TrimSpace(cfg.Username) != "" { + auth := smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host) + if err := client.Auth(auth); err != nil { + return err + } + } + if err := client.Mail(cfg.FromAddr); err != nil { + return err + } + if err := client.Rcpt(to); err != nil { + return err + } + w, err := client.Data() + if err != nil { + return err + } + if _, err := w.Write(msg); err != nil { + _ = w.Close() + return err + } + if err := w.Close(); err != nil { + return err + } + return client.Quit() +} diff --git a/backend/internal/service/tokens.go b/backend/internal/service/tokens.go new file mode 100644 index 0000000..3ddebdd --- /dev/null +++ b/backend/internal/service/tokens.go @@ -0,0 +1,1668 @@ +package service + +import ( + "context" + "sync" + "sync/atomic" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "log" + "strings" + "time" + + "backend/internal/model" + "backend/internal/provider/adobe" + "backend/internal/provider/chatgpt" + "backend/internal/provider/imagine" + "backend/internal/provider/krea" + "backend/internal/provider/leonardo" + "backend/internal/provider/runway" + "backend/internal/repo" + + "gorm.io/datatypes" + "gorm.io/gorm" +) + +var validTokenPools = map[string]string{ + "chatgpt": "openai", + "adobe": "adobe", + "runway": "runway", + "leonardo": "leonardo", + "krea": "krea", + "imagine": "imagine", +} + +type TokenService struct { + tokens *repo.TokenRepository + refresh *repo.RefreshProfileRepository + events *repo.EventRepository + settings *repo.SiteSettingRepository + adobe *adobe.Client + chatgpt *chatgpt.Client + runway *runway.Client + leonardo *leonardo.Client + krea *krea.Client + imagine *imagine.Client + // sem caps concurrent background pending-probe goroutines (mirrors Python's + // 10-worker _quota_check_pool) so a big paste doesn't fire hundreds of + // simultaneous upstream requests. + sem chan struct{} + // kreaActivating guards the once-per-day krea /app activation sweep so the 60s + // maintenance tick can't pile up overlapping sweeps. + kreaActivating atomic.Bool +} + +func NewTokenService(tokens *repo.TokenRepository, refresh *repo.RefreshProfileRepository, events *repo.EventRepository, settings *repo.SiteSettingRepository, adobeClient *adobe.Client, chatGPTClient *chatgpt.Client, runwayClient *runway.Client, leonardoClient *leonardo.Client, kreaClient *krea.Client, imagineClient *imagine.Client) *TokenService { + return &TokenService{ + tokens: tokens, + refresh: refresh, + events: events, + settings: settings, + adobe: adobeClient, + chatgpt: chatGPTClient, + runway: runwayClient, + leonardo: leonardoClient, + krea: kreaClient, + imagine: imagineClient, + sem: make(chan struct{}, 10), + } +} + +// applyProxy snapshots the configured outbound proxy onto a provider client right +// before an upstream call. Adobe/ChatGPT/Runway/Leonardo all egress through it; +// without this the import/quota probes would dial from the bare server IP (which +// Leonardo rate-limits with a 429). +func (s *TokenService) applyProxy(ctx context.Context) { + if s.settings == nil { + return + } + proxy, err := s.settings.GetValue(ctx, "proxy.url") + if err != nil { + return + } + if s.leonardo != nil { + s.leonardo.SetProxy(proxy) + } + if s.krea != nil { + s.krea.SetProxy(proxy) + } + if s.imagine != nil { + s.imagine.SetProxy(proxy) + } +} + +// RefreshExpiringTokens proactively renews krea/imagine sessions ~10min before +// the access token expires (the providers' refreshLeadSeconds gate), so a dormant +// account's rotating refresh_token never lapses. A dead token can't be recovered +// and — for krea — also means the daily free-credit meter can't be re-created, so +// keeping it perpetually fresh is what lets额度 auto-recover each day. Called by +// the maintenance sweep; refresh only hits the network for near-expiry accounts. +func (s *TokenService) RefreshExpiringTokens(ctx context.Context) { + items, err := s.tokens.List(ctx) + if err != nil { + return + } + s.applyProxy(ctx) + for i := range items { + it := items[i] + if it.Dead || it.Status == "disabled" { + continue + } + switch it.Pool { + case "krea": + if s.krea == nil { + continue + } + if _, rerr := kreaRefreshAndPersist(ctx, s.krea, s.tokens, it.ID, it.Value); rerr != nil && errors.Is(rerr, krea.ErrAuth) { + _, _ = s.tokens.Update(ctx, "krea", it.ID, map[string]any{"status": "disabled", "dead": true}) + } + case "imagine": + if s.imagine == nil { + continue + } + if _, rerr := imagineRefreshAndPersist(ctx, s.imagine, s.tokens, it.ID, it.Value); rerr != nil && errors.Is(rerr, imagine.ErrAuth) { + _, _ = s.tokens.Update(ctx, "imagine", it.ID, map[string]any{"status": "disabled", "dead": true}) + } + } + } +} + +// ActivateKreaDue loads /app (Activate) for each krea account that hasn't been +// synced since the most recent daily reset, then re-syncs its balance. Krea only +// grants the daily free balance after the SSR app page loads, so without this an +// always-active account (one that never went 限额, hence never recovered) would +// read 0 / 402 after the reset. Runs in the background off the maintenance tick, +// guarded so sweeps never overlap, bounded concurrency to avoid a reset burst. +func (s *TokenService) ActivateKreaDue(ctx context.Context) { + if s.krea == nil { + return + } + if !s.kreaActivating.CompareAndSwap(false, true) { + return // a sweep is already running + } + bg := context.WithoutCancel(ctx) + go func() { + defer s.kreaActivating.Store(false) + items, err := s.tokens.ListByPool(bg, "krea") + if err != nil { + return + } + s.applyProxy(bg) + // Most recent UTC midnight (== last Beijing-08:00 reset). An account whose + // last sync (cached_quota_at) predates this hasn't been activated today. + lastReset := (time.Now().Unix() / 86400) * 86400 + sem := make(chan struct{}, 4) + var wg sync.WaitGroup + for i := range items { + it := items[i] + if it.Dead || it.Status == "disabled" || strings.TrimSpace(it.Value) == "" { + continue + } + if at, ok := jsonMapInt(it.Meta, "cached_quota_at"); ok && int64(at) >= lastReset { + continue // already activated/synced since the last reset + } + wg.Add(1) + sem <- struct{}{} + go func(it model.TokenAccount) { + defer wg.Done() + defer func() { <-sem }() + actx, cancel := context.WithTimeout(bg, 90*time.Second) + defer cancel() + s.krea.Activate(actx, it.Value) // load /app → grant daily balance + _, _ = s.Quota(actx, "krea", it.ID) + }(it) + } + wg.Wait() + }() +} + +func (s *TokenService) List(ctx context.Context) (map[string][]ginToken, error) { + items, err := s.tokens.List(ctx) + if err != nil { + return nil, err + } + out := map[string][]ginToken{} + for _, item := range items { + out[item.Pool] = append(out[item.Pool], ginToken{ + ID: item.ID, + ValuePreview: previewSecret(item.Value), + Status: item.Status, + Fails: item.Fails, + AddedAt: item.AddedAt, + }) + } + return out, nil +} + +func (s *TokenService) Add(ctx context.Context, pool, value, tokenID string) (*model.TokenAccount, error) { + pool = normalizePool(pool) + if pool == "" { + return nil, errors.New("unknown pool") + } + value = strings.TrimSpace(value) + if value == "" { + return nil, errors.New("pool and value required") + } + if tokenID == "" { + tokenID = newTokenID(pool) + } + return s.createToken(ctx, pool, tokenID, value, "active", nil) +} + +func (s *TokenService) ImportChatGPTToken(ctx context.Context, accessToken, tokenID string) (*model.TokenAccount, error) { + accessToken = strings.TrimSpace(accessToken) + if accessToken == "" { + return nil, errors.New("access_token required") + } + // Land as pending and return instantly; a background worker probes quota and + // flips the row active/dead (Python import_chatgpt_token). pending tokens are + // not schedulable — the pool only hands out status=="active". + meta := datatypes.JSONMap{"pending_check": true} + info := chatgpt.ExtractAccountInfo(accessToken) + _, exp := parseJWTEmailExpiry(accessToken) + // Identity is (pool, email): reuse the existing row for this email, else mint a + // fresh id — never trust the caller's id (it can collide with an unrelated row + // → a spurious 23505/400 for a brand-new account). + email := strings.TrimSpace(stringValue(info["email"])) + if existing, _ := s.tokens.GetByPoolEmail(ctx, "chatgpt", email); existing != nil { + tokenID = existing.ID + } else if email != "" || tokenID == "" { + tokenID = newTokenID("chatgpt") + } + item, err := s.createToken(ctx, "chatgpt", tokenID, accessToken, "pending", meta) + if err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + if item, err = s.tokens.Update(ctx, "chatgpt", tokenID, map[string]any{ + "value": accessToken, "status": "pending", "meta": meta, + }); err != nil { + return nil, err + } + } else { + return nil, err + } + } + // JWT-derived fields are free (no network) — hydrate them up front. OpenAI + // tokens carry the email under the nested "https://api.openai.com/profile" + // claim, so read it from ExtractAccountInfo — parseJWTEmailExpiry only sees + // top-level claims and returns "" for ChatGPT tokens. + patch := map[string]any{} + if email := strings.TrimSpace(stringValue(info["email"])); email != "" { + patch["account_email"] = email + } + if display := strings.TrimSpace(stringValue(info["plan_type"])); display != "" { + patch["account_display_name"] = display + } + if exp != nil { + patch["cached_quota_reset_after"] = exp.Format(time.RFC3339) + } + if len(patch) > 0 { + if updated, uerr := s.tokens.Update(ctx, "chatgpt", tokenID, patch); uerr == nil { + item = updated + } + } + go s.checkPendingChatGPT(tokenID, accessToken) + return item, nil +} + +// ImportRunwayToken lands a Runway JWT as a pending account and probes its +// credit balance off-thread (mirrors ImportChatGPTToken). The workspace/team id +// (= the JWT "id" claim) is stashed in meta["team_id"] so generation can send it +// as x-runway-workspace later. Recovery time == the JWT expiry. +func (s *TokenService) ImportRunwayToken(ctx context.Context, accessToken, tokenID string) (*model.TokenAccount, error) { + accessToken = strings.TrimSpace(strings.TrimPrefix(accessToken, "Bearer ")) + if accessToken == "" { + return nil, errors.New("access_token required") + } + if !runway.IsRunwayToken(accessToken) { + return nil, errors.New("not a runway token") + } + teamID := runway.TeamIDFromToken(accessToken) + // JWT-derived fields are free (no network); email + exp are top-level claims. + email, exp := parseJWTEmailExpiry(accessToken) + // Identity is (pool, email): reuse the existing row for this email, else mint a + // fresh id — never trust the caller's id (it can collide → spurious 400). + if existing, _ := s.tokens.GetByPoolEmail(ctx, "runway", email); existing != nil { + tokenID = existing.ID + } else if email != "" || tokenID == "" { + tokenID = newTokenID("runway") + } + meta := datatypes.JSONMap{"pending_check": true} + if teamID != "" { + meta["team_id"] = teamID + } + item, err := s.createToken(ctx, "runway", tokenID, accessToken, "pending", meta) + if err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + if item, err = s.tokens.Update(ctx, "runway", tokenID, map[string]any{ + "value": accessToken, "status": "pending", "meta": meta, + }); err != nil { + return nil, err + } + } else { + return nil, err + } + } + patch := map[string]any{} + if email != "" { + patch["account_email"] = email + } + if exp != nil { + patch["cached_quota_reset_after"] = exp.Format(time.RFC3339) + } + if len(patch) > 0 { + if updated, uerr := s.tokens.Update(ctx, "runway", tokenID, patch); uerr == nil { + item = updated + } + } + go s.checkPendingRunway(tokenID, accessToken) + return item, nil +} + +// ImportLeonardoCookie imports a Leonardo account. Unlike Adobe (which keeps a +// refresh profile to re-mint a bearer), Leonardo's stored credential IS the +// cookie — the bearer is derived on demand at generation time via get-session — +// so there's no refresh profile. Lands a pending row, then the worker validates +// the cookie + hydrates email/quota off-thread. +func (s *TokenService) ImportLeonardoCookie(ctx context.Context, cookie, tokenID string) (*model.TokenAccount, error) { + cookie = cleanAdobeCookie(cookie) // same paste-cleanup (JSON / "Cookie:" prefix) + if cookie == "" { + return nil, errors.New("cookie required") + } + if !leonardo.IsLeonardoCookie(cookie) { + return nil, errors.New("not a leonardo cookie") + } + if tokenID == "" { + tokenID = newTokenID("leonardo") + } + meta := datatypes.JSONMap{"pending_check": true} + item, err := s.createToken(ctx, "leonardo", tokenID, cookie, "pending", meta) + if err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + item, err = s.tokens.Update(ctx, "leonardo", tokenID, map[string]any{ + "value": cookie, + "status": "pending", + "meta": meta, + }) + if err != nil { + return nil, err + } + } else { + return nil, err + } + } + // Fill 恢复时间 synchronously at import (next 08:00 Beijing) so it appears + // alongside 创建时间 immediately — not only after the background quota probe. + if updated, uerr := s.tokens.Update(ctx, "leonardo", tokenID, map[string]any{ + "cached_quota_reset_after": leonardoResetAfter(""), + }); uerr == nil { + item = updated + } + go s.checkPendingLeonardo(tokenID, cookie) + return item, nil +} + +// checkPendingLeonardo validates a freshly imported Leonardo cookie off-thread: +// get-session must succeed (else the cookie is dead → disabled), then it hydrates +// email/display-name + the token balance and the daily renewal time (so the +// maintenance sweep can auto-recover a 限额 account). +func (s *TokenService) checkPendingLeonardo(tokenID, cookie string) { + defer func() { + if r := recover(); r != nil { + log.Printf("token import: leonardo pending check panicked for %s: %v", tokenID, r) + } + }() + s.sem <- struct{}{} + defer func() { <-s.sem }() + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + if s.leonardo == nil { + s.finishPending(ctx, "leonardo", tokenID, "active", false, nil) + return + } + s.applyProxy(ctx) + data, err := s.leonardo.FetchCreditsBalance(ctx, cookie) + if err != nil { + if errors.Is(err, leonardo.ErrAuth) { + s.finishPending(ctx, "leonardo", tokenID, "disabled", true, nil) + return + } + // network/proxy blip — benefit of the doubt, activate. + s.finishPending(ctx, "leonardo", tokenID, "active", false, nil) + return + } + seed := map[string]any{} + if em := strings.TrimSpace(stringValue(data["email"])); em != "" { + seed["account_email"] = em + } + if dn := strings.TrimSpace(stringValue(data["display_name"])); dn != "" { + seed["account_display_name"] = dn + } + // Always fill 恢复时间: use upstream's renewal time if present, else the next + // daily reset (08:00 Beijing == next UTC midnight). + seed["cached_quota_reset_after"] = leonardoResetAfter(stringValue(data["available_until"])) + if len(seed) > 0 { + _, _ = s.tokens.Update(ctx, "leonardo", tokenID, seed) + } + quotaMeta := map[string]any{} + if rem, ok := data["remaining"].(int); ok { + quotaMeta["cached_quota_remaining"] = rem + quotaMeta["cached_quota_at"] = int(time.Now().Unix()) + } + if uid := strings.TrimSpace(stringValue(data["user_id"])); uid != "" { + quotaMeta["user_id"] = uid + } + s.finishPending(ctx, "leonardo", tokenID, "active", false, quotaMeta) +} + +// ImportKreaCookie imports a Krea account. Like Leonardo the stored credential +// IS the cookie (Supabase session); quota/generation forward it directly. +func (s *TokenService) ImportKreaCookie(ctx context.Context, cookie, tokenID string) (*model.TokenAccount, error) { + cookie = cleanAdobeCookie(cookie) // same paste-cleanup (JSON / "Cookie:" prefix) + if cookie == "" { + return nil, errors.New("cookie required") + } + if !krea.IsKreaCookie(cookie) { + return nil, errors.New("not a krea cookie") + } + // Identity is (pool, email), NOT the caller-supplied id — a colliding id from + // the upstream importer would otherwise raise a spurious 23505/400 for a brand + // new account. Reuse the existing row for this email; else mint a fresh unique + // id (never trust the caller's id for a new row). + email := krea.EmailFromCookie(cookie) + if existing, _ := s.tokens.GetByPoolEmail(ctx, "krea", email); existing != nil { + tokenID = existing.ID + } else { + tokenID = newTokenID("krea") + } + meta := datatypes.JSONMap{"pending_check": true} + item, err := s.createToken(ctx, "krea", tokenID, cookie, "pending", meta) + if err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + item, err = s.tokens.Update(ctx, "krea", tokenID, map[string]any{ + "value": cookie, + "status": "pending", + "meta": meta, + }) + if err != nil { + return nil, err + } + } else { + return nil, err + } + } + // Email is in the cookie (no network) — hydrate up front, plus 恢复时间. + seed := map[string]any{"cached_quota_reset_after": leonardoResetAfter("")} + if email != "" { + seed["account_email"] = email + } + if updated, uerr := s.tokens.Update(ctx, "krea", tokenID, seed); uerr == nil { + item = updated + } + go s.checkPendingKrea(tokenID, cookie) + return item, nil +} + +// checkPendingKrea validates a freshly imported Krea cookie off-thread and +// hydrates the credit balance. A 401 from billing-data → the cookie is dead. +func (s *TokenService) checkPendingKrea(tokenID, cookie string) { + defer func() { + if r := recover(); r != nil { + log.Printf("token import: krea pending check panicked for %s: %v", tokenID, r) + } + }() + s.sem <- struct{}{} + defer func() { <-s.sem }() + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + if s.krea == nil { + s.finishPending(ctx, "krea", tokenID, "active", false, nil) + return + } + s.applyProxy(ctx) + cookie, rerr := kreaRefreshAndPersist(ctx, s.krea, s.tokens, tokenID, cookie) + if rerr != nil { + if errors.Is(rerr, krea.ErrAuth) { + s.finishPending(ctx, "krea", tokenID, "disabled", true, nil) + return + } + s.finishPending(ctx, "krea", tokenID, "active", false, nil) + return + } + data, err := s.krea.FetchCreditsBalance(ctx, cookie) + if err != nil { + if errors.Is(err, krea.ErrAuth) { + s.finishPending(ctx, "krea", tokenID, "disabled", true, nil) + return + } + s.finishPending(ctx, "krea", tokenID, "active", false, nil) + return + } + if em := strings.TrimSpace(stringValue(data["email"])); em != "" { + _, _ = s.tokens.Update(ctx, "krea", tokenID, map[string]any{"account_email": em}) + } + quotaMeta := map[string]any{} + if rem, ok := data["remaining"].(int); ok { + quotaMeta["cached_quota_remaining"] = rem + quotaMeta["cached_quota_at"] = int(time.Now().Unix()) + } + s.finishPending(ctx, "krea", tokenID, "active", false, quotaMeta) +} + +// ImportImagineToken imports an Imagine.art account. The stored credential IS the +// JSON {"token","refreshToken"}; quota/generation forward it (refreshing the +// access token from the refreshToken when expired). +func (s *TokenService) ImportImagineToken(ctx context.Context, cred, tokenID string) (*model.TokenAccount, error) { + cred = strings.TrimSpace(cred) + if cred == "" { + return nil, errors.New("credential required") + } + if !imagine.IsImagineToken(cred) { + return nil, errors.New("not an imagine token") + } + // Identity is (pool, email=userId), NOT the caller-supplied id — reuse the + // existing row for this account; else mint a fresh unique id. + email := imagine.EmailFromCred(cred) + if existing, _ := s.tokens.GetByPoolEmail(ctx, "imagine", email); existing != nil { + tokenID = existing.ID + } else { + tokenID = newTokenID("imagine") + } + meta := datatypes.JSONMap{"pending_check": true} + item, err := s.createToken(ctx, "imagine", tokenID, cred, "pending", meta) + if err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + item, err = s.tokens.Update(ctx, "imagine", tokenID, map[string]any{ + "value": cred, + "status": "pending", + "meta": meta, + }) + if err != nil { + return nil, err + } + } else { + return nil, err + } + } + // userId is in the token (no network) — hydrate up front, plus 恢复时间. + // Imagine free credits renew daily like Krea (next UTC midnight = 08:00 北京). + seed := map[string]any{"cached_quota_reset_after": leonardoResetAfter("")} + if email != "" { + seed["account_email"] = email + } + if updated, uerr := s.tokens.Update(ctx, "imagine", tokenID, seed); uerr == nil { + item = updated + } + go s.checkPendingImagine(tokenID, cred) + return item, nil +} + +// checkPendingImagine validates a freshly imported Imagine token off-thread and +// hydrates the credit balance. A 401 from /v1/credit → the token is dead. +func (s *TokenService) checkPendingImagine(tokenID, cred string) { + defer func() { + if r := recover(); r != nil { + log.Printf("token import: imagine pending check panicked for %s: %v", tokenID, r) + } + }() + s.sem <- struct{}{} + defer func() { <-s.sem }() + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + if s.imagine == nil { + s.finishPending(ctx, "imagine", tokenID, "active", false, nil) + return + } + s.applyProxy(ctx) + cred, rerr := imagineRefreshAndPersist(ctx, s.imagine, s.tokens, tokenID, cred) + if rerr != nil { + if errors.Is(rerr, imagine.ErrAuth) { + s.finishPending(ctx, "imagine", tokenID, "disabled", true, nil) + return + } + s.finishPending(ctx, "imagine", tokenID, "active", false, nil) + return + } + data, err := s.imagine.FetchCreditsBalance(ctx, cred) + if err != nil { + if errors.Is(err, imagine.ErrAuth) { + s.finishPending(ctx, "imagine", tokenID, "disabled", true, nil) + return + } + s.finishPending(ctx, "imagine", tokenID, "active", false, nil) + return + } + if em := strings.TrimSpace(stringValue(data["email"])); em != "" { + _, _ = s.tokens.Update(ctx, "imagine", tokenID, map[string]any{"account_email": em}) + } + quotaMeta := map[string]any{} + if rem, ok := data["remaining"].(int); ok { + quotaMeta["cached_quota_remaining"] = rem + quotaMeta["cached_quota_at"] = int(time.Now().Unix()) + } + s.finishPending(ctx, "imagine", tokenID, "active", false, quotaMeta) +} + +func (s *TokenService) ImportAdobeCookie(ctx context.Context, cookie, tokenID string) (*model.TokenAccount, *model.RefreshProfile, error) { + cookie = cleanAdobeCookie(cookie) + if cookie == "" { + return nil, nil, errors.New("cookie required") + } + if tokenID == "" { + tokenID = newTokenID("adobe") + } + now := time.Now() + // Register the cookie refresh profile up front. Push next_retry_at out a full + // interval so the maintenance loop doesn't race the import worker on the first + // exchange — the worker below owns the initial hydrate. + nextRetry := now.Add(54000 * time.Second) + profile := &model.RefreshProfile{ + ID: tokenID, + Name: tokenID, + Pool: "adobe", + Kind: "adobe_cookie", + Cookie: cookie, + Enabled: true, + IntervalSeconds: 54000, + ImportedAt: &now, + NextRetryAt: &nextRetry, + CreatedAt: now, + UpdatedAt: now, + } + if err := s.refresh.Create(ctx, profile); err != nil && !errors.Is(err, gorm.ErrDuplicatedKey) { + return nil, nil, err + } + // Land a placeholder pending token (value filled in by the worker). NOT + // schedulable — the pool only hands out status=="active". The import returns + // instantly; the row flips active/dead once the worker finishes the three + // Adobe round-trips. Mirrors Python import_adobe_cookie. + meta := datatypes.JSONMap{"pending_check": true} + item, err := s.createToken(ctx, "adobe", tokenID, "", "pending", meta) + if err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + item, err = s.tokens.Update(ctx, "adobe", tokenID, map[string]any{ + "status": "pending", + "meta": meta, + }) + if err != nil { + return nil, nil, err + } + } else { + return nil, nil, err + } + } + go s.checkPendingAdobe(tokenID, cookie) + return item, profile, nil +} + +// checkPendingAdobe runs the three Adobe round-trips off-thread for a freshly +// imported cookie so the import request returns instantly (Python +// _check_pending_adobe). Step 1 (exchange) is authoritative — a bad/expired +// cookie can't mint a token, so failure marks the row dead. Steps 2-3 (credits / +// profile) are best-effort hydration. +func (s *TokenService) checkPendingAdobe(tokenID, cookie string) { + defer func() { + if r := recover(); r != nil { + log.Printf("token import: adobe pending check panicked for %s: %v", tokenID, r) + } + }() + s.sem <- struct{}{} + defer func() { <-s.sem }() + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + if s.adobe == nil { + s.finishPending(ctx, "adobe", tokenID, "disabled", true, nil) + return + } + result, err := s.adobe.ExchangeCookie(ctx, cookie) + if err != nil { + s.finishPending(ctx, "adobe", tokenID, "disabled", true, nil) + _, _ = s.refresh.Update(ctx, tokenID, map[string]any{ + "last_attempt_at": time.Now(), + "last_error": err.Error(), + "consecutive_failures": 1, + }) + return + } + // Seed the real access token, then activate so the pool can schedule it. + seed := map[string]any{"value": result.AccessToken} + email, exp := parseJWTEmailExpiry(result.AccessToken) + if email != "" { + seed["account_email"] = email + } + if exp != nil { + seed["cached_quota_reset_after"] = exp.Format(time.RFC3339) + } + _, _ = s.tokens.Update(ctx, "adobe", tokenID, seed) + + quotaMeta := map[string]any{} + if cb, e := s.adobe.FetchCreditsBalance(ctx, result.AccessToken); e == nil { + if ra := strings.TrimSpace(stringValue(cb["available_until"])); ra != "" { + _, _ = s.tokens.Update(ctx, "adobe", tokenID, map[string]any{"cached_quota_reset_after": ra}) + } + quotaMeta["cached_quota_at"] = int(time.Now().Unix()) + if rem, ok := cb["remaining"].(int); ok { + quotaMeta["cached_quota_remaining"] = rem + } + } + if prof, e := s.adobe.FetchAccountProfile(ctx, result.AccessToken); e == nil { + p := map[string]any{} + if em := strings.TrimSpace(stringValue(prof["email"])); em != "" { + p["account_email"] = em + } + if dn := strings.TrimSpace(stringValue(prof["display_name"])); dn != "" { + p["account_display_name"] = dn + } + if len(p) > 0 { + _, _ = s.tokens.Update(ctx, "adobe", tokenID, p) + } + } + + s.finishPending(ctx, "adobe", tokenID, "active", false, quotaMeta) + _, _ = s.refresh.Update(ctx, tokenID, map[string]any{ + "last_attempt_at": time.Now(), + "last_success_at": time.Now(), + "last_error": "", + "consecutive_failures": 0, + "next_retry_at": time.Now().Add(54000 * time.Second), + }) +} + +// checkPendingChatGPT probes a freshly imported ChatGPT token's quota off-thread +// (Python _check_pending_chatgpt). 401 → dead; a non-auth error gets the benefit +// of the doubt and activates so a transient blip can't sideline a good account. +func (s *TokenService) checkPendingChatGPT(tokenID, accessToken string) { + defer func() { + if r := recover(); r != nil { + log.Printf("token import: chatgpt pending check panicked for %s: %v", tokenID, r) + } + }() + s.sem <- struct{}{} + defer func() { <-s.sem }() + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + if s.chatgpt == nil { + s.finishPending(ctx, "chatgpt", tokenID, "active", false, nil) + return + } + data, err := s.chatgpt.FetchImageQuota(ctx, accessToken) + if err != nil { + // network/proxy error — benefit of the doubt, activate. + s.finishPending(ctx, "chatgpt", tokenID, "active", false, nil) + return + } + if boolValueWithDefault(data["auth_failed"], false) { + s.finishPending(ctx, "chatgpt", tokenID, "disabled", true, nil) + return + } + rem, exhausted := chatgptRemaining(data) + quotaMeta := map[string]any{ + "cached_quota_remaining": rem, + "cached_quota_at": int(time.Now().Unix()), + } + // reset 时间:优先用 OpenAI 的 reset_after,缺失则默认次日重置,保证限额号能被 + // RecoverQuota 到点自动复活、重新探测,而不会永久搁置。 + reset := strings.TrimSpace(stringValue(data["reset_after"])) + if reset == "" { + reset = leonardoResetAfter("") + } + _, _ = s.tokens.Update(ctx, "chatgpt", tokenID, map[string]any{"cached_quota_reset_after": reset}) + // remaining<=0(0 / 负数 / 未知)→ 置「限额」,池子不再调度,到点自动恢复。 + status := "active" + if exhausted { + status = "quota" + } + s.finishPending(ctx, "chatgpt", tokenID, status, false, quotaMeta) +} + +// chatgptRemaining normalizes OpenAI's image_gen remaining: the raw rate-limit +// counter can go NEGATIVE on over-used accounts, and "—"(absent)means unknown — +// both clamp to 0, and 0 counts as exhausted (→ 限额). Returns (remaining≥0, +// exhausted). +func chatgptRemaining(data map[string]any) (int, bool) { + raw, ok := data["remaining"] + if !ok || raw == nil { + return 0, true + } + rem := intValue(raw) + if rem < 0 { + rem = 0 + } + return rem, rem <= 0 +} + +// checkPendingRunway probes a freshly imported Runway token's credit balance +// off-thread (mirrors checkPendingChatGPT). ErrAuth → dead; any other error +// gets the benefit of the doubt and activates so a transient blip can't sideline +// a good account. +func (s *TokenService) checkPendingRunway(tokenID, accessToken string) { + defer func() { + if r := recover(); r != nil { + log.Printf("token import: runway pending check panicked for %s: %v", tokenID, r) + } + }() + s.sem <- struct{}{} + defer func() { <-s.sem }() + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + if s.runway == nil { + s.finishPending(ctx, "runway", tokenID, "active", false, nil) + return + } + data, err := s.runway.FetchCreditsBalance(ctx, accessToken) + if err != nil { + if errors.Is(err, runway.ErrAuth) { + s.finishPending(ctx, "runway", tokenID, "disabled", true, nil) + return + } + // network/proxy error — benefit of the doubt, activate. + s.finishPending(ctx, "runway", tokenID, "active", false, nil) + return + } + quotaMeta := map[string]any{} + if rem, ok := data["remaining"].(int); ok { + quotaMeta["cached_quota_remaining"] = rem + quotaMeta["cached_quota_at"] = int(time.Now().Unix()) + } + if used, ok := data["used"].(int); ok { + quotaMeta["cached_quota_used"] = used + } + if total, ok := data["total"].(int); ok { + quotaMeta["cached_quota_total"] = total + } + s.finishPending(ctx, "runway", tokenID, "active", false, quotaMeta) +} + +// finishPending writes the terminal status/dead flag and clears the pending_check +// marker (merging any cached quota) for a background import probe. +func (s *TokenService) finishPending(ctx context.Context, pool, id, status string, dead bool, quotaMeta map[string]any) { + item, err := s.tokens.Get(ctx, pool, id) + if err != nil { + return + } + meta := cloneJSONMap(item.Meta) + meta["pending_check"] = false + for k, v := range quotaMeta { + meta[k] = v + } + patch := map[string]any{"status": status, "meta": meta} + if dead { + patch["dead"] = true + } + _, _ = s.tokens.Update(ctx, pool, id, patch) +} + +func (s *TokenService) Update(ctx context.Context, pool, id string, body map[string]any) (*model.TokenAccount, error) { + pool = normalizePool(pool) + if pool == "" { + return nil, errors.New("unknown pool") + } + patch := map[string]any{} + if raw, ok := body["status"]; ok { + status := normalizeTokenStatus(stringValue(raw)) + if status == "" { + return nil, errors.New("invalid status") + } + patch["status"] = status + if status == "active" { + patch["dead"] = false + if _, hasFails := body["fails"]; !hasFails { + patch["fails"] = 0 + } + } + } + if raw, ok := body["value"]; ok { + value := strings.TrimSpace(stringValue(raw)) + if value == "" { + return nil, errors.New("value cannot be empty") + } + patch["value"] = value + patch["dead"] = false + } + if raw, ok := body["fails"]; ok { + patch["fails"] = intValue(raw) + } + if len(patch) == 0 { + return s.tokens.Get(ctx, pool, id) + } + return s.tokens.Update(ctx, pool, id, patch) +} + +func (s *TokenService) Delete(ctx context.Context, pool, id string) error { + pool = normalizePool(pool) + if pool == "" { + return errors.New("unknown pool") + } + rows, err := s.tokens.Delete(ctx, pool, id) + if err != nil { + return err + } + // Also drop the matching cookie refresh profile (token id == profile id), + // otherwise the background refresher re-creates the token. Track whether a + // profile existed so we can mirror Python's 404-when-nothing-removed. + profileRemoved := false + if _, getErr := s.refresh.Get(ctx, id); getErr == nil { + profileRemoved = true + } + _ = s.refresh.Delete(ctx, id) + if rows == 0 && !profileRemoved { + return ErrNotFound + } + return nil +} + +// DeleteBulk removes many accounts by id (across pools) plus their cookie +// refresh profiles. Returns how many account rows were removed. +func (s *TokenService) DeleteBulk(ctx context.Context, ids []string) (int, error) { + seen := make(map[string]struct{}, len(ids)) + clean := make([]string, 0, len(ids)) + for _, id := range ids { + id = strings.TrimSpace(id) + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + clean = append(clean, id) + } + if len(clean) == 0 { + return 0, nil + } + rows, err := s.tokens.DeleteByIDs(ctx, clean) + if err != nil { + return 0, err + } + // Drop matching cookie refresh profiles (id == token id) so the background + // refresher doesn't re-create the tokens. + _ = s.refresh.DeleteByIDs(ctx, clean) + return int(rows), nil +} + +func (s *TokenService) Accounts(ctx context.Context) ([]map[string]any, error) { + items, err := s.tokens.List(ctx) + if err != nil { + return nil, err + } + inFlight, err := s.events.InFlightByAccount(ctx) + if err != nil { + return nil, err + } + out := make([]map[string]any, 0, len(items)) + for _, item := range items { + out = append(out, accountRow(item, inFlight[item.ID])) + } + return out, nil +} + +func (s *TokenService) Quota(ctx context.Context, pool, id string) (map[string]any, error) { + item, err := s.tokens.Get(ctx, normalizePool(pool), id) + if err != nil { + return nil, err + } + if poolToType(item.Pool) == "openai" && s.chatgpt != nil { + data, err := s.chatgpt.FetchImageQuota(ctx, item.Value) + if err != nil { + return nil, err + } + authFailed := boolValueWithDefault(data["auth_failed"], false) + if authFailed { + _, _ = s.tokens.Update(ctx, item.Pool, item.ID, map[string]any{ + "status": "disabled", + "dead": true, + "fails": gorm.Expr("fails + 1"), + }) + } + patch := map[string]any{} + meta := cloneJSONMap(item.Meta) + meta["cached_quota_at"] = int(time.Now().Unix()) + rem, exhausted := chatgptRemaining(data) + meta["cached_quota_remaining"] = rem + patch["meta"] = meta + resetAfter := strings.TrimSpace(stringValue(data["reset_after"])) + if resetAfter == "" { + resetAfter = leonardoResetAfter("") + } + patch["cached_quota_reset_after"] = resetAfter + item.CachedQuotaResetAfter = resetAfter + // remaining<=0(负数/未知/0)→ 限额;>0 且当前是 quota → 恢复 active。 + // auth 失效已在上面置死,这里不再改它的状态。 + if !authFailed { + if exhausted { + patch["status"] = "quota" + } else if item.Status == "quota" { + patch["status"] = "active" + } + } + if updated, updateErr := s.tokens.Update(ctx, item.Pool, item.ID, patch); updateErr == nil { + item = updated + } + return map[string]any{ + "supported": true, + "remaining": rem, + "total": nil, + "reset_after": emptyToNil(item.CachedQuotaResetAfter), + "quota_cached_at": meta["cached_quota_at"], + "unchanged": false, + "unknown": boolValueWithDefault(data["unknown"], false), + "error": data["error"], + }, nil + } + if poolToType(item.Pool) == "adobe" && s.adobe != nil { + data, err := s.adobe.FetchCreditsBalance(ctx, item.Value) + if err != nil { + if errors.Is(err, adobe.ErrAuth) { + _, _ = s.tokens.Update(ctx, item.Pool, item.ID, map[string]any{ + "status": "disabled", + "dead": true, + "fails": gorm.Expr("fails + 1"), + }) + } + return nil, err + } + patch := map[string]any{} + meta := cloneJSONMap(item.Meta) + meta["cached_quota_at"] = int(time.Now().Unix()) + if remaining, ok := data["remaining"].(int); ok { + meta["cached_quota_remaining"] = remaining + } + if used, ok := data["used"].(int); ok { + meta["cached_quota_used"] = used + } + if total, ok := data["total"].(int); ok { + meta["cached_quota_total"] = total + } + patch["meta"] = meta + if resetAfter := strings.TrimSpace(stringValue(data["available_until"])); resetAfter != "" { + patch["cached_quota_reset_after"] = resetAfter + item.CachedQuotaResetAfter = resetAfter + } + if len(patch) > 0 { + if updated, updateErr := s.tokens.Update(ctx, item.Pool, item.ID, patch); updateErr == nil { + item = updated + } + } + return map[string]any{ + "supported": true, + "remaining": data["remaining"], + "used": data["used"], + "total": data["total"], + "reset_after": emptyToNil(item.CachedQuotaResetAfter), + "quota_cached_at": meta["cached_quota_at"], + "unchanged": false, + "unknown": boolValueWithDefault(data["unknown"], false), + "error": data["error"], + }, nil + } + if poolToType(item.Pool) == "krea" && s.krea != nil { + s.applyProxy(ctx) + cookie, rerr := kreaRefreshAndPersist(ctx, s.krea, s.tokens, item.ID, item.Value) + if rerr != nil { + if errors.Is(rerr, krea.ErrAuth) { + _, _ = s.tokens.Update(ctx, item.Pool, item.ID, map[string]any{ + "status": "disabled", "dead": true, "fails": gorm.Expr("fails + 1"), + }) + } + return nil, rerr + } + data, err := s.krea.FetchCreditsBalance(ctx, cookie) + if err != nil { + if errors.Is(err, krea.ErrAuth) { + _, _ = s.tokens.Update(ctx, item.Pool, item.ID, map[string]any{ + "status": "disabled", "dead": true, "fails": gorm.Expr("fails + 1"), + }) + } + return nil, err + } + meta := cloneJSONMap(item.Meta) + meta["cached_quota_at"] = int(time.Now().Unix()) + rem, hasRem := data["remaining"].(int) + if hasRem { + meta["cached_quota_remaining"] = rem + } + // 每日免费额度在 UTC 零点(北京 08:00)重置 —— 恢复时间始终重算为"下一个零点", + // 不保留已过期的旧值(否则过了 08:00 还一直显示今天 08:00,不会变明天)。 + resetAfter := leonardoResetAfter("") + patch := map[string]any{"meta": meta, "cached_quota_reset_after": resetAfter} + // Krea 限额由生成时的 402 判定;一旦余额恢复(galactus 触发刷新后 >0),把之前 + // 沉下去的 quota 翻回 active,避免有余额却卡在限额。 + if item.Status == "quota" && hasRem && rem > 0 { + patch["status"] = "active" + } + if updated, updateErr := s.tokens.Update(ctx, item.Pool, item.ID, patch); updateErr == nil { + item = updated + } + return map[string]any{ + "supported": true, + "remaining": data["remaining"], + "used": data["used"], + "total": data["total"], + "reset_after": emptyToNil(resetAfter), + "quota_cached_at": meta["cached_quota_at"], + "unchanged": false, + "unknown": boolValueWithDefault(data["unknown"], false), + "error": data["error"], + }, nil + } + if poolToType(item.Pool) == "imagine" && s.imagine != nil { + s.applyProxy(ctx) + cred, rerr := imagineRefreshAndPersist(ctx, s.imagine, s.tokens, item.ID, item.Value) + if rerr != nil { + if errors.Is(rerr, imagine.ErrAuth) { + _, _ = s.tokens.Update(ctx, item.Pool, item.ID, map[string]any{ + "status": "disabled", "dead": true, "fails": gorm.Expr("fails + 1"), + }) + } + return nil, rerr + } + data, err := s.imagine.FetchCreditsBalance(ctx, cred) + if err != nil { + if errors.Is(err, imagine.ErrAuth) { + _, _ = s.tokens.Update(ctx, item.Pool, item.ID, map[string]any{ + "status": "disabled", "dead": true, "fails": gorm.Expr("fails + 1"), + }) + } + return nil, err + } + meta := cloneJSONMap(item.Meta) + meta["cached_quota_at"] = int(time.Now().Unix()) + rem, hasRem := data["remaining"].(int) + if hasRem { + meta["cached_quota_remaining"] = rem + } + // 每日免费额度在 UTC 零点(北京 08:00)重置 —— 恢复时间始终重算为"下一个零点", + // 不保留已过期的旧值(否则过了 08:00 还一直显示今天 08:00,不会变明天)。 + resetAfter := leonardoResetAfter("") + patch := map[string]any{"meta": meta, "cached_quota_reset_after": resetAfter} + // 余额恢复(>0)→ 把之前因 402 沉下去的 quota 翻回 active。 + if item.Status == "quota" && hasRem && rem > 0 { + patch["status"] = "active" + } + if updated, updateErr := s.tokens.Update(ctx, item.Pool, item.ID, patch); updateErr == nil { + item = updated + } + return map[string]any{ + "supported": true, + "remaining": data["remaining"], + "used": data["used"], + "total": data["total"], + "reset_after": emptyToNil(resetAfter), + "quota_cached_at": meta["cached_quota_at"], + "unchanged": false, + "unknown": boolValueWithDefault(data["unknown"], false), + "error": data["error"], + }, nil + } + if poolToType(item.Pool) == "leonardo" && s.leonardo != nil { + s.applyProxy(ctx) + data, err := s.leonardo.FetchCreditsBalance(ctx, item.Value) + if err != nil { + if errors.Is(err, leonardo.ErrAuth) { + _, _ = s.tokens.Update(ctx, item.Pool, item.ID, map[string]any{ + "status": "disabled", + "dead": true, + "fails": gorm.Expr("fails + 1"), + }) + } + return nil, err + } + patch := map[string]any{} + meta := cloneJSONMap(item.Meta) + meta["cached_quota_at"] = int(time.Now().Unix()) + if remaining, ok := data["remaining"].(int); ok { + meta["cached_quota_remaining"] = remaining + // Below the per-generation floor → sink to "限额" so it stops being + // scheduled. The daily renewal time (below) lets the sweep auto-recover. + if remaining < leonardoMinCredits && item.Status == "active" { + patch["status"] = "quota" + } + } + if uid := strings.TrimSpace(stringValue(data["user_id"])); uid != "" { + meta["user_id"] = uid + } + patch["meta"] = meta + // Daily reset (08:00 Beijing == next UTC midnight) unless upstream gives an + // explicit renewal time. Drives RecoverQuota. + resetAfter := leonardoResetAfter(stringValue(data["available_until"])) + patch["cached_quota_reset_after"] = resetAfter + if updated, updateErr := s.tokens.Update(ctx, item.Pool, item.ID, patch); updateErr == nil { + item = updated + } + return map[string]any{ + "supported": true, + "remaining": data["remaining"], + "used": data["used"], + "total": data["total"], + "reset_after": emptyToNil(resetAfter), + "quota_cached_at": meta["cached_quota_at"], + "unchanged": false, + "unknown": boolValueWithDefault(data["unknown"], false), + "error": data["error"], + }, nil + } + if poolToType(item.Pool) == "runway" && s.runway != nil { + data, err := s.runway.FetchCreditsBalance(ctx, item.Value) + if err != nil { + if errors.Is(err, runway.ErrAuth) { + _, _ = s.tokens.Update(ctx, item.Pool, item.ID, map[string]any{ + "status": "disabled", + "dead": true, + "fails": gorm.Expr("fails + 1"), + }) + } + return nil, err + } + patch := map[string]any{} + meta := cloneJSONMap(item.Meta) + meta["cached_quota_at"] = int(time.Now().Unix()) + if remaining, ok := data["remaining"].(int); ok { + meta["cached_quota_remaining"] = remaining + // Refreshing in account management: an account below the credit floor + // is sunk into "限额" (quota) so it stops being scheduled. Mark-down + // only — recovery is a separate (unwritten) path. + if remaining < runwayMinCredits && item.Status == "active" { + patch["status"] = "quota" + } + } + if used, ok := data["used"].(int); ok { + meta["cached_quota_used"] = used + } + if total, ok := data["total"].(int); ok { + meta["cached_quota_total"] = total + } + patch["meta"] = meta + if updated, updateErr := s.tokens.Update(ctx, item.Pool, item.ID, patch); updateErr == nil { + item = updated + } + // Recovery time stays the JWT expiry (cached at import) — Runway credits + // reset monthly, so the credits endpoint carries no reset timestamp. + return map[string]any{ + "supported": true, + "remaining": data["remaining"], + "used": data["used"], + "total": data["total"], + "reset_after": emptyToNil(item.CachedQuotaResetAfter), + "quota_cached_at": meta["cached_quota_at"], + "unchanged": false, + "unknown": boolValueWithDefault(data["unknown"], false), + "error": data["error"], + }, nil + } + remaining, hasRemaining := jsonMapInt(item.Meta, "cached_quota_remaining") + quotaAt, _ := jsonMapInt(item.Meta, "cached_quota_at") + typeLabel := poolToType(item.Pool) + return map[string]any{ + "supported": typeLabel == "openai" || typeLabel == "adobe" || typeLabel == "runway", + "remaining": valueOrNil((typeLabel == "openai" || typeLabel == "runway") && hasRemaining, remaining), + "total": nil, + "reset_after": emptyToNil(item.CachedQuotaResetAfter), + "quota_cached_at": valueOrNil(quotaAt != 0, quotaAt), + "unchanged": true, + "unknown": false, + "error": nil, + }, nil +} + +func (s *TokenService) Email(ctx context.Context, pool, id string) (map[string]any, error) { + item, err := s.tokens.Get(ctx, normalizePool(pool), id) + if err != nil { + return nil, err + } + if poolToType(item.Pool) == "openai" { + email := strings.TrimSpace(item.AccountEmail) + if email != "" { + return map[string]any{"email": email, "cached": true}, nil + } + info := chatgpt.ExtractAccountInfo(item.Value) + if extracted := strings.TrimSpace(stringValue(info["email"])); extracted != "" { + _, _ = s.tokens.Update(ctx, item.Pool, item.ID, map[string]any{"account_email": extracted}) + return map[string]any{"email": extracted, "cached": false}, nil + } + return map[string]any{"email": nil, "cached": false}, nil + } + if poolToType(item.Pool) == "runway" { + email := strings.TrimSpace(item.AccountEmail) + if email != "" { + return map[string]any{"email": email, "cached": true}, nil + } + // Runway email is a top-level JWT claim — decode it (no network). + if extracted, _ := parseJWTEmailExpiry(item.Value); extracted != "" { + _, _ = s.tokens.Update(ctx, item.Pool, item.ID, map[string]any{"account_email": extracted}) + return map[string]any{"email": extracted, "cached": false}, nil + } + return map[string]any{"email": nil, "cached": false}, nil + } + if poolToType(item.Pool) != "adobe" { + return map[string]any{"email": nil}, nil + } + email := strings.TrimSpace(item.AccountEmail) + if email == "" { + if s.adobe == nil { + return map[string]any{"email": nil, "cached": false}, nil + } + profile, err := s.adobe.FetchAccountProfile(ctx, item.Value) + if err != nil { + if errors.Is(err, adobe.ErrAuth) { + _, _ = s.tokens.Update(ctx, item.Pool, item.ID, map[string]any{ + "status": "disabled", + "dead": true, + "fails": gorm.Expr("fails + 1"), + }) + } + return nil, err + } + patch := map[string]any{} + if profileEmail := strings.TrimSpace(stringValue(profile["email"])); profileEmail != "" { + patch["account_email"] = profileEmail + email = profileEmail + } + if displayName := strings.TrimSpace(stringValue(profile["display_name"])); displayName != "" { + patch["account_display_name"] = displayName + } + if len(patch) > 0 { + _, _ = s.tokens.Update(ctx, item.Pool, item.ID, patch) + } + return map[string]any{"email": emptyToNil(email), "cached": false}, nil + } + return map[string]any{"email": email, "cached": true}, nil +} + +func (s *TokenService) createToken(ctx context.Context, pool, tokenID, value, status string, meta datatypes.JSONMap) (*model.TokenAccount, error) { + now := time.Now() + item := &model.TokenAccount{ + ID: tokenID, + Pool: pool, + Value: value, + Status: status, + AddedAt: &now, + Meta: meta, + CreatedAt: now, + UpdatedAt: now, + } + if err := s.tokens.Create(ctx, item); err != nil { + return nil, err + } + return item, nil +} + +func accountRow(item model.TokenAccount, inFlight int64) map[string]any { + remaining, hasRemaining := jsonMapInt(item.Meta, "cached_quota_remaining") + quotaAt, _ := jsonMapInt(item.Meta, "cached_quota_at") + pending, _ := jsonMapBool(item.Meta, "pending_check") + // OpenAI email lives in the token's JWT (nested profile claim). Decode it at + // render time like the Python reference (_account_row) so accounts imported + // before the email was persisted still show a name; fall back to the cached + // field for adobe (whose email comes from a network profile fetch). + email := item.AccountEmail + if poolToType(item.Pool) == "openai" { + if decoded := strings.TrimSpace(stringValue(chatgpt.ExtractAccountInfo(item.Value)["email"])); decoded != "" { + email = decoded + } + } + typeLabel := poolToType(item.Pool) + teamID := "" + if item.Meta != nil { + teamID = strings.TrimSpace(stringValue(item.Meta["team_id"])) + } + hasQuota := typeLabel == "openai" || typeLabel == "adobe" || typeLabel == "runway" || typeLabel == "leonardo" || typeLabel == "krea" || typeLabel == "imagine" + return map[string]any{ + "id": item.ID, + "pool": item.Pool, + "type": typeLabel, + "email": emptyToNil(email), + "team_id": emptyToNil(teamID), + "remaining": valueOrNil(hasQuota && hasRemaining, remaining), + "reset_after": emptyToNil(item.CachedQuotaResetAfter), + "quota_cached_at": valueOrNil(quotaAt != 0, quotaAt), + "created_at": unixOrNil(item.AddedAt), + "last_used_at": unixOrNil(item.LastUsedAt), + "expires_at": jwtExpiryUnix(item.Value), + "in_flight": inFlight, + "success_total": item.SuccessTotal, + "fail_total": item.FailTotal, + "fails_streak": item.Fails, + "status": item.Status, + "dead": item.Dead, + "image_limited": item.ImageLimited, + "video_limited": item.VideoLimited, + "pending": pending, + "quota_supported": hasQuota, + "needs_reset_fetch": typeLabel == "adobe" && item.Status == "active" && strings.TrimSpace(item.CachedQuotaResetAfter) == "", + } +} + +// jwtExpiryUnix returns the access token's exp claim (epoch seconds) for the +// accounts UI, or nil when the token is absent/opaque (e.g. a pending row whose +// value hasn't been minted yet). +func jwtExpiryUnix(token string) any { + if strings.TrimSpace(token) == "" { + return nil + } + _, exp := parseJWTEmailExpiry(token) + if exp == nil { + return nil + } + return exp.Unix() +} + +// cleanAdobeCookie mirrors the Python admin import preprocessing +// (api/admin.py import_adobe_cookie): tolerate JSON array/object pastes, +// unwrap a one-level {"cookie": "..."} wrapper, strip a leading "Cookie:" +// prefix and collapse stray whitespace/newlines. +func cleanAdobeCookie(cookie string) string { + cookieStr := strings.TrimSpace(cookie) + + // ① A JSON array/object paste -> turn into a cookie string up front. + if strings.HasPrefix(cookieStr, "[") { + if converted := cookieStringFromInput(cookieStr); converted != "" { + cookieStr = converted + } + } + + // ② Tolerate the whole JSON object `{"cookie": "..."}` pasted into the + // textarea. Unwrap one level of JSON before validating. + if strings.HasPrefix(cookieStr, "{") && strings.HasSuffix(cookieStr, "}") { + var parsed map[string]any + if err := json.Unmarshal([]byte(cookieStr), &parsed); err == nil { + inner, ok := parsed["cookie"] + if !ok { + inner = parsed["value"] + } + switch v := inner.(type) { + case string: + cookieStr = strings.TrimSpace(v) + case []any, map[string]any: + if converted := cookieStringFromInputValue(v); converted != "" { + cookieStr = converted + } + } + } + } + + // ③ Strip a leading "Cookie: " prefix (case-insensitive). + if len(cookieStr) >= 7 && strings.EqualFold(cookieStr[:7], "cookie:") { + cookieStr = strings.TrimSpace(cookieStr[7:]) + } + + // ④ Collapse stray newlines and excess whitespace. + cookieStr = strings.Join(strings.Fields(cookieStr), " ") + return cookieStr +} + +// cookieStringFromInput parses a JSON string (array or object) describing +// browser cookies into a "name=value; name=value" cookie string, mirroring +// providers/adobe/_auth.py _cookie_string_from_input. +func cookieStringFromInput(raw string) string { + raw = strings.TrimSpace(raw) + var parsed any + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + return "" + } + return cookieStringFromInputValue(parsed) +} + +func cookieStringFromInputValue(raw any) string { + switch v := raw.(type) { + case string: + text := strings.TrimSpace(v) + if len(text) >= 7 && strings.EqualFold(text[:7], "cookie:") { + text = strings.TrimSpace(text[7:]) + } + return text + case []any: + parts := make([]string, 0, len(v)) + for _, item := range v { + m, ok := item.(map[string]any) + if !ok { + continue + } + name := strings.TrimSpace(stringValue(m["name"])) + value := stringValue(m["value"]) + if name != "" { + parts = append(parts, name+"="+value) + } + } + return strings.Join(parts, "; ") + case map[string]any: + if cookies, ok := v["cookies"].([]any); ok { + return cookieStringFromInputValue(cookies) + } + if inner, ok := v["cookie"]; ok { + switch inner.(type) { + case string, []any: + return cookieStringFromInputValue(inner) + } + } + return "" + default: + return "" + } +} + +func previewSecret(v string) string { + v = strings.TrimSpace(v) + if v == "" { + return "" + } + if len(v) <= 16 { + return "***" + } + return v[:6] + "…" + v[len(v)-4:] +} + +func normalizePool(pool string) string { + pool = strings.ToLower(strings.TrimSpace(pool)) + if _, ok := validTokenPools[pool]; ok { + return pool + } + return "" +} + +func normalizeTokenStatus(v string) string { + switch strings.ToLower(strings.TrimSpace(v)) { + case "active", "disabled", "quota", "pending": + return strings.ToLower(strings.TrimSpace(v)) + default: + return "" + } +} + +func newTokenID(pool string) string { + prefix := "TK" + if pool == "adobe" { + prefix = "AD" + } + if pool == "chatgpt" { + prefix = "OA" + } + if pool == "runway" { + prefix = "RW" + } + if pool == "leonardo" { + prefix = "LN" + } + if pool == "krea" { + prefix = "KR" + } + if pool == "imagine" { + prefix = "IM" + } + return prefix + randomUpper(10) +} + +func poolToType(pool string) string { + if mapped, ok := validTokenPools[pool]; ok { + return mapped + } + return pool +} + +func parseJWTEmailExpiry(token string) (string, *time.Time) { + parts := strings.Split(strings.TrimSpace(token), ".") + if len(parts) < 2 { + return "", nil + } + payload := parts[1] + raw, err := base64.RawURLEncoding.DecodeString(payload) + if err != nil { + return "", nil + } + var claims map[string]any + if err := json.Unmarshal(raw, &claims); err != nil { + return "", nil + } + email := strings.TrimSpace(stringValue(claims["email"])) + switch v := claims["exp"].(type) { + case float64: + t := time.Unix(int64(v), 0) + return email, &t + case json.Number: + n, err := v.Int64() + if err == nil { + t := time.Unix(n, 0) + return email, &t + } + } + return email, nil +} + +func jsonMapInt(m datatypes.JSONMap, key string) (int, bool) { + if m == nil { + return 0, false + } + v, ok := m[key] + if !ok || v == nil { + return 0, false + } + return intValue(v), true +} + +func jsonMapBool(m datatypes.JSONMap, key string) (bool, bool) { + if m == nil { + return false, false + } + v, ok := m[key] + if !ok || v == nil { + return false, false + } + switch x := v.(type) { + case bool: + return x, true + default: + return boolValueWithDefault(x, false), true + } +} + +func cloneJSONMap(in datatypes.JSONMap) datatypes.JSONMap { + out := datatypes.JSONMap{} + for k, v := range in { + out[k] = v + } + return out +} + +func timeOrNil(t *time.Time) any { + if t == nil { + return nil + } + return *t +} + +func unixOrNil(t *time.Time) any { + if t == nil { + return nil + } + return t.Unix() +} + +func emptyToNil(v string) any { + if strings.TrimSpace(v) == "" { + return nil + } + return strings.TrimSpace(v) +} + +func valueOrNil(ok bool, v any) any { + if !ok { + return nil + } + return v +} + +type ginToken struct { + ID string `json:"id"` + ValuePreview string `json:"value_preview"` + Status string `json:"status"` + Fails int `json:"fails"` + AddedAt *time.Time `json:"added_at"` +} + +var _ = fmt.Sprint diff --git a/backend/internal/service/user_generation.go b/backend/internal/service/user_generation.go new file mode 100644 index 0000000..945aad8 --- /dev/null +++ b/backend/internal/service/user_generation.go @@ -0,0 +1,200 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "strings" + + "backend/internal/model" + "backend/internal/repo" +) + +type UserGenerationService struct { + v1 *V1Service + events *repo.EventRepository + users *repo.UserRepository + models *repo.ModelRepository +} + +func NewUserGenerationService(v1 *V1Service, events *repo.EventRepository, users *repo.UserRepository, models *repo.ModelRepository) *UserGenerationService { + return &UserGenerationService{ + v1: v1, + events: events, + users: users, + models: models, + } +} + +type UserGenerateRequest struct { + Model string + Prompt string + Ratio string + Resolution string + Duration string + ReferenceImages []string +} + +func (s *UserGenerationService) Generate(ctx context.Context, user *model.User, in UserGenerateRequest) (map[string]any, error) { + if user == nil || strings.TrimSpace(user.ID) == "" { + return nil, errors.New("未登录或会话已过期") + } + pending, err := s.events.PendingByUser(ctx, user.ID, "user") + if err != nil { + return nil, err + } + if pending != nil { + return nil, errors.New("已有正在生成的任务,请稍候") + } + + modelItem, err := s.models.Get(ctx, strings.TrimSpace(in.Model)) + if err != nil { + return nil, ErrUnknownModel + } + + principal := &APIPrincipal{ + User: user, + TokenType: "session", + } + + switch modelItem.Type { + case "video": + resp, err := s.v1.prepareSessionVideo(ctx, principal, V1VideoRequest{ + Model: in.Model, + Prompt: in.Prompt, + Duration: in.Duration, + AspectRatio: in.Ratio, + Resolution: in.Resolution, + ReferenceImages: in.ReferenceImages, + }) + if err != nil { + return nil, err + } + return resp, nil + default: + resp, err := s.v1.prepareSessionImage(ctx, principal, V1ImageRequest{ + Model: in.Model, + Prompt: in.Prompt, + AspectRatio: in.Ratio, + Resolution: in.Resolution, + ReferenceImages: in.ReferenceImages, + }) + if err != nil { + return nil, err + } + return resp, nil + } +} + +func (s *UserGenerationService) AdminTest(ctx context.Context, user *model.User, in UserGenerateRequest) (map[string]any, error) { + if user == nil || strings.TrimSpace(user.ID) == "" { + return nil, errors.New("未登录或会话已过期") + } + modelItem, err := s.models.Get(ctx, strings.TrimSpace(in.Model)) + if err != nil { + return nil, ErrUnknownModel + } + principal := &APIPrincipal{ + User: user, + TokenType: "session", + } + switch modelItem.Type { + case "video": + return s.v1.prepareAdminTestVideo(ctx, principal, V1VideoRequest{ + Model: in.Model, + Prompt: in.Prompt, + Duration: in.Duration, + AspectRatio: in.Ratio, + Resolution: in.Resolution, + ReferenceImages: in.ReferenceImages, + }) + default: + return s.v1.prepareAdminTestImage(ctx, principal, V1ImageRequest{ + Model: in.Model, + Prompt: in.Prompt, + AspectRatio: in.Ratio, + Resolution: in.Resolution, + ReferenceImages: in.ReferenceImages, + }) + } +} + +func (s *UserGenerationService) MyJobs(ctx context.Context, user *model.User, source string) (map[string]any, error) { + if user == nil || strings.TrimSpace(user.ID) == "" { + return map[string]any{"pending": nil, "latest": nil}, nil + } + // source scopes the lookup: "user" = 画图台(默认),"admin" = 后台测试模型。 + // Both are this caller's own events; the admin-test poll uses "admin" so a + // gateway-timed-out (524) test can still recover its result. + if source != "admin" { + source = "user" + } + pending, err := s.events.PendingByUser(ctx, user.ID, source) + if err != nil { + return nil, err + } + latest, err := s.events.LatestByUser(ctx, user.ID, source) + if err != nil { + return nil, err + } + return map[string]any{ + "pending": shapeJobEvent(pending), + "latest": shapeJobEvent(latest), + }, nil +} + +func shapeJobEvent(item *model.EventLog) map[string]any { + if item == nil { + return nil + } + status := item.Status + url := "" + if strings.TrimSpace(item.File) != "" { + url = "/images/" + strings.ReplaceAll(strings.TrimSpace(item.File), "\\", "/") + } + return map[string]any{ + "id": item.ID, + "kind": item.Kind, + "model": item.Model, + "prompt": item.Prompt, + "ratio": item.Ratio, + "resolution": item.Resolution, + "duration": item.Duration, + "status": status, + "file": emptyOrNil(item.File), + "url": emptyOrNil(url), + "reference_urls": referenceURLs(item.RefFiles), + "elapsed_ms": item.ElapsedMS, + "error": emptyOrNil(item.Error), + "charged": item.Cost, + "cost": item.Cost, + "ts": item.TS.Unix(), + } +} + +// referenceURLs turns the stored relative reference paths into /images URLs so +// the playground can re-display the uploaded reference image(s) after a reload. +func referenceURLs(raw []byte) []string { + if len(raw) == 0 { + return []string{} + } + var paths []string + if err := json.Unmarshal(raw, &paths); err != nil { + return []string{} + } + out := make([]string, 0, len(paths)) + for _, p := range paths { + p = strings.ReplaceAll(strings.TrimSpace(p), "\\", "/") + if p != "" { + out = append(out, "/images/"+p) + } + } + return out +} + +func emptyOrNil(v string) any { + if strings.TrimSpace(v) == "" { + return nil + } + return v +} diff --git a/backend/internal/service/v1.go b/backend/internal/service/v1.go new file mode 100644 index 0000000..50982ea --- /dev/null +++ b/backend/internal/service/v1.go @@ -0,0 +1,2384 @@ +package service + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "path/filepath" + "sort" + "strings" + "sync" + "sync/atomic" + "time" + + "backend/internal/config" + "backend/internal/model" + "backend/internal/provider/adobe" + "backend/internal/provider/chatgpt" + "backend/internal/provider/imagine" + "backend/internal/provider/krea" + "backend/internal/provider/leonardo" + "backend/internal/provider/runway" + "backend/internal/repo" + "backend/internal/storage" + "gorm.io/gorm" +) + +var ( + ErrMissingAPIKey = errors.New("missing api key") + ErrInvalidAPIKey = errors.New("invalid api key") + ErrUnknownModel = errors.New("unknown model") + ErrUnsupportedParams = errors.New("unsupported or unpriced parameters for this model") + ErrInsufficientFunds = errors.New("insufficient credits") + ErrGenerationPending = errors.New("generation executor not implemented yet") + ErrProviderAuth = errors.New("provider token invalid or expired") + ErrNoProviderAccount = errors.New("no provider account available, please ask an admin to configure one") + ErrProviderQuota = errors.New("provider quota exhausted") + ErrProviderTemporary = errors.New("provider temporary unavailable") + ErrProviderExecution = errors.New("provider request failed") + ErrProviderUnsupported = errors.New("provider not implemented") + ErrReferenceTooLarge = errors.New("reference image too large") + // ErrConcurrencyFull — every eligible account is busy (each account runs at + // most ONE generation at a time). English message: surfaced to API / UI. + ErrConcurrencyFull = errors.New("all accounts are busy (1 concurrent job each), please try again shortly") + // ErrVideoJobNotFound / ErrVideoNotReady — /v1/videos async job lookups. + ErrVideoJobNotFound = errors.New("video job not found") + ErrVideoNotReady = errors.New("video is not ready yet") +) + +// maxReferenceImageBytes bounds a single decoded reference image. 8 MB +// comfortably covers real photos/screenshots; anything larger is almost +// certainly abuse or a mistake. Mirrors Python core/refs.py. +const maxReferenceImageBytes = 8 * 1024 * 1024 + +type V1Service struct { + cfg *config.Config + models *repo.ModelRepository + users *repo.UserRepository + events *repo.EventRepository + tokens *repo.TokenRepository + settings *repo.SiteSettingRepository + adobe *adobe.Client + chatgpt *chatgpt.Client + runway *runway.Client + leonardo *leonardo.Client + krea *krea.Client + imagine *imagine.Client + store *storage.Client + // refresh re-mints an Adobe access token from its cookie when a request hits a + // 401 mid-flight (set via SetRefresh — wired after construction to avoid an + // init cycle). nil for deployments without cookie refresh. + refresh *RefreshProfileService + + // tokenCursors holds one strict round-robin cursor per pool (key: pool name, + // value: *uint64). Each pick advances the pool's cursor by one so accounts + // are used in a fixed, even rotation (acct1→acct2→acct3→acct1…) independent + // of fails/last_used. The atomic counter also serializes concurrent picks so + // two simultaneous requests never start on the same account. + tokenCursors sync.Map + + // inflight maps an in-progress event ID → the cancel func of its generation + // work context, so the maintenance sweep can stop a stuck generation the + // moment it abandons the row (instead of letting an orphaned goroutine run on + // for minutes and surface a late "success" on an already-abandoned event). + inflight *InflightRegistry + + // gate enforces 1 concurrent generation PER account: a scheduler skips any + // account that's currently busy, and fails with ErrConcurrencyFull when every + // eligible account is occupied. In-memory (single process). + gate accountGate +} + +// accountGate is a 1-slot-per-account in-flight gate. tryAcquire wins only if the +// account isn't already running a generation; release frees it when done. +type accountGate struct{ m sync.Map } // accountID -> struct{} held while busy + +func (g *accountGate) tryAcquire(id string) bool { + if id == "" { + return true + } + _, loaded := g.m.LoadOrStore(id, struct{}{}) + return !loaded +} + +func (g *accountGate) release(id string) { + if id != "" { + g.m.Delete(id) + } +} + +// InflightRegistry tracks the cancel func of every in-progress generation by +// event ID. The generation registers on start and removes on finish; the +// maintenance sweep calls Cancel when it gives up on (abandons) an event. +type InflightRegistry struct { + m sync.Map // eventID -> context.CancelFunc +} + +func (r *InflightRegistry) Add(eventID string, cancel context.CancelFunc) { + if eventID != "" { + r.m.Store(eventID, cancel) + } +} + +// Done deregisters an event (called on normal completion). +func (r *InflightRegistry) Done(eventID string) { r.m.Delete(eventID) } + +// Cancel stops an in-flight generation by event ID. Returns true if one was +// running and got cancelled. No-op (false) if it already finished. +func (r *InflightRegistry) Cancel(eventID string) bool { + if v, ok := r.m.LoadAndDelete(eventID); ok { + v.(context.CancelFunc)() + return true + } + return false +} + +type APIPrincipal struct { + User *model.User + TokenType string +} + +type V1ImageRequest struct { + Model string + Prompt string + Size string + // Quality is OpenAI's image quality (low|medium|high|auto). For our tiered + // models it selects the resolution (low→1K, medium→2K, high→4K, auto→default), + // clamped to whatever tiers the model actually prices. Only used when + // Resolution is left blank (the strict /v1 OpenAI path); the playground passes + // Resolution directly and ignores this. + Quality string + AspectRatio string + Resolution string + N int + ReferenceImages []string + // BaseURL is the scheme+host of the inbound request (e.g. "https://host"), + // used to build absolute, directly-downloadable output URLs. Empty falls + // back to a relative "/images/..." path. + BaseURL string +} + +type V1VideoRequest struct { + Model string + Prompt string + Duration string + AspectRatio string + Resolution string + ReferenceImages []string + // BaseURL — see V1ImageRequest.BaseURL. + BaseURL string +} + +func NewV1Service(cfg *config.Config, models *repo.ModelRepository, users *repo.UserRepository, events *repo.EventRepository, tokens *repo.TokenRepository, settings *repo.SiteSettingRepository, adobeClient *adobe.Client, chatGPTClient *chatgpt.Client, runwayClient *runway.Client, leonardoClient *leonardo.Client, kreaClient *krea.Client, imagineClient *imagine.Client, store *storage.Client) *V1Service { + return &V1Service{ + cfg: cfg, + models: models, + users: users, + events: events, + tokens: tokens, + settings: settings, + adobe: adobeClient, + chatgpt: chatGPTClient, + runway: runwayClient, + leonardo: leonardoClient, + krea: kreaClient, + imagine: imagineClient, + store: store, + inflight: &InflightRegistry{}, + } +} + +// Inflight exposes the registry so the maintenance sweep can cancel a stuck +// generation when it abandons that event. +func (s *V1Service) Inflight() *InflightRegistry { return s.inflight } + +// SetRefresh wires the Adobe cookie-refresh service in after construction +// (RefreshProfileService is built later in bootstrap, so it can't be a ctor arg +// without reordering). Enables refresh-then-retry on a mid-request 401. +func (s *V1Service) SetRefresh(r *RefreshProfileService) { s.refresh = r } + +// refreshAdobeToken re-mints an Adobe account's access token from its cookie +// (RefreshNow) and returns the updated row. Used to retry a 401 with a fresh +// token instead of replaying the stale one. Returns false if refresh is +// unavailable or the cookie can no longer mint a token (genuinely dead). +func (s *V1Service) refreshAdobeToken(ctx context.Context, tokenID string) (model.TokenAccount, bool) { + if s.refresh == nil { + return model.TokenAccount{}, false + } + if err := s.refresh.RefreshNow(ctx, tokenID); err != nil { + return model.TokenAccount{}, false + } + t, err := s.tokens.Get(ctx, "adobe", tokenID) + if err != nil || t == nil { + return model.TokenAccount{}, false + } + return *t, true +} + +func (s *V1Service) Authenticate(ctx context.Context, authHeader string) (*APIPrincipal, error) { + token := ParseBearer(authHeader) + if token == "" { + return nil, ErrMissingAPIKey + } + + // Only per-user API keys (hashed in the DB) authenticate to /v1. The old + // global/shared API_KEY backdoor has been removed. + user, err := s.users.GetByAPIKeyHash(ctx, HashAPIKey(token)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrInvalidAPIKey + } + return nil, err + } + if user.Status != "active" { + return nil, ErrInvalidAPIKey + } + _ = s.users.TouchAPIKeyUsage(ctx, HashAPIKey(token)) + return &APIPrincipal{ + User: user, + TokenType: "user", + }, nil +} + +func (s *V1Service) ListModels(ctx context.Context) ([]map[string]any, error) { + items, err := s.models.List(ctx) + if err != nil { + return nil, err + } + now := time.Now().Unix() + out := make([]map[string]any, 0, len(items)) + for _, item := range items { + if !item.Enabled { + continue + } + out = append(out, map[string]any{ + "id": item.ID, + "object": "model", + "created": now, + "owned_by": item.Provider, + "kind": item.Type, + "supported_ratios": repo.JSONStrings(item.Ratios), + "supported_resolutions": repo.JSONStrings(item.Resolutions), + }) + } + return out, nil +} + +func (s *V1Service) PrepareImageRequest(ctx context.Context, principal *APIPrincipal, in V1ImageRequest) (map[string]any, error) { + return s.prepareImageExecution(ctx, principal, in, "v1", true) +} + +func (s *V1Service) prepareSessionImage(ctx context.Context, principal *APIPrincipal, in V1ImageRequest) (map[string]any, error) { + return s.prepareImageExecution(ctx, principal, in, "user", true) +} + +func (s *V1Service) prepareAdminTestImage(ctx context.Context, principal *APIPrincipal, in V1ImageRequest) (map[string]any, error) { + return s.prepareImageExecution(ctx, principal, in, "admin", false) +} + +func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPrincipal, in V1ImageRequest, source string, charge bool) (map[string]any, error) { + // Detach the whole execution from the request lifecycle. The frontend tracks + // progress by polling /jobs/mine, so a client disconnect — or an nginx/CDN + // gateway timeout on the slow synchronous response — must NOT cancel an + // in-flight generation. Binding to the request ctx meant a cancelled request + // (a) spun uselessly in the upstream poll until its 180s timeout and + // (b) silently dropped the refund + final status write, leaving the row stuck + // pending until the maintenance sweep mislabeled it "abandoned". + // + // `ctx` (WithoutCancel) is durable and used for ALL bookkeeping (status / + // refund / cleanup) so those always land. `genCtx` is the cancellable WORK + // context: an 8-min backstop, AND registered in s.inflight so the maintenance + // sweep can cancel it the instant it abandons the row — stopping a stuck + // generation from running on for minutes and surfacing a late "success" on an + // already-abandoned event. + ctx = context.WithoutCancel(ctx) + genCtx, cancel := context.WithTimeout(ctx, 8*time.Minute) + defer cancel() + + modelItem, resolution, aspectRatio, price, err := s.prepareImage(ctx, principal, in, charge) + if err != nil { + return nil, err + } + refCount := len(in.ReferenceImages) + refFiles := s.saveReferenceImages(ctx, principal, in.ReferenceImages) + // API-key (source "v1") requests don't persist the output: we return the image + // as base64 inline (OpenAI gpt-image-1 also returns only b64_json) and never + // upload to RustFS, so there's no URL. The event is still logged (empty file) + // for usage; the customer logs page hides source="v1" rows. + noStore := source == "v1" + var fileURL, relativePath string + if !noStore { + fileURL, relativePath = s.allocateOutput(principal, "png", in.BaseURL) + } + eventID, err := s.logPendingEvent(ctx, "image", modelItem, principal, in.Prompt, aspectRatio, resolution, "", refCount, price, relativePath, source, refFiles) + if err != nil { + s.cleanupReferenceImages(ctx, "", refFiles) + return nil, err + } + // Register so the maintenance sweep can cancel this generation if it abandons + // the row; deregister on return. + s.inflight.Add(eventID, cancel) + defer s.inflight.Done(eventID) + // Reference images are transient — remove them (and clear the event's ref + // paths) once this attempt finishes, whether it succeeds OR fails. + defer s.cleanupReferenceImages(ctx, eventID, refFiles) + startedAt := time.Now() + + var imageBytes []byte + switch modelItem.Provider { + case "adobe": + b, execErr := s.generateAdobeImage(genCtx, eventID, modelItem, in, aspectRatio, resolution) + if execErr != nil { + _ = s.refundIfNeeded(ctx, principal, eventID, price) + _ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0) + switch { + case errors.Is(execErr, adobe.ErrAuth): + return nil, ErrProviderAuth + case errors.Is(execErr, adobe.ErrQuotaExhausted): + return nil, ErrProviderQuota + case errors.Is(execErr, adobe.ErrTemporaryUpstream): + return nil, ErrProviderTemporary + default: + return nil, fmt.Errorf("%w: %v", ErrProviderExecution, execErr) + } + } + imageBytes = b + case "chatgpt": + b, execErr := s.generateChatGPTImage(genCtx, eventID, modelItem, in, aspectRatio, resolution) + if execErr != nil { + _ = s.refundIfNeeded(ctx, principal, eventID, price) + _ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0) + switch { + case errors.Is(execErr, chatgpt.ErrAuth): + return nil, ErrProviderAuth + case errors.Is(execErr, chatgpt.ErrQuotaExhausted): + return nil, ErrProviderQuota + case errors.Is(execErr, chatgpt.ErrTemporaryUpstream): + return nil, ErrProviderTemporary + default: + return nil, fmt.Errorf("%w: %v", ErrProviderExecution, execErr) + } + } + imageBytes = b + case "leonardo": + b, execErr := s.generateLeonardoImage(genCtx, eventID, modelItem, in, aspectRatio, resolution) + if execErr != nil { + _ = s.refundIfNeeded(ctx, principal, eventID, price) + _ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0) + switch { + case errors.Is(execErr, leonardo.ErrAuth): + return nil, ErrProviderAuth + case errors.Is(execErr, leonardo.ErrQuotaExhausted): + return nil, ErrProviderQuota + case errors.Is(execErr, leonardo.ErrTemporaryUpstream): + return nil, ErrProviderTemporary + default: + return nil, fmt.Errorf("%w: %v", ErrProviderExecution, execErr) + } + } + imageBytes = b + case "krea": + b, execErr := s.generateKreaImage(genCtx, eventID, modelItem, in, aspectRatio, resolution) + if execErr != nil { + _ = s.refundIfNeeded(ctx, principal, eventID, price) + _ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0) + switch { + case errors.Is(execErr, krea.ErrAuth): + return nil, ErrProviderAuth + case errors.Is(execErr, krea.ErrQuotaExhausted): + return nil, ErrProviderQuota + case errors.Is(execErr, krea.ErrTemporaryUpstream): + return nil, ErrProviderTemporary + default: + return nil, fmt.Errorf("%w: %v", ErrProviderExecution, execErr) + } + } + imageBytes = b + case "imagine": + b, execErr := s.generateImagineImage(genCtx, eventID, modelItem, in, aspectRatio, resolution) + if execErr != nil { + _ = s.refundIfNeeded(ctx, principal, eventID, price) + _ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0) + switch { + case errors.Is(execErr, imagine.ErrAuth): + return nil, ErrProviderAuth + case errors.Is(execErr, imagine.ErrQuotaExhausted): + return nil, ErrProviderQuota + case errors.Is(execErr, imagine.ErrTemporaryUpstream): + return nil, ErrProviderTemporary + default: + return nil, fmt.Errorf("%w: %v", ErrProviderExecution, execErr) + } + } + imageBytes = b + default: + _ = s.refundIfNeeded(ctx, principal, eventID, price) + _ = s.events.UpdateStatus(ctx, eventID, "failed", "provider not implemented", 0) + return nil, fmt.Errorf("%w: %s", ErrProviderUnsupported, modelItem.Provider) + } + if !noStore { + // Upload to RustFS. On failure the generation fails and credits are + // refunded — we never fall back to local disk. + if err := s.store.Put(genCtx, relativePath, imageBytes, "image/png"); err != nil { + _ = s.refundIfNeeded(ctx, principal, eventID, price) + _ = s.events.UpdateStatus(ctx, eventID, "failed", "storage upload failed: "+err.Error(), 0) + return nil, fmt.Errorf("%w: %v", ErrProviderExecution, err) + } + } + elapsedMS := int(time.Since(startedAt).Milliseconds()) + if err := s.events.UpdateStatus(ctx, eventID, "success", "", elapsedMS); err != nil { + return nil, err + } + if charge { + _ = s.maybeGrantInviteReward(ctx, principal) + } + if noStore { + b64 := base64.StdEncoding.EncodeToString(imageBytes) + return map[string]any{ + "created": time.Now().Unix(), + "data": []map[string]any{{"b64_json": b64}}, + "model": modelItem.ID, + "provider": modelItem.Provider, + "kind": "image", + "b64_json": b64, + "elapsed_ms": elapsedMS, + "charged": price, + "credits": principalCredits(principal), + }, nil + } + return map[string]any{ + "created": time.Now().Unix(), + "data": []map[string]any{{"url": fileURL, "b64_json": nil}}, + "model": modelItem.ID, + "provider": modelItem.Provider, + "kind": "image", + "url": fileURL, + "elapsed_ms": elapsedMS, + "charged": price, + "credits": principalCredits(principal), + }, nil +} + +func (s *V1Service) PrepareVideoRequest(ctx context.Context, principal *APIPrincipal, in V1VideoRequest) (map[string]any, error) { + return s.prepareVideoExecution(ctx, principal, in, "v1", true) +} + +func (s *V1Service) prepareSessionVideo(ctx context.Context, principal *APIPrincipal, in V1VideoRequest) (map[string]any, error) { + return s.prepareVideoExecution(ctx, principal, in, "user", true) +} + +func (s *V1Service) prepareAdminTestVideo(ctx context.Context, principal *APIPrincipal, in V1VideoRequest) (map[string]any, error) { + return s.prepareVideoExecution(ctx, principal, in, "admin", false) +} + +func (s *V1Service) prepareVideoExecution(ctx context.Context, principal *APIPrincipal, in V1VideoRequest, source string, charge bool) (map[string]any, error) { + // Detach from the request lifecycle — see prepareImageExecution. `ctx` + // (WithoutCancel) carries all bookkeeping; `genCtx` is the cancellable work + // context (12-min backstop — video polls up to 10 min — and registered so the + // maintenance sweep can cancel a stuck render when it abandons the row). + ctx = context.WithoutCancel(ctx) + genCtx, cancel := context.WithTimeout(ctx, 12*time.Minute) + defer cancel() + + modelItem, resolution, aspectRatio, duration, price, err := s.prepareVideo(ctx, principal, in, charge) + if err != nil { + return nil, err + } + refCount := len(in.ReferenceImages) + refFiles := s.saveReferenceImages(ctx, principal, in.ReferenceImages) + // API-key (source "v1") requests return base64 inline and never persist a + // file — see prepareImageExecution for the rationale. + noStore := source == "v1" + var fileURL, relativePath string + if !noStore { + fileURL, relativePath = s.allocateOutput(principal, "mp4", in.BaseURL) + } + eventID, err := s.logPendingEvent(ctx, "video", modelItem, principal, in.Prompt, aspectRatio, resolution, duration, refCount, price, relativePath, source, refFiles) + if err != nil { + s.cleanupReferenceImages(ctx, "", refFiles) + return nil, err + } + // Register so the maintenance sweep can cancel this render if it abandons the + // row; deregister on return. + s.inflight.Add(eventID, cancel) + defer s.inflight.Done(eventID) + // Frame / reference images are transient — clean up on success OR failure. + defer s.cleanupReferenceImages(ctx, eventID, refFiles) + startedAt := time.Now() + + var videoBytes []byte + var execErr error + switch modelItem.Provider { + case "adobe": + videoBytes, _, execErr = s.generateAdobeVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), true) + case "runway": + videoBytes, _, execErr = s.generateRunwayVideo(genCtx, eventID, modelItem, in, aspectRatio, parseDurationSeconds(duration), true) + default: + _ = s.refundIfNeeded(ctx, principal, eventID, price) + _ = s.events.UpdateStatus(ctx, eventID, "failed", "provider not implemented", 0) + return nil, fmt.Errorf("%w: %s", ErrProviderUnsupported, modelItem.Provider) + } + if execErr != nil { + _ = s.refundIfNeeded(ctx, principal, eventID, price) + _ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0) + switch { + case errors.Is(execErr, ErrNoProviderAccount): + return nil, ErrNoProviderAccount + case errors.Is(execErr, adobe.ErrAuth), errors.Is(execErr, runway.ErrAuth): + return nil, ErrProviderAuth + case errors.Is(execErr, adobe.ErrQuotaExhausted), errors.Is(execErr, runway.ErrQuotaExhausted): + return nil, ErrProviderQuota + case errors.Is(execErr, adobe.ErrTemporaryUpstream), errors.Is(execErr, runway.ErrTemporaryUpstream): + return nil, ErrProviderTemporary + default: + return nil, fmt.Errorf("%w: %v", ErrProviderExecution, execErr) + } + } + if !noStore { + if err := s.store.Put(genCtx, relativePath, videoBytes, "video/mp4"); err != nil { + _ = s.refundIfNeeded(ctx, principal, eventID, price) + _ = s.events.UpdateStatus(ctx, eventID, "failed", "storage upload failed: "+err.Error(), 0) + return nil, fmt.Errorf("%w: %v", ErrProviderExecution, err) + } + } + elapsedMS := int(time.Since(startedAt).Milliseconds()) + if err := s.events.UpdateStatus(ctx, eventID, "success", "", elapsedMS); err != nil { + return nil, err + } + if charge { + _ = s.maybeGrantInviteReward(ctx, principal) + } + if noStore { + b64 := base64.StdEncoding.EncodeToString(videoBytes) + return map[string]any{ + "created": time.Now().Unix(), + "data": []map[string]any{{"b64_json": b64}}, + "model": modelItem.ID, + "provider": modelItem.Provider, + "kind": "video", + "b64_json": b64, + "elapsed_ms": elapsedMS, + "charged": price, + "credits": principalCredits(principal), + }, nil + } + return map[string]any{ + "created": time.Now().Unix(), + "data": []map[string]any{{"url": fileURL}}, + "model": modelItem.ID, + "provider": modelItem.Provider, + "kind": "video", + "url": fileURL, + "elapsed_ms": elapsedMS, + "charged": price, + "credits": principalCredits(principal), + }, nil +} + +// ===== /v1/videos — OpenAI Sora-style async jobs ===== +// POST /v1/videos charges + creates a pending event and renders in the +// background; the render captures only the UPSTREAM video URL (no download, no +// RustFS). GET /v1/videos/{id} polls status; /content proxies the upstream URL. + +// StartVideoJob validates+charges, creates the job event, kicks the render off in +// the background, and returns the OpenAI video object (status "queued"). +func (s *V1Service) StartVideoJob(ctx context.Context, principal *APIPrincipal, in V1VideoRequest) (map[string]any, error) { + ctx = context.WithoutCancel(ctx) + modelItem, resolution, aspectRatio, duration, price, err := s.prepareVideo(ctx, principal, in, true) + if err != nil { + return nil, err + } + refFiles := s.saveReferenceImages(ctx, principal, in.ReferenceImages) + // Source "v1": no output file is allocated — the result is the upstream URL, + // stored on the event when the render completes. + eventID, err := s.logPendingEvent(ctx, "video", modelItem, principal, in.Prompt, aspectRatio, resolution, duration, len(in.ReferenceImages), price, "", "v1", refFiles) + if err != nil { + s.cleanupReferenceImages(ctx, "", refFiles) + return nil, err + } + go s.runVideoJob(ctx, principal, in, modelItem, eventID, aspectRatio, resolution, duration, price, refFiles) + return videoJobObject(eventID, modelItem.ID, "queued", 0, duration, sizeFromRatioRes(aspectRatio, resolution), time.Now().Unix(), 0, ""), nil +} + +// runVideoJob renders the clip in the background, capturing the upstream URL +// (downloadResult=false → no bytes, no RustFS) and storing it on the event. +func (s *V1Service) runVideoJob(ctx context.Context, principal *APIPrincipal, in V1VideoRequest, modelItem *model.ModelConfig, eventID, aspectRatio, resolution, duration string, price float64, refFiles []string) { + genCtx, cancel := context.WithTimeout(ctx, 12*time.Minute) + defer cancel() + s.inflight.Add(eventID, cancel) + defer s.inflight.Done(eventID) + defer s.cleanupReferenceImages(ctx, eventID, refFiles) + startedAt := time.Now() + + var videoURL string + var execErr error + switch modelItem.Provider { + case "adobe": + _, videoURL, execErr = s.generateAdobeVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), false) + case "runway": + _, videoURL, execErr = s.generateRunwayVideo(genCtx, eventID, modelItem, in, aspectRatio, parseDurationSeconds(duration), false) + default: + _ = s.refundIfNeeded(ctx, principal, eventID, price) + _ = s.events.UpdateStatus(ctx, eventID, "failed", "provider not implemented", 0) + return + } + if execErr != nil { + _ = s.refundIfNeeded(ctx, principal, eventID, price) + _ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0) + return + } + if strings.TrimSpace(videoURL) == "" { + _ = s.refundIfNeeded(ctx, principal, eventID, price) + _ = s.events.UpdateStatus(ctx, eventID, "failed", "upstream returned no video url", 0) + return + } + // Store the upstream URL as the event's "file"; /content proxies it. + if err := s.events.MarkVideoReady(ctx, eventID, videoURL, int(time.Since(startedAt).Milliseconds())); err != nil { + return + } + _ = s.maybeGrantInviteReward(ctx, principal) +} + +// VideoJob returns the OpenAI video object for a job, scoped to the caller. +func (s *V1Service) VideoJob(ctx context.Context, principal *APIPrincipal, id string) (map[string]any, error) { + ev, err := s.videoEventForUser(ctx, principal, id) + if err != nil { + return nil, err + } + status, progress := videoJobStatus(ev) + completedAt := int64(0) + if ev.Status == "success" || ev.Status == "failed" { + completedAt = ev.UpdatedAt.Unix() + } + errMsg := "" + if ev.Status == "failed" { + errMsg = ev.Error + } + return videoJobObject(ev.ID, ev.Model, status, progress, ev.Duration, sizeFromRatioRes(ev.Ratio, ev.Resolution), ev.TS.Unix(), completedAt, errMsg), nil +} + +// OpenVideoContent streams a completed job's video by proxying the stored +// upstream URL (downloaded on demand — never persisted). +func (s *V1Service) OpenVideoContent(ctx context.Context, principal *APIPrincipal, id string) (io.ReadCloser, string, error) { + ev, err := s.videoEventForUser(ctx, principal, id) + if err != nil { + return nil, "", err + } + if ev.Status != "success" || strings.TrimSpace(ev.File) == "" { + return nil, "", ErrVideoNotReady + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, ev.File, nil) + if err != nil { + return nil, "", err + } + resp, err := (&http.Client{Timeout: 5 * time.Minute}).Do(req) + if err != nil { + return nil, "", fmt.Errorf("%w: fetch upstream video: %v", ErrProviderTemporary, err) + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return nil, "", fmt.Errorf("%w: upstream video status %d", ErrProviderTemporary, resp.StatusCode) + } + ct := strings.TrimSpace(resp.Header.Get("Content-Type")) + if ct == "" { + ct = "video/mp4" + } + return resp.Body, ct, nil +} + +func (s *V1Service) videoEventForUser(ctx context.Context, principal *APIPrincipal, id string) (*model.EventLog, error) { + ev, err := s.events.GetByID(ctx, strings.TrimSpace(id)) + if err != nil { + return nil, err + } + if ev == nil || ev.Kind != "video" { + return nil, ErrVideoJobNotFound + } + if principal != nil && principal.User != nil && ev.UserID != principal.User.ID { + return nil, ErrVideoJobNotFound + } + return ev, nil +} + +// videoJobStatus maps our event status → OpenAI's (queued|in_progress|completed| +// failed) plus a coarse progress. +func videoJobStatus(ev *model.EventLog) (string, int) { + switch ev.Status { + case "success": + return "completed", 100 + case "failed": + return "failed", 0 + default: + if strings.TrimSpace(ev.AccountID) != "" { + return "in_progress", 50 + } + return "queued", 0 + } +} + +func videoJobObject(id, modelID, status string, progress int, seconds, size string, createdAt, completedAt int64, errMsg string) map[string]any { + obj := map[string]any{ + "id": id, + "object": "video", + "model": modelID, + "status": status, + "progress": progress, + "created_at": createdAt, + "size": size, + "seconds": strings.TrimSuffix(strings.TrimSpace(seconds), "s"), + } + if completedAt > 0 { + obj["completed_at"] = completedAt + } else { + obj["completed_at"] = nil + } + if errMsg != "" { + obj["error"] = map[string]any{"message": errMsg} + } else { + obj["error"] = nil + } + return obj +} + +// sizeFromRatioRes reconstructs an OpenAI-style "WxH" label from our stored ratio +// + resolution tier (best-effort; only for display in the job object). +func sizeFromRatioRes(ratio, resolution string) string { + long := 720 + res := strings.ToUpper(resolution) + switch { + case strings.Contains(res, "1080") || strings.Contains(res, "2K"): + long = 1080 + case strings.Contains(res, "4K") || strings.Contains(res, "2160"): + long = 2160 + } + w, h := long, long + switch strings.TrimSpace(ratio) { + case "16:9": + w, h = long, long*9/16 + case "9:16": + w, h = long*9/16, long + case "4:3": + w, h = long, long*3/4 + case "3:4": + w, h = long*3/4, long + case "1:1": + w, h = long, long + default: + w, h = long, long*9/16 + } + return fmt.Sprintf("%dx%d", w, h) +} + +// hasActiveProviderToken reports whether the provider pool holds at least one +// usable token for this kind of generation — mirrors the selection filter in +// the generate* paths. Used to fail fast (before charging / creating a job) +// with a clear "no account" error instead of dialing upstream with no token. +func (s *V1Service) hasActiveProviderToken(ctx context.Context, provider, kind string) (bool, error) { + items, err := s.tokens.ListByPool(ctx, provider) + if err != nil { + return false, err + } + for _, item := range items { + if item.Status != "active" || item.Dead || strings.TrimSpace(item.Value) == "" { + continue + } + if provider == "adobe" { + if kind == "video" && item.VideoLimited { + continue + } + if kind == "image" && item.ImageLimited { + continue + } + } + return true, nil + } + return false, nil +} + +func (s *V1Service) prepareImage(ctx context.Context, principal *APIPrincipal, in V1ImageRequest, charge bool) (*model.ModelConfig, string, string, float64, error) { + modelID := strings.TrimSpace(in.Model) + prompt := strings.TrimSpace(in.Prompt) + if modelID == "" || prompt == "" { + return nil, "", "", 0, errors.New("model and prompt required") + } + modelItem, err := s.models.Get(ctx, modelID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, "", "", 0, ErrUnknownModel + } + return nil, "", "", 0, err + } + if !modelItem.Enabled || modelItem.Type != "image" { + return nil, "", "", 0, ErrUnknownModel + } + // Fail fast before charging if the provider has no usable account. + if ok, err := s.hasActiveProviderToken(ctx, modelItem.Provider, "image"); err != nil { + return nil, "", "", 0, err + } else if !ok { + return nil, "", "", 0, ErrNoProviderAccount + } + refLimit := 0 + if modelItem.ImageToImage { + refLimit = modelItem.MaxReferenceImages + if refLimit <= 0 { + refLimit = 1 + } + } + if len(in.ReferenceImages) > refLimit { + return nil, "", "", 0, errors.New("too many reference images") + } + // Reject oversized reference images before charging (all providers, all paths). + if err := ensureReferenceSizes(in.ReferenceImages); err != nil { + return nil, "", "", 0, err + } + aspectRatio, resolution := parseImageSize(in.Size, in.AspectRatio, in.Resolution) + // Strict OpenAI path (/v1) sends no resolution — pick the tier from `quality` + // (low/medium/high/auto → 1K/2K/4K/default), clamped to the model's tiers. + if strings.TrimSpace(in.Resolution) == "" { + resolution = resolutionForQuality(modelItem, in.Quality) + } + // parseImageSize defaults a blank resolution to "2K" (OpenAI-size parity). + // For a model that doesn't price that tier — e.g. gpt-image-2 is 1K-only — + // fall back to its first supported tier so a missing/stale resolution from + // the client doesn't get rejected as "unsupported or unpriced". + if _, ok := modelPrice(modelItem, "image", resolution, "", false); !ok { + if fb := firstPricedResolution(modelItem); fb != "" { + resolution = fb + } + } + price, err := s.chargeForModel(ctx, principal, modelItem, "image", resolution, "", charge) + if err != nil { + return nil, "", "", 0, err + } + return modelItem, resolution, aspectRatio, price, nil +} + +func (s *V1Service) prepareVideo(ctx context.Context, principal *APIPrincipal, in V1VideoRequest, charge bool) (*model.ModelConfig, string, string, string, float64, error) { + modelID := strings.TrimSpace(in.Model) + prompt := strings.TrimSpace(in.Prompt) + duration := strings.TrimSpace(in.Duration) + if modelID == "" || prompt == "" { + return nil, "", "", "", 0, errors.New("model and prompt required") + } + if duration == "" { + return nil, "", "", "", 0, errors.New("duration required") + } + modelItem, err := s.models.Get(ctx, modelID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, "", "", "", 0, ErrUnknownModel + } + return nil, "", "", "", 0, err + } + if !modelItem.Enabled || modelItem.Type != "video" { + return nil, "", "", "", 0, ErrUnknownModel + } + // Fail fast before charging if the provider has no usable account. + if ok, err := s.hasActiveProviderToken(ctx, modelItem.Provider, "video"); err != nil { + return nil, "", "", "", 0, err + } else if !ok { + return nil, "", "", "", 0, ErrNoProviderAccount + } + refLimit := modelItem.MaxReferenceImages + if refLimit <= 0 { + refLimit = 10 + } + if len(in.ReferenceImages) > refLimit { + return nil, "", "", "", 0, errors.New("too many reference images") + } + // Reject oversized reference images before charging (all providers, all paths). + if err := ensureReferenceSizes(in.ReferenceImages); err != nil { + return nil, "", "", "", 0, err + } + // Runway i2v strictly requires exactly one first-frame image. Enforce it here, + // BEFORE charging, so a missing/extra frame fails fast instead of charge → + // upstream reject → refund. generateRunwayVideo keeps its own guard too. + if modelItem.Provider == "runway" { + n := 0 + for _, r := range in.ReferenceImages { + if strings.TrimSpace(r) != "" { + n++ + } + } + if n != 1 { + return nil, "", "", "", 0, errors.New("runway 图生视频需要且仅需 1 张首帧图") + } + } + aspectRatio := strings.TrimSpace(strings.ReplaceAll(in.AspectRatio, "x", ":")) + if aspectRatio == "" { + aspectRatio = "16:9" + } + resolution := strings.TrimSpace(in.Resolution) + if resolution == "" { + resolution = "720p" + } + price, err := s.chargeForModel(ctx, principal, modelItem, "video", resolution, duration, charge) + if err != nil { + return nil, "", "", "", 0, err + } + return modelItem, resolution, aspectRatio, duration, price, nil +} + +func (s *V1Service) chargeForModel(ctx context.Context, principal *APIPrincipal, modelItem *model.ModelConfig, kind, resolution, duration string, charge bool) (float64, error) { + // 代理用户走代理价(某档未设代理价则回退普通价)。principal.User 即将被扣费的 + // 用户,无论画图台还是 key 调用都从这里取,所以一处即覆盖所有路径。 + agent := principal != nil && principal.User != nil && principal.User.Role == "agent" + price, ok := modelPrice(modelItem, kind, resolution, duration, agent) + if !ok { + return 0, ErrUnsupportedParams + } + if !charge || principal == nil || principal.User == nil { + return 0, nil + } + updated, debited, err := s.users.TryDebitCredits(ctx, principal.User.ID, price) + if err != nil { + return 0, err + } + if !debited { + if updated != nil { + principal.User = updated + } + return 0, ErrInsufficientFunds + } + principal.User = updated + return price, nil +} + +func (s *V1Service) userDir(principal *APIPrincipal) string { + if principal == nil { + return "anon" + } + return OwnerDir(principal.User) +} + +// OwnerDir is the storage directory (= /images// segment) a user's outputs +// live under: sanitized name → sanitized email-local → id → "anon". +func OwnerDir(user *model.User) string { + if user != nil { + if d := sanitizeOwnerName(user.Name); d != "" { + return d + } + if d := sanitizeOwnerName(strings.Split(user.Email, "@")[0]); d != "" { + return d + } + if user.ID != "" { + return user.ID + } + } + return "anon" +} + +// saveReferenceImages persists the user's uploaded reference images under the +// media root (same tree as outputs, served cookie-authed via /images) so the +// playground can re-display them after a reload. Best-effort: a save failure +// just drops that thumbnail and never blocks generation. Returns slash paths. +func (s *V1Service) saveReferenceImages(ctx context.Context, principal *APIPrincipal, inputs []string) []string { + decoded, err := decodeReferenceImages(inputs, len(inputs)) + if err != nil || len(decoded) == 0 { + return nil + } + userDir := s.userDir(principal) + var paths []string + for _, data := range decoded { + ext := imageExtFromBytes(data) + filename := time.Now().Format("20060102-150405") + "-ref-" + randomUpper(6) + "." + ext + rel := filepath.ToSlash(filepath.Join(userDir, filename)) + if err := s.store.Put(ctx, rel, data, contentTypeForExt(ext)); err != nil { + continue + } + paths = append(paths, rel) + } + return paths +} + +// cleanupReferenceImages deletes a generation's reference images from storage and +// clears the event's ref_files paths. Called when an attempt finishes — success +// OR failure — since refs are only needed while generating (no storage bloat, not +// shown in the admin gallery, no dangling回显 URLs). Best-effort: errors ignored. +func (s *V1Service) cleanupReferenceImages(ctx context.Context, eventID string, refFiles []string) { + if len(refFiles) == 0 { + return + } + for _, rf := range refFiles { + if strings.TrimSpace(rf) != "" { + _ = s.store.Delete(ctx, rf) + } + } + if strings.TrimSpace(eventID) != "" { + _ = s.events.ClearRefFiles(ctx, eventID) + } +} + +// contentTypeForExt maps a file extension to a MIME type for storage uploads. +func contentTypeForExt(ext string) string { + switch strings.ToLower(strings.TrimPrefix(ext, ".")) { + case "png": + return "image/png" + case "jpg", "jpeg": + return "image/jpeg" + case "webp": + return "image/webp" + case "gif": + return "image/gif" + case "mp4": + return "video/mp4" + case "webm": + return "video/webm" + case "mov": + return "video/quicktime" + default: + return "application/octet-stream" + } +} + +// imageExtFromBytes sniffs a sensible file extension from the magic bytes so the +// saved reference keeps its real type (the /images handler types by extension). +func imageExtFromBytes(b []byte) string { + switch { + case len(b) >= 3 && b[0] == 0xFF && b[1] == 0xD8 && b[2] == 0xFF: + return "jpg" + case len(b) >= 6 && string(b[0:6]) == "GIF89a", len(b) >= 6 && string(b[0:6]) == "GIF87a": + return "gif" + case len(b) >= 12 && string(b[0:4]) == "RIFF" && string(b[8:12]) == "WEBP": + return "webp" + default: + return "png" + } +} + +// allocateOutput builds the object key (= relative path, user-scoped) and the +// directly-downloadable URL pointing at this site's /images proxy. Nothing is +// written here — the bytes are uploaded to RustFS by the caller. +func (s *V1Service) allocateOutput(principal *APIPrincipal, ext, baseURL string) (string, string) { + userDir := s.userDir(principal) + filename := time.Now().Format("20060102-150405") + "-" + randomUpper(8) + "." + strings.TrimPrefix(ext, ".") + relativePath := filepath.ToSlash(filepath.Join(userDir, filename)) + // OpenAI-style clients need a directly-downloadable absolute URL. When the + // inbound request's base URL is known, build "{scheme}://{host}/images/..."; + // otherwise fall back to the relative path for backward compatibility. + if base := strings.TrimRight(strings.TrimSpace(baseURL), "/"); base != "" { + return base + "/images/" + relativePath, relativePath + } + return "/images/" + relativePath, relativePath +} + +func (s *V1Service) logPendingEvent(ctx context.Context, kind string, modelItem *model.ModelConfig, principal *APIPrincipal, prompt, ratio, resolution, duration string, refs int, cost float64, file, source string, refFiles []string) (string, error) { + event := &model.EventLog{ + ID: "evt-" + randomUpper(12), + TS: time.Now(), + Kind: kind, + Status: "pending", + Model: modelItem.ID, + Provider: modelItem.Provider, + Prompt: prompt, + Ratio: ratio, + Resolution: resolution, + Duration: duration, + Refs: refs, + Source: source, + Cost: cost, + File: file, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if len(refFiles) > 0 { + event.RefFiles = jsonArray(refFiles) + } + if principal != nil && principal.User != nil { + event.UserID = principal.User.ID + } + if err := s.events.Create(ctx, event); err != nil { + return "", err + } + return event.ID, nil +} + +func (s *V1Service) finishUnimplementedEvent(ctx context.Context, eventID string) error { + return s.events.UpdateStatus(ctx, eventID, "failed", "generation executor not implemented yet", 0) +} + +// maxSameAccountAttempts is how many times ONE account is retried for the same +// request before that account is abandoned. Transient/request errors stay on the +// same account; account-level errors (auth/quota) skip straight to the next. +const maxSameAccountAttempts = 3 + +// runPoolWithFailover drives a generation across a round-robin-ordered account +// list with per-error-class behavior, so a bad request never burns the whole +// pool while genuinely limited accounts still fail over: +// - 额度耗尽 quota → mark the account and FAIL OVER to the next account +// immediately (same-account retry can't help). Repeats until one succeeds or +// the pool is exhausted. +// - 认证失效 auth → refresh the token from its cookie and retry ONCE with the +// fresh token; if it still auth-fails (or there's nothing to refresh, e.g. +// chatgpt's JWT IS the credential), mark the account and fail over. +// - 上游临时 temporary → retry the SAME account up to maxSameAccountAttempts +// times (not counted); if still failing, STOP (no fan-out — an upstream-wide +// blip fails identically everywhere). +// - 参数错 / request-level (anything else) → return immediately, no retry, no +// account penalty (the account isn't at fault). +// +// Returns the actual upstream error (never a synthetic "retry failed"). On +// success it stamps success_total/fails=0 on the winning account. classify maps +// a provider error to (isAuth, isQuota, isTemporary). refreshOnAuth (nil for +// providers whose token IS the credential) re-mints the account's token so an +// auth retry uses a FRESH token instead of replaying the stale one. +func (s *V1Service) runPoolWithFailover(ctx context.Context, eventID, pool string, active []model.TokenAccount, kind string, + attempt func(token model.TokenAccount) ([]byte, error), + classify func(error) (isAuth, isQuota, isTemporary bool), + refreshOnAuth func(tokenID string) (model.TokenAccount, bool), +) ([]byte, error) { + var lastErr error + busy := 0 + for _, token := range active { + // 1 concurrent job per account: skip any account already generating. + if !s.gate.tryAcquire(token.ID) { + busy++ + continue + } + // release via defer so a panic in tryAccount can't leak the 1-job slot. + data, err, failover := func() ([]byte, error, bool) { + defer s.gate.release(token.ID) + return s.tryAccount(ctx, eventID, pool, token, kind, attempt, classify, refreshOnAuth) + }() + if err == nil { + return data, nil + } + lastErr = err + if failover { + continue + } + // temporary exhausted or request-level error → surface it, no fan-out. + return nil, lastErr + } + // Nothing ran. If accounts were skipped ONLY because they were all busy + // (no real failure), tell the caller the pool is at its concurrency cap. + if lastErr == nil { + if busy > 0 { + return nil, ErrConcurrencyFull + } + return nil, ErrProviderExecution + } + return nil, lastErr +} + +// tryAccount runs one account's attempt with the same-account retry policy used +// by the pool: 额度耗尽/认证失效 → mark + failover; 上游临时 → retry ≤3 same account; +// 参数错 → fail fast. Returns (data, err, failover) — failover=true means move on +// to the next account. The per-account concurrency gate is held by the caller. +func (s *V1Service) tryAccount(ctx context.Context, eventID, pool string, token model.TokenAccount, kind string, + attempt func(token model.TokenAccount) ([]byte, error), + classify func(error) (isAuth, isQuota, isTemporary bool), + refreshOnAuth func(tokenID string) (model.TokenAccount, bool), +) ([]byte, error, bool) { + _ = s.events.SetAccount(ctx, eventID, token.ID) + _ = s.tokens.TouchLastUsed(ctx, token.ID) + authRefreshed := false + tempAttempts := 0 + for { + data, err := attempt(token) + if err == nil { + _, _ = s.tokens.Update(ctx, pool, token.ID, map[string]any{ + "last_used_at": time.Now(), + "success_total": gorm.Expr("success_total + 1"), + "fails": 0, + }) + return data, nil, false + } + isAuth, isQuota, isTemp := classify(err) + if isQuota { + s.markTokenFailure(ctx, pool, token, kind, false, true) + return nil, err, true + } + if isAuth { + // Refresh from cookie and retry ONCE; otherwise the credential is dead. + if refreshOnAuth != nil && !authRefreshed { + if refreshed, ok := refreshOnAuth(token.ID); ok { + token = refreshed + authRefreshed = true + continue + } + } + s.markTokenFailure(ctx, pool, token, kind, true, false) + return nil, err, true + } + if isTemp { + tempAttempts++ + if tempAttempts < maxSameAccountAttempts { + // Short linear backoff (1s, 2s) so an overloaded/rate-limited upstream + // (e.g. adobe "system under load") gets a moment to recover before the + // same-account retry, instead of hammering it instantly. + select { + case <-time.After(time.Duration(tempAttempts) * time.Second): + case <-ctx.Done(): + return nil, err, false + } + continue + } + return nil, err, false // exhausted; no fan-out + } + return nil, err, false // 参数错 / request-level + } +} + +func adobeErrClass(e error) (bool, bool, bool) { + return errors.Is(e, adobe.ErrAuth), errors.Is(e, adobe.ErrQuotaExhausted), errors.Is(e, adobe.ErrTemporaryUpstream) +} + +func (s *V1Service) generateAdobeImage(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1ImageRequest, aspectRatio, resolution string) ([]byte, error) { + if s.adobe == nil { + return nil, errors.New("adobe client not configured") + } + if s.settings != nil { + if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == nil { + s.adobe.SetProxy(proxy) + } + } + + items, err := s.tokens.ListByPool(ctx, "adobe") + if err != nil { + return nil, err + } + var active []model.TokenAccount + for _, item := range items { + // Image quota is tracked separately from video — an account whose video + // quota is exhausted (VideoLimited) is still usable for image as long as + // its image quota remains. status=="quota" means BOTH kinds are limited + // (or a legacy/full quota mark), so it's excluded for either kind. + if item.Status == "active" && !item.Dead && !item.ImageLimited && strings.TrimSpace(item.Value) != "" { + active = append(active, item) + } + } + if len(active) == 0 { + return nil, ErrNoProviderAccount + } + s.rotateRoundRobin("adobe", active) + + refs, err := decodeReferenceImages(in.ReferenceImages, max(1, modelItem.MaxReferenceImages)) + if err != nil { + return nil, err + } + + // Round-robin order; same-account retry on transient errors, fail over to the + // next account on auth/quota (see runPoolWithFailover). + return s.runPoolWithFailover(ctx, eventID, "adobe", active, "image", func(token model.TokenAccount) ([]byte, error) { + var blobIDs []string + for _, ref := range refs { + id, upErr := s.adobe.UploadImage(ctx, token.Value, ref, "image/png", "") + if upErr != nil { + return nil, upErr + } + blobIDs = append(blobIDs, id) + } + data, _, genErr := s.adobe.GenerateImage(ctx, token.Value, modelItem.ID, in.Prompt, aspectRatio, resolution, blobIDs) + return data, genErr + }, adobeErrClass, func(id string) (model.TokenAccount, bool) { + return s.refreshAdobeToken(ctx, id) + }) +} + +func (s *V1Service) generateAdobeVideo(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1VideoRequest, aspectRatio, resolution string, durationSeconds int, downloadResult bool) ([]byte, string, error) { + if s.adobe == nil { + return nil, "", errors.New("adobe client not configured") + } + if s.settings != nil { + if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == nil { + s.adobe.SetProxy(proxy) + } + } + + items, err := s.tokens.ListByPool(ctx, "adobe") + if err != nil { + return nil, "", err + } + var active []model.TokenAccount + for _, item := range items { + // Video quota is tracked separately from image — skip accounts whose + // video quota is exhausted (VideoLimited), but an image-only limit + // (ImageLimited) leaves the account usable for video. status=="quota" + // means BOTH kinds are limited (or a legacy/full quota mark), so it's + // excluded for either kind. + if item.Status == "active" && !item.Dead && !item.VideoLimited && strings.TrimSpace(item.Value) != "" { + active = append(active, item) + } + } + if len(active) == 0 { + return nil, "", ErrNoProviderAccount + } + s.rotateRoundRobin("adobe", active) + + refLimit := modelItem.MaxReferenceImages + if refLimit <= 0 { + refLimit = 10 + } + refs, err := decodeReferenceImages(in.ReferenceImages, refLimit) + if err != nil { + return nil, "", err + } + + engine, upstreamModel := resolveAdobeVideoEngine(modelItem.ID) + referenceMode := defaultString(strings.TrimSpace(modelItem.ReferenceMode), "frame") + + // Round-robin order; same-account retry on transient errors, fail over to the + // next account on auth/quota (see runPoolWithFailover). videoURL is captured + // from the successful attempt's meta (the upstream presigned URL). + var videoURL string + data, err := s.runPoolWithFailover(ctx, eventID, "adobe", active, "video", func(token model.TokenAccount) ([]byte, error) { + var blobIDs []string + for _, ref := range refs { + id, upErr := s.adobe.UploadImage(ctx, token.Value, ref, "image/png", engine) + if upErr != nil { + return nil, upErr + } + blobIDs = append(blobIDs, id) + } + bytes, meta, genErr := s.adobe.GenerateVideo(ctx, token.Value, engine, in.Prompt, aspectRatio, durationSeconds, resolution, referenceMode, upstreamModel, blobIDs, downloadResult) + if genErr == nil { + videoURL = strings.TrimSpace(stringValue(meta["video_url"])) + } + return bytes, genErr + }, adobeErrClass, func(id string) (model.TokenAccount, bool) { + return s.refreshAdobeToken(ctx, id) + }) + return data, videoURL, err +} + +// runwayMinCredits gates account scheduling: a Runway account with fewer than +// this many credits remaining is treated as quota-limited and skipped, so we +// never dial upstream with an account that's about to run dry. Flat threshold +// (not per-duration) by request. +const runwayMinCredits = 50 + +// runwayCreditsPerSecond is Gen-4 Turbo's price (5 credits/sec → 5s=25, 10s=50), +// used to pre-reserve the exact render cost so concurrent picks of one account +// can't over-commit it. +const runwayCreditsPerSecond = 5 + +// leonardoMinCredits is the per-generation token cost (one Leonardo image = 30 +// tokens). An account with fewer is treated as 限额 and skipped — it can't afford +// a generation. Daily renewal (tokenRenewalDate) drives auto-recovery. +const leonardoMinCredits = 30 + +func (s *V1Service) generateRunwayVideo(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1VideoRequest, aspectRatio string, durationSeconds int, downloadResult bool) ([]byte, string, error) { + if s.runway == nil { + return nil, "", errors.New("runway client not configured") + } + if s.settings != nil { + if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == nil { + s.runway.SetProxy(proxy) + } + } + + // Runway i2v strictly requires exactly one first-frame image. + refs, err := decodeReferenceImages(in.ReferenceImages, 1) + if err != nil { + return nil, "", err + } + if len(refs) != 1 { + return nil, "", errors.New("runway 图生视频需要且仅需 1 张首帧图") + } + frame := refs[0] + + items, err := s.tokens.ListByPool(ctx, "runway") + if err != nil { + return nil, "", err + } + var active []model.TokenAccount + for _, item := range items { + if item.Status != "active" || item.Dead || strings.TrimSpace(item.Value) == "" { + continue + } + // Skip accounts under the credit floor (treated as quota-limited). Only + // skip when we KNOW the balance is too low — an unknown balance gets the + // benefit of the doubt (upstream will reject if it's truly empty). + if rem, ok := jsonMapInt(item.Meta, "cached_quota_remaining"); ok && rem < runwayMinCredits { + continue + } + active = append(active, item) + } + if len(active) == 0 { + return nil, "", ErrNoProviderAccount + } + s.rotateRoundRobin("runway", active) + + // Pre-reserve the exact render cost (5 credits/sec) so two concurrent renders + // can't over-commit the same account. + cost := durationSeconds * runwayCreditsPerSecond + var lastErr error + var videoURL string + busy := 0 + for _, token := range active { + // 1 concurrent job per account: skip any account already generating. + if !s.gate.tryAcquire(token.ID) { + busy++ + continue + } + var data []byte + ok := func() bool { + defer s.gate.release(token.ID) + _ = s.events.SetAccount(ctx, eventID, token.ID) + _ = s.tokens.TouchLastUsed(ctx, token.ID) + // Atomic pre-deduction (row-locked). Known-insufficient → sink to 限额 + // and fail over to the next account. + allowed, deducted, rerr := s.tokens.ReserveQuota(ctx, "runway", token.ID, cost) + if rerr != nil { + lastErr = fmt.Errorf("%w: reserve: %v", runway.ErrTemporaryUpstream, rerr) + return false + } + if !allowed { + s.markTokenFailure(ctx, "runway", token, "video", false, true) + lastErr = runway.ErrQuotaExhausted + return false + } + teamID := "" + if token.Meta != nil { + teamID = strings.TrimSpace(stringValue(token.Meta["team_id"])) + } + d, meta, genErr := s.runway.GenerateVideo(ctx, token.Value, teamID, in.Prompt, aspectRatio, durationSeconds, frame, downloadResult) + if genErr == nil { + _, _ = s.tokens.Update(ctx, "runway", token.ID, map[string]any{ + "last_used_at": time.Now(), + "success_total": gorm.Expr("success_total + 1"), + "fails": 0, + }) + // Re-fetch the REAL balance after the render and sink to 限额 if below + // the floor. Best-effort — never fail an already-successful render. + s.reconcileRunwayCredits(ctx, token.ID, token.Value) + data = d + videoURL = strings.TrimSpace(stringValue(meta["video_url"])) + return true + } + // Release the hold so a failed render doesn't burn credits. + if deducted { + _ = s.tokens.RefundQuota(ctx, "runway", token.ID, cost) + } + lastErr = genErr + s.markTokenFailure(ctx, "runway", token, "video", + errors.Is(genErr, runway.ErrAuth), + errors.Is(genErr, runway.ErrQuotaExhausted)) + return false + }() + if ok { + return data, videoURL, nil + } + } + if lastErr == nil { + if busy > 0 { + return nil, "", ErrConcurrencyFull + } + lastErr = ErrProviderExecution + } + return nil, "", lastErr +} + +// reconcileRunwayCredits re-fetches an account's authoritative credit balance +// (after a render) and writes it back, flipping the account to "限额" (quota) +// when it's below the floor. Concurrency-safe: every write stores a freshly +// observed real balance — no local arithmetic that could lose updates under +// concurrent renders. Best-effort; marks down only (recovery is unwritten). +func (s *V1Service) reconcileRunwayCredits(ctx context.Context, tokenID, tokenValue string) { + if s.runway == nil { + return + } + data, err := s.runway.FetchCreditsBalance(ctx, tokenValue) + if err != nil { + return + } + rem, ok := data["remaining"].(int) + if !ok { + return + } + item, err := s.tokens.Get(ctx, "runway", tokenID) + if err != nil { + return + } + meta := cloneJSONMap(item.Meta) + meta["cached_quota_remaining"] = rem + meta["cached_quota_at"] = int(time.Now().Unix()) + if used, ok := data["used"].(int); ok { + meta["cached_quota_used"] = used + } + if total, ok := data["total"].(int); ok { + meta["cached_quota_total"] = total + } + patch := map[string]any{"meta": meta} + if rem < runwayMinCredits && item.Status == "active" { + patch["status"] = "quota" + } + _, _ = s.tokens.Update(ctx, "runway", tokenID, patch) +} + +// reconcileChatGPTQuota re-reads OpenAI's image_gen remaining right after a +// successful generation and writes it back (negative / unknown clamp to 0), +// flipping the account to 限额 when it hits 0 — so accounts limit one-by-one as +// they're used, not all at once on a later batch probe. Runs while the +// per-account concurrency gate is still held. Best-effort (never fails the render). +func (s *V1Service) reconcileChatGPTQuota(ctx context.Context, tokenID, accessToken string) { + if s.chatgpt == nil { + return + } + data, err := s.chatgpt.FetchImageQuota(ctx, accessToken) + if err != nil || boolValueWithDefault(data["auth_failed"], false) { + return + } + rem, exhausted := chatgptRemaining(data) + item, err := s.tokens.Get(ctx, "chatgpt", tokenID) + if err != nil { + return + } + meta := cloneJSONMap(item.Meta) + meta["cached_quota_remaining"] = rem + meta["cached_quota_at"] = int(time.Now().Unix()) + patch := map[string]any{"meta": meta} + if reset := strings.TrimSpace(stringValue(data["reset_after"])); reset != "" { + patch["cached_quota_reset_after"] = reset + } else if strings.TrimSpace(item.CachedQuotaResetAfter) == "" { + patch["cached_quota_reset_after"] = leonardoResetAfter("") + } + if exhausted && item.Status == "active" { + patch["status"] = "quota" + } + _, _ = s.tokens.Update(ctx, "chatgpt", tokenID, patch) +} + +func (s *V1Service) generateChatGPTImage(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1ImageRequest, aspectRatio, resolution string) ([]byte, error) { + if s.chatgpt == nil { + return nil, errors.New("chatgpt client not configured") + } + if s.settings != nil { + if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == nil { + s.chatgpt.SetProxy(proxy) + } + } + + items, err := s.tokens.ListByPool(ctx, "chatgpt") + if err != nil { + return nil, err + } + var active []model.TokenAccount + for _, item := range items { + if item.Status == "active" && !item.Dead && strings.TrimSpace(item.Value) != "" { + active = append(active, item) + } + } + if len(active) == 0 { + return nil, ErrNoProviderAccount + } + s.rotateRoundRobin("chatgpt", active) + + refLimit := modelItem.MaxReferenceImages + if refLimit <= 0 { + refLimit = 1 + } + refs, err := decodeReferenceImages(in.ReferenceImages, refLimit) + if err != nil { + return nil, err + } + + // Round-robin order; same-account retry on transient errors, fail over to the + // next account on auth/quota (see runPoolWithFailover). + return s.runPoolWithFailover(ctx, eventID, "chatgpt", active, "image", func(token model.TokenAccount) ([]byte, error) { + data, _, genErr := s.chatgpt.GenerateImage(ctx, token.Value, in.Prompt, modelItem.ID, aspectRatio, resolution, refs) + if genErr == nil { + // Sync the real OpenAI quota BEFORE the concurrency gate releases, so the + // freshly-decremented remaining (and 限额 flip at 0) gates the next pick. + s.reconcileChatGPTQuota(ctx, token.ID, token.Value) + } + return data, genErr + }, func(e error) (bool, bool, bool) { + return errors.Is(e, chatgpt.ErrAuth), errors.Is(e, chatgpt.ErrQuotaExhausted), errors.Is(e, chatgpt.ErrTemporaryUpstream) + }, nil) // chatgpt token IS the credential — no cookie to refresh +} + +// leonardoResetAfter returns when a Leonardo account's daily free tokens renew. +// Leonardo resets at 08:00 Beijing == 00:00 UTC, so when the upstream gives no +// explicit renewal time we deterministically use the next UTC midnight — this is +// filled at import so 恢复时间 is always populated, not left blank. +func leonardoResetAfter(availableUntil string) string { + if v := strings.TrimSpace(availableUntil); v != "" { + return v + } + return time.Unix((time.Now().Unix()/86400+1)*86400, 0).UTC().Format(time.RFC3339) +} + +// leonardoDimensions maps the catalog's resolution+ratio to Leonardo pixel sizes. +func leonardoDimensions(resolution, aspectRatio string) (int, int) { + res := strings.ToUpper(strings.TrimSpace(resolution)) + ar := strings.TrimSpace(aspectRatio) + if res == "4K" { + switch ar { + case "2:3": + return 2000, 3000 + case "16:9": + return 4096, 2304 + case "4:3": + return 4096, 3072 + case "4:5": + return 3264, 4080 + case "9:16": + return 2160, 3840 + case "2:1": + return 4096, 2048 + default: // 1:1 + return 4096, 4096 + } + } + switch ar { // 2K (default) + case "2:3": + return 1664, 2496 + case "16:9": + return 2560, 1440 + case "4:3": + return 2304, 1728 + case "4:5": + return 2432, 3040 + case "9:16": + return 1440, 2560 + case "2:1": + return 3232, 1616 + default: // 1:1 + return 2048, 2048 + } +} + +func (s *V1Service) generateLeonardoImage(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1ImageRequest, aspectRatio, resolution string) ([]byte, error) { + if s.leonardo == nil { + return nil, errors.New("leonardo client not configured") + } + if s.settings != nil { + if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == nil { + s.leonardo.SetProxy(proxy) + } + } + + items, err := s.tokens.ListByPool(ctx, "leonardo") + if err != nil { + return nil, err + } + var active []model.TokenAccount + for _, item := range items { + if item.Status != "active" || item.Dead || strings.TrimSpace(item.Value) == "" { + continue + } + // Skip accounts under the per-generation floor (treated as 限额). Unknown + // balance gets the benefit of the doubt (upstream rejects if truly empty). + if rem, ok := jsonMapInt(item.Meta, "cached_quota_remaining"); ok && rem < leonardoMinCredits { + continue + } + active = append(active, item) + } + if len(active) == 0 { + return nil, ErrNoProviderAccount + } + s.rotateRoundRobin("leonardo", active) + + width, height := leonardoDimensions(resolution, aspectRatio) + // The catalog model id is the upstream Leonardo model name (e.g. seedream-4.5). + upstreamModel := strings.TrimSpace(modelItem.ID) + + // Optional image-to-image: decode the reference image once up front (Leonardo + // seedream takes at most one). + refLimit := modelItem.MaxReferenceImages + if refLimit <= 0 { + refLimit = 1 + } + refs, err := decodeReferenceImages(in.ReferenceImages, refLimit) + if err != nil { + return nil, err + } + + // token.Value is the cookie; GenerateImage mints a fresh JWT each attempt, so an + // auth failure means the cookie itself is dead — no refresher (nil). + return s.runPoolWithFailover(ctx, eventID, "leonardo", active, "image", func(token model.TokenAccount) ([]byte, error) { + // Atomically pre-deduct the per-generation cost so concurrent picks of the + // same near-empty account can't over-commit it. A known-insufficient + // balance surfaces as quota → the driver fails over to the next account. + allowed, deducted, rerr := s.tokens.ReserveQuota(ctx, "leonardo", token.ID, leonardoMinCredits) + if rerr != nil { + return nil, fmt.Errorf("%w: reserve: %v", leonardo.ErrTemporaryUpstream, rerr) + } + if !allowed { + return nil, leonardo.ErrQuotaExhausted + } + data, _, genErr := s.leonardo.GenerateImage(ctx, token.Value, upstreamModel, in.Prompt, width, height, nil, refs) + if genErr != nil { + // Release the hold so a failed render doesn't burn credits. + if deducted { + _ = s.tokens.RefundQuota(ctx, "leonardo", token.ID, leonardoMinCredits) + } + return nil, genErr + } + // Success → overwrite the held value with the REAL upstream balance and + // sink to 限额 if below the floor (best-effort; never fails a done render). + s.reconcileLeonardoCredits(ctx, token.ID, token.Value) + return data, nil + }, func(e error) (bool, bool, bool) { + return errors.Is(e, leonardo.ErrAuth), errors.Is(e, leonardo.ErrQuotaExhausted), errors.Is(e, leonardo.ErrTemporaryUpstream) + }, nil) +} + +// reconcileLeonardoCredits re-fetches an account's real token balance after a +// render and writes it back, flipping the account to 限额 when below the per-gen +// floor. Stores the daily renewal time so RecoverQuota can auto-recover it. +func (s *V1Service) reconcileLeonardoCredits(ctx context.Context, tokenID, cookie string) { + if s.leonardo == nil { + return + } + data, err := s.leonardo.FetchCreditsBalance(ctx, cookie) + if err != nil { + return + } + rem, ok := data["remaining"].(int) + if !ok { + return + } + item, err := s.tokens.Get(ctx, "leonardo", tokenID) + if err != nil { + return + } + meta := cloneJSONMap(item.Meta) + meta["cached_quota_remaining"] = rem + meta["cached_quota_at"] = int(time.Now().Unix()) + patch := map[string]any{"meta": meta} + patch["cached_quota_reset_after"] = leonardoResetAfter(stringValue(data["available_until"])) + if rem < leonardoMinCredits && item.Status == "active" { + patch["status"] = "quota" + } + _, _ = s.tokens.Update(ctx, "leonardo", tokenID, patch) +} + +// kreaRefreshAndPersist ensures the account's Krea cookie has a valid access token +// (refreshing via the rotating refresh_token when expired) and persists the new +// cookie — the refresh_token is single-use, so the rotated value MUST be saved. +func kreaRefreshAndPersist(ctx context.Context, client *krea.Client, tokens *repo.TokenRepository, tokenID, cookie string) (string, error) { + if client == nil { + return cookie, nil + } + fresh, changed, err := client.RefreshIfNeeded(ctx, cookie) + if err != nil { + return "", err + } + if changed && tokenID != "" { + _, _ = tokens.Update(ctx, "krea", tokenID, map[string]any{"value": fresh}) + } + return fresh, nil +} + +// kreaDimensions maps the catalog's resolution+ratio to Krea pixel sizes. +func kreaDimensions(resolution, aspectRatio string) (int, int) { + res := strings.ToUpper(strings.TrimSpace(resolution)) + ar := strings.TrimSpace(aspectRatio) + if res == "2K" { + switch ar { + case "4:3": + return 2048, 1536 + case "3:4": + return 1536, 2048 + case "16:9": + return 2048, 1152 + case "9:16": + return 1152, 2048 + default: // 1:1 + return 2048, 2048 + } + } + switch ar { // 1K (default) + case "4:3": + return 1024, 768 + case "3:4": + return 768, 1024 + case "16:9": + return 1024, 576 + case "9:16": + return 576, 1024 + default: // 1:1 + return 1024, 1024 + } +} + +func (s *V1Service) generateKreaImage(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1ImageRequest, aspectRatio, resolution string) ([]byte, error) { + if s.krea == nil { + return nil, errors.New("krea client not configured") + } + if s.settings != nil { + if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == nil { + s.krea.SetProxy(proxy) + } + } + + items, err := s.tokens.ListByPool(ctx, "krea") + if err != nil { + return nil, err + } + var active []model.TokenAccount + for _, item := range items { + // No numeric floor — Krea signals 限额 with a 402 at generation time, which + // the failover driver turns into mark-quota + next account. + if item.Status == "active" && !item.Dead && strings.TrimSpace(item.Value) != "" { + active = append(active, item) + } + } + if len(active) == 0 { + return nil, ErrNoProviderAccount + } + s.rotateRoundRobin("krea", active) + + width, height := kreaDimensions(resolution, aspectRatio) + refLimit := modelItem.MaxReferenceImages + if refLimit <= 0 { + refLimit = 1 + } + refs, err := decodeReferenceImages(in.ReferenceImages, refLimit) + if err != nil { + return nil, err + } + + return s.runPoolWithFailover(ctx, eventID, "krea", active, "image", func(token model.TokenAccount) ([]byte, error) { + // Refresh the (rotating) Supabase token if expired and persist the new + // cookie, then generate with the fresh cookie. + cookie, rerr := kreaRefreshAndPersist(ctx, s.krea, s.tokens, token.ID, token.Value) + if rerr != nil { + return nil, rerr + } + data, _, genErr := s.krea.GenerateImage(ctx, cookie, in.Prompt, width, height, refs) + return data, genErr + }, func(e error) (bool, bool, bool) { + return errors.Is(e, krea.ErrAuth), errors.Is(e, krea.ErrQuotaExhausted), errors.Is(e, krea.ErrTemporaryUpstream) + }, nil) +} + +// imagineRefreshAndPersist ensures the account's Imagine credential has a valid +// access token (refreshing via the rotating refreshToken when expired) and +// persists the new credential — both tokens rotate, so the value MUST be saved. +func imagineRefreshAndPersist(ctx context.Context, client *imagine.Client, tokens *repo.TokenRepository, tokenID, cred string) (string, error) { + if client == nil { + return cred, nil + } + fresh, changed, err := client.RefreshIfNeeded(ctx, cred) + if err != nil { + return "", err + } + if changed && tokenID != "" { + _, _ = tokens.Update(ctx, "imagine", tokenID, map[string]any{"value": fresh}) + } + return fresh, nil +} + +// imagineStyle maps the catalog model id to its upstream style_id + resolution. +func imagineStyle(modelID string) (int, string) { + if strings.TrimSpace(modelID) == "imagine-1.5pro" { + return 41004, "4K" + } + return 41001, "2K" +} + +func (s *V1Service) generateImagineImage(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1ImageRequest, aspectRatio, resolution string) ([]byte, error) { + if s.imagine == nil { + return nil, errors.New("imagine client not configured") + } + if s.settings != nil { + if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == nil { + s.imagine.SetProxy(proxy) + } + } + + items, err := s.tokens.ListByPool(ctx, "imagine") + if err != nil { + return nil, err + } + var active []model.TokenAccount + for _, item := range items { + // No numeric floor — Imagine signals 限额 with a 402 at generation time, + // which the failover driver turns into mark-quota + next account. + if item.Status == "active" && !item.Dead && strings.TrimSpace(item.Value) != "" { + active = append(active, item) + } + } + if len(active) == 0 { + return nil, ErrNoProviderAccount + } + s.rotateRoundRobin("imagine", active) + + // Each model supports exactly one resolution (2K / 4K) — force it per model. + styleID, res := imagineStyle(modelItem.ID) + + return s.runPoolWithFailover(ctx, eventID, "imagine", active, "image", func(token model.TokenAccount) ([]byte, error) { + // Refresh the (rotating) access token if expired and persist the new + // credential, then generate with the fresh token. + cred, rerr := imagineRefreshAndPersist(ctx, s.imagine, s.tokens, token.ID, token.Value) + if rerr != nil { + return nil, rerr + } + data, _, genErr := s.imagine.GenerateImage(ctx, cred, styleID, res, aspectRatio, in.Prompt) + if genErr != nil { + return nil, genErr + } + // Success → re-sync the displayed balance (best-effort). + s.reconcileImagineCredits(ctx, token.ID, cred) + return data, nil + }, func(e error) (bool, bool, bool) { + return errors.Is(e, imagine.ErrAuth), errors.Is(e, imagine.ErrQuotaExhausted), errors.Is(e, imagine.ErrTemporaryUpstream) + }, nil) +} + +// reconcileImagineCredits re-fetches the account's real balance after a render +// and writes it back (best-effort; never fails a done render). Imagine credits +// don't daily-reset, so there's no reset marker to advance. +func (s *V1Service) reconcileImagineCredits(ctx context.Context, tokenID, cred string) { + if s.imagine == nil { + return + } + data, err := s.imagine.FetchCreditsBalance(ctx, cred) + if err != nil { + return + } + rem, ok := data["remaining"].(int) + if !ok { + return + } + item, err := s.tokens.Get(ctx, "imagine", tokenID) + if err != nil { + return + } + meta := cloneJSONMap(item.Meta) + meta["cached_quota_remaining"] = rem + meta["cached_quota_at"] = int(time.Now().Unix()) + _, _ = s.tokens.Update(ctx, "imagine", tokenID, map[string]any{"meta": meta}) +} + +func (s *V1Service) refundIfNeeded(ctx context.Context, principal *APIPrincipal, eventID string, price float64) error { + if principal == nil || principal.User == nil || price <= 0 { + return nil + } + // Exactly-once: claim the refund via the event's `refunded` flag. If another + // path (e.g. the abandoned-purge sweep) already refunded, MarkRefunded + // returns false and we skip — no double refund. + claimed, err := s.events.MarkRefunded(ctx, eventID) + if err != nil { + return err + } + if !claimed { + return nil + } + updated, err := s.users.AdjustCredits(ctx, principal.User.ID, price) + if err == nil { + principal.User = updated + } + return err +} + +func (s *V1Service) maybeGrantInviteReward(ctx context.Context, principal *APIPrincipal) error { + if principal == nil || principal.User == nil || s.settings == nil { + return nil + } + enabledRaw, err := s.settings.GetValue(ctx, "credits.invite_enabled") + if err != nil { + return err + } + if !parseBoolSetting(enabledRaw, true) { + return nil + } + rewardRaw, err := s.settings.GetValue(ctx, "credits.invite_reward") + if err != nil { + return err + } + _, err = s.users.GrantInviteReward(ctx, principal.User.ID, parseIntSetting(rewardRaw, 3)) + return err +} + +// ensureReferenceSizes rejects any reference image over the byte cap BEFORE +// charging, so an oversized image fails fast (no charge, no pending-log churn) +// across every entry path — session /generate, API-key /v1, and admin /test. +// decodeReferenceImages re-checks at decode time as a backstop; this mirrors its +// base64 length pre-check (decoded ≈ len(b64)*3/4). +func ensureReferenceSizes(inputs []string) error { + for _, raw := range inputs { + v := strings.TrimSpace(raw) + if v == "" { + continue + } + if (len(v)*3)/4 > maxReferenceImageBytes { + return ErrReferenceTooLarge + } + } + return nil +} + +func decodeReferenceImages(inputs []string, limit int) ([][]byte, error) { + if limit <= 0 { + limit = 1 + } + if len(inputs) > limit { + return nil, errors.New("too many reference images") + } + out := make([][]byte, 0, len(inputs)) + for _, raw := range inputs { + v := strings.TrimSpace(raw) + if v == "" { + continue + } + // Only raw base64 is accepted (no "data:...;base64," URL prefix). A data + // URL now fails to decode rather than being silently stripped. + // decoded size ≈ len(b64) * 3 / 4 — reject oversized payloads up front, + // before allocating the decoded buffer. + if (len(v)*3)/4 > maxReferenceImageBytes { + return nil, ErrReferenceTooLarge + } + data, err := base64.StdEncoding.DecodeString(v) + if err != nil { + data, err = base64.RawStdEncoding.DecodeString(v) + if err != nil { + return nil, errors.New("invalid reference image encoding") + } + } + if len(data) == 0 { + return nil, errors.New("empty reference image") + } + if len(data) > maxReferenceImageBytes { + return nil, ErrReferenceTooLarge + } + out = append(out, data) + } + return out, nil +} + +func parseImageSize(size, aspectRatio, resolution string) (string, string) { + ar := strings.TrimSpace(strings.ReplaceAll(aspectRatio, "x", ":")) + rs := strings.TrimSpace(resolution) + if size != "" && strings.Contains(strings.ToLower(size), "x") { + var w, h int + _, _ = fmt.Sscanf(strings.ToLower(size), "%dx%d", &w, &h) + if w > 0 && h > 0 { + if ar == "" { + ar = guessRatio(w, h) + } + if rs == "" { + maxEdge := w + if h > maxEdge { + maxEdge = h + } + switch { + case maxEdge >= 3500: + rs = "4K" + case maxEdge >= 1800: + rs = "2K" + default: + rs = "1K" + } + } + } + } + if ar == "" { + ar = "1:1" + } + if rs == "" { + rs = "2K" + } + return ar, rs +} + +func guessRatio(w, h int) string { + type candidate struct { + W int + H int + } + candidates := []candidate{{1, 1}, {16, 9}, {9, 16}, {4, 3}, {3, 4}, {4, 1}, {1, 4}, {8, 1}, {1, 8}} + best := candidates[0] + bestDelta := absFloat(float64(w)/float64(h) - float64(best.W)/float64(best.H)) + for _, item := range candidates[1:] { + delta := absFloat(float64(w)/float64(h) - float64(item.W)/float64(item.H)) + if delta < bestDelta { + best = item + bestDelta = delta + } + } + return fmt.Sprintf("%d:%d", best.W, best.H) +} + +// firstPricedResolution returns the model's lowest priced image tier (1K/2K/4K +// order), or "" if none is priced. Used to rescue a request whose resolution +// the model doesn't support. +func firstPricedResolution(item *model.ModelConfig) string { + if item == nil { + return "" + } + for _, r := range []string{"1K", "2K", "4K"} { + if _, ok := jsonMapFloat(item.Prices, r); ok { + return r + } + } + return "" +} + +// resolutionForQuality maps OpenAI's `quality` to one of the model's priced +// resolution tiers: low→1K, medium→2K, high→4K, auto/blank→the model's lowest +// priced tier. The desired tier is clamped to the nearest tier the model +// actually prices (e.g. seedream is 2K/4K only: low→2K, high→4K). +func resolutionForQuality(item *model.ModelConfig, quality string) string { + order := []string{"1K", "2K", "4K"} + var priced []string + for _, r := range order { + if _, ok := jsonMapFloat(item.Prices, r); ok { + priced = append(priced, r) + } + } + if len(priced) == 0 { + return firstPricedResolution(item) + } + rank := map[string]int{"low": 0, "medium": 1, "high": 2} + want, ok := rank[strings.ToLower(strings.TrimSpace(quality))] + if !ok { + return priced[0] // auto / unknown → model default (lowest priced) + } + idxOf := func(r string) int { + for i, v := range order { + if v == r { + return i + } + } + return 0 + } + best, bestDist := priced[0], 99 + for _, r := range priced { + d := idxOf(r) - want + if d < 0 { + d = -d + } + if d < bestDist { + best, bestDist = r, d + } + } + return best +} + +// modelPrice returns the charge for (kind, resolution, duration). The set of +// supported tiers is always driven by the NORMAL prices; `agent` only overrides +// the amount with the agent price when one is set for that tier (else it falls +// back to the normal price). +func modelPrice(item *model.ModelConfig, kind, resolution, duration string, agent bool) (float64, bool) { + if item == nil { + return 0, false + } + // tierPrice: normal price gates support; agent price (if present) overrides. + tierPrice := func(normal, agentMap map[string]any, key string) (float64, bool) { + nv, ok := jsonMapFloat(normal, key) + if !ok { + return 0, false + } + if agent { + if av, aok := jsonMapFloat(agentMap, key); aok { + return av, true + } + } + return nv, true + } + if kind == "video" { + rv, rok := tierPrice(item.Prices, item.PricesAgent, resolution) + dv, dok := tierPrice(item.DurationPrices, item.DurationPricesAgent, duration) + if !rok || !dok { + return 0, false + } + return rv + dv, true + } + return tierPrice(item.Prices, item.PricesAgent, resolution) +} + +func jsonMapFloat(m map[string]any, key string) (float64, bool) { + if m == nil { + return 0, false + } + v, ok := m[key] + if !ok || v == nil { + return 0, false + } + switch x := v.(type) { + case float64: + return x, true + case float32: + return float64(x), true + case int: + return float64(x), true + case int64: + return float64(x), true + case json.Number: + // datatypes.JSONMap.Scan decodes with UseNumber(), so values loaded from + // the DB arrive as json.Number — NOT float64. Without this case every + // price read back from Postgres looked "unpriced". + if f, err := x.Float64(); err == nil { + return f, true + } + case string: + var out float64 + if _, err := fmt.Sscanf(strings.TrimSpace(x), "%f", &out); err == nil { + return out, true + } + } + return 0, false +} + +func sanitizeOwnerName(v string) string { + v = strings.TrimSpace(v) + if v == "" { + return "" + } + var b strings.Builder + for _, r := range v { + switch { + case r >= 'a' && r <= 'z': + b.WriteRune(r) + case r >= 'A' && r <= 'Z': + b.WriteRune(r) + case r >= '0' && r <= '9': + b.WriteRune(r) + } + } + return b.String() +} + +func parseDurationSeconds(raw string) int { + raw = strings.ToLower(strings.TrimSpace(raw)) + raw = strings.TrimSuffix(raw, "s") + var n int + if _, err := fmt.Sscanf(raw, "%d", &n); err != nil || n <= 0 { + return 5 + } + return n +} + +func resolveAdobeVideoEngine(modelID string) (string, string) { + switch strings.ToLower(strings.TrimSpace(modelID)) { + case "gemini-veo31", "firefly-veo31": + // Use the fast tier — it's the only Veo 3.1 version this account is + // entitled to (standard "3.1-generate" returns 403 user_not_entitled). + // "firefly-veo31" is the legacy id, kept for back-compat with historical + // rows/logs; the model is branded "gemini-veo31" now. + return "veo31-fast", "" + case "firefly-ray": + return "luma", "" + case "firefly-video": + return "firefly-video", "" + default: + return "sora2", "" + } +} + +func absFloat(v float64) float64 { + if v < 0 { + return -v + } + return v +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +func principalCredits(principal *APIPrincipal) float64 { + if principal == nil || principal.User == nil { + return 0 + } + return principal.User.Credits +} + +// markTokenFailure applies Python mark_bad semantics for a failed generation +// attempt against a pool token. It always bumps fail counters; the status side +// effects depend on the failure reason and the provider/pool. +// +// - quota: status="quota"; when no cached_quota_reset_after is present, set +// quota_recover_at to next UTC midnight so the maintenance loop can revive it. +// - auth on chatgpt: status="disabled" + dead=true (the access token IS the +// credential; a 401 means it's dead). +// - auth on adobe: NOT disabled/dead — the access token auto-refreshes from the +// cookie, so rotate for this request and let the refresh loop mint a new one. +// - other (non-auth/non-quota): NEITHER pool is auto-disabled — accounts stay +// active/green and fails is tracked only for rotation ordering. +func (s *V1Service) markTokenFailure(ctx context.Context, pool string, token model.TokenAccount, kind string, isAuth, isQuota bool) { + patch := map[string]any{ + "last_used_at": time.Now(), + "fail_total": gorm.Expr("fail_total + 1"), + "fails": gorm.Expr("fails + 1"), + } + switch { + case isQuota: + // Adobe quota is per-kind: a video-quota error must not block image + // requests (and vice-versa). Flag only the failing kind, and only sink + // the account into the shared "quota" waiting status once BOTH kinds are + // limited. Other pools (chatgpt) are single-kind, so they go straight to + // "quota" as before. + if pool == "adobe" { + imageLimited := token.ImageLimited + videoLimited := token.VideoLimited + if kind == "video" { + videoLimited = true + patch["video_limited"] = true + } else { + imageLimited = true + patch["image_limited"] = true + } + if imageLimited && videoLimited { + patch["status"] = "quota" + } + } else { + patch["status"] = "quota" + } + if strings.TrimSpace(token.CachedQuotaResetAfter) == "" { + recoverAt := time.Unix((time.Now().Unix()/86400+1)*86400, 0).UTC() + patch["quota_recover_at"] = &recoverAt + } + case isAuth: + // Adobe auth failures are NOT disabling: the access token refreshes from + // the cookie. chatgpt/runway/leonardo auth means the stored credential is + // dead — a raw JWT (chatgpt/runway) or a cookie whose session no longer + // authenticates (leonardo) — there's nothing left to refresh from. + if pool == "chatgpt" || pool == "runway" || pool == "leonardo" || pool == "krea" || pool == "imagine" { + patch["status"] = "disabled" + patch["dead"] = true + } + default: + // Neither pool is auto-disabled on generic (non-auth / non-quota) failures + // — the account usually still works, so it stays active (green). fails is + // only tracked for rotation ordering. (A chatgpt *auth* failure still marks + // the token dead in the isAuth case above; that is a genuinely dead token.) + } + _, _ = s.tokens.Update(ctx, pool, token.ID, patch) +} + +// nextCursor returns the pool's current round-robin position and atomically +// advances it by one. Concurrent callers each get a distinct value, so parallel +// picks land on different accounts instead of racing onto the same one. The +// counter is in-memory (per process): it resets on restart, which only shifts +// the rotation's starting point — distribution stays even. +func (s *V1Service) nextCursor(pool string) uint64 { + v, _ := s.tokenCursors.LoadOrStore(pool, new(uint64)) + return atomic.AddUint64(v.(*uint64), 1) - 1 +} + +// rotateRoundRobin orders the active accounts by a stable key (ID) and rotates +// the slice in place so iteration begins at the pool's current cursor position, +// then advances the cursor. This is strict round-robin: account selection +// cycles in fixed order regardless of fails or last_used. The fall-through +// retry chain is preserved — on failure the caller's loop simply continues to +// the next account in rotation order. +func (s *V1Service) rotateRoundRobin(pool string, items []model.TokenAccount) { + sort.SliceStable(items, func(i, j int) bool { + return items[i].ID < items[j].ID + }) + n := len(items) + if n <= 1 { + return + } + start := int(s.nextCursor(pool) % uint64(n)) + if start == 0 { + return + } + rotated := make([]model.TokenAccount, 0, n) + rotated = append(rotated, items[start:]...) + rotated = append(rotated, items[:start]...) + copy(items, rotated) +} diff --git a/backend/internal/service/validation.go b/backend/internal/service/validation.go new file mode 100644 index 0000000..630d607 --- /dev/null +++ b/backend/internal/service/validation.go @@ -0,0 +1,161 @@ +package service + +import ( + "errors" + "net/mail" + "regexp" + "strings" + "unicode" + "unicode/utf8" +) + +const ( + MinUsernameLength = 6 + MaxUsernameLength = 24 + MinPasswordLength = 8 + MaxPasswordLength = 24 +) + +var ( + usernamePattern = regexp.MustCompile(`^[A-Za-z0-9]{6,24}$`) + emailCodePattern = regexp.MustCompile(`^\d{6}$`) +) + +func ValidateEmail(email string) (string, error) { + normalized := strings.TrimSpace(strings.ToLower(email)) + if normalized == "" { + return "", errors.New("邮箱不能为空") + } + if len(normalized) > 254 { + return "", errors.New("邮箱长度不能超过 254 个字符") + } + addr, err := mail.ParseAddress(normalized) + if err != nil || strings.TrimSpace(strings.ToLower(addr.Address)) != normalized { + return "", errors.New("邮箱格式不正确") + } + local, domain, ok := strings.Cut(normalized, "@") + if !ok || local == "" || domain == "" || strings.Contains(domain, "..") || !strings.Contains(domain, ".") { + return "", errors.New("邮箱格式不正确") + } + return normalized, nil +} + +func ValidateUsername(username string) (string, error) { + normalized := strings.TrimSpace(username) + if normalized == "" { + return "", errors.New("用户名不能为空") + } + length := utf8.RuneCountInString(normalized) + if length < MinUsernameLength || length > MaxUsernameLength { + return "", errors.New("用户名长度需为 6 到 24 个字符") + } + if !usernamePattern.MatchString(normalized) { + return "", errors.New("用户名只能使用字母和数字") + } + return normalized, nil +} + +func ValidatePassword(password string) error { + length := utf8.RuneCountInString(password) + if length < MinPasswordLength || length > MaxPasswordLength { + return errors.New("密码长度需为 8 到 24 个字符") + } + + var hasLetter bool + var hasUpper bool + var hasLower bool + var hasDigit bool + var hasSymbol bool + for _, r := range password { + if unicode.IsSpace(r) { + return errors.New("密码不能包含空白字符") + } + if !isAllowedPasswordRune(r) { + return errors.New("密码包含不允许的字符") + } + if unicode.IsLetter(r) { + hasLetter = true + if unicode.IsUpper(r) { + hasUpper = true + } + if unicode.IsLower(r) { + hasLower = true + } + } + if unicode.IsDigit(r) { + hasDigit = true + } + if !unicode.IsLetter(r) && !unicode.IsDigit(r) { + hasSymbol = true + } + } + if !hasLetter || !hasUpper || !hasLower || !hasDigit || !hasSymbol { + return errors.New("密码必须同时包含大写字母、小写字母、数字和符号") + } + return nil +} + +func isAllowedPasswordRune(r rune) bool { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + return true + } + switch r { + case '(', ')', '~', '!', '@', '#', '$', '%', '^', '&', '*', '-', '_', '+', '=', '|', + '{', '}', '[', ']', ':', ';', '\'', '<', '>', ',', '.', '?', '/': + return true + default: + return false + } +} + +func ValidateEmailCode(code string) (string, error) { + normalized := strings.TrimSpace(code) + if !emailCodePattern.MatchString(normalized) { + return "", errors.New("邮箱验证码必须是 6 位纯数字") + } + return normalized, nil +} + +func ValidateLoginIdentifier(identifier string) (string, error) { + normalized := strings.TrimSpace(identifier) + if normalized == "" { + return "", errors.New("账号不能为空") + } + if strings.Contains(normalized, "@") { + return ValidateEmail(normalized) + } + return ValidateUsername(normalized) +} + +func ValidateAllowedEmailDomains(domains []string) []string { + out := make([]string, 0, len(domains)) + seen := map[string]struct{}{} + for _, raw := range domains { + normalized := strings.TrimSpace(strings.ToLower(strings.TrimPrefix(raw, "@"))) + if normalized == "" || strings.Contains(normalized, " ") { + continue + } + if _, ok := seen[normalized]; ok { + continue + } + seen[normalized] = struct{}{} + out = append(out, normalized) + } + return out +} + +func EmailDomainAllowed(email string, domains []string) bool { + if len(domains) == 0 { + return true + } + _, domain, ok := strings.Cut(strings.ToLower(strings.TrimSpace(email)), "@") + if !ok { + return false + } + for _, allowed := range ValidateAllowedEmailDomains(domains) { + if domain == allowed { + return true + } + } + return false +} diff --git a/backend/internal/storage/rustfs.go b/backend/internal/storage/rustfs.go new file mode 100644 index 0000000..8b34cb6 --- /dev/null +++ b/backend/internal/storage/rustfs.go @@ -0,0 +1,294 @@ +// Package storage is a minimal S3-compatible client for RustFS, implemented with +// AWS Signature V4 over the standard library only (no external SDK — the build +// environment can't reach the Go module proxy). It's intentionally small: Put / +// Get / Delete / List cover everything the app needs (store generated media, +// proxy it back through /images, list for the admin gallery, prune by age). +// +// The surface mirrors what a thin wrapper over aws-sdk-go-v2 would expose, so it +// can be swapped for the official SDK later by reimplementing this one file. +package storage + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/xml" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const ( + region = "us-east-1" // RustFS ignores the value but SigV4 requires a fixed one + service = "s3" +) + +type Client struct { + endpoint string // e.g. http://154.9.26.140:9000 (no trailing slash) + host string // e.g. 154.9.26.140:9000 + bucket string + ak, sk string + http *http.Client +} + +// Object is one entry returned by List. +type Object struct { + Key string + Size int64 + LastModified time.Time +} + +// New builds a client. endpoint must include the scheme (http:// or https://). +func New(endpoint, bucket, accessKey, secretKey string) *Client { + endpoint = strings.TrimRight(strings.TrimSpace(endpoint), "/") + host := endpoint + if i := strings.Index(host, "://"); i >= 0 { + host = host[i+3:] + } + return &Client{ + endpoint: endpoint, + host: host, + bucket: strings.TrimSpace(bucket), + ak: strings.TrimSpace(accessKey), + sk: strings.TrimSpace(secretKey), + http: &http.Client{Timeout: 60 * time.Second}, + } +} + +// Configured reports whether the client has the minimum config to be usable. +func (c *Client) Configured() bool { + return c != nil && c.endpoint != "" && c.bucket != "" && c.ak != "" && c.sk != "" +} + +// PublicURL is the direct object URL (used only for reference/debugging — the app +// serves through the authenticated /images proxy, not this). +func (c *Client) PublicURL(key string) string { + return c.endpoint + "/" + c.bucket + "/" + strings.TrimPrefix(key, "/") +} + +// Put uploads body under key with the given content type. +func (c *Client) Put(ctx context.Context, key string, body []byte, contentType string) error { + resp, err := c.do(ctx, http.MethodPut, c.bucket+"/"+key, nil, body, contentType, nil) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return c.statusErr("put", key, resp) + } + return nil +} + +// Get fetches key. The caller owns resp.Body (must Close it) and streams it. A +// non-empty rangeHeader is forwarded verbatim (for video seeking). Returns the +// raw *http.Response so headers/status can be passed through by the proxy. +func (c *Client) Get(ctx context.Context, key, rangeHeader string) (*http.Response, error) { + extra := map[string]string{} + if strings.TrimSpace(rangeHeader) != "" { + extra["Range"] = rangeHeader + } + return c.do(ctx, http.MethodGet, c.bucket+"/"+key, nil, nil, "", extra) +} + +// Delete removes key. A missing object is not an error. +func (c *Client) Delete(ctx context.Context, key string) error { + resp, err := c.do(ctx, http.MethodDelete, c.bucket+"/"+key, nil, nil, "", nil) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 && resp.StatusCode != http.StatusNotFound { + return c.statusErr("delete", key, resp) + } + return nil +} + +// List returns every object whose key starts with prefix (paginated internally). +func (c *Client) List(ctx context.Context, prefix string) ([]Object, error) { + var out []Object + token := "" + for { + q := map[string]string{"list-type": "2", "max-keys": "1000"} + if prefix != "" { + q["prefix"] = prefix + } + if token != "" { + q["continuation-token"] = token + } + resp, err := c.do(ctx, http.MethodGet, c.bucket, q, nil, "", nil) + if err != nil { + return nil, err + } + data, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode/100 != 2 { + return nil, fmt.Errorf("rustfs list: status %d: %s", resp.StatusCode, truncate(data)) + } + var parsed struct { + Contents []struct { + Key string `xml:"Key"` + Size int64 `xml:"Size"` + LastModified time.Time `xml:"LastModified"` + } `xml:"Contents"` + IsTruncated bool `xml:"IsTruncated"` + NextContinuationToken string `xml:"NextContinuationToken"` + } + if err := xml.Unmarshal(data, &parsed); err != nil { + return nil, fmt.Errorf("rustfs list: parse: %w", err) + } + for _, it := range parsed.Contents { + out = append(out, Object{Key: it.Key, Size: it.Size, LastModified: it.LastModified}) + } + if !parsed.IsTruncated || parsed.NextContinuationToken == "" { + break + } + token = parsed.NextContinuationToken + } + return out, nil +} + +func (c *Client) statusErr(op, key string, resp *http.Response) error { + data, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("rustfs %s %q: status %d: %s", op, key, resp.StatusCode, truncate(data)) +} + +func truncate(b []byte) string { + s := strings.TrimSpace(string(b)) + if len(s) > 300 { + return s[:300] + } + return s +} + +// do builds, signs (SigV4) and sends a request. resourcePath is the path after +// the host WITHOUT a leading slash (e.g. "bucket/dir/file.png" or "bucket"). +func (c *Client) do(ctx context.Context, method, resourcePath string, query map[string]string, body []byte, contentType string, extraHeaders map[string]string) (*http.Response, error) { + now := time.Now().UTC() + amzDate := now.Format("20060102T150405Z") + dateStamp := now.Format("20060102") + + canonicalURI := "/" + uriEncode(resourcePath, true) + canonicalQuery := canonicalQueryString(query) + + payloadHash := hexSHA256(body) + // Signed headers: always host + x-amz-content-sha256 + x-amz-date, plus + // content-type on PUT. Range etc. are sent unsigned. + signed := map[string]string{ + "host": c.host, + "x-amz-content-sha256": payloadHash, + "x-amz-date": amzDate, + } + if strings.TrimSpace(contentType) != "" { + signed["content-type"] = contentType + } + names := sortedKeys(signed) + var canonHeaders strings.Builder + for _, k := range names { + canonHeaders.WriteString(k + ":" + signed[k] + "\n") + } + signedHeaders := strings.Join(names, ";") + + canonicalRequest := strings.Join([]string{ + method, canonicalURI, canonicalQuery, canonHeaders.String(), signedHeaders, payloadHash, + }, "\n") + + scope := dateStamp + "/" + region + "/" + service + "/aws4_request" + stringToSign := strings.Join([]string{ + "AWS4-HMAC-SHA256", amzDate, scope, hexSHA256([]byte(canonicalRequest)), + }, "\n") + signature := hex.EncodeToString(hmacSHA256(signingKey(c.sk, dateStamp), []byte(stringToSign))) + auth := fmt.Sprintf("AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s", + c.ak, scope, signedHeaders, signature) + + url := c.endpoint + canonicalURI + if canonicalQuery != "" { + url += "?" + canonicalQuery + } + var rdr io.Reader + if body != nil { + rdr = bytes.NewReader(body) + } + req, err := http.NewRequestWithContext(ctx, method, url, rdr) + if err != nil { + return nil, err + } + req.Host = c.host + req.Header.Set("Authorization", auth) + req.Header.Set("x-amz-date", amzDate) + req.Header.Set("x-amz-content-sha256", payloadHash) + if ct := strings.TrimSpace(contentType); ct != "" { + req.Header.Set("Content-Type", ct) + } + for k, v := range extraHeaders { + req.Header.Set(k, v) + } + return c.http.Do(req) +} + +// ---- SigV4 helpers ---- + +func hexSHA256(b []byte) string { + h := sha256.Sum256(b) + return hex.EncodeToString(h[:]) +} + +func hmacSHA256(key, msg []byte) []byte { + h := hmac.New(sha256.New, key) + h.Write(msg) + return h.Sum(nil) +} + +func signingKey(secret, dateStamp string) []byte { + kDate := hmacSHA256([]byte("AWS4"+secret), []byte(dateStamp)) + kRegion := hmacSHA256(kDate, []byte(region)) + kService := hmacSHA256(kRegion, []byte(service)) + return hmacSHA256(kService, []byte("aws4_request")) +} + +func sortedKeys(m map[string]string) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + // simple insertion sort (small n) + for i := 1; i < len(out); i++ { + for j := i; j > 0 && out[j-1] > out[j]; j-- { + out[j-1], out[j] = out[j], out[j-1] + } + } + return out +} + +func canonicalQueryString(q map[string]string) string { + if len(q) == 0 { + return "" + } + keys := sortedKeys(q) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, uriEncode(k, false)+"="+uriEncode(q[k], false)) + } + return strings.Join(parts, "&") +} + +// uriEncode applies AWS's URI encoding rules. When keepSlash is true, '/' is left +// as-is (for object key paths); otherwise it's percent-encoded (for query parts). +func uriEncode(s string, keepSlash bool) string { + var b strings.Builder + for _, r := range []byte(s) { + switch { + case (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'), + r == '-', r == '_', r == '.', r == '~': + b.WriteByte(r) + case r == '/' && keepSlash: + b.WriteByte('/') + default: + b.WriteString(fmt.Sprintf("%%%02X", r)) + } + } + return b.String() +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..32a42a0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,165 @@ +# image2api — one-command stack: PostgreSQL + Redis + RustFS (S3) + backend +# (pre-built binary, closed-source — backend/bin/api ships with the repo) + +# frontend/nginx (open-source, built here). +# 1) cp .env.docker.example .env # 填 DOMAIN / ACME_EMAIL / 密码 / 密钥 +# 2) docker compose up -d --build (或 sh install.sh) +# Then open https:/// (nginx serves the SPA, proxies the API, TLS via acme.sh). +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: vivid_ai + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me-postgres} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d vivid_ai"] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + + redis: + image: redis:7-alpine + command: ["redis-server", "--appendonly", "yes"] + volumes: + - redisdata:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + + # RustFS — S3-compatible object storage. The backend talks plain S3 (v4-signed) + # on port 9000. Media is served (cookie-authed) via the backend's /images + # proxy, so the bucket stays private. + rustfs: + image: rustfs/rustfs:latest + environment: + RUSTFS_ACCESS_KEY: ${S3_ACCESS_KEY:-vividai} + RUSTFS_SECRET_KEY: ${S3_SECRET_KEY:-vividai-secret-change-me} + RUSTFS_ADDRESS: ":9000" + RUSTFS_VOLUMES: /data + volumes: + - rustfsdata:/data + restart: unless-stopped + + # One-shot: wait for RustFS, create the bucket, then exit. mc (the MinIO client) + # speaks S3 so it works against RustFS too. + createbucket: + image: minio/mc:latest + depends_on: + - rustfs + entrypoint: > + /bin/sh -c " + until mc alias set s3 http://rustfs:9000 ${S3_ACCESS_KEY:-vividai} ${S3_SECRET_KEY:-vividai-secret-change-me}; do + echo 'waiting for rustfs...'; sleep 2; + done; + mc mb -p s3/${S3_BUCKET:-vivid-ai} || true; + echo 'bucket ready'; + " + restart: "no" + + backend: + build: + context: ./backend + environment: + APP_ENV: production + APP_TITLE: ${APP_TITLE:-image2api} + HTTP_ADDR: 0.0.0.0:6666 + POSTGRES_DSN: host=postgres user=postgres password=${POSTGRES_PASSWORD:-change-me-postgres} dbname=vivid_ai port=5432 sslmode=disable TimeZone=Asia/Shanghai + REDIS_ADDR: redis:6379 + REDIS_PASSWORD: "" + REDIS_DB: "0" + RUSTFS_ENDPOINT: http://rustfs:9000 + RUSTFS_BUCKET: ${S3_BUCKET:-vivid-ai} + RUSTFS_ACCESS_KEY: ${S3_ACCESS_KEY:-vividai} + RUSTFS_SECRET_KEY: ${S3_SECRET_KEY:-vividai-secret-change-me} + CORS_ORIGINS: https://${DOMAIN:-localhost} + COOKIE_SECURE: ${COOKIE_SECURE:-true} + volumes: + - generated:/app/data/generated + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + createbucket: + condition: service_completed_successfully + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:6666/health"] + interval: 10s + timeout: 5s + retries: 10 + restart: unless-stopped + + web: + build: + context: ./frontend + environment: + DOMAIN: ${DOMAIN:-localhost} + # Only substitute ${DOMAIN} in the nginx template — leave $host/$uri/etc. + NGINX_ENVSUBST_FILTER: DOMAIN + ports: + - "${HTTP_PORT:-80}:80" + - "${HTTPS_PORT:-443}:443" + volumes: + - certs:/etc/nginx/certs + - acme_webroot:/var/www/acme + depends_on: + - backend + restart: unless-stopped + + # acme.sh — issues + auto-renews the Let's Encrypt cert via http-01 (webroot + # shared with nginx) into the shared certs volume. nginx's cert-watch reloads + # it. Requires DOMAIN to resolve to this host and ports 80/443 reachable. + acme: + image: neilpang/acme.sh:latest + depends_on: + - web + environment: + DOMAIN: ${DOMAIN:-localhost} + ACME_EMAIL: ${ACME_EMAIL:-admin@example.com} + # Persist account + cert state here (a volume on /acme.sh would shadow the + # installed binary, so use a separate config-home). + LE_CONFIG_HOME: /acme-data + volumes: + - certs:/etc/nginx/certs + - acme_webroot:/var/www/acme + - acmedata:/acme-data + entrypoint: ["/bin/sh", "-c"] + command: + - | + set -e + D="$${DOMAIN:-localhost}" + # Skip ACME for non-public domains (localhost / bare IP) — the self-signed + # bootstrap cert keeps 443 working for local testing. + case "$$D" in + localhost|127.0.0.1|"") echo "acme: DOMAIN=$$D not public, skipping issuance"; exec tail -f /dev/null ;; + esac + mkdir -p /etc/nginx/certs/live/$$D /var/www/acme /acme-data + acme.sh --set-default-ca --server letsencrypt + acme.sh --register-account -m "$${ACME_EMAIL}" || true + # Retry the first issue until nginx :80 is reachable for the http-01 check. + i=0 + while [ $$i -lt 10 ]; do + if acme.sh --issue -d "$$D" -w /var/www/acme --keylength ec-256; then break; fi + i=$$((i+1)); echo "acme: issue attempt $$i failed, retry in 30s"; sleep 30 + done + acme.sh --install-cert -d "$$D" --ecc \ + --key-file /etc/nginx/certs/live/$$D/privkey.pem \ + --fullchain-file /etc/nginx/certs/live/$$D/fullchain.pem || true + # daemon: renew checks daily; renewals auto-reinstall to the paths above. + while true; do sleep 12h; acme.sh --cron || true; done + restart: unless-stopped + +volumes: + pgdata: + redisdata: + rustfsdata: + generated: + certs: + acme_webroot: + acmedata: diff --git a/docs/screenshots/accounts.png b/docs/screenshots/accounts.png new file mode 100644 index 0000000000000000000000000000000000000000..572834f14411eed1c77fbd6419c139844f268451 GIT binary patch literal 43234 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 LG6GhiXdd)mi`{>X literal 0 HcmV?d00001 diff --git a/docs/screenshots/dashboard.png b/docs/screenshots/dashboard.png new file mode 100644 index 0000000000000000000000000000000000000000..4590b355d08bd7b26c2767b3f894dd713fb64afa GIT binary patch literal 222559 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 z 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 KrGjgwPyY|&wQfHE literal 0 HcmV?d00001 diff --git a/docs/screenshots/logs.png b/docs/screenshots/logs.png new file mode 100644 index 0000000000000000000000000000000000000000..c92db83672d826a706cbc7b855c457e5c1231d0e GIT binary patch literal 194002 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 @4>2Ep>87@w^ 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 z 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

N?3!-At^88rn(3M@(uT}{SL#aaT~B=Yn*ZP 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%0D#v(OohRw`(D!Cdy%72u85t*8LZf&zS^#U6 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

Gb;t=T`-QvaG)e=JC|_gzHC)LPOi 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

Jt25kz0ro-YD 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�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

x#H1p5~t%Djd1+hsZe)iK+16f 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 backend ---- + # 动态 API no-store,防止 CDN 缓存 GET 响应(如 managed-models)→ 改价不生效。 + location ^~ /admin/api/ { proxy_pass http://backend:6666; add_header Cache-Control "no-store" always; } + location ^~ /images/ { proxy_pass http://backend:6666; } + location = /health { proxy_pass http://backend:6666; } + + # /v1 is per-API-key authenticated — must NOT be cached by any CDN/proxy. + location ^~ /v1/ { + proxy_pass http://backend:6666; + add_header Cache-Control "no-store" always; + } +} diff --git a/frontend/docker-entrypoint.d/10-selfsigned.sh b/frontend/docker-entrypoint.d/10-selfsigned.sh new file mode 100644 index 0000000..4e2540e --- /dev/null +++ b/frontend/docker-entrypoint.d/10-selfsigned.sh @@ -0,0 +1,14 @@ +#!/bin/sh +# Bootstrap a self-signed cert so nginx's 443 server block can start BEFORE +# acme.sh has issued the real certificate. acme.sh later overwrites these files +# in the shared volume; 30-cert-watch.sh reloads nginx when that happens. +set -e +D="${DOMAIN:-localhost}" +CERT_DIR="/etc/nginx/certs/live/$D" +mkdir -p "$CERT_DIR" /var/www/acme +if [ ! -s "$CERT_DIR/fullchain.pem" ] || [ ! -s "$CERT_DIR/privkey.pem" ]; then + echo "nginx: generating self-signed bootstrap cert for $D" + openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \ + -keyout "$CERT_DIR/privkey.pem" -out "$CERT_DIR/fullchain.pem" \ + -subj "/CN=$D" >/dev/null 2>&1 +fi diff --git a/frontend/docker-entrypoint.d/30-cert-watch.sh b/frontend/docker-entrypoint.d/30-cert-watch.sh new file mode 100644 index 0000000..ebed017 --- /dev/null +++ b/frontend/docker-entrypoint.d/30-cert-watch.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# Reload nginx whenever the certificate file changes — i.e. once acme.sh has +# issued/renewed the real cert into the shared volume, nginx picks it up within +# ~a minute without a container restart. Runs in the background so it doesn't +# block startup. +D="${DOMAIN:-localhost}" +CERT="/etc/nginx/certs/live/$D/fullchain.pem" +( + last="" + while true; do + sleep 60 + cur="$(stat -c %Y "$CERT" 2>/dev/null || echo '')" + if [ -n "$cur" ] && [ "$cur" != "$last" ]; then + # Skip the very first observation (the self-signed bootstrap); only reload + # on a genuine change (acme.sh overwrote the cert). + [ -n "$last" ] && nginx -s reload 2>/dev/null || true + last="$cur" + fi + done +) & diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..83f6278 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Vivid 首页 + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..3607cb5 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1986 @@ +{ + "name": "ai-gateway-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ai-gateway-frontend", + "version": "0.1.0", + "dependencies": { + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@vitejs/plugin-vue": "^5.2.1", + "tailwindcss": "^4.0.0", + "vite": "^6.0.7" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", + "integrity": "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.0.tgz", + "integrity": "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.0.tgz", + "integrity": "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.0.tgz", + "integrity": "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.0.tgz", + "integrity": "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.0.tgz", + "integrity": "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.0.tgz", + "integrity": "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.0.tgz", + "integrity": "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.0.tgz", + "integrity": "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.0.tgz", + "integrity": "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.0.tgz", + "integrity": "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.0.tgz", + "integrity": "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.0.tgz", + "integrity": "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.0.tgz", + "integrity": "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.0.tgz", + "integrity": "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.0.tgz", + "integrity": "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.0.tgz", + "integrity": "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.0.tgz", + "integrity": "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.0.tgz", + "integrity": "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.0.tgz", + "integrity": "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.0.tgz", + "integrity": "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.0.tgz", + "integrity": "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.0.tgz", + "integrity": "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz", + "integrity": "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz", + "integrity": "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.3.1.tgz", + "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide/-/oxide-4.3.1.tgz", + "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-x64": "4.3.1", + "@tailwindcss/oxide-freebsd-x64": "4.3.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-x64-musl": "4.3.1", + "@tailwindcss/oxide-wasm32-wasi": "4.3.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", + "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", + "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", + "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", + "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", + "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", + "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", + "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", + "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", + "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", + "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", + "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", + "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/vite/-/vite-4.3.1.tgz", + "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.1", + "@tailwindcss/oxide": "4.3.1", + "tailwindcss": "4.3.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.38.tgz", + "integrity": "sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.38", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.38.tgz", + "integrity": "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.38", + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.38.tgz", + "integrity": "sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.38", + "@vue/compiler-dom": "3.5.38", + "@vue/compiler-ssr": "3.5.38", + "@vue/shared": "3.5.38", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.38.tgz", + "integrity": "sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.38", + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.38.tgz", + "integrity": "sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.38.tgz", + "integrity": "sha512-iyW8WVfF1CpCXxncZY5Ei6rSd6oZr5DgEom//fUjRBRl56AXPD+s9ATvukRt77ZFTuYlnVA1bxY+dJB94tWVYw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.38", + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.38.tgz", + "integrity": "sha512-apX2wt9sdfDshS+a2xueFZLVpt0GkRJZSoPmrW/SA4yzXTznhfcMVW59gr7h4YQeY0vJhdJkk2rsIDwgfFgC5A==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.38", + "@vue/runtime-core": "3.5.38", + "@vue/shared": "3.5.38", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.38.tgz", + "integrity": "sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.38", + "@vue/shared": "3.5.38" + }, + "peerDependencies": { + "vue": "3.5.38" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.38.tgz", + "integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.0.tgz", + "integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.0", + "@rollup/rollup-android-arm64": "4.62.0", + "@rollup/rollup-darwin-arm64": "4.62.0", + "@rollup/rollup-darwin-x64": "4.62.0", + "@rollup/rollup-freebsd-arm64": "4.62.0", + "@rollup/rollup-freebsd-x64": "4.62.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", + "@rollup/rollup-linux-arm-musleabihf": "4.62.0", + "@rollup/rollup-linux-arm64-gnu": "4.62.0", + "@rollup/rollup-linux-arm64-musl": "4.62.0", + "@rollup/rollup-linux-loong64-gnu": "4.62.0", + "@rollup/rollup-linux-loong64-musl": "4.62.0", + "@rollup/rollup-linux-ppc64-gnu": "4.62.0", + "@rollup/rollup-linux-ppc64-musl": "4.62.0", + "@rollup/rollup-linux-riscv64-gnu": "4.62.0", + "@rollup/rollup-linux-riscv64-musl": "4.62.0", + "@rollup/rollup-linux-s390x-gnu": "4.62.0", + "@rollup/rollup-linux-x64-gnu": "4.62.0", + "@rollup/rollup-linux-x64-musl": "4.62.0", + "@rollup/rollup-openbsd-x64": "4.62.0", + "@rollup/rollup-openharmony-arm64": "4.62.0", + "@rollup/rollup-win32-arm64-msvc": "4.62.0", + "@rollup/rollup-win32-ia32-msvc": "4.62.0", + "@rollup/rollup-win32-x64-gnu": "4.62.0", + "@rollup/rollup-win32-x64-msvc": "4.62.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.3.1.tgz", + "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.38.tgz", + "integrity": "sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.38", + "@vue/compiler-sfc": "3.5.38", + "@vue/runtime-dom": "3.5.38", + "@vue/server-renderer": "3.5.38", + "@vue/shared": "3.5.38" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..094caac --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,21 @@ +{ + "name": "vivid-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@vitejs/plugin-vue": "^5.2.1", + "tailwindcss": "^4.0.0", + "vite": "^6.0.7" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..79153eb --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..f88c1e2 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..3ea7285 --- /dev/null +++ b/frontend/src/api.js @@ -0,0 +1,53 @@ +// Thin fetch wrapper mirroring the old admin.html `API()` helper. +// In dev, requests use relative paths and are proxied by Vite to the backend. +// For a separately-hosted frontend, set VITE_API_BASE (e.g. http://host:6060). +import { auth, clearSession } from './auth' + +const BASE = import.meta.env.VITE_API_BASE || '' + +/** Call an /admin/api endpoint. Returns { ok, status, data }. Automatically + * attaches the bearer token and clears the session on a 401 so admin pages + * fall back to the login screen via the router guard. */ +export async function api(path, opts = {}) { + const headers = { ...(opts.headers || {}) } + if (auth.token) headers.Authorization = `Bearer ${auth.token}` + const r = await fetch(`${BASE}/admin/api${path}`, { ...opts, headers }) + let data = null + try { + data = await r.json() + } catch { + data = null + } + // A 401 only means "log out" when it's the *caller's* session that's invalid. + // Business/upstream failures (a dead provider account, etc.) must NOT clear + // the session — they used to surface as 401 and kick the user out mid-action. + // The backend now flags genuine session expiry with detail "未登录或会话已过期"; + // treat only those (or a token-less 401) as a real logout signal. + if (r.status === 401) { + const detail = data?.detail || '' + if (!auth.token || detail.includes('未登录') || detail.includes('会话')) { + clearSession() + } + } + return { ok: r.ok, status: r.status, data } +} + +/** Shorthand for a JSON POST/PATCH body. */ +export function jsonBody(method, payload) { + return { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + } +} + +/** Health check hitting the plain /health endpoint. */ +export async function fetchHealth() { + const r = await fetch(`${BASE}/health`) + return r.json() +} + +/** Absolute URL for a generated artifact (works in dev via proxy too). */ +export function generatedUrl(name) { + return `${BASE}/images/${name}` +} diff --git a/frontend/src/auth.js b/frontend/src/auth.js new file mode 100644 index 0000000..a61f739 --- /dev/null +++ b/frontend/src/auth.js @@ -0,0 +1,85 @@ +// Client-side auth state. The session token lives in localStorage and is sent +// as `Authorization: Bearer ` on every admin API call (see api.js). +// The server slides the 24h session whenever it's used with <22h left, so an +// active admin never gets logged out; we also re-validate via /me on a timer. +import { reactive } from 'vue' + +const TOKEN_KEY = 'gw_token' +const BASE = import.meta.env.VITE_API_BASE || '' + +export const auth = reactive({ + token: localStorage.getItem(TOKEN_KEY) || '', + user: null, // { id, email, name, role, status, credits, invite_code, ... } + ready: false, // true once an initial /me check has resolved + loginOpen: false, // is the login modal showing? + loginIntent: '', // where to go after a successful login + startMode: 'login',// which tab the modal opens on: 'login' | 'register' + pendingInvite: '', // invite code from a /?ref=CODE link, sent with register +}) + +export function isAuthed() { return !!auth.token && !!auth.user } +export function isAdmin() { return isAuthed() && auth.user.role === 'admin' } +export function isAgent() { return isAuthed() && auth.user.role === 'agent' } +export function getToken() { return auth.token } + +/** Open the login modal, remembering where the user wanted to go. */ +export function openLogin(intent = '') { auth.loginIntent = intent || ''; auth.startMode = 'login'; auth.loginOpen = true } +/** Open straight to the register tab, carrying an optional invite code. + * Default landing is the home page — an invitee clicking a /?ref=CODE + * link should NOT be punted into the画图 flow before they've explored. */ +export function openRegister(inviteCode = '', intent = '/') { + auth.pendingInvite = inviteCode || '' + auth.loginIntent = intent || '' + auth.startMode = 'register' + auth.loginOpen = true +} +export function closeLogin() { auth.loginOpen = false } + +export function setSession(token, user) { + auth.token = token || '' + auth.user = user || null + if (token) localStorage.setItem(TOKEN_KEY, token) + else localStorage.removeItem(TOKEN_KEY) +} + +export function clearSession() { + auth.token = '' + auth.user = null + localStorage.removeItem(TOKEN_KEY) +} + +/** Validate the stored token against /me. Refreshes auth.user; clears on 401. + * Returns the user (or null). Hitting /me also slides the server session. */ +export async function refreshMe() { + if (!auth.token) { auth.user = null; auth.ready = true; return null } + try { + const r = await fetch(`${BASE}/admin/api/auth/me`, { + headers: { Authorization: `Bearer ${auth.token}` }, + }) + if (r.ok) { + const d = await r.json() + auth.user = d.user + } else { + clearSession() + } + } catch { + // network error — keep the token, don't force a logout + } + auth.ready = true + return auth.user +} + +export async function logout() { + if (auth.token) { + try { + await fetch(`${BASE}/admin/api/auth/logout`, { + method: 'POST', + headers: { Authorization: `Bearer ${auth.token}` }, + }) + } catch { /* ignore */ } + } + clearSession() +} + +// Keep the session warm: re-validate every 10 minutes while a tab is open. +setInterval(() => { if (auth.token) refreshMe() }, 10 * 60 * 1000) diff --git a/frontend/src/components/Icon.vue b/frontend/src/components/Icon.vue new file mode 100644 index 0000000..b2440eb --- /dev/null +++ b/frontend/src/components/Icon.vue @@ -0,0 +1,32 @@ + + + diff --git a/frontend/src/components/ImportModal.vue b/frontend/src/components/ImportModal.vue new file mode 100644 index 0000000..9e3f0eb --- /dev/null +++ b/frontend/src/components/ImportModal.vue @@ -0,0 +1,134 @@ + + + diff --git a/frontend/src/components/LoginModal.vue b/frontend/src/components/LoginModal.vue new file mode 100644 index 0000000..d049c6b --- /dev/null +++ b/frontend/src/components/LoginModal.vue @@ -0,0 +1,404 @@ + + + + + diff --git a/frontend/src/components/Logo.vue b/frontend/src/components/Logo.vue new file mode 100644 index 0000000..4ec4e44 --- /dev/null +++ b/frontend/src/components/Logo.vue @@ -0,0 +1,39 @@ + + + diff --git a/frontend/src/components/MediaLightbox.vue b/frontend/src/components/MediaLightbox.vue new file mode 100644 index 0000000..4fa9fd6 --- /dev/null +++ b/frontend/src/components/MediaLightbox.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/frontend/src/components/ModelFormModal.vue b/frontend/src/components/ModelFormModal.vue new file mode 100644 index 0000000..c9e18ac --- /dev/null +++ b/frontend/src/components/ModelFormModal.vue @@ -0,0 +1,342 @@ + + + + + diff --git a/frontend/src/components/SelectMenu.vue b/frontend/src/components/SelectMenu.vue new file mode 100644 index 0000000..fc25919 --- /dev/null +++ b/frontend/src/components/SelectMenu.vue @@ -0,0 +1,109 @@ + + + diff --git a/frontend/src/components/TagInput.vue b/frontend/src/components/TagInput.vue new file mode 100644 index 0000000..9e965d0 --- /dev/null +++ b/frontend/src/components/TagInput.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/frontend/src/components/TestModal.vue b/frontend/src/components/TestModal.vue new file mode 100644 index 0000000..522d089 --- /dev/null +++ b/frontend/src/components/TestModal.vue @@ -0,0 +1,314 @@ + + + + + diff --git a/frontend/src/credits.js b/frontend/src/credits.js new file mode 100644 index 0000000..b250c13 --- /dev/null +++ b/frontend/src/credits.js @@ -0,0 +1,12 @@ +// Credit display helpers. Credits and model prices are stored server-side as +// integer 积分 (points) — the single unit across the whole app. These helpers +// only format for display; they do NOT convert units. +/** Round to an integer 积分 value. */ +export function points(value) { + return Math.round(Number(value || 0)) +} + +/** " 积分" label with thousands separators. */ +export function pointsLabel(value) { + return points(value).toLocaleString('en-US') + ' 积分' +} diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue new file mode 100644 index 0000000..26435d9 --- /dev/null +++ b/frontend/src/layouts/AdminLayout.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/frontend/src/layouts/PublicLayout.vue b/frontend/src/layouts/PublicLayout.vue new file mode 100644 index 0000000..2062af8 --- /dev/null +++ b/frontend/src/layouts/PublicLayout.vue @@ -0,0 +1,208 @@ + + + + + diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..3cd6e29 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,119 @@ +import { createApp } from 'vue' +import { createRouter, createWebHistory } from 'vue-router' +import App from './App.vue' +import './style.css' +import { auth, refreshMe, openLogin } from './auth' +import { site, loadSite } from './site' + +import PublicLayout from './layouts/PublicLayout.vue' +import AdminLayout from './layouts/AdminLayout.vue' + +import HomeView from './views/HomeView.vue' +import PlaygroundView from './views/PlaygroundView.vue' +import UserLogsView from './views/UserLogsView.vue' +import UserLogsTableView from './views/UserLogsTableView.vue' +import SettingsView from './views/SettingsView.vue' +import InviteView from './views/InviteView.vue' +import DocsView from './views/DocsView.vue' +import AboutView from './views/AboutView.vue' +import OverviewView from './views/OverviewView.vue' +import ModelsView from './views/ModelsView.vue' +import AccountsView from './views/AccountsView.vue' +import UsersView from './views/UsersView.vue' +import CdksView from './views/CdksView.vue' +import InvitesAdminView from './views/InvitesAdminView.vue' +import ImagesView from './views/ImagesView.vue' +import LogsView from './views/LogsView.vue' +import ConfigView from './views/ConfigView.vue' +import ShowcaseView from './views/ShowcaseView.vue' + +const routes = [ + { + path: '/', + component: PublicLayout, + children: [ + { path: '', component: HomeView, meta: { label: '首页' } }, + { path: 'user', component: PlaygroundView, meta: { label: '画图' } }, + { path: 'logs', component: UserLogsView, meta: { label: '记录' } }, + { path: 'mylogs', component: UserLogsTableView, meta: { label: '日志' } }, + { path: 'invite', component: InviteView, meta: { label: '邀请' } }, + { path: 'docs', component: DocsView, meta: { label: '文档' } }, + { path: 'about', component: AboutView, meta: { label: '关于' } }, + { path: 'settings', component: SettingsView, meta: { label: '设置' } }, + ], + }, + { + path: '/admin', + component: AdminLayout, + children: [ + { path: '', redirect: '/admin/overview' }, + { path: 'overview', component: OverviewView, meta: { label: '概览' } }, + { path: 'models', component: ModelsView, meta: { label: '模型管理' } }, + { path: 'accounts', component: AccountsView, meta: { label: '账号管理' } }, + { path: 'users', component: UsersView, meta: { label: '用户管理' } }, + { path: 'cdks', component: CdksView, meta: { label: '兑换码' } }, + { path: 'invites', component: InvitesAdminView, meta: { label: '邀请日志' } }, + { path: 'images', component: ImagesView, meta: { label: '图片管理' } }, + { path: 'showcase', component: ShowcaseView, meta: { label: '首页内容' } }, + { path: 'logs', component: LogsView, meta: { label: '日志' } }, + { path: 'config', component: ConfigView, meta: { label: '配置' } }, + ], + }, + // legacy redirects + { path: '/playground', redirect: '/user' }, + { path: '/home', redirect: '/' }, + { path: '/overview', redirect: '/admin/overview' }, + { path: '/models', redirect: '/admin/models' }, + { path: '/video-models', redirect: '/admin/models' }, + { path: '/accounts', redirect: '/admin/accounts' }, + // NOTE: /images is the generated-artifact path (served by the backend), so the + // old "/images → /admin/images" shortcut is gone. Use /files for that shortcut. + { path: '/files', redirect: '/admin/images' }, + { path: '/config', redirect: '/admin/config' }, + { path: '/refresh', redirect: '/admin/overview' }, + { path: '/test', redirect: '/user' }, +] + +const router = createRouter({ + history: createWebHistory(), + routes, +}) + +// Pages that require a login. The home page (/) stays public; everything a +// signed-in user touches (画图/记录/设置) and the whole admin area is gated. +const PROTECTED = ['/user', '/logs', '/invite', '/settings'] +function isProtected(path) { + return path.startsWith('/admin') || PROTECTED.includes(path) +} + +// Guard: validate the stored token against /me once (auth.ready), then trust +// state. Unauthed visits to a protected page stay on home and pop the login +// modal (no separate login page); the modal navigates to `intent` on success. +router.beforeEach(async (to) => { + if (!isProtected(to.path)) return true + if (!auth.ready) await refreshMe() + if (!auth.token || !auth.user) { + openLogin(to.fullPath) + return to.path === '/' ? false : '/' + } + if (to.path.startsWith('/admin') && auth.user.role !== 'admin') { + return '/user' // logged in but not an admin -> user side + } + return true +}) + +// Keep the browser tab title in sync with the current route's label and the +// admin-editable site title. Admin routes get an extra prefix so the two +// sides are distinguishable at a glance. +function applyTitle(route) { + const label = route.meta?.label || '' + const scope = route.path.startsWith('/admin') ? 'Admin · ' : '' + const brand = site.title || 'Vivid' + document.title = label ? `${brand} • ${scope}${label}` : brand +} +router.afterEach(applyTitle) +// Re-apply once the admin-set title resolves (loadSite is async — the first +// navigation uses the default, then this catches up). +loadSite().then(() => applyTitle(router.currentRoute.value)) + +createApp(App).use(router).mount('#app') diff --git a/frontend/src/playground.js b/frontend/src/playground.js new file mode 100644 index 0000000..be0625b --- /dev/null +++ b/frontend/src/playground.js @@ -0,0 +1,26 @@ +// Reactive draft of the 画图 form fields. Lives at module scope so the +// values survive PlaygroundView being unmounted (navigation to 首页/记录 etc.) +// and remounted — without this, switching away and back wiped the prompt +// and selected model. Per-tab only; not persisted to localStorage. +import { reactive } from 'vue' + +export const draft = reactive({ + mode: '', // 'image' | 'video' + modelId: '', + prompt: '', + ratio: '', + resolution: '', + duration: '', +}) + +// Copy fields from a server-side job entry (the `/jobs/mine` payload) into +// the draft so a parallel tab can pick up exactly what's being generated. +export function applyJobToDraft(entry) { + if (!entry) return + draft.mode = entry.kind === 'video' ? 'video' : 'image' + draft.modelId = entry.model || '' + draft.prompt = entry.prompt || '' + draft.ratio = entry.ratio || '' + draft.resolution = entry.resolution || '' + draft.duration = entry.duration || '' +} diff --git a/frontend/src/site.js b/frontend/src/site.js new file mode 100644 index 0000000..adc7fc2 --- /dev/null +++ b/frontend/src/site.js @@ -0,0 +1,35 @@ +// Site-wide branding (currently just the wordmark / tab title) backed by the +// admin-editable config at /admin/api/site. One reactive object so every +// component that wants to display "" stays in sync the moment the +// admin saves a change. +import { reactive } from 'vue' + +const BASE = import.meta.env.VITE_API_BASE || '' + +export const site = reactive({ + title: 'Vivid', + // Defaults so the 关于 page is never blank even if /site hasn't loaded (or a + // cache serves an older payload without `contact`). The backend value, once + // fetched, overrides these. + contact: { + qq: '1114639355', + qq_link: 'https://qm.qq.com/q/ItgCcNA7ac', + qq_group: '1106849765', + qq_group_link: 'https://qm.qq.com/q/976LeMFoHu', + email: 'vividairun@gmail.com', + shop: 'https://pay.ldxp.cn/shop/chiyi', + }, + ready: false, +}) + +export async function loadSite() { + try { + const r = await fetch(`${BASE}/admin/api/site`) + if (r.ok) { + const data = await r.json() + if (data.title) site.title = String(data.title) + if (data.contact) site.contact = { ...site.contact, ...data.contact } + } + } catch { /* offline — keep the default. */ } + site.ready = true +} diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..42a57e5 --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,416 @@ +@import "tailwindcss"; + +:root { + font-family: "Inter", ui-sans-serif, system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; + --tw-ring-color: rgb(15 23 42 / 0.08); + + /* ===== Theme palette — LIGHT (default) ===== + Drives the app chrome (rails/header) and the public marketing pages. + The `.dark` class on <html> flips these to the dark values below. + Text over generated images keeps a literal text-white (handled in markup). */ + --app-bg: rgb(250 250 251); /* page background */ + --fg: rgb(15 23 42); /* primary heading/text */ + --fg-2: rgb(15 23 42 / 0.62); /* body / secondary */ + --fg-3: rgb(15 23 42 / 0.45); /* muted */ + --fg-faint: rgb(15 23 42 / 0.3); /* labels / hints */ + --surface: rgb(255 255 255 / 0.7); /* raised card on page bg */ + --surface-2: rgb(15 23 42 / 0.035); /* subtle inset panel (counter cells) */ + --hairline: rgb(15 23 42 / 0.08); /* borders / rings */ + --hover: rgb(15 23 42 / 0.05); /* hover wash */ + /* solid CTA button that sits on the page bg — inverts per theme */ + --btn-solid-bg: rgb(15 23 42); + --btn-solid-bg-h: rgb(30 41 59); + --btn-solid-fg: #fff; + --menu-bg: #ffffff; /* dropdown / popover panel */ +} + +html.dark { + --app-bg: #08090d; + --fg: rgb(255 255 255 / 0.92); + --fg-2: rgb(255 255 255 / 0.55); + --fg-3: rgb(255 255 255 / 0.4); + --fg-faint: rgb(255 255 255 / 0.3); + --surface: rgb(255 255 255 / 0.03); + --surface-2: rgb(255 255 255 / 0.04); + --hairline: rgb(255 255 255 / 0.06); + --hover: rgb(255 255 255 / 0.06); + --btn-solid-bg: #fff; + --btn-solid-bg-h: rgb(255 255 255 / 0.9); + --btn-solid-fg: rgb(2 6 23); + --menu-bg: #15171f; +} + +/* Smooth the flip so toggling doesn't hard-cut. */ +body, .theme-x { transition: background-color 0.25s ease, color 0.25s ease; } + +/* Footer CTA band — soft tinted panel that flips with the theme. */ +.cta-band { + background: + radial-gradient(at 30% 20%, rgb(168 85 247 / 0.16) 0%, transparent 50%), + radial-gradient(at 80% 70%, rgb(244 114 182 / 0.14) 0%, transparent 55%), + linear-gradient(180deg, rgb(255 255 255), rgb(246 244 251)); +} +html.dark .cta-band { + background: + radial-gradient(at 30% 20%, rgb(168 85 247 / 0.35) 0%, transparent 50%), + radial-gradient(at 80% 70%, rgb(244 114 182 / 0.3) 0%, transparent 55%), + linear-gradient(180deg, #0f1117, #080a10); +} + +/* ===== Light-mode rescue for hardcoded-dark pages ===== + The admin shell + settings/workbench views were authored dark with literal + text-white / white-alpha / bg-white utilities (not the slate system). In + LIGHT mode, remap those neutral whites to the dark-on-light palette. Scoped + to a `.theme-text` wrapper so the marketing homepage's over-image whites are + never touched. Un-layered, so these win over Tailwind's @layer utilities. */ +html:not(.dark) .theme-text :is(.text-white, [class*="text-white/9"], [class*="text-white/8"]) { color: rgb(15 23 42 / 0.95); } +html:not(.dark) .theme-text :is([class*="text-white/7"], [class*="text-white/6"]) { color: rgb(15 23 42 / 0.7); } +html:not(.dark) .theme-text :is([class*="text-white/5"], [class*="text-white/4"]) { color: rgb(15 23 42 / 0.52); } +html:not(.dark) .theme-text :is([class*="text-white/3"], [class*="text-white/2"], [class*="text-white/1"]) { color: rgb(15 23 42 / 0.4); } +html:not(.dark) .theme-text [class*="bg-white/"] { background-color: var(--surface-2); } +html:not(.dark) .theme-text [class*="border-white/"] { border-color: var(--hairline); } +html:not(.dark) .theme-text [class*="ring-white/"] { --tw-ring-color: var(--hairline); } +/* White placeholders (placeholder:text-white/30 etc.) — the rescue above only + recolors element text, not the ::placeholder pseudo. */ +html:not(.dark) .theme-text [class*="placeholder:text-white"]::placeholder { color: var(--fg-faint); } +/* Exception: "selected" pills / dark buttons keep their white text. They paint a + genuinely dark surface (bg-slate-900/800, bg-black) so the rescue must NOT + flip their text to dark — that's where it goes invisible. */ +html:not(.dark) .theme-text :is([class*="bg-slate-900"], [class*="bg-slate-800"], [class*="bg-black"])[class*="text-white"] { color: #fff !important; } +/* Pastel -200/-300 accent text (labels, KPI numbers, badges) is tuned for a dark + shell and washes out on white. In LIGHT mode, darken to the -600 tone. The + matcher also catches the /80 opacity variants. Badges keep their pale tinted + bg (bg-*-500/10) which reads fine on white. */ +html:not(.dark) .theme-text :is([class*="text-indigo-300"], [class*="text-indigo-200"]) { color: rgb(79 70 229); } +html:not(.dark) .theme-text :is([class*="text-violet-300"], [class*="text-violet-200"]) { color: rgb(124 58 237); } +html:not(.dark) .theme-text :is([class*="text-fuchsia-300"], [class*="text-fuchsia-200"]) { color: rgb(192 38 211); } +html:not(.dark) .theme-text :is([class*="text-emerald-300"], [class*="text-emerald-200"]) { color: rgb(5 150 105); } +html:not(.dark) .theme-text :is([class*="text-sky-300"], [class*="text-sky-200"]) { color: rgb(2 132 199); } +html:not(.dark) .theme-text :is([class*="text-amber-300"], [class*="text-amber-200"]) { color: rgb(217 119 6); } +html:not(.dark) .theme-text :is([class*="text-rose-300"], [class*="text-rose-200"]) { color: rgb(225 29 72); } +html:not(.dark) .theme-text :is([class*="text-teal-300"], [class*="text-teal-200"]) { color: rgb(13 148 136); } + +/* Per-view scoped pills / buttons / chips hardcode white for the dark shell. + Recolor them centrally in light mode — the extra .theme-text class outranks + the scoped `.x[data-v-*]` rules. Dark mode keeps the scoped look (gated on + :not(.dark)). */ +html:not(.dark) .theme-text :is(.fp, .pg, .act, .filter-pill, .kind-btn, .preset-btn, .opt, .chip-x, .ro-chip) { + color: var(--fg-2); background: var(--surface-2); box-shadow: inset 0 0 0 1px var(--hairline); +} +html:not(.dark) .theme-text :is(.fp, .pg, .act, .filter-pill, .kind-btn, .preset-btn, .opt, .chip-x):hover { + color: var(--fg); background: var(--hover); +} +html:not(.dark) .theme-text :is(.fp-on, .pg-on, .seg-on, .opt-on) { background: rgb(15 23 42); color: #fff; box-shadow: none; } +/* Selected COLORED filter pills — their scoped (dark) selected colors get + overridden by the neutral `.fp` rescue above, so re-state a light-mode + variant (tinted bg + dark-enough text + colored ring) with equal specificity + placed later, so the selection reads on a white page. */ +html:not(.dark) .theme-text .fp-emerald { background: rgb(16 185 129 / 0.14); color: rgb(4 120 87); box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.45); } +html:not(.dark) .theme-text .fp-rose { background: rgb(244 63 94 / 0.12); color: rgb(190 18 60); box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.45); } +html:not(.dark) .theme-text .fp-fuchsia { background: rgb(217 70 239 / 0.12); color: rgb(162 28 175); box-shadow: inset 0 0 0 1px rgb(217 70 239 / 0.45); } +html:not(.dark) .theme-text .fp-amber { background: rgb(245 158 11 / 0.16); color: rgb(180 83 9); box-shadow: inset 0 0 0 1px rgb(245 158 11 / 0.5); } +html:not(.dark) .theme-text .fp-violet { background: rgb(139 92 246 / 0.14); color: rgb(109 40 217); box-shadow: inset 0 0 0 1px rgb(139 92 246 / 0.45); } +html:not(.dark) .theme-text .fp-sky { background: rgb(14 165 233 / 0.14); color: rgb(3 105 161); box-shadow: inset 0 0 0 1px rgb(14 165 233 / 0.45); } +html:not(.dark) .theme-text .fp-teal { background: rgb(20 184 166 / 0.14); color: rgb(15 118 110); box-shadow: inset 0 0 0 1px rgb(20 184 166 / 0.45); } + +/* Custom checkbox for admin tables (<input type="checkbox" class="chk">). + Replaces the plain accent-color box with a rounded, branded one + white check. + Neutral border + transparent bg so it reads on both the dark admin shell and + light mode; fills fuchsia when checked. */ +.chk { + appearance: none; + -webkit-appearance: none; + width: 1rem; + height: 1rem; + flex: none; + border-radius: 0.3rem; + border: 1.5px solid rgb(148 163 184 / 0.55); + background: transparent; + cursor: pointer; + display: inline-grid; + place-content: center; + vertical-align: middle; + transition: background 0.15s, border-color 0.15s; +} +.chk:hover:not(:checked) { border-color: rgb(217 70 239 / 0.7); } +.chk:checked { + background: rgb(217 70 239); + border-color: rgb(217 70 239); +} +.chk:checked::after { + content: ""; + width: 0.3rem; + height: 0.55rem; + border: solid #fff; + border-width: 0 2px 2px 0; + transform: rotate(45deg); + margin-top: -0.12rem; +} +.chk:focus-visible { outline: 2px solid rgb(217 70 239 / 0.45); outline-offset: 1px; } +/* Capability chips (ModelsView): all variants need bg + ring + color reset in light mode. + The scoped dark-shell values (white-alpha bg/ring, pale text) are invisible on white. */ +html:not(.dark) .theme-text .cap-emerald { + background: rgb(16 185 129 / 0.12); + color: rgb(4 120 87); + box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.4); +} +html:not(.dark) .theme-text .cap-amber { + background: rgb(245 158 11 / 0.12); + color: rgb(180 83 9); + box-shadow: inset 0 0 0 1px rgb(245 158 11 / 0.45); +} +html:not(.dark) .theme-text :is(.cap-slate, .cap-mono, .seg) { + color: var(--fg-2); + background: var(--surface-2); + box-shadow: inset 0 0 0 1px var(--hairline); +} +/* Price chips (ModelsView): the white-alpha bg/ring disappear on a white card. */ +html:not(.dark) .theme-text .price-chip { + background: var(--surface-2); + box-shadow: inset 0 0 0 1px var(--hairline); +} +/* fp-white selected pill: on white bg the all-white fill vanishes — use a neutral dark variant. */ +html:not(.dark) .theme-text .fp-white { + background: var(--surface-2); + color: var(--fg-2); + box-shadow: inset 0 0 0 1px var(--hairline); +} +/* label / hint text classes (form labels in modals & config) */ +html:not(.dark) .theme-text .lbl { color: var(--fg); } +html:not(.dark) .theme-text :is(.hint, .flbl) { color: var(--fg-3); } +/* Delete icon button: a clean neutral button with a red glyph (not a heavy pink + box), filling solid red on hover. */ +html:not(.dark) .theme-text .act.danger { color: rgb(225 29 72); background: var(--surface-2); box-shadow: inset 0 0 0 1px var(--hairline); } +html:not(.dark) .theme-text .act.danger:hover { color: #fff; background: rgb(244 63 94); box-shadow: inset 0 0 0 1px rgb(225 29 72); } +/* Soft "danger" toolbar buttons (删除选中 / 删除异常账号 …): a muted red, not a + heavy pink fill; solid red on hover. Outranks the per-view scoped pink. */ +html:not(.dark) .theme-text .btn-soft.danger { color: rgb(190 18 60); background: rgb(244 63 94 / 0.08); box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.22); } +html:not(.dark) .theme-text .btn-soft.danger:hover { color: #fff; background: rgb(225 29 72); box-shadow: none; } + +/* ===== Media card ===== + An image/video thumbnail whose overlay text sits ON the media — it must stay + white even when the surrounding page is in LIGHT mode. The page itself stays + light; only these cards keep the dark-on-image treatment. */ +html:not(.dark) .media-card :is(.text-white, [class*="text-white/9"], [class*="text-white/8"], [class*="text-white/7"], [class*="text-white/6"]) { color: rgb(255 255 255 / 0.95) !important; } +html:not(.dark) .media-card :is([class*="text-white/5"], [class*="text-white/4"]) { color: rgb(255 255 255 / 0.6) !important; } +html:not(.dark) .media-card :is([class*="text-white/3"], [class*="text-white/2"]) { color: rgb(255 255 255 / 0.45) !important; } +html:not(.dark) .media-card [class*="bg-white/"] { background-color: rgb(255 255 255 / 0.12) !important; } +html:not(.dark) .media-card :is([class*="border-white/"], [class*="ring-white/"]) { border-color: rgb(255 255 255 / 0.12) !important; --tw-ring-color: rgb(255 255 255 / 0.12) !important; } +/* Accent text inside a media-card sits on dark imagery too — keep it at its + bright (dark-mode) shade instead of the darkened light-theme rescue above. */ +html:not(.dark) .media-card :is([class*="text-emerald-3"], [class*="text-emerald-2"]) { color: rgb(110 231 183) !important; } +html:not(.dark) .media-card :is([class*="text-fuchsia-3"], [class*="text-fuchsia-2"]) { color: rgb(240 171 252) !important; } +html:not(.dark) .media-card :is([class*="text-indigo-3"], [class*="text-indigo-2"]) { color: rgb(165 180 252) !important; } +html:not(.dark) .media-card :is([class*="text-violet-3"], [class*="text-violet-2"]) { color: rgb(196 181 253) !important; } +html:not(.dark) .media-card :is([class*="text-sky-3"], [class*="text-sky-2"]) { color: rgb(125 211 252) !important; } +html:not(.dark) .media-card :is([class*="text-amber-3"], [class*="text-amber-2"]) { color: rgb(252 211 77) !important; } + +/* Code example blocks (docs) read like an editor — solid dark surface in BOTH + themes. Paired with .media-card so the code/title text stays light in light + mode instead of being darkened by the rescue above. */ +.doc-code { background-color: #0f172a; } +/* `.public-dark code` gives the inner <code> its faint highlight in dark mode; + doc-code is dark in BOTH themes, so re-apply the same in light mode. */ +html:not(.dark) .doc-code code { background: rgb(255 255 255 / 0.08); color: rgb(255 255 255 / 0.9); } + +html { + scroll-behavior: smooth; + /* Kill the bounce/glow when scrolling past the top or bottom of any + scroll surface (the dark admin shell has fixed viewport height and we + don't want a rubber-band-y feel at the edges of inner scroll areas). */ + overscroll-behavior: none; +} + +body { + margin: 0; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + overscroll-behavior: none; + /* Subtle ambient gradient + grain (used by PublicLayout via .ambient-bg) */ +} + +::selection { background: rgb(99 102 241 / 0.22); color: rgb(15 23 42); } + +/* Tailwind v4 / preflight leaves <button> on the default arrow cursor, so + admins miss the affordance on icon-only actions (复制 / 删除 / 生成 等). + One global rule restores the pointer cursor everywhere, while disabled + buttons keep the "not-allowed" cursor already applied via Tailwind. */ +button:not(:disabled), [role="button"]:not([aria-disabled="true"]) { cursor: pointer; } + +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-thumb { background: rgb(203 213 225); border-radius: 4px; } +::-webkit-scrollbar-thumb:hover { background: rgb(148 163 184); } +::-webkit-scrollbar-track { background: transparent; } + +/* ===== Ambient background — used by the public shell ===== */ +.ambient-bg { + background: + radial-gradient(at 15% 10%, rgb(238 242 255) 0%, transparent 45%), + radial-gradient(at 85% 25%, rgb(253 244 255) 0%, transparent 50%), + radial-gradient(at 50% 90%, rgb(236 254 255) 0%, transparent 50%), + rgb(250 250 251); +} +.ambient-grain::before { + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + background-image: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22180%22 height=%22180%22><filter id=%22n%22><feTurbulence baseFrequency=%220.9%22 numOctaves=%222%22 seed=%225%22/></filter><rect width=%22100%25%22 height=%22100%25%22 filter=%22url(%23n)%22 opacity=%220.4%22/></svg>'); + opacity: 0.035; + mix-blend-mode: multiply; + z-index: 1; +} + +/* Float a soft mesh "orb" anywhere */ +.mesh-orb { + position: absolute; + border-radius: 9999px; + filter: blur(80px); + pointer-events: none; + z-index: 0; +} + +/* ===== Dark public-shell theme overrides ===== + Apply by adding `.public-dark` to a wrapper element. Views can keep their + existing Tailwind classes (text-slate-*, .card, .field) — these rules + remap them to the dark palette. */ +.public-dark { color: rgb(255 255 255 / 0.9); } +.public-dark .card { + background: rgb(255 255 255 / 0.03); + border-color: rgb(255 255 255 / 0.07); + color: rgb(255 255 255 / 0.92); + box-shadow: 0 1px 0 rgb(255 255 255 / 0.04) inset, 0 8px 24px rgb(0 0 0 / 0.25); +} +.public-dark .field { + background: rgb(255 255 255 / 0.04); + border-color: rgb(255 255 255 / 0.1); + color: rgb(255 255 255 / 0.95); + transition: border-color 0.18s, background 0.18s, box-shadow 0.18s; +} +.public-dark .field::placeholder { color: rgb(255 255 255 / 0.3); } +/* Single focus treatment shared by every <.field> across the admin shell — + matches the per-component overrides in ConfigView/LoginModal so all + inputs glow with the same violet ring on focus. */ +.public-dark .field:focus, +.public-dark .field:focus-visible { + border-color: rgb(167 139 250 / 0.65); + background: rgb(255 255 255 / 0.06); + box-shadow: 0 0 0 3px rgb(167 139 250 / 0.15); + outline: none; +} +.public-dark .btn-soft { + background: rgb(255 255 255 / 0.06); + color: rgb(255 255 255 / 0.85); +} +.public-dark .btn-soft:hover { background: rgb(255 255 255 / 0.12); color: white; } +.public-dark .btn-ghost { color: rgb(255 255 255 / 0.65); } +.public-dark .btn-ghost:hover { background: rgb(255 255 255 / 0.06); color: white; } +.public-dark .btn-primary { + background: white; color: rgb(2 6 23); box-shadow: 0 1px 0 rgb(0 0 0 / 0.1); +} +.public-dark .btn-primary:hover { background: rgb(255 255 255 / 0.92); } +.public-dark .pill { background: rgb(255 255 255 / 0.06); color: rgb(255 255 255 / 0.85); } + +/* Remap slate text tokens */ +.public-dark .text-slate-900, +.public-dark .text-slate-800, +.public-dark .text-slate-700 { color: rgb(255 255 255 / 0.95); } +.public-dark .text-slate-600 { color: rgb(255 255 255 / 0.75); } +.public-dark .text-slate-500 { color: rgb(255 255 255 / 0.55); } +.public-dark .text-slate-400 { color: rgb(255 255 255 / 0.4); } +.public-dark .text-slate-300 { color: rgb(255 255 255 / 0.3); } + +/* Backgrounds & borders that views used */ +.public-dark .bg-white { background: rgb(255 255 255 / 0.12); color: rgb(255 255 255 / 0.95); } +.public-dark .bg-slate-50, .public-dark .bg-slate-50\/60 { background: rgb(255 255 255 / 0.025); } +.public-dark .bg-slate-100 { background: rgb(255 255 255 / 0.06); } +.public-dark .bg-slate-200 { background: rgb(255 255 255 / 0.1); } +.public-dark .border-slate-100, +.public-dark .border-slate-200, +.public-dark .border-slate-300 { border-color: rgb(255 255 255 / 0.08); } +.public-dark .ring-slate-200 { --tw-ring-color: rgb(255 255 255 / 0.08); } + +/* Tables in dark */ +.public-dark .th { color: rgb(255 255 255 / 0.4); } +.public-dark .row { border-color: rgb(255 255 255 / 0.06); } +.public-dark .row:hover { background: rgb(255 255 255 / 0.03); } + +/* Inline code blocks (pill highlight) — only for inline <code>, NOT code inside + a <pre> block (docs examples), which would get an unwanted full-width tint. */ +.public-dark code { background: rgb(255 255 255 / 0.08); color: rgb(255 255 255 / 0.9); } +.public-dark pre code { background: transparent; padding: 0; } + +/* Filter pills + segmented controls that admin pages stamp with bg-slate-900 + for the "selected" state — that lands invisible on the dark shell. Treat + slate-900 as a strong-contrast surface in the dark palette. */ +.public-dark .bg-slate-900 { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); } +.public-dark .hover\:bg-slate-700:hover { background: rgb(255 255 255 / 0.85); color: rgb(15 23 42); } +.public-dark .text-slate-700 { color: rgb(255 255 255 / 0.85); } +.public-dark .text-slate-200 { color: rgb(255 255 255 / 0.7); } +.public-dark .text-slate-100 { color: rgb(255 255 255 / 0.92); } + +/* Divider utilities used by lists (recent activity, table rows). */ +.public-dark .divide-slate-50 > :not([hidden]) ~ :not([hidden]), +.public-dark .divide-slate-100 > :not([hidden]) ~ :not([hidden]) { + border-color: rgb(255 255 255 / 0.05); +} +.public-dark .border-slate-100\/80 { border-color: rgb(255 255 255 / 0.06); } +.public-dark .hover\:bg-slate-50:hover { background: rgb(255 255 255 / 0.04); } +.public-dark .hover\:bg-slate-100:hover { background: rgb(255 255 255 / 0.06); } + +@layer components { + /* surfaces */ + .card { @apply bg-white rounded-xl border border-slate-200 shadow-sm; } + + /* buttons — each variant is self-contained (Tailwind v4 can't @apply a custom class) */ + .btn-primary { + @apply inline-flex items-center justify-center gap-1.5 rounded-lg text-sm font-medium + transition-colors disabled:opacity-50 disabled:cursor-not-allowed + bg-slate-900 text-white hover:bg-slate-700 px-3.5 py-2; + } + .btn-soft { + @apply inline-flex items-center justify-center gap-1.5 rounded-lg text-xs font-medium + transition-colors disabled:opacity-50 disabled:cursor-not-allowed + bg-slate-100 text-slate-700 hover:bg-slate-200 px-3 py-1.5; + } + .btn-ghost { + @apply inline-flex items-center justify-center gap-1.5 rounded-lg text-xs font-medium + transition-colors disabled:opacity-50 disabled:cursor-not-allowed + text-slate-600 hover:bg-slate-100 hover:text-slate-900 px-3 py-1.5; + } + + /* text link / quiet action */ + .link { @apply text-slate-500 hover:text-slate-900 transition-colors cursor-pointer; } + + /* form controls */ + .field { + @apply w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-800 + outline-none transition-colors placeholder:text-slate-400 + focus:border-slate-400 focus:ring-2; + } + + /* Native <select> using .field: drop the OS arrow for a custom chevron that + sits away from the right edge, with room so the text never overlaps it. */ + select.field { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + padding-right: 2.5rem; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.9rem center; + background-size: 1rem; + } + select.field::-ms-expand { display: none; } + + /* status pill */ + .pill { @apply inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium; } + + /* table primitives */ + .th { @apply text-left px-4 py-3 text-[11px] font-semibold uppercase tracking-wider text-slate-400; } + .td { @apply px-4 py-3.5 align-middle; } + .row { @apply border-t border-slate-100 transition-colors hover:bg-slate-50; } + + /* avatar chip for identity columns */ + .avatar { @apply w-8 h-8 rounded-full grid place-items-center text-xs font-semibold shrink-0 ring-1 ring-black/5; } +} diff --git a/frontend/src/theme.js b/frontend/src/theme.js new file mode 100644 index 0000000..da24243 --- /dev/null +++ b/frontend/src/theme.js @@ -0,0 +1,46 @@ +// Light/dark theme state. Default is LIGHT. The choice persists in localStorage +// and is reflected as a `dark` class on <html>, which drives the CSS-variable +// palette in style.css (and the conditional `.public-dark` override layer). +import { ref } from 'vue' + +const KEY = 'gw_theme' +const mql = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null + +function systemTheme() { + return mql && mql.matches ? 'dark' : 'light' +} + +// Precedence: an explicit user choice (localStorage) wins; otherwise follow the +// OS's light/dark setting. +const saved = localStorage.getItem(KEY) +const initial = saved === 'dark' || saved === 'light' ? saved : systemTheme() + +export const theme = ref(initial) +export const isDark = ref(initial === 'dark') + +function apply(t) { + isDark.value = t === 'dark' + const el = document.documentElement + el.classList.toggle('dark', t === 'dark') +} + +// Apply at module load so the first paint already matches the resolved choice. +apply(theme.value) + +// While the user hasn't made an explicit choice, keep tracking the OS setting +// live (e.g. they flip macOS/Windows to dark mode with the tab open). +if (mql) { + mql.addEventListener('change', () => { + if (!localStorage.getItem(KEY)) { + theme.value = systemTheme() + apply(theme.value) + } + }) +} + +/** Flip light ⇄ dark and persist as the user's explicit choice. */ +export function toggleTheme() { + theme.value = theme.value === 'dark' ? 'light' : 'dark' + localStorage.setItem(KEY, theme.value) + apply(theme.value) +} diff --git a/frontend/src/utils/format.js b/frontend/src/utils/format.js new file mode 100644 index 0000000..bcd0ec5 --- /dev/null +++ b/frontend/src/utils/format.js @@ -0,0 +1,107 @@ +// Date/relative-time formatting helpers ported from admin.html. +const CN_LOCALE = 'zh-CN' +const CN_TZ_OPTS = { timeZone: 'Asia/Shanghai', hour12: false } + +/** Format a unix-seconds timestamp in Asia/Shanghai. */ +export function fmtTs(sec) { + sec = Number(sec) + if (!sec || Number.isNaN(sec)) return '—' + try { + return new Date(sec * 1000).toLocaleString(CN_LOCALE, { + ...CN_TZ_OPTS, year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', + }) + } catch { return '—' } +} + +/** Format an ISO-8601 string in Asia/Shanghai. */ +export function fmtIso(iso) { + if (!iso) return '—' + const d = new Date(iso) + if (isNaN(d.getTime())) return iso + try { + return d.toLocaleString(CN_LOCALE, { + ...CN_TZ_OPTS, year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', + }) + } catch { return iso } +} + +/** Human-friendly "5m 后 / 3h 前" relative time from unix seconds. + * Floors to integer seconds so floating-point ts (e.g. `time.time()` on the + * server) never leaks "15.88s 前" into the UI. */ +export function fmtRelative(ts) { + ts = Number(ts) + if (!ts || Number.isNaN(ts)) return '—' + const diff = Math.round(ts - Date.now() / 1000) + const abs = Math.abs(diff) + const u = (n, s) => `${n}${s}` + let txt + if (abs < 60) txt = u(abs, 's') + else if (abs < 3600) txt = u(Math.floor(abs / 60), 'm') + else if (abs < 86400) txt = u(Math.floor(abs / 3600), 'h') + else txt = u(Math.floor(abs / 86400), 'd') + return diff >= 0 ? `${txt} 后` : `${txt} 前` +} + +// Accepts either unix-seconds (number or numeric string) or an ISO-8601 string, +// returning a Date in either case (null when unparseable). Lets the stacked +// date/time cells below work for both the unix timestamps (created_at, etc.) +// and the ISO reset_after string without callers caring which they hold. +function toDate(v) { + if (v === null || v === undefined || v === '') return null + if (typeof v === 'number' || /^\d+(\.\d+)?$/.test(String(v))) { + const n = Number(v) + return n ? new Date(n * 1000) : null + } + const d = new Date(v) + return isNaN(d.getTime()) ? null : d +} + +/** Date part only — "2026/06/18". Pair with fmtClock for a compact 2-line cell. */ +export function fmtDate(v) { + const d = toDate(v) + if (!d) return '—' + try { + return d.toLocaleDateString(CN_LOCALE, { ...CN_TZ_OPTS, year: 'numeric', month: '2-digit', day: '2-digit' }) + } catch { return '—' } +} + +/** Time part only — "00:31:09". Empty string when there's no timestamp. */ +export function fmtClock(v) { + const d = toDate(v) + if (!d) return '' + try { + return d.toLocaleTimeString(CN_LOCALE, { ...CN_TZ_OPTS, hour: '2-digit', minute: '2-digit', second: '2-digit' }) + } catch { return '' } +} + +// Rank a resolution tier for ascending sort. Handles both video ("720p"/"1080p" +// /"4k") and image ("1K"/"2K"/"4K"): the "k" suffix scales ×1000 so "4k"/"4K" +// rank ABOVE "1080p" (a plain parseFloat would put "4k"=4 first, which is wrong). +function resRank(r) { + const s = String(r).trim() + const n = parseFloat(s) || 0 + return /k$/i.test(s) ? n * 1000 : n +} + +/** Sort resolution tiers ascending (720p before 1080p; 1K before 2K before 4K). */ +export function sortResolutions(list) { + return [...(list || [])].sort((a, b) => resRank(a) - resRank(b)) +} + +export function nowTime() { + return new Date().toLocaleTimeString(CN_LOCALE, CN_TZ_OPTS) +} + +/** Human-readable byte size — "86 MB", "5.3 MB", "512 KB". Rounds to a whole + * number at ≥10 units, one decimal below, so the same byte count reads + * identically everywhere (overview KPI, 图片管理, lightbox…). */ +export function fmtSize(bytes) { + bytes = Number(bytes) + if (!bytes || Number.isNaN(bytes)) return '0 B' + const u = ['B', 'KB', 'MB', 'GB', 'TB'] + let i = 0; let v = bytes + while (v >= 1024 && i < u.length - 1) { v /= 1024; i++ } + return `${v < 10 && i > 0 ? v.toFixed(1) : Math.round(v)} ${u[i]}` +} diff --git a/frontend/src/utils/import.js b/frontend/src/utils/import.js new file mode 100644 index 0000000..62f670f --- /dev/null +++ b/frontend/src/utils/import.js @@ -0,0 +1,115 @@ +// Smart parsing of pasted credentials (Adobe cookies / ChatGPT JWTs), +// ported verbatim from admin.html so import behaviour is unchanged. + +export function looksLikeJwt(s) { + s = (s || '').replace(/^Bearer\s+/i, '').trim() + const parts = s.split('.') + if (parts.length !== 3) return false + return parts.every((p) => /^[A-Za-z0-9_-]+$/.test(p) && p.length > 4) +} + +function decodeJwtPayload(s) { + try { + let p = (s || '').replace(/^Bearer\s+/i, '').trim().split('.')[1] + if (!p) return null + p = p.replace(/-/g, '+').replace(/_/g, '/') + p += '='.repeat((4 - (p.length % 4)) % 4) + return JSON.parse(atob(p)) + } catch (_) { return null } +} + +// Runway JWTs carry a top-level numeric `id` plus an `sso` claim and, crucially, +// no OpenAI (https://api.openai.com/*) claims — that's what distinguishes them +// from a ChatGPT JWT, which is otherwise also an opaque three-part token. +export function looksLikeRunwayJwt(s) { + const claims = decodeJwtPayload(s) + if (!claims || typeof claims !== 'object') return false + if (Object.keys(claims).some((k) => k.startsWith('https://api.openai.com/'))) return false + return 'sso' in claims && claims.id != null +} + +// Leonardo cookies carry the better-auth session cookie — that's what tells them +// apart from an Adobe cookie (both are otherwise opaque cookie strings). +export function looksLikeLeonardoCookie(s) { + return /better-auth\.session_token/.test(s || '') || /better-auth\.session_data/.test(s || '') +} + +// Krea cookies carry the Supabase auth cookie. +export function looksLikeKreaCookie(s) { + return /sb-superb-auth-token/.test(s || '') +} + +// An Imagine.art credential is a JSON object { token, refreshToken } (both JWTs). +function isImagineObj(o) { + return !!o && typeof o === 'object' && + typeof o.token === 'string' && looksLikeJwt(o.token) && + typeof o.refreshToken === 'string' && looksLikeJwt(o.refreshToken) +} + +// String form (a pasted JSON object on a line). +export function looksLikeImagineToken(s) { + try { return isImagineObj(JSON.parse(s)) } catch (_) { return false } +} + +// Classify an opaque credential string by its distinctive shape. Imagine is +// JSON-shaped, so it must be checked before the cookie heuristics. +function cookieType(v) { + if (looksLikeImagineToken(v)) return 'imagine' + if (looksLikeKreaCookie(v)) return 'krea' + if (looksLikeLeonardoCookie(v)) return 'leonardo' + return 'adobe' +} + +function cookieFromAny(item) { + if (typeof item === 'string') return item.trim() + if (item && typeof item === 'object') { + if (typeof item.cookie === 'string') return item.cookie.trim() + if (typeof item.value === 'string' && !('name' in item)) return item.value.trim() + if (Array.isArray(item.cookies)) { + return item.cookies.filter((c) => c && c.name).map((c) => `${c.name}=${c.value}`).join('; ') + } + } + return '' +} + +/** Returns a list of { type: 'adobe' | 'openai' | 'runway' | 'leonardo', value }. */ +export function parseImportInput(text) { + text = (text || '').trim() + if (!text) return [] + // Try JSON first. + try { + const j = JSON.parse(text) + if (Array.isArray(j) && j.length > 0) { + // Chrome cookie export: array of {name,value} → one cookie account. + if (j.every((it) => it && typeof it === 'object' && 'name' in it && 'value' in it)) { + const joined = j.filter((c) => c && c.name).map((c) => `${c.name}=${c.value}`).join('; ') + return joined ? [{ type: cookieType(joined), value: joined }] : [] + } + // Otherwise treat as multiple accounts. An Imagine credential is itself a + // JSON object {token,refreshToken} — keep it as its JSON string value. + return j.map((it) => { + if (isImagineObj(it)) return { type: 'imagine', value: JSON.stringify(it) } + const v = cookieFromAny(it) + return { type: cookieType(v), value: v } + }).filter((x) => x.value) + } + if (j && typeof j === 'object') { + if (isImagineObj(j)) return [{ type: 'imagine', value: JSON.stringify(j) }] + const v = cookieFromAny(j) + return v ? [{ type: cookieType(v), value: v }] : [] + } + } catch (_) { /* not JSON */ } + // Not JSON → split per line, identify each. A JWT is either a Runway token + // (top-level id+sso, no openai claims) or a ChatGPT token; anything else is + // treated as an Adobe cookie string. + const lines = text.split(/\r?\n/).map((s) => s.trim()).filter(Boolean) + return lines.map((line) => { + if (looksLikeJwt(line)) { + const value = line.replace(/^Bearer\s+/i, '') + return looksLikeRunwayJwt(value) + ? { type: 'runway', value } + : { type: 'openai', value } + } + return { type: cookieType(line), value: line } + }) +} diff --git a/frontend/src/views/AboutView.vue b/frontend/src/views/AboutView.vue new file mode 100644 index 0000000..52592fc --- /dev/null +++ b/frontend/src/views/AboutView.vue @@ -0,0 +1,72 @@ +<script setup> +import { computed } from 'vue' +import { site } from '../site' + +const contact = computed(() => site.contact || {}) +</script> + +<template> + <div class="space-y-12 max-w-3xl"> + <!-- intro --> + <section> + <div class="text-[10px] uppercase tracking-[0.3em] text-fuchsia-300/70 font-medium">关于</div> + <h1 class="mt-2 text-4xl md:text-5xl font-bold tracking-tight text-[color:var(--fg)]">关于 {{ site.title }}</h1> + <p class="text-[color:var(--fg-2)] mt-5 text-base md:text-lg leading-relaxed max-w-xl"> + {{ site.title }} 是一个聚合 GPT、Gemini、Firefly、Flux 等顶级模型的 AI 生图与生视频平台。 + 把脑海里的画面写成一句话,交给我们替你变成图像与视频。 + </p> + </section> + + <!-- contact --> + <section> + <h2 class="text-2xl font-bold tracking-tight text-[color:var(--fg)] mb-1">联系我们</h2> + <p class="text-[color:var(--fg-3)] mb-6">有问题、合作,或想加入交流群?随时找我们。</p> + + <div class="grid grid-cols-1 sm:grid-cols-2 gap-3"> + <a v-if="contact.shop" :href="contact.shop" target="_blank" rel="noopener" + class="group flex items-center justify-between gap-3 rounded-2xl bg-[var(--surface)] ring-1 ring-[color:var(--hairline)] p-5 hover:ring-[color:var(--fg-faint)] transition-all"> + <div> + <div class="text-[10px] uppercase tracking-[0.25em] text-fuchsia-300/80">商店</div> + <div class="text-base font-semibold text-[color:var(--fg)] mt-1 group-hover:text-fuchsia-400 transition-colors">前往充值商店</div> + </div> + <span class="text-[color:var(--fg-faint)] group-hover:translate-x-1 transition-transform">→</span> + </a> + + <a v-if="contact.email" :href="`mailto:${contact.email}`" + class="group rounded-2xl bg-[var(--surface)] ring-1 ring-[color:var(--hairline)] p-5 hover:ring-[color:var(--fg-faint)] transition-all"> + <div class="text-[10px] uppercase tracking-[0.25em] text-[color:var(--fg-3)]">邮箱</div> + <div class="text-base font-semibold text-[color:var(--fg)] mt-1 break-all group-hover:text-sky-400 transition-colors">{{ contact.email }}</div> + </a> + + <a v-if="contact.qq && contact.qq_link" :href="contact.qq_link" target="_blank" rel="noopener" + class="group flex items-center justify-between gap-3 rounded-2xl bg-[var(--surface)] ring-1 ring-[color:var(--hairline)] p-5 hover:ring-[color:var(--fg-faint)] transition-all"> + <div> + <div class="text-[10px] uppercase tracking-[0.25em] text-[color:var(--fg-3)]">QQ</div> + <div class="text-base font-semibold text-[color:var(--fg)] mt-1 tabular-nums break-all group-hover:text-sky-400 transition-colors">{{ contact.qq }}</div> + </div> + <span class="text-[color:var(--fg-faint)] group-hover:translate-x-1 transition-transform">→</span> + </a> + <div v-else-if="contact.qq" class="rounded-2xl bg-[var(--surface)] ring-1 ring-[color:var(--hairline)] p-5"> + <div class="text-[10px] uppercase tracking-[0.25em] text-[color:var(--fg-3)]">QQ</div> + <div class="text-base font-semibold text-[color:var(--fg)] mt-1 tabular-nums break-all">{{ contact.qq }}</div> + </div> + + <a v-if="contact.qq_group && contact.qq_group_link" :href="contact.qq_group_link" target="_blank" rel="noopener" + class="group flex items-center justify-between gap-3 rounded-2xl bg-[var(--surface)] ring-1 ring-[color:var(--hairline)] p-5 hover:ring-[color:var(--fg-faint)] transition-all"> + <div> + <div class="text-[10px] uppercase tracking-[0.25em] text-[color:var(--fg-3)]">QQ 群</div> + <div class="text-base font-semibold text-[color:var(--fg)] mt-1 tabular-nums break-all group-hover:text-emerald-400 transition-colors">{{ contact.qq_group }}</div> + </div> + <span class="text-[color:var(--fg-faint)] group-hover:translate-x-1 transition-transform">→</span> + </a> + <div v-else-if="contact.qq_group" class="rounded-2xl bg-[var(--surface)] ring-1 ring-[color:var(--hairline)] p-5"> + <div class="text-[10px] uppercase tracking-[0.25em] text-[color:var(--fg-3)]">QQ 群</div> + <div class="text-base font-semibold text-[color:var(--fg)] mt-1 tabular-nums break-all">{{ contact.qq_group }}</div> + </div> + </div> + + <p v-if="!contact.shop && !contact.email && !contact.qq && !contact.qq_group" + class="text-sm text-[color:var(--fg-3)]">管理员尚未配置联系方式。</p> + </section> + </div> +</template> diff --git a/frontend/src/views/AccountsView.vue b/frontend/src/views/AccountsView.vue new file mode 100644 index 0000000..9da9828 --- /dev/null +++ b/frontend/src/views/AccountsView.vue @@ -0,0 +1,651 @@ +<script setup> +import { ref, computed, onMounted, watch } from 'vue' +import { api, jsonBody } from '../api' +import { fmtTs, fmtIso, fmtDate, fmtClock } from '../utils/format' +import ImportModal from '../components/ImportModal.vue' +import Icon from '../components/Icon.vue' + +const rows = ref([]) +const loading = ref(false) +const quotaStatus = ref('') +const showImport = ref(false) + +const typeFilter = ref('') // '' | 'openai' | 'adobe' | 'runway' | 'leonardo' +const statusFilter = ref('') // '' | 'active' | 'quota' | 'disabled' +const search = ref('') + +const page = ref(1) +const pageSize = ref(20) +// Typing a search term must jump back to page 1 — otherwise a narrowed result +// set can leave you stranded on a now-empty page. +watch(search, () => { page.value = 1 }) + +// 每个类型的 成功/失败/限额 三个数(成功=正常可用, 失败=失效/禁用, 限额=额度耗尽)。 +const stats = computed(() => { + const by = (t) => { + const s = rows.value.filter((r) => r.type === t) + return { + n: s.length, + ok: s.filter((r) => r.status === 'active').length, + dead: s.filter((r) => r.dead || r.status === 'disabled').length, + quota: s.filter((r) => r.status === 'quota').length, + } + } + return { + total: rows.value.length, + openai: by('openai'), adobe: by('adobe'), runway: by('runway'), + leonardo: by('leonardo'), krea: by('krea'), imagine: by('imagine'), + } +}) + +// 异常账号 = 已失效(401)被锁定的号(红色锁定行)。用于「一键删除异常账号」。 +const deadCount = computed(() => rows.value.filter((r) => r.dead).length) + +function typePill(t) { + return { + adobe: 'bg-rose-500/10 text-rose-300 ring-rose-400/30', + openai: 'bg-emerald-500/10 text-emerald-300 ring-emerald-400/30', + runway: 'bg-violet-500/10 text-violet-300 ring-violet-400/30', + leonardo: 'bg-amber-500/10 text-amber-300 ring-amber-400/30', + krea: 'bg-sky-500/10 text-sky-300 ring-sky-400/30', + imagine: 'bg-teal-500/10 text-teal-300 ring-teal-400/30', + }[t] || 'bg-white/[0.06] text-white/70 ring-white/15' +} +const STATUS_LABEL = { active: '正常', quota: '额度耗尽', disabled: '已禁用', pending: '检测中' } + +const filtered = computed(() => { + const q = search.value.trim().toLowerCase() + const sorted = [...rows.value].sort((a, b) => (b.created_at || 0) - (a.created_at || 0)) + return sorted.filter((a) => { + if (typeFilter.value && a.type !== typeFilter.value) return false + if (statusFilter.value && a.status !== statusFilter.value) return false + if (q && !( + (a.email || '').toLowerCase().includes(q) || + (a.id || '').toLowerCase().includes(q) || + (a.type || '').toLowerCase().includes(q) + )) return false + return true + }) +}) + +const totalPages = computed(() => Math.max(1, Math.ceil(filtered.value.length / pageSize.value))) +const pagedItems = computed(() => { + const start = (page.value - 1) * pageSize.value + return filtered.value.slice(start, start + pageSize.value) +}) +function goPage(n) { + const target = Math.max(1, Math.min(totalPages.value, n)) + if (target !== page.value) page.value = target +} +function setFilter(fn) { fn(); page.value = 1 } +const pageNumbers = computed(() => { + const n = totalPages.value + const cur = page.value + if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1) + const want = new Set([1, n, cur - 1, cur, cur + 1]) + if (cur <= 3) { want.add(2); want.add(3); want.add(4) } + if (cur >= n - 2) { want.add(n - 1); want.add(n - 2); want.add(n - 3) } + const list = [...want].filter((x) => x >= 1 && x <= n).sort((a, b) => a - b) + const out = [] + for (let i = 0; i < list.length; i++) { + if (i > 0 && list[i] - list[i - 1] > 1) out.push(null) + out.push(list[i]) + } + return out +}) + +let pendingTimer = null + +async function loadAccounts() { + loading.value = true + quotaStatus.value = '' + const r = await api('/accounts') + rows.value = r.data?.data || [] + loading.value = false + if (rows.value.length) reconcile() + schedulePendingPoll() +} + +// While any imported account is still being checked server-side, re-fetch the +// list so it flips pending → active/dead on its own (no manual refresh). +function schedulePendingPoll() { + if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null } + if (!rows.value.some((r) => r.pending)) return + pendingTimer = setTimeout(async () => { + const r = await api('/accounts') + rows.value = r.data?.data || [] + schedulePendingPoll() + }, 2000) +} + +// Background reconciliation: openai → live quota; adobe → reset_after; +// adobe without email → fetch email. Only ACTIVE accounts — pending ones are +// handled by the import worker, dead/disabled ones need no re-check. +// +// Scope: ONLY the accounts visible on the current page. Probing all 100+ rows on +// every open floods the backend; the user only ever sees ~20 at a time, so we +// re-check just those and re-run when the page (or filters) change. New imports +// are hydrated server-side by the import worker and surfaced via the pending +// poll reading the store — they don't need a frontend probe. +let reconcileToken = 0 +async function reconcile() { + const myToken = ++reconcileToken // supersede any in-flight run (fast page flips) + const visible = pagedItems.value + // NEW accounts (still pending the import worker's server-side check) are never + // probed here — the pending poll just reads the store until the worker writes + // their quota/email. OLD accounts (active) get a real live /quota probe for + // up-to-date remaining + refresh time. + const quotaRows = visible.filter((r) => !r.pending && r.status === 'active' && (r.type === 'openai' || r.type === 'adobe' || r.type === 'runway' || r.type === 'leonardo' || r.type === 'krea' || r.type === 'imagine')) + const adobeNeedEmail = visible.filter((r) => !r.pending && r.type === 'adobe' && !r.email) + const total = quotaRows.length + adobeNeedEmail.length + if (total === 0) { quotaStatus.value = ''; return } + + let done = 0, updates = 0 + quotaStatus.value = `后台校对… 0/${total}` + const bump = () => { + done++ + if (myToken !== reconcileToken) return // a newer page-flip superseded us + quotaStatus.value = `后台校对… ${done}/${total}${updates ? ` · 更新 ${updates}` : ''}` + } + + // Build thunks (NOT immediately-invoked) so the pool controls how many run at + // once. Each /accounts/.../quota probe is a *synchronous* backend call to + // OpenAI/Adobe. We only probe the visible page (≤ pageSize rows), so the + // limit below is effectively bounded by that — no full-list flood. + const jobs = [] + for (const row of quotaRows) { + jobs.push(async () => { + const result = await fetchOneQuota(row.pool, row.id) + if (result && result.auth_failed) { + // backend auto-disabled this dead (401) token — reflect it immediately + row.status = result.status || 'disabled' + row.dead = true + row.remaining = null + row._unknown = true + updates++ + } else if (result && result.unchanged === false) { + if (row.type === 'adobe') row.reset_after = result.reset_after + else applyQuota(row, result) + updates++ + } + bump() + }) + } + for (const row of adobeNeedEmail) { + jobs.push(async () => { + const result = await fetchOneEmail(row.pool, row.id) + if (result && result.email && (!row.email || row.email === '—')) { + row.email = result.email + updates++ + } + bump() + }) + } + await runWithLimit(jobs, Infinity) // no JS-side cap — fire all visible-page probes at once (browser still limits ~6 conns/origin) + // clear the indicator when done — but only if we're still the current run + // (a page flip mid-reconcile starts a fresh one that owns the indicator). + if (myToken === reconcileToken) quotaStatus.value = '' +} + +// Re-check the newly visible accounts whenever the page or filters change. +// Only the on-screen page is ever probed (see reconcile), so flipping pages is +// what triggers checking the rest — never all rows at once. +watch([page, typeFilter, statusFilter], () => { + if (rows.value.length) reconcile() +}) + +// Bounded-concurrency runner: keeps at most `limit` thunks in flight at once. +async function runWithLimit(thunks, limit) { + let next = 0 + const workers = Array.from({ length: Math.min(limit, thunks.length) }, async () => { + while (next < thunks.length) { + const idx = next++ + await thunks[idx]() + } + }) + await Promise.all(workers) +} + +async function fetchOneQuota(pool, id) { + try { return (await api(`/accounts/${pool}/${id}/quota`)).data || {} } + catch (e) { return { error: String(e) } } +} +async function fetchOneEmail(pool, id) { + try { return (await api(`/accounts/${pool}/${id}/email`)).data || {} } + catch (e) { return { error: String(e) } } +} + +function applyQuota(row, result) { + // A transient probe error (e.g. connection reset when OpenAI is unreachable + // without a proxy) must NOT blank the cached number — keep the last-known + // value so a network blip doesn't turn the whole column into "—". + if (result.error) { row._quotaError = result.error; return } + row._quotaError = null + if (result.unknown && result.remaining === null) { row.remaining = null; row._unknown = true; return } + row._unknown = false + row.remaining = result.remaining + row.reset_after = result.reset_after +} + +async function toggleAccountStatus(pool, id, current) { + const row = rows.value.find((r) => r.pool === pool && r.id === id) + const next = current === 'active' ? 'disabled' : 'active' + // Optimistic: flip the switch instantly so the UI never waits on the network. + // The PATCH itself is a cheap in-memory update server-side; the old 5s lag came + // from the follow-up loadAccounts() → reconcile() probing every account's quota. + if (row) row.status = next + try { + const r = await api(`/tokens/${pool}/${id}`, jsonBody('PATCH', { status: next })) + if (!r.ok && row) row.status = current // revert on server rejection + } catch (e) { + if (row) row.status = current // revert on network error + } +} + +async function deleteAccount(pool, id) { + if (!confirm(`确认删除 ${pool} / ${id}?`)) return + await api(`/tokens/${pool}/${id}`, { method: 'DELETE' }) + loadAccounts() +} + +// 一键删除全部异常(已失效/红色锁定)账号。逐个走与单删相同的 DELETE 接口。 +async function deleteDeadAccounts() { + const dead = rows.value.filter((r) => r.dead) + if (!dead.length) return + if (!confirm(`确认删除全部 ${dead.length} 个异常(已失效)账号?此操作不可撤销。`)) return + await Promise.all(dead.map((r) => api(`/tokens/${r.pool}/${r.id}`, { method: 'DELETE' }))) + loadAccounts() +} + +// ===== 多选删除 ===== +const selected = ref(new Set()) +function toggleSelect(id) { + const s = new Set(selected.value) + s.has(id) ? s.delete(id) : s.add(id) + selected.value = s +} +// Header checkbox controls the whole filtered set (not just the visible page). +const allSelected = computed(() => + filtered.value.length > 0 && filtered.value.every((a) => selected.value.has(a.id))) +function toggleSelectAll() { + const s = new Set(selected.value) + if (allSelected.value) filtered.value.forEach((a) => s.delete(a.id)) + else filtered.value.forEach((a) => s.add(a.id)) + selected.value = s +} +async function deleteSelected() { + const ids = [...selected.value] + if (!ids.length) return + if (!confirm(`确认删除选中的 ${ids.length} 个账号?此操作不可撤销。`)) return + const r = await api('/tokens/delete-bulk', jsonBody('POST', { ids })) + if (r.ok) { + selected.value = new Set() + loadAccounts() + } +} + +onMounted(loadAccounts) +</script> + +<template> + <section class="space-y-4"> + <!-- KPI strip — 每个类型显示 成功/失败/限额 三个数(绿/红/琥珀) --> + <div class="grid grid-cols-2 md:grid-cols-4 xl:grid-cols-7 gap-3"> + <div class="card p-4"> + <div class="text-[11px] uppercase tracking-wider text-white/45">账号总数</div> + <div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.total }}</div> + <div class="text-[10px] text-white/35 mt-0.5">成功/失败/限额</div> + </div> + <div v-for="t in [['openai','OpenAI','text-emerald-300/80'],['adobe','Adobe','text-rose-300/80'],['runway','Runway','text-violet-300/80'],['leonardo','Leonardo','text-amber-300/80'],['krea','Krea','text-sky-300/80'],['imagine','Imagine','text-teal-300/80']]" + :key="t[0]" class="card p-4"> + <div class="text-[11px] uppercase tracking-wider" :class="t[2]">{{ t[1] }}</div> + <div class="text-2xl font-semibold mt-1 tabular-nums"> + <span class="text-emerald-300">{{ stats[t[0]].ok }}</span><span class="text-white/30">/</span><span class="text-rose-300">{{ stats[t[0]].dead }}</span><span class="text-white/30">/</span><span class="text-amber-300">{{ stats[t[0]].quota }}</span> + </div> + <div class="text-[10px] text-white/35 mt-0.5">共 {{ stats[t[0]].n }}</div> + </div> + </div> + + <!-- Toolbar --> + <div class="card p-3 flex items-center gap-3 flex-wrap"> + <div class="flex items-center gap-1"> + <button @click="setFilter(() => typeFilter = '')" class="fp" :class="typeFilter === '' && 'fp-on'">全部类型</button> + <button @click="setFilter(() => typeFilter = 'openai')" class="fp" :class="typeFilter === 'openai' && 'fp-emerald'"> + <span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>OpenAI + </button> + <button @click="setFilter(() => typeFilter = 'adobe')" class="fp" :class="typeFilter === 'adobe' && 'fp-rose'"> + <span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>Adobe + </button> + <button @click="setFilter(() => typeFilter = 'runway')" class="fp" :class="typeFilter === 'runway' && 'fp-violet'"> + <span class="w-1.5 h-1.5 rounded-full bg-violet-400"></span>Runway + </button> + <button @click="setFilter(() => typeFilter = 'leonardo')" class="fp" :class="typeFilter === 'leonardo' && 'fp-amber'"> + <span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span>Leonardo + </button> + <button @click="setFilter(() => typeFilter = 'krea')" class="fp" :class="typeFilter === 'krea' && 'fp-sky'"> + <span class="w-1.5 h-1.5 rounded-full bg-sky-400"></span>Krea + </button> + <button @click="setFilter(() => typeFilter = 'imagine')" class="fp" :class="typeFilter === 'imagine' && 'fp-teal'"> + <span class="w-1.5 h-1.5 rounded-full bg-teal-400"></span>Imagine + </button> + </div> + <div class="w-px h-5 bg-white/10"></div> + <div class="flex items-center gap-1"> + <button @click="setFilter(() => statusFilter = '')" class="fp" :class="statusFilter === '' && 'fp-on'">所有状态</button> + <button @click="setFilter(() => statusFilter = 'active')" class="fp" :class="statusFilter === 'active' && 'fp-emerald'"> + <span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>正常 + </button> + <button @click="setFilter(() => statusFilter = 'quota')" class="fp" :class="statusFilter === 'quota' && 'fp-amber'"> + <span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span>额度耗尽 + </button> + <button @click="setFilter(() => statusFilter = 'disabled')" class="fp" :class="statusFilter === 'disabled' && 'fp-rose'"> + <span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>已禁用 + </button> + </div> + <div class="flex-1 min-w-[200px]"> + <input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索 邮箱 / ID / 类型…" /> + </div> + <button v-if="selected.size" @click="deleteSelected" class="btn-soft danger" title="删除选中的账号"> + <Icon name="trash" class="w-3.5 h-3.5" /> 删除选中 ({{ selected.size }}) + </button> + <button v-if="deadCount" @click="deleteDeadAccounts" class="btn-soft danger" title="删除全部已失效(401)账号"> + <Icon name="trash" class="w-3.5 h-3.5" /> 删除异常账号 ({{ deadCount }}) + </button> + <button @click="loadAccounts" class="btn-soft"> + <Icon name="refresh" class="w-3.5 h-3.5" /> 刷新 + </button> + <button @click="showImport = true" class="btn-primary"> + <Icon name="plus" class="w-3.5 h-3.5" /> 导入账号 + </button> + </div> + + <!-- Table --> + <div class="card overflow-hidden"> + <div v-if="loading && !rows.length" class="text-center text-sm text-white/40 py-20">加载中…</div> + <div v-else-if="!filtered.length" class="flex flex-col items-center gap-3 text-white/40 py-20"> + <span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center"> + <Icon name="accounts" class="w-6 h-6" /> + </span> + <span class="text-sm">{{ rows.length ? '没有匹配的账号' : '还没有账号' }}</span> + <button v-if="!rows.length" @click="showImport = true" class="btn-soft mt-1">导入第一个</button> + </div> + + <table v-else class="w-full text-sm table-fixed"> + <colgroup> + <col class="w-9" /> <!-- select --> + <col /> <!-- identity (flex) --> + <col class="w-20" /> <!-- type --> + <col class="w-24" /> <!-- remaining --> + <col class="w-32" /> <!-- reset --> + <col class="w-28" /> <!-- created --> + <col class="w-28" /> <!-- last used --> + <col class="w-40" /> <!-- inflight/success/fail --> + <col class="w-16" /> <!-- status switch --> + <col class="w-16" /> <!-- actions --> + </colgroup> + <thead> + <tr class="text-[10px] uppercase tracking-[0.2em] text-white/40 border-b border-white/[0.06]"> + <th class="text-center px-3 py-3 font-medium"> + <input type="checkbox" :checked="allSelected" @change="toggleSelectAll" + class="chk" title="全选" /> + </th> + <th class="text-left px-5 py-3 font-medium">账户</th> + <th class="text-left px-3 py-3 font-medium">类型</th> + <th class="text-right px-3 py-3 font-medium">额度</th> + <th class="text-left px-3 py-3 font-medium">恢复时间</th> + <th class="text-left px-3 py-3 font-medium">创建时间</th> + <th class="text-left px-3 py-3 font-medium">最后使用</th> + <th class="text-center px-3 py-3 font-medium">在途 / 成功 / 失败</th> + <th class="text-left px-3 py-3 font-medium">状态</th> + <th class="text-right px-3 py-3 font-medium">操作</th> + </tr> + </thead> + <tbody> + <tr v-for="a in pagedItems" :key="a.pool + '/' + a.id" + class="border-b border-white/[0.04] hover:bg-white/[0.03] transition-colors" + :class="a.dead && 'dead-row'"> + <!-- select --> + <td class="px-3 py-3.5 align-middle text-center"> + <input type="checkbox" :checked="selected.has(a.id)" @change="toggleSelect(a.id)" @click.stop + class="chk" /> + </td> + <!-- identity --> + <td class="px-5 py-3.5 align-middle"> + <!-- email + per-kind quota markers on one line. Both-limited shows as + 额度耗尽 in the status column, so here we only surface the single + case. --> + <div class="flex items-center gap-2 min-w-0"> + <span class="text-sm text-white/90 truncate" :title="a.email || '-'">{{ a.email || '-' }}</span> + <span v-if="a.team_id" + class="shrink-0 inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-mono bg-violet-500/15 text-violet-300 ring-1 ring-violet-400/20" + :title="'Runway team_id ' + a.team_id">{{ a.team_id }}</span> + <span v-if="a.image_limited && a.status !== 'quota'" + class="shrink-0 inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium bg-amber-500/15 text-amber-300 ring-1 ring-amber-400/20" + title="图片额度耗尽,仅视频可用">图片限额</span> + <span v-if="a.video_limited && a.status !== 'quota'" + class="shrink-0 inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium bg-amber-500/15 text-amber-300 ring-1 ring-amber-400/20" + title="视频额度耗尽,仅图片可用">视频限额</span> + </div> + </td> + <!-- type --> + <td class="px-3 py-3.5 align-middle"> + <span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1 whitespace-nowrap" + :class="typePill(a.type)">{{ a.type }}</span> + </td> + <!-- remaining --> + <td class="px-3 py-3.5 align-middle text-right text-sm tabular-nums whitespace-nowrap"> + <!-- quota column: 数字 / — (never "未知"/"失败"/"检测中") --> + <!-- remaining === -1 is the provider "unlimited" sentinel → show — not a scary red -1 --> + <span v-if="(a.type === 'openai' || a.type === 'runway' || a.type === 'leonardo' || a.type === 'krea' || a.type === 'imagine') && a.remaining != null && a.remaining !== -1" + class="font-mono font-semibold" + :class="a.remaining > 0 ? 'text-emerald-300' : 'text-rose-300'">{{ a.remaining }}</span> + <span v-else class="text-white/25" :title="a._quotaError || ''">—</span> + </td> + <!-- reset_after --> + <td class="px-3 py-3.5 align-middle text-xs whitespace-nowrap"> + <div v-if="a.reset_after" class="leading-tight" :title="fmtIso(a.reset_after)"> + <div class="text-white/65 tabular-nums">{{ fmtDate(a.reset_after) }}</div> + <div class="text-white/35 tabular-nums">{{ fmtClock(a.reset_after) }}</div> + </div> + <span v-else class="text-white/25">—</span> + </td> + <!-- created_at --> + <td class="px-3 py-3.5 align-middle text-xs whitespace-nowrap"> + <div class="leading-tight" :title="fmtTs(a.created_at)"> + <div class="text-white/65 tabular-nums">{{ fmtDate(a.created_at) }}</div> + <div class="text-white/35 tabular-nums">{{ fmtClock(a.created_at) }}</div> + </div> + </td> + <!-- last_used_at --> + <td class="px-3 py-3.5 align-middle text-xs whitespace-nowrap"> + <div v-if="a.last_used_at" class="leading-tight" :title="fmtTs(a.last_used_at)"> + <div class="text-white/65 tabular-nums">{{ fmtDate(a.last_used_at) }}</div> + <div class="text-white/35 tabular-nums">{{ fmtClock(a.last_used_at) }}</div> + </div> + <span v-else class="text-white/25">从未</span> + </td> + <!-- inflight / success / fail --> + <td class="px-3 py-3.5 align-middle"> + <div class="flex items-center justify-center gap-1.5 text-xs tabular-nums"> + <span class="px-1.5 py-0.5 rounded" + :class="a.in_flight ? 'bg-indigo-500/15 text-indigo-300 font-semibold' : 'text-white/25'" + title="在途">{{ a.in_flight || 0 }}</span> + <span class="text-white/20">/</span> + <span class="px-1.5 py-0.5 rounded text-emerald-300 font-medium" title="成功">{{ a.success_total || 0 }}</span> + <span class="text-white/20">/</span> + <span class="px-1.5 py-0.5 rounded" + :class="a.fail_total ? 'bg-rose-500/15 text-rose-300 font-medium' : 'text-white/25'" + title="失败">{{ a.fail_total || 0 }}</span> + </div> + </td> + <!-- status (switch) --> + <td class="px-3 py-3.5 align-middle"> + <button class="sw" + :class="{ 'sw-on': a.status === 'active', 'sw-dead': a.dead, 'sw-pending': a.status === 'pending', 'sw-quota': a.status === 'quota', 'sw-locked': a.dead || a.status === 'pending' || a.status === 'quota' }" + :disabled="a.dead || a.status === 'pending' || a.status === 'quota'" + :aria-pressed="a.status === 'active'" + :title="a.status === 'pending' ? '正在检测额度…(暂不调度)' : (a.dead ? '号已失效(401) · 已锁定(删除后重新导入有效令牌)' : (a.status === 'quota' ? '额度耗尽 · 已锁定,到恢复时间自动解开' : (a.status === 'active' ? '点击禁用' : '点击启用')))" + @click="!(a.dead || a.status === 'pending' || a.status === 'quota') && toggleAccountStatus(a.pool, a.id, a.status)"> + <span class="sw-thumb"></span> + </button> + </td> + <!-- actions --> + <td class="px-3 py-3.5 align-middle text-right whitespace-nowrap"> + <button @click="deleteAccount(a.pool, a.id)" class="act danger" title="删除"> + <Icon name="trash" class="w-3.5 h-3.5" /> + </button> + </td> + </tr> + </tbody> + </table> + + <!-- pagination --> + <div v-if="!loading && totalPages > 1" + class="flex items-center justify-between gap-3 border-t border-white/[0.06] px-5 py-3 text-xs text-white/55"> + <div> + <span class="tabular-nums text-white/85">{{ (page - 1) * pageSize + 1 }}–{{ Math.min(filtered.length, page * pageSize) }}</span> + <span class="ml-1">/ {{ filtered.length }} 条</span> + </div> + <div class="flex items-center gap-1"> + <template v-for="(n, i) in pageNumbers" :key="i"> + <span v-if="n === null" class="px-1 text-white/35">…</span> + <button v-else @click="goPage(n)" class="pg" :class="page === n && 'pg-on'">{{ n }}</button> + </template> + </div> + </div> + </div> + + <ImportModal v-if="showImport" @close="showImport = false" @imported="loadAccounts" /> + </section> +</template> + +<style scoped> +/* --- filter pills (mirrors LogsView/UsersView/ModelsView) */ +.fp { + display: inline-flex; align-items: center; gap: 0.35rem; + padding: 0.35rem 0.7rem; font-size: 0.72rem; + border-radius: 0.55rem; + color: rgb(255 255 255 / 0.65); + background: rgb(255 255 255 / 0.05); + box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.06); + transition: background 0.15s, color 0.15s, box-shadow 0.15s; +} +.fp:hover { background: rgb(255 255 255 / 0.09); color: white; } +.fp-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); box-shadow: none; } +.fp-emerald { + background: rgb(16 185 129 / 0.22); + color: rgb(110 231 183); + box-shadow: inset 0 0 0 1px rgb(110 231 183 / 0.45); +} +.fp-rose { + background: rgb(244 63 94 / 0.22); + color: rgb(253 164 175); + box-shadow: inset 0 0 0 1px rgb(253 164 175 / 0.45); +} +.fp-amber { + background: rgb(245 158 11 / 0.22); + color: rgb(253 224 71); + box-shadow: inset 0 0 0 1px rgb(253 224 71 / 0.4); +} +.fp-violet { + background: rgb(139 92 246 / 0.22); + color: rgb(196 181 253); + box-shadow: inset 0 0 0 1px rgb(196 181 253 / 0.45); +} +.fp-sky { + background: rgb(56 189 248 / 0.22); + color: rgb(125 211 252); + box-shadow: inset 0 0 0 1px rgb(125 211 252 / 0.45); +} +.fp-teal { + background: rgb(20 184 166 / 0.22); + color: rgb(94 234 212); + box-shadow: inset 0 0 0 1px rgb(94 234 212 / 0.45); +} + +/* --- icon-only action buttons */ +.act { + display: inline-flex; align-items: center; justify-content: center; + width: 1.9rem; height: 1.9rem; + border-radius: 0.5rem; + color: rgb(255 255 255 / 0.7); + background: rgb(255 255 255 / 0.04); + box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08); + transition: background 0.15s, color 0.15s; +} +.act:hover { background: rgb(255 255 255 / 0.1); color: white; } +.act.danger { + color: rgb(253 164 175); + background: rgb(244 63 94 / 0.12); + box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3); +} +.act.danger:hover { color: white; background: rgb(244 63 94 / 0.25); } + +/* toolbar 「删除异常账号」按钮 — rose 变体 */ +.btn-soft.danger { + color: rgb(253 164 175); + background: rgb(244 63 94 / 0.12); + box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3); +} +.btn-soft.danger:hover { color: white; background: rgb(244 63 94 / 0.25); } + +/* iOS-style switch (mirrors UsersView/ModelsView) */ +.sw { + position: relative; + width: 2.25rem; height: 1.3rem; + border-radius: 9999px; + background: rgb(255 255 255 / 0.12); + box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08); + transition: background 0.18s ease; +} +.sw-thumb { + position: absolute; + top: 2px; left: 2px; + width: calc(1.3rem - 4px); height: calc(1.3rem - 4px); + border-radius: 9999px; + background: white; + box-shadow: 0 1px 2px rgb(15 23 42 / 0.3); + transition: transform 0.18s ease; +} +.sw-on { + background: rgb(16 185 129 / 0.7); + box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.5); +} +.sw-on .sw-thumb { transform: translateX(calc(2.25rem - 1.3rem)); } +/* dead account (401) — red, thumb stays left */ +.sw-dead { + background: rgb(244 63 94 / 0.8); + box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.6); +} +/* pending (import quota probe in flight) — neutral indigo, thumb stays left */ +.sw-pending { + background: rgb(99 102 241 / 0.45); + box-shadow: inset 0 0 0 1px rgb(99 102 241 / 0.4); +} +/* quota exhausted — amber (NOT red/dead), thumb stays left, locked until reset */ +.sw-quota { + background: rgb(245 158 11 / 0.5); + box-shadow: inset 0 0 0 1px rgb(245 158 11 / 0.45); +} +/* dead / pending toggle is locked — can't be flipped */ +.sw-locked { cursor: not-allowed; } +/* tint the whole row so a dead account is obvious at a glance */ +.dead-row { background: rgb(244 63 94 / 0.07); } +.dead-row:hover { background: rgb(244 63 94 / 0.12); } + +/* --- numbered pagination buttons */ +.pg { + min-width: 1.75rem; + padding: 0.3rem 0.55rem; + font-size: 0.72rem; + font-weight: 500; + text-align: center; + border-radius: 0.45rem; + color: rgb(255 255 255 / 0.7); + background: rgb(255 255 255 / 0.04); + box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08); + transition: background 0.15s, color 0.15s; +} +.pg:hover:not(.pg-on) { background: rgb(255 255 255 / 0.1); color: white; } +.pg-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); box-shadow: none; } +</style> diff --git a/frontend/src/views/CdksView.vue b/frontend/src/views/CdksView.vue new file mode 100644 index 0000000..52971e1 --- /dev/null +++ b/frontend/src/views/CdksView.vue @@ -0,0 +1,383 @@ +<script setup> +// Admin CDK (redeem code) management — generate fixed-amount codes, list, +// copy, delete. Amounts are 积分. +import { ref, computed, onMounted, watch } from 'vue' +import { api, jsonBody } from '../api' +import { fmtTs } from '../utils/format' +import Icon from '../components/Icon.vue' + +const items = ref([]) +const stats = ref({ total: 0, active: 0, redeemed: 0, active_amount: 0, redeemed_amount: 0 }) +const loading = ref(false) + +// filters +const statusFilter = ref('') // '' | 'active'(未使用) | 'used'(已使用) +const typeFilter = ref('') // '' | 'normal' | 'marketing' +const search = ref('') +function setFilter(fn) { fn(); page.value = 1 } +watch(search, () => { page.value = 1 }) +const filtered = computed(() => { + let list = items.value + if (statusFilter.value === 'active') list = list.filter((c) => c.status === 'active') + else if (statusFilter.value === 'used') list = list.filter((c) => c.status !== 'active') + if (typeFilter.value === 'marketing') list = list.filter((c) => c.type === 'marketing') + else if (typeFilter.value === 'normal') list = list.filter((c) => c.type !== 'marketing') + const q = search.value.trim().toUpperCase() + if (q) list = list.filter((c) => (c.code || '').toUpperCase().includes(q)) + return list +}) + +const form = ref({ amount: 5000, count: 10, type: 'normal' }) +const lastBatch = ref([]) // codes from the most recent generate +const flashMsg = ref('') +let flashTimer = null +function flash(m) { flashMsg.value = m; clearTimeout(flashTimer); flashTimer = setTimeout(() => (flashMsg.value = ''), 2000) } + +const page = ref(1) +const pageSize = ref(20) + +async function load() { + loading.value = true + const r = await api('/cdks') + loading.value = false + if (r.ok) { items.value = r.data?.data || []; stats.value = r.data?.stats || stats.value } +} +onMounted(load) + +async function generate() { + const amount = Number(form.value.amount), count = Number(form.value.count) + if (!amount || amount <= 0) { flash('金额必须大于 0'); return } + if (!count || count <= 0) { flash('数量必须大于 0'); return } + const r = await api('/cdks', jsonBody('POST', { amount, count, type: form.value.type })) + if (!r.ok) { flash(r.data?.detail || '生成失败'); return } + lastBatch.value = (r.data?.created || []).map((c) => c.code) + flash(`已生成 ${lastBatch.value.length} 个兑换码`) + page.value = 1 + load() +} + +async function del(code) { + if (!confirm(`删除兑换码 ${code}?`)) return + const r = await api(`/cdks/${code}`, { method: 'DELETE' }) + if (r.ok) { + flash('已删除') + await load() + // deleting the last row on the last page would otherwise strand us on an + // empty page past the end — clamp back into range. + if (page.value > totalPages.value) page.value = totalPages.value + } else flash(r.data?.detail || '删除失败') +} + +// ===== 多选删除 ===== +const selected = ref(new Set()) +function toggleSelect(code) { + const s = new Set(selected.value) + s.has(code) ? s.delete(code) : s.add(code) + selected.value = s +} +const allSelected = computed(() => + filtered.value.length > 0 && filtered.value.every((c) => selected.value.has(c.code))) +function toggleSelectAll() { + const s = new Set(selected.value) + if (allSelected.value) filtered.value.forEach((c) => s.delete(c.code)) + else filtered.value.forEach((c) => s.add(c.code)) + selected.value = s +} +async function delSelected() { + const codes = [...selected.value] + if (!codes.length) return + if (!confirm(`确认删除选中的 ${codes.length} 个兑换码?此操作不可撤销。`)) return + const r = await api('/cdks/delete-bulk', jsonBody('POST', { codes })) + if (r.ok) { + flash(`已删除 ${r.data?.deleted ?? codes.length} 个`) + selected.value = new Set() + await load() + if (page.value > totalPages.value) page.value = totalPages.value + } else flash(r.data?.detail || '删除失败') +} + +async function copy(text) { + try { await navigator.clipboard.writeText(text); flash('已复制') } catch { flash('复制失败') } +} +function copyBatch() { copy(lastBatch.value.join('\n')) } + +// Client-side pagination over the full list (CDK volumes are bounded by +// how many the admin generates — comfortably small). +const totalPages = computed(() => Math.max(1, Math.ceil(filtered.value.length / pageSize.value))) +const pagedItems = computed(() => { + const start = (page.value - 1) * pageSize.value + return filtered.value.slice(start, start + pageSize.value) +}) +function goPage(n) { + const target = Math.max(1, Math.min(totalPages.value, n)) + if (target !== page.value) page.value = target +} +const pageNumbers = computed(() => { + const n = totalPages.value + const cur = page.value + if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1) + const want = new Set([1, n, cur - 1, cur, cur + 1]) + if (cur <= 3) { want.add(2); want.add(3); want.add(4) } + if (cur >= n - 2) { want.add(n - 1); want.add(n - 2); want.add(n - 3) } + const list = [...want].filter((x) => x >= 1 && x <= n).sort((a, b) => a - b) + const out = [] + for (let i = 0; i < list.length; i++) { + if (i > 0 && list[i] - list[i - 1] > 1) out.push(null) + out.push(list[i]) + } + return out +}) +</script> + +<template> + <section class="space-y-4"> + <!-- KPI strip — same shape as LogsView / InvitesAdminView --> + <div class="grid grid-cols-2 md:grid-cols-4 gap-3"> + <div class="card p-4"> + <div class="text-[11px] uppercase tracking-wider text-white/45">总数</div> + <div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.total }}</div> + </div> + <div class="card p-4"> + <div class="text-[11px] uppercase tracking-wider text-emerald-300/80">未使用</div> + <div class="text-2xl font-semibold mt-1 tabular-nums text-emerald-300">{{ stats.active }}</div> + </div> + <div class="card p-4"> + <div class="text-[11px] uppercase tracking-wider text-white/35">已使用</div> + <div class="text-2xl font-semibold mt-1 tabular-nums text-white/60">{{ stats.redeemed }}</div> + </div> + <div class="card p-4"> + <div class="text-[11px] uppercase tracking-wider text-fuchsia-300/80">未使用面额</div> + <div class="text-2xl font-semibold mt-1 tabular-nums text-fuchsia-300">{{ Number(stats.active_amount || 0).toLocaleString('en-US') }}</div> + </div> + </div> + + <!-- generate --> + <div class="card p-5"> + <div class="flex items-center justify-between mb-4"> + <h2 class="text-sm font-semibold">生成兑换码</h2> + </div> + <div class="flex flex-wrap items-end gap-3"> + <div> + <label class="block text-xs text-white/55 mb-1.5">单个金额 (积分)</label> + <input v-model.number="form.amount" type="number" min="1" step="1" class="field w-40" /> + </div> + <div> + <label class="block text-xs text-white/55 mb-1.5">数量</label> + <input v-model.number="form.count" type="number" min="1" max="500" step="1" class="field w-28" /> + </div> + <div> + <label class="block text-xs text-white/55 mb-1.5">类型</label> + <div class="flex items-center gap-1"> + <button type="button" @click="form.type = 'normal'" class="fp" :class="form.type === 'normal' && 'fp-on'">普通</button> + <button type="button" @click="form.type = 'marketing'" class="fp" :class="form.type === 'marketing' && 'fp-fuchsia'">营销</button> + </div> + </div> + <button @click="generate" class="btn-primary"><Icon name="plus" class="w-3.5 h-3.5" /> 生成</button> + </div> + <p v-if="form.type === 'marketing'" class="text-[11px] text-fuchsia-300/80 mt-2">营销兑换码:同一批次每个用户只能兑换一次。</p> + + <!-- last batch --> + <div v-if="lastBatch.length" class="mt-4 rounded-xl bg-white/[0.04] ring-1 ring-white/10 p-4"> + <div class="flex items-center justify-between mb-2"> + <span class="text-xs font-medium text-white/75">刚生成 {{ lastBatch.length }} 个 — 请复制保存</span> + <button @click="copyBatch" class="text-xs btn-soft"><Icon name="copy" class="w-3.5 h-3.5" /> 全部复制</button> + </div> + <div class="font-mono text-xs text-white/85 space-y-0.5 max-h-40 overflow-auto"> + <div v-for="code in lastBatch" :key="code">{{ code }}</div> + </div> + </div> + </div> + + <!-- Toolbar --> + <div class="card p-3 flex items-center gap-3 flex-wrap"> + <div class="flex items-center gap-1"> + <button @click="setFilter(() => typeFilter = '')" class="fp" :class="typeFilter === '' && 'fp-on'">全部类型</button> + <button @click="setFilter(() => typeFilter = 'normal')" class="fp" :class="typeFilter === 'normal' && 'fp-on'">普通</button> + <button @click="setFilter(() => typeFilter = 'marketing')" class="fp" :class="typeFilter === 'marketing' && 'fp-fuchsia'"> + <span class="w-1.5 h-1.5 rounded-full bg-fuchsia-400"></span>营销 + </button> + </div> + <div class="w-px h-5 bg-white/10"></div> + <div class="flex items-center gap-1"> + <button @click="setFilter(() => statusFilter = '')" class="fp" :class="statusFilter === '' && 'fp-on'">所有状态</button> + <button @click="setFilter(() => statusFilter = 'active')" class="fp" :class="statusFilter === 'active' && 'fp-emerald'"> + <span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>未使用 + </button> + <button @click="setFilter(() => statusFilter = 'used')" class="fp" :class="statusFilter === 'used' && 'fp-on'">已使用</button> + </div> + <div class="flex-1 min-w-[160px]"> + <input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索兑换码…" /> + </div> + <button v-if="selected.size" @click="delSelected" class="btn-soft danger" title="删除选中的兑换码"> + <Icon name="trash" class="w-3.5 h-3.5" /> 删除选中 ({{ selected.size }}) + </button> + <button @click="load" class="btn-soft"> + <Icon name="refresh" class="w-3.5 h-3.5" /> 刷新 + </button> + </div> + + <!-- table --> + <div class="card overflow-hidden"> + <div v-if="loading && !items.length" class="text-center text-sm text-white/40 py-16">加载中…</div> + <div v-else-if="!items.length" class="text-center text-sm text-white/40 py-16">还没有兑换码</div> + <div v-else-if="!filtered.length" class="text-center text-sm text-white/40 py-16">没有匹配的兑换码</div> + <table v-else class="w-full text-sm"> + <colgroup> + <col class="w-9" /> + <col /> + <col class="w-28" /> + <col class="w-28" /> + <col class="w-44" /> + <col class="w-44" /> + <col class="w-40" /> + <col class="w-20" /> + </colgroup> + <thead> + <tr class="text-[10px] uppercase tracking-[0.2em] text-white/40 border-b border-white/[0.06]"> + <th class="text-center px-3 py-3 font-medium"> + <input type="checkbox" :checked="allSelected" @change="toggleSelectAll" + class="chk" title="全选" /> + </th> + <th class="text-left px-5 py-3 font-medium">兑换码</th> + <th class="text-right px-3 py-3 font-medium">金额</th> + <th class="text-left px-3 py-3 font-medium">状态</th> + <th class="text-left px-3 py-3 font-medium">创建时间</th> + <th class="text-left px-3 py-3 font-medium">使用时间</th> + <th class="text-left px-3 py-3 font-medium">使用者</th> + <th class="text-right px-5 py-3 font-medium">操作</th> + </tr> + </thead> + <tbody> + <tr v-for="c in pagedItems" :key="c.code" + class="border-b border-white/[0.04] hover:bg-white/[0.03] transition-colors"> + <td class="px-3 py-3.5 align-middle text-center"> + <input type="checkbox" :checked="selected.has(c.code)" @change="toggleSelect(c.code)" @click.stop + class="chk" /> + </td> + <td class="px-5 py-3.5 align-middle font-mono text-xs text-white/90 truncate" :title="c.code"> + <span class="inline-flex items-center gap-2"> + <span class="truncate">{{ c.code }}</span> + <span v-if="c.type === 'marketing'" class="shrink-0 inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-sans font-medium bg-fuchsia-500/15 text-fuchsia-300 ring-1 ring-fuchsia-400/25">营销</span> + </span> + </td> + <td class="px-3 py-3.5 align-middle text-right tabular-nums text-white/85 whitespace-nowrap"> + {{ Number(c.amount).toLocaleString('en-US') }} + </td> + <td class="px-3 py-3.5 align-middle"> + <span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1" + :class="c.status === 'active' + ? 'bg-emerald-500/10 text-emerald-300 ring-emerald-400/30' + : 'bg-white/[0.06] text-white/55 ring-white/15'"> + <span class="w-1.5 h-1.5 rounded-full" + :class="c.status === 'active' ? 'bg-emerald-400' : 'bg-slate-400'"></span> + {{ c.status === 'active' ? '未使用' : '已使用' }} + </span> + </td> + <td class="px-3 py-3.5 align-middle text-xs text-white/55 tabular-nums whitespace-nowrap">{{ fmtTs(c.created_at) }}</td> + <td class="px-3 py-3.5 align-middle text-xs text-white/55 tabular-nums whitespace-nowrap">{{ c.redeemed_at ? fmtTs(c.redeemed_at) : '—' }}</td> + <td class="px-3 py-3.5 align-middle text-xs text-white/55 truncate">{{ c.redeemed_by_name || '—' }}</td> + <td class="px-5 py-3.5 align-middle text-right"> + <div class="inline-flex items-center gap-1"> + <button @click="copy(c.code)" class="act" title="复制"><Icon name="copy" class="w-3.5 h-3.5" /></button> + <button @click="del(c.code)" class="act danger" title="删除"><Icon name="trash" class="w-3.5 h-3.5" /></button> + </div> + </td> + </tr> + </tbody> + </table> + + <!-- pagination --> + <div v-if="!loading && totalPages > 1" + class="flex items-center justify-between gap-3 border-t border-white/[0.06] px-5 py-3 text-xs text-white/55"> + <div> + <span class="tabular-nums text-white/85">{{ (page - 1) * pageSize + 1 }}–{{ Math.min(items.length, page * pageSize) }}</span> + <span class="ml-1">/ {{ items.length }} 条</span> + </div> + <div class="flex items-center gap-1"> + <template v-for="(n, i) in pageNumbers" :key="i"> + <span v-if="n === null" class="px-1 text-white/35">…</span> + <button v-else @click="goPage(n)" class="pg" :class="page === n && 'pg-on'">{{ n }}</button> + </template> + </div> + </div> + </div> + + <transition name="fade"> + <div v-if="flashMsg" class="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 bg-slate-900 text-white text-sm font-medium px-5 py-2.5 rounded-full shadow-2xl">{{ flashMsg }}</div> + </transition> + </section> +</template> + +<style scoped> +.fade-enter-active, .fade-leave-active { transition: opacity 0.18s ease; } +.fade-enter-from, .fade-leave-to { opacity: 0; } + +.btn-soft.danger { + color: rgb(253 164 175); + background: rgb(244 63 94 / 0.12); + box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3); +} +.btn-soft.danger:hover { + color: white; + background: rgb(244 63 94 / 0.25); +} + +/* row icon action buttons — mirror 账号管理 for a consistent look */ +.act { + display: inline-flex; align-items: center; justify-content: center; + width: 1.9rem; height: 1.9rem; + border-radius: 0.5rem; + color: rgb(255 255 255 / 0.7); + background: rgb(255 255 255 / 0.04); + box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08); + transition: background 0.15s, color 0.15s; +} +.act:hover { background: rgb(255 255 255 / 0.1); color: white; } +.act.danger { + color: rgb(253 164 175); + background: rgb(244 63 94 / 0.12); + box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3); +} +.act.danger:hover { color: white; background: rgb(244 63 94 / 0.25); } + +.fp { + display: inline-flex; align-items: center; gap: 0.35rem; + padding: 0.35rem 0.7rem; font-size: 0.72rem; + border-radius: 0.55rem; + color: rgb(255 255 255 / 0.65); + background: rgb(255 255 255 / 0.05); + box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.06); + transition: background 0.15s, color 0.15s, box-shadow 0.15s; +} +.fp:hover { background: rgb(255 255 255 / 0.09); color: white; } +.fp-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); box-shadow: none; } +.fp-emerald { + background: rgb(16 185 129 / 0.22); + color: rgb(110 231 183); + box-shadow: inset 0 0 0 1px rgb(110 231 183 / 0.45); +} +.fp-fuchsia { + background: rgb(217 70 239 / 0.22); + color: rgb(245 208 254); + box-shadow: inset 0 0 0 1px rgb(245 208 254 / 0.45); +} + +.pg { + min-width: 1.75rem; + padding: 0.3rem 0.55rem; + font-size: 0.72rem; + font-weight: 500; + text-align: center; + border-radius: 0.45rem; + color: rgb(255 255 255 / 0.7); + background: rgb(255 255 255 / 0.04); + box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08); + transition: background 0.15s, color 0.15s; +} +.pg:hover:not(.pg-on) { background: rgb(255 255 255 / 0.1); color: white; } +.pg-on { + background: rgb(255 255 255 / 0.92); + color: rgb(15 23 42); + box-shadow: none; +} +</style> diff --git a/frontend/src/views/ConfigView.vue b/frontend/src/views/ConfigView.vue new file mode 100644 index 0000000..a8c87ab --- /dev/null +++ b/frontend/src/views/ConfigView.vue @@ -0,0 +1,509 @@ +<script setup> +import { ref, reactive, computed, onMounted } from 'vue' +import { api, jsonBody } from '../api' +import { site } from '../site' +import TagInput from '../components/TagInput.vue' + +// ---- logs (retention window) ---- +const logsCfg = reactive({ retention_days: 30 }) +const logsBusy = ref(false); const logsSaved = ref(false) +async function loadLogs() { + const r = await api('/settings/logs') + if (r.ok && r.data) logsCfg.retention_days = Number(r.data.retention_days) || 30 +} +async function saveLogs() { + logsBusy.value = true; logsSaved.value = false + const r = await api('/settings/logs', jsonBody('PUT', { retention_days: Number(logsCfg.retention_days) || 30 })) + logsBusy.value = false + if (r.ok) { logsSaved.value = true; setTimeout(() => (logsSaved.value = false), 2000) } +} + +// ---- media (生成图片/视频文件留存) ---- +const mediaCfg = reactive({ retention_days: 30 }) +const mediaBusy = ref(false); const mediaSaved = ref(false); const mediaRemoved = ref(0) +async function loadMedia() { + const r = await api('/settings/media') + if (r.ok && r.data) mediaCfg.retention_days = Number(r.data.retention_days) || 30 +} +async function saveMedia() { + mediaBusy.value = true; mediaSaved.value = false; mediaRemoved.value = 0 + const r = await api('/settings/media', jsonBody('PUT', { retention_days: Number(mediaCfg.retention_days) || 30 })) + mediaBusy.value = false + if (r.ok) { + mediaSaved.value = true + mediaRemoved.value = Number(r.data?.removed || 0) + setTimeout(() => (mediaSaved.value = false), 2500) + } +} + +// ---- site (branding shown across the app) ---- +const siteForm = reactive({ title: '', qq: '', qq_link: '', qq_group: '', qq_group_link: '', email: '', shop: '' }) +const siteBusy = ref(false); const siteSaved = ref(false) +async function loadSite() { + const r = await api('/settings/site') + if (r.ok && r.data) { + siteForm.title = r.data.title || '' + const c = r.data.contact || {} + siteForm.qq = c.qq || ''; siteForm.qq_link = c.qq_link || '' + siteForm.qq_group = c.qq_group || '' + siteForm.qq_group_link = c.qq_group_link || '' + siteForm.email = c.email || ''; siteForm.shop = c.shop || '' + } +} +async function saveSite() { + siteBusy.value = true; siteSaved.value = false + const r = await api('/settings/site', jsonBody('PUT', { + title: siteForm.title, + contact: { qq: siteForm.qq, qq_link: siteForm.qq_link, qq_group: siteForm.qq_group, qq_group_link: siteForm.qq_group_link, email: siteForm.email, shop: siteForm.shop }, + })) + siteBusy.value = false + if (r.ok && r.data) { + // Mirror the change into the shared `site` store so every header / + // wordmark / tab title updates without a reload. The PUT response is + // nested ({ ok, data: { title } }) unlike the flat GET, so read the + // saved value from there — falling back to the input we just submitted. + site.title = r.data.data?.title || siteForm.title.trim() + site.contact = r.data.data?.contact || site.contact + siteSaved.value = true + setTimeout(() => (siteSaved.value = false), 2000) + } +} + +// ---- registration ---- +const reg = reactive({ open: true, email_code: false, allow_password_reset: true }) +// Email domain whitelist edited as tag chips so admins can't typo a comma +// out of a domain or accidentally leave a stray space. +const domains = ref([]) +const regBusy = ref(false); const regSaved = ref(false) + +// ---- smtp ---- +const smtp = reactive({ host: '', port: 587, username: '', password: '', from_addr: '', use_tls: true }) +const smtpBusy = ref(false); const smtpSaved = ref(false) + +// ---- rewards ---- +const credits = reactive({ checkin_enabled: true, checkin_reward: 3, invite_enabled: true, invite_reward: 3 }) +const credBusy = ref(false); const credSaved = ref(false) + +// ---- proxy (carried when calling upstream during generation) ---- +const proxy = reactive({ proxy: '' }) +const proxyBusy = ref(false); const proxySaved = ref(false) +async function loadProxy() { + const r = await api('/settings/proxy') + if (r.ok && r.data) proxy.proxy = r.data.proxy || '' +} +async function saveProxy() { + proxyBusy.value = true; proxySaved.value = false + const r = await api('/settings/proxy', jsonBody('PUT', { proxy: proxy.proxy })) + proxyBusy.value = false + if (r.ok) { proxySaved.value = true; setTimeout(() => (proxySaved.value = false), 2000) } +} +// Probe the currently-entered proxy (not necessarily saved) — surfaces the +// egress IP on success, or the concrete dial/DNS error on failure. +const proxyTestBusy = ref(false) +const proxyTest = reactive({ ok: null, msg: '' }) // ok: null=idle, true/false=result +async function testProxy() { + proxyTestBusy.value = true; proxyTest.ok = null; proxyTest.msg = '' + const r = await api('/settings/proxy/test', jsonBody('POST', { proxy: proxy.proxy })) + proxyTestBusy.value = false + if (r.ok && r.data?.ok) { + proxyTest.ok = true + const ip = r.data.data?.exit_ip || '未知' + const ms = r.data.data?.elapsed_ms + proxyTest.msg = `连接成功 · 出口 IP ${ip}${ms != null ? ` · ${ms}ms` : ''}` + } else { + proxyTest.ok = false + proxyTest.msg = r.data?.detail || '代理测试失败' + } +} + +// Email-code requires SMTP to be configured (host saved) first. +const smtpConfigured = computed(() => !!(smtp.host || '').trim()) + +// SMTP save requires the four required fields to be non-empty. Password is +// optional on update — leaving it blank means "keep the current one". +const smtpReady = computed(() => + (smtp.host || '').trim() && + Number(smtp.port) > 0 && + (smtp.username || '').trim() && + (smtp.from_addr || '').trim() +) + +async function loadReg() { + const r = await api('/settings/registration') + if (r.ok && r.data) { + Object.assign(reg, { open: r.data.open, email_code: r.data.email_code, + allow_password_reset: r.data.allow_password_reset }) + // Normalise to lowercase + strip a leading @ on each so the chips render + // exactly what the server will compare against. + domains.value = (r.data.allowed_email_domains || []) + .map((d) => String(d).trim().toLowerCase().replace(/^@/, '')) + .filter(Boolean) + } +} +async function loadSmtp() { + const r = await api('/settings/smtp') + if (r.ok && r.data) Object.assign(smtp, r.data) +} +async function loadCredits() { + const r = await api('/settings/credits') + if (r.ok && r.data) Object.assign(credits, r.data) +} + +async function saveReg() { + regBusy.value = true; regSaved.value = false + const r = await api('/settings/registration', jsonBody('PUT', { + open: reg.open, email_code: reg.email_code, + allow_password_reset: reg.allow_password_reset, + allowed_email_domains: domains.value, + })) + regBusy.value = false + if (r.ok) { regSaved.value = true; setTimeout(() => (regSaved.value = false), 2000); loadReg() } +} +async function saveSmtp() { + smtpBusy.value = true; smtpSaved.value = false + const payload = { host: smtp.host, port: Number(smtp.port) || 587, username: smtp.username, + from_addr: smtp.from_addr, use_tls: smtp.use_tls } + if (smtp.password && smtp.password !== '***') payload.password = smtp.password + const r = await api('/settings/smtp', jsonBody('PUT', payload)) + smtpBusy.value = false + if (r.ok) { smtpSaved.value = true; setTimeout(() => (smtpSaved.value = false), 2000); loadSmtp() } +} + +// ---- SMTP test send ---- +const testEmail = ref('') +const testBusy = ref(false) +const testMsg = ref('') // result message (success or error) +const testOk = ref(false) +async function sendTest() { + const to = (testEmail.value || '').trim() + if (!to || !to.includes('@')) { testMsg.value = '请填写有效的收件邮箱'; testOk.value = false; return } + testBusy.value = true; testMsg.value = '' + // Tests the SAVED config, so save first if you just edited the fields. + const r = await api('/settings/smtp/test', jsonBody('POST', { email: to })) + testBusy.value = false + testOk.value = r.ok + testMsg.value = r.ok ? (r.data?.detail || `测试邮件已发送至 ${to}`) : (r.data?.detail || `发送失败 (${r.status})`) +} +async function saveCredits() { + credBusy.value = true; credSaved.value = false + const r = await api('/settings/credits', jsonBody('PUT', { + checkin_enabled: credits.checkin_enabled, + checkin_reward: Number(credits.checkin_reward) || 0, + invite_enabled: credits.invite_enabled, + invite_reward: Number(credits.invite_reward) || 0, + })) + credBusy.value = false + if (r.ok) { credSaved.value = true; setTimeout(() => (credSaved.value = false), 2000) } +} + +onMounted(() => { loadSite(); loadReg(); loadSmtp(); loadCredits(); loadProxy(); loadLogs(); loadMedia() }) +</script> + +<template> + <section class="space-y-5"> + <!-- site --> + <div class="card p-5"> + <div class="flex items-center justify-between mb-4"> + <h2 class="text-sm font-semibold">网站</h2> + <span v-if="siteSaved" class="text-xs text-emerald-300">已保存 ✓</span> + </div> + <div class="space-y-3"> + <label class="row"> + <span><span class="lbl">网页主标题</span><span class="hint">显示在浏览器标签、首页 Logo、侧栏和登录卡上。未设置时默认显示 "Vivid"。</span></span> + <input v-model="siteForm.title" placeholder="Vivid" class="txt" /> + </label> + <label class="row"> + <span><span class="lbl">联系 QQ</span><span class="hint">QQ 号(显示用)。留空则不显示该项。</span></span> + <input v-model="siteForm.qq" placeholder="1114639355" class="txt" /> + </label> + <label class="row"> + <span><span class="lbl">QQ 链接</span><span class="hint">加好友链接(qm.qq.com/...)。填了则「关于」里的 QQ 可点击、新标签打开。</span></span> + <input v-model="siteForm.qq_link" placeholder="https://qm.qq.com/q/ItgCcNA7ac" class="txt" /> + </label> + <label class="row"> + <span><span class="lbl">QQ 群</span><span class="hint">交流群号(显示用)。留空则不显示。</span></span> + <input v-model="siteForm.qq_group" placeholder="1106849765" class="txt" /> + </label> + <label class="row"> + <span><span class="lbl">QQ 群链接</span><span class="hint">加群链接(qm.qq.com/...)。填了则「关于」里的 QQ 群可点击、新标签打开。</span></span> + <input v-model="siteForm.qq_group_link" placeholder="https://qm.qq.com/q/976LeMFoHu" class="txt" /> + </label> + <label class="row"> + <span><span class="lbl">联系邮箱</span><span class="hint">首页「联系我们」里可点击发邮件。留空则不显示。</span></span> + <input v-model="siteForm.email" placeholder="vividairun@gmail.com" class="txt" /> + </label> + <label class="row"> + <span><span class="lbl">商店地址</span><span class="hint">充值/购买页链接,首页「联系我们」里展示为"前往充值商店"。留空则不显示。</span></span> + <input v-model="siteForm.shop" placeholder="https://pay.ldxp.cn/shop/chiyi" class="txt" /> + </label> + </div> + <div class="mt-4 flex items-center gap-3"> + <button @click="saveSite" :disabled="siteBusy || !siteForm.title.trim()" class="btn-primary">{{ siteBusy ? '保存中…' : '保存设置' }}</button> + <span v-if="!siteForm.title.trim()" class="text-xs text-slate-400">请输入主标题</span> + </div> + </div> + + <!-- registration --> + <div class="card p-5"> + <div class="flex items-center justify-between mb-4"> + <h2 class="text-sm font-semibold">注册与登录</h2> + <span v-if="regSaved" class="text-xs text-emerald-300">已保存 ✓</span> + </div> + <div class="space-y-3"> + <label class="row"> + <span><span class="lbl">开放注册</span><span class="hint">关闭后只能由管理员手动创建账号(首个账号不受限)。</span></span> + <input type="checkbox" v-model="reg.open" class="sw" /> + </label> + <label class="row" :class="!smtpConfigured && 'opacity-50'"> + <span><span class="lbl">注册/找回需要邮箱验证码</span><span class="hint">启用后注册、找回密码需输入邮件发送的验证码。<b v-if="!smtpConfigured">需先在下方配置并保存 SMTP 才能开启。</b></span></span> + <input type="checkbox" v-model="reg.email_code" :disabled="!smtpConfigured" class="sw" /> + </label> + <label class="row" :class="!reg.email_code && 'opacity-50'"> + <span><span class="lbl">支持找回密码</span><span class="hint">凭邮箱+邮件验证码重置。<b v-if="!reg.email_code">需先开启「邮箱验证码」才能启用。</b></span></span> + <!-- When email_code is off, password reset is effectively disabled + regardless of the stored flag (the auth endpoint gates it the + same way). Reflect that by showing the switch off — but keep + the stored value untouched so re-enabling email_code restores + the user's prior preference. --> + <input type="checkbox" + :checked="reg.email_code && reg.allow_password_reset" + :disabled="!reg.email_code" + @change="reg.allow_password_reset = $event.target.checked" + class="sw" /> + </label> + <!-- Stack the tag input under the label so the chips have room --> + <div class="row !block !border-b-0"> + <div class="mb-2"> + <span class="lbl">允许的邮箱后缀</span><br /> + <span class="hint">输入后缀按回车添加(如 gmail.com),点 × 删除。<b>留空 = 不限制</b>,允许任意域名注册。</span> + </div> + <TagInput v-model="domains" placeholder="留空 = 不限制,或输入后缀回车添加" /> + </div> + </div> + <div class="mt-4 flex items-center gap-3"> + <button @click="saveReg" :disabled="regBusy" class="btn-primary">{{ regBusy ? '保存中…' : '保存设置' }}</button> + <span v-if="!domains.length" class="text-xs text-slate-400">未设置后缀 = 不限制邮箱域名</span> + </div> + </div> + + <!-- SMTP --> + <div class="card p-5"> + <div class="flex items-center justify-between mb-4"> + <h2 class="text-sm font-semibold">邮件服务 (SMTP)</h2> + <span v-if="smtpSaved" class="text-xs text-emerald-300">已保存 ✓</span> + </div> + <p class="text-xs text-slate-400 mb-4">用于发送注册 / 找回密码的验证码邮件。465 端口自动用 SSL,其余端口可选 STARTTLS。</p> + <div class="grid sm:grid-cols-2 gap-3"> + <div><label class="flbl">SMTP 主机</label><input v-model="smtp.host" placeholder="smtp.gmail.com" class="field" /></div> + <div><label class="flbl">端口</label><input type="number" v-model.number="smtp.port" placeholder="587" class="field" /></div> + <div><label class="flbl">用户名</label><input v-model="smtp.username" placeholder="you@gmail.com" class="field" /></div> + <div><label class="flbl">密码 / 授权码</label><input v-model="smtp.password" type="password" placeholder="留空表示不修改" class="field" /></div> + <div><label class="flbl">发件地址 (From)</label><input v-model="smtp.from_addr" placeholder="no-reply@yourdomain.com" class="field" /></div> + </div> + <!-- STARTTLS sits below the grid as its own labelled row, matching the + layout of the toggle-style settings in 注册与登录 / 积分奖励 --> + <label class="row mt-2"> + <span><span class="lbl">使用 STARTTLS</span><span class="hint">端口 587 等明文端口加密会话;465 端口已自动用 SSL,无需开启。</span></span> + <input type="checkbox" v-model="smtp.use_tls" class="sw" /> + </label> + <div class="mt-4 flex items-center gap-3"> + <button @click="saveSmtp" :disabled="smtpBusy || !smtpReady" class="btn-primary">{{ smtpBusy ? '保存中…' : '保存设置' }}</button> + <span v-if="!smtpReady" class="text-xs text-slate-400">请填写 主机 / 端口 / 用户名 / 发件地址</span> + </div> + + <!-- Test send: verifies the SAVED config actually delivers mail --> + <div class="mt-4 pt-4 border-t border-white/[0.06]"> + <label class="flbl">测试发送 <span class="text-slate-400 font-normal">(用已保存的配置发一封测试邮件验证)</span></label> + <div class="flex items-center gap-3 mt-1"> + <input v-model="testEmail" type="email" placeholder="收件邮箱,如 you@example.com" class="field flex-1" @keyup.enter="sendTest" /> + <button @click="sendTest" :disabled="testBusy || !smtpConfigured" class="btn-soft whitespace-nowrap"> + {{ testBusy ? '发送中…' : '发送测试' }} + </button> + </div> + <p v-if="!smtpConfigured" class="text-xs text-slate-400 mt-1.5">请先保存 SMTP 配置后再测试。</p> + <p v-else-if="testMsg" class="text-xs mt-1.5" :class="testOk ? 'text-emerald-300' : 'text-rose-300'">{{ testMsg }}</p> + </div> + </div> + + <!-- proxy --> + <div class="card p-5"> + <div class="flex items-center justify-between mb-4"> + <h2 class="text-sm font-semibold">代理 (生图请求)</h2> + <span v-if="proxySaved" class="text-xs text-emerald-300">已保存 ✓</span> + </div> + <p class="text-xs text-slate-400 mb-4">调用上游生成图片/视频时统一使用的 HTTP 代理,留空 = 直连。格式如 <code class="px-1 bg-slate-100 rounded">http://127.0.0.1:7890</code>。修改即时生效,无需重启。</p> + <input v-model="proxy.proxy" placeholder="留空 = 直连,如 http://127.0.0.1:7890" class="field" /> + <div class="mt-4 flex items-center gap-2"> + <button @click="saveProxy" :disabled="proxyBusy" class="btn-primary">{{ proxyBusy ? '保存中…' : '保存设置' }}</button> + <button @click="testProxy" :disabled="proxyTestBusy || !proxy.proxy.trim()" class="btn-ghost">{{ proxyTestBusy ? '测试中…' : '代理测试' }}</button> + </div> + <p v-if="proxyTest.msg" class="text-xs mt-2" :class="proxyTest.ok ? 'text-emerald-300' : 'text-rose-300'">{{ proxyTest.msg }}</p> + </div> + + <!-- rewards --> + <div class="card p-5"> + <div class="flex items-center justify-between mb-4"> + <h2 class="text-sm font-semibold">积分奖励</h2> + <span v-if="credSaved" class="text-xs text-emerald-300">已保存 ✓</span> + </div> + <div class="space-y-3"> + <label class="row"> + <span><span class="lbl">开启每日签到</span><span class="hint">关闭后用户无法签到领取积分。</span></span> + <input type="checkbox" v-model="credits.checkin_enabled" class="sw" /> + </label> + <label class="row" :class="!credits.checkin_enabled && 'opacity-50'"> + <span><span class="lbl">每日签到奖励</span><span class="hint">用户每天签到获得的积分。</span></span> + <input type="number" min="0" v-model.number="credits.checkin_reward" :disabled="!credits.checkin_enabled" class="num" /> + </label> + <label class="row"> + <span><span class="lbl">开启邀请奖励</span><span class="hint">关闭后邀请好友不再发放积分奖励。</span></span> + <input type="checkbox" v-model="credits.invite_enabled" class="sw" /> + </label> + <label class="row" :class="!credits.invite_enabled && 'opacity-50'"> + <span><span class="lbl">邀请奖励</span><span class="hint">被邀请好友首次生图后,邀请人获得的积分。</span></span> + <input type="number" min="0" v-model.number="credits.invite_reward" :disabled="!credits.invite_enabled" class="num" /> + </label> + </div> + <div class="mt-4"><button @click="saveCredits" :disabled="credBusy" class="btn-primary">{{ credBusy ? '保存中…' : '保存设置' }}</button></div> + </div> + + <!-- logs retention --> + <div class="card p-5"> + <div class="flex items-center justify-between mb-4"> + <h2 class="text-sm font-semibold">日志</h2> + <span v-if="logsSaved" class="text-xs text-emerald-300">已保存 ✓</span> + </div> + <div class="space-y-3"> + <label class="row"> + <span> + <span class="lbl">最大留存时间</span> + <span class="hint">超过这个天数的日志会被自动清除,内存里同时还有 500 条的硬上限。范围 1–365 天,默认 30。</span> + </span> + <div class="flex items-center gap-2"> + <input type="number" min="1" max="365" v-model.number="logsCfg.retention_days" class="num" /> + <span class="text-xs text-white/45">天</span> + </div> + </label> + </div> + <div class="mt-4"><button @click="saveLogs" :disabled="logsBusy || !logsCfg.retention_days" class="btn-primary">{{ logsBusy ? '保存中…' : '保存设置' }}</button></div> + </div> + + <!-- media (生成文件) --> + <div class="card p-5"> + <div class="flex items-center justify-between mb-4"> + <h2 class="text-sm font-semibold">生成文件 (图片 / 视频)</h2> + <span v-if="mediaSaved" class="text-xs text-emerald-300"> + 已保存 ✓<span v-if="mediaRemoved" class="text-white/45"> · 立即清理 {{ mediaRemoved }} 个文件</span> + </span> + </div> + <div class="space-y-3"> + <label class="row"> + <span> + <span class="lbl">最大留存时间</span> + <span class="hint">超过该天数的生成文件(包括用户生图和后台测试图)会被自动删除。范围 1–365 天,默认 30。每 5 分钟最多扫一次,保存设置时会立刻清理一次。</span> + </span> + <div class="flex items-center gap-2"> + <input type="number" min="1" max="365" v-model.number="mediaCfg.retention_days" class="num" /> + <span class="text-xs text-white/45">天</span> + </div> + </label> + </div> + <div class="mt-4"><button @click="saveMedia" :disabled="mediaBusy || !mediaCfg.retention_days" class="btn-primary">{{ mediaBusy ? '保存中…' : '保存设置' }}</button></div> + </div> + </section> +</template> + +<style scoped> +/* Colors here pair with the dark admin shell (`.public-dark` wraps <main>). + We use white-alpha instead of slate-* so they hold up against the glass + card background; the older slate values were authored for the white + light-mode admin and washed out almost completely. */ +.row { display: flex; align-items: center; justify-content: space-between; gap: 1.5rem; padding: 0.75rem 0; border-bottom: 1px solid var(--hairline); } +.row:last-child { border-bottom: none; } +.row > span:first-child { display: flex; flex-direction: column; gap: 0.2rem; } +.lbl { font-weight: 500; color: var(--fg); font-size: 0.875rem; } +.hint { font-size: 0.72rem; color: var(--fg-3); line-height: 1.5; } +.hint b { color: rgb(225 29 72); font-weight: 500; } +html.dark .hint b { color: rgb(253 164 175); } /* rose — visible warning, not pure red */ +.flbl { display: block; font-size: 0.72rem; color: var(--fg-3); margin-bottom: 0.35rem; } +/* Pill toggle switch — applied to <input type="checkbox" class="sw">. Keeps + the markup as-is so the existing v-model bindings keep working, but the + control now reads as an on/off slider instead of a tick box. The "locked + but currently on" state (e.g. allow_password_reset when email_code is off) + now reads as a disabled-but-on switch, which matches user intuition. */ +.sw { + -webkit-appearance: none; + appearance: none; + position: relative; + flex-shrink: 0; + width: 2.25rem; + height: 1.3rem; + border-radius: 9999px; + background: rgb(203 213 225); /* slate-300 */ + cursor: pointer; + transition: background 0.18s ease; + outline: none; +} +.sw::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: calc(1.3rem - 4px); + height: calc(1.3rem - 4px); + border-radius: 9999px; + background: white; + box-shadow: 0 1px 2px rgb(15 23 42 / 0.2); + transition: transform 0.18s ease; +} +.sw:checked { background: #4f46e5; } /* indigo-600 — matches btn-primary */ +.sw:checked::after { transform: translateX(calc(2.25rem - 1.3rem)); } +.sw:focus-visible { box-shadow: 0 0 0 3px rgb(99 102 241 / 0.25); } +.sw:disabled { cursor: not-allowed; opacity: 0.55; } +/* All three input variants now share the same dark-glass surface as the rest + of the admin shell. Background + border use white-alpha so they read + against the card; placeholders are tuned for legibility, not noise. */ +.num, .txt, .field { + background: rgb(15 23 42 / 0.03); + border: 1px solid var(--hairline); + color: var(--fg); + border-radius: 0.55rem; + outline: none; + transition: border-color 0.18s, background 0.18s, box-shadow 0.18s; +} +html.dark .num, html.dark .txt, html.dark .field { + background: rgb(255 255 255 / 0.04); + border-color: rgb(255 255 255 / 0.1); + color: white; +} +.num::placeholder, .txt::placeholder, .field::placeholder { color: var(--fg-faint); } +.num:focus, .txt:focus, .field:focus { + border-color: rgb(167 139 250 / 0.65); + background: rgb(255 255 255 / 0.06); + box-shadow: 0 0 0 3px rgb(167 139 250 / 0.15); +} +.num:disabled, .txt:disabled, .field:disabled { opacity: 0.45; cursor: not-allowed; } +.num { width: 6rem; padding: 0.4rem 0.55rem; font-size: 0.8rem; text-align: right; } +.txt { width: 16rem; max-width: 60%; padding: 0.4rem 0.65rem; font-size: 0.8rem; } +.field { width: 100%; padding: 0.55rem 0.75rem; font-size: 0.85rem; } + +/* Section save buttons — solid violet to match brand. The global .btn-primary + under .public-dark goes to white; we override here so the save action stays + visually distinct as the primary action on a form. */ +.btn-primary { + padding: 0.55rem 1.15rem; border-radius: 0.6rem; + font-size: 0.8rem; font-weight: 600; color: white; + background: linear-gradient(135deg, #a855f7 0%, #7c3aed 50%, #ec4899 100%); + box-shadow: 0 8px 20px -8px rgb(168 85 247 / 0.55); + transition: filter 0.15s, transform 0.12s, box-shadow 0.18s, opacity 0.15s; +} +/* Re-assert the gradient on hover for ALL states: the global + `.public-dark .btn-primary:hover` in style.css repaints the background white + with no `:not(:disabled)` guard, so even disabled save buttons (注册与登录 / + 邮件服务 default to disabled) flashed white on hover. Keep this rule + unconditional; only the brightness/lift below is gated on :not(:disabled). */ +.btn-primary:hover { background: linear-gradient(135deg, #a855f7 0%, #7c3aed 50%, #ec4899 100%); } +.btn-primary:hover:not(:disabled) { filter: brightness(1.08); box-shadow: 0 10px 24px -8px rgb(168 85 247 / 0.7); } +.btn-primary:active:not(:disabled) { transform: translateY(1px); } +.btn-primary:disabled { opacity: 0.45; cursor: not-allowed; box-shadow: none; } +</style> diff --git a/frontend/src/views/DocsView.vue b/frontend/src/views/DocsView.vue new file mode 100644 index 0000000..b3494b9 --- /dev/null +++ b/frontend/src/views/DocsView.vue @@ -0,0 +1,372 @@ +<script setup> +// API 对接文档 — OpenAI-compatible. Lists live models and shows ready-to-run +// curl / Python(openai SDK) examples for image + video, wired to this +// deployment's base URL and the caller's model ids. +import { ref, computed, onMounted } from 'vue' +import { auth } from '../auth' +import { api } from '../api' +import { points } from '../credits' +import Icon from '../components/Icon.vue' + +const base = computed(() => location.origin) // /v1 is same-origin (dev: Vite proxy) +const keyHint = computed(() => auth.user?.api_keys?.[0]?.key_preview || 'YOUR_API_KEY') + +const models = ref([]) +onMounted(async () => { + const r = await api('/managed-models') + if (r.ok) models.value = (r.data?.data || []).filter((m) => m.enabled !== false) +}) + +const imageModels = computed(() => models.value.filter((m) => m.type === 'image')) +const videoModels = computed(() => models.value.filter((m) => m.type === 'video')) +const sampleImage = computed(() => imageModels.value[0]?.id || 'firefly-image-4') +const sampleVideo = computed(() => videoModels.value[0]?.id || 'firefly-kling3') +const sampleSeconds = computed(() => String(videoModels.value[0]?.durations?.[0] || '8s').replace(/s$/, '')) + +function priceOf(m) { + if (m.type === 'video') { + // Video charge = resolution price + duration price; show the combined range. + const rv = Object.values(m.prices || {}).filter((v) => v != null).map(Number) + const dv = Object.values(m.duration_prices || {}).filter((v) => v != null).map(Number) + if (!rv.length || !dv.length) return '—' + const lo = Math.min(...rv) + Math.min(...dv) + const hi = Math.max(...rv) + Math.max(...dv) + return lo === hi ? `${points(lo)} 积分` : `${points(lo)}–${points(hi)} 积分` + } + const vals = Object.values(m.prices || {}).filter((v) => v != null).map(Number) + if (!vals.length) return '—' + const lo = Math.min(...vals), hi = Math.max(...vals) + return lo === hi ? `${points(lo)} 积分` : `${points(lo)}–${points(hi)} 积分` +} + +// ---- request parameter tables ---- +const imageParams = [ + ['model', 'string', '必填', '模型 id,见上表(图像)'], + ['prompt', 'string', '必填', '文字描述'], + ['size', 'string', '可选', '"1024x1024" / "1536x1024" / "1024x1536" / "auto" → 决定比例'], + ['quality', 'string', '可选', '"low"|"medium"|"high"|"auto" → 画质档 1K/2K/4K(钳到模型支持档)'], +] +const editParams = [ + ['image', 'file', '必填', '输入图;多张参考图重复 image[] 字段(multipart 文件上传)'], + ['prompt', 'string', '必填', '编辑/参考描述'], + ['model', 'string', '必填', '模型 id(需支持图生图)'], + ['size', 'string', '可选', '同图像:决定比例'], + ['quality', 'string', '可选', '同图像:决定画质档'], +] +const videoParams = [ + ['model', 'string', '必填', '模型 id,见上表(视频)'], + ['prompt', 'string', '必填', '文字描述'], + ['seconds', 'string|int', '必填', '时长秒数,如 "5" "8"(取决于模型支持)'], + ['size', 'string', '可选', '如 "1280x720" / "720x1280" → 决定比例与分辨率'], + ['input_reference', 'file', '可选', '首帧/参考图(multipart 文件;runway 图生视频必填 1 张)'], +] + +// ---- examples (built in script so refs resolve correctly) ---- +const examples = computed(() => [ + { + title: '文生图 · curl', + code: +`curl ${base.value}/v1/images/generations \\ + -H "Authorization: Bearer ${keyHint.value}" \\ + -H "Content-Type: application/json" \\ + -d '{ + "model": "${sampleImage.value}", + "prompt": "a corgi running in a golden wheat field, cinematic", + "size": "1024x1024", + "quality": "high" + }'`, + }, + { + title: '文生图 · Python (openai SDK)', + code: +`import base64 +from openai import OpenAI + +client = OpenAI(api_key="${keyHint.value}", base_url="${base.value}/v1") + +resp = client.images.generate( + model="${sampleImage.value}", + prompt="a corgi running in a golden wheat field, cinematic", + size="1024x1024", + quality="high", +) +# 结果是 base64(无 URL) +with open("out.png", "wb") as f: + f.write(base64.b64decode(resp.data[0].b64_json))`, + }, + { + title: '图生图 / 参考图 · curl (multipart)', + code: +`curl ${base.value}/v1/images/edits \\ + -H "Authorization: Bearer ${keyHint.value}" \\ + -F model="${sampleImage.value}" \\ + -F prompt="把这张图改成赛博朋克风格" \\ + -F quality="high" \\ + -F image=@input.png +# 多张参考图:重复 -F image=@a.png -F image=@b.png`, + }, + { + title: '图生图 · Python (openai SDK)', + code: +`import base64 +from openai import OpenAI + +client = OpenAI(api_key="${keyHint.value}", base_url="${base.value}/v1") + +resp = client.images.edit( + model="${sampleImage.value}", + image=open("input.png", "rb"), # 多张:image=[open("a.png","rb"), open("b.png","rb")] + prompt="把这张图改成赛博朋克风格", +) +with open("out.png", "wb") as f: + f.write(base64.b64decode(resp.data[0].b64_json))`, + }, + { + title: '视频 · curl(创建 → 轮询 → 下载)', + code: +`# 1) 创建任务 → 立即返回 {"id": "...", "status": "queued"} +curl ${base.value}/v1/videos \\ + -H "Authorization: Bearer ${keyHint.value}" \\ + -H "Content-Type: application/json" \\ + -d '{ + "model": "${sampleVideo.value}", + "prompt": "a paper boat sailing down a rainy street, cinematic", + "seconds": "${sampleSeconds.value}", + "size": "1280x720" + }' + +# 2) 轮询状态,直到 status=completed +curl ${base.value}/v1/videos/<VIDEO_ID> \\ + -H "Authorization: Bearer ${keyHint.value}" + +# 3) 下载 mp4(完成后) +curl ${base.value}/v1/videos/<VIDEO_ID>/content \\ + -H "Authorization: Bearer ${keyHint.value}" -o out.mp4`, + }, + { + title: '视频 · Python (requests, 轮询)', + code: +`import time, requests + +base = "${base.value}/v1" +h = {"Authorization": "Bearer ${keyHint.value}"} + +# 1) 创建 +job = requests.post(f"{base}/videos", headers=h, json={ + "model": "${sampleVideo.value}", + "prompt": "a paper boat sailing down a rainy street", + "seconds": "${sampleSeconds.value}", + "size": "1280x720", +}).json() +vid = job["id"] + +# 2) 轮询 +while True: + s = requests.get(f"{base}/videos/{vid}", headers=h).json() + if s["status"] in ("completed", "failed"): + break + time.sleep(5) + +# 3) 下载 +if s["status"] == "completed": + mp4 = requests.get(f"{base}/videos/{vid}/content", headers=h).content + open("out.mp4", "wb").write(mp4)`, + }, + { + title: '列出模型 · curl', + code: +`curl ${base.value}/v1/models \\ + -H "Authorization: Bearer ${keyHint.value}"`, + }, +]) + +// ---- copy + toast ---- +const toastMsg = ref('') +let t = null +function toast(m) { toastMsg.value = m; clearTimeout(t); t = setTimeout(() => (toastMsg.value = ''), 1800) } +async function copy(text) { + try { await navigator.clipboard.writeText(text); toast('已复制') } catch { toast('复制失败') } +} +</script> + +<template> + <div class="theme-text space-y-10"> + <header> + <div class="text-[10px] uppercase tracking-[0.3em] text-sky-300/70 font-medium">开发者</div> + <h1 class="mt-2 text-4xl md:text-5xl font-bold tracking-tight">接口文档</h1> + <p class="text-white/45 mt-2">完全兼容 OpenAI 接口规范 — 改个 <code class="text-white/70">base_url</code> 和 <code class="text-white/70">api_key</code> 即可直接调用。图像 / 视频 / 图生图全支持。</p> + </header> + + <!-- quickstart --> + <section class="grid md:grid-cols-2 gap-4"> + <div class="card p-6"> + <h2 class="text-sm font-semibold text-white/80">基础信息</h2> + <dl class="mt-4 space-y-3 text-sm"> + <div class="flex items-center justify-between gap-3"> + <dt class="text-white/45">Base URL</dt><dd class="font-mono text-white/90">{{ base }}/v1</dd> + </div> + <div class="flex items-center justify-between gap-3"> + <dt class="text-white/45">鉴权</dt><dd class="font-mono text-white/90">Authorization: Bearer <key></dd> + </div> + <div class="flex items-center justify-between gap-3"> + <dt class="text-white/45">你的 Key</dt><dd class="font-mono text-white/70">{{ keyHint }}</dd> + </div> + </dl> + <p class="text-[11px] text-white/40 mt-4">还没有 Key?去 <router-link to="/settings" class="text-violet-300 underline">设置 → API Key</router-link> 生成。</p> + </div> + + <div class="card p-6"> + <h2 class="text-sm font-semibold text-white/80">端点</h2> + <ul class="mt-4 space-y-2.5 text-sm font-mono"> + <li class="flex items-center gap-2"><span class="badge-get">GET</span><span class="text-white/80">/v1/models</span></li> + <li class="flex items-center gap-2"><span class="badge-post">POST</span><span class="text-white/80">/v1/images/generations</span><span class="text-white/35 font-sans text-xs">文生图</span></li> + <li class="flex items-center gap-2"><span class="badge-post">POST</span><span class="text-white/80">/v1/images/edits</span><span class="text-white/35 font-sans text-xs">图生图(multipart)</span></li> + <li class="flex items-center gap-2"><span class="badge-post">POST</span><span class="text-white/80">/v1/videos</span><span class="text-white/35 font-sans text-xs">建视频任务</span></li> + <li class="flex items-center gap-2"><span class="badge-get">GET</span><span class="text-white/80">/v1/videos/{id}</span><span class="text-white/35 font-sans text-xs">查状态</span></li> + <li class="flex items-center gap-2"><span class="badge-get">GET</span><span class="text-white/80">/v1/videos/{id}/content</span><span class="text-white/35 font-sans text-xs">下载 mp4</span></li> + </ul> + </div> + </section> + + <!-- models --> + <section> + <h2 class="text-lg font-semibold mb-3">可用模型</h2> + <div class="card overflow-hidden"> + <table class="w-full text-sm"> + <thead> + <tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]"> + <th class="px-4 py-3 font-medium">model</th> + <th class="px-4 py-3 font-medium">类型</th> + <th class="px-4 py-3 font-medium">分辨率 / 时长</th> + <th class="px-4 py-3 font-medium text-right">价格</th> + </tr> + </thead> + <tbody> + <tr v-for="m in models" :key="m.id" class="border-b border-white/[0.04] last:border-0"> + <td class="px-4 py-3 font-mono text-white/90">{{ m.id }}</td> + <td class="px-4 py-3 text-white/60">{{ m.type === 'video' ? '视频' : '图像' }}</td> + <td class="px-4 py-3 text-white/60">{{ (m.type === 'video' ? m.durations : m.resolutions || [])?.join(' · ') || '—' }}</td> + <td class="px-4 py-3 text-right tabular-nums text-white/80">{{ priceOf(m) }}</td> + </tr> + <tr v-if="!models.length"><td colspan="4" class="px-4 py-10 text-center text-white/35">暂无可用模型</td></tr> + </tbody> + </table> + </div> + </section> + + <!-- parameters --> + <section class="grid lg:grid-cols-2 gap-6"> + <div> + <h2 class="text-lg font-semibold mb-3">文生图参数 <span class="text-xs font-normal text-white/40">/v1/images/generations</span></h2> + <div class="card overflow-hidden"> + <table class="w-full text-sm"> + <thead><tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]"> + <th class="px-4 py-2.5 font-medium">参数</th><th class="px-4 py-2.5 font-medium">类型</th><th class="px-4 py-2.5 font-medium">必填</th><th class="px-4 py-2.5 font-medium">说明</th> + </tr></thead> + <tbody> + <tr v-for="p in imageParams" :key="p[0]" class="border-b border-white/[0.04] last:border-0"> + <td class="px-4 py-2.5 font-mono text-white/85">{{ p[0] }}</td> + <td class="px-4 py-2.5 text-white/50 font-mono text-xs">{{ p[1] }}</td> + <td class="px-4 py-2.5 text-white/55">{{ p[2] }}</td> + <td class="px-4 py-2.5 text-white/60 text-xs">{{ p[3] }}</td> + </tr> + </tbody> + </table> + </div> + </div> + + <div> + <h2 class="text-lg font-semibold mb-3">图生图参数 <span class="text-xs font-normal text-white/40">/v1/images/edits · multipart</span></h2> + <div class="card overflow-hidden"> + <table class="w-full text-sm"> + <thead><tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]"> + <th class="px-4 py-2.5 font-medium">参数</th><th class="px-4 py-2.5 font-medium">类型</th><th class="px-4 py-2.5 font-medium">必填</th><th class="px-4 py-2.5 font-medium">说明</th> + </tr></thead> + <tbody> + <tr v-for="p in editParams" :key="p[0]" class="border-b border-white/[0.04] last:border-0"> + <td class="px-4 py-2.5 font-mono text-white/85">{{ p[0] }}</td> + <td class="px-4 py-2.5 text-white/50 font-mono text-xs">{{ p[1] }}</td> + <td class="px-4 py-2.5 text-white/55">{{ p[2] }}</td> + <td class="px-4 py-2.5 text-white/60 text-xs">{{ p[3] }}</td> + </tr> + </tbody> + </table> + </div> + </div> + + <div class="lg:col-span-2"> + <h2 class="text-lg font-semibold mb-3">视频参数 <span class="text-xs font-normal text-white/40">/v1/videos · 异步</span></h2> + <div class="card overflow-hidden"> + <table class="w-full text-sm"> + <thead><tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]"> + <th class="px-4 py-2.5 font-medium">参数</th><th class="px-4 py-2.5 font-medium">类型</th><th class="px-4 py-2.5 font-medium">必填</th><th class="px-4 py-2.5 font-medium">说明</th> + </tr></thead> + <tbody> + <tr v-for="p in videoParams" :key="p[0]" class="border-b border-white/[0.04] last:border-0"> + <td class="px-4 py-2.5 font-mono text-white/85">{{ p[0] }}</td> + <td class="px-4 py-2.5 text-white/50 font-mono text-xs">{{ p[1] }}</td> + <td class="px-4 py-2.5 text-white/55">{{ p[2] }}</td> + <td class="px-4 py-2.5 text-white/60 text-xs">{{ p[3] }}</td> + </tr> + </tbody> + </table> + </div> + </div> + </section> + + <!-- examples --> + <section class="space-y-4"> + <h2 class="text-lg font-semibold">调用示例</h2> + <div v-for="ex in examples" :key="ex.title" class="card overflow-hidden"> + <div class="flex items-center justify-between px-4 py-2.5 border-b border-white/[0.06]"> + <span class="text-xs text-white/55">{{ ex.title }}</span> + <button @click="copy(ex.code)" class="text-xs text-white/50 hover:text-white inline-flex items-center gap-1.5 transition-colors"> + <Icon name="copy" class="w-3.5 h-3.5" /> 复制 + </button> + </div> + <pre class="p-4 text-[12px] leading-relaxed text-white/80 overflow-auto"><code>{{ ex.code }}</code></pre> + </div> + </section> + + <!-- responses --> + <section> + <h2 class="text-lg font-semibold mb-3">响应 & 计费</h2> + <div class="card p-6 space-y-3 text-sm text-white/70"> + <p><strong class="text-white/90">图像</strong>(generations / edits)返回 OpenAI 图片格式:<code class="text-white/85 font-mono">{{ '{ "created": ..., "data": [{ "b64_json": "..." }] }' }}</code> —— 产物以 <strong class="text-white/90">base64</strong> 直接放在 <code class="text-white/85 font-mono">data[0].b64_json</code>(原始 base64、无 <code class="text-white/70">data:</code> 前缀),自行解码保存为图片。<strong class="text-white/90">不返回 URL、服务端不留存</strong>。</p> + <p><strong class="text-white/90">视频</strong>(异步,Sora 风格三步):</p> + <ol class="list-decimal list-inside space-y-1 text-white/65 pl-1"> + <li><code class="text-white/85 font-mono">POST /v1/videos</code> 立即返回任务对象 <code class="text-white/85 font-mono">{{ '{ "id": "...", "object": "video", "status": "queued", ... }' }}</code></li> + <li>轮询 <code class="text-white/85 font-mono">GET /v1/videos/{id}</code>,<code class="text-white/70">status</code> 从 <code class="text-white/70">queued → in_progress → completed</code>(或 <code class="text-white/70">failed</code>)</li> + <li>完成后 <code class="text-white/85 font-mono">GET /v1/videos/{id}/content</code> 返回 <strong class="text-white/90">mp4 原始二进制</strong>(非 base64、非 URL)</li> + </ol> + <p><strong class="text-white/90">计费(预扣)</strong>:生成<strong class="text-white/90">前</strong>按上表价格从你的 Key 账号预扣积分;图像或视频上游失败会自动退回 —— 失败不扣费。</p> + <p><strong class="text-white/90">参数映射</strong>:<code class="text-white/70">size</code>→比例,<code class="text-white/70">quality</code>(low/medium/high)→画质档(1K/2K/4K,钳到模型支持档),<code class="text-white/70">seconds</code>→视频时长。参数须落在该模型定价表内,否则 400;余额不足 402。</p> + <div class="pt-2 grid sm:grid-cols-2 gap-2 text-xs"> + <div class="flex items-center gap-2"><span class="badge-err">401</span> Key 无效 / 上游需重新授权</div> + <div class="flex items-center gap-2"><span class="badge-err">404</span> 未知 model / 视频任务不存在</div> + <div class="flex items-center gap-2"><span class="badge-err">400</span> 参数缺失 / 不支持或未定价</div> + <div class="flex items-center gap-2"><span class="badge-err">402</span> 积分不足</div> + <div class="flex items-center gap-2"><span class="badge-err">409</span> 视频尚未完成(content 未就绪)</div> + <div class="flex items-center gap-2"><span class="badge-err">429</span> 账号并发已满,请重试</div> + <div class="flex items-center gap-2"><span class="badge-err">503</span> 上游繁忙,请重试</div> + </div> + </div> + </section> + + <transition name="fade"> + <div v-if="toastMsg" class="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 bg-white text-black text-sm font-medium px-5 py-2.5 rounded-full shadow-2xl">{{ toastMsg }}</div> + </transition> + </div> +</template> + +<style scoped> +.badge-get, .badge-post, .badge-err { + border-radius: 4px; padding: 2px 6px; font-size: 10px; line-height: 1; +} +.badge-get { background: rgb(16 185 129 / 0.14); color: rgb(4 120 87); box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.35); } +.badge-post { background: rgb(14 165 233 / 0.14); color: rgb(3 105 161); box-shadow: inset 0 0 0 1px rgb(14 165 233 / 0.35); } +.badge-err { background: rgb(244 63 94 / 0.12); color: rgb(190 18 60); box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3); font-family: ui-monospace, monospace; } +html.dark .badge-get { background: rgb(16 185 129 / 0.15); color: rgb(110 231 183); box-shadow: inset 0 0 0 1px rgb(52 211 153 / 0.3); } +html.dark .badge-post { background: rgb(14 165 233 / 0.15); color: rgb(125 211 252); box-shadow: inset 0 0 0 1px rgb(56 189 248 / 0.3); } +html.dark .badge-err { background: rgb(244 63 94 / 0.15); color: rgb(253 164 175); box-shadow: inset 0 0 0 1px rgb(251 113 133 / 0.3); } +</style> diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue new file mode 100644 index 0000000..140186d --- /dev/null +++ b/frontend/src/views/HomeView.vue @@ -0,0 +1,402 @@ +<script setup> +import { ref, computed, onMounted, onUnmounted } from 'vue' +import { useRouter } from 'vue-router' +import { api, generatedUrl } from '../api' +import { site } from '../site' +import { isAuthed, openLogin } from '../auth' +import Icon from '../components/Icon.vue' + +const router = useRouter() + +// Navigate to a signed-in page, or pop the login modal (remembering the +// destination) when the visitor isn't logged in yet. +function go(path, query) { + const target = query ? { path, query } : { path } + if (isAuthed()) { router.push(target); return } + openLogin(typeof target === 'string' ? target : router.resolve(target).fullPath) +} + +const stats = ref({ generated_count: 0, recent: [] }) +const managed = ref([]) // managed model records (provider, type, ...) +const showcase = ref({ hero: [], bento: [] }) +// heroDeck holds the top-3 hero cards in a RANDOMIZED order, so a different card +// fronts the deck on each page load. It's reshuffled only when the hero set first +// loads or its members change — not on every 30s poll, so the deck stays put. +const heroDeck = ref([]) +function shuffleArr(arr) { + const a = [...arr] + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[a[i], a[j]] = [a[j], a[i]] + } + return a +} +let timer = null + +async function refresh() { + try { + // No /logs here — that endpoint now requires a login and only returns the + // caller's own entries. The avg-latency KPI comes from /stats (aggregate, + // prompt-free) so the public home page exposes nothing per-user. + const [s, m, sc] = await Promise.all([ + api('/stats'), + api('/managed-models'), + api('/showcase'), + ]) + stats.value = s.data || { generated_count: 0, recent: [] } + managed.value = (m.data?.data || []).filter((x) => x.enabled !== false) + showcase.value = sc.data?.data || { hero: [], bento: [] } + // Randomize which hero card fronts the deck: shuffle the top-3 once when the + // set first loads / changes; keep the order stable across refreshes so the + // deck doesn't reshuffle every 30s while the visitor is looking at it. + const top3 = (showcase.value.hero || []).slice(0, 3) + const sameSet = heroDeck.value.length === top3.length + && top3.every((c) => heroDeck.value.some((d) => d.id === c.id)) + if (!sameSet) heroDeck.value = shuffleArr(top3) + } catch {} +} +onMounted(() => { refresh(); timer = setInterval(refresh, 30000) }) +onUnmounted(() => clearInterval(timer)) + +// ---- KPI strip — three signals derived from real data ---- +const modelCount = computed(() => managed.value.length) + +// Show the 24h average (matches the admin overview); fall back to the all-time +// average on a quiet day so the KPI isn't blank. +const avgElapsed = computed(() => stats.value?.avg_elapsed_ms_24h ?? stats.value?.avg_elapsed_ms ?? null) +const avgLabel = computed(() => { + if (avgElapsed.value == null) return '—' + if (avgElapsed.value < 1000) return avgElapsed.value + 'ms' + return (avgElapsed.value / 1000).toFixed(1) + 's' +}) + +// ---- “已接入” provider strip, derived from managed models ---- +const PROVIDER_TINT = { + adobe: 'from-rose-400 to-orange-400', + chatgpt: 'from-violet-500 to-fuchsia-500', + google: 'from-sky-400 to-indigo-500', + default: 'from-emerald-400 to-teal-500', +} +const providerGroups = computed(() => { + const map = new Map() + for (const m of managed.value) { + const key = (m.provider || 'unknown').toLowerCase() + const g = map.get(key) || { name: m.provider || 'unknown', image: 0, video: 0 } + if (m.type === 'video') g.video++ + else g.image++ + map.set(key, g) + } + return [...map.entries()].map(([key, g]) => ({ + ...g, + grad: PROVIDER_TINT[key] || PROVIDER_TINT.default, + })).sort((a, b) => (b.image + b.video) - (a.image + a.video)) +}) + +// Admin-curated "我们的作品" entries from /admin/api/showcase (kind=work), +// already sorted by weight on the server. +const works = computed(() => showcase.value.work || []) + +// Hero deck = a random ordering of the top-3 hero entries (see refresh). +const bento = computed(() => showcase.value.bento || []) + +// Resolve an image reference: external URLs pass through, relative paths +// (like "user/abc.png") are served from /generated by the backend. +function imgSrc(image) { + if (!image) return '' + return /^https?:\/\//i.test(image) ? image : generatedUrl(image) +} +// Background style for a showcase card. Prefers a real image (the new shape); +// falls back to the legacy CSS gradient so seed entries still render. +function cardBg(card) { + if (card.image) { + return { + backgroundImage: `url("${imgSrc(card.image)}")`, + backgroundSize: 'cover', + backgroundPosition: 'center', + } + } + return { background: card.gradient } +} + +function useExample(ex) { + go('/user', { prompt: ex.prompt }) +} +</script> + +<template> + <div class="space-y-28"> + <!-- ============ HERO ============ --> + <section class="relative pt-8 md:pt-16 grid lg:grid-cols-[1.15fr_1fr] gap-10 lg:gap-16 items-center min-h-[640px]"> + <!-- LEFT: copy --> + <div> + <h1 class="font-bold tracking-tight leading-[0.9] text-[clamp(2.5rem,6.5vw,6.5rem)] text-[color:var(--fg)]"> + <span class="block text-[color:var(--fg-3)] font-light italic">Imagine</span> + <span class="block">it, + <span class="bg-gradient-to-r from-fuchsia-300 via-violet-300 to-sky-300 bg-clip-text text-transparent italic">type</span> + it, + </span> + <span class="block">own it.</span> + </h1> + + <p class="mt-8 text-base md:text-lg text-[color:var(--fg-2)] max-w-md leading-relaxed"> + 把脑海里的画面写成一句话,GPT、Gemini、Firefly、Flux 等顶级模型替你变成图像与视频。 + </p> + + <div class="mt-10 flex items-center gap-4"> + <button @click="go('/user')" + class="group inline-flex items-center gap-3 rounded-full bg-[var(--btn-solid-bg)] text-[color:var(--btn-solid-fg)] hover:bg-[var(--btn-solid-bg-h)] pl-6 pr-3 py-3 text-sm font-semibold transition-all"> + 开始画图 + <span class="w-8 h-8 rounded-full bg-[var(--btn-solid-fg)] text-[color:var(--btn-solid-bg)] grid place-items-center group-hover:translate-x-1 transition-transform"> + → + </span> + </button> + <a href="#bento" class="text-sm text-[color:var(--fg-3)] hover:text-[color:var(--fg)] transition-colors">浏览灵感 ↓</a> + </div> + + <!-- counter strip — three real signals: models we support, total + outputs ever generated, average wall-clock to produce one. --> + <div class="mt-14 grid grid-cols-3 gap-px rounded-2xl overflow-hidden ring-1 ring-[color:var(--hairline)] max-w-xl" style="background: var(--hairline)"> + <div class="bg-[var(--surface)] px-5 py-4"> + <div class="text-2xl md:text-3xl font-bold tabular-nums text-[color:var(--fg)]">{{ modelCount }}</div> + <div class="text-[10px] text-[color:var(--fg-3)] mt-1 uppercase tracking-[0.2em]">已接入模型</div> + </div> + <div class="bg-[var(--surface)] px-5 py-4"> + <div class="text-2xl md:text-3xl font-bold tabular-nums text-[color:var(--fg)]">{{ stats.generated_count || 0 }}</div> + <div class="text-[10px] text-[color:var(--fg-3)] mt-1 uppercase tracking-[0.2em]">已生成作品</div> + </div> + <div class="bg-[var(--surface)] px-5 py-4"> + <div class="text-2xl md:text-3xl font-bold tabular-nums text-[color:var(--fg)]">{{ avgLabel }}</div> + <div class="text-[10px] text-[color:var(--fg-3)] mt-1 uppercase tracking-[0.2em]">平均出片</div> + </div> + </div> + </div> + + <!-- RIGHT: stacked card deck — driven by /admin/api/showcase (kind=hero), + top 3 by weight. Position classes are picked by index so existing CSS + transforms in <style> still apply. --> + <div class="relative h-[480px] lg:h-[560px] hero-deck"> + <template v-for="(card, i) in heroDeck" :key="card.id"> + <!-- back --> + <div v-if="i === 2" + class="deck-card deck-card-3 absolute inset-y-8 right-12 lg:right-20 w-[68%] rounded-3xl overflow-hidden ring-1 ring-white/10 shadow-2xl" + :style="cardBg(card)"> + <div class="absolute inset-0 mix-blend-overlay opacity-25" + style="background-image:url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22120%22 height=%22120%22><filter id=%22n%22><feTurbulence baseFrequency=%220.85%22 numOctaves=%222%22 seed=%222%22/></filter><rect width=%22100%25%22 height=%22100%25%22 filter=%22url(%23n)%22 opacity=%220.5%22/></svg>')"></div> + <div class="absolute inset-x-0 bottom-0 p-5 bg-gradient-to-t from-black/85 via-black/30 to-transparent"> + <div class="text-[10px] uppercase tracking-[0.3em] text-white/55">{{ card.subtitle }}</div> + <div class="text-lg font-semibold text-white mt-1">{{ card.title }}</div> + </div> + </div> + + <!-- middle --> + <div v-if="i === 1" + class="deck-card deck-card-2 absolute inset-y-4 right-4 lg:right-8 w-[72%] rounded-3xl overflow-hidden ring-1 ring-white/10 shadow-2xl" + :style="cardBg(card)"> + <div class="absolute inset-0 mix-blend-overlay opacity-25" + style="background-image:url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22120%22 height=%22120%22><filter id=%22n%22><feTurbulence baseFrequency=%220.85%22 numOctaves=%222%22 seed=%223%22/></filter><rect width=%22100%25%22 height=%22100%25%22 filter=%22url(%23n)%22 opacity=%220.5%22/></svg>')"></div> + <div class="absolute inset-x-0 bottom-0 p-5 bg-gradient-to-t from-black/85 via-black/30 to-transparent"> + <div class="text-[10px] uppercase tracking-[0.3em] text-white/55">{{ card.subtitle }}</div> + <div class="text-lg font-semibold text-white mt-1">{{ card.title }}</div> + </div> + </div> + + <!-- front --> + <div v-if="i === 0" + class="deck-card deck-card-1 absolute inset-y-0 right-0 w-[78%] rounded-3xl overflow-hidden ring-1 ring-white/15 shadow-[0_30px_80px_-20px_rgba(168,85,247,0.45)]" + :style="cardBg(card)"> + <div class="absolute inset-0 mix-blend-overlay opacity-30" + style="background-image:url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22120%22 height=%22120%22><filter id=%22n%22><feTurbulence baseFrequency=%220.85%22 numOctaves=%222%22 seed=%221%22/></filter><rect width=%22100%25%22 height=%22100%25%22 filter=%22url(%23n)%22 opacity=%220.5%22/></svg>')"></div> + <div class="absolute top-0 inset-x-0 flex items-center justify-between px-5 py-4"> + <div class="flex items-center gap-1.5"> + <span class="w-2.5 h-2.5 rounded-full bg-white/20"></span> + <span class="w-2.5 h-2.5 rounded-full bg-white/20"></span> + <span class="w-2.5 h-2.5 rounded-full bg-white/20"></span> + </div> + <div class="text-[10px] uppercase tracking-[0.25em] text-white/60 font-mono">live</div> + </div> + <div class="absolute inset-x-0 bottom-0 p-6 bg-gradient-to-t from-black/90 via-black/40 to-transparent"> + <div class="text-[10px] uppercase tracking-[0.3em] text-white/65">{{ card.subtitle }}</div> + <div class="text-2xl font-bold text-white mt-2">{{ card.title }}</div> + <p class="text-xs text-white/70 mt-2 line-clamp-2 leading-relaxed">{{ card.prompt }}</p> + </div> + </div> + </template> + </div> + </section> + + <!-- ============ WORKS STRIP ============ --> + <section v-if="works.length" class="-mx-8 md:-mx-14"> + <div class="px-8 md:px-14 mb-6"> + <h2 class="text-3xl md:text-4xl font-bold tracking-tight text-[color:var(--fg)]">我们的作品</h2> + </div> + <div class="marquee-wrap"> + <div class="marquee-track"> + <div v-for="(w, i) in [...works, ...works]" :key="w.id + '-' + i" + class="shrink-0 w-56 h-56 rounded-2xl overflow-hidden ring-1 ring-white/[0.08] hover:ring-white/30 hover:scale-[1.02] transition-all cursor-pointer relative" + @click="go('/logs')"> + <img :src="imgSrc(w.image)" loading="lazy" + class="w-full h-full object-cover" /> + <div v-if="w.title" class="absolute inset-x-0 bottom-0 p-3 bg-gradient-to-t from-black/85 via-black/30 to-transparent"> + <div class="text-xs font-medium text-white line-clamp-1">{{ w.title }}</div> + </div> + </div> + </div> + </div> + </section> + + <!-- ============ BENTO EXAMPLES ============ --> + <section id="bento"> + <div class="flex items-end justify-between flex-wrap gap-3 mb-8"> + <div> + <div class="text-[10px] uppercase tracking-[0.3em] text-violet-300/70 font-medium">灵感</div> + <h2 class="mt-2 text-3xl md:text-4xl font-bold tracking-tight text-[color:var(--fg)]">从一个起点开始</h2> + <p class="text-[color:var(--fg-3)] mt-2 max-w-md">点任意一张,自动进入画图工作台并预填提示词。</p> + </div> + </div> + + <div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 grid-flow-row-dense gap-4 auto-rows-[200px]"> + <button v-for="ex in bento" :key="ex.id" @click="useExample(ex)" + class="group relative text-left overflow-hidden rounded-3xl ring-1 ring-white/[0.06] hover:ring-white/20 transition-all" + :class="ex.span" + :style="cardBg(ex)"> + <!-- grain --> + <div class="absolute inset-0 mix-blend-overlay opacity-30" + style="background-image: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22120%22 height=%22120%22><filter id=%22n%22><feTurbulence baseFrequency=%220.85%22 numOctaves=%222%22 seed=%221%22/></filter><rect width=%22100%25%22 height=%22100%25%22 filter=%22url(%23n)%22 opacity=%220.5%22/></svg>')"></div> + <!-- veil --> + <div class="absolute inset-0 bg-gradient-to-t from-black/85 via-black/30 to-transparent"></div> + + <div class="absolute top-5 right-5 w-9 h-9 rounded-xl bg-white/15 backdrop-blur ring-1 ring-white/20 grid place-items-center opacity-0 group-hover:opacity-100 group-hover:scale-100 scale-95 transition-all"> + <Icon name="open" class="w-3.5 h-3.5" /> + </div> + + <div class="absolute inset-x-0 bottom-0 p-5 md:p-6"> + <div class="text-[10px] uppercase tracking-[0.3em] text-white/55">{{ ex.subtitle }}</div> + <div class="text-xl md:text-2xl font-bold mt-2 text-white">{{ ex.title }}</div> + <div class="text-xs text-white/65 mt-2 line-clamp-2 leading-relaxed">{{ ex.prompt }}</div> + </div> + </button> + </div> + </section> + + <!-- ============ MODELS GRID ============ --> + <section> + <div class="flex items-end justify-between flex-wrap gap-3 mb-8"> + <div> + <div class="text-[10px] uppercase tracking-[0.3em] text-sky-300/70 font-medium">模型</div> + <h2 class="mt-2 text-3xl md:text-4xl font-bold tracking-tight text-[color:var(--fg)]">已接入</h2> + <p class="text-[color:var(--fg-3)] mt-2 max-w-md">由管理员在后台注册;以下为已对接的上游 family。</p> + </div> + </div> + + <div class="grid sm:grid-cols-2 md:grid-cols-3 gap-3"> + <div v-for="p in providerGroups" :key="p.name" + class="group relative overflow-hidden rounded-2xl bg-[var(--surface)] ring-1 ring-[color:var(--hairline)] p-5 hover:ring-[color:var(--fg-faint)] transition-all"> + <div class="absolute -right-8 -top-8 w-32 h-32 rounded-full bg-gradient-to-br opacity-30 blur-xl group-hover:opacity-50 transition-opacity" + :class="p.grad"></div> + <div class="relative flex items-center justify-between"> + <div> + <div class="text-base font-semibold capitalize text-[color:var(--fg)]">{{ p.name }}</div> + <div class="text-[10px] uppercase tracking-[0.25em] text-[color:var(--fg-3)] mt-1 flex gap-2"> + <span v-if="p.image">{{ p.image }} 图像</span> + <span v-if="p.video">{{ p.video }} 视频</span> + </div> + </div> + <span class="w-2.5 h-2.5 rounded-full bg-gradient-to-br" :class="p.grad"></span> + </div> + </div> + <div v-if="!providerGroups.length" class="text-sm text-[color:var(--fg-3)] col-span-full text-center py-10"> + 管理员尚未接入任何模型 + </div> + </div> + </section> + + <!-- ============ FOOTER CTA ============ --> + <section class="cta-band relative overflow-hidden rounded-[2rem] ring-1 ring-[color:var(--hairline)] p-12 md:p-20"> + <div class="relative max-w-2xl"> + <h3 class="text-4xl md:text-6xl font-bold tracking-tight leading-[1.05] text-[color:var(--fg)]"> + 停下来思考,<br /> + <span class="italic font-light text-[color:var(--fg-3)]">不如</span> 直接开始。 + </h3> + <p class="text-base md:text-lg text-[color:var(--fg-2)] mt-6 max-w-lg leading-relaxed"> + 登录即可开始。打开画图工作台,写一句话,看 AI 替你完成。 + </p> + <button @click="go('/user')" + class="mt-10 group inline-flex items-center gap-3 rounded-full bg-[var(--btn-solid-bg)] text-[color:var(--btn-solid-fg)] hover:bg-[var(--btn-solid-bg-h)] pl-6 pr-3 py-3 text-sm font-semibold transition-all"> + 开始画图 + <span class="w-8 h-8 rounded-full bg-[var(--btn-solid-fg)] text-[color:var(--btn-solid-bg)] grid place-items-center group-hover:translate-x-1 transition-transform"> + → + </span> + </button> + </div> + </section> + + <!-- thin footer line --> + <footer class="pt-12 border-t border-[color:var(--hairline)] flex flex-wrap items-center justify-between gap-4 text-xs text-[color:var(--fg-3)]"> + <div class="flex items-center gap-3"> + <span class="font-mono">{{ site.title }}</span> + <span>·</span> + <span>AI 生图与生视频平台</span> + </div> + </footer> + </div> +</template> + +<style scoped> +.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } + +/* ----- Hero stacked card deck ----- */ +.hero-deck { perspective: 1200px; } +.deck-card { transform-origin: center bottom; will-change: transform, opacity; } +/* staggered fade-in-up on load, then a livelier continuous float with a gentle + rotation wobble. The :hover rules below still take over to fan the deck out. */ +.deck-card-1 { animation: deckIn1 0.8s cubic-bezier(0.2,0.7,0.2,1) backwards, deckFloat1 7s ease-in-out 0.8s infinite; } +.deck-card-2 { animation: deckIn2 0.8s cubic-bezier(0.2,0.7,0.2,1) 0.12s backwards, deckFloat2 9s ease-in-out 0.92s infinite; } +.deck-card-3 { animation: deckIn3 0.8s cubic-bezier(0.2,0.7,0.2,1) 0.24s backwards, deckFloat3 11s ease-in-out 1.04s infinite; opacity: 0.85; } +@keyframes deckFloat1 { + 0%,100% { transform: rotate(2deg) translateY(0); } + 35% { transform: rotate(0.5deg) translateY(-16px); } + 70% { transform: rotate(3deg) translateY(-7px); } +} +@keyframes deckFloat2 { + 0%,100% { transform: rotate(-3deg) translateY(0); } + 50% { transform: rotate(-1.5deg) translateY(-13px); } +} +@keyframes deckFloat3 { + 0%,100% { transform: rotate(5deg) translateY(0); } + 50% { transform: rotate(3.5deg) translateY(-10px); } +} +@keyframes deckIn1 { + from { opacity: 0; transform: rotate(2deg) translateY(48px) scale(0.92); } + to { opacity: 1; transform: rotate(2deg) translateY(0) scale(1); } +} +@keyframes deckIn2 { + from { opacity: 0; transform: rotate(-3deg) translateY(48px) scale(0.92); } + to { opacity: 1; transform: rotate(-3deg) translateY(0) scale(1); } +} +@keyframes deckIn3 { + from { opacity: 0; transform: rotate(5deg) translateY(48px) scale(0.92); } + to { opacity: 0.85; transform: rotate(5deg) translateY(0) scale(1); } +} +.hero-deck:hover .deck-card-1 { transform: rotate(0deg) translateY(-12px); transition: transform 0.4s ease; animation: none; } +.hero-deck:hover .deck-card-2 { transform: rotate(-6deg) translate(-30px, 10px); transition: transform 0.4s ease; animation: none; } +.hero-deck:hover .deck-card-3 { transform: rotate(8deg) translate(-50px, 20px); transition: transform 0.4s ease; animation: none; opacity: 0.7; } + +.marquee-wrap { + position: relative; + overflow: hidden; + mask-image: linear-gradient(to right, transparent 0%, #000 6%, #000 94%, transparent 100%); + -webkit-mask-image: linear-gradient(to right, transparent 0%, #000 6%, #000 94%, transparent 100%); +} +.marquee-track { + display: flex; + gap: 1rem; + width: max-content; + padding: 0 2rem; + animation: marquee 55s linear infinite; +} +.marquee-wrap:hover .marquee-track { animation-play-state: paused; } +@keyframes marquee { + 0% { transform: translateX(0); } + 100% { transform: translateX(-50%); } +} +</style> diff --git a/frontend/src/views/ImagesView.vue b/frontend/src/views/ImagesView.vue new file mode 100644 index 0000000..dd3f5f9 --- /dev/null +++ b/frontend/src/views/ImagesView.vue @@ -0,0 +1,272 @@ +<script setup> +import { ref, computed, onMounted, onUnmounted } from 'vue' +import { api, generatedUrl } from '../api' +import { fmtTs, fmtSize } from '../utils/format' +import Icon from '../components/Icon.vue' +import MediaLightbox from '../components/MediaLightbox.vue' + +const items = ref([]) +const total = ref(0) +const stats = ref({ total: 0, image: 0, video: 0, size_bytes: 0 }) +const loading = ref(false) +const kind = ref('') // '' | 'image' | 'video' +const selected = ref(null) +const toast = ref('') + +const page = ref(1) +// 20 per page so a 4-col (lg) or 5-col (xl) grid lays out as clean rows of +// 5×4 or 4×5 instead of a half-empty trailing row. +const pageSize = ref(20) + + +async function load() { + loading.value = true + const qs = new URLSearchParams({ + limit: String(pageSize.value), + offset: String((page.value - 1) * pageSize.value), + }) + if (kind.value) qs.set('kind', kind.value) + const r = await api('/images?' + qs.toString()) + items.value = r.data?.data || [] + total.value = Number(r.data?.total ?? items.value.length) + // Stats arrive with the same payload so the KPI strip stays cheap. + stats.value = r.data?.stats || { total: 0, image: 0, video: 0, size_bytes: 0 } + loading.value = false +} + +function absUrl(name) { + const u = generatedUrl(name) + return u.startsWith('http') ? u : location.origin + u +} + +async function copyLink(name) { + try { + await navigator.clipboard.writeText(absUrl(name)) + flash('链接已复制') + } catch { + flash('复制失败') + } +} + +let toastTimer = null +function flash(msg) { + toast.value = msg + clearTimeout(toastTimer) + toastTimer = setTimeout(() => (toast.value = ''), 1800) +} + +function setKind(v) { kind.value = v; page.value = 1; load() } + +const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value))) +function goPage(n) { + const target = Math.max(1, Math.min(totalPages.value, n)) + if (target === page.value) return + page.value = target + load() +} + +const pageNumbers = computed(() => { + const n = totalPages.value + const cur = page.value + if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1) + const want = new Set([1, n, cur - 1, cur, cur + 1]) + if (cur <= 3) { want.add(2); want.add(3); want.add(4) } + if (cur >= n - 2) { want.add(n - 1); want.add(n - 2); want.add(n - 3) } + const list = [...want].filter((x) => x >= 1 && x <= n).sort((a, b) => a - b) + const out = [] + for (let i = 0; i < list.length; i++) { + if (i > 0 && list[i] - list[i - 1] > 1) out.push(null) + out.push(list[i]) + } + return out +}) + +function onKey(e) { + if (e.key === 'Escape') selected.value = null +} +onMounted(() => { load(); window.addEventListener('keydown', onKey) }) +onUnmounted(() => window.removeEventListener('keydown', onKey)) +</script> + +<template> + <section class="space-y-4"> + <!-- KPI strip — same shape as the LogsView so the admin shell stays + consistent. /images returns all four numbers in one payload. --> + <div class="grid grid-cols-2 md:grid-cols-4 gap-3"> + <div class="card p-4"> + <div class="text-[11px] uppercase tracking-wider text-white/45">总计</div> + <div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.total }}</div> + </div> + <div class="card p-4"> + <div class="text-[11px] uppercase tracking-wider text-indigo-300/80">图像</div> + <div class="text-2xl font-semibold mt-1 tabular-nums text-indigo-300">{{ stats.image }}</div> + </div> + <div class="card p-4"> + <div class="text-[11px] uppercase tracking-wider text-fuchsia-300/80">视频</div> + <div class="text-2xl font-semibold mt-1 tabular-nums text-fuchsia-300">{{ stats.video }}</div> + </div> + <div class="card p-4"> + <div class="text-[11px] uppercase tracking-wider text-amber-300/80">存储</div> + <div class="text-2xl font-semibold mt-1 tabular-nums text-amber-300">{{ fmtSize(stats.size_bytes) }}</div> + </div> + </div> + + <!-- toolbar --> + <div class="card p-3 flex items-center justify-between gap-3 flex-wrap"> + <div class="flex items-center gap-1"> + <button @click="setKind('')" class="fp" :class="kind === '' && 'fp-on'">全部</button> + <button @click="setKind('image')" class="fp" :class="kind === 'image' && 'fp-on'">图像</button> + <button @click="setKind('video')" class="fp" :class="kind === 'video' && 'fp-on'">视频</button> + </div> + <button @click="load" class="btn-soft"> + <Icon name="refresh" class="w-3.5 h-3.5" /> 刷新 + </button> + </div> + + <!-- grid --> + <div v-if="loading && !items.length" class="text-center text-sm text-white/40 py-20">加载中…</div> + <div v-else-if="!items.length" class="card flex flex-col items-center gap-3 text-white/40 py-20"> + <span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center"> + <Icon name="files" class="w-6 h-6" /> + </span> + <span class="text-sm">还没有生成过任何图片</span> + </div> + + <div v-else class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3"> + <div v-for="f in items" :key="f.name" + class="media-card group relative rounded-xl overflow-hidden ring-1 ring-white/[0.06] bg-white/[0.03] aspect-[4/5] cursor-zoom-in" + @click="selected = f"> + <!-- media --> + <template v-if="f.kind === 'video'"> + <video :src="generatedUrl(f.name)" muted loop preload="metadata" + class="absolute inset-0 w-full h-full object-cover" + @mouseenter="$event.target.play && $event.target.play()" + @mouseleave="$event.target.pause && $event.target.pause()" /> + </template> + <img v-else :src="generatedUrl(f.name)" loading="lazy" + class="absolute inset-0 w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" /> + + <!-- gradient veil (always visible so the prompt overlay reads) --> + <div class="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/85 via-black/40 to-transparent pointer-events-none"></div> + + <!-- kind chip --> + <span class="absolute top-3 left-3 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ring-1" + :class="f.kind === 'video' ? 'bg-fuchsia-500/20 text-fuchsia-200 ring-fuchsia-400/30' : 'bg-indigo-500/20 text-indigo-200 ring-indigo-400/30'"> + {{ f.kind === 'video' ? '视频' : '图像' }} + </span> + + <!-- quick actions, hover-revealed; same style as 首页内容 --> + <div class="absolute top-3 right-3 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> + <a :href="generatedUrl(f.name)" target="_blank" @click.stop title="新标签打开" + class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-black/70 text-white grid place-items-center"> + <Icon name="open" class="w-3.5 h-3.5" /> + </a> + <button @click.stop="copyLink(f.name)" title="复制链接" + class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-black/70 text-white grid place-items-center"> + <Icon name="copy" class="w-3.5 h-3.5" /> + </button> + <a :href="generatedUrl(f.name)" :download="f.name.split('/').pop()" @click.stop title="下载" + class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-black/70 text-white grid place-items-center"> + <Icon name="download" class="w-3.5 h-3.5" /> + </a> + </div> + + <!-- caption: prompt (truncated 2 lines) + meta line --> + <div class="absolute inset-x-0 bottom-0 p-3 pointer-events-none"> + <div class="text-[12px] leading-tight text-white font-medium line-clamp-2 mb-1" + :title="f.prompt || f.name"> + {{ f.prompt || f.name.split('/').pop() }} + </div> + <div class="text-[10px] text-white/55 flex items-center justify-between gap-2 tabular-nums"> + <span class="truncate" :title="f.model || ''">{{ f.model || '—' }}</span> + <span class="shrink-0 flex items-center gap-1"> + <span v-if="f.resolution" class="text-emerald-300/90">{{ f.resolution }}</span> + <span v-if="f.ratio" class="text-white/40">{{ f.ratio }}</span> + <span v-if="f.kind === 'video' && f.duration" class="text-fuchsia-300/80">{{ f.duration }}</span> + </span> + </div> + <div class="text-[10px] text-white/35 mt-0.5 tabular-nums">{{ fmtSize(f.size) }} · {{ fmtTs(f.mtime) }}</div> + </div> + </div> + </div> + + <!-- pagination — hidden when everything fits on one page --> + <div v-if="!loading && totalPages > 1" class="card !p-3 flex items-center justify-between gap-3"> + <div class="text-xs text-white/55 tabular-nums px-2"> + <span class="text-white/85">{{ (page - 1) * pageSize + 1 }}–{{ Math.min(total, page * pageSize) }}</span> + / {{ total }} 张 + </div> + <div class="flex items-center gap-1"> + <template v-for="(n, i) in pageNumbers" :key="i"> + <span v-if="n === null" class="px-1 text-white/35">…</span> + <button v-else @click="goPage(n)" class="pg" :class="page === n && 'pg-on'">{{ n }}</button> + </template> + </div> + </div> + + <!-- Lightbox (shared component) --> + <MediaLightbox + v-if="selected" + :src="generatedUrl(selected.name)" + :kind="selected.kind" + :prompt="selected.prompt" + :meta="[selected.model, selected.name].filter(Boolean).join(' · ')" + :meta-sub="[selected.resolution, selected.ratio, (selected.kind === 'video' ? selected.duration : ''), fmtSize(selected.size), fmtTs(selected.mtime)].filter(Boolean).join(' · ')" + :download-name="selected.name.split('/').pop()" + @close="selected = null" /> + + <!-- toast --> + <transition name="fade"> + <div v-if="toast" + class="fixed bottom-6 left-1/2 -translate-x-1/2 z-[60] bg-slate-900 text-white text-xs px-4 py-2 rounded-lg shadow-lg"> + {{ toast }} + </div> + </transition> + </section> +</template> + +<style scoped> +.fade-enter-active, .fade-leave-active { transition: opacity 0.18s ease; } +.fade-enter-from, .fade-leave-to { opacity: 0; } + +.line-clamp-2 { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* filter pill — same shape as LogsView so the admin shell stays consistent */ +.fp { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.35rem 0.7rem; + font-size: 0.72rem; + border-radius: 0.55rem; + color: var(--fg-3); + background: var(--surface-2); + box-shadow: inset 0 0 0 1px var(--hairline); + transition: background 0.15s, color 0.15s; +} +.fp:hover { background: var(--hover); color: var(--fg); } +.fp-on { background: rgb(15 23 42); color: white; box-shadow: none; } +html.dark .fp-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); } + +/* numbered pagination buttons */ +.pg { + min-width: 1.75rem; + padding: 0.3rem 0.55rem; + font-size: 0.72rem; + font-weight: 500; + text-align: center; + border-radius: 0.45rem; + color: var(--fg-2); + background: var(--surface-2); + box-shadow: inset 0 0 0 1px var(--hairline); + transition: background 0.15s, color 0.15s; +} +.pg:hover:not(.pg-on) { background: var(--hover); color: var(--fg); } +.pg-on { background: rgb(15 23 42); color: white; box-shadow: none; } +html.dark .pg-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); } +</style> diff --git a/frontend/src/views/InviteView.vue b/frontend/src/views/InviteView.vue new file mode 100644 index 0000000..b7fb748 --- /dev/null +++ b/frontend/src/views/InviteView.vue @@ -0,0 +1,229 @@ +<script setup> +// 邀请好友 — its own page (moved out of 设置). Invite data is real, from the +// logged-in account (auth.user). The inviter earns INVITE_REWARD 积分 once each +// invited friend completes their FIRST generation (生图). +import { ref, computed, onMounted } from 'vue' +import { auth, refreshMe } from '../auth' +import { api } from '../api' +import { fmtIso } from '../utils/format' +import Icon from '../components/Icon.vue' + +// Reward per completed invite — comes from the backend (credits.invite_reward), +// falling back to 3 until the response lands. +const INVITE_REWARD = ref(3) + +const records = ref([]) +const loading = ref(false) +const page = ref(1) +const pageSize = 10 +async function loadRecords() { + loading.value = true + const r = await api('/auth/invites') + loading.value = false + if (r.ok) { + records.value = r.data?.data || [] + if (r.data?.reward != null) INVITE_REWARD.value = Number(r.data.reward) + if ((page.value - 1) * pageSize >= records.value.length) page.value = 1 + } +} + +// Client-side numbered pagination — matches the admin 日志/图片管理 .pg strip. +const totalPages = computed(() => Math.max(1, Math.ceil(records.value.length / pageSize))) +const paged = computed(() => records.value.slice((page.value - 1) * pageSize, page.value * pageSize)) +function goPage(n) { page.value = Math.max(1, Math.min(totalPages.value, n)) } +const pageNumbers = computed(() => { + const n = totalPages.value + const cur = page.value + if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1) + const want = new Set([1, n, cur - 1, cur, cur + 1]) + if (cur <= 3) { want.add(2); want.add(3); want.add(4) } + if (cur >= n - 2) { want.add(n - 1); want.add(n - 2); want.add(n - 3) } + const list = [...want].filter((x) => x >= 1 && x <= n).sort((a, b) => a - b) + const out = [] + for (let i = 0; i < list.length; i++) { + if (i > 0 && list[i] - list[i - 1] > 1) out.push(null) + out.push(list[i]) + } + return out +}) + +onMounted(async () => { + await refreshMe() // latest invite_count / invite_earned + loadRecords() +}) + +const inviteCode = computed(() => auth.user?.invite_code || '') +const inviteCount = computed(() => Number(auth.user?.invite_count || 0)) +const inviteEarned = computed(() => Number(auth.user?.invite_earned || 0)) +const inviteUrl = computed(() => `${location.origin}/?ref=${inviteCode.value}`) + +async function copyInvite() { + try { await navigator.clipboard.writeText(inviteUrl.value); toast('邀请链接已复制') } + catch { toast('复制失败') } +} +async function copyCode() { + try { await navigator.clipboard.writeText(inviteCode.value); toast('邀请码已复制') } + catch { toast('复制失败') } +} + +// ---- Toast ---- +const toastMsg = ref('') +let toastTimer = null +function toast(m) { + toastMsg.value = m + clearTimeout(toastTimer) + toastTimer = setTimeout(() => (toastMsg.value = ''), 2200) +} +</script> + +<template> + <div class="theme-text space-y-10"> + <!-- header --> + <header> + <div class="text-[10px] uppercase tracking-[0.3em] text-amber-300/70 font-medium">奖励</div> + <h1 class="mt-2 text-4xl md:text-5xl font-bold tracking-tight">邀请好友</h1> + <p class="text-white/45 mt-2">好友用你的链接注册,并完成首次生图后,你得 {{ INVITE_REWARD }} 积分。</p> + </header> + + <section class="relative rounded-3xl ring-1 ring-white/[0.08] p-7 md:p-8 overflow-hidden" + style="background: radial-gradient(at 70% 30%,rgba(251,191,36,0.16) 0%, transparent 55%),linear-gradient(180deg,rgba(255,255,255,0.04),rgba(255,255,255,0.02))"> + <div class="inline-grid w-10 h-10 rounded-xl bg-amber-500/15 ring-1 ring-amber-400/30 grid place-items-center text-amber-300"> + <Icon name="accounts" class="w-4 h-4" /> + </div> + + <!-- summary --> + <div class="mt-6 grid grid-cols-2 gap-3"> + <div class="rounded-xl bg-white/[0.04] ring-1 ring-white/[0.06] px-4 py-3"> + <div class="text-2xl font-bold tabular-nums">{{ inviteCount }}</div> + <div class="text-[10px] text-white/40 mt-1 uppercase tracking-widest">已邀请</div> + </div> + <div class="rounded-xl bg-white/[0.04] ring-1 ring-white/[0.06] px-4 py-3"> + <div class="text-2xl font-bold tabular-nums">{{ inviteEarned.toLocaleString('en-US') }}</div> + <div class="text-[10px] text-white/40 mt-1 uppercase tracking-widest">累计积分</div> + </div> + </div> + + <!-- code --> + <div class="mt-6"> + <label class="block text-xs text-white/50 mb-2">邀请码</label> + <div class="flex gap-2"> + <button @click="copyCode" + class="flex-1 rounded-xl bg-white/[0.05] ring-1 ring-white/10 hover:ring-white/30 hover:bg-white/[0.08] px-4 py-3 text-sm font-mono text-left transition-all"> + {{ inviteCode || '—' }} + </button> + <button @click="copyInvite" + class="rounded-xl bg-white text-black hover:bg-white/90 px-5 py-3 text-sm font-semibold transition-colors"> + 复制链接 + </button> + </div> + <div class="mt-2 text-[11px] text-white/35 break-all font-mono">{{ inviteUrl }}</div> + </div> + </section> + + <!-- records --> + <section> + <div class="flex items-center justify-between mb-3"> + <h2 class="text-lg font-semibold">邀请记录</h2> + <button @click="loadRecords" class="text-xs text-white/50 hover:text-white inline-flex items-center gap-1.5 transition-colors"> + <Icon name="refresh" class="w-3.5 h-3.5" /> 刷新 + </button> + </div> + + <div class="card overflow-hidden"> + <div v-if="loading && !records.length" class="text-center text-sm text-white/40 py-16">加载中…</div> + <div v-else-if="!records.length" class="text-center text-sm text-white/40 py-16">还没有人通过你的链接注册</div> + + <table v-else class="w-full text-sm"> + <colgroup> + <col /> + <col class="w-24" /> + <col class="w-44" /> + <col class="w-44" /> + <col class="w-28" /> + </colgroup> + <thead> + <tr class="text-[10px] uppercase tracking-[0.2em] text-white/40 border-b border-white/[0.06]"> + <th class="text-left px-5 py-3 font-medium">注册用户名</th> + <th class="text-right px-3 py-3 font-medium">奖励</th> + <th class="text-left px-3 py-3 font-medium">注册时间</th> + <th class="text-left px-3 py-3 font-medium">完成时间</th> + <th class="text-right px-5 py-3 font-medium">状态</th> + </tr> + </thead> + <tbody> + <tr v-for="(r, i) in paged" :key="i" + class="border-b border-white/[0.04] hover:bg-white/[0.03] transition-colors"> + <td class="px-5 py-3.5 align-middle font-medium text-white/90 truncate">{{ r.name }}</td> + <td class="px-3 py-3.5 align-middle text-right tabular-nums whitespace-nowrap" + :class="r.reward ? 'text-emerald-300' : 'text-white/25'"> + {{ r.reward ? '+' + r.reward : '—' }} + </td> + <td class="px-3 py-3.5 align-middle text-xs text-white/55 tabular-nums whitespace-nowrap"> + {{ r.registered_at ? fmtIso(r.registered_at) : '—' }} + </td> + <td class="px-3 py-3.5 align-middle text-xs text-white/55 tabular-nums whitespace-nowrap"> + {{ r.completed_at ? fmtIso(r.completed_at) : '—' }} + </td> + <td class="px-5 py-3.5 align-middle text-right"> + <span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1" + :class="r.status === 'completed' + ? 'bg-emerald-500/10 text-emerald-300 ring-emerald-400/30' + : 'bg-amber-500/10 text-amber-300 ring-amber-400/30'"> + <span class="w-1.5 h-1.5 rounded-full" + :class="r.status === 'completed' ? 'bg-emerald-400' : 'bg-amber-400'"></span> + {{ r.status === 'completed' ? '已完成' : '待生图' }} + </span> + </td> + </tr> + </tbody> + </table> + + <!-- pagination — numbered with ellipsis, same as the admin pages --> + <div v-if="records.length && totalPages > 1" + class="flex items-center justify-between gap-3 border-t border-white/[0.06] px-5 py-3 text-xs text-white/55"> + <div> + <span class="tabular-nums text-white/85">{{ (page - 1) * pageSize + 1 }}–{{ Math.min(records.length, page * pageSize) }}</span> + <span class="ml-1">/ {{ records.length }} 条</span> + </div> + <div class="flex items-center gap-1"> + <template v-for="(n, i) in pageNumbers" :key="i"> + <span v-if="n === null" class="px-1 text-white/35">…</span> + <button v-else @click="goPage(n)" class="pg" :class="page === n && 'pg-on'">{{ n }}</button> + </template> + </div> + </div> + </div> + </section> + + <!-- toast --> + <transition name="fade"> + <div v-if="toastMsg" + class="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 bg-white text-black text-sm font-medium px-5 py-2.5 rounded-full shadow-2xl"> + {{ toastMsg }} + </div> + </transition> + </div> +</template> + +<style scoped> +/* Numbered pagination buttons — dark base (matches the admin pages); the global + .theme-text rules in style.css recolor these for light mode automatically. */ +.pg { + min-width: 1.75rem; + padding: 0.3rem 0.55rem; + font-size: 0.72rem; + font-weight: 500; + text-align: center; + border-radius: 0.45rem; + color: rgb(255 255 255 / 0.7); + background: rgb(255 255 255 / 0.04); + box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08); + transition: background 0.15s, color 0.15s, box-shadow 0.15s; +} +.pg:hover:not(.pg-on) { background: rgb(255 255 255 / 0.1); color: white; } +.pg-on { + background: rgb(255 255 255 / 0.92); + color: rgb(15 23 42); + box-shadow: none; +} +</style> diff --git a/frontend/src/views/InvitesAdminView.vue b/frontend/src/views/InvitesAdminView.vue new file mode 100644 index 0000000..ed2e760 --- /dev/null +++ b/frontend/src/views/InvitesAdminView.vue @@ -0,0 +1,102 @@ +<script setup> +// Admin invite log — global view of every invite relationship across accounts. +import { ref, onMounted } from 'vue' +import { api } from '../api' +import { fmtIso } from '../utils/format' +import Icon from '../components/Icon.vue' + +const items = ref([]) +const stats = ref({ total: 0, completed: 0, pending: 0, reward_paid: 0 }) +const loading = ref(false) + +async function load() { + loading.value = true + const r = await api('/invites') + loading.value = false + if (r.ok) { items.value = r.data?.data || []; stats.value = r.data?.stats || stats.value } +} +onMounted(load) +</script> + +<template> + <section class="space-y-4"> + <!-- KPI strip — same shape as the LogsView so the admin shell stays + consistent. /invites already returns the stats payload. --> + <div class="grid grid-cols-2 md:grid-cols-4 gap-3"> + <div class="card p-4"> + <div class="text-[11px] uppercase tracking-wider text-white/45">总邀请</div> + <div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.total }}</div> + </div> + <div class="card p-4"> + <div class="text-[11px] uppercase tracking-wider text-emerald-300/80">已完成</div> + <div class="text-2xl font-semibold mt-1 tabular-nums text-emerald-300">{{ stats.completed }}</div> + </div> + <div class="card p-4"> + <div class="text-[11px] uppercase tracking-wider text-amber-300/80">待生图</div> + <div class="text-2xl font-semibold mt-1 tabular-nums text-amber-300">{{ stats.pending }}</div> + </div> + <div class="card p-4"> + <div class="text-[11px] uppercase tracking-wider text-fuchsia-300/80">已发奖励</div> + <div class="text-2xl font-semibold mt-1 tabular-nums text-fuchsia-300">{{ Number(stats.reward_paid || 0).toLocaleString('en-US') }}</div> + </div> + </div> + + <div class="card overflow-hidden"> + <div class="px-5 py-3 border-b border-white/[0.06] flex items-center justify-between"> + <div class="text-xs text-white/55">全站邀请记录:谁邀请了谁、注册与完成时间、奖励状态。</div> + <button @click="load" class="btn-soft"><Icon name="refresh" class="w-3.5 h-3.5" /> 刷新</button> + </div> + + <div v-if="loading && !items.length" class="text-center text-sm text-white/40 py-16">加载中…</div> + <div v-else-if="!items.length" class="text-center text-sm text-white/40 py-16">还没有邀请记录</div> + + <table v-else class="w-full text-sm"> + <colgroup> + <col /> + <col /> + <col class="w-24" /> + <col class="w-44" /> + <col class="w-44" /> + <col class="w-28" /> + </colgroup> + <thead> + <tr class="text-[10px] uppercase tracking-[0.2em] text-white/40 border-b border-white/[0.06]"> + <th class="text-left px-5 py-3 font-medium">邀请人</th> + <th class="text-left px-3 py-3 font-medium">被邀请人</th> + <th class="text-right px-3 py-3 font-medium">奖励</th> + <th class="text-left px-3 py-3 font-medium">注册时间</th> + <th class="text-left px-3 py-3 font-medium">完成时间</th> + <th class="text-right px-5 py-3 font-medium">状态</th> + </tr> + </thead> + <tbody> + <tr v-for="(r, i) in items" :key="i" + class="border-b border-white/[0.04] hover:bg-white/[0.03] transition-colors"> + <td class="px-5 py-3.5 align-middle font-medium text-white/90 truncate">{{ r.inviter }}</td> + <td class="px-3 py-3.5 align-middle text-white/75 truncate">{{ r.invitee }}</td> + <td class="px-3 py-3.5 align-middle text-right tabular-nums whitespace-nowrap" + :class="r.reward ? 'text-emerald-300' : 'text-white/25'"> + {{ r.reward ? '+' + r.reward : '—' }} + </td> + <td class="px-3 py-3.5 align-middle text-xs text-white/55 tabular-nums whitespace-nowrap"> + {{ r.registered_at ? fmtIso(r.registered_at) : '—' }} + </td> + <td class="px-3 py-3.5 align-middle text-xs text-white/55 tabular-nums whitespace-nowrap"> + {{ r.completed_at ? fmtIso(r.completed_at) : '—' }} + </td> + <td class="px-5 py-3.5 align-middle text-right"> + <span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1" + :class="r.status === 'completed' + ? 'bg-emerald-500/10 text-emerald-300 ring-emerald-400/30' + : 'bg-amber-500/10 text-amber-300 ring-amber-400/30'"> + <span class="w-1.5 h-1.5 rounded-full" + :class="r.status === 'completed' ? 'bg-emerald-400' : 'bg-amber-400'"></span> + {{ r.status === 'completed' ? '已完成' : '待生图' }} + </span> + </td> + </tr> + </tbody> + </table> + </div> + </section> +</template> diff --git a/frontend/src/views/LogsView.vue b/frontend/src/views/LogsView.vue new file mode 100644 index 0000000..344aec4 --- /dev/null +++ b/frontend/src/views/LogsView.vue @@ -0,0 +1,438 @@ +<script setup> +import { ref, computed, onMounted, onUnmounted } from 'vue' +import { api } from '../api' +import { fmtTs, fmtDate, fmtClock } from '../utils/format' +import { generatedUrl } from '../api' +import Icon from '../components/Icon.vue' +import MediaLightbox from '../components/MediaLightbox.vue' + +const items = ref([]) +const stats = ref({ total: 0, success: 0, failed: 0, pending: 0 }) +const loading = ref(false) +const kindFilter = ref('') // '' | 'image' | 'video' +const statusFilter = ref('') // '' | 'success' | 'failed' | 'pending' +const sourceFilter = ref('') // '' | 'v1' | 'user' | 'admin' +const search = ref('') +const page = ref(1) +const pageSize = ref(15) +const total = ref(0) + +async function load() { + loading.value = true + const offset = (page.value - 1) * pageSize.value + const qs = new URLSearchParams({ limit: String(pageSize.value), offset: String(offset) }) + // Admin 日志 page: request the full cross-user view. The backend only honors + // scope=all for admins; without it /logs returns the caller's own records. + qs.set('scope', 'all') + if (kindFilter.value) qs.set('kind', kindFilter.value) + if (statusFilter.value) qs.set('status', statusFilter.value) + if (sourceFilter.value) qs.set('source', sourceFilter.value) + const r = await api('/logs?' + qs.toString()) + items.value = r.data?.data || [] + total.value = Number(r.data?.total ?? items.value.length) + stats.value = r.data?.stats || { total: 0, success: 0, failed: 0, pending: 0 } + loading.value = false +} + +const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value))) +const pageStart = computed(() => total.value === 0 ? 0 : (page.value - 1) * pageSize.value + 1) +const pageEnd = computed(() => Math.min(total.value, page.value * pageSize.value)) + +// Numbered pagination strip: always shows first + last + a window around +// the current page; gaps collapse to `null` (rendered as "…"). +const pageNumbers = computed(() => { + const n = totalPages.value + const cur = page.value + if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1) + const want = new Set([1, n, cur - 1, cur, cur + 1]) + // pad the second slot from each end so 1 2 … X … N-1 N feels balanced + if (cur <= 3) { want.add(2); want.add(3); want.add(4) } + if (cur >= n - 2) { want.add(n - 1); want.add(n - 2); want.add(n - 3) } + const list = [...want].filter((x) => x >= 1 && x <= n).sort((a, b) => a - b) + const out = [] + for (let i = 0; i < list.length; i++) { + if (i > 0 && list[i] - list[i - 1] > 1) out.push(null) + out.push(list[i]) + } + return out +}) + +function goPage(n) { + const target = Math.max(1, Math.min(totalPages.value, n)) + if (target === page.value) return + page.value = target + load() +} + +// Filters reset the cursor so a narrower view always starts on page 1. +function setKind(v) { kindFilter.value = v; page.value = 1; load() } +function setStatus(v) { statusFilter.value = v; page.value = 1; load() } +function setSource(v) { sourceFilter.value = v; page.value = 1; load() } + +const filtered = computed(() => { + const q = search.value.trim().toLowerCase() + if (!q) return items.value + return items.value.filter((e) => + (e.model || '').toLowerCase().includes(q) || + (e.prompt || '').toLowerCase().includes(q) || + (e.error || '').toLowerCase().includes(q), + ) +}) + +function fmtMs(ms) { + if (!ms) return '—' + if (ms < 1000) return ms + 'ms' + return Math.round(ms / 1000) + 's' +} + +// One-line timestamp: within 3 days show a relative phrase ("12h 前"), +// older entries collapse to a full Y-M-D H:M:S so the row stays compact. +function fmtWhen(ts) { + if (!ts) return '—' + return fmtTs(ts) +} + +const previewing = ref(null) // entry whose generated file is open in the lightbox +function openPreview(e) { + // API (v1) outputs aren't persisted/served by us (image=b64 inline, video=an + // upstream URL for /content) — no in-log preview, same as images. Skip them. + if (e.status !== 'success' || !e.file || e.source === 'v1') return + previewing.value = e +} +function closePreview() { previewing.value = null } +function onKey(ev) { if (ev.key === 'Escape') closePreview() } + +// 日志不支持手动清空(清空按钮已移除);仅由后台保留期策略自动清理。 + +// Auto-refresh removed: the admin can hit 刷新 / change a filter to reload. +onMounted(() => { + load() + window.addEventListener('keydown', onKey) +}) +onUnmounted(() => { + window.removeEventListener('keydown', onKey) +}) + +// ---- chip helpers ---- +const statusLabel = (s) => ({ success: '成功', failed: '失败', pending: '进行中' }[s] || s) +const statusPill = (s) => ({ + success: 'bg-emerald-500/10 text-emerald-300 ring-emerald-400/30', + failed: 'bg-rose-500/10 text-rose-300 ring-rose-400/30', + pending: 'bg-amber-500/10 text-amber-300 ring-amber-400/30', +}[s] || 'bg-white/[0.06] text-white/65 ring-white/15') +const statusDot = (s) => ({ + success: 'bg-emerald-400', + failed: 'bg-rose-400', + pending: 'bg-amber-400', +}[s] || 'bg-white/40') + +// Source: backend stamps "v1" (API key), "user" (画图台), "admin" (后台测试模型). +const sourceLabel = (s) => ({ v1: 'API', user: '画图台', admin: '测试' }[s] || '画图台') +const sourcePill = (s) => ({ + v1: 'bg-violet-500/15 text-violet-300 ring-violet-400/30', + admin: 'bg-amber-500/15 text-amber-300 ring-amber-400/30', + user: 'bg-sky-500/15 text-sky-300 ring-sky-400/30', +}[s] || 'bg-sky-500/15 text-sky-300 ring-sky-400/30') +</script> + +<template> + <section class="space-y-4"> + <!-- KPI strip — dense pills aligned with the dashboard tints --> + <div class="grid grid-cols-2 md:grid-cols-4 gap-3"> + <div class="card p-4"> + <div class="text-[11px] uppercase tracking-wider text-white/45">总计</div> + <div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.total }}</div> + </div> + <div class="card p-4"> + <div class="text-[11px] uppercase tracking-wider text-emerald-300/80">成功</div> + <div class="text-2xl font-semibold mt-1 tabular-nums text-emerald-300">{{ stats.success }}</div> + </div> + <div class="card p-4"> + <div class="text-[11px] uppercase tracking-wider text-rose-300/80">失败</div> + <div class="text-2xl font-semibold mt-1 tabular-nums text-rose-300">{{ stats.failed }}</div> + </div> + <div class="card p-4"> + <div class="text-[11px] uppercase tracking-wider text-amber-300/80">进行中</div> + <div class="text-2xl font-semibold mt-1 tabular-nums text-amber-300">{{ stats.pending }}</div> + </div> + </div> + + <!-- Toolbar --> + <div class="card p-3 flex items-center gap-3 flex-wrap"> + <div class="flex items-center gap-1"> + <button @click="setKind('')" class="fp" :class="kindFilter === '' && 'fp-on'">全部</button> + <button @click="setKind('image')" class="fp" :class="kindFilter === 'image' && 'fp-on'">图像</button> + <button @click="setKind('video')" class="fp" :class="kindFilter === 'video' && 'fp-on'">视频</button> + </div> + <div class="w-px h-5 bg-white/10"></div> + <div class="flex items-center gap-1"> + <button @click="setStatus('')" class="fp" :class="statusFilter === '' && 'fp-on'">所有状态</button> + <button @click="setStatus('success')" class="fp" :class="statusFilter === 'success' && 'fp-emerald'"> + <span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>成功 + </button> + <button @click="setStatus('failed')" class="fp" :class="statusFilter === 'failed' && 'fp-rose'"> + <span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>失败 + </button> + <button @click="setStatus('pending')" class="fp" :class="statusFilter === 'pending' && 'fp-amber'"> + <span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span>进行中 + </button> + </div> + <div class="w-px h-5 bg-white/10"></div> + <div class="flex items-center gap-1"> + <button @click="setSource('')" class="fp" :class="sourceFilter === '' && 'fp-on'">所有来源</button> + <button @click="setSource('user')" class="fp" :class="sourceFilter === 'user' && 'fp-on'">画图台</button> + <button @click="setSource('v1')" class="fp" :class="sourceFilter === 'v1' && 'fp-on'">API</button> + <button @click="setSource('admin')" class="fp" :class="sourceFilter === 'admin' && 'fp-on'">测试</button> + </div> + <div class="flex-1 min-w-[200px]"> + <input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索 模型 / 提示词 / 错误…" /> + </div> + <button @click="load" class="btn-soft"> + <Icon name="refresh" class="w-3.5 h-3.5" /> 刷新 + </button> + </div> + + <!-- Table --> + <div class="card overflow-hidden"> + <div v-if="loading && !items.length" class="text-center text-sm text-white/40 py-20">加载中…</div> + <div v-else-if="!filtered.length" class="flex flex-col items-center gap-3 text-white/40 py-20"> + <span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center"><Icon name="files" class="w-6 h-6" /></span> + <!-- Search is client-side over the CURRENT page only, so "no match" here + doesn't mean the term is absent globally — say so to avoid confusion. --> + <span class="text-sm">{{ search.trim() ? '当前页没有匹配的记录(搜索仅作用于本页)' : '还没有日志' }}</span> + </div> + + <!-- Each row is a thumbnail + a stack of model/prompt + a meta line. + Beats a 9-column table for scanability — the eye lands on the + image first, then reads the model + intent, then params. --> + <table v-else class="w-full text-sm table-fixed log-table"> + <colgroup> + <col class="w-20" /> <!-- preview --> + <col class="w-32" /> <!-- time --> + <col class="w-24" /> <!-- status --> + <col class="w-28" /> <!-- user --> + <col class="w-40" /> <!-- model --> + <col /> <!-- prompt + error --> + <col class="w-48" /> <!-- params --> + <col class="w-16" /> <!-- credits --> + <col class="w-16" /> <!-- elapsed --> + </colgroup> + <thead> + <tr class="text-[10px] uppercase tracking-[0.2em] text-white/40 border-b border-white/[0.06]"> + <th class="text-center px-4 py-3 font-medium">预览</th> + <th class="text-left px-4 py-3 font-medium">时间</th> + <th class="text-left px-3 py-3 font-medium">状态</th> + <th class="text-left px-3 py-3 font-medium">用户</th> + <th class="text-left px-3 py-3 font-medium">模型</th> + <th class="text-left px-3 py-3 font-medium">提示词 / 错误</th> + <th class="text-left px-3 py-3 font-medium">参数</th> + <th class="text-right px-3 py-3 font-medium">积分</th> + <th class="text-right px-4 py-3 font-medium">耗时</th> + </tr> + </thead> + <tbody> + <tr v-for="e in filtered" :key="e.id" class="log-row"> + <td class="px-4 py-3.5 align-middle text-center"> + <button v-if="e.status === 'success' && e.file && e.source !== 'v1'" + @click="openPreview(e)" + class="block w-12 h-12 mx-auto rounded-lg overflow-hidden ring-1 ring-white/10 hover:ring-fuchsia-400/60 transition-all"> + <img v-if="e.kind !== 'video'" :src="generatedUrl(e.file)" loading="lazy" + class="w-full h-full object-cover" /> + <video v-else :src="generatedUrl(e.file)" muted loop preload="metadata" playsinline + class="w-full h-full object-cover" + @mouseenter="$event.target.play && $event.target.play()" + @mouseleave="$event.target.pause && $event.target.pause()" /> + </button> + <div v-else-if="e.status === 'pending'" class="w-12 h-12 mx-auto rounded-lg bg-amber-500/10 ring-1 ring-amber-400/30 grid place-items-center"> + <span class="w-2 h-2 rounded-full bg-amber-400 animate-pulse"></span> + </div> + <!-- failed (and any non-success/non-pending) rows: no thumbnail, just a dash. + The 状态 column already flags the failure. --> + <span v-else class="text-white/20">—</span> + </td> + <td class="px-4 py-3.5 align-middle text-xs whitespace-nowrap" :title="fmtTs(e.ts)"> + <div v-if="e.ts" class="leading-tight"> + <div class="text-white/80 tabular-nums">{{ fmtDate(e.ts) }}</div> + <div class="text-white/45 tabular-nums">{{ fmtClock(e.ts) }}</div> + </div> + <span v-else class="text-white/25">—</span> + </td> + <td class="px-3 py-3.5 align-middle"> + <span class="chip ring-1" :class="statusPill(e.status)"> + <span class="w-1.5 h-1.5 rounded-full" :class="statusDot(e.status)"></span> + {{ statusLabel(e.status) }} + </span> + </td> + <td class="px-3 py-3.5 align-middle min-w-0"> + <div class="text-xs text-white/80 truncate" :title="e.user_name || '匿名'">{{ e.user_name || '匿名' }}</div> + </td> + <td class="px-3 py-3.5 align-middle min-w-0"> + <div class="font-mono text-xs text-white/90 truncate" :title="e.model">{{ e.model }}</div> + <div class="mt-1 flex items-center gap-1.5 min-w-0"> + <span class="text-[10px] uppercase tracking-wider font-medium truncate min-w-0" + :class="e.kind === 'video' ? 'text-fuchsia-300/80' : 'text-indigo-300/80'"> + {{ e.kind === 'video' ? '视频' : '图像' }} + <span v-if="e.provider" class="text-white/30 ml-1">· {{ e.provider }}</span> + </span> + <span class="inline-flex items-center rounded px-1.5 py-px text-[10px] font-medium ring-1 whitespace-nowrap shrink-0" + :class="sourcePill(e.source)">{{ sourceLabel(e.source) }}</span> + </div> + </td> + <!-- Prompt with error inline; the error reads as a follow-up rather + than wasting a whole column when there's nothing to show. --> + <td class="px-3 py-3.5 align-middle min-w-0"> + <div class="text-xs text-white/80 truncate" :title="e.prompt">{{ e.prompt || '—' }}</div> + <div v-if="e.error" class="mt-1 text-[11px] text-rose-300/85 truncate flex items-center gap-1.5" :title="e.error"> + <Icon name="close" class="w-3 h-3 shrink-0" /> + {{ e.error }} + </div> + </td> + <!-- Compact single-line params, dot-separated. --> + <td class="px-3 py-3.5 align-middle text-[11px] text-white/55 font-mono whitespace-nowrap tabular-nums"> + <span>{{ e.ratio || '—' }}</span> + <span class="text-white/25 mx-1.5">·</span> + <span>{{ e.resolution || '—' }}</span> + <template v-if="e.duration"> + <span class="text-white/25 mx-1.5">·</span> + <span>{{ e.duration }}</span> + </template> + <template v-if="e.refs > 0"> + <span class="text-white/25 mx-1.5">·</span> + <span class="text-white/40">参考 {{ e.refs }}</span> + </template> + </td> + <td class="px-3 py-3.5 text-right text-xs tabular-nums align-middle whitespace-nowrap"> + <span v-if="e.cost > 0" class="text-amber-300 font-medium">{{ e.cost }}</span> + <span v-else class="text-white/25">0</span> + </td> + <td class="px-4 py-3.5 text-right text-xs tabular-nums align-middle whitespace-nowrap text-white/85"> + {{ fmtMs(e.elapsed_ms) }} + </td> + </tr> + </tbody> + </table> + + <!-- pagination — numbered with ellipsis, no prev/next buttons --> + <div v-if="!loading && total > 0" + class="flex items-center justify-between gap-3 border-t border-white/[0.06] px-5 py-3 text-xs text-white/55"> + <div> + <span class="tabular-nums text-white/85">{{ pageStart }}–{{ pageEnd }}</span> + <span class="ml-1">/ {{ total }} 条</span> + </div> + <div class="flex items-center gap-1"> + <template v-for="(n, i) in pageNumbers" :key="i"> + <span v-if="n === null" class="px-1 text-white/35">…</span> + <button v-else @click="goPage(n)" class="pg" :class="page === n && 'pg-on'">{{ n }}</button> + </template> + </div> + </div> + </div> + + <!-- Lightbox (shared component) --> + <MediaLightbox + v-if="previewing" + :src="generatedUrl(previewing.file)" + :kind="previewing.kind" + :prompt="previewing.prompt" + :meta="[previewing.model, previewing.ratio, previewing.resolution, previewing.duration, fmtMs(previewing.elapsed_ms)].filter(Boolean).join(' · ')" + :download-name="previewing.file" + @close="closePreview" /> + </section> +</template> + +<style scoped> +/* --- filter pills --- */ +.fp { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.35rem 0.7rem; + font-size: 0.72rem; + border-radius: 0.55rem; + color: rgb(255 255 255 / 0.65); + background: rgb(255 255 255 / 0.05); + box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.06); + transition: background 0.15s, color 0.15s, box-shadow 0.15s; +} +.fp:hover { background: rgb(255 255 255 / 0.09); color: white; } +.fp-on { + background: rgb(255 255 255 / 0.92); + color: rgb(15 23 42); + box-shadow: none; +} +.fp-emerald { + background: rgb(16 185 129 / 0.22); + color: rgb(110 231 183); + box-shadow: inset 0 0 0 1px rgb(110 231 183 / 0.45); +} +.fp-rose { + background: rgb(244 63 94 / 0.22); + color: rgb(253 164 175); + box-shadow: inset 0 0 0 1px rgb(253 164 175 / 0.45); +} +.fp-amber { + background: rgb(245 158 11 / 0.22); + color: rgb(252 211 77); + box-shadow: inset 0 0 0 1px rgb(252 211 77 / 0.45); +} + +/* --- type / status chip used inside table rows --- */ +.chip { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.18rem 0.55rem; + font-size: 0.7rem; + font-weight: 500; + border-radius: 9999px; + white-space: nowrap; +} + +/* --- "danger" variant for the 清空 button --- */ +.btn-soft.danger { + color: rgb(253 164 175); + background: rgb(244 63 94 / 0.12); + box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3); +} +.btn-soft.danger:hover { + color: white; + background: rgb(244 63 94 / 0.25); +} + +.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; } +.fade-enter-from, .fade-leave-to { opacity: 0; } + +/* --- log table: subtle row separators + a barely-there hover tint that + extends a soft violet accent on the left of the row (read as a + focus indicator without being noisy). --- */ +.log-table { border-collapse: separate; border-spacing: 0; } +.log-row td { + border-bottom: 1px solid rgb(255 255 255 / 0.04); + transition: background-color 0.15s ease, box-shadow 0.15s ease; +} +.log-row:hover td { background: rgb(255 255 255 / 0.025); } +.log-row:hover td:first-child { + box-shadow: inset 2px 0 0 rgb(167 139 250 / 0.55); +} +.log-row:last-child td { border-bottom: none; } + +/* --- pagination buttons --- */ +.pg { + min-width: 1.75rem; + padding: 0.3rem 0.55rem; + font-size: 0.72rem; + font-weight: 500; + text-align: center; + border-radius: 0.45rem; + color: rgb(255 255 255 / 0.7); + background: rgb(255 255 255 / 0.04); + box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08); + transition: background 0.15s, color 0.15s, box-shadow 0.15s; +} +.pg:hover:not(.pg-on) { background: rgb(255 255 255 / 0.1); color: white; } +.pg-on { + background: rgb(255 255 255 / 0.92); + color: rgb(15 23 42); + box-shadow: none; +} +</style> diff --git a/frontend/src/views/ModelsView.vue b/frontend/src/views/ModelsView.vue new file mode 100644 index 0000000..2948f88 --- /dev/null +++ b/frontend/src/views/ModelsView.vue @@ -0,0 +1,399 @@ +<script setup> +import { ref, computed, onMounted } from 'vue' +import { api } from '../api' +import Icon from '../components/Icon.vue' +import ModelFormModal from '../components/ModelFormModal.vue' +import TestModal from '../components/TestModal.vue' +import { points } from '../credits' + +const models = ref([]) +const loading = ref(false) +const showForm = ref(false) +const editing = ref(null) // null = add, object = edit +const testing = ref(null) // model being tested, or null + +const kindFilter = ref('') // '' | 'image' | 'video' +const statusFilter = ref('') // '' | 'enabled' | 'disabled' +const search = ref('') + +const TYPE_LABEL = { image: '生图', video: '生视频' } +const REF_MODE_LABEL = { none: '无', frame: '首帧/首尾帧', asset: '参考图模式' } + +async function loadModels() { + loading.value = true + const r = await api('/managed-models') + models.value = r.data?.data || [] + loading.value = false +} + +function openAdd() { editing.value = null; showForm.value = true } +function openEdit(m) { editing.value = { ...m }; showForm.value = true } +function onSaved() { showForm.value = false; loadModels() } + +async function toggleEnabled(m) { + // Optimistic: flip the switch instantly, persist in the background, revert on + // failure. Avoids the lag of awaiting the PATCH + a full table reload before + // the toggle visibly moves. + const cur = m.enabled !== false + const next = !cur + m.enabled = next + const r = await api(`/managed-models/${encodeURIComponent(m.id)}`, { + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: next }), + }) + if (!r.ok) m.enabled = cur +} + +async function remove(m) { + if (!confirm(`确认删除模型 ${m.id}?`)) return + await api(`/managed-models/${encodeURIComponent(m.id)}`, { method: 'DELETE' }) + loadModels() +} + +const stats = computed(() => { + const total = models.value.length + const image = models.value.filter((m) => m.type === 'image').length + const video = models.value.filter((m) => m.type === 'video').length + const enabled = models.value.filter((m) => m.enabled !== false).length + return { total, image, video, enabled, disabled: total - enabled } +}) + +const filtered = computed(() => { + const q = search.value.trim().toLowerCase() + return models.value.filter((m) => { + if (kindFilter.value && m.type !== kindFilter.value) return false + if (statusFilter.value === 'enabled' && m.enabled === false) return false + if (statusFilter.value === 'disabled' && m.enabled !== false) return false + if (q && !(m.id.toLowerCase().includes(q) || (m.provider || '').toLowerCase().includes(q))) return false + return true + }) +}) + +onMounted(loadModels) +</script> + +<template> + <section class="space-y-4"> + <!-- KPI strip — same shape as LogsView / InvitesAdminView --> + <div class="grid grid-cols-2 md:grid-cols-4 gap-3"> + <div class="card p-4"> + <div class="text-[11px] uppercase tracking-wider text-white/45">总数</div> + <div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.total }}</div> + </div> + <div class="card p-4"> + <div class="text-[11px] uppercase tracking-wider text-indigo-300/80">图像</div> + <div class="text-2xl font-semibold mt-1 tabular-nums text-indigo-300">{{ stats.image }}</div> + </div> + <div class="card p-4"> + <div class="text-[11px] uppercase tracking-wider text-fuchsia-300/80">视频</div> + <div class="text-2xl font-semibold mt-1 tabular-nums text-fuchsia-300">{{ stats.video }}</div> + </div> + <div class="card p-4"> + <div class="text-[11px] uppercase tracking-wider text-emerald-300/80">启用</div> + <div class="text-2xl font-semibold mt-1 tabular-nums text-emerald-300">{{ stats.enabled }}<span class="text-white/35 text-lg ml-1">/ {{ stats.total }}</span></div> + </div> + </div> + + <!-- Toolbar --> + <div class="card p-3 flex items-center gap-3 flex-wrap"> + <div class="flex items-center gap-1"> + <button @click="kindFilter = ''" class="fp" :class="kindFilter === '' && 'fp-on'">全部</button> + <button @click="kindFilter = 'image'" class="fp" :class="kindFilter === 'image' && 'fp-on'">图像</button> + <button @click="kindFilter = 'video'" class="fp" :class="kindFilter === 'video' && 'fp-on'">视频</button> + </div> + <div class="w-px h-5 bg-white/10"></div> + <div class="flex items-center gap-1"> + <button @click="statusFilter = ''" class="fp" :class="statusFilter === '' && 'fp-on'">所有状态</button> + <button @click="statusFilter = 'enabled'" class="fp" :class="statusFilter === 'enabled' && 'fp-emerald'"> + <span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>启用 + </button> + <button @click="statusFilter = 'disabled'" class="fp" :class="statusFilter === 'disabled' && 'fp-white'"> + <span class="w-1.5 h-1.5 rounded-full bg-white/40"></span>停用 + </button> + </div> + <div class="flex-1 min-w-[200px]"> + <input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索 模型 ID / Provider…" /> + </div> + <button @click="loadModels" class="btn-soft"> + <Icon name="refresh" class="w-3.5 h-3.5" /> 刷新 + </button> + <button @click="openAdd" class="btn-primary"> + <Icon name="plus" class="w-3.5 h-3.5" /> 新增模型 + </button> + </div> + + <!-- Table --> + <div class="card overflow-hidden"> + <div v-if="loading && !models.length" class="text-center text-sm text-white/40 py-20">加载中…</div> + <div v-else-if="!filtered.length" class="flex flex-col items-center gap-3 text-white/40 py-20"> + <span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center"><Icon name="models" class="w-6 h-6" /></span> + <span class="text-sm">{{ models.length ? '没有匹配的模型' : '还没有模型,点右上角「新增模型」' }}</span> + </div> + + <table v-else class="w-full text-sm table-fixed"> + <colgroup> + <col /> <!-- model id + provider --> + <col class="w-20" /> <!-- type --> + <col /> <!-- pricing --> + <col /> <!-- capability --> + <col class="w-20" /> <!-- weight --> + <col class="w-24" /> <!-- generation count --> + <col class="w-20" /> <!-- status switch --> + <col class="w-36" /> <!-- actions --> + </colgroup> + <thead> + <tr class="text-[10px] uppercase tracking-[0.2em] text-white/40 border-b border-white/[0.06]"> + <th class="text-left px-5 py-3 font-medium">模型</th> + <th class="text-left px-3 py-3 font-medium">类型</th> + <th class="text-left px-3 py-3 font-medium">定价</th> + <th class="text-left px-3 py-3 font-medium">能力</th> + <th class="text-right px-3 py-3 font-medium">权重</th> + <th class="text-right px-3 py-3 font-medium">生图次数</th> + <th class="text-left px-3 py-3 font-medium">状态</th> + <th class="text-right px-5 py-3 font-medium">操作</th> + </tr> + </thead> + <tbody> + <tr v-for="m in filtered" :key="m.id" + class="border-b border-white/[0.04] hover:bg-white/[0.03] transition-colors"> + <!-- Model id + provider underneath --> + <td class="px-5 py-3.5 align-middle min-w-0"> + <div class="font-mono text-xs text-white/90 truncate" :title="m.id">{{ m.id }}</div> + <div class="mt-1 text-[10px] text-white/45 capitalize truncate">{{ m.provider || '—' }}</div> + </td> + + <!-- Type chip with the same shape as Logs/Provider 健康 --> + <td class="px-3 py-3.5 align-middle"> + <span class="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1" + :class="m.type === 'video' + ? 'bg-fuchsia-500/10 text-fuchsia-300 ring-fuchsia-400/30' + : 'bg-indigo-500/10 text-indigo-300 ring-indigo-400/30'"> + {{ TYPE_LABEL[m.type] || m.type }} + </span> + </td> + + <!-- Pricing block — image shows 1K/2K/4K + price, video shows + duration + price; each as a small price-chip. --> + <td class="px-3 py-3.5 align-middle"> + <!-- Each tier shows a pair: 普通价 (emerald) + 代理价 (amber). + 代理价未设置时回退普通价数值。 --> + <div v-if="m.type === 'image'" class="flex flex-wrap gap-1"> + <span v-for="r in (m.resolutions || [])" :key="r" class="price-chip"> + <span class="text-white/85">{{ r }}</span> + <span class="text-white/30 mx-1">普通</span> + <span class="text-emerald-300 tabular-nums">{{ points(m.prices?.[r]) }}</span> + <span class="text-amber-300/40 ml-1.5">代理</span> + <span class="text-amber-300 tabular-nums">{{ points(m.prices_agent?.[r] ?? m.prices?.[r]) }}</span> + </span> + <span v-if="!(m.resolutions || []).length" class="text-white/30 text-xs">—</span> + </div> + <div v-else class="flex flex-wrap gap-1"> + <!-- video charge = resolution price + duration price; both show 普通/代理 --> + <span v-for="r in (m.resolutions || [])" :key="'r'+r" class="price-chip"> + <span class="text-white/85">{{ r }}</span> + <span class="text-white/30 mx-1">普通</span> + <span class="text-emerald-300 tabular-nums">{{ points(m.prices?.[r]) }}</span> + <span class="text-amber-300/40 ml-1.5">代理</span> + <span class="text-amber-300 tabular-nums">{{ points(m.prices_agent?.[r] ?? m.prices?.[r]) }}</span> + </span> + <span v-for="d in (m.durations || [])" :key="'d'+d" class="price-chip"> + <span class="text-white/85">{{ d }}</span> + <span class="text-white/30 mx-1">普通</span> + <span class="text-sky-300 tabular-nums">+{{ points(m.duration_prices?.[d]) }}</span> + <span class="text-amber-300/40 ml-1.5">代理</span> + <span class="text-amber-300 tabular-nums">+{{ points(m.duration_prices_agent?.[d] ?? m.duration_prices?.[d]) }}</span> + </span> + <span v-if="!(m.resolutions || []).length && !(m.durations || []).length" class="text-white/30 text-xs">—</span> + </div> + </td> + + <!-- Capability — extras that aren't a price: image-to-image, + video frame mode, supported resolutions for video. --> + <td class="px-3 py-3.5 align-middle"> + <div class="flex flex-wrap items-center gap-1 text-[11px]"> + <span v-if="m.type === 'image' && m.image_to_image" + class="cap-chip cap-emerald">图生图</span> + <!-- 参考图 is a capability like 图生图 — same emerald style, shown first --> + <span v-if="m.type === 'video' && m.max_reference_images > 0" + class="cap-chip cap-emerald" + :title="REF_MODE_LABEL[m.reference_mode]">参考图 {{ m.max_reference_images }}</span> + <span v-if="m.type === 'video'" v-for="r in (m.resolutions || [])" :key="'vr'+r" + class="cap-chip cap-slate">{{ r }}</span> + <span v-if="(m.type === 'image' && !(m.ratios || []).length && !m.image_to_image) || + (m.type === 'video' && !(m.resolutions || []).length && !m.max_reference_images)" + class="text-white/30 text-xs">—</span> + <span v-for="r in (m.ratios || [])" :key="'rt'+r" class="cap-chip cap-mono">{{ r }}</span> + </div> + </td> + + <!-- Display weight — higher floats to the top of the dropdown/list --> + <td class="px-3 py-3.5 align-middle text-right tabular-nums whitespace-nowrap" + :class="(m.weight || 0) !== 0 ? 'text-white/85' : 'text-white/30'" + title="展示权重(越大越靠前)"> + {{ m.weight || 0 }} + </td> + + <!-- Successful generations to date, from event_log via /managed-models --> + <td class="px-3 py-3.5 align-middle text-right tabular-nums whitespace-nowrap" + :class="m.generation_count > 0 ? 'text-white/85' : 'text-white/25'"> + {{ (m.generation_count || 0).toLocaleString('en-US') }} + </td> + + <!-- Status as a real toggle so admins read it as on/off, not a + button to delete or whatever. --> + <td class="px-3 py-3.5 align-middle"> + <button class="sw" :class="m.enabled !== false && 'sw-on'" + :aria-pressed="m.enabled !== false" @click="toggleEnabled(m)"> + <span class="sw-thumb"></span> + </button> + </td> + + <!-- Actions — small soft buttons, danger variant for delete --> + <td class="px-3 py-3.5 align-middle text-right whitespace-nowrap"> + <div class="inline-flex items-center gap-1"> + <button @click="testing = m" class="act" title="测试生成"> + <Icon name="test" class="w-3.5 h-3.5" /> + </button> + <button @click="openEdit(m)" class="act" title="编辑"> + <Icon name="config" class="w-3.5 h-3.5" /> + </button> + <button @click="remove(m)" class="act danger" title="删除"> + <Icon name="trash" class="w-3.5 h-3.5" /> + </button> + </div> + </td> + </tr> + </tbody> + </table> + </div> + + <ModelFormModal v-if="showForm" :model="editing" @close="showForm = false" @saved="onSaved" /> + <TestModal v-if="testing" :model="testing" @close="testing = null" /> + </section> +</template> + +<style scoped> +/* --- filter pills (mirrors LogsView so the admin shell stays consistent) */ +.fp { + display: inline-flex; align-items: center; gap: 0.35rem; + padding: 0.35rem 0.7rem; font-size: 0.72rem; + border-radius: 0.55rem; + color: rgb(255 255 255 / 0.65); + background: rgb(255 255 255 / 0.05); + box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.06); + transition: background 0.15s, color 0.15s, box-shadow 0.15s; +} +.fp:hover { background: rgb(255 255 255 / 0.09); color: white; } +.fp-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); box-shadow: none; } +.fp-emerald { + background: rgb(16 185 129 / 0.22); + color: rgb(110 231 183); + box-shadow: inset 0 0 0 1px rgb(110 231 183 / 0.45); +} +.fp-white { + background: rgb(255 255 255 / 0.18); + color: white; + box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.35); +} + +/* --- price chips: tight pill showing a key (resolution or duration) plus + its emerald-tinted price. */ +.price-chip { + display: inline-flex; + align-items: center; + padding: 0.18rem 0.55rem; + font-size: 0.7rem; + border-radius: 9999px; + background: rgb(255 255 255 / 0.04); + box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08); + white-space: nowrap; +} + +/* --- capability chips: smaller, monochrome variants */ +.cap-chip { + display: inline-flex; align-items: center; + padding: 0.15rem 0.5rem; + font-size: 0.68rem; font-weight: 500; + border-radius: 9999px; + white-space: nowrap; +} +.cap-emerald { + background: rgb(16 185 129 / 0.12); + color: rgb(110 231 183); + box-shadow: inset 0 0 0 1px rgb(110 231 183 / 0.3); +} +.cap-amber { + background: rgb(245 158 11 / 0.12); + color: rgb(252 211 77); + box-shadow: inset 0 0 0 1px rgb(252 211 77 / 0.3); +} +.cap-slate { + background: rgb(255 255 255 / 0.05); + color: rgb(255 255 255 / 0.7); + box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08); +} +.cap-mono { + background: transparent; + color: rgb(255 255 255 / 0.5); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08); +} + +/* --- iOS-style toggle for 启用/停用 */ +.sw { + position: relative; + width: 2.25rem; height: 1.3rem; + border-radius: 9999px; + background: rgb(255 255 255 / 0.12); + box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08); + transition: background 0.18s ease; +} +.sw-thumb { + position: absolute; + top: 2px; left: 2px; + width: calc(1.3rem - 4px); height: calc(1.3rem - 4px); + border-radius: 9999px; + background: white; + box-shadow: 0 1px 2px rgb(15 23 42 / 0.3); + transition: transform 0.18s ease; +} +.sw-on { background: rgb(16 185 129 / 0.7); box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.5); } +.sw-on .sw-thumb { transform: translateX(calc(2.25rem - 1.3rem)); } + +/* --- danger variant for 删除 (kept for any other .btn-soft callers) */ +.btn-soft.danger { + color: rgb(253 164 175); + background: rgb(244 63 94 / 0.12); + box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3); +} +.btn-soft.danger:hover { + color: white; + background: rgb(244 63 94 / 0.25); +} + +/* --- compact square icon button for the actions column. Predictable + width (1.9rem × 3 + gaps) keeps the row inside its 9rem slot, so + the last button never gets clipped. */ +.act { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.9rem; + height: 1.9rem; + border-radius: 0.5rem; + color: rgb(255 255 255 / 0.7); + background: rgb(255 255 255 / 0.04); + box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08); + transition: background 0.15s, color 0.15s, box-shadow 0.15s; +} +.act:hover { background: rgb(255 255 255 / 0.1); color: white; } +.act.danger { + color: rgb(253 164 175); + background: rgb(244 63 94 / 0.12); + box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3); +} +.act.danger:hover { + color: white; + background: rgb(244 63 94 / 0.25); +} +</style> diff --git a/frontend/src/views/OverviewView.vue b/frontend/src/views/OverviewView.vue new file mode 100644 index 0000000..a731ada --- /dev/null +++ b/frontend/src/views/OverviewView.vue @@ -0,0 +1,518 @@ +<script setup> +import { ref, computed, onMounted, onUnmounted } from 'vue' +import { api } from '../api' +import { fmtTs, fmtSize } from '../utils/format' +import Icon from '../components/Icon.vue' + +const providers = ref([]) +const stats = ref({ generated_count: 0, generated_size_bytes: 0 }) +const userStats = ref({ total: 0, active: 0, disabled: 0, admins: 0, credits_total: 0, new_24h: 0, new_7d: 0, active_24h: 0 }) +// Everything time-windowed now comes pre-aggregated from /dashboard (server-side +// SQL) instead of being recomputed in the browser from the last 200 logs — which +// silently undercounted week / DAU / trend / top-N once volume grew past 200. +const dash = ref(null) +const logs = ref([]) // recent-activity feed only (small page) +const models = ref([]) // managed models, for the model-count card +const range = ref('day') // analytics window toggle: 'day' (24h) | 'week' (7d) +let timer = null + +async function refreshAll() { + const [p, s, u, d, l, m] = await Promise.all([ + api('/providers'), + api('/stats'), + api('/users'), + api('/dashboard'), + api('/logs?limit=20'), + api('/managed-models'), + ]) + providers.value = p.data?.data || [] + stats.value = s.data || {} + userStats.value = u.data?.stats || {} + dash.value = d.data || null + logs.value = l.data?.data || [] + models.value = m.data?.data || [] +} + +// ---- windowed event aggregates (from /dashboard) ---- +const EMPTY_WINDOW = { total: 0, success: 0, failed: 0, pending: 0, image: 0, video: 0, api: 0, web: 0, spent: 0 } +const day = computed(() => dash.value?.day || EMPTY_WINDOW) +const week = computed(() => dash.value?.week || EMPTY_WINDOW) +const successRate = computed(() => (day.value.total ? Math.round((day.value.success / day.value.total) * 100) : 0)) + +// Direction vs the previous 24h (24–48h ago) — a quiet day after a busy week is +// worth seeing. prev_day_total is computed server-side. +const dayDelta = computed(() => { + const cur = day.value.total + const prev = dash.value?.prev_day_total || 0 + if (!prev) return cur ? { pct: null, dir: 'up' } : { pct: null, dir: 'flat' } + const pct = Math.round(((cur - prev) / prev) * 100) + return { pct, dir: cur > prev ? 'up' : cur < prev ? 'down' : 'flat' } +}) + +const dau = computed(() => dash.value?.dau || 0) +const avg24hMs = computed(() => stats.value?.avg_elapsed_ms_24h ?? null) + +// ---- range-toggled top-N analytics (both windows ship in the payload, so the +// 24h/7d switch is instant — no re-fetch) ---- +const rangeLabel = computed(() => (range.value === 'week' ? '近 7 天' : '近 24h')) +const analytics = computed(() => dash.value?.analytics?.[range.value] || { models: [], failures: [], top_users: [] }) +const modelUsage = computed(() => analytics.value.models || []) +const usageMax = computed(() => Math.max(1, ...modelUsage.value.map((m) => m.count))) +const failures = computed(() => analytics.value.failures || []) +const topUsers = computed(() => analytics.value.top_users || []) +const topUserMax = computed(() => Math.max(1, ...topUsers.value.map((u) => u.spent))) + +// ---- 24h trend (always 24h) ---- +const hourBuckets = computed(() => dash.value?.hourly || Array.from({ length: 24 }, () => ({ image: 0, video: 0 }))) +const hourMax = computed(() => Math.max(1, ...hourBuckets.value.map((b) => b.image + b.video))) + +// ---- operations cards ---- +const cdk = computed(() => dash.value?.cdk || {}) +const invites = computed(() => dash.value?.invites || {}) +const checkin = computed(() => dash.value?.checkin || {}) + +// ---- token / model / provider summaries (from /providers + /managed-models) ---- +const tokens = computed(() => { + let active = 0, total = 0 + for (const p of providers.value) { + active += p.tokens_active || 0 + total += (p.tokens_active || 0) + (p.tokens_disabled || 0) + (p.tokens_quota || 0) + } + return { active, total } +}) + +const modelTypes = computed(() => { + const image = models.value.filter((m) => m.type === 'image').length + const video = models.value.filter((m) => m.type === 'video').length + return { image, video, total: models.value.length } +}) + +const providerHealth = computed(() => + providers.value.map((p) => { + const total = (p.tokens_active || 0) + (p.tokens_disabled || 0) + (p.tokens_quota || 0) + let status = 'down' + if ((p.tokens_active || 0) > 0) status = 'healthy' + else if (total > 0) status = 'warning' + return { ...p, status, total } + }) +) + +// One-glance system health badge derived from provider token availability. +const overallHealth = computed(() => { + const list = providerHealth.value + if (!list.length) return { label: '未配置 Provider', tone: 'down' } + if (list.some((p) => p.status === 'down')) return { label: 'Provider 异常', tone: 'down' } + if (list.some((p) => p.status === 'warning')) return { label: 'Provider 告警', tone: 'warning' } + return { label: '系统健康', tone: 'healthy' } +}) + +const recentLogs = computed(() => logs.value.slice(0, 12)) + +// ---- formatters ---- +function statusLabel(s) { return s === 'healthy' ? '健康' : s === 'warning' ? '告警' : '未配置' } +function statusDot(s) { return s === 'healthy' ? 'bg-emerald-400' : s === 'warning' ? 'bg-amber-400' : 'bg-rose-500' } +function statusPill(s) { + if (s === 'healthy') return 'bg-emerald-500/10 text-emerald-300 ring-emerald-400/30' + if (s === 'warning') return 'bg-amber-500/10 text-amber-300 ring-amber-400/30' + return 'bg-rose-500/10 text-rose-300 ring-rose-400/30' +} +function logDot(status) { + if (status === 'success') return 'bg-emerald-400' + if (status === 'pending') return 'bg-amber-400' + return 'bg-rose-500' +} +function fmtMs(ms) { + if (!ms) return '—' + if (ms < 1000) return ms + 'ms' + return (ms / 1000).toFixed(1) + 's' +} +function fmtInt(n) { return (n ?? 0).toLocaleString('zh-CN') } +function fmtCredits(n) { + const v = Number(n || 0) + if (v >= 10000) return (v / 10000).toFixed(1) + ' 万' + return fmtInt(Math.round(v)) +} + +onMounted(() => { + refreshAll() + timer = setInterval(refreshAll, 10000) +}) +onUnmounted(() => clearInterval(timer)) +</script> + +<template> + <section class="space-y-4"> + <!-- ===== Toolbar: overall health + refresh ===== --> + <div class="flex items-center justify-between gap-3"> + <span class="inline-flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium ring-1 tabular-nums" + :class="statusPill(overallHealth.tone)"> + <span class="w-1.5 h-1.5 rounded-full" :class="statusDot(overallHealth.tone)"></span> + {{ overallHealth.label }} + </span> + <button @click="refreshAll" class="btn-ghost">刷新</button> + </div> + + <!-- ===== KPI strip ===== --> + <div class="grid grid-cols-2 lg:grid-cols-4 gap-3"> + <!-- 用户 --> + <div class="card p-4"> + <div class="flex items-center justify-between"> + <span class="text-xs text-white/55">用户</span> + <span class="w-7 h-7 rounded-lg bg-indigo-500/15 text-indigo-300 grid place-items-center ring-1 ring-indigo-400/20"> + <Icon name="accounts" class="w-3.5 h-3.5" /> + </span> + </div> + <div class="text-2xl font-semibold tabular-nums mt-2">{{ fmtInt(userStats.total) }}</div> + <div class="text-[11px] text-white/45 mt-1">{{ userStats.active }} 活跃 · {{ userStats.admins }} 管理员</div> + <div class="text-[11px] text-emerald-300/80 mt-0.5"> + 今日新增 <span class="tabular-nums font-medium">{{ fmtInt(userStats.new_24h) }}</span> + · 7日 <span class="tabular-nums font-medium">{{ fmtInt(userStats.new_7d) }}</span> + </div> + </div> + + <!-- 24h --> + <div class="card p-4"> + <div class="flex items-center justify-between"> + <span class="text-xs text-white/55">近 24 小时生成</span> + <span class="w-7 h-7 rounded-lg bg-violet-500/15 text-violet-300 grid place-items-center ring-1 ring-violet-400/20"> + <Icon name="spark" class="w-3.5 h-3.5" /> + </span> + </div> + <div class="text-2xl font-semibold tabular-nums mt-2 flex items-baseline gap-2"> + <span>{{ fmtInt(day.total) }}</span> + <span v-if="dayDelta.pct != null" + class="text-[11px] font-medium tabular-nums" + :class="dayDelta.dir === 'up' ? 'text-emerald-300' : dayDelta.dir === 'down' ? 'text-rose-300' : 'text-white/45'"> + {{ dayDelta.dir === 'up' ? '↑' : dayDelta.dir === 'down' ? '↓' : '·' }}{{ Math.abs(dayDelta.pct) }}% + </span> + </div> + <div class="text-[11px] mt-1 flex flex-wrap gap-x-2"> + <span class="text-emerald-300 tabular-nums">{{ day.success }} 成功</span> + <span v-if="day.failed" class="text-rose-300 tabular-nums">{{ day.failed }} 失败</span> + <span v-if="day.pending" class="text-amber-300 tabular-nums">{{ day.pending }} 进行中</span> + </div> + <div v-if="day.total" class="text-[10px] text-white/40 mt-1 tabular-nums"> + Web {{ day.web }} · API {{ day.api }} · 图 {{ day.image }} · 视 {{ day.video }} + </div> + </div> + + <!-- 平均耗时 --> + <div class="card p-4"> + <div class="flex items-center justify-between"> + <span class="text-xs text-white/55">平均耗时</span> + <span class="w-7 h-7 rounded-lg bg-emerald-500/15 text-emerald-300 grid place-items-center ring-1 ring-emerald-400/20"> + <Icon name="refresh" class="w-3.5 h-3.5" /> + </span> + </div> + <div class="text-2xl font-semibold tabular-nums mt-2">{{ fmtMs(avg24hMs) }}</div> + <div class="text-[11px] text-white/45 mt-1">{{ successRate }}% 成功率 · 24h</div> + </div> + + <!-- 存储 --> + <div class="card p-4"> + <div class="flex items-center justify-between"> + <span class="text-xs text-white/55">产物存储</span> + <span class="w-7 h-7 rounded-lg bg-amber-500/15 text-amber-300 grid place-items-center ring-1 ring-amber-400/20"> + <Icon name="files" class="w-3.5 h-3.5" /> + </span> + </div> + <div class="text-2xl font-semibold tabular-nums mt-2">{{ fmtSize(stats.generated_size_bytes || 0) }}</div> + <div class="text-[11px] text-white/45 mt-1">{{ fmtInt(stats.generated_count) }} 个文件</div> + </div> + </div> + + <!-- ===== Secondary stat row ===== --> + <div class="grid grid-cols-2 lg:grid-cols-4 gap-3"> + <div class="card p-4"> + <div class="text-xs text-white/55">系统积分总和</div> + <div class="text-xl font-semibold tabular-nums mt-2">{{ fmtCredits(userStats.credits_total) }}</div> + <div class="text-[11px] text-white/45 mt-1"> + 所有用户余额累加 · <span class="text-amber-300">24h 消耗 {{ fmtCredits(day.spent) }}</span> + </div> + </div> + <div class="card p-4"> + <div class="text-xs text-white/55">Token</div> + <div class="text-xl font-semibold tabular-nums mt-2 flex items-baseline gap-1"> + <span class="text-emerald-300">{{ tokens.active }}</span> + <span class="text-white/30">/</span> + <span>{{ tokens.total }}</span> + </div> + <div class="text-[11px] text-white/45 mt-1">活跃 / 已配置</div> + </div> + <div class="card p-4"> + <div class="text-xs text-white/55">模型</div> + <div class="text-xl font-semibold tabular-nums mt-2">{{ modelTypes.total }}</div> + <div class="text-[11px] text-white/45 mt-1">图像 {{ modelTypes.image }} · 视频 {{ modelTypes.video }}</div> + </div> + <div class="card p-4"> + <div class="text-xs text-white/55">活跃用户 · 24h</div> + <div class="text-xl font-semibold tabular-nums mt-2">{{ fmtInt(dau) }}</div> + <div class="text-[11px] text-white/45 mt-1">近 7 天累计生成 {{ fmtInt(week.total) }}</div> + </div> + </div> + + <!-- ===== Operations row: CDK / 邀请 / 签到 ===== --> + <div class="grid grid-cols-1 sm:grid-cols-3 gap-3"> + <!-- 兑换码 --> + <div class="card p-4"> + <div class="flex items-center justify-between"> + <span class="text-xs text-white/55">兑换码 CDK</span> + <span class="w-7 h-7 rounded-lg bg-sky-500/15 text-sky-300 grid place-items-center ring-1 ring-sky-400/20"> + <Icon name="spark" class="w-3.5 h-3.5" /> + </span> + </div> + <div class="text-xl font-semibold tabular-nums mt-2 flex items-baseline gap-1"> + <span class="text-emerald-300">{{ fmtInt(cdk.active) }}</span> + <span class="text-white/30 text-sm">未用 /</span> + <span class="text-white/70 text-base">{{ fmtInt(cdk.redeemed) }} 已兑</span> + </div> + <div class="text-[11px] text-white/45 mt-1"> + 待兑积分 <span class="text-amber-300 tabular-nums">{{ fmtCredits(cdk.active_amount) }}</span> + · 已发出 <span class="tabular-nums">{{ fmtCredits(cdk.redeemed_amount) }}</span> + </div> + </div> + + <!-- 邀请 --> + <div class="card p-4"> + <div class="flex items-center justify-between"> + <span class="text-xs text-white/55">邀请</span> + <span class="w-7 h-7 rounded-lg bg-fuchsia-500/15 text-fuchsia-300 grid place-items-center ring-1 ring-fuchsia-400/20"> + <Icon name="accounts" class="w-3.5 h-3.5" /> + </span> + </div> + <div class="text-xl font-semibold tabular-nums mt-2 flex items-baseline gap-1"> + <span>{{ fmtInt(invites.total) }}</span> + <span class="text-white/40 text-sm">邀请注册</span> + </div> + <div class="text-[11px] text-white/45 mt-1"> + <span class="text-emerald-300 tabular-nums">{{ fmtInt(invites.completed) }}</span> 已达成奖励 + · 已发 <span class="text-amber-300 tabular-nums">{{ fmtCredits(invites.reward_paid) }}</span> 积分 + </div> + </div> + + <!-- 签到 --> + <div class="card p-4"> + <div class="flex items-center justify-between"> + <span class="text-xs text-white/55">今日签到</span> + <span class="w-7 h-7 rounded-lg bg-teal-500/15 text-teal-300 grid place-items-center ring-1 ring-teal-400/20"> + <Icon name="refresh" class="w-3.5 h-3.5" /> + </span> + </div> + <div class="text-xl font-semibold tabular-nums mt-2 flex items-baseline gap-1"> + <span>{{ fmtInt(checkin.today) }}</span> + <span class="text-white/40 text-sm">人</span> + </div> + <div class="text-[11px] text-white/45 mt-1"> + 发放 <span class="text-amber-300 tabular-nums">{{ fmtCredits(checkin.awarded_today) }}</span> 积分 + </div> + </div> + </div> + + <!-- ===== Provider health + 24h trend ===== --> + <div class="grid lg:grid-cols-2 gap-3"> + <div class="card"> + <div class="px-5 py-3 border-b border-white/[0.06] flex items-center justify-between"> + <h2 class="text-sm font-semibold">Provider 健康</h2> + <span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1" + :class="statusPill(overallHealth.tone)"> + <span class="w-1.5 h-1.5 rounded-full" :class="statusDot(overallHealth.tone)"></span> + {{ overallHealth.label }} + </span> + </div> + <div class="p-3"> + <div v-if="!providerHealth.length" class="text-center text-xs text-white/40 py-8">未注册 Provider</div> + <div v-else class="space-y-0.5"> + <div v-for="p in providerHealth" :key="p.name" + class="flex items-center gap-3 px-2 py-2 rounded-lg hover:bg-white/[0.04] transition-colors"> + <div class="flex-1 min-w-0"> + <div class="text-sm font-medium capitalize truncate">{{ p.name }}</div> + <div class="text-[11px] text-white/45 mt-0.5"> + {{ p.model_count }} 模型 · token {{ p.tokens_active }}/{{ p.total || 0 }} 活跃 + <span v-if="p.tokens_quota"> · {{ p.tokens_quota }} 限额</span> + <span v-if="p.tokens_disabled"> · {{ p.tokens_disabled }} 停用</span> + </div> + </div> + <span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1 tabular-nums" + :class="statusPill(p.status)"> + <span class="w-1.5 h-1.5 rounded-full" :class="statusDot(p.status)"></span> + {{ statusLabel(p.status) }} + </span> + </div> + </div> + </div> + </div> + + <div class="card"> + <div class="px-5 py-3 border-b border-white/[0.06] flex items-baseline justify-between"> + <h2 class="text-sm font-semibold">24 小时生成趋势</h2> + <div class="text-[11px] text-white/45 flex items-center gap-3"> + <span class="flex items-center gap-1"><span class="w-2 h-2 rounded-sm bg-indigo-400/80"></span>图像</span> + <span class="flex items-center gap-1"><span class="w-2 h-2 rounded-sm bg-fuchsia-400/80"></span>视频</span> + <span class="tabular-nums">峰值 {{ hourMax }}/h</span> + </div> + </div> + <div class="p-5"> + <div class="flex items-end gap-[3px] h-32"> + <div v-for="(b, i) in hourBuckets" :key="i" + class="group/bar relative flex-1 flex flex-col justify-end rounded-t overflow-visible" + :style="{ height: Math.max(4, ((b.image + b.video) / hourMax) * 100) + '%' }"> + <!-- hover tooltip --> + <div class="pointer-events-none absolute -top-9 left-1/2 -translate-x-1/2 z-10 hidden group-hover/bar:block + whitespace-nowrap rounded-md bg-black/90 ring-1 ring-white/10 px-2 py-1 text-[10px] text-white/90 tabular-nums"> + {{ 23 - i }}h 前 · 图 {{ b.image }} / 视 {{ b.video }} + </div> + <div v-if="b.video" class="bg-fuchsia-400/80 group-hover/bar:bg-fuchsia-400" :style="{ flex: b.video }"></div> + <div v-if="b.image" class="bg-indigo-400/80 group-hover/bar:bg-indigo-400" :style="{ flex: b.image }"></div> + <div v-if="!b.image && !b.video" class="bg-white/[0.06] group-hover/bar:bg-white/15 flex-1 rounded-t"></div> + </div> + </div> + <div class="flex justify-between text-[10px] text-white/40 mt-2 tabular-nums"> + <span>-24h</span><span>-18h</span><span>-12h</span><span>-6h</span><span>现在</span> + </div> + </div> + </div> + </div> + + <!-- ===== Analytics (range-toggled): top models / failures / spenders ===== --> + <div class="flex items-center justify-between gap-3 pt-1"> + <h2 class="text-sm font-semibold text-white/80">使用分析 · <span class="text-white/45 font-normal">{{ rangeLabel }}</span></h2> + <div class="inline-flex rounded-lg bg-white/[0.04] ring-1 ring-white/[0.08] p-0.5 text-xs"> + <button @click="range = 'day'" + class="px-3 py-1 rounded-md transition-colors" + :class="range === 'day' ? 'bg-white/10 text-white font-medium' : 'text-white/50 hover:text-white/80'"> + 近 24h + </button> + <button @click="range = 'week'" + class="px-3 py-1 rounded-md transition-colors" + :class="range === 'week' ? 'bg-white/10 text-white font-medium' : 'text-white/50 hover:text-white/80'"> + 近 7d + </button> + </div> + </div> + + <div class="grid lg:grid-cols-2 gap-3"> + <div class="card"> + <div class="px-5 py-3 border-b border-white/[0.06]"> + <h2 class="text-sm font-semibold">热门模型</h2> + </div> + <div class="p-5"> + <div v-if="!modelUsage.length" class="text-center text-xs text-white/40 py-6">尚无生成记录</div> + <div v-else class="space-y-3"> + <div v-for="m in modelUsage" :key="m.model" class="text-sm"> + <div class="flex items-baseline justify-between gap-3 mb-1"> + <span class="font-mono text-[12px] text-white/85 truncate">{{ m.model }}</span> + <span class="flex items-baseline gap-2 shrink-0"> + <span v-if="m.avg_ms" class="text-[10px] text-white/40 tabular-nums">{{ fmtMs(m.avg_ms) }}</span> + <span class="tabular-nums text-xs font-semibold">{{ m.count }}</span> + </span> + </div> + <div class="h-1.5 rounded-full bg-white/[0.06] overflow-hidden"> + <div class="h-full rounded-full bg-gradient-to-r from-violet-400 to-fuchsia-500" + :style="{ width: ((m.count / usageMax) * 100) + '%' }"></div> + </div> + </div> + </div> + </div> + </div> + + <div class="card"> + <div class="px-5 py-3 border-b border-white/[0.06]"> + <h2 class="text-sm font-semibold">失败原因 Top</h2> + </div> + <div class="p-5"> + <div v-if="!failures.length" class="text-center text-xs text-white/40 py-6">{{ rangeLabel }}内没有失败 — 一切正常</div> + <div v-else class="space-y-2"> + <div v-for="f in failures" :key="f.reason" + class="flex items-start gap-3 px-2 py-2 rounded-lg hover:bg-white/[0.04]"> + <span class="w-1.5 h-1.5 mt-1.5 rounded-full bg-rose-400 shrink-0"></span> + <div class="flex-1 min-w-0"> + <div class="text-xs text-white/85 break-all leading-snug">{{ f.reason }}</div> + </div> + <span class="tabular-nums text-xs font-semibold text-rose-300 shrink-0">×{{ f.count }}</span> + </div> + </div> + </div> + </div> + </div> + + <!-- ===== User consumption + spend summary ===== --> + <div class="grid lg:grid-cols-2 gap-3"> + <div class="card"> + <div class="px-5 py-3 border-b border-white/[0.06]"> + <h2 class="text-sm font-semibold">用户消耗 Top · {{ rangeLabel }}</h2> + </div> + <div class="p-5"> + <div v-if="!topUsers.length" class="text-center text-xs text-white/40 py-6">{{ rangeLabel }}内无消耗记录</div> + <div v-else class="space-y-3"> + <div v-for="u in topUsers" :key="u.user_id || u.name" class="text-sm"> + <div class="flex items-baseline justify-between gap-3 mb-1"> + <span class="text-[12px] text-white/85 truncate">{{ u.name }}</span> + <span class="flex items-baseline gap-2 shrink-0"> + <span class="text-[10px] text-white/40 tabular-nums">{{ u.count }} 次</span> + <span class="tabular-nums text-xs font-semibold text-amber-300">{{ fmtCredits(u.spent) }}</span> + </span> + </div> + <div class="h-1.5 rounded-full bg-white/[0.06] overflow-hidden"> + <div class="h-full rounded-full bg-gradient-to-r from-amber-400 to-orange-500" + :style="{ width: ((u.spent / topUserMax) * 100) + '%' }"></div> + </div> + </div> + </div> + </div> + </div> + + <div class="card"> + <div class="px-5 py-3 border-b border-white/[0.06]"> + <h2 class="text-sm font-semibold">积分消耗概览</h2> + </div> + <div class="p-5 grid grid-cols-2 gap-4"> + <div> + <div class="text-xs text-white/55">近 24 小时</div> + <div class="text-2xl font-semibold tabular-nums mt-1 text-amber-300">{{ fmtCredits(day.spent) }}</div> + <div class="text-[11px] text-white/40 mt-1">{{ day.success }} 次成功生成</div> + </div> + <div> + <div class="text-xs text-white/55">近 7 天</div> + <div class="text-2xl font-semibold tabular-nums mt-1 text-amber-300">{{ fmtCredits(week.spent) }}</div> + <div class="text-[11px] text-white/40 mt-1">{{ week.success }} 次成功生成</div> + </div> + <div> + <div class="text-xs text-white/55">单次均价</div> + <div class="text-xl font-semibold tabular-nums mt-1">{{ day.success ? fmtCredits(Math.round(day.spent / day.success)) : '—' }}</div> + <div class="text-[11px] text-white/40 mt-1">24h 平均</div> + </div> + <div> + <div class="text-xs text-white/55">消耗用户数</div> + <div class="text-xl font-semibold tabular-nums mt-1">{{ topUsers.length }}</div> + <div class="text-[11px] text-white/40 mt-1">{{ rangeLabel }}有消耗</div> + </div> + </div> + </div> + </div> + + <!-- ===== Recent activity (full width) ===== --> + <div class="card"> + <div class="px-5 py-3 border-b border-white/[0.06] flex items-baseline justify-between"> + <h2 class="text-sm font-semibold">最近活动</h2> + <router-link to="/admin/logs" class="text-[11px] text-white/55 hover:text-white">查看全部 →</router-link> + </div> + <div class="p-3"> + <div v-if="!recentLogs.length" class="text-center text-xs text-white/40 py-6">尚无活动</div> + <div v-else> + <div v-for="e in recentLogs" :key="e.id" + class="flex items-center gap-3 px-2 py-2 text-xs rounded-lg hover:bg-white/[0.03]"> + <span class="w-1.5 h-1.5 rounded-full shrink-0" :class="logDot(e.status)"></span> + <span class="text-[10px] uppercase tracking-wider font-medium w-9" + :class="e.kind === 'video' ? 'text-fuchsia-300' : 'text-indigo-300'"> + {{ e.kind === 'video' ? '视频' : '图像' }} + </span> + <span class="font-mono text-white/85 truncate w-40 shrink-0">{{ e.model }}</span> + <span class="text-white/55 truncate flex-1 min-w-0">{{ e.prompt }}</span> + <span class="text-white/40 tabular-nums whitespace-nowrap w-12 text-right">{{ fmtMs(e.elapsed_ms) }}</span> + <span class="text-white/40 whitespace-nowrap text-right tabular-nums">{{ fmtTs(e.ts) }}</span> + </div> + </div> + </div> + </div> + </section> +</template> diff --git a/frontend/src/views/PlaygroundView.vue b/frontend/src/views/PlaygroundView.vue new file mode 100644 index 0000000..8645652 --- /dev/null +++ b/frontend/src/views/PlaygroundView.vue @@ -0,0 +1,648 @@ +<script setup> +import { ref, computed, watch, onMounted, onUnmounted } from 'vue' +import { useRoute } from 'vue-router' +import { api, jsonBody } from '../api' +import { auth, refreshMe } from '../auth' +import { draft, applyJobToDraft } from '../playground' +import Icon from '../components/Icon.vue' +import SelectMenu from '../components/SelectMenu.vue' +import MediaLightbox from '../components/MediaLightbox.vue' +import { points, pointsLabel } from '../credits' +import { sortResolutions } from '../utils/format' + +const route = useRoute() + +// ---- credits: the logged-in user's REAL server-side balance ---- +const credits = computed(() => Number(auth.user?.credits || 0)) + +const allModels = ref([]) // managed-models list +const presets = ref([]) // video family presets + +// Seed every form field from the shared draft (module-level) so navigating +// away from the page and coming back keeps the prompt + selected model + +// params. Each local ref then syncs back into the draft on change. +const mode = ref(draft.mode || 'image') +const modelId = ref(draft.modelId || '') +const prompt = ref(draft.prompt || '') +const ratio = ref(draft.ratio || '') +const resolution = ref(draft.resolution || '') +const duration = ref(draft.duration || '') + +watch(mode, (v) => { draft.mode = v }) +watch(modelId, (v) => { draft.modelId = v }) +watch(prompt, (v) => { draft.prompt = v }) +watch(ratio, (v) => { draft.ratio = v }) +watch(resolution, (v) => { draft.resolution = v }) +watch(duration, (v) => { draft.duration = v }) + +const refImages = ref([]) // [{ name, dataUrl }] +const fileInput = ref(null) + +const busy = ref(false) +// submitting = run() owns the busy/current state end-to-end while a /generate +// call is in flight. The 2s poll() must NOT touch busy or current during this +// window, or it races run() and the controls flicker unlocked mid-generation. +const submitting = ref(false) +// Gateway-timeout statuses our BACKEND never emits — they mean a CDN/proxy +// (e.g. EdgeOne 524) gave up waiting while the synchronous /generate is STILL +// rendering server-side. Treat these as "still running", NOT a failure: keep the +// controls locked and let poll() follow the live job to completion (出图). +const GATEWAY_TIMEOUT = new Set([0, 408, 504, 520, 521, 522, 523, 524, 525]) +const error = ref('') +const statusText = ref('') + +// Only ever show the latest generation on the right side. Each new run +// replaces it; the persistent history lives at /logs (UserLogsView). +// `current` is restored from the server on mount and refreshed via /jobs/mine +// polling, so a reload, a parallel tab, or a different browser sees the same +// in-flight job and the same final result without re-running anything. +const current = ref(null) +const lightbox = ref(null) +const toast = ref('') +let pollTimer = null + +// ---- derived ---- +const models = computed(() => + allModels.value.filter((m) => m.enabled !== false && m.type === mode.value), +) +const modelOptions = computed(() => + models.value.map((m) => ({ value: m.id, label: m.name || m.id })), +) +const model = computed(() => allModels.value.find((m) => m.id === modelId.value) || null) +const familyPreset = computed(() => { + if (mode.value !== 'video' || !model.value) return null + return presets.value.find((p) => p.key === model.value.id) || null +}) + +const ratios = computed(() => { + const fromModel = model.value?.ratios || [] + if (fromModel.length) return fromModel + return mode.value === 'video' ? ['16x9'] : ['1:1'] +}) +// Firefly Image 5 instruct-edit derives the aspect ratio from the reference +// image — hide the ratio picker (backend also omits aspectRatio) when a ref is +// attached, otherwise the request is rejected with a validation error. +const showRatio = computed(() => !(modelId.value === 'firefly-image-5' && refImages.value.length > 0)) +const resolutions = computed(() => { + const fromModel = model.value?.resolutions || [] + if (fromModel.length) return sortResolutions(fromModel) + // Legacy record with no declared tiers: fall back to the priced tiers so we + // never offer (or default to) a resolution the server has no price for. + const priced = Object.keys(model.value?.prices || {}) + if (priced.length) return sortResolutions(priced) + return mode.value === 'video' ? ['720p'] : ['1K'] +}) +const durations = computed(() => { + // duration_prices arrives as a JSON object whose keys Go sorts alphabetically + // ("10s" before "5s"). Re-sort by the numeric seconds so the shortest is first. + const keys = Object.keys(model.value?.duration_prices || {}) + .sort((a, b) => parseFloat(a) - parseFloat(b)) + if (keys.length) return keys + return familyPreset.value?.durations || ['5s'] +}) + +const maxRefs = computed(() => { + if (mode.value === 'video') { + const a = Number(familyPreset.value?.max_reference_images || 0) + const b = Number(model.value?.max_reference_images || 0) + return Math.max(a, b) + } + // Image-to-image: honor the model's configured max (gpt-image-2=3, + // seedream-4.5=6, flux-klein-2=4 …). Fall back to 1 when image_to_image is on + // but no count was set. + const m = Number(model.value?.max_reference_images || 0) + if (m > 0) return m + return model.value?.image_to_image ? 1 : 0 +}) +const refMode = computed(() => familyPreset.value?.reference_mode || model.value?.reference_mode || 'none') +// Most video models (veo31, luma) support pure text2video, so refs are optional. +// A model can opt into strict image-to-video by declaring `requires_reference` +// in its preset (e.g. runway-gen4-turbo) — then a first-frame image is mandatory. +const refsRequired = computed(() => + mode.value === 'video' && !!familyPreset.value?.requires_reference) + +// ---- price (per generation, derived from selected model + params) ---- +// 代理用户走代理价:某档设了代理价就用它,否则回退普通价(支持的档位始终由普通价决定)。 +const isAgent = computed(() => auth.user?.role === 'agent') +function tierPrice(normalMap, agentMap, key) { + const n = (normalMap || {})[key] + if (n == null) return null // 不支持该档(由普通价决定) + if (isAgent.value) { + const a = (agentMap || {})[key] + if (a != null) return Number(a) + } + return Number(n) +} +const price = computed(() => { + if (!model.value) return null + const m = model.value + if (mode.value === 'video') { + const rp = tierPrice(m.prices, m.prices_agent, resolution.value) + const dp = tierPrice(m.duration_prices, m.duration_prices_agent, duration.value) + if (rp == null || dp == null) return null + return rp + dp + } + return tierPrice(m.prices, m.prices_agent, resolution.value) +}) +const priceLabel = computed(() => price.value == null ? '—' : pointsLabel(price.value)) +const canAfford = computed(() => price.value == null || credits.value >= price.value) + +// ---- helpers ---- +function firstOf(arr) { + return (arr && arr.length) ? arr[0] : '' +} +// Selecting a model (or switching image/video) resets each picker to that +// model's FIRST tier — the default should always be the first option, not +// whatever was carried over from the previously-selected model. +function applyModelDefaults() { + ratio.value = firstOf(ratios.value) + resolution.value = firstOf(resolutions.value) + duration.value = firstOf(durations.value) + // 切换模型保留已上传的参考图,只按新模型的上限裁剪(上限为 0 则清空)。 + if (refImages.value.length > maxRefs.value) { + refImages.value = refImages.value.slice(0, maxRefs.value) + } +} +function selectModel(id) { + modelId.value = id + applyModelDefaults() +} + +function setMode(m) { + if (mode.value === m) return + mode.value = m + // pick a default model of the new kind, if any + const first = allModels.value.find((x) => x.enabled !== false && x.type === m) + modelId.value = first?.id || '' + applyModelDefaults() +} + +function openPicker() { fileInput.value && fileInput.value.click() } +// Backend rejects reference images over 8MB (maxReferenceImageBytes). Enforce it +// here at pick time so an oversized image fails fast with a clear message instead +// of charging + failing upstream after the upload. +const MAX_REF_BYTES = 8 * 1024 * 1024 +function onFiles(ev) { + const files = Array.from(ev.target.files || []) + const room = Math.max(0, maxRefs.value - refImages.value.length) + const tooBig = [] + let added = 0 + for (const f of files) { + if (added >= room) break + if (f.size > MAX_REF_BYTES) { tooBig.push(f.name); continue } + const reader = new FileReader() + reader.onload = () => refImages.value.push({ name: f.name, dataUrl: reader.result }) + reader.readAsDataURL(f) + added++ + } + error.value = tooBig.length + ? `图片超过 8MB 已跳过:${tooBig.join('、')}(请压缩后再传)` + : '' + if (ev.target) ev.target.value = '' +} +function removeRef(i) { refImages.value.splice(i, 1) } + +// Re-hydrate reference thumbnails from server URLs (after a reload). Fetches +// each /images URL (same-origin, cookie-authed) and converts to a data URL so +// the thumbnail renders AND the ref can be re-submitted unchanged. Shared by +// image and video — both persist their refs the same way server-side. +// Re-display refs by URL only — the thumbnail renders straight from /images/<ref>. +// We DON'T fetch+convert here; conversion to base64 happens lazily at submit time +// (refToBase64), so a ref that's only viewed never needs a network round-trip. +function restoreRefs(urls) { + if (!Array.isArray(urls) || !urls.length) return + if (refImages.value.length) return // don't clobber refs the user already added + refImages.value = urls.map((u) => ({ name: 'ref', url: u })) +} + +// refToBase64 yields the raw base64 the backend expects, from either a freshly +// uploaded ref (dataUrl) or a restored one (url → fetch). Returns '' on failure. +async function refToBase64(r) { + try { + if (r.dataUrl) return r.dataUrl.replace(/^data:[^,]*,/, '') + if (r.url) { + const blob = await (await fetch(r.url)).blob() + const dataUrl = await new Promise((res, rej) => { + const fr = new FileReader() + fr.onload = () => res(fr.result) + fr.onerror = rej + fr.readAsDataURL(blob) + }) + return dataUrl.replace(/^data:[^,]*,/, '') + } + } catch { /* fall through */ } + return '' +} + +let toastTimer = null +function flash(msg) { + toast.value = msg + clearTimeout(toastTimer) + toastTimer = setTimeout(() => (toast.value = ''), 1800) +} + +async function copyLink(url) { + try { + const abs = url.startsWith('http') ? url : location.origin + url + await navigator.clipboard.writeText(abs) + flash('链接已复制') + } catch { + flash('复制失败') + } +} + +// ---- generate ---- +async function run() { + if (!modelId.value) { error.value = '请选择模型'; return } + if (!prompt.value.trim()) { error.value = '请输入提示词'; return } + if (refsRequired.value && refImages.value.length < 1) { + error.value = '该视频模型需要至少 1 张参考图 (首帧)' + return + } + if (price.value == null) { + error.value = '该参数组合未定价 (留空 = 不支持)' + return + } + if (!canAfford.value) { + error.value = `积分不足 — 需要 ${pointsLabel(price.value)},余额 ${pointsLabel(credits.value)}` + return + } + const job = { + id: Math.random().toString(36).slice(2, 10), + model: modelId.value, + kind: mode.value, + prompt: prompt.value, + ratio: ratio.value, + resolution: resolution.value, + duration: mode.value === 'video' ? duration.value : '', + refs: refImages.value.length, + status: 'pending', + url: '', + error: '', + elapsed_ms: 0, + charged: price.value, + ts: Date.now(), + } + current.value = job + + busy.value = true + submitting.value = true + error.value = '' + statusText.value = mode.value === 'video' ? '生成视频中 (约 1–3 分钟)…' : '生成中…' + + try { + // Optimistically deduct the price from the displayed balance right away. The + // server debits BEFORE generating (which can take minutes for video), so + // otherwise 余额 looks unchanged the whole time. The success response carries + // the authoritative balance (reconciled below); a failure refunds + refreshMe. + if (auth.user && price.value != null) { + auth.user.credits = Math.max(0, Number(auth.user.credits || 0) - price.value) + } + + const payload = { + model: modelId.value, + prompt: prompt.value, + ratio: ratio.value, + resolution: resolution.value, + } + if (mode.value === 'video') payload.duration = duration.value + if (refImages.value.length) { + // Backend accepts raw base64 only — convert each ref (uploaded dataUrl or + // restored /images URL) to base64 at submit time. + const refs = await Promise.all(refImages.value.map(refToBase64)) + payload.reference_images = refs.filter(Boolean) + } + + // Single charged call: the server debits the price atomically BEFORE + // generating and refunds on failure, so the client can't skip the charge. + const r = await api('/generate', jsonBody('POST', payload)) + + if (r.ok && r.data?.url) { + job.status = 'done' + job.url = r.data.url + job.elapsed_ms = r.data.elapsed_ms + job.charged = r.data.charged ?? price.value + if (auth.user && r.data.credits != null) auth.user.credits = r.data.credits + statusText.value = `完成 · 扣费 ${pointsLabel(job.charged)} · ${(r.data.elapsed_ms / 1000).toFixed(1)}s · 余额 ${pointsLabel(credits.value)}` + busy.value = false // 出图 → 解锁 + } else if (GATEWAY_TIMEOUT.has(r.status)) { + // CDN/代理回源超时(如 EdgeOne 524)—— 后端仍在生成。不当失败、不解锁: + // 保持 busy=true,交给下面的 poll() + 2s 轮询跟到出图("不出图就不闪")。 + statusText.value = mode.value === 'video' ? '生成视频中 (约 1–3 分钟)…' : '生成中…' + } else { + // 真失败:服务端已退款,resync 余额并解锁。 + await refreshMe() + job.status = 'failed' + job.error = r.data?.detail || `失败 (${r.status})` + statusText.value = '' + busy.value = false // 真失败 → 解锁 + } + } finally { + // Hand control back to poll(); busy is left as set above (locked when the + // job is still rendering after a gateway timeout). + submitting.value = false + } + // Sync real server state: poll() picks up the live pending job (replacing our + // optimistic one with the real id) and will unlock + show the result the + // moment it finishes — so a 524 mid-flight never leaves the UI unlocked. + poll() +} + +// Recover the current generation from the server: any pending job for this +// user lives in event_log, so reload / parallel tab / parallel browser can +// all see the same in-flight state and the same final result. +async function poll() { + // While run() is mid-submit it fully owns busy/current — don't race it. + if (submitting.value) return + const r = await api('/jobs/mine') + if (!r.ok) return + const { pending, latest } = r.data || {} + if (pending) { + busy.value = true + if (!statusText.value) { + statusText.value = pending.kind === 'video' ? '生成视频中 (约 1–3 分钟)…' : '生成中…' + } + if (!current.value || current.value.id !== pending.id) { + current.value = { ...pending } + // Replay the pending job's params onto the form so a fresh tab shows + // what's cooking — and writes them into the cross-component draft. + applyJobToDraft(pending) + mode.value = pending.kind === 'video' ? 'video' : 'image' + modelId.value = pending.model || modelId.value + prompt.value = pending.prompt || prompt.value + ratio.value = pending.ratio || ratio.value + resolution.value = pending.resolution || resolution.value + duration.value = pending.duration || duration.value + // Re-display the uploaded reference image(s) after a reload. They're + // served (cookie-authed) from /images; re-fetch into data URLs so the + // thumbnails show AND the refs stay re-submittable if the user regenerates. + restoreRefs(pending.reference_urls) + } + return + } + // No pending. If our locally-shown job just finished on the server (same id, + // status flipped to success/failed), promote it to the result view — this is + // the live "I'm watching my own generation finish" case and stays. + if (current.value && current.value.status === 'pending' && latest && latest.id === current.value.id) { + current.value = { ...latest, status: latest.status === 'success' ? 'done' : latest.status } + if (latest.url) current.value.url = latest.url + busy.value = false + statusText.value = '' + refreshMe() + return + } + // Intentionally NO restore of an already-finished result on first paint / + // navigation: the playground only ever shows an in-progress job (or the one + // that just completed while watched). Past results live in /记录 (logs), not + // re-echoed onto a freshly opened workspace. +} + +function onKey(e) { if (e.key === 'Escape') lightbox.value = null } + +onMounted(async () => { + refreshMe() // pull the latest real balance + const [mm, pp] = await Promise.all([api('/managed-models'), api('/video-presets')]) + allModels.value = mm.data?.data || [] + presets.value = pp.data?.data || [] + // Pre-fill from query string (?prompt=...&model=...) — used by the home + // page's example cards to seed the form in one click. + const qPrompt = String(route.query.prompt || '') + const qModel = String(route.query.model || '') + if (qPrompt) prompt.value = qPrompt + let selected = null + if (qModel) { + selected = allModels.value.find((m) => m.id === qModel && m.enabled !== false) + } + // If the draft already points at a still-available model AND no fresher + // intent came in from the URL, keep the draft as-is. Otherwise fall back to + // the first usable model. + const draftModel = !qModel && modelId.value + ? allModels.value.find((m) => m.id === modelId.value && m.enabled !== false) + : null + if (!selected && !draftModel) { + selected = allModels.value.find((m) => m.enabled !== false && m.type === 'image') + || allModels.value.find((m) => m.enabled !== false) + } + // Always re-apply defaults — even when restoring the persisted draft model — + // so a stale ratio/resolution that the model no longer supports (e.g. a saved + // "2K" for a model that's now 1K-only) is normalized to a valid, priced tier + // instead of being sent as-is and rejected with "unsupported or unpriced". + const chosen = selected || draftModel + if (chosen) { + mode.value = chosen.type + modelId.value = chosen.id + applyModelDefaults() + } + window.addEventListener('keydown', onKey) + // Restore any in-flight or recently-finished job for this user, then poll + // every 2s so a parallel tab / device sees changes within one tick. + poll() + pollTimer = setInterval(poll, 2000) +}) +onUnmounted(() => { + window.removeEventListener('keydown', onKey) + clearInterval(pollTimer) +}) +</script> + +<template> + <section class="theme-text grid lg:grid-cols-[420px_1fr] gap-6"> + <!-- LEFT: controls — every interactive element accepts :disabled="busy" + so the form locks the moment a generation kicks off. Reload, parallel + tab and tab-switch all see the same locked state via poll(). --> + <div class="card p-5 space-y-5 lg:sticky lg:top-24 self-start"> + <!-- mode switch --> + <div class="grid grid-cols-2 gap-2 p-1 bg-slate-100 rounded-xl"> + <button @click="setMode('image')" type="button" :disabled="busy" + class="rounded-lg py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed" + :class="mode === 'image' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'"> + <Icon name="files" class="w-4 h-4 inline -mt-0.5" /> 生图 + </button> + <button @click="setMode('video')" type="button" :disabled="busy" + class="rounded-lg py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed" + :class="mode === 'video' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'"> + <Icon name="video" class="w-4 h-4 inline -mt-0.5" /> 生视频 + </button> + </div> + + <!-- model --> + <div> + <label class="block text-xs font-medium text-slate-500 mb-1.5">模型</label> + <SelectMenu v-if="models.length" :model-value="modelId" @update:model-value="selectModel" + :options="modelOptions" placeholder="选择模型" mono :disabled="busy" /> + <div v-else class="rounded-lg border border-dashed border-slate-200 px-3 py-4 text-xs text-slate-400 text-center"> + 还没有可用的{{ mode === 'video' ? '视频' : '图像' }}模型 · + <router-link to="/admin/models" class="text-slate-700 underline">去添加</router-link> + </div> + </div> + + <!-- prompt --> + <div> + <label class="block text-xs font-medium text-slate-500 mb-1.5">提示词</label> + <textarea v-model="prompt" rows="4" :disabled="busy" class="field resize-none disabled:opacity-60 disabled:cursor-not-allowed" + placeholder="描述想要的画面…如:黄昏时分,金色麦田里奔跑的金毛猎犬,电影感"></textarea> + </div> + + <!-- ratio + res + duration. Single-option controls are hidden — the + value is still set from the model's defaults and sent to the API, + so the user doesn't have to acknowledge a choice they don't have. --> + <div v-if="ratios.length > 0 && showRatio"> + <label class="block text-xs font-medium text-slate-500 mb-1.5">比例</label> + <div class="flex flex-wrap gap-1.5"> + <button v-for="r in ratios" :key="r" type="button" @click="ratio = r" :disabled="busy" + class="rounded-lg px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + :class="ratio === r ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'"> + {{ r }} + </button> + </div> + </div> + + <div v-if="resolutions.length > 0"> + <label class="block text-xs font-medium text-slate-500 mb-1.5">{{ mode === 'video' ? '分辨率' : '画质' }}</label> + <div class="flex flex-wrap gap-1.5"> + <button v-for="r in resolutions" :key="r" type="button" @click="resolution = r" :disabled="busy" + class="rounded-lg px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + :class="resolution === r ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'"> + {{ r }} + </button> + </div> + </div> + + <div v-if="mode === 'video' && durations.length > 0"> + <label class="block text-xs font-medium text-slate-500 mb-1.5">时长</label> + <div class="flex flex-wrap gap-1.5"> + <button v-for="d in durations" :key="d" type="button" @click="duration = d" :disabled="busy" + class="rounded-lg px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + :class="duration === d ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'"> + {{ d }} + </button> + </div> + </div> + + <!-- reference images --> + <div v-if="maxRefs > 0"> + <label class="block text-xs font-medium text-slate-500 mb-1.5"> + 参考图 + <span class="text-slate-400 font-normal"> + (最多 {{ maxRefs }} 张{{ refMode === 'frame' && mode === 'video' ? (maxRefs >= 2 ? ' · 首帧/末帧' : ' · 首帧') : '' }} · 单张 ≤8MB) + </span> + <span v-if="refsRequired" class="text-rose-500">*</span> + </label> + <div class="flex gap-2 flex-wrap items-start"> + <div v-for="(img, i) in refImages" :key="i" + class="relative w-20 h-20 rounded-lg overflow-hidden border border-slate-200 bg-slate-50 transition-all" + :class="busy ? 'opacity-60 grayscale pointer-events-none' : ''"> + <img :src="img.dataUrl || img.url" class="w-full h-full object-cover" /> + <button type="button" @click="removeRef(i)" :disabled="busy" + class="absolute top-1 right-1 w-5 h-5 rounded-full bg-slate-900/70 text-white hover:bg-rose-500 grid place-items-center disabled:opacity-40 disabled:cursor-not-allowed"> + <Icon name="close" class="w-3 h-3" /> + </button> + <div v-if="refMode === 'frame' && mode === 'video' && maxRefs >= 2" + class="absolute bottom-0 inset-x-0 text-[10px] text-white bg-slate-900/60 text-center py-0.5"> + {{ i === 0 ? '首帧' : (i === 1 ? '末帧' : '') }} + </div> + </div> + <button v-if="refImages.length < maxRefs" type="button" @click="openPicker" :disabled="busy" + class="w-20 h-20 rounded-lg border-2 border-dashed border-slate-200 text-slate-400 hover:bg-slate-50 hover:border-slate-300 grid place-items-center disabled:opacity-40 disabled:cursor-not-allowed"> + <Icon name="plus" class="w-5 h-5" /> + </button> + </div> + <input ref="fileInput" type="file" accept="image/*" multiple class="hidden" @change="onFiles" /> + </div> + + <button @click="run" :disabled="busy || !models.length || price == null || !canAfford" + class="btn-primary w-full !py-3 flex items-center justify-center gap-2 leading-none"> + <Icon name="spark" class="w-4 h-4 shrink-0" /> + <span class="leading-none">{{ busy ? (mode === 'video' ? '生成中…请耐心等待' : '生成中…') : '生成' }}</span> + <span v-if="!busy && price != null" class="text-xs opacity-70 tabular-nums leading-none">· {{ priceLabel }}</span> + <span v-if="!busy && price != null && !canAfford" class="text-xs text-rose-200 leading-none">积分不足</span> + </button> + + <!-- Validation / upload errors (model/prompt/ref/price/credits/oversized + image). The `error` ref had no render target before, so these messages + were silently swallowed. --> + <p v-if="error" class="text-xs text-rose-500 break-all">{{ error }}</p> + + </div> + + <!-- RIGHT: single latest result (replaces on each new generation). + min-w-0: the 1fr grid track defaults to min-width:auto, so a long + unbroken prompt would otherwise blow the column wider than the page + (truncate can't shrink a track that won't shrink). --> + <div class="space-y-4 min-w-0"> + <div v-if="!current && !busy" + class="card p-14 grid place-items-center text-slate-400 text-center"> + <span class="w-16 h-16 rounded-2xl bg-slate-100 grid place-items-center mb-4"> + <Icon name="spark" class="w-7 h-7 text-slate-400" /> + </span> + <p class="text-sm">还没有生成过 — 在左侧写提示词,点击「生成」</p> + <router-link to="/logs" class="text-xs text-slate-500 hover:text-white mt-3 transition-colors">查看历史记录 →</router-link> + </div> + + <div v-else-if="current" class="card overflow-hidden"> + <div class="px-5 py-3 border-b border-slate-100 flex items-center justify-between gap-3"> + <div class="min-w-0"> + <div class="text-sm font-medium line-clamp-2 break-words">{{ current.prompt }}</div> + <div class="text-[11px] text-slate-400 mt-0.5 font-mono"> + {{ current.model }} · {{ current.ratio }} · {{ current.resolution }} + <span v-if="current.kind === 'video'"> · {{ current.duration }}</span> + <span v-if="current.elapsed_ms"> · {{ (current.elapsed_ms / 1000).toFixed(1) }}s</span> + </div> + </div> + <!-- only when a finished result exists — hidden while pending/failed --> + <div v-if="current.url && current.status !== 'pending' && current.status !== 'failed'" + class="flex items-center gap-1.5 shrink-0"> + <a :href="current.url" :download="''" class="btn-soft" title="下载"> + <Icon name="download" class="w-3.5 h-3.5" /> + </a> + <button @click="copyLink(current.url)" class="btn-soft" title="复制链接"> + <Icon name="copy" class="w-3.5 h-3.5" /> + </button> + </div> + </div> + + <div class="bg-slate-50 grid place-items-center min-h-[260px]"> + <div v-if="current.status === 'pending'" class="text-sm text-slate-400 py-12 flex flex-col items-center gap-2"> + <span class="w-10 h-10 rounded-xl bg-white grid place-items-center animate-pulse"> + <Icon name="spark" class="w-4 h-4 text-slate-400" /> + </span> + {{ statusText || '生成中…' }} + </div> + <div v-else-if="current.status === 'failed'" class="text-sm text-rose-600 py-12 px-5 max-w-xl text-center"> + <div class="font-medium mb-1">生成失败</div> + <div class="text-xs text-rose-500 break-all">{{ current.error }}</div> + </div> + <template v-else> + <video v-if="current.kind === 'video'" :src="current.url" controls + class="max-w-full max-h-[600px] object-contain" /> + <img v-else :src="current.url" @click="lightbox = current" + class="max-w-full max-h-[600px] object-contain cursor-zoom-in" /> + </template> + </div> + </div> + </div> + + <!-- Lightbox — shared component, consistent with 图片管理 / 日志 --> + <MediaLightbox + v-if="lightbox" + :src="lightbox.url" + :kind="lightbox.kind" + :prompt="lightbox.prompt" + :meta="[lightbox.model, lightbox.ratio, lightbox.resolution, (lightbox.kind === 'video' ? lightbox.duration : '')].filter(Boolean).join(' · ')" + :download-name="(lightbox.url || '').split('/').pop()" + @close="lightbox = null" /> + + <!-- Toast --> + <transition name="fade"> + <div v-if="toast" + class="fixed bottom-6 left-1/2 -translate-x-1/2 z-[60] bg-slate-900 text-white text-xs px-4 py-2 rounded-lg shadow-lg"> + {{ toast }} + </div> + </transition> + </section> +</template> + +<style scoped> +.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; } +.fade-enter-from, .fade-leave-to { opacity: 0; } +</style> diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue new file mode 100644 index 0000000..4a50d00 --- /dev/null +++ b/frontend/src/views/SettingsView.vue @@ -0,0 +1,364 @@ +<script setup> +import { ref, computed, onMounted } from 'vue' +import { useRouter } from 'vue-router' +import { auth, refreshMe, logout as authLogout } from '../auth' +import { api, jsonBody } from '../api' +import Icon from '../components/Icon.vue' +import { points, pointsLabel } from '../credits' +import { site } from '../site' + +const router = useRouter() + +// Pull the latest server-side credits + check-in state when the page opens +// (so an admin adjustment, or a check-in from another tab, shows up). +onMounted(refreshMe) + +// Hide the check-in card when the admin has disabled the feature, and read the +// daily reward amount from the same config (admin-configurable, not hardcoded). +const checkinEnabled = ref(true) +const checkinReward = ref(3) // fallback until /auth/config loads +onMounted(async () => { + const r = await api('/auth/config') + if (r.ok) { + checkinEnabled.value = r.data.checkin_enabled !== false + checkinReward.value = Number(r.data.checkin_reward) || 0 + } +}) + +// ---- API Key (REAL: minted + verified server-side; OpenAI-compatible) ---- +// The full plaintext is returned only once, right after minting. On reload we +// only have the server's masked preview (the plaintext is never stored). +const apiKey = ref('') // full plaintext — present only right after minting +const keyPreview = ref('') // masked preview from the server (persists) +const apiKeyRevealed = ref(false) +const hasKey = computed(() => !!apiKey.value || !!keyPreview.value) + +async function loadKey() { + const r = await api('/auth/api-key') + if (r.ok) keyPreview.value = r.data?.key?.key_preview || '' +} +onMounted(loadKey) + +// Uniform mask for the hidden / persisted state — always "***" so the just-minted +// preview and the server-stored preview read the same (no sk-xxx…yyyy mismatch). +const maskedKey = computed(() => (apiKey.value || keyPreview.value) ? '***' : '') + +async function generateKey() { + if (hasKey.value && !confirm('已有 Key — 重新生成会让旧 Key 立刻失效,确认?')) return + const r = await api('/auth/api-key', jsonBody('POST', {})) + if (!r.ok) { toast(r.data?.detail || '生成失败'); return } + apiKey.value = r.data.key + keyPreview.value = r.data.preview || '' + apiKeyRevealed.value = true + toast('新 Key 已生成 — 请立刻复制保存(只显示这一次)') +} + +async function copyKey() { + if (!hasKey.value) return + try { + await navigator.clipboard.writeText(apiKey.value || keyPreview.value) + toast(apiKey.value ? '已复制完整 Key' : '完整 Key 仅生成时显示一次,这里只能复制预览') + } catch { toast('复制失败') } +} + +async function clearKey() { + if (!confirm('清除 Key? 之后用该 Key 的调用都会失败。')) return + const r = await api('/auth/api-key', { method: 'DELETE' }) + if (!r.ok) { toast(r.data?.detail || '清除失败'); return } + apiKey.value = ''; keyPreview.value = ''; apiKeyRevealed.value = false + toast('已清除 API Key') +} + +// ---- Password (real: verifies current password server-side) ---- +const pwdForm = ref({ current: '', next: '', confirm: '' }) +const pwdSubmitting = ref(false) +const pwdError = ref('') +async function changePwd() { + pwdError.value = '' + if (!pwdForm.value.current) { pwdError.value = '请输入当前密码'; return } + const next = pwdForm.value.next || '' + if (next.length < 8 || next.length > 24) { pwdError.value = '新密码长度需为 8-24 位'; return } + if (!/[A-Z]/.test(next) || !/[a-z]/.test(next) || !/\d/.test(next) || !/[()~!@#$%^&*\-_=|{}\[\]:;'<>.,?/]/.test(next)) { + pwdError.value = '新密码必须包含大写字母、小写字母、数字和符号' + return + } + if (pwdForm.value.next !== pwdForm.value.confirm) { pwdError.value = '两次输入的新密码不一致'; return } + pwdSubmitting.value = true + const r = await api('/auth/change-password', jsonBody('POST', { + current_password: pwdForm.value.current, + new_password: pwdForm.value.next, + })) + pwdSubmitting.value = false + if (!r.ok) { pwdError.value = r.data?.detail || '修改失败'; return } + pwdForm.value = { current: '', next: '', confirm: '' } + toast('密码已更新') +} + +// ---- Credits: the REAL server-side balance of the logged-in user ---- +// (admin adjustments in 用户管理 write this same field). Top-up is via CDK. +const balance = computed(() => Number(auth.user?.credits || 0)) + +const cdkCode = ref('') +const cdkBusy = ref(false) +const cdkError = ref('') +async function redeemCdk() { + cdkError.value = '' + const code = cdkCode.value.trim() + if (!code) { cdkError.value = '请输入兑换码'; return } + cdkBusy.value = true + const r = await api('/auth/redeem-cdk', jsonBody('POST', { code })) + cdkBusy.value = false + if (!r.ok) { cdkError.value = r.data?.detail || '兑换失败'; return } + if (auth.user) auth.user.credits = r.data.credits + cdkCode.value = '' + toast(`兑换成功 +${Number(r.data.amount).toLocaleString('en-US')} 积分`) +} + +// ---- Daily check-in ---- +// Reward amount lives in `checkinReward` (loaded from /auth/config above). + +// Streak + "checked today" come from the server account (date authority is +// the backend, so timezone quirks can't desync the disabled state). +const streak = computed(() => Number(auth.user?.checkin_streak || 0)) +const checkedToday = computed(() => !!auth.user?.checkin_today) +const last7 = computed(() => { + const out = [] + const today = new Date() + for (let i = 6; i >= 0; i--) { + const d = new Date(today); d.setDate(today.getDate() - i) + const ds = d.toISOString().slice(0, 10) + const isToday = i === 0 + // if streak >= i+1 and today checked, then day (today - i) was within streak + const lit = checkedToday.value && i < streak.value + out.push({ ds, day: d.getDate(), isToday, lit }) + } + return out +}) + +async function checkin() { + if (checkedToday.value) return + const r = await api('/auth/checkin', jsonBody('POST', {})) + if (!r.ok) { toast(r.data?.detail || '签到失败'); return } + await refreshMe() // refresh real balance + streak + checkin_today + const d = r.data || {} + if (d.already) { toast('今日已签到'); return } + toast(`签到成功 +${pointsLabel(d.awarded)} → 余额 ${pointsLabel(balance.value)}`) +} + +// ---- Logout (real: ends the server session, then returns home) ---- +async function logout() { + if (!confirm('确定要退出登录?')) return + await authLogout() + // Clear local-only leftovers (API key + legacy demo keys). Credits/streak are + // computed from auth.user, which authLogout() has already cleared. + const stale = ['gw_api_key', 'gw_credits', 'gw_checkin_last', 'gw_checkin_streak', + 'gw_invite_code', 'gw_invite_count', 'gw_invite_earned'] + stale.forEach((k) => localStorage.removeItem(k)) + apiKey.value = '' + toast('已退出登录') + setTimeout(() => router.push('/'), 600) +} + +// ---- Toast ---- +const toastMsg = ref('') +let toastTimer = null +function toast(m) { + toastMsg.value = m + clearTimeout(toastTimer) + toastTimer = setTimeout(() => (toastMsg.value = ''), 2200) +} +</script> + +<template> + <div class="theme-text space-y-12"> + <!-- header --> + <header class="flex items-end justify-between flex-wrap gap-4"> + <div> + <div class="text-[10px] uppercase tracking-[0.3em] text-violet-300/70 font-medium">账户</div> + <h1 class="mt-2 text-4xl md:text-5xl font-bold tracking-tight">设置</h1> + <p class="text-white/45 mt-2">API Key、密码、积分、登录状态 — 都在这里。</p> + </div> + <button @click="logout" + class="inline-flex items-center gap-2 rounded-full bg-rose-500/15 text-rose-300 hover:bg-rose-500/25 hover:text-rose-200 ring-1 ring-rose-500/30 px-4 py-2 text-sm font-medium transition-all"> + <Icon name="close" class="w-3.5 h-3.5" /> 退出登录 + </button> + </header> + + <!-- ===== Two-column grid ===== --> + <div class="grid lg:grid-cols-2 gap-5"> + + <!-- API KEY (full width) --> + <section class="lg:col-span-2 card overflow-hidden"> + <div class="p-7 md:p-8 relative"> + <div class="absolute -top-16 -right-16 w-48 h-48 rounded-full opacity-30 blur-3xl" + style="background: radial-gradient(circle,#a855f7,transparent 60%)"></div> + <div class="relative grid md:grid-cols-[260px_1fr] gap-8 items-start"> + <div> + <div class="inline-grid w-10 h-10 rounded-xl bg-violet-500/15 ring-1 ring-violet-400/30 grid place-items-center text-violet-300"> + <Icon name="plug" class="w-4 h-4" /> + </div> + <h2 class="text-xl font-bold mt-4">API Key</h2> + <p class="text-sm text-white/50 mt-2 leading-relaxed"> + 调用 <code class="bg-white/10 text-white/90 px-1 py-0.5 rounded text-xs">/v1/*</code> 接口需要的访问密钥。会保存在浏览器本地。 + </p> + </div> + <div> + <label class="block text-xs text-white/50 mb-2">当前密钥</label> + + <!-- has a key --> + <div v-if="hasKey" class="rounded-xl bg-white/[0.05] ring-1 ring-white/10 px-4 py-3 flex items-center gap-3"> + <code class="flex-1 font-mono text-sm text-white/90 break-all">{{ apiKeyRevealed && apiKey ? apiKey : maskedKey }}</code> + <button v-if="apiKey" @click="apiKeyRevealed = !apiKeyRevealed" + class="text-xs rounded-lg px-2.5 py-1.5 ring-1 ring-white/10 hover:bg-white/[0.06] hover:ring-white/20 transition-all whitespace-nowrap"> + {{ apiKeyRevealed ? '隐藏' : '显示' }} + </button> + <button @click="copyKey" + class="text-xs rounded-lg px-2.5 py-1.5 ring-1 ring-white/10 hover:bg-white/[0.06] hover:ring-white/20 transition-all"> + 复制 + </button> + </div> + + <!-- no key --> + <div v-else class="rounded-xl border border-dashed border-white/15 px-4 py-5 text-center text-xs text-white/40"> + 还没有 Key — 点下面的「生成」按钮自动生成 + </div> + + <p v-if="apiKey" class="text-[11px] text-amber-300/80 mt-2">⚠ 完整 Key 仅显示这一次,请立刻复制保存。</p> + + <!-- actions --> + <div class="mt-3 flex gap-2"> + <button @click="generateKey" + class="rounded-xl bg-white text-black hover:bg-white/90 px-5 py-2.5 text-sm font-semibold transition-colors"> + {{ hasKey ? '重新生成' : '生成 Key' }} + </button> + <button v-if="hasKey" @click="clearKey" + class="rounded-xl ring-1 ring-rose-500/30 text-rose-300 hover:bg-rose-500/15 px-4 py-2.5 text-sm transition-colors"> + 清除 + </button> + </div> + + <p class="text-[11px] text-white/40 mt-3"> + Key 由系统随机生成,不能手动填写。重新生成会让旧 Key 立刻失效。完整调用示例见 + <router-link to="/docs" class="text-violet-300 underline">接口文档</router-link>。 + </p> + </div> + </div> + </div> + </section> + + <!-- CHECK-IN --> + <section v-if="checkinEnabled" class="relative card p-7 md:p-8 overflow-hidden"> + <div class="inline-grid w-10 h-10 rounded-xl bg-sky-500/15 ring-1 ring-sky-400/30 grid place-items-center text-sky-300"> + <Icon name="refresh" class="w-4 h-4" /> + </div> + <h2 class="text-xl font-bold mt-4">每日签到</h2> + <p class="text-sm text-white/50 mt-2">每天签到 +{{ checkinReward }} 积分。</p> + + <!-- streak --> + <div class="mt-5 flex items-baseline gap-2"> + <span class="text-3xl font-bold tabular-nums">{{ streak }}</span> + <span class="text-xs text-white/40 uppercase tracking-widest">天连续</span> + </div> + + <!-- 7-day dots --> + <div class="mt-5 flex items-center gap-1.5"> + <div v-for="(d, i) in last7" :key="d.ds" + class="flex-1 h-12 rounded-xl ring-1 transition-all flex flex-col items-center justify-center" + :class="d.lit + ? 'bg-sky-400/25 ring-sky-300/50 text-sky-100' + : d.isToday + ? (checkedToday ? 'bg-sky-400/25 ring-sky-300/50 text-sky-100' : 'bg-white/[0.05] ring-white/15 text-white/60') + : 'bg-white/[0.02] ring-white/[0.06] text-white/30'"> + <Icon v-if="d.lit || (d.isToday && checkedToday)" name="spark" class="w-3 h-3" /> + <span v-else class="text-[10px] uppercase">{{ d.isToday ? '今' : i + 1 }}</span> + </div> + </div> + + <button @click="checkin" :disabled="checkedToday" + class="mt-5 w-full rounded-xl bg-white text-black hover:bg-white/90 disabled:bg-white/10 disabled:text-white/40 disabled:cursor-not-allowed py-3 text-sm font-semibold transition-colors"> + {{ checkedToday ? `今日已签到 · 明天再来` : `立即签到 +${checkinReward} 积分` }} + </button> + </section> + + <!-- CDK REDEEM --> + <section class="relative card p-7 md:p-8 overflow-hidden"> + <div class="inline-grid w-10 h-10 rounded-xl bg-emerald-500/15 ring-1 ring-emerald-400/30 grid place-items-center text-emerald-300"> + <Icon name="spark" class="w-4 h-4" /> + </div> + <h2 class="text-xl font-bold mt-4">兑换码充值</h2> + <div class="mt-2 flex items-baseline gap-2"> + <span class="text-3xl font-bold tabular-nums">{{ points(balance).toLocaleString('en-US') }}</span> + <span class="text-xs text-white/40 uppercase tracking-widest">积分余额</span> + </div> + + <div class="mt-5 flex gap-2"> + <input v-model="cdkCode" @keyup.enter="redeemCdk" placeholder="输入兑换码 (CDK)" + class="flex-1 rounded-xl bg-white/[0.05] ring-1 ring-white/10 focus:ring-white/30 px-4 py-3 text-sm font-mono uppercase tracking-wider outline-none transition-colors placeholder:text-white/30 placeholder:normal-case placeholder:tracking-normal" /> + <button @click="redeemCdk" :disabled="cdkBusy" + class="rounded-xl bg-white text-black hover:bg-white/90 disabled:opacity-40 px-5 py-3 text-sm font-semibold transition-colors"> + {{ cdkBusy ? '兑换中…' : '兑换' }} + </button> + </div> + <p v-if="cdkError" class="text-xs text-rose-400 mt-2">{{ cdkError }}</p> + <p class="text-[11px] text-white/35 mt-3">兑换码每个仅可使用一次。还没有兑换码?到商店购买后回此处充值。</p> + <a v-if="site.contact?.shop" :href="site.contact.shop" target="_blank" rel="noopener" + class="mt-3 group inline-flex items-center gap-2 rounded-xl bg-emerald-500/15 ring-1 ring-emerald-400/30 text-emerald-300 hover:bg-emerald-500/25 px-4 py-2.5 text-sm font-semibold transition-colors"> + <Icon name="spark" class="w-4 h-4" /> + 前往商店购买兑换码 + <span class="group-hover:translate-x-0.5 transition-transform">→</span> + </a> + </section> + + <!-- PASSWORD --> + <section class="card p-7 md:p-8"> + <div class="inline-grid w-10 h-10 rounded-xl bg-amber-500/15 ring-1 ring-amber-400/30 grid place-items-center text-amber-300"> + <Icon name="refresh" class="w-4 h-4" /> + </div> + <h2 class="text-xl font-bold mt-4">修改密码</h2> + <p class="text-sm text-white/50 mt-2">8-24 位,必须包含大写字母、小写字母、数字和符号。</p> + + <div class="mt-6 space-y-3"> + <input v-model="pwdForm.current" type="password" placeholder="当前密码" autocomplete="current-password" + class="w-full rounded-xl bg-white/[0.05] ring-1 ring-white/10 focus:ring-white/30 px-4 py-3 text-sm outline-none transition-colors placeholder:text-white/30" /> + <input v-model="pwdForm.next" type="password" placeholder="新密码(8-24位,含大小写/数字/符号)" autocomplete="new-password" + class="w-full rounded-xl bg-white/[0.05] ring-1 ring-white/10 focus:ring-white/30 px-4 py-3 text-sm outline-none transition-colors placeholder:text-white/30" /> + <input v-model="pwdForm.confirm" type="password" placeholder="再次输入新密码" autocomplete="new-password" + class="w-full rounded-xl bg-white/[0.05] ring-1 ring-white/10 focus:ring-white/30 px-4 py-3 text-sm outline-none transition-colors placeholder:text-white/30" /> + <p v-if="pwdError" class="text-xs text-rose-400">{{ pwdError }}</p> + <button @click="changePwd" :disabled="pwdSubmitting" + class="w-full rounded-xl bg-white text-black hover:bg-white/90 disabled:opacity-40 py-3 text-sm font-semibold transition-colors"> + {{ pwdSubmitting ? '提交中…' : '更新密码' }} + </button> + </div> + </section> + + <!-- SIGN OUT (full width) --> + <section class="lg:col-span-2 card p-6 md:p-7 flex items-center gap-5 flex-wrap"> + <div class="inline-grid w-10 h-10 rounded-xl bg-rose-500/15 ring-1 ring-rose-400/30 grid place-items-center text-rose-300"> + <Icon name="close" class="w-4 h-4" /> + </div> + <div class="flex-1 min-w-0"> + <div class="text-base font-semibold">退出登录</div> + <p class="text-sm text-white/50 mt-1">会清除本地 API Key、余额缓存。下次进来要重新设置。</p> + </div> + <button @click="logout" + class="rounded-xl bg-rose-500/20 text-rose-200 hover:bg-rose-500/30 ring-1 ring-rose-500/30 px-5 py-2.5 text-sm font-semibold transition-colors"> + 退出 + </button> + </section> + </div> + + <!-- toast --> + <transition name="fade"> + <div v-if="toastMsg" + class="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 bg-white text-black text-sm font-medium px-5 py-2.5 rounded-full shadow-2xl"> + {{ toastMsg }} + </div> + </transition> + </div> +</template> + +<style scoped> +.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease, transform 0.15s ease; } +.fade-enter-from, .fade-leave-to { opacity: 0; transform: translateY(8px); } +</style> diff --git a/frontend/src/views/ShowcaseView.vue b/frontend/src/views/ShowcaseView.vue new file mode 100644 index 0000000..b3d6b8c --- /dev/null +++ b/frontend/src/views/ShowcaseView.vue @@ -0,0 +1,417 @@ +<script setup> +// Admin → 首页内容 +// CRUD for the cards rendered on the public home page: +// - hero : top-3-by-weight stacked deck in the hero +// - bento : "从一个起点开始" grid +// - work : "我们的作品" marquee — admin-curated featured outputs +// All three kinds use a real image as the background; admins pick one from +// the already-generated files or paste an external URL. +import { ref, reactive, computed, onMounted } from 'vue' +import { api, jsonBody, generatedUrl } from '../api' +import Icon from '../components/Icon.vue' + +const items = ref([]) +const filter = ref('all') // 'all' | 'hero' | 'bento' | 'work' +const loading = ref(false) +const editing = ref(null) // truthy when the form modal is open +const picking = ref(false) // truthy when the image-picker modal is open +const recentFiles = ref([]) // populated from /stats.recent for the picker +const page = ref(1) +const pageSize = ref(12) +const form = reactive({ + id: '', kind: 'hero', title: '', subtitle: '', prompt: '', + image: '', weight: 100, span: '', +}) +const saving = ref(false) +const error = ref('') + +async function refresh() { + loading.value = true + const r = await api('/showcase') + const grouped = r.data?.data || {} + // Guard every group — a payload missing hero/bento would throw on spread of + // undefined and freeze the page on "加载中…". + items.value = [...(grouped.hero || []), ...(grouped.bento || []), ...(grouped.work || [])] + loading.value = false +} + +const filtered = computed(() => { + if (filter.value === 'all') return items.value + return items.value.filter((x) => x.kind === filter.value) +}) + +// Client-side pagination over the filtered set. The showcase store is small +// (admin curates manually) so paging client-side is fine — no extra API calls +// when the admin flips pages. +const totalPages = computed(() => Math.max(1, Math.ceil(filtered.value.length / pageSize.value))) +const pagedItems = computed(() => { + const start = (page.value - 1) * pageSize.value + return filtered.value.slice(start, start + pageSize.value) +}) +function setFilter(v) { filter.value = v; page.value = 1 } +function goPage(n) { + const target = Math.max(1, Math.min(totalPages.value, n)) + if (target !== page.value) page.value = target +} +const pageNumbers = computed(() => { + const n = totalPages.value + const cur = page.value + if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1) + const want = new Set([1, n, cur - 1, cur, cur + 1]) + if (cur <= 3) { want.add(2); want.add(3); want.add(4) } + if (cur >= n - 2) { want.add(n - 1); want.add(n - 2); want.add(n - 3) } + const list = [...want].filter((x) => x >= 1 && x <= n).sort((a, b) => a - b) + const out = [] + for (let i = 0; i < list.length; i++) { + if (i > 0 && list[i] - list[i - 1] > 1) out.push(null) + out.push(list[i]) + } + return out +}) + +const KIND_DEFAULT_WEIGHT = { hero: 200, bento: 300, work: 100 } + +function openNew(kind) { + editing.value = { kind } + Object.assign(form, { + id: '', kind, title: '', subtitle: '', prompt: '', + image: '', weight: KIND_DEFAULT_WEIGHT[kind], span: '', + }) + error.value = '' +} + +function openEdit(rec) { + editing.value = rec + Object.assign(form, { + id: rec.id, kind: rec.kind, title: rec.title || '', subtitle: rec.subtitle || '', + prompt: rec.prompt || '', image: rec.image || '', + weight: rec.weight ?? 100, span: rec.span || '', + }) + error.value = '' +} + +function closeForm() { editing.value = null } + +async function save() { + if (!form.image.trim()) { error.value = '请选择底图'; return } + if (form.kind !== 'work') { + if (!form.title.trim()) { error.value = '请输入标题'; return } + if (!form.prompt.trim()) { error.value = '请输入提示词'; return } + } + saving.value = true; error.value = '' + const payload = { + kind: form.kind, + title: form.title.trim(), + subtitle: form.subtitle.trim(), + prompt: form.prompt.trim(), + image: form.image.trim(), + weight: Number(form.weight) || 0, + span: form.span.trim(), + } + const r = form.id + ? await api(`/showcase/${form.id}`, jsonBody('PATCH', payload)) + : await api('/showcase', jsonBody('POST', payload)) + saving.value = false + if (r.ok) { closeForm(); refresh() } + else error.value = r.data?.detail || `保存失败 (${r.status})` +} + +async function remove(rec) { + if (!confirm(`删除「${rec.title || rec.image}」?`)) return + const r = await api(`/showcase/${rec.id}`, { method: 'DELETE' }) + if (r.ok) refresh() +} + +// Image picker — show the admin's OWN recently generated images (scoped to their +// owner directory, not everyone's). The admin clicks one to fill `form.image`, +// or pastes a URL into the text field. +async function openPicker() { + picking.value = true + if (!recentFiles.value.length) { + const r = await api('/my-images') + const files = r.data?.data || [] + recentFiles.value = files.filter((f) => + /\.(png|jpe?g|webp|gif)$/i.test(f.name) + ) + } +} +function closePicker() { picking.value = false } +function pickImage(file) { + form.image = file.name + picking.value = false +} + +function bgFor(image) { + if (!image) return {} + const src = /^https?:\/\//i.test(image) ? image : generatedUrl(image) + return { + backgroundImage: `url("${src}")`, + backgroundSize: 'cover', + backgroundPosition: 'center', + } +} + +const SPAN_PRESETS = ['', 'md:col-span-2', 'md:row-span-2', 'md:col-span-2 md:row-span-2'] + +onMounted(refresh) +</script> + +<template> + <section class="space-y-4"> + <!-- header / filter / new --> + <div class="card p-4 flex items-center justify-between gap-3 flex-wrap"> + <div class="flex items-center gap-1.5"> + <button @click="setFilter('all')" class="filter-pill" :class="filter === 'all' && 'on'">全部</button> + <button @click="setFilter('hero')" class="filter-pill" :class="filter === 'hero' && 'on'">Hero 卡片</button> + <button @click="setFilter('bento')" class="filter-pill" :class="filter === 'bento' && 'on'">Bento 灵感</button> + <button @click="setFilter('work')" class="filter-pill" :class="filter === 'work' && 'on'">我们的作品</button> + </div> + <div class="flex items-center gap-2"> + <button @click="openNew('hero')" class="btn-soft">+ Hero</button> + <button @click="openNew('bento')" class="btn-soft">+ Bento</button> + <button @click="openNew('work')" class="btn-soft">+ Work</button> + </div> + </div> + + <!-- grid --> + <div v-if="loading" class="text-center text-xs text-white/40 py-12">加载中…</div> + <div v-else-if="!filtered.length" class="text-center text-xs text-white/40 py-12">没有条目</div> + <div v-else class="grid sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3"> + <div v-for="rec in pagedItems" :key="rec.id" + class="media-card relative rounded-2xl overflow-hidden ring-1 ring-white/10 aspect-[4/3] group bg-white/[0.04]" + :style="bgFor(rec.image)"> + <!-- Fallback gradient for legacy entries that haven't been migrated yet. --> + <div v-if="!rec.image" class="absolute inset-0" :style="{ background: rec.gradient }"></div> + <div class="absolute inset-0 bg-gradient-to-t from-black/85 via-black/30 to-transparent"></div> + <div class="absolute top-3 left-3 flex items-center gap-1.5"> + <span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded ring-1" + :class="rec.kind === 'hero' ? 'bg-fuchsia-500/20 text-fuchsia-200 ring-fuchsia-400/30' + : rec.kind === 'bento' ? 'bg-violet-500/20 text-violet-200 ring-violet-400/30' + : 'bg-sky-500/20 text-sky-200 ring-sky-400/30'"> + {{ rec.kind }} + </span> + <span class="text-[10px] text-white/65 tabular-nums px-1.5 py-0.5 rounded bg-black/40 ring-1 ring-white/10">w={{ rec.weight }}</span> + </div> + <div class="absolute top-3 right-3 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> + <button @click="openEdit(rec)" class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-black/70 text-white grid place-items-center" title="编辑"> + <Icon name="config" class="w-3.5 h-3.5" /> + </button> + <button @click="remove(rec)" class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-rose-500/80 text-white grid place-items-center" title="删除"> + <Icon name="trash" class="w-3.5 h-3.5" /> + </button> + </div> + <div class="absolute inset-x-0 bottom-0 p-4"> + <div v-if="rec.subtitle" class="text-[10px] uppercase tracking-[0.3em] text-white/55">{{ rec.subtitle }}</div> + <div v-if="rec.title" class="text-base font-bold text-white mt-1">{{ rec.title }}</div> + <div v-if="rec.prompt" class="text-[11px] text-white/65 mt-1 line-clamp-2">{{ rec.prompt }}</div> + </div> + </div> + </div> + + <!-- pagination — shown when there's more than one page worth of entries --> + <div v-if="!loading && totalPages > 1" + class="card !p-3 flex items-center justify-between gap-3"> + <div class="text-xs text-white/55 tabular-nums px-2"> + <span class="text-white/85">{{ (page - 1) * pageSize + 1 }}–{{ Math.min(filtered.length, page * pageSize) }}</span> + / {{ filtered.length }} 条 + </div> + <div class="flex items-center gap-1"> + <template v-for="(n, i) in pageNumbers" :key="i"> + <span v-if="n === null" class="px-1 text-white/35">…</span> + <button v-else @click="goPage(n)" class="pg" :class="page === n && 'pg-on'">{{ n }}</button> + </template> + </div> + </div> + + <!-- ======= form modal ======= --> + <transition name="fade"> + <div v-if="editing" + class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm grid place-items-center p-4" + @click.self="closeForm"> + <div class="card w-full max-w-2xl !shadow-2xl"> + <div class="px-5 py-3 border-b border-white/[0.06] flex items-center justify-between"> + <h2 class="text-sm font-semibold"> + {{ form.id ? '编辑' : '新增' }} · + {{ form.kind === 'hero' ? 'Hero 卡片' : form.kind === 'bento' ? 'Bento 灵感' : '作品' }} + </h2> + <button @click="closeForm" class="text-white/40 hover:text-white"> + <Icon name="close" class="w-4 h-4" /> + </button> + </div> + + <div class="p-5 space-y-4 max-h-[70vh] overflow-y-auto"> + <!-- live preview --> + <div class="relative rounded-2xl overflow-hidden ring-1 ring-white/10 aspect-[5/2] bg-white/[0.04]" + :style="bgFor(form.image)"> + <div class="absolute inset-0 bg-gradient-to-t from-black/85 via-black/30 to-transparent"></div> + <div v-if="!form.image" class="absolute inset-0 grid place-items-center text-xs text-white/40"> + 未选择底图 + </div> + <div class="absolute inset-x-0 bottom-0 p-5"> + <div v-if="form.subtitle" class="text-[10px] uppercase tracking-[0.3em] text-white/55">{{ form.subtitle }}</div> + <div v-if="form.title" class="text-xl font-bold text-white mt-1">{{ form.title }}</div> + <div v-if="form.prompt" class="text-xs text-white/65 mt-1 line-clamp-2">{{ form.prompt }}</div> + </div> + </div> + + <div class="grid sm:grid-cols-2 gap-3"> + <div> + <label class="block text-xs text-white/55 mb-1.5">类型</label> + <div class="flex gap-1.5"> + <button type="button" @click="form.kind = 'hero'" class="kind-btn" :class="form.kind === 'hero' && 'on'">Hero</button> + <button type="button" @click="form.kind = 'bento'" class="kind-btn" :class="form.kind === 'bento' && 'on'">Bento</button> + <button type="button" @click="form.kind = 'work'" class="kind-btn" :class="form.kind === 'work' && 'on'">Work</button> + </div> + </div> + <div> + <label class="block text-xs text-white/55 mb-1.5">权重 <span class="text-white/35">(越大越靠前)</span></label> + <input v-model.number="form.weight" type="number" class="field" /> + </div> + </div> + + <!-- image picker (the central change — admins pick a real image) --> + <div> + <label class="block text-xs text-white/55 mb-1.5">底图</label> + <div class="flex gap-2"> + <input v-model="form.image" class="field font-mono text-[11px]" + placeholder="user/abc.png 或 https://…" /> + <button type="button" @click="openPicker" class="btn-soft shrink-0">选择已生成</button> + </div> + <p class="text-[11px] text-white/35 mt-1">填写 /generated 下的相对路径,或粘贴一个外链 URL。</p> + </div> + + <template v-if="form.kind !== 'work'"> + <div class="grid sm:grid-cols-2 gap-3"> + <div> + <label class="block text-xs text-white/55 mb-1.5">标题</label> + <input v-model="form.title" class="field" placeholder="电影感人物" /> + </div> + <div> + <label class="block text-xs text-white/55 mb-1.5">副标题</label> + <input v-model="form.subtitle" class="field" placeholder="CINEMATIC PORTRAIT" /> + </div> + </div> + <div> + <label class="block text-xs text-white/55 mb-1.5">提示词 <span class="text-white/35">(点 Bento 后会预填到画图)</span></label> + <textarea v-model="form.prompt" rows="3" class="field resize-none" + placeholder="一位身穿米色风衣的女子站在雨夜的霓虹街道,胶片质感,浅景深,电影感"></textarea> + </div> + </template> + <template v-else> + <div> + <label class="block text-xs text-white/55 mb-1.5">作品标题 <span class="text-white/35">(可选)</span></label> + <input v-model="form.title" class="field" placeholder="留空则只展示图片" /> + </div> + </template> + + <div v-if="form.kind === 'bento'"> + <label class="block text-xs text-white/55 mb-1.5">网格跨度 <span class="text-white/35">(Tailwind class)</span></label> + <div class="flex gap-1.5 flex-wrap mb-2"> + <button v-for="s in SPAN_PRESETS" :key="s" type="button" @click="form.span = s" + class="px-2.5 py-1 text-[11px] rounded-lg ring-1 ring-white/10 hover:bg-white/[0.08]" + :class="form.span === s ? 'bg-white text-slate-900' : 'bg-white/[0.04] text-white/70'"> + {{ s || '默认 1×1' }} + </button> + </div> + <input v-model="form.span" class="field font-mono text-[11px]" placeholder="md:col-span-2" /> + </div> + + <p v-if="error" class="text-xs text-rose-300">{{ error }}</p> + </div> + + <div class="px-5 py-3 border-t border-white/[0.06] flex items-center justify-end gap-2"> + <button @click="closeForm" class="btn-ghost">取消</button> + <button @click="save" :disabled="saving" class="btn-primary"> + {{ saving ? '保存中…' : '保存' }} + </button> + </div> + </div> + </div> + </transition> + + <!-- ======= image picker modal ======= --> + <transition name="fade"> + <div v-if="picking" + class="fixed inset-0 z-[60] bg-black/80 backdrop-blur-sm grid place-items-center p-4" + @click.self="closePicker"> + <div class="card w-full max-w-4xl !shadow-2xl"> + <div class="px-5 py-3 border-b border-white/[0.06] flex items-center justify-between"> + <h2 class="text-sm font-semibold">选择底图 · 最近生成</h2> + <button @click="closePicker" class="text-white/40 hover:text-white"> + <Icon name="close" class="w-4 h-4" /> + </button> + </div> + <div class="p-4 max-h-[70vh] overflow-y-auto"> + <div v-if="!recentFiles.length" class="text-center text-xs text-white/40 py-10">尚未有生成过的图片</div> + <div v-else class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2"> + <button v-for="f in recentFiles" :key="f.name" type="button" @click="pickImage(f)" + class="relative aspect-square rounded-lg overflow-hidden ring-1 ring-white/10 hover:ring-fuchsia-400/60 transition-all"> + <img :src="generatedUrl(f.name)" loading="lazy" class="w-full h-full object-cover" /> + </button> + </div> + </div> + </div> + </div> + </transition> + </section> +</template> + +<style scoped> +.filter-pill { + padding: 0.375rem 0.75rem; + font-size: 0.75rem; + border-radius: 0.5rem; + background: rgb(255 255 255 / 0.06); + color: rgb(255 255 255 / 0.65); + transition: background 0.15s, color 0.15s; +} +.filter-pill:hover { background: rgb(255 255 255 / 0.1); color: white; } +.filter-pill.on { background: white; color: rgb(15 23 42); } + +.kind-btn { + flex: 1; + padding: 0.5rem 0; + border-radius: 0.5rem; + font-size: 0.75rem; + background: rgb(255 255 255 / 0.06); + color: rgb(255 255 255 / 0.7); + transition: background 0.15s, color 0.15s; +} +.kind-btn:hover { background: rgb(255 255 255 / 0.1); } +.kind-btn.on { background: white; color: rgb(15 23 42); } + +.field { + width: 100%; + padding: 0.5rem 0.7rem; + border-radius: 0.6rem; + font-size: 0.85rem; + outline: none; + background: rgb(255 255 255 / 0.04); + border: 1px solid rgb(255 255 255 / 0.1); + color: white; + transition: border-color 0.18s, background 0.18s; +} +.field:focus { border-color: rgb(167 139 250 / 0.65); background: rgb(255 255 255 / 0.06); } +.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; } +.fade-enter-from, .fade-leave-to { opacity: 0; } + +/* numbered pagination — matches LogsView / ImagesView */ +.pg { + min-width: 1.75rem; + padding: 0.3rem 0.55rem; + font-size: 0.72rem; + font-weight: 500; + text-align: center; + border-radius: 0.45rem; + color: rgb(255 255 255 / 0.7); + background: rgb(255 255 255 / 0.04); + box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08); + transition: background 0.15s, color 0.15s; +} +.pg:hover:not(.pg-on) { background: rgb(255 255 255 / 0.1); color: white; } +.pg-on { + background: rgb(255 255 255 / 0.92); + color: rgb(15 23 42); + box-shadow: none; +} +</style> diff --git a/frontend/src/views/UserLogsTableView.vue b/frontend/src/views/UserLogsTableView.vue new file mode 100644 index 0000000..4bf8adf --- /dev/null +++ b/frontend/src/views/UserLogsTableView.vue @@ -0,0 +1,302 @@ +<script setup> +// Front-end "日志" page — a row-per-event log of the signed-in user's OWN +// generations (success / failed / pending), surfacing failure reasons that the +// image-only 记录 gallery hides. Uses the same /logs endpoint (auto-scoped to +// the caller), just without the success-only filter. +import { ref, computed, onMounted } from 'vue' +import { useRouter } from 'vue-router' +import { api, generatedUrl } from '../api' +import { fmtDate, fmtClock, fmtTs } from '../utils/format' +import { points } from '../credits' +import Icon from '../components/Icon.vue' +import MediaLightbox from '../components/MediaLightbox.vue' + +const router = useRouter() +const items = ref([]) // current server page +const total = ref(0) // server-side total (matches current filters) +const stats = ref({ total: 0, success: 0, failed: 0, pending: 0 }) // 本人统计 +const loading = ref(false) +const statusFilter = ref('') // '' | success | failed | pending +const sourceFilter = ref('') // '' | api | web (api = key 调用, web = 画图台) +const search = ref('') +const page = ref(1) +const pageSize = 20 +const lightbox = ref(null) + +// 来源筛选走服务端:画图台 = source "user",API = source "v1"。 +const SOURCE_PARAM = { web: 'user', api: 'v1' } + +// 服务端分页 —— 不再只拉前 200 条;按页向后端取,可翻到全部历史。 +async function load() { + loading.value = true + const qs = new URLSearchParams({ + limit: String(pageSize), + offset: String((page.value - 1) * pageSize), + }) + if (statusFilter.value) qs.set('status', statusFilter.value) + if (SOURCE_PARAM[sourceFilter.value]) qs.set('source', SOURCE_PARAM[sourceFilter.value]) + const r = await api('/logs?' + qs.toString()) + loading.value = false + if (r.ok) { + items.value = r.data?.data || [] + total.value = Number(r.data?.total ?? items.value.length) + if (r.data?.stats) stats.value = r.data.stats + } +} +onMounted(load) + +// Source: backend stamps "v1" for API-key calls, "user"/"admin" for the +// playground/test page. Collapse to two buckets the user cares about. +const isApi = (e) => e.source === 'v1' +const sourceLabel = (e) => (isApi(e) ? 'API' : '画图台') +const sourcePill = (e) => (isApi(e) + ? 'bg-violet-50 text-violet-700 ring-violet-200' + : 'bg-sky-50 text-sky-700 ring-sky-200') + +// 搜索只在当前页内过滤(状态/来源已由服务端筛选并分页)。 +const displayed = computed(() => { + const q = search.value.trim().toLowerCase() + if (!q) return items.value + return items.value.filter((e) => + (e.model || '').toLowerCase().includes(q) || + (e.prompt || '').toLowerCase().includes(q) || + (e.error || '').toLowerCase().includes(q)) +}) +const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize))) +const pageStart = computed(() => total.value === 0 ? 0 : (page.value - 1) * pageSize + 1) +const pageEnd = computed(() => Math.min(total.value, page.value * pageSize)) +function setStatus(v) { statusFilter.value = v; page.value = 1; load() } +function setSource(v) { sourceFilter.value = v; page.value = 1; load() } + +// Numbered pagination strip — first + last + a window around current; gaps +// collapse to null ("…"). Mirrors the admin 日志 page so both look the same. +const pageNumbers = computed(() => { + const n = totalPages.value + const cur = page.value + if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1) + const want = new Set([1, n, cur - 1, cur, cur + 1]) + if (cur <= 3) { want.add(2); want.add(3); want.add(4) } + if (cur >= n - 2) { want.add(n - 1); want.add(n - 2); want.add(n - 3) } + const list = [...want].filter((x) => x >= 1 && x <= n).sort((a, b) => a - b) + const out = [] + for (let i = 0; i < list.length; i++) { + if (i > 0 && list[i] - list[i - 1] > 1) out.push(null) + out.push(list[i]) + } + return out +}) +function goPage(n) { + const t = Math.max(1, Math.min(totalPages.value, n)) + if (t === page.value) return + page.value = t + load() +} + +const statusLabel = (s) => ({ success: '成功', failed: '失败', pending: '进行中' }[s] || s) +const statusPill = (s) => ({ + success: 'bg-emerald-50 text-emerald-700 ring-emerald-200', + failed: 'bg-rose-50 text-rose-700 ring-rose-200', + pending: 'bg-amber-50 text-amber-700 ring-amber-200', +}[s] || 'bg-slate-100 text-slate-500 ring-slate-200') +const statusDot = (s) => ({ + success: 'bg-emerald-500', failed: 'bg-rose-500', pending: 'bg-amber-500', +}[s] || 'bg-slate-400') +// Match the admin 日志 params exactly: 比例 · 画质 · [时长] · [参考 N]. +const params = (e) => { + const parts = [e.ratio || '—', e.resolution || '—'] + if (e.duration) parts.push(e.duration) + if (e.refs > 0) parts.push(`参考 ${e.refs}`) + return parts.join(' · ') +} +</script> + +<template> + <section class="space-y-5 log-page"> + <!-- Header --> + <div class="flex items-end justify-between flex-wrap gap-3"> + <div> + <h1 class="text-2xl font-semibold tracking-tight text-slate-900">生成日志</h1> + <p class="text-sm text-slate-500 mt-1">{{ total }} 条记录 · 含失败原因</p> + </div> + <button @click="router.push('/user')" class="btn-primary"> + <Icon name="spark" class="w-4 h-4" /> 去画图 + </button> + </div> + + <!-- KPI 统计(本人累计) --> + <div class="grid grid-cols-2 md:grid-cols-4 gap-3"> + <div class="card p-4"> + <div class="text-[11px] uppercase tracking-wider text-slate-400">总计</div> + <div class="text-2xl font-semibold mt-1 tabular-nums text-slate-900">{{ stats.total }}</div> + </div> + <div class="card p-4"> + <div class="text-[11px] uppercase tracking-wider text-emerald-600/80">成功</div> + <div class="text-2xl font-semibold mt-1 tabular-nums text-emerald-600">{{ stats.success }}</div> + </div> + <div class="card p-4"> + <div class="text-[11px] uppercase tracking-wider text-rose-600/80">失败</div> + <div class="text-2xl font-semibold mt-1 tabular-nums text-rose-600">{{ stats.failed }}</div> + </div> + <div class="card p-4"> + <div class="text-[11px] uppercase tracking-wider text-amber-600/80">进行中</div> + <div class="text-2xl font-semibold mt-1 tabular-nums text-amber-600">{{ stats.pending }}</div> + </div> + </div> + + <!-- Filter bar --> + <div class="card p-3 flex items-center gap-3 flex-wrap"> + <div class="flex items-center gap-1.5"> + <button v-for="s in [['','全部'],['success','成功'],['failed','失败'],['pending','进行中']]" :key="s[0]" + @click="setStatus(s[0])" + class="text-xs rounded-lg px-2.5 py-1.5 transition-colors" + :class="statusFilter === s[0] ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">{{ s[1] }}</button> + </div> + <div class="w-px h-5 bg-slate-200"></div> + <div class="flex items-center gap-1.5"> + <button v-for="s in [['','全部来源'],['web','画图台'],['api','API']]" :key="s[0]" + @click="setSource(s[0])" + class="text-xs rounded-lg px-2.5 py-1.5 transition-colors" + :class="sourceFilter === s[0] ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">{{ s[1] }}</button> + </div> + <div class="flex-1 min-w-[180px]"> + <input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索 提示词 / 模型 / 错误…" /> + </div> + <button @click="load" class="btn-soft"><Icon name="refresh" class="w-3.5 h-3.5" /> 刷新</button> + </div> + + <!-- States --> + <div v-if="loading && !items.length" class="card text-center text-sm text-slate-400 py-24">加载中…</div> + <div v-else-if="!total" class="card flex flex-col items-center gap-3 text-slate-400 py-24"> + <span class="w-14 h-14 rounded-2xl bg-slate-100 grid place-items-center"><Icon name="log" class="w-6 h-6" /></span> + <span class="text-sm">还没有生成日志</span> + </div> + + <!-- Table --> + <div v-else class="card overflow-hidden !p-0"> + <table class="w-full text-sm table-fixed log-table"> + <colgroup> + <col class="w-16" /> <!-- preview --> + <col class="w-28" /> <!-- time --> + <col class="w-24" /> <!-- status --> + <col class="w-36" /> <!-- model --> + <col /> <!-- prompt/error --> + <col class="w-40" /> <!-- params --> + <col class="w-14" /> <!-- cost --> + <col class="w-16" /> <!-- elapsed --> + </colgroup> + <thead> + <tr class="text-[10px] uppercase tracking-[0.18em] text-slate-400 border-b border-slate-200"> + <th class="text-center px-3 py-3 font-medium">预览</th> + <th class="text-left px-3 py-3 font-medium">时间</th> + <th class="text-left px-3 py-3 font-medium">状态</th> + <th class="text-left px-3 py-3 font-medium">模型</th> + <th class="text-left px-3 py-3 font-medium">提示词 / 错误</th> + <th class="text-left px-3 py-3 font-medium">参数</th> + <th class="text-right px-3 py-3 font-medium">积分</th> + <th class="text-right px-3 py-3 font-medium">耗时</th> + </tr> + </thead> + <tbody> + <tr v-for="e in displayed" :key="e.id" class="log-row"> + <td class="px-3 py-3 align-middle text-center"> + <button v-if="e.status === 'success' && e.file" @click="lightbox = e" + class="block w-11 h-11 mx-auto rounded-lg overflow-hidden ring-1 ring-slate-200 hover:ring-fuchsia-300 transition-all"> + <img v-if="e.kind !== 'video'" :src="generatedUrl(e.file)" loading="lazy" class="w-full h-full object-cover" /> + <video v-else :src="generatedUrl(e.file)" muted preload="metadata" class="w-full h-full object-cover" /> + </button> + <span v-else class="text-slate-300">—</span> + </td> + <td class="px-3 py-3 align-middle text-xs whitespace-nowrap" :title="fmtTs(e.ts)"> + <div v-if="e.ts" class="leading-tight"> + <div class="text-slate-600 tabular-nums">{{ fmtDate(e.ts) }}</div> + <div class="text-slate-400 tabular-nums">{{ fmtClock(e.ts) }}</div> + </div> + <span v-else class="text-slate-300">—</span> + </td> + <td class="px-3 py-3 align-middle"> + <span class="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-medium ring-1 whitespace-nowrap" :class="statusPill(e.status)"> + <span class="w-1.5 h-1.5 rounded-full" :class="statusDot(e.status)"></span>{{ statusLabel(e.status) }} + </span> + </td> + <td class="px-3 py-3 align-middle min-w-0"> + <div class="font-mono text-xs text-slate-800 truncate" :title="e.model">{{ e.model }}</div> + <div class="mt-0.5 flex items-center gap-1.5"> + <span class="text-[10px] uppercase tracking-wider font-medium" + :class="e.kind === 'video' ? 'text-fuchsia-600' : 'text-indigo-600'"> + {{ e.kind === 'video' ? '视频' : '图像' }} + </span> + <span class="inline-flex items-center rounded px-1.5 py-px text-[10px] font-medium ring-1 whitespace-nowrap" + :class="sourcePill(e)">{{ sourceLabel(e) }}</span> + </div> + </td> + <td class="px-3 py-3 align-middle min-w-0"> + <div class="text-xs text-slate-700 truncate" :title="e.prompt">{{ e.prompt || '—' }}</div> + <div v-if="e.error" class="mt-1 text-[11px] text-rose-600 truncate" :title="e.error">⚠ {{ e.error }}</div> + </td> + <td class="px-3 py-3 align-middle text-xs text-slate-500 tabular-nums">{{ params(e) || '—' }}</td> + <td class="px-3 py-3 align-middle text-right text-xs text-slate-700 tabular-nums">{{ e.cost ? points(e.cost) : '—' }}</td> + <td class="px-3 py-3 align-middle text-right text-xs text-slate-500 tabular-nums">{{ e.elapsed_ms ? (e.elapsed_ms / 1000).toFixed(1) + 's' : '—' }}</td> + </tr> + </tbody> + </table> + + <!-- Pagination — numbered with ellipsis, inside the card footer exactly + like the admin 日志 page (top border + px-5 py-3). --> + <div v-if="total && totalPages > 1" + class="flex items-center justify-between gap-3 border-t border-slate-200 px-5 py-3 text-xs text-slate-500"> + <div> + <span class="tabular-nums text-slate-700">{{ pageStart }}–{{ pageEnd }}</span> + <span class="ml-1">/ {{ total }} 条</span> + </div> + <div class="flex items-center gap-1"> + <template v-for="(n, i) in pageNumbers" :key="i"> + <span v-if="n === null" class="px-1 text-slate-300">…</span> + <button v-else @click="goPage(n)" class="pg" :class="page === n && 'pg-on'">{{ n }}</button> + </template> + </div> + </div> + </div> + + <MediaLightbox + v-if="lightbox" + :src="generatedUrl(lightbox.file)" + :kind="lightbox.kind" + :prompt="lightbox.prompt" + :meta="[lightbox.model, lightbox.ratio, lightbox.resolution, lightbox.duration].filter(Boolean).join(' · ')" + :download-name="lightbox.file" + @close="lightbox = null" /> + </section> +</template> + +<style scoped> +/* Row hover — light-theme twin of the admin 日志 page's .log-row: a subtle + tint plus a violet accent bar on the left edge of the hovered row. */ +.log-table { border-collapse: separate; border-spacing: 0; } +.log-row td { + border-bottom: 1px solid rgb(15 23 42 / 0.06); + transition: background-color 0.15s ease, box-shadow 0.15s ease; +} +.log-row:hover td { background: rgb(15 23 42 / 0.025); } +.log-row:hover td:first-child { box-shadow: inset 2px 0 0 rgb(124 58 237 / 0.6); } +.log-row:last-child td { border-bottom: none; } + +/* Numbered pagination buttons — light-theme twin of the admin 日志 page's .pg */ +.pg { + min-width: 1.75rem; + padding: 0.3rem 0.55rem; + font-size: 0.72rem; + font-weight: 500; + text-align: center; + border-radius: 0.45rem; + color: rgb(71 85 105); + background: rgb(241 245 249); + box-shadow: inset 0 0 0 1px rgb(15 23 42 / 0.06); + transition: background 0.15s, color 0.15s, box-shadow 0.15s; +} +.pg:hover:not(.pg-on) { background: rgb(226 232 240); color: rgb(15 23 42); } +.pg-on { + background: rgb(15 23 42); + color: white; + box-shadow: none; +} +</style> diff --git a/frontend/src/views/UserLogsView.vue b/frontend/src/views/UserLogsView.vue new file mode 100644 index 0000000..0eb7569 --- /dev/null +++ b/frontend/src/views/UserLogsView.vue @@ -0,0 +1,269 @@ +<script setup> +import { ref, computed, onMounted, onUnmounted } from 'vue' +import { useRouter } from 'vue-router' +import { api, generatedUrl } from '../api' +import { fmtTs } from '../utils/format' +import Icon from '../components/Icon.vue' +import MediaLightbox from '../components/MediaLightbox.vue' + +const router = useRouter() + +const items = ref([]) +const total = ref(0) +const loading = ref(false) +const kindFilter = ref('') // '', 'image', 'video' +const search = ref('') +const page = ref(1) +// 20 per page so a 4-col (lg) / 5-col (xl) grid lays out as clean rows, +// matching the admin 图片管理 (ImagesView) page. +const pageSize = 20 +let timer = null + +async function load() { + loading.value = true + // Server-side pagination over real media only: status=success + has_file=1 + // makes the row count == displayable count, so the numbered pager is accurate. + // Failed/pending/file-pruned rows live in admin /admin/logs, never here. + const qs = new URLSearchParams({ + limit: String(pageSize), + offset: String((page.value - 1) * pageSize), + status: 'success', + has_file: '1', + }) + if (kindFilter.value) qs.set('kind', kindFilter.value) + const r = await api('/logs?' + qs.toString()) + items.value = (r.data?.data || []).filter((e) => e.status === 'success' && e.file) + total.value = Number(r.data?.total ?? items.value.length) + loading.value = false +} + +// Search narrows the CURRENT page (same as the admin 日志 page); the numbered +// pager still reflects the full server-side total. +const filtered = computed(() => { + const q = search.value.trim().toLowerCase() + if (!q) return items.value + return items.value.filter((e) => + (e.model || '').toLowerCase().includes(q) || + (e.prompt || '').toLowerCase().includes(q), + ) +}) + +const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize))) +function setKind(v) { kindFilter.value = v; page.value = 1; load() } +function goPage(n) { + const target = Math.max(1, Math.min(totalPages.value, n)) + if (target === page.value) return + page.value = target + load() +} +const pageNumbers = computed(() => { + const n = totalPages.value + const cur = page.value + if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1) + const want = new Set([1, n, cur - 1, cur, cur + 1]) + if (cur <= 3) { want.add(2); want.add(3); want.add(4) } + if (cur >= n - 2) { want.add(n - 1); want.add(n - 2); want.add(n - 3) } + const list = [...want].filter((x) => x >= 1 && x <= n).sort((a, b) => a - b) + const out = [] + for (let i = 0; i < list.length; i++) { + if (i > 0 && list[i] - list[i - 1] > 1) out.push(null) + out.push(list[i]) + } + return out +}) + +function fmtMs(ms) { + if (!ms) return '' + if (ms < 1000) return ms + 'ms' + return (ms / 1000).toFixed(1) + 's' +} + + +async function copyLink(name) { + try { + const u = generatedUrl(name) + await navigator.clipboard.writeText(u.startsWith('http') ? u : location.origin + u) + toast.value = '链接已复制' + setTimeout(() => (toast.value = ''), 1500) + } catch {} +} + +const toast = ref('') +const lightbox = ref(null) +function onKey(e) { if (e.key === 'Escape') lightbox.value = null } + +onMounted(() => { + load() + timer = setInterval(load, 3000) + window.addEventListener('keydown', onKey) +}) +onUnmounted(() => { + clearInterval(timer) + window.removeEventListener('keydown', onKey) +}) +</script> + +<template> + <section class="space-y-5"> + <!-- Header --> + <div class="flex items-end justify-between flex-wrap gap-3"> + <div> + <h1 class="text-2xl font-semibold tracking-tight text-slate-900">我的创作记录</h1> + <p class="text-sm text-slate-500 mt-1"> + {{ total }} 条作品 + </p> + </div> + <button @click="router.push('/user')" class="btn-primary"> + <Icon name="spark" class="w-4 h-4" /> 去画图 + </button> + </div> + + <!-- Filter bar --> + <div class="card p-3 flex items-center gap-3 flex-wrap"> + <div class="flex items-center gap-1.5"> + <button @click="setKind('')" class="text-xs rounded-lg px-2.5 py-1.5 transition-colors" + :class="kindFilter === '' ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">全部</button> + <button @click="setKind('image')" class="text-xs rounded-lg px-2.5 py-1.5 transition-colors" + :class="kindFilter === 'image' ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">图像</button> + <button @click="setKind('video')" class="text-xs rounded-lg px-2.5 py-1.5 transition-colors" + :class="kindFilter === 'video' ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">视频</button> + </div> + <div class="flex-1 min-w-[180px]"> + <input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索提示词或模型…" /> + </div> + </div> + + <!-- Empty --> + <div v-if="loading && !items.length" class="card text-center text-sm text-slate-400 py-24">加载中…</div> + <div v-else-if="!filtered.length" + class="card flex flex-col items-center gap-3 text-slate-400 py-24"> + <span class="w-14 h-14 rounded-2xl bg-slate-100 grid place-items-center"><Icon name="spark" class="w-6 h-6" /></span> + <span class="text-sm">还没有创作记录</span> + <button @click="router.push('/user')" class="btn-soft mt-2">开始第一张</button> + </div> + + <!-- Cards — gallery layout, matching 图片管理 (ImagesView) --> + <div v-else class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3"> + <div v-for="e in filtered" :key="e.id" + class="group relative rounded-xl overflow-hidden ring-1 ring-slate-200 bg-slate-100 aspect-[4/5]" + :class="(e.status === 'success' && e.file) && 'cursor-zoom-in'" + @click="(e.status === 'success' && e.file) && (lightbox = e)"> + <!-- media --> + <template v-if="e.status === 'success' && e.file"> + <video v-if="e.kind === 'video'" :src="generatedUrl(e.file)" muted loop preload="metadata" + class="absolute inset-0 w-full h-full object-cover" + @mouseenter="$event.target.play && $event.target.play()" + @mouseleave="$event.target.pause && $event.target.pause()" /> + <img v-else :src="generatedUrl(e.file)" loading="lazy" + class="absolute inset-0 w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" /> + <div class="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/85 via-black/40 to-transparent pointer-events-none"></div> + </template> + <!-- pending / failed placeholders --> + <div v-else-if="e.status === 'pending'" class="absolute inset-0 grid place-items-center text-slate-400 text-xs"> + <div class="flex flex-col items-center gap-2"> + <span class="w-10 h-10 rounded-xl bg-white grid place-items-center animate-pulse"><Icon name="spark" class="w-4 h-4" /></span> + 生成中… + </div> + </div> + <div v-else class="absolute inset-0 grid place-items-center text-rose-500 text-xs px-4 text-center"> + <div> + <Icon name="close" class="w-6 h-6 mx-auto mb-2 opacity-60" /> + <div>生成失败</div> + <div v-if="e.error" class="text-[10px] text-rose-400 line-clamp-2 mt-1">{{ e.error }}</div> + </div> + </div> + + <!-- kind chip --> + <span class="absolute top-3 left-3 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ring-1" + :class="e.kind === 'video' ? 'bg-fuchsia-500/20 text-fuchsia-200 ring-fuchsia-400/30' : 'bg-indigo-500/20 text-indigo-200 ring-indigo-400/30'"> + {{ e.kind === 'video' ? '视频' : '图像' }} + </span> + + <!-- hover actions (only when there's a file) --> + <div v-if="e.status === 'success' && e.file" + class="absolute top-3 right-3 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> + <a :href="generatedUrl(e.file)" target="_blank" @click.stop title="新标签打开" + class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-black/70 text-white grid place-items-center"> + <Icon name="open" class="w-3.5 h-3.5" /> + </a> + <button @click.stop="copyLink(e.file)" title="复制链接" + class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-black/70 text-white grid place-items-center"> + <Icon name="copy" class="w-3.5 h-3.5" /> + </button> + <a :href="generatedUrl(e.file)" :download="e.file.split('/').pop()" @click.stop title="下载" + class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-black/70 text-white grid place-items-center"> + <Icon name="download" class="w-3.5 h-3.5" /> + </a> + </div> + + <!-- caption (over a real image) --> + <div v-if="e.status === 'success' && e.file" class="absolute inset-x-0 bottom-0 p-3 pointer-events-none"> + <div class="text-[12px] leading-tight text-white font-medium line-clamp-2 mb-1" :title="e.prompt">{{ e.prompt }}</div> + <div class="text-[10px] text-white/55 flex items-center justify-between gap-2 tabular-nums"> + <span class="truncate" :title="e.model || ''">{{ e.model || '—' }}</span> + <span class="shrink-0 flex items-center gap-1"> + <span v-if="e.resolution" class="text-emerald-300/90">{{ e.resolution }}</span> + <span v-if="e.ratio" class="text-white/40">{{ e.ratio }}</span> + <span v-if="e.kind === 'video' && e.duration" class="text-fuchsia-300/80">{{ e.duration }}</span> + </span> + </div> + <div class="text-[10px] text-white/35 mt-0.5 tabular-nums">{{ fmtTs(e.ts) }}<span v-if="e.elapsed_ms"> · {{ fmtMs(e.elapsed_ms) }}</span></div> + </div> + </div> + </div> + + <!-- Pagination — its own card, exactly like 图片管理 (ImagesView) --> + <div v-if="total && totalPages > 1" class="card !p-3 flex items-center justify-between gap-3"> + <div class="text-xs text-slate-500 tabular-nums px-2"> + <span class="text-slate-700">{{ (page - 1) * pageSize + 1 }}–{{ Math.min(total, page * pageSize) }}</span> + / {{ total }} 张 + </div> + <div class="flex items-center gap-1"> + <template v-for="(n, i) in pageNumbers" :key="i"> + <span v-if="n === null" class="px-1 text-slate-300">…</span> + <button v-else @click="goPage(n)" class="pg" :class="page === n && 'pg-on'">{{ n }}</button> + </template> + </div> + </div> + + <!-- Lightbox (shared component) --> + <MediaLightbox + v-if="lightbox" + :src="generatedUrl(lightbox.file)" + :kind="lightbox.kind" + :prompt="lightbox.prompt" + :meta="[lightbox.model, lightbox.ratio, lightbox.resolution, lightbox.duration, fmtMs(lightbox.elapsed_ms)].filter(Boolean).join(' · ')" + :download-name="lightbox.file" + @close="lightbox = null" /> + + <!-- Toast --> + <transition name="fade"> + <div v-if="toast" + class="fixed bottom-6 left-1/2 -translate-x-1/2 z-[60] bg-slate-900 text-white text-xs px-4 py-2 rounded-lg shadow-lg"> + {{ toast }} + </div> + </transition> + </section> +</template> + +<style scoped> +.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } +.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; } +.fade-enter-from, .fade-leave-to { opacity: 0; } + +/* Numbered pagination buttons — light-theme twin of the admin .pg */ +.pg { + min-width: 1.75rem; + padding: 0.3rem 0.55rem; + font-size: 0.72rem; + font-weight: 500; + text-align: center; + border-radius: 0.45rem; + color: rgb(71 85 105); + background: rgb(241 245 249); + box-shadow: inset 0 0 0 1px rgb(15 23 42 / 0.06); + transition: background 0.15s, color 0.15s, box-shadow 0.15s; +} +.pg:hover:not(.pg-on) { background: rgb(226 232 240); color: rgb(15 23 42); } +.pg-on { background: rgb(15 23 42); color: white; box-shadow: none; } +</style> diff --git a/frontend/src/views/UsersView.vue b/frontend/src/views/UsersView.vue new file mode 100644 index 0000000..f64fd69 --- /dev/null +++ b/frontend/src/views/UsersView.vue @@ -0,0 +1,569 @@ +<script setup> +import { ref, computed, onMounted } from 'vue' +import { api, jsonBody } from '../api' +import { fmtTs, fmtDate, fmtClock } from '../utils/format' +import Icon from '../components/Icon.vue' +import SelectMenu from '../components/SelectMenu.vue' +import { points } from '../credits' + +const items = ref([]) +const stats = ref({ total: 0, active: 0, disabled: 0, admins: 0, credits_total: 0 }) +const loading = ref(false) +const search = ref('') +const roleFilter = ref('') // '' | 'admin' | 'user' +const statusFilter = ref('') // '' | 'active' | 'disabled' + +const page = ref(1) +const pageSize = ref(20) + +const showAdd = ref(false) +const editing = ref(null) +const toast = ref('') + +const addForm = ref({ email: '', name: '', password: '', role: 'user', credits: 0 }) + +const STATUS_OPTIONS = [ + { value: 'active', label: '正常' }, + { value: 'disabled', label: '禁用' }, +] + +// 代理 = 走代理价的客户(不享管理权限)。 +// 管理员唯一:不能通过用户管理创建/改成管理员,所以选项只给 普通用户 / 代理。 +const ROLE_OPTIONS = [ + { value: 'user', label: '普通用户' }, + { value: 'agent', label: '代理' }, +] +const roleLabel = (r) => ({ user: '用户', agent: '代理', admin: '管理员' }[r] || '用户') + +async function load() { + loading.value = true + const r = await api('/users') + items.value = r.data?.data || [] + stats.value = r.data?.stats || stats.value + loading.value = false +} +onMounted(load) + +const filtered = computed(() => { + const q = search.value.trim().toLowerCase() + // Newest first — created_at desc, falling back to id so users without a + // timestamp still get a stable order. + const sorted = [...items.value].sort((a, b) => (b.created_at || 0) - (a.created_at || 0)) + return sorted.filter((u) => { + if (roleFilter.value && u.role !== roleFilter.value) return false + if (statusFilter.value && u.status !== statusFilter.value) return false + if (q && !( + (u.email || '').toLowerCase().includes(q) || + (u.name || '').toLowerCase().includes(q) || + (u.id || '').toLowerCase().includes(q) + )) return false + return true + }) +}) + +// Client-side pagination — user list is bounded. +const totalPages = computed(() => Math.max(1, Math.ceil(filtered.value.length / pageSize.value))) +const pagedItems = computed(() => { + const start = (page.value - 1) * pageSize.value + return filtered.value.slice(start, start + pageSize.value) +}) +function goPage(n) { + const target = Math.max(1, Math.min(totalPages.value, n)) + if (target !== page.value) page.value = target +} +function setFilter(fn) { fn(); page.value = 1 } +const pageNumbers = computed(() => { + const n = totalPages.value + const cur = page.value + if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1) + const want = new Set([1, n, cur - 1, cur, cur + 1]) + if (cur <= 3) { want.add(2); want.add(3); want.add(4) } + if (cur >= n - 2) { want.add(n - 1); want.add(n - 2); want.add(n - 3) } + const list = [...want].filter((x) => x >= 1 && x <= n).sort((a, b) => a - b) + const out = [] + for (let i = 0; i < list.length; i++) { + if (i > 0 && list[i] - list[i - 1] > 1) out.push(null) + out.push(list[i]) + } + return out +}) + +let toastTimer = null +function flash(m) { + toast.value = m + clearTimeout(toastTimer) + toastTimer = setTimeout(() => (toast.value = ''), 2000) +} + +async function createUser() { + if (!addForm.value.email.trim()) { flash('请输入邮箱'); return } + const r = await api('/users', jsonBody('POST', addForm.value)) + if (r.ok) { + showAdd.value = false + addForm.value = { email: '', name: '', password: '', role: 'user', credits: 0 } + flash('用户已创建') + load() + } else flash(r.data?.detail || '创建失败') +} + +async function saveEdit() { + const u = editing.value + // Email + 用户名 are intentionally NOT in the patch — they're displayed + // read-only in the form, and the admin shouldn't be in the habit of + // rewriting a user's identity from this page. + const patch = { + status: u.status, + credits: u.credits, + role: u.role, + } + if (u._newPassword) patch.password = u._newPassword + const r = await api(`/users/${u.id}`, jsonBody('PATCH', patch)) + if (r.ok) { editing.value = null; flash('已保存'); load() } + else flash(r.data?.detail || '保存失败') +} + +async function toggleStatus(u) { + // Optimistic: flip instantly so the switch moves the moment it's clicked; + // persist in the background and revert on failure (no full table reload). + const prev = u.status + const next = u.status === 'active' ? 'disabled' : 'active' + u.status = next + const r = await api(`/users/${u.id}`, jsonBody('PATCH', { status: next })) + if (r.ok) flash(next === 'active' ? '已启用' : '已禁用') + else { u.status = prev; flash(r.data?.detail || '操作失败') } +} + +async function delUser(u) { + if (!confirm(`删除用户 ${u.email}? 此操作不可恢复`)) return + const r = await api(`/users/${u.id}`, { method: 'DELETE' }) + if (r.ok) { flash('已删除'); load() } else flash(r.data?.detail || '删除失败') +} + +// ===== 多选删除 ===== +const selected = ref(new Set()) +function toggleSelect(id) { + const s = new Set(selected.value) + s.has(id) ? s.delete(id) : s.add(id) + selected.value = s +} +const allSelected = computed(() => + filtered.value.length > 0 && filtered.value.every((u) => selected.value.has(u.id))) +function toggleSelectAll() { + const s = new Set(selected.value) + if (allSelected.value) filtered.value.forEach((u) => s.delete(u.id)) + else filtered.value.forEach((u) => s.add(u.id)) + selected.value = s +} +async function delSelected() { + const ids = [...selected.value] + if (!ids.length) return + if (!confirm(`确认删除选中的 ${ids.length} 个用户?此操作不可恢复。`)) return + const r = await api('/users/delete-bulk', jsonBody('POST', { ids })) + if (r.ok) { flash(`已删除 ${r.data?.deleted ?? ids.length} 个`); selected.value = new Set(); load() } + else flash(r.data?.detail || '删除失败') +} + +async function quickCredits(u, delta) { + const r = await api(`/users/${u.id}/credits`, jsonBody('POST', { delta })) + if (r.ok) { flash(`已${delta > 0 ? '增加' : '扣除'} ${Math.abs(delta).toLocaleString('en-US')} 积分`); load() } + else flash(r.data?.detail || '调整失败') +} +</script> + +<template> + <section class="space-y-4"> + <!-- KPI strip — same shape as LogsView / ModelsView --> + <div class="grid grid-cols-2 md:grid-cols-4 gap-3"> + <div class="card p-4"> + <div class="text-[11px] uppercase tracking-wider text-white/45">用户总数</div> + <div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.total }}</div> + </div> + <div class="card p-4"> + <div class="text-[11px] uppercase tracking-wider text-emerald-300/80">正常</div> + <div class="text-2xl font-semibold mt-1 tabular-nums text-emerald-300">{{ stats.active }}</div> + </div> + <div class="card p-4"> + <div class="text-[11px] uppercase tracking-wider text-fuchsia-300/80">管理员</div> + <div class="text-2xl font-semibold mt-1 tabular-nums text-fuchsia-300">{{ stats.admins }}</div> + </div> + <div class="card p-4"> + <div class="text-[11px] uppercase tracking-wider text-amber-300/80">总积分</div> + <div class="text-2xl font-semibold mt-1 tabular-nums text-amber-300">{{ points(stats.credits_total).toLocaleString('en-US') }}</div> + </div> + </div> + + <!-- Toolbar --> + <div class="card p-3 flex items-center gap-3 flex-wrap"> + <div class="flex items-center gap-1"> + <button @click="setFilter(() => roleFilter = '')" class="fp" :class="roleFilter === '' && 'fp-on'">全部角色</button> + <button @click="setFilter(() => roleFilter = 'admin')" class="fp" :class="roleFilter === 'admin' && 'fp-fuchsia'"> + <span class="w-1.5 h-1.5 rounded-full bg-fuchsia-400"></span>管理员 + </button> + <button @click="setFilter(() => roleFilter = 'agent')" class="fp" :class="roleFilter === 'agent' && 'fp-amber'"> + <span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span>代理 + </button> + <button @click="setFilter(() => roleFilter = 'user')" class="fp" :class="roleFilter === 'user' && 'fp-on'">用户</button> + </div> + <div class="w-px h-5 bg-white/10"></div> + <div class="flex items-center gap-1"> + <button @click="setFilter(() => statusFilter = '')" class="fp" :class="statusFilter === '' && 'fp-on'">所有状态</button> + <button @click="setFilter(() => statusFilter = 'active')" class="fp" :class="statusFilter === 'active' && 'fp-emerald'"> + <span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>正常 + </button> + <button @click="setFilter(() => statusFilter = 'disabled')" class="fp" :class="statusFilter === 'disabled' && 'fp-rose'"> + <span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>禁用 + </button> + </div> + <div class="flex-1 min-w-[200px]"> + <input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索 邮箱 / 用户名 / ID…" /> + </div> + <button v-if="selected.size" @click="delSelected" class="btn-soft danger" title="删除选中的用户"> + <Icon name="trash" class="w-3.5 h-3.5" /> 删除选中 ({{ selected.size }}) + </button> + <button @click="load" class="btn-soft"> + <Icon name="refresh" class="w-3.5 h-3.5" /> 刷新 + </button> + <button @click="showAdd = true" class="btn-primary"> + <Icon name="plus" class="w-3.5 h-3.5" /> 新建用户 + </button> + </div> + + <!-- Table --> + <div class="card overflow-hidden"> + <div v-if="loading && !items.length" class="text-center text-sm text-white/40 py-20">加载中…</div> + <div v-else-if="!filtered.length" class="flex flex-col items-center gap-3 text-white/40 py-20"> + <span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center"> + <Icon name="accounts" class="w-6 h-6" /> + </span> + <span class="text-sm">{{ items.length ? '没有匹配的用户' : '还没有用户' }}</span> + <button v-if="!items.length" @click="showAdd = true" class="btn-soft mt-1">新建第一个</button> + </div> + + <table v-else class="w-full text-sm table-fixed"> + <colgroup> + <col class="w-9" /> <!-- select --> + <col class="w-40" /> <!-- username --> + <col /> <!-- email (flex) --> + <col class="w-20" /> <!-- role --> + <col class="w-16" /> <!-- status switch --> + <col class="w-24" /> <!-- credits --> + <col class="w-20" /> <!-- generation count --> + <col class="w-28" /> <!-- registered --> + <col class="w-28" /> <!-- last login --> + <col class="w-32" /> <!-- login IP --> + <col class="w-24" /> <!-- actions --> + </colgroup> + <thead> + <tr class="text-[10px] uppercase tracking-[0.2em] text-white/40 border-b border-white/[0.06]"> + <th class="text-center px-3 py-3 font-medium"> + <input type="checkbox" :checked="allSelected" @change="toggleSelectAll" + class="chk" title="全选" /> + </th> + <th class="text-left px-5 py-3 font-medium">用户名</th> + <th class="text-left px-3 py-3 font-medium">邮箱</th> + <th class="text-left px-3 py-3 font-medium">角色</th> + <th class="text-left px-3 py-3 font-medium">状态</th> + <th class="text-right px-3 py-3 font-medium">积分</th> + <th class="text-right px-3 py-3 font-medium">生图次数</th> + <th class="text-left px-3 py-3 font-medium">注册时间</th> + <th class="text-left px-3 py-3 font-medium">最近登录</th> + <th class="text-left px-3 py-3 font-medium">登录 IP</th> + <th class="text-right px-3 py-3 font-medium">操作</th> + </tr> + </thead> + <tbody> + <tr v-for="u in pagedItems" :key="u.id" + class="border-b border-white/[0.04] hover:bg-white/[0.03] transition-colors"> + <td class="px-3 py-3.5 align-middle text-center"> + <input type="checkbox" :checked="selected.has(u.id)" @change="toggleSelect(u.id)" @click.stop + class="chk" /> + </td> + <td class="px-5 py-3.5 align-middle text-sm font-medium text-white/90 truncate" :title="u.name || '—'"> + {{ u.name || '—' }} + </td> + <td class="px-3 py-3.5 align-middle text-xs text-white/75 truncate" :title="u.email"> + {{ u.email || '—' }} + </td> + <td class="px-3 py-3.5 align-middle"> + <span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1 whitespace-nowrap" + :class="u.role === 'admin' + ? 'bg-fuchsia-500/10 text-fuchsia-300 ring-fuchsia-400/30' + : u.role === 'agent' + ? 'bg-amber-500/10 text-amber-300 ring-amber-400/30' + : 'bg-white/[0.06] text-white/70 ring-white/15'"> + <span class="w-1.5 h-1.5 rounded-full" + :class="u.role === 'admin' ? 'bg-fuchsia-400' : u.role === 'agent' ? 'bg-amber-400' : 'bg-slate-400'"></span> + {{ roleLabel(u.role) }} + </span> + </td> + <td class="px-3 py-3.5 align-middle"> + <button class="sw" :class="u.status === 'active' && 'sw-on'" + :aria-pressed="u.status === 'active'" + :title="u.status === 'active' ? '点击禁用账号' : '点击启用账号'" + @click="toggleStatus(u)"> + <span class="sw-thumb"></span> + </button> + </td> + <td class="px-3 py-3.5 align-middle text-right tabular-nums text-white/85 whitespace-nowrap"> + {{ points(u.credits).toLocaleString('en-US') }} + </td> + <td class="px-3 py-3.5 align-middle text-right tabular-nums whitespace-nowrap" + :class="u.generation_count > 0 ? 'text-white/85' : 'text-white/25'"> + {{ (u.generation_count || 0).toLocaleString('en-US') }} + </td> + <td class="px-3 py-3.5 align-middle text-xs whitespace-nowrap"> + <div v-if="u.created_at" class="leading-tight" :title="fmtTs(u.created_at)"> + <div class="text-white/65 tabular-nums">{{ fmtDate(u.created_at) }}</div> + <div class="text-white/35 tabular-nums">{{ fmtClock(u.created_at) }}</div> + </div> + <span v-else class="text-white/25">—</span> + </td> + <td class="px-3 py-3.5 align-middle text-xs whitespace-nowrap"> + <div v-if="u.last_login_at" class="leading-tight" :title="fmtTs(u.last_login_at)"> + <div class="text-white/65 tabular-nums">{{ fmtDate(u.last_login_at) }}</div> + <div class="text-white/35 tabular-nums">{{ fmtClock(u.last_login_at) }}</div> + </div> + <span v-else class="text-white/25">从未登录</span> + </td> + <td class="px-3 py-3.5 align-middle text-xs font-mono text-white/55 truncate" :title="u.last_login_ip || ''"> + {{ u.last_login_ip || '—' }} + </td> + <td class="px-3 py-3.5 align-middle text-right whitespace-nowrap"> + <div class="inline-flex items-center gap-1"> + <button @click="editing = JSON.parse(JSON.stringify(u))" class="act" title="编辑"> + <Icon name="config" class="w-3.5 h-3.5" /> + </button> + <button @click="delUser(u)" class="act danger" title="删除"> + <Icon name="trash" class="w-3.5 h-3.5" /> + </button> + </div> + </td> + </tr> + </tbody> + </table> + + <!-- pagination --> + <div v-if="!loading && totalPages > 1" + class="flex items-center justify-between gap-3 border-t border-white/[0.06] px-5 py-3 text-xs text-white/55"> + <div> + <span class="tabular-nums text-white/85">{{ (page - 1) * pageSize + 1 }}–{{ Math.min(filtered.length, page * pageSize) }}</span> + <span class="ml-1">/ {{ filtered.length }} 条</span> + </div> + <div class="flex items-center gap-1"> + <template v-for="(n, i) in pageNumbers" :key="i"> + <span v-if="n === null" class="px-1 text-white/35">…</span> + <button v-else @click="goPage(n)" class="pg" :class="page === n && 'pg-on'">{{ n }}</button> + </template> + </div> + </div> + </div> + + <!-- Add modal --> + <div v-if="showAdd" + class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center p-4 overflow-y-auto" + @click.self="showAdd = false"> + <div class="card !shadow-2xl my-12 w-full max-w-md"> + <div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between"> + <h2 class="text-sm font-semibold">新建用户</h2> + <button @click="showAdd = false" class="text-white/40 hover:text-white"> + <Icon name="close" class="w-5 h-5" /> + </button> + </div> + <div class="p-5 space-y-3"> + <div> + <label class="lbl">邮箱 <span class="text-rose-300">*</span></label> + <input v-model="addForm.email" class="field" placeholder="user@example.com" /> + </div> + <div> + <label class="lbl">用户名</label> + <input v-model="addForm.name" class="field" placeholder="6-24位,仅字母数字" /> + </div> + <div> + <label class="lbl">初始密码</label> + <input v-model="addForm.password" type="password" class="field" placeholder="留空表示不设密码;否则需满足8-24位且含大小写/数字/符号" /> + </div> + <div> + <label class="lbl">初始积分</label> + <input v-model.number="addForm.credits" type="number" min="0" step="1" class="field" /> + </div> + <div> + <label class="lbl">角色</label> + <SelectMenu v-model="addForm.role" :options="ROLE_OPTIONS" /> + </div> + <div class="flex justify-end gap-2 pt-2"> + <button @click="showAdd = false" class="btn-soft">取消</button> + <button @click="createUser" class="btn-primary">创建</button> + </div> + </div> + </div> + </div> + + <!-- Edit modal --> + <div v-if="editing" + class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center p-4 overflow-y-auto" + @click.self="editing = null"> + <div class="card !shadow-2xl my-12 w-full max-w-md"> + <div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between"> + <h2 class="text-sm font-semibold">编辑用户</h2> + <button @click="editing = null" class="text-white/40 hover:text-white"> + <Icon name="close" class="w-5 h-5" /> + </button> + </div> + <div class="p-5 space-y-3"> + <!-- Email + 用户名 are show-only — identity edits go through register + or a future support flow, not from this maintenance screen. --> + <div> + <label class="lbl">邮箱</label> + <input :value="editing.email" disabled class="field font-mono" /> + </div> + <div> + <label class="lbl">用户名</label> + <input :value="editing.name" disabled class="field" /> + </div> + <div> + <label class="lbl">状态</label> + <SelectMenu v-model="editing.status" :options="STATUS_OPTIONS" /> + </div> + <div> + <label class="lbl">角色</label> + <!-- 管理员唯一:管理员账号角色锁定,不可改;其他人只能在 普通用户/代理 间切换 --> + <input v-if="editing.role === 'admin'" value="管理员(唯一,不可更改)" disabled class="field" /> + <SelectMenu v-else v-model="editing.role" :options="ROLE_OPTIONS" /> + </div> + <div> + <label class="lbl">积分</label> + <input v-model.number="editing.credits" type="number" min="0" step="1" class="field" /> + </div> + <div> + <label class="lbl">重置密码 <span class="text-white/35">(留空保持不变)</span></label> + <input v-model="editing._newPassword" type="password" class="field" placeholder="新密码(8-24位,含大小写/数字/符号)" autocomplete="new-password" /> + </div> + <div class="flex justify-end gap-2 pt-2"> + <button @click="editing = null" class="btn-soft">取消</button> + <button @click="saveEdit" class="btn-primary">保存</button> + </div> + </div> + </div> + </div> + + <!-- Toast --> + <transition name="fade"> + <div v-if="toast" + class="fixed bottom-6 left-1/2 -translate-x-1/2 z-[60] bg-slate-900 text-white text-xs px-4 py-2 rounded-lg shadow-lg"> + {{ toast }} + </div> + </transition> + </section> +</template> + +<style scoped> +.lbl { + display: block; + font-size: 0.72rem; + font-weight: 500; + color: rgb(255 255 255 / 0.55); + margin-bottom: 0.4rem; +} + +/* --- filter pills (mirrors LogsView/ModelsView) */ +.fp { + display: inline-flex; align-items: center; gap: 0.35rem; + padding: 0.35rem 0.7rem; font-size: 0.72rem; + border-radius: 0.55rem; + color: rgb(255 255 255 / 0.65); + background: rgb(255 255 255 / 0.05); + box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.06); + transition: background 0.15s, color 0.15s, box-shadow 0.15s; +} +.fp:hover { background: rgb(255 255 255 / 0.09); color: white; } +.fp-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); box-shadow: none; } +.fp-emerald { + background: rgb(16 185 129 / 0.22); + color: rgb(110 231 183); + box-shadow: inset 0 0 0 1px rgb(110 231 183 / 0.45); +} +.fp-rose { + background: rgb(244 63 94 / 0.22); + color: rgb(253 164 175); + box-shadow: inset 0 0 0 1px rgb(253 164 175 / 0.45); +} +.fp-fuchsia { + background: rgb(217 70 239 / 0.22); + color: rgb(245 208 254); + box-shadow: inset 0 0 0 1px rgb(245 208 254 / 0.45); +} +.fp-amber { + background: rgb(245 158 11 / 0.22); + color: rgb(252 211 77); + box-shadow: inset 0 0 0 1px rgb(252 211 77 / 0.45); +} + +/* --- icon-only action buttons */ +.act { + display: inline-flex; align-items: center; justify-content: center; + width: 1.9rem; height: 1.9rem; + border-radius: 0.5rem; + color: rgb(255 255 255 / 0.7); + background: rgb(255 255 255 / 0.04); + box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08); + transition: background 0.15s, color 0.15s; +} +.act:hover { background: rgb(255 255 255 / 0.1); color: white; } +.act.danger { + color: rgb(253 164 175); + background: rgb(244 63 94 / 0.12); + box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3); +} +.act.danger:hover { color: white; background: rgb(244 63 94 / 0.25); } + +/* disabled inputs in the edit modal — readable, but visually 'cool' so the + admin knows they can't change them. */ +.field:disabled { + opacity: 0.65; + cursor: not-allowed; + background: rgb(255 255 255 / 0.025); +} + +/* iOS-style switch for the 状态 column — mirrors the one in ModelsView. */ +.sw { + position: relative; + width: 2.25rem; height: 1.3rem; + border-radius: 9999px; + background: rgb(255 255 255 / 0.12); + box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08); + transition: background 0.18s ease; +} +.sw-thumb { + position: absolute; + top: 2px; left: 2px; + width: calc(1.3rem - 4px); height: calc(1.3rem - 4px); + border-radius: 9999px; + background: white; + box-shadow: 0 1px 2px rgb(15 23 42 / 0.3); + transition: transform 0.18s ease; +} +.sw-on { + background: rgb(16 185 129 / 0.7); + box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.5); +} +.sw-on .sw-thumb { transform: translateX(calc(2.25rem - 1.3rem)); } + +/* --- numbered pagination buttons */ +.pg { + min-width: 1.75rem; + padding: 0.3rem 0.55rem; + font-size: 0.72rem; + font-weight: 500; + text-align: center; + border-radius: 0.45rem; + color: rgb(255 255 255 / 0.7); + background: rgb(255 255 255 / 0.04); + box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08); + transition: background 0.15s, color 0.15s; +} +.pg:hover:not(.pg-on) { background: rgb(255 255 255 / 0.1); color: white; } +.pg-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); box-shadow: none; } + +.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; } +.fade-enter-from, .fade-leave-to { opacity: 0; } +</style> diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..0ed6158 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,39 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import tailwindcss from '@tailwindcss/vite' + +// Dev server proxies backend routes to the Go backend (default :6666) so the +// frontend can use relative paths exactly like the old static UI did. Override +// the target with VITE_BACKEND when the backend runs elsewhere. +const backend = process.env.VITE_BACKEND || 'http://127.0.0.1:6666' + +// Vite's underlying http-proxy doesn't add X-Forwarded-For / X-Real-IP by +// default, so the backend just sees the proxy's loopback address as the +// caller and stamps every login as 127.0.0.1. This hook forwards the real +// socket peer instead. (Only useful when the dev server is reachable from +// another device on the LAN — same-machine browsing is genuinely 127.0.0.1.) +function forwardClientIp(proxy) { + proxy.on('proxyReq', (proxyReq, req) => { + const ip = (req.socket && req.socket.remoteAddress) || '' + if (!ip) return + const existing = req.headers['x-forwarded-for'] + proxyReq.setHeader('x-forwarded-for', existing ? `${existing}, ${ip}` : ip) + if (!req.headers['x-real-ip']) proxyReq.setHeader('x-real-ip', ip) + }) +} + +export default defineConfig({ + plugins: [vue(), tailwindcss()], + server: { + port: 5173, + proxy: { + // Only the admin API is proxied — bare /admin/* is an SPA route now + // (the admin shell), handled client-side by vue-router. + '/admin/api': { target: backend, changeOrigin: true, configure: forwardClientIp }, + '/health': { target: backend, changeOrigin: true, configure: forwardClientIp }, + // Generated artifacts are served from /images. + '/images': { target: backend, changeOrigin: true, configure: forwardClientIp }, + '/v1': { target: backend, changeOrigin: true, configure: forwardClientIp }, + }, + }, +}) diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..9723684 --- /dev/null +++ b/install.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env sh +# image2api — one-command install (Docker). Run from the repo root: +# sh install.sh +# Brings up Postgres + Redis + RustFS + backend + frontend, and auto-issues a +# Let's Encrypt HTTPS certificate via the built-in acme.sh service. +set -e +cd "$(dirname "$0")" + +# --- docker present? --- +if ! command -v docker >/dev/null 2>&1; then + echo "ERROR: 未安装 Docker。请先安装 Docker + Docker Compose。" + exit 1 +fi + +# --- env file --- +if [ ! -f .env ]; then + echo "==> 生成 .env(从 .env.docker.example),请按提示编辑后重跑" + cp .env.docker.example .env + echo + echo " 必填:DOMAIN(你的域名)、ACME_EMAIL(证书邮箱)、POSTGRES_PASSWORD、S3_SECRET_KEY" + echo " 编辑好后再次执行: sh install.sh" + exit 0 +fi + +# --- backend binary (closed-source, shipped prebuilt) --- +if [ ! -f backend/bin/api ]; then + echo "ERROR: 缺少 backend/bin/api(后端二进制)。" + echo " 请从 Releases 下载 linux/amd64 的 api 放到 backend/bin/api 后重试。" + exit 1 +fi +chmod +x backend/bin/api 2>/dev/null || true + +# --- up --- +echo "==> docker compose up -d --build" +docker compose up -d --build + +DOMAIN_VAL="$(grep -E '^DOMAIN=' .env | head -1 | cut -d= -f2-)" +echo +echo "完成 ✅ 打开 https://${DOMAIN_VAL:-<你的域名>}/" +echo "证书签发进度: docker compose logs -f acme" +echo "后端日志: docker compose logs -f backend" +echo "停止: docker compose down"