121 lines
3.7 KiB
Go
121 lines
3.7 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"io"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gogf/gf/v2/errors/gerror"
|
||
"github.com/gogf/gf/v2/frame/g"
|
||
"golang.org/x/crypto/bcrypt"
|
||
|
||
"36wisdom/biz/consts"
|
||
"36wisdom/biz/dao"
|
||
"36wisdom/biz/model/dto"
|
||
"36wisdom/common/auth"
|
||
)
|
||
|
||
type adminUser struct{}
|
||
|
||
var AdminUser = &adminUser{}
|
||
|
||
// Login 管理员登录:用户名 + bcrypt 密码校验,签发 admin 角色 token。
|
||
// token 有效期读 auth.expire 配置(≤0 回退 consts 默认)。
|
||
func (s *adminUser) Login(ctx context.Context, req *dto.AdminLoginReq) (*dto.AdminLoginRes, error) {
|
||
rec, err := dao.AdminUser.GetByUsername(ctx, req.Username)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if rec == nil || rec.Status != consts.StatusEnabled {
|
||
return nil, gerror.New("用户名或密码错误")
|
||
}
|
||
if err = bcrypt.CompareHashAndPassword([]byte(rec.Password), []byte(req.Password)); err != nil {
|
||
return nil, gerror.New("用户名或密码错误")
|
||
}
|
||
expire := g.Cfg().MustGet(ctx, "auth.expire", consts.AuthExpireSeconds).Int()
|
||
if expire <= 0 {
|
||
expire = consts.AuthExpireSeconds
|
||
}
|
||
token, err := auth.GenerateToken(auth.Secret(ctx), rec.Id, consts.RoleAdmin, expire)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &dto.AdminLoginRes{Token: token, AdminId: rec.Id}, nil
|
||
}
|
||
|
||
// uploadAllow 扩展名白名单 → 素材分类(技术设计.md 4.17)。
|
||
var uploadAllow = map[string]string{
|
||
".png": "image", ".jpg": "image", ".jpeg": "image", ".webp": "image", ".gif": "image",
|
||
".mp3": "audio", ".wav": "audio", ".m4a": "audio", ".ogg": "audio",
|
||
}
|
||
|
||
// Upload 素材上传:白名单 + 内容嗅探双校验,落盘 workspace/uploads/{分类}/YYYYMMDD_<随机>.ext,
|
||
// 返回 /uploads/... 访问路径。
|
||
func (s *adminUser) Upload(ctx context.Context, req *dto.AdminUploadReq) (*dto.AdminUploadRes, error) {
|
||
f := req.File
|
||
if f == nil || f.FileHeader == nil {
|
||
return nil, gerror.New("未收到上传文件")
|
||
}
|
||
ext := strings.ToLower(filepath.Ext(f.FileHeader.Filename))
|
||
cat, ok := uploadAllow[ext]
|
||
if !ok {
|
||
return nil, gerror.New("不支持的文件类型(png/jpg/jpeg/webp/gif/mp3/wav/m4a/ogg)")
|
||
}
|
||
maxBytes := int64(consts.UploadMaxImageBytes)
|
||
if cat == "audio" {
|
||
maxBytes = consts.UploadMaxAudioBytes
|
||
}
|
||
if f.FileHeader.Size > maxBytes {
|
||
return nil, gerror.Newf("文件超过大小限制(%dMB)", maxBytes>>20)
|
||
}
|
||
|
||
fh, err := f.Open()
|
||
if err != nil {
|
||
return nil, gerror.Wrap(err, "读取上传文件失败")
|
||
}
|
||
data, err := io.ReadAll(fh)
|
||
if err != nil {
|
||
return nil, gerror.Wrap(err, "读取上传文件失败")
|
||
}
|
||
if err = fh.Close(); err != nil {
|
||
return nil, gerror.Wrap(err, "关闭上传文件失败")
|
||
}
|
||
if len(data) == 0 {
|
||
return nil, gerror.New("上传文件为空")
|
||
}
|
||
if mime := http.DetectContentType(data); !contentTypeMatch(cat, mime) {
|
||
return nil, gerror.New("文件内容与扩展名不符")
|
||
}
|
||
|
||
randBytes := make([]byte, 6)
|
||
if _, err = rand.Read(randBytes); err != nil {
|
||
return nil, err
|
||
}
|
||
name := time.Now().Format("20060102") + "_" + hex.EncodeToString(randBytes) + ext
|
||
dir := filepath.Join("workspace", "uploads", cat)
|
||
if err = os.MkdirAll(dir, 0o755); err != nil {
|
||
return nil, err
|
||
}
|
||
if err = os.WriteFile(filepath.Join(dir, name), data, 0o644); err != nil {
|
||
return nil, err
|
||
}
|
||
return &dto.AdminUploadRes{Url: "/uploads/" + cat + "/" + name}, nil
|
||
}
|
||
|
||
// contentTypeMatch 内容嗅探校验:image 分类须 image/*;audio 分类须 audio/* 或 video/*(m4a 被识别为 video/mp4)。
|
||
func contentTypeMatch(cat, mime string) bool {
|
||
if mime == "" || mime == "application/octet-stream" {
|
||
return false
|
||
}
|
||
if cat == "image" {
|
||
return strings.HasPrefix(mime, "image/")
|
||
}
|
||
return strings.HasPrefix(mime, "audio/") || strings.HasPrefix(mime, "video/")
|
||
}
|