hi-server/internal/logic/public/iap/apple/attachTransactionLogic.go
shanshanzhong bb80df5786
Some checks failed
Build docker and publish / build (20.15.1) (push) Has been cancelled
权限问题
2026-03-11 08:06:13 -07:00

659 lines
30 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 apple
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"github.com/google/uuid"
"github.com/hibiken/asynq"
commonLogic "github.com/perfect-panel/server/internal/logic/common"
iapmodel "github.com/perfect-panel/server/internal/model/iap/apple"
ordermodel "github.com/perfect-panel/server/internal/model/order"
"github.com/perfect-panel/server/internal/model/subscribe"
"github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/constant"
iapapple "github.com/perfect-panel/server/pkg/iap/apple"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/xerr"
queueType "github.com/perfect-panel/server/queue/types"
"github.com/pkg/errors"
"gorm.io/gorm"
)
type AttachTransactionLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
const (
orderTypeSubscribe uint8 = 1
orderStatusPending uint8 = 1
orderStatusPaid uint8 = 2
orderStatusClosed uint8 = 3
orderStatusFinished uint8 = 5
)
func NewAttachTransactionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AttachTransactionLogic {
return &AttachTransactionLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *AttachTransactionLogic) Attach(req *types.AttachAppleTransactionRequest) (*types.AttachAppleTransactionResponse, error) {
l.Infow("开始绑定 Apple IAP 交易", logger.Field("orderNo", req.OrderNo))
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
if !ok || u == nil {
l.Errorw("无效访问,用户信息缺失")
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "invalid access")
}
if strings.TrimSpace(req.OrderNo) == "" {
l.Errorw("参数错误orderNo 不能为空")
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "order_no is required")
}
orderInfo, orderErr := l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, req.OrderNo)
switch {
case errors.Is(orderErr, gorm.ErrRecordNotFound):
l.Errorw("订单不存在", logger.Field("orderNo", req.OrderNo))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.OrderNotExist), "order not exist")
case orderErr != nil:
l.Errorw("查询订单失败", logger.Field("orderNo", req.OrderNo), logger.Field("error", orderErr.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find order error: %v", orderErr.Error())
case orderInfo == nil || orderInfo.Id == 0:
l.Errorw("订单不存在", logger.Field("orderNo", req.OrderNo))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.OrderNotExist), "order not exist")
case orderInfo.UserId != u.Id:
l.Errorw("订单与当前用户不匹配", logger.Field("orderNo", req.OrderNo), logger.Field("orderUserId", orderInfo.UserId), logger.Field("userId", u.Id))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "order owner mismatch")
}
isNewPurchaseOrder := orderInfo.Type == orderTypeSubscribe
if isNewPurchaseOrder {
l.Infow("首购订单将只由订单激活流程创建订阅", logger.Field("orderNo", req.OrderNo), logger.Field("orderType", orderInfo.Type))
}
txPayload, err := iapapple.VerifyTransactionJWS(req.SignedTransactionJWS)
if err != nil {
l.Errorw("JWS 验签失败", logger.Field("error", err.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "invalid jws")
}
l.Infow("JWS 验签成功", logger.Field("productId", txPayload.ProductId), logger.Field("originalTransactionId", txPayload.OriginalTransactionId), logger.Field("purchaseAt", txPayload.PurchaseDate))
tradeNoCandidates := l.getAppleTradeNoCandidates(txPayload)
existingOrderNo, validateErr := l.validateOrderTradeNoBinding(orderInfo, tradeNoCandidates)
if validateErr != nil {
l.Errorw("Apple 交易绑定校验失败", logger.Field("orderNo", req.OrderNo), logger.Field("tradeNoCandidates", tradeNoCandidates), logger.Field("error", validateErr.Error()))
l.sendIAPAttachTraceToTelegram("REJECT_BINDING_ERROR", orderInfo, u.Id, orderInfo.SubscribeId, "", orderInfo.Quantity, txPayload.PurchaseDate, txPayload.TransactionId, txPayload.OriginalTransactionId, validateErr.Error())
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "apple transaction binding error")
}
if existingOrderNo != "" {
l.Errorw("Apple 交易重复绑定,返回已绑定订单", logger.Field("orderNo", req.OrderNo), logger.Field("existingOrderNo", existingOrderNo), logger.Field("tradeNoCandidates", tradeNoCandidates))
l.sendIAPAttachTraceToTelegram("REJECT_DUPLICATE_TRANSACTION", orderInfo, u.Id, orderInfo.SubscribeId, "", orderInfo.Quantity, txPayload.PurchaseDate, txPayload.TransactionId, txPayload.OriginalTransactionId, "already used by "+existingOrderNo)
// 关闭当前 pending 订单,避免产生孤儿订单
if orderInfo.Status == orderStatusPending {
if closeErr := l.svcCtx.DB.Model(&ordermodel.Order{}).
Where("order_no = ? AND status = ?", req.OrderNo, orderStatusPending).
Update("status", orderStatusClosed).Error; closeErr != nil {
l.Errorw("关闭重复订单失败", logger.Field("orderNo", req.OrderNo), logger.Field("error", closeErr.Error()))
} else {
l.Infow("已关闭重复 pending 订单", logger.Field("orderNo", req.OrderNo), logger.Field("existingOrderNo", existingOrderNo))
}
}
return &types.AttachAppleTransactionResponse{ExistingOrderNo: existingOrderNo}, nil
}
tradeNo := ""
if len(tradeNoCandidates) > 0 {
tradeNo = tradeNoCandidates[0]
}
orderAlreadyBound := containsString(tradeNoCandidates, orderInfo.TradeNo)
// idempotency: check existing transaction by original id
var existTx *iapmodel.Transaction
existTx, existTxErr := iapmodel.NewModel(l.svcCtx.DB, l.svcCtx.Redis).FindByOriginalId(l.ctx, txPayload.OriginalTransactionId)
if existTxErr != nil && !errors.Is(existTxErr, gorm.ErrRecordNotFound) {
l.Errorw("查询 IAP 事务记录失败", logger.Field("error", existTxErr.Error()), logger.Field("originalTransactionId", txPayload.OriginalTransactionId))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find iap transaction error: %v", existTxErr.Error())
}
l.Infow("幂等等检查", logger.Field("originalTransactionId", txPayload.OriginalTransactionId), logger.Field("exists", existTx != nil && existTx.Id > 0))
// 解析 Apple 商品ID中的单位与数量支持 dayN / monthN / yearN
var parsedUnit string
var parsedQuantity int64
if parsed := iapapple.ParseProductIdDuration(txPayload.ProductId); parsed != nil {
parsedUnit = parsed.Unit
parsedQuantity = parsed.Quantity
}
l.Infow("商品映射解析", logger.Field("productId", txPayload.ProductId), logger.Field("解析单位", parsedUnit), logger.Field("解析数量", parsedQuantity))
// 基于订阅列表的折扣配置做匹配UnitTime=Day 且 Discount.quantity == parsedQuantity
var duration int64
var tier string
var subscribeId int64
if parsedQuantity > 0 {
_, subs, e := l.svcCtx.SubscribeModel.FilterList(l.ctx, &subscribe.FilterParams{
Page: 1,
Size: 9999,
Show: true,
Sell: true,
DefaultLanguage: true,
})
if e == nil && len(subs) > 0 {
for _, item := range subs {
if parsedUnit != "" && !strings.EqualFold(item.UnitTime, parsedUnit) {
continue
}
var discounts []types.SubscribeDiscount
if item.Discount != "" {
_ = json.Unmarshal([]byte(item.Discount), &discounts)
}
for _, d := range discounts {
if int64(d.Quantity) == parsedQuantity {
switch parsedUnit {
case "Day":
duration = parsedQuantity
case "Month":
duration = parsedQuantity * 30
case "Year":
duration = parsedQuantity * 365
default:
duration = parsedQuantity
}
subscribeId = item.Id
tier = item.Name
l.Infow("订阅映射命中", logger.Field("subscribeId", subscribeId), logger.Field("name", tier), logger.Field("durationDays", duration))
break
}
}
if subscribeId > 0 {
break
}
}
} else {
l.Infow("订阅列表为空或查询失败", logger.Field("error", func() string {
if e != nil {
return e.Error()
}
return ""
}()))
}
}
if subscribeId == 0 {
// fallback from order_no if provided
duration = orderInfo.Quantity
subscribeId = orderInfo.SubscribeId
l.Infow("使用订单信息回退", logger.Field("orderNo", req.OrderNo), logger.Field("durationDays", duration), logger.Field("subscribeId", subscribeId))
// final fallback: use request fields
if duration <= 0 {
duration = req.DurationDays
}
if tier == "" {
tier = req.Tier
}
if subscribeId <= 0 {
subscribeId = req.SubscribeId
}
l.Infow("使用请求参数回退", logger.Field("durationDays", duration), logger.Field("tier", tier), logger.Field("subscribeId", subscribeId))
if duration <= 0 || subscribeId <= 0 {
l.Errorw("商品识别失败", logger.Field("durationDays", duration), logger.Field("tier", tier), logger.Field("subscribeId", subscribeId))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "unknown product")
}
}
exp := iapapple.CalcExpire(txPayload.PurchaseDate, duration)
shouldAccumulate := duration > 0 && (strings.EqualFold(parsedUnit, "Day") || strings.EqualFold(txPayload.Type, "Consumable"))
accumulateDuration := int64(0)
if shouldAccumulate {
accumulateDuration = duration
}
l.Infow("计算订阅到期时间", logger.Field("expireAt", exp), logger.Field("expireUnix", exp.Unix()))
var orderLinkedSub *user.Subscribe
if !isNewPurchaseOrder && orderInfo != nil && orderInfo.SubscribeToken != "" {
orderSub, subErr := l.svcCtx.UserModel.FindOneSubscribeByToken(l.ctx, orderInfo.SubscribeToken)
switch {
case subErr == nil && orderSub != nil && orderSub.Id > 0:
if orderSub.UserId != u.Id {
l.Errorw("订单订阅与当前用户不匹配", logger.Field("orderNo", orderInfo.OrderNo), logger.Field("orderSubUserId", orderSub.UserId), logger.Field("userId", u.Id))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "order subscribe owner mismatch")
}
orderLinkedSub = orderSub
subscribeId = orderSub.SubscribeId
l.Infow("IAP 绑定命中订单订阅", logger.Field("orderNo", orderInfo.OrderNo), logger.Field("userSubscribeId", orderSub.Id), logger.Field("subscribeToken", orderInfo.SubscribeToken))
case subErr != nil && !errors.Is(subErr, gorm.ErrRecordNotFound):
l.Errorw("查询订单订阅失败", logger.Field("orderNo", orderInfo.OrderNo), logger.Field("subscribeToken", orderInfo.SubscribeToken), logger.Field("error", subErr.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find order subscribe error: %v", subErr.Error())
}
}
var singleModeAnchorSub *user.Subscribe
if !isNewPurchaseOrder && l.svcCtx.Config.Subscribe.SingleModel && orderLinkedSub == nil {
anchorSub, anchorErr := findSingleModeMergeTarget(l.ctx, l.svcCtx, u.Id, subscribeId)
switch {
case errors.Is(anchorErr, commonLogic.ErrSingleModePlanMismatch):
l.Errorw("单订阅模式下 IAP 套餐不匹配", logger.Field("userId", u.Id), logger.Field("orderNo", req.OrderNo), logger.Field("iapSubscribeId", subscribeId))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.SingleSubscribePlanMismatch), "single subscribe mode plan mismatch")
case anchorErr == nil && anchorSub != nil && anchorSub.Id > 0:
singleModeAnchorSub = anchorSub
subscribeId = anchorSub.SubscribeId
l.Infow("IAP 绑定命中单订阅锚点", logger.Field("userSubscribeId", anchorSub.Id), logger.Field("subscribeId", anchorSub.SubscribeId))
case errors.Is(anchorErr, gorm.ErrRecordNotFound):
case anchorErr != nil:
l.Errorw("查询单订阅锚点失败", logger.Field("userId", u.Id), logger.Field("error", anchorErr.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find single mode anchor subscribe error: %v", anchorErr.Error())
}
}
existSub, existSubErr := l.findIAPSubscribeByOriginalTransactionID(txPayload.OriginalTransactionId)
if existSubErr != nil && !errors.Is(existSubErr, gorm.ErrRecordNotFound) {
l.Errorw("查询 IAP 订阅失败", logger.Field("error", existSubErr.Error()), logger.Field("originalTransactionId", txPayload.OriginalTransactionId))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find iap subscribe error: %v", existSubErr.Error())
}
if existTx != nil && existTx.Id > 0 {
if orderAlreadyBound {
expiresAt := exp.Unix()
if existSub != nil && existSub.Id > 0 {
expiresAt = existSub.ExpireTime.Unix()
}
if orderLinkedSub != nil && orderLinkedSub.Id > 0 {
expiresAt = orderLinkedSub.ExpireTime.Unix()
}
if singleModeAnchorSub != nil && singleModeAnchorSub.Id > 0 {
expiresAt = singleModeAnchorSub.ExpireTime.Unix()
}
if bindErr := l.bindOrderTradeNo(orderInfo, tradeNo); bindErr != nil {
l.Errorw("回填订单交易号失败", logger.Field("orderNo", req.OrderNo), logger.Field("tradeNo", tradeNo), logger.Field("error", bindErr.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "bind order trade_no failed: %v", bindErr.Error())
}
if syncErr := l.syncOrderStatusAndEnqueue(orderInfo, 0); syncErr != nil {
l.Errorw("同订单幂等同步失败", logger.Field("orderNo", req.OrderNo), logger.Field("error", syncErr.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "sync order status failed: %v", syncErr.Error())
}
l.sendIAPAttachTraceToTelegram("IDEMPOTENT_SAME_ORDER", orderInfo, u.Id, subscribeId, tier, duration, txPayload.PurchaseDate, txPayload.TransactionId, txPayload.OriginalTransactionId, "")
l.Infow("事务已处理,同订单幂等返回", logger.Field("orderNo", req.OrderNo), logger.Field("expiresAt", expiresAt))
return &types.AttachAppleTransactionResponse{ExpiresAt: expiresAt, Tier: tier}, nil
}
if isNewPurchaseOrder {
if bindErr := l.bindOrderTradeNo(orderInfo, tradeNo); bindErr != nil {
l.Errorw("写入订单交易号失败", logger.Field("orderNo", req.OrderNo), logger.Field("tradeNo", tradeNo), logger.Field("error", bindErr.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "bind order trade_no failed: %v", bindErr.Error())
}
if syncErr := l.syncOrderStatusAndEnqueue(orderInfo, 0); syncErr != nil {
l.Errorw("事务已处理但同步订单状态失败", logger.Field("orderNo", req.OrderNo), logger.Field("error", syncErr.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "sync order status failed: %v", syncErr.Error())
}
l.sendIAPAttachTraceToTelegram("SUCCESS_NEW_PURCHASE_QUEUE", orderInfo, u.Id, subscribeId, tier, duration, txPayload.PurchaseDate, txPayload.TransactionId, txPayload.OriginalTransactionId, "")
l.Infow("事务已处理,首购订单等待激活队列发放订阅", logger.Field("orderNo", req.OrderNo), logger.Field("expiresAt", exp.Unix()))
return &types.AttachAppleTransactionResponse{ExpiresAt: exp.Unix(), Tier: tier}, nil
}
switch {
case existSubErr == nil && existSub != nil && existSub.Id > 0:
newExpire, updateErr := l.extendSubscribeForIAP(existSub, exp, accumulateDuration, subscribeId)
if updateErr != nil {
l.Errorw("刷新 IAP 订阅失败", logger.Field("error", updateErr.Error()), logger.Field("subscribeId", existSub.Id))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update iap subscribe failed: %v", updateErr.Error())
}
if bindErr := l.bindOrderTradeNo(orderInfo, tradeNo); bindErr != nil {
l.Errorw("写入订单交易号失败", logger.Field("orderNo", req.OrderNo), logger.Field("tradeNo", tradeNo), logger.Field("error", bindErr.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "bind order trade_no failed: %v", bindErr.Error())
}
if syncErr := l.syncOrderStatusAndEnqueue(orderInfo, 0); syncErr != nil {
l.Errorw("同步订单状态失败(existSub)", logger.Field("orderNo", req.OrderNo), logger.Field("error", syncErr.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "sync order status failed: %v", syncErr.Error())
}
l.sendIAPAttachTraceToTelegram("SUCCESS_RENEW_EXIST_SUB", orderInfo, u.Id, subscribeId, tier, duration, txPayload.PurchaseDate, txPayload.TransactionId, txPayload.OriginalTransactionId, "")
l.Infow("事务已处理,刷新订阅到期时间", logger.Field("originalTransactionId", txPayload.OriginalTransactionId), logger.Field("tier", tier), logger.Field("expiresAt", newExpire.Unix()))
return &types.AttachAppleTransactionResponse{
ExpiresAt: newExpire.Unix(),
Tier: tier,
}, nil
}
if orderLinkedSub != nil {
newExpire, updateErr := l.extendSubscribeForIAP(orderLinkedSub, exp, accumulateDuration, subscribeId)
if updateErr != nil {
l.Errorw("刷新订单关联订阅失败", logger.Field("error", updateErr.Error()), logger.Field("subscribeId", orderLinkedSub.Id))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update order subscribe failed: %v", updateErr.Error())
}
if bindErr := l.bindOrderTradeNo(orderInfo, tradeNo); bindErr != nil {
l.Errorw("写入订单交易号失败", logger.Field("orderNo", req.OrderNo), logger.Field("tradeNo", tradeNo), logger.Field("error", bindErr.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "bind order trade_no failed: %v", bindErr.Error())
}
if syncErr := l.syncOrderStatusAndEnqueue(orderInfo, 0); syncErr != nil {
l.Errorw("同步订单状态失败(orderLinkedSub)", logger.Field("orderNo", req.OrderNo), logger.Field("error", syncErr.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "sync order status failed: %v", syncErr.Error())
}
l.sendIAPAttachTraceToTelegram("SUCCESS_RENEW_ORDER_LINKED_SUB", orderInfo, u.Id, subscribeId, tier, duration, txPayload.PurchaseDate, txPayload.TransactionId, txPayload.OriginalTransactionId, "")
l.Infow("事务已处理,刷新订单关联订阅到期时间", logger.Field("orderNo", req.OrderNo), logger.Field("userSubscribeId", orderLinkedSub.Id), logger.Field("expiresAt", newExpire.Unix()))
return &types.AttachAppleTransactionResponse{ExpiresAt: newExpire.Unix(), Tier: tier}, nil
}
if singleModeAnchorSub != nil {
newExpire, updateErr := l.extendSubscribeForIAP(singleModeAnchorSub, exp, accumulateDuration, subscribeId)
if updateErr != nil {
l.Errorw("刷新单订阅锚点订阅失败", logger.Field("error", updateErr.Error()), logger.Field("subscribeId", singleModeAnchorSub.Id))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update single mode anchor subscribe failed: %v", updateErr.Error())
}
if bindErr := l.bindOrderTradeNo(orderInfo, tradeNo); bindErr != nil {
l.Errorw("写入订单交易号失败", logger.Field("orderNo", req.OrderNo), logger.Field("tradeNo", tradeNo), logger.Field("error", bindErr.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "bind order trade_no failed: %v", bindErr.Error())
}
if syncErr := l.syncOrderStatusAndEnqueue(orderInfo, 0); syncErr != nil {
l.Errorw("同步订单状态失败(singleModeAnchorSub)", logger.Field("orderNo", req.OrderNo), logger.Field("error", syncErr.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "sync order status failed: %v", syncErr.Error())
}
l.sendIAPAttachTraceToTelegram("SUCCESS_RENEW_SINGLE_MODE_ANCHOR", orderInfo, u.Id, subscribeId, tier, duration, txPayload.PurchaseDate, txPayload.TransactionId, txPayload.OriginalTransactionId, "")
l.Infow("事务已处理,刷新单订阅锚点到期时间", logger.Field("userSubscribeId", singleModeAnchorSub.Id), logger.Field("expiresAt", newExpire.Unix()))
return &types.AttachAppleTransactionResponse{ExpiresAt: newExpire.Unix(), Tier: tier}, nil
}
}
sum := sha256.Sum256([]byte(req.SignedTransactionJWS))
jwsHash := hex.EncodeToString(sum[:])
l.Infow("准备写入事务记录", logger.Field("userId", u.Id), logger.Field("transactionId", txPayload.TransactionId), logger.Field("originalTransactionId", txPayload.OriginalTransactionId), logger.Field("productId", txPayload.ProductId), logger.Field("jwsHash", jwsHash))
iapTx := &iapmodel.Transaction{
UserId: u.Id,
OriginalTransactionId: txPayload.OriginalTransactionId,
TransactionId: txPayload.TransactionId,
ProductId: txPayload.ProductId,
PurchaseAt: txPayload.PurchaseDate,
RevocationAt: txPayload.RevocationDate,
JWSHash: jwsHash,
}
err = l.svcCtx.DB.Transaction(func(tx *gorm.DB) error {
if existTx == nil || existTx.Id == 0 {
if e := tx.Model(&iapmodel.Transaction{}).Create(iapTx).Error; e != nil {
l.Errorw("写入事务表失败", logger.Field("error", e.Error()))
return e
}
l.Infow("写入事务表成功", logger.Field("id", iapTx.Id))
}
if !isNewPurchaseOrder {
merged := false
if orderLinkedSub != nil {
// 不在此处更新 expire_time由激活队列统一写入避免双重叠加天数
merged = true
} else if singleModeAnchorSub != nil {
// 同上
merged = true
}
if !merged {
userSub := user.Subscribe{
UserId: u.Id,
SubscribeId: subscribeId,
StartTime: time.Now(),
ExpireTime: exp,
Traffic: 0,
Download: 0,
Upload: 0,
Token: fmt.Sprintf("iap:%s", txPayload.OriginalTransactionId),
UUID: uuid.New().String(),
Status: 1,
}
if e := l.svcCtx.UserModel.InsertSubscribe(l.ctx, &userSub, tx); e != nil {
l.Errorw("写入用户订阅失败", logger.Field("error", e.Error()))
return e
}
l.Infow("写入用户订阅成功", logger.Field("userId", u.Id), logger.Field("subscribeId", subscribeId), logger.Field("expireUnix", exp.Unix()))
}
} else {
l.Infow("首购订单跳过 attach 阶段订阅写入", logger.Field("orderNo", orderInfo.OrderNo), logger.Field("orderType", orderInfo.Type))
}
if e := l.bindOrderTradeNo(orderInfo, tradeNo, tx); e != nil {
l.Errorw("写入订单交易号失败", logger.Field("orderNo", req.OrderNo), logger.Field("tradeNo", tradeNo), logger.Field("error", e.Error()))
return e
}
if e := l.syncOrderStatusAndEnqueue(orderInfo, exp.Unix(), tx); e != nil {
l.Errorw("同步订单状态失败", logger.Field("orderNo", req.OrderNo), logger.Field("error", e.Error()))
return e
}
return nil
})
if err != nil {
l.Errorw("绑定事务提交失败", logger.Field("error", err.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "insert error: %v", err.Error())
}
l.sendIAPAttachTraceToTelegram("SUCCESS_COMMIT", orderInfo, u.Id, subscribeId, tier, duration, txPayload.PurchaseDate, txPayload.TransactionId, txPayload.OriginalTransactionId, "")
l.Infow("绑定完成", logger.Field("userId", u.Id), logger.Field("tier", tier), logger.Field("expiresAt", exp.Unix()))
return &types.AttachAppleTransactionResponse{
ExpiresAt: exp.Unix(),
Tier: tier,
}, nil
}
func (l *AttachTransactionLogic) syncOrderStatusAndEnqueue(orderInfo *ordermodel.Order, iapExpireAt int64, tx ...*gorm.DB) error {
if orderInfo == nil || orderInfo.OrderNo == "" {
return errors.New("order info is nil")
}
if orderInfo.Status == orderStatusPending {
if err := l.svcCtx.OrderModel.UpdateOrderStatus(l.ctx, orderInfo.OrderNo, orderStatusPaid, tx...); err != nil {
return err
}
orderInfo.Status = orderStatusPaid
l.Infow("更新订单状态成功", logger.Field("orderNo", orderInfo.OrderNo), logger.Field("status", orderStatusPaid))
}
// enqueue activation regardless (idempotent handler downstream)
payload := queueType.ForthwithActivateOrderPayload{OrderNo: orderInfo.OrderNo, IAPExpireAt: iapExpireAt}
bytes, _ := json.Marshal(payload)
task := asynq.NewTask(queueType.ForthwithActivateOrder, bytes)
if _, err := l.svcCtx.Queue.EnqueueContext(l.ctx, task); err != nil {
// non-fatal
l.Errorw("enqueue activate task error", logger.Field("error", err.Error()))
} else {
l.Infow("已加入订单激活队列", logger.Field("orderNo", orderInfo.OrderNo))
}
return nil
}
func (l *AttachTransactionLogic) findIAPSubscribeByOriginalTransactionID(originalTransactionID string) (*user.Subscribe, error) {
if originalTransactionID == "" {
return nil, gorm.ErrRecordNotFound
}
candidates := []string{fmt.Sprintf("iap:%s", originalTransactionID), originalTransactionID}
for _, token := range candidates {
sub, err := l.svcCtx.UserModel.FindOneSubscribeByToken(l.ctx, token)
if err == nil && sub != nil && sub.Id > 0 {
return sub, nil
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
}
return nil, gorm.ErrRecordNotFound
}
func (l *AttachTransactionLogic) extendSubscribeForIAP(userSub *user.Subscribe, exp time.Time, durationDays int64, subscribeId int64, tx ...*gorm.DB) (time.Time, error) {
if userSub == nil {
return time.Time{}, errors.New("user subscribe is nil")
}
newExpire := l.calcIAPRenewalExpire(userSub.ExpireTime, exp, durationDays)
userSub.ExpireTime = newExpire
if subscribeId > 0 && userSub.SubscribeId != subscribeId {
userSub.SubscribeId = subscribeId
}
userSub.Status = 1
userSub.FinishedAt = nil
if err := l.svcCtx.UserModel.UpdateSubscribe(l.ctx, userSub, tx...); err != nil {
return time.Time{}, err
}
return newExpire, nil
}
func (l *AttachTransactionLogic) calcIAPRenewalExpire(currentExpire time.Time, fallbackExpire time.Time, durationDays int64) time.Time {
if durationDays > 0 {
base := currentExpire
now := time.Now()
if base.Before(now) {
base = now
}
return base.Add(time.Duration(durationDays) * 24 * time.Hour)
}
if fallbackExpire.After(currentExpire) {
return fallbackExpire
}
return currentExpire
}
func (l *AttachTransactionLogic) getAppleTradeNoCandidates(txPayload *iapapple.TransactionPayload) []string {
if txPayload == nil {
return nil
}
candidates := make([]string, 0, 1)
if txPayload.TransactionId != "" {
candidates = append(candidates, txPayload.TransactionId)
}
return candidates
}
// validateOrderTradeNoBinding returns (existingOrderNo, error).
// existingOrderNo is non-empty when the Apple transaction is already bound to a paid/finished order.
func (l *AttachTransactionLogic) validateOrderTradeNoBinding(orderInfo *ordermodel.Order, tradeNoCandidates []string) (string, error) {
if orderInfo == nil || len(tradeNoCandidates) == 0 {
return "", nil
}
if orderInfo.TradeNo != "" && !containsString(tradeNoCandidates, orderInfo.TradeNo) {
return "", errors.New("order already bound to another apple transaction")
}
var boundOrder ordermodel.Order
err := l.svcCtx.DB.WithContext(l.ctx).
Model(&ordermodel.Order{}).
Where("trade_no IN ? AND order_no <> ? AND status IN ?", tradeNoCandidates, orderInfo.OrderNo, []int64{int64(orderStatusPaid), int64(orderStatusFinished)}).
Order("id DESC").
First(&boundOrder).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", nil
}
if err != nil {
return "", err
}
return boundOrder.OrderNo, nil
}
func (l *AttachTransactionLogic) bindOrderTradeNo(orderInfo *ordermodel.Order, tradeNo string, tx ...*gorm.DB) error {
if orderInfo == nil || tradeNo == "" {
return nil
}
if orderInfo.TradeNo == tradeNo {
return nil
}
if orderInfo.TradeNo != "" && orderInfo.TradeNo != tradeNo {
return errors.New("order already bound to another apple transaction")
}
orderInfo.TradeNo = tradeNo
if err := l.svcCtx.OrderModel.Update(l.ctx, orderInfo, tx...); err != nil {
return err
}
return nil
}
func containsString(candidates []string, target string) bool {
if target == "" {
return false
}
for _, item := range candidates {
if item == target {
return true
}
}
return false
}
func (l *AttachTransactionLogic) sendIAPAttachTraceToTelegram(status string, orderInfo *ordermodel.Order, userID int64, subscribeID int64, subscribeName string, quantity int64, purchaseAt time.Time, transactionID string, originalTransactionID string, note string) {
if l.svcCtx == nil {
return
}
orderNo := ""
if orderInfo != nil {
orderNo = orderInfo.OrderNo
}
if subscribeName == "" {
subscribeName = "-"
}
message := fmt.Sprintf(
"IAP Attach Log [%s]\n订单号: %s\n购买时间: %s\n购买人ID: %d\n订阅信息: %s (subscribe_id=%d, quantity=%d)\ntransaction: %s\noriginal_transaction: %s",
status,
orderNo,
purchaseAt.Format("2006-01-02 15:04:05"),
userID,
subscribeName,
subscribeID,
quantity,
transactionID,
originalTransactionID,
)
if note != "" {
message += "\n备注: " + note
}
overrideBotToken := strings.TrimSpace(os.Getenv("TG_BOT_TOKEN"))
overrideChatID := strings.TrimSpace(os.Getenv("TG_CHAT_ID"))
if overrideBotToken != "" && overrideChatID != "" {
if chatID, err := strconv.ParseInt(overrideChatID, 10, 64); err == nil && chatID != 0 {
bot := l.svcCtx.TelegramBot
if bot == nil || strings.TrimSpace(l.svcCtx.Config.Telegram.BotToken) != overrideBotToken {
overrideBot, newErr := tgbotapi.NewBotAPI(overrideBotToken)
if newErr == nil {
bot = overrideBot
} else {
l.Errorw("初始化 TG 覆盖 Bot 失败", logger.Field("error", newErr.Error()))
}
}
if bot != nil {
msg := tgbotapi.NewMessage(chatID, message)
if _, sendErr := bot.Send(msg); sendErr != nil {
l.Errorw("发送 IAP TG 覆盖通道消息失败", logger.Field("error", sendErr.Error()))
}
return
}
}
}
if l.svcCtx.TelegramBot == nil || !l.svcCtx.Config.Telegram.EnableNotify {
return
}
if groupChatID, err := strconv.ParseInt(strings.TrimSpace(l.svcCtx.Config.Telegram.GroupChatID), 10, 64); err == nil && groupChatID != 0 {
msg := tgbotapi.NewMessage(groupChatID, message)
if _, sendErr := l.svcCtx.TelegramBot.Send(msg); sendErr != nil {
l.Errorw("发送 IAP TG 群消息失败", logger.Field("error", sendErr.Error()))
}
return
}
admins, err := l.svcCtx.UserModel.QueryAdminUsers(l.ctx)
if err != nil {
l.Errorw("查询管理员失败(IAP TG日志)", logger.Field("error", err.Error()))
return
}
for _, admin := range admins {
if telegramID, ok := findTelegramAuth(admin); ok {
msg := tgbotapi.NewMessage(telegramID, message)
if _, sendErr := l.svcCtx.TelegramBot.Send(msg); sendErr != nil {
l.Errorw("发送 IAP TG 管理员消息失败", logger.Field("error", sendErr.Error()))
}
}
}
}
func findTelegramAuth(u *user.User) (int64, bool) {
if u == nil {
return 0, false
}
for _, item := range u.AuthMethods {
if item.AuthType == "telegram" {
if telegramID, err := strconv.ParseInt(item.AuthIdentifier, 10, 64); err == nil {
return telegramID, true
}
}
}
return 0, false
}