Files
2026-09-24 10:21:31 +08:00

470 lines
40 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Team-API — 项目开发规范
## 项目概述
多租户 大模型 API 网关 SaaS 平台。租户注册后获取 API Key,通过统一接口调用 OpenAI/Claude/Gemini 等大模型,平台负责计费、限流、监控。
双控制台架构:管理后台(平台运营)+ 租户控制台(组织使用),两套完全独立的用户体系。
## 技术栈
- **后端**:Go + GoFrame (v2) + goose(数据库迁移)
- **数据库**:PostgreSQL(唯一,不用 MySQL/SQLite)
- **缓存**:Redis + 内存缓存(双层缓存)
- **前端**:Vue 3 + Vite + TailwindCSS + Arco Design Vue(管理后台)
- **包管理**:bun
- **对象存储**:S3 / OSS / COS / MinIO(禁止纯本地磁盘作为生产存储)
## 项目结构
项目基于 GoFrame v2 标准项目结构(`gf init` 生成),在此基础上扩展业务模块。
```
team-api/
├── api/ # API 定义(请求/响应结构体 + 路由注解)
│ ├── admin/v1/ # 管理后台 API(gf gen ctrl 源)
│ ├── tenant/v1/ # 租户控制台 API(gf gen ctrl 源)
│ ├── captcha/v1/ # 验证码 API(管理后台 + 租户控制台共用)
│ ├── docs/v1/ # OpenAPI 文档 API
│ ├── open/v1/ # 开放平台 API
│ ├── payment/v1/ # 支付回调 API
│ └── settings/v1/ # 公开设置 API(IsPublic 设置项、公开公告,无需认证)
├── hack/
│ └── config.yaml # gf CLI 脚手架配置(gen dao 等工具的数据库连接和生成规则)
├── internal/
│ ├── cmd/ # 入口命令、服务启动、路由注册
│ ├── consts/ # 常量定义(业务状态码、枚举等)
│ ├── controller/ # 控制器(由 gf gen ctrl 从 api/ 自动生成,禁止手动修改)
│ ├── dao/ # 数据访问对象(由 gf gen dao 自动生成,禁止手动修改)
│ ├── dispatchadapter/ # 渠道调度适配层(桥接 relaykit/dispatch 与宿主:渠道目录、调度配置、Redis/本地健康状态)
│ ├── handler/ # 特殊端点处理器(不走 Controller 链路,见下方说明)
│ ├── logic/ # 业务逻辑实现(核心代码所在)
│ ├── middleware/ # 中间件
│ ├── model/ # 数据模型(由 gf gen dao 自动生成,禁止手动修改)
│ ├── packed/ # 打包资源(内嵌静态文件、编译时生成)
│ ├── plugin/ # 插件框架(接口定义、注册表、生命周期、钩子、引导)
│ ├── response/ # 统一响应工具包(Success/Error 封装)
│ ├── service/ # 业务接口定义(由 gf gen service 从 logic/ 自动生成,禁止手动修改)
│ ├── testutil/ # 测试工具(GoFrame 测试服务器封装)
│ └── utility/ # 工具函数
├── relay/ # AI 模型代理层(顶级模块,独立于 GoFrame 脚手架生成目录)
│ ├── channel/ # 供应商适配器(registry.go + 23 个子目录,每个供应商一个)
│ │ └── openai/ claude/ gemini/ vertex/ codex/ ali/ ...
│ ├── common/ # Relay 层共享类型和工具
│ ├── constant/ # Relay 层常量(供应商类型、模式、错误码)
│ ├── dto/ # 兼容层 DTO(主体已迁 relaykit/dto,余 Responses DTO 与 relaykit 类型桥接)
│ ├── handler/ # Relay 请求处理器(chat/claude/gemini/audio/rerank/task/realtime/videos/responses/passthrough 等)
│ ├── helper/ # Relay 辅助函数(错误信息、状态码映射、模型名处理、供应商原生格式判定、脱敏)
│ ├── override/ # 请求/响应覆盖(Header 改写、参数覆盖)
│ ├── relaykit_bridge/ # relaykit 协议转换桥(格式路由分发、响应/流式回接)
│ └── taskchannel/ # 异步任务渠道适配器(registry.go + helpers.go + 8 个子目录)
│ └── kling/ midjourney/ sora/ suno/ ali/ gemini/ minimax/ volcengine/
├── relaykit/ # 协议转换 + 渠道调度(独立 Go module,不依赖 GoFrame)
├── plugins/ # 插件实现目录(registry.go 汇总注册,当前含 example 示例插件)
├── scripts/ # 辅助脚本(wrk 压测、Turnstile 测试、渠道测试缓存清理)
├── tests/
│ └── integration/ # 集成测试(admin/ tenant/,起真实服务做端到端断言)
├── manifest/ # GoFrame相关配置
├── migrations/ # goose 数据库迁移脚本(六位序号编号:000001_xxx.sql)
├── web/ # 前端
│ ├── admin/ # 管理后台
│ ├── tenant/ # 租户控制台
│ ├── embed.go # 前端打包产物内嵌(embedweb 构建标签)
│ └── embed_stub.go # 非 embedweb 构建的空实现
├── docs/ # 项目文档
├── main.go # 应用入口
├── go.mod
└── Makefile
```
### Relay 层设计说明
Relay 层作为顶级 `relay/` 模块独立于 GoFrame 脚手架生成目录(`internal/` 下的 `controller/service/dao/model`),原因:
- **文件量大**:23 个供应商适配器 + 8 个异步任务适配器 + 共享模块,放在 `internal/logic/` 下层级过深
- **职责独立**:Relay 是纯粹的代理转发层,不依赖 GoFrame ORM/DAO,与 `internal/` 下的业务逻辑解耦
`internal/logic/` 中的业务逻辑(如计费)通过 Go import 调用 `relay/` 包,而非将 relay 嵌入 logic 内部。
### relaykit 独立模块(协议转换 + 渠道调度)
`relaykit/` 是自带 go.mod 的独立 Go module(`github.com/qianfree/team-api/relaykit`,不依赖 GoFrame),承载两块核心能力,`relay/` 下已不再有 scheduler:
- **协议转换**(`relayconvert/`):12 方向协议互转的注册表 + 引擎,`relay/handler` 经 `relay/relaykit_bridge` 调用;原 `relay/dto` 主体已迁入 `relaykit/dto`
- **渠道调度**(`dispatch/`):渠道选择(HRW 一致性哈希)、错误分类、熔断、健康分、会话亲和;`internal/dispatchadapter` 负责桥接宿主的渠道目录/调度配置/Redis 健康状态
### GoFrame 代码生成与手动编写分界
| 目录 | 生成方式 | 能否手动修改 |
|------|---------|------------|
| `api/` | 手动编写 | 是(结构体定义和路由注解) |
| `hack/config.yaml` | 手动编写 | 是(CLI 工具配置) |
| `internal/controller/` | `gf gen ctrl` | 否(自动生成) |
| `internal/service/` | `gf gen service` | 否(自动生成接口) |
| `internal/model/do/` | `gf gen dao` | 否(自动生成) |
| `internal/model/entity/` | `gf gen dao` | 否(自动生成) |
| `internal/dao/` | `gf gen dao` | 否(自动生成) |
| `internal/logic/` | 手动编写 | 是(核心业务逻辑) |
| `internal/cmd/` | 手动编写 | 是(路由注册、服务启动) |
| `internal/consts/` | 手动编写 | 是(常量定义) |
| `internal/handler/` | 手动编写 | 是(特殊端点处理器,见下方说明) |
| `internal/response/` | 手动编写 | 是(统一响应工具包) |
| `internal/middleware/` | 手动编写 | 是(中间件) |
| `internal/utility/` | 手动编写 | 是(工具函数:加密、导出、TOTP、Turnstile) |
| `manifest/config/` | 手动编写 | 是(运行时配置) |
| `relay/` | 手动编写 | 是(AI 模型代理层,独立于 GoFrame 脚手架) |
| `relaykit/` | 手动编写 | 是(独立 Go module:协议转换 + 渠道调度) |
| `internal/dispatchadapter/` | 手动编写 | 是(渠道调度适配层,桥接 relaykit/dispatch) |
| `internal/plugin/` `plugins/` | 手动编写 | 是(插件框架与插件实现) |
| `internal/testutil/` `scripts/` `tests/` | 手动编写 | 是(测试与辅助脚本) |
### internal/handler/ 目录说明
`internal/handler/` 目录包含**特殊端点的处理器**,这些端点不适合走 GoFrame 的标准 Controller 链路:
| 子目录 | 用途 |
|--------|------|
| `handler/relay/` | `/v1/*` AI 代理端点(OpenAI/Claude/Gemini 兼容格式)- 需要直接操作 `http.ResponseWriter` 进行流式转发 |
| `handler/public/` | `/api/payment/*` 支付回调端点 - 需要返回非 JSON 格式的响应(如支付宝的表单重定向) |
| `handler/setup/` | `/api/setup/*` 系统初始化向导端点 - 首次部署时的系统配置页面和初始化接口 |
| `handler/admin/` | `/api/admin/files/*` 管理后台文件服务 - 应用层代理下载(local 存储无预签名 URL 时),直写二进制流 |
| `handler/landing/` | `/` 后端根路径落地页 - 非 embedweb 构建时返回项目介绍页,引导进入两套控制台 |
**为什么不用 Controller**:
- **流式响应**:AI 代理端点需要实时转发上游的 SSE 流,Controller 的响应包装会破坏流式传输
- **原生格式透传**:必须保持与 OpenAI/Claude/Gemini API 完全兼容的请求/响应格式
- **自定义响应**:支付回调需要返回 HTML 表单或文本响应,而非统一的 JSON 格式
**与 Controller 的区别**:
- Controller:走 GoFrame 标准链路(`api → controller → service → logic → dao`),返回统一 JSON 响应
- Handler:直接处理 HTTP 请求/响应,不走 GoFrame 的 `ghttp.Request` 包装,用于特殊场景
### GoFrame 脚手架生成顺序(重要)
项目 `hack/config.yaml` 中 `gfcli.gen.ctrl` 已配置 `withService: true`,意味着 `gf gen ctrl` 会尝试自动将控制器连接到 service 方法。但前提是 **service 接口中已存在对应方法**,否则生成 `CodeNotImplemented` 桩代码。
**新增接口时必须按以下顺序执行,不可颠倒:**
1. **编写 API 定义**(`api/xxx/v1/xxx.go`)— 添加 Req/Res 结构体
2. **编写 Logic 实现**(`internal/logic/xxx/xxx.go`)— 实现业务逻辑
3. **`gf gen service`** — 从 Logic 方法签名自动生成 Service 接口
4. **`gf gen ctrl`** — 从 API 定义生成 Controller,此时 `withService: true` 会自动生成 `return service.Xxx().Method(ctx, req)` 调用
如果先执行 `gf gen ctrl` 再执行 `gf gen service`,控制器会是桩代码(返回 501 Not Implemented),需要手动修改。始终遵循 "logic → gen service → gen ctrl" 的顺序可避免此问题。
**重要:`gf gen ctrl` 执行后必须验证**。即使顺序正确,`withService: true` 的自动接线对新方法也可能失败,生成 `CodeNotImplemented` 桩代码。执行后务必检查:
```bash
grep -r "CodeNotImplemented" internal/controller/
```
如果有匹配,手动将桩代码替换为 `return service.Xxx().Method(ctx, req)` 调用。切勿假设 `gf gen ctrl` 的输出一定正确。
### Controller 采用 merge 模式(合并生成,强约束)
**本项目所有 controller 按 `merge` 模式生成**:`gf gen ctrl` 默认每个接口方法生成一个独立 `.go` 文件(一个模块动辄两三百个文件),项目已统一改为**按 `api/` 源文件合并**——每个 `api/xxx/v1/*.go` 对应一个 controller 文件(如 `api/admin/v1/ticket.go` 的所有方法合并进 `internal/controller/admin/admin_v1_ticket.go`)。因此:
- **生成命令必须带 `-m` 参数**:`gf gen ctrl -m`(不要用裸 `gf gen ctrl`,否则会按方法拆分,与现有合并文件产生重复方法定义、编译报错)。
> ⚠️ `hack/config.yaml` 被 gitignore(含数据库密码),其中的 `merge: true` 不入库、不随仓库同步。**不能依赖 config,必须显式带 `-m`**。
- **`gf gen ctrl` 永不覆盖已存在的 controller 文件**:日常新增接口时,新方法会以 `CodeNotImplemented` 桩代码**追加**进对应的合并文件,手动接线一次即长期保留。仍按上文「`grep -r CodeNotImplemented` → 手动替换为 `return service.Xxx().Method(ctx, req)`」处理新桩代码。
- **切勿删除整个合并文件再重新生成**:合并文件里的手写 service 接线(含 controller 方法名 ≠ service 方法名的自定义映射,如 `TicketList → service.Admin().ListAllTickets`)一旦删文件重生成就会全部退化为桩代码,需要逐方法重接。只在确实新增/删除了 api 方法时让 `-m` 增量更新对应文件。
### GoFrame 请求处理链路
```
cmd(路由注册)
→ api(请求/响应结构体定义)
→ controller(解析请求、校验参数、调用 service)
→ service(接口定义,自动生成)
→ logic(业务逻辑实现,使用 DO 构造查询)
→ dao(ORM 操作,返回 Entity)
← 返回 Entity
← controller 格式化响应(Res 结构体)
```
## 参考项目(本地可选,当前未放置)
`new-api/` 和 `sub2api/` 是大模型网关领域的成熟实现,曾作为只读参考放在项目根目录(gitignore 未跟踪,不随仓库分发),**当前本地已移除**;需要参考时自行克隆回来即可。三项目渠道调度的横向对比结论沉淀在 `docs/reference/多项目渠道调度对比.md`(注意:其 team-api 侧描述为调度重写前的旧实现,现行调度以 `relaykit/dispatch` 为准)。
### 参考要点
- **new-api**:Relay 代理层架构(适配器模式、请求转发、响应解析)、40+ 供应商适配器、协议转换、渠道调度(优先级/权重选择、自动重试、Key 轮询)、计费模型(预扣/结算/退款、梯度定价、Token 计量)
- **sub2api**:渠道亲和性(用户+模型→渠道映射缓存策略)、运维监控(仪表盘设计、健康监控页面)、用户体系(前端布局和组件设计)
### 参考原则
参考不等于照搬。new-api 使用 Gin + GORM,需适配到 GoFrame 框架,代码重新组织以符合 GoFrame 标准结构(`api → controller → service → logic → dao` 分层)和项目的多租户架构。注意 GoFrame 的 `model/do/`、`model/entity/`、`dao/`、`service/`、`controller/` 均由脚手架自动生成,业务代码只写在 `api/`、`logic/`、`cmd/`、`handler/`、`middleware/`、`response/`、`utility/`、`consts/`、顶级 `relay/` 和独立模块 `relaykit/` 中;协议转换与渠道调度已沉淀为自有实现,优先复用 `relaykit/`。
## 核心架构决策
### 多租户隔离
行级隔离:所有业务表包含 `tenant_id` 字段。**没有 ORM 全局注入/hook 这类安全网**——隔离完全靠 logic 层手写过滤:租户身份一律取 `middleware.GetTenantID(ctx)`(来自 TenantAuth 注入的 ctx,客户端无法伪造),所有涉及租户数据的查询/更新必须显式带 `tenant_id` 条件;按 ID 取对象时用双键校验(`WHERE id = ? AND tenant_id = ?`)。新增查询漏写该条件即直接跨租户,评审时必须逐条核对。
### 路由前缀
```
/api/admin/ 管理后台(JWT 认证,admin_users 表)
/api/tenant/ 租户控制台(JWT 认证,tenant_users 表,RAM 格式 用户名@租户代码)
/api/payment/ 支付回调(签名验证,无用户认证)
/api/captcha/ 验证码服务(无需认证,管理后台 + 租户控制台共用)
/api/docs/ OpenAPI 文档(无需认证)
/api/open/ 开放平台 API(HMAC-SHA256 认证)
/api/setup/ 系统初始化向导(首次部署,完成后禁用)
/v1/ AI 代理端点 — OpenAI 兼容格式(API Key 认证,Bearer Token)
/v1beta/ AI 代理端点 — Gemini 兼容格式(API Key 认证,Bearer Token)
/suno/ Suno 音乐生成端点(API Key 认证)
```
### RBAC 权限模型
三层:角色 + 权限点 + 数据范围。公式:`is_allowed = has_permission_point AND within_data_scope AND resource_state_allows`。
管理后台角色:`super_admin`(全权限)、`admin`(可配置权限集)。
租户控制台角色:`owner`(全权限)、`admin`(管理权限)、`member`(个人数据)。
### 五层额度模型
```
租户钱包(货币池)→ 套餐额度(资源池)→ 成员额度(控制线)→ 项目预算(控制线)→ Key 额度(控制线)
```
扣费优先级:先扣套餐资源量,不足部分扣钱包余额。免费版额度耗尽停止服务,钱包余额不兜底。
### 计费流程
```
预扣(Redis Lua 原子冻结)→ 转发请求 → 结算(幂等闸门 → Redis 认领扣款 → 记流水)→ 退款/补扣差额
```
价格查询优先级:租户独立价 > 套餐价 > 模型基础价 > 硬编码默认。
最终价格公式:`最终价格 = 基础价格 × 模型乘数 × 租户乘数 × 时段乘数`。模型乘数根据模型稀有度/成本动态调整;租户乘数根据租户等级/用量等级设置(VIP 折扣等)。双层乘数替代单纯的"租户独立定价",提供更灵活的定价能力。
**时段定价**(`mdl_pricing.time_segments` JSONB,挂在 min_tokens=0 锚点行):命名时段有序数组(名称/适用日/每日时间窗/有效期/乘数),**从上到下先命中先生效,未命中=默认价 1.0**;乘数作用于全部价格(各阶梯档/缓存价/按次价),不支持每时段独立阶梯结构。**定价时刻 = 请求(任务)受理时刻**——结算/异步任务路径必须传受理时刻(`GetModelPriceAt`),预扣时刻≈受理时刻可用 `GetModelPrice`;时段按平台时区(`sys_options.pricing_time_timezone`,默认 Asia/Shanghai)解释。时段配置随 `PricingResult` 进 600s 定价缓存,缓存命中后按 billAt 重评估乘数(`reapplyTimeMultiplier`),TimeMultiplier 零值兜底为 1.0。
### 钱包状态:Redis 权威 + DB 物化(强约束)
钱包资金状态采用 **Redis 唯一实时权威 + DB 滞后物化视图** 架构:
- **Redis 权威**:`wallet:v2:{tenant_id}` hash(balance / frozen_balance / total_consumed / ver,整数 micro-USD)是余额的唯一实时真相。所有资金变动(预扣冻结、结算扣款、解冻、充值/退款/调账)一律通过 `internal/logic/billing/wallet.go` 的 Lua 脚本原子完成,**任何 Go 代码不得直接读改写 Redis 钱包 hash 或从 DB 反向重建它**。
- **累计消费(total_consumed)只随结算事件变动**:`settleClaimLua` 与扣款同一原子 `HINCRBY` 递增;结算记流水失败的补偿逆转走专用 `ReverseConsumeRedis`(余额与计数同一原子回退)。充值/兑换/调账/退款/预扣/解冻一律**不得**触碰该计数(`CreditWalletRedis`/`DebitWalletRedis` 不感知计数)。字段基线由三层补种保证(boot 种子 `SeedWalletTotalConsumed` / 灾备重建 `rebuildWalletFromDB` / 物化器自愈 `materializeOneWallet`),存量历史从 `bil_transactions(type=consume)` 回填(幂等可重放)。
- **DB 物化**:`bil_wallets.balance / frozen_balance / total_consumed` 只是展示/报表/灾难恢复用的滞后副本,由后台物化器每 `billing_wallet_materialize_interval_ms`(默认 5000ms)从 Redis 全量覆盖。**禁止**把 DB 钱包余额当作实时判断依据(如余额门槛、防超扣闸门)。
- **预扣记录纯 Redis**:`bil_prededuct_tracks` 表已废弃(预扣明细 = `prededuct:v2:{request_id}` hash + `prededuct_active:{tenant}` 集合,TTL 2h),孤儿清扫由 cron `prededuct_sweep` 每 2 分钟按「重算 frozen = Σ 幸存预扣」自愈,不再依赖 DB 表。
- **可用余额** = balance - frozen_balance,预扣/扣款/退款门槛都在 Redis Lua 内原子判断。
- **幂等闸门**:结算幂等靠 `bil_records.request_id` 唯一约束(DB,先行于任何资金变动);预扣/解冻幂等靠 Redis hash 认领即删。
- **Redis 服务不可用**:预扣/结算 fail-closed(拒绝请求),禁止降级放行无额度保护的流量;接受 Redis 数据丢失窗口内的少量超扣。
- 充值/兑换/调账/退款等低频写路径:**Redis Lua 为提交点**(实时生效)→ DB 只写账本流水与累计充值(余额由物化器覆盖),事务回滚时补偿逆转 Redis。
- 流水(`bil_transactions`)的 `balance_after / frozen_after` 一律取 Redis Lua 返回值(账本链连续可信)。
### 系统货币规则
平台采用**本位币制 + 充值入口 CNY**策略:系统在初始化向导(setup)时选定**本位币**(`billing_currency`,USD 或 CNY,**选定后不可更改**,存量部署默认 USD),内部记账与全站显示统一使用本位币:
| 业务场景 | 货币 | 说明 |
|----------|------|------|
| 大模型价格展示和计费 | **本位币** | 模型定价、Token 单价、计费扣费统一使用本位币(本位币=CNY 的部署按人民币配价) |
| 用户钱包余额 | **本位币** | 本位币=USD 时充值 CNY 按汇率转换为 USD 入账;本位币=CNY 时直接入账无换汇 |
| 计费记录 / 交易流水 | **本位币** | 所有 bil_records、bil_transactions 金额均为本位币 |
| 套餐价格 | **CNY(人民币)** | 管理后台创建套餐时设置的价格为人民币,用户购买套餐使用人民币支付 |
| 充值/支付 | **CNY(人民币)** | 用户通过支付渠道(支付宝/微信/Stripe)充值为人民币(收款渠道物理现实),履约时按本位币入账钱包 |
#### 三层币种固定规则(强约束,写新代码必须遵守)
币种**按层固定**,不允许在层内混用。判断一个金额用什么币种,只看它属于哪一层,不需要逐字段记忆:
| 层 | 表前缀 | 币种 | 说明 |
|----|--------|------|------|
| **订单 / 支付层** | `ord_` | **永远 CNY** | 充值订单、套餐订单、退款,金额一律人民币。`ord_orders.currency` 默认值必须为 `CNY` |
| **钱包 / 计费 / 账本层** | `bil_` | **本位币** | 钱包余额、冻结余额、累计充值、交易流水、计费记录、用量、预扣/结算/退款,一律本位币(部署级常量,`billing.Currency(ctx)` 读取;初始化选定后不可切换,账本永远单币种) |
| **套餐定价** | `pln_` | **CNY** | 套餐卖的是 token 额度(`monthly_quota_tokens`),价格用人民币,不与钱包余额直接比较 |
| **租户等级阈值** | `tnt_tenant_level_configs.cumulative_recharge_threshold` | **本位币** | 与 `bil_wallets.cumulative_recharge`(本位币)同币种比较 |
**唯一换汇点**:CNY → 本位币的转换**只允许发生在充值履约**(`payment.FulfillOrder` 的 `recharge` 分支与线下充值 `OfflineRecharge`,本位币=USD 时调用 `billing.ConvertCNYToUSD`;本位币=CNY 时直接入账,`exchange_rate` 快照存 1)。除此之外任何地方都不得做币种换算——套餐购买不入钱包不换算,计费引擎全程本位币不换算。
**新增带金额字段的代码前,先确认它属于哪一层**,按上表取币种;跨层传递金额(如订单金额入钱包)必须经过唯一换汇点。
#### 汇率规则
- 只维护**单一方向**汇率 `payment_exchange_rate_cny_to_usd`(管理后台支付设置配置,默认 0.14,已 IsPublic 暴露给前端做展示折算)。需要反向(USD→CNY)时**取倒数**,禁止再配置第二个独立汇率,避免两个汇率漂移导致往返不闭合。
- **充值时必须快照汇率**:履约入账时除了写入换算后的本位币金额(`credited_usd` 列,语义为入账本位币金额),还要持久化「原始 CNY 金额 + 当时汇率 + 入账金额」,使历史换算可重建(本位币=CNY 时 `exchange_rate` 存 1)。汇率配置变更不得影响已完成订单的对账。
- **退款认原始 CNY 金额**:退款按订单原始人民币金额退回支付渠道,**禁止**用钱包当前本位币余额按当前汇率反向折算(防止汇率波动产生套利/亏损)。
#### 前端展示规则
全站显示货币 = 本位币(`/settings/public` 暴露 `billing_currency` + 汇率,两端 `composables/useCurrency.ts` 统一格式化):
- bil 层数据(钱包 / 交易流水 / 计费 / 用量 / Token 单价 / 阈值 / 兑换码面值)→ 本位币符号直显,**不折算**
- ord_/pln_ 层数据(套餐价格 / 充值金额 / 订单金额 / 退款金额 / 优惠码折扣,存 CNY)→ 本位币=CNY 直显 `¥`;本位币=USD 时按汇率折算显示 `$`(**唯一折算场景**)
- **编辑表单保持存储币种原值输入**(模型定价按本位币、套餐价格按 CNY),label 标注存储币种;折算仅用于只读展示
- 充值页锚定 CNY(订单/履约/折扣口径),本位币=USD 时档位与输入按汇率折算显示、提交时换算回 CNY
- ⚠️ 营收类指标(`total_cost`、`revenue` 等来自 `bil_usage_logs` 的统计)是本位币,跟随本位币符号;需要另一币种营收报表时,在**出报表时**按快照汇率折算,账本本身仍存本位币。
#### 人民币对账 / 导出规则
判断要不要存 CNY,只看**这笔钱有没有真的以人民币发生过**,两种对账答案相反:
| 对账类型 | CNY 是否真实发生 | 做法 |
|----------|------------------|------|
| **现金对账**(充值 / 退款,对支付宝/微信/银行流水) | ✅ 真实发生 | 在**充值订单/充值交易**上快照「原始 CNY 金额 + 当时汇率 + 入账 USD」,CNY 是真相、USD 是派生 |
| **消费对账**(AI 用量 / 计费扣费) | ❌ 从未发生(逐笔均为 USD 计算) | **禁止**在 `bil_usage_logs` 等高频写入表加 CNY 快照字段,导出时折算 |
**为什么消费记录不加 CNY 快照字段**:① 没有真实人民币事件可对,存进去是虚构事实;② `bil_usage_logs` 是全系统写入量最大的表,加派生列存储代价大;③ 逐行存 CNY 再 SUM 会累积舍入误差;④ 制造与 USD 列冲突的第二真相源,反而新增对账问题。
**消费需要展示/导出人民币时**:先按 USD 聚合(`SUM(total_cost)`),**最后乘一次汇率**得到 CNY;报表头注明「所用汇率 + 折算口径 + 仅供参考,非实际支付/开票金额」。汇率用统一的报表汇率(或 `payment_exchange_rate_cny_to_usd` 取倒数),不得逐行各取各的。
**发票 / 税务口径**:合法可开票的人民币金额是用户**实际支付的充值额**(真实 CNY),绝不是「消费额 × 汇率」。租户人民币对账单/发票一律从充值记录出,精确到分、与支付渠道流水一一对应。
> 决策规则:**钱真的以人民币动过 → 存真实 CNY 快照(充值/退款);没动过、只是想看人民币 → 导出时对聚合 USD 折算一次,不落库。**
## 数据库表前缀定义
按功能域划分,新建表时必须归入对应前缀,除非加入了新的功能模型,否则不得自行新增前缀。
| 前缀 | 功能域 | 周期 | 涉及表(示例) |
|------|--------|------|--------------|
| `sys_` | 平台系统 + 系统配置 | 一 | `sys_admin_users` `sys_admin_role_perms` `sys_admin_data_scopes` `sys_sessions` `sys_email_verify_codes` `sys_idempotency_records` `sys_options` |
| `tnt_` | 租户核心 + 项目管理 | 一 | `tnt_tenants` `tnt_users` `tnt_invitations` `tnt_member_imports` `tnt_member_model_scopes` `tnt_projects` |
| `mdl_` | AI 模型 | 一 | `mdl_models` `mdl_pricing_tiers` `mdl_tenant_models` |
| `chn_` | 渠道管理 | 一 | `chn_channels` `chn_abilities` `chn_health_scores` `chn_channel_keys` `chn_channel_affinities` |
| `api_` | API 密钥 | 一 | `api_keys` `api_key_model_scopes` |
| `bil_` | 计费账单 | 一 | `bil_records` `bil_wallets` `bil_transactions` `bil_usage_logs` `bil_daily_usage_summary` `bil_daily_revenue_summary` `bil_monthly_usage_summary` `bil_monthly_revenue_summary` |
| `ord_` | 订单支付 + 优惠券 | 二 | `ord_orders` `ord_refunds` `ord_payment_channels` `ord_promo_codes` `ord_promo_code_usages` `ord_redemptions` `ord_redemption_usages` |
| `pln_` | 套餐订阅 | 二 | `pln_plans` `pln_tenant_plans` `pln_feature_flags` |
| `ntf_` | 通知消息 | 三 | `ntf_templates` `ntf_send_log` `ntf_messages` `ntf_read_status` `ntf_preferences` `ntf_announcements` |
| `aud_` | 审计日志 | 三 | `aud_request_logs` `aud_operation_logs` `aud_sensitive_access_logs` `aud_login_history` |
| `spt_` | 客户支持(工单+帮助+反馈) | 三/六 | `spt_tickets` `spt_replies` `spt_attachments` `spt_categories` `spt_articles` `spt_feedbacks` |
| `ops_` | 运维监控 | 四 | `ops_alert_rules` `ops_alert_events` `ops_system_metrics` |
| `tsk_` | 异步任务 | 四 | `tsk_tasks` `tsk_task_logs` `tsk_model_tasks` |
| `fil_` | 文件存储 | 四 | `fil_files` |
| `opn_` | 开放平台 + Webhook | 五 | `opn_apps` `opn_webhook_configs` `opn_webhook_events` `opn_webhook_delivery_logs` |
| `clg_` | 更新日志 | 六 | `clg_changelogs` |
### 前缀使用规则
- 新建表必须从上表选择前缀,不允许自行新增前缀
- 如果一个表明显属于两个域,选择核心归属域的前缀
- 通用/跨域表(如 `sys_options` 系统配置)放在最相关的域下
- 迁移脚本文件名按现有文件顺序递增编号,格式:`{序号}_{变更内容描述}.sql`,例如 `000002_alter_mdl_pricing_cache_fields.sql`
## 开发规范
### Go 后端
- **缩进统一使用 tab**:所有 Go 代码的缩进必须使用 tab,与 `go fmt` 自动格式化保持一致,禁止混用空格和 tab 缩进
- **金额用 `decimal.Decimal`,不用 `float64`(强约束)**:所有资金列(`NUMERIC(20,10)`)在 Go 层映射为 `github.com/shopspring/decimal` 的 `decimal.Decimal`(gf 全局 `typeMapping: numeric → decimal.Decimal`)。**多步金额运算(加/减/乘/除、循环累加、倍率×折扣链)一律用 decimal**,禁止用 float64 做中间计算——IEEE double 会在链式运算中累积漂移。工具函数在 `internal/logic/billing/money.go`(`AddMoney`/`SubtractMoney`/`MultiplyMoney`/`DivideMoney`/`RoundMoney`/`Max`/`Min`/`IsPositive` 等),比较用 `.GreaterThan(billing.Zero)`/`.IsZero()`/`.Equal()`,不要用 `== 0`。判断非金额小数(评分/利用率/延迟/告警阈值)保持 `float64`——判据是「这个数最终会不会变成钱」。
- **边界转换(只在出入口调用,禁止用于计算链路)**:
- **出口 → float64**:API `Res` 响应、内存缓存结构体、遗留 float64 接口,用 `billing.InexactFloat64(d)`(前端 JSON number 契约不变)。
- **入口 ← float64**:API `Req` 里的 float64 金额,用 `billing.NewFromFloat(f)` 转进 decimal 再运算。
- **DB 落库**:decimal 直传 ORM(`.Insert(do)`/`.Update(map)`)或原生 SQL 参数(`balance + ?`)——shopspring decimal 实现 `driver.Valuer`,写出精确 NUMERIC 字符串,**这条路径不要转 float64**。
- **JSONB 陷阱**:decimal 默认 marshal 成**带引号的字符串**,写进「读取方期望 number」的 JSONB blob(如快照)会破坏整个 unmarshal——JSONB 缝隙处必须先 `.InexactFloat64()`。(注意:DB 列 ≠ JSONB,前者走 Valuer 精确直传,后者走 JSON 需转 float64。)
- **Redis 钱包**:用整数 micro-USD(`int64` + `HINCRBY`),`billing.ToMicro`/`FromMicro` 转换,杜绝 `HINCRBYFLOAT` 漂移。
- **唯一换汇点**:CNY→本位币仅在 `payment.FulfillOrder` recharge 分支与 `OfflineRecharge` 调 `billing.ConvertCNYToUSD`(返回 decimal,本位币=CNY 时直接入账无换算),其余地方不换算(见《系统货币规则》三层币种固定规则)。
- GoFrame 标准项目结构,不要偏离。框架使用规范(时间处理、错误处理、ORM 模式、日志等)详见 [`docs/reference/goframe-conventions.md`](docs/reference/goframe-conventions.md),遇到不确定的 GoFrame 用法时善用 `/goframe-v2` skill 查询框架规范
- **修复 GoFrame 框架使用 bug 时**,必须在 `docs/reference/goframe-conventions.md` 末尾的「已修复的框架使用错误记录」章节追加记录(问题描述、原因、修复方式),防止同类问题重现
- 数据库迁移用 goose,脚本按六位序号递增编号(`migrations/000001_xxx.sql`)
- 所有业务表必须有 `id`, `created_at`, `updated_at` 字段
- 审计相关表按月分区(audit_logs)
- 使用量大的写入操作(usage_logs)异步批量写入
- 错误响应统一格式:`{"error": {"type": "...", "message": "...", "code": "...", "request_id": "..."}}`
- 每个 API 请求生成唯一 Request ID,贯穿全链路
- 敏感字段(密码、API Key 原值)AES-256 加密存储
- 缓存策略:API Key 60s、用户信息 300s、模型定价 600s、渠道信息 300s
- 代码注释统一使用中文编写,不需要每行都写注释,在关键方法和决策的地方写注释即可
### 前端
- Vue 3 Composition API + `<script setup>` 语法
- **管理后台**:使用 **Arco Design Vue** 组件库(`@arco-design/web-vue`),快速构建数据密集型页面(表格、表单、图表等),可参考 [Soybean Admin](https://github.com/soybeanjs/soybean-admin) 的架构设计(路由、权限、布局)
- **租户控制台**:TailwindCSS 原子类 + **Naive UI 组件库**(`naive-ui`,全量引入 + `n-config-provider` 主题对齐主色 `#14b8a6`)。表格用 `NDataTable`、表单用 `NForm`/`NInput`/`NInputNumber`/`NSelect`/`NCheckbox`/`NModal`;保留自研组件:`SlideCaptcha`(滑块验证码)、`Turnstile`(Cloudflare)、`Icon`(图标)、`MarkdownRenderer`。公共渲染工具在 `src/utils/renderUtils.ts`(`renderBadge`/`formatMoney`/`formatDate`/`formatTokens`/`formatMs`)。面向客户,注重品牌调性和视觉差异化
- TailwindCSS 作为基础样式层,两个控制台共用(注意与 Arco 组件内置样式共存,避免全局 reset 冲突)
- 状态管理用 Pinia
- HTTP 请求封装 Axios,JWT 自动刷新,统一错误处理
- 按钮级权限控制:无权限的按钮不渲染(`v-if="hasPermission('xxx')"`)
- 前端不用 React,不用 JSX
### 数据库
- 只用 PostgreSQL,写 SQL 迁移脚本时使用 PostgreSQL 语法
- **数据库是远程连接**,本地没有 psql 等命令可用,数据库操作只能通过 goose 迁移脚本执行
- **表名带模块前缀**:同一模块/功能/域的表使用统一前缀(见下方《数据库表前缀定义》),提升可维护性
- **全部小写蛇形命名**:表名和字段名一律 `snake_case`,禁止驼峰或大写
- **禁止数据库级外键**:所有关联都是逻辑关联,不使用 `REFERENCES`/`FOREIGN KEY`,在代码层面处理关联关系和级联操作
- **所有字段必须带注释**:建表时每个字段都加 `COMMENT '说明'`,便于后续维护
- JSONB 字段用于灵活配置(功能权限、通知偏好等)
- 字符串字段注意长度限制,不要用 `VARCHAR(255)` 一刀切
- 金额字段统一用 `NUMERIC(20,10)`(PostgreSQL 中 NUMERIC 与 DECIMAL 等价),避免累计计算精度丢失,不用 FLOAT
- **资金显示精度**:系统中所有资金相关数值(钱包余额、交易金额、计费金额、单价等)在 API 响应和前端展示时一律精确到小数点后 6 位(特殊说明除外),与 `NUMERIC(20,10)` 存储精度对齐,保证前端显示与后端计算结果一致
- 索引按实际查询场景创建,不过度索引
- **追加写表使用 BRIN 索引**:`usage_logs`、`audit_logs`、`billing_records` 等按时间追加写入的表,在 `created_at` 字段上使用 BRIN 索引(而非 B-tree),大幅节省存储空间
### API 接口
- **URL 路径统一小写蛇形**:`/api/admin/tenant-plans` 而非 `/api/admin/tenantPlans`
- GoFrame 有脚手架命令(`gf gen`),能使用脚手架生成的代码(model/dao/api)就用脚手架生成,不要手写
- 完整的 JSON 示例、SSE 流式格式、各供应商响应格式、错误类型映射、中间件差异等详见 [`docs/reference/api-format-reference.md`](docs/reference/api-format-reference.md)
#### 统一响应格式(`/api/*` 管理类接口)
所有管理类接口(`/api/admin/*`、`/api/tenant/*`、`/api/payment/*`、`/api/open/*`)使用统一 JSON 结构:`{"code": 0, "message": "ok", "data": ..., "request_id": "..."}`。`code=0` 成功,非 0 错误。HTTP 状态码照常设置,前端以 `code` 为主。
**data 字段规范**:分页列表 `{list, total, page, page_size}`,不分页 `{list}`,创建 `{id}`,详情直接返回对象,更新/删除 `null`。数组字段名统一用 `list`,list 内部可为空但不能有 null。
**后端**:统一使用 `internal/response` 包(`response.Success` / `response.Error`),禁止直接拼 `g.Map`。业务错误码 >= 10000 定义在 `internal/consts/consts.go`,映射为 HTTP 422。`message` 必须中文,禁止暴露技术细节。
**前端**:Axios 拦截器以 `code === 0` 为唯一成功判据,统一处理错误提示。所有 API 调用 `try/catch` 包裹。
#### 大模型代理接口(`/v1/*`、`/v1beta/*`、`/suno/*`)
代理端点是 OpenAI/Claude/Gemini 等 AI 服务的反向代理,必须与上游 API 格式完全兼容。核心规则:
- **禁止使用统一响应格式和 `internal/response` 包**,直接操作 `http.ResponseWriter`
- **原生透传**:上游响应原样转发,错误也用供应商原生格式
- 错误处理:`WriteRelayError`(OpenAI 格式)、`WriteClaudeRelayError`(Claude 格式)
### Definition of Done(功能完成标准)
每个功能模块交付时需满足以下标准:
| # | 标准 | 说明 |
|---|------|------|
| 1 | 功能通过手动测试 | 核心业务流程端到端可跑通 |
| 2 | 单元测试覆盖率 ≥ 80% | 核心计费/调度/权限逻辑必须有单元测试 |
| 3 | 接口文档同步更新 | API 端点变更时同步更新 OpenAPI 文档 |
| 4 | 数据库迁移可重复执行 | goose up/down 幂等,不破坏已有数据 |
| 5 | 错误码与提示文案完整 | 所有错误场景有明确错误码和用户可读消息 |
| 6 | 日志记录完整(含 trace_id) | 关键业务操作日志贯穿 request_id |
| 7 | 权限校验覆盖所有端点 | 新增端点必须配置权限点,无权限返回 403 |
| 8 | 代码审查通过 | 核心模块需 Code Review 后合入 |
## 文档索引
关键文档在 `docs/` 下,需要时直接阅读:
| 文档 | 路径 | 用途 |
|------|------|------|
| relaykit 迁移状态 | `docs/relaykit迁移工作状态.md` | relay/ → relaykit 协议转换迁移进度、覆盖矩阵与守卫说明 |
| 渠道调度对比 | `docs/reference/多项目渠道调度对比.md` | new-api/sub2api/team-api 调度方案对比(team-api 侧描述已过时,以 relaykit/dispatch 为准) |
| Docker 部署 | `docs/deployment-docker.md` | Docker 部署指引 |
| 协议文档 | `docs/协议文档/` | 各供应商官方协议参考(OpenAI/Claude/Gemini/智谱/豆包/阿里等系列) |
| API 格式参考 | `docs/reference/api-format-reference.md` | JSON 示例、SSE 格式、错误映射、中间件差异 |
| 协议转换语料采集 | `docs/协议转换语料采集手册.md` | `corpus-extract` 命令用法:从渠道调试日志提取真实流量语料喂给协议转换测试 |
| GoFrame 使用规范 | `docs/reference/goframe-conventions.md` | 框架使用规范 + 已修复的框架 bug 记录 |
## 开发环境
- **操作系统**:Windows
- **GoFrame CLI**:已安装 `gf` 脚手架,优先使用 CLI 完成以下工作:
| 命令 | 用途 | 何时使用 |
|------|------|---------|
| `gf init` | 初始化项目骨架 | 项目创建时 |
| `gf gen dao` | 从数据库生成 DAO/DO/Entity 代码 | 表结构变更后 |
| `gf gen service` | 从 Logic 层生成 Service 接口 | 业务接口变更后 |
| `gf gen ctrl -m` | 从 API 定义生成 Controller(**merge 合并模式,必带 `-m`**) | API 定义变更后 |
| `gf gen pb` | 生成 protobuf 文件 | 需要时 |
| `gf run` | 热编译运行开发服务器 | 开发调试 |
| `gf build` | 编译生产二进制 | 构建部署 |
## AI工具要求
不允许在提交内容中加入AI工具相关的身份表示和内容,不允许将AI作为仓库贡献者提交