简介:这套Golang四方支付系统源码面向具备一定Go语言基础的后端开发者与支付领域学习者,提供一套可运行、可二次开发的中立支付平台实现,帮助理解商家、消费者、银行及第三方支付机构之间的交易对接流程。压缩包共154个文件,约2.09MB,以vue前端组件与js脚本为主,辅以scss样式、png与svg图标资源,并包含go入口文件、conf配置、Dockerfile及dockerignore等部署文件,前后端结构相对完整。目录以sifang-payment-system-master为主分支,可据此梳理订单创建、支付接口调用、状态同步与退款处理等核心模块的代码组织方式。目前已有1006人学习下载,适合通过阅读源码掌握Go并发模型在支付场景中的应用,并借鉴配置管理与容器化部署思路,为后端支付方向的能力提升提供参考。
1. 四方支付系统源码拆解:从 Golang 工程结构到能跑起来的最小闭环
拿到一套 Golang 写的四方支付系统源码,第一反应往往不是兴奋,而是懵——目录里躺着几十个包,cmd、internal、pkg、configs各占一块,README 只写了「go run main.go」,真跑起来却连数据库都连不上。四方支付系统本质是一个聚合层:它对接上游多个通道(银行、第三方支付机构),对外提供统一的下单、回调、对账接口,商户只跟它签一次约,就能拿到多个通道的收单能力。这套源码要解决的核心问题就三个:订单怎么在多个通道间路由、回调怎么保证不丢不重、资金流水怎么对得上。
适合读这篇的人有三类:想自建聚合支付平台的技术负责人、需要二次开发支付模块的后端工程师、以及拿这套源码当 Golang 工程范本学习的人。热搜里「golang 八股文」「源码剖析与架构实战」这些词能火,说明大家不满足于只会写 CRUD,而是想看真实项目里 goroutine、channel、context 是怎么落地的。四方支付系统恰好是个好样本——它既有高并发下单,又有异步回调,还有定时对账,Golang 的并发原语在这里不是炫技,是刚需。接下来我按「先看懂结构、再跑通链路、最后避坑」的顺序,把这套源码拆开讲。
2. 工程结构与启动链路:先搞清楚钱从哪个包流过
2.1 典型目录布局与各层职责
一套能上生产的四方支付系统,目录不会平铺。常见做法是按「入口层、业务层、领域层、基础设施层」切分,Golang 社区虽然没有强制的分层规范,但cmd+internal+pkg这套组合几乎是事实标准。下面是我见过的比较合理的一种布局,你可以对照手里的源码看是否吻合:
pay-system/ ├── cmd/ │ └── server/ │ └── main.go # 唯一入口,只做装配和启动 ├── internal/ │ ├── order/ # 订单领域:创建、查询、状态机 │ ├── channel/ # 通道领域:路由、限额、权重 │ ├── callback/ # 回调领域:通知、重试、验签 │ ├── reconcile/ # 对账领域:拉单、比对、差错处理 │ └── merchant/ # 商户领域:密钥、费率、白名单 ├── pkg/ │ ├── sign/ # 签名算法:MD5、RSA、HMAC │ ├── httpclient/ # 带重试和超时的 HTTP 客户端 │ └── logger/ # 结构化日志 ├── configs/ │ └── config.yaml # 数据库、Redis、通道参数 └── go.modcmd/server/main.go里通常只做三件事:加载配置、初始化依赖(数据库连接池、Redis 客户端、各通道的 HTTP 客户端)、启动 HTTP 服务和后台 goroutine。如果你看到main.go里塞了几百行业务逻辑,那这套源码的工程质量要打个问号。internal目录是 Go 编译器强制的私有包,外部模块无法 import,这对支付系统很重要——核心资金逻辑不应该被外部随意引用。
2.2 启动流程与依赖装配
启动链路决定了你排查问题的起点。我一般会先看main.go的初始化顺序,因为支付系统对「配置没加载完就开始收单」这种事零容忍。下面是一段典型的启动代码骨架,你可以对照源码里的实际写法:
// cmd/server/main.go package main import ( "context" "log" "net/http" "os" "os/signal" "syscall" "time" "pay-system/internal/callback" "pay-system/internal/channel" "pay-system/internal/order" "pay-system/pkg/config" "pay-system/pkg/db" ) func main() { // 1. 加载配置,失败直接退出,不进入降级逻辑 cfg, err := config.Load("configs/config.yaml") if err != nil { log.Fatalf("load config failed: %v", err) } // 2. 初始化数据库连接池,注意 SetMaxOpenConns 要按通道数估算 pool, err := db.NewPool(cfg.DB) if err != nil { log.Fatalf("init db pool failed: %v", err) } defer pool.Close() // 3. 装配各领域服务,依赖通过构造函数注入 orderSvc := order.NewService(pool, cfg) channelSvc := channel.NewService(pool, cfg) callbackSvc := callback.NewService(pool, cfg) // 4. 启动后台对账 goroutine,用 context 控制生命周期 ctx, cancel := context.WithCancel(context.Background()) defer cancel() go callbackSvc.StartRetryLoop(ctx) go orderSvc.StartTimeoutScanner(ctx) // 5. 注册路由并启动 HTTP 服务 mux := http.NewServeMux() order.RegisterRoutes(mux, orderSvc) callback.RegisterRoutes(mux, callbackSvc) channel.RegisterRoutes(mux, channelSvc) srv := &http.Server{ Addr: cfg.Server.Addr, Handler: mux, ReadTimeout: 5 * time.Second, WriteTimeout: 10 * time.Second, } // 6. 优雅关闭:收到信号后停止接收新请求,等待在途请求完成 go func() { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) <-sigCh shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second) defer shutdownCancel() _ = srv.Shutdown(shutdownCtx) cancel() }() log.Printf("server listening on %s", cfg.Server.Addr) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("server exited: %v", err) } }这段代码里有几个参数值得你逐个核对。ReadTimeout设 5 秒是因为支付下单请求体很小,超过 5 秒还没读完基本是客户端问题;WriteTimeout设 10 秒是给上游通道调用留余量,但如果你在 handler 里同步调上游,这个值要按上游 P99 延迟来调,否则会出现「上游还没返回,连接已经被写超时切断」的玄学问题。SetMaxOpenConns如果源码里没显式设置,默认是无限,高并发下会把数据库连接打满,这是血泪经验——我见过一套源码在压测时数据库直接拒绝连接,就是因为连接池没设上限。
后台 goroutine 用context控制生命周期,cancel()一调,所有select { case <-ctx.Done(): return }的循环都会退出。如果你手里的源码用for { time.Sleep(...) }且没有退出条件,那优雅关闭时会卡住,这是需要改的第一处。
3. 订单与通道路由:一笔支付怎么选到正确的上游
3.1 订单状态机与幂等设计
四方支付系统的订单状态不能随便改,必须有明确的状态机。常见状态包括:CREATED(已创建)、PAYING(支付中)、SUCCESS(成功)、FAILED(失败)、CLOSED(已关闭)、REFUNDED(已退款)。状态流转必须是单向的,比如SUCCESS不能再回到PAYING,否则对账时会出现「已成功的单又被回调改成失败」的灾难。
幂等是支付系统的命根子。商户重复提交同一笔订单号,系统必须返回第一次的结果,而不是创建两笔单。实现方式通常是在orders表上对merchant_id + merchant_order_no建唯一索引,插入时捕获唯一键冲突,然后查回已有记录返回。下面是一段典型的订单创建逻辑:
// internal/order/service.go func (s *Service) CreateOrder(ctx context.Context, req *CreateOrderReq) (*Order, error) { // 1. 参数校验:金额必须大于 0,商户订单号不能为空 if req.Amount <= 0 || req.MerchantOrderNo == "" { return nil, ErrInvalidParam } // 2. 幂等检查:先查一次,避免大部分重复请求走到插入 existing, err := s.repo.GetByMerchantOrderNo(ctx, req.MerchantID, req.MerchantOrderNo) if err == nil && existing != nil { return existing, nil // 直接返回已有订单,不重复创建 } // 3. 通道路由:根据金额、商户费率、通道权重选一个可用通道 ch, err := s.channelSvc.Route(ctx, req.MerchantID, req.Amount) if err != nil { return nil, ErrNoAvailableChannel } // 4. 落库,状态为 CREATED,同时写入通道信息 order := &Order{ MerchantID: req.MerchantID, MerchantOrderNo: req.MerchantOrderNo, Amount: req.Amount, ChannelID: ch.ID, Status: StatusCreated, CreatedAt: time.Now(), } if err := s.repo.Insert(ctx, order); err != nil { // 唯一键冲突说明并发下已有请求插入成功,查回返回 if isDuplicateKey(err) { return s.repo.GetByMerchantOrderNo(ctx, req.MerchantID, req.MerchantOrderNo) } return nil, err } return order, nil }Route方法是四方支付系统的核心之一。常见路由策略有:按权重轮询、按费率最低优先、按通道限额过滤、按商户白名单指定。我一般会建议先做「限额过滤 + 权重轮询」,因为费率最优策略在通道不稳定时会导致所有单涌向同一个通道,反而容易触发上游风控。源码里如果Route方法直接return channels[0],那基本没做路由,需要你自己补。
3.2 通道适配器与签名差异
每个上游通道的接口协议都不一样:有的用 form 表单,有的用 JSON;有的签名用 MD5 拼串,有的用 RSA2;有的回调是 GET,有的是 POST。四方支付系统要做的就是把差异封装在通道适配器里,对上层暴露统一的Pay、Query、Refund、Callback接口。下面是一个适配器接口定义和两个实现的骨架:
// internal/channel/adapter.go type Adapter interface { // Pay 发起支付,返回上游订单号和支付参数(如二维码链接) Pay(ctx context.Context, order *Order) (*PayResult, error) // Query 主动查询上游订单状态,用于回调丢失时补偿 Query(ctx context.Context, order *Order) (*QueryResult, error) // VerifyCallback 验证上游回调签名,返回解析后的结果 VerifyCallback(r *http.Request) (*CallbackResult, error) } // 通道 A:MD5 签名,form 表单提交 type ChannelA struct { merchantID string secretKey string httpClient *http.Client } func (c *ChannelA) Pay(ctx context.Context, order *Order) (*PayResult, error) { params := map[string]string{ "merchant_id": c.merchantID, "order_no": order.OrderNo, "amount": strconv.FormatInt(order.Amount, 10), "notify_url": order.NotifyURL, } // 签名规则:按 key 字典序拼接,末尾追加 secretKey,MD5 后转大写 params["sign"] = sign.MD5Upper(params, c.secretKey) // 表单提交,注意 Content-Type 必须是 application/x-www-form-urlencoded resp, err := c.httpClient.PostForm(ctx, "https://upstream-a/pay", toValues(params)) // ... 解析响应,返回 PayResult }签名是最容易翻车的地方。MD5 拼串时,空值参数要不要参与签名、参数按字典序还是按文档顺序、签名结果大写还是小写,这三个问题每个通道的答案都可能不同。我踩过的坑是:某通道文档写「空值不参与签名」,但实际测试时发现notify_url为空也必须拼进去,否则验签失败。解决办法是拿通道提供的测试工具或沙箱环境逐字段比对,不要只信文档。
回调验签同样要注意:VerifyCallback里必须先验签再解析业务字段,顺序反了等于没验签。另外,回调可能重复到达,所以验签通过后还要做幂等——用上游订单号查本地订单,如果已经是SUCCESS状态,直接返回成功,不重复处理。
4. 回调与对账:钱到账了但系统不知道怎么办
4.1 回调重试机制与幂等落库
回调丢失是支付系统的常态,不是异常。上游说「已通知」,但你的服务器可能正好在重启、网络抖动、或者处理超时。所以四方支付系统必须有两手准备:一是回调接口本身要快速返回,二是后台要有主动查询补偿。
回调接口的处理原则是「先落库、再返回、异步处理」。收到回调后,验签通过就写一条callback_log,然后立即返回success给上游,避免上游因为等待超时而重试。真正的订单状态更新由后台 worker 从callback_log里读取并处理。下面是一段回调处理的代码:
// internal/callback/handler.go func (h *Handler) HandleCallback(w http.ResponseWriter, r *http.Request) { // 1. 读取原始请求体,注意要限制大小,防止恶意大包 body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 最大 1MB if err != nil { http.Error(w, "read body failed", http.StatusBadRequest) return } // 2. 验签,失败直接返回,不落库 result, err := h.adapter.VerifyCallback(r, body) if err != nil { log.Printf("callback verify failed: %v", err) http.Error(w, "invalid sign", http.StatusBadRequest) return } // 3. 幂等落库:用上游订单号 + 回调类型做唯一键 logEntry := &CallbackLog{ ChannelID: result.ChannelID, UpstreamOrder: result.UpstreamOrderNo, RawBody: string(body), Status: CallbackPending, CreatedAt: time.Now(), } if err := h.repo.InsertCallbackLog(r.Context(), logEntry); err != nil { if isDuplicateKey(err) { // 重复回调,直接返回成功,不重复处理 w.Write([]byte("success")) return } http.Error(w, "internal error", http.StatusInternalServerError) return } // 4. 立即返回成功,处理交给后台 worker w.Write([]byte("success")) }后台 worker 从callback_log里捞pending的记录,按上游订单号找到本地订单,更新状态,然后通知商户。通知商户也要重试,常见策略是「1 分钟、5 分钟、15 分钟、1 小时、3 小时」五次,超过五次标记为FAILED,等人工介入。重试时要用指数退避,避免商户服务器刚重启就被打满。
4.2 对账文件拉取与差错处理
对账是四方支付系统最后一道防线。每天凌晨,系统要拉取上游的对账文件,和自己库里的订单逐笔比对。比对结果分三类:一致、上游有本地无(长款)、本地有上游无(短款)。长款要补单,短款要挂起排查。
对账文件的格式通常是 CSV 或定长文本,字段包括上游订单号、商户订单号、金额、手续费、状态、交易时间。下面是一段对账比对的伪代码:
// internal/reconcile/service.go func (s *Service) Reconcile(ctx context.Context, channelID int, date string) error { // 1. 拉取上游对账文件,解析成 map[upstreamOrderNo]Record upstreamRecords, err := s.fetchUpstreamFile(ctx, channelID, date) if err != nil { return fmt.Errorf("fetch upstream file: %w", err) } // 2. 查询本地当天所有成功订单 localOrders, err := s.repo.ListByChannelAndDate(ctx, channelID, date) if err != nil { return fmt.Errorf("list local orders: %w", err) } // 3. 逐笔比对 for _, order := range localOrders { up, ok := upstreamRecords[order.UpstreamOrderNo] if !ok { // 本地有上游无:短款,挂起 s.markException(ctx, order, "SHORT") continue } if up.Amount != order.Amount || up.Status != "SUCCESS" { // 金额或状态不一致:差错 s.markException(ctx, order, "MISMATCH") } delete(upstreamRecords, order.UpstreamOrderNo) } // 4. 剩余的上游记录:长款,补单 for _, up := range upstreamRecords { s.createSupplementOrder(ctx, up) } return nil }对账最容易出的问题是时区。上游文件里的交易时间可能是 UTC,你本地库存的是东八区,直接按日期过滤会漏单。我一般会在配置里显式声明通道的时区,解析时统一转成 UTC 再比对。另一个坑是金额单位:有的通道用分,有的用元,对账时如果不统一,会出现「金额差 100 倍」的假差错。
5. 避坑与排查:这套源码跑不起来时先看这几处
5.1 数据库连接池打满导致下单超时
现象:压测时下单接口 P99 从 50ms 飙到 5s,日志里出现too many connections。原因:源码里sql.Open之后没有调SetMaxOpenConns和SetMaxIdleConns,默认无限连接,每个请求都新建连接,数据库很快达到上限。解决:在初始化连接池时显式设置,SetMaxOpenConns按CPU 核数 * 2 + 磁盘数估算,支付系统一般设 50 到 100;SetMaxIdleConns设成和最大连接数一样,避免频繁创建销毁;SetConnMaxLifetime设 30 分钟,防止连接被数据库端断开后客户端还在用。
5.2 回调验签失败但日志里看不到原始报文
现象:上游说回调成功,但本地订单一直是PAYING,查日志只有verify failed,没有原始请求体。原因:验签代码在读取r.Body之前先做了r.ParseForm(),导致r.Body被消费,后续再读就是空。解决:回调 handler 里第一件事就是用io.ReadAll(r.Body)把原始报文读出来存到变量,验签和解析都用这个变量,不要依赖r.Form。同时把原始报文写进callback_log,方便事后排查。
5.3 通道路由选到已下线通道
现象:某通道已经维护下线,但新订单还是往它发,全部失败。原因:路由方法只按权重轮询,没有过滤通道状态。解决:Route方法里先过滤status = 'ONLINE'的通道,再按权重选。另外,通道下线时要把权重设为 0 而不是直接删记录,因为历史订单还关联着通道 ID,删了会导致对账时找不到通道信息。
5.4 对账时金额单位不一致导致全量差错
现象:对账报告里所有订单都标记为金额不一致,但人工核对发现金额是对的。原因:上游对账文件金额单位是分,本地库存的是元,比对时直接比数值。解决:在通道配置里加一个amount_unit字段,解析上游文件时统一转成分,或者统一转成元,比对前做一次转换。这个坑很隐蔽,因为差错率 100% 反而让人以为是文件拉错了。
5.5 优雅关闭时后台 goroutine 卡住
现象:发SIGTERM后进程不退出,要等 30 秒被 kill -9。原因:后台重试循环用time.Sleep且没有select ctx.Done(),cancel()后还在睡。解决:把所有后台循环改成select { case <-ctx.Done(): return; case <-time.After(interval): },这样cancel()一调就能立即退出。另外,srv.Shutdown的超时时间要小于容器编排系统给的terminationGracePeriodSeconds,否则还没关完就被强杀。
6. 二次开发前先做这三件事:压测、对账演练、密钥轮换
拿到源码准备二次开发或上线前,我习惯先做三件事,顺序不能反。第一件是压测下单链路,用wrk或vegeta打CreateOrder接口,观察数据库连接池、goroutine 数量、GC 频率。Golang 的pprof这时候要打开,go tool pprof http://localhost:6060/debug/pprof/goroutine能看到 goroutine 有没有泄漏。压测时把上游通道 mock 掉,否则会把真实通道打挂。
第二件是对账演练。手动造一批数据:正常单、长款、短款、金额不一致、状态不一致,跑一遍对账逻辑,看差错分类是否正确。这一步能暴露时区、金额单位、状态映射的问题。演练用的对账文件要覆盖边界:空文件、只有表头、字段缺失、编码不是 UTF-8。我见过一套源码在解析 GBK 编码的对账文件时直接 panic,因为没做编码转换。
第三件是密钥轮换。支付系统的商户密钥和通道密钥不能写死在代码或配置文件里,要用 KMS 或至少是环境变量注入。轮换时要支持双密钥并行:新密钥生效后,旧密钥保留一段时间用于验签历史回调。下面是一个密钥加载的示例:
// pkg/sign/keystore.go type KeyStore struct { current map[string]string // merchantID -> secretKey previous map[string]string // 旧密钥,仅用于验签 } func (ks *KeyStore) Sign(merchantID string, params map[string]string) string { key := ks.current[merchantID] return sign.MD5Upper(params, key) } func (ks *KeyStore) Verify(merchantID string, params map[string]string, sig string) bool { // 先用当前密钥验,失败再用旧密钥验,支持轮换过渡期 if sign.MD5Upper(params, ks.current[merchantID]) == sig { return true } if old, ok := ks.previous[merchantID]; ok { return sign.MD5Upper(params, old) == sig } return false }这三件事做完,你对这套源码的边界就有数了:它能扛多少 TPS、对账准不准、密钥管理是否合规。四方支付系统不是跑起来就能收单的,资金链路上一分钱的差错都会变成客诉。我自己的习惯是每次改完订单或回调相关代码,都跑一遍「下单 → 回调 → 对账」的端到端测试,用测试数据而不是真实资金。这套流程看起来笨,但能省下半夜被叫起来查账的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取