app区域
This commit is contained in:
parent
8715b19f28
commit
ed3e7096e3
@ -131,7 +131,8 @@ func NewService(store *repository.Store) *AppConfigService {
|
|||||||
|
|
||||||
func (s *AppConfigService) ListH5Links(appCode string) ([]H5Link, error) {
|
func (s *AppConfigService) ListH5Links(appCode string) ([]H5Link, error) {
|
||||||
appCode = appctx.Normalize(appCode)
|
appCode = appctx.Normalize(appCode)
|
||||||
configs, err := s.store.ListScopedAppConfigs(appCode, h5LinkGroup)
|
// H5 是 App 品牌和运营内容的一部分,未配置时必须返回空列表,不能继承其他 App 的历史链接。
|
||||||
|
configs, err := s.store.ListOwnedAppConfigs(appCode, h5LinkGroup)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -176,16 +177,16 @@ func (s *AppConfigService) CreateH5Link(appCode string, req h5LinkPayload) (H5Li
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return H5Link{}, err
|
return H5Link{}, err
|
||||||
}
|
}
|
||||||
if _, err := s.store.GetScopedAppConfig(appCode, h5LinkGroup, item.Key); err == nil {
|
if _, err := s.store.GetOwnedAppConfig(appCode, h5LinkGroup, item.Key); err == nil {
|
||||||
return H5Link{}, fmt.Errorf("h5 link key already exists: %s", item.Key)
|
return H5Link{}, fmt.Errorf("h5 link key already exists: %s", item.Key)
|
||||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return H5Link{}, err
|
return H5Link{}, err
|
||||||
}
|
}
|
||||||
// 创建同样使用 upsert,以便显式恢复当前 App 曾删除过的 inherited key,而不触碰 legacy 基线。
|
// upsert 只以三元唯一键写当前 App,重试不会触碰其他 App 的同名 H5 key。
|
||||||
if err := s.store.UpsertScopedAppConfigs(appCode, []model.AppConfig{item}); err != nil {
|
if err := s.store.UpsertScopedAppConfigs(appCode, []model.AppConfig{item}); err != nil {
|
||||||
return H5Link{}, err
|
return H5Link{}, err
|
||||||
}
|
}
|
||||||
stored, err := s.store.GetScopedAppConfig(appCode, h5LinkGroup, item.Key)
|
stored, err := s.store.GetOwnedAppConfig(appCode, h5LinkGroup, item.Key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return H5Link{}, err
|
return H5Link{}, err
|
||||||
}
|
}
|
||||||
@ -194,7 +195,7 @@ func (s *AppConfigService) CreateH5Link(appCode string, req h5LinkPayload) (H5Li
|
|||||||
|
|
||||||
func (s *AppConfigService) UpdateH5Link(appCode string, key string, req h5LinkPayload) (H5Link, error) {
|
func (s *AppConfigService) UpdateH5Link(appCode string, key string, req h5LinkPayload) (H5Link, error) {
|
||||||
appCode = appctx.Normalize(appCode)
|
appCode = appctx.Normalize(appCode)
|
||||||
item, err := s.store.GetScopedAppConfig(appCode, h5LinkGroup, key)
|
item, err := s.store.GetOwnedAppConfig(appCode, h5LinkGroup, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return H5Link{}, err
|
return H5Link{}, err
|
||||||
}
|
}
|
||||||
@ -203,11 +204,11 @@ func (s *AppConfigService) UpdateH5Link(appCode string, key string, req h5LinkPa
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return H5Link{}, err
|
return H5Link{}, err
|
||||||
}
|
}
|
||||||
// inherited 基线永远不原地更新;upsert 会为当前 App 创建或覆盖 scoped 行。
|
// 请求作用域决定 app_code;请求体只能更新当前 App 已存在的 key。
|
||||||
if err := s.store.UpsertScopedAppConfigs(appCode, []model.AppConfig{updated}); err != nil {
|
if err := s.store.UpsertScopedAppConfigs(appCode, []model.AppConfig{updated}); err != nil {
|
||||||
return H5Link{}, err
|
return H5Link{}, err
|
||||||
}
|
}
|
||||||
stored, err := s.store.GetScopedAppConfig(appCode, h5LinkGroup, updated.Key)
|
stored, err := s.store.GetOwnedAppConfig(appCode, h5LinkGroup, updated.Key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return H5Link{}, err
|
return H5Link{}, err
|
||||||
}
|
}
|
||||||
@ -216,10 +217,10 @@ func (s *AppConfigService) UpdateH5Link(appCode string, key string, req h5LinkPa
|
|||||||
|
|
||||||
func (s *AppConfigService) DeleteH5Link(appCode string, key string) error {
|
func (s *AppConfigService) DeleteH5Link(appCode string, key string) error {
|
||||||
appCode = appctx.Normalize(appCode)
|
appCode = appctx.Normalize(appCode)
|
||||||
if _, err := s.store.GetScopedAppConfig(appCode, h5LinkGroup, key); err != nil {
|
if _, err := s.store.GetOwnedAppConfig(appCode, h5LinkGroup, key); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return s.store.TombstoneScopedAppConfig(appCode, h5LinkGroup, key)
|
return s.store.DeleteOwnedAppConfig(appCode, h5LinkGroup, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AppConfigService) ListBanners(appCode string, options repository.AppBannerListOptions) ([]AppBanner, error) {
|
func (s *AppConfigService) ListBanners(appCode string, options repository.AppBannerListOptions) ([]AppBanner, error) {
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import (
|
|||||||
"hyapp-admin-server/internal/model"
|
"hyapp-admin-server/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestH5AppScopeMigrationKeepsAppAgnosticLegacyBaseline(t *testing.T) {
|
func TestH5AppScopeSchemaMigrationCreatesScopedKeys(t *testing.T) {
|
||||||
body, err := os.ReadFile("../../../migrations/086_app_config_app_scope.sql")
|
body, err := os.ReadFile("../../../migrations/086_app_config_app_scope.sql")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read h5 app scope migration failed: %v", err)
|
t.Fatalf("read h5 app scope migration failed: %v", err)
|
||||||
@ -25,7 +25,7 @@ func TestH5AppScopeMigrationKeepsAppAgnosticLegacyBaseline(t *testing.T) {
|
|||||||
t.Fatalf("h5 app scope migration missing %q", snippet)
|
t.Fatalf("h5 app scope migration missing %q", snippet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 迁移不能枚举当前 App;空基线 + runtime override 才能让未来 app_code 自动继承旧配置。
|
// 086 只负责补齐通用 scoped 存储结构;091 再按 H5 的强隔离边界迁走历史空作用域数据。
|
||||||
for _, appCode := range []string{"huwaa", "fami", "yumi", "aslan"} {
|
for _, appCode := range []string{"huwaa", "fami", "yumi", "aslan"} {
|
||||||
if strings.Contains(sqlText, "'"+appCode+"'") {
|
if strings.Contains(sqlText, "'"+appCode+"'") {
|
||||||
t.Fatalf("migration must not hard-code app_code %q", appCode)
|
t.Fatalf("migration must not hard-code app_code %q", appCode)
|
||||||
@ -33,6 +33,25 @@ func TestH5AppScopeMigrationKeepsAppAgnosticLegacyBaseline(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestH5StrictAppScopeMigrationMovesLegacyLinksToDefaultApp(t *testing.T) {
|
||||||
|
body, err := os.ReadFile("../../../migrations/091_h5_config_strict_app_scope.sql")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read strict h5 app scope migration failed: %v", err)
|
||||||
|
}
|
||||||
|
sqlText := string(body)
|
||||||
|
for _, snippet := range []string{
|
||||||
|
"scoped.app_code = 'lalu'",
|
||||||
|
"legacy.app_code = ''",
|
||||||
|
"legacy.`group` = 'h5-links'",
|
||||||
|
"SET app_code = 'lalu'",
|
||||||
|
"`group` = 'h5-links'",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(sqlText, snippet) {
|
||||||
|
t.Fatalf("strict h5 app scope migration missing %q", snippet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestH5LinkModelFromPayloadValidatesDynamicConfig(t *testing.T) {
|
func TestH5LinkModelFromPayloadValidatesDynamicConfig(t *testing.T) {
|
||||||
item, err := h5LinkModelFromPayload(" YUMI ", h5LinkPayload{
|
item, err := h5LinkModelFromPayload(" YUMI ", h5LinkPayload{
|
||||||
Key: "host-center.v2",
|
Key: "host-center.v2",
|
||||||
|
|||||||
@ -108,6 +108,35 @@ func (s *Store) GetScopedAppConfig(appCode string, group string, key string) (mo
|
|||||||
return items[0], nil
|
return items[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListOwnedAppConfigs 只返回当前 App 自己持有的配置行。H5 等产品入口不能继承空 app_code
|
||||||
|
// 基线,否则新增 App 在尚未配置时会直接暴露默认 App 的运营链接,形成跨 App 内容串用。
|
||||||
|
func (s *Store) ListOwnedAppConfigs(appCode string, group string) ([]model.AppConfig, error) {
|
||||||
|
var items []model.AppConfig
|
||||||
|
err := s.db.
|
||||||
|
Where("app_code = ? AND `group` = ? AND is_deleted = FALSE", normalizeScopedAppCode(appCode), strings.TrimSpace(group)).
|
||||||
|
Order("`key` ASC").
|
||||||
|
Find(&items).Error
|
||||||
|
return items, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOwnedAppConfig 和列表保持同一强隔离语义;更新与删除不能通过 legacy 基线找到其他
|
||||||
|
// App 可见的同名 key,也不能把墓碑当成可编辑配置。
|
||||||
|
func (s *Store) GetOwnedAppConfig(appCode string, group string, key string) (model.AppConfig, error) {
|
||||||
|
var item model.AppConfig
|
||||||
|
err := s.db.
|
||||||
|
Where("app_code = ? AND `group` = ? AND `key` = ? AND is_deleted = FALSE", normalizeScopedAppCode(appCode), strings.TrimSpace(group), strings.TrimSpace(key)).
|
||||||
|
First(&item).Error
|
||||||
|
return item, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOwnedAppConfig 物理删除当前 App 的配置。强隔离配置不存在跨 App 基线回退,因此无需保留
|
||||||
|
// 墓碑;删除后当前 App 应立即回到“未配置”状态。
|
||||||
|
func (s *Store) DeleteOwnedAppConfig(appCode string, group string, key string) error {
|
||||||
|
return s.db.
|
||||||
|
Where("app_code = ? AND `group` = ? AND `key` = ?", normalizeScopedAppCode(appCode), strings.TrimSpace(group), strings.TrimSpace(key)).
|
||||||
|
Delete(&model.AppConfig{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
// UpsertScopedAppConfigs 只写当前 app_code;调用方无法通过请求体覆盖租户作用域。
|
// UpsertScopedAppConfigs 只写当前 app_code;调用方无法通过请求体覆盖租户作用域。
|
||||||
func (s *Store) UpsertScopedAppConfigs(appCode string, items []model.AppConfig) error {
|
func (s *Store) UpsertScopedAppConfigs(appCode string, items []model.AppConfig) error {
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
|
|||||||
@ -10,11 +10,11 @@ import (
|
|||||||
|
|
||||||
func TestMergeScopedAppConfigsDoesNotLeakOverridesOrTombstonesAcrossApps(t *testing.T) {
|
func TestMergeScopedAppConfigsDoesNotLeakOverridesOrTombstonesAcrossApps(t *testing.T) {
|
||||||
rows := []model.AppConfig{
|
rows := []model.AppConfig{
|
||||||
{AppCode: "", Group: "h5-links", Key: "admin", Description: "Legacy Admin", Value: "https://legacy.example.com/admin"},
|
{AppCode: "", Group: "runtime-config", Key: "admin", Description: "Legacy Admin", Value: "https://legacy.example.com/admin"},
|
||||||
{AppCode: "", Group: "h5-links", Key: "host", Description: "Legacy Host", Value: "https://legacy.example.com/host"},
|
{AppCode: "", Group: "runtime-config", Key: "host", Description: "Legacy Host", Value: "https://legacy.example.com/host"},
|
||||||
{AppCode: "yumi", Group: "h5-links", Key: "admin", Description: "Yumi Admin", Value: "https://yumi.example.com/admin"},
|
{AppCode: "yumi", Group: "runtime-config", Key: "admin", Description: "Yumi Admin", Value: "https://yumi.example.com/admin"},
|
||||||
{AppCode: "yumi", Group: "h5-links", Key: "host", IsDeleted: true},
|
{AppCode: "yumi", Group: "runtime-config", Key: "host", IsDeleted: true},
|
||||||
{AppCode: "fami", Group: "h5-links", Key: "admin", Description: "Fami Admin", Value: "https://fami.example.com/admin"},
|
{AppCode: "fami", Group: "runtime-config", Key: "admin", Description: "Fami Admin", Value: "https://fami.example.com/admin"},
|
||||||
}
|
}
|
||||||
|
|
||||||
yumi := mergeScopedAppConfigs(rows, "yumi")
|
yumi := mergeScopedAppConfigs(rows, "yumi")
|
||||||
@ -37,11 +37,11 @@ func TestTombstoneScopedAppConfigWritesOnlyCurrentApp(t *testing.T) {
|
|||||||
|
|
||||||
mock.ExpectBegin()
|
mock.ExpectBegin()
|
||||||
mock.ExpectExec("INSERT INTO `admin_app_configs`").
|
mock.ExpectExec("INSERT INTO `admin_app_configs`").
|
||||||
WithArgs("yumi", "h5-links", "host", "", "", true, sqlmock.AnyArg(), sqlmock.AnyArg(), true, sqlmock.AnyArg()).
|
WithArgs("yumi", "runtime-config", "host", "", "", true, sqlmock.AnyArg(), sqlmock.AnyArg(), true, sqlmock.AnyArg()).
|
||||||
WillReturnResult(sqlmock.NewResult(9, 1))
|
WillReturnResult(sqlmock.NewResult(9, 1))
|
||||||
mock.ExpectCommit()
|
mock.ExpectCommit()
|
||||||
|
|
||||||
if err := store.TombstoneScopedAppConfig(" YUMI ", "h5-links", "host"); err != nil {
|
if err := store.TombstoneScopedAppConfig(" YUMI ", "runtime-config", "host"); err != nil {
|
||||||
t.Fatalf("write scoped tombstone failed: %v", err)
|
t.Fatalf("write scoped tombstone failed: %v", err)
|
||||||
}
|
}
|
||||||
if err := mock.ExpectationsWereMet(); err != nil {
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
@ -54,15 +54,15 @@ func TestListScopedAppConfigsOverlaysBaselineAndHonorsTombstone(t *testing.T) {
|
|||||||
defer closeStore()
|
defer closeStore()
|
||||||
|
|
||||||
mock.ExpectQuery("SELECT \\* FROM `admin_app_configs` WHERE `group` = \\? AND app_code IN \\(\\?,\\?\\) ORDER BY `key` ASC, app_code ASC").
|
mock.ExpectQuery("SELECT \\* FROM `admin_app_configs` WHERE `group` = \\? AND app_code IN \\(\\?,\\?\\) ORDER BY `key` ASC, app_code ASC").
|
||||||
WithArgs("h5-links", "", "yumi").
|
WithArgs("runtime-config", "", "yumi").
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"id", "app_code", "group", "key", "value", "description", "is_deleted", "created_at_ms", "updated_at_ms"}).
|
WillReturnRows(sqlmock.NewRows([]string{"id", "app_code", "group", "key", "value", "description", "is_deleted", "created_at_ms", "updated_at_ms"}).
|
||||||
AddRow(1, "", "h5-links", "admin", "https://legacy.example.com/admin", "Admin", false, int64(100), int64(100)).
|
AddRow(1, "", "runtime-config", "admin", "https://legacy.example.com/admin", "Admin", false, int64(100), int64(100)).
|
||||||
AddRow(2, "yumi", "h5-links", "admin", "https://yumi.example.com/admin", "Yumi Admin", false, int64(200), int64(200)).
|
AddRow(2, "yumi", "runtime-config", "admin", "https://yumi.example.com/admin", "Yumi Admin", false, int64(200), int64(200)).
|
||||||
AddRow(3, "", "h5-links", "host", "https://legacy.example.com/host", "Host", false, int64(100), int64(100)).
|
AddRow(3, "", "runtime-config", "host", "https://legacy.example.com/host", "Host", false, int64(100), int64(100)).
|
||||||
AddRow(4, "yumi", "h5-links", "host", "", "", true, int64(300), int64(300)).
|
AddRow(4, "yumi", "runtime-config", "host", "", "", true, int64(300), int64(300)).
|
||||||
AddRow(5, "", "h5-links", "public", "https://legacy.example.com/public", "Public", false, int64(100), int64(100)))
|
AddRow(5, "", "runtime-config", "public", "https://legacy.example.com/public", "Public", false, int64(100), int64(100)))
|
||||||
|
|
||||||
items, err := store.ListScopedAppConfigs(" YUMI ", "h5-links")
|
items, err := store.ListScopedAppConfigs(" YUMI ", "runtime-config")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("list scoped app configs failed: %v", err)
|
t.Fatalf("list scoped app configs failed: %v", err)
|
||||||
}
|
}
|
||||||
@ -79,3 +79,24 @@ func TestListScopedAppConfigsOverlaysBaselineAndHonorsTombstone(t *testing.T) {
|
|||||||
t.Fatalf("sql expectations mismatch: %v", err)
|
t.Fatalf("sql expectations mismatch: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListOwnedAppConfigsDoesNotReadLegacyOrOtherAppRows(t *testing.T) {
|
||||||
|
store, mock, closeStore := newRepositorySQLMock(t)
|
||||||
|
defer closeStore()
|
||||||
|
|
||||||
|
mock.ExpectQuery("SELECT \\* FROM `admin_app_configs` WHERE app_code = \\? AND `group` = \\? AND is_deleted = FALSE ORDER BY `key` ASC").
|
||||||
|
WithArgs("yumi", "h5-links").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "app_code", "group", "key", "value", "description", "is_deleted", "created_at_ms", "updated_at_ms"}).
|
||||||
|
AddRow(2, "yumi", "h5-links", "admin", "https://yumi.example.com/admin", "Yumi Admin", false, int64(200), int64(200)))
|
||||||
|
|
||||||
|
items, err := store.ListOwnedAppConfigs(" YUMI ", "h5-links")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list owned app configs failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 || items[0].AppCode != "yumi" || items[0].Key != "admin" {
|
||||||
|
t.Fatalf("owned config scope mismatch: %+v", items)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("sql expectations mismatch: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
18
server/admin/migrations/091_h5_config_strict_app_scope.sql
Normal file
18
server/admin/migrations/091_h5_config_strict_app_scope.sql
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 086 曾把迁移前的 H5 行保留为 app_code='' 全局基线,导致所有未配置 App 都继承同一批链接。
|
||||||
|
-- 历史后台默认 App 是 lalu:若 lalu 已有同名 override,保留 override 并删除旧基线;其余基线归属 lalu。
|
||||||
|
DELETE legacy
|
||||||
|
FROM admin_app_configs legacy
|
||||||
|
JOIN admin_app_configs scoped
|
||||||
|
ON scoped.app_code = 'lalu'
|
||||||
|
AND scoped.`group` = legacy.`group`
|
||||||
|
AND scoped.`key` = legacy.`key`
|
||||||
|
WHERE legacy.app_code = ''
|
||||||
|
AND legacy.`group` = 'h5-links';
|
||||||
|
|
||||||
|
UPDATE admin_app_configs
|
||||||
|
SET app_code = 'lalu',
|
||||||
|
is_deleted = FALSE
|
||||||
|
WHERE app_code = ''
|
||||||
|
AND `group` = 'h5-links';
|
||||||
@ -6,7 +6,6 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"sort"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -18,7 +17,7 @@ import (
|
|||||||
const h5LinkGroup = "h5-links"
|
const h5LinkGroup = "h5-links"
|
||||||
const roomRegionWhitelistGroup = "room-region-whitelist"
|
const roomRegionWhitelistGroup = "room-region-whitelist"
|
||||||
|
|
||||||
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 listH5LinksSQL = "SELECT `key`, COALESCE(description, ''), COALESCE(value, ''), updated_at_ms FROM admin_app_configs WHERE app_code = ? AND `group` = ? AND is_deleted = FALSE ORDER BY `key` ASC"
|
||||||
const getRoomRegionWhitelistSQL = "SELECT COALESCE(value, '') FROM admin_app_configs WHERE app_code = '' AND `group` = ? AND `key` = ? LIMIT 1"
|
const getRoomRegionWhitelistSQL = "SELECT COALESCE(value, '') FROM admin_app_configs WHERE app_code = '' AND `group` = ? AND `key` = ? LIMIT 1"
|
||||||
const bdLeaderPositionAliasSQL = `
|
const bdLeaderPositionAliasSQL = `
|
||||||
SELECT position_alias
|
SELECT position_alias
|
||||||
@ -72,16 +71,6 @@ type H5Link struct {
|
|||||||
UpdatedAtMs int64 `json:"updated_at_ms"`
|
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 入口配置的可选用户态筛选条件。
|
// H5LinkQuery 是 H5 入口配置的可选用户态筛选条件。
|
||||||
type H5LinkQuery struct {
|
type H5LinkQuery struct {
|
||||||
AppCode string
|
AppCode string
|
||||||
@ -274,24 +263,30 @@ func (r *MySQLReader) ListH5Links(ctx context.Context, query H5LinkQuery) ([]H5L
|
|||||||
}
|
}
|
||||||
|
|
||||||
appCode := normalizeAppCode(query.AppCode)
|
appCode := normalizeAppCode(query.AppCode)
|
||||||
rows, err := r.db.QueryContext(ctx, listH5LinksSQL, h5LinkGroup, appCode)
|
// H5 链接是 App 专属运营内容;未配置的 App 返回空列表,不能回退到空 app_code 历史基线。
|
||||||
|
rows, err := r.db.QueryContext(ctx, listH5LinksSQL, appCode, h5LinkGroup)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
stored := make([]storedH5Link, 0)
|
items := make([]H5Link, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var item storedH5Link
|
var item H5Link
|
||||||
if err := rows.Scan(&item.AppCode, &item.Key, &item.Label, &item.URL, &item.IsDeleted, &item.UpdatedAtMs); err != nil {
|
if err := rows.Scan(&item.Key, &item.Label, &item.URL, &item.UpdatedAtMs); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
stored = append(stored, item)
|
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 {
|
if err := rows.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items := mergeStoredH5Links(stored, appCode)
|
|
||||||
if alias, err := r.bdLeaderPositionAlias(ctx, query); err != nil {
|
if alias, err := r.bdLeaderPositionAlias(ctx, query); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
} else if alias != "" {
|
} else if alias != "" {
|
||||||
@ -300,62 +295,6 @@ func (r *MySQLReader) ListH5Links(ctx context.Context, query H5LinkQuery) ([]H5L
|
|||||||
return items, nil
|
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) {
|
func (r *MySQLReader) bdLeaderPositionAlias(ctx context.Context, query H5LinkQuery) (string, error) {
|
||||||
if query.UserID <= 0 {
|
if query.UserID <= 0 {
|
||||||
return "", nil
|
return "", nil
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import (
|
|||||||
"github.com/DATA-DOG/go-sqlmock"
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestListH5LinksScopesQueryAndMergesLegacyBaseline(t *testing.T) {
|
func TestListH5LinksReadsOnlyRequestedApp(t *testing.T) {
|
||||||
db, mock, err := sqlmock.New()
|
db, mock, err := sqlmock.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("new sql mock failed: %v", err)
|
t.Fatalf("new sql mock failed: %v", err)
|
||||||
@ -16,13 +16,10 @@ func TestListH5LinksScopesQueryAndMergesLegacyBaseline(t *testing.T) {
|
|||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
mock.ExpectQuery(regexp.QuoteMeta(listH5LinksSQL)).
|
mock.ExpectQuery(regexp.QuoteMeta(listH5LinksSQL)).
|
||||||
WithArgs(h5LinkGroup, "yumi").
|
WithArgs("yumi", h5LinkGroup).
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"app_code", "key", "description", "value", "is_deleted", "updated_at_ms"}).
|
WillReturnRows(sqlmock.NewRows([]string{"key", "description", "value", "updated_at_ms"}).
|
||||||
AddRow("", "admin", "Admin", "https://legacy.example.com/admin", false, int64(100)).
|
AddRow("admin", "Yumi Admin", "https://yumi.example.com/admin", int64(200)).
|
||||||
AddRow("yumi", "admin", "Yumi Admin", "https://yumi.example.com/admin", false, int64(200)).
|
AddRow("public", "", "https://yumi.example.com/public", int64(300)))
|
||||||
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}
|
reader := &MySQLReader{db: db}
|
||||||
items, err := reader.ListH5Links(context.Background(), H5LinkQuery{AppCode: " YUMI "})
|
items, err := reader.ListH5Links(context.Background(), H5LinkQuery{AppCode: " YUMI "})
|
||||||
@ -35,24 +32,14 @@ func TestListH5LinksScopesQueryAndMergesLegacyBaseline(t *testing.T) {
|
|||||||
if items[0].Key != "admin" || items[0].Label != "Yumi Admin" || items[0].URL != "https://yumi.example.com/admin" {
|
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])
|
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" {
|
if items[1].Key != "public" || items[1].Label != "public" || items[1].URL != "https://yumi.example.com/public" {
|
||||||
t.Fatalf("legacy baseline was not retained: %+v", items[1])
|
t.Fatalf("requested app config mismatch: %+v", items[1])
|
||||||
}
|
}
|
||||||
if err := mock.ExpectationsWereMet(); err != nil {
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
t.Fatalf("sql expectations mismatch: %v", err)
|
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) {
|
func TestNormalizeBannerDisplayScopeAcceptsChineseLabels(t *testing.T) {
|
||||||
cases := map[string]string{
|
cases := map[string]string{
|
||||||
"": "home",
|
"": "home",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user