Files
sub2api/backend/internal/handler/setting_handler.go
T
TT 0d3dbae710 feat(channel-monitor-v2): add user ranking toggle and keep missing TTFT neutral
Give operators a V2 setting (channel_monitor_hide_user_ranking) to hide
the user ranking tab and the /users payload from non-admin channel
monitor v2 viewers. Default false (ranking stays visible). Admin
endpoints and views always keep the ranking.

Also stop painting first-token '-' as a red/empty failure when traffic
is sync-only and has no first-token samples: a missing TTFT is
"not applicable" (health-unknown), not a breached latency budget.

Changes:
- new setting key + fail-open parse (only literal "true" hides), wired
  through public/admin settings DTOs, handlers, and the V2 runtime
- ChannelMonitorV2Service.Users returns an empty list to non-admin
  viewers when the toggle is on
- frontend: feature flag + SettingsView toggle + user V2 view hides the
  ranking tab and skips the /users call
- monitorFormat: isTtftUnavailable/ttftDisplayState keep missing TTFT
  neutral

Tests:
- setting parse defaults to visible and only literal "true" hides
- V2 Users honors the toggle for non-admin viewers
- frontend MetricCell/monitorFormat keep missing TTFT neutral
2026-09-07 18:31:03 +08:00

159 lines
7.6 KiB
Go

package handler
import (
"html"
"net/http"
"strings"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// SettingHandler 公开设置处理器(无需认证)
type SettingHandler struct {
settingService *service.SettingService
notificationEmailService *service.NotificationEmailService
version string
}
// NewSettingHandler 创建公开设置处理器
func NewSettingHandler(settingService *service.SettingService, version string) *SettingHandler {
return &SettingHandler{
settingService: settingService,
version: version,
}
}
// SetNotificationEmailService attaches the public notification email service without
// changing the constructor signature used by existing tests.
func (h *SettingHandler) SetNotificationEmailService(notificationEmailService *service.NotificationEmailService) {
h.notificationEmailService = notificationEmailService
}
// GetPublicSettings 获取公开设置
// GET /api/v1/settings/public
func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
settings, err := h.settingService.GetPublicSettings(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, dto.PublicSettings{
RegistrationEnabled: settings.RegistrationEnabled,
EmailVerifyEnabled: settings.EmailVerifyEnabled,
ForceEmailOnThirdPartySignup: settings.ForceEmailOnThirdPartySignup,
RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist,
RegistrationEmailDomainQuotaEnabled: settings.RegistrationEmailDomainQuotaEnabled,
PromoCodeEnabled: settings.PromoCodeEnabled,
PasswordResetEnabled: settings.PasswordResetEnabled,
InvitationCodeEnabled: settings.InvitationCodeEnabled,
TotpEnabled: settings.TotpEnabled,
PasskeyEnabled: settings.PasskeyEnabled,
LoginAgreementEnabled: settings.LoginAgreementEnabled,
LoginAgreementMode: settings.LoginAgreementMode,
LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt,
LoginAgreementRevision: settings.LoginAgreementRevision,
LoginAgreementDocuments: publicLoginAgreementDocumentsToDTO(settings.LoginAgreementDocuments),
TurnstileEnabled: settings.TurnstileEnabled,
TurnstileSiteKey: settings.TurnstileSiteKey,
TencentCaptchaEnabled: settings.TencentCaptchaEnabled,
TencentCaptchaAppID: settings.TencentCaptchaAppID,
TencentCaptchaRegion: settings.TencentCaptchaRegion,
AliyunCaptchaEnabled: settings.AliyunCaptchaEnabled,
AliyunCaptchaSceneID: settings.AliyunCaptchaSceneID,
AliyunCaptchaPrefix: settings.AliyunCaptchaPrefix,
AliyunCaptchaRegion: settings.AliyunCaptchaRegion,
SiteName: settings.SiteName,
SiteLogo: settings.SiteLogo,
SiteSubtitle: settings.SiteSubtitle,
APIBaseURL: settings.APIBaseURL,
ContactInfo: settings.ContactInfo,
DocURL: settings.DocURL,
HomeContent: settings.HomeContent,
CompactHomeEnabled: settings.CompactHomeEnabled,
HideCcsImportButton: settings.HideCcsImportButton,
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
TableDefaultPageSize: settings.TableDefaultPageSize,
TablePageSizeOptions: settings.TablePageSizeOptions,
CustomMenuItems: dto.ParseUserVisibleMenuItems(settings.CustomMenuItems),
CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints),
DingTalkOAuthEnabled: settings.DingTalkOAuthEnabled,
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
WeChatOAuthEnabled: settings.WeChatOAuthEnabled,
WeChatOAuthOpenEnabled: settings.WeChatOAuthOpenEnabled,
WeChatOAuthMPEnabled: settings.WeChatOAuthMPEnabled,
WeChatOAuthMobileEnabled: settings.WeChatOAuthMobileEnabled,
OIDCOAuthEnabled: settings.OIDCOAuthEnabled,
OIDCOAuthProviderName: settings.OIDCOAuthProviderName,
GitHubOAuthEnabled: settings.GitHubOAuthEnabled,
GoogleOAuthEnabled: settings.GoogleOAuthEnabled,
BackendModeEnabled: settings.BackendModeEnabled,
PaymentEnabled: settings.PaymentEnabled,
Version: h.version,
ServerTimezone: timezone.Name(),
ServerUTCOffset: timezone.UTCOffset(),
BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled,
AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled,
BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold,
BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL,
ChannelMonitorEnabled: settings.ChannelMonitorEnabled,
ChannelMonitorMode: settings.ChannelMonitorMode,
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput,
ChannelMonitorShowQuota: settings.ChannelMonitorShowQuota,
ChannelMonitorHideUserRanking: settings.ChannelMonitorHideUserRanking,
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
ModelPlazaEnabled: settings.ModelPlazaEnabled,
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
PluginManagementEnabled: settings.PluginManagementEnabled,
AffiliateEnabled: settings.AffiliateEnabled,
RiskControlEnabled: settings.RiskControlEnabled,
AllowUserViewErrorRequests: settings.AllowUserViewErrorRequests,
})
}
// UnsubscribeNotificationEmail handles optional notification email opt-outs.
// GET /api/v1/settings/email-unsubscribe?token=...
func (h *SettingHandler) UnsubscribeNotificationEmail(c *gin.Context) {
if h.notificationEmailService == nil {
response.InternalError(c, "notification email service is not configured")
return
}
token := strings.TrimSpace(c.Query("token"))
if token == "" {
response.BadRequest(c, "token is required")
return
}
result, err := h.notificationEmailService.Unsubscribe(c.Request.Context(), token)
if err != nil {
response.BadRequest(c, err.Error())
return
}
body := "<!doctype html><html><head><meta charset=\"utf-8\"><title>Unsubscribed</title></head><body style=\"font-family:-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;padding:32px;\"><h1>Unsubscribed</h1><p>You have unsubscribed <strong>" + html.EscapeString(result.Email) + "</strong> from <strong>" + html.EscapeString(result.Event) + "</strong> emails.</p></body></html>"
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(body))
}
func publicLoginAgreementDocumentsToDTO(items []service.LoginAgreementDocument) []dto.LoginAgreementDocument {
result := make([]dto.LoginAgreementDocument, 0, len(items))
for _, item := range items {
result = append(result, dto.LoginAgreementDocument{
ID: item.ID,
Title: item.Title,
ContentMD: item.ContentMD,
})
}
return result
}