760 lines
23 KiB
Go
760 lines
23 KiB
Go
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
|
|
}
|