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 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 22:59:04 +08:00
co-authored by Claude Opus 4.8
commit 606caaf047
142 changed files with 33648 additions and 0 deletions
+28
View File
@@ -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
+60
View File
@@ -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/
+21
View File
@@ -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.
+271
View File
@@ -0,0 +1,271 @@
<div align="center">
<h1>image2api</h1>
**Multi-provider AI image / video generation gateway — one OpenAI-compatible API, six platforms aggregated, a ready-to-run operations system**
<sub>Live instance (brand): [Vivid AI · vividai.run](https://vividai.run)</sub>
[简体中文](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)
<br/>
<img src="docs/screenshots/playground.png" alt="image2api — playground" width="860" />
</div>
---
## 📖 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
<table>
<tr>
<td width="50%" valign="top">
### 🎨 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
</td>
<td width="50%" valign="top">
### 🔐 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
</td>
</tr>
</table>
**🧰 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://<your-domain>/`; watch cert progress with `docker compose logs -f acme`. With `DOMAIN=localhost` a self-signed cert is used (local testing).
<details>
<summary><b>Option 2: Manual install</b> — bring your own PostgreSQL / Redis / RustFS / Nginx (click to expand)</summary>
<br/>
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.
</details>
## 🧱 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
<!-- After creating the GitHub repo, uncomment the line below and replace OWNER with your username to show the chart: -->
<!-- [![Star History Chart](https://api.star-history.com/svg?repos=OWNER/image2api&type=Date)](https://star-history.com/#OWNER/image2api&Date) -->
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.
<div align="center">
If this project helps you, a ⭐ Star is much appreciated!
</div>
+271
View File
@@ -0,0 +1,271 @@
<div align="center">
<h1>image2api</h1>
**多供应商 AI 生图 / 生视频网关 —— 一套 OpenAI 兼容 API,聚合六大平台,开箱即用的运营系统**
<sub>线上实例(品牌):[Vivid AI · vividai.run](https://vividai.run)</sub>
**简体中文** | [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) · [交流群](#-交流--联系)
<br/>
<img src="docs/screenshots/playground.png" alt="image2api — 画图台" width="860" />
</div>
---
## 📖 目录
- [简介](#-简介)
- [界面预览](#-界面预览)
- [核心功能](#-核心功能)
- [支持的模型 / 供应商](#-支持的模型--供应商)
- [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) |
## 🚀 核心功能
<table>
<tr>
<td width="50%" valign="top">
### 🎨 生成能力
- 生图 + 生视频一站式,支持**图生图 / 参考图**(首帧、末帧、风格参考)
- 多分辨率(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** · **参数错→直接报错**
- **预扣额度**:生成前原子扣减,失败自动退回,杜绝并发超额
</td>
<td width="50%" valign="top">
### 🔐 Token 自动保活
- 一次性轮换 token(Krea / Imagine)**到期前 10 分钟主动续期**,新 token 自动落库
- Adobe cookie 定时换 token;纯 JWT 到期自动判死
- 每日额度按平台重置时间自动恢复 + 重新探测真实余额
### 💳 计费与运营
- 积分制(**预扣 + 失败退款**),按模型 / 分辨率 / 时长精细定价
- **代理价体系**:用户可设为「代理」角色,模型可设代理价;代理用户(含其 API Key 调用)自动按代理价计费,未设代理价则回退普通价
- **CDK 兑换码**充值 · **邀请奖励** · 邮箱注册 / 验证码 / 找回密码
- 三级角色:普通用户 / 代理 / 管理员(唯一)
### 🖥️ 用户前台(Vue 3)
- 画图台 · 创作记录画廊 · 生成日志(含失败原因 / 来源标签)
- API 文档 · API Key 管理 · 邀请 · 关于,亮 / 暗主题
### 🛠️ 管理后台
- 概览看板(趋势 / DAU / 失败 Top / 消费榜)
- 模型管理(普通价 + 代理价) · 账号管理(批量导入 / 去重 / 额度) · 全站日志 · 用户管理(设为代理) · CDK · 展示位 · 站点配置
</td>
</tr>
</table>
**🧰 工程亮点**: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` 时用自签证书(本地测试)。
<details>
<summary><b>方式二:手动安装</b> — 自建 PostgreSQL / Redis / RustFS / Nginx(点击展开)</summary>
<br/>
自备 **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`。
</details>
## 🧱 技术栈
| 层 | 技术 |
|---|---|
| 后端 | 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
<!-- 建好 GitHub 仓库后,取消下面这行注释并把 OWNER 换成你的用户名即可显示趋势图: -->
<!-- [![Star History Chart](https://api.star-history.com/svg?repos=OWNER/image2api&type=Date)](https://star-history.com/#OWNER/image2api&Date) -->
觉得有用就点个 ⭐ 吧 —— 建好仓库后取消上方注释即可展示 Star 趋势图。
## 📄 License
本项目(前端 + 后端)基于 [MIT](LICENSE) 协议开源,可自由使用、修改、商用与二次分发。
<div align="center">
如果这个项目对你有帮助,欢迎 ⭐ Star 支持!
</div>
+6
View File
@@ -0,0 +1,6 @@
# Keep secrets, local data and build artifacts out of the Docker build context.
.env
.env.*
bin/
data/
*.log
+20
View File
@@ -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=
+9
View File
@@ -0,0 +1,9 @@
.gocache/
.gomodcache/
backend.exe
*.exe
*.out
*.test
.env
backend.out.log
backend.err.log
+24
View File
@@ -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"]
+55
View File
@@ -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.
+50
View File
@@ -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)
}
}
+45
View File
@@ -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)
}
+71
View File
@@ -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
)
+178
View File
@@ -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=
+174
View File
@@ -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
}
+51
View File
@@ -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
}
+174
View File
@@ -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
}
+234
View File
@@ -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
}
@@ -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})
}
@@ -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})
}
+350
View File
@@ -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,
})
}
+104
View File
@@ -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
}
+13
View File
@@ -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})
}
+88
View File
@@ -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
}
@@ -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})
}
+45
View File
@@ -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})
}
+25
View File
@@ -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())})
}
@@ -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())}})
}
@@ -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
}
@@ -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
}
+373
View File
@@ -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
}
+70
View File
@@ -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
}
@@ -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()
}
}
+166
View File
@@ -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
}
+190
View File
@@ -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{},
}
}
Binary file not shown.
+39
View File
@@ -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
}
+875
View File
@@ -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]
}
+466
View File
@@ -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
}
+144
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
+151
View File
@@ -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{
"registerProtocolHandlerfunction registerProtocolHandler() { [native code] }",
"storage[object StorageManager]",
"locks[object LockManager]",
"appCodeNameMozilla",
"permissions[object Permissions]",
"sharefunction share() { [native code] }",
"webdriverfalse",
"vendorGoogle Inc.",
"mediaDevices[object MediaDevices]",
"cookieEnabledtrue",
"onLinetrue",
"mimeTypes[object MimeTypeArray]",
"credentials[object CredentialsContainer]",
"serviceWorker[object ServiceWorkerContainer]",
"keyboard[object Keyboard]",
"gpu[object GPU]",
"doNotTrack",
"languagezh-CN",
"geolocation[object Geolocation]",
"hardwareConcurrency32",
}
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)
}
@@ -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)
}
+125
View File
@@ -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(`<script[^>]+src="([^"]+)"`)
dataBuildPathRE = regexp.MustCompile(`c/[^/]*/_`)
htmlDataBuildRE = regexp.MustCompile(`<html[^>]*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()
}
+441
View File
@@ -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
}
+242
View File
@@ -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
}
+488
View File
@@ -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
}
+308
View File
@@ -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)
}
@@ -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
}
+326
View File
@@ -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))
}
}
+254
View File
@@ -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
}
+391
View File
@@ -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
}
}
+53
View File
@@ -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
}
+134
View File
@@ -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
}
+650
View File
@@ -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
}
+69
View File
@@ -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
}
@@ -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
}
+99
View File
@@ -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
}
@@ -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()
}
+390
View File
@@ -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
}
+627
View File
@@ -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
}
+568
View File
@@ -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
}
+635
View File
@@ -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
+20
View File
@@ -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
}
+130
View File
@@ -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)
}
+438
View File
@@ -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
}
+584
View File
@@ -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/<owner>/... 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/<owner>/... 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
}
+167
View File
@@ -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()
}
+126
View File
@@ -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
}
+48
View File
@@ -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)
}
+169
View File
@@ -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:<ip>|<identifier> — targeted guessing of one account from one IP (5 free).
// ip:<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()
}
+282
View File
@@ -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
}
+46
View File
@@ -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
}
+79
View File
@@ -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)
}
@@ -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)
}
+103
View File
@@ -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
}
+20
View File
@@ -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)
}
+81
View File
@@ -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
}
+117
View File
@@ -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()
}
File diff suppressed because it is too large Load Diff
+200
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
+161
View File
@@ -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
}
+294
View File
@@ -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()
}
+165
View File
@@ -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://<DOMAIN>/ (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:
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 613 KiB

+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.vite
*.log
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.env
.env.local
*.local
+23
View File
@@ -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
+36
View File
@@ -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.
+74
View File
@@ -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;
}
}
@@ -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
@@ -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
) &
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Vivid 首页</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1986
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -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"
}
}
+17
View File
@@ -0,0 +1,17 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="vbg" x1="6" y1="4" x2="42" y2="44" gradientUnits="userSpaceOnUse">
<stop stop-color="#a855f7" />
<stop offset="0.55" stop-color="#7c3aed" />
<stop offset="1" stop-color="#ec4899" />
</linearGradient>
<linearGradient id="vgl" x1="24" y1="2" x2="24" y2="30" gradientUnits="userSpaceOnUse">
<stop stop-color="#ffffff" stop-opacity="0.28" />
<stop offset="1" stop-color="#ffffff" stop-opacity="0" />
</linearGradient>
</defs>
<rect x="2" y="2" width="44" height="44" rx="13" fill="url(#vbg)" />
<rect x="2" y="2" width="44" height="22" rx="13" fill="url(#vgl)" />
<path d="M12 15 H19 L24 26 L24 37 Z" fill="#ffffff" fill-opacity="0.95" />
<path d="M36 15 H29 L24 26 L24 37 Z" fill="#ffffff" fill-opacity="0.72" />
</svg>

After

Width:  |  Height:  |  Size: 898 B

Some files were not shown because too many files have changed in this diff Show More