Files
nl-pms-api/internal/handler/file.go
2026-08-15 07:41:11 +08:00

266 lines
8.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}