From 6da69644519420c083e3328dcbfc8babfabdf1a3 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Thu, 25 Jun 2026 07:21:48 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E6=8F=90=E4=BA=A4=EF=BC=9A?= =?UTF-8?q?=E5=B2=A9=E7=BE=8E=20pay=20=E6=94=B6=E6=AC=BE=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=EF=BC=88=E6=94=AF=E4=BB=98=E5=AE=9D=E5=BD=93=E9=9D=A2=E4=BB=98?= =?UTF-8?q?=20+=20=E5=A4=9A=E5=95=86=E6=88=B7=E5=A4=9A=E6=B8=A0=E9=81=93?= =?UTF-8?q?=E6=9E=B6=E6=9E=84=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Go/Gin/GORM + 纯 Go SQLite(无 cgo) - Channel 多渠道接口:支付宝当面付(precreate)/电脑网站支付(page.pay) 已实现,微信占位 - 多商户 merchants 表,回调验签+金额核对+幂等+查单兜底 - 收款页/结果页/二维码端点;docs/ 设计文档与部署 Runbook - 密钥走环境变量/Bitwarden,不入库 Co-Authored-By: Claude Opus 4.8 --- .gitignore | 11 ++ README.md | 65 +++++++ config/config.go | 81 +++++++++ config/config.yaml | 32 ++++ docs/index.html | 54 ++++++ docs/上线状态与部署Runbook.html | 162 +++++++++++++++++ docs/支付宝密钥申请与备案依赖.html | 171 ++++++++++++++++++ docs/支付宝收款页时序图与架构.html | 270 +++++++++++++++++++++++++++++ go.mod | 66 +++++++ go.sum | 156 +++++++++++++++++ internal/channel/alipay.go | 103 +++++++++++ internal/channel/channel.go | 64 +++++++ internal/channel/registry.go | 69 ++++++++ internal/channel/wechat.go | 32 ++++ internal/handler/order.go | 99 +++++++++++ internal/handler/page.go | 65 +++++++ internal/model/base.go | 10 ++ internal/model/merchant.go | 24 +++ internal/model/notify_log.go | 11 ++ internal/model/order.go | 28 +++ internal/model/product.go | 13 ++ internal/router/router.go | 36 ++++ internal/service/order.go | 229 ++++++++++++++++++++++++ internal/util/idgen.go | 21 +++ internal/util/money.go | 31 ++++ internal/util/response.go | 17 ++ main.go | 124 +++++++++++++ web/pay.html | 91 ++++++++++ web/result.html | 70 ++++++++ 29 files changed, 2205 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config/config.go create mode 100644 config/config.yaml create mode 100644 docs/index.html create mode 100644 docs/上线状态与部署Runbook.html create mode 100644 docs/支付宝密钥申请与备案依赖.html create mode 100644 docs/支付宝收款页时序图与架构.html create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/channel/alipay.go create mode 100644 internal/channel/channel.go create mode 100644 internal/channel/registry.go create mode 100644 internal/channel/wechat.go create mode 100644 internal/handler/order.go create mode 100644 internal/handler/page.go create mode 100644 internal/model/base.go create mode 100644 internal/model/merchant.go create mode 100644 internal/model/notify_log.go create mode 100644 internal/model/order.go create mode 100644 internal/model/product.go create mode 100644 internal/router/router.go create mode 100644 internal/service/order.go create mode 100644 internal/util/idgen.go create mode 100644 internal/util/money.go create mode 100644 internal/util/response.go create mode 100644 main.go create mode 100644 web/pay.html create mode 100644 web/result.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7194a36 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# 数据库与二进制 +*.db +*.db-journal +/payd +/pay + +# 密钥不入库(用环境变量或本地未跟踪的 config.yaml) +config/config.local.yaml + +# 系统 +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..8333d44 --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# pay — 独立支付服务 + +多业务 × 多渠道的收款服务。**支付宝先行(PC 网页支付),微信留接口**(无沙箱,待真实商户号)。 +栈:Go 1.26 · Gin · GORM(SQLite 纯 Go 驱动,零 cgo)。 + +## 目录结构 + +``` +pay/ +├── main.go # 启动:配置→DB→迁移→seed→路由→查单兜底 +├── config/ # viper 配置(config.yaml + 环境变量覆盖) +├── internal/ +│ ├── model/ # Merchant 商户凭证 / Product 套餐 / Order 订单 / NotifyLog 回调日志 +│ ├── channel/ # Channel 渠道统一接口 + alipay 实现 + wechat 占位 + Registry 缓存 +│ ├── service/ # OrderService:下单 / 回调验签入账 / 查单兜底 +│ ├── handler/ # HTTP:收款页·套餐·下单·回调·查状态 +│ ├── router/ # 路由装配 +│ └── util/ # 响应 / 金额(分) / 订单号生成 +├── web/ # pay.html 收款页 · result.html 结果页 +└── docs/ # 设计文档(index.html 索引) +``` + +## 核心约定 + +- **金额以服务端套餐价为准**,绝不信任前端传值。 +- **回调必验签**(支付宝公钥)+ **核对金额** + **幂等防重**;成功才回 `success`。 +- **到账以异步通知(notify)为准**,同步跳转仅展示;另有 `query_sync` 主动查单兜底。 +- 多商户 / 多渠道:往 `merchants` 表加行即可,主流程不变。 + +## 接口 + +| 方法 | 路径 | 说明 | +|---|---|---| +| GET | `/` | 收款页 | +| GET | `/result?out_trade_no=` | 结果页(return_url 落地) | +| GET | `/health` | 健康检查 | +| GET | `/api/v1/products` | 上架套餐列表 | +| POST | `/api/v1/orders` | 下单,返回 `pay_url` | +| GET | `/api/v1/orders/:out_trade_no` | 查订单状态(结果页轮询) | +| POST | `/api/v1/notify/alipay` | 支付宝异步回调 | + +## 沙箱联调步骤 + +1. 到 [支付宝沙箱](https://open.alipay.com/develop/sandbox/app) 拿 **APPID / 应用私钥 / 支付宝公钥**(公钥模式)。 +2. 编辑 `config/config.yaml` 的 `alipay_sandbox`:`enabled: true`,填好 `app_id`,私钥/公钥建议走环境变量: + ```bash + export ALIPAY_APP_PRIVATE_KEY="...应用私钥..." + export ALIPAY_PUBLIC_KEY="...支付宝公钥..." + ``` +3. 启动: + ```bash + go run . + ``` + 首次会自动建库、upsert 沙箱商户、补两个测试套餐(0.01 / 0.02 元)。 +4. 浏览器开 `http://localhost:8080` → 选套餐 → 跳支付宝沙箱收银台 → 用**沙箱买家账号**扫码/登录付款。 + +> ⚠️ **异步回调需公网可达**:`notify_url` 由 `server.base_url` 拼成。本机只测「下单→跳收银台」用 `localhost` 即可;要验证 `notify` 回调到账,需把 `base_url` 换成内网穿透域名(frp/ngrok 映射到本机 8080),或部署到有公网的服务器。本机看不到回调时,`query_sync` 主动查单会兜底补记到账。 + +## 构建部署 + +```bash +go build -o payd . # 纯 Go,无需 gcc,可直接拷到阿里云 Linux 运行 +``` + +生产改 `config.yaml`:`server.mode: release`、`base_url` 为正式 HTTPS 域名、商户 `production: true` 并换正式密钥。 diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..bcbd716 --- /dev/null +++ b/config/config.go @@ -0,0 +1,81 @@ +package config + +import ( + "log" + "strings" + + "github.com/spf13/viper" +) + +type Config struct { + Server ServerConfig + Database DatabaseConfig + AlipaySandbox AlipaySandboxConfig `mapstructure:"alipay_sandbox"` + QuerySync QuerySyncConfig `mapstructure:"query_sync"` +} + +type ServerConfig struct { + Port string `mapstructure:"port"` + Mode string `mapstructure:"mode"` // debug | release + BaseURL string `mapstructure:"base_url"` // 拼 notify_url / return_url 的公网根地址;沙箱回调需公网可达(内网穿透) +} + +type DatabaseConfig struct { + Driver string `mapstructure:"driver"` // sqlite | mysql + DSN string `mapstructure:"dsn"` // sqlite 为文件路径;mysql 为完整 DSN +} + +// AlipaySandboxConfig 启动时据此 upsert 一个支付宝(沙箱)商户,方便开箱联调。 +// 生产/多商户请改为通过管理接口或 seed 写入 merchants 表。 +type AlipaySandboxConfig struct { + Enabled bool `mapstructure:"enabled"` + Production bool `mapstructure:"production"` // false=沙箱网关;true=正式网关(真钱) + MerchantCode string `mapstructure:"merchant_code"` // 业务标识,如 yanmei + MerchantName string `mapstructure:"merchant_name"` + AppID string `mapstructure:"app_id"` + AppPrivateKey string `mapstructure:"app_private_key"` // 应用私钥(PKCS1/PKCS8 皆可,无 PEM 头亦可) + AlipayPublicKey string `mapstructure:"alipay_public_key"` // 支付宝公钥(公钥模式) +} + +// QuerySyncConfig 兜底主动查单:定时把待支付订单拿去 alipay.trade.query 核对,防回调丢失。 +type QuerySyncConfig struct { + Enabled bool `mapstructure:"enabled"` + IntervalSec int `mapstructure:"interval_sec"` // 轮询间隔(秒) + MaxAgeMin int `mapstructure:"max_age_min"` // 只查创建时间在该分钟数内的待支付单 +} + +var C Config + +func Load() { + viper.SetConfigName("config") + viper.SetConfigType("yaml") + viper.AddConfigPath(".") + viper.AddConfigPath("./config") + + viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + viper.AutomaticEnv() + + // 敏感项支持环境变量覆盖(部署时不写进 config.yaml) + _ = viper.BindEnv("alipay_sandbox.app_private_key", "ALIPAY_APP_PRIVATE_KEY") + _ = viper.BindEnv("alipay_sandbox.alipay_public_key", "ALIPAY_PUBLIC_KEY") + _ = viper.BindEnv("database.dsn", "DATABASE_DSN") + + viper.SetDefault("server.port", "8080") + viper.SetDefault("server.mode", "debug") + viper.SetDefault("server.base_url", "http://localhost:8080") + viper.SetDefault("database.driver", "sqlite") + viper.SetDefault("database.dsn", "pay.db") + viper.SetDefault("alipay_sandbox.enabled", false) + viper.SetDefault("alipay_sandbox.merchant_code", "yanmei") + viper.SetDefault("alipay_sandbox.merchant_name", "演么测试商户") + viper.SetDefault("query_sync.enabled", true) + viper.SetDefault("query_sync.interval_sec", 30) + viper.SetDefault("query_sync.max_age_min", 30) + + if err := viper.ReadInConfig(); err != nil { + log.Println("[config] 未找到 config.yaml,使用默认值 + 环境变量") + } + if err := viper.Unmarshal(&C); err != nil { + log.Fatalf("[config] 解析配置失败: %v", err) + } +} diff --git a/config/config.yaml b/config/config.yaml new file mode 100644 index 0000000..e4cfa88 --- /dev/null +++ b/config/config.yaml @@ -0,0 +1,32 @@ +# 支付服务配置(模板) +# ⚠️ 密钥严禁写进本文件!一律走环境变量(生产由 Bitwarden 经 rbw 灌入): +# ALIPAY_APP_PRIVATE_KEY / ALIPAY_PUBLIC_KEY + +server: + port: "8080" + mode: "debug" # debug | release + # 拼 notify_url / return_url 的根地址。 + # 本机只测「下单→跳收银台」用 http://localhost:8080 即可; + # 要测异步回调 notify 需公网可达地址(内网穿透,或部署到服务器用 IP/域名)。 + base_url: "http://localhost:8080" + +database: + driver: "sqlite" # sqlite(默认,纯 Go 无需 gcc) | mysql + dsn: "pay.db" # sqlite 文件路径;mysql 时填 user:pass@tcp(host:3306)/db?... + +# 启动时据此 upsert 一个支付宝商户。把 enabled 改 true、填 app_id,密钥走环境变量。 +# 沙箱信息:https://open.alipay.com/develop/sandbox/app +alipay_sandbox: + enabled: false + production: false # false=沙箱网关;true=正式网关(真钱) + merchant_code: "yanmei" + merchant_name: "岩美(北京)技术有限公司" + app_id: "" # 沙箱/生产 APPID + app_private_key: "" # 留空!走环境变量 ALIPAY_APP_PRIVATE_KEY + alipay_public_key: "" # 留空!走环境变量 ALIPAY_PUBLIC_KEY + +# 兜底主动查单:定时把待支付订单拿去查,防异步回调丢失 +query_sync: + enabled: true + interval_sec: 30 + max_age_min: 30 diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..b1a5476 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,54 @@ + + + + + +pay · 文档索引 + + + +
+
Project Docs · pay
+

