feat: 经理中心赠送天数可选并改由 Java 更新用户国家

- SendProps 新增 days 参数(限 30/60/90,缺省按新规则 30 天),VIP 赠送及贵族附属道具统一按所选天数发放
- ChangeUserCountry 不再直写 user_base_info,改调 Java other updateSelectiveById(只传 id+countryId)复用 country->user 锁顺序,避免与后台国家批量同步并发丢数据,写后强制清资料缓存

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
hy001 2026-07-16 20:06:06 +08:00
parent 5296562391
commit fb43e3e9fa
7 changed files with 127 additions and 31 deletions

View File

@ -141,6 +141,11 @@ func (g *Gateways) GetUserProfile(ctx context.Context, userID int64) (UserProfil
return g.Profile.GetUserProfile(ctx, userID) return g.Profile.GetUserProfile(ctx, userID)
} }
// UpdateUserCountry 透传到 Java 用户国家更新接口。
func (g *Gateways) UpdateUserCountry(ctx context.Context, userID int64, countryID int64) error {
return g.Profile.UpdateUserCountry(ctx, userID, countryID)
}
// GetUserRegion 透传到用户地区网关。 // GetUserRegion 透传到用户地区网关。
func (g *Gateways) GetUserRegion(ctx context.Context, userID int64, authorization string) (UserRegion, error) { func (g *Gateways) GetUserRegion(ctx context.Context, userID int64, authorization string) (UserRegion, error) {
return g.Profile.GetUserRegion(ctx, userID, authorization) return g.Profile.GetUserRegion(ctx, userID, authorization)
@ -400,6 +405,11 @@ func (g *ProfileGateway) GetUserProfile(ctx context.Context, userID int64) (User
return g.client.GetUserProfile(ctx, userID) return g.client.GetUserProfile(ctx, userID)
} }
// UpdateUserCountry 通过 Java other 更新用户国家。
func (g *ProfileGateway) UpdateUserCountry(ctx context.Context, userID int64, countryID int64) error {
return g.client.UpdateUserCountry(ctx, userID, countryID)
}
// GetUserRegion 查询用户当前地区。 // GetUserRegion 查询用户当前地区。
func (g *ProfileGateway) GetUserRegion(ctx context.Context, userID int64, authorization string) (UserRegion, error) { func (g *ProfileGateway) GetUserRegion(ctx context.Context, userID int64, authorization string) (UserRegion, error) {
return g.client.GetUserRegion(ctx, userID, authorization) return g.client.GetUserRegion(ctx, userID, authorization)

View File

@ -1034,6 +1034,36 @@ func (c *Client) GetUserProfile(ctx context.Context, userID int64) (UserProfile,
return resp.Body, nil return resp.Body, nil
} }
// UpdateUserCountry 只提交稳定的用户 ID 和国家 ID国家码、名称以及并发锁统一由 Java other 处理。
func (c *Client) UpdateUserCountry(ctx context.Context, userID int64, countryID int64) error {
if userID <= 0 {
return fmt.Errorf("userId is required")
}
if countryID <= 0 {
return fmt.Errorf("countryId is required")
}
endpoint := strings.TrimRight(c.cfg.Java.OtherBaseURL, "/") + "/user-profile/client/updateSelectiveById"
payload := struct {
ID int64 `json:"id"`
CountryID int64 `json:"countryId"`
}{
ID: userID,
CountryID: countryID,
}
var resp resultResponse[any]
if err := c.postJSON(ctx, endpoint, payload, nil, &resp); err != nil {
return err
}
if resp.Success != nil && !*resp.Success {
message := strings.TrimSpace(resp.Message)
if message == "" {
message = "user country update failed"
}
return errors.New(message)
}
return nil
}
func (c *Client) GetUserRegion(ctx context.Context, userID int64, authorization string) (UserRegion, error) { func (c *Client) GetUserRegion(ctx context.Context, userID int64, authorization string) (UserRegion, error) {
if userID <= 0 { if userID <= 0 {
return UserRegion{}, fmt.Errorf("userId is required") return UserRegion{}, fmt.Errorf("userId is required")

View File

@ -78,6 +78,32 @@ func TestResolveRegionCodeByCountryCodeUsesRegionConfig(t *testing.T) {
} }
} }
func TestUpdateUserCountryPostsOnlyStableIDs(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/user-profile/client/updateSelectiveById" {
t.Fatalf("request = %s %s, want POST /user-profile/client/updateSelectiveById", r.Method, r.URL.Path)
}
var payload map[string]json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode request: %v", err)
}
if len(payload) != 2 || string(payload["id"]) != "2" || string(payload["countryId"]) != "11" {
t.Fatalf("payload = %s, want only id and countryId", payload)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true}`))
}))
defer server.Close()
client := New(config.Config{
HTTP: config.HTTPConfig{Timeout: time.Second},
Java: config.JavaConfig{OtherBaseURL: server.URL},
})
if err := client.UpdateUserCountry(context.Background(), 2, 11); err != nil {
t.Fatalf("UpdateUserCountry() error = %v", err)
}
}
func TestGetRewardGroupDetailAcceptsDataWrapper(t *testing.T) { func TestGetRewardGroupDetailAcceptsDataWrapper(t *testing.T) {
groupID := int64(2056981527522242600) groupID := int64(2056981527522242600)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View File

@ -209,6 +209,10 @@ func (s *Service) SendProps(ctx context.Context, user AuthUser, req SendPropsReq
if req.PropsID.Int64() <= 0 { if req.PropsID.Int64() <= 0 {
return nil, badRequest("props_required", "propsId is required") return nil, badRequest("props_required", "propsId is required")
} }
days, err := normalizeGiftDays(req.Days)
if err != nil {
return nil, err
}
if s.java == nil { if s.java == nil {
return nil, serviceUnavailable("java_gateway_unavailable", "java gateway is unavailable") return nil, serviceUnavailable("java_gateway_unavailable", "java gateway is unavailable")
} }
@ -224,7 +228,7 @@ func (s *Service) SendProps(ctx context.Context, user AuthUser, req SendPropsReq
prop, err := s.loadGiftableProps(ctx, req.PropsID.Int64()) prop, err := s.loadGiftableProps(ctx, req.PropsID.Int64())
if err != nil { if err != nil {
if isAppErrorCode(err, "prop_not_giftable") { if isAppErrorCode(err, "prop_not_giftable") {
if resp, vipErr := s.sendVIPGift(ctx, user, target, req.PropsID.Int64()); vipErr != nil { if resp, vipErr := s.sendVIPGift(ctx, user, target, req.PropsID.Int64(), days); vipErr != nil {
return nil, vipErr return nil, vipErr
} else if resp != nil { } else if resp != nil {
return resp, nil return resp, nil
@ -233,7 +237,6 @@ func (s *Service) SendProps(ctx context.Context, user AuthUser, req SendPropsReq
return nil, err return nil, err
} }
days := propsGiftDays(prop.Type)
if prop.Type == propsTypeBadge { if prop.Type == propsTypeBadge {
if err := s.java.ActivateTemporaryBadge(ctx, req.AcceptUserID.Int64(), prop.ID, days); err != nil { if err := s.java.ActivateTemporaryBadge(ctx, req.AcceptUserID.Int64(), prop.ID, days); err != nil {
return nil, serverError("send_badge_failed", err.Error()) return nil, serverError("send_badge_failed", err.Error())
@ -260,7 +263,7 @@ func (s *Service) SendProps(ctx context.Context, user AuthUser, req SendPropsReq
if err != nil { if err != nil {
return nil, serverError("vip_ability_query_failed", err.Error()) return nil, serverError("vip_ability_query_failed", err.Error())
} }
grants = append(grants, nobleVIPAccessoryGrants(ability)...) grants = append(grants, nobleVIPAccessoryGrants(ability, days)...)
} }
for _, grant := range grants { for _, grant := range grants {
@ -393,6 +396,9 @@ func (s *Service) ChangeUserCountry(ctx context.Context, user AuthUser, req Chan
if s.db == nil { if s.db == nil {
return nil, serviceUnavailable("database_unavailable", "database is unavailable") return nil, serviceUnavailable("database_unavailable", "database is unavailable")
} }
if s.java == nil {
return nil, serviceUnavailable("java_gateway_unavailable", "java gateway is unavailable")
}
target, err := s.loadUserRowByID(ctx, req.UserID.Int64()) target, err := s.loadUserRowByID(ctx, req.UserID.Int64())
if err != nil { if err != nil {
@ -409,24 +415,12 @@ func (s *Service) ChangeUserCountry(ctx context.Context, user AuthUser, req Chan
return nil, err return nil, err
} }
// 只改国家三元组,不触碰昵称、头像等资料审核字段;清理 Java 资料缓存后 App/H5 会重新读到新国家。 // 国家写入统一交给 Java复用 country -> user 锁顺序,避免与后台国家批量同步并发时漏数据。
updates := map[string]any{ if err := s.java.UpdateUserCountry(ctx, target.ID, country.ID); err != nil {
"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()) return nil, serverError("user_country_update_failed", err.Error())
} }
if s.java != nil { if err := s.java.RemoveUserProfileCacheAll(ctx, target.ID); err != nil {
if err := s.java.RemoveUserProfileCacheAll(ctx, target.ID); err != nil { return nil, serverError("remove_profile_cache_failed", err.Error())
return nil, serverError("remove_profile_cache_failed", err.Error())
}
} }
target.CountryID = country.ID target.CountryID = country.ID
@ -797,13 +791,13 @@ type propsGrant struct {
days int days int
} }
func nobleVIPAccessoryGrants(ability integration.PropsNobleVIPAbility) []propsGrant { func nobleVIPAccessoryGrants(ability integration.PropsNobleVIPAbility, days int) []propsGrant {
grants := make([]propsGrant, 0, 4) grants := make([]propsGrant, 0, 4)
appendIfPositive := func(propsID int64, typ string) { appendIfPositive := func(propsID int64, typ string) {
if propsID <= 0 { if propsID <= 0 {
return return
} }
grants = append(grants, propsGrant{propsID: propsID, typ: typ, days: defaultVIPGiftDays}) grants = append(grants, propsGrant{propsID: propsID, typ: typ, days: days})
} }
appendIfPositive(int64(ability.CarID), propsTypeRide) appendIfPositive(int64(ability.CarID), propsTypeRide)
appendIfPositive(int64(ability.AvatarFrameID), propsTypeAvatarFrame) appendIfPositive(int64(ability.AvatarFrameID), propsTypeAvatarFrame)
@ -881,6 +875,19 @@ func propsGiftDays(propsType string) int {
return defaultGiftDays return defaultGiftDays
} }
func normalizeGiftDays(days int) (int, error) {
// days=0 兼容尚未升级的旧 H5并按新规则默认赠送 30 天。
if days == 0 {
return defaultGiftDays, nil
}
switch days {
case giftDays30, giftDays60, giftDays90:
return days, nil
default:
return 0, badRequest("invalid_gift_days", "days must be one of 30, 60, 90")
}
}
func normalizePropsType(propsType string) string { func normalizePropsType(propsType string) string {
return strings.ToUpper(strings.TrimSpace(propsType)) return strings.ToUpper(strings.TrimSpace(propsType))
} }

View File

@ -26,9 +26,16 @@ type fakeManagerGateway struct {
unblockRequests []integration.UnblockAccountRequest unblockRequests []integration.UnblockAccountRequest
switchIDs []int64 switchIDs []int64
cacheCleared []int64 cacheCleared []int64
countryUpdates []fakeCountryUpdate
countryUpdateErr error
badgeActivations []fakeBadgeActivation badgeActivations []fakeBadgeActivation
} }
type fakeCountryUpdate struct {
userID int64
countryID int64
}
type fakeBadgeActivation struct { type fakeBadgeActivation struct {
userID int64 userID int64
badgeID int64 badgeID int64
@ -39,6 +46,14 @@ func (f *fakeManagerGateway) GetUserProfile(context.Context, int64) (integration
return integration.UserProfile{}, nil return integration.UserProfile{}, nil
} }
func (f *fakeManagerGateway) UpdateUserCountry(_ context.Context, userID int64, countryID int64) error {
if f.countryUpdateErr != nil {
return f.countryUpdateErr
}
f.countryUpdates = append(f.countryUpdates, fakeCountryUpdate{userID: userID, countryID: countryID})
return nil
}
func (f *fakeManagerGateway) GetUserProfileByAccountOne(context.Context, string) (integration.UserProfile, error) { func (f *fakeManagerGateway) GetUserProfileByAccountOne(context.Context, string) (integration.UserProfile, error) {
return integration.UserProfile{}, nil return integration.UserProfile{}, nil
} }
@ -788,7 +803,7 @@ func TestListCountriesReturnsOpenCountries(t *testing.T) {
} }
} }
func TestChangeUserCountryUpdatesCountryAndClearsCache(t *testing.T) { func TestChangeUserCountryDelegatesToJavaAndClearsCache(t *testing.T) {
service, fake := newManagerCenterTestService(t) service, fake := newManagerCenterTestService(t)
db := service.db.(*gorm.DB) db := service.db.(*gorm.DB)
seedManager(t, db, 1) seedManager(t, db, 1)
@ -805,13 +820,16 @@ func TestChangeUserCountryUpdatesCountryAndClearsCache(t *testing.T) {
if !resp.Success || resp.User.CountryCode != "AE" || resp.User.CountryName != "United Arab Emirates" { if !resp.Success || resp.User.CountryCode != "AE" || resp.User.CountryName != "United Arab Emirates" {
t.Fatalf("resp = %+v, want changed country", resp) t.Fatalf("resp = %+v, want changed country", resp)
} }
if len(fake.countryUpdates) != 1 || fake.countryUpdates[0] != (fakeCountryUpdate{userID: 2, countryID: 11}) {
t.Fatalf("countryUpdates = %+v, want user 2 country 11", fake.countryUpdates)
}
var row userBaseInfoRow var row userBaseInfoRow
if err := db.Where("id = ?", int64(2)).First(&row).Error; err != nil { if err := db.Where("id = ?", int64(2)).First(&row).Error; err != nil {
t.Fatalf("load user: %v", err) t.Fatalf("load user: %v", err)
} }
if row.CountryID != 11 || row.CountryCode != "AE" || row.CountryName != "United Arab Emirates" || row.UpdateUser != 1 { if row.CountryID != 10 || row.CountryCode != "SA" || row.CountryName != "Saudi Arabia" {
t.Fatalf("user row = %+v, want AE country and manager updater", row) t.Fatalf("user row = %+v, want Go service not to write country directly", row)
} }
if !equalInt64Slice(fake.cacheCleared, []int64{2}) { if !equalInt64Slice(fake.cacheCleared, []int64{2}) {
t.Fatalf("cacheCleared = %+v, want [2]", fake.cacheCleared) t.Fatalf("cacheCleared = %+v, want [2]", fake.cacheCleared)

View File

@ -28,8 +28,12 @@ const (
vipGiftOrigin = "VIP_PURCHASE" vipGiftOrigin = "VIP_PURCHASE"
vipGiftOriginDesc = "VIP purchase" vipGiftOriginDesc = "VIP purchase"
defaultGiftDays = 7 defaultGiftDays = 30
defaultVIPGiftDays = 30 defaultVIPGiftDays = 30
// 经理赠送统一限制为运营确认的三个有效期,避免客户端提交任意天数。
giftDays30 = 30
giftDays60 = 60
giftDays90 = 90
// 经理中心 VIP 赠送按当前运营范围放开 VIP1-VIP5列表和发放校验共用该上限避免展示可选但提交失败。 // 经理中心 VIP 赠送按当前运营范围放开 VIP1-VIP5列表和发放校验共用该上限避免展示可选但提交失败。
maxGiftableVIPLevel = 5 maxGiftableVIPLevel = 5
@ -60,6 +64,7 @@ type managerDB interface {
type managerGateway interface { type managerGateway interface {
GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error) GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error)
UpdateUserCountry(ctx context.Context, userID int64, countryID int64) error
GetUserProfileByAccountOne(ctx context.Context, account string) (integration.UserProfile, error) GetUserProfileByAccountOne(ctx context.Context, account string) (integration.UserProfile, error)
MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]integration.UserTotalRecharge, error) MapUserTotalRecharge(ctx context.Context, userIDs []int64) (map[int64][]integration.UserTotalRecharge, error)
ExistsFreightSeller(ctx context.Context, userID int64) (bool, error) ExistsFreightSeller(ctx context.Context, userID int64) (bool, error)
@ -178,8 +183,9 @@ type PropsView struct {
} }
type SendPropsRequest struct { type SendPropsRequest struct {
AcceptUserID ID `json:"acceptUserId"` AcceptUserID ID `json:"acceptUserId"`
PropsID ID `json:"propsId"` PropsID ID `json:"propsId"`
Days int `json:"days"`
} }
type SendPropsResponse struct { type SendPropsResponse struct {

View File

@ -79,7 +79,7 @@ func (s *Service) listVIPProps(ctx context.Context, user AuthUser) (*PropsListRe
}, nil }, nil
} }
func (s *Service) sendVIPGift(ctx context.Context, manager AuthUser, target userBaseInfoRow, configID int64) (*SendPropsResponse, error) { func (s *Service) sendVIPGift(ctx context.Context, manager AuthUser, target userBaseInfoRow, configID int64, days int) (*SendPropsResponse, error) {
sysOrigin := normalizeSysOrigin(target.OriginSys) sysOrigin := normalizeSysOrigin(target.OriginSys)
config, found, err := s.loadVIPConfigByID(ctx, sysOrigin, configID) config, found, err := s.loadVIPConfigByID(ctx, sysOrigin, configID)
if err != nil || !found { if err != nil || !found {
@ -91,9 +91,8 @@ func (s *Service) sendVIPGift(ctx context.Context, manager AuthUser, target user
if config.Level < 1 || config.Level > maxGiftableVIPLevel { if config.Level < 1 || config.Level > maxGiftableVIPLevel {
return nil, badRequest("vip_not_giftable", "vip level is invalid") return nil, badRequest("vip_not_giftable", "vip level is invalid")
} }
if config.DurationDays <= 0 { // 经理选择的赠送天数覆盖 VIP 售卖配置时长,订单、状态和附属权益保持同一到期口径。
config.DurationDays = defaultVIPGiftDays config.DurationDays = days
}
now := time.Now() now := time.Now()
orderID, err := utils.NextID() orderID, err := utils.NextID()