将 model/service/handler/middleware 迁至 models/services/routers/api/modules/auth,并删除 frontend、embed_static、scripts 及误入库缓存/二进制。 Co-authored-by: Cursor <cursoragent@cursor.com>
153 lines
3.5 KiB
Go
153 lines
3.5 KiB
Go
package service
|
||
|
||
import (
|
||
"bytes"
|
||
"errors"
|
||
"fmt"
|
||
"image"
|
||
"image/gif"
|
||
"io"
|
||
"mime/multipart"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
"github.com/KarpelesLab/gowebp"
|
||
|
||
// 注册解码器
|
||
_ "image/jpeg"
|
||
_ "image/png"
|
||
|
||
_ "golang.org/x/image/webp"
|
||
)
|
||
|
||
// 图片展示方案(上传时同时保留原图与 WebP,按此决定返回给前端的 URL)
|
||
const (
|
||
ImageDeliveryWebP = "webp" // 使用 WebP(默认,省流量)
|
||
ImageDeliveryOriginal = "original" // 使用原图
|
||
)
|
||
|
||
const (
|
||
// UploadWebPQuality 上传衍生 WebP 有损质量(0–100)
|
||
UploadWebPQuality float32 = 82
|
||
// UploadWebPMethod 编码档位:3 速度与体积较均衡
|
||
UploadWebPMethod = 3
|
||
// ThumbWebPQuality 帖子预览图质量
|
||
ThumbWebPQuality float32 = 80
|
||
)
|
||
|
||
// preparedUpload 原图 + 可选 WebP 衍生
|
||
type preparedUpload struct {
|
||
OrigExt string // 含点,如 .jpg
|
||
OrigContentType string
|
||
OrigData []byte
|
||
WebPData []byte // 空表示无衍生(动图 GIF,或原图已是 WebP)
|
||
}
|
||
|
||
// prepareUploadImage 始终保留原图字节;静态图额外生成 WebP 衍生
|
||
func prepareUploadImage(file *multipart.FileHeader) (*preparedUpload, error) {
|
||
ext := strings.ToLower(filepath.Ext(file.Filename))
|
||
if !allowedImageExt[ext] {
|
||
return nil, errors.New("仅支持 jpg/png/gif/webp 格式")
|
||
}
|
||
if ext == ".jpeg" {
|
||
ext = ".jpg"
|
||
}
|
||
|
||
src, err := file.Open()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer src.Close()
|
||
|
||
raw, err := io.ReadAll(src)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(raw) == 0 {
|
||
return nil, errors.New("空图片文件")
|
||
}
|
||
|
||
out := &preparedUpload{
|
||
OrigExt: ext,
|
||
OrigContentType: imageContentType(ext),
|
||
OrigData: raw,
|
||
}
|
||
|
||
// 动图 GIF:只保留原文件
|
||
if ext == ".gif" && gifFrameCount(raw) > 1 {
|
||
return out, nil
|
||
}
|
||
// 上传已是 WebP:原图即 WebP,不再重复衍生
|
||
if ext == ".webp" {
|
||
return out, nil
|
||
}
|
||
|
||
img, _, err := image.Decode(bytes.NewReader(raw))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("解码图片失败: %w", err)
|
||
}
|
||
|
||
webpBytes, err := encodeWebPBytes(img, UploadWebPQuality, UploadWebPMethod)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("转换 WebP 失败: %w", err)
|
||
}
|
||
out.WebPData = webpBytes
|
||
return out, nil
|
||
}
|
||
|
||
func gifFrameCount(raw []byte) int {
|
||
g, err := gif.DecodeAll(bytes.NewReader(raw))
|
||
if err != nil || g == nil {
|
||
return 0
|
||
}
|
||
return len(g.Image)
|
||
}
|
||
|
||
func encodeWebPBytes(img image.Image, quality float32, method int) ([]byte, error) {
|
||
var buf bytes.Buffer
|
||
if err := gowebp.Encode(&buf, img, &gowebp.Options{
|
||
Lossy: true,
|
||
Quality: quality,
|
||
Method: method,
|
||
}); err != nil {
|
||
return nil, err
|
||
}
|
||
return buf.Bytes(), nil
|
||
}
|
||
|
||
func normalizeImageDelivery(raw string) string {
|
||
if strings.ToLower(strings.TrimSpace(raw)) == ImageDeliveryOriginal {
|
||
return ImageDeliveryOriginal
|
||
}
|
||
return ImageDeliveryWebP
|
||
}
|
||
|
||
func imageContentType(ext string) string {
|
||
switch strings.ToLower(ext) {
|
||
case ".jpg", ".jpeg":
|
||
return "image/jpeg"
|
||
case ".png":
|
||
return "image/png"
|
||
case ".gif":
|
||
return "image/gif"
|
||
case ".webp":
|
||
return "image/webp"
|
||
default:
|
||
return "application/octet-stream"
|
||
}
|
||
}
|
||
|
||
// siblingUploadExts 同主文件名可能存在的伴生扩展名(删除时一并清理)
|
||
func siblingUploadExts(ext string) []string {
|
||
ext = strings.ToLower(ext)
|
||
all := []string{".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||
out := make([]string, 0, len(all))
|
||
for _, e := range all {
|
||
if e == ext || (ext == ".jpg" && e == ".jpeg") || (ext == ".jpeg" && e == ".jpg") {
|
||
continue
|
||
}
|
||
out = append(out, e)
|
||
}
|
||
return out
|
||
}
|