2026-06-12 19:37:04 +08:00

769 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"`
Contact string `gorm:"column:contact"`
RegionID string `gorm:"column:region_id"`
CanAddBDLeader *bool `gorm:"column:can_add_bd_leader"`
CanGiftProps *bool `gorm:"column:can_gift_props"`
CanBanUser *bool `gorm:"column:can_ban_user"`
CanUnbanUser *bool `gorm:"column:can_unban_user"`
CreateUser int64 `gorm:"column:create_user"`
UpdateUser int64 `gorm:"column:update_user"`
}
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) {
manager, err := s.requireManagerInfo(ctx, user)
if err != nil {
return nil, err
}
view, err := s.loadUserViewByID(ctx, user.UserID)
if err != nil {
return nil, err
}
return &ProfileResponse{Manager: view, Features: managerFeaturesFromRow(manager)}, 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.requireManagerFeature(ctx, user, managerFeatureGiftProps); 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.requireManagerFeature(ctx, user, managerFeatureBanUser); 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.requireManagerFeature(ctx, user, managerFeatureUnbanUser); 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 {
_, err := s.requireManagerInfo(ctx, user)
return err
}
// requireManagerFeature 先确认调用人仍然是团队经理,再检查 admin 给这个经理打开的单项 H5 操作权限。
// 这样前端隐藏按钮和后端写操作使用同一张 team_manager_base_info 表做判断H5 被绕过时也会被服务层拦截。
func (s *Service) requireManagerFeature(ctx context.Context, user AuthUser, feature managerFeature) (teamManagerInfoRow, error) {
manager, err := s.requireManagerInfo(ctx, user)
if err != nil {
return teamManagerInfoRow{}, err
}
features := managerFeaturesFromRow(manager)
if !features.enabled(feature) {
return teamManagerInfoRow{}, forbidden("manager_feature_disabled", "manager feature is disabled")
}
return manager, nil
}
func managerFeaturesFromRow(row teamManagerInfoRow) ManagerFeatures {
return ManagerFeatures{
AddBDLeader: defaultEnabled(row.CanAddBDLeader),
GiftProps: defaultEnabled(row.CanGiftProps),
BanUser: defaultEnabled(row.CanBanUser),
UnbanUser: defaultEnabled(row.CanUnbanUser),
}
}
func defaultEnabled(value *bool) bool {
return value == nil || *value
}
func (features ManagerFeatures) enabled(feature managerFeature) bool {
switch feature {
case managerFeatureAddBDLeader:
return features.AddBDLeader
case managerFeatureGiftProps:
return features.GiftProps
case managerFeatureBanUser:
return features.BanUser
case managerFeatureUnbanUser:
return features.UnbanUser
default:
return false
}
}
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,
}
}