- submit.php 验签建单后不再 302,直接渲染自包含收银台 HTML, 支付宝二维码由服务端 skip2/go-qrcode 生成 PNG data URL 内嵌 - 新增公开轮询端点 /cashier-status(页面经 /api 前缀访问), 与 PaySvc.CashierStatus 共用 cashierStatusReply - Cashier.vue 收银台页同步改版:取景框二维码、倒计时进度条, 去掉跳上游收银台的外链 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
861 lines
26 KiB
Go
861 lines
26 KiB
Go
// 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) {
|
||
status, returnURL, err := cashierStatusReply(s.Store, strings.TrimSpace(d.TradeNo))
|
||
if err != nil {
|
||
return dto.PayCashierStatusView{}, fun.Error(4004, "订单不存在")
|
||
}
|
||
return dto.PayCashierStatusView{Status: status, ReturnUrl: returnURL}, 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)
|
||
}
|