Files
qitongxue-api/internal/logic/upload.go
2026-09-30 17:01:18 +08:00

413 lines
15 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 logic
import (
"context"
"crypto/md5"
"encoding/hex"
"io"
"path/filepath"
"strings"
"unicode/utf8"
"github.com/gogf/gf/v2/database/gdb"
"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"
adminv1 "tool-api/api/admin/v1"
v1 "tool-api/api/user/v1"
"tool-api/internal/consts"
)
// ============================================================================
// 头像上传(N3 / §2.2)+ 通用上传(封面/附件,迭代-2026-09-29)
//
// 本机静态目录方案(U1 已裁定接受):落盘 <StaticDir>/<scene>/<yyyyMM>/<md5>.<ext>。
//
// 📌 图片域名不落库(迭代-2026-09-29 裁定):数据库只存相对路径(/static/...),
// 对外输出一律经 absoluteURL 用 settings 的 site.base_url 拼接前缀;未配置时
// 回退为按请求 Host 现拼(本地联调零配置)。
//
// 已知限制:单实例部署正常;多实例(水平扩容)下非落盘实例会 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
// AttachmentMaxBytes 通用附件体积上限(20MB)。
const AttachmentMaxBytes = 20 * 1024 * 1024
// avatarAllowedExt 允许的图片扩展名(小写,含点)。
var avatarAllowedExt = map[string]bool{
".jpg": true,
".jpeg": true,
".png": true,
".webp": true,
}
// 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,
}
// 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 → 同一张图重复上传天然去重,且文件名不含用户输入(无路径穿越风险)。
// 同时登记附件表(scene=avatar,上传者=当前用户),供后台附件管理统一查看。
func AvatarUpload(ctx context.Context, fileName string, data []byte) (*v1.AvatarUploadRes, error) {
if err := validateAvatarUpload(fileName, data); err != nil {
return nil, err
}
relPath, err := storeStatic(ctx, "avatar", fileName, data)
if err != nil {
return nil, err
}
recordAttachment(ctx, consts.AttachmentSceneAvatar, fileName, relPath, data, "user", CtxUserId(ctx))
return &v1.AvatarUploadRes{AvatarUrl: absoluteURL(ctx, relPath)}, nil
}
// 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>)。
// 无请求上下文(如单测)时回退为相对路径。保留旧名兼容既有调用点,实现统一走 absoluteURL。
func absoluteStaticURL(ctx context.Context, urlPath string) string {
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, "/")
}
req := g.RequestFromCtx(ctx)
if req == nil || req.Host == "" {
return ""
}
scheme := "http"
if req.TLS != nil {
scheme = "https"
}
if proto := strings.TrimSpace(req.Header.Get("X-Forwarded-Proto")); proto != "" {
scheme = proto
}
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
}