1141 lines
44 KiB
Markdown
1141 lines
44 KiB
Markdown
# pay-bridge 技术详细设计文档(Tech Design)
|
||
|
||
| 字段 | 内容 |
|
||
|-----|------|
|
||
| 文档版本 | v1.0 |
|
||
| 创建日期 | 2026-02-27 |
|
||
| 状态 | 草稿 |
|
||
|
||
---
|
||
|
||
## 1. 数据库表结构(DDL)
|
||
|
||
> 所有表使用 MySQL 8.0,字符集 utf8mb4,存储引擎 InnoDB。金额字段统一使用 BIGINT 以分为单位存储,避免浮点精度问题。
|
||
|
||
### 1.1 接入应用(app)
|
||
|
||
```sql
|
||
CREATE TABLE `app` (
|
||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||
`app_id` VARCHAR(32) NOT NULL COMMENT '应用 ID,下游系统鉴权凭证',
|
||
`app_secret` VARCHAR(128) NOT NULL COMMENT '应用密钥(AES 加密存储)',
|
||
`app_name` VARCHAR(64) NOT NULL COMMENT '应用名称',
|
||
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1=启用 0=禁用',
|
||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||
PRIMARY KEY (`id`),
|
||
UNIQUE KEY `uk_app_id` (`app_id`)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='接入应用';
|
||
```
|
||
|
||
### 1.2 交易订单(trade_order)
|
||
|
||
```sql
|
||
CREATE TABLE `trade_order` (
|
||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||
`trade_no` VARCHAR(32) NOT NULL COMMENT 'pay-bridge 生成的交易号',
|
||
`merchant_order_no` VARCHAR(64) NOT NULL COMMENT '下游系统的商户订单号',
|
||
`app_id` VARCHAR(32) NOT NULL COMMENT '所属应用 ID',
|
||
`channel_code` VARCHAR(32) NOT NULL COMMENT '支付渠道编码(如 HEPAY)',
|
||
`channel_trade_no` VARCHAR(64) DEFAULT NULL COMMENT '上游渠道交易号',
|
||
`pay_method` VARCHAR(32) NOT NULL COMMENT '支付方式(WECHAT_JSAPI/WECHAT_H5/WECHAT_NATIVE/WECHAT_MINI/ALIPAY/QUICK_PAY/TRANSFER)',
|
||
`amount` BIGINT NOT NULL COMMENT '订单金额(分)',
|
||
`profit_sharing_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '分润金额(分),0=不分润',
|
||
`service_fee_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '服务费金额(分),支付完成后计算填入',
|
||
`subject` VARCHAR(256) NOT NULL COMMENT '商品描述',
|
||
`notify_url` VARCHAR(512) NOT NULL COMMENT '下游通知地址',
|
||
`status` VARCHAR(20) NOT NULL DEFAULT 'CREATING' COMMENT '状态(见状态机)',
|
||
`extra` JSON DEFAULT NULL COMMENT '支付方式相关扩展参数(如 openid)',
|
||
`channel_extra` JSON DEFAULT NULL COMMENT '渠道返回的支付凭证原始数据',
|
||
`expire_time` DATETIME(3) NOT NULL COMMENT '订单过期时间',
|
||
`pay_time` DATETIME(3) DEFAULT NULL COMMENT '支付成功时间',
|
||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||
PRIMARY KEY (`id`),
|
||
UNIQUE KEY `uk_trade_no` (`trade_no`),
|
||
UNIQUE KEY `uk_app_merchant_order` (`app_id`, `merchant_order_no`),
|
||
KEY `idx_channel_trade_no` (`channel_trade_no`),
|
||
KEY `idx_app_status_created` (`app_id`, `status`, `created_at`),
|
||
KEY `idx_created_at` (`created_at`)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='交易订单';
|
||
```
|
||
|
||
### 1.3 退款记录(refund_order)
|
||
|
||
```sql
|
||
CREATE TABLE `refund_order` (
|
||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||
`refund_no` VARCHAR(32) NOT NULL COMMENT 'pay-bridge 退款单号',
|
||
`trade_no` VARCHAR(32) NOT NULL COMMENT '关联交易号',
|
||
`app_id` VARCHAR(32) NOT NULL,
|
||
`channel_code` VARCHAR(32) NOT NULL,
|
||
`channel_refund_no` VARCHAR(64) DEFAULT NULL COMMENT '上游渠道退款单号',
|
||
`refund_amount` BIGINT NOT NULL COMMENT '退款金额(分)',
|
||
`reason` VARCHAR(256) DEFAULT NULL COMMENT '退款原因',
|
||
`status` VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/PROCESSING/SUCCESS/FAILED',
|
||
`notify_url` VARCHAR(512) DEFAULT NULL,
|
||
`refund_time` DATETIME(3) DEFAULT NULL COMMENT '退款完成时间',
|
||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||
PRIMARY KEY (`id`),
|
||
UNIQUE KEY `uk_refund_no` (`refund_no`),
|
||
KEY `idx_trade_no` (`trade_no`),
|
||
KEY `idx_app_id_status` (`app_id`, `status`)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='退款记录';
|
||
```
|
||
|
||
### 1.4 渠道配置(channel_config)
|
||
|
||
```sql
|
||
CREATE TABLE `channel_config` (
|
||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||
`app_id` VARCHAR(32) NOT NULL COMMENT '关联应用 ID',
|
||
`channel_code` VARCHAR(32) NOT NULL COMMENT '渠道编码',
|
||
`merchant_id` VARCHAR(64) NOT NULL COMMENT '渠道商户 ID',
|
||
`api_key` TEXT DEFAULT NULL COMMENT 'API 密钥(AES 加密)',
|
||
`private_key` TEXT DEFAULT NULL COMMENT 'RSA 私钥(AES 加密)',
|
||
`public_key` TEXT DEFAULT NULL COMMENT '渠道公钥',
|
||
`notify_url` VARCHAR(512) NOT NULL COMMENT '上游回调接收地址',
|
||
`sandbox` TINYINT NOT NULL DEFAULT 0 COMMENT '1=沙箱 0=生产',
|
||
`extra_config` JSON DEFAULT NULL COMMENT '渠道特有扩展配置',
|
||
`status` TINYINT NOT NULL DEFAULT 1,
|
||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||
PRIMARY KEY (`id`),
|
||
UNIQUE KEY `uk_app_channel` (`app_id`, `channel_code`)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='渠道配置';
|
||
```
|
||
|
||
### 1.5 通知记录(notify_log)
|
||
|
||
```sql
|
||
CREATE TABLE `notify_log` (
|
||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||
`trade_no` VARCHAR(32) NOT NULL COMMENT '关联交易号',
|
||
`notify_type` VARCHAR(20) NOT NULL COMMENT 'PAYMENT / REFUND',
|
||
`notify_url` VARCHAR(512) NOT NULL,
|
||
`status` VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/SUCCESS/FAILED/GIVEUP',
|
||
`retry_count` INT NOT NULL DEFAULT 0 COMMENT '已重试次数',
|
||
`next_retry_time` DATETIME(3) DEFAULT NULL COMMENT '下次重试时间',
|
||
`last_response` TEXT DEFAULT NULL COMMENT '最近一次响应内容',
|
||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||
PRIMARY KEY (`id`),
|
||
UNIQUE KEY `uk_trade_notify_type` (`trade_no`, `notify_type`),
|
||
KEY `idx_status_next_retry` (`status`, `next_retry_time`)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='下游通知记录';
|
||
```
|
||
|
||
### 1.6 分润配置(profit_sharing_config)
|
||
|
||
```sql
|
||
CREATE TABLE `profit_sharing_config` (
|
||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||
`app_id` VARCHAR(32) NOT NULL,
|
||
`receiver_merchant_id` VARCHAR(64) NOT NULL COMMENT '分润接收方商户 ID(上游平台)',
|
||
`receiver_type` VARCHAR(20) NOT NULL COMMENT 'PLATFORM / SUB_MERCHANT',
|
||
`max_sharing_ratio` DECIMAL(5,4) NOT NULL COMMENT '最大分润比例(0.0001-1.0000)',
|
||
`status` TINYINT NOT NULL DEFAULT 1,
|
||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||
PRIMARY KEY (`id`),
|
||
UNIQUE KEY `uk_app_id` (`app_id`)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分润配置(应用级)';
|
||
```
|
||
|
||
### 1.7 分润记录(profit_sharing_order)
|
||
|
||
```sql
|
||
CREATE TABLE `profit_sharing_order` (
|
||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||
`sharing_no` VARCHAR(32) NOT NULL COMMENT '分润单号',
|
||
`trade_no` VARCHAR(32) NOT NULL COMMENT '关联交易号',
|
||
`app_id` VARCHAR(32) NOT NULL,
|
||
`receiver_merchant_id` VARCHAR(64) NOT NULL,
|
||
`sharing_amount` BIGINT NOT NULL COMMENT '分润金额(分)',
|
||
`status` VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/PROCESSING/SUCCESS/FAILED/ROLLBACK',
|
||
`channel_sharing_no` VARCHAR(64) DEFAULT NULL COMMENT '上游分账单号',
|
||
`fail_reason` VARCHAR(256) DEFAULT NULL,
|
||
`sharing_time` DATETIME(3) DEFAULT NULL,
|
||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||
PRIMARY KEY (`id`),
|
||
UNIQUE KEY `uk_sharing_no` (`sharing_no`),
|
||
UNIQUE KEY `uk_trade_no` (`trade_no`),
|
||
KEY `idx_status` (`status`)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分润记录';
|
||
```
|
||
|
||
### 1.8 服务费配置(service_fee_config)
|
||
|
||
```sql
|
||
CREATE TABLE `service_fee_config` (
|
||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||
`app_id` VARCHAR(32) NOT NULL,
|
||
`pay_method_group` VARCHAR(20) NOT NULL COMMENT 'SCAN(扫码)/TRANSFER(对公转账)/BALANCE(余额)',
|
||
`fee_rate` DECIMAL(6,4) NOT NULL COMMENT '费率(0=免费,最大 9.9999%)',
|
||
`fee_receiver_merchant_id` VARCHAR(64) NOT NULL COMMENT '服务费收款方商户 ID',
|
||
`status` TINYINT NOT NULL DEFAULT 1,
|
||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||
PRIMARY KEY (`id`),
|
||
UNIQUE KEY `uk_app_method` (`app_id`, `pay_method_group`)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务费配置';
|
||
```
|
||
|
||
### 1.9 服务费流水(service_fee_log)
|
||
|
||
```sql
|
||
CREATE TABLE `service_fee_log` (
|
||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||
`trade_no` VARCHAR(32) NOT NULL,
|
||
`config_id` BIGINT UNSIGNED NOT NULL,
|
||
`fee_amount` BIGINT NOT NULL COMMENT '服务费金额(分)',
|
||
`fee_rate` DECIMAL(6,4) NOT NULL COMMENT '实际使用的费率快照',
|
||
`receiver_merchant_id` VARCHAR(64) NOT NULL,
|
||
`action` VARCHAR(20) NOT NULL COMMENT 'CHARGE / ROLLBACK',
|
||
`status` VARCHAR(20) NOT NULL DEFAULT 'PENDING',
|
||
`channel_sharing_no` VARCHAR(64) DEFAULT NULL,
|
||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||
PRIMARY KEY (`id`),
|
||
UNIQUE KEY `uk_trade_action` (`trade_no`, `action`),
|
||
KEY `idx_status` (`status`)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务费流水';
|
||
```
|
||
|
||
### 1.10 子商户收款账户(sub_merchant_account)
|
||
|
||
```sql
|
||
CREATE TABLE `sub_merchant_account` (
|
||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||
`app_id` VARCHAR(32) NOT NULL,
|
||
`sub_merchant_id` VARCHAR(64) NOT NULL COMMENT '子商户 ID(上游平台)',
|
||
`channel_code` VARCHAR(32) NOT NULL,
|
||
`account_type` VARCHAR(20) NOT NULL COMMENT 'BANK_CARD / 其他',
|
||
`account_no` VARCHAR(64) NOT NULL COMMENT '银行账号(脱敏存储,完整值加密)',
|
||
`account_no_enc` TEXT DEFAULT NULL COMMENT '银行账号(AES 加密完整值)',
|
||
`account_name` VARCHAR(128) NOT NULL COMMENT '账户名称',
|
||
`bank_name` VARCHAR(64) DEFAULT NULL COMMENT '开户行',
|
||
`status` TINYINT NOT NULL DEFAULT 1,
|
||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||
PRIMARY KEY (`id`),
|
||
KEY `idx_app_merchant` (`app_id`, `sub_merchant_id`),
|
||
KEY `idx_account_no` (`account_no`)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='子商户固定收款账户';
|
||
```
|
||
|
||
### 1.11 收款匹配记录(payment_match_log)
|
||
|
||
```sql
|
||
CREATE TABLE `payment_match_log` (
|
||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||
`account_id` BIGINT UNSIGNED NOT NULL COMMENT '关联子商户收款账户 ID',
|
||
`trade_no` VARCHAR(32) DEFAULT NULL COMMENT '匹配成功的交易号',
|
||
`incoming_amount` BIGINT NOT NULL COMMENT '入账金额(分)',
|
||
`incoming_remark` VARCHAR(256) DEFAULT NULL COMMENT '转账备注',
|
||
`payer_name` VARCHAR(128) DEFAULT NULL COMMENT '付款方账户名称',
|
||
`channel_bill_no` VARCHAR(64) DEFAULT NULL COMMENT '上游入账流水号',
|
||
`match_status` VARCHAR(20) NOT NULL DEFAULT 'PENDING_MANUAL' COMMENT 'MATCHED/PENDING_MANUAL/NAME_DIFF',
|
||
`name_diff` TINYINT NOT NULL DEFAULT 0 COMMENT '1=名称不一致(已标记差异)',
|
||
`match_time` DATETIME(3) DEFAULT NULL,
|
||
`operator` VARCHAR(64) DEFAULT NULL COMMENT '人工操作者(非自动匹配时填入)',
|
||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||
PRIMARY KEY (`id`),
|
||
KEY `idx_account_status` (`account_id`, `match_status`),
|
||
KEY `idx_channel_bill_no` (`channel_bill_no`)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='收款匹配记录';
|
||
```
|
||
|
||
### 1.12 订单编码序列(order_sequence)
|
||
|
||
```sql
|
||
CREATE TABLE `order_sequence` (
|
||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||
`app_id` VARCHAR(32) NOT NULL,
|
||
`seq_type` VARCHAR(20) NOT NULL COMMENT 'TRADE / REFUND / SHARING',
|
||
`prefix` VARCHAR(8) NOT NULL COMMENT '序号前缀(如 PAY/REF/SHA)',
|
||
`current_value` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前序号',
|
||
`step` INT NOT NULL DEFAULT 1 COMMENT '步长(批量预分配时用)',
|
||
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||
PRIMARY KEY (`id`),
|
||
UNIQUE KEY `uk_app_type` (`app_id`, `seq_type`)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单编码序列';
|
||
```
|
||
|
||
### 1.13 对账记录(reconciliation)
|
||
|
||
```sql
|
||
CREATE TABLE `reconciliation` (
|
||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||
`recon_date` DATE NOT NULL COMMENT '对账日期',
|
||
`channel_code` VARCHAR(32) NOT NULL,
|
||
`app_id` VARCHAR(32) NOT NULL,
|
||
`status` VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/RUNNING/SUCCESS/FAILED',
|
||
`match_count` INT NOT NULL DEFAULT 0,
|
||
`long_count` INT NOT NULL DEFAULT 0 COMMENT '长款数(上游有本地无)',
|
||
`short_count` INT NOT NULL DEFAULT 0 COMMENT '短款数(本地有上游无)',
|
||
`diff_count` INT NOT NULL DEFAULT 0 COMMENT '金额不一致数',
|
||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||
PRIMARY KEY (`id`),
|
||
UNIQUE KEY `uk_date_channel_app` (`recon_date`, `channel_code`, `app_id`)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='对账记录';
|
||
```
|
||
|
||
### 1.14 商户(merchant)
|
||
|
||
```sql
|
||
CREATE TABLE `merchant` (
|
||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||
`merchant_id` VARCHAR(32) NOT NULL COMMENT '内部商户 ID',
|
||
`merchant_name` VARCHAR(128) NOT NULL,
|
||
`license_no` VARCHAR(64) DEFAULT NULL COMMENT '营业执照号',
|
||
`legal_person` VARCHAR(64) DEFAULT NULL COMMENT '法人姓名',
|
||
`bank_account` VARCHAR(64) DEFAULT NULL COMMENT '银行账号(脱敏)',
|
||
`channel_merchant_id` VARCHAR(64) DEFAULT NULL COMMENT '上游平台商户 ID',
|
||
`status` VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/ACTIVE/FROZEN/REJECTED',
|
||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||
PRIMARY KEY (`id`),
|
||
UNIQUE KEY `uk_merchant_id` (`merchant_id`),
|
||
KEY `idx_status` (`status`)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商户';
|
||
```
|
||
|
||
### 1.15 微信绑定(wechat_binding)
|
||
|
||
```sql
|
||
CREATE TABLE `wechat_binding` (
|
||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||
`merchant_id` VARCHAR(32) NOT NULL,
|
||
`openid` VARCHAR(64) NOT NULL,
|
||
`union_id` VARCHAR(64) DEFAULT NULL,
|
||
`nickname` VARCHAR(64) DEFAULT NULL,
|
||
`notify_types` JSON DEFAULT NULL COMMENT '订阅的消息类型数组',
|
||
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1=有效 0=已解绑',
|
||
`bind_time` DATETIME(3) NOT NULL,
|
||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||
PRIMARY KEY (`id`),
|
||
UNIQUE KEY `uk_merchant_openid` (`merchant_id`, `openid`),
|
||
KEY `idx_openid` (`openid`)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微信绑定';
|
||
```
|
||
|
||
---
|
||
|
||
## 2. 核心接口定义(Go Interface)
|
||
|
||
### 2.1 渠道适配器接口
|
||
|
||
```go
|
||
// package channel
|
||
|
||
// PaymentChannel 支付渠道统一接口,所有渠道适配器必须实现此接口
|
||
type PaymentChannel interface {
|
||
// Code 返回渠道编码,如 "HEPAY"
|
||
Code() string
|
||
|
||
// CreateOrder 统一下单,返回支付凭证
|
||
CreateOrder(ctx context.Context, req *CreateOrderReq) (*CreateOrderResp, error)
|
||
|
||
// QueryOrder 查询订单状态
|
||
QueryOrder(ctx context.Context, req *QueryOrderReq) (*QueryOrderResp, error)
|
||
|
||
// CloseOrder 关闭订单
|
||
CloseOrder(ctx context.Context, req *CloseOrderReq) (*CloseOrderResp, error)
|
||
|
||
// Refund 发起退款
|
||
Refund(ctx context.Context, req *RefundReq) (*RefundResp, error)
|
||
|
||
// QueryRefund 查询退款状态
|
||
QueryRefund(ctx context.Context, req *QueryRefundReq) (*QueryRefundResp, error)
|
||
|
||
// VerifyNotify 验证上游回调签名,返回解析后的通知数据
|
||
VerifyNotify(ctx context.Context, rawBody []byte, headers map[string]string) (*NotifyData, error)
|
||
|
||
// ProfitSharing 发起分账(渠道不支持时返回 ErrNotSupported)
|
||
ProfitSharing(ctx context.Context, req *ProfitSharingReq) (*ProfitSharingResp, error)
|
||
|
||
// RollbackProfitSharing 回退分账(用于退款场景)
|
||
RollbackProfitSharing(ctx context.Context, req *RollbackSharingReq) (*RollbackSharingResp, error)
|
||
|
||
// DownloadBill 下载对账账单
|
||
DownloadBill(ctx context.Context, req *DownloadBillReq) (*BillData, error)
|
||
|
||
// MerchantApply 商户进件
|
||
MerchantApply(ctx context.Context, req *MerchantApplyReq) (*MerchantApplyResp, error)
|
||
|
||
// QueryMerchantStatus 查询商户审核状态
|
||
QueryMerchantStatus(ctx context.Context, channelMerchantID string) (*MerchantStatusResp, error)
|
||
}
|
||
|
||
// ChannelFactory 渠道工厂函数类型
|
||
type ChannelFactory func(config *ChannelConfig) PaymentChannel
|
||
|
||
// CreateOrderReq 下单请求(统一格式)
|
||
type CreateOrderReq struct {
|
||
AppID string // 内部应用 ID
|
||
TradeNo string // pay-bridge 交易号
|
||
MerchantOrderNo string // 下游商户订单号
|
||
PayMethod PayMethod // 支付方式
|
||
Amount int64 // 金额(分)
|
||
Subject string // 商品描述
|
||
NotifyURL string // 回调地址
|
||
ExpireTime time.Time // 过期时间
|
||
Extra map[string]any // 支付方式特有参数(如 openid)
|
||
}
|
||
|
||
// CreateOrderResp 下单响应
|
||
type CreateOrderResp struct {
|
||
ChannelTradeNo string // 渠道交易号
|
||
PayCredential map[string]any // 支付凭证(各支付方式格式不同)
|
||
RawResponse []byte // 渠道原始响应(用于日志)
|
||
}
|
||
|
||
// NotifyData 上游回调解析结果
|
||
type NotifyData struct {
|
||
TradeNo string // pay-bridge 交易号
|
||
ChannelTradeNo string // 渠道交易号
|
||
Status TradeStatus
|
||
Amount int64
|
||
PayTime time.Time
|
||
NotifyType NotifyType // PAYMENT / REFUND
|
||
RefundNo string // 退款单号(退款通知时)
|
||
RawData []byte // 原始报文(用于验签)
|
||
}
|
||
```
|
||
|
||
### 2.2 交易服务接口
|
||
|
||
```go
|
||
// package service
|
||
|
||
type TradeService interface {
|
||
// CreateOrder 统一下单
|
||
CreateOrder(ctx context.Context, req *CreateOrderServiceReq) (*CreateOrderServiceResp, error)
|
||
|
||
// QueryOrder 查询交易
|
||
QueryOrder(ctx context.Context, appID, tradeNo string) (*TradeOrderDTO, error)
|
||
|
||
// CloseOrder 关闭订单
|
||
CloseOrder(ctx context.Context, appID, tradeNo string) error
|
||
|
||
// HandleUpstreamNotify 处理上游回调
|
||
HandleUpstreamNotify(ctx context.Context, channelCode string, rawBody []byte, headers map[string]string) (string, error)
|
||
}
|
||
|
||
type RefundService interface {
|
||
// CreateRefund 发起退款
|
||
CreateRefund(ctx context.Context, req *CreateRefundReq) (*RefundOrderDTO, error)
|
||
|
||
// QueryRefund 查询退款
|
||
QueryRefund(ctx context.Context, appID, refundNo string) (*RefundOrderDTO, error)
|
||
}
|
||
|
||
type ProfitSharingService interface {
|
||
// TriggerSharing 支付成功后触发分润(异步)
|
||
TriggerSharing(ctx context.Context, tradeNo string) error
|
||
|
||
// RollbackSharing 退款前回退分润
|
||
RollbackSharing(ctx context.Context, tradeNo string) error
|
||
|
||
// QuerySharing 查询分润状态
|
||
QuerySharing(ctx context.Context, sharingNo string) (*ProfitSharingDTO, error)
|
||
}
|
||
|
||
type NotifyService interface {
|
||
// SendNotify 向下游发送通知(含首次)
|
||
SendNotify(ctx context.Context, tradeNo string, notifyType NotifyType) error
|
||
|
||
// ProcessRetryQueue 处理重试队列(由 Poller 调用)
|
||
ProcessRetryQueue(ctx context.Context) error
|
||
}
|
||
|
||
type PaymentMatchService interface {
|
||
// HandleIncomingPayment 处理固定账户入账通知
|
||
HandleIncomingPayment(ctx context.Context, req *IncomingPaymentReq) error
|
||
|
||
// ManualBindOrder 人工关联入账与订单
|
||
ManualBindOrder(ctx context.Context, matchID int64, tradeNo string, operator string) error
|
||
}
|
||
|
||
type SequenceService interface {
|
||
// NextTradeNo 生成下一个交易号
|
||
NextTradeNo(ctx context.Context, appID string) (string, error)
|
||
|
||
// NextRefundNo 生成下一个退款单号
|
||
NextRefundNo(ctx context.Context, appID string) (string, error)
|
||
|
||
// NextSharingNo 生成下一个分润单号
|
||
NextSharingNo(ctx context.Context, appID string) (string, error)
|
||
}
|
||
```
|
||
|
||
### 2.3 仓储接口
|
||
|
||
```go
|
||
// package repository
|
||
|
||
type TradeOrderRepository interface {
|
||
Create(ctx context.Context, order *TradeOrder) error
|
||
GetByTradeNo(ctx context.Context, tradeNo string) (*TradeOrder, error)
|
||
GetByMerchantOrderNo(ctx context.Context, appID, merchantOrderNo string) (*TradeOrder, error)
|
||
UpdateStatus(ctx context.Context, tradeNo string, fromStatus, toStatus TradeStatus, updates map[string]any) (bool, error)
|
||
ListPaying(ctx context.Context, before time.Time, limit int) ([]*TradeOrder, error)
|
||
}
|
||
|
||
type NotifyLogRepository interface {
|
||
Upsert(ctx context.Context, log *NotifyLog) error
|
||
GetByTradeNo(ctx context.Context, tradeNo string, notifyType NotifyType) (*NotifyLog, error)
|
||
ListPendingRetry(ctx context.Context, before time.Time, limit int) ([]*NotifyLog, error)
|
||
IncrRetryCount(ctx context.Context, id int64, nextRetryTime time.Time, lastResponse string) error
|
||
MarkSuccess(ctx context.Context, id int64) error
|
||
MarkGiveup(ctx context.Context, id int64) error
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 3. 状态机定义
|
||
|
||
### 3.1 交易订单状态机
|
||
|
||
```
|
||
┌──────────┐
|
||
│ CREATING │ (下单请求到达,尚未调用渠道)
|
||
└────┬─────┘
|
||
渠道下单成功│ │渠道下单失败
|
||
┌────▼─────┐ ┌────────────┐
|
||
│ PAYING │ │CREATE_FAILED│
|
||
└────┬─────┘ └────────────┘
|
||
│
|
||
┌────────────────┼──────────────────┐
|
||
│ │ │
|
||
用户支付成功 超时/主动关闭 支付失败
|
||
(上游回调/查询)
|
||
│ │ │
|
||
┌────▼────┐ ┌────▼────┐ ┌─────▼────┐
|
||
│ PAID │ │ CLOSED │ │ FAILED │
|
||
└────┬────┘ └─────────┘ └──────────┘
|
||
│
|
||
┌────┼──────────────────────┐
|
||
│ │ │
|
||
全额退款 分润触发 部分退款
|
||
│ │ │
|
||
┌───▼──┐ ▼ ┌────▼──────┐
|
||
│REFUND│SHARING_PENDING → │PART_REFUND│
|
||
│ │SHARING_SUCCESS └───────────┘
|
||
└──────┘
|
||
```
|
||
|
||
**状态枚举**:
|
||
|
||
| 状态 | 说明 | 可流转至 |
|
||
|-----|-----|---------|
|
||
| CREATING | 创建中(调用渠道前) | PAYING / CREATE_FAILED |
|
||
| PAYING | 支付中(等待用户付款) | PAID / CLOSED / FAILED |
|
||
| PAID | 已支付 | REFUNDED / CLOSED(退款完成后) |
|
||
| CLOSED | 已关闭 | - |
|
||
| FAILED | 支付失败 | - |
|
||
| CREATE_FAILED | 下单失败 | PAYING(重试下单时) |
|
||
| REFUNDED | 已全额退款 | - |
|
||
|
||
### 3.2 退款状态机
|
||
|
||
```
|
||
PENDING → PROCESSING → SUCCESS
|
||
└→ FAILED(可重试)
|
||
```
|
||
|
||
### 3.3 分润状态机
|
||
|
||
```
|
||
PENDING → PROCESSING → SUCCESS
|
||
└→ FAILED(重试补偿)
|
||
SUCCESS → ROLLBACK(退款触发回退)
|
||
```
|
||
|
||
### 3.4 通知状态机
|
||
|
||
```
|
||
PENDING → SUCCESS
|
||
└→ RETRY(重试中,retry_count < 8)
|
||
└→ GIVEUP(超过 8 次,人工介入)
|
||
```
|
||
|
||
---
|
||
|
||
## 4. 关键流程时序图
|
||
|
||
### 4.1 统一下单流程
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant Client as 下游系统
|
||
participant API as API 层
|
||
participant Trade as 交易服务
|
||
participant Seq as 订单编码服务
|
||
participant Redis as Redis
|
||
participant DB as MySQL
|
||
participant Channel as 渠道适配器
|
||
participant HePay as 汇元支付
|
||
|
||
Client->>API: POST /api/v1/pay/unified-order
|
||
API->>API: 鉴权(appId+appSecret 签名验证)
|
||
API->>API: 参数校验
|
||
API->>Trade: CreateOrder(req)
|
||
Trade->>Redis: SET NX idempotent:{appId}:{merchantOrderNo}
|
||
alt 幂等 key 已存在
|
||
Redis-->>Trade: false(已存在)
|
||
Trade->>DB: GetByMerchantOrderNo
|
||
DB-->>Trade: 已有订单
|
||
Trade-->>API: 返回已有凭证
|
||
else 首次请求
|
||
Redis-->>Trade: true(创建成功)
|
||
Trade->>Seq: NextTradeNo(appId)
|
||
Seq->>DB: UPDATE order_sequence ... RETURNING current_value(行锁)
|
||
DB-->>Seq: 序号
|
||
Seq-->>Trade: trade_no
|
||
Trade->>DB: INSERT trade_order(status=CREATING)
|
||
Trade->>Channel: CreateOrder(channelReq)
|
||
Channel->>HePay: 调用汇元下单 API(3DES 加密 + RSA 签名)
|
||
HePay-->>Channel: 支付凭证(解密+验签)
|
||
Channel-->>Trade: CreateOrderResp
|
||
Trade->>DB: UPDATE trade_order SET status=PAYING, channel_extra=...
|
||
Trade-->>API: 统一格式支付凭证
|
||
end
|
||
API-->>Client: HTTP 200 支付凭证
|
||
```
|
||
|
||
### 4.2 异步通知处理流程
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant HePay as 汇元支付
|
||
participant NotifyAPI as 回调接口
|
||
participant Trade as 交易服务
|
||
participant Notify as 通知服务
|
||
participant DB as MySQL
|
||
participant Redis as Redis
|
||
participant Downstream as 下游系统
|
||
|
||
HePay->>NotifyAPI: POST /api/v1/notify/payment/HEPAY
|
||
NotifyAPI->>NotifyAPI: 渠道验签(RSA)
|
||
NotifyAPI->>Trade: HandleUpstreamNotify(data)
|
||
Trade->>DB: UpdateStatus(PAYING→PAID)(乐观锁)
|
||
Note over Trade,DB: WHERE status='PAYING' AND trade_no=?
|
||
Trade->>DB: INSERT/UPDATE notify_log
|
||
Trade->>Notify: SendNotify(tradeNo, PAYMENT)
|
||
Notify->>Downstream: POST {notify_url} 通知
|
||
alt 下游返回成功
|
||
Downstream-->>Notify: HTTP 200 / "success"
|
||
Notify->>DB: UPDATE notify_log SET status=SUCCESS
|
||
else 下游返回失败或超时
|
||
Downstream-->>Notify: 失败
|
||
Notify->>DB: UPDATE notify_log SET retry_count++, next_retry_time=now+15s
|
||
Notify->>Redis: ZADD notify_retry {score=next_retry_ts} {trade_no}
|
||
end
|
||
NotifyAPI-->>HePay: "success"(必须快速响应,异步处理通知)
|
||
|
||
loop Poller goroutine(每 5s 扫描)
|
||
Redis->>Redis: ZRANGEBYSCORE notify_retry 0 now
|
||
Redis-->>Notify: 到期任务列表
|
||
Notify->>Downstream: POST {notify_url} 重试
|
||
alt 成功
|
||
Notify->>DB: status=SUCCESS
|
||
Notify->>Redis: ZREM
|
||
else 失败且 retry_count < 8
|
||
Notify->>DB: UPDATE next_retry_time(按退避策略)
|
||
Notify->>Redis: ZADD(更新 score)
|
||
else retry_count >= 8
|
||
Notify->>DB: status=GIVEUP
|
||
Notify->>Redis: ZREM
|
||
end
|
||
end
|
||
```
|
||
|
||
### 4.3 固定账户收款匹配流程
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant HePay as 汇元支付
|
||
participant MatchAPI as 入账回调接口
|
||
participant Match as 收款匹配服务
|
||
participant DB as MySQL
|
||
participant Notify as 通知服务
|
||
|
||
HePay->>MatchAPI: POST /api/v1/notify/account-payment/HEPAY
|
||
MatchAPI->>MatchAPI: 验签
|
||
MatchAPI->>Match: HandleIncomingPayment(incoming)
|
||
Match->>DB: 查询 sub_merchant_account(by account_no)
|
||
DB-->>Match: 账户信息(app_id, sub_merchant_id)
|
||
|
||
Note over Match: 开始三维度匹配
|
||
Match->>Match: Step1: 解析 incoming_remark 提取订单号(正则)
|
||
alt 备注含有效订单号
|
||
Match->>DB: 查询 trade_order by merchant_order_no / trade_no(status=PAYING)
|
||
DB-->>Match: 候选订单
|
||
Match->>Match: Step2: 校验金额是否一致
|
||
alt 金额一致
|
||
Match->>Match: Step3: 比对 payer_name 与订单 invoice_name
|
||
alt 名称一致
|
||
Match->>DB: 更新订单状态 PAYING→PAID(name_diff=0)
|
||
Match->>DB: INSERT payment_match_log(status=MATCHED)
|
||
Match->>Notify: SendNotify(tradeNo, PAYMENT)
|
||
else 名称不一致
|
||
Match->>DB: 更新订单状态 PAYING→PAID(标记 name_diff)
|
||
Match->>DB: INSERT payment_match_log(status=NAME_DIFF)
|
||
Match->>Notify: SendNotify(含名称差异标记)
|
||
end
|
||
else 金额不一致
|
||
Match->>DB: INSERT payment_match_log(status=PENDING_MANUAL)
|
||
end
|
||
else 备注无效/为空
|
||
Match->>DB: 按金额在 app 下的 PAYING 订单中查找
|
||
alt 唯一匹配
|
||
Match->>DB: 更新为 MATCHED(同名称逻辑)
|
||
else 多个匹配/无匹配
|
||
Match->>DB: INSERT payment_match_log(status=PENDING_MANUAL)
|
||
end
|
||
end
|
||
MatchAPI-->>HePay: "success"
|
||
```
|
||
|
||
---
|
||
|
||
## 5. 错误码设计
|
||
|
||
采用分段式错误码,格式:`{类别}{4位数字}`,HTTP 状态码与业务错误码分离。
|
||
|
||
```go
|
||
// package errcode
|
||
|
||
const (
|
||
// 成功
|
||
OK = "0"
|
||
|
||
// 1xxxx 参数/请求错误(HTTP 400)
|
||
ErrInvalidParam = "10001" // 参数校验失败
|
||
ErrMissingParam = "10002" // 缺少必填参数
|
||
ErrInvalidPayMethod = "10003" // 不支持的支付方式
|
||
ErrInvalidAmount = "10004" // 金额非法
|
||
|
||
// 2xxxx 鉴权错误(HTTP 401/403)
|
||
ErrUnauthorized = "20001" // 签名验证失败
|
||
ErrAppNotFound = "20002" // 应用不存在或已禁用
|
||
ErrPermissionDenied = "20003" // 无权操作该资源
|
||
|
||
// 3xxxx 业务规则错误(HTTP 422)
|
||
ErrOrderNotFound = "30001" // 订单不存在
|
||
ErrOrderAlreadyPaid = "30002" // 订单已支付
|
||
ErrOrderClosed = "30003" // 订单已关闭
|
||
ErrRefundAmountExceed = "30004" // 退款金额超限
|
||
ErrSharingAmountExceed = "30005" // 分润金额超过最大比例
|
||
ErrSharingNotConfig = "30006" // 未配置分润接收方
|
||
ErrSharingFeeExceed = "30007" // 分润+服务费超过订单金额
|
||
ErrOrderIdempotent = "30008" // 幂等:返回已有订单
|
||
|
||
// 4xxxx 渠道错误(HTTP 502)
|
||
ErrChannelCreateFail = "40001" // 渠道下单失败
|
||
ErrChannelRefundFail = "40002" // 渠道退款失败
|
||
ErrChannelTimeout = "40003" // 渠道调用超时
|
||
ErrChannelNotSupport = "40004" // 渠道不支持该功能
|
||
ErrChannelVerifyFail = "40005" // 回调验签失败
|
||
|
||
// 5xxxx 系统内部错误(HTTP 500)
|
||
ErrInternalDB = "50001" // 数据库错误
|
||
ErrInternalRedis = "50002" // Redis 错误
|
||
ErrInternalSystem = "50099" // 系统内部错误
|
||
)
|
||
|
||
// APIError 统一错误响应结构
|
||
type APIError struct {
|
||
Code string `json:"code"`
|
||
Message string `json:"message"`
|
||
TraceID string `json:"trace_id,omitempty"`
|
||
}
|
||
```
|
||
|
||
**统一响应格式**:
|
||
|
||
```json
|
||
{
|
||
"code": "0",
|
||
"message": "success",
|
||
"data": { ... },
|
||
"trace_id": "abc123"
|
||
}
|
||
```
|
||
|
||
错误时:
|
||
|
||
```json
|
||
{
|
||
"code": "30004",
|
||
"message": "退款金额超过可退金额",
|
||
"trace_id": "abc123"
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 6. 关键算法
|
||
|
||
### 6.1 订单号生成算法
|
||
|
||
格式:`{prefix}{yyMMdd}{8位序号(补零)}`,例如 `PAY26022700000001`。
|
||
|
||
```go
|
||
func (s *sequenceService) NextTradeNo(ctx context.Context, appID string) (string, error) {
|
||
// 使用 MySQL 行锁保证原子性
|
||
// UPDATE order_sequence SET current_value = current_value + 1
|
||
// WHERE app_id = ? AND seq_type = 'TRADE'
|
||
// 读取更新后的 current_value
|
||
val, err := s.repo.IncrAndGet(ctx, appID, SeqTypeTrade)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
prefix := "PAY"
|
||
date := time.Now().Format("060102") // yyMMdd
|
||
seq := fmt.Sprintf("%08d", val%100000000) // 8位,溢出归零(支持每日1亿笔)
|
||
return prefix + date + seq, nil
|
||
}
|
||
```
|
||
|
||
对于高并发场景(QPS > 1000),可改为批量预取(step > 1),每次取 100 个序号在内存消费,减少 DB 压力。
|
||
|
||
### 6.2 通知重试间隔计算
|
||
|
||
```go
|
||
var retryIntervals = []time.Duration{
|
||
0, // 第 1 次(立即)
|
||
15 * time.Second,
|
||
30 * time.Second,
|
||
1 * time.Minute,
|
||
5 * time.Minute,
|
||
30 * time.Minute,
|
||
1 * time.Hour,
|
||
6 * time.Hour,
|
||
12 * time.Hour, // 第 9 次(最后一次)
|
||
}
|
||
|
||
// NextRetryTime 计算下次重试时间
|
||
// retryCount 为当前已重试次数(0 = 尚未重试)
|
||
func NextRetryTime(retryCount int) (time.Time, bool) {
|
||
if retryCount >= len(retryIntervals)-1 {
|
||
return time.Time{}, false // 已超过最大重试次数,放弃
|
||
}
|
||
interval := retryIntervals[retryCount+1]
|
||
return time.Now().Add(interval), true
|
||
}
|
||
```
|
||
|
||
### 6.3 固定账户收款匹配算法
|
||
|
||
```go
|
||
// MatchIncomingPayment 核心匹配逻辑
|
||
func (s *paymentMatchService) match(ctx context.Context, incoming *IncomingPayment, accountID int64, appID string) (*MatchResult, error) {
|
||
// Step 1: 从备注中提取订单号(正则优先匹配 trade_no/merchant_order_no)
|
||
orderNoPatterns := []string{
|
||
`PAY\d{14}`, // pay-bridge 交易号格式
|
||
`[A-Z0-9]{16,32}`, // 通用订单号格式
|
||
}
|
||
candidates := extractOrderNos(incoming.Remark, orderNoPatterns)
|
||
|
||
var matched *TradeOrder
|
||
for _, orderNo := range candidates {
|
||
order, err := s.tradeRepo.GetByTradeOrMerchantNo(ctx, appID, orderNo)
|
||
if err != nil || order == nil || order.Status != StatusPaying {
|
||
continue
|
||
}
|
||
// Step 2: 金额精确匹配
|
||
if order.Amount != incoming.Amount {
|
||
continue
|
||
}
|
||
matched = order
|
||
break
|
||
}
|
||
|
||
// 备注无法匹配时,降级为金额匹配
|
||
if matched == nil {
|
||
orders, _ := s.tradeRepo.ListPayingByAmount(ctx, appID, incoming.Amount, 7*24*time.Hour)
|
||
if len(orders) == 1 {
|
||
matched = orders[0]
|
||
} else if len(orders) > 1 {
|
||
// Step 3(辅助): 用付款方名称缩小范围
|
||
matched = filterByPayerName(orders, incoming.PayerName)
|
||
if matched == nil {
|
||
return &MatchResult{Status: StatusPendingManual}, nil
|
||
}
|
||
} else {
|
||
return &MatchResult{Status: StatusPendingManual}, nil
|
||
}
|
||
}
|
||
|
||
// Step 3: 付款方名称一致性检查
|
||
nameDiff := false
|
||
if matched.InvoiceName != "" && incoming.PayerName != "" {
|
||
nameDiff = matched.InvoiceName != incoming.PayerName
|
||
}
|
||
|
||
return &MatchResult{
|
||
TradeNo: matched.TradeNo,
|
||
Status: StatusMatched,
|
||
NameDiff: nameDiff,
|
||
}, nil
|
||
}
|
||
```
|
||
|
||
### 6.4 服务费计算
|
||
|
||
```go
|
||
// CalculateServiceFee 服务费计算(四舍五入到分)
|
||
func CalculateServiceFee(amount int64, rate decimal.Decimal) int64 {
|
||
amountDec := decimal.NewFromInt(amount)
|
||
fee := amountDec.Mul(rate).Round(0) // 四舍五入
|
||
return fee.IntPart()
|
||
}
|
||
|
||
// ValidateSharingAndFee 校验分润+服务费不超过订单金额
|
||
func ValidateSharingAndFee(orderAmount, sharingAmount, serviceFee int64) error {
|
||
if sharingAmount+serviceFee > orderAmount {
|
||
return errors.New(errcode.ErrSharingFeeExceed, "分润与服务费之和超过订单金额")
|
||
}
|
||
return nil
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 7. 配置结构
|
||
|
||
### 7.1 应用配置(config.yaml)
|
||
|
||
```yaml
|
||
server:
|
||
port: 8080
|
||
read_timeout: 30s
|
||
write_timeout: 30s
|
||
|
||
database:
|
||
dsn: "${DB_DSN}" # 从环境变量注入,格式: user:pass@tcp(host:port)/db?charset=utf8mb4&parseTime=true
|
||
max_open_conns: 50
|
||
max_idle_conns: 10
|
||
conn_max_lifetime: 1h
|
||
|
||
redis:
|
||
addr: "${REDIS_ADDR}" # host:port
|
||
password: "${REDIS_PASS}"
|
||
db: 0
|
||
pool_size: 20
|
||
|
||
security:
|
||
field_encrypt_key: "${FIELD_ENCRYPT_KEY}" # AES-256 密钥,用于加密数据库敏感字段
|
||
app_secret_salt: "${APP_SECRET_SALT}" # appSecret 哈希盐
|
||
|
||
notify:
|
||
poller_interval: 5s # 重试队列扫描间隔
|
||
poller_batch: 100 # 每次扫描处理的任务数
|
||
http_timeout: 10s # 向下游发送通知的超时
|
||
|
||
reconciliation:
|
||
cron: "0 2 * * *" # 每日 02:00 触发对账
|
||
bill_retry_times: 3 # 账单下载失败重试次数
|
||
|
||
merchant:
|
||
status_check_cron: "0 9 * * *" # 每日 09:00 检测商户状态
|
||
|
||
wechat:
|
||
app_id: "${WECHAT_APP_ID}"
|
||
app_secret: "${WECHAT_APP_SECRET}"
|
||
template_ids:
|
||
payment_success: "template_id_1"
|
||
refund_success: "template_id_2"
|
||
anomaly_alert: "template_id_3"
|
||
|
||
log:
|
||
level: "info" # debug/info/warn/error
|
||
format: "json" # json/text
|
||
```
|
||
|
||
### 7.2 Go 配置结构体
|
||
|
||
```go
|
||
// package config
|
||
|
||
type Config struct {
|
||
Server ServerConfig
|
||
Database DatabaseConfig
|
||
Redis RedisConfig
|
||
Security SecurityConfig
|
||
Notify NotifyConfig
|
||
Reconciliation ReconciliationConfig
|
||
Wechat WechatConfig
|
||
Log LogConfig
|
||
}
|
||
|
||
type ServerConfig struct {
|
||
Port int `yaml:"port"`
|
||
ReadTimeout time.Duration `yaml:"read_timeout"`
|
||
WriteTimeout time.Duration `yaml:"write_timeout"`
|
||
}
|
||
|
||
type DatabaseConfig struct {
|
||
DSN string `yaml:"dsn"`
|
||
MaxOpenConns int `yaml:"max_open_conns"`
|
||
MaxIdleConns int `yaml:"max_idle_conns"`
|
||
ConnMaxLifetime time.Duration `yaml:"conn_max_lifetime"`
|
||
}
|
||
|
||
type SecurityConfig struct {
|
||
FieldEncryptKey string `yaml:"field_encrypt_key"` // 从环境变量读取
|
||
AppSecretSalt string `yaml:"app_secret_salt"`
|
||
}
|
||
|
||
type NotifyConfig struct {
|
||
PollerInterval time.Duration `yaml:"poller_interval"`
|
||
PollerBatch int `yaml:"poller_batch"`
|
||
HTTPTimeout time.Duration `yaml:"http_timeout"`
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 8. 目录结构
|
||
|
||
```
|
||
pay-bridge/
|
||
├── cmd/
|
||
│ └── server/
|
||
│ └── main.go # 启动入口
|
||
├── internal/
|
||
│ ├── api/ # API 层(Handler + 中间件)
|
||
│ │ ├── handler/
|
||
│ │ │ ├── pay.go # 支付相关 Handler
|
||
│ │ │ ├── notify.go # 回调接收 Handler
|
||
│ │ │ └── admin.go # 管理接口 Handler
|
||
│ │ └── middleware/
|
||
│ │ ├── auth.go # 鉴权中间件
|
||
│ │ └── ratelimit.go # 限流中间件
|
||
│ ├── service/ # 业务逻辑层
|
||
│ │ ├── trade.go
|
||
│ │ ├── refund.go
|
||
│ │ ├── notify.go
|
||
│ │ ├── profit_sharing.go
|
||
│ │ ├── service_fee.go
|
||
│ │ ├── payment_match.go
|
||
│ │ ├── reconciliation.go
|
||
│ │ ├── merchant.go
|
||
│ │ └── wechat_notify.go
|
||
│ ├── channel/ # 渠道适配层
|
||
│ │ ├── interface.go # PaymentChannel interface
|
||
│ │ ├── factory.go # 渠道工厂 + 注册表
|
||
│ │ └── hepay/ # 汇元支付适配器
|
||
│ │ ├── adapter.go
|
||
│ │ ├── sign.go # RSA/MD5 签名
|
||
│ │ └── crypto.go # RSA+3DES 加解密
|
||
│ ├── repository/ # 数据访问层
|
||
│ │ ├── trade_order.go
|
||
│ │ ├── refund_order.go
|
||
│ │ ├── notify_log.go
|
||
│ │ └── ...
|
||
│ ├── model/ # 数据模型(DB entity)
|
||
│ ├── dto/ # 数据传输对象(Service 层进出)
|
||
│ └── errcode/ # 错误码定义
|
||
├── pkg/
|
||
│ ├── config/ # 配置加载
|
||
│ ├── crypto/ # AES 加解密工具
|
||
│ ├── sequence/ # 订单号生成
|
||
│ └── logger/ # 日志封装
|
||
├── docs/
|
||
│ ├── PRD.md
|
||
│ ├── approach-design.md
|
||
│ └── tech-design.md
|
||
├── go.mod
|
||
└── main.go
|
||
```
|
||
|
||
---
|
||
|
||
## 9. 汇元支付(HePay)适配器关键实现说明
|
||
|
||
### 9.1 签名方案
|
||
|
||
汇元支付支持 RSA 和 MD5 两种签名方式,根据渠道配置中 `sign_type` 字段决定。
|
||
|
||
```go
|
||
// RSA 签名(SHA256WithRSA)
|
||
func signRSA(params map[string]string, privateKey *rsa.PrivateKey) (string, error) {
|
||
// 1. 将参数按字典序排列,拼接为 key=value&... 格式(排除 sign 字段)
|
||
// 2. SHA256 哈希
|
||
// 3. RSA PKCS1v15 签名
|
||
// 4. Base64 编码
|
||
sortedStr := sortAndJoin(params)
|
||
hash := sha256.Sum256([]byte(sortedStr))
|
||
sig, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, hash[:])
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return base64.StdEncoding.EncodeToString(sig), nil
|
||
}
|
||
```
|
||
|
||
### 9.2 请求体加密
|
||
|
||
```go
|
||
// EncryptRequest 使用 RSA+3DES 加密请求体
|
||
// 1. 随机生成 3DES 密钥(24 字节)
|
||
// 2. 用 3DES 加密请求 JSON
|
||
// 3. 用汇元公钥 RSA 加密 3DES 密钥
|
||
// 4. 将加密后的数据和密钥一起发送
|
||
func EncryptRequest(plaintext []byte, hePayPublicKey *rsa.PublicKey) (*EncryptedPayload, error) {
|
||
desKey := make([]byte, 24)
|
||
_, _ = rand.Read(desKey)
|
||
|
||
ciphertext, err := tripleDesEncrypt(plaintext, desKey)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
encryptedKey, err := rsa.EncryptPKCS1v15(rand.Reader, hePayPublicKey, desKey)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return &EncryptedPayload{
|
||
Data: base64.StdEncoding.EncodeToString(ciphertext),
|
||
EncryptedKey: base64.StdEncoding.EncodeToString(encryptedKey),
|
||
}, nil
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 10. 监控与可观测性
|
||
|
||
### 10.1 关键指标(Prometheus)
|
||
|
||
| 指标名 | 类型 | 标签 | 说明 |
|
||
|-------|------|-----|-----|
|
||
| `paybridge_order_total` | Counter | app_id, pay_method, status | 订单总数 |
|
||
| `paybridge_order_amount_total` | Counter | app_id, pay_method | 订单总金额 |
|
||
| `paybridge_channel_request_duration_seconds` | Histogram | channel_code, method | 渠道调用耗时 |
|
||
| `paybridge_channel_request_errors_total` | Counter | channel_code, method, error_code | 渠道调用错误数 |
|
||
| `paybridge_notify_retry_total` | Counter | app_id, notify_type | 通知重试总数 |
|
||
| `paybridge_notify_giveup_total` | Counter | app_id | 放弃通知总数(需告警) |
|
||
| `paybridge_match_pending_manual_total` | Gauge | app_id | 待人工确认的收款匹配数 |
|
||
|
||
### 10.2 告警规则
|
||
|
||
| 告警名 | 条件 | 严重级别 |
|
||
|-------|-----|---------|
|
||
| 渠道调用错误率过高 | channel_error_rate > 5%(5 分钟窗口) | P1 |
|
||
| 通知放弃量激增 | giveup_total 5 分钟内增长 > 10 | P2 |
|
||
| 订单幂等冲突激增 | 30002 错误码 5 分钟 > 50 次 | P2 |
|
||
| 对账差异发现 | recon_diff_count > 0 | P1 |
|
||
| 收款未匹配积压 | pending_manual_total > 20 | P2 |
|