shanshanzhong 130fb702ab
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 7m42s
fix: IAP 支付流程优化与关键 bug 修复
- getStatusLogic 类型断言修复(*user.User)
- restoreLogic 事务拆分为单条处理 + appAccountToken 解析
- attachTransactionLogic 提取 ParseProductIdDuration 共享函数
- 新增 config_helper.go 统一 Apple API 配置加载
- reconcileLogic 补充 BundleID 配置读取
- activateOrderLogic 邀请赠送天数逻辑完善

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-09 00:27:16 -07:00

213 lines
6.7 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"
"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")
}
if err := commonLogic.DenyIfFamilyMemberReadonly(l.ctx, l.svcCtx.DB, u.Id); err != nil {
return err
}
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
})
}