26.9:直链下载 + 过期回收 + SHA512 去重 + 防盗链 + 媒体预览 + 文件夹上传提示
CI 测试 / go vet + go test (push) Failing after 5s

- 对象存储直链:S3 引擎 302 到限时预签名 URL(有效期钳位分享剩余时效),失败自动回落代理
- 过期回收:janitor 定时扫描 + 取件惰性回收 + 管理端手动触发(POST /admin/recycle/run),
  retention_days 最长存储时长;删除走引用计数(去重对象安全)
- SHA512 内容去重:三条上传链路落库后计算哈希,命中即复用旧对象并删除本次副本
- 下载防盗链:Referer 白名单(同源/空 Referer/通配域名放行),挂 /share/download
- 取件页图片/音频内联预览(下载地址直连,加载失败回退下载按钮)
- 文件夹上传:拖拽目录明确提示"建议压缩后上传"(webkitGetAsEntry 探测)
- 管理端设置卡「回收与下载安全」8 个新配置键(KVSchema + configKeys + UI + i18n)
- 前端产物重建并同步 server/web/dist 与 web-embed
- 文档:10-config 配置表、03-file-share 直链/防盗链/文件夹章节、07-admin 回收端点、openapi
This commit is contained in:
2026-09-08 04:54:05 +08:00
parent 84df9996cb
commit 7a9015aad0
25 changed files with 1259 additions and 15 deletions
+41
View File
@@ -1,6 +1,7 @@
package api
import (
"context"
"errors"
"fmt"
"io"
@@ -13,6 +14,7 @@ import (
"gorm.io/gorm"
"fileshare/internal/audit"
"fileshare/internal/janitor"
"fileshare/internal/middleware"
"fileshare/internal/model"
"fileshare/internal/response"
@@ -61,9 +63,22 @@ func (d *Deps) consumeUsage(c *gin.Context, fc *model.FileCodes) bool {
"expired_count": gorm.Expr("CASE WHEN expired_count > 0 THEN expired_count - 1 ELSE expired_count END"),
"used_count": gorm.Expr("used_count + 1"),
})
if res.Error == nil && res.RowsAffected == 0 {
// 26.9:取件时惰性回收——记录已过期/次数耗尽,后台异步删除记录与对象
// (定时回收循环之外的"更好检查方法":访问即发现即回收,不等下一轮扫描)
d.recycleAsync(fc)
}
return res.Error == nil && res.RowsAffected > 0
}
// recycleAsync 异步回收单条过期分享(不阻塞请求;记录不存在时为幂等空操作)。
func (d *Deps) recycleAsync(fc *model.FileCodes) {
go janitor.RecycleRecord(context.Background(), d.DB, d.Store, fc, &janitor.Recycler{
Enabled: d.Cfg.RecycleEnabled,
RetentionDays: d.Cfg.RetentionDays,
})
}
// fileSavePath 拼接分享记录的存储相对路径(file_path/uuid_file_name)。
func fileSavePath(fc *model.FileCodes) string {
dir := ""
@@ -263,6 +278,9 @@ func (d *Deps) shareFile(c *gin.Context) {
err = mapCodeConflict(err) // 26.9
// 记录创建失败:清理已落盘文件
_ = d.Store.DeleteFile(ctx, savePath)
} else {
// 26.9:SHA512 内容去重(命中则复用旧对象并删除本次副本)
d.applyDedup(ctx, d.Store, savePath, &fc)
}
} else {
// 保存失败:尽力清理半写文件
@@ -553,6 +571,29 @@ func (d *Deps) serveFile(c *gin.Context, fc *model.FileCodes) {
return
}
// 26.9:对象存储直链下载——引擎支持 + 直链开关开启时 302 到限时预签名 URL,
// 文件字节不再经过本服务器(带宽成本转嫁对象存储)。签名有效期取
// direct_link_expire 与分享剩余时效的较小值;直链不可用静默回落代理。
if d.Cfg.DirectDownload() {
expires := d.Cfg.DirectLinkExpire()
if fc.ExpiredAt != nil {
if remain := int64(time.Until(*fc.ExpiredAt).Seconds()); remain > 0 && remain < expires {
expires = remain
}
}
if url, err := store.PresignGetURL(ctx, savePath, expires); err == nil && url != "" {
auditUploadEntry(c, fc.Code, name, fc.Size, fc.Size)
middleware.AuditSet(c, func(e *audit.Entry) {
e.TransferredBytes = fc.Size
e.SizeBytes = fc.Size
})
auditRecordSuccess(c, d.AuditSvc)
c.Redirect(http.StatusFound, url)
return
}
// 直链不可用:继续走代理下载(不中断取件)
}
// 先 Stat 拿总大小(用于审计与 Range 后缀解析)
var total int64 = -1
if meta, err := store.Stat(ctx, savePath); err == nil && meta != nil {