diff --git a/internal/gateway/image_channel.go b/internal/gateway/image_channel.go index 1ebd793..d9f1f03 100644 --- a/internal/gateway/image_channel.go +++ b/internal/gateway/image_channel.go @@ -146,6 +146,7 @@ func (h *ImagesHandler) dispatchImageToChannel(c *gin.Context, rec.Status = usage.StatusSuccess rec.ModelID = m.ID rec.CreditCost = finalCost + rec.ImageCount = actualCount(result) c.JSON(http.StatusOK, ImageGenResponse{ Created: time.Now().Unix(), diff --git a/internal/gateway/images.go b/internal/gateway/images.go index 0073c39..dbee73d 100644 --- a/internal/gateway/images.go +++ b/internal/gateway/images.go @@ -299,6 +299,7 @@ func (h *ImagesHandler) ImageGenerations(c *gin.Context) { // 7) usage rec.Status = usage.StatusSuccess rec.CreditCost = cost + rec.ImageCount = len(res.SignedURLs) // 8) DAO 回写 credit_cost(Runner 已经 MarkSuccess,这里只补 credit_cost) if h.DAO != nil { @@ -363,14 +364,14 @@ func (h *ImagesHandler) ImageTask(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{ - "task_id": t.TaskID, - "status": t.Status, - "conversation_id": t.ConversationID, - "created": t.CreatedAt.Unix(), - "finished_at": nullableUnix(t.FinishedAt), - "error": t.Error, - "credit_cost": t.CreditCost, - "data": data, + "task_id": t.TaskID, + "status": t.Status, + "conversation_id": t.ConversationID, + "created": t.CreatedAt.Unix(), + "finished_at": nullableUnix(t.FinishedAt), + "error": t.Error, + "credit_cost": t.CreditCost, + "data": data, }) } @@ -496,6 +497,7 @@ func (h *ImagesHandler) handleChatAsImage(c *gin.Context, rec *usage.Log, ak *ap rec.Status = usage.StatusSuccess rec.CreditCost = cost + rec.ImageCount = len(res.SignedURLs) rec.DurationMs = int(time.Since(startAt).Milliseconds()) // 以 chat 响应返回(content 里内嵌 markdown 图片)。 @@ -809,6 +811,7 @@ func (h *ImagesHandler) ImageEdits(c *gin.Context) { rec.Status = usage.StatusSuccess rec.CreditCost = cost + rec.ImageCount = len(res.SignedURLs) if h.DAO != nil { _ = h.DAO.UpdateCost(c.Request.Context(), taskID, cost) } diff --git a/internal/gateway/images_proxy.go b/internal/gateway/images_proxy.go index 2295654..a7b2385 100644 --- a/internal/gateway/images_proxy.go +++ b/internal/gateway/images_proxy.go @@ -23,10 +23,6 @@ package gateway import ( "context" - "crypto/hmac" - "crypto/rand" - "crypto/sha256" - "encoding/hex" "fmt" "net/http" "strconv" @@ -55,19 +51,6 @@ type ImageAccountResolver interface { ProxyURL(ctx context.Context, accountID uint64) string } -// imageProxySecret 进程级随机密钥,用于 HMAC 签名图片 URL。 -// 进程重启后旧的签名 URL 全部失效,这是故意的(防止长期有效的 URL 泄漏)。 -var imageProxySecret []byte - -func init() { - imageProxySecret = make([]byte, 32) - if _, err := rand.Read(imageProxySecret); err != nil { - for i := range imageProxySecret { - imageProxySecret[i] = byte(i*31 + 7) - } - } -} - // ImageProxyTTL 单条签名 URL 的默认有效期(24h,够前端离线展示一段时间)。 const ImageProxyTTL = 24 * time.Hour @@ -75,26 +58,7 @@ const ImageProxyTTL = 24 * time.Hour // // 默认 ttl=24h。前端展示一张历史图片,最多走一次上游获取 bytes,之后浏览器缓存即可。 func BuildImageProxyURL(taskID string, idx int, ttl time.Duration) string { - if ttl <= 0 { - ttl = ImageProxyTTL - } - expMs := time.Now().Add(ttl).UnixMilli() - sig := computeImgSig(taskID, idx, expMs) - return fmt.Sprintf("/p/img/%s/%d?exp=%d&sig=%s", taskID, idx, expMs, sig) -} - -func computeImgSig(taskID string, idx int, expMs int64) string { - mac := hmac.New(sha256.New, imageProxySecret) - fmt.Fprintf(mac, "%s|%d|%d", taskID, idx, expMs) - return hex.EncodeToString(mac.Sum(nil))[:24] -} - -func verifyImgSig(taskID string, idx int, expMs int64, sig string) bool { - if expMs < time.Now().UnixMilli() { - return false - } - want := computeImgSig(taskID, idx, expMs) - return hmac.Equal([]byte(sig), []byte(want)) + return image.BuildProxyURL(taskID, idx, ttl) } // ImageProxy 按签名代理下载上游图片。无需 API Key,只靠 URL 签名校验。 @@ -113,12 +77,17 @@ func (h *ImagesHandler) ImageProxy(c *gin.Context) { c.AbortWithStatus(http.StatusBadRequest) return } + thumbKB, err := strconv.Atoi(c.DefaultQuery("thumb_kb", "0")) + if err != nil || thumbKB < 0 || thumbKB > 64 { + c.AbortWithStatus(http.StatusBadRequest) + return + } expMs, err := strconv.ParseInt(expStr, 10, 64) if err != nil { c.AbortWithStatus(http.StatusBadRequest) return } - if !verifyImgSig(taskID, idx, expMs, sig) { + if !image.VerifyImgSig(taskID, idx, expMs, sig) { c.AbortWithStatus(http.StatusForbidden) return } @@ -179,6 +148,9 @@ func (h *ImagesHandler) ImageProxy(c *gin.Context) { // 按需放大:若 task 上打了 upscale 标记,先走进程内 LRU,命中则直接返回。 // 未命中再拉原图,放大成 PNG 后写入缓存。 scale := image.ValidateUpscale(t.Upscale) + if thumbKB > 0 { + scale = "" + } cacheKey := "" if scale != "" { cacheKey = fmt.Sprintf("%s|%d|%s", taskID, idx, scale) @@ -200,6 +172,18 @@ func (h *ImagesHandler) ImageProxy(c *gin.Context) { if ct == "" { ct = "image/png" } + if thumbKB > 0 { + thumbBytes, thumbCT, err := image.MakeThumbJPEG(body, thumbKB*1024) + if err != nil { + logger.L().Warn("image proxy thumb", + zap.Error(err), zap.String("task_id", taskID), + zap.Int("thumb_kb", thumbKB)) + } else { + body = thumbBytes + ct = thumbCT + c.Header("X-Thumb-KB", strconv.Itoa(thumbKB)) + } + } if scale != "" { // 并发闸:避免 4K 请求风暴把 CPU 打满影响生图主流程 diff --git a/internal/image/admin_handler.go b/internal/image/admin_handler.go index fec4663..40d30b2 100644 --- a/internal/image/admin_handler.go +++ b/internal/image/admin_handler.go @@ -2,6 +2,7 @@ package image import ( "strconv" + "time" "github.com/gin-gonic/gin" @@ -46,16 +47,22 @@ func (h *AdminHandler) List(c *gin.Context) { return } - // 把 result_urls JSON bytes 解成可读字符串数组后输出 + // 把 result_urls JSON bytes 解成代理 URL 后输出 type rowOut struct { AdminTaskRow ResultURLsParsed []string `json:"result_urls_parsed"` } out := make([]rowOut, 0, len(rows)) for _, r := range rows { + // 生成代理 URL 而不是直接返回上游 URL + fids := r.DecodeFileIDs() + urls := make([]string, 0, len(fids)) + for i := range fids { + urls = append(urls, BuildProxyURL(r.TaskID, i, 24*time.Hour)) + } out = append(out, rowOut{ AdminTaskRow: r, - ResultURLsParsed: r.DecodeResultURLs(), + ResultURLsParsed: urls, }) } diff --git a/internal/image/dao.go b/internal/image/dao.go index 092c2d2..31efc7f 100644 --- a/internal/image/dao.go +++ b/internal/image/dao.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "time" "github.com/jmoiron/sqlx" @@ -111,20 +112,46 @@ SELECT id, task_id, user_id, key_id, model_id, account_id, prompt, n, size, upsc return &t, nil } +// MyTaskFilter 当前用户图片任务筛选条件。 +type MyTaskFilter struct { + Status string + Keyword string + CreatedAt *time.Time + CreatedTo *time.Time +} + // ListByUser 按用户分页。 -func (d *DAO) ListByUser(ctx context.Context, userID uint64, limit, offset int) ([]Task, error) { +func (d *DAO) ListByUser(ctx context.Context, userID uint64, f MyTaskFilter, limit, offset int) ([]Task, error) { if limit <= 0 { limit = 20 } + where := []string{"user_id = ?"} + args := []interface{}{userID} + if f.Status != "" { + where = append(where, "status = ?") + args = append(args, f.Status) + } + if f.Keyword != "" { + where = append(where, "prompt LIKE ?") + args = append(args, "%"+f.Keyword+"%") + } + if f.CreatedAt != nil { + where = append(where, "created_at >= ?") + args = append(args, *f.CreatedAt) + } + if f.CreatedTo != nil { + where = append(where, "created_at <= ?") + args = append(args, *f.CreatedTo) + } var out []Task err := d.db.SelectContext(ctx, &out, ` SELECT id, task_id, user_id, key_id, model_id, account_id, prompt, n, size, upscale, status, conversation_id, file_ids, result_urls, error, estimated_credit, credit_cost, created_at, started_at, finished_at FROM image_tasks - WHERE user_id = ? + WHERE `+strings.Join(where, " AND ")+` ORDER BY id DESC - LIMIT ? OFFSET ?`, userID, limit, offset) + LIMIT ? OFFSET ?`, append(args, limit, offset)...) return out, err } diff --git a/internal/image/me_handler.go b/internal/image/me_handler.go index a49e621..845f07d 100644 --- a/internal/image/me_handler.go +++ b/internal/image/me_handler.go @@ -22,44 +22,50 @@ func NewMeHandler(dao *DAO) *MeHandler { return &MeHandler{dao: dao} } // taskView 是对外返回的视图结构,解码 JSON 列 + 隐藏内部字段。 type taskView struct { - ID uint64 `json:"id"` - TaskID string `json:"task_id"` - UserID uint64 `json:"user_id"` - ModelID uint64 `json:"model_id"` - AccountID uint64 `json:"account_id"` - Prompt string `json:"prompt"` - N int `json:"n"` - Size string `json:"size"` - Upscale string `json:"upscale,omitempty"` - Status string `json:"status"` - ConversationID string `json:"conversation_id,omitempty"` - Error string `json:"error,omitempty"` - CreditCost int64 `json:"credit_cost"` - ImageURLs []string `json:"image_urls"` - FileIDs []string `json:"file_ids,omitempty"` - CreatedAt time.Time `json:"created_at"` + ID uint64 `json:"id"` + TaskID string `json:"task_id"` + UserID uint64 `json:"user_id"` + ModelID uint64 `json:"model_id"` + AccountID uint64 `json:"account_id"` + Prompt string `json:"prompt"` + N int `json:"n"` + Size string `json:"size"` + Upscale string `json:"upscale,omitempty"` + Status string `json:"status"` + ConversationID string `json:"conversation_id,omitempty"` + Error string `json:"error,omitempty"` + CreditCost int64 `json:"credit_cost"` + ImageURLs []string `json:"image_urls"` + FileIDs []string `json:"file_ids,omitempty"` + CreatedAt time.Time `json:"created_at"` StartedAt *time.Time `json:"started_at,omitempty"` FinishedAt *time.Time `json:"finished_at,omitempty"` } func toView(t *Task) taskView { - urls := t.DecodeResultURLs() fids := t.DecodeFileIDs() for i, id := range fids { fids[i] = strings.TrimPrefix(id, "sed:") } + + // 生成代理 URL 而不是直接返回上游 URL(防止 403) + urls := make([]string, 0, len(fids)) + for i := range fids { + urls = append(urls, BuildProxyURL(t.TaskID, i, 24*time.Hour)) + } + return taskView{ ID: t.ID, TaskID: t.TaskID, UserID: t.UserID, ModelID: t.ModelID, AccountID: t.AccountID, Prompt: t.Prompt, N: t.N, Size: t.Size, Upscale: t.Upscale, - Status: t.Status, ConversationID: t.ConversationID, Error: t.Error, + Status: t.Status, ConversationID: t.ConversationID, Error: t.Error, CreditCost: t.CreditCost, ImageURLs: urls, FileIDs: fids, CreatedAt: t.CreatedAt, StartedAt: t.StartedAt, FinishedAt: t.FinishedAt, } } // GET /api/me/images/tasks -// 查询参数:limit(默认 20,上限 100), offset +// 查询参数:limit(默认 20,上限 100), offset, status, keyword, start_at, end_at func (h *MeHandler) List(c *gin.Context) { uid := middleware.UserID(c) if uid == 0 { @@ -77,7 +83,27 @@ func (h *MeHandler) List(c *gin.Context) { if offset < 0 { offset = 0 } - tasks, err := h.dao.ListByUser(c.Request.Context(), uid, limit, offset) + filter := MyTaskFilter{ + Status: strings.TrimSpace(c.Query("status")), + Keyword: strings.TrimSpace(c.Query("keyword")), + } + if startAt := strings.TrimSpace(c.Query("start_at")); startAt != "" { + tm, err := parseFilterTime(startAt) + if err != nil { + resp.Fail(c, resp.CodeBadRequest, "start_at 格式错误,期望 2006-01-02 15:04:05") + return + } + filter.CreatedAt = &tm + } + if endAt := strings.TrimSpace(c.Query("end_at")); endAt != "" { + tm, err := parseFilterTime(endAt) + if err != nil { + resp.Fail(c, resp.CodeBadRequest, "end_at 格式错误,期望 2006-01-02 15:04:05") + return + } + filter.CreatedTo = &tm + } + tasks, err := h.dao.ListByUser(c.Request.Context(), uid, filter, limit, offset) if err != nil { resp.Internal(c, err.Error()) return @@ -89,6 +115,21 @@ func (h *MeHandler) List(c *gin.Context) { resp.OK(c, gin.H{"items": items, "limit": limit, "offset": offset}) } +func parseFilterTime(s string) (time.Time, error) { + loc := time.Local + layouts := []string{ + "2006-01-02 15:04:05", + time.RFC3339, + "2006-01-02", + } + for _, layout := range layouts { + if t, err := time.ParseInLocation(layout, s, loc); err == nil { + return t, nil + } + } + return time.Time{}, errors.New("invalid time") +} + // GET /api/me/images/tasks/:id func (h *MeHandler) Get(c *gin.Context) { uid := middleware.UserID(c) diff --git a/internal/image/proxy_url.go b/internal/image/proxy_url.go new file mode 100644 index 0000000..6464bb7 --- /dev/null +++ b/internal/image/proxy_url.go @@ -0,0 +1,53 @@ +package image + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "time" +) + +// imageProxySecret 进程级随机密钥,用于 HMAC 签名图片 URL。 +// 进程重启后旧的签名 URL 全部失效,这是故意的(防止长期有效的 URL 泄漏)。 +var imageProxySecret []byte + +func init() { + imageProxySecret = make([]byte, 32) + if _, err := rand.Read(imageProxySecret); err != nil { + for i := range imageProxySecret { + imageProxySecret[i] = byte(i*31 + 7) + } + } +} + +// BuildProxyURL 生成代理 URL。返回绝对 path(不含 host)。 +func BuildProxyURL(taskID string, idx int, ttl time.Duration) string { + if ttl <= 0 { + ttl = 24 * time.Hour + } + expMs := time.Now().Add(ttl).UnixMilli() + sig := computeImgSig(taskID, idx, expMs) + return fmt.Sprintf("/p/img/%s/%d?exp=%d&sig=%s", taskID, idx, expMs, sig) +} + +// ComputeImgSig 计算图片 URL 签名(供 gateway 验证使用)。 +func ComputeImgSig(taskID string, idx int, expMs int64) string { + return computeImgSig(taskID, idx, expMs) +} + +func computeImgSig(taskID string, idx int, expMs int64) string { + mac := hmac.New(sha256.New, imageProxySecret) + fmt.Fprintf(mac, "%s|%d|%d", taskID, idx, expMs) + return hex.EncodeToString(mac.Sum(nil))[:24] +} + +// VerifyImgSig 验证图片 URL 签名。 +func VerifyImgSig(taskID string, idx int, expMs int64, sig string) bool { + if expMs < time.Now().UnixMilli() { + return false + } + want := computeImgSig(taskID, idx, expMs) + return hmac.Equal([]byte(sig), []byte(want)) +} diff --git a/internal/image/thumb.go b/internal/image/thumb.go new file mode 100644 index 0000000..46ae607 --- /dev/null +++ b/internal/image/thumb.go @@ -0,0 +1,87 @@ +package image + +import ( + "bytes" + "fmt" + stdimage "image" + "image/jpeg" + + "golang.org/x/image/draw" +) + +const ( + DefaultThumbMaxBytes = 10 * 1024 + minThumbSide = 96 +) + +var ( + thumbWidths = []int{512, 448, 384, 320, 288, 256, 224, 192, 160, 128, 96} + thumbQualities = []int{55, 45, 35, 28, 22, 16, 10, 5} +) + +// MakeThumbJPEG 把原图压缩成 JPEG 缩略图,尽量控制在 maxBytes 内。 +func MakeThumbJPEG(srcBytes []byte, maxBytes int) ([]byte, string, error) { + if maxBytes <= 0 { + maxBytes = DefaultThumbMaxBytes + } + src, _, err := stdimage.Decode(bytes.NewReader(srcBytes)) + if err != nil { + return nil, "", fmt.Errorf("thumb decode: %w", err) + } + + b := src.Bounds() + sw, sh := b.Dx(), b.Dy() + if sw <= 0 || sh <= 0 { + return nil, "", fmt.Errorf("thumb decode: invalid size %dx%d", sw, sh) + } + + var best []byte + for _, maxW := range thumbWidths { + tw, th := fitThumb(sw, sh, maxW) + dst := stdimage.NewRGBA(stdimage.Rect(0, 0, tw, th)) + draw.CatmullRom.Scale(dst, dst.Bounds(), src, b, draw.Over, nil) + + for _, q := range thumbQualities { + buf := bytes.NewBuffer(make([]byte, 0, maxBytes)) + if err := jpeg.Encode(buf, dst, &jpeg.Options{Quality: q}); err != nil { + return nil, "", fmt.Errorf("thumb jpeg encode: %w", err) + } + out := buf.Bytes() + if len(out) <= maxBytes { + return out, "image/jpeg", nil + } + if len(best) == 0 || len(out) < len(best) { + best = append(best[:0], out...) + } + } + } + + if len(best) > 0 { + return best, "image/jpeg", nil + } + return nil, "", fmt.Errorf("thumb jpeg encode: no output") +} + +func fitThumb(sw, sh, maxW int) (int, int) { + if maxW < minThumbSide { + maxW = minThumbSide + } + if sw <= maxW { + if sw < minThumbSide { + return minThumbSide, max(minThumbSide, sh*minThumbSide/max(sw, 1)) + } + return sw, sh + } + th := sh * maxW / sw + if th < minThumbSide { + th = minThumbSide + } + return maxW, th +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/internal/usage/query_dao.go b/internal/usage/query_dao.go index 5a759b5..0cd9e6e 100644 --- a/internal/usage/query_dao.go +++ b/internal/usage/query_dao.go @@ -96,6 +96,13 @@ type Overall struct { CreditCost int64 `db:"credit_cost" json:"credit_cost"` } +const imageCountExpr = `CASE + WHEN u.type <> 'image' THEN 0 + WHEN u.image_count > 0 THEN u.image_count + WHEN u.status = 'success' THEN 1 + ELSE 0 +END` + // ---------- 内部 ---------- // buildWhere 根据 filter 生成 WHERE 片段 + 参数列表。 @@ -183,7 +190,7 @@ func (d *QueryDAO) Overall(ctx context.Context, f Filter) (Overall, error) { SELECT COUNT(*) AS requests, COALESCE(SUM(CASE WHEN u.status = 'failed' THEN 1 ELSE 0 END), 0) AS failures, COALESCE(SUM(CASE WHEN u.type = 'chat' THEN 1 ELSE 0 END), 0) AS chat_requests, - COALESCE(SUM(CASE WHEN u.type = 'image' THEN u.image_count ELSE 0 END), 0) AS image_images, + COALESCE(SUM(`+imageCountExpr+`), 0) AS image_images, COALESCE(SUM(u.input_tokens), 0) AS input_tokens, COALESCE(SUM(u.output_tokens), 0) AS output_tokens, COALESCE(SUM(u.credit_cost), 0) AS credit_cost @@ -210,7 +217,7 @@ SELECT u.model_id, COALESCE(SUM(CASE WHEN u.status='failed' THEN 1 ELSE 0 END), 0) AS failures, COALESCE(SUM(u.input_tokens), 0) AS input_tokens, COALESCE(SUM(u.output_tokens), 0) AS output_tokens, - COALESCE(SUM(u.image_count), 0) AS image_count, + COALESCE(SUM(`+imageCountExpr+`), 0) AS image_count, COALESCE(SUM(u.credit_cost), 0) AS credit_cost, /* AVG 返回 DECIMAL(driver 会给 []uint8),必须 CAST 回整数才能 scan 进 int64 */ COALESCE(CAST(AVG(u.duration_ms) AS SIGNED), 0) AS avg_dur_ms @@ -268,7 +275,7 @@ SELECT DATE_FORMAT(u.created_at, '%%Y-%%m-%%d') AS day, COALESCE(SUM(CASE WHEN u.status='failed' THEN 1 ELSE 0 END), 0) AS failures, COALESCE(SUM(u.input_tokens), 0) AS input_tokens, COALESCE(SUM(u.output_tokens), 0) AS output_tokens, - COALESCE(SUM(u.image_count), 0) AS image_count, + COALESCE(SUM(`+imageCountExpr+`), 0) AS image_count, COALESCE(SUM(u.credit_cost), 0) AS credit_cost FROM usage_logs u %s diff --git a/web/package-lock.json b/web/package-lock.json index 90e265f..40fe1ae 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "gpt2api-web", "version": "0.1.0", + "license": "MIT", "dependencies": { "@element-plus/icons-vue": "^2.3.1", "@vueuse/core": "^10.9.0", diff --git a/web/src/api/me.ts b/web/src/api/me.ts index dfe59f2..5c38965 100644 --- a/web/src/api/me.ts +++ b/web/src/api/me.ts @@ -147,6 +147,10 @@ export interface ImageTask { export function listMyImageTasks(params: { limit?: number offset?: number + status?: string + keyword?: string + start_at?: string + end_at?: string } = {}): Promise<{ items: ImageTask[]; limit: number; offset: number }> { return http.get('/api/me/images/tasks', { params }) } diff --git a/web/src/views/admin/ImageTasks.vue b/web/src/views/admin/ImageTasks.vue index c187e24..0abb8c4 100644 --- a/web/src/views/admin/ImageTasks.vue +++ b/web/src/views/admin/ImageTasks.vue @@ -1,5 +1,6 @@