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.
70 KiB
审计报告: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 - 证据:
// 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 会终止整个进程 → 远程未鉴权成本极低的整站拒绝服务(进程由 supervisorautorestart=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 - 证据:
// 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 - 证据:
// 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 - 证据:
// 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 - 证据:
// 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 - 证据:
# etc/wallet_prod.yaml:53-60 —— 真实商户号 + 明文 APIv3 密钥
WeChat:
AppID: wx9a2f163207219776
SerialNo: 209F629E63E56D2366361969B51FBA0ADCF69F55
APIV3Key: MakeSupplyChainFinance1123581321
MchID: 1670561983
// 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 - 证据:
// apply_cash.go:55-59 —— 只返回"已生成提现单",无后续
//生成提现单
return &pb.StatusReply{ Message: vars.OK, Timeseq: time.Now().UnixMilli() }, nil
// 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 - 证据:
// 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) // 返回值未接收
}
// 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
// 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(读余额在校验、扣减在另一条语句) - 证据:
// 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 - 证据:
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 - 证据:
// 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 - 证据:
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 - 证据:
// 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
// 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 - 证据:
# 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 - 证据:
// 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 - 证据:
// 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 - 证据:
// 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("默认用户未找到") }
// 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 - 证据:
// 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 接入点 - 证据:
// 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 - 证据:
// 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 - 证据:
// 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"` // 交易金额,单位:分
// 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 - 证据:
// 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 - 证据:
// 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 - 证据:
// 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:支付成功
// 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/AddBankCardReplyvs 实际的ByOrder/ByCharge/Get/StatusReply);README.md:446-459的wallet_basicDDL 与模型不一致:status INTEGER DEFAULT 1vs 模型default:0(wallet_basic.go:23)、identity VARCHAR(255)vsvarchar(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: 不加 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 - 证据:
// 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)
- 修复崩溃面:
NewAlipay()初始化Body与ctx(alipay.go:17-50);grpc.NewServer()挂 panic-recovery 拦截器(server/new.go:23);WxCallback检查NewWechat()错误(wx_callback.go:26)。三者合起来才能消除"单请求打死进程"。 - 停止资金裸奔:在入账逻辑完成前,
WxCallback不得返回SUCCESS(改为返回失败让渠道重试并在内部告警);ByCharge若无法保证入账,应先下线该接口(返回Unimplemented)。 - 关闭伪造入口:
Payment.Callback从匿名清单移除并限制为内网/管理角色,或直接删除该 RPC,支付状态只允许由验签后的渠道回调写入。 - 提现前置冻结:新增
frozen_balance字段(或 hold 表),ApplyCash在事务 + 行锁内校验amount > 0 && amount <= withdrawal_balance - frozen并冻结,同时校验channel白名单。 - 敏感数据治理:银行卡/身份证/手机号改为密文 + 掩码 + hash 三列存储,出参掩码;清理仓库中的真实商户号/APIv3 密钥并轮换。
4.2 短期(账务正确性地基,对应 P1)
- 统一资金变更入口:新增
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)幂等。 - 原子扣减模板:
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 中期(可观测性与对账)
- 结构化日志:所有资金路径固定字段
request_id, passport_identity, wallet_identity, order_no/cash_no, channel, amount, balance_before, balance_after, trade_no。 - 指标:入账成功/失败、回调验签失败、支付单创建/成功转化率、提现各状态时长、余额与流水差异条数(Prometheus 端点已在
etc/*.yaml配置)。 - 对账任务:定时拉取微信/支付宝对账单,与
wallet_payment/wallet_record三方比对,差异写入wallet_reconcile_diff并告警;因wallet_refund与RefundRequest已有模型/proto 骨架,可在此基础上补齐退款与冲正。 - 测试补齐清单(
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.modGo 版本声明|验收: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 个动作:
- 修复
alipay.go:17-53的Body/ctx未初始化并给 gRPC server 加 panic recovery,消除远程进程崩溃; - 把微信回调改造成"验签 → 事务内幂等入账(
ChargeWallet+wallet_record)→ 置状态 → 最后返回 SUCCESS",未实现前不返回 SUCCESS; - 删除/限制
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)。
- 仓库无任何