diff --git a/docs/flutter对接/开屏配置Flutter对接.md b/docs/flutter对接/开屏配置Flutter对接.md new file mode 100644 index 00000000..10e920fe --- /dev/null +++ b/docs/flutter对接/开屏配置Flutter对接.md @@ -0,0 +1,69 @@ +# 开屏配置 Flutter 对接 + +## 地址 + +`GET /api/v1/app/splash-screens` + +公开接口,不需要登录。返回后台 `APP配置 -> 开屏配置` 中当前有效的开屏列表。 + +## 参数 + +| 参数 | 位置 | 必填 | 说明 | +| --- | --- | --- | --- | +| `X-App-Code` | Header | 否 | App 编码,默认 `lalu`。 | +| `X-App-Package` | Header | 否 | 包名或 Bundle ID,用于服务端解析 App。 | +| `platform` | Query | 否 | `android` 或 `ios`。不传时读取 `X-App-Platform` / `X-Platform`。 | +| `region_id` | Query | 否 | 当前用户区域 ID。 | +| `country` | Query | 否 | 当前用户国家码。不传时读取 `X-Country-Code` / `X-App-Country`。 | + +示例: + +```http +GET /api/v1/app/splash-screens?platform=android®ion_id=1&country=CN +X-App-Code: lalu +X-App-Platform: android +``` + +## 返回值 + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_abc", + "data": { + "items": [ + { + "id": 12, + "cover_url": "https://cdn.example.com/splash.png", + "type": "h5", + "param": "https://h5.example.com/splash", + "platform": "android", + "sort_order": 1, + "region_id": 1, + "country_code": "CN", + "description": "活动开屏", + "starts_at_ms": 1700000000000, + "ends_at_ms": 1800000000000, + "updated_at_ms": 1700000003000 + } + ], + "total": 1 + } +} +``` + +| 字段 | 说明 | +| --- | --- | +| `items` | 当前可展示的开屏配置列表,已按 `sort_order ASC, id DESC` 排序。 | +| `cover_url` | 开屏图片地址。 | +| `type` | `h5` 或 `app`。 | +| `param` | `type=h5` 时是 H5 链接;`type=app` 时由客户端解释。 | +| `platform` | `android` 或 `ios`。 | +| `region_id` | 配置限制的区域 ID;为空或 0 表示全部区域。 | +| `country_code` | 配置限制的国家码;为空表示全部国家。 | +| `starts_at_ms` / `ends_at_ms` | 投放时间。0 表示不限。 | + +## 相关 IM + +无。 diff --git a/docs/openapi/gateway.swagger.yaml b/docs/openapi/gateway.swagger.yaml index ce54b195..e8359b26 100644 --- a/docs/openapi/gateway.swagger.yaml +++ b/docs/openapi/gateway.swagger.yaml @@ -393,6 +393,47 @@ paths: $ref: "#/definitions/H5LinkListEnvelope" "502": $ref: "#/responses/UpstreamError" + /api/v1/app/splash-screens: + get: + tags: + - app-config + summary: 获取 App 开屏配置 + operationId: listSplashScreens + description: 公开读接口,返回后台 APP配置/开屏配置 中当前有效的开屏列表,客户端按列表顺序取最高优先级配置展示。 + parameters: + - name: platform + in: query + required: false + type: string + enum: [android, ios] + description: 可选平台覆盖值;不传时读取 `X-App-Platform` / `X-Platform`。 + - name: region_id + in: query + required: false + type: integer + format: int64 + description: 当前用户区域 ID;0 或不传只命中全区域配置。 + - name: country + in: query + required: false + type: string + description: 当前用户国家码;不传时读取 `X-Country-Code` / `X-App-Country`。 + - name: X-App-Platform + in: header + required: false + type: string + enum: [android, ios] + - name: X-Country-Code + in: header + required: false + type: string + responses: + "200": + description: 查询成功,`data.items` 返回当前可展示的开屏配置。 + schema: + $ref: "#/definitions/SplashScreenListEnvelope" + "502": + $ref: "#/responses/UpstreamError" /api/v1/im/usersig: get: tags: @@ -3278,6 +3319,53 @@ definitions: total: type: integer format: int32 + SplashScreenData: + type: object + properties: + id: + type: integer + format: uint64 + cover_url: + type: string + description: 开屏图片 URL。 + type: + type: string + enum: [h5, app] + description: h5 表示 param 是 H5 链接;app 表示 param 由客户端解释。 + param: + type: string + platform: + type: string + enum: [android, ios] + sort_order: + type: integer + format: int32 + region_id: + type: integer + format: int64 + country_code: + type: string + description: + type: string + starts_at_ms: + type: integer + format: int64 + ends_at_ms: + type: integer + format: int64 + updated_at_ms: + type: integer + format: int64 + SplashScreenListData: + type: object + properties: + items: + type: array + items: + $ref: "#/definitions/SplashScreenData" + total: + type: integer + format: int32 AppBootstrapTab: type: object properties: @@ -5121,6 +5209,13 @@ definitions: properties: data: $ref: "#/definitions/H5LinkListData" + SplashScreenListEnvelope: + allOf: + - $ref: "#/definitions/GatewayOKEnvelopeBase" + - type: object + properties: + data: + $ref: "#/definitions/SplashScreenListData" ResourceListEnvelope: allOf: - $ref: "#/definitions/GatewayOKEnvelopeBase" diff --git a/docs/接口清单.md b/docs/接口清单.md index 9a00a3cf..f637567a 100644 --- a/docs/接口清单.md +++ b/docs/接口清单.md @@ -18,6 +18,7 @@ | GET | `/api/v1/countries` | countries | `listRegistrationCountries` | 获取 App 注册页可选国家 | | GET | `/api/v1/app/bootstrap` | app-config | `getAppBootstrap` | 获取 App 启动配置摘要 | | GET | `/api/v1/app/h5-links` | app-config | `listAppH5Links` | 获取 App H5 入口地址 | +| GET | `/api/v1/app/splash-screens` | app-config | `listSplashScreens` | 获取 App 开屏配置 | | POST | `/api/v1/app/heartbeat` | app | `appHeartbeat` | 刷新当前 App 登录会话心跳 | | GET | `/api/v1/im/usersig` | tencent-im | `issueTencentIMUserSig` | 获取腾讯云 IM UserSig | | POST | `/api/v1/rtc/token` | tencent-rtc | `issueTencentRTCToken` | 获取腾讯 RTC 进房 UserSig | @@ -122,6 +123,10 @@ | POST | `/api/v1/admin/app-config/banners` | admin | `createBanner` | createBanner | | DELETE | `/api/v1/admin/app-config/banners/{banner_id}` | admin | `deleteBanner` | deleteBanner | | PUT | `/api/v1/admin/app-config/banners/{banner_id}` | admin | `updateBanner` | updateBanner | +| GET | `/api/v1/admin/app-config/splash-screens` | admin | `listSplashScreens` | listSplashScreens | +| POST | `/api/v1/admin/app-config/splash-screens` | admin | `createSplashScreen` | createSplashScreen | +| DELETE | `/api/v1/admin/app-config/splash-screens/{splash_id}` | admin | `deleteSplashScreen` | deleteSplashScreen | +| PUT | `/api/v1/admin/app-config/splash-screens/{splash_id}` | admin | `updateSplashScreen` | updateSplashScreen | | GET | `/api/v1/admin/app-config/h5-links` | admin | `listH5Links` | listH5Links | | PUT | `/api/v1/admin/app-config/h5-links` | admin | `updateH5Links` | updateH5Links | | GET | `/api/v1/admin/apps` | admin | `listApps` | listApps | diff --git a/server/admin/internal/model/models.go b/server/admin/internal/model/models.go index b654bcec..7a158904 100644 --- a/server/admin/internal/model/models.go +++ b/server/admin/internal/model/models.go @@ -115,6 +115,28 @@ func (AppBanner) TableName() string { return "admin_app_banners" } +type AppSplashScreen struct { + ID uint `gorm:"primaryKey" json:"id"` + AppCode string `gorm:"size:32;index:idx_admin_app_splash_screens_app_sort,not null;default:lalu" json:"appCode"` + CoverURL string `gorm:"size:1024;not null" json:"coverUrl"` + SplashType string `gorm:"size:16;not null" json:"splashType"` + Param string `gorm:"size:2048" json:"param"` + Status string `gorm:"size:24;index:idx_admin_app_splash_screens_app_sort,not null;default:active" json:"status"` + Platform string `gorm:"size:24;index:idx_admin_app_splash_screens_scope,not null" json:"platform"` + SortOrder int `gorm:"index:idx_admin_app_splash_screens_app_sort,not null;default:0" json:"sortOrder"` + RegionID int64 `gorm:"index:idx_admin_app_splash_screens_scope,not null;default:0" json:"regionId"` + CountryCode string `gorm:"size:8;index:idx_admin_app_splash_screens_scope" json:"countryCode"` + Description string `gorm:"size:255" json:"description"` + StartsAtMS int64 `gorm:"column:starts_at_ms;not null;default:0;comment:投放开始时间,UTC epoch ms" json:"startsAtMs"` + EndsAtMS int64 `gorm:"column:ends_at_ms;not null;default:0;comment:投放结束时间,UTC epoch ms" json:"endsAtMs"` + CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"` + UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"` +} + +func (AppSplashScreen) TableName() string { + return "admin_app_splash_screens" +} + type AppVersion struct { ID uint `gorm:"primaryKey" json:"id"` AppCode string `gorm:"size:32;uniqueIndex:uk_admin_app_versions_build,not null;default:lalu" json:"appCode"` diff --git a/server/admin/internal/modules/appconfig/handler.go b/server/admin/internal/modules/appconfig/handler.go index 2a5551d2..f748a7b8 100644 --- a/server/admin/internal/modules/appconfig/handler.go +++ b/server/admin/internal/modules/appconfig/handler.go @@ -160,6 +160,69 @@ func (h *Handler) DeleteBanner(c *gin.Context) { response.OK(c, gin.H{"deleted": true}) } +func (h *Handler) ListSplashScreens(c *gin.Context) { + options := shared.ListOptions(c) + items, err := h.service.ListSplashScreens(appctx.FromContext(c.Request.Context()), repository.AppSplashScreenListOptions{ + Keyword: options.Keyword, + Status: options.Status, + Platform: strings.TrimSpace(c.Query("platform")), + RegionID: parseOptionalInt64(c.Query("regionId"), c.Query("region_id")), + Country: firstQuery(c, "countryCode", "country_code", "country"), + }) + if err != nil { + response.ServerError(c, "获取开屏配置失败") + return + } + response.OK(c, gin.H{"items": items, "total": len(items)}) +} + +func (h *Handler) CreateSplashScreen(c *gin.Context) { + var request splashScreenRequest + if err := c.ShouldBindJSON(&request); err != nil { + response.BadRequest(c, "参数不正确") + return + } + item, err := h.service.CreateSplashScreen(appctx.FromContext(c.Request.Context()), request) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + shared.OperationLog(c, h.audit, "create-app-splash-screen", "admin_app_splash_screens", "success", fmt.Sprintf("splash_id=%d", item.ID)) + response.Created(c, item) +} + +func (h *Handler) UpdateSplashScreen(c *gin.Context) { + id, ok := splashScreenID(c) + if !ok { + return + } + var request splashScreenRequest + if err := c.ShouldBindJSON(&request); err != nil { + response.BadRequest(c, "参数不正确") + return + } + item, err := h.service.UpdateSplashScreen(appctx.FromContext(c.Request.Context()), id, request) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + shared.OperationLog(c, h.audit, "update-app-splash-screen", "admin_app_splash_screens", "success", fmt.Sprintf("splash_id=%d", item.ID)) + response.OK(c, item) +} + +func (h *Handler) DeleteSplashScreen(c *gin.Context) { + id, ok := splashScreenID(c) + if !ok { + return + } + if err := h.service.DeleteSplashScreen(appctx.FromContext(c.Request.Context()), id); err != nil { + response.BadRequest(c, err.Error()) + return + } + shared.OperationLog(c, h.audit, "delete-app-splash-screen", "admin_app_splash_screens", "success", fmt.Sprintf("splash_id=%d", id)) + response.OK(c, gin.H{"deleted": true}) +} + func (h *Handler) ListAppVersions(c *gin.Context) { items, err := h.service.ListAppVersions(appctx.FromContext(c.Request.Context()), repository.AppVersionListOptions{ Keyword: strings.TrimSpace(c.Query("keyword")), @@ -297,6 +360,15 @@ func bannerID(c *gin.Context) (uint, bool) { return uint(value), true } +func splashScreenID(c *gin.Context) (uint, bool) { + value, err := strconv.ParseUint(c.Param("splash_id"), 10, 64) + if err != nil || value == 0 { + response.BadRequest(c, "splash screen id is invalid") + return 0, false + } + return uint(value), true +} + func appVersionID(c *gin.Context) (uint, bool) { value, err := strconv.ParseUint(c.Param("version_id"), 10, 64) if err != nil || value == 0 { diff --git a/server/admin/internal/modules/appconfig/request.go b/server/admin/internal/modules/appconfig/request.go index bff655ab..63dfb1a3 100644 --- a/server/admin/internal/modules/appconfig/request.go +++ b/server/admin/internal/modules/appconfig/request.go @@ -27,6 +27,20 @@ type bannerRequest struct { EndsAtMs int64 `json:"endsAtMs"` } +type splashScreenRequest struct { + CoverURL string `json:"coverUrl" binding:"required"` + SplashType string `json:"splashType" binding:"required"` + Param string `json:"param"` + Status string `json:"status" binding:"required"` + Platform string `json:"platform" binding:"required"` + SortOrder int `json:"sortOrder"` + RegionID int64 `json:"regionId"` + CountryCode string `json:"countryCode"` + Description string `json:"description"` + StartsAtMs int64 `json:"startsAtMs"` + EndsAtMs int64 `json:"endsAtMs"` +} + type appVersionRequest struct { Platform string `json:"platform" binding:"required"` Version string `json:"version" binding:"required"` diff --git a/server/admin/internal/modules/appconfig/routes.go b/server/admin/internal/modules/appconfig/routes.go index b36595cb..c112af9f 100644 --- a/server/admin/internal/modules/appconfig/routes.go +++ b/server/admin/internal/modules/appconfig/routes.go @@ -27,6 +27,11 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) { protected.PUT("/admin/app-config/banners/:banner_id", middleware.RequirePermission("app-config:update"), h.UpdateBanner) protected.DELETE("/admin/app-config/banners/:banner_id", middleware.RequirePermission("app-config:update"), h.DeleteBanner) + protected.GET("/admin/app-config/splash-screens", middleware.RequirePermission("app-config:view"), h.ListSplashScreens) + protected.POST("/admin/app-config/splash-screens", middleware.RequirePermission("app-config:update"), h.CreateSplashScreen) + protected.PUT("/admin/app-config/splash-screens/:splash_id", middleware.RequirePermission("app-config:update"), h.UpdateSplashScreen) + protected.DELETE("/admin/app-config/splash-screens/:splash_id", middleware.RequirePermission("app-config:update"), h.DeleteSplashScreen) + protected.GET("/admin/app-config/versions", middleware.RequirePermission("app-version:view"), h.ListAppVersions) protected.POST("/admin/app-config/versions", middleware.RequirePermission("app-version:create"), h.CreateAppVersion) protected.PUT("/admin/app-config/versions/:version_id", middleware.RequirePermission("app-version:update"), h.UpdateAppVersion) diff --git a/server/admin/internal/modules/appconfig/service.go b/server/admin/internal/modules/appconfig/service.go index b9e35fff..1ae84b97 100644 --- a/server/admin/internal/modules/appconfig/service.go +++ b/server/admin/internal/modules/appconfig/service.go @@ -59,6 +59,24 @@ type AppBanner struct { UpdatedAtMs int64 `json:"updatedAtMs"` } +type AppSplashScreen struct { + ID uint `json:"id"` + AppCode string `json:"appCode"` + CoverURL string `json:"coverUrl"` + SplashType string `json:"splashType"` + Param string `json:"param"` + Status string `json:"status"` + Platform string `json:"platform"` + SortOrder int `json:"sortOrder"` + RegionID int64 `json:"regionId"` + CountryCode string `json:"countryCode"` + Description string `json:"description"` + StartsAtMs int64 `json:"startsAtMs"` + EndsAtMs int64 `json:"endsAtMs"` + CreatedAtMs int64 `json:"createdAtMs"` + UpdatedAtMs int64 `json:"updatedAtMs"` +} + type AppVersion struct { ID uint `json:"id"` AppCode string `json:"appCode"` @@ -225,6 +243,70 @@ func (s *AppConfigService) DeleteBanner(appCode string, id uint) error { return s.store.DeleteAppBanner(appctx.Normalize(appCode), id) } +func (s *AppConfigService) ListSplashScreens(appCode string, options repository.AppSplashScreenListOptions) ([]AppSplashScreen, error) { + // 列表入口先把管理后台传入的筛选条件统一成数据库稳定值,再执行过期回收,避免已过期 active 数据继续展示。 + options.AppCode = appctx.Normalize(appCode) + options.Status = normalizeBannerStatus(options.Status) + options.Platform = normalizeBannerPlatform(options.Platform) + options.Country = normalizeCountryCode(options.Country) + options.Keyword = strings.TrimSpace(options.Keyword) + if err := s.store.ExpireAppSplashScreens(options.AppCode, time.Now().UTC().UnixMilli()); err != nil { + return nil, err + } + items, err := s.store.ListAppSplashScreens(options) + if err != nil { + return nil, err + } + out := make([]AppSplashScreen, 0, len(items)) + for _, item := range items { + out = append(out, appSplashScreenFromModel(item)) + } + return out, nil +} + +func (s *AppConfigService) CreateSplashScreen(appCode string, req splashScreenRequest) (AppSplashScreen, error) { + // 创建时只接受 service 归一化后的模型,保证 status、平台、国家码和时间窗口在入库前已经被校验。 + item, err := splashScreenModelFromRequest(appctx.Normalize(appCode), req) + if err != nil { + return AppSplashScreen{}, err + } + if err := s.store.CreateAppSplashScreen(&item); err != nil { + return AppSplashScreen{}, err + } + return appSplashScreenFromModel(item), nil +} + +func (s *AppConfigService) UpdateSplashScreen(appCode string, id uint, req splashScreenRequest) (AppSplashScreen, error) { + // 更新先按 app_code 和 id 锁定原记录,再只覆盖业务字段,避免请求体伪造 app_code 或创建时间。 + item, err := s.store.GetAppSplashScreen(appctx.Normalize(appCode), id) + if err != nil { + return AppSplashScreen{}, err + } + updated, err := splashScreenModelFromRequest(item.AppCode, req) + if err != nil { + return AppSplashScreen{}, err + } + item.CoverURL = updated.CoverURL + item.SplashType = updated.SplashType + item.Param = updated.Param + item.Status = updated.Status + item.Platform = updated.Platform + item.SortOrder = updated.SortOrder + item.RegionID = updated.RegionID + item.CountryCode = updated.CountryCode + item.Description = updated.Description + item.StartsAtMS = updated.StartsAtMS + item.EndsAtMS = updated.EndsAtMS + if err := s.store.UpdateAppSplashScreen(&item); err != nil { + return AppSplashScreen{}, err + } + return appSplashScreenFromModel(item), nil +} + +func (s *AppConfigService) DeleteSplashScreen(appCode string, id uint) error { + return s.store.DeleteAppSplashScreen(appctx.Normalize(appCode), id) +} + func (s *AppConfigService) ListAppVersions(appCode string, options repository.AppVersionListOptions) ([]AppVersion, error) { options.AppCode = appctx.Normalize(appCode) options.Platform = normalizeAppPlatform(options.Platform) @@ -495,6 +577,91 @@ func appBannerFromModel(item model.AppBanner) AppBanner { } } +func splashScreenModelFromRequest(appCode string, req splashScreenRequest) (model.AppSplashScreen, error) { + item := model.AppSplashScreen{ + AppCode: appctx.Normalize(appCode), + CoverURL: strings.TrimSpace(req.CoverURL), + SplashType: normalizeSplashType(req.SplashType), + Param: strings.TrimSpace(req.Param), + Status: normalizeBannerStatus(req.Status), + Platform: normalizeBannerPlatform(req.Platform), + SortOrder: req.SortOrder, + RegionID: req.RegionID, + CountryCode: normalizeCountryCode(req.CountryCode), + Description: strings.TrimSpace(req.Description), + StartsAtMS: req.StartsAtMs, + EndsAtMS: req.EndsAtMs, + } + if item.CoverURL == "" || len(item.CoverURL) > 1024 { + return model.AppSplashScreen{}, errors.New("splash cover is invalid") + } + if containsWhitespace(item.CoverURL) { + return model.AppSplashScreen{}, errors.New("splash cover cannot contain whitespace") + } + if item.SplashType != "h5" && item.SplashType != "app" { + return model.AppSplashScreen{}, errors.New("splash type is invalid") + } + if item.SplashType == "h5" { + // H5 开屏必须带可直接打开的链接;APP 类型参数由客户端解释,所以只做长度限制。 + if item.Param == "" { + return model.AppSplashScreen{}, errors.New("h5 link is required") + } + if err := validateH5URL(item.Param); err != nil { + return model.AppSplashScreen{}, err + } + } + if len(item.Param) > 2048 { + return model.AppSplashScreen{}, errors.New("splash param is too long") + } + if item.Status != bannerStatusActive && item.Status != bannerStatusDisabled && item.Status != bannerStatusExpired { + return model.AppSplashScreen{}, errors.New("splash status is invalid") + } + if item.StartsAtMS < 0 || item.EndsAtMS < 0 { + return model.AppSplashScreen{}, errors.New("splash delivery time is invalid") + } + if item.StartsAtMS > 0 && item.EndsAtMS > 0 && item.StartsAtMS >= item.EndsAtMS { + return model.AppSplashScreen{}, errors.New("splash delivery time range is invalid") + } + if item.Status == bannerStatusActive && item.EndsAtMS > 0 && item.EndsAtMS <= time.Now().UTC().UnixMilli() { + // 管理员保存已经结束的 active 配置时直接落为 expired,列表和 App 侧都不会误判为可投放。 + item.Status = bannerStatusExpired + } + if item.Platform != "android" && item.Platform != "ios" { + return model.AppSplashScreen{}, errors.New("splash platform is invalid") + } + if item.RegionID < 0 { + return model.AppSplashScreen{}, errors.New("splash region is invalid") + } + if item.CountryCode != "" && !validCountryCode(item.CountryCode) { + return model.AppSplashScreen{}, errors.New("splash country is invalid") + } + if utf8.RuneCountInString(item.Description) > 255 { + return model.AppSplashScreen{}, errors.New("splash description is too long") + } + return item, nil +} + +func appSplashScreenFromModel(item model.AppSplashScreen) AppSplashScreen { + // DTO 只返回后台页面需要的字段,不暴露数据库列名;gateway 另有 snake_case 的 App 输出结构。 + return AppSplashScreen{ + ID: item.ID, + AppCode: item.AppCode, + CoverURL: item.CoverURL, + SplashType: item.SplashType, + Param: item.Param, + Status: item.Status, + Platform: item.Platform, + SortOrder: item.SortOrder, + RegionID: item.RegionID, + CountryCode: item.CountryCode, + Description: item.Description, + StartsAtMs: item.StartsAtMS, + EndsAtMs: item.EndsAtMS, + CreatedAtMs: item.CreatedAtMS, + UpdatedAtMs: item.UpdatedAtMS, + } +} + func appVersionModelFromRequest(appCode string, req appVersionRequest) (model.AppVersion, error) { item := model.AppVersion{ AppCode: appctx.Normalize(appCode), @@ -580,6 +747,10 @@ func normalizeBannerType(value string) string { return strings.ToLower(strings.TrimSpace(value)) } +func normalizeSplashType(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + func normalizeBannerStatus(value string) string { value = strings.ToLower(strings.TrimSpace(value)) if value == "" { diff --git a/server/admin/internal/modules/appconfig/service_test.go b/server/admin/internal/modules/appconfig/service_test.go index 39d8d118..e33a63ef 100644 --- a/server/admin/internal/modules/appconfig/service_test.go +++ b/server/admin/internal/modules/appconfig/service_test.go @@ -104,6 +104,82 @@ func TestAppBannerFromModelReturnsDisplayScopes(t *testing.T) { } } +func TestSplashScreenModelFromRequestValidatesH5Config(t *testing.T) { + startsAtMs := time.Now().UTC().Add(time.Hour).UnixMilli() + endsAtMs := startsAtMs + int64(time.Hour/time.Millisecond) + item, err := splashScreenModelFromRequest("lalu", splashScreenRequest{ + CoverURL: "https://cdn.example.com/splash.png", + SplashType: "h5", + Param: "https://h5.example.com/splash", + Status: bannerStatusActive, + Platform: "android", + SortOrder: 7, + RegionID: 1001, + CountryCode: "us", + Description: "launch campaign", + StartsAtMs: startsAtMs, + EndsAtMs: endsAtMs, + }) + if err != nil { + t.Fatalf("splash screen model should be valid: %v", err) + } + if item.AppCode != "lalu" || item.SplashType != "h5" || item.CountryCode != "US" || item.StartsAtMS != startsAtMs || item.EndsAtMS != endsAtMs { + t.Fatalf("splash screen model mismatch: %+v", item) + } +} + +func TestSplashScreenModelFromRequestRequiresH5Link(t *testing.T) { + _, err := splashScreenModelFromRequest("lalu", splashScreenRequest{ + CoverURL: "https://cdn.example.com/splash.png", + SplashType: "h5", + Status: bannerStatusActive, + Platform: "android", + }) + if err == nil { + t.Fatal("expected h5 splash screen without link to fail") + } +} + +func TestSplashScreenModelFromRequestExpiresEndedActiveConfig(t *testing.T) { + item, err := splashScreenModelFromRequest("lalu", splashScreenRequest{ + CoverURL: "https://cdn.example.com/splash.png", + SplashType: "app", + Status: bannerStatusActive, + Platform: "ios", + EndsAtMs: 1, + }) + if err != nil { + t.Fatalf("ended app splash screen should be accepted: %v", err) + } + if item.Status != bannerStatusExpired { + t.Fatalf("ended active splash screen must become expired: %+v", item) + } +} + +func TestAppSplashScreenFromModelMapsDTOFields(t *testing.T) { + item := appSplashScreenFromModel(model.AppSplashScreen{ + ID: 9, + AppCode: "lalu", + CoverURL: "https://cdn.example.com/splash.png", + SplashType: "app", + Param: "profile", + Status: bannerStatusDisabled, + Platform: "ios", + SortOrder: 3, + RegionID: 2002, + CountryCode: "BR", + Description: "disabled campaign", + StartsAtMS: 1700000000000, + EndsAtMS: 1800000000000, + CreatedAtMS: 1690000000000, + UpdatedAtMS: 1700000002000, + }) + + if item.ID != 9 || item.SplashType != "app" || item.Param != "profile" || item.RegionID != 2002 || item.UpdatedAtMs != 1700000002000 { + t.Fatalf("splash screen dto mismatch: %+v", item) + } +} + func bannerModel(displayScope string) model.AppBanner { return model.AppBanner{ ID: 1, diff --git a/server/admin/internal/repository/app_splash_screen_repository.go b/server/admin/internal/repository/app_splash_screen_repository.go new file mode 100644 index 00000000..cd37a690 --- /dev/null +++ b/server/admin/internal/repository/app_splash_screen_repository.go @@ -0,0 +1,75 @@ +package repository + +import ( + "strings" + + "hyapp-admin-server/internal/model" +) + +type AppSplashScreenListOptions struct { + AppCode string + Keyword string + Status string + Platform string + RegionID int64 + Country string +} + +func (s *Store) ListAppSplashScreens(options AppSplashScreenListOptions) ([]model.AppSplashScreen, error) { + var items []model.AppSplashScreen + // 后台列表始终先按 app_code 隔离,再叠加可选筛选;区域 0 和国家空值在管理端表示全量配置,不需要额外匹配。 + query := s.db.Where("app_code = ?", strings.TrimSpace(options.AppCode)) + if options.Status != "" { + query = query.Where("status = ?", strings.TrimSpace(options.Status)) + } + if options.Platform != "" { + query = query.Where("platform = ?", strings.TrimSpace(options.Platform)) + } + if options.RegionID > 0 { + query = query.Where("region_id = ?", options.RegionID) + } + if options.Country != "" { + query = query.Where("country_code = ?", strings.TrimSpace(options.Country)) + } + if options.Keyword != "" { + like := "%" + strings.TrimSpace(options.Keyword) + "%" + // 关键字只查人工可识别的字段,避免把封面 URL 这类长字段带进模糊查询拖慢列表。 + query = query.Where("(description LIKE ? OR param LIKE ? OR country_code LIKE ?)", like, like, like) + } + err := query.Order("sort_order ASC, id DESC").Find(&items).Error + return items, err +} + +func (s *Store) ExpireAppSplashScreens(appCode string, nowMs int64) error { + appCode = strings.TrimSpace(appCode) + if appCode == "" || nowMs <= 0 { + return nil + } + // 过期回收只处理 active 且已到结束时间的记录,手动关闭和历史 expired 状态保持原样,方便后台审计。 + return s.db.Model(&model.AppSplashScreen{}). + Where("app_code = ? AND status = ? AND ends_at_ms > 0 AND ends_at_ms <= ?", appCode, "active", nowMs). + Updates(map[string]any{ + "status": "expired", + "updated_at_ms": nowMs, + }).Error +} + +func (s *Store) GetAppSplashScreen(appCode string, id uint) (model.AppSplashScreen, error) { + var item model.AppSplashScreen + // app_code 和 id 共同限定记录,避免多 App 后台误编辑其他租户配置。 + err := s.db.Where("app_code = ? AND id = ?", strings.TrimSpace(appCode), id).First(&item).Error + return item, err +} + +func (s *Store) CreateAppSplashScreen(item *model.AppSplashScreen) error { + return s.db.Create(item).Error +} + +func (s *Store) UpdateAppSplashScreen(item *model.AppSplashScreen) error { + return s.db.Save(item).Error +} + +func (s *Store) DeleteAppSplashScreen(appCode string, id uint) error { + // 删除沿用硬删除语义,和 Banner 配置保持一致;操作日志在 handler 层记录。 + return s.db.Where("app_code = ? AND id = ?", strings.TrimSpace(appCode), id).Delete(&model.AppSplashScreen{}).Error +} diff --git a/server/admin/internal/repository/repository.go b/server/admin/internal/repository/repository.go index 8b630098..ed4a0f08 100644 --- a/server/admin/internal/repository/repository.go +++ b/server/admin/internal/repository/repository.go @@ -64,6 +64,7 @@ func (s *Store) AutoMigrate() error { &model.Menu{}, &model.AppConfig{}, &model.AppBanner{}, + &model.AppSplashScreen{}, &model.AppVersion{}, &model.AppExploreTab{}, &model.HostAgencySalaryPolicy{}, diff --git a/server/admin/internal/repository/seed.go b/server/admin/internal/repository/seed.go index 0db4b6e0..fddeb14d 100644 --- a/server/admin/internal/repository/seed.go +++ b/server/admin/internal/repository/seed.go @@ -249,9 +249,10 @@ func (s *Store) seedMenus() error { {ParentID: &roomsID, Title: "房间配置", Code: "room-config", Path: "/rooms/config", Icon: "settings", PermissionCode: "room-config:view", Sort: 67, Visible: true}, {ParentID: &appConfigID, Title: "H5配置", Code: "app-config-h5", Path: "/app-config/h5", Icon: "settings", PermissionCode: "app-config:view", Sort: 66, Visible: true}, {ParentID: &appConfigID, Title: "BANNER配置", Code: "app-config-banners", Path: "/app-config/banners", Icon: "image", PermissionCode: "app-config:view", Sort: 67, Visible: true}, - {ParentID: &appConfigID, Title: "Explore配置", Code: "app-config-explore", Path: "/app-config/explore", Icon: "explore", PermissionCode: "app-config:view", Sort: 68, Visible: true}, - {ParentID: &appConfigID, Title: "内购配置", Code: "payment-recharge-products", Path: "/app-config/recharge-products", Icon: "wallet", PermissionCode: "payment-product:view", Sort: 69, Visible: true}, - {ParentID: &appConfigID, Title: "版本管理", Code: "app-config-versions", Path: "/app-config/versions", Icon: "settings", PermissionCode: "app-version:view", Sort: 70, Visible: true}, + {ParentID: &appConfigID, Title: "开屏配置", Code: "app-config-splash-screens", Path: "/app-config/splash-screens", Icon: "image", PermissionCode: "app-config:view", Sort: 68, Visible: true}, + {ParentID: &appConfigID, Title: "Explore配置", Code: "app-config-explore", Path: "/app-config/explore", Icon: "explore", PermissionCode: "app-config:view", Sort: 69, Visible: true}, + {ParentID: &appConfigID, Title: "内购配置", Code: "payment-recharge-products", Path: "/app-config/recharge-products", Icon: "wallet", PermissionCode: "payment-product:view", Sort: 70, Visible: true}, + {ParentID: &appConfigID, Title: "版本管理", Code: "app-config-versions", Path: "/app-config/versions", Icon: "settings", PermissionCode: "app-version:view", Sort: 71, Visible: true}, {ParentID: &resourceID, Title: "资源列表", Code: "resource-list", Path: "/resources", Icon: "inventory", PermissionCode: "resource:view", Sort: 67, Visible: true}, {ParentID: &resourceID, Title: "道具商店", Code: "resource-shop-list", Path: "/resource-shop", Icon: "storefront", PermissionCode: "resource-shop:view", Sort: 68, Visible: true}, {ParentID: &resourceID, Title: "资源组列表", Code: "resource-group-list", Path: "/resource-groups", Icon: "category", PermissionCode: "resource-group:view", Sort: 69, Visible: true}, diff --git a/server/admin/migrations/042_admin_app_splash_screens.sql b/server/admin/migrations/042_admin_app_splash_screens.sql new file mode 100644 index 00000000..47b3e356 --- /dev/null +++ b/server/admin/migrations/042_admin_app_splash_screens.sql @@ -0,0 +1,50 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- 开屏配置和 Banner 共用投放维度,但没有显示范围和房间小图;gateway 只读取当前有效窗口。 +SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED); + +CREATE TABLE IF NOT EXISTS admin_app_splash_screens ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID', + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', + cover_url VARCHAR(1024) NOT NULL COMMENT '开屏图片 URL', + splash_type VARCHAR(16) NOT NULL COMMENT '开屏跳转类型', + param VARCHAR(2048) NOT NULL DEFAULT '' COMMENT '跳转参数', + status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT '业务状态', + platform VARCHAR(24) NOT NULL COMMENT '平台', + sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越小越靠前', + region_id BIGINT NOT NULL DEFAULT 0 COMMENT '区域 ID,0 表示全部区域', + country_code VARCHAR(8) NOT NULL DEFAULT '' COMMENT '国家或地区编码,空表示全部国家', + description VARCHAR(255) NOT NULL DEFAULT '' COMMENT '描述信息', + starts_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '投放开始时间,UTC epoch ms', + ends_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '投放结束时间,UTC epoch ms', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + INDEX idx_admin_app_splash_screens_app_sort (app_code, status, sort_order, id), + INDEX idx_admin_app_splash_screens_scope (app_code, platform, region_id, country_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='后台 App 开屏配置表'; + +INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms) +SELECT parent.id, '开屏配置', 'app-config-splash-screens', '/app-config/splash-screens', 'image', 'app-config:view', 68, TRUE, @now_ms, @now_ms +FROM admin_menus parent +WHERE parent.code = 'app-config' +ON DUPLICATE KEY UPDATE + parent_id = VALUES(parent_id), + title = VALUES(title), + path = VALUES(path), + icon = VALUES(icon), + permission_code = VALUES(permission_code), + sort = VALUES(sort), + visible = VALUES(visible), + updated_at_ms = @now_ms; + +UPDATE admin_menus +SET sort = 69, updated_at_ms = @now_ms +WHERE code = 'app-config-explore' AND sort < 69; + +UPDATE admin_menus +SET sort = 70, updated_at_ms = @now_ms +WHERE code = 'payment-recharge-products' AND sort < 70; + +UPDATE admin_menus +SET sort = 71, updated_at_ms = @now_ms +WHERE code = 'app-config-versions' AND sort < 71; diff --git a/services/gateway-service/internal/appconfig/mysql.go b/services/gateway-service/internal/appconfig/mysql.go index 153a7b5a..eaded1b0 100644 --- a/services/gateway-service/internal/appconfig/mysql.go +++ b/services/gateway-service/internal/appconfig/mysql.go @@ -35,6 +35,16 @@ const listAppBannersSQL = ` AND (starts_at_ms = 0 OR starts_at_ms <= ?) AND (ends_at_ms = 0 OR ends_at_ms > ?) ORDER BY sort_order ASC, id DESC` +const listSplashScreensSQL = ` + SELECT id, app_code, cover_url, splash_type, param, platform, sort_order, region_id, country_code, description, starts_at_ms, ends_at_ms, updated_at_ms + FROM admin_app_splash_screens + WHERE app_code = ? AND status = 'active' + AND (? = '' OR platform = ?) + AND (region_id = 0 OR region_id = ?) + AND (country_code = '' OR country_code = ?) + AND (starts_at_ms = 0 OR starts_at_ms <= ?) + AND (ends_at_ms = 0 OR ends_at_ms > ?) + ORDER BY sort_order ASC, id DESC` const latestAppVersionSQL = ` SELECT id, app_code, platform, version, build_number, force_update, download_url, COALESCE(description, ''), updated_at_ms FROM admin_app_versions @@ -95,6 +105,30 @@ type Banner struct { UpdatedAtMs int64 `json:"updated_at_ms"` } +// SplashScreenQuery 是 App 开屏配置的公开筛选条件。 +type SplashScreenQuery struct { + AppCode string + Platform string + RegionID int64 + Country string +} + +// SplashScreen 是 gateway 下发给 App 的开屏配置;type 语义和 banner 保持一致,h5 参数是链接,app 参数由客户端解释。 +type SplashScreen struct { + ID uint `json:"id"` + CoverURL string `json:"cover_url"` + SplashType string `json:"type"` + Param string `json:"param"` + Platform string `json:"platform"` + SortOrder int `json:"sort_order"` + RegionID int64 `json:"region_id"` + CountryCode string `json:"country_code"` + Description string `json:"description"` + StartsAtMs int64 `json:"starts_at_ms"` + EndsAtMs int64 `json:"ends_at_ms"` + UpdatedAtMs int64 `json:"updated_at_ms"` +} + // VersionQuery 是 App 检查更新的公开筛选条件。 type VersionQuery struct { AppCode string @@ -120,6 +154,7 @@ type Reader interface { ListH5Links(ctx context.Context, query H5LinkQuery) ([]H5Link, error) ListExploreTabs(ctx context.Context, appCode string) ([]ExploreTab, error) ListBanners(ctx context.Context, query BannerQuery) ([]Banner, error) + ListSplashScreens(ctx context.Context, query SplashScreenQuery) ([]SplashScreen, error) LatestVersion(ctx context.Context, query VersionQuery) (Version, error) } @@ -283,6 +318,53 @@ func (r *MySQLReader) ListBanners(ctx context.Context, query BannerQuery) ([]Ban return items, nil } +// ListSplashScreens 返回当前 App、平台和地域可见的开屏配置;未限定平台时返回所有平台配置,客户端可按需自筛。 +func (r *MySQLReader) ListSplashScreens(ctx context.Context, query SplashScreenQuery) ([]SplashScreen, error) { + if r == nil || r.db == nil { + return nil, errors.New("app config reader is not configured") + } + + appCode := normalizeAppCode(query.AppCode) + platform := normalizePlatform(query.Platform) + country := normalizeCountry(query.Country) + nowMs := time.Now().UTC().UnixMilli() + rows, err := r.db.QueryContext(ctx, listSplashScreensSQL, appCode, platform, platform, query.RegionID, country, nowMs, nowMs) + if err != nil { + return nil, err + } + defer rows.Close() + + items := make([]SplashScreen, 0) + for rows.Next() { + var item SplashScreen + var appCode string + var updatedAtMS int64 + if err := rows.Scan( + &item.ID, + &appCode, + &item.CoverURL, + &item.SplashType, + &item.Param, + &item.Platform, + &item.SortOrder, + &item.RegionID, + &item.CountryCode, + &item.Description, + &item.StartsAtMs, + &item.EndsAtMs, + &updatedAtMS, + ); err != nil { + return nil, err + } + item.UpdatedAtMs = updatedAtMS + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + // LatestVersion 返回指定 App 和平台的最高 build_number 版本;未配置时返回空版本,方便 App 端按无更新处理。 func (r *MySQLReader) LatestVersion(ctx context.Context, query VersionQuery) (Version, error) { if r == nil || r.db == nil { @@ -315,6 +397,7 @@ type StaticReader struct { Links []H5Link ExploreTabs []ExploreTab Banners []Banner + SplashScreens []SplashScreen Version Version Err error PositionAliases map[string]string @@ -361,6 +444,17 @@ func (r StaticReader) ListBanners(context.Context, BannerQuery) ([]Banner, error return out, nil } +// ListSplashScreens 返回预置开屏配置,供 HTTP 单测和无 MySQL 场景复用同一 Reader 接口。 +func (r StaticReader) ListSplashScreens(context.Context, SplashScreenQuery) ([]SplashScreen, error) { + if r.Err != nil { + return nil, r.Err + } + + out := make([]SplashScreen, len(r.SplashScreens)) + copy(out, r.SplashScreens) + return out, nil +} + // LatestVersion 返回预置版本配置。 func (r StaticReader) LatestVersion(_ context.Context, query VersionQuery) (Version, error) { if r.Err != nil { 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 aeba5dda..5dcc899d 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 @@ -142,6 +142,27 @@ func (h *Handler) listAppBanners(writer http.ResponseWriter, request *http.Reque httpkit.WriteOK(writer, request, map[string]any{"items": items, "total": len(items)}) } +// listSplashScreens 返回后台 APP配置/开屏配置 中当前可见的开屏列表;客户端按列表顺序取最高优先级配置展示。 +func (h *Handler) listSplashScreens(writer http.ResponseWriter, request *http.Request) { + if h.appConfigReader == nil { + httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") + return + } + + items, err := h.appConfigReader.ListSplashScreens(request.Context(), appconfig.SplashScreenQuery{ + AppCode: appcode.FromContext(request.Context()), + 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 err != nil { + httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") + return + } + + httpkit.WriteOK(writer, request, map[string]any{"items": items, "total": len(items)}) +} + // getAppVersion 返回当前平台最新版本;App 用 build_number 判定是否需要提示升级。 func (h *Handler) getAppVersion(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 06fa397f..11a7f9ee 100644 --- a/services/gateway-service/internal/transport/http/appapi/handler.go +++ b/services/gateway-service/internal/transport/http/appapi/handler.go @@ -14,6 +14,7 @@ type ConfigReader interface { ListH5Links(ctx context.Context, query appconfig.H5LinkQuery) ([]appconfig.H5Link, error) ListExploreTabs(ctx context.Context, appCode string) ([]appconfig.ExploreTab, error) ListBanners(ctx context.Context, query appconfig.BannerQuery) ([]appconfig.Banner, error) + ListSplashScreens(ctx context.Context, query appconfig.SplashScreenQuery) ([]appconfig.SplashScreen, error) LatestVersion(ctx context.Context, query appconfig.VersionQuery) (appconfig.Version, error) } @@ -63,6 +64,10 @@ func (h *Handler) ListAppBanners(writer http.ResponseWriter, request *http.Reque h.listAppBanners(writer, request) } +func (h *Handler) ListSplashScreens(writer http.ResponseWriter, request *http.Request) { + h.listSplashScreens(writer, request) +} + func (h *Handler) GetAppVersion(writer http.ResponseWriter, request *http.Request) { h.getAppVersion(writer, request) } diff --git a/services/gateway-service/internal/transport/http/httproutes/router.go b/services/gateway-service/internal/transport/http/httproutes/router.go index 1b3f9382..0f444c5a 100644 --- a/services/gateway-service/internal/transport/http/httproutes/router.go +++ b/services/gateway-service/internal/transport/http/httproutes/router.go @@ -44,6 +44,7 @@ type AppHandlers struct { ListH5Links http.HandlerFunc ListExploreTabs http.HandlerFunc ListAppBanners http.HandlerFunc + ListSplashScreens http.HandlerFunc GetAppVersion http.HandlerFunc GetResourceGroup http.HandlerFunc ListResourceShopItems http.HandlerFunc @@ -308,6 +309,7 @@ func (r routes) registerAppRoutes() { r.public("/app/h5-links", "", h.ListH5Links) r.public("/app/explore-tabs", http.MethodGet, h.ListExploreTabs) r.public("/app/banners", "", h.ListAppBanners) + r.public("/app/splash-screens", http.MethodGet, h.ListSplashScreens) r.public("/app/version", http.MethodGet, h.GetAppVersion) r.public("/resource-groups/{group_id}", "", h.GetResourceGroup) r.public("/resource-shop/items", http.MethodGet, h.ListResourceShopItems) diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index 9b62eaef..de0d1471 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -3473,6 +3473,7 @@ func TestAppBootstrapIsPublicAndLightweight(t *testing.T) { type captureAppConfigReader struct { appconfig.StaticReader bannerQuery appconfig.BannerQuery + splashQuery appconfig.SplashScreenQuery } func (r *captureAppConfigReader) ListBanners(ctx context.Context, query appconfig.BannerQuery) ([]appconfig.Banner, error) { @@ -3480,6 +3481,11 @@ func (r *captureAppConfigReader) ListBanners(ctx context.Context, query appconfi return r.StaticReader.ListBanners(ctx, query) } +func (r *captureAppConfigReader) ListSplashScreens(ctx context.Context, query appconfig.SplashScreenQuery) ([]appconfig.SplashScreen, error) { + r.splashQuery = query + return r.StaticReader.ListSplashScreens(ctx, query) +} + func TestListAppBannersReturnsAdminAppConfig(t *testing.T) { handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) reader := &captureAppConfigReader{StaticReader: appconfig.StaticReader{Banners: []appconfig.Banner{ @@ -3547,6 +3553,88 @@ func TestListAppBannersReturnsAdminAppConfig(t *testing.T) { } } +func TestListSplashScreensReturnsAdminAppConfig(t *testing.T) { + handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) + reader := &captureAppConfigReader{StaticReader: appconfig.StaticReader{SplashScreens: []appconfig.SplashScreen{ + { + ID: 12, + CoverURL: "https://cdn.example.com/splash.png", + SplashType: "h5", + Param: "https://h5.example.com/splash", + Platform: "ios", + SortOrder: 2, + RegionID: 7, + CountryCode: "BR", + Description: "launch splash", + StartsAtMs: 1700000000000, + EndsAtMs: 1800000000000, + UpdatedAtMs: 1700000003000, + }, + }}} + handler.SetAppConfigReader(reader) + router := handler.Routes(auth.NewVerifier("secret")) + request := httptest.NewRequest(http.MethodGet, "/api/v1/app/splash-screens?platform=ios&country=BR®ion_id=7", nil) + request.Header.Set("X-Request-ID", "req-app-splash") + request.Header.Set("X-App-Code", "lalu") + 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()) + } + var response httpkit.ResponseEnvelope + if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { + t.Fatalf("decode response failed: %v", err) + } + data, ok := response.Data.(map[string]any) + if response.Code != httpkit.CodeOK || !ok { + t.Fatalf("unexpected envelope: %+v", response) + } + if reader.splashQuery.AppCode != "lalu" || reader.splashQuery.Platform != "ios" || reader.splashQuery.RegionID != 7 || reader.splashQuery.Country != "BR" { + t.Fatalf("splash query mismatch: %+v", reader.splashQuery) + } + if data["total"].(float64) != 1 { + t.Fatalf("splash total mismatch: %+v", data) + } + items, ok := data["items"].([]any) + if !ok || len(items) != 1 { + t.Fatalf("splash items shape mismatch: %+v", data) + } + first, ok := items[0].(map[string]any) + if !ok || first["cover_url"] != "https://cdn.example.com/splash.png" || first["type"] != "h5" || first["param"] != "https://h5.example.com/splash" { + t.Fatalf("splash item mismatch: %+v", first) + } + if first["platform"] != "ios" || first["sort_order"].(float64) != 2 || first["region_id"].(float64) != 7 || first["country_code"] != "BR" { + t.Fatalf("splash scope mismatch: %+v", first) + } + if first["starts_at_ms"].(float64) != 1700000000000 || first["ends_at_ms"].(float64) != 1800000000000 || first["updated_at_ms"].(float64) != 1700000003000 { + t.Fatalf("splash metadata mismatch: %+v", first) + } +} + +func TestSplashScreenJSONIncludesEmptyScopeFields(t *testing.T) { + payload, err := json.Marshal(appconfig.SplashScreen{ + ID: 99, + CoverURL: "https://cdn.example.com/splash.png", + SplashType: "app", + Platform: "android", + UpdatedAtMs: 1700000003000, + }) + if err != nil { + t.Fatalf("marshal splash screen failed: %v", err) + } + var item map[string]any + if err := json.Unmarshal(payload, &item); err != nil { + t.Fatalf("unmarshal splash screen failed: %v", err) + } + for _, key := range []string{"region_id", "country_code", "description", "starts_at_ms", "ends_at_ms"} { + if _, ok := item[key]; !ok { + t.Fatalf("splash json missing %s: %+v", key, item) + } + } +} + func TestGetAppVersionReturnsLatestVersionAndUpdateDecision(t *testing.T) { handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetAppConfigReader(appconfig.StaticReader{Version: appconfig.Version{ diff --git a/services/gateway-service/internal/transport/http/router.go b/services/gateway-service/internal/transport/http/router.go index 4156ea8d..cc088df6 100644 --- a/services/gateway-service/internal/transport/http/router.go +++ b/services/gateway-service/internal/transport/http/router.go @@ -151,6 +151,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler { ListH5Links: appAPI.ListH5Links, ListExploreTabs: appAPI.ListExploreTabs, ListAppBanners: appAPI.ListAppBanners, + ListSplashScreens: appAPI.ListSplashScreens, GetAppVersion: appAPI.GetAppVersion, GetResourceGroup: resourceAPI.GetResourceGroup, ListResourceShopItems: resourceAPI.ListResourceShopItems,