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) resourceIDs := resourceIDsFromConfigMap(configs) resourceIDs = appendResourceIDsFromState(resourceIDs, state) resources, err := s.loadResourceLookup(ctx, resourceIDs) if err != nil { return nil, err } levels := make([]VipLevelHomePayload, 0, levelCount) for level := 1; level <= levelCount; level++ { payload := defaultLevelPayload(level) config, exists := configs[level] if exists { payload = payloadFromConfigWithResources(config, resources) } 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 := statePayloadFromModelWithResources(state, now, resources) 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 } resources, err := s.loadResourceLookup(ctx, appendResourceIDsFromState(nil, state)) if err != nil { return nil, err } payload := statePayloadFromModelWithResources(state, now, resources) 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 } resources, err := s.loadResourceLookup(ctx, appendResourceIDsFromConfig(nil, plan.TargetConfig)) 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: payloadFromConfigWithResources(plan.TargetConfig, resources), }, 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 } if err := s.ensureStateResourcesGranted(ctx, user.UserID, state, now); err != nil { return nil, NewAppError(http.StatusBadGateway, "vip_resource_grant_failed", err.Error()) } resources, resourceErr := s.loadResourceLookup(ctx, appendResourceIDsFromState(nil, state)) if resourceErr != nil { return nil, resourceErr } statePayload := statePayloadFromModelWithResources(state, now, resources) 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 := mapWalletPurchaseError(err) 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 if err := s.ensurePlanResourcesGranted(ctx, user.UserID, plan, now); err != nil { return nil, NewAppError(http.StatusBadGateway, "vip_resource_grant_failed", err.Error()) } resources, err := s.loadResourceLookup(ctx, appendResourceIDsFromState(nil, state)) if err != nil { return nil, err } payload := statePayloadFromModelWithResources(state, now, resources) payload.UserID = user.UserID payload.SysOrigin = sysOrigin 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 = "" state.BadgeURL = "" state.AvatarFrameResourceID = plan.TargetConfig.AvatarFrameResourceID state.AvatarFrameName = "" state.AvatarFrameURL = "" state.EntryEffectResourceID = plan.TargetConfig.EntryEffectResourceID state.EntryEffectName = "" state.EntryEffectURL = "" state.ChatBubbleResourceID = plan.TargetConfig.ChatBubbleResourceID state.ChatBubbleName = "" state.ChatBubbleURL = "" state.FloatPictureResourceID = plan.TargetConfig.FloatPictureResourceID state.FloatPictureName = "" state.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 (s *Service) ensurePlanResourcesGranted(ctx context.Context, userID int64, plan purchasePlan, now time.Time) error { return s.ensureVIPResourcesGranted(ctx, userID, vipGrantResourcesFromConfig(plan.TargetConfig), plan.ExpireAt, plan.DurationDays, now) } func (s *Service) ensureStateResourcesGranted(ctx context.Context, userID int64, state *model.UserVipState, now time.Time) error { if !isActiveState(state, now) { return nil } return s.ensureVIPResourcesGranted(ctx, userID, vipGrantResourcesFromState(state), state.ExpireAt, vipGrantDaysUntil(now, state.ExpireAt), now) } func (s *Service) ensureVIPResourcesGranted(ctx context.Context, userID int64, resources []vipGrantResource, expireAt time.Time, days int, now time.Time) error { if len(resources) == 0 { return nil } if s.java == nil { return fmt.Errorf("java gateway is unavailable") } if days <= 0 { days = 1 } for _, resource := range resources { if resource.ResourceID <= 0 { continue } exists, err := s.vipResourceExists(ctx, userID, resource, expireAt) if err != nil { return err } if !exists { if err := s.grantVIPResource(ctx, userID, resource, days); err != nil { return err } } if !resource.Badge { if err := s.java.SwitchUseProps(ctx, userID, resource.ResourceID); err != nil { return fmt.Errorf("switch vip resource %s:%d: %w", resource.PropsType, resource.ResourceID, err) } } if err := s.alignVIPResourceExpireAt(ctx, userID, resource, expireAt, now); err != nil { return err } } if err := s.java.RemoveUserProfileCacheAll(ctx, userID); err != nil { return fmt.Errorf("remove vip user profile cache: %w", err) } return nil } func (s *Service) grantVIPResource(ctx context.Context, userID int64, resource vipGrantResource, days int) error { if resource.Badge { if err := s.java.ActivateTemporaryBadge(ctx, userID, resource.ResourceID, days); err != nil { return fmt.Errorf("grant vip badge %d: %w", resource.ResourceID, err) } return nil } req := integration.GivePropsBackpackRequest{ AcceptUserID: userID, PropsID: resource.ResourceID, Type: resource.PropsType, Origin: walletOrigin, OriginDesc: walletOriginDesc, Days: days, UseProps: true, AllowGive: false, } if err := s.java.GivePropsBackpack(ctx, req); err != nil { return fmt.Errorf("grant vip resource %s:%d: %w", resource.PropsType, resource.ResourceID, err) } return nil } func (s *Service) vipResourceExists(ctx context.Context, userID int64, resource vipGrantResource, expireAt time.Time) (bool, error) { var count int64 threshold := expireAt.Add(-time.Second) query := s.db.WithContext(ctx) if resource.Badge { query = query.Table("user_badge_backpack"). Where("user_id = ? AND badge_id = ? AND expire_time >= ?", userID, resource.ResourceID, threshold) } else { query = query.Table("user_props_backpack"). Where("user_id = ? AND props_id = ? AND type = ? AND expire_time >= ?", userID, resource.ResourceID, resource.PropsType, threshold) } if err := query.Count(&count).Error; err != nil { return false, err } return count > 0, nil } func (s *Service) alignVIPResourceExpireAt(ctx context.Context, userID int64, resource vipGrantResource, expireAt time.Time, now time.Time) error { var result *gorm.DB if resource.Badge { result = s.db.WithContext(ctx).Exec( "UPDATE user_badge_backpack SET expire_type = ?, expire_time = ?, is_use_props = ?, update_time = ? WHERE user_id = ? AND badge_id = ?", "TEMPORARY", expireAt, true, now, userID, resource.ResourceID, ) } else { result = s.db.WithContext(ctx).Exec( "UPDATE user_props_backpack SET expire_time = ?, is_use_props = ?, allow_give = ?, update_time = ? WHERE user_id = ? AND props_id = ? AND type = ?", expireAt, true, false, now, userID, resource.ResourceID, resource.PropsType, ) } if result.Error != nil { return result.Error } if result.RowsAffected == 0 { exists, err := s.vipResourceExists(ctx, userID, resource, expireAt) if err != nil { return err } if !exists { if resource.Badge { return fmt.Errorf("vip badge backpack row not found after grant: %d", resource.ResourceID) } return fmt.Errorf("vip props backpack row not found after grant: %s:%d", resource.PropsType, resource.ResourceID) } } return nil } func vipGrantResourcesFromConfig(config model.VipLevelConfig) []vipGrantResource { return compactVIPGrantResources([]vipGrantResource{ {ResourceID: config.BadgeResourceID, Badge: true}, {ResourceID: config.AvatarFrameResourceID, PropsType: vipPropsTypeAvatarFrame}, {ResourceID: config.EntryEffectResourceID, PropsType: vipPropsTypeRide}, {ResourceID: config.ChatBubbleResourceID, PropsType: vipPropsTypeChatBubble}, {ResourceID: config.FloatPictureResourceID, PropsType: vipPropsTypeFloatPicture}, }) } func vipGrantResourcesFromState(state *model.UserVipState) []vipGrantResource { if state == nil { return nil } return compactVIPGrantResources([]vipGrantResource{ {ResourceID: state.BadgeResourceID, Badge: true}, {ResourceID: state.AvatarFrameResourceID, PropsType: vipPropsTypeAvatarFrame}, {ResourceID: state.EntryEffectResourceID, PropsType: vipPropsTypeRide}, {ResourceID: state.ChatBubbleResourceID, PropsType: vipPropsTypeChatBubble}, {ResourceID: state.FloatPictureResourceID, PropsType: vipPropsTypeFloatPicture}, }) } func compactVIPGrantResources(resources []vipGrantResource) []vipGrantResource { compacted := make([]vipGrantResource, 0, len(resources)) for _, resource := range resources { if resource.ResourceID > 0 { compacted = append(compacted, resource) } } return compacted } func vipGrantDaysUntil(now time.Time, expireAt time.Time) int { if !expireAt.After(now) { return 1 } duration := expireAt.Sub(now) days := int(duration / (24 * time.Hour)) if duration%(24*time.Hour) != 0 { days++ } if days < 1 { return 1 } return days } func mapWalletPurchaseError(err error) *AppError { message := err.Error() normalized := strings.ToLower(message) if strings.Contains(normalized, "insufficient_balance") || strings.Contains(normalized, "balance not made") || strings.Contains(normalized, `"errorcode":5000`) { return NewAppError(http.StatusNotAcceptable, "vip_insufficient_balance", "insufficient balance") } return NewAppError(http.StatusBadGateway, "vip_wallet_deduct_failed", message) } 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: walletOriginDesc, } } 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 }