#869·go-admin

支付渠道插件:管理员填表就能用 / Payment channel plugin: configure it, don't integrate it

Author: wenjianzhangCreated Aug 22, 2026Updated Sep 4, 2026

go-admin has no payment channel mechanism, and people keep needing one. Alipay, WeChat Pay and PayPal all occupy the same slot, and today every project that needs one writes the integration itself, per provider.

The bar I would hold this to: an admin opens payment settings, picks a channel, pastes the merchant id and key their provider gave them, saves, and orders start getting paid. No code, no rebuild, no redeploy. It should be an opt-in plugin, because plenty of go-admin users never take a payment and shouldn't carry one.

That puts most of the work somewhere other than any provider's API. Calling those is the straightforward part. The mechanism around it is the feature:

Parameters are data, not YAML. If the keys live in settings.yml, then "configure it" means editing a file on the server and restarting, and we have built a developer feature wearing an admin feature's clothes. They belong in a table the panel writes, the way sys_config already works, so a channel can be switched on in a running system.

Each channel declares what it needs. Alipay wants app id, application private key and platform public key; WeChat Pay wants a merchant id, an API key and a certificate; PayPal wants something else again. If a channel declares its own parameter list, the settings form is generated from that, and adding the fourth channel doesn't mean touching the UI at all.

Credentials are not ordinary config. Encrypted at rest, never logged, and once saved the panel reports that a value is set rather than showing it back. A merchant key sitting in plaintext in a config table — readable by anyone with a database connection or a config export — costs more than the feature is worth.

Callback URLs must be stable and per-channel. The merchant pastes one into the provider's dashboard once and never thinks about it again; it cannot move when we reorganise routes.

Then the parts that always break in payment code, worth saying before anyone writes them:

The callback arrives more than once. That is normal operation, not an edge case, so handling it twice must not credit twice. It also arrives out of order, so a late pending must not overwrite a recorded paid. The signature is checked before anything touches the database.

The order table needs a unique key on the merchant order number, which in this repo means including the soft-delete marker: (merchant_order_no, deleted_at). A nullable marker cannot do it — NULL is not equal to NULL, so MySQL will hold two live rows for one payment. That is what common.ModelTime was changed for in #863.

I would design against two channels at once rather than one, even if only one gets implemented. Writing the interface against a single provider and adding the second later is how you find out, too late, that the interface was that provider's API with the field names changed.

If anyone has built a payment channel abstraction they were happy with, please say so — I would rather copy a shape that works than invent one.

How it goes in, and how it comes out

Compile-time, using the mechanism already in this repository rather than a new one. cmd/api/demo.go is seven lines:

go
package api

import "go-admin/app/demo/router"

func init() {
	AppRouters = append(AppRouters, router.InitRouter)
}

Adding a module is a file like that; removing one is deleting it. Nothing in the main program names app/demo, app/other or app/jobs.

Payment gets two layers of the same shape. The module itself lives in its own repository, so switching it on is go get plus one file with a blank import — and deleting that file, then go mod tidy, leaves no payment code in the binary at all. Individual channels register the same way routerCheckRole does:

go
func init() { payment.Register("alipay", func() payment.Channel { return &Alipay{} }) }

Drop the blank import for a channel and it is gone. That registry is also where the parameter list from earlier in this issue lives, so the settings form is generated from whatever is registered, and adding a channel touches no frontend code.

Not Go's plugin package. It can load .so files at runtime, and it requires the host and the plugin to be built with the same Go version and the same dependency versions, on Linux or macOS only. Version drift shows up as a failed load or worse. Projects that genuinely need runtime plugins run them as subprocesses over gRPC, which is a different order of complexity than this earns.

So "quick to add or remove" means editing one import and rebuilding. Not hot-swapping — but the cost is one go build, and what you get back is compile-time checking instead of a version-matching problem at startup.

One honest boundary: removing the plugin does not remove its tables. sys_payment_order stays, and its migration stays recorded in sys_migration. That is the right behaviour — deleting an import line should not delete anyone's orders — but it is worth saying out loud rather than discovering.


go-admin 现在没有支付渠道机制,而这个需求一直有人提。支付宝、微信支付、PayPal 占的都是同一个槽位,今天每个需要收款的项目都得自己按服务商写一遍对接。

