958 lines
29 KiB
Go
958 lines
29 KiB
Go
package managercenter
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"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"`
|
||
CanChangeCountry *bool `gorm:"column:can_change_country"`
|
||
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"`
|
||
CountryID int64 `gorm:"column:country_id"`
|
||
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"`
|
||
UpdateTime time.Time `gorm:"column:update_time"`
|
||
UpdateUser int64 `gorm:"column:update_user"`
|
||
}
|
||
|
||
func (userBaseInfoRow) TableName() string {
|
||
return "user_base_info"
|
||
}
|
||
|
||
type sysCountryCodeRow struct {
|
||
ID int64 `gorm:"column:id;primaryKey"`
|
||
AlphaTwo string `gorm:"column:alpha_two"`
|
||
CountryName string `gorm:"column:country_name"`
|
||
NationalFlag string `gorm:"column:national_flag"`
|
||
PhonePrefix int `gorm:"column:phone_prefix"`
|
||
Open bool `gorm:"column:is_open"`
|
||
Sort int `gorm:"column:sort"`
|
||
}
|
||
|
||
func (sysCountryCodeRow) TableName() string {
|
||
return "sys_country_code"
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// ListCountries 给经理中心返回当前开放国家,H5 只展示可绑定目标,避免经理手输无效国家码。
|
||
func (s *Service) ListCountries(ctx context.Context, user AuthUser) (*CountryListResponse, error) {
|
||
if err := s.ensureManager(ctx, user); err != nil {
|
||
return nil, err
|
||
}
|
||
if s.db == nil {
|
||
return nil, serviceUnavailable("database_unavailable", "database is unavailable")
|
||
}
|
||
|
||
var rows []sysCountryCodeRow
|
||
if err := s.db.WithContext(ctx).
|
||
Table("sys_country_code").
|
||
Select("id, alpha_two, country_name, national_flag, phone_prefix, is_open, sort").
|
||
Where("is_open = ?", true).
|
||
Order("sort ASC, country_name ASC, id ASC").
|
||
Find(&rows).Error; err != nil {
|
||
return nil, serverError("country_query_failed", err.Error())
|
||
}
|
||
|
||
records := make([]CountryView, 0, len(rows))
|
||
for _, row := range rows {
|
||
if view, ok := countryRowToView(row); ok {
|
||
records = append(records, view)
|
||
}
|
||
}
|
||
return &CountryListResponse{Records: records}, 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
|
||
}
|
||
|
||
// ChangeUserCountry 只允许 admin 开启改国家权限的经理移动普通用户国家;目标用户已有业务身份时拒绝,避免跨国家后团队、币商和主播归属数据不一致。
|
||
func (s *Service) ChangeUserCountry(ctx context.Context, user AuthUser, req ChangeUserCountryRequest) (*ChangeUserCountryResponse, error) {
|
||
if _, err := s.requireManagerFeature(ctx, user, managerFeatureChangeCountry); err != nil {
|
||
return nil, err
|
||
}
|
||
if req.UserID.Int64() <= 0 {
|
||
return nil, badRequest("user_required", "userId is required")
|
||
}
|
||
if req.CountryID.Int64() <= 0 {
|
||
return nil, badRequest("country_required", "countryId is required")
|
||
}
|
||
if s.db == nil {
|
||
return nil, serviceUnavailable("database_unavailable", "database 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())
|
||
}
|
||
country, err := s.loadOpenCountryByID(ctx, req.CountryID.Int64())
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.ensureUserCountryMovable(ctx, target.ID); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 只改国家三元组,不触碰昵称、头像等资料审核字段;清理 Java 资料缓存后 App/H5 会重新读到新国家。
|
||
updates := map[string]any{
|
||
"country_id": country.ID,
|
||
"country_code": strings.ToUpper(strings.TrimSpace(country.AlphaTwo)),
|
||
"country_name": strings.TrimSpace(country.CountryName),
|
||
"update_time": time.Now(),
|
||
"update_user": user.UserID,
|
||
}
|
||
if err := s.db.WithContext(ctx).
|
||
Table("user_base_info").
|
||
Where("id = ? AND is_del = ?", target.ID, false).
|
||
Updates(updates).Error; err != nil {
|
||
return nil, serverError("user_country_update_failed", err.Error())
|
||
}
|
||
if s.java != nil {
|
||
if err := s.java.RemoveUserProfileCacheAll(ctx, target.ID); err != nil {
|
||
return nil, serverError("remove_profile_cache_failed", err.Error())
|
||
}
|
||
}
|
||
|
||
target.CountryID = country.ID
|
||
target.CountryCode = strings.ToUpper(strings.TrimSpace(country.AlphaTwo))
|
||
target.CountryName = strings.TrimSpace(country.CountryName)
|
||
view, err := s.userRowToView(ctx, target)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &ChangeUserCountryResponse{Success: true, User: view}, 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),
|
||
ChangeCountry: defaultEnabled(row.CanChangeCountry),
|
||
}
|
||
}
|
||
|
||
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
|
||
case managerFeatureChangeCountry:
|
||
return features.ChangeCountry
|
||
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) loadOpenCountryByID(ctx context.Context, countryID int64) (sysCountryCodeRow, error) {
|
||
var row sysCountryCodeRow
|
||
err := s.db.WithContext(ctx).
|
||
Table("sys_country_code").
|
||
Select("id, alpha_two, country_name, national_flag, phone_prefix, is_open, sort").
|
||
Where("id = ? AND is_open = ?", countryID, true).
|
||
First(&row).Error
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return sysCountryCodeRow{}, notFound("country_not_found", "country not found")
|
||
}
|
||
if err != nil {
|
||
return sysCountryCodeRow{}, serverError("country_query_failed", err.Error())
|
||
}
|
||
if strings.TrimSpace(row.AlphaTwo) == "" {
|
||
return sysCountryCodeRow{}, badRequest("country_code_required", "country code is required")
|
||
}
|
||
return row, nil
|
||
}
|
||
|
||
// ensureUserCountryMovable 串行检查所有会绑定归属关系的身份;任一命中都返回同一个业务错误,前端显示统一提示。
|
||
func (s *Service) ensureUserCountryMovable(ctx context.Context, userID int64) error {
|
||
locked, err := s.userHasLockedHistoryIdentity(ctx, userID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if locked {
|
||
return badRequest("user_identity_locked", "user has host, agency, BD, BD leader or coin seller identity and cannot be moved")
|
||
}
|
||
locked, err = s.userIsBDLeader(ctx, userID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if locked {
|
||
return badRequest("user_identity_locked", "user has host, agency, BD, BD leader or coin seller identity and cannot be moved")
|
||
}
|
||
if s.java == nil {
|
||
return serviceUnavailable("java_gateway_unavailable", "java gateway is unavailable")
|
||
}
|
||
locked, err = s.java.ExistsFreightSeller(ctx, userID)
|
||
if err != nil {
|
||
return serverError("coin_seller_query_failed", err.Error())
|
||
}
|
||
if locked {
|
||
return badRequest("user_identity_locked", "user has host, agency, BD, BD leader or coin seller identity and cannot be moved")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) userHasLockedHistoryIdentity(ctx context.Context, userID int64) (bool, error) {
|
||
var count int64
|
||
if err := s.db.WithContext(ctx).
|
||
Table("user_history_identity").
|
||
Where("user_id = ? AND (is_host = ? OR is_agent = ? OR is_bd = ?)", userID, true, true, true).
|
||
Count(&count).Error; err != nil {
|
||
return false, serverError("history_identity_query_failed", err.Error())
|
||
}
|
||
return count > 0, nil
|
||
}
|
||
|
||
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,
|
||
CountryID: ID(row.CountryID),
|
||
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,
|
||
CountryID: ID(profile.CountryID),
|
||
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 int64(profile.CountryID) > 0 {
|
||
view.CountryID = ID(profile.CountryID)
|
||
}
|
||
if profile.OriginSys != "" {
|
||
view.OriginSys = profile.OriginSys
|
||
}
|
||
if profile.AccountStatus != "" {
|
||
view.AccountStatus = profile.AccountStatus
|
||
if isUnbannableAccountStatus(profile.AccountStatus) {
|
||
view.CanBan = false
|
||
}
|
||
}
|
||
}
|
||
|
||
func countryRowToView(row sysCountryCodeRow) (CountryView, bool) {
|
||
code := strings.ToUpper(strings.TrimSpace(row.AlphaTwo))
|
||
name := strings.TrimSpace(row.CountryName)
|
||
if row.ID <= 0 || code == "" || name == "" {
|
||
return CountryView{}, false
|
||
}
|
||
return CountryView{
|
||
ID: ID(row.ID),
|
||
CountryCode: code,
|
||
CountryName: name,
|
||
NationalFlag: strings.TrimSpace(row.NationalFlag),
|
||
PhonePrefix: row.PhonePrefix,
|
||
}, true
|
||
}
|
||
|
||
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,
|
||
}
|
||
}
|