fix(settings): inject channel_monitor & available_channels into SSR payload

Root cause: GetPublicSettingsForInjection used an inline struct that silently
drifted from dto.PublicSettings and omitted channel_monitor_enabled /
available_channels_enabled. On refresh window.__APP_CONFIG__ lacked these
keys, so cachedPublicSettings.available_channels_enabled resolved to
undefined and the opt-in sidebar entry (=== true) disappeared.

Backend: extract PublicSettingsInjectionPayload as a named type with all
feature-flag fields wired, and add a reflect-based drift test in the dto
package so forgetting a future flag fails CI instead of the browser.

Frontend: introduce utils/featureFlags.ts as the single registry for
public-settings-driven toggles, with explicit opt-in / opt-out modes that
encode the pre-load fallback. AppSidebar switches to makeSidebarFlag() so
adding a new switch only touches the registry.
This commit is contained in:
erio
2026-04-21 21:08:10 +08:00
parent 3cdd5754df
commit 84b03efa0b
4 changed files with 219 additions and 4 deletions

View File

@@ -0,0 +1,68 @@
package dto
import (
"reflect"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
)
// TestPublicSettingsInjectionPayload_SchemaDoesNotDrift guarantees the SSR
// injection struct exposes every JSON field consumed by the frontend.
//
// Why this test exists: before we extracted a named PublicSettingsInjectionPayload
// type, the inline struct was manually kept in sync with dto.PublicSettings and
// drifted — ChannelMonitorEnabled / AvailableChannelsEnabled were missing, which
// made the frontend read `undefined` on refresh and hide the "可用渠道" menu
// until the async /api/v1/settings/public round-trip finished.
//
// This test compares the two JSON-tag sets and fails if injection is missing
// any field that dto.PublicSettings exposes. Adding a new feature flag with
// only a DTO entry will fail this test until the injection struct is updated.
//
// Intentional exclusions (fields present on dto.PublicSettings that SSR does
// not need to inject) are listed in `dtoOnlyFields` below with a reason.
func TestPublicSettingsInjectionPayload_SchemaDoesNotDrift(t *testing.T) {
injection := jsonTags(reflect.TypeOf(service.PublicSettingsInjectionPayload{}))
dtoKeys := jsonTags(reflect.TypeOf(PublicSettings{}))
// Fields that legitimately live only on the DTO. Keep tiny; document each.
dtoOnlyFields := map[string]string{
// sora_client_enabled is an upstream-only field the fork does not surface.
"sora_client_enabled": "upstream-only field, not used on this fork",
}
var missing []string
for key := range dtoKeys {
if _, ok := injection[key]; ok {
continue
}
if _, allowed := dtoOnlyFields[key]; allowed {
continue
}
missing = append(missing, key)
}
if len(missing) > 0 {
t.Fatalf("service.PublicSettingsInjectionPayload is missing JSON fields present on dto.PublicSettings: %s\n"+
"add the field to PublicSettingsInjectionPayload (and GetPublicSettingsForInjection), or "+
"document the exclusion in dtoOnlyFields with a reason.", strings.Join(missing, ", "))
}
}
func jsonTags(t reflect.Type) map[string]struct{} {
out := make(map[string]struct{})
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
tag := f.Tag.Get("json")
if tag == "" || tag == "-" {
continue
}
name := strings.SplitN(tag, ",", 2)[0]
if name == "" {
continue
}
out[name] = struct{}{}
}
return out
}

View File

@@ -675,6 +675,8 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"`
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
}{
RegistrationEnabled: settings.RegistrationEnabled,
EmailVerifyEnabled: settings.EmailVerifyEnabled,
@@ -713,6 +715,8 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled,
BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold,
BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL,
ChannelMonitorEnabled: settings.ChannelMonitorEnabled,
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
}, nil
}

View File

@@ -186,6 +186,7 @@ import { useI18n } from 'vue-i18n'
import { useAdminSettingsStore, useAppStore, useAuthStore, useOnboardingStore } from '@/stores'
import VersionBadge from '@/components/common/VersionBadge.vue'
import { sanitizeSvg } from '@/utils/sanitize'
import { FeatureFlags, makeSidebarFlag } from '@/utils/featureFlags'
interface NavItem {
path: string
@@ -627,10 +628,13 @@ const ChevronDownIcon = {
)
}
// 各个开关集中声明:所有菜单项引用这里的 getter未来加新开关只需在此加一个常量。
// getter 返回 false = 隐藏undefined/true = 显示(宽容策略,避免 public settings 未加载闪烁)。
const flagChannelMonitor = () => appStore.cachedPublicSettings?.channel_monitor_enabled
const flagPayment = () => appStore.cachedPublicSettings?.payment_enabled
// Public-settings flags go through the registry in utils/featureFlags.ts,
// which handles the opt-in vs opt-out fallback when settings haven't loaded
// yet. Admin-only flags (not in public settings) stay inline below.
const flagChannelMonitor = makeSidebarFlag(FeatureFlags.channelMonitor)
const flagPayment = makeSidebarFlag(FeatureFlags.payment)
const flagAvailableChannels = makeSidebarFlag(FeatureFlags.availableChannels)
void flagAvailableChannels
const flagOpsMonitoring = () => adminSettingsStore.opsMonitoringEnabled
const flagAdminPayment = () => adminSettingsStore.paymentEnabled

View File

