收银页三种付款方式、内置体验页、反代支持与部署文档
收银页(/pay/{token})
- 三种付款方式按设备自动切换:桌面端扫码(由 RECEIVE_LINK 服务端生成收款二维码,
不再需要上传图片)、手机端一键唤起币安 App(通用链接)、手动按 UID 转账兜底
- 三种到账确认方式(唯一金额 / 备注码 / 回填订单编号)在网关内始终同时生效
体验页(/demo,DEMO_ENABLED 开启,默认关闭)
- 选择金额档位与币种一键下单;可选择要体验的到账确认方式,收银页引导随之变化,
结果页显示实际命中方式;按 IP 限流,金额仅限 DEMO_AMOUNTS 档位;根路径跳转 /demo
反向代理
- 新增 TRUST_PROXY:开启后限流按 CF-Connecting-IP / X-Real-IP / X-Forwarded-For 取真实访客 IP
部署
- docs/deploy.md:VPS 选型(须能直连币安 API)、systemd、Caddy、Cloudflare Tunnel
- deploy/ 下提供 bpaygate.service、Caddyfile、cloudflared.yml 模板
其他
- 新增依赖 github.com/skip2/go-qrcode(纯 Go)
- 新增体验页与三模式的测试用例
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
ce83c5e0c2
commit
a749e9b989
15 files changed
+690
-32
No files matched your search
@@ -50,7 +50,8 @@ sequenceDiagram
|
|||||||
## 特性
|
## 特性
|
||||||
|
|
||||||
- **单二进制 + SQLite**,无外部依赖;Docker 一行起
|
- **单二进制 + SQLite**,无外部依赖;Docker 一行起
|
||||||
- 托管收银页:大字金额一键复制、二维码展示、倒计时、状态轮询、回填入口,适配移动端与深色模式
|
- 托管收银页三种付款方式按设备自动切换:**桌面扫码 / 手机一键唤起币安 App / 手动按 UID 转账**,大字金额一键复制、倒计时、状态轮询、回填入口,适配深色模式
|
||||||
|
- 内置体验页 `/demo`(默认关闭),一键部署一个公开演示站
|
||||||
- 唯一金额**递进式尾数**:优先 0.0001–0.0099 档(付款方最多多付一分钱级别),档位用满自动升位,同价并发容量最高 9999
|
- 唯一金额**递进式尾数**:优先 0.0001–0.0099 档(付款方最多多付一分钱级别),档位用满自动升位,同价并发容量最高 9999
|
||||||
- 金额冷却:订单结束后该金额 24h 不复用,杜绝迟付错配
|
- 金额冷却:订单结束后该金额 24h 不复用,杜绝迟付错配
|
||||||
- 回调 HMAC-SHA256 签名 + 时间戳 + nonce 防重放,`0s/15s/1m/5m/15m/1h/3h` 七次重试
|
- 回调 HMAC-SHA256 签名 + 时间戳 + nonce 防重放,`0s/15s/1m/5m/15m/1h/3h` 七次重试
|
||||||
@@ -112,6 +113,10 @@ print(gw.create_order("TEST-1", "1", currency="USDT")["pay_url"])
|
|||||||
| `AMOUNT_DECIMALS` | 4 | 唯一金额小数位(实测币安支持至少 4) |
|
| `AMOUNT_DECIMALS` | 4 | 唯一金额小数位(实测币安支持至少 4) |
|
||||||
| `SUFFIX_MODE` | add | add=多收尾数 / sub=少收尾数 |
|
| `SUFFIX_MODE` | add | add=多收尾数 / sub=少收尾数 |
|
||||||
| `EXPIRED_GRACE` | 1800 | 过期后宽限期,期内精确到账仍自动确认 |
|
| `EXPIRED_GRACE` | 1800 | 过期后宽限期,期内精确到账仍自动确认 |
|
||||||
|
| `RECEIVE_LINK` | 空 | 收款二维码解码出的链接;配置后启用扫码与唤起 App 两种方式 |
|
||||||
|
| `DEMO_ENABLED` | false | 开启 `/demo` 体验页 |
|
||||||
|
|
||||||
|
部署到 VPS + Cloudflare 域名:[docs/deploy.md](docs/deploy.md)(含 systemd、Caddy、Cloudflare Tunnel 配置)。
|
||||||
|
|
||||||
## 必读:风险与边界
|
## 必读:风险与边界
|
||||||
|
|
||||||
|
|||||||
+13
-1
@@ -20,11 +20,19 @@ BINANCE_UID=
|
|||||||
BINANCE_API_BASE=https://api.binance.com
|
BINANCE_API_BASE=https://api.binance.com
|
||||||
|
|
||||||
# ---- 收银页展示 ----
|
# ---- 收银页展示 ----
|
||||||
# 可选:静态收款二维码图片路径(币安 App「收款」保存的那张);配置后收银页显示扫码
|
# 推荐:收款二维码解码出的链接(币安 App「收款」二维码内容,https://app.binance.com/... 形式)。
|
||||||
|
# 配置后:桌面端显示服务端生成的二维码;手机端显示「打开币安 App」一键唤起按钮。
|
||||||
|
RECEIVE_LINK=
|
||||||
|
# 可选:改用静态二维码图片文件(与 RECEIVE_LINK 二选一,优先此项;只提供扫码不提供唤起)
|
||||||
QR_IMAGE=
|
QR_IMAGE=
|
||||||
# 可选:额外展示的收款邮箱(不填则只展示 UID)
|
# 可选:额外展示的收款邮箱(不填则只展示 UID)
|
||||||
RECEIVE_EMAIL=
|
RECEIVE_EMAIL=
|
||||||
|
|
||||||
|
# ---- 体验页(对外演示用,默认关闭)----
|
||||||
|
# 开启后 /demo 提供体验下单页,根路径自动跳转过去;金额只允许下列档位,按 IP 限流 5 次/分钟
|
||||||
|
DEMO_ENABLED=false
|
||||||
|
DEMO_AMOUNTS=0.5,1
|
||||||
|
|
||||||
# ---- 订单与匹配 ----
|
# ---- 订单与匹配 ----
|
||||||
# 允许的收款币种(逗号分隔)
|
# 允许的收款币种(逗号分隔)
|
||||||
CURRENCIES=USDT,USDC
|
CURRENCIES=USDT,USDC
|
||||||
@@ -41,6 +49,10 @@ SUFFIX_COOLDOWN=86400
|
|||||||
# 币安流水轮询间隔(秒)。接口权重 3000/次、UID 限额 180000/分钟,最低可到 1
|
# 币安流水轮询间隔(秒)。接口权重 3000/次、UID 限额 180000/分钟,最低可到 1
|
||||||
POLL_INTERVAL=4
|
POLL_INTERVAL=4
|
||||||
|
|
||||||
|
# ---- 反向代理 ----
|
||||||
|
# 放在 Caddy/Nginx/Cloudflare 之后时开启,限流按真实访客 IP(CF-Connecting-IP / X-Real-IP / X-Forwarded-For)
|
||||||
|
TRUST_PROXY=false
|
||||||
|
|
||||||
# ---- 存储与日志 ----
|
# ---- 存储与日志 ----
|
||||||
DB_PATH=./bpaygate.db
|
DB_PATH=./bpaygate.db
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 方案 A:Caddy 反代 + 自动 HTTPS(Cloudflare DNS 只解析、或代理模式下 SSL 设为 Full (strict))
|
||||||
|
pay.example.com {
|
||||||
|
reverse_proxy 127.0.0.1:8080
|
||||||
|
encode gzip
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# systemd 单元:sudo cp deploy/bpaygate.service /etc/systemd/system/ && sudo systemctl enable --now bpaygate
|
||||||
|
[Unit]
|
||||||
|
Description=BinancePayTool gateway
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=bpg
|
||||||
|
Group=bpg
|
||||||
|
WorkingDirectory=/opt/bpaygate
|
||||||
|
ExecStart=/opt/bpaygate/bpaygate -config /opt/bpaygate/config.env
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
# 安全加固
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ReadWritePaths=/opt/bpaygate
|
||||||
|
ProtectHome=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# 方案 B(推荐):Cloudflare Tunnel,不开公网端口、不管证书
|
||||||
|
# cloudflared tunnel login
|
||||||
|
# cloudflared tunnel create bpaygate
|
||||||
|
# cloudflared tunnel route dns bpaygate pay.example.com
|
||||||
|
# sudo cp deploy/cloudflared.yml /etc/cloudflared/config.yml # 改 tunnel 与 credentials-file
|
||||||
|
# sudo cloudflared service install && sudo systemctl enable --now cloudflared
|
||||||
|
tunnel: <TUNNEL-ID>
|
||||||
|
credentials-file: /root/.cloudflared/<TUNNEL-ID>.json
|
||||||
|
ingress:
|
||||||
|
- hostname: pay.example.com
|
||||||
|
service: http://127.0.0.1:8080
|
||||||
|
- service: http_status:404
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# 部署指南(VPS + Cloudflare 域名)
|
||||||
|
|
||||||
|
目标:`https://pay.你的域名/` 对外提供收银页、体验页与商户 API。
|
||||||
|
|
||||||
|
## 0. 选 VPS 的唯一硬要求:能直连币安 API
|
||||||
|
|
||||||
|
币安对部分地区 IP(如美国)返回 `451 Service unavailable from a restricted location`。
|
||||||
|
香港、新加坡、日本等机房通常可用。开机后先验:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s https://api.binance.com/api/v3/ping && echo OK
|
||||||
|
```
|
||||||
|
|
||||||
|
配置 1 核 1G 足够;网关是单进程、SQLite、每 3–4 秒一次 API 调用。
|
||||||
|
|
||||||
|
## 1. 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在 VPS 上(Debian/Ubuntu 为例)
|
||||||
|
sudo useradd -r -s /usr/sbin/nologin bpg
|
||||||
|
sudo mkdir -p /opt/bpaygate && sudo chown bpg:bpg /opt/bpaygate
|
||||||
|
# 方式一:本机交叉编译后上传
|
||||||
|
# cd server && GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o bpaygate-linux .
|
||||||
|
# scp bpaygate-linux vps:/opt/bpaygate/bpaygate
|
||||||
|
# 方式二:VPS 上有 Go 直接 git clone 后 go build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u bpg cp config.example.env /opt/bpaygate/config.env
|
||||||
|
sudo -u bpg /opt/bpaygate/bpaygate -gen-key # 得到 API_AUTH_KEY
|
||||||
|
sudo -u bpg nano /opt/bpaygate/config.env
|
||||||
|
sudo chmod 600 /opt/bpaygate/config.env
|
||||||
|
```
|
||||||
|
|
||||||
|
必填:`BASE_URL=https://pay.你的域名`、`API_AUTH_KEY`、`BINANCE_API_KEY/SECRET`(只读,**绑定 VPS 出口 IP**)、`BINANCE_UID`。
|
||||||
|
对外体验建议:`DEMO_ENABLED=true`、`DEMO_AMOUNTS=0.5,1`、`RECEIVE_LINK=<收款二维码解码内容>`(见下)。
|
||||||
|
|
||||||
|
### 拿到 RECEIVE_LINK
|
||||||
|
|
||||||
|
币安 App → 支付 → 收款 → 保存二维码,用任意扫码工具读出内容,通常是 `https://app.binance.com/...` 形式的链接。
|
||||||
|
填入后网关会:① 服务端生成同样的二维码给桌面端扫;② 在手机端收银页放「打开币安 App」按钮直接唤起。
|
||||||
|
不填则只提供手动转账模式(或用 `QR_IMAGE` 指向二维码图片文件,仅提供扫码)。
|
||||||
|
|
||||||
|
## 3. 起服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp deploy/bpaygate.service /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload && sudo systemctl enable --now bpaygate
|
||||||
|
sudo journalctl -u bpaygate -f # 看到「监听 0.0.0.0:8080」即正常
|
||||||
|
```
|
||||||
|
|
||||||
|
网关只监听本机即可(`LISTEN=127.0.0.1:8080`),对外由下面的入口层负责 HTTPS。
|
||||||
|
|
||||||
|
## 4. 对外 HTTPS(二选一)
|
||||||
|
|
||||||
|
**方案 B · Cloudflare Tunnel(推荐)**:不开公网端口、证书全托管、VPS 防火墙可以全关。
|
||||||
|
按 `deploy/cloudflared.yml` 头部注释五步走完,Cloudflare DNS 里会自动出现 `pay.你的域名` 的 CNAME。
|
||||||
|
|
||||||
|
**方案 A · Caddy**:`deploy/Caddyfile` 改域名后 `caddy run`,Cloudflare DNS 记录开代理时把 SSL/TLS 设为 **Full (strict)**。
|
||||||
|
|
||||||
|
## 5. 上线检查
|
||||||
|
|
||||||
|
- `curl https://pay.你的域名/healthz` 返回 `{"ok":true}`
|
||||||
|
- 打开 `https://pay.你的域名/demo`,手机与桌面各走一遍
|
||||||
|
- 币安后台确认 API Key 已绑定 VPS IP、仅读取权限
|
||||||
|
- 定期把收款账户余额转走,收款专号不放大额
|
||||||
|
|
||||||
|
## 6. 升级
|
||||||
|
|
||||||
|
替换二进制后 `sudo systemctl restart bpaygate`。数据库 schema 自动迁移(`CREATE IF NOT EXISTS`)。
|
||||||
+36
-1
@@ -3,12 +3,15 @@ package main
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
qrcode "github.com/skip2/go-qrcode"
|
||||||
)
|
)
|
||||||
|
|
||||||
type App struct {
|
type App struct {
|
||||||
@@ -24,6 +27,8 @@ type App struct {
|
|||||||
|
|
||||||
rlMu sync.Mutex
|
rlMu sync.Mutex
|
||||||
rl map[string][]int64 // 限流:key → 时间戳窗口
|
rl map[string][]int64 // 限流:key → 时间戳窗口
|
||||||
|
|
||||||
|
qrPNG []byte // 由 RECEIVE_LINK 生成的收款二维码(启动时生成一次)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newApp(cfg *Config) (*App, error) {
|
func newApp(cfg *Config) (*App, error) {
|
||||||
@@ -39,6 +44,13 @@ func newApp(cfg *Config) (*App, error) {
|
|||||||
nonces: map[string]int64{},
|
nonces: map[string]int64{},
|
||||||
rl: map[string][]int64{},
|
rl: map[string][]int64{},
|
||||||
}
|
}
|
||||||
|
if cfg.ReceiveLink != "" && cfg.QRImage == "" {
|
||||||
|
png, err := qrcode.Encode(cfg.ReceiveLink, qrcode.Medium, 360)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("生成收款二维码失败: %w", err)
|
||||||
|
}
|
||||||
|
a.qrPNG = png
|
||||||
|
}
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("POST /api/v1/orders", a.auth(a.handleCreate))
|
mux.HandleFunc("POST /api/v1/orders", a.auth(a.handleCreate))
|
||||||
mux.HandleFunc("GET /api/v1/orders/{id}", a.auth(a.handleGet))
|
mux.HandleFunc("GET /api/v1/orders/{id}", a.auth(a.handleGet))
|
||||||
@@ -51,7 +63,16 @@ func newApp(cfg *Config) (*App, error) {
|
|||||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, 200, map[string]any{"ok": true, "version": version})
|
writeJSON(w, 200, map[string]any{"ok": true, "version": version})
|
||||||
})
|
})
|
||||||
|
if cfg.DemoEnabled {
|
||||||
|
mux.HandleFunc("GET /demo", a.handleDemo)
|
||||||
|
mux.HandleFunc("POST /demo/order", a.handleDemoOrder)
|
||||||
|
mux.HandleFunc("GET /demo/done", a.handleDemoDone)
|
||||||
|
}
|
||||||
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if cfg.DemoEnabled {
|
||||||
|
http.Redirect(w, r, "/demo", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
w.Write([]byte("BinancePayTool " + version + " running\n"))
|
w.Write([]byte("BinancePayTool " + version + " running\n"))
|
||||||
})
|
})
|
||||||
@@ -269,7 +290,21 @@ func (a *App) handleClose(w http.ResponseWriter, r *http.Request, _ []byte) {
|
|||||||
ok(w, a.orderJSON(o))
|
ok(w, a.orderJSON(o))
|
||||||
}
|
}
|
||||||
|
|
||||||
func clientIP(r *http.Request) string {
|
// clientIP 取访客 IP。直连时用 RemoteAddr;TRUST_PROXY 开启时依次信任
|
||||||
|
// CF-Connecting-IP(Cloudflare)、X-Real-IP、X-Forwarded-For 首个地址。
|
||||||
|
func (a *App) clientIP(r *http.Request) string {
|
||||||
|
if a.cfg.TrustProxy {
|
||||||
|
for _, h := range []string{"CF-Connecting-IP", "X-Real-IP"} {
|
||||||
|
if v := strings.TrimSpace(r.Header.Get(h)); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||||
|
if first := strings.TrimSpace(strings.Split(xff, ",")[0]); first != "" {
|
||||||
|
return first
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if i := strings.LastIndex(r.RemoteAddr, ":"); i > 0 {
|
if i := strings.LastIndex(r.RemoteAddr, ":"); i > 0 {
|
||||||
return r.RemoteAddr[:i]
|
return r.RemoteAddr[:i]
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-14
@@ -17,6 +17,9 @@ var cashierHTML string
|
|||||||
var cashierTpl = template.Must(template.New("cashier").Parse(cashierHTML))
|
var cashierTpl = template.Must(template.New("cashier").Parse(cashierHTML))
|
||||||
|
|
||||||
var reBinanceOrderID = regexp.MustCompile(`^[0-9]{6,20}$`)
|
var reBinanceOrderID = regexp.MustCompile(`^[0-9]{6,20}$`)
|
||||||
|
var reMobileUA = regexp.MustCompile(`(?i)android|iphone|ipad|ipod|mobile|harmonyos`)
|
||||||
|
|
||||||
|
func isMobile(r *http.Request) bool { return reMobileUA.MatchString(r.UserAgent()) }
|
||||||
|
|
||||||
func (a *App) handleCashier(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleCashier(w http.ResponseWriter, r *http.Request) {
|
||||||
o, err := a.st.OrderByToken(r.PathValue("token"))
|
o, err := a.st.OrderByToken(r.PathValue("token"))
|
||||||
@@ -28,16 +31,37 @@ func (a *App) handleCashier(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "internal error", 500)
|
http.Error(w, "internal error", 500)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
hasQR := a.cfg.QRImage != "" || a.qrPNG != nil
|
||||||
|
// mode 仅用于体验站引导:note/claim 模式下引导付基础金额,让另外两种匹配方式有机会命中
|
||||||
|
mode := demoMode(r.URL.Query().Get("mode"))
|
||||||
|
showAmount := fmtAmount(o.PayAmount)
|
||||||
|
if mode != "amount" {
|
||||||
|
showAmount = fmtAmount(o.BaseAmount)
|
||||||
|
}
|
||||||
|
noteLabel, noteHint := "转账备注(可选)", template.HTML("备注填 <b>"+o.NoteCode+"</b>(可选,能加速确认)")
|
||||||
|
switch mode {
|
||||||
|
case "note":
|
||||||
|
noteLabel, noteHint = "转账备注(必填)", template.HTML("备注<b>必须</b>填 <b>"+o.NoteCode+"</b>")
|
||||||
|
case "claim":
|
||||||
|
noteLabel, noteHint = "转账备注(本次不填)", template.HTML("<b>不要填备注</b>,付完后在下方回填订单编号")
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
cashierTpl.Execute(w, map[string]any{
|
cashierTpl.Execute(w, map[string]any{
|
||||||
"PayAmount": fmtAmount(o.PayAmount),
|
"Mode": mode,
|
||||||
"Currency": o.Currency,
|
"ShowAmount": showAmount,
|
||||||
"UID": a.cfg.BinanceUID,
|
"NoteLabel": noteLabel,
|
||||||
"Email": a.cfg.ReceiveEmail,
|
"NoteHint": noteHint,
|
||||||
"HasQR": a.cfg.QRImage != "",
|
"PayAmount": fmtAmount(o.PayAmount),
|
||||||
"NoteCode": o.NoteCode,
|
"Currency": o.Currency,
|
||||||
"ExpiresAt": o.ExpiresAt,
|
"UID": a.cfg.BinanceUID,
|
||||||
"Token": o.Token,
|
"Email": a.cfg.ReceiveEmail,
|
||||||
|
"HasQR": hasQR,
|
||||||
|
"AppLink": a.cfg.ReceiveLink,
|
||||||
|
"IsMobile": isMobile(r),
|
||||||
|
"NoteCode": o.NoteCode,
|
||||||
|
"ExpiresAt": o.ExpiresAt,
|
||||||
|
"Token": o.Token,
|
||||||
|
"Status": o.Status,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +105,7 @@ func (a *App) handleClaim(w http.ResponseWriter, r *http.Request) {
|
|||||||
fail(w, 500, "ERR_INTERNAL", "内部错误")
|
fail(w, 500, "ERR_INTERNAL", "内部错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !a.allow("claim:"+tok, 5, 60) || !a.allow("claimip:"+clientIP(r), 20, 60) {
|
if !a.allow("claim:"+tok, 5, 60) || !a.allow("claimip:"+a.clientIP(r), 20, 60) {
|
||||||
fail(w, 429, "ERR_RATE_LIMIT", "操作过于频繁")
|
fail(w, 429, "ERR_RATE_LIMIT", "操作过于频繁")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -105,14 +129,21 @@ func (a *App) handleClaim(w http.ResponseWriter, r *http.Request) {
|
|||||||
ok(w, map[string]any{"code": res.Code, "status": res.Status})
|
ok(w, map[string]any{"code": res.Code, "status": res.Status})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleQR 返回收款二维码:优先 QR_IMAGE 文件,否则用 RECEIVE_LINK 服务端生成。
|
||||||
func (a *App) handleQR(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleQR(w http.ResponseWriter, r *http.Request) {
|
||||||
if a.cfg.QRImage == "" {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, err := a.st.OrderByToken(r.PathValue("token")); err != nil {
|
if _, err := a.st.OrderByToken(r.PathValue("token")); err != nil {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.ServeFile(w, r, a.cfg.QRImage)
|
if a.cfg.QRImage != "" {
|
||||||
|
http.ServeFile(w, r, a.cfg.QRImage)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.qrPNG != nil {
|
||||||
|
w.Header().Set("Content-Type", "image/png")
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||||
|
w.Write(a.qrPNG)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
@@ -17,7 +17,11 @@ type Config struct {
|
|||||||
BinanceUID string
|
BinanceUID string
|
||||||
BinanceAPIBase string
|
BinanceAPIBase string
|
||||||
QRImage string
|
QRImage string
|
||||||
|
ReceiveLink string // 收款二维码解码出的通用链接,用于服务端生成二维码与 H5 唤起 App
|
||||||
ReceiveEmail string
|
ReceiveEmail string
|
||||||
|
DemoEnabled bool
|
||||||
|
DemoAmounts []string
|
||||||
|
TrustProxy bool // 反代之后按 CF-Connecting-IP / X-Real-IP / X-Forwarded-For 取访客 IP
|
||||||
Currencies map[string]bool
|
Currencies map[string]bool
|
||||||
OrderTTL int64 // 秒
|
OrderTTL int64 // 秒
|
||||||
ExpiredGrace int64 // 秒
|
ExpiredGrace int64 // 秒
|
||||||
@@ -77,7 +81,10 @@ func loadConfig(path string) (*Config, error) {
|
|||||||
BinanceUID: get("BINANCE_UID", ""),
|
BinanceUID: get("BINANCE_UID", ""),
|
||||||
BinanceAPIBase: strings.TrimRight(get("BINANCE_API_BASE", "https://api.binance.com"), "/"),
|
BinanceAPIBase: strings.TrimRight(get("BINANCE_API_BASE", "https://api.binance.com"), "/"),
|
||||||
QRImage: get("QR_IMAGE", ""),
|
QRImage: get("QR_IMAGE", ""),
|
||||||
|
ReceiveLink: strings.TrimSpace(get("RECEIVE_LINK", "")),
|
||||||
ReceiveEmail: get("RECEIVE_EMAIL", ""),
|
ReceiveEmail: get("RECEIVE_EMAIL", ""),
|
||||||
|
DemoEnabled: isTrue(get("DEMO_ENABLED", "false")),
|
||||||
|
TrustProxy: isTrue(get("TRUST_PROXY", "false")),
|
||||||
Currencies: map[string]bool{},
|
Currencies: map[string]bool{},
|
||||||
OrderTTL: getInt("ORDER_TTL", 900),
|
OrderTTL: getInt("ORDER_TTL", 900),
|
||||||
ExpiredGrace: getInt("EXPIRED_GRACE", 1800),
|
ExpiredGrace: getInt("EXPIRED_GRACE", 1800),
|
||||||
@@ -94,6 +101,15 @@ func loadConfig(path string) (*Config, error) {
|
|||||||
c.Currencies[cur] = true
|
c.Currencies[cur] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, a := range strings.Split(get("DEMO_AMOUNTS", "0.5,1"), ",") {
|
||||||
|
a = strings.TrimSpace(a)
|
||||||
|
if v, err := parseAmount(a); err == nil && v > 0 {
|
||||||
|
c.DemoAmounts = append(c.DemoAmounts, fmtAmount(v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.ReceiveLink != "" && !strings.HasPrefix(c.ReceiveLink, "https://") {
|
||||||
|
return nil, errors.New("RECEIVE_LINK 须为 https:// 链接(币安收款二维码解码内容)")
|
||||||
|
}
|
||||||
if len(c.APIAuthKey) < 16 {
|
if len(c.APIAuthKey) < 16 {
|
||||||
return nil, errors.New("API_AUTH_KEY 未设置或过短(≥16 字符,可用 ./bpaygate -gen-key 生成)")
|
return nil, errors.New("API_AUTH_KEY 未设置或过短(≥16 字符,可用 ./bpaygate -gen-key 生成)")
|
||||||
}
|
}
|
||||||
@@ -119,3 +135,11 @@ func loadConfig(path string) (*Config, error) {
|
|||||||
}
|
}
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isTrue(v string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||||
|
case "1", "true", "yes", "on":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"errors"
|
||||||
|
"html/template"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed templates/demo.html
|
||||||
|
var demoHTML string
|
||||||
|
|
||||||
|
var demoTpl = template.Must(template.New("demo").Parse(demoHTML))
|
||||||
|
|
||||||
|
const demoRepoURL = "https://github.com/qianyubtc/BinancePayTool"
|
||||||
|
|
||||||
|
func (a *App) demoCurrencies() []string {
|
||||||
|
var out []string
|
||||||
|
for c := range a.cfg.Currencies {
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
// USDT 最常用,放到首位作为默认选项
|
||||||
|
for i, c := range out {
|
||||||
|
if c == "USDT" && i > 0 {
|
||||||
|
out = append([]string{"USDT"}, append(out[:i:i], out[i+1:]...)...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) renderDemo(w http.ResponseWriter, data map[string]any) {
|
||||||
|
base := map[string]any{
|
||||||
|
"Amounts": a.cfg.DemoAmounts,
|
||||||
|
"Currencies": a.demoCurrencies(),
|
||||||
|
"HasApp": a.cfg.ReceiveLink != "",
|
||||||
|
"HasQR": a.cfg.QRImage != "" || a.qrPNG != nil,
|
||||||
|
"Repo": demoRepoURL,
|
||||||
|
"Version": version,
|
||||||
|
}
|
||||||
|
for k, v := range data {
|
||||||
|
base[k] = v
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
demoTpl.Execute(w, base)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleDemo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a.renderDemo(w, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDemoOrder 体验页下单:金额只允许 DEMO_AMOUNTS 中的档位,按 IP 限流。
|
||||||
|
func (a *App) handleDemoOrder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.allow("demo:"+a.clientIP(r), 5, 60) {
|
||||||
|
a.renderDemo(w, map[string]any{"Error": "操作过于频繁,请一分钟后再试"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
a.renderDemo(w, map[string]any{"Error": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
amount := strings.TrimSpace(r.FormValue("amount"))
|
||||||
|
cur := strings.ToUpper(strings.TrimSpace(r.FormValue("currency")))
|
||||||
|
allowed := false
|
||||||
|
for _, x := range a.cfg.DemoAmounts {
|
||||||
|
if x == amount {
|
||||||
|
allowed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !allowed || !a.cfg.Currencies[cur] {
|
||||||
|
a.renderDemo(w, map[string]any{"Error": "金额或币种不在体验范围内"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mode := demoMode(r.FormValue("mode"))
|
||||||
|
base, _ := parseAmount(amount)
|
||||||
|
o, err := a.st.CreateOrder(a.cfg, "DEMO-"+randString(idAlphabet, 12), cur, base, a.cfg.OrderTTL, "", a.cfg.BaseURL+"/demo/done")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[error] 体验下单失败: %v", err)
|
||||||
|
a.renderDemo(w, map[string]any{"Error": "创建订单失败,请稍后再试"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("[info] 体验订单 %s %s %s 应付 %s 体验方式=%s", o.ID, o.Currency, amount, fmtAmount(o.PayAmount), mode)
|
||||||
|
http.Redirect(w, r, "/pay/"+o.Token+"?mode="+mode, http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// demoMode 规范化体验的到账确认方式:amount(唯一金额,默认)/ note(备注码)/ claim(回填订单编号)。
|
||||||
|
// 三种匹配在网关里始终同时生效,mode 只改变收银页的引导文案。
|
||||||
|
func demoMode(v string) string {
|
||||||
|
switch v {
|
||||||
|
case "note", "claim":
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return "amount"
|
||||||
|
}
|
||||||
|
|
||||||
|
var matchedByLabel = map[string]string{
|
||||||
|
"amount": "唯一金额自动匹配",
|
||||||
|
"note": "备注码匹配",
|
||||||
|
"claim": "回填订单编号核验",
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleDemoDone(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.URL.Query().Get("order_id")
|
||||||
|
o, err := a.st.OrderByID(id)
|
||||||
|
if errors.Is(err, ErrNotFound) || err != nil {
|
||||||
|
a.renderDemo(w, map[string]any{"Error": "订单不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
label := matchedByLabel[o.MatchedBy]
|
||||||
|
if label == "" {
|
||||||
|
label = o.MatchedBy
|
||||||
|
}
|
||||||
|
a.renderDemo(w, map[string]any{
|
||||||
|
"Done": true,
|
||||||
|
"DoneStatus": o.Status,
|
||||||
|
"DoneAmount": fmtAmount(o.ActualAmount),
|
||||||
|
"DoneCur": o.Currency,
|
||||||
|
"DoneBy": label,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newDemoEnv(t *testing.T) *env {
|
||||||
|
mock := newMockBinance()
|
||||||
|
cfg := &Config{
|
||||||
|
Listen: "127.0.0.1:0", BaseURL: "http://gw.test", APIAuthKey: testSecret,
|
||||||
|
BinanceKey: "k", BinanceSecret: "s", BinanceUID: "90000001",
|
||||||
|
BinanceAPIBase: mock.srv.URL,
|
||||||
|
Currencies: map[string]bool{"USDT": true},
|
||||||
|
OrderTTL: 900, ExpiredGrace: 1800, SuffixCooldown: 86400, PollInterval: 1,
|
||||||
|
AmountDecimals: 4, SuffixMode: "add",
|
||||||
|
DBPath: t.TempDir() + "/demo.db",
|
||||||
|
ReceiveLink: "https://app.binance.com/qr/dplkTESTLINK",
|
||||||
|
DemoEnabled: true, DemoAmounts: []string{"0.5", "1"},
|
||||||
|
}
|
||||||
|
app, err := newApp(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newApp: %v", err)
|
||||||
|
}
|
||||||
|
srv := httptest.NewServer(app.mux)
|
||||||
|
_, cancel := context.WithCancel(context.Background())
|
||||||
|
e := &env{t: t, app: app, srv: srv, mock: mock}
|
||||||
|
t.Cleanup(func() { cancel(); srv.Close(); mock.srv.Close(); app.st.Close() })
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDemoFlowAndThreeModes(t *testing.T) {
|
||||||
|
e := newDemoEnv(t)
|
||||||
|
noRedirect := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||||
|
|
||||||
|
// 根路径跳转体验页;体验页可渲染
|
||||||
|
resp, _ := noRedirect.Get(e.srv.URL + "/")
|
||||||
|
if resp.StatusCode != 302 || resp.Header.Get("Location") != "/demo" {
|
||||||
|
t.Fatalf("根路径应跳转 /demo: %d %s", resp.StatusCode, resp.Header.Get("Location"))
|
||||||
|
}
|
||||||
|
resp, _ = http.Get(e.srv.URL + "/demo")
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != 200 || !strings.Contains(string(body), "体验") {
|
||||||
|
t.Fatalf("体验页渲染失败: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 体验下单 → 302 到收银页
|
||||||
|
form := url.Values{"amount": {"0.5"}, "currency": {"USDT"}}
|
||||||
|
resp, _ = noRedirect.Post(e.srv.URL+"/demo/order", "application/x-www-form-urlencoded", strings.NewReader(form.Encode()))
|
||||||
|
loc := resp.Header.Get("Location")
|
||||||
|
if resp.StatusCode != 302 || !strings.HasPrefix(loc, "/pay/") {
|
||||||
|
t.Fatalf("体验下单应跳转收银页: %d %s", resp.StatusCode, loc)
|
||||||
|
}
|
||||||
|
token := strings.TrimPrefix(strings.SplitN(loc, "?", 2)[0], "/pay/") // 跳转带 ?mode=,取 token 时去掉查询串
|
||||||
|
|
||||||
|
// 服务端生成的二维码
|
||||||
|
resp, _ = http.Get(e.srv.URL + "/pay/" + token + "/qr")
|
||||||
|
png, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != 200 || !bytes.HasPrefix(png, []byte("\x89PNG")) {
|
||||||
|
t.Fatalf("二维码应为 PNG: %d %d bytes", resp.StatusCode, len(png))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 桌面 UA:有扫码与 App 两个标签;手机 UA:默认切到 App
|
||||||
|
req, _ := http.NewRequest("GET", e.srv.URL+"/pay/"+token, nil)
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh)")
|
||||||
|
resp, _ = http.DefaultClient.Do(req)
|
||||||
|
html, _ := io.ReadAll(resp.Body)
|
||||||
|
h := strings.ReplaceAll(string(html), " ", "") // html/template 在 JS 上下文输出布尔值会加空格
|
||||||
|
// 页面内所有异步请求必须用带 token 的绝对路径:/pay/{token} 无尾斜杠,相对路径会解析到 /pay/xxx
|
||||||
|
for _, want := range []string{"扫码支付", "打开币安App", "手动转账", "https://app.binance.com/qr/dplkTESTLINK", "isMobile=false",
|
||||||
|
"/pay/" + token + "/qr", "/pay/\"+token+\"/status", "/pay/\"+token+\"/claim"} {
|
||||||
|
if !strings.Contains(h, want) {
|
||||||
|
t.Fatalf("桌面收银页缺少 %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)")
|
||||||
|
resp, _ = http.DefaultClient.Do(req)
|
||||||
|
html, _ = io.ReadAll(resp.Body)
|
||||||
|
if !strings.Contains(strings.ReplaceAll(string(html), " ", ""), "isMobile=true") {
|
||||||
|
t.Fatal("手机 UA 应标记 isMobile=true")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非法金额被拒
|
||||||
|
form = url.Values{"amount": {"999"}, "currency": {"USDT"}}
|
||||||
|
resp, _ = noRedirect.Post(e.srv.URL+"/demo/order", "application/x-www-form-urlencoded", strings.NewReader(form.Encode()))
|
||||||
|
body, _ = io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != 200 || !strings.Contains(string(body), "不在体验范围内") {
|
||||||
|
t.Fatalf("非法金额应被拒: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限流:同 IP 一分钟 5 次
|
||||||
|
hit429 := false
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
form = url.Values{"amount": {"1"}, "currency": {"USDT"}}
|
||||||
|
resp, _ = noRedirect.Post(e.srv.URL+"/demo/order", "application/x-www-form-urlencoded", strings.NewReader(form.Encode()))
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
if strings.Contains(string(b), "操作过于频繁") {
|
||||||
|
hit429 = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hit429 {
|
||||||
|
t.Fatal("体验下单应被限流")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDemoDisabledByDefault(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
resp, _ := http.Get(e.srv.URL + "/demo")
|
||||||
|
if resp.StatusCode != 404 {
|
||||||
|
t.Fatalf("未启用时 /demo 应 404,得到 %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrustProxyClientIP(t *testing.T) {
|
||||||
|
e := newDemoEnv(t)
|
||||||
|
e.app.cfg.TrustProxy = true
|
||||||
|
req, _ := http.NewRequest("GET", e.srv.URL+"/demo", nil)
|
||||||
|
req.Header.Set("CF-Connecting-IP", "203.0.113.9")
|
||||||
|
req.Header.Set("X-Forwarded-For", "198.51.100.1, 10.0.0.1")
|
||||||
|
if ip := e.app.clientIP(req); ip != "203.0.113.9" {
|
||||||
|
t.Fatalf("应优先 CF-Connecting-IP,得到 %s", ip)
|
||||||
|
}
|
||||||
|
req.Header.Del("CF-Connecting-IP")
|
||||||
|
if ip := e.app.clientIP(req); ip != "198.51.100.1" {
|
||||||
|
t.Fatalf("应取 X-Forwarded-For 首个地址,得到 %s", ip)
|
||||||
|
}
|
||||||
|
e.app.cfg.TrustProxy = false
|
||||||
|
req.RemoteAddr = "127.0.0.1:5555"
|
||||||
|
if ip := e.app.clientIP(req); ip != "127.0.0.1" {
|
||||||
|
t.Fatalf("未开启 TRUST_PROXY 应忽略代理头,得到 %s", ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 体验站三种到账确认方式的引导:note/claim 模式显示基础金额并调整备注文案,claim 模式回填区默认展开
|
||||||
|
func TestDemoConfirmModes(t *testing.T) {
|
||||||
|
e := newDemoEnv(t)
|
||||||
|
noRedirect := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||||
|
get := func(mode string) string {
|
||||||
|
form := url.Values{"amount": {"0.5"}, "currency": {"USDT"}, "mode": {mode}}
|
||||||
|
resp, _ := noRedirect.Post(e.srv.URL+"/demo/order", "application/x-www-form-urlencoded", strings.NewReader(form.Encode()))
|
||||||
|
loc := resp.Header.Get("Location")
|
||||||
|
if !strings.Contains(loc, "?mode="+demoMode(mode)) {
|
||||||
|
t.Fatalf("mode=%s 跳转应带 mode 参数: %s", mode, loc)
|
||||||
|
}
|
||||||
|
resp, _ = http.Get(e.srv.URL + loc)
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
return string(body)
|
||||||
|
}
|
||||||
|
h := get("amount")
|
||||||
|
if !strings.Contains(h, "一分不差") || strings.Contains(h, "0.5<small>") {
|
||||||
|
t.Fatal("amount 模式应显示唯一金额与一分不差提示")
|
||||||
|
}
|
||||||
|
h = get("note")
|
||||||
|
if !strings.Contains(h, "0.5<small>") || !strings.Contains(h, "备注<b>必须</b>填") || !strings.Contains(h, "转账备注(必填)") {
|
||||||
|
t.Fatal("note 模式应显示基础金额与备注必填")
|
||||||
|
}
|
||||||
|
h = get("claim")
|
||||||
|
if !strings.Contains(h, "0.5<small>") || !strings.Contains(h, "<details open>") || !strings.Contains(h, "不要填备注") {
|
||||||
|
t.Fatal("claim 模式应显示基础金额、不填备注、回填区展开")
|
||||||
|
}
|
||||||
|
if get("bogus") == "" || demoMode("bogus") != "amount" {
|
||||||
|
t.Fatal("非法 mode 应回落到 amount")
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-1
@@ -2,7 +2,10 @@ module bpaygate
|
|||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require modernc.org/sqlite v1.57.0
|
require (
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||||
|
modernc.org/sqlite v1.57.0
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
|
|||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
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=
|
||||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||||
|
|||||||
@@ -14,12 +14,11 @@ h1{font-size:17px;margin:0 0 4px}.muted{color:var(--muted);font-size:13px}
|
|||||||
.amt{font-size:34px;font-weight:700;margin:10px 0 2px;word-break:break-all}
|
.amt{font-size:34px;font-weight:700;margin:10px 0 2px;word-break:break-all}
|
||||||
.amt small{font-size:16px;font-weight:600;margin-left:4px}
|
.amt small{font-size:16px;font-weight:600;margin-left:4px}
|
||||||
.row{display:flex;justify-content:space-between;align-items:center;padding:9px 0;border-top:1px dashed var(--line);gap:10px}
|
.row{display:flex;justify-content:space-between;align-items:center;padding:9px 0;border-top:1px dashed var(--line);gap:10px}
|
||||||
.row:first-of-type{border-top:none}
|
.row:first-child{border-top:none}
|
||||||
.k{color:var(--muted);font-size:13px;white-space:nowrap}.v{font-weight:600;word-break:break-all;text-align:right}
|
.k{color:var(--muted);font-size:13px;white-space:nowrap}.v{font-weight:600;word-break:break-all;text-align:right}
|
||||||
button{border:0;border-radius:9px;padding:9px 14px;font-size:13px;cursor:pointer;background:var(--brand);color:#111;font-weight:600}
|
button{border:0;border-radius:9px;padding:9px 14px;font-size:13px;cursor:pointer;background:var(--brand);color:#111;font-weight:600}
|
||||||
button.ghost{background:transparent;border:1px solid var(--line);color:var(--fg)}
|
|
||||||
.copy{padding:3px 10px;font-size:12px;margin-left:6px;background:transparent;border:1px solid var(--line);color:var(--fg)}
|
.copy{padding:3px 10px;font-size:12px;margin-left:6px;background:transparent;border:1px solid var(--line);color:var(--fg)}
|
||||||
.qr{display:block;margin:12px auto 4px;width:210px;max-width:80%;border-radius:10px}
|
.qr{display:block;margin:12px auto 6px;width:220px;max-width:80%;border-radius:10px;background:#fff;padding:8px}
|
||||||
.tip{background:rgba(240,185,11,.12);border:1px solid rgba(240,185,11,.4);border-radius:10px;padding:10px 12px;font-size:13px;margin-top:12px}
|
.tip{background:rgba(240,185,11,.12);border:1px solid rgba(240,185,11,.4);border-radius:10px;padding:10px 12px;font-size:13px;margin-top:12px}
|
||||||
.status{display:flex;align-items:center;gap:8px;font-weight:600}
|
.status{display:flex;align-items:center;gap:8px;font-weight:600}
|
||||||
.dot{width:9px;height:9px;border-radius:50%;background:var(--brand);animation:b 1.2s infinite}
|
.dot{width:9px;height:9px;border-radius:50%;background:var(--brand);animation:b 1.2s infinite}
|
||||||
@@ -30,6 +29,12 @@ input{width:100%;padding:10px;border:1px solid var(--line);border-radius:9px;bac
|
|||||||
details summary{cursor:pointer;color:var(--muted);font-size:13px}
|
details summary{cursor:pointer;color:var(--muted);font-size:13px}
|
||||||
#claimMsg{font-size:13px;margin-top:8px}
|
#claimMsg{font-size:13px;margin-top:8px}
|
||||||
.center{text-align:center}
|
.center{text-align:center}
|
||||||
|
.tabs{display:flex;gap:6px;margin-bottom:14px}
|
||||||
|
.tab{flex:1;text-align:center;padding:9px 6px;border-radius:9px;border:1px solid var(--line);font-size:13px;cursor:pointer;color:var(--muted);background:transparent}
|
||||||
|
.tab.on{background:var(--brand);color:#111;border-color:var(--brand);font-weight:600}
|
||||||
|
.panel{display:none}.panel.on{display:block}
|
||||||
|
.appbtn{display:block;width:100%;text-align:center;padding:14px;border-radius:12px;background:var(--brand);color:#111;font-weight:700;font-size:16px;text-decoration:none;margin:8px 0 4px}
|
||||||
|
.steps{font-size:13px;color:var(--muted);line-height:1.9;margin:6px 0 0;padding-left:18px}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -37,24 +42,59 @@ details summary{cursor:pointer;color:var(--muted);font-size:13px}
|
|||||||
<div class="card center">
|
<div class="card center">
|
||||||
<h1>请用币安 App「支付」转账</h1>
|
<h1>请用币安 App「支付」转账</h1>
|
||||||
<div class="muted">Binance Pay · 即时到账 · 0 手续费</div>
|
<div class="muted">Binance Pay · 即时到账 · 0 手续费</div>
|
||||||
<div class="amt" id="payAmount">{{.PayAmount}}<small>{{.Currency}}</small></div>
|
<div class="amt" id="payAmount">{{.ShowAmount}}<small>{{.Currency}}</small></div>
|
||||||
<div class="muted">请务必按上方金额<strong>一分不差</strong>支付,用于自动确认</div>
|
{{if eq .Mode "note"}}<div class="muted">体验<strong>备注码匹配</strong>:金额 ≥ {{.ShowAmount}} 即可,不要求精确;<strong>备注必须填 {{.NoteCode}}</strong></div>
|
||||||
<button class="copy" onclick="cp('{{.PayAmount}}',this)">复制金额</button>
|
{{else if eq .Mode "claim"}}<div class="muted">体验<strong>回填订单编号</strong>:支付 {{.ShowAmount}},<strong>不要填备注</strong>,付完在下方粘贴币安订单编号</div>
|
||||||
{{if .HasQR}}<img class="qr" src="/pay/{{.Token}}/qr" alt="收款二维码"><div class="muted">币安 App 扫码,或用下方 UID 转账</div>{{end}}
|
{{else}}<div class="muted">请务必按上方金额<strong>一分不差</strong>支付,用于自动确认</div>{{end}}
|
||||||
|
<button class="copy" onclick="cp('{{.ShowAmount}}',this)">复制金额</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="tabs">
|
||||||
|
{{if .HasQR}}<div class="tab" data-t="scan">扫码支付</div>{{end}}
|
||||||
|
{{if .AppLink}}<div class="tab" data-t="app">打开币安 App</div>{{end}}
|
||||||
|
<div class="tab" data-t="manual">手动转账</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .HasQR}}
|
||||||
|
<div class="panel" id="p-scan">
|
||||||
|
<img class="qr" src="/pay/{{.Token}}/qr" alt="收款二维码">
|
||||||
|
<ol class="steps">
|
||||||
|
<li>打开币安 App,首页点「扫一扫」扫描上方二维码</li>
|
||||||
|
<li>选择币种 <b>{{.Currency}}</b>,输入金额 <b>{{.ShowAmount}}</b></li>
|
||||||
|
<li>{{.NoteHint}},确认支付</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .AppLink}}
|
||||||
|
<div class="panel" id="p-app">
|
||||||
|
<a class="appbtn" href="{{.AppLink}}">打开币安 App 支付</a>
|
||||||
|
<div class="muted center">未安装币安 App 时会打开网页版</div>
|
||||||
|
<ol class="steps">
|
||||||
|
<li>跳转后收款人已自动填好,请核对 UID <b>{{.UID}}</b></li>
|
||||||
|
<li>选择币种 <b>{{.Currency}}</b>,输入金额 <b>{{.ShowAmount}}</b></li>
|
||||||
|
<li>{{.NoteHint}},确认支付后回到本页等待确认</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="panel" id="p-manual">
|
||||||
|
<div class="row"><span class="k">收款人币安 UID</span><span class="v">{{.UID}}<button class="copy" onclick="cp('{{.UID}}',this)">复制</button></span></div>
|
||||||
|
{{if .Email}}<div class="row"><span class="k">或收款邮箱</span><span class="v">{{.Email}}<button class="copy" onclick="cp('{{.Email}}',this)">复制</button></span></div>{{end}}
|
||||||
|
<div class="row"><span class="k">{{.NoteLabel}}</span><span class="v">{{.NoteCode}}<button class="copy" onclick="cp('{{.NoteCode}}',this)">复制</button></span></div>
|
||||||
|
<div class="tip">路径:币安 App → 首页「支付」→ 转账 → 输入 UID → 选 {{.Currency}} → 输入金额 {{.ShowAmount}}。{{.NoteHint}}。</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="row"><span class="k">收款人币安 UID</span><span class="v">{{.UID}}<button class="copy" onclick="cp('{{.UID}}',this)">复制</button></span></div>
|
|
||||||
{{if .Email}}<div class="row"><span class="k">或收款邮箱</span><span class="v">{{.Email}}<button class="copy" onclick="cp('{{.Email}}',this)">复制</button></span></div>{{end}}
|
|
||||||
<div class="row"><span class="k">转账备注(可选)</span><span class="v">{{.NoteCode}}<button class="copy" onclick="cp('{{.NoteCode}}',this)">复制</button></span></div>
|
|
||||||
<div class="row"><span class="k">剩余时间</span><span class="v" id="cd">--:--</span></div>
|
<div class="row"><span class="k">剩余时间</span><span class="v" id="cd">--:--</span></div>
|
||||||
<div class="row"><span class="k">状态</span><span class="v status" id="st"><span class="dot"></span><span id="stTxt">等待支付</span></span></div>
|
<div class="row"><span class="k">状态</span><span class="v status" id="st"><span class="dot"></span><span id="stTxt">等待支付</span></span></div>
|
||||||
<div class="tip">路径:币安 App → 首页「支付」→ 转账 → 输入 UID → 选 {{.Currency}} → 输入金额。备注填 <b>{{.NoteCode}}</b> 可加速确认。</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<details>
|
<details{{if eq .Mode "claim"}} open{{end}}>
|
||||||
<summary>已付款但长时间未确认?回填币安订单编号</summary>
|
<summary>{{if eq .Mode "claim"}}付完款后在这里回填币安订单编号{{else}}已付款但长时间未确认?回填币安订单编号{{end}}</summary>
|
||||||
<p class="muted" style="margin:10px 0 6px">币安 App → 支付 → 交易记录 → 点开这笔转账,复制「订单编号」(18 位数字)粘贴到此处:</p>
|
<p class="muted" style="margin:10px 0 6px">币安 App → 支付 → 交易记录 → 点开这笔转账,复制「订单编号」(18 位数字)粘贴到此处:</p>
|
||||||
<input id="claimInput" inputmode="numeric" placeholder="例如 452021922068888888">
|
<input id="claimInput" inputmode="numeric" placeholder="例如 452021922068888888">
|
||||||
<div style="margin-top:10px"><button onclick="claim()">提交核验</button></div>
|
<div style="margin-top:10px"><button onclick="claim()">提交核验</button></div>
|
||||||
@@ -63,7 +103,10 @@ details summary{cursor:pointer;color:var(--muted);font-size:13px}
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
var expiresAt={{.ExpiresAt}}, token={{.Token}}, done=false;
|
var expiresAt={{.ExpiresAt}}, token={{.Token}}, isMobile={{.IsMobile}}, hasApp={{if .AppLink}}true{{else}}false{{end}}, hasQR={{if .HasQR}}true{{else}}false{{end}}, done=false;
|
||||||
|
function showTab(t){document.querySelectorAll(".tab").forEach(function(e){e.classList.toggle("on",e.dataset.t===t)});document.querySelectorAll(".panel").forEach(function(e){e.classList.toggle("on",e.id==="p-"+t)})}
|
||||||
|
document.querySelectorAll(".tab").forEach(function(e){e.onclick=function(){showTab(e.dataset.t)}});
|
||||||
|
showTab(isMobile?(hasApp?"app":"manual"):(hasQR?"scan":"manual"));
|
||||||
function cp(t,btn){navigator.clipboard&&navigator.clipboard.writeText(t).then(function(){var o=btn.textContent;btn.textContent="已复制";setTimeout(function(){btn.textContent=o},1200)})}
|
function cp(t,btn){navigator.clipboard&&navigator.clipboard.writeText(t).then(function(){var o=btn.textContent;btn.textContent="已复制";setTimeout(function(){btn.textContent=o},1200)})}
|
||||||
function tick(){var left=Math.max(0,Math.floor((expiresAt-Date.now())/1000));var m=String(Math.floor(left/60)).padStart(2,"0"),s=String(left%60).padStart(2,"0");document.getElementById("cd").textContent=m+":"+s;if(left<=0&&!done){setTxt("已超时,若已付款请回填订单编号","bad")}}
|
function tick(){var left=Math.max(0,Math.floor((expiresAt-Date.now())/1000));var m=String(Math.floor(left/60)).padStart(2,"0"),s=String(left%60).padStart(2,"0");document.getElementById("cd").textContent=m+":"+s;if(left<=0&&!done){setTxt("已超时,若已付款请回填订单编号","bad")}}
|
||||||
function setTxt(t,cls){var el=document.getElementById("stTxt");el.textContent=t;el.className=cls||""}
|
function setTxt(t,cls){var el=document.getElementById("stTxt");el.textContent=t;el.className=cls||""}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>BinancePayTool 体验</title>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#f6f7f9;--card:#fff;--fg:#16181d;--muted:#6b7280;--brand:#f0b90b;--ok:#16a34a;--err:#dc2626;--line:#e5e7eb}
|
||||||
|
@media(prefers-color-scheme:dark){:root{--bg:#101216;--card:#181b21;--fg:#e7e9ee;--muted:#9aa1ad;--line:#2a2f38}}
|
||||||
|
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.6 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif}
|
||||||
|
.wrap{max-width:560px;margin:0 auto;padding:24px 16px 48px}
|
||||||
|
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:22px;margin-bottom:14px}
|
||||||
|
h1{font-size:22px;margin:0 0 6px}h2{font-size:15px;margin:0 0 10px}
|
||||||
|
.muted{color:var(--muted);font-size:13px}
|
||||||
|
a{color:inherit}
|
||||||
|
.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:8px 0 14px}
|
||||||
|
.opt{border:1px solid var(--line);border-radius:10px;padding:12px 8px;text-align:center;cursor:pointer;font-size:13px}
|
||||||
|
.opt b{display:block;font-size:17px}
|
||||||
|
.opt.on{border-color:var(--brand);background:rgba(240,185,11,.12)}
|
||||||
|
label{font-size:13px;color:var(--muted)}
|
||||||
|
select{padding:9px 12px;border:1px solid var(--line);border-radius:9px;background:transparent;color:var(--fg);font-size:14px}
|
||||||
|
button{border:0;border-radius:10px;padding:13px 16px;font-size:15px;cursor:pointer;background:var(--brand);color:#111;font-weight:700;width:100%;margin-top:12px}
|
||||||
|
.err{color:var(--err);font-size:13px;margin-top:8px}
|
||||||
|
.ok{color:var(--ok)}
|
||||||
|
ul{margin:6px 0 0;padding-left:18px;font-size:14px}li{margin:4px 0}
|
||||||
|
.modes{display:grid;gap:8px;margin:6px 0 4px}
|
||||||
|
.mode{display:flex;gap:10px;align-items:flex-start;border:1px solid var(--line);border-radius:10px;padding:10px 12px;cursor:pointer;font-size:13px}
|
||||||
|
.mode.on{border-color:var(--brand);background:rgba(240,185,11,.12)}
|
||||||
|
.mode input{margin-top:3px}.mode b{display:block;font-size:14px}.mode span{color:var(--muted)}
|
||||||
|
.warn{background:rgba(220,38,38,.08);border:1px solid rgba(220,38,38,.35);border-radius:10px;padding:10px 12px;font-size:13px;margin-top:12px}
|
||||||
|
code{background:rgba(127,127,127,.15);padding:1px 5px;border-radius:4px;font-size:13px}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="card">
|
||||||
|
<h1>BinancePayTool 体验</h1>
|
||||||
|
<div class="muted">用普通币安个人账户收 Binance Pay 转账的开源收款网关 · <a href="{{.Repo}}" target="_blank" rel="noopener">GitHub</a> · v{{.Version}}</div>
|
||||||
|
<div class="muted" style="margin-top:10px"><b>三种付款方式</b>(收银页按设备自动切换)</div>
|
||||||
|
<ul>
|
||||||
|
<li><b>桌面端</b>:币安 App 扫收银页二维码</li>
|
||||||
|
<li><b>手机端</b>:一键唤起币安 App{{if not .HasApp}}(本站暂未配置){{end}}</li>
|
||||||
|
<li><b>兜底</b>:按 UID 手动转账</li>
|
||||||
|
</ul>
|
||||||
|
<div class="muted" style="margin-top:10px"><b>三种到账确认方式</b>(网关同时生效,下单时可选一种来体验)</div>
|
||||||
|
<ul>
|
||||||
|
<li><b>唯一金额</b>:每单分配唯一尾数,付款金额即订单指纹,零动作自动确认</li>
|
||||||
|
<li><b>备注码</b>:金额不精确也行,备注带上 6 位码即可确认</li>
|
||||||
|
<li><b>回填订单编号</b>:什么都没带?付完粘贴币安订单编号,秒级核验</li>
|
||||||
|
</ul>
|
||||||
|
<div class="muted" style="margin-top:8px">付完款后收银页几秒内自动变绿,这就是网关在用币安官方只读接口对账。</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .Done}}
|
||||||
|
<div class="card">
|
||||||
|
<h2>体验结果</h2>
|
||||||
|
{{if eq .DoneStatus "paid"}}
|
||||||
|
<p class="ok"><b>支付成功 ✓</b> 实收 {{.DoneAmount}} {{.DoneCur}}</p>
|
||||||
|
<p>本笔由「<b>{{.DoneBy}}</b>」确认到账。</p>
|
||||||
|
{{else}}
|
||||||
|
<p>订单状态:<b>{{.DoneStatus}}</b></p>
|
||||||
|
{{end}}
|
||||||
|
<a href="/demo">再来一次</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>选择体验金额</h2>
|
||||||
|
<form method="post" action="/demo/order" id="f">
|
||||||
|
<div class="grid" id="amts">
|
||||||
|
{{range $i, $a := .Amounts}}<div class="opt{{if eq $i 0}} on{{end}}" data-v="{{$a}}"><b>{{$a}}</b>体验</div>{{end}}
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="amount" id="amount" value="{{index .Amounts 0}}">
|
||||||
|
<label>币种 <select name="currency">{{range .Currencies}}<option value="{{.}}">{{.}}</option>{{end}}</select></label>
|
||||||
|
<h2 style="margin-top:16px">选择要体验的到账确认方式</h2>
|
||||||
|
<div class="modes">
|
||||||
|
<label class="mode on"><input type="radio" name="mode" value="amount" checked><div><b>唯一金额(默认)</b><span>按收银页金额一分不差支付,不填备注,付完自动确认</span></div></label>
|
||||||
|
<label class="mode"><input type="radio" name="mode" value="note"><div><b>备注码</b><span>金额 ≥ 基础金额即可,备注必须填 6 位码,靠备注确认</span></div></label>
|
||||||
|
<label class="mode"><input type="radio" name="mode" value="claim"><div><b>回填订单编号</b><span>付基础金额、不填备注,付完在收银页粘贴币安订单编号核验</span></div></label>
|
||||||
|
</div>
|
||||||
|
<button type="submit">去支付</button>
|
||||||
|
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
|
||||||
|
</form>
|
||||||
|
<div class="warn">这是<b>真实转账</b>,付出的加密货币会进入站长账户,仅用于演示,请选择最小金额体验。</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>接入到你的项目</h2>
|
||||||
|
<div class="muted">三步:创建订单 → 跳转收银页 → 处理签名回调。提供 Go / Java / PHP / Python SDK,协议与源码见 <a href="{{.Repo}}" target="_blank" rel="noopener">仓库</a>。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.querySelectorAll(".opt").forEach(function(e){e.onclick=function(){document.querySelectorAll(".opt").forEach(function(x){x.classList.remove("on")});e.classList.add("on");document.getElementById("amount").value=e.dataset.v}});
|
||||||
|
document.querySelectorAll(".mode input").forEach(function(i){i.onchange=function(){document.querySelectorAll(".mode").forEach(function(m){m.classList.toggle("on",m.querySelector("input").checked)})}});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in new issue
Block a user