小程序一些功能更新

This commit is contained in:
李琦
2026-09-29 10:57:01 +08:00
parent 0cb70a7618
commit 3bb7fa8db3
96 changed files with 13890 additions and 213 deletions

163
internal/logic/upload.go Normal file
View File

@@ -0,0 +1,163 @@
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
}