package apple import ( "context" "encoding/json" "fmt" "strconv" "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/payment" "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") } if err := commonLogic.DenyIfFamilyMemberReadonly(l.ctx, l.svcCtx.DB, u.Id); err != nil { return err } pm, _ := iapapple.ParseProductMap(l.svcCtx.Config.Site.CustomData) // Try to load payment config to get API credentials var apiCfg iapapple.ServerAPIConfig // We need to find *any* apple payment config to get credentials. // In most cases, there is only one apple payment method. // We can try to find by platform "apple" payMethods, err := l.svcCtx.PaymentModel.FindListByPlatform(l.ctx, "apple") if err == nil && len(payMethods) > 0 { // Use the first available config pay := payMethods[0] var cfg payment.AppleIAPConfig if err := cfg.Unmarshal([]byte(pay.Config)); err == nil { apiCfg = iapapple.ServerAPIConfig{ KeyID: cfg.KeyID, IssuerID: cfg.IssuerID, PrivateKey: cfg.PrivateKey, Sandbox: cfg.Sandbox, } // Fix private key format if needed (same as in attachTransactionByIdLogic) if !strings.Contains(apiCfg.PrivateKey, "\n") && strings.Contains(apiCfg.PrivateKey, "BEGIN PRIVATE KEY") { apiCfg.PrivateKey = strings.ReplaceAll(apiCfg.PrivateKey, " ", "\n") apiCfg.PrivateKey = strings.ReplaceAll(apiCfg.PrivateKey, "-----BEGIN\nPRIVATE\nKEY-----", "-----BEGIN PRIVATE KEY-----") apiCfg.PrivateKey = strings.ReplaceAll(apiCfg.PrivateKey, "-----END\nPRIVATE\nKEY-----", "-----END PRIVATE KEY-----") } } } // Fallback credentials if missing if apiCfg.PrivateKey == "" || apiCfg.KeyID == "" || apiCfg.IssuerID == "" { l.Errorw("restore: apple server api credential missing", logger.Field("keyID", apiCfg.KeyID), logger.Field("issuerID", apiCfg.IssuerID), logger.Field("hasPrivateKey", apiCfg.PrivateKey != "")) return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "apple server api credential missing") } // Try to get BundleID if apiCfg.BundleID == "" && l.svcCtx.Config.Site.CustomData != "" { var customData struct { IapBundleId string `json:"iapBundleId"` } _ = json.Unmarshal([]byte(l.svcCtx.Config.Site.CustomData), &customData) apiCfg.BundleID = customData.IapBundleId } return l.svcCtx.DB.Transaction(func(tx *gorm.DB) error { for _, txID := range req.Transactions { // 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)) continue } m, ok := pm.Items[txp.ProductId] if !ok { // fallback: 按命名约定(day/month/year + 数字)从订阅列表匹配 var parsedUnit string var parsedQuantity int64 pid := strings.ToLower(txp.ProductId) parts := strings.Split(pid, ".") for i := len(parts) - 1; i >= 0; i-- { p := parts[i] switch { case strings.HasPrefix(p, "day"): parsedUnit = "Day" p = p[len("day"):] case strings.HasPrefix(p, "month"): parsedUnit = "Month" p = p[len("month"):] case strings.HasPrefix(p, "year"): parsedUnit = "Year" p = p[len("year"):] default: continue } digits := p for j := 0; j < len(digits); j++ { if digits[j] < '0' || digits[j] > '9' { digits = digits[:j] break } } if q, e := strconv.ParseInt(digits, 10, 64); e == nil && q > 0 { parsedQuantity = q break } } 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)) continue } } // Check if already processed _, e := iapmodel.NewModel(l.svcCtx.DB, l.svcCtx.Redis).FindByOriginalId(l.ctx, txp.OriginalTransactionId) if e == nil { continue // Already processed, skip } 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) // Strategy 1: appAccountToken (from JWS) -> OrderNo (UUID) if txp.AppAccountToken != "" { // appAccountToken is usually a UUID string // Try to find order by parsing UUID or matching direct orderNo (if we stored it as uuid) // Since our orderNo is string, we can try to search it. // However, AppAccountToken is strictly UUID format. If our orderNo is not UUID, we might need a mapping. // Assuming orderNo -> UUID conversion was consistent on client side. // Here we just try to update if we find an unpaid order with this ID (if orderNo was used as appAccountToken) _ = l.svcCtx.OrderModel.UpdateOrderStatus(l.ctx, txp.AppAccountToken, 2, tx) } // Strategy 2: If we had a way to pass orderNo in restore request (optional field in future), we could use it here. // But for now, we only rely on appAccountToken or just skip order linking. 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 } continue 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 }) }