feat(cordis): add OpenFlare Cordis 架构改造设计

docs(changelog): 修正表述笔误

refactor(cordis): 磁盘缓存改用上上游能力并清理本地副本

按上游/下游归属规约:类型断言守卫已回流 Wavelet(f3d85d5,附回归用例),
本仓库删除 OpenFlare/plugins/server/pkg/cache 整包并改 import 到
Wavelet/pkg/cache/disk,同步后与上游零漂移。

验证:go build 通过;go test ./... exit 0(137 包 ok);256 条路由对拍与
232 条 swagger 操作均零差异;make build-all 四进制;前端零改动。

docs(cordis): 记录 T1 清理结果与五个复用阻塞点

refactor(cordis): server 复用上游 pkg 能力并删除等价本地副本

按上游/下游归属规约清理重复实现,删除 7 个与上游等价的本地包并改 import:
shared/response→pkg/response、pkg/{logger,mail,trace,httppool,cache/ram}→
上游同名包、infra/persistence/batchwriter→pkg/batchwriter。逐项核过差异:
httppool 逐字节相同;logger 的 Config 字段完全一致;response 的 7 个 Abort*
一致;cache/ram 换过去顺带把裸 go 变回带 panic 恢复的 util.Go。

两处非等价差异按语义处理:
- batchwriter.Stats 与 status DTO 原为类型别名,改为消费侧逐字段转换,
  避免 model 反向依赖基础设施类型;
- 上游 pkg/idgen 要求显式 Init(本地副本为懒加载自动初始化),本次保留本地
  副本,待与 infra 初始化一并迁移(已登记在清理计划)。

验证:go build 通过;go test ./... exit 0(138 包 ok);256 条路由对拍零差异;
make swagger 232 条操作零增减,且归一化后与旧文档深度相等——差异仅为
response.Any / logger.LogEntry 两个定义名随包路径改名,接口形状未变。

chore(cordis): 回流内核与 pkg/util 通用能力并清理 vendoring 污染

按新增的上游/下游归属规约:HandleRaw/BasePath 与版本比较、网络、格式化助手
属通用能力,已提交到 Wavelet 分支 feat/cordis-router-raw-routes,本仓库改为
纯同步获取(pkg/util 已零漂移),补丁登记保留至上游合并。

