348 lines
38 KiB
Markdown
348 lines
38 KiB
Markdown
# 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 创建支付单 → 渠道下单 → 回调 → 流水
|
||
|
||
```mermaid
|
||
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 提现申请
|
||
|
||
```mermaid
|
||
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. 修复建议(务实项)
|
||
|
||
1. **回调改为渠道验签入口**(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` 至多保留为内部/管理端接口,并从匿名清单中移除。
|
||
2. **钱包扣减改成条件更新**(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`,加唯一索引)。
|
||
3. **支付密码**(W6):`SetPayPassword` 增加旧密码校验分支(首次设置与修改分开);把 `EncodePassword` 换成 SDK 或 `golang.org/x/crypto/bcrypt` 的慢哈希,并在钱包表增加失败次数/锁定字段。密码长度做最小校验(当前示例数据用的 `123456` 应被拒)。
|
||
4. **补齐 `wallet_record` 流水**(W11):`TradeConsum`(消费)、`ApplyCash`(提现)、`ChargeWallet`(充值)三条路径都必须写流水,并把流水写入与余额变更放进同一事务;`TradeConsum` 目前只改余额不写流水。
|
||
5. **修 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 入口显式判空返回错误。
|
||
6. **微信下单**(W9/W20):`JsapiPreOrder` 补 `service.ParseMetaCtx`;金额改由服务端按业务单据决定,不接受请求方直接指定;`NewWechat` 的返回错误不得丢弃;把渠道密钥从包级变量改为 `WeChatPay` 结构体字段。
|
||
7. **配置**(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 密钥。
|
||
8. **查询与统计**(W15/W16):`FindWalletRecords` 的 4 处 `tx.Where` 回写 `tx`,`total` 用独立的 `Count` 查询;`transactions` 给 `page_size` 设上限;`GetWallet` 的 6 个统计各自使用独立变量并在 `Scan` 前清零、检查 `.Error`;`CreateTradeRecord` 补写 `Ymd`/`Ym`。
|
||
9. **敏感数据**(W18):`wallet_bank.card_no`/`id_card` 改为加密存储(或至少仅存掩码 + 密文),接口回显掩码;`add_bank_card.go:36` 的卡号查询加 `passport_identity` 条件,去掉存在性侧信道。
|
||
10. **占位与死代码**(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 条接入验签则保留)。
|
||
11. **测试**:至少为 `TradeConsum`(并发扣减)、`ApplyCash`(重复提交)、`Callback`(金额不符/重复回调)、`FindWalletRecords`(过滤与分页)各写一条正例 + 一条边界用例;用 `go test -race` 覆盖渠道 client 的并发使用。
|
||
|
||
> 本报告只列出与现有实现直接相关的修复项。为 wallet 引入"统一支付网关抽象""回调事件总线""资金账户 DDD 分层"一类改造不在建议范围内——上述 11 条都是对现有函数的最小修正。
|