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)
}
// UpdateUserCountry 透传到 Java 用户国家更新接口。
func (g *Gateways) UpdateUserCountry(ctx context.Context, userID int64, countryID int64) error {
return g.Profile.UpdateUserCountry(ctx, userID, countryID)
}
// GetUserRegion 透传到用户地区网关。
func (g *Gateways) GetUserRegion(ctx context.Context, userID int64, authorization string) (UserRegion, error) {
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)
}
// UpdateUserCountry 通过 Java other 更新用户国家。
func (g *ProfileGateway) UpdateUserCountry(ctx context.Context, userID int64, countryID int64) error {
return g.client.UpdateUserCountry(ctx, userID, countryID)
}
// GetUserRegion 查询用户当前地区。
func (g *ProfileGateway) GetUserRegion(ctx context.Context, userID int64, authorization string) (UserRegion, error) {
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
}
// 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) {
if userID <= 0 {
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) {
groupID := int64(2056981527522242600)
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 {
return nil, badRequest("props_required", "propsId is required")
}
days, err := normalizeGiftDays(req.Days)
if err != nil {
return nil, err
}
if s.java == nil {
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())
if err != nil {
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
} else if resp != nil {
return resp, nil
@ -233,7 +237,6 @@ func (s *Service) SendProps(ctx context.Context, user AuthUser, req SendPropsReq
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())
@ -260,7 +263,7 @@ func (s *Service) SendProps(ctx context.Context, user AuthUser, req SendPropsReq
if err != nil {
return nil, serverError("vip_ability_query_failed", err.Error())
}
grants = append(grants, nobleVIPAccessoryGrants(ability)...)
grants = append(grants, nobleVIPAccessoryGrants(ability, days)...)
}
for _, grant := range grants {
@ -393,6 +396,9 @@ func (s *Service) ChangeUserCountry(ctx context.Context, user AuthUser, req Chan
if s.db == nil {
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())
if err != nil {
@ -409,24 +415,12 @@ func (s *Service) ChangeUserCountry(ctx context.Context, user AuthUser, req Chan
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 {
// 国家写入统一交给 Java复用 country -> user 锁顺序,避免与后台国家批量同步并发时漏数据。
if err := s.java.UpdateUserCountry(ctx, target.ID, country.ID); 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())
}
if err := s.java.RemoveUserProfileCacheAll(ctx, target.ID); err != nil {
return nil, serverError("remove_profile_cache_failed", err.Error())
}
target.CountryID = country.ID
@ -797,13 +791,13 @@ type propsGrant struct {
days int
}
func nobleVIPAccessoryGrants(ability integration.PropsNobleVIPAbility) []propsGrant {
func nobleVIPAccessoryGrants(ability integration.PropsNobleVIPAbility, days int) []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})
grants = append(grants, propsGrant{propsID: propsID, typ: typ, days: days})
}
appendIfPositive(int64(ability.CarID), propsTypeRide)
appendIfPositive(int64(ability.AvatarFrameID), propsTypeAvatarFrame)
@ -881,6 +875,19 @@ func propsGiftDays(propsType string) int {
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 {
return strings.ToUpper(strings.TrimSpace(propsType))
}

View File

@ -26,9 +26,16 @@ type fakeManagerGateway struct {
unblockRequests []integration.UnblockAccountRequest
switchIDs []int64
cacheCleared []int64
countryUpdates []fakeCountryUpdate
countryUpdateErr error
badgeActivations []fakeBadgeActivation
}
type fakeCountryUpdate struct {
userID int64
countryID int64
}
type fakeBadgeActivation struct {
userID int64
badgeID int64
@ -39,6 +46,14 @@ func (f *fakeManagerGateway) GetUserProfile(context.Context, int64) (integration
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) {
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)
db := service.db.(*gorm.DB)
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" {
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
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 row.CountryID != 10 || row.CountryCode != "SA" || row.CountryName != "Saudi Arabia" {
t.Fatalf("user row = %+v, want Go service not to write country directly", row)
}
if !equalInt64Slice(fake.cacheCleared, []int64{2}) {
t.Fatalf("cacheCleared = %+v, want [2]", fake.cacheCleared)

View File

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

View File

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