2026-05-17 00:34:56 +08:00

735 lines
21 KiB
Go

package managercenter
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model"
"gorm.io/gorm"
)
type teamManagerInfoRow struct {
ID int64 `gorm:"column:id;primaryKey"`
UserID int64 `gorm:"column:user_id"`
Origin string `gorm:"column:sys_origin"`
}
func (teamManagerInfoRow) TableName() string {
return "team_manager_base_info"
}
type userBaseInfoRow struct {
ID int64 `gorm:"column:id;primaryKey"`
Account string `gorm:"column:account"`
UserAvatar string `gorm:"column:user_avatar"`
UserNickname string `gorm:"column:user_nickname"`
CountryCode string `gorm:"column:country_code"`
CountryName string `gorm:"column:country_name"`
OriginSys string `gorm:"column:origin_sys"`
AccountStatus string `gorm:"column:account_status"`
IsDel bool `gorm:"column:is_del"`
}
func (userBaseInfoRow) TableName() string {
return "user_base_info"
}
type propsSourceRecordRow struct {
ID int64 `gorm:"column:id;primaryKey"`
Type string `gorm:"column:type"`
Code string `gorm:"column:code"`
Name string `gorm:"column:name"`
Cover string `gorm:"column:cover"`
SourceURL string `gorm:"column:source_url"`
Expand string `gorm:"column:expand"`
AdminFree bool `gorm:"column:admin_free"`
IsDel bool `gorm:"column:is_del"`
}
func (propsSourceRecordRow) TableName() string {
return "props_source_record"
}
type propsNobleVIPAbilityRow struct {
ID int64 `gorm:"column:id;primaryKey"`
VIPLevel int `gorm:"column:vip_level"`
AvatarFrameID int64 `gorm:"column:avatar_frame_id"`
CarID int64 `gorm:"column:car_id"`
ChatBubbleID int64 `gorm:"column:chat_bubble_id"`
DataCardID int64 `gorm:"column:data_card_id"`
}
func (propsNobleVIPAbilityRow) TableName() string {
return "props_noble_vip_ability"
}
func (s *Service) GetProfile(ctx context.Context, user AuthUser) (*ProfileResponse, error) {
if err := s.ensureManager(ctx, user); err != nil {
return nil, err
}
view, err := s.loadUserViewByID(ctx, user.UserID)
if err != nil {
return nil, err
}
return &ProfileResponse{Manager: view}, nil
}
func (s *Service) SearchUser(ctx context.Context, user AuthUser, account string) (*UserSearchResponse, error) {
if err := s.ensureManager(ctx, user); err != nil {
return nil, err
}
account = strings.TrimSpace(account)
if account == "" {
return nil, badRequest("account_required", "account is required")
}
view, err := s.findUserView(ctx, account)
if err != nil {
return nil, err
}
return &UserSearchResponse{User: view}, nil
}
func (s *Service) ListProps(ctx context.Context, user AuthUser, propsType string) (*PropsListResponse, error) {
if err := s.ensureManager(ctx, user); err != nil {
return nil, err
}
if s.db == nil {
return nil, serviceUnavailable("database_unavailable", "database is unavailable")
}
propsType = normalizePropsType(propsType)
if propsType == propsTypeNobleVIP {
return s.listVIPProps(ctx, user)
}
query := s.db.WithContext(ctx).
Table("props_source_record").
Select("id, type, code, name, cover, source_url, expand, admin_free, is_del").
Where("is_del = ? AND admin_free = ?", false, true)
if propsType != "" {
query = query.Where("type = ?", propsType)
}
var rows []propsSourceRecordRow
if err := query.Order("type ASC, id ASC").Find(&rows).Error; err != nil {
return nil, serverError("props_query_failed", err.Error())
}
vipLevels, err := s.loadVIPLevels(ctx, rows)
if err != nil {
return nil, err
}
records := make([]PropsView, 0, len(rows))
for _, row := range rows {
view, ok := propsRowToView(row, vipLevels)
if !ok {
continue
}
records = append(records, view)
}
return &PropsListResponse{
Type: propsType,
Records: records,
}, nil
}
func (s *Service) SendProps(ctx context.Context, user AuthUser, req SendPropsRequest) (*SendPropsResponse, error) {
if err := s.ensureManager(ctx, user); err != nil {
return nil, err
}
if req.AcceptUserID.Int64() <= 0 {
return nil, badRequest("accept_user_required", "acceptUserId is required")
}
if req.PropsID.Int64() <= 0 {
return nil, badRequest("props_required", "propsId is required")
}
if s.java == nil {
return nil, serviceUnavailable("java_gateway_unavailable", "java gateway is unavailable")
}
target, err := s.loadUserRowByID(ctx, req.AcceptUserID.Int64())
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, notFound("user_not_found", "user not found")
}
return nil, serverError("user_query_failed", err.Error())
}
prop, err := s.loadGiftableProps(ctx, req.PropsID.Int64())
if err != nil {
if isAppErrorCode(err, "prop_not_giftable") {
if resp, vipErr := s.sendVIPGift(ctx, user, target, req.PropsID.Int64()); vipErr != nil {
return nil, vipErr
} else if resp != nil {
return resp, nil
}
}
return nil, err
}
days := propsGiftDays(prop.Type)
if prop.Type == propsTypeBadge {
if err := s.java.ActivateTemporaryBadge(ctx, req.AcceptUserID.Int64(), prop.ID, days); err != nil {
return nil, serverError("send_badge_failed", err.Error())
}
return &SendPropsResponse{
Success: true,
AcceptUserID: req.AcceptUserID,
PropsID: req.PropsID,
Type: prop.Type,
Days: days,
}, nil
}
grants := []propsGrant{{
propsID: prop.ID,
typ: prop.Type,
days: days,
}}
if prop.Type == propsTypeNobleVIP {
if _, err := s.loadGiftableNobleVIPAbility(ctx, prop.ID); err != nil {
return nil, err
}
ability, err := s.java.GetNobleVIPAbility(ctx, prop.ID)
if err != nil {
return nil, serverError("vip_ability_query_failed", err.Error())
}
grants = append(grants, nobleVIPAccessoryGrants(ability)...)
}
for _, grant := range grants {
if err := s.java.GivePropsBackpack(ctx, integration.GivePropsBackpackRequest{
AcceptUserID: req.AcceptUserID.Int64(),
PropsID: grant.propsID,
Type: grant.typ,
Origin: adminFreeOrigin,
OriginDesc: adminFreeOriginDesc,
Days: grant.days,
UseProps: true,
AllowGive: false,
OpUser: user.UserID,
}); err != nil {
return nil, serverError("send_props_failed", err.Error())
}
}
if prop.Type == propsTypeNobleVIP {
if err := s.switchMaxNobleVIP(ctx, req.AcceptUserID.Int64()); err != nil {
return nil, err
}
}
return &SendPropsResponse{
Success: true,
AcceptUserID: req.AcceptUserID,
PropsID: req.PropsID,
Type: prop.Type,
Days: days,
}, nil
}
func (s *Service) BanUser(ctx context.Context, user AuthUser, req BanUserRequest) (*BanUserResponse, error) {
if err := s.ensureManager(ctx, user); err != nil {
return nil, err
}
if req.UserID.Int64() <= 0 {
return nil, badRequest("user_required", "userId is required")
}
if s.java == nil {
return nil, serviceUnavailable("java_gateway_unavailable", "java gateway is unavailable")
}
target, err := s.loadUserRowByID(ctx, req.UserID.Int64())
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, notFound("user_not_found", "user not found")
}
return nil, serverError("user_query_failed", err.Error())
}
if isUnbannableAccountStatus(target.AccountStatus) {
return nil, badRequest("user_already_banned", "user is already banned")
}
hasRecharge, err := s.userHasRecharge(ctx, target.ID)
if err != nil {
return nil, err
}
if hasRecharge {
return nil, badRequest("user_recharged", "only users without recharge can be banned")
}
description := buildBanDescription(user.UserID, req.Reason)
if err := s.java.PunishAccount(ctx, integration.PunishAccountRequest{
BeOptUserID: target.ID,
OptUserID: user.UserID,
Status: accountStatusArchive,
Description: description,
}); err != nil {
return nil, serverError("ban_user_failed", err.Error())
}
return &BanUserResponse{
Success: true,
UserID: req.UserID,
AccountStatus: accountStatusArchive,
}, nil
}
func (s *Service) UnbanUser(ctx context.Context, user AuthUser, req UnbanUserRequest) (*UnbanUserResponse, error) {
if err := s.ensureManager(ctx, user); err != nil {
return nil, err
}
if req.UserID.Int64() <= 0 {
return nil, badRequest("user_required", "userId is required")
}
if s.java == nil {
return nil, serviceUnavailable("java_gateway_unavailable", "java gateway is unavailable")
}
target, err := s.loadUserRowByID(ctx, req.UserID.Int64())
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, notFound("user_not_found", "user not found")
}
return nil, serverError("user_query_failed", err.Error())
}
if !isUnbannableAccountStatus(target.AccountStatus) {
return nil, badRequest("user_not_banned", "only banned or frozen users can be unbanned")
}
description := buildUnbanDescription(user.UserID, req.Reason)
if err := s.java.UnblockAccount(ctx, integration.UnblockAccountRequest{
BeOptUserID: target.ID,
OptUserID: user.UserID,
Description: description,
}); err != nil {
return nil, serverError("unban_user_failed", err.Error())
}
return &UnbanUserResponse{
Success: true,
UserID: req.UserID,
AccountStatus: accountStatusNormal,
}, nil
}
func (s *Service) ensureManager(ctx context.Context, user AuthUser) error {
if user.UserID <= 0 {
return unauthorized("invalid_user", "authenticated user is required")
}
if s.db == nil {
return serviceUnavailable("database_unavailable", "database is unavailable")
}
var count int64
err := s.db.WithContext(ctx).
Table("team_manager_base_info").
Where("user_id = ?", user.UserID).
Count(&count).Error
if err != nil {
return serverError("manager_query_failed", err.Error())
}
if count == 0 {
return forbidden("not_manager", "user is not manager")
}
return nil
}
func (s *Service) findUserView(ctx context.Context, account string) (UserView, error) {
if userID, err := strconv.ParseInt(account, 10, 64); err == nil && userID > 0 {
if row, err := s.loadUserRowByID(ctx, userID); err == nil {
return s.userRowToView(ctx, row)
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return UserView{}, serverError("user_query_failed", err.Error())
}
}
if s.java != nil {
profile, err := s.java.GetUserProfileByAccountOne(ctx, account)
if err == nil && int64(profile.ID) > 0 {
if row, rowErr := s.loadUserRowByID(ctx, int64(profile.ID)); rowErr == nil {
view, viewErr := s.userRowToView(ctx, row)
if viewErr != nil {
return UserView{}, viewErr
}
overlayJavaProfile(&view, profile)
return view, nil
} else if !errors.Is(rowErr, gorm.ErrRecordNotFound) {
return UserView{}, serverError("user_query_failed", rowErr.Error())
}
return s.javaProfileToView(ctx, profile)
}
}
row, err := s.loadUserRowByAccount(ctx, account)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return UserView{}, notFound("user_not_found", "user not found")
}
return UserView{}, serverError("user_query_failed", err.Error())
}
return s.userRowToView(ctx, row)
}
func (s *Service) loadUserViewByID(ctx context.Context, userID int64) (UserView, error) {
row, err := s.loadUserRowByID(ctx, userID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return UserView{}, notFound("user_not_found", "user not found")
}
return UserView{}, serverError("user_query_failed", err.Error())
}
return s.userRowToView(ctx, row)
}
func (s *Service) loadUserRowByID(ctx context.Context, userID int64) (userBaseInfoRow, error) {
var row userBaseInfoRow
err := s.db.WithContext(ctx).
Table("user_base_info").
Where("id = ? AND is_del = ?", userID, false).
First(&row).Error
return row, err
}
func (s *Service) loadUserRowByAccount(ctx context.Context, account string) (userBaseInfoRow, error) {
var row userBaseInfoRow
err := s.db.WithContext(ctx).
Table("user_base_info").
Where("account = ? AND is_del = ?", account, false).
First(&row).Error
return row, err
}
func (s *Service) userRowToView(ctx context.Context, row userBaseInfoRow) (UserView, error) {
hasRecharge, canBan := s.userRechargeEligibility(ctx, row.ID)
canBan = canBan && !isUnbannableAccountStatus(row.AccountStatus)
return UserView{
UserID: ID(row.ID),
Account: row.Account,
UserAvatar: row.UserAvatar,
UserNickname: row.UserNickname,
CountryCode: row.CountryCode,
CountryName: row.CountryName,
OriginSys: row.OriginSys,
AccountStatus: row.AccountStatus,
HasRecharge: hasRecharge,
CanBan: canBan,
}, nil
}
func (s *Service) javaProfileToView(ctx context.Context, profile integration.UserProfile) (UserView, error) {
hasRecharge, canBan := s.userRechargeEligibility(ctx, int64(profile.ID))
canBan = canBan && !isUnbannableAccountStatus(profile.AccountStatus)
return UserView{
UserID: ID(profile.ID),
Account: profile.Account,
UserAvatar: profile.UserAvatar,
UserNickname: profile.UserNickname,
CountryCode: profile.CountryCode,
CountryName: profile.CountryName,
OriginSys: profile.OriginSys,
AccountStatus: profile.AccountStatus,
HasRecharge: hasRecharge,
CanBan: canBan,
}, nil
}
func overlayJavaProfile(view *UserView, profile integration.UserProfile) {
if profile.Account != "" {
view.Account = profile.Account
}
if profile.UserAvatar != "" {
view.UserAvatar = profile.UserAvatar
}
if profile.UserNickname != "" {
view.UserNickname = profile.UserNickname
}
if profile.CountryCode != "" {
view.CountryCode = profile.CountryCode
}
if profile.CountryName != "" {
view.CountryName = profile.CountryName
}
if profile.OriginSys != "" {
view.OriginSys = profile.OriginSys
}
if profile.AccountStatus != "" {
view.AccountStatus = profile.AccountStatus
if isUnbannableAccountStatus(profile.AccountStatus) {
view.CanBan = false
}
}
}
func (s *Service) userHasRecharge(ctx context.Context, userID int64) (bool, error) {
if userID <= 0 {
return false, nil
}
if s.java == nil {
return false, nil
}
recharges, err := s.java.MapUserTotalRecharge(ctx, []int64{userID})
if err != nil {
return false, serverError("recharge_query_failed", err.Error())
}
for _, item := range recharges[userID] {
raw := strings.TrimSpace(string(item.Amount))
if raw == "" {
continue
}
amount, err := strconv.ParseFloat(raw, 64)
if err != nil {
return false, serverError("recharge_query_failed", err.Error())
}
if amount > 0 {
return true, nil
}
}
return false, nil
}
func (s *Service) userRechargeEligibility(ctx context.Context, userID int64) (bool, bool) {
hasRecharge, err := s.userHasRecharge(ctx, userID)
if err != nil {
return false, false
}
return hasRecharge, !hasRecharge
}
func (s *Service) loadGiftableProps(ctx context.Context, propsID int64) (propsSourceRecordRow, error) {
var row propsSourceRecordRow
err := s.db.WithContext(ctx).
Table("props_source_record").
Select("id, type, code, name, cover, source_url, expand, admin_free, is_del").
Where("id = ? AND is_del = ? AND admin_free = ?", propsID, false, true).
First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return propsSourceRecordRow{}, badRequest("prop_not_giftable", "props is not admin-free giftable")
}
if err != nil {
return propsSourceRecordRow{}, serverError("props_query_failed", err.Error())
}
return row, nil
}
func (s *Service) loadGiftableNobleVIPAbility(ctx context.Context, propsID int64) (propsNobleVIPAbilityRow, error) {
var row propsNobleVIPAbilityRow
err := s.db.WithContext(ctx).
Table("props_noble_vip_ability").
Where("id = ?", propsID).
First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return propsNobleVIPAbilityRow{}, badRequest("vip_not_giftable", "noble VIP ability is not giftable")
}
if err != nil {
return propsNobleVIPAbilityRow{}, serverError("vip_ability_query_failed", err.Error())
}
if row.VIPLevel > maxGiftableVIPLevel {
return propsNobleVIPAbilityRow{}, badRequest("vip_not_giftable", "only noble VIP level 1-3 can be gifted")
}
return row, nil
}
func (s *Service) loadVIPLevels(ctx context.Context, rows []propsSourceRecordRow) (map[int64]int, error) {
ids := make([]int64, 0)
for _, row := range rows {
if row.Type == propsTypeNobleVIP {
ids = append(ids, row.ID)
}
}
if len(ids) == 0 {
return nil, nil
}
var abilities []propsNobleVIPAbilityRow
if err := s.db.WithContext(ctx).
Table("props_noble_vip_ability").
Select("id, vip_level").
Where("id IN ?", ids).
Find(&abilities).Error; err != nil {
return nil, serverError("vip_ability_query_failed", err.Error())
}
levels := make(map[int64]int, len(abilities))
for _, ability := range abilities {
levels[ability.ID] = ability.VIPLevel
}
return levels, nil
}
type propsGrant struct {
propsID int64
typ string
days int
}
func nobleVIPAccessoryGrants(ability integration.PropsNobleVIPAbility) []propsGrant {
grants := make([]propsGrant, 0, 4)
appendIfPositive := func(propsID int64, typ string) {
if propsID <= 0 {
return
}
grants = append(grants, propsGrant{propsID: propsID, typ: typ, days: defaultVIPGiftDays})
}
appendIfPositive(int64(ability.CarID), propsTypeRide)
appendIfPositive(int64(ability.AvatarFrameID), propsTypeAvatarFrame)
appendIfPositive(int64(ability.ChatBubbleID), propsTypeChatBubble)
appendIfPositive(int64(ability.DataCardID), propsTypeDataCard)
return grants
}
func (s *Service) switchMaxNobleVIP(ctx context.Context, userID int64) error {
ability, err := s.java.GetUserMaxNobleVIPAbility(ctx, userID)
if err != nil {
return serverError("vip_ability_query_failed", err.Error())
}
ids := []int64{
int64(ability.ID),
int64(ability.AvatarFrameID),
int64(ability.DataCardID),
int64(ability.CarID),
int64(ability.ChatBubbleID),
}
for _, propsID := range compactPositiveIDs(ids) {
if err := s.java.SwitchUseProps(ctx, userID, propsID); err != nil {
return serverError("switch_props_failed", err.Error())
}
}
if err := s.java.RemoveUserProfileCacheAll(ctx, userID); err != nil {
return serverError("remove_profile_cache_failed", err.Error())
}
return nil
}
func compactPositiveIDs(ids []int64) []int64 {
seen := make(map[int64]struct{}, len(ids))
out := make([]int64, 0, len(ids))
for _, id := range ids {
if id <= 0 {
continue
}
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
func propsRowToView(row propsSourceRecordRow, vipLevels map[int64]int) (PropsView, bool) {
level := 0
if row.Type == propsTypeNobleVIP {
var exists bool
level, exists = vipLevels[row.ID]
if !exists || level > 3 {
return PropsView{}, false
}
}
return PropsView{
ID: ID(row.ID),
Type: row.Type,
Code: row.Code,
Name: row.Name,
Cover: row.Cover,
SourceURL: row.SourceURL,
Expand: row.Expand,
Days: propsGiftDays(row.Type),
VIPLevel: level,
}, true
}
func propsGiftDays(propsType string) int {
if propsType == propsTypeNobleVIP {
return defaultVIPGiftDays
}
return defaultGiftDays
}
func normalizePropsType(propsType string) string {
return strings.ToUpper(strings.TrimSpace(propsType))
}
func buildBanDescription(managerID int64, reason string) string {
reason = strings.TrimSpace(reason)
if len([]rune(reason)) > 200 {
runes := []rune(reason)
reason = string(runes[:200])
}
if reason == "" {
return fmt.Sprintf("manager center ban non-recharged user, manager=%d", managerID)
}
return fmt.Sprintf("manager center ban non-recharged user, manager=%d, reason=%s", managerID, reason)
}
func buildUnbanDescription(managerID int64, reason string) string {
reason = strings.TrimSpace(reason)
if len([]rune(reason)) > 200 {
runes := []rune(reason)
reason = string(runes[:200])
}
if reason == "" {
return fmt.Sprintf("manager center unban user, manager=%d", managerID)
}
return fmt.Sprintf("manager center unban user, manager=%d, reason=%s", managerID, reason)
}
func isUnbannableAccountStatus(status string) bool {
switch strings.ToUpper(strings.TrimSpace(status)) {
case accountStatusFreeze, accountStatusArchive, accountStatusArchiveDevice:
return true
default:
return false
}
}
func isAppErrorCode(err error, code string) bool {
var appErr *AppError
return errors.As(err, &appErr) && appErr.Code == code
}
func normalizeSysOrigin(sysOrigin string) string {
sysOrigin = strings.ToUpper(strings.TrimSpace(sysOrigin))
if sysOrigin == "" {
return "LIKEI"
}
return sysOrigin
}
func vipPropsViewFromConfig(row model.VipLevelConfig) PropsView {
days := row.DurationDays
if days <= 0 {
days = defaultVIPGiftDays
}
name := strings.TrimSpace(row.DisplayName)
if name == "" {
name = fmt.Sprintf("VIP %d", row.Level)
}
code := strings.TrimSpace(row.LevelCode)
if code == "" {
code = fmt.Sprintf("VIP%d", row.Level)
}
return PropsView{
ID: ID(row.ID),
Type: propsTypeNobleVIP,
Code: code,
Name: name,
Cover: row.BadgeURL,
SourceURL: row.BadgeURL,
Days: days,
VIPLevel: row.Level,
}
}