From 84b03efa0bb636857f12721c4a59086164ac06f8 Mon Sep 17 00:00:00 2001 From: erio Date: Tue, 21 Apr 2026 21:08:10 +0800 Subject: [PATCH] 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. --- .../public_settings_injection_schema_test.go | 68 +++++++++ backend/internal/service/setting_service.go | 4 + frontend/src/components/layout/AppSidebar.vue | 12 +- frontend/src/utils/featureFlags.ts | 139 ++++++++++++++++++ 4 files changed, 219 insertions(+), 4 deletions(-) create mode 100644 backend/internal/handler/dto/public_settings_injection_schema_test.go create mode 100644 frontend/src/utils/featureFlags.ts diff --git a/backend/internal/handler/dto/public_settings_injection_schema_test.go b/backend/internal/handler/dto/public_settings_injection_schema_test.go new file mode 100644 index 00000000..24853c7d --- /dev/null +++ b/backend/internal/handler/dto/public_settings_injection_schema_test.go @@ -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 +} diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 5340ff18..5a7ccefe 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -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 } diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index 25284276..d828dc88 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -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 diff --git a/frontend/src/utils/featureFlags.ts b/frontend/src/utils/featureFlags.ts new file mode 100644 index 00000000..51b043cc --- /dev/null +++ b/frontend/src/utils/featureFlags.ts @@ -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` → `SettingKeyEnabled` + * 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( + 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) +}