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
@@ -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,
}))
}