开屏配置

This commit is contained in:
zhx 2026-06-09 22:08:52 +08:00
parent 6b16f8c96e
commit 1e180b844c
19 changed files with 870 additions and 3 deletions

View 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&region_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
无。

View File

@ -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: 当前用户区域 ID0 或不传只命中全区域配置。
- 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"

View File

@ -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 |

View File

@ -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"`

View File

@ -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 {

View File

@ -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"`

View File

@ -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)

View File

@ -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 == "" {

View File

@ -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,

View File

@ -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
}

View File

@ -64,6 +64,7 @@ func (s *Store) AutoMigrate() error {
&model.Menu{},
&model.AppConfig{},
&model.AppBanner{},
&model.AppSplashScreen{},
&model.AppVersion{},
&model.AppExploreTab{},
&model.HostAgencySalaryPolicy{},

View File

@ -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},

View 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 '区域 ID0 表示全部区域',
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;

View File

@ -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 {

View File

@ -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 {

View File

@ -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)
}

View File

@ -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)

View File

@ -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&region_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{

View File

@ -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,