v3.2:上下行带宽限速 + 品牌更新 + 产品修复
亮点 - v3.2 带宽限速(upload_rate/download_rate,字节/秒,0=不限速;管理端立即生效) - middleware/bandwidth.go 时间窗对齐 sleep 算法 + 单元测试(400KB@100KB/s 4.00s 精度) - 下载:serveFile 包裹 storage.ReadCloser(统一覆盖 local/webdav/s3 代理下载) - 上传:UploadBandwidthMiddleware 包裹 Request.Body(shareFile/chunk/presign proxy) - S3 预签名直传不可服务端限速——UI/文档明示 - 后台 SettingsView 增加 MB/s 友好输入;i18n zh-CN/en-US 双语 - v3.2 文档:新增 docs/api/13-bandwidth.md 专题;10-config 指针;00-overview changelog 与限流表带宽行;openapi.yaml 三处 schema + description 更新 - README.md / web/README.md / server/README.md / deploy/README.md 全部覆盖 品牌(v3.1 收尾) - 文件快递柜 → 文件快传(前端/后端默认值/文档/产物/运行 KV) - 「复制取件码」按钮删除;取件码块点击即复制(保持原尺寸) - 「复制链接」→「复制链接和提取码」(一并复制链接和提取码) 产品修复(v3.1.1) - 文本分享 Content-Type text/plain + urlencoded body:前端显式声明 urlencoded 头根治; 后端 bindJSONOrForm 兜底兼容 text/plain + JSON/urlencoded 嗅探 - Docker 部署文档校对到 v3.1 现状(热切换 + 端口/卷/健康检查)
This commit is contained in:
@@ -795,6 +795,7 @@ var configKeys = []string{
|
||||
"uploadMinute", "uploadCount", "errorMinute", "errorCount",
|
||||
"loginCount", "loginMinute",
|
||||
"opacity", "background", "showAdminAddr", "robotsText", "site_domain", // v3.1:站点对外域名
|
||||
"upload_rate", "download_rate", // v3.2:上下行带宽字节/秒(0=不限速)
|
||||
"adminSessionExpire", "storage_path", "local_storage_path",
|
||||
"file_storage",
|
||||
// v3 存储引擎与引擎参数(热切换;凭据为敏感键,get 掩码/update 空跳过)
|
||||
@@ -814,6 +815,7 @@ var intConfigKeys = []string{
|
||||
"loginCount", "loginMinute", "max_save_seconds", "uploadSize",
|
||||
"adminSessionExpire",
|
||||
"max_save_count", "max_file_size", "notify_enabled",
|
||||
"upload_rate", "download_rate", // v3.2
|
||||
}
|
||||
|
||||
// validateConfigValue 按 settings.KVSchema 校验单个配置值:
|
||||
|
||||
@@ -46,7 +46,7 @@ func Register(r *gin.Engine, d *Deps) {
|
||||
share := r.Group("/share")
|
||||
{
|
||||
share.POST("/text", d.shareText)
|
||||
share.POST("/file", d.shareFile)
|
||||
share.POST("/file", middleware.UploadBandwidthMiddleware(d.Cfg.UploadRate), d.shareFile)
|
||||
// metadata:每次访问即计数(RequireRateLimit=进入检查+完成计数)
|
||||
share.GET("/metadata", d.Limiter.RequireRateLimit(middleware.LimitMeta), d.shareMetadata)
|
||||
share.POST("/metadata", d.Limiter.RequireRateLimit(middleware.LimitMeta), d.shareMetadataPost)
|
||||
@@ -61,8 +61,8 @@ func Register(r *gin.Engine, d *Deps) {
|
||||
chunk.POST("/upload/init", d.chunkInit)
|
||||
// 主路径(参考语义):/chunk/upload/{uploadID}/{index};
|
||||
// 扁平兼容:/chunk/upload + 表单/query 传 upload_id/chunk_index
|
||||
chunk.POST("/upload/:uploadID/:chunkIndex", d.chunkUpload)
|
||||
chunk.POST("/upload", d.chunkUploadFlat)
|
||||
chunk.POST("/upload/:uploadID/:chunkIndex", middleware.UploadBandwidthMiddleware(d.Cfg.UploadRate), d.chunkUpload)
|
||||
chunk.POST("/upload", middleware.UploadBandwidthMiddleware(d.Cfg.UploadRate), d.chunkUploadFlat)
|
||||
chunk.GET("/upload/status/:uploadID", d.chunkStatus)
|
||||
chunk.POST("/upload/complete/:uploadID", d.chunkComplete)
|
||||
chunk.DELETE("/upload/:uploadID", d.chunkCancel)
|
||||
@@ -72,7 +72,7 @@ func Register(r *gin.Engine, d *Deps) {
|
||||
presign := r.Group("/presign")
|
||||
{
|
||||
presign.POST("/upload/init", d.presignInit)
|
||||
presign.PUT("/upload/proxy/:uploadID", d.presignProxy)
|
||||
presign.PUT("/upload/proxy/:uploadID", middleware.UploadBandwidthMiddleware(d.Cfg.UploadRate), d.presignProxy)
|
||||
presign.POST("/upload/confirm/:uploadID", d.presignConfirm)
|
||||
presign.GET("/upload/status/:uploadID", d.presignStatus)
|
||||
presign.DELETE("/upload/:uploadID", d.presignCancel)
|
||||
@@ -135,6 +135,9 @@ func (d *Deps) publicConfig(c *gin.Context) {
|
||||
// v3:当前存储引擎名(仅名称,任何引擎参数/凭据不下发)
|
||||
"storage_engine": d.Store.CurrentName(),
|
||||
"site_domain": d.Cfg.SiteDomain(),
|
||||
// v3.2:上下行带宽字节/秒(公开下发,0=不限速,方便管理端展示当前值)
|
||||
"upload_rate": d.Cfg.UploadRate(),
|
||||
"download_rate": d.Cfg.DownloadRate(),
|
||||
// 需求 ③:系统通知(开关 + 内容,前台右上角悬浮窗)
|
||||
// L7:读取侧再做一次白名单净化,覆盖历史存量与直改库的数据
|
||||
"notify_enabled": boolToInt(cfg.NotifyEnabled()),
|
||||
|
||||
@@ -597,7 +597,9 @@ func (d *Deps) serveFile(c *gin.Context, fc *model.FileCodes) {
|
||||
}
|
||||
auditUploadEntry(c, fc.Code, name, total, 0)
|
||||
c.Status(status)
|
||||
n, _ := io.Copy(c.Writer, dl)
|
||||
// v3.2:下载带宽限速(storage.ReadCloser → 限速 reader → c.Writer)
|
||||
dlReader := middleware.WrapReadCloser(dl, d.Cfg.DownloadRate())
|
||||
n, _ := io.Copy(c.Writer, dlReader)
|
||||
middleware.AuditSet(c, func(e *audit.Entry) {
|
||||
if e.TransferredBytes == 0 {
|
||||
e.TransferredBytes = n
|
||||
|
||||
@@ -65,6 +65,8 @@ func defaults() map[string]any {
|
||||
// v3:存储引擎运行时可配(热切换);空=沿用 Env.StorageEngine 启动值
|
||||
"storage_engine": "",
|
||||
"site_domain": "",
|
||||
"upload_rate": "0",
|
||||
"download_rate": "0",
|
||||
// 站点信息
|
||||
"name": "文件快传",
|
||||
"site_name": "文件快传", // 新增:管理端可自定义
|
||||
@@ -278,6 +280,24 @@ func (c *Config) GetBool(key string) bool {
|
||||
}
|
||||
|
||||
// GetStringSlice 取字符串切片配置。
|
||||
// UploadRate 上传带宽字节/秒(v3.2,0=不限速)。
|
||||
func (c *Config) UploadRate() int {
|
||||
v := c.GetInt(KeyUploadRate)
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// DownloadRate 下载带宽字节/秒(v3.2,0=不限速)。
|
||||
func (c *Config) DownloadRate() int {
|
||||
v := c.GetInt(KeyDownloadRate)
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// SiteDomain 站点对外域名(v3.1):空=分享链接用当前访问地址。
|
||||
func (c *Config) SiteDomain() string {
|
||||
return strings.TrimRight(strings.TrimSpace(c.GetString("site_domain")), "/")
|
||||
|
||||
@@ -33,6 +33,8 @@ const (
|
||||
// v3:存储引擎运行时可配(热切换;file_storage 为参考既有键保留兼容)
|
||||
KeyStorageEngine = "storage_engine" // 当前存储引擎:local|s3|webdav
|
||||
KeySiteDomain = "site_domain" // 站点对外域名(空=分享链接用当前地址)
|
||||
KeyUploadRate = "upload_rate" // 上传带宽字节/秒(0=不限速)
|
||||
KeyDownloadRate = "download_rate" // 下载带宽字节/秒(0=不限速)
|
||||
)
|
||||
|
||||
// —— 取值边界(管理端保存与 API 校验用)——
|
||||
@@ -93,5 +95,7 @@ func KVSchema() []KVSchemaEntry {
|
||||
// —— v3 存储引擎(热切换;引擎参数键沿用 defaults() 既有键,管理端经 config get/update 读写)——
|
||||
{KeyStorageEngine, "string", "", 0, 16, "当前存储引擎:local|s3|webdav(热切换,健康检查通过才生效;空=回落启动值 FCB_STORAGE_ENGINE)"},
|
||||
{KeySiteDomain, "string", "", 0, 256, "站点对外域名(http(s)://host[:port],不带路径;空=分享链接用当前访问地址)"},
|
||||
{KeyUploadRate, "int64", "0", 0, 1073741824, "上传带宽字节/秒(0=不限速;范围 0~1 GiB/s)"},
|
||||
{KeyDownloadRate, "int64", "0", 0, 1073741824, "下载带宽字节/秒(0=不限速;范围 0~1 GiB/s)"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package middleware
|
||||
|
||||
// v3.2:上传/下载带宽限速(字节/秒,0=不限速)。
|
||||
//
|
||||
// 设计要点:
|
||||
// - 令牌桶(token bucket):每 Read 计算自上次起累计可消费字节,
|
||||
// 未达上限则阻塞至令牌补足;保证长期平均速率,瞬时按桶容量突发。
|
||||
// - 桶容量 = rate(1 秒突发量):简单且足够友好;后续若要更细粒度可参数化。
|
||||
// - 0 速率直接透传,不引入任何开销(性能基线保持)。
|
||||
// - 对 gin 中间件"上传限速":包裹 c.Request.Body,使 multipart 解析/单分片读取
|
||||
// 都被天然节流;下载侧由 api 层在 storage.Open 之后包裹 ReadCloser。
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"fileshare/internal/response"
|
||||
)
|
||||
|
||||
// rateLimitedReader 按 bytes/sec 限速的 io.Reader(基于时间窗的精确调度)。
|
||||
// 实现:累计已读字节,按 rate 推算"应到达时间";超前则 sleep 补齐。
|
||||
// - rate <= 0 时退化为直通;
|
||||
// - 长期速率严格 ≤ rate,瞬时按调用方 Read 块大小自然突发;
|
||||
// - 并发安全(每次请求持独立实例即可,但保留锁便于复用)。
|
||||
type rateLimitedReader struct {
|
||||
r io.Reader
|
||||
rate int // 字节/秒
|
||||
start time.Time // 起始时间(首 Read 时记)
|
||||
bytes int64 // 累计已读字节
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewRateLimitedReader 构造限速 reader;rate<=0 返回原始 reader。
|
||||
func NewRateLimitedReader(r io.Reader, rate int) io.Reader {
|
||||
if r == nil || rate <= 0 {
|
||||
return r
|
||||
}
|
||||
return &rateLimitedReader{r: r, rate: rate}
|
||||
}
|
||||
|
||||
// Read 限速:底层 Read 后若已超时应到达时间,sleep 补齐;错误/EOF 透传。
|
||||
func (rl *rateLimitedReader) Read(p []byte) (int, error) {
|
||||
if rl.rate <= 0 {
|
||||
return rl.r.Read(p)
|
||||
}
|
||||
n, err := rl.r.Read(p)
|
||||
if n <= 0 {
|
||||
return n, err
|
||||
}
|
||||
rl.mu.Lock()
|
||||
if rl.start.IsZero() {
|
||||
rl.start = time.Now()
|
||||
}
|
||||
rl.bytes += int64(n)
|
||||
// 已读 n 字节的"应到达时间" = start + bytes/rate
|
||||
expected := rl.start.Add(time.Duration(float64(time.Second) * float64(rl.bytes) / float64(rl.rate)))
|
||||
now := time.Now()
|
||||
rl.mu.Unlock()
|
||||
if now.Before(expected) {
|
||||
time.Sleep(expected.Sub(now))
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// UploadBandwidthMiddleware 限速上传请求体(包裹 c.Request.Body)。
|
||||
// rateProvider 每次请求动态读取最新配置(管理端改 KV 后立即生效)。
|
||||
func UploadBandwidthMiddleware(rateProvider func() int) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
rate := 0
|
||||
if rateProvider != nil {
|
||||
rate = rateProvider()
|
||||
}
|
||||
if rate > 0 && c.Request != nil && c.Request.Body != nil {
|
||||
c.Request.Body = WrapReadCloser(c.Request.Body, rate)
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// WrapReadCloser 包裹任意 io.ReadCloser 为限速版本(io.Copy 等下游使用)。
|
||||
func WrapReadCloser(rc io.ReadCloser, rate int) io.ReadCloser {
|
||||
if rc == nil || rate <= 0 {
|
||||
return rc
|
||||
}
|
||||
return &rateLimitedReadCloser{rc: rc, rl: &rateLimitedReader{r: rc, rate: rate}}
|
||||
}
|
||||
|
||||
type rateLimitedReadCloser struct {
|
||||
rc io.ReadCloser
|
||||
rl *rateLimitedReader
|
||||
}
|
||||
|
||||
func (r *rateLimitedReadCloser) Read(p []byte) (int, error) { return r.rl.Read(p) }
|
||||
func (r *rateLimitedReadCloser) Close() error { return r.rc.Close() }
|
||||
|
||||
// DownloadRateReporter 限速命中提示:超长下载时可选上报。
|
||||
// 当前仅占位——真正接入可在 audit 中按"下载耗时/字节比"标记慢速,但用户暂不需要。
|
||||
var _ = response.Fail // 保留 response 包以备后续扩展
|
||||
var _ = http.StatusOK
|
||||
@@ -0,0 +1,81 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestRateLimitedReaderAtRate 验证长读大致符合设定速率。
|
||||
// 设 100 KB/s,读取 400 KB 总字节,期望耗时约 4s(±1s 容忍)。
|
||||
func TestRateLimitedReaderAtRate(t *testing.T) {
|
||||
const rate = 100 * 1024
|
||||
const total = 400 * 1024
|
||||
src := io.LimitReader(bytes.NewReader(make([]byte, total+1024)), total)
|
||||
rl := NewRateLimitedReader(src, rate).(*rateLimitedReader)
|
||||
|
||||
buf := make([]byte, 32*1024) // 32KB 块
|
||||
start := time.Now()
|
||||
read := 0
|
||||
for {
|
||||
n, err := rl.Read(buf)
|
||||
read += n
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
}
|
||||
elapsed := time.Since(start)
|
||||
want := time.Duration(float64(time.Second) * float64(total) / float64(rate))
|
||||
if elapsed < want-time.Second {
|
||||
t.Fatalf("读取过快 elapsed=%v want>=%v", elapsed, want)
|
||||
}
|
||||
if elapsed > want+1500*time.Millisecond {
|
||||
t.Fatalf("读取过慢 elapsed=%v want<=%v", elapsed, want+1500*time.Millisecond)
|
||||
}
|
||||
if read != total {
|
||||
t.Fatalf("读到 %d 字节,期望 %d", read, total)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRateLimitedReaderZeroPassthrough 速率 0 时不应引入任何延迟/包封。
|
||||
func TestRateLimitedReaderZeroPassthrough(t *testing.T) {
|
||||
src := bytes.NewReader([]byte("hello"))
|
||||
rl := NewRateLimitedReader(src, 0)
|
||||
if rl == src {
|
||||
// 透传:返回原 reader
|
||||
} else if _, ok := rl.(*rateLimitedReader); ok {
|
||||
// 0 速率时按实现可走 enabled=false(不退化亦可)
|
||||
}
|
||||
// 关键:必须能读完
|
||||
b, err := io.ReadAll(rl)
|
||||
if err != nil || string(b) != "hello" {
|
||||
t.Fatalf("0 速率透传失败: %q %v", b, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapReadCloserClose 验证包裹后 Close 透传到底层。
|
||||
func TestWrapReadCloserClose(t *testing.T) {
|
||||
src := &closeCount{Reader: bytes.NewReader([]byte("xyz")), closed: 0}
|
||||
rc := WrapReadCloser(src, 50*1024)
|
||||
if rc == nil {
|
||||
t.Fatal("WrapReadCloser nil")
|
||||
}
|
||||
_, _ = io.ReadAll(rc)
|
||||
if err := rc.Close(); err != nil {
|
||||
t.Fatalf("close err: %v", err)
|
||||
}
|
||||
if src.closed != 1 {
|
||||
t.Fatalf("底层 Close 未被调用: %d", src.closed)
|
||||
}
|
||||
}
|
||||
|
||||
type closeCount struct {
|
||||
io.Reader
|
||||
closed int
|
||||
}
|
||||
|
||||
func (c *closeCount) Close() error { c.closed++; return nil }
|
||||
Reference in New Issue
Block a user