Files
full/docs/audit/module-finance-wallet.md
yanweidong 63aeedc2fe docs: add per-module audit reports (18 modules)
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.
2026-09-14 22:16:27 +08:00

70 KiB
Raw Blame History

审计报告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 支付宝支付与转账

数据模型PostgreSQLinternal/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.gocmd/main/main.go:25 调用 with.Databases(cfg, nil)SDK 在 options == nil 时把 IsAutoMigrate 置为 falsebsm-sdk/core/database/sql/postgresql.go:17),因此表结构与索引实际不会被自动创建或校正,这会影响下文所有索引类结论的解释。

整体健康度结论:该模块目前是一个未完成、不可上线的骨架。资金入账链路(支付回调→余额)整体缺失,提现链路只有"提交申请"一步,而已经接线的部分存在崩溃级缺陷与可越权的支付状态接口。

2. 审计范围与方法

已覆盖

  • 全部非 pb Go 源码 48 个文件cmd/2internal/config1internal/excode1internal/impl1internal/logic/alipay5internal/logic/basic9internal/logic/payment6internal/logic/wechat6internal/models7internal/server5service/2
  • proto 服务与消息定义proto/{wallet,payment,wechat,alipay,const}.proto 全部读完(用于确认金额单位、字段语义、默认值语义)。
  • 配置etc/wallet_{dev,test,prod}.yamletc/supervisor.bsm-finance-wallet.conf
  • 测试资产test/*.http5 个)、test/readme.md
  • 文档README.mdwiki/api/12-wallet.md(用于"文档-实现一致性"比对)。
  • 跨模块交叉验证(只读)full/go.workmodule/** 搜索 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:36totalMoney

两条命令均在首次执行即通过,未超时。

未覆盖 / 无法验证

  • 无真实数据库与索引 DDL:仓库内无任何 .sqlAutoMigrate 运行期关闭,因此"索引是否缺失"依据的是 GORM 结构体标签本身,而非线上实际索引;已在相关问题中标注。
  • 无真实密钥/证书etc/wallet_prod.yaml:57 指向 /usr/local/cert/wechat/apiclient_key.pem,该文件不在仓库内,无法验证私钥保护措施与文件权限。
  • 无运行期验证:未启动服务、未压测、未做黑盒验证;所有"可利用性"结论均为代码级推演。特别是"一可用 token 即可改支付状态"这一点,我确认了网关对匿名接口不转发 authorization 头gateway/internal/http/http.go:169-178gateway/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-53alipay.go:17-50alipay.go:62-69;调用方 internal/logic/payment/by_charge.go:102internal/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.BodyMapalipay.go:14),在 NewAlipay() 中从未分配;SetBody 第一行即解引用。srv.ctx 同理恒为 nilalipay.go:63alipay.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 处触发空指针。由于无 recoverpanic 会终止整个进程 → 远程未鉴权成本极低的整站拒绝服务(进程由 supervisor autorestart=true 拉起,但可被持续打死,见 etc/supervisor.bsm-finance-wallet.conf:5)。修好 Body 后,即便不 panicsrv.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-137internal/logic/wechat/wx_callback.go:15-41internal/logic/payment/callback.go:15-40internal/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.UpsetWalletPaymentByIdentityquery.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_idpay_channeltrade_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 只有 BalanceWithdrawalBalance 两个字段,没有冻结金额frozen字段models/wallet_basic.go:14-24wallet_apply_cash 也没有 cash_noCashNo 字段存在但从未赋值,wallet_apply_cash.go:21)。同时 in.Amountint64,负数能通过 Amount == 0Amount > Balance 两道校验(负数不大于余额)。

  • 影响:① 同一用户可反复提交等额提现申请每次都能通过余额校验N 次申请 = N 倍余额的提现请求;一旦后端"审核通过即打款"(当前无审核实现,见 P1-5即造成超额提现的直接资金损失。② 负数金额申请会把"打款金额"变成向用户扣款/反向转账语义(取决于下游实现),并污染对账。③ 请提现的同时余额未冻结,用户可在提现审核期间把同一笔钱在 ByOrder(channel=3) 花掉,形成"钱既花了又提走了"的双花。④ Channel 仅校验非 0channel=99channel=-1 均被接受(apply_cash.go:25)。
  • 建议:引入 frozen_balance(或独立的 wallet_hold 表);ApplyCash一个事务 + 行锁SELECT ... FOR UPDATE)内:校验 amount > 0amount <= withdrawal_balance - frozen、校验 channel 白名单、扣减可用余额并增加冻结额、生成唯一 cash_no、写入申请单。提现成功/失败/过期时按状态机反向解冻或落账。

4. 支付状态接口由客户端单方决定,可把未支付的支付单改成"支付成功"

  • 位置internal/logic/payment/callback.go:15-40internal/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:40wallet.payment.Callback)与 etc/wallet_test.yaml:33;真实流量入口的网关按方法名精确匹配匿名(gateway/internal/rpc/rpc.go:157-163utils.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-25internal/logic/basic/add_bank_card.go:38-53internal/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-62etc/wallet_dev.yaml:69-79internal/logic/wechat/wechat.go:34-35,68internal/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: filewallet_prod.yaml:32-37),扩大泄露面。
  • 建议:立即轮换微信商户 APIv3 密钥、平台证书与商户私钥,密钥改由 KMS/Vault 或环境变量注入(配置只留引用名),并在仓库历史中清理;DebugSwitch 必须由配置控制且生产默认 DebugOff

7. 提现审核/状态机/放款实现完全缺失,提现永远无法闭环

  • 位置internal/logic/basic/apply_cash.go:49-59internal/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/ 中没有审核/驳回/放款 RPCgrep "ApplyCash" proto/wallet.proto:30,133internal/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-165internal/logic/basic/wallet.go:53-63internal/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-71wallet_record.in_trade_no 无唯一索引(wallet_record.go:22wallet_paymentorder_no 无唯一索引(wallet_payment.go:21)。

  • 影响:① balancewallet_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.goUpdateColumns 走 Update 回调),既不会因并发而失败,也不会基于最新值计算。

  • 影响:两个并发请求各自读到 balance=100各扣 80最终 balance=20应为 -60 被拒绝或 0+一次失败),即经典的丢失更新,等价于少扣/超额扣款;同时 withdrawal_balance 被同步扣减(wallet.go:56)但校验只针对 balancewallet.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:102alipay.SetBody(payBodyAttach.String(), orderNo, in.Amount),金额由客户端提供)、by_order.go:118(金额取自 order_summary.trans_price)。

  • 影响amount = 150015.00 元)时向支付宝传 15 元 → 用户少付 0.00 元成立但 amount = 155015少付 0.5 元amount = 990 元;钱包记录的应付金额与渠道实收金额不一致,对账必然不平,且用户可能以低于订单价的金额完成支付(若后续以回调金额入账则少入账,若以本地金额入账则凭空多入账)。
  • 建议Set("total_amount", decimal(amount, 100)),即用 strconv.FormatFloat(float64(amount)/100, 'f', 2, 64) 或整数拼串生成精确两位小数字符串;禁止用整型除法做金额单位换算,并在下单前断言 amount > 0amount % 1 == 0(分)。

11. 金额等关键参数普遍缺少边界校验0、负数、超大值、渠道/类型枚举)

  • 位置internal/logic/payment/by_charge.go:26internal/logic/basic/apply_cash.go:25internal/logic/wechat/wechat.go:182-195internal/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 }

ByChargein.Amount 直接进入 wechat.GetResponse(..., in.Amount)by_charge.go:66)与支付单落库(by_charge.go:39PayChannel 由客户端给定,仅在 switchdefault 分支兜底(by_charge.go:124-125PayType 同理(by_charge.go:91-92)。

  • 影响:负数金额可生成支付单并下发到渠道请求(total: -100),部分渠道/中间件行为不可预期;超大值(如 9223372036854775807)经 int64 运算在 amount/100balance + 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_MEwallet_dev.yaml:74)即会走到 nil 解引用。

  • 影响:① 配置错误/私钥缺失时,匿名可达的回调端点(wallet_dev.yaml:39wallet_test.yaml:32)被任意请求打成 panic → 进程级 DoS与 P0-1 同类根因,均因缺少 recover② 回调处理不记录 transaction_idwallet_payment.trade_no)、实收金额、success_timeTradeNo 字段全仓无写入方(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-197internal/logic/basic/get_wallet.go:36-73internal/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_typeymdym 三个单列索引(wallet_record.go:21,29,30没有 passport_id / passport_identity 索引,也没有 (passport_id, trans_type, ymd) 复合索引WalletBasic.PassportIdentitytypes.Std_Passport 提供索引(bsm-sdk/core/types/db.go:54),但 PassportID 仅为普通 Indexdb.go:53)。分页参数只做下界修正,无上界:transactions.go:24-26PageSize <= 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-30etc/wallet_dev.yaml:36-41etc/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

生产配置还缺少本模块必需的 AlipayWalletGatewayDatabases 段(对比 wallet_dev.yaml:20-22,36),而 config.Spec.Wallet.*config.Spec.Alipay.* 在多处被直接解引用(payment/way.go:22-36alipay.go:22config.go:83-88conf.NotNil(Spec.Service, Spec.Cache) 只校验了两个字段)。

  • 影响:生产匿名清单形同虚设(列的是别家服务的方法),一旦按 dev 清单修补,wallet.payment.Callback 会被匿名放行(放大 P0-4生产配置缺段会导致 config.Spec.Wallet/Alipay 为 nil → way.go:22alipay.go:22 处 panic 或启动即失败,属于"配置校验缺失 + 不安全默认值"。
  • 建议:按环境校正匿名清单,只保留渠道回调(wallet.wechat.WxCallback)且回调内部必须验签;Payment.Callback 不应匿名;config.New 增加必需的 nil 校验(WalletWechatAlipayDatabasesCache),启动即 fail-fast 并打印缺失项。

15. 支付密码可无鉴权重置,且哈希强度不足、无暴力破解防护

  • 位置internal/logic/basic/set_pay_password.go:17-38internal/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-SHA256salt 为"公开公钥 + 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的唯一凭据。

  • 影响:① 攻击者一旦拿到用户 tokenXSS/中间人/共享设备),可直接重设支付密码并用其清空钱包余额,无需知道原密码;② HMAC-SHA256 密钥部分由 passport_identity + 配置 PublicKey 组成,PublicKey 对服务端可见但不具备真正随机性,且单轮无迭代、无慢哈希,泄露数据库后可高速离线爆破 6 位数字口令;③ 全模块无任何失败计数、锁定、验证码或限流。
  • 建议SetPayPassword 增加旧支付密码校验(首次设置走独立流程并要求短信/实名二次验证);密码改用 Argon2id/bcrypt每用户随机 salt存于独立列增加连续失败锁定与图形/短信验证码;NewWallet 中的密码比较改为常量时间比较(hmac.Equalsubtle.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_idbind_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-53cmd/main/main.go:20-51bsm-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-139SDK—— 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,但代码里不存在该子命令);GetWalletByPassportIdentityCreate 必须检查错误并处理唯一索引冲突(冲突时重查);读接口不写库。

18. 全模块忽略 context.Context,第三方调用无超时/重试/熔断

  • 位置internal/logic/wechat/wechat.go:70-72internal/logic/payment/by_charge.go:66internal/logic/payment/by_order.go:139
  • 证据
// wechat.go:70-73 —— 客户端固定使用 Background ctx与请求 ctx 无关
return &WeChatPay{
	ctx:    context.Background(),
	Client: client,
}, nil

传入的请求 ctxby_charge.go:20 被接收后只用于 ParseMetaCtxby_charge.go:21),之后所有渠道调用(wechat.GetResponsealipay.TradeWapPaymodels.* 的 DB 调用)都不带 WithContextNewWechat() 每次请求都重新 os.ReadFile 私钥并新建客户端(wechat.go:41-74),且 AutoVerifySign() 会去拉取平台证书(wechat.go:62)。

  • 影响:客户端断开或上游取消后,渠道请求仍会跑满其内部超时,白占连接与 goroutine单请求失败没有重试与熔断渠道抖动会直接把延迟透传给用户每请求读文件+建客户端+拉证书是明显的低效路径(也是潜在的性能瓶颈)。
  • 建议NewWechat(ctx)/NewAlipay(ctx) 接收并使用请求 contextDB 访问统一 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.PrometheusTelemetry 只配在 yaml 中(wallet_prod.yaml:39-50),模块代码未产生任何业务指标(如入账金额、回调成功率、提现待处理数);没有任何对账/补偿任务(grep "cron\|ticker\|reconcil" 无命中)。

  • 影响:资金事故(重复入账、提现失败、回调丢失)无法从日志还原与定量,也无法告警;无对账任务意味着差异只能在用户投诉后被动发现。
  • 建议引入结构化日志zap/logger 包已具备)并在所有资金路径固定输出 request_idorder_no/cash_noamountbalance_before/afterchanneltrade_no;为核心流程埋点(入账成功/失败计数、回调验签失败计数、提现待处理时长);新增定时对账任务(wallet_recordwallet_payment ↔ 渠道对账单)与差异告警。

20. 支付单可重复创建、Get 不校验 type、订单支付不校验订单归属

  • 位置internal/logic/payment/get.go:22-36internal/logic/payment/by_order.go:31-58internal/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 每次调用都会新建 WalletPaymentby_order.go:38-58by_order.go:152),不检查该 order_no 是否已有支付单,也不复用;order_summary 的查询条件是 status=1query.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,24internal/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-34etc/wallet_dev.yaml:8bsm-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/Shanghaiwallet_dev.yaml:8),但 ymd/ym应用侧计算并落库的字段(wallet_record.go:29-30created_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-49internal/logic/payment/way.go:37-42internal/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-31internal/logic/payment/by_charge.go:35-39,56-126internal/logic/payment/by_order.go:40-148internal/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,112by_order.go:62,75,82,88,94,111,120,126,138query.go:153-158proto 的 const.proto 也没有为这些枚举定义 enum 类型。

  • 影响:状态与渠道语义只存在于注释和分散的 switch,任何新增渠道/状态都需要全局搜索修改,极易出现状态机漏洞(如 P0-4 中"成功/失败可互相覆盖"TypePayChannel 混淆(by_order.go:40 注释 "2为订单" 与 by_order.go:42PayChannel: int8(in.PayChannel))增加了误用概率。
  • 建议:在 proto 中定义 enum PayChannelenum TradeTypeenum 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.Unimplementedstatus.Error(codes.Unimplemented, ...))而非空成功;同步更新 wiki 文档。

26. 未使用代码 / 死字段 / 未使用错误码

  • 位置与证据
    • internal/impl/impl.go:22-23 初始化的 MemorySericeRedisService 全仓无读写(grep "RedisService\|MemorySerice" 仅命中 impl 与 dependencies
    • internal/models/wallet_refund.go:16-25WalletRefund 无任何读写方;
    • proto/wallet.proto:89-98RefundRequest/RefundReply 无服务方法引用;
    • internal/config/config.go:28QrCodeSavePath 无使用方;
    • internal/excode/ex.go:10-20ErrPassportId(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:36var 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-252go test ./.../go test -cover ./... 会得到"无测试文件"的结果(模块内无 *_test.go
    • README.md:266-269go run cmd/main/main.go migrate 子命令不存在(cmd/main/main.goRun()/main()
    • README.md:104-133 的 RPC 名称与实际不符(PayByOrder/PayByCharge/GetPayment/SetPayPasswordReply/AddBankCardReply vs 实际的 ByOrder/ByCharge/Get/StatusReply
    • README.md:446-459wallet_basic DDL 与模型不一致:status INTEGER DEFAULT 1 vs 模型 default:0wallet_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: 不加 Bearer 前缀",但 test/basic.http 等示例使用 {{BSM_TOKEN}} 变量且未说明前缀,README.md 亦未提及该约束。
  • 影响误导接入方与运维迁移命令不存在、端口错误、RPC 名称错误会导致联调失败)。
  • 建议:以生成的 wikiwiki/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-39internal/impl/impl.go:12-26internal/models/query.go:43service/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-17InitDatautils.ULID()GetWalletByPassportIdentityutils.UUID()query.go:43 vs query.go:82)——同一张表的 identity 长度口径不统一ULID 26 位 vs UUID 36 位,types/db.go:70varchar(36))。service/expose.go:22-43 注册了两套 handlergRPC server 与 gateway handler server但模块自身 cmd/main/main.go 走的是 server.New(nil) + SDK service.StartExpose 仅在被 pkgs/all 引用的场景下生效,两条路径的鉴权一致性没有测试覆盖。

  • 影响:全局配置在并发下被反复覆写(虽为同值,但存在数据竞争的可读性问题);identity 长度口径不统一在 ULID 迁移时会触发截断/唯一索引冲突。
  • 建议:微信配置在进程启动时一次性解析为不可变结构体并注入;identity 生成统一为一个工具(建议 ULID)并断言长度;为 Expose 路径补充与 cmd/main 等价的鉴权测试。

30. 测试资产严重不足

  • 位置test/ 目录
  • 证据:目录内仅 alipay.httpbasic.httphello.httppayment.httpwechat.httpreadme.md(内容仅一行 "grpc & http rest test."没有任何 Go 测试文件glob **/*_test.go 无命中)。
  • 影响资金核心模块零自动化测试P0/P1 中的崩溃、越权、账务不一致问题均无法被 CI 拦住。
  • 建议:补齐关键用例(详见第 4 节"测试补齐清单")。

