Files
hi-server/internal/logic/public/iap/apple/attachTransactionLogic.go
T
shanshanzhong147 ab38cd4943
Build docker and publish / build (20.15.1) (push) Failing after 4m44s
x
2026-04-26 21:12:22 -07:00

617 lines
28 KiB
Go

package apple
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
"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")
}
// 解析家庭权益:member 的订阅归属于 owner
entitlement, entErr := commonLogic.ResolveEntitlementUser(l.ctx, l.svcCtx.DB, u.Id)
if entErr != nil {
l.Errorw("解析家庭权益失败", logger.Field("userId", u.Id), logger.Field("error", entErr.Error()))
return nil, entErr
}
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")
}
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowOrder, "iap_attach_start",
"[SubscriptionFlow] apple iap attach flow started",
append(commonLogic.OrderTraceFields(orderInfo),
logger.Field("request_user_id", u.Id),
logger.Field("effective_user_id", entitlement.EffectiveUserID),
)...,
)
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))
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowOrder, "iap_attach_verified",
"[SubscriptionFlow] apple iap transaction verified",
append(commonLogic.OrderTraceFields(orderInfo),
logger.Field("product_id", txPayload.ProductId),
logger.Field("original_transaction_tail", commonLogic.SensitiveTail(txPayload.OriginalTransactionId)),
logger.Field("transaction_id_tail", commonLogic.SensitiveTail(txPayload.TransactionId)),
)...,
)
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()))
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))
// 关闭当前 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 && orderSub.UserId != entitlement.EffectiveUserID {
l.Errorw("订单订阅与当前用户不匹配", logger.Field("orderNo", orderInfo.OrderNo), logger.Field("orderSubUserId", orderSub.UserId), logger.Field("userId", u.Id), logger.Field("effectiveUserId", entitlement.EffectiveUserID))
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, entitlement.EffectiveUserID, 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.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.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:
// 不在此处更新 expire_time:由激活队列统一写入,避免双重叠加天数
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, exp.Unix()); 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())
}
estimatedExpire := l.calcIAPRenewalExpire(existSub.ExpireTime, exp, accumulateDuration)
l.Infow("事务已处理,订阅到期时间由队列更新", logger.Field("originalTransactionId", txPayload.OriginalTransactionId), logger.Field("tier", tier), logger.Field("estimatedExpiresAt", estimatedExpire.Unix()))
return &types.AttachAppleTransactionResponse{
ExpiresAt: estimatedExpire.Unix(),
Tier: tier,
}, nil
}
if orderLinkedSub != nil {
// 不在此处更新 expire_time:由激活队列统一写入,避免双重叠加天数
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, exp.Unix()); 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())
}
estimatedExpire := l.calcIAPRenewalExpire(orderLinkedSub.ExpireTime, exp, accumulateDuration)
l.Infow("事务已处理,订单关联订阅到期时间由队列更新", logger.Field("orderNo", req.OrderNo), logger.Field("userSubscribeId", orderLinkedSub.Id), logger.Field("estimatedExpiresAt", estimatedExpire.Unix()))
return &types.AttachAppleTransactionResponse{ExpiresAt: estimatedExpire.Unix(), Tier: tier}, nil
}
if singleModeAnchorSub != nil {
// 不在此处更新 expire_time:由激活队列统一写入,避免双重叠加天数
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, exp.Unix()); 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())
}
estimatedExpire := l.calcIAPRenewalExpire(singleModeAnchorSub.ExpireTime, exp, accumulateDuration)
l.Infow("事务已处理,单订阅锚点到期时间由队列更新", logger.Field("userSubscribeId", singleModeAnchorSub.Id), logger.Field("estimatedExpiresAt", estimatedExpire.Unix()))
return &types.AttachAppleTransactionResponse{ExpiresAt: estimatedExpire.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: entitlement.EffectiveUserID,
SubscribeId: subscribeId,
OrderId: orderInfo.Id,
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()))
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowOrder, "subscription_created",
"[SubscriptionFlow] apple iap attach created a subscription placeholder before queue activation",
append(commonLogic.OrderTraceFields(orderInfo),
commonLogic.UserSubscribeTraceFields(&userSub)...,
)...,
)
}
} 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())
}
// 事务提交后立即清除订阅缓存,避免 App 查到旧数据(激活队列异步执行,存在竞态)
if orderLinkedSub != nil {
_ = l.svcCtx.UserModel.ClearSubscribeCache(l.ctx, orderLinkedSub)
} else if singleModeAnchorSub != nil {
_ = l.svcCtx.UserModel.ClearSubscribeCache(l.ctx, singleModeAnchorSub)
}
// merged 路径下,exp 仅从购买日算起,需要用已有订阅到期时间 + duration 作为预估值返回前端
responseExpire := exp
var mergeSub *user.Subscribe
if orderLinkedSub != nil {
mergeSub = orderLinkedSub
} else if singleModeAnchorSub != nil {
mergeSub = singleModeAnchorSub
}
if mergeSub != nil {
base := mergeSub.ExpireTime
now := time.Now()
if base.Before(now) {
base = now
}
estimated := base.AddDate(0, 0, int(duration))
if estimated.After(responseExpire) {
responseExpire = estimated
}
}
l.Infow("绑定完成", logger.Field("userId", u.Id), logger.Field("tier", tier), logger.Field("expiresAt", responseExpire.Unix()))
return &types.AttachAppleTransactionResponse{
ExpiresAt: responseExpire.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))
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowOrder, "payment_settled",
"[SubscriptionFlow] apple iap attach marked order as paid",
append(commonLogic.OrderTraceFields(orderInfo),
logger.Field("iap_expire_at", iapExpireAt),
)...,
)
}
// 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))
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowOrder, "activation_task_enqueued",
"[SubscriptionFlow] apple iap attach enqueued activation task",
append(commonLogic.OrderTraceFields(orderInfo),
logger.Field("iap_expire_at", iapExpireAt),
)...,
)
}
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
}