初始提交:岩美 pay 收款服务(支付宝当面付 + 多商户多渠道架构)
- Go/Gin/GORM + 纯 Go SQLite(无 cgo) - Channel 多渠道接口:支付宝当面付(precreate)/电脑网站支付(page.pay) 已实现,微信占位 - 多商户 merchants 表,回调验签+金额核对+幂等+查单兜底 - 收款页/结果页/二维码端点;docs/ 设计文档与部署 Runbook - 密钥走环境变量/Bitwarden,不入库 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+11
@@ -0,0 +1,11 @@
|
|||||||
|
# 数据库与二进制
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
/payd
|
||||||
|
/pay
|
||||||
|
|
||||||
|
# 密钥不入库(用环境变量或本地未跟踪的 config.yaml)
|
||||||
|
config/config.local.yaml
|
||||||
|
|
||||||
|
# 系统
|
||||||
|
.DS_Store
|
||||||
@@ -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` 并换正式密钥。
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>pay · 文档索引</title>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#0d1117;--card:#161b22;--card-2:#1c2330;--border:#283041;--fg:#e6edf3;--fg-soft:#aeb9c7;--muted:#7d8896;--accent:#58a6ff;--radius:14px}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;background:radial-gradient(1200px 600px at 80% -10%,rgba(88,166,255,.08),transparent 60%),var(--bg);color:var(--fg);font:15px/1.7 -apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;-webkit-font-smoothing:antialiased}
|
||||||
|
.wrap{max-width:860px;margin:0 auto;padding:48px 22px 80px}
|
||||||
|
.eyebrow{color:var(--accent);font-weight:600;letter-spacing:.12em;font-size:12px;text-transform:uppercase}
|
||||||
|
h1{font-size:30px;margin:10px 0 8px}
|
||||||
|
.lead{color:var(--fg-soft);margin:0 0 24px}
|
||||||
|
h2{font-size:18px;margin:34px 0 10px;color:var(--accent);border-bottom:1px solid var(--border);padding-bottom:8px}
|
||||||
|
a{color:var(--accent);text-decoration:none}
|
||||||
|
.doc{display:block;background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:16px 18px;margin:12px 0;transition:.15s}
|
||||||
|
.doc:hover{border-color:var(--accent);background:var(--card-2)}
|
||||||
|
.doc .title{font-size:16px;font-weight:600;color:var(--fg)}
|
||||||
|
.doc .desc{color:var(--fg-soft);font-size:13.5px;margin-top:4px}
|
||||||
|
.doc .meta{color:var(--muted);font-size:12px;margin-top:6px}
|
||||||
|
.empty{color:var(--muted);font-size:13.5px}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="eyebrow">Project Docs · pay</div>
|
||||||
|
<h1>pay 支付服务 · 文档索引</h1>
|
||||||
|
<p class="lead">独立支付服务(Go·Gin·GORM),多业务 × 多渠道(支付宝先行,微信留接口)。新增文档请同步登记此处。</p>
|
||||||
|
|
||||||
|
<h2>🎨 设计方案 / 技术方案</h2>
|
||||||
|
<a class="doc" href="./支付宝收款页时序图与架构.html">
|
||||||
|
<div class="title">支付宝收款页 · 支付时序图与架构</div>
|
||||||
|
<div class="desc">PC 网页支付(alipay.trade.page.pay)固定套餐收款页的设计说明:SVG 时序图讲清「下单→跳收银台→扫码付→异步回调验签入账→兜底查单」全链路(到账以异步通知为准);对比「独立服务 vs 集成进 jiu」的区别;说明独立服务为何必须建库、要存哪些数据(orders / products / notify_logs 字段表)与安全红线。</div>
|
||||||
|
<div class="meta">v1.0 · 2026-06-24 · Go(Gin)+GORM · 沙箱联调阶段</div>
|
||||||
|
</a>
|
||||||
|
<a class="doc" href="./支付宝密钥申请与备案依赖.html">
|
||||||
|
<div class="title">支付宝密钥申请与备案依赖关系</div>
|
||||||
|
<div class="desc">沙箱 vs 生产两阶段各需什么;密钥怎么拿(沙箱 4 步 / 生产 5 步,APPID·应用私钥·支付宝公钥分别对应哪个配置);「不用域名是否就不用备案」的结论与三处卡点(商户签约/HTTPS/微信);含两线并行推进路线图(联调线 vs 上线线)。</div>
|
||||||
|
<div class="meta">v1.0 · 2026-06-24 · onboarding · 含备案依赖路线图</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<h2>📋 实现计划</h2>
|
||||||
|
<p class="empty">见仓库根 README.md(运行与联调步骤)</p>
|
||||||
|
|
||||||
|
<h2>🔧 排障 Runbook</h2>
|
||||||
|
<a class="doc" href="./上线状态与部署Runbook.html">
|
||||||
|
<div class="title">上线状态与部署 Runbook</div>
|
||||||
|
<div class="desc">支付宝当面付在阿里云的部署现状(systemd 守护、密钥走 Bitwarden→rbw→env、下单加签已验证、仅差支付宝审核)、部署架构图、服务器路径清单、运维常用命令(状态/重启/日志/更新密钥/更新程序/查订单)、上线检查清单;以及微信支付规划与硬前提(V3 回调强制 HTTPS→须先备案)。</div>
|
||||||
|
<div class="meta">v1.0 · 2026-06-25 · 阿里云 182.92.213.171 · 当面付</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>pay · 上线状态与部署 Runbook</title>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#0d1117;--card:#161b22;--card-2:#1c2330;--border:#283041;--fg:#e6edf3;--fg-soft:#aeb9c7;--muted:#7d8896;--accent:#58a6ff;--green:#3fb950;--orange:#d29922;--red:#f85149;--radius:14px}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;background:radial-gradient(1200px 600px at 80% -10%,rgba(88,166,255,.08),transparent 60%),var(--bg);color:var(--fg);font:15px/1.7 -apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;-webkit-font-smoothing:antialiased}
|
||||||
|
.wrap{max-width:1000px;margin:0 auto;padding:44px 22px 90px}
|
||||||
|
.eyebrow{color:var(--accent);font-weight:600;letter-spacing:.12em;font-size:12px;text-transform:uppercase}
|
||||||
|
h1{font-size:28px;margin:10px 0 8px}
|
||||||
|
.lead{color:var(--fg-soft);margin:0 0 26px}
|
||||||
|
h2{font-size:19px;margin:38px 0 12px;color:var(--accent);border-bottom:1px solid var(--border);padding-bottom:8px}
|
||||||
|
h3{font-size:16px;margin:22px 0 8px;color:var(--fg)}
|
||||||
|
p{color:var(--fg-soft)}
|
||||||
|
code{background:var(--card-2);border:1px solid var(--border);border-radius:5px;padding:1px 6px;font-size:13px;color:#e6edf3;font-family:"SF Mono",Menlo,Consolas,monospace}
|
||||||
|
pre{background:var(--card-2);border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;font-size:12.5px;line-height:1.6}
|
||||||
|
pre code{background:none;border:0;padding:0}
|
||||||
|
a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
|
||||||
|
table{width:100%;border-collapse:collapse;margin:14px 0;font-size:14px}
|
||||||
|
th,td{border:1px solid var(--border);padding:9px 11px;text-align:left;vertical-align:top}
|
||||||
|
th{background:var(--card-2);color:var(--fg);font-weight:600}
|
||||||
|
td{color:var(--fg-soft)}
|
||||||
|
.callout{border-left:3px solid var(--accent);background:var(--card-2);border-radius:0 8px 8px 0;padding:12px 16px;margin:16px 0;color:var(--fg-soft)}
|
||||||
|
.callout.warn{border-left-color:var(--orange)}
|
||||||
|
.callout.ok{border-left-color:var(--green)}
|
||||||
|
.callout.bad{border-left-color:var(--red)}
|
||||||
|
.ok{color:var(--green);font-weight:600}.wait{color:var(--orange);font-weight:600}.no{color:var(--red);font-weight:600}
|
||||||
|
.tag{display:inline-block;font-size:12px;padding:1px 9px;border-radius:20px;border:1px solid var(--border);background:var(--card-2);color:var(--fg-soft)}
|
||||||
|
ul{color:var(--fg-soft)}
|
||||||
|
svg{display:block;margin:8px auto;max-width:100%}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="eyebrow">pay · Status & Runbook</div>
|
||||||
|
<h1>上线状态与部署 Runbook</h1>
|
||||||
|
<p class="lead">岩美 pay 收款服务(支付宝当面付)在阿里云的部署现状、运维手册,以及微信支付的规划与硬前提。更新于 2026-06-25。</p>
|
||||||
|
|
||||||
|
<h2>一、当前状态总览</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th style="width:34%">事项</th><th style="width:14%">状态</th><th>说明</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>pay 服务部署到阿里云</td><td class="ok">✅ 完成</td><td>systemd 守护、开机自启、health OK</td></tr>
|
||||||
|
<tr><td>支付宝密钥(生产)</td><td class="ok">✅ 完成</td><td>存 Bitwarden「ali pay key」→ rbw 灌入服务器 env,私钥不落明文/不进聊天</td></tr>
|
||||||
|
<tr><td>下单 + RSA2 加签</td><td class="ok">✅ 验证</td><td>测试下单返回「应用未上线」= 密钥/加签/网关全通,只差审核</td></tr>
|
||||||
|
<tr><td>支付宝应用审核(当面付)</td><td class="wait">⏳ 审核中</td><td>1 天内出结果;通过后下单即出码,无需再改</td></tr>
|
||||||
|
<tr><td>阿里云安全组开 8080</td><td class="wait">⏳ 待操作</td><td>用户在 ECS 控制台开:TCP 8080 / 源 0.0.0.0/0</td></tr>
|
||||||
|
<tr><td>1 分钱真实实测</td><td class="wait">⏳ 待</td><td>审核过 + 端口开后,真支付宝扫码付</td></tr>
|
||||||
|
<tr><td>微信支付</td><td class="no">⛔ 阻塞</td><td>V3 回调强制 HTTPS → 需先备案+域名+证书(见第四节)</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>二、部署架构</h2>
|
||||||
|
<svg viewBox="0 0 940 250" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system,PingFang SC,sans-serif" font-size="12.5">
|
||||||
|
<defs><marker id="a" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto"><path d="M0,0 L9,3 L0,6 Z" fill="#58a6ff"/></marker></defs>
|
||||||
|
<!-- 客户 -->
|
||||||
|
<rect x="20" y="100" width="130" height="50" rx="9" fill="#1c2330" stroke="#58a6ff"/><text x="85" y="122" fill="#e6edf3" text-anchor="middle">客户</text><text x="85" y="139" fill="#7d8896" text-anchor="middle" font-size="11">支付宝扫码</text>
|
||||||
|
<!-- 收款页/QR -->
|
||||||
|
<line x1="150" y1="125" x2="198" y2="125" stroke="#58a6ff" stroke-width="2" marker-end="url(#a)"/>
|
||||||
|
<text x="174" y="118" fill="#aeb9c7" text-anchor="middle" font-size="10.5">扫码</text>
|
||||||
|
<!-- pay服务 -->
|
||||||
|
<rect x="200" y="80" width="220" height="92" rx="10" fill="#161b22" stroke="#3fb950"/>
|
||||||
|
<text x="310" y="104" fill="#e6edf3" text-anchor="middle" font-weight="600">pay 服务(阿里云)</text>
|
||||||
|
<text x="310" y="124" fill="#aeb9c7" text-anchor="middle" font-size="11">182.92.213.171:8080</text>
|
||||||
|
<text x="310" y="142" fill="#7d8896" text-anchor="middle" font-size="11">systemd · Go · SQLite</text>
|
||||||
|
<text x="310" y="159" fill="#7d8896" text-anchor="middle" font-size="11">当面付 precreate / 回调验签 / 查单</text>
|
||||||
|
<!-- 支付宝 -->
|
||||||
|
<line x1="420" y1="110" x2="528" y2="110" stroke="#58a6ff" stroke-width="2" marker-end="url(#a)"/>
|
||||||
|
<text x="474" y="103" fill="#aeb9c7" text-anchor="middle" font-size="10.5">下单(加签)</text>
|
||||||
|
<line x1="528" y1="140" x2="420" y2="140" stroke="#7d8896" stroke-width="1.8" stroke-dasharray="5 4" marker-end="url(#a)"/>
|
||||||
|
<text x="474" y="155" fill="#aeb9c7" text-anchor="middle" font-size="10.5">notify(验签)</text>
|
||||||
|
<rect x="530" y="90" width="150" height="70" rx="10" fill="#1c2330" stroke="#3fb950"/>
|
||||||
|
<text x="605" y="120" fill="#e6edf3" text-anchor="middle" font-weight="600">支付宝</text>
|
||||||
|
<text x="605" y="138" fill="#7d8896" text-anchor="middle" font-size="11">正式网关</text>
|
||||||
|
<!-- Bitwarden -->
|
||||||
|
<rect x="200" y="200" width="220" height="40" rx="9" fill="#1c2330" stroke="#283041"/>
|
||||||
|
<text x="310" y="225" fill="#aeb9c7" text-anchor="middle" font-size="11.5">🔑 Bitwarden「ali pay key」→ rbw → /etc/pay/pay.env</text>
|
||||||
|
<line x1="310" y1="200" x2="310" y2="174" stroke="#7d8896" stroke-width="1.5" stroke-dasharray="3 3" marker-end="url(#a)"/>
|
||||||
|
<!-- 备注 -->
|
||||||
|
<text x="760" y="118" fill="#7d8896" font-size="11">notify 用 IP:8080(http)</text>
|
||||||
|
<text x="760" y="136" fill="#7d8896" font-size="11">支付宝当面付不校验备案</text>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<h2>三、服务器部署详情</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th style="width:30%">项</th><th>值</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>服务器</td><td><code>ssh ali</code> → 182.92.213.171(Alibaba Cloud Linux 3, x86_64)</td></tr>
|
||||||
|
<tr><td>二进制</td><td><code>/opt/pay/payd</code>(Go 交叉编译 linux/amd64,纯静态无 cgo)</td></tr>
|
||||||
|
<tr><td>前端页面</td><td><code>/opt/pay/web/</code>(pay.html / result.html)</td></tr>
|
||||||
|
<tr><td>配置</td><td><code>/opt/pay/config.yaml</code>(production:true、app_id、base_url、enabled:true)</td></tr>
|
||||||
|
<tr><td>数据库</td><td><code>/opt/pay/pay.db</code>(SQLite)</td></tr>
|
||||||
|
<tr><td>密钥 env</td><td><code>/etc/pay/pay.env</code>(600,含 ALIPAY_APP_PRIVATE_KEY / ALIPAY_PUBLIC_KEY,来自 rbw)</td></tr>
|
||||||
|
<tr><td>systemd 单元</td><td><code>/etc/systemd/system/pay.service</code></td></tr>
|
||||||
|
<tr><td>商户</td><td>code=<code>yanmei</code> · APPID <code>2021006166629060</code> · 当面付 · 正式网关</td></tr>
|
||||||
|
<tr><td>notify_url</td><td><code>http://182.92.213.171:8080/api/v1/notify/alipay</code></td></tr>
|
||||||
|
<tr><td>查单兜底</td><td>每 30s 主动 query 待支付订单(防回调丢失)</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="callout"><strong>密钥来源(Bitwarden):</strong>条目 <code>ali pay key</code>,字段 <code>app_private_key</code> / <code>alipay_public_key</code> / <code>appid</code>。部署时 <code>rbw get</code> 管道直灌服务器 env,明文不落盘到仓库、不进聊天。</div>
|
||||||
|
|
||||||
|
<h2>四、运维 Runbook(常用命令)</h2>
|
||||||
|
<pre><code># 状态 / 健康
|
||||||
|
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;"'</code></pre>
|
||||||
|
|
||||||
|
<div class="callout ok"><strong>审核通过后无需任何改动</strong>——商户已 enabled、密钥已就位。支付宝应用一上线,下单即出码。直接开 <code>http://182.92.213.171:8080</code> 测 1 分钱即可。</div>
|
||||||
|
|
||||||
|
<h2>五、上线检查清单(支付宝当面付)</h2>
|
||||||
|
<ul>
|
||||||
|
<li>☑ 服务部署、systemd 自启、health OK</li>
|
||||||
|
<li>☑ 生产密钥灌入(Bitwarden → env)</li>
|
||||||
|
<li>☑ 下单加签验证通过(「应用未上线」是预期)</li>
|
||||||
|
<li>☐ <span class="wait">支付宝应用 + 当面付审核通过</span>(1 天内)</li>
|
||||||
|
<li>☐ <span class="wait">阿里云安全组开 8080</span>(TCP / 0.0.0.0/0)</li>
|
||||||
|
<li>☐ <span class="wait">1 分钱真实扫码实测</span> → 订单变 paid</li>
|
||||||
|
<li>☐ 把测试套餐(0.01/0.02)换成真实授权套餐(¥99/¥268/¥888…)</li>
|
||||||
|
<li>☐ 配服务器 IP 白名单(资金接口加固,可选)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>六、微信支付规划</h2>
|
||||||
|
<div class="callout warn"><strong>硬前提:微信支付 V3 回调强制 HTTPS</strong>——必须 <code>https://域名/notify</code>,不能像支付宝当面付那样用 <code>http://IP:8080</code>。HTTPS 要域名+证书,域名+大陆服务器又要 <strong>ICP 备案</strong>。所以<strong>微信必须等备案好才能上线</strong>。</div>
|
||||||
|
<h3>不依赖备案、可现在并行申请</h3>
|
||||||
|
<ul>
|
||||||
|
<li>申请<strong>微信支付商户号</strong>(pay.weixin.qq.com,企业+营业执照+对公账户,审核约 1~7 天)</li>
|
||||||
|
<li>注册一个<strong>认证的公众号/服务号或小程序</strong>(当 APPID 用;服务号认证约 300/年)</li>
|
||||||
|
<li>配 <strong>API 证书 + APIv3 密钥 + 微信支付公钥</strong>(商户平台 → 账户中心 → API 安全)</li>
|
||||||
|
</ul>
|
||||||
|
<h3>依赖备案</h3>
|
||||||
|
<ul>
|
||||||
|
<li>域名转入 + ICP 备案 → 服务器配 HTTPS(域名+证书)→ 微信 notify 才能用</li>
|
||||||
|
</ul>
|
||||||
|
<h3>代码侧(待我实现)</h3>
|
||||||
|
<ul>
|
||||||
|
<li><code>internal/channel/wechat.go</code> 现为占位;需实现微信支付 V3:Native 下单 / 回调 AES-GCM 解密+验签 / 查单</li>
|
||||||
|
<li>架构已预留:<code>Channel</code> 接口 + <code>merchants</code> 多渠道表,补实现 + 加一条商户即可,主流程不变</li>
|
||||||
|
</ul>
|
||||||
|
<div class="callout"><strong>节奏:</strong>现在支付宝当面付先收钱(不等备案);并行申请微信商户号+公众号;备案+域名+HTTPS 就绪后,我实现微信 V3 渠道并 1 分钱实测(微信无沙箱)。届时支付宝电脑网站支付(网页收款页)也能一并上。</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>支付宝密钥申请与备案依赖</title>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#0d1117;--card:#161b22;--card-2:#1c2330;--border:#283041;--fg:#e6edf3;--fg-soft:#aeb9c7;--muted:#7d8896;--accent:#58a6ff;--green:#3fb950;--orange:#d29922;--red:#f85149;--radius:14px}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;background:radial-gradient(1200px 600px at 80% -10%,rgba(88,166,255,.08),transparent 60%),var(--bg);color:var(--fg);font:15px/1.7 -apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;-webkit-font-smoothing:antialiased}
|
||||||
|
.wrap{max-width:980px;margin:0 auto;padding:44px 22px 90px}
|
||||||
|
.eyebrow{color:var(--accent);font-weight:600;letter-spacing:.12em;font-size:12px;text-transform:uppercase}
|
||||||
|
h1{font-size:28px;margin:10px 0 8px}
|
||||||
|
.lead{color:var(--fg-soft);margin:0 0 26px}
|
||||||
|
h2{font-size:19px;margin:38px 0 12px;color:var(--accent);border-bottom:1px solid var(--border);padding-bottom:8px}
|
||||||
|
h3{font-size:16px;margin:22px 0 8px;color:var(--fg)}
|
||||||
|
p{color:var(--fg-soft)}
|
||||||
|
code{background:var(--card-2);border:1px solid var(--border);border-radius:5px;padding:1px 6px;font-size:13px;color:#e6edf3;font-family:"SF Mono",Menlo,Consolas,monospace}
|
||||||
|
pre{background:var(--card-2);border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;font-size:13px;line-height:1.55}
|
||||||
|
pre code{background:none;border:0;padding:0}
|
||||||
|
a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
|
||||||
|
table{width:100%;border-collapse:collapse;margin:14px 0;font-size:14px}
|
||||||
|
th,td{border:1px solid var(--border);padding:9px 11px;text-align:left;vertical-align:top}
|
||||||
|
th{background:var(--card-2);color:var(--fg);font-weight:600}
|
||||||
|
td{color:var(--fg-soft)}
|
||||||
|
.callout{border-left:3px solid var(--accent);background:var(--card-2);border-radius:0 8px 8px 0;padding:12px 16px;margin:16px 0;color:var(--fg-soft)}
|
||||||
|
.callout.warn{border-left-color:var(--orange)}
|
||||||
|
.callout.ok{border-left-color:var(--green)}
|
||||||
|
.callout.bad{border-left-color:var(--red)}
|
||||||
|
.step{display:flex;gap:14px;margin:14px 0;background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:16px 18px}
|
||||||
|
.step .n{flex:0 0 30px;height:30px;border-radius:50%;background:var(--accent);color:#04122b;font-weight:700;display:flex;align-items:center;justify-content:center}
|
||||||
|
.step .body{flex:1}
|
||||||
|
.step .body h3{margin:2px 0 6px}
|
||||||
|
.pill{font-size:12px;font-weight:600;padding:2px 9px;border-radius:6px}
|
||||||
|
.pill.sand{background:rgba(63,185,80,.15);color:var(--green)}
|
||||||
|
.pill.prod{background:rgba(210,153,34,.15);color:var(--orange)}
|
||||||
|
.yes{color:var(--green);font-weight:600}.no{color:var(--red);font-weight:600}
|
||||||
|
ul{color:var(--fg-soft)}
|
||||||
|
svg{display:block;margin:8px auto;max-width:100%}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="eyebrow">pay · Onboarding</div>
|
||||||
|
<h1>支付宝密钥申请与备案依赖关系</h1>
|
||||||
|
<p class="lead">沙箱 vs 生产两阶段分别需要什么、密钥怎么拿、备案到底卡在哪一步。配套独立支付服务 <code>pay</code>(PC 网页支付 <code>alipay.trade.page.pay</code>)。</p>
|
||||||
|
|
||||||
|
<h2>一、两阶段总览</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th style="width:24%"></th><th><span class="pill sand">沙箱(现在就能做)</span></th><th><span class="pill prod">生产(收真钱)</span></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><strong>企业实名</strong></td><td class="no">不需要</td><td class="yes">需要(营业执照/对公/法人)</td></tr>
|
||||||
|
<tr><td><strong>产品签约</strong></td><td class="no">不需要</td><td class="yes">需签约「电脑网站支付」</td></tr>
|
||||||
|
<tr><td><strong>域名</strong></td><td class="no">不需要</td><td>签约审核基本要备案域名</td></tr>
|
||||||
|
<tr><td><strong>ICP 备案</strong></td><td class="no">不需要</td><td>基本绕不开(见第二节)</td></tr>
|
||||||
|
<tr><td><strong>HTTPS</strong></td><td class="no">不需要(localhost 即可)</td><td class="yes">需要(域名证书)</td></tr>
|
||||||
|
<tr><td><strong>能验证什么</strong></td><td>下单→跳收银台→扫码付→(查单兜底)全链路</td><td>真实到账 + 异步回调 notify</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>二、不用域名 = 不用备案?</h2>
|
||||||
|
<p>技术层面:<strong>不绑域名就不触发 ICP 备案</strong>(备案=域名+大陆服务器对外提供服务)。而且有个对支付宝有利的事实:</p>
|
||||||
|
<div class="callout ok">
|
||||||
|
<strong>支付宝电脑网站支付的 <code>notify_url</code> / <code>return_url</code> 支付宝不校验是否备案</strong>——只要能 POST 回调到你的地址就行。这点和微信不同(微信 JSAPI/H5 要在后台填「已备案授权域名」)。所以单从回调技术看,甚至能用服务器公网 IP 当 notify_url 把真实支付跑通。
|
||||||
|
</div>
|
||||||
|
<p>但「想正经收真钱」时,备案基本还是绕不开,卡在这三处:</p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th style="width:26%">卡点</th><th>说明</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><strong>商户签约审核</strong></td><td>申请生产「电脑网站支付」要提交<strong>网站地址</strong>,支付宝审核要求网站可正常访问且合规,实践中基本要备案域名(裸 IP 网站过审很难)</td></tr>
|
||||||
|
<tr><td><strong>HTTPS</strong></td><td>给纯 IP 签受信任 HTTPS 证书极难(Let's Encrypt 不给 IP 发证),无 HTTPS 收款页显示「不安全」</td></tr>
|
||||||
|
<tr><td><strong>微信支付</strong></td><td>微信必须填备案授权域名,完全绕不开</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="callout bad">
|
||||||
|
<strong>结论:</strong>沙箱阶段不用域名/备案/实名;真实收款备案是刚需。<strong>但你不必等备案</strong>就能先把支付宝沙箱联调全做完,备案/签约和联调可并行。
|
||||||
|
</div>
|
||||||
|
<div class="callout">
|
||||||
|
完全不备案也想收真钱的旁路:支付宝<strong>「当面付」(扫码)</strong>签约不依赖网站,或用<strong>收款链接 / 第三方托管收银台</strong>——但那不是自研网页收款页这条路。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>三、沙箱密钥获取(4 步 · 现在就能做)</h2>
|
||||||
|
<div class="step"><div class="n">1</div><div class="body">
|
||||||
|
<h3>进沙箱控制台</h3>
|
||||||
|
<p>支付宝账号登录 → <a href="https://open.alipay.com/develop/sandbox/app">https://open.alipay.com/develop/sandbox/app</a>。页面直接给你 <strong>沙箱 APPID</strong>(形如 <code>9021000xxxxxxxxx</code>)。</p>
|
||||||
|
</div></div>
|
||||||
|
<div class="step"><div class="n">2</div><div class="body">
|
||||||
|
<h3>选「公钥模式」+ 生成密钥</h3>
|
||||||
|
<p>加签方式选 <strong>公钥模式</strong>(对应代码 <code>LoadAliPayPublicKey</code>)。下载官方<a href="https://opendocs.alipay.com/common/02kipl">密钥生成工具</a>(有 Mac 版)→ 格式 <strong>PKCS8</strong>、长度 <strong>2048</strong> → 生成得到 <strong>应用私钥</strong> 和 <strong>应用公钥</strong>。</p>
|
||||||
|
</div></div>
|
||||||
|
<div class="step"><div class="n">3</div><div class="body">
|
||||||
|
<h3>上传应用公钥 → 换回支付宝公钥</h3>
|
||||||
|
<p>把<strong>应用公钥</strong>粘贴到沙箱「公钥模式」处保存,页面会生成一串 <strong>支付宝公钥</strong>。</p>
|
||||||
|
<div class="callout warn" style="margin:8px 0 0">别搞混:你<strong>上传</strong>的是「应用公钥」;要<strong>复制回配置</strong>的是支付宝生成的「支付宝公钥」。</div>
|
||||||
|
</div></div>
|
||||||
|
<div class="step"><div class="n">4</div><div class="body">
|
||||||
|
<h3>拿沙箱买家账号</h3>
|
||||||
|
<p>沙箱页「<strong>沙箱账号</strong>」标签提供买家账号(邮箱)+ 登录密码 + 支付密码(假钱)。扫码付需装<strong>沙箱版支付宝 App</strong>(同页二维码),或用沙箱网页登录买家付款。</p>
|
||||||
|
</div></div>
|
||||||
|
|
||||||
|
<h2>四、生产密钥申请(5 步 · 收真钱)</h2>
|
||||||
|
<div class="step"><div class="n">1</div><div class="body">
|
||||||
|
<h3>企业实名</h3>
|
||||||
|
<p>登录 <a href="https://open.alipay.com">open.alipay.com</a> → 用企业支付宝账号完成企业实名认证(营业执照、法人/对公信息)。个体户用对应类型亦可。</p>
|
||||||
|
</div></div>
|
||||||
|
<div class="step"><div class="n">2</div><div class="body">
|
||||||
|
<h3>创建生产应用</h3>
|
||||||
|
<p>控制台 → 创建应用 → 选「<strong>网页 & 移动应用</strong>」→ 得到 <strong>正式 APPID</strong>。</p>
|
||||||
|
</div></div>
|
||||||
|
<div class="step"><div class="n">3</div><div class="body">
|
||||||
|
<h3>签约「电脑网站支付」</h3>
|
||||||
|
<p>应用里「添加能力/产品签约」→ 签约「<strong>电脑网站支付</strong>」(对应 <code>alipay.trade.page.pay</code>)。需提交企业资质、<strong>网站地址(基本要备案域名)</strong>、经营信息。审核约 1–3 个工作日。</p>
|
||||||
|
</div></div>
|
||||||
|
<div class="step"><div class="n">4</div><div class="body">
|
||||||
|
<h3>配置生产密钥(同沙箱操作)</h3>
|
||||||
|
<p>密钥工具生成应用私钥/公钥 → 应用「接口加签方式 → 公钥模式」上传应用公钥 → 复制返回的「支付宝公钥」。</p>
|
||||||
|
</div></div>
|
||||||
|
<div class="step"><div class="n">5</div><div class="body">
|
||||||
|
<h3>切到生产</h3>
|
||||||
|
<p>把正式 APPID/应用私钥/支付宝公钥写入商户配置,<code>production: true</code>(代码走正式网关),<code>base_url</code> 用正式 HTTPS 备案域名。</p>
|
||||||
|
</div></div>
|
||||||
|
|
||||||
|
<h2>五、三样东西 → 配置对应</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th style="width:24%">拿到的</th><th style="width:34%">来源</th><th>填到哪</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>APPID</td><td>沙箱页面 / 生产应用页直接给</td><td><code>config.yaml → alipay_sandbox.app_id</code>(或 merchants 表)</td></tr>
|
||||||
|
<tr><td>应用私钥</td><td>你用密钥工具生成,自己保管</td><td>环境变量 <code>ALIPAY_APP_PRIVATE_KEY</code></td></tr>
|
||||||
|
<tr><td>支付宝公钥</td><td>上传应用公钥后支付宝生成</td><td>环境变量 <code>ALIPAY_PUBLIC_KEY</code></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<pre><code>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 选套餐 → 跳沙箱收银台 → 沙箱买家账号付款</code></pre>
|
||||||
|
<div class="callout warn">
|
||||||
|
本机能测「下单 → 跳收银台 → 扫码付成功」整条链路;唯一测不到的是<strong>异步回调 notify</strong>(支付宝回调不到本机)。但服务内置的 <code>query_sync</code> 主动查单兜底会在 30 秒内把订单补成「已支付」,结果页照样变 ✅。要完整验证 notify 需内网穿透或部署到公网服务器。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>六、推进路线(两线并行,互不等待)</h2>
|
||||||
|
<svg viewBox="0 0 900 230" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system,PingFang SC,sans-serif" font-size="13">
|
||||||
|
<defs><marker id="ar" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto"><path d="M0,0 L9,3 L0,6 Z" fill="#58a6ff"/></marker></defs>
|
||||||
|
<!-- line A -->
|
||||||
|
<text x="20" y="46" fill="#3fb950" font-weight="600">A · 联调线(现在)</text>
|
||||||
|
<rect x="20" y="58" width="170" height="44" rx="8" fill="#161b22" stroke="#3fb950"/><text x="105" y="84" fill="#e6edf3" text-anchor="middle">拿沙箱密钥</text>
|
||||||
|
<line x1="190" y1="80" x2="232" y2="80" stroke="#58a6ff" stroke-width="2" marker-end="url(#ar)"/>
|
||||||
|
<rect x="234" y="58" width="200" height="44" rx="8" fill="#161b22" stroke="#3fb950"/><text x="334" y="84" fill="#e6edf3" text-anchor="middle">本机跑通支付宝沙箱</text>
|
||||||
|
<line x1="434" y1="80" x2="476" y2="80" stroke="#58a6ff" stroke-width="2" marker-end="url(#ar)"/>
|
||||||
|
<rect x="478" y="58" width="200" height="44" rx="8" fill="#161b22" stroke="#283041"/><text x="578" y="84" fill="#aeb9c7" text-anchor="middle">代码就绪 · 等生产</text>
|
||||||
|
<!-- line B -->
|
||||||
|
<text x="20" y="150" fill="#d29922" font-weight="600">B · 上线线(并行)</text>
|
||||||
|
<rect x="20" y="162" width="150" height="44" rx="8" fill="#161b22" stroke="#d29922"/><text x="95" y="188" fill="#e6edf3" text-anchor="middle">域名转入</text>
|
||||||
|
<line x1="170" y1="184" x2="208" y2="184" stroke="#58a6ff" stroke-width="2" marker-end="url(#ar)"/>
|
||||||
|
<rect x="210" y="162" width="120" height="44" rx="8" fill="#161b22" stroke="#d29922"/><text x="270" y="188" fill="#e6edf3" text-anchor="middle">ICP 备案</text>
|
||||||
|
<line x1="330" y1="184" x2="368" y2="184" stroke="#58a6ff" stroke-width="2" marker-end="url(#ar)"/>
|
||||||
|
<rect x="370" y="162" width="170" height="44" rx="8" fill="#161b22" stroke="#d29922"/><text x="455" y="188" fill="#e6edf3" text-anchor="middle">企业实名+签约</text>
|
||||||
|
<line x1="540" y1="184" x2="578" y2="184" stroke="#58a6ff" stroke-width="2" marker-end="url(#ar)"/>
|
||||||
|
<rect x="580" y="162" width="150" height="44" rx="8" fill="#161b22" stroke="#d29922"/><text x="655" y="188" fill="#e6edf3" text-anchor="middle">生产密钥</text>
|
||||||
|
<!-- merge -->
|
||||||
|
<line x1="655" y1="102" x2="655" y2="160" stroke="#7d8896" stroke-width="1.5" stroke-dasharray="4 4"/>
|
||||||
|
<rect x="740" y="110" width="140" height="46" rx="8" fill="#1c2330" stroke="#58a6ff"/><text x="810" y="130" fill="#e6edf3" text-anchor="middle" font-weight="600">换密钥+域名</text><text x="810" y="146" fill="#58a6ff" text-anchor="middle">→ 上线收真钱</text>
|
||||||
|
<line x1="678" y1="80" x2="738" y2="120" stroke="#58a6ff" stroke-width="2" marker-end="url(#ar)"/>
|
||||||
|
<line x1="730" y1="184" x2="800" y2="158" stroke="#58a6ff" stroke-width="2" marker-end="url(#ar)"/>
|
||||||
|
</svg>
|
||||||
|
<p>两条线独立推进:A 线现在就能把代码全跑通,B 线(域名/备案/签约)慢慢走;B 线齐了,只需「换生产密钥 + 换备案域名」即可上线,主流程一行不改(<code>Merchant.Production</code> + <code>base_url</code>)。</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>支付宝收款页 · 支付时序图与架构</title>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#0d1117;--card:#161b22;--card-2:#1c2330;--border:#283041;--fg:#e6edf3;--fg-soft:#aeb9c7;--muted:#7d8896;--accent:#58a6ff;--green:#3fb950;--orange:#d29922;--radius:14px}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;background:radial-gradient(1200px 600px at 80% -10%,rgba(88,166,255,.08),transparent 60%),var(--bg);color:var(--fg);font:15px/1.7 -apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;-webkit-font-smoothing:antialiased}
|
||||||
|
.wrap{max-width:1040px;margin:0 auto;padding:44px 22px 90px}
|
||||||
|
.eyebrow{color:var(--accent);font-weight:600;letter-spacing:.12em;font-size:12px;text-transform:uppercase}
|
||||||
|
h1{font-size:29px;margin:10px 0 8px}
|
||||||
|
.lead{color:var(--fg-soft);margin:0 0 26px}
|
||||||
|
h2{font-size:19px;margin:38px 0 12px;color:var(--accent);border-bottom:1px solid var(--border);padding-bottom:8px}
|
||||||
|
h3{font-size:16px;margin:24px 0 8px;color:var(--fg)}
|
||||||
|
p{color:var(--fg-soft)}
|
||||||
|
.card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:20px 22px;margin:16px 0}
|
||||||
|
.diagram{overflow-x:auto;background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:18px}
|
||||||
|
svg{display:block;margin:0 auto;min-width:920px}
|
||||||
|
table{width:100%;border-collapse:collapse;margin:14px 0;font-size:14px}
|
||||||
|
th,td{border:1px solid var(--border);padding:9px 11px;text-align:left;vertical-align:top}
|
||||||
|
th{background:var(--card-2);color:var(--fg);font-weight:600}
|
||||||
|
td{color:var(--fg-soft)}
|
||||||
|
code{background:var(--card-2);border:1px solid var(--border);border-radius:5px;padding:1px 6px;font-size:13px;color:#e6edf3;font-family:"SF Mono",Menlo,Consolas,monospace}
|
||||||
|
.tag{display:inline-block;font-size:12px;padding:1px 8px;border-radius:20px;border:1px solid var(--border);background:var(--card-2);color:var(--fg-soft);margin-right:4px}
|
||||||
|
.tag.must{color:var(--green);border-color:rgba(63,185,80,.4)}
|
||||||
|
.tag.opt{color:var(--orange);border-color:rgba(210,153,34,.4)}
|
||||||
|
.legend{display:flex;flex-wrap:wrap;gap:16px;margin-top:14px;font-size:13px;color:var(--fg-soft)}
|
||||||
|
.legend span{display:inline-flex;align-items:center;gap:7px}
|
||||||
|
.lg-line{width:30px;height:0;border-top:2px solid var(--accent)}
|
||||||
|
.lg-line.dash{border-top-style:dashed;border-color:var(--muted)}
|
||||||
|
.lg-line.dot{border-top-style:dotted;border-color:var(--orange)}
|
||||||
|
.lg-box{width:14px;height:14px;border-radius:3px;background:var(--card-2);border:1px solid var(--border)}
|
||||||
|
.callout{border-left:3px solid var(--accent);background:var(--card-2);border-radius:0 8px 8px 0;padding:12px 16px;margin:16px 0;color:var(--fg-soft)}
|
||||||
|
.callout.warn{border-left-color:var(--orange)}
|
||||||
|
.callout.ok{border-left-color:var(--green)}
|
||||||
|
.cols{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin:16px 0}
|
||||||
|
.cols .card{margin:0}
|
||||||
|
.cols h3{margin-top:0}
|
||||||
|
ul{color:var(--fg-soft);padding-left:20px}
|
||||||
|
.pill{font-size:12px;font-weight:600;padding:2px 9px;border-radius:6px}
|
||||||
|
.pill.a{background:rgba(88,166,255,.15);color:var(--accent)}
|
||||||
|
.pill.b{background:rgba(63,185,80,.15);color:var(--green)}
|
||||||
|
@media(max-width:760px){.cols{grid-template-columns:1fr}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="eyebrow">Payment · Design</div>
|
||||||
|
<h1>支付宝收款页 · 支付时序图与架构</h1>
|
||||||
|
<p class="lead">PC 网页支付(<code>alipay.trade.page.pay</code>)· 固定套餐 · Go(Gin)+GORM · 沙箱联调阶段。本文说清楚一笔支付的完整时序、「独立服务」与「集成进 jiu」的区别,以及独立服务要不要建库、存哪些数据。</p>
|
||||||
|
|
||||||
|
<h2>一、一笔支付的完整时序</h2>
|
||||||
|
<div class="diagram">
|
||||||
|
<svg viewBox="0 0 1000 700" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system,PingFang SC,sans-serif">
|
||||||
|
<defs>
|
||||||
|
<marker id="arrowBlue" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="userSpaceOnUse">
|
||||||
|
<path d="M0,0 L9,3 L0,6 Z" fill="#58a6ff"/>
|
||||||
|
</marker>
|
||||||
|
<marker id="arrowMuted" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="userSpaceOnUse">
|
||||||
|
<path d="M0,0 L9,3 L0,6 Z" fill="#7d8896"/>
|
||||||
|
</marker>
|
||||||
|
<marker id="arrowOrange" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="userSpaceOnUse">
|
||||||
|
<path d="M0,0 L9,3 L0,6 Z" fill="#d29922"/>
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- lifelines -->
|
||||||
|
<g stroke="#283041" stroke-width="1.5" stroke-dasharray="4 5">
|
||||||
|
<line x1="140" y1="92" x2="140" y2="682"/>
|
||||||
|
<line x1="400" y1="92" x2="400" y2="682"/>
|
||||||
|
<line x1="660" y1="92" x2="660" y2="682"/>
|
||||||
|
<line x1="900" y1="92" x2="900" y2="682"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- activation bars -->
|
||||||
|
<rect x="394" y="124" width="12" height="518" rx="2" fill="#1c2330" stroke="#283041"/>
|
||||||
|
<rect x="654" y="216" width="12" height="340" rx="2" fill="#1c2330" stroke="#283041"/>
|
||||||
|
|
||||||
|
<!-- actor boxes -->
|
||||||
|
<g font-size="13" font-weight="600" text-anchor="middle">
|
||||||
|
<rect x="65" y="44" width="150" height="46" rx="9" fill="#1c2330" stroke="#58a6ff"/>
|
||||||
|
<text x="140" y="66" fill="#e6edf3">客户浏览器</text>
|
||||||
|
<text x="140" y="82" fill="#7d8896" font-size="11" font-weight="400">收款页</text>
|
||||||
|
|
||||||
|
<rect x="325" y="44" width="150" height="46" rx="9" fill="#1c2330" stroke="#58a6ff"/>
|
||||||
|
<text x="400" y="66" fill="#e6edf3">收款服务后端</text>
|
||||||
|
<text x="400" y="82" fill="#7d8896" font-size="11" font-weight="400">Go · Gin · GORM</text>
|
||||||
|
|
||||||
|
<rect x="585" y="44" width="150" height="46" rx="9" fill="#1c2330" stroke="#3fb950"/>
|
||||||
|
<text x="660" y="66" fill="#e6edf3">支付宝网关</text>
|
||||||
|
<text x="660" y="82" fill="#7d8896" font-size="11" font-weight="400">收银台 / 服务端</text>
|
||||||
|
|
||||||
|
<rect x="825" y="44" width="150" height="46" rx="9" fill="#1c2330" stroke="#3fb950"/>
|
||||||
|
<text x="900" y="66" fill="#e6edf3">客户手机</text>
|
||||||
|
<text x="900" y="82" fill="#7d8896" font-size="11" font-weight="400">支付宝 App</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- messages -->
|
||||||
|
<g font-size="12.5">
|
||||||
|
<!-- 1 -->
|
||||||
|
<line x1="140" y1="130" x2="398" y2="130" stroke="#58a6ff" stroke-width="2" marker-end="url(#arrowBlue)"/>
|
||||||
|
<text x="269" y="124" fill="#e6edf3" text-anchor="middle">① POST /order/create(选定套餐)</text>
|
||||||
|
|
||||||
|
<!-- note B1 -->
|
||||||
|
<rect x="290" y="150" width="244" height="30" rx="6" fill="#161b22" stroke="#283041"/>
|
||||||
|
<text x="412" y="170" fill="#aeb9c7" font-size="11.5" text-anchor="middle">生成 out_trade_no · 订单落库(待支付)</text>
|
||||||
|
|
||||||
|
<!-- 2 -->
|
||||||
|
<line x1="406" y1="222" x2="658" y2="222" stroke="#58a6ff" stroke-width="2" marker-end="url(#arrowBlue)"/>
|
||||||
|
<text x="532" y="216" fill="#e6edf3" text-anchor="middle">② alipay.trade.page.pay(RSA2 加签下单)</text>
|
||||||
|
|
||||||
|
<!-- 2r -->
|
||||||
|
<line x1="654" y1="262" x2="408" y2="262" stroke="#7d8896" stroke-width="1.8" stroke-dasharray="5 4" marker-end="url(#arrowMuted)"/>
|
||||||
|
<text x="532" y="256" fill="#aeb9c7" text-anchor="middle">返回收银台跳转 URL / 表单</text>
|
||||||
|
|
||||||
|
<!-- 3 -->
|
||||||
|
<line x1="394" y1="300" x2="142" y2="300" stroke="#7d8896" stroke-width="1.8" stroke-dasharray="5 4" marker-end="url(#arrowMuted)"/>
|
||||||
|
<text x="269" y="294" fill="#aeb9c7" text-anchor="middle">③ 返回跳转链接给浏览器</text>
|
||||||
|
|
||||||
|
<!-- 4 -->
|
||||||
|
<line x1="140" y1="338" x2="652" y2="338" stroke="#58a6ff" stroke-width="2" marker-end="url(#arrowBlue)"/>
|
||||||
|
<text x="396" y="332" fill="#e6edf3" text-anchor="middle">④ 跳转支付宝收银台 · 展示付款二维码</text>
|
||||||
|
|
||||||
|
<!-- 5 -->
|
||||||
|
<line x1="900" y1="378" x2="668" y2="378" stroke="#3fb950" stroke-width="2" marker-end="url(#arrowMuted)"/>
|
||||||
|
<text x="784" y="372" fill="#e6edf3" text-anchor="middle">⑤ 扫码 · 确认付款</text>
|
||||||
|
|
||||||
|
<!-- 6 -->
|
||||||
|
<line x1="660" y1="418" x2="408" y2="418" stroke="#3fb950" stroke-width="2" marker-end="url(#arrowMuted)"/>
|
||||||
|
<text x="534" y="412" fill="#e6edf3" text-anchor="middle">⑥ 异步通知 notify_url(POST)</text>
|
||||||
|
|
||||||
|
<!-- note B2 -->
|
||||||
|
<rect x="300" y="438" width="300" height="48" rx="6" fill="#161b22" stroke="#3fb950"/>
|
||||||
|
<text x="450" y="456" fill="#e6edf3" font-size="11.5" text-anchor="middle">验签 + 核对金额 + 核对订单号</text>
|
||||||
|
<text x="450" y="473" fill="#aeb9c7" font-size="11.5" text-anchor="middle">+ 防重复通知 → 更新订单为「已支付」</text>
|
||||||
|
|
||||||
|
<!-- 6r -->
|
||||||
|
<line x1="406" y1="512" x2="658" y2="512" stroke="#7d8896" stroke-width="1.8" stroke-dasharray="5 4" marker-end="url(#arrowMuted)"/>
|
||||||
|
<text x="532" y="506" fill="#aeb9c7" text-anchor="middle">应答 "success"(告知已处理,否则会重发)</text>
|
||||||
|
|
||||||
|
<!-- 7 -->
|
||||||
|
<line x1="660" y1="550" x2="142" y2="550" stroke="#3fb950" stroke-width="2" marker-end="url(#arrowMuted)"/>
|
||||||
|
<text x="396" y="544" fill="#e6edf3" text-anchor="middle">⑦ 浏览器同步跳转 return_url</text>
|
||||||
|
|
||||||
|
<!-- note A -->
|
||||||
|
<rect x="58" y="572" width="200" height="30" rx="6" fill="#161b22" stroke="#283041"/>
|
||||||
|
<text x="158" y="592" fill="#aeb9c7" font-size="11.5" text-anchor="middle">展示「支付成功」结果页</text>
|
||||||
|
|
||||||
|
<!-- 8 -->
|
||||||
|
<line x1="406" y1="640" x2="658" y2="640" stroke="#d29922" stroke-width="1.8" stroke-dasharray="2 3" marker-end="url(#arrowOrange)"/>
|
||||||
|
<text x="532" y="634" fill="#d29922" text-anchor="middle">⑧(兜底)alipay.trade.query 主动查单对账</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="legend">
|
||||||
|
<span><i class="lg-line"></i> 请求(同步调用)</span>
|
||||||
|
<span><i class="lg-line dash"></i> 应答 / 跳转返回</span>
|
||||||
|
<span><i class="lg-line dot"></i> 兜底主动查单</span>
|
||||||
|
<span><i class="lg-box"></i> 后端处理 / 备注</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="callout warn">
|
||||||
|
<strong>两条“到账”路径,以异步为准:</strong>⑦ 同步跳转(return_url)只是把用户带回页面,<strong>不能作为到账依据</strong>(用户可能中途关页面)。真正确认收款的是 ⑥ 异步通知(notify_url)。两条都可能丢,所以 ⑧ 用 <code>alipay.trade.query</code> 主动查单兜底。<strong>订单状态以「⑥验签通过 / ⑧查单成功」为准。</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>二、整体架构(沙箱阶段)</h2>
|
||||||
|
<div class="card">
|
||||||
|
<p style="margin-top:0">三个角色、五个核心接口,全程不需要客户登录:</p>
|
||||||
|
<ul>
|
||||||
|
<li><span class="tag must">必做</span><strong>收款页</strong>:静态 HTML,列固定套餐 → 选一个 → 调下单接口 → 跳转支付宝。</li>
|
||||||
|
<li><span class="tag must">必做</span><strong>POST /order/create</strong>:生成 <code>out_trade_no</code>、订单落库(待支付)、调 <code>alipay.trade.page.pay</code> 加签、返回跳转链接。</li>
|
||||||
|
<li><span class="tag must">必做</span><strong>POST /alipay/notify</strong>:异步回调,验签 + 核对金额/订单号 + 幂等防重 → 更新订单为已支付。<strong>支付安全的核心。</strong></li>
|
||||||
|
<li><span class="tag must">必做</span><strong>GET /order/return</strong>:同步跳转回的结果页(仅展示,不作为到账依据)。</li>
|
||||||
|
<li><span class="tag opt">建议</span><strong>查单兜底</strong>:<code>alipay.trade.query</code> 对待支付订单轮询,防回调丢失。</li>
|
||||||
|
<li><span class="tag opt">可选</span><strong>商家后台</strong>:看订单/对账列表 —— <em>唯一需要登录鉴权的部分</em>。</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>三、独立服务 vs 集成进 jiu</h2>
|
||||||
|
<p>两种落地方式,差别核心就一句话:<strong>要不要复用 jiu 已有的数据库和 JWT 鉴权后台。</strong></p>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead><tr><th style="width:22%"></th><th><span class="pill a">独立服务</span>(ai 下新建)</th><th><span class="pill b">集成进 jiu</span>(jiu/backend 加模块)</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><strong>代码位置</strong></td><td>/Users/wangjia/code/ai 下新建 <code>pay/</code>,独立 Go 进程</td><td>jiu/backend 里加 <code>handler/service/model/router</code></td></tr>
|
||||||
|
<tr><td><strong>数据库</strong></td><td>自建一套(SQLite 单文件起步即可,或 MySQL)</td><td>直接复用 jiu 的 GORM/MySQL,订单表加进去</td></tr>
|
||||||
|
<tr><td><strong>鉴权后台</strong></td><td>要自己做登录鉴权(或第一版不做后台)</td><td><strong>白送</strong>——复用 jiu 的 JWT 中间件</td></tr>
|
||||||
|
<tr><td><strong>部署</strong></td><td>单独一个二进制 + systemd,独立上线</td><td>跟 jiu 一起构建/部署,绑在一条流水线</td></tr>
|
||||||
|
<tr><td><strong>耦合度</strong></td><td>与 jiu 完全解耦,可给任意业务(如 yanmei)用</td><td>与 jiu 强耦合,jiu 是进销存系统,收款无关会让它变臃肿</td></tr>
|
||||||
|
<tr><td><strong>适合</strong></td><td>收款是<strong>独立业务</strong>、或要给多个项目复用</td><td>收款<strong>本就是 jiu 业务的一环</strong>(订单/客户都在 jiu 里)</td></tr>
|
||||||
|
<tr><td><strong>开发量</strong></td><td>核心一致,多了「建库 + 鉴权(若要后台)」</td><td>核心一致,省掉建库和鉴权</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="callout">
|
||||||
|
<strong>判断方法:</strong>收款数据要不要跟 jiu 的客户/订单/库存打通?<br>
|
||||||
|
· 要打通 → <span class="pill b">集成进 jiu</span>,省事且数据一体。<br>
|
||||||
|
· 不相干(给 yanmei 等独立收款)→ <span class="pill a">独立服务</span>,第一版连后台都不做,最快跑通沙箱。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>四、独立服务需要建数据库吗?</h2>
|
||||||
|
<div class="callout ok"><strong>需要。哪怕一张表也得有。</strong>SQLite 单文件起步就够(零运维),后面要并发/多实例再换 MySQL。</div>
|
||||||
|
<p>不能「只下单不落库」,原因是支付的钱和安全全靠订单记录兜底:</p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th style="width:30%">为什么必须存</th><th>不存会怎样</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><strong>回调核对金额防篡改</strong></td><td>⑥ 收到通知时要拿订单原始金额比对,没存就无法判断金额是否被改 → 可被刷单</td></tr>
|
||||||
|
<tr><td><strong>幂等防重复入账</strong></td><td>支付宝会重发通知,没有订单状态记录就会重复发货/重复确认</td></tr>
|
||||||
|
<tr><td><strong>查单对账兜底</strong></td><td>⑧ 主动查单需要 <code>out_trade_no</code>,没存就无单可查,回调一丢就丢钱</td></tr>
|
||||||
|
<tr><td><strong>给客户/商家看结果</strong></td><td>结果页、订单列表、退款都要读订单,无库无从谈起</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>五、存哪些数据</h2>
|
||||||
|
<h3>① 订单表 <code>orders</code>(核心,必建)</h3>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th style="width:22%">字段</th><th style="width:18%">类型</th><th>说明</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>id</code></td><td>uint PK</td><td>自增主键</td></tr>
|
||||||
|
<tr><td><code>out_trade_no</code></td><td>varchar · 唯一</td><td>商户订单号(我们生成,全局唯一),贯穿下单/回调/查单</td></tr>
|
||||||
|
<tr><td><code>subject</code></td><td>varchar</td><td>套餐/商品名称(展示在收银台)</td></tr>
|
||||||
|
<tr><td><code>amount</code></td><td>decimal(10,2)</td><td>订单金额(元),<strong>回调时用它核对防篡改</strong></td></tr>
|
||||||
|
<tr><td><code>status</code></td><td>varchar / enum</td><td>待支付 / 已支付 / 已关闭 / 已退款</td></tr>
|
||||||
|
<tr><td><code>trade_no</code></td><td>varchar</td><td>支付宝交易号(回调/查单回填)</td></tr>
|
||||||
|
<tr><td><code>buyer_logon_id</code></td><td>varchar</td><td>买家支付宝账号(可选,回调带回,脱敏存)</td></tr>
|
||||||
|
<tr><td><code>paid_at</code></td><td>datetime</td><td>支付完成时间(回调回填)</td></tr>
|
||||||
|
<tr><td><code>product_id</code></td><td>uint</td><td>指向套餐表(固定套餐场景)</td></tr>
|
||||||
|
<tr><td><code>client_ip</code> / <code>remark</code></td><td>varchar</td><td>下单来源 IP、备注(可选)</td></tr>
|
||||||
|
<tr><td><code>created_at</code> / <code>updated_at</code></td><td>datetime</td><td>GORM 自动维护</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>② 套餐表 <code>products</code>(固定套餐场景,建议建)</h3>
|
||||||
|
<p style="margin-top:0">固定商品/套餐就是从这张表来,避免金额写死在前端被篡改 —— <strong>下单时金额一律以服务端这张表为准,不信任前端传来的价格。</strong></p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th style="width:22%">字段</th><th style="width:18%">类型</th><th>说明</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>id</code></td><td>uint PK</td><td>套餐 ID(前端只传这个)</td></tr>
|
||||||
|
<tr><td><code>name</code></td><td>varchar</td><td>套餐名(如「基础版 / 年付」)</td></tr>
|
||||||
|
<tr><td><code>price</code></td><td>decimal(10,2)</td><td>价格(元),<strong>服务端权威价格</strong></td></tr>
|
||||||
|
<tr><td><code>description</code></td><td>varchar</td><td>套餐说明</td></tr>
|
||||||
|
<tr><td><code>active</code></td><td>bool</td><td>是否上架</td></tr>
|
||||||
|
<tr><td><code>sort</code></td><td>int</td><td>展示排序</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>③ 回调日志 <code>notify_logs</code>(可选,建议)</h3>
|
||||||
|
<p style="margin-top:0">把每条异步通知的原始报文、验签结果、处理结果存一份,方便排查对账纠纷与审计。非必须,但出问题时极其有用。</p>
|
||||||
|
|
||||||
|
<div class="callout warn">
|
||||||
|
<strong>安全红线(无论独立还是集成都一样):</strong><br>
|
||||||
|
1. 金额、套餐价格<strong>一律以服务端 DB 为准</strong>,绝不信任前端传值;<br>
|
||||||
|
2. 回调 <strong>必须验签</strong>(支付宝公钥)+ 核对 <code>out_trade_no</code> 与 <code>total_amount</code>;<br>
|
||||||
|
3. 回调<strong>幂等</strong>:同一 <code>trade_no</code> 只处理一次;<br>
|
||||||
|
4. 处理成功才返回 <code>success</code>,否则支付宝会按策略重发。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>六、结论与下一步</h2>
|
||||||
|
<ul>
|
||||||
|
<li>时序固定:<strong>下单→跳收银台→扫码付→异步回调验签入账→(兜底查单)</strong>,到账以异步回调为准。</li>
|
||||||
|
<li><strong>独立服务必须建库</strong>,最少 <code>orders</code>(+ 固定套餐再加 <code>products</code>),SQLite 起步零运维。</li>
|
||||||
|
<li>独立 vs 集成的分水岭:<strong>收款数据要不要跟 jiu 打通</strong>、要不要复用 jiu 的鉴权后台。</li>
|
||||||
|
</ul>
|
||||||
|
<p>定了「独立 / 集成」后,我就把它写成正式设计文档 + 实现计划,照 jiu 的 Go(Gin)+GORM 约定搭骨架,用支付宝沙箱跑通全链路。</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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:"-"`
|
||||||
|
}
|
||||||
@@ -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"` // 原始表单报文
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewOutTradeNo 生成全局唯一的商户订单号:<code>-<时间>-<随机>,控制在 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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>收款</title>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#0d1117;--card:#161b22;--card-2:#1c2330;--border:#283041;--fg:#e6edf3;--fg-soft:#aeb9c7;--muted:#7d8896;--accent:#58a6ff;--green:#3fb950;--radius:14px}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;min-height:100vh;background:radial-gradient(1000px 500px at 80% -10%,rgba(88,166,255,.08),transparent 60%),var(--bg);color:var(--fg);font:15px/1.6 -apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;display:flex;align-items:center;justify-content:center;padding:24px}
|
||||||
|
.box{width:100%;max-width:420px}
|
||||||
|
h1{font-size:22px;margin:0 0 4px;text-align:center}
|
||||||
|
.sub{color:var(--muted);font-size:13px;text-align:center;margin-bottom:22px}
|
||||||
|
.item{display:flex;justify-content:space-between;align-items:center;gap:12px;background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:16px 18px;margin:12px 0;cursor:pointer;transition:.15s}
|
||||||
|
.item:hover{border-color:var(--accent);background:var(--card-2)}
|
||||||
|
.item.sel{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent) inset}
|
||||||
|
.item .name{font-weight:600}
|
||||||
|
.item .desc{color:var(--fg-soft);font-size:13px;margin-top:2px}
|
||||||
|
.item .price{color:var(--green);font-size:20px;font-weight:700;white-space:nowrap}
|
||||||
|
.item .price small{font-size:12px;color:var(--muted);font-weight:400}
|
||||||
|
button{width:100%;margin-top:18px;padding:14px;border:0;border-radius:12px;background:var(--accent);color:#04122b;font-size:16px;font-weight:700;cursor:pointer;transition:.15s}
|
||||||
|
button:disabled{opacity:.5;cursor:not-allowed}
|
||||||
|
.msg{margin-top:14px;text-align:center;font-size:13px;color:var(--muted);min-height:18px}
|
||||||
|
.msg.err{color:#f85149}
|
||||||
|
.hint{margin-top:16px;font-size:12.5px;color:var(--muted);text-align:center;line-height:1.7}
|
||||||
|
.hint code{background:var(--card-2);border:1px solid var(--border);border-radius:4px;padding:1px 5px;color:var(--fg-soft)}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="box">
|
||||||
|
<h1>选择套餐</h1>
|
||||||
|
<div class="sub">支付宝支付 · 沙箱联调</div>
|
||||||
|
<div id="list"></div>
|
||||||
|
<button id="pay" disabled>请选择套餐</button>
|
||||||
|
<div class="msg" id="msg"></div>
|
||||||
|
<div class="hint">
|
||||||
|
跳转到支付宝收银台后,用<strong>右边「登录支付宝账户付款」</strong>:<br>
|
||||||
|
买家账号 <code>vtlvff1163@sandbox.com</code> · 支付密码 <code>111111</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
let selected = null;
|
||||||
|
const listEl = document.getElementById('list');
|
||||||
|
const payBtn = document.getElementById('pay');
|
||||||
|
const msgEl = document.getElementById('msg');
|
||||||
|
|
||||||
|
function setMsg(t, err){ msgEl.textContent = t || ''; msgEl.classList.toggle('err', !!err); }
|
||||||
|
|
||||||
|
async function load(){
|
||||||
|
try{
|
||||||
|
const r = await fetch('/api/v1/products');
|
||||||
|
const j = await r.json();
|
||||||
|
const items = j.data || [];
|
||||||
|
if(!items.length){ listEl.innerHTML = '<div class="sub">暂无可购套餐</div>'; return; }
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
items.forEach(p => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'item';
|
||||||
|
el.innerHTML = `<div><div class="name">${p.name}</div><div class="desc">${p.description||''}</div></div>
|
||||||
|
<div class="price">¥${p.price}<small> 元</small></div>`;
|
||||||
|
el.onclick = () => {
|
||||||
|
document.querySelectorAll('.item').forEach(x=>x.classList.remove('sel'));
|
||||||
|
el.classList.add('sel');
|
||||||
|
selected = p;
|
||||||
|
payBtn.disabled = false;
|
||||||
|
payBtn.textContent = `支付 ¥${p.price}`;
|
||||||
|
};
|
||||||
|
listEl.appendChild(el);
|
||||||
|
});
|
||||||
|
}catch(e){ setMsg('加载套餐失败:'+e, true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
payBtn.onclick = async () => {
|
||||||
|
if(!selected) return;
|
||||||
|
payBtn.disabled = true; setMsg('正在创建订单…');
|
||||||
|
try{
|
||||||
|
const r = await fetch('/api/v1/orders', {
|
||||||
|
method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({product_id: selected.id})
|
||||||
|
});
|
||||||
|
const j = await r.json();
|
||||||
|
if(!r.ok){ setMsg(j.message || '下单失败', true); payBtn.disabled=false; return; }
|
||||||
|
setMsg('正在跳转支付宝收银台…');
|
||||||
|
window.location.href = j.data.pay_url;
|
||||||
|
}catch(e){ setMsg('下单异常:'+e, true); payBtn.disabled=false; }
|
||||||
|
};
|
||||||
|
|
||||||
|
load();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>支付结果</title>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#0d1117;--card:#161b22;--border:#283041;--fg:#e6edf3;--fg-soft:#aeb9c7;--muted:#7d8896;--accent:#58a6ff;--green:#3fb950;--orange:#d29922;--radius:14px}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;min-height:100vh;background:radial-gradient(1000px 500px at 80% -10%,rgba(88,166,255,.08),transparent 60%),var(--bg);color:var(--fg);font:15px/1.6 -apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;display:flex;align-items:center;justify-content:center;padding:24px}
|
||||||
|
.card{width:100%;max-width:420px;background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:30px 26px;text-align:center}
|
||||||
|
.icon{font-size:52px;line-height:1;margin-bottom:10px}
|
||||||
|
h1{font-size:21px;margin:6px 0 16px}
|
||||||
|
.row{display:flex;justify-content:space-between;padding:9px 0;border-top:1px solid var(--border);font-size:14px}
|
||||||
|
.row .k{color:var(--muted)}
|
||||||
|
.row .v{color:var(--fg-soft);word-break:break-all;text-align:right;max-width:60%}
|
||||||
|
.paid{color:var(--green)} .pending{color:var(--orange)}
|
||||||
|
a{display:inline-block;margin-top:20px;color:var(--accent);text-decoration:none;font-size:14px}
|
||||||
|
.hint{color:var(--muted);font-size:12.5px;margin-top:8px}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="icon" id="icon">⏳</div>
|
||||||
|
<h1 id="title">正在确认支付结果…</h1>
|
||||||
|
<div id="detail"></div>
|
||||||
|
<div class="hint" id="hint">到账以异步通知为准,正在轮询…</div>
|
||||||
|
<a href="/">← 返回收款页</a>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const out = params.get('out_trade_no');
|
||||||
|
const iconEl = document.getElementById('icon');
|
||||||
|
const titleEl = document.getElementById('title');
|
||||||
|
const detailEl = document.getElementById('detail');
|
||||||
|
const hintEl = document.getElementById('hint');
|
||||||
|
|
||||||
|
function render(o){
|
||||||
|
const paid = o.status === 'paid';
|
||||||
|
iconEl.textContent = paid ? '✅' : '⏳';
|
||||||
|
titleEl.textContent = paid ? '支付成功' : '等待支付确认';
|
||||||
|
titleEl.className = paid ? 'paid' : 'pending';
|
||||||
|
detailEl.innerHTML = `
|
||||||
|
<div class="row"><span class="k">商品</span><span class="v">${o.subject||'-'}</span></div>
|
||||||
|
<div class="row"><span class="k">金额</span><span class="v">¥${o.amount}</span></div>
|
||||||
|
<div class="row"><span class="k">状态</span><span class="v ${paid?'paid':'pending'}">${paid?'已支付':'待支付'}</span></div>
|
||||||
|
<div class="row"><span class="k">订单号</span><span class="v">${o.out_trade_no}</span></div>
|
||||||
|
${o.trade_no?`<div class="row"><span class="k">支付宝流水</span><span class="v">${o.trade_no}</span></div>`:''}`;
|
||||||
|
return paid;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tries = 0;
|
||||||
|
async function poll(){
|
||||||
|
if(!out){ titleEl.textContent='缺少订单号'; iconEl.textContent='⚠️'; hintEl.textContent=''; return; }
|
||||||
|
try{
|
||||||
|
const r = await fetch('/api/v1/orders/'+encodeURIComponent(out));
|
||||||
|
const j = await r.json();
|
||||||
|
if(r.ok){
|
||||||
|
const paid = render(j.data);
|
||||||
|
if(paid){ hintEl.textContent=''; return; }
|
||||||
|
}
|
||||||
|
}catch(e){}
|
||||||
|
tries++;
|
||||||
|
if(tries < 10){ setTimeout(poll, 2000); }
|
||||||
|
else { hintEl.textContent = '尚未确认到账。异步通知可能稍有延迟,可稍后刷新本页。'; }
|
||||||
|
}
|
||||||
|
poll();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user