4. 推荐优化方案

4.1 立即(阻断上线,对应 P0

  1. 修复崩溃面NewAlipay() 初始化 Bodyctxalipay.go:17-50grpc.NewServer() 挂 panic-recovery 拦截器(server/new.go:23WxCallback 检查 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. 原子扣减模板
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 + 随机 saltSetPayPassword 校验旧密码或二次验证;失败锁定与限流;比较使用常量时间比较。

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_refundRefundRequest 已有模型/proto 骨架,可在此基础上补齐退款与冲正。
  4. 测试补齐清单(test/ 目前只有 .http 示例):
    • 并发扣款:同一钱包 100 并发各扣 1 分,断言最终余额精确、无负余额;
    • 回调幂等:同一微信 transaction_id 重复回调 N 次,断言只入账一次;
    • 提现审核:正/负/零/超额/负数金额、重复申请、审核重复提交、失败解冻;
    • 余额与流水一致性:随机化操作后断言 balance == Σ(收入) - Σ(支出) ± 调整
    • 支付渠道签名:伪造/重放/篡改金额的回调必须被拒;
    • 崩溃回归:pay_channel=2 下单不再 panicP0-1
    • 引入 -racego vet/gofmt 到 CI并对 logic/+models/ 设最低覆盖率门禁。

5. TODO 清单

  • P0-1 修复 AliPay.Body/ctx 未初始化并安装 panic-recovery 拦截器|验收:pay_channel=2ByOrder/ByCharge 集成测试通过且进程不退出|涉及:module/finance/wallet/internal/logic/alipay/alipay.go:17module/finance/wallet/internal/server/new.go:23
  • P0-2 打通支付回调入账:验签→定位支付单加锁→ChargeWallet 入账→写流水→置状态→返回 SUCCESS整个过程单事务验收微信重复回调 N 次只入账一次,且 balance 增加额等于实收金额|涉及:module/finance/wallet/internal/logic/wechat/wx_callback.go:33module/finance/wallet/internal/models/query.go:135
  • P0-3 提现申请引入冻结与可提现余额校验(含金额/channel 白名单、cash_no 生成)|验收:同一用户连续提交合计超过余额的申请会被拒,余额在审核期间不可被消费|涉及:module/finance/wallet/internal/logic/basic/apply_cash.go:25module/finance/wallet/internal/models/wallet_basic.go:22
  • P0-4 收敛支付状态写入口:Payment.Callback 移出匿名/限制内网或删除,状态写入加前置状态条件更新|验收:普通用户 token 无法把支付单置为成功;终态不可回改|涉及:module/finance/wallet/internal/logic/payment/callback.go:25module/finance/wallet/etc/wallet_test.yaml:33
  • P0-5 银行卡/身份证/手机号改密文+掩码+hash 存储并按掩码出参|验收:库内无明文卡号/身份证,接口仅返回尾号,日志无敏感字段|涉及:module/finance/wallet/internal/models/wallet_bank.go:21module/finance/wallet/internal/logic/basic/get_bank_card.go:37
  • P1-6 轮换并外置微信商户 APIv3 密钥/证书,生产关闭 SDK Debug验收仓库与 Git 历史无真实密钥,生产日志无渠道请求体|涉及:module/finance/wallet/etc/wallet_prod.yaml:56module/finance/wallet/internal/logic/wechat/wechat.go:68
  • P1-7 实现提现状态机、审核接口与放款/解冻冲正|验收:提现单可从待审核流转到成功/失败且失败自动解冻,审核有独立角色与审计日志|涉及:module/finance/wallet/internal/logic/basic/apply_cash.go:55module/finance/wallet/internal/models/wallet_apply_cash.go:26
  • P1-8 余额变更与流水写入并入同一事务,加幂等唯一索引|验收:注入"流水写入失败"故障后余额回滚;重复 in_trade_no 不产生第二条流水|涉及:module/finance/wallet/internal/models/query.go:141module/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:26module/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:189module/finance/wallet/internal/logic/basic/transactions.go:24
  • P1-14 校正各环境匿名清单并补配置必填校验fail-fast验收仅渠道回调匿名且回调内部验签缺少 Wallet/Alipay/Wechat/Databases 段时启动即报错|涉及:module/finance/wallet/etc/wallet_prod.yaml:23module/finance/wallet/etc/wallet_dev.yaml:38
  • P1-15 支付密码改用 Argon2id/bcryptSetPayPassword 增加旧密码或二次验证与失败锁定|验收:无旧密码不能改支付密码,连续失败触发锁定|涉及:module/finance/wallet/internal/logic/basic/set_pay_password.go:23module/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:24module/finance/wallet/cmd/main/main.go:47
  • P2-18 渠道客户端单例化、传递请求 ctx、增加超时与熔断验收客户端断开后渠道调用被取消渠道抖动时失败快速返回涉及module/finance/wallet/internal/logic/wechat/wechat.go:70module/finance/wallet/internal/logic/payment/by_charge.go:66
  • P2-19 资金路径结构化日志 + 指标 + 日终对账任务与告警|验收:可按 order_no 还原完整资金链路;存在余额与流水差异告警|涉及:module/finance/wallet/internal/logic/basic/apply_cash.go:49module/finance/wallet/internal/logic/payment/callback.go:31
  • P2-20 支付单以 order_no 幂等、校验订单归属、Get 增加业务类型校验|验收:同一订单重复下单返回既有支付单;他人订单无法下单|涉及:module/finance/wallet/internal/logic/payment/by_order.go:31module/finance/wallet/internal/logic/payment/get.go:22
  • P2-21 修正收出口径并补 wallet_record 复合索引/预聚合|验收:支出统计语义与前端约定一致;GetWallet 统计查询走索引|涉及:module/finance/wallet/internal/logic/basic/get_wallet.go:46module/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:56module/finance/wallet/internal/logic/payment/way.go:37
  • P2-24 渠道/状态/类型枚举下沉到 proto enum 并集中状态机校验验收logic 中不再出现字面量状态/渠道值,非法流转被拒|涉及:module/finance/wallet/proto/payment.proto:50module/finance/wallet/internal/models/wallet_payment.go:23
  • P3-25 未实现 RPC 返回 Unimplemented 或从 proto 移除并同步文档|验收:不再出现"空响应即成功"的接口|涉及:module/finance/wallet/internal/logic/alipay/wap_pay.go:18module/finance/wallet/internal/logic/wechat/transfer.go:18
  • P3-26 清理未使用代码/死模型/死错误码与 totalMoney 写法|验收:go vet 与静态检查无未使用项,wallet_refund/RefundRequest 要么实现要么删除|涉及:module/finance/wallet/internal/impl/impl.go:22module/finance/wallet/internal/models/wallet_refund.go:16
  • P3-27 README 与实现对齐RPC 名、端口、迁移命令、缓存/健康检查/Docker 承诺、DDL验收README 描述可在仓库中逐条验证|涉及:module/finance/wallet/README.md:104module/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:21module/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-53Body/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 转发时匿名请求不携带 authorizationgateway/internal/http/http.go:169-178gateway/internal/rpc/rpc.go:165-196),会被服务自身 ParseMetaCtx 拦住,但直连 gRPC 端口 0.0.0.0:12238 或同族 pkgs/all 网关(把该路径视为匿名)场景下服务侧无任何渠道身份校验;
    • 提现审核、退款/冲正、对账任务的实现对模块内不存在,只能作为缺失项列出,未做实现级审计;
    • 未审计 pb/ 生成代码与依赖供应链(go.sum)。