mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +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 覆盖开关双向切换。
206 lines
8.4 KiB
Go
206 lines
8.4 KiB
Go
//go:build unit
|
|
|
|
package admin
|
|
|
|
import (
|
|
"net/http"
|
|
"testing"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// Saving settings is a whole-document PUT. A client that sends only the field it
|
|
// cares about must not reset everything else: a payload as small as
|
|
// `{"risk_control_enabled":true}` used to clear site_name, after which
|
|
// getStringOrDefault rendered the empty value as the built-in default and the
|
|
// login page silently changed name.
|
|
|
|
func TestUpdateSettingsPartialPayloadKeepsUnsentKeys(t *testing.T) {
|
|
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
|
|
service.SettingKeySiteName: "Example Gateway",
|
|
service.SettingKeySiteSubtitle: "Example Gateway Platform",
|
|
service.SettingKeySMTPHost: "smtp.example.com",
|
|
service.SettingKeySMTPFrom: "noreply@example.com",
|
|
service.SettingKeyTurnstileEnabled: "true",
|
|
})
|
|
|
|
rec := doUpdateSettings(t, h, map[string]any{"risk_control_enabled": true}, nil)
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
require.Equal(t, "true", repo.values[service.SettingKeyRiskControlEnabled],
|
|
"the field the caller actually sent must be written")
|
|
|
|
require.Equal(t, "Example Gateway", repo.values[service.SettingKeySiteName])
|
|
require.Equal(t, "Example Gateway Platform", repo.values[service.SettingKeySiteSubtitle])
|
|
require.Equal(t, "smtp.example.com", repo.values[service.SettingKeySMTPHost])
|
|
require.Equal(t, "noreply@example.com", repo.values[service.SettingKeySMTPFrom])
|
|
require.Equal(t, "true", repo.values[service.SettingKeyTurnstileEnabled])
|
|
}
|
|
|
|
// A full payload keeps whole-document semantics: fields explicitly set to their
|
|
// zero value are still cleared.
|
|
func TestUpdateSettingsFullPayloadStillClearsSentEmptyFields(t *testing.T) {
|
|
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
|
|
service.SettingKeySiteName: "Example Gateway",
|
|
})
|
|
|
|
rec := doUpdateSettings(t, h, map[string]any{"site_name": ""}, nil)
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
require.Equal(t, "", repo.values[service.SettingKeySiteName],
|
|
"an explicitly sent empty value is a deliberate clear, not an omission")
|
|
}
|
|
|
|
// smtp_from_email is the one request field whose JSON name differs from its
|
|
// setting key; the alias keeps it from being treated as always-omitted.
|
|
func TestUpdateSettingsSMTPFromAliasIsWritable(t *testing.T) {
|
|
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
|
|
service.SettingKeySMTPFrom: "old@example.com",
|
|
})
|
|
|
|
rec := doUpdateSettings(t, h, map[string]any{"smtp_from_email": "new@example.com"}, nil)
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
require.Equal(t, "new@example.com", repo.values[service.SettingKeySMTPFrom])
|
|
}
|
|
|
|
func TestUpdateSettingsGrokDefaultBaseURLModeIsWritable(t *testing.T) {
|
|
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
|
|
service.SettingKeyGrokDefaultBaseURLMode: service.GrokDefaultBaseURLModeCLI,
|
|
})
|
|
|
|
rec := doUpdateSettings(t, h, map[string]any{
|
|
"grok_default_base_url_mode": service.GrokDefaultBaseURLModeEUWest1,
|
|
}, nil)
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
require.Equal(t, service.GrokDefaultBaseURLModeEUWest1, repo.values[service.SettingKeyGrokDefaultBaseURLMode])
|
|
}
|
|
|
|
func TestUpdateSettingsRejectsTwoCaptchaProviders(t *testing.T) {
|
|
h, _ := newStepUpSwitchTestHandler(t, map[string]string{
|
|
service.SettingKeyTurnstileEnabled: "true",
|
|
service.SettingKeyTurnstileSiteKey: "site-key",
|
|
service.SettingKeyTurnstileSecretKey: "turnstile-secret",
|
|
})
|
|
|
|
rec := doUpdateSettings(t, h, map[string]any{
|
|
"turnstile_enabled": true,
|
|
"turnstile_site_key": "site-key",
|
|
"turnstile_secret_key": "turnstile-secret",
|
|
"tencent_captcha_enabled": true,
|
|
"tencent_captcha_app_id": "123456789",
|
|
"tencent_captcha_app_secret_key": "app-secret",
|
|
"tencent_captcha_cloud_secret_id": "cloud-secret-id",
|
|
"tencent_captcha_cloud_secret_key": "cloud-secret-key",
|
|
}, nil)
|
|
|
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
|
require.Contains(t, rec.Body.String(), "cannot be enabled at the same time")
|
|
}
|
|
|
|
func TestUpdateSettingsRequiresFourTencentCaptchaCredentialsWhenEnabled(t *testing.T) {
|
|
h, _ := newStepUpSwitchTestHandler(t, map[string]string{})
|
|
|
|
rec := doUpdateSettings(t, h, map[string]any{
|
|
"tencent_captcha_enabled": true,
|
|
"tencent_captcha_app_id": "123456789",
|
|
}, nil)
|
|
|
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
|
require.Contains(t, rec.Body.String(), "AppSecretKey")
|
|
}
|
|
|
|
func TestUpdateSettingsRetainsStoredTencentCaptchaCredentialsWhenInputsEmpty(t *testing.T) {
|
|
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
|
|
service.SettingKeyTencentCaptchaAppSecretKey: "stored-app-secret",
|
|
service.SettingKeyTencentCaptchaCloudSecretID: "stored-cloud-secret-id",
|
|
service.SettingKeyTencentCaptchaCloudSecretKey: "stored-cloud-secret-key",
|
|
})
|
|
|
|
rec := doUpdateSettings(t, h, map[string]any{
|
|
"tencent_captcha_enabled": true,
|
|
"tencent_captcha_app_id": "123456789",
|
|
"tencent_captcha_app_secret_key": "",
|
|
"tencent_captcha_cloud_secret_id": "",
|
|
"tencent_captcha_cloud_secret_key": "",
|
|
}, nil)
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
require.Equal(t, "stored-app-secret", repo.values[service.SettingKeyTencentCaptchaAppSecretKey])
|
|
require.Equal(t, "stored-cloud-secret-id", repo.values[service.SettingKeyTencentCaptchaCloudSecretID])
|
|
require.Equal(t, "stored-cloud-secret-key", repo.values[service.SettingKeyTencentCaptchaCloudSecretKey])
|
|
}
|
|
|
|
// 天御站点决定前端加载哪个 SDK 与服务端打哪个接入点,两端必须一致。
|
|
// 部分载荷把它重置回中国站,会让已配国际站的部署在下一次任意保存后整体失效。
|
|
func TestUpdateSettingsPartialPayloadKeepsTencentCaptchaRegion(t *testing.T) {
|
|
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
|
|
service.SettingKeyTencentCaptchaRegion: service.TencentCaptchaRegionINTL,
|
|
})
|
|
|
|
rec := doUpdateSettings(t, h, map[string]any{"risk_control_enabled": true}, nil)
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
require.Equal(t, service.TencentCaptchaRegionINTL,
|
|
repo.values[service.SettingKeyTencentCaptchaRegion])
|
|
}
|
|
|
|
func TestUpdateSettingsNormalizesUnknownTencentCaptchaRegion(t *testing.T) {
|
|
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
|
|
service.SettingKeyTencentCaptchaRegion: service.TencentCaptchaRegionINTL,
|
|
})
|
|
|
|
rec := doUpdateSettings(t, h, map[string]any{"tencent_captcha_region": "sgp"}, nil)
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
require.Equal(t, service.TencentCaptchaRegionCN,
|
|
repo.values[service.SettingKeyTencentCaptchaRegion],
|
|
"未知站点必须落回中国站,不能写入无法识别的值")
|
|
}
|
|
|
|
func TestUpdateSettingsWritesTencentCaptchaRegionWhenSent(t *testing.T) {
|
|
h, repo := newStepUpSwitchTestHandler(t, map[string]string{})
|
|
|
|
rec := doUpdateSettings(t, h, map[string]any{"tencent_captcha_region": "intl"}, nil)
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
require.Equal(t, service.TencentCaptchaRegionINTL,
|
|
repo.values[service.SettingKeyTencentCaptchaRegion])
|
|
}
|
|
|
|
func TestUpdateSettingsValidatesTencentCaptchaAppIDWhenEnabledFlagIsOmitted(t *testing.T) {
|
|
h, _ := newStepUpSwitchTestHandler(t, map[string]string{
|
|
service.SettingKeyTencentCaptchaEnabled: "true",
|
|
service.SettingKeyTencentCaptchaAppID: "123456789",
|
|
service.SettingKeyTencentCaptchaAppSecretKey: "stored-app-secret",
|
|
service.SettingKeyTencentCaptchaCloudSecretID: "stored-cloud-secret-id",
|
|
service.SettingKeyTencentCaptchaCloudSecretKey: "stored-cloud-secret-key",
|
|
})
|
|
|
|
rec := doUpdateSettings(t, h, map[string]any{
|
|
"tencent_captcha_app_id": "not-a-number",
|
|
}, nil)
|
|
|
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
|
require.Contains(t, rec.Body.String(), "positive integer")
|
|
}
|
|
|
|
// subscription_enabled is an opt-out switch: an explicit false is written as-is,
|
|
// and a later payload that omits the field keeps the stored value.
|
|
func TestUpdateSettingsSubscriptionEnabledIsWritableAndKeptWhenOmitted(t *testing.T) {
|
|
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
|
|
service.SettingKeySubscriptionEnabled: "true",
|
|
})
|
|
|
|
rec := doUpdateSettings(t, h, map[string]any{"subscription_enabled": false}, nil)
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
require.Equal(t, "false", repo.values[service.SettingKeySubscriptionEnabled])
|
|
|
|
rec = doUpdateSettings(t, h, map[string]any{"site_name": "Example Gateway"}, nil)
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
require.Equal(t, "false", repo.values[service.SettingKeySubscriptionEnabled],
|
|
"a payload without subscription_enabled must not flip the stored value back to true")
|
|
}
|