875 lines
27 KiB
Go
875 lines
27 KiB
Go
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"
|
||
)
|
||
|
||
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"`
|
||
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.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)
|
||
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),
|
||
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),
|
||
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 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
|
||
}
|