Files
cyi-box/backend/internal/service/pay.go
T
chiyiandDevin 834ecacdb9 cyi-box: Memphis-style toolbox admin platform
Go(fun)+SQLite backend, Vue 3 frontend. Modules: dashboard, pay gateway
(Epay-compatible + CDK stock), license/card system (per-app appid, domain
binding), AI memory bank with built-in MCP server (/mcp, FTS5 search,
signed read links), server SSH monitor + terminal + SFTP, proxy pool with
protocol-level alive checks, DB manager (mysql/pg/sqlite + SQL
autocomplete + import/export), disk share, vault (AES-GCM + TOTP),
bookmarks, dev tools.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-01 01:58:47 +08:00

868 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// pay.go 支付核心:上游 catfk 账号托管、分类/商品自检、卡密库存保障(全程带锁)、
// 订单 RPC、支付轮询与商户异步通知。易支付协议端点在 pay_routes.go。
package service
import (
"context"
"crypto/md5"
"crypto/rand"
"database/sql"
"encoding/hex"
"fmt"
"io"
"log"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/cyi-cc/fun"
"github.com/cyi-cc/cyi-box/backend/internal/config"
"github.com/cyi-cc/cyi-box/backend/internal/db"
"github.com/cyi-cc/cyi-box/backend/internal/dto"
"github.com/cyi-cc/cyi-box/backend/internal/payx"
"github.com/cyi-cc/cyi-box/backend/internal/store"
"github.com/cyi-cc/cyi-box/backend/internal/vault"
)
// ---- 常量与 settings 键 ----
const (
payStatusPending = int64(0)
payStatusPaid = int64(1)
payStatusClosed = int64(3)
payUnitFen = int64(1) // 上游商品单价 0.01 元 = 1 分
payTargetFen = int64(100000) // 库存目标 1000 元 = 100000 分 = 10w 张卡
payCatName = "CDK"
payGoodsName = "CDK0.01元"
payMaxFen = int64(200000) // 单笔上限 2000 元
)
const (
keyPayPid = "pay_pid"
keyPayKey = "pay_key"
keyUpUsername = "up_username"
keyUpPassword = "up_password" // AES 加密存储
keyUpNickname = "up_nickname"
keyUpShop = "up_shop"
keyUpGoodsID = "up_goods_id"
keyUpGoodsKey = "up_goods_key"
keyUpGoodsName = "up_goods_name"
keyUpLastErr = "up_last_error"
)
// ---- 易支付签名与金额工具 ----
// epaySign 标准 MD5 签名:参数名 ASCII 升序拼 k=v&…(忽略 sign/sign_type 与空值),末尾直接拼接商户密钥。
func epaySign(params map[string]string, key string) string {
keys := make([]string, 0, len(params))
for k, v := range params {
if k == "sign" || k == "sign_type" || v == "" {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
for _, k := range keys {
if b.Len() > 0 {
b.WriteByte('&')
}
b.WriteString(k)
b.WriteByte('=')
b.WriteString(params[k])
}
b.WriteString(key)
sum := md5.Sum([]byte(b.String()))
return hex.EncodeToString(sum[:])
}
func epayVerify(params map[string]string, key, sign string) bool {
if params["sign_type"] != "MD5" {
return false
}
return sign != "" && epaySign(params, key) == sign
}
// fenToYuan 分转元字符串,如 199 -> "1.99"。
func fenToYuan(fen int64) string {
sign := ""
if fen < 0 {
sign, fen = "-", -fen
}
return fmt.Sprintf("%s%d.%02d", sign, fen/100, fen%100)
}
// yuanToFen 元字符串转分(最多两位小数)。
func yuanToFen(s string) (int64, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, fmt.Errorf("金额不能为空")
}
if strings.HasPrefix(s, "-") {
return 0, fmt.Errorf("金额不能为负")
}
parts := strings.SplitN(s, ".", 2)
whole, err := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64)
if err != nil {
return 0, fmt.Errorf("金额格式错误")
}
fen := whole * 100
if len(parts) == 2 {
frac := parts[1]
if len(frac) > 2 {
frac = frac[:2]
}
frac += strings.Repeat("0", 2-len(frac))
f, err := strconv.ParseInt(frac, 10, 64)
if err != nil {
return 0, fmt.Errorf("金额格式错误")
}
fen += f
}
return fen, nil
}
// genTradeNo 平台订单号:P + 时间戳 + 16 位随机 hex(不可枚举)。
func genTradeNo() string {
b := make([]byte, 8)
_, _ = rand.Read(b)
return "P" + strconv.FormatInt(time.Now().Unix(), 10) + hex.EncodeToString(b)
}
// ---- 商户凭证 ----
func payMerchant(st *store.Store) (pid, key string, err error) {
if pid, err = st.GetSetting(keyPayPid); err != nil {
return "", "", err
}
if key, err = st.GetSetting(keyPayKey); err != nil {
return "", "", err
}
return pid, key, nil
}
func strSetting(st *store.Store, key string) string {
v, _ := st.GetSetting(key)
return v
}
func intSetting(st *store.Store, key string) int64 {
v, _ := strconv.ParseInt(strSetting(st, key), 10, 64)
return v
}
func anyI64(v any) int64 {
switch t := v.(type) {
case int64:
return t
case float64:
return int64(t)
case []byte:
n, _ := strconv.ParseInt(string(t), 10, 64)
return n
case string:
n, _ := strconv.ParseInt(t, 10, 64)
return n
}
return 0
}
// ---- PaySvc 管理端 RPC ----
type PaySvc struct {
fun.Ctx
Store *store.Store
Up *payx.Client
Cfg *config.Config
}
// List 订单分页。
func (s *PaySvc) List(d dto.PayOrderListDto) (dto.PayOrderPageView, error) {
page, size := d.Page, d.PageSize
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
status := int64(-1)
if d.Status != nil {
status = *d.Status
}
kw := sql.NullString{}
if d.Kw != nil {
kw = sql.NullString{String: strings.TrimSpace(*d.Kw), Valid: true}
}
ctx := context.Background()
arg := db.ListPayOrdersParams{
Column1: status, Status: status,
Column3: kw.String, Column4: kw, Column5: kw,
Limit: size, Offset: (page - 1) * size,
}
rows, err := s.Store.Q().ListPayOrders(ctx, arg)
if err != nil {
return dto.PayOrderPageView{}, fun.Error(5000, "查询订单失败")
}
total, err := s.Store.Q().CountPayOrders(ctx, db.CountPayOrdersParams{
Column1: arg.Column1, Status: arg.Status,
Column3: arg.Column3, Column4: arg.Column4, Column5: arg.Column5,
})
if err != nil {
return dto.PayOrderPageView{}, fun.Error(5000, "统计订单失败")
}
items := make([]dto.PayOrderView, 0, len(rows))
for _, r := range rows {
items = append(items, toPayOrderView(r))
}
return dto.PayOrderPageView{Total: total, Items: items}, nil
}
// Stats 今日与累计统计。
func (s *PaySvc) Stats() (dto.PayStatsView, error) {
now := time.Now()
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Unix()
ctx := context.Background()
today, err := s.Store.Q().PayOrderStats(ctx, dayStart)
if err != nil {
return dto.PayStatsView{}, fun.Error(5000, "统计失败")
}
total, err := s.Store.Q().PayOrderStats(ctx, 0)
if err != nil {
return dto.PayStatsView{}, fun.Error(5000, "统计失败")
}
return dto.PayStatsView{
TodayCount: today.Cnt, TodayMoney: anyI64(today.Money), TodayPaidCount: anyI64(today.Paid),
TotalCount: total.Cnt, TotalMoney: anyI64(total.Money), PendingCount: anyI64(total.Pending),
TotalPaidCount: anyI64(total.Paid),
}, nil
}
// UpstreamStatus 上游账号与商品状态(管理页展示)。
func (s *PaySvc) UpstreamStatus() (dto.PayUpstreamStatusView, error) {
v := dto.PayUpstreamStatusView{
Username: strSetting(s.Store, keyUpUsername),
Nickname: strSetting(s.Store, keyUpNickname),
Shop: strSetting(s.Store, keyUpShop),
GoodsName: strSetting(s.Store, keyUpGoodsName),
LastError: strSetting(s.Store, keyUpLastErr),
TokenAge: s.Up.TokenAge(),
GoodsId: intSetting(s.Store, keyUpGoodsID),
GoodsKey: strSetting(s.Store, keyUpGoodsKey),
UnitPrice: payUnitFen,
TargetMoney: payTargetFen,
}
if payEnsuring.Load() {
v.Ensuring = 1
}
if v.Username != "" {
v.Configured = 1
}
if v.GoodsId > 0 && v.Configured == 1 {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if _, _, stock, err := s.Up.GoodsInfo(ctx, v.GoodsId); err == nil {
v.StockCount = stock
}
if avail, frozen, err := s.Up.WalletInfo(ctx); err == nil {
v.WalletOk, v.WalletAvail, v.WalletFrozen = 1, avail, frozen
}
}
return v, nil
}
// SaveUpstream 保存上游账号并登录验证;成功后异步触发分类/商品自检与库存补齐。
func (s *PaySvc) SaveUpstream(d dto.PayUpstreamDto) (dto.PayUpstreamStatusView, error) {
username := strings.TrimSpace(d.Username)
if username == "" {
return dto.PayUpstreamStatusView{}, fun.Error(4001, "上游账号不能为空")
}
password := d.Password
if password == "" {
u, p, err := s.decryptUpPassword()
if err != nil || u != username || p == "" {
return dto.PayUpstreamStatusView{}, fun.Error(4001, "请输入上游密码")
}
password = p
}
if err := s.Up.SetCredentials(username, password); err != nil {
return dto.PayUpstreamStatusView{}, fun.Error(4002, "上游登录失败:"+err.Error())
}
nickname, shop, err := s.Up.Userinfo(context.Background())
if err != nil {
return dto.PayUpstreamStatusView{}, fun.Error(5000, "获取商户信息失败:"+err.Error())
}
enc, err := vault.Encrypt(s.Cfg.VaultKey, password)
if err != nil {
return dto.PayUpstreamStatusView{}, fun.Error(5000, "凭据加密失败")
}
old := strSetting(s.Store, keyUpUsername)
sets := map[string]string{
keyUpUsername: username, keyUpPassword: enc,
keyUpNickname: nickname, keyUpShop: shop,
}
if old != "" && old != username {
sets[keyUpGoodsID], sets[keyUpGoodsKey], sets[keyUpGoodsName] = "", "", ""
}
for k, v := range sets {
if err := s.Store.SetSetting(k, v); err != nil {
return dto.PayUpstreamStatusView{}, fun.Error(5000, "保存配置失败")
}
}
ensureGoodsAsync(s.Store, s.Up)
return s.UpstreamStatus()
}
// TestPay 创建一笔真实订单走完整上游流程(收银台二维码 + 轮询结算)。
func (s *PaySvc) TestPay(d dto.PayTestPayDto) (dto.PayTestPayView, error) {
money, err := yuanToFen(d.Money)
if err != nil || money <= 0 || money > payMaxFen {
return dto.PayTestPayView{}, fun.Error(4001, "金额非法(0-2000 元)")
}
order, err := createPayOrder(s.Store, s.Up, createPayOrderIn{
Channel: "alipay", Subject: "测试支付", Money: money,
})
if err != nil {
return dto.PayTestPayView{}, fun.Error(5000, err.Error())
}
return dto.PayTestPayView{
TradeNo: order.TradeNo, Payurl: order.Payurl, Qrcode: order.Qrcode,
Money: order.Money, Cashier: "/cashier/" + order.TradeNo,
}, nil
}
// CashierInfo 收银台公开信息(trade_no 不可枚举即凭证)。
func (s *PaySvc) CashierInfo(d dto.PayCashierDto) (dto.PayCashierView, error) {
o, err := s.Store.Q().GetPayOrderByTradeNo(context.Background(), strings.TrimSpace(d.TradeNo))
if err != nil {
return dto.PayCashierView{}, fun.Error(4004, "订单不存在")
}
lazyCloseExpired(s.Store, &o)
return dto.PayCashierView{
TradeNo: o.TradeNo, OutTradeNo: o.OutTradeNo, Subject: o.Subject,
Money: o.Money, Channel: o.Channel, Status: o.Status,
Payurl: o.Payurl, Qrcode: o.Qrcode, Cards: o.Cards,
CreatedAt: o.CreatedAt, ExpiredAt: o.ExpiredAt,
}, nil
}
// Watch 收银台状态流:服务端推送订单状态变化,终态(已支付/已关闭)自动关闭。
func (s *PaySvc) Watch(d dto.PayCashierDto) (*fun.Stream[dto.PayCashierStatusView], error) {
tradeNo := strings.TrimSpace(d.TradeNo)
o, err := s.Store.Q().GetPayOrderByTradeNo(context.Background(), tradeNo)
if err != nil {
return nil, fun.Error(4004, "订单不存在")
}
st := &fun.Stream[dto.PayCashierStatusView]{}
go s.watchLoop(st, tradeNo, o)
return st, nil
}
func (s *PaySvc) watchLoop(st *fun.Stream[dto.PayCashierStatusView], tradeNo string, first db.PayOrder) {
defer st.Close()
last := first.Status
push := func(status int64, o db.PayOrder) bool {
msg := dto.PayCashierStatusView{Status: status}
if status == payStatusPaid && o.ReturnUrl != "" {
if pid, key, err := payMerchant(s.Store); err == nil {
msg.ReturnUrl = buildSignedURL(o.ReturnUrl, notifyParams(o, pid), key)
}
}
return st.Send(msg) == nil
}
if !push(last, first) || last != payStatusPending {
return
}
deadline := time.Now().Add(40 * time.Minute)
t := time.NewTicker(2 * time.Second)
defer t.Stop()
for time.Now().Before(deadline) {
<-t.C
o, err := s.Store.Q().GetPayOrderByTradeNo(context.Background(), tradeNo)
if err != nil {
return
}
lazyCloseExpired(s.Store, &o)
if o.Status != last {
if !push(o.Status, o) {
return
}
last = o.Status
if o.Status != payStatusPending {
return
}
}
}
}
// CashierStatus 收银台轮询:已支付且配置了 return_url 时返回签名回跳地址。
func (s *PaySvc) CashierStatus(d dto.PayCashierDto) (dto.PayCashierStatusView, error) {
o, err := s.Store.Q().GetPayOrderByTradeNo(context.Background(), strings.TrimSpace(d.TradeNo))
if err != nil {
return dto.PayCashierStatusView{}, fun.Error(4004, "订单不存在")
}
res := dto.PayCashierStatusView{Status: o.Status}
if o.Status == payStatusPaid && o.ReturnUrl != "" {
pid, key, err := payMerchant(s.Store)
if err == nil {
res.ReturnUrl = buildSignedURL(o.ReturnUrl, notifyParams(o, pid), key)
}
}
return res, nil
}
func toPayOrderView(r db.PayOrder) dto.PayOrderView {
return dto.PayOrderView{
Id: r.ID, TradeNo: r.TradeNo, OutTradeNo: r.OutTradeNo, Channel: r.Channel,
Subject: r.Subject, Money: r.Money, Status: r.Status, Notified: r.Notified,
Payurl: r.Payurl, PaidAt: r.PaidAt, ExpiredAt: r.ExpiredAt, CreatedAt: r.CreatedAt,
}
}
func lazyCloseExpired(st *store.Store, o *db.PayOrder) {
if o.Status == payStatusPending && o.ExpiredAt > 0 && o.ExpiredAt < time.Now().Unix() {
_ = st.Q().SetPayOrderStatus(context.Background(), db.SetPayOrderStatusParams{
Status: payStatusClosed, PaidAt: o.PaidAt, Cards: o.Cards, ID: o.ID,
})
o.Status = payStatusClosed
}
}
func (s *PaySvc) decryptUpPassword() (string, string, error) {
u := strSetting(s.Store, keyUpUsername)
enc := strSetting(s.Store, keyUpPassword)
if u == "" || enc == "" {
return "", "", fmt.Errorf("未保存上游凭据")
}
p, err := vault.Decrypt(s.Cfg.VaultKey, enc)
return u, p, err
}
// ---- 商品自检 + 库存保障 ----
var payEnsuring atomic.Bool
// ensureGoodsAsync 后台幂等确保分类「CDK」与商品「CDK0.01元」存在并补齐库存。
func ensureGoodsAsync(st *store.Store, up *payx.Client) {
if !payEnsuring.CompareAndSwap(false, true) {
return
}
go func() {
defer payEnsuring.Store(false)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
if err := ensureGoods(ctx, st, up); err != nil {
_ = st.SetSetting(keyUpLastErr, err.Error())
log.Printf("[pay] 上游商品自检失败: %v", err)
return
}
_ = st.SetSetting(keyUpLastErr, "")
}()
}
// ensureGoods 幂等自检:分类 CDK → 商品 CDK0.01元(¥0.01)→ 库存补到 1000 元(10w 张,1w/批)。
func ensureGoods(ctx context.Context, st *store.Store, up *payx.Client) error {
goodsID := intSetting(st, keyUpGoodsID)
goodsKey := strSetting(st, keyUpGoodsKey)
target := payTargetFen / payUnitFen // 10w 张
if goodsID > 0 && goodsKey != "" {
return ensureStockTo(ctx, up, goodsID, target)
}
list, total, err := up.GoodsList(ctx, "", 1)
if err != nil {
return err
}
for page := int64(1); ; page++ {
for _, it := range list {
if it.Name == payGoodsName && it.Price == payUnitFen && it.Status == 1 {
goodsID, goodsKey = it.ID, it.GoodsKey
}
}
if goodsID != 0 || page*20 >= total {
break
}
list, _, err = up.GoodsList(ctx, "", page+1)
if err != nil {
return err
}
}
if goodsID == 0 {
var catID int64
cats, err := up.CategoryListAll(ctx)
if err != nil {
return err
}
for _, cat := range cats {
if cat.Name == payCatName {
catID = cat.ID
break
}
}
if catID == 0 {
if err := up.CategoryAdd(ctx, payCatName); err != nil {
return err
}
cats, err = up.CategoryListAll(ctx)
if err != nil {
return err
}
for _, cat := range cats {
if cat.Name == payCatName {
catID = cat.ID
break
}
}
}
if catID == 0 {
return fmt.Errorf("上游分类「%s」创建失败", payCatName)
}
if goodsID, goodsKey, err = up.GoodsAdd(ctx, payGoodsName, catID, 0.01); err != nil {
return err
}
if goodsID <= 0 || goodsKey == "" {
return fmt.Errorf("上游商品创建未返回有效标识")
}
log.Printf("[pay] 已创建上游商品「%s」id=%d", payGoodsName, goodsID)
}
for k, v := range map[string]string{
keyUpGoodsID: strconv.FormatInt(goodsID, 10),
keyUpGoodsKey: goodsKey,
keyUpGoodsName: payGoodsName,
} {
if err := st.SetSetting(k, v); err != nil {
return err
}
}
return ensureStockTo(ctx, up, goodsID, target)
}
// ensureStockTo 全程带锁把库存补到 target 张。
func ensureStockTo(ctx context.Context, up *payx.Client, goodsID, target int64) error {
unlock := up.StockLock()
defer unlock()
_, err := up.EnsureStock(ctx, goodsID, target)
return err
}
// ---- 下单 ----
type createPayOrderIn struct {
OutTradeNo string
Channel string
Subject string
Money int64 // 分
NotifyURL string
ReturnURL string
ClientIP string
}
// createPayOrder 本地建单 → 库存保障(带锁)→ 上游下单 → 写回 payurl/二维码。
// 幂等:同 out_trade_no 复用未支付订单。
func createPayOrder(st *store.Store, up *payx.Client, in createPayOrderIn) (db.PayOrder, error) {
now := time.Now().Unix()
tradeNo := genTradeNo()
outNo := in.OutTradeNo
if outNo == "" {
outNo = tradeNo // 内部测试单:商户单号=平台单号
}
if exist, err := st.Q().GetPayOrderByOutNo(context.Background(), outNo); err == nil {
switch {
case exist.Money != in.Money:
return db.PayOrder{}, fmt.Errorf("商户订单号重复且金额不一致")
case exist.Status == payStatusPaid:
return db.PayOrder{}, fmt.Errorf("该订单已支付")
case exist.Status != payStatusPending || (exist.ExpiredAt > 0 && exist.ExpiredAt < now):
return db.PayOrder{}, fmt.Errorf("订单已关闭,请更换订单号")
default:
return exist, nil
}
}
goodsID := intSetting(st, keyUpGoodsID)
goodsKey := strSetting(st, keyUpGoodsKey)
if goodsID <= 0 || goodsKey == "" {
return db.PayOrder{}, fmt.Errorf("上游商品未就绪:请先在支付配置页保存上游账号")
}
if in.Money%payUnitFen != 0 || in.Money/payUnitFen <= 0 {
return db.PayOrder{}, fmt.Errorf("金额必须是 ¥0.01 的整数倍")
}
quantity := in.Money / payUnitFen
id, err := st.Q().CreatePayOrder(context.Background(), db.CreatePayOrderParams{
TradeNo: tradeNo, OutTradeNo: outNo, Channel: in.Channel,
Subject: in.Subject, Money: in.Money, Fee: 0,
NotifyUrl: in.NotifyURL, ReturnUrl: in.ReturnURL, ClientIp: in.ClientIP,
ExpiredAt: now + 30*60, CreatedAt: now,
})
if err != nil {
return db.PayOrder{}, fmt.Errorf("创建订单失败")
}
o, err := st.Q().GetPayOrder(context.Background(), id)
if err != nil {
return db.PayOrder{}, fmt.Errorf("创建订单失败")
}
if err := placeUpstream(st, up, &o, goodsID, goodsKey, quantity); err != nil {
if o.UpstreamTradeNo == "" {
_ = st.Q().SetPayOrderStatus(context.Background(), db.SetPayOrderStatusParams{
Status: payStatusClosed, PaidAt: 0, Cards: "", ID: o.ID,
})
}
return db.PayOrder{}, err
}
return o, nil
}
// placeUpstream 库存保障(全程带锁)→ 上游下单 → 回写。
func placeUpstream(st *store.Store, up *payx.Client, o *db.PayOrder, goodsID int64, goodsKey string, quantity int64) error {
ctx := context.Background()
shop := strSetting(st, keyUpShop)
if shop == "" {
return fmt.Errorf("上游店铺未配置")
}
channelID, err := up.AlipayChannelID(ctx, shop)
if err != nil {
return fmt.Errorf("上游支付通道不可用:%v", err)
}
contact, queryPwd := payx.RandContact(), payx.RandQueryPwd()
unlock := up.StockLock()
res, err := placeUnderLock(ctx, up, goodsID, goodsKey, quantity, channelID, contact, queryPwd)
unlock()
if err != nil {
return err
}
o.Payurl = res.PayURL
o.UpstreamTradeNo = res.TradeNo
if res.TotalAmount != o.Money {
log.Printf("[pay] 订单 %s 上游金额 %s 与本地 %s 不一致", o.TradeNo, fenToYuan(res.TotalAmount), fenToYuan(o.Money))
}
if err := st.Q().SetPayOrderUpstream(ctx, db.SetPayOrderUpstreamParams{
UpstreamTradeNo: res.TradeNo, GoodsKey: goodsKey, Quantity: quantity,
UnitPrice: payUnitFen, Payurl: res.PayURL, BuyerContact: contact,
QueryPwd: queryPwd, ID: o.ID,
}); err != nil {
return fmt.Errorf("回写上游订单失败")
}
if qr, qrErr := up.FetchQRCode(ctx, res.TradeNo); qrErr == nil && qr != "" {
_ = st.Q().SetPayOrderQrcode(ctx, db.SetPayOrderQrcodeParams{Qrcode: qr, ID: o.ID})
o.Qrcode = qr
} else if qrErr != nil {
log.Printf("[pay] 订单 %s 取二维码失败: %v(payurl 仍可用)", o.TradeNo, qrErr)
}
return nil
}
// placeUnderLock 持锁执行:库存不足先补齐(补到 max(10w, 当前+需求))再下单。
func placeUnderLock(ctx context.Context, up *payx.Client, goodsID int64, goodsKey string, quantity, channelID int64, contact, queryPwd string) (payx.PayOrderResult, error) {
target := payTargetFen / payUnitFen
_, _, stock, err := up.GoodsInfo(ctx, goodsID)
if err != nil {
return payx.PayOrderResult{}, fmt.Errorf("上游库存查询失败:%v", err)
}
if stock < quantity {
goal := target
if stock+quantity > goal {
goal = stock + quantity
}
log.Printf("[pay] 库存 %d 不足订单需求 %d,补到 %d", stock, quantity, goal)
if _, err := up.EnsureStock(ctx, goodsID, goal); err != nil {
return payx.PayOrderResult{}, fmt.Errorf("上游库存补充失败:%v", err)
}
}
res, err := up.CreateOrder(ctx, goodsKey, quantity, channelID, contact, queryPwd)
if err != nil && strings.Contains(err.Error(), "库存") {
if _, serr := up.EnsureStock(ctx, goodsID, target+quantity); serr != nil {
return payx.PayOrderResult{}, fmt.Errorf("上游库存补充失败:%v", serr)
}
res, err = up.CreateOrder(ctx, goodsKey, quantity, channelID, contact, queryPwd)
}
if err != nil {
return payx.PayOrderResult{}, fmt.Errorf("上游下单失败:%v", err)
}
return res, nil
}
// ---- 支付轮询 ----
// PayPoller 待支付订单轮询器(fun.Wired 单例,5s 一扫)。
type PayPoller struct {
Store *store.Store `fun:"auto"`
Up *payx.Client `fun:"auto"`
Notify *PayNotifier `fun:"auto"`
}
func (p *PayPoller) New() {
go p.loop()
}
func (p *PayPoller) loop() {
t := time.NewTicker(5 * time.Second)
defer t.Stop()
for range t.C {
p.sweep()
}
}
func (p *PayPoller) sweep() {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
rows, err := p.Store.Q().ListPendingPayOrders(ctx)
if err != nil {
return
}
now := time.Now().Unix()
for _, o := range rows {
if o.ExpiredAt > 0 && o.ExpiredAt < now {
_ = p.Store.Q().SetPayOrderStatus(ctx, db.SetPayOrderStatusParams{
Status: payStatusClosed, PaidAt: 0, Cards: "", ID: o.ID,
})
continue
}
paid, err := p.Up.OrderPaid(ctx, o.UpstreamTradeNo)
if err != nil || !paid {
continue
}
cards, _ := p.Up.OrderCards(ctx, o.UpstreamTradeNo)
_ = p.Store.Q().SetPayOrderStatus(ctx, db.SetPayOrderStatusParams{
Status: payStatusPaid, PaidAt: now, Cards: strings.Join(cards, "\n"), ID: o.ID,
})
log.Printf("[pay] 订单 %s 已支付", o.TradeNo)
p.Notify.OnPaid(o.TradeNo)
// 库存被消耗:异步补回目标水位
if g := intSetting(p.Store, keyUpGoodsID); g > 0 {
ensureStockAsync(p.Store, p.Up, g, payTargetFen/payUnitFen)
}
}
}
var stockFilling atomic.Bool
func ensureStockAsync(st *store.Store, up *payx.Client, goodsID, target int64) {
if !stockFilling.CompareAndSwap(false, true) {
return
}
go func() {
defer stockFilling.Store(false)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
if err := ensureStockTo(ctx, up, goodsID, target); err != nil {
log.Printf("[pay] 库存回充失败: %v", err)
}
}()
}
// ---- 商户异步通知 ----
// PayNotifier 商户异步通知器(fun.Wired 单例)。
type PayNotifier struct {
Store *store.Store `fun:"auto"`
}
var notifyBackoff = []time.Duration{0, 15 * time.Second, time.Minute, 5 * time.Minute, 15 * time.Minute}
// notifyParams 通知/回跳参数(未签名)。
func notifyParams(o db.PayOrder, pid string) map[string]string {
return map[string]string{
"pid": pid,
"trade_no": o.TradeNo,
"out_trade_no": o.OutTradeNo,
"type": o.Channel,
"name": o.Subject,
"money": fenToYuan(o.Money),
"trade_status": "TRADE_SUCCESS",
}
}
// buildSignedURL 签名参数拼接到回调地址。
func buildSignedURL(base string, params map[string]string, key string) string {
u, err := url.Parse(base)
if err != nil {
return base
}
q := u.Query()
for k, v := range params {
q.Set(k, v)
}
q.Set("sign_type", "MD5")
q.Set("sign", epaySign(params, key))
u.RawQuery = q.Encode()
return u.String()
}
// OnPaid 异步投递商户 notify_url(退避:立即/15s/1m/5m/15m)。
func (n *PayNotifier) OnPaid(tradeNo string) {
go n.deliver(tradeNo)
}
func (n *PayNotifier) deliver(tradeNo string) {
for i, delay := range notifyBackoff {
if delay > 0 {
time.Sleep(delay)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
o, err := n.Store.Q().GetPayOrderByTradeNo(ctx, tradeNo)
cancel()
if err != nil || o.Status != payStatusPaid || o.Notified == 1 || o.NotifyUrl == "" {
return
}
if n.attempt(o) {
_ = n.Store.Q().SetPayNotified(context.Background(), o.ID)
return
}
_ = n.Store.Q().IncPayNotifyAttempts(context.Background(), o.ID)
log.Printf("[pay] %s 第 %d 次通知未确认", tradeNo, i+1)
}
}
func (n *PayNotifier) attempt(o db.PayOrder) bool {
pid, key, err := payMerchant(n.Store)
if err != nil {
return false
}
params := notifyParams(o, pid)
params["sign"] = epaySign(params, key)
params["sign_type"] = "MD5"
form := url.Values{}
for k, v := range params {
form.Set(k, v)
}
cli := &http.Client{Timeout: 10 * time.Second}
if resp, err := cli.Post(o.NotifyUrl, "application/x-www-form-urlencoded",
strings.NewReader(form.Encode())); err == nil && notifyRespOK(resp) {
return true
}
sep := "?"
if strings.Contains(o.NotifyUrl, "?") {
sep = "&"
}
if resp, err := cli.Get(o.NotifyUrl + sep + form.Encode()); err == nil {
return notifyRespOK(resp)
}
return false
}
func notifyRespOK(resp *http.Response) bool {
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
return resp.StatusCode == 200 && strings.Contains(strings.ToLower(string(body)), "success")
}
// BootPay 启动时装配上游凭据(若有)并触发商品自检。
func BootPay(st *store.Store, up *payx.Client, cfg *config.Config) {
username := strSetting(st, keyUpUsername)
enc := strSetting(st, keyUpPassword)
if username == "" || enc == "" {
return
}
pass, err := vault.Decrypt(cfg.VaultKey, enc)
if err != nil || pass == "" {
log.Printf("[pay] 上游凭据解密失败: %v", err)
return
}
up.SetCredentialsLazy(username, pass)
ensureGoodsAsync(st, up)
}