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 弹窗保存为 expired,App 接口不会再下发,后台也能直接看到过期状态。 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 }