From fb43e3e9fa12f01afe1908624665c0630ba3a12d Mon Sep 17 00:00:00 2001 From: hy001 Date: Thu, 16 Jul 2026 20:06:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BB=8F=E7=90=86=E4=B8=AD=E5=BF=83?= =?UTF-8?q?=E8=B5=A0=E9=80=81=E5=A4=A9=E6=95=B0=E5=8F=AF=E9=80=89=E5=B9=B6?= =?UTF-8?q?=E6=94=B9=E7=94=B1=20Java=20=E6=9B=B4=E6=96=B0=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E5=9B=BD=E5=AE=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SendProps 新增 days 参数(限 30/60/90,缺省按新规则 30 天),VIP 赠送及贵族附属道具统一按所选天数发放 - ChangeUserCountry 不再直写 user_base_info,改调 Java other updateSelectiveById(只传 id+countryId)复用 country->user 锁顺序,避免与后台国家批量同步并发丢数据,写后强制清资料缓存 Co-Authored-By: Claude Fable 5 --- internal/integration/gateways.go | 10 ++++ internal/integration/java.go | 30 ++++++++++++ internal/integration/java_test.go | 26 ++++++++++ internal/service/managercenter/service.go | 49 +++++++++++-------- .../service/managercenter/service_test.go | 24 +++++++-- internal/service/managercenter/types.go | 12 +++-- internal/service/managercenter/vip_gift.go | 7 ++- 7 files changed, 127 insertions(+), 31 deletions(-) diff --git a/internal/integration/gateways.go b/internal/integration/gateways.go index b7d4313..be73d11 100644 --- a/internal/integration/gateways.go +++ b/internal/integration/gateways.go @@ -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) diff --git a/internal/integration/java.go b/internal/integration/java.go index 0a34e40..6f0bb88 100644 --- a/internal/integration/java.go +++ b/internal/integration/java.go @@ -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") diff --git a/internal/integration/java_test.go b/internal/integration/java_test.go index 4eb06b4..da994ac 100644 --- a/internal/integration/java_test.go +++ b/internal/integration/java_test.go @@ -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) { diff --git a/internal/service/managercenter/service.go b/internal/service/managercenter/service.go index a7c10b4..2794307 100644 --- a/internal/service/managercenter/service.go +++ b/internal/service/managercenter/service.go @@ -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)) } diff --git a/internal/service/managercenter/service_test.go b/internal/service/managercenter/service_test.go index 074245a..e23297f 100644 --- a/internal/service/managercenter/service_test.go +++ b/internal/service/managercenter/service_test.go @@ -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) diff --git a/internal/service/managercenter/types.go b/internal/service/managercenter/types.go index 8511ebd..7ed2a37 100644 --- a/internal/service/managercenter/types.go +++ b/internal/service/managercenter/types.go @@ -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 { diff --git a/internal/service/managercenter/vip_gift.go b/internal/service/managercenter/vip_gift.go index f92dbe1..935fce0 100644 --- a/internal/service/managercenter/vip_gift.go +++ b/internal/service/managercenter/vip_gift.go @@ -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()