diff --git a/cmd/api/main.go b/cmd/api/main.go index 3969a43..be9746f 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -53,8 +53,9 @@ func main() { signInRewardService := signinreward.NewSignInRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) taskCenterService := taskcenter.NewService(app.Config, app.Repository.DB, &app.Gateways) vipService := vip.NewService(app.Config, app.Repository.DB, &app.Gateways) - regionIMGroupService := regionimgroup.NewService(app.Config, app.Repository.DB) + regionIMGroupService := regionimgroup.NewService(app.Config, app.Repository.DB, &app.Gateways) voiceRoomRedPacketService := voiceroomredpacket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet, regionIMGroupService, app.Gateways.Room) + voiceRoomRedPacketService.SetRegionResolver(&app.Gateways) weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) roomTurnoverRewardService := roomturnoverreward.NewService(app.Config, app.Repository.DB, &app.Gateways) hostCenterService := hostcenter.NewService(app.Config, &app.Gateways) diff --git a/cmd/consumer/main.go b/cmd/consumer/main.go index ed05abf..3394ce0 100644 --- a/cmd/consumer/main.go +++ b/cmd/consumer/main.go @@ -24,8 +24,9 @@ func main() { registerRewardService := registerreward.NewRegisterRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) - regionIMGroupService := regionimgroup.NewService(app.Config, app.Repository.DB) + regionIMGroupService := regionimgroup.NewService(app.Config, app.Repository.DB, &app.Gateways) voiceRoomRedPacketService := voiceroomredpacket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet, regionIMGroupService, app.Gateways.Room) + voiceRoomRedPacketService.SetRegionResolver(&app.Gateways) workerCtx, workerCancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer workerCancel() diff --git a/internal/integration/gateways.go b/internal/integration/gateways.go index 53fa165..207fb91 100644 --- a/internal/integration/gateways.go +++ b/internal/integration/gateways.go @@ -126,6 +126,11 @@ func (g *Gateways) GetUserRegion(ctx context.Context, userID int64, authorizatio return g.Profile.GetUserRegion(ctx, userID, authorization) } +// ResolveRegionCodeByCountryCode 透传到 Java 区域配置网关。 +func (g *Gateways) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) { + return g.Profile.ResolveRegionCodeByCountryCode(ctx, sysOrigin, countryCode) +} + // GetUserProfileByAccount 透传到资料网关。 func (g *Gateways) GetUserProfileByAccount(ctx context.Context, sysOrigin, account string) (UserProfile, error) { return g.Profile.GetUserProfileByAccount(ctx, sysOrigin, account) @@ -316,6 +321,11 @@ func (g *ProfileGateway) GetUserRegion(ctx context.Context, userID int64, author return g.client.GetUserRegion(ctx, userID, authorization) } +// ResolveRegionCodeByCountryCode 根据 Java 区域配置把国家码解析成区域编码。 +func (g *ProfileGateway) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) { + return g.client.ResolveRegionCodeByCountryCode(ctx, sysOrigin, countryCode) +} + // GetUserProfileByAccount 根据账号查询用户资料。 func (g *ProfileGateway) GetUserProfileByAccount(ctx context.Context, sysOrigin, account string) (UserProfile, error) { return g.client.GetUserProfileByAccount(ctx, sysOrigin, account) diff --git a/internal/integration/java.go b/internal/integration/java.go index bf5cab3..07c84a6 100644 --- a/internal/integration/java.go +++ b/internal/integration/java.go @@ -12,14 +12,17 @@ import ( "net/url" "strconv" "strings" + "sync" "time" "chatapp3-golang/internal/config" ) type Client struct { - cfg config.Config - httpClient *http.Client + cfg config.Config + httpClient *http.Client + regionConfigCacheMu sync.Mutex + regionConfigCacheMap map[string]regionConfigCacheEntry } type UserCredential struct { @@ -55,6 +58,21 @@ type UserRegion struct { RegionCode string `json:"regionCode"` } +type RegionConfig struct { + RegionCode string `json:"regionCode"` + SysOrigin string `json:"sysOrigin"` + RegionName string `json:"regionName"` + CountryCodes string `json:"countryCodes"` + Del bool `json:"del"` +} + +type regionConfigCacheEntry struct { + expiresAt time.Time + items []RegionConfig +} + +const regionConfigCacheTTL = time.Minute + type RoomContributionActivityCount struct { ID string `json:"id"` RoomID Int64Value `json:"roomId"` @@ -370,6 +388,7 @@ func New(cfg config.Config) *Client { httpClient: &http.Client{ Timeout: cfg.HTTP.Timeout, }, + regionConfigCacheMap: map[string]regionConfigCacheEntry{}, } } @@ -715,6 +734,75 @@ func (c *Client) GetUserRegion(ctx context.Context, userID int64, authorization return resp.Body, nil } +func (c *Client) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) { + countryCode = strings.ToUpper(strings.TrimSpace(countryCode)) + if countryCode == "" { + return "", nil + } + configs, err := c.ListRegionConfigs(ctx, sysOrigin) + if err != nil { + return "", err + } + for _, item := range configs { + if item.Del { + continue + } + if !regionConfigContainsCountryCode(item.CountryCodes, countryCode) { + continue + } + regionCode := strings.TrimSpace(item.RegionCode) + if regionCode != "" { + return regionCode, nil + } + } + return "", nil +} + +func (c *Client) ListRegionConfigs(ctx context.Context, sysOrigin string) ([]RegionConfig, error) { + sysOrigin = strings.ToUpper(strings.TrimSpace(sysOrigin)) + if sysOrigin == "" { + sysOrigin = "LIKEI" + } + now := time.Now() + c.regionConfigCacheMu.Lock() + if entry, ok := c.regionConfigCacheMap[sysOrigin]; ok && now.Before(entry.expiresAt) { + items := append([]RegionConfig(nil), entry.items...) + c.regionConfigCacheMu.Unlock() + return items, nil + } + c.regionConfigCacheMu.Unlock() + + endpoint := strings.TrimRight(c.cfg.Java.OtherBaseURL, "/") + "/region-config/client/listByCondition" + var resp resultResponse[[]RegionConfig] + if err := c.postJSON(ctx, endpoint, map[string]string{"sysOrigin": sysOrigin}, nil, &resp); err != nil { + return nil, err + } + items := append([]RegionConfig(nil), resp.Body...) + c.regionConfigCacheMu.Lock() + if c.regionConfigCacheMap == nil { + c.regionConfigCacheMap = map[string]regionConfigCacheEntry{} + } + c.regionConfigCacheMap[sysOrigin] = regionConfigCacheEntry{ + expiresAt: now.Add(regionConfigCacheTTL), + items: append([]RegionConfig(nil), items...), + } + c.regionConfigCacheMu.Unlock() + return items, nil +} + +func regionConfigContainsCountryCode(countryCodes string, countryCode string) bool { + countryCode = strings.ToUpper(strings.TrimSpace(countryCode)) + if countryCode == "" { + return false + } + for _, item := range strings.Split(countryCodes, ",") { + if strings.ToUpper(strings.TrimSpace(item)) == countryCode { + return true + } + } + return false +} + func (c *Client) GetUserProfileByAccount(ctx context.Context, sysOrigin, account string) (UserProfile, error) { endpoint := c.cfg.Java.OtherBaseURL + "/user-profile/client/getByAccount?sysOrigin=" + url.QueryEscape(strings.TrimSpace(sysOrigin)) + "&account=" + url.QueryEscape(strings.TrimSpace(account)) diff --git a/internal/integration/java_test.go b/internal/integration/java_test.go index b05023d..88e72b9 100644 --- a/internal/integration/java_test.go +++ b/internal/integration/java_test.go @@ -1,8 +1,14 @@ package integration import ( + "context" "encoding/json" + "net/http" + "net/http/httptest" "testing" + "time" + + "chatapp3-golang/internal/config" ) func TestUserTotalRechargeAcceptsNumericTimestamps(t *testing.T) { @@ -29,3 +35,45 @@ func TestUserTotalRechargeAcceptsNumericTimestamps(t *testing.T) { t.Fatalf("Amount = %q, want 1.60", item.Amount) } } + +func TestResolveRegionCodeByCountryCodeUsesRegionConfig(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.Method != http.MethodPost || r.URL.Path != "/region-config/client/listByCondition" { + t.Fatalf("request = %s %s, want POST /region-config/client/listByCondition", r.Method, r.URL.Path) + } + var payload map[string]string + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode request: %v", err) + } + if payload["sysOrigin"] != "LIKEI" { + t.Fatalf("sysOrigin = %q, want LIKEI", payload["sysOrigin"]) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"body":[{"regionCode":"中东区","sysOrigin":"LIKEI","countryCodes":"JO, EG, KSA","del":false},{"regionCode":"US","sysOrigin":"LIKEI","countryCodes":"US","del":true}]}`)) + })) + defer server.Close() + + client := New(config.Config{ + HTTP: config.HTTPConfig{Timeout: time.Second}, + Java: config.JavaConfig{OtherBaseURL: server.URL}, + }) + got, err := client.ResolveRegionCodeByCountryCode(context.Background(), " likei ", " ksa ") + if err != nil { + t.Fatalf("ResolveRegionCodeByCountryCode() error = %v", err) + } + if got != "中东区" { + t.Fatalf("region = %q, want 中东区", got) + } + got, err = client.ResolveRegionCodeByCountryCode(context.Background(), "LIKEI", "eg") + if err != nil { + t.Fatalf("ResolveRegionCodeByCountryCode() cached error = %v", err) + } + if got != "中东区" { + t.Fatalf("cached region = %q, want 中东区", got) + } + if calls != 1 { + t.Fatalf("region config calls = %d, want cached 1", calls) + } +} diff --git a/internal/service/regionimgroup/region.go b/internal/service/regionimgroup/region.go index 68e533f..84cfe48 100644 --- a/internal/service/regionimgroup/region.go +++ b/internal/service/regionimgroup/region.go @@ -25,6 +25,15 @@ func (s *Service) resolveUserRegion(ctx context.Context, sysOrigin string, userI return "", NewAppError(http.StatusBadRequest, "voice_room_region_missing", "user country code is missing") } + if s != nil && s.regionResolver != nil { + regionCode, err := s.regionResolver.ResolveRegionCodeByCountryCode(ctx, sysOrigin, countryCode) + if err == nil { + if normalized := normalizeRegionCode(regionCode); normalized != "" { + return normalized, nil + } + } + } + var mapping model.VoiceRoomRegionCountry err = s.db.WithContext(ctx). Where("sys_origin = ? AND country_code = ? AND enabled = ?", sysOrigin, countryCode, true). diff --git a/internal/service/regionimgroup/service_test.go b/internal/service/regionimgroup/service_test.go index d8c4e6f..8ede8b5 100644 --- a/internal/service/regionimgroup/service_test.go +++ b/internal/service/regionimgroup/service_test.go @@ -133,6 +133,42 @@ func TestSendRegionBroadcastResolvesRegionAndEnrichesGiftPayload(t *testing.T) { } } +func TestSendRegionBroadcastPrefersJavaRegionResolver(t *testing.T) { + service, db := newRegionIMGroupTestService(t) + service.regionResolver = fakeRegionResolver{ + regions: map[string]string{"LIKEI|SA": "中东区"}, + } + fakeIM := &fakeIMGateway{} + service.im = fakeIM + now := time.Now() + if err := db.Create(&model.VoiceRoomRegionIMGroup{ + ID: 3003, + SysOrigin: "LIKEI", + RegionCode: "中东区", + RegionName: "Middle East", + GroupID: "@region-middle-east", + GroupName: "region-middle-east", + Status: imGroupStatusActive, + CreateTime: now, + UpdateTime: now, + }).Error; err != nil { + t.Fatalf("seed im group: %v", err) + } + + resp, err := service.SendRegionBroadcast(context.Background(), RegionBroadcastRequest{ + SysOrigin: "LIKEI", + SenderUserID: flexibleInt64(1001), + Type: "SEND_GIFT", + Data: map[string]any{"roomId": "9001"}, + }) + if err != nil { + t.Fatalf("SendRegionBroadcast() error = %v", err) + } + if resp.RegionCode != "中东区" || resp.GroupID != "@region-middle-east" { + t.Fatalf("SendRegionBroadcast() response = %+v, want Java region group", resp) + } +} + func newRegionIMGroupTestService(t *testing.T) (*Service, *gorm.DB) { t.Helper() db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) @@ -191,3 +227,16 @@ func (g *fakeIMGateway) SendCustomGroupMessage(_ context.Context, groupID string g.sent = append(g.sent, fakeIMSendRecord{groupID: groupID, body: body}) return nil } + +type fakeRegionResolver struct { + regions map[string]string + err error +} + +func (g fakeRegionResolver) ResolveRegionCodeByCountryCode(_ context.Context, sysOrigin string, countryCode string) (string, error) { + if g.err != nil { + return "", g.err + } + key := strings.ToUpper(strings.TrimSpace(sysOrigin)) + "|" + strings.ToUpper(strings.TrimSpace(countryCode)) + return strings.TrimSpace(g.regions[key]), nil +} diff --git a/internal/service/regionimgroup/types.go b/internal/service/regionimgroup/types.go index 22b83f3..40359da 100644 --- a/internal/service/regionimgroup/types.go +++ b/internal/service/regionimgroup/types.go @@ -36,18 +36,28 @@ type imGateway interface { SendCustomGroupMessage(ctx context.Context, groupID string, body any) error } -// Service 负责区域通用 IM 群组创建、查询和区域广播。 -type Service struct { - cfg config.Config - db dbHandle - im imGateway +type regionResolver interface { + ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) } -func NewService(cfg config.Config, db dbHandle) *Service { +// Service 负责区域通用 IM 群组创建、查询和区域广播。 +type Service struct { + cfg config.Config + db dbHandle + im imGateway + regionResolver regionResolver +} + +func NewService(cfg config.Config, db dbHandle, resolvers ...regionResolver) *Service { + var resolver regionResolver + if len(resolvers) > 0 { + resolver = resolvers[0] + } return &Service{ - cfg: cfg, - db: db, - im: tencentim.NewClient(cfg.TencentIM), + cfg: cfg, + db: db, + im: tencentim.NewClient(cfg.TencentIM), + regionResolver: resolver, } } diff --git a/internal/service/voiceroomredpacket/presence.go b/internal/service/voiceroomredpacket/presence.go index c2b43ef..5171aaf 100644 --- a/internal/service/voiceroomredpacket/presence.go +++ b/internal/service/voiceroomredpacket/presence.go @@ -108,6 +108,15 @@ func (s *Service) resolveUserRegion(ctx context.Context, sysOrigin string, userI return "", NewAppError(http.StatusBadRequest, "voice_room_region_missing", "user country code is missing") } + if s != nil && s.regionResolver != nil { + regionCode, err := s.regionResolver.ResolveRegionCodeByCountryCode(ctx, sysOrigin, countryCode) + if err == nil { + if normalized := strings.ToUpper(strings.TrimSpace(regionCode)); normalized != "" { + return normalized, nil + } + } + } + var mapping model.VoiceRoomRegionCountry err = s.db.WithContext(ctx). Where("sys_origin = ? AND country_code = ? AND enabled = ?", sysOrigin, countryCode, true). diff --git a/internal/service/voiceroomredpacket/service_test.go b/internal/service/voiceroomredpacket/service_test.go index 9d07bc8..b7a840a 100644 --- a/internal/service/voiceroomredpacket/service_test.go +++ b/internal/service/voiceroomredpacket/service_test.go @@ -176,6 +176,38 @@ func TestDelayedPacketSendsRegionIMBroadcastOnly(t *testing.T) { } } +func TestResolveUserRegionPrefersJavaRegionResolver(t *testing.T) { + service, _, _ := newVoiceRoomRedPacketTestService(t) + service.SetRegionResolver(fakeRegionResolver{ + regions: map[string]string{"LIKEI|SA": "中东区"}, + }) + fakeRegionIM := &fakeRegionIMGateway{} + service.regionIM = fakeRegionIM + ctx := context.Background() + sender := AuthUser{UserID: 1001, SysOrigin: "LIKEI"} + presence, err := service.Heartbeat(ctx, sender, PresenceRequest{RoomID: 9001}) + if err != nil { + t.Fatalf("Heartbeat() error = %v", err) + } + if presence.RegionCode != "中东区" { + t.Fatalf("presence region = %q, want Java region", presence.RegionCode) + } + + _, err = service.Send(ctx, sender, SendRequest{ + RequestID: "req-delayed-java-region", + RoomID: 9001, + PacketMode: packetModeDelayed, + TotalAmount: 15000, + TotalCount: 5, + }) + if err != nil { + t.Fatalf("Send() error = %v", err) + } + if len(fakeRegionIM.sent) != 1 || fakeRegionIM.sent[0].regionCode != "中东区" { + t.Fatalf("sent region messages = %+v, want one Java-region message", fakeRegionIM.sent) + } +} + func TestImmediatePacketSendsRoomIMMessage(t *testing.T) { service, _, _ := newVoiceRoomRedPacketTestService(t) fakeIM := &fakeIMGateway{} @@ -410,6 +442,19 @@ func (g *fakeRegionIMGateway) SendRegionCustomMessage(_ context.Context, sysOrig return "@region-" + strings.ToLower(regionCode), nil } +type fakeRegionResolver struct { + regions map[string]string + err error +} + +func (g fakeRegionResolver) ResolveRegionCodeByCountryCode(_ context.Context, sysOrigin string, countryCode string) (string, error) { + if g.err != nil { + return "", g.err + } + key := strings.ToUpper(strings.TrimSpace(sysOrigin)) + "|" + strings.ToUpper(strings.TrimSpace(countryCode)) + return strings.TrimSpace(g.regions[key]), nil +} + type fakeWalletGateway struct { balance map[int64]int64 events map[string]struct{} diff --git a/internal/service/voiceroomredpacket/types.go b/internal/service/voiceroomredpacket/types.go index 32b684e..9db2082 100644 --- a/internal/service/voiceroomredpacket/types.go +++ b/internal/service/voiceroomredpacket/types.go @@ -96,16 +96,21 @@ type roomProfileGateway interface { MapRoomProfiles(ctx context.Context, roomIDs []int64) (map[int64]integration.RoomProfile, error) } +type regionResolver interface { + ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) +} + // Service 承接语音房红包配置、发送、领取、在线态和退款补偿。 type Service struct { - cfg config.Config - db voiceRoomRedPacketDB - redis redis.Cmdable - wallet walletGateway - im imGateway - regionIM regionIMGateway - roomProfiles roomProfileGateway - notifier *redisNotifier + cfg config.Config + db voiceRoomRedPacketDB + redis redis.Cmdable + wallet walletGateway + im imGateway + regionIM regionIMGateway + roomProfiles roomProfileGateway + regionResolver regionResolver + notifier *redisNotifier } func NewService(cfg config.Config, db voiceRoomRedPacketDB, cache redis.Cmdable, wallet walletGateway, regionIM regionIMGateway, roomProfiles ...roomProfileGateway) *Service { @@ -124,6 +129,12 @@ func NewService(cfg config.Config, db voiceRoomRedPacketDB, cache redis.Cmdable, return service } +func (s *Service) SetRegionResolver(resolver regionResolver) { + if s != nil { + s.regionResolver = resolver + } +} + type ConfigResponse struct { Configured bool `json:"configured"` ID int64 `json:"id,string"`