chore(release): 版本发布 - publish v1.0.11

This commit is contained in:
ddCat
2026-07-28 21:39:04 +08:00
parent 2ed4acb564
commit 1380849d03
9 changed files with 176 additions and 31 deletions

View File

@@ -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 {

View File

@@ -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"`

View File

@@ -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 {

View File

@@ -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

View File

@@ -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("资源尚未上传完成")
}