fix: isolate H5 links by app code
This commit is contained in:
parent
551ad9b03a
commit
b6d240e86f
@ -125,10 +125,12 @@ func (Menu) TableName() string {
|
|||||||
|
|
||||||
type AppConfig struct {
|
type AppConfig struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
Group string `gorm:"size:64;index:idx_admin_app_config_group_key,unique;not null" json:"group"`
|
AppCode string `gorm:"size:32;uniqueIndex:uk_admin_app_config_app_group_key;index:idx_admin_app_configs_app_group;not null" json:"appCode"`
|
||||||
Key string `gorm:"size:100;index:idx_admin_app_config_group_key,unique;not null" json:"key"`
|
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"`
|
Value string `gorm:"type:text" json:"value"`
|
||||||
Description string `gorm:"size:255" json:"description"`
|
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"`
|
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
|
||||||
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
|
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,7 +23,8 @@ func New(store *repository.Store, audit shared.OperationLogger) *Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) ListH5Links(c *gin.Context) {
|
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 {
|
if err != nil {
|
||||||
response.ServerError(c, "获取 H5 配置失败")
|
response.ServerError(c, "获取 H5 配置失败")
|
||||||
return
|
return
|
||||||
@ -38,12 +39,13 @@ func (h *Handler) UpdateH5Links(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
items, err := h.service.UpdateH5Links(request)
|
appCode := appctx.FromContext(c.Request.Context())
|
||||||
|
items, err := h.service.UpdateH5Links(appCode, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.BadRequest(c, err.Error())
|
response.BadRequest(c, err.Error())
|
||||||
return
|
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)})
|
response.OK(c, gin.H{"items": items, "total": len(items)})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,12 +55,13 @@ func (h *Handler) CreateH5Link(c *gin.Context) {
|
|||||||
response.BadRequest(c, "参数不正确")
|
response.BadRequest(c, "参数不正确")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
item, err := h.service.CreateH5Link(request)
|
appCode := appctx.FromContext(c.Request.Context())
|
||||||
|
item, err := h.service.CreateH5Link(appCode, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.BadRequest(c, err.Error())
|
response.BadRequest(c, err.Error())
|
||||||
return
|
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)
|
response.Created(c, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -73,12 +76,13 @@ func (h *Handler) UpdateH5Link(c *gin.Context) {
|
|||||||
response.BadRequest(c, "参数不正确")
|
response.BadRequest(c, "参数不正确")
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
response.BadRequest(c, err.Error())
|
response.BadRequest(c, err.Error())
|
||||||
return
|
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)
|
response.OK(c, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -88,11 +92,12 @@ func (h *Handler) DeleteH5Link(c *gin.Context) {
|
|||||||
response.BadRequest(c, "h5 config key is invalid")
|
response.BadRequest(c, "h5 config key is invalid")
|
||||||
return
|
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())
|
response.BadRequest(c, err.Error())
|
||||||
return
|
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})
|
response.OK(c, gin.H{"deleted": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,8 @@ import (
|
|||||||
"hyapp-admin-server/internal/appctx"
|
"hyapp-admin-server/internal/appctx"
|
||||||
"hyapp-admin-server/internal/model"
|
"hyapp-admin-server/internal/model"
|
||||||
"hyapp-admin-server/internal/repository"
|
"hyapp-admin-server/internal/repository"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
const h5LinkGroup = "h5-links"
|
const h5LinkGroup = "h5-links"
|
||||||
@ -35,6 +37,7 @@ type AppConfigService struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type H5Link struct {
|
type H5Link struct {
|
||||||
|
AppCode string `json:"appCode"`
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
@ -126,8 +129,9 @@ func NewService(store *repository.Store) *AppConfigService {
|
|||||||
return &AppConfigService{store: store}
|
return &AppConfigService{store: store}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AppConfigService) ListH5Links() ([]H5Link, error) {
|
func (s *AppConfigService) ListH5Links(appCode string) ([]H5Link, error) {
|
||||||
configs, err := s.store.ListAppConfigs(h5LinkGroup)
|
appCode = appctx.Normalize(appCode)
|
||||||
|
configs, err := s.store.ListScopedAppConfigs(appCode, h5LinkGroup)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -139,7 +143,8 @@ func (s *AppConfigService) ListH5Links() ([]H5Link, error) {
|
|||||||
return items, nil
|
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 {
|
if len(request.Items) == 0 {
|
||||||
return nil, errors.New("items are required")
|
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))
|
items := make([]model.AppConfig, 0, len(request.Items))
|
||||||
seen := make(map[string]struct{}, len(request.Items))
|
seen := make(map[string]struct{}, len(request.Items))
|
||||||
for _, item := range request.Items {
|
for _, item := range request.Items {
|
||||||
config, err := h5LinkModelFromPayload(item)
|
config, err := h5LinkModelFromPayload(appCode, item)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -159,43 +164,62 @@ func (s *AppConfigService) UpdateH5Links(request updateH5LinksRequest) ([]H5Link
|
|||||||
items = append(items, config)
|
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 nil, err
|
||||||
}
|
}
|
||||||
return s.ListH5Links()
|
return s.ListH5Links(appCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AppConfigService) CreateH5Link(req h5LinkPayload) (H5Link, error) {
|
func (s *AppConfigService) CreateH5Link(appCode string, req h5LinkPayload) (H5Link, error) {
|
||||||
item, err := h5LinkModelFromPayload(req)
|
appCode = appctx.Normalize(appCode)
|
||||||
|
item, err := h5LinkModelFromPayload(appCode, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return H5Link{}, err
|
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 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) {
|
func (s *AppConfigService) UpdateH5Link(appCode string, key string, req h5LinkPayload) (H5Link, error) {
|
||||||
item, err := s.store.GetAppConfig(h5LinkGroup, key)
|
appCode = appctx.Normalize(appCode)
|
||||||
|
item, err := s.store.GetScopedAppConfig(appCode, h5LinkGroup, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return H5Link{}, err
|
return H5Link{}, err
|
||||||
}
|
}
|
||||||
req.Key = item.Key
|
req.Key = item.Key
|
||||||
updated, err := h5LinkModelFromPayload(req)
|
updated, err := h5LinkModelFromPayload(appCode, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return H5Link{}, err
|
return H5Link{}, err
|
||||||
}
|
}
|
||||||
item.Value = updated.Value
|
// inherited 基线永远不原地更新;upsert 会为当前 App 创建或覆盖 scoped 行。
|
||||||
item.Description = updated.Description
|
if err := s.store.UpsertScopedAppConfigs(appCode, []model.AppConfig{updated}); err != nil {
|
||||||
if err := s.store.UpdateAppConfig(&item); err != nil {
|
|
||||||
return H5Link{}, err
|
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 {
|
func (s *AppConfigService) DeleteH5Link(appCode string, key string) error {
|
||||||
return s.store.DeleteAppConfig(h5LinkGroup, key)
|
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) {
|
func (s *AppConfigService) ListBanners(appCode string, options repository.AppBannerListOptions) ([]AppBanner, error) {
|
||||||
@ -517,7 +541,7 @@ func validateH5URL(value string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func h5LinkModelFromPayload(req h5LinkPayload) (model.AppConfig, error) {
|
func h5LinkModelFromPayload(appCode string, req h5LinkPayload) (model.AppConfig, error) {
|
||||||
key := strings.TrimSpace(req.Key)
|
key := strings.TrimSpace(req.Key)
|
||||||
label := strings.TrimSpace(req.Label)
|
label := strings.TrimSpace(req.Label)
|
||||||
url := strings.TrimSpace(req.URL)
|
url := strings.TrimSpace(req.URL)
|
||||||
@ -534,6 +558,7 @@ func h5LinkModelFromPayload(req h5LinkPayload) (model.AppConfig, error) {
|
|||||||
return model.AppConfig{}, err
|
return model.AppConfig{}, err
|
||||||
}
|
}
|
||||||
return model.AppConfig{
|
return model.AppConfig{
|
||||||
|
AppCode: appctx.Normalize(appCode),
|
||||||
Group: h5LinkGroup,
|
Group: h5LinkGroup,
|
||||||
Key: key,
|
Key: key,
|
||||||
Value: url,
|
Value: url,
|
||||||
@ -564,6 +589,7 @@ func h5LinkFromModel(config model.AppConfig) H5Link {
|
|||||||
label = config.Key
|
label = config.Key
|
||||||
}
|
}
|
||||||
return H5Link{
|
return H5Link{
|
||||||
|
AppCode: appctx.Normalize(config.AppCode),
|
||||||
Key: config.Key,
|
Key: config.Key,
|
||||||
Label: label,
|
Label: label,
|
||||||
URL: config.Value,
|
URL: config.Value,
|
||||||
|
|||||||
@ -1,14 +1,39 @@
|
|||||||
package appconfig
|
package appconfig
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hyapp-admin-server/internal/model"
|
"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) {
|
func TestH5LinkModelFromPayloadValidatesDynamicConfig(t *testing.T) {
|
||||||
item, err := h5LinkModelFromPayload(h5LinkPayload{
|
item, err := h5LinkModelFromPayload(" YUMI ", h5LinkPayload{
|
||||||
Key: "host-center.v2",
|
Key: "host-center.v2",
|
||||||
Label: "Host Center",
|
Label: "Host Center",
|
||||||
URL: "https://h5.example.com/host",
|
URL: "https://h5.example.com/host",
|
||||||
@ -16,13 +41,13 @@ func TestH5LinkModelFromPayloadValidatesDynamicConfig(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("h5 config should be valid: %v", err)
|
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)
|
t.Fatalf("h5 config model mismatch: %+v", item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestH5LinkModelFromPayloadRejectsInvalidKey(t *testing.T) {
|
func TestH5LinkModelFromPayloadRejectsInvalidKey(t *testing.T) {
|
||||||
_, err := h5LinkModelFromPayload(h5LinkPayload{
|
_, err := h5LinkModelFromPayload("lalu", h5LinkPayload{
|
||||||
Key: "host center",
|
Key: "host center",
|
||||||
Label: "Host Center",
|
Label: "Host Center",
|
||||||
URL: "https://h5.example.com/host",
|
URL: "https://h5.example.com/host",
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
package repository
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hyapp-admin-server/internal/model"
|
"hyapp-admin-server/internal/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -23,13 +25,14 @@ type AppExploreTabListOptions struct {
|
|||||||
|
|
||||||
func (s *Store) ListAppConfigs(group string) ([]model.AppConfig, error) {
|
func (s *Store) ListAppConfigs(group string) ([]model.AppConfig, error) {
|
||||||
var items []model.AppConfig
|
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
|
return items, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) GetAppConfig(group string, key string) (model.AppConfig, error) {
|
func (s *Store) GetAppConfig(group string, key string) (model.AppConfig, error) {
|
||||||
var item model.AppConfig
|
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
|
return item, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -42,7 +45,7 @@ func (s *Store) UpdateAppConfig(item *model.AppConfig) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) DeleteAppConfig(group string, key string) 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 {
|
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()
|
nowMS := time.Now().UTC().UnixMilli()
|
||||||
for index := range items {
|
for index := range items {
|
||||||
|
items[index].AppCode = ""
|
||||||
items[index].Group = strings.TrimSpace(items[index].Group)
|
items[index].Group = strings.TrimSpace(items[index].Group)
|
||||||
items[index].Key = strings.TrimSpace(items[index].Key)
|
items[index].Key = strings.TrimSpace(items[index].Key)
|
||||||
|
items[index].IsDeleted = false
|
||||||
items[index].UpdatedAtMS = nowMS
|
items[index].UpdatedAtMS = nowMS
|
||||||
if items[index].CreatedAtMS == 0 {
|
if items[index].CreatedAtMS == 0 {
|
||||||
items[index].CreatedAtMS = nowMS
|
items[index].CreatedAtMS = nowMS
|
||||||
@ -62,13 +67,143 @@ func (s *Store) UpsertAppConfigs(items []model.AppConfig) error {
|
|||||||
|
|
||||||
return s.db.Clauses(clause.OnConflict{
|
return s.db.Clauses(clause.OnConflict{
|
||||||
Columns: []clause.Column{
|
Columns: []clause.Column{
|
||||||
|
{Name: "app_code"},
|
||||||
{Name: "group"},
|
{Name: "group"},
|
||||||
{Name: "key"},
|
{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
|
}).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) {
|
func (s *Store) ListAppVersions(options AppVersionListOptions) ([]model.AppVersion, error) {
|
||||||
db := s.db.Where("app_code = ?", strings.TrimSpace(options.AppCode))
|
db := s.db.Where("app_code = ?", strings.TrimSpace(options.AppCode))
|
||||||
if platform := strings.TrimSpace(options.Platform); platform != "" {
|
if platform := strings.TrimSpace(options.Platform); platform != "" {
|
||||||
|
|||||||
11
server/admin/migrations/086_app_config_app_scope.sql
Normal file
11
server/admin/migrations/086_app_config_app_scope.sql
Normal file
@ -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`);
|
||||||
@ -6,6 +6,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -17,8 +18,8 @@ import (
|
|||||||
const h5LinkGroup = "h5-links"
|
const h5LinkGroup = "h5-links"
|
||||||
const roomRegionWhitelistGroup = "room-region-whitelist"
|
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 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 `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
|
||||||
FROM admin_bd_leader_position_aliases
|
FROM admin_bd_leader_position_aliases
|
||||||
@ -71,6 +72,16 @@ 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
|
||||||
@ -262,29 +273,25 @@ func (r *MySQLReader) ListH5Links(ctx context.Context, query H5LinkQuery) ([]H5L
|
|||||||
return nil, errors.New("app config reader is not configured")
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
items := make([]H5Link, 0)
|
stored := make([]storedH5Link, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var item H5Link
|
var item storedH5Link
|
||||||
if err := rows.Scan(&item.Key, &item.Label, &item.URL, &item.UpdatedAtMs); err != nil {
|
if err := rows.Scan(&item.AppCode, &item.Key, &item.Label, &item.URL, &item.IsDeleted, &item.UpdatedAtMs); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
item.Key = strings.TrimSpace(item.Key)
|
stored = append(stored, item)
|
||||||
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 != "" {
|
||||||
@ -293,6 +300,62 @@ 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
|
||||||
|
|||||||
@ -1,6 +1,57 @@
|
|||||||
package appconfig
|
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) {
|
func TestNormalizeBannerDisplayScopeAcceptsChineseLabels(t *testing.T) {
|
||||||
cases := map[string]string{
|
cases := map[string]string{
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user