From 1380849d03124122642a2f4e0efd445b4dddb262 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Tue, 28 Jul 2026 21:39:04 +0800 Subject: [PATCH] =?UTF-8?q?chore(release):=20=E7=89=88=E6=9C=AC=E5=8F=91?= =?UTF-8?q?=E5=B8=83=20-=20publish=20v1.0.11?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 5 ++ VERSION | 2 +- backend/internal/handler/auth.go | 43 +++++++++++++++++ backend/internal/model/models.go | 2 + backend/internal/repository/analytics.go | 58 +++++++++++----------- backend/internal/service/analytics.go | 61 ++++++++++++++++++++++++ backend/internal/service/resource.go | 4 ++ web/src/pages/admin/logs/logs-page.tsx | 30 ++++++++++-- web/src/services/api/auth.ts | 2 + 9 files changed, 176 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 978be42fa..3ce741986 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +## v1.0.11 + +- 修复 PostgreSQL 请求明细分页继承 `DISTINCT` 后与创建时间排序冲突的问题,恢复管理后台列表读取。 +- 请求明细列表支持预览和下载图片、视频结果,私有媒体通过管理员鉴权流接口读取并支持视频 Range 请求。 + ## v1.0.10 - 运维后台请求明细按真实调用聚合视频轮询,补充用户账号、状态、错误、耗时、计费和 Token,并仅在详情中展示脱敏后的请求与响应报文。 diff --git a/VERSION b/VERSION index 62ffa11b2..7c46aa193 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.0.10 +v1.0.11 diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index aa3806674..6e4f48ac3 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "net/url" + "path/filepath" "strconv" "strings" "time" @@ -521,6 +522,48 @@ func RegisterAdminRoutes(r *gin.RouterGroup, svc *service.Service) { } ok(c, logs) }) + r.GET("/admin/api-logs/:id/media", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + stream, err := svc.OpenAdminAPICallLogMediaRange(user, c.Param("id"), c.GetHeader("Range")) + if err != nil { + failService(c, err) + return + } + defer stream.Body.Close() + resource := stream.Resource + mimeType := resource.MimeType + if mimeType == "" { + mimeType = "application/octet-stream" + } + c.Header("Cache-Control", "private, no-cache") + c.Header("Accept-Ranges", "bytes") + c.Header("X-Content-Type-Options", "nosniff") + if c.Query("download") == "1" { + extension := filepath.Ext(resource.ObjectKey) + if len(extension) > 12 { + extension = "" + } + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=api-log-media%s", extension)) + } + if resource.Provider == "local" { + if seeker, ok := stream.Body.(io.ReadSeeker); ok { + c.Header("Content-Type", mimeType) + http.ServeContent(c.Writer, c.Request, resource.ID, resource.UpdatedAt, seeker) + return + } + } + if stream.ContentRange != "" { + c.Header("Content-Range", stream.ContentRange) + } + if stream.AcceptRanges != "" { + c.Header("Accept-Ranges", stream.AcceptRanges) + } + c.DataFromReader(stream.StatusCode, stream.ContentLength, mimeType, stream.Body, nil) + }) r.GET("/admin/api-logs/:id", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go index 2249ab809..fd5cac1a5 100644 --- a/backend/internal/model/models.go +++ b/backend/internal/model/models.go @@ -253,6 +253,8 @@ type ApiCallLog struct { CachedTokens int64 `json:"cachedTokens"` UsageAvailable bool `json:"usageAvailable"` MediaCount int `json:"mediaCount"` + MediaPreviewURL string `json:"mediaPreviewUrl,omitempty" gorm:"-"` + MediaPreviewKind string `json:"mediaPreviewKind,omitempty" gorm:"-"` VideoSeconds int `json:"videoSeconds"` ProviderRequestID string `json:"providerRequestId" gorm:"size:160"` EstimatedCostMicros int64 `json:"estimatedCostMicros"` diff --git a/backend/internal/repository/analytics.go b/backend/internal/repository/analytics.go index 1f723275b..1f4ecaa54 100644 --- a/backend/internal/repository/analytics.go +++ b/backend/internal/repository/analytics.go @@ -113,6 +113,30 @@ func (r *Repository) QueryAPICallLogs(filter APICallLogFilter) ([]model.ApiCallL if filter.Limit <= 0 || filter.Limit > 200 { filter.Limit = 50 } + var total int64 + // GORM 会把 Distinct 状态保留在同一查询链;计数和分页必须分别构造,避免 PostgreSQL 的 DISTINCT + ORDER BY 冲突。 + if err := r.filteredAPICallLogQuery(filter).Model(&model.ApiCallLog{}).Distinct("api_call_logs.id").Count(&total).Error; err != nil { + return nil, 0, err + } + var logs []model.ApiCallLog + err := r.filteredAPICallLogQuery(filter).Omit("RequestBody", "ResponseBody").Order("api_call_logs.created_at desc").Offset((filter.Page - 1) * filter.Limit).Limit(filter.Limit).Find(&logs).Error + return logs, total, err +} + +func (r *Repository) ExportAPICallLogs(filter APICallLogFilter, limit int) ([]model.ApiCallLog, error) { + if limit <= 0 || limit > 10_000 { + limit = 10_000 + } + query := r.filteredAPICallLogQuery(filter) + if len(filter.IDs) > 0 { + query = query.Where("api_call_logs.id IN ?", filter.IDs) + } + var logs []model.ApiCallLog + err := query.Omit("RequestBody", "ResponseBody").Order("api_call_logs.created_at desc").Limit(limit).Find(&logs).Error + return logs, err +} + +func (r *Repository) filteredAPICallLogQuery(filter APICallLogFilter) *gorm.DB { query := visibleAPICallLogQuery(r.apiCallLogQuery(filter.AnalyticsFilter)) if value := strings.TrimSpace(filter.Keyword); value != "" { pattern := "%" + strings.ToLower(value) + "%" @@ -127,36 +151,16 @@ func (r *Repository) QueryAPICallLogs(filter APICallLogFilter) ([]model.ApiCallL if filter.Status != "" { query = query.Where("api_call_logs.status = ?", filter.Status) } - var total int64 - if err := query.Model(&model.ApiCallLog{}).Distinct("api_call_logs.id").Count(&total).Error; err != nil { - return nil, 0, err - } - var logs []model.ApiCallLog - err := query.Omit("RequestBody", "ResponseBody").Order("api_call_logs.created_at desc").Offset((filter.Page - 1) * filter.Limit).Limit(filter.Limit).Find(&logs).Error - return logs, total, err + return query } -func (r *Repository) ExportAPICallLogs(filter APICallLogFilter, limit int) ([]model.ApiCallLog, error) { - if limit <= 0 || limit > 10_000 { - limit = 10_000 +func (r *Repository) APICallLogTasks(ids []string) ([]model.Task, error) { + if len(ids) == 0 { + return []model.Task{}, nil } - query := visibleAPICallLogQuery(r.apiCallLogQuery(filter.AnalyticsFilter)) - if len(filter.IDs) > 0 { - query = query.Where("api_call_logs.id IN ?", filter.IDs) - } - if value := strings.TrimSpace(filter.Keyword); value != "" { - pattern := "%" + strings.ToLower(value) + "%" - query = query.Joins("LEFT JOIN users ON users.id = api_call_logs.user_id").Joins("LEFT JOIN model_channels ON model_channels.id = api_call_logs.channel_id").Where( - "lower(api_call_logs.user_id) LIKE ? OR lower(users.username) LIKE ? OR lower(users.display_name) LIKE ? OR lower(api_call_logs.channel_id) LIKE ? OR lower(model_channels.name) LIKE ? OR lower(api_call_logs.model) LIKE ? OR lower(api_call_logs.path) LIKE ? OR lower(api_call_logs.provider_request_id) LIKE ?", - pattern, pattern, pattern, pattern, pattern, pattern, pattern, pattern, - ) - } - if filter.Status != "" { - query = query.Where("api_call_logs.status = ?", filter.Status) - } - var logs []model.ApiCallLog - err := query.Omit("RequestBody", "ResponseBody").Order("api_call_logs.created_at desc").Limit(limit).Find(&logs).Error - return logs, err + var tasks []model.Task + err := r.db.Select("id", "user_id", "type", "result_json").Where("id IN ?", ids).Find(&tasks).Error + return tasks, err } func visibleAPICallLogQuery(query *gorm.DB) *gorm.DB { diff --git a/backend/internal/service/analytics.go b/backend/internal/service/analytics.go index ad08dd35f..d56e87b24 100644 --- a/backend/internal/service/analytics.go +++ b/backend/internal/service/analytics.go @@ -228,6 +228,26 @@ func (s *Service) decorateAPICallLogs(logs []model.ApiCallLog) error { for _, user := range users { userByID[user.ID] = user } + taskIDs := make([]string, 0, len(logs)) + seenTaskIDs := make(map[string]struct{}, len(logs)) + for _, log := range logs { + if (log.Capability != "image" && log.Capability != "video") || log.TaskID == "" { + continue + } + if _, exists := seenTaskIDs[log.TaskID]; exists { + continue + } + seenTaskIDs[log.TaskID] = struct{}{} + taskIDs = append(taskIDs, log.TaskID) + } + tasks, err := s.repo.APICallLogTasks(taskIDs) + if err != nil { + return err + } + taskByID := make(map[string]model.Task, len(tasks)) + for _, task := range tasks { + taskByID[task.ID] = task + } for index := range logs { if logs[index].StartedAt.IsZero() { logs[index].StartedAt = logs[index].CreatedAt @@ -243,10 +263,51 @@ func (s *Service) decorateAPICallLogs(logs []model.ApiCallLog) error { logs[index].UserDisplayName = user.DisplayName logs[index].UserAccount = user.Username } + if task, exists := taskByID[logs[index].TaskID]; exists && task.UserID == logs[index].UserID { + previewURL, previewKind := taskMediaPreview(task.ResultJSON, task.Type) + if canvasResourceID(previewURL) != "" { + logs[index].MediaPreviewURL = "/api/admin/api-logs/" + logs[index].ID + "/media" + logs[index].MediaPreviewKind = previewKind + } else if strings.HasPrefix(previewURL, "https://") || strings.HasPrefix(previewURL, "http://") { + logs[index].MediaPreviewURL = previewURL + logs[index].MediaPreviewKind = previewKind + } + } } return nil } +// 管理员媒体读取必须同时校验日志、任务和资源归属,不能绕过用户资源边界按资源 ID 任意读取。 +func (s *Service) OpenAdminAPICallLogMediaRange(actor *model.User, logID string, rangeHeader string) (*ResourceStream, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + log, err := s.repo.APICallLog(strings.TrimSpace(logID)) + if err != nil { + return nil, err + } + if log.TaskID == "" || (log.Capability != "image" && log.Capability != "video") { + return nil, BadAuthRequest("该请求没有可预览媒体") + } + task, err := s.repo.Task(log.TaskID) + if err != nil { + return nil, err + } + if task.UserID != log.UserID { + return nil, BadAuthRequest("请求与媒体归属不一致") + } + previewURL, _ := taskMediaPreview(task.ResultJSON, task.Type) + resourceID := canvasResourceID(previewURL) + if resourceID == "" { + return nil, BadAuthRequest("该请求没有已持久化媒体") + } + resource, err := s.repo.ResourceForUser(log.UserID, resourceID) + if err != nil { + return nil, err + } + return s.openResourceRange(log.UserID, resource, rangeHeader) +} + func (s *Service) AdminAPICallLog(actor *model.User, id string) (*model.ApiCallLog, error) { if err := s.RequireAdmin(actor); err != nil { return nil, err diff --git a/backend/internal/service/resource.go b/backend/internal/service/resource.go index 715cab6ff..0f2526313 100644 --- a/backend/internal/service/resource.go +++ b/backend/internal/service/resource.go @@ -165,6 +165,10 @@ func (s *Service) OpenResourceRange(userID string, id string, rangeHeader string if err != nil { return nil, err } + return s.openResourceRange(userID, resource, rangeHeader) +} + +func (s *Service) openResourceRange(userID string, resource *model.Resource, rangeHeader string) (*ResourceStream, error) { if resource.Status != model.ResourceStatusReady { return nil, BadAuthRequest("资源尚未上传完成") } diff --git a/web/src/pages/admin/logs/logs-page.tsx b/web/src/pages/admin/logs/logs-page.tsx index 5178553c4..a097ce3cc 100644 --- a/web/src/pages/admin/logs/logs-page.tsx +++ b/web/src/pages/admin/logs/logs-page.tsx @@ -1,6 +1,7 @@ -import { App, Button, Input, Select, Table, Tag } from "antd"; +import { App, Button, Input, Modal, Select, Table, Tag } from "antd"; import type { ColumnsType } from "antd/es/table"; -import { Eye, Search } from "lucide-react"; +import { Download, Eye, Play, Search } from "lucide-react"; +import { saveAs } from "file-saver"; import { useEffect, useRef, useState } from "react"; import { useSearchParams } from "react-router"; @@ -24,6 +25,7 @@ export default function LogsPage() { const [loading, setLoading] = useState(true); const [selectedIds, setSelectedIds] = useState([]); const [detailLogId, setDetailLogId] = useState(null); + const [mediaPreview, setMediaPreview] = useState<{ url: string; kind: "image" | "video"; title: string } | null>(null); const requestSequence = useRef(0); const hasFilters = Boolean(keyword || status !== "all"); @@ -57,6 +59,7 @@ export default function LogsPage() { { title: "用户", width: 180, render: (_, log) =>
{log.userDisplayName || log.userAccount || "未知用户"}
{log.userAccount ? `@${log.userAccount}` : "账号未记录"}
}, { title: "渠道 / 模型", width: 230, render: (_, log) =>
{log.channelName || "未记录渠道"}
{log.model || "未识别模型"}
}, { title: "能力", dataIndex: "capability", width: 88, render: capabilityText }, + { title: "结果", width: 118, render: (_, log) => setMediaPreview({ url, kind, title: `${capabilityText(log.capability)}结果` })} /> }, { title: "调用状态", width: 160, render: (_, log) => }, { title: "错误信息", width: 260, render: (_, log) => log.status === "failed" || log.error || log.errorCode ?
{log.errorCode || `HTTP ${log.statusCode || "失败"}`}
{log.error || "上游未返回错误详情"}
: -- }, { title: "耗时", dataIndex: "durationMs", width: 112, render: (value) => {formatDuration(value)} }, @@ -73,9 +76,12 @@ export default function LogsPage() { setSelectedIds([])}> exportAdminApiLogs({ ids: selectedIds })} fileName={() => `请求明细-已选${selectedIds.length}条.csv`} label="导出已选" successMessage={`已导出选中的 ${selectedIds.length} 条请求明细`} errorMessage="导出请求明细失败" /> - {loading && logs.length === 0 ? : setSelectedIds(keys.map(String)) }} columns={columns} dataSource={logs} locale={{ emptyText: }} pagination={{ current: page, pageSize, total, showSizeChanger: true, pageSizeOptions: [20, 50, 100], showTotal: (value, range) => `${range[0]}-${range[1]} / 共 ${value} 条`, onChange: (nextPage, nextSize) => updateUrl({ page: nextSize !== pageSize ? 1 : nextPage, pageSize: nextSize }) }} scroll={{ x: 1580 }} />} + {loading && logs.length === 0 ? :
setSelectedIds(keys.map(String)) }} columns={columns} dataSource={logs} locale={{ emptyText: }} pagination={{ current: page, pageSize, total, showSizeChanger: true, pageSizeOptions: [20, 50, 100], showTotal: (value, range) => `${range[0]}-${range[1]} / 共 ${value} 条`, onChange: (nextPage, nextSize) => updateUrl({ page: nextSize !== pageSize ? 1 : nextPage, pageSize: nextSize }) }} scroll={{ x: 1700 }} />} setDetailLogId(null)} /> + setMediaPreview(null)} footer={mediaPreview ? : null} destroyOnHidden> + {mediaPreview?.kind === "video" ? ); } @@ -88,6 +94,24 @@ function capabilityText(value: string) { return ({ text: "文本", image: "图 function formatDuration(value: number) { if (value < 1_000) return `${value} ms`; if (value < 60_000) return `${(value / 1_000).toFixed(value < 10_000 ? 1 : 0)} 秒`; const minutes = Math.floor(value / 60_000); const seconds = Math.round((value % 60_000) / 1_000); return `${minutes} 分 ${seconds} 秒`; } function formatCost(log: ApiCallLog) { return `${log.currency || "USD"} ${(log.estimatedCostMicros / 1_000_000).toFixed(6)}`; } +function MediaResult({ log, onPreview }: { log: ApiCallLog; onPreview: (url: string, kind: "image" | "video") => void }) { + const url = log.mediaPreviewUrl; + const kind = log.mediaPreviewKind; + if (!url || (kind !== "image" && kind !== "video")) return --; + return
+ +
; +} + +function downloadMedia(url: string, kind: "image" | "video") { + saveAs(url, `api-call-${kind}.${kind === "video" ? "mp4" : "png"}`); +} + function CallStatus({ log }: { log: ApiCallLog }) { const providerStatus = log.providerStatus?.toLowerCase(); const processing = ["queued", "pending", "processing", "running", "in_progress"].includes(providerStatus || ""); diff --git a/web/src/services/api/auth.ts b/web/src/services/api/auth.ts index f6979c48f..21256cd28 100644 --- a/web/src/services/api/auth.ts +++ b/web/src/services/api/auth.ts @@ -65,6 +65,8 @@ export type ApiCallLog = { cachedTokens: number; usageAvailable: boolean; mediaCount: number; + mediaPreviewUrl?: string; + mediaPreviewKind?: "image" | "video"; videoSeconds: number; providerRequestId?: string; estimatedCostMicros: number;