package apple import ( "context" "encoding/json" "strings" "time" "github.com/google/uuid" 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/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) // 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 (dev/debug) if apiCfg.PrivateKey == "" { apiCfg.PrivateKey = `-----BEGIN PRIVATE KEY----- MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgsVDj0g/D7uNCm8aC E4TuaiDT4Pgb1IuuZ69YdGNvcAegCgYIKoZIzj0DAQehRANCAARObgGumaESbPMM SIRDAVLcWemp0fMlnfDE4EHmqcD58arEJWsr3aWEhc4BHocOUIGjko0cVWGchrFa /T/KG1tr -----END PRIVATE KEY-----` apiCfg.KeyID = "2C4X3HVPM8" } if apiCfg.IssuerID == "" { apiCfg.IssuerID = "34f54810-5118-4b7f-8069-c8c1e012b7a9" } // 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 { 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) userSub := user.Subscribe{ UserId: u.Id, SubscribeId: m.SubscribeId, StartTime: time.Now(), ExpireTime: exp, Traffic: 0, Download: 0, Upload: 0, Token: txp.OriginalTransactionId, UUID: uuid.New().String(), Status: 1, } if err := l.svcCtx.UserModel.InsertSubscribe(l.ctx, &userSub, tx); err != nil { return err } } return nil }) }