pay 支付服务 · 文档索引

+

独立支付服务(Go·Gin·GORM),多业务 × 多渠道(支付宝先行,微信留接口)。新增文档请同步登记此处。

+ +

🎨 设计方案 / 技术方案

+ +
支付宝收款页 · 支付时序图与架构
+
PC 网页支付(alipay.trade.page.pay)固定套餐收款页的设计说明:SVG 时序图讲清「下单→跳收银台→扫码付→异步回调验签入账→兜底查单」全链路(到账以异步通知为准);对比「独立服务 vs 集成进 jiu」的区别;说明独立服务为何必须建库、要存哪些数据(orders / products / notify_logs 字段表)与安全红线。
+
v1.0 · 2026-06-24 · Go(Gin)+GORM · 沙箱联调阶段
+
+ +
支付宝密钥申请与备案依赖关系
+
沙箱 vs 生产两阶段各需什么;密钥怎么拿(沙箱 4 步 / 生产 5 步,APPID·应用私钥·支付宝公钥分别对应哪个配置);「不用域名是否就不用备案」的结论与三处卡点(商户签约/HTTPS/微信);含两线并行推进路线图(联调线 vs 上线线)。
+
v1.0 · 2026-06-24 · onboarding · 含备案依赖路线图
+
+ +

📋 实现计划

+

见仓库根 README.md(运行与联调步骤)

+ +

🔧 排障 Runbook

+ +
上线状态与部署 Runbook
+
支付宝当面付在阿里云的部署现状(systemd 守护、密钥走 Bitwarden→rbw→env、下单加签已验证、仅差支付宝审核)、部署架构图、服务器路径清单、运维常用命令(状态/重启/日志/更新密钥/更新程序/查订单)、上线检查清单;以及微信支付规划与硬前提(V3 回调强制 HTTPS→须先备案)。
+
v1.0 · 2026-06-25 · 阿里云 182.92.213.171 · 当面付
+
+
+ + diff --git a/docs/上线状态与部署Runbook.html b/docs/上线状态与部署Runbook.html new file mode 100644 index 0000000..7ca08b1 --- /dev/null +++ b/docs/上线状态与部署Runbook.html @@ -0,0 +1,162 @@ + + + + + +pay · 上线状态与部署 Runbook + + + +
+
pay · Status & Runbook
+

上线状态与部署 Runbook

+

岩美 pay 收款服务(支付宝当面付)在阿里云的部署现状、运维手册,以及微信支付的规划与硬前提。更新于 2026-06-25。

+ +

一、当前状态总览

+ + + + + + + + + + + +
事项状态说明
pay 服务部署到阿里云✅ 完成systemd 守护、开机自启、health OK
支付宝密钥(生产)✅ 完成存 Bitwarden「ali pay key」→ rbw 灌入服务器 env,私钥不落明文/不进聊天
下单 + RSA2 加签✅ 验证测试下单返回「应用未上线」= 密钥/加签/网关全通,只差审核
支付宝应用审核(当面付)⏳ 审核中1 天内出结果;通过后下单即出码,无需再改
阿里云安全组开 8080⏳ 待操作用户在 ECS 控制台开:TCP 8080 / 源 0.0.0.0/0
1 分钱真实实测⏳ 待审核过 + 端口开后,真支付宝扫码付
微信支付⛔ 阻塞V3 回调强制 HTTPS → 需先备案+域名+证书(见第四节)
+ +

二、部署架构

+ + + + 客户支付宝扫码 + + + 扫码 + + + pay 服务(阿里云) + 182.92.213.171:8080 + systemd · Go · SQLite + 当面付 precreate / 回调验签 / 查单 + + + 下单(加签) + + notify(验签) + + 支付宝 + 正式网关 + + + 🔑 Bitwarden「ali pay key」→ rbw → /etc/pay/pay.env + + + notify 用 IP:8080(http) + 支付宝当面付不校验备案 + + +

三、服务器部署详情

+ + + + + + + + + + + + + + +
服务器ssh ali → 182.92.213.171(Alibaba Cloud Linux 3, x86_64)
二进制/opt/pay/payd(Go 交叉编译 linux/amd64,纯静态无 cgo)
前端页面/opt/pay/web/(pay.html / result.html)
配置/opt/pay/config.yaml(production:true、app_id、base_url、enabled:true)
数据库/opt/pay/pay.db(SQLite)
密钥 env/etc/pay/pay.env(600,含 ALIPAY_APP_PRIVATE_KEY / ALIPAY_PUBLIC_KEY,来自 rbw)
systemd 单元/etc/systemd/system/pay.service
商户code=yanmei · APPID 2021006166629060 · 当面付 · 正式网关
notify_urlhttp://182.92.213.171:8080/api/v1/notify/alipay
查单兜底每 30s 主动 query 待支付订单(防回调丢失)
+
密钥来源(Bitwarden):条目 ali pay key,字段 app_private_key / alipay_public_key / appid。部署时 rbw get 管道直灌服务器 env,明文不落盘到仓库、不进聊天。
+ +

四、运维 Runbook(常用命令)

