From 3a8dbf5a9957eb7bc6ca8d54e4b40e64c5f29743 Mon Sep 17 00:00:00 2001 From: yangjianbo Date: Sat, 27 Dec 2025 10:57:53 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20golang=201.24->=201.25=20node=2020?= =?UTF-8?q?=20->=20node=2024=20=E5=85=B7=E4=BD=93=E6=8F=90=E5=8D=87?= =?UTF-8?q?=E8=AF=B7=E6=9F=A5=E7=9C=8B=E5=AE=98=E6=96=B9=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 098d4e3a..6bdcd94f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,7 @@ # ----------------------------------------------------------------------------- # Stage 1: Frontend Builder # ----------------------------------------------------------------------------- -FROM node:20-alpine AS frontend-builder +FROM node:24-alpine AS frontend-builder WORKDIR /app/frontend @@ -24,7 +24,7 @@ RUN npm run build # ----------------------------------------------------------------------------- # Stage 2: Backend Builder # ----------------------------------------------------------------------------- -FROM golang:1.24-alpine AS backend-builder +FROM golang:1.25-alpine AS backend-builder # Build arguments for version info (set by CI) ARG VERSION=docker From 3252c378aa497fe59ab5b8aa600bc50df095f547 Mon Sep 17 00:00:00 2001 From: yangjianbo Date: Sat, 27 Dec 2025 21:30:14 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat=20=E5=A2=9E=E5=8A=A0=20caddy=20?= =?UTF-8?q?=E7=A4=BA=E4=BE=8B=E5=AE=89=E5=85=A8=E5=8F=8D=E5=90=91=E4=BB=A3?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/Caddyfile | 184 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 deploy/Caddyfile diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 00000000..eaba462b --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,184 @@ +# ============================================================================= +# Sub2API Caddy Reverse Proxy Configuration (宿主机部署) +# ============================================================================= +# 使用方法: +# 1. 安装 Caddy: https://caddyserver.com/docs/install +# 2. 修改下方 example.com 为你的域名 +# 3. 确保域名 DNS 已指向服务器 +# 4. 复制配置: sudo cp Caddyfile /etc/caddy/Caddyfile +# 5. 重载配置: sudo systemctl reload caddy +# +# Caddy 会自动申请和续期 Let's Encrypt SSL 证书 +# ============================================================================= + +# 全局配置 +{ + # Let's Encrypt 邮箱通知 + email admin@example.com + + # 服务器配置 + servers { + # 启用 HTTP/2 和 HTTP/3 + protocols h1 h2 h3 + + # 超时配置 + timeouts { + read_body 30s + read_header 10s + write 60s + idle 120s + } + } +} + +# 修改为你的域名 +example.com { + # ========================================================================= + # TLS 安全配置 + # ========================================================================= + tls { + # 仅使用 TLS 1.2 和 1.3 + protocols tls1.2 tls1.3 + + # 优先使用的加密套件 + ciphers TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 + } + + # ========================================================================= + # 反向代理配置 + # ========================================================================= + reverse_proxy localhost:8080 { + # 健康检查 + health_uri /health + health_interval 30s + health_timeout 10s + health_status 200 + + # 负载均衡策略(单节点可忽略,多节点时有用) + lb_policy round_robin + lb_try_duration 5s + lb_try_interval 250ms + + # 传递真实客户端信息 + # 兼容 Cloudflare 和直连:后端应优先读取 CF-Connecting-IP,其次 X-Real-IP + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + header_up X-Forwarded-Host {host} + # 保留 Cloudflare 原始头(如果存在) + # 后端获取 IP 的优先级建议: CF-Connecting-IP → X-Real-IP → X-Forwarded-For + header_up CF-Connecting-IP {http.request.header.CF-Connecting-IP} + + # 连接池优化 + transport http { + keepalive 120s + keepalive_idle_conns 256 + read_buffer 16KB + write_buffer 16KB + compression off + } + + # 故障转移 + fail_duration 30s + max_fails 3 + unhealthy_status 500 502 503 504 + } + + # ========================================================================= + # 压缩配置 + # ========================================================================= + encode { + zstd + gzip 6 + minimum_length 256 + match { + header Content-Type text/* + header Content-Type application/json* + header Content-Type application/javascript* + header Content-Type application/xml* + header Content-Type application/rss+xml* + header Content-Type image/svg+xml* + } + } + + # ========================================================================= + # 速率限制 (需要 caddy-ratelimit 插件) + # 如未安装插件,请注释掉此段 + # ========================================================================= + # rate_limit { + # zone api { + # key {remote_host} + # events 100 + # window 1m + # } + # } + + # ========================================================================= + # 安全响应头 + # ========================================================================= + header { + # 防止点击劫持 + X-Frame-Options "SAMEORIGIN" + + # XSS 保护 + X-XSS-Protection "1; mode=block" + + # 防止 MIME 类型嗅探 + X-Content-Type-Options "nosniff" + + # 引用策略 + Referrer-Policy "strict-origin-when-cross-origin" + + # HSTS - 强制 HTTPS (max-age=1年) + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + + # 内容安全策略 (根据需要调整) + # Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https:;" + + # 权限策略 + Permissions-Policy "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()" + + # 跨域资源策略 + Cross-Origin-Opener-Policy "same-origin" + Cross-Origin-Embedder-Policy "require-corp" + Cross-Origin-Resource-Policy "same-origin" + + # 移除敏感头 + -Server + -X-Powered-By + } + + # ========================================================================= + # 请求大小限制 (防止大文件攻击) + # ========================================================================= + request_body { + max_size 100MB + } + + # ========================================================================= + # 日志配置 + # ========================================================================= + log { + output file /var/log/caddy/sub2api.log { + roll_size 50mb + roll_keep 10 + roll_keep_for 720h + } + format json + level INFO + } + + # ========================================================================= + # 错误处理 + # ========================================================================= + handle_errors { + respond "{err.status_code} {err.status_text}" + } +} + +# ============================================================================= +# HTTP 重定向到 HTTPS (Caddy 默认自动处理,此处显式声明) +# ============================================================================= +; http://example.com { +; redir https://{host}{uri} permanent +; } From 97ab649d16f9f85fc2167bf2deb237596053251f Mon Sep 17 00:00:00 2001 From: yangjianbo Date: Sat, 27 Dec 2025 23:08:38 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(=E4=BB=AA=E8=A1=A8=E7=9B=98):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=9C=80=E8=BF=91=E7=94=A8=E9=87=8F=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E6=97=A5=E6=9C=9F=E5=8F=82=E6=95=B0=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:仪表盘“最近用量”调用 /usage 时传入完整 ISO 时间戳(含时分秒/时区),后端 start_date/end_date 仅接受 YYYY-MM-DD,导致请求参数校验失败,页面无法正常展示最近用量。 解决: - loadRecentUsage 改为传入 YYYY-MM-DD(从 toISOString() 取日期部分),与后端参数格式约定保持一致 - 补充注释说明:后端会将 end_date 扩展到当日结束时间,以及 toISOString() 为 UTC 可能带来的统计口径差异 - 同步修正 usageAPI.getByDateRange 的参数注释,避免后续误用 验证:npm -C frontend run build --- frontend/src/api/usage.ts | 4 ++-- frontend/src/views/user/DashboardView.vue | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/frontend/src/api/usage.ts b/frontend/src/api/usage.ts index 20581603..caf763de 100644 --- a/frontend/src/api/usage.ts +++ b/frontend/src/api/usage.ts @@ -148,8 +148,8 @@ export async function getStatsByDateRange( /** * Get usage by date range - * @param startDate - Start date (ISO format) - * @param endDate - End date (ISO format) + * @param startDate - Start date (YYYY-MM-DD format) + * @param endDate - End date (YYYY-MM-DD format) * @param apiKeyId - Optional API key ID filter * @returns Usage logs within date range */ diff --git a/frontend/src/views/user/DashboardView.vue b/frontend/src/views/user/DashboardView.vue index f214c98a..199a0efc 100644 --- a/frontend/src/views/user/DashboardView.vue +++ b/frontend/src/views/user/DashboardView.vue @@ -987,8 +987,13 @@ const loadChartData = async () => { const loadRecentUsage = async () => { loadingUsage.value = true try { - const endDate = new Date().toISOString() - const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString() + // 后端 /usage 查询参数 start_date/end_date 仅接受 YYYY-MM-DD(见 backend usage handler 的校验逻辑)。 + // 同时后端会将 end_date 自动扩展到当天 23:59:59.999...,因此前端只需要传「日期」即可。 + // 注意:toISOString() 生成的是 UTC 日期字符串;如果需要按本地/服务端时区对齐统计口径, + // 请改用时区感知的日期格式化方法(例如 Intl.DateTimeFormat 指定 timeZone)。 + const now = new Date() + const endDate = now.toISOString().split('T')[0] + const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] const usageResponse = await usageAPI.getByDateRange(startDate, endDate) recentUsage.value = usageResponse.items.slice(0, 5) } catch (error) { From fd51ff697092bf1948c987de97b4d1be1c1bf591 Mon Sep 17 00:00:00 2001 From: yangjianbo Date: Sun, 28 Dec 2025 14:34:05 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=E4=BB=A3=E7=A0=81=E7=9A=84=E6=A0=B8?= =?UTF-8?q?=E5=BF=83=E9=97=AE=E9=A2=98=E6=98=AF=E5=88=A4=E9=94=99=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E7=94=A8=E9=94=99=E4=BA=86=E5=B1=82=E7=BA=A7=EF=BC=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - apiKeyService.GetByKey(...) 返回的“找不到 API key”在这个项目里通常会被翻译成业务错误(比如 service.ErrApiKeyNotFound 这类 ApplicationError),而不是直接把 gorm.ErrRecordNotFound 透传到中 间件层。 - 因此你在中间件里用 errors.Is(err, gorm.ErrRecordNotFound) 去判断“无效 key”,很容易匹配不到(尤其 是:后面加 Redis 缓存、换存储实现、或测试里用 stub repo 时,根本不会出现 gorm 的错误)。 - 匹配不到时就会走到 500 Failed to validate API key,导致无效 API key 被错误地当成服务端故障返回 500(应该是 401)。 修复思路:中间件不要依赖 gorm 的错误,改成判断业务层错误,例如: if errors.Is(err, service.ErrApiKeyNotFound) { abortWithGoogleError(c, 401, "Invalid API key") return } 如果你把 GetByKey 的“not found”统一封装成业务错误,这样才不会被底层实现(gorm/redis/mock)影响。 --- backend/internal/server/middleware/api_key_auth_google.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/internal/server/middleware/api_key_auth_google.go b/backend/internal/server/middleware/api_key_auth_google.go index e37b389e..199aca82 100644 --- a/backend/internal/server/middleware/api_key_auth_google.go +++ b/backend/internal/server/middleware/api_key_auth_google.go @@ -8,7 +8,6 @@ import ( "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" - "gorm.io/gorm" ) // ApiKeyAuthGoogle is a Google-style error wrapper for API key auth. @@ -30,7 +29,7 @@ func ApiKeyAuthWithSubscriptionGoogle(apiKeyService *service.ApiKeyService, subs apiKey, err := apiKeyService.GetByKey(c.Request.Context(), apiKeyString) if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { + if errors.Is(err, service.ErrApiKeyNotFound) { abortWithGoogleError(c, 401, "Invalid API key") return }