chore(release): 版本发布 - publish v1.0.11
This commit is contained in:
@@ -2,6 +2,11 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
## v1.0.11
|
||||
|
||||
- 修复 PostgreSQL 请求明细分页继承 `DISTINCT` 后与创建时间排序冲突的问题,恢复管理后台列表读取。
|
||||
- 请求明细列表支持预览和下载图片、视频结果,私有媒体通过管理员鉴权流接口读取并支持视频 Range 请求。
|
||||
|
||||
## v1.0.10
|
||||
|
||||
- 运维后台请求明细按真实调用聚合视频轮询,补充用户账号、状态、错误、耗时、计费和 Token,并仅在详情中展示脱敏后的请求与响应报文。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("资源尚未上传完成")
|
||||
}
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
const [detailLogId, setDetailLogId] = useState<string | null>(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) => <div className="min-w-0"><div className="truncate font-medium text-foreground/85">{log.userDisplayName || log.userAccount || "未知用户"}</div><div className="truncate text-xs text-foreground/45">{log.userAccount ? `@${log.userAccount}` : "账号未记录"}</div></div> },
|
||||
{ title: "渠道 / 模型", width: 230, render: (_, log) => <div className="min-w-0"><div className="truncate text-foreground/78">{log.channelName || "未记录渠道"}</div><div className="truncate text-xs text-foreground/45" title={log.model}>{log.model || "未识别模型"}</div></div> },
|
||||
{ title: "能力", dataIndex: "capability", width: 88, render: capabilityText },
|
||||
{ title: "结果", width: 118, render: (_, log) => <MediaResult log={log} onPreview={(url, kind) => setMediaPreview({ url, kind, title: `${capabilityText(log.capability)}结果` })} /> },
|
||||
{ title: "调用状态", width: 160, render: (_, log) => <CallStatus log={log} /> },
|
||||
{ title: "错误信息", width: 260, render: (_, log) => log.status === "failed" || log.error || log.errorCode ? <div className="min-w-0" title={[log.errorCode, log.error].filter(Boolean).join(" · ")}><div className="truncate text-xs font-medium text-red-500">{log.errorCode || `HTTP ${log.statusCode || "失败"}`}</div><div className="line-clamp-2 text-xs leading-5 text-foreground/55">{log.error || "上游未返回错误详情"}</div></div> : <span className="text-foreground/30">--</span> },
|
||||
{ title: "耗时", dataIndex: "durationMs", width: 112, render: (value) => <span className="tabular-nums">{formatDuration(value)}</span> },
|
||||
@@ -73,9 +76,12 @@ export default function LogsPage() {
|
||||
</ListToolbar>
|
||||
<AdminBatchBar count={selectedIds.length} onClear={() => setSelectedIds([])}><AdminExportButton type="primary" size="small" exportFile={() => exportAdminApiLogs({ ids: selectedIds })} fileName={() => `请求明细-已选${selectedIds.length}条.csv`} label="导出已选" successMessage={`已导出选中的 ${selectedIds.length} 条请求明细`} errorMessage="导出请求明细失败" /></AdminBatchBar>
|
||||
<TableSurface>
|
||||
{loading && logs.length === 0 ? <AdminTableSkeleton rows={8} columns={10} /> : <Table className="app-data-table" size="middle" rowKey="id" loading={loading} rowSelection={{ selectedRowKeys: selectedIds, preserveSelectedRowKeys: false, onChange: (keys) => setSelectedIds(keys.map(String)) }} columns={columns} dataSource={logs} locale={{ emptyText: <AdminTableEmpty filtered={hasFilters} /> }} 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 ? <AdminTableSkeleton rows={8} columns={11} /> : <Table className="app-data-table" size="middle" rowKey="id" loading={loading} rowSelection={{ selectedRowKeys: selectedIds, preserveSelectedRowKeys: false, onChange: (keys) => setSelectedIds(keys.map(String)) }} columns={columns} dataSource={logs} locale={{ emptyText: <AdminTableEmpty filtered={hasFilters} /> }} 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 }} />}
|
||||
</TableSurface>
|
||||
<ApiLogDetailDrawer logId={detailLogId} onClose={() => setDetailLogId(null)} />
|
||||
<Modal title={mediaPreview?.title || "媒体预览"} open={Boolean(mediaPreview)} width={880} onCancel={() => setMediaPreview(null)} footer={mediaPreview ? <Button icon={<Download className="size-4" />} onClick={() => downloadMedia(mediaPreview.url, mediaPreview.kind)}>下载原文件</Button> : null} destroyOnHidden>
|
||||
{mediaPreview?.kind === "video" ? <video src={mediaPreview.url} controls playsInline preload="metadata" className="max-h-[72vh] w-full bg-black object-contain" /> : mediaPreview ? <img src={mediaPreview.url} alt={mediaPreview.title} className="max-h-[72vh] w-full bg-black object-contain" /> : null}
|
||||
</Modal>
|
||||
</AdminPageFrame>
|
||||
);
|
||||
}
|
||||
@@ -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 <span className="text-foreground/30">--</span>;
|
||||
return <div className="flex w-[90px] items-center gap-1.5">
|
||||
<button type="button" title={`预览${kind === "video" ? "视频" : "图片"}`} className="group relative h-11 w-16 shrink-0 overflow-hidden rounded border border-border/75 bg-black/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" onClick={() => onPreview(url, kind)}>
|
||||
{kind === "video" ? <video src={url} muted playsInline preload="metadata" className="size-full object-cover" /> : <img src={url} alt="生成结果" loading="lazy" decoding="async" className="size-full object-cover" />}
|
||||
<span className="absolute inset-0 grid place-items-center bg-black/0 text-white opacity-0 transition group-hover:bg-black/35 group-hover:opacity-100 group-focus-visible:bg-black/35 group-focus-visible:opacity-100">{kind === "video" ? <Play className="size-4 fill-current" /> : <Eye className="size-4" />}</span>
|
||||
{log.mediaCount > 1 ? <span className="absolute bottom-0.5 right-0.5 rounded-sm bg-black/65 px-1 text-[9px] leading-4 text-white">{log.mediaCount}</span> : null}
|
||||
</button>
|
||||
<Button type="text" size="small" className="!size-7 !min-w-7 !p-0" icon={<Download className="size-3.5" />} onClick={() => downloadMedia(url, kind)} title="下载原文件" aria-label="下载原文件" />
|
||||
</div>;
|
||||
}
|
||||
|
||||
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 || "");
|
||||
|
||||
@@ -65,6 +65,8 @@ export type ApiCallLog = {
|
||||
cachedTokens: number;
|
||||
usageAvailable: boolean;
|
||||
mediaCount: number;
|
||||
mediaPreviewUrl?: string;
|
||||
mediaPreviewKind?: "image" | "video";
|
||||
videoSeconds: number;
|
||||
providerRequestId?: string;
|
||||
estimatedCostMicros: number;
|
||||
|
||||
Reference in New Issue
Block a user