Files
platform/web/services/trade.go
2026-03-18 13:07:06 +08:00

733 lines
19 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.
package services
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"platform/pkg/env"
"platform/pkg/u"
"platform/web/core"
e "platform/web/events"
g "platform/web/globals"
m "platform/web/models"
q "platform/web/queries"
"time"
"github.com/hibiken/asynq"
"github.com/shopspring/decimal"
wecahtpaycore "github.com/wechatpay-apiv3/wechatpay-go/core"
"github.com/smartwalle/alipay/v3"
"github.com/wechatpay-apiv3/wechatpay-go/services/partnerpayments/h5"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/native"
"gorm.io/gorm"
)
var Trade = &tradeService{}
type tradeService struct {
}
// 创建交易
func (s *tradeService) CreateTrade(uid int32, now time.Time, payment *CreateTradeData, product ProductInfo) (*CreateTradeResult, error) {
platform := payment.Platform
method := payment.Method
tType := product.GetType()
expire := time.Now().Add(30 * time.Minute)
subject := product.GetSubject()
amount := product.GetAmount()
// 实际支付金额,只在创建真实订单时使用
amountReal := amount
if env.RunMode == env.RunModeDev {
amountReal = decimal.NewFromFloat(0.01)
}
// 附加优惠券
if payment.CouponCode != nil {
coupon, err := q.Coupon.
Where(
q.Coupon.Code.Eq(*payment.CouponCode),
q.Coupon.Status.Eq(int(m.CouponStatusUnused)),
).
Take()
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("优惠券不存在或已失效")
}
return nil, err
}
expireAt := time.Time(u.Z(coupon.ExpireAt))
if !expireAt.IsZero() && expireAt.Before(now) {
_, err = q.Coupon.
Where(q.Coupon.ID.Eq(coupon.ID)).
Update(q.Coupon.Status, m.CouponStatusExpired)
if err != nil {
return nil, err
}
return nil, errors.New("优惠券已过期")
}
if amount.Cmp(coupon.MinAmount) < 0 {
return nil, errors.New("订单金额未达到使用优惠券的条件")
}
if coupon.UserID != nil {
switch *coupon.UserID {
// 指定用户的优惠券
case uid:
amount = amount.Sub(coupon.Amount)
if expireAt.IsZero() {
_, err = q.Coupon.
Where(q.Coupon.ID.Eq(coupon.ID)).
Update(q.Coupon.Status, int(m.CouponStatusUsed))
if err != nil {
return nil, err
}
}
// 该优惠券不属于当前用户
default:
return nil, errors.New("优惠券不属于当前用户")
}
} else {
// 公开优惠券
amount = amount.Sub(coupon.Amount)
}
}
// 生成订单号
tradeNo, err := ID.GenSerial()
if err != nil {
return nil, core.NewServErr("生成订单号失败", err)
}
// 提交支付订单
var paymentUrl string
switch {
// 支付宝 + 电脑网站
case method == m.TradeMethodAlipay && platform == m.TradePlatformPC:
resp, err := g.Alipay.TradePagePay(alipay.TradePagePay{
QRPayMode: "4",
QRCodeWidth: "196", // 二维码宽度需要-4支付宝页面布局有问题
Trade: alipay.Trade{
ProductCode: "FAST_INSTANT_TRADE_PAY",
OutTradeNo: tradeNo,
Subject: subject,
TotalAmount: amountReal.StringFixed(2),
TimeExpire: expire.Format("2006-01-02 15:04:05"),
},
})
if err != nil {
return nil, err
}
paymentUrl = resp.String()
// 微信 + 电脑网站
case method == m.TradeMethodWechat && platform == m.TradePlatformPC:
resp, _, err := g.WechatPay.Native.Prepay(context.Background(), native.PrepayRequest{
Appid: &env.WechatPayAppId,
Mchid: &env.WechatPayMchId,
OutTradeNo: &tradeNo,
Description: &subject,
TimeExpire: &expire,
NotifyUrl: &env.WechatPayCallbackUrl,
Amount: &native.Amount{
Total: u.P(amountReal.Mul(decimal.NewFromInt(100)).Round(0).IntPart()),
},
})
if err != nil {
return nil, err
}
paymentUrl = *resp.CodeUrl
// 微信 + 手机网站
case method == m.TradeMethodWechat && platform == m.TradePlatformMobile:
resp, _, err := g.WechatPay.H5.Prepay(context.Background(), h5.PrepayRequest{
SpAppid: &env.WechatPayAppId,
SpMchid: &env.WechatPayMchId,
OutTradeNo: &tradeNo,
Description: &subject,
TimeExpire: &expire,
NotifyUrl: &env.WechatPayCallbackUrl,
Amount: &h5.Amount{
Total: u.P(amountReal.Mul(decimal.NewFromInt(100)).Round(0).IntPart()),
},
})
if err != nil {
return nil, err
}
paymentUrl = *resp.H5Url
// 商福通 + 电脑网站
case
method == m.TradeMethodSftAlipay && platform == m.TradePlatformPC,
method == m.TradeMethodSftWechat && platform == m.TradePlatformPC:
var payType g.SftPayType
switch method {
case m.TradeMethodSftAlipay:
payType = g.SftAlipay
case m.TradeMethodSftWechat:
payType = g.SftWeChat
default:
panic("unhandled default case")
}
resp, err := g.SFTPay.PaymentScanPay(&g.PaymentScanPayReq{
MchOrderNo: tradeNo,
Subject: subject,
Body: subject,
Amount: amountReal.Mul(decimal.NewFromInt(100)).Round(0).IntPart(),
PayType: payType,
Currency: "cny",
ClientIp: "123.52.74.23",
OrderTimeout: u.P(expire.Format("2006-01-02 15:04:05")),
})
if err != nil {
return nil, err
}
paymentUrl = u.Z(u.Z(resp.PayInfo).QrCodeUrl)
// 商福通 + 手机网站
case
method == m.TradeMethodSftAlipay && platform == m.TradePlatformMobile,
method == m.TradeMethodSftWechat && platform == m.TradePlatformMobile:
var payType g.SftPayType
switch method {
case m.TradeMethodSftAlipay:
payType = g.SftAlipay
case m.TradeMethodSftWechat:
payType = g.SftWeChat
default:
panic("unhandled default case")
}
resp, err := g.SFTPay.PaymentH5Pay(&g.PaymentH5PayReq{
MchOrderNo: tradeNo,
Subject: subject,
Body: subject,
Amount: amountReal.Mul(decimal.NewFromInt(100)).Round(0).IntPart(),
PayType: payType,
Currency: "cny",
ClientIp: "123.52.74.23",
OrderTimeout: u.P(expire.Format("2006-01-02 15:04:05")),
})
if err != nil {
return nil, err
}
paymentUrl = u.Z(u.Z(resp.PayInfo).PayUrl)
// 不支持的支付方式
default:
slog.Warn(ErrTransactionNotSupported.Error(), "method", method, "platform", platform)
return nil, ErrTransactionNotSupported
}
// 保存订单
err = q.Trade.Create(&m.Trade{
UserID: uid,
InnerNo: tradeNo,
Type: tType,
Subject: subject,
Amount: amount,
Method: method,
Platform: platform,
PaymentURL: &paymentUrl,
})
if err != nil {
return nil, core.NewServErr("保存交易订单失败", err)
}
// 缓存产品数据
serialized, err := product.Serialize()
if err != nil {
return nil, core.NewServErr("序列化产品信息失败", err)
}
err = g.Redis.Set(
context.Background(),
tradeProductKey(tradeNo),
serialized,
time.Duration(env.TradeExpire+10)*time.Second,
).Err()
if err != nil {
return nil, core.NewServErr("保存购买信息失败", err)
}
// 提交异步关闭事件
closeAt := now.Add(time.Duration(env.TradeExpire) * time.Second)
_, err = g.Asynq.Enqueue(e.NewCancelTrade(e.CompleteTradeData{
TradeNo: tradeNo,
Method: method,
}), asynq.ProcessAt(closeAt))
if err != nil {
return nil, core.NewServErr("提交异步关闭事件失败", err)
}
return &CreateTradeResult{
PaymentUrl: paymentUrl,
TradeNo: tradeNo,
}, nil
}
// 完成交易
func (s *tradeService) CompleteTrade(data *ModifyTradeData) error {
return g.Redsync.WithLock(tradeLockKey(data.TradeNo), func() error {
// 检查订单状态
result, err := s.CheckTrade(data)
if err != nil {
return core.NewServErr("检查订单状态失败", err)
}
if result.Status != m.TradeStatusSuccess {
switch result.Status {
case m.TradeStatusPending:
return core.NewBizErr("订单未支付")
case m.TradeStatusCanceled:
return core.NewBizErr("订单已过期")
}
}
// 更新交易状态
trade, err := completeTrade(&OnTradeCompletedData{
data.TradeNo,
result.TransId,
result.Success,
})
if err != nil {
return core.NewServErr("处理交易失败", err)
}
// 处理交易完成事件
err = afterTradeComplete(trade)
if err != nil {
return core.NewServErr("处理交易完成事件失败", err)
}
return nil
})
}
func (s *tradeService) OnTradeCompleted(data *OnTradeCompletedData) error {
return g.Redsync.WithLock(tradeLockKey(data.TradeNo), func() error {
// 更新交易状态
trade, err := completeTrade(data)
if err != nil {
return core.NewServErr("处理交易失败", err)
}
// 处理交易完成事件
err = afterTradeComplete(trade)
if err != nil {
return core.NewServErr("处理交易完成事件失败", err)
}
return nil
})
}
func completeTrade(data *OnTradeCompletedData) (*m.Trade, error) {
var trade = new(m.Trade)
var err = q.Q.Transaction(func(tx *q.Query) error {
var tradeNo = data.TradeNo
var transId = data.TransId
var payment = data.Payment
var acquirer = data.Acquirer
var paidAt = data.Time
// 获取交易信息
var err error
trade, err = q.Trade.
Where(q.Trade.InnerNo.Eq(tradeNo)).
Take()
if err != nil {
return core.NewBizErr("获取交易信息失败", err)
}
// 检查交易状态
switch trade.Status {
case m.TradeStatusCanceled:
return core.NewBizErr("交易已取消")
case m.TradeStatusSuccess:
return nil // 跳过更新交易信息
case m.TradeStatusPending:
}
// 更新交易信息
trade.Status = m.TradeStatusSuccess
trade.OuterNo = &transId
trade.Payment = payment
trade.Acquirer = u.P(acquirer)
trade.CompletedAt = u.P(paidAt)
rs, err := q.Trade.
Where(q.Trade.InnerNo.Eq(tradeNo), q.Trade.Status.Eq(int(m.TradeStatusPending))).
Updates(trade)
if rs.RowsAffected == 0 {
return core.NewBizErr("交易状态已发生变化")
}
if err != nil {
return core.NewServErr("更新交易信息失败", err)
}
return nil
})
if err != nil {
return nil, err
} else {
return trade, err
}
}
func afterTradeComplete(trade *m.Trade) error {
// 恢复购买信息
productData, err := g.Redis.Get(context.Background(), tradeProductKey(trade.InnerNo)).Result()
if err != nil {
return core.NewServErr("恢复购买信息失败", err)
}
// 执行资源创建
var ComplementEvents = []CompleteEvent{
ResourceOnTradeComplete{},
UserOnTradeComplete{},
}
for _, event := range ComplementEvents {
info, ok := event.Check(trade.Type)
if !ok {
continue
}
err = info.Deserialize(productData)
if err != nil {
return core.NewServErr("反序列化购买信息失败", err)
}
err = event.OnTradeComplete(info, trade)
if err != nil {
return core.NewServErr("处理交易完成事件失败", err)
}
}
return nil
}
// 取消交易
func (s *tradeService) CancelTrade(data *ModifyTradeData, now time.Time) error {
tradeNo := data.TradeNo
method := data.Method
return g.Redsync.WithLock(tradeLockKey(tradeNo), func() error {
switch method {
case m.TradeMethodAlipay:
resp, err := g.Alipay.TradeCancel(context.Background(), alipay.TradeCancel{
OutTradeNo: tradeNo,
})
if err != nil {
return core.NewServErr("上游取消交易失败", err)
}
if resp.Code != alipay.CodeSuccess {
slog.Error("支付宝交易取消失败", "code", resp.Code, "sub_code", resp.SubCode, "msg", resp.Msg)
return errors.New("上游取消交易失败")
}
case m.TradeMethodWechat:
resp, err := g.WechatPay.Native.CloseOrder(context.Background(), native.CloseOrderRequest{
Mchid: &env.WechatPayMchId,
OutTradeNo: &tradeNo,
})
if err != nil {
return core.NewServErr("上游取消交易失败", err)
}
if resp.Response.StatusCode != http.StatusNoContent {
body, err := io.ReadAll(resp.Response.Body)
if err != nil {
slog.Error("读取微信交易取消响应失败", "error", err)
return core.NewServErr("上游取消交易失败", err)
}
slog.Error("微信交易取消失败", "code", resp.Response.StatusCode, "body", string(body))
return errors.New("上游取消交易失败")
}
case m.TradeMethodSft, m.TradeMethodSftAlipay, m.TradeMethodSftWechat:
_, err := g.SFTPay.OrderClose(&g.OrderCloseReq{
MchOrderNo: &tradeNo,
})
if err != nil {
slog.Debug(fmt.Sprintf("订单无需关闭: %s", err.Error()))
return nil
}
default:
return ErrTransactionNotSupported
}
err := cancelTrade(tradeNo, now)
if err != nil {
return err
}
return nil
})
}
func (s *tradeService) OnTradeCanceled(tradeNo string, now time.Time) error {
err := g.Redsync.WithLock(tradeLockKey(tradeNo), func() error {
return cancelTrade(tradeNo, now)
})
if err != nil {
return core.NewServErr("处理交易取消失败", err)
}
return nil
}
func cancelTrade(tradeNo string, now time.Time) error {
return q.Q.Transaction(func(q *q.Query) error {
// 获取交易信息
var status m.TradeStatus
err := q.Trade.
Where(q.Trade.InnerNo.Eq(tradeNo)).
Select(q.Trade.Status).
Scan(&status)
if err != nil {
return core.NewBizErr("获取交易信息失败", err)
}
// 检查交易状态
switch status {
case m.TradeStatusCanceled:
return core.NewBizErr("交易已取消")
case m.TradeStatusSuccess:
return core.NewBizErr("交易已完成")
case m.TradeStatusPending:
}
// 更新交易状态
_, err = q.Trade.
Where(q.Trade.InnerNo.Eq(tradeNo)).
UpdateSimple(
q.Trade.Status.Value(int(m.TradeStatusCanceled)),
q.Trade.CanceledAt.Value(now),
)
if err != nil {
return core.NewServErr("更新交易状态失败", err)
}
return nil
})
}
// 交易退款
func (s *tradeService) RefundTrade(data *ModifyTradeData) error {
panic("todo")
}
func (s *tradeService) OnTradeRefunded(q *q.Query, tradeNo string, now time.Time) error {
panic("todo")
}
// 检查交易状态
func (s *tradeService) CheckTrade(data *ModifyTradeData) (*CheckTradeResult, error) {
var tradeNo = data.TradeNo
var method = data.Method
// 检查交易号是否存在
var result = new(CheckTradeResult)
switch method {
// 支付宝
case m.TradeMethodAlipay:
// 查询交易状态
resp, err := g.Alipay.TradeQuery(context.Background(), alipay.TradeQuery{
OutTradeNo: tradeNo,
})
if err != nil {
return nil, err
}
if resp.Code != alipay.CodeSuccess {
slog.Warn("支付宝交易查询失败", "code", resp.Code, "sub_code", resp.SubCode, "msg", resp.Msg)
return nil, errors.New("交易查询失败")
}
// 填充返回值
result.TransId = resp.TradeNo
switch resp.TradeStatus {
case alipay.TradeStatusWaitBuyerPay:
result.Status = m.TradeStatusPending
case alipay.TradeStatusClosed:
result.Status = m.TradeStatusCanceled
case alipay.TradeStatusSuccess, alipay.TradeStatusFinished:
result.Status = m.TradeStatusSuccess
result.Success = &TradeSuccessResult{}
result.Success.Acquirer = m.TradeAcquirerAlipay
result.Success.Payment, err = decimal.NewFromString(resp.TotalAmount)
if err != nil {
return nil, err
}
result.Success.Time, err = time.Parse("2006-01-02 15:04:05", resp.SendPayDate)
if err != nil {
return nil, err
}
}
// 微信
case m.TradeMethodWechat:
// 查询交易状态
resp, _, err := g.WechatPay.Native.QueryOrderByOutTradeNo(context.Background(), native.QueryOrderByOutTradeNoRequest{
OutTradeNo: &tradeNo,
Mchid: &env.WechatPayMchId,
})
if err != nil {
var apiErr *wecahtpaycore.APIError
if errors.As(err, &apiErr) {
if apiErr.Code == "ORDER_NOT_EXIST" {
return nil, core.NewBizErr("订单不存在")
}
return nil, core.NewServErr(
fmt.Sprintf("微信上游接口异常: code=%vmessage=%v", apiErr.Code, apiErr.Message),
apiErr,
)
}
return nil, core.NewServErr(fmt.Sprintf("微信上游支付接口异常: %s", err.Error()))
}
// 填充返回值
result.TransId = *resp.TransactionId
switch *resp.TradeState {
case "NOTPAY":
result.Status = m.TradeStatusPending
case "CLOSED":
result.Status = m.TradeStatusCanceled
case "SUCCESS", "REFUND":
result.Status = m.TradeStatusSuccess
result.Success = &TradeSuccessResult{}
result.Success.Acquirer = m.TradeAcquirerWechat
result.Success.Payment = decimal.NewFromInt(*resp.Amount.PayerTotal).Div(decimal.NewFromInt(100))
result.Success.Time, err = time.Parse(time.RFC3339, *resp.SuccessTime)
if err != nil {
return nil, err
}
}
// 商福通
case m.TradeMethodSft, m.TradeMethodSftAlipay, m.TradeMethodSftWechat:
// 查询交易状态
resp, err := g.SFTPay.QueryTrade(&g.QueryTradeReq{
MchOrderNo: &tradeNo,
})
if err != nil {
return nil, err
}
if resp.PayOrderId == nil {
return nil, errors.New("商福通交易号不存在")
}
// 填充返回值
result.TransId = *resp.PayOrderId
switch resp.State {
case g.SftInit, g.SftTradeAwait, g.SftTradeFail:
result.Status = m.TradeStatusPending
case g.SftTradeClosed, g.SftTradeCancel:
result.Status = m.TradeStatusCanceled
case g.SftTradeSuccess, g.SftTradeRefund, g.SftRefundIng:
result.Status = m.TradeStatusSuccess
result.Success = &TradeSuccessResult{}
switch resp.PayType {
case "WECHAT":
result.Success.Acquirer = m.TradeAcquirerWechat
case "ALIPAY":
result.Success.Acquirer = m.TradeAcquirerAlipay
case "UNIONPAY":
result.Success.Acquirer = m.TradeAcquirerUnionPay
}
result.Success.Payment = decimal.NewFromInt(resp.Amount).Div(decimal.NewFromInt(100))
result.Success.Time, err = time.Parse("2006-01-02 15:04:05", *resp.PayTime)
if err != nil {
return nil, err
}
}
// 不支持的支付方式
default:
return nil, ErrTransactionNotSupported
}
return result, nil
}
func tradeProductKey(no string) string {
return fmt.Sprintf("trade:%s:product", no)
}
func tradeLockKey(no string) string {
return fmt.Sprintf("trade:%s:lock", no)
}
type CreateTradeData struct {
Platform m.TradePlatform `json:"platform" validate:"required"`
Method m.TradeMethod `json:"method" validate:"required"`
CouponCode *string `json:"coupon_code"`
}
type CreateTradeResult struct {
TradeNo string
PaymentUrl string
}
type ModifyTradeData struct {
TradeNo string `json:"trade_no" query:"trade_no" validate:"required"`
Method m.TradeMethod `json:"method" validate:"required"`
}
type CheckTradeResult struct {
TransId string
Status m.TradeStatus
Success *TradeSuccessResult
}
type TradeSuccessResult struct {
Acquirer m.TradeAcquirer
Payment decimal.Decimal
Time time.Time
}
type OnTradeCompletedData struct {
TradeNo string
TransId string
*TradeSuccessResult
}
type ProductInfo interface {
GetType() m.TradeType
GetSubject() string
GetAmount() decimal.Decimal
Serialize() (string, error)
Deserialize(str string) error
}
type CompleteEvent interface {
Check(t m.TradeType) (ProductInfo, bool)
OnTradeComplete(info ProductInfo, trade *m.Trade) error
}
type TradeErr string
func (e TradeErr) Error() string {
return string(e)
}
var (
ErrTransactionNotSupported = core.NewBizErr("不支持的支付方式")
)