71f0a1abdb
- 所有Go模块路径从 github.com/yourname/cyrene-ai 迁移到 git.yeij.top/AskaEth/Cyrene - 5个Go Dockerfile添加 GOPROXY=https://goproxy.cn,direct 解决国内构建问题 - ai-core go.mod 添加 pkg/plugins replace 指令 - Caddyfile 简化为 http:// 通配 + handle 保留 /api 前缀 - ethend Dockerfile 适配 (npm install + 仅 COPY package.json) - ethend 新增 RUNNING_IN_DOCKER 环境变量,健康检查改用Docker服务名 - ethend 数据库状态检查支持Docker hostname (postgres/redis/qdrant/minio) - process-manager 新增 CONTAINER_SVC_MAP + Docker模式自动检测 - 统一 docker-compose.dev.db.yml 卷名 (pg_data/redis_data/qdrant_data/minio_data) - docker-compose.yml ethend服务挂载docker.sock + 端口变量化 - 清理 .env 统一后的残留文件与提示信息 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
707 lines
19 KiB
Go
707 lines
19 KiB
Go
package handler
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"image/jpeg"
|
|
"image/png"
|
|
"io"
|
|
"git.yeij.top/AskaEth/Cyrene/pkg/logger"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene/gateway/internal/middleware"
|
|
"git.yeij.top/AskaEth/Cyrene/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 {
|
|
logger.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 {
|
|
logger.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)
|
|
logger.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)
|
|
logger.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)
|
|
logger.Printf("[FileHandler] 创建文件记录失败: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建文件记录失败", "errorType": "db_error"})
|
|
return
|
|
}
|
|
|
|
logger.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 {
|
|
logger.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 {
|
|
logger.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 {
|
|
logger.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 {
|
|
logger.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) {
|
|
logger.Printf("[FileHandler] 删除磁盘文件失败 (stored_path=%s): %v", f.StoredPath, err)
|
|
}
|
|
|
|
// 删除数据库记录
|
|
if err := h.store.DeleteFile(fileID); err != nil {
|
|
logger.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 {
|
|
logger.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 {
|
|
logger.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})
|
|
}
|
|
}
|
|
}
|
|
|