M0+M1: 基建 + 用户/密钥/核心代理

后端 (Go/Gin/GORM):
- 配置(viper+env)、SQLite/Postgres 迁移、argon2id、AES-GCM 渠道密钥、JWT+refresh cookie
- 用户注册/登录/刷新/登出、API Key CRUD(仅存哈希、明文一次展示)
- 代理网关: /v1/chat/completions、/v1/responses、/v1/models 直通 OpenAI 渠道
  非流式+流式(SSE 零缓冲转发), 用量捕获(chat 末块/responses completed 嵌套),
  OpenAI 错误格式(401/402/404/502), 余额检查
- 异步批量记账 + 余额流水 + 日聚合, admin 用户/余额/配置 API
- 单测: crypto/jwt/apikey/流式 usage 提取

前端 (Vue3+TS+Vite+Tailwind v4):
- taste-skill 设计 tokens: 深色仪表盘, 石墨+信号铜色, Outfit+JetBrains Mono
- Landing/登录/注册, 控制台(仪表盘图表/密钥管理/用量明细)
- 基础组件 Button/Input/Badge/Modal, ECharts 用量图

部署: docker-compose(nginx+api+postgres), 双 Dockerfile, nginx SSE 反代
联调: scripts/mockupstream 本地 mock 上游, 端到端验证通过
This commit is contained in:
Sakurasan
2026-08-15 13:10:47 +08:00
commit 360c6b33a6
75 changed files with 7044 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
# openteam 服务配置示例 —— 复制为 .env 后修改
# 所有项均可用环境变量 OT_<KEY> 覆盖(点号转下划线,如 db.driver → OT_DB_DRIVER)
# 运行环境 development | production
OT_ENV=development
OT_PORT=8080
# 数据库(开发默认 SQLite;生产切 postgres)
OT_DB_DRIVER=sqlite
OT_DB_DSN=data/openteam.db
# OT_DB_DRIVER=postgres
# OT_DB_DSN=host=localhost user=openteam password=openteam dbname=openteam port=5432 sslmode=disable
# JWT(生产必须更换为随机长字符串)
OT_JWT_SECRET=change-me-to-a-long-random-string
OT_JWT_ACCESS_TTL=2h
OT_JWT_REFRESH_TTL=168h
OT_JWT_COOKIE_SECURE=false
# 注册策略:open(开放)| invite(邀请码)
OT_AUTH_REGISTRATION_MODE=open
# 渠道密钥加密主密钥(生产必须设置,32 字节以上)
OT_MASTER_KEY=change-me-master-key
# 默认上游渠道(首次启动自动创建 OpenAI 渠道)
OT_PROXY_UPSTREAM_KEY=
OT_PROXY_UPSTREAM_BASE_URL=https://api.openai.com
OT_PROXY_DEFAULT_MODEL=gpt-4o-mini
OT_PROXY_TIMEOUT=120s
# 初始管理员(仅首次创建生效)
OT_ADMIN_USERNAME=admin
OT_ADMIN_EMAIL=admin@localhost
OT_ADMIN_PASSWORD=admin123
+29
View File
@@ -0,0 +1,29 @@
# 依赖与产物
node_modules/
dist/
*.local
.DS_Store
# Go
server/bin/
server/data/
# 环境与密钥
.env
.env.*
!.env.example
# 数据库
*.db
*.db-shm
*.db-wal
# 日志与临时
*.log
/tmp/
nohup.out
# 编辑器
.idea/
.vscode/
*.swp
+24
View File
@@ -0,0 +1,24 @@
.PHONY: run build test mock-upstream tidy
run:
cd server && go run ./cmd/server
build:
cd server && go build -o bin/openteam ./cmd/server
test:
cd server && go test ./...
tidy:
cd server && go mod tidy
# 本地 mock OpenAI 上游(联调用,无需真实 key)
mock-upstream:
cd scripts && go run ./mockupstream
# 前端
web-dev:
cd web && pnpm dev
web-build:
cd web && pnpm build
+538
View File
@@ -0,0 +1,538 @@
# 大模型中转站(OpenRouter-like)规划文档
> 版本:v0.2 · 2026-08-15 · 状态:规划(未开始编码)
>
> 本文档是项目蓝图,覆盖技术选型、系统架构、核心功能、数据模型、API 设计、前端设计方向与开发里程碑。编码开始前,本文档应与团队确认一遍。
---
## 1. 项目概述
### 1.1 定位
一个自托管的 **LLM API 中转网关**,功能对标 OpenRouter / one-api:
- 对下游用户暴露**统一的、OpenAI 兼容**的 API 入口,背后接入多个上游渠道(OpenAI、Anthropic、兼容第三方等)。
- 对外提供三套协议入口:**OpenAI Responses API、OpenAI Chat Completions、Anthropic Messages**,覆盖两大生态的 SDK 与客户端。
- 内置用户体系、API Key 管理、用量统计与计费(充值暂缓,见 §4.5)。
### 1.2 核心价值
| 对用户 | 对管理员 |
| --- | --- |
| 一个 Key 访问多家模型,OpenAI/Anthropic 生态格式互通 | 统一管理多个上游渠道,做模型定价 |
| 查询用量、成本明细 | 管理用户、审核充值、看全局营收 |
| 配额/余额控制 | 渠道健康检查、负载均衡、故障转移 |
### 1.3 对外协议(明确范围)
| 端点 | 协议 | 说明 |
| --- | --- | --- |
| `POST /v1/responses` | OpenAI Responses API | OpenAI 新生态(SDK/Agents)首选,含流式、工具调用、结构化输出 |
| `POST /v1/chat/completions` | OpenAI Chat Completions | 兼容面最广的格式,含流式(SSE)与工具调用 |
| `POST /v1/messages` | Anthropic Messages | Claude 生态原生格式,含流式与工具调用 |
| `GET /v1/models` | OpenAI 风格模型列表 | 对外列出可用模型;也用于渠道侧自动导入模型列表 |
> 三套协议之间可**互相转换**:例如客户端按 Responses 调用 `claude-sonnet-5`,网关会转换成 Anthropic 协议打给 Anthropic 渠道,再以 Responses 流式返回;同理可反向。协议与渠道匹配时走**直通**(见 4.1.3)。
---
## 1.4 首版范围(MVP)
| 纳入 | 暂缓 |
| --- | --- |
| 大模型代理(三套协议 + 渠道 + 转换) | 充值(**暂停**,见 §4.5) |
| 用户管理(注册/登录/角色/API Key) | 邀请码(配置开关预留) |
| 用量计费(token 统计、计价、余额、流水) | 在线支付、审计报表、多实例 |
| 渠道管理(API 类型、模型导入、健康检查) | 组织/团队(多租户) |
| 管理后台(用户/渠道/模型/全局用量) | |
## 2. 技术选型
### 2.1 后端(Go)
| 项 | 选型 | 理由 |
| --- | --- | --- |
| 语言/运行时 | Go 1.23+ | 高并发流式转发、低内存、部署为单二进制 |
| Web 框架 | Gin | 生态成熟,中间件丰富;代理层可用标准库 `net/http` 做流式读写 |
| ORM | GORM | 简单、迁移工具内建;后期可换 sqlc |
| 数据库 | PostgreSQL 15+(开发可 SQLite 起步) | JSON/数组字段、数值精度对计费友好;单一存储,无额外部署 |
| 缓存/限流 | Redis 7 | token bucket 限流、热点数据、分布式计数器 |
| 认证 | JWT(访问令牌)+ 刷新令牌 HttpOnly Cookie | 见 4.3 |
| 密码 | argon2id | 现代 KDF |
| 配置 | viper + `.env` | 密钥进环境变量,不进代码库 |
| 日志 | zap | 结构化日志,含请求 trace |
| 上游密钥加密 | AES-GCM(主密钥来自环境变量) | 渠道 key 落库前加密 |
### 2.2 前端(Vue 3)
| 项 | 选型 | 理由 |
| --- | --- | --- |
| 框架 | Vue 3 + TypeScript + Vite | 团队栈、构建快 |
| 状态 | Pinia | 官方推荐 |
| 路由 | Vue Router | 标准 |
| 样式 | Tailwind CSS + **taste-skill 产出的设计 tokens** | 自建设计系统,避免套模板 |
| 组件基座 | Ark UI(headless)+ 自建基础组件 | 无头组件可控性强,符合 taste-skill 的 anti-slop 取向 |
| 图表 | ECharts(vue-echarts) | 用量/营收图表 |
| HTTP | axios + TanStack Query | 缓存、重试、请求状态管理 |
> 说明:不选用 Element Plus 这类"完整模板感"较重的库,管理后台的表格/表单由自建组件提供,视觉由 taste-skill 统一定调。
### 2.3 部署
- Docker Compose 起步:`nginx`(静态资源 + 反代) + `api`(Go) + `postgres` + `redis`。
- 单实例起步(记账时序简单),需要时再做多实例(见 §9 风险)。
---
## 3. 系统架构
### 3.1 模块划分
```
┌─────────────────────────────────────────────────────────────┐
│ 前端 web (Vue3) │
│ Landing / 登录注册 / 控制台(密钥·用量·充值) / 管理后台 │
└──────────────────────────┬──────────────────────────────────┘
│ HTTP/JSON(管理 API)
┌──────────────────────────▼──────────────────────────────────┐
│ Go API 服务 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────────────┐ │
│ │ 认证/用户 │ │ API Key │ │ 用量/计费 │ │ 充值(暂停) │ │
│ └──────────┘ └──────────┘ └──────────┘ └────────────────┘ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ API 网关(代理核心) │ │
│ │ Auth/Ratelimit → 余额检查 → 模型解析 → 渠道选择 │ │
│ │ → 格式转换 → 上游调用 → 流式转发 → 记账 │ │
│ └────────────────────────────────────────────────────────┘ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │
│ │ 渠道管理 │ │ 负载均衡 │ │ 健康检查 │ │ 重试/故障转移 │ │
│ └──────────┘ └──────────┘ └──────────┘ └───────────────┘ │
└──────────┬──────────────────────────────┬───────────────────┘
│ │
┌───────▼────────┐ ┌────────▼────────┐
│ PostgreSQL │ │ Redis │
│ 用户/密钥/渠道/ │ │ 限流/配额/热点 │
│ 模型/用量/订单 │ │ │
└────────────────┘ └─────────────────┘
```
### 3.2 一次代理请求的完整链路
```
Client Go 网关 上游渠道(如 Anthropic)
│ POST /v1/chat/completions (sk-xxx) │
├──────────────────────────────▶│ │
│ │ 1. 取 Bearer,哈希查 API Key │
│ │ 2. 限流检查(Redis) │
│ │ 3. 余额检查 │
│ │ 4. 解析请求 → 标准中间模型 │
│ │ 5. 按模型选渠道(LB/健康) │
│ │ 6. 转换请求为 Claude 格式 │
│ ├───────────────────────────▶ │
│ │ ◀────────────────────────── │
│ │ 7. 流式/非流式转发+格式转回 │
│ ◀──────────────────────────────│ │
│ │ 8. 异步记账(token→价格→扣费)│
```
关键点:
- **记账是异步的**:请求完成后写入 `usage_logs`,批量落库,不阻塞响应。
- **流式响应不整体缓冲**:用 `io.Pipe` 边读上游边写客户端;Token 计数取流结束时的 usage 字段(OpenAI 末块 / Claude `message_delta`)。
- **只转发请求体与必要头**:`Authorization` 一律替换为渠道 key,不向客户端暴露上游信息。
---
## 4. 核心功能设计
### 4.1 API 网关 / 格式转换
#### 4.1.1 内部统一格式(标准模型)
网关内部使用 **OpenAI Chat Completions 形状** 作为"标准中间模型",三套协议都先转成它、再转成目标协议:
```
OpenAI /v1/responses ──┐
OpenAI /v1/chat/completions ─┼──▶ 标准模型(OpenAI chat 形状) ──▶ 各渠道原生格式
Anthropic /v1/messages ──┘
```
这样新增一种上游渠道(如 Gemini)只需写**一对**转换器(标准模型 ↔ 渠道格式),不用为每个协议组合写转换器;Responses 与 Chat、Messages 与 Chat 之间各维护一个转换适配器。
#### 4.1.2 转换映射要点
**Chat ↔ Claude Messages**
| 维度 | OpenAI chat ↔ Claude messages |
| --- | --- |
| system | OpenAI `messages[role=system]` ↔ Claude `system` 参数(支持数组/文本) |
| 消息 | `assistant.tool_calls` ↔ `content` 中的 `tool_use` 块;`role=tool` ↔ `tool_result` 块 |
| 工具 | `tools[{type:function,name,description,parameters}]` ↔ `tools[{name,description,input_schema}]` |
| 采样 | `temperature`(OpenAI 0–2,Claude 0–1,越界按目标协议 clamp)、`top_p` |
| 长度 | `max_tokens` ↔ `max_tokens`(Claude 必填,缺失时给默认值) |
| 停止 | `stop` ↔ `stop_sequences`(数组对齐) |
| 流式 | OpenAI SSE `data: {chunk}` + `[DONE]` ↔ Claude 事件流 `message_start / content_block_delta / message_delta / message_stop`,逐事件互转 |
| 用量 | `usage.prompt_tokens/completion_tokens` ↔ `usage.input_tokens/output_tokens`,并映射 Claude 的缓存 token |
**Responses ↔ Chat**(Responses 结构化能力更多,映射如下)
| Responses 字段 | Chat 对应 |
| --- | --- |
| `instructions` | `messages[0].system` |
| `input[]`(`input_text` / `input_image` / `input_file`) | `messages[]`(user 多模态 content 数组) |
| `input[]` 中 `function_call` / `function_call_output` 条目 | `assistant.tool_calls` / `role=tool` 消息 |
| `output[]` 中 `message` / `function_call` / `reasoning` 条目 | `choices[].message` / `tool_calls`(reasoning 跨协议丢弃) |
| `tools[{type:function,name,description,parameters}]` | `tools[{type:function,...}]`(结构相同) |
| `output_format` / `text.format` | `response_format` |
| `max_output_tokens` | `max_tokens` |
| `previous_response_id` | 仅直通 OpenAI 渠道可用;跨协议时降级(见 4.1.3) |
| `reasoning.effort` | 仅直通或特定渠道,跨协议丢弃 |
| 流式事件 `response.created / output_text.delta / function_call_arguments.delta / response.completed` | chat SSE `data: {delta}` + `[DONE]`,逐事件互转 |
#### 4.1.3 直通(passthrough)与转换策略
- **直通优先**:客户端协议与渠道原生协议一致时直接透传报文(仅做鉴权/限额/记账),**不转格式**——保证 Responses 的 `previous_response_id`、`reasoning`、结构化输出等新能力在 OpenAI 渠道上零损失。
- **转换路径**:协议不匹配时才经标准模型转换(如 Responses → Anthropic 渠道、Messages → OpenAI 渠道)。
- **有损边界(文档明示)**:
- `previous_response_id`、`reasoning.effort` 跨协议时**降级或丢弃**,错误响应中提示。
- Anthropic 渠道不接收 `response_format` 类结构化约束,降级为 prompt 提示或丢弃。
- Claude 的 thinking 块在 OpenAI 协议侧丢弃(无法表达)。
- **转换标记**:转换过的请求/响应加 `x-converted: true` 头,便于排查。
#### 4.1.4 错误响应统一
无论上游是什么错误,都按**客户端请求的协议**返回错误体:
- OpenAI 格式:`{"error":{"message","type","param","code"}}` + 映射过的 HTTP 状态码。
- Claude 格式:`{"type":"error","error":{"type","message"}}`。
- 状态码映射:上游 `429` → `429`(附 `retry-after`)、上游 `5xx` → 触发重试后返回 `502/504`、`400`(含 context length 超限)→ 原样返回、余额不足 → `402`。
### 4.2 渠道系统
| 能力 | 设计 |
| --- | --- |
| 渠道 CRUD | 管理员增删改:名称、**API 类型**(openai / anthropic / compatible)、base_url、上游 key(AES-GCM 加密存储)、超时、并发上限 |
| API 类型选择 | 新增渠道时选择类型,决定支持的原生协议(决定直通还是转换)与模型列表导入方式 |
| 模型列表导入 | 渠道支持 `GET /v1/models` 时提供"拉取模型列表"按钮,自动导入可用模型到全局模型库并生成绑定;不支持该端点的渠道(部分第三方)可手动录入 |
| 模型绑定 | 模型(全局) ↔ 渠道(多个) 多对多,每个绑定记录 `upstream_model` 名、权重/优先级 |
| 负载均衡 | 按权重 + 优先级 + 健康状态选择渠道;健康渠道优先 |
| 健康检查 | 定时用最廉价模型发一次测试请求(非流式),连续失败 N 次进入 cooldown,恢复后再放回 |
| 重试/故障转移 | 仅对"可安全重试"的失败(网络错误、429、5xx、超时、上游连接断开**且尚未写出响应头**);对 400/context 类错误不重试。流式一旦已向客户端写出首字节,放弃重试 |
| 并发控制 | 每渠道信号量限制最大并发,超限排队或溢出到其他渠道 |
### 4.3 认证与用户
#### 4.3.1 角色
| 角色 | 权限 |
| --- | --- |
| `admin` | 全部;渠道管理、模型定价、用户管理、余额调整、充值审核、全局用量 |
| `user` | 创建/管理自己的 API Key、查询用量、充值、查看余额 |
> 预留 `viewer`(只读运营)角色,首版不做。
#### 4.3.2 会话
- 登录:用户名/邮箱 + 密码(argon2id 校验)。
- 颁发:短时访问令牌(JWT,如 2h,存内存)+ 刷新令牌(存 HttpOnly Cookie,7d)。
- 注册:**开放注册,可切换**——默认 `open`,配置项 `registration.mode` 切到 `invite` 即启用邀请码(管理员后台生成)。
#### 4.3.3 API Key
- 生成格式:`sk-` + 48 位随机字符(base62),**创建时仅展示一次**。
- 存储:库中只存 SHA-256 哈希 + 展示用前缀(如 `sk-aB3c…`);请求时对 Bearer 哈希后查表。
- 附加能力:密钥级配额(每日 token 上限 / 每日请求数上限)、模型白名单、过期时间、启停。
- 限额检查用 Redis 计数,与用户级限流叠加。
### 4.4 用量与计费
#### 4.4.1 Token 统计
- 优先取上游响应中的 usage(OpenAI `usage` 字段、Claude `message_delta.usage`)。
- 上游缺失时 fallback:本地近似计数(按字符/字节估算,或引入 tiktoken-go 按模型分词)。
- Claude 渠道额外记录 `cache_read_input_tokens` / `cache_creation_input_tokens`,用于缓存计费。
#### 4.4.2 计价
- 模型注册表(`models`)中每个模型配置:`input_price`、`output_price`、`cache_read_price`(按 **每百万 token**)。
- 单次成本 = `in×in_price + out×out_price + cache_read×cache_read_price`(统一以 USD 记账,前端按配置汇率显示)。
- 管理员可随时调价,历史用量按**当时价格**入账(用量表冗余快照价格字段)。
#### 4.4.3 余额与扣费
- 预充值余额制:每次请求结束后异步扣费并写余额流水(`balance_logs`)。
- 扣费前先检查:余额 ≤ 0 时新请求返回 `402`。可选开关:按模型估算成本超余额即拦截(防止大单超额)。
- 余额为负不拒绝已进行的流式请求(流中途无法中断),但后续请求被拒。
#### 4.4.4 用量查询
- `usage_logs`:请求级明细(用户、密钥、模型、渠道、token、成本、耗时、状态)。
- 聚合:日粒度预聚合表(`usage_daily`)支撑 Dashboard 图表,避免每次实时扫明细表。
### 4.5 充值(暂停开发)
> 已决定:**充值暂缓**,首版不做,先交付"代理 + 用户 + 计费"。方案(人工审核 / 在线支付)确定后再落地。
> 预留:`recharge_orders` / `balance_logs` 数据模型与订单状态机先行建好,后续接入不影响现有结构。
订单状态机(预留):
```
pending(待审核) ──approve──▶ credited(已入账)
│ │
└──reject──▶ rejected └─(错误入账→adjust 冲正)
```
### 4.6 管理后台
- 渠道管理:增删改、模型绑定、手动测试连接、健康状态查看。
- 模型管理:全局模型清单、多渠道绑定、价格设置、启停。
- 用户管理:列表/搜索、改角色/状态、调整余额、重置密码。
- 充值审核:待审订单列表、通过/驳回、流水留痕。
- 全局用量:跨用户查询、按模型/渠道/天聚合、营收统计。
- 系统配置:开放注册、邀请码、汇率、限流阈值、维护开关。
---
## 5. 数据模型
> 统一 `id` 为 bigint 自增(或 snowflake),时间用 UTC,金额/价格用 `numeric(20,8)`,token 用 `bigint`。
### 5.1 users
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| id | bigint PK | |
| username / email | text UNIQUE | |
| password_hash | text | argon2id |
| role | enum(`user`,`admin`) | |
| balance | numeric(20,8) | 余额 |
| status | enum(`active`,`disabled`) | |
| invite_code | text nullable | 注册来源邀请码 |
| last_login_at / created_at / updated_at | timestamptz | |
### 5.2 api_keys
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| id | bigint PK | |
| user_id | bigint FK | |
| name | text | 展示名 |
| key_hash | text UNIQUE | SHA-256 |
| key_prefix | text | `sk-aB3c…` |
| quota_tokens_per_day | bigint nullable | 密钥级限额 |
| quota_requests_per_day | int nullable | |
| allowed_models | jsonb nullable | 模型白名单 |
| expires_at | timestamptz nullable | |
| status | enum(`active`,`revoked`) | |
| last_used_at | timestamptz nullable | |
| created_at | timestamptz | |
### 5.3 channels
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| id | bigint PK | |
| name | text | |
| provider | enum(`openai`,`anthropic`,`compatible`) | API 类型:决定原生协议与模型导入方式 |
| base_url | text | 上游地址 |
| api_key_enc | text | AES-GCM 密文 |
| weight / priority | int | 负载均衡权重 / 优先级 |
| timeout_ms | int | |
| max_concurrency | int | |
| health_status | enum(`healthy`,`degraded`,`cooldown`) | |
| enabled | bool | |
| created_at / updated_at | timestamptz | |
### 5.4 models + channel_model_bindings
`models`(全局模型 + 价格):
| 字段 | 类型 |
| --- | --- |
| id, name(全局名如 `claude-sonnet-5`), display_name | |
| input_price / output_price / cache_read_price | numeric(20,8)(每百万 token) |
| enabled, sort | |
`channel_model_bindings`(多对多):
| 字段 | 类型 |
| --- | --- |
| id, channel_id FK, model_id FK | |
| upstream_model | text(如 `us.anthropic.com:claude-sonnet-5`) |
| weight | int |
### 5.5 usage_logs(请求级明细)
| 字段 | 类型 |
| --- | --- |
| id, request_id(上游 id), user_id FK, key_id FK, channel_id FK, model_id FK | |
| input_tokens / output_tokens / cache_read_tokens / cache_creation_tokens | bigint |
| input_price / output_price / cache_read_price | numeric(20,8) 快照 |
| cost | numeric(20,8) |
| latency_ms | int |
| status | enum(`success`,`error`,`canceled`) |
| error_code | text nullable |
| created_at | timestamptz,带索引 `(user_id, created_at)` |
### 5.6 usage_daily(日聚合)
`id, user_id, model_id, date, requests, input_tokens, output_tokens, cache_read_tokens, cost`
### 5.7 recharge_orders
`id, user_id FK, amount numeric(20,8), status enum(pending/credited/rejected), method enum(manual/online), transaction_id, reviewed_by FK, reviewed_at, remark, created_at`
### 5.8 balance_logs(余额流水,幂等保证)
`id, user_id FK, change numeric(20,8), balance_after numeric(20,8), type enum(recharge/usage/refund/admin_adjust), ref_id, created_at`
### 5.9 system_configs
`key text PK, value jsonb`
---
## 6. API 设计
### 6.1 代理端点(对外,Bearer API Key 认证)
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| POST | `/v1/responses` | OpenAI Responses API |
| POST | `/v1/chat/completions` | OpenAI Chat 格式 |
| POST | `/v1/messages` | Anthropic Messages |
| GET | `/v1/models` | 可用模型列表(OpenAI 风格) |
### 6.2 管理 API(`/api/v1`,会话认证)
**认证/用户**
- `POST auth/register` · `POST auth/login` · `POST auth/logout` · `GET auth/me`
- `GET user/profile` · `GET user/balance`
**API Key**
- `GET/POST /keys` · `PATCH/DELETE /keys/:id`
**用量**
- `GET /usage/summary`(今日/本月汇总)
- `GET /usage/stats?from&to&group=day|model`
- `GET /usage/logs?from&to&page&model&keyId`
**充值**
- `POST /recharges` · `GET /recharges`(暂停,接口预留)
**管理后台(`/api/v1/admin`,仅 admin)**
- `GET/POST/PUT/DELETE /channels` · `POST /channels/:id/test`
- `GET/POST/PUT /models` · `PUT /models/:id/price`
- `GET /users` · `PATCH /users/:id` · `POST /users/:id/balance`
- `GET /recharges` · `POST /recharges/:id/approve|reject`(暂停,接口预留)
- `GET /usage` · `GET /stats/overview`
- `GET/PUT /config`
---
## 7. 前端设计(taste-skill)
### 7.1 设计流程
1. 前端阶段启动时**调用 taste-skill**:传入产品 brief 与页面清单,由它推断设计方向,产出:
- 设计 tokens:色板(含暗色/亮色)、字体系统、间距、圆角、阴影、栅格。
- 核心页面高保真方向(先做 3–5 个代表页,不一次铺开)。
2. 以 tokens 建立 Tailwind 主题(tailwind.config + CSS variables)与基础组件(Button / Table / Form / Modal / Nav)。
3. 按页面清单逐组实现,每个阶段结束用 **web-design-guidelines** 复查(对比度、可访问性、交互细节)。
4. 设计评审迭代,**不套模板**。
### 7.2 预期设计方向(taste-skill 最终决定,此处为倾向)
开发者工具 / API 网关类产品,倾向:
- 深色优先、仪表盘质感;等宽字体点缀 token、端点、代码片段。
- 数据密度高的表格(用量、密钥、订单),克制的中性色 + 单一强调色。
- Landing 简洁可信:产品价值、端点示例、模型列表预览。
### 7.3 页面清单
| 区域 | 页面 |
| --- | --- |
| 公开 | Landing · 登录 · 注册 |
| 用户 | Dashboard(余额/今日用量/最近请求/图表)· API Keys · 用量查询 · 充值(后置) · 个人设置 |
| 管理 | 运营总览 · 渠道管理(API 类型 + 模型导入) · 模型与定价 · 用户管理 · 充值审核(后置) · 全局用量 · 系统配置 |
### 7.4 前端工程注意点
- 流式调试体验:控制台页提供"用 curl / 请求编辑器"快速验证 key 与模型(可选,v2)。
- 图表统一用 ECharts,暗色主题与设计 tokens 对齐。
- 表格/表单为自建组件,行为一致性优先,后续可沉淀为内部组件库。
---
## 8. 非功能需求
| 类别 | 要求 |
| --- | --- |
| 安全 | API Key 仅存哈希;渠道密钥加密存储;密码 argon2id;JWT 刷新令牌 HttpOnly;日志/错误信息脱敏(不泄露渠道 key、完整 key);CORS 白名单;管理接口二次鉴权 |
| 限流 | Redis token bucket:用户级 + 密钥级 + 全局并发保护 |
| 稳定性 | 渠道 cooldown + 重试;流式请求 client 断连即取消上游调用(ctx cancel);上游超时兜底 |
| 可观测 | zap 结构化日志 + request_id;Prometheus 指标(请求数/延迟/错误率/成本);管理后台健康概览 |
| 性能 | 流式零缓冲转发;记账异步批量落库;聚合查询走预聚合表 |
| 合规 | 用户协议与数据留存说明;退款/冲正流程可追溯 |
---
## 9. 仓库结构(规划)
```
openteam/
├── server/ # Go 后端
│ ├── cmd/server/main.go
│ ├── internal/
│ │ ├── config/ # viper + env
│ │ ├── user/ # 认证/角色/用户
│ │ ├── apikey/
│ │ ├── proxy/ # 网关核心
│ │ │ ├── responses/ # OpenAI Responses 格式编解码
│ │ │ ├── openai/ # OpenAI Chat 格式编解码
│ │ │ ├── claude/ # Anthropic Messages 格式编解码
│ │ │ ├── convert/ # 标准模型 ↔ 各协议转换
│ │ │ └── stream/ # SSE 双向流式转发
│ │ ├── channel/ # 渠道、负载均衡、健康检查、重试
│ │ ├── billing/ # 计价、余额、流水
│ │ ├── usage/ # 记账、聚合
│ │ ├── recharge/ # 订单(待定方案)
│ │ ├── admin/ # 管理 API
│ │ ├── store/ # GORM models + repositories
│ │ └── pkg/ # jwt, crypto, ratelimit, tiktoken
├── web/ # Vue3 前端
│ ├── src/styles/ # taste-skill 设计 tokens
│ ├── src/components/ # 基础组件
│ ├── src/views/ # 页面
│ ├── src/stores/ · src/api/ · src/router/
├── deploy/ # docker-compose, nginx, Dockerfile
└── docs/
```
---
## 10. 开发里程碑
| 里程碑 | 内容 | 验收标准 |
| --- | --- | --- |
| **M0 基建** | 仓库结构、配置、DB 迁移、JWT/密码工具、CI;taste-skill 启动产出设计 tokens | 空服务可启动,tokens 落地 |
| **M1 用户+密钥+核心代理** | 注册/登录/角色、API Key CRUD;`/v1/responses` 与 `/v1/chat/completions` 直连 OpenAI 渠道(非流式+流式,直通);OpenAI 错误格式 | 用 curl 完成一次带流式的对话与一次 Responses 调用;密钥可建可吊销 |
| **M2 用量+计费** | 记账、token 统计、价格表、余额扣减、用量 API;前端 Dashboard/用量页 | 请求后余额正确变化,用量图表正确 |
| **M3 跨协议转换** | `/v1/messages` 代理;Responses ↔ Chat ↔ Messages 转换(含流式、工具调用);Anthropic 渠道 | OpenAI 客户端调 Claude 模型、Anthropic 客户端调 OpenAI 模型均通 |
| **M4 渠道系统** | 渠道 CRUD(API 类型)、`/models` 模型导入、模型绑定、负载均衡、健康检查、重试/故障转移;管理后台渠道页 | 一个渠道挂掉自动切换;后台可加渠道、拉取模型并测试 |
| **M5 充值(暂停)** | 方案待定,首版不做;仅预留订单表与状态机 | — |
| **M6 打磨上线** | 限流、监控指标、审计日志、taste-skill 全站设计复查、docker-compose 部署、文档与测试补全 | 可对外交付部署 |
---
## 11. 风险与待定决策
| # | 事项 | 状态 | 说明 |
| --- | --- | --- | --- |
| 1 | **充值** | ⏸ 暂停 | 首版不做,订单表与状态机预留,方案确定后再落地 |
| 2 | **注册策略** | ✅ 已确认 | 开放注册,配置可切换邀请码 |
| 3 | **协议范围** | ✅ 已确认 | Responses + Chat Completions + Anthropic Messages;去掉 legacy `/v1/completions` |
| 4 | **流式重试边界**:首字节发出后不可重试 | ✅ 已决策 | 仅连接建立前重试;文档明示 |
| 5 | **Responses 跨协议有损边界** | 🟡 实现中确认 | `previous_response_id`、`reasoning` 跨协议降级/丢弃,加 `x-converted` 头 |
| 6 | **模型列表导入差异** | 🟡 实现中确认 | 部分渠道无 `/v1/models`,需手动录入 fallback |
| 7 | **计费精度**:Claude 缓存 token、上游缺 usage | ✅ 已决策 | 冗余快照价格;近似计数 fallback |
| 8 | **多实例扩展**:记账时序 | 🟡 后置 | 单实例起步,必要时引入消息队列 |
| 9 | **合规**:数据留存、日志脱敏 | 🟡 上线前 | 隐私说明、审计日志 |
---
## 12. 下一步
范围已收敛:**代理(三协议)+ 用户管理 + 用量计费 + 渠道管理**,充值暂停。
1. 无阻塞性待定项,可按 **M0 → M1** 开始实施;taste-skill 先行产出设计方向,后端同时搭骨架。
2. 实施中确认两处细节:Responses 跨协议降级边界(§11 #5)、模型导入的渠道差异(#6)。
+90
View File
@@ -0,0 +1,90 @@
# openteam · 大模型中转站
自托管的 LLM API 中转网关,功能对标 OpenRouter / one-api:统一 OpenAI 与 Anthropic 协议入口,背后对接多个上游渠道,内置用户体系、API Key 管理与用量计费。
> 规划文档见 [PLANNING.md](./PLANNING.md)。当前进度:**M0(基建)+ M1(用户+密钥+核心代理)已完成**。
## 功能(当前)
- **代理端点**(Bearer API Key)
- `POST /v1/chat/completions` — OpenAI Chat(非流式 + 流式 SSE)
- `POST /v1/responses` — OpenAI Responses API(非流式 + 流式事件)
- `GET /v1/models` — 可用模型列表
- 错误统一为 OpenAI 格式(401/402/404/429/502…)
- **用户体系**:注册(开放/邀请码可切换)、登录(JWT access + HttpOnly refresh cookie)、argon2id 密码
- **API Key**:`sk-` 48 位 base62,仅存 SHA-256 哈希,明文一次性展示;支持限额/过期/白名单字段
- **用量计费**:请求级 `usage_logs` 异步批量落库,按模型价格扣减余额,日粒度预聚合(`usage_daily`)
- **管理 API**:用户列表/角色/状态/余额调整、系统配置
- **前端**:Landing / 登录注册 / 控制台(仪表盘 + 密钥管理 + 用量明细)
## 快速开始(开发)
前置:Go 1.23+、Node 20+、pnpm。
```bash
# 1. 配置(复制并修改,至少设置上游 key)
cp .env.example .env
# 2. 启动后端(SQLite 起步,无需数据库)
cd server && go run ./cmd/server
# 默认管理员 admin / admin123(生产务必修改)
# 3. 启动前端
cd web && pnpm i && pnpm dev # http://localhost:5173
# 4. 用 mock 上游联调(无需真实 key)
make mock-upstream # :9000 起一个模拟 OpenAI 服务
```
### 冒烟测试(curl)
```bash
# 注册 → 登录 → 建 key
curl -s -X POST localhost:8080/api/v1/auth/register -H 'Content-Type: application/json' \
-d '{"username":"alice","email":"a@b.com","password":"password123"}'
TOKEN=$(curl -s -X POST localhost:8080/api/v1/auth/login -H 'Content-Type: application/json' \
-d '{"username":"alice","password":"password123"}' | jq -r .data.access_token)
KEY=$(curl -s -X POST localhost:8080/api/v1/keys -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"name":"dev"}' | jq -r .data.key)
# 对话(非流式 + 流式)
curl -s localhost:8080/v1/chat/completions -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}'
curl -sN localhost:8080/v1/chat/completions -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' -d '{"model":"gpt-4o-mini","stream":true,"messages":[{"role":"user","content":"hi"}]}'
```
## 生产部署
```bash
cp .env.example .env # 填写 JWT_SECRET / MASTER_KEY / 上游 key
docker compose -f deploy/docker-compose.yml up -d --build
```
`nginx` 托管前端静态资源并反代 `/api` 与 `/v1`(SSE 关闭缓冲)。
## 仓库结构
```
server/ Go 后端(cmd + internal/{api,proxy,channel,usage,store,pkg})
web/ Vue 3 前端(Vite + Tailwind v4 + Pinia + ECharts)
deploy/ docker-compose / Dockerfile / nginx
scripts/ mock 上游(联调用)
docs/ 文档
```
## 设计系统(taste-skill)
深色优先的开发者控制台:石墨墨底 + 暖白文本 + 单一信号铜色强调(信号灯意象);UI 字体 Outfit,数据一律 JetBrains Mono(tabular numerals)。tokens 定义于 `web/src/style.css`(`@theme`),支持 `data-theme="light"` 切换。
## 路线图
| 里程碑 | 状态 |
| --- | --- |
| M0 基建(结构/配置/DB/工具/CI 前身) | ✅ |
| M1 用户 + 密钥 + 核心代理(chat/responses 直通) | ✅ |
| M2 用量 + 计费(记账/价格/前端图表) | 🟡 后端已备,前端图表就绪 |
| M3 跨协议转换(/v1/messages、Responses↔Chat↔Messages) | ⏳ |
| M4 渠道系统(导入/绑定/LB/健康检查/重试) | ⏳ |
| M5 充值(暂停,表结构已预留) | ⏸ |
| M6 打磨上线(限流/监控/审计/全站复查) | ⏳ |
+15
View File
@@ -0,0 +1,15 @@
# API 镜像:多阶段构建 Go 二进制
FROM golang:1.26-alpine AS builder
WORKDIR /src
COPY server/go.mod server/go.sum ./
RUN go mod download
COPY server/ .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/openteam ./cmd/server
FROM alpine:3.21
RUN adduser -D -u 10001 app
USER app
WORKDIR /app
COPY --from=builder /out/openteam /app/openteam
EXPOSE 8080
ENTRYPOINT ["/app/openteam"]
+12
View File
@@ -0,0 +1,12 @@
# 前端镜像:构建静态资源 + nginx
FROM node:24-alpine AS builder
WORKDIR /src
COPY web/package.json web/pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY web/ .
RUN pnpm build
FROM nginx:1.27-alpine
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /src/dist /usr/share/nginx/html
EXPOSE 80
+54
View File
@@ -0,0 +1,54 @@
# ===== openteam 单实例部署(nginx + api + postgres)=====
# 使用:cp .env.example ../.env 并修改密钥,然后 docker compose up -d
services:
api:
build:
context: ..
dockerfile: deploy/Dockerfile.api
restart: unless-stopped
environment:
OT_ENV: production
OT_PORT: "8080"
OT_DB_DRIVER: postgres
OT_DB_DSN: host=postgres user=openteam password=${OT_DB_PASSWORD:-openteam} dbname=openteam port=5432 sslmode=disable
OT_JWT_SECRET: ${OT_JWT_SECRET:?set in .env}
OT_MASTER_KEY: ${OT_MASTER_KEY:?set in .env}
OT_ADMIN_PASSWORD: ${OT_ADMIN_PASSWORD:-admin123}
OT_PROXY_UPSTREAM_KEY: ${OT_PROXY_UPSTREAM_KEY:-}
OT_PROXY_UPSTREAM_BASE_URL: ${OT_PROXY_UPSTREAM_BASE_URL:-https://api.openai.com}
OT_PROXY_DEFAULT_MODEL: ${OT_PROXY_DEFAULT_MODEL:-gpt-4o-mini}
depends_on:
postgres:
condition: service_healthy
# 预留 Redis(M6 限流);当前单实例不依赖
# redis:
# image: redis:7-alpine
# restart: unless-stopped
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: openteam
POSTGRES_PASSWORD: ${OT_DB_PASSWORD:-openteam}
POSTGRES_DB: openteam
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U openteam"]
interval: 5s
timeout: 3s
retries: 10
web:
build:
context: ..
dockerfile: deploy/Dockerfile.web
restart: unless-stopped
ports:
- "${OT_HTTP_PORT:-80}:80"
depends_on:
- api
volumes:
pgdata:
+42
View File
@@ -0,0 +1,42 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SPA 路由回退
location / {
try_files $uri $uri/ /index.html;
}
# 管理 API + 代理端点 → Go 服务
location /api/ {
proxy_pass http://api:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /v1/ {
proxy_pass http://api:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# SSE 流式透传:关闭缓冲
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s;
}
# 静态资源缓存
location /assets/ {
expires 30d;
add_header Cache-Control "public, immutable";
}
gzip on;
gzip_types text/plain text/css application/json application/javascript image/svg+xml;
}
+3
View File
@@ -0,0 +1,3 @@
module mockupstream
go 1.26.5
+140
View File
@@ -0,0 +1,140 @@
// mockupstream 本地 mock OpenAI 上游服务(联调代理链路,无需真实 key)。
// 支持 /v1/chat/completions 与 /v1/responses,含流式与非流式。
package main
import (
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net/http"
"time"
)
func main() {
addr := flag.String("addr", ":9000", "listen address")
flag.Parse()
http.HandleFunc("/v1/chat/completions", func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req struct {
Model string `json:"model"`
Stream bool `json:"stream"`
}
_ = json.Unmarshal(body, &req)
if req.Stream {
streamChat(w, req.Model)
return
}
replyChat(w, req.Model)
})
http.HandleFunc("/v1/responses", func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req struct {
Model string `json:"model"`
Stream bool `json:"stream"`
}
_ = json.Unmarshal(body, &req)
if req.Stream {
streamResponses(w, req.Model)
return
}
replyResponses(w, req.Model)
})
http.HandleFunc("/v1/models", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"object":"list","data":[{"id":"gpt-4o-mini","object":"model"},{"id":"gpt-4o","object":"model"}]}`)
})
log.Printf("mock upstream listening on %s", *addr)
log.Fatal(http.ListenAndServe(*addr, nil))
}
func replyChat(w http.ResponseWriter, model string) {
w.Header().Set("Content-Type", "application/json")
resp := map[string]any{
"id": "chatcmpl-mock123",
"object": "chat.completion",
"model": model,
"choices": []any{map[string]any{
"index": 0,
"message": map[string]any{"role": "assistant", "content": "你好,这是 mock 上游的回复。"},
"finish_reason": "stop",
}},
"usage": map[string]any{"prompt_tokens": 12, "completion_tokens": 9, "total_tokens": 21},
}
_ = json.NewEncoder(w).Encode(resp)
}
func streamChat(w http.ResponseWriter, model string) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
fl, _ := w.(http.Flusher)
chunks := []string{"你好", ",这是", " mock ", "流式回复。"}
for i, c := range chunks {
chunk := map[string]any{
"id": "chatcmpl-mock123", "object": "chat.completion.chunk", "model": model,
"choices": []any{map[string]any{
"index": 0,
"delta": map[string]any{"content": c},
"finish_reason": nil,
}},
}
if i == len(chunks)-1 {
chunk["choices"] = []any{map[string]any{
"index": 0, "delta": map[string]any{}, "finish_reason": "stop",
}}
}
b, _ := json.Marshal(chunk)
fmt.Fprintf(w, "data: %s\n\n", b)
fl.Flush()
time.Sleep(50 * time.Millisecond)
}
// usage 末块(include_usage 时返回)
usage := map[string]any{
"id": "chatcmpl-mock123", "object": "chat.completion.chunk", "model": model,
"choices": []any{}, "usage": map[string]any{"prompt_tokens": 12, "completion_tokens": 9, "total_tokens": 21},
}
b, _ := json.Marshal(usage)
fmt.Fprintf(w, "data: %s\n\n", b)
fmt.Fprint(w, "data: [DONE]\n\n")
fl.Flush()
}
func replyResponses(w http.ResponseWriter, model string) {
w.Header().Set("Content-Type", "application/json")
resp := map[string]any{
"id": "resp_mock456",
"object": "response",
"model": model,
"status": "completed",
"output": []any{map[string]any{
"type": "message", "role": "assistant",
"content": []any{map[string]any{"type": "output_text", "text": "这是 Responses API 的 mock 回复。"}},
}},
"usage": map[string]any{"input_tokens": 15, "output_tokens": 11, "total_tokens": 26},
}
_ = json.NewEncoder(w).Encode(resp)
}
func streamResponses(w http.ResponseWriter, model string) {
w.Header().Set("Content-Type", "text/event-stream")
fl, _ := w.(http.Flusher)
events := []map[string]any{
{"type": "response.created", "response": map[string]any{"id": "resp_mock456", "model": model, "object": "response", "status": "in_progress"}},
{"type": "response.output_text.delta", "delta": "流式 Responses 内容", "item_id": "msg_1", "output_index": 0, "content_index": 0},
{"type": "response.completed", "response": map[string]any{
"id": "resp_mock456", "object": "response", "model": model, "status": "completed",
"usage": map[string]any{"input_tokens": 15, "output_tokens": 11, "total_tokens": 26},
}},
}
for _, e := range events {
b, _ := json.Marshal(e)
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", e["type"], b)
fl.Flush()
time.Sleep(50 * time.Millisecond)
}
}
+82
View File
@@ -0,0 +1,82 @@
// openteam 大模型中转站 API 服务入口。
// 启动:OT_PROXY_UPSTREAM_KEY=sk-xxx go run ./cmd/server
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/openteam/server/internal/api"
"github.com/openteam/server/internal/app"
"github.com/openteam/server/internal/config"
"github.com/openteam/server/internal/proxy"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("config: %v", err)
}
a, err := app.New(cfg)
if err != nil {
log.Fatalf("app init: %v", err)
}
defer a.Shutdown(context.Background())
gw := proxy.NewGateway(a.DB, a.Enc, a.Usage)
router := api.NewRouter(a, gw)
srv := &http.Server{
Addr: ":" + itoa(cfg.Port),
Handler: router,
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
log.Printf("openteam listening on %s (env=%s)", srv.Addr, cfg.Env)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server: %v", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("server shutdown: %v", err)
}
a.Usage.Close()
}
func itoa(n int) string {
if n == 0 {
return "8080"
}
neg := n < 0
if neg {
n = -n
}
var b [12]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
b[i] = '-'
}
return string(b[i:])
}
+68
View File
@@ -0,0 +1,68 @@
module github.com/openteam/server
go 1.26.5
require (
github.com/gin-gonic/gin v1.12.0
github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/spf13/viper v1.21.0
golang.org/x/crypto v0.55.0
gorm.io/driver/postgres v1.6.2
gorm.io/gorm v1.31.2
)
require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.10.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
)
+165
View File
@@ -0,0 +1,165 @@
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.2 h1:BvXQ/cNUg63q5TFNg672DmDcowZSFrNLkkA3Xe6GXq4=
gorm.io/driver/postgres v1.6.2/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
+160
View File
@@ -0,0 +1,160 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/openteam/server/internal/pkg/resp"
"github.com/openteam/server/internal/store"
"gorm.io/gorm"
)
// AdminUsers GET /api/v1/admin/users — 用户列表(搜索、分页)。
func (h *Handler) AdminUsers(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
q := h.a.DB.Model(&store.User{})
if kw := c.Query("q"); kw != "" {
q = q.Where("username LIKE ? OR email LIKE ?", "%"+kw+"%", "%"+kw+"%")
}
var total int64
q.Count(&total)
var users []store.User
q.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&users)
out := make([]gin.H, 0, len(users))
for _, u := range users {
out = append(out, h.publicUser(&u))
}
resp.OK(c, gin.H{"items": out, "total": total, "page": page, "page_size": size})
}
// AdminPatchUser PATCH /api/v1/admin/users/:id — 角色/状态/余额。
func (h *Handler) AdminPatchUser(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid user id")
return
}
var req struct {
Role *string `json:"role"`
Status *string `json:"status"`
}
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid input")
return
}
var u store.User
if err := h.a.DB.First(&u, id).Error; err != nil {
resp.Fail(c, http.StatusNotFound, "user not found")
return
}
updates := map[string]any{}
if req.Role != nil {
if *req.Role != store.RoleUser && *req.Role != store.RoleAdmin {
resp.Fail(c, http.StatusBadRequest, "role must be user or admin")
return
}
updates["role"] = *req.Role
}
if req.Status != nil {
if *req.Status != store.UserStatusActive && *req.Status != store.UserStatusDisabled {
resp.Fail(c, http.StatusBadRequest, "status must be active or disabled")
return
}
updates["status"] = *req.Status
}
if len(updates) > 0 {
h.a.DB.Model(&u).Updates(updates)
}
resp.OK(c, gin.H{"ok": true})
}
// AdminAdjustBalance POST /api/v1/admin/users/:id/balance — 调整余额并写流水。
func (h *Handler) AdminAdjustBalance(c *gin.Context) {
admin, _ := userFromContext(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid user id")
return
}
var req struct {
Change float64 `json:"change" binding:"required"`
Remark string `json:"remark"`
}
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid input: change is required")
return
}
var u store.User
if err := h.a.DB.First(&u, id).Error; err != nil {
resp.Fail(c, http.StatusNotFound, "user not found")
return
}
newBalance := u.Balance + req.Change
ref := fmt.Sprintf("admin:%d:%d", admin.ID, time.Now().UnixNano())
err = h.a.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&store.User{}).Where("id = ?", u.ID).Update("balance", newBalance).Error; err != nil {
return err
}
return tx.Create(&store.BalanceLog{
UserID: u.ID,
Change: req.Change,
BalanceAfter: newBalance,
Type: store.BalanceTypeAdminAdjust,
RefID: ref,
Remark: req.Remark,
}).Error
})
if err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to adjust balance")
return
}
resp.OK(c, gin.H{"ok": true, "balance": newBalance})
}
// AdminConfig GET /api/v1/admin/config
func (h *Handler) AdminConfig(c *gin.Context) {
var cfgs []store.SystemConfig
h.a.DB.Find(&cfgs)
m := map[string]any{}
for _, cfg := range cfgs {
var v any
_ = json.Unmarshal([]byte(cfg.Value), &v)
m[cfg.Key] = v
}
m["registration.mode"] = h.a.Cfg.Auth.RegistrationMode
resp.OK(c, gin.H{"config": m})
}
// AdminPutConfig PUT /api/v1/admin/config
func (h *Handler) AdminPutConfig(c *gin.Context) {
var req struct {
Config map[string]any `json:"config"`
}
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid input")
return
}
for k, v := range req.Config {
if k == "registration.mode" {
if v == "open" || v == "invite" {
h.a.Cfg.Auth.RegistrationMode = v.(string)
}
continue
}
b, _ := json.Marshal(v)
cfg := store.SystemConfig{Key: k, Value: string(b)}
h.a.DB.Save(&cfg)
}
resp.OK(c, gin.H{"ok": true})
}
+197
View File
@@ -0,0 +1,197 @@
// Package api 管理 API(/api/v1):认证、用户、密钥、用量、管理后台。
package api
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/openteam/server/internal/app"
"github.com/openteam/server/internal/pkg/resp"
"github.com/openteam/server/internal/store"
"gorm.io/gorm"
)
// Handler 聚合所有管理 API。
type Handler struct {
a *app.App
}
func NewHandler(a *app.App) *Handler { return &Handler{a: a} }
// ---------------------------------------------------------------------------
// 认证
type registerReq struct {
Username string `json:"username" binding:"required,min=3,max=32"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8,max=72"`
InviteCode string `json:"invite_code"`
}
// Register POST /api/v1/auth/register
func (h *Handler) Register(c *gin.Context) {
var req registerReq
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid input: "+err.Error())
return
}
if h.a.Cfg.Auth.RegistrationMode == "invite" {
var ic string
h.a.DB.Model(&store.SystemConfig{}).Where("key = ?", "invite_codes").Pluck("value", &ic)
if !strings.Contains(ic, req.InviteCode) {
resp.Fail(c, http.StatusForbidden, "valid invite code required")
return
}
}
var count int64
h.a.DB.Model(&store.User{}).Where("username = ? OR email = ?", req.Username, req.Email).Count(&count)
if count > 0 {
resp.Fail(c, http.StatusConflict, "username or email already exists")
return
}
hash, err := h.a.Hasher.HashPassword(req.Password)
if err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to hash password")
return
}
u := store.User{
Username: req.Username,
Email: strings.ToLower(req.Email),
PasswordHash: hash,
Role: store.RoleUser,
Balance: 5.0, // 新用户赠送体验余额(可通过 admin 调整)
Status: store.UserStatusActive,
}
if req.InviteCode != "" {
ic := req.InviteCode
u.InviteCode = &ic
}
if err := h.a.DB.Create(&u).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to create user")
return
}
resp.Created(c, gin.H{"id": u.ID, "username": u.Username})
}
type loginReq struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
// Login POST /api/v1/auth/login — 返回 access token,refresh token 写入 HttpOnly Cookie。
func (h *Handler) Login(c *gin.Context) {
var req loginReq
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid input")
return
}
var u store.User
err := h.a.DB.Where("username = ? OR email = ?", req.Username, strings.ToLower(req.Username)).First(&u).Error
if err != nil || u.Status != store.UserStatusActive {
resp.Fail(c, http.StatusUnauthorized, "invalid credentials")
return
}
ok, err := h.a.Hasher.VerifyPassword(u.PasswordHash, req.Password)
if err != nil || !ok {
resp.Fail(c, http.StatusUnauthorized, "invalid credentials")
return
}
now := time.Now()
h.a.DB.Model(&u).Update("last_login_at", now)
access, _, err := h.a.JWT.Sign(u.ID, u.Username, u.Role, "access")
if err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to issue token")
return
}
refresh, _, err := h.a.JWT.Sign(u.ID, u.Username, u.Role, "refresh")
if err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to issue token")
return
}
h.setRefreshCookie(c, refresh)
resp.OK(c, gin.H{"access_token": access, "expires_in": int(h.a.JWT.AccessTTL().Seconds()), "user": h.publicUser(&u)})
}
// Refresh POST /api/v1/auth/refresh — 用 refresh cookie 换新 access token。
func (h *Handler) Refresh(c *gin.Context) {
tok, err := c.Cookie(h.a.Cfg.JWT.CookieName)
if err != nil || tok == "" {
resp.Fail(c, http.StatusUnauthorized, "refresh token missing")
return
}
claims, err := h.a.JWT.Parse(tok)
if err != nil || claims.Subject != "refresh" {
resp.Fail(c, http.StatusUnauthorized, "invalid refresh token")
return
}
var u store.User
if err := h.a.DB.First(&u, claims.UserID).Error; err != nil || u.Status != store.UserStatusActive {
resp.Fail(c, http.StatusUnauthorized, "user not found or disabled")
return
}
access, _, err := h.a.JWT.Sign(u.ID, u.Username, u.Role, "access")
if err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to issue token")
return
}
resp.OK(c, gin.H{"access_token": access, "expires_in": int(h.a.JWT.AccessTTL().Seconds())})
}
// Logout POST /api/v1/auth/logout — 清除 refresh cookie。
func (h *Handler) Logout(c *gin.Context) {
h.setRefreshCookie(c, "")
resp.OK(c, gin.H{"ok": true})
}
// Me GET /api/v1/auth/me
func (h *Handler) Me(c *gin.Context) {
u := sessionUser(c)
resp.OK(c, gin.H{"user": h.publicUser(u)})
}
func (h *Handler) setRefreshCookie(c *gin.Context, value string) {
maxAge := int(h.a.JWT.RefreshTTL().Seconds())
if value == "" {
maxAge = -1
}
http.SetCookie(c.Writer, &http.Cookie{
Name: h.a.Cfg.JWT.CookieName,
Value: value,
Path: "/",
MaxAge: maxAge,
HttpOnly: true,
Secure: h.a.Cfg.JWT.CookieSecure,
SameSite: http.SameSiteLaxMode,
Domain: h.a.Cfg.JWT.CookieDomain,
})
}
func (h *Handler) publicUser(u *store.User) gin.H {
return gin.H{
"id": u.ID,
"username": u.Username,
"email": u.Email,
"role": u.Role,
"balance": u.Balance,
"status": u.Status,
"created_at": u.CreatedAt,
}
}
func sessionUser(c *gin.Context) *store.User {
u, _ := c.Get("session_user")
return u.(*store.User)
}
func userFromContext(c *gin.Context) (*store.User, bool) {
u, ok := c.Get("session_user")
if !ok {
return nil, false
}
return u.(*store.User), true
}
var _ = gorm.ErrRecordNotFound
+181
View File
@@ -0,0 +1,181 @@
package api
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/openteam/server/internal/pkg/apikey"
"github.com/openteam/server/internal/pkg/resp"
"github.com/openteam/server/internal/store"
)
type createKeyReq struct {
Name string `json:"name" binding:"required,min=1,max=64"`
QuotaTokensPerDay *int64 `json:"quota_tokens_per_day"`
QuotaRequestsPerDay *int `json:"quota_requests_per_day"`
AllowedModels []string `json:"allowed_models"`
ExpiresAt *string `json:"expires_at"` // RFC3339
}
// CreateKey POST /api/v1/keys — 创建密钥,明文仅此一次返回。
func (h *Handler) CreateKey(c *gin.Context) {
u, ok := userFromContext(c)
if !ok {
resp.Fail(c, http.StatusUnauthorized, "authentication required")
return
}
var req createKeyReq
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid input: "+err.Error())
return
}
plain, hash, prefix, err := apikey.Generate()
if err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to generate key")
return
}
k := store.APIKey{
UserID: u.ID,
Name: req.Name,
KeyHash: hash,
KeyPrefix: prefix,
QuotaTokensPerDay: req.QuotaTokensPerDay,
QuotaRequestsPerDay: req.QuotaRequestsPerDay,
AllowedModels: req.AllowedModels,
Status: store.KeyStatusActive,
}
if req.ExpiresAt != nil {
t, err := time.Parse(time.RFC3339, *req.ExpiresAt)
if err != nil {
resp.Fail(c, http.StatusBadRequest, "expires_at must be RFC3339")
return
}
k.ExpiresAt = &t
}
if err := h.a.DB.Create(&k).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to create key")
return
}
resp.Created(c, gin.H{
"id": k.ID,
"name": k.Name,
"key": plain, // 仅此一次
"key_prefix": k.KeyPrefix,
"created_at": k.CreatedAt,
})
}
// ListKeys GET /api/v1/keys
func (h *Handler) ListKeys(c *gin.Context) {
u, ok := userFromContext(c)
if !ok {
resp.Fail(c, http.StatusUnauthorized, "authentication required")
return
}
var keys []store.APIKey
if err := h.a.DB.Where("user_id = ?", u.ID).Order("id DESC").Find(&keys).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to load keys")
return
}
out := make([]gin.H, 0, len(keys))
for _, k := range keys {
out = append(out, gin.H{
"id": k.ID,
"name": k.Name,
"key_prefix": k.KeyPrefix,
"quota_tokens_per_day": k.QuotaTokensPerDay,
"quota_requests_per_day": k.QuotaRequestsPerDay,
"allowed_models": k.AllowedModels,
"expires_at": k.ExpiresAt,
"status": k.Status,
"last_used_at": k.LastUsedAt,
"created_at": k.CreatedAt,
})
}
resp.OK(c, gin.H{"items": out})
}
// PatchKey PATCH /api/v1/keys/:id — 改名、限额、白名单、启停。
func (h *Handler) PatchKey(c *gin.Context) {
u, ok := userFromContext(c)
if !ok {
resp.Fail(c, http.StatusUnauthorized, "authentication required")
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid key id")
return
}
var req struct {
Name *string `json:"name"`
QuotaTokensPerDay *int64 `json:"quota_tokens_per_day"`
QuotaRequestsPerDay *int `json:"quota_requests_per_day"`
AllowedModels *[]string `json:"allowed_models"`
Status *string `json:"status"`
}
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid input")
return
}
var k store.APIKey
if err := h.a.DB.Where("id = ? AND user_id = ?", id, u.ID).First(&k).Error; err != nil {
resp.Fail(c, http.StatusNotFound, "key not found")
return
}
updates := map[string]any{}
if req.Name != nil {
updates["name"] = *req.Name
}
if req.QuotaTokensPerDay != nil {
updates["quota_tokens_per_day"] = *req.QuotaTokensPerDay
}
if req.QuotaRequestsPerDay != nil {
updates["quota_requests_per_day"] = *req.QuotaRequestsPerDay
}
if req.AllowedModels != nil {
updates["allowed_models"] = *req.AllowedModels
}
if req.Status != nil {
if *req.Status != store.KeyStatusActive && *req.Status != store.KeyStatusRevoked {
resp.Fail(c, http.StatusBadRequest, "status must be active or revoked")
return
}
updates["status"] = *req.Status
}
if len(updates) > 0 {
if err := h.a.DB.Model(&k).Updates(updates).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to update key")
return
}
}
resp.OK(c, gin.H{"ok": true})
}
// DeleteKey DELETE /api/v1/keys/:id — 吊销。
func (h *Handler) DeleteKey(c *gin.Context) {
u, ok := userFromContext(c)
if !ok {
resp.Fail(c, http.StatusUnauthorized, "authentication required")
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid key id")
return
}
res := h.a.DB.Model(&store.APIKey{}).
Where("id = ? AND user_id = ?", id, u.ID).
Update("status", store.KeyStatusRevoked)
if res.Error != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to revoke key")
return
}
if res.RowsAffected == 0 {
resp.Fail(c, http.StatusNotFound, "key not found")
return
}
resp.OK(c, gin.H{"ok": true})
}
@@ -0,0 +1,79 @@
// Package middleware Gin 中间件:会话鉴权、admin 校验。
package middleware
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/openteam/server/internal/app"
"github.com/openteam/server/internal/pkg/resp"
"github.com/openteam/server/internal/store"
)
const CtxSessionUser = "session_user"
// SessionAuth 会话鉴权:Authorization: Bearer <access token>。
func SessionAuth(a *app.App) gin.HandlerFunc {
return func(c *gin.Context) {
auth := c.GetHeader("Authorization")
if len(auth) < 8 || auth[:7] != "Bearer " {
resp.Fail(c, http.StatusUnauthorized, "missing or invalid access token")
c.Abort()
return
}
claims, err := a.JWT.Parse(auth[7:])
if err != nil || claims.Subject != "access" {
resp.Fail(c, http.StatusUnauthorized, "invalid or expired access token")
c.Abort()
return
}
var u store.User
if err := a.DB.First(&u, claims.UserID).Error; err != nil {
resp.Fail(c, http.StatusUnauthorized, "user not found")
c.Abort()
return
}
if u.Status != store.UserStatusActive {
resp.Fail(c, http.StatusForbidden, "user account disabled")
c.Abort()
return
}
c.Set(CtxSessionUser, &u)
c.Next()
}
}
// AdminOnly 仅管理员。
func AdminOnly(c *gin.Context) {
u, ok := c.Get(CtxSessionUser)
if !ok {
resp.Fail(c, http.StatusUnauthorized, "authentication required")
c.Abort()
return
}
if u.(*store.User).Role != store.RoleAdmin {
resp.Fail(c, http.StatusForbidden, "admin permission required")
c.Abort()
return
}
c.Next()
}
// CORS 开发期放开;生产按配置白名单(PLANNING §8)。
func CORS() gin.HandlerFunc {
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
if origin != "" {
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Vary", "Origin")
c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Requested-With")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
}
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
+84
View File
@@ -0,0 +1,84 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/openteam/server/internal/api/middleware"
"github.com/openteam/server/internal/app"
"github.com/openteam/server/internal/proxy"
)
// NewRouter 装配所有路由:
// - /v1/* 代理端点(Bearer API Key,OpenAI 格式错误体)
// - /api/v1/* 管理 API(会话 JWT)
func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine {
if a.Cfg.Env == "production" {
gin.SetMode(gin.ReleaseMode)
}
r := gin.New()
r.Use(gin.Logger(), gin.Recovery(), middleware.CORS())
h := NewHandler(a)
// --- 代理端点(对外)---
proxyGroup := r.Group("/v1")
{
proxyGroup.Any("/chat/completions", gw.Auth, gw.Handle)
proxyGroup.Any("/responses", gw.Auth, gw.Handle)
proxyGroup.Any("/models", gw.Auth, gw.Handle)
}
// 未匹配的 /v1/* 返回 OpenAI 风格 404(需先认证)
r.NoRoute(func(c *gin.Context) {
if len(c.Request.URL.Path) >= 3 && c.Request.URL.Path[:3] == "/v1" {
gw.Auth(c)
if !c.IsAborted() {
gw.Handle(c)
}
return
}
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
})
// --- 管理 API ---
api := r.Group("/api/v1")
{
auth := api.Group("/auth")
{
auth.POST("/register", h.Register)
auth.POST("/login", h.Login)
auth.POST("/refresh", h.Refresh)
auth.POST("/logout", h.Logout)
auth.GET("/me", middleware.SessionAuth(a), h.Me)
}
user := api.Group("", middleware.SessionAuth(a))
{
user.GET("/user/profile", h.UserProfile)
user.GET("/user/balance", h.UserBalance)
user.GET("/user/models", h.UserModels)
user.GET("/usage/summary", h.UsageSummary)
user.GET("/usage/stats", h.UsageStats)
user.GET("/usage/logs", h.UsageLogs)
user.POST("/keys", h.CreateKey)
user.GET("/keys", h.ListKeys)
user.PATCH("/keys/:id", h.PatchKey)
user.DELETE("/keys/:id", h.DeleteKey)
}
admin := api.Group("/admin", middleware.SessionAuth(a), middleware.AdminOnly)
{
admin.GET("/users", h.AdminUsers)
admin.PATCH("/users/:id", h.AdminPatchUser)
admin.POST("/users/:id/balance", h.AdminAdjustBalance)
admin.GET("/config", h.AdminConfig)
admin.PUT("/config", h.AdminPutConfig)
// 渠道/模型/用量管理(M4);充值审核(M5 预留)
}
}
r.GET("/healthz", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
return r
}
+165
View File
@@ -0,0 +1,165 @@
package api
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/openteam/server/internal/pkg/resp"
"github.com/openteam/server/internal/store"
)
// UserProfile GET /api/v1/user/profile
func (h *Handler) UserProfile(c *gin.Context) {
u, _ := userFromContext(c)
resp.OK(c, gin.H{"user": h.publicUser(u)})
}
// UserBalance GET /api/v1/user/balance — 余额 + 近 30 日消耗。
func (h *Handler) UserBalance(c *gin.Context) {
u, _ := userFromContext(c)
var spent float64
h.a.DB.Model(&store.UsageLog{}).
Where("user_id = ? AND status = ? AND created_at >= ?", u.ID, store.UsageStatusSuccess, time.Now().Add(-30*24*time.Hour)).
Select("COALESCE(SUM(cost),0)").Scan(&spent)
resp.OK(c, gin.H{
"balance": u.Balance,
"spent_last_30d": spent,
"today": h.todayUsage(c, u.ID),
"models_available": h.availableModelCount(),
})
}
func (h *Handler) availableModelCount() int64 {
var n int64
h.a.DB.Model(&store.Model{}).Where("enabled = ?", true).Count(&n)
return n
}
func (h *Handler) todayUsage(c *gin.Context, userID uint64) gin.H {
var requests int64
var tokens int64
var cost float64
today := time.Now().UTC().Format("2006-01-02")
h.a.DB.Model(&store.UsageDaily{}).
Where("user_id = ? AND date = ?", userID, today).
Select("COALESCE(SUM(requests),0), COALESCE(SUM(input_tokens+output_tokens+cache_read_tokens),0), COALESCE(SUM(cost),0)").
Row().Scan(&requests, &tokens, &cost)
return gin.H{"requests": requests, "tokens": tokens, "cost": cost}
}
// UserModels GET /api/v1/user/models — 控制台可用模型列表(无需 API Key)。
func (h *Handler) UserModels(c *gin.Context) {
var ms []store.Model
if err := h.a.DB.Where("enabled = ?", true).Order("sort ASC, id ASC").Find(&ms).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to load models")
return
}
out := make([]string, 0, len(ms))
for _, m := range ms {
out = append(out, m.Name)
}
resp.OK(c, gin.H{"items": out})
}
// UsageSummary GET /api/v1/usage/summary — 今日/本月汇总。
func (h *Handler) UsageSummary(c *gin.Context) {
u, _ := userFromContext(c)
now := time.Now().UTC()
today := now.Format("2006-01-02")
month := now.Format("2006-01")
var todayReq, monthReq int64
var todayTok, monthTok int64
var todayCost, monthCost float64
h.a.DB.Model(&store.UsageDaily{}).Where("user_id = ? AND date = ?", u.ID, today).
Select("COALESCE(SUM(requests),0), COALESCE(SUM(input_tokens+output_tokens+cache_read_tokens),0), COALESCE(SUM(cost),0)").
Row().Scan(&todayReq, &todayTok, &todayCost)
h.a.DB.Model(&store.UsageDaily{}).Where("user_id = ? AND date LIKE ?", u.ID, month+"%").
Select("COALESCE(SUM(requests),0), COALESCE(SUM(input_tokens+output_tokens+cache_read_tokens),0), COALESCE(SUM(cost),0)").
Row().Scan(&monthReq, &monthTok, &monthCost)
resp.OK(c, gin.H{
"today": gin.H{"requests": todayReq, "tokens": todayTok, "cost": todayCost},
"month": gin.H{"requests": monthReq, "tokens": monthTok, "cost": monthCost},
})
}
// UsageStats GET /api/v1/usage/stats?from&to&group=day|model
func (h *Handler) UsageStats(c *gin.Context) {
u, _ := userFromContext(c)
from := c.DefaultQuery("from", time.Now().Add(-30*24*time.Hour).Format("2006-01-02"))
to := c.DefaultQuery("to", time.Now().Format("2006-01-02"))
group := c.DefaultQuery("group", "day")
q := h.a.DB.Model(&store.UsageDaily{}).Where("user_id = ? AND date BETWEEN ? AND ?", u.ID, from, to)
out := make([]gin.H, 0, 64)
if group == "model" {
var rows []struct {
ModelID uint64
Requests int64
Tokens int64
Cost float64
}
q.Select("model_id, SUM(requests) requests, SUM(input_tokens+output_tokens+cache_read_tokens) tokens, SUM(cost) cost").
Group("model_id").Scan(&rows)
for _, r := range rows {
var m store.Model
name := strconv.FormatUint(r.ModelID, 10)
if h.a.DB.First(&m, r.ModelID).Error == nil {
name = m.Name
}
out = append(out, gin.H{"model": name, "requests": r.Requests, "tokens": r.Tokens, "cost": r.Cost})
}
} else {
var rows []struct {
Date string
Requests int64
Tokens int64
Cost float64
}
q.Select("date, SUM(requests) requests, SUM(input_tokens+output_tokens+cache_read_tokens) tokens, SUM(cost) cost").
Group("date").Order("date").Scan(&rows)
for _, r := range rows {
out = append(out, gin.H{"date": r.Date, "requests": r.Requests, "tokens": r.Tokens, "cost": r.Cost})
}
}
resp.OK(c, gin.H{"items": out})
}
// UsageLogs GET /api/v1/usage/logs?from&to&page&page_size&model
func (h *Handler) UsageLogs(c *gin.Context) {
u, _ := userFromContext(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
q := h.a.DB.Model(&store.UsageLog{}).Where("user_id = ?", u.ID)
if from := c.Query("from"); from != "" {
q = q.Where("created_at >= ?", from+" 00:00:00")
}
if to := c.Query("to"); to != "" {
q = q.Where("created_at <= ?", to+" 23:59:59")
}
if model := c.Query("model"); model != "" {
q = q.Where("model_name = ?", model)
}
var total int64
q.Count(&total)
var logs []store.UsageLog
q.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&logs)
out := make([]gin.H, 0, len(logs))
for _, l := range logs {
out = append(out, gin.H{
"id": l.ID, "request_id": l.RequestID, "model": l.ModelName, "protocol": l.Protocol,
"input_tokens": l.InputTokens, "output_tokens": l.OutputTokens,
"cache_read_tokens": l.CacheReadTokens, "cost": l.Cost,
"latency_ms": l.LatencyMS, "status": l.Status, "error_code": l.ErrorCode,
"created_at": l.CreatedAt,
})
}
resp.OK(c, gin.H{"items": out, "total": total, "page": page, "page_size": size})
}
+132
View File
@@ -0,0 +1,132 @@
// Package app 应用容器:装配配置、数据库、密码/加密/JWT 与记账器。
package app
import (
"context"
"log"
"time"
"github.com/openteam/server/internal/config"
"github.com/openteam/server/internal/pkg/crypto"
"github.com/openteam/server/internal/pkg/jwt"
"github.com/openteam/server/internal/store"
"github.com/openteam/server/internal/usage"
"gorm.io/gorm"
)
type App struct {
Cfg *config.Config
DB *gorm.DB
Hasher *crypto.PasswordHasher
Enc *crypto.Encryptor
JWT *jwt.Manager
Usage *usage.Recorder
startedAt time.Time
}
func New(cfg *config.Config) (*App, error) {
db, err := store.Open(cfg.DB.Driver, cfg.DB.DSN)
if err != nil {
return nil, err
}
a := &App{
Cfg: cfg,
DB: db,
Hasher: crypto.NewPasswordHasher(cfg.Auth.Argon2Time, cfg.Auth.Argon2Memory, cfg.Auth.Argon2Threads, cfg.Auth.Argon2KeyLen, cfg.Auth.SaltLen),
Enc: crypto.NewEncryptor(cfg.Master),
JWT: jwt.NewManager(cfg.JWT.Secret, cfg.JWT.Issuer, cfg.JWT.AccessTTL, cfg.JWT.RefreshTTL),
startedAt: time.Now(),
}
a.Usage = usage.NewRecorder(db)
if err := a.Seed(); err != nil {
return nil, err
}
return a, nil
}
func (a *App) Close() {
a.Usage.Close()
}
// Seed 首次启动初始化:管理员账号 + 默认渠道 + 默认模型。
func (a *App) Seed() error {
// 1. 管理员(从环境变量读取,默认 admin/admin123,生产必须改)
var count int64
a.DB.Model(&store.User{}).Where("role = ?", store.RoleAdmin).Count(&count)
if count == 0 {
hash, err := a.Hasher.HashPassword(envOr("OT_ADMIN_PASSWORD", "admin123"))
if err != nil {
return err
}
admin := store.User{
Username: envOr("OT_ADMIN_USERNAME", "admin"),
Email: envOr("OT_ADMIN_EMAIL", "admin@localhost"),
PasswordHash: hash,
Role: store.RoleAdmin,
Balance: 1000, // 初始余额,便于联调;生产由充值/调整决定
Status: store.UserStatusActive,
}
if err := a.DB.Create(&admin).Error; err != nil {
return err
}
log.Printf("seed: created admin user %q (change the default password!)", admin.Username)
}
// 2. 默认渠道(配置了上游 key 时创建)
if a.Cfg.Proxy.UpstreamKey != "" {
var chCount int64
a.DB.Model(&store.Channel{}).Count(&chCount)
if chCount == 0 {
enc, err := a.Enc.Encrypt(a.Cfg.Proxy.UpstreamKey)
if err != nil {
return err
}
ch := store.Channel{
Name: a.Cfg.Proxy.DefaultChannelName,
Provider: store.ChannelProviderOpenAI,
BaseURL: a.Cfg.Proxy.UpstreamBaseURL,
APIKeyEnc: enc,
Weight: 1,
Priority: 0,
TimeoutMS: int(a.Cfg.Proxy.Timeout / time.Millisecond),
MaxConcurrency: 16,
HealthStatus: store.ChannelHealthHealthy,
Enabled: true,
}
if err := a.DB.Create(&ch).Error; err != nil {
return err
}
// 默认模型 + 绑定
m := store.Model{
Name: a.Cfg.Proxy.DefaultModel,
DisplayName: a.Cfg.Proxy.DefaultModel,
InputPrice: 0.15, // 每百万 token,示例价
OutputPrice: 0.60,
Enabled: true,
}
if err := a.DB.Create(&m).Error; err == nil {
a.DB.Create(&store.ChannelModelBinding{ChannelID: ch.ID, ModelID: m.ID, UpstreamModel: m.Name})
}
log.Printf("seed: created default channel %q (%s)", ch.Name, ch.BaseURL)
}
}
return nil
}
func (a *App) Shutdown(ctx context.Context) {
a.Usage.Close()
if sqlDB, err := a.DB.DB(); err == nil {
_ = sqlDB.Close()
}
_ = ctx
}
func envOr(key, fallback string) string {
v := envLookup(key)
if v == "" {
return fallback
}
return v
}
+5
View File
@@ -0,0 +1,5 @@
package app
import "os"
func envLookup(key string) string { return os.Getenv(key) }
+68
View File
@@ -0,0 +1,68 @@
// Package channel 渠道仓储:选择、加解密、健康过滤。
// M1 阶段实现最小选择逻辑(按优先级+权重取第一个健康启用的渠道),
// 负载均衡/健康检查/故障转移在 M4 完善。
package channel
import (
"errors"
"github.com/openteam/server/internal/pkg/crypto"
"github.com/openteam/server/internal/store"
"gorm.io/gorm"
)
var ErrNoChannel = errors.New("no available channel")
type Service struct {
db *gorm.DB
enc *crypto.Encryptor
}
func NewService(db *gorm.DB, enc *crypto.Encryptor) *Service {
return &Service{db: db, enc: enc}
}
// Select 选择处理请求的渠道:启用 + 健康,按 priority 升序、weight 降序。
func (s *Service) Select() (*store.Channel, error) {
var chs []store.Channel
if err := s.db.Where("enabled = ? AND health_status = ?", true, store.ChannelHealthHealthy).
Order("priority ASC, weight DESC, id ASC").Find(&chs).Error; err != nil {
return nil, err
}
if len(chs) == 0 {
return nil, ErrNoChannel
}
return &chs[0], nil
}
// UpstreamKey 解密渠道上游密钥。
func (s *Service) UpstreamKey(ch *store.Channel) (string, error) {
return s.enc.Decrypt(ch.APIKeyEnc)
}
// ResolveModel 按全局模型名找到绑定渠道;M1 简化:返回绑定该模型的第一个健康渠道。
func (s *Service) ResolveModel(modelName string) (*store.Channel, *store.ChannelModelBinding, error) {
var m store.Model
if err := s.db.Where("name = ? AND enabled = ?", modelName, true).First(&m).Error; err != nil {
return nil, nil, err
}
var b store.ChannelModelBinding
if err := s.db.Where("model_id = ?", m.ID).
Joins("JOIN channels ON channels.id = channel_model_bindings.channel_id AND channels.enabled = ? AND channels.health_status = ?", true, store.ChannelHealthHealthy).
Order("channel_model_bindings.weight DESC").
First(&b).Error; err != nil {
return nil, nil, err
}
ch, err := s.Select()
if err != nil {
return nil, nil, err
}
// 用绑定里的渠道(如果健康),否则回退默认渠道
if b.ChannelID != ch.ID {
var bound store.Channel
if err := s.db.First(&bound, b.ChannelID).Error; err == nil && bound.Enabled && bound.HealthStatus == store.ChannelHealthHealthy {
return &bound, &b, nil
}
}
return ch, &b, nil
}
+124
View File
@@ -0,0 +1,124 @@
// Package config 加载服务配置:.env / 环境变量 / 默认值(viper)。
package config
import (
"strings"
"time"
"github.com/spf13/viper"
)
type Config struct {
Env string // development | production
Port int
DB DBConfig
JWT JWTConfig
Auth AuthConfig
Proxy ProxyConfig
Master string // 渠道密钥 AES-GCM 主密钥(来自环境变量)
}
type DBConfig struct {
Driver string // sqlite | postgres
DSN string
}
type JWTConfig struct {
Secret string
AccessTTL time.Duration
RefreshTTL time.Duration
Issuer string
CookieName string
CookieSecure bool
CookieDomain string
}
type AuthConfig struct {
RegistrationMode string // open | invite
Argon2Time uint32
Argon2Memory uint32
Argon2Threads uint8
Argon2KeyLen uint32
SaltLen int
}
type ProxyConfig struct {
DefaultChannelName string // 首次启动自动创建的渠道名(如 openai)
UpstreamBaseURL string // 渠道 base_url 默认值
UpstreamKey string // 渠道上游 key 默认值
DefaultModel string // 渠道模型导入时使用的模型名
Timeout time.Duration
}
func Load() (*Config, error) {
v := viper.New()
v.SetEnvPrefix("OT")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
// 默认值(与 .env.example 对应)
v.SetDefault("env", "development")
v.SetDefault("port", 8080)
v.SetDefault("db.driver", "sqlite")
v.SetDefault("db.dsn", "data/openteam.db")
v.SetDefault("jwt.secret", "dev-only-secret-change-me")
v.SetDefault("jwt.access_ttl", "2h")
v.SetDefault("jwt.refresh_ttl", "168h")
v.SetDefault("jwt.issuer", "openteam")
v.SetDefault("jwt.cookie_name", "ot_refresh")
v.SetDefault("jwt.cookie_secure", false)
v.SetDefault("jwt.cookie_domain", "")
v.SetDefault("auth.registration_mode", "open")
v.SetDefault("auth.argon2_time", 3)
v.SetDefault("auth.argon2_memory", 64*1024) // 64 MiB
v.SetDefault("auth.argon2_threads", 2)
v.SetDefault("auth.argon2_keylen", 32)
v.SetDefault("auth.salt_len", 16)
v.SetDefault("proxy.default_channel_name", "openai")
v.SetDefault("proxy.upstream_base_url", "https://api.openai.com")
v.SetDefault("proxy.upstream_key", "")
v.SetDefault("proxy.default_model", "gpt-4o-mini")
v.SetDefault("proxy.timeout", "120s")
// 支持读取 .env 文件(可选,不强制)
v.SetConfigFile(".env")
_ = v.ReadInConfig()
return &Config{
Env: v.GetString("env"),
Port: v.GetInt("port"),
DB: DBConfig{
Driver: v.GetString("db.driver"),
DSN: v.GetString("db.dsn"),
},
JWT: JWTConfig{
Secret: v.GetString("jwt.secret"),
AccessTTL: v.GetDuration("jwt.access_ttl"),
RefreshTTL: v.GetDuration("jwt.refresh_ttl"),
Issuer: v.GetString("jwt.issuer"),
CookieName: v.GetString("jwt.cookie_name"),
CookieSecure: v.GetBool("jwt.cookie_secure"),
CookieDomain: v.GetString("jwt.cookie_domain"),
},
Auth: AuthConfig{
RegistrationMode: v.GetString("auth.registration_mode"),
Argon2Time: v.GetUint32("auth.argon2_time"),
Argon2Memory: v.GetUint32("auth.argon2_memory"),
Argon2Threads: v.GetUint8("auth.argon2_threads"),
Argon2KeyLen: v.GetUint32("auth.argon2_keylen"),
SaltLen: v.GetInt("auth.salt_len"),
},
Proxy: ProxyConfig{
DefaultChannelName: v.GetString("proxy.default_channel_name"),
UpstreamBaseURL: v.GetString("proxy.upstream_base_url"),
UpstreamKey: v.GetString("proxy.upstream_key"),
DefaultModel: v.GetString("proxy.default_model"),
Timeout: v.GetDuration("proxy.timeout"),
},
Master: v.GetString("master_key"),
}, nil
}
+52
View File
@@ -0,0 +1,52 @@
// Package apikey 生成与管理 API Key:sk- + 48 位 base62 随机串。
// 库中仅存 SHA-256 哈希与展示前缀(PLANNING §4.3.3)。
package apikey
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"strings"
)
const (
alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
keyLen = 48
prefix = "sk-"
)
// Generate 生成明文 key(仅创建时展示一次)与哈希、前缀。
func Generate() (plain, hash, keyPrefix string, err error) {
buf := make([]byte, keyLen)
if _, err = rand.Read(buf); err != nil {
return "", "", "", err
}
for i := range buf {
buf[i] = alphabet[int(buf[i])%len(alphabet)]
}
plain = prefix + string(buf)
return plain, Hash(plain), Prefix(plain), nil
}
// Hash 返回 key 的 SHA-256 十六进制。
func Hash(key string) string {
sum := sha256.Sum256([]byte(key))
return hex.EncodeToString(sum[:])
}
// Prefix 展示前缀:sk-aB3cD5…(前 12 字符)
func Prefix(key string) string {
if len(key) <= 12 {
return key
}
return key[:12]
}
// Valid 校验明文格式。
func Valid(key string) bool {
return strings.HasPrefix(key, prefix) && len(key) == len(prefix)+keyLen
}
// base64 占位,避免未使用导入告警
var _ = base64.StdEncoding
+33
View File
@@ -0,0 +1,33 @@
package apikey
import "testing"
func TestGenerate(t *testing.T) {
plain, hash, prefix, err := Generate()
if err != nil {
t.Fatal(err)
}
if !Valid(plain) {
t.Fatalf("generated key invalid: %q", plain)
}
if len(plain) != 3+48 {
t.Fatalf("key length = %d, want 51", len(plain))
}
if Hash(plain) != hash {
t.Fatal("hash mismatch")
}
if len(prefix) > len(plain) || prefix != plain[:len(prefix)] {
t.Fatal("prefix must be prefix of plain key")
}
// 两次生成不重复
plain2, _, _, _ := Generate()
if plain == plain2 {
t.Fatal("keys should be unique")
}
}
func TestValid(t *testing.T) {
if Valid("") || Valid("sk-short") || Valid("xxx") {
t.Fatal("invalid keys should be rejected")
}
}
+125
View File
@@ -0,0 +1,125 @@
// Package crypto 密码哈希(argon2id)与对称加密(AES-GCM)。
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
type PasswordHasher struct {
Time uint32
Memory uint32
Threads uint8
KeyLen uint32
SaltLen int
}
func NewPasswordHasher(time, memory uint32, threads uint8, keyLen uint32, saltLen int) *PasswordHasher {
return &PasswordHasher{Time: time, Memory: memory, Threads: threads, KeyLen: keyLen, SaltLen: saltLen}
}
// HashPassword argon2id 编码为 $argon2id$v=19$m=...,t=...,p=...$salt$hash
func (h *PasswordHasher) HashPassword(password string) (string, error) {
salt := make([]byte, h.SaltLen)
if _, err := rand.Read(salt); err != nil {
return "", err
}
key := argon2.IDKey([]byte(password), salt, h.Time, h.Memory, h.Threads, h.KeyLen)
enc := base64.RawStdEncoding
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
h.Memory, h.Time, h.Threads, enc.EncodeToString(salt), enc.EncodeToString(key)), nil
}
// VerifyPassword 校验密码,返回是否匹配(常数时间比较)。
func (h *PasswordHasher) VerifyPassword(encoded, password string) (bool, error) {
parts := strings.Split(encoded, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return false, errors.New("invalid hash format")
}
var memory uint32
var time uint32
var threads uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil {
return false, err
}
enc := base64.RawStdEncoding
salt, err := enc.DecodeString(parts[4])
if err != nil {
return false, err
}
want, err := enc.DecodeString(parts[5])
if err != nil {
return false, err
}
got := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1, nil
}
// ---------------------------------------------------------------------------
// AES-GCM 渠道密钥加密
type Encryptor struct {
key []byte
}
// NewEncryptor 主密钥必须为 16/24/32 字节;不足时用 SHA-256 派生固定 32 字节。
func NewEncryptor(master string) *Encryptor {
key := []byte(master)
switch len(key) {
case 16, 24, 32:
default:
sum := sha256Sum(master)
key = sum
}
return &Encryptor{key: key}
}
// Encrypt 输出 base64(nonce || ciphertext)
func (e *Encryptor) Encrypt(plain string) (string, error) {
block, err := aes.NewCipher(e.key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", err
}
ct := gcm.Seal(nil, nonce, []byte(plain), nil)
return base64.StdEncoding.EncodeToString(append(nonce, ct...)), nil
}
func (e *Encryptor) Decrypt(enc string) (string, error) {
raw, err := base64.StdEncoding.DecodeString(enc)
if err != nil {
return "", err
}
block, err := aes.NewCipher(e.key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
if len(raw) < gcm.NonceSize() {
return "", errors.New("ciphertext too short")
}
nonce, ct := raw[:gcm.NonceSize()], raw[gcm.NonceSize():]
plain, err := gcm.Open(nil, nonce, ct, nil)
if err != nil {
return "", err
}
return string(plain), nil
}
+44
View File
@@ -0,0 +1,44 @@
package crypto
import "testing"
func TestPasswordHashRoundTrip(t *testing.T) {
h := NewPasswordHasher(3, 64*1024, 2, 32, 16)
hash, err := h.HashPassword("s3cret-password")
if err != nil {
t.Fatalf("hash: %v", err)
}
ok, err := h.VerifyPassword(hash, "s3cret-password")
if err != nil || !ok {
t.Fatalf("verify correct password: ok=%v err=%v", ok, err)
}
ok, _ = h.VerifyPassword(hash, "wrong-password")
if ok {
t.Fatal("wrong password should not verify")
}
}
func TestEncryptDecrypt(t *testing.T) {
e := NewEncryptor("master-key-0123456789abcdef")
enc, err := e.Encrypt("sk-upstream-secret")
if err != nil {
t.Fatalf("encrypt: %v", err)
}
dec, err := e.Decrypt(enc)
if err != nil || dec != "sk-upstream-secret" {
t.Fatalf("decrypt: got %q err %v", dec, err)
}
// 密文不可读
if dec == enc {
t.Fatal("ciphertext should differ from plaintext")
}
}
func TestShortMasterKeyDerived(t *testing.T) {
e := NewEncryptor("short")
enc, _ := e.Encrypt("x")
dec, err := e.Decrypt(enc)
if err != nil || dec != "x" {
t.Fatalf("short key derive failed: %v", err)
}
}
+8
View File
@@ -0,0 +1,8 @@
package crypto
import "crypto/sha256"
func sha256Sum(s string) []byte {
sum := sha256.Sum256([]byte(s))
return sum[:]
}
+71
View File
@@ -0,0 +1,71 @@
// Package jwt 访问令牌(短时,存内存)与刷新令牌(HttpOnly Cookie)签发/校验。
package jwt
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
UserID uint64 `json:"uid"`
Username string `json:"uname"`
Role string `json:"role"`
jwt.RegisteredClaims
}
type Manager struct {
secret []byte
issuer string
accessTTL time.Duration
refreshTTL time.Duration
}
func NewManager(secret, issuer string, accessTTL, refreshTTL time.Duration) *Manager {
return &Manager{secret: []byte(secret), issuer: issuer, accessTTL: accessTTL, refreshTTL: refreshTTL}
}
func (m *Manager) AccessTTL() time.Duration { return m.accessTTL }
func (m *Manager) RefreshTTL() time.Duration { return m.refreshTTL }
// Sign 签发 token;typ 取 "access" / "refresh"。
func (m *Manager) Sign(userID uint64, username, role, typ string) (string, time.Time, error) {
ttl := m.accessTTL
if typ == "refresh" {
ttl = m.refreshTTL
}
now := time.Now()
exp := now.Add(ttl)
claims := Claims{
UserID: userID,
Username: username,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: m.issuer,
Subject: typ,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(exp),
},
}
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
s, err := tok.SignedString(m.secret)
return s, exp, err
}
var ErrInvalidToken = errors.New("invalid token")
// Parse 校验签名与有效期。
func (m *Manager) Parse(token string) (*Claims, error) {
claims := &Claims{}
tok, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, ErrInvalidToken
}
return m.secret, nil
})
if err != nil || !tok.Valid {
return nil, ErrInvalidToken
}
return claims, nil
}
+41
View File
@@ -0,0 +1,41 @@
package jwt
import (
"testing"
"time"
)
func TestSignParse(t *testing.T) {
m := NewManager("test-secret", "openteam", time.Hour, 24*time.Hour)
tok, exp, err := m.Sign(42, "alice", "admin", "access")
if err != nil {
t.Fatal(err)
}
if time.Until(exp) < 50*time.Minute {
t.Fatal("expiry too short")
}
claims, err := m.Parse(tok)
if err != nil {
t.Fatal(err)
}
if claims.UserID != 42 || claims.Username != "alice" || claims.Role != "admin" || claims.Subject != "access" {
t.Fatalf("claims mismatch: %+v", claims)
}
}
func TestExpiredToken(t *testing.T) {
m := NewManager("test-secret", "openteam", -time.Minute, time.Hour)
tok, _, _ := m.Sign(1, "a", "user", "access")
if _, err := m.Parse(tok); err == nil {
t.Fatal("expired token should fail")
}
}
func TestWrongSecret(t *testing.T) {
m1 := NewManager("secret-a", "openteam", time.Hour, time.Hour)
m2 := NewManager("secret-b", "openteam", time.Hour, time.Hour)
tok, _, _ := m1.Sign(1, "a", "user", "access")
if _, err := m2.Parse(tok); err == nil {
t.Fatal("token signed with different secret should fail")
}
}
+39
View File
@@ -0,0 +1,39 @@
// Package resp 统一 JSON 响应与错误格式。
package resp
import (
"net/http"
"github.com/gin-gonic/gin"
)
type Body struct {
Data any `json:"data,omitempty"`
Error *Error `json:"error,omitempty"`
}
type Error struct {
Message string `json:"message"`
Type string `json:"type,omitempty"`
Code string `json:"code,omitempty"`
}
// OK 200
func OK(c *gin.Context, data any) {
c.JSON(http.StatusOK, Body{Data: data})
}
// Created 201
func Created(c *gin.Context, data any) {
c.JSON(http.StatusCreated, Body{Data: data})
}
// Fail 业务错误(message 会展示给用户)
func Fail(c *gin.Context, status int, message string) {
c.JSON(status, Body{Error: &Error{Message: message}})
}
// FailCode 带错误码的业务错误
func FailCode(c *gin.Context, status int, code, message string) {
c.JSON(status, Body{Error: &Error{Message: message, Type: code}})
}
+133
View File
@@ -0,0 +1,133 @@
// Package proxy API 网关核心:代理 /v1/chat/completions、/v1/responses、/v1/models。
// M1:对 OpenAI 渠道直通(passthrough),不转格式;M3 起加入协议转换。
package proxy
import (
"errors"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/openteam/server/internal/channel"
"github.com/openteam/server/internal/pkg/apikey"
"github.com/openteam/server/internal/pkg/crypto"
"github.com/openteam/server/internal/store"
"github.com/openteam/server/internal/usage"
"gorm.io/gorm"
)
const (
CtxUserID = "proxy_user_id"
CtxKeyID = "proxy_key_id"
CtxTrace = "proxy_trace_id"
)
type Gateway struct {
db *gorm.DB
ch *channel.Service
rec *usage.Recorder
enc *crypto.Encryptor
hc *http.Client
}
func NewGateway(db *gorm.DB, enc *crypto.Encryptor, rec *usage.Recorder) *Gateway {
return &Gateway{
db: db,
ch: channel.NewService(db, enc),
rec: rec,
enc: enc,
hc: &http.Client{Timeout: 120 * time.Second},
}
}
// Auth 代理鉴权中间件:Bearer sk-xxx → 哈希查表 → 校验状态/过期/模型白名单。
func (g *Gateway) Auth(c *gin.Context) {
auth := c.GetHeader("Authorization")
key := strings.TrimPrefix(auth, "Bearer ")
key = strings.TrimSpace(key)
if !apikey.Valid(key) {
openAIError(c, http.StatusUnauthorized, "invalid_api_key", "Invalid API key format. Expected: Bearer sk-...")
c.Abort()
return
}
hash := apikey.Hash(key)
var k store.APIKey
if err := g.db.Where("key_hash = ? AND status = ?", hash, store.KeyStatusActive).First(&k).Error; err != nil {
openAIError(c, http.StatusUnauthorized, "invalid_api_key", "Invalid API key")
c.Abort()
return
}
var u store.User
if err := g.db.First(&u, k.UserID).Error; err != nil || u.Status != store.UserStatusActive {
openAIError(c, http.StatusForbidden, "user_disabled", "User account is disabled")
c.Abort()
return
}
if k.ExpiresAt != nil && time.Now().After(*k.ExpiresAt) {
openAIError(c, http.StatusUnauthorized, "key_expired", "API key has expired")
c.Abort()
return
}
c.Set(CtxUserID, u.ID)
c.Set(CtxKeyID, k.ID)
c.Set(CtxTrace, newTraceID())
g.db.Model(&store.APIKey{}).Where("id = ?", k.ID).Update("last_used_at", time.Now())
c.Next()
}
// Handle 路由到对应协议处理器。
func (g *Gateway) Handle(c *gin.Context) {
switch {
case c.Request.URL.Path == "/v1/chat/completions":
g.chatCompletions(c)
case c.Request.URL.Path == "/v1/responses":
g.responses(c)
case c.Request.URL.Path == "/v1/models" && c.Request.Method == http.MethodGet:
g.models(c)
default:
openAIError(c, http.StatusNotFound, "not_found", "Unknown endpoint: "+c.Request.URL.Path)
}
}
// models GET /v1/models:返回启用的全局模型(OpenAI 风格)。
func (g *Gateway) models(c *gin.Context) {
var ms []store.Model
if err := g.db.Where("enabled = ?", true).Order("sort ASC, id ASC").Find(&ms).Error; err != nil {
openAIError(c, http.StatusInternalServerError, "internal_error", "failed to load models")
return
}
data := make([]gin.H, 0, len(ms))
for _, m := range ms {
data = append(data, gin.H{
"id": m.Name,
"object": "model",
"created": m.CreatedAt.Unix(),
"owned_by": "openteam",
})
}
c.JSON(http.StatusOK, gin.H{"object": "list", "data": data})
}
// resolveUser 取当前用户(含余额)。
func (g *Gateway) resolveUser(c *gin.Context) (*store.User, bool) {
uid, _ := c.Get(CtxUserID)
var u store.User
if err := g.db.First(&u, uid).Error; err != nil {
openAIError(c, http.StatusUnauthorized, "invalid_api_key", "Invalid API key")
return nil, false
}
return &u, true
}
// checkBalance 余额不足返回 402(PLANNING §4.4.3)。
func (g *Gateway) checkBalance(c *gin.Context, u *store.User) bool {
if u.Balance <= 0 {
openAIError(c, http.StatusPaymentRequired, "insufficient_balance", "Insufficient balance. Please recharge or contact admin.")
return false
}
return true
}
var errNoChannel = errors.New("no available channel")
+115
View File
@@ -0,0 +1,115 @@
package proxy
import (
"encoding/json"
"net/http"
"github.com/gin-gonic/gin"
"github.com/openteam/server/internal/store"
)
// chatCompletions POST /v1/chat/completions
func (g *Gateway) chatCompletions(c *gin.Context) {
u, ok := g.resolveUser(c)
if !ok {
return
}
if !g.checkBalance(c, u) {
return
}
br, body, err := parseBody(c)
if err != nil {
openAIError(c, http.StatusBadRequest, "invalid_request_error", "failed to read request body")
return
}
c.Set("protocol", "chat")
c.Set("model_name", br.Model)
ch, err := g.ch.Select()
if err != nil {
openAIError(c, http.StatusServiceUnavailable, "no_channel", "No available upstream channel")
g.recordError(c, nil, nil, now(), "no_channel")
return
}
sink := &usageSink{}
c.Set("usage_raw", &sinkHolder{sink: sink})
g.doPassthrough(c, ch, "/v1/chat/completions", body, br.Stream, func(raw json.RawMessage) {
sink.push(raw)
})
}
// responses POST /v1/responses(OpenAI Responses API)
func (g *Gateway) responses(c *gin.Context) {
u, ok := g.resolveUser(c)
if !ok {
return
}
if !g.checkBalance(c, u) {
return
}
br, body, err := parseBody(c)
if err != nil {
openAIError(c, http.StatusBadRequest, "invalid_request_error", "failed to read request body")
return
}
c.Set("protocol", "responses")
c.Set("model_name", br.Model)
ch, err := g.ch.Select()
if err != nil {
openAIError(c, http.StatusServiceUnavailable, "no_channel", "No available upstream channel")
g.recordError(c, nil, nil, now(), "no_channel")
return
}
// M1 仅支持 OpenAI 原生渠道直通;Anthropic 渠道的转换在 M3
if ch.Provider != store.ChannelProviderOpenAI {
openAIError(c, http.StatusNotImplemented, "conversion_pending",
"Responses protocol on this channel requires format conversion (planned in M3)")
return
}
sink := &usageSink{}
c.Set("usage_raw", &sinkHolder{sink: sink})
g.doPassthrough(c, ch, "/v1/responses", body, br.Stream, func(raw json.RawMessage) {
sink.push(raw)
})
}
// usageSinkHolder 桥接:gin context 里保存 sink 引用,供 finishUsage 读取最终 usage。
type sinkHolder struct {
sink *usageSink
}
// openAIError 按 OpenAI 错误格式返回(PLANNING §4.1.4)。
func openAIError(c *gin.Context, status int, code, message string) {
c.AbortWithStatusJSON(status, gin.H{
"error": gin.H{
"message": message,
"type": errorTypeFor(status),
"param": nil,
"code": code,
},
})
}
func errorTypeFor(status int) string {
switch status {
case http.StatusUnauthorized:
return "authentication_error"
case http.StatusForbidden:
return "permission_error"
case http.StatusNotFound:
return "invalid_request_error"
case http.StatusBadRequest:
return "invalid_request_error"
case http.StatusPaymentRequired:
return "insufficient_quota"
case http.StatusTooManyRequests:
return "rate_limit_error"
default:
return "api_error"
}
}
+382
View File
@@ -0,0 +1,382 @@
package proxy
import (
"bufio"
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/openteam/server/internal/store"
)
// newTraceID 生成请求 trace(用于日志与记账幂等 ref)。
func newTraceID() string {
b := make([]byte, 8)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// bodyReq 统一取出请求体并解析 model / stream 字段。
type bodyReq struct {
Model string `json:"model"`
Stream bool `json:"stream"`
}
// parseBody 读取并回填请求体,解析 model/stream。
func parseBody(c *gin.Context) (*bodyReq, []byte, error) {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
return nil, nil, err
}
c.Request.Body = io.NopCloser(bytes.NewReader(body))
br := &bodyReq{}
_ = json.Unmarshal(body, br) // 解析失败按空处理,直通仍可转发
return br, body, nil
}
// upstreamURL 组装上游地址:base_url + 客户端路径(/v1/chat/completions 等)。
func upstreamURL(ch *store.Channel, path string) string {
base := strings.TrimRight(ch.BaseURL, "/")
return base + path
}
// doPassthrough 通用直通:替换 Authorization 为渠道密钥,转发请求。
// convert 回调用于改写请求体(M1 直通为原样;M3 转换时改写)。
func (g *Gateway) doPassthrough(c *gin.Context, ch *store.Channel, path string, body []byte, stream bool, outUsage func(usageRaw json.RawMessage)) {
upKey, err := g.ch.UpstreamKey(ch)
if err != nil {
openAIError(c, http.StatusInternalServerError, "channel_error", "failed to decrypt channel key")
return
}
upBody := body
// 流式 chat:注入 stream_options.include_usage,保证末块带 usage(OpenAI 行为)
if stream && path == "/v1/chat/completions" && !bytes.Contains(upBody, []byte(`"include_usage"`)) {
var m map[string]any
if json.Unmarshal(upBody, &m) == nil {
m["stream_options"] = map[string]any{"include_usage": true}
if b, err := json.Marshal(m); err == nil {
upBody = b
}
}
}
ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(ch.TimeoutMS)*time.Millisecond)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL(ch, path), bytes.NewReader(upBody))
if err != nil {
openAIError(c, http.StatusInternalServerError, "internal_error", "failed to build upstream request")
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+upKey)
req.Header.Set("Accept", c.GetHeader("Accept"))
if ua := c.GetHeader("User-Agent"); ua != "" {
req.Header.Set("User-Agent", ua)
}
// 透传 OpenAI 生态请求头(组织/项目等)
for _, h := range []string{"OpenAI-Organization", "OpenAI-Project", "OpenAI-Beta"} {
if v := c.GetHeader(h); v != "" {
req.Header.Set(h, v)
}
}
start := time.Now()
resp, err := g.hc.Do(req)
if err != nil {
status := http.StatusBadGateway
msg := "Upstream request failed: " + err.Error()
if ctx.Err() == context.DeadlineExceeded {
status = http.StatusGatewayTimeout
msg = "Upstream request timed out"
}
openAIError(c, status, "upstream_error", msg)
g.recordError(c, ch, nil, start, "upstream_error")
return
}
defer resp.Body.Close()
// 非 2xx:透传上游错误体(OpenAI 格式),并记录 error 用量
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
errBody, _ := io.ReadAll(resp.Body)
status := resp.StatusCode
// 上游 5xx → 网关 502/504(重试逻辑 M4)
if status >= 500 {
status = http.StatusBadGateway
}
c.DataFromReader(status, int64(len(errBody)), "application/json", bytes.NewReader(errBody), nil)
c.Header("Content-Type", "application/json")
g.recordError(c, ch, resp, start, "upstream_http_"+strconv.Itoa(resp.StatusCode))
return
}
// 成功响应
c.Header("Content-Type", resp.Header.Get("Content-Type"))
c.Status(http.StatusOK)
if stream {
g.streamCopy(c, ch, resp.Body, start, outUsage)
} else {
g.copyAndCapture(c, ch, resp.Body, start, outUsage)
}
}
// copyAndCapture 非流式:整体转发 + 解析 usage + 记账。
func (g *Gateway) copyAndCapture(c *gin.Context, ch *store.Channel, r io.Reader, start time.Time, outUsage func(json.RawMessage)) {
data, err := io.ReadAll(r)
if err != nil {
openAIError(c, http.StatusBadGateway, "upstream_error", "failed reading upstream response")
g.recordError(c, ch, nil, start, "read_error")
return
}
// 尝试解析 usage(chat / responses 字段不同)
if usageRaw := extractUsage(data); usageRaw != nil {
outUsage(usageRaw)
}
_, _ = c.Writer.Write(data)
g.finishUsage(c, ch, start, store.UsageStatusSuccess, "")
}
// streamCopy 流式:边读上游 SSE 边写客户端,零缓冲转发;扫描 usage 行记账。
// 客户端断连(ctx cancel)即中止上游读取。
func (g *Gateway) streamCopy(c *gin.Context, ch *store.Channel, r io.Reader, start time.Time, outUsage func(json.RawMessage)) {
w := c.Writer
flusher, ok := w.(http.Flusher)
if !ok {
flusher = nopFlusher{}
}
scanner := newSSEScanner(r)
for {
line, err := scanner.Next()
if line != nil {
if _, werr := w.Write(line); werr != nil {
// 客户端断开:取消上游(ctx cancel 由 request ctx 处理)
g.recordError(c, ch, nil, start, "client_disconnect")
return
}
flusher.Flush()
if usageRaw := scanUsage(line); usageRaw != nil {
outUsage(usageRaw)
}
}
if err != nil {
if err == io.EOF {
g.finishUsage(c, ch, start, store.UsageStatusSuccess, "")
} else {
g.recordError(c, ch, nil, start, "stream_read_error")
}
return
}
}
}
type nopFlusher struct{}
func (nopFlusher) Flush() {}
// ---------------------------------------------------------------------------
// usage 提取
// usageShape 兼容 chat (prompt/completion) 与 responses (input/output) 两种命名。
type usageShape struct {
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
TotalTokens int64 `json:"total_tokens"`
// Claude 缓存口径(M3 接入)
CacheReadInputTokens int64 `json:"cache_read_input_tokens"`
CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"`
}
// extractUsage 从完整响应体提取 usage 子对象。
func extractUsage(data []byte) json.RawMessage {
var m map[string]json.RawMessage
if json.Unmarshal(data, &m) != nil {
return nil
}
if u, ok := m["usage"]; ok && string(u) != "null" {
return u
}
// responses 事件/响应:usage 嵌套在 response 对象内
if respRaw, ok := m["response"]; ok {
var resp map[string]json.RawMessage
if json.Unmarshal(respRaw, &resp) == nil {
if u, ok := resp["usage"]; ok && string(u) != "null" {
return u
}
}
}
// chat 兜底:choices[].message.usage
if choices, ok := m["choices"]; ok {
var cs []map[string]json.RawMessage
if json.Unmarshal(choices, &cs) == nil {
for _, ch := range cs {
if msgRaw, ok := ch["message"]; ok {
var msg map[string]json.RawMessage
if json.Unmarshal(msgRaw, &msg) == nil {
if u, ok := msg["usage"]; ok && string(u) != "null" {
return u
}
}
}
}
}
}
return nil
}
// scanUsage 从 SSE 一行中提取 usage(OpenAI 末块 / responses completed 事件)。
func scanUsage(line []byte) json.RawMessage {
s := string(line)
if !strings.Contains(s, `"usage"`) {
return nil
}
if strings.HasPrefix(s, "data: ") {
s = strings.TrimPrefix(s, "data: ")
}
s = strings.TrimSpace(s)
if s == "[DONE]" || s == "" {
return nil
}
var m map[string]json.RawMessage
if json.Unmarshal([]byte(s), &m) != nil {
return nil
}
if u, ok := m["usage"]; ok && string(u) != "null" {
return u
}
// responses 流式:usage 在 response 对象内(response.completed 事件)
if respRaw, ok := m["response"]; ok {
var resp map[string]json.RawMessage
if json.Unmarshal(respRaw, &resp) == nil {
if u, ok := resp["usage"]; ok && string(u) != "null" {
return u
}
}
}
return nil
}
// sseScanner 按 SSE 行边界读取(兼容 \n 与 \r\n),保留原始行内容。
// 基于 bufio.Reader:行内可含任意内容,跨 chunk 自动拼接。
type sseScanner struct {
r *bufio.Reader
}
func newSSEScanner(r io.Reader) *sseScanner { return &sseScanner{r: bufio.NewReaderSize(r, 32*1024)} }
func (s *sseScanner) Next() ([]byte, error) {
line, err := s.r.ReadBytes('\n')
if len(line) > 0 {
return line, nil
}
if err != nil {
return nil, err
}
return nil, io.EOF
}
// ---------------------------------------------------------------------------
// 记账
// usageSink 累积流式多次 usage(取最后一次,即最终值)。
type usageSink struct {
last json.RawMessage
}
func (u *usageSink) push(raw json.RawMessage) {
if len(raw) > 0 {
u.last = raw
}
}
// finishUsage 落账:计算成本并异步写入。
func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time, status, errCode string) {
uid, _ := c.Get(CtxUserID)
kid, _ := c.Get(CtxKeyID)
trace, _ := c.Get(CtxTrace)
var us usageShape
if h, ok := c.Get("usage_raw"); ok {
if holder, ok := h.(*sinkHolder); ok && holder.sink != nil && len(holder.sink.last) > 0 {
_ = json.Unmarshal(holder.sink.last, &us)
}
}
in := us.PromptTokens + us.InputTokens
out := us.CompletionTokens + us.OutputTokens
cacheRead := us.CacheReadInputTokens
cacheCreate := us.CacheCreationInputTokens
modelName, _ := c.Get("model_name")
mn, _ := modelName.(string)
var model store.Model
var cost float64
var modelID uint64
_ = g.db.Where("name = ?", mn).First(&model).Error
if model.ID > 0 {
modelID = model.ID
cost = float64(in)/1e6*model.InputPrice +
float64(out)/1e6*model.OutputPrice +
float64(cacheRead)/1e6*model.CacheReadPrice
} else {
cost = float64(in)/1e6*0.15 + float64(out)/1e6*0.60 // 无定价模型时按示例价
}
proto, _ := c.Get("protocol")
p, _ := proto.(string)
if p == "" {
p = "chat"
}
traceStr, _ := trace.(string)
errMsg := errCode
latency := int(time.Since(start).Milliseconds())
// 已写响应头但流中途出错:记 error
if status == store.UsageStatusSuccess && c.Writer.Status() >= 400 {
status = store.UsageStatusError
}
g.rec.Record(&store.UsageLog{
RequestID: fmt.Sprintf("trace-%s", traceStr),
TraceID: traceStr,
UserID: uid.(uint64),
KeyID: kid.(uint64),
ChannelID: ch.ID,
ModelID: modelID,
ModelName: mn,
Protocol: p,
InputTokens: in,
OutputTokens: out,
CacheReadTokens: cacheRead,
CacheCreationTokens: cacheCreate,
InputPrice: model.InputPrice,
OutputPrice: model.OutputPrice,
CacheReadPrice: model.CacheReadPrice,
Cost: cost,
LatencyMS: latency,
Status: status,
ErrorCode: &errMsg,
CreatedAt: time.Now().UTC(),
})
}
// recordError 失败请求的记账(不产生扣费,status=error)。
func (g *Gateway) recordError(c *gin.Context, ch *store.Channel, resp *http.Response, start time.Time, code string) {
status := store.UsageStatusError
_ = resp
g.finishUsage(c, ch, start, status, code)
}
func now() time.Time { return time.Now() }
+107
View File
@@ -0,0 +1,107 @@
package proxy
import (
"encoding/json"
"io"
"testing"
)
func TestScanUsageChat(t *testing.T) {
line := []byte(`data: {"id":"x","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":9,"total_tokens":21}}`)
raw := scanUsage(line)
if raw == nil {
t.Fatal("chat usage not detected")
}
var us usageShape
if err := json.Unmarshal(raw, &us); err != nil {
t.Fatal(err)
}
if us.PromptTokens != 12 || us.CompletionTokens != 9 {
t.Fatalf("usage mismatch: %+v", us)
}
}
func TestScanUsageResponsesNested(t *testing.T) {
line := []byte(`data: {"response":{"id":"r","status":"completed","usage":{"input_tokens":15,"output_tokens":11,"total_tokens":26}},"type":"response.completed"}`)
raw := scanUsage(line)
if raw == nil {
t.Fatal("responses nested usage not detected")
}
var us usageShape
if err := json.Unmarshal(raw, &us); err != nil {
t.Fatal(err)
}
if us.InputTokens != 15 || us.OutputTokens != 11 {
t.Fatalf("usage mismatch: %+v", us)
}
}
func TestScanUsageIgnoresNonData(t *testing.T) {
if scanUsage([]byte("event: response.completed")) != nil {
t.Fatal("event line should be ignored")
}
if scanUsage([]byte("data: [DONE]")) != nil {
t.Fatal("[DONE] should be ignored")
}
if scanUsage([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}")) != nil {
t.Fatal("content chunk without usage should be ignored")
}
}
func TestExtractUsageFromFullBody(t *testing.T) {
body := []byte(`{"id":"x","choices":[{"message":{"content":"hi"}}],"usage":{"prompt_tokens":1,"completion_tokens":2}}`)
raw := extractUsage(body)
if raw == nil {
t.Fatal("usage not extracted from full body")
}
var us usageShape
_ = json.Unmarshal(raw, &us)
if us.PromptTokens != 1 || us.CompletionTokens != 2 {
t.Fatalf("usage mismatch: %+v", us)
}
}
func TestSSEScannerLines(t *testing.T) {
// 模拟分块写入的 SSE 流
data := "data: {\"a\":1}\n\ndata: {\"usage\":{\"input_tokens\":3}}\n\n"
parts := [][]byte{[]byte(data[:10]), []byte(data[10:20]), []byte(data[20:])}
reader := newChunkReader(parts)
s := newSSEScanner(reader)
var lines [][]byte
for {
line, err := s.Next()
if line != nil {
lines = append(lines, line)
}
if err != nil {
break
}
}
if len(lines) != 4 {
t.Fatalf("expected 4 lines, got %d", len(lines))
}
// 合并后应能还原原始数据
joined := ""
for _, l := range lines {
joined += string(l)
}
if joined != string(data) {
t.Fatalf("stream corrupted:\n got: %q\nwant: %q", joined, data)
}
}
type chunkReader struct {
parts [][]byte
idx int
}
func newChunkReader(parts [][]byte) *chunkReader { return &chunkReader{parts: parts} }
func (r *chunkReader) Read(p []byte) (int, error) {
if r.idx >= len(r.parts) {
return 0, io.EOF
}
n := copy(p, r.parts[r.idx])
r.idx++
return n, nil
}
+34
View File
@@ -0,0 +1,34 @@
package store
import (
"log"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
// Open 打开数据库连接并自动迁移。
// 开发默认 SQLite(dsn 支持 file:...?_journal_mode=WAL),生产可切 postgres。
func Open(driver, dsn string) (*gorm.DB, error) {
var dialector gorm.Dialector
switch driver {
case "postgres":
dialector = postgresDialector(dsn)
default:
dialector = sqlite.Open(dsn)
}
db, err := gorm.Open(dialector, &gorm.Config{
Logger: gormlogger.Default.LogMode(gormlogger.Warn),
})
if err != nil {
return nil, err
}
if err := db.AutoMigrate(AllModels()...); err != nil {
return nil, err
}
log.Printf("store: connected driver=%s (migrated)", driver)
return db, nil
}
+11
View File
@@ -0,0 +1,11 @@
package store
import (
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
// postgresDialector 延迟引用 postgres 驱动,避免开发环境额外依赖。
func postgresDialector(dsn string) gorm.Dialector {
return postgres.Open(dsn)
}
+202
View File
@@ -0,0 +1,202 @@
// Package store 数据模型与仓储层(GORM)。
// 字段设计对应 PLANNING.md §5:金额/价格 numeric(20,8),token bigint,时间 UTC。
package store
import (
"time"
)
// 角色 / 状态枚举(字符串存库,便于阅读与迁移)
const (
RoleUser = "user"
RoleAdmin = "admin"
UserStatusActive = "active"
UserStatusDisabled = "disabled"
KeyStatusActive = "active"
KeyStatusRevoked = "revoked"
ChannelProviderOpenAI = "openai"
ChannelProviderAnthropic = "anthropic"
ChannelProviderCompatible = "compatible"
ChannelHealthHealthy = "healthy"
ChannelHealthDegraded = "degraded"
ChannelHealthCooldown = "cooldown"
UsageStatusSuccess = "success"
UsageStatusError = "error"
UsageStatusCanceled = "canceled"
BalanceTypeRecharge = "recharge"
BalanceTypeUsage = "usage"
BalanceTypeRefund = "refund"
BalanceTypeAdminAdjust = "admin_adjust"
RechargeStatusPending = "pending"
RechargeStatusCredited = "credited"
RechargeStatusRejected = "rejected"
RechargeMethodManual = "manual"
RechargeMethodOnline = "online"
)
// User 用户(PLANNING §5.1)
type User struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
Username string `gorm:"uniqueIndex;size:64;not null" json:"username"`
Email string `gorm:"uniqueIndex;size:255;not null" json:"email"`
PasswordHash string `gorm:"size:255;not null" json:"-"`
Role string `gorm:"size:16;not null;default:user" json:"role"`
Balance float64 `gorm:"type:numeric(20,8);not null;default:0" json:"balance"`
Status string `gorm:"size:16;not null;default:active" json:"status"`
InviteCode *string `json:"invite_code,omitempty"`
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// APIKey 密钥(PLANNING §5.2):库中只存 SHA-256 哈希 + 展示前缀
type APIKey struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
UserID uint64 `gorm:"index;not null" json:"user_id"`
Name string `gorm:"size:64;not null" json:"name"`
KeyHash string `gorm:"uniqueIndex;size:64;not null" json:"-"`
KeyPrefix string `gorm:"size:32;not null" json:"key_prefix"`
QuotaTokensPerDay *int64 `json:"quota_tokens_per_day,omitempty"`
QuotaRequestsPerDay *int `json:"quota_requests_per_day,omitempty"`
AllowedModels []string `gorm:"type:jsonb;serializer:json" json:"allowed_models,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
Status string `gorm:"size:16;not null;default:active" json:"status"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Channel 上游渠道(PLANNING §5.3)
type Channel struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
Name string `gorm:"uniqueIndex;size:64;not null" json:"name"`
Provider string `gorm:"size:16;not null" json:"provider"` // openai|anthropic|compatible
BaseURL string `gorm:"size:255;not null" json:"base_url"`
APIKeyEnc string `gorm:"size:1024;not null" json:"-"` // AES-GCM 密文
Weight int `gorm:"not null;default:1" json:"weight"`
Priority int `gorm:"not null;default:0" json:"priority"` // 数值小优先
TimeoutMS int `gorm:"not null;default:120000" json:"timeout_ms"`
MaxConcurrency int `gorm:"not null;default:16" json:"max_concurrency"`
HealthStatus string `gorm:"size:16;not null;default:healthy" json:"health_status"`
Enabled bool `gorm:"not null;default:true" json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Model 全局模型 + 定价(PLANNING §5.4,价格按每百万 token,USD)
type Model struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
Name string `gorm:"uniqueIndex;size:128;not null" json:"name"`
DisplayName string `gorm:"size:128" json:"display_name"`
InputPrice float64 `gorm:"type:numeric(20,8);not null;default:0" json:"input_price"`
OutputPrice float64 `gorm:"type:numeric(20,8);not null;default:0" json:"output_price"`
CacheReadPrice float64 `gorm:"type:numeric(20,8);not null;default:0" json:"cache_read_price"`
Enabled bool `gorm:"not null;default:true" json:"enabled"`
Sort int `gorm:"not null;default:0" json:"sort"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ChannelModelBinding 渠道↔模型绑定(多对多,PLANNING §5.4)
type ChannelModelBinding struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
ChannelID uint64 `gorm:"index:idx_ch_model,unique;not null" json:"channel_id"`
ModelID uint64 `gorm:"index:idx_ch_model,unique;not null" json:"model_id"`
UpstreamModel string `gorm:"size:255;not null" json:"upstream_model"`
Weight int `gorm:"not null;default:1" json:"weight"`
Channel Channel `gorm:"foreignKey:ChannelID" json:"-"`
Model Model `gorm:"foreignKey:ModelID" json:"-"`
}
// UsageLog 请求级用量明细(PLANNING §5.5)
type UsageLog struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
RequestID string `gorm:"size:128" json:"request_id"` // 上游 request id
TraceID string `gorm:"size:64;index" json:"trace_id"`
UserID uint64 `gorm:"index:idx_user_created;not null" json:"user_id"`
KeyID uint64 `json:"key_id"`
ChannelID uint64 `json:"channel_id"`
ModelID uint64 `json:"model_id"`
ModelName string `gorm:"size:128" json:"model_name"`
Protocol string `gorm:"size:32" json:"protocol"` // responses|chat|messages
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
CacheReadTokens int64 `json:"cache_read_tokens"`
CacheCreationTokens int64 `json:"cache_creation_tokens"`
InputPrice float64 `gorm:"type:numeric(20,8)" json:"input_price"` // 快照
OutputPrice float64 `gorm:"type:numeric(20,8)" json:"output_price"` // 快照
CacheReadPrice float64 `gorm:"type:numeric(20,8)" json:"cache_read_price"` // 快照
Cost float64 `gorm:"type:numeric(20,8)" json:"cost"`
LatencyMS int `json:"latency_ms"`
Status string `gorm:"size:16;not null" json:"status"`
ErrorCode *string `json:"error_code,omitempty"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
}
// UsageDaily 日粒度预聚合(PLANNING §5.6)
type UsageDaily struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
UserID uint64 `gorm:"index:idx_daily_user_model,unique" json:"user_id"`
ModelID uint64 `gorm:"index:idx_daily_user_model,unique" json:"model_id"`
Date string `gorm:"size:10;index:idx_daily_user_model,unique" json:"date"` // YYYY-MM-DD (UTC)
Requests int64 `json:"requests"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
CacheReadTokens int64 `json:"cache_read_tokens"`
Cost float64 `gorm:"type:numeric(20,8)" json:"cost"`
}
// RechargeOrder 充值订单(PLANNING §5.7,预留:首版不做充值)
type RechargeOrder struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
UserID uint64 `gorm:"index;not null" json:"user_id"`
Amount float64 `gorm:"type:numeric(20,8);not null" json:"amount"`
Status string `gorm:"size:16;not null;default:pending" json:"status"`
Method string `gorm:"size:16;not null;default:manual" json:"method"`
TransactionID string `gorm:"size:128" json:"transaction_id,omitempty"`
ReviewedBy *uint64 `json:"reviewed_by,omitempty"`
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
Remark string `gorm:"size:512" json:"remark,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// BalanceLog 余额流水(PLANNING §5.8,幂等:ref_id + type 唯一)
type BalanceLog struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
UserID uint64 `gorm:"index:idx_balance_user;not null" json:"user_id"`
Change float64 `gorm:"type:numeric(20,8);not null" json:"change"`
BalanceAfter float64 `gorm:"type:numeric(20,8);not null" json:"balance_after"`
Type string `gorm:"size:16;not null" json:"type"`
RefID string `gorm:"size:128;index:idx_balance_ref,unique" json:"ref_id"`
Remark string `gorm:"size:512" json:"remark,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// SystemConfig 系统配置(PLANNING §5.9)
type SystemConfig struct {
Key string `gorm:"primaryKey;size:64" json:"key"`
Value string `gorm:"type:jsonb;not null" json:"value"`
}
// AllModels 迁移顺序(外键依赖在后)
func AllModels() []any {
return []any{
&User{},
&APIKey{},
&Channel{},
&Model{},
&ChannelModelBinding{},
&UsageLog{},
&UsageDaily{},
&RechargeOrder{},
&BalanceLog{},
&SystemConfig{},
}
}
+144
View File
@@ -0,0 +1,144 @@
// Package usage 异步记账:请求完成后写入 usage_logs,批量落库(PLANNING §3.2)。
package usage
import (
"log"
"sync"
"time"
"github.com/openteam/server/internal/store"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Recorder struct {
db *gorm.DB
ch chan *store.UsageLog
wg sync.WaitGroup
closed chan struct{}
}
const batchSize = 32
func NewRecorder(db *gorm.DB) *Recorder {
r := &Recorder{
db: db,
ch: make(chan *store.UsageLog, 512),
closed: make(chan struct{}),
}
r.wg.Add(1)
go r.run()
return r
}
// Record 提交一条用量(非阻塞;队列满时同步写入,保证不丢账)。
func (r *Recorder) Record(l *store.UsageLog) {
select {
case r.ch <- l:
default:
// 队列积压:直接同步写,避免丢账
if err := r.flush([]*store.UsageLog{l}); err != nil {
log.Printf("usage: sync write failed: %v", err)
}
}
}
func (r *Recorder) Close() {
close(r.closed)
r.wg.Wait()
close(r.ch)
}
func (r *Recorder) run() {
defer r.wg.Done()
buf := make([]*store.UsageLog, 0, batchSize)
tick := time.NewTicker(2 * time.Second)
defer tick.Stop()
for {
select {
case l, ok := <-r.ch:
if !ok {
return
}
buf = append(buf, l)
if len(buf) >= batchSize {
if err := r.flush(buf); err != nil {
log.Printf("usage: batch write failed: %v", err)
}
buf = buf[:0]
}
case <-r.closed:
if len(buf) > 0 {
if err := r.flush(buf); err != nil {
log.Printf("usage: final batch write failed: %v", err)
}
}
return
case <-tick.C:
if len(buf) > 0 {
if err := r.flush(buf); err != nil {
log.Printf("usage: batch write failed: %v", err)
}
buf = buf[:0]
}
}
}
}
// flush 批量插入用量明细,并同步更新余额、余额流水与日聚合。
// 记账口径:单次成本 = in×in_price + out×out_price + cache_read×cache_read_price(每百万 token)。
func (r *Recorder) flush(logs []*store.UsageLog) error {
if len(logs) == 0 {
return nil
}
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(logs).Error; err != nil {
return err
}
for _, l := range logs {
if l.Status != store.UsageStatusSuccess || l.Cost <= 0 {
continue
}
// 扣余额(余额可为负,流式请求不中断;后续请求被拒)
var user store.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, l.UserID).Error; err != nil {
continue
}
newBalance := user.Balance - l.Cost
tx.Model(&store.User{}).Where("id = ?", l.UserID).Update("balance", newBalance)
tx.Create(&store.BalanceLog{
UserID: l.UserID,
Change: -l.Cost,
BalanceAfter: newBalance,
Type: store.BalanceTypeUsage,
RefID: usageRefID(l.TraceID),
Remark: "usage: " + l.ModelName,
})
// 日聚合 upsert
date := l.CreatedAt.UTC().Format("2006-01-02")
tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "model_id"}, {Name: "date"}},
DoUpdates: clause.Assignments(map[string]any{
"requests": gorm.Expr("requests + 1"),
"input_tokens": gorm.Expr("input_tokens + ?", l.InputTokens),
"output_tokens": gorm.Expr("output_tokens + ?", l.OutputTokens),
"cache_read_tokens": gorm.Expr("cache_read_tokens + ?", l.CacheReadTokens),
"cost": gorm.Expr("cost + ?", l.Cost),
}),
}).Create(&store.UsageDaily{
UserID: l.UserID, ModelID: l.ModelID, Date: date,
Requests: 1, InputTokens: l.InputTokens, OutputTokens: l.OutputTokens,
CacheReadTokens: l.CacheReadTokens, Cost: l.Cost,
})
}
return nil
})
}
func usageRefID(traceID string) string {
if traceID == "" {
traceID = "unknown"
}
return "usage:" + traceID
}
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+5
View File
@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>openteam · LLM 中转网关</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+32
View File
@@ -0,0 +1,32 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@fontsource/jetbrains-mono": "^5.3.0",
"@fontsource/outfit": "^5.3.0",
"axios": "^1.19.0",
"echarts": "^6.1.0",
"pinia": "^4.0.3",
"vue": "^3.5.40",
"vue-echarts": "^8.1.0",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^24.13.3",
"@vitejs/plugin-vue": "^6.0.8",
"@vue/tsconfig": "^0.9.1",
"playwright": "^1.62.1",
"tailwindcss": "^4.3.3",
"typescript": "~6.0.2",
"vite": "^8.2.0",
"vue-tsc": "^3.3.8"
}
}
+1478
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#E5A13C"/><path d="M6 16h6l4-10 6 20 4-10h0" fill="none" stroke="#0C0D0F" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 250 B

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+32
View File
@@ -0,0 +1,32 @@
const { chromium } = require('playwright')
async function main() {
const browser = await chromium.launch()
const page = await browser.newPage()
const uname = 'probe' + Date.now().toString().slice(-6)
await page.goto('http://localhost:5173/register', { waitUntil: 'networkidle' })
await page.fill('input[autocomplete="username"]', uname)
await page.fill('input[autocomplete="email"]', uname + '@test.com')
await page.fill('input[autocomplete="new-password"]', 'password123')
await page.locator('input[autocomplete="new-password"]').nth(1).fill('password123')
await page.click('button[type="submit"]')
await page.waitForURL('**/console/dashboard', { timeout: 10000 })
await page.waitForTimeout(500)
const results = await page.evaluate(async () => {
const out = {}
for (const [name, url] of Object.entries({
balance: '/api/v1/user/balance',
stats: '/api/v1/usage/stats?group=day',
logs: '/api/v1/usage/logs?page_size=8',
})) {
try {
const r = await fetch(url, { headers: { Authorization: 'Bearer ' + localStorage.getItem('ot_access') } })
const j = await r.json()
out[name] = { status: r.status, body: JSON.stringify(j).slice(0, 220) }
} catch (e) { out[name] = { err: String(e) } }
}
return out
})
console.log(JSON.stringify(results, null, 1))
await browser.close()
}
main().catch(e => { console.error(e); process.exit(1) })
+29
View File
@@ -0,0 +1,29 @@
// 定位 pageerror 来源
const { chromium } = require('playwright')
async function main() {
const browser = await chromium.launch()
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
page.on('console', (m) => { if (m.type() === 'error') console.log('CONSOLE ERR @', page.url(), '→', m.text().slice(0, 200)) })
page.on('pageerror', (e) => console.log('PAGE ERR @', page.url(), '→', e.stack?.split('\n').slice(0, 3).join(' | ')))
const uname = 'dbg' + Date.now().toString().slice(-6)
await page.goto('http://localhost:5173/register', { waitUntil: 'networkidle' })
await page.fill('input[autocomplete="username"]', uname)
await page.fill('input[autocomplete="email"]', uname + '@test.com')
await page.fill('input[autocomplete="new-password"]', 'password123')
await page.locator('input[autocomplete="new-password"]').nth(1).fill('password123')
await page.click('button[type="submit"]')
await page.waitForURL('**/console/dashboard', { timeout: 10000 })
await page.waitForTimeout(2500)
console.log('URL now:', page.url())
// 检查页面内容
const cards = await page.locator('.mt-6.grid .rounded-lg').count()
const chart = await page.locator('canvas').count()
const empty = await page.locator('text=暂无数据').count()
console.log('cards:', cards, 'canvas:', chart, 'emptyState:', empty)
const bodyText = (await page.locator('body').innerText()).slice(0, 300)
console.log('body:', bodyText.replace(/\n+/g, ' | '))
await browser.close()
}
main().catch((e) => { console.error(e); process.exit(1) })
+52
View File
@@ -0,0 +1,52 @@
// 前端截图验证脚本:登录 → 仪表盘 → 密钥页
const { chromium } = require('playwright')
async function main() {
const browser = await chromium.launch()
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
const shots = process.argv[2] || '/tmp/shots'
const fs = require('fs')
fs.mkdirSync(shots, { recursive: true })
// 1. Landing
await page.goto('http://localhost:5173/', { waitUntil: 'networkidle' })
await page.screenshot({ path: `${shots}/01-landing.png` })
// 2. 注册
await page.goto('http://localhost:5173/register', { waitUntil: 'networkidle' })
const uname = 'dave' + Date.now().toString().slice(-6)
await page.fill('input[autocomplete="username"]', uname)
await page.fill('input[autocomplete="email"]', uname + '@test.com')
await page.fill('input[autocomplete="new-password"]', 'password123')
await page.locator('input[autocomplete="new-password"]').nth(1).fill('password123')
await page.screenshot({ path: `${shots}/02-register.png` })
await page.click('button[type="submit"]')
await page.waitForURL('**/console/dashboard', { timeout: 10000 })
await page.waitForTimeout(1200)
// 3. 跳过登录(注册后已自动登录)—— 直接看仪表盘
await page.goto('http://localhost:5173/console/dashboard', { waitUntil: 'networkidle' })
await page.waitForTimeout(1500)
await page.screenshot({ path: `${shots}/03-dashboard.png` })
// 4. 密钥页
await page.goto('http://localhost:5173/console/keys', { waitUntil: 'networkidle' })
await page.screenshot({ path: `${shots}/04-keys.png` })
await page.click('text=新建密钥')
await page.waitForTimeout(400)
await page.fill('input[placeholder*="本地开发"]', 'playwright-test')
await page.screenshot({ path: `${shots}/05-key-modal.png` })
await page.click('button:has-text("创建")')
await page.waitForTimeout(600)
await page.screenshot({ path: `${shots}/06-key-created.png` })
// 5. 用量页
await page.goto('http://localhost:5173/console/usage', { waitUntil: 'networkidle' })
await page.waitForTimeout(800)
await page.screenshot({ path: `${shots}/07-usage.png` })
await browser.close()
console.log('done')
}
main().catch((e) => { console.error(e); process.exit(1) })
+80
View File
@@ -0,0 +1,80 @@
// 前端渲染断言:检查关键元素与 console 错误
const { chromium } = require('playwright')
async function main() {
const browser = await chromium.launch()
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
const errors = []
page.on('console', (m) => { if (m.type() === 'error') errors.push(page.url() + ' :: ' + m.text()) })
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message))
async function check(name, url, selectors) {
await page.goto(url, { waitUntil: 'networkidle' })
await page.waitForTimeout(800)
const results = []
for (const [label, sel] of Object.entries(selectors)) {
const n = await page.locator(sel).count()
results.push(`${label}:${n}`)
}
console.log(`${name}: ${results.join(' ')}`)
}
await check('landing', 'http://localhost:5173/', {
nav: 'header nav',
hero: 'h1',
protocols: '#protocols .grid > div',
footer: 'footer',
})
await check('login', 'http://localhost:5173/login', {
username: 'input[autocomplete="username"]',
password: 'input[autocomplete="current-password"]',
submit: 'button[type="submit"]',
})
// 注册一个测试用户并进控制台
const uname = 'verify' + Date.now().toString().slice(-6)
await page.goto('http://localhost:5173/register', { waitUntil: 'networkidle' })
await page.fill('input[autocomplete="username"]', uname)
await page.fill('input[autocomplete="email"]', uname + '@test.com')
await page.fill('input[autocomplete="new-password"]', 'password123')
await page.locator('input[autocomplete="new-password"]').nth(1).fill('password123')
await page.click('button[type="submit"]')
await page.waitForURL('**/console/dashboard', { timeout: 10000 })
await page.waitForTimeout(1200)
await check('dashboard', 'http://localhost:5173/console/dashboard', {
sidebar: 'aside',
cards: '.mt-6.grid .rounded-lg',
chart: '.divide-y, canvas',
recent: '.divide-y',
navKeys: 'a[href="/console/keys"]',
balance: 'aside .text-mint-400',
})
await check('keys', 'http://localhost:5173/console/keys', {
table: 'table',
createBtn: 'button:has-text("新建密钥")',
})
await check('usage', 'http://localhost:5173/console/usage', {
table: 'table',
filter: 'select',
pagination: 'button:has-text("下一页")',
})
// 创建密钥弹窗
await page.goto('http://localhost:5173/console/keys', { waitUntil: 'networkidle' })
await page.click('button:has-text("新建密钥")')
await page.waitForTimeout(300)
await page.fill('input[placeholder*="本地开发"]', 'verify-key')
await page.click('button:has-text("创建")')
await page.waitForTimeout(600)
const created = await page.locator('code.text-mint-300').count()
console.log(`key-created-modal: oneTimeKey:${created}`)
console.log('console-errors:', errors.length ? errors.slice(0, 5) : 'none')
await browser.close()
}
main().catch((e) => { console.error(e); process.exit(1) })
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import { useAuthStore } from './stores/auth'
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
const auth = useAuthStore()
const router = useRouter()
onMounted(() => {
// 初始化时若已有 token 则拉取用户信息
if (auth.token && !auth.user) auth.fetchMe()
void router
})
</script>
<template>
<router-view />
</template>
+60
View File
@@ -0,0 +1,60 @@
// API 客户端:统一 baseURL、token 注入、401 刷新兜底。
import axios from 'axios'
// 代理端点(/v1/*,Bearer API Key)与管理 API(/api/v1)baseURL 不同,分开实例
const proxyClient = axios.create({ baseURL: '/v1', timeout: 30000 })
const client = axios.create({
baseURL: '/api/v1',
timeout: 20000,
withCredentials: true, // refresh cookie
})
export { proxyClient }
client.interceptors.request.use((config) => {
const token = localStorage.getItem('ot_access')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
let refreshing: Promise<string | null> | null = null
client.interceptors.response.use(
(res) => res,
async (err) => {
const original = err.config
// 401 且非刷新请求本身:尝试刷新一次
if (err.response?.status === 401 && !original?._retried && !original?.url?.includes('/auth/')) {
original._retried = true
refreshing = refreshing ?? refreshAccess()
const token = await refreshing
refreshing = null
if (token) {
localStorage.setItem('ot_access', token)
original.headers.Authorization = `Bearer ${token}`
return client(original)
}
}
return Promise.reject(err)
},
)
async function refreshAccess(): Promise<string | null> {
try {
const { data } = await client.post('/auth/refresh')
return data.data?.access_token ?? null
} catch {
return null
}
}
// 响应壳:{ data: {...} } 或 { error: {...} }
export function unwrap<T>(p: Promise<{ data: { data?: T; error?: { message?: string } } }>): Promise<T> {
return p.then((res) => {
if (res.data.error) throw new Error(res.data.error.message || 'request failed')
return res.data.data as T
})
}
export default client
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+25
View File
@@ -0,0 +1,25 @@
<script setup lang="ts">
// 状态信号灯徽标:healthy/degraded/cooldown/active/revoked/success/error
const props = defineProps<{ tone: string; label?: string }>()
const tones: Record<string, { dot: string; text: string; bg: string }> = {
healthy: { dot: 'bg-mint-400', text: 'text-mint-300', bg: 'bg-mint-400/10' },
success: { dot: 'bg-mint-400', text: 'text-mint-300', bg: 'bg-mint-400/10' },
active: { dot: 'bg-mint-400', text: 'text-mint-300', bg: 'bg-mint-400/10' },
degraded: { dot: 'bg-signal-400', text: 'text-signal-300', bg: 'bg-signal-400/10' },
cooldown: { dot: 'bg-signal-400', text: 'text-signal-300', bg: 'bg-signal-400/10' },
error: { dot: 'bg-ember-400', text: 'text-ember-300', bg: 'bg-ember-400/10' },
revoked: { dot: 'bg-ember-400', text: 'text-ember-300', bg: 'bg-ember-400/10' },
disabled: { dot: 'bg-paper-600', text: 'text-paper-500', bg: 'bg-paper-600/10' },
pending: { dot: 'bg-sky-400', text: 'text-sky-300', bg: 'bg-sky-400/10' },
}
const t = tones[props.tone] ?? tones.disabled
</script>
<template>
<span class="inline-flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs font-medium" :class="[t.bg, t.text]">
<span class="h-1.5 w-1.5 rounded-full" :class="t.dot" />
{{ label ?? tone }}
</span>
</template>
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
withDefaults(
defineProps<{
variant?: 'primary' | 'ghost' | 'danger' | 'outline'
size?: 'sm' | 'md'
type?: 'button' | 'submit'
loading?: boolean
disabled?: boolean
}>(),
{ variant: 'primary', size: 'md', type: 'button', loading: false, disabled: false },
)
</script>
<template>
<button
:type="type"
:disabled="disabled || loading"
class="inline-flex items-center justify-center gap-2 font-medium transition-all select-none
active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none cursor-pointer"
:class="[
size === 'sm' ? 'h-8 px-3 text-[13px] rounded-md' : 'h-10 px-4 text-sm rounded-md',
variant === 'primary' && 'bg-signal-400 text-ink-950 hover:bg-signal-300',
variant === 'outline' && 'border border-ink-600 text-paper-300 hover:border-signal-400 hover:text-signal-300 bg-transparent',
variant === 'ghost' && 'text-paper-500 hover:text-paper-100 hover:bg-ink-800',
variant === 'danger' && 'bg-ember-500/90 text-white hover:bg-ember-400',
]"
>
<span v-if="loading" class="h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
<slot />
</button>
</template>
+35
View File
@@ -0,0 +1,35 @@
<script setup lang="ts">
withDefaults(defineProps<{
label?: string
type?: string
placeholder?: string
modelValue?: string
error?: string
hint?: string
mono?: boolean
autocomplete?: string
}>(), { type: 'text', placeholder: '', modelValue: '' })
defineEmits<{ (e: 'update:modelValue', v: string): void }>()
</script>
<template>
<label class="block">
<span v-if="label" class="mb-1.5 block text-[13px] font-medium text-paper-300">{{ label }}</span>
<input
:type="type"
:value="modelValue"
:placeholder="placeholder"
:autocomplete="autocomplete"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
class="h-10 w-full rounded-md border bg-ink-900 px-3 text-sm text-paper-100 placeholder:text-paper-600
transition-colors focus:border-signal-400 focus:outline-none"
:class="[
mono ? 'font-mono' : '',
error ? 'border-ember-500' : 'border-ink-600 hover:border-ink-700',
]"
/>
<span v-if="error" class="mt-1 block text-xs text-ember-400">{{ error }}</span>
<span v-else-if="hint" class="mt-1 block text-xs text-paper-600">{{ hint }}</span>
</label>
</template>
+35
View File
@@ -0,0 +1,35 @@
<script setup lang="ts">
defineProps<{ title: string; open: boolean; width?: string }>()
defineEmits<{ (e: 'close'): void }>()
</script>
<template>
<Teleport to="body">
<Transition name="modal">
<div v-if="open" class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 pt-[12vh]">
<div class="fixed inset-0 bg-black/60 backdrop-blur-[2px]" @click="$emit('close')" />
<div
class="relative w-full rounded-lg border border-ink-700 bg-ink-900 shadow-2xl"
:class="width ?? 'max-w-md'"
role="dialog"
aria-modal="true"
>
<div class="flex items-center justify-between border-b border-ink-700 px-5 py-3.5">
<h3 class="text-sm font-semibold text-paper-100">{{ title }}</h3>
<button class="text-paper-500 transition-colors hover:text-paper-100 cursor-pointer" @click="$emit('close')" aria-label="关闭">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
</button>
</div>
<div class="px-5 py-4"><slot /></div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.modal-enter-active, .modal-leave-active { transition: opacity 0.15s ease; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
.modal-enter-active .relative, .modal-leave-active .relative { transition: transform 0.15s ease; }
.modal-enter-from .relative, .modal-leave-to .relative { transform: translateY(6px) scale(0.99); }
</style>
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './style.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
+33
View File
@@ -0,0 +1,33 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', name: 'landing', component: () => import('../views/LandingView.vue') },
{ path: '/login', name: 'login', component: () => import('../views/LoginView.vue'), meta: { guest: true } },
{ path: '/register', name: 'register', component: () => import('../views/RegisterView.vue'), meta: { guest: true } },
{
path: '/console',
component: () => import('../views/console/ConsoleLayout.vue'),
meta: { auth: true },
children: [
{ path: '', redirect: '/console/dashboard' },
{ path: 'dashboard', name: 'dashboard', component: () => import('../views/console/DashboardView.vue') },
{ path: 'keys', name: 'keys', component: () => import('../views/console/KeysView.vue') },
{ path: 'usage', name: 'usage', component: () => import('../views/console/UsageView.vue') },
],
},
{ path: '/:pathMatch(.*)*', redirect: '/' },
],
})
router.beforeEach(async (to) => {
const auth = useAuthStore()
if (!auth.ready && auth.token) await auth.fetchMe()
if (to.meta.auth && !auth.isAuthed) return { name: 'login', query: { redirect: to.fullPath } }
if (to.meta.guest && auth.isAuthed) return { name: 'dashboard' }
return true
})
export default router
+61
View File
@@ -0,0 +1,61 @@
import { defineStore } from 'pinia'
import client, { unwrap } from '../api/client'
export interface User {
id: number
username: string
email: string
role: 'user' | 'admin'
balance: number
status: string
created_at: string
}
interface LoginResp {
access_token: string
expires_in: number
user: User
}
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null as User | null,
token: localStorage.getItem('ot_access') ?? '',
ready: false,
}),
getters: {
isAuthed: (s) => !!s.token,
isAdmin: (s) => s.user?.role === 'admin',
},
actions: {
setToken(t: string) {
this.token = t
localStorage.setItem('ot_access', t)
},
async login(username: string, password: string) {
const data = await unwrap<LoginResp>(client.post('/auth/login', { username, password }))
this.setToken(data.access_token)
this.user = data.user
},
async register(username: string, email: string, password: string) {
await unwrap(client.post('/auth/register', { username, email, password }))
},
async fetchMe() {
if (!this.token) return
try {
const data = await unwrap<{ user: User }>(client.get('/auth/me'))
this.user = data.user
} catch {
this.logout()
} finally {
this.ready = true
}
},
async logout() {
try { await client.post('/auth/logout') } catch { /* ignore */ }
this.user = null
this.token = ''
localStorage.removeItem('ot_access')
},
},
})
+122
View File
@@ -0,0 +1,122 @@
@import "tailwindcss";
@import "@fontsource/outfit/400.css";
@import "@fontsource/outfit/500.css";
@import "@fontsource/outfit/600.css";
@import "@fontsource/outfit/700.css";
@import "@fontsource/jetbrains-mono/400.css";
@import "@fontsource/jetbrains-mono/500.css";
@import "@fontsource/jetbrains-mono/600.css";
/* ============================================================
openteam 设计 tokens(taste-skill 产出)
方向:深色优先的开发者控制台 / 信号系统语言
色板:石墨墨底 + 暖白文本 + 单一信号铜色强调(信号灯)
数据一律 mono(JetBrains Mono),UI 用 Outfit
============================================================ */
@theme {
/* 墨色层(背景阶梯) */
--color-ink-950: #0c0d0f;
--color-ink-900: #121417;
--color-ink-850: #16191d;
--color-ink-800: #1c2025;
--color-ink-700: #282d34;
--color-ink-600: #363c45;
/* 纸色层(文本) */
--color-paper-100: #eae8e3;
--color-paper-300: #c8c5bd;
--color-paper-500: #8b909a;
--color-paper-600: #63686f;
/* 信号铜色(唯一强调,信号灯意象) */
--color-signal-200: #f7d9a8;
--color-signal-300: #f0be6d;
--color-signal-400: #e5a13c;
--color-signal-500: #c9842a;
--color-signal-600: #a56a1f;
/* 语义色 */
--color-mint-300: #7fd0ac;
--color-mint-400: #4cb58a;
--color-mint-500: #33946f;
--color-ember-300: #ec8a80;
--color-ember-400: #d9685c;
--color-ember-500: #b34c42;
--color-sky-300: #93bce4;
--color-sky-400: #6e9fd8;
--font-sans: "Outfit", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, "SF Mono", monospace;
/* 圆角:全局统一 6px(工具类,克制) */
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 10px;
}
/* 亮色主题(保留:data-theme="light" 时切换,默认深色优先) */
[data-theme="light"] {
--color-ink-950: #f4f3f0;
--color-ink-900: #ffffff;
--color-ink-850: #faf9f6;
--color-ink-800: #f0efeb;
--color-ink-700: #e2e0da;
--color-ink-600: #cfccc4;
--color-paper-100: #1d2024;
--color-paper-300: #3a3f46;
--color-paper-500: #5f6670;
--color-paper-600: #8a9099;
--color-signal-400: #b3741c;
--color-signal-500: #9a6116;
--color-mint-400: #1f8a61;
--color-ember-400: #c24b41;
--color-sky-400: #3f78b8;
}
html {
color-scheme: dark;
}
[data-theme="light"] {
color-scheme: light;
}
body {
@apply bg-ink-950 text-paper-100 font-sans antialiased;
font-feature-settings: "ss01" on, "cv05" on;
}
/* 数字统一用 tabular 对齐(数据密集场景) */
.num {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
}
/* 聚焦可见性:键盘可达性 */
:focus-visible {
outline: 2px solid var(--color-signal-400);
outline-offset: 2px;
}
/* 滚动条克制化 */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: var(--color-ink-700);
border-radius: 6px;
border: 2px solid var(--color-ink-950);
}
::-webkit-scrollbar-track {
background: transparent;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
+92
View File
@@ -0,0 +1,92 @@
<script setup lang="ts">
import { useAuthStore } from '../stores/auth'
import { computed } from 'vue'
const auth = useAuthStore()
const primaryAction = computed(() => (auth.isAuthed ? '/console/dashboard' : '/register'))
const protocols = [
{ name: 'POST /v1/chat/completions', desc: 'OpenAI Chat Completions · 流式 + 工具调用' },
{ name: 'POST /v1/responses', desc: 'OpenAI Responses API · 新一代生态' },
{ name: 'POST /v1/messages', desc: 'Anthropic Messages · Claude 原生格式' },
{ name: 'GET /v1/models', desc: 'OpenAI 风格模型列表' },
]
</script>
<template>
<div class="min-h-[100dvh] bg-ink-950 text-paper-100">
<!-- 顶部导航 -->
<header class="sticky top-0 z-40 border-b border-ink-800 bg-ink-950/90 backdrop-blur">
<div class="mx-auto flex h-16 max-w-6xl items-center justify-between px-6">
<div class="flex items-center gap-2.5">
<span class="flex h-7 w-7 items-center justify-center rounded-md bg-signal-400">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M2 8h3l2-5 3 10 2-5h2" stroke="#0c0d0f" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
<span class="text-[15px] font-semibold tracking-tight">openteam</span>
<span class="ml-1 rounded border border-ink-700 px-1.5 py-px font-mono text-[10px] text-paper-500">relay</span>
</div>
<nav class="flex items-center gap-2">
<a v-if="!auth.isAuthed" href="/login" class="rounded-md px-3 py-2 text-sm text-paper-500 transition-colors hover:text-paper-100">登录</a>
<a :href="primaryAction" class="rounded-md bg-signal-400 px-4 py-2 text-sm font-medium text-ink-950 transition-colors hover:bg-signal-300">开始使用</a>
</nav>
</div>
</header>
<main>
<!-- Hero:左对齐,信号路径示意 -->
<section class="mx-auto grid max-w-6xl grid-cols-1 items-center gap-14 px-6 pt-16 pb-20 lg:grid-cols-[1.1fr_0.9fr]">
<div>
<p class="font-mono text-xs uppercase tracking-[0.2em] text-signal-400">self-hosted llm relay</p>
<h1 class="mt-4 text-4xl leading-[1.05] font-bold tracking-tight md:text-5xl">
一个 Key,<br />接入全部模型
</h1>
<p class="mt-5 max-w-[52ch] text-base leading-relaxed text-paper-500">
自托管 LLM API 中转网关。统一 OpenAI 与 Anthropic 协议入口,背后对接任意上游渠道,用量计费一目了然。
</p>
<div class="mt-8 flex items-center gap-3">
<a href="/register" class="inline-flex h-10 items-center rounded-md bg-signal-400 px-5 text-sm font-medium text-ink-950 transition-all hover:bg-signal-300 active:scale-[0.98]">立即开始</a>
<a href="#protocols" class="inline-flex h-10 items-center rounded-md border border-ink-600 px-5 text-sm text-paper-300 transition-colors hover:border-signal-400 hover:text-signal-300">查看端点</a>
</div>
</div>
<!-- 信号路径:client → gateway → upstream -->
<div class="rounded-lg border border-ink-700 bg-ink-900 p-5 font-mono text-[13px]">
<div class="flex items-center gap-3 pb-4">
<span class="h-1.5 w-1.5 animate-pulse rounded-full bg-mint-400" />
<span class="text-xs text-paper-500">request path · live</span>
</div>
<div class="space-y-2">
<div class="rounded-md border border-ink-700 bg-ink-850 px-3 py-2 text-paper-300">client <span class="text-signal-400">──▶</span> <span class="text-paper-500">/v1/chat/completions</span></div>
<div class="rounded-md border border-ink-700 bg-ink-850 px-3 py-2 text-paper-300">gateway <span class="text-signal-400">──▶</span> <span class="text-paper-500">auth · quota · route</span></div>
<div class="rounded-md border border-ink-700 bg-ink-850 px-3 py-2 text-paper-300">upstream <span class="text-mint-400">◀──</span> <span class="text-paper-500">openai / anthropic</span></div>
<div class="rounded-md border border-ink-700 bg-ink-850 px-3 py-2 text-paper-300">billing <span class="text-mint-400">──▶</span> <span class="num text-paper-500">usage · cost · ledger</span></div>
</div>
</div>
</section>
<!-- 协议端点 -->
<section id="protocols" class="border-t border-ink-800 bg-ink-900/50">
<div class="mx-auto max-w-6xl px-6 py-16">
<h2 class="text-xl font-semibold tracking-tight">三套协议,一个入口</h2>
<p class="mt-2 max-w-[60ch] text-sm leading-relaxed text-paper-500">
OpenAI 与 Anthropic 生态的 SDK 与客户端无需改动,直接指向本网关。协议间自动互转。
</p>
<div class="mt-8 grid grid-cols-1 gap-3 md:grid-cols-2">
<div v-for="p in protocols" :key="p.name" class="rounded-lg border border-ink-700 bg-ink-900 p-4">
<code class="font-mono text-[13px] text-signal-300">{{ p.name }}</code>
<p class="mt-1.5 text-[13px] text-paper-500">{{ p.desc }}</p>
</div>
</div>
</div>
</section>
<!-- 页脚 -->
<footer class="border-t border-ink-800">
<div class="mx-auto flex max-w-6xl items-center justify-between px-6 py-6 text-xs text-paper-600">
<span>openteam · 自托管 LLM 中转网关</span>
<span class="font-mono">v0.1 · M1</span>
</div>
</footer>
</main>
</div>
</template>
+61
View File
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import Input from '../components/ui/Input.vue'
import Button from '../components/ui/Button.vue'
const auth = useAuthStore()
const router = useRouter()
const route = useRoute()
const username = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
async function submit() {
error.value = ''
loading.value = true
try {
await auth.login(username.value, password.value)
const redirect = (route.query.redirect as string) || '/console/dashboard'
router.push(redirect)
} catch (e: any) {
error.value = e.response?.data?.error?.message || '登录失败,请检查用户名与密码'
} finally {
loading.value = false
}
}
</script>
<template>
<div class="flex min-h-[100dvh] items-center justify-center bg-ink-950 px-4">
<div class="w-full max-w-sm">
<div class="mb-8 flex items-center gap-2.5">
<span class="flex h-7 w-7 items-center justify-center rounded-md bg-signal-400">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M2 8h3l2-5 3 10 2-5h2" stroke="#0c0d0f" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
<span class="text-[15px] font-semibold tracking-tight">openteam</span>
</div>
<h1 class="text-xl font-semibold tracking-tight">登录控制台</h1>
<p class="mt-1 text-sm text-paper-500">管理密钥、查看用量与余额</p>
<form class="mt-8 space-y-4" @submit.prevent="submit">
<Input v-model="username" label="用户名或邮箱" placeholder="alice" autocomplete="username" />
<Input v-model="password" label="密码" type="password" placeholder="••••••••" autocomplete="current-password" />
<p v-if="error" class="rounded-md border border-ember-500/40 bg-ember-500/10 px-3 py-2 text-[13px] text-ember-300">{{ error }}</p>
<Button type="submit" class="w-full" :loading="loading">登录</Button>
</form>
<p class="mt-6 text-center text-[13px] text-paper-500">
还没有账号?
<router-link to="/register" class="text-signal-300 hover:text-signal-200">注册</router-link>
</p>
<p class="mt-4 text-center">
<router-link to="/" class="text-xs text-paper-600 hover:text-paper-500">← 返回首页</router-link>
</p>
</div>
</div>
</template>
+69
View File
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import Input from '../components/ui/Input.vue'
import Button from '../components/ui/Button.vue'
const auth = useAuthStore()
const router = useRouter()
const username = ref('')
const email = ref('')
const password = ref('')
const confirm = ref('')
const error = ref('')
const loading = ref(false)
async function submit() {
error.value = ''
if (password.value !== confirm.value) {
error.value = '两次输入的密码不一致'
return
}
if (password.value.length < 8) {
error.value = '密码至少 8 位'
return
}
loading.value = true
try {
await auth.register(username.value, email.value, password.value)
await auth.login(username.value, password.value)
router.push('/console/dashboard')
} catch (e: any) {
error.value = e.response?.data?.error?.message || '注册失败,请稍后重试'
} finally {
loading.value = false
}
}
</script>
<template>
<div class="flex min-h-[100dvh] items-center justify-center bg-ink-950 px-4">
<div class="w-full max-w-sm">
<div class="mb-8 flex items-center gap-2.5">
<span class="flex h-7 w-7 items-center justify-center rounded-md bg-signal-400">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M2 8h3l2-5 3 10 2-5h2" stroke="#0c0d0f" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
<span class="text-[15px] font-semibold tracking-tight">openteam</span>
</div>
<h1 class="text-xl font-semibold tracking-tight">创建账号</h1>
<p class="mt-1 text-sm text-paper-500">注册即赠体验额度,一个 Key 接入全部模型</p>
<form class="mt-8 space-y-4" @submit.prevent="submit">
<Input v-model="username" label="用户名" placeholder="alice" autocomplete="username" />
<Input v-model="email" label="邮箱" type="email" placeholder="alice@example.com" autocomplete="email" />
<Input v-model="password" label="密码" type="password" placeholder="至少 8 位" autocomplete="new-password" hint="使用 argon2id 加密存储" />
<Input v-model="confirm" label="确认密码" type="password" placeholder="再次输入" autocomplete="new-password" />
<p v-if="error" class="rounded-md border border-ember-500/40 bg-ember-500/10 px-3 py-2 text-[13px] text-ember-300">{{ error }}</p>
<Button type="submit" class="w-full" :loading="loading">注册</Button>
</form>
<p class="mt-6 text-center text-[13px] text-paper-500">
已有账号?
<router-link to="/login" class="text-signal-300 hover:text-signal-200">登录</router-link>
</p>
</div>
</div>
</template>
+69
View File
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { useAuthStore } from '../../stores/auth'
import { useRouter } from 'vue-router'
import { computed } from 'vue'
const auth = useAuthStore()
const router = useRouter()
const nav = [
{ to: '/console/dashboard', label: '仪表盘', icon: 'M3 12l9-9 9 9M5 10v10h5v-6h4v6h5V10' },
{ to: '/console/keys', label: 'API 密钥', icon: 'M15 7a4 4 0 11-8 0 4 4 0 018 0zM3 21v-1a6 6 0 0112 0v1' },
{ to: '/console/usage', label: '用量明细', icon: 'M4 20V10M10 20V4M16 20v-7M22 20H2' },
]
const balanceFmt = computed(() =>
auth.user ? auth.user.balance.toFixed(4) : '—',
)
</script>
<template>
<div class="flex min-h-[100dvh] bg-ink-950">
<!-- 侧边栏 -->
<aside class="fixed inset-y-0 left-0 z-30 flex w-56 flex-col border-r border-ink-800 bg-ink-900/60">
<div class="flex h-16 items-center gap-2.5 border-b border-ink-800 px-5">
<span class="flex h-7 w-7 items-center justify-center rounded-md bg-signal-400">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M2 8h3l2-5 3 10 2-5h2" stroke="#0c0d0f" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
<span class="text-[15px] font-semibold tracking-tight">openteam</span>
</div>
<nav class="flex-1 space-y-0.5 px-3 py-4">
<router-link
v-for="item in nav"
:key="item.to"
:to="item.to"
class="flex items-center gap-3 rounded-md px-3 py-2 text-[13.5px] text-paper-500 transition-colors hover:bg-ink-800 hover:text-paper-100"
active-class="bg-ink-800 text-signal-300! font-medium"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path :d="item.icon" /></svg>
{{ item.label }}
</router-link>
</nav>
<div class="border-t border-ink-800 px-3 py-4">
<div class="flex items-center justify-between rounded-md bg-ink-850 px-3 py-2.5">
<div class="min-w-0">
<p class="truncate text-[13px] font-medium text-paper-100">{{ auth.user?.username }}</p>
<p class="text-xs text-paper-600">{{ auth.isAdmin ? 'admin' : 'user' }}</p>
</div>
<span class="num text-[13px] font-medium text-mint-400">${{ balanceFmt }}</span>
</div>
<button
class="mt-2 flex w-full items-center justify-center gap-2 rounded-md px-3 py-2 text-[13px] text-paper-500 transition-colors hover:bg-ink-800 hover:text-ember-300 cursor-pointer"
@click="auth.logout().then(() => router.push('/'))"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9" /></svg>
退出登录
</button>
</div>
</aside>
<!-- 主区域 -->
<div class="ml-56 flex-1">
<div class="mx-auto max-w-6xl px-8 py-8">
<router-view />
</div>
</div>
</div>
</template>
+141
View File
@@ -0,0 +1,141 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { BarChart } from 'echarts/charts'
import { GridComponent, TooltipComponent } from 'echarts/components'
import VChart from 'vue-echarts'
import client, { unwrap } from '../../api/client'
import Badge from '../../components/ui/Badge.vue'
use([CanvasRenderer, BarChart, GridComponent, TooltipComponent])
interface BalanceInfo { balance: number; spent_last_30d: number; today: { requests: number; tokens: number; cost: number }; models_available: number }
interface UsagePoint { date: string; requests: number; tokens: number; cost: number }
interface LogItem { id: number; model: string; protocol: string; input_tokens: number; output_tokens: number; cost: number; latency_ms: number; status: string; created_at: string }
const balance = ref<BalanceInfo | null>(null)
const stats = ref<UsagePoint[]>([])
const recentLogs = ref<LogItem[]>([])
const error = ref('')
const todayCost = computed(() => balance.value?.today.cost ?? 0)
const chartOption = computed(() => ({
grid: { left: 8, right: 8, top: 24, bottom: 0, containLabel: true },
tooltip: {
trigger: 'axis',
backgroundColor: '#16191d',
borderColor: '#282d34',
textStyle: { color: '#eae8e3', fontSize: 12, fontFamily: 'JetBrains Mono' },
},
xAxis: {
type: 'category',
data: stats.value.map((s) => s.date.slice(5)),
axisLine: { lineStyle: { color: '#282d34' } },
axisLabel: { color: '#63686f', fontFamily: 'JetBrains Mono', fontSize: 11 },
axisTick: { show: false },
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { color: '#1c2025' } },
axisLabel: { color: '#63686f', fontFamily: 'JetBrains Mono', fontSize: 11 },
},
series: [
{
name: '请求数',
type: 'bar',
data: stats.value.map((s) => s.requests),
itemStyle: { color: '#e5a13c', borderRadius: [3, 3, 0, 0] },
barMaxWidth: 22,
},
],
}))
onMounted(async () => {
try {
const [b, s, logs] = await Promise.all([
unwrap<BalanceInfo>(client.get('/user/balance')),
unwrap<{ items: UsagePoint[] }>(client.get('/usage/stats', { params: { group: 'day' } })),
unwrap<{ items: LogItem[] }>(client.get('/usage/logs', { params: { page_size: 8 } })),
])
balance.value = b
stats.value = s.items ?? []
recentLogs.value = logs.items ?? []
} catch (e: any) {
error.value = e.message || '加载失败'
}
})
const fmtCost = (n: number) => (n >= 0.01 ? n.toFixed(4) : n.toExponential(2))
</script>
<template>
<div>
<div class="flex items-end justify-between">
<div>
<h1 class="text-xl font-semibold tracking-tight">仪表盘</h1>
<p class="mt-1 text-[13px] text-paper-500">今日与近 30 日用量总览</p>
</div>
<router-link to="/console/keys" class="rounded-md bg-signal-400 px-4 py-2 text-sm font-medium text-ink-950 transition-colors hover:bg-signal-300">新建密钥</router-link>
</div>
<p v-if="error" class="mt-4 rounded-md border border-ember-500/40 bg-ember-500/10 px-3 py-2 text-[13px] text-ember-300">{{ error }}</p>
<!-- 指标行 -->
<div class="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-4">
<div class="rounded-lg border border-ink-700 bg-ink-900 p-4">
<p class="text-xs text-paper-500">余额</p>
<p class="num mt-1.5 text-2xl font-semibold text-mint-400">${{ balance?.balance.toFixed(4) ?? '—' }}</p>
<p class="num mt-1 text-[11px] text-paper-600">30 日消耗 ${{ fmtCost(balance?.spent_last_30d ?? 0) }}</p>
</div>
<div class="rounded-lg border border-ink-700 bg-ink-900 p-4">
<p class="text-xs text-paper-500">今日请求</p>
<p class="num mt-1.5 text-2xl font-semibold">{{ balance?.today.requests ?? '—' }}</p>
<p class="num mt-1 text-[11px] text-paper-600">{{ balance?.today.tokens ?? 0 }} tokens</p>
</div>
<div class="rounded-lg border border-ink-700 bg-ink-900 p-4">
<p class="text-xs text-paper-500">今日成本</p>
<p class="num mt-1.5 text-2xl font-semibold text-signal-300">${{ todayCost.toFixed(6) }}</p>
<p class="num mt-1 text-[11px] text-paper-600">按量计费 · USD</p>
</div>
<div class="rounded-lg border border-ink-700 bg-ink-900 p-4">
<p class="text-xs text-paper-500">可用模型</p>
<p class="num mt-1.5 text-2xl font-semibold">{{ balance?.models_available ?? '—' }}</p>
<p class="mt-1 text-[11px] text-paper-600">GET /v1/models 查看</p>
</div>
</div>
<!-- 图表 + 最近请求 -->
<div class="mt-6 grid grid-cols-1 gap-3 lg:grid-cols-[1.4fr_1fr]">
<div class="rounded-lg border border-ink-700 bg-ink-900 p-4">
<div class="mb-2 flex items-center justify-between">
<h2 class="text-sm font-medium text-paper-300">近 30 日请求</h2>
<span class="font-mono text-[11px] text-paper-600">usage/stats?group=day</span>
</div>
<VChart v-if="stats.length" class="h-56" :option="chartOption" autoresize />
<div v-else class="flex h-56 items-center justify-center text-[13px] text-paper-600">暂无数据,发起第一次请求后这里会出现图表</div>
</div>
<div class="rounded-lg border border-ink-700 bg-ink-900 p-4">
<div class="mb-2 flex items-center justify-between">
<h2 class="text-sm font-medium text-paper-300">最近请求</h2>
<router-link to="/console/usage" class="text-xs text-signal-300 hover:text-signal-200">全部 →</router-link>
</div>
<div v-if="recentLogs.length" class="divide-y divide-ink-800">
<div v-for="l in recentLogs" :key="l.id" class="flex items-center justify-between gap-3 py-2.5">
<div class="min-w-0">
<p class="truncate font-mono text-[12.5px] text-paper-100">{{ l.model }}</p>
<p class="num mt-0.5 text-[11px] text-paper-600">{{ l.protocol }} · {{ l.input_tokens }}/{{ l.output_tokens }} tok · {{ l.latency_ms }}ms</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<span class="num text-[12.5px] text-paper-300">${{ fmtCost(l.cost) }}</span>
<Badge :tone="l.status === 'success' ? 'success' : 'error'" />
</div>
</div>
</div>
<div v-else class="flex h-48 items-center justify-center text-[13px] text-paper-600">还没有请求记录</div>
</div>
</div>
</div>
</template>
+186
View File
@@ -0,0 +1,186 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import client, { unwrap } from '../../api/client'
import Badge from '../../components/ui/Badge.vue'
import Button from '../../components/ui/Button.vue'
import Modal from '../../components/ui/Modal.vue'
import Input from '../../components/ui/Input.vue'
interface APIKey {
id: number
name: string
key_prefix: string
quota_tokens_per_day: number | null
quota_requests_per_day: number | null
status: string
last_used_at: string | null
created_at: string
}
const keys = ref<APIKey[]>([])
const loading = ref(false)
const error = ref('')
// 创建
const showCreate = ref(false)
const newName = ref('')
const creating = ref(false)
const createdKey = ref('')
const createError = ref('')
// 吊销
const revokeTarget = ref<APIKey | null>(null)
const revoking = ref(false)
async function load() {
loading.value = true
try {
const data = await unwrap<{ items: APIKey[] }>(client.get('/keys'))
keys.value = data.items
} catch (e: any) {
error.value = e.message || '加载失败'
} finally {
loading.value = false
}
}
async function create() {
createError.value = ''
if (!newName.value.trim()) {
createError.value = '请填写密钥名称'
return
}
creating.value = true
try {
const data = await unwrap<{ key: string }>(client.post('/keys', { name: newName.value.trim() }))
createdKey.value = data.key
newName.value = ''
await load()
} catch (e: any) {
createError.value = e.response?.data?.error?.message || '创建失败'
} finally {
creating.value = false
}
}
async function revoke() {
if (!revokeTarget.value) return
revoking.value = true
try {
await unwrap(client.delete(`/keys/${revokeTarget.value.id}`))
revokeTarget.value = null
await load()
} catch (e: any) {
error.value = e.message || '吊销失败'
} finally {
revoking.value = false
}
}
async function copyKey(text: string) {
try {
await navigator.clipboard.writeText(text)
} catch {
const ta = document.createElement('textarea')
ta.value = text
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
ta.remove()
}
}
onMounted(load)
const fmtDate = (s: string | null) => (s ? new Date(s).toLocaleString('zh-CN', { hour12: false }) : '从未使用')
</script>
<template>
<div>
<div class="flex items-end justify-between">
<div>
<h1 class="text-xl font-semibold tracking-tight">API 密钥</h1>
<p class="mt-1 text-[13px] text-paper-500">密钥仅以 SHA-256 哈希存储,明文只在创建时展示一次</p>
</div>
<Button @click="showCreate = true">新建密钥</Button>
</div>
<p v-if="error" class="mt-4 rounded-md border border-ember-500/40 bg-ember-500/10 px-3 py-2 text-[13px] text-ember-300">{{ error }}</p>
<div class="mt-6 overflow-hidden rounded-lg border border-ink-700">
<table class="w-full text-left text-[13px]">
<thead>
<tr class="border-b border-ink-700 bg-ink-900 text-xs text-paper-500">
<th class="px-4 py-3 font-medium">名称</th>
<th class="px-4 py-3 font-medium">密钥前缀</th>
<th class="px-4 py-3 font-medium">每日限额</th>
<th class="px-4 py-3 font-medium">最近使用</th>
<th class="px-4 py-3 font-medium">状态</th>
<th class="px-4 py-3 text-right font-medium">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-ink-800 bg-ink-900/50">
<tr v-for="k in keys" :key="k.id" class="transition-colors hover:bg-ink-850">
<td class="px-4 py-3 font-medium text-paper-100">{{ k.name }}</td>
<td class="px-4 py-3"><code class="font-mono text-[12.5px] text-signal-300">{{ k.key_prefix }}…</code></td>
<td class="num px-4 py-3 text-paper-500">
{{ k.quota_tokens_per_day ? `${(k.quota_tokens_per_day / 1000).toFixed(0)}k tok` : '—' }}
/ {{ k.quota_requests_per_day ? `${k.quota_requests_per_day} req` : '—' }}
</td>
<td class="num px-4 py-3 text-paper-500">{{ fmtDate(k.last_used_at) }}</td>
<td class="px-4 py-3"><Badge :tone="k.status" /></td>
<td class="px-4 py-3 text-right">
<button
v-if="k.status === 'active'"
class="text-xs text-ember-400 transition-colors hover:text-ember-300 cursor-pointer"
@click="revokeTarget = k"
>吊销</button>
<span v-else class="text-xs text-paper-600">已吊销</span>
</td>
</tr>
<tr v-if="!keys.length">
<td colspan="6" class="px-4 py-12 text-center text-[13px] text-paper-600">
还没有密钥 — 点击右上角「新建密钥」创建第一个
</td>
</tr>
</tbody>
</table>
</div>
<!-- 创建模态 -->
<Modal :open="showCreate" title="新建 API 密钥" @close="showCreate = false">
<template v-if="!createdKey">
<Input v-model="newName" label="密钥名称" placeholder="例如:本地开发" hint="用于在用量明细中区分来源" />
<p v-if="createError" class="mt-3 rounded-md border border-ember-500/40 bg-ember-500/10 px-3 py-2 text-[13px] text-ember-300">{{ createError }}</p>
<div class="mt-5 flex justify-end gap-2">
<Button variant="ghost" @click="showCreate = false">取消</Button>
<Button :loading="creating" @click="create">创建</Button>
</div>
</template>
<template v-else>
<p class="text-[13px] leading-relaxed text-paper-500">密钥已生成。出于安全考虑,<span class="text-paper-300">明文只会展示这一次</span>,请立即复制保存。</p>
<div class="mt-3 flex items-center gap-2 rounded-md border border-mint-500/40 bg-mint-400/10 px-3 py-2.5">
<code class="flex-1 break-all font-mono text-[12.5px] text-mint-300">{{ createdKey }}</code>
<button
class="shrink-0 text-xs text-mint-300 transition-colors hover:text-mint-400 cursor-pointer"
@click="copyKey(createdKey)"
>复制</button>
</div>
<div class="mt-5 flex justify-end">
<Button @click="showCreate = false; createdKey = ''">完成</Button>
</div>
</template>
</Modal>
<!-- 吊销确认 -->
<Modal :open="!!revokeTarget" title="吊销密钥" @close="revokeTarget = null">
<p class="text-[13px] leading-relaxed text-paper-500">
吊销后 <code class="font-mono text-paper-300">{{ revokeTarget?.key_prefix }}…</code> 将立即失效,使用它的请求会返回 401。此操作不可撤销。
</p>
<div class="mt-5 flex justify-end gap-2">
<Button variant="ghost" @click="revokeTarget = null">取消</Button>
<Button variant="danger" :loading="revoking" @click="revoke">确认吊销</Button>
</div>
</Modal>
</div>
</template>
+131
View File
@@ -0,0 +1,131 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import client, { unwrap } from '../../api/client'
import Badge from '../../components/ui/Badge.vue'
interface LogItem {
id: number
request_id: string
model: string
protocol: string
input_tokens: number
output_tokens: number
cache_read_tokens: number
cost: number
latency_ms: number
status: string
created_at: string
}
const logs = ref<LogItem[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 20
const modelFilter = ref('')
const models = ref<string[]>([])
const loading = ref(false)
const pages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
async function load() {
loading.value = true
try {
const data = await unwrap<{ items: LogItem[]; total: number }>(
client.get('/usage/logs', { params: { page: page.value, page_size: pageSize, model: modelFilter.value || undefined } }),
)
logs.value = data.items
total.value = data.total
if (!modelFilter.value) {
const m = await unwrap<{ items: string[] }>(client.get('/usage/logs', { params: { page_size: 1 } })).catch(() => ({ items: [] }))
void m
}
} catch {
// ignore
} finally {
loading.value = false
}
}
async function loadModels() {
try {
const data = await unwrap<{ items: string[] }>(client.get('/user/models'))
models.value = data.items ?? []
} catch { /* ignore */ }
}
onMounted(() => {
load()
loadModels()
})
const fmtCost = (n: number) => (n >= 0.01 ? n.toFixed(4) : n.toExponential(2))
const fmtDate = (s: string) => new Date(s).toLocaleString('zh-CN', { hour12: false })
</script>
<template>
<div>
<div class="flex items-end justify-between">
<div>
<h1 class="text-xl font-semibold tracking-tight">用量明细</h1>
<p class="mt-1 text-[13px] text-paper-500">请求级记录 · 按当时价格入账</p>
</div>
<select
v-model="modelFilter"
class="h-9 rounded-md border border-ink-600 bg-ink-900 px-3 text-[13px] text-paper-300 focus:border-signal-400 focus:outline-none"
@change="page = 1; load()"
>
<option value="">全部模型</option>
<option v-for="m in models" :key="m" :value="m">{{ m }}</option>
</select>
</div>
<div class="mt-6 overflow-hidden rounded-lg border border-ink-700">
<table class="w-full text-left text-[13px]">
<thead>
<tr class="border-b border-ink-700 bg-ink-900 text-xs text-paper-500">
<th class="px-4 py-3 font-medium">时间</th>
<th class="px-4 py-3 font-medium">模型</th>
<th class="px-4 py-3 font-medium">协议</th>
<th class="px-4 py-3 text-right font-medium">输入 tok</th>
<th class="px-4 py-3 text-right font-medium">输出 tok</th>
<th class="px-4 py-3 text-right font-medium">成本</th>
<th class="px-4 py-3 text-right font-medium">耗时</th>
<th class="px-4 py-3 font-medium">状态</th>
</tr>
</thead>
<tbody class="divide-y divide-ink-800 bg-ink-900/50">
<tr v-for="l in logs" :key="l.id" class="transition-colors hover:bg-ink-850">
<td class="num px-4 py-3 text-paper-500">{{ fmtDate(l.created_at) }}</td>
<td class="px-4 py-3"><code class="font-mono text-[12.5px] text-paper-100">{{ l.model }}</code></td>
<td class="px-4 py-3 font-mono text-[12px] text-paper-500">{{ l.protocol }}</td>
<td class="num px-4 py-3 text-right text-paper-300">{{ l.input_tokens }}</td>
<td class="num px-4 py-3 text-right text-paper-300">{{ l.output_tokens }}</td>
<td class="num px-4 py-3 text-right text-signal-300">${{ fmtCost(l.cost) }}</td>
<td class="num px-4 py-3 text-right text-paper-500">{{ l.latency_ms }}ms</td>
<td class="px-4 py-3"><Badge :tone="l.status === 'success' ? 'success' : 'error'" /></td>
</tr>
<tr v-if="!logs.length">
<td colspan="8" class="px-4 py-12 text-center text-[13px] text-paper-600">{{ loading ? '加载中…' : '暂无用量记录' }}</td>
</tr>
</tbody>
</table>
</div>
<div class="mt-4 flex items-center justify-between">
<p class="num text-xs text-paper-600">共 {{ total }} 条</p>
<div class="flex items-center gap-1.5">
<button
class="rounded-md border border-ink-600 px-3 py-1.5 text-xs text-paper-300 transition-colors hover:border-signal-400 disabled:opacity-40 cursor-pointer disabled:cursor-default"
:disabled="page <= 1"
@click="page--; load()"
>上一页</button>
<span class="num px-2 text-xs text-paper-500">{{ page }} / {{ pages }}</span>
<button
class="rounded-md border border-ink-600 px-3 py-1.5 text-xs text-paper-300 transition-colors hover:border-signal-400 disabled:opacity-40 cursor-pointer disabled:cursor-default"
:disabled="page >= pages"
@click="page++; load()"
>下一页</button>
</div>
</div>
</div>
</template>
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue(), tailwindcss()],
server: {
port: 5173,
proxy: {
// 开发代理到 Go API
'/api': 'http://localhost:8080',
'/v1': 'http://localhost:8080',
},
},
})