我想把标准定在这里:管理员打开支付设置,选一个渠道,把服务商给的商户号和密钥粘进去,保存,订单就能收款了。不用写代码,不用重新编译,不用重新部署。它应该做成可选插件:很多 go-admin 的使用者根本不涉及收款,没必要让他们也背上这一套。

这样一来,主要的工作量就不在对接哪一家的 API 上了。把接口调通并不难,难的是外面这一层机制,它才是这个功能本身

参数是数据,不是 YAML。 如果密钥写在 settings.yml 里,那所谓「配置一下」就等于登服务器改文件再重启 —— 那这就不是给管理员用的功能,而是给开发者用的功能。这些参数应该落在后台可写的表里,就像 sys_config 现在这样,让渠道能在运行中的系统里打开。

每个渠道自己声明需要什么。 支付宝要 app id、应用私钥、支付宝公钥;微信支付要商户号、API 密钥、证书;PayPal 又是另一套。如果渠道自己声明参数清单,设置表单就能由它生成,加第四个渠道时根本不用碰前端。

凭证不是普通配置。 落库加密,不进日志,保存之后面板只显示「已设置」而不是把值回显出来。商户密钥明文躺在配置表里 —— 任何有数据库连接或能导配置的人都看得见 —— 这个代价比功能本身还大。

回调地址必须稳定,且按渠道区分。 商户在服务商后台里填一次就不会再管它,所以我们这边调整路由的时候,这个地址不能跟着变。

还有几个支付里几乎必然踩到的坑,趁没人动手之前先说清楚:

回调会重复到达,这是正常现象而不是极端情况,所以同一条通知处理两遍不能记成两笔账。它还会乱序到达,迟到的 pending 不能把已经记下的 paid 覆盖掉。另外验签要放在读写数据库之前。

订单表要在商户订单号上建唯一索引,在本仓库还得把软删除标记一并放进去:(merchant_order_no, deleted_at)。可空的标记撑不起这个索引 —— NULL 不等于 NULL,MySQL 会让同一笔支付存进两行。#863 改 common.ModelTime 就是为了这件事。

接口最好一开始就对着两个渠道设计,哪怕先只落地一个。 只照着一家写,等接第二家的时候才发现所谓的「渠道接口」其实就是那一家的 API 改了改字段名 —— 到那时候再返工,代价就大了。

如果有人做过还算顺手的支付渠道抽象,欢迎在下面说一声。能直接借鉴现成的,就不用自己从头设计。

怎么装上,怎么卸下

编译期插拔,用的是本仓库已有的机制,不新造一套。cmd/api/demo.go 全文只有七行:

go
package api

import "go-admin/app/demo/router"

func init() {
	AppRouters = append(AppRouters, router.InitRouter)
}

加一个模块就是新建这么一个文件,去掉一个模块就是删掉它。主程序里没有任何地方写死 app/demoapp/otherapp/jobs

支付按同样的形状分两层。模块本身放独立仓库,接入就是 go get 加一个只有空导入的文件;把这个文件删掉再 go mod tidy,二进制里一行支付代码都不会剩。单个渠道的注册方式和 routerCheckRole 完全同构:

go
func init() { payment.Register("alipay", func() payment.Channel { return &Alipay{} }) }

不要某个渠道,删掉它那行空导入即可。这个 registry 同时也是前面说的「参数清单」的落点 —— 设置表单由已注册的渠道生成,加渠道不用碰前端

不用 Go 的 plugin 包。 它能在运行时加载 .so,但要求主程序和插件用完全相同的 Go 版本、完全相同的依赖版本编译,而且只支持 Linux 和 macOS。版本稍有偏差,轻则加载失败,重则更麻烦。真正需要运行时插件的项目走的是子进程 + gRPC,那是另一个量级的复杂度,这个需求撑不起。

所以「快速增减」的准确含义是:改一行导入,重新编译。不是热插拔 —— 代价是一次 go build,换回来的是编译期检查,而不是启动时的版本匹配问题。

有一条边界要说在前面:卸载插件卸不掉数据。 sys_payment_order 会留在库里,它的迁移也仍然记在 sys_migration 里。这是对的 —— 删掉一行导入不该把谁的订单删掉 —— 但值得写出来,而不是让人自己撞上。