2026-06-11 17:11:59 +08:00

1068 lines
35 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package appconfig
import (
"errors"
"fmt"
"strings"
"time"
"unicode"
"unicode/utf8"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/repository"
)
const h5LinkGroup = "h5-links"
const (
bannerDisplayScopeHome = "home"
bannerDisplayScopeRoom = "room"
bannerDisplayScopeRecharge = "recharge"
bannerStatusActive = "active"
bannerStatusDisabled = "disabled"
bannerStatusExpired = "expired"
splashDefaultDisplayDurationMS = 3000
)
var bannerDisplayScopeOrder = []string{bannerDisplayScopeHome, bannerDisplayScopeRoom, bannerDisplayScopeRecharge}
type AppConfigService struct {
store *repository.Store
}
type H5Link struct {
Key string `json:"key"`
Label string `json:"label"`
URL string `json:"url"`
UpdatedAtMs int64 `json:"updatedAtMs"`
}
type AppBanner struct {
ID uint `json:"id"`
AppCode string `json:"appCode"`
CoverURL string `json:"coverUrl"`
RoomSmallImageURL string `json:"roomSmallImageUrl"`
BannerType string `json:"bannerType"`
DisplayScope string `json:"displayScope"`
DisplayScopes []string `json:"displayScopes"`
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 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"`
DisplayDurationMs int `json:"displayDurationMs"`
StartsAtMs int64 `json:"startsAtMs"`
EndsAtMs int64 `json:"endsAtMs"`
CreatedAtMs int64 `json:"createdAtMs"`
UpdatedAtMs int64 `json:"updatedAtMs"`
}
type AppPopup struct {
ID uint `json:"id"`
AppCode string `json:"appCode"`
Code string `json:"code"`
Name string `json:"name"`
ImageURL string `json:"imageUrl"`
JumpType string `json:"jumpType"`
JumpURL string `json:"jumpUrl"`
DisplayPeriodDays int `json:"displayPeriodDays"`
Status string `json:"status"`
SortOrder int `json:"sortOrder"`
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"`
Platform string `json:"platform"`
Version string `json:"version"`
BuildNumber int64 `json:"buildNumber"`
ForceUpdate bool `json:"forceUpdate"`
DownloadURL string `json:"downloadUrl"`
Description string `json:"description"`
CreatedAtMs int64 `json:"createdAtMs"`
UpdatedAtMs int64 `json:"updatedAtMs"`
}
type ExploreTab struct {
ID uint `json:"id"`
AppCode string `json:"appCode"`
Tab string `json:"tab"`
H5URL string `json:"h5Url"`
Enabled bool `json:"enabled"`
SortOrder int `json:"sortOrder"`
CreatedAtMs int64 `json:"createdAtMs"`
UpdatedAtMs int64 `json:"updatedAtMs"`
}
func NewService(store *repository.Store) *AppConfigService {
return &AppConfigService{store: store}
}
func (s *AppConfigService) ListH5Links() ([]H5Link, error) {
configs, err := s.store.ListAppConfigs(h5LinkGroup)
if err != nil {
return nil, err
}
items := make([]H5Link, 0, len(configs))
for _, config := range configs {
items = append(items, h5LinkFromModel(config))
}
return items, nil
}
func (s *AppConfigService) UpdateH5Links(request updateH5LinksRequest) ([]H5Link, error) {
if len(request.Items) == 0 {
return nil, errors.New("items are required")
}
items := make([]model.AppConfig, 0, len(request.Items))
seen := make(map[string]struct{}, len(request.Items))
for _, item := range request.Items {
config, err := h5LinkModelFromPayload(item)
if err != nil {
return nil, err
}
key := config.Key
if _, ok := seen[key]; ok {
return nil, fmt.Errorf("duplicate h5 link key: %s", key)
}
seen[key] = struct{}{}
items = append(items, config)
}
if err := s.store.UpsertAppConfigs(items); err != nil {
return nil, err
}
return s.ListH5Links()
}
func (s *AppConfigService) CreateH5Link(req h5LinkPayload) (H5Link, error) {
item, err := h5LinkModelFromPayload(req)
if err != nil {
return H5Link{}, err
}
if err := s.store.CreateAppConfig(&item); err != nil {
return H5Link{}, err
}
return h5LinkFromModel(item), nil
}
func (s *AppConfigService) UpdateH5Link(key string, req h5LinkPayload) (H5Link, error) {
item, err := s.store.GetAppConfig(h5LinkGroup, key)
if err != nil {
return H5Link{}, err
}
req.Key = item.Key
updated, err := h5LinkModelFromPayload(req)
if err != nil {
return H5Link{}, err
}
item.Value = updated.Value
item.Description = updated.Description
if err := s.store.UpdateAppConfig(&item); err != nil {
return H5Link{}, err
}
return h5LinkFromModel(item), nil
}
func (s *AppConfigService) DeleteH5Link(key string) error {
return s.store.DeleteAppConfig(h5LinkGroup, key)
}
func (s *AppConfigService) ListBanners(appCode string, options repository.AppBannerListOptions) ([]AppBanner, error) {
options.AppCode = appctx.Normalize(appCode)
options.Status = normalizeBannerStatus(options.Status)
if strings.TrimSpace(options.DisplayScope) != "" {
options.DisplayScope = normalizeBannerDisplayScope(options.DisplayScope)
}
options.Platform = normalizeBannerPlatform(options.Platform)
options.Country = normalizeCountryCode(options.Country)
options.Keyword = strings.TrimSpace(options.Keyword)
if err := s.store.ExpireAppBanners(options.AppCode, time.Now().UTC().UnixMilli()); err != nil {
return nil, err
}
items, err := s.store.ListAppBanners(options)
if err != nil {
return nil, err
}
out := make([]AppBanner, 0, len(items))
for _, item := range items {
out = append(out, appBannerFromModel(item))
}
return out, nil
}
func (s *AppConfigService) CreateBanner(appCode string, req bannerRequest) (AppBanner, error) {
item, err := bannerModelFromRequest(appctx.Normalize(appCode), req)
if err != nil {
return AppBanner{}, err
}
if err := s.store.CreateAppBanner(&item); err != nil {
return AppBanner{}, err
}
return appBannerFromModel(item), nil
}
func (s *AppConfigService) UpdateBanner(appCode string, id uint, req bannerRequest) (AppBanner, error) {
item, err := s.store.GetAppBanner(appctx.Normalize(appCode), id)
if err != nil {
return AppBanner{}, err
}
updated, err := bannerModelFromRequest(item.AppCode, req)
if err != nil {
return AppBanner{}, err
}
item.CoverURL = updated.CoverURL
item.RoomSmallImageURL = updated.RoomSmallImageURL
item.BannerType = updated.BannerType
item.DisplayScope = updated.DisplayScope
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.UpdateAppBanner(&item); err != nil {
return AppBanner{}, err
}
return appBannerFromModel(item), nil
}
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.DisplayDurationMS = updated.DisplayDurationMS
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) ListPopups(appCode string, options repository.AppPopupListOptions) ([]AppPopup, error) {
// 列表先清理已过期 active 记录,再返回后台可见数据,保证运营看到的状态和 App 实际可展示状态一致。
options.AppCode = appctx.Normalize(appCode)
options.Status = normalizeBannerStatus(options.Status)
options.Keyword = strings.TrimSpace(options.Keyword)
if err := s.store.ExpireAppPopups(options.AppCode, time.Now().UTC().UnixMilli()); err != nil {
return nil, err
}
items, err := s.store.ListAppPopups(options)
if err != nil {
return nil, err
}
out := make([]AppPopup, 0, len(items))
for _, item := range items {
out = append(out, appPopupFromModel(item))
}
return out, nil
}
func (s *AppConfigService) CreatePopup(appCode string, req popupRequest) (AppPopup, error) {
item, err := popupModelFromRequest(appctx.Normalize(appCode), req)
if err != nil {
return AppPopup{}, err
}
exists, err := s.store.HasAppPopupCode(item.AppCode, item.Code, 0)
if err != nil {
return AppPopup{}, err
}
if exists {
return AppPopup{}, errors.New("popup code already exists")
}
if err := s.store.CreateAppPopup(&item); err != nil {
return AppPopup{}, err
}
return appPopupFromModel(item), nil
}
func (s *AppConfigService) UpdatePopup(appCode string, id uint, req popupRequest) (AppPopup, error) {
// 更新时先按 app_code 锁定原记录,再覆盖可编辑字段,避免请求体伪造租户和创建时间。
item, err := s.store.GetAppPopup(appctx.Normalize(appCode), id)
if err != nil {
return AppPopup{}, err
}
updated, err := popupModelFromRequest(item.AppCode, req)
if err != nil {
return AppPopup{}, err
}
exists, err := s.store.HasAppPopupCode(item.AppCode, updated.Code, item.ID)
if err != nil {
return AppPopup{}, err
}
if exists {
return AppPopup{}, errors.New("popup code already exists")
}
item.Code = updated.Code
item.Name = updated.Name
item.ImageURL = updated.ImageURL
item.JumpType = updated.JumpType
item.JumpURL = updated.JumpURL
item.DisplayPeriodDays = updated.DisplayPeriodDays
item.Status = updated.Status
item.SortOrder = updated.SortOrder
item.StartsAtMS = updated.StartsAtMS
item.EndsAtMS = updated.EndsAtMS
if err := s.store.UpdateAppPopup(&item); err != nil {
return AppPopup{}, err
}
return appPopupFromModel(item), nil
}
func (s *AppConfigService) DeletePopup(appCode string, id uint) error {
return s.store.DeleteAppPopup(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)
options.Keyword = strings.TrimSpace(options.Keyword)
items, err := s.store.ListAppVersions(options)
if err != nil {
return nil, err
}
out := make([]AppVersion, 0, len(items))
for _, item := range items {
out = append(out, appVersionFromModel(item))
}
return out, nil
}
func (s *AppConfigService) CreateAppVersion(appCode string, req appVersionRequest) (AppVersion, error) {
item, err := appVersionModelFromRequest(appctx.Normalize(appCode), req)
if err != nil {
return AppVersion{}, err
}
if err := s.store.CreateAppVersion(&item); err != nil {
return AppVersion{}, err
}
return appVersionFromModel(item), nil
}
func (s *AppConfigService) UpdateAppVersion(appCode string, id uint, req appVersionRequest) (AppVersion, error) {
item, err := s.store.GetAppVersion(appctx.Normalize(appCode), id)
if err != nil {
return AppVersion{}, err
}
updated, err := appVersionModelFromRequest(item.AppCode, req)
if err != nil {
return AppVersion{}, err
}
item.Platform = updated.Platform
item.Version = updated.Version
item.BuildNumber = updated.BuildNumber
item.ForceUpdate = updated.ForceUpdate
item.DownloadURL = updated.DownloadURL
item.Description = updated.Description
if err := s.store.UpdateAppVersion(&item); err != nil {
return AppVersion{}, err
}
return appVersionFromModel(item), nil
}
func (s *AppConfigService) DeleteAppVersion(appCode string, id uint) error {
return s.store.DeleteAppVersion(appctx.Normalize(appCode), id)
}
func (s *AppConfigService) ListExploreTabs(appCode string, options repository.AppExploreTabListOptions) ([]ExploreTab, error) {
options.AppCode = appctx.Normalize(appCode)
options.Keyword = strings.TrimSpace(options.Keyword)
items, err := s.store.ListAppExploreTabs(options)
if err != nil {
return nil, err
}
out := make([]ExploreTab, 0, len(items))
for _, item := range items {
out = append(out, exploreTabFromModel(item))
}
return out, nil
}
func (s *AppConfigService) CreateExploreTab(appCode string, req exploreTabRequest) (ExploreTab, error) {
item, err := exploreTabModelFromRequest(appctx.Normalize(appCode), req)
if err != nil {
return ExploreTab{}, err
}
if err := s.store.CreateAppExploreTab(&item); err != nil {
return ExploreTab{}, err
}
return exploreTabFromModel(item), nil
}
func (s *AppConfigService) UpdateExploreTab(appCode string, id uint, req exploreTabRequest) (ExploreTab, error) {
item, err := s.store.GetAppExploreTab(appctx.Normalize(appCode), id)
if err != nil {
return ExploreTab{}, err
}
updated, err := exploreTabModelFromRequest(item.AppCode, req)
if err != nil {
return ExploreTab{}, err
}
item.Tab = updated.Tab
item.H5URL = updated.H5URL
item.Enabled = updated.Enabled
item.SortOrder = updated.SortOrder
if err := s.store.UpdateAppExploreTab(&item); err != nil {
return ExploreTab{}, err
}
return exploreTabFromModel(item), nil
}
func (s *AppConfigService) DeleteExploreTab(appCode string, id uint) error {
return s.store.DeleteAppExploreTab(appctx.Normalize(appCode), id)
}
func validateH5URL(value string) error {
if value == "" {
return nil
}
if len(value) > 2048 {
return errors.New("h5 link is too long")
}
for _, char := range value {
if unicode.IsSpace(char) {
return errors.New("h5 link cannot contain whitespace")
}
}
return nil
}
func h5LinkModelFromPayload(req h5LinkPayload) (model.AppConfig, error) {
key := strings.TrimSpace(req.Key)
label := strings.TrimSpace(req.Label)
url := strings.TrimSpace(req.URL)
if err := validateH5Key(key); err != nil {
return model.AppConfig{}, err
}
if label == "" || utf8.RuneCountInString(label) > 80 {
return model.AppConfig{}, errors.New("h5 config label is invalid")
}
if url == "" {
return model.AppConfig{}, errors.New("h5 link is required")
}
if err := validateH5URL(url); err != nil {
return model.AppConfig{}, err
}
return model.AppConfig{
Group: h5LinkGroup,
Key: key,
Value: url,
Description: label,
}, nil
}
func validateH5Key(value string) error {
if value == "" || len(value) > 80 {
return errors.New("h5 config key is invalid")
}
for _, char := range value {
switch {
case char >= 'a' && char <= 'z':
case char >= 'A' && char <= 'Z':
case char >= '0' && char <= '9':
case char == '-' || char == '_' || char == '.' || char == ':':
default:
return errors.New("h5 config key is invalid")
}
}
return nil
}
func h5LinkFromModel(config model.AppConfig) H5Link {
label := strings.TrimSpace(config.Description)
if label == "" {
label = config.Key
}
return H5Link{
Key: config.Key,
Label: label,
URL: config.Value,
UpdatedAtMs: config.UpdatedAtMS,
}
}
func bannerModelFromRequest(appCode string, req bannerRequest) (model.AppBanner, error) {
displayScopes := normalizeBannerDisplayScopes(req.DisplayScopes, req.DisplayScope)
item := model.AppBanner{
AppCode: appctx.Normalize(appCode),
CoverURL: strings.TrimSpace(req.CoverURL),
RoomSmallImageURL: strings.TrimSpace(req.RoomSmallImageURL),
BannerType: normalizeBannerType(req.BannerType),
DisplayScope: bannerDisplayScopeValue(displayScopes),
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.AppBanner{}, errors.New("banner cover is invalid")
}
if containsWhitespace(item.CoverURL) {
return model.AppBanner{}, errors.New("banner cover cannot contain whitespace")
}
if !validBannerDisplayScopes(displayScopes) {
return model.AppBanner{}, errors.New("banner display scope is invalid")
}
if bannerDisplayScopesContain(displayScopes, bannerDisplayScopeRoom) {
if item.RoomSmallImageURL == "" || len(item.RoomSmallImageURL) > 1024 {
return model.AppBanner{}, errors.New("room small image is required")
}
if containsWhitespace(item.RoomSmallImageURL) {
return model.AppBanner{}, errors.New("room small image cannot contain whitespace")
}
} else {
// 未投放到房间内时不保留小屏图,避免客户端误用房间专属素材。
item.RoomSmallImageURL = ""
}
if item.BannerType != "h5" && item.BannerType != "app" {
return model.AppBanner{}, errors.New("banner type is invalid")
}
if item.BannerType == "h5" {
if item.Param == "" {
return model.AppBanner{}, errors.New("h5 link is required")
}
if err := validateH5URL(item.Param); err != nil {
return model.AppBanner{}, err
}
}
if len(item.Param) > 2048 {
return model.AppBanner{}, errors.New("banner param is too long")
}
if item.Status != bannerStatusActive && item.Status != bannerStatusDisabled && item.Status != bannerStatusExpired {
return model.AppBanner{}, errors.New("banner status is invalid")
}
if item.StartsAtMS < 0 || item.EndsAtMS < 0 {
return model.AppBanner{}, errors.New("banner delivery time is invalid")
}
if item.StartsAtMS > 0 && item.EndsAtMS > 0 && item.StartsAtMS >= item.EndsAtMS {
return model.AppBanner{}, errors.New("banner delivery time range is invalid")
}
if item.Status == bannerStatusActive && item.EndsAtMS > 0 && item.EndsAtMS <= time.Now().UTC().UnixMilli() {
item.Status = bannerStatusExpired
}
if item.Platform != "android" && item.Platform != "ios" {
return model.AppBanner{}, errors.New("banner platform is invalid")
}
if item.RegionID < 0 {
return model.AppBanner{}, errors.New("banner region is invalid")
}
if item.CountryCode != "" && !validCountryCode(item.CountryCode) {
return model.AppBanner{}, errors.New("banner country is invalid")
}
if utf8.RuneCountInString(item.Description) > 255 {
return model.AppBanner{}, errors.New("banner description is too long")
}
return item, nil
}
func appBannerFromModel(item model.AppBanner) AppBanner {
return AppBanner{
ID: item.ID,
AppCode: item.AppCode,
CoverURL: item.CoverURL,
RoomSmallImageURL: item.RoomSmallImageURL,
BannerType: item.BannerType,
DisplayScope: item.DisplayScope,
DisplayScopes: bannerDisplayScopeList(item.DisplayScope),
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 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),
DisplayDurationMS: normalizeSplashDisplayDurationMS(req.DisplayDurationMs),
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")
}
if req.DisplayDurationMs < 0 {
return model.AppSplashScreen{}, errors.New("splash display duration is invalid")
}
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,
DisplayDurationMs: item.DisplayDurationMS,
StartsAtMs: item.StartsAtMS,
EndsAtMs: item.EndsAtMS,
CreatedAtMs: item.CreatedAtMS,
UpdatedAtMs: item.UpdatedAtMS,
}
}
func popupModelFromRequest(appCode string, req popupRequest) (model.AppPopup, error) {
item := model.AppPopup{
AppCode: appctx.Normalize(appCode),
Code: strings.TrimSpace(req.Code),
Name: strings.TrimSpace(req.Name),
ImageURL: strings.TrimSpace(req.ImageURL),
JumpType: normalizeJumpType(req.JumpType),
JumpURL: strings.TrimSpace(req.JumpURL),
DisplayPeriodDays: req.DisplayPeriodDays,
Status: normalizeBannerStatus(req.Status),
SortOrder: req.SortOrder,
StartsAtMS: req.StartsAtMs,
EndsAtMS: req.EndsAtMs,
}
if err := validateH5Key(item.Code); err != nil {
return model.AppPopup{}, errors.New("popup code is invalid")
}
if item.Name == "" || utf8.RuneCountInString(item.Name) > 80 {
return model.AppPopup{}, errors.New("popup name is invalid")
}
if len(item.ImageURL) > 1024 {
return model.AppPopup{}, errors.New("popup image is too long")
}
if item.ImageURL != "" && containsWhitespace(item.ImageURL) {
return model.AppPopup{}, errors.New("popup image cannot contain whitespace")
}
if item.JumpType != "h5" && item.JumpType != "app" {
return model.AppPopup{}, errors.New("popup jump type is invalid")
}
if item.JumpURL == "" {
return model.AppPopup{}, errors.New("popup jump url is required")
}
if len(item.JumpURL) > 2048 {
return model.AppPopup{}, errors.New("popup jump url is too long")
}
if item.JumpType == "h5" {
// H5 弹窗必须是无空白的可打开地址APP 类型由客户端解释路径,但同样不允许空白字符破坏路由解析。
if err := validateH5URL(item.JumpURL); err != nil {
return model.AppPopup{}, err
}
} else if containsWhitespace(item.JumpURL) {
return model.AppPopup{}, errors.New("popup jump url cannot contain whitespace")
}
if item.DisplayPeriodDays < 0 {
return model.AppPopup{}, errors.New("popup display period is invalid")
}
if item.Status != bannerStatusActive && item.Status != bannerStatusDisabled && item.Status != bannerStatusExpired {
return model.AppPopup{}, errors.New("popup status is invalid")
}
if item.StartsAtMS < 0 || item.EndsAtMS < 0 {
return model.AppPopup{}, errors.New("popup effective time is invalid")
}
if item.StartsAtMS > 0 && item.EndsAtMS > 0 && item.StartsAtMS >= item.EndsAtMS {
return model.AppPopup{}, errors.New("popup effective time range is invalid")
}
if item.Status == bannerStatusActive && item.EndsAtMS > 0 && item.EndsAtMS <= time.Now().UTC().UnixMilli() {
// 已经结束的 active 弹窗保存为 expiredApp 接口不会再下发,后台也能直接看到过期状态。
item.Status = bannerStatusExpired
}
return item, nil
}
func appPopupFromModel(item model.AppPopup) AppPopup {
return AppPopup{
ID: item.ID,
AppCode: item.AppCode,
Code: item.Code,
Name: item.Name,
ImageURL: item.ImageURL,
JumpType: item.JumpType,
JumpURL: item.JumpURL,
DisplayPeriodDays: item.DisplayPeriodDays,
Status: item.Status,
SortOrder: item.SortOrder,
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),
Platform: normalizeAppPlatform(req.Platform),
Version: strings.TrimSpace(req.Version),
BuildNumber: req.BuildNumber,
ForceUpdate: req.ForceUpdate,
DownloadURL: strings.TrimSpace(req.DownloadURL),
Description: strings.TrimSpace(req.Description),
}
if item.Platform != "android" && item.Platform != "ios" {
return model.AppVersion{}, errors.New("app version platform is invalid")
}
if item.Version == "" || utf8.RuneCountInString(item.Version) > 40 {
return model.AppVersion{}, errors.New("app version is invalid")
}
if item.BuildNumber <= 0 {
return model.AppVersion{}, errors.New("app build number is invalid")
}
if item.DownloadURL == "" || len(item.DownloadURL) > 2048 {
return model.AppVersion{}, errors.New("app download url is invalid")
}
for _, char := range item.DownloadURL {
if unicode.IsSpace(char) {
return model.AppVersion{}, errors.New("app download url cannot contain whitespace")
}
}
if utf8.RuneCountInString(item.Description) > 1000 {
return model.AppVersion{}, errors.New("app version description is too long")
}
return item, nil
}
func appVersionFromModel(item model.AppVersion) AppVersion {
return AppVersion{
ID: item.ID,
AppCode: item.AppCode,
Platform: item.Platform,
Version: item.Version,
BuildNumber: item.BuildNumber,
ForceUpdate: item.ForceUpdate,
DownloadURL: item.DownloadURL,
Description: item.Description,
CreatedAtMs: item.CreatedAtMS,
UpdatedAtMs: item.UpdatedAtMS,
}
}
func exploreTabModelFromRequest(appCode string, req exploreTabRequest) (model.AppExploreTab, error) {
item := model.AppExploreTab{
AppCode: appctx.Normalize(appCode),
Tab: strings.TrimSpace(req.Tab),
H5URL: strings.TrimSpace(req.H5URL),
Enabled: req.Enabled,
SortOrder: req.SortOrder,
}
if item.Tab == "" || utf8.RuneCountInString(item.Tab) > 40 {
return model.AppExploreTab{}, errors.New("explore tab is invalid")
}
if item.H5URL == "" || len(item.H5URL) > 2048 {
return model.AppExploreTab{}, errors.New("explore h5 url is invalid")
}
if err := validateH5URL(item.H5URL); err != nil {
return model.AppExploreTab{}, err
}
return item, nil
}
func exploreTabFromModel(item model.AppExploreTab) ExploreTab {
return ExploreTab{
ID: item.ID,
AppCode: item.AppCode,
Tab: item.Tab,
H5URL: item.H5URL,
Enabled: item.Enabled,
SortOrder: item.SortOrder,
CreatedAtMs: item.CreatedAtMS,
UpdatedAtMs: item.UpdatedAtMS,
}
}
func normalizeBannerType(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func normalizeSplashType(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func normalizeJumpType(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func normalizeSplashDisplayDurationMS(value int) int {
// 管理端允许 0 表示“客户端默认 3 秒”,空请求和历史数据都统一补成 3000ms避免 App 侧拿到缺省字段后出现 0 秒闪退式开屏。
if value == 0 {
return splashDefaultDisplayDurationMS
}
return value
}
func normalizeBannerStatus(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
return bannerStatusActive
}
return value
}
func normalizeBannerDisplayScope(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
switch value {
case "":
return bannerDisplayScopeHome
case bannerDisplayScopeHome, "首页":
return bannerDisplayScopeHome
case bannerDisplayScopeRoom, "房间内":
return bannerDisplayScopeRoom
case bannerDisplayScopeRecharge, "充值页":
return bannerDisplayScopeRecharge
default:
return value
}
}
func normalizeBannerDisplayScopes(values []string, fallback string) []string {
seen := make(map[string]struct{}, len(bannerDisplayScopeOrder))
for _, value := range values {
for _, part := range strings.Split(value, ",") {
scope := normalizeBannerDisplayScope(part)
if scope == "" {
continue
}
seen[scope] = struct{}{}
}
}
if len(seen) == 0 {
for _, part := range strings.Split(fallback, ",") {
scope := normalizeBannerDisplayScope(part)
if scope == "" {
continue
}
seen[scope] = struct{}{}
}
}
if len(seen) == 0 {
seen[bannerDisplayScopeHome] = struct{}{}
}
out := make([]string, 0, len(seen))
for _, scope := range bannerDisplayScopeOrder {
if _, ok := seen[scope]; ok {
out = append(out, scope)
delete(seen, scope)
}
}
for scope := range seen {
out = append(out, scope)
}
return out
}
func bannerDisplayScopeList(value string) []string {
return normalizeBannerDisplayScopes(nil, value)
}
func bannerDisplayScopeValue(scopes []string) string {
return strings.Join(scopes, ",")
}
func validBannerDisplayScopes(scopes []string) bool {
if len(scopes) == 0 {
return false
}
for _, scope := range scopes {
if scope != bannerDisplayScopeHome && scope != bannerDisplayScopeRoom && scope != bannerDisplayScopeRecharge {
return false
}
}
return true
}
func bannerDisplayScopesContain(scopes []string, target string) bool {
for _, scope := range scopes {
if scope == target {
return true
}
}
return false
}
func normalizeBannerPlatform(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func normalizeAppPlatform(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func normalizeCountryCode(value string) string {
return strings.ToUpper(strings.TrimSpace(value))
}
func containsWhitespace(value string) bool {
for _, char := range value {
if unicode.IsSpace(char) {
return true
}
}
return false
}
func validCountryCode(value string) bool {
if len(value) < 2 || len(value) > 3 {
return false
}
for _, char := range value {
if char < 'A' || char > 'Z' {
return false
}
}
return true
}