package vip import ( "context" "crypto/sha1" "encoding/hex" "errors" "fmt" "net/http" "strconv" "strings" "time" "chatapp3-golang/internal/integration" "chatapp3-golang/internal/model" "chatapp3-golang/internal/utils" "gorm.io/gorm" "gorm.io/gorm/clause" ) // GetHome returns VIP configuration plus the current user's purchasable state. func (s *Service) GetHome(ctx context.Context, user AuthUser) (*HomeResponse, error) { sysOrigin, err := normalizeUser(user) if err != nil { return nil, err } configs, err := s.loadConfigMap(ctx, sysOrigin) if err != nil { return nil, err } now := time.Now() state, err := s.loadUserState(ctx, sysOrigin, user.UserID, now) if err != nil { return nil, err } activeState := activeStateOrNil(state, now) levels := make([]VipLevelHomePayload, 0, levelCount) for level := 1; level <= levelCount; level++ { payload := defaultLevelPayload(level) config, exists := configs[level] if exists { payload = payloadFromConfig(config) } item := VipLevelHomePayload{VipLevelPayload: payload} if exists && config.Enabled { plan, planErr := buildPurchasePlan(now, activeState, config, configs) item.CanPurchase = planErr == nil if planErr == nil { item.PayableGold = plan.PayableGold } } levels = append(levels, item) } statePayload := statePayloadFromModel(state, now) statePayload.UserID = user.UserID statePayload.SysOrigin = sysOrigin return &HomeResponse{ UserID: user.UserID, SysOrigin: sysOrigin, Configured: len(configs) > 0, State: statePayload, Levels: levels, }, nil } // GetStatus returns only the current user's VIP state. func (s *Service) GetStatus(ctx context.Context, user AuthUser) (*UserVipStatePayload, error) { sysOrigin, err := normalizeUser(user) if err != nil { return nil, err } now := time.Now() state, err := s.loadUserState(ctx, sysOrigin, user.UserID, now) if err != nil { return nil, err } payload := statePayloadFromModel(state, now) payload.UserID = user.UserID payload.SysOrigin = sysOrigin return &payload, nil } // PreviewPurchase calculates the cost and resulting period before purchase. func (s *Service) PreviewPurchase(ctx context.Context, user AuthUser, req PurchaseRequest) (*PurchasePreviewResponse, error) { sysOrigin, err := normalizeUser(user) if err != nil { return nil, err } targetLevel := parseTargetLevel(req) if err := validateLevel(targetLevel); err != nil { return nil, err } configs, err := s.loadConfigMap(ctx, sysOrigin) if err != nil { return nil, err } targetConfig, err := resolveTargetConfig(configs, targetLevel) if err != nil { return nil, err } now := time.Now() state, err := s.loadUserState(ctx, sysOrigin, user.UserID, now) if err != nil { return nil, err } plan, err := buildPurchasePlan(now, activeStateOrNil(state, now), targetConfig, configs) if err != nil { return nil, err } return &PurchasePreviewResponse{ UserID: user.UserID, SysOrigin: sysOrigin, OrderType: plan.OrderType, CurrentLevel: plan.CurrentLevel, TargetLevel: plan.TargetLevel, DurationDays: plan.DurationDays, OriginalPriceGold: plan.OriginalPriceGold, PayableGold: plan.PayableGold, StartAt: formatTime(plan.StartAt), ExpireAt: formatTime(plan.ExpireAt), TargetConfig: payloadFromConfig(plan.TargetConfig), }, nil } // Purchase buys, renews, or upgrades a 30-day VIP package. func (s *Service) Purchase(ctx context.Context, user AuthUser, req PurchaseRequest) (*PurchaseResponse, error) { sysOrigin, err := normalizeUser(user) if err != nil { return nil, err } targetLevel := parseTargetLevel(req) if err := validateLevel(targetLevel); err != nil { return nil, err } idempotentKey := normalizeIdempotentKey(req.BizIdempotentKey) if idempotentKey != "" { eventID := buildVIPEventID(sysOrigin, user.UserID, idempotentKey) existing, err := s.loadOrderByEventID(ctx, eventID) if err != nil { return nil, err } if existing != nil { if existing.Status != orderStatusSuccess { return nil, NewAppError(http.StatusConflict, "vip_order_in_progress", "vip order is already submitted") } now := time.Now() state, stateErr := s.loadUserState(ctx, sysOrigin, user.UserID, now) if stateErr != nil { return nil, stateErr } statePayload := statePayloadFromModel(state, now) statePayload.UserID = user.UserID statePayload.SysOrigin = sysOrigin return &PurchaseResponse{ Success: true, Order: orderPayloadFromModel(*existing), State: statePayload, }, nil } } configs, err := s.loadConfigMap(ctx, sysOrigin) if err != nil { return nil, err } targetConfig, err := resolveTargetConfig(configs, targetLevel) if err != nil { return nil, err } now := time.Now() orderID, err := utils.NextID() if err != nil { return nil, err } eventKey := idempotentKey if eventKey == "" { eventKey = strconv.FormatInt(orderID, 10) } tx := s.db.WithContext(ctx).Begin() if tx.Error != nil { return nil, tx.Error } committed := false defer func() { if !committed { _ = tx.Rollback().Error } }() state, err := s.loadOrCreateLockedState(tx, sysOrigin, user.UserID, now) if err != nil { return nil, err } activeState := activeStateOrNil(state, now) plan, err := buildPurchasePlan(now, activeState, targetConfig, configs) if err != nil { return nil, err } order := model.VipOrderRecord{ ID: orderID, EventID: buildVIPEventID(sysOrigin, user.UserID, eventKey), SysOrigin: sysOrigin, UserID: user.UserID, OrderType: plan.OrderType, FromLevel: plan.CurrentLevel, ToLevel: plan.TargetLevel, DurationDays: plan.DurationDays, OriginalPriceGold: plan.OriginalPriceGold, PaidGold: plan.PayableGold, Status: orderStatusInit, StartAt: plan.StartAt, ExpireAt: plan.ExpireAt, CreateTime: now, UpdateTime: now, } if err := tx.Create(&order).Error; err != nil { return nil, err } if plan.PayableGold > 0 { if s.java == nil { err := NewAppError(http.StatusServiceUnavailable, "wallet_unavailable", "wallet gateway is unavailable") if saveErr := s.markOrderFailed(tx, &order, err.Error()); saveErr != nil { return nil, saveErr } if commitErr := tx.Commit().Error; commitErr != nil { return nil, commitErr } committed = true return nil, err } if err := s.java.ChangeGoldBalance(ctx, buildWalletCommand(user.UserID, sysOrigin, order)); err != nil { appErr := NewAppError(http.StatusBadGateway, "vip_wallet_deduct_failed", err.Error()) if saveErr := s.markOrderFailed(tx, &order, err.Error()); saveErr != nil { return nil, saveErr } if commitErr := tx.Commit().Error; commitErr != nil { return nil, commitErr } committed = true return nil, appErr } } applyPlanToState(state, plan, now) order.Status = orderStatusSuccess order.ErrorMessage = "" order.UpdateTime = now if err := tx.Save(state).Error; err != nil { return nil, err } if err := tx.Save(&order).Error; err != nil { return nil, err } if err := tx.Commit().Error; err != nil { return nil, err } committed = true payload := statePayloadFromModel(state, now) return &PurchaseResponse{ Success: true, Order: orderPayloadFromModel(order), State: payload, }, nil } func normalizeUser(user AuthUser) (string, error) { if user.UserID <= 0 { return "", NewAppError(http.StatusUnauthorized, "invalid_user", "user is required") } sysOrigin := normalizeSysOrigin(user.SysOrigin) if sysOrigin == "" { return "", NewAppError(http.StatusBadRequest, "bad_request", "sysOrigin is required") } return sysOrigin, nil } func resolveTargetConfig(configs map[int]model.VipLevelConfig, targetLevel int) (model.VipLevelConfig, error) { config, exists := configs[targetLevel] if !exists { return model.VipLevelConfig{}, NewAppError(http.StatusBadRequest, "vip_not_configured", "target vip level is not configured") } if !config.Enabled { return model.VipLevelConfig{}, NewAppError(http.StatusBadRequest, "vip_level_disabled", "target vip level is disabled") } if config.PriceGold < 0 { return model.VipLevelConfig{}, NewAppError(http.StatusBadRequest, "invalid_price", "target vip level price is invalid") } if config.DurationDays <= 0 { config.DurationDays = vipDurationDay } return config, nil } func buildPurchasePlan( now time.Time, currentState *model.UserVipState, targetConfig model.VipLevelConfig, configs map[int]model.VipLevelConfig, ) (purchasePlan, error) { targetLevel := targetConfig.Level if err := validateLevel(targetLevel); err != nil { return purchasePlan{}, err } if !targetConfig.Enabled { return purchasePlan{}, NewAppError(http.StatusBadRequest, "vip_level_disabled", "target vip level is disabled") } durationDays := targetConfig.DurationDays if durationDays <= 0 { durationDays = vipDurationDay } currentLevel := 0 startAt := now expireAt := now.AddDate(0, 0, durationDays) orderType := orderTypePurchase payableGold := targetConfig.PriceGold if isActiveState(currentState, now) { currentLevel = currentState.Level if targetLevel < currentLevel { return purchasePlan{}, NewAppError(http.StatusBadRequest, "vip_downgrade_not_allowed", "vip downgrade is not allowed") } startAt = currentState.StartAt if startAt.IsZero() { startAt = now } if targetLevel == currentLevel { orderType = orderTypeRenew expireAt = currentState.ExpireAt.AddDate(0, 0, durationDays) payableGold = targetConfig.PriceGold } else { orderType = orderTypeUpgrade expireAt = currentState.ExpireAt currentPrice := int64(0) if currentConfig, exists := configs[currentLevel]; exists { currentPrice = currentConfig.PriceGold } payableGold = targetConfig.PriceGold - currentPrice if payableGold < 0 { payableGold = 0 } } } return purchasePlan{ OrderType: orderType, CurrentLevel: currentLevel, TargetLevel: targetLevel, DurationDays: durationDays, OriginalPriceGold: targetConfig.PriceGold, PayableGold: payableGold, StartAt: startAt, ExpireAt: expireAt, TargetConfig: targetConfig, }, nil } func (s *Service) loadUserState(ctx context.Context, sysOrigin string, userID int64, now time.Time) (*model.UserVipState, error) { var row model.UserVipState err := s.db.WithContext(ctx). Where("sys_origin = ? AND user_id = ?", sysOrigin, userID). First(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } if err != nil { return nil, err } if row.Status == statusActive && !row.ExpireAt.After(now) { expireState(&row, now) if err := s.db.WithContext(ctx).Save(&row).Error; err != nil { return nil, err } } return &row, nil } func (s *Service) loadOrCreateLockedState(tx *gorm.DB, sysOrigin string, userID int64, now time.Time) (*model.UserVipState, error) { var row model.UserVipState err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("sys_origin = ? AND user_id = ?", sysOrigin, userID). First(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { nextID, idErr := utils.NextID() if idErr != nil { return nil, idErr } row = model.UserVipState{ ID: nextID, SysOrigin: sysOrigin, UserID: userID, Status: statusExpired, StartAt: now, ExpireAt: now, CreateTime: now, UpdateTime: now, } if err := tx.Create(&row).Error; err != nil { return nil, err } return &row, nil } if err != nil { return nil, err } if row.Status == statusActive && !row.ExpireAt.After(now) { expireState(&row, now) if err := tx.Save(&row).Error; err != nil { return nil, err } } return &row, nil } func activeStateOrNil(state *model.UserVipState, now time.Time) *model.UserVipState { if isActiveState(state, now) { return state } return nil } func applyPlanToState(state *model.UserVipState, plan purchasePlan, now time.Time) { state.Level = plan.TargetLevel state.LevelCode = levelCode(plan.TargetLevel) state.DisplayName = strings.TrimSpace(plan.TargetConfig.DisplayName) if state.DisplayName == "" { state.DisplayName = levelDisplayName(plan.TargetLevel) } state.Status = statusActive state.StartAt = plan.StartAt state.ExpireAt = plan.ExpireAt state.BadgeResourceID = plan.TargetConfig.BadgeResourceID state.BadgeName = plan.TargetConfig.BadgeName state.BadgeURL = plan.TargetConfig.BadgeURL state.AvatarFrameResourceID = plan.TargetConfig.AvatarFrameResourceID state.AvatarFrameName = plan.TargetConfig.AvatarFrameName state.AvatarFrameURL = plan.TargetConfig.AvatarFrameURL state.EntryEffectResourceID = plan.TargetConfig.EntryEffectResourceID state.EntryEffectName = plan.TargetConfig.EntryEffectName state.EntryEffectURL = plan.TargetConfig.EntryEffectURL state.ChatBubbleResourceID = plan.TargetConfig.ChatBubbleResourceID state.ChatBubbleName = plan.TargetConfig.ChatBubbleName state.ChatBubbleURL = plan.TargetConfig.ChatBubbleURL state.FloatPictureResourceID = plan.TargetConfig.FloatPictureResourceID state.FloatPictureName = plan.TargetConfig.FloatPictureName state.FloatPictureURL = plan.TargetConfig.FloatPictureURL state.UpdateTime = now if state.CreateTime.IsZero() { state.CreateTime = now } } func expireState(state *model.UserVipState, now time.Time) { state.Level = 0 state.LevelCode = "" state.DisplayName = "" state.Status = statusExpired state.BadgeResourceID = 0 state.BadgeName = "" state.BadgeURL = "" state.AvatarFrameResourceID = 0 state.AvatarFrameName = "" state.AvatarFrameURL = "" state.EntryEffectResourceID = 0 state.EntryEffectName = "" state.EntryEffectURL = "" state.ChatBubbleResourceID = 0 state.ChatBubbleName = "" state.ChatBubbleURL = "" state.FloatPictureResourceID = 0 state.FloatPictureName = "" state.FloatPictureURL = "" state.UpdateTime = now } func buildWalletCommand(userID int64, sysOrigin string, order model.VipOrderRecord) integration.GoldReceiptCommand { return integration.GoldReceiptCommand{ ReceiptType: walletReceiptExpenditure, UserID: userID, SysOrigin: sysOrigin, EventID: order.EventID, Remark: fmt.Sprintf("VIP %s from level %d to %d, %d days", order.OrderType, order.FromLevel, order.ToLevel, order.DurationDays), Amount: integration.NewPennyAmountPayloadFromDollar(order.PaidGold), CloseDelayAsset: false, OpUserType: "APP", CustomizeOrigin: walletOrigin, CustomizeOriginDesc: "VIP purchase", } } func (s *Service) markOrderFailed(tx *gorm.DB, order *model.VipOrderRecord, message string) error { order.Status = orderStatusFailed order.ErrorMessage = trimErrorMessage(message) order.UpdateTime = time.Now() return tx.Save(order).Error } func (s *Service) loadOrderByEventID(ctx context.Context, eventID string) (*model.VipOrderRecord, error) { var row model.VipOrderRecord err := s.db.WithContext(ctx).Where("event_id = ?", eventID).First(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } if err != nil { return nil, err } return &row, nil } func buildVIPEventID(sysOrigin string, userID int64, key string) string { sysPart := normalizeSysOrigin(sysOrigin) if len(sysPart) > 8 { sysPart = sysPart[:8] } sum := sha1.Sum([]byte(fmt.Sprintf("%s:%d:%s", sysOrigin, userID, key))) return fmt.Sprintf("VIP:%s:%d:%s", sysPart, userID, hex.EncodeToString(sum[:])[:16]) } func normalizeIdempotentKey(key string) string { key = strings.TrimSpace(key) if len(key) > 64 { key = key[:64] } return key } func trimErrorMessage(message string) string { message = strings.TrimSpace(message) if len(message) > 512 { return message[:512] } return message }