38 KiB
wallet(钱包与支付接入)代码审计报告
| 项 | 内容 |
|---|---|
| 审计对象 | module/finance/wallet(module 路径 bsm/full/module/finance/wallet) |
| 服务域 | 资金与支付 |
| 审计日期 | 2026-09-22 |
| 代码规模 | 手写 Go 45 个文件 / 2382 行;生成代码 pb/ 13 个文件 / 10075 行;proto/ 5 个文件 / 684 行;etc/ 3 个 yaml + 1 个 supervisor conf;*_test.go 0 个 |
| 入口 | 独立入口 cmd/main/main.go(gRPC + grpc-gateway 单进程,端口 12238/12239);聚合入口 pkgs/all、pkgs/ecmall(经 service.Expose);cmd/cli/main.go 为占位 CLI |
| 对外协议 | gRPC(4 个 service / 23 个 RPC)+ grpc-gateway REST(POST /{proto包名}.{服务}/{方法});proto/*.proto 中没有任何 google.api.http 注解,路径由 protoc-gen-slc 生成 |
| 结论摘要 | 分层与目录照模板齐备,但资金链路端到端不通:唯一入账函数 ChargeWallet 全仓库无调用点,支付宝分支必然 panic,微信/支付宝共 7 个 RPC 为 TODO;Payment.Callback 只需用户 JWT 即可把支付单置为成功;提现只校验不扣减、余额扣减为读后写且不写流水、支付密码可被直接覆盖。生产/测试配置因字段名不匹配而无法加载。 |
1. 服务定位与职责
面向 C 端用户的钱包主体:用户钱包(余额、可提现余额、支付密码、绑定的微信/支付宝标识)、银行卡绑定、交易流水、充值/订单支付下单、提现申请,以及对微信支付(APIv3)与支付宝的渠道接入。
它是资金账户与支付下单服务。不负责订单主体生命周期(订单金额从 order_summary 表直接读取,internal/models/query.go:104)、不负责结算清算、也没有对账/退款实现(wallet_refund 表只有模型与迁移注册,无任何代码引用)。
2. 代码结构与入口
| 路径 | 职责 |
|---|---|
cmd/main/main.go |
独立进程入口:config.New("Wallet") → impl.NewImpl() → server.New(nil) → service.New → srv.Use(models.InitData) → srv.Start() |
cmd/cli/main.go |
占位 CLI,只打印 Hello World! / Success |
internal/config/config.go |
SrvConfig(Base、Databases、MicroService、Rpc、Gateway、APM、Etcd、WeChat、Alipay、Wallet、QrCodeSavePath)与校验 |
internal/impl/impl.go |
初始化 Memory、Redis、DB、Etcd 四个全局实例 |
internal/excode/ex.go |
钱包业务错误码(3801~3821) |
internal/server/{new,payment_server,basic_server,wechat_server,alipay_server}.go |
gRPC 服务注册与 1:1 转发到 logic 层(protoc-gen-slc 生成) |
internal/logic/basic/{wallet,get_wallet,set_pay_password,bind_payment_id,transactions,add_bank_card,get_bank_card,rm_bank_card,apply_cash}.go |
钱包/密码/银行卡/流水/提现 |
internal/logic/payment/{hello,way,get,by_order,by_charge,callback}.go |
支付网关列表、下单、支付详情、回调 |
internal/logic/wechat/{wechat,jsapi_pre_order,app_pre_order,native_pre_order,transfer,wx_callback}.go |
微信 APIv3 客户端与下单/回调 |
internal/logic/alipay/{alipay,wap_pay,page_pay,app_pay,transfer}.go |
支付宝客户端与支付/转账 |
internal/models/{wallet_basic,wallet_payment,wallet_record,wallet_apply_cash,wallet_bank,wallet_refund,query}.go |
6 张表的模型 + GORM 自动迁移注册 + query.go 中的全部数据访问函数 |
service/{expose,dependencies}.go |
聚合宿主注入用:Expose 负责注册 gRPC/gateway handler,Dependencies 支持外部覆盖 Redis/Etcd/DB/Cache |
proto/*.proto |
4 个 service 契约(无 HTTP 注解) |
etc/{wallet_dev,wallet_prod,wallet_test}.yaml、etc/supervisor.bsm-finance-wallet.conf |
配置与进程守护配置 |
依赖注入:service.Expose(service/expose.go:22-43)先 applyDependencies 覆盖 internal/impl 中的全局对象,再注册 4 个 gRPC service 与 4 组 gateway handler。独立入口不走该路径,直接使用 internal/impl 自行初始化的对象。
3. 接口清单
全部 RPC 的 HTTP 映射均为 POST /{proto包名}.{服务}/{方法},路径由生成代码固定(pb/wallet.pb.gw.go:600-607、pb/payment.pb.gw.go:472-477、pb/wechat.pb.gw.go:408-412、pb/alipay.pb.gw.go:344-347)。
| 方法 | 路径 | 功能 | 鉴权 | 实现位置 |
|---|---|---|---|---|
| POST | /wallet.Basic/GetWallet |
钱包信息 + 六类收支统计 | JWT | logic/basic/get_wallet.go:18 |
| POST | /wallet.Basic/SetPayPassword |
设置/覆盖支付密码 | JWT | logic/basic/set_pay_password.go:17 |
| POST | /wallet.Basic/BindPaymentId |
绑定微信/支付宝账户标识 | JWT | logic/basic/bind_payment_id.go:17 |
| POST | /wallet.Basic/Transactions |
交易流水分页查询 | JWT | logic/basic/transactions.go:15 |
| POST | /wallet.Basic/AddBankCard |
添加银行卡 | JWT | logic/basic/add_bank_card.go:18 |
| POST | /wallet.Basic/GetBankCard |
银行卡列表 | JWT | logic/basic/get_bank_card.go:16 |
| POST | /wallet.Basic/RmBankCard |
删除银行卡 | JWT | logic/basic/rm_bank_card.go:17 |
| POST | /wallet.Basic/ApplyCash |
提现申请 | JWT | logic/basic/apply_cash.go:19 |
| POST | /wallet.Payment/Hello |
支付服务健康探测 | JWT | logic/payment/hello.go:13 |
| POST | /wallet.Payment/Way |
平台可用支付方式列表 | JWT | logic/payment/way.go:14 |
| POST | /wallet.Payment/Get |
支付单详情 | JWT(按 passport 过滤) | logic/payment/get.go:13 |
| POST | /wallet.Payment/ByOrder |
订单支付下单 | JWT | logic/payment/by_order.go:21 |
| POST | /wallet.Payment/ByCharge |
充值下单 | JWT | logic/payment/by_charge.go:20 |
| POST | /wallet.Payment/Callback |
更新支付单结果与状态 | JWT(仅按 passport 过滤) | logic/payment/callback.go:15 |
| POST | /wallet.Wechat/JsapiPreOrder |
微信 JSAPI 预下单 | 无(未调用 ParseMetaCtx) |
logic/wechat/jsapi_pre_order.go:10 |
| POST | /wallet.Wechat/AppPreOrder |
微信 APP 下单 | JWT | 占位 logic/wechat/app_pre_order.go:11 |
| POST | /wallet.Wechat/NativePreOrder |
微信 Native 下单 | JWT | 占位 logic/wechat/native_pre_order.go:11 |
| POST | /wallet.Wechat/Transfer |
微信转账到零钱 | JWT | 占位 logic/wechat/transfer.go:11 |
| POST | /wallet.Wechat/WxCallback |
微信支付回调 | 无 JWT,使用微信平台验签 | logic/wechat/wx_callback.go:15(业务逻辑为 TODO) |
| POST | /wallet.Alipay/WapPay |
支付宝手机网站支付 | JWT | 占位 logic/alipay/wap_pay.go:11 |
| POST | /wallet.Alipay/PagePay |
支付宝电脑网站支付 | JWT | 占位 logic/alipay/page_pay.go:11 |
| POST | /wallet.Alipay/AppPay |
支付宝 APP 支付 | JWT | 占位 logic/alipay/app_pay.go:11 |
| POST | /wallet.Alipay/Transfer |
支付宝转账 | JWT | 占位 logic/alipay/transfer.go:11 |
鉴权方式:除
Wechat.WxCallback外,所有 logic 入口均调用service.ParseMetaCtx(ctx, nil)(git.apinb.com/bsm-sdk/core/service/meta.go:19-49),要求 metadata 中携带可解析的 JWT;没有做角色/私有 IP 校验(opts恒为nil)。
声明但未实现 / 空转的接口
| 类别 | 接口 | 证据 |
|---|---|---|
TODO 占位(只做鉴权,随后直接 return) |
Wechat.AppPreOrder |
internal/logic/wechat/app_pre_order.go:18-22 |
| 同上 | Wechat.NativePreOrder |
internal/logic/wechat/native_pre_order.go:18-22 |
| 同上 | Wechat.Transfer |
internal/logic/wechat/transfer.go:18-22 |
| 同上 | Alipay.WapPay |
internal/logic/alipay/wap_pay.go:18-22 |
| 同上 | Alipay.PagePay |
internal/logic/alipay/page_pay.go:18-22 |
| 同上 | Alipay.AppPay |
internal/logic/alipay/app_pay.go:18-22 |
| 同上 | Alipay.Transfer |
internal/logic/alipay/transfer.go:18-22 |
| 有实现但业务逻辑未落地 | Wechat.WxCallback(验签+解密后原样返回) |
internal/logic/wechat/wx_callback.go:38 注释 // Todo: 业务逻辑 |
| 有实现但入账函数无人调用 | 充值入账 models.ChargeWallet |
定义于 internal/models/query.go:135,全仓库唯一引用为该定义本身(无调用点) |
上述 7 个占位 RPC 的 Go 写法是
func(...) (reply *XxxReply, err error) { ...; return },即返回(nil, nil)。gRPC 侧会回一个空消息,grpc-gateway 侧对 nil 响应做序列化时会失败(客户端表现为 500/空响应),不是"返回空对象"。
4. 数据模型与表
模型统一在 init() 中通过 database.AppendMigrate 注册自动迁移(internal/models/*.go 的 init())。公共字段来自 SDK:gorm.Model = id/created_at/updated_at/deleted_at;types.Std_Identity = identity varchar(36) uniqueIndex;types.Std_Passport = passport_id、passport_identity varchar(36) index;types.Std_Status = status int64 default 0 index。
表 wallet_basic(internal/models/wallet_basic.go:14-24)
| 字段 | 类型 | 键/约束 | 说明 |
|---|---|---|---|
id |
uint | PK | 自增主键 |
created_at/updated_at/deleted_at |
timestamp | index(deleted_at) | 软删除 |
identity |
varchar(36) | uniqueIndex | 钱包唯一码 |
passport_id |
uint | index | 用户 ID |
passport_identity |
varchar(36) | index(非唯一) | 用户唯一标识 |
alipay_id |
varchar(64) | 默认 '' |
绑定的支付宝 ID |
wxpay_id |
varchar(64) | 默认 '' |
绑定的微信支付 ID |
pay_password |
varchar(255) | 默认 '' |
支付密码(HMAC-SHA256 十六进制串) |
balance |
int64 | 默认 0 | 余额,单位:分 |
withdrawal_balance |
int64 | 默认 0 | 可提现余额,单位:分 |
status |
int64 | index, 默认 0 | 状态(代码中把 -1 视为禁用,logic/basic/wallet.go:31) |
passport_identity没有唯一约束,而懒创建路径(internal/models/query.go:76-92)在记录不存在时新建钱包 → 同一用户可能持有多条钱包记录。
表 wallet_payment(internal/models/wallet_payment.go:16-31)
| 字段 | 类型 | 键/约束 | 说明 |
|---|---|---|---|
identity |
varchar(36) | uniqueIndex | 支付单唯一码 |
passport_id / passport_identity |
uint / varchar(36) | index | 下单人 |
wallet_identity |
varchar(36) | not null | 钱包唯一码 |
order_no |
varchar(255) | not null,无唯一索引 | 订单号(充值单为随机串) |
trade_no |
varchar(255) | 默认 '' |
第三方交易流水号;全仓库无写入点 |
type |
int8 | 默认 0 | 1 充值 / 2 电商订单 |
pay_channel |
int8 | 默认 0 | 1 微信 / 2 支付宝 / 3 钱包余额 |
pay_type |
varchar(255) | not null | JSAPI/APP/MINI/NATIVE/WAP 等 |
amount |
int64 | 默认 0 | 金额,单位:分 |
args |
varchar(255) | 默认 '' |
相关参数;无写入点 |
remark |
varchar(255) | 默认 '' |
备注;无写入点 |
callback_msg |
varchar(255) | 默认 '' |
回调信息 |
status |
int64 | index, 默认 0 | -3 手动取消 / -2 超时 / -1 失败 / 0 创建 / 1 支付中 / 2 成功 |
表 wallet_record(internal/models/wallet_record.go:16-32)
| 字段 | 类型 | 键/约束 | 说明 |
|---|---|---|---|
identity |
varchar(36) | uniqueIndex | 流水唯一码 |
passport_id / passport_identity |
uint / varchar(36) | index | 用户 |
wallet_identity |
varchar(36) | not null | 钱包唯一码 |
trans_type |
int8 | index | -1 支出 / 1 收入 |
in_trade_no |
varchar(50) | not null,无唯一索引 | 内部交易流水号 |
out_trade_no |
varchar(50) | 默认 '' |
外部交易流水号 |
money |
int64 unsigned | 默认 0 | 金额,单位:分 |
fee |
int64 unsigned | 默认 0 | 手续费,单位:分 |
pay_channel |
int8 | 默认 0 | 1 微信 / 2 支付宝 / 3 银行卡 |
pay_type |
varchar(255) | not null | 支付类型 |
trade_type |
int8 | 默认 0 | 1 充值 / 2 提现 / 3 消费 / 4 退款 / 5 收益 |
ymd |
int32 | index, not null | 年月日(用于当日统计) |
ym |
int32 | index, not null | 年月(用于当月统计) |
remark |
varchar(255) | 默认 '' |
备注 |
表 wallet_apply_cash(internal/models/wallet_apply_cash.go:16-28)
| 字段 | 类型 | 键/约束 | 说明 |
|---|---|---|---|
identity |
varchar(36) | uniqueIndex | 提现单唯一码 |
passport_id / passport_identity |
uint / varchar(36) | index | 申请人 |
wallet_identity |
varchar(36) | not null | 钱包唯一码 |
cash_no |
varchar(255) | 默认 '' |
内部交易流水号;无写入点(logic/basic/apply_cash.go:39-47 未设置) |
trade_no |
varchar(255) | 默认 '' |
第三方流水号;无写入点 |
remark |
varchar(255) | 默认 '' |
备注 |
amount |
int64 | 默认 0 | 提现金额,单位:分 |
channel |
int8 | 默认 0 | 1 微信 / 2 支付宝 / 3 银行卡 |
status |
int8 | 默认 0 | -1 失败 / 0 待处理 / 1 成功 |
call_back_msg |
varchar(255) | 默认 '' |
提现回调信息 |
表 wallet_bank(internal/models/wallet_bank.go:16-29)
| 字段 | 类型 | 键/约束 | 说明 |
|---|---|---|---|
identity |
varchar(36) | uniqueIndex | 银行卡记录唯一码 |
passport_id / passport_identity |
uint / varchar(36) | index | 归属人 |
wallet_identity |
varchar(36) | not null | 钱包唯一码 |
card_no |
varchar(255) | not null,明文、无唯一约束 | 银行卡号 |
bank_name / bank |
varchar(255)/(64) | not null | 银行名 / 所属行 |
card_owner |
varchar(255) | not null | 持卡人姓名 |
id_card |
varchar(255) | not null,明文 | 持卡人身份证号 |
phone |
varchar(20) | not null | 银行预留手机号 |
bind_id |
varchar(20) | 默认 '' |
绑卡返回的 bind_id(无写入点) |
bank_type |
varchar(20) | 默认 '' |
DC 储蓄卡 / CC 信用卡 |
表 wallet_refund(internal/models/wallet_refund.go:16-25)
| 字段 | 类型 | 键/约束 | 说明 |
|---|---|---|---|
identity |
varchar(36) | uniqueIndex | 退款单唯一码 |
passport_id / passport_identity |
uint / varchar(36) | index | 用户 |
wallet_identity |
varchar(36) | not null | 钱包唯一码 |
order_identity |
varchar(64) | not null | 订单唯一码 |
reason / order_info / result |
text | 默认 '' |
退款理由 / 订单信息 / 处理结果(均无写入点) |
该表只在迁移注册中被引用,没有任何业务代码使用(退款链路未实现)。
直接访问的外部表
| 表 | 位置 | 用途 |
|---|---|---|
order_summary |
internal/models/query.go:104 |
按 order_no 读取 trans_price 作为订单支付金额 |
passport_account |
internal/models/query.go:24 |
InitData 中按硬编码 account='demo' 查默认用户 |
5. 核心流程
5.1 创建支付单 → 渠道下单 → 回调 → 流水
flowchart TD
A["POST /wallet.Payment/ByCharge 或 ByOrder<br/>service.ParseMetaCtx JWT"] --> B["GetWalletByPassportIdentity<br/>不存在则懒创建钱包"]
B --> C{"in.PayChannel"}
C -->|"1 微信"| D["wechat.NewWechat 读私钥文件<br/>GetResponse 统一下单"]
C -->|"2 支付宝"| E["alipay.NewAlipay<br/>SetBody -> TradeWapPay 或 TradeAppPay"]
C -->|"3 钱包余额"| F["basic.NewWallet<br/>校验状态 支付密码 余额"]
F --> G["TradeConsum 读到的余额减去金额后回写"]
D --> H["models.CreatePaymentRecord<br/>写 wallet_payment status=0"]
E --> H
G --> H
H --> I["渠道异步通知"]
I --> J["wallet.Wechat/WxCallback<br/>VerifySignByPKMap 验签 + 解密"]
J --> K["业务逻辑 TODO 原样返回 SUCCESS"]
I --> L["wallet.Payment/Callback<br/>用户 JWT 直接指定成功或失败"]
L --> M["UpsetWalletPaymentByIdentity<br/>只更新 status 与 callback_msg"]
K --> N["不落库 不改状态"]
M --> O["入账缺失"]
O --> P["ChargeWallet 无调用点<br/>wallet_basic.balance 不增加"]
P --> Q["wallet_record 不写流水"]
5.2 提现申请
flowchart TD
A["POST /wallet.Basic/ApplyCash<br/>service.ParseMetaCtx JWT"] --> B["校验 amount 与 channel 非 0"]
B --> C["GetWalletByPassportIdentity 读取余额"]
C --> D{"in.Amount 大于 myWallet.Balance"}
D -->|是| E["返回 ErrBalanceNotEnough"]
D -->|否| F["Create wallet_apply_cash status=0"]
F --> G["返回 OK"]
G -.-> H["不扣减 balance 不冻结 不出款"]
H -.-> I["不写 wallet_record 流水"]
I -.-> J["无幂等键 cash_no 未写<br/>重复提交按次数叠加提现单"]
6. 审计发现
6.1 安全
| 级别 | 位置 | 问题 |
|---|---|---|
| 高 | internal/logic/payment/callback.go:16-31 |
支付结果回调由客户端自证。接口只做 JWT 解析,随后用调用方给的 in.CallbackStatus 直接决定 status = 2(成功)或 -1(失败),只用 auth.Identity 作为 passport_identity 过滤条件定位支付单。没有渠道验签、没有金额核对、没有 trade_no 校验、没有状态机约束、没有幂等。任何持有有效 JWT 且知道(或猜到)自己支付单 identity 的调用方,都能把自己的待支付单标记为"支付成功"。 |
| 高 | etc/wallet_test.yaml:33 + internal/logic/payment/callback.go:16 |
wallet.payment.Callback 被列入匿名清单(etc/wallet_dev.yaml:40 同类),而实现要求 JWT(meta.go:21-29)。渠道服务端回调不可能携带用户 JWT,因此该接口作为真实回调必定失败;若网关按匿名放行,则等于把"支付成功"开关完全开放。配置与实现互相矛盾,两条路都不可接受。 |
| 高 | internal/logic/wechat/jsapi_pre_order.go:10-27 |
Wechat.JsapiPreOrder 完全没有鉴权(函数体内无 ParseMetaCtx),且 amount 由请求方直接给出并原样作为微信下单金额(internal/logic/wechat/wechat.go:77-101 的 Set("total", amount))。任何人都可匿名发起任意金额的微信下单。 |
| 高 | internal/logic/basic/set_pay_password.go:23-29 |
修改支付密码不校验旧密码、不校验密码强度与长度(只判 in.Password == ""),直接 Update("pay_password", ...)。持有任意有效 JWT 即可覆盖支付密码,进而配合 PayChannel=3 的钱包扣款(logic/payment/by_order.go:139-146)完成盗刷。 |
| 高 | internal/logic/basic/wallet.go:67-69 |
支付密码使用单轮 HMAC-SHA256(git.apinb.com/bsm-sdk/core/utils/crypto.go:19-25),key 为 passportIdentity + config.Spec.Wallet.PublicKey,结果以十六进制串明文存库(wallet_basic.pay_password)。无迭代、无随机盐、无失败次数限制;6 位数字密码(示例 test/basic.http:22 即 123456)离线穷举仅 10^6 次 HMAC。 |
| 高 | etc/wallet_prod.yaml:56、etc/wallet_dev.yaml:73 |
微信支付凭据明文硬编码并提交入库:APIV3Key: MakeSupplyChainFinance1123581321、MchID: 1670561983、SerialNo: 209F629E63E56D2366361969B51FBA0ADCF69F55、AppID: wx9a2f163207219776。APIv3 密钥是微信支付回调报文解密密钥,泄露即可解密全部交易通知。 |
| 高 | internal/logic/basic/apply_cash.go:19-53 |
提现申请不要求支付密码,也不校验提现渠道是否已绑定对应账户(Channel 只判非 0),任意 JWT 即可为任意渠道生成提现单。 |
| 中 | internal/logic/basic/bind_payment_id.go:23-36 |
PayType 只判 == 0,传 3 等非法值时 filed 保持空串,最终执行 Update("", in.AuthCode)(目标列名非法);同时把前端传入的 auth_code 直接当作 openid 落库,没有走 code2session 换取并校验 openid,可绑定他人账号标识。 |
| 中 | internal/logic/basic/add_bank_card.go:34-47、internal/logic/basic/get_bank_card.go:37-47 |
银行卡号与身份证号明文存储、并以 BankNumber/IdCard 原样回显;add_bank_card.go:36 的 Where("card_no = ?", in.CardNo) 未限定 passport,用于给任意卡号填充 bank_name/bank/bank_type,可据此探测某卡号是否已被其他用户绑定(存在性侧信道)。 |
| 中 | internal/logic/wechat/wechat.go:68、internal/logic/alipay/alipay.go:27 |
生产代码中开启 gopay 调试开关 client.DebugSwitch = gopay.DebugOn,会把渠道请求/响应(含金额、out_trade_no、openid)写入进程日志。 |
6.2 正确性与逻辑缺陷
| 级别 | 位置 | 问题 |
|---|---|---|
| 高 | internal/logic/payment/by_order.go:37-49、internal/logic/payment/by_charge.go:30-44 |
空指针 panic。myWallet.Identity 在 wErr 判空之前被求值(付款记录结构体字面量里直接引用),而 GetWalletByPassportIdentity 在非 ErrRecordNotFound 的错误上返回 (nil, err)(internal/models/query.go:88)→ 查询一失败就在构造结构体时崩溃。 |
| 高 | internal/logic/basic/wallet.go:53-63 |
余额扣减为读后写,且无事务/无行锁/无余额条件:"balance": srv.Body.Balance - amount 用的是 NewWallet 时刻读到的旧值,UPDATE 语句只有 WHERE identity = ?(wallet.go:58)。并发两笔消费都能通过 wallet.go:42 的余额校验,最终写入同一个余额值 → 双花/丢更新。withdrawal_balance 同样被硬减,可能变为负数。 |
| 高 | internal/logic/basic/apply_cash.go:35-49 |
提现只校验不扣减。校验 in.Amount > myWallet.Balance 后仅插入一条 wallet_apply_cash,既不扣 balance 也不冻结,且 cash_no 留空、无幂等键、无重复提交防护。余额 100 元可连续提交 N 张 100 元提现单。 |
| 高 | internal/models/query.go:135-165 |
ChargeWallet 是唯一的钱包入账函数,但全仓库无调用点(仅有定义)。其内部还叠加三个缺陷:余额加法为读后写(query.go:143,与 6.2 第 2 条同型);余额更新与流水写入不在同一事务(query.go:146 / query.go:161);CreateTradeRecord 的返回值被直接丢弃(query.go:161)。 |
| 高 | 全链路 | 充值/支付回调不会改变余额(对应 6.3)。ByCharge 只写 wallet_payment(by_charge.go:129),WxCallback 不落库(wx_callback.go:38),Payment.Callback 只改 status/callback_msg(internal/models/query.go:119-122),ChargeWallet 无人调用 → wallet_basic.balance 永不增加。 |
| 高 | internal/logic/payment/by_charge.go:26-28 |
金额只判 in.Amount == 0,负数金额可以通过(如 -100),也没有最小/最大金额限制。 |
| 中 | internal/logic/payment/callback.go:25-31 |
无状态机约束:任意状态可直接置为 2 或 -1,无时间窗、无幂等(重复回调只是覆盖 callback_msg);wallet_payment.trade_no 字段全仓库无写入点,因此回调后也拿不到渠道流水号,无法对账。 |
| 中 | internal/models/query.go:168-197 |
FindWalletRecords 中 4 处 tx.Where(...) 未回写(query.go:174、177、182、185),时间范围与收支/交易类型过滤实际不生效;返回的 total 用的是 res.RowsAffected(query.go:196),即当页条数而非总条数,却被 logic/basic/transactions.go:33 直接作为 Total 返回。 |
| 中 | internal/logic/basic/transactions.go:21-26 |
if in.PageSize <= 1 { in.PageSize = 20 }:page_size = 1 被强制放大为 20;且 page_size 无上限,配合 query.go:189 的 Limit 可一次拉取全表流水。 |
| 中 | internal/logic/basic/get_wallet.go:36-73 |
六个统计查询共用同一个 totalMoney 变量,每次 Scan 前不清零,且全部忽略 .Error。任一次 sum() 返回 NULL 或查询失败时,会把上一次的统计结果当作本次结果返回(例如"当日支出"显示成"当日收入"的金额)。 |
| 中 | internal/logic/basic/get_wallet.go:41,47,53,59 |
当日/当月统计按 ymd/ym 过滤,但唯一的流水写入点(internal/models/query.go:151-158)从不写 Ymd/Ym(落库为 0)→ 当日/当月收支统计恒为 0。 |
| 中 | internal/logic/alipay/alipay.go:46-49 + alipay.go:52-60 |
必然 panic。NewAlipay 返回的 AliPay.Body 为 nil(构造时只赋 Client),而 SetBody 第一句就是 srv.Body.Set(...)(alipay.go:53)。go-pay v1.5.123 的 BodyMap.Set 是值接收者(gopay/body_map.go:32),经 nil 指针调用会解引用空指针;已在同版本 gopay 上最小复现(panic: runtime error: invalid memory address or nil pointer dereference)。ByOrder/ByCharge 的 PayChannel == 2 分支必然触发(by_order.go:118、by_charge.go:102)。 |
| 中 | internal/logic/alipay/alipay.go:23-24,48 |
ctx 字段被注释掉、从未赋值,TradeWapPay/TradeAppPay 拿到 nil context(alipay.go:63、alipay.go:68)→ 即便修好 Body,渠道调用仍会以 nil Context 失败。 |
| 中 | internal/logic/alipay/alipay.go:55-57 |
无论 WAP 还是 APP 都写死 product_code = QUICK_WAP_WAY(APP 应为 QUICK_MSECURITY_PAY);total_amount 用 amount/100 整数除法,非整元金额被截断(10050 分 → 100 元),且未按支付宝要求传两位小数字符串。 |
| 中 | internal/logic/wechat/jsapi_pre_order.go:22、internal/logic/wechat/wx_callback.go:26 |
wx, _ := NewWechat() 丢弃错误。NewWechat 在读不到私钥文件时返回 (nil, err)(wechat.go:48-51),下一行访问 wx.Client 即 panic。 |
| 中 | internal/logic/wechat/wechat.go:77-102、146-173、internal/logic/payment/by_order.go:95 |
GetResponse 与 NativePaySign 的语义与用途不符:GetResponse 内部固定调用 V3TransactionJsapi(wechat.go:96),NativePaySign 实际调用 V3TransactionNative(wechat.go:164)却接收一个 expire 参数;by_order.go:95 调用时该位置传空串,导致 Set("time_expire", "") 被发给微信。 |
| 中 | internal/logic/payment/by_order.go:152-155、by_charge.go:129-132 |
先向渠道下单、再写本地支付记录。写库失败时渠道侧订单已生成(孤儿单),且无幂等键,重复调用会为同一 order_no 生成多条 wallet_payment(order_no 无唯一索引)。 |
| 中 | internal/models/query.go:76-92 |
钱包懒创建:impl.DBService.Create(wallet) 的错误被忽略(query.go:85),失败时仍返回一个 Identity 有值但库中不存在的钱包,后续所有 UpdateColumns 静默影响 0 行;且 passport_identity 无唯一约束,并发下可为同一用户创建多条钱包,余额分裂。 |
| 中 | internal/logic/basic/bind_payment_id.go:27-36 |
变量名 filed(应为 field)拼写错误;PayType 不在 {1,2} 时目标列为空串(见 6.1)。 |
| 低 | internal/models/query.go:102-108 |
GetTransPriceByOrderNo 忽略 Scan 的 .Error,以 amount == 0 代替错误判断,"0 元订单"与"订单不存在"无法区分;同时直接读 order_summary(跨模块直连表)。 |
| 低 | internal/models/query.go:119 |
函数名 UpsetWalletPaymentByIdentity 拼写错误(应为 Upsert),且实现是 Updates,语义为"更新"而非"更新或插入"。 |
6.3 未完成实现
| 类别 | 内容 | 证据 |
|---|---|---|
| TODO 占位 RPC(7 个) | Wechat.AppPreOrder、Wechat.NativePreOrder、Wechat.Transfer、Alipay.WapPay、Alipay.PagePay、Alipay.AppPay、Alipay.Transfer |
internal/logic/wechat/{app_pre_order,native_pre_order,transfer}.go:18-22、internal/logic/alipay/{wap_pay,page_pay,app_pay,transfer}.go:18-22 |
| 回调业务逻辑未落地 | WxCallback 验签+解密后 // Todo: 业务逻辑,直接返回 SUCCESS;不入库、不改支付状态、不入账 |
internal/logic/wechat/wx_callback.go:38-40 |
| 入账链路断裂 | models.ChargeWallet(唯一加余额的函数)无任何调用点 |
定义 internal/models/query.go:135;全仓库引用仅此一处 |
| 退款链路未实现 | WalletRefund 模型只注册迁移,无任何业务代码引用 |
internal/models/wallet_refund.go:27-29 |
| 启动依赖 Demo 数据 | InitData 按硬编码 account='demo' 查 passport_account,查不到即返回 error,而 srv.Use 会 panic(SDK service/service.go:133-138)→ 生产库中无 demo 账号时独立入口启动失败 |
internal/models/query.go:24-30、cmd/main/main.go:47 |
| 支付单不可回填渠道流水 | wallet_payment.trade_no、wallet_apply_cash.cash_no、wallet_bank.bind_id、wallet_payment.args/remark 均无写入点 |
internal/models/wallet_payment.go:22,27-28、internal/models/wallet_apply_cash.go:21-22、internal/models/wallet_bank.go:26 |
| 文档与实现不符 | README 声称存在 Makefile、Dockerfile、swagger/、scripts/ 目录以及 go run cmd/main/main.go migrate 迁移子命令,实际目录中均不存在,main.go:53-55 也只有 Run() |
README.md:53,66,92-93,269 |
6.4 健壮性与可维护性
| 级别 | 位置 | 问题 |
|---|---|---|
| 高 | etc/wallet_prod.yaml:1-4、etc/wallet_test.yaml:1-4 |
配置文件使用 Name/ListenOn/Dsn,而 SrvConfig 只识别 Service/Port/Databases(internal/config/config.go:16-18)。SDK 在解析前先检查文件是否含子串 "Service:"(git.apinb.com/bsm-sdk/core/conf/new.go:58-60),这两个文件都不含 → log.Fatalln("ERROR: Service Not Nil") 直接退出。三个配置中只有 wallet_dev.yaml 能被加载。 |
| 高 | etc/wallet_dev.yaml(无 Wallet:/Alipay: 段)+ internal/config/config.go:25-27 |
config.Spec.Wallet、config.Spec.Alipay 保持 nil,而 internal/logic/payment/way.go:22,29,36 与 internal/logic/alipay/alipay.go:53,59 直接解引用 → 扣款方式列表与支付宝下单均 panic。 |
| 高 | internal/impl/impl.go:24、internal/config/config.go:83 |
with.Databases(config.Spec.Databases, nil),但 conf.NotNil 只校验 Service/Cache,未校验 Databases;Databases 缺失时 SDK 直接 panic("No Database Source Found !")(git.apinb.com/bsm-sdk/core/with/databases.go:13-15)。 |
| 中 | internal/server/new.go:16-30、cmd/main/main.go:39 |
Server.Mux 声明后从未赋值,独立入口把 nil 传给 GatewayMux;而 grpc-gateway 的 RegisterXxxHandlerServer 只在 service/expose.go:30-41 调用(仅聚合入口触发)→ 独立进程启动 HTTP 网关时 handler 为 nil(落到 DefaultServeMux),所有 REST 路径 404。 |
| 中 | internal/impl/impl.go:22-23,25、service/dependencies.go:29-31 |
RedisService、MemorySerice 被初始化/注入,但全模块无任何使用点(死代码);EtcdService 仅在微服务注册时被 SDK 使用。 |
| 中 | internal/logic/wechat/wechat.go:21-29,41-74 |
mchID、SerialNumber、mchAPIv3Key、ApiClientKey、AppId 等为包级可变变量,每次 NewWechat 都被重写且无同步;并发下单时存在数据竞争(go test -race 会报)。 |
| 低 | internal/excode/ex.go:10-16 |
定义的 ErrPassportId/ErrAuthCode/ErrOrderNo/ErrAmount/ErrIdentityArgument/ErrPasswordArgument 中,ErrVerifySign(签名验证失败)全模块无使用点——与"回调不验签"的实际实现一致。 |
| 低 | internal/logic/basic/get_wallet.go:74 |
残留注释 // remove useless fmt.Sprint side-effect-free call。 |
| 低 | 全模块 | 0 个 *_test.go;test/ 下仅有 5 个 .http 手测样例与一行说明的 readme.md。 |
| 低 | README.md:444-491 |
README 中的建表 DDL 与真实模型不一致:wallet_payment DDL 缺 wallet_identity/trade_no/type/pay_channel/args/remark;wallet_record DDL 缺 fee/pay_type/ymd/ym/remark/passport_id。 |
7. 风险汇总
| 编号 | 级别 | 问题 | 影响面 |
|---|---|---|---|
| W1 | 高 | Payment.Callback 凭用户 JWT 即可把支付单置为成功(无验签/无金额核对/无幂等) |
资金安全、订单履约 |
| W2 | 高 | 支付回调匿名清单与 JWT 实现矛盾,真实渠道回调不可用 | 支付链路可用性 |
| W3 | 高 | 充值入账链路断裂:ChargeWallet 无调用点,回调不增加余额 |
核心功能不可用 |
| W4 | 高 | 钱包余额扣减为读后写、无事务/条件更新 → 并发双花 | 资金安全 |
| W5 | 高 | 提现只校验不扣减、无幂等键 → 重复提交超额提现 | 资金安全 |
| W6 | 高 | 支付密码可被任意 JWT 直接覆盖;密码仅单轮 HMAC-SHA256 | 账户与资金安全 |
| W7 | 高 | 支付宝分支 Body 为 nil → PayChannel=2 必然 panic |
接口可用性 |
| W8 | 高 | ByOrder/ByCharge 在 wErr 判空前解引用 myWallet.Identity → 空指针 panic |
接口可用性 |
| W9 | 高 | JsapiPreOrder 无鉴权且金额由请求方指定 |
资金安全、接口滥用 |
| W10 | 高 | 微信 APIv3 密钥、商户号明文硬编码入库 | 渠道凭据泄露 |
| W11 | 高 | 余额/提现/消费全链路不写 wallet_record 流水 |
对账与可追溯性 |
| W12 | 高 | 生产与测试配置字段名不匹配,服务无法启动(独立入口) | 部署可用性 |
| W13 | 高 | wallet_dev.yaml 缺 Wallet/Alipay 段 → Way 等方法 nil 解引用 |
运行期崩溃 |
| W14 | 高 | Databases 未做非空校验,缺失即 panic |
启动崩溃 |
| W15 | 中 | 交易流水查询过滤失效、Total 语义错误、分页无上限 |
功能不正确 + 全表拉取 |
| W16 | 中 | GetWallet 六个统计共用变量且忽略错误;ymd/ym 从未写入 → 统计结果错误或恒为 0 |
数据展示错误 |
| W17 | 中 | 7 个 RPC 为 TODO 占位,返回 (nil, nil) |
功能缺失、网关异常 |
| W18 | 中 | 银行卡号/身份证明文存储与回显;按卡号跨用户探测 | 隐私与合规 |
| W19 | 中 | 独立入口 HTTP 网关 Mux 为 nil(handler 从未注册) |
接口不可用 |
| W20 | 中 | 渠道 client 包级变量并发写、NewWechat 错误被丢弃 |
并发崩溃 |
| W21 | 低 | 死代码(Redis/Memory 未用、ErrVerifySign 未用)、无测试、README 与实现不符 |
可维护性 |
8. 修复建议(务实项)
- 回调改为渠道验签入口(W1/W2/W3):
Payment.Callback不应接受客户端提交的CallbackStatus。把status的来源改为渠道回调(微信走WxCallback的验签+解密结果,支付宝走其异步通知验签),并在回调内完成:按out_trade_no反查支付单 → 用渠道返回的金额与wallet_payment.amount比对 → 校验当前status后置为2(幂等:WHERE status IN (0,1))→ 在同一事务内调用ChargeWallet入账并写wallet_record(补上Ymd/Ym)。Payment.Callback至多保留为内部/管理端接口,并从匿名清单中移除。 - 钱包扣减改成条件更新(W4/W5):
TradeConsum用一条语句完成UPDATE wallet_basic SET balance = balance - ?, withdrawal_balance = withdrawal_balance - ? WHERE identity = ? AND balance >= ? AND status <> -1,按RowsAffected == 0判定余额不足;ApplyCash在同一事务内先扣减/冻结余额再插提现单,并给wallet_apply_cash加上请求幂等键(写cash_no,加唯一索引)。 - 支付密码(W6):
SetPayPassword增加旧密码校验分支(首次设置与修改分开);把EncodePassword换成 SDK 或golang.org/x/crypto/bcrypt的慢哈希,并在钱包表增加失败次数/锁定字段。密码长度做最小校验(当前示例数据用的123456应被拒)。 - 补齐
wallet_record流水(W11):TradeConsum(消费)、ApplyCash(提现)、ChargeWallet(充值)三条路径都必须写流水,并把流水写入与余额变更放进同一事务;TradeConsum目前只改余额不写流水。 - 修 panic 点(W7/W8/W13):把
models.WalletPayment{...}的构造移到wErr判断之后;NewAlipay初始化Body(Body: &gopay.BodyMap{})并给ctx赋context.Background();对config.Spec.Wallet/Alipay/WeChat在config.New中做非空校验(与conf.NotNil一致的处理),或在 logic 入口显式判空返回错误。 - 微信下单(W9/W20):
JsapiPreOrder补service.ParseMetaCtx;金额改由服务端按业务单据决定,不接受请求方直接指定;NewWechat的返回错误不得丢弃;把渠道密钥从包级变量改为WeChatPay结构体字段。 - 配置(W12/W14):
wallet_prod.yaml、wallet_test.yaml按SrvConfig的字段名重写(Service/Port/Databases/Cache),补上Wallet、Alipay、MicroService、Gateway段;config.New里补conf.NotNil(Spec.Databases);把APIV3Key/MchID/PrivateKey改为从环境变量注入,并立即轮换已泄露的 APIv3 密钥。 - 查询与统计(W15/W16):
FindWalletRecords的 4 处tx.Where回写tx,total用独立的Count查询;transactions给page_size设上限;GetWallet的 6 个统计各自使用独立变量并在Scan前清零、检查.Error;CreateTradeRecord补写Ymd/Ym。 - 敏感数据(W18):
wallet_bank.card_no/id_card改为加密存储(或至少仅存掩码 + 密文),接口回显掩码;add_bank_card.go:36的卡号查询加passport_identity条件,去掉存在性侧信道。 - 占位与死代码(W17/W19/W21):7 个 TODO RPC 在实现前应显式返回
Unimplemented错误而不是nil, nil(避免网关侧空响应);internal/server/new.go中给Mux赋gwRuntime.NewServeMux()并在独立入口注册 handler,或直接在独立入口改走service.Expose;删除internal/impl中未使用的 Redis/Memory 初始化与ErrVerifySign(若已按第 1 条接入验签则保留)。 - 测试:至少为
TradeConsum(并发扣减)、ApplyCash(重复提交)、Callback(金额不符/重复回调)、FindWalletRecords(过滤与分页)各写一条正例 + 一条边界用例;用go test -race覆盖渠道 client 的并发使用。
本报告只列出与现有实现直接相关的修复项。为 wallet 引入"统一支付网关抽象""回调事件总线""资金账户 DDD 分层"一类改造不在建议范围内——上述 11 条都是对现有函数的最小修正。