2026-05-11 18:47:16 +08:00

426 lines
13 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).
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 > 5 {
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,
}
}
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, target.ID, manager.UserID, config, expireAt, 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.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, userID int64, managerID int64, config model.VipLevelConfig, expireAt time.Time, now time.Time) error {
resources := vipGiftResourcesFromConfig(config)
if len(resources) == 0 {
return nil
}
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
}
}
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.AvatarFrameResourceID, propsType: propsTypeAvatarFrame},
{resourceID: config.EntryEffectResourceID, propsType: propsTypeRide},
{resourceID: config.ChatBubbleResourceID, propsType: propsTypeChatBubble},
{resourceID: config.FloatPictureResourceID, propsType: propsTypeFloatPicture},
}
out := make([]vipGiftResource, 0, len(resources))
for _, resource := range resources {
if resource.resourceID > 0 {
out = append(out, resource)
}
}
return out
}
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
}