From 53c6f361f9f3bcf1ea304af378aaa9d863781b3e Mon Sep 17 00:00:00 2001 From: hy001 Date: Mon, 22 Jun 2026 17:34:52 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=8F=E7=90=86=E4=B8=AD=E5=BF=83=E9=85=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/router/manager_center_routes.go | 23 ++ internal/service/managercenter/service.go | 203 +++++++++++++++++- .../service/managercenter/service_test.go | 116 +++++++++- internal/service/managercenter/types.go | 24 +++ 4 files changed, 355 insertions(+), 11 deletions(-) diff --git a/internal/router/manager_center_routes.go b/internal/router/manager_center_routes.go index d02059f..517ceb2 100644 --- a/internal/router/manager_center_routes.go +++ b/internal/router/manager_center_routes.go @@ -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) + }) } diff --git a/internal/service/managercenter/service.go b/internal/service/managercenter/service.go index 302c2e1..47af0d1 100644 --- a/internal/service/managercenter/service.go +++ b/internal/service/managercenter/service.go @@ -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 diff --git a/internal/service/managercenter/service_test.go b/internal/service/managercenter/service_test.go index 4e2e6c1..68d4d35 100644 --- a/internal/service/managercenter/service_test.go +++ b/internal/service/managercenter/service_test.go @@ -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 { diff --git a/internal/service/managercenter/types.go b/internal/service/managercenter/types.go index fced10b..1f8bd6f 100644 --- a/internal/service/managercenter/types.go +++ b/internal/service/managercenter/types.go @@ -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"`