Files
qitongxue-api/internal/logic/upload.go
2026-09-29 10:57:01 +08:00

164 lines
5.8 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"
"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"
v1 "tool-api/api/user/v1"
"tool-api/internal/consts"
)
// ============================================================================
// 头像上传(N3 / §2.2)
//
// 本机静态目录方案(U1 已裁定接受):落盘 <StaticDir>/avatar/<yyyyMM>/<md5>.<ext>,
// 返回「浏览器可直接打开」的绝对 URL。
//
// 已知限制:单实例部署正常;多实例(水平扩容)下非落盘实例会 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
// avatarAllowedExt 允许的图片扩展名(小写,含点)。
var avatarAllowedExt = map[string]bool{
".jpg": true,
".jpeg": true,
".png": true,
".webp": 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 → 同一张图重复上传天然去重,且文件名不含用户输入(无路径穿越风险)。
func AvatarUpload(ctx context.Context, fileName string, data []byte) (*v1.AvatarUploadRes, error) {
if err := validateAvatarUpload(fileName, data); err != nil {
return nil, err
}
sum := md5.Sum(data)
name := hex.EncodeToString(sum[:]) + strings.ToLower(filepath.Ext(fileName))
ym := gtime.Now().Format("Ym")
relDir := filepath.Join(StaticDir, "avatar", ym)
if err := gfile.Mkdir(relDir); err != nil {
g.Log().Errorf(ctx, "[upload] 创建头像目录失败 dir=%s err=%v", relDir, err)
return nil, 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 nil, errUploadFailed("头像上传失败,请重试")
}
urlPath := staticURLPrefix + "/avatar/" + ym + "/" + name
return &v1.AvatarUploadRes{AvatarUrl: absoluteStaticURL(ctx, urlPath)}, 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>)。
// 无请求上下文(如单测)时回退为相对路径。
func absoluteStaticURL(ctx context.Context, urlPath string) string {
req := g.RequestFromCtx(ctx)
if req == nil || req.Host == "" {
return urlPath
}
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 + urlPath
}