refactor: Gitea 式目录改组,移除本分支 SPA 与杂项产物

将 model/service/handler/middleware 迁至 models/services/routers/api/modules/auth,并删除 frontend、embed_static、scripts 及误入库缓存/二进制。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-29 03:54:56 +08:00
parent 1414c71dec
commit 9fe299a45f
449 changed files with 0 additions and 52779 deletions

466
services/storage.go Normal file
View File

@@ -0,0 +1,466 @@
package service
import (
"bytes"
"context"
"errors"
"fmt"
"mime/multipart"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
"git.iioio.com/freefire/jiang13-forum/config"
)
// UploadCategory 上传分类目录名
const (
UploadCategoryAvatars = "avatars"
UploadCategoryPosts = "posts"
UploadCategorySite = "site"
)
// StorageConfig 上传存储配置(管理后台 / 内部使用)
type StorageConfig struct {
Type string `json:"type"` // local | s3
Endpoint string `json:"endpoint"`
Region string `json:"region"`
Bucket string `json:"bucket"`
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key,omitempty"` // 更新时传入;回显时为空
PublicBaseURL string `json:"public_base_url"`
Prefix string `json:"prefix"`
ForcePathStyle bool `json:"force_path_style"`
HasSecretKey bool `json:"has_secret_key"`
Ready bool `json:"ready"`
// ImageDelivery 展示方案webp默认| original上传始终保留原图
ImageDelivery string `json:"image_delivery"`
}
// UploadStore 统一上传存储(本地或 S3 兼容),支持运行时热切换
type UploadStore struct {
mu sync.RWMutex
dataDir string
settings *ForumSettingsService
mode string
s3 *s3Backend
publicBase string
keyPrefix string
}
type s3Backend struct {
client *minio.Client
bucket string
}
// NewUploadStore 创建本地默认存储;调用 Apply / ReloadFromSettings 切换后端
func NewUploadStore(dataDir string, settings *ForumSettingsService) *UploadStore {
return &UploadStore{
dataDir: dataDir,
settings: settings,
mode: config.StorageTypeLocal,
}
}
// ReloadFromSettings 按数据库配置重建存储客户端
func (s *UploadStore) ReloadFromSettings(settings *ForumSettingsService) error {
if settings == nil {
return errors.New("设置服务未初始化")
}
return s.Apply(settings.StorageConfig())
}
// Apply 应用存储配置(失败时保持原配置不变)
func (s *UploadStore) Apply(cfg StorageConfig) error {
if s == nil {
return errors.New("上传存储未初始化")
}
typ := normalizeStorageType(cfg.Type)
if typ == config.StorageTypeLocal {
s.mu.Lock()
s.mode = config.StorageTypeLocal
s.s3 = nil
s.publicBase = ""
s.keyPrefix = ""
s.mu.Unlock()
return nil
}
if err := validateStorageConfigForApply(cfg); err != nil {
return err
}
endpoint, secure, err := parseS3Endpoint(cfg.Endpoint)
if err != nil {
return err
}
region := strings.TrimSpace(cfg.Region)
if region == "" {
region = "us-east-1"
}
lookup := minio.BucketLookupDNS
if cfg.ForcePathStyle {
lookup = minio.BucketLookupPath
}
client, err := minio.New(endpoint, &minio.Options{
Creds: credentials.NewStaticV4(strings.TrimSpace(cfg.AccessKey), cfg.SecretKey, ""),
Secure: secure,
Region: region,
BucketLookup: lookup,
})
if err != nil {
return fmt.Errorf("初始化 S3 客户端失败: %w", err)
}
s.mu.Lock()
s.mode = config.StorageTypeS3
s.s3 = &s3Backend{client: client, bucket: strings.TrimSpace(cfg.Bucket)}
s.publicBase = normalizeRootURL(cfg.PublicBaseURL)
s.keyPrefix = normalizeObjectPrefix(cfg.Prefix)
s.mu.Unlock()
return nil
}
func validateStorageConfigForApply(cfg StorageConfig) error {
if strings.TrimSpace(cfg.Endpoint) == "" {
return errors.New("S3 Endpoint 不能为空")
}
if strings.TrimSpace(cfg.Bucket) == "" {
return errors.New("S3 Bucket 不能为空")
}
if strings.TrimSpace(cfg.AccessKey) == "" {
return errors.New("S3 Access Key 不能为空")
}
if strings.TrimSpace(cfg.SecretKey) == "" {
return errors.New("S3 Secret Key 不能为空")
}
if normalizeRootURL(cfg.PublicBaseURL) == "" {
return errors.New("公开访问地址 PUBLIC_BASE_URL 不能为空")
}
return nil
}
func normalizeStorageType(raw string) string {
t := strings.ToLower(strings.TrimSpace(raw))
if t == config.StorageTypeS3 {
return config.StorageTypeS3
}
return config.StorageTypeLocal
}
func normalizeObjectPrefix(raw string) string {
p := strings.TrimSpace(raw)
p = strings.TrimPrefix(p, "/")
if p == "" {
return ""
}
return strings.TrimSuffix(p, "/") + "/"
}
// snapshot 读取当前后端快照(调用方勿修改返回指针)
func (s *UploadStore) snapshot() (mode, publicBase, keyPrefix string, s3 *s3Backend) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.mode, s.publicBase, s.keyPrefix, s.s3
}
// IsLocal 是否本地磁盘存储
func (s *UploadStore) IsLocal() bool {
if s == nil {
return true
}
mode, _, _, backend := s.snapshot()
return mode != config.StorageTypeS3 || backend == nil
}
// UploadsRoot 本地 uploads 根目录
func (s *UploadStore) UploadsRoot() string {
if s == nil {
return ""
}
return filepath.Join(s.dataDir, "uploads")
}
// SaveImage 保存图片:始终保留原图;静态图额外写 WebP 衍生,按展示方案返回 URL
func (s *UploadStore) SaveImage(file *multipart.FileHeader, category, namePrefix string) (string, error) {
if s == nil {
return "", errors.New("上传存储未初始化")
}
category = strings.Trim(category, "/")
if category == "" {
return "", errors.New("无效的上传分类")
}
prepared, err := prepareUploadImage(file)
if err != nil {
return "", err
}
base := fmt.Sprintf("%s_%d", namePrefix, time.Now().UnixNano())
origName := base + prepared.OrigExt
webpName := base + ".webp"
delivery := ImageDeliveryWebP
if s.settings != nil {
delivery = s.settings.ImageDelivery()
}
mode, publicBase, keyPrefix, backend := s.snapshot()
useS3 := mode == config.StorageTypeS3
if useS3 && backend == nil {
return "", errors.New("对象存储未就绪,请检查管理后台「对象存储」配置")
}
// 1) 写原图
if useS3 {
if err := s.putBytesS3(backend, keyPrefix, category, origName, prepared.OrigContentType, prepared.OrigData); err != nil {
return "", err
}
} else {
if err := s.putBytesLocal(category, origName, prepared.OrigData); err != nil {
return "", err
}
}
// 2) 写 WebP 衍生(若有)
hasWebP := len(prepared.WebPData) > 0
if hasWebP {
if useS3 {
if err := s.putBytesS3(backend, keyPrefix, category, webpName, "image/webp", prepared.WebPData); err != nil {
return "", err
}
} else {
if err := s.putBytesLocal(category, webpName, prepared.WebPData); err != nil {
return "", err
}
}
}
// 3) 按展示方案选择返回 URL
returnName := origName
if delivery == ImageDeliveryWebP && (hasWebP || prepared.OrigExt == ".webp") {
if hasWebP {
returnName = webpName
} else {
returnName = origName // 原图已是 webp
}
}
publicURL := s.publicURL(useS3, publicBase, category, returnName)
// 写入媒体索引(原图 + WebP 衍生均登记)
storageType := config.StorageTypeLocal
if useS3 {
storageType = config.StorageTypeS3
}
uploader := parseUploaderID(category, namePrefix)
origURL := s.publicURL(useS3, publicBase, category, origName)
_ = s.upsertMediaRecord(category, origName, origURL, int64(len(prepared.OrigData)), prepared.OrigContentType, storageType, uploader)
if hasWebP {
webpURL := s.publicURL(useS3, publicBase, category, webpName)
_ = s.upsertMediaRecord(category, webpName, webpURL, int64(len(prepared.WebPData)), "image/webp", storageType, uploader)
}
// 缩略图优先用 WebP 衍生(更小);否则用返回文件
if category == UploadCategoryPosts && !useS3 {
thumbFile := returnName
if hasWebP {
thumbFile = webpName
}
rel := filepath.ToSlash(filepath.Join(category, thumbFile))
go WarmPostImageThumb(s.UploadsRoot(), rel)
}
return publicURL, nil
}
func (s *UploadStore) publicURL(useS3 bool, publicBase, category, filename string) string {
if useS3 {
return publicBase + "/" + category + "/" + filename
}
return "/uploads/" + category + "/" + filename
}
func (s *UploadStore) putBytesLocal(category, filename string, data []byte) error {
dir := filepath.Join(s.UploadsRoot(), category)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, filename), data, 0644)
}
func (s *UploadStore) putBytesS3(backend *s3Backend, keyPrefix, category, filename, contentType string, data []byte) error {
key := keyPrefix + category + "/" + filename
opts := minio.PutObjectOptions{ContentType: contentType}
_, err := backend.client.PutObject(
context.Background(),
backend.bucket,
key,
bytes.NewReader(data),
int64(len(data)),
opts,
)
if err != nil {
return fmt.Errorf("上传到对象存储失败: %w", err)
}
return nil
}
// DeleteByURL 删除本站管理的上传文件(非本站 URL 则忽略)
func (s *UploadStore) DeleteByURL(rawURL string) {
if s == nil {
return
}
rawURL = strings.TrimSpace(rawURL)
if rawURL == "" {
return
}
siblingURLs := s.resolveSiblingPublicURLs(rawURL)
// 始终尝试清理本地 /uploads/…(兼容切换到 S3 前的旧文件)
s.deleteLocalByURL(rawURL)
_, publicBase, keyPrefix, backend := s.snapshot()
if backend != nil {
s.deleteS3ByURL(backend, publicBase, keyPrefix, rawURL)
}
s.deleteMediaRecords(siblingURLs)
}
func (s *UploadStore) deleteLocalByURL(rawURL string) {
path := rawURL
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
return
}
if i := strings.Index(path, "?"); i >= 0 {
path = path[:i]
}
const prefix = "/uploads/"
if !strings.HasPrefix(path, prefix) {
return
}
rel := strings.TrimPrefix(path, prefix)
rel = filepath.Clean(filepath.FromSlash(rel))
if rel == "." || strings.HasPrefix(rel, "..") {
return
}
relSlash := filepath.ToSlash(rel)
for _, candidate := range uploadSiblingRels(relSlash) {
full := filepath.Join(s.UploadsRoot(), filepath.FromSlash(candidate))
_ = os.Remove(full)
if strings.HasPrefix(candidate, UploadCategoryPosts+"/") {
thumbDir := filepath.Join(s.UploadsRoot(), ".thumbs")
_ = os.Remove(filepath.Join(thumbDir, filepath.FromSlash(candidate)+".webp"))
_ = os.Remove(filepath.Join(thumbDir, filepath.FromSlash(candidate)+".jpg"))
}
}
}
func (s *UploadStore) deleteS3ByURL(backend *s3Backend, publicBase, keyPrefix, rawURL string) {
if backend == nil || publicBase == "" {
return
}
rel, ok := relativeUnderPublicBase(rawURL, publicBase)
if !ok {
return
}
parts := strings.SplitN(rel, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return
}
for _, candidate := range uploadSiblingRels(parts[0] + "/" + parts[1]) {
key := keyPrefix + candidate
_ = backend.client.RemoveObject(context.Background(), backend.bucket, key, minio.RemoveObjectOptions{})
}
}
// uploadSiblingRels 返回同一主文件名下的自身与伴生扩展名路径rel 使用 /
func uploadSiblingRels(rel string) []string {
rel = strings.TrimSpace(strings.ReplaceAll(rel, "\\", "/"))
ext := ""
if i := strings.LastIndex(rel, "."); i >= 0 && i > strings.LastIndex(rel, "/") {
ext = strings.ToLower(rel[i:])
}
stem := rel
if ext != "" {
stem = rel[:len(rel)-len(ext)]
}
if stem == "" {
return nil
}
seen := map[string]bool{}
out := make([]string, 0, 6)
add := func(e string) {
p := stem + e
if seen[p] {
return
}
seen[p] = true
out = append(out, p)
}
if ext != "" {
add(ext)
}
for _, e := range siblingUploadExts(ext) {
add(e)
}
return out
}
func parseS3Endpoint(raw string) (host string, secure bool, err error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", false, errors.New("S3 ENDPOINT 不能为空")
}
secure = true
if strings.Contains(raw, "://") {
u, err := url.Parse(raw)
if err != nil {
return "", false, fmt.Errorf("S3 ENDPOINT 无效: %w", err)
}
if u.Host == "" {
return "", false, errors.New("S3 ENDPOINT 无效")
}
switch strings.ToLower(u.Scheme) {
case "http":
secure = false
case "https":
secure = true
default:
return "", false, fmt.Errorf("S3 ENDPOINT 不支持协议 %q", u.Scheme)
}
return u.Host, secure, nil
}
host = raw
if strings.HasPrefix(host, "localhost") || strings.HasPrefix(host, "127.0.0.1") {
secure = false
}
return host, secure, nil
}
func relativeUnderPublicBase(rawURL, publicBase string) (string, bool) {
publicBase = strings.TrimRight(strings.TrimSpace(publicBase), "/")
rawURL = strings.TrimSpace(rawURL)
if publicBase == "" || rawURL == "" {
return "", false
}
if strings.HasPrefix(rawURL, publicBase+"/") {
rel := strings.TrimPrefix(rawURL, publicBase+"/")
if i := strings.Index(rel, "?"); i >= 0 {
rel = rel[:i]
}
rel = strings.TrimPrefix(rel, "/")
if rel == "" || strings.Contains(rel, "..") {
return "", false
}
return rel, true
}
return "", false
}