diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 1619030a..00000000 --- a/.gitattributes +++ /dev/null @@ -1,17 +0,0 @@ -# 默认按 git 自动识别二进制 -* text=auto - -# Linux 容器内执行的脚本必须是 LF -*.sh text eol=lf -deploy/mysql-init/** text eol=lf - -# Go / SQL / yaml 默认 LF(跨平台一致) -*.go text eol=lf -*.sql text eol=lf -*.yaml text eol=lf -*.yml text eol=lf - -# Windows 端工具脚本保持 CRLF -*.ps1 text eol=crlf -*.bat text eol=crlf -*.cmd text eol=crlf diff --git a/.gitignore b/.gitignore index bdc7a06a..be988b4e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,49 +1,87 @@ -# Secrets -.env -.env.* -!.env.example -*.local - -# Node -node_modules/ -**/node_modules/ -.pnpm-store/ -dist/ -.next/ -.turbo/ - -# Build -**/bin/ -**/coverage/ -*.log +# ============================================================================= +# Go +# ============================================================================= +*.exe +*.dll +*.so +*.dylib +*.test *.out -*.bin -*.tsbuildinfo -*.tar.gz - -# Local caches / scratch files -.gocache/ -.tmp/ -**/.gocache/ - -# Local operation artifacts -backend/img/ -sub2api-account-*.json -scripts/deploy_web_fix.py -tools/check_remote_*.py -tools/inspect_*_trace.py +/bin/ +/dist/ +/deploy/bin/ -# OS -.DS_Store -Thumbs.db +# ============================================================================= +# 本地配置 / 密钥(绝对不能进仓库) +# ============================================================================= +/configs/config.yaml +/configs/*.local.yaml +/deploy/.env +.env +.env.local +# ============================================================================= # IDE +# ============================================================================= .idea/ .vscode/ *.swp +*.swo -# Cursor 私有规则不入仓(项目规则保留) -# .cursor/rules/ 已纳入版本控制(项目级) +# ============================================================================= +# 日志 & 数据 +# ============================================================================= +/logs/ +/deploy/logs/ +*.log +/data/ +/uploads/ +/storage/ + +# ============================================================================= +# 前端 +# ============================================================================= +/web/node_modules/ +/web/dist/ +/web/.cache/ +/web/.vite/ + +# ============================================================================= +# Python(仅 legacy 参考) +# ============================================================================= +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ + +# ============================================================================= +# 打包产物 / 临时目录(不要提交到开源仓库) +# ============================================================================= +*.tgz +*.tar.gz +/_tmp/ +/tmp/ + +# 历史部署产物(运维自己 build 即可,不要带进仓库) +/deploy/bin/ +/deploy/logs/ +/deploy/updates/ +/deploy/web/ + +# ============================================================================= +# 杂项 +# ============================================================================= +.DS_Store +Thumbs.db +*.bak +*.tmp +*_backup_* -# 大图素材原档(按需) -/assets/raw/ +# ============================================================================= +# 根目录随手放的哈希命名图片 / HAR(README 正式图放 docs/screenshots/) +# ============================================================================= +/[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]*.png +/[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]*.jpg +/[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]*.har +!/docs/** diff --git a/API_NOTES.md b/API_NOTES.md new file mode 100644 index 00000000..4fe518c4 --- /dev/null +++ b/API_NOTES.md @@ -0,0 +1,277 @@ +# ChatGPT 图像生成 & 额度查询接口备忘录 + +> 本文记录我们目前复用的 `chatgpt.com` 后端接口,及其请求/响应关键字段。 +> 所有接口都走同一个 `Bearer {AUTH_TOKEN}`,host 固定 `https://chatgpt.com`。 +> 运行环境:`curl_cffi` + `impersonate="chrome124"`(chrome131 有时 TLS 握手失败,chrome124 稳定)。 + +--- + +## 0. 通用请求头 + +绝大多数接口共用下面这套头,区别只在于 `referer` / `x-openai-target-*`: + +``` +authorization: Bearer +accept: */* +accept-language: zh-CN,zh;q=0.9,en;q=0.8 +content-type: application/json +origin: https://chatgpt.com +referer: https://chatgpt.com/ # 或 /c/{conversation_id} +user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 + (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 +oai-language: zh-CN +oai-device-id: # 首次 GET / 后从 cookie 取 +oai-client-version: prod-be885abbfcfe7b1f511e88b3003d9ee44757fbad +oai-client-build-number: 5955942 +``` + +> `AUTH_TOKEN` 是 Bearer JWT,有效期约 10 天。过期后要用户重新登录页面从 network 里抓。 + +--- + +## 1. 额度查询(重点!) + +### `POST /backend-api/conversation/init` + +用途:**新建会话前注册 + 返回当前账号各类功能剩余额度**。 +这就是网页左下角"今日还剩 XX 张图"数字的来源。 + +请求体: + +```json +{ + "gizmo_id": null, + "requested_default_model": null, + "conversation_id": null, + "timezone_offset_min": -480, + "system_hints": ["picture_v2"] +} +``` + +响应体(200): + +```json +{ + "type": "conversation_detail_metadata", + "banner_info": null, + "blocked_features": [], + "model_limits": [], + "limits_progress": [ + { "feature_name": "deep_research", "remaining": 25, "reset_after": "…" }, + { "feature_name": "odyssey", "remaining": 40, "reset_after": "…" }, + { "feature_name": "file_upload", "remaining": 80, "reset_after": "…" }, + { "feature_name": "paste_text_to_file", "remaining": 80, "reset_after": "…" }, + { "feature_name": "image_gen", "remaining": 68, "reset_after": "2026-04-17T18:15:51Z" } + ], + "default_model_slug": "gpt-5-3", + "atlas_mode_enabled": null +} +``` + +关键字段: + +| 字段 | 含义 | +|---|---| +| `limits_progress[].feature_name == "image_gen"` | **生图额度**。Plus 每日满额约 100~120 次 | +| `limits_progress[].remaining` | 当前剩余次数(抓包时是 105/112,当前是 68) | +| `limits_progress[].reset_after` | 下次重置时间(UTC,通常每日一次) | +| `blocked_features` | 被风控限制的功能列表,正常为空 `[]` | +| `default_model_slug` | 账号默认模型(普通 Plus = `gpt-5-3`) | + +用法: +- 每次 `run_once` 开头单独调一次即可做额度监控,**不消耗额度**(只在新建会话时必调,复用会话时可不调)。 +- 独立脚本:`_check_image_gen_quota.py`。 + +--- + +## 2. 生图完整调用链 + +按顺序编号: + +``` +[1] GET / → 拿 oai-did cookie +[2] POST /backend-api/sentinel/chat-requirements → 拿 chat_token (+可选 POW 挑战) +[3] POST /backend-api/conversation/init → 注册 + 查余额(见 §1) +[4] POST /backend-api/f/conversation/prepare → 拿 conduit_token(灰度分桶关键) +[5] POST /backend-api/f/conversation (SSE) → 正式下发 prompt,流式拿 file_id +[6] GET /backend-api/conversation/{conv_id} → 轮询补齐最终 file-service URL +[7] GET /backend-api/files/{file_id}/download → 拿短期签名 URL +[8] GET → 下载图片 bytes +``` + +> 复用现有会话(`FIXED_CONVERSATION_ID` 有值)时跳过 `[3]`,但 `[4][5]` 仍需每次调。 + +--- + +### [2] `POST /backend-api/sentinel/chat-requirements` + +作用:拿 `chat_token`(写进 `openai-sentinel-chat-requirements-token`),以及判断是否要做 POW / Turnstile。 + +请求体: + +```json +{ "p": "gAAAAAC..." } +``` + +响应关键字段: + +```json +{ + "token": "...chat_token...", + "proofofwork": { + "required": true, + "seed": "...", + "difficulty": "0fffff" + } +} +``` + +如果 `proofofwork.required=true`,需用本地 SHA3-512 暴力算 `openai-sentinel-proof-token`(见 `gen_image.py` 的 `generate_proof_token`)。 + +--- + +### [4] `POST /backend-api/f/conversation/prepare` + +作用:**灰度桶分配**。服务器在这里决定本次请求走哪套生图后端(DALL-E 3 preview 或 IMG2 gray-bucket),返回一个 `conduit_token` 代表分桶决策。 + +请求头额外需要: + +``` +openai-sentinel-chat-requirements-token: +openai-sentinel-proof-token: # 若 POW required +``` + +请求体: + +```json +{ + "model": "auto", + "system_hints": ["picture_v2"], + "timezone_offset_min": -480, + "conversation_id": null, // 或已有会话 id + "message_id": "<前端生成 UUID>", + "supports_buffering": true +} +``` + +响应体: + +```json +{ "conduit_token": "ct_...." } +``` + +`conduit_token` 要在 `[5]` 里通过请求头 `x-conduit-token` 传回去。 + +--- + +### [5] `POST /backend-api/f/conversation` (SSE) + +作用:正式提交 prompt 并接收流式响应,里面会陆续下发 `image_gen_task_id` / 初始 `file_id`。 + +请求头额外需要: + +``` +openai-sentinel-chat-requirements-token: +openai-sentinel-proof-token: +x-conduit-token: # 关键!否则不进灰度桶 +accept: text/event-stream +``` + +请求体骨架(精简): + +```json +{ + "action": "next", + "messages": [{ + "id": "", + "author": { "role": "user" }, + "content": { "content_type": "text", "parts": [""] }, + "metadata": {} + }], + "parent_message_id": "", + "model": "auto", + "conversation_id": null, + "system_hints": ["picture_v2"], // ← 必须,开启图像工具 + "force_paragen": false, + "force_rate_limit": false, + "timezone_offset_min": -480, + "reset_rate_limits": false, + "supports_buffering": true +} +``` + +SSE 事件里要抓的字段: + +| 字段 | 位置 | 作用 | +|---|---|---| +| `conversation_id` | `message.metadata` 或顶层 | 后续轮询用 | +| `image_gen_task_id` | `message.metadata.image_gen_async` | 确认任务已发起 | +| `author.name` | tool 消息 | **判灰度关键**:
· `dalle.text2im` / `t2uay3k.sj1i4kz` → DALL-E 3 preview
· IMG2 灰度时会是不同名字(需进一步抓包确认) | +| `content.parts[].asset_pointer` | assistant 消息 | `file-service://file-XXX` 或 `sediment://...` | + +--- + +### [6] `GET /backend-api/conversation/{conversation_id}` + +作用:SSE 结束后轮询补齐最终 file-service URL(尤其灰度会出第二张高清图)。 + +响应:完整会话 JSON,结构里 `mapping` 是消息树。 + +polling 策略(见 `poll_conversation_for_images`): +- 使用 **baseline diff**:请求前先记录 "现有 tool 消息 id 集合",轮询时只看新增的。 +- `sediment://` 代表中间产物,`file-service://` 代表最终版。 +- 如果出现 2 条以上新 tool 消息且最新 `sediment` 连续 4 轮不变 → IMG2 已稳定。 +- 单 tool 消息 + 30s 内没出第二条 → `preview_only`(非灰度)。 +- 最大等待 900s;连续 3 次 429 直接中止。 + +--- + +### [7] `GET /backend-api/files/{file_id}/download` + +响应: + +```json +{ "status": "success", "download_url": "https://files.oaiusercontent.com/…签名URL…" } +``` + +--- + +## 3. 其他已观察到的接口(非必用) + +| 接口 | 方法 | 用途 | 响应 | +|---|---|---|---| +| `/backend-api/image-gen/image-paragen-display` | POST | **前端上报**:告诉后端"已展示 N 张图" | 204 空 | +| `/backend-api/conversation/{id}/async-status` | POST `{"status":null}` | 异步任务健康检查 | `{"status":"OK"}` | +| `/backend-api/accounts/check/v4-2023-04-27` | GET | 账号 features/entitlements | 用来查 `gpt_image_1` / `image_gen_better_text` 等灰度 flag | +| `/backend-api/files/library` | POST | 用户图像库列表 | 不用于本流程 | +| `/backend-api/models` | GET | 当前账号可用模型 | 诊断用 | +| `/backend-api/me` | GET | 用户基本信息 | 诊断用 | + +--- + +## 4. 关键排查经验 + +1. **额度**:看 `/conversation/init` 响应 `limits_progress[image_gen].remaining`。 + Plus 日配额约 100~120,每日 UTC 18:15 附近重置一次。 +2. **灰度桶**:`conduit_token` 每次请求都可能不同,服务端随机分桶。 + 当前账号 `accounts/check` 的 features 里 **缺 `gpt_image_1` / `image_gen_better_text` / `image_gen_v2`**,属于"非白名单账号",灰度命中率极低;HAR 抓包那次是偶发灰度。 +3. **风控**:`blocked_features` 为空且 HTTP 未出现 403,就说明没被封。429 是瞬时限流,退避后即可恢复。 +4. **TLS**:`curl_cffi` 用 `impersonate="chrome124"` 稳定;`chrome131` 偶发 `TLS connect error`。 +5. **网络**:arxlabs.io 代理不稳时直接关掉 `PROXY_TEMPLATE`,走本机 Mihomo/Clash Verge TUN 直连更靠谱。 + +--- + +## 5. 相关脚本索引 + +| 脚本 | 用途 | +|---|---| +| `gen_image.py` | 主生图流程(含重试/轮询/下载) | +| `_check_image_gen_quota.py` | **仅查 `image_gen` 余额**,不消耗额度 | +| `_dump_acc.py` | 完整 dump `/accounts/check`,用于看 feature flag | +| `_check_quota.py` | 遍历多个诊断接口(me/models/accounts/check) | +| `_scan_har_gen.py` / `_scan_har_quota.py` | 扫 HAR 找接口/关键字段 | +| `_har_gen_endpoints.py` / `_dump_init.py` | Dump HAR 里特定接口的完整请求响应 | + +--- + +_最后更新:2026-04-17_ diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..8e42a351 --- /dev/null +++ b/LICENSE @@ -0,0 +1,34 @@ +MIT License + +Copyright (c) 2026 gpt2api contributors + +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. + +================================================================================ + +IMPORTANT — ADDITIONAL DISCLAIMER + +This project reverse-engineers the private APIs of chatgpt.com and is provided +for educational and research purposes only. Use of this software may violate +OpenAI's Terms of Service and the laws of your jurisdiction. The authors and +contributors accept no responsibility for account bans, legal consequences, or +any other damages arising from the use of this software. + +You are solely responsible for complying with all applicable laws, terms of +service, and regulations in your use of this project. diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..ce46c75b --- /dev/null +++ b/Makefile @@ -0,0 +1,60 @@ +.PHONY: help run build tidy test fmt vet lint migrate-up migrate-down migrate-status docker-up docker-down docker-logs + +SHELL := /bin/sh +APP_NAME := gpt2api +BIN_DIR := bin + +CONFIG ?= configs/config.yaml +DSN ?= $(shell awk '/mysql:/,/^[^ ]/' $(CONFIG) | grep -E "^\s*dsn:" | head -1 | sed 's/.*dsn:\s*//;s/"//g') + +help: + @echo "Targets:" + @echo " run - go run cmd/server" + @echo " build - build binary to bin/$(APP_NAME)" + @echo " tidy - go mod tidy" + @echo " test - go test ./..." + @echo " fmt - gofmt -w" + @echo " vet - go vet ./..." + @echo " migrate-up - goose up" + @echo " migrate-down - goose down" + @echo " migrate-status - goose status" + @echo " docker-up - docker compose up -d" + @echo " docker-down - docker compose down" + @echo " docker-logs - docker compose logs -f" + +run: + go run ./cmd/server + +build: + @mkdir -p $(BIN_DIR) + go build -ldflags "-s -w" -o $(BIN_DIR)/$(APP_NAME) ./cmd/server + +tidy: + go mod tidy + +test: + go test ./... + +fmt: + gofmt -w . + +vet: + go vet ./... + +migrate-up: + goose -dir sql/migrations mysql "$(DSN)" up + +migrate-down: + goose -dir sql/migrations mysql "$(DSN)" down + +migrate-status: + goose -dir sql/migrations mysql "$(DSN)" status + +docker-up: + docker compose -f deploy/docker-compose.yml up -d + +docker-down: + docker compose -f deploy/docker-compose.yml down + +docker-logs: + docker compose -f deploy/docker-compose.yml logs -f diff --git a/PROGRESS.md b/PROGRESS.md deleted file mode 100644 index b674205b..00000000 --- a/PROGRESS.md +++ /dev/null @@ -1,435 +0,0 @@ -# gpt2api 开发进度看板 - -> 最近更新:2026-04-27(UX 调优后) -> 主文档:`[README.md](./README.md)` · 规范:`[docs/](./docs/)` · 常驻 AI 规则:`.cursor/rules/` -> -> **项目定位**:基于 GPT / GROK 双账号池的高并发 AIGC 平台,OpenAI 协议兼容;前端 React + Tailwind,后端 Go + MySQL + Redis。 -> **皮肤规范**:默认主题 = 「克莱因蓝(Klein Blue · IKB `#002FA7`)」,仅作为视觉皮肤通过 `packages/theme` token 集中管理,可在 `tokens.css` 切换为其它色板而不影响业务代码。代码内部 module/包名 `@kleinai/`* 保留作为既有命名空间,不外露给最终用户。 - ---- - -## ⚡ 本地起服务(Sprint 9 已具备完整可观察形态) - -提供两种启动模式,按需选择: - -### 模式 A · 全容器(推荐:零本地依赖、最贴近线上) - -> MySQL / Redis / 4 个 Go 后端 / 2 个前端静态站全部跑容器,前端容器内 nginx 统一反代到后端。 - -```powershell -# 首次构建镜像(≈ 5–8 分钟,分别打 backend / user-web / admin-web 三个镜像) -cd deploy -$env:KLEIN_DEV_MYSQL_PORT='23306' # 仅当 13306 被 Hyper-V 占用时设置 -docker compose -f docker-compose.dev-full.yml up -d --build - -# 之后日常启动 / 关停 -docker compose -f docker-compose.dev-full.yml up -d -docker compose -f docker-compose.dev-full.yml down # 仅停容器 -docker compose -f docker-compose.dev-full.yml down -v # 同时清数据卷 -``` - - -| 入口 | 地址 | 默认账号 | -| ------------- | ---------------------------------------------------------------- | -------------------- | -| 用户端 | [http://localhost:17080](http://localhost:17080) | 注册即用 | -| 管理后台 | [http://localhost:17088](http://localhost:17088) | `admin` / `admin123` | -| OpenAI 兼容(直连) | [http://localhost:17200/v1](http://localhost:17200/v1) | 用户 KEY 管理生成 sk-... | -| OpenAI 兼容(反代) | [http://localhost:17080/v1](http://localhost:17080/v1) | 同上(推荐前端调用) | -| 用户 API(直连) | [http://localhost:17180/healthz](http://localhost:17180/healthz) | - | -| 后台 API(直连) | [http://localhost:17188/healthz](http://localhost:17188/healthz) | - | - - -### 模式 B · 半容器(适合改 Go / 改 React 边写边热更新) - -> 仅 MySQL / Redis 跑容器,4 个 Go 后端 + 2 个 Vite 跑主机,省去镜像构建时间。 - -```powershell -pwsh ./scripts/dev-up.ps1 -# 该脚本会: -# 1. docker compose -f deploy/docker-compose.dev.yml up -d -# 2. 等 MySQL 健康 -# 3. 复制 backend/.env.example → backend/.env.local(首次) -# 4. 在 4 个新窗口拉起 api / admin / openai / worker - -# 起前端(首次需 pnpm install) -cd frontend -pnpm install -pnpm --filter @kleinai/user dev # → http://localhost:5173 -pnpm --filter @kleinai/admin dev # → http://localhost:5174 - -# 关停 -pwsh ./scripts/dev-down.ps1 -``` - -> 两种模式默认都用 `KLEIN_PROVIDER_GPT/GROK=mock`,无需真实 OpenAI / GROK 凭证即可走通生成全流程。 -> 切真实通道:模式 A 改 `deploy/docker-compose.dev-full.yml` 里的 `KLEIN_PROVIDER_*=real`;模式 B 改 `backend/.env.local`。 -> 真实凭证一律走 **管理后台 → Token 管理** 入库(AES-256-GCM 落盘)。 - -**Windows 上 13306 / 16379 端口被 Hyper-V 占用怎么办?** - -```powershell -# 查看 TCP 排除范围 -netsh interface ipv4 show excludedportrange protocol=tcp -# 临时释放(需管理员,重启后失效) -net stop winnat -net start winnat -# 或:模式 A 直接 $env:KLEIN_DEV_MYSQL_PORT='23306' 后再 up -# 或:模式 B 改 deploy/docker-compose.dev.yml + backend/.env.local 的 DSN -``` - ---- - -## 总览 - - -| 模块 | 状态 | 负责 | 备注 | -| -------------- | ------ | --- | ---------------------------------------------------- | -| **规范文档** | ✅ Done | - | 6 篇规范 + README | -| **AI 常驻规则** | ✅ Done | - | `.cursor/rules/` 4 份规则 | -| **后端脚手架** | ✅ Done | - | 4 个 cmd 二进制 + healthz / readyz | -| **前端脚手架** | ✅ Done | - | pnpm monorepo + 用户端 / 后台 骨架 | -| **部署脚手架** | ✅ Done | - | docker-compose + 3 份 nginx + Dockerfile | -| **账号体系** | ✅ Done | - | 注册 / 登录 / refresh / me / 改密 | -| **账号池核心** | ✅ Done | - | 增删改查 + 批量导入 + RR 调度 + AES 加密 | -| **API Key 管理** | ✅ Done | - | 用户 CRUD + OpenAI 兼容鉴权 | -| **计费引擎** | ✅ Done | - | 钱包 / 预扣 / 结算 / 退款 + CDK 兑换 | -| **生成调度** | ✅ Done | - | 真实 GPT / GROK provider + AES 解密凭证 + env 切换 mock/real | -| **前后端联调** | ✅ Done | - | user 全部页面接入真实 API;admin 主流程接入 | -| **管理后台联调** | ✅ Done | - | 登录 / 仪表盘 / 账号池 CRUD + 批量导入 / CDK 批次 | - - -图例:✅ 完成 · 🚧 进行中 · ⏳ 待开始 · ⛔ 阻塞 · 🐛 待修复 - ---- - -## Sprint 0 · 规范与基础(已完成) - -- 编写 6 份开发规范(`docs/01 ~ 06`) -- 项目根 README -- Cursor AI 常驻规则 `.cursor/rules/` - - `00-core.mdc`(始终生效) - - `10-backend.mdc`(backend/**) - - `20-frontend.mdc`(frontend/**) - - `30-deploy.mdc`(deploy/**、Dockerfile) -- PROGRESS 看板 - ---- - -## Sprint 1 · 后端脚手架(已完成) - -> 目标:4 个 cmd 二进制能起服务、返回 healthz;MySQL / Redis 连接通;核心表迁移完成。 - -- 仓库结构 `backend/`:cmd / internal / pkg / configs / migrations / scripts -- `go.mod` + 依赖锁定 -- `Makefile`:build / run-api / run-admin / run-openai / run-worker / migrate-up / migrate-down / lint / test -- `.env.example` + `configs/config.yaml` -- 基础包 `pkg/`:config / logger / database / snowflake / jwtx / crypto / errcode / response / ratelimit / httpc / version -- 中间件 `internal/middleware/`:recovery / requestid / access_log / cors / auth / ratelimit / security -- 入口 `cmd/`:api / admin / openai / worker -- 数据库迁移 `migrations/` 10 个文件覆盖核心域 - ---- - -## Sprint 2 · 前端脚手架(已完成) - -- `frontend/` pnpm workspace + tsconfig.base + eslint / prettier -- `packages/theme`:tokens.css + tailwind.preset.ts + animations -- `apps/user`:Vite + React Router + AuthLayout / AppLayout + 登录 / 注册 / 创作 / 历史 / 计费 / KEY / 邀请 / 设置 -- `apps/admin`:AdminLayout + 后台登录 + 仪表盘 + Token 管理骨架 - ---- - -## Sprint 3 · 部署脚手架(已完成) - -- `deploy/docker-compose.yml`(基础) -- `deploy/env/.env.example` -- `deploy/nginx/`:`user.conf` / `admin.conf` / `openai.conf` -- `backend/Dockerfile`(多阶段、distroless) -- `frontend/apps/user/Dockerfile` + `frontend/apps/admin/Dockerfile` - ---- - -## Sprint 4 · 账号体系 + 账号池 MVP(已完成) - -- 用户:注册 / 登录 / 刷新 / me / 改密 -- 账号池: - - 单条 CRUD + 启用 / 停用 / 解除熔断 - - 批量导入(每行一条;支持 `name@@cred` / `cred@base_url` / `cred`) - - 调度器 RoundRobin / WeightedRR + 30s 缓存 - - 凭证 AES-256-GCM 加密存储 - - 分组管理 / 健康检查 worker(待补) -- 管理后台:账号池列表 / 详情 / 批量导入 / 池状态接口 - ---- - -## Sprint 5 · API Key + OpenAI 兼容鉴权(已完成) - -- `api_key` 模型 + repo(hash + salt + last4) -- 用户端 CRUD:list / create(明文仅返回一次)/ toggle / delete -- `AuthAPIKey` 中间件:`Authorization: Bearer sk-klein-xxx` -- OpenAI 兼容服务挂入鉴权 + scope 校验 - ---- - -## Sprint 6 · 计费引擎(已完成) - -- `wallet_log` + `consume_record` + `refund_record` -- BillingService:PreDeduct / Settle / FailRefund / GrantPoints -- CDK 服务:批次生成 + 用户兑换(事务 + 余量 + per_user_limit) -- 用户端:钱包流水 / CDK 兑换 接口 -- 管理后台:CDK 批次创建接口 -- 充值订单(支付宝 / 微信 / Stripe)—— 后续接入 -- 优惠码 / 邀请返点 —— 后续接入 - ---- - -## Sprint 7 · 生成调度(已完成) - -- `generation_task` / `generation_result` 模型 + repo -- `provider.Provider` 接口 + `mock` 实现 -- `GenerationService`:幂等 + 预扣 + 池调度 + 失败退款 -- 用户端 `/api/v1/gen/{image,video}` + 任务详情 + 历史 -- OpenAI 兼容 `/v1/{images,videos}/generations`(同步等待) -- 真实 GPT / GROK 适配(Sprint 9 已完成,见下方) -- WebSocket / SSE 进度推送 —— Sprint 10 - ---- - -## Sprint 8 · 前后端联调(已完成 stub) - -> 目标:用户端真实跑通 注册 → 登录 → 兑换 CDK → 创建生成 → 查看历史 → 管理 KEY 全链路。 - -- `apps/user/src/lib/api.ts` axios 客户端(baseURL / token / 401 / 错误码) -- `apps/user/src/lib/services.ts` 领域 API 封装 -- `apps/user/src/lib/types.ts` + `format.ts` 类型与展示工具 -- `stores/auth.ts` zustand + `stores/toast.ts` + `components/Toaster` -- `routes/RequireAuth` 路由守卫 + 401 自动跳登录 -- 登录 / 注册页对接 `/auth/`*(zod 表单校验 + 自动跳转) -- AppLayout 顶栏对接 `/users/me`(实时余额、退出登录) -- 创作中心 · 图像 对接 `/gen/image` + 轮询任务 + 余额刷新 -- 创作中心 · 视频 对接 `/gen/video` + 轮询任务 + 余额刷新 -- 生成历史对接 `/gen/history`(图/视频筛选、分页加载) -- KEY 管理对接 `/keys/`*(创建一次性明文展示、停启用、删除) -- 余额明细对接 `/billing/logs` + `/billing/cdk/redeem` -- 设置页对接 `/users/password` + 资料展示 + 主题切换 -- 邀请页展示真实邀请码 + 一键复制 -- 调用说明页基于 `VITE_OPENAI_BASE_URL` 生成示例 + 一键复制 -- `vite.config.ts` 增加 `/api` → 17180、`/v1` → 17200 代理 - ---- - -## Sprint 9.6 · UI/UX Pro 规范化(已完成) - -> 目标:把已联调的页面从「能用」升级到「整套规范」。引入「设计 token + 共享组件层」架构,前后台高频页面全量替换为统一字号、间距、按钮、卡片、表格、空状态、徽标、对话框组件;本地 `index.css` 仅留站点级覆盖。 - -- **Design Tokens(`packages/theme/src/tokens.css`)**:8pt 间距栅格 + `clamp()` 流式字号(display/h1-h4/body/small/tiny)+ `tracking-`* / `weight-*` 排印变量 + 控件高度变量 `ctl-h-{xs,sm,md,lg,xl}` + 控件 padding 变量;阴影增加 `shadow-4 / shadow-inset / focus-ring / focus-ring-danger`;动画 `--ease-* / --duration-*` 标准化;语义颜色补 `success-soft / warning-soft / danger-soft / info-soft`。 -- **共享组件层(`packages/theme/src/components.css`,~720 行)**: - - 排印:`text-display / text-h1..h4 / text-body / text-small / text-tiny / text-overline / text-mono / gradient-text` - - 按钮:`btn` + `btn-primary|secondary|outline|ghost|danger|danger-ghost|link` × `btn-xs|sm|md|lg|xl` + `btn-icon|btn-block`,旧 `.btn-klein` 作为 alias 保留 - - 表单:`field / field-label / field-hint / field-error / input / select / textarea / input-affix / checkbox / radio`,`.input-klein` alias - - 卡片:`card / card-flat / card-elevated / card-glass / card-gradient / card-tinted / card-section / dialog-surface`,`.glass-card` alias - - 数据展示:`stat-grid / stat-tile(-accent) / stat-label / stat-value / stat-delta`、`data-table` 全表样式、`list-row` - - 状态/标记:`badge(-success|warning|danger|klein|solid|outline) / chip(-active|outline) / kbd / progress / tabs / tab` - - 空态/骨架:`empty-state(-icon|title|desc) / skeleton(-text)` - - 页面骨架:`page / page-narrow / page-wide / page-header / page-title / page-subtitle / section / section-header / section-title` -- **Tailwind preset**:暴露所有新 token 为原子类(`bg-info-soft`、`text-tiny`、`font-extra`、`tracking-wide`、`shadow-4`、`duration-base`、`ease-out` 等)。 -- **入口收敛**:在 `apps/{user,admin}/src/index.css` 顶部 `@import '@kleinai/theme/components.css'`,确保 `@layer components` 与 `@apply` 在同一 PostCSS 上下文;本地 `index.css` 只保留 `body line-height` 与 `.creative-pane / .admin-pane` 等页面级覆盖。 -- **页面重构**(用户端):`LoginPage / RegisterPage / CreateImagePage / CreateVideoPage / HistoryPage / BillingPage / KeysPage / SettingsPage / DocsPage / InvitePage / AppLayout / LoginGate` 全量切换 `btn / btn-{variant} / field / input / card / page-* / badge / progress / tabs / kbd / empty-state`。 -- **页面重构**(管理端):`Dashboard / TokenAccounts / CDK / Login / AdminLayout / _placeholder` 同步升级;`TokenAccountsPage` 表格切到 `data-table`,状态徽标切到 `badge badge-{success|warning|danger}`;`_placeholder` 改为带图标的 `empty-state`,所有占位页(用户管理 / 充值消费 / 优惠码 / 系统配置 / 请求日志)拥有一致空态外观。 -- **构建验证**:`pnpm typecheck` & `pnpm build` 全绿;docker `user-web` / `admin-web` 镜像在 `docker-compose.dev-full.yml` 上重新构建并热替换,仅 `28KB` (admin) / `38KB` (user) gzipped CSS。 - ---- - -## Sprint 9.5 · UX & 品牌微调(已完成) - -> 目标:把项目从「Klein Blue 主题站」收敛为「gpt2api · AIGC 平台」,主题降级为默认皮肤;首页开放浏览,生成动作未登录时弹浮层。 - -- **品牌降级**:用户可见文案统一改为 `gpt2api`,`Logo` 标识改为 `gpt2api`(`g2a` 角标)。代码 module path(`@kleinai/`*、Go module)保留不变,避免 churn。 -- **首页开放**:`/`、`/create/image`、`/create/video`、`/docs` 不再要求登录,未登录用户可直接体验生成 UI;受保护路由(历史 / 余额 / KEY / 邀请 / 设置)外层挂 ``,未登录访问会回到首页并自动弹登录浮层。 -- **登录浮层**:新增 `` 全局浮层 + `useLoginGateStore` 状态机 + `useEnsureLoggedIn(action)` Hook。生成按钮、受保护导航、401 拦截均通过浮层完成「断点续做」,无需中断当前页面状态。 -- **响应式**: - - 用户端侧栏宽度改 `clamp(220px,18vw,260px)`,避免 1024px 上吃宽度过多。 - - 创作页三栏改为 `lg`(≥1024px)双栏 + `2xl`(≥1536px)三栏;中等屏幕将「当前任务进度」合并到结果区头部。 - - 后台 `` 补齐移动端抽屉 + 顶栏汉堡按钮,header 加 `truncate` / `flex-shrink-0`,避免昵称撑爆。 -- **空白页修复**:`apps/admin` 的 `BrowserRouter basename="/admin"` 与 nginx 的根挂载路径冲突 → 改成无 basename;401 跳转改为 `/login`。 -- **PROGRESS / README 改写**:明确「克莱因蓝是默认皮肤而非项目身份」;默认账号 `admin / admin123`、用户端注册即用。 - ---- - -## Sprint 9 · 真实 Provider + 后台联调(已完成) - -> 目标:替换 mock provider,跑通真实 GPT / GROK 调用;管理后台前端联调账号池 / CDK。 - -### Provider 真实化 - -- `provider.Request` 加入 `Credential` / `BaseURL`,避免 provider 持有 AES -- `GenerationService` 注入 AES,调用前解密 `account.credential_enc` -- `provider/gpt`:OpenAI 兼容 `/v1/images/generations`,同步返回 - - URL / b64 双兼容;4xx/5xx 失败时自动熔断账号 -- `provider/grok`:通用「异步任务 + 轮询」协议 - - 同步直返 / 异步 `task_id` 自动适配 - - 内置 12 min 超时 + 3s→10s 指数 backoff -- `provider/factory`:env 驱动 `KLEIN_PROVIDER_GPT/GROK = mock|real`,零代码切换 -- `.env.example` 增加 provider 模式与 base url 配置 -- `go build ./... && go vet ./...` 全绿 - -### 管理后台前端 - -- `lib/api`(独立 token KEY = `klein:admin:token`)+ `lib/types` + `lib/format` + `lib/services` -- `stores/auth` + `stores/toast` + `components/Toaster` -- `routes/RequireAuth` 路由守卫;401 自动清 token + 跳 `/admin/login` -- 登录页:zod + react-hook-form + `/admin/api/v1/auth/login` -- AdminLayout 顶栏:当前管理员信息 + 角色 + 退出登录 -- **仪表盘**:实时拉 `accounts/stats` + 列表 total,渲染 GPT / GROK 池水位 -- **Token 管理**:列表(筛选 + 关键字 + 分页)+ 状态切换 + 删除 -- **Token 管理**:新增账号 Dialog(明文凭证,提交后端加密) -- **Token 管理**:批量导入 Dialog(粘贴文本,每行一条,三种格式自动识别) -- **CDK 批次**:批次号 / 名称 / 单码点数 / 数量 / per_user_limit / 过期 — 提交并展示结果 -- `pnpm --filter @kleinai/admin typecheck` + `build` 全绿 - -### 仍开口 - -- 真实 webhook 回调 + 写 `generation_result`(异步 worker,Sprint 10) -- 管理后台:CDK 列表 + 导出 CSV(下一轮) -- 管理后台:用户管理(封禁 / 加点 / 修改套餐,下一轮,需补后端 API) -- 管理后台:充值消费 / 优惠码 / 请求日志(下一轮,目前为占位页) - ---- - -## Sprint 9.5 · 账号池高级能力(已完成) - -> 目标:把已写好的 `AccountTestService / OpenAIOAuthService / SystemConfigService / ProxyService` 接入 router,并在管理后台做完代理管理 + 系统配置两块拼图。 - -### 后端 - -- `router.MountAdmin` 装配补齐: - - `POST /admin/api/v1/accounts/:id/test`、`POST /accounts/:id/refresh`、`POST /accounts/batch-refresh` - - 整组 `proxies`:`GET / POST / PUT / DELETE / POST /:id/test` - - 整组 `system`:`GET /system/settings`、`PUT /system/settings` - - `accountAdmin.SetTestService(testSvc)` 回填,使 Test/Refresh 走得通 -- 复用既有服务(无新增逻辑): - - `AccountTestService.Test`:GPT/GROK `GET /v1/models` 探活 - - `AccountTestService.RefreshOAuth`:`auth.openai.com/oauth/token` refresh_token grant,写回 `access_token_enc / access_token_expires_at / last_refresh_at` - - `AccountTestService.maybeRefresh`:access_token 距过期阈值内自动刷新 - - `AccountTestService.TestProxy`:通过代理探测 `https://www.google.com/generate_204` 测延迟 - - `SystemConfigService`:30s 内存缓存 + 类型化便捷方法(`GlobalProxyEnabled / RefreshBeforeHours / OpenAIClientID / OpenAITokenURL`) - -### 后台前端 - -- `lib/types`:补 `AccountItem` 上 OAuth/Test 字段;新增 `AccountTestResp / AccountRefreshResp / AccountBatchRefreshResp / ProxyItem / ProxyCreateBody / ProxyUpdateBody / ProxyTestResp / SystemSettings` -- `lib/services`:补 `accountsApi.test / refresh / batchRefresh`;新增 `proxiesApi`、`systemApi` -- **Token 管理页**升级: - - 顶栏新增「批量刷新 OAuth」按钮(按当前 provider 过滤) - - 表格新列「OAuth / 最近测试」:RT / AT 徽标 + access_token 倒计时 + last_test 状态/延迟/相对时间 - - 操作列新增「测试连通」按钮(所有账号)+「刷新 access_token」按钮(仅 OAuth 账号) -- **代理管理**新页(`/proxies`):列表(启用/禁用 tabs + 关键字 + 分页)+ 新增/编辑 Dialog + 启停 + 删除 + 测试连通 -- **系统配置**新页(`/config`)替代占位页: - - 「全局代理」分区:开关 + 下拉选择已启用的代理 - - 「OAuth 调度」分区:刷新窗口(小时)/ OpenAI client_id / Token Endpoint - - 「完整配置(只读)」JSON 视图便于诊断 -- 侧边栏新增「代理管理」入口 - -### 验收 - -- `go vet ./... && go build ./...` 全绿 -- `pnpm --filter @kleinai/admin typecheck` 全绿 -- 容器 `klein-admin-dev` 重建后 GIN 启动日志已注册全部新路由 -- `curl /admin/api/v1/{accounts,proxies,system/settings}` 返回 401(已挂中间件) - -### 9.5 修订(hotfix · 2026-04-27 晚) - -> 用户在管理后台试新增 / 批量导入账号时点出 5 个真实问题,本轮一并修齐: - -- **数据库 schema 漂移**:`migrations/20260427130011_init_proxy_oauth.sql` 中 `oauth_meta` 列 `AFTER` 与 `COMMENT` 顺序倒置,MySQL 8.0 拒绝执行;旧库(已建在 9.5 之前)的 mysql 容器 `docker-entrypoint-initdb.d` 不会重跑,导致 `account` 表缺 `proxy_id / oauth_meta / access_token_enc / refresh_token_enc / access_token_expires_at / last_refresh_at / last_test_*` 共 10 列 + 缺 5 条 `system_config` 默认值。已修正迁移并对运行中库手动补齐。 -- **批量导入 OAuth 行为不一致**:`AccountAdminService.BatchImport` 漏写 `refresh_token_enc`,与单条 `Create` 不同;现一并写入,使后续 `RefreshOAuth` 行为对齐。 -- **DTO 缺 `proxy_id`**:`AccountBatchImportReq` 加 `ProxyID *uint64`,让批量导入也能直接绑代理。 -- **新增账号 / 批量导入 UI 字段不全**: - - `CreateDialog` 增加:绑定代理下拉(取 `proxies.list status=1`)、`rpm_limit / tpm_limit / daily_quota / monthly_quota` 折叠区块、按 `auth_type` 切换的 placeholder + hint(API Key / Cookie / OAuth `refresh_token` 各自文案) - - `ImportDialog` 增加:默认绑定代理下拉、按 `auth_type` 切换的多行示例(API Key/Cookie/OAuth) - - `base_url` 字段统一经 `normalizeBaseURL()` 自动补 `https://`,规避后端 `binding:"omitempty,url"` 校验对 `api.openai.com` 这类裸域的 400 -- **端到端验证**:登录后 `POST /accounts`(OAuth + 限速字段)、`POST /accounts/import`(OAuth × 3 行)、`GET /accounts?keyword=e2e` 全部 200,`has_refresh_token=true` 在所有 OAuth 行上正确回传。 - ---- - -## 🟡 剩余未开发清单(截至 2026-04-27 23:00) - -> 已落地的功能可在本地容器中观察。以下为后续 Sprint 待补部分。 - -### 后端 - - -| 模块 | 子项 | 优先级 | 备注 | -| ----------- | ----------------------------- | --- | ------------------------------- | -| Worker | 改造为 asynq 真实异步消费 | P1 | 现在是 inline goroutine,单机够用,多副本需要 | -| 进度推送 | WebSocket / SSE 把 task 状态推到前端 | P1 | 现在用 1.5s 轮询 | -| Webhook | provider 异步回调端点 | P1 | 配合 grok 真异步任务 | -| 用户管理 | 列表 / 封禁 / 加点 / 改套餐 API | P1 | 后台前端已留位 | -| 充值订单 | 微信 / 支付宝 / Stripe 通道 | P1 | 现在只能 CDK | -| 优惠码 | promo_code 表 + CRUD + 校验 | P2 | CDK 已通;优惠码用于折扣 | -| 请求日志 | 持久化 + 后台查询 API | P2 | 目前只有 access log 文件 | -| 邀请返点 | 首充返点 + 终身分润落账 | P2 | 表已建,逻辑未串 | -| 健康检查 worker | 自动探测账号池 + 解熔断 | P2 | 现在只有手动 | - - -### 后台前端(占位页待对接) - - -| 页面 | 状态 | -| ------------------- | ---------------- | -| 用户管理(列表 / 编辑 / 封禁) | ⏳ 待对接(需上方后端 API) | -| 充值消费记录 | ⏳ 待对接 | -| 优惠码 | ⏳ 待对接 | -| 兑换码 CDK 列表 + CSV 导出 | ⏳ 待对接(创建已通) | -| 系统配置 | ✅ Sprint 9.5 已上线(代理 / OAuth / 完整 KV 视图) | -| 代理管理 | ✅ Sprint 9.5 已上线(CRUD + 测试 + 全局开关) | -| 请求日志 | ⏳ 待对接 | - - -### 部署 / 运维 - - -| 项目 | 状态 | -| -------------------------- | ------------------- | -| 自签 / Let's Encrypt 证书脚本 | ⏳ 生产 nginx.conf 已留位 | -| K8s manifests / Helm chart | ⏳ Sprint 10 | -| Prometheus + Grafana 接入 | ⏳ Sprint 10 | -| OpenTelemetry trace 接入 | ⏳ Sprint 10 | - - ---- - -## Sprint 10 · 上线准备 - -- 性能压测(k6) -- 安全演练(越权 / 注入 / 限流绕过) -- 监控 / 告警接入 -- 灰度发布脚本 -- Runbook 演练 - ---- - -## 决策记录(ADR-Lite) - - -| 编号 | 决策 | 时间 | 备注 | -| --- | ------------------------------------------------------------------------- | ---------- | ---------------------------- | -| 001 | 默认皮肤采用克莱因蓝 IKB `#002FA7` + 电光蓝 `#1E3DFF` 高光(仅视觉,可换) | 2026-04-27 | 替代之前的紫色方案;非项目身份 | -| 002 | 4 个二进制独立部署 | 2026-04-27 | api/admin/openai/worker 解耦扩缩 | -| 003 | 端口段 17000-17999;MySQL 13306 / Redis 16379 | 2026-04-27 | 避开常用端口 | -| 004 | 点数最小单位 0.01,DB int64 *100 存储 | 2026-04-27 | 避免浮点精度 | -| 005 | 用户 API Key 仅创建时返回明文 | 2026-04-27 | DB 仅存 SHA256+salt+last4 | -| 006 | provider 不持有 AES,credential 由 GenerationService 解密后注入 Request | 2026-04-27 | 简化 provider 实现,集中密钥使用 | -| 007 | provider 默认 `mock`,env `KLEIN_PROVIDER_GPT/GROK=real` 切真实通道 | 2026-04-27 | 本地 / CI 友好,生产显式启用 | -| 008 | 后台 token 与用户 token 分别落 localStorage(`klein:token` vs `klein:admin:token`) | 2026-04-27 | 同源同浏览器隔离会话 | -| 009 | 前端首页对未登录用户开放,生成等关键动作通过 `` 浮层断点续做 | 2026-04-27 | 避免「打开就是登录页」的硬墙体验 | -| 010 | 用户可见品牌名 = `gpt2api`;代码 module/CSS 类(`@kleinai/`*、`klein-*`)保留为内部命名空间 | 2026-04-27 | 把克莱因蓝降级为默认皮肤而非身份 | - - ---- - -## 风险与待办 - -- ⚠️ Grok 视频接口协议尚未对齐,需要在 Sprint 6 前完成调研,确认是否走第三方代理 -- ⚠️ 支付通道优先支持 微信 + 支付宝;境外支付(Stripe)作为 Sprint 7 末尾扩展 -- ⚠️ 账号池凭证加密密钥的运维流程(KMS / 手动)需在上线前敲定 - diff --git a/README.md b/README.md index e447cdf9..e4fc1d73 100644 --- a/README.md +++ b/README.md @@ -1,184 +1,920 @@ -# gpt2api / KleinAI +# gpt2api -> 一个面向 GPT / GROK 账号池的 AIGC 聚合平台,提供图片、文字、视频的一站式生成能力。 -> 前台面向创作,后台面向运营,开放 OpenAI 兼容接口,方便直接接入现有 SDK。 +> 基于逆向 **chatgpt.com** 的 OpenAI 兼容 SaaS 网关 —— 多账号池 / 代理池 / **IMG2 终稿直出** / **批量出图** / **本地 2K/4K 高清放大** / **高并发调度** / 积分计费 / 管理后台一体化。 -## 2.0 是什么 +

