Files
nl-pms-api/internal/handler/file.go

266 lines
8.3 KiB
Go
Raw Normal View History

2026-08-15 07:41:11 +08:00
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/filesmultipart 字段 file 必填kindavatar|content
// userId、teamId 可选。同一归属userId+teamId重复上传同内容直接复用已有记录秒传
// 不同归属各自落盘,保证"删除自己的素材"不影响他人。
// 成功返回 {id, name, url, size, mime}url 可直接放进 <img src> / 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
}