# 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 |