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个后台调度器
This commit is contained in:
@@ -0,0 +1,706 @@
|
||||
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})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user