From d795734352732d0066ba114186dcb24caf2f929d Mon Sep 17 00:00:00 2001 From: kunish Date: Mon, 16 Mar 2026 17:37:15 +0800 Subject: [PATCH 01/95] fix(antigravity): add stream keepalive to prevent connection drops Antigravity streaming handlers were missing the keepalive mechanism that exists in the standard gateway, causing proxy/CDN idle timeouts to break connections during long thinking phases (e.g. claude-opus-4-6). This resulted in truncated responses with missing tool calls. Add StreamKeepaliveInterval support to all three Antigravity streaming paths: Claude SSE, Gemini SSE, and upstream passthrough. --- .../service/antigravity_gateway_service.go | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go index cafc2a7901..aff53331cd 100644 --- a/backend/internal/service/antigravity_gateway_service.go +++ b/backend/internal/service/antigravity_gateway_service.go @@ -3079,6 +3079,22 @@ func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context intervalCh = intervalTicker.C } + // 下游 keepalive:防止代理/Cloudflare Tunnel 因连接空闲而断开 + keepaliveInterval := time.Duration(0) + if s.settingService.cfg != nil && s.settingService.cfg.Gateway.StreamKeepaliveInterval > 0 { + keepaliveInterval = time.Duration(s.settingService.cfg.Gateway.StreamKeepaliveInterval) * time.Second + } + var keepaliveTicker *time.Ticker + if keepaliveInterval > 0 { + keepaliveTicker = time.NewTicker(keepaliveInterval) + defer keepaliveTicker.Stop() + } + var keepaliveCh <-chan time.Time + if keepaliveTicker != nil { + keepaliveCh = keepaliveTicker.C + } + lastDataAt := time.Now() + cw := newAntigravityClientWriter(c.Writer, flusher, "antigravity gemini") // 仅发送一次错误事件,避免多次写入导致协议混乱 @@ -3111,6 +3127,8 @@ func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context return nil, ev.err } + lastDataAt = time.Now() + line := ev.line trimmed := strings.TrimRight(line, "\r\n") if strings.HasPrefix(trimmed, "data:") { @@ -3170,6 +3188,19 @@ func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context logger.LegacyPrintf("service.antigravity_gateway", "Stream data interval timeout (antigravity)") sendErrorEvent("stream_timeout") return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs}, fmt.Errorf("stream data interval timeout") + + case <-keepaliveCh: + if cw.Disconnected() { + continue + } + if time.Since(lastDataAt) < keepaliveInterval { + continue + } + // SSE ping/keepalive:保持连接活跃防止 Cloudflare Tunnel 等代理断开 + if !cw.Fprintf(":\n\n") { + logger.LegacyPrintf("service.antigravity_gateway", "Client disconnected during keepalive ping (antigravity gemini), continuing to drain upstream for billing") + continue + } } } } @@ -3895,6 +3926,22 @@ func (s *AntigravityGatewayService) handleClaudeStreamingResponse(c *gin.Context intervalCh = intervalTicker.C } + // 下游 keepalive:防止代理/Cloudflare Tunnel 因连接空闲而断开 + keepaliveInterval := time.Duration(0) + if s.settingService.cfg != nil && s.settingService.cfg.Gateway.StreamKeepaliveInterval > 0 { + keepaliveInterval = time.Duration(s.settingService.cfg.Gateway.StreamKeepaliveInterval) * time.Second + } + var keepaliveTicker *time.Ticker + if keepaliveInterval > 0 { + keepaliveTicker = time.NewTicker(keepaliveInterval) + defer keepaliveTicker.Stop() + } + var keepaliveCh <-chan time.Time + if keepaliveTicker != nil { + keepaliveCh = keepaliveTicker.C + } + lastDataAt := time.Now() + cw := newAntigravityClientWriter(c.Writer, flusher, "antigravity claude") // 仅发送一次错误事件,避免多次写入导致协议混乱 @@ -3947,6 +3994,8 @@ func (s *AntigravityGatewayService) handleClaudeStreamingResponse(c *gin.Context return nil, fmt.Errorf("stream read error: %w", ev.err) } + lastDataAt = time.Now() + // 处理 SSE 行,转换为 Claude 格式 claudeEvents := processor.ProcessLine(strings.TrimRight(ev.line, "\r\n")) if len(claudeEvents) > 0 { @@ -3969,6 +4018,20 @@ func (s *AntigravityGatewayService) handleClaudeStreamingResponse(c *gin.Context logger.LegacyPrintf("service.antigravity_gateway", "Stream data interval timeout (antigravity)") sendErrorEvent("stream_timeout") return &antigravityStreamResult{usage: convertUsage(nil), firstTokenMs: firstTokenMs}, fmt.Errorf("stream data interval timeout") + + case <-keepaliveCh: + if cw.Disconnected() { + continue + } + if time.Since(lastDataAt) < keepaliveInterval { + continue + } + // SSE ping 事件:Anthropic 原生格式,客户端会正确处理, + // 同时保持连接活跃防止 Cloudflare Tunnel 等代理断开 + if !cw.Fprintf("event: ping\ndata: {\"type\": \"ping\"}\n\n") { + logger.LegacyPrintf("service.antigravity_gateway", "Client disconnected during keepalive ping (antigravity claude), continuing to drain upstream for billing") + continue + } } } } @@ -4299,6 +4362,22 @@ func (s *AntigravityGatewayService) streamUpstreamResponse(c *gin.Context, resp intervalCh = intervalTicker.C } + // 下游 keepalive:防止代理/Cloudflare Tunnel 因连接空闲而断开 + keepaliveInterval := time.Duration(0) + if s.settingService.cfg != nil && s.settingService.cfg.Gateway.StreamKeepaliveInterval > 0 { + keepaliveInterval = time.Duration(s.settingService.cfg.Gateway.StreamKeepaliveInterval) * time.Second + } + var keepaliveTicker *time.Ticker + if keepaliveInterval > 0 { + keepaliveTicker = time.NewTicker(keepaliveInterval) + defer keepaliveTicker.Stop() + } + var keepaliveCh <-chan time.Time + if keepaliveTicker != nil { + keepaliveCh = keepaliveTicker.C + } + lastDataAt := time.Now() + flusher, _ := c.Writer.(http.Flusher) cw := newAntigravityClientWriter(c.Writer, flusher, "antigravity upstream") @@ -4316,6 +4395,8 @@ func (s *AntigravityGatewayService) streamUpstreamResponse(c *gin.Context, resp return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs} } + lastDataAt = time.Now() + line := ev.line // 记录首 token 时间 @@ -4341,6 +4422,20 @@ func (s *AntigravityGatewayService) streamUpstreamResponse(c *gin.Context, resp } logger.LegacyPrintf("service.antigravity_gateway", "Stream data interval timeout (antigravity upstream)") return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs} + + case <-keepaliveCh: + if cw.Disconnected() { + continue + } + if time.Since(lastDataAt) < keepaliveInterval { + continue + } + // SSE ping 事件:Anthropic 原生格式,客户端会正确处理, + // 同时保持连接活跃防止 Cloudflare Tunnel 等代理断开 + if !cw.Fprintf("event: ping\ndata: {\"type\": \"ping\"}\n\n") { + logger.LegacyPrintf("service.antigravity_gateway", "Client disconnected during keepalive ping (antigravity upstream), continuing to drain upstream for billing") + continue + } } } } From d45cc231712896184c5a3fb3c26df31f22631e08 Mon Sep 17 00:00:00 2001 From: erio Date: Mon, 16 Mar 2026 18:25:41 +0800 Subject: [PATCH 02/95] fix(i18n): correct usage label from "Total" to "Last 30d" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The usage stats query defaults to a 30-day rolling window, but the UI label said "Total"/"累计" implying lifetime aggregation. Rename to "Last 30d"/"近30天" so the label matches the actual query semantics. Closes #1060 --- frontend/src/i18n/locales/en.ts | 4 ++-- frontend/src/i18n/locales/zh.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index a66fab847d..6484a27a28 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -574,7 +574,7 @@ export default { groupRequired: 'Please select a group', usage: 'Usage', today: 'Today', - total: 'Total', + total: 'Last 30d', quota: 'Quota', lastUsedAt: 'Last Used', useKey: 'Use Key', @@ -1308,7 +1308,7 @@ export default { actions: 'Actions' }, today: 'Today', - total: 'Total', + total: 'Last 30d', noSubscription: 'No subscription', daysRemaining: '{days}d', expired: 'Expired', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index dbd72a908d..2f3eb2903f 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -574,7 +574,7 @@ export default { groupRequired: '请选择分组', usage: '用量', today: '今日', - total: '累计', + total: '近30天', quota: '额度', lastUsedAt: '上次使用时间', useKey: '使用密钥', @@ -1335,7 +1335,7 @@ export default { actions: '操作' }, today: '今日', - total: '累计', + total: '近30天', noSubscription: '暂无订阅', daysRemaining: '{days}天', expired: '已过期', From 84f60c3f689e06e5d7772209f7ef8cace0154397 Mon Sep 17 00:00:00 2001 From: erio Date: Mon, 16 Mar 2026 18:25:41 +0800 Subject: [PATCH 03/95] fix(i18n): correct usage label from "Total" to "Last 30d" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The usage stats query defaults to a 30-day rolling window, but the UI label said "Total"/"累计" implying lifetime aggregation. Rename to "Last 30d"/"近30天" so the label matches the actual query semantics. Closes #1060 --- frontend/src/i18n/locales/en.ts | 4 ++-- frontend/src/i18n/locales/zh.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index a66fab847d..6484a27a28 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -574,7 +574,7 @@ export default { groupRequired: 'Please select a group', usage: 'Usage', today: 'Today', - total: 'Total', + total: 'Last 30d', quota: 'Quota', lastUsedAt: 'Last Used', useKey: 'Use Key', @@ -1308,7 +1308,7 @@ export default { actions: 'Actions' }, today: 'Today', - total: 'Total', + total: 'Last 30d', noSubscription: 'No subscription', daysRemaining: '{days}d', expired: 'Expired', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index dbd72a908d..2f3eb2903f 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -574,7 +574,7 @@ export default { groupRequired: '请选择分组', usage: '用量', today: '今日', - total: '累计', + total: '近30天', quota: '额度', lastUsedAt: '上次使用时间', useKey: '使用密钥', @@ -1335,7 +1335,7 @@ export default { actions: '操作' }, today: '今日', - total: '累计', + total: '近30天', noSubscription: '暂无订阅', daysRemaining: '{days}天', expired: '已过期', From aa5846b282284ff6f6d2efb98382389c7df063d8 Mon Sep 17 00:00:00 2001 From: shaw Date: Mon, 16 Mar 2026 19:52:14 +0800 Subject: [PATCH 04/95] fix(docker): resolve /app/data permission denied on volume mounts Docker named volumes and host bind-mounts may be owned by root, causing "open data/model_pricing.sha256: permission denied" when the container runs as the non-root sub2api user. Add an entrypoint script that fixes /app/data ownership before dropping to sub2api via su-exec. Replace USER directive with the entrypoint approach across all three Dockerfiles and update both GoReleaser configs to include the script in Docker build contexts. --- .goreleaser.simple.yaml | 2 ++ .goreleaser.yaml | 8 ++++++++ Dockerfile | 11 +++++++---- Dockerfile.goreleaser | 9 +++++++-- deploy/Dockerfile | 11 +++++++---- deploy/docker-entrypoint.sh | 22 ++++++++++++++++++++++ 6 files changed, 53 insertions(+), 10 deletions(-) create mode 100644 deploy/docker-entrypoint.sh diff --git a/.goreleaser.simple.yaml b/.goreleaser.simple.yaml index 2155ed9df8..14f67fd1cb 100644 --- a/.goreleaser.simple.yaml +++ b/.goreleaser.simple.yaml @@ -47,6 +47,8 @@ dockers: - "ghcr.io/{{ .Env.GITHUB_REPO_OWNER_LOWER }}/sub2api:latest" dockerfile: Dockerfile.goreleaser use: buildx + extra_files: + - deploy/docker-entrypoint.sh build_flag_templates: - "--platform=linux/amd64" - "--label=org.opencontainers.image.version={{ .Version }}" diff --git a/.goreleaser.yaml b/.goreleaser.yaml index da2f9aa57c..41f9a5559b 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -63,6 +63,8 @@ dockers: - "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Version }}-amd64" dockerfile: Dockerfile.goreleaser use: buildx + extra_files: + - deploy/docker-entrypoint.sh build_flag_templates: - "--platform=linux/amd64" - "--label=org.opencontainers.image.version={{ .Version }}" @@ -76,6 +78,8 @@ dockers: - "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Version }}-arm64" dockerfile: Dockerfile.goreleaser use: buildx + extra_files: + - deploy/docker-entrypoint.sh build_flag_templates: - "--platform=linux/arm64" - "--label=org.opencontainers.image.version={{ .Version }}" @@ -89,6 +93,8 @@ dockers: - "ghcr.io/{{ .Env.GITHUB_REPO_OWNER_LOWER }}/sub2api:{{ .Version }}-amd64" dockerfile: Dockerfile.goreleaser use: buildx + extra_files: + - deploy/docker-entrypoint.sh build_flag_templates: - "--platform=linux/amd64" - "--label=org.opencontainers.image.version={{ .Version }}" @@ -102,6 +108,8 @@ dockers: - "ghcr.io/{{ .Env.GITHUB_REPO_OWNER_LOWER }}/sub2api:{{ .Version }}-arm64" dockerfile: Dockerfile.goreleaser use: buildx + extra_files: + - deploy/docker-entrypoint.sh build_flag_templates: - "--platform=linux/arm64" - "--label=org.opencontainers.image.version={{ .Version }}" diff --git a/Dockerfile b/Dockerfile index 8fd48cc234..a16eb958f2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -92,6 +92,7 @@ LABEL org.opencontainers.image.source="https://github.com/Wei-Shaw/sub2api" RUN apk add --no-cache \ ca-certificates \ tzdata \ + su-exec \ libpq \ zstd-libs \ lz4-libs \ @@ -120,8 +121,9 @@ COPY --from=backend-builder --chown=sub2api:sub2api /app/backend/resources /app/ # Create data directory RUN mkdir -p /app/data && chown sub2api:sub2api /app/data -# Switch to non-root user -USER sub2api +# Copy entrypoint script (fixes volume permissions then drops to sub2api) +COPY deploy/docker-entrypoint.sh /app/docker-entrypoint.sh +RUN chmod +x /app/docker-entrypoint.sh # Expose port (can be overridden by SERVER_PORT env var) EXPOSE 8080 @@ -130,5 +132,6 @@ EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ CMD wget -q -T 5 -O /dev/null http://localhost:${SERVER_PORT:-8080}/health || exit 1 -# Run the application -ENTRYPOINT ["/app/sub2api"] +# Run the application (entrypoint fixes /app/data ownership then execs as sub2api) +ENTRYPOINT ["/app/docker-entrypoint.sh"] +CMD ["/app/sub2api"] diff --git a/Dockerfile.goreleaser b/Dockerfile.goreleaser index 419994b9ac..f251d154c6 100644 --- a/Dockerfile.goreleaser +++ b/Dockerfile.goreleaser @@ -21,6 +21,7 @@ RUN apk add --no-cache \ ca-certificates \ tzdata \ curl \ + su-exec \ libpq \ zstd-libs \ lz4-libs \ @@ -47,11 +48,15 @@ COPY sub2api /app/sub2api # Create data directory RUN mkdir -p /app/data && chown -R sub2api:sub2api /app -USER sub2api +# Copy entrypoint script (fixes volume permissions then drops to sub2api) +COPY deploy/docker-entrypoint.sh /app/docker-entrypoint.sh +RUN chmod +x /app/docker-entrypoint.sh EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ CMD curl -f http://localhost:${SERVER_PORT:-8080}/health || exit 1 -ENTRYPOINT ["/app/sub2api"] +# Run the application (entrypoint fixes /app/data ownership then execs as sub2api) +ENTRYPOINT ["/app/docker-entrypoint.sh"] +CMD ["/app/sub2api"] diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 0f4f1de9b4..7caa5ca63e 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -82,6 +82,7 @@ RUN apk add --no-cache \ ca-certificates \ tzdata \ curl \ + su-exec \ && rm -rf /var/cache/apk/* # Create non-root user @@ -97,8 +98,9 @@ COPY --from=backend-builder /app/sub2api /app/sub2api # Create data directory RUN mkdir -p /app/data && chown -R sub2api:sub2api /app -# Switch to non-root user -USER sub2api +# Copy entrypoint script (fixes volume permissions then drops to sub2api) +COPY deploy/docker-entrypoint.sh /app/docker-entrypoint.sh +RUN chmod +x /app/docker-entrypoint.sh # Expose port (can be overridden by SERVER_PORT env var) EXPOSE 8080 @@ -107,5 +109,6 @@ EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ CMD wget -q -T 5 -O /dev/null http://localhost:${SERVER_PORT:-8080}/health || exit 1 -# Run the application -ENTRYPOINT ["/app/sub2api"] +# Run the application (entrypoint fixes /app/data ownership then execs as sub2api) +ENTRYPOINT ["/app/docker-entrypoint.sh"] +CMD ["/app/sub2api"] diff --git a/deploy/docker-entrypoint.sh b/deploy/docker-entrypoint.sh new file mode 100644 index 0000000000..344429bdfb --- /dev/null +++ b/deploy/docker-entrypoint.sh @@ -0,0 +1,22 @@ +#!/bin/sh +set -e + +# Fix data directory permissions when running as root. +# Docker named volumes / host bind-mounts may be owned by root, +# preventing the non-root sub2api user from writing files. +if [ "$(id -u)" = "0" ]; then + mkdir -p /app/data + chown -R sub2api:sub2api /app/data + # Re-invoke this script as sub2api so the flag-detection below + # also runs under the correct user. + exec su-exec sub2api "$0" "$@" +fi + +# Compatibility: if the first arg looks like a flag (e.g. --help), +# prepend the default binary so it behaves the same as the old +# ENTRYPOINT ["/app/sub2api"] style. +if [ "${1#-}" != "$1" ]; then + set -- /app/sub2api "$@" +fi + +exec "$@" From c1fab7f8d8d22c2fecd65eff7b86b641cb2a55ba Mon Sep 17 00:00:00 2001 From: QTom Date: Mon, 16 Mar 2026 20:03:08 +0800 Subject: [PATCH 05/95] =?UTF-8?q?feat(backup):=20=E5=A4=87=E4=BB=BD/?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E5=BC=82=E6=AD=A5=E5=8C=96=EF=BC=8C=E8=A7=A3?= =?UTF-8?q?=E5=86=B3=20504=20=E8=B6=85=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /backups 和 POST /backups/:id/restore 改为异步:立即返回 HTTP 202, 后台 goroutine 独立执行 pg_dump → gzip → S3 上传,前端每 2s 轮询状态。 后端: - 新增 StartBackup/StartRestore 方法,后台 goroutine 不依赖 HTTP 连接 - Graceful shutdown 等待活跃操作完成,启动时清理孤立 running 记录 - BackupRecord 新增 progress/restore_status 字段支持进度和恢复状态追踪 前端: - 创建备份/恢复后轮询 GET /backups/:id 直到完成或失败 - 标签页切换暂停/恢复轮询,组件卸载清理定时器 - 正确处理 409(备份进行中)和轮询超时 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../internal/handler/admin/backup_handler.go | 9 +- backend/internal/pkg/response/response.go | 9 + .../internal/repository/backup_s3_store.go | 1 + backend/internal/service/backup_service.go | 453 ++++++++++++++++-- .../internal/service/backup_service_test.go | 181 ++++++- frontend/src/api/admin/backup.ts | 11 +- frontend/src/i18n/locales/en.ts | 12 +- frontend/src/i18n/locales/zh.ts | 12 +- frontend/src/views/admin/BackupView.vue | 160 ++++++- 9 files changed, 780 insertions(+), 68 deletions(-) diff --git a/backend/internal/handler/admin/backup_handler.go b/backend/internal/handler/admin/backup_handler.go index d19713ee46..2f528322f3 100644 --- a/backend/internal/handler/admin/backup_handler.go +++ b/backend/internal/handler/admin/backup_handler.go @@ -98,12 +98,12 @@ func (h *BackupHandler) CreateBackup(c *gin.Context) { expireDays = *req.ExpireDays } - record, err := h.backupService.CreateBackup(c.Request.Context(), "manual", expireDays) + record, err := h.backupService.StartBackup(c.Request.Context(), "manual", expireDays) if err != nil { response.ErrorFrom(c, err) return } - response.Success(c, record) + response.Accepted(c, record) } func (h *BackupHandler) ListBackups(c *gin.Context) { @@ -196,9 +196,10 @@ func (h *BackupHandler) RestoreBackup(c *gin.Context) { return } - if err := h.backupService.RestoreBackup(c.Request.Context(), backupID); err != nil { + record, err := h.backupService.StartRestore(c.Request.Context(), backupID) + if err != nil { response.ErrorFrom(c, err) return } - response.Success(c, gin.H{"restored": true}) + response.Accepted(c, record) } diff --git a/backend/internal/pkg/response/response.go b/backend/internal/pkg/response/response.go index 0519c2cc1c..b1d6c2d059 100644 --- a/backend/internal/pkg/response/response.go +++ b/backend/internal/pkg/response/response.go @@ -47,6 +47,15 @@ func Created(c *gin.Context, data any) { }) } +// Accepted 返回异步接受响应 (HTTP 202) +func Accepted(c *gin.Context, data any) { + c.JSON(http.StatusAccepted, Response{ + Code: 0, + Message: "accepted", + Data: data, + }) +} + // Error 返回错误响应 func Error(c *gin.Context, statusCode int, message string) { c.JSON(statusCode, Response{ diff --git a/backend/internal/repository/backup_s3_store.go b/backend/internal/repository/backup_s3_store.go index ba5434f591..5d419f574b 100644 --- a/backend/internal/repository/backup_s3_store.go +++ b/backend/internal/repository/backup_s3_store.go @@ -57,6 +57,7 @@ func NewS3BackupStoreFactory() service.BackupObjectStoreFactory { func (s *S3BackupStore) Upload(ctx context.Context, key string, body io.Reader, contentType string) (int64, error) { // 读取全部内容以获取大小(S3 PutObject 需要知道内容长度) + // 注意:阿里云 OSS 不兼容 s3manager 分片上传的签名方式,因此使用 PutObject data, err := io.ReadAll(body) if err != nil { return 0, fmt.Errorf("read body: %w", err) diff --git a/backend/internal/service/backup_service.go b/backend/internal/service/backup_service.go index 25f1e9a159..2fcf2da89f 100644 --- a/backend/internal/service/backup_service.go +++ b/backend/internal/service/backup_service.go @@ -4,11 +4,13 @@ import ( "compress/gzip" "context" "encoding/json" + "errors" "fmt" "io" "sort" "strings" "sync" + "sync/atomic" "time" "github.com/google/uuid" @@ -84,17 +86,21 @@ type BackupScheduleConfig struct { // BackupRecord 备份记录 type BackupRecord struct { - ID string `json:"id"` - Status string `json:"status"` // pending, running, completed, failed - BackupType string `json:"backup_type"` // postgres - FileName string `json:"file_name"` - S3Key string `json:"s3_key"` - SizeBytes int64 `json:"size_bytes"` - TriggeredBy string `json:"triggered_by"` // manual, scheduled - ErrorMsg string `json:"error_message,omitempty"` - StartedAt string `json:"started_at"` - FinishedAt string `json:"finished_at,omitempty"` - ExpiresAt string `json:"expires_at,omitempty"` // 过期时间 + ID string `json:"id"` + Status string `json:"status"` // pending, running, completed, failed + BackupType string `json:"backup_type"` // postgres + FileName string `json:"file_name"` + S3Key string `json:"s3_key"` + SizeBytes int64 `json:"size_bytes"` + TriggeredBy string `json:"triggered_by"` // manual, scheduled + ErrorMsg string `json:"error_message,omitempty"` + StartedAt string `json:"started_at"` + FinishedAt string `json:"finished_at,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` // 过期时间 + Progress string `json:"progress,omitempty"` // "dumping", "uploading", "" + RestoreStatus string `json:"restore_status,omitempty"` // "", "running", "completed", "failed" + RestoreError string `json:"restore_error,omitempty"` + RestoredAt string `json:"restored_at,omitempty"` } // BackupService 数据库备份恢复服务 @@ -105,17 +111,24 @@ type BackupService struct { storeFactory BackupObjectStoreFactory dumper DBDumper - mu sync.Mutex - store BackupObjectStore - s3Cfg *BackupS3Config + opMu sync.Mutex // 保护 backingUp/restoring 标志 backingUp bool restoring bool + storeMu sync.Mutex // 保护 store/s3Cfg 缓存 + store BackupObjectStore + s3Cfg *BackupS3Config + recordsMu sync.Mutex // 保护 records 的 load/save 操作 cronMu sync.Mutex cronSched *cron.Cron cronEntryID cron.EntryID + + wg sync.WaitGroup // 追踪活跃的备份/恢复 goroutine + shuttingDown atomic.Bool // 阻止新备份启动 + bgCtx context.Context // 所有后台操作的 parent context + bgCancel context.CancelFunc // 取消所有活跃后台操作 } func NewBackupService( @@ -125,20 +138,26 @@ func NewBackupService( storeFactory BackupObjectStoreFactory, dumper DBDumper, ) *BackupService { + bgCtx, bgCancel := context.WithCancel(context.Background()) return &BackupService{ settingRepo: settingRepo, dbCfg: &cfg.Database, encryptor: encryptor, storeFactory: storeFactory, dumper: dumper, + bgCtx: bgCtx, + bgCancel: bgCancel, } } -// Start 启动定时备份调度器 +// Start 启动定时备份调度器并清理孤立记录 func (s *BackupService) Start() { s.cronSched = cron.New() s.cronSched.Start() + // 清理重启后孤立的 running 记录 + s.recoverStaleRecords() + // 加载已有的定时配置 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -154,13 +173,65 @@ func (s *BackupService) Start() { } } -// Stop 停止定时备份 +// recoverStaleRecords 启动时将孤立的 running 记录标记为 failed +func (s *BackupService) recoverStaleRecords() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + records, err := s.loadRecords(ctx) + if err != nil { + return + } + for i := range records { + if records[i].Status == "running" { + records[i].Status = "failed" + records[i].ErrorMsg = "interrupted by server restart" + records[i].Progress = "" + records[i].FinishedAt = time.Now().Format(time.RFC3339) + _ = s.saveRecord(ctx, &records[i]) + logger.LegacyPrintf("service.backup", "[Backup] recovered stale running record: %s", records[i].ID) + } + if records[i].RestoreStatus == "running" { + records[i].RestoreStatus = "failed" + records[i].RestoreError = "interrupted by server restart" + _ = s.saveRecord(ctx, &records[i]) + logger.LegacyPrintf("service.backup", "[Backup] recovered stale restoring record: %s", records[i].ID) + } + } +} + +// Stop 停止定时备份并等待活跃操作完成 func (s *BackupService) Stop() { + s.shuttingDown.Store(true) + s.cronMu.Lock() - defer s.cronMu.Unlock() if s.cronSched != nil { s.cronSched.Stop() } + s.cronMu.Unlock() + + // 等待活跃备份/恢复完成(最多 5 分钟) + done := make(chan struct{}) + go func() { + s.wg.Wait() + close(done) + }() + select { + case <-done: + logger.LegacyPrintf("service.backup", "[Backup] all active operations finished") + case <-time.After(5 * time.Minute): + logger.LegacyPrintf("service.backup", "[Backup] shutdown timeout after 5min, cancelling active operations") + if s.bgCancel != nil { + s.bgCancel() // 取消所有后台操作 + } + // 给 goroutine 时间响应取消并完成清理 + select { + case <-done: + logger.LegacyPrintf("service.backup", "[Backup] active operations cancelled and cleaned up") + case <-time.After(10 * time.Second): + logger.LegacyPrintf("service.backup", "[Backup] goroutine cleanup timed out") + } + } } // ─── S3 配置管理 ─── @@ -203,10 +274,10 @@ func (s *BackupService) UpdateS3Config(ctx context.Context, cfg BackupS3Config) } // 清除缓存的 S3 客户端 - s.mu.Lock() + s.storeMu.Lock() s.store = nil s.s3Cfg = nil - s.mu.Unlock() + s.storeMu.Unlock() cfg.SecretAccessKey = "" return &cfg, nil @@ -314,7 +385,10 @@ func (s *BackupService) removeCronSchedule() { } func (s *BackupService) runScheduledBackup() { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + s.wg.Add(1) + defer s.wg.Done() + + ctx, cancel := context.WithTimeout(s.bgCtx, 30*time.Minute) defer cancel() // 读取定时备份配置中的过期天数 @@ -327,7 +401,11 @@ func (s *BackupService) runScheduledBackup() { logger.LegacyPrintf("service.backup", "[Backup] 开始执行定时备份, 过期天数: %d", expireDays) record, err := s.CreateBackup(ctx, "scheduled", expireDays) if err != nil { - logger.LegacyPrintf("service.backup", "[Backup] 定时备份失败: %v", err) + if errors.Is(err, ErrBackupInProgress) { + logger.LegacyPrintf("service.backup", "[Backup] 定时备份跳过: 已有备份正在进行中") + } else { + logger.LegacyPrintf("service.backup", "[Backup] 定时备份失败: %v", err) + } return } logger.LegacyPrintf("service.backup", "[Backup] 定时备份完成: id=%s size=%d", record.ID, record.SizeBytes) @@ -346,17 +424,21 @@ func (s *BackupService) runScheduledBackup() { // CreateBackup 创建全量数据库备份并上传到 S3(流式处理) // expireDays: 备份过期天数,0=永不过期,默认14天 func (s *BackupService) CreateBackup(ctx context.Context, triggeredBy string, expireDays int) (*BackupRecord, error) { - s.mu.Lock() + if s.shuttingDown.Load() { + return nil, infraerrors.ServiceUnavailable("SERVER_SHUTTING_DOWN", "server is shutting down") + } + + s.opMu.Lock() if s.backingUp { - s.mu.Unlock() + s.opMu.Unlock() return nil, ErrBackupInProgress } s.backingUp = true - s.mu.Unlock() + s.opMu.Unlock() defer func() { - s.mu.Lock() + s.opMu.Lock() s.backingUp = false - s.mu.Unlock() + s.opMu.Unlock() }() s3Cfg, err := s.loadS3Config(ctx) @@ -405,36 +487,47 @@ func (s *BackupService) CreateBackup(ctx context.Context, triggeredBy string, ex // 使用 io.Pipe 将 gzip 压缩数据流式传递给 S3 上传 pr, pw := io.Pipe() - var gzipErr error + gzipDone := make(chan error, 1) go func() { + defer func() { + if r := recover(); r != nil { + pw.CloseWithError(fmt.Errorf("gzip goroutine panic: %v", r)) //nolint:errcheck + gzipDone <- fmt.Errorf("gzip goroutine panic: %v", r) + } + }() gzWriter := gzip.NewWriter(pw) - _, gzipErr = io.Copy(gzWriter, dumpReader) - if closeErr := gzWriter.Close(); closeErr != nil && gzipErr == nil { - gzipErr = closeErr + var gzErr error + _, gzErr = io.Copy(gzWriter, dumpReader) + if closeErr := gzWriter.Close(); closeErr != nil && gzErr == nil { + gzErr = closeErr } - if closeErr := dumpReader.Close(); closeErr != nil && gzipErr == nil { - gzipErr = closeErr + if closeErr := dumpReader.Close(); closeErr != nil && gzErr == nil { + gzErr = closeErr } - if gzipErr != nil { - _ = pw.CloseWithError(gzipErr) + if gzErr != nil { + _ = pw.CloseWithError(gzErr) } else { _ = pw.Close() } + gzipDone <- gzErr }() contentType := "application/gzip" sizeBytes, err := objectStore.Upload(ctx, s3Key, pr, contentType) if err != nil { + _ = pr.CloseWithError(err) // 确保 gzip goroutine 不会悬挂 + gzErr := <-gzipDone // 安全等待 gzip goroutine 完成 record.Status = "failed" errMsg := fmt.Sprintf("S3 upload failed: %v", err) - if gzipErr != nil { - errMsg = fmt.Sprintf("gzip/dump failed: %v", gzipErr) + if gzErr != nil { + errMsg = fmt.Sprintf("gzip/dump failed: %v", gzErr) } record.ErrorMsg = errMsg record.FinishedAt = time.Now().Format(time.RFC3339) _ = s.saveRecord(ctx, record) return record, fmt.Errorf("backup upload: %w", err) } + <-gzipDone // 确保 gzip goroutine 已退出 record.SizeBytes = sizeBytes record.Status = "completed" @@ -446,19 +539,187 @@ func (s *BackupService) CreateBackup(ctx context.Context, triggeredBy string, ex return record, nil } +// StartBackup 异步创建备份,立即返回 running 状态的记录 +func (s *BackupService) StartBackup(ctx context.Context, triggeredBy string, expireDays int) (*BackupRecord, error) { + if s.shuttingDown.Load() { + return nil, infraerrors.ServiceUnavailable("SERVER_SHUTTING_DOWN", "server is shutting down") + } + + s.opMu.Lock() + if s.backingUp { + s.opMu.Unlock() + return nil, ErrBackupInProgress + } + s.backingUp = true + s.opMu.Unlock() + + // 初始化阶段出错时自动重置标志 + launched := false + defer func() { + if !launched { + s.opMu.Lock() + s.backingUp = false + s.opMu.Unlock() + } + }() + + // 在返回前加载 S3 配置和创建 store,避免 goroutine 中配置被修改 + s3Cfg, err := s.loadS3Config(ctx) + if err != nil { + return nil, err + } + if s3Cfg == nil || !s3Cfg.IsConfigured() { + return nil, ErrBackupS3NotConfigured + } + + objectStore, err := s.getOrCreateStore(ctx, s3Cfg) + if err != nil { + return nil, fmt.Errorf("init object store: %w", err) + } + + now := time.Now() + backupID := uuid.New().String()[:8] + fileName := fmt.Sprintf("%s_%s.sql.gz", s.dbCfg.DBName, now.Format("20060102_150405")) + s3Key := s.buildS3Key(s3Cfg, fileName) + + var expiresAt string + if expireDays > 0 { + expiresAt = now.AddDate(0, 0, expireDays).Format(time.RFC3339) + } + + record := &BackupRecord{ + ID: backupID, + Status: "running", + BackupType: "postgres", + FileName: fileName, + S3Key: s3Key, + TriggeredBy: triggeredBy, + StartedAt: now.Format(time.RFC3339), + ExpiresAt: expiresAt, + Progress: "pending", + } + + if err := s.saveRecord(ctx, record); err != nil { + return nil, fmt.Errorf("save initial record: %w", err) + } + + launched = true + // 在启动 goroutine 前完成拷贝,避免数据竞争 + result := *record + + s.wg.Add(1) + go func() { + defer s.wg.Done() + defer func() { + s.opMu.Lock() + s.backingUp = false + s.opMu.Unlock() + }() + defer func() { + if r := recover(); r != nil { + logger.LegacyPrintf("service.backup", "[Backup] panic recovered: %v", r) + record.Status = "failed" + record.ErrorMsg = fmt.Sprintf("internal panic: %v", r) + record.Progress = "" + record.FinishedAt = time.Now().Format(time.RFC3339) + _ = s.saveRecord(context.Background(), record) + } + }() + s.executeBackup(record, objectStore) + }() + + return &result, nil +} + +// executeBackup 后台执行备份(独立于 HTTP context) +func (s *BackupService) executeBackup(record *BackupRecord, objectStore BackupObjectStore) { + ctx, cancel := context.WithTimeout(s.bgCtx, 30*time.Minute) + defer cancel() + + // 阶段1: pg_dump + record.Progress = "dumping" + _ = s.saveRecord(ctx, record) + + dumpReader, err := s.dumper.Dump(ctx) + if err != nil { + record.Status = "failed" + record.ErrorMsg = fmt.Sprintf("pg_dump failed: %v", err) + record.Progress = "" + record.FinishedAt = time.Now().Format(time.RFC3339) + _ = s.saveRecord(context.Background(), record) + return + } + + // 阶段2: gzip + upload + record.Progress = "uploading" + _ = s.saveRecord(ctx, record) + + pr, pw := io.Pipe() + gzipDone := make(chan error, 1) + go func() { + defer func() { + if r := recover(); r != nil { + pw.CloseWithError(fmt.Errorf("gzip goroutine panic: %v", r)) //nolint:errcheck + gzipDone <- fmt.Errorf("gzip goroutine panic: %v", r) + } + }() + gzWriter := gzip.NewWriter(pw) + var gzErr error + _, gzErr = io.Copy(gzWriter, dumpReader) + if closeErr := gzWriter.Close(); closeErr != nil && gzErr == nil { + gzErr = closeErr + } + if closeErr := dumpReader.Close(); closeErr != nil && gzErr == nil { + gzErr = closeErr + } + if gzErr != nil { + _ = pw.CloseWithError(gzErr) + } else { + _ = pw.Close() + } + gzipDone <- gzErr + }() + + contentType := "application/gzip" + sizeBytes, err := objectStore.Upload(ctx, record.S3Key, pr, contentType) + if err != nil { + _ = pr.CloseWithError(err) // 确保 gzip goroutine 不会悬挂 + gzErr := <-gzipDone // 安全等待 gzip goroutine 完成 + record.Status = "failed" + errMsg := fmt.Sprintf("S3 upload failed: %v", err) + if gzErr != nil { + errMsg = fmt.Sprintf("gzip/dump failed: %v", gzErr) + } + record.ErrorMsg = errMsg + record.Progress = "" + record.FinishedAt = time.Now().Format(time.RFC3339) + _ = s.saveRecord(context.Background(), record) + return + } + <-gzipDone // 确保 gzip goroutine 已退出 + + record.SizeBytes = sizeBytes + record.Status = "completed" + record.Progress = "" + record.FinishedAt = time.Now().Format(time.RFC3339) + if err := s.saveRecord(context.Background(), record); err != nil { + logger.LegacyPrintf("service.backup", "[Backup] 保存备份记录失败: %v", err) + } +} + // RestoreBackup 从 S3 下载备份并流式恢复到数据库 func (s *BackupService) RestoreBackup(ctx context.Context, backupID string) error { - s.mu.Lock() + s.opMu.Lock() if s.restoring { - s.mu.Unlock() + s.opMu.Unlock() return ErrRestoreInProgress } s.restoring = true - s.mu.Unlock() + s.opMu.Unlock() defer func() { - s.mu.Lock() + s.opMu.Lock() s.restoring = false - s.mu.Unlock() + s.opMu.Unlock() }() record, err := s.GetBackupRecord(ctx, backupID) @@ -500,6 +761,112 @@ func (s *BackupService) RestoreBackup(ctx context.Context, backupID string) erro return nil } +// StartRestore 异步恢复备份,立即返回 +func (s *BackupService) StartRestore(ctx context.Context, backupID string) (*BackupRecord, error) { + if s.shuttingDown.Load() { + return nil, infraerrors.ServiceUnavailable("SERVER_SHUTTING_DOWN", "server is shutting down") + } + + s.opMu.Lock() + if s.restoring { + s.opMu.Unlock() + return nil, ErrRestoreInProgress + } + s.restoring = true + s.opMu.Unlock() + + // 初始化阶段出错时自动重置标志 + launched := false + defer func() { + if !launched { + s.opMu.Lock() + s.restoring = false + s.opMu.Unlock() + } + }() + + record, err := s.GetBackupRecord(ctx, backupID) + if err != nil { + return nil, err + } + if record.Status != "completed" { + return nil, infraerrors.BadRequest("BACKUP_NOT_COMPLETED", "can only restore from a completed backup") + } + + s3Cfg, err := s.loadS3Config(ctx) + if err != nil { + return nil, err + } + objectStore, err := s.getOrCreateStore(ctx, s3Cfg) + if err != nil { + return nil, fmt.Errorf("init object store: %w", err) + } + + record.RestoreStatus = "running" + _ = s.saveRecord(ctx, record) + + launched = true + result := *record + + s.wg.Add(1) + go func() { + defer s.wg.Done() + defer func() { + s.opMu.Lock() + s.restoring = false + s.opMu.Unlock() + }() + defer func() { + if r := recover(); r != nil { + logger.LegacyPrintf("service.backup", "[Backup] restore panic recovered: %v", r) + record.RestoreStatus = "failed" + record.RestoreError = fmt.Sprintf("internal panic: %v", r) + _ = s.saveRecord(context.Background(), record) + } + }() + s.executeRestore(record, objectStore) + }() + + return &result, nil +} + +// executeRestore 后台执行恢复 +func (s *BackupService) executeRestore(record *BackupRecord, objectStore BackupObjectStore) { + ctx, cancel := context.WithTimeout(s.bgCtx, 30*time.Minute) + defer cancel() + + body, err := objectStore.Download(ctx, record.S3Key) + if err != nil { + record.RestoreStatus = "failed" + record.RestoreError = fmt.Sprintf("S3 download failed: %v", err) + _ = s.saveRecord(context.Background(), record) + return + } + defer func() { _ = body.Close() }() + + gzReader, err := gzip.NewReader(body) + if err != nil { + record.RestoreStatus = "failed" + record.RestoreError = fmt.Sprintf("gzip reader: %v", err) + _ = s.saveRecord(context.Background(), record) + return + } + defer func() { _ = gzReader.Close() }() + + if err := s.dumper.Restore(ctx, gzReader); err != nil { + record.RestoreStatus = "failed" + record.RestoreError = fmt.Sprintf("pg restore: %v", err) + _ = s.saveRecord(context.Background(), record) + return + } + + record.RestoreStatus = "completed" + record.RestoredAt = time.Now().Format(time.RFC3339) + if err := s.saveRecord(context.Background(), record); err != nil { + logger.LegacyPrintf("service.backup", "[Backup] 保存恢复记录失败: %v", err) + } +} + // ─── 备份记录管理 ─── func (s *BackupService) ListBackups(ctx context.Context) ([]BackupRecord, error) { @@ -614,8 +981,8 @@ func (s *BackupService) loadS3Config(ctx context.Context) (*BackupS3Config, erro } func (s *BackupService) getOrCreateStore(ctx context.Context, cfg *BackupS3Config) (BackupObjectStore, error) { - s.mu.Lock() - defer s.mu.Unlock() + s.storeMu.Lock() + defer s.storeMu.Unlock() if s.store != nil && s.s3Cfg != nil { return s.store, nil diff --git a/backend/internal/service/backup_service_test.go b/backend/internal/service/backup_service_test.go index e752997c35..b308e6d09d 100644 --- a/backend/internal/service/backup_service_test.go +++ b/backend/internal/service/backup_service_test.go @@ -134,6 +134,30 @@ func (m *mockDumper) Restore(_ context.Context, data io.Reader) error { return nil } +// blockingDumper 可控延迟的 dumper,用于测试异步行为 +type blockingDumper struct { + blockCh chan struct{} + data []byte + restErr error +} + +func (d *blockingDumper) Dump(ctx context.Context) (io.ReadCloser, error) { + select { + case <-d.blockCh: + case <-ctx.Done(): + return nil, ctx.Err() + } + return io.NopCloser(bytes.NewReader(d.data)), nil +} + +func (d *blockingDumper) Restore(_ context.Context, data io.Reader) error { + if d.restErr != nil { + return d.restErr + } + _, _ = io.ReadAll(data) + return nil +} + type mockObjectStore struct { objects map[string][]byte mu sync.Mutex @@ -179,7 +203,7 @@ func (m *mockObjectStore) HeadBucket(_ context.Context) error { return nil } -func newTestBackupService(repo *mockSettingRepo, dumper *mockDumper, store *mockObjectStore) *BackupService { +func newTestBackupService(repo *mockSettingRepo, dumper DBDumper, store *mockObjectStore) *BackupService { cfg := &config.Config{ Database: config.DatabaseConfig{ Host: "localhost", @@ -361,9 +385,9 @@ func TestBackupService_CreateBackup_ConcurrentBlocked(t *testing.T) { svc := newTestBackupService(repo, dumper, store) // 手动设置 backingUp 标志 - svc.mu.Lock() + svc.opMu.Lock() svc.backingUp = true - svc.mu.Unlock() + svc.opMu.Unlock() _, err := svc.CreateBackup(context.Background(), "manual", 14) require.ErrorIs(t, err, ErrBackupInProgress) @@ -526,3 +550,154 @@ func TestBackupService_LoadS3Config_Corrupted(t *testing.T) { require.Error(t, err) require.Nil(t, cfg) } + +// ─── Async Backup Tests ─── + +func TestStartBackup_ReturnsImmediately(t *testing.T) { + repo := newMockSettingRepo() + seedS3Config(t, repo) + + dumper := &blockingDumper{blockCh: make(chan struct{}), data: []byte("data")} + store := newMockObjectStore() + svc := newTestBackupService(repo, dumper, store) + + record, err := svc.StartBackup(context.Background(), "manual", 14) + require.NoError(t, err) + require.Equal(t, "running", record.Status) + require.NotEmpty(t, record.ID) + + // 释放 dumper 让后台完成 + close(dumper.blockCh) + svc.wg.Wait() + + // 验证最终状态 + final, err := svc.GetBackupRecord(context.Background(), record.ID) + require.NoError(t, err) + require.Equal(t, "completed", final.Status) + require.Greater(t, final.SizeBytes, int64(0)) +} + +func TestStartBackup_ConcurrentBlocked(t *testing.T) { + repo := newMockSettingRepo() + seedS3Config(t, repo) + + dumper := &blockingDumper{blockCh: make(chan struct{}), data: []byte("data")} + store := newMockObjectStore() + svc := newTestBackupService(repo, dumper, store) + + // 第一次启动 + _, err := svc.StartBackup(context.Background(), "manual", 14) + require.NoError(t, err) + + // 第二次应被阻塞 + _, err = svc.StartBackup(context.Background(), "manual", 14) + require.ErrorIs(t, err, ErrBackupInProgress) + + close(dumper.blockCh) + svc.wg.Wait() +} + +func TestStartBackup_ShuttingDown(t *testing.T) { + repo := newMockSettingRepo() + seedS3Config(t, repo) + svc := newTestBackupService(repo, &mockDumper{dumpData: []byte("data")}, newMockObjectStore()) + + svc.shuttingDown.Store(true) + + _, err := svc.StartBackup(context.Background(), "manual", 14) + require.Error(t, err) + require.Contains(t, err.Error(), "shutting down") +} + +func TestRecoverStaleRecords(t *testing.T) { + repo := newMockSettingRepo() + svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore()) + + // 模拟一条孤立的 running 记录 + _ = svc.saveRecord(context.Background(), &BackupRecord{ + ID: "stale-1", + Status: "running", + StartedAt: time.Now().Add(-1 * time.Hour).Format(time.RFC3339), + }) + // 模拟一条孤立的恢复中记录 + _ = svc.saveRecord(context.Background(), &BackupRecord{ + ID: "stale-2", + Status: "completed", + RestoreStatus: "running", + StartedAt: time.Now().Add(-1 * time.Hour).Format(time.RFC3339), + }) + + svc.recoverStaleRecords() + + r1, _ := svc.GetBackupRecord(context.Background(), "stale-1") + require.Equal(t, "failed", r1.Status) + require.Contains(t, r1.ErrorMsg, "server restart") + + r2, _ := svc.GetBackupRecord(context.Background(), "stale-2") + require.Equal(t, "failed", r2.RestoreStatus) + require.Contains(t, r2.RestoreError, "server restart") +} + +func TestGracefulShutdown(t *testing.T) { + repo := newMockSettingRepo() + seedS3Config(t, repo) + + dumper := &blockingDumper{blockCh: make(chan struct{}), data: []byte("data")} + store := newMockObjectStore() + svc := newTestBackupService(repo, dumper, store) + + _, err := svc.StartBackup(context.Background(), "manual", 14) + require.NoError(t, err) + + // Stop 应该等待备份完成 + done := make(chan struct{}) + go func() { + svc.Stop() + close(done) + }() + + // 短暂等待确认 Stop 还在等待 + select { + case <-done: + t.Fatal("Stop returned before backup finished") + case <-time.After(100 * time.Millisecond): + // 预期:Stop 还在等待 + } + + // 释放备份 + close(dumper.blockCh) + + // 现在 Stop 应该完成 + select { + case <-done: + // 预期 + case <-time.After(5 * time.Second): + t.Fatal("Stop did not return after backup finished") + } +} + +func TestStartRestore_Async(t *testing.T) { + repo := newMockSettingRepo() + seedS3Config(t, repo) + + dumpContent := "-- PostgreSQL dump\nCREATE TABLE test (id int);\n" + dumper := &mockDumper{dumpData: []byte(dumpContent)} + store := newMockObjectStore() + svc := newTestBackupService(repo, dumper, store) + + // 先创建一个备份(同步方式) + record, err := svc.CreateBackup(context.Background(), "manual", 14) + require.NoError(t, err) + + // 异步恢复 + restored, err := svc.StartRestore(context.Background(), record.ID) + require.NoError(t, err) + require.Equal(t, "running", restored.RestoreStatus) + + svc.wg.Wait() + + // 验证最终状态 + final, err := svc.GetBackupRecord(context.Background(), record.ID) + require.NoError(t, err) + require.Equal(t, "completed", final.RestoreStatus) +} diff --git a/frontend/src/api/admin/backup.ts b/frontend/src/api/admin/backup.ts index d349c862fa..bccb1f8023 100644 --- a/frontend/src/api/admin/backup.ts +++ b/frontend/src/api/admin/backup.ts @@ -29,6 +29,10 @@ export interface BackupRecord { started_at: string finished_at?: string expires_at?: string + progress?: string + restore_status?: string + restore_error?: string + restored_at?: string } export interface CreateBackupRequest { @@ -69,7 +73,7 @@ export async function updateSchedule(config: BackupScheduleConfig): Promise { - const { data } = await apiClient.post('/admin/backups', req || {}, { timeout: 600000 }) + const { data } = await apiClient.post('/admin/backups', req || {}) return data } @@ -93,8 +97,9 @@ export async function getDownloadURL(id: string): Promise<{ url: string }> { } // Restore -export async function restoreBackup(id: string, password: string): Promise { - await apiClient.post(`/admin/backups/${id}/restore`, { password }, { timeout: 600000 }) +export async function restoreBackup(id: string, password: string): Promise { + const { data } = await apiClient.post(`/admin/backups/${id}/restore`, { password }) + return data } export const backupAPI = { diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 6484a27a28..eeb5434f40 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1025,7 +1025,12 @@ export default { createBackup: 'Create Backup', backing: 'Backing up...', backupCreated: 'Backup created successfully', - expireDays: 'Expire Days' + expireDays: 'Expire Days', + alreadyInProgress: 'A backup is already in progress', + backupRunning: 'Backup in progress...', + backupFailed: 'Backup failed', + restoreRunning: 'Restore in progress...', + restoreFailed: 'Restore failed', }, columns: { status: 'Status', @@ -1042,6 +1047,11 @@ export default { completed: 'Completed', failed: 'Failed' }, + progress: { + pending: 'Preparing', + dumping: 'Dumping database', + uploading: 'Uploading', + }, trigger: { manual: 'Manual', scheduled: 'Scheduled' diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 2f3eb2903f..0720a0eabb 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -1047,7 +1047,12 @@ export default { createBackup: '创建备份', backing: '备份中...', backupCreated: '备份创建成功', - expireDays: '过期天数' + expireDays: '过期天数', + alreadyInProgress: '已有备份正在进行中', + backupRunning: '备份进行中...', + backupFailed: '备份失败', + restoreRunning: '恢复进行中...', + restoreFailed: '恢复失败', }, columns: { status: '状态', @@ -1064,6 +1069,11 @@ export default { completed: '已完成', failed: '失败' }, + progress: { + pending: '准备中', + dumping: '导出数据库', + uploading: '上传中', + }, trigger: { manual: '手动', scheduled: '定时' diff --git a/frontend/src/views/admin/BackupView.vue b/frontend/src/views/admin/BackupView.vue index 93da19a928..903dfe321e 100644 --- a/frontend/src/views/admin/BackupView.vue +++ b/frontend/src/views/admin/BackupView.vue @@ -139,7 +139,9 @@ class="rounded px-2 py-0.5 text-xs" :class="statusClass(record.status)" > - {{ t(`admin.backup.status.${record.status}`) }} + {{ record.status === 'running' && record.progress + ? t(`admin.backup.progress.${record.progress}`) + : t(`admin.backup.status.${record.status}`) }} {{ record.file_name }} @@ -277,7 +279,7 @@ From a0b76bd6081ac5cecd50cb43a41e196c7ec80eec Mon Sep 17 00:00:00 2001 From: Ethan0x0000 <3352979663@qq.com> Date: Mon, 16 Mar 2026 19:46:24 +0800 Subject: [PATCH 06/95] feat: implement last 24 hours date range preset and update filters in UsageView --- .../components/admin/usage/UsageFilters.vue | 30 +----- .../src/components/common/DateRangePicker.vue | 14 ++- .../common/__tests__/DateRangePicker.spec.ts | 96 +++++++++++++++++++ frontend/src/i18n/locales/en.ts | 1 + frontend/src/i18n/locales/zh.ts | 1 + frontend/src/views/admin/UsageView.vue | 62 ++++++++++-- .../views/admin/__tests__/UsageView.spec.ts | 22 +++++ 7 files changed, 190 insertions(+), 36 deletions(-) create mode 100644 frontend/src/components/common/__tests__/DateRangePicker.spec.ts diff --git a/frontend/src/components/admin/usage/UsageFilters.vue b/frontend/src/components/admin/usage/UsageFilters.vue index a632a76ed0..ee5020e796 100644 --- a/frontend/src/components/admin/usage/UsageFilters.vue +++ b/frontend/src/components/admin/usage/UsageFilters.vue @@ -139,17 +139,6 @@ +
+
+ {{ t('admin.dashboard.timeRange') }}: + +
+
+ {{ t('admin.dashboard.granularity') }}: +
+ +
@@ -813,6 +821,7 @@ let userSearchTimeout: ReturnType | null = null const filters = reactive({ status: 'active', group_id: '', + platform: '', user_id: null as number | null }) @@ -855,6 +864,15 @@ const groupOptions = computed(() => [ ...groups.value.map((g) => ({ value: g.id.toString(), label: g.name })) ]) +const platformFilterOptions = computed(() => [ + { value: '', label: t('admin.subscriptions.allPlatforms') }, + { value: 'anthropic', label: 'Anthropic' }, + { value: 'openai', label: 'OpenAI' }, + { value: 'gemini', label: 'Gemini' }, + { value: 'antigravity', label: 'Antigravity' }, + { value: 'sora', label: 'Sora' } +]) + // Group options for assign (only subscription type groups) const subscriptionGroupOptions = computed(() => groups.value @@ -890,6 +908,7 @@ const loadSubscriptions = async () => { { status: (filters.status as any) || undefined, group_id: filters.group_id ? parseInt(filters.group_id) : undefined, + platform: filters.platform || undefined, user_id: filters.user_id || undefined, sort_by: sortState.sort_by, sort_order: sortState.sort_order From 13e85b3147665865c61bb50138da473388652063 Mon Sep 17 00:00:00 2001 From: Gemini Wen Date: Wed, 18 Mar 2026 09:35:08 +0800 Subject: [PATCH 44/95] fix: update remaining test stubs for List interface signature Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/internal/server/api_contract_test.go | 2 +- backend/internal/server/middleware/api_key_auth_google_test.go | 2 +- backend/internal/server/middleware/api_key_auth_test.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 309dcf4e0e..6fe361cfd3 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -1289,7 +1289,7 @@ func (r *stubUserSubscriptionRepo) ListActiveByUserID(ctx context.Context, userI func (stubUserSubscriptionRepo) ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]service.UserSubscription, *pagination.PaginationResult, error) { return nil, nil, errors.New("not implemented") } -func (stubUserSubscriptionRepo) List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status, sortBy, sortOrder string) ([]service.UserSubscription, *pagination.PaginationResult, error) { +func (stubUserSubscriptionRepo) List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status, platform, sortBy, sortOrder string) ([]service.UserSubscription, *pagination.PaginationResult, error) { return nil, nil, errors.New("not implemented") } func (stubUserSubscriptionRepo) ExistsByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (bool, error) { diff --git a/backend/internal/server/middleware/api_key_auth_google_test.go b/backend/internal/server/middleware/api_key_auth_google_test.go index 49db5f1998..9f9bba1349 100644 --- a/backend/internal/server/middleware/api_key_auth_google_test.go +++ b/backend/internal/server/middleware/api_key_auth_google_test.go @@ -135,7 +135,7 @@ func (f fakeGoogleSubscriptionRepo) ListActiveByUserID(ctx context.Context, user func (f fakeGoogleSubscriptionRepo) ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]service.UserSubscription, *pagination.PaginationResult, error) { return nil, nil, errors.New("not implemented") } -func (f fakeGoogleSubscriptionRepo) List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status, sortBy, sortOrder string) ([]service.UserSubscription, *pagination.PaginationResult, error) { +func (f fakeGoogleSubscriptionRepo) List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status, platform, sortBy, sortOrder string) ([]service.UserSubscription, *pagination.PaginationResult, error) { return nil, nil, errors.New("not implemented") } func (f fakeGoogleSubscriptionRepo) ExistsByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (bool, error) { diff --git a/backend/internal/server/middleware/api_key_auth_test.go b/backend/internal/server/middleware/api_key_auth_test.go index 22befa2afb..a633ffdd38 100644 --- a/backend/internal/server/middleware/api_key_auth_test.go +++ b/backend/internal/server/middleware/api_key_auth_test.go @@ -646,7 +646,7 @@ func (r *stubUserSubscriptionRepo) ListByGroupID(ctx context.Context, groupID in return nil, nil, errors.New("not implemented") } -func (r *stubUserSubscriptionRepo) List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status, sortBy, sortOrder string) ([]service.UserSubscription, *pagination.PaginationResult, error) { +func (r *stubUserSubscriptionRepo) List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status, platform, sortBy, sortOrder string) ([]service.UserSubscription, *pagination.PaginationResult, error) { return nil, nil, errors.New("not implemented") } From 961c30e7c04d80163b80725a82f9631e375b505b Mon Sep 17 00:00:00 2001 From: QTom Date: Tue, 17 Mar 2026 22:09:28 +0800 Subject: [PATCH 45/95] =?UTF-8?q?feat(admin):=20=E5=88=86=E7=BB=84?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=88=97=E8=A1=A8=E6=96=B0=E5=A2=9E=E7=94=A8?= =?UTF-8?q?=E9=87=8F=E5=88=97=E4=B8=8E=E8=B4=A6=E5=8F=B7=E6=95=B0=E5=88=86?= =?UTF-8?q?=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 分组管理列表增强: 1. 今日/累计用量列: - 新增独立端点 GET /admin/groups/usage-summary - 一次查询返回所有分组的今日费用和累计费用(actual_cost) - 前端异步加载后合并显示在分组列表中 2. 账号数区分可用/限流/总量: - 将账号数列从单一总量改为 badge 内多行展示 - 可用: active + schedulable 的账号数(绿色) - 限流: rate_limit/overload/temp_unschedulable 的账号数(橙色,无限流时隐藏) - 总量: 全部关联账号数 Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/cmd/server/wire_gen.go | 2 +- .../admin/admin_basic_handlers_test.go | 4 +- .../internal/handler/admin/group_handler.go | 25 ++++++- backend/internal/handler/dto/mappers.go | 4 +- backend/internal/handler/dto/types.go | 4 +- ...eway_handler_warmup_intercept_unit_test.go | 2 +- .../handler/sora_gateway_handler_test.go | 4 +- .../pkg/usagestats/usage_log_types.go | 7 ++ backend/internal/repository/group_repo.go | 69 ++++++++++++++----- .../repository/group_repo_integration_test.go | 8 +-- backend/internal/repository/usage_log_repo.go | 38 ++++++++++ backend/internal/server/routes/admin.go | 1 + .../internal/service/account_usage_service.go | 1 + .../service/admin_service_apikey_test.go | 2 +- .../service/admin_service_delete_test.go | 2 +- .../service/admin_service_group_test.go | 6 +- backend/internal/service/dashboard_service.go | 9 +++ .../service/gateway_multiplatform_test.go | 4 +- .../service/gemini_multiplatform_test.go | 4 +- backend/internal/service/group.go | 6 +- backend/internal/service/group_service.go | 4 +- .../service/sora_quota_service_test.go | 4 +- .../subscription_assign_idempotency_test.go | 2 +- frontend/src/api/admin/groups.ts | 19 ++++- frontend/src/i18n/locales/en.ts | 7 ++ frontend/src/i18n/locales/zh.ts | 7 ++ frontend/src/types/index.ts | 2 + frontend/src/views/admin/GroupsView.vue | 65 +++++++++++++++-- 28 files changed, 257 insertions(+), 55 deletions(-) diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index f632bff395..a245167242 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -110,7 +110,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { concurrencyCache := repository.ProvideConcurrencyCache(redisClient, configConfig) concurrencyService := service.ProvideConcurrencyService(concurrencyCache, accountRepository, configConfig) adminUserHandler := admin.NewUserHandler(adminService, concurrencyService) - groupHandler := admin.NewGroupHandler(adminService) + groupHandler := admin.NewGroupHandler(adminService, dashboardService) claudeOAuthClient := repository.NewClaudeOAuthClient() oAuthService := service.NewOAuthService(proxyRepository, claudeOAuthClient) openAIOAuthClient := repository.NewOpenAIOAuthClient() diff --git a/backend/internal/handler/admin/admin_basic_handlers_test.go b/backend/internal/handler/admin/admin_basic_handlers_test.go index 4de10d3e22..4b5f1bfbe9 100644 --- a/backend/internal/handler/admin/admin_basic_handlers_test.go +++ b/backend/internal/handler/admin/admin_basic_handlers_test.go @@ -17,8 +17,8 @@ func setupAdminRouter() (*gin.Engine, *stubAdminService) { adminSvc := newStubAdminService() userHandler := NewUserHandler(adminSvc, nil) - groupHandler := NewGroupHandler(adminSvc) - proxyHandler := NewProxyHandler(adminSvc) + groupHandler := NewGroupHandler(adminSvc, nil) + proxyHandler := NewProxyHandler(adminSvc, nil, nil) redeemHandler := NewRedeemHandler(adminSvc, nil) router.GET("/api/v1/admin/users", userHandler.List) diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 4ffe64ee07..abdba78c46 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -9,6 +9,7 @@ import ( "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" @@ -16,7 +17,8 @@ import ( // GroupHandler handles admin group management type GroupHandler struct { - adminService service.AdminService + adminService service.AdminService + dashboardService *service.DashboardService } type optionalLimitField struct { @@ -69,9 +71,10 @@ func (f optionalLimitField) ToServiceInput() *float64 { } // NewGroupHandler creates a new admin group handler -func NewGroupHandler(adminService service.AdminService) *GroupHandler { +func NewGroupHandler(adminService service.AdminService, dashboardService *service.DashboardService) *GroupHandler { return &GroupHandler{ - adminService: adminService, + adminService: adminService, + dashboardService: dashboardService, } } @@ -363,6 +366,22 @@ func (h *GroupHandler) GetStats(c *gin.Context) { _ = groupID // TODO: implement actual stats } +// GetUsageSummary returns today's and cumulative cost for all groups. +// GET /api/v1/admin/groups/usage-summary?timezone=Asia/Shanghai +func (h *GroupHandler) GetUsageSummary(c *gin.Context) { + userTZ := c.Query("timezone") + now := timezone.NowInUserLocation(userTZ) + todayStart := timezone.StartOfDayInUserLocation(now, userTZ) + + results, err := h.dashboardService.GetGroupUsageSummary(c.Request.Context(), todayStart) + if err != nil { + response.Error(c, 500, "Failed to get group usage summary") + return + } + + response.Success(c, results) +} + // GetGroupAPIKeys handles getting API keys in a group // GET /api/v1/admin/groups/:id/api-keys func (h *GroupHandler) GetGroupAPIKeys(c *gin.Context) { diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 8e5f23e7d1..bb6bb59477 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -141,7 +141,9 @@ func GroupFromServiceAdmin(g *service.Group) *AdminGroup { MCPXMLInject: g.MCPXMLInject, DefaultMappedModel: g.DefaultMappedModel, SupportedModelScopes: g.SupportedModelScopes, - AccountCount: g.AccountCount, + AccountCount: g.AccountCount, + ActiveAccountCount: g.ActiveAccountCount, + RateLimitedAccountCount: g.RateLimitedAccountCount, SortOrder: g.SortOrder, } if len(g.AccountGroups) > 0 { diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index c52e357e6a..3ee3ac2900 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -124,7 +124,9 @@ type AdminGroup struct { // 支持的模型系列(仅 antigravity 平台使用) SupportedModelScopes []string `json:"supported_model_scopes"` AccountGroups []AccountGroup `json:"account_groups,omitempty"` - AccountCount int64 `json:"account_count,omitempty"` + AccountCount int64 `json:"account_count,omitempty"` + ActiveAccountCount int64 `json:"active_account_count,omitempty"` + RateLimitedAccountCount int64 `json:"rate_limited_account_count,omitempty"` // 分组排序 SortOrder int `json:"sort_order"` diff --git a/backend/internal/handler/gateway_handler_warmup_intercept_unit_test.go b/backend/internal/handler/gateway_handler_warmup_intercept_unit_test.go index 6bcc0003e8..b9dbe0ce1e 100644 --- a/backend/internal/handler/gateway_handler_warmup_intercept_unit_test.go +++ b/backend/internal/handler/gateway_handler_warmup_intercept_unit_test.go @@ -76,7 +76,7 @@ func (f *fakeGroupRepo) ListActiveByPlatform(context.Context, string) ([]service return nil, nil } func (f *fakeGroupRepo) ExistsByName(context.Context, string) (bool, error) { return false, nil } -func (f *fakeGroupRepo) GetAccountCount(context.Context, int64) (int64, error) { return 0, nil } +func (f *fakeGroupRepo) GetAccountCount(context.Context, int64) (int64, int64, error) { return 0, 0, nil } func (f *fakeGroupRepo) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) { return 0, nil } diff --git a/backend/internal/handler/sora_gateway_handler_test.go b/backend/internal/handler/sora_gateway_handler_test.go index 06b094374a..8066a65646 100644 --- a/backend/internal/handler/sora_gateway_handler_test.go +++ b/backend/internal/handler/sora_gateway_handler_test.go @@ -273,8 +273,8 @@ func (r *stubGroupRepo) ListActiveByPlatform(ctx context.Context, platform strin func (r *stubGroupRepo) ExistsByName(ctx context.Context, name string) (bool, error) { return false, nil } -func (r *stubGroupRepo) GetAccountCount(ctx context.Context, groupID int64) (int64, error) { - return 0, nil +func (r *stubGroupRepo) GetAccountCount(ctx context.Context, groupID int64) (int64, int64, error) { + return 0, 0, nil } func (r *stubGroupRepo) DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error) { return 0, nil diff --git a/backend/internal/pkg/usagestats/usage_log_types.go b/backend/internal/pkg/usagestats/usage_log_types.go index f42a746fc9..91b636386c 100644 --- a/backend/internal/pkg/usagestats/usage_log_types.go +++ b/backend/internal/pkg/usagestats/usage_log_types.go @@ -90,6 +90,13 @@ type EndpointStat struct { ActualCost float64 `json:"actual_cost"` // 实际扣除 } +// GroupUsageSummary represents today's and cumulative cost for a single group. +type GroupUsageSummary struct { + GroupID int64 `json:"group_id"` + TodayCost float64 `json:"today_cost"` + TotalCost float64 `json:"total_cost"` +} + // GroupStat represents usage statistics for a single group type GroupStat struct { GroupID int64 `json:"group_id"` diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go index c195f1f1e6..674c655b80 100644 --- a/backend/internal/repository/group_repo.go +++ b/backend/internal/repository/group_repo.go @@ -88,8 +88,9 @@ func (r *groupRepository) GetByID(ctx context.Context, id int64) (*service.Group if err != nil { return nil, err } - count, _ := r.GetAccountCount(ctx, out.ID) - out.AccountCount = count + total, active, _ := r.GetAccountCount(ctx, out.ID) + out.AccountCount = total + out.ActiveAccountCount = active return out, nil } @@ -256,7 +257,10 @@ func (r *groupRepository) ListWithFilters(ctx context.Context, params pagination counts, err := r.loadAccountCounts(ctx, groupIDs) if err == nil { for i := range outGroups { - outGroups[i].AccountCount = counts[outGroups[i].ID] + c := counts[outGroups[i].ID] + outGroups[i].AccountCount = c.Total + outGroups[i].ActiveAccountCount = c.Active + outGroups[i].RateLimitedAccountCount = c.RateLimited } } @@ -283,7 +287,10 @@ func (r *groupRepository) ListActive(ctx context.Context) ([]service.Group, erro counts, err := r.loadAccountCounts(ctx, groupIDs) if err == nil { for i := range outGroups { - outGroups[i].AccountCount = counts[outGroups[i].ID] + c := counts[outGroups[i].ID] + outGroups[i].AccountCount = c.Total + outGroups[i].ActiveAccountCount = c.Active + outGroups[i].RateLimitedAccountCount = c.RateLimited } } @@ -310,7 +317,10 @@ func (r *groupRepository) ListActiveByPlatform(ctx context.Context, platform str counts, err := r.loadAccountCounts(ctx, groupIDs) if err == nil { for i := range outGroups { - outGroups[i].AccountCount = counts[outGroups[i].ID] + c := counts[outGroups[i].ID] + outGroups[i].AccountCount = c.Total + outGroups[i].ActiveAccountCount = c.Active + outGroups[i].RateLimitedAccountCount = c.RateLimited } } @@ -369,12 +379,20 @@ func (r *groupRepository) ExistsByIDs(ctx context.Context, ids []int64) (map[int return result, nil } -func (r *groupRepository) GetAccountCount(ctx context.Context, groupID int64) (int64, error) { - var count int64 - if err := scanSingleRow(ctx, r.sql, "SELECT COUNT(*) FROM account_groups WHERE group_id = $1", []any{groupID}, &count); err != nil { - return 0, err - } - return count, nil +func (r *groupRepository) GetAccountCount(ctx context.Context, groupID int64) (total int64, active int64, err error) { + var rateLimited int64 + err = scanSingleRow(ctx, r.sql, + `SELECT COUNT(*), + COUNT(*) FILTER (WHERE a.status = 'active' AND a.schedulable = true), + COUNT(*) FILTER (WHERE a.status = 'active' AND ( + a.rate_limit_reset_at > NOW() OR + a.overload_until > NOW() OR + a.temp_unschedulable_until > NOW() + )) + FROM account_groups ag JOIN accounts a ON a.id = ag.account_id + WHERE ag.group_id = $1`, + []any{groupID}, &total, &active, &rateLimited) + return } func (r *groupRepository) DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error) { @@ -500,15 +518,32 @@ func (r *groupRepository) DeleteCascade(ctx context.Context, id int64) ([]int64, return affectedUserIDs, nil } -func (r *groupRepository) loadAccountCounts(ctx context.Context, groupIDs []int64) (counts map[int64]int64, err error) { - counts = make(map[int64]int64, len(groupIDs)) +type groupAccountCounts struct { + Total int64 + Active int64 + RateLimited int64 +} + +func (r *groupRepository) loadAccountCounts(ctx context.Context, groupIDs []int64) (counts map[int64]groupAccountCounts, err error) { + counts = make(map[int64]groupAccountCounts, len(groupIDs)) if len(groupIDs) == 0 { return counts, nil } rows, err := r.sql.QueryContext( ctx, - "SELECT group_id, COUNT(*) FROM account_groups WHERE group_id = ANY($1) GROUP BY group_id", + `SELECT ag.group_id, + COUNT(*) AS total, + COUNT(*) FILTER (WHERE a.status = 'active' AND a.schedulable = true) AS active, + COUNT(*) FILTER (WHERE a.status = 'active' AND ( + a.rate_limit_reset_at > NOW() OR + a.overload_until > NOW() OR + a.temp_unschedulable_until > NOW() + )) AS rate_limited + FROM account_groups ag + JOIN accounts a ON a.id = ag.account_id + WHERE ag.group_id = ANY($1) + GROUP BY ag.group_id`, pq.Array(groupIDs), ) if err != nil { @@ -523,11 +558,11 @@ func (r *groupRepository) loadAccountCounts(ctx context.Context, groupIDs []int6 for rows.Next() { var groupID int64 - var count int64 - if err = rows.Scan(&groupID, &count); err != nil { + var c groupAccountCounts + if err = rows.Scan(&groupID, &c.Total, &c.Active, &c.RateLimited); err != nil { return nil, err } - counts[groupID] = count + counts[groupID] = c } if err = rows.Err(); err != nil { return nil, err diff --git a/backend/internal/repository/group_repo_integration_test.go b/backend/internal/repository/group_repo_integration_test.go index 4a849a4602..eccf5ceacb 100644 --- a/backend/internal/repository/group_repo_integration_test.go +++ b/backend/internal/repository/group_repo_integration_test.go @@ -603,7 +603,7 @@ func (s *GroupRepoSuite) TestGetAccountCount() { _, err = s.tx.ExecContext(s.ctx, "INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, $3, NOW())", a2, group.ID, 2) s.Require().NoError(err) - count, err := s.repo.GetAccountCount(s.ctx, group.ID) + count, _, err := s.repo.GetAccountCount(s.ctx, group.ID) s.Require().NoError(err, "GetAccountCount") s.Require().Equal(int64(2), count) } @@ -619,7 +619,7 @@ func (s *GroupRepoSuite) TestGetAccountCount_Empty() { } s.Require().NoError(s.repo.Create(s.ctx, group)) - count, err := s.repo.GetAccountCount(s.ctx, group.ID) + count, _, err := s.repo.GetAccountCount(s.ctx, group.ID) s.Require().NoError(err) s.Require().Zero(count) } @@ -651,7 +651,7 @@ func (s *GroupRepoSuite) TestDeleteAccountGroupsByGroupID() { s.Require().NoError(err, "DeleteAccountGroupsByGroupID") s.Require().Equal(int64(1), affected, "expected 1 affected row") - count, err := s.repo.GetAccountCount(s.ctx, g.ID) + count, _, err := s.repo.GetAccountCount(s.ctx, g.ID) s.Require().NoError(err, "GetAccountCount") s.Require().Equal(int64(0), count, "expected 0 account groups") } @@ -692,7 +692,7 @@ func (s *GroupRepoSuite) TestDeleteAccountGroupsByGroupID_MultipleAccounts() { s.Require().NoError(err) s.Require().Equal(int64(3), affected) - count, _ := s.repo.GetAccountCount(s.ctx, g.ID) + count, _, _ := s.repo.GetAccountCount(s.ctx, g.ID) s.Require().Zero(count) } diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index dcdaeaee84..ca77bb9088 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -3000,6 +3000,7 @@ func (r *usageLogRepository) GetGroupStatsWithFilters(ctx context.Context, start return results, nil } +<<<<<<< HEAD // GetUserBreakdownStats returns per-user usage breakdown within a specific dimension. func (r *usageLogRepository) GetUserBreakdownStats(ctx context.Context, startTime, endTime time.Time, dim usagestats.UserBreakdownDimension, limit int) (results []usagestats.UserBreakdownItem, err error) { query := ` @@ -3067,6 +3068,43 @@ func (r *usageLogRepository) GetUserBreakdownStats(ctx context.Context, startTim return results, nil } +// GetAllGroupUsageSummary returns today's and cumulative actual_cost for every group. +// todayStart is the start-of-day in the caller's timezone (UTC-based). +// TODO(perf): This query scans ALL usage_logs rows for total_cost aggregation. +// When usage_logs exceeds ~1M rows, consider adding a short-lived cache (30s) +// or a materialized view / pre-aggregation table for cumulative costs. +func (r *usageLogRepository) GetAllGroupUsageSummary(ctx context.Context, todayStart time.Time) ([]usagestats.GroupUsageSummary, error) { + query := ` + SELECT + g.id AS group_id, + COALESCE(SUM(ul.actual_cost), 0) AS total_cost, + COALESCE(SUM(CASE WHEN ul.created_at >= $1 THEN ul.actual_cost ELSE 0 END), 0) AS today_cost + FROM groups g + LEFT JOIN usage_logs ul ON ul.group_id = g.id + GROUP BY g.id + ` + + rows, err := r.sql.QueryContext(ctx, query, todayStart) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []usagestats.GroupUsageSummary + for rows.Next() { + var row usagestats.GroupUsageSummary + if err := rows.Scan(&row.GroupID, &row.TotalCost, &row.TodayCost); err != nil { +>>>>>>> c8c1b4d4 (feat(admin): 分组管理列表新增用量列与账号数分类) + return nil, err + } + results = append(results, row) + } + if err := rows.Err(); err != nil { + return nil, err + } + return results, nil +} + // resolveEndpointColumn maps endpoint type to the corresponding DB column name. func resolveEndpointColumn(endpointType string) string { switch endpointType { diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 67d7cb45b3..5f41cfaddd 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -227,6 +227,7 @@ func registerGroupRoutes(admin *gin.RouterGroup, h *handler.Handlers) { { groups.GET("", h.Admin.Group.List) groups.GET("/all", h.Admin.Group.GetAll) + groups.GET("/usage-summary", h.Admin.Group.GetUsageSummary) groups.PUT("/sort-order", h.Admin.Group.UpdateSortOrder) groups.GET("/:id", h.Admin.Group.GetByID) groups.POST("", h.Admin.Group.Create) diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index 4a05c64a1e..7414270022 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -49,6 +49,7 @@ type UsageLogRepository interface { GetUpstreamEndpointStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) ([]usagestats.EndpointStat, error) GetGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) ([]usagestats.GroupStat, error) GetUserBreakdownStats(ctx context.Context, startTime, endTime time.Time, dim usagestats.UserBreakdownDimension, limit int) ([]usagestats.UserBreakdownItem, error) + GetAllGroupUsageSummary(ctx context.Context, todayStart time.Time) ([]usagestats.GroupUsageSummary, error) GetAPIKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.APIKeyUsageTrendPoint, error) GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.UserUsageTrendPoint, error) GetUserSpendingRanking(ctx context.Context, startTime, endTime time.Time, limit int) (*usagestats.UserSpendingRankingResponse, error) diff --git a/backend/internal/service/admin_service_apikey_test.go b/backend/internal/service/admin_service_apikey_test.go index 88d2f492e0..7588c16d12 100644 --- a/backend/internal/service/admin_service_apikey_test.go +++ b/backend/internal/service/admin_service_apikey_test.go @@ -194,7 +194,7 @@ func (s *groupRepoStubForGroupUpdate) ListActiveByPlatform(context.Context, stri func (s *groupRepoStubForGroupUpdate) ExistsByName(context.Context, string) (bool, error) { panic("unexpected") } -func (s *groupRepoStubForGroupUpdate) GetAccountCount(context.Context, int64) (int64, error) { +func (s *groupRepoStubForGroupUpdate) GetAccountCount(context.Context, int64) (int64, int64, error) { panic("unexpected") } func (s *groupRepoStubForGroupUpdate) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) { diff --git a/backend/internal/service/admin_service_delete_test.go b/backend/internal/service/admin_service_delete_test.go index 2e0f7d9070..662b477153 100644 --- a/backend/internal/service/admin_service_delete_test.go +++ b/backend/internal/service/admin_service_delete_test.go @@ -160,7 +160,7 @@ func (s *groupRepoStub) ExistsByName(ctx context.Context, name string) (bool, er panic("unexpected ExistsByName call") } -func (s *groupRepoStub) GetAccountCount(ctx context.Context, groupID int64) (int64, error) { +func (s *groupRepoStub) GetAccountCount(ctx context.Context, groupID int64) (int64, int64, error) { panic("unexpected GetAccountCount call") } diff --git a/backend/internal/service/admin_service_group_test.go b/backend/internal/service/admin_service_group_test.go index ef77a98059..536be0b583 100644 --- a/backend/internal/service/admin_service_group_test.go +++ b/backend/internal/service/admin_service_group_test.go @@ -100,7 +100,7 @@ func (s *groupRepoStubForAdmin) ExistsByName(_ context.Context, _ string) (bool, panic("unexpected ExistsByName call") } -func (s *groupRepoStubForAdmin) GetAccountCount(_ context.Context, _ int64) (int64, error) { +func (s *groupRepoStubForAdmin) GetAccountCount(_ context.Context, _ int64) (int64, int64, error) { panic("unexpected GetAccountCount call") } @@ -383,7 +383,7 @@ func (s *groupRepoStubForFallbackCycle) ExistsByName(_ context.Context, _ string panic("unexpected ExistsByName call") } -func (s *groupRepoStubForFallbackCycle) GetAccountCount(_ context.Context, _ int64) (int64, error) { +func (s *groupRepoStubForFallbackCycle) GetAccountCount(_ context.Context, _ int64) (int64, int64, error) { panic("unexpected GetAccountCount call") } @@ -458,7 +458,7 @@ func (s *groupRepoStubForInvalidRequestFallback) ExistsByName(_ context.Context, panic("unexpected ExistsByName call") } -func (s *groupRepoStubForInvalidRequestFallback) GetAccountCount(_ context.Context, _ int64) (int64, error) { +func (s *groupRepoStubForInvalidRequestFallback) GetAccountCount(_ context.Context, _ int64) (int64, int64, error) { panic("unexpected GetAccountCount call") } diff --git a/backend/internal/service/dashboard_service.go b/backend/internal/service/dashboard_service.go index ad29990f8f..c04e142840 100644 --- a/backend/internal/service/dashboard_service.go +++ b/backend/internal/service/dashboard_service.go @@ -148,6 +148,15 @@ func (s *DashboardService) GetGroupStatsWithFilters(ctx context.Context, startTi return stats, nil } +// GetGroupUsageSummary returns today's and cumulative cost for all groups. +func (s *DashboardService) GetGroupUsageSummary(ctx context.Context, todayStart time.Time) ([]usagestats.GroupUsageSummary, error) { + results, err := s.usageRepo.GetAllGroupUsageSummary(ctx, todayStart) + if err != nil { + return nil, fmt.Errorf("get group usage summary: %w", err) + } + return results, nil +} + func (s *DashboardService) getCachedDashboardStats(ctx context.Context) (*usagestats.DashboardStats, bool, error) { data, err := s.cache.GetDashboardStats(ctx) if err != nil { diff --git a/backend/internal/service/gateway_multiplatform_test.go b/backend/internal/service/gateway_multiplatform_test.go index ea8fa78464..718cd42ade 100644 --- a/backend/internal/service/gateway_multiplatform_test.go +++ b/backend/internal/service/gateway_multiplatform_test.go @@ -278,8 +278,8 @@ func (m *mockGroupRepoForGateway) ListActiveByPlatform(ctx context.Context, plat func (m *mockGroupRepoForGateway) ExistsByName(ctx context.Context, name string) (bool, error) { return false, nil } -func (m *mockGroupRepoForGateway) GetAccountCount(ctx context.Context, groupID int64) (int64, error) { - return 0, nil +func (m *mockGroupRepoForGateway) GetAccountCount(ctx context.Context, groupID int64) (int64, int64, error) { + return 0, 0, nil } func (m *mockGroupRepoForGateway) DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error) { return 0, nil diff --git a/backend/internal/service/gemini_multiplatform_test.go b/backend/internal/service/gemini_multiplatform_test.go index b0b804eb00..a78c56e768 100644 --- a/backend/internal/service/gemini_multiplatform_test.go +++ b/backend/internal/service/gemini_multiplatform_test.go @@ -230,8 +230,8 @@ func (m *mockGroupRepoForGemini) ListActiveByPlatform(ctx context.Context, platf func (m *mockGroupRepoForGemini) ExistsByName(ctx context.Context, name string) (bool, error) { return false, nil } -func (m *mockGroupRepoForGemini) GetAccountCount(ctx context.Context, groupID int64) (int64, error) { - return 0, nil +func (m *mockGroupRepoForGemini) GetAccountCount(ctx context.Context, groupID int64) (int64, int64, error) { + return 0, 0, nil } func (m *mockGroupRepoForGemini) DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error) { return 0, nil diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index 537b5a3b1b..e17032e000 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -64,8 +64,10 @@ type Group struct { CreatedAt time.Time UpdatedAt time.Time - AccountGroups []AccountGroup - AccountCount int64 + AccountGroups []AccountGroup + AccountCount int64 + ActiveAccountCount int64 + RateLimitedAccountCount int64 } func (g *Group) IsActive() bool { diff --git a/backend/internal/service/group_service.go b/backend/internal/service/group_service.go index 22a67edac8..87174e0376 100644 --- a/backend/internal/service/group_service.go +++ b/backend/internal/service/group_service.go @@ -27,7 +27,7 @@ type GroupRepository interface { ListActiveByPlatform(ctx context.Context, platform string) ([]Group, error) ExistsByName(ctx context.Context, name string) (bool, error) - GetAccountCount(ctx context.Context, groupID int64) (int64, error) + GetAccountCount(ctx context.Context, groupID int64) (total int64, active int64, err error) DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error) // GetAccountIDsByGroupIDs 获取多个分组的所有账号 ID(去重) GetAccountIDsByGroupIDs(ctx context.Context, groupIDs []int64) ([]int64, error) @@ -202,7 +202,7 @@ func (s *GroupService) GetStats(ctx context.Context, id int64) (map[string]any, } // 获取账号数量 - accountCount, err := s.groupRepo.GetAccountCount(ctx, id) + accountCount, _, err := s.groupRepo.GetAccountCount(ctx, id) if err != nil { return nil, fmt.Errorf("get account count: %w", err) } diff --git a/backend/internal/service/sora_quota_service_test.go b/backend/internal/service/sora_quota_service_test.go index 040e427d8f..da8efe77e9 100644 --- a/backend/internal/service/sora_quota_service_test.go +++ b/backend/internal/service/sora_quota_service_test.go @@ -52,8 +52,8 @@ func (r *stubGroupRepoForQuota) ListActiveByPlatform(context.Context, string) ([ func (r *stubGroupRepoForQuota) ExistsByName(context.Context, string) (bool, error) { return false, nil } -func (r *stubGroupRepoForQuota) GetAccountCount(context.Context, int64) (int64, error) { - return 0, nil +func (r *stubGroupRepoForQuota) GetAccountCount(context.Context, int64) (int64, int64, error) { + return 0, 0, nil } func (r *stubGroupRepoForQuota) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) { return 0, nil diff --git a/backend/internal/service/subscription_assign_idempotency_test.go b/backend/internal/service/subscription_assign_idempotency_test.go index 0defafba95..619bebf4d0 100644 --- a/backend/internal/service/subscription_assign_idempotency_test.go +++ b/backend/internal/service/subscription_assign_idempotency_test.go @@ -40,7 +40,7 @@ func (groupRepoNoop) ListActiveByPlatform(context.Context, string) ([]Group, err func (groupRepoNoop) ExistsByName(context.Context, string) (bool, error) { panic("unexpected ExistsByName call") } -func (groupRepoNoop) GetAccountCount(context.Context, int64) (int64, error) { +func (groupRepoNoop) GetAccountCount(context.Context, int64) (int64, int64, error) { panic("unexpected GetAccountCount call") } func (groupRepoNoop) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) { diff --git a/frontend/src/api/admin/groups.ts b/frontend/src/api/admin/groups.ts index 7c2658fa45..5c63ea95ce 100644 --- a/frontend/src/api/admin/groups.ts +++ b/frontend/src/api/admin/groups.ts @@ -218,6 +218,22 @@ export async function batchSetGroupRateMultipliers( return data } +/** + * Get usage summary (today + cumulative cost) for all groups + * @param timezone - IANA timezone string (e.g. "Asia/Shanghai") + * @returns Array of group usage summaries + */ +export async function getUsageSummary( + timezone?: string +): Promise<{ group_id: number; today_cost: number; total_cost: number }[]> { + const { data } = await apiClient.get< + { group_id: number; today_cost: number; total_cost: number }[] + >('/admin/groups/usage-summary', { + params: timezone ? { timezone } : undefined + }) + return data +} + export const groupsAPI = { list, getAll, @@ -232,7 +248,8 @@ export const groupsAPI = { getGroupRateMultipliers, clearGroupRateMultipliers, batchSetGroupRateMultipliers, - updateSortOrder + updateSortOrder, + getUsageSummary } export default groupsAPI diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 26edcfe9fe..b7a301b271 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1505,6 +1505,7 @@ export default { rateMultiplier: 'Rate Multiplier', type: 'Type', accounts: 'Accounts', + usage: 'Usage', status: 'Status', actions: 'Actions', billingType: 'Billing Type', @@ -1513,6 +1514,12 @@ export default { userNotes: 'Notes', userStatus: 'Status' }, + usageToday: 'Today', + usageTotal: 'Total', + accountsAvailable: 'Avail:', + accountsRateLimited: 'Limited:', + accountsTotal: 'Total:', + accountsUnit: '', rateAndAccounts: '{rate}x rate · {count} accounts', accountsCount: '{count} accounts', form: { diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 39c900ca24..324c5f8eec 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -1561,6 +1561,7 @@ export default { priority: '优先级', apiKeys: 'API 密钥数', accounts: '账号数', + usage: '用量', status: '状态', actions: '操作', billingType: '计费类型', @@ -1569,6 +1570,12 @@ export default { userNotes: '备注', userStatus: '状态' }, + usageToday: '今日', + usageTotal: '累计', + accountsAvailable: '可用:', + accountsRateLimited: '限流:', + accountsTotal: '总量:', + accountsUnit: '个账号', form: { name: '名称', description: '描述', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index dffc0d20e2..70e6dc42c1 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -411,6 +411,8 @@ export interface AdminGroup extends Group { // 分组下账号数量(仅管理员可见) account_count?: number + active_account_count?: number + rate_limited_account_count?: number // OpenAI Messages 调度配置(仅 openai 平台使用) default_mapped_model?: string diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index f8ee39e96b..36e968dfd9 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -158,12 +158,38 @@ - - +