feat: save generated images to local server storage
Docker Build / Build and Push Docker Image (push) Successful in 3m57s

This commit is contained in:
2026-06-22 02:15:38 +08:00
parent d60df24ee6
commit ced375076c
5 changed files with 296 additions and 16 deletions
+5 -2
View File
@@ -1,9 +1,12 @@
package controller
import (
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
func GetImage(c *gin.Context) {
// ServeImage 提供本地存储的图片文件服务
func ServeImage(c *gin.Context) {
service.ServeImage(c)
}
+53 -2
View File
@@ -40,6 +40,25 @@ func OpenaiImageHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
}
// 下载远程图片到本地并替换 URL
var imageResp dto.ImageResponse
if common.Unmarshal(responseBody, &imageResp) == nil {
modified := false
for i := range imageResp.Data {
if imageResp.Data[i].Url != "" && !strings.HasPrefix(imageResp.Data[i].Url, "/api/images/") {
if filename, err := service.SaveRemoteImage(c, imageResp.Data[i].Url); err == nil && filename != "" {
imageResp.Data[i].Url = service.GetLocalImageURL(c, filename)
modified = true
}
}
}
if modified {
if newBody, err := common.Marshal(imageResp); err == nil {
responseBody = newBody
}
}
}
// 写入新的 response body
service.IOCopyBytesGracefully(c, resp, responseBody)
@@ -182,12 +201,38 @@ func OpenaiImageStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
// writeOpenaiImageStreamChunk rebuilds the SSE frame for an image stream chunk:
// it emits an "event:" line derived from the JSON "type" field (when present)
// followed by the verbatim "data:" payload, mirroring helper.ResponseChunkData.
// followed by the "data:" payload.
// If the payload contains a remote image URL, it downloads the image to local
// storage and replaces the URL with a local path so the image persists after
// the upstream temporary URL expires.
func writeOpenaiImageStreamChunk(c *gin.Context, data []byte) {
var payload struct {
Type string `json:"type"`
Type string `json:"type"`
URL string `json:"url"`
B64Json string `json:"b64_json"`
RevisedPrompt string `json:"revised_prompt"`
}
_ = common.Unmarshal(data, &payload)
// 如果包含远程 URL,下载图片到本地并替换 URL
if payload.URL != "" && !strings.HasPrefix(payload.URL, "/api/images/") {
filename, err := service.SaveRemoteImage(c, payload.URL)
if err == nil && filename != "" {
payload.URL = service.GetLocalImageURL(c, filename)
// 重新序列化 payload
// 保留原始 JSON 中的其他字段
var rawMap map[string]json.RawMessage
if common.Unmarshal(data, &rawMap) == nil {
if urlBytes, err := common.Marshal(payload.URL); err == nil {
rawMap["url"] = urlBytes
}
if newData, err := common.Marshal(rawMap); err == nil {
data = newData
}
}
}
}
if eventName := strings.TrimSpace(payload.Type); eventName != "" {
c.Render(-1, common.CustomEvent{Data: fmt.Sprintf("event: %s\n", eventName)})
}
@@ -282,6 +327,12 @@ func OpenaiImageJSONAsStreamHandler(c *gin.Context, info *relaycommon.RelayInfo,
"created_at": created,
}
if image.Url != "" {
// 下载远程图片到本地并替换 URL
if !strings.HasPrefix(image.Url, "/api/images/") {
if filename, err := service.SaveRemoteImage(c, image.Url); err == nil && filename != "" {
image.Url = service.GetLocalImageURL(c, filename)
}
}
payload["url"] = image.Url
}
if image.B64Json != "" {
+11 -12
View File
@@ -93,16 +93,15 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
statusCodeMappingStr := c.GetString("status_code_mapping")
// When the client requests streaming, send SSE headers immediately and
// start a periodic keepalive goroutine. Image generation can take 60+
// seconds; without periodic data the connection will be closed by
// reverse-proxies (Nginx proxy_read_timeout) or the browser.
// 当客户端请求流式响应时,立即发送 SSE 头并启动周期性 keepalive 协程。
// 图片生成可能耗时 60 秒以上,若期间无数据传输,反向代理(如 Nginx 的
// proxy_read_timeout,默认 60 秒)或浏览器会关闭连接。
//
// IMPORTANT: The keepalive must span both DoRequest AND DoResponse.
// For SSE upstreams, DoRequest returns quickly (after receiving response
// headers), while DoResponse is where the actual waiting happens as it
// reads streaming events. Stopping the keepalive after DoRequest would
// leave the connection idle during DoResponse, causing proxy timeouts.
// 【重要】keepalive 必须覆盖 DoRequest DoResponse 两个阶段。
// 对于 SSE 上游,DoRequest 在收到响应头后即返回(SSE 先发 200 + headers
// 事件数据稍后到达),真正的等待发生在 DoResponse 中——它逐行读取上游
// SSE 事件。若在 DoRequest 后就停止 keepaliveDoResponse 期间连接将
// 处于空闲状态,导致反向代理超时关闭连接(client_gone / context canceled)。
var keepaliveDone chan struct{}
if info.IsStream {
helper.SetEventStreamHeaders(c)
@@ -127,8 +126,8 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
}
resp, err := adaptor.DoRequest(c, info, requestBody)
// NOTE: do NOT close keepaliveDone here — DoRequest may return quickly
// for SSE upstreams while the real waiting happens in DoResponse.
// 注意:此处不能关闭 keepaliveDoneSSE 上游的 DoRequest 会快速返回,
// 真正的等待在 DoResponse 中,keepalive 必须持续到 DoResponse 完成。
if err != nil {
if keepaliveDone != nil {
close(keepaliveDone)
@@ -164,7 +163,7 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
}
usage, newAPIError := adaptor.DoResponse(c, httpResp, info)
// DoResponse has completed — now it is safe to stop the keepalive.
// DoResponse 已完成,现在可以安全地停止 keepalive 协程
if keepaliveDone != nil {
close(keepaliveDone)
}
+3
View File
@@ -64,6 +64,9 @@ func SetApiRouter(router *gin.Engine) {
// Universal secure verification routes
apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify)
// 图片文件服务(UUID 文件名保证安全性,无需认证,支持 <img> 标签直接访问)
apiRouter.GET("/images/:filename", controller.ServeImage)
userRoute := apiRouter.Group("/user")
{
userRoute.POST("/register", middleware.CriticalRateLimit(), anonymousRequestBodyLimit, middleware.TurnstileCheck(), controller.Register)
+224
View File
@@ -0,0 +1,224 @@
package service
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
const (
imageDirName = "images"
)
var (
imageDirOnce sync.Once
imageDirPath string
imageDirInitErr error
)
// getImageDir 获取图片存储目录路径
// 优先级:DISK_CACHE_PATH/images > /data/images > 临时目录/images
func getImageDir() (string, error) {
imageDirOnce.Do(func() {
// 1. 优先使用磁盘缓存路径
cachePath := common.GetDiskCachePath()
if cachePath == "" {
// 2. 尝试 /data 目录(Docker 部署时的持久化目录)
if info, err := os.Stat("/data"); err == nil && info.IsDir() {
cachePath = "/data"
} else {
// 3. 回退到临时目录
cachePath = os.TempDir()
}
}
imageDirPath = filepath.Join(cachePath, imageDirName)
imageDirInitErr = os.MkdirAll(imageDirPath, 0755)
})
return imageDirPath, imageDirInitErr
}
// SaveRemoteImage 下载远程图片并保存到服务器本地存储。
// 返回本地文件名(不含路径)和可能的错误。
// 如果 remoteURL 为空或已经是本地路径,则直接返回不做处理。
func SaveRemoteImage(c *gin.Context, remoteURL string) (string, error) {
if remoteURL == "" {
return "", nil
}
// 已经是本地路径则跳过
if strings.HasPrefix(remoteURL, "/api/images/") {
return filepath.Base(remoteURL), nil
}
// 下载远程图片
resp, err := DoDownloadRequest(remoteURL, "image_storage")
if err != nil {
return "", fmt.Errorf("下载图片失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("下载图片失败, HTTP %d", resp.StatusCode)
}
// 读取图片数据(限制 20MB
imageData, err := io.ReadAll(io.LimitReader(resp.Body, 20*1024*1024+1))
if err != nil {
return "", fmt.Errorf("读取图片数据失败: %w", err)
}
if len(imageData) > 20*1024*1024 {
return "", fmt.Errorf("图片大小超过 20MB 限制")
}
// 检测图片格式并确定扩展名
ext := detectImageExtension(resp, imageData)
// 生成文件名
filename := uuid.New().String() + ext
// 保存到磁盘
dir, err := getImageDir()
if err != nil {
return "", fmt.Errorf("获取图片目录失败: %w", err)
}
filePath := filepath.Join(dir, filename)
if err := os.WriteFile(filePath, imageData, 0644); err != nil {
return "", fmt.Errorf("保存图片文件失败: %w", err)
}
logger.LogInfo(c.Request.Context(), fmt.Sprintf("图片已保存到本地: %s (原始URL: %s)", filename, common.MaskSensitiveInfo(remoteURL)))
return filename, nil
}
// detectImageExtension 从 HTTP 响应和图片数据中检测扩展名
func detectImageExtension(resp *http.Response, data []byte) string {
// 1. 从 Content-Type 检测
contentType := resp.Header.Get("Content-Type")
switch {
case strings.HasPrefix(contentType, "image/png"):
return ".png"
case strings.HasPrefix(contentType, "image/jpeg"), strings.HasPrefix(contentType, "image/jpg"):
return ".jpg"
case strings.HasPrefix(contentType, "image/gif"):
return ".gif"
case strings.HasPrefix(contentType, "image/webp"):
return ".webp"
}
// 2. 从内容嗅探
if len(data) > 0 {
sniffed := http.DetectContentType(data)
switch {
case strings.HasPrefix(sniffed, "image/png"):
return ".png"
case strings.HasPrefix(sniffed, "image/jpeg"):
return ".jpg"
case strings.HasPrefix(sniffed, "image/gif"):
return ".gif"
case strings.HasPrefix(sniffed, "image/webp"):
return ".webp"
}
}
// 默认 png
return ".png"
}
// GetLocalImageURL 根据文件名生成本地访问 URL
func GetLocalImageURL(c *gin.Context, filename string) string {
if filename == "" {
return ""
}
// 使用相对路径,避免硬编码域名和端口
return "/api/images/" + filename
}
// ServeImage 提供图片文件服务
func ServeImage(c *gin.Context) {
filename := c.Param("filename")
if filename == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "图片不存在"})
return
}
// 防止路径遍历攻击
filename = filepath.Base(filename)
dir, err := getImageDir()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "图片服务不可用"})
return
}
filePath := filepath.Join(dir, filename)
// 检查文件是否存在
info, err := os.Stat(filePath)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "图片不存在"})
return
}
// 设置缓存头:图片是静态资源,缓存 30 天
c.Header("Cache-Control", "public, max-age=2592000")
c.Header("ETag", fmt.Sprintf(`"%x-%x"`, info.ModTime().Unix(), info.Size()))
// 设置 Content-Type
ext := strings.ToLower(filepath.Ext(filename))
switch ext {
case ".png":
c.Header("Content-Type", "image/png")
case ".jpg", ".jpeg":
c.Header("Content-Type", "image/jpeg")
case ".gif":
c.Header("Content-Type", "image/gif")
case ".webp":
c.Header("Content-Type", "image/webp")
default:
c.Header("Content-Type", "application/octet-stream")
}
c.File(filePath)
}
// CleanupOldImages 清理超过 maxAge 的图片文件
func CleanupOldImages(maxAge time.Duration) error {
dir, err := getImageDir()
if err != nil {
return err
}
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
now := time.Now()
for _, entry := range entries {
if entry.IsDir() {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
if now.Sub(info.ModTime()) > maxAge {
os.Remove(filepath.Join(dir, entry.Name()))
}
}
return nil
}