Add static security/quality audit reports for all 18 Go service modules under module/, plus a consolidated index (docs/audit/README.md) with per-module statistics, top risks, cross-module systemic defects and a phased TODO list (T1-T19). No production code is modified.
776 lines
70 KiB
Markdown
776 lines
70 KiB
Markdown
# 审计报告:module/finance/wallet
|
||
|
||
## 1. 模块概览
|
||
|
||
`module/finance/wallet`(绝对路径 `D:\work\bsm-infra\full\module\finance\wallet`)是 BSM 财务域的**资金核心模块**,以 gRPC + grpc-gateway + 动态 HTTP 代理三种方式对外暴露,共 4 个 gRPC 服务 23 个方法:
|
||
|
||
| 服务 | 方法 | 定位 |
|
||
|---|---|---|
|
||
| `wallet.Basic` | GetWallet、SetPayPassword、BindPaymentId、Transactions、AddBankCard、GetBankCard、RmBankCard、ApplyCash | 钱包账户/余额/支付密码/银行卡/流水查询/提现申请 |
|
||
| `wallet.Payment` | Hello、Way、Get、ByOrder、ByCharge、Callback | 充值下单、订单支付、支付结果回调 |
|
||
| `wallet.Wechat` | JsapiPreOrder、AppPreOrder、NativePreOrder、Transfer、WxCallback | 微信支付与转账 |
|
||
| `wallet.Alipay` | WapPay、PagePay、AppPay、Transfer | 支付宝支付与转账 |
|
||
|
||
数据模型(PostgreSQL,`internal/models/*.go`):
|
||
|
||
- `wallet_basic`:钱包主表,`balance` / `withdrawal_balance` 均为 `int64`,**单位分**(`wallet_basic.go:21-22`);
|
||
- `wallet_record`:资金流水表,`money` / `fee` 单位分(`wallet_record.go:24-25`);
|
||
- `wallet_payment`:支付单(充值/订单支付)(`wallet_payment.go:16-31`);
|
||
- `wallet_apply_cash`:提现申请单(`wallet_apply_cash.go:16-28`);
|
||
- `wallet_bank`:银行卡(`wallet_bank.go:16-29`);
|
||
- `wallet_refund`:退款记录(`wallet_refund.go:16-25`,全库无写入方)。
|
||
|
||
模块没有独立数据库迁移脚本,表结构依赖 `database.AppendMigrate` 注册(各 model `init()`)+ SDK 的 `AutoMigrate`;而 `service/dependencies.go` 与 `cmd/main/main.go:25` 调用 `with.Databases(cfg, nil)`,SDK 在 `options == nil` 时把 `IsAutoMigrate` 置为 `false`(`bsm-sdk/core/database/sql/postgresql.go:17`),因此**表结构与索引实际不会被自动创建或校正**,这会影响下文所有索引类结论的解释。
|
||
|
||
整体健康度结论:**该模块目前是一个未完成、不可上线的骨架**。资金入账链路(支付回调→余额)整体缺失,提现链路只有"提交申请"一步,而已经接线的部分存在崩溃级缺陷与可越权的支付状态接口。
|
||
|
||
## 2. 审计范围与方法
|
||
|
||
### 已覆盖
|
||
|
||
- **全部非 pb Go 源码 48 个文件**:`cmd/`(2)、`internal/config`(1)、`internal/excode`(1)、`internal/impl`(1)、`internal/logic/alipay`(5)、`internal/logic/basic`(9)、`internal/logic/payment`(6)、`internal/logic/wechat`(6)、`internal/models`(7)、`internal/server`(5)、`service/`(2)。
|
||
- **proto 服务与消息定义**:`proto/{wallet,payment,wechat,alipay,const}.proto` 全部读完(用于确认金额单位、字段语义、默认值语义)。
|
||
- **配置**:`etc/wallet_{dev,test,prod}.yaml`、`etc/supervisor.bsm-finance-wallet.conf`。
|
||
- **测试资产**:`test/*.http`(5 个)、`test/readme.md`。
|
||
- **文档**:`README.md`、`wiki/api/12-wallet.md`(用于"文档-实现一致性"比对)。
|
||
- **跨模块交叉验证(只读)**:`full/go.work`、`module/**` 搜索 `wallet` / `WalletPayment` / `by_order` 引用;`pkgs/all/internal/server/authorization.go`(同族鉴权范式);`bsm-infra/gateway/internal/{http,rpc,middleware,discovery}`(真实流量入口的匿名清单判定);`bsm-sdk/core/{service/meta.go,types/db.go,crypto/token/jwt.go,database/new.go,database/sql/postgresql.go,utils/crypto.go}`;`gopay@v1.5.123/alipay/{client,param,payment_api}.go`。
|
||
- **子域覆盖矩阵**:账户与余额(basic/get_wallet、wallet、models/query、models/wallet_basic);资金流水(basic/transactions、models/wallet_record、models/query.FindWalletRecords);支付渠道与回调(payment/*、wechat/*、alipay/*、models/wallet_payment);提现申请与审核(basic/apply_cash、models/wallet_apply_cash);银行卡与敏感信息(basic/{add,get,rm}_bank_card、models/wallet_bank);进程与配置(cmd/main、server/new、config、impl、service)。
|
||
|
||
### 抽样方式
|
||
|
||
按子域分批全文精读 `logic/` 与 `models/`(资金正确性核心),`pb/` 生成代码仅按需检索以确认字段类型与路由;结论全部以源码行为为准,凡是依赖"上游/下游是否存在"的判断均通过全仓 grep 核实(例如 `ChargeWallet` 无调用方、`wallet_payment` 无外部消费者)。
|
||
|
||
### 执行过的命令及结果
|
||
|
||
| 命令 | 工作目录 | 结果 |
|
||
|---|---|---|
|
||
| `gofmt -l .` | `module/finance/wallet` | 空输出(无格式问题) |
|
||
| `go vet ./...` | `module/finance/wallet` | 退出码 0,无告警(注:`go vet` 不报未使用的赋值型变量,如 `get_wallet.go:36` 的 `totalMoney`) |
|
||
|
||
两条命令均在首次执行即通过,未超时。
|
||
|
||
### 未覆盖 / 无法验证
|
||
|
||
- **无真实数据库与索引 DDL**:仓库内无任何 `.sql`,`AutoMigrate` 运行期关闭,因此"索引是否缺失"依据的是 GORM 结构体标签本身,而非线上实际索引;已在相关问题中标注。
|
||
- **无真实密钥/证书**:`etc/wallet_prod.yaml:57` 指向 `/usr/local/cert/wechat/apiclient_key.pem`,该文件不在仓库内,无法验证私钥保护措施与文件权限。
|
||
- **无运行期验证**:未启动服务、未压测、未做黑盒验证;所有"可利用性"结论均为代码级推演。特别是"一可用 token 即可改支付状态"这一点,我确认了网关对匿名接口**不转发 authorization 头**(`gateway/internal/http/http.go:169-178`、`gateway/internal/rpc/rpc.go:165-196`),所以该接口在**经网关**时会被自身 `ParseMetaCtx` 拦住;但**直连 gRPC 端口**(生产 `0.0.0.0:12238`,见 `etc/wallet_prod.yaml:2`)或在同族 `pkgs/all` 网关(把 `wallet.payment.Callback` 当作匿名)场景下无此保护,判定为 P0 的依据是"服务自身未做任何渠道身份校验"这一代码事实。
|
||
- **提现审核、退款/冲正、对账任务**:模块内不存在对应实现,无法审计其实现,只能作为缺失项列出。
|
||
- 未审计 `pb/` 生成代码与 `go.sum` 依赖供应链。
|
||
|
||
## 3. 问题清单
|
||
|
||
### P0
|
||
|
||
#### 1. 新增银行卡(channel=2)必然 nil 指针 panic,导致钱包进程整体崩溃
|
||
|
||
- **位置**:`module/finance/wallet/internal/logic/alipay/alipay.go:52-53`、`alipay.go:17-50`、`alipay.go:62-69`;调用方 `internal/logic/payment/by_charge.go:102`、`internal/logic/payment/by_order.go:118`
|
||
- **证据**:
|
||
|
||
```go
|
||
// alipay.go:17-50 —— Body 从未初始化
|
||
func NewAlipay() (*AliPay, error) {
|
||
client, err := alipay.NewClient(config.Spec.Alipay.AppID, config.Spec.Alipay.PrivateKey, config.Spec.Alipay.IsProd)
|
||
...
|
||
return &AliPay{
|
||
Client: client,
|
||
// ctx: context.Background(), // ctx 也被注释掉
|
||
}, nil
|
||
}
|
||
|
||
// alipay.go:52-53 —— 对 nil 的 *gopay.BodyMap 调用 Set
|
||
func (srv *AliPay) SetBody(body, orderNo string, amount int64) {
|
||
srv.Body.Set("subject", config.Spec.Wallet.Name).
|
||
```
|
||
|
||
`Body` 声明为 `*gopay.BodyMap`(`alipay.go:14`),在 `NewAlipay()` 中从未分配;`SetBody` 第一行即解引用。`srv.ctx` 同理恒为 nil(`alipay.go:63`、`alipay.go:68` 传给 `TradeWapPay`/`TradeAppPay`)。服务侧未安装任何 panic 恢复拦截器:`internal/server/new.go:23` 使用裸 `grpc.NewServer()`,`cmd/main/main.go:31-50` 用 SDK 的 `service.New/Start`,而 `bsm-sdk/core/service/service.go` 全文无 `recover`/`UnaryInterceptor`。
|
||
- **影响**:任意持 token 用户调用 `POST /wallet.Payment/ByOrder` 或 `/wallet.Payment/ByCharge`,携带 `"pay_channel": 2`(支付宝渠道),即可在 `SetBody` 处触发空指针。由于无 recover,panic 会终止整个进程 → **远程未鉴权成本极低的整站拒绝服务**(进程由 supervisor `autorestart=true` 拉起,但可被持续打死,见 `etc/supervisor.bsm-finance-wallet.conf:5`)。修好 Body 后,即便不 panic,`srv.ctx == nil` 也会把 nil context 传给支付宝 SDK。
|
||
- **建议**:`NewAlipay()` 中 `Body: &gopay.BodyMap{}` 且 `ctx: context.Background()`(或由调用方以请求 ctx 注入);或在 `AliPay` 上改为 `NewAlipay(ctx)` 并保留 ctx。同时应在 `grpc.NewServer()` 增加 panic-recovery 拦截器(`grpc.ChainUnaryInterceptor(recoveryInterceptor)`),把 panic 转成 `codes.Internal` 而不是进程退出。补充一条针对 channel=2 的集成测试。
|
||
|
||
#### 2. 充值入账链路整体缺失:用户付款成功后钱包余额永不增加
|
||
|
||
- **位置**:`internal/logic/payment/by_charge.go:20-137`、`internal/logic/wechat/wx_callback.go:15-41`、`internal/logic/payment/callback.go:15-40`、`internal/models/query.go:134-165`
|
||
- **证据**:
|
||
|
||
```go
|
||
// by_charge.go:129-137 —— 只写支付单,不动余额
|
||
if err := models.CreatePaymentRecord(&paymentRecrod); err != nil { ... }
|
||
return &pb.PaymentReply{Code: 200, Result: replyResult}, nil
|
||
|
||
// wx_callback.go:33-40 —— 验签解密后没有任何入账动作
|
||
res, err := notifyReq.DecryptPayCipherText(config.Spec.WeChat.APIV3Key)
|
||
...
|
||
// Todo: 业务逻辑
|
||
payResult, _ := json.Marshal(res)
|
||
return &pb.CallBackReply{Code: "SUCCESS", Message: string(payResult)}, nil
|
||
```
|
||
|
||
全仓检索确认:定义于 `models/query.go:135` 的唯一入账函数 `ChargeWallet`(写 `balance`/`withdrawal_balance` + 建 `wallet_record`)**没有任何调用方**:
|
||
|
||
```
|
||
grep -rn "ChargeWallet" module/ → 仅 models/query.go:134 (定义)
|
||
```
|
||
|
||
`models.UpsetWalletPaymentByIdentity`(`query.go:119-122`)也只改状态与消息,不入账。`callback.go:25-31` 仅按客户端传入的 `callback_status` 改状态。
|
||
- **影响**:微信/支付宝渠道确实收到用户资金(`ByCharge` 真实调用第三方下单),但钱包侧 `balance` 永远停留在旧值(新钱包恒为 0)。后果是**必然的资金/数据不一致**:用户实付未到账 → 客服投诉;运营若手工改库补账,则与第三方对账单、`wallet_record` 三方不一致且无审计留痕;同时因为 `wx_callback.go:40` 直接回 `SUCCESS`,微信会认为通知成功而不再重试,**遗漏的入账不会自动补回**。
|
||
- **建议**:`WxCallback` 解密成功后必须在**单个数据库事务**内完成:以微信 `out_trade_no`/`attach` 定位 `wallet_payment` 并加行锁校验(状态必须为"创建/支付中"、金额与 `amount` 一致)→ 调 `ChargeWallet` 入账 → 写 `wallet_record`(附 `out_trade_no`=微信 `transaction_id`、`pay_channel`、`trade_type`)→ 置状态成功 → 最后才返回 SUCCESS。未实现前**不得返回 SUCCESS**(返回 FAIL 让渠道重试),并禁止 `ByCharge` 对未入账渠道开放。
|
||
|
||
#### 3. 提现申请不做资金冻结、不校验可提现余额,可重复申请超额提现
|
||
|
||
- **位置**:`internal/logic/basic/apply_cash.go:25-53`
|
||
- **证据**:
|
||
|
||
```go
|
||
// apply_cash.go:25-37
|
||
if in.Amount == 0 || in.Channel == 0 {
|
||
return nil, errcode.ErrInvalidArgument
|
||
}
|
||
myWallet, err := models.GetWalletByPassportIdentity(auth.ID, auth.Identity)
|
||
...
|
||
if in.Amount > myWallet.Balance {
|
||
return nil, excode.ErrBalanceNotEnough
|
||
}
|
||
|
||
// apply_cash.go:39-53 —— 仅落一条申请单,余额/frozen 均不变
|
||
data := &models.WalletApplyCash{ WalletIdentity: myWallet.Identity, Amount: in.Amount, ... }
|
||
err = impl.DBService.Create(&data).Error
|
||
```
|
||
|
||
`WalletBasic` 只有 `Balance` 与 `WithdrawalBalance` 两个字段,**没有冻结金额(frozen)字段**(`models/wallet_basic.go:14-24`);`wallet_apply_cash` 也没有 `cash_no`(`CashNo` 字段存在但从未赋值,`wallet_apply_cash.go:21`)。同时 `in.Amount` 是 `int64`,负数能通过 `Amount == 0` 与 `Amount > Balance` 两道校验(负数不大于余额)。
|
||
- **影响**:① 同一用户可反复提交等额提现申请(每次都能通过余额校验),N 次申请 = N 倍余额的提现请求;一旦后端"审核通过即打款"(当前无审核实现,见 P1-5),即造成**超额提现的直接资金损失**。② 负数金额申请会把"打款金额"变成向用户扣款/反向转账语义(取决于下游实现),并污染对账。③ 请提现的同时余额未冻结,用户可在提现审核期间把同一笔钱在 `ByOrder`(channel=3) 花掉,形成"钱既花了又提走了"的双花。④ `Channel` 仅校验非 0,`channel=99`、`channel=-1` 均被接受(`apply_cash.go:25`)。
|
||
- **建议**:引入 `frozen_balance`(或独立的 `wallet_hold` 表);`ApplyCash` 在**一个事务 + 行锁**(`SELECT ... FOR UPDATE`)内:校验 `amount > 0` 且 `amount <= withdrawal_balance - frozen`、校验 channel 白名单、扣减可用余额并增加冻结额、生成唯一 `cash_no`、写入申请单。提现成功/失败/过期时按状态机反向解冻或落账。
|
||
|
||
#### 4. 支付状态接口由客户端单方决定,可把未支付的支付单改成"支付成功"
|
||
|
||
- **位置**:`internal/logic/payment/callback.go:15-40`、`internal/models/query.go:118-122`
|
||
- **证据**:
|
||
|
||
```go
|
||
// callback.go:25-31
|
||
var status int8 = 0
|
||
if in.CallbackStatus {
|
||
status = 2 // 2=支付成功
|
||
} else {
|
||
status = -1
|
||
}
|
||
err = models.UpsetWalletPaymentByIdentity(auth.Identity, in.Identity, status, in.CallbackMsg)
|
||
|
||
// query.go:119-122 —— 无状态机校验,直接覆盖
|
||
err := impl.DBService.Model(&WalletPayment{}).Where("passport_identity=? and identity=?", ...).
|
||
Updates(map[string]interface{}{"status": CallbackStatus, "call_back_msg": CallbackMsg}).Error
|
||
```
|
||
|
||
该接口是**给支付渠道的服务端回调入口**(proto 注释"回调更新支付的结果和状态",`proto/payment.proto:21-22`),却只依赖调用方自己的 JWT 身份,用请求体里的一个布尔值决定资金状态。它还被写进了匿名清单 `etc/wallet_dev.yaml:40`(`wallet.payment.Callback`)与 `etc/wallet_test.yaml:33`;真实流量入口的网关按方法名精确匹配匿名(`gateway/internal/rpc/rpc.go:157-163` 的 `utils.In` 为精确比较,`bsm-sdk/engine/utils/array.go:5-13`)。此外状态机任意流转:已是成功(2)/失败(-1)的单可被无限次改写,且没有与渠道流水号/金额绑定的校验,没有幂等键。
|
||
- **影响**:任何拿到有效 token 的用户(或任何能直连 gRPC 端口 `0.0.0.0:12238` 的内网/越权访问者)都能把自己的支付单改成"支付成功",即**凭空制造已支付凭证**。当前余额不入账(见 P0-2)使得直接套现被掩盖,但该状态一旦被订单/发货/结算侧消费即等价于"未付款拿走商品";同时渠道真实回调与伪造回调无法区分,事故后无法追责。
|
||
- **建议**:删除面向客户端的 `Payment.Callback`(或将其限定为仅内网/管理角色,`service.ParseMetaCtx(ctx, &service.ParseOptions{MustPrivateAllow: true, RoleValue: "..."})`);支付结果只能由经过验签的渠道回调(`WxCallback`)或主动查单写入。所有状态写入必须走显式状态机(`创建→支付中→成功/失败`,终态不可回改)并以渠道流水号做幂等键。
|
||
|
||
#### 5. 敏感信息(卡号/身份证/姓名/手机号)明文入库、明文出参
|
||
|
||
- **位置**:`internal/models/wallet_bank.go:21-25`、`internal/logic/basic/add_bank_card.go:38-53`、`internal/logic/basic/get_bank_card.go:37-48`
|
||
- **证据**:
|
||
|
||
```go
|
||
// wallet_bank.go:21-25 —— 无加密/无掩码
|
||
CardNo string `gorm:"column:card_no;type:varchar(255);not null;" json:"card_no"` // 银行卡号
|
||
CardOwner string `gorm:"column:card_owner;type:varchar(255);not null;" json:"card_owner"` // 持卡人姓名
|
||
IDCard string `gorm:"column:id_card;type:varchar(255);not null;" json:"id_card"` // 持卡人身份证号
|
||
Phone string `gorm:"column:phone;type:varchar(20);not null;" json:"phone"` // 银行预留的手机号
|
||
|
||
// get_bank_card.go:37-48 —— 全量回传
|
||
data = append(data, &pb.BankCardInfo{ BankNumber: card.CardNo, ..., Phone: card.Phone, CardOwner: card.CardOwner, IdCard: card.IDCard, ... })
|
||
```
|
||
|
||
入库时也是原样直存(`add_bank_card.go:40-46`)。全仓未发现任何字段级加密(SDK 提供了 `crypto/encipher`,模块内只用于 JWT 密钥初始化,`config.go:86`)。
|
||
- **影响**:一次数据库/备份泄露即造成全量持卡人实名信息(PAN + 身份证 + 手机号)泄露,属重大合规风险(PCI-DSS/个人信息保护法);任何持有账号的登录态被 XSS/中间人窃取后,攻击者可完整读取用户银行卡与身份信息;`README.md:16` 声称"安全加密:支付密码加密、签名验证",但未见字段级加密实现。
|
||
- **建议**:卡号仅存 `card_no_masked`(如 `6222****1234`)+ `card_no_hash`(用于查重)+ 密文(KMS/信封加密,密钥不入库不入代码);身份证/手机号同理加密存储;出参一律掩码;日志与错误信息禁止打印这些字段;对 `GetBankCard` 增加二次验证(支付密码/短信)后再返回明文尾号。
|
||
|
||
### P1
|
||
|
||
#### 6. 支付渠道密钥硬编码进仓库,且 SDK 调试日志常开
|
||
|
||
- **位置**:`etc/wallet_prod.yaml:52-62`、`etc/wallet_dev.yaml:69-79`、`internal/logic/wechat/wechat.go:34-35,68`、`internal/logic/alipay/alipay.go:22,27`
|
||
- **证据**:
|
||
|
||
```yaml
|
||
# etc/wallet_prod.yaml:53-60 —— 真实商户号 + 明文 APIv3 密钥
|
||
WeChat:
|
||
AppID: wx9a2f163207219776
|
||
SerialNo: 209F629E63E56D2366361969B51FBA0ADCF69F55
|
||
APIV3Key: MakeSupplyChainFinance1123581321
|
||
MchID: 1670561983
|
||
```
|
||
|
||
```go
|
||
// wechat.go:68 / alipay.go:27 —— 无条件打开 Debug
|
||
client.DebugSwitch = gopay.DebugOn
|
||
```
|
||
|
||
gopay 在 `DebugOn` 时会打印完整请求参数(含金额、openid、attach、签名):`gopay@v1.5.123/alipay/client.go:156-158,236-238`。
|
||
- **影响**:APIv3 密钥是微信支付回调报文解密与平台证书校验的根密钥,进入 Git 历史即视为已泄露;持有它可伪造/解密支付通知(可直接配合 P0-2 实现"凭空入账")。Debug 日志会把支付要素写入磁盘文件日志(`Log.Mode: file`,`wallet_prod.yaml:32-37`),扩大泄露面。
|
||
- **建议**:立即轮换微信商户 APIv3 密钥、平台证书与商户私钥,密钥改由 KMS/Vault 或环境变量注入(配置只留引用名),并在仓库历史中清理;`DebugSwitch` 必须由配置控制且生产默认 `DebugOff`。
|
||
|
||
#### 7. 提现审核/状态机/放款实现完全缺失,提现永远无法闭环
|
||
|
||
- **位置**:`internal/logic/basic/apply_cash.go:49-59`、`internal/models/wallet_apply_cash.go:26`、proto 侧无对应 RPC
|
||
- **证据**:
|
||
|
||
```go
|
||
// apply_cash.go:55-59 —— 只返回"已生成提现单",无后续
|
||
//生成提现单
|
||
return &pb.StatusReply{ Message: vars.OK, Timeseq: time.Now().UnixMilli() }, nil
|
||
```
|
||
|
||
```go
|
||
// wallet_apply_cash.go:26 —— 状态字段有定义
|
||
Status int8 `gorm:"column:status;default:0;" json:"status"` // 提现状态 -1:提现失败 0:待处理 1:提现成功
|
||
```
|
||
|
||
全仓检索 `WalletApplyCash`:除定义(`models/wallet_apply_cash.go:16`)与创建(`apply_cash.go:49`)外无任何读写;`proto/` 中没有审核/驳回/放款 RPC(`grep "ApplyCash" proto/` 仅 `wallet.proto:30,133`);`internal/logic/{alipay/transfer.go:11-23}` 与 `{wechat/transfer.go:11-23}` 的转账函数都是空实现(`// TODO: add your logic code`)。
|
||
- **影响**:`wallet_apply_cash` 只进不出,用户申请永远不会变为成功/失败;没有审核即没有权限点与操作留痕;没有放款即资金无法出账(业务不可用),而"手工改库放款"会彻底破坏对账与审计。状态机不存在导致"同一提现单被多次处理/失败单重复处理"的风险在设计上无法排除。
|
||
- **建议**:补齐提现状态机(`待审核→审核通过→放款中→成功/失败/已驳回`,含终态不可回改与幂等键 `cash_no`)、审核接口(独立管理角色 + 权限点 + 操作审计日志)、放款后回调更新,以及失败/驳回时的解冻冲正;同时对账任务比对 `wallet_apply_cash` 与渠道转账流水。
|
||
|
||
#### 8. 余额与流水写入不原子、无事务、无幂等键,存在部分失败与重复入账风险
|
||
|
||
- **位置**:`internal/models/query.go:135-165`、`internal/logic/basic/wallet.go:53-63`、`internal/logic/payment/by_order.go:138-155`
|
||
- **证据**:
|
||
|
||
```go
|
||
// query.go:141-162 —— 改余额与写流水是两条独立语句,且 CreateTradeRecord 的 error 被丢弃
|
||
data := map[string]interface{}{
|
||
"balance": wallet.Balance + amount,
|
||
"withdrawal_balance": wallet.WithdrawalBalance + amount,
|
||
}
|
||
err = impl.DBService.Model(&WalletBasic{}).Where("identity=?", wallet.Identity).UpdateColumns(data).Error
|
||
if err != nil { return err } else {
|
||
record := &WalletRecord{ WalletIdentity: wallet.Identity, TransType: 1, InTradeNo: order_no, Money: amount, ... }
|
||
CreateTradeRecord(record) // 返回值未接收
|
||
}
|
||
```
|
||
|
||
```go
|
||
// wallet.go:53-63 —— 读改写、无锁、无事务、无余额下限断言
|
||
data := map[string]interface{}{
|
||
"balance": srv.Body.Balance - amount,
|
||
"withdrawal_balance": srv.Body.WithdrawalBalance - amount,
|
||
}
|
||
err := impl.DBService.Model(&models.WalletBasic{}).Where("identity=?", srv.Body.Identity).UpdateColumns(data).Error
|
||
```
|
||
|
||
```go
|
||
// by_order.go:139-146 —— 余额扣减与支付单落库分离(跨语句部分失败)
|
||
walletSrv, err := basic.NewWallet(auth.Identity, in.Password, amount)
|
||
...
|
||
err = walletSrv.TradeConsum(amount)
|
||
...
|
||
if err := models.CreatePaymentRecord(&paymentRecrod); err != nil { return nil, errcode.ErrDB }
|
||
```
|
||
|
||
`wallet_basic` 上无 `passport_identity` 索引/唯一索引(`wallet_basic.go:14-24`,仅 `types.Std_Identity` 提供 `identity` 唯一索引,`bsm-sdk/core/types/db.go:69-71`);`wallet_record.in_trade_no` 无唯一索引(`wallet_record.go:22`);`wallet_payment` 的 `order_no` 无唯一索引(`wallet_payment.go:21`)。
|
||
- **影响**:① `balance` 与 `wallet_record` 可能不一致(余额成功、流水丢失);② 扣款成功而 `CreatePaymentRecord` 失败 → 用户被扣钱但无支付单,无法对账/退款;③ 渠道回调重试(微信默认重试多次)没有幂等键 `(wallet_identity, in_trade_no)` 唯一约束,一旦按 P0-2 建议接上入账逻辑就会**重复入账**;④ 钱包消费(`TradeConsum`)**不写任何 `wallet_record`**,用户账单缺失支出记录。
|
||
- **建议**:所有资金变更收敛到一个领域服务方法,内部使用 `DBService.Transaction(func(tx *gorm.DB) error { ... })`,配合 `SELECT ... FOR UPDATE`(或 `UPDATE ... SET balance = balance - ? WHERE identity=? AND balance >= ?` 并检查 `RowsAffected`);余额变更与流水写入必须同事务;给 `wallet_record` 加 `(wallet_identity, in_trade_no)` 唯一索引并以 `ON CONFLICT DO NOTHING` 实现幂等;`wallet_payment.order_no` 加唯一索引。
|
||
|
||
#### 9. 余额扣减为先读后写的非原子更新,并发下丢失更新/超额消费
|
||
|
||
- **位置**:`internal/logic/basic/wallet.go:23-63`(读余额在校验、扣减在另一条语句)
|
||
- **证据**:
|
||
|
||
```go
|
||
// wallet.go:41-44 —— 校验用的是读到的快照
|
||
if amount > wallet.Balance {
|
||
return nil, excode.ErrBalanceNotEnough
|
||
}
|
||
// wallet.go:54-58 —— 写入的是"快照 - amount"的绝对值,而非 balance = balance - amount
|
||
data := map[string]interface{}{
|
||
"balance": srv.Body.Balance - amount,
|
||
"withdrawal_balance": srv.Body.WithdrawalBalance - amount,
|
||
}
|
||
```
|
||
|
||
`UpdateColumns(map)` 生成 `SET balance = <常量>`(`gorm@v1.31.2/finisher_api.go` 中 `UpdateColumns` 走 Update 回调),既不会因并发而失败,也不会基于最新值计算。
|
||
- **影响**:两个并发请求各自读到 balance=100,各扣 80,最终 balance=20(应为 -60 被拒绝或 0+一次失败),即**经典的丢失更新**,等价于少扣/超额扣款;同时 `withdrawal_balance` 被同步扣减(`wallet.go:56`)但校验只针对 `balance`(`wallet.go:42`),若两者不相等会把可提现余额扣成负数。
|
||
- **建议**:改为数据库端原子条件更新并检查影响行数,例如 `UPDATE wallet_basic SET balance = balance - ?, withdrawal_balance = withdrawal_balance - ? WHERE identity = ? AND balance >= ? AND withdrawal_balance >= ?`,`RowsAffected == 0` 时返回余额不足;或 `SELECT ... FOR UPDATE` + 事务。同时为热点账户增加业务层限流/排队(同一 wallet 的并发扣款串行化)。
|
||
|
||
#### 10. 支付宝下单金额用整数除法把"分"截断成整"元",并向渠道传递被截断的金额
|
||
|
||
- **位置**:`internal/logic/alipay/alipay.go:57`
|
||
- **证据**:
|
||
|
||
```go
|
||
srv.Body.Set("subject", config.Spec.Wallet.Name).
|
||
...
|
||
Set("total_amount", amount/100). // int64 除法,向下取整
|
||
```
|
||
|
||
调用链:`by_charge.go:102`(`alipay.SetBody(payBodyAttach.String(), orderNo, in.Amount)`,金额由客户端提供)、`by_order.go:118`(金额取自 `order_summary.trans_price`)。
|
||
- **影响**:`amount = 1500`(15.00 元)时向支付宝传 `15` 元 → 用户少付 0.00 元成立但 `amount = 1550` 传 `15` 元 **少付 0.5 元**,`amount = 99` 传 `0` 元;钱包记录的应付金额与渠道实收金额不一致,对账必然不平,且用户可能以低于订单价的金额完成支付(若后续以回调金额入账则少入账,若以本地金额入账则凭空多入账)。
|
||
- **建议**:`Set("total_amount", decimal(amount, 100))`,即用 `strconv.FormatFloat(float64(amount)/100, 'f', 2, 64)` 或整数拼串生成精确两位小数字符串;禁止用整型除法做金额单位换算,并在下单前断言 `amount > 0` 且 `amount % 1 == 0`(分)。
|
||
|
||
#### 11. 金额等关键参数普遍缺少边界校验(0、负数、超大值、渠道/类型枚举)
|
||
|
||
- **位置**:`internal/logic/payment/by_charge.go:26`、`internal/logic/basic/apply_cash.go:25`、`internal/logic/wechat/wechat.go:182-195`、`internal/logic/wechat/jsapi_pre_order.go:11-23`
|
||
- **证据**:
|
||
|
||
```go
|
||
// by_charge.go:26 —— 只挡了 0
|
||
if in.Amount == 0 || in.Desc == "" { return nil, errcode.ErrInvalidArgument }
|
||
|
||
// apply_cash.go:25 —— 只挡了 0 与 channel==0
|
||
if in.Amount == 0 || in.Channel == 0 { return nil, errcode.ErrInvalidArgument }
|
||
|
||
// wechat.go:183-185 —— CheckParam 同样只挡 0
|
||
if amount == 0 { return excode.ErrAmount }
|
||
```
|
||
|
||
`ByCharge` 的 `in.Amount` 直接进入 `wechat.GetResponse(..., in.Amount)`(`by_charge.go:66`)与支付单落库(`by_charge.go:39`);`PayChannel` 由客户端给定,仅在 `switch` 的 `default` 分支兜底(`by_charge.go:124-125`),`PayType` 同理(`by_charge.go:91-92`)。
|
||
- **影响**:负数金额可生成支付单并下发到渠道请求(`total: -100`),部分渠道/中间件行为不可预期;超大值(如 `9223372036854775807`)经 `int64` 运算在 `amount/100`、`balance + amount` 等处溢出,配合 P1-8 的 `wallet.Balance + amount` 可造成余额被写成负数或巨值;渠道/支付类型枚举缺失导致错误码与真实原因不符(`by_charge.go:92` 一律返回 `ErrPayType`)。
|
||
- **建议**:统一在 handler 入口做参数校验:`amount > 0 && amount <= 单笔上限(如 5,000,000 分)`、`pay_channel ∈ {1,2,3}`、`pay_type ∈ 白名单(按渠道区分)`、`order_no` 格式与长度校验;金额一律 `int64` 分并在 proto/文档中固化;对 `int64` 加减做溢出保护(`if wallet.Balance > MaxInt64-amount`)。
|
||
|
||
#### 12. 微信回调忽略初始化错误并在无签名校验上下文下使用客户端,且回调不落任何渠道信息
|
||
|
||
- **位置**:`internal/logic/wechat/wx_callback.go:26-40`
|
||
- **证据**:
|
||
|
||
```go
|
||
wx, _ := NewWechat() // 错误被丢弃
|
||
pubKey := wx.Client.WxPublicKeyMap() // wx 可能为 nil → panic
|
||
err = notifyReq.VerifySignByPKMap(pubKey)
|
||
if err != nil { ...; return &pb.CallBackReply{Code: "FAIL", Message: "验签失败"}, nil }
|
||
res, err := notifyReq.DecryptPayCipherText(config.Spec.WeChat.APIV3Key)
|
||
...
|
||
// Todo: 业务逻辑
|
||
payResult, _ := json.Marshal(res)
|
||
return &pb.CallBackReply{Code: "SUCCESS", Message: string(payResult)}, nil
|
||
```
|
||
|
||
对比同模块更严谨的写法(`jsapi_pre_order.go:22-26`)仍同样丢弃错误。`NewWechat()` 在私钥文件读取失败时返回 `(nil, err)`(`wechat.go:48-51`),配置缺失(如 dev 环境 `PrivateKey: CHANGE_ME`,`wallet_dev.yaml:74`)即会走到 nil 解引用。
|
||
- **影响**:① 配置错误/私钥缺失时,匿名可达的回调端点(`wallet_dev.yaml:39`、`wallet_test.yaml:32`)被任意请求打成 panic → 进程级 DoS(与 P0-1 同类根因,均因缺少 recover);② 回调处理不记录 `transaction_id`(`wallet_payment.trade_no`)、实收金额、`success_time`,`TradeNo` 字段全仓无写入方(`grep "TradeNo ="` 无命中),导致后续无法主动查单核对、无法以渠道流水做幂等键;③ 验签失败时返回 `{"Code":"FAIL"}` 但 gRPC 层仍返回 error(第 33-37 行既返回 reply 又返回 err),调用方拿不到确定语义。
|
||
- **建议**:改为 `wx, err := NewWechat(); if err != nil { return fail, err }` 并加 recover;验签通过后解析 `resource` 明文,写入 `trade_no`/实收金额/时间/渠道状态到支付单与流水;错误时明确返回渠道可重试的失败响应;把"回调成功但入账失败"与"回调校验失败"区分成不同错误码并告警。
|
||
|
||
#### 13. 交易流水与汇总查询缺索引、缺真实总数,且分页无上限
|
||
|
||
- **位置**:`internal/models/query.go:168-197`、`internal/logic/basic/get_wallet.go:36-73`、`internal/logic/basic/transactions.go:21-27`
|
||
- **证据**:
|
||
|
||
```go
|
||
// query.go:189-196 —— 总数是 RowsAffected(分页后行数),不是符合条件的总记录数
|
||
res := tx.Order("created_at desc").Limit(int(pageSize)).Offset(int((page - 1) * pageSize)).Find(&list)
|
||
if res.Error != nil { ... }
|
||
return list, res.RowsAffected, nil
|
||
```
|
||
|
||
```go
|
||
// get_wallet.go:41 —— 每次调用最多 6 条 SUM,全部按 passport_id + 时间维度过滤
|
||
impl.DBService.Model(&models.WalletRecord{}).Select("sum(money)").Where("passport_id=? AND trans_type=? AND ymd=?", auth.ID, transIn, ymd).Scan(&totalMoney)
|
||
```
|
||
|
||
字段标签层面:`wallet_record` 只有 `trans_type`、`ymd`、`ym` 三个单列索引(`wallet_record.go:21,29,30`),**没有 `passport_id` / `passport_identity` 索引,也没有 `(passport_id, trans_type, ymd)` 复合索引**;`WalletBasic.PassportIdentity` 由 `types.Std_Passport` 提供索引(`bsm-sdk/core/types/db.go:54`),但 `PassportID` 仅为普通 `Index`(`db.go:53`)。分页参数只做下界修正,无上界:`transactions.go:24-26` 将 `PageSize <= 1` 置为 20,客户端可传 `page_size = 10000000`。
|
||
- **影响**:① 流水表随时间线性增长后,`GetWallet` 每次最多 6 次全表扫描、`Transactions` 一次全表扫描 + 排序,P99 延迟与数据库 CPU 随数据量恶化(P1 性能瓶颈);② `TransactionsReply.total` 返回的是"本页行数"(最多 20),前端分页/总数展示必然错误;③ 无 `pageSize` 上限时单请求可拉取巨量行,形成内存与带宽放大(可被用于 DoS)。
|
||
- **建议**:为 `wallet_record` 建 `(passport_id, trans_type, ymd)`、`(passport_id, created_at)` 索引,为 `wallet_apply_cash(user_id/status/created_at)`、`wallet_payment(order_no)` 建索引(并确认这些索引能真正生效,见第 1 节 AutoMigrate 说明);`total` 用独立 `Count()` 查询返回真实总数;`pageSize` 限制在 `[1, 100]`,`page` 上限校验;`created_at` 排序追加 `id` 作为稳定次序键以避免翻页重复/遗漏;汇总统计改用按日/按月预聚合表或缓存,避免每次实时 SUM。
|
||
|
||
#### 14. 匿名访问清单配置错误与生产配置缺失
|
||
|
||
- **位置**:`etc/wallet_prod.yaml:22-30`、`etc/wallet_dev.yaml:36-41`、`etc/wallet_test.yaml:28-33`
|
||
- **证据**:
|
||
|
||
```yaml
|
||
# etc/wallet_prod.yaml:23-30 —— 列出的 5 个方法在本服务中全不存在
|
||
Anonymous:
|
||
Key: anonymous.{Workspace}
|
||
Urls:
|
||
- wallet.Check.Hello
|
||
- wallet.Check.Updates
|
||
- wallet.Data.Configure
|
||
- wallet.Data.Areas
|
||
- wallet.Data.Tags
|
||
|
||
# etc/wallet_dev.yaml:38-41 —— 真正的回调端点被匿名
|
||
MicroService:
|
||
Enable: false
|
||
Anonymous:
|
||
- wallet.wechat.WxCallback
|
||
- wallet.payment.Callback
|
||
- wallet.payment.Hello
|
||
```
|
||
|
||
生产配置还缺少本模块必需的 `Alipay`、`Wallet`、`Gateway`、`Databases` 段(对比 `wallet_dev.yaml:20-22,36`),而 `config.Spec.Wallet.*`、`config.Spec.Alipay.*` 在多处被直接解引用(`payment/way.go:22-36`、`alipay.go:22`、`config.go:83-88` 的 `conf.NotNil(Spec.Service, Spec.Cache)` 只校验了两个字段)。
|
||
- **影响**:生产匿名清单形同虚设(列的是别家服务的方法),一旦按 dev 清单修补,`wallet.payment.Callback` 会被匿名放行(放大 P0-4);生产配置缺段会导致 `config.Spec.Wallet`/`Alipay` 为 nil → `way.go:22`、`alipay.go:22` 处 panic 或启动即失败,属于"配置校验缺失 + 不安全默认值"。
|
||
- **建议**:按环境校正匿名清单,只保留渠道回调(`wallet.wechat.WxCallback`)且回调内部必须验签;`Payment.Callback` 不应匿名;`config.New` 增加必需的 nil 校验(`Wallet`、`Wechat`、`Alipay`、`Databases`、`Cache`),启动即 fail-fast 并打印缺失项。
|
||
|
||
#### 15. 支付密码可无鉴权重置,且哈希强度不足、无暴力破解防护
|
||
|
||
- **位置**:`internal/logic/basic/set_pay_password.go:17-38`、`internal/logic/basic/wallet.go:67-69`
|
||
- **证据**:
|
||
|
||
```go
|
||
// set_pay_password.go:23-33 —— 只要 token 有效即可改密码,无旧密码/短信二次校验
|
||
if in.Password == "" { return nil, errcode.ErrPassword }
|
||
encPassword := EncodePassword(in.Password, auth.Identity)
|
||
err = impl.DBService.Model(&models.WalletBasic{}).Where("passport_identity", auth.Identity).Update("pay_password", encPassword).Error
|
||
|
||
// wallet.go:67-69 —— HMAC-SHA256,salt 为"公开公钥 + passport_identity"
|
||
func EncodePassword(pwd, passportIdentity string) string {
|
||
return utils.Sha256(pwd, passportIdentity+config.Spec.Wallet.PublicKey)
|
||
}
|
||
```
|
||
|
||
`utils.Sha256(src, privateKey)` 实为 `hmac.New(sha256.New, key)`(`bsm-sdk/core/utils/crypto.go`)。支付密码是钱包消费(`by_order.go:139` channel=3)的唯一凭据。
|
||
- **影响**:① 攻击者一旦拿到用户 token(XSS/中间人/共享设备),可直接重设支付密码并用其清空钱包余额,**无需知道原密码**;② HMAC-SHA256 密钥部分由 `passport_identity` + 配置 `PublicKey` 组成,`PublicKey` 对服务端可见但不具备真正随机性,且单轮无迭代、无慢哈希,泄露数据库后可高速离线爆破 6 位数字口令;③ 全模块无任何失败计数、锁定、验证码或限流。
|
||
- **建议**:`SetPayPassword` 增加旧支付密码校验(首次设置走独立流程并要求短信/实名二次验证);密码改用 Argon2id/bcrypt(每用户随机 salt,存于独立列);增加连续失败锁定与图形/短信验证码;`NewWallet` 中的密码比较改为常量时间比较(`hmac.Equal` 或 `subtle.ConstantTimeCompare`)。
|
||
|
||
#### 16. `AddBankCard` 按卡号跨用户查询,且不校验卡归属/不与渠道绑定
|
||
|
||
- **位置**:`internal/logic/basic/add_bank_card.go:24-53`
|
||
- **证据**:
|
||
|
||
```go
|
||
// add_bank_card.go:35-47 —— 跨用户按卡号查询(无 passport 范围),并把结果回填为本人的卡信息
|
||
var cardInfo models.WalletBank
|
||
_ = impl.DBService.Where("card_no = ?", in.CardNo).First(&cardInfo).Error
|
||
data := &models.WalletBank{
|
||
WalletIdentity: myWallet.Identity,
|
||
CardNo: in.CardNo, BankName: cardInfo.BankName, Bank: cardInfo.Bank, BankType: cardInfo.BankType,
|
||
CardOwner: in.CardOwner, IDCard: in.IdCard, Phone: in.Phone,
|
||
}
|
||
```
|
||
|
||
`data.BindID` 从未赋值(`wallet_bank.go:26` 有字段),`CardOwner`/`IDCard`/`Phone` 完全由客户端提供,未做四要素核验,也没有与微信/支付宝的绑卡(`basic.BindPaymentId` 只写 `wxpay_id`/`alipay_id`,`bind_payment_id.go:27-36`)。`PayPassword`/`AddBankCard` 均无限流。
|
||
- **影响**:① 任意登录用户可用已知卡号探测该卡在本平台的登记信息(银行名称等),构成跨用户信息泄露(IDOR 的变体);② 攻击者可为自己绑定**任意他人卡号 + 任意姓名/身份证**用于提现,提现风控(若按 `wallet_bank` 打款)会把资金打到攻击者可控账户;③ 卡号无唯一约束,同一张卡可重复绑定多份,污染数据。
|
||
- **建议**:删除跨用户查询(如确需补全卡信息,改为调用有资质的卡 BIN/四要素核验服务);绑卡必须走渠道绑卡(微信/支付宝/银联)并保存渠道返回的 `bind_id`,提现只允许使用渠道已绑定的卡;`in.CardNo` 做 Luhn 校验与长度校验,`IDCard`/`Phone` 做格式校验;给 `(wallet_identity, card_no_hash)` 加唯一索引。
|
||
|
||
### P2
|
||
|
||
#### 17. `InitData` 把 seed 数据与启动强耦合,`demo` 账号缺失即 panic
|
||
|
||
- **位置**:`internal/models/query.go:16-53`、`cmd/main/main.go:20-51`、`bsm-sdk/core/service/service.go:133-139`
|
||
- **证据**:
|
||
|
||
```go
|
||
// query.go:24-30 —— 依赖 passport_account 中必须有 account='demo'
|
||
err = impl.DBService.Table("passport_account").Select("identity").Where("account=?", "demo").Find(&userIdentity).Error
|
||
if userIdentity == "" { return errors.New("默认用户未找到") }
|
||
```
|
||
|
||
```go
|
||
// service.go:133-139(SDK)—— Use 回调返回 error 直接 panic
|
||
func (s *Service) Use(initFunc func() error) { err := (initFunc)(); if err != nil { printer.Error(err.Error()); panic(err) } }
|
||
```
|
||
|
||
`main.go:47` 注册 `srv.Use(models.InitData)`;另外 `GetWalletByPassportIdentity` 在钱包不存在时会隐式创建(`query.go:80-86`),且 `Create` 的错误被忽略(`query.go:85`)。
|
||
- **影响**:生产库若无 `demo` 账号,服务直接 panic 无法启动(可用性风险);"隐式创建钱包"会在查询接口(`GetWallet`/`GetBankCard`/`ApplyCash` 都会调用)上写库,读接口产生副作用,且在并发首次调用下可能因唯一索引冲突而静默失败(错误被丢弃),返回一个未落库的 wallet 对象。
|
||
- **建议**:把 demo/seed 数据从启动流程剥离为独立 migrate/seed 命令(README 中已提到 `go run cmd/main/main.go migrate`,但代码里不存在该子命令);`GetWalletByPassportIdentity` 的 `Create` 必须检查错误并处理唯一索引冲突(冲突时重查);读接口不写库。
|
||
|
||
#### 18. 全模块忽略 `context.Context`,第三方调用无超时/重试/熔断
|
||
|
||
- **位置**:`internal/logic/wechat/wechat.go:70-72`、`internal/logic/payment/by_charge.go:66`、`internal/logic/payment/by_order.go:139`
|
||
- **证据**:
|
||
|
||
```go
|
||
// wechat.go:70-73 —— 客户端固定使用 Background ctx,与请求 ctx 无关
|
||
return &WeChatPay{
|
||
ctx: context.Background(),
|
||
Client: client,
|
||
}, nil
|
||
```
|
||
|
||
传入的请求 `ctx` 在 `by_charge.go:20` 被接收后只用于 `ParseMetaCtx`(`by_charge.go:21`),之后所有渠道调用(`wechat.GetResponse`、`alipay.TradeWapPay`、`models.*` 的 DB 调用)都不带 `WithContext`。`NewWechat()` 每次请求都重新 `os.ReadFile` 私钥并新建客户端(`wechat.go:41-74`),且 `AutoVerifySign()` 会去拉取平台证书(`wechat.go:62`)。
|
||
- **影响**:客户端断开或上游取消后,渠道请求仍会跑满其内部超时,白占连接与 goroutine;单请求失败没有重试与熔断,渠道抖动会直接把延迟透传给用户;每请求读文件+建客户端+拉证书是明显的低效路径(也是潜在的性能瓶颈)。
|
||
- **建议**:`NewWechat(ctx)`/`NewAlipay(ctx)` 接收并使用请求 `context`,DB 访问统一 `DBService.WithContext(ctx)`;客户端按配置单例初始化(进程启动时一次),渠道 HTTP 客户端设置连接/读写超时;对渠道调用增加有限重试(仅幂等读操作)与熔断(如 `gobreaker`)。
|
||
|
||
#### 19. 日志与可观测性不足:无资金操作审计留痕、无对账任务与告警
|
||
|
||
- **位置**:`internal/logic/basic/apply_cash.go:31-32,51-52`(仅 `printer.Error`)、`internal/logic/payment/callback.go:31-34`、全模块无 metrics/tracing 接入点
|
||
- **证据**:
|
||
|
||
```go
|
||
// apply_cash.go:49-53 —— 提现申请成功/失败无结构化业务日志
|
||
err = impl.DBService.Create(&data).Error
|
||
if err != nil { printer.Error(err.Error()); return nil, errcode.ErrDB }
|
||
```
|
||
|
||
全模块 `grep "printer\."` 只有 `Error`/`Info` 的裸字符串,**没有 request_id / passport_identity / 金额 / 单号**等结构化字段;`config.Spec.Prometheus`、`Telemetry` 只配在 yaml 中(`wallet_prod.yaml:39-50`),模块代码未产生任何业务指标(如入账金额、回调成功率、提现待处理数);没有任何对账/补偿任务(`grep "cron\|ticker\|reconcil"` 无命中)。
|
||
- **影响**:资金事故(重复入账、提现失败、回调丢失)无法从日志还原与定量,也无法告警;无对账任务意味着差异只能在用户投诉后被动发现。
|
||
- **建议**:引入结构化日志(zap/logger 包已具备)并在所有资金路径固定输出 `request_id`、`order_no`/`cash_no`、`amount`、`balance_before/after`、`channel`、`trade_no`;为核心流程埋点(入账成功/失败计数、回调验签失败计数、提现待处理时长);新增定时对账任务(`wallet_record` ↔ `wallet_payment` ↔ 渠道对账单)与差异告警。
|
||
|
||
#### 20. 支付单可重复创建、`Get` 不校验 `type`、订单支付不校验订单归属
|
||
|
||
- **位置**:`internal/logic/payment/get.go:22-36`、`internal/logic/payment/by_order.go:31-58`、`internal/models/query.go:102-109`
|
||
- **证据**:
|
||
|
||
```go
|
||
// get.go:22-25 —— 只要 identity 属于自己的钱包就返回,不校验业务类型/订单
|
||
paymentRecord, err := models.GetWalletPaymentByIdentity(auth.Identity, in.Identity)
|
||
|
||
// by_order.go:31-34 —— 金额取本地订单,但不校验订单属于当前用户
|
||
amount, err := models.GetTransPriceByOrderNo(in.OrderNo)
|
||
```
|
||
|
||
`ByOrder` 每次调用都会新建 `WalletPayment`(`by_order.go:38-58`、`by_order.go:152`),不检查该 `order_no` 是否已有支付单,也不复用;`order_summary` 的查询条件是 `status=1`(`query.go:104`),订单模块的状态语义需交叉确认。
|
||
- **影响**:同一订单可被反复下单支付(配合 P0-4 的伪造回调可放大为多次"成功");`Get` 返回他人业务类型的支付单信息(越权程度轻,但属缺少授权粒度);订单状态与支付状态无联动,可能出现"订单已关闭但支付单成功"。
|
||
- **建议**:以 `order_no` 为幂等键(唯一索引 + 冲突时返回既有支付单);`ByOrder` 校验订单归属与可支付状态(`order_summary` 增加 `passport_identity`/`member` 归属校验);`Get` 增加 `type` 与调用方角色校验。
|
||
|
||
#### 21. `wallet_record` 语义与统计口径错误:支出未取负、每次请求 6 次聚合查询
|
||
|
||
- **位置**:`internal/models/wallet_record.go:21,24`、`internal/logic/basic/get_wallet.go:30-73`
|
||
- **证据**:
|
||
|
||
```go
|
||
// wallet_record.go:21,24
|
||
TransType int8 `gorm:"column:trans_type;default:0;Index;" json:"trans_type"` // 收支类型,-1:支出,1:收入
|
||
Money int64 `gorm:"column:money; unsigned;default:0;" json:"money"` // 交易金额,单位:分
|
||
```
|
||
|
||
```go
|
||
// get_wallet.go:46-49 —— 按 trans_type=-1 求 sum(money)
|
||
impl.DBService.Model(&models.WalletRecord{}).Select("sum(money)").Where("passport_id=? AND trans_type=? AND ymd=?", auth.ID, transOut, ymd).Scan(&totalMoney)
|
||
total["TodayOut"] = totalMoney
|
||
```
|
||
|
||
`models/query.go:151-161` 建流水时 `Money: amount` 恒为正数。
|
||
- **影响**:`TodayOut/MonthOut/AllOut` 返回的是正数,"支出"口径与前端/对账预期相反;支出统计依赖 `trans_type` 过滤而流水表没有 `(passport_id, trans_type, ymd)` 复合索引(同 P1-13);"余额"与"流水合计"两条独立数据源没有任何一致性校验,一旦写入顺序出错(P1-8)无法发现。
|
||
- **建议**:明确约定 `money` 恒为正、方向由 `trans_type` 表达(当前即如此),在出参层对支出取负或提供明确字段语义并同步文档;增加"余额 = Σ(收入) - Σ(支出) ± 调整"的日终对账校验任务;补充复合索引与预聚合。
|
||
|
||
#### 22. 时区与日期口径存在漂移风险
|
||
|
||
- **位置**:`internal/logic/basic/get_wallet.go:33-34`、`etc/wallet_dev.yaml:8`、`bsm-sdk/core/database/sql/postgresql.go`
|
||
- **证据**:
|
||
|
||
```go
|
||
// get_wallet.go:33-34 —— 应用服务器本地时间决定日/月口径
|
||
ymd, _ := strconv.Atoi(time.Now().Format(vars.YYYYMMDD))
|
||
ym := time.Now().Year()*100 + int(time.Now().Month())
|
||
```
|
||
|
||
DSN 中虽有 `TimeZone=Asia/Shanghai`(`wallet_dev.yaml:8`),但 `ymd`/`ym` 是**应用侧**计算并落库的字段(`wallet_record.go:29-30`),`created_at` 由 GORM 按应用本地时区写入,README 也把 `TZ` 列为环境变量(`README.md:394`)。
|
||
- **影响**:容器时区若非 `Asia/Shanghai`,"今日/本月"统计与用户账单口径不一致;跨时区部署(K8s 多区域)会进一步放大。
|
||
- **建议**:固定业务时区(如 `Asia/Shanghai`)并在启动时校验,或统一以 UTC 存储、按业务时区聚合;`ymd`/`ym` 的生成收敛到一个工具函数并加单元测试。
|
||
|
||
#### 23. 越权与信息泄露面:注册的 API 与实现能力错配
|
||
|
||
- **位置**:`internal/logic/basic/get_bank_card.go:36-49`、`internal/logic/payment/way.go:37-42`、`internal/models/query.go:56-63`
|
||
- **证据**:
|
||
|
||
```go
|
||
// way.go:37-42 —— 余额直接拼进展示文案
|
||
balance := fmt.Sprintf("%.2f", models.GetWalletBalanceByPassportIdentity(auth.Identity))
|
||
items = append(items, &pb.WayItem{ Ident: "WALLET", Title: "钱包余额", Intro: "余额:" + balance + " 元" })
|
||
|
||
// query.go:56-63 —— 查询失败静默返回 0
|
||
func GetWalletBalanceByPassportIdentity(passport_identity string) float64 {
|
||
wallet := new(WalletBasic)
|
||
err := impl.DBService.Where("passport_identity = ?", passport_identity).First(wallet).Error
|
||
if err != nil { return 0.00 }
|
||
return float64(wallet.Balance / 100)
|
||
}
|
||
```
|
||
|
||
- **影响**:`GetBankCard` 返回全量卡号/身份证/手机号(与 P0-5 同源);`GetWalletBalanceByPassportIdentity` 把 DB 错误伪装成"余额 0 元",前端展示错误余额;`float64` 返回值用于展示会引入浮点表示问题(分→元的展示路径应使用十进制定点)。
|
||
- **建议**:`GetWalletBalanceByPassportIdentity` 返回 `(int64, error)`,由调用方决定展示与错误处理;金额展示统一用整数分 + 定点格式化函数。
|
||
|
||
#### 24. 支付单状态枚举、渠道枚举与金额单位在代码中散落为魔法数字
|
||
|
||
- **位置**:`internal/models/wallet_payment.go:23-31`、`internal/logic/payment/by_charge.go:35-39,56-126`、`internal/logic/payment/by_order.go:40-148`、`internal/logic/basic/apply_cash.go:42`
|
||
- **证据**:
|
||
|
||
```go
|
||
// wallet_payment.go:23-31 —— 状态语义只写在注释里
|
||
Type int8 `gorm:"column:type;default:0;" json:"type"` // 类型:1充值;2支付电商订单
|
||
PayChannel int8 `gorm:"column:pay_channel;default:0;" json:"pay_channel"` // 方式 1:微信 2:支付宝 3:银行卡
|
||
types.Std_Status // 支付状态:-3:手动取消,-2:超时自动取消,-1:支付失败,0:创建,1:支付中,2:支付成功
|
||
```
|
||
|
||
```go
|
||
// callback.go:25-30 —— 状态值硬编码
|
||
if in.CallbackStatus { status = 2 } else { status = -1 }
|
||
```
|
||
|
||
`PayChannel`/`PayType`/`Type`/`TransType`/`TradeType`/`Status` 在 logic 中大量以字面量出现(`by_charge.go:57,72,79,85,95,105,112`、`by_order.go:62,75,82,88,94,111,120,126,138`、`query.go:153-158`),proto 的 `const.proto` 也没有为这些枚举定义 enum 类型。
|
||
- **影响**:状态与渠道语义只存在于注释和分散的 `switch`,任何新增渠道/状态都需要全局搜索修改,极易出现状态机漏洞(如 P0-4 中"成功/失败可互相覆盖");`Type` 与 `PayChannel` 混淆(`by_order.go:40` 注释 "2为订单" 与 `by_order.go:42` 的 `PayChannel: int8(in.PayChannel)`)增加了误用概率。
|
||
- **建议**:在 proto 中定义 `enum PayChannel`、`enum TradeType`、`enum PaymentStatus` 并生成常量;Go 侧统一引用生成常量与 `String()`,配套状态机校验函数(`CanTransit(from, to)`)。
|
||
|
||
### P3
|
||
|
||
#### 25. 空实现与未接线代码(逐条列出)
|
||
|
||
- **位置与证据**(均为 `// TODO: valid code` + `// TODO: add your logic code & delete this line.` 后直接 `return`):
|
||
|
||
```
|
||
internal/logic/alipay/app_pay.go:18-22 AppPay → 空实现
|
||
internal/logic/alipay/page_pay.go:18-22 PagePay → 空实现
|
||
internal/logic/alipay/wap_pay.go:18-22 WapPay → 空实现
|
||
internal/logic/alipay/transfer.go:18-22 Transfer → 空实现(提现/转账能力缺失)
|
||
internal/logic/wechat/app_pre_order.go:18-22 AppPreOrder → 空实现
|
||
internal/logic/wechat/native_pre_order.go:18-22 NativePreOrder → 空实现
|
||
internal/logic/wechat/transfer.go:11-23 Transfer → 空实现
|
||
internal/logic/wechat/wx_callback.go:38 // Todo: 业务逻辑
|
||
```
|
||
|
||
- **影响**:对外暴露 7 个"总是返回空响应"的 RPC,客户端会以为自己下单/转账成功(`WxpayTransferReply{}`、`AlipayUniTransferReply{}` 均为空消息),造成资金与业务误判;这些接口同时占用 gRPC 方法注册表并出现在 `wiki/api/12-wallet.md` 的公开文档中。
|
||
- **建议**:未实现的 RPC 应从 proto 中移除,或返回 `codes.Unimplemented`(`status.Error(codes.Unimplemented, ...)`)而非空成功;同步更新 wiki 文档。
|
||
|
||
#### 26. 未使用代码 / 死字段 / 未使用错误码
|
||
|
||
- **位置与证据**:
|
||
- `internal/impl/impl.go:22-23` 初始化的 `MemorySerice`、`RedisService` 全仓无读写(`grep "RedisService\|MemorySerice"` 仅命中 impl 与 dependencies);
|
||
- `internal/models/wallet_refund.go:16-25` 的 `WalletRefund` 无任何读写方;
|
||
- `proto/wallet.proto:89-98` 的 `RefundRequest`/`RefundReply` 无服务方法引用;
|
||
- `internal/config/config.go:28` 的 `QrCodeSavePath` 无使用方;
|
||
- `internal/excode/ex.go:10-20` 的 `ErrPassportId`(3801)、`ErrAuthCode`(3802)、`ErrOrderNo`(3803)、`ErrAmount`(3805)、`ErrIdentityArgument`(3807)、`ErrPasswordArgument`(3808)、`ErrVerifySign`(3809)、`ErrPay`(3813) 中,仅 `ErrPassportId`(wechat.go:190)、`ErrAuthCode`/`ErrOrderNo`(wechat.go:187,193)、`ErrAmount`(wechat.go:184) 被使用,其余无引用;
|
||
- `internal/logic/basic/get_wallet.go:36` 的 `var totalMoney int64`,虽被 `Scan` 使用,但每次查询前未清零(依赖覆盖),且第 74 行留下 `// remove useless fmt.Sprint side-effect-free call` 的历史注释。
|
||
- **影响**:阅读成本与维护风险;`totalMoney` 未显式重置属于脆弱写法(一旦某个分支不再赋值就会串值)。
|
||
- **建议**:删除死字段/死模型/未使用错误码,或补齐其实现;`totalMoney` 改为循环内局部变量或每次显式置 0。
|
||
|
||
#### 27. 文档与实际实现严重不一致
|
||
|
||
- **位置与证据**:
|
||
- `README.md:16-19` 声称 "安全加密"、"Redis 缓存 + 数据库优化"、"健康检查和 APM 集成"、"容器化",但模块内无字段级加密、无 Redis 使用、无 health 端点、无 Dockerfile;
|
||
- `README.md:245-252` 的 `go test ./...`/`go test -cover ./...` 会得到"无测试文件"的结果(模块内无 `*_test.go`);
|
||
- `README.md:266-269` 的 `go run cmd/main/main.go migrate` 子命令不存在(`cmd/main/main.go` 仅 `Run()`/`main()`);
|
||
- `README.md:104-133` 的 RPC 名称与实际不符(`PayByOrder`/`PayByCharge`/`GetPayment`/`SetPayPasswordReply`/`AddBankCardReply` vs 实际的 `ByOrder`/`ByCharge`/`Get`/`StatusReply`);
|
||
- `README.md:446-459` 的 `wallet_basic` DDL 与模型不一致:`status INTEGER DEFAULT 1` vs 模型 `default:0`(`wallet_basic.go:23`)、`identity VARCHAR(255)` vs `varchar(36)`(`types/db.go:70`)、且缺少 `withdrawal_balance` 之外的 `deleted_at` 等列;
|
||
- `README.md:321-326` 承诺"关键字段建立复合索引",实际流水表无 `passport_id` 复合索引(同 P1-13);
|
||
- `README.md:69` 的端口 `12101:12102` 与实际配置 `12238`/网关 `12239` 不符(`wallet_dev.yaml:2,22`);
|
||
- `wiki/api/12-wallet.md:12` 要求 "Authorization: <JWT> 不加 Bearer 前缀",但 `test/basic.http` 等示例使用 `{{BSM_TOKEN}}` 变量且未说明前缀,`README.md` 亦未提及该约束。
|
||
- **影响**:误导接入方与运维(迁移命令不存在、端口错误、RPC 名称错误会导致联调失败)。
|
||
- **建议**:以生成的 wiki(`wiki/api/12-wallet.md` 由 proto descriptor 生成)为唯一接口事实源,README 只保留架构与运维说明;删除或补齐承诺的功能(缓存/健康检查/Docker/迁移命令);在 CI 中加入 proto 与文档的一致性校验。
|
||
|
||
#### 28. `go.mod` 中 Go 版本声明异常
|
||
|
||
- **位置**:`go.mod:3`
|
||
- **证据**:
|
||
|
||
```
|
||
module bsm/full/module/finance/wallet
|
||
|
||
go 1.26.5
|
||
```
|
||
|
||
`README.md:36` 声明 "Go 1.25.1+",而 `go` 指令按语义应为 `major.minor`(如 `1.26`),带 patch 的 `go 1.26.5` 属于非常规写法(`toolchain` 指令才表达精确版本)。
|
||
- **影响**:不同 Go 版本对这些字段的容忍度不同,可能造成构建工具链告警或不一致。
|
||
- **建议**:改为 `go 1.26` 并在需要时用 `toolchain go1.26.5` 固定;同步 README 的版本要求。
|
||
|
||
#### 29. 全局可变状态与死配置
|
||
|
||
- **位置**:`internal/logic/wechat/wechat.go:21-39`、`internal/impl/impl.go:12-26`、`internal/models/query.go:43`、`service/expose.go:22-43`
|
||
- **证据**:
|
||
|
||
```go
|
||
// wechat.go:21-39 —— 包级可变全局量,每次 NewWechat() 都被重写
|
||
var (
|
||
mchID string
|
||
SerialNumber string
|
||
mchAPIv3Key string
|
||
ApiClientKey string
|
||
AppId string
|
||
Currency string
|
||
NotifyUrl string
|
||
)
|
||
func new() { mchID = config.Spec.WeChat.MchID; ... }
|
||
```
|
||
|
||
`impl.DBService`/`RedisService`/`EtcdService`/`MemorySerice` 同样是包级全局(`impl.go:12-17`),`InitData` 用 `utils.ULID()` 而 `GetWalletByPassportIdentity` 用 `utils.UUID()`(`query.go:43` vs `query.go:82`)——同一张表的 `identity` 长度口径不统一(ULID 26 位 vs UUID 36 位,`types/db.go:70` 为 `varchar(36)`)。`service/expose.go:22-43` 注册了两套 handler(gRPC server 与 gateway handler server),但模块自身 `cmd/main/main.go` 走的是 `server.New(nil)` + SDK `service.Start`,`Expose` 仅在被 `pkgs/all` 引用的场景下生效,两条路径的鉴权一致性没有测试覆盖。
|
||
- **影响**:全局配置在并发下被反复覆写(虽为同值,但存在数据竞争的可读性问题);`identity` 长度口径不统一在 ULID 迁移时会触发截断/唯一索引冲突。
|
||
- **建议**:微信配置在进程启动时一次性解析为不可变结构体并注入;`identity` 生成统一为一个工具(建议 `ULID`)并断言长度;为 `Expose` 路径补充与 `cmd/main` 等价的鉴权测试。
|
||
|
||
#### 30. 测试资产严重不足
|
||
|
||
- **位置**:`test/` 目录
|
||
- **证据**:目录内仅 `alipay.http`、`basic.http`、`hello.http`、`payment.http`、`wechat.http`、`readme.md`(内容仅一行 "grpc & http rest test."),**没有任何 Go 测试文件**(`glob **/*_test.go` 无命中)。
|
||
- **影响**:资金核心模块零自动化测试,P0/P1 中的崩溃、越权、账务不一致问题均无法被 CI 拦住。
|
||
- **建议**:补齐关键用例(详见第 4 节"测试补齐清单")。
|
||
|
||
## 4. 推荐优化方案
|
||
|
||
### 4.1 立即(阻断上线,对应 P0)
|
||
|
||
1. **修复崩溃面**:`NewAlipay()` 初始化 `Body` 与 `ctx`(`alipay.go:17-50`);`grpc.NewServer()` 挂 panic-recovery 拦截器(`server/new.go:23`);`WxCallback` 检查 `NewWechat()` 错误(`wx_callback.go:26`)。三者合起来才能消除"单请求打死进程"。
|
||
2. **停止资金裸奔**:在入账逻辑完成前,`WxCallback` 不得返回 `SUCCESS`(改为返回失败让渠道重试并在内部告警);`ByCharge` 若无法保证入账,应先下线该接口(返回 `Unimplemented`)。
|
||
3. **关闭伪造入口**:`Payment.Callback` 从匿名清单移除并限制为内网/管理角色,或直接删除该 RPC,支付状态只允许由验签后的渠道回调写入。
|
||
4. **提现前置冻结**:新增 `frozen_balance` 字段(或 hold 表),`ApplyCash` 在事务 + 行锁内校验 `amount > 0 && amount <= withdrawal_balance - frozen` 并冻结,同时校验 `channel` 白名单。
|
||
5. **敏感数据治理**:银行卡/身份证/手机号改为密文 + 掩码 + hash 三列存储,出参掩码;清理仓库中的真实商户号/APIv3 密钥并轮换。
|
||
|
||
### 4.2 短期(账务正确性地基,对应 P1)
|
||
|
||
1. **统一资金变更入口**:新增 `internal/logic/ledger`(或 `models/wallet_ledger.go`),唯一暴露 `Apply(tx, {walletIdentity, delta, tradeType, transType, inTradeNo, outTradeNo, channel, fee, remark})`,其内部保证:单事务、`SELECT ... FOR UPDATE` 或条件 UPDATE + `RowsAffected` 校验、余额下限断言、流水与余额同写、`(wallet_identity, in_trade_no)` 幂等。
|
||
2. **原子扣减模板**:
|
||
|
||
```go
|
||
res := tx.Model(&models.WalletBasic{}).
|
||
Where("identity = ? AND balance >= ? AND withdrawal_balance >= ?", id, amount, amount).
|
||
Updates(map[string]any{
|
||
"balance": gorm.Expr("balance - ?", amount),
|
||
"withdrawal_balance": gorm.Expr("withdrawal_balance - ?", amount),
|
||
"version": gorm.Expr("version + 1"),
|
||
})
|
||
if res.Error != nil { return res.Error }
|
||
if res.RowsAffected == 0 { return excode.ErrBalanceNotEnough }
|
||
```
|
||
|
||
(或显式 `SELECT ... FOR UPDATE` + 乐观锁 `version` 列;两者择一并在代码评审中固化。)
|
||
3. **支付状态机**:为 `wallet_payment.status` 定义 `创建(0) → 支付中(1) → 成功(2) / 失败(-1) / 取消(-2,-3)`,终态不可回改;所有状态写入走 `WHERE status IN (允许的前置状态)` 的条件更新并检查 `RowsAffected`。
|
||
4. **提现闭环**:状态机 `待审核(0) → 审核通过 → 放款中 → 成功(1)/失败(-1)`,审核接口独立角色 + 审计日志;放款失败/驳回时解冻冲正;`cash_no` 唯一索引用于幂等。
|
||
5. **索引与查询**:为 `wallet_record` 建 `(passport_id, trans_type, ymd)`、`(passport_id, created_at)`,为 `wallet_payment(order_no)` 唯一索引、`wallet_apply_cash(status, created_at)`、`wallet_basic(passport_identity)` 唯一索引;确认迁移能在部署流水线中真实执行(`AutoMigrate` 当前为关闭状态)。
|
||
6. **参数与口径**:所有金额入参统一 `amount > 0 && amount <= 上限`;`total_amount` 用两位小数定点字符串;分页 `pageSize` 上限 100 且返回真实 `total`;口径与时区固化。
|
||
7. **渠道客户端治理**:按进程单例初始化,传入请求 ctx,设置超时,`DebugSwitch` 由配置控制且生产关闭。
|
||
8. **支付密码**:Argon2id/bcrypt + 随机 salt;`SetPayPassword` 校验旧密码或二次验证;失败锁定与限流;比较使用常量时间比较。
|
||
|
||
### 4.3 中期(可观测性与对账)
|
||
|
||
1. 结构化日志:所有资金路径固定字段 `request_id, passport_identity, wallet_identity, order_no/cash_no, channel, amount, balance_before, balance_after, trade_no`。
|
||
2. 指标:入账成功/失败、回调验签失败、支付单创建/成功转化率、提现各状态时长、余额与流水差异条数(Prometheus 端点已在 `etc/*.yaml` 配置)。
|
||
3. 对账任务:定时拉取微信/支付宝对账单,与 `wallet_payment`/`wallet_record` 三方比对,差异写入 `wallet_reconcile_diff` 并告警;因 `wallet_refund` 与 `RefundRequest` 已有模型/proto 骨架,可在此基础上补齐退款与冲正。
|
||
4. 测试补齐清单(`test/` 目前只有 .http 示例):
|
||
- 并发扣款:同一钱包 100 并发各扣 1 分,断言最终余额精确、无负余额;
|
||
- 回调幂等:同一微信 `transaction_id` 重复回调 N 次,断言只入账一次;
|
||
- 提现审核:正/负/零/超额/负数金额、重复申请、审核重复提交、失败解冻;
|
||
- 余额与流水一致性:随机化操作后断言 `balance == Σ(收入) - Σ(支出) ± 调整`;
|
||
- 支付渠道签名:伪造/重放/篡改金额的回调必须被拒;
|
||
- 崩溃回归:`pay_channel=2` 下单不再 panic(P0-1)。
|
||
- 引入 `-race` 与 `go vet`/`gofmt` 到 CI,并对 `logic/`+`models/` 设最低覆盖率门禁。
|
||
|
||
## 5. TODO 清单
|
||
|
||
- [ ] **P0-1** 修复 `AliPay.Body`/`ctx` 未初始化并安装 panic-recovery 拦截器|验收:`pay_channel=2` 的 `ByOrder`/`ByCharge` 集成测试通过且进程不退出|涉及:`module/finance/wallet/internal/logic/alipay/alipay.go:17`、`module/finance/wallet/internal/server/new.go:23`
|
||
- [ ] **P0-2** 打通支付回调入账:验签→定位支付单加锁→`ChargeWallet` 入账→写流水→置状态→返回 SUCCESS,整个过程单事务|验收:微信重复回调 N 次只入账一次,且 `balance` 增加额等于实收金额|涉及:`module/finance/wallet/internal/logic/wechat/wx_callback.go:33`、`module/finance/wallet/internal/models/query.go:135`
|
||
- [ ] **P0-3** 提现申请引入冻结与可提现余额校验(含金额/channel 白名单、cash_no 生成)|验收:同一用户连续提交合计超过余额的申请会被拒,余额在审核期间不可被消费|涉及:`module/finance/wallet/internal/logic/basic/apply_cash.go:25`、`module/finance/wallet/internal/models/wallet_basic.go:22`
|
||
- [ ] **P0-4** 收敛支付状态写入口:`Payment.Callback` 移出匿名/限制内网或删除,状态写入加前置状态条件更新|验收:普通用户 token 无法把支付单置为成功;终态不可回改|涉及:`module/finance/wallet/internal/logic/payment/callback.go:25`、`module/finance/wallet/etc/wallet_test.yaml:33`
|
||
- [ ] **P0-5** 银行卡/身份证/手机号改密文+掩码+hash 存储并按掩码出参|验收:库内无明文卡号/身份证,接口仅返回尾号,日志无敏感字段|涉及:`module/finance/wallet/internal/models/wallet_bank.go:21`、`module/finance/wallet/internal/logic/basic/get_bank_card.go:37`
|
||
- [ ] **P1-6** 轮换并外置微信商户 APIv3 密钥/证书,生产关闭 SDK Debug|验收:仓库与 Git 历史无真实密钥,生产日志无渠道请求体|涉及:`module/finance/wallet/etc/wallet_prod.yaml:56`、`module/finance/wallet/internal/logic/wechat/wechat.go:68`
|
||
- [ ] **P1-7** 实现提现状态机、审核接口与放款/解冻冲正|验收:提现单可从待审核流转到成功/失败且失败自动解冻,审核有独立角色与审计日志|涉及:`module/finance/wallet/internal/logic/basic/apply_cash.go:55`、`module/finance/wallet/internal/models/wallet_apply_cash.go:26`
|
||
- [ ] **P1-8** 余额变更与流水写入并入同一事务,加幂等唯一索引|验收:注入"流水写入失败"故障后余额回滚;重复 `in_trade_no` 不产生第二条流水|涉及:`module/finance/wallet/internal/models/query.go:141`、`module/finance/wallet/internal/logic/payment/by_order.go:143`
|
||
- [ ] **P1-9** 扣减改为条件 UPDATE/行锁的原子操作并断言 `withdrawal_balance` 下限|验收:并发扣款压测下余额精确、无负值、无丢失更新|涉及:`module/finance/wallet/internal/logic/basic/wallet.go:54`
|
||
- [ ] **P1-10** 支付宝 `total_amount` 改为两位小数定点字符串|验收:1550 分下单金额为 "15.50",与本地金额一致|涉及:`module/finance/wallet/internal/logic/alipay/alipay.go:57`
|
||
- [ ] **P1-11** 统一金额/渠道/类型参数校验与溢出保护|验收:负数、0、超大值、非法枚举均返回 `InvalidArgument`,无 panic/溢出|涉及:`module/finance/wallet/internal/logic/payment/by_charge.go:26`、`module/finance/wallet/internal/logic/basic/apply_cash.go:25`
|
||
- [ ] **P1-12** `WxCallback` 检查客户端初始化错误、落 `trade_no`/实收金额/时间并区分失败语义|验收:私钥缺失时返回明确错误而非 panic;支付单可查到渠道流水号|涉及:`module/finance/wallet/internal/logic/wechat/wx_callback.go:26`
|
||
- [ ] **P1-13** 补索引、返回真实 `total`、限制 `pageSize` 上限|验收:`wallet_record` 查询走索引;`Transactions.total` 等于符合条件的总记录数;`pageSize>100` 被拒绝|涉及:`module/finance/wallet/internal/models/query.go:189`、`module/finance/wallet/internal/logic/basic/transactions.go:24`
|
||
- [ ] **P1-14** 校正各环境匿名清单并补配置必填校验(fail-fast)|验收:仅渠道回调匿名且回调内部验签;缺少 `Wallet`/`Alipay`/`Wechat`/`Databases` 段时启动即报错|涉及:`module/finance/wallet/etc/wallet_prod.yaml:23`、`module/finance/wallet/etc/wallet_dev.yaml:38`
|
||
- [ ] **P1-15** 支付密码改用 Argon2id/bcrypt,`SetPayPassword` 增加旧密码或二次验证与失败锁定|验收:无旧密码不能改支付密码,连续失败触发锁定|涉及:`module/finance/wallet/internal/logic/basic/set_pay_password.go:23`、`module/finance/wallet/internal/logic/basic/wallet.go:67`
|
||
- [ ] **P1-16** 绑卡走渠道绑卡并移除跨用户卡号查询|验收:能用他人卡号探测到信息的路径消失;提现仅能使用渠道已绑定卡|涉及:`module/finance/wallet/internal/logic/basic/add_bank_card.go:36`
|
||
- [ ] **P2-17** 剥离启动 seed、读接口不隐式建钱包且检查 Create 错误|验收:无 `demo` 账号时服务仍可启动;`GetWallet` 不产生写库|涉及:`module/finance/wallet/internal/models/query.go:24`、`module/finance/wallet/cmd/main/main.go:47`
|
||
- [ ] **P2-18** 渠道客户端单例化、传递请求 ctx、增加超时与熔断|验收:客户端断开后渠道调用被取消;渠道抖动时失败快速返回|涉及:`module/finance/wallet/internal/logic/wechat/wechat.go:70`、`module/finance/wallet/internal/logic/payment/by_charge.go:66`
|
||
- [ ] **P2-19** 资金路径结构化日志 + 指标 + 日终对账任务与告警|验收:可按 `order_no` 还原完整资金链路;存在余额与流水差异告警|涉及:`module/finance/wallet/internal/logic/basic/apply_cash.go:49`、`module/finance/wallet/internal/logic/payment/callback.go:31`
|
||
- [ ] **P2-20** 支付单以 `order_no` 幂等、校验订单归属、`Get` 增加业务类型校验|验收:同一订单重复下单返回既有支付单;他人订单无法下单|涉及:`module/finance/wallet/internal/logic/payment/by_order.go:31`、`module/finance/wallet/internal/logic/payment/get.go:22`
|
||
- [ ] **P2-21** 修正收出口径并补 `wallet_record` 复合索引/预聚合|验收:支出统计语义与前端约定一致;`GetWallet` 统计查询走索引|涉及:`module/finance/wallet/internal/logic/basic/get_wallet.go:46`、`module/finance/wallet/internal/models/wallet_record.go:29`
|
||
- [ ] **P2-22** 固化业务时区与 `ymd`/`ym` 生成函数并加测试|验收:容器时区变化不影响"今日/本月"统计|涉及:`module/finance/wallet/internal/logic/basic/get_wallet.go:33`
|
||
- [ ] **P2-23** `GetWalletBalanceByPassportIdentity` 返回 `(int64, error)` 并统一定点展示|验收:DB 故障不再被伪装成"余额 0 元"|涉及:`module/finance/wallet/internal/models/query.go:56`、`module/finance/wallet/internal/logic/payment/way.go:37`
|
||
- [ ] **P2-24** 渠道/状态/类型枚举下沉到 proto enum 并集中状态机校验|验收:logic 中不再出现字面量状态/渠道值,非法流转被拒|涉及:`module/finance/wallet/proto/payment.proto:50`、`module/finance/wallet/internal/models/wallet_payment.go:23`
|
||
- [ ] **P3-25** 未实现 RPC 返回 `Unimplemented` 或从 proto 移除并同步文档|验收:不再出现"空响应即成功"的接口|涉及:`module/finance/wallet/internal/logic/alipay/wap_pay.go:18`、`module/finance/wallet/internal/logic/wechat/transfer.go:18`
|
||
- [ ] **P3-26** 清理未使用代码/死模型/死错误码与 `totalMoney` 写法|验收:`go vet` 与静态检查无未使用项,`wallet_refund`/`RefundRequest` 要么实现要么删除|涉及:`module/finance/wallet/internal/impl/impl.go:22`、`module/finance/wallet/internal/models/wallet_refund.go:16`
|
||
- [ ] **P3-27** README 与实现对齐(RPC 名、端口、迁移命令、缓存/健康检查/Docker 承诺、DDL)|验收:README 描述可在仓库中逐条验证|涉及:`module/finance/wallet/README.md:104`、`module/finance/wallet/README.md:266`
|
||
- [ ] **P3-28** 修正 `go.mod` Go 版本声明|验收:`go 1.26` + 可选 `toolchain`,与 README 一致|涉及:`module/finance/wallet/go.mod:3`
|
||
- [ ] **P3-29** 消除包级可变配置、统一 `identity` 生成方式|验收:微信配置启动时一次性加载;钱包 identity 生成统一为 ULID 或 UUID|涉及:`module/finance/wallet/internal/logic/wechat/wechat.go:21`、`module/finance/wallet/internal/models/query.go:43`
|
||
- [ ] **P3-30** 建立 Go 测试与 CI 门禁|验收:并发扣款、回调幂等、提现审核、余额-流水一致性、回调验签五类用例入 CI 且 `-race` 通过|涉及:`module/finance/wallet/test/payment.http:1`
|
||
|
||
## 6. 审计摘要(供汇总使用)
|
||
|
||
- 问题数:P0=5 P1=11 P2=8 P3=6
|
||
- 最高风险(一句话):支付回调完全不入账导致用户实付永不增加余额(必然资金/数据不一致),而 `Payment.Callback` 允许客户端自行把支付单置为"支付成功",叠加 `ByOrder`/`ByCharge` 走支付宝渠道时的 nil 指针 panic(可远程打死钱包进程),使该模块在当前状态下既不能正确管钱也不能安全存活。
|
||
- 最优先 3 个动作:
|
||
1) 修复 `alipay.go:17-53` 的 `Body`/`ctx` 未初始化并给 gRPC server 加 panic recovery,消除远程进程崩溃;
|
||
2) 把微信回调改造成"验签 → 事务内幂等入账(`ChargeWallet` + `wallet_record`)→ 置状态 → 最后返回 SUCCESS",未实现前不返回 SUCCESS;
|
||
3) 删除/限制 `Payment.Callback` 并把所有余额变更收敛到"单事务 + 行锁/条件 UPDATE + 流水同写 + 幂等键"的统一账务入口,同时提现申请先冻结资金。
|
||
- 未能覆盖/无法验证的部分:
|
||
- 仓库无任何 `.sql` 迁移,且 `with.Databases(cfg, nil)` 使 SDK 的 `AutoMigrate` 处于关闭状态(`bsm-sdk/core/database/sql/postgresql.go:17`),因此线上真实索引/约束**无法从代码确认**,索引类结论均基于 GORM 结构体标签;
|
||
- `etc/wallet_prod.yaml:57` 引用的商户私钥文件不在仓库内,无法验证私钥文件权限与保护措施;
|
||
- 未做运行期验证(未启动服务、未压测、未黑盒验证);"匿名接口可被普通 token 改写支付状态"的可利用性取决于部署拓扑:经 `gateway` 转发时匿名请求不携带 `authorization`(`gateway/internal/http/http.go:169-178`、`gateway/internal/rpc/rpc.go:165-196`),会被服务自身 `ParseMetaCtx` 拦住,但**直连 gRPC 端口 `0.0.0.0:12238`** 或同族 `pkgs/all` 网关(把该路径视为匿名)场景下服务侧无任何渠道身份校验;
|
||
- 提现审核、退款/冲正、对账任务的实现对模块内不存在,只能作为缺失项列出,未做实现级审计;
|
||
- 未审计 `pb/` 生成代码与依赖供应链(`go.sum`)。
|