782 lines
23 KiB
Go
782 lines
23 KiB
Go
// 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
|
||
}
|