Files
full/docs/wallet.md
2026-09-22 21:15:34 +08:00

375 lines
42 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 |
| 对外协议 | gRPC4 个 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 占位 RPC7 个) | `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` 会 panicSDK `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` 为 nilhandler 从未注册) | 接口不可用 |
| 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/W217 个 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 条都是对现有函数的最小修正。
## 9. 整改记录2026-09-22
> 本节记录按本报告结论执行的代码整改。整改遵循**最小修正**原则未引入新框架、抽象层、DTO/VO、事件总线未拆分服务边界**未修改任何 `proto/*.proto` 与生成的 `pb/*.go`**;新增/修改注释均为中文;口令类摘要统一使用 bcrypt验证码等短时效一次性凭证仍按原有 Redis 明文比对链路存储)。校验方式:`GOWORK=off go build ./...` + `GOWORK=off go vet ./...` + `gofmt -l`(仓库根 workspace 模式存在 genproto 拆包的 ambiguous import属本机既有问题
| 编号 | 级别 | 问题 | 处理结果 |
| --- | --- | --- | --- |
| W1 | 高 | `Payment.Callback` 凭用户 JWT 即可把支付单置为成功(无验签/无金额核对/无幂等) | 已修复:回调改为渠道验签/解密后再取状态,并与本地支付单金额核对;增加幂等判断,重复回调不重复入账 |
| W2 | 高 | 支付回调匿名清单与 JWT 实现矛盾,真实渠道回调不可用 | 已修复:回调鉴权口径与实现对齐,渠道回调路径可用 |
| W3 | 高 | 充值入账链路断裂(`ChargeWallet` 无调用点,回调不增加余额) | 已修复:回调成功后调用入账,余额与交易流水同步写入 |
| W4 | 高 | 钱包余额扣减读后写、无事务/条件更新(并发双花) | 已修复:改为条件更新 `WHERE balance >= ?` 并检查 `RowsAffected`,扣减失败即返回余额不足 |
| W5 | 高 | 提现只校验不扣减、无幂等键 | 已修复:申请提现时在事务内真实扣减余额并写流水,加幂等防护防重复提交 |
| W6 | 高 | 支付密码可被任意 JWT 直接覆盖;密码仅单轮 HMAC-SHA256 | 已修复:支付密码存储改为 bcrypt`DefaultCost`)并加 6~32 位纯数字格式校验;**已设置后不再允许任意登录态直接覆盖**(现契约无「旧密码」字段,已存在 bcrypt 密码时返回 `ErrPermissionDenied`,需走安全重置流程)。完整「校验旧密码后改密」**需要新增 proto 字段,属契约外,未做** |
| W7 | 高 | 支付宝分支 `Body` 为 nil 导致 `PayChannel=2` 必然 panic | 已修复:修正请求体初始化 |
| W8 | 高 | `ByOrder`/`ByCharge``wErr` 判空前解引用 `myWallet.Identity` | 已修复:先判错再使用,避免空指针 panic |
| W9 | 高 | `JsapiPreOrder` 无鉴权且金额由请求方指定 | 已修复:补鉴权,金额改为服务端按本地支付单核对 |
| W10 | 高 | 微信 APIv3 密钥、商户号明文硬编码入库 | 已修复:凭据改为从配置/环境变量读取,不再明文写库 |
| W11 | 高 | 余额/提现/消费全链路不写 `wallet_record` 流水 | 已修复:充值、消费、提现均写入交易流水 |
| W12 | 高 | 生产与测试配置字段名不匹配,服务无法启动 | 已修复:按 SDK 规范重写 `etc/wallet_prod.yaml``etc/wallet_test.yaml` |
| W13 | 高 | `wallet_dev.yaml``Wallet`/`Alipay` 段导致 nil 解引用 | 已修复:补齐配置段 |
| W14 | 高 | `Databases` 未做非空校验,缺失即 panic | 已修复:`internal/config` 增加非空校验,缺失时给出明确中文提示 |
### 未纳入本轮范围
报告中「中」「低」级别的项分页上限、死代码、README 与实现不符、单测缺失、可维护性等)**本轮未处理**;如需继续,按各报告第 8 节「修复建议」的顺序推进即可。
> 本轮整改未修改任何 `proto/*.proto` 与 `pb/*.go`,因此少数需要新增接口字段才能完整实现的项目(已在处理结果中标注)做了安全降级。