同时修掉我此前 git add -A 造成的污染:首次 vendoring 把上游工作区里被
gitignore 的运行期产物一起提交进来(upload 的 diskcache 缓存块 650 个与
driver_http/dist 前端构建物 380 个,共 12872 行/1030 文件)。sync-upstream.sh
现显式排除 uploads/dist/data/*.db,.gitignore 补上对应兜底规则。

AGENTS.md 增加上游/下游改动归属规约,并把仍指向前 Cordis 布局的硬性约束
(internal/router + Serve、internal/repository/logstore、internal/platform/bootstrap、
internal/cmd)改到当前插件路径。

验证:go build 通过;go test ./... exit 0(144 包 ok);make swagger 232 条
操作与基线逐条一致;make build-all 四进制;gofmt 干净。

feat(cordis): server 插件化并改由内核挂载控制面路由

新增 plugins/server/plugin.go:Apply 以 ctx.Router().Group(app.api_prefix)
声明根级与 /v1 全部路由;33 个注册函数由 *gin.RouterGroup 改为
core.RouterExtension,RegisterCollection 改用内核新增的 HandleRaw 保留
尾部斜杠变体,AdminMiddlewares 返回 []any(Go 不允许把 []T 展开为 ...any)。
删除 router.Serve 与 registerRoutes,装配根改为 core.App +
driver_http.New(WithEngine(router.BuildEngine())),监听、信号与优雅退出归内核;
前端 SPA 的 NoRoute 兜底因内核暂无贡献点而保留在引擎层。

路由保真证据:plugin_parity_test 对拍 baseline/routes-engine.txt 的 256 条
(方法 路径) 零差异;go test ./... exit 0(144 包 ok,含真实 handler 的
openflare/integration 用例走同一条挂载路径);make swagger 232 条操作与基线
逐条一致;golangci-lint 0 issues;make build-all 四进制;embed_frontend
标签编译通过;前端零改动。

已知待补:带 Redis 的实机 HTTP 冒烟(本机 6379 未启动,session store 与
改造前一样在建店阶段即 fatal),以及 bootstrap 的任务/设置/迁移注册迁入 Apply。

feat(core): RouterExtension 增加 HandleRaw 与 BasePath 以保真尾部斜杠路由

server 插件化的前置:Handle 经 cleanPath 会剥掉尾部斜杠,无法表达
/resource 与 /resource/ 两条不同路由,而 OpenFlare 有 20 个历史 list
端点两者都注册且部署关闭了 RedirectTrailingSlash,缺失即 404。新增
HandleRaw 与 BasePath(作用域包装器同样登记反注册),补 extpoints 用例;
并把 router.Serve 拆出 BuildEngine 以便交给 driver_http.WithEngine 复用,
新增路由表导出 harness,固化 256 条 (方法 路径) 基线供插件化对拍。
上游补丁登记于 backend/OpenFlare/upstream-patches.md,同步脚本改为按目录
前缀输出差异并在同步后提醒确认补丁是否仍在。

验证:go build 通过;go test ./... exit 0(143 包 ok);gofmt 干净。

docs(cordis): 记录 server 插件接入内核的可行路径与内核能力缺口

feat(cordis): agent/relay/flared 落地为内核驱动插件

三个边缘守护进程各新增 plugin.go,实现 core.Plugin + core.Driver
(自定义 DriverType 与同名 profile),装配与生命周期从 main 迁入
Apply/Start/Stop:Apply 负责 JSON 配置加载、运行环境与用户确保、
openresty/frps/frpc 管理器与各服务装配;Start 以 util.Go 拉起阻塞式
runner 与 GeoIP 周期更新;Stop 收敛主循环结果并在超时时报错而非静默。

入口改为 core.NewApp(core.WithProfile(...)) + Prepare/Run,保持
-config 旗标、默认路径、退出码与启动/停止日志不变。

验证:go build 通过;go test ./... exit 0(143 包 ok,含 3 个插件身份
与配置失败路径测试);make build-all 四进制产出;三进制实跑缺失配置
均 exit 1 且错误链保留 load {agent,relay,flared} config 原因;gofmt 干净。

refactor(cordis): 按功能职责拆分为 4 个插件与 share 共享层

backend/OpenFlare 不再平铺遗留分层,改为 plugins/{server,agent,relay,flared}
加 share/:控制面业务(openflare/admin/oauth/user/upload/cap/config/health 与
repository/model/infra/router 等支撑层)归 server;三个边缘守护进程各自成插件;
被两个以上插件消费的 protocol/geoip/wsclient/render/pagesarchive/edge 归 share。
同时把 pkg/util 与 buildinfo 合并回上游 pkg(上游已覆盖全部符号,仅 8 个函数与
2 个类型为 OpenFlare 独有,已一并迁入),装配根统一到 backend/cmd(含三个 daemon
入口),Dockerfile 与 release 工作流的构建路径和 -X 注入路径同步更新。

验证:go build 通过;go test ./... exit 0(141 包 ok);make swagger exit 0 且
232 条 API 操作与基线逐条一致;make build-all 产出 4 进制;-X 注入经二进制
strings 实测生效;日志后端直连门禁改写为按 server 插件业务域扫描并在扫描数为 0
时报错(防门禁静默失效);前端零改动。

feat(cordis): 落地 backend/share 共享层与上游同步脚本

跨插件共享资源(控制消息协议、GeoIP+iputil、边缘守护进程日志)从下游包
移入 backend/share,并声明其只能依赖 core/pkg 与标准/第三方库,禁止反向
引用下游业务与具体插件实现;新增 scripts/sync-upstream.sh 只覆盖
backend/{core,pkg,plugins},同步后 --check 报告零差异,证明与上游逐字一致。

go build 通过,go test ./... exit 0(142 包 ok),前端零改动。

refactor(cordis): 采用与 Wavelet 同构的单模块布局并引入上游内核

按上游结构落位:backend/{core,pkg,plugins} 为 Wavelet 上游拷贝,OpenFlare
全部业务收拢到上游 downstream 所对应的位置 backend/OpenFlare/,模块名保持
Wavelet 以保证上游 import 路径逐字一致、同步零改写;三个 daemon 入口移至
backend/OpenFlare/cmd,backend/cmd 与 main.go 作为控制面装配根。

行为不变:go build 通过,142 个测试包全绿(含上游插件测试),232 条 API
操作与改造前逐条一致,四进制产物正常,前端零改动。swagger 暂只扫描下游代码,
待 P4 挂载上游路由后再纳入 plugins/。

style: 修正模块路径改写导致的 import 分组排序漂移

refactor(layout): Go 代码迁入 backend/ 并将模块名简化为 OpenFlare

对齐上游 Wavelet 的仓库布局,为以第二 module 形态 vendoring Cordis 内核与
平台插件做准备:模块路径整体改写为 OpenFlare,Go 目标加 cd backend,
swaggo 产物移至 backend/docs 并把 json/yaml 复制回 docs/ 供站点消费,
Dockerfile 与 release 工作流的构建目录、ldflags 模块路径同步更新。

行为保持不变:232 条路由与改造前逐条一致,95 个测试包全绿,
四进制产物正常,前端零改动。

chore(cordis): 落地改造计划与 schema/路由基线

新增 legacy_dump_test 迁移快照 harness:在临时 sqlite 库上按生产顺序
(goose.UpTo → zone 导入 → goose.Up)跑完 76 个历史迁移并导出 schema 与
版本序列,作为改造前后一致性门禁的唯一事实来源。同时记录 232 条路由清单
与 foundation 实施计划。

docs(cordis): add OpenFlare Cordis 架构改造设计

明确上游以第二 module 形态 vendoring 进 backend/Wavelet、4 个插件
(server/agent/relay/flared) 全部装载内核,并规定保留 76 个历史 goose
迁移 + 一次性版本 stamp 桥接的迁移方案,配套三方 schema 一致性门禁,
确保已部署库不重跑历史、不丢数据。
This commit is contained in:
ryan
2026-08-29 19:28:39 +08:00
parent 9f79fb9969
commit dbaa3bf140
1327 changed files with 91634 additions and 4157 deletions
+154
View File
@@ -0,0 +1,154 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package cache provides in-process upload access-control caches.
package cache
import (
"Wavelet/pkg/logger"
"Wavelet/plugins/domain/upload/shared"
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"time"
"gorm.io/gorm"
uploadstorage "Wavelet/plugins/domain/upload/storage"
)
const fileAccessInvalidationChannel = "upload:file_access_invalidation"
var (
fileAccessWhitelistMu sync.RWMutex
fileAccessWhitelistTypes map[string]struct{}
fileAccessWhitelistValid bool
fileAccessWhitelistCheckedAt time.Time
)
// ResetAccessCaches clears in-process upload access caches.
func ResetAccessCaches() {
uploadstorage.ResetMigrationAccessCache()
fileAccessWhitelistMu.Lock()
fileAccessWhitelistValid = false
fileAccessWhitelistTypes = nil
fileAccessWhitelistMu.Unlock()
}
// PublishAccessCacheInvalidation broadcasts upload access cache eviction to all nodes.
func PublishAccessCacheInvalidation(ctx context.Context) {
if cache := shared.GetCache(ctx); cache != nil {
_ = cache.Invalidate(ctx, fileAccessInvalidationChannel)
}
ResetAccessCaches()
}
// IsFilePublic reports whether uploadType is in the public access whitelist.
func IsFilePublic(ctx context.Context, uploadType string) bool {
whitelist := loadFileAccessWhitelist(ctx)
_, ok := whitelist[strings.ToLower(uploadType)]
return ok
}
func loadFileAccessWhitelist(ctx context.Context) map[string]struct{} {
fileAccessWhitelistMu.RLock()
if fileAccessWhitelistValid && time.Since(fileAccessWhitelistCheckedAt) < time.Duration(shared.AccessCacheTTL)*time.Second {
types := fileAccessWhitelistTypes
fileAccessWhitelistMu.RUnlock()
return types
}
fileAccessWhitelistMu.RUnlock()
fileAccessWhitelistMu.Lock()
defer fileAccessWhitelistMu.Unlock()
if fileAccessWhitelistValid && time.Since(fileAccessWhitelistCheckedAt) < time.Duration(shared.AccessCacheTTL)*time.Second {
return fileAccessWhitelistTypes
}
types, err := fetchFileAccessWhitelist(ctx)
if err != nil {
logger.ErrorF(ctx, "[Upload] 读取公开访问白名单失败: %v", err)
if fileAccessWhitelistTypes != nil {
// A previously loaded list is still the best answer; keep it until its
// TTL rather than narrowing access because one read failed.
fileAccessWhitelistValid = true
fileAccessWhitelistCheckedAt = time.Now()
return fileAccessWhitelistTypes
}
// Nothing cached yet: serve the restricted default but stay invalid so the
// next request retries instead of pinning it for the whole TTL.
return fallbackFileAccessWhitelist()
}
fileAccessWhitelistTypes = types
fileAccessWhitelistValid = true
fileAccessWhitelistCheckedAt = time.Now()
return types
}
// fallbackFileAccessWhitelist is the restricted default used when nothing better is known.
func fallbackFileAccessWhitelist() map[string]struct{} {
return map[string]struct{}{strings.ToLower(shared.DefaultPublicUploadType): {}}
}
func fetchFileAccessWhitelist(ctx context.Context) (map[string]struct{}, error) {
whitelist, err := parseFileAccessWhitelist(ctx)
if err != nil {
return nil, err
}
types := make(map[string]struct{}, len(whitelist))
for _, item := range whitelist {
types[strings.ToLower(item)] = struct{}{}
}
return types, nil
}
// parseFileAccessWhitelist reads the configured public access types.
//
// A missing row or an empty value is a real answer and yields the default; only a
// read that actually fails is reported as an error, so a database outage is no
// longer mistaken for "the admin never configured a whitelist".
func parseFileAccessWhitelist(ctx context.Context) ([]string, error) {
var sc struct{ Value string }
db := shared.GetDB(ctx)
if db == nil {
return nil, errors.New("database not available")
}
if err := db.Table("w_system_configs").Where("key = ?", "file_access_whitelist").First(&sc).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return []string{shared.DefaultPublicUploadType}, nil
}
return nil, fmt.Errorf("read file_access_whitelist: %w", err)
}
if sc.Value == "" {
return []string{shared.DefaultPublicUploadType}, nil
}
var whitelist []string
if err := json.Unmarshal([]byte(sc.Value), &whitelist); err == nil && len(whitelist) > 0 {
return whitelist, nil
}
whitelist = parseCommaSeparatedWhitelist(sc.Value)
if len(whitelist) == 0 {
return []string{shared.DefaultPublicUploadType}, nil
}
return whitelist, nil
}
func parseCommaSeparatedWhitelist(value string) []string {
parts := strings.Split(value, ",")
whitelist := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
whitelist = append(whitelist, part)
}
}
return whitelist
}
@@ -0,0 +1,56 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package cache
import (
"Wavelet/plugins/domain/upload/shared"
"context"
"testing"
)
// configRow mirrors just enough of w_system_configs to rebuild it mid-test.
type configRow struct {
Key string `gorm:"primaryKey;size:64"`
Value string `gorm:"type:text;not null"`
Type string `gorm:"size:32;not null"`
}
// TableName returns the system config table.
func (configRow) TableName() string { return "w_system_configs" }
// TestWhitelistReadFailureIsNotCachedAsConfigured pins the defect where a failed
// read of the public access whitelist was discarded and the resulting restricted
// default was then cached as though the admin had configured it, narrowing access
// for the whole TTL with nothing to retry it sooner.
func TestWhitelistReadFailureIsNotCachedAsConfigured(t *testing.T) {
db, cleanup := shared.SetupTestEnv(t)
defer cleanup()
ResetAccessCaches()
t.Cleanup(ResetAccessCaches)
ctx := context.Background()
// Make the config unreadable, then take one sample through the failure path.
if err := db.Exec("DROP TABLE w_system_configs").Error; err != nil {
t.Fatalf("drop config table: %v", err)
}
if IsFilePublic(ctx, "document") {
t.Fatal(`document reported public while the whitelist cannot be read`)
}
// Restore a configured whitelist that includes document.
if err := db.AutoMigrate(&configRow{}); err != nil {
t.Fatalf("recreate config table: %v", err)
}
row := configRow{Key: "file_access_whitelist", Value: `["avatar","document"]`, Type: "system"}
if err := db.Create(&row).Error; err != nil {
t.Fatalf("seed whitelist: %v", err)
}
// A failed first read must not have been cached as a real answer, so this call
// has to re-read and see the configured list.
if !IsFilePublic(ctx, "document") {
t.Error("whitelist stayed pinned to the default after a read failure; the failed lookup must be retried")
}
}
@@ -0,0 +1,88 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package cache
import (
"Wavelet/plugins/domain/upload/shared"
"context"
"testing"
"time"
uploadstorage "Wavelet/plugins/domain/upload/storage"
)
func TestLoadMigrationAccessStateCachesResult(t *testing.T) {
_, cleanup := shared.SetupTestEnv(t)
defer cleanup()
ResetAccessCaches()
ctx := context.Background()
first := uploadstorage.LoadMigrationAccessState(ctx)
second := uploadstorage.LoadMigrationAccessState(ctx)
if first.ReadOnly != second.ReadOnly {
t.Fatalf("readOnly mismatch: first=%v second=%v", first.ReadOnly, second.ReadOnly)
}
if first.HasTarget != second.HasTarget {
t.Fatalf("hasTarget mismatch: first=%v second=%v", first.HasTarget, second.HasTarget)
}
}
func TestIsFilePublicUsesCachedWhitelist(t *testing.T) {
_, cleanup := shared.SetupTestEnv(t)
defer cleanup()
ResetAccessCaches()
ctx := context.Background()
if !IsFilePublic(ctx, "avatar") {
t.Fatal("expected avatar to be public by default")
}
if IsFilePublic(ctx, "attachment") {
t.Fatal("expected attachment to be private by default")
}
if !IsFilePublic(ctx, "AVATAR") {
t.Fatal("expected whitelist lookup to be case-insensitive")
}
}
func TestResetAccessCachesRefreshesWhitelist(t *testing.T) {
dbConn, cleanup := shared.SetupTestEnv(t)
defer cleanup()
ResetAccessCaches()
ctx := context.Background()
if !IsFilePublic(ctx, "avatar") {
t.Fatal("expected seeded avatar whitelist before reset")
}
if err := dbConn.Table("w_system_configs").Where("key = ?", "file_access_whitelist").Update("value", `["attachment"]`).Error; err != nil {
t.Fatalf("update whitelist config: %v", err)
}
ResetAccessCaches()
if !IsFilePublic(ctx, "attachment") {
t.Fatal("expected attachment to be public after whitelist refresh")
}
if IsFilePublic(ctx, "avatar") {
t.Fatal("expected avatar to be private after whitelist refresh")
}
}
func TestAccessCacheTTLExpires(t *testing.T) {
_, cleanup := shared.SetupTestEnv(t)
defer cleanup()
ResetAccessCaches()
ctx := context.Background()
_ = loadFileAccessWhitelist(ctx)
fileAccessWhitelistMu.Lock()
fileAccessWhitelistCheckedAt = time.Now().Add(-time.Duration(shared.AccessCacheTTL)*time.Second - time.Second)
fileAccessWhitelistMu.Unlock()
// Should still work after TTL by reloading from config.
if !IsFilePublic(ctx, "avatar") {
t.Fatal("expected whitelist reload after TTL expiration")
}
}
+131
View File
@@ -0,0 +1,131 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package cache
import (
"Wavelet/pkg/cache/ram"
"Wavelet/plugins/domain/upload/models"
"Wavelet/plugins/domain/upload/shared"
"context"
"fmt"
"time"
"gorm.io/gorm"
)
const (
uploadMetaRedisCacheTTL = 30 * 60 // seconds
uploadMetaRAMMaximumSize = 4096
uploadMetaInvalidationChan = "upload:meta_invalidation"
)
var uploadMetaRAM = ram.MustNew[uint64, models.Upload](ram.Options{MaximumSize: uploadMetaRAMMaximumSize})
func uploadMetaRedisKey(id uint64) string {
return fmt.Sprintf("upload:meta:%d", id)
}
func cloneUpload(u models.Upload) models.Upload {
return u
}
// PublishUploadMetaInvalidation broadcasts upload metadata cache eviction.
func PublishUploadMetaInvalidation(ctx context.Context, id uint64) {
if cache := shared.GetCache(ctx); cache != nil {
_ = cache.Invalidate(ctx, uploadMetaInvalidationChan)
}
EvictUploadMetaLocal(id)
}
// GetUploadByID loads upload metadata from RAM, Redis, or the database.
func GetUploadByID(ctx context.Context, id uint64) (models.Upload, error) {
if id == 0 {
return models.Upload{}, gorm.ErrRecordNotFound
}
// 1. RAM L1 Cache
if u, ok := uploadMetaRAM.GetIfPresent(id); ok {
return cloneUpload(u), nil
}
key := uploadMetaRedisKey(id)
// 2. Redis L2 Cache
if cache := shared.GetCache(ctx); cache != nil {
var u models.Upload
if err := cache.Get(ctx, key, &u); err == nil {
uploadMetaRAM.Set(id, u)
return cloneUpload(u), nil
}
}
// 3. Database L3 Source of Truth
var upload models.Upload
db := shared.GetDB(ctx)
if db == nil {
return models.Upload{}, gorm.ErrRecordNotFound
}
if err := db.
Where("id = ? AND status != ?", id, models.UploadStatusDeleted).
First(&upload).Error; err != nil {
return models.Upload{}, err
}
SetUploadMeta(ctx, upload)
return cloneUpload(upload), nil
}
// SetUploadMeta populates RAM and Redis caches with the provided upload metadata.
func SetUploadMeta(ctx context.Context, u models.Upload) {
if u.ID == 0 {
return
}
cloned := cloneUpload(u)
uploadMetaRAM.Set(u.ID, cloned)
if cache := shared.GetCache(ctx); cache != nil {
_ = cache.Set(ctx, uploadMetaRedisKey(u.ID), cloned, uploadMetaRedisCacheTTL*time.Second)
}
}
// EvictUploadMeta evicts an upload metadata record from RAM, Redis, and broadcasts eviction.
func EvictUploadMeta(ctx context.Context, id uint64) {
EvictUploadMetaLocal(id)
if cache := shared.GetCache(ctx); cache != nil {
_ = cache.Delete(ctx, uploadMetaRedisKey(id))
}
PublishUploadMetaInvalidation(ctx, id)
}
// EvictUploadMetaLocal removes upload metadata from the local process RAM cache only.
func EvictUploadMetaLocal(id uint64) {
uploadMetaRAM.Invalidate(id)
}
// ResetUploadMetaCache cleans up local memory cache.
func ResetUploadMetaCache() {
uploadMetaRAM.InvalidateAll()
}
// ResetUploadMetaCacheForTest clears the in-memory cache for tests.
func ResetUploadMetaCacheForTest() {
ResetUploadMetaCache()
}
// SetUploadMetaCache is a backward-compatible alias for SetUploadMeta.
func SetUploadMetaCache(ctx context.Context, u *models.Upload) {
if u != nil {
SetUploadMeta(ctx, *u)
}
}
// InvalidateUploadMetaCache is an alias for EvictUploadMeta.
func InvalidateUploadMetaCache(ctx context.Context, id uint64) {
EvictUploadMeta(ctx, id)
}
// StopUploadMetaCacheListener stops listener for tests.
func StopUploadMetaCacheListener() {}
+160
View File
@@ -0,0 +1,160 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package cache
import (
"Wavelet/pkg/testhelper"
"Wavelet/plugins/domain/upload/models"
"Wavelet/plugins/domain/upload/shared"
"context"
"testing"
"gorm.io/gorm"
)
func init() {
testhelper.RegisterCleanup(func() {
ResetUploadMetaCacheForTest()
})
}
func seedUpload(t *testing.T, dbConn *gorm.DB, upload models.Upload) {
t.Helper()
if err := dbConn.Create(&upload).Error; err != nil {
t.Fatalf("create upload: %v", err)
}
}
func TestGetUploadByIDLoadsFromDBAndPopulatesCache(t *testing.T) {
dbConn, cleanup := shared.SetupTestEnv(t)
defer cleanup()
ResetUploadMetaCacheForTest()
ctx := context.Background()
upload := models.Upload{
ID: 91001,
UserID: 1,
FileName: "cached.png",
FilePath: "cached.png",
FileSize: 12,
MimeType: "image/png",
Extension: "png",
Type: "avatar",
Status: models.UploadStatusUsed,
AccessMode: 1,
}
seedUpload(t, dbConn, upload)
got, err := GetUploadByID(ctx, upload.ID)
if err != nil {
t.Fatalf("GetUploadByID: %v", err)
}
if got.ID != upload.ID || got.FileName != upload.FileName {
t.Fatalf("unexpected upload: %+v", got)
}
if err := dbConn.Delete(&models.Upload{}, upload.ID).Error; err != nil {
t.Fatalf("delete upload from db: %v", err)
}
gotCached, err := GetUploadByID(ctx, upload.ID)
if err != nil {
t.Fatalf("GetUploadByID from RAM cache: %v", err)
}
if gotCached.ID != upload.ID {
t.Fatalf("expected RAM cache hit for upload %d", upload.ID)
}
}
func TestGetUploadByIDReadsFromRedisWhenRAMEmpty(t *testing.T) {
dbConn, cleanup := shared.SetupTestEnv(t)
defer cleanup()
ResetUploadMetaCacheForTest()
ctx := context.Background()
upload := models.Upload{
ID: 91002,
UserID: 1,
FileName: "redis.png",
FilePath: "redis.png",
FileSize: 8,
MimeType: "image/png",
Extension: "png",
Type: "avatar",
Status: models.UploadStatusPending,
AccessMode: 0,
}
seedUpload(t, dbConn, upload)
SetUploadMetaCache(ctx, &upload)
ResetUploadMetaCacheForTest()
if err := dbConn.Delete(&models.Upload{}, upload.ID).Error; err != nil {
t.Fatalf("delete upload from db: %v", err)
}
got, err := GetUploadByID(ctx, upload.ID)
if err != nil {
t.Fatalf("GetUploadByID from redis: %v", err)
}
if got.ID != upload.ID || got.FileName != upload.FileName {
t.Fatalf("unexpected upload from redis: %+v", got)
}
}
func TestInvalidateUploadMetaCacheClearsRAMAndRedis(t *testing.T) {
dbConn, cleanup := shared.SetupTestEnv(t)
defer cleanup()
ResetUploadMetaCacheForTest()
ctx := context.Background()
upload := models.Upload{
ID: 91003,
UserID: 1,
FileName: "invalidate.png",
FilePath: "invalidate.png",
FileSize: 4,
MimeType: "image/png",
Extension: "png",
Type: "avatar",
Status: models.UploadStatusUsed,
AccessMode: 1,
}
seedUpload(t, dbConn, upload)
SetUploadMetaCache(ctx, &upload)
InvalidateUploadMetaCache(ctx, upload.ID)
got, err := GetUploadByID(ctx, upload.ID)
if err != nil {
t.Fatalf("GetUploadByID after invalidate should reload from DB: %v", err)
}
if got.ID != upload.ID {
t.Fatalf("unexpected upload reloaded from DB: %+v", got)
}
}
func TestGetUploadByIDSkipsDeletedUploads(t *testing.T) {
dbConn, cleanup := shared.SetupTestEnv(t)
defer cleanup()
ResetUploadMetaCacheForTest()
ctx := context.Background()
upload := models.Upload{
ID: 91004,
UserID: 1,
FileName: "deleted.png",
FilePath: "deleted.png",
FileSize: 4,
MimeType: "image/png",
Extension: "png",
Type: "avatar",
Status: models.UploadStatusDeleted,
AccessMode: 1,
}
seedUpload(t, dbConn, upload)
if _, err := GetUploadByID(ctx, upload.ID); err == nil {
t.Fatal("expected error for deleted upload")
}
}
+38
View File
@@ -0,0 +1,38 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package upload 提供文件上传与下载功能
package upload
import "Wavelet/plugins/domain/upload/shared"
// 文件管理常量
const (
ErrNoFileSelected = shared.ErrNoFileSelected
ErrUnsupportedFormat = shared.ErrUnsupportedFormat
ErrProcessFileFailed = shared.ErrProcessFileFailed
ErrSaveFileFailed = shared.ErrSaveFileFailed
ErrOpenFileFailed = shared.ErrOpenFileFailed
ErrSaveUploadRecordFailed = shared.ErrSaveUploadRecordFailed
ErrGenericFileTooLarge = shared.ErrGenericFileTooLarge
ErrFileContentExtensionMismatch = shared.ErrFileContentExtensionMismatch
ErrFileValidationFailed = shared.ErrFileValidationFailed
ErrInvalidMetadataJSON = shared.ErrInvalidMetadataJSON
ErrInvalidFileID = shared.ErrInvalidFileID
ErrQueryUploadRecordFailed = shared.ErrQueryUploadRecordFailed
ErrInvalidBatchDownloadRequest = shared.ErrInvalidBatchDownloadRequest
ErrInvalidIDValueFormat = shared.ErrInvalidIDValueFormat
ErrRetrieveUploadRecordsFailed = shared.ErrRetrieveUploadRecordsFailed
ErrNoValidFilesForArchive = shared.ErrNoValidFilesForArchive
ErrInvalidParams = shared.ErrInvalidParams
ErrQueryFileCountFailed = shared.ErrQueryFileCountFailed
ErrQueryFileListFailed = shared.ErrQueryFileListFailed
ErrDeleteFileFailed = shared.ErrDeleteFileFailed
ErrStorageReadOnly = shared.ErrStorageReadOnly
ErrS3KeyRequired = shared.ErrS3KeyRequired
ErrS3KeyTooLongFormat = shared.ErrS3KeyTooLongFormat
ErrS3KeyStartsWithSlash = shared.ErrS3KeyStartsWithSlash
ErrS3KeyContainsNullBytes = shared.ErrS3KeyContainsNullBytes
ErrQueryUnusedUploadsFailed = shared.ErrQueryUnusedUploadsFailed
ErrUnauthorized = shared.ErrUnauthorized
)
+114
View File
@@ -0,0 +1,114 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package upload
import (
"Wavelet/plugins/domain/upload/cache"
"Wavelet/plugins/domain/upload/filesrv"
"Wavelet/plugins/domain/upload/handler"
"Wavelet/plugins/domain/upload/ingest"
uploadstats "Wavelet/plugins/domain/upload/stats"
uploadtask "Wavelet/plugins/domain/upload/task"
"Wavelet/plugins/domain/upload/util"
)
// HTTP handlers
var (
UploadFile = handler.UploadFile
DownloadFile = handler.DownloadFile
BatchDownloadFiles = handler.BatchDownloadFiles
ListFiles = handler.ListFiles
DeleteFile = handler.DeleteFile
GetDistinctUploadTypes = handler.GetDistinctUploadTypes
ListMyFiles = handler.ListMyFiles
DeleteMyFile = handler.DeleteMyFile
UpdateMyFile = handler.UpdateMyFile
GetFileStats = handler.GetFileStats
ServeFileByID = filesrv.ServeFileByID
)
// Programmatic ingest API
var (
Ingest = ingest.Ingest
Remove = ingest.Remove
RemoveOwned = ingest.RemoveOwned
FindByHash = ingest.FindByHash
)
// Ingest policy constants
const (
PolicyCreate = ingest.PolicyCreate
PolicyDedupNewRecord = ingest.PolicyDedupNewRecord
PolicyResolveExisting = ingest.PolicyResolveExisting
)
type (
// IngestRequest is the programmatic upload ingest payload.
IngestRequest = ingest.Request
// IngestResult reports ingest side effects.
IngestResult = ingest.Result
// IngestPolicy controls hash-collision behavior during ingest.
IngestPolicy = ingest.Policy
)
// Ingest errors
var (
ErrIngestForbidden = ingest.ErrForbidden
ErrIngestStorageReadOnly = ingest.ErrStorageReadOnly
)
// Cache management
var (
ResetAccessCaches = cache.ResetAccessCaches
PublishAccessCacheInvalidation = cache.PublishAccessCacheInvalidation
)
// Stats
var (
// Deprecated: use upload.Ingest or upload.Remove; stats are applied internally.
ApplyUploadStatsAdd = uploadstats.ApplyUploadStatsAdd
// Deprecated: use upload.Ingest or upload.Remove; stats are applied internally.
ApplyUploadStatsRemove = uploadstats.ApplyUploadStatsRemove
RebuildUploadStats = uploadstats.RebuildUploadStats
)
// Utilities
var (
CompressImageToWebP = util.CompressImageToWebP
ValidateS3Key = util.ValidateS3Key
)
// Task identifiers and metadata
const (
StorageMigrationTask = uploadtask.StorageMigrationTask
SystemCleanupTask = uploadtask.SystemCleanupTask
WarmImageCacheTask = uploadtask.WarmImageCacheTask
RebuildUploadStatsTask = uploadtask.RebuildUploadStatsTask
)
var (
// StorageMigrationMeta describes the storage migration async task.
StorageMigrationMeta = uploadtask.StorageMigrationMeta
// SystemCleanupMeta describes the orphaned upload cleanup task.
SystemCleanupMeta = uploadtask.SystemCleanupMeta
// WarmImageCacheMeta describes the image compression cache warmup task.
WarmImageCacheMeta = uploadtask.WarmImageCacheMeta
// RebuildUploadStatsMeta describes the upload stats rebuild task.
RebuildUploadStatsMeta = uploadtask.RebuildUploadStatsMeta
)
// MigrationHandler executes storage migration tasks.
type MigrationHandler = uploadtask.MigrationHandler
// SystemCleanupHandler removes orphaned upload files.
type SystemCleanupHandler = uploadtask.SystemCleanupHandler
// WarmImageCacheHandler pre-warms compressed image caches.
type WarmImageCacheHandler = uploadtask.WarmImageCacheHandler
// RebuildUploadStatsHandler rebuilds upload stats from active records.
type RebuildUploadStatsHandler = uploadtask.RebuildUploadStatsHandler
// WarmImageCachePayload is the payload for image cache warmup tasks.
type WarmImageCachePayload = uploadtask.WarmImageCachePayload
@@ -0,0 +1,361 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package filesrv serves uploaded files with access control and image compression.
package filesrv
import (
"Wavelet/core/contracts"
"Wavelet/pkg/logger"
"Wavelet/pkg/response"
"Wavelet/plugins/domain/upload/cache"
"Wavelet/plugins/domain/upload/models"
"Wavelet/plugins/domain/upload/shared"
"Wavelet/plugins/domain/upload/util"
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"sync"
pkgcache "Wavelet/pkg/cache/disk"
"Wavelet/pkg/ginutil"
"Wavelet/plugins/domain/upload/repository"
uploadstorage "Wavelet/plugins/domain/upload/storage"
"github.com/gin-gonic/gin"
"golang.org/x/sync/singleflight"
)
var (
compressedImageFlight singleflight.Group
globalDiskCache *pkgcache.Cache
globalDiskCacheOnce sync.Once
)
func getGlobalDiskCache() *pkgcache.Cache {
globalDiskCacheOnce.Do(func() {
globalDiskCache = pkgcache.New("uploads/diskcache")
})
return globalDiskCache
}
type compressedImageCacheResult struct {
bytes []byte
cached bool
err error
}
type fileTypeCategory string
const (
fileTypeImage fileTypeCategory = "image"
fileTypeVideo fileTypeCategory = "video"
fileTypeAudio fileTypeCategory = "audio"
fileTypeOther fileTypeCategory = "other"
)
// ServeFileByID 根据 ID 获取并提供已上传的文件
// @Summary 获取已上传文件
// @Description 根据文件 ID 获取并提供已上传的临时或正式文件,若配置了缓存则优先走本地缓存,否则从 S3 等后端存储读取并流式返回
// @Tags upload
// @Produce octet-stream
// @Param id path string true "文件 ID"
// @Param quality query string false "图片质量 (low, medium, high, origin),默认为 origin"
// @Success 200 {file} file "成功获取文件内容"
// @Failure 400 {object} response.Any "文件 ID 格式错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "文件未找到"
// @Failure 500 {object} response.Any "服务内部错误"
// @Router /f/{id} [get]
func ServeFileByID(c *gin.Context) {
upload, err := GetUploadRecordByID(c)
if err != nil {
if AbortUploadRecordError(c, err) {
return
}
response.AbortInternal(c, shared.ErrInternalServerError)
return
}
if err := CheckFileAccessPermission(c, upload); err != nil {
response.AbortUnauthorized(c, shared.ErrUnauthorized)
return
}
ServeUpload(c, upload)
}
// AbortUploadRecordError maps an upload record lookup failure to its HTTP
// response and reports whether it handled the error. A missing record is 404
// and a malformed path ID is 400; anything else is left to the caller.
func AbortUploadRecordError(c *gin.Context, err error) bool {
var numErr *strconv.NumError
switch {
case repository.IsRecordNotFound(err):
response.AbortNotFound(c, shared.ErrFileRecordNotFound)
return true
case errors.As(err, &numErr):
response.AbortBadRequest(c, shared.ErrInvalidFileID)
return true
default:
return false
}
}
// GetUploadRecordByID 从请求路径参数中解析文件 ID 并从数据库中检索处于 Pending 或 Used 状态的上传记录。
func GetUploadRecordByID(c *gin.Context) (*models.Upload, error) {
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Content-Security-Policy", "sandbox")
idStr := c.Param("id")
uploadID, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return nil, err
}
upload, err := cache.GetUploadByID(c.Request.Context(), uploadID)
if err != nil {
return nil, err
}
return &upload, nil
}
func getFileTypeCategory(upload *models.Upload) fileTypeCategory {
mime := strings.ToLower(upload.MimeType)
ext := strings.ToLower(upload.Extension)
if strings.HasPrefix(mime, "image/") || util.IsImageExtension(ext) {
return fileTypeImage
}
if strings.HasPrefix(mime, "video/") {
return fileTypeVideo
}
if strings.HasPrefix(mime, "audio/") {
return fileTypeAudio
}
return fileTypeOther
}
// ServeUpload 将已存在的文件内容读取并流式响应给客户端。
func ServeUpload(c *gin.Context, upload *models.Upload) {
setCacheHeaders(c, upload)
category := getFileTypeCategory(upload)
quality := util.NormalizeImageQuality(c.Query("quality"))
switch category {
case fileTypeImage:
if quality != shared.ImageQualityOrigin {
serveCompressedImage(c, upload, quality)
return
}
fallthrough
default:
serveOriginalWithConditionalCheck(c, upload)
}
}
func setCacheHeaders(c *gin.Context, upload *models.Upload) {
if cache.IsFilePublic(c.Request.Context(), upload.Type) {
c.Header("Cache-Control", "public, max-age=31536000")
} else {
c.Header("Cache-Control", "private, no-cache")
}
}
func serveOriginalWithConditionalCheck(c *gin.Context, upload *models.Upload) {
etag := fmt.Sprintf(`W/"%s"`, upload.Hash)
c.Header("ETag", etag)
if c.GetHeader("If-None-Match") == etag {
c.AbortWithStatus(http.StatusNotModified)
return
}
serveOriginal(c, upload)
}
func serveCompressedImage(c *gin.Context, upload *models.Upload, quality string) {
etag := fmt.Sprintf(`W/"%s-%s"`, upload.Hash, quality)
c.Header("ETag", etag)
if c.GetHeader("If-None-Match") == etag {
c.AbortWithStatus(http.StatusNotModified)
return
}
webpBytes, hit, err := EnsureCompressedImageCache(c.Request.Context(), upload, quality)
if hit {
c.Header("X-Cache", "HIT")
} else {
c.Header("X-Cache", "MISS")
}
if err != nil {
if len(webpBytes) > 0 {
logger.WarnF(c.Request.Context(), "failed to cache compressed image: %v", err)
c.Data(http.StatusOK, "image/webp", webpBytes)
return
}
logger.ErrorF(c.Request.Context(), "failed to prepare compressed image cache: %v", err)
serveOriginal(c, upload)
return
}
c.Data(http.StatusOK, "image/webp", webpBytes)
}
// EnsureCompressedImageCache returns cached or freshly generated WebP bytes for an upload.
func EnsureCompressedImageCache(
ctx context.Context,
upload *models.Upload,
quality string,
) ([]byte, bool, error) {
cacheStore := getGlobalDiskCache()
cacheKey := ImageCompressionCacheKey(upload, quality)
webpBytes, err := cacheStore.Get(cacheKey)
if err == nil {
return webpBytes, true, nil
}
if !errors.Is(err, pkgcache.ErrCacheMiss) {
return nil, false, fmt.Errorf("read compressed image cache: %w", err)
}
// The flight body serves every concurrent requester for this key, so it must
// not die when whichever caller happened to arrive first disconnects.
flightCtx := context.WithoutCancel(ctx)
result, err, _ := compressedImageFlight.Do(cacheKey, func() (any, error) {
return generateCompressedImageCache(flightCtx, upload, quality, cacheKey)
})
if err != nil {
return nil, false, err
}
res := result.(compressedImageCacheResult)
return res.bytes, res.cached, res.err
}
func generateCompressedImageCache(
ctx context.Context,
upload *models.Upload,
quality string,
cacheKey string,
) (compressedImageCacheResult, error) {
cacheStore := getGlobalDiskCache()
webpBytes, err := cacheStore.Get(cacheKey)
if err == nil {
return compressedImageCacheResult{bytes: webpBytes, cached: true}, nil
}
if !errors.Is(err, pkgcache.ErrCacheMiss) {
return compressedImageCacheResult{}, fmt.Errorf("read compressed image cache: %w", err)
}
origBytes, err := getOriginalFileBytes(ctx, upload)
if err != nil {
return compressedImageCacheResult{}, fmt.Errorf("read original image: %w", err)
}
webpBytes, err = util.CompressImageToWebP(bytes.NewReader(origBytes), quality)
if err != nil {
return compressedImageCacheResult{}, fmt.Errorf("compress image to WebP: %w", err)
}
if err := cacheStore.Set(cacheKey, webpBytes, pkgcache.NoExpiration); err != nil {
return compressedImageCacheResult{
bytes: webpBytes,
err: fmt.Errorf("write compressed image cache: %w", err),
}, nil
}
return compressedImageCacheResult{bytes: webpBytes}, nil
}
// ImageCompressionCacheKey returns the disk cache key for a compressed upload image.
func ImageCompressionCacheKey(upload *models.Upload, quality string) string {
return fmt.Sprintf(
"upload_webp_v1_%d_%d_%d_%s_%s",
upload.ID,
upload.UpdatedAt.UnixNano(),
upload.FileSize,
upload.Hash,
quality,
)
}
func serveOriginal(c *gin.Context, upload *models.Upload) {
obj, err := uploadstorage.OpenStoredObject(c.Request.Context(), upload)
if err != nil {
response.AbortNotFound(c, shared.ErrFileNotFound)
return
}
defer func() { _ = obj.Body.Close() }()
contentType := obj.ContentType
if upload.MimeType != "" {
contentType = upload.MimeType
}
c.DataFromReader(http.StatusOK, obj.ContentLength, contentType, obj.Body, nil)
}
func getOriginalFileBytes(ctx context.Context, upload *models.Upload) ([]byte, error) {
obj, err := uploadstorage.OpenStoredObject(ctx, upload)
if err != nil {
return nil, err
}
defer func() { _ = obj.Body.Close() }()
return io.ReadAll(obj.Body)
}
func checkPrivateFileOwner(c *gin.Context, ownerID uint64) error {
var currUserID uint64
var isAdmin bool
if u, ok := ginutil.GetFromContext[*contracts.UserDTO](c, contracts.AuthUserObjKey); ok && u != nil {
currUserID = u.ID
isAdmin = u.IsAdmin
} else if authSvc := shared.GetAuthService(c); authSvc != nil {
u, err := authSvc.GetCurrentUser(c)
if err != nil {
return err
}
currUserID = u.ID
isAdmin = u.IsAdmin
} else {
return errors.New("unauthorized")
}
if isAdmin {
return nil
}
if currUserID != ownerID {
return errors.New("forbidden: cross-user access denied")
}
return nil
}
// CheckFileAccessPermission 校验文件是否可以被当前请求访问
func CheckFileAccessPermission(c *gin.Context, upload *models.Upload) error {
if upload.AccessMode == 0 {
return checkPrivateFileOwner(c, upload.UserID)
}
if cache.IsFilePublic(c.Request.Context(), upload.Type) {
return nil
}
if _, ok := ginutil.GetFromContext[*contracts.UserDTO](c, contracts.AuthUserObjKey); ok {
return nil
}
authSvc := shared.GetAuthService(c)
if authSvc == nil {
return nil
}
_, err := authSvc.GetCurrentUser(c)
return err
}
@@ -0,0 +1,432 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package filesrv
import (
"Wavelet/core/contracts"
"Wavelet/pkg/response"
"Wavelet/pkg/testhelper"
"Wavelet/plugins/domain/upload/cache"
"Wavelet/plugins/domain/upload/models"
"Wavelet/plugins/domain/upload/shared"
"bytes"
"context"
"crypto/sha256"
"fmt"
"image"
"image/color"
"image/png"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
uploadutil "Wavelet/plugins/domain/upload/util"
)
func init() {
testhelper.RegisterCleanup(cache.ResetUploadMetaCacheForTest)
}
type localTestStorageService struct {
mu sync.RWMutex
root string
}
func (s *localTestStorageService) Put(_ context.Context, key string, body io.Reader, _ int64, _ string) (contracts.StoragePutResult, error) {
s.mu.Lock()
defer s.mu.Unlock()
path := filepath.Join(s.root, key)
_ = os.MkdirAll(filepath.Dir(path), 0o755)
f, err := os.Create(path)
if err != nil {
return contracts.StoragePutResult{}, err
}
defer f.Close()
_, err = io.Copy(f, body)
return contracts.StoragePutResult{Key: key, Bucket: "local"}, err
}
func (s *localTestStorageService) Get(ctx context.Context, key string) (*contracts.StorageObject, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
s.mu.RLock()
defer s.mu.RUnlock()
path := filepath.Join(s.root, key)
f, err := os.Open(path)
if err != nil {
return nil, err
}
info, _ := f.Stat()
return &contracts.StorageObject{
Key: key,
Body: f,
ContentLength: info.Size(),
ContentType: "image/png",
}, nil
}
func (s *localTestStorageService) Delete(_ context.Context, key string) error {
s.mu.Lock()
defer s.mu.Unlock()
return os.Remove(filepath.Join(s.root, key))
}
func (s *localTestStorageService) Ingest(_ context.Context, _ io.Reader, _ contracts.IngestOptions) (*contracts.IngestResult, error) {
return nil, nil
}
func TestServeFileByIDAccessControl(t *testing.T) {
dbConn, cleanup := shared.SetupTestEnv(t)
defer cleanup()
cache.ResetAccessCaches()
tempDir := t.TempDir()
storageSvc := &localTestStorageService{root: tempDir}
shared.SetStorageService(storageSvc)
// Create a user in DB
user := contracts.UserDTO{
ID: 12345,
Username: "file_test_user",
IsActive: true,
}
if err := dbConn.Table("w_users").Create(&user).Error; err != nil {
t.Fatalf("failed to create user: %v", err)
}
// Create an access token for this user
tokenStr := "test-secret-token-123"
tokenHash := fmt.Sprintf("%x", sha256.Sum256([]byte(tokenStr)))
tokenRecord := map[string]any{
"user_id": user.ID,
"name": "test_token",
"token_hash": tokenHash,
"masked_token": "test-***",
}
if err := dbConn.Table("w_access_tokens").Create(&tokenRecord).Error; err != nil {
t.Fatalf("failed to create token: %v", err)
}
// Create two files: one in whitelist (avatar), one not in whitelist (attachment)
avatarFile := models.Upload{
ID: 8001,
UserID: user.ID,
FileName: "avatar.png",
FilePath: "avatar.png",
FileSize: 5,
MimeType: "image/png",
Extension: "png",
Type: "avatar",
Status: models.UploadStatusUsed,
AccessMode: 1,
}
attachmentFile := models.Upload{
ID: 8002,
UserID: user.ID,
FileName: "doc.pdf",
FilePath: "doc.pdf",
FileSize: 5,
MimeType: "application/pdf",
Extension: "pdf",
Type: "attachment",
Status: models.UploadStatusUsed,
AccessMode: 1,
}
if err := os.WriteFile(filepath.Join(tempDir, "avatar.png"), []byte("image"), 0o644); err != nil {
t.Fatalf("failed to write avatar file: %v", err)
}
if err := os.WriteFile(filepath.Join(tempDir, "doc.pdf"), []byte("bytes"), 0o644); err != nil {
t.Fatalf("failed to write attachment file: %v", err)
}
dbConn.Create(&avatarFile)
dbConn.Create(&attachmentFile)
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(response.ErrorHandlerMiddleware())
store := cookie.NewStore([]byte("secret"))
r.Use(sessions.Sessions("wavelet_session_id", store))
r.GET("/f/:id", ServeFileByID)
t.Run("public access allowed for whitelist type (avatar)", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/f/8001", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200 for public file, got %d", w.Code)
}
})
t.Run("public access rejected for non-whitelist type (attachment)", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/f/8002", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected status 401 for private file without auth, got %d", w.Code)
}
})
t.Run("authenticated access allowed for non-whitelist type (attachment)", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/f/8002", nil)
req.Header.Set("Authorization", "Bearer "+tokenStr)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200 for authenticated request, got %d", w.Code)
}
})
t.Run("non-existent file returns 404", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/f/99999", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected status 404 for non-existent file, got %d", w.Code)
}
})
t.Run("invalid id format returns 400", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/f/invalid_id", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400 for invalid id format, got %d", w.Code)
}
})
}
func TestServeFileByIDImageCompression(t *testing.T) {
dbConn, cleanup := shared.SetupTestEnv(t)
defer cleanup()
cache.ResetAccessCaches()
tempDir := t.TempDir()
storageSvc := &localTestStorageService{root: tempDir}
shared.SetStorageService(storageSvc)
// Create test user
user := contracts.UserDTO{
ID: 54321,
Username: "compress_test_user",
IsActive: true,
}
dbConn.Table("w_users").Create(&user)
// Create a small 1x1 test image
img := image.NewRGBA(image.Rect(0, 0, 1, 1))
img.Set(0, 0, color.RGBA{R: 255, G: 0, B: 0, A: 255})
var pngBuf bytes.Buffer
if err := png.Encode(&pngBuf, img); err != nil {
t.Fatalf("failed to encode test png: %v", err)
}
filePath := filepath.Join(tempDir, "test_image.png")
if err := os.WriteFile(filePath, pngBuf.Bytes(), 0o644); err != nil {
t.Fatalf("failed to write test png: %v", err)
}
// Save upload record to DB
uploadRecord := models.Upload{
ID: 3001,
UserID: user.ID,
FileName: "test_image.png",
FilePath: "test_image.png",
FileSize: int64(pngBuf.Len()),
MimeType: "image/png",
Extension: "png",
Type: "avatar", // Whitelisted by default
Status: models.UploadStatusUsed,
AccessMode: 1,
}
dbConn.Create(&uploadRecord)
// Setup Router
gin.SetMode(gin.TestMode)
r := gin.New()
r.GET("/f/:id", ServeFileByID)
t.Run("serve original file without compress parameter", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/f/3001", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
if w.Header().Get("Content-Type") != "image/png" {
t.Errorf("expected Content-Type image/png, got %s", w.Header().Get("Content-Type"))
}
if w.Header().Get("X-Cache") != "" {
t.Errorf("expected no X-Cache header for original file, got %s", w.Header().Get("X-Cache"))
}
if w.Header().Get("ETag") == "" {
t.Errorf("expected ETag header for original file")
}
})
t.Run("first request with quality=medium produces cache MISS and converts to WebP", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/f/3001?quality=medium", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
if w.Header().Get("Content-Type") != "image/webp" {
t.Errorf("expected Content-Type image/webp, got %s", w.Header().Get("Content-Type"))
}
if w.Header().Get("X-Cache") != "MISS" {
t.Errorf("expected X-Cache MISS on first compress request, got %s", w.Header().Get("X-Cache"))
}
if w.Header().Get("ETag") == "" {
t.Errorf("expected ETag header")
}
if len(w.Body.Bytes()) == 0 {
t.Errorf("expected non-empty body")
}
})
t.Run("second request with quality=medium produces cache HIT", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/f/3001?quality=medium", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
if w.Header().Get("Content-Type") != "image/webp" {
t.Errorf("expected Content-Type image/webp, got %s", w.Header().Get("Content-Type"))
}
if w.Header().Get("X-Cache") != "HIT" {
t.Errorf("expected X-Cache HIT on second compress request, got %s", w.Header().Get("X-Cache"))
}
})
t.Run("request with quality=origin behaves like original request", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/f/3001?quality=origin", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
if w.Header().Get("Content-Type") != "image/png" {
t.Errorf("expected Content-Type image/png, got %s", w.Header().Get("Content-Type"))
}
if w.Header().Get("X-Cache") != "" {
t.Errorf("expected no X-Cache header for origin quality, got %s", w.Header().Get("X-Cache"))
}
})
t.Run("conditional GET with matching If-None-Match returns 304", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/f/3001?quality=medium", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
etag := w.Header().Get("ETag")
if etag == "" {
t.Fatalf("expected ETag header from initial request")
}
// Second request with If-None-Match
req2, _ := http.NewRequest("GET", "/f/3001?quality=medium", nil)
req2.Header.Set("If-None-Match", etag)
w2 := httptest.NewRecorder()
r.ServeHTTP(w2, req2)
if w2.Code != http.StatusNotModified {
t.Fatalf("expected status 304 Not Modified, got %d", w2.Code)
}
if w2.Body.Len() != 0 {
t.Errorf("expected empty body on 304 response, got %d bytes", w2.Body.Len())
}
})
}
// The singleflight body generates once on behalf of every concurrent requester
// sharing a cache key, so the caller that happens to arrive first must not be
// able to fail the others by disconnecting.
func TestEnsureCompressedImageCacheSurvivesCallerCancellation(t *testing.T) {
tempDir := t.TempDir()
shared.SetStorageService(&localTestStorageService{root: tempDir})
img := image.NewRGBA(image.Rect(0, 0, 1, 1))
img.Set(0, 0, color.RGBA{R: 0, G: 255, B: 0, A: 255})
var pngBuf bytes.Buffer
if err := png.Encode(&pngBuf, img); err != nil {
t.Fatalf("failed to encode test png: %v", err)
}
const filePath = "cancel_probe.png"
if err := os.WriteFile(filepath.Join(tempDir, filePath), pngBuf.Bytes(), 0o600); err != nil {
t.Fatalf("failed to write test png: %v", err)
}
upload := &models.Upload{
ID: 990001,
FilePath: filePath,
FileSize: int64(pngBuf.Len()),
MimeType: "image/png",
Extension: "png",
// Unique per run so the persistent disk cache can never serve this key.
Hash: fmt.Sprintf("cancel-probe-%d", time.Now().UnixNano()),
UpdatedAt: time.Now(),
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
webpBytes, cached, err := EnsureCompressedImageCache(ctx, upload, "medium")
if err != nil {
t.Fatalf("compressed image generation failed with a canceled caller context: %v", err)
}
if cached {
t.Errorf("expected a freshly generated image, got a cache hit")
}
if len(webpBytes) == 0 {
t.Errorf("expected non-empty webp bytes")
}
}
func TestNormalizeImageQuality(t *testing.T) {
tests := []struct {
name string
quality string
want string
}{
{name: "empty quality returns origin", quality: "", want: shared.ImageQualityOrigin},
{name: "origin returns origin", quality: "origin", want: shared.ImageQualityOrigin},
{name: "ORIGIN case-insensitive returns origin", quality: "ORIGIN", want: shared.ImageQualityOrigin},
{name: "low returns low", quality: "low", want: shared.ImageQualityLow},
{name: "LOW returns low", quality: "LOW", want: shared.ImageQualityLow},
{name: "medium returns medium", quality: "medium", want: shared.ImageQualityMedium},
{name: "high returns high", quality: "high", want: shared.ImageQualityHigh},
{name: "unknown quality defaults to origin", quality: "ultra_hd", want: shared.ImageQualityOrigin},
{name: "whitespace padded quality is trimmed", quality: " medium ", want: shared.ImageQualityMedium},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := uploadutil.NormalizeImageQuality(tt.quality); got != tt.want {
t.Errorf("NormalizeImageQuality(%q) = %q, want %q", tt.quality, got, tt.want)
}
})
}
}
@@ -0,0 +1,300 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package handler
import (
"Wavelet/core/contracts"
"Wavelet/pkg/ginutil"
"Wavelet/pkg/response"
"Wavelet/plugins/domain/upload/ingest"
"Wavelet/plugins/domain/upload/models"
"Wavelet/plugins/domain/upload/repository"
"Wavelet/plugins/domain/upload/shared"
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
uploadstorage "Wavelet/plugins/domain/upload/storage"
)
type listFilesRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
Keyword string `form:"keyword"`
Type string `form:"type"`
Extension string `form:"extension"`
UserID uint64 `form:"user_id"`
}
type listFilesResponse struct {
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Items []models.Upload `json:"items"`
}
// ListFiles 获取系统上传的文件列表
// @Summary 获取文件列表
// @Description 分页获取系统上传的文件列表,支持文件名关键词、业务类型、扩展名、上传用户ID过滤
// @Tags admin
// @Produce json
// @Param page query int false "页码(默认 1)"
// @Param page_size query int false "每页数量(默认 20,最大 100)"
// @Param keyword query string false "文件名关键词(模糊匹配)"
// @Param type query string false "业务分类过滤"
// @Param extension query string false "扩展名过滤"
// @Param user_id query int false "上传用户 ID 过滤"
// @Security SessionCookie
// @Success 200 {object} response.Any{data=listFilesResponse} "查询成功"
// @Failure 400 {object} response.Any "参数错误"
// @Router /api/v1/admin/uploads/files [get]
func ListFiles(c *gin.Context) {
ctx := c.Request.Context()
var req listFilesRequest
if err := c.ShouldBindQuery(&req); err != nil {
response.AbortBadRequest(c, shared.ErrInvalidParams)
return
}
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 || req.PageSize > 100 {
req.PageSize = 20
}
total, items, err := listUploadFiles(ctx, repository.UploadListFilter{
UserID: req.UserID,
Keyword: req.Keyword,
Type: req.Type,
Extension: req.Extension,
Page: req.Page,
PageSize: req.PageSize,
})
if err != nil {
response.AbortBadRequest(c, shared.ErrQueryFileListFailed)
return
}
c.JSON(http.StatusOK, response.OK(listFilesResponse{
Total: total,
Page: req.Page,
PageSize: req.PageSize,
Items: items,
}))
}
// DeleteFile 软删除指定的文件记录
// @Summary 删除文件
// @Description 将指定 ID 的文件状态置为 deleted(软删除)
// @Tags admin
// @Produce json
// @Param id path string true "文件 ID"
// @Security SessionCookie
// @Success 200 {object} response.Any "删除成功"
// @Failure 404 {object} response.Any "文件不存在"
// @Router /api/v1/admin/uploads/files/{id} [delete]
func DeleteFile(c *gin.Context) {
ctx := c.Request.Context()
if uploadstorage.ReadOnly(ctx) {
response.AbortConflict(c, shared.ErrStorageReadOnly)
return
}
uploadID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.AbortBadRequest(c, shared.ErrInvalidFileID)
return
}
if _, err := softDeleteUpload(ctx, uploadID); err != nil {
if isRecordNotFound(err) {
response.AbortNotFound(c, shared.ErrFileRecordNotFound)
return
}
response.AbortBadRequest(c, shared.ErrDeleteFileFailed)
return
}
c.JSON(http.StatusOK, response.OKNil())
}
// GetDistinctUploadTypes 获取所有已存在的文件业务分类列表
// @Summary 获取业务分类列表
// @Description 查询系统内所有不重复的上传业务分类标识(如 avatar, doc 等)
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=[]string} "查询成功"
// @Router /api/v1/admin/uploads/types [get]
func GetDistinctUploadTypes(c *gin.Context) {
ctx := c.Request.Context()
types, err := listDistinctUploadTypes(ctx)
if err != nil {
response.AbortBadRequest(c, shared.ErrQueryTypeListFailed)
return
}
c.JSON(http.StatusOK, response.OK(types))
}
type listMyFilesRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
Keyword string `form:"keyword"`
Type string `form:"type"`
Extension string `form:"extension"`
}
type listMyFilesResponse struct {
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Items []models.Upload `json:"items"`
}
// ListMyFiles 获取当前用户上传的文件列表
// @Summary 获取我的文件列表
// @Description 分页获取当前登录用户上传的文件,支持文件名关键词、业务类型、扩展名过滤
// @Tags upload
// @Produce json
// @Param page query int false "页码(默认 1)"
// @Param page_size query int false "每页数量(默认 20,最大 100)"
// @Param keyword query string false "文件名关键词(模糊匹配)"
// @Param type query string false "业务分类过滤"
// @Param extension query string false "扩展名过滤"
// @Security SessionCookie
// @Success 200 {object} response.Any{data=listMyFilesResponse} "查询成功"
// @Failure 401 {object} response.Any "未登录"
// @Router /api/v1/upload/my [get]
func ListMyFiles(c *gin.Context) {
currUser, _ := ginutil.GetFromContext[*contracts.UserDTO](c, contracts.AuthUserObjKey)
ctx := c.Request.Context()
var req listMyFilesRequest
if err := c.ShouldBindQuery(&req); err != nil {
response.AbortBadRequest(c, shared.ErrInvalidParams)
return
}
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 || req.PageSize > 100 {
req.PageSize = 20
}
total, items, err := listMyUploadFiles(ctx, currUser.ID, repository.UploadListFilter{
Keyword: req.Keyword,
Type: req.Type,
Extension: req.Extension,
Page: req.Page,
PageSize: req.PageSize,
})
if err != nil {
response.AbortBadRequest(c, shared.ErrQueryFileListFailed)
return
}
c.JSON(http.StatusOK, response.OK(listMyFilesResponse{
Total: total,
Page: req.Page,
PageSize: req.PageSize,
Items: items,
}))
}
// DeleteMyFile 软删除当前用户本人的文件
// @Summary 删除我的文件
// @Description 将当前用户本人的文件状态置为 deleted(软删除)
// @Tags upload
// @Produce json
// @Param id path string true "文件 ID"
// @Security SessionCookie
// @Success 200 {object} response.Any "删除成功"
// @Failure 403 {object} response.Any "无权操作"
// @Failure 404 {object} response.Any "文件不存在"
// @Router /api/v1/upload/{id} [delete]
func DeleteMyFile(c *gin.Context) {
currUser, _ := ginutil.GetFromContext[*contracts.UserDTO](c, contracts.AuthUserObjKey)
ctx := c.Request.Context()
if uploadstorage.ReadOnly(ctx) {
response.AbortConflict(c, shared.ErrStorageReadOnly)
return
}
uploadID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.AbortBadRequest(c, shared.ErrInvalidFileID)
return
}
if _, err := softDeleteOwnedUpload(ctx, currUser.ID, uploadID); err != nil {
if isRecordNotFound(err) {
response.AbortNotFound(c, shared.ErrFileRecordNotFound)
return
}
if errors.Is(err, ingest.ErrForbidden) {
response.AbortForbidden(c, shared.ErrOperationForbidden)
return
}
response.AbortBadRequest(c, shared.ErrDeleteFileFailed)
return
}
c.JSON(http.StatusOK, response.OKNil())
}
type updateMyFileRequest struct {
FileName string `json:"file_name" binding:"max=255"`
AccessMode *int `json:"access_mode" binding:"omitempty,oneof=0 1"`
}
// UpdateMyFile 更新当前用户本人的文件信息
// @Summary 更新我的文件信息
// @Description 更新当前用户本人的文件名或访问权限模式 (AccessMode)
// @Tags upload
// @Accept json
// @Produce json
// @Param id path string true "文件 ID"
// @Param request body updateMyFileRequest true "更新字段"
// @Security SessionCookie
// @Success 200 {object} response.Any{data=models.Upload} "更新成功"
// @Failure 403 {object} response.Any "无权操作"
// @Failure 404 {object} response.Any "文件不存在"
// @Router /api/v1/upload/{id} [put]
func UpdateMyFile(c *gin.Context) {
currUser, _ := ginutil.GetFromContext[*contracts.UserDTO](c, contracts.AuthUserObjKey)
ctx := c.Request.Context()
if uploadstorage.ReadOnly(ctx) {
response.AbortConflict(c, shared.ErrStorageReadOnly)
return
}
uploadID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.AbortBadRequest(c, shared.ErrInvalidFileID)
return
}
var req updateMyFileRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, shared.ErrInvalidParams)
return
}
updated, err := updateOwnedUpload(ctx, currUser.ID, uploadID, updateMyUploadInput(req))
if err != nil {
if isRecordNotFound(err) {
response.AbortNotFound(c, shared.ErrFileRecordNotFound)
return
}
if errors.Is(err, ingest.ErrForbidden) {
response.AbortForbidden(c, shared.ErrOperationForbidden)
return
}
response.AbortBadRequest(c, shared.ErrUpdateFileFailed)
return
}
c.JSON(http.StatusOK, response.OK(updated))
}
@@ -0,0 +1,65 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package handler
import (
"Wavelet/core/contracts"
"Wavelet/plugins/domain/upload/models"
"Wavelet/plugins/domain/upload/shared"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestGetDistinctUploadTypes(t *testing.T) {
dbConn, cleanup := shared.SetupTestEnv(t)
defer cleanup()
user := contracts.UserDTO{ID: 2222, Username: "test_user_2"}
dbConn.Table("w_users").Create(&user)
customUpload := models.Upload{
ID: 9001,
UserID: user.ID,
FileName: "custom.txt",
FilePath: "uploads/custom.txt",
FileSize: 10,
MimeType: "text/plain",
Extension: "txt",
Type: "custom_type_xyz",
Status: models.UploadStatusUsed,
}
dbConn.Create(&customUpload)
gin.SetMode(gin.TestMode)
r := gin.New()
r.GET("/api/v1/admin/uploads/types", GetDistinctUploadTypes)
req, _ := http.NewRequest("GET", "/api/v1/admin/uploads/types", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp struct {
ErrorMsg string `json:"error_msg"`
Data []string `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse JSON: %v", err)
}
if resp.ErrorMsg != "" {
t.Fatalf("unexpected error: %s", resp.ErrorMsg)
}
if len(resp.Data) != 1 || resp.Data[0] != "custom_type_xyz" {
t.Fatalf("expected ['custom_type_xyz'], got %v", resp.Data)
}
}
@@ -0,0 +1,83 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package handler
import (
"Wavelet/plugins/domain/upload/ingest"
"Wavelet/plugins/domain/upload/models"
"Wavelet/plugins/domain/upload/repository"
"context"
"sort"
)
func listUploadFiles(ctx context.Context, filter repository.UploadListFilter) (int64, []models.Upload, error) {
return repository.ListUploads(ctx, filter)
}
func listMyUploadFiles(ctx context.Context, userID uint64, filter repository.UploadListFilter) (int64, []models.Upload, error) {
filter.UserID = userID
return repository.ListUploads(ctx, filter)
}
func softDeleteUpload(ctx context.Context, uploadID uint64) (models.Upload, error) {
return ingest.Remove(ctx, uploadID)
}
func softDeleteOwnedUpload(ctx context.Context, userID, uploadID uint64) (models.Upload, error) {
return ingest.RemoveOwned(ctx, userID, uploadID)
}
func listDistinctUploadTypes(ctx context.Context) ([]string, error) {
types, err := repository.ListDistinctUploadTypes(ctx)
if err != nil {
return nil, err
}
sort.Strings(types)
return types, nil
}
type updateMyUploadInput struct {
FileName string
AccessMode *int
}
func updateOwnedUpload(ctx context.Context, userID, uploadID uint64, input updateMyUploadInput) (models.Upload, error) {
u, err := repository.GetActiveUploadByID(ctx, uploadID)
if err != nil {
return models.Upload{}, err
}
if u.UserID != userID {
return models.Upload{}, ingest.ErrForbidden
}
updates := make(map[string]any)
if input.FileName != "" {
updates["file_name"] = input.FileName
}
if input.AccessMode != nil {
updates["access_mode"] = *input.AccessMode
}
if err := repository.UpdateUpload(ctx, &u, updates); err != nil {
return models.Upload{}, err
}
if name, ok := updates["file_name"].(string); ok {
u.FileName = name
}
if mode, ok := updates["access_mode"].(int); ok {
u.AccessMode = mode
}
return u, nil
}
func listUploadsForBatchDownload(ctx context.Context, ids []uint64) ([]models.Upload, error) {
return repository.ListUploadsByIDs(ctx, ids)
}
func loadUploadStats(ctx context.Context) ([]models.UploadStat, error) {
return repository.ListUploadStats(ctx)
}
func isRecordNotFound(err error) bool {
return repository.IsRecordNotFound(err)
}
@@ -0,0 +1,326 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package handler provides upload HTTP API handlers.
package handler
import (
"Wavelet/core/contracts"
"Wavelet/pkg/logger"
"Wavelet/pkg/response"
"Wavelet/plugins/domain/upload/filesrv"
"Wavelet/plugins/domain/upload/ingest"
"Wavelet/plugins/domain/upload/models"
"Wavelet/plugins/domain/upload/shared"
"Wavelet/plugins/domain/upload/util"
"archive/zip"
"bufio"
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"path/filepath"
"strconv"
"strings"
"Wavelet/pkg/ginutil"
"github.com/gin-gonic/gin"
uploadstorage "Wavelet/plugins/domain/upload/storage"
)
type batchDownloadRequest struct {
IDs []string `json:"ids" binding:"required,min=1"`
}
// UploadFile 通用上传文件接口
// @Summary 上传文件
// @Description 支持各种类型的通用文件上传,支持自动文件类型检测、哈希计算与“秒传”去重
// @Tags upload
// @Accept multipart/form-data
// @Produce json
// @Param file formData file true "要上传的文件"
// @Param type formData string false "业务分类 (例如: avatar, attachment, doc,默认为 generic)"
// @Param metadata formData string false "额外的 JSON 格式元数据"
// @Security SessionCookie
// @Success 200 {object} response.Any{data=models.Upload} "上传成功"
// @Failure 400 {object} response.Any "请求参数错误或文件受限"
// @Failure 401 {object} response.Any "未登录"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/upload [post]
func UploadFile(c *gin.Context) {
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Content-Security-Policy", "sandbox")
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, shared.MaxUploadSize)
currUser, _ := ginutil.GetFromContext[*contracts.UserDTO](c, contracts.AuthUserObjKey)
ctx := c.Request.Context()
header, err := c.FormFile("file")
if err != nil {
response.AbortBadRequest(c, shared.ErrNoFileSelected)
return
}
file, err := header.Open()
if err != nil {
response.AbortBadRequest(c, shared.ErrOpenFileFailed)
return
}
defer func() { _ = file.Close() }()
if header.Size > shared.MaxUploadSize {
response.AbortBadRequest(c, shared.ErrGenericFileTooLarge)
return
}
origName := header.Filename
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(origName), "."))
if ext == "" {
ext = "bin"
}
hashWriter := sha256.New()
var buf bytes.Buffer
size, err := io.Copy(&buf, io.TeeReader(file, hashWriter))
if err != nil {
response.AbortBadRequest(c, shared.ErrProcessFileFailed)
return
}
fileHash := hex.EncodeToString(hashWriter.Sum(nil))
mimeType := detectMimeType(&buf, header, size)
if util.IsImageExtension(ext) && !strings.HasPrefix(mimeType, "image/") {
response.AbortBadRequest(c, shared.ErrFileContentExtensionMismatch)
return
}
uploadType := c.DefaultPostForm("type", "generic")
accessMode, errMsg := resolveUploadAccessMode(c, uploadType)
if errMsg != "" {
response.AbortBadRequest(c, errMsg)
return
}
meta, errMsg := parseUploadMetadata(c, mimeType)
if errMsg != "" {
response.AbortBadRequest(c, errMsg)
return
}
result, err := ingest.Ingest(ctx, ingest.Request{
UserID: currUser.ID,
Reader: bytes.NewReader(buf.Bytes()),
Size: size,
FileName: origName,
MimeType: mimeType,
Extension: ext,
Hash: fileHash,
Type: uploadType,
AccessMode: &accessMode,
Metadata: meta,
Policy: ingest.PolicyDedupNewRecord,
})
if err != nil {
if errors.Is(err, ingest.ErrStorageReadOnly) {
response.AbortConflict(c, shared.ErrStorageReadOnly)
return
}
if err.Error() == shared.ErrUnsupportedFormat {
response.AbortBadRequest(c, shared.ErrUnsupportedFormat)
return
}
if err.Error() == shared.ErrSaveFileFailed {
response.AbortBadRequest(c, shared.ErrSaveFileFailed)
return
}
response.AbortBadRequest(c, shared.ErrSaveUploadRecordFailed)
return
}
c.JSON(http.StatusOK, response.OK(result.Upload))
}
// DownloadFile 通用单文件下载接口
// @Summary 下载单文件
// @Description 根据文件 ID 获取文件,以附件形式 (Attachment) 强制开启客户端浏览器下载
// @Tags admin
// @Produce octet-stream
// @Param id path string true "文件 ID"
// @Param quality query string false "图片质量 (low, medium, high, origin),默认为 origin"
// @Security SessionCookie
// @Success 200 {file} file "成功下载文件"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 404 {object} response.Any "文件不存在"
// @Failure 500 {object} response.Any "服务内部错误"
// @Router /api/v1/admin/uploads/download/{id} [get]
func DownloadFile(c *gin.Context) {
upload, err := filesrv.GetUploadRecordByID(c)
if err != nil {
if filesrv.AbortUploadRecordError(c, err) {
return
}
response.AbortBadRequest(c, shared.ErrQueryUploadRecordFailed)
return
}
if err := filesrv.CheckFileAccessPermission(c, upload); err != nil {
response.AbortUnauthorized(c, shared.ErrUnauthorized)
return
}
fileName := upload.FileName
quality := util.NormalizeImageQuality(c.Query("quality"))
isImage := strings.HasPrefix(strings.ToLower(upload.MimeType), "image/") || util.IsImageExtension(strings.ToLower(upload.Extension))
if quality != shared.ImageQualityOrigin && isImage {
ext := filepath.Ext(fileName)
if ext != "" {
fileName = strings.TrimSuffix(fileName, ext) + ".webp"
} else {
fileName += ".webp"
}
}
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename*=UTF-8''%s", url.PathEscape(fileName)))
filesrv.ServeUpload(c, upload)
}
// BatchDownloadFiles 批量打包 ZIP 下载接口
// @Summary 批量打包下载
// @Description 传入多个文件 ID,后台实时将其打包压缩为 ZIP 流并输出,自动处理文件名重复冲突
// @Tags admin
// @Accept json
// @Produce octet-stream
// @Param request body handler.batchDownloadRequest true "包含文件 ID 数组 of string 的请求体"
// @Security SessionCookie
// @Success 200 {file} file "成功下载打包后的 ZIP"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 500 {object} response.Any "打包失败"
// @Router /api/v1/admin/uploads/download/batch [post]
func BatchDownloadFiles(c *gin.Context) {
ctx := c.Request.Context()
var req batchDownloadRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, shared.ErrInvalidBatchDownloadRequest)
return
}
var ids []uint64
for _, idStr := range req.IDs {
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
response.AbortBadRequest(c, fmt.Sprintf(shared.ErrInvalidIDValueFormat, idStr))
return
}
ids = append(ids, id)
}
uploads, err := listUploadsForBatchDownload(ctx, ids)
if err != nil {
response.AbortBadRequest(c, shared.ErrRetrieveUploadRecordsFailed)
return
}
if len(uploads) == 0 {
response.AbortBadRequest(c, shared.ErrNoValidFilesForArchive)
return
}
c.Header("Content-Type", "application/zip")
c.Header("Content-Disposition", "attachment; filename=\"batch_download.zip\"")
bufferedWriter := bufio.NewWriter(c.Writer)
zipWriter := zip.NewWriter(bufferedWriter)
defer func() {
_ = zipWriter.Close()
_ = bufferedWriter.Flush()
}()
usedNames := make(map[string]int)
for _, upload := range uploads {
if err := filesrv.CheckFileAccessPermission(c, &upload); err != nil {
logger.WarnF(ctx, "Batch download: skip file %d due to permission denied: %v", upload.ID, err)
continue
}
fileName := upload.FileName
if count, exists := usedNames[fileName]; exists {
usedNames[fileName] = count + 1
ext := filepath.Ext(fileName)
base := strings.TrimSuffix(fileName, ext)
fileName = fmt.Sprintf("%s_%d%s", base, count, ext)
} else {
usedNames[fileName] = 1
}
zipFileEntry, err := zipWriter.Create(fileName)
if err != nil {
logger.ErrorF(ctx, "ZIP 添加条目失败 [%s]: %v", fileName, err)
continue
}
obj, err := uploadstorage.OpenStoredObject(ctx, &upload)
if err != nil {
logger.ErrorF(ctx, "打包时读取文件失败: %v", err)
continue
}
rc := obj.Body
_, err = io.Copy(zipFileEntry, rc)
_ = rc.Close()
if err != nil {
logger.ErrorF(ctx, "写入 ZIP 流失败: %v", err)
}
}
}
func resolveUploadAccessMode(c *gin.Context, uploadType string) (int, string) {
accessModeStr := c.PostForm("access_mode")
if accessModeStr == "" {
if uploadType == shared.DefaultPublicUploadType {
return 1, ""
}
return 0, ""
}
accessMode, err := strconv.Atoi(accessModeStr)
if err != nil || (accessMode != 0 && accessMode != 1) {
return 0, shared.ErrInvalidAccessModeParam
}
return accessMode, ""
}
func parseUploadMetadata(c *gin.Context, mimeType string) (models.UploadMetadata, string) {
var meta models.UploadMetadata
metadataStr := c.DefaultPostForm("metadata", "")
if metadataStr != "" {
if err := json.Unmarshal([]byte(metadataStr), &meta); err != nil {
return meta, shared.ErrInvalidMetadataJSON
}
}
meta.OriginalMime = mimeType
meta.UserAgent = c.Request.UserAgent()
meta.ClientIP = c.ClientIP()
return meta, ""
}
func detectMimeType(buf *bytes.Buffer, header *multipart.FileHeader, size int64) string {
mimeType := http.DetectContentType(buf.Bytes()[:min(shared.DetectContentBytes, int(size))])
if mimeType == "application/octet-stream" && header.Header.Get("Content-Type") != "" {
mimeType = header.Header.Get("Content-Type")
}
return mimeType
}
@@ -0,0 +1,983 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package handler
import (
"Wavelet/core/contracts"
"Wavelet/pkg/ginutil"
"Wavelet/pkg/response"
"Wavelet/plugins/domain/upload/models"
"Wavelet/plugins/domain/upload/shared"
"archive/zip"
"bytes"
"context"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/gin-gonic/gin"
"Wavelet/pkg/idgen"
uploadstats "Wavelet/plugins/domain/upload/stats"
)
type testResponse struct {
ErrorMsg string `json:"error_msg"`
Data json.RawMessage `json:"data"`
}
func setupTestRouter(authUser *contracts.UserDTO) *gin.Engine {
_ = idgen.Init(1)
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(response.ErrorHandlerMiddleware())
authMiddleware := func(c *gin.Context) {
if authUser != nil {
ginutil.SetToContext[*contracts.UserDTO](c, contracts.AuthUserObjKey, authUser)
}
c.Next()
}
uploadGroup := r.Group("/api/v1/upload")
uploadGroup.Use(authMiddleware)
{
uploadGroup.POST("", UploadFile)
uploadGroup.GET("/my", ListMyFiles)
uploadGroup.DELETE("/:id", DeleteMyFile)
uploadGroup.PUT("/:id", UpdateMyFile)
uploadGroup.GET("/download/:id", DownloadFile)
uploadGroup.POST("/download/batch", BatchDownloadFiles)
}
adminGroup := r.Group("/api/v1/admin/uploads")
adminGroup.Use(authMiddleware)
{
adminGroup.GET("", ListFiles)
adminGroup.GET("/stats", GetFileStats)
adminGroup.DELETE("/:id", DeleteFile)
adminGroup.GET("/download/:id", DownloadFile)
adminGroup.POST("/download/batch", BatchDownloadFiles)
}
return r
}
func createMultipartRequest(t *testing.T, fieldName, fileName string, fileContent []byte, extraFields map[string]string) (string, *bytes.Buffer) {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile(fieldName, fileName)
if err != nil {
t.Fatalf("failed to create form file: %v", err)
}
_, err = part.Write(fileContent)
if err != nil {
t.Fatalf("failed to write file content: %v", err)
}
for k, v := range extraFields {
err = writer.WriteField(k, v)
}
err = writer.Close()
if err != nil {
t.Fatalf("failed to close multipart writer: %v", err)
}
return writer.FormDataContentType(), body
}
type handlerTestStorage struct {
mu sync.RWMutex
mockFiles map[string][]byte
putCount *int
}
func (s *handlerTestStorage) Put(_ context.Context, key string, body io.Reader, _ int64, _ string) (contracts.StoragePutResult, error) {
s.mu.Lock()
defer s.mu.Unlock()
data, _ := io.ReadAll(body)
s.mockFiles[key] = data
if s.putCount != nil {
*s.putCount++
}
if strings.HasPrefix(key, "uploads/") {
_ = os.MkdirAll(filepath.Dir(key), 0o755)
_ = os.WriteFile(key, data, 0o644)
}
return contracts.StoragePutResult{Key: key, Bucket: "test-bucket"}, nil
}
func (s *handlerTestStorage) Get(_ context.Context, key string) (*contracts.StorageObject, error) {
s.mu.RLock()
defer s.mu.RUnlock()
data, ok := s.mockFiles[key]
if ok {
return &contracts.StorageObject{
Key: key,
Body: io.NopCloser(bytes.NewReader(data)),
ContentLength: int64(len(data)),
ContentType: "application/octet-stream",
}, nil
}
if f, err := os.Open(key); err == nil {
info, _ := f.Stat()
return &contracts.StorageObject{
Key: key,
Body: f,
ContentLength: info.Size(),
ContentType: "application/octet-stream",
}, nil
}
return nil, os.ErrNotExist
}
func (s *handlerTestStorage) Delete(_ context.Context, key string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.mockFiles, key)
return nil
}
func (s *handlerTestStorage) Ingest(_ context.Context, _ io.Reader, _ contracts.IngestOptions) (*contracts.IngestResult, error) {
return nil, nil
}
func TestUploadFile(t *testing.T) {
dbConn, cleanup := shared.SetupTestEnv(t)
defer cleanup()
defer func() { _ = os.RemoveAll("uploads") }() // Clean up local files created during tests
authUser := &contracts.UserDTO{ID: 1001, Username: "test_user"}
router := setupTestRouter(authUser)
var putCount int
mockStorage := &handlerTestStorage{
mockFiles: make(map[string][]byte),
putCount: &putCount,
}
shared.SetStorageService(mockStorage)
t.Run("upload allowed image file successfully", func(t *testing.T) {
putCount = 0
imgContent := []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89") // Valid PNG header
contentType, body := createMultipartRequest(t, "file", "test.png", imgContent, map[string]string{
"type": "avatar",
"metadata": `{"extra":{"source":"test_runner"}}`,
})
req, _ := http.NewRequest("POST", "/api/v1/upload", body)
req.Header.Set("Content-Type", contentType)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. Body: %s", w.Code, w.Body.String())
}
var resp testResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if resp.ErrorMsg != "" {
t.Fatalf("expected success response, got failure: %s", resp.ErrorMsg)
}
// Verify database record
var uploadRecord models.Upload
if err := json.Unmarshal(resp.Data, &uploadRecord); err != nil {
t.Fatalf("failed to unmarshal upload record: %v", err)
}
var dbRecord models.Upload
if err := dbConn.First(&dbRecord, uploadRecord.ID).Error; err != nil {
t.Fatalf("failed to retrieve database record: %v", err)
}
if dbRecord.FileName != "test.png" || dbRecord.Extension != "png" {
t.Errorf("incorrect filename or extension: %s, %s", dbRecord.FileName, dbRecord.Extension)
}
if dbRecord.MimeType != "image/png" {
t.Errorf("incorrect mime type detected: %s", dbRecord.MimeType)
}
if dbRecord.Metadata.Extra["source"] != "test_runner" {
t.Errorf("expected extra meta 'source' to be 'test_runner', got %v", dbRecord.Metadata.Extra)
}
if putCount != 1 {
t.Errorf("expected 1 storage Put operation, got %d", putCount)
}
})
t.Run("upload blocked extension file", func(t *testing.T) {
// System config allowed: jpg,png,webp. Uploading docx should be blocked.
contentType, body := createMultipartRequest(t, "file", "contract.docx", []byte("fake docx content"), nil)
req, _ := http.NewRequest("POST", "/api/v1/upload", body)
req.Header.Set("Content-Type", contentType)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d. Body: %s", w.Code, w.Body.String())
}
var resp testResponse
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if resp.ErrorMsg == "" || !strings.Contains(resp.ErrorMsg, shared.ErrUnsupportedFormat) {
t.Errorf("expected unsupported format error, got: %v", resp)
}
})
t.Run("instant upload deduplication (秒传)", func(t *testing.T) {
putCount = 0
imgContent := []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01")
// Upload first time
contentType1, body1 := createMultipartRequest(t, "file", "avatar1.png", imgContent, map[string]string{"type": "avatar"})
req1, _ := http.NewRequest("POST", "/api/v1/upload", body1)
req1.Header.Set("Content-Type", contentType1)
w1 := httptest.NewRecorder()
router.ServeHTTP(w1, req1)
if w1.Code != http.StatusOK {
t.Fatalf("first upload failed: %s", w1.Body.String())
}
if putCount != 1 {
t.Errorf("expected 1 put count on first upload, got %d", putCount)
}
// Upload same file second time (different filename, same content)
contentType2, body2 := createMultipartRequest(t, "file", "avatar2.png", imgContent, map[string]string{"type": "avatar"})
req2, _ := http.NewRequest("POST", "/api/v1/upload", body2)
req2.Header.Set("Content-Type", contentType2)
w2 := httptest.NewRecorder()
router.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("second upload failed: %s", w2.Body.String())
}
var resp2 testResponse
_ = json.Unmarshal(w2.Body.Bytes(), &resp2)
if resp2.ErrorMsg != "" {
t.Fatalf("second upload was unsuccessful: %s", resp2.ErrorMsg)
}
var uploadRecord2 models.Upload
if err := json.Unmarshal(resp2.Data, &uploadRecord2); err != nil {
t.Fatalf("failed to unmarshal second upload record: %v", err)
}
// Check if it triggered another storage put
if putCount != 1 {
t.Errorf("PutObject was triggered again! Expected deduplication (putCount=1), got putCount=%d", putCount)
}
// Check if database contains both records sharing the same FilePath
var records []models.Upload
dbConn.Where("hash = ?", uploadRecord2.Hash).Find(&records)
if len(records) != 2 {
t.Errorf("expected 2 database records sharing the same hash, got %d", len(records))
}
if records[0].FilePath != records[1].FilePath {
t.Errorf("file paths are different: %s vs %s", records[0].FilePath, records[1].FilePath)
}
if records[0].ID == records[1].ID {
t.Error("database record IDs should be unique")
}
t.Logf("Instant upload success. Record 1: %d, Record 2: %d", records[0].ID, records[1].ID)
})
t.Run("upload in local storage fallback mode", func(t *testing.T) {
// Seed allowed extensions configuration to allow txt files
dbConn.Table("w_system_configs").Where("key = ?", "upload_allowed_extensions").Update("value", "jpg,png,webp,txt")
contentType, body := createMultipartRequest(t, "file", "doc.txt", []byte("hello world generic document file"), map[string]string{
"type": "document",
})
req, _ := http.NewRequest("POST", "/api/v1/upload", body)
req.Header.Set("Content-Type", contentType)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. Body: %s", w.Code, w.Body.String())
}
var resp testResponse
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if resp.ErrorMsg != "" {
t.Fatalf("local upload failed: %s", resp.ErrorMsg)
}
var localRecord models.Upload
if err := json.Unmarshal(resp.Data, &localRecord); err != nil {
t.Fatalf("failed to unmarshal local upload record: %v", err)
}
// Confirm file was actually written to local disk
fileContent, err := os.ReadFile(localRecord.FilePath)
if err != nil {
t.Fatalf("failed to read local file: %v", err)
}
if string(fileContent) != "hello world generic document file" {
t.Errorf("unexpected local file contents: %s", string(fileContent))
}
})
}
func TestDownloadFile(t *testing.T) {
dbConn, cleanup := shared.SetupTestEnv(t)
defer cleanup()
authUser := &contracts.UserDTO{ID: 1001, Username: "test_user"}
router := setupTestRouter(authUser)
// Seed upload records in DB
localUpload := models.Upload{
ID: 2001,
UserID: 1001,
FileName: "中文文件名.txt",
FilePath: "uploads/test_download.txt",
FileSize: 12,
MimeType: "text/plain",
Extension: "txt",
Status: models.UploadStatusUsed,
}
// Seed file content into the in-memory mock storage service.
storageSvc := shared.NewMockStorageService()
storageSvc.PutRaw(localUpload.FilePath, []byte("hello download"))
shared.SetStorageService(storageSvc)
dbConn.Create(&localUpload)
t.Run("download file successfully", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/api/v1/admin/uploads/download/2001", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. Body: %s", w.Code, w.Body.String())
}
if w.Body.String() != "hello download" {
t.Errorf("expected body 'hello download', got '%s'", w.Body.String())
}
// Verify Content-Disposition header (supports UTF-8 escaping)
contentDisp := w.Header().Get("Content-Disposition")
expectedDisp := "attachment; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%96%87%E4%BB%B6%E5%90%8D.txt"
if contentDisp != expectedDisp {
t.Errorf("expected Content-Disposition header %q, got %q", expectedDisp, contentDisp)
}
if !strings.HasPrefix(w.Header().Get("Content-Type"), "text/plain") {
t.Errorf("expected Content-Type starting with text/plain, got %s", w.Header().Get("Content-Type"))
}
})
t.Run("download non-existent file", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/api/v1/admin/uploads/download/9999", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d", w.Code)
}
})
}
func TestListFiles(t *testing.T) {
dbConn, cleanup := shared.SetupTestEnv(t)
defer cleanup()
authUser := &contracts.UserDTO{ID: 1001, Username: "test_user"}
router := setupTestRouter(authUser)
uploads := []models.Upload{
{
ID: 2101,
UserID: authUser.ID,
FileName: "first-report.txt",
FilePath: "uploads/first-report.txt",
FileSize: 10,
MimeType: "text/plain",
Extension: "txt",
Status: models.UploadStatusUsed,
},
{
ID: 2102,
UserID: authUser.ID,
FileName: "Second-Photo.PNG",
FilePath: "uploads/second-photo.png",
FileSize: 20,
MimeType: "image/png",
Extension: "png",
Status: models.UploadStatusUsed,
},
{
ID: 2103,
UserID: authUser.ID,
FileName: "third-notes.md",
FilePath: "uploads/third-notes.md",
FileSize: 30,
MimeType: "text/markdown",
Extension: "md",
Status: models.UploadStatusUsed,
},
{
ID: 2104,
UserID: 2002,
FileName: "other-user.txt",
FilePath: "uploads/other-user.txt",
FileSize: 40,
MimeType: "text/plain",
Extension: "txt",
Status: models.UploadStatusUsed,
},
}
for i := range uploads {
if err := dbConn.Create(&uploads[i]).Error; err != nil {
t.Fatalf("failed to create upload %d: %v", uploads[i].ID, err)
}
}
t.Run("returns requested page", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/api/v1/admin/uploads?page=2&page_size=2", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
var resp testResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.ErrorMsg != "" {
t.Fatalf("ListFiles() error = %q, want empty", resp.ErrorMsg)
}
var got listFilesResponse
if err := json.Unmarshal(resp.Data, &got); err != nil {
t.Fatalf("failed to parse list response: %v", err)
}
if got.Page != 2 {
t.Errorf("ListFiles(page=2).Page = %d, want 2", got.Page)
}
if got.PageSize != 2 {
t.Errorf("ListFiles(page_size=2).PageSize = %d, want 2", got.PageSize)
}
if got.Total != 4 {
t.Errorf("ListFiles().Total = %d, want 4", got.Total)
}
if len(got.Items) != 2 {
t.Fatalf("ListFiles(page=2, page_size=2) returned %d items, want 2", len(got.Items))
}
})
t.Run("filters filename case insensitively", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/api/v1/admin/uploads?keyword=photo", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
var resp testResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.ErrorMsg != "" {
t.Fatalf("ListFiles(keyword=photo) error = %q, want empty", resp.ErrorMsg)
}
var got listFilesResponse
if err := json.Unmarshal(resp.Data, &got); err != nil {
t.Fatalf("failed to parse list response: %v", err)
}
if got.Total != 1 {
t.Errorf("ListFiles(keyword=photo).Total = %d, want 1", got.Total)
}
if len(got.Items) != 1 {
t.Fatalf("ListFiles(keyword=photo) returned %d items, want 1", len(got.Items))
}
if got.Items[0].FileName != "Second-Photo.PNG" {
t.Errorf("ListFiles(keyword=photo).Items[0].FileName = %q, want %q", got.Items[0].FileName, "Second-Photo.PNG")
}
})
t.Run("filters by user_id", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/api/v1/admin/uploads?user_id=1001", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
var resp testResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.ErrorMsg != "" {
t.Fatalf("ListFiles(user_id=1001) error = %q, want empty", resp.ErrorMsg)
}
var got listFilesResponse
if err := json.Unmarshal(resp.Data, &got); err != nil {
t.Fatalf("failed to parse list response: %v", err)
}
if got.Total != 3 {
t.Errorf("ListFiles(user_id=1001).Total = %d, want 3", got.Total)
}
if len(got.Items) != 3 {
t.Fatalf("ListFiles(user_id=1001) returned %d items, want 3", len(got.Items))
}
})
}
func TestBatchDownloadFiles(t *testing.T) {
dbConn, cleanup := shared.SetupTestEnv(t)
defer cleanup()
authUser := &contracts.UserDTO{ID: 1001, Username: "test_user"}
router := setupTestRouter(authUser)
storageSvc := shared.NewMockStorageService()
storageSvc.PutRaw("uploads/f1.txt", []byte("file1 content"))
storageSvc.PutRaw("uploads/f2.txt", []byte("file2 content"))
storageSvc.PutRaw("uploads/f3.txt", []byte("duplicate name file content"))
shared.SetStorageService(storageSvc)
// Seed upload records. Note f2 and f3 have the same FileName "file_a.txt" to trigger name collision resolution.
uploads := []models.Upload{
{
ID: 3001,
UserID: 1001,
FileName: "file_a.txt",
FilePath: "uploads/f1.txt",
FileSize: 13,
MimeType: "text/plain",
Extension: "txt",
Status: models.UploadStatusUsed,
},
{
ID: 3002,
UserID: 1001,
FileName: "file_b.txt",
FilePath: "uploads/f2.txt",
FileSize: 13,
MimeType: "text/plain",
Extension: "txt",
Status: models.UploadStatusUsed,
},
{
ID: 3003,
UserID: 1001,
FileName: "file_a.txt", // COLLISION with 3001!
FilePath: "uploads/f3.txt",
FileSize: 28,
MimeType: "text/plain",
Extension: "txt",
Status: models.UploadStatusUsed,
},
}
for _, up := range uploads {
dbConn.Create(&up)
}
t.Run("batch download zip successfully and check duplicate renaming", func(t *testing.T) {
reqBody, _ := json.Marshal(batchDownloadRequest{
IDs: []string{"3001", "3002", "3003"},
})
req, _ := http.NewRequest("POST", "/api/v1/admin/uploads/download/batch", bytes.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. Body: %s", w.Code, w.Body.String())
}
if w.Header().Get("Content-Type") != "application/zip" {
t.Errorf("expected Content-Type application/zip, got %s", w.Header().Get("Content-Type"))
}
// Unzip in-memory
zipReader, err := zip.NewReader(bytes.NewReader(w.Body.Bytes()), int64(w.Body.Len()))
if err != nil {
t.Fatalf("failed to read zip buffer: %v", err)
}
if len(zipReader.File) != 3 {
t.Errorf("expected 3 files inside the ZIP, got %d", len(zipReader.File))
}
// Extract files to check their contents and name collision resolutions
extracted := make(map[string]string)
for _, f := range zipReader.File {
rc, err := f.Open()
if err != nil {
t.Fatalf("failed to open zip file entry %s: %v", f.Name, err)
}
content, _ := io.ReadAll(rc)
_ = rc.Close()
extracted[f.Name] = string(content)
}
// Checks
if extracted["file_a.txt"] != "file1 content" {
t.Errorf("file_a.txt content incorrect: %q", extracted["file_a.txt"])
}
if extracted["file_b.txt"] != "file2 content" {
t.Errorf("file_b.txt content incorrect: %q", extracted["file_b.txt"])
}
// The second file_a.txt should be renamed to file_a_1.txt
if extracted["file_a_1.txt"] != "duplicate name file content" {
t.Errorf("file_a_1.txt content incorrect: %q. Extracted files: %v", extracted["file_a_1.txt"], extracted)
}
t.Logf("Successfully unzipped batch. Extracted files: %+v", extracted)
})
}
func TestUploadAccessModeAccessControl(t *testing.T) {
dbConn, cleanup := shared.SetupTestEnv(t)
defer cleanup()
defer func() { _ = os.RemoveAll("uploads") }()
user1 := &contracts.UserDTO{ID: 1001, Username: "user1"}
user2 := &contracts.UserDTO{ID: 1002, Username: "user2"}
// Seed user1
if err := dbConn.Table("w_users").Create(user1).Error; err != nil {
t.Fatalf("create test user1 failed: %v", err)
}
// Seed user2
if err := dbConn.Table("w_users").Create(user2).Error; err != nil {
t.Fatalf("create test user2 failed: %v", err)
}
router := setupTestRouter(user1)
// 1. Upload private file for user1 (explicitly specifying access_mode = 0)
imgContent := []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89")
contentType, body := createMultipartRequest(t, "file", "private.png", imgContent, map[string]string{
"type": "generic",
"access_mode": "0",
})
req, _ := http.NewRequest("POST", "/api/v1/upload", body)
req.Header.Set("Content-Type", contentType)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Upload failed: %d, %s", w.Code, w.Body.String())
}
t.Logf("Raw upload response: %s", w.Body.String())
var resp1 testResponse
_ = json.Unmarshal(w.Body.Bytes(), &resp1)
var upload1 models.Upload
_ = json.Unmarshal(resp1.Data, &upload1)
if upload1.AccessMode != 0 {
t.Errorf("expected access_mode 0, got %d", upload1.AccessMode)
}
// 2. Upload public file for user1 (type avatar, should default to public 1)
contentType2, body2 := createMultipartRequest(t, "file", "public.png", []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89"), map[string]string{
"type": "avatar",
})
req2, _ := http.NewRequest("POST", "/api/v1/upload", body2)
req2.Header.Set("Content-Type", contentType2)
w2 := httptest.NewRecorder()
router.ServeHTTP(w2, req2)
var resp2 testResponse
_ = json.Unmarshal(w2.Body.Bytes(), &resp2)
var upload2 models.Upload
_ = json.Unmarshal(resp2.Data, &upload2)
if upload2.AccessMode != 1 {
t.Errorf("expected access_mode 1 (public) for avatar, got %d", upload2.AccessMode)
}
// 3. Verify accessing private file as user1 (owner) succeeds
wAccessOwner := httptest.NewRecorder()
reqAccessOwner, _ := http.NewRequest("GET", "/api/v1/admin/uploads/download/"+strconv.FormatUint(upload1.ID, 10), nil)
router.ServeHTTP(wAccessOwner, reqAccessOwner)
if wAccessOwner.Code != http.StatusOK {
t.Errorf("owner should be allowed to download private file, got status %d", wAccessOwner.Code)
}
// 4. Verify accessing private file as user2 (non-owner) fails
routerUser2 := setupTestRouter(user2)
wAccessOther := httptest.NewRecorder()
reqAccessOther, _ := http.NewRequest("GET", "/api/v1/admin/uploads/download/"+strconv.FormatUint(upload1.ID, 10), nil)
routerUser2.ServeHTTP(wAccessOther, reqAccessOther)
if wAccessOther.Code != http.StatusUnauthorized {
t.Errorf("non-owner should be denied download of private file, got status %d, want 401", wAccessOther.Code)
}
// 5. Verify accessing public file as user2 (non-owner) succeeds
wAccessPublic := httptest.NewRecorder()
reqAccessPublic, _ := http.NewRequest("GET", "/api/v1/admin/uploads/download/"+strconv.FormatUint(upload2.ID, 10), nil)
routerUser2.ServeHTTP(wAccessPublic, reqAccessPublic)
if wAccessPublic.Code != http.StatusOK {
t.Errorf("any logged-in user should be allowed to download public file, got status %d", wAccessPublic.Code)
}
}
func TestGetFileStats(t *testing.T) {
dbConn, cleanup := shared.SetupTestEnv(t)
defer cleanup()
authUser := &contracts.UserDTO{ID: 1001, Username: "test_user"}
router := setupTestRouter(authUser)
// Insert some dummy uploads
uploads := []models.Upload{
{
ID: 3101,
UserID: authUser.ID,
FileName: "photo.png",
FilePath: "uploads/photo.png",
FileSize: 100,
MimeType: "image/png",
Extension: "png",
Type: "generic",
Status: models.UploadStatusUsed,
CreatedAt: time.Now(),
},
{
ID: 3102,
UserID: authUser.ID,
FileName: "video.mp4",
FilePath: "uploads/video.mp4",
FileSize: 500,
MimeType: "video/mp4",
Extension: "mp4",
Type: "generic",
Status: models.UploadStatusUsed,
CreatedAt: time.Now().AddDate(0, 0, -2), // 2 days ago
},
{
ID: 3103,
UserID: authUser.ID,
FileName: "document.pdf",
FilePath: "uploads/document.pdf",
FileSize: 200,
MimeType: "application/pdf",
Extension: "pdf",
Type: "avatar", // different type
Status: models.UploadStatusUsed,
CreatedAt: time.Now().AddDate(0, 0, -10), // older than 7 days
},
}
for i := range uploads {
if err := dbConn.Create(&uploads[i]).Error; err != nil {
t.Fatalf("failed to create upload: %v", err)
}
}
if err := uploadstats.RebuildUploadStats(context.Background()); err != nil {
t.Fatalf("failed to rebuild upload stats: %v", err)
}
req, _ := http.NewRequest("GET", "/api/v1/admin/uploads/stats", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d, body: %s", w.Code, w.Body.String())
}
var resp struct {
ErrorMsg string `json:"error_msg"`
Data fileStatsResponse `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if resp.ErrorMsg != "" {
t.Fatalf("expected no error, got: %s", resp.ErrorMsg)
}
// Verify total count and size
if resp.Data.TotalCount != 3 {
t.Errorf("expected 3 total files, got %d", resp.Data.TotalCount)
}
if resp.Data.TotalSize != 800 {
t.Errorf("expected 800 total size, got %d", resp.Data.TotalSize)
}
// Verify trend (last 7 days should include photo.png (100) and video.mp4 (500), but NOT pdf (older))
// Total size in trend should be 600
var trendSizeSum int64
for _, trendItem := range resp.Data.Trend {
trendSizeSum += trendItem.Size
}
if trendSizeSum != 600 {
t.Errorf("expected 7-day trend size sum to be 600, got %d", trendSizeSum)
}
// Verify categories
categoryMap := make(map[string]int64)
for _, cat := range resp.Data.Categories {
categoryMap[cat.Name] = cat.Count
}
if categoryMap["图片"] != 1 {
t.Errorf("expected 1 image category, got %d", categoryMap["图片"])
}
if categoryMap["视频"] != 1 {
t.Errorf("expected 1 video category, got %d", categoryMap["视频"])
}
if categoryMap["文档"] != 1 {
t.Errorf("expected 1 document category, got %d", categoryMap["文档"])
}
}
func TestUserUploadManagement(t *testing.T) {
dbConn, cleanup := shared.SetupTestEnv(t)
defer cleanup()
user1 := &contracts.UserDTO{ID: 1001, Username: "user1"}
user2 := &contracts.UserDTO{ID: 1002, Username: "user2"}
_ = dbConn.Table("w_users").Create(user1)
_ = dbConn.Table("w_users").Create(user2)
router1 := setupTestRouter(user1)
router2 := setupTestRouter(user2)
// Seed upload records
upload1 := models.Upload{
ID: 4001,
UserID: 1001,
FileName: "user1-file.txt",
FilePath: "uploads/user1-file.txt",
FileSize: 100,
MimeType: "text/plain",
Extension: "txt",
Status: models.UploadStatusUsed,
CreatedAt: time.Now(),
}
upload2 := models.Upload{
ID: 4002,
UserID: 1002,
FileName: "user2-file.png",
FilePath: "uploads/user2-file.png",
FileSize: 200,
MimeType: "image/png",
Extension: "png",
Status: models.UploadStatusUsed,
CreatedAt: time.Now(),
}
_ = dbConn.Create(&upload1)
_ = dbConn.Create(&upload2)
t.Run("ListMyFiles only returns own files", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/api/v1/upload/my", nil)
w := httptest.NewRecorder()
router1.ServeHTTP(w, req)
var resp struct {
ErrorMsg string `json:"error_msg"`
Data listMyFilesResponse `json:"data"`
}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if resp.ErrorMsg != "" {
t.Fatalf("ListMyFiles error: %s", resp.ErrorMsg)
}
if resp.Data.Total != 1 {
t.Errorf("expected 1 file for user1, got %d", resp.Data.Total)
}
if len(resp.Data.Items) != 1 || resp.Data.Items[0].ID != 4001 {
t.Errorf("expected file 4001, got items: %+v", resp.Data.Items)
}
})
t.Run("UpdateMyFile updates file name and access mode successfully", func(t *testing.T) {
newMode := 1
reqBody, _ := json.Marshal(updateMyFileRequest{
FileName: "renamed.txt",
AccessMode: &newMode,
})
req, _ := http.NewRequest("PUT", "/api/v1/upload/4001", bytes.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router1.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. Body: %s", w.Code, w.Body.String())
}
var updated models.Upload
dbConn.First(&updated, 4001)
if updated.FileName != "renamed.txt" {
t.Errorf("expected file name renamed.txt, got %s", updated.FileName)
}
if updated.AccessMode != 1 {
t.Errorf("expected access mode 1, got %d", updated.AccessMode)
}
})
t.Run("UpdateMyFile blocks non-owners", func(t *testing.T) {
reqBody, _ := json.Marshal(updateMyFileRequest{
FileName: "hack.txt",
})
req, _ := http.NewRequest("PUT", "/api/v1/upload/4001", bytes.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router2.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("expected status 403, got %d", w.Code)
}
})
t.Run("DeleteMyFile blocks non-owners", func(t *testing.T) {
req, _ := http.NewRequest("DELETE", "/api/v1/upload/4001", nil)
w := httptest.NewRecorder()
router2.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("expected status 403, got %d", w.Code)
}
})
t.Run("DeleteMyFile deletes file successfully", func(t *testing.T) {
req, _ := http.NewRequest("DELETE", "/api/v1/upload/4001", nil)
w := httptest.NewRecorder()
router1.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
var deleted models.Upload
dbConn.First(&deleted, 4001)
if deleted.Status != models.UploadStatusDeleted {
t.Errorf("expected status deleted, got %s", deleted.Status)
}
})
}
@@ -0,0 +1,125 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package handler
import (
"Wavelet/pkg/response"
"Wavelet/plugins/domain/upload/shared"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
type trendItem struct {
Date string `json:"date"`
Count int64 `json:"count"`
Size int64 `json:"size"`
}
type distributionItem struct {
Name string `json:"name"`
Count int64 `json:"count"`
Size int64 `json:"size"`
}
type fileStatsResponse struct {
TotalCount int64 `json:"total_count"`
TotalSize int64 `json:"total_size"`
Trend []trendItem `json:"trend"`
Categories []distributionItem `json:"categories"`
Types []distributionItem `json:"types"`
}
// GetFileStats 获取系统上传的文件统计数据
// @Summary 获取文件统计数据
// @Description 返回系统级的总文件数、占用大小、最近 7 天新增趋势、文件类型/格式分布等数据
// @Tags admin
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=fileStatsResponse} "获取成功"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/admin/uploads/stats [get]
func GetFileStats(c *gin.Context) {
ctx := c.Request.Context()
stats, err := loadUploadStats(ctx)
if err != nil {
response.AbortBadRequest(c, err.Error())
return
}
now := time.Now()
trendDates := make([]string, 0, shared.FileStatsTrendDays)
trendCountMap := make(map[string]int64, shared.FileStatsTrendDays)
trendSizeMap := make(map[string]int64, shared.FileStatsTrendDays)
for i := shared.FileStatsTrendDays - 1; i >= 0; i-- {
date := now.AddDate(0, 0, -i).Format("2006-01-02")
trendDates = append(trendDates, date)
trendCountMap[date] = 0
trendSizeMap[date] = 0
}
var (
totalCount int64
totalSize int64
types []distributionItem
categories []distributionItem
)
categoriesList := []string{"图片", "视频", "音频", "文档", "压缩包", "其他"}
categoryMap := make(map[string]distributionItem, len(categoriesList))
for _, cat := range categoriesList {
categoryMap[cat] = distributionItem{Name: cat}
}
for _, stat := range stats {
switch stat.Dimension {
case shared.UploadStatDimensionTotal:
totalCount = stat.FileCount
totalSize = stat.FileSize
case shared.UploadStatDimensionType:
types = append(types, distributionItem{
Name: stat.StatKey,
Count: stat.FileCount,
Size: stat.FileSize,
})
case shared.UploadStatDimensionCategory:
if item, ok := categoryMap[stat.StatKey]; ok {
item.Count = stat.FileCount
item.Size = stat.FileSize
categoryMap[stat.StatKey] = item
}
case shared.UploadStatDimensionTrend:
if _, ok := trendCountMap[stat.StatKey]; ok {
trendCountMap[stat.StatKey] = stat.FileCount
trendSizeMap[stat.StatKey] = stat.FileSize
}
}
}
categories = make([]distributionItem, 0, len(categoriesList))
for _, cat := range categoriesList {
categories = append(categories, categoryMap[cat])
}
trend := make([]trendItem, 0, len(trendDates))
for _, date := range trendDates {
trend = append(trend, trendItem{
Date: date,
Count: trendCountMap[date],
Size: trendSizeMap[date],
})
}
c.JSON(http.StatusOK, response.OK(fileStatsResponse{
TotalCount: totalCount,
TotalSize: totalSize,
Trend: trend,
Categories: categories,
Types: types,
}))
}
@@ -0,0 +1,15 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package ingest
import (
"Wavelet/plugins/domain/upload/shared"
"errors"
)
// ErrForbidden indicates the caller is not allowed to mutate the upload record.
var ErrForbidden = errors.New("upload forbidden")
// ErrStorageReadOnly indicates the storage backend is in migration read-only mode.
var ErrStorageReadOnly = errors.New(shared.ErrStorageReadOnly)
@@ -0,0 +1,217 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package ingest
import (
"Wavelet/pkg/idgen"
"Wavelet/pkg/logger"
"Wavelet/plugins/domain/upload/models"
"Wavelet/plugins/domain/upload/repository"
"Wavelet/plugins/domain/upload/shared"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"time"
"gorm.io/gorm"
uploadcache "Wavelet/plugins/domain/upload/cache"
uploadstats "Wavelet/plugins/domain/upload/stats"
uploadstorage "Wavelet/plugins/domain/upload/storage"
)
func normalizeRequest(req *Request) {
req.Extension = strings.ToLower(strings.TrimSpace(req.Extension))
if req.Extension == "" {
req.Extension = "bin"
}
if req.Type == "" {
req.Type = "generic"
}
if req.Status == "" {
req.Status = models.UploadStatusUsed
}
}
func resolveAccessMode(uploadType string, explicit *int) int {
if explicit != nil {
return *explicit
}
if uploadType == shared.DefaultPublicUploadType {
return 1
}
return 0
}
func validateAllowedExtension(ctx context.Context, ext string) error {
var val string
db := shared.GetDB(ctx)
if db != nil {
err := db.Table("w_system_configs").Where("key = ?", "upload_allowed_extensions").Pluck("value", &val).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
logger.WarnF(ctx, "failed to query upload_allowed_extensions: %v", err)
return nil
}
}
if val == "" {
return nil
}
var list []string
if err := json.Unmarshal([]byte(val), &list); err == nil {
for _, allowedExt := range list {
if strings.EqualFold(strings.TrimSpace(allowedExt), ext) {
return nil
}
}
return errors.New(shared.ErrUnsupportedFormat)
}
allowedExts := strings.Split(strings.ToLower(val), ",")
for _, allowedExt := range allowedExts {
if strings.TrimSpace(allowedExt) == ext {
return nil
}
}
return errors.New(shared.ErrUnsupportedFormat)
}
func defaultObjectKey(id uint64, ext string) string {
return fmt.Sprintf("uploads/%s/%d.%s", time.Now().Format("2006/01/02"), id, ext)
}
func buildObjectKey(req Request, id uint64) string {
if req.ObjectKeyFn != nil {
return req.ObjectKeyFn(id, req.Extension)
}
return defaultObjectKey(id, req.Extension)
}
func storeObject(ctx context.Context, objectKey string, reader io.Reader, size int64, mimeType string, meta *models.UploadMetadata) (string, error) {
if uploadstorage.ReadOnly(ctx) {
return "", ErrStorageReadOnly
}
storageSvc := shared.GetStorage(ctx)
if storageSvc == nil {
logger.ErrorF(ctx, "初始化活动存储失败: storage service is nil")
return "", errors.New(shared.ErrSaveFileFailed)
}
result, err := storageSvc.Put(ctx, objectKey, reader, size, mimeType)
if err != nil {
logger.ErrorF(ctx, "写入存储失败: %v", err)
return "", errors.New(shared.ErrSaveFileFailed)
}
meta.Bucket = result.Bucket
return result.Key, nil
}
func persistUploadRecord(ctx context.Context, upload *models.Upload, objectKey string) error {
if err := createUploadWithStats(ctx, upload); err != nil {
if storageSvc := shared.GetStorage(ctx); storageSvc != nil {
if deleteErr := storageSvc.Delete(ctx, objectKey); deleteErr != nil {
logger.WarnF(ctx, "清理未写入数据库的上传对象失败: %v", deleteErr)
}
}
return err
}
uploadcache.SetUploadMeta(ctx, *upload)
return nil
}
func createUploadWithStats(ctx context.Context, upload *models.Upload) error {
db := shared.GetDB(ctx)
if db == nil {
return errors.New("database service not available")
}
return db.Transaction(func(tx *gorm.DB) error {
if err := repository.CreateUploadTx(tx, upload); err != nil {
return err
}
return uploadstats.ApplyUploadStatsDeltaTx(tx, upload, 1)
})
}
func createDedupRecord(ctx context.Context, existing models.Upload, req Request) (Result, error) {
accessMode := resolveAccessMode(req.Type, req.AccessMode)
newUpload := models.Upload{
ID: idgen.NextUint64ID(),
UserID: req.UserID,
FileName: req.FileName,
FilePath: existing.FilePath,
FileSize: req.Size,
MimeType: req.MimeType,
Extension: req.Extension,
Hash: req.Hash,
Type: req.Type,
Status: req.Status,
AccessMode: accessMode,
Metadata: existing.Metadata,
}
if err := persistUploadRecord(ctx, &newUpload, existing.FilePath); err != nil {
return Result{}, err
}
logger.InfoF(ctx, "文件触发秒传成功! ID: %d, Path: %s", newUpload.ID, existing.FilePath)
return Result{
Upload: newUpload,
Created: true,
Stored: false,
}, nil
}
func uploadstorageReadOnly(ctx context.Context) bool {
return uploadstorage.ReadOnly(ctx)
}
func createNewUpload(ctx context.Context, req Request) (Result, error) {
if uploadstorageReadOnly(ctx) {
return Result{}, ErrStorageReadOnly
}
if !req.SkipExtensionCheck {
if err := validateAllowedExtension(ctx, req.Extension); err != nil {
return Result{}, err
}
}
id := idgen.NextUint64ID()
objectKey := buildObjectKey(req, id)
storedKey, err := storeObject(ctx, objectKey, req.Reader, req.Size, req.MimeType, &req.Metadata)
if err != nil {
return Result{}, err
}
accessMode := resolveAccessMode(req.Type, req.AccessMode)
upload := models.Upload{
ID: id,
UserID: req.UserID,
FileName: req.FileName,
FilePath: storedKey,
FileSize: req.Size,
MimeType: req.MimeType,
Extension: req.Extension,
Hash: req.Hash,
Type: req.Type,
Status: req.Status,
AccessMode: accessMode,
Metadata: req.Metadata,
}
if err := persistUploadRecord(ctx, &upload, storedKey); err != nil {
return Result{}, err
}
return Result{
Upload: upload,
Created: true,
Stored: true,
}, nil
}
@@ -0,0 +1,64 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package ingest
import (
"Wavelet/plugins/domain/upload/models"
"Wavelet/plugins/domain/upload/repository"
"context"
"errors"
"gorm.io/gorm"
)
// Ingest stores or resolves an upload using the configured policy and side effects.
func Ingest(ctx context.Context, req Request) (Result, error) {
normalizeRequest(&req)
if req.Hash == "" {
return Result{}, errors.New("ingest hash is required")
}
if req.Reader == nil {
return Result{}, errors.New("ingest reader is required")
}
if req.Size < 0 {
return Result{}, errors.New("ingest size must be non-negative")
}
switch req.Policy {
case PolicyDedupNewRecord, PolicyResolveExisting:
return ingestWithHashPolicy(ctx, req)
case PolicyCreate:
return createNewUpload(ctx, req)
default:
return Result{}, errors.New("unsupported ingest policy")
}
}
// FindByHash returns a reusable active upload with the same hash and size.
func FindByHash(ctx context.Context, hash string, size int64) (models.Upload, error) {
return repository.FindReusableUploadByHash(ctx, hash, size)
}
func ingestWithHashPolicy(ctx context.Context, req Request) (Result, error) {
existing, err := repository.FindReusableUploadByHash(ctx, req.Hash, req.Size)
if err == nil {
switch req.Policy {
case PolicyResolveExisting:
return Result{
Upload: existing,
Resolved: true,
}, nil
case PolicyDedupNewRecord:
if uploadstorageReadOnly(ctx) {
return Result{}, ErrStorageReadOnly
}
return createDedupRecord(ctx, existing, req)
}
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return Result{}, err
}
return createNewUpload(ctx, req)
}
@@ -0,0 +1,358 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package ingest
import (
"Wavelet/core/contracts"
"Wavelet/pkg/idgen"
"Wavelet/plugins/domain/upload/models"
"Wavelet/plugins/domain/upload/shared"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"io"
"os"
"sync"
"testing"
)
func init() {
_ = idgen.Init(1)
}
type testStorageService struct {
mu sync.RWMutex
mockFiles map[string][]byte
putCount *int
}
func (s *testStorageService) Put(_ context.Context, key string, body io.Reader, _ int64, _ string) (contracts.StoragePutResult, error) {
s.mu.Lock()
defer s.mu.Unlock()
data, err := io.ReadAll(body)
if err != nil {
return contracts.StoragePutResult{}, err
}
s.mockFiles[key] = data
if s.putCount != nil {
*s.putCount++
}
return contracts.StoragePutResult{Key: key, Bucket: "test-bucket"}, nil
}
func (s *testStorageService) Get(_ context.Context, key string) (*contracts.StorageObject, error) {
s.mu.RLock()
defer s.mu.RUnlock()
data, ok := s.mockFiles[key]
if !ok {
return nil, os.ErrNotExist
}
return &contracts.StorageObject{
Key: key,
Body: io.NopCloser(bytes.NewReader(data)),
ContentLength: int64(len(data)),
ContentType: "application/octet-stream",
}, nil
}
func (s *testStorageService) Delete(_ context.Context, key string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.mockFiles, key)
return nil
}
func (s *testStorageService) Ingest(_ context.Context, _ io.Reader, _ contracts.IngestOptions) (*contracts.IngestResult, error) {
return nil, nil
}
func setupMockStorage(t *testing.T, putCount *int) (restore, disable func()) {
t.Helper()
mockSvc := &testStorageService{
mockFiles: make(map[string][]byte),
putCount: putCount,
}
shared.SetStorageService(mockSvc)
return func() {
shared.SetStorageService(nil)
}, func() {
shared.SetStorageService(nil)
}
}
func loadTotalStats(ctx context.Context) (totalStatsSnapshot, error) {
var rows []models.UploadStat
if err := shared.GetDB(ctx).Where("dimension = ?", models.UploadStatDimensionTotal).Find(&rows).Error; err != nil {
return totalStatsSnapshot{}, err
}
if len(rows) == 0 {
return totalStatsSnapshot{}, nil
}
return totalStatsSnapshot{
TotalCount: rows[0].FileCount,
TotalSize: rows[0].FileSize,
}, nil
}
type totalStatsSnapshot struct {
TotalCount int64
TotalSize int64
}
func TestIngestPolicyCreateIncrementsStats(t *testing.T) {
_, cleanup := shared.SetupTestEnv(t)
defer cleanup()
ctx := context.Background()
content := []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01")
hash := sha256.Sum256(content)
restoreStorage, disableStorage := setupMockStorage(t, nil)
defer restoreStorage()
defer disableStorage()
result, err := Ingest(ctx, Request{
UserID: 1001,
Reader: bytes.NewReader(content),
Size: int64(len(content)),
FileName: "mirror.png",
MimeType: "image/png",
Extension: "png",
Hash: hex.EncodeToString(hash[:]),
Type: "pixez_mirror",
Policy: PolicyCreate,
})
if err != nil {
t.Fatalf("Ingest(PolicyCreate) returned error: %v", err)
}
if !result.Created || !result.Stored || result.Resolved {
t.Fatalf("Ingest(PolicyCreate) = %+v, want Created+Stored without Resolved", result)
}
stats, err := loadTotalStats(ctx)
if err != nil {
t.Fatalf("loadTotalStats returned error: %v", err)
}
if stats.TotalCount != 1 || stats.TotalSize != int64(len(content)) {
t.Fatalf("loadTotalStats() = count %d size %d, want count 1 size %d", stats.TotalCount, stats.TotalSize, len(content))
}
}
func TestIngestPolicyResolveExistingSkipsStatsOnHit(t *testing.T) {
_, cleanup := shared.SetupTestEnv(t)
defer cleanup()
ctx := context.Background()
content := []byte("hello duplicate resolution")
hash := sha256.Sum256(content)
hashStr := hex.EncodeToString(hash[:])
putCount := 0
restoreStorage, disableStorage := setupMockStorage(t, &putCount)
defer restoreStorage()
defer disableStorage()
first, err := Ingest(ctx, Request{
UserID: 1001,
Reader: bytes.NewReader(content),
Size: int64(len(content)),
FileName: "first.txt",
MimeType: "text/plain",
Extension: "txt",
Hash: hashStr,
Type: "attachment",
Policy: PolicyCreate,
})
if err != nil {
t.Fatalf("first Ingest returned error: %v", err)
}
if !first.Created || !first.Stored {
t.Fatalf("first Ingest = %+v, want Created and Stored true", first)
}
if putCount != 1 {
t.Fatalf("putCount = %d, want 1 after initial store", putCount)
}
second, err := Ingest(ctx, Request{
UserID: 1002,
Reader: bytes.NewReader(content),
Size: int64(len(content)),
FileName: "second.txt",
MimeType: "text/plain",
Extension: "txt",
Hash: hashStr,
Type: "attachment",
Policy: PolicyResolveExisting,
})
if err != nil {
t.Fatalf("second Ingest(PolicyResolveExisting) returned error: %v", err)
}
if second.Created || second.Stored || !second.Resolved {
t.Fatalf("second Ingest = %+v, want Created/Stored false and Resolved true", second)
}
if second.Upload.ID != first.Upload.ID {
t.Fatalf("resolved ID = %d, want %d", second.Upload.ID, first.Upload.ID)
}
if putCount != 1 {
t.Fatalf("putCount = %d, want 1 after hit with PolicyResolveExisting", putCount)
}
stats, err := loadTotalStats(ctx)
if err != nil {
t.Fatalf("loadTotalStats returned error: %v", err)
}
if stats.TotalCount != 1 || stats.TotalSize != int64(len(content)) {
t.Fatalf("stats = %+v, want count 1 size %d", stats, len(content))
}
}
func TestIngestPolicyDedupNewRecordReusesStorage(t *testing.T) {
_, cleanup := shared.SetupTestEnv(t)
defer cleanup()
ctx := context.Background()
content := []byte("hello dedup reuse")
hash := sha256.Sum256(content)
hashStr := hex.EncodeToString(hash[:])
putCount := 0
restoreStorage, disableStorage := setupMockStorage(t, &putCount)
defer restoreStorage()
defer disableStorage()
first, err := Ingest(ctx, Request{
UserID: 1001,
Reader: bytes.NewReader(content),
Size: int64(len(content)),
FileName: "first.txt",
MimeType: "text/plain",
Extension: "txt",
Hash: hashStr,
Type: "attachment",
Policy: PolicyCreate,
})
if err != nil {
t.Fatalf("first Ingest: %v", err)
}
second, err := Ingest(ctx, Request{
UserID: 1002,
Reader: bytes.NewReader(content),
Size: int64(len(content)),
FileName: "second.txt",
MimeType: "text/plain",
Extension: "txt",
Hash: hashStr,
Type: "attachment",
Policy: PolicyDedupNewRecord,
})
if err != nil {
t.Fatalf("second Ingest(PolicyDedupNewRecord): %v", err)
}
if !second.Created || second.Stored || second.Resolved {
t.Fatalf("second Ingest = %+v, want Created=true Stored=false Resolved=false", second)
}
if second.Upload.ID == first.Upload.ID {
t.Fatalf("expected new upload record, got matching ID %d", second.Upload.ID)
}
if second.Upload.FilePath != first.Upload.FilePath {
t.Fatalf("expected reused FilePath %q, got %q", first.Upload.FilePath, second.Upload.FilePath)
}
if putCount != 1 {
t.Fatalf("putCount = %d, want 1 after dedup new record", putCount)
}
stats, err := loadTotalStats(ctx)
if err != nil {
t.Fatalf("loadTotalStats: %v", err)
}
if stats.TotalCount != 2 || stats.TotalSize != int64(len(content)*2) {
t.Fatalf("stats = %+v, want count 2 size %d", stats, len(content)*2)
}
}
func TestRemoveDecrementsStats(t *testing.T) {
_, cleanup := shared.SetupTestEnv(t)
defer cleanup()
ctx := context.Background()
content := []byte("remove payload")
hash := sha256.Sum256(content)
restoreStorage, disableStorage := setupMockStorage(t, nil)
defer restoreStorage()
defer disableStorage()
ingested, err := Ingest(ctx, Request{
UserID: 1001,
Reader: bytes.NewReader(content),
Size: int64(len(content)),
FileName: "to_remove.txt",
MimeType: "text/plain",
Extension: "txt",
Hash: hex.EncodeToString(hash[:]),
Type: "generic",
Policy: PolicyCreate,
})
if err != nil {
t.Fatalf("Ingest: %v", err)
}
removed, err := Remove(ctx, ingested.Upload.ID)
if err != nil {
t.Fatalf("Remove: %v", err)
}
if removed.Status != models.UploadStatusDeleted {
t.Fatalf("removed status = %q, want deleted", removed.Status)
}
stats, err := loadTotalStats(ctx)
if err != nil {
t.Fatalf("loadTotalStats: %v", err)
}
if stats.TotalCount != 0 || stats.TotalSize != 0 {
t.Fatalf("stats = %+v, want count 0 size 0 after remove", stats)
}
}
func TestRemoveOwnedEnforcesOwnership(t *testing.T) {
_, cleanup := shared.SetupTestEnv(t)
defer cleanup()
ctx := context.Background()
content := []byte("owner payload")
hash := sha256.Sum256(content)
restoreStorage, disableStorage := setupMockStorage(t, nil)
defer restoreStorage()
defer disableStorage()
ingested, err := Ingest(ctx, Request{
UserID: 1001,
Reader: bytes.NewReader(content),
Size: int64(len(content)),
FileName: "owned.txt",
MimeType: "text/plain",
Extension: "txt",
Hash: hex.EncodeToString(hash[:]),
Type: "generic",
Policy: PolicyCreate,
})
if err != nil {
t.Fatalf("Ingest: %v", err)
}
if _, err := RemoveOwned(ctx, 2002, ingested.Upload.ID); err == nil {
t.Fatal("expected ErrForbidden for non-owner RemoveOwned")
}
removed, err := RemoveOwned(ctx, 1001, ingested.Upload.ID)
if err != nil {
t.Fatalf("RemoveOwned owner failed: %v", err)
}
if removed.Status != models.UploadStatusDeleted {
t.Fatalf("removed status = %q, want deleted", removed.Status)
}
}
@@ -0,0 +1,63 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package ingest
import (
"Wavelet/plugins/domain/upload/models"
"Wavelet/plugins/domain/upload/repository"
"Wavelet/plugins/domain/upload/shared"
"context"
"gorm.io/gorm"
uploadcache "Wavelet/plugins/domain/upload/cache"
uploadstats "Wavelet/plugins/domain/upload/stats"
)
// Remove soft-deletes an upload and decrements incremental stats.
func Remove(ctx context.Context, uploadID uint64) (models.Upload, error) {
upload, err := repository.GetActiveUploadByID(ctx, uploadID)
if err != nil {
return models.Upload{}, err
}
if err := softDeleteUploadWithStats(ctx, &upload); err != nil {
return models.Upload{}, err
}
upload.Status = models.UploadStatusDeleted
return upload, nil
}
// RemoveOwned soft-deletes an upload owned by userID and decrements incremental stats.
func RemoveOwned(ctx context.Context, userID, uploadID uint64) (models.Upload, error) {
upload, err := repository.GetActiveUploadByID(ctx, uploadID)
if err != nil {
return models.Upload{}, err
}
if upload.UserID != userID {
return models.Upload{}, ErrForbidden
}
if err := softDeleteUploadWithStats(ctx, &upload); err != nil {
return models.Upload{}, err
}
upload.Status = models.UploadStatusDeleted
return upload, nil
}
func softDeleteUploadWithStats(ctx context.Context, upload *models.Upload) error {
statsSnapshot := *upload
db := shared.GetDB(ctx)
if db != nil {
if err := db.Transaction(func(tx *gorm.DB) error {
if err := repository.SoftDeleteUploadTx(tx, upload); err != nil {
return err
}
return uploadstats.ApplyUploadStatsDeltaTx(tx, &statsSnapshot, -1)
}); err != nil {
return err
}
}
uploadcache.EvictUploadMeta(ctx, upload.ID)
return nil
}
@@ -0,0 +1,59 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package ingest provides the programmatic upload domain service for Wavelet.
package ingest
import (
"Wavelet/plugins/domain/upload/models"
"io"
)
// Policy controls how ingest handles hash collisions and record creation.
type Policy int
const (
// PolicyCreate always stores a new object and creates a new upload record.
PolicyCreate Policy = iota
// PolicyDedupNewRecord reuses an existing object path on hash match but creates a new record and stats delta.
PolicyDedupNewRecord
// PolicyResolveExisting returns an existing upload on hash match without creating a record or stats delta.
PolicyResolveExisting
)
// ObjectKeyFn builds the storage object key for a new upload.
type ObjectKeyFn func(id uint64, ext string) string
// Request describes a programmatic file ingest operation.
type Request struct {
UserID uint64
Type string
AccessMode *int
Status models.UploadStatus
Reader io.Reader
Size int64
FileName string
MimeType string
Extension string
Hash string
Metadata models.UploadMetadata
Policy Policy
ObjectKeyFn ObjectKeyFn
// SkipExtensionCheck bypasses the configured upload extension whitelist.
SkipExtensionCheck bool
}
// Result reports the outcome of an ingest operation.
type Result struct {
Upload models.Upload
Created bool
Stored bool
Resolved bool
}
@@ -0,0 +1,40 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS w_uploads (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
file_name VARCHAR(255) NOT NULL,
file_path VARCHAR(500) NOT NULL,
file_size BIGINT NOT NULL,
mime_type VARCHAR(100) NOT NULL,
extension VARCHAR(50) NOT NULL,
hash VARCHAR(64),
type VARCHAR(50) NOT NULL,
status VARCHAR(20) NOT NULL,
access_mode INTEGER NOT NULL DEFAULT 0,
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_w_uploads_user_id ON w_uploads (user_id);
CREATE INDEX IF NOT EXISTS idx_w_uploads_file_path ON w_uploads (file_path);
CREATE INDEX IF NOT EXISTS idx_w_uploads_hash ON w_uploads (hash);
CREATE INDEX IF NOT EXISTS idx_w_uploads_type ON w_uploads (type);
CREATE INDEX IF NOT EXISTS idx_w_uploads_status_created_at ON w_uploads (status, created_at);
CREATE INDEX IF NOT EXISTS idx_w_uploads_hash_file_size_status ON w_uploads (hash, file_size, status);
CREATE TABLE IF NOT EXISTS w_upload_stats (
dimension VARCHAR(32) NOT NULL,
stat_key VARCHAR(64) NOT NULL DEFAULT '',
file_count BIGINT NOT NULL DEFAULT 0,
file_size BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (dimension, stat_key)
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS w_upload_stats;
DROP TABLE IF EXISTS w_uploads;
-- +goose StatementEnd
@@ -0,0 +1,40 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS w_uploads (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
file_name VARCHAR(255) NOT NULL,
file_path VARCHAR(500) NOT NULL,
file_size BIGINT NOT NULL,
mime_type VARCHAR(100) NOT NULL,
extension VARCHAR(50) NOT NULL,
hash VARCHAR(64),
type VARCHAR(50) NOT NULL,
status VARCHAR(20) NOT NULL,
access_mode INTEGER NOT NULL DEFAULT 0,
metadata TEXT NOT NULL DEFAULT '{}',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_w_uploads_user_id ON w_uploads (user_id);
CREATE INDEX IF NOT EXISTS idx_w_uploads_file_path ON w_uploads (file_path);
CREATE INDEX IF NOT EXISTS idx_w_uploads_hash ON w_uploads (hash);
CREATE INDEX IF NOT EXISTS idx_w_uploads_type ON w_uploads (type);
CREATE INDEX IF NOT EXISTS idx_w_uploads_status_created_at ON w_uploads (status, created_at);
CREATE INDEX IF NOT EXISTS idx_w_uploads_hash_file_size_status ON w_uploads (hash, file_size, status);
CREATE TABLE IF NOT EXISTS w_upload_stats (
dimension VARCHAR(32) NOT NULL,
stat_key VARCHAR(64) NOT NULL DEFAULT '',
file_count BIGINT NOT NULL DEFAULT 0,
file_size BIGINT NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (dimension, stat_key)
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS w_upload_stats;
DROP TABLE IF EXISTS w_uploads;
-- +goose StatementEnd
+34
View File
@@ -0,0 +1,34 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package upload 提供上传域的门面与类型重导出。
package upload
import (
"Wavelet/plugins/domain/upload/models"
)
// UploadStatus 上传状态类型别名
//
//nolint:revive
type UploadStatus = models.UploadStatus
// UploadMetadata 上传元数据类型别名
//
//nolint:revive
type UploadMetadata = models.UploadMetadata
// Upload 上传实体类型别名
type Upload = models.Upload
// UploadStat 上传统计实体类型别名
//
//nolint:revive
type UploadStat = models.UploadStat
// 上传状态常量别名
const (
UploadStatusPending = models.UploadStatusPending
UploadStatusUsed = models.UploadStatusUsed
UploadStatusDeleted = models.UploadStatusDeleted
)
@@ -0,0 +1,76 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package models 提供上传域核心数据模型。
package models
import (
"time"
)
// UploadStatus 上传状态
type UploadStatus string
// 上传状态
const (
UploadStatusPending UploadStatus = "pending" // 待使用
UploadStatusUsed UploadStatus = "used" // 已使用
UploadStatusDeleted UploadStatus = "deleted" // 已删除
)
// UploadMetadata 自定义可扩展的 JSON 字段存储非核心或可选的文件元数据
type UploadMetadata struct {
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
Duration float64 `json:"duration,omitempty"`
OriginalMime string `json:"original_mime,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
ClientIP string `json:"client_ip,omitempty"`
Bucket string `json:"bucket,omitempty"`
Extra map[string]any `json:"extra,omitempty"`
}
// Upload 上传文件记录
type Upload struct {
ID uint64 `json:"id,string" gorm:"primaryKey"`
UserID uint64 `json:"user_id,string" gorm:"index;not null"`
FileName string `json:"file_name" gorm:"size:255;not null"`
FilePath string `json:"file_path" gorm:"size:500;not null;index"`
FileSize int64 `json:"file_size" gorm:"not null"`
MimeType string `json:"mime_type" gorm:"size:100;not null"`
Extension string `json:"extension" gorm:"size:50;not null"`
Hash string `json:"hash" gorm:"size:64;index"`
Type string `json:"type" gorm:"column:type;size:50;not null;index"`
Status UploadStatus `json:"status" gorm:"type:varchar(20);not null"`
AccessMode int `json:"access_mode" gorm:"column:access_mode;not null;default:0"`
Metadata UploadMetadata `json:"metadata" gorm:"serializer:json;type:jsonb"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
}
// TableName 表名
func (Upload) TableName() string {
return "w_uploads"
}
// Upload stats dimension keys stored in w_upload_stats.dimension.
const (
UploadStatDimensionTotal = "total"
UploadStatDimensionType = "type"
UploadStatDimensionCategory = "category"
UploadStatDimensionTrend = "trend"
)
// UploadStat 聚合统计记录
type UploadStat struct {
Dimension string `json:"dimension" gorm:"primaryKey;size:32;not null"`
StatKey string `json:"stat_key" gorm:"primaryKey;size:64;not null;default:''"`
FileCount int64 `json:"file_count" gorm:"not null;default:0"`
FileSize int64 `json:"file_size" gorm:"not null;default:0"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
}
// TableName 表名
func (UploadStat) TableName() string {
return "w_upload_stats"
}
+193
View File
@@ -0,0 +1,193 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package upload provides file uploading, storage abstraction, image transcoding, and caching domain plugin for Cordis.
package upload
import (
"Wavelet/core"
"Wavelet/core/contracts"
"Wavelet/core/extpoints"
"Wavelet/plugins/domain/upload/filesrv"
"Wavelet/plugins/domain/upload/handler"
"Wavelet/plugins/domain/upload/shared"
"Wavelet/plugins/domain/upload/task"
"context"
"embed"
"reflect"
"github.com/gin-gonic/gin"
)
//go:embed migrations/*/*.sql
var uploadMigrations embed.FS
// Plugin implements core.Plugin to provide file upload and media serving domain services.
type Plugin struct{}
// New creates a new upload domain plugin.
func New() *Plugin {
return &Plugin{}
}
// Name returns the unique identifier for the upload domain plugin.
func (p *Plugin) Name() string {
return "upload"
}
// Inject declares required dependencies for the upload domain plugin.
func (p *Plugin) Inject() []reflect.Type {
return []reflect.Type{
reflect.TypeFor[contracts.DBService](),
reflect.TypeFor[contracts.StorageService](),
reflect.TypeFor[contracts.AuthService](),
}
}
// Manifest returns the plugin metadata.
func (p *Plugin) Manifest() core.Manifest {
return core.Manifest{
Name: "upload",
Version: "1.0.0",
Description: "File upload, secure delivery, image transcoding, and storage management domain plugin",
Author: "Wavelet Team",
}
}
// Apply registers upload routes, tasks, and settings into the Context.
func (p *Plugin) Apply(ctx *core.Context) error {
// Bind DBService
if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil {
shared.SetDBService(db)
} else {
core.When[contracts.DBService](ctx, func(db contracts.DBService) {
shared.SetDBService(db)
})
}
// Bind CacheService
if cache, err := core.Inject[contracts.CacheService](ctx); err == nil && cache != nil {
shared.SetCacheService(cache)
} else {
core.When[contracts.CacheService](ctx, func(cache contracts.CacheService) {
shared.SetCacheService(cache)
})
}
// Bind StorageService
if storage, err := core.Inject[contracts.StorageService](ctx); err == nil && storage != nil {
shared.SetStorageService(storage)
} else {
core.When[contracts.StorageService](ctx, func(storage contracts.StorageService) {
shared.SetStorageService(storage)
})
}
// Bind TaskService
if taskSvc, err := core.Inject[contracts.TaskService](ctx); err == nil && taskSvc != nil {
shared.SetTaskService(taskSvc)
} else {
core.When[contracts.TaskService](ctx, func(taskSvc contracts.TaskService) {
shared.SetTaskService(taskSvc)
})
}
// Bind AuthService
if authSvc, err := core.Inject[contracts.AuthService](ctx); err == nil && authSvc != nil {
shared.SetAuthService(authSvc)
} else {
core.When[contracts.AuthService](ctx, func(authSvc contracts.AuthService) {
shared.SetAuthService(authSvc)
})
}
ctx.OnDispose(func() error {
shared.ResetServices()
return nil
})
// 0. Resolve auth service for middleware
var authSvc contracts.AuthService
if err := core.Using[contracts.AuthService](ctx, func(svc contracts.AuthService) { authSvc = svc }); err != nil {
return err
}
loginMW := authSvc.RequireAuthMiddleware().(gin.HandlerFunc)
// 0a. Register migrations
ctx.Migrations().Register("upload", uploadMigrations)
// 1. Register File Server Routes
ctx.Router().GET("/f/:id", filesrv.ServeFileByID)
// 2. Register User/Admin Upload HTTP Routes
uploadGroup := ctx.Router().Group("/api/v1/upload", loginMW)
{
uploadGroup.POST("", handler.UploadFile)
uploadGroup.GET("", handler.ListFiles)
uploadGroup.DELETE("/:id", handler.DeleteFile)
uploadGroup.POST("/batch-download", handler.BatchDownloadFiles)
}
adminUploadGroup := ctx.Router().Group("/api/v1/admin/uploads", loginMW)
{
adminUploadGroup.GET("", handler.ListFiles)
adminUploadGroup.GET("/stats", handler.GetFileStats)
adminUploadGroup.DELETE("/:id", handler.DeleteFile)
adminUploadGroup.GET("/download/:id", handler.DownloadFile)
adminUploadGroup.POST("/download/batch", handler.BatchDownloadFiles)
adminUploadGroup.GET("/types", handler.GetDistinctUploadTypes)
}
const (
defaultCleanupRetry = 3
defaultStatsRetry = 2
defaultSingleRetry = 1
)
// 3. Register tasks. Handlers take raw payload bytes rather than a driver
// specific task type so they run under both the asynq and in-process workers.
cleanupHandler := &task.SystemCleanupHandler{}
ctx.Task().Register(task.SystemCleanupTask, func(c context.Context, payload []byte) error {
_, err := cleanupHandler.Execute(c, payload)
return err
}, extpoints.WithTaskMeta(task.SystemCleanupMeta), extpoints.WithTaskRetry(defaultCleanupRetry))
rebuildStatsHandler := &task.RebuildUploadStatsHandler{}
ctx.Task().Register(task.RebuildUploadStatsTask, func(c context.Context, payload []byte) error {
_, err := rebuildStatsHandler.Execute(c, payload)
return err
}, extpoints.WithTaskMeta(task.RebuildUploadStatsMeta), extpoints.WithTaskRetry(defaultStatsRetry))
migrationHandler := &task.MigrationHandler{}
ctx.Task().Register(task.StorageMigrationTask, func(c context.Context, payload []byte) error {
_, err := migrationHandler.Execute(c, payload)
return err
}, extpoints.WithTaskMeta(task.StorageMigrationMeta), extpoints.WithTaskRetry(defaultSingleRetry))
warmHandler := &task.WarmImageCacheHandler{}
ctx.Task().Register(task.WarmImageCacheTask, func(c context.Context, payload []byte) error {
_, err := warmHandler.Execute(c, payload)
return err
}, extpoints.WithTaskMeta(task.WarmImageCacheMeta), extpoints.WithTaskRetry(1))
// 4. Register Cron Schedule
ctx.Schedule().RegisterCron("0 3 * * *", task.SystemCleanupTask, nil)
// 5. Register Settings Schemas
ctx.Settings().Register(extpoints.SettingSchema{
Key: "upload.max_file_size_mb",
Default: 100,
Description: "Maximum upload file size limit in MB",
Type: "integer",
Category: "storage",
})
ctx.Settings().Register(extpoints.SettingSchema{
Key: "upload.allowed_extensions",
Default: "png,jpg,jpeg,gif,webp,svg,pdf,zip,tar,gz",
Description: "Allowed upload file extensions separated by commas",
Type: "string",
Category: "storage",
})
return nil
}
+146
View File
@@ -0,0 +1,146 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package upload
import (
"Wavelet/pkg/idgen"
"Wavelet/pkg/util"
"Wavelet/plugins/domain/upload/shared"
"context"
"strings"
"gorm.io/gorm"
)
// UploadListFilter filters paginated upload queries.
//
//nolint:revive
type UploadListFilter struct {
UserID uint64
Keyword string
Type string
Extension string
Page int
PageSize int
}
// ListUploads returns paginated upload records matching the filter.
func ListUploads(ctx context.Context, filter UploadListFilter) (int64, []Upload, error) {
query := shared.GetDB(ctx).Model(&Upload{}).
Where("status != ?", UploadStatusDeleted)
if filter.UserID != 0 {
query = query.Where("user_id = ?", filter.UserID)
}
if filter.Keyword != "" {
query = query.Where("LOWER(file_name) LIKE ? ESCAPE '\\'", "%"+util.EscapeLike(strings.ToLower(filter.Keyword))+"%")
}
if filter.Type != "" {
query = query.Where("type = ?", filter.Type)
}
if filter.Extension != "" {
query = query.Where("extension = ?", strings.ToLower(filter.Extension))
}
var total int64
if err := query.Count(&total).Error; err != nil {
return 0, nil, err
}
var items []Upload
offset := (filter.Page - 1) * filter.PageSize
if err := query.Order("created_at DESC").Offset(offset).Limit(filter.PageSize).Find(&items).Error; err != nil {
return 0, nil, err
}
return total, items, nil
}
// GetActiveUploadByID loads a non-deleted upload by ID.
func GetActiveUploadByID(ctx context.Context, id uint64) (Upload, error) {
var upload Upload
if err := shared.GetDB(ctx).Where("id = ? AND status != ?", id, UploadStatusDeleted).First(&upload).Error; err != nil {
return Upload{}, err
}
return upload, nil
}
// SoftDeleteUpload marks an upload as deleted.
// External modules must use upload.Remove or upload.RemoveOwned; only internal/apps/upload may call this.
func SoftDeleteUpload(ctx context.Context, upload *Upload) error {
return SoftDeleteUploadTx(shared.GetDB(ctx), upload)
}
// SoftDeleteUploadTx marks an upload as deleted within an existing transaction.
func SoftDeleteUploadTx(tx *gorm.DB, upload *Upload) error {
return tx.Model(upload).Update("status", UploadStatusDeleted).Error
}
// UpdateUpload applies partial field updates to an upload record.
func UpdateUpload(ctx context.Context, upload *Upload, updates map[string]any) error {
if len(updates) == 0 {
return nil
}
return shared.GetDB(ctx).Model(upload).Updates(updates).Error
}
// ListDistinctUploadTypes returns all distinct non-empty upload business types.
func ListDistinctUploadTypes(ctx context.Context) ([]string, error) {
var types []string
if err := shared.GetDB(ctx).Model(&Upload{}).
Where("type IS NOT NULL AND type != ''").
Distinct().
Pluck("type", &types).Error; err != nil {
return nil, err
}
return types, nil
}
// FindReusableUploadByHash finds an existing upload with the same hash and size.
func FindReusableUploadByHash(ctx context.Context, hash string, size int64) (Upload, error) {
var existing Upload
err := shared.GetDB(ctx).
Where("hash = ? AND file_size = ? AND status IN (?, ?)", hash, size, UploadStatusPending, UploadStatusUsed).
First(&existing).Error
return existing, err
}
// CreateUpload persists a new upload record.
func CreateUpload(ctx context.Context, upload *Upload) error {
return CreateUploadTx(shared.GetDB(ctx), upload)
}
// CreateUploadTx persists a new upload record within an existing transaction.
func CreateUploadTx(tx *gorm.DB, upload *Upload) error {
if upload.ID == 0 {
upload.ID = idgen.NextUint64ID()
}
return tx.Create(upload).Error
}
// ListUploadsByIDs returns active uploads matching the given IDs.
func ListUploadsByIDs(ctx context.Context, ids []uint64) ([]Upload, error) {
var uploads []Upload
if err := shared.GetDB(ctx).
Where("id IN ? AND status IN (?, ?)", ids, UploadStatusPending, UploadStatusUsed).
Find(&uploads).Error; err != nil {
return nil, err
}
return uploads, nil
}
// UploadQuery returns a scoped GORM query for uploads.
//
//nolint:revive
func UploadQuery(ctx context.Context) *gorm.DB {
return shared.GetDB(ctx).Model(&Upload{})
}
// ListUploadStats returns all upload statistics rows.
func ListUploadStats(ctx context.Context) ([]UploadStat, error) {
var stats []UploadStat
if err := shared.GetDB(ctx).Find(&stats).Error; err != nil {
return nil, err
}
return stats, nil
}
@@ -0,0 +1,146 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package repository 提供上传域数据库仓储层操作。
package repository
import (
"Wavelet/pkg/util"
"Wavelet/plugins/domain/upload/models"
"Wavelet/plugins/domain/upload/shared"
"context"
"errors"
"strings"
"gorm.io/gorm"
)
// UploadListFilter filters paginated upload queries.
type UploadListFilter struct {
UserID uint64
Keyword string
Type string
Extension string
Page int
PageSize int
}
// ListUploads returns paginated upload records matching the filter.
func ListUploads(ctx context.Context, filter UploadListFilter) (int64, []models.Upload, error) {
query := shared.GetDB(ctx).Model(&models.Upload{}).
Where("status != ?", models.UploadStatusDeleted)
if filter.UserID != 0 {
query = query.Where("user_id = ?", filter.UserID)
}
if filter.Keyword != "" {
query = query.Where("LOWER(file_name) LIKE ? ESCAPE '\\'", "%"+util.EscapeLike(strings.ToLower(filter.Keyword))+"%")
}
if filter.Type != "" {
query = query.Where("type = ?", filter.Type)
}
if filter.Extension != "" {
query = query.Where("extension = ?", strings.ToLower(filter.Extension))
}
var total int64
if err := query.Count(&total).Error; err != nil {
return 0, nil, err
}
var items []models.Upload
offset := (filter.Page - 1) * filter.PageSize
if err := query.Order("created_at DESC").Offset(offset).Limit(filter.PageSize).Find(&items).Error; err != nil {
return 0, nil, err
}
return total, items, nil
}
// GetActiveUploadByID loads a non-deleted upload by ID.
func GetActiveUploadByID(ctx context.Context, id uint64) (models.Upload, error) {
var upload models.Upload
if err := shared.GetDB(ctx).Where("id = ? AND status != ?", id, models.UploadStatusDeleted).First(&upload).Error; err != nil {
return models.Upload{}, err
}
return upload, nil
}
// SoftDeleteUpload marks an upload as deleted.
func SoftDeleteUpload(ctx context.Context, upload *models.Upload) error {
return SoftDeleteUploadTx(shared.GetDB(ctx), upload)
}
// SoftDeleteUploadTx marks an upload as deleted within an existing transaction.
func SoftDeleteUploadTx(tx *gorm.DB, upload *models.Upload) error {
return tx.Model(upload).Update("status", models.UploadStatusDeleted).Error
}
// UpdateUpload applies partial field updates to an upload record.
func UpdateUpload(ctx context.Context, upload *models.Upload, updates map[string]any) error {
if len(updates) == 0 {
return nil
}
return shared.GetDB(ctx).Model(upload).Updates(updates).Error
}
// ListDistinctUploadTypes returns all distinct non-empty upload business types.
func ListDistinctUploadTypes(ctx context.Context) ([]string, error) {
var types []string
if err := shared.GetDB(ctx).Model(&models.Upload{}).
Where("type IS NOT NULL AND type != ''").
Distinct().
Pluck("type", &types).Error; err != nil {
return nil, err
}
return types, nil
}
// FindReusableUploadByHash finds an existing upload with the same hash and size.
func FindReusableUploadByHash(ctx context.Context, hash string, size int64) (models.Upload, error) {
var existing models.Upload
err := shared.GetDB(ctx).
Where("hash = ? AND file_size = ? AND status IN (?, ?)", hash, size, models.UploadStatusPending, models.UploadStatusUsed).
First(&existing).Error
return existing, err
}
// CreateUpload persists a new upload record.
func CreateUpload(ctx context.Context, upload *models.Upload) error {
return CreateUploadTx(shared.GetDB(ctx), upload)
}
// CreateUploadTx persists a new upload record within an existing transaction.
func CreateUploadTx(tx *gorm.DB, upload *models.Upload) error {
return tx.Create(upload).Error
}
// ListUploadsByIDs returns active uploads matching the given IDs.
func ListUploadsByIDs(ctx context.Context, ids []uint64) ([]models.Upload, error) {
var uploads []models.Upload
if err := shared.GetDB(ctx).
Where("id IN ? AND status IN (?, ?)", ids, models.UploadStatusPending, models.UploadStatusUsed).
Find(&uploads).Error; err != nil {
return nil, err
}
return uploads, nil
}
// UploadQuery returns a scoped GORM query for uploads.
func UploadQuery(ctx context.Context) *gorm.DB {
return shared.GetDB(ctx).Model(&models.Upload{})
}
// ListUploadStats returns all upload statistics rows.
func ListUploadStats(ctx context.Context) ([]models.UploadStat, error) {
var stats []models.UploadStat
if err := shared.GetDB(ctx).Find(&stats).Error; err != nil {
return nil, err
}
return stats, nil
}
// IsRecordNotFound reports whether err is the persistence "record not found" sentinel.
// Upper layers must use this helper instead of importing gorm directly.
func IsRecordNotFound(err error) bool {
return errors.Is(err, gorm.ErrRecordNotFound)
}
@@ -0,0 +1,25 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package shared
// Upload size, path, media quality, and cache constants shared across subpackages.
const (
MaxUploadSize = 32 * 1024 * 1024 // 32MB
DetectContentBytes = 512 // http.DetectContentType 需要的最小字节数
UploadDirPerm = 0o755 // 上传目录权限
UploadFilePerm = 0o644 // 上传文件权限
ImageQualityLow = "low"
ImageQualityMedium = "medium"
ImageQualityHigh = "high"
ImageQualityOrigin = "origin"
DefaultPublicUploadType = "avatar"
FileStatsTrendDays = 7
MaxS3KeyLength = 1024
AccessCacheTTL = 5 // seconds; multiplied by time.Second at use site
UploadStatDimensionTotal = "total"
UploadStatDimensionType = "type"
UploadStatDimensionCategory = "category"
UploadStatDimensionTrend = "trend"
)
@@ -0,0 +1,130 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package shared
import (
"Wavelet/core"
"Wavelet/core/contracts"
"context"
"sync"
"gorm.io/gorm"
)
var (
svcMu sync.RWMutex
dbSvc contracts.DBService
cacheSvc contracts.CacheService
storageSvc contracts.StorageService
taskSvc contracts.TaskService
authSvc contracts.AuthService
)
// SetDBService configures the DBService.
func SetDBService(s contracts.DBService) {
svcMu.Lock()
defer svcMu.Unlock()
dbSvc = s
}
// SetCacheService configures the CacheService.
func SetCacheService(s contracts.CacheService) {
svcMu.Lock()
defer svcMu.Unlock()
cacheSvc = s
}
// SetStorageService configures the StorageService.
func SetStorageService(s contracts.StorageService) {
svcMu.Lock()
defer svcMu.Unlock()
storageSvc = s
}
// SetTaskService configures the TaskService.
func SetTaskService(s contracts.TaskService) {
svcMu.Lock()
defer svcMu.Unlock()
taskSvc = s
}
// SetAuthService configures the AuthService.
func SetAuthService(s contracts.AuthService) {
svcMu.Lock()
defer svcMu.Unlock()
authSvc = s
}
// ResetServices clears all injected services.
func ResetServices() {
svcMu.Lock()
defer svcMu.Unlock()
dbSvc = nil
cacheSvc = nil
storageSvc = nil
taskSvc = nil
authSvc = nil
}
// GetDB resolves the GORM DB instance.
func GetDB(ctx context.Context) *gorm.DB {
if c, ok := ctx.(*core.Context); ok && c != nil {
if s, err := core.Inject[contracts.DBService](c); err == nil && s != nil {
return s.DB(ctx)
}
}
svcMu.RLock()
s := dbSvc
svcMu.RUnlock()
if s != nil {
return s.DB(ctx)
}
return nil
}
// GetCache resolves the CacheService instance.
func GetCache(ctx context.Context) contracts.CacheService {
if c, ok := ctx.(*core.Context); ok && c != nil {
if s, err := core.Inject[contracts.CacheService](c); err == nil && s != nil {
return s
}
}
svcMu.RLock()
s := cacheSvc
svcMu.RUnlock()
return s
}
// GetStorage resolves the StorageService instance.
func GetStorage(ctx context.Context) contracts.StorageService {
if c, ok := ctx.(*core.Context); ok && c != nil {
if s, err := core.Inject[contracts.StorageService](c); err == nil && s != nil {
return s
}
}
svcMu.RLock()
s := storageSvc
svcMu.RUnlock()
return s
}
// GetTaskService resolves the TaskService instance.
func GetTaskService() contracts.TaskService {
svcMu.RLock()
defer svcMu.RUnlock()
return taskSvc
}
// GetAuthService resolves the AuthService instance.
func GetAuthService(ctx context.Context) contracts.AuthService {
if c, ok := ctx.(*core.Context); ok && c != nil {
if s, err := core.Inject[contracts.AuthService](c); err == nil && s != nil {
return s
}
}
svcMu.RLock()
s := authSvc
svcMu.RUnlock()
return s
}
@@ -0,0 +1,48 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package shared holds upload error and configuration constants shared across subpackages.
package shared
// 文件管理常量
const (
ErrNoFileSelected = "请选择要上传的文件"
ErrUnsupportedFormat = "只支持 JPG、PNG、WEBP 格式的图片"
ErrProcessFileFailed = "处理文件失败"
ErrSaveFileFailed = "保存文件失败"
ErrOpenFileFailed = "打开文件失败"
ErrSaveUploadRecordFailed = "保存上传记录失败"
ErrGenericFileTooLarge = "文件大小不能超过 32MB"
ErrFileContentExtensionMismatch = "文件内容与扩展名不匹配,可能包含安全风险"
ErrFileValidationFailed = "文件校验失败"
ErrInvalidMetadataJSON = "元数据 JSON 格式不合法"
ErrInvalidFileID = "无效的文件 ID"
ErrQueryUploadRecordFailed = "查询文件记录失败"
ErrInvalidBatchDownloadRequest = "参数绑定失败,请传入有效的文件 ID 数组"
ErrInvalidIDValueFormat = "无效的 ID 值: %s"
ErrRetrieveUploadRecordsFailed = "检索文件记录失败"
ErrNoValidFilesForArchive = "没有找到任何有效的文件记录进行打包"
ErrInvalidParams = "参数错误"
ErrQueryFileCountFailed = "查询文件数量失败"
ErrQueryFileListFailed = "查询文件列表失败"
ErrDeleteFileFailed = "删除文件失败"
ErrStorageReadOnly = "存储迁移维护中,当前仅允许读取文件"
ErrS3KeyRequired = "s3 key must not be empty"
ErrS3KeyTooLongFormat = "s3 key exceeds maximum length of %d"
ErrS3KeyStartsWithSlash = "s3 key must not start with /"
ErrS3KeyContainsNullBytes = "s3 key must not contain null bytes"
ErrQueryUnusedUploadsFailed = "查询未使用的上传文件失败: %w"
ErrImageCacheWarmupPayloadRequired = "图片缓存预热参数不能为空"
ErrInvalidImageCacheWarmupPayload = "图片缓存预热参数格式无效: %w"
ErrInvalidImageCacheWarmupQuality = "图片质量仅支持 low、medium、high"
ErrParseImageCacheWarmupPayload = "解析图片缓存预热参数失败: %w"
ErrQueryImagesForCacheWarmup = "查询待预热图片失败: %w"
ErrQueryTypeListFailed = "查询文件类型列表失败"
ErrUpdateFileFailed = "更新文件失败"
ErrUnauthorized = "未登录"
ErrFileRecordNotFound = "文件记录未找到"
ErrFileNotFound = "文件未找到"
ErrOperationForbidden = "无权操作"
ErrInvalidAccessModeParam = "无效的 access_mode 参数"
ErrInternalServerError = "服务器内部错误"
)
@@ -0,0 +1,299 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package shared
import (
"Wavelet/core/contracts"
"Wavelet/pkg/testhelper"
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"sync"
"testing"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// MockDBService is a mock implementation of contracts.DBService for unit testing.
type MockDBService struct {
DBInstance *gorm.DB
}
// GORM returns the underlying GORM instance.
func (m *MockDBService) GORM() *gorm.DB {
return m.DBInstance
}
// DB returns the GORM instance bound to context.
func (m *MockDBService) DB(ctx context.Context) *gorm.DB {
return m.DBInstance.WithContext(ctx)
}
// Named returns the named GORM instance.
func (m *MockDBService) Named(_ string) *gorm.DB {
return m.DBInstance
}
// MockCacheService is an in-memory mock implementation of contracts.CacheService for unit testing.
type MockCacheService struct {
mu sync.RWMutex
data map[string][]byte
}
// NewMockCacheService creates a new MockCacheService.
func NewMockCacheService() *MockCacheService {
return &MockCacheService{
data: make(map[string][]byte),
}
}
// Get retrieves a cached value.
func (m *MockCacheService) Get(_ context.Context, key string, val any) error {
m.mu.RLock()
defer m.mu.RUnlock()
b, ok := m.data[key]
if !ok {
return contracts.ErrCacheMiss
}
return json.Unmarshal(b, val)
}
// Set stores a key-value pair in cache.
func (m *MockCacheService) Set(_ context.Context, key string, val any, _ time.Duration) error {
m.mu.Lock()
defer m.mu.Unlock()
b, err := json.Marshal(val)
if err != nil {
return err
}
m.data[key] = b
return nil
}
// Delete removes a key from cache.
func (m *MockCacheService) Delete(_ context.Context, key string) error {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.data, key)
return nil
}
// GetOrSet retrieves or populates a cache entry.
func (m *MockCacheService) GetOrSet(ctx context.Context, key string, target any, ttl time.Duration, loader func() (any, error)) error {
err := m.Get(ctx, key, target)
if err == nil {
return nil
}
val, err := loader()
if err != nil {
return err
}
return m.Set(ctx, key, val, ttl)
}
// Invalidate invalidates a cache tag or prefix.
func (m *MockCacheService) Invalidate(_ context.Context, _ string) error {
return nil
}
// MockStorageService is an in-memory mock implementation of contracts.StorageService for unit testing.
type MockStorageService struct {
mu sync.RWMutex
objects map[string][]byte
}
// NewMockStorageService creates a new MockStorageService.
func NewMockStorageService() *MockStorageService {
return &MockStorageService{
objects: make(map[string][]byte),
}
}
// Put uploads an object into mock storage.
func (m *MockStorageService) Put(_ context.Context, key string, body io.Reader, _ int64, _ string) (contracts.StoragePutResult, error) {
m.mu.Lock()
defer m.mu.Unlock()
data, err := io.ReadAll(body)
if err != nil {
return contracts.StoragePutResult{}, err
}
m.objects[key] = data
return contracts.StoragePutResult{Key: key, Bucket: "test-bucket"}, nil
}
// PutRaw seeds an object directly into the in-memory store (test helper, no I/O).
func (m *MockStorageService) PutRaw(key string, data []byte) {
m.mu.Lock()
defer m.mu.Unlock()
m.objects[key] = data
}
// Get retrieves an object from mock storage.
func (m *MockStorageService) Get(_ context.Context, key string) (*contracts.StorageObject, error) {
m.mu.RLock()
defer m.mu.RUnlock()
data, ok := m.objects[key]
if ok {
return &contracts.StorageObject{
Key: key,
Body: io.NopCloser(bytes.NewReader(data)),
ContentLength: int64(len(data)),
ContentType: "application/octet-stream",
}, nil
}
return nil, gorm.ErrRecordNotFound
}
// Delete removes an object from mock storage.
func (m *MockStorageService) Delete(_ context.Context, key string) error {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.objects, key)
return nil
}
// Ingest handles programmatic file ingestion for mock storage.
func (m *MockStorageService) Ingest(_ context.Context, _ io.Reader, _ contracts.IngestOptions) (*contracts.IngestResult, error) {
return &contracts.IngestResult{ID: 1, Key: "test.png", Created: true, Stored: true}, nil
}
// MockAuthService is a mock implementation of contracts.AuthService for unit testing.
type MockAuthService struct {
DB *gorm.DB
}
// errMockNotImplemented marks a mock method no test exercises yet, so an
// accidental call reports this instead of dereferencing a nil result.
var errMockNotImplemented = errors.New("mock auth service: not implemented")
// RequireAuthMiddleware returns a dummy auth middleware.
func (a *MockAuthService) RequireAuthMiddleware() any {
return func(c *gin.Context) { c.Next() }
}
// RequireAdminMiddleware returns a dummy admin middleware.
func (a *MockAuthService) RequireAdminMiddleware() any {
return func(c *gin.Context) { c.Next() }
}
// DisallowTokenAuthMiddleware returns a dummy disallow token middleware.
func (a *MockAuthService) DisallowTokenAuthMiddleware() any {
return func(c *gin.Context) { c.Next() }
}
// GetCurrentUser returns the user associated with the request context.
func (a *MockAuthService) GetCurrentUser(ctx context.Context) (*contracts.UserDTO, error) {
if c, ok := ctx.(*gin.Context); ok {
authHeader := c.GetHeader("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
tokenHash := fmt.Sprintf("%x", sha256.Sum256([]byte(tokenStr)))
var tokenRecord struct {
UserID uint64
}
if err := a.DB.Table("w_access_tokens").Where("token_hash = ?", tokenHash).First(&tokenRecord).Error; err == nil {
return &contracts.UserDTO{ID: tokenRecord.UserID, IsActive: true}, nil
}
}
}
return nil, errors.New("unauthorized")
}
// GetCurrentUserID returns the current user ID.
func (a *MockAuthService) GetCurrentUserID(ctx context.Context) (uint64, error) {
u, err := a.GetCurrentUser(ctx)
if err != nil {
return 0, err
}
return u.ID, nil
}
// VerifyToken verifies an access token.
func (a *MockAuthService) VerifyToken(_ context.Context, token string) (*contracts.UserDTO, error) {
tokenHash := fmt.Sprintf("%x", sha256.Sum256([]byte(token)))
var tokenRecord struct {
UserID uint64
}
if err := a.DB.Table("w_access_tokens").Where("token_hash = ?", tokenHash).First(&tokenRecord).Error; err == nil {
return &contracts.UserDTO{ID: tokenRecord.UserID, IsActive: true}, nil
}
return nil, errors.New("unauthorized")
}
// Authenticate verifies credentials.
func (a *MockAuthService) Authenticate(_ context.Context, _, _ string) (*contracts.UserDTO, error) {
return nil, errMockNotImplemented
}
// CreateSession creates a login session.
func (a *MockAuthService) CreateSession(_ context.Context, _ uint64, _ map[string]any) (string, error) {
return "test-session", nil
}
// RevokeToken revokes an access token.
func (a *MockAuthService) RevokeToken(_ context.Context, _ string) error {
return nil
}
// RevokeUserSessions revokes all sessions for a user.
func (a *MockAuthService) RevokeUserSessions(_ context.Context, _ uint64) error {
return nil
}
// InvalidateCachedUser invalidates cached user profile.
func (a *MockAuthService) InvalidateCachedUser(_ context.Context, _ uint64) {}
// InvalidateCachedToken invalidates cached access token.
func (a *MockAuthService) InvalidateCachedToken(_ context.Context, _ string) {}
// ListAuthSources lists configured authentication sources.
func (a *MockAuthService) ListAuthSources(_ context.Context) ([]contracts.AuthSourceViewDTO, error) {
return nil, nil
}
// CreateAuthSource creates an authentication source.
func (a *MockAuthService) CreateAuthSource(_ context.Context, _ contracts.AuthSourceDTO) (*contracts.AuthSourceDTO, error) {
return nil, errMockNotImplemented
}
// UpdateAuthSource updates an authentication source.
func (a *MockAuthService) UpdateAuthSource(_ context.Context, _ uint64, _ contracts.AuthSourceDTO) (*contracts.AuthSourceDTO, error) {
return nil, errMockNotImplemented
}
// DeleteAuthSource deletes an authentication source.
func (a *MockAuthService) DeleteAuthSource(_ context.Context, _ uint64) error {
return nil
}
// ToggleAuthSource toggles an authentication source active state.
func (a *MockAuthService) ToggleAuthSource(_ context.Context, _ uint64) (*contracts.AuthSourceDTO, error) {
return nil, errMockNotImplemented
}
// SetupTestEnv initializes test helper environment and binds DB, Cache, Storage, Auth mocks to shared services.
func SetupTestEnv(t *testing.T) (*gorm.DB, func()) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
dbSvc := &MockDBService{DBInstance: dbConn}
cacheSvc := NewMockCacheService()
storageSvc := NewMockStorageService()
authSvc := &MockAuthService{DB: dbConn}
SetDBService(dbSvc)
SetCacheService(cacheSvc)
SetStorageService(storageSvc)
SetAuthService(authSvc)
return dbConn, func() {
ResetServices()
cleanup()
}
}
@@ -0,0 +1,42 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package stats maintains incremental upload statistics and aggregations.
package stats
import (
"Wavelet/plugins/domain/upload/util"
"strings"
)
const (
catImage = "图片"
catVideo = "视频"
catAudio = "音频"
catDocument = "文档"
catArchive = "压缩包"
catOther = "其他"
)
// GetFileCategory classifies a file by mime type and extension.
func GetFileCategory(mimeType, ext string) string {
mimeType = strings.ToLower(mimeType)
ext = strings.ToLower(ext)
if strings.HasPrefix(mimeType, "image/") || util.IsImageExtension(ext) {
return catImage
}
if strings.HasPrefix(mimeType, "video/") {
return catVideo
}
if strings.HasPrefix(mimeType, "audio/") {
return catAudio
}
if util.IsArchiveExtension(ext) || strings.Contains(mimeType, "zip") || strings.Contains(mimeType, "tar") || strings.Contains(mimeType, "gzip") {
return catArchive
}
if util.IsDocumentExtension(ext) || strings.HasPrefix(mimeType, "text/") || mimeType == "application/pdf" {
return catDocument
}
return catOther
}
@@ -0,0 +1,131 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package stats
import (
"Wavelet/pkg/logger"
"Wavelet/plugins/domain/upload/models"
"Wavelet/plugins/domain/upload/shared"
"context"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// ApplyUploadStatsAdd increments incremental stats for a newly active upload record.
func ApplyUploadStatsAdd(ctx context.Context, upload *models.Upload) error {
return applyUploadStatsDelta(ctx, upload, 1)
}
// ApplyUploadStatsRemove decrements incremental stats for a removed active upload record.
func ApplyUploadStatsRemove(ctx context.Context, upload *models.Upload) error {
return applyUploadStatsDelta(ctx, upload, -1)
}
// RebuildUploadStats rebuilds all incremental stats from current upload records.
func RebuildUploadStats(ctx context.Context) error {
return shared.GetDB(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("1 = 1").Delete(&models.UploadStat{}).Error; err != nil {
return err
}
var uploads []models.Upload
if err := tx.Where("status != ?", models.UploadStatusDeleted).Find(&uploads).Error; err != nil {
return err
}
for i := range uploads {
if err := ApplyUploadStatsDeltaTx(tx, &uploads[i], 1); err != nil {
return err
}
}
return nil
})
}
func applyUploadStatsDelta(ctx context.Context, upload *models.Upload, sign int64) error {
if upload == nil || !isActiveUploadStatus(upload.Status) {
return nil
}
return shared.GetDB(ctx).Transaction(func(tx *gorm.DB) error {
return ApplyUploadStatsDeltaTx(tx, upload, sign)
})
}
// ApplyUploadStatsDeltaTx applies incremental upload stats within an existing transaction.
func ApplyUploadStatsDeltaTx(tx *gorm.DB, upload *models.Upload, sign int64) error {
if upload == nil || !isActiveUploadStatus(upload.Status) || sign == 0 {
return nil
}
countDelta := sign
sizeDelta := sign * upload.FileSize
typeKey := upload.Type
if typeKey == "" {
typeKey = "generic"
}
entries := []struct {
dimension string
key string
}{
{models.UploadStatDimensionTotal, ""},
{models.UploadStatDimensionType, typeKey},
{models.UploadStatDimensionCategory, GetFileCategory(upload.MimeType, upload.Extension)},
{models.UploadStatDimensionTrend, upload.CreatedAt.Format("2006-01-02")},
}
for _, entry := range entries {
if err := upsertUploadStatDelta(tx, entry.dimension, entry.key, countDelta, sizeDelta); err != nil {
return err
}
}
return nil
}
func upsertUploadStatDelta(tx *gorm.DB, dimension, key string, countDelta, sizeDelta int64) error {
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{
{Name: "dimension"},
{Name: "stat_key"},
},
DoUpdates: clause.Assignments(map[string]any{
"file_count": gorm.Expr(
"CASE WHEN w_upload_stats.file_count + ? < 0 THEN 0 ELSE w_upload_stats.file_count + ? END",
countDelta,
countDelta,
),
"file_size": gorm.Expr(
"CASE WHEN w_upload_stats.file_size + ? < 0 THEN 0 ELSE w_upload_stats.file_size + ? END",
sizeDelta,
sizeDelta,
),
"updated_at": time.Now(),
}),
}).Create(&models.UploadStat{
Dimension: dimension,
StatKey: key,
FileCount: countDelta,
FileSize: sizeDelta,
}).Error
}
// RecordUploadStatsAdd logs and applies upload stats increment.
func RecordUploadStatsAdd(ctx context.Context, upload *models.Upload) {
if err := ApplyUploadStatsAdd(ctx, upload); err != nil {
logger.WarnF(ctx, "increment upload stats failed: %v", err)
}
}
// RecordUploadStatsRemove logs and applies upload stats decrement.
func RecordUploadStatsRemove(ctx context.Context, upload *models.Upload) {
if err := ApplyUploadStatsRemove(ctx, upload); err != nil {
logger.WarnF(ctx, "decrement upload stats failed: %v", err)
}
}
func isActiveUploadStatus(status models.UploadStatus) bool {
return status == models.UploadStatusPending || status == models.UploadStatusUsed
}
@@ -0,0 +1,127 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package stats
import (
"Wavelet/pkg/testhelper"
"Wavelet/plugins/domain/upload/models"
"Wavelet/plugins/domain/upload/shared"
"context"
"testing"
"time"
"gorm.io/gorm"
)
type mockDBService struct {
db *gorm.DB
}
func (m *mockDBService) GORM() *gorm.DB {
return m.db
}
func (m *mockDBService) DB(ctx context.Context) *gorm.DB {
return m.db.WithContext(ctx)
}
func (m *mockDBService) Named(_ string) *gorm.DB {
return m.db
}
func TestApplyUploadStatsDeltaTxWithinTransaction(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
shared.SetDBService(&mockDBService{db: dbConn})
defer func() {
shared.SetDBService(nil)
cleanup()
}()
ctx := context.Background()
upload := &models.Upload{
ID: 42002,
FileSize: 256,
MimeType: "image/jpeg",
Extension: "jpg",
Type: "avatar",
Status: models.UploadStatusUsed,
CreatedAt: time.Now(),
}
if err := shared.GetDB(ctx).Transaction(func(tx *gorm.DB) error {
return ApplyUploadStatsDeltaTx(tx, upload, 1)
}); err != nil {
t.Fatalf("ApplyUploadStatsDeltaTx returned error: %v", err)
}
stats, err := loadUploadStats(ctx)
if err != nil {
t.Fatalf("loadUploadStats returned error: %v", err)
}
if stats.TotalCount != 1 || stats.TotalSize != 256 {
t.Fatalf("unexpected total stats: count=%d size=%d", stats.TotalCount, stats.TotalSize)
}
}
func TestApplyUploadStatsAddAndRemove(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
shared.SetDBService(&mockDBService{db: dbConn})
defer func() {
shared.SetDBService(nil)
cleanup()
}()
ctx := context.Background()
upload := &models.Upload{
ID: 42001,
FileSize: 128,
MimeType: "image/png",
Extension: "png",
Type: "avatar",
Status: models.UploadStatusUsed,
CreatedAt: time.Now(),
}
if err := ApplyUploadStatsAdd(ctx, upload); err != nil {
t.Fatalf("ApplyUploadStatsAdd returned error: %v", err)
}
stats, err := loadUploadStats(ctx)
if err != nil {
t.Fatalf("loadUploadStats returned error: %v", err)
}
if stats.TotalCount != 1 || stats.TotalSize != 128 {
t.Fatalf("unexpected total stats: count=%d size=%d", stats.TotalCount, stats.TotalSize)
}
if err := ApplyUploadStatsRemove(ctx, upload); err != nil {
t.Fatalf("ApplyUploadStatsRemove returned error: %v", err)
}
stats, err = loadUploadStats(ctx)
if err != nil {
t.Fatalf("loadUploadStats after remove returned error: %v", err)
}
if stats.TotalCount != 0 || stats.TotalSize != 0 {
t.Fatalf("expected zeroed total stats, got count=%d size=%d", stats.TotalCount, stats.TotalSize)
}
}
type uploadStatsSnapshot struct {
TotalCount int64
TotalSize int64
}
func loadUploadStats(ctx context.Context) (uploadStatsSnapshot, error) {
var rows []models.UploadStat
if err := shared.GetDB(ctx).Where("dimension = ?", models.UploadStatDimensionTotal).Find(&rows).Error; err != nil {
return uploadStatsSnapshot{}, err
}
if len(rows) == 0 {
return uploadStatsSnapshot{}, nil
}
return uploadStatsSnapshot{
TotalCount: rows[0].FileCount,
TotalSize: rows[0].FileSize,
}, nil
}
@@ -0,0 +1,86 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package storage provides upload storage backend operations and migration state.
package storage
import (
"Wavelet/core/contracts"
"Wavelet/plugins/domain/upload/shared"
"context"
"sync"
"time"
)
// MigrationAccessState captures cached migration maintenance state.
type MigrationAccessState struct {
ReadOnly bool
Target contracts.StorageConfigDTO
HasTarget bool
TargetErr error
LoadErr error
}
var (
migrationAccessMu sync.RWMutex
migrationAccessCached MigrationAccessState
migrationAccessValid bool
migrationAccessCheckedAt time.Time
)
// ResetMigrationAccessCache clears the in-process migration access cache.
func ResetMigrationAccessCache() {
migrationAccessMu.Lock()
migrationAccessValid = false
migrationAccessMu.Unlock()
}
// LoadMigrationAccessState returns cached migration maintenance state.
func LoadMigrationAccessState(ctx context.Context) MigrationAccessState {
migrationAccessMu.RLock()
if migrationAccessValid && time.Since(migrationAccessCheckedAt) < time.Duration(shared.AccessCacheTTL)*time.Second {
state := migrationAccessCached
migrationAccessMu.RUnlock()
return state
}
migrationAccessMu.RUnlock()
migrationAccessMu.Lock()
defer migrationAccessMu.Unlock()
if migrationAccessValid && time.Since(migrationAccessCheckedAt) < time.Duration(shared.AccessCacheTTL)*time.Second {
return migrationAccessCached
}
migrationAccessCached = buildMigrationAccessState(ctx)
migrationAccessValid = true
migrationAccessCheckedAt = time.Now()
return migrationAccessCached
}
func buildMigrationAccessState(ctx context.Context) MigrationAccessState {
execution, ok, err := LatestMigrationExecution(ctx)
if err != nil {
return MigrationAccessState{LoadErr: err, ReadOnly: true}
}
if !ok || execution == nil {
return MigrationAccessState{}
}
state := MigrationAccessState{
ReadOnly: execution.Status != "succeeded",
}
if execution.Status == "succeeded" {
return state
}
target, err := ParseMigrationTargetConfig(ctx, []byte(execution.Payload))
if err != nil {
state.TargetErr = err
return state
}
state.Target = target
state.HasTarget = true
return state
}
@@ -0,0 +1,116 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package storage
import (
"Wavelet/core/contracts"
"Wavelet/plugins/domain/upload/shared"
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"gorm.io/gorm"
)
// StorageMigrationTask is the task name for storage migration.
const StorageMigrationTask = "storage:migrate"
// LatestMigrationExecution returns the most recent storage migration task execution.
func LatestMigrationExecution(ctx context.Context) (*contracts.TaskExecutionDTO, bool, error) {
db := shared.GetDB(ctx)
if db == nil {
return nil, false, nil
}
var exec contracts.TaskExecutionDTO
err := db.Table("w_task_executions").Where("task_type = ?", StorageMigrationTask).Order("id DESC").First(&exec).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, false, nil
}
return nil, false, err
}
return &exec, true, nil
}
// ParseMigrationTargetConfig parses and validates a storage migration target payload.
func ParseMigrationTargetConfig(_ context.Context, payload []byte) (contracts.StorageConfigDTO, error) {
if strings.TrimSpace(string(payload)) == "" {
return contracts.StorageConfigDTO{}, errors.New("storage migration target payload is required")
}
var raw struct {
Target json.RawMessage `json:"target"`
}
if err := json.Unmarshal(payload, &raw); err != nil {
return contracts.StorageConfigDTO{}, fmt.Errorf("parse storage migration payload envelope: %w", err)
}
if len(raw.Target) == 0 {
return contracts.StorageConfigDTO{}, errors.New("storage migration target payload is required")
}
var targetBytes []byte
var targetStr string
if err := json.Unmarshal(raw.Target, &targetStr); err == nil {
targetBytes = []byte(targetStr)
} else {
targetBytes = raw.Target
}
var target contracts.StorageConfigDTO
if err := json.Unmarshal(targetBytes, &target); err != nil {
return contracts.StorageConfigDTO{}, fmt.Errorf("parse target storage config: %w", err)
}
return target, nil
}
// NormalizeMigrationPayload validates and normalizes a storage migration payload.
func NormalizeMigrationPayload(ctx context.Context, payload []byte) ([]byte, contracts.StorageConfigDTO, error) {
target, err := ParseMigrationTargetConfig(ctx, payload)
if err != nil {
return nil, contracts.StorageConfigDTO{}, err
}
raw, err := json.Marshal(struct {
Target contracts.StorageConfigDTO `json:"target"`
}{Target: target})
if err != nil {
return nil, contracts.StorageConfigDTO{}, fmt.Errorf("serialize normalized payload: %w", err)
}
return raw, target, nil
}
// SaveActiveConfig persists the active storage configuration to w_system_configs.
func SaveActiveConfig(ctx context.Context, cfg contracts.StorageConfigDTO) error {
db := shared.GetDB(ctx)
if db == nil {
return errors.New("database not available")
}
data, err := json.Marshal(cfg)
if err != nil {
return err
}
return db.Table("w_system_configs").Where("key = ?", "storage_config").Update("value", string(data)).Error
}
// LoadStorageConfig loads the current storage configuration from w_system_configs.
func LoadStorageConfig(ctx context.Context) (contracts.StorageConfigDTO, error) {
db := shared.GetDB(ctx)
if db == nil {
return contracts.StorageConfigDTO{}, errors.New("database not available")
}
var row struct {
Value string
}
if err := db.Table("w_system_configs").Where("key = ?", "storage_config").First(&row).Error; err != nil {
return contracts.StorageConfigDTO{}, err
}
var cfg contracts.StorageConfigDTO
if err := json.Unmarshal([]byte(row.Value), &cfg); err != nil {
return contracts.StorageConfigDTO{}, err
}
return cfg, nil
}
@@ -0,0 +1,32 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package storage
import (
"Wavelet/core/contracts"
"Wavelet/pkg/logger"
"Wavelet/plugins/domain/upload/models"
"Wavelet/plugins/domain/upload/shared"
"context"
"errors"
)
// ReadOnly checks if the storage system is in read-only maintenance mode.
func ReadOnly(ctx context.Context) bool {
state := LoadMigrationAccessState(ctx)
if state.LoadErr != nil {
logger.ErrorF(ctx, "读取存储维护状态失败: %v", state.LoadErr)
return true
}
return state.ReadOnly
}
// OpenStoredObject opens a stored upload object from the active storage backend.
func OpenStoredObject(ctx context.Context, upload *models.Upload) (*contracts.StorageObject, error) {
storageSvc := shared.GetStorage(ctx)
if storageSvc == nil {
return nil, errors.New("storage service not available")
}
return storageSvc.Get(ctx, upload.FilePath)
}
@@ -0,0 +1,155 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package task provides upload-related async background task handlers.
package task
import (
"Wavelet/core/contracts"
"Wavelet/pkg/logger"
"Wavelet/plugins/domain/upload/models"
"Wavelet/plugins/domain/upload/shared"
"context"
"errors"
"fmt"
"time"
"gorm.io/gorm"
uploadcache "Wavelet/plugins/domain/upload/cache"
uploadstats "Wavelet/plugins/domain/upload/stats"
uploadstorage "Wavelet/plugins/domain/upload/storage"
)
const (
// SystemCleanupTask 系统定期垃圾清理任务标识
SystemCleanupTask = "system:cleanup"
// TaskTypeSystemCleanup 系统定期垃圾清理管理类型
TaskTypeSystemCleanup = "system_cleanup"
)
// SystemCleanupMeta represents the task metadata.
var SystemCleanupMeta = contracts.TaskMetaDTO{
Type: TaskTypeSystemCleanup,
AsynqTask: SystemCleanupTask,
Name: "系统垃圾清理",
DisplayName: "系统垃圾清理",
Description: "定期清理未使用上传文件、历史推送记录和过期任务执行日志",
Category: "maintenance",
SupportsTime: false,
MaxRetry: 3,
Queue: taskQueueDefault,
Retryable: true,
}
// SystemCleanupHandler 系统定期垃圾清理异步任务处理器
type SystemCleanupHandler struct{}
// Execute 执行系统清理(包含文件清理、历史推送日志和任务执行日志清理)
func (h *SystemCleanupHandler) Execute(ctx context.Context, _ []byte) (*contracts.TaskResultDTO, error) {
if uploadstorage.ReadOnly(ctx) {
return nil, errors.New(shared.ErrStorageReadOnly)
}
const batchSize = 100
var lastID uint64
var totalProcessed int
var totalDeleted int
oneHourAgo := time.Now().Add(-1 * time.Hour)
logger.InfoF(ctx, "开始扫描未使用的待删除上传文件,阈值时间: %s", oneHourAgo.Format(time.RFC3339))
db := shared.GetDB(ctx)
if db == nil {
return nil, errors.New("database service not available")
}
storageSvc := shared.GetStorage(ctx)
for {
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("system cleanup canceled: %w", err)
}
var pendingUploads []models.Upload
if err := db.
Where("id > ? AND status = ? AND created_at < ?", lastID, models.UploadStatusPending, oneHourAgo).
Order("id ASC").
Limit(batchSize).
Find(&pendingUploads).Error; err != nil {
logger.ErrorF(ctx, "查询过期待使用上传文件失败: %v", err)
return nil, fmt.Errorf("failed to query pending uploads: %w", err)
}
if len(pendingUploads) == 0 {
break
}
for i := range pendingUploads {
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("system cleanup canceled: %w", err)
}
upload := &pendingUploads[i]
totalProcessed++
lastID = upload.ID
if storageSvc != nil {
if err := storageSvc.Delete(ctx, upload.FilePath); err != nil {
logger.WarnF(ctx, "清理过期未确认上传底层文件失败 [ID:%d, Path:%s]: %v", upload.ID, upload.FilePath, err)
}
}
statsSnapshot := *upload
if err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Delete(upload).Error; err != nil {
return err
}
return uploadstats.ApplyUploadStatsDeltaTx(tx, &statsSnapshot, -1)
}); err != nil {
logger.ErrorF(ctx, "删除过期未确认上传记录失败 [ID:%d]: %v", upload.ID, err)
continue
}
uploadcache.EvictUploadMeta(ctx, upload.ID)
totalDeleted++
}
}
// 清理过期任务执行记录
var deletedExecutions int64
sevenDaysAgo := time.Now().Add(-7 * 24 * time.Hour)
if err := db.
Table("w_task_executions").
Where("created_at < ?", sevenDaysAgo).
Delete(&struct{}{}).Error; err != nil {
logger.WarnF(ctx, "清理过期任务执行日志失败: %v", err)
} else {
deletedExecutions = db.RowsAffected
logger.InfoF(ctx, "已清理 7 天前任务执行日志,共 %d 条", deletedExecutions)
}
// 清理已过期推送日志
var deletedPushLogs int64
thirtyDaysAgo := time.Now().Add(-30 * 24 * time.Hour)
if err := db.
Table("w_push_logs").
Where("created_at < ?", thirtyDaysAgo).
Delete(&struct{}{}).Error; err != nil {
logger.WarnF(ctx, "清理历史推送日志失败: %v", err)
} else {
deletedPushLogs = db.RowsAffected
logger.InfoF(ctx, "已清理 30 天前推送日志,共 %d 条", deletedPushLogs)
}
msg := fmt.Sprintf(
"系统垃圾清理完成,处理未确认文件: %d 个,物理删除: %d 个,清理过期任务日志: %d 条,清理历史推送日志: %d 条",
totalProcessed,
totalDeleted,
deletedExecutions,
deletedPushLogs,
)
logger.InfoF(ctx, "%s", msg)
return &contracts.TaskResultDTO{Message: msg}, nil
}
@@ -0,0 +1,80 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package task
import (
"Wavelet/core/contracts"
"Wavelet/pkg/logger"
"Wavelet/plugins/domain/upload/models"
"Wavelet/plugins/domain/upload/shared"
"context"
"errors"
"fmt"
uploadstats "Wavelet/plugins/domain/upload/stats"
)
const (
// RebuildUploadStatsTask is the Asynq task name for rebuilding upload stats.
RebuildUploadStatsTask = "upload:rebuild_stats"
// TaskTypeRebuildUploadStats is the admin-dispatchable task type.
TaskTypeRebuildUploadStats = "rebuild_upload_stats"
)
// RebuildUploadStatsMeta describes the upload stats rebuild task.
var RebuildUploadStatsMeta = contracts.TaskMetaDTO{
Type: TaskTypeRebuildUploadStats,
AsynqTask: RebuildUploadStatsTask,
Name: "重算文件存储统计",
DisplayName: "重算文件存储统计",
Description: "根据当前 w_uploads 活跃记录全量重建 w_upload_stats(总量、类型、分类、趋势)",
Category: taskCategoryUpload,
SupportsTime: false,
MaxRetry: 2,
Queue: taskQueueDefault,
Retryable: true,
}
// RebuildUploadStatsHandler rebuilds incremental upload stats from active upload records.
type RebuildUploadStatsHandler struct{}
// Execute scans active uploads and rebuilds all upload stat dimensions.
func (h *RebuildUploadStatsHandler) Execute(ctx context.Context, _ []byte) (*contracts.TaskResultDTO, error) {
db := shared.GetDB(ctx)
if db == nil {
return nil, errors.New("database service not available")
}
var activeCount int64
if err := db.
Model(&models.Upload{}).
Where("status != ?", models.UploadStatusDeleted).
Count(&activeCount).Error; err != nil {
logger.ErrorF(ctx, "统计活跃上传记录失败: %v", err)
return nil, fmt.Errorf("count active uploads: %w", err)
}
logger.InfoF(ctx, "开始重算文件存储统计,活跃记录数: %d", activeCount)
if err := uploadstats.RebuildUploadStats(ctx); err != nil {
logger.ErrorF(ctx, "重算文件存储统计失败: %v", err)
return nil, fmt.Errorf("rebuild upload stats: %w", err)
}
var totalStat models.UploadStat
if err := db.
Where("dimension = ? AND stat_key = ?", models.UploadStatDimensionTotal, "").
First(&totalStat).Error; err != nil {
logger.ErrorF(ctx, "读取总量统计失败: %v", err)
return nil, fmt.Errorf("read total upload stat: %w", err)
}
msg := fmt.Sprintf(
"文件存储统计重算完成,活跃文件: %d 个,总大小: %d 字节",
totalStat.FileCount,
totalStat.FileSize,
)
logger.InfoF(ctx, "%s", msg)
return &contracts.TaskResultDTO{Message: msg}, nil
}
@@ -0,0 +1,66 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package task
import (
"Wavelet/plugins/domain/upload/models"
"Wavelet/plugins/domain/upload/shared"
"context"
"testing"
"time"
)
func TestRebuildUploadStatsHandler_Execute(t *testing.T) {
_, cleanup := shared.SetupTestEnv(t)
defer cleanup()
ctx := context.Background()
now := time.Now()
uploads := []models.Upload{
{
UserID: 1001, FileName: "a.jpg", FilePath: "uploads/a.jpg",
FileSize: 100, MimeType: "image/jpeg", Extension: "jpg", Hash: "hash-a",
Type: "pixez_mirror", Status: models.UploadStatusUsed, CreatedAt: now,
},
{
UserID: 1001, FileName: "b.png", FilePath: "uploads/b.png",
FileSize: 200, MimeType: "image/png", Extension: "png", Hash: "hash-b",
Type: "attachment", Status: models.UploadStatusUsed, CreatedAt: now,
},
}
db := shared.GetDB(ctx)
for i := range uploads {
if err := db.Create(&uploads[i]).Error; err != nil {
t.Fatalf("seed upload failed: %v", err)
}
}
// Corrupt stats to ensure rebuild recalculates from uploads.
if err := db.Create(&models.UploadStat{
Dimension: models.UploadStatDimensionTotal,
StatKey: "",
FileCount: 0,
FileSize: 0,
}).Error; err != nil {
t.Fatalf("seed broken total stat failed: %v", err)
}
handler := &RebuildUploadStatsHandler{}
result, err := handler.Execute(ctx, nil)
if err != nil {
t.Fatalf("Execute() error = %v", err)
}
if result == nil || result.Message == "" {
t.Fatalf("Execute() returned empty result: %+v", result)
}
var totalStat models.UploadStat
if err := db.Where("dimension = ? AND stat_key = ?", models.UploadStatDimensionTotal, "").First(&totalStat).Error; err != nil {
t.Fatalf("query total stat failed: %v", err)
}
if totalStat.FileCount != 2 || totalStat.FileSize != 300 {
t.Fatalf("total stat mismatch: count=%d size=%d, want count=2 size=300", totalStat.FileCount, totalStat.FileSize)
}
}
@@ -0,0 +1,399 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package task
import (
"Wavelet/core/contracts"
"Wavelet/pkg/logger"
"Wavelet/pkg/util"
"Wavelet/plugins/domain/upload/models"
"Wavelet/plugins/domain/upload/shared"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"strings"
"sync/atomic"
"time"
"golang.org/x/sync/errgroup"
uploadstats "Wavelet/plugins/domain/upload/stats"
uploadstorage "Wavelet/plugins/domain/upload/storage"
)
const (
// StorageMigrationTask is the task name for storage migration.
StorageMigrationTask = uploadstorage.StorageMigrationTask
// TaskTypeStorageMigration is the task metadata type for storage migration.
TaskTypeStorageMigration = "storage_migration"
)
// StorageMigrationMeta describes the manually dispatchable migration task.
var StorageMigrationMeta = contracts.TaskMetaDTO{
Type: TaskTypeStorageMigration,
AsynqTask: StorageMigrationTask,
Name: "迁移文件存储",
DisplayName: "迁移文件存储",
Description: "将活动存储中的文件迁移到待切换的目标存储,迁移期间文件系统保持只读",
Category: taskCategoryUpload,
SupportsTime: false,
MaxRetry: 1,
Queue: taskQueueDefault,
Retryable: true,
Params: []contracts.TaskParamDTO{
{
Name: "target",
Label: "目标存储配置 (JSON)",
Type: "text",
Required: true,
Placeholder: `{"driver": "s3", "local": {"root": "."}, "s3": {"bucket": "my-bucket"}}`,
Description: "待迁移到的目标存储引擎完整配置 JSON 字符串",
},
},
}
// MigrationHandler copies stored objects and activates the target backend.
type MigrationHandler struct{}
// ValidatePayload rejects duplicate active migrations through the task framework.
func (h *MigrationHandler) ValidatePayload(payload []byte) ([]byte, error) {
normalized, _, err := uploadstorage.NormalizeMigrationPayload(context.Background(), payload)
if err != nil {
return payload, err
}
active, err := hasUnresolvedMigrationTask(context.Background())
if err != nil {
return payload, err
}
if active {
return payload, fmt.Errorf("storage migration task is already unresolved")
}
return normalized, nil
}
// Execute migrates all unique active-storage objects to the pending backend.
func (h *MigrationHandler) Execute(ctx context.Context, payload []byte) (*contracts.TaskResultDTO, error) {
cache := shared.GetCache(ctx)
if cache != nil {
const (
cleanupTimeout = 5 * time.Second
renewalInterval = 10 * time.Minute
)
lockKey := "lock:storage:migrate"
var lockVal string
if err := cache.Get(ctx, lockKey, &lockVal); err == nil && lockVal != "" {
return nil, errors.New("另一个存储迁移任务正在运行中")
}
_ = cache.Set(ctx, lockKey, "locked", time.Hour)
stopRenewal := make(chan struct{})
//nolint:contextcheck
defer func() {
close(stopRenewal)
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
defer cancel()
_ = cache.Delete(cleanupCtx, lockKey)
}()
util.Go(func() {
ticker := time.NewTicker(renewalInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
renewCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
//nolint:contextcheck // renewal must carry its own deadline; the task context may already be canceled
_ = cache.Set(renewCtx, lockKey, "locked", time.Hour)
cancel()
case <-stopRenewal:
return
case <-ctx.Done():
return
}
}
})
}
active, err := uploadstorage.LoadStorageConfig(ctx)
if err != nil {
return nil, fmt.Errorf("load active storage config: %w", err)
}
target, err := uploadstorage.ParseMigrationTargetConfig(ctx, payload)
if err != nil {
return nil, err
}
if target.Driver == active.Driver {
if err := uploadstorage.SaveActiveConfig(ctx, target); err != nil {
return nil, fmt.Errorf("activate same-driver storage config: %w", err)
}
message := fmt.Sprintf("存储配置已更新,活动存储保持为 %s", target.Driver)
logger.InfoF(ctx, "%s", message)
return &contracts.TaskResultDTO{Message: message}, nil
}
total, err := countStorageObjects(ctx)
if err != nil {
return nil, fmt.Errorf("count source objects: %w", err)
}
if total == 0 {
if err := uploadstorage.SaveActiveConfig(ctx, target); err != nil {
return nil, fmt.Errorf("activate empty storage config: %w", err)
}
message := fmt.Sprintf("当前存储没有需要迁移的对象,活动存储已切换为 %s", target.Driver)
logger.InfoF(ctx, "%s", message)
return &contracts.TaskResultDTO{Message: message}, nil
}
storageSvc := shared.GetStorage(ctx)
if storageSvc == nil {
return nil, errors.New("source storage service not available")
}
logger.InfoF(ctx, "开始存储迁移: %s -> %s,总对象数: %d", active.Driver, target.Driver, total)
migrated, err := migrateObjects(ctx, storageSvc, storageSvc, total)
if err != nil {
return nil, err
}
if err := uploadstorage.SaveActiveConfig(ctx, target); err != nil {
return nil, fmt.Errorf("activate target storage: %w", err)
}
message := fmt.Sprintf("存储迁移完成,共迁移 %d 个对象,活动存储已切换为 %s", migrated, target.Driver)
logger.InfoF(ctx, "%s", message)
return &contracts.TaskResultDTO{Message: message}, nil
}
func countStorageObjects(ctx context.Context) (int64, error) {
var count int64
db := shared.GetDB(ctx)
if db == nil {
return 0, errors.New("database not available")
}
err := db.Model(&models.Upload{}).
Where("status != ?", models.UploadStatusDeleted).
Distinct("file_path").
Count(&count).Error
return count, err
}
func hasUnresolvedMigrationTask(ctx context.Context) (bool, error) {
execution, ok, err := uploadstorage.LatestMigrationExecution(ctx)
if err != nil || !ok || execution == nil {
return false, err
}
return execution.Status == "pending" || execution.Status == "running", nil
}
type migrationObject struct {
FilePath string `gorm:"column:file_path"`
FileSize int64 `gorm:"column:file_size"`
MimeType string `gorm:"column:mime_type"`
Hash string `gorm:"column:hash"`
}
type storageReaderWriter interface {
Get(ctx context.Context, key string) (*contracts.StorageObject, error)
Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) (contracts.StoragePutResult, error)
}
func migrateObjects(
ctx context.Context,
sourceBackend storageReaderWriter,
targetBackend storageReaderWriter,
total int64,
) (int64, error) {
const batchSize = 50
const migrationConcurrency = 10
const sha256HexLength = 64
var migrated int64
var lastFilePath string
db := shared.GetDB(ctx)
if db == nil {
return 0, errors.New("database not available")
}
for {
if err := ctx.Err(); err != nil {
return atomic.LoadInt64(&migrated), fmt.Errorf("storage migration canceled: %w", err)
}
logger.InfoF(ctx, "正在查询待迁移对象批次,当前已完成迁移: %d/%d", atomic.LoadInt64(&migrated), total)
var objects []migrationObject
query := db.Model(&models.Upload{}).
Select("file_path, MAX(file_size) AS file_size, MAX(mime_type) AS mime_type, MAX(hash) AS hash").
Where("status != ?", models.UploadStatusDeleted)
if lastFilePath != "" {
query = query.Where("file_path > ?", lastFilePath)
}
if err := query.Group("file_path").
Order("file_path ASC").
Limit(batchSize).
Scan(&objects).Error; err != nil {
return atomic.LoadInt64(&migrated), fmt.Errorf("query source objects: %w", err)
}
if len(objects) == 0 {
logger.InfoF(ctx, "所有对象迁移完毕")
break
}
lastFilePath = objects[len(objects)-1].FilePath
logger.InfoF(ctx, "获取当前批次迁移对象,批次大小: %d,实际获取对象数: %d", batchSize, len(objects))
var g errgroup.Group
g.SetLimit(migrationConcurrency)
for _, object := range objects {
obj := object
g.Go(func() error {
if err := migrateSingleObject(ctx, sourceBackend, targetBackend, obj, sha256HexLength); err != nil {
return err
}
atomic.AddInt64(&migrated, 1)
return nil
})
}
if err := g.Wait(); err != nil {
return atomic.LoadInt64(&migrated), err
}
logger.InfoF(ctx, "当前批次迁移完成。迁移进度: %d/%d", atomic.LoadInt64(&migrated), total)
}
return atomic.LoadInt64(&migrated), nil
}
func migrateSingleObject(
ctx context.Context,
sourceBackend storageReaderWriter,
targetBackend storageReaderWriter,
obj migrationObject,
sha256HexLength int,
) error {
if shouldSkipMigration(ctx, sourceBackend, targetBackend, obj) {
logger.InfoF(ctx, "[跳过迁移] 目标存储已存在相同文件: %s", obj.FilePath)
return nil
}
logger.InfoF(ctx, "[迁移开始] 正在从源存储读取文件: %s", obj.FilePath)
source, err := sourceBackend.Get(ctx, obj.FilePath)
if err != nil {
if isNotFoundError(err) {
return markMissingMigrationObjectDeleted(ctx, obj.FilePath, err)
}
return fmt.Errorf("open source object %q: %w", obj.FilePath, err)
}
logger.InfoF(ctx, "[传输中] 正在向目标存储上传文件: %s (大小: %d 字节, 类型: %s)", obj.FilePath, obj.FileSize, obj.MimeType)
targetResult, putErr := targetBackend.Put(ctx, obj.FilePath, source.Body, obj.FileSize, obj.MimeType)
closeErr := source.Body.Close()
if putErr != nil {
return fmt.Errorf("copy object %q: %w", obj.FilePath, putErr)
}
if closeErr != nil {
return fmt.Errorf("close source object %q: %w", obj.FilePath, closeErr)
}
if len(obj.Hash) == sha256HexLength {
logger.InfoF(ctx, "[校验中] 正在对目标文件进行数据一致性校验 (SHA-256): %s", targetResult.Key)
targetObj, getErr := targetBackend.Get(ctx, targetResult.Key)
if getErr != nil {
return fmt.Errorf("retrieve target object for verification %q: %w", obj.FilePath, getErr)
}
if targetObj == nil || targetObj.Body == nil {
return fmt.Errorf("retrieve target object for verification %q: object or body is nil", obj.FilePath)
}
h := sha256.New()
if _, copyErr := io.Copy(h, targetObj.Body); copyErr != nil {
_ = targetObj.Body.Close()
return fmt.Errorf("read target object for verification %q: %w", obj.FilePath, copyErr)
}
_ = targetObj.Body.Close()
computedHash := hex.EncodeToString(h.Sum(nil))
if computedHash != obj.Hash {
return fmt.Errorf("integrity check failed for %q: got hash %s, want %s", obj.FilePath, computedHash, obj.Hash)
}
logger.InfoF(ctx, "[校验通过] 文件一致性校验成功: %s", targetResult.Key)
}
db := shared.GetDB(ctx)
if targetResult.Key != obj.FilePath && db != nil {
logger.InfoF(ctx, "[更新数据库] 正在更新文件路径: %s -> %s", obj.FilePath, targetResult.Key)
if err := db.Model(&models.Upload{}).
Where("file_path = ? AND status != ?", obj.FilePath, models.UploadStatusDeleted).
Update("file_path", targetResult.Key).Error; err != nil {
return fmt.Errorf("update migrated object %q: %w", obj.FilePath, err)
}
}
logger.InfoF(ctx, "[迁移成功] 文件已完成迁移: %s", targetResult.Key)
return nil
}
func shouldSkipMigration(
ctx context.Context,
sourceBackend storageReaderWriter,
targetBackend storageReaderWriter,
obj migrationObject,
) bool {
if sourceBackend == targetBackend {
return false
}
targetObj, err := targetBackend.Get(ctx, obj.FilePath)
if err != nil || targetObj == nil || targetObj.Body == nil {
return false
}
defer func() {
_ = targetObj.Body.Close()
}()
return targetObj.ContentLength == obj.FileSize
}
func markMissingMigrationObjectDeleted(
ctx context.Context,
filePath string,
sourceErr error,
) error {
logger.WarnF(ctx, "警告: 源存储中物理文件不存在,标记为已删除并跳过: %s (错误: %v)", filePath, sourceErr)
db := shared.GetDB(ctx)
if db == nil {
return errors.New("database not available")
}
var affectedUploads []models.Upload
if err := db.
Where("file_path = ? AND status != ?", filePath, models.UploadStatusDeleted).
Find(&affectedUploads).Error; err != nil {
return fmt.Errorf("load missing object uploads %q: %w", filePath, err)
}
if err := db.Model(&models.Upload{}).
Where("file_path = ?", filePath).
Update("status", models.UploadStatusDeleted).Error; err != nil {
return fmt.Errorf("update missing object %q: %w", filePath, err)
}
for i := range affectedUploads {
_ = uploadstats.ApplyUploadStatsRemove(ctx, &affectedUploads[i])
}
return nil
}
func isNotFoundError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, os.ErrNotExist) {
return true
}
errStr := strings.ToLower(err.Error())
for _, sub := range []string{"not found", "nosuchkey", "nosuchbucket", "404", "does not exist"} {
if strings.Contains(errStr, sub) {
return true
}
}
return false
}
@@ -0,0 +1,37 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package task
import (
"Wavelet/plugins/domain/upload/shared"
"context"
"strings"
"testing"
)
// TestMigrationHandlerRejectsCorruptActiveConfig pins the failure path the task
// package used to swallow. Its local loader discarded the read error and the
// JSON parse error and returned a zero config with a nil error, so a migration
// could proceed believing the active storage driver was simply unknown.
func TestMigrationHandlerRejectsCorruptActiveConfig(t *testing.T) {
dbConn, cleanup := shared.SetupTestEnv(t)
defer cleanup()
if err := dbConn.Table("w_system_configs").
Where("key = ?", "storage_config").
Update("value", "{ this is not json").Error; err != nil {
t.Fatalf("corrupt stored config: %v", err)
}
payload := []byte(`{"target":{"driver":"s3","s3":{"region":"us-east-1","bucket":"dst"}}}`)
result, err := (&MigrationHandler{}).Execute(context.Background(), payload)
if err == nil {
t.Fatalf("Execute() = (%v, nil), want an error for an unparsable active config", result)
}
if !strings.Contains(err.Error(), "load active storage config") {
t.Errorf("Execute() error = %q, want it to name the failed active-config read", err)
}
}
@@ -0,0 +1,227 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package task
import (
"Wavelet/core/contracts"
"Wavelet/plugins/domain/upload/models"
"Wavelet/plugins/domain/upload/shared"
"Wavelet/plugins/domain/upload/storage"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"strings"
"testing"
)
func TestMigrationHandlerExecute(t *testing.T) {
dbConn, cleanup := shared.SetupTestEnv(t)
defer cleanup()
ctx := context.Background()
storageSvc, ok := shared.GetStorage(ctx).(*shared.MockStorageService)
if !ok {
t.Fatalf("shared.GetStorage(%T) returned %T, want *shared.MockStorageService", ctx, storageSvc)
}
const sourcePath = "uploads/test.txt"
const content = "storage migration"
storageSvc.PutRaw(sourcePath, []byte(content))
active := contracts.StorageConfigDTO{
Driver: contracts.StorageDriverLocal,
Local: contracts.LocalStorageConfigDTO{Root: "unit"},
}
if err := storage.SaveActiveConfig(ctx, active); err != nil {
t.Fatalf("SaveActiveConfig() returned error: %v", err)
}
target := contracts.StorageConfigDTO{
Driver: contracts.StorageDriverS3,
S3: contracts.ObjectStorageConfigDTO{
Region: "us-east-1",
Bucket: "target",
AccessKeyID: "key",
SecretAccessKey: "secret",
},
}
payload, err := json.Marshal(struct {
Target contracts.StorageConfigDTO `json:"target"`
}{Target: target})
if err != nil {
t.Fatalf("Marshal(storageMigrationPayload) returned error: %v", err)
}
upload := models.Upload{
ID: 99101,
UserID: 1,
FileName: "test.txt",
FilePath: sourcePath,
FileSize: int64(len(content)),
MimeType: "text/plain",
Extension: "txt",
Hash: "hash",
Type: "attachment",
Status: models.UploadStatusUsed,
}
if err := dbConn.Create(&upload).Error; err != nil {
t.Fatalf("Create(upload) returned error: %v", err)
}
result, err := (&MigrationHandler{}).Execute(ctx, payload)
if err != nil {
t.Fatalf("Execute() returned error: %v", err)
}
if result == nil {
t.Fatal("Execute() result = nil, want non-nil")
}
var migrated models.Upload
if err := dbConn.First(&migrated, upload.ID).Error; err != nil {
t.Fatalf("First(upload) returned error: %v", err)
}
current, err := storage.LoadStorageConfig(ctx)
if err != nil {
t.Fatalf("LoadStorageConfig() returned error: %v", err)
}
if current.Driver != contracts.StorageDriverS3 {
t.Errorf("active driver = %q, want %q", current.Driver, contracts.StorageDriverS3)
}
}
func TestMigrationHandlerExecuteWithHashValidation(t *testing.T) {
dbConn, cleanup := shared.SetupTestEnv(t)
defer cleanup()
ctx := context.Background()
storageSvc, ok := shared.GetStorage(ctx).(*shared.MockStorageService)
if !ok {
t.Fatalf("shared.GetStorage(%T) returned %T, want *shared.MockStorageService", ctx, storageSvc)
}
const sourcePath = "uploads/test-hash.txt"
const content = "storage migration integrity check content"
storageSvc.PutRaw(sourcePath, []byte(content))
// Calculate correct SHA-256 hash
h := sha256.New()
h.Write([]byte(content))
correctHash := hex.EncodeToString(h.Sum(nil))
active := contracts.StorageConfigDTO{
Driver: contracts.StorageDriverLocal,
Local: contracts.LocalStorageConfigDTO{Root: "unit"},
}
if err := storage.SaveActiveConfig(ctx, active); err != nil {
t.Fatalf("SaveActiveConfig() returned error: %v", err)
}
target := contracts.StorageConfigDTO{
Driver: contracts.StorageDriverS3,
S3: contracts.ObjectStorageConfigDTO{
Region: "us-east-1",
Bucket: "target",
AccessKeyID: "key",
SecretAccessKey: "secret",
},
}
payload, err := json.Marshal(struct {
Target contracts.StorageConfigDTO `json:"target"`
}{Target: target})
if err != nil {
t.Fatalf("Marshal(storageMigrationPayload) returned error: %v", err)
}
// Case 1: Incorrect Hash (should fail validation)
uploadIncorrect := models.Upload{
ID: 99102,
UserID: 1,
FileName: "test-hash.txt",
FilePath: sourcePath,
FileSize: int64(len(content)),
MimeType: "text/plain",
Extension: "txt",
Hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", // Invalid hash
Type: "attachment",
Status: models.UploadStatusUsed,
}
if err := dbConn.Create(&uploadIncorrect).Error; err != nil {
t.Fatalf("Create(uploadIncorrect) returned error: %v", err)
}
// Running execution with incorrect hash should fail with integrity error
_, err = (&MigrationHandler{}).Execute(ctx, payload)
if err == nil {
t.Fatal("Execute() succeeded with incorrect hash, want error")
}
if !strings.Contains(err.Error(), "integrity check failed") {
t.Errorf("expected integrity check failed error, got: %v", err)
}
// Case 2: Correct Hash (should succeed)
if err := dbConn.Model(&models.Upload{}).Where("id = ?", uploadIncorrect.ID).Update("hash", correctHash).Error; err != nil {
t.Fatalf("Update hash to correct value returned error: %v", err)
}
// Run execution with correct hash should succeed
result, err := (&MigrationHandler{}).Execute(ctx, payload)
if err != nil {
t.Fatalf("Execute() with correct hash failed: %v", err)
}
if result == nil {
t.Fatal("Execute() result = nil, want non-nil")
}
var migrated models.Upload
if err := dbConn.First(&migrated, uploadIncorrect.ID).Error; err != nil {
t.Fatalf("First(upload) returned error: %v", err)
}
if migrated.FilePath != sourcePath {
t.Errorf("FilePath = %q, want %q", migrated.FilePath, sourcePath)
}
}
func TestMigrationHandlerExecuteWithLock(t *testing.T) {
_, cleanup := shared.SetupTestEnv(t)
defer cleanup()
ctx := context.Background()
cacheSvc := shared.GetCache(ctx)
if cacheSvc != nil {
_ = cacheSvc.Set(ctx, "lock:storage:migrate", "locked", 3600)
}
active := contracts.StorageConfigDTO{
Driver: contracts.StorageDriverLocal,
}
if err := storage.SaveActiveConfig(ctx, active); err != nil {
t.Fatalf("SaveActiveConfig() returned error: %v", err)
}
payload, err := json.Marshal(struct {
Target contracts.StorageConfigDTO `json:"target"`
}{Target: active})
if err != nil {
t.Fatalf("Marshal payload failed: %v", err)
}
// Execution should fail because lock is already acquired
_, err = (&MigrationHandler{}).Execute(ctx, payload)
if err == nil {
t.Fatal("Execute() succeeded when lock was held, want error")
}
if !strings.Contains(err.Error(), "另一个存储迁移任务正在运行中") {
t.Errorf("expected lock warning, got: %v", err)
}
// Release lock and run again, should succeed
if cacheSvc != nil {
_ = cacheSvc.Delete(ctx, "lock:storage:migrate")
}
_, err = (&MigrationHandler{}).Execute(ctx, payload)
if err != nil {
t.Fatalf("Execute() failed after lock released: %v", err)
}
}
+195
View File
@@ -0,0 +1,195 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package task
import (
"Wavelet/core/contracts"
"Wavelet/pkg/logger"
"Wavelet/plugins/domain/upload/filesrv"
"Wavelet/plugins/domain/upload/models"
"Wavelet/plugins/domain/upload/shared"
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
)
// 上传域任务的通用元数据常量(TaskMetaDTO Category/Queue 复用)
const (
taskCategoryUpload = "upload"
taskQueueDefault = "default"
)
const (
// WarmImageCacheTask 图片压缩缓存预热任务标识
WarmImageCacheTask = "upload:warm_image_cache"
// TaskTypeWarmImageCache 图片压缩缓存预热管理类型
TaskTypeWarmImageCache = "warm_image_cache"
)
var warmImageCacheMu sync.Mutex
// WarmImageCacheMeta represents the image cache warmup task metadata.
var WarmImageCacheMeta = contracts.TaskMetaDTO{
Type: TaskTypeWarmImageCache,
AsynqTask: WarmImageCacheTask,
Name: "预热图片压缩缓存",
DisplayName: "预热图片压缩缓存",
Description: "串行将文件管理中的图片转换为指定质量的 WebP 并写入永久缓存",
Category: taskCategoryUpload,
SupportsTime: false,
MaxRetry: 1,
Queue: taskQueueDefault,
Retryable: true,
Params: []contracts.TaskParamDTO{
{
Name: "quality",
Label: "图片质量",
Type: "string",
Required: true,
Placeholder: "low / medium / high",
Description: "WebP 压缩质量,仅支持 low、medium、high",
},
},
}
// WarmImageCachePayload is the image cache warmup task payload.
type WarmImageCachePayload struct {
Quality string `json:"quality"`
}
// WarmImageCacheHandler serially warms compressed image cache entries.
type WarmImageCacheHandler struct{}
// ValidatePayload validates and normalizes image cache warmup parameters.
func (h *WarmImageCacheHandler) ValidatePayload(payload []byte) ([]byte, error) {
if len(payload) == 0 {
return nil, errors.New(shared.ErrImageCacheWarmupPayloadRequired)
}
var req WarmImageCachePayload
if err := json.Unmarshal(payload, &req); err != nil {
return nil, fmt.Errorf(shared.ErrInvalidImageCacheWarmupPayload, err)
}
req.Quality = strings.ToLower(strings.TrimSpace(req.Quality))
if req.Quality != shared.ImageQualityLow &&
req.Quality != shared.ImageQualityMedium &&
req.Quality != shared.ImageQualityHigh {
return nil, errors.New(shared.ErrInvalidImageCacheWarmupQuality)
}
return json.Marshal(req)
}
// Execute serially converts all managed images to WebP cache entries.
func (h *WarmImageCacheHandler) Execute(ctx context.Context, payload []byte) (*contracts.TaskResultDTO, error) {
normalizedPayload, err := h.ValidatePayload(payload)
if err != nil {
logger.WarnF(ctx, "图片缓存预热参数无效: %v", err)
return nil, err
}
var req WarmImageCachePayload
if err := json.Unmarshal(normalizedPayload, &req); err != nil {
return nil, fmt.Errorf(shared.ErrParseImageCacheWarmupPayload, err)
}
logger.InfoF(ctx, "等待获取图片缓存预热执行锁,质量: %s", req.Quality)
warmImageCacheMu.Lock()
defer warmImageCacheMu.Unlock()
const (
batchSize = 50
maxFailureLogs = 5
)
var lastID uint64
var totalProcessed int
var totalCached int
var totalGenerated int
var totalFailed int
logger.InfoF(ctx, "开始串行预热图片压缩缓存,质量: %s,每批: %d", req.Quality, batchSize)
db := shared.GetDB(ctx)
if db == nil {
return nil, errors.New("database service not available")
}
for {
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("image cache warmup canceled: %w", err)
}
var uploads []models.Upload
if err := db.
Where("id > ? AND status != ? AND (LOWER(mime_type) LIKE ? OR LOWER(extension) IN ?)",
lastID,
models.UploadStatusDeleted,
"image/%",
[]string{"jpg", "jpeg", "png", "webp", "gif"},
).
Order("id ASC").
Limit(batchSize).
Find(&uploads).Error; err != nil {
logger.ErrorF(ctx, "查询图片上传记录失败: %v", err)
return nil, fmt.Errorf(shared.ErrQueryImagesForCacheWarmup, err)
}
if len(uploads) == 0 {
break
}
batchGenerated := 0
batchCached := 0
batchFailed := 0
for i := range uploads {
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("image cache warmup canceled: %w", err)
}
upload := &uploads[i]
totalProcessed++
lastID = upload.ID
_, cacheHit, err := filesrv.EnsureCompressedImageCache(ctx, upload, req.Quality)
if err != nil {
totalFailed++
batchFailed++
if totalFailed <= maxFailureLogs {
logger.WarnF(ctx, "图片处理失败 [ID:%d]: %v", upload.ID, err)
}
continue
}
if cacheHit {
totalCached++
batchCached++
continue
}
totalGenerated++
batchGenerated++
}
logger.InfoF(
ctx,
"批次完成,末尾 ID: %d,生成: %d,命中: %d,失败: %d",
lastID,
batchGenerated,
batchCached,
batchFailed,
)
}
msg := fmt.Sprintf(
"图片缓存预热完成,共处理 %d 张,生成 %d 张,命中 %d 张,失败 %d 张",
totalProcessed,
totalGenerated,
totalCached,
totalFailed,
)
logger.InfoF(ctx, "%s", msg)
return &contracts.TaskResultDTO{Message: msg}, nil
}
@@ -0,0 +1,288 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package task
import (
"Wavelet/plugins/domain/upload/filesrv"
"Wavelet/plugins/domain/upload/models"
"Wavelet/plugins/domain/upload/shared"
"bytes"
"context"
"encoding/json"
"image"
"image/color"
"image/png"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSystemCleanupHandler_Execute(t *testing.T) {
_, cleanup := shared.SetupTestEnv(t)
defer cleanup()
ctx := context.Background()
db := shared.GetDB(ctx)
// 准备测试数据:创建一些上传记录
now := time.Now()
twoHoursAgo := now.Add(-2 * time.Hour)
records := []*models.Upload{
// 超过1小时且状态为 pending 的记录 —— 应被清理
{
UserID: 1001, FileName: "old_file_1.jpg", FilePath: "uploads/old_1.jpg",
FileSize: 1024, MimeType: "image/jpeg", Extension: "jpg", Hash: "hash1",
Type: "attachment", Status: models.UploadStatusPending,
CreatedAt: twoHoursAgo,
},
{
UserID: 1001, FileName: "old_file_2.png", FilePath: "uploads/old_2.png",
FileSize: 2048, MimeType: "image/png", Extension: "png", Hash: "hash2",
Type: "attachment", Status: models.UploadStatusPending,
CreatedAt: twoHoursAgo,
},
// 状态为 used 的记录 —— 不应被清理
{
UserID: 1001, FileName: "used_file.jpg", FilePath: "uploads/used.jpg",
FileSize: 512, MimeType: "image/jpeg", Extension: "jpg", Hash: "hash3",
Type: "attachment", Status: models.UploadStatusUsed,
CreatedAt: twoHoursAgo,
},
// 不到1小时的 pending 记录 —— 不应被清理
{
UserID: 1001, FileName: "recent_file.jpg", FilePath: "uploads/recent.jpg",
FileSize: 256, MimeType: "image/jpeg", Extension: "jpg", Hash: "hash4",
Type: "attachment", Status: models.UploadStatusPending,
CreatedAt: now.Add(-10 * time.Minute),
},
}
for _, r := range records {
err := db.Create(r).Error
require.NoError(t, err)
}
// 执行 handler
handler := &SystemCleanupHandler{}
result, err := handler.Execute(ctx, nil)
// 验证结果
require.NoError(t, err)
require.NotNil(t, result)
assert.Contains(t, result.Message, "系统垃圾清理完成")
// 验证数据库状态:pending 且超过1小时的已被清理
var pendingCount int64
db.Model(&models.Upload{}).Where("status = ?", models.UploadStatusPending).Count(&pendingCount)
assert.Equal(t, int64(1), pendingCount, "应只剩1条 pending 记录(最近的文件)")
var usedCount int64
db.Model(&models.Upload{}).Where("status = ?", models.UploadStatusUsed).Count(&usedCount)
assert.Equal(t, int64(1), usedCount, "used 状态的文件不应受影响")
}
func TestSystemCleanupHandler_ExecuteNoFiles(t *testing.T) {
_, cleanup := shared.SetupTestEnv(t)
defer cleanup()
ctx := context.Background()
// 没有任何上传记录
handler := &SystemCleanupHandler{}
result, err := handler.Execute(ctx, nil)
require.NoError(t, err)
require.NotNil(t, result)
assert.Contains(t, result.Message, "系统垃圾清理完成")
}
func TestWarmImageCacheHandlerValidatePayload(t *testing.T) {
tests := []struct {
name string
payload []byte
wantQuality string
wantErr bool
}{
{
name: "normalizes quality",
payload: []byte(`{"quality":" HIGH "}`),
wantQuality: shared.ImageQualityHigh,
},
{
name: "empty payload",
wantErr: true,
},
{
name: "invalid json",
payload: []byte(`{`),
wantErr: true,
},
{
name: "origin is not a compressed quality",
payload: []byte(`{"quality":"origin"}`),
wantErr: true,
},
{
name: "unsupported quality",
payload: []byte(`{"quality":"maximum"}`),
wantErr: true,
},
}
handler := &WarmImageCacheHandler{}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotPayload, err := handler.ValidatePayload(tt.payload)
if gotErr := err != nil; gotErr != tt.wantErr {
t.Fatalf("ValidatePayload(%s) error = %v, want error presence = %t", tt.payload, err, tt.wantErr)
}
if tt.wantErr {
return
}
var got WarmImageCachePayload
if err := json.Unmarshal(gotPayload, &got); err != nil {
t.Fatalf("json.Unmarshal(%s) returned error: %v", gotPayload, err)
}
if got.Quality != tt.wantQuality {
t.Errorf("ValidatePayload(%s).Quality = %q, want %q", tt.payload, got.Quality, tt.wantQuality)
}
})
}
}
func TestWarmImageCacheHandlerExecute(t *testing.T) {
dbConn, cleanup := shared.SetupTestEnv(t)
defer cleanup()
ctx := context.Background()
firstPath := "uploads/first.png"
secondPath := "uploads/second.jpg"
firstData := writeTaskTestPNG(t, color.RGBA{R: 255, A: 255})
secondData := writeTaskTestPNG(t, color.RGBA{G: 255, A: 255})
storageSvc, ok := shared.GetStorage(ctx).(*shared.MockStorageService)
if !ok {
t.Fatalf("shared.GetStorage(%T) returned %T, want *shared.MockStorageService", ctx, storageSvc)
}
storageSvc.PutRaw(firstPath, firstData)
storageSvc.PutRaw(secondPath, secondData)
records := []models.Upload{
{
ID: 4101,
UserID: 1001,
FileName: "first.png",
FilePath: firstPath,
FileSize: int64(len(firstData)),
MimeType: "image/png",
Extension: "png",
Hash: "hash1",
Type: "attachment",
Status: models.UploadStatusUsed,
},
{
ID: 4102,
UserID: 1001,
FileName: "second.jpg",
FilePath: secondPath,
FileSize: int64(len(secondData)),
MimeType: "application/octet-stream",
Extension: "jpg",
Hash: "hash2",
Type: "attachment",
Status: models.UploadStatusPending,
},
{
ID: 4103,
UserID: 1001,
FileName: "notes.txt",
FilePath: "uploads/notes.txt",
FileSize: 123,
MimeType: "text/plain",
Extension: "txt",
Hash: "hash3",
Type: "attachment",
Status: models.UploadStatusUsed,
},
{
ID: 4104,
UserID: 1001,
FileName: "deleted.png",
FilePath: firstPath,
FileSize: int64(len(firstData)),
MimeType: "image/png",
Extension: "png",
Hash: "hash4",
Type: "attachment",
Status: models.UploadStatusDeleted,
},
}
for i := range records {
if err := dbConn.Create(&records[i]).Error; err != nil {
t.Fatalf("failed to create upload %d: %v", records[i].ID, err)
}
}
handler := &WarmImageCacheHandler{}
payload := []byte(`{"quality":"low"}`)
result, err := handler.Execute(ctx, payload)
if err != nil {
t.Fatalf("Execute(%s) returned error: %v", payload, err)
}
if result == nil {
t.Fatal("Execute() result = nil, want non-nil")
}
if result.Message != "图片缓存预热完成,共处理 2 张,生成 2 张,命中 0 张,失败 0 张" {
t.Errorf("Execute() message = %q, want generated summary", result.Message)
}
for i := range records[:2] {
key := filesrv.ImageCompressionCacheKey(&records[i], shared.ImageQualityLow)
got, hit, err := filesrv.EnsureCompressedImageCache(context.Background(), &records[i], shared.ImageQualityLow)
if err != nil {
t.Errorf("EnsureCompressedImageCache(%q) returned error: %v", key, err)
continue
}
if !hit {
t.Errorf("expected cache hit for %q", key)
}
if len(got) == 0 {
t.Errorf("EnsureCompressedImageCache(%q) returned empty WebP data", key)
}
}
secondResult, err := handler.Execute(ctx, payload)
if err != nil {
t.Fatalf("second Execute(%s) returned error: %v", payload, err)
}
if secondResult.Message != "图片缓存预热完成,共处理 2 张,生成 0 张,命中 2 张,失败 0 张" {
t.Errorf("second Execute() message = %q, want cache-hit summary", secondResult.Message)
}
}
func writeTaskTestPNG(t *testing.T, fill color.RGBA) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 2, 2))
for y := 0; y < 2; y++ {
for x := 0; x < 2; x++ {
img.Set(x, y, fill)
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("png.Encode() returned error: %v", err)
}
return buf.Bytes()
}
@@ -0,0 +1,50 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package util
import (
"Wavelet/plugins/domain/upload/shared"
"strings"
)
// IsImageExtension reports whether ext is a common image format.
func IsImageExtension(ext string) bool {
for _, imgExt := range []string{"jpg", "jpeg", "png", "webp", "gif"} {
if ext == imgExt {
return true
}
}
return false
}
// IsArchiveExtension reports whether ext is a common archive format.
func IsArchiveExtension(ext string) bool {
for _, e := range []string{"zip", "rar", "7z", "tar", "gz", "tgz", "bz2", "xz"} {
if ext == e {
return true
}
}
return false
}
// IsDocumentExtension reports whether ext is a common document format.
func IsDocumentExtension(ext string) bool {
for _, e := range []string{"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "md", "csv", "json", "yaml", "yml", "xml"} {
if ext == e {
return true
}
}
return false
}
// NormalizeImageQuality normalizes the requested image quality query parameter.
func NormalizeImageQuality(quality string) string {
q := strings.TrimSpace(strings.ToLower(quality))
switch q {
case shared.ImageQualityLow, shared.ImageQualityMedium, shared.ImageQualityHigh:
return q
default:
return shared.ImageQualityOrigin
}
}
@@ -0,0 +1,74 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package util provides upload media helpers and image utilities.
package util
import (
"Wavelet/plugins/domain/upload/shared"
"bytes"
"errors"
"fmt"
"image" // Register GIF decoder for image.Decode
_ "image/gif" // Register JPEG decoder for image.Decode
_ "image/jpeg" // Register PNG decoder for image.Decode
_ "image/png"
"io"
"strings"
"github.com/deepteams/webp"
_ "golang.org/x/image/webp" // Register WebP decoder for image.Decode
)
// ValidateS3Key validates an S3 object key for safety.
func ValidateS3Key(key string) error {
if key == "" {
return errors.New(shared.ErrS3KeyRequired)
}
if len(key) > shared.MaxS3KeyLength {
return fmt.Errorf(shared.ErrS3KeyTooLongFormat, shared.MaxS3KeyLength)
}
if strings.HasPrefix(key, "/") {
return errors.New(shared.ErrS3KeyStartsWithSlash)
}
if strings.Contains(key, "\x00") {
return errors.New(shared.ErrS3KeyContainsNullBytes)
}
return nil
}
// CompressImageToWebP decodes an image from srcReader and encodes it into WebP format
// using the specified quality (low -> 60, medium -> 75, high -> 85).
func CompressImageToWebP(srcReader io.Reader, quality string) ([]byte, error) {
img, format, err := image.Decode(srcReader)
if err != nil {
return nil, fmt.Errorf("failed to decode image (format: %s): %w", format, err)
}
var qualityScore float32
switch strings.ToLower(quality) {
case shared.ImageQualityLow:
qualityScore = 60
case shared.ImageQualityMedium:
qualityScore = 75
case shared.ImageQualityHigh, "":
qualityScore = 85
default:
qualityScore = 85
}
var buf bytes.Buffer
err = webp.Encode(&buf, img, &webp.EncoderOptions{
Quality: qualityScore,
Method: 4,
})
if err != nil {
return nil, fmt.Errorf("failed to encode WebP: %w", err)
}
return buf.Bytes(), nil
}