chore:移除无用文件

This commit is contained in:
martin committed 2026-07-27 22:59:33 +08:00
1 parent 274293462d
commit bc2bbe747f
5 files changed
-608

No files matched your search

-1
View File
@@ -1 +0,0 @@
0.1.4
-113
View File
@@ -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),优先把本次发现的并发/越权场景写成回归测试,防止再次引入
-220
View File
@@ -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`?
---
**报告结束**
-168
View File
@@ -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 <secret_key>")
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))
}
-106
View File
@@ -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 化后自然解决。
---