package wallpaper import ( "bytes" "context" "fmt" "image" "image/color" "image/jpeg" _ "image/gif" // 注册 GIF 解码器 _ "image/png" // 注册 PNG 解码器 "os" "path" "path/filepath" "strings" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" "golang.org/x/image/draw" _ "golang.org/x/image/webp" // 注册 WebP 解码器(只解不编,够用) ) // 派生图的尺寸上限。 // // 这两个数字直接决定服务器出口带宽的消耗方式,是本模块最重要的性能参数: // - 缩略图 480px:列表网格一屏可能要几十张,必须小(单张约 30~60KB); // - 预览图 1920px:全屏展示用,单张约 300~600KB,一次只看一张,可以接受; // - 原图:只在用户点「下载」时访问,不参与浏览。 // // 5M 出口(约 625KB/s)下,如果不分档、列表直接拉原图(动辄 3~8MB), // 一屏就能把带宽占满十几秒。 const ( thumbMaxEdge = 480 previewMaxEdge = 1920 thumbQuality = 82 previewQuality = 88 // MaxUploadBytes 单张原图上限。4K 无损壁纸一般也不超过这个数。 MaxUploadBytes = 20 << 20 // 20MB ) // Storage 负责自建图库的文件落盘与派生图生成。 type Storage struct { root string // 存储根目录(绝对路径) baseURL string // 对外访问前缀,如 https://xpcool.com/wallpaper } // NewStorage 从配置构造存储。 // // 配置项(支持环境变量注入,见 cmd.injectEnv): // // wallpaper.root 存储根目录,生产为 /data/www/wallpaper // wallpaper.baseUrl 对外访问前缀,生产为 https://xpcool.com/wallpaper func NewStorage(ctx context.Context) *Storage { root := g.Cfg().MustGet(ctx, "wallpaper.root", "./data/wallpaper").String() base := g.Cfg().MustGet(ctx, "wallpaper.baseUrl", "/wallpaper").String() return &Storage{ root: root, baseURL: strings.TrimRight(base, "/"), } } // Root 返回存储根目录。 func (st *Storage) Root() string { return st.root } // URL 把库里的相对路径拼成对外可访问的完整地址。 // 由 nginx 直出,Go 服务不参与传图。 func (st *Storage) URL(rel string) string { if rel == "" { return "" } return st.baseURL + "/" + strings.TrimLeft(rel, "/") } // relPath 生成相对路径:按年月分目录,文件名用内容 MD5。 // // 用 MD5 作文件名有两个好处:天然幂等(同一张图重复上传覆盖同一个文件, // 不会堆垃圾);URL 不可枚举。 func relPath(hash, ext string, t *gtime.Time) string { return path.Join(t.Format("Y"), t.Format("m"), hash+ext) } // derivedRelPath 派生图的相对路径(在原名后加 _t / _p 后缀)。 func derivedRelPath(hash, suffix string, t *gtime.Time) string { return path.Join(t.Format("Y"), t.Format("m"), hash+suffix+".jpg") } // SaveOriginal 把原图写到磁盘,返回相对路径。 func (st *Storage) SaveOriginal(hash, ext string, data []byte) (string, error) { t := gtime.Now() rel := relPath(hash, ext, t) if err := st.writeFile(rel, data); err != nil { return "", err } return rel, nil } // SaveDerived 生成并写入派生图(缩略图 / 预览图),返回相对路径。 // // src 是已经解码好的原图。缩放走 CatmullRom(质量与速度的平衡点), // 且**不放大**:原图本来就比上限小的时候直接沿用,避免糊图还白占空间。 func (st *Storage) SaveDerived(src image.Image, hash, suffix string, maxEdge, quality int) (string, error) { resized := resizeTo(src, maxEdge) t := gtime.Now() rel := derivedRelPath(hash, suffix, t) var b bytes.Buffer if err := jpeg.Encode(&b, resized, &jpeg.Options{Quality: quality}); err != nil { return "", fmt.Errorf("编码派生图失败: %w", err) } if err := st.writeFile(rel, b.Bytes()); err != nil { return "", err } return rel, nil } // Remove 删除一组文件(原图 + 派生图)。文件不存在不算错误。 func (st *Storage) Remove(rels ...string) error { for _, rel := range rels { if rel == "" { continue } full := filepath.Join(st.root, filepath.FromSlash(rel)) if err := os.Remove(full); err != nil && !os.IsNotExist(err) { return fmt.Errorf("删除文件 %s 失败: %w", rel, err) } } return nil } // writeFile 写文件,自动建目录。 func (st *Storage) writeFile(rel string, data []byte) error { full := filepath.Join(st.root, filepath.FromSlash(rel)) if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { return fmt.Errorf("创建目录失败: %w", err) } if err := os.WriteFile(full, data, 0o644); err != nil { return fmt.Errorf("写入文件失败: %w", err) } return nil } // --------------------------------------------------------------------------- // 图像处理 // --------------------------------------------------------------------------- // resizeTo 等比缩放到「长边不超过 maxEdge」,且不放大。 func resizeTo(src image.Image, maxEdge int) image.Image { b := src.Bounds() sw, sh := b.Dx(), b.Dy() if sw <= 0 || sh <= 0 { return src } longest := sw if sh > longest { longest = sh } if longest <= maxEdge { return src } scale := float64(maxEdge) / float64(longest) dw := int(float64(sw)*scale + 0.5) dh := int(float64(sh)*scale + 0.5) if dw < 1 { dw = 1 } if dh < 1 { dh = 1 } dst := image.NewRGBA(image.Rect(0, 0, dw, dh)) // 先铺白底再叠加:原图若带透明通道(PNG),直接编成 JPEG 会变成黑底。 draw.Draw(dst, dst.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src) draw.CatmullRom.Scale(dst, dst.Bounds(), src, b, draw.Over, nil) return dst } // normalizeExt 把解码得到的格式名归一成文件扩展名。 // 未知格式一律按 .jpg 存(我们只接受能解码的格式,见 Upload 的校验)。 func normalizeExt(format string) string { switch strings.ToLower(format) { case "jpeg", "jpg": return ".jpg" case "png": return ".png" case "gif": return ".gif" case "webp": return ".webp" default: return ".jpg" } }