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>
This commit is contained in:
2026-10-01 01:58:47 +08:00
co-authored by Devin
commit 834ecacdb9
326 changed files with 27813 additions and 0 deletions
+867
View File
@@ -0,0 +1,867 @@
// 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)
}