commit 360c6b33a6fb75c2ea9e7e8626200f65ac2552df Author: Sakurasan <26715255+Sakurasan@users.noreply.github.com> Date: Sat Aug 15 13:10:47 2026 +0800 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 上游, 端到端验证通过 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0244045 --- /dev/null +++ b/.env.example @@ -0,0 +1,35 @@ +# openteam 服务配置示例 —— 复制为 .env 后修改 +# 所有项均可用环境变量 OT_ 覆盖(点号转下划线,如 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a2945ef --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f62c079 --- /dev/null +++ b/Makefile @@ -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 diff --git a/PLANNING.md b/PLANNING.md new file mode 100644 index 0000000..eef2bfa --- /dev/null +++ b/PLANNING.md @@ -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)。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..7c88223 --- /dev/null +++ b/README.md @@ -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 打磨上线(限流/监控/审计/全站复查) | ⏳ | diff --git a/deploy/Dockerfile.api b/deploy/Dockerfile.api new file mode 100644 index 0000000..c3af716 --- /dev/null +++ b/deploy/Dockerfile.api @@ -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"] diff --git a/deploy/Dockerfile.web b/deploy/Dockerfile.web new file mode 100644 index 0000000..491c0c6 --- /dev/null +++ b/deploy/Dockerfile.web @@ -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 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..64d254e --- /dev/null +++ b/deploy/docker-compose.yml @@ -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: diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..e183d5c --- /dev/null +++ b/deploy/nginx.conf @@ -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; +} diff --git a/scripts/mockupstream/go.mod b/scripts/mockupstream/go.mod new file mode 100644 index 0000000..60c057c --- /dev/null +++ b/scripts/mockupstream/go.mod @@ -0,0 +1,3 @@ +module mockupstream + +go 1.26.5 diff --git a/scripts/mockupstream/main.go b/scripts/mockupstream/main.go new file mode 100644 index 0000000..ebdf8bc --- /dev/null +++ b/scripts/mockupstream/main.go @@ -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) + } +} diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go new file mode 100644 index 0000000..45885a4 --- /dev/null +++ b/server/cmd/server/main.go @@ -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:]) +} diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..c4e96e1 --- /dev/null +++ b/server/go.mod @@ -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 +) diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 0000000..7de6646 --- /dev/null +++ b/server/go.sum @@ -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= diff --git a/server/internal/api/admin.go b/server/internal/api/admin.go new file mode 100644 index 0000000..7cfb430 --- /dev/null +++ b/server/internal/api/admin.go @@ -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}) +} diff --git a/server/internal/api/auth.go b/server/internal/api/auth.go new file mode 100644 index 0000000..af07717 --- /dev/null +++ b/server/internal/api/auth.go @@ -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 diff --git a/server/internal/api/keys.go b/server/internal/api/keys.go new file mode 100644 index 0000000..8248c67 --- /dev/null +++ b/server/internal/api/keys.go @@ -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}) +} diff --git a/server/internal/api/middleware/middleware.go b/server/internal/api/middleware/middleware.go new file mode 100644 index 0000000..37d465e --- /dev/null +++ b/server/internal/api/middleware/middleware.go @@ -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 。 +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() + } +} diff --git a/server/internal/api/router.go b/server/internal/api/router.go new file mode 100644 index 0000000..0b9cfc9 --- /dev/null +++ b/server/internal/api/router.go @@ -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 +} diff --git a/server/internal/api/user.go b/server/internal/api/user.go new file mode 100644 index 0000000..36240d7 --- /dev/null +++ b/server/internal/api/user.go @@ -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}) +} diff --git a/server/internal/app/app.go b/server/internal/app/app.go new file mode 100644 index 0000000..e4da92b --- /dev/null +++ b/server/internal/app/app.go @@ -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 +} diff --git a/server/internal/app/env.go b/server/internal/app/env.go new file mode 100644 index 0000000..35c745c --- /dev/null +++ b/server/internal/app/env.go @@ -0,0 +1,5 @@ +package app + +import "os" + +func envLookup(key string) string { return os.Getenv(key) } diff --git a/server/internal/channel/channel.go b/server/internal/channel/channel.go new file mode 100644 index 0000000..308e724 --- /dev/null +++ b/server/internal/channel/channel.go @@ -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 +} diff --git a/server/internal/config/config.go b/server/internal/config/config.go new file mode 100644 index 0000000..3ca3b2b --- /dev/null +++ b/server/internal/config/config.go @@ -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 +} diff --git a/server/internal/pkg/apikey/apikey.go b/server/internal/pkg/apikey/apikey.go new file mode 100644 index 0000000..c98cb03 --- /dev/null +++ b/server/internal/pkg/apikey/apikey.go @@ -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 diff --git a/server/internal/pkg/apikey/apikey_test.go b/server/internal/pkg/apikey/apikey_test.go new file mode 100644 index 0000000..57a2d55 --- /dev/null +++ b/server/internal/pkg/apikey/apikey_test.go @@ -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") + } +} diff --git a/server/internal/pkg/crypto/crypto.go b/server/internal/pkg/crypto/crypto.go new file mode 100644 index 0000000..21b9af5 --- /dev/null +++ b/server/internal/pkg/crypto/crypto.go @@ -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 +} diff --git a/server/internal/pkg/crypto/crypto_test.go b/server/internal/pkg/crypto/crypto_test.go new file mode 100644 index 0000000..a4399cf --- /dev/null +++ b/server/internal/pkg/crypto/crypto_test.go @@ -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) + } +} diff --git a/server/internal/pkg/crypto/sha256.go b/server/internal/pkg/crypto/sha256.go new file mode 100644 index 0000000..b367cb1 --- /dev/null +++ b/server/internal/pkg/crypto/sha256.go @@ -0,0 +1,8 @@ +package crypto + +import "crypto/sha256" + +func sha256Sum(s string) []byte { + sum := sha256.Sum256([]byte(s)) + return sum[:] +} diff --git a/server/internal/pkg/jwt/jwt.go b/server/internal/pkg/jwt/jwt.go new file mode 100644 index 0000000..66f68fb --- /dev/null +++ b/server/internal/pkg/jwt/jwt.go @@ -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 +} diff --git a/server/internal/pkg/jwt/jwt_test.go b/server/internal/pkg/jwt/jwt_test.go new file mode 100644 index 0000000..39ec88f --- /dev/null +++ b/server/internal/pkg/jwt/jwt_test.go @@ -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") + } +} diff --git a/server/internal/pkg/resp/resp.go b/server/internal/pkg/resp/resp.go new file mode 100644 index 0000000..5e9e967 --- /dev/null +++ b/server/internal/pkg/resp/resp.go @@ -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}}) +} diff --git a/server/internal/proxy/gateway.go b/server/internal/proxy/gateway.go new file mode 100644 index 0000000..a13327d --- /dev/null +++ b/server/internal/proxy/gateway.go @@ -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") diff --git a/server/internal/proxy/handlers.go b/server/internal/proxy/handlers.go new file mode 100644 index 0000000..76e2b61 --- /dev/null +++ b/server/internal/proxy/handlers.go @@ -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" + } +} diff --git a/server/internal/proxy/passthrough.go b/server/internal/proxy/passthrough.go new file mode 100644 index 0000000..80b7815 --- /dev/null +++ b/server/internal/proxy/passthrough.go @@ -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() } diff --git a/server/internal/proxy/stream_test.go b/server/internal/proxy/stream_test.go new file mode 100644 index 0000000..818c69f --- /dev/null +++ b/server/internal/proxy/stream_test.go @@ -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 +} diff --git a/server/internal/store/db.go b/server/internal/store/db.go new file mode 100644 index 0000000..c00bd65 --- /dev/null +++ b/server/internal/store/db.go @@ -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 +} diff --git a/server/internal/store/db_postgres.go b/server/internal/store/db_postgres.go new file mode 100644 index 0000000..f99f2b7 --- /dev/null +++ b/server/internal/store/db_postgres.go @@ -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) +} diff --git a/server/internal/store/models.go b/server/internal/store/models.go new file mode 100644 index 0000000..63808a1 --- /dev/null +++ b/server/internal/store/models.go @@ -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{}, + } +} diff --git a/server/internal/usage/usage.go b/server/internal/usage/usage.go new file mode 100644 index 0000000..b38ff1b --- /dev/null +++ b/server/internal/usage/usage.go @@ -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 +} diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/web/.gitignore @@ -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? diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..33895ab --- /dev/null +++ b/web/README.md @@ -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 ` + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..6b76f59 --- /dev/null +++ b/web/package.json @@ -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" + } +} diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml new file mode 100644 index 0000000..74b1e3e --- /dev/null +++ b/web/pnpm-lock.yaml @@ -0,0 +1,1478 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@fontsource/jetbrains-mono': + specifier: ^5.3.0 + version: 5.3.0 + '@fontsource/outfit': + specifier: ^5.3.0 + version: 5.3.0 + axios: + specifier: ^1.19.0 + version: 1.19.0(debug@4.4.3) + echarts: + specifier: ^6.1.0 + version: 6.1.0 + pinia: + specifier: ^4.0.3 + version: 4.0.3(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)) + vue: + specifier: ^3.5.40 + version: 3.5.41(typescript@6.0.3) + vue-echarts: + specifier: ^8.1.0 + version: 8.1.0(echarts@6.1.0)(vue@3.5.41(typescript@6.0.3)) + vue-router: + specifier: ^4.6.4 + version: 4.6.4(vue@3.5.41(typescript@6.0.3)) + devDependencies: + '@tailwindcss/vite': + specifier: ^4.3.3 + version: 4.3.3(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0)) + '@types/node': + specifier: ^24.13.3 + version: 24.13.3 + '@vitejs/plugin-vue': + specifier: ^6.0.8 + version: 6.0.8(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0))(vue@3.5.41(typescript@6.0.3)) + '@vue/tsconfig': + specifier: ^0.9.1 + version: 0.9.1(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)) + playwright: + specifier: ^1.62.1 + version: 1.62.1 + tailwindcss: + specifier: ^4.3.3 + version: 4.3.3 + typescript: + specifier: ~6.0.2 + version: 6.0.3 + vite: + specifier: ^8.2.0 + version: 8.2.1(@types/node@24.13.3)(jiti@2.7.0) + vue-tsc: + specifier: ^3.3.8 + version: 3.3.9(typescript@6.0.3) + +packages: + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@fontsource/jetbrains-mono@5.3.0': + resolution: {integrity: sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==} + + '@fontsource/outfit@5.3.0': + resolution: {integrity: sha512-0AVHzTTVJSxWDOxPKSfMeHhZeBFDU3cmgrcOb76ZEyhGo0+xgyG3GsLF3h9+X8w0IX7G4jzjJgFQEBiWdX6VgA==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@oxc-project/types@0.144.0': + resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==} + + '@rolldown/binding-android-arm64@1.2.4': + resolution: {integrity: sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.4': + resolution: {integrity: sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.4': + resolution: {integrity: sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.4': + resolution: {integrity: sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.4': + resolution: {integrity: sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.4': + resolution: {integrity: sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.4': + resolution: {integrity: sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.4': + resolution: {integrity: sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.4': + resolution: {integrity: sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.4': + resolution: {integrity: sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.4': + resolution: {integrity: sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.4': + resolution: {integrity: sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.4': + resolution: {integrity: sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.4': + resolution: {integrity: sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@tailwindcss/node@4.3.3': + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} + + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.3': + resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + + '@vitejs/plugin-vue@6.0.8': + resolution: {integrity: sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + vue: ^3.2.25 + + '@volar/language-core@2.4.28': + resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} + + '@volar/source-map@2.4.28': + resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==} + + '@volar/typescript@2.4.28': + resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/compiler-core@3.5.41': + resolution: {integrity: sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==} + + '@vue/compiler-dom@3.5.41': + resolution: {integrity: sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==} + + '@vue/compiler-sfc@3.5.41': + resolution: {integrity: sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==} + + '@vue/compiler-ssr@3.5.41': + resolution: {integrity: sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==} + + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + + '@vue/devtools-api@8.2.1': + resolution: {integrity: sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A==} + + '@vue/devtools-kit@8.2.1': + resolution: {integrity: sha512-FIGIuq3AWReEpbAHY/cRGeHDfI0qOb8OCQ3YjbEAX04uaxIDbGc9rhkbVcG7rnfHPXE3RsU5KrWOu9V/okd8AQ==} + + '@vue/devtools-shared@8.2.1': + resolution: {integrity: sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==} + + '@vue/language-core@3.3.9': + resolution: {integrity: sha512-in/68oAa4BCtVY6n/nkuhLIkV8DHYd2UivedJ6cMZ6UYtlq9jaoaSNUBHYCVO44z3nKg7MdE5OBoHKt5SxeBKQ==} + + '@vue/reactivity@3.5.41': + resolution: {integrity: sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==} + + '@vue/runtime-core@3.5.41': + resolution: {integrity: sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==} + + '@vue/runtime-dom@3.5.41': + resolution: {integrity: sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==} + + '@vue/server-renderer@3.5.41': + resolution: {integrity: sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==} + + '@vue/shared@3.5.41': + resolution: {integrity: sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==} + + '@vue/tsconfig@0.9.1': + resolution: {integrity: sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w==} + peerDependencies: + typescript: '>= 5.8' + vue: ^3.4.0 + peerDependenciesMeta: + typescript: + optional: true + vue: + optional: true + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + alien-signals@3.2.1: + resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.19.0: + resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==} + + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + echarts@6.1.0: + resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==} + + enhanced-resolve@5.24.5: + resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} + engines: {node: '>=10.13.0'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nostics@1.2.0: + resolution: {integrity: sha512-FGqEfhQjrvo1lL8KFifdTQiNwwQHJxC1jtYE1Rc54qF/jxONUNL+kC9gS1krX8Q65PgrQ5fCqH/I4NhWBvdSqg==} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pinia@4.0.3: + resolution: {integrity: sha512-XMQqpvjgG7LMqVhhFUzKLT4KEbsYbfZZ0CZU9PgdFm1O2VKBmIkykL8+SfNhOaT7sR0wT6BEgKT0YNDYWgmVRA==} + peerDependencies: + '@vue/devtools-api': ^8.1.5 + typescript: '>=5.6.0' + vue: ^3.5.11 + peerDependenciesMeta: + typescript: + optional: true + + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + rolldown@1.2.4: + resolution: {integrity: sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + vite@8.2.1: + resolution: {integrity: sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + vue-echarts@8.1.0: + resolution: {integrity: sha512-/uJVwijy3M2vIZ0NcPDgdZVqgboc6zZtC/vERfESau0eFpkHOIujj0sIiMiLP4kztQrckMFnDYWrid+gLI+IOg==} + peerDependencies: + echarts: ^6.0.0 + vue: ^3.3.0 + + vue-router@4.6.4: + resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} + peerDependencies: + vue: ^3.5.0 + + vue-tsc@3.3.9: + resolution: {integrity: sha512-TS3Y1ux/IRoE8OCP2PpACAeOseuIs0UvWrcr7u+w3PmfY+SlCfEf8zjrBgnQksHUgLpthi5vHlffcQTQTdPBZA==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue@3.5.41: + resolution: {integrity: sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + zrender@6.1.0: + resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==} + +snapshots: + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@fontsource/jetbrains-mono@5.3.0': {} + + '@fontsource/outfit@5.3.0': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@oxc-project/types@0.144.0': {} + + '@rolldown/binding-android-arm64@1.2.4': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.4': + optional: true + + '@rolldown/binding-darwin-x64@1.2.4': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.4': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.4': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.4': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.4': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.4': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.4': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.4': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.4': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.4': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.4': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.4': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@tailwindcss/node@4.3.3': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.24.5 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.3 + + '@tailwindcss/oxide-android-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide@4.3.3': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 + + '@tailwindcss/vite@4.3.3(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0))': + dependencies: + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + tailwindcss: 4.3.3 + vite: 8.2.1(@types/node@24.13.3)(jiti@2.7.0) + + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + + '@vitejs/plugin-vue@6.0.8(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0))(vue@3.5.41(typescript@6.0.3))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.2.1(@types/node@24.13.3)(jiti@2.7.0) + vue: 3.5.41(typescript@6.0.3) + + '@volar/language-core@2.4.28': + dependencies: + '@volar/source-map': 2.4.28 + + '@volar/source-map@2.4.28': {} + + '@volar/typescript@2.4.28(typescript@6.0.3)': + dependencies: + '@volar/language-core': 2.4.28 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + optionalDependencies: + typescript: 6.0.3 + + '@vue/compiler-core@3.5.41': + dependencies: + '@babel/parser': 7.29.8 + '@vue/shared': 3.5.41 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.41': + dependencies: + '@vue/compiler-core': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/compiler-sfc@3.5.41': + dependencies: + '@babel/parser': 7.29.8 + '@vue/compiler-core': 3.5.41 + '@vue/compiler-dom': 3.5.41 + '@vue/compiler-ssr': 3.5.41 + '@vue/shared': 3.5.41 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.26 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.41': + dependencies: + '@vue/compiler-dom': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/devtools-api@6.6.4': {} + + '@vue/devtools-api@8.2.1': + dependencies: + '@vue/devtools-kit': 8.2.1 + + '@vue/devtools-kit@8.2.1': + dependencies: + '@vue/devtools-shared': 8.2.1 + birpc: 2.9.0 + hookable: 5.5.3 + perfect-debounce: 2.1.0 + + '@vue/devtools-shared@8.2.1': {} + + '@vue/language-core@3.3.9': + dependencies: + '@volar/language-core': 2.4.28 + '@vue/compiler-dom': 3.5.41 + '@vue/shared': 3.5.41 + alien-signals: 3.2.1 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + picomatch: 4.0.5 + + '@vue/reactivity@3.5.41': + dependencies: + '@vue/shared': 3.5.41 + + '@vue/runtime-core@3.5.41': + dependencies: + '@vue/reactivity': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/runtime-dom@3.5.41': + dependencies: + '@vue/reactivity': 3.5.41 + '@vue/runtime-core': 3.5.41 + '@vue/shared': 3.5.41 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.41': + dependencies: + '@vue/compiler-ssr': 3.5.41 + '@vue/runtime-dom': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/shared@3.5.41': {} + + '@vue/tsconfig@0.9.1(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))': + optionalDependencies: + typescript: 6.0.3 + vue: 3.5.41(typescript@6.0.3) + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + alien-signals@3.2.1: {} + + asynckit@0.4.0: {} + + axios@1.19.0(debug@4.4.3): + dependencies: + follow-redirects: 1.16.0(debug@4.4.3) + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + + birpc@2.9.0: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + delayed-stream@1.0.0: {} + + detect-libc@2.1.2: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + echarts@6.1.0: + dependencies: + tslib: 2.3.0 + zrender: 6.1.0 + + enhanced-resolve@5.24.5: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + entities@7.0.1: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + estree-walker@2.0.2: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + follow-redirects@1.16.0(debug@4.4.3): + optionalDependencies: + debug: 4.4.3 + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hookable@5.5.3: {} + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + jiti@2.7.0: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + ms@2.1.3: {} + + muggle-string@0.4.1: {} + + nanoid@3.3.18: {} + + nostics@1.2.0: {} + + path-browserify@1.0.1: {} + + perfect-debounce@2.1.0: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)): + dependencies: + '@vue/devtools-api': 8.2.1 + nostics: 1.2.0 + vue: 3.5.41(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + proxy-from-env@2.1.0: {} + + rolldown@1.2.4: + dependencies: + '@oxc-project/types': 0.144.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.2.4 + '@rolldown/binding-darwin-arm64': 1.2.4 + '@rolldown/binding-darwin-x64': 1.2.4 + '@rolldown/binding-freebsd-x64': 1.2.4 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.4 + '@rolldown/binding-linux-arm64-gnu': 1.2.4 + '@rolldown/binding-linux-arm64-musl': 1.2.4 + '@rolldown/binding-linux-ppc64-gnu': 1.2.4 + '@rolldown/binding-linux-s390x-gnu': 1.2.4 + '@rolldown/binding-linux-x64-gnu': 1.2.4 + '@rolldown/binding-linux-x64-musl': 1.2.4 + '@rolldown/binding-openharmony-arm64': 1.2.4 + '@rolldown/binding-win32-arm64-msvc': 1.2.4 + '@rolldown/binding-win32-x64-msvc': 1.2.4 + + source-map-js@1.2.1: {} + + tailwindcss@4.3.3: {} + + tapable@2.3.3: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tslib@2.3.0: {} + + typescript@6.0.3: {} + + undici-types@7.18.2: {} + + vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.26 + rolldown: 1.2.4 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.3 + fsevents: 2.3.3 + jiti: 2.7.0 + + vscode-uri@3.1.0: {} + + vue-echarts@8.1.0(echarts@6.1.0)(vue@3.5.41(typescript@6.0.3)): + dependencies: + echarts: 6.1.0 + vue: 3.5.41(typescript@6.0.3) + + vue-router@4.6.4(vue@3.5.41(typescript@6.0.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.41(typescript@6.0.3) + + vue-tsc@3.3.9(typescript@6.0.3): + dependencies: + '@volar/typescript': 2.4.28(typescript@6.0.3) + '@vue/language-core': 3.3.9 + typescript: 6.0.3 + + vue@3.5.41(typescript@6.0.3): + dependencies: + '@vue/compiler-dom': 3.5.41 + '@vue/compiler-sfc': 3.5.41 + '@vue/runtime-dom': 3.5.41 + '@vue/server-renderer': 3.5.41 + '@vue/shared': 3.5.41 + optionalDependencies: + typescript: 6.0.3 + + zrender@6.1.0: + dependencies: + tslib: 2.3.0 diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..b471fb3 --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/web/public/icons.svg b/web/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/web/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/scripts/api_probe.cjs b/web/scripts/api_probe.cjs new file mode 100644 index 0000000..9ae1792 --- /dev/null +++ b/web/scripts/api_probe.cjs @@ -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) }) diff --git a/web/scripts/debug.cjs b/web/scripts/debug.cjs new file mode 100644 index 0000000..dd26943 --- /dev/null +++ b/web/scripts/debug.cjs @@ -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) }) diff --git a/web/scripts/shot.cjs b/web/scripts/shot.cjs new file mode 100644 index 0000000..a11b15a --- /dev/null +++ b/web/scripts/shot.cjs @@ -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) }) diff --git a/web/scripts/verify.cjs b/web/scripts/verify.cjs new file mode 100644 index 0000000..97f6c71 --- /dev/null +++ b/web/scripts/verify.cjs @@ -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) }) diff --git a/web/src/App.vue b/web/src/App.vue new file mode 100644 index 0000000..2b486ad --- /dev/null +++ b/web/src/App.vue @@ -0,0 +1,17 @@ + + + diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..b245135 --- /dev/null +++ b/web/src/api/client.ts @@ -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 | 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 { + try { + const { data } = await client.post('/auth/refresh') + return data.data?.access_token ?? null + } catch { + return null + } +} + +// 响应壳:{ data: {...} } 或 { error: {...} } +export function unwrap(p: Promise<{ data: { data?: T; error?: { message?: string } } }>): Promise { + 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 diff --git a/web/src/assets/hero.png b/web/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/web/src/assets/hero.png differ diff --git a/web/src/assets/vite.svg b/web/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/web/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/web/src/components/ui/Badge.vue b/web/src/components/ui/Badge.vue new file mode 100644 index 0000000..1ac01d2 --- /dev/null +++ b/web/src/components/ui/Badge.vue @@ -0,0 +1,25 @@ + + + diff --git a/web/src/components/ui/Button.vue b/web/src/components/ui/Button.vue new file mode 100644 index 0000000..373d059 --- /dev/null +++ b/web/src/components/ui/Button.vue @@ -0,0 +1,31 @@ + + + diff --git a/web/src/components/ui/Input.vue b/web/src/components/ui/Input.vue new file mode 100644 index 0000000..1eefeb6 --- /dev/null +++ b/web/src/components/ui/Input.vue @@ -0,0 +1,35 @@ + + + diff --git a/web/src/components/ui/Modal.vue b/web/src/components/ui/Modal.vue new file mode 100644 index 0000000..72e4a2b --- /dev/null +++ b/web/src/components/ui/Modal.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..6a92eff --- /dev/null +++ b/web/src/main.ts @@ -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') diff --git a/web/src/router/index.ts b/web/src/router/index.ts new file mode 100644 index 0000000..49ca75e --- /dev/null +++ b/web/src/router/index.ts @@ -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 diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts new file mode 100644 index 0000000..902743b --- /dev/null +++ b/web/src/stores/auth.ts @@ -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(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') + }, + }, +}) diff --git a/web/src/style.css b/web/src/style.css new file mode 100644 index 0000000..91e0c41 --- /dev/null +++ b/web/src/style.css @@ -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; + } +} diff --git a/web/src/views/LandingView.vue b/web/src/views/LandingView.vue new file mode 100644 index 0000000..0123d06 --- /dev/null +++ b/web/src/views/LandingView.vue @@ -0,0 +1,92 @@ + + + diff --git a/web/src/views/LoginView.vue b/web/src/views/LoginView.vue new file mode 100644 index 0000000..99db229 --- /dev/null +++ b/web/src/views/LoginView.vue @@ -0,0 +1,61 @@ + + + diff --git a/web/src/views/RegisterView.vue b/web/src/views/RegisterView.vue new file mode 100644 index 0000000..b3f1607 --- /dev/null +++ b/web/src/views/RegisterView.vue @@ -0,0 +1,69 @@ + + + diff --git a/web/src/views/console/ConsoleLayout.vue b/web/src/views/console/ConsoleLayout.vue new file mode 100644 index 0000000..338933e --- /dev/null +++ b/web/src/views/console/ConsoleLayout.vue @@ -0,0 +1,69 @@ + + + diff --git a/web/src/views/console/DashboardView.vue b/web/src/views/console/DashboardView.vue new file mode 100644 index 0000000..60ab452 --- /dev/null +++ b/web/src/views/console/DashboardView.vue @@ -0,0 +1,141 @@ + + + diff --git a/web/src/views/console/KeysView.vue b/web/src/views/console/KeysView.vue new file mode 100644 index 0000000..260a534 --- /dev/null +++ b/web/src/views/console/KeysView.vue @@ -0,0 +1,186 @@ + + + diff --git a/web/src/views/console/UsageView.vue b/web/src/views/console/UsageView.vue new file mode 100644 index 0000000..1ade28c --- /dev/null +++ b/web/src/views/console/UsageView.vue @@ -0,0 +1,131 @@ + + + diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json new file mode 100644 index 0000000..d72aa75 --- /dev/null +++ b/web/tsconfig.app.json @@ -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"] +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..8455dcb --- /dev/null +++ b/web/tsconfig.node.json @@ -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"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..9cb6471 --- /dev/null +++ b/web/vite.config.ts @@ -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', + }, + }, +})