经理中心配置

This commit is contained in:
hy001 2026-06-22 17:34:52 +08:00
parent e370c3ca32
commit 53c6f361f9
4 changed files with 355 additions and 11 deletions

View File

@ -37,6 +37,15 @@ func registerManagerCenterRoutes(engine *gin.Engine, javaClient authGateway, ser
writeOK(c, resp)
})
group.GET("/countries", func(c *gin.Context) {
resp, err := service.ListCountries(c.Request.Context(), mustAuthUser(c))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
group.GET("/props", func(c *gin.Context) {
resp, err := service.ListProps(c.Request.Context(), mustAuthUser(c), c.Query("type"))
if err != nil {
@ -110,4 +119,18 @@ func registerManagerCenterRoutes(engine *gin.Engine, javaClient authGateway, ser
}
writeOK(c, resp)
})
group.POST("/users/country", func(c *gin.Context) {
var req managercenter.ChangeUserCountryRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, managercenter.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := service.ChangeUserCountry(c.Request.Context(), mustAuthUser(c), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
}

View File

@ -6,6 +6,7 @@ import (
"fmt"
"strconv"
"strings"
"time"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model"
@ -32,21 +33,38 @@ func (teamManagerInfoRow) TableName() string {
}
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"`
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"`
@ -106,6 +124,34 @@ func (s *Service) SearchUser(ctx context.Context, user AuthUser, account string)
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
@ -332,6 +378,66 @@ func (s *Service) UnbanUser(ctx context.Context, user AuthUser, req UnbanUserReq
}, nil
}
// ChangeUserCountry 只允许经理移动普通用户国家;用户已有业务身份时拒绝,避免跨国家后团队、币商和主播归属数据不一致。
func (s *Service) ChangeUserCountry(ctx context.Context, user AuthUser, req ChangeUserCountryRequest) (*ChangeUserCountryResponse, 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 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
@ -444,6 +550,65 @@ func (s *Service) loadUserRowByAccount(ctx context.Context, account string) (use
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)
@ -452,6 +617,7 @@ func (s *Service) userRowToView(ctx context.Context, row userBaseInfoRow) (UserV
Account: row.Account,
UserAvatar: row.UserAvatar,
UserNickname: row.UserNickname,
CountryID: ID(row.CountryID),
CountryCode: row.CountryCode,
CountryName: row.CountryName,
OriginSys: row.OriginSys,
@ -469,6 +635,7 @@ func (s *Service) javaProfileToView(ctx context.Context, profile integration.Use
Account: profile.Account,
UserAvatar: profile.UserAvatar,
UserNickname: profile.UserNickname,
CountryID: ID(profile.CountryID),
CountryCode: profile.CountryCode,
CountryName: profile.CountryName,
OriginSys: profile.OriginSys,
@ -494,6 +661,9 @@ func overlayJavaProfile(view *UserView, profile integration.UserProfile) {
if profile.CountryName != "" {
view.CountryName = profile.CountryName
}
if int64(profile.CountryID) > 0 {
view.CountryID = ID(profile.CountryID)
}
if profile.OriginSys != "" {
view.OriginSys = profile.OriginSys
}
@ -505,6 +675,21 @@ func overlayJavaProfile(view *UserView, profile integration.UserProfile) {
}
}
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

View File

@ -17,6 +17,8 @@ import (
type fakeManagerGateway struct {
recharges map[int64][]integration.UserTotalRecharge
rechargeErr error
freightSellers map[int64]bool
freightSellerErr error
abilities map[int64]integration.PropsNobleVIPAbility
maxAbility integration.PropsNobleVIPAbility
giveRequests []integration.GivePropsBackpackRequest
@ -52,6 +54,13 @@ func (f *fakeManagerGateway) MapUserTotalRecharge(_ context.Context, userIDs []i
return result, nil
}
func (f *fakeManagerGateway) ExistsFreightSeller(_ context.Context, userID int64) (bool, error) {
if f.freightSellerErr != nil {
return false, f.freightSellerErr
}
return f.freightSellers[userID], nil
}
func (f *fakeManagerGateway) GivePropsBackpack(_ context.Context, req integration.GivePropsBackpackRequest) error {
f.giveRequests = append(f.giveRequests, req)
return nil
@ -732,6 +741,98 @@ func TestUnbanUserAllowsFrozenUser(t *testing.T) {
}
}
func TestListCountriesReturnsOpenCountries(t *testing.T) {
service, _ := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManager(t, db, 1)
seedCountries(t, db,
sysCountryCodeRow{ID: 10, AlphaTwo: "SA", CountryName: "Saudi Arabia", Open: true, Sort: 2},
sysCountryCodeRow{ID: 11, AlphaTwo: "AE", CountryName: "United Arab Emirates", Open: true, Sort: 1},
sysCountryCodeRow{ID: 12, AlphaTwo: "XX", CountryName: "Closed", Open: false, Sort: 3},
)
resp, err := service.ListCountries(context.Background(), AuthUser{UserID: 1})
if err != nil {
t.Fatalf("ListCountries() error = %v", err)
}
if len(resp.Records) != 2 {
t.Fatalf("countries = %+v, want two open rows", resp.Records)
}
if resp.Records[0].CountryCode != "AE" || resp.Records[1].CountryCode != "SA" {
t.Fatalf("countries = %+v, want sort order AE then SA", resp.Records)
}
}
func TestChangeUserCountryUpdatesCountryAndClearsCache(t *testing.T) {
service, fake := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManager(t, db, 1)
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", CountryID: 10, CountryCode: "SA", CountryName: "Saudi Arabia"})
seedCountries(t, db, sysCountryCodeRow{ID: 11, AlphaTwo: "AE", CountryName: "United Arab Emirates", Open: true})
resp, err := service.ChangeUserCountry(context.Background(), AuthUser{UserID: 1}, ChangeUserCountryRequest{
UserID: ID(2),
CountryID: ID(11),
})
if err != nil {
t.Fatalf("ChangeUserCountry() error = %v", err)
}
if !resp.Success || resp.User.CountryCode != "AE" || resp.User.CountryName != "United Arab Emirates" {
t.Fatalf("resp = %+v, want changed country", resp)
}
var row userBaseInfoRow
if err := db.Where("id = ?", int64(2)).First(&row).Error; err != nil {
t.Fatalf("load user: %v", err)
}
if row.CountryID != 11 || row.CountryCode != "AE" || row.CountryName != "United Arab Emirates" || row.UpdateUser != 1 {
t.Fatalf("user row = %+v, want AE country and manager updater", row)
}
if !equalInt64Slice(fake.cacheCleared, []int64{2}) {
t.Fatalf("cacheCleared = %+v, want [2]", fake.cacheCleared)
}
}
func TestChangeUserCountryRejectsHistoryIdentity(t *testing.T) {
service, _ := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManager(t, db, 1)
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", CountryID: 10, CountryCode: "SA"})
seedCountries(t, db, sysCountryCodeRow{ID: 11, AlphaTwo: "AE", CountryName: "United Arab Emirates", Open: true})
if err := db.Create(&userHistoryIdentityRow{UserID: 2, IsHost: true}).Error; err != nil {
t.Fatalf("seed identity: %v", err)
}
_, err := service.ChangeUserCountry(context.Background(), AuthUser{UserID: 1}, ChangeUserCountryRequest{UserID: ID(2), CountryID: ID(11)})
assertAppErrorCode(t, err, "user_identity_locked")
}
func TestChangeUserCountryRejectsBDLeader(t *testing.T) {
service, _ := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManager(t, db, 1)
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", CountryID: 10, CountryCode: "SA"})
seedCountries(t, db, sysCountryCodeRow{ID: 11, AlphaTwo: "AE", CountryName: "United Arab Emirates", Open: true})
if err := db.Create(&bdLeaderRow{ID: 20, UserID: 2, Origin: "LIKEI", RegionID: "SA"}).Error; err != nil {
t.Fatalf("seed bd leader: %v", err)
}
_, err := service.ChangeUserCountry(context.Background(), AuthUser{UserID: 1}, ChangeUserCountryRequest{UserID: ID(2), CountryID: ID(11)})
assertAppErrorCode(t, err, "user_identity_locked")
}
func TestChangeUserCountryRejectsCoinSeller(t *testing.T) {
service, fake := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManager(t, db, 1)
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", CountryID: 10, CountryCode: "SA"})
seedCountries(t, db, sysCountryCodeRow{ID: 11, AlphaTwo: "AE", CountryName: "United Arab Emirates", Open: true})
fake.freightSellers[2] = true
_, err := service.ChangeUserCountry(context.Background(), AuthUser{UserID: 1}, ChangeUserCountryRequest{UserID: ID(2), CountryID: ID(11)})
assertAppErrorCode(t, err, "user_identity_locked")
}
func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
@ -741,6 +842,7 @@ func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) {
if err := db.AutoMigrate(
&teamManagerInfoRow{},
&userBaseInfoRow{},
&sysCountryCodeRow{},
&bdLeaderRow{},
&businessDevelopmentBaseInfoRow{},
&userHistoryIdentityRow{},
@ -755,8 +857,9 @@ func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) {
t.Fatalf("auto migrate: %v", err)
}
fake := &fakeManagerGateway{
recharges: map[int64][]integration.UserTotalRecharge{},
abilities: map[int64]integration.PropsNobleVIPAbility{},
recharges: map[int64][]integration.UserTotalRecharge{},
freightSellers: map[int64]bool{},
abilities: map[int64]integration.PropsNobleVIPAbility{},
}
return NewService(db, fake), fake
}
@ -803,6 +906,15 @@ func seedUsers(t *testing.T, db *gorm.DB, rows ...userBaseInfoRow) {
}
}
func seedCountries(t *testing.T, db *gorm.DB, rows ...sysCountryCodeRow) {
t.Helper()
for _, row := range rows {
if err := db.Create(&row).Error; err != nil {
t.Fatalf("seed country: %v", err)
}
}
}
func seedProps(t *testing.T, db *gorm.DB, rows ...propsSourceRecordRow) {
t.Helper()
for _, row := range rows {

View File

@ -60,6 +60,7 @@ type managerGateway interface {
GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error)
GetUserProfileByAccountOne(ctx context.Context, account string) (integration.UserProfile, error)
MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]integration.UserTotalRecharge, error)
ExistsFreightSeller(ctx context.Context, userID int64) (bool, error)
GivePropsBackpack(ctx context.Context, req integration.GivePropsBackpackRequest) error
GetNobleVIPAbility(ctx context.Context, sourceID int64) (integration.PropsNobleVIPAbility, error)
GetUserMaxNobleVIPAbility(ctx context.Context, userID int64) (integration.PropsNobleVIPAbility, error)
@ -130,11 +131,24 @@ type UserSearchResponse struct {
User UserView `json:"user"`
}
type CountryListResponse struct {
Records []CountryView `json:"records"`
}
type CountryView struct {
ID ID `json:"id"`
CountryCode string `json:"countryCode"`
CountryName string `json:"countryName"`
NationalFlag string `json:"nationalFlag,omitempty"`
PhonePrefix int `json:"phonePrefix,omitempty"`
}
type UserView struct {
UserID ID `json:"userId"`
Account string `json:"account"`
UserAvatar string `json:"userAvatar"`
UserNickname string `json:"userNickname"`
CountryID ID `json:"countryId,omitempty"`
CountryCode string `json:"countryCode,omitempty"`
CountryName string `json:"countryName,omitempty"`
OriginSys string `json:"originSys,omitempty"`
@ -196,6 +210,16 @@ type UnbanUserResponse struct {
AccountStatus string `json:"accountStatus"`
}
type ChangeUserCountryRequest struct {
UserID ID `json:"userId"`
CountryID ID `json:"countryId"`
}
type ChangeUserCountryResponse struct {
Success bool `json:"success"`
User UserView `json:"user"`
}
type BDLeaderListResponse struct {
Count int `json:"count"`
Records []BDLeaderView `json:"records"`