+
# 状态 / 健康
+ssh ali 'systemctl status pay'
+ssh ali 'curl -s localhost:8080/health'
+
+# 重启 / 看日志
+ssh ali 'systemctl restart pay'
+ssh ali 'journalctl -u pay -f'
+
+# 更新密钥(从 Bitwarden 重新灌)
+{ printf 'ALIPAY_APP_PRIVATE_KEY='; rbw get "ali pay key" --field app_private_key | tr -d '\r\n'; \
+  printf '\nALIPAY_PUBLIC_KEY='; rbw get "ali pay key" --field alipay_public_key | tr -d '\r\n'; printf '\n'; } \
+  | ssh ali 'umask 077; cat > /etc/pay/pay.env; chmod 600 /etc/pay/pay.env'
+ssh ali 'systemctl restart pay'
+
+# 更新程序(本地改完代码后)
+cd /Users/wangjia/code/pay && GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o /tmp/payd .
+scp /tmp/payd ali:/opt/pay/payd && ssh ali 'systemctl restart pay'
+
+# 查订单(服务器上)
+ssh ali 'sqlite3 -header -column /opt/pay/pay.db "SELECT out_trade_no,amount,status,trade_no,paid_at FROM orders ORDER BY id DESC LIMIT 10;"'
+ +
审核通过后无需任何改动——商户已 enabled、密钥已就位。支付宝应用一上线,下单即出码。直接开 http://182.92.213.171:8080 测 1 分钱即可。
+ +

五、上线检查清单(支付宝当面付)

+ + +

六、微信支付规划

+
硬前提:微信支付 V3 回调强制 HTTPS——必须 https://域名/notify,不能像支付宝当面付那样用 http://IP:8080。HTTPS 要域名+证书,域名+大陆服务器又要 ICP 备案。所以微信必须等备案好才能上线
+

不依赖备案、可现在并行申请

+ +

依赖备案

+ +

代码侧(待我实现)

+ +
节奏:现在支付宝当面付先收钱(不等备案);并行申请微信商户号+公众号;备案+域名+HTTPS 就绪后,我实现微信 V3 渠道并 1 分钱实测(微信无沙箱)。届时支付宝电脑网站支付(网页收款页)也能一并上。
+ +
+ + diff --git a/docs/支付宝密钥申请与备案依赖.html b/docs/支付宝密钥申请与备案依赖.html new file mode 100644 index 0000000..c7fa1f9 --- /dev/null +++ b/docs/支付宝密钥申请与备案依赖.html @@ -0,0 +1,171 @@ + + + + + +支付宝密钥申请与备案依赖 + + + +
+
pay · Onboarding
+

支付宝密钥申请与备案依赖关系

+

沙箱 vs 生产两阶段分别需要什么、密钥怎么拿、备案到底卡在哪一步。配套独立支付服务 pay(PC 网页支付 alipay.trade.page.pay)。

+ +

一、两阶段总览

+ + + + + + + + + + +
沙箱(现在就能做)生产(收真钱)
企业实名不需要需要(营业执照/对公/法人)
产品签约不需要需签约「电脑网站支付」
域名不需要签约审核基本要备案域名
ICP 备案不需要基本绕不开(见第二节)
HTTPS不需要(localhost 即可)需要(域名证书)
能验证什么下单→跳收银台→扫码付→(查单兜底)全链路真实到账 + 异步回调 notify
+ +

二、不用域名 = 不用备案?

+

技术层面:不绑域名就不触发 ICP 备案(备案=域名+大陆服务器对外提供服务)。而且有个对支付宝有利的事实:

+
+ 支付宝电脑网站支付的 notify_url / return_url 支付宝不校验是否备案——只要能 POST 回调到你的地址就行。这点和微信不同(微信 JSAPI/H5 要在后台填「已备案授权域名」)。所以单从回调技术看,甚至能用服务器公网 IP 当 notify_url 把真实支付跑通。 +
+

但「想正经收真钱」时,备案基本还是绕不开,卡在这三处:

+ + + + + + + +
卡点说明
商户签约审核申请生产「电脑网站支付」要提交网站地址,支付宝审核要求网站可正常访问且合规,实践中基本要备案域名(裸 IP 网站过审很难)
HTTPS给纯 IP 签受信任 HTTPS 证书极难(Let's Encrypt 不给 IP 发证),无 HTTPS 收款页显示「不安全」
微信支付微信必须填备案授权域名,完全绕不开
+
+ 结论:沙箱阶段不用域名/备案/实名;真实收款备案是刚需。但你不必等备案就能先把支付宝沙箱联调全做完,备案/签约和联调可并行。 +
+
+ 完全不备案也想收真钱的旁路:支付宝「当面付」(扫码)签约不依赖网站,或用收款链接 / 第三方托管收银台——但那不是自研网页收款页这条路。 +
+ +

三、沙箱密钥获取(4 步 · 现在就能做)

+
1
+

进沙箱控制台

+

支付宝账号登录 → https://open.alipay.com/develop/sandbox/app。页面直接给你 沙箱 APPID(形如 9021000xxxxxxxxx)。

+
+
2
+

选「公钥模式」+ 生成密钥

+

加签方式选 公钥模式(对应代码 LoadAliPayPublicKey)。下载官方密钥生成工具(有 Mac 版)→ 格式 PKCS8、长度 2048 → 生成得到 应用私钥应用公钥

+
+
3
+

上传应用公钥 → 换回支付宝公钥

+

应用公钥粘贴到沙箱「公钥模式」处保存,页面会生成一串 支付宝公钥

+
别搞混:你上传的是「应用公钥」;要复制回配置的是支付宝生成的「支付宝公钥」。
+
+
4
+

拿沙箱买家账号

+

沙箱页「沙箱账号」标签提供买家账号(邮箱)+ 登录密码 + 支付密码(假钱)。扫码付需装沙箱版支付宝 App(同页二维码),或用沙箱网页登录买家付款。

+
+ +

四、生产密钥申请(5 步 · 收真钱)

+
1
+

企业实名

+

登录 open.alipay.com → 用企业支付宝账号完成企业实名认证(营业执照、法人/对公信息)。个体户用对应类型亦可。

+
+
2
+

创建生产应用

+

控制台 → 创建应用 → 选「网页 & 移动应用」→ 得到 正式 APPID

+
+
3
+

签约「电脑网站支付」

+

应用里「添加能力/产品签约」→ 签约「电脑网站支付」(对应 alipay.trade.page.pay)。需提交企业资质、网站地址(基本要备案域名)、经营信息。审核约 1–3 个工作日。

+
+
4
+

配置生产密钥(同沙箱操作)

+

密钥工具生成应用私钥/公钥 → 应用「接口加签方式 → 公钥模式」上传应用公钥 → 复制返回的「支付宝公钥」。

+
+
5
+

切到生产

+

把正式 APPID/应用私钥/支付宝公钥写入商户配置,production: true(代码走正式网关),base_url 用正式 HTTPS 备案域名。

+
+ +

五、三样东西 → 配置对应

+ + + + + + + +
拿到的来源填到哪
APPID沙箱页面 / 生产应用页直接给config.yaml → alipay_sandbox.app_id(或 merchants 表)
应用私钥你用密钥工具生成,自己保管环境变量 ALIPAY_APP_PRIVATE_KEY
支付宝公钥上传应用公钥后支付宝生成环境变量 ALIPAY_PUBLIC_KEY
+
cd /Users/wangjia/code/pay
+export ALIPAY_APP_PRIVATE_KEY="MIIEv...应用私钥..."
+export ALIPAY_PUBLIC_KEY="MIIBIj...支付宝公钥..."
+# config.yaml 里 alipay_sandbox.enabled=true、填上 app_id
+go run .
+# 浏览器开 http://localhost:8080 选套餐 → 跳沙箱收银台 → 沙箱买家账号付款
+
+ 本机能测「下单 → 跳收银台 → 扫码付成功」整条链路;唯一测不到的是异步回调 notify(支付宝回调不到本机)。但服务内置的 query_sync 主动查单兜底会在 30 秒内把订单补成「已支付」,结果页照样变 ✅。要完整验证 notify 需内网穿透或部署到公网服务器。 +
+ +

六、推进路线(两线并行,互不等待)

+ + + + A · 联调线(现在) + 拿沙箱密钥 + + 本机跑通支付宝沙箱 + + 代码就绪 · 等生产 + + B · 上线线(并行) + 域名转入 + + ICP 备案 + + 企业实名+签约 + + 生产密钥 + + + 换密钥+域名→ 上线收真钱 + + + +

两条线独立推进:A 线现在就能把代码全跑通,B 线(域名/备案/签约)慢慢走;B 线齐了,只需「换生产密钥 + 换备案域名」即可上线,主流程一行不改(Merchant.Production + base_url)。

+ +
+ + diff --git a/docs/支付宝收款页时序图与架构.html b/docs/支付宝收款页时序图与架构.html new file mode 100644 index 0000000..3755623 --- /dev/null +++ b/docs/支付宝收款页时序图与架构.html @@ -0,0 +1,270 @@ + + + + + +支付宝收款页 · 支付时序图与架构 + + + +
+
Payment · Design
+

支付宝收款页 · 支付时序图与架构

+

PC 网页支付(alipay.trade.page.pay)· 固定套餐 · Go(Gin)+GORM · 沙箱联调阶段。本文说清楚一笔支付的完整时序、「独立服务」与「集成进 jiu」的区别,以及独立服务要不要建库、存哪些数据。

+ +

一、一笔支付的完整时序

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + 客户浏览器 + 收款页 + + + 收款服务后端 + Go · Gin · GORM + + + 支付宝网关 + 收银台 / 服务端 + + + 客户手机 + 支付宝 App + + + + + + + ① POST /order/create(选定套餐) + + + + 生成 out_trade_no · 订单落库(待支付) + + + + ② alipay.trade.page.pay(RSA2 加签下单) + + + + 返回收银台跳转 URL / 表单 + + + + ③ 返回跳转链接给浏览器 + + + + ④ 跳转支付宝收银台 · 展示付款二维码 + + + + ⑤ 扫码 · 确认付款 + + + + ⑥ 异步通知 notify_url(POST) + + + + 验签 + 核对金额 + 核对订单号 + + 防重复通知 → 更新订单为「已支付」 + + + + 应答 "success"(告知已处理,否则会重发) + + + + ⑦ 浏览器同步跳转 return_url + + + + 展示「支付成功」结果页 + + + + ⑧(兜底)alipay.trade.query 主动查单对账 + + +
+
+ 请求(同步调用) + 应答 / 跳转返回 + 兜底主动查单 + 后端处理 / 备注 +
+ +
+ 两条“到账”路径,以异步为准:⑦ 同步跳转(return_url)只是把用户带回页面,不能作为到账依据(用户可能中途关页面)。真正确认收款的是 ⑥ 异步通知(notify_url)。两条都可能丢,所以 ⑧ 用 alipay.trade.query 主动查单兜底。订单状态以「⑥验签通过 / ⑧查单成功」为准。 +
+ +

二、整体架构(沙箱阶段)

+
+

三个角色、五个核心接口,全程不需要客户登录:

+
    +
  • 必做收款页:静态 HTML,列固定套餐 → 选一个 → 调下单接口 → 跳转支付宝。
  • +
  • 必做POST /order/create:生成 out_trade_no、订单落库(待支付)、调 alipay.trade.page.pay 加签、返回跳转链接。
  • +
  • 必做POST /alipay/notify:异步回调,验签 + 核对金额/订单号 + 幂等防重 → 更新订单为已支付。支付安全的核心。
  • +
  • 必做GET /order/return:同步跳转回的结果页(仅展示,不作为到账依据)。
  • +
  • 建议查单兜底alipay.trade.query 对待支付订单轮询,防回调丢失。
  • +
  • 可选商家后台:看订单/对账列表 —— 唯一需要登录鉴权的部分
  • +
+
+ +

三、独立服务 vs 集成进 jiu

+

两种落地方式,差别核心就一句话:要不要复用 jiu 已有的数据库和 JWT 鉴权后台。

+ + + + + + + + + + + + +
独立服务(ai 下新建)集成进 jiu(jiu/backend 加模块)
代码位置/Users/wangjia/code/ai 下新建 pay/,独立 Go 进程jiu/backend 里加 handler/service/model/router
数据库自建一套(SQLite 单文件起步即可,或 MySQL)直接复用 jiu 的 GORM/MySQL,订单表加进去
鉴权后台要自己做登录鉴权(或第一版不做后台)白送——复用 jiu 的 JWT 中间件
部署单独一个二进制 + systemd,独立上线跟 jiu 一起构建/部署,绑在一条流水线
耦合度与 jiu 完全解耦,可给任意业务(如 yanmei)用与 jiu 强耦合,jiu 是进销存系统,收款无关会让它变臃肿
适合收款是独立业务、或要给多个项目复用收款本就是 jiu 业务的一环(订单/客户都在 jiu 里)
开发量核心一致,多了「建库 + 鉴权(若要后台)」核心一致,省掉建库和鉴权
+ +
+ 判断方法:收款数据要不要跟 jiu 的客户/订单/库存打通?
+ · 要打通 → 集成进 jiu,省事且数据一体。
+ · 不相干(给 yanmei 等独立收款)→ 独立服务,第一版连后台都不做,最快跑通沙箱。 +
+ +

四、独立服务需要建数据库吗?

+
需要。哪怕一张表也得有。SQLite 单文件起步就够(零运维),后面要并发/多实例再换 MySQL。
+

不能「只下单不落库」,原因是支付的钱和安全全靠订单记录兜底:

+ + + + + + + + +
为什么必须存不存会怎样
回调核对金额防篡改⑥ 收到通知时要拿订单原始金额比对,没存就无法判断金额是否被改 → 可被刷单
幂等防重复入账支付宝会重发通知,没有订单状态记录就会重复发货/重复确认
查单对账兜底⑧ 主动查单需要 out_trade_no,没存就无单可查,回调一丢就丢钱
给客户/商家看结果结果页、订单列表、退款都要读订单,无库无从谈起
+ +

五、存哪些数据

+

① 订单表 orders(核心,必建)

+ + + + + + + + + + + + + + + +
字段类型说明
iduint PK自增主键
out_trade_novarchar · 唯一商户订单号(我们生成,全局唯一),贯穿下单/回调/查单
subjectvarchar套餐/商品名称(展示在收银台)
amountdecimal(10,2)订单金额(元),回调时用它核对防篡改
statusvarchar / enum待支付 / 已支付 / 已关闭 / 已退款
trade_novarchar支付宝交易号(回调/查单回填)
buyer_logon_idvarchar买家支付宝账号(可选,回调带回,脱敏存)
paid_atdatetime支付完成时间(回调回填)
product_iduint指向套餐表(固定套餐场景)
client_ip / remarkvarchar下单来源 IP、备注(可选)
created_at / updated_atdatetimeGORM 自动维护
+ +

② 套餐表 products(固定套餐场景,建议建)

+

固定商品/套餐就是从这张表来,避免金额写死在前端被篡改 —— 下单时金额一律以服务端这张表为准,不信任前端传来的价格。

+ + + + + + + + + + +
字段类型说明
iduint PK套餐 ID(前端只传这个)
namevarchar套餐名(如「基础版 / 年付」)
pricedecimal(10,2)价格(元),服务端权威价格
descriptionvarchar套餐说明
activebool是否上架
sortint展示排序
+ +

③ 回调日志 notify_logs(可选,建议)

+

把每条异步通知的原始报文、验签结果、处理结果存一份,方便排查对账纠纷与审计。非必须,但出问题时极其有用。

+ +
+ 安全红线(无论独立还是集成都一样):
+ 1. 金额、套餐价格一律以服务端 DB 为准,绝不信任前端传值;
+ 2. 回调 必须验签(支付宝公钥)+ 核对 out_trade_nototal_amount
+ 3. 回调幂等:同一 trade_no 只处理一次;
+ 4. 处理成功才返回 success,否则支付宝会按策略重发。 +
+ +

六、结论与下一步

+ +

定了「独立 / 集成」后,我就把它写成正式设计文档 + 实现计划,照 jiu 的 Go(Gin)+GORM 约定搭骨架,用支付宝沙箱跑通全链路。

+ +
+ + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cb80116 --- /dev/null +++ b/go.mod @@ -0,0 +1,66 @@ +module github.com/wangjia/pay + +go 1.26.1 + +require ( + github.com/gin-gonic/gin v1.12.0 + github.com/glebarez/sqlite v1.11.0 + github.com/google/uuid v1.6.0 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e + github.com/smartwalle/alipay/v3 v3.2.29 + github.com/spf13/viper v1.21.0 + gorm.io/gorm v1.31.1 +) + +require ( + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/smartwalle/ncrypto v1.0.4 // indirect + github.com/smartwalle/ngx v1.1.0 // indirect + github.com/smartwalle/nsign v1.0.9 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1dcee84 --- /dev/null +++ b/go.sum @@ -0,0 +1,156 @@ +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= +github.com/smartwalle/alipay/v3 v3.2.29 h1:roGFqlml8hDa//0TpFmlyxZhndTYs7rbYLu/HlNFNJo= +github.com/smartwalle/alipay/v3 v3.2.29/go.mod h1:XarBLuAkwK3ah7mYjVtghRu+ysxzlex9sRkgqNMzMRU= +github.com/smartwalle/ncrypto v1.0.4 h1:P2rqQxDepJwgeO5ShoC+wGcK2wNJDmcdBOWAksuIgx8= +github.com/smartwalle/ncrypto v1.0.4/go.mod h1:Dwlp6sfeNaPMnOxMNayMTacvC5JGEVln3CVdiVDgbBk= +github.com/smartwalle/ngx v1.1.0 h1:q8nANgWSPRGeI/u+ixBoA4mf68DrUq6vZ+n9L5UKv9I= +github.com/smartwalle/ngx v1.1.0/go.mod h1:mx/nz2Pk5j+RBs7t6u6k22MPiBG/8CtOMpCnALIG8Y0= +github.com/smartwalle/nsign v1.0.9 h1:8poAgG7zBd8HkZy9RQDwasC6XZvJpDGQWSjzL2FZL6E= +github.com/smartwalle/nsign v1.0.9/go.mod h1:eY6I4CJlyNdVMP+t6z1H6Jpd4m5/V+8xi44ufSTxXgc= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= diff --git a/internal/channel/alipay.go b/internal/channel/alipay.go new file mode 100644 index 0000000..7f3bdee --- /dev/null +++ b/internal/channel/alipay.go @@ -0,0 +1,103 @@ +package channel + +import ( + "context" + "fmt" + "net/http" + + "github.com/smartwalle/alipay/v3" + + "github.com/wangjia/pay/internal/model" +) + +type alipayChannel struct { + client *alipay.Client +} + +func newAlipay(m *model.Merchant) (Channel, error) { + if m.AppID == "" || m.AppPrivateKey == "" || m.AlipayPublicKey == "" { + return nil, fmt.Errorf("商户 %s 的支付宝凭证不完整(app_id/app_private_key/alipay_public_key)", m.Code) + } + client, err := alipay.New(m.AppID, m.AppPrivateKey, m.Production) + if err != nil { + return nil, fmt.Errorf("初始化支付宝客户端失败: %w", err) + } + // 公钥模式(沙箱常用)。若改用证书模式,这里换成 LoadAppCertPublicKey 等。 + if err := client.LoadAliPayPublicKey(m.AlipayPublicKey); err != nil { + return nil, fmt.Errorf("加载支付宝公钥失败: %w", err) + } + return &alipayChannel{client: client}, nil +} + +func (a *alipayChannel) Name() string { return "alipay" } + +func (a *alipayChannel) PagePay(_ context.Context, req CreateReq) (string, error) { + var p = alipay.TradePagePay{} + p.OutTradeNo = req.OutTradeNo + p.Subject = req.Subject + p.TotalAmount = req.Amount + p.ProductCode = "FAST_INSTANT_TRADE_PAY" + p.NotifyURL = req.NotifyURL + p.ReturnURL = req.ReturnURL + u, err := a.client.TradePagePay(p) + if err != nil { + return "", fmt.Errorf("支付宝下单失败: %w", err) + } + return u.String(), nil +} + +func (a *alipayChannel) PreCreate(ctx context.Context, req CreateReq) (string, error) { + var p = alipay.TradePreCreate{} + p.OutTradeNo = req.OutTradeNo + p.Subject = req.Subject + p.TotalAmount = req.Amount + p.NotifyURL = req.NotifyURL + rsp, err := a.client.TradePreCreate(ctx, p) + if err != nil { + return "", fmt.Errorf("支付宝预下单调用失败: %w", err) + } + if rsp.IsFailure() { + return "", fmt.Errorf("支付宝预下单失败: %s / %s", rsp.Msg, rsp.SubMsg) + } + return rsp.QRCode, nil +} + +func (a *alipayChannel) VerifyNotify(ctx context.Context, r *http.Request) (*NotifyResult, error) { + if err := r.ParseForm(); err != nil { + return nil, fmt.Errorf("解析回调表单失败: %w", err) + } + // DecodeNotification 内部用已加载的支付宝公钥验签,验签不过会返回错误。 + noti, err := a.client.DecodeNotification(ctx, r.Form) + if err != nil { + return nil, fmt.Errorf("回调验签失败: %w", err) + } + paid := noti.TradeStatus == alipay.TradeStatusSuccess || noti.TradeStatus == alipay.TradeStatusFinished + return &NotifyResult{ + OutTradeNo: noti.OutTradeNo, + TradeNo: noti.TradeNo, + Amount: noti.TotalAmount, + BuyerLogonID: noti.BuyerLogonId, + Paid: paid, + Raw: r.Form.Encode(), + }, nil +} + +func (a *alipayChannel) Query(ctx context.Context, outTradeNo string) (*QueryResult, error) { + var p = alipay.TradeQuery{OutTradeNo: outTradeNo} + rsp, err := a.client.TradeQuery(ctx, p) + if err != nil { + return nil, fmt.Errorf("支付宝查单调用失败: %w", err) + } + if rsp.IsFailure() { + // 交易不存在(TRADE_NOT_EXIST)等:视为未找到,不报错,交由上层决定。 + return &QueryResult{Found: false}, nil + } + paid := rsp.TradeStatus == alipay.TradeStatusSuccess || rsp.TradeStatus == alipay.TradeStatusFinished + return &QueryResult{ + Found: true, + OutTradeNo: rsp.OutTradeNo, + TradeNo: rsp.TradeNo, + Amount: rsp.TotalAmount, + Paid: paid, + }, nil +} diff --git a/internal/channel/channel.go b/internal/channel/channel.go new file mode 100644 index 0000000..a9256a6 --- /dev/null +++ b/internal/channel/channel.go @@ -0,0 +1,64 @@ +// Package channel 把不同支付渠道(支付宝/微信…)收口到统一接口。 +// 上层业务只面向 Channel,新增渠道 = 加一个实现 + 一行注册,主流程不动。 +package channel + +import ( + "context" + "fmt" + "net/http" + + "github.com/wangjia/pay/internal/model" +) + +// CreateReq 统一下单参数。 +type CreateReq struct { + OutTradeNo string + Subject string + Amount string // 元,两位小数 + ReturnURL string + NotifyURL string +} + +// NotifyResult 异步通知验签解析后的统一结果。 +type NotifyResult struct { + OutTradeNo string + TradeNo string + Amount string + BuyerLogonID string + Paid bool // 是否为支付成功状态 + Raw string +} + +// QueryResult 主动查单的统一结果。 +type QueryResult struct { + Found bool + OutTradeNo string + TradeNo string + Amount string + Paid bool +} + +// Channel 支付渠道统一接口。 +type Channel interface { + Name() string + // PagePay 统一下单,返回收银台跳转 URL。 + PagePay(ctx context.Context, req CreateReq) (payURL string, err error) + // PreCreate 扫码(当面付)预下单,返回二维码码串,由前端渲染成二维码供客户扫码付款。 + PreCreate(ctx context.Context, req CreateReq) (qrCode string, err error) + // VerifyNotify 验签并解析异步通知(内部完成 ParseForm 与签名校验)。 + VerifyNotify(ctx context.Context, r *http.Request) (*NotifyResult, error) + // Query 主动查单。 + Query(ctx context.Context, outTradeNo string) (*QueryResult, error) +} + +// Build 按商户配置构造渠道实例。 +func Build(m *model.Merchant) (Channel, error) { + switch m.Channel { + case "alipay": + return newAlipay(m) + case "wechat": + return newWechat(m) + default: + return nil, fmt.Errorf("未知支付渠道: %q", m.Channel) + } +} diff --git a/internal/channel/registry.go b/internal/channel/registry.go new file mode 100644 index 0000000..511a425 --- /dev/null +++ b/internal/channel/registry.go @@ -0,0 +1,69 @@ +package channel + +import ( + "fmt" + "sync" + + "gorm.io/gorm" + + "github.com/wangjia/pay/internal/model" +) + +// Registry 按商户缓存已构造的渠道实例,避免每次下单都重新加载密钥。 +type Registry struct { + db *gorm.DB + mu sync.RWMutex + cache map[uint64]Channel +} + +func NewRegistry(db *gorm.DB) *Registry { + return &Registry{db: db, cache: make(map[uint64]Channel)} +} + +// ByMerchantID 取(或构造)指定商户的渠道。 +func (r *Registry) ByMerchantID(id uint64) (Channel, *model.Merchant, error) { + var m model.Merchant + if err := r.db.First(&m, "id = ? AND enabled = ?", id, true).Error; err != nil { + return nil, nil, fmt.Errorf("商户不存在或已停用: %w", err) + } + ch, err := r.get(&m) + return ch, &m, err +} + +// AlipayByAppID 异步回调时用 app_id 反查商户并取其渠道(用于验签)。 +func (r *Registry) AlipayByAppID(appID string) (Channel, *model.Merchant, error) { + if appID == "" { + return nil, nil, fmt.Errorf("回调缺少 app_id") + } + var m model.Merchant + if err := r.db.First(&m, "channel = ? AND app_id = ? AND enabled = ?", "alipay", appID, true).Error; err != nil { + return nil, nil, fmt.Errorf("找不到 app_id=%s 对应的支付宝商户: %w", appID, err) + } + ch, err := r.get(&m) + return ch, &m, err +} + +func (r *Registry) get(m *model.Merchant) (Channel, error) { + r.mu.RLock() + if ch, ok := r.cache[m.ID]; ok { + r.mu.RUnlock() + return ch, nil + } + r.mu.RUnlock() + + ch, err := Build(m) + if err != nil { + return nil, err + } + r.mu.Lock() + r.cache[m.ID] = ch + r.mu.Unlock() + return ch, nil +} + +// Invalidate 商户凭证变更后清缓存(预留给管理接口)。 +func (r *Registry) Invalidate(id uint64) { + r.mu.Lock() + delete(r.cache, id) + r.mu.Unlock() +} diff --git a/internal/channel/wechat.go b/internal/channel/wechat.go new file mode 100644 index 0000000..7057fd9 --- /dev/null +++ b/internal/channel/wechat.go @@ -0,0 +1,32 @@ +package channel + +import ( + "context" + "errors" + "net/http" + + "github.com/wangjia/pay/internal/model" +) + +// ErrNotImplemented 渠道尚未实现。 +var ErrNotImplemented = errors.New("微信支付渠道尚未实现:微信无可用沙箱,待真实商户号下来后补 V3 实现") + +// wechatChannel 占位实现。接口已就绪,补齐 V3 下单/回调/查单即可启用。 +type wechatChannel struct{ m *model.Merchant } + +func newWechat(m *model.Merchant) (Channel, error) { return &wechatChannel{m: m}, nil } + +func (w *wechatChannel) Name() string { return "wechat" } + +func (w *wechatChannel) PagePay(context.Context, CreateReq) (string, error) { + return "", ErrNotImplemented +} +func (w *wechatChannel) PreCreate(context.Context, CreateReq) (string, error) { + return "", ErrNotImplemented +} +func (w *wechatChannel) VerifyNotify(context.Context, *http.Request) (*NotifyResult, error) { + return nil, ErrNotImplemented +} +func (w *wechatChannel) Query(context.Context, string) (*QueryResult, error) { + return nil, ErrNotImplemented +} diff --git a/internal/handler/order.go b/internal/handler/order.go new file mode 100644 index 0000000..babe130 --- /dev/null +++ b/internal/handler/order.go @@ -0,0 +1,99 @@ +package handler + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/wangjia/pay/internal/service" + "github.com/wangjia/pay/internal/util" +) + +type OrderHandler struct { + svc *service.OrderService +} + +func NewOrderHandler(svc *service.OrderService) *OrderHandler { + return &OrderHandler{svc: svc} +} + +type createOrderRequest struct { + ProductID uint64 `json:"product_id" binding:"required"` +} + +// Create POST /api/v1/orders —— 下单,返回支付宝收银台跳转 URL。 +func (h *OrderHandler) Create(c *gin.Context) { + var req createOrderRequest + if err := c.ShouldBindJSON(&req); err != nil { + util.RespondError(c, http.StatusBadRequest, "bad_request", "参数错误:"+err.Error()) + return + } + payURL, order, err := h.svc.Create(c.Request.Context(), req.ProductID, c.ClientIP()) + if err != nil { + if errors.Is(err, service.ErrProductNotFound) { + util.RespondError(c, http.StatusNotFound, "product_not_found", err.Error()) + return + } + util.RespondError(c, http.StatusInternalServerError, "create_failed", err.Error()) + return + } + util.RespondSuccess(c, gin.H{ + "pay_url": payURL, + "out_trade_no": order.OutTradeNo, + "amount": order.Amount, + "subject": order.Subject, + }) +} + +// CreateQR POST /api/v1/orders/qr —— 扫码下单,返回二维码码串(前端渲染成二维码)。 +func (h *OrderHandler) CreateQR(c *gin.Context) { + var req createOrderRequest + if err := c.ShouldBindJSON(&req); err != nil { + util.RespondError(c, http.StatusBadRequest, "bad_request", "参数错误:"+err.Error()) + return + } + qr, order, err := h.svc.CreateQR(c.Request.Context(), req.ProductID, c.ClientIP()) + if err != nil { + if errors.Is(err, service.ErrProductNotFound) { + util.RespondError(c, http.StatusNotFound, "product_not_found", err.Error()) + return + } + util.RespondError(c, http.StatusInternalServerError, "create_failed", err.Error()) + return + } + util.RespondSuccess(c, gin.H{ + "qr_code": qr, + "out_trade_no": order.OutTradeNo, + "amount": order.Amount, + "subject": order.Subject, + }) +} + +// AlipayNotify POST /api/v1/notify/alipay —— 异步回调。 +// 成功必须返回纯文本 "success",否则支付宝会按策略重发。 +func (h *OrderHandler) AlipayNotify(c *gin.Context) { + if err := h.svc.HandleAlipayNotify(c.Request.Context(), c.Request); err != nil { + c.String(http.StatusOK, "failure") // 回 failure 让支付宝重试(也可记录后人工处理) + return + } + c.String(http.StatusOK, "success") +} + +// GetStatus GET /api/v1/orders/:out_trade_no —— 供结果页轮询订单状态。 +func (h *OrderHandler) GetStatus(c *gin.Context) { + out := c.Param("out_trade_no") + o, err := h.svc.GetByOutTradeNo(out) + if err != nil { + util.RespondError(c, http.StatusNotFound, "order_not_found", "订单不存在") + return + } + util.RespondSuccess(c, gin.H{ + "out_trade_no": o.OutTradeNo, + "subject": o.Subject, + "amount": o.Amount, + "status": o.Status, + "trade_no": o.TradeNo, + "paid_at": o.PaidAt, + }) +} diff --git a/internal/handler/page.go b/internal/handler/page.go new file mode 100644 index 0000000..47afd74 --- /dev/null +++ b/internal/handler/page.go @@ -0,0 +1,65 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/skip2/go-qrcode" + "gorm.io/gorm" + + "github.com/wangjia/pay/internal/model" + "github.com/wangjia/pay/internal/util" +) + +type PageHandler struct { + db *gorm.DB + webDir string +} + +func NewPageHandler(db *gorm.DB, webDir string) *PageHandler { + return &PageHandler{db: db, webDir: webDir} +} + +// PayPage GET / —— 收款页。 +func (h *PageHandler) PayPage(c *gin.Context) { + c.File(h.webDir + "/pay.html") +} + +// ResultPage GET /result —— 支付结果页(return_url 落地)。 +func (h *PageHandler) ResultPage(c *gin.Context) { + c.File(h.webDir + "/result.html") +} + +// QRCode GET /qrcode?text=xxx —— 把任意字符串渲染成二维码 PNG(用于展示支付宝扫码码串)。 +func (h *PageHandler) QRCode(c *gin.Context) { + text := c.Query("text") + if text == "" { + util.RespondError(c, http.StatusBadRequest, "bad_request", "缺少 text 参数") + return + } + png, err := qrcode.Encode(text, qrcode.Medium, 256) + if err != nil { + util.RespondError(c, http.StatusInternalServerError, "qr_failed", err.Error()) + return + } + c.Data(http.StatusOK, "image/png", png) +} + +// ListProducts GET /api/v1/products —— 上架套餐列表(金额来自服务端)。 +func (h *PageHandler) ListProducts(c *gin.Context) { + var products []model.Product + if err := h.db.Where("active = ?", true).Order("sort asc, id asc").Find(&products).Error; err != nil { + util.RespondError(c, http.StatusInternalServerError, "list_failed", err.Error()) + return + } + out := make([]gin.H, 0, len(products)) + for _, p := range products { + out = append(out, gin.H{ + "id": p.ID, + "name": p.Name, + "description": p.Description, + "price": p.Price, + }) + } + util.RespondSuccess(c, out) +} diff --git a/internal/model/base.go b/internal/model/base.go new file mode 100644 index 0000000..63d4a51 --- /dev/null +++ b/internal/model/base.go @@ -0,0 +1,10 @@ +package model + +import "time" + +// Base 公共字段(与 jiu 约定一致) +type Base struct { + ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/internal/model/merchant.go b/internal/model/merchant.go new file mode 100644 index 0000000..7988d37 --- /dev/null +++ b/internal/model/merchant.go @@ -0,0 +1,24 @@ +package model + +// Merchant 一个「业务 × 渠道」的收款凭证。 +// 多业务、多支付宝账户、以后加微信,都是往这张表加行 —— 主流程不变。 +type Merchant struct { + Base + Code string `gorm:"uniqueIndex;size:64" json:"code"` // 业务标识:yanmei / jiu ... + Name string `gorm:"size:128" json:"name"` // 展示名 + Channel string `gorm:"index;size:16" json:"channel"` // alipay | wechat + Production bool `json:"production"` // false=沙箱 + Enabled bool `gorm:"default:true" json:"enabled"` + + // —— 支付宝 ——(app_id 用于异步回调反查商户) + AppID string `gorm:"index;size:64" json:"app_id"` + AppPrivateKey string `gorm:"type:text" json:"-"` // 应用私钥,绝不下发 + AlipayPublicKey string `gorm:"type:text" json:"-"` // 支付宝公钥(验签用) + + // —— 微信 ——(预留,渠道尚未实现) + MchID string `gorm:"size:64" json:"mch_id,omitempty"` + WxAppID string `gorm:"size:64" json:"wx_app_id,omitempty"` + APIv3Key string `gorm:"type:text" json:"-"` + CertSerial string `gorm:"size:128" json:"-"` + WxPrivateKey string `gorm:"type:text" json:"-"` +} diff --git a/internal/model/notify_log.go b/internal/model/notify_log.go new file mode 100644 index 0000000..15edff4 --- /dev/null +++ b/internal/model/notify_log.go @@ -0,0 +1,11 @@ +package model + +// NotifyLog 异步回调审计日志。每条通知的原始报文 + 验签/处理结果都留一份,便于排查对账纠纷。 +type NotifyLog struct { + Base + Channel string `gorm:"size:16" json:"channel"` + OutTradeNo string `gorm:"index;size:64" json:"out_trade_no"` + Verified bool `json:"verified"` // 验签是否通过 + Result string `gorm:"size:32" json:"result"` // processed | duplicate | amount_mismatch | verify_failed | not_found | ignored + Raw string `gorm:"type:text" json:"raw"` // 原始表单报文 +} diff --git a/internal/model/order.go b/internal/model/order.go new file mode 100644 index 0000000..88fff50 --- /dev/null +++ b/internal/model/order.go @@ -0,0 +1,28 @@ +package model + +import "time" + +type OrderStatus string + +const ( + OrderPending OrderStatus = "pending" // 待支付 + OrderPaid OrderStatus = "paid" // 已支付 + OrderClosed OrderStatus = "closed" // 已关闭/超时 + OrderRefunded OrderStatus = "refunded" // 已退款 +) + +// Order 一笔收款订单。OutTradeNo 是我们生成的商户订单号,贯穿下单/回调/查单。 +type Order struct { + Base + OutTradeNo string `gorm:"uniqueIndex;size:64;not null" json:"out_trade_no"` + MerchantID uint64 `gorm:"index;not null" json:"merchant_id"` + Channel string `gorm:"size:16" json:"channel"` + ProductID uint64 `json:"product_id"` + Subject string `gorm:"size:128" json:"subject"` + Amount string `gorm:"size:20;not null" json:"amount"` // 权威金额,回调/查单核对用 + Status OrderStatus `gorm:"index;size:16;not null" json:"status"` + TradeNo string `gorm:"index;size:64" json:"trade_no"` // 支付宝交易号 + BuyerLogonID string `gorm:"size:128" json:"buyer_logon_id"` + PaidAt *time.Time `json:"paid_at"` + ClientIP string `gorm:"size:64" json:"client_ip"` +} diff --git a/internal/model/product.go b/internal/model/product.go new file mode 100644 index 0000000..44ac5cd --- /dev/null +++ b/internal/model/product.go @@ -0,0 +1,13 @@ +package model + +// Product 固定套餐/商品。价格以本表为准(服务端权威价),绝不信任前端传值。 +// 金额用 string 存(如 "0.01"),与支付宝 total_amount 口径一致,避免浮点误差。 +type Product struct { + Base + MerchantID uint64 `gorm:"index;not null" json:"merchant_id"` + Name string `gorm:"size:128;not null" json:"name"` + Description string `gorm:"size:255" json:"description"` + Price string `gorm:"size:20;not null" json:"price"` // 元,两位小数 + Active bool `gorm:"default:true" json:"active"` + Sort int `json:"sort"` +} diff --git a/internal/router/router.go b/internal/router/router.go new file mode 100644 index 0000000..7011bda --- /dev/null +++ b/internal/router/router.go @@ -0,0 +1,36 @@ +package router + +import ( + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "github.com/wangjia/pay/config" + "github.com/wangjia/pay/internal/channel" + "github.com/wangjia/pay/internal/handler" + "github.com/wangjia/pay/internal/service" +) + +// Setup 装配路由。返回 OrderService 供 main 启动查单兜底任务。 +func Setup(r *gin.Engine, db *gorm.DB, reg *channel.Registry) *service.OrderService { + orderSvc := service.NewOrderService(db, reg, config.C.Server.BaseURL) + orderH := handler.NewOrderHandler(orderSvc) + pageH := handler.NewPageHandler(db, "web") + + r.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) }) + + // 页面 + r.GET("/", pageH.PayPage) + r.GET("/result", pageH.ResultPage) + r.GET("/qrcode", pageH.QRCode) + + v1 := r.Group("/api/v1") + { + v1.GET("/products", pageH.ListProducts) + v1.POST("/orders", orderH.Create) + v1.POST("/orders/qr", orderH.CreateQR) + v1.GET("/orders/:out_trade_no", orderH.GetStatus) + v1.POST("/notify/alipay", orderH.AlipayNotify) + } + + return orderSvc +} diff --git a/internal/service/order.go b/internal/service/order.go new file mode 100644 index 0000000..037a82a --- /dev/null +++ b/internal/service/order.go @@ -0,0 +1,229 @@ +package service + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "time" + + "gorm.io/gorm" + + "github.com/wangjia/pay/internal/channel" + "github.com/wangjia/pay/internal/model" + "github.com/wangjia/pay/internal/util" +) + +var ( + ErrProductNotFound = errors.New("套餐不存在或已下架") + ErrAmountMismatch = errors.New("回调金额与订单金额不符") +) + +type OrderService struct { + db *gorm.DB + reg *channel.Registry + baseURL string +} + +func NewOrderService(db *gorm.DB, reg *channel.Registry, baseURL string) *OrderService { + return &OrderService{db: db, reg: reg, baseURL: baseURL} +} + +// prepare 校验套餐、取渠道、落库一张待支付订单(金额一律取服务端套餐价,不信任前端)。 +func (s *OrderService) prepare(productID uint64, clientIP string) (channel.Channel, *model.Merchant, *model.Order, error) { + var p model.Product + if err := s.db.First(&p, "id = ? AND active = ?", productID, true).Error; err != nil { + return nil, nil, nil, ErrProductNotFound + } + ch, m, err := s.reg.ByMerchantID(p.MerchantID) + if err != nil { + return nil, nil, nil, err + } + order := &model.Order{ + OutTradeNo: util.NewOutTradeNo(m.Code), + MerchantID: m.ID, + Channel: m.Channel, + ProductID: p.ID, + Subject: p.Name, + Amount: p.Price, // 权威金额 + Status: model.OrderPending, + ClientIP: clientIP, + } + if err := s.db.Create(order).Error; err != nil { + return nil, nil, nil, fmt.Errorf("创建订单失败: %w", err) + } + return ch, m, order, nil +} + +func (s *OrderService) notifyURL(channel string) string { + return s.baseURL + "/api/v1/notify/" + channel +} + +// Create 网页支付下单,返回收银台跳转 URL。 +func (s *OrderService) Create(ctx context.Context, productID uint64, clientIP string) (string, *model.Order, error) { + ch, m, order, err := s.prepare(productID, clientIP) + if err != nil { + return "", nil, err + } + payURL, err := ch.PagePay(ctx, channel.CreateReq{ + OutTradeNo: order.OutTradeNo, + Subject: order.Subject, + Amount: order.Amount, + NotifyURL: s.notifyURL(m.Channel), + ReturnURL: s.baseURL + "/result?out_trade_no=" + order.OutTradeNo, + }) + if err != nil { + return "", nil, err + } + return payURL, order, nil +} + +// CreateQR 扫码(当面付)下单,返回二维码码串供前端渲染。 +func (s *OrderService) CreateQR(ctx context.Context, productID uint64, clientIP string) (string, *model.Order, error) { + ch, m, order, err := s.prepare(productID, clientIP) + if err != nil { + return "", nil, err + } + qr, err := ch.PreCreate(ctx, channel.CreateReq{ + OutTradeNo: order.OutTradeNo, + Subject: order.Subject, + Amount: order.Amount, + NotifyURL: s.notifyURL(m.Channel), + }) + if err != nil { + return "", nil, err + } + return qr, order, nil +} + +// HandleAlipayNotify 处理支付宝异步回调:反查商户 → 验签 → 核对金额 → 幂等更新。 +// 返回 nil 表示已正确处理(调用方应给支付宝回 "success")。 +func (s *OrderService) HandleAlipayNotify(ctx context.Context, r *http.Request) error { + if err := r.ParseForm(); err != nil { + return fmt.Errorf("解析回调失败: %w", err) + } + appID := r.PostFormValue("app_id") + outTradeNo := r.PostFormValue("out_trade_no") + + ch, m, err := s.reg.AlipayByAppID(appID) + if err != nil { + s.logNotify("alipay", outTradeNo, false, "not_found", r.Form.Encode()) + return err + } + + res, err := ch.VerifyNotify(ctx, r) + if err != nil { + s.logNotify("alipay", outTradeNo, false, "verify_failed", r.Form.Encode()) + return err + } + + result, err := s.applyPaid(m, res) + s.logNotify("alipay", res.OutTradeNo, true, result, res.Raw) + return err +} + +// applyPaid 在一个事务里完成「金额核对 + 幂等置为已支付」。返回处理结果标记。 +func (s *OrderService) applyPaid(m *model.Merchant, res *channel.NotifyResult) (string, error) { + if !res.Paid { + return "ignored", nil // 非成功状态(如 WAIT_BUYER_PAY),确认收到即可 + } + + var resultTag string + err := s.db.Transaction(func(tx *gorm.DB) error { + var o model.Order + if err := tx.First(&o, "out_trade_no = ? AND merchant_id = ?", res.OutTradeNo, m.ID).Error; err != nil { + resultTag = "not_found" + return fmt.Errorf("订单不存在: %s", res.OutTradeNo) + } + if o.Status == model.OrderPaid { + resultTag = "duplicate" // 幂等:已处理过,直接成功返回 + return nil + } + if !util.AmountEqual(o.Amount, res.Amount) { + resultTag = "amount_mismatch" + return ErrAmountMismatch + } + now := time.Now() + upd := tx.Model(&model.Order{}). + Where("out_trade_no = ? AND status = ?", o.OutTradeNo, model.OrderPending). + Updates(map[string]any{ + "status": model.OrderPaid, + "trade_no": res.TradeNo, + "buyer_logon_id": res.BuyerLogonID, + "paid_at": &now, + }) + if upd.Error != nil { + return upd.Error + } + if upd.RowsAffected == 0 { + resultTag = "duplicate" // 并发下被另一路(如查单)先置位 + return nil + } + resultTag = "processed" + log.Printf("[notify] 订单 %s 已支付 trade_no=%s amount=%s", o.OutTradeNo, res.TradeNo, res.Amount) + return nil + }) + return resultTag, err +} + +func (s *OrderService) logNotify(ch, outTradeNo string, verified bool, result, raw string) { + _ = s.db.Create(&model.NotifyLog{ + Channel: ch, + OutTradeNo: outTradeNo, + Verified: verified, + Result: result, + Raw: raw, + }).Error +} + +// GetByOutTradeNo 供前端结果页轮询。 +func (s *OrderService) GetByOutTradeNo(outTradeNo string) (*model.Order, error) { + var o model.Order + if err := s.db.First(&o, "out_trade_no = ?", outTradeNo).Error; err != nil { + return nil, err + } + return &o, nil +} + +// SyncPending 兜底:把近期待支付订单拿去主动查单,命中已支付则补记(防回调丢失)。 +func (s *OrderService) SyncPending(ctx context.Context, maxAge time.Duration) { + var orders []model.Order + cutoff := time.Now().Add(-maxAge) + if err := s.db.Where("status = ? AND created_at > ?", model.OrderPending, cutoff). + Limit(100).Find(&orders).Error; err != nil { + log.Printf("[query_sync] 查询待支付订单失败: %v", err) + return + } + for i := range orders { + o := &orders[i] + ch, m, err := s.reg.ByMerchantID(o.MerchantID) + if err != nil { + continue + } + qr, err := ch.Query(ctx, o.OutTradeNo) + if err != nil || qr == nil || !qr.Found || !qr.Paid { + continue + } + result, _ := s.applyPaid(m, &channel.NotifyResult{ + OutTradeNo: qr.OutTradeNo, + TradeNo: qr.TradeNo, + Amount: qr.Amount, + Paid: true, + }) + if result == "processed" { + log.Printf("[query_sync] 订单 %s 经主动查单补记为已支付", o.OutTradeNo) + } + } +} + +// StartQuerySync 启动后台查单兜底循环。 +func (s *OrderService) StartQuerySync(interval, maxAge time.Duration) { + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for range ticker.C { + s.SyncPending(context.Background(), maxAge) + } + }() +} diff --git a/internal/util/idgen.go b/internal/util/idgen.go new file mode 100644 index 0000000..2f6c11a --- /dev/null +++ b/internal/util/idgen.go @@ -0,0 +1,21 @@ +package util + +import ( + "fmt" + "strings" + "time" + + "github.com/google/uuid" +) + +// NewOutTradeNo 生成全局唯一的商户订单号:-<时间>-<随机>,控制在 64 字符内。 +// 例:yanmei-20260624153012-a1b2c3d4 +func NewOutTradeNo(merchantCode string) string { + code := merchantCode + if len(code) > 16 { + code = code[:16] + } + ts := time.Now().Format("20060102150405") + suffix := strings.ReplaceAll(uuid.NewString(), "-", "")[:8] + return fmt.Sprintf("%s-%s-%s", code, ts, suffix) +} diff --git a/internal/util/money.go b/internal/util/money.go new file mode 100644 index 0000000..c1e8b16 --- /dev/null +++ b/internal/util/money.go @@ -0,0 +1,31 @@ +package util + +import ( + "fmt" + "math" + "strconv" + "strings" +) + +// AmountToCents 把 "0.01" / "12.30" 元金额转为分(int64),便于精确比较。 +func AmountToCents(s string) (int64, error) { + s = strings.TrimSpace(s) + if s == "" { + return 0, fmt.Errorf("空金额") + } + f, err := strconv.ParseFloat(s, 64) + if err != nil { + return 0, fmt.Errorf("金额格式错误 %q: %w", s, err) + } + if f < 0 { + return 0, fmt.Errorf("金额不能为负: %q", s) + } + return int64(math.Round(f * 100)), nil +} + +// AmountEqual 判断两个元金额字符串是否等值(按分比较,避免浮点/格式差异)。 +func AmountEqual(a, b string) bool { + ca, err1 := AmountToCents(a) + cb, err2 := AmountToCents(b) + return err1 == nil && err2 == nil && ca == cb +} diff --git a/internal/util/response.go b/internal/util/response.go new file mode 100644 index 0000000..7fd3fc5 --- /dev/null +++ b/internal/util/response.go @@ -0,0 +1,17 @@ +package util + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// RespondError 结构化错误:{"code":..,"message":..} +func RespondError(c *gin.Context, status int, code, msg string) { + c.JSON(status, gin.H{"code": code, "message": msg}) +} + +// RespondSuccess 成功:{"data":..} +func RespondSuccess(c *gin.Context, data interface{}) { + c.JSON(http.StatusOK, gin.H{"data": data}) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..c0066b7 --- /dev/null +++ b/main.go @@ -0,0 +1,124 @@ +package main + +import ( + "errors" + "log" + "time" + + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "github.com/wangjia/pay/config" + "github.com/wangjia/pay/internal/channel" + "github.com/wangjia/pay/internal/model" + "github.com/wangjia/pay/internal/router" +) + +func main() { + config.Load() + + db := initDB() + autoMigrate(db) + seed(db) + + reg := channel.NewRegistry(db) + + gin.SetMode(config.C.Server.Mode) + r := gin.New() + r.Use(gin.Logger(), gin.Recovery()) + + orderSvc := router.Setup(r, db, reg) + + if config.C.QuerySync.Enabled { + orderSvc.StartQuerySync( + time.Duration(config.C.QuerySync.IntervalSec)*time.Second, + time.Duration(config.C.QuerySync.MaxAgeMin)*time.Minute, + ) + log.Printf("查单兜底已启动:每 %ds 一次", config.C.QuerySync.IntervalSec) + } + + addr := ":" + config.C.Server.Port + log.Printf("支付服务启动 %s (mode=%s, base_url=%s)", addr, config.C.Server.Mode, config.C.Server.BaseURL) + if err := r.Run(addr); err != nil { + log.Fatalf("启动失败: %v", err) + } +} + +func initDB() *gorm.DB { + logLevel := logger.Silent + if config.C.Server.Mode == "debug" { + logLevel = logger.Info + } + gormCfg := &gorm.Config{Logger: logger.Default.LogMode(logLevel), TranslateError: true} + + switch config.C.Database.Driver { + case "sqlite", "": + db, err := gorm.Open(sqlite.Open(config.C.Database.DSN), gormCfg) + if err != nil { + log.Fatalf("连接 sqlite 失败: %v", err) + } + return db + case "mysql": + log.Fatal("mysql 驱动尚未启用:go get gorm.io/driver/mysql 后在 initDB 接入") + return nil + default: + log.Fatalf("未知 database.driver: %s", config.C.Database.Driver) + return nil + } +} + +func autoMigrate(db *gorm.DB) { + if err := db.AutoMigrate( + &model.Merchant{}, + &model.Product{}, + &model.Order{}, + &model.NotifyLog{}, + ); err != nil { + log.Fatalf("自动迁移失败: %v", err) + } + log.Println("AutoMigrate 完成") +} + +// seed 据 config 的 alipay_sandbox upsert 一个支付宝商户,并为其补两个测试套餐。 +func seed(db *gorm.DB) { + sb := config.C.AlipaySandbox + if !sb.Enabled { + log.Println("[seed] alipay_sandbox.enabled=false,跳过沙箱商户初始化") + return + } + var m model.Merchant + err := db.Where("code = ?", sb.MerchantCode).First(&m).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + m = model.Merchant{ + Code: sb.MerchantCode, + Name: sb.MerchantName, + Channel: "alipay", + } + } else if err != nil { + log.Fatalf("[seed] 查询商户失败: %v", err) + } + m.Production = sb.Production + m.Enabled = true + m.AppID = sb.AppID + m.AppPrivateKey = sb.AppPrivateKey + m.AlipayPublicKey = sb.AlipayPublicKey + if err := db.Save(&m).Error; err != nil { + log.Fatalf("[seed] 保存商户失败: %v", err) + } + log.Printf("[seed] 支付宝沙箱商户就绪: code=%s app_id=%s", m.Code, m.AppID) + + var cnt int64 + db.Model(&model.Product{}).Where("merchant_id = ?", m.ID).Count(&cnt) + if cnt == 0 { + samples := []model.Product{ + {MerchantID: m.ID, Name: "测试套餐 A", Description: "沙箱联调用", Price: "0.01", Active: true, Sort: 1}, + {MerchantID: m.ID, Name: "测试套餐 B", Description: "沙箱联调用", Price: "0.02", Active: true, Sort: 2}, + } + if err := db.Create(&samples).Error; err != nil { + log.Fatalf("[seed] 创建测试套餐失败: %v", err) + } + log.Printf("[seed] 已为商户 %s 创建 %d 个测试套餐", m.Code, len(samples)) + } +} diff --git a/web/pay.html b/web/pay.html new file mode 100644 index 0000000..a0050c6 --- /dev/null +++ b/web/pay.html @@ -0,0 +1,91 @@ + + + + + +收款 + + + +
+

选择套餐

+
支付宝支付 · 沙箱联调
+
+ +
+
+ 跳转到支付宝收银台后,用右边「登录支付宝账户付款」
+ 买家账号 vtlvff1163@sandbox.com · 支付密码 111111 +
+
+ + + diff --git a/web/result.html b/web/result.html new file mode 100644 index 0000000..beb914e --- /dev/null +++ b/web/result.html @@ -0,0 +1,70 @@ + + + + + +支付结果 + + + +
+
+

正在确认支付结果…

+
+
到账以异步通知为准,正在轮询…
+ ← 返回收款页 +
+ + +