首页 / 资讯中心 / 文章详情

Golang四方支付系统源码实战:从能跑到敢收单的架构与避坑指南

Golang四方支付系统源码实战:从能跑到敢收单的架构与避坑指南 ★ FEATURED ARTICLE
简介这是一套用 Golang 编写的四方支付系统完整源码面向具备一定后端基础、希望深入理解支付业务链路的开发者与二次开发团队。四方支付平台连接商户、消费者、银行及第三方支付机构源码覆盖订单创建、支付接口调用、状态同步、退款处理等核心环节适合用于学习支付系统架构或搭建自有支付服务。压缩包共 154 个文件约 2.09MB以 vue 前端页面、js 脚本、png 与 svg 图标资源、scss 样式为主另含 go 主程序、conf 配置、Dockerfile 与 nginx 配置等部署文件前后端结构相对完整。目前已有 1006 人学习下载。通过研读这套源码读者可掌握 Go 语言在高并发支付场景下的并发模型与内存管理实践理解支付网关、订单模块与渠道对接的设计模式并借助配置文件与容器化脚本快速完成本地部署与调试对提升后端支付领域开发能力有实际参考价值。1. 一套 Golang 四方支付系统源码从「能跑」到「敢收单」之间差了什么很多人拿到一套 Golang 四方支付系统源码第一反应是go build跑起来看到后台能登录、订单能创建就觉得这事成了。真到接商户、跑真实资金流的时候才发现问题全在后面回调没验签被伪造、订单状态和渠道状态对不上、对账文件解析错位、并发下单把同一个商户号余额扣穿。四方支付系统本质是一个「资金信息中转 状态机 对账」的系统Golang 在这里的价值是高并发下的稳定性和部署简单但语言本身不解决业务正确性。这套源码适合两类人一类是想自建聚合收款能力的技术负责人需要判断这套东西能不能改造成生产可用另一类是接手了别人交付的支付系统、要把它从 demo 推到线上的工程师。下面按「架构怎么读 → 核心链路怎么写 → 对账怎么做 → 坑在哪 → 怎么验证」的顺序拆代码和参数都给到能直接抄的程度。热搜里常提的 golang 八股文、源码剖析与架构实战放到支付系统里其实就一句话并发安全和状态一致性这两点没做对其他都是花架子。2. 先读懂这套 Golang 四方支付系统的分层与资金流2.1 四方支付系统的角色和资金流到底怎么走先把角色理清楚不然读源码会迷路。典型四方支付系统里有四个角色商户发起收款请求、平台这套源码本身、上游渠道真正处理资金的第三方或银行通道、用户付款人。资金流是用户付款 → 上游渠道收款 → 渠道回调平台 → 平台回调商户 → 平台与商户、平台与渠道分别结算。注意平台本身通常不碰资金池它做的是信息流和状态流的编排这一点决定了源码里最核心的表是订单表和流水表而不是账户余额表。一套健康的源码目录结构一般长这样cmd/放启动入口internal/放业务逻辑pkg/放通用工具configs/放配置migrations/放建表 SQL。读的时候先看internal/order订单、internal/channel渠道适配、internal/notify回调、internal/reconcile对账这四个包支付系统的命脉全在这。如果源码里渠道逻辑和订单逻辑糊在一个文件里说明抽象没做好改造量会很大。2.2 订单状态机源码里最该先看懂的一张表订单状态机是四方支付系统的骨架。常见状态有created已创建、paying支付中、success支付成功、failed支付失败、closed已关闭、refunded已退款。状态流转必须是单向的、可审计的任何「从 success 改回 paying」的操作都是 bug。看源码时重点找状态变更函数正常写法是把状态变更收敛到一个方法里带乐观锁版本号。-- 订单表核心字段注意 version 和 channel_order_no CREATE TABLE pay_order ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, merchant_no VARCHAR(32) NOT NULL COMMENT 商户号, merchant_order_no VARCHAR(64) NOT NULL COMMENT 商户订单号, platform_order_no VARCHAR(64) NOT NULL COMMENT 平台订单号, channel_code VARCHAR(32) NOT NULL COMMENT 渠道编码, channel_order_no VARCHAR(64) DEFAULT NULL COMMENT 上游订单号, amount BIGINT NOT NULL COMMENT 金额单位分, status TINYINT NOT NULL DEFAULT 0 COMMENT 0创建 1支付中 2成功 3失败 4关闭, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本, notify_status TINYINT NOT NULL DEFAULT 0 COMMENT 商户回调状态, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_platform_order_no (platform_order_no), UNIQUE KEY uk_merchant_order (merchant_no, merchant_order_no), KEY idx_channel_order_no (channel_order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;金额用BIGINT存分不要用DECIMAL或浮点浮点在并发累加时会出现精度玄学。uk_merchant_order这个唯一索引是防重复下单的第一道闸很多源码漏了它结果商户重试就生成两笔订单。channel_order_no单独建索引因为对账和回调查询全靠它。2.3 渠道适配层为什么必须做接口抽象上游渠道少则三五个多则几十个每个渠道的下单接口、签名方式、回调格式都不一样。如果源码里用if channel alipay { ... } else if channel wxpay { ... }这种写法加一个渠道就要改一遍主流程迟早翻车。正确做法是定义统一接口每个渠道实现自己的适配器。// 渠道适配统一接口放在 internal/channel 包 type Channel interface { // 下单返回上游订单号和支付参数 CreateOrder(ctx context.Context, req *CreateOrderReq) (*CreateOrderResp, error) // 查询订单用于主动补偿 QueryOrder(ctx context.Context, platformOrderNo string) (*QueryOrderResp, error) // 验签回调返回解析后的统一结构 VerifyNotify(ctx context.Context, raw []byte, headers map[string]string) (*NotifyData, error) // 发起退款 Refund(ctx context.Context, req *RefundReq) (*RefundResp, error) } type CreateOrderReq struct { PlatformOrderNo string Amount int64 // 分 Subject string NotifyURL string Extra map[string]string }接口里Amount统一用int64分NotifyURL由平台生成而不是商户传避免商户传错地址导致回调丢失。Extra放渠道特有参数比如某些渠道要传商品类目。实现时每个渠道一个文件注册到一张 map 里主流程只依赖接口。这样加渠道就是加文件加注册不动主流程这是判断一套源码能不能长期维护的关键。3. 核心链路落地下单、回调、幂等三件事怎么写3.1 下单接口从参数校验到落库的完整代码下单是入口也是最容易出并发问题的地方。完整流程是验签商户请求 → 参数校验 → 幂等检查 → 生成平台订单号 → 落库 → 调渠道下单 → 更新渠道订单号。每一步都要能失败回滚或补偿。func (s *OrderService) CreateOrder(ctx context.Context, req *CreateOrderReq) (*CreateOrderResp, error) { // 1. 商户签名校验防止伪造请求 if err : s.verifyMerchantSign(req); err ! nil { return nil, ErrSignInvalid } // 2. 金额必须为正且不超过商户单笔限额 if req.Amount 0 || req.Amount s.limitOf(req.MerchantNo) { return nil, ErrAmountInvalid } // 3. 幂等先查是否已存在同商户订单号 if exist, err : s.repo.GetByMerchantOrderNo(ctx, req.MerchantNo, req.MerchantOrderNo); err nil exist ! nil { return buildResp(exist), nil // 直接返回原订单不重复下单 } // 4. 生成平台订单号并落库状态 created order : Order{ MerchantNo: req.MerchantNo, MerchantOrderNo: req.MerchantOrderNo, PlatformOrderNo: genPlatformOrderNo(), ChannelCode: req.ChannelCode, Amount: req.Amount, Status: StatusCreated, } if err : s.repo.Insert(ctx, order); err ! nil { // 唯一索引冲突说明并发重复回查返回 if isDuplicate(err) { old, _ : s.repo.GetByMerchantOrderNo(ctx, req.MerchantNo, req.MerchantOrderNo) return buildResp(old), nil } return nil, err } // 5. 调渠道下单失败则标记订单失败 ch : s.channelMgr.Get(req.ChannelCode) resp, err : ch.CreateOrder(ctx, toChannelReq(order)) if err ! nil { _ s.repo.UpdateStatus(ctx, order.PlatformOrderNo, StatusFailed, 0) return nil, err } // 6. 回写渠道订单号状态置为 paying _ s.repo.UpdateChannelOrderNo(ctx, order.PlatformOrderNo, resp.ChannelOrderNo, StatusPaying) return buildResp(order), nil }关键点在第三步和第四步的双重幂等先查一次减少无谓插入靠唯一索引兜底并发。genPlatformOrderNo建议用「时间戳 商户号后四位 随机数」不要用纯自增避免被遍历。第五步渠道失败要落失败状态不能只返回错误不落库否则订单永远停在 created对账时对不上。UpdateStatus带 version 参数做乐观锁防止并发覆盖。3.2 回调处理验签、幂等、状态机三重防护回调是资金确认的唯一可信来源也是最容易被攻击的入口。处理顺序必须是先验签 → 再幂等 → 再改状态 → 最后异步通知商户。顺序错了就是血泪教训。func (s *NotifyService) HandleChannelNotify(ctx context.Context, channelCode string, raw []byte, headers map[string]string) error { ch : s.channelMgr.Get(channelCode) // 1. 验签失败直接拒绝不落任何数据 data, err : ch.VerifyNotify(ctx, raw, headers) if err ! nil { return ErrNotifySignInvalid } // 2. 幂等同一笔渠道订单只处理一次 if ok, _ : s.redis.SetNX(ctx, notify:lock:data.ChannelOrderNo, 1, 10*time.Minute); !ok { return nil // 已处理过直接返回成功避免渠道重推 } // 3. 查订单校验金额一致防止篡改 order, err : s.repo.GetByChannelOrderNo(ctx, data.ChannelOrderNo) if err ! nil { return err } if order.Amount ! data.Amount { return ErrAmountMismatch } // 4. 状态机流转只有 paying 才能转 success if err : s.repo.UpdateStatusCAS(ctx, order.PlatformOrderNo, StatusPaying, StatusSuccess, order.Version); err ! nil { return err // 状态不对说明重复或异常交给对账兜底 } // 5. 异步通知商户失败进重试队列 s.notifyQueue.Push(order.PlatformOrderNo) return nil }SetNX做幂等锁key 用渠道订单号过期时间给 10 分钟足够覆盖渠道重推窗口。金额校验不能省曾经见过渠道回调金额被中间人改大导致多记账的案例。UpdateStatusCAS是带原状态和版本号的 CAS 更新只有当前是 paying 才允许转 success这样即使锁失效重复进来第二次也会因为状态已是 success 而失败天然幂等。通知商户走异步队列不要同步等商户响应商户接口慢会拖垮回调线程。3.3 通知商户重试策略和签名怎么设计通知商户是平台对外的最后一步做不好商户会说「你没通知我」。策略是立即通知一次失败后按 1min、5min、15min、30min、1h、2h、6h 退避重试最多 8 次仍失败则标记为需人工介入。每次通知都带签名签名串包含订单号、金额、状态、时间戳商户侧用同样规则验签。func (s *NotifyService) notifyMerchant(ctx context.Context, order *Order) error { body : map[string]interface{}{ merchant_order_no: order.MerchantOrderNo, platform_order_no: order.PlatformOrderNo, amount: order.Amount, status: success, timestamp: time.Now().Unix(), } // 按 key 字典序拼接后加商户密钥做 HMAC-SHA256 sign : signMD5OrHMAC(body, s.merchantSecret(order.MerchantNo)) body[sign] sign resp, err : s.httpClient.PostJSON(ctx, order.NotifyURL, body, 5*time.Second) if err ! nil || resp.Code ! SUCCESS { return s.retryQueue.PushWithBackoff(order.PlatformOrderNo) } return s.repo.MarkNotified(ctx, order.PlatformOrderNo) }超时给 5 秒商户接口正常都在几百毫秒内。签名用 HMAC-SHA256 比裸 MD5 安全密钥每个商户独立。重试队列建议用 Redis 的 ZSet 按时间戳排序worker 轮询到期任务比内存队列可靠重启不丢。4. 对账与补偿让账目自己说话4.1 对账文件的下载、解析与比对对账是四方支付系统的后悔药。每天定时拉取上游渠道的对账文件解析后和本地订单逐笔比对找出「本地成功渠道没有」「渠道成功本地没有」「金额不一致」三类差异。文件格式各渠道不同常见是 CSV 或定长文本。func (s *ReconcileService) Reconcile(ctx context.Context, channelCode, billDate string) error { // 1. 下载对账文件 localPath, err : s.channelMgr.Get(channelCode).DownloadBill(ctx, billDate) if err ! nil { return err } // 2. 解析成统一结构 records, err : parseBillFile(localPath) if err ! nil { return err } // 3. 拉取本地当日成功订单 localOrders, _ : s.repo.ListSuccessByDate(ctx, channelCode, billDate) localMap : indexByChannelOrderNo(localOrders) // 4. 逐笔比对 var diffs []Diff for _, r : range records { lo, ok : localMap[r.ChannelOrderNo] if !ok { diffs append(diffs, Diff{Type: channel_only, Record: r}) continue } if lo.Amount ! r.Amount { diffs append(diffs, Diff{Type: amount_mismatch, Local: lo, Channel: r}) } delete(localMap, r.ChannelOrderNo) } // 5. 剩下的本地订单就是 local_only for _, lo : range localMap { diffs append(diffs, Diff{Type: local_only, Local: lo}) } return s.repo.SaveDiffs(ctx, channelCode, billDate, diffs) }parseBillFile要处理编码问题很多渠道给的是 GBK直接按 UTF-8 读会乱码用golang.org/x/text/encoding/simplifiedchinese转。比对用 map 索引O(n) 复杂度十万笔订单秒级完成。差异结果落库人工处理界面按类型分组展示。4.2 差异处理三类差异分别怎么补channel_only说明渠道收了钱但本地没记录通常是回调丢失处理方式是补单根据渠道订单号反查商户订单号补一条成功记录并触发商户通知。local_only说明本地记了成功但渠道没有可能是渠道退款或本地状态错误需要人工核实后决定是否冲正。amount_mismatch最危险必须人工介入不能自动改金额。func (s *ReconcileService) FixChannelOnly(ctx context.Context, d Diff) error { // 补单前先确认本地确实没有 if _, err : s.repo.GetByChannelOrderNo(ctx, d.Record.ChannelOrderNo); err nil { return nil } // 从渠道记录里取商户订单号补一条成功订单 order : Order{ MerchantNo: d.Record.MerchantNo, MerchantOrderNo: d.Record.MerchantOrderNo, PlatformOrderNo: genPlatformOrderNo(), ChannelOrderNo: d.Record.ChannelOrderNo, Amount: d.Record.Amount, Status: StatusSuccess, } if err : s.repo.Insert(ctx, order); err ! nil { return err } s.notifyQueue.Push(order.PlatformOrderNo) return nil }补单必须记录操作日志谁在什么时候补的、依据是什么这是审计要求。补单接口要做权限控制不能开放给普通运营。4.3 主动查询补偿回调没来怎么办不能只等回调要有主动查询兜底。对paying状态超过 5 分钟的订单定时任务调渠道查询接口查到成功就按回调逻辑处理。这个机制能覆盖大部分回调丢失场景。func (s *CompensateJob) Run(ctx context.Context) { // 查 5 分钟前到 24 小时前的 paying 订单 orders, _ : s.repo.ListPayingBetween(ctx, time.Now().Add(-24*time.Hour), time.Now().Add(-5*time.Minute)) for _, o : range orders { ch : s.channelMgr.Get(o.ChannelCode) resp, err : ch.QueryOrder(ctx, o.PlatformOrderNo) if err ! nil { continue // 查询失败下次再试 } if resp.Status SUCCESS { _ s.notifyService.HandleChannelNotify(ctx, o.ChannelCode, resp.Raw, nil) } } }查询频率别太高渠道有频率限制一般 1 分钟一轮每轮限制并发数。24 小时还没成功的订单基本可以判定异常转人工。5. 这套源码最容易翻车的 5 个坑5.1 坑一回调没验签被伪造通知刷单现象后台突然多出一批成功订单但渠道对账文件里没有。原因回调接口直接信任请求体没做签名校验攻击者构造 POST 请求就能把订单改成成功。解决所有渠道回调必须先验签再处理验签失败直接返回错误码不落任何数据。验签逻辑放在渠道适配器里主流程强制调用不给绕过的机会。5.2 坑二订单状态用字符串且到处改现象同一笔订单在不同接口里状态值不一致有的写success有的写SUCCESS对账时匹配不上。原因状态没有统一定义各模块自己写字符串。解决状态用常量或枚举统一定义数据库存数字对外转换。所有状态变更收敛到一个方法禁止在业务代码里直接UPDATE status。5.3 坑三金额用浮点或元做单位现象对账时出现 0.01 元的差异累加多了差几块钱。原因金额用float64或DECIMAL存元浮点运算有精度损失。解决全链路用int64存分接口出入参也是分只在展示层转元。数据库字段用BIGINTGo 结构体用int64一处都不能漏。5.4 坑四回调处理同步等商户响应现象渠道回调超时渠道重推订单被处理多次。原因回调处理里同步调商户通知接口商户接口慢导致整个回调超时。解决回调只做验签、幂等、改状态商户通知丢异步队列。回调接口响应时间控制在 100ms 内渠道才不会重推。5.5 坑五没有对账全靠回调现象运行几个月后发现账对不上但不知道从哪天开始错的。原因只依赖回调回调丢了没人知道。解决每天定时对账差异落库人工处理。对账是最后一道防线没有对账的支付系统等于裸奔。6. 上线前怎么验证这套系统真的能用验证分四层从单元到全链路。第一层单元测试重点测状态机流转和幂等逻辑用表驱动覆盖所有状态组合。第二层集成测试用渠道的沙箱环境跑完整下单到回调验证签名和金额。第三层压测用wrk或hey对下单接口打 1000 并发看订单表有没有重复、状态有没有错乱。# 用 hey 压测下单接口1000 并发共 10000 请求 hey -n 10000 -c 1000 -m POST \ -H Content-Type: application/json \ -d {merchant_no:M001,merchant_order_no:T$(date %s%N),amount:100,channel_code:test} \ http://127.0.0.1:8080/api/order/create压测后立刻查库SELECT merchant_order_no, COUNT(*) FROM pay_order GROUP BY merchant_order_no HAVING COUNT(*) 1;如果有结果说明幂等没做好。再查状态分布看有没有卡在 paying 的订单。第四层对账演练手动造几笔差异删掉一笔本地成功订单模拟channel_only改一笔金额模拟amount_mismatch跑对账任务看能不能正确识别。这一步很多人跳过真出问题时才发现对账逻辑有 bug。我自己的习惯是上线前必做一次「断网演练」把渠道回调地址改成不可达看主动查询补偿能不能在 10 分钟内把订单补成功。这个演练能暴露 80% 的补偿逻辑问题。支付系统没有后悔药能提前演练的坑就别留到线上。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站