Files
observer/server/biz/service/payment.go
T

381 lines
13 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 service
import (
"bytes"
"context"
"fmt"
"math"
"net/http"
"net/url"
"os"
"strconv"
"sync"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"github.com/smartwalle/alipay/v3"
"github.com/wechatpay-apiv3/wechatpay-go/core"
"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
"github.com/wechatpay-apiv3/wechatpay-go/core/downloader"
"github.com/wechatpay-apiv3/wechatpay-go/core/notify"
"github.com/wechatpay-apiv3/wechatpay-go/core/option"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/app"
"github.com/wechatpay-apiv3/wechatpay-go/utils"
"observer-server/biz/consts"
"observer-server/biz/dao"
"observer-server/biz/model/dto"
"observer-server/biz/model/entity"
"observer-server/common"
)
// paymentService 支付渠道接入:统一下单、回调验签落授权(回调是唯一授权来源,confirm 仅加速刷新)
type paymentService struct{}
var Payment = &paymentService{}
// ensureChannelConfigured 渠道未配置时拒绝下单(避免产生无法支付的订单)
func (s *paymentService) ensureChannelConfigured(ctx context.Context, channel string) error {
switch channel {
case consts.ChannelWechat:
if !wechatConf(ctx).configured() {
return gerror.NewCode(common.CodePaymentNotConfigured, "微信支付未配置")
}
case consts.ChannelAlipay:
if !alipayConf(ctx).configured() {
return gerror.NewCode(common.CodePaymentNotConfigured, "支付宝未配置")
}
}
return nil
}
// CreatePayParams 渠道统一下单,返回客户端拉起支付参数
func (s *paymentService) CreatePayParams(ctx context.Context, order *entity.PaymentOrder, planLabel, channel string) (*dto.PayParams, error) {
switch channel {
case consts.ChannelWechat:
return s.wechatPrepay(ctx, order, planLabel)
case consts.ChannelAlipay:
return s.alipayPrepay(ctx, order, planLabel)
}
return nil, gerror.Newf("未知支付渠道: %s", channel)
}
// ---------- 微信支付(APIv3 APP 支付) ----------
type wechatConfig struct {
appid string
mchid string
apiV3Key string
serialNo string
privateKey string
notifyUrl string
}
func wechatConf(ctx context.Context) wechatConfig {
return wechatConfig{
appid: g.Cfg().MustGet(ctx, "payment.wechat.appid", "").String(),
mchid: g.Cfg().MustGet(ctx, "payment.wechat.mchid", "").String(),
apiV3Key: g.Cfg().MustGet(ctx, "payment.wechat.apiV3Key", "").String(),
serialNo: g.Cfg().MustGet(ctx, "payment.wechat.serialNo", "").String(),
privateKey: g.Cfg().MustGet(ctx, "payment.wechat.privateKey", "").String(),
notifyUrl: g.Cfg().MustGet(ctx, "payment.wechat.notifyUrl", "").String(),
}
}
func (c wechatConfig) configured() bool {
return c.appid != "" && c.mchid != "" && c.apiV3Key != "" && c.serialNo != "" &&
c.privateKey != "" && c.notifyUrl != ""
}
var (
wechatOnce sync.Once
wechatClient *core.Client
wechatNotifyHdl *notify.Handler
wechatErr error
)
// wechatClientInstance 懒加载微信客户端与回调处理器(配置静态,进程内只初始化一次)
func wechatClientInstance() (*core.Client, *notify.Handler, error) {
wechatOnce.Do(func() {
ctx := context.Background()
cfg := wechatConf(ctx)
if !cfg.configured() {
wechatErr = gerror.NewCode(common.CodePaymentNotConfigured, "微信支付未配置")
return
}
key, err := utils.LoadPrivateKeyWithPath(cfg.privateKey)
if err != nil {
wechatErr = gerror.Wrap(err, "加载微信商户私钥失败")
return
}
client, err := core.NewClient(ctx, option.WithWechatPayAutoAuthCipher(cfg.mchid, cfg.serialNo, key, cfg.apiV3Key))
if err != nil {
wechatErr = gerror.Wrap(err, "初始化微信支付客户端失败")
return
}
visitor := downloader.MgrInstance().GetCertificateVisitor(cfg.mchid)
handler, err := notify.NewRSANotifyHandler(cfg.apiV3Key, verifiers.NewSHA256WithRSAVerifier(visitor))
if err != nil {
wechatErr = gerror.Wrap(err, "初始化微信回调处理器失败")
return
}
wechatClient, wechatNotifyHdl = client, handler
})
return wechatClient, wechatNotifyHdl, wechatErr
}
func (s *paymentService) wechatPrepay(ctx context.Context, order *entity.PaymentOrder, planLabel string) (*dto.PayParams, error) {
cfg := wechatConf(ctx)
client, _, err := wechatClientInstance()
if err != nil {
return nil, err
}
svc := app.AppApiService{Client: client}
resp, _, err := svc.PrepayWithRequestPayment(ctx, app.PrepayRequest{
Appid: core.String(cfg.appid),
Mchid: core.String(cfg.mchid),
Description: core.String("视野会员-" + planLabel),
OutTradeNo: core.String(order.OrderId),
NotifyUrl: core.String(cfg.notifyUrl),
Amount: &app.Amount{Total: core.Int64(order.AmountCents)},
})
if err != nil {
return nil, gerror.Wrap(err, "微信统一下单失败")
}
return &dto.PayParams{
PartnerId: strDeref(resp.PartnerId),
PrepayId: strDeref(resp.PrepayId),
NonceStr: strDeref(resp.NonceStr),
TimeStamp: strDeref(resp.TimeStamp),
Sign: strDeref(resp.Sign),
PackageValue: strDeref(resp.Package),
}, nil
}
// WechatNotify 微信支付回调:APIv3 验签+解密 → 商户校验 → 落授权
func (s *paymentService) WechatNotify(ctx context.Context, header http.Header, body []byte) error {
cfg := wechatConf(ctx)
if !cfg.configured() {
return gerror.NewCode(common.CodePaymentNotConfigured, "微信支付未配置")
}
_, handler, err := wechatClientInstance()
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "", bytes.NewReader(body))
if err != nil {
return gerror.Wrap(err, "构造微信回调请求失败")
}
req.Header = header.Clone()
txn := new(payments.Transaction)
if _, err := handler.ParseNotifyRequest(ctx, req, txn); err != nil {
return gerror.NewCode(common.CodeCallbackVerifyFailed, "微信回调验签/解密失败: "+err.Error())
}
// 商户校验:解密后校验 appid/mchid 与配置一致(防跨商户重放)
if txn.Appid == nil || *txn.Appid != cfg.appid || txn.Mchid == nil || *txn.Mchid != cfg.mchid {
return gerror.NewCode(common.CodeCallbackMismatch, "微信回调商户不匹配")
}
if txn.OutTradeNo == nil || txn.TransactionId == nil {
return gerror.Newf("微信回调缺少订单号")
}
return s.persistPaid(ctx, *txn.OutTradeNo, *txn.TransactionId, consts.ChannelWechat, 0)
}
// ---------- 支付宝(APP 支付) ----------
type alipayConfig struct {
appid string
privateKey string
alipayPublicKey string
sellerId string
notifyUrl string
}
func alipayConf(ctx context.Context) alipayConfig {
return alipayConfig{
appid: g.Cfg().MustGet(ctx, "payment.alipay.appid", "").String(),
privateKey: g.Cfg().MustGet(ctx, "payment.alipay.privateKey", "").String(),
alipayPublicKey: g.Cfg().MustGet(ctx, "payment.alipay.alipayPublicKey", "").String(),
sellerId: g.Cfg().MustGet(ctx, "payment.alipay.sellerId", "").String(),
notifyUrl: g.Cfg().MustGet(ctx, "payment.alipay.notifyUrl", "").String(),
}
}
func (c alipayConfig) configured() bool {
return c.appid != "" && c.privateKey != "" && c.alipayPublicKey != "" &&
c.sellerId != "" && c.notifyUrl != ""
}
var (
alipayOnce sync.Once
alipayClient *alipay.Client
alipayErr error
)
// alipayClientInstance 懒加载支付宝客户端(密钥文件路径 → PEM 内容)
func alipayClientInstance() (*alipay.Client, error) {
alipayOnce.Do(func() {
ctx := context.Background()
cfg := alipayConf(ctx)
if !cfg.configured() {
alipayErr = gerror.NewCode(common.CodePaymentNotConfigured, "支付宝未配置")
return
}
pri, err := os.ReadFile(cfg.privateKey)
if err != nil {
alipayErr = gerror.Wrap(err, "读取支付宝应用私钥失败")
return
}
client, err := alipay.New(cfg.appid, string(pri), true)
if err != nil {
alipayErr = gerror.Wrap(err, "初始化支付宝客户端失败")
return
}
pub, err := os.ReadFile(cfg.alipayPublicKey)
if err != nil {
alipayErr = gerror.Wrap(err, "读取支付宝公钥失败")
return
}
if err := client.LoadAliPayPublicKey(string(pub)); err != nil {
alipayErr = gerror.Wrap(err, "加载支付宝公钥失败")
return
}
alipayClient = client
})
return alipayClient, alipayErr
}
func (s *paymentService) alipayPrepay(ctx context.Context, order *entity.PaymentOrder, planLabel string) (*dto.PayParams, error) {
cfg := alipayConf(ctx)
client, err := alipayClientInstance()
if err != nil {
return nil, err
}
// 支付宝接口金额以「元」字符串传输(外部网关契约;内部存储与传输一律整数分)
pay := alipay.TradeAppPay{}
pay.Subject = "视野会员-" + planLabel
pay.OutTradeNo = order.OrderId
pay.TotalAmount = strconv.FormatFloat(float64(order.AmountCents)/100, 'f', 2, 64)
pay.ProductCode = "QUICK_MSECURITY_PAY"
pay.NotifyURL = cfg.notifyUrl
orderStr, err := client.TradeAppPay(pay)
if err != nil {
return nil, gerror.Wrap(err, "支付宝统一下单失败")
}
return &dto.PayParams{OrderStr: orderStr}, nil
}
// AlipayNotify 支付宝回调:RSA2 验签 → 商户/金额校验 → 落授权
func (s *paymentService) AlipayNotify(ctx context.Context, values url.Values) error {
cfg := alipayConf(ctx)
if !cfg.configured() {
return gerror.NewCode(common.CodePaymentNotConfigured, "支付宝未配置")
}
client, err := alipayClientInstance()
if err != nil {
return err
}
if err := client.VerifySign(ctx, values); err != nil {
return gerror.NewCode(common.CodeCallbackVerifyFailed, "支付宝回调验签失败: "+err.Error())
}
// 商户校验:app_id/seller_id 与配置一致(防跨商户重放)
if values.Get("app_id") != cfg.appid || values.Get("seller_id") != cfg.sellerId {
return gerror.NewCode(common.CodeCallbackMismatch, "支付宝回调商户不匹配")
}
status := values.Get("trade_status")
if status != "TRADE_SUCCESS" && status != "TRADE_FINISHED" {
g.Log().Infof(ctx, "支付宝回调非终态 %s,忽略", status)
return nil
}
outTradeNo := values.Get("out_trade_no")
tradeNo := values.Get("trade_no")
if outTradeNo == "" || tradeNo == "" {
return gerror.Newf("支付宝回调缺少订单号")
}
amount, err := strconv.ParseFloat(values.Get("total_amount"), 64)
if err != nil {
return gerror.Newf("支付宝回调金额格式错误: %s", values.Get("total_amount"))
}
// 回调金额(元)转分后与订单快照比对
cents := int64(math.Round(amount * 100))
return s.persistPaid(ctx, outTradeNo, tradeNo, consts.ChannelAlipay, cents)
}
// ---------- 回调落授权(唯一授权来源) ----------
// persistPaid 回调落授权:「查订单 → 算 expiresAt → 更新订单 → 写 license → 清缓存」,
// 整条链路在事务 + 单写者下串行执行(SQLite 无 WAL,串行同时保证 expiresAt 只按一次现授权
// 状态计算,叠加语义不被并发重复累加)。
// 幂等:订单已 paid 直接成功(重复回调忽略);closed 仅告警不落授权。
func (s *paymentService) persistPaid(ctx context.Context, outTradeNo, tradeNo, channel string, amountCents int64) error {
var phoneNum string
err := common.Serial().Submit(ctx, func() error {
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
o, err := dao.PaymentOrder.GetByPkInTx(ctx, tx, outTradeNo)
if err != nil {
return err
}
if o == nil {
return gerror.Newf("回调订单 %s 不存在", outTradeNo)
}
if o.Status == consts.OrderStatusPaid {
return nil // 幂等:重复回调直接成功
}
if o.Status != consts.OrderStatusCreated {
g.Log().Errorf(ctx, "回调订单 %s 状态 %s,跳过落授权", outTradeNo, o.Status)
return nil
}
// 金额一致性校验(支付宝回调带金额;微信回调无金额字段、以微信侧为准)
if channel == consts.ChannelAlipay && amountCents != o.AmountCents {
return gerror.NewCode(common.CodeCallbackMismatch,
fmt.Sprintf("回调金额 %d 与订单金额 %d 不一致", amountCents, o.AmountCents))
}
plan, err := common.GetPlan(ctx, o.PlanId)
if err != nil {
return err
}
if plan == nil {
return gerror.Newf("订单 %s 套餐 %s 不存在", outTradeNo, o.PlanId)
}
// 续费叠加:base = max(现有到期, 当前时间),已过期则从当前时间起算
lic, err := dao.License.GetByPhoneInTx(ctx, tx, o.PhoneNum)
if err != nil {
return err
}
base := time.Now()
if lic != nil && lic.ExpiresAt != nil && lic.ExpiresAt.Time.After(base) {
base = lic.ExpiresAt.Time
}
expiresAt := License.CalcExpiresAt(base, plan.Days)
if channel == consts.ChannelWechat {
err = dao.PaymentOrder.UpdatePaidWechatInTx(ctx, tx, o.OrderId, tradeNo)
} else {
err = dao.PaymentOrder.UpdatePaidAlipayInTx(ctx, tx, o.OrderId, tradeNo)
}
if err != nil {
return err
}
phoneNum = o.PhoneNum
return dao.License.UpsertAuthInTx(ctx, tx, o.PhoneNum, expiresAt, gtime.Now())
})
})
if err != nil {
return err
}
// 提交成功后清缓存(提交前清可能被并发授权查询以旧值回填)
common.ClearCache(ctx, "license:"+phoneNum)
return nil
}
// strDeref 解指针(SDK 返回的 *string 字段可能为 nil
func strDeref(p *string) string {
if p == nil {
return ""
}
return *p
}