Files
qitongxue-api/internal/logic/upload.go

413 lines
15 KiB
Go
Raw Normal View History

2026-09-29 10:57:01 +08:00
package logic
import (
"context"
"crypto/md5"
"encoding/hex"
"io"
"path/filepath"
"strings"
2026-09-30 17:01:18 +08:00
"unicode/utf8"
2026-09-29 10:57:01 +08:00
2026-09-30 17:01:18 +08:00
"github.com/gogf/gf/v2/database/gdb"
2026-09-29 10:57:01 +08:00
"github.com/gogf/gf/v2/errors/gcode"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gfile"
"github.com/gogf/gf/v2/os/gtime"
2026-09-30 17:01:18 +08:00
adminv1 "tool-api/api/admin/v1"
2026-09-29 10:57:01 +08:00
v1 "tool-api/api/user/v1"
"tool-api/internal/consts"
)
// ============================================================================
2026-09-30 17:01:18 +08:00
// 头像上传(N3 / §2.2)+ 通用上传(封面/附件,迭代-2026-09-29)
2026-09-29 10:57:01 +08:00
//
2026-09-30 17:01:18 +08:00
// 本机静态目录方案(U1 已裁定接受):落盘 <StaticDir>/<scene>/<yyyyMM>/<md5>.<ext>。
//
// 📌 图片域名不落库(迭代-2026-09-29 裁定):数据库只存相对路径(/static/...),
// 对外输出一律经 absoluteURL 用 settings 的 site.base_url 拼接前缀;未配置时
// 回退为按请求 Host 现拼(本地联调零配置)。
2026-09-29 10:57:01 +08:00
//
// 已知限制:单实例部署正常;多实例(水平扩容)下非落盘实例会 404 —— 上线前须换对象存储。
//
// ⚠️ 目录常量 StaticDir 导出给 cmd 包:AddStaticPath 在目录不存在时**直接 FATAL 退出、不降级**,
// 必须由 cmd.go 启动期先 gfile.Mkdir 再注册静态路由(与 QrCodeDir 同一套坑与治法)。
// ============================================================================
// StaticDir 头像/静态资源落盘目录(相对服务运行目录)。导出给 cmd 包做启动期建目录(单一来源)。
const StaticDir = "manifest/static"
// staticURLPrefix 静态资源对外 URL 前缀(由 cmd.go 的 s.AddStaticPath 提供)。
const staticURLPrefix = "/static"
// AvatarMaxBytes 头像体积上限(5MB)。
const AvatarMaxBytes = 5 * 1024 * 1024
2026-09-30 17:01:18 +08:00
// AttachmentMaxBytes 通用附件体积上限(20MB)。
const AttachmentMaxBytes = 20 * 1024 * 1024
2026-09-29 10:57:01 +08:00
// avatarAllowedExt 允许的图片扩展名(小写,含点)。
var avatarAllowedExt = map[string]bool{
".jpg": true,
".jpeg": true,
".png": true,
".webp": true,
}
2026-09-30 17:01:18 +08:00
// attachmentAllowedExt 通用附件允许的扩展名(小写,含点)。
var attachmentAllowedExt = map[string]bool{
".jpg": true, ".jpeg": true, ".png": true, ".webp": true, ".gif": true,
".pdf": true, ".zip": true,
".doc": true, ".docx": true, ".xls": true, ".xlsx": true, ".ppt": true, ".pptx": true,
".txt": true, ".md": true,
}
2026-09-29 10:57:01 +08:00
// errUploadInvalid 4016:上传校验失败(体积 / 格式)。
func errUploadInvalid(msg string) error {
return gerror.NewCode(gcode.New(consts.CodeUploadInvalid, "", nil), msg)
}
// errUploadFailed 4017:上传存储失败(写盘 / 目录异常),给可读中文提示,绝不静默失败。
func errUploadFailed(msg string) error {
return gerror.NewCode(gcode.New(consts.CodeUploadFailed, "", nil), msg)
}
// AvatarUploadFromRequest 处理 multipart 头像上传:取 file 字段 → 校验 → 落盘 → 返回可直开 URL。
//
// multipart 表单文件字段名固定为 `file`(跨端契约)。
func AvatarUploadFromRequest(ctx context.Context) (*v1.AvatarUploadRes, error) {
req := g.RequestFromCtx(ctx)
if req == nil {
return nil, errUploadFailed("头像上传失败,请重试")
}
file := req.GetUploadFile("file")
if file == nil {
return nil, errUploadInvalid("请选择要上传的图片")
}
f, err := file.Open()
if err != nil {
return nil, errUploadFailed("头像上传失败,请重试")
}
defer func() { _ = f.Close() }()
// 多读 1 字节用于判定「超过上限」,避免把超大文件全读进内存。
data, err := io.ReadAll(io.LimitReader(f, AvatarMaxBytes+1))
if err != nil {
return nil, errUploadFailed("头像上传失败,请重试")
}
return AvatarUpload(ctx, file.Filename, data)
}
// AvatarUpload 校验并落盘头像,返回「浏览器可直接打开」的绝对 URL。
//
// 落盘名用内容 md5 → 同一张图重复上传天然去重,且文件名不含用户输入(无路径穿越风险)。
2026-09-30 17:01:18 +08:00
// 同时登记附件表(scene=avatar,上传者=当前用户),供后台附件管理统一查看。
2026-09-29 10:57:01 +08:00
func AvatarUpload(ctx context.Context, fileName string, data []byte) (*v1.AvatarUploadRes, error) {
if err := validateAvatarUpload(fileName, data); err != nil {
return nil, err
}
2026-09-30 17:01:18 +08:00
relPath, err := storeStatic(ctx, "avatar", fileName, data)
if err != nil {
return nil, err
2026-09-29 10:57:01 +08:00
}
2026-09-30 17:01:18 +08:00
recordAttachment(ctx, consts.AttachmentSceneAvatar, fileName, relPath, data, "user", CtxUserId(ctx))
return &v1.AvatarUploadRes{AvatarUrl: absoluteURL(ctx, relPath)}, nil
2026-09-29 10:57:01 +08:00
}
// validateAvatarUpload 头像上传校验(纯函数,可单测):非空 + 体积 ≤5MB + 扩展名合法 + 内容确为图片。
func validateAvatarUpload(fileName string, data []byte) error {
if len(data) == 0 {
return errUploadInvalid("上传内容为空")
}
if len(data) > AvatarMaxBytes {
return errUploadInvalid("头像不能超过 5MB")
}
ext := strings.ToLower(filepath.Ext(fileName))
if !avatarAllowedExt[ext] {
return errUploadInvalid("仅支持 jpg / png / webp 格式的图片")
}
if !isImageBytes(data) {
return errUploadInvalid("文件不是有效的图片")
}
return nil
}
// isImageBytes 依「魔数」判定是否为受支持的图片(JPEG / PNG / WEBP)。
//
// 仅看扩展名可被伪造(把 .txt 改名成 .jpg),故再校验内容头,双保险。
func isImageBytes(b []byte) bool {
if len(b) < 12 {
return false
}
// JPEG: FF D8 FF
if b[0] == 0xFF && b[1] == 0xD8 && b[2] == 0xFF {
return true
}
// PNG: 89 50 4E 47
if b[0] == 0x89 && b[1] == 0x50 && b[2] == 0x4E && b[3] == 0x47 {
return true
}
// WEBP: "RIFF"...."WEBP"
if string(b[0:4]) == "RIFF" && string(b[8:12]) == "WEBP" {
return true
}
return false
}
// absoluteStaticURL 拼成「浏览器可直接打开」的绝对 URL(<scheme>://<host><path>)。
2026-09-30 17:01:18 +08:00
// 无请求上下文(如单测)时回退为相对路径。保留旧名兼容既有调用点,实现统一走 absoluteURL。
2026-09-29 10:57:01 +08:00
func absoluteStaticURL(ctx context.Context, urlPath string) string {
2026-09-30 17:01:18 +08:00
return absoluteURL(ctx, urlPath)
}
// ============================================================================
// 图片域名拼接(迭代-2026-09-29):库内只存相对路径,域名统一在这里出现
// ============================================================================
// baseURL 静态资源对外拼接前缀:settings 的 site.base_url 优先(后台「系统配置」可改);
// 留空时按请求 Host 现拼(本地联调零配置)。
func baseURL(ctx context.Context) string {
if v := strings.TrimSpace(SettingValue(ctx, consts.SettingSiteBaseUrl)); v != "" {
return strings.TrimRight(v, "/")
}
2026-09-29 10:57:01 +08:00
req := g.RequestFromCtx(ctx)
if req == nil || req.Host == "" {
2026-09-30 17:01:18 +08:00
return ""
2026-09-29 10:57:01 +08:00
}
scheme := "http"
if req.TLS != nil {
scheme = "https"
}
if proto := strings.TrimSpace(req.Header.Get("X-Forwarded-Proto")); proto != "" {
scheme = proto
}
2026-09-30 17:01:18 +08:00
return scheme + "://" + req.Host
}
// absoluteURL 相对路径 → 对外绝对 URL;空值或已是绝对 URL 时原样返回。
// base 取不到(无请求上下文且未配置 site.base_url)时返回相对路径本身(调用端自行拼接)。
func absoluteURL(ctx context.Context, path string) string {
p := strings.TrimSpace(path)
if p == "" || strings.HasPrefix(p, "http://") || strings.HasPrefix(p, "https://") {
return p
}
base := baseURL(ctx)
if base == "" {
return p
}
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
return base + p
}
// normalizeToRelativePath 把前端回传的 URL 归一化为相对路径后入库(剥掉 scheme://host)。
// 兼容存量:头像等历史数据存过绝对 URL,写入侧统一收敛;读出侧统一 absoluteURL 拼接。
// 兜底:若串中含 /static/ 段(如被调用侧拼过部署根路径 / 带盘符的路径),截取最后一个
// /static/ 起始段,保证库内永远是 staticURLPrefix 开头的形态。
func normalizeToRelativePath(url string) string {
u := strings.TrimSpace(url)
if u == "" {
return ""
}
if i := strings.Index(u, "://"); i > 0 {
rest := u[i+3:]
if j := strings.Index(rest, "/"); j >= 0 {
u = rest[j:]
} else {
return ""
}
}
if i := strings.LastIndex(u, staticURLPrefix+"/"); i > 0 {
return u[i:]
}
return u
}
// staticPathToDisk 相对 URL 路径(/static/...)→ 磁盘路径(StaticDir/...),供删除文件用。
// ok=false 表示不是 /static 下的合法相对路径(含 .. 一律拒绝,防路径穿越)。
func staticPathToDisk(urlPath string) (disk string, ok bool) {
if !strings.HasPrefix(urlPath, staticURLPrefix+"/") {
return "", false
}
rel := strings.TrimPrefix(urlPath, staticURLPrefix+"/")
if rel == "" || strings.Contains(rel, "..") {
return "", false
}
return filepath.Join(StaticDir, filepath.FromSlash(rel)), true
}
// ============================================================================
// 通用上传(管理端:服务封面 / 通用附件)
// ============================================================================
// AdminUploadFromRequest 处理管理端 multipart 上传:取 file 字段 → 按场景校验 → 落盘 → 登记 → 返回。
//
// multipart 表单文件字段名固定为 `file`(与头像上传同一跨端契约);scene 为表单字段。
func AdminUploadFromRequest(ctx context.Context, scene string) (*adminv1.AdminUploadRes, error) {
req := g.RequestFromCtx(ctx)
if req == nil {
return nil, errUploadFailed("上传失败,请重试")
}
file := req.GetUploadFile("file")
if file == nil {
return nil, errUploadInvalid("请选择要上传的文件")
}
f, err := file.Open()
if err != nil {
return nil, errUploadFailed("上传失败,请重试")
}
defer func() { _ = f.Close() }()
// 多读 1 字节用于判定「超过上限」,避免把超大文件全读进内存。
data, err := io.ReadAll(io.LimitReader(f, AttachmentMaxBytes+1))
if err != nil {
return nil, errUploadFailed("上传失败,请重试")
}
return AdminUpload(ctx, scene, file.Filename, data)
}
// AdminUpload 按场景校验并落盘,登记附件表,返回相对路径 + 现拼绝对 URL。
//
// 封面(cover)仅图片 ≤5MB;通用附件(attachment)白名单格式 ≤20MB。
// 场景决定落盘子目录与附件表 scene 字段(avatar 场景走用户端头像接口,不经此入口)。
func AdminUpload(ctx context.Context, scene, fileName string, data []byte) (*adminv1.AdminUploadRes, error) {
dirName := ""
switch scene {
case consts.AttachmentSceneCover:
if err := validateImageUpload(fileName, data, "封面"); err != nil {
return nil, err
}
dirName = "cover"
case consts.AttachmentSceneAttachment:
if err := validateAttachmentUpload(fileName, data); err != nil {
return nil, err
}
dirName = "attachment"
default:
return nil, errUploadInvalid("不支持的上传场景")
}
relPath, err := storeStatic(ctx, dirName, fileName, data)
if err != nil {
return nil, err
}
id := recordAttachment(ctx, scene, fileName, relPath, data, "admin", CtxAdminId(ctx))
return &adminv1.AdminUploadRes{
Id: id,
Url: absoluteURL(ctx, relPath),
Path: relPath,
FileName: fileName,
Ext: strings.ToLower(filepath.Ext(fileName)),
SizeBytes: int64(len(data)),
}, nil
}
// storeStatic 通用落盘:<StaticDir>/<dirName>/<yyyyMM>/<md5>.<ext>,返回相对 URL 路径。
func storeStatic(ctx context.Context, dirName, fileName string, data []byte) (string, error) {
if len(data) == 0 {
return "", errUploadInvalid("上传内容为空")
}
sum := md5.Sum(data)
name := hex.EncodeToString(sum[:]) + strings.ToLower(filepath.Ext(fileName))
ym := gtime.Now().Format("Ym")
relDir := filepath.Join(StaticDir, dirName, ym)
if err := gfile.Mkdir(relDir); err != nil {
g.Log().Errorf(ctx, "[upload] 创建目录失败 dir=%s err=%v", relDir, err)
return "", errUploadFailed("上传失败,请重试")
}
if err := gfile.PutBytes(filepath.Join(relDir, name), data); err != nil {
g.Log().Errorf(ctx, "[upload] 写入文件失败 dir=%s name=%s err=%v", relDir, name, err)
return "", errUploadFailed("上传失败,请重试")
}
return staticURLPrefix + "/" + dirName + "/" + ym + "/" + name, nil
}
// recordAttachment 登记附件表(同 file_path 已有未删记录则跳过 —— md5 落盘天然去重)。
// 失败仅告警不阻断上传主流程;返回记录 id(跳过/失败返回 0)。
// TZ-01:datetime 写库一律 gdb.Raw("NOW()")。
func recordAttachment(ctx context.Context, scene, fileName, relPath string, data []byte, uploaderType string, uploaderId int64) int64 {
sum := md5.Sum(data)
count, err := g.Model(consts.TableAttachments).Where("file_path", relPath).WhereNull("deleted_at").Count()
if err != nil {
g.Log().Warningf(ctx, "[upload] 附件登记查重失败 path=%s err=%v", relPath, err)
return 0
}
if count > 0 {
return 0
}
res, err := g.Model(consts.TableAttachments).Data(g.Map{
"scene": scene,
// 客户端文件名可能带非法字节(老客户端 GBK 等):消毒为合法 UTF-8,
// 避免「文件已落盘但登记失败」把附件管理变成盲区
"file_name": strings.ToValidUTF8(fileName, string(utf8.RuneError)),
"file_path": relPath,
"ext": strings.ToLower(filepath.Ext(fileName)),
"size_bytes": len(data),
"md5": hex.EncodeToString(sum[:]),
"uploader_id": uploaderId,
"uploader_type": uploaderType,
"created_at": gdb.Raw("NOW()"),
}).Insert()
if err != nil {
g.Log().Warningf(ctx, "[upload] 附件登记失败 path=%s err=%v", relPath, err)
return 0
}
id, _ := res.LastInsertId()
return id
}
// validateImageUpload 图片上传校验(封面等):非空 + 体积 ≤5MB + 扩展名合法 + 内容确为图片。
func validateImageUpload(fileName string, data []byte, label string) error {
if len(data) == 0 {
return errUploadInvalid("上传内容为空")
}
if len(data) > AvatarMaxBytes {
return errUploadInvalid(label + "不能超过 5MB")
}
ext := strings.ToLower(filepath.Ext(fileName))
if !avatarAllowedExt[ext] {
return errUploadInvalid("仅支持 jpg / png / webp 格式的图片")
}
if !isImageBytes(data) {
return errUploadInvalid("文件不是有效的图片")
}
return nil
}
// validateAttachmentUpload 通用附件校验:非空 + 体积 ≤20MB + 扩展名白名单 + 抽查魔数。
func validateAttachmentUpload(fileName string, data []byte) error {
if len(data) == 0 {
return errUploadInvalid("上传内容为空")
}
if len(data) > AttachmentMaxBytes {
return errUploadInvalid("附件不能超过 20MB")
}
ext := strings.ToLower(filepath.Ext(fileName))
if !attachmentAllowedExt[ext] {
return errUploadInvalid("不支持的附件格式")
}
if !attachmentMagicOk(ext, data) {
return errUploadInvalid("文件内容与扩展名不符")
}
return nil
}
// attachmentMagicOk 按扩展名抽查内容魔数(可校验的才校验,txt/md 等纯文本跳过)。
func attachmentMagicOk(ext string, b []byte) bool {
switch ext {
case ".jpg", ".jpeg", ".png", ".webp":
return isImageBytes(b)
case ".gif":
return len(b) >= 6 && (string(b[0:3]) == "GIF")
case ".pdf":
return len(b) >= 5 && string(b[0:4]) == "%PDF"
case ".zip", ".docx", ".xlsx", ".pptx":
// Office 新格式(docx/xlsx/pptx)本质是 zip 容器,同用 PK 头
return len(b) >= 4 && b[0] == 0x50 && b[1] == 0x4B
case ".doc", ".xls", ".ppt":
// OLE2 复合文档头
return len(b) >= 8 && b[0] == 0xD0 && b[1] == 0xCF && b[2] == 0x11 && b[3] == 0xE0
}
return true
2026-09-29 10:57:01 +08:00
}