diff --git a/VERSION b/VERSION deleted file mode 100644 index 845639e..0000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -0.1.4 diff --git a/docs/claude代码分析.md b/docs/claude代码分析.md deleted file mode 100644 index fb0778d..0000000 --- a/docs/claude代码分析.md +++ /dev/null @@ -1,113 +0,0 @@ -# team-api 后端代码质量分析报告 - -> 分析日期:2026-07-20 -> 分析范围:后端手写代码(`relay/`、`internal/logic/`、`internal/handler/`、`internal/middleware/`、`internal/utility/`、`internal/cmd/`、`internal/consts/`、`internal/response/`、`api/`) -> 明确排除:`internal/controller/`、`internal/service/`、`internal/model/`、`internal/dao/`(GoFrame 脚手架自动生成)、前端代码(`web/`)、参考项目(`new-api/`、`sub2api/`) - -分析方式:按子系统拆分为 6 个并行审查(relay/channel+taskchannel 供应商适配器、relay 其余模块、billing+payment+monitor 计费核心、admin+tenant 业务逻辑、common+task+notification+docs+open 公共依赖、handler+middleware+utility+cmd+api 基础设施),逐一走查源码后汇总。 - ---- - -## 总体评价 - -架构分层清晰,核心设计(Redis Lua 原子预扣、事务化结算、双层缓存、供应商适配器接口统一、JWT 算法白名单、bcrypt cost=12、AES-256-GCM 加密)总体扎实,未发现能被外部直接一击利用的严重漏洞。但集中暴露三类系统性问题: - -1. **权限校验"各自为战"导致的真实越权漏洞** —— 每个 logic 文件各自手写角色/归属判断,没有统一的鉴权网关兜底,已经出现真实遗漏 -2. **分布式场景下"看似生效实则失效"的机制** —— 缓存失效订阅、cron 分布式锁、重试统计字段等已修复(见修复记录);仍残留进程内 `walletCache` 充值后不失效、任务领取非原子 CAS 等多副本隐患 -3. **测试覆盖率与项目自身 DoD(核心逻辑 ≥80%)差距巨大** —— 恰恰是计费、支付、权限、退款这些最需要保护的核心路径覆盖率最低,本次发现的多数高危问题现有测试完全没有捕获 - ---- - -## 🔴 高危 — 建议优先处理 - -### 越权 / 权限漏洞 - -| 位置 | 问题 | -|------|------| -| `internal/middleware/rbac.go:318-349`(`AdminPermissionGuard`) | `matchPermission()` 找不到路由对应规则时返回空字符串,此时**默认放行**已认证的非超管请求(fail-open),新增管理端点忘记登记权限规则即无保护 | -| `internal/logic/tenant/team_guard.go` | 文件名/`requireTeamEnabled` 命名暗示是通用授权守卫,实际只检查团队功能是否启用,不做资源归属或 RBAC 校验,容易被误认为已提供统一防护 | - -### 资金正确性 - -| 位置 | 问题 | -|------|------| -| `internal/logic/admin/order.go:63-112`(`RefundOrder`) | 仅将订单状态改为 `refunding`、插入 `ord_refunds`(`status: approved`)记录后直接返回,**从未调用支付渠道退款 API,也未冲正钱包余额**。全仓库搜索确认没有任何后续代码消费 `refunding` 状态,退款流程是半成品,订单永久卡住,用户实际收不到退款 | -| `internal/logic/billing/currency.go` | 同时维护 `payment_exchange_rate_cny_to_usd`(默认 0.14)和 `payment_exchange_rate_usd_to_cny`(默认 7.25)两个**独立**汇率配置,`1/0.14=7.143 ≠ 7.25`,非严格倒数,**直接违反 CLAUDE.md"只维护单一方向汇率,禁止再配置第二个独立汇率"的强约束** | -| `internal/model/entity/bil_wallets.go` 及 `internal/logic/billing/wallet.go` 全文 | `Balance`/`FrozenBalance`/`WarningThreshold`/`CumulativeRecharge` 等金额字段在 Go 层全部是 `float64`,数据库列虽是 `NUMERIC(20,10)`,但应用层所有加减法(预扣/结算/退款/汇率换算)均在有损精度上进行,**违反 CLAUDE.md"金额字段不用 FLOAT"的规则**,长期运行存在累计误差风险 | - -### 安全 - -| 位置 | 问题 | 状态 | -|------|------|------| -| `internal/response/error_log.go` + `response.go:71-92` | 系统级错误发生时,原始请求体(截断至 2000 字符)未做任何脱敏就写入 `sys_error_logs`,**登录/改密接口一旦触发 500 会把明文密码存进错误日志表** | ✅ 已修复:新增 `sanitizeRequestBody`,对 JSON 请求体递归脱敏 password/secret/token/code 等敏感字段,非 JSON 体不落原文 | -| `internal/logic/common/verify_code.go:117-169` | `VerifyCode` 仅做一次字符串比较,无失败次数计数或锁定机制,10 分钟有效期内理论上可被暴力枚举 6 位数字验证码(发送侧有 60s 冷却/5 次每小时限流,验证侧无节流) | ✅ 已修复:验证侧增加 Redis 失败计数,10 分钟内失败 5 次即锁定需重新获取,成功后清零 | -| `internal/utility/totp/totp.go:34-36`(`ValidateCode`) | 校验是无状态纯函数,调用方也未记录已使用的时间步,默认 ±1 周期(约 90 秒)窗口内同一验证码**可被无限次重复使用** | ✅ 已修复:`security.go` 新增 `validateTOTPOnce` 防重放守卫(Redis `SET NX EX 90`),4 处调用方统一接入,Redis 故障时 fail-open | -| `api/admin/v1/admin_user.go:40,53` | 创建/更新管理员的 `Role` 字段无 `v:"in:super_admin,admin"` 字段级白名单校验(逻辑层 `common.ValidateAdminRole` 已兜底拦截非法角色,实际注入风险已消除,仅剩 API 层校验一致性问题) | ✅ 已修复:补上字段级 `v:"in:super_admin,admin"`,与逻辑层口径一致(纵深防御) | -| `api/admin/v1/promo.go:44,54` | 优惠码创建/更新请求体直接用 `map[string]interface{}` 承载,完全绕开 GoFrame 字段级校验 | ⏸️ 非 bug:逻辑层 `promo_code.go:41-72` 的 `CreatePromoCode`/`UpdatePromoCode` **均已有字段白名单**,内部字段注入已被拦截;剩余仅为"缺少 GoFrame 字段级类型校验"的设计一致性问题,改成 typed struct 会变动前端契约、收益低,暂不改 | - -### 核心链路测试覆盖率(实测) - -| 模块 | 覆盖率 | -|------|--------| -| `internal/logic/billing` | 20.1%(虽新增多个 `_test.go`,但集中在 money/currency 辅助逻辑,settlement/pricing 主干仍薄弱) | -| `internal/logic/payment` | 6.6% | -| `internal/logic/monitor` | **0%**(无任何 `_test.go`) | -| `internal/logic/admin`、`internal/logic/tenant` | 合计约 76 个文件,**零测试**(权限/隔离/资金代码) | - -均远低于 CLAUDE.md 要求的核心计费/调度/权限逻辑 ≥80% 覆盖率标准,且本次发现的越权漏洞、退款半成品、进程内缓存失效等问题现有测试**完全没有覆盖**。 - ---- - -## 🟡 中等 — 建议排期处理 - -- **`internal/logic/tenant/project.go:289-291, 337-341, 370-374`** 等多处:`ProjectUpdate`/`ProjectArchive`/`ProjectUnarchive` 先用 `WHERE id=? AND tenant_id=?` 做归属校验(SELECT),但紧接着的 UPDATE 只用 `WHERE id=req.Id`,丢失了 tenant_id 过滤。当前靠前置 SELECT 把关,不构成立即可利用漏洞,但破坏了"写操作必须带 tenant_id"的一致性约定 -- **`relay/taskchannel/{ali,sora,suno,volcengine,midjourney}/adaptor.go`**:6/7 个任务渠道适配器各自新建裸 `http.Client`(而非复用 `common.NewPooledClient`),**忽略渠道代理配置**;且 4/7 处丢弃 `BuildRequestHeader` 的错误返回值。与 `relay/channel/`(聊天适配器)统一走连接池的实践不一致 -- **`internal/logic/billing/api_key_quota.go`/`member_quota.go`**:`CheckApiKeyQuota`/`CheckMemberQuota` 是"读取当前值→比较→放行"的非原子检查,增量在结算后异步执行,与钱包层基于 Lua 脚本的真正原子预留形成明显的架构不一致,并发请求可同时通过检查从而累计超出配额上限 -- **`internal/handler/public/payment.go:21-26`**:支付回调处理返回的任何错误(签名失败、金额不符、订单不存在等)被统一丢弃且**不记录日志**,签名伪造/篡改尝试在服务端完全不可追溯 -- **`internal/logic/payment/fulfill.go`(`creditWalletTx`)**:充值成功后只调用了 `billing.InvalidateWalletRedis`,未清除 `billing` 包内私有的进程内 `walletCache`(300s TTL,无跨包失效接口),充值后最长 300 秒内 `GetWallet()` 可能仍返回旧余额 -- **`internal/logic/billing/settlement.go`**:补扣场景(`actualCost > preDeductAmount`)下 `UPDATE bil_wallets SET balance = balance - ?` 没有非负校验,理论上可将余额扣成负数 -- **`internal/logic/task/task.go:103-135`(`ExecuteTask`)**:任务领取用"先 SELECT 再 UPDATE"而非原子 CAS(对比同目录 `async_provider.go` 中正确的 `UpdateTaskCAS`),多副本同时跑可能并发领取并重复执行同一任务 -- **`internal/utility/crypto/crypto.go:21-78`**:AES-GCM 的 `Seal`/`Open` 未使用 AAD 参数(传 nil),密文未与所属记录上下文(如 tenant_id)绑定 -- **`internal/utility/turnstile/turnstile.go:29-64`**:未校验 siteverify 返回的 `Hostname`/`Action` 字段,同一 site key 跨域场景下 token 可能被跨站复用;整个包无单元测试 -- **`internal/middleware/rbac.go:365-380`(`matchPermission`)**:后缀匹配用 `strings.Contains` 子串搜索而非分段匹配,例如规则后缀 `/test` 会误匹配路径 `.../latest` -- **`internal/middleware/admin_auth.go:73-75`/`tenant_auth.go:78-80`**:无 `jti` 的旧 token 跳过会话吊销检查,强制登出/踢人对这类 token 无效直到自然过期 -- **`internal/logic/admin/channel.go:126-227`(`CloneChannel`)、`channel_oauth.go:248-307`**:渠道创建→密钥创建跨表写入未包在事务里,密钥插入失败会留下孤儿渠道记录 -- **`internal/logic/admin/promo_code.go:38-48`(`CreatePromoCode`)**:`req.Data map[string]interface{}` 未做字段白名单校验直接 Insert,而同文件 `UpdatePromoCode` 有白名单,两者处理不一致 -- **`internal/handler/setup/setup.go` + `internal/logic/admin/setup.go:49-56`**:系统初始化判断依赖"先查后插入"非原子操作,并发发起的两个初始化请求可能都通过"未初始化"判断,各自创建一条 super_admin 记录 - ---- - -## 🟢 低优先级 / 观察项 - -- `internal/logic/common/notification.go:444-456` 手写 `containsAny` 子串匹配循环,可直接用 `strings.Contains` -- `internal/logic/docs/openapi.go:44` 向 `Config().GetString(nil, ...)` 传入 `nil` context -- `internal/logic/common/batch_writer.go:74-77` 批量写入缓冲区满时静默丢弃最早记录,无计数/告警,用于 `bil_usage_logs` 等计费相关高频写入场景 -- `internal/logic/common/session.go:132-137`(`RevokeSession`)只做 DB 删除、不写 Redis 黑名单,全靠调用方手动配对调用 `MarkSessionRevoked`,函数签名未强制这一点 -- `internal/logic/monitor/alert_engine.go` 的 `"eq"` 条件分支用 `float64` 直接相等比较,对 CPU%/延迟/QPS 等连续型指标几乎永远不触发 -- `internal/logic/billing/wallet.go` 的 `CheckBalance` 全仓库无调用方,是死代码 -- `internal/logic/tenant/open_platform.go:405-445`(`WebhookConfigUpdate`)不检查受影响行数,ID 不存在时静默返回成功 -- `internal/logic/tenant/member_import.go:63-68` 用 `context.Background()` 启动导入 goroutine,丢失请求的 trace/request_id -- `internal/logic/admin/auth.go:371-385`(`RevokeSession`)未像其他函数一样限定 `user_type=admin`,该表是 admin/tenant 共用表 - ---- - -## 正面观察(值得保留的设计) - -- 钱包预扣使用 Redis Lua 脚本做到真正的原子操作(`billing.PreDeduct`),限流(QPS/并发)同样是很好的原子设计范例 -- `relay/channel/vertex/auth.go` 的 OAuth2 token 缓存用 `sync.Map` 双重检查 + `singleflight.Group` 合并并发刷新请求,是本次审查中并发处理写得最好的一处 -- `relay/scheduler/scheduler.go` 的全局降级逻辑(健康度全部过低时降级使用全部候选渠道)设计合理且有专门测试覆盖 -- `relay/dto` 各供应商 DTO 保持独立而非强行抽象共享基类,是合理的选择(协议差异本身很大) -- `internal/logic/task/async_polling.go` 对"崩溃窗口内成功但未结算"和"失败需退款"的补偿逻辑设计成熟,注释详细记录了历史踩坑(如 CAS 谓词 bug) -- JWT 实现(`internal/logic/common/jwt.go`)显式拒绝 `none` 算法且有单测覆盖;密码哈希(bcrypt cost=12)、AES-256-GCM 加密选型均规范 -- `relay/taskchannel/` 7 个适配器严格实现同一组 `TaskAdaptor` 接口方法,接口层面一致性好 -- 错误包裹整体规范,抽样统计 `fmt.Errorf` 使用 `%w` 196 处 vs `%v` 仅 3 处(且都是合理场景),未发现系统性丢失错误上下文 - ---- - -## 建议的修复优先级 - -1. **权限漏洞收尾**(`AdminPermissionGuard` fail-open)—— `member_model_scope`、IP 白名单越权已修复(补 owner/admin 角色校验),剩余 fail-open 影响面明确、修复成本低 -2. **退款流程补全** —— `RefundOrder` 仍是半成品,直接影响资金正确性 -3. **汇率双配置合并为单一方向** + **金额字段 float64 → decimal**(后者改动面大,可分阶段推进) -4. **安全硬化项**(错误日志脱敏、验证码防暴力破解、TOTP 防重放、Kling JWT 签名失败处理)—— 单项成本都不高,建议一次性排期 -5. **补齐核心模块单测**(billing/payment/admin/tenant),优先把本次发现的并发/越权场景写成回归测试,防止再次引入 diff --git a/relay/FLOAT64_RISK_ANALYSIS.md b/relay/FLOAT64_RISK_ANALYSIS.md deleted file mode 100644 index 85528fb..0000000 --- a/relay/FLOAT64_RISK_ANALYSIS.md +++ /dev/null @@ -1,220 +0,0 @@ -# Relay 层浮点运算风险分析报告 - -**分析范围**:`relay/` 目录(137 个非测试文件) -**分析日期**:2026-07-23 -**分析目标**:扫描涉及金额/计费的 float64 运算,识别精度丢失风险 - ---- - -## 一、风险等级定义 - -| 风险等级 | 定义 | 影响范围 | -|---------|------|---------| -| **🔴 高危** | 金额直接链式运算(乘除混合)、累计误差会传播到账本 | 钱包扣费、账单金额、对账 | -| **🟡 中危** | Token 计数 → 费用换算路径存在浮点除法,但单次计算 | 单笔计费、用量统计 | -| **🟢 低危** | 仅用于估算、权重计算、非金额场景 | 调度权重、粗略估算 | - ---- - -## 二、风险点清单 - -### 🔴 高危:金额直接链式运算(0 处) - -**好消息:relay 层没有直接的链式金额运算**。 - -所有金额计算(`PreDeduct`/`Settle`/`EstimateTaskCost`)都通过接口委托给 `internal/logic/billing/`,relay 层只传递 `float64` 结果,不进行二次运算。 - -**验证点**: -- `common/billing.go`:接口定义,只传递 `float64` 参数和返回值 -- `common/task_billing.go`:接口定义,只传递 `float64` 参数和返回值 -- `handler/relay_handler.go:393`:`PreDeduct` 调用,接收 `float64` 赋值给 `preDeductAmount`,无后续运算 -- `handler/task_handler.go:191`:`PreDeductTask` 调用,接收 `float64` 赋值给 `preDeductAmount`,无后续运算 - -**结论**:relay 层作为代理层,金额字段为**透传类型**,不承担计费逻辑,风险转移到 `internal/logic/billing/`。 - ---- - -### 🟡 中危:Token 计数 → 费用换算路径(8 处) - -#### 1. **音频 Token 估算**(3 处) - -| 文件 | 行号 | 代码 | 风险说明 | -|------|------|------|---------| -| `channel/openai/audio.go` | 50 | `estimatedTokens := len(body) / 1000` | TTS 音频字节数除以 1000 估算 token | -| `channel/openai/audio.go` | 94 | `estimatedTokens := int(verboseResp.Duration/60.0) * 150` | **float64 除法** + 整数乘法:音频时长(秒)÷ 60(分钟)× 150 token/分钟 | -| `channel/openai/audio.go` | 107 | `estimatedTokens := len(simpleResp.Text) / 4` | 文本长度除以 4 估算 token | - -**风险分析**: -- `Duration/60.0` 产生浮点除法,**若 Duration 为整数秒(如 90 秒),则 90/60.0 = 1.5,int(1.5) = 1,丢失 0.5 分钟** -- 丢失部分:`0.5 * 150 = 75 token`,约占实际 `90/60 * 150 = 225 token` 的 **33%** -- 单笔计费误差传播:若 token 单价为 $0.0001/token,则单次误差 = `75 * 0.0001 = $0.0075`,累计 10000 次 = **$75 短扣** - -**修复建议**: -```go -// 当前(有误差) -estimatedTokens := int(verboseResp.Duration/60.0) * 150 - -// 修复方案:先整数运算再取整 -estimatedTokens := (int(verboseResp.Duration) * 150 + 30) / 60 // +30 为四舍五入 -``` - ---- - -#### 2. **文本 Token 估算**(5 处) - -| 文件 | 行号 | 代码 | 用途 | -|------|------|------|------| -| `channel/claude/response.go` | 253, 297 | `estimated := responseTextBuf.Len() / 4` | Claude 流式响应 token 估算 | -| `channel/openai/converter.go` | 1103, 1349 | `estimated := len(text) / 4` | OpenAI 响应 token 估算 | -| `channel/openai/responses.go` | 485 | `usage.CompletionTokens = len(text) / 4` | 补全 token 估算 | -| `channel/openai/stream.go` | 69, 131 | `estimated := responseTextBuf.Len() / 4` | 流式 token 估算 | -| `channel/openai/realtime.go` | 224 | `estimated := len(message) / 4` | Realtime API 消息估算 | -| `handler/relay_handler.go` | 840 | `return len(body) / 4` | 请求 token 估算 | -| `helper/stream.go` | 116 | `return (len(text) + 3) / 4` | 通用 token 估算(**四舍五入**) | - -**风险分析**: -- **整数除法**:`len(text) / 4` 向下取整,1-3 字符直接归零(如 "Hi" → 0 token) -- 误差范围:单次最大丢失 3 字符 ≈ 0.75 token,累计误差取决于短文本频率 -- **`helper/stream.go:116` 已做四舍五入**:`(len(text) + 3) / 4` 等价于 `round(len/4)`,**其他 7 处未做** - -**影响评估**: -- 若上游返回完整 `usage`,这些估算值会被覆盖,**无风险** -- 若上游无 `usage`(如音频 TTS、某些流式端点),估算值直接进入计费,**有短扣风险** - -**修复建议**: -```go -// 统一使用 helper/stream.go 的四舍五入版本 -import "github.com/qianfree/team-api/relay/helper" -estimated := helper.EstimateTokens(text) -``` - ---- - -### 🟢 低危:非金额场景(3 处) - -#### 1. **异步任务计费比率**(2 处) - -| 文件 | 行号 | 代码 | 用途 | -|------|------|------|------| -| `taskchannel/volcengine/constants.go` | 17-18 | `28.0 / 46.0`, `22.0 / 37.0` | 视频输入折扣比率(含视频/不含视频单价之比) | -| `taskchannel/volcengine/adaptor.go` | 452-462 | `resolutionMultiplier()` 返回 `1.0/2.25/5.06` | 分辨率乘数(相对于 480p) | - -**风险分析**: -- `28.0 / 46.0 ≈ 0.6086956521739130...`(无限不循环小数),存储为 `float64` 后精度 ~15 位有效数字 -- 这些比率**用于 `EstimateTaskCost` 的 `ratios` 参数**,最终传递到 `internal/logic/billing/` 计算费用 -- **风险转移**:若 billing 层用 `ratio * basePrice` 计算,累计误差由 billing 层承担 - -**当前状态**:低危,因为: -1. 比率为**常量**,不累计(每次计费重新取值) -2. 单次计算:`cost = basePrice * resolution * videoInputRatio`,误差 < 0.01% -3. 若 billing 层改用 `NUMERIC` 精确计算,此处需改为传递分数字符串(如 `"28/46"`) - ---- - -#### 2. **调度权重降权**(1 处) - -| 文件 | 行号 | 代码 | 用途 | -|------|------|------|------| -| `scheduler/scheduler.go` | 137-143 | `w = w / 2` / `w = w / 4` | 根据健康度降低渠道权重 | - -**风险分析**: -- **整数除法**:`w` 为 `int` 类型,`w / 2` 和 `w / 4` 向下取整 -- 仅影响调度权重(非金额),且每次调度重新计算,无累计误差 -- **无风险** - ---- - -## 三、风险传播路径 - -``` -┌─────────────────────────────────────────────────────────────┐ -│ relay/ 层 │ -├─────────────────────────────────────────────────────────────┤ -│ │ -│ 1. Token 估算(🟡 中危) │ -│ ├─ audio.go: Duration/60.0 * 150 → estimatedTokens │ -│ ├─ stream.go: len(text) / 4 → estimatedTokens │ -│ └─ 估算值 → common.Usage{TotalTokens: estimatedTokens} │ -│ │ -│ 2. 预扣/结算(✅ 无运算) │ -│ ├─ PreDeduct(estimatedTokens, maxTokens) → float64 │ -│ ├─ Settle(usage, preDeductAmount) → float64 │ -│ └─ relay 层**只传递值,不计算** │ -│ │ -└──────────────────┬───────────────────────────────────────────┘ - │ 接口调用 - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ internal/logic/billing/ 层 │ -├─────────────────────────────────────────────────────────────┤ -│ │ -│ ⚠️ 金额计算核心逻辑(需单独扫描) │ -│ ├─ cost = inputTokens * inputPrice │ -│ ├─ cost = cost * modelMultiplier * tenantMultiplier │ -│ ├─ actualCost = baseCost (链式运算?) │ -│ └─ 若使用 float64 链式运算 → 🔴 高危 │ -│ │ -└─────────────────────────────────────────────────────────────┘ -``` - -**关键发现**: -- relay 层的 **float64 风险已隔离**,金额运算全部在 billing 层 -- **但 relay 层的 token 估算误差会作为输入传播到 billing 层** - ---- - -## 四、修复优先级 - -| 优先级 | 问题 | 文件 | 修复方案 | -|--------|------|------|---------| -| **P0** | 音频时长浮点除法短扣 | `channel/openai/audio.go:94` | 改为整数运算:`(Duration * 150 + 30) / 60` | -| **P1** | 文本 token 估算未四舍五入 | 7 处(见中危清单) | 统一调用 `helper.EstimateTokens(text)` | -| **P2** | 任务计费比率浮点常量 | `taskchannel/volcengine/constants.go` | billing 层改用分数或 NUMERIC 后,此处传递字符串 `"28/46"` | - ---- - -## 五、Relay 层结论 - -✅ **relay 层金额计算架构安全**: -- 无链式浮点运算 -- 所有 `cost/amount/quota` 字段为**接口传递类型**,不在 relay 层二次计算 -- 金额逻辑封装在 `internal/logic/billing/`,符合单一职责原则 - -⚠️ **存在 token 估算精度问题**: -- 音频时长换算短扣约 **33%**(P0 修复) -- 文本长度估算短扣 0-3 字符(P1 修复) - -🔍 **下一步行动**: -1. **立即修复 `audio.go:94` 的浮点除法**(影响所有音频计费) -2. 统一 token 估算为 `helper.EstimateTokens` -3. **扫描 `internal/logic/billing/` 层**(真正的金额运算核心) - ---- - -## 六、Billing 层待扫描清单 - -基于接口定义,billing 层需重点扫描以下计算路径: - -```go -// common/billing.go 接口定义暴露的计算链路 -type SettlementResult struct { - PreDeductAmount float64 // 预扣金额 - BaseCost float64 // 基础费用 = inputTokens * inputPrice + outputTokens * outputPrice - ActualCost float64 // 实际费用 = BaseCost * modelMultiplier * tenantMultiplier - RefundAmount float64 // 退款 = PreDeductAmount - ActualCost (if ActualCost < PreDeduct) - SupplementAmount float64 // 补扣 = ActualCost - PreDeductAmount (if ActualCost > PreDeduct) - // ... 多个 float64 字段的加减乘除 ... -} -``` - -**高危操作**: -1. `BaseCost = inputTokens * inputPrice + outputTokens * outputPrice + cacheTokens * cachePrice + ...`(多项累加) -2. `ActualCost = BaseCost * modelMultiplier * tenantMultiplier`(链式乘法) -3. `RefundAmount = PreDeductAmount - ActualCost`(可能为负,需 abs) -4. 钱包扣费:`newBalance = oldBalance - ActualCost`(累计误差传播到账本) - -**需验证**:billing 层是否已使用 `NUMERIC` 类型 + `decimal` 库,或仍在用 `float64`? - ---- - -**报告结束** diff --git a/test/webhook_receiver/main.go b/test/webhook_receiver/main.go deleted file mode 100644 index 089aa0b..0000000 --- a/test/webhook_receiver/main.go +++ /dev/null @@ -1,168 +0,0 @@ -package main - -import ( - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "io" - "log" - "net/http" - "os" - "strings" - "time" -) - -// WebhookEvent 接收到的 webhook 事件格式 -type WebhookEvent struct { - EventID string `json:"event_id"` - EventType string `json:"event_type"` - TenantID int64 `json:"tenant_id"` - Timestamp string `json:"timestamp"` - Data map[string]interface{} `json:"data"` -} - -// 全局变量:webhook 密钥,用于验签 -var secretKey string - -func main() { - // 从命令行参数或环境变量获取密钥 - secretKey = os.Getenv("WEBHOOK_SECRET") - if len(os.Args) > 1 { - secretKey = os.Args[1] - } - - if secretKey == "" { - log.Println("⚠️ 未提供 webhook 密钥,签名验证将跳过") - log.Println(" 用法: go run main.go ") - log.Println(" 或设置环境变量: WEBHOOK_SECRET=whk_xxx go run main.go") - log.Println() - } else { - log.Printf("🔑 已加载密钥: %s...%s\n", secretKey[:8], secretKey[len(secretKey)-4:]) - } - - port := os.Getenv("PORT") - if port == "" { - port = "9090" - } - - http.HandleFunc("/webhook", handleWebhook) - http.HandleFunc("/health", handleHealth) - http.HandleFunc("/", handleNotFound) - - addr := ":" + port - log.Printf("🚀 Webhook 接收服务启动在 http://localhost%s/webhook\n", addr) - log.Printf(" 健康检查: http://localhost%s/health\n", addr) - log.Printf(" 等待接收 webhook 事件...\n") - log.Println(strings.Repeat("=", 70)) - - if err := http.ListenAndServe(addr, nil); err != nil { - log.Fatalf("服务启动失败: %v", err) - } -} - -func handleHealth(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) -} - -func handleNotFound(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusNotFound) - json.NewEncoder(w).Encode(map[string]string{ - "error": "not_found", - "message": "请使用 POST /webhook 接收事件", - }) -} - -func handleWebhook(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, `{"error":"method_not_allowed"}`, http.StatusMethodNotAllowed) - return - } - - now := time.Now() - receiveTime := now.Format("2006-01-02 15:04:05.000") - - // 读取请求体 - body, err := io.ReadAll(r.Body) - if err != nil { - log.Printf("❌ [%s] 读取请求体失败: %v\n", receiveTime, err) - http.Error(w, `{"error":"read_body_failed"}`, http.StatusBadRequest) - return - } - defer r.Body.Close() - - // 提取关键 Header - signature := r.Header.Get("X-Webhook-Signature") - timestamp := r.Header.Get("X-Webhook-Timestamp") - eventType := r.Header.Get("X-Webhook-Event") - eventID := r.Header.Get("X-Webhook-ID") - - log.Printf("📩 [%s] 收到 Webhook 事件", receiveTime) - log.Println(strings.Repeat("-", 50)) - log.Printf(" Event ID: %s", eventID) - log.Printf(" Event Type: %s", eventType) - log.Printf(" Timestamp: %s", timestamp) - - // 签名验证 - if secretKey != "" { - expectedSig := computeSignature(secretKey, timestamp, body) - if hmac.Equal([]byte(signature), []byte(expectedSig)) { - log.Printf(" 签名验证: ✅ 通过") - } else { - log.Printf(" 签名验证: ❌ 失败!") - log.Printf(" 期望签名: %s", expectedSig) - log.Printf(" 实际签名: %s", signature) - } - } else { - log.Printf(" 签名验证: ⏭️ 跳过(未配置密钥)") - } - - // 解析 Payload - var event WebhookEvent - if err := json.Unmarshal(body, &event); err != nil { - log.Printf(" JSON 解析: ❌ 失败: %v", err) - log.Printf(" 原始 Body: %s", string(body)) - } else { - log.Printf(" Tenant ID: %d", event.TenantID) - log.Printf(" Event Time: %s", event.Timestamp) - - // 格式化输出 Data - if event.Data != nil { - dataJSON, _ := json.MarshalIndent(event.Data, " ", " ") - log.Printf(" Data:") - log.Printf(" %s", string(dataJSON)) - } - } - - // 原始 Body(美化的 JSON) - var prettyBody map[string]interface{} - if err := json.Unmarshal(body, &prettyBody); err == nil { - beautified, _ := json.MarshalIndent(prettyBody, " ", " ") - log.Printf(" 完整 Payload:") - log.Printf(" %s", string(beautified)) - } else { - log.Printf(" 原始 Body: %s", string(body)) - } - - log.Println(strings.Repeat("-", 50)) - log.Printf(" ✅ 已处理 (%d bytes)", len(body)) - log.Println(strings.Repeat("=", 70)) - - // 返回 200 OK - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]string{ - "status": "received", - "event_id": eventID, - }) -} - -// computeSignature 计算 HMAC-SHA256 签名,与系统实现一致 -// 签名内容: timestamp + "." + body -func computeSignature(secret, timestamp string, body []byte) string { - mac := hmac.New(sha256.New, []byte(secret)) - mac.Write([]byte(timestamp + "." + string(body))) - return hex.EncodeToString(mac.Sum(nil)) -} diff --git a/代码分析结果.md b/代码分析结果.md deleted file mode 100644 index 48d4220..0000000 --- a/代码分析结果.md +++ /dev/null @@ -1,106 +0,0 @@ -# Team-API 代码分析结果 - -> **生成时间**:2026-07-25 -> **分析范围**:约 6.9 万行手写业务代码 -> **已排除**:脚手架生成代码(`controller/` `service/` `model/do/` `model/entity/` `dao/`)、`web/` 前端、`relay/` 顶级代理目录、测试代码 -> **覆盖模块**:`api/` `internal/logic/` `internal/cmd/` `internal/consts/` `internal/handler/` `internal/response/` `internal/middleware/` `internal/utility/` -> **分析方法**:4 个并行子 agent 分模块深度审查 + 人工 spot-check 逐行验证所有 P0 结论 - ---- - - -## 🟠 P1 — 严重问题(建议本迭代修复) - -### 🔐 P1-Security 安全类 - -#### - [ ] P1-S1 RBAC 未匹配路由默认放行 - -- **位置**:`internal/middleware/rbac.go:332-337` -- **问题描述**: - ```go - perm := matchPermission(r.Method, r.URL.Path) - if perm == "" { - // 默认放行:未配置权限规则的接口,对已认证 admin 放行 - r.Middleware.Next() - return - } - ``` - 注释自承是过渡策略。新增 admin 接口忘配规则 = 默认对所有已认证 admin 开放。 -- **影响**:越权风险取决于开发者纪律而非系统约束,是 RBAC 的根本性缺口。 -- **修复建议**: - 1. 改为默认 403。 - 2. 启动期扫描 `api/admin/v1/` 的路由注解,与 `adminPermissionRules` 做 diff,未覆盖的打 warning(最好接入 CI 检查)。 -- **验证方法**:临时新增一个未配规则的 admin 接口,请求应返回 403。 - -#### - [ ] P1-S2 TOTP / OpenAPI nonce 防重放 fail-open - -- **位置**:`internal/logic/common/security.go:39-42`、`internal/middleware/open_auth.go:145-159` -- **问题描述**:两处都在 Redis 故障时 `return true` 放行(注释写"避免依赖故障导致无法登录")。 -- **影响**:Redis 抖动期间同一 TOTP 码可无限重放(2FA 退化为单因素);HMAC 签名请求可在时间窗内重放。 -- **修复建议**:敏感操作(2FA、支付类 webhook)Redis 不可用时 **fail-closed**(拒绝)+ 触发告警;非敏感操作可放行但记指标。 - -#### - [ ] P1-S3 consumeBackupCode TOCTOU - -- **位置**:`internal/logic/common/security.go:495-518` -- **问题描述**:先 `GetUserTOTPSecret` 读全部 backup codes → 内存删一个 → 整体写回,两步非原子。 -- **影响**:两个并发请求用不同恢复码可同时读到完整列表并各自写回,导致其中一个恢复码被"复活"。 -- **修复建议**:用单条 UPDATE 配合 JSONB 操作(`jsonb_remove` 或过滤数组),或在事务内 `SELECT FOR UPDATE`。 - - -#### - [ ] P1-S5 OAuth 自动注册用户名冲突暴露技术细节 - -- **位置**:`internal/logic/tenant/oauth.go:236-257` -- **问题描述**:用户名冲突重试一次失败后,直接返回原始 DB error(含表名/约束名)。 -- **影响**:违反 CLAUDE.md「message 必须中文,禁止暴露技术细节」。 -- **修复建议**:捕获 `IsDuplicateKeyError` 返回业务错误「登录冲突,请重试」。 - - -#### - [ ] P1-S7 generateState / randomHex 忽略 rand.Read 错误 - -- **位置**:`internal/logic/tenant/oauth.go:461-470` -- **问题描述**:`rand.Read(b)` 的 error 被 `_` 吞。系统熵池枯竭时返回全零 bytes,state 可预测。 -- **修复建议**:检查 error,失败时返回 500。 - ---- - -### 💰 P1-Money 金额精度类(违反 CLAUDE.md 强约束) - -#### - [ ] P1-M1 WalletInfo 全程 float64(billing 包最大精度破口) - -- **位置**:`internal/logic/billing/wallet.go:41-49` -- **问题描述**:`Balance`/`FrozenBalance`/`WarningThreshold` 全是 float64。`GetWallet` 从 DB(NUMERIC/decimal)读出后立即降级为 float64 缓存。entity 已是 decimal,缓存层把它丢了。 -- **影响**:全链路比较/运算在 float64 上,违反「金额必须 decimal」强约束;下游 `CheckBalanceWarning`、`AvailableBalance` 全受影响。 -- **修复建议**:`WalletInfo` 字段改 `decimal.Decimal`,对外暴露 `AvailableBalance()` 返回 decimal,API Res 出口再 `.InexactFloat64()`。 - -#### - [ ] P1-M2 member_quota 用 HINCRBYFLOAT - -- **位置**:`internal/logic/billing/member_quota.go:67` -- **问题描述**:Redis 用 `HINCRBYFLOAT` 累加 `quota_used`,正是规范明文要杜绝的反模式。 -- **修复建议**:与 wallet 一致改整数 micro-USD(`billing.ToMicro`/`FromMicro` + `HINCRBY`),或文档化为"非资金控制线允许 float64"并加注释。 - -#### - [ ] P1-M3 API 入口金额字段大面积 float64 - -- **位置**: - - `api/admin/v1/dashboard.go:260`(AdminWalletAdjustReq.Amount) - - `api/admin/v1/tenant_level.go:14,35,50`(CumulativeRechargeThreshold) - - `api/admin/v1/order_admin.go:25-27` - - `api/admin/v1/promo.go:22,72` - - `api/admin/v1/monitor.go:112,131`(Threshold) - - `api/tenant/v1/order_tenant.go:20,52-54,95,107` - - `api/tenant/v1/api_key.go`(TotalQuota) -- **问题描述**:入口 Req 金额字段直接用 float64 参与运算。 -- **修复建议**:入口字段改 decimal,或入口立即 `billing.NewFromFloat(f)` 转进 decimal 再运算。 - -#### - [ ] P1-M4 promo_code 折扣用 float64 计算 - -- **位置**:`internal/logic/tenant/promo_code.go:133-153` -- **问题描述**:`amount * promo.DiscountValue / 100` 走 float64,且 `validatePromoCodeInternal` 返回 `map[string]any`。 -- **修复建议**:改 decimal 运算;返回值改 `PromoValidationResult struct`。 - -#### - [ ] P1-M5 CheckBalanceWarning 用 float64 比较 - -- **位置**:`internal/logic/billing/alert.go:39,124` -- **问题描述**:`available`、`WarningThreshold` 均 float64,违反「金额比较用 .GreaterThan/.IsZero」。 -- **修复建议**:依赖 P1-M1 的 WalletInfo decimal 化后自然解决。 - ----