From 8c4a217f0320c6a091894c515b6be4c7859a64db Mon Sep 17 00:00:00 2001 From: Ethan0x0000 <3352979663@qq.com> Date: Sat, 21 Mar 2026 23:30:13 +0800 Subject: [PATCH 01/28] feat(ops): add endpoint/model/request_type fields to error log structs + safeUpstreamURL --- backend/internal/service/ops_models.go | 6 ++++ backend/internal/service/ops_port.go | 11 ++++++++ .../internal/service/ops_upstream_context.go | 21 ++++++++++++++ ...079_ops_error_logs_add_endpoint_fields.sql | 28 +++++++++++++++++++ 4 files changed, 66 insertions(+) create mode 100644 backend/migrations/079_ops_error_logs_add_endpoint_fields.sql diff --git a/backend/internal/service/ops_models.go b/backend/internal/service/ops_models.go index 2ed06d90..5fefb74f 100644 --- a/backend/internal/service/ops_models.go +++ b/backend/internal/service/ops_models.go @@ -62,6 +62,12 @@ type OpsErrorLog struct { ClientIP *string `json:"client_ip"` RequestPath string `json:"request_path"` Stream bool `json:"stream"` + + InboundEndpoint string `json:"inbound_endpoint"` + UpstreamEndpoint string `json:"upstream_endpoint"` + RequestedModel string `json:"requested_model"` + UpstreamModel string `json:"upstream_model"` + RequestType *int16 `json:"request_type"` } type OpsErrorLogDetail struct { diff --git a/backend/internal/service/ops_port.go b/backend/internal/service/ops_port.go index 0ce9d425..04bf91c8 100644 --- a/backend/internal/service/ops_port.go +++ b/backend/internal/service/ops_port.go @@ -79,6 +79,17 @@ type OpsInsertErrorLogInput struct { Model string RequestPath string Stream bool + // InboundEndpoint is the normalized client-facing API endpoint path, e.g. /v1/chat/completions. + InboundEndpoint string + // UpstreamEndpoint is the normalized upstream endpoint path, e.g. /v1/responses. + UpstreamEndpoint string + // RequestedModel is the client-requested model name before mapping. + RequestedModel string + // UpstreamModel is the actual model sent to upstream after mapping. Empty means no mapping. + UpstreamModel string + // RequestType is the granular request type: 0=unknown, 1=sync, 2=stream, 3=ws_v2. + // Matches service.RequestType enum semantics from usage_log.go. + RequestType *int16 UserAgent string ErrorPhase string diff --git a/backend/internal/service/ops_upstream_context.go b/backend/internal/service/ops_upstream_context.go index 9adf5896..05d444e1 100644 --- a/backend/internal/service/ops_upstream_context.go +++ b/backend/internal/service/ops_upstream_context.go @@ -93,6 +93,10 @@ type OpsUpstreamErrorEvent struct { UpstreamStatusCode int `json:"upstream_status_code,omitempty"` UpstreamRequestID string `json:"upstream_request_id,omitempty"` + // UpstreamURL is the actual upstream URL that was called (host + path, query/fragment stripped). + // Helps debug 404/routing errors by showing which endpoint was targeted. + UpstreamURL string `json:"upstream_url,omitempty"` + // Best-effort upstream request capture (sanitized+trimmed). // Required for retrying a specific upstream attempt. UpstreamRequestBody string `json:"upstream_request_body,omitempty"` @@ -119,6 +123,7 @@ func appendOpsUpstreamError(c *gin.Context, ev OpsUpstreamErrorEvent) { ev.UpstreamRequestBody = strings.TrimSpace(ev.UpstreamRequestBody) ev.UpstreamResponseBody = strings.TrimSpace(ev.UpstreamResponseBody) ev.Kind = strings.TrimSpace(ev.Kind) + ev.UpstreamURL = strings.TrimSpace(ev.UpstreamURL) ev.Message = strings.TrimSpace(ev.Message) ev.Detail = strings.TrimSpace(ev.Detail) if ev.Message != "" { @@ -205,3 +210,19 @@ func ParseOpsUpstreamErrors(raw string) ([]*OpsUpstreamErrorEvent, error) { } return out, nil } + +// safeUpstreamURL returns scheme + host + path from a URL, stripping query/fragment +// to avoid leaking sensitive query parameters (e.g. OAuth tokens). +func safeUpstreamURL(rawURL string) string { + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return "" + } + if idx := strings.IndexByte(rawURL, '?'); idx >= 0 { + rawURL = rawURL[:idx] + } + if idx := strings.IndexByte(rawURL, '#'); idx >= 0 { + rawURL = rawURL[:idx] + } + return rawURL +} diff --git a/backend/migrations/079_ops_error_logs_add_endpoint_fields.sql b/backend/migrations/079_ops_error_logs_add_endpoint_fields.sql new file mode 100644 index 00000000..56f83b84 --- /dev/null +++ b/backend/migrations/079_ops_error_logs_add_endpoint_fields.sql @@ -0,0 +1,28 @@ +-- Ops error logs: add endpoint, model mapping, and request_type fields +-- to match usage_logs observability coverage. +-- +-- All columns are nullable with no default to preserve backward compatibility +-- with existing rows. + +SET LOCAL lock_timeout = '5s'; +SET LOCAL statement_timeout = '10min'; + +-- 1) Standardized endpoint paths (analogous to usage_logs.inbound_endpoint / upstream_endpoint) +ALTER TABLE ops_error_logs + ADD COLUMN IF NOT EXISTS inbound_endpoint VARCHAR(256), + ADD COLUMN IF NOT EXISTS upstream_endpoint VARCHAR(256); + +-- 2) Model mapping fields (analogous to usage_logs.requested_model / upstream_model) +ALTER TABLE ops_error_logs + ADD COLUMN IF NOT EXISTS requested_model VARCHAR(100), + ADD COLUMN IF NOT EXISTS upstream_model VARCHAR(100); + +-- 3) Granular request type enum (analogous to usage_logs.request_type: 0=unknown, 1=sync, 2=stream, 3=ws_v2) +ALTER TABLE ops_error_logs + ADD COLUMN IF NOT EXISTS request_type SMALLINT; + +COMMENT ON COLUMN ops_error_logs.inbound_endpoint IS 'Normalized client-facing API endpoint path, e.g. /v1/chat/completions. Populated from InboundEndpointMiddleware.'; +COMMENT ON COLUMN ops_error_logs.upstream_endpoint IS 'Normalized upstream endpoint path derived from platform, e.g. /v1/responses.'; +COMMENT ON COLUMN ops_error_logs.requested_model IS 'Client-requested model name before mapping (raw from request body).'; +COMMENT ON COLUMN ops_error_logs.upstream_model IS 'Actual model sent to upstream provider after mapping. NULL means no mapping applied.'; +COMMENT ON COLUMN ops_error_logs.request_type IS 'Request type enum: 0=unknown, 1=sync, 2=stream, 3=ws_v2. Matches usage_logs.request_type semantics.'; From 1fb29d59b70921533749e40e425ed7174223dfb0 Mon Sep 17 00:00:00 2001 From: Eilen6316 Date: Sat, 21 Mar 2026 23:36:30 +0800 Subject: [PATCH 02/28] fix(settings): prevent SMTP config overwrite and stabilize test after refresh --- .../internal/handler/admin/setting_handler.go | 94 +++++++++++++++---- backend/internal/service/email_service.go | 11 ++- frontend/src/views/admin/SettingsView.vue | 23 ++++- 3 files changed, 100 insertions(+), 28 deletions(-) diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index c91566c8..1c89393f 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -231,11 +231,27 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { if req.DefaultBalance < 0 { req.DefaultBalance = 0 } + req.SMTPHost = strings.TrimSpace(req.SMTPHost) + req.SMTPUsername = strings.TrimSpace(req.SMTPUsername) + req.SMTPPassword = strings.TrimSpace(req.SMTPPassword) + req.SMTPFrom = strings.TrimSpace(req.SMTPFrom) + req.SMTPFromName = strings.TrimSpace(req.SMTPFromName) if req.SMTPPort <= 0 { req.SMTPPort = 587 } req.DefaultSubscriptions = normalizeDefaultSubscriptions(req.DefaultSubscriptions) + // SMTP 配置保护:如果请求中 smtp_host 为空但数据库中已有配置,则保留已有 SMTP 配置 + // 防止前端加载设置失败时空表单覆盖已保存的 SMTP 配置 + if req.SMTPHost == "" && previousSettings.SMTPHost != "" { + req.SMTPHost = previousSettings.SMTPHost + req.SMTPPort = previousSettings.SMTPPort + req.SMTPUsername = previousSettings.SMTPUsername + req.SMTPFrom = previousSettings.SMTPFrom + req.SMTPFromName = previousSettings.SMTPFromName + req.SMTPUseTLS = previousSettings.SMTPUseTLS + } + // Turnstile 参数验证 if req.TurnstileEnabled { // 检查必填字段 @@ -828,7 +844,7 @@ func equalDefaultSubscriptions(a, b []service.DefaultSubscriptionSetting) bool { // TestSMTPRequest 测试SMTP连接请求 type TestSMTPRequest struct { - SMTPHost string `json:"smtp_host" binding:"required"` + SMTPHost string `json:"smtp_host"` SMTPPort int `json:"smtp_port"` SMTPUsername string `json:"smtp_username"` SMTPPassword string `json:"smtp_password"` @@ -844,18 +860,35 @@ func (h *SettingHandler) TestSMTPConnection(c *gin.Context) { return } - if req.SMTPPort <= 0 { - req.SMTPPort = 587 + req.SMTPHost = strings.TrimSpace(req.SMTPHost) + req.SMTPUsername = strings.TrimSpace(req.SMTPUsername) + + var savedConfig *service.SMTPConfig + if cfg, err := h.emailService.GetSMTPConfig(c.Request.Context()); err == nil && cfg != nil { + savedConfig = cfg } - // 如果未提供密码,从数据库获取已保存的密码 - password := req.SMTPPassword - if password == "" { - savedConfig, err := h.emailService.GetSMTPConfig(c.Request.Context()) - if err == nil && savedConfig != nil { - password = savedConfig.Password + if req.SMTPHost == "" && savedConfig != nil { + req.SMTPHost = savedConfig.Host + } + if req.SMTPPort <= 0 { + if savedConfig != nil && savedConfig.Port > 0 { + req.SMTPPort = savedConfig.Port + } else { + req.SMTPPort = 587 } } + if req.SMTPUsername == "" && savedConfig != nil { + req.SMTPUsername = savedConfig.Username + } + password := strings.TrimSpace(req.SMTPPassword) + if password == "" && savedConfig != nil { + password = savedConfig.Password + } + if req.SMTPHost == "" { + response.BadRequest(c, "SMTP host is required") + return + } config := &service.SMTPConfig{ Host: req.SMTPHost, @@ -877,7 +910,7 @@ func (h *SettingHandler) TestSMTPConnection(c *gin.Context) { // SendTestEmailRequest 发送测试邮件请求 type SendTestEmailRequest struct { Email string `json:"email" binding:"required,email"` - SMTPHost string `json:"smtp_host" binding:"required"` + SMTPHost string `json:"smtp_host"` SMTPPort int `json:"smtp_port"` SMTPUsername string `json:"smtp_username"` SMTPPassword string `json:"smtp_password"` @@ -895,18 +928,43 @@ func (h *SettingHandler) SendTestEmail(c *gin.Context) { return } - if req.SMTPPort <= 0 { - req.SMTPPort = 587 + req.SMTPHost = strings.TrimSpace(req.SMTPHost) + req.SMTPUsername = strings.TrimSpace(req.SMTPUsername) + req.SMTPFrom = strings.TrimSpace(req.SMTPFrom) + req.SMTPFromName = strings.TrimSpace(req.SMTPFromName) + + var savedConfig *service.SMTPConfig + if cfg, err := h.emailService.GetSMTPConfig(c.Request.Context()); err == nil && cfg != nil { + savedConfig = cfg } - // 如果未提供密码,从数据库获取已保存的密码 - password := req.SMTPPassword - if password == "" { - savedConfig, err := h.emailService.GetSMTPConfig(c.Request.Context()) - if err == nil && savedConfig != nil { - password = savedConfig.Password + if req.SMTPHost == "" && savedConfig != nil { + req.SMTPHost = savedConfig.Host + } + if req.SMTPPort <= 0 { + if savedConfig != nil && savedConfig.Port > 0 { + req.SMTPPort = savedConfig.Port + } else { + req.SMTPPort = 587 } } + if req.SMTPUsername == "" && savedConfig != nil { + req.SMTPUsername = savedConfig.Username + } + password := strings.TrimSpace(req.SMTPPassword) + if password == "" && savedConfig != nil { + password = savedConfig.Password + } + if req.SMTPFrom == "" && savedConfig != nil { + req.SMTPFrom = savedConfig.From + } + if req.SMTPFromName == "" && savedConfig != nil { + req.SMTPFromName = savedConfig.FromName + } + if req.SMTPHost == "" { + response.BadRequest(c, "SMTP host is required") + return + } config := &service.SMTPConfig{ Host: req.SMTPHost, diff --git a/backend/internal/service/email_service.go b/backend/internal/service/email_service.go index 44edf7f7..00691233 100644 --- a/backend/internal/service/email_service.go +++ b/backend/internal/service/email_service.go @@ -12,6 +12,7 @@ import ( "net/smtp" "net/url" "strconv" + "strings" "time" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" @@ -111,7 +112,7 @@ func (s *EmailService) GetSMTPConfig(ctx context.Context) (*SMTPConfig, error) { return nil, fmt.Errorf("get smtp settings: %w", err) } - host := settings[SettingKeySMTPHost] + host := strings.TrimSpace(settings[SettingKeySMTPHost]) if host == "" { return nil, ErrEmailNotConfigured } @@ -128,10 +129,10 @@ func (s *EmailService) GetSMTPConfig(ctx context.Context) (*SMTPConfig, error) { return &SMTPConfig{ Host: host, Port: port, - Username: settings[SettingKeySMTPUsername], - Password: settings[SettingKeySMTPPassword], - From: settings[SettingKeySMTPFrom], - FromName: settings[SettingKeySMTPFromName], + Username: strings.TrimSpace(settings[SettingKeySMTPUsername]), + Password: strings.TrimSpace(settings[SettingKeySMTPPassword]), + From: strings.TrimSpace(settings[SettingKeySMTPFrom]), + FromName: strings.TrimSpace(settings[SettingKeySMTPFromName]), UseTLS: useTLS, }, nil } diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 99cd247e..dafa75f5 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -1580,7 +1580,7 @@