426 lines
13 KiB
Go
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
|
|
}
|