开屏配置
This commit is contained in:
parent
6b16f8c96e
commit
1e180b844c
69
docs/flutter对接/开屏配置Flutter对接.md
Normal file
69
docs/flutter对接/开屏配置Flutter对接.md
Normal file
@ -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
|
||||
|
||||
无。
|
||||
@ -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"
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 == "" {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -64,6 +64,7 @@ func (s *Store) AutoMigrate() error {
|
||||
&model.Menu{},
|
||||
&model.AppConfig{},
|
||||
&model.AppBanner{},
|
||||
&model.AppSplashScreen{},
|
||||
&model.AppVersion{},
|
||||
&model.AppExploreTab{},
|
||||
&model.HostAgencySalaryPolicy{},
|
||||
|
||||
@ -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},
|
||||
|
||||
50
server/admin/migrations/042_admin_app_splash_screens.sql
Normal file
50
server/admin/migrations/042_admin_app_splash_screens.sql
Normal file
@ -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;
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user