@@ -0,0 +1,139 @@
/**
* Feature flag registry — single source of truth for public-settings-driven
* feature switches used by the sidebar, routes, and views.
*
* ## Why this module exists
*
* `public settings` reach the frontend through two channels:
*
* 1. **SSR injection** — the backend embeds `window.__APP_CONFIG__` into the
* HTML. `main.ts` calls `appStore.initFromInjectedConfig()` synchronously
* before Vue mounts, so `cachedPublicSettings` is populated on first
* render.
* 2. **Async API** — `App.vue` awaits `appStore.fetchPublicSettings()` on
* mount as a fallback (used when injection is missing or stale).
*
* If the SSR injection struct forgets to include a feature flag field — the
* exact bug that hid the "可用渠道" menu after every refresh — the frontend
* reads `undefined` until the async call resolves. An opt-in flag written as
* `settings?.xxx_enabled === true` then evaluates to `false` and the menu
* disappears. An opt-out flag written as `settings?.xxx_enabled !== false`
* evaluates to `true` (menu stays) but will flicker off if the backend sends
* `false`.
*
* This module hides that `undefined` handling behind two explicit modes.
*
* ## Modes
*
* - **`opt-out`** (default enabled) — menu visible when settings unloaded,
* hidden only when the backend explicitly sends `false`. Use for features
* that ship enabled by default (Channel Monitor, Payment).
* - **`opt-in`** (default disabled) — menu hidden when settings unloaded,
* visible only when the backend explicitly sends `true`. Use for features
* that ship disabled (Available Channels).
*
* For `opt-in` flags to render immediately on refresh, the backend **must**
* inject the field through `PublicSettingsInjectionPayload`. A drift test in
* `backend/internal/handler/dto/public_settings_injection_schema_test.go`
* catches omissions.
*
* ## Adding a new flag
*
* 1. Backend `service/domain_constants.go` → `SettingKey<Name>Enabled`
* 2. Backend `service/settings_view.go` → `PublicSettings` + `SystemSettings`
* 3. Backend `service/setting_service.go` → `GetPublicSettings` / `UpdateSettings` /
* `GetAllSettings` / `InitDefaultSettings` /
* **`PublicSettingsInjectionPayload`**
* (the drift test enforces this)
* 4. Backend `handler/dto/settings.go` → `PublicSettings` + `SystemSettings`
* 5. Backend `handler/setting_handler.go` → handler response
* 6. Backend `handler/admin/setting_handler.go` → update request + audit diff
* 7. Frontend `types/index.ts` → `PublicSettings` typings
* 8. Frontend `api/admin/settings.ts` → admin DTO typings
* 9. **Frontend `utils/featureFlags.ts` (this file)** → register via `defineFlag`
* 10. Frontend `views/admin/SettingsView.vue` → Toggle UI + form defaults + save payload
* 11. Frontend `components/layout/AppSidebar.vue` → attach via `makeSidebarFlag`
*
* ## Usage
*
* ```ts
* import { FeatureFlags, makeSidebarFlag } from '@/utils/featureFlags'
*
* const flagAvailableChannels = makeSidebarFlag(FeatureFlags.availableChannels)
* // ...
* { path: '/available-channels', label: ..., featureFlag: flagAvailableChannels }
* ```
*
* `isFeatureFlagEnabled(flag)` returns the resolved boolean (`true` = show).
* `makeSidebarFlag(flag)` returns a `() => boolean | undefined` compatible with
* `AppSidebar.NavItem.featureFlag`, where `false` hides the menu entry.
*/
import { useAppStore } from '@/stores/app'
import type { PublicSettings } from '@/types'
export type FeatureFlagMode = 'opt-in' | 'opt-out'
export interface FeatureFlagDefinition {
/** Public-settings key used for lookup. */
readonly key: keyof PublicSettings
/** Resolution mode when the key is missing/undefined. */
readonly mode: FeatureFlagMode
/** Short human label for logs and debug tooling. */
readonly label: string
}
function defineFlag<K extends keyof PublicSettings>(
def: { key: K; mode: FeatureFlagMode; label: string },
): FeatureFlagDefinition {
return def
}
/**
* Registered feature flags. Add a new entry here when introducing a new
* public-settings-driven switch; see the "Adding a new flag" checklist above.
*/
export const FeatureFlags = {
channelMonitor: defineFlag({
key: 'channel_monitor_enabled',
mode: 'opt-out',
label: 'Channel Monitor',
}),
availableChannels: defineFlag({
key: 'available_channels_enabled',
mode: 'opt-in',
label: 'Available Channels',
}),
payment: defineFlag({
key: 'payment_enabled',
mode: 'opt-out',
label: 'Payment',
}),
} as const
export type RegisteredFeatureFlag = keyof typeof FeatureFlags
/**
* Read the current value of a flag, honoring the mode's fallback.
* `true` → the feature is enabled (menu/route should render).
* `false` → the feature is disabled (menu/route should hide).
*/
export function isFeatureFlagEnabled(flag: FeatureFlagDefinition): boolean {
const appStore = useAppStore()
const raw = appStore.cachedPublicSettings?.[flag.key] as
| boolean
| undefined
if (typeof raw === 'boolean') return raw
// Settings not yet loaded → fall back to the flag's declared mode:
// opt-out → visible by default, opt-in → hidden by default.
return flag.mode === 'opt-out'
}
/**
* Sidebar NavItem.featureFlag accepts a getter that returns
* `false` to hide. Keeping the same contract lets callers swap in
* registry-backed flags without changing AppSidebar's filter logic.
*/
export function makeSidebarFlag(flag: FeatureFlagDefinition): () => boolean {
return () => isFeatureFlagEnabled(flag)
}