From b6d240e86fbca848ff2df244a99b2f9c7b2d9c44 Mon Sep 17 00:00:00 2001 From: zhx Date: Fri, 10 Jul 2026 20:23:43 +0800 Subject: [PATCH] fix: isolate H5 links by app code --- server/admin/internal/model/models.go | 6 +- .../internal/modules/appconfig/handler.go | 23 +-- .../internal/modules/appconfig/service.go | 66 +++++--- .../modules/appconfig/service_test.go | 31 +++- .../repository/app_config_repository.go | 143 +++++++++++++++++- .../migrations/086_app_config_app_scope.sql | 11 ++ .../internal/appconfig/mysql.go | 89 +++++++++-- .../internal/appconfig/mysql_test.go | 53 ++++++- 8 files changed, 370 insertions(+), 52 deletions(-) create mode 100644 server/admin/migrations/086_app_config_app_scope.sql diff --git a/server/admin/internal/model/models.go b/server/admin/internal/model/models.go index ff2c2ad7..00bc450c 100644 --- a/server/admin/internal/model/models.go +++ b/server/admin/internal/model/models.go @@ -125,10 +125,12 @@ func (Menu) TableName() string { type AppConfig struct { ID uint `gorm:"primaryKey" json:"id"` - Group string `gorm:"size:64;index:idx_admin_app_config_group_key,unique;not null" json:"group"` - Key string `gorm:"size:100;index:idx_admin_app_config_group_key,unique;not null" json:"key"` + AppCode string `gorm:"size:32;uniqueIndex:uk_admin_app_config_app_group_key;index:idx_admin_app_configs_app_group;not null" json:"appCode"` + Group string `gorm:"size:64;uniqueIndex:uk_admin_app_config_app_group_key;index:idx_admin_app_configs_app_group;not null" json:"group"` + Key string `gorm:"size:100;uniqueIndex:uk_admin_app_config_app_group_key;not null" json:"key"` Value string `gorm:"type:text" json:"value"` Description string `gorm:"size:255" json:"description"` + IsDeleted bool `gorm:"column:is_deleted;not null;default:false" json:"-"` CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"` UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"` } diff --git a/server/admin/internal/modules/appconfig/handler.go b/server/admin/internal/modules/appconfig/handler.go index 525b32b7..b049e7fb 100644 --- a/server/admin/internal/modules/appconfig/handler.go +++ b/server/admin/internal/modules/appconfig/handler.go @@ -23,7 +23,8 @@ func New(store *repository.Store, audit shared.OperationLogger) *Handler { } func (h *Handler) ListH5Links(c *gin.Context) { - items, err := h.service.ListH5Links() + appCode := appctx.FromContext(c.Request.Context()) + items, err := h.service.ListH5Links(appCode) if err != nil { response.ServerError(c, "获取 H5 配置失败") return @@ -38,12 +39,13 @@ func (h *Handler) UpdateH5Links(c *gin.Context) { return } - items, err := h.service.UpdateH5Links(request) + appCode := appctx.FromContext(c.Request.Context()) + items, err := h.service.UpdateH5Links(appCode, request) if err != nil { response.BadRequest(c, err.Error()) return } - shared.OperationLog(c, h.audit, "update-app-h5-links", "admin_app_configs", "success", fmt.Sprintf("%d h5 links", len(request.Items))) + shared.OperationLog(c, h.audit, "update-app-h5-links", "admin_app_configs", "success", fmt.Sprintf("app_code=%s %d h5 links", appCode, len(request.Items))) response.OK(c, gin.H{"items": items, "total": len(items)}) } @@ -53,12 +55,13 @@ func (h *Handler) CreateH5Link(c *gin.Context) { response.BadRequest(c, "参数不正确") return } - item, err := h.service.CreateH5Link(request) + appCode := appctx.FromContext(c.Request.Context()) + item, err := h.service.CreateH5Link(appCode, request) if err != nil { response.BadRequest(c, err.Error()) return } - shared.OperationLog(c, h.audit, "create-app-h5-link", "admin_app_configs", "success", fmt.Sprintf("h5_key=%s", item.Key)) + shared.OperationLog(c, h.audit, "create-app-h5-link", "admin_app_configs", "success", fmt.Sprintf("app_code=%s h5_key=%s", appCode, item.Key)) response.Created(c, item) } @@ -73,12 +76,13 @@ func (h *Handler) UpdateH5Link(c *gin.Context) { response.BadRequest(c, "参数不正确") return } - item, err := h.service.UpdateH5Link(key, request) + appCode := appctx.FromContext(c.Request.Context()) + item, err := h.service.UpdateH5Link(appCode, key, request) if err != nil { response.BadRequest(c, err.Error()) return } - shared.OperationLog(c, h.audit, "update-app-h5-link", "admin_app_configs", "success", fmt.Sprintf("h5_key=%s", item.Key)) + shared.OperationLog(c, h.audit, "update-app-h5-link", "admin_app_configs", "success", fmt.Sprintf("app_code=%s h5_key=%s", appCode, item.Key)) response.OK(c, item) } @@ -88,11 +92,12 @@ func (h *Handler) DeleteH5Link(c *gin.Context) { response.BadRequest(c, "h5 config key is invalid") return } - if err := h.service.DeleteH5Link(key); err != nil { + appCode := appctx.FromContext(c.Request.Context()) + if err := h.service.DeleteH5Link(appCode, key); err != nil { response.BadRequest(c, err.Error()) return } - shared.OperationLog(c, h.audit, "delete-app-h5-link", "admin_app_configs", "success", fmt.Sprintf("h5_key=%s", key)) + shared.OperationLog(c, h.audit, "delete-app-h5-link", "admin_app_configs", "success", fmt.Sprintf("app_code=%s h5_key=%s", appCode, key)) response.OK(c, gin.H{"deleted": true}) } diff --git a/server/admin/internal/modules/appconfig/service.go b/server/admin/internal/modules/appconfig/service.go index 5f39c0b4..02639a95 100644 --- a/server/admin/internal/modules/appconfig/service.go +++ b/server/admin/internal/modules/appconfig/service.go @@ -11,6 +11,8 @@ import ( "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/model" "hyapp-admin-server/internal/repository" + + "gorm.io/gorm" ) const h5LinkGroup = "h5-links" @@ -35,6 +37,7 @@ type AppConfigService struct { } type H5Link struct { + AppCode string `json:"appCode"` Key string `json:"key"` Label string `json:"label"` URL string `json:"url"` @@ -126,8 +129,9 @@ func NewService(store *repository.Store) *AppConfigService { return &AppConfigService{store: store} } -func (s *AppConfigService) ListH5Links() ([]H5Link, error) { - configs, err := s.store.ListAppConfigs(h5LinkGroup) +func (s *AppConfigService) ListH5Links(appCode string) ([]H5Link, error) { + appCode = appctx.Normalize(appCode) + configs, err := s.store.ListScopedAppConfigs(appCode, h5LinkGroup) if err != nil { return nil, err } @@ -139,7 +143,8 @@ func (s *AppConfigService) ListH5Links() ([]H5Link, error) { return items, nil } -func (s *AppConfigService) UpdateH5Links(request updateH5LinksRequest) ([]H5Link, error) { +func (s *AppConfigService) UpdateH5Links(appCode string, request updateH5LinksRequest) ([]H5Link, error) { + appCode = appctx.Normalize(appCode) if len(request.Items) == 0 { return nil, errors.New("items are required") } @@ -147,7 +152,7 @@ func (s *AppConfigService) UpdateH5Links(request updateH5LinksRequest) ([]H5Link 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) + config, err := h5LinkModelFromPayload(appCode, item) if err != nil { return nil, err } @@ -159,43 +164,62 @@ func (s *AppConfigService) UpdateH5Links(request updateH5LinksRequest) ([]H5Link items = append(items, config) } - if err := s.store.UpsertAppConfigs(items); err != nil { + if err := s.store.UpsertScopedAppConfigs(appCode, items); err != nil { return nil, err } - return s.ListH5Links() + return s.ListH5Links(appCode) } -func (s *AppConfigService) CreateH5Link(req h5LinkPayload) (H5Link, error) { - item, err := h5LinkModelFromPayload(req) +func (s *AppConfigService) CreateH5Link(appCode string, req h5LinkPayload) (H5Link, error) { + appCode = appctx.Normalize(appCode) + item, err := h5LinkModelFromPayload(appCode, req) if err != nil { return H5Link{}, err } - if err := s.store.CreateAppConfig(&item); err != nil { + if _, err := s.store.GetScopedAppConfig(appCode, h5LinkGroup, item.Key); err == nil { + return H5Link{}, fmt.Errorf("h5 link key already exists: %s", item.Key) + } else if !errors.Is(err, gorm.ErrRecordNotFound) { return H5Link{}, err } - return h5LinkFromModel(item), nil + // 创建同样使用 upsert,以便显式恢复当前 App 曾删除过的 inherited key,而不触碰 legacy 基线。 + if err := s.store.UpsertScopedAppConfigs(appCode, []model.AppConfig{item}); err != nil { + return H5Link{}, err + } + stored, err := s.store.GetScopedAppConfig(appCode, h5LinkGroup, item.Key) + if err != nil { + return H5Link{}, err + } + return h5LinkFromModel(stored), nil } -func (s *AppConfigService) UpdateH5Link(key string, req h5LinkPayload) (H5Link, error) { - item, err := s.store.GetAppConfig(h5LinkGroup, key) +func (s *AppConfigService) UpdateH5Link(appCode string, key string, req h5LinkPayload) (H5Link, error) { + appCode = appctx.Normalize(appCode) + item, err := s.store.GetScopedAppConfig(appCode, h5LinkGroup, key) if err != nil { return H5Link{}, err } req.Key = item.Key - updated, err := h5LinkModelFromPayload(req) + updated, err := h5LinkModelFromPayload(appCode, req) if err != nil { return H5Link{}, err } - item.Value = updated.Value - item.Description = updated.Description - if err := s.store.UpdateAppConfig(&item); err != nil { + // inherited 基线永远不原地更新;upsert 会为当前 App 创建或覆盖 scoped 行。 + if err := s.store.UpsertScopedAppConfigs(appCode, []model.AppConfig{updated}); err != nil { return H5Link{}, err } - return h5LinkFromModel(item), nil + stored, err := s.store.GetScopedAppConfig(appCode, h5LinkGroup, updated.Key) + if err != nil { + return H5Link{}, err + } + return h5LinkFromModel(stored), nil } -func (s *AppConfigService) DeleteH5Link(key string) error { - return s.store.DeleteAppConfig(h5LinkGroup, key) +func (s *AppConfigService) DeleteH5Link(appCode string, key string) error { + appCode = appctx.Normalize(appCode) + if _, err := s.store.GetScopedAppConfig(appCode, h5LinkGroup, key); err != nil { + return err + } + return s.store.TombstoneScopedAppConfig(appCode, h5LinkGroup, key) } func (s *AppConfigService) ListBanners(appCode string, options repository.AppBannerListOptions) ([]AppBanner, error) { @@ -517,7 +541,7 @@ func validateH5URL(value string) error { return nil } -func h5LinkModelFromPayload(req h5LinkPayload) (model.AppConfig, error) { +func h5LinkModelFromPayload(appCode string, req h5LinkPayload) (model.AppConfig, error) { key := strings.TrimSpace(req.Key) label := strings.TrimSpace(req.Label) url := strings.TrimSpace(req.URL) @@ -534,6 +558,7 @@ func h5LinkModelFromPayload(req h5LinkPayload) (model.AppConfig, error) { return model.AppConfig{}, err } return model.AppConfig{ + AppCode: appctx.Normalize(appCode), Group: h5LinkGroup, Key: key, Value: url, @@ -564,6 +589,7 @@ func h5LinkFromModel(config model.AppConfig) H5Link { label = config.Key } return H5Link{ + AppCode: appctx.Normalize(config.AppCode), Key: config.Key, Label: label, URL: config.Value, diff --git a/server/admin/internal/modules/appconfig/service_test.go b/server/admin/internal/modules/appconfig/service_test.go index ca1726fb..098fa0f7 100644 --- a/server/admin/internal/modules/appconfig/service_test.go +++ b/server/admin/internal/modules/appconfig/service_test.go @@ -1,14 +1,39 @@ package appconfig import ( + "os" + "strings" "testing" "time" "hyapp-admin-server/internal/model" ) +func TestH5AppScopeMigrationKeepsAppAgnosticLegacyBaseline(t *testing.T) { + body, err := os.ReadFile("../../../migrations/086_app_config_app_scope.sql") + if err != nil { + t.Fatalf("read h5 app scope migration failed: %v", err) + } + sqlText := string(body) + for _, snippet := range []string{ + "app_code VARCHAR(32) NOT NULL DEFAULT ''", + "is_deleted BOOLEAN NOT NULL DEFAULT FALSE", + "uk_admin_app_config_app_group_key (app_code, `group`, `key`)", + } { + if !strings.Contains(sqlText, snippet) { + t.Fatalf("h5 app scope migration missing %q", snippet) + } + } + // 迁移不能枚举当前 App;空基线 + runtime override 才能让未来 app_code 自动继承旧配置。 + for _, appCode := range []string{"huwaa", "fami", "yumi", "aslan"} { + if strings.Contains(sqlText, "'"+appCode+"'") { + t.Fatalf("migration must not hard-code app_code %q", appCode) + } + } +} + func TestH5LinkModelFromPayloadValidatesDynamicConfig(t *testing.T) { - item, err := h5LinkModelFromPayload(h5LinkPayload{ + item, err := h5LinkModelFromPayload(" YUMI ", h5LinkPayload{ Key: "host-center.v2", Label: "Host Center", URL: "https://h5.example.com/host", @@ -16,13 +41,13 @@ func TestH5LinkModelFromPayloadValidatesDynamicConfig(t *testing.T) { if err != nil { t.Fatalf("h5 config should be valid: %v", err) } - if item.Group != h5LinkGroup || item.Key != "host-center.v2" || item.Description != "Host Center" || item.Value != "https://h5.example.com/host" { + if item.AppCode != "yumi" || item.Group != h5LinkGroup || item.Key != "host-center.v2" || item.Description != "Host Center" || item.Value != "https://h5.example.com/host" { t.Fatalf("h5 config model mismatch: %+v", item) } } func TestH5LinkModelFromPayloadRejectsInvalidKey(t *testing.T) { - _, err := h5LinkModelFromPayload(h5LinkPayload{ + _, err := h5LinkModelFromPayload("lalu", h5LinkPayload{ Key: "host center", Label: "Host Center", URL: "https://h5.example.com/host", diff --git a/server/admin/internal/repository/app_config_repository.go b/server/admin/internal/repository/app_config_repository.go index 54ceb22f..4e2edc26 100644 --- a/server/admin/internal/repository/app_config_repository.go +++ b/server/admin/internal/repository/app_config_repository.go @@ -1,11 +1,13 @@ package repository import ( + "sort" "strings" "time" "hyapp-admin-server/internal/model" + "gorm.io/gorm" "gorm.io/gorm/clause" ) @@ -23,13 +25,14 @@ type AppExploreTabListOptions struct { func (s *Store) ListAppConfigs(group string) ([]model.AppConfig, error) { var items []model.AppConfig - err := s.db.Where("`group` = ?", strings.TrimSpace(group)).Order("`key` ASC").Find(&items).Error + // 旧的通用配置继续使用 app_code='' 基线行;H5 配置必须走 scoped API,避免覆盖行被其他旧调用方误读。 + err := s.db.Where("app_code = '' AND `group` = ?", strings.TrimSpace(group)).Order("`key` ASC").Find(&items).Error return items, err } func (s *Store) GetAppConfig(group string, key string) (model.AppConfig, error) { var item model.AppConfig - err := s.db.Where("`group` = ? AND `key` = ?", strings.TrimSpace(group), strings.TrimSpace(key)).First(&item).Error + err := s.db.Where("app_code = '' AND `group` = ? AND `key` = ?", strings.TrimSpace(group), strings.TrimSpace(key)).First(&item).Error return item, err } @@ -42,7 +45,7 @@ func (s *Store) UpdateAppConfig(item *model.AppConfig) error { } func (s *Store) DeleteAppConfig(group string, key string) error { - return s.db.Where("`group` = ? AND `key` = ?", strings.TrimSpace(group), strings.TrimSpace(key)).Delete(&model.AppConfig{}).Error + return s.db.Where("app_code = '' AND `group` = ? AND `key` = ?", strings.TrimSpace(group), strings.TrimSpace(key)).Delete(&model.AppConfig{}).Error } func (s *Store) UpsertAppConfigs(items []model.AppConfig) error { @@ -52,8 +55,10 @@ func (s *Store) UpsertAppConfigs(items []model.AppConfig) error { nowMS := time.Now().UTC().UnixMilli() for index := range items { + items[index].AppCode = "" items[index].Group = strings.TrimSpace(items[index].Group) items[index].Key = strings.TrimSpace(items[index].Key) + items[index].IsDeleted = false items[index].UpdatedAtMS = nowMS if items[index].CreatedAtMS == 0 { items[index].CreatedAtMS = nowMS @@ -62,13 +67,143 @@ func (s *Store) UpsertAppConfigs(items []model.AppConfig) error { return s.db.Clauses(clause.OnConflict{ Columns: []clause.Column{ + {Name: "app_code"}, {Name: "group"}, {Name: "key"}, }, - DoUpdates: clause.AssignmentColumns([]string{"value", "description", "updated_at_ms"}), + DoUpdates: clause.AssignmentColumns([]string{"value", "description", "is_deleted", "updated_at_ms"}), }).Create(&items).Error } +// ListScopedAppConfigs 返回当前 App 的有效配置视图:scoped 行覆盖 legacy 基线,墓碑显式屏蔽基线。 +// legacy 基线保持只读,因此迁移后未知的新 App 也能继续获得原配置,同时任意 App 的写操作不会污染其他 App。 +func (s *Store) ListScopedAppConfigs(appCode string, group string) ([]model.AppConfig, error) { + appCode = normalizeScopedAppCode(appCode) + var rows []model.AppConfig + err := s.db. + Where("`group` = ? AND app_code IN ?", strings.TrimSpace(group), []string{"", appCode}). + Order("`key` ASC, app_code ASC"). + Find(&rows).Error + if err != nil { + return nil, err + } + return mergeScopedAppConfigs(rows, appCode), nil +} + +// GetScopedAppConfig 和列表使用同一覆盖规则,防止更新/删除误操作其他 App 或 legacy 基线行。 +func (s *Store) GetScopedAppConfig(appCode string, group string, key string) (model.AppConfig, error) { + appCode = normalizeScopedAppCode(appCode) + var rows []model.AppConfig + err := s.db. + Where("`group` = ? AND `key` = ? AND app_code IN ?", strings.TrimSpace(group), strings.TrimSpace(key), []string{"", appCode}). + Order("app_code ASC"). + Find(&rows).Error + if err != nil { + return model.AppConfig{}, err + } + items := mergeScopedAppConfigs(rows, appCode) + if len(items) == 0 { + return model.AppConfig{}, gorm.ErrRecordNotFound + } + return items[0], nil +} + +// UpsertScopedAppConfigs 只写当前 app_code;调用方无法通过请求体覆盖租户作用域。 +func (s *Store) UpsertScopedAppConfigs(appCode string, items []model.AppConfig) error { + if len(items) == 0 { + return nil + } + appCode = normalizeScopedAppCode(appCode) + nowMS := time.Now().UTC().UnixMilli() + for index := range items { + items[index].AppCode = appCode + items[index].Group = strings.TrimSpace(items[index].Group) + items[index].Key = strings.TrimSpace(items[index].Key) + items[index].IsDeleted = false + items[index].UpdatedAtMS = nowMS + if items[index].CreatedAtMS == 0 { + items[index].CreatedAtMS = nowMS + } + } + return s.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "app_code"}, {Name: "group"}, {Name: "key"}}, + DoUpdates: clause.AssignmentColumns([]string{"value", "description", "is_deleted", "updated_at_ms"}), + }).Create(&items).Error +} + +// TombstoneScopedAppConfig 不物理删除 scoped 行。若配置来自 legacy 基线,墓碑会阻止它在删除后重新出现。 +func (s *Store) TombstoneScopedAppConfig(appCode string, group string, key string) error { + appCode = normalizeScopedAppCode(appCode) + nowMS := time.Now().UTC().UnixMilli() + item := model.AppConfig{ + AppCode: appCode, + Group: strings.TrimSpace(group), + Key: strings.TrimSpace(key), + IsDeleted: true, + CreatedAtMS: nowMS, + UpdatedAtMS: nowMS, + } + return s.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "app_code"}, {Name: "group"}, {Name: "key"}}, + DoUpdates: clause.Assignments(map[string]any{ + "is_deleted": true, + "updated_at_ms": nowMS, + }), + }).Create(&item).Error +} + +func normalizeScopedAppCode(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + +func mergeScopedAppConfigs(rows []model.AppConfig, appCode string) []model.AppConfig { + appCode = normalizeScopedAppCode(appCode) + baseline := make(map[string]model.AppConfig) + overrides := make(map[string]model.AppConfig) + for _, item := range rows { + item.AppCode = normalizeScopedAppCode(item.AppCode) + item.Group = strings.TrimSpace(item.Group) + item.Key = strings.TrimSpace(item.Key) + if item.Key == "" { + continue + } + switch item.AppCode { + case "": + baseline[item.Key] = item + case appCode: + overrides[item.Key] = item + } + } + + keys := make(map[string]struct{}, len(baseline)+len(overrides)) + for key := range baseline { + keys[key] = struct{}{} + } + for key := range overrides { + keys[key] = struct{}{} + } + sortedKeys := make([]string, 0, len(keys)) + for key := range keys { + sortedKeys = append(sortedKeys, key) + } + sort.Strings(sortedKeys) + + items := make([]model.AppConfig, 0, len(sortedKeys)) + for _, key := range sortedKeys { + item, overridden := overrides[key] + if !overridden { + item = baseline[key] + } + if item.IsDeleted { + continue + } + // 对外有效视图始终归属于当前 App;baseline 的空作用域不应泄漏给响应或后续写操作。 + item.AppCode = appCode + items = append(items, item) + } + return items +} + func (s *Store) ListAppVersions(options AppVersionListOptions) ([]model.AppVersion, error) { db := s.db.Where("app_code = ?", strings.TrimSpace(options.AppCode)) if platform := strings.TrimSpace(options.Platform); platform != "" { diff --git a/server/admin/migrations/086_app_config_app_scope.sql b/server/admin/migrations/086_app_config_app_scope.sql new file mode 100644 index 00000000..0f02b786 --- /dev/null +++ b/server/admin/migrations/086_app_config_app_scope.sql @@ -0,0 +1,11 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- 历史 admin_app_configs 没有租户列,所有 App 共同读取同一行。迁移把这些行保留为 app_code='' +-- 的只读默认基线:未知或未来新增 App 仍能读取原配置,后续写操作只创建当前 App 的 scoped override。 +-- 同一个 key 可以同时存在一行 legacy 基线和多行 App override;三元唯一键是隔离写入的最终兜底。 +ALTER TABLE admin_app_configs + ADD COLUMN app_code VARCHAR(32) NOT NULL DEFAULT '' COMMENT '应用编码;空值仅表示迁移前的只读默认基线' AFTER id, + ADD COLUMN is_deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '当前 App 删除墓碑;用于屏蔽默认基线' AFTER description, + DROP INDEX idx_admin_app_config_group_key, + ADD UNIQUE KEY uk_admin_app_config_app_group_key (app_code, `group`, `key`), + ADD KEY idx_admin_app_configs_app_group (app_code, `group`); diff --git a/services/gateway-service/internal/appconfig/mysql.go b/services/gateway-service/internal/appconfig/mysql.go index 53213c1c..3bd9adac 100644 --- a/services/gateway-service/internal/appconfig/mysql.go +++ b/services/gateway-service/internal/appconfig/mysql.go @@ -6,6 +6,7 @@ import ( "database/sql" "encoding/json" "errors" + "sort" "strconv" "strings" "time" @@ -17,8 +18,8 @@ import ( 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 listH5LinksSQL = "SELECT app_code, `key`, COALESCE(description, ''), COALESCE(value, ''), is_deleted, updated_at_ms FROM admin_app_configs WHERE `group` = ? AND app_code IN ('', ?) ORDER BY `key` ASC, app_code ASC" +const getRoomRegionWhitelistSQL = "SELECT COALESCE(value, '') FROM admin_app_configs WHERE app_code = '' AND `group` = ? AND `key` = ? LIMIT 1" const bdLeaderPositionAliasSQL = ` SELECT position_alias FROM admin_bd_leader_position_aliases @@ -71,6 +72,16 @@ type H5Link struct { UpdatedAtMs int64 `json:"updated_at_ms"` } +// storedH5Link 保留数据库作用域和墓碑字段;对 App 的公开响应只返回合并后的 H5Link。 +type storedH5Link struct { + AppCode string + Key string + Label string + URL string + IsDeleted bool + UpdatedAtMs int64 +} + // H5LinkQuery 是 H5 入口配置的可选用户态筛选条件。 type H5LinkQuery struct { AppCode string @@ -262,29 +273,25 @@ func (r *MySQLReader) ListH5Links(ctx context.Context, query H5LinkQuery) ([]H5L return nil, errors.New("app config reader is not configured") } - rows, err := r.db.QueryContext(ctx, listH5LinksSQL, h5LinkGroup) + appCode := normalizeAppCode(query.AppCode) + rows, err := r.db.QueryContext(ctx, listH5LinksSQL, h5LinkGroup, appCode) if err != nil { return nil, err } defer rows.Close() - items := make([]H5Link, 0) + stored := make([]storedH5Link, 0) for rows.Next() { - var item H5Link - if err := rows.Scan(&item.Key, &item.Label, &item.URL, &item.UpdatedAtMs); err != nil { + var item storedH5Link + if err := rows.Scan(&item.AppCode, &item.Key, &item.Label, &item.URL, &item.IsDeleted, &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) + stored = append(stored, item) } if err := rows.Err(); err != nil { return nil, err } + items := mergeStoredH5Links(stored, appCode) if alias, err := r.bdLeaderPositionAlias(ctx, query); err != nil { return nil, err } else if alias != "" { @@ -293,6 +300,62 @@ func (r *MySQLReader) ListH5Links(ctx context.Context, query H5LinkQuery) ([]H5L return items, nil } +// mergeStoredH5Links 与 admin-server 使用同一规则:App override 覆盖 legacy 基线,墓碑阻止已删除 key 回退重现。 +func mergeStoredH5Links(rows []storedH5Link, appCode string) []H5Link { + appCode = normalizeAppCode(appCode) + baseline := make(map[string]storedH5Link) + overrides := make(map[string]storedH5Link) + for _, item := range rows { + item.AppCode = strings.ToLower(strings.TrimSpace(item.AppCode)) + item.Key = strings.TrimSpace(item.Key) + item.Label = strings.TrimSpace(item.Label) + item.URL = strings.TrimSpace(item.URL) + if item.Key == "" { + continue + } + switch item.AppCode { + case "": + baseline[item.Key] = item + case appCode: + overrides[item.Key] = item + } + } + + keys := make(map[string]struct{}, len(baseline)+len(overrides)) + for key := range baseline { + keys[key] = struct{}{} + } + for key := range overrides { + keys[key] = struct{}{} + } + sortedKeys := make([]string, 0, len(keys)) + for key := range keys { + sortedKeys = append(sortedKeys, key) + } + sort.Strings(sortedKeys) + + items := make([]H5Link, 0, len(sortedKeys)) + for _, key := range sortedKeys { + stored, overridden := overrides[key] + if !overridden { + stored = baseline[key] + } + if stored.IsDeleted { + continue + } + if stored.Label == "" { + stored.Label = stored.Key + } + items = append(items, H5Link{ + Key: stored.Key, + Label: stored.Label, + URL: stored.URL, + UpdatedAtMs: stored.UpdatedAtMs, + }) + } + return items +} + func (r *MySQLReader) bdLeaderPositionAlias(ctx context.Context, query H5LinkQuery) (string, error) { if query.UserID <= 0 { return "", nil diff --git a/services/gateway-service/internal/appconfig/mysql_test.go b/services/gateway-service/internal/appconfig/mysql_test.go index 1b430ea7..9aff7951 100644 --- a/services/gateway-service/internal/appconfig/mysql_test.go +++ b/services/gateway-service/internal/appconfig/mysql_test.go @@ -1,6 +1,57 @@ package appconfig -import "testing" +import ( + "context" + "regexp" + "testing" + + "github.com/DATA-DOG/go-sqlmock" +) + +func TestListH5LinksScopesQueryAndMergesLegacyBaseline(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("new sql mock failed: %v", err) + } + defer db.Close() + + mock.ExpectQuery(regexp.QuoteMeta(listH5LinksSQL)). + WithArgs(h5LinkGroup, "yumi"). + WillReturnRows(sqlmock.NewRows([]string{"app_code", "key", "description", "value", "is_deleted", "updated_at_ms"}). + AddRow("", "admin", "Admin", "https://legacy.example.com/admin", false, int64(100)). + AddRow("yumi", "admin", "Yumi Admin", "https://yumi.example.com/admin", false, int64(200)). + AddRow("", "host", "Host", "https://legacy.example.com/host", false, int64(100)). + AddRow("yumi", "host", "", "", true, int64(300)). + AddRow("", "public", "", "https://legacy.example.com/public", false, int64(100))) + + reader := &MySQLReader{db: db} + items, err := reader.ListH5Links(context.Background(), H5LinkQuery{AppCode: " YUMI "}) + if err != nil { + t.Fatalf("list scoped h5 links failed: %v", err) + } + if len(items) != 2 { + t.Fatalf("unexpected merged h5 links: %+v", items) + } + if items[0].Key != "admin" || items[0].Label != "Yumi Admin" || items[0].URL != "https://yumi.example.com/admin" { + t.Fatalf("scoped override did not replace baseline: %+v", items[0]) + } + if items[1].Key != "public" || items[1].Label != "public" || items[1].URL != "https://legacy.example.com/public" { + t.Fatalf("legacy baseline was not retained: %+v", items[1]) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("sql expectations mismatch: %v", err) + } +} + +func TestMergeStoredH5LinksKeepsBaselineForUnknownFutureApp(t *testing.T) { + items := mergeStoredH5Links([]storedH5Link{ + {AppCode: "", Key: "host", Label: "Host", URL: "https://legacy.example.com/host"}, + {AppCode: "another-app", Key: "host", Label: "Other Host", URL: "https://other.example.com/host"}, + }, "future-app") + if len(items) != 1 || items[0].Label != "Host" || items[0].URL != "https://legacy.example.com/host" { + t.Fatalf("future app must inherit only the legacy baseline: %+v", items) + } +} func TestNormalizeBannerDisplayScopeAcceptsChineseLabels(t *testing.T) { cases := map[string]string{