Files
platform/web/services/trade.go

635 lines
16 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 (
"bytes"
"context"
"encoding/gob"
"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"
)
func init() {
gob.Register(&CreateResourceData{})
gob.Register(&UpdateBalanceData{})
}
var Trade = &tradeService{}
type tradeService struct {
}
// 创建交易
func (s *tradeService) Create(user *m.User, tradeData *CreateTradeData, productData ProductData) (*CreateTradeResult, error) {
if user == nil {
return nil, core.NewBizErr("用户未登录")
}
detail, err := productData.TradeDetail(user)
if err != nil {
return nil, core.NewServErr("获取产品支付信息失败", err)
}
now := time.Now()
platform := tradeData.Platform
method := tradeData.Method
expireIn := time.Duration(env.TradeExpire) * time.Second
expireAt := now.Add(expireIn)
// 生成订单号
tradeNo, err := ID.GenSerial()
if err != nil {
return nil, core.NewServErr("生成订单号失败", err)
}
// 实际支付金额,只在创建真实订单时使用
amountReal := detail.Actual
if env.RunMode == env.RunModeDev {
amountReal = decimal.NewFromFloat(0.01)
}
// 提交支付订单
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: detail.Subject,
TotalAmount: amountReal.StringFixed(2),
TimeExpire: expireAt.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: &detail.Subject,
TimeExpire: &expireAt,
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: &detail.Subject,
TimeExpire: &expireAt,
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
}
resp, err := g.SFTPay.PaymentScanPay(&g.PaymentScanPayReq{
MchOrderNo: tradeNo,
Subject: detail.Subject,
Body: detail.Subject,
Amount: amountReal.Mul(decimal.NewFromInt(100)).Round(0).IntPart(),
PayType: payType,
Currency: "cny",
ClientIp: "123.52.74.23",
OrderTimeout: u.P(expireAt.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
}
resp, err := g.SFTPay.PaymentH5Pay(&g.PaymentH5PayReq{
MchOrderNo: tradeNo,
Subject: detail.Subject,
Body: detail.Subject,
Amount: amountReal.Mul(decimal.NewFromInt(100)).Round(0).IntPart(),
PayType: payType,
Currency: "cny",
ClientIp: "123.52.74.23",
OrderTimeout: u.P(expireAt.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: user.ID,
InnerNo: tradeNo,
Type: detail.Type,
Subject: detail.Subject,
Amount: detail.Actual,
Method: method,
Platform: platform,
PaymentURL: &paymentUrl,
})
if err != nil {
return nil, core.NewServErr("保存交易订单失败", err)
}
// 缓存产品数据
w := bytes.Buffer{}
gob.NewEncoder(&w).Encode(detail)
err = g.Redis.Set(
context.Background(),
tradeProductKey(tradeNo),
w.Bytes(),
expireIn,
).Err()
if err != nil {
return nil, core.NewServErr("保存购买信息失败", err)
}
// 提交异步关闭事件
_, err = g.Asynq.Enqueue(e.NewCloseTradeTask(user.ID, tradeNo, method), asynq.ProcessAt(expireAt))
if err != nil {
return nil, core.NewServErr("提交异步关闭事件失败", err)
}
return &CreateTradeResult{
PayUrl: paymentUrl,
TradeNo: tradeNo,
}, nil
}
// 完成交易
func (s *tradeService) CompleteTrade(user *m.User, ref *TradeRef) error {
// 检查订单状态
result, err := s.CheckTrade(ref)
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("订单已过期")
}
}
// 更新交易状态
err = s.OnCompleteTrade(user, ref.TradeNo, result.TransId, &result.Success)
if err != nil {
return core.NewServErr("处理交易失败", err)
}
return nil
}
func (s *tradeService) OnCompleteTrade(user *m.User, interNo string, outerNo string, result *TradeSuccessResult) error {
// 获取交易信息
trade, err := q.Trade.
Where(q.Trade.InnerNo.Eq(interNo)).
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:
}
// 恢复购买信息;如果反序列化失败,检查开头 init 函数中是否注册了对应的 struct 类型
detailBytes, err := g.Redis.Get(context.Background(), tradeProductKey(interNo)).Bytes()
if err != nil {
return core.NewServErr("恢复购买信息失败", err)
}
var detail TradeDetail
r := bytes.NewReader(detailBytes)
if err := gob.NewDecoder(r).Decode(&detail); err != nil {
return core.NewServErr("解析购买信息失败", err)
}
err = q.Q.Transaction(func(q *q.Query) error {
// 更新交易信息
_, err := q.Trade.
Where(
q.Trade.InnerNo.Eq(interNo),
q.Trade.Status.Eq(int(m.TradeStatusPending)),
).
UpdateSimple(
q.Trade.Status.Value(int(m.TradeStatusSuccess)),
q.Trade.OuterNo.Value(outerNo),
q.Trade.Payment.Value(result.Actual),
q.Trade.Acquirer.Value(int(result.Acquirer)),
q.Trade.CompletedAt.Value(result.Time),
)
if err != nil {
return core.NewServErr("更新交易信息失败", err)
}
switch trade.Type {
case m.TradeTypeRecharge:
// 更新用户余额
if err := User.UpdateBalance(q, user, detail.Actual, "充值余额", nil); err != nil {
return err
}
// 生成账单
err = Bill.CreateForBalance(q, user.ID, trade.ID, &detail)
if err != nil {
return core.NewServErr("生成账单失败", err)
}
case m.TradeTypePurchase:
data, ok := detail.Product.(*CreateResourceData)
if !ok {
return core.NewServErr("购买信息解析失败", nil)
}
// 保存套餐
resource, err := Resource.Create(q, user.ID, result.Time, data)
if err != nil {
return core.NewServErr("创建套餐失败", err)
}
// 生成账单
err = Bill.CreateForResource(q, user.ID, resource.ID, &trade.ID, &detail)
if err != nil {
return core.NewServErr("生成账单失败", err)
}
// 核销优惠券
if detail.CouponUserId != nil {
err = Coupon.UseCoupon(q, *detail.CouponUserId)
if err != nil {
return core.NewServErr("核销优惠券失败", err)
}
}
}
return nil
})
if err != nil {
return err
}
return nil
}
// 取消交易
func (s *tradeService) CancelTrade(ref *TradeRef) error {
now := time.Now()
switch ref.Method {
case m.TradeMethodAlipay:
resp, err := g.Alipay.TradeCancel(context.Background(), alipay.TradeCancel{
OutTradeNo: ref.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: &ref.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: &ref.TradeNo,
})
if err != nil {
slog.Debug(fmt.Sprintf("订单无需关闭: %s", err.Error()))
return nil
}
default:
return ErrTransactionNotSupported
}
err := s.OnCancelTrade(ref.TradeNo, now)
if err != nil {
return err
}
return nil
}
func (s *tradeService) OnCancelTrade(tradeNo string, now time.Time) error {
_, err := q.Trade.
Where(
q.Trade.InnerNo.Eq(tradeNo),
q.Trade.Status.Eq(int(m.TradeStatusPending)),
).
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(ref *TradeRef) error {
panic("todo")
}
func (s *tradeService) OnRefundTrade(q *q.Query, tradeNo string, now time.Time) error {
panic("todo")
}
// 检查交易状态
func (s *tradeService) CheckTrade(ref *TradeRef) (*CheckTradeResult, error) {
var tradeNo = ref.TradeNo
var method = ref.Method
// 检查交易号是否存在
var result 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.Acquirer = m.TradeAcquirerAlipay
result.Success.Actual, err = decimal.NewFromString(resp.ReceiptAmount)
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.Acquirer = m.TradeAcquirerWechat
result.Success.Actual = 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
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.Actual = 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"`
}
type CreateTradeResult struct {
PayUrl string `json:"pay_url"`
TradeNo string `json:"trade_no"`
}
type TradeRef 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
Actual decimal.Decimal
Time time.Time
}
type OnTradeCompletedData struct {
TradeNo string
TransId string
*TradeSuccessResult
}
type ProductData interface {
TradeDetail(user *m.User) (*TradeDetail, error)
}
type TradeDetail struct {
Product ProductData `json:"product"`
Type m.TradeType `json:"type"`
Subject string `json:"subject"`
Amount decimal.Decimal `json:"amount"`
Actual decimal.Decimal `json:"actual"`
DiscountId *int32 `json:"discount_id,omitempty"`
CouponUserId *int32 `json:"coupon_id,omitempty"`
}
type TradeErr string
func (e TradeErr) Error() string {
return string(e)
}
var (
ErrTransactionNotSupported = core.NewBizErr("不支持的支付方式")
)