diff --git a/internal/logic/public/iap/apple/attachTransactionLogic.go b/internal/logic/public/iap/apple/attachTransactionLogic.go index c10281e..973fd83 100644 --- a/internal/logic/public/iap/apple/attachTransactionLogic.go +++ b/internal/logic/public/iap/apple/attachTransactionLogic.go @@ -92,10 +92,16 @@ func (l *AttachTransactionLogic) Attach(req *types.AttachAppleTransactionRequest } l.Infow("JWS 验签成功", logger.Field("productId", txPayload.ProductId), logger.Field("originalTransactionId", txPayload.OriginalTransactionId), logger.Field("purchaseAt", txPayload.PurchaseDate)) tradeNoCandidates := l.getAppleTradeNoCandidates(txPayload) - if err = l.validateOrderTradeNoBinding(orderInfo, tradeNoCandidates); err != nil { - l.Errorw("Apple 交易重复绑定,拒绝处理", logger.Field("orderNo", req.OrderNo), logger.Field("tradeNoCandidates", tradeNoCandidates), logger.Field("error", err.Error())) - l.sendIAPAttachTraceToTelegram("REJECT_DUPLICATE_TRANSACTION", orderInfo, u.Id, orderInfo.SubscribeId, "", orderInfo.Quantity, txPayload.PurchaseDate, txPayload.TransactionId, txPayload.OriginalTransactionId, err.Error()) - return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "apple transaction already used") + 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())) + l.sendIAPAttachTraceToTelegram("REJECT_BINDING_ERROR", orderInfo, u.Id, orderInfo.SubscribeId, "", orderInfo.Quantity, txPayload.PurchaseDate, txPayload.TransactionId, txPayload.OriginalTransactionId, 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)) + l.sendIAPAttachTraceToTelegram("REJECT_DUPLICATE_TRANSACTION", orderInfo, u.Id, orderInfo.SubscribeId, "", orderInfo.Quantity, txPayload.PurchaseDate, txPayload.TransactionId, txPayload.OriginalTransactionId, "already used by "+existingOrderNo) + return &types.AttachAppleTransactionResponse{ExistingOrderNo: existingOrderNo}, nil } tradeNo := "" if len(tradeNoCandidates) > 0 { @@ -104,42 +110,19 @@ func (l *AttachTransactionLogic) Attach(req *types.AttachAppleTransactionRequest orderAlreadyBound := containsString(tradeNoCandidates, orderInfo.TradeNo) // 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) + 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 - { - pid := strings.ToLower(txPayload.ProductId) - parts := strings.Split(pid, ".") - for i := len(parts) - 1; i >= 0; i-- { - p := parts[i] - if strings.HasPrefix(p, "day") || strings.HasPrefix(p, "month") || strings.HasPrefix(p, "year") { - 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"):] - } - 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 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)) @@ -314,7 +297,7 @@ func (l *AttachTransactionLogic) Attach(req *types.AttachAppleTransactionRequest 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, newExpire.Unix()); syncErr != nil { + if syncErr := l.syncOrderStatusAndEnqueue(orderInfo, 0); 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()) } @@ -335,7 +318,7 @@ func (l *AttachTransactionLogic) Attach(req *types.AttachAppleTransactionRequest 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, newExpire.Unix()); syncErr != nil { + if syncErr := l.syncOrderStatusAndEnqueue(orderInfo, 0); 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()) } @@ -353,7 +336,7 @@ func (l *AttachTransactionLogic) Attach(req *types.AttachAppleTransactionRequest 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, newExpire.Unix()); syncErr != nil { + if syncErr := l.syncOrderStatusAndEnqueue(orderInfo, 0); 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()) } @@ -523,12 +506,14 @@ func (l *AttachTransactionLogic) getAppleTradeNoCandidates(txPayload *iapapple.T return candidates } -func (l *AttachTransactionLogic) validateOrderTradeNoBinding(orderInfo *ordermodel.Order, tradeNoCandidates []string) error { +// 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 + return "", nil } if orderInfo.TradeNo != "" && !containsString(tradeNoCandidates, orderInfo.TradeNo) { - return errors.New("order already bound to another apple transaction") + return "", errors.New("order already bound to another apple transaction") } var boundOrder ordermodel.Order @@ -538,12 +523,12 @@ func (l *AttachTransactionLogic) validateOrderTradeNoBinding(orderInfo *ordermod Order("id DESC"). First(&boundOrder).Error if errors.Is(err, gorm.ErrRecordNotFound) { - return nil + return "", nil } if err != nil { - return err + return "", err } - return errors.Errorf("apple transaction already used by order %s", boundOrder.OrderNo) + return boundOrder.OrderNo, nil } func (l *AttachTransactionLogic) bindOrderTradeNo(orderInfo *ordermodel.Order, tradeNo string, tx ...*gorm.DB) error { diff --git a/internal/logic/public/iap/apple/config_helper.go b/internal/logic/public/iap/apple/config_helper.go new file mode 100644 index 0000000..d54a665 --- /dev/null +++ b/internal/logic/public/iap/apple/config_helper.go @@ -0,0 +1,57 @@ +package apple + +import ( + "context" + "encoding/json" + "strings" + + "github.com/perfect-panel/server/internal/model/payment" + "github.com/perfect-panel/server/internal/svc" + iapapple "github.com/perfect-panel/server/pkg/iap/apple" +) + +// LoadAppleServerAPIConfig loads the first enabled Apple IAP payment config +// from the payment table and returns a ServerAPIConfig ready for API calls. +func LoadAppleServerAPIConfig(ctx context.Context, svc *svc.ServiceContext) (*iapapple.ServerAPIConfig, error) { + pays, err := svc.PaymentModel.FindListByPlatform(ctx, "apple") + if err != nil { + return nil, err + } + for _, pay := range pays { + if pay.Enable == nil || !*pay.Enable || pay.Config == "" { + continue + } + var cfg payment.AppleIAPConfig + if e := cfg.Unmarshal([]byte(pay.Config)); e != nil { + continue + } + if cfg.KeyID == "" || cfg.IssuerID == "" || cfg.PrivateKey == "" { + continue + } + apiCfg := &iapapple.ServerAPIConfig{ + KeyID: cfg.KeyID, + IssuerID: cfg.IssuerID, + PrivateKey: fixPEMKey(cfg.PrivateKey), + Sandbox: cfg.Sandbox, + } + // Read BundleID from site custom data + if svc.Config.Site.CustomData != "" { + var customData struct { + IapBundleId string `json:"iapBundleId"` + } + _ = json.Unmarshal([]byte(svc.Config.Site.CustomData), &customData) + apiCfg.BundleID = customData.IapBundleId + } + return apiCfg, nil + } + return nil, nil +} + +func fixPEMKey(key string) string { + if !strings.Contains(key, "\n") && strings.Contains(key, "BEGIN PRIVATE KEY") { + key = strings.ReplaceAll(key, " ", "\n") + key = strings.ReplaceAll(key, "-----BEGIN\nPRIVATE\nKEY-----", "-----BEGIN PRIVATE KEY-----") + key = strings.ReplaceAll(key, "-----END\nPRIVATE\nKEY-----", "-----END PRIVATE KEY-----") + } + return key +} diff --git a/internal/logic/public/iap/apple/getStatusLogic.go b/internal/logic/public/iap/apple/getStatusLogic.go index 524a155..1088076 100644 --- a/internal/logic/public/iap/apple/getStatusLogic.go +++ b/internal/logic/public/iap/apple/getStatusLogic.go @@ -5,6 +5,7 @@ import ( "strings" "time" + "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" @@ -28,7 +29,7 @@ func NewGetStatusLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetStat } func (l *GetStatusLogic) GetStatus() (*types.GetAppleStatusResponse, error) { - u, ok := l.ctx.Value(constant.CtxKeyUser).(*struct{ Id int64 }) + u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User) if !ok || u == nil { return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "invalid access") } diff --git a/internal/logic/public/iap/apple/restoreLogic.go b/internal/logic/public/iap/apple/restoreLogic.go index 3e15c1d..3bd7b18 100644 --- a/internal/logic/public/iap/apple/restoreLogic.go +++ b/internal/logic/public/iap/apple/restoreLogic.go @@ -4,14 +4,12 @@ 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" @@ -47,214 +45,167 @@ func (l *RestoreLogic) Restore(req *types.RestoreAppleTransactionsRequest) error 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-----") - } - } + // 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) } - - // 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 != "")) + if apiCfgPtr == nil { + l.Errorw("restore: apple server api credential missing") 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"` + 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) } - _ = 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 + 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) + } - // 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: + 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 } - digits := p - for j := 0; j < len(digits); j++ { - if digits[j] < '0' || digits[j] > '9' { - digits = digits[:j] + 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 q, e := strconv.ParseInt(digits, 10, 64); e == nil && q > 0 { - parsedQuantity = q + if ok { 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 } + } + 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 + } - // 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) + 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) + } + } - // 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 + 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 - } + } + 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 }) diff --git a/internal/model/order/model.go b/internal/model/order/model.go index 12ff227..94b641e 100644 --- a/internal/model/order/model.go +++ b/internal/model/order/model.go @@ -104,7 +104,7 @@ func (m *customOrderModel) QueryOrderListByPage(ctx context.Context, page, size return total, list, err } -// UpdateOrderStatus Update order status +// UpdateOrderStatus Update order status with state guard to prevent backward transitions func (m *customOrderModel) UpdateOrderStatus(ctx context.Context, orderNo string, status uint8, tx ...*gorm.DB) error { orderInfo, err := m.FindOneByOrderNo(ctx, orderNo) if err != nil { @@ -114,7 +114,15 @@ func (m *customOrderModel) UpdateOrderStatus(ctx context.Context, orderNo string if len(tx) > 0 { conn = tx[0] } - return conn.Model(&Order{}).Where("order_no = ?", orderNo).Update("status", status).Error + result := conn.Model(&Order{}).Where("order_no = ? AND status < ?", orderNo, status).Update("status", status) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + // Order already at or past the target status — idempotent success + return nil + } + return nil }, m.getCacheKeys(orderInfo)...) } diff --git a/internal/types/types.go b/internal/types/types.go index a4f015e..0895690 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -130,8 +130,9 @@ type AttachAppleTransactionRequest struct { } type AttachAppleTransactionResponse struct { - ExpiresAt int64 `json:"expires_at"` - Tier string `json:"tier"` + ExpiresAt int64 `json:"expires_at"` + Tier string `json:"tier"` + ExistingOrderNo string `json:"existing_order_no,omitempty"` } type AuthConfig struct { diff --git a/pkg/iap/apple/productmap.go b/pkg/iap/apple/productmap.go index 45a51c0..6a821c3 100644 --- a/pkg/iap/apple/productmap.go +++ b/pkg/iap/apple/productmap.go @@ -2,6 +2,8 @@ package apple import ( "encoding/json" + "strconv" + "strings" "time" ) @@ -37,3 +39,44 @@ func CalcExpire(start time.Time, days int64) time.Time { } return start.Add(time.Duration(days) * 24 * time.Hour) } + +// ParsedProduct holds the result of parsing an Apple product ID. +type ParsedProduct struct { + Unit string // "Day", "Month", "Year" + Quantity int64 +} + +// ParseProductIdDuration extracts time unit and quantity from Apple product IDs. +// e.g. "com.app.vip.day30" -> {Unit: "Day", Quantity: 30} +func ParseProductIdDuration(productId string) *ParsedProduct { + pid := strings.ToLower(productId) + parts := strings.Split(pid, ".") + for i := len(parts) - 1; i >= 0; i-- { + p := parts[i] + var unit string + switch { + case strings.HasPrefix(p, "day"): + unit = "Day" + p = p[len("day"):] + case strings.HasPrefix(p, "month"): + unit = "Month" + p = p[len("month"):] + case strings.HasPrefix(p, "year"): + unit = "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 { + return &ParsedProduct{Unit: unit, Quantity: q} + } + } + return nil +} diff --git a/queue/logic/iap/reconcileLogic.go b/queue/logic/iap/reconcileLogic.go index e79690c..de36bd5 100644 --- a/queue/logic/iap/reconcileLogic.go +++ b/queue/logic/iap/reconcileLogic.go @@ -72,7 +72,9 @@ func (l *ReconcileLogic) reconcile(ctx context.Context, minAge, maxAge time.Dura if txPayload.RevocationDate != nil { // 苹果已撤销交易 → 关闭订单 logger.Infof("[IAPReconcile] transaction revoked, closing order: %s", ord.OrderNo) - _ = l.svc.OrderModel.UpdateOrderStatus(ctx, ord.OrderNo, 3) + if closeErr := l.svc.OrderModel.UpdateOrderStatus(ctx, ord.OrderNo, 3); closeErr != nil { + logger.Errorf("[IAPReconcile] close revoked order failed: orderNo=%s err=%v", ord.OrderNo, closeErr) + } continue } // 正常已付款交易 → enqueue 激活(activateOrderLogic 内部有幂等保护) diff --git a/queue/logic/order/activateOrderLogic.go b/queue/logic/order/activateOrderLogic.go index 9872966..0737d83 100644 --- a/queue/logic/order/activateOrderLogic.go +++ b/queue/logic/order/activateOrderLogic.go @@ -191,7 +191,7 @@ func (l *ActivateOrderLogic) processOrderByType(ctx context.Context, orderInfo * // finalizeCouponAndOrder handles post-processing tasks including coupon updates // and order status finalization func (l *ActivateOrderLogic) finalizeCouponAndOrder(ctx context.Context, orderInfo *order.Order) { - // Update coupon if exists + // Update coupon if exists (non-critical, logged but not blocking) if orderInfo.Coupon != "" { if err := l.svc.CouponModel.UpdateCount(ctx, orderInfo.Coupon); err != nil { logger.WithContext(ctx).Error("Update coupon status failed", @@ -201,14 +201,14 @@ func (l *ActivateOrderLogic) finalizeCouponAndOrder(ctx context.Context, orderIn } } - // Update order status - orderInfo.Status = OrderStatusFinished - if err := l.svc.OrderModel.Update(ctx, orderInfo); err != nil { + // Update order status using state-guarded UpdateOrderStatus to prevent double finalization + if err := l.svc.OrderModel.UpdateOrderStatus(ctx, orderInfo.OrderNo, OrderStatusFinished); err != nil { logger.WithContext(ctx).Error("Update order status failed", logger.Field("error", err.Error()), logger.Field("order_no", orderInfo.OrderNo), ) } + orderInfo.Status = OrderStatusFinished } // NewPurchase handles new subscription purchase including user creation, @@ -594,10 +594,28 @@ func (l *ActivateOrderLogic) handleCommission(ctx context.Context, userInfo *use // Use transaction for commission updates err = l.svc.DB.Transaction(func(tx *gorm.DB) error { - referer.Commission += amount - if err = l.svc.UserModel.Update(ctx, referer, tx); err != nil { - return err + // Idempotency: check if commission log already exists for this order + var existingLogCount int64 + if e := tx.Model(&log.SystemLog{}). + Where("type = ? AND object_id = ? AND content LIKE ?", + log.TypeCommission.Uint8(), referer.Id, fmt.Sprintf("%%\"%s\"%%", orderInfo.OrderNo)). + Count(&existingLogCount).Error; e != nil { + return e } + if existingLogCount > 0 { + logger.WithContext(ctx).Info("Commission already processed, skip", + logger.Field("order_no", orderInfo.OrderNo), + logger.Field("referer_id", referer.Id), + ) + return nil + } + + // Atomic increment to prevent lost updates under concurrency + if e := tx.Model(&user.User{}).Where("id = ?", referer.Id). + UpdateColumn("commission", gorm.Expr("commission + ?", amount)).Error; e != nil { + return e + } + referer.Commission += amount var commissionType uint16 switch orderInfo.Type { @@ -605,6 +623,8 @@ func (l *ActivateOrderLogic) handleCommission(ctx context.Context, userInfo *use commissionType = log.CommissionTypePurchase case OrderTypeRenewal: commissionType = log.CommissionTypeRenewal + default: + commissionType = log.CommissionTypePurchase } commissionLog := &log.Commission{ @@ -657,6 +677,23 @@ func (l *ActivateOrderLogic) grantGiftDays(ctx context.Context, u *user.User, da if u == nil || days <= 0 { return nil } + + // Idempotency: check if gift log already exists for this order + user + var existingLogCount int64 + if e := l.svc.DB.Model(&log.SystemLog{}). + Where("type = ? AND object_id = ? AND content LIKE ?", + log.TypeGift.Uint8(), u.Id, fmt.Sprintf("%%\"%s\"%%", orderNo)). + Count(&existingLogCount).Error; e != nil { + return e + } + if existingLogCount > 0 { + logger.WithContext(ctx).Info("Gift days already granted, skip", + logger.Field("order_no", orderNo), + logger.Field("user_id", u.Id), + ) + return nil + } + activeSubscribe, err := l.svc.UserModel.FindActiveSubscribe(ctx, u.Id) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -946,10 +983,28 @@ func (l *ActivateOrderLogic) Recharge(ctx context.Context, orderInfo *order.Orde // Update balance in transaction err = l.svc.DB.Transaction(func(tx *gorm.DB) error { - userInfo.Balance += orderInfo.Price - if err = l.svc.UserModel.Update(ctx, userInfo, tx); err != nil { - return err + // Idempotency: check if balance log already exists for this order + var existingLogCount int64 + if e := tx.Model(&log.SystemLog{}). + Where("type = ? AND object_id = ? AND content LIKE ?", + log.TypeBalance.Uint8(), userInfo.Id, fmt.Sprintf("%%\"%s\"%%", orderInfo.OrderNo)). + Count(&existingLogCount).Error; e != nil { + return e } + if existingLogCount > 0 { + logger.WithContext(ctx).Info("Recharge already processed, skip", + logger.Field("order_no", orderInfo.OrderNo), + logger.Field("user_id", userInfo.Id), + ) + return nil + } + + // Atomic increment to prevent lost updates + if e := tx.Model(&user.User{}).Where("id = ?", userInfo.Id). + UpdateColumn("balance", gorm.Expr("balance + ?", orderInfo.Price)).Error; e != nil { + return e + } + userInfo.Balance += orderInfo.Price balanceLog := &log.Balance{ Amount: orderInfo.Price, @@ -1068,6 +1123,9 @@ func (l *ActivateOrderLogic) buildAdminNotificationData(orderInfo *order.Order, // sendUserNotifyWithTelegram sends a notification message to a user via Telegram func (l *ActivateOrderLogic) sendUserNotifyWithTelegram(chatId int64, text string) { + if l.svc.TelegramBot == nil { + return + } msg := tgbotapi.NewMessage(chatId, text) msg.ParseMode = "markdown" if _, err := l.svc.TelegramBot.Send(msg); err != nil { @@ -1077,6 +1135,9 @@ func (l *ActivateOrderLogic) sendUserNotifyWithTelegram(chatId int64, text strin // sendAdminNotifyWithTelegram sends a notification message to all admin users via Telegram func (l *ActivateOrderLogic) sendAdminNotifyWithTelegram(ctx context.Context, text string) { + if l.svc.TelegramBot == nil { + return + } admins, err := l.svc.UserModel.QueryAdminUsers(ctx) if err != nil { logger.WithContext(ctx).Error("Query admin users failed", logger.Field("error", err.Error()))