536 lines
17 KiB
Go
536 lines
17 KiB
Go
package managercenter
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"chatapp3-golang/internal/integration"
|
|
"chatapp3-golang/internal/model"
|
|
"chatapp3-golang/internal/utils"
|
|
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
const (
|
|
vipStatusActive = "ACTIVE"
|
|
vipStatusSuccess = "SUCCESS"
|
|
vipOrderTypeGMGift = "GM_GIFT"
|
|
vipGiftEventPrefix = "MANAGER-VIP-GIFT-"
|
|
mysqlTimestampMaxYear = 2038
|
|
)
|
|
|
|
type gmVIPGiftRecordRow struct {
|
|
ID int64 `gorm:"column:id;primaryKey"`
|
|
UserID int64 `gorm:"column:user_id"`
|
|
SysOrigin string `gorm:"column:sys_origin"`
|
|
FromLevel int `gorm:"column:from_level"`
|
|
VIPLevel int `gorm:"column:vip_level"`
|
|
LevelCode string `gorm:"column:level_code"`
|
|
DisplayName string `gorm:"column:display_name"`
|
|
DurationDays int `gorm:"column:duration_days"`
|
|
PriceGold int64 `gorm:"column:price_gold"`
|
|
StartAt time.Time `gorm:"column:start_at"`
|
|
ExpireAt time.Time `gorm:"column:expire_at"`
|
|
OrderID int64 `gorm:"column:order_id"`
|
|
EventID string `gorm:"column:event_id"`
|
|
Status string `gorm:"column:status"`
|
|
ApplicantID int64 `gorm:"column:applicant_id"`
|
|
ApplicantName string `gorm:"column:applicant_name"`
|
|
Remarks string `gorm:"column:remarks"`
|
|
ExecutedTime time.Time `gorm:"column:executed_time"`
|
|
CreateTime time.Time `gorm:"column:create_time"`
|
|
UpdateTime time.Time `gorm:"column:update_time"`
|
|
CreateUser int64 `gorm:"column:create_user"`
|
|
UpdateUser int64 `gorm:"column:update_user"`
|
|
}
|
|
|
|
func (gmVIPGiftRecordRow) TableName() string {
|
|
return "gm_vip_gift_record"
|
|
}
|
|
|
|
type vipGiftResource struct {
|
|
resourceID int64
|
|
propsType string
|
|
badge bool
|
|
}
|
|
|
|
func (s *Service) listVIPProps(ctx context.Context, user AuthUser) (*PropsListResponse, error) {
|
|
sysOrigin := normalizeSysOrigin(user.SysOrigin)
|
|
var rows []model.VipLevelConfig
|
|
if err := s.db.WithContext(ctx).
|
|
Where("sys_origin = ? AND enabled = ?", sysOrigin, true).
|
|
Where("level BETWEEN ? AND ?", 1, maxGiftableVIPLevel).
|
|
Order("level ASC").
|
|
Find(&rows).Error; err != nil {
|
|
return nil, serverError("vip_config_query_failed", err.Error())
|
|
}
|
|
|
|
records := make([]PropsView, 0, len(rows))
|
|
for _, row := range rows {
|
|
records = append(records, vipPropsViewFromConfig(row))
|
|
}
|
|
return &PropsListResponse{
|
|
Type: propsTypeNobleVIP,
|
|
Records: records,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) sendVIPGift(ctx context.Context, manager AuthUser, target userBaseInfoRow, configID int64) (*SendPropsResponse, error) {
|
|
sysOrigin := normalizeSysOrigin(target.OriginSys)
|
|
config, found, err := s.loadVIPConfigByID(ctx, sysOrigin, configID)
|
|
if err != nil || !found {
|
|
return nil, err
|
|
}
|
|
if !config.Enabled {
|
|
return nil, badRequest("vip_level_disabled", "vip level is disabled")
|
|
}
|
|
if config.Level < 1 || config.Level > maxGiftableVIPLevel {
|
|
return nil, badRequest("vip_not_giftable", "vip level is invalid")
|
|
}
|
|
if config.DurationDays <= 0 {
|
|
config.DurationDays = defaultVIPGiftDays
|
|
}
|
|
|
|
now := time.Now()
|
|
orderID, err := utils.NextID()
|
|
if err != nil {
|
|
return nil, serverError("id_generate_failed", err.Error())
|
|
}
|
|
recordID, err := utils.NextID()
|
|
if err != nil {
|
|
return nil, serverError("id_generate_failed", err.Error())
|
|
}
|
|
stateID, err := utils.NextID()
|
|
if err != nil {
|
|
return nil, serverError("id_generate_failed", err.Error())
|
|
}
|
|
eventID := fmt.Sprintf("%s%d", vipGiftEventPrefix, orderID)
|
|
applicantName := s.managerApplicantName(ctx, manager.UserID)
|
|
|
|
tx := s.db.WithContext(ctx).Begin()
|
|
if tx.Error != nil {
|
|
return nil, serverError("vip_gift_failed", tx.Error.Error())
|
|
}
|
|
committed := false
|
|
defer func() {
|
|
if !committed {
|
|
_ = tx.Rollback().Error
|
|
}
|
|
}()
|
|
|
|
state, err := s.loadLockedVIPState(tx, sysOrigin, target.ID)
|
|
if err != nil {
|
|
return nil, serverError("vip_state_query_failed", err.Error())
|
|
}
|
|
currentActive := isActiveVIPState(state, now)
|
|
fromLevel := 0
|
|
if currentActive {
|
|
fromLevel = state.Level
|
|
}
|
|
if currentActive && fromLevel > config.Level {
|
|
return nil, badRequest("vip_downgrade_not_allowed", "vip downgrade is not allowed")
|
|
}
|
|
|
|
startAt := now
|
|
if currentActive && !state.StartAt.IsZero() {
|
|
startAt = state.StartAt
|
|
}
|
|
expireAt := resolveVIPGiftExpireAt(state, config, currentActive, now)
|
|
|
|
order := model.VipOrderRecord{
|
|
ID: orderID,
|
|
EventID: eventID,
|
|
SysOrigin: sysOrigin,
|
|
UserID: target.ID,
|
|
OrderType: vipOrderTypeGMGift,
|
|
FromLevel: fromLevel,
|
|
ToLevel: config.Level,
|
|
DurationDays: config.DurationDays,
|
|
OriginalPriceGold: config.PriceGold,
|
|
PaidGold: 0,
|
|
Status: vipStatusSuccess,
|
|
StartAt: startAt,
|
|
ExpireAt: expireAt,
|
|
ErrorMessage: "",
|
|
CreateTime: now,
|
|
UpdateTime: now,
|
|
}
|
|
if err := tx.Create(&order).Error; err != nil {
|
|
return nil, serverError("vip_order_create_failed", err.Error())
|
|
}
|
|
|
|
if state == nil {
|
|
state = &model.UserVipState{
|
|
ID: stateID,
|
|
SysOrigin: sysOrigin,
|
|
UserID: target.ID,
|
|
CreateTime: now,
|
|
}
|
|
}
|
|
previousResources := vipGiftResourcesFromState(state)
|
|
applyVIPGiftConfigToState(state, config, startAt, expireAt, now)
|
|
if err := tx.Save(state).Error; err != nil {
|
|
return nil, serverError("vip_state_save_failed", err.Error())
|
|
}
|
|
|
|
record := gmVIPGiftRecordRow{
|
|
ID: recordID,
|
|
UserID: target.ID,
|
|
SysOrigin: sysOrigin,
|
|
FromLevel: fromLevel,
|
|
VIPLevel: config.Level,
|
|
LevelCode: vipLevelCode(config),
|
|
DisplayName: vipDisplayName(config),
|
|
DurationDays: config.DurationDays,
|
|
PriceGold: config.PriceGold,
|
|
StartAt: startAt,
|
|
ExpireAt: expireAt,
|
|
OrderID: orderID,
|
|
EventID: eventID,
|
|
Status: vipStatusSuccess,
|
|
ApplicantID: manager.UserID,
|
|
ApplicantName: applicantName,
|
|
Remarks: fmt.Sprintf("manager center gift, manager=%d", manager.UserID),
|
|
ExecutedTime: now,
|
|
CreateTime: now,
|
|
UpdateTime: now,
|
|
CreateUser: manager.UserID,
|
|
UpdateUser: manager.UserID,
|
|
}
|
|
if err := tx.Create(&record).Error; err != nil {
|
|
return nil, serverError("vip_gift_record_create_failed", err.Error())
|
|
}
|
|
|
|
if err := tx.Commit().Error; err != nil {
|
|
return nil, serverError("vip_gift_failed", err.Error())
|
|
}
|
|
committed = true
|
|
|
|
if err := s.grantVIPGiftResources(ctx, sysOrigin, target.ID, manager.UserID, config, expireAt, previousResources, now); err != nil {
|
|
return nil, serverError("vip_resource_grant_failed", err.Error())
|
|
}
|
|
|
|
return &SendPropsResponse{
|
|
Success: true,
|
|
AcceptUserID: ID(target.ID),
|
|
PropsID: ID(config.ID),
|
|
Type: propsTypeNobleVIP,
|
|
Days: config.DurationDays,
|
|
VIPLevel: config.Level,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) loadVIPConfigByID(ctx context.Context, sysOrigin string, configID int64) (model.VipLevelConfig, bool, error) {
|
|
var row model.VipLevelConfig
|
|
err := s.db.WithContext(ctx).
|
|
Where("id = ? AND sys_origin = ?", configID, sysOrigin).
|
|
First(&row).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return model.VipLevelConfig{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return model.VipLevelConfig{}, false, serverError("vip_config_query_failed", err.Error())
|
|
}
|
|
return row, true, nil
|
|
}
|
|
|
|
func (s *Service) loadLockedVIPState(tx *gorm.DB, sysOrigin string, userID int64) (*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) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &row, nil
|
|
}
|
|
|
|
func isActiveVIPState(state *model.UserVipState, now time.Time) bool {
|
|
return state != nil && state.Status == vipStatusActive && state.Level > 0 && state.ExpireAt.After(now)
|
|
}
|
|
|
|
func resolveVIPGiftExpireAt(state *model.UserVipState, config model.VipLevelConfig, currentActive bool, now time.Time) time.Time {
|
|
durationDays := config.DurationDays
|
|
if durationDays <= 0 {
|
|
durationDays = defaultVIPGiftDays
|
|
}
|
|
plusDuration := now.AddDate(0, 0, durationDays)
|
|
if !currentActive {
|
|
return plusDuration
|
|
}
|
|
if state.Level == config.Level {
|
|
return maxTime(state.ExpireAt, now).AddDate(0, 0, durationDays)
|
|
}
|
|
return maxTime(state.ExpireAt, plusDuration)
|
|
}
|
|
|
|
func applyVIPGiftConfigToState(state *model.UserVipState, config model.VipLevelConfig, startAt time.Time, expireAt time.Time, now time.Time) {
|
|
state.Level = config.Level
|
|
state.LevelCode = vipLevelCode(config)
|
|
state.DisplayName = vipDisplayName(config)
|
|
state.Status = vipStatusActive
|
|
state.StartAt = startAt
|
|
state.ExpireAt = expireAt
|
|
state.BadgeResourceID = config.BadgeResourceID
|
|
state.BadgeName = config.BadgeName
|
|
state.BadgeURL = config.BadgeURL
|
|
state.ShortBadgeResourceID = config.ShortBadgeResourceID
|
|
state.ShortBadgeName = config.ShortBadgeName
|
|
state.ShortBadgeURL = config.ShortBadgeURL
|
|
state.AvatarFrameResourceID = config.AvatarFrameResourceID
|
|
state.AvatarFrameName = config.AvatarFrameName
|
|
state.AvatarFrameURL = config.AvatarFrameURL
|
|
state.EntryEffectResourceID = config.EntryEffectResourceID
|
|
state.EntryEffectName = config.EntryEffectName
|
|
state.EntryEffectURL = config.EntryEffectURL
|
|
state.ChatBubbleResourceID = config.ChatBubbleResourceID
|
|
state.ChatBubbleName = config.ChatBubbleName
|
|
state.ChatBubbleURL = config.ChatBubbleURL
|
|
state.FloatPictureResourceID = config.FloatPictureResourceID
|
|
state.FloatPictureName = config.FloatPictureName
|
|
state.FloatPictureURL = config.FloatPictureURL
|
|
state.UpdateTime = now
|
|
if state.CreateTime.IsZero() {
|
|
state.CreateTime = now
|
|
}
|
|
}
|
|
|
|
func (s *Service) grantVIPGiftResources(ctx context.Context, sysOrigin string, userID int64, managerID int64, config model.VipLevelConfig, expireAt time.Time, additionalOldResources []vipGiftResource, now time.Time) error {
|
|
resources := vipGiftResourcesFromConfig(config)
|
|
days := config.DurationDays
|
|
if days <= 0 {
|
|
days = defaultVIPGiftDays
|
|
}
|
|
for _, resource := range resources {
|
|
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)
|
|
}
|
|
} else {
|
|
if err := s.java.GivePropsBackpack(ctx, integration.GivePropsBackpackRequest{
|
|
AcceptUserID: userID,
|
|
PropsID: resource.resourceID,
|
|
Type: resource.propsType,
|
|
Origin: vipGiftOrigin,
|
|
OriginDesc: vipGiftOriginDesc,
|
|
Days: days,
|
|
UseProps: true,
|
|
AllowGive: false,
|
|
OpUser: managerID,
|
|
}); err != nil {
|
|
return fmt.Errorf("grant vip resource %s:%d: %w", resource.propsType, resource.resourceID, err)
|
|
}
|
|
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.alignVIPGiftResourceExpireAt(ctx, userID, resource, expireAt, now); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
allResources, err := s.vipGiftResourcesFromSysOrigin(ctx, sysOrigin)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
allResources = append(allResources, additionalOldResources...)
|
|
if err := s.cleanupSupersededVIPGiftResources(ctx, userID, resources, allResources, 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) alignVIPGiftResourceExpireAt(ctx context.Context, userID int64, resource vipGiftResource, expireAt time.Time, now time.Time) error {
|
|
expireAt = capMySQLTimestamp(expireAt)
|
|
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 {
|
|
return fmt.Errorf("vip resource backpack row not found after grant: %d", resource.resourceID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func vipGiftResourcesFromConfig(config model.VipLevelConfig) []vipGiftResource {
|
|
resources := []vipGiftResource{
|
|
{resourceID: config.BadgeResourceID, propsType: propsTypeBadge, badge: true},
|
|
{resourceID: config.ShortBadgeResourceID, propsType: propsTypeBadge, badge: true},
|
|
{resourceID: config.AvatarFrameResourceID, propsType: propsTypeAvatarFrame},
|
|
{resourceID: config.EntryEffectResourceID, propsType: propsTypeRide},
|
|
{resourceID: config.ChatBubbleResourceID, propsType: propsTypeChatBubble},
|
|
{resourceID: config.FloatPictureResourceID, propsType: propsTypeFloatPicture},
|
|
{resourceID: config.BackgroundCardResourceID, propsType: propsTypeDataCard},
|
|
}
|
|
out := make([]vipGiftResource, 0, len(resources))
|
|
for _, resource := range resources {
|
|
if resource.resourceID > 0 {
|
|
out = append(out, resource)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func vipGiftResourcesFromState(state *model.UserVipState) []vipGiftResource {
|
|
if state == nil {
|
|
return nil
|
|
}
|
|
return compactVIPGiftResources([]vipGiftResource{
|
|
{resourceID: state.BadgeResourceID, propsType: propsTypeBadge, badge: true},
|
|
{resourceID: state.ShortBadgeResourceID, propsType: propsTypeBadge, badge: true},
|
|
{resourceID: state.AvatarFrameResourceID, propsType: propsTypeAvatarFrame},
|
|
{resourceID: state.EntryEffectResourceID, propsType: propsTypeRide},
|
|
{resourceID: state.ChatBubbleResourceID, propsType: propsTypeChatBubble},
|
|
{resourceID: state.FloatPictureResourceID, propsType: propsTypeFloatPicture},
|
|
{resourceID: state.BackgroundCardResourceID, propsType: propsTypeDataCard},
|
|
})
|
|
}
|
|
|
|
func compactVIPGiftResources(resources []vipGiftResource) []vipGiftResource {
|
|
out := make([]vipGiftResource, 0, len(resources))
|
|
for _, resource := range resources {
|
|
if resource.resourceID > 0 {
|
|
out = append(out, resource)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Service) vipGiftResourcesFromSysOrigin(ctx context.Context, sysOrigin string) ([]vipGiftResource, error) {
|
|
var configs []model.VipLevelConfig
|
|
if err := s.db.WithContext(ctx).
|
|
Where("sys_origin = ?", normalizeSysOrigin(sysOrigin)).
|
|
Find(&configs).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
resources := make([]vipGiftResource, 0, len(configs)*7)
|
|
for _, config := range configs {
|
|
resources = append(resources, vipGiftResourcesFromConfig(config)...)
|
|
}
|
|
return resources, nil
|
|
}
|
|
|
|
func (s *Service) cleanupSupersededVIPGiftResources(ctx context.Context, userID int64, targetResources []vipGiftResource, allVIPResources []vipGiftResource, now time.Time) error {
|
|
targets := make(map[string]struct{}, len(targetResources))
|
|
for _, resource := range targetResources {
|
|
targets[vipGiftResourceKey(resource)] = struct{}{}
|
|
}
|
|
|
|
badgeIDs := make([]int64, 0)
|
|
propsIDsByType := map[string][]int64{}
|
|
seen := make(map[string]struct{}, len(allVIPResources))
|
|
for _, resource := range allVIPResources {
|
|
if resource.resourceID <= 0 {
|
|
continue
|
|
}
|
|
key := vipGiftResourceKey(resource)
|
|
if _, exists := targets[key]; exists {
|
|
continue
|
|
}
|
|
if _, exists := seen[key]; exists {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
if resource.badge {
|
|
badgeIDs = append(badgeIDs, resource.resourceID)
|
|
continue
|
|
}
|
|
if strings.TrimSpace(resource.propsType) != "" {
|
|
propsIDsByType[resource.propsType] = append(propsIDsByType[resource.propsType], resource.resourceID)
|
|
}
|
|
}
|
|
|
|
if len(badgeIDs) > 0 {
|
|
if err := 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 IN ? AND expire_time > ?",
|
|
"TEMPORARY", now, false, now, userID, badgeIDs, now,
|
|
).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for propsType, propsIDs := range propsIDsByType {
|
|
if len(propsIDs) == 0 {
|
|
continue
|
|
}
|
|
if err := 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 IN ? AND type = ? AND expire_time > ?",
|
|
now, false, false, now, userID, propsIDs, propsType, now,
|
|
).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func vipGiftResourceKey(resource vipGiftResource) string {
|
|
if resource.badge {
|
|
return fmt.Sprintf("BADGE:%d", resource.resourceID)
|
|
}
|
|
return fmt.Sprintf("%s:%d", resource.propsType, resource.resourceID)
|
|
}
|
|
|
|
func (s *Service) managerApplicantName(ctx context.Context, managerID int64) string {
|
|
row, err := s.loadUserRowByID(ctx, managerID)
|
|
if err != nil {
|
|
return fmt.Sprintf("%d", managerID)
|
|
}
|
|
if strings.TrimSpace(row.UserNickname) != "" {
|
|
return row.UserNickname
|
|
}
|
|
if strings.TrimSpace(row.Account) != "" {
|
|
return row.Account
|
|
}
|
|
return fmt.Sprintf("%d", managerID)
|
|
}
|
|
|
|
func vipLevelCode(config model.VipLevelConfig) string {
|
|
code := strings.TrimSpace(config.LevelCode)
|
|
if code == "" {
|
|
return fmt.Sprintf("VIP%d", config.Level)
|
|
}
|
|
return code
|
|
}
|
|
|
|
func vipDisplayName(config model.VipLevelConfig) string {
|
|
name := strings.TrimSpace(config.DisplayName)
|
|
if name == "" {
|
|
return fmt.Sprintf("VIP %d", config.Level)
|
|
}
|
|
return name
|
|
}
|
|
|
|
func maxTime(a time.Time, b time.Time) time.Time {
|
|
if a.After(b) {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func capMySQLTimestamp(value time.Time) time.Time {
|
|
maxValue := time.Date(mysqlTimestampMaxYear, time.January, 19, 0, 0, 0, 0, time.Local)
|
|
if value.After(maxValue) {
|
|
return maxValue
|
|
}
|
|
return value
|
|
}
|