Files
past-go/past-go/internal/upload/upload.go
T
2026-10-07 09:28:43 +08:00

195 lines
6.0 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 upload 对应 config.php 中的 handleUpload() 与路径辅助函数。
package upload
import (
"bytes"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"log/slog"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
// 上传错误码(对应 config.php UPLOAD_ERR_* 常量)
const (
ErrNoFileReceived = 1001
ErrFileFieldMissing = 1002
ErrHandleFailed = 1003
ErrRawBodySave = 1004
ErrAlbumNotFound = 1005
ErrNotLoggedIn = 1006
ErrNotBound = 1007
ErrMethodNotAllowed = 1008
ErrPartnerInvalid = 1009
ErrFileTooLarge = 1010
)
var allowedExts = map[string]bool{"jpg": true, "jpeg": true, "png": true, "gif": true, "webp": true}
// ImageChecker 图片内容安全检测(由 wechat 包实现)
type ImageChecker interface {
CheckImage(filePath, openid string) (bool, string)
}
type Uploader struct {
// Dir 上传根目录(UPLOAD_DIR)
Dir string
MaxSize int64
Checker ImageChecker
}
// Result 对应 handleUpload 返回的 ['path' => ...] 或 ['error' => ...]
type Result struct {
Path string // 相对路径,如 uploads/avatars/520001/20260101120000_ab12cd34.jpg
Error string
}
// AvatarPath 对应 avatarPath($displayId) → avatars/{displayId}
func AvatarPath(userID string) string { return "avatars/" + userID }
// CouplePath 对应 couplePath($spaceId, $type)
func CouplePath(spaceID, typ string) string {
if typ != "" {
return "couples/" + spaceID + "/" + typ
}
return "couples/" + spaceID
}
// UserFilePath 对应 userFilePath($userId, $type) → users/{userId}/{type}
func UserFilePath(userID int64, typ string) string {
p := fmt.Sprintf("users/%d", userID)
if typ != "" {
p += "/" + typ
}
return p
}
// NewFilename 对应 date('YmdHis') . '_' . bin2hex(random_bytes(4)) . '.' . ext
func NewFilename(ext string) string {
b := make([]byte, 4)
rand.Read(b)
return time.Now().Format("20060102150405") + "_" + hex.EncodeToString(b) + "." + ext
}
// Ext 取小写扩展名(不含点)
func Ext(name string) string {
return strings.ToLower(strings.TrimPrefix(filepath.Ext(name), "."))
}
// Handle 对应 handleUpload($file, $subDir, $prefixPath)
func (u *Uploader) Handle(fh *multipart.FileHeader, subDir, prefixPath string) Result {
if fh == nil {
return Result{Error: "未接收到文件"}
}
f, err := fh.Open()
if err != nil {
return Result{Error: "未接收到文件"}
}
defer f.Close()
return u.HandleReader(f, fh.Filename, fh.Size, subDir, prefixPath)
}
// HandleReader 与 Handle 相同的校验流程(大小/扩展名/MIME),输入为任意可 seek 的数据源
func (u *Uploader) HandleReader(f io.ReadSeeker, filename string, size int64, subDir, prefixPath string) Result {
if size > u.MaxSize {
return Result{Error: fmt.Sprintf("文件超过大小限制(%dMB)", u.MaxSize/1024/1024)}
}
ext := Ext(filename)
if !allowedExts[ext] {
return Result{Error: "不支持的文件类型(." + ext + "),仅支持: jpg,jpeg,png,gif,webp"}
}
// MIME 检测(对应 finfo):仅当既不是 image/* 也不是 octet-stream 时拒绝
head := make([]byte, 512)
n, _ := io.ReadFull(f, head)
// finfo 只返回主类型(不带 "; charset=...")
mime := http.DetectContentType(head[:n])
if i := strings.Index(mime, ";"); i >= 0 {
mime = strings.TrimSpace(mime[:i])
}
if !strings.HasPrefix(mime, "image/") && mime != "application/octet-stream" {
return Result{Error: "文件MIME类型不匹配: " + mime}
}
if _, err := f.Seek(0, io.SeekStart); err != nil {
return Result{Error: "文件读取失败"}
}
return u.save(f, "", ext, subDir, prefixPath)
}
// HandleBytes 对 HandleReader 的便捷封装(原始请求体 → 伪装成 $_FILES 走完整校验)
func (u *Uploader) HandleBytes(data []byte, filename, subDir, prefixPath string) Result {
return u.HandleReader(bytes.NewReader(data), filename, int64(len(data)), subDir, prefixPath)
}
// SaveRaw 保存原始二进制数据(photo_upload / set_bg_image 的 RAW BODY 兜底路径),不做扩展名/MIME 校验。
// namePrefix 为文件名前缀(如 "bg_")。
func (u *Uploader) SaveRaw(data []byte, namePrefix, ext, subDir, prefixPath string) Result {
return u.save(bytes.NewReader(data), namePrefix, ext, subDir, prefixPath)
}
func (u *Uploader) save(src io.Reader, namePrefix, ext, subDir, prefixPath string) Result {
rel := "uploads/"
dir := u.Dir
if prefixPath != "" {
rel += prefixPath + "/"
dir = filepath.Join(dir, filepath.FromSlash(prefixPath))
}
if subDir != "" {
rel += subDir + "/"
dir = filepath.Join(dir, filepath.FromSlash(subDir))
}
if err := os.MkdirAll(dir, 0755); err != nil {
return Result{Error: "文件保存失败。无法写入目录: " + dir + "; 系统错误: " + err.Error()}
}
name := namePrefix + NewFilename(ext)
dest := filepath.Join(dir, name)
out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return Result{Error: "文件保存失败。无法写入目录: " + dir + "; 系统错误: " + err.Error()}
}
if _, err := io.Copy(out, src); err != nil {
out.Close()
os.Remove(dest)
return Result{Error: "文件保存失败。系统错误: " + err.Error()}
}
out.Close()
slog.Info("upload saved", "path", rel+name)
if u.Checker != nil {
if pass, msg := u.Checker.CheckImage(dest, ""); !pass {
os.Remove(dest)
return Result{Error: msg}
}
}
return Result{Path: rel + name}
}
// Remove 删除 uploads 下的文件(path 为 DB 中存的相对路径 uploads/...);忽略错误。
func (u *Uploader) Remove(relPath string) {
if relPath == "" || strings.Contains(relPath, "..") {
return
}
p := strings.TrimPrefix(relPath, "uploads/")
if p == relPath {
return
}
os.Remove(filepath.Join(u.Dir, filepath.FromSlash(p)))
}
// Abs 返回相对路径对应的绝对路径
func (u *Uploader) Abs(relPath string) string {
p := strings.TrimPrefix(relPath, "uploads/")
return filepath.Join(u.Dir, filepath.FromSlash(p))
}
// Exists 文件是否存在
func (u *Uploader) Exists(relPath string) bool {
st, err := os.Stat(u.Abs(relPath))
return err == nil && !st.IsDir()
}