Files
Cyrene/backend/gateway/internal/handler/file_handler.go
T
AskaEth bcf4d4e621 feat: 第五轮开发 - 14项未来路线图功能完整实现
W1-W14 全部完成:
- W1: 消息搜索 (ILIKE全文检索 + SearchModal)
- W2: 对话导出 (JSON/Markdown/TXT三格式)
- W3: 记忆时间线 DevTools 可视化
- W4: 通知推送系统 (WebSocket + Browser Notification API)
- W5: 定时提醒 (30s轮询 + 重复提醒 + WebSocket推送)
- W6: 每日简报 (08:00自动生成: 天气+新闻+提醒+AI摘要)
- W7: IoT场景自动化 (规则引擎 10s轮询 + 条件评估 + 场景执行)
- W8: 语音输入 (浏览器 Speech Recognition API)
- W9: STT服务 (voice-service + whisper.cpp)
- W10: TTS服务 (浏览器 Speech Synthesis + edge-tts三档回退)
- W11: 文件管理 (上传/下载/缩略图/纯Go bilinear缩放)
- W12: 知识库RAG (PostgreSQL tsvector + 文档分块 + 检索)
- W13: 多模态 (图片上传+分析: Vision API + 本地Go分析回退)
- W14: PWA (Service Worker + 离线页 + install prompt)

总计: 6个Go微服务 + 10+前端组件 + 10+ PostgreSQL表 + 4个后台调度器
2026-05-19 12:01:09 +08:00

707 lines
19 KiB
Go

package handler
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"image"
"image/color"
"image/jpeg"
"image/png"
"io"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/yourname/cyrene-ai/gateway/internal/middleware"
"github.com/yourname/cyrene-ai/gateway/internal/store"
)
// FileHandler 文件管理处理器
type FileHandler struct {
store *store.FileStore
uploadDir string
}
// NewFileHandler 创建文件处理器
func NewFileHandler(s *store.FileStore) *FileHandler {
return &FileHandler{
store: s,
uploadDir: "./uploads",
}
}
// ========== 允许的文件类型 ==========
var allowedMimeTypes = map[string]bool{
// 图片
"image/jpeg": true,
"image/png": true,
"image/gif": true,
"image/webp": true,
"image/svg+xml": true,
// 文档
"application/pdf": true,
"text/plain": true,
"text/markdown": true,
"application/msword": true,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": true,
// 音频
"audio/mpeg": true,
"audio/wav": true,
"audio/ogg": true,
"audio/webm": true,
// 视频
"video/mp4": true,
"video/webm": true,
}
var allowedExtensions = map[string]string{
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".svg": "image/svg+xml",
".pdf": "application/pdf",
".txt": "text/plain",
".md": "text/markdown",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".mp3": "audio/mpeg",
".wav": "audio/wav",
".ogg": "audio/ogg",
".mp4": "video/mp4",
}
const maxFileSize = 20 * 1024 * 1024 // 20MB
// ========== POST /api/v1/files/upload ==========
// Upload 处理文件上传
func (h *FileHandler) Upload(c *gin.Context) {
userID := middleware.GetUserID(c)
// Nil store guard — 数据库不可用时拒绝上传
if h.store == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "文件存储不可用,数据库未连接", "errorType": "service_unavailable"})
return
}
// 获取上传的文件
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "未找到上传文件", "errorType": "missing_file"})
return
}
defer file.Close()
// 检查文件大小
if header.Size > maxFileSize {
c.JSON(http.StatusBadRequest, gin.H{
"error": "文件大小超过限制 (最大 20MB)",
"errorType": "file_too_large",
})
return
}
// 验证文件类型
mimeType := header.Header.Get("Content-Type")
ext := strings.ToLower(filepath.Ext(header.Filename))
if mimeType == "" || mimeType == "application/octet-stream" {
// 尝试从扩展名推断
if inferred, ok := allowedExtensions[ext]; ok {
mimeType = inferred
}
}
if !allowedMimeTypes[mimeType] {
// 再尝试通过扩展名检查
if _, ok := allowedExtensions[ext]; !ok {
c.JSON(http.StatusBadRequest, gin.H{
"error": "不支持的文件类型: " + mimeType,
"errorType": "unsupported_type",
})
return
}
mimeType = allowedExtensions[ext]
}
// 安全化文件名:移除路径分隔符和特殊字符
safeFilename := sanitizeFilename(header.Filename)
// 生成文件ID (crypto/rand UUID v4)
fileID := generateUUID()
// 创建按日期组织的目录
dateDir := time.Now().Format("2006-01-02")
storedDir := filepath.Join(h.uploadDir, dateDir)
if err := os.MkdirAll(storedDir, 0755); err != nil {
log.Printf("[FileHandler] 创建上传目录失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建上传目录失败", "errorType": "server_error"})
return
}
// 存储路径:以UUID+原始扩展名保存
storedFilename := fileID + ext
storedPath := filepath.Join(storedDir, storedFilename)
// 计算 SHA256 hash
hasher := sha256.New()
teeReader := io.TeeReader(file, hasher)
// 保存到磁盘
dst, err := os.Create(storedPath)
if err != nil {
log.Printf("[FileHandler] 创建文件失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败", "errorType": "server_error"})
return
}
defer dst.Close()
written, err := io.Copy(dst, teeReader)
if err != nil {
os.Remove(storedPath)
log.Printf("[FileHandler] 写入文件失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "写入文件失败", "errorType": "server_error"})
return
}
hash := hex.EncodeToString(hasher.Sum(nil))
// 去重检查:如果存在相同hash的文件,复用已有记录
if existing, err := h.store.GetFileByHash(hash); err == nil && existing != nil {
// 删除刚保存的重复文件
os.Remove(storedPath)
log.Printf("[FileHandler] 文件去重: 复用已有文件 %s (hash=%s)", existing.ID, hash[:16])
c.JSON(http.StatusOK, gin.H{
"id": existing.ID,
"filename": existing.Filename,
"mime_type": existing.MimeType,
"size": existing.Size,
"url": fmt.Sprintf("/api/v1/files/%s/download", existing.ID),
"dedup": true,
})
return
}
// 创建数据库记录
fileRecord := &store.File{
ID: fileID,
UserID: userID,
Filename: safeFilename,
StoredPath: storedPath,
MimeType: mimeType,
Size: written,
Hash: hash,
IsPublic: false,
CreatedAt: time.Now(),
}
if err := h.store.CreateFile(fileRecord); err != nil {
os.Remove(storedPath)
log.Printf("[FileHandler] 创建文件记录失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建文件记录失败", "errorType": "db_error"})
return
}
log.Printf("[FileHandler] 文件上传成功: %s (%s, %d bytes, hash=%s)", fileID, safeFilename, written, hash[:16])
c.JSON(http.StatusCreated, gin.H{
"id": fileID,
"filename": safeFilename,
"mime_type": mimeType,
"size": written,
"url": fmt.Sprintf("/api/v1/files/%s/download", fileID),
})
}
// ========== GET /api/v1/files ==========
// List 列出用户的所有文件 (支持分页)
func (h *FileHandler) List(c *gin.Context) {
userID := middleware.GetUserID(c)
if h.store == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "文件存储不可用", "errorType": "service_unavailable"})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
files, total, err := h.store.GetUserFiles(userID, page, limit)
if err != nil {
log.Printf("[FileHandler] 查询文件列表失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询文件列表失败", "errorType": "db_error"})
return
}
// 转换为响应格式
result := make([]gin.H, 0, len(files))
for _, f := range files {
item := gin.H{
"id": f.ID,
"user_id": f.UserID,
"filename": f.Filename,
"mime_type": f.MimeType,
"size": f.Size,
"hash": f.Hash,
"is_public": f.IsPublic,
"created_at": f.CreatedAt.UnixMilli(),
"url": fmt.Sprintf("/api/v1/files/%s/download", f.ID),
}
// 图片类型添加缩略图URL
if isImageType(f.MimeType) {
item["thumbnail_url"] = fmt.Sprintf("/api/v1/files/%s/thumbnail", f.ID)
}
result = append(result, item)
}
c.JSON(http.StatusOK, gin.H{
"files": result,
"total": total,
"page": page,
"limit": limit,
})
}
// ========== GET /api/v1/files/:id ==========
// Get 获取文件元数据
func (h *FileHandler) Get(c *gin.Context) {
userID := middleware.GetUserID(c)
fileID := c.Param("id")
if h.store == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "文件存储不可用", "errorType": "service_unavailable"})
return
}
f, err := h.store.GetFile(fileID)
if err != nil {
log.Printf("[FileHandler] 查询文件失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询文件失败", "errorType": "db_error"})
return
}
if f == nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "文件不存在",
"errorType": "file_not_found",
})
return
}
// 检查权限
if f.UserID != userID && !f.IsPublic {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问此文件", "errorType": "access_denied"})
return
}
item := gin.H{
"id": f.ID,
"user_id": f.UserID,
"filename": f.Filename,
"mime_type": f.MimeType,
"size": f.Size,
"hash": f.Hash,
"is_public": f.IsPublic,
"created_at": f.CreatedAt.UnixMilli(),
"url": fmt.Sprintf("/api/v1/files/%s/download", f.ID),
}
if isImageType(f.MimeType) {
item["thumbnail_url"] = fmt.Sprintf("/api/v1/files/%s/thumbnail", f.ID)
}
c.JSON(http.StatusOK, item)
}
// ========== GET /api/v1/files/:id/download ==========
// Download 下载文件
func (h *FileHandler) Download(c *gin.Context) {
userID := middleware.GetUserID(c)
fileID := c.Param("id")
if h.store == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "文件存储不可用", "errorType": "service_unavailable"})
return
}
f, err := h.store.GetFile(fileID)
if err != nil {
log.Printf("[FileHandler] 查询文件失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询文件失败", "errorType": "db_error"})
return
}
if f == nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "文件不存在",
"errorType": "file_not_found",
})
return
}
// 检查权限
if f.UserID != userID && !f.IsPublic {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问此文件", "errorType": "access_denied"})
return
}
// 检查文件在磁盘上是否存在
if _, err := os.Stat(f.StoredPath); os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{
"error": "文件实体不存在(可能已被清理)",
"errorType": "file_missing",
})
return
}
// 设置 Content-Disposition
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, f.Filename))
c.Header("Content-Type", f.MimeType)
c.File(f.StoredPath)
}
// ========== DELETE /api/v1/files/:id ==========
// Delete 删除文件
func (h *FileHandler) Delete(c *gin.Context) {
userID := middleware.GetUserID(c)
fileID := c.Param("id")
if h.store == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "文件存储不可用", "errorType": "service_unavailable"})
return
}
f, err := h.store.GetFile(fileID)
if err != nil {
log.Printf("[FileHandler] 查询文件失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询文件失败", "errorType": "db_error"})
return
}
if f == nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "文件不存在",
"errorType": "file_not_found",
})
return
}
// 检查权限
if f.UserID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "无权删除此文件", "errorType": "access_denied"})
return
}
// 删除磁盘上的文件(忽略错误,可能已被删除)
if err := os.Remove(f.StoredPath); err != nil && !os.IsNotExist(err) {
log.Printf("[FileHandler] 删除磁盘文件失败 (stored_path=%s): %v", f.StoredPath, err)
}
// 删除数据库记录
if err := h.store.DeleteFile(fileID); err != nil {
log.Printf("[FileHandler] 删除文件记录失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除文件记录失败", "errorType": "db_error"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
}
// ========== GET /api/v1/files/:id/thumbnail ==========
// Thumbnail 返回文件缩略图
func (h *FileHandler) Thumbnail(c *gin.Context) {
userID := middleware.GetUserID(c)
fileID := c.Param("id")
if h.store == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "文件存储不可用", "errorType": "service_unavailable"})
return
}
f, err := h.store.GetFile(fileID)
if err != nil {
log.Printf("[FileHandler] 查询文件失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询文件失败", "errorType": "db_error"})
return
}
if f == nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "文件不存在",
"errorType": "file_not_found",
})
return
}
if f.UserID != userID && !f.IsPublic {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问此文件", "errorType": "access_denied"})
return
}
// 如果是图片,生成缩略图
if isImageType(f.MimeType) && f.MimeType != "image/svg+xml" {
if thumbData, contentType, err := generateThumbnail(f.StoredPath, f.MimeType); err == nil {
c.Header("Content-Type", contentType)
c.Header("Cache-Control", "public, max-age=86400")
c.Data(http.StatusOK, contentType, thumbData)
return
} else {
log.Printf("[FileHandler] 生成缩略图失败: %v", err)
}
}
// 非图片文件或缩略图生成失败,返回占位图标 SVG
placeholder := generatePlaceholderSVG(f.MimeType)
c.Header("Content-Type", "image/svg+xml")
c.Header("Cache-Control", "public, max-age=86400")
c.Data(http.StatusOK, "image/svg+xml", []byte(placeholder))
}
// ========== 辅助函数 ==========
// isImageType 判断是否图片类型
func isImageType(mimeType string) bool {
return strings.HasPrefix(mimeType, "image/")
}
// sanitizeFilename 安全化文件名:移除路径分隔符、特殊字符
var unsafeChars = regexp.MustCompile(`[\\/:*?"<>|]`)
func sanitizeFilename(name string) string {
// 移除路径分隔符和Windows特殊字符
name = unsafeChars.ReplaceAllString(name, "_")
// 限制长度
if len(name) > 255 {
ext := filepath.Ext(name)
base := name[:255-len(ext)]
name = base + ext
}
// 为空时给默认名
if name == "" || name == "." || name == ".." {
name = "unnamed_file"
}
return name
}
// generateThumbnail 使用 Go 标准库生成缩略图 (最大 300x300)
func generateThumbnail(filePath, mimeType string) ([]byte, string, error) {
// 打开源文件
srcFile, err := os.Open(filePath)
if err != nil {
return nil, "", fmt.Errorf("打开文件失败: %w", err)
}
defer srcFile.Close()
// 解码图像
var srcImg image.Image
switch mimeType {
case "image/jpeg":
srcImg, err = jpeg.Decode(srcFile)
case "image/png":
srcImg, err = png.Decode(srcFile)
default:
// 尝试通用解码
srcImg, _, err = image.Decode(srcFile)
}
if err != nil {
return nil, "", fmt.Errorf("解码图像失败: %w", err)
}
// 计算缩略图尺寸
bounds := srcImg.Bounds()
srcW := bounds.Dx()
srcH := bounds.Dy()
maxDim := 300
newW, newH := srcW, srcH
if srcW > maxDim || srcH > maxDim {
if srcW > srcH {
newW = maxDim
newH = srcH * maxDim / srcW
} else {
newH = maxDim
newW = srcW * maxDim / srcH
}
}
if newW < 1 {
newW = 1
}
if newH < 1 {
newH = 1
}
// 使用标准库双线性缩放
thumbImg := image.NewRGBA(image.Rect(0, 0, newW, newH))
scaleBilinear(thumbImg, srcImg)
// 编码为 JPEG 输出
var buf strings.Builder
errWriter := &stringWriter{&buf}
if err := jpeg.Encode(errWriter, thumbImg, &jpeg.Options{Quality: 80}); err != nil {
return nil, "", fmt.Errorf("编码缩略图失败: %w", err)
}
return []byte(buf.String()), "image/jpeg", nil
}
// stringWriter 实现 io.Writer 到 strings.Builder
type stringWriter struct {
b *strings.Builder
}
func (w *stringWriter) Write(p []byte) (int, error) {
return w.b.Write(p)
}
// generatePlaceholderSVG 为非图片文件生成占位图标 SVG
func generatePlaceholderSVG(mimeType string) string {
var icon, color string
switch {
case strings.HasPrefix(mimeType, "audio/"):
icon = "🎵"
color = "#8B5CF6" // purple
case strings.HasPrefix(mimeType, "video/"):
icon = "🎬"
color = "#EF4444" // red
case strings.HasPrefix(mimeType, "application/pdf"):
icon = "📄"
color = "#F59E0B" // amber
case strings.HasPrefix(mimeType, "text/"):
icon = "📝"
color = "#3B82F6" // blue
default:
icon = "📎"
color = "#6B7280" // gray
}
return fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="300" height="300" viewBox="0 0 300 300">
<rect width="300" height="300" fill="%s" opacity="0.1"/>
<text x="150" y="160" text-anchor="middle" font-size="64" fill="%s">%s</text>
<text x="150" y="220" text-anchor="middle" font-size="16" fill="%s" opacity="0.7">%s</text>
</svg>`, color, color, icon, color, getMimeTypeShort(mimeType))
}
// getMimeTypeShort 获取MIME类型简称
func getMimeTypeShort(mimeType string) string {
parts := strings.SplitN(mimeType, "/", 2)
if len(parts) < 2 {
return mimeType
}
return strings.ToUpper(parts[1])
}
// generateUUID 使用 crypto/rand 生成 UUID v4 格式的字符串
func generateUUID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
// 降级方案:基于时间戳 + 随机数的唯一标识
b = make([]byte, 16)
ts := time.Now().UnixNano()
for i := 0; i < 8; i++ {
b[i] = byte(ts >> (i * 8))
}
// 用简单 PRNG 填充剩余字节
for i := 8; i < 16; i++ {
b[i] = byte((ts * int64(i+1)) % 256)
}
}
// 设置 UUID v4 版本位 (version = 4)
b[6] = (b[6] & 0x0f) | 0x40
// 设置 UUID variant 位 (variant = 10xx)
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
// scaleBilinear 使用双线性插值将 src 图像缩放到 dst 的尺寸 (纯标准库实现)
func scaleBilinear(dst *image.RGBA, src image.Image) {
dstBounds := dst.Bounds()
srcBounds := src.Bounds()
dstW := dstBounds.Dx()
dstH := dstBounds.Dy()
srcW := srcBounds.Dx()
srcH := srcBounds.Dy()
// 计算缩放比例
scaleX := float64(srcW) / float64(dstW)
scaleY := float64(srcH) / float64(dstH)
for dy := 0; dy < dstH; dy++ {
for dx := 0; dx < dstW; dx++ {
// 源图像中的浮点坐标
sx := float64(dx)*scaleX + float64(srcBounds.Min.X)
sy := float64(dy)*scaleY + float64(srcBounds.Min.Y)
// 四个邻近像素的整数坐标
x0 := int(sx)
y0 := int(sy)
x1 := x0 + 1
y1 := y0 + 1
// 边界限制
if x0 < srcBounds.Min.X {
x0 = srcBounds.Min.X
}
if x1 >= srcBounds.Max.X {
x1 = srcBounds.Max.X - 1
}
if x0 >= srcBounds.Max.X {
x0 = srcBounds.Max.X - 1
}
if x1 < srcBounds.Min.X {
x1 = srcBounds.Min.X
}
if y0 < srcBounds.Min.Y {
y0 = srcBounds.Min.Y
}
if y1 >= srcBounds.Max.Y {
y1 = srcBounds.Max.Y - 1
}
if y0 >= srcBounds.Max.Y {
y0 = srcBounds.Max.Y - 1
}
if y1 < srcBounds.Min.Y {
y1 = srcBounds.Min.Y
}
// 插值权重
fracX := sx - float64(x0)
fracY := sy - float64(y0)
// 四个角的 RGBA 值
r00, g00, b00, a00 := src.At(x0, y0).RGBA()
r10, g10, b10, a10 := src.At(x1, y0).RGBA()
r01, g01, b01, a01 := src.At(x0, y1).RGBA()
r11, g11, b11, a11 := src.At(x1, y1).RGBA()
// 双线性插值 (在 0-65535 范围内进行)
interp := func(c00, c10, c01, c11 uint32) uint8 {
top := float64(c00)*(1-fracX) + float64(c10)*fracX
bot := float64(c01)*(1-fracX) + float64(c11)*fracX
val := top*(1-fracY) + bot*fracY
return uint8(val / 256)
}
r := interp(r00, r10, r01, r11)
g := interp(g00, g10, g01, g11)
b := interp(b00, b10, b01, b11)
a := interp(a00, a10, a01, a11)
dst.SetRGBA(dx+int(dstBounds.Min.X), dy+int(dstBounds.Min.Y), color.RGBA{r, g, b, a})
}
}
}