hi-server/internal/logic/public/iap/apple/attachTransactionLogic.go
shanshanzhong e98709b511 feat(iap/apple): 增强商品ID解析逻辑以支持多种时间单位
扩展商品ID解析功能,支持从day/month/year格式中提取单位和数量
根据不同的时间单位计算对应的订阅时长
2025-12-17 19:05:55 -08:00

267 lines
9.9 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"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/hibiken/asynq"
iapmodel "github.com/perfect-panel/server/internal/model/iap/apple"
"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
}
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")
}
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))
// idempotency: check existing transaction by original id
var existTx *iapmodel.Transaction
existTx, _ = iapmodel.NewModel(l.svcCtx.DB, l.svcCtx.Redis).FindByOriginalId(l.ctx, txPayload.OriginalTransactionId)
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
{
pid := strings.ToLower(txPayload.ProductId)
var unitIdx = -1
var unitLen = 0
if i := strings.Index(pid, "day"); i >= 0 {
unitIdx, unitLen, parsedUnit = i, 3, "Day"
}
if i := strings.Index(pid, "month"); i >= 0 {
if unitIdx == -1 || i < unitIdx {
unitIdx, unitLen, parsedUnit = i, 5, "Month"
}
}
if i := strings.Index(pid, "year"); i >= 0 {
if unitIdx == -1 || i < unitIdx {
unitIdx, unitLen, parsedUnit = i, 4, "Year"
}
}
if unitIdx >= 0 {
sub := pid[unitIdx+unitLen:]
for i := 0; i < len(sub); i++ {
if sub[i] < '0' || sub[i] > '9' {
sub = sub[:i]
break
}
}
if q, e := strconv.ParseInt(sub, 10, 64); e == nil && q > 0 {
parsedQuantity = q
}
}
}
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
if req.OrderNo != "" {
if ord, e := l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, req.OrderNo); e == nil && ord != nil && ord.Id != 0 {
duration = ord.Quantity
subscribeId = ord.SubscribeId
l.Infow("使用订单信息回退", logger.Field("orderNo", req.OrderNo), logger.Field("durationDays", duration), logger.Field("subscribeId", subscribeId))
} else {
l.Infow("订单信息不可用,尝试请求参数回退", logger.Field("orderNo", req.OrderNo))
}
}
// 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)
l.Infow("计算订阅到期时间", logger.Field("expireAt", exp), logger.Field("expireUnix", exp.Unix()))
if existTx != nil && existTx.Id > 0 {
token := fmt.Sprintf("iap:%s", txPayload.OriginalTransactionId)
existSub, err := l.svcCtx.UserModel.FindOneSubscribeByToken(l.ctx, token)
if err == nil && existSub != nil && existSub.Id > 0 {
// Already processed, return success
l.Infow("事务已处理,直接返回", logger.Field("originalTransactionId", txPayload.OriginalTransactionId), logger.Field("tier", tier), logger.Field("expiresAt", exp.Unix()))
return &types.AttachAppleTransactionResponse{
ExpiresAt: exp.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))
}
// insert user_subscribe
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()))
// optional: mark related order as paid and enqueue activation
if req.OrderNo != "" {
orderInfo, e := l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, req.OrderNo)
if e != nil {
// do not fail transaction if order not found; just continue
l.Infow("订单不存在或查询失败,跳过订单状态更新", logger.Field("orderNo", req.OrderNo))
return nil
}
if orderInfo.Status == 1 {
if e := l.svcCtx.OrderModel.UpdateOrderStatus(l.ctx, req.OrderNo, 2, tx); e != nil {
l.Errorw("更新订单状态失败", logger.Field("orderNo", req.OrderNo), logger.Field("error", e.Error()))
return e
}
l.Infow("更新订单状态成功", logger.Field("orderNo", req.OrderNo), logger.Field("status", 2))
}
// enqueue activation regardless (idempotent handler downstream)
payload := queueType.ForthwithActivateOrderPayload{OrderNo: req.OrderNo}
bytes, _ := json.Marshal(payload)
task := asynq.NewTask(queueType.ForthwithActivateOrder, bytes)
if _, e := l.svcCtx.Queue.EnqueueContext(l.ctx, task); e != nil {
// non-fatal
l.Errorw("enqueue activate task error", logger.Field("error", e.Error()))
} else {
l.Infow("已加入订单激活队列", logger.Field("orderNo", req.OrderNo))
}
}
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.Infow("绑定完成", logger.Field("userId", u.Id), logger.Field("tier", tier), logger.Field("expiresAt", exp.Unix()))
return &types.AttachAppleTransactionResponse{
ExpiresAt: exp.Unix(),
Tier: tier,
}, nil
}