package apple import ( "context" "encoding/json" "fmt" "strings" "time" "github.com/google/uuid" commonLogic "github.com/perfect-panel/server/internal/logic/common" 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" "github.com/pkg/errors" "gorm.io/gorm" ) type RestoreLogic struct { logger.Logger ctx context.Context svcCtx *svc.ServiceContext } func NewRestoreLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RestoreLogic { return &RestoreLogic{ Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx, } } func (l *RestoreLogic) Restore(req *types.RestoreAppleTransactionsRequest) error { u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User) if !ok || u == nil { return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "invalid access") } pm, _ := iapapple.ParseProductMap(l.svcCtx.Config.Site.CustomData) // Load Apple Server API config from payment table apiCfgPtr, err := LoadAppleServerAPIConfig(l.ctx, l.svcCtx) if err != nil { l.Errorw("restore: load apple api config error", logger.Field("error", err)) return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "load apple api config error: %v", err) } if apiCfgPtr == nil { l.Errorw("restore: apple server api credential missing") return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "apple server api credential missing") } apiCfg := *apiCfgPtr for _, txID := range req.Transactions { if err := l.processSingleRestore(u, txID, pm, apiCfg); err != nil { l.Errorw("restore: single transaction failed", logger.Field("id", txID), logger.Field("error", err)) // continue to next, don't return error } } return nil } func (l *RestoreLogic) processSingleRestore(u *user.User, txID string, pm *iapapple.ProductMap, apiCfg iapapple.ServerAPIConfig) error { // 1. Try to verify as JWS first (if client sends JWS) var txp *iapapple.TransactionPayload var err error // Try to parse as JWS if len(txID) > 50 && (strings.Contains(txID, ".") || strings.HasPrefix(txID, "ey")) { txp, err = iapapple.VerifyTransactionJWS(txID) } else { // 2. If not JWS, treat as TransactionID and fetch from Apple var jws string jws, err = iapapple.GetTransactionInfo(apiCfg, txID) if err == nil { txp, err = iapapple.VerifyTransactionJWS(jws) } } if err != nil || txp == nil { l.Errorw("restore: invalid transaction", logger.Field("id", txID), logger.Field("error", err)) return fmt.Errorf("invalid transaction %s: %w", txID, err) } m, ok := pm.Items[txp.ProductId] if !ok { // fallback: 按命名约定(day/month/year + 数字)从订阅列表匹配 var parsedUnit string var parsedQuantity int64 if parsed := iapapple.ParseProductIdDuration(txp.ProductId); parsed != nil { parsedUnit = parsed.Unit parsedQuantity = parsed.Quantity } 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 { 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 { var dur int64 switch parsedUnit { case "Day": dur = parsedQuantity case "Month": dur = parsedQuantity * 30 case "Year": dur = parsedQuantity * 365 default: dur = parsedQuantity } m = iapapple.ProductMapping{DurationDays: dur, Tier: item.Name, SubscribeId: item.Id} ok = true break } } if ok { break } } } } if !ok { l.Errorw("restore: product mapping not found", logger.Field("productId", txp.ProductId)) return fmt.Errorf("product mapping not found for %s", txp.ProductId) } } // Check if already processed _, e := iapmodel.NewModel(l.svcCtx.DB, l.svcCtx.Redis).FindByOriginalId(l.ctx, txp.OriginalTransactionId) if e == nil { return nil // Already processed, skip } return l.svcCtx.DB.Transaction(func(tx *gorm.DB) error { iapTx := &iapmodel.Transaction{ UserId: u.Id, OriginalTransactionId: txp.OriginalTransactionId, TransactionId: txp.TransactionId, ProductId: txp.ProductId, PurchaseAt: txp.PurchaseDate, RevocationAt: txp.RevocationDate, JWSHash: "", } if err := tx.Model(&iapmodel.Transaction{}).Create(iapTx).Error; err != nil { return err } // Try to link with existing order if possible (Best Effort) // Parse "uid_123|orderNo_XXX" format from client if txp.AppAccountToken != "" { orderNo := txp.AppAccountToken if idx := strings.Index(orderNo, "orderNo_"); idx >= 0 { orderNo = orderNo[idx+len("orderNo_"):] } if orderNo != "" { _ = l.svcCtx.OrderModel.UpdateOrderStatus(l.ctx, orderNo, 2) } } exp := iapapple.CalcExpire(txp.PurchaseDate, m.DurationDays) if l.svcCtx.Config.Subscribe.SingleModel { anchorSub, anchorErr := findSingleModeMergeTarget(l.ctx, l.svcCtx, u.Id, m.SubscribeId) switch { case errors.Is(anchorErr, commonLogic.ErrSingleModePlanMismatch): return errors.Wrapf(xerr.NewErrCode(xerr.SingleSubscribePlanMismatch), "single subscribe mode plan mismatch") case anchorErr == nil && anchorSub != nil && anchorSub.Id > 0: if exp.After(anchorSub.ExpireTime) { anchorSub.ExpireTime = exp } anchorSub.Status = 1 anchorSub.FinishedAt = nil if err := l.svcCtx.UserModel.UpdateSubscribe(l.ctx, anchorSub, tx); err != nil { return err } return nil case errors.Is(anchorErr, gorm.ErrRecordNotFound): case anchorErr != nil: return anchorErr } } userSub := user.Subscribe{ UserId: u.Id, SubscribeId: m.SubscribeId, StartTime: time.Now(), ExpireTime: exp, Traffic: 0, Download: 0, Upload: 0, Token: fmt.Sprintf("iap:%s", txp.OriginalTransactionId), UUID: uuid.New().String(), Status: 1, } if err := l.svcCtx.UserModel.InsertSubscribe(l.ctx, &userSub, tx); err != nil { return err } return nil }) }