2026-06-17 23:13:08 +08:00

782 lines
23 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 读取后台管理维护的 App 运行时配置。
package appconfig
import (
"context"
"database/sql"
"encoding/json"
"errors"
"strconv"
"strings"
"time"
mysqlDriver "github.com/go-sql-driver/mysql"
"github.com/redis/go-redis/v9"
)
const h5LinkGroup = "h5-links"
const roomRegionWhitelistGroup = "room-region-whitelist"
const listH5LinksSQL = "SELECT `key`, COALESCE(description, ''), COALESCE(value, ''), updated_at_ms FROM admin_app_configs WHERE `group` = ? ORDER BY `key` ASC"
const getRoomRegionWhitelistSQL = "SELECT COALESCE(value, '') FROM admin_app_configs WHERE `group` = ? AND `key` = ? LIMIT 1"
const bdLeaderPositionAliasSQL = `
SELECT position_alias
FROM admin_bd_leader_position_aliases
WHERE app_code = ? AND user_id = ?
LIMIT 1`
const listExploreTabsSQL = `
SELECT id, app_code, tab, h5_url, enabled, sort_order, updated_at_ms
FROM admin_app_explore_tabs
WHERE app_code = ? AND enabled = TRUE
ORDER BY sort_order ASC, id DESC`
const listAppBannersSQL = `
SELECT id, app_code, cover_url, room_small_image_url, banner_type, display_scope, param, platform, sort_order, region_id, country_code, description, starts_at_ms, ends_at_ms, updated_at_ms
FROM admin_app_banners
WHERE app_code = ? AND status = 'active' AND FIND_IN_SET(?, display_scope) > 0
AND (? = '' OR platform = ?)
AND (region_id = 0 OR region_id = ?)
AND (country_code = '' OR country_code = ?)
AND (starts_at_ms = 0 OR starts_at_ms <= ?)
AND (ends_at_ms = 0 OR ends_at_ms > ?)
ORDER BY sort_order ASC, id DESC`
const listSplashScreensSQL = `
SELECT id, app_code, cover_url, splash_type, param, platform, sort_order, region_id, country_code, description, display_duration_ms, starts_at_ms, ends_at_ms, updated_at_ms
FROM admin_app_splash_screens
WHERE app_code = ? AND status = 'active'
AND (? = '' OR platform = ?)
AND (region_id = 0 OR region_id = ?)
AND (country_code = '' OR country_code = ?)
AND (starts_at_ms = 0 OR starts_at_ms <= ?)
AND (ends_at_ms = 0 OR ends_at_ms > ?)
ORDER BY sort_order ASC, id DESC`
const listPopupsSQL = `
SELECT id, app_code, code, name, image_url, jump_type, jump_url, display_period_days, sort_order, starts_at_ms, ends_at_ms, updated_at_ms
FROM admin_app_popups
WHERE app_code = ? AND status = 'active'
AND (starts_at_ms = 0 OR starts_at_ms <= ?)
AND (ends_at_ms = 0 OR ends_at_ms > ?)
ORDER BY sort_order ASC, id DESC`
const latestAppVersionSQL = `
SELECT id, app_code, platform, version, build_number, force_update, download_url, COALESCE(description, ''), updated_at_ms
FROM admin_app_versions
WHERE app_code = ? AND platform = ?
ORDER BY build_number DESC, id DESC
LIMIT 1`
// H5Link 是 gateway 下发给 App 的 H5 入口配置。
type H5Link struct {
Key string `json:"key"`
Label string `json:"label"`
URL string `json:"url"`
UpdatedAtMs int64 `json:"updated_at_ms"`
}
// H5LinkQuery 是 H5 入口配置的可选用户态筛选条件。
type H5LinkQuery struct {
AppCode string
UserID int64
}
// ExploreTab 是后台 APP配置/Explore配置 中启用的 H5 tab。
type ExploreTab struct {
ID uint `json:"id"`
AppCode string `json:"app_code"`
Tab string `json:"tab"`
H5URL string `json:"h5_url"`
Enabled bool `json:"enabled"`
SortOrder int `json:"sort_order"`
UpdatedAtMs int64 `json:"updated_at_ms"`
}
// BannerQuery 是 App banner 的公开筛选条件。
type BannerQuery struct {
AppCode string
DisplayScope string
Platform string
RegionID int64
Country string
}
// Banner 是 gateway 下发给 App 指定显示范围的 banner 配置。
type Banner struct {
ID uint `json:"id"`
CoverURL string `json:"cover_url"`
RoomSmallImageURL string `json:"room_small_image_url,omitempty"`
BannerType string `json:"type"`
DisplayScope string `json:"display_scope"`
DisplayScopes []string `json:"display_scopes,omitempty"`
Param string `json:"param"`
Platform string `json:"platform"`
SortOrder int `json:"sort_order"`
RegionID int64 `json:"region_id,omitempty"`
CountryCode string `json:"country_code,omitempty"`
Description string `json:"description,omitempty"`
StartsAtMs int64 `json:"starts_at_ms,omitempty"`
EndsAtMs int64 `json:"ends_at_ms,omitempty"`
UpdatedAtMs int64 `json:"updated_at_ms"`
}
// SplashScreenQuery 是 App 开屏配置的公开筛选条件。
type SplashScreenQuery struct {
AppCode string
Platform string
RegionID int64
Country string
}
// SplashScreen 是 gateway 下发给 App 的开屏配置type 语义和 banner 保持一致h5 参数是链接app 参数由客户端解释。
type SplashScreen struct {
ID uint `json:"id"`
CoverURL string `json:"cover_url"`
SplashType string `json:"type"`
Param string `json:"param"`
Platform string `json:"platform"`
SortOrder int `json:"sort_order"`
RegionID int64 `json:"region_id"`
CountryCode string `json:"country_code"`
Description string `json:"description"`
DisplayDurationMs int `json:"display_duration_ms"`
StartsAtMs int64 `json:"starts_at_ms"`
EndsAtMs int64 `json:"ends_at_ms"`
UpdatedAtMs int64 `json:"updated_at_ms"`
}
// PopupQuery 是 App 弹窗配置的公开筛选条件。
type PopupQuery struct {
AppCode string
}
// Popup 是 gateway 下发给 App 的弹窗配置display_period_days=0 表示客户端每次打开都展示。
type Popup struct {
ID uint `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
ImageURL string `json:"image_url"`
JumpType string `json:"jump_type"`
JumpURL string `json:"jump_url"`
DisplayPeriodDays int `json:"display_period_days"`
SortOrder int `json:"sort_order"`
StartsAtMs int64 `json:"starts_at_ms"`
EndsAtMs int64 `json:"ends_at_ms"`
UpdatedAtMs int64 `json:"updated_at_ms"`
}
// VersionQuery 是 App 检查更新的公开筛选条件。
type VersionQuery struct {
AppCode string
Platform string
CurrentBuildNumber int64
}
// Version 是后台 APP配置/版本管理 中当前平台的最新 App 版本。
type Version struct {
ID uint `json:"id"`
AppCode string `json:"app_code"`
Platform string `json:"platform"`
Version string `json:"version"`
BuildNumber int64 `json:"build_number"`
ForceUpdate bool `json:"force_update"`
DownloadURL string `json:"download_url"`
Description string `json:"description"`
UpdatedAtMs int64 `json:"updated_at_ms"`
}
// Reader 是 HTTP 层读取 H5 配置的最小依赖。
type Reader interface {
ListH5Links(ctx context.Context, query H5LinkQuery) ([]H5Link, error)
ListExploreTabs(ctx context.Context, appCode string) ([]ExploreTab, error)
ListBanners(ctx context.Context, query BannerQuery) ([]Banner, error)
ListSplashScreens(ctx context.Context, query SplashScreenQuery) ([]SplashScreen, error)
ListPopups(ctx context.Context, query PopupQuery) ([]Popup, error)
LatestVersion(ctx context.Context, query VersionQuery) (Version, error)
RoomRegionWhitelistAllows(ctx context.Context, appCode string, userID int64) (bool, error)
}
// MySQLReader 从 hyapp_admin 读取后台 App 配置。
type MySQLReader struct {
db *sql.DB
cache *redis.Client
cachePrefix string
cacheTTL time.Duration
}
// OpenMySQLReader 创建后台配置只读连接池。
func OpenMySQLReader(dsn string) (*MySQLReader, error) {
db, err := sql.Open("mysql", strings.TrimSpace(dsn))
if err != nil {
return nil, err
}
return &MySQLReader{db: db}, nil
}
// Close 关闭后台配置连接池。
func (r *MySQLReader) Close() error {
if r == nil || r.db == nil {
return nil
}
return errors.Join(r.db.Close(), r.closeCache())
}
func (r *MySQLReader) closeCache() error {
if r == nil || r.cache == nil {
return nil
}
return r.cache.Close()
}
// OpenRedisCache 创建 App 配置 Redis 缓存连接;启动期探测失败直接暴露配置问题。
func OpenRedisCache(ctx context.Context, addr string, password string, db int) (*redis.Client, error) {
client := redis.NewClient(&redis.Options{
Addr: strings.TrimSpace(addr),
Password: password,
DB: db,
})
if err := client.Ping(ctx).Err(); err != nil {
_ = client.Close()
return nil, err
}
return client, nil
}
// SetRedisCache 给低频后台配置挂 Redis 五分钟读缓存,避免房间列表每次都查 hyapp_admin。
func (r *MySQLReader) SetRedisCache(client *redis.Client, keyPrefix string, ttl time.Duration) {
if r == nil {
return
}
r.cache = client
r.cachePrefix = strings.TrimSpace(keyPrefix)
if r.cachePrefix == "" {
r.cachePrefix = "gateway:app_config:"
}
if ttl <= 0 {
ttl = 5 * time.Minute
}
r.cacheTTL = ttl
}
// ListH5Links 返回后台动态维护的 H5 入口集合。
func (r *MySQLReader) ListH5Links(ctx context.Context, query H5LinkQuery) ([]H5Link, error) {
if r == nil || r.db == nil {
return nil, errors.New("app config reader is not configured")
}
rows, err := r.db.QueryContext(ctx, listH5LinksSQL, h5LinkGroup)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]H5Link, 0)
for rows.Next() {
var item H5Link
if err := rows.Scan(&item.Key, &item.Label, &item.URL, &item.UpdatedAtMs); err != nil {
return nil, err
}
item.Key = strings.TrimSpace(item.Key)
item.Label = strings.TrimSpace(item.Label)
if item.Label == "" {
item.Label = item.Key
}
item.URL = strings.TrimSpace(item.URL)
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
if alias, err := r.bdLeaderPositionAlias(ctx, query); err != nil {
return nil, err
} else if alias != "" {
applyAdminH5LinkAlias(items, alias)
}
return items, nil
}
func (r *MySQLReader) bdLeaderPositionAlias(ctx context.Context, query H5LinkQuery) (string, error) {
if query.UserID <= 0 {
return "", nil
}
var alias string
err := r.db.QueryRowContext(ctx, bdLeaderPositionAliasSQL, normalizeAppCode(query.AppCode), query.UserID).Scan(&alias)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
if isMissingTableError(err) {
// gateway 读的是 admin 库;迁移未发布前保持 H5 配置原始返回,不让可选别名阻断老环境。
return "", nil
}
if err != nil {
return "", err
}
return strings.TrimSpace(alias), nil
}
// ListExploreTabs 返回当前 App 启用的 Explore H5 tabs后台关闭的 tab 不下发给客户端。
func (r *MySQLReader) ListExploreTabs(ctx context.Context, appCode string) ([]ExploreTab, error) {
if r == nil || r.db == nil {
return nil, errors.New("app config reader is not configured")
}
rows, err := r.db.QueryContext(ctx, listExploreTabsSQL, normalizeAppCode(appCode))
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]ExploreTab, 0)
for rows.Next() {
var item ExploreTab
if err := rows.Scan(&item.ID, &item.AppCode, &item.Tab, &item.H5URL, &item.Enabled, &item.SortOrder, &item.UpdatedAtMs); err != nil {
return nil, err
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
// ListBanners 返回当前 App、显示范围、平台和地域可见的 banner。
func (r *MySQLReader) ListBanners(ctx context.Context, query BannerQuery) ([]Banner, error) {
if r == nil || r.db == nil {
return nil, errors.New("app config reader is not configured")
}
appCode := normalizeAppCode(query.AppCode)
displayScope := NormalizeBannerDisplayScope(query.DisplayScope)
if displayScope == "" {
return []Banner{}, nil
}
platform := normalizePlatform(query.Platform)
country := normalizeCountry(query.Country)
nowMs := time.Now().UTC().UnixMilli()
rows, err := r.db.QueryContext(ctx, listAppBannersSQL, appCode, displayScope, platform, platform, query.RegionID, country, nowMs, nowMs)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]Banner, 0)
for rows.Next() {
var item Banner
var appCode string
var updatedAtMS int64
if err := rows.Scan(
&item.ID,
&appCode,
&item.CoverURL,
&item.RoomSmallImageURL,
&item.BannerType,
&item.DisplayScope,
&item.Param,
&item.Platform,
&item.SortOrder,
&item.RegionID,
&item.CountryCode,
&item.Description,
&item.StartsAtMs,
&item.EndsAtMs,
&updatedAtMS,
); err != nil {
return nil, err
}
item.UpdatedAtMs = updatedAtMS
item.DisplayScopes = bannerDisplayScopeList(item.DisplayScope)
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
// ListSplashScreens 返回当前 App、平台和地域可见的开屏配置未限定平台时返回所有平台配置客户端可按需自筛。
func (r *MySQLReader) ListSplashScreens(ctx context.Context, query SplashScreenQuery) ([]SplashScreen, error) {
if r == nil || r.db == nil {
return nil, errors.New("app config reader is not configured")
}
appCode := normalizeAppCode(query.AppCode)
platform := normalizePlatform(query.Platform)
country := normalizeCountry(query.Country)
nowMs := time.Now().UTC().UnixMilli()
rows, err := r.db.QueryContext(ctx, listSplashScreensSQL, appCode, platform, platform, query.RegionID, country, nowMs, nowMs)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]SplashScreen, 0)
for rows.Next() {
var item SplashScreen
var appCode string
var updatedAtMS int64
if err := rows.Scan(
&item.ID,
&appCode,
&item.CoverURL,
&item.SplashType,
&item.Param,
&item.Platform,
&item.SortOrder,
&item.RegionID,
&item.CountryCode,
&item.Description,
&item.DisplayDurationMs,
&item.StartsAtMs,
&item.EndsAtMs,
&updatedAtMS,
); err != nil {
return nil, err
}
item.UpdatedAtMs = updatedAtMS
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
// ListPopups 返回当前 App 生效窗口内的弹窗配置;展示频控由客户端按 display_period_days 本地执行。
func (r *MySQLReader) ListPopups(ctx context.Context, query PopupQuery) ([]Popup, error) {
if r == nil || r.db == nil {
return nil, errors.New("app config reader is not configured")
}
appCode := normalizeAppCode(query.AppCode)
nowMs := time.Now().UTC().UnixMilli()
rows, err := r.db.QueryContext(ctx, listPopupsSQL, appCode, nowMs, nowMs)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]Popup, 0)
for rows.Next() {
var item Popup
var appCode string
var updatedAtMS int64
if err := rows.Scan(
&item.ID,
&appCode,
&item.Code,
&item.Name,
&item.ImageURL,
&item.JumpType,
&item.JumpURL,
&item.DisplayPeriodDays,
&item.SortOrder,
&item.StartsAtMs,
&item.EndsAtMs,
&updatedAtMS,
); err != nil {
return nil, err
}
item.UpdatedAtMs = updatedAtMS
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
// LatestVersion 返回指定 App 和平台的最高 build_number 版本;未配置时返回空版本,方便 App 端按无更新处理。
func (r *MySQLReader) LatestVersion(ctx context.Context, query VersionQuery) (Version, error) {
if r == nil || r.db == nil {
return Version{}, errors.New("app config reader is not configured")
}
var item Version
err := r.db.QueryRowContext(ctx, latestAppVersionSQL, normalizeAppCode(query.AppCode), normalizePlatform(query.Platform)).Scan(
&item.ID,
&item.AppCode,
&item.Platform,
&item.Version,
&item.BuildNumber,
&item.ForceUpdate,
&item.DownloadURL,
&item.Description,
&item.UpdatedAtMs,
)
if errors.Is(err, sql.ErrNoRows) {
return Version{AppCode: normalizeAppCode(query.AppCode), Platform: normalizePlatform(query.Platform)}, nil
}
if err != nil {
return Version{}, err
}
return item, nil
}
// RoomRegionWhitelistAllows 判断用户是否可绕过房间区域限制;结果按 app+user 缓存五分钟。
func (r *MySQLReader) RoomRegionWhitelistAllows(ctx context.Context, appCode string, userID int64) (bool, error) {
if userID <= 0 {
return false, nil
}
if r == nil || r.db == nil {
return false, errors.New("app config reader is not configured")
}
app := normalizeAppCode(appCode)
if cached, ok, err := r.cachedRoomRegionWhitelist(ctx, app, userID); err == nil && ok {
return cached, nil
}
allowed, err := r.roomRegionWhitelistAllowsFromDB(ctx, app, userID)
if err != nil {
return false, err
}
r.storeRoomRegionWhitelistCache(ctx, app, userID, allowed)
return allowed, nil
}
func (r *MySQLReader) roomRegionWhitelistAllowsFromDB(ctx context.Context, appCode string, userID int64) (bool, error) {
var raw string
err := r.db.QueryRowContext(ctx, getRoomRegionWhitelistSQL, roomRegionWhitelistGroup, appCode).Scan(&raw)
if errors.Is(err, sql.ErrNoRows) || isMissingTableError(err) {
return false, nil
}
if err != nil {
return false, err
}
ids, err := parseRoomRegionWhitelist(raw)
if err != nil {
return false, err
}
_, ok := ids[userID]
return ok, nil
}
func (r *MySQLReader) cachedRoomRegionWhitelist(ctx context.Context, appCode string, userID int64) (bool, bool, error) {
if r == nil || r.cache == nil {
return false, false, nil
}
value, err := r.cache.Get(ctx, r.roomRegionWhitelistCacheKey(appCode, userID)).Result()
if errors.Is(err, redis.Nil) {
return false, false, nil
}
if err != nil {
return false, false, err
}
return value == "1", true, nil
}
func (r *MySQLReader) storeRoomRegionWhitelistCache(ctx context.Context, appCode string, userID int64, allowed bool) {
if r == nil || r.cache == nil {
return
}
value := "0"
if allowed {
value = "1"
}
// 缓存只优化读路径;写入失败时保持 DB 判定结果,不让 Redis 抖动阻断房间列表。
_ = r.cache.Set(ctx, r.roomRegionWhitelistCacheKey(appCode, userID), value, r.cacheTTL).Err()
}
func (r *MySQLReader) roomRegionWhitelistCacheKey(appCode string, userID int64) string {
return strings.TrimRight(r.cachePrefix, ":") + ":room_region_whitelist:" + normalizeAppCode(appCode) + ":" + strconv.FormatInt(userID, 10)
}
type roomRegionWhitelistPayload struct {
UserIDs []string `json:"user_ids"`
}
func parseRoomRegionWhitelist(raw string) (map[int64]struct{}, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return map[int64]struct{}{}, nil
}
var payload roomRegionWhitelistPayload
if strings.HasPrefix(raw, "[") {
if err := json.Unmarshal([]byte(raw), &payload.UserIDs); err != nil {
return nil, err
}
} else if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return nil, err
}
out := make(map[int64]struct{}, len(payload.UserIDs))
for _, item := range payload.UserIDs {
id, err := strconv.ParseInt(strings.TrimSpace(item), 10, 64)
if err != nil || id <= 0 {
continue
}
out[id] = struct{}{}
}
return out, nil
}
// StaticReader 给测试和临时环境提供内存版 H5 配置读取。
type StaticReader struct {
Links []H5Link
ExploreTabs []ExploreTab
Banners []Banner
SplashScreens []SplashScreen
Popups []Popup
Version Version
Err error
PositionAliases map[string]string
RoomWhitelist map[string]bool
}
// ListH5Links 返回预置 H5 配置。
func (r StaticReader) ListH5Links(_ context.Context, query H5LinkQuery) ([]H5Link, error) {
if r.Err != nil {
return nil, r.Err
}
out := make([]H5Link, len(r.Links))
copy(out, r.Links)
if alias := strings.TrimSpace(r.PositionAliases[bdLeaderPositionAliasKey(query.AppCode, query.UserID)]); alias != "" {
applyAdminH5LinkAlias(out, alias)
}
return out, nil
}
// ListExploreTabs 返回预置 Explore tab 配置。
func (r StaticReader) ListExploreTabs(context.Context, string) ([]ExploreTab, error) {
if r.Err != nil {
return nil, r.Err
}
out := make([]ExploreTab, len(r.ExploreTabs))
copy(out, r.ExploreTabs)
return out, nil
}
// ListBanners 返回预置 App banner 配置。
func (r StaticReader) ListBanners(context.Context, BannerQuery) ([]Banner, error) {
if r.Err != nil {
return nil, r.Err
}
out := make([]Banner, len(r.Banners))
copy(out, r.Banners)
for index := range out {
if len(out[index].DisplayScopes) == 0 {
out[index].DisplayScopes = bannerDisplayScopeList(out[index].DisplayScope)
}
}
return out, nil
}
// ListSplashScreens 返回预置开屏配置,供 HTTP 单测和无 MySQL 场景复用同一 Reader 接口。
func (r StaticReader) ListSplashScreens(context.Context, SplashScreenQuery) ([]SplashScreen, error) {
if r.Err != nil {
return nil, r.Err
}
out := make([]SplashScreen, len(r.SplashScreens))
copy(out, r.SplashScreens)
return out, nil
}
// ListPopups 返回预置弹窗配置,供 HTTP 单测和无 MySQL 场景复用同一 Reader 接口。
func (r StaticReader) ListPopups(context.Context, PopupQuery) ([]Popup, error) {
if r.Err != nil {
return nil, r.Err
}
out := make([]Popup, len(r.Popups))
copy(out, r.Popups)
return out, nil
}
// LatestVersion 返回预置版本配置。
func (r StaticReader) LatestVersion(_ context.Context, query VersionQuery) (Version, error) {
if r.Err != nil {
return Version{}, r.Err
}
item := r.Version
if item.AppCode == "" {
item.AppCode = normalizeAppCode(query.AppCode)
}
if item.Platform == "" {
item.Platform = normalizePlatform(query.Platform)
}
return item, nil
}
// RoomRegionWhitelistAllows 返回内存白名单结果,测试可用 "app:user_id" 作为 key。
func (r StaticReader) RoomRegionWhitelistAllows(_ context.Context, appCode string, userID int64) (bool, error) {
if r.Err != nil {
return false, r.Err
}
return r.RoomWhitelist[bdLeaderPositionAliasKey(appCode, userID)], nil
}
func normalizeAppCode(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
return "lalu"
}
return value
}
func normalizePlatform(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func normalizeCountry(value string) string {
return strings.ToUpper(strings.TrimSpace(value))
}
func applyAdminH5LinkAlias(items []H5Link, alias string) {
alias = strings.TrimSpace(alias)
if alias == "" {
return
}
for index := range items {
if strings.EqualFold(strings.TrimSpace(items[index].Key), "admin") {
// admin 入口只覆盖展示名key 和 URL 仍来自后台 H5 配置,保证 Flutter 路由识别不变。
items[index].Label = alias
}
}
}
func bdLeaderPositionAliasKey(appCode string, userID int64) string {
return normalizeAppCode(appCode) + ":" + strconv.FormatInt(userID, 10)
}
func isMissingTableError(err error) bool {
var mysqlErr *mysqlDriver.MySQLError
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1146
}
// NormalizeBannerDisplayScope 统一 App 和后台约定的显示范围枚举。
func NormalizeBannerDisplayScope(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
switch value {
case "":
return "home"
case "home", "首页":
return "home"
case "room", "房间内":
return "room"
case "recharge", "充值页":
return "recharge"
case "me", "我的页", "我的":
return "me"
default:
return ""
}
}
func bannerDisplayScopeList(value string) []string {
seen := make(map[string]struct{}, 4)
for _, part := range strings.Split(value, ",") {
scope := NormalizeBannerDisplayScope(part)
if scope != "" {
seen[scope] = struct{}{}
}
}
out := make([]string, 0, len(seen))
for _, scope := range []string{"home", "room", "recharge", "me"} {
if _, ok := seen[scope]; ok {
out = append(out, scope)
}
}
return out
}