commit 606caaf047acc894b5af0767cbe15556857824c6 Author: chiyi Date: Thu Jun 25 22:53:03 2026 +0800 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 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 0000000..5ef5be1 Binary files /dev/null and b/backend/internal/provider/adobe.zip differ diff --git a/backend/internal/provider/adobe/auth.go b/backend/internal/provider/adobe/auth.go new file mode 100644 index 0000000..7e386f4 --- /dev/null +++ b/backend/internal/provider/adobe/auth.go @@ -0,0 +1,39 @@ +package adobe + +import ( + "context" + "errors" + "net/http" + "strings" +) + +const ( + refreshURL = "https://adobeid-na1.services.adobe.com/ims/check/v6/token?jslVersion=v2-v0.48.0-1-g1e322cb" + clientID = "clio-playground-web" + scopeValue = "AdobeID,firefly_api,openid,pps.read,pps.write,additional_info.projectedProductContext,additional_info.ownerOrg,uds_read,uds_write,ab.manage,read_organizations,additional_info.roles,account_cluster.read,creative_production,profile" +) + +var ErrAdobeCookieEmpty = errors.New("cookie is empty") + +type CookieExchangeResult struct { + AccessToken string + ExpiresIn int + Raw map[string]any +} + +func ExchangeCookieToAccessToken(ctx context.Context, client *http.Client, cookie string) (*CookieExchangeResult, error) { + _ = client + tlsClient, err := NewClient(clientID, "").newTLSClient() + if err != nil { + return nil, err + } + return exchangeCookieWithTLSClient(ctx, tlsClient, cookie) +} + +func normalizeCookie(v string) string { + v = strings.TrimSpace(v) + if strings.HasPrefix(strings.ToLower(v), "cookie:") { + v = strings.TrimSpace(v[len("cookie:"):]) + } + return v +} diff --git a/backend/internal/provider/adobe/client.go b/backend/internal/provider/adobe/client.go new file mode 100644 index 0000000..96fa00a --- /dev/null +++ b/backend/internal/provider/adobe/client.go @@ -0,0 +1,875 @@ +package adobe + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "strings" + "time" + + http "github.com/bogdanfinn/fhttp" + tlsclient "github.com/bogdanfinn/tls-client" + "github.com/bogdanfinn/tls-client/profiles" +) + +const ( + submitURL = "https://firefly-3p.ff.adobe.io/v2/3p-images/generate-async" + image5SubmitURL = "https://image-v5.ff.adobe.io/v1/images/generate-async" + videoSubmitURL = "https://firefly-3p.ff.adobe.io/v2/3p-videos/generate-async" + // Firefly-native video model (project id "firefly-video"): distinct host, + // submit path and storage host from the 3p (veo/luma) video flow. + fireflyVideoSubmitURL = "https://video-v1.ff.adobe.io/v2/videos/generate" + fireflyVideoUploadURL = "https://video-v1.ff.adobe.io/v2/storage/image" + uploadURL = "https://firefly-3p.ff.adobe.io/v2/storage/image" + creditsURL = "https://firefly.adobe.io/v1/credits/balance" + creditsAPIKey = "SunbreakWebUI1" +) + +var ( + ErrAuth = errors.New("adobe auth failed") + ErrQuotaExhausted = errors.New("adobe quota exhausted") + ErrTemporaryUpstream = errors.New("adobe upstream temporary error") +) + +var profileURLs = []string{ + "https://ims-na1.adobelogin.com/ims/profile/v1", + "https://adobeid-na1.services.adobe.com/ims/profile/v1", +} + +type Client struct { + apiKey string + proxy string +} + +func NewClient(apiKey, proxy string) *Client { + return &Client{ + apiKey: defaultString(apiKey, clientID), + proxy: strings.TrimSpace(proxy), + } +} + +func (c *Client) SetProxy(proxy string) { + c.proxy = strings.TrimSpace(proxy) +} + +func (c *Client) ExchangeCookie(ctx context.Context, cookie string) (*CookieExchangeResult, error) { + client, err := c.newTLSClient() + if err != nil { + return nil, err + } + return exchangeCookieWithTLSClient(ctx, client, cookie) +} + +func (c *Client) UploadImage(ctx context.Context, token string, content []byte, contentType, engine string) (string, error) { + client, err := c.newTLSClient() + if err != nil { + return "", err + } + + endpoint := uploadURL + if engine == "firefly-video" { + endpoint = fireflyVideoUploadURL + } + req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(content)) + if err != nil { + return "", err + } + req = req.WithContext(ctx) + req.Header = http.Header{ + "authorization": {"Bearer " + strings.TrimSpace(token)}, + "x-api-key": {c.apiKey}, + "content-type": {defaultString(contentType, "image/png")}, + "accept": {"*/*"}, + "user-agent": {defaultUserAgent}, + http.HeaderOrderKey: { + "authorization", + "x-api-key", + "content-type", + "accept", + "user-agent", + }, + } + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("adobe upload request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + if resp.StatusCode == 401 || resp.StatusCode == 403 { + return "", fmt.Errorf("%w (upload %d %s: %s)", ErrAuth, resp.StatusCode, resp.Header.Get("x-access-error"), clip(body, 300)) + } + if resp.StatusCode != 200 { + return "", fmt.Errorf("adobe upload failed: %d %s", resp.StatusCode, clip(body, 300)) + } + + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return "", err + } + if images, ok := payload["images"].([]any); ok && len(images) > 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 0000000..572834f Binary files /dev/null and b/docs/screenshots/accounts.png differ diff --git a/docs/screenshots/dashboard.png b/docs/screenshots/dashboard.png new file mode 100644 index 0000000..4590b35 Binary files /dev/null and b/docs/screenshots/dashboard.png differ diff --git a/docs/screenshots/logs.png b/docs/screenshots/logs.png new file mode 100644 index 0000000..c92db83 Binary files /dev/null and b/docs/screenshots/logs.png differ diff --git a/docs/screenshots/playground.png b/docs/screenshots/playground.png new file mode 100644 index 0000000..5e66db3 Binary files /dev/null and b/docs/screenshots/playground.png differ diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..a62c6b7 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,4 @@ +node_modules +dist +.vite +*.log diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..7b07160 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +.env +.env.local +*.local diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..43263fd --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,23 @@ +# syntax=docker/dockerfile:1 +# Frontend is open-source: built from source inside the image, then served by +# nginx which also reverse-proxies the API and terminates TLS (certs from the +# acme.sh sidecar via a shared volume). +# ---- build stage ---- +FROM node:22-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY . . +RUN npm run build + +# ---- serve stage ---- +FROM nginx:1.27-alpine +# openssl: self-signed bootstrap cert. The nginx image substitutes ${DOMAIN} in +# /etc/nginx/templates/*.template at startup (limited to DOMAIN via the filter). +RUN apk add --no-cache openssl +COPY --from=build /app/dist /usr/share/nginx/html +COPY default.conf.template /etc/nginx/templates/default.conf.template +COPY docker-entrypoint.d/10-selfsigned.sh /docker-entrypoint.d/10-selfsigned.sh +COPY docker-entrypoint.d/30-cert-watch.sh /docker-entrypoint.d/30-cert-watch.sh +RUN chmod +x /docker-entrypoint.d/10-selfsigned.sh /docker-entrypoint.d/30-cert-watch.sh +EXPOSE 80 443 diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..8ad9c5b --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,36 @@ +# ai-gateway frontend + +Vue 3 + Vite admin console for the ai-gateway backend. This replaces the old +single-file `static/admin.html`. + +## Develop + +```bash +npm install +npm run dev # http://localhost:5173 +``` + +The dev server proxies `/admin`, `/health`, `/generated`, `/v1` to the backend. +Start the backend separately: + +```bash +# from repo root +python app.py # http://0.0.0.0:6060 +``` + +If the backend runs elsewhere, set `VITE_BACKEND` before `npm run dev`: + +```bash +VITE_BACKEND=http://192.168.1.10:6060 npm run dev +``` + +## Build + +```bash +npm run build # outputs static assets to ./dist +npm run preview # serve the production build locally +``` + +When hosting `dist/` on a different origin than the API, set `VITE_API_BASE` +(e.g. `VITE_API_BASE=http://api-host:6060`) at build time, and add that frontend +origin to the backend's `CORS_ORIGINS` env var. diff --git a/frontend/default.conf.template b/frontend/default.conf.template new file mode 100644 index 0000000..397523f --- /dev/null +++ b/frontend/default.conf.template @@ -0,0 +1,74 @@ +# nginx for the docker stack. ${DOMAIN} is filled at container start (envsubst, +# limited to DOMAIN via NGINX_ENVSUBST_FILTER). Port 80 serves the ACME +# http-01 challenge and redirects everything else to HTTPS; port 443 serves the +# SPA + reverse-proxies the API. Certs come from the shared volume, issued/renewed +# by the acme.sh sidecar (a self-signed cert bootstraps 443 before the real one). + +# ---- 80: ACME challenge + redirect to HTTPS ---- +server { + listen 80; + listen [::]:80; + server_name ${DOMAIN}; + + # acme.sh writes http-01 challenge files here (shared volume). + location ^~ /.well-known/acme-challenge/ { + root /var/www/acme; + default_type "text/plain"; + } + location / { + return 301 https://$host$request_uri; + } +} + +# ---- 443: the app ---- +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name ${DOMAIN}; + + ssl_certificate /etc/nginx/certs/live/${DOMAIN}/fullchain.pem; + ssl_certificate_key /etc/nginx/certs/live/${DOMAIN}/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_session_cache shared:SSL:10m; + + root /usr/share/nginx/html; + index index.html; + + client_max_body_size 50m; + + # Proxy headers (inherited by all proxy_pass locations below). + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # video / large-image generation can block minutes — avoid the 60s 504. + proxy_connect_timeout 600s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + + # Hashed build assets never change — cache hard. + location /assets/ { + expires 1y; + add_header Cache-Control "public, max-age=31536000, immutable"; + } + + # SPA fallback; index.html must never be cached (else stale bundle hash). + location / { + try_files $uri $uri/ /index.html; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + } + + # ---- API / media / health -> 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"