mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-16 07:33:06 +08:00
后台功能开关页新增「站点类型」单选:充值 & 订阅(默认,即现状)/ 仅充值 / 仅订阅。 它由两个后端开关派生并在保存时同时写回: - subscription_enabled(新增;默认 true,缺省/空值视为开启,仅显式 false 关闭),按既有 开关模式贯通常量、公共设置、SSR 注入载荷、默认值、更新写入、DTO、审计 diff 与前端注册表; - payment_balance_disabled(支付配置里既有的 BALANCE_PAYMENT_DISABLED,此前只能走接口/数据库 改),补进公共设置与注入载荷,让用户端在任何鉴权请求之前就能推导站点类型。 两者都关闭是接口层可表达但 UI 不提供的组合,前端归为「仅充值」,后台保存时会把余额充值重新打开。 「仅充值」时的表现: - 用户侧边栏隐藏「我的订阅」,「充值/订阅」入口收窄为「充值」;管理端侧边栏隐藏「订阅管理」 入口(页面仍可通过地址访问); - 购买页不再渲染「订阅」tab,只剩单个 tab 时顶部切换器随之隐藏;tab 列表变化时当前 tab 自动收敛到可用项(关闭订阅回到充值、仅订阅站点重新打开订阅进入订阅),列表为空时展示 「充值与订阅均暂未开放」而不是一个必定被后端拒绝的充值表单; - /purchase 的页头标题/描述与 document.title 改走 resolveRouteMetaKeys 按站点类型切换; - /subscriptions 路由新增 requiresSubscription 守卫,直达时重定向到仪表盘; - 顶栏订阅进度徽章不再挂载,App.vue、PaymentView 与徽章组件都不再预加载/轮询订阅接口; - 用量页隐藏「计费类型(余额/订阅)」筛选,Key 用量公开页「订阅类型」行改为「计费方式」。 「仅订阅」时侧边栏入口与页头显示为「订阅」,购买页只剩订阅 tab(余额下单由后端既有逻辑拒绝)。 购买订阅接口、已有订阅的计费、兑换码发放订阅与管理端订阅管理页面本身均不受影响。 featureFlags 抽出纯函数 resolveFeatureFlag,新增 utils/siteBillingMode 承载三态派生与反解, PaymentView 测试改用响应式 store mock 覆盖开关双向切换。
312 lines
12 KiB
Go
312 lines
12 KiB
Go
//go:build unit
|
|
|
|
package service
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
type settingPublicRepoStub struct {
|
|
values map[string]string
|
|
err error
|
|
}
|
|
|
|
func (s *settingPublicRepoStub) Get(ctx context.Context, key string) (*Setting, error) {
|
|
panic("unexpected Get call")
|
|
}
|
|
|
|
func (s *settingPublicRepoStub) GetValue(ctx context.Context, key string) (string, error) {
|
|
panic("unexpected GetValue call")
|
|
}
|
|
|
|
func (s *settingPublicRepoStub) Set(ctx context.Context, key, value string) error {
|
|
panic("unexpected Set call")
|
|
}
|
|
|
|
func (s *settingPublicRepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) {
|
|
if s.err != nil {
|
|
return nil, s.err
|
|
}
|
|
out := make(map[string]string, len(keys))
|
|
for _, key := range keys {
|
|
if value, ok := s.values[key]; ok {
|
|
out[key] = value
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *settingPublicRepoStub) SetMultiple(ctx context.Context, settings map[string]string) error {
|
|
panic("unexpected SetMultiple call")
|
|
}
|
|
|
|
func (s *settingPublicRepoStub) GetAll(ctx context.Context) (map[string]string, error) {
|
|
panic("unexpected GetAll call")
|
|
}
|
|
|
|
func (s *settingPublicRepoStub) Delete(ctx context.Context, key string) error {
|
|
panic("unexpected Delete call")
|
|
}
|
|
|
|
func TestSettingService_GetPublicSettings_ExposesRegistrationEmailSuffixWhitelist(t *testing.T) {
|
|
repo := &settingPublicRepoStub{
|
|
values: map[string]string{
|
|
SettingKeyRegistrationEnabled: "true",
|
|
SettingKeyEmailVerifyEnabled: "true",
|
|
SettingKeyRegistrationEmailSuffixWhitelist: `["@EXAMPLE.com"," @foo.bar ","*.EDU.CN","@invalid_domain",""]`,
|
|
},
|
|
}
|
|
svc := NewSettingService(repo, &config.Config{})
|
|
|
|
settings, err := svc.GetPublicSettings(context.Background())
|
|
require.NoError(t, err)
|
|
require.Equal(t, []string{"@example.com", "@foo.bar", "*.edu.cn"}, settings.RegistrationEmailSuffixWhitelist)
|
|
}
|
|
|
|
func TestSettingService_GetPublicSettings_ExposesTablePreferences(t *testing.T) {
|
|
repo := &settingPublicRepoStub{
|
|
values: map[string]string{
|
|
SettingKeyTableDefaultPageSize: "50",
|
|
SettingKeyTablePageSizeOptions: "[20,50,100]",
|
|
},
|
|
}
|
|
svc := NewSettingService(repo, &config.Config{})
|
|
|
|
settings, err := svc.GetPublicSettings(context.Background())
|
|
require.NoError(t, err)
|
|
require.Equal(t, 50, settings.TableDefaultPageSize)
|
|
require.Equal(t, []int{20, 50, 100}, settings.TablePageSizeOptions)
|
|
}
|
|
|
|
func TestSettingService_GetPublicSettings_ExposesCompactHomeEnabled(t *testing.T) {
|
|
repo := &settingPublicRepoStub{
|
|
values: map[string]string{
|
|
SettingKeyCompactHomeEnabled: "true",
|
|
},
|
|
}
|
|
svc := NewSettingService(repo, &config.Config{})
|
|
|
|
settings, err := svc.GetPublicSettings(context.Background())
|
|
|
|
require.NoError(t, err)
|
|
require.True(t, settings.CompactHomeEnabled)
|
|
|
|
missingSettings, err := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, &config.Config{}).
|
|
GetPublicSettings(context.Background())
|
|
require.NoError(t, err)
|
|
require.False(t, missingSettings.CompactHomeEnabled)
|
|
}
|
|
|
|
func TestSettingService_ChannelMonitorHideThroughputDefaultsToPrivate(t *testing.T) {
|
|
missing := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, &config.Config{}).GetChannelMonitorRuntime(context.Background())
|
|
require.True(t, missing.HideThroughput)
|
|
public, err := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, &config.Config{}).GetPublicSettings(context.Background())
|
|
require.NoError(t, err)
|
|
require.True(t, public.ChannelMonitorHideThroughput)
|
|
|
|
for _, value := range []string{"false", "0", "off", "disabled"} {
|
|
runtime := NewSettingService(&settingPublicRepoStub{values: map[string]string{
|
|
SettingKeyChannelMonitorHideThroughput: value,
|
|
}}, &config.Config{}).GetChannelMonitorRuntime(context.Background())
|
|
require.False(t, runtime.HideThroughput, "value=%q", value)
|
|
}
|
|
}
|
|
|
|
func TestSettingService_ChannelMonitorShowQuotaFailsClosed(t *testing.T) {
|
|
// 缺省(迁移插入 'false' / 老库无行)一律不展示。
|
|
missingRuntime := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, &config.Config{}).GetChannelMonitorRuntime(context.Background())
|
|
require.False(t, missingRuntime.ShowQuota)
|
|
missingPublic, err := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, &config.Config{}).
|
|
GetPublicSettings(context.Background())
|
|
require.NoError(t, err)
|
|
require.False(t, missingPublic.ChannelMonitorShowQuota)
|
|
|
|
// 仅字面 "true" 视为开启;其余值(含异常值)fail-closed。
|
|
runtime := NewSettingService(&settingPublicRepoStub{values: map[string]string{
|
|
SettingKeyChannelMonitorShowQuota: "true",
|
|
}}, &config.Config{}).GetChannelMonitorRuntime(context.Background())
|
|
require.True(t, runtime.ShowQuota)
|
|
|
|
for _, value := range []string{"false", "TRUE", "1", "yes", "on", "garbage"} {
|
|
rt := NewSettingService(&settingPublicRepoStub{values: map[string]string{
|
|
SettingKeyChannelMonitorShowQuota: value,
|
|
}}, &config.Config{}).GetChannelMonitorRuntime(context.Background())
|
|
require.False(t, rt.ShowQuota, "value=%q", value)
|
|
}
|
|
}
|
|
|
|
func TestSettingService_ChannelMonitorHideUserRankingDefaultsToVisible(t *testing.T) {
|
|
missing := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, &config.Config{}).GetChannelMonitorRuntime(context.Background())
|
|
require.False(t, missing.HideUserRanking)
|
|
public, err := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, &config.Config{}).GetPublicSettings(context.Background())
|
|
require.NoError(t, err)
|
|
require.False(t, public.ChannelMonitorHideUserRanking)
|
|
|
|
for _, value := range []string{"true", "1", "on", "enabled"} {
|
|
runtime := NewSettingService(&settingPublicRepoStub{values: map[string]string{
|
|
SettingKeyChannelMonitorHideUserRanking: value,
|
|
}}, &config.Config{}).GetChannelMonitorRuntime(context.Background())
|
|
require.True(t, runtime.HideUserRanking, "value=%q", value)
|
|
}
|
|
}
|
|
|
|
func TestSettingService_GetPublicSettings_ExposesForceEmailOnThirdPartySignup(t *testing.T) {
|
|
repo := &settingPublicRepoStub{
|
|
values: map[string]string{
|
|
SettingKeyForceEmailOnThirdPartySignup: "true",
|
|
},
|
|
}
|
|
svc := NewSettingService(repo, &config.Config{})
|
|
|
|
settings, err := svc.GetPublicSettings(context.Background())
|
|
require.NoError(t, err)
|
|
require.True(t, settings.ForceEmailOnThirdPartySignup)
|
|
}
|
|
|
|
func TestSettingService_GetPublicSettings_ExposesAllowUserViewErrorRequests(t *testing.T) {
|
|
repo := &settingPublicRepoStub{
|
|
values: map[string]string{
|
|
SettingKeyAllowUserViewErrorRequests: "true",
|
|
},
|
|
}
|
|
svc := NewSettingService(repo, &config.Config{})
|
|
|
|
settings, err := svc.GetPublicSettings(context.Background())
|
|
require.NoError(t, err)
|
|
require.True(t, settings.AllowUserViewErrorRequests)
|
|
}
|
|
|
|
func TestSettingService_GetPublicSettings_ExposesWeChatOAuthModeCapabilities(t *testing.T) {
|
|
svc := NewSettingService(&settingPublicRepoStub{
|
|
values: map[string]string{
|
|
SettingKeyWeChatConnectEnabled: "true",
|
|
SettingKeyWeChatConnectAppID: "wx-mp-app",
|
|
SettingKeyWeChatConnectAppSecret: "wx-mp-secret",
|
|
SettingKeyWeChatConnectMode: "mp",
|
|
SettingKeyWeChatConnectScopes: "snsapi_base",
|
|
SettingKeyWeChatConnectOpenEnabled: "true",
|
|
SettingKeyWeChatConnectMPEnabled: "true",
|
|
SettingKeyWeChatConnectRedirectURL: "https://api.example.com/api/v1/auth/oauth/wechat/callback",
|
|
SettingKeyWeChatConnectFrontendRedirectURL: "/auth/wechat/callback",
|
|
},
|
|
}, &config.Config{})
|
|
|
|
settings, err := svc.GetPublicSettings(context.Background())
|
|
require.NoError(t, err)
|
|
require.True(t, settings.WeChatOAuthEnabled)
|
|
require.True(t, settings.WeChatOAuthOpenEnabled)
|
|
require.True(t, settings.WeChatOAuthMPEnabled)
|
|
}
|
|
|
|
func TestSettingService_GetPublicSettings_DoesNotExposeMobileOnlyWeChatAsWebOAuthAvailable(t *testing.T) {
|
|
svc := NewSettingService(&settingPublicRepoStub{
|
|
values: map[string]string{
|
|
SettingKeyWeChatConnectEnabled: "true",
|
|
SettingKeyWeChatConnectMobileEnabled: "true",
|
|
SettingKeyWeChatConnectMode: "mobile",
|
|
SettingKeyWeChatConnectMobileAppID: "wx-mobile-app",
|
|
SettingKeyWeChatConnectMobileAppSecret: "wx-mobile-secret",
|
|
SettingKeyWeChatConnectFrontendRedirectURL: "/auth/wechat/callback",
|
|
},
|
|
}, &config.Config{})
|
|
|
|
settings, err := svc.GetPublicSettings(context.Background())
|
|
require.NoError(t, err)
|
|
require.False(t, settings.WeChatOAuthEnabled)
|
|
require.False(t, settings.WeChatOAuthOpenEnabled)
|
|
require.False(t, settings.WeChatOAuthMPEnabled)
|
|
require.True(t, settings.WeChatOAuthMobileEnabled)
|
|
}
|
|
|
|
func TestSettingService_GetPublicSettings_FallsBackToConfigForWeChatOAuthCapabilities(t *testing.T) {
|
|
svc := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, &config.Config{
|
|
WeChat: config.WeChatConnectConfig{
|
|
Enabled: true,
|
|
OpenEnabled: true,
|
|
OpenAppID: "wx-open-config",
|
|
OpenAppSecret: "wx-open-secret",
|
|
FrontendRedirectURL: "/auth/wechat/config-callback",
|
|
},
|
|
})
|
|
|
|
settings, err := svc.GetPublicSettings(context.Background())
|
|
require.NoError(t, err)
|
|
require.True(t, settings.WeChatOAuthEnabled)
|
|
require.True(t, settings.WeChatOAuthOpenEnabled)
|
|
require.False(t, settings.WeChatOAuthMPEnabled)
|
|
require.False(t, settings.WeChatOAuthMobileEnabled)
|
|
}
|
|
|
|
// subscription_enabled gates the user-facing "My Subscriptions" sidebar entry and
|
|
// is opt-out: only an explicit "false" hides it.
|
|
func TestSettingService_GetPublicSettings_SubscriptionEnabledOnlyExplicitFalseDisables(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
values map[string]string
|
|
want bool
|
|
}{
|
|
{name: "missing key defaults to enabled", values: map[string]string{}, want: true},
|
|
{name: "empty value stays enabled", values: map[string]string{SettingKeySubscriptionEnabled: ""}, want: true},
|
|
{name: "explicit true", values: map[string]string{SettingKeySubscriptionEnabled: "true"}, want: true},
|
|
{name: "explicit false disables", values: map[string]string{SettingKeySubscriptionEnabled: "false"}, want: false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
svc := NewSettingService(&settingPublicRepoStub{values: tc.values}, &config.Config{})
|
|
|
|
settings, err := svc.GetPublicSettings(context.Background())
|
|
require.NoError(t, err)
|
|
require.Equal(t, tc.want, settings.SubscriptionEnabled)
|
|
})
|
|
}
|
|
}
|
|
|
|
// The SSR injection payload must mirror the switch so the opt-out flag does not
|
|
// flicker off on first render before the async public-settings fetch resolves.
|
|
func TestSettingService_GetPublicSettingsForInjection_MirrorsSubscriptionEnabled(t *testing.T) {
|
|
for _, value := range []string{"false", "true"} {
|
|
repo := &settingPublicRepoStub{values: map[string]string{SettingKeySubscriptionEnabled: value}}
|
|
svc := NewSettingService(repo, &config.Config{})
|
|
|
|
raw, err := svc.GetPublicSettingsForInjection(context.Background())
|
|
require.NoError(t, err)
|
|
payload, ok := raw.(*PublicSettingsInjectionPayload)
|
|
require.True(t, ok)
|
|
require.Equal(t, value == "true", payload.SubscriptionEnabled, "value=%q", value)
|
|
}
|
|
}
|
|
|
|
// payment_balance_disabled is exposed publicly so the user shell can derive the site
|
|
// billing mode (recharge & subscription / recharge only / subscription only) before any
|
|
// authenticated checkout call. Strict true, mirroring the payment-config parser.
|
|
func TestSettingService_GetPublicSettings_PaymentBalanceDisabledStrictTrue(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
value map[string]string
|
|
want bool
|
|
}{
|
|
{name: "missing key stays enabled", value: map[string]string{}, want: false},
|
|
{name: "explicit false", value: map[string]string{SettingBalancePayDisabled: "false"}, want: false},
|
|
{name: "explicit true disables balance recharge", value: map[string]string{SettingBalancePayDisabled: "true"}, want: true},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
svc := NewSettingService(&settingPublicRepoStub{values: tc.value}, &config.Config{})
|
|
|
|
settings, err := svc.GetPublicSettings(context.Background())
|
|
require.NoError(t, err)
|
|
require.Equal(t, tc.want, settings.PaymentBalanceDisabled)
|
|
|
|
raw, err := svc.GetPublicSettingsForInjection(context.Background())
|
|
require.NoError(t, err)
|
|
payload, ok := raw.(*PublicSettingsInjectionPayload)
|
|
require.True(t, ok)
|
|
require.Equal(t, tc.want, payload.PaymentBalanceDisabled)
|
|
})
|
|
}
|
|
}
|