381 lines
13 KiB
Go
381 lines
13 KiB
Go
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
|
||
}
|