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
+40 -1
View File
@@ -1,6 +1,7 @@
package api
import (
"context"
"errors"
"fmt"
"log"
@@ -13,6 +14,7 @@ import (
"gorm.io/gorm"
"fileshare/internal/config"
"fileshare/internal/janitor"
"fileshare/internal/middleware"
"fileshare/internal/model"
"fileshare/internal/response"
@@ -81,6 +83,9 @@ func registerAdmin(r *gin.Engine, d *Deps) {
// 26.9 存储引擎:运行时热切换(健康检查通过才生效,失败保持原引擎)
authed.POST("/storage/switch", d.adminStorageSwitch)
// 26.9 过期回收:手动触发一轮(定时循环之外的管理端入口)
authed.POST("/recycle/run", d.adminRecycleRun)
// 审计日志查询(需求 ③;logs 为 list 的别名)
authed.GET("/audit/list", d.adminAuditList)
authed.GET("/audit/logs", d.adminAuditList)
@@ -796,6 +801,9 @@ var configKeys = []string{
"loginCount", "loginMinute",
"opacity", "background", "showAdminAddr", "robotsText", "site_domain", // 26.9:站点对外域名
"upload_rate", "download_rate", // 26.9:上下行带宽字节/秒(0=不限速)
// 26.9 回收与下载安全
"recycle_enabled", "recycle_interval", "retention_days", "dedup_enabled",
"hotlink_enabled", "hotlink_whitelist", "direct_download", "direct_link_expire",
"adminSessionExpire", "storage_path", "local_storage_path",
"file_storage",
// 26.9 存储引擎与引擎参数(热切换;凭据为敏感键,get 掩码/update 空跳过)
@@ -816,6 +824,8 @@ var intConfigKeys = []string{
"adminSessionExpire",
"max_save_count", "max_file_size", "notify_enabled",
"upload_rate", "download_rate", // 26.9
"recycle_enabled", "recycle_interval", "retention_days", "dedup_enabled",
"hotlink_enabled", "direct_download", "direct_link_expire", // 26.9hotlink_whitelist 为字符串键)
}
// validateConfigValue 按 settings.KVSchema 校验单个配置值:
@@ -1300,11 +1310,15 @@ func (d *Deps) fileByID(c *gin.Context, id int64) (*model.FileCodes, error) {
}
// deleteFileCode 删除分享记录与存储文件(文本分享无存储文件)。
// 26.9:SHA512 去重开启时同一对象可能被多条分享引用——删除前按
// ContentHash+Engine+UUIDFileName 引用计数,仍有其他引用则保留对象。
func (d *Deps) deleteFileCode(c *gin.Context, fc *model.FileCodes) error {
if fc.Text == nil && fc.FilePath != nil && fc.UUIDFileName != nil {
// 26.9:删除走文件归属引擎(旧引擎里的文件也要能删掉)
if delStore, dErr := d.storeFor(fc.Engine); dErr == nil {
if err := delStore.DeleteFile(c.Request.Context(), fileSavePath(fc)); err != nil && !errors.Is(err, storage.ErrNotFound) {
if d.referencedByOther(c.Request.Context(), fc) {
// 还有其他分享引用该对象:仅删记录
} else if err := delStore.DeleteFile(c.Request.Context(), fileSavePath(fc)); err != nil && !errors.Is(err, storage.ErrNotFound) {
return errInternal("存储文件删除失败: " + err.Error())
}
}
@@ -1316,6 +1330,31 @@ func (d *Deps) deleteFileCode(c *gin.Context, fc *model.FileCodes) error {
return nil
}
// referencedByOther 该分享的存储对象是否仍被其他分享引用(SHA512 去重)。
// 无 ContentHash(历史数据/去重未开启)时恒 false——按旧语义直接删对象。
func (d *Deps) referencedByOther(ctx context.Context, fc *model.FileCodes) bool {
if fc.ContentHash == nil || *fc.ContentHash == "" || fc.UUIDFileName == nil {
return false
}
var cnt int64
_ = d.DB.WithContext(ctx).Model(&model.FileCodes{}).
Where("content_hash = ? AND engine = ? AND uuid_file_name = ? AND id <> ?",
*fc.ContentHash, fc.Engine, *fc.UUIDFileName, fc.ID).
Count(&cnt).Error
return cnt > 0
}
// adminRecycleRun 手动触发一轮过期回收(26.9;定时循环之外的入口)。
// 返回 {removed: 本轮回收条数}。
func (d *Deps) adminRecycleRun(c *gin.Context) {
removed := janitor.RecycleExpired(c.Request.Context(), d.DB, d.Store, &janitor.Recycler{
Enabled: d.Cfg.RecycleEnabled,
RetentionDays: d.Cfg.RetentionDays,
})
auditRecordSuccess(c, d.AuditSvc)
response.OK(c, gin.H{"removed": removed})
}
// deleteMany 批量删除:返回 (已删除, 不存在, 失败)。
func (d *Deps) deleteMany(c *gin.Context, ids []int64) (deleted []int64, missing []int64, failed []gin.H) {
deleted, missing = []int64{}, []int64{}