# 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 ├── hack/ │ └── config.yaml # gf CLI 脚手架配置(gen dao 等工具的数据库连接和生成规则) ├── internal/ │ ├── cmd/ # 入口命令、服务启动、路由注册 │ │ ├── cmd.go # main 包注册,路由分组定义 │ │ └── cmd_reset_pwd.go # 密码重置命令 │ ├── consts/ # 常量定义(业务状态码、枚举等) │ ├── controller/ # 控制器(由 gf gen ctrl 从 api/ 自动生成,禁止手动修改) │ │ ├── admin/ # 管理后台控制器 │ │ ├── tenant/ # 租户控制台控制器 │ │ ├── captcha/ # 验证码控制器 │ │ ├── docs/ # 文档控制器 │ │ ├── open/ # 开放平台控制器 │ ├── handler/ # 特殊端点处理器(不走 Controller 链路,见下方说明) │ │ ├── relay/ # AI 代理端点处理器 │ │ ├── public/ # 支付回调端点处理器 │ │ └── setup/ # 系统初始化向导处理器 │ ├── logic/ # 业务逻辑实现(核心代码所在) │ │ ├── admin/ # 管理后台业务逻辑 │ │ ├── tenant/ # 租户控制台业务逻辑 │ │ ├── billing/ # 计费引擎 │ │ ├── common/ # 公共逻辑(缓存、配置、安全、邮件、验证码、JWT 等) │ │ ├── docs/ # OpenAPI 文档生成 │ │ ├── monitor/ # 监控告警引擎 │ │ ├── notification/ # 通知服务 │ │ ├── open/ # 开放平台业务逻辑 │ │ ├── payment/ # 支付逻辑(回调处理、订单履约) │ │ ├── relay/ # Relay 业务逻辑(亲和性、健康检查、缓存) │ │ └── task/ # 异步任务框架 │ ├── model/ # 数据模型(由 gf gen dao 自动生成,禁止手动修改) │ │ ├── do/ # Domain Objects(查询条件、输入结构体) │ │ └── entity/ # Entity(与数据库表 1:1 映射的数据实体) │ ├── packed/ # 打包资源(内嵌静态文件、编译时生成) │ ├── dao/ # 数据访问对象(由 gf gen dao 自动生成,禁止手动修改) │ ├── response/ # 统一响应工具包(Success/Error 封装) │ ├── service/ # 业务接口定义(由 gf gen service 从 logic/ 自动生成,禁止手动修改) │ ├── middleware/ # 中间件 │ └── utility/ # 工具函数 │ ├── crypto/ # 加密工具 │ ├── export/ # 数据导出 │ ├── totp/ # TOTP 两步验证 │ └── turnstile/ # Cloudflare Turnstile 验证 ├── relay/ # AI 模型代理层(顶级模块,独立于 GoFrame 脚手架生成目录) │ ├── channel/ # 供应商适配器(registry.go + 24 个子目录,每个供应商一个) │ │ └── openai/ claude/ gemini/ ... │ ├── common/ # Relay 层共享类型和工具 │ ├── constant/ # Relay 层常量(供应商类型、模式、错误码) │ ├── dto/ # 数据传输对象(OpenAI/Claude/Gemini/Realtime/Task 等格式定义) │ ├── handler/ # Relay 请求处理器(chat/claude/gemini/audio/rerank/task/realtime 等) │ ├── helper/ # Relay 辅助函数(流式处理、状态码映射、系统提示词、thinking 处理) │ ├── override/ # 请求/响应覆盖(Header 改写、参数覆盖) │ ├── scheduler/ # 渠道调度器(调度、亲和性、重试) │ └── taskchannel/ # 异步任务渠道适配器 │ ├── registry.go # 任务渠道注册表 │ ├── kling/ # 可灵视频生成 │ ├── midjourney/ # Midjourney 图像生成 │ ├── sora/ # Sora 视频生成 │ └── suno/ # Suno 音乐生成 ├── manifest/ │ ├── config/ # 应用运行时配置(GoFrame 标准路径) │ ├── deploy/ # 部署配置(K8s/Compose 等) │ ├── docker/ # Docker 构建配置 │ ├── i18n/ # 国际化资源文件 │ └── protobuf/ # Protobuf 定义文件 ├── migrations/ # goose 数据库迁移脚本(六位序号编号:000001_xxx.sql) ├── web/ # 前端 │ ├── admin/ # 管理后台 │ └── tenant/ # 租户控制台 ├── docs/ # 项目文档 │ ├── 开发计划-v2/ # 分周期开发计划(周期一~六) │ └── 协议文档/ # 协议相关文档 ├── main.go # 应用入口 ├── go.mod └── Makefile ``` ### Relay 层设计说明 Relay 层作为顶级 `relay/` 模块独立于 GoFrame 脚手架生成目录(`internal/` 下的 `controller/service/dao/model`),原因: - **文件量大**:24 个供应商适配器 + 4 个异步任务适配器 + 共享模块,放在 `internal/logic/` 下层级过深 - **职责独立**:Relay 是纯粹的代理转发层,不依赖 GoFrame ORM/DAO,与 `internal/` 下的业务逻辑解耦 - **参考对齐**:目录结构与 new-api 的 `relay/` 保持一致,降低移植和理解成本 `internal/logic/` 中的业务逻辑(如计费、渠道调度)通过 Go import 调用 `relay/` 包,而非将 relay 嵌入 logic 内部。 ### 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 脚手架) | ### internal/handler/ 目录说明 `internal/handler/` 目录包含**特殊端点的处理器**,这些端点不适合走 GoFrame 的标准 Controller 链路: | 子目录 | 用途 | |--------|------| | `handler/relay/` | `/v1/*` AI 代理端点(OpenAI/Claude/Gemini 兼容格式)- 需要直接操作 `http.ResponseWriter` 进行流式转发 | | `handler/public/` | `/api/payment/*` 支付回调端点 - 需要返回非 JSON 格式的响应(如支付宝的表单重定向) | | `handler/setup/` | `/api/setup/*` 系统初始化向导端点 - 首次部署时的系统配置页面和初始化接口 | **为什么不用 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/` 两个参考项目,它们是大模型网关领域的成熟实现。 大多数情况下不需要查看这2个目录下的代码,除非我主动提及或者涉及到大模型相关的实现逻辑。 ### new-api 重点参考 - **Relay 代理层架构**:`controller/relay/`,适配器模式、请求转发、响应解析 - **供应商适配器**:`relay/channel/` 下 40+ 供应商的适配器实现,优先复用 - **协议转换**:OpenAI ↔ Claude ↔ Gemini 消息格式互转 - **渠道调度**:优先级/权重选择、自动重试、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/` 中。 ## 核心架构决策 ### 多租户隔离 行级隔离:所有业务表包含 `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 滞后物化视图** 架构(详见 [`docs/钱包Redis权威化设计.md`](docs/钱包Redis权威化设计.md)): - **Redis 权威**:`wallet:v2:{tenant_id}` hash(balance / frozen_balance / ver,整数 micro-USD)是余额的唯一实时真相。所有资金变动(预扣冻结、结算扣款、解冻、充值/退款/调账)一律通过 `internal/logic/billing/wallet.go` 的 Lua 脚本原子完成,**任何 Go 代码不得直接读改写 Redis 钱包 hash 或从 DB 反向重建它**。 - **DB 物化**:`bil_wallets.balance / frozen_balance` 只是展示/报表/灾难恢复用的滞后副本,由后台物化器每 `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`——判据见 [`docs/float64-decimal-field-classification.md`](docs/float64-decimal-field-classification.md)(「这个数最终会不会变成钱」)。 - **边界转换(只在出入口调用,禁止用于计算链路)**: - **出口 → 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 + `