+ stars + release + go + vue + license +

-`v2.0.0` 不是简单重排界面,而是一次完整的能力升级: +- **仓库地址**: +- **技术交流 QQ 群**:`382446`(入群请注明「gpt2api」) -- 统一文字、图片、视频三条生成链路 -- 统一账号池、代理、刷新、熔断、轮换、用量检测 -- 统一 OpenAI 兼容 API,前端和第三方 SDK 都能直接接 -- 统一后台运营能力:用户、账单、CDK、优惠码、模型价格、请求日志、上游日志 -- 统一部署方式:单机 Docker Compose 可跑,后续可平滑迁移到 K8s +--- -## 界面预览 +## 目录 -### 用户创作端 +- [一、项目定位](#一项目定位) +- [📸 界面预览](#-界面预览) +- [二、核心特性](#二核心特性) +- [三、技术栈](#三技术栈) +- [四、架构概览](#四架构概览) +- [五、快速开始(Docker 一键部署)](#五快速开始docker-一键部署) +- [六、配置说明](#六配置说明) +- [七、API 使用示例](#七api-使用示例) +- [八、重点能力详解](#八重点能力详解) + - [8.1 IMG2 出图](#81-img2-出图) + - [8.2 4K / 2K 高清输出(本地 Catmull-Rom 放大)](#82-4k--2k-高清输出本地-catmull-rom-放大) + - [8.3 批量出图 / 多张聚合](#83-批量出图--多张聚合) + - [8.4 高性能高并发调度](#84-高性能高并发调度) +- [九、管理后台功能概览](#九管理后台功能概览) +- [十、目录结构](#十目录结构) +- [十一、二次开发 / 定制](#十一二次开发--定制) +- [十二、FAQ](#十二faq) +- [十三、Roadmap](#十三roadmap) +- [十四、参与贡献](#十四参与贡献) +- [十五、免责声明与风险提示](#十五免责声明与风险提示) +- [十六、License](#十六license) -![gpt2api 用户创作端](docs/ffaa6c7c-ee8b-4a1f-bdcc-df93c8d91abf.png) +--- -### 管理后台 +## 一、项目定位 -![gpt2api 管理后台](docs/7f76a99c-1100-4216-970b-624ff135808d.png) +`gpt2api` 是一个**自建的 ChatGPT → OpenAI 兼容网关**,把 `chatgpt.com` Plus / Team / Codex 账号的能力,以 **完全兼容 OpenAI API** 的形式(`/v1/chat/completions` / `/v1/images/generations`)开放给下游调用方,同时配套一整套 SaaS 运营后台。 -## 核心能力 +适合的场景: -- 账号池管理:GPT / GROK 账号批量导入、刷新、检测、熔断、轮换 -- 创作中心:文字对话、文生图、图生图、文生视频、图生视频 -- 异步任务:图片和视频支持任务查询、历史记录、预览、下载 -- OpenAI 兼容 API: - - `GET /v1/models` - - `POST /v1/chat/completions` - - `POST /v1/images/generations` - - `POST /v1/images/edits` - - `GET /v1/images/generations/:task_id` - - `POST /v1/video/generations` - - `GET /v1/video/generations/:task_id` -- 管理后台:仪表盘、Token 管理、代理管理、用户管理、充值消费、优惠码、CDK、系统配置、模型价格、请求日志 -- 运营能力:充值套餐、扣费规则、模型映射、自动刷新、上游日志追踪 -- 部署能力:支持本地开发、单机部署、反向代理、SSL 证书自动更新 +- 你手头有一批 ChatGPT Plus / Team / Codex 账号,想对外提供稳定的 **GPT Image / DALL·E 3 / IMG2 高清大图**服务; +- 想给公司 / 团队内部开通 OpenAI 风格的代理网关,把所有调用统计、计费、审计集中管理; +- 想低成本搭一个带积分 / 套餐 / 易支付的 AI API 中台,面向 C 端或 B 端开发者售卖。 -## 2.0 设计目标 +> 本项目当前版本**聚焦图片模型**(详见 [8.1 IMG2 出图](#81-img2-出图)、[8.2 4K/2K 高清输出](#82-4k--2k-高清输出本地-catmull-rom-放大) 与 [8.3 批量出图](#83-批量出图--多张聚合))。文字通路(`/v1/chat/completions`)代码层完整保留,但因 `chatgpt.com` 新 sentinel 协议存在短期不稳定因素,UI 入口已在当前版本关闭,恢复只需改一行 feature flag,详见 [十一、二次开发](#十一二次开发--定制)。 -1. 前台简洁统一,图片 / 文字 / 视频三个入口标准化 -2. 后台更适合运营,所有配置尽量表单化,不依赖 JSON 手填 -3. 账号与请求逻辑稳定,支持自动重试、换号、熔断、分批刷新 -4. 上游问题可追踪,失败时能看到完整 provider 日志 -5. 部署简单,能在一台 Linux 服务器上直接跑起来 +--- -## 技术栈 +## 📸 界面预览 -- 后端:Go 1.24 + Gin + GORM + MySQL + Redis -- 前端:React 18 + Vite + TypeScript + Tailwind -- 部署:Docker / Docker Compose / Nginx / Caddy -- 外部依赖:FlareSolverr、代理、对象存储(可选) +> 截图来自 **在线体验(Playground)** 页 · `gpt-image-2` / `picture_v2`(IMG2 终稿)· `9:16` 比例 · 单次调用一张 prompt 批量出图。 + +### 在线体验 · 文生图 / 批量出图 -## 仓库结构 +

+ gpt2api 在线体验 · 文生图 · IMG2 批量出图 +

+ +- 左侧:模型选择、画面比例(1:1 / 16:9 / **9:16**)、张数、PROMPT 输入、prompt 预设库; +- 右侧:**同一个任务聚合返回多张高清终稿**(IMG2 命中时单次 `/f/conversation` 一次性产出 2 张,再配合"张数 N"即可成批扩图); +- 点击任意一张可进入全屏放大预览 ↓。 + +### 管理后台 · 单图放大预览 + +

+ gpt2api 管理后台 · 图片全屏预览 · 左侧完整菜单 +

+ +- 左侧:**个人中心 / 后台管理** 双分区菜单 —— API Keys、使用记录、账单与充值、在线体验、接口文档、用户管理、GPT 账号池、代理管理、模型配置、用户分组、用量统计、全局 Keys、审计日志、数据备份、系统设置,一个台子全搞定; +- 中间:全屏放大查看终稿,直接右键"图片另存为"。所有图片 URL 都是内置 `/p/img/:task/:idx` **HMAC 签名代理**,绕过 `chatgpt.com` `estuary/content` 的 403 防盗链。 + +--- + +## 二、核心特性 + +| 分类 | 能力 | +|------|------| +| **上游协议** | 完整逆向 `chatgpt.com` `f/conversation` 两步 sentinel(`/prepare` + `/finalize`)、PoW、`conduit_token`、全套 `oai-*` / `Sec-Ch-Ua-*` 指纹头 | +| **图片生成** | 文生图、**图生图 / 多图参考**、**IMG2 正式版直出**(速度优先,SSE 够数即返回,最长 300s 补齐轮询兜底)、**本地 2K/4K PNG 高清放大**(Catmull-Rom 插值,按需触发 + 进程内 LRU)、轮询 + SSE 直出双通道 | +| **账号池** | JSON / AT / RT / ST 四种方式批量导入,**自动刷新**、**额度探测**、**风控熔断**、按账号稳定绑定 `oai-device-id` / `oai-session-id` | +| **代理池** | 支持 HTTP / SOCKS5,健康分自动探测,按账号强绑定代理,避免 IP 指纹混用 | +| **调度器** | 串行 lease + Redis 分布式锁,`min_interval_sec` 单号最小间隔、`daily_usage_ratio` 日熔断、`cooldown_429_sec` 限速退避 | +| **OpenAI 兼容** | `/v1/chat/completions`(保留)、`/v1/images/generations`、`/v1/images/edits`、`/v1/images/tasks/:id`、`/v1/models` | +| **下游 Key** | 独立于用户账号的 `sk-` Key,支持 **RPM / TPM / 日配额 / IP 白名单 / 模型白名单** | +| **计费** | 积分钱包 + 预扣结算、分组倍率(VIP / 内部 / 渠道)、充值套餐、**易支付(EPay)**接入 | +| **安全** | AES-256-GCM 加密 AT / cookies、JWT 登录、RBAC 权限、**管理员写操作全链路审计**、高危操作 `X-Admin-Confirm` 二次确认 | +| **运维** | 数据库一键备份 / 恢复(`mysqldump` + gzip)、上传单文件限额、备份保留策略 | +| **图片防盗链** | 内置签名代理 `/p/img/:task/:idx`,HMAC 签名 + 过期时间,绕过 `chatgpt.com` `estuary/content` 的 403 | +| **前端** | Vue 3 + Element Plus 单页控制台,账户池 / 代理池 / 模型 / 用户 / 积分 / 审计 / 备份 / 系统设置全覆盖 | + +--- + +## 三、技术栈 + +**后端** + +- Go 1.22+ +- Gin(HTTP 框架) / sqlx(MySQL 访问) / Viper(配置) / Zap(日志) +- MySQL 8.0(业务数据 + 审计 + 账变) / Redis 7(分布式锁 / 限流 / 缓存) +- `refraction-networking/utls`(TLS 指纹,用于规避 `chatgpt.com` JA3 检测) +- `golang-jwt/jwt` / `golang.org/x/crypto`(鉴权 + 密码学) +- Goose(数据库迁移) + +**前端** + +- Vue 3 + Vite 5 + TypeScript 5 +- Element Plus 2.7(组件库) +- Pinia(状态管理) / `pinia-plugin-persistedstate` +- Vue Router 4 +- Axios + +**部署** + +- Docker Compose(MySQL + Redis + server,可选 nginx) +- 默认单机;水平扩展见 [`deploy/README.md`](deploy/README.md) + +--- + +## 四、架构概览 + +```mermaid +flowchart LR + subgraph Client["下游调用方"] + SDK["OpenAI SDK / curl / 自家业务"] + end + + subgraph Gateway["gpt2api Server :8080"] + direction TB + API["Gin Router
/v1/* · /api/*"] + Auth["APIKey / JWT
RPM · TPM · IP 白名单"] + Billing["积分预扣
分组倍率"] + Scheduler["账号调度器
Redis 锁 · lease · 熔断"] + Upstream["ChatGPT Client
utls · sentinel v2 · 多 header 指纹"] + ImgProxy["图片签名代理
/p/img/:id/:n"] + end -```text -. -├── backend/ # Go 后端:API / Admin / OpenAI 兼容 / Worker -├── frontend/ # 用户前台 + 管理后台 -├── deploy/ # Docker Compose、Nginx、Caddy、环境变量 -├── docs/ # 开发、API、部署、前端规范 -└── README.md + subgraph Pool["资源池"] + Accounts[("GPT 账号池
AT / RT / ST")] + Proxies[("代理池
HTTP / SOCKS5")] + Models[("模型配置
对外 slug ⇄ 上游 slug")] + end + + subgraph Storage["持久化"] + MySQL[(MySQL 8.0
用户 · 账号 · 账变 · 审计)] + Redis[(Redis 7
分布式锁 · 限流)] + end + + subgraph UpstreamAPI["chatgpt.com"] + Sentinel[/chat-requirements prepare + finalize/] + FConv[/f/conversation/] + Estuary[/estuary/content/] + end + + SDK -- "Bearer sk-xxx" --> API + API --> Auth --> Billing --> Scheduler + Scheduler --> Accounts + Scheduler --> Proxies + Scheduler --> Models + Scheduler --> Upstream + Upstream --> Sentinel + Upstream --> FConv + Upstream --> Estuary + Estuary -. "fid / sid" .-> ImgProxy + ImgProxy --> SDK + Gateway <--> MySQL + Gateway <--> Redis ``` -## 端口说明 +**数据流(一次文生图调用)**: -### 对外端口 +1. 下游 `POST /v1/images/generations` 携带 `Authorization: Bearer sk-xxx`; +2. Gateway 校验 Key → 查下游限流(RPM/TPM/日配额)→ 预扣积分; +3. Scheduler 从账号池挑一个 `idle` 且满足 `min_interval_sec` 的账号,拿 Redis 锁建立 lease; +4. 通过账号绑定的代理,走 `utls` TLS 指纹,按真实 Edge 143 浏览器的 header/payload 访问 `chatgpt.com`; +5. 两步 sentinel 换 chat-requirements token → `/f/conversation/prepare` 拿 `conduit_token` → SSE 上游生图; +6. 解析 tool message 拿 `fids` / `sids`,够 N 张立即短路下载,不够再短轮询最多 300s 补齐; +7. 所有图片 URL 经 HMAC 签名,返回 `https:///p/img//?exp=…&sig=…`; +8. 扣费结算 + 写 usage_logs + 释放 lease + 更新账号状态。 -- `17080`:用户前台 -- `17088`:管理后台 -- `17200`:OpenAI 兼容 API +--- -### 本机调试端口 +## 五、快速开始(Docker 一键部署) -- `17180`:用户后端 API -- `17188`:管理后台 API -- `17200`:OpenAI 兼容 API -- `23306`:MySQL -- `16379`:Redis -- `18191`:FlareSolverr +> ⚠️ **本项目的 Dockerfile 是"宿主预编译 + 容器运行"架构**(为规避国内拉 `proxy.golang.org` / npm registry 卡死的问题)。容器内**不做** `go build` / `npm install`,完全依赖宿主机先产出二进制和前端产物。 +> 因此步骤是:**准备环境 → 克隆仓库 → 本地预编译 → docker compose build + up**。直接 `docker compose up --build` 会报 `deploy/bin/gpt2api: not found`。 -## 快速部署 +### 1. 准备环境 -下面是推荐的线上部署方式,和当前仓库的 `deploy/docker-compose.server.yml` 对齐。 +**宿主机(打包机)需要安装**: -### 1. 准备环境 +| 软件 | 最低版本 | 用途 | +|------|---------|------| +| **Go** | 1.22+ | 交叉编译 `gpt2api` + `goose` 二进制 | +| **Node.js** | 18+(推荐 20 LTS)| 编译前端 Vite 产物 | +| **Docker** | 24+ | 构建 + 运行镜像 | +| **docker compose** | v2 插件 | 启动 mysql / redis / server 编排 | +| **git** | 任意 | 克隆仓库 | -- 一台 Linux 服务器 -- Docker 和 Docker Compose -- 1 个域名或 3 个子域名 -- 80 / 443 端口可用 -- MySQL / Redis 空间充足 +> Windows 用户装 Go + Node + Docker Desktop 即可;Linux 服务器一条 `apt install -y golang-go nodejs npm docker.io docker-compose-plugin` 基本够用。 +> 打包机与运行机**不必是同一台**,`build-local.sh/ps1` 默认交叉编译成 `linux/amd64`,产出拷到服务器上也能直接 `docker compose build` 起。 -### 2. 拉取代码 +**运行环境需要**: + +- 一个能直连 `chatgpt.com` 的 VPS,或者至少一个可用的 HTTP / SOCKS5 代理; +- 至少 1 个 ChatGPT Plus / Team / Codex 账号(能导出 AT / RT / ST 或 JSON 会话信息)。 + +### 2. 克隆仓库 ```bash git clone https://github.com/432539/gpt2api.git cd gpt2api ``` -### 3. 配置环境 +### 3. 本地预编译(**必做,容器内不会帮你 build**) + +这一步会产出三个东西,镜像 COPY 进去就能直接起: -复制环境变量模板并修改: +| 产物 | 路径 | 由谁产出 | +|------|------|---------| +| 后端二进制(linux/amd64) | `deploy/bin/gpt2api` | `go build ./cmd/server` | +| 迁移工具(linux/amd64) | `deploy/bin/goose` | `go build github.com/pressly/goose/v3/cmd/goose@v3.20.0` | +| 前端产物 | `web/dist/` | `cd web && npm install && npm run build` | + +仓库已经把**这三步打包到一个脚本**,一条命令搞定: + +**Linux / macOS / WSL:** ```bash -cp deploy/env/.env.example deploy/env/.env.prod +bash deploy/build-local.sh +# 增量:只编译缺失的 goose。第一次会自动 npm install(首次慢,之后秒级) +# 强制重编译 goose:bash deploy/build-local.sh --force ``` -重点检查这些项: +**Windows PowerShell:** + +```powershell +powershell -NoProfile -File deploy\build-local.ps1 +# 强制重编译 goose:powershell -NoProfile -File deploy\build-local.ps1 -Force +``` -- 数据库连接 -- Redis 地址 -- JWT 密钥 -- AES 密钥 -- 域名 / CORS -- OpenAI / GROK 基础地址 -- 代理与 FlareSolverr 地址 +脚本结束后应当能看到: -### 4. 启动服务 +```text +[build-local] done. artifacts: +-rwxr-xr-x ... deploy/bin/gpt2api ~32M +-rwxr-xr-x ... deploy/bin/goose ~34M +-rw-r--r-- ... web/dist/index.html +``` + +> 改完后端代码后**只需重跑 `build-local` 再 `docker compose build server`**;改前端只跑 `npm run build` + `docker compose build server` 即可。 +> 有同事反馈 `go get` / `npm install` 慢,可以先 `go env -w GOPROXY=https://goproxy.cn,direct` 和 `npm config set registry https://registry.npmmirror.com`。 + +### 4. 配置 `.env` 与启动容器 ```bash cd deploy -docker compose -f docker-compose.server.yml up -d --build +cp .env.example .env +``` + +**必改** `.env` 中的三项: + +```env +JWT_SECRET=请改成 >=32 位随机串 +CRYPTO_AES_KEY=请改成严格 64 位 hex(32 字节 AES-256) +MYSQL_ROOT_PASSWORD=你自己的强密码 +MYSQL_PASSWORD=你自己的强密码 ``` -### 5. 检查状态 +生成两个随机值的快捷命令: ```bash -docker compose -f docker-compose.server.yml ps -docker logs -f klein-api-dev -docker logs -f klein-admin-dev -docker logs -f klein-openai-dev -docker logs -f klein-worker-dev +openssl rand -hex 32 # CRYPTO_AES_KEY(64 位 hex) +openssl rand -base64 48 | tr -d '=/+' | cut -c1-48 # JWT_SECRET ``` -### 6. 访问地址 +启动: + +```bash +docker compose build server # 首次或后端/前端代码有更新后执行 +docker compose up -d +docker compose logs -f server +``` + +启动过程里 `server` 会自动: + +1. 等 `mysql` 健康; +2. 跑 `goose up` 应用全部迁移(用户 / 账号 / 审计 / 备份元数据等十余张表); +3. 启动 HTTP 服务 `:8080`。 + +> ### ⚠️ 没有默认账号 / 密码 —— 首位注册者自动成为管理员 +> +> **本项目 *不* 预置任何"默认管理员账号"或"默认密码"。** 部署起来后请按以下步骤走: +> +> 1. 浏览器打开 **`http://<服务器IP>:8080/register`** +> 2. 用自己的邮箱 + 自设密码完成第一次注册 +> 3. **这第一个账号会自动拿到 `admin` 角色**(见 `internal/auth/service.go` 的 `Register` Bootstrap 规则) +> 4. 之后再注册的账号都是普通用户 +> 5. 首位 admin 登录后,强烈建议去**管理后台 → 系统设置**把"允许开放注册"关掉,避免被陌生人占用 +> +> 如果你在网上看到"Admin123456" / "admin@smoke.test" 这类字样,那是 `scripts/smoke.mjs` 冒烟测试脚本自己创建测试账号时用的参数,**与部署默认凭证无关**。 -- 用户前台:`http(s)://你的域名:17080` -- 管理后台:`http(s)://你的域名:17088` -- OpenAI 兼容 API:`http(s)://你的域名:17200/v1` +### 5. 首次登录 -## 生产建议 +- 前端站点地址:`http://<服务器IP>:8080/` +- 按上面的 ⚠️ 框完成首位 admin 注册即可登录。 +- 忘记管理员密码、或需要把某个普通用户提权为 admin,见「FAQ · 管理员密码找回 / 提权」。 -- 前台、后台、API 分域名部署更清晰 -- 管理后台建议限制来源 IP -- OpenAI 兼容接口建议走独立域名 -- 80 / 443 端口建议由 Caddy 或 Nginx 统一接管 SSL -- 图片和视频素材建议落 OSS 或本地缓存,避免直接暴露上游地址 +### 6. 日常更新流程速查 -## 开发方式 +| 场景 | 命令 | +|------|------| +| **仅改了前端** | `cd web && npm run build` → `cd ../deploy && docker compose build server && docker compose up -d server` | +| **仅改了后端** | `bash deploy/build-local.sh`(前端 `npm run build` 无代价也会重跑)→ `cd deploy && docker compose build server && docker compose up -d server` | +| **拉 main 新版** | `git pull` → `bash deploy/build-local.sh` → `docker compose build server && docker compose up -d server` | +| **只重启不重建** | `docker compose restart server` | +| **想回滚上一版** | `docker compose down server` → 恢复 `deploy/bin/gpt2api` + `web/dist` 备份 → `docker compose build server && docker compose up -d server` | + +### 7. 五分钟跑通第一次生图 + +1. **管理后台 → 代理管理** → 新建一个代理(或批量导入),确认健康分为绿色; +2. **管理后台 → GPT账号** → 批量导入 JSON / AT / RT / ST,绑定上一步的代理; +3. **管理后台 → 模型配置** → 确认有 `gpt-image-2` 等图像模型且已启用; +4. **管理后台 → 用户管理** → 给自己(或业务账号)加点积分; +5. **个人中心 → 在线体验** → 文生图 tab → 输入 prompt → 点生成; +6. 观察 `docker compose logs -f server` 里 `image runner` 系列日志,看到 `image runner result summary refs=[...] signed_count=N` 就是成功出图。 + +--- + +## 六、配置说明 + +**核心配置文件:`configs/config.yaml`**(Docker 部署时通过环境变量 `GPT2API_*` 覆盖)。完整字段见 [`configs/config.example.yaml`](configs/config.example.yaml)。 + +| 段落 | 关键字段 | 说明 | +|------|---------|------| +| `app` | `listen`, `base_url` | HTTP 监听地址 / 对外 base URL(签名图片代理用) | +| `mysql` | `dsn`, `max_open_conns` | MySQL 连接,生产推荐 500 + | +| `redis` | `addr`, `pool_size` | Redis,生产推荐 pool=500(锁 / 限流 / 令牌桶) | +| `jwt` | `secret`, `*_ttl_sec` | **生产必须覆盖** `secret` | +| `crypto` | `aes_key` | **生产必须覆盖**,32 字节 hex,用于加密账号 AT / cookies | +| `scheduler` | `min_interval_sec` | **单账号最小间隔秒**,对抗风控核心参数 | +| `scheduler` | `daily_usage_ratio` | 单号日消耗熔断阈值(0~1,0.6 = 消耗超过日额度 60% 自动下线) | +| `scheduler` | `cooldown_429_sec` | 连续 429 冷却时间 | +| `upstream` | `request_timeout_sec` | 上游 chatgpt.com 请求超时(图片建议 60+) | +| `upstream` | `sse_read_timeout_sec` | SSE 读超时,批量出图场景建议 300+ | +| `epay` | `gateway_url`, `pid`, `key` | 易支付网关,用于积分充值 | + +**环境变量覆盖规则**:任何 `configs/config.yaml` 字段都可以用 `GPT2API_XXX_YYY` 覆盖。例如 `app.listen` → `GPT2API_APP_LISTEN`。 + +--- + +## 七、API 使用示例 + +> 所有 API 完全兼容 OpenAI 官方 SDK,把 `base_url` 换成你的部署地址即可。 + +### 7.1 生图(同步,单张) ```bash -cd deploy -docker compose -f docker-compose.dev-full.yml up -d --build +curl https://your-domain.com/v1/images/generations \ + -H "Authorization: Bearer sk-xxx" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-image-2", + "prompt": "a cute orange cat playing with yarn, studio ghibli style", + "n": 1, + "size": "1024x1024" + }' +``` + +**返回**(已经是 HMAC 签名的图片代理地址,可直接 `` 嵌入): + +```json +{ + "created": 1776582860, + "data": [ + { + "url": "https://your-domain.com/p/img/img_2631ffad.../0?exp=...&sig=..." + } + ] +} +``` + +**可选:本地 2K / 4K 高清放大** —— 在 body 里加 `"upscale": "2k"` 或 `"upscale": "4k"`,后端会在图片代理 URL 首次被请求时对原图做 Catmull-Rom 插值放大并以 PNG 返回(长边 2560 / 3840 等比缩)。算法本地执行,不调用任何外部服务;首次 ~0.5~1.5s,之后进程内 LRU 毫秒级命中。**请注意这是传统插值算法,不是 AI 超分**,不会补出新纹理。详见 [8.2 4K / 2K 高清输出](#82-4k--2k-高清输出本地-catmull-rom-放大)。 + +### 7.2 图生图 / 多图参考(项目扩展字段) + +```bash +curl https://your-domain.com/v1/images/generations \ + -H "Authorization: Bearer sk-xxx" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-image-2", + "prompt": "将这两张图合成为赛博朋克风格的海报", + "n": 2, + "size": "1792x1024", + "reference_images": [ + "https://example.com/ref1.jpg", + "data:image/png;base64,iVBORw0KG..." + ] + }' +``` + +### 7.3 Python(OpenAI SDK) + +```python +from openai import OpenAI + +client = OpenAI( + base_url="https://your-domain.com/v1", + api_key="sk-xxx", +) + +resp = client.images.generate( + model="gpt-image-2", + prompt="cyberpunk alley in the rain, cinematic lighting", + n=2, + size="1792x1024", +) +for img in resp.data: + print(img.url) +``` + +### 7.4 异步(适合慢 prompt / 批量场景) + +```bash +# 提交任务 +curl -X POST https://your-domain.com/v1/images/generations \ + -H "Authorization: Bearer sk-xxx" \ + -H "Content-Type: application/json" \ + -d '{"model":"gpt-image-2","prompt":"...", "async":true}' +# 返回 {"task_id":"img_xxx","status":"queued"} + +# 轮询结果 +curl https://your-domain.com/v1/images/tasks/img_xxx \ + -H "Authorization: Bearer sk-xxx" ``` -本地开发时,前后端都可以单独启动,也可以只拉起 MySQL / Redis。 +--- + +## 八、重点能力详解 + +### 8.1 IMG2 出图 + +`chatgpt.com` 的 **IMG2 管线已正式上线**:Plus / Team 账号单次调用可返回 1~2 张高清图,体感就是**出图更快、画质更好**。`gpt2api` 的生图链路已全面对齐正式版协议,不再做"灰度命中判定 / preview_only 重试"这类节流: + +- **速度优先**:SSE 里出现 `file-service` / `sediment` 引用立即下载,**不等齐 N 张**; +- **单轮单账号**:一次 `f/conversation` SSE 之后最多再短轮询 300 秒补齐(per-attempt 总上限 6 分钟,外层 handler 上限 7 分钟),超时仍有 ≥ 1 张也按成功返回; +- **硬错误才切账号**:只有 `rate_limited` / `no_available_account` / `auth_required` 才会触发一次跨账号重试,其他错误直接暴露给调用方便于排障。 + +#### 如何判断本次成功出图? + +`gpt2api` 在图片生成的每一个关键节点都打了结构化日志,观察 `docker compose logs -f server` 中的 `image runner` 系列: + +```text +image runner SSE parsed sse_fids=[file_xxx,file_yyy] finish_type=stop image_gen_task_id=... +image runner enough refs from SSE, skip polling # SSE 阶段已够数,0 次轮询 +image runner poll done poll_status=success poll_fids=[file_xxx] +image runner result summary refs=[...] signed_count=2 +``` + +如果 Poll 超时(默认 300 秒内没拿到任何图),会直接落到 `poll_timeout`,不再悄悄换账号重试。 + +#### 数据库里复盘 + +每张图片都会被持久化到 `image_tasks` 表,带上 `account_id` / `status` / `image_urls`: + +```sql +SELECT + account_id, + COUNT(*) AS total, + SUM(status = 'success') AS success, + ROUND(SUM(status = 'success') * 100 / COUNT(*), 2) AS success_rate_pct, + ROUND(AVG(JSON_LENGTH(image_urls)), 2) AS avg_imgs_per_task +FROM image_tasks +WHERE created_at > NOW() - INTERVAL 1 DAY +GROUP BY account_id +ORDER BY success_rate_pct DESC; +``` + +### 8.2 4K / 2K 高清输出(本地 Catmull-Rom 放大) + +`chatgpt.com` 原生只提供 `1024×1024` / `1792×1024` / `1024×1792` 三档原图。面板 / API 都支持一个扩展字段 `upscale`,在**拿到原图后**由网关在本地把画面放大到 2K / 4K 后以 PNG 返回。 + +#### 档位与尺寸 + +| `upscale` | 长边 | 举例(原 1024×1024) | 举例(原 1792×1024) | +|-----------|------|---------------------|---------------------| +| `""`(默认) | 原图 | 1024×1024 | 1792×1024 | +| `"2k"` | 2560 | 2560×2560 | 2560×1463 | +| `"4k"` | 3840 | 3840×3840 | 3840×2194 | + +短边按原比例等比缩,不做裁切;**若原图长边已经 ≥ 目标,直接透传原字节**(避免重复有损编码)。 + +#### 工作原理 + +1. 生图时 `upscale` 只写进 `image_tasks.upscale`,**不改变**与上游 `chatgpt.com` 的交互,也不影响生图速度; +2. 图片代理 URL `/p/img/:task_id/:idx` 被请求时,后端按 `task.upscale` 决定是否放大: + - 查进程内 **LRU 缓存**(默认 512MB,约 50 张 4K PNG); + - 未命中 → 拉原图 → `image.Decode` → `golang.org/x/image/draw.CatmullRom` → `png.Encode`(BestSpeed) → 写入 LRU; + - 命中 → 毫秒级直接返回字节。 +3. 放大计算有**并发信号量**限制(默认 `NumCPU`),避免 4K 请求风暴把 CPU 打满影响主生图链路; +4. 放大失败自动**回落到原图**,不给用户白屏。 + +响应头 `X-Upscale` 便于排障: + +```text +X-Upscale: 4k;cache=miss # 首次放大 +X-Upscale: 4k;cache=hit # 命中缓存 +X-Upscale: 4k;noop # 原图长边已足够大,未重新编码 +X-Upscale: 4k;err # 放大失败,已回落到原图 +``` + +#### API 调用 + +```bash +curl https://your-domain.com/v1/images/generations \ + -H "Authorization: Bearer sk-xxx" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-image-2", + "prompt": "a futuristic city at dusk, cinematic light", + "n": 1, + "size": "1792x1024", + "upscale": "4k" + }' +``` + +`/v1/images/edits`(multipart/form-data)同样支持 `upscale` 字段。 + +#### 面板操作 + +**个人中心 → 在线体验 → 文生图 / 图生图**,左侧表单新增「**输出尺寸**」单选:原图 / 2K 高清 / 4K 高清,默认原图。切换后重新生成的图代理 URL 会自动走对应放大档位。 + +#### 取舍与注意事项 + +- **不是 AI 超分**:Catmull-Rom 是传统双三次插值,只会把画面变"更大、更平滑",不会补出新的毛发、纹理、细节。对"原图本身细节不足"的画面,4K 的视觉收益有限; +- **4K 文件较大**:单张 4K PNG 通常 5~15MB。首屏仅加载 1~2 张没问题,大批量下载请考虑改调 `"2k"` 或直接用原图; +- **原图 1792 → 4K 仅 2.14x**;而方形 1024 → 4K 是 3.75x,**方形档位的视觉"放大感"最强**,效果差异也最明显; +- 若需要真正的"补细节"效果,请接入 Real-ESRGAN / SwinIR 等 AI 超分模型,那是另一个工程量级(需要模型权重 + GPU / ONNX Runtime),不在当前项目范围。 + +### 8.3 批量出图 / 多张聚合 + +`gpt2api` 支持三种"批量"场景: + +| 场景 | 调用方式 | 实际并发 | +|------|---------|---------| +| **单请求 N 张** | `{"n": 4}` | 1 个账号跑 1 个会话,**IMG2 命中时会把 2 张放到同一 tool message,框架自动聚合到 `image_urls`** | +| **多请求并发** | SDK 线程池同时发 K 个请求 | K 个账号 lease 并行,受限于账号池数 × `min_interval_sec` | +| **纯异步任务池** | `{"async": true}` 提交 + 轮询 | 后端 Worker 池消费,适合 1000+ 条 prompt 的大批量场景 | + +**单请求多张(`n`)**:IMG2 终稿通道下,`n=2` 通常一次就到位;`n>2` 会被框架拆成多轮 follow-up 请求在**同一会话**里完成,共享一个 account lease,避免占用多个账号。 + +**多请求并发(脚本示例)**: + +```python +import concurrent.futures +from openai import OpenAI + +client = OpenAI(base_url="https://your-domain.com/v1", api_key="sk-xxx") + +prompts = ["..." for _ in range(100)] # 100 条 prompt + +def one(p): + return client.images.generate(model="gpt-image-2", prompt=p, n=1, size="1024x1024") + +with concurrent.futures.ThreadPoolExecutor(max_workers=32) as ex: + for r in ex.map(one, prompts): + print(r.data[0].url) +``` + +**账号池够大时,脚本端只需控制 `max_workers`,后端会自动按 `min_interval_sec` 给每个账号排队**,不会因为并发撞风控。 + +### 8.4 高性能高并发调度 + +#### 并发目标 + +| 场景 | 典型配置 | 能力 | +|------|---------|------| +| 图片生成(IMG2,账号池 100+) | `min_interval_sec=60` | **单机 >= 1000 并发图**(受账号池规模线性缩放) | +| 文字 SSE(沉睡中,见第一节) | `min_interval_sec=30` | 单机 >= 2000 并发 SSE | +| 下游 RPM/TPM 限流 | Redis 令牌桶 | 单 Key 5000 RPM 无压力 | + +#### 调度核心参数(`configs/config.yaml → scheduler`) + +```yaml +scheduler: + min_interval_sec: 60 # 单账号最小间隔秒(对抗同号高频 → 429) + daily_usage_ratio: 0.6 # 单号日配额消耗超过 60% 自动熔断下线 + lock_ttl_sec: 1200 # Redis 账号锁 TTL,lease 超时自动释放 + cooldown_429_sec: 600 # 连续 429 时该账号冷却时间 + warned_pause_hours: 24 # 收到"警告页"后的账号强制停用时长 +``` + +#### 为什么能稳住高并发? + +1. **串行 lease + Redis 锁**:每个账号同一时刻只有 1 个请求在飞,`min_interval_sec` 保证两次请求之间的最小间隔,风控曲线平滑; +2. **代理强绑定**:每个账号锁死一个代理,IP 指纹不混用,触发风控的只是个别账号,其它账号不受牵连; +3. **熔断自恢复**:账号消耗到阈值 / 收到 429 / 拿到警告页,自动进入冷却,冷却结束自动复活,无需人工干预; +4. **横向扩展**:`docker compose up --scale server=3` 即可多副本;Redis 锁天然跨节点,MySQL + backups 卷共享即可; +5. **观测友好**:`usage_logs` + `image_tasks` 两张表足以做任意维度(账号 / 用户 / 模型 / 时段)的下钻分析;后台「用量统计」已内置可视化。 + +#### 压测建议 + +- 用 `vegeta` / `wrk2` 对 `/v1/images/generations` 做恒定 QPS 压测,观察 `usage_logs.status` 分布; +- 对比调节 `min_interval_sec` 在 `30 / 60 / 90` 的成功率曲线,每批至少 500 样本; +- Redis `pool_size` 和 MySQL `max_open_conns` 生产都推荐至少 500,否则会成为瓶颈。 + +--- + +## 九、管理后台功能概览 + +| 页面 | 路径 | 核心能力 | +|------|------|---------| +| 个人总览 | `/personal/dashboard` | 积分余额、14 天请求趋势、热门模型、最近请求/账变 | +| 在线体验 | `/personal/play` | 浏览器内 Playground,文生图 / 图生图,实时扣费 | +| 接口文档 | `/personal/docs` | curl / Python SDK 代码片段、历史任务列表 | +| API Keys | `/personal/keys` | 创建 / 禁用 / 限流 Key | +| 使用记录 | `/personal/usage` | 本人的请求日志 / 积分流水 | +| 账单与充值 | `/personal/billing` | 套餐购买、易支付下单 | +| 用户管理 | `/admin/users` | 用户 CRUD、角色、状态、分组 | +| 积分管理 | `/admin/credits` | 手动调账、账变流水 | +| 充值订单 | `/admin/recharges` | 充值流水、套餐管理 | +| GPT 账号池 | `/admin/accounts` | JSON / AT / RT / ST 批量导入、刷新、探测、熔断 | +| 代理管理 | `/admin/proxies` | HTTP / SOCKS5、健康分探测 | +| 模型配置 | `/admin/models` | 对外 slug → 上游 slug 映射、每张图 / 每 1M token 计费 | +| 用户分组 | `/admin/groups` | 分组倍率(VIP / 内部 / 渠道) | +| 全局 Keys | `/admin/keys` | 跨用户管控所有下游 Key | +| 用量统计 | `/admin/usage` | 全站成功率 / Token / 积分收入 | +| 审计日志 | `/admin/audit` | 管理员所有写操作自动落审计 | +| 数据备份 | `/admin/backup` | `mysqldump` 一键备份 / 恢复 | +| 系统设置 | `/admin/settings` | 站点名 / 邮件 / 易支付 / 网关调度参数 | + +--- + +## 十、目录结构 + +```text +gpt2api/ +├── cmd/server/ # 主入口 +├── configs/ # 配置示例 +├── deploy/ # Docker / compose / entrypoint / nginx +├── docs/ # 补充文档 +├── internal/ # 后端核心(所有业务代码,Go 风格私有包) +│ ├── account/ # 账号池:导入 / 刷新 / 探测 / DAO +│ ├── apikey/ # 下游 Key +│ ├── audit/ # 审计日志中间件 +│ ├── auth/ # 登录 / JWT +│ ├── backup/ # 数据库备份 / 恢复 +│ ├── billing/ # 积分预扣 / 结算 +│ ├── gateway/ # OpenAI 兼容入口(chat / images / images_proxy) +│ ├── image/ # 图片任务 Runner / 异步任务 / DAO +│ ├── middleware/ # CORS / JWT / Recover / RequestID / RateLimit +│ ├── model/ # 模型配置(slug 映射 + 价格) +│ ├── proxy/ # 代理池 + 健康分探测 +│ ├── ratelimit/ # Redis 令牌桶 +│ ├── rbac/ # 权限常量 +│ ├── recharge/ # 充值 / 套餐 / EPay 对接 +│ ├── scheduler/ # 账号调度器(核心) +│ ├── server/ # Router 装配 +│ ├── settings/ # 动态系统设置 +│ ├── upstream/chatgpt/ # ChatGPT 逆向客户端(sentinel / fchat / image / pow / headers) +│ ├── usage/ # 请求日志 / 统计 +│ └── user/ # 用户模块 +├── pkg/ # 可被外部复用的纯工具包 +├── sql/migrations/ # Goose 迁移 +├── web/ # 前端 Vue 3 源码 +│ ├── src/ +│ │ ├── api/ # axios 封装 +│ │ ├── config/ # feature flag(含 ENABLE_CHAT_MODEL) +│ │ ├── stores/ # pinia +│ │ ├── views/personal/ # 用户侧页面 +│ │ ├── views/admin/ # 管理员页面 +│ │ └── router/ +│ └── dist/ # 构建产物(Dockerfile 会 COPY 进镜像) +├── API_NOTES.md # chatgpt.com 逆向接口备忘 +├── RISK_AND_SAAS.md # 风控 / 防封号原则 +└── README.md # 当前文档 +``` + +--- + +## 十一、二次开发 / 定制 + +### 后端 + +```bash +# 本机拉依赖 +go mod tidy +# 跑迁移(先启 MySQL) +make migrate-up +# 本地热跑 +make run +``` + +加一个新的后端接口: + +1. `internal/xxx/handler.go` 加方法; +2. `internal/server/router.go` 注册路由; +3. 如果是写操作,配合 `audit.Middleware` 自动写审计。 + +### 前端 + +```bash +cd web +npm install +npm run dev # http://localhost:5173,自动代理到 :8080 +npm run build # 构建到 web/dist/,供后端 SPA 路由挂载 +``` + +**恢复文字模型 UI**(当前版本默认关闭): + +```ts +// web/src/config/feature.ts +export const ENABLE_CHAT_MODEL = true // ← 改这里,重新 build +``` + +所有涉及文字入口的页面(在线体验 / 接口文档 / 用量统计 / 模型配置 …)会自动重新出现。 + +### 自定义模型 slug 映射 + +chatgpt.com 的实际上游 slug 会随账号等级微调(例如 Plus 用 `gpt-5-3`,免费用 `gpt-5`),映射表集中在 `internal/gateway/chat.go` 的 `mapUpstreamModelSlug`。图片模型的映射在 `admin/Models.vue` 的「模型配置」页面里可视化编辑。 + +--- + +## 十二、FAQ + +
+Q1. 为什么要强制绑定代理? + +`chatgpt.com` 对 IP × 账号 × TLS 指纹 × 设备指纹做联合风控。同一个出口 IP 跑多个账号,会被识别为同一批"多开"用户,整批被封。每个账号锁死一个代理是目前最稳的防封号方案。 +
+ +
+Q2. 出图偶尔 poll_timeout 怎么办? + +IMG2 正式上线后,`gpt2api` 默认 SSE 解析完成后最多短轮询 **300 秒**补齐图片(per-attempt 硬上限 6 分钟,handler 硬上限 7 分钟)。如果依然没拿到任何 `file-service` / `sediment` 引用,会直接抛 `poll_timeout` 给调用方,**不会再悄悄换账号重试**(那样只会吞掉用户时间)。常见处理: + +1. 在「管理后台 → GPT账号」对该账号做「全部探测」,确认代理 / 账号本身可用; +2. 把长期 `poll_timeout` 的账号绑定到更快的代理,或移出主力池; +3. 如果想在网关层面做"失败切账号"的托底重试,自行在客户端实现即可——这符合"出错就让上层看到"的设计原则。 +
+ +
+Q3. 文字模型为什么默认关闭? + +`chatgpt.com` 新 sentinel(`/prepare` + `/finalize`)对文字通路引入了 Turnstile 挑战,项目已实现完整的两步 sentinel + conduit token + 全套 header 指纹,但 Turnstile 本身需要外部 solver 才能稳定返回 `turnstile` 字段。当前版本默认走**单步回退**,适合图片(图片通路容忍度高),对文字则存在静默拒绝。接入 Turnstile solver 后把 `ENABLE_CHAT_MODEL` 改回 `true` 即可恢复。 +
+ +
+Q4. 部署后图片 403 / 图片刷不出来? + +`chatgpt.com` 的 `estuary/content` 图片 CDN 有防盗链。本项目已内置带 HMAC 签名的图片代理(`/p/img/...`),所有返回给下游的 `image_urls` 都已经是**代理后的签名 URL**。如果你仍然拿到了 `chatgpt.com` 原始 URL,说明是老版本,拉取 main 分支重建即可。 +
+ +
+Q5. MySQL / Redis 能用公有云托管吗? + +可以。修改 `.env` / `configs/config.yaml` 的 DSN 与 `redis.addr` 即可。Redis 建议至少 Redis 7,且开启 AOF;MySQL 建议 8.0+,`max_connections >= 500`。 +
+ +
+Q6. 如何横向扩展到多节点? + +`docker compose up -d --scale server=3` + 前面挂 Nginx / Traefik 做 L7。Redis 分布式锁天然支持多副本;MySQL 和 JWT / AES 密钥统一即可;`backups` 卷改成共享存储(NFS / S3 fuse)。详见 [`deploy/README.md`](deploy/README.md#单节点-vs-多节点)。 +
+ +
+Q7. 支持 ChatGPT Codex / GPT-5 / Claude / Gemini 吗? + +当前聚焦 `chatgpt.com` 逆向(涵盖 GPT-5 / GPT-5-3 / gpt-image-2 / Codex 等)。Claude / Gemini 需要接入各自原生 API 或其对应的逆向客户端,不在当前仓库范围内。 +
+ +
+Q8. 管理员密码找回 / 把某个普通用户提权为 admin? + +本项目没有内置默认 admin 账号(见「5. 首次登录」的 Bootstrap 说明),忘记密码时有两种救急手段: + +**① 提权已有用户为 admin**(前提:你记得该账号的密码) + +```bash +# 替换成你的 MySQL 容器名 / 用户名 / 密码 / 目标邮箱 +docker exec -e MYSQL_PWD='' gpt2api-mysql \ + mysql -ugpt2api gpt2api \ + -e "UPDATE users SET role='admin' WHERE email='you@example.com';" +``` + +**② 重置某个用户的密码为已知明文** + +项目用 `golang.org/x/crypto/bcrypt`(cost=10)保存密码哈希,重置流程: + +```bash +# 1) 在任意一台装了 Go 的机器上,用下面这段一次性小脚本把明文转成 bcrypt hash +cd /tmp && mkdir bcgen && cd bcgen && go mod init bcgen \ + && go get golang.org/x/crypto/bcrypt +cat > main.go <<'EOF' +package main +import ("fmt"; "os"; "golang.org/x/crypto/bcrypt") +func main() { + h, _ := bcrypt.GenerateFromPassword([]byte(os.Args[1]), 10) + fmt.Println(string(h)) +} +EOF +go run . 'MyNewPassword@123' +# 输出例如:$2a$10$ljpcvSGUybg8vN4Bd3zjBu1YlQipf/gkeWMOflGUvw7EoTfM4/t.i + +# 2) 把这个 hash 写进 users.password_hash(整行原样粘贴,带 $2a$...) +docker exec -e MYSQL_PWD='' gpt2api-mysql \ + mysql -ugpt2api gpt2api -e "UPDATE users \ + SET password_hash='\$2a\$10\$ljpcvSGUybg8vN4Bd3zjBu1YlQipf/gkeWMOflGUvw7EoTfM4/t.i' \ + WHERE email='you@example.com';" + +# 3) 用新密码 MyNewPassword@123 登录即可。 +``` + +bcrypt 同明文每次生成的 hash 不同都能互相校验,上面的 hash 字符串**只能对应明文 `MyNewPassword@123`**,换明文必须重跑 Step 1。 +
+ +
+Q9. 4K 出图看着像"糊了放大",没有更多细节? + +这是预期行为。`gpt2api` 的 4K / 2K 是**本地 Catmull-Rom 插值放大**,属于传统算法:只会让图像尺寸变大、边缘更平滑,**不会像 AI 超分那样补出新的纹理 / 毛发 / 发丝细节**。适合的场景: + +- 打印 / 海报 / 大屏展示,需要物理像素够大; +- 原图已经细节充分(例如 `1792×1024` 的复杂场景),仅仅是想铺满 4K 显示器; +- 不想引入额外的 GPU / 超分模型推理成本。 + +如果你确实需要"补细节",请自行接入 Real-ESRGAN / SwinIR / GFPGAN 等 AI 超分模型(通常需要 GPU 或 ONNX Runtime),或等待后续 Roadmap 里的 M14。详细原理见 [8.2 4K / 2K 高清输出](#82-4k--2k-高清输出本地-catmull-rom-放大)。 +
+ +
+Q10. GPT 账号池批量删除后再导入报 Error 1062 Duplicate entry xxx for key 'oai_accounts.uk_email'? + +这是 **v0.x 初期 schema 的遗留 bug**,已在迁移 `20260423000004_accounts_uk_email_soft_delete_aware.sql` 修复。升级后重导不再冲突,无需手工清理。 + +**为什么会冲突?** `oai_accounts` 的删除是软删除(只置 `deleted_at`,不真删行),方便审计回溯;但初始 schema 对 `email` 建的是**纯列**唯一索引 `uk_email`,没考虑"软删应当释放 email 槽位"。结果软删后那个 email 仍占着唯一键,再导入同 email 立刻 1062。 + +**修复做法**:MySQL 原生不支持 Postgres 那种 `CREATE UNIQUE INDEX ... WHERE deleted_at IS NULL` 的部分索引,所以引入一个 STORED 生成列: + +```sql +active_email = CASE WHEN deleted_at IS NULL THEN email ELSE NULL END +UNIQUE KEY uk_active_email (active_email) +``` + +MySQL 的唯一索引允许多个 NULL 共存:活行 `active_email = email` → 唯一性生效;软删行 `active_email = NULL` → 互不冲突,也不和活行冲突。再导入同 email 完全放行。 + +**升级步骤**:`git pull` → `docker compose build server` → `docker compose up -d`,容器内 goose 会自动跑这条迁移。老库那些被软删卡住的行**迁移生效后自动"让位"**,不需要你手工 `UPDATE` 或 `DELETE` 清理。 +
+ +--- + +## 十三、Roadmap + +- [x] M1 骨架:配置 / 迁移 / 鉴权 / RBAC +- [x] M2 账号池 + 调度器 +- [x] M3 生图 runner + IMG2 支持 +- [x] M4 下游 Key 全特性(RPM/TPM/IP/模型白名单) +- [x] M5 积分钱包 + 易支付充值 +- [x] M6 管理后台(Vue 3) +- [x] M7 风控熔断 + 图片签名代理 +- [x] M8 IMG2 终稿直出 + 多图聚合 +- [x] M9 本地 2K/4K 高清放大(Catmull-Rom + LRU 缓存) +- [ ] M10 Turnstile solver 接入 → 恢复文字通路 +- [ ] M11 图片任务大批量 Worker 池 +- [ ] M12 账号分组(按出图成功率 / 地区分配) +- [ ] M13 Prometheus 指标 + Grafana 大盘 +- [ ] M14 对接 Real-ESRGAN 等 AI 超分作为 4K 放大的可选后端 + +--- + +## 十四、参与贡献 + +欢迎 PR / Issue。提交前请: + +1. `go vet ./... && go test ./...` 全绿; +2. `cd web && npm run build` 能通过; +3. Commit 用中文或英文均可,但请**明确写清楚动机**(是 fix 还是 feature,涉及哪个模块); +4. 涉及上游协议改动的 PR,请在 PR 描述里附上 HAR / curl 证据,不凭感觉改指纹。 + +**代码规范**: + +- Go:标准 `gofmt`,包名小写;业务代码放 `internal/`,纯工具放 `pkg/`; +- Vue / TS:`vue-tsc --noEmit` 必须通过;文件名 PascalCase,组件 ` - - diff --git a/frontend/apps/admin/nginx.conf b/frontend/apps/admin/nginx.conf deleted file mode 100644 index d0664d20..00000000 --- a/frontend/apps/admin/nginx.conf +++ /dev/null @@ -1,22 +0,0 @@ -server { - listen 80; - server_name _; - - root /usr/share/nginx/html; - index index.html; - - gzip on; - gzip_types text/plain text/css application/javascript application/json image/svg+xml; - - location / { - try_files $uri /admin/index.html; - add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always; - add_header Pragma "no-cache" always; - add_header Expires "0" always; - } - - location ~* \.(?:css|js|woff2?|ttf|svg|png|jpg|jpeg|webp|avif)$ { - expires 30d; - add_header Cache-Control "public, immutable"; - } -} diff --git a/frontend/apps/admin/package.json b/frontend/apps/admin/package.json deleted file mode 100644 index e8f6b42f..00000000 --- a/frontend/apps/admin/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "@kleinai/admin", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "dev": "vite --port 5174 --host", - "build": "tsc -b && vite build", - "preview": "vite preview --port 5174", - "typecheck": "tsc --noEmit", - "lint": "eslint src --ext .ts,.tsx" - }, - "dependencies": { - "@hookform/resolvers": "^3.10.0", - "@kleinai/theme": "workspace:*", - "@tanstack/react-query": "^5.51.5", - "axios": "^1.7.2", - "clsx": "^2.1.1", - "lucide-react": "^0.402.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-hook-form": "^7.52.1", - "react-router-dom": "^6.25.1", - "zod": "^3.23.8", - "zustand": "^4.5.4" - }, - "devDependencies": { - "@types/react": "^18.3.3", - "@types/react-dom": "^18.3.0", - "@vitejs/plugin-react": "^4.3.1", - "autoprefixer": "^10.4.19", - "postcss": "^8.4.39", - "tailwindcss": "^3.4.10", - "typescript": "^5.5.3", - "vite": "^5.3.4" - } -} diff --git a/frontend/apps/admin/postcss.config.cjs b/frontend/apps/admin/postcss.config.cjs deleted file mode 100644 index 12a703d9..00000000 --- a/frontend/apps/admin/postcss.config.cjs +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -}; diff --git a/frontend/apps/admin/public/logo-icon.png b/frontend/apps/admin/public/logo-icon.png deleted file mode 100644 index 990f6214..00000000 Binary files a/frontend/apps/admin/public/logo-icon.png and /dev/null differ diff --git a/frontend/apps/admin/public/logo.png b/frontend/apps/admin/public/logo.png deleted file mode 100644 index 990f6214..00000000 Binary files a/frontend/apps/admin/public/logo.png and /dev/null differ diff --git a/frontend/apps/admin/src/App.tsx b/frontend/apps/admin/src/App.tsx deleted file mode 100644 index dd7cbc1b..00000000 --- a/frontend/apps/admin/src/App.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { Suspense, lazy } from 'react'; -import { Navigate, Route, Routes } from 'react-router-dom'; -import { AdminLayout } from './layouts/AdminLayout'; -import RequireAuth from './routes/RequireAuth'; -import { Toaster } from './components/Toaster'; - -const LoginPage = lazy(() => import('./pages/auth/LoginPage')); -const DashboardPage = lazy(() => import('./pages/dashboard/DashboardPage')); -const TokenAccountsPage = lazy(() => import('./pages/accounts/TokenAccountsPage')); -const ProxiesPage = lazy(() => import('./pages/proxies/ProxiesPage')); -const UsersPage = lazy(() => import('./pages/users/UsersPage')); -const BillingPage = lazy(() => import('./pages/billing/BillingPage')); -const PromoPage = lazy(() => import('./pages/promo/PromoPage')); -const CDKPage = lazy(() => import('./pages/promo/CDKPage')); -const ConfigPage = lazy(() => import('./pages/system/ConfigPage')); -const BillingSettingsPage = lazy(() => import('./pages/system/BillingSettingsPage')); -const RechargePackagesPage = lazy(() => import('./pages/system/RechargePackagesPage')); -const ModelPricesPage = lazy(() => import('./pages/system/ModelPricesPage')); -const LogsPage = lazy(() => import('./pages/logs/LogsPage')); - -export default function App() { - return ( - <> - 加载中…}> - - } /> - }> - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - - - - ); -} diff --git a/frontend/apps/admin/src/components/Logo.tsx b/frontend/apps/admin/src/components/Logo.tsx deleted file mode 100644 index 28081b73..00000000 --- a/frontend/apps/admin/src/components/Logo.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import clsx from 'clsx'; - -const LOGO_SRC = '/logo-icon.png?v=20260501'; - -interface LogoProps { - size?: 'sm' | 'md' | 'lg'; - /** 仅图标,不渲染文字 */ - iconOnly?: boolean; - /** 头部独占文案(例如「管理后台」) */ - suffix?: string; - className?: string; -} - -const SIZE: Record, { icon: number; text: string }> = { - sm: { icon: 24, text: 'text-small' }, - md: { icon: 30, text: 'text-h4' }, - lg: { icon: 40, text: 'text-h3' }, -}; - -export function Logo({ size = 'md', iconOnly = false, suffix, className }: LogoProps) { - const cfg = SIZE[size]; - return ( -
- 首页 - {!iconOnly && ( - - 首页 - {suffix && {suffix}} - - )} -
- ); -} diff --git a/frontend/apps/admin/src/components/Toaster.tsx b/frontend/apps/admin/src/components/Toaster.tsx deleted file mode 100644 index 395289d0..00000000 --- a/frontend/apps/admin/src/components/Toaster.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import clsx from 'clsx'; -import { CheckCircle2, X, AlertTriangle, Info } from 'lucide-react'; - -import { useToastStore } from '../stores/toast'; - -const ICONS = { - success: CheckCircle2, - error: AlertTriangle, - info: Info, -} as const; - -const COLOR = { - success: 'border-success bg-surface-1 text-success', - error: 'border-danger bg-surface-1 text-danger', - info: 'border-klein-500 bg-surface-1 text-klein-500', -} as const; - -export function Toaster() { - const items = useToastStore((s) => s.items); - const dismiss = useToastStore((s) => s.dismiss); - return ( -
- {items.map((t) => { - const Icon = ICONS[t.kind]; - return ( -
- -

{t.msg}

- -
- ); - })} -
- ); -} diff --git a/frontend/apps/admin/src/index.css b/frontend/apps/admin/src/index.css deleted file mode 100644 index e32dad02..00000000 --- a/frontend/apps/admin/src/index.css +++ /dev/null @@ -1,24 +0,0 @@ -@import '@kleinai/theme/components.css'; - -@tailwind base; -@tailwind components; -@tailwind utilities; - -/* =========================================================== - admin app · 仅保留站点级覆盖; - 按钮 / 表单 / 卡片 / 文字 等统一在 @kleinai/theme/components.css - =========================================================== */ - -@layer base { - /* 管理后台默认浅色,正文略紧凑,提升表格可读性 */ - body { - line-height: 1.5; - } -} - -@layer components { - /* 后台 layout 内可滚动主体 */ - .admin-pane { - @apply rounded-lg border border-border bg-surface-1 shadow-1; - } -} diff --git a/frontend/apps/admin/src/layouts/AdminLayout.tsx b/frontend/apps/admin/src/layouts/AdminLayout.tsx deleted file mode 100644 index 9f33b827..00000000 --- a/frontend/apps/admin/src/layouts/AdminLayout.tsx +++ /dev/null @@ -1,282 +0,0 @@ -import { FormEvent, useState } from 'react'; -import { NavLink, Outlet, useNavigate } from 'react-router-dom'; -import { - BadgeDollarSign, - ChevronDown, - FileText, - Globe2, - Github, - KeyRound, - LayoutDashboard, - LockKeyhole, - LogOut, - Menu, - ReceiptText, - Settings, - Tag, - Ticket, - UserCircle2, - Users, - Wallet, - WalletCards, - X, -} from 'lucide-react'; -import clsx from 'clsx'; - -import { Logo } from '../components/Logo'; -import { authApi } from '../lib/services'; -import { useAuthStore } from '../stores/auth'; -import { toast } from '../stores/toast'; - -const APP_VERSION = 'v2.0.0'; -const SOURCE_HREF = String.fromCharCode( - 104, 116, 116, 112, 115, 58, 47, 47, 103, 105, 116, 104, 117, 98, 46, 99, - 111, 109, 47, 52, 51, 50, 53, 51, 57, 47, 103, 112, 116, 50, 97, 112, 105, -); - -const NAV = [ - { to: '/dashboard', label: '仪表盘', icon: LayoutDashboard }, - { to: '/accounts', label: 'Token 管理', icon: KeyRound }, - { to: '/proxies', label: '代理管理', icon: Globe2 }, - { to: '/users', label: '用户管理', icon: Users }, - { to: '/billing', label: '充值消费', icon: Wallet }, - { to: '/promo', label: '优惠码', icon: Tag }, - { to: '/cdk', label: '兑换码 CDK', icon: Ticket }, - { to: '/config', label: '系统配置', icon: Settings }, - { to: '/billing-settings', label: '扣费设置', icon: ReceiptText }, - { to: '/recharge-packages', label: '充值套餐', icon: WalletCards }, - { to: '/model-prices', label: '模型价格', icon: BadgeDollarSign }, - { to: '/logs', label: '请求日志', icon: FileText }, -]; - -export function AdminLayout() { - const [mobileOpen, setMobileOpen] = useState(false); - const [menuOpen, setMenuOpen] = useState(false); - const [passwordOpen, setPasswordOpen] = useState(false); - const me = useAuthStore((s) => s.me); - const logout = useAuthStore((s) => s.logout); - const nav = useNavigate(); - - const handleLogout = () => { - logout(); - toast.info('已退出登录'); - nav('/login', { replace: true }); - }; - - const displayName = me?.nickname || me?.username || '管理员'; - const roleName = me?.role_name || me?.role_code || '管理员'; - const initial = displayName.slice(0, 1).toUpperCase(); - - return ( -
-
- - -
- - - - {mobileOpen && ( - - - {menuOpen && ( -
-
-
- - {initial} - -
-

{displayName}

-

{me?.username || 'admin'}

-
-
-
- {roleName} -
-
- - - -
- )} -
- -
- -
- - - {passwordOpen && setPasswordOpen(false)} />} - - ); -} - -function PasswordDialog({ onClose }: { onClose: () => void }) { - const [oldPassword, setOldPassword] = useState(''); - const [newPassword, setNewPassword] = useState(''); - const [confirm, setConfirm] = useState(''); - const [saving, setSaving] = useState(false); - - const submit = async (e: FormEvent) => { - e.preventDefault(); - if (newPassword.length < 8) { - toast.error('新密码至少 8 位'); - return; - } - if (newPassword !== confirm) { - toast.error('两次输入的新密码不一致'); - return; - } - setSaving(true); - try { - await authApi.changePassword({ old_password: oldPassword, new_password: newPassword }); - toast.success('密码已修改'); - onClose(); - } catch (err) { - toast.error(err instanceof Error ? err.message : '修改失败'); - } finally { - setSaving(false); - } - }; - - return ( -
- -
-
- - - -
-
- - -
- - - ); -} diff --git a/frontend/apps/admin/src/lib/api.ts b/frontend/apps/admin/src/lib/api.ts deleted file mode 100644 index 523856f3..00000000 --- a/frontend/apps/admin/src/lib/api.ts +++ /dev/null @@ -1,113 +0,0 @@ -// 后台管理 axios 客户端。与用户端独立: -// - baseURL: /admin/api/v1 -// - token 存 localStorage(key: klein:admin:token) -// - 401 → 清 token,跳转 /login -import axios, { - AxiosError, - type AxiosInstance, - type AxiosRequestConfig, - type InternalAxiosRequestConfig, -} from 'axios'; - -import type { ApiBody, AdminLoginResp } from './types'; - -const TOKEN_KEY = 'klein:admin:token'; - -export interface StoredToken { - access: string; - refresh: string; - type: string; - accessExpireAt: number; - refreshExpireAt: number; -} - -export function loadToken(): StoredToken | null { - try { - const raw = localStorage.getItem(TOKEN_KEY); - if (!raw) return null; - return JSON.parse(raw) as StoredToken; - } catch { - return null; - } -} - -export function saveToken(tok: AdminLoginResp['token']): StoredToken { - const now = Date.now(); - const v: StoredToken = { - access: tok.access_token, - refresh: tok.refresh_token, - type: tok.token_type || 'Bearer', - accessExpireAt: now + tok.access_expire_in * 1000, - refreshExpireAt: now + tok.refresh_expire_in * 1000, - }; - localStorage.setItem(TOKEN_KEY, JSON.stringify(v)); - return v; -} - -export function clearToken() { - localStorage.removeItem(TOKEN_KEY); -} - -export class ApiError extends Error { - code: number; - httpStatus?: number; - traceId?: string; - constructor(msg: string, code: number, opts?: { httpStatus?: number; traceId?: string }) { - super(msg); - this.code = code; - this.httpStatus = opts?.httpStatus; - this.traceId = opts?.traceId; - } -} - -const baseURL = - (import.meta.env.VITE_ADMIN_BASE_URL as string | undefined)?.replace(/\/+$/, '') ?? - '/admin/api/v1'; - -export const api: AxiosInstance = axios.create({ - baseURL, - timeout: 30_000, - headers: { Accept: 'application/json' }, -}); - -api.interceptors.request.use((cfg: InternalAxiosRequestConfig) => { - const tok = loadToken(); - if (tok && cfg.headers) { - cfg.headers.set?.('Authorization', `${tok.type} ${tok.access}`); - } - return cfg; -}); - -let unauthorizedHandler: (() => void) | null = null; -export function setUnauthorizedHandler(fn: () => void) { - unauthorizedHandler = fn; -} - -api.interceptors.response.use( - (res) => { - const body = res.data as ApiBody; - if (body && typeof body === 'object' && 'code' in body && body.code !== 0) { - throw new ApiError(body.msg || '请求失败', body.code, { traceId: body.trace_id }); - } - return res; - }, - (err: AxiosError>) => { - const status = err.response?.status; - const body = err.response?.data; - const msg = body?.msg ?? err.message ?? '网络异常'; - const code = body?.code ?? status ?? -1; - if (status === 401) { - clearToken(); - unauthorizedHandler?.(); - } - return Promise.reject( - new ApiError(msg, code, { httpStatus: status, traceId: body?.trace_id }), - ); - }, -); - -/** 统一请求并解构 data,抹平 axios 返回结构 */ -export async function request(cfg: AxiosRequestConfig): Promise { - const res = await api.request>(cfg); - return (res.data?.data ?? (undefined as unknown)) as T; -} diff --git a/frontend/apps/admin/src/lib/format.ts b/frontend/apps/admin/src/lib/format.ts deleted file mode 100644 index 36d2aca3..00000000 --- a/frontend/apps/admin/src/lib/format.ts +++ /dev/null @@ -1,47 +0,0 @@ -// 后台展示格式化工具。 - -const numberFmt = new Intl.NumberFormat('zh-CN'); - -/** 后端 points(*100) → 展示数值 */ -export function fmtPoints(p: number | undefined | null): string { - if (p == null) return '0'; - return numberFmt.format(p / 100); -} - -export function fmtNumber(n: number | undefined | null): string { - if (n == null) return '0'; - return numberFmt.format(n); -} - -export function fmtTime(ts?: number): string { - if (!ts) return '—'; - const d = new Date(ts * 1000); - if (Number.isNaN(d.getTime())) return '—'; - const pad = (n: number) => n.toString().padStart(2, '0'); - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; -} - -export function fmtRelative(ts?: number): string { - if (!ts) return '—'; - const diff = Date.now() / 1000 - ts; - if (diff < 60) return `${Math.max(0, Math.floor(diff))} 秒前`; - if (diff < 3600) return `${Math.floor(diff / 60)} 分钟前`; - if (diff < 86400) return `${Math.floor(diff / 3600)} 小时前`; - if (diff < 86400 * 30) return `${Math.floor(diff / 86400)} 天前`; - return fmtTime(ts); -} - -export function statusLabel(s: number): { label: string; tone: 'ok' | 'warn' | 'err' | 'mute' } { - switch (s) { - case 1: - return { label: '正常', tone: 'ok' }; - case 0: - return { label: '禁用', tone: 'mute' }; - case 2: - return { label: '熔断', tone: 'warn' }; - case -1: - return { label: '已删除', tone: 'err' }; - default: - return { label: String(s), tone: 'mute' }; - } -} diff --git a/frontend/apps/admin/src/lib/services.ts b/frontend/apps/admin/src/lib/services.ts deleted file mode 100644 index 962f3568..00000000 --- a/frontend/apps/admin/src/lib/services.ts +++ /dev/null @@ -1,240 +0,0 @@ -// 后台 API 抽象。 -import { request } from './api'; -import type { - AccountBatchImportBody, - AccountBatchImportResult, - AccountBatchRefreshResp, - AccountBulkOpResult, - AccountCreateBody, - AccountItem, - AccountPurgeBody, - AccountRefreshResp, - AccountSecretsResp, - AccountTestResp, - AccountUpdateBody, - AdminUserAdjustPointsBody, - AdminUserAdjustPointsResp, - AdminUserCreateBody, - AdminGenerationLogItem, - AdminGenerationLogPurgeResp, - AdminGenerationUpstreamLogItem, - AdminPromoBody, - AdminPromoItem, - AdminUserItem, - AdminUserUpdateBody, - AdminWalletLogItem, - AdminLoginResp, - AdminMe, - CDKCreateBatchBody, - CDKCreateBatchResp, - DashboardOverviewResp, - PageData, - PoolStatsResp, - ProxyCreateBody, - ProxyItem, - ProxyTestResp, - ProxyUpdateBody, - SystemSettings, -} from './types'; - -export const authApi = { - login: (username: string, password: string) => - request({ - url: '/auth/login', - method: 'POST', - // 后端 dto.LoginReq 字段名为 account,前端表单仍展示「管理员账号」 - data: { account: username, password }, - }), - me: () => request({ url: '/auth/me', method: 'GET' }), - changePassword: (body: { old_password: string; new_password: string }) => - request<{ ok: boolean }>({ url: '/auth/password', method: 'POST', data: body }), -}; - -export const dashboardApi = { - overview: () => request({ url: '/dashboard/overview', method: 'GET' }), -}; - -export interface AdminUserListQuery { - keyword?: string; - status?: 0 | 1; - page?: number; - page_size?: number; -} - -export const usersApi = { - list: (q: AdminUserListQuery = {}) => - request>({ url: '/users', method: 'GET', params: q }), - create: (body: AdminUserCreateBody) => - request<{ id: number }>({ url: '/users', method: 'POST', data: body }), - update: (id: number, body: AdminUserUpdateBody) => - request({ url: `/users/${id}`, method: 'PUT', data: body }), - adjustPoints: (id: number, body: AdminUserAdjustPointsBody) => - request({ url: `/users/${id}/points`, method: 'POST', data: body }), -}; - -export interface GenerationLogListQuery { - keyword?: string; - kind?: 'image' | 'video' | 'chat' | 'text'; - status?: 0 | 1 | 2 | 3 | 4; - page?: number; - page_size?: number; -} - -export const logsApi = { - generations: (q: GenerationLogListQuery = {}) => - request>({ url: '/logs/generations', method: 'GET', params: q }), - generationUpstream: (taskId: string) => - request({ url: `/logs/generations/${taskId}/upstream`, method: 'GET' }), - purgeGenerations: (days: number) => - request({ url: '/logs/generations', method: 'DELETE', data: { days } }), -}; - -export interface WalletLogListQuery { - keyword?: string; - user_id?: number; - biz_type?: string; - direction?: 1 | -1 | ''; - page?: number; - page_size?: number; -} - -export const billingApi = { - walletLogs: (q: WalletLogListQuery = {}) => - request>({ url: '/billing/wallet-logs', method: 'GET', params: q }), -}; - -export interface PromoListQuery { - keyword?: string; - status?: 0 | 1 | ''; - discount_type?: 1 | 2 | 3 | ''; - page?: number; - page_size?: number; -} - -export const promoApi = { - list: (q: PromoListQuery = {}) => - request>({ url: '/promo/codes', method: 'GET', params: q }), - create: (body: AdminPromoBody) => - request<{ id: number }>({ url: '/promo/codes', method: 'POST', data: body }), - update: (id: number, body: AdminPromoBody) => - request({ url: `/promo/codes/${id}`, method: 'PUT', data: body }), - remove: (id: number) => request({ url: `/promo/codes/${id}`, method: 'DELETE' }), -}; - -export interface AccountListQuery { - provider?: 'gpt' | 'grok'; - status?: -1 | 0 | 1 | 2; - keyword?: string; - page?: number; - page_size?: number; -} - -export const accountsApi = { - list: (q: AccountListQuery = {}) => - request>({ - url: '/accounts', - method: 'GET', - params: q, - }), - create: (body: AccountCreateBody) => - request<{ id: number }>({ url: '/accounts', method: 'POST', data: body }), - update: (id: number, body: AccountUpdateBody) => - request({ url: `/accounts/${id}`, method: 'PUT', data: body }), - remove: (id: number) => request({ url: `/accounts/${id}`, method: 'DELETE' }), - batchImport: (body: AccountBatchImportBody) => - request({ - url: '/accounts/import', - method: 'POST', - data: body, - }), - stats: () => request({ url: '/accounts/stats', method: 'GET' }), - test: (id: number) => - request({ url: `/accounts/${id}/test`, method: 'POST' }), - refresh: (id: number) => - request({ url: `/accounts/${id}/refresh`, method: 'POST' }), - secrets: (id: number) => - request({ url: `/accounts/${id}/secrets`, method: 'GET' }), - batchRefresh: (provider?: 'gpt' | 'grok' | '', page = 1, pageSize = 50) => - request({ - url: '/accounts/batch-refresh', - method: 'POST', - data: { provider: provider ?? '', page, page_size: pageSize }, - }), - batchProbe: (provider?: 'gpt' | 'grok' | '', page = 1, pageSize = 20) => - request<{ - probed: number; - failed_ids: number[]; - page: number; - page_size: number; - total: number; - has_more: boolean; - next_page?: number; - }>({ - url: '/accounts/batch-probe', - method: 'POST', - data: { provider: provider ?? '', page, page_size: pageSize }, - }), - batchDelete: (ids: number[]) => - request({ - url: '/accounts/batch-delete', - method: 'POST', - data: { ids }, - }), - purge: (body: AccountPurgeBody) => - request({ - url: '/accounts/purge', - method: 'POST', - data: body, - }), -}; - -export const cdkApi = { - createBatch: (body: CDKCreateBatchBody) => - request({ - url: '/cdk/batches', - method: 'POST', - data: body, - }), -}; - -// ==================== 代理 ==================== - -export interface ProxyListQuery { - status?: 0 | 1; - keyword?: string; - page?: number; - page_size?: number; -} - -export const proxiesApi = { - list: (q: ProxyListQuery = {}) => - request>({ url: '/proxies', method: 'GET', params: q }), - create: (body: ProxyCreateBody) => - request<{ id: number }>({ url: '/proxies', method: 'POST', data: body }), - update: (id: number, body: ProxyUpdateBody) => - request({ url: `/proxies/${id}`, method: 'PUT', data: body }), - remove: (id: number) => - request({ url: `/proxies/${id}`, method: 'DELETE' }), - test: (id: number) => - request({ url: `/proxies/${id}/test`, method: 'POST' }), -}; - -// ==================== 系统配置 ==================== - -export const systemApi = { - get: () => request({ url: '/system/settings', method: 'GET' }), - update: (kv: Partial) => - request<{ updated: number }>({ - url: '/system/settings', - method: 'PUT', - data: kv, - }), - cacheStats: () => - request<{ root: string; files: number; bytes: number }>({ url: '/system/cache', method: 'GET' }), - cleanCache: (body: { days?: number; all?: boolean }) => - request<{ deleted_files: number; deleted_bytes: number; remain_bytes: number }>({ - url: '/system/cache', - method: 'DELETE', - data: body, - }), -}; diff --git a/frontend/apps/admin/src/lib/types.ts b/frontend/apps/admin/src/lib/types.ts deleted file mode 100644 index 63bf5266..00000000 --- a/frontend/apps/admin/src/lib/types.ts +++ /dev/null @@ -1,494 +0,0 @@ -// 后台管理 - 与后端 dto / response 对齐的前端类型。 -// 注意:所有 *_points / points 字段单位为「点 *100」,展示请除以 100。 - -export interface ApiBody { - code: number; - msg: string; - data?: T; - trace_id?: string; -} - -export interface PageData { - list: T[]; - total: number; - page: number; - page_size: number; -} - -export interface AdminLoginResp { - id: number; - username: string; - nickname: string; - role_id: number; - token: { - access_token: string; - refresh_token: string; - token_type: string; - access_expire_in: number; - refresh_expire_in: number; - }; -} - -export interface AdminMe { - id: number; - username: string; - nickname: string; - email?: string; - role_id: number; - role_code: string; - role_name: string; -} - -/** 账号池条目 */ -export interface AdminUserItem { - id: number; - uuid: string; - email?: string; - phone?: string; - username?: string; - avatar?: string; - points: number; - frozen_points: number; - total_recharge: number; - plan_code: string; - plan_expire_at?: number; - inviter_id?: number; - invite_code: string; - status: 0 | 1 | number; - register_ip?: string; - last_login_at?: number; - last_login_ip?: string; - created_at: number; - updated_at: number; -} - -export interface AdminUserCreateBody { - account: string; - password: string; - username?: string; - points?: number; - status?: 0 | 1; -} - -export interface AdminUserUpdateBody { - email?: string | null; - phone?: string | null; - username?: string | null; - avatar?: string | null; - password?: string; - status?: 0 | 1; - plan_code?: string; - plan_expire_at?: number | null; -} - -export interface AdminUserAdjustPointsBody { - action: 'recharge' | 'deduct'; - points: number; - remark?: string; -} - -export interface AdminUserAdjustPointsResp { - points_before: number; - points_after: number; -} - -export interface AdminGenerationLogItem { - task_id: string; - created_at: number; - user_id: number; - user_label: string; - api_key_id?: number; - key_label?: string; - kind: 'image' | 'video' | string; - model_code: string; - prompt: string; - status: 0 | 1 | 2 | 3 | 4 | number; - duration_ms?: number; - cost_points: number; - preview_url?: string; - error?: string; -} - -export interface AdminGenerationLogPurgeResp { - deleted: number; -} - -export interface AdminGenerationUpstreamLogItem { - id: number; - task_id: string; - provider: string; - account_id?: number; - stage: string; - method?: string; - url?: string; - status_code: number; - duration_ms: number; - request_excerpt?: string; - response_excerpt?: string; - error?: string; - meta?: string; - created_at: number; -} - -export interface AdminWalletLogItem { - id: number; - created_at: number; - user_id: number; - user_label: string; - direction: 1 | -1 | number; - biz_type: string; - biz_id: string; - points: number; - points_before: number; - points_after: number; - remark?: string; -} - -export interface AdminPromoItem { - id: number; - code: string; - name: string; - discount_type: 1 | 2 | 3 | number; - discount_val: number; - min_amount: number; - apply_to: string; - total_qty: number; - used_qty: number; - per_user_limit: number; - start_at: number; - end_at: number; - status: 0 | 1 | number; - created_at: number; - updated_at: number; -} - -export interface AdminPromoBody { - code?: string; - name?: string; - discount_type?: 1 | 2 | 3; - discount_val?: number; - min_amount?: number; - apply_to?: string; - total_qty?: number; - per_user_limit?: number; - start_at?: number; - end_at?: number; - status?: 0 | 1; -} - -export interface DashboardProviderRow { - provider: string; - total: number; - enabled: number; - available: number; - broken: number; - test_ok: number; - quota_remaining: number; - quota_total: number; - quota_used: number; - success_count: number; - error_count: number; -} - -export interface DashboardRecentTask { - task_id: string; - created_at: number; - user_label: string; - kind: 'image' | 'video' | string; - model_code: string; - count: number; - status: number; - cost_points: number; -} - -export interface DashboardTrendPoint { - date: string; - generated: number; - cost_points: number; -} - -export interface DashboardOverviewResp { - generated_today: number; - generated_total: number; - image_today: number; - image_total: number; - video_today: number; - video_total: number; - text_tokens_today: number; - text_tokens_total: number; - cost_points_today: number; - cost_points_total: number; - wallet_spend_today: number; - wallet_spend_total: number; - users_total: number; - users_today: number; - active_users_today: number; - success_rate_today: number; - account_providers: DashboardProviderRow[]; - recent_generations: DashboardRecentTask[]; - trend: DashboardTrendPoint[]; -} - -export interface AccountItem { - id: number; - provider: 'gpt' | 'grok' | string; - name: string; - auth_type: 'api_key' | 'cookie' | 'oauth' | string; - credential_mask: string; - base_url?: string; - proxy_id?: number; - weight: number; - rpm_limit: number; - tpm_limit: number; - daily_quota: number; - monthly_quota: number; - /** -1 软删 / 0 禁用 / 1 启用 / 2 熔断 */ - status: -1 | 0 | 1 | 2 | number; - cooldown_until?: number; - last_used_at?: number; - last_error?: string; - error_count: number; - success_count: number; - remark?: string; - /** OAuth 状态 */ - has_refresh_token?: boolean; - has_access_token?: boolean; - access_token_expire_at?: number; - last_refresh_at?: number; - /** 最近一次连通性测试 */ - last_test_at?: number; - /** 0 未测 / 1 OK / 2 FAIL */ - last_test_status?: 0 | 1 | 2 | number; - last_test_latency_ms?: number; - last_test_error?: string; - plan_type?: string; - default_model?: string; - image_quota_remaining?: number; - image_quota_total?: number; - image_quota_reset_at?: number; - created_at: number; - updated_at: number; -} - -/** 账号连通性测试结果 */ -export interface AccountTestResp { - ok: boolean; - latency_ms: number; - error?: string; - plan_type?: string; - default_model?: string; - image_quota_remaining?: number; - image_quota_total?: number; - image_quota_reset_at?: number; -} - -/** OAuth 刷新结果 */ -export interface AccountRefreshResp { - ok: boolean; - expires_in?: number; - refreshed_at: number; - has_refresh_token: boolean; -} - -/** 批量刷新结果 */ -export interface AccountBatchRefreshResp { - refreshed: number; - failed_ids: number[]; - page: number; - page_size: number; - total: number; - has_more: boolean; - next_page?: number; -} - -/** 创建账号入参(明文,后端加密);OAuth 可与 sora2ok 一致拆 AT/RT/ST/client_id。 */ -export interface AccountCreateBody { - provider: 'gpt' | 'grok'; - name: string; - auth_type: 'api_key' | 'cookie' | 'oauth'; - /** api_key / cookie 必填;oauth 可与 access_token / refresh_token 组合 */ - credential?: string; - access_token?: string; - refresh_token?: string; - session_token?: string; - client_id?: string; - base_url?: string; - /** 绑定代理 ID;0/undefined = 不绑定 */ - proxy_id?: number; - weight?: number; - rpm_limit?: number; - tpm_limit?: number; - daily_quota?: number; - monthly_quota?: number; - remark?: string; -} - -/** POST /accounts/batch-delete、/accounts/purge 响应 */ -export interface AccountBulkOpResult { - deleted: number; -} - -export interface AccountPurgeBody { - scope: 'all' | 'invalid' | 'zero_quota'; - provider?: 'gpt' | 'grok'; - confirm?: string; -} - -/** 单个账号的明文凭证(管理员编辑面板回显用,解密失败为空串) */ -export interface AccountSecretsResp { - credential?: string; - access_token?: string; - refresh_token?: string; - session_token?: string; - client_id?: string; -} - -export interface AccountUpdateBody { - name?: string; - credential?: string; - /** OAuth 账号专用:单独替换三件套(空字符串表示清空对应列) */ - access_token?: string; - refresh_token?: string; - session_token?: string; - client_id?: string; - base_url?: string; - /** 绑定代理 ID;0 = 不绑定 */ - proxy_id?: number; - weight?: number; - rpm_limit?: number; - tpm_limit?: number; - daily_quota?: number; - monthly_quota?: number; - status?: -1 | 0 | 1 | 2; - remark?: string; -} - -/** sub2api / Codex 导出 JSON 中单条账号 */ -export interface Sub2APIAccountItem { - name?: string; - platform?: string; - type?: string; - priority?: number; - concurrency?: number; - credentials?: { - access_token?: string; - refresh_token?: string; - client_id?: string; - id_token?: string; - email?: string; - chatgpt_account_id?: string; - chatgpt_user_id?: string; - organization_id?: string; - plan_type?: string; - }; -} - -export interface AccountBatchImportBody { - /** 默认 lines;sub2api 为 JSON 分片导入 */ - format?: 'lines' | 'sub2api'; - provider: 'gpt' | 'grok'; - /** lines 模式必填 */ - auth_type?: 'api_key' | 'cookie' | 'oauth'; - base_url?: string; - /** 默认绑定代理 ID;0/undefined = 不绑定 */ - proxy_id?: number; - weight?: number; - /** - * lines:一行一条;支持 `@@` / `@` / ``。 - */ - text?: string; - /** sub2api:当前分片的账号列表(建议每批 ≤500) */ - accounts?: Sub2APIAccountItem[]; -} - -/** POST /accounts/import 响应 */ -export interface AccountBatchImportResult { - imported: number; - skipped: number; -} - -export interface PoolStatsResp { - pool: Record; -} -export interface CDKCreateBatchBody { - batch_no: string; - name: string; - /** 单码价值(后端 *100,传 *100 后的整数) */ - points: number; - qty: number; - per_user_limit?: number; - /** unix 秒;0/不传 = 永不过期 */ - expire_at?: number; -} - -export interface CDKCreateBatchResp { - id: number; - batch_no: string; - total_qty: number; -} - -// ==================== 代理 ==================== - -export interface ProxyItem { - id: number; - name: string; - protocol: 'http' | 'https' | 'socks5' | 'socks5h' | string; - host: string; - port: number; - username?: string; - has_password: boolean; - /** 0 禁用 / 1 启用 */ - status: 0 | 1 | number; - last_check_at?: number; - /** 0 未测 / 1 OK / 2 FAIL */ - last_check_ok: 0 | 1 | 2 | number; - last_check_ms: number; - last_error?: string; - remark?: string; - created_at: number; - updated_at: number; -} - -export interface ProxyCreateBody { - name: string; - protocol: 'http' | 'https' | 'socks5' | 'socks5h'; - host: string; - port: number; - username?: string; - password?: string; - remark?: string; -} - -export interface ProxyUpdateBody { - name?: string; - protocol?: 'http' | 'https' | 'socks5' | 'socks5h'; - host?: string; - port?: number; - username?: string; - password?: string; - status?: 0 | 1; - remark?: string; -} - -export interface ProxyTestResp { - ok: boolean; - latency_ms: number; - error?: string; -} - -// ==================== 系统配置 ==================== - -/** 已知 key(前端只列展示需要的,未列的也允许保存) */ -export interface SystemSettings { - /** 是否启用全局代理 */ - 'proxy.global_enabled'?: boolean; - /** 全局代理 ID(0 表示不启用) */ - 'proxy.global_id'?: number; - /** OAuth access_token 距过期 N 小时内自动刷新 */ - 'oauth.refresh_before_hours'?: number; - /** OpenAI Codex CLI client_id */ - 'oauth.openai_client_id'?: string; - /** OpenAI OAuth Token Endpoint */ - 'oauth.openai_token_url'?: string; - [key: string]: unknown; -} diff --git a/frontend/apps/admin/src/main.tsx b/frontend/apps/admin/src/main.tsx deleted file mode 100644 index fd72ae80..00000000 --- a/frontend/apps/admin/src/main.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import { BrowserRouter } from 'react-router-dom'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; - -import App from './App'; -import { setUnauthorizedHandler } from './lib/api'; -import { useAuthStore } from './stores/auth'; -import { toast } from './stores/toast'; -import '@kleinai/theme/tokens.css'; -import '@kleinai/theme/animations.css'; -import './index.css'; - -const qc = new QueryClient({ - defaultOptions: { - queries: { retry: 1, refetchOnWindowFocus: false, staleTime: 30_000 }, - }, -}); - -setUnauthorizedHandler(() => { - useAuthStore.getState().logout(); - toast.error('登录已过期,请重新登录'); - if (typeof window !== 'undefined' && !window.location.pathname.endsWith('/login')) { - window.location.href = '/login'; - } -}); - -ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( - - - - - - - , -); diff --git a/frontend/apps/admin/src/pages/_placeholder.tsx b/frontend/apps/admin/src/pages/_placeholder.tsx deleted file mode 100644 index edf73a99..00000000 --- a/frontend/apps/admin/src/pages/_placeholder.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { Construction } from 'lucide-react'; - -interface Props { - title: string; - desc: string; - hint?: string; -} - -export function PlaceholderPage({ title, desc, hint }: Props) { - return ( -
-
-
-

{title}

-

{desc}

-
-
- -
-
-
- -
-

该模块开发中

-

- {hint ?? '正在对接对应 admin API;上线后会替换此处占位界面,请稍候。'} -

-
-
-
- ); -} diff --git a/frontend/apps/admin/src/pages/accounts/TokenAccountsPage.tsx b/frontend/apps/admin/src/pages/accounts/TokenAccountsPage.tsx deleted file mode 100644 index 33d83751..00000000 --- a/frontend/apps/admin/src/pages/accounts/TokenAccountsPage.tsx +++ /dev/null @@ -1,1766 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { - Plus, Upload, RefreshCw, Trash2, Power, Activity, RotateCw, CheckCircle2, XCircle, Clock, - ChevronDown, ChevronUp, AlertCircle, Pencil, - ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, -} from 'lucide-react'; -import { useEffect, useMemo, useRef, useState } from 'react'; - -import { ApiError } from '../../lib/api'; -import { fmtNumber, fmtRelative, fmtTime, statusLabel } from '../../lib/format'; -import { accountsApi, proxiesApi } from '../../lib/services'; -import type { - AccountBatchImportBody, - AccountCreateBody, - AccountItem, - AccountPurgeBody, - AccountUpdateBody, - ProxyItem, - Sub2APIAccountItem, -} from '../../lib/types'; -import { toast } from '../../stores/toast'; - -/** 把用户可能漏 scheme 的 host 自动补成 https://;空字符串保持空 */ -function normalizeBaseURL(s?: string): string | undefined { - const v = (s || '').trim(); - if (!v) return undefined; - if (/^https?:\/\//i.test(v)) return v; - return `https://${v}`; -} - -/** 默认 auth_type:GPT 走 OAuth (AT/RT/ST);GROK 走 SSO Token。 */ -function defaultAuthType(provider: 'gpt' | 'grok'): 'api_key' | 'oauth' | 'cookie' { - return provider === 'gpt' ? 'oauth' : 'cookie'; -} - -const TONE_CLS: Record<'ok' | 'warn' | 'err' | 'mute', string> = { - ok: 'badge badge-success', - warn: 'badge badge-warning', - err: 'badge badge-danger', - mute: 'badge', -}; - -function testLabel(s?: number): { label: string; cls: string; icon: typeof CheckCircle2 } { - switch (s) { - case 1: return { label: 'OK', cls: 'text-success', icon: CheckCircle2 }; - case 2: return { label: 'FAIL', cls: 'text-danger', icon: XCircle }; - default: return { label: '未测', cls: 'text-text-tertiary', icon: Clock }; - } -} - -function expireState(expSec?: number): { label: string; detail: string; cls: string } { - if (!expSec) return { label: '未设置', detail: '未设置到期时间', cls: 'text-text-tertiary' }; - const expIn = expSec - Date.now() / 1000; - if (expIn <= 0) return { label: '已过期', detail: fmtTime(expSec), cls: 'text-danger' }; - if (expIn < 3600) return { label: `${Math.max(1, Math.floor(expIn / 60))} 分钟`, detail: fmtTime(expSec), cls: 'text-warning' }; - if (expIn < 86400) return { label: `${Math.floor(expIn / 3600)} 小时`, detail: fmtTime(expSec), cls: 'text-warning' }; - return { label: `${Math.floor(expIn / 86400)} 天`, detail: fmtTime(expSec), cls: 'text-text-secondary' }; -} - -/** 调度状态 + 最近错误/连通结果,用于列表「状态」列(避免启用仍显示「正常」)。 */ -function accountRowStatus(r: AccountItem): { label: string; tone: 'ok' | 'warn' | 'err' | 'mute' } { - const base = statusLabel(r.status); - if (r.status !== 1) { - return { label: base.label, tone: base.tone }; - } - const le = (r.last_error || '').trim(); - const te = (r.last_test_error || '').trim(); - const testFail = r.last_test_status === 2; - if (le || testFail || te) { - return { label: '异常', tone: 'err' }; - } - return { label: base.label, tone: base.tone }; -} - -export default function TokenAccountsPage() { - const qc = useQueryClient(); - - const [provider, setProvider] = useState<'all' | 'gpt' | 'grok'>('all'); - const [keyword, setKeyword] = useState(''); - const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(10); - const PAGE_SIZE_OPTIONS = [10, 20, 50, 100, 1000]; - - const [openCreate, setOpenCreate] = useState(false); - const [openImport, setOpenImport] = useState(false); - const [editTarget, setEditTarget] = useState(null); - - const query = useMemo( - () => ({ - provider: provider === 'all' ? undefined : provider, - keyword: keyword || undefined, - page, - page_size: pageSize, - }), - [provider, keyword, page, pageSize], - ); - - const list = useQuery({ - queryKey: ['admin', 'accounts', 'list', query], - queryFn: () => accountsApi.list(query), - }); - - const refresh = () => { - qc.invalidateQueries({ queryKey: ['admin', 'accounts'] }); - qc.invalidateQueries({ queryKey: ['admin', 'pool', 'stats'] }); - }; - - const toggleStatus = useMutation({ - mutationFn: ({ id, status }: { id: number; status: 0 | 1 }) => - accountsApi.update(id, { status }), - onSuccess: () => { - refresh(); - toast.success('已更新'); - }, - onError: (e: ApiError) => toast.error(e.message), - }); - - const remove = useMutation({ - mutationFn: (id: number) => accountsApi.remove(id), - onSuccess: () => { - refresh(); - toast.success('已删除'); - }, - onError: (e: ApiError) => toast.error(e.message), - }); - - const testMut = useMutation({ - mutationFn: (id: number) => accountsApi.test(id), - onSuccess: (r) => { - refresh(); - if (r.ok) { - toast.success(`连通正常 · ${r.latency_ms}ms`); - } else { - toast.error(`不可用:${r.error || '未知错误'}`); - } - }, - onError: (e: ApiError) => toast.error(e.message), - }); - - const refreshOAuthMut = useMutation({ - mutationFn: (id: number) => accountsApi.refresh(id), - onSuccess: (r) => { - refresh(); - const ttl = r.expires_in ? `,有效期 ${Math.floor(r.expires_in / 3600)}h` : ''; - toast.success(`已刷新 access_token${ttl}`); - }, - onError: (e: ApiError) => toast.error(e.message), - }); - - const batchRefresh = useMutation({ - mutationFn: async (p: 'gpt' | 'grok' | '') => { - let page = 1; - let refreshed = 0; - const failed_ids: number[] = []; - let total = 0; - const batchPageSize = Math.min(Math.max(pageSize, 1), 1000); - for (;;) { - const r = await accountsApi.batchRefresh(p || undefined, page, batchPageSize); - refreshed += r.refreshed; - failed_ids.push(...r.failed_ids); - total = r.total || total; - if (r.has_more && r.next_page) { - page = r.next_page; - continue; - } - if (total > 0 && page * batchPageSize < total) { - page += 1; - continue; - } - break; - } - return { refreshed, failed_ids }; - }, - onSuccess: (r) => { - refresh(); - toast.success(`已刷新 ${r.refreshed} 个 OAuth 账号${r.failed_ids.length ? `,失败 ${r.failed_ids.length}` : ''}`); - }, - onError: (e: ApiError) => toast.error(e.message), - }); - - const batchProbe = useMutation({ - mutationFn: async (p: 'gpt' | 'grok' | '') => { - let page = 1; - let probed = 0; - const failed_ids: number[] = []; - const batchPageSize = Math.min(Math.max(pageSize, 1), 1000); - let total = 0; - for (;;) { - const r = await accountsApi.batchProbe(p || undefined, page, batchPageSize); - probed += r.probed; - failed_ids.push(...r.failed_ids); - total = r.total || total; - if (r.has_more && r.next_page) { - page = r.next_page; - continue; - } - if (total > 0 && page * batchPageSize < total) { - page += 1; - continue; - } - break; - } - return { probed, failed_ids }; - }, - onSuccess: (r) => { - refresh(); - toast.success(`已检测 ${r.probed} 个账号用量${r.failed_ids.length ? `,失败 ${r.failed_ids.length} 个` : ''}`); - }, - onError: (e: ApiError) => toast.error(e.message), - }); - - const total = list.data?.total ?? 0; - const items: AccountItem[] = list.data?.list ?? []; - const lastPage = Math.max(1, Math.ceil(total / pageSize)); - - const [selected, setSelected] = useState>(new Set()); - - useEffect(() => { - setSelected(new Set()); - }, [provider, keyword]); - - const toggleSelect = (id: number) => { - setSelected((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - }; - - const toggleSelectPage = () => { - const pageIds = items.map((r) => r.id); - const allOn = pageIds.length > 0 && pageIds.every((id) => selected.has(id)); - setSelected((prev) => { - const next = new Set(prev); - if (allOn) { - pageIds.forEach((id) => next.delete(id)); - } else { - pageIds.forEach((id) => next.add(id)); - } - return next; - }); - }; - - const batchDeleteMut = useMutation({ - mutationFn: (ids: number[]) => accountsApi.batchDelete(ids), - onSuccess: (r) => { - refresh(); - setSelected(new Set()); - toast.success(`已删除 ${r.deleted} 条`); - }, - onError: (e: ApiError) => toast.error(e.message), - }); - - const purgeMut = useMutation({ - mutationFn: (b: AccountPurgeBody) => accountsApi.purge(b), - onSuccess: (r) => { - refresh(); - setSelected(new Set()); - toast.success(`已清理 ${r.deleted} 条`); - }, - onError: (e: ApiError) => toast.error(e.message), - }); - - const purgeProvider = provider === 'all' ? undefined : provider; - - const pageIds = items.map((r) => r.id); - const pageAllSelected = pageIds.length > 0 && pageIds.every((id) => selected.has(id)); - const headerCbRef = useRef(null); - const [bulkOpen, setBulkOpen] = useState(false); - const [purgeAllOpen, setPurgeAllOpen] = useState(false); - const bulkWrapRef = useRef(null); - useEffect(() => { - if (!bulkOpen) return; - const onDocClick = (e: MouseEvent) => { - if (!bulkWrapRef.current) return; - if (!bulkWrapRef.current.contains(e.target as Node)) setBulkOpen(false); - }; - const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') setBulkOpen(false); - }; - document.addEventListener('mousedown', onDocClick); - document.addEventListener('keydown', onKey); - return () => { - document.removeEventListener('mousedown', onDocClick); - document.removeEventListener('keydown', onKey); - }; - }, [bulkOpen]); - - useEffect(() => { - const el = headerCbRef.current; - if (!el) return; - const some = pageIds.some((id) => selected.has(id)); - el.indeterminate = some && !pageAllSelected; - }, [pageIds, selected, pageAllSelected]); - - return ( -
-
-
-

Token 管理

-

GPT / GROK 账号池

-
-
- - - - -
- - {bulkOpen && ( -
-
-
批量删除
-
- 作用范围:{provider === 'all' ? '全部账号' : provider.toUpperCase()} -
-
- - - -
- -
- )} -
- -
-
- - {/* 筛选 */} -
-
- {(['all', 'gpt', 'grok'] as const).map((p) => ( - - ))} -
- { setKeyword(e.target.value); setPage(1); }} - /> - - 共 {fmtNumber(total)} 条 - -
- - {/* 表格 */} -
- - - - - - - - - - - - - - - {list.isLoading && ( - - - - )} - {!list.isLoading && items.length === 0 && ( - - - - )} - {items.map((r) => { - const s = accountRowStatus(r); - const enabled = r.status === 1; - const isOAuth = r.auth_type === 'oauth'; - const t = testLabel(r.last_test_status); - const TestIcon = t.icon; - const exp = expireState(r.access_token_expire_at); - const lastErr = (r.last_error || '').trim(); - const testErr = (r.last_test_error || '').trim(); - const statusErrTip = [lastErr, testErr].filter(Boolean).join('\n\n'); - const atNeedsAttention = - isOAuth && (!r.has_access_token || r.last_test_status === 2 || !!testErr); - return ( - - - - - - - - - - - ); - })} - -
- - 名称Provider状态凭证 / 最近测试用量到期时间操作
加载中…
-
-

暂无账号

-

点击右上角【新增账号】或【批量导入】开始。

-
-
- toggleSelect(r.id)} - aria-label={`选择 ${r.name}`} - /> - - {r.name} - {r.remark && ( - {r.remark} - )} - {r.provider} - {s.label} - {!!statusErrTip && ( - - - - )} - - {isOAuth ? ( -
-
- - RT {r.has_refresh_token ? '✓' : '✗'} - - - AT {r.has_access_token ? '✓' : '∅'} - -
-
- - - {t.label} - {r.last_test_latency_ms ? ` · ${r.last_test_latency_ms}ms` : ''} - - {r.last_test_at && ( - {fmtRelative(r.last_test_at)} - )} - {testErr && ( - - - - )} -
-
- ) : ( -
- - - {t.label} - {r.last_test_latency_ms ? ` · ${r.last_test_latency_ms}ms` : ''} - - {r.last_test_at && ( - {fmtRelative(r.last_test_at)} - )} - {testErr && ( - - - - )} -
- )} -
- {r.image_quota_total ? ( - - 已用 {fmtNumber(Math.max(0, r.image_quota_total - (r.image_quota_remaining ?? 0)))} / {fmtNumber(r.image_quota_total)} - - ) : typeof r.image_quota_remaining === 'number' ? ( - 剩余 {fmtNumber(r.image_quota_remaining)} / 总额未知 - ) : ( - 未检测 - )} - -
- {exp.label} - {exp.detail} -
-
-
- - {isOAuth && ( - - )} - - - -
-
-
- - {/* 分页栏 */} -
-
- 每页 -
- -
- -
- -
- {total === 0 - ? '0' - : `${(page - 1) * pageSize + 1}–${Math.min(page * pageSize, total)} / ${fmtNumber(total)}`} -
- -
- - - - {page} - / {lastPage} - - - -
-
- - {openCreate && ( - setOpenCreate(false)} - onSuccess={() => { - setOpenCreate(false); - refresh(); - }} - /> - )} - {openImport && ( - setOpenImport(false)} - onSuccess={() => { - setOpenImport(false); - refresh(); - }} - /> - )} - {editTarget && ( - setEditTarget(null)} - onSuccess={() => { - setEditTarget(null); - refresh(); - }} - /> - )} - {purgeAllOpen && ( - setPurgeAllOpen(false)} - onConfirm={() => { - purgeMut.mutate( - { - scope: 'all', - provider: purgeProvider, - confirm: 'DELETE_ALL_ACCOUNTS', - }, - { onSuccess: () => setPurgeAllOpen(false) }, - ); - }} - /> - )} -
- ); -} - -// ============== Create Dialog ============== -function CreateDialog({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) { - const [body, setBody] = useState({ - provider: 'gpt', - name: '', - auth_type: 'oauth', - access_token: '', - refresh_token: '', - session_token: '', - client_id: '', - credential: '', - base_url: '', - proxy_id: undefined, - weight: 10, - rpm_limit: 0, - tpm_limit: 0, - daily_quota: 0, - monthly_quota: 0, - remark: '', - }); - const [showAdvanced, setShowAdvanced] = useState(false); - - const isOAuth = body.auth_type === 'oauth'; - - const m = useMutation({ - mutationFn: (b: AccountCreateBody) => accountsApi.create(b), - onSuccess: () => { - toast.success('账号已添加'); - onSuccess(); - }, - onError: (e: ApiError) => toast.error(e.message), - }); - - return ( - -
{ - e.preventDefault(); - const name = body.name.trim(); - if (!name) { - toast.error('请填写名称'); - return; - } - const at = (body.access_token || '').trim(); - const rt = (body.refresh_token || '').trim(); - const st = (body.session_token || '').trim(); - const cid = (body.client_id || '').trim(); - const cred = (body.credential || '').trim(); - if (isOAuth) { - if (!at && !rt) { - toast.error('请至少填写 Access Token 或 Refresh Token'); - return; - } - } else if (!cred) { - toast.error(body.auth_type === 'cookie' ? '请填写 Grok Token' : '请填写 API Key'); - return; - } - const payload: AccountCreateBody = { - provider: body.provider, - name, - auth_type: body.auth_type, - base_url: normalizeBaseURL(body.base_url), - proxy_id: body.proxy_id && body.proxy_id > 0 ? body.proxy_id : undefined, - weight: body.weight ?? 10, - rpm_limit: body.rpm_limit && body.rpm_limit > 0 ? body.rpm_limit : undefined, - tpm_limit: body.tpm_limit && body.tpm_limit > 0 ? body.tpm_limit : undefined, - daily_quota: body.daily_quota && body.daily_quota > 0 ? body.daily_quota : undefined, - monthly_quota: - body.monthly_quota && body.monthly_quota > 0 ? body.monthly_quota : undefined, - remark: body.remark?.trim() || undefined, - }; - if (isOAuth) { - if (at) payload.access_token = at; - if (rt) payload.refresh_token = rt; - if (st) payload.session_token = st; - if (cid) payload.client_id = cid; - } else { - payload.credential = cred; - } - m.mutate(payload); - }} - > -
- - - - - setBody((s) => ({ ...s, name: e.target.value }))} - /> - -
- - {isOAuth ? ( -
- -