package handler import ( "crypto/rand" "crypto/sha256" "encoding/hex" "io" "net/http" "os" "path/filepath" "strconv" "time" "github.com/gin-gonic/gin" "gorm.io/gorm" "nl-pms-api/internal/config" "nl-pms-api/internal/model" ) // adminUserID 是 code_count 体系的超级管理员账号(users.id=1),可见/可管全部文件。 const adminUserID = 1 // FileHandler 提供图片上传与素材库管理:内容落盘 storage_dir,元数据入 pms_files 表。 // 身份沿用 code-count 的内网信任模型:客户端自报 userId/teamId,服务端据 // code_count 库的 team_members / users 表判定管理范围(防误操作,不防伪造)。 type FileHandler struct { DB *gorm.DB Cfg *config.Config } // extByMime 是允许上传的图片类型白名单(按内容嗅探判定,不信任扩展名)。 var extByMime = map[string]string{ "image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp", } // Upload 处理 POST /api/v1/files:multipart 字段 file 必填,kind(avatar|content)、 // userId、teamId 可选。同一归属(userId+teamId)重复上传同内容直接复用已有记录(秒传); // 不同归属各自落盘,保证"删除自己的素材"不影响他人。 // 成功返回 {id, name, url, size, mime},url 可直接放进 / Markdown。 func (h *FileHandler) Upload(c *gin.Context) { fh, err := c.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "FILE_REQUIRED"}) return } if fh.Size > h.Cfg.MaxUploadBytes() { c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "FILE_TOO_LARGE"}) return } f, err := fh.Open() if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "FILE_READ_FAILED"}) return } defer f.Close() data, err := io.ReadAll(io.LimitReader(f, h.Cfg.MaxUploadBytes()+1)) if err != nil || int64(len(data)) > h.Cfg.MaxUploadBytes() { c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "FILE_TOO_LARGE"}) return } mime := http.DetectContentType(data) ext, ok := extByMime[mime] if !ok { c.JSON(http.StatusUnsupportedMediaType, gin.H{"error": "UNSUPPORTED_TYPE"}) return } uid := parseID(c.PostForm("userId")) tid := parseID(c.PostForm("teamId")) sum := hex.EncodeToString(func() []byte { s := sha256.Sum256(data); return s[:] }()) var rec model.File if h.DB.Where("sha256 = ? AND user_id = ? AND team_id = ?", sum, uid, tid).First(&rec).Error == nil { c.JSON(http.StatusOK, h.fileResponse(c, rec)) return } name := storedName(ext) full := filepath.Join(h.Cfg.StorageDir, filepath.FromSlash(name)) if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "SAVE_FAILED"}) return } if err := os.WriteFile(full, data, 0644); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "SAVE_FAILED"}) return } rec = model.File{ Name: name, Original: clip(filepath.Base(fh.Filename), 255), Mime: mime, Size: int64(len(data)), SHA256: sum, UserID: uid, TeamID: tid, Kind: normalizeKind(c.PostForm("kind")), CreatedAt: time.Now().UTC().Format(time.RFC3339), } if err := h.DB.Create(&rec).Error; err != nil { os.Remove(full) c.JSON(http.StatusInternalServerError, gin.H{"error": "SAVE_FAILED"}) return } c.JSON(http.StatusOK, h.fileResponse(c, rec)) } // fileItem 是素材库列表项(pms_files 联 users 取上传者名)。 type fileItem struct { ID int64 `json:"id"` Name string `json:"name"` Original string `json:"original"` Mime string `json:"mime"` Size int64 `json:"size"` UserID int64 `json:"userId"` TeamID int64 `json:"teamId"` Kind string `json:"kind"` CreatedAt string `json:"createdAt"` Username string `json:"username"` URL string `json:"url" gorm:"-"` } // List 处理 GET /api/v1/files:素材库分页列表。 // scope=mine 看自己(任何登录用户);scope=team 看指定团队(需为该团队 owner/admin); // scope=all 看全部(仅 userId=1)。按 id 倒序分页。 func (h *FileHandler) List(c *gin.Context) { uid := parseID(c.Query("userId")) if uid <= 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "USER_REQUIRED"}) return } var where func(*gorm.DB) *gorm.DB switch c.Query("scope") { case "mine": where = func(db *gorm.DB) *gorm.DB { return db.Where("pms_files.user_id = ?", uid) } case "team": tid := parseID(c.Query("teamId")) if !h.isTeamAdmin(tid, uid) { c.JSON(http.StatusForbidden, gin.H{"error": "FORBIDDEN"}) return } where = func(db *gorm.DB) *gorm.DB { return db.Where("pms_files.team_id = ?", tid) } case "all": if uid != adminUserID { c.JSON(http.StatusForbidden, gin.H{"error": "FORBIDDEN"}) return } where = func(db *gorm.DB) *gorm.DB { return db } default: c.JSON(http.StatusBadRequest, gin.H{"error": "BAD_SCOPE"}) return } page := parseID(c.Query("page")) if page < 1 { page = 1 } size := parseID(c.Query("pageSize")) if size < 1 || size > 100 { size = 24 } var total int64 if err := h.DB.Table("pms_files").Scopes(where).Count(&total).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "QUERY_FAILED"}) return } items := []fileItem{} err := h.DB.Table("pms_files").Scopes(where). Select("pms_files.id, pms_files.name, pms_files.original, pms_files.mime, pms_files.size, pms_files.user_id, pms_files.team_id, pms_files.kind, pms_files.created_at, COALESCE(u.username,'') AS username"). Joins("LEFT JOIN users u ON u.id = pms_files.user_id"). Order("pms_files.id DESC").Limit(int(size)).Offset(int((page - 1) * size)). Scan(&items).Error if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "QUERY_FAILED"}) return } for i := range items { items[i].URL = h.publicURL(c, items[i].Name) } c.JSON(http.StatusOK, gin.H{"total": total, "items": items}) } // Delete 处理 DELETE /api/v1/files/:id:删除记录与磁盘文件。 // 允许:本人、超管(userId=1)、该文件归属团队的 owner/admin。 func (h *FileHandler) Delete(c *gin.Context) { uid := parseID(c.Query("userId")) id := parseID(c.Param("id")) var rec model.File if h.DB.First(&rec, id).Error != nil { c.JSON(http.StatusNotFound, gin.H{"error": "NOT_FOUND"}) return } allowed := uid == adminUserID || (uid > 0 && rec.UserID == uid) || (rec.TeamID > 0 && h.isTeamAdmin(rec.TeamID, uid)) if !allowed { c.JSON(http.StatusForbidden, gin.H{"error": "FORBIDDEN"}) return } if err := h.DB.Delete(&model.File{}, id).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "DELETE_FAILED"}) return } // 每条记录独占一个文件(name 唯一),删记录即可删盘;失败不影响结果(孤儿文件可人工清理)。 _ = os.Remove(filepath.Join(h.Cfg.StorageDir, filepath.FromSlash(rec.Name))) c.JSON(http.StatusOK, gin.H{"ok": true}) } // isTeamAdmin 查 code_count 库的 team_members:该用户是否为团队 owner/admin。 func (h *FileHandler) isTeamAdmin(teamID, userID int64) bool { if teamID <= 0 || userID <= 0 { return false } var n int64 h.DB.Table("team_members"). Where("team_id = ? AND user_id = ? AND role IN ('owner','admin')", teamID, userID). Count(&n) return n > 0 } func (h *FileHandler) fileResponse(c *gin.Context, f model.File) gin.H { return gin.H{"id": f.ID, "name": f.Name, "url": h.publicURL(c, f.Name), "size": f.Size, "mime": f.Mime, "teamId": f.TeamID} } // publicURL 拼接文件公开访问地址:优先配置的 base_url,否则按本次请求推断。 func (h *FileHandler) publicURL(c *gin.Context, name string) string { base := h.Cfg.BaseURL if base == "" { scheme := "http" if c.Request.TLS != nil { scheme = "https" } base = scheme + "://" + c.Request.Host } return base + "/files/" + name } // storedName 生成不可枚举的存储相对路径(日期目录 + 128 位随机 hex), // 文件公开可读但路径不可猜测,等效 capability URL。 func storedName(ext string) string { b := make([]byte, 16) _, _ = rand.Read(b) return time.Now().UTC().Format("2006/01/02") + "/" + hex.EncodeToString(b) + ext } func normalizeKind(k string) string { if k == "avatar" || k == "content" { return k } return "" } func parseID(s string) int64 { n, _ := strconv.ParseInt(s, 10, 64) if n < 0 { return 0 } return n } func clip(s string, n int) string { if len(s) > n { return s[:n] } return s }