// Package appconfig 读取后台管理维护的 App 运行时配置。 package appconfig import ( "context" "database/sql" "errors" "strconv" "strings" "time" mysqlDriver "github.com/go-sql-driver/mysql" ) const h5LinkGroup = "h5-links" const listH5LinksSQL = "SELECT `key`, COALESCE(description, ''), COALESCE(value, ''), updated_at_ms FROM admin_app_configs WHERE `group` = ? ORDER BY `key` ASC" 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 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"` } // 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) LatestVersion(ctx context.Context, query VersionQuery) (Version, error) } // MySQLReader 从 hyapp_admin 读取后台 App 配置。 type MySQLReader struct { db *sql.DB } // 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 r.db.Close() } // 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 } // 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 } // StaticReader 给测试和临时环境提供内存版 H5 配置读取。 type StaticReader struct { Links []H5Link ExploreTabs []ExploreTab Banners []Banner Version Version Err error PositionAliases map[string]string } // 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 } // 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 } 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" default: return "" } } func bannerDisplayScopeList(value string) []string { seen := make(map[string]struct{}, 3) 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"} { if _, ok := seen[scope]; ok { out = append(out, scope) } } return out }