package repository import ( "sort" "strings" "time" "hyapp-admin-server/internal/model" "gorm.io/gorm" "gorm.io/gorm/clause" ) type AppVersionListOptions struct { AppCode string Platform string Keyword string } type AppExploreTabListOptions struct { AppCode string Keyword string Enabled *bool } func (s *Store) ListAppConfigs(group string) ([]model.AppConfig, error) { var items []model.AppConfig // 旧的通用配置继续使用 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("app_code = '' AND `group` = ? AND `key` = ?", strings.TrimSpace(group), strings.TrimSpace(key)).First(&item).Error return item, err } func (s *Store) CreateAppConfig(item *model.AppConfig) error { return s.db.Create(item).Error } func (s *Store) UpdateAppConfig(item *model.AppConfig) error { return s.db.Save(item).Error } func (s *Store) DeleteAppConfig(group string, key string) 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 { if len(items) == 0 { return nil } 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 } } 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 } // 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 != "" { db = db.Where("platform = ?", platform) } if keyword := strings.TrimSpace(options.Keyword); keyword != "" { like := "%" + keyword + "%" db = db.Where("version LIKE ? OR download_url LIKE ? OR description LIKE ?", like, like, like) } var items []model.AppVersion err := db.Order("platform ASC, build_number DESC, id DESC").Find(&items).Error return items, err } func (s *Store) GetAppVersion(appCode string, id uint) (model.AppVersion, error) { var item model.AppVersion err := s.db.Where("app_code = ? AND id = ?", strings.TrimSpace(appCode), id).First(&item).Error return item, err } func (s *Store) CreateAppVersion(item *model.AppVersion) error { return s.db.Create(item).Error } func (s *Store) UpdateAppVersion(item *model.AppVersion) error { return s.db.Save(item).Error } func (s *Store) DeleteAppVersion(appCode string, id uint) error { return s.db.Where("app_code = ? AND id = ?", strings.TrimSpace(appCode), id).Delete(&model.AppVersion{}).Error } func (s *Store) ListAppExploreTabs(options AppExploreTabListOptions) ([]model.AppExploreTab, error) { db := s.db.Where("app_code = ?", strings.TrimSpace(options.AppCode)) if options.Enabled != nil { db = db.Where("enabled = ?", *options.Enabled) } if keyword := strings.TrimSpace(options.Keyword); keyword != "" { like := "%" + keyword + "%" db = db.Where("tab LIKE ? OR h5_url LIKE ?", like, like) } var items []model.AppExploreTab err := db.Order("sort_order ASC, id DESC").Find(&items).Error return items, err } func (s *Store) GetAppExploreTab(appCode string, id uint) (model.AppExploreTab, error) { var item model.AppExploreTab err := s.db.Where("app_code = ? AND id = ?", strings.TrimSpace(appCode), id).First(&item).Error return item, err } func (s *Store) CreateAppExploreTab(item *model.AppExploreTab) error { return s.db.Create(item).Error } func (s *Store) UpdateAppExploreTab(item *model.AppExploreTab) error { return s.db.Save(item).Error } func (s *Store) DeleteAppExploreTab(appCode string, id uint) error { return s.db.Where("app_code = ? AND id = ?", strings.TrimSpace(appCode), id).Delete(&model.AppExploreTab{}).Error }