diff --git a/services/gateway-service/internal/transport/http/appapi/app_config_handler.go b/services/gateway-service/internal/transport/http/appapi/app_config_handler.go index c1fbf245..e48d160d 100644 --- a/services/gateway-service/internal/transport/http/appapi/app_config_handler.go +++ b/services/gateway-service/internal/transport/http/appapi/app_config_handler.go @@ -10,6 +10,8 @@ import ( "hyapp/services/gateway-service/internal/appconfig" "hyapp/services/gateway-service/internal/auth" "hyapp/services/gateway-service/internal/transport/http/httpkit" + + userv1 "hyapp.local/api/proto/user/v1" ) type appBootstrapBottomTab struct { @@ -128,13 +130,16 @@ func (h *Handler) listAppBanners(writer http.ResponseWriter, request *http.Reque return } - items, err := h.appConfigReader.ListBanners(request.Context(), appconfig.BannerQuery{ + query := appconfig.BannerQuery{ AppCode: appcode.FromContext(request.Context()), DisplayScope: displayScope, Platform: httpkit.FirstQueryOrHeader(request, "platform", "X-App-Platform", "X-Platform"), - RegionID: optionalInt64Query(request, "region_id"), - Country: httpkit.FirstQueryOrHeader(request, "country", "X-Country-Code", "X-App-Country"), - }) + } + if !h.applyAuthenticatedBannerAudience(writer, request, &query) { + return + } + + items, err := h.appConfigReader.ListBanners(request.Context(), query) if err != nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return @@ -143,6 +148,44 @@ func (h *Handler) listAppBanners(writer http.ResponseWriter, request *http.Reque httpkit.WriteOK(writer, request, map[string]any{"items": items, "total": len(items)}) } +func (h *Handler) applyAuthenticatedBannerAudience(writer http.ResponseWriter, request *http.Request, query *appconfig.BannerQuery) bool { + viewerUserID := auth.UserIDFromContext(request.Context()) + if viewerUserID == 0 { + // banner 是公开接口,但未登录请求不能用 region_id/country 伪装区域身份;空区域只会命中全局 banner。 + return true + } + if h.userProfileClient == nil { + // 登录态区域必须以 user-service 的用户事实为准,缺少依赖时不能退回客户端传参。 + httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") + return false + } + + resp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{ + Meta: httpkit.UserMeta(request, ""), + UserId: viewerUserID, + }) + if err != nil { + // user-service 查询失败时返回上游错误,避免用客户端 region_id/country 打开错误区域的 banner。 + httpkit.WriteRPCError(writer, request, err) + return false + } + user := resp.GetUser() + if user == nil { + httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") + return false + } + if user.GetAppCode() != "" && appcode.Normalize(user.GetAppCode()) != query.AppCode { + // token 所属租户和用户资料租户不一致时拒绝返回区域配置,防止跨 App 读取 banner 策略。 + httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied") + return false + } + + // 已登录用户的区域由后端用户资料覆盖;客户端保留 region_id/country 只做老版本参数兼容,不参与裁决。 + query.RegionID = user.GetRegionId() + query.Country = strings.TrimSpace(user.GetCountry()) + return true +} + // listSplashScreens 返回后台 APP配置/开屏配置 中当前可见的开屏列表;客户端按列表顺序取最高优先级配置展示。 func (h *Handler) listSplashScreens(writer http.ResponseWriter, request *http.Request) { if h.appConfigReader == nil { diff --git a/services/gateway-service/internal/transport/http/appapi/handler.go b/services/gateway-service/internal/transport/http/appapi/handler.go index 6a249901..7fb2c1f6 100644 --- a/services/gateway-service/internal/transport/http/appapi/handler.go +++ b/services/gateway-service/internal/transport/http/appapi/handler.go @@ -28,25 +28,28 @@ type ObjectUploader interface { // Handler owns app platform endpoints such as bootstrap, config, upload and device token binding. // It does not own user identity or resource semantics; those remain in their owner services/packages. type Handler struct { - appConfigReader ConfigReader - userAuthClient client.UserAuthClient - userDeviceClient client.UserDeviceClient - objectUploader ObjectUploader + appConfigReader ConfigReader + userAuthClient client.UserAuthClient + userProfileClient client.UserProfileClient + userDeviceClient client.UserDeviceClient + objectUploader ObjectUploader } type Config struct { - AppConfigReader ConfigReader - UserAuthClient client.UserAuthClient - UserDeviceClient client.UserDeviceClient - ObjectUploader ObjectUploader + AppConfigReader ConfigReader + UserAuthClient client.UserAuthClient + UserProfileClient client.UserProfileClient + UserDeviceClient client.UserDeviceClient + ObjectUploader ObjectUploader } func New(config Config) *Handler { return &Handler{ - appConfigReader: config.AppConfigReader, - userAuthClient: config.UserAuthClient, - userDeviceClient: config.UserDeviceClient, - objectUploader: config.ObjectUploader, + appConfigReader: config.AppConfigReader, + userAuthClient: config.UserAuthClient, + userProfileClient: config.UserProfileClient, + userDeviceClient: config.UserDeviceClient, + objectUploader: config.ObjectUploader, } } diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index ebd17d00..e070c67a 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -3787,7 +3787,8 @@ func (r *captureAppConfigReader) ListPopups(ctx context.Context, query appconfig } func TestListAppBannersReturnsAdminAppConfig(t *testing.T) { - handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) + profileClient := &fakeUserProfileClient{} + handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient) reader := &captureAppConfigReader{StaticReader: appconfig.StaticReader{Banners: []appconfig.Banner{ { ID: 8, @@ -3799,8 +3800,6 @@ func TestListAppBannersReturnsAdminAppConfig(t *testing.T) { Param: "https://h5.example.com/activity", Platform: "android", SortOrder: 10, - RegionID: 1, - CountryCode: "CN", Description: "room banner", StartsAtMs: 1700000000000, EndsAtMs: 1800000000000, @@ -3827,9 +3826,12 @@ func TestListAppBannersReturnsAdminAppConfig(t *testing.T) { if response.Code != httpkit.CodeOK || !ok { t.Fatalf("unexpected envelope: %+v", response) } - if reader.bannerQuery.DisplayScope != "me" || reader.bannerQuery.Platform != "android" || reader.bannerQuery.RegionID != 1 || reader.bannerQuery.Country != "CN" { + if reader.bannerQuery.DisplayScope != "me" || reader.bannerQuery.Platform != "android" || reader.bannerQuery.RegionID != 0 || reader.bannerQuery.Country != "" { t.Fatalf("banner query mismatch: %+v", reader.bannerQuery) } + if profileClient.lastGet != nil { + t.Fatalf("anonymous banner request must not query user profile: %+v", profileClient.lastGet) + } if data["total"].(float64) != 1 { t.Fatalf("banner total mismatch: %+v", data) } @@ -3853,6 +3855,53 @@ func TestListAppBannersReturnsAdminAppConfig(t *testing.T) { } } +func TestListAppBannersUsesAuthenticatedUserRegion(t *testing.T) { + profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{ + 42: { + UserId: 42, + AppCode: "lalu", + RegionId: 1, + Country: "AE", + }, + }} + handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient) + reader := &captureAppConfigReader{StaticReader: appconfig.StaticReader{Banners: []appconfig.Banner{ + { + ID: 16, + CoverURL: "https://cdn.example.com/middle-east-banner.png", + BannerType: "h5", + DisplayScope: "home", + Param: "https://h5.example.com/middle-east", + Platform: "android", + SortOrder: 1, + RegionID: 1, + CountryCode: "AE", + }, + }}} + handler.SetAppConfigReader(reader) + router := handler.Routes(auth.NewVerifier("secret")) + request := httptest.NewRequest(http.MethodGet, "/api/v1/app/banners?display_scope=home&platform=android&country=US®ion_id=999", nil) + request.Header.Set("X-Request-ID", "req-app-banners-auth") + request.Header.Set("X-App-Code", "lalu") + request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) + } + if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 42 { + t.Fatalf("user profile request mismatch: %+v", profileClient.lastGet) + } + if profileClient.lastGet.GetMeta().GetAppCode() != "lalu" { + t.Fatalf("user profile app_code mismatch: %+v", profileClient.lastGet.GetMeta()) + } + if reader.bannerQuery.DisplayScope != "home" || reader.bannerQuery.Platform != "android" || reader.bannerQuery.RegionID != 1 || reader.bannerQuery.Country != "AE" { + t.Fatalf("banner query mismatch: %+v", reader.bannerQuery) + } +} + func TestListSplashScreensReturnsAdminAppConfig(t *testing.T) { handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) reader := &captureAppConfigReader{StaticReader: appconfig.StaticReader{SplashScreens: []appconfig.SplashScreen{ diff --git a/services/gateway-service/internal/transport/http/router.go b/services/gateway-service/internal/transport/http/router.go index d64eb78d..7af7aa05 100644 --- a/services/gateway-service/internal/transport/http/router.go +++ b/services/gateway-service/internal/transport/http/router.go @@ -107,10 +107,11 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler { GrowthLevelClient: h.growthLevelClient, }) appAPI := appapi.New(appapi.Config{ - AppConfigReader: h.appConfigReader, - UserAuthClient: h.userClient, - UserDeviceClient: h.userDeviceClient, - ObjectUploader: h.objectUploader, + AppConfigReader: h.appConfigReader, + UserAuthClient: h.userClient, + UserProfileClient: h.userProfileClient, + UserDeviceClient: h.userDeviceClient, + ObjectUploader: h.objectUploader, }) callbackAPI := callbackapi.New(callbackapi.Config{ RoomClient: h.roomClient,