From 8b19ffed90e005bf23b776f455966ba7c6cb1bec Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 13 Jun 2026 16:04:21 +0800 Subject: [PATCH] refactor(storage): move file management routes from user to admin namespace - Remove file list, stats, download, and deletion routes from '/api/v1/upload' - Move these endpoints under '/api/v1/admin/uploads' - Remove user-specific filtering from files query and statistics to aggregate system-wide uploads by default - Allow admins to bypass ownership check when downloading private files - Update backend unit tests, Swagger documentation, and frontend service client and components --- docs/docs.go | 579 +++++++++--------- docs/swagger.json | 579 +++++++++--------- docs/swagger.yaml | 357 +++++------ .../components/common/admin/file-list.tsx | 4 +- frontend/components/common/admin/files.tsx | 4 +- frontend/components/layout/sidebar.tsx | 2 +- .../lib/services/upload/upload.service.ts | 14 +- internal/apps/upload/file_management.go | 41 +- internal/apps/upload/file_server.go | 3 + internal/apps/upload/routers.go | 10 +- internal/apps/upload/routers_test.go | 98 ++- internal/apps/upload/stats.go | 20 +- internal/router/router.go | 15 +- 13 files changed, 908 insertions(+), 818 deletions(-) diff --git a/docs/docs.go b/docs/docs.go index c532a46d..b02bbca3 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -2618,6 +2618,258 @@ const docTemplate = `{ } } }, + "/api/v1/admin/uploads": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "分页获取系统上传的文件列表,支持文件名关键词、业务类型、扩展名、上传用户ID过滤", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取文件列表", + "parameters": [ + { + "type": "integer", + "description": "页码(默认 1)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量(默认 20,最大 100)", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "文件名关键词(模糊匹配)", + "name": "keyword", + "in": "query" + }, + { + "type": "string", + "description": "业务分类过滤", + "name": "type", + "in": "query" + }, + { + "type": "string", + "description": "扩展名过滤", + "name": "extension", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "上传用户 ID", + "name": "user_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "查询成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/upload.listFilesResponse" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/admin/uploads/download/batch": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "传入多个文件 ID,后台实时将其打包压缩为 ZIP 流并输出,自动处理文件名重复冲突", + "consumes": [ + "application/json" + ], + "produces": [ + "application/octet-stream" + ], + "tags": [ + "admin" + ], + "summary": "批量打包下载", + "parameters": [ + { + "description": "包含文件 ID 数组 of string 的请求体", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/upload.batchDownloadRequest" + } + } + ], + "responses": { + "200": { + "description": "成功下载打包后的 ZIP", + "schema": { + "type": "file" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "打包失败", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/admin/uploads/download/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "根据文件 ID 获取文件,以附件形式 (Attachment) 强制开启客户端浏览器下载", + "produces": [ + "application/octet-stream" + ], + "tags": [ + "admin" + ], + "summary": "下载单文件", + "parameters": [ + { + "type": "string", + "description": "文件 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "图片质量 (low, medium, high, origin),默认为 origin", + "name": "quality", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功下载文件", + "schema": { + "type": "file" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "404": { + "description": "文件不存在", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "服务内部错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/admin/uploads/stats": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回系统级的总文件数、占用大小、最近 7 天新增趋势、文件类型/格式分布等数据", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取文件统计数据", + "responses": { + "200": { + "description": "获取成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/upload.fileStatsResponse" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/api/v1/admin/uploads/types": { "get": { "security": [ @@ -2676,6 +2928,52 @@ const docTemplate = `{ } } }, + "/api/v1/admin/uploads/{id}": { + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "将文件状态置为 deleted(软删除),不会立即清理底层存储对象", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "删除文件", + "parameters": [ + { + "type": "string", + "description": "文件 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无权操作", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "404": { + "description": "文件不存在", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/api/v1/admin/users": { "get": { "security": [ @@ -3646,285 +3944,6 @@ const docTemplate = `{ } } }, - "/api/v1/upload/download/batch": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "传入多个文件 ID,后台实时将其打包压缩为 ZIP 流并输出,自动处理文件名重复冲突", - "consumes": [ - "application/json" - ], - "produces": [ - "application/octet-stream" - ], - "tags": [ - "upload" - ], - "summary": "批量打包下载", - "parameters": [ - { - "description": "包含文件 ID 数组的请求体", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/upload.batchDownloadRequest" - } - } - ], - "responses": { - "200": { - "description": "成功下载打包后的 ZIP", - "schema": { - "type": "file" - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/util.ResponseAny" - } - }, - "500": { - "description": "打包失败", - "schema": { - "$ref": "#/definitions/util.ResponseAny" - } - } - } - } - }, - "/api/v1/upload/download/{id}": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "根据文件 ID 获取文件,以附件形式 (Attachment) 强制开启客户端浏览器下载", - "produces": [ - "application/octet-stream" - ], - "tags": [ - "upload" - ], - "summary": "下载单文件", - "parameters": [ - { - "type": "string", - "description": "文件 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "图片质量 (low, medium, high, origin),默认为 origin", - "name": "quality", - "in": "query" - } - ], - "responses": { - "200": { - "description": "成功下载文件", - "schema": { - "type": "file" - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/util.ResponseAny" - } - }, - "404": { - "description": "文件不存在", - "schema": { - "$ref": "#/definitions/util.ResponseAny" - } - }, - "500": { - "description": "服务内部错误", - "schema": { - "$ref": "#/definitions/util.ResponseAny" - } - } - } - } - }, - "/api/v1/upload/my": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "分页获取当前登录用户上传的文件,支持文件名关键词、业务类型、扩展名过滤", - "produces": [ - "application/json" - ], - "tags": [ - "upload" - ], - "summary": "获取我的文件列表", - "parameters": [ - { - "type": "integer", - "description": "页码(默认 1)", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "每页数量(默认 20,最大 100)", - "name": "page_size", - "in": "query" - }, - { - "type": "string", - "description": "文件名关键词(模糊匹配)", - "name": "keyword", - "in": "query" - }, - { - "type": "string", - "description": "业务分类过滤", - "name": "type", - "in": "query" - }, - { - "type": "string", - "description": "扩展名过滤", - "name": "extension", - "in": "query" - } - ], - "responses": { - "200": { - "description": "查询成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/util.ResponseAny" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/upload.listMyFilesResponse" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/util.ResponseAny" - } - } - } - } - }, - "/api/v1/upload/stats": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回当前用户的总文件数、占用大小、最近 7 天新增趋势、文件类型/格式分布等数据", - "produces": [ - "application/json" - ], - "tags": [ - "upload" - ], - "summary": "获取文件统计数据", - "responses": { - "200": { - "description": "获取成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/util.ResponseAny" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/upload.fileStatsResponse" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/util.ResponseAny" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/util.ResponseAny" - } - } - } - } - }, - "/api/v1/upload/{id}": { - "delete": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "将文件状态置为 deleted(软删除),不会立即清理底层存储对象", - "produces": [ - "application/json" - ], - "tags": [ - "upload" - ], - "summary": "删除文件", - "parameters": [ - { - "type": "string", - "description": "文件 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除成功", - "schema": { - "$ref": "#/definitions/util.ResponseAny" - } - }, - "403": { - "description": "无权操作", - "schema": { - "$ref": "#/definitions/util.ResponseAny" - } - }, - "404": { - "description": "文件不存在", - "schema": { - "$ref": "#/definitions/util.ResponseAny" - } - } - } - } - }, "/api/v1/user-info": { "get": { "security": [ @@ -5983,7 +6002,7 @@ const docTemplate = `{ } } }, - "upload.listMyFilesResponse": { + "upload.listFilesResponse": { "type": "object", "properties": { "items": { diff --git a/docs/swagger.json b/docs/swagger.json index 53d90ddf..b188c157 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -2611,6 +2611,258 @@ } } }, + "/api/v1/admin/uploads": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "分页获取系统上传的文件列表,支持文件名关键词、业务类型、扩展名、上传用户ID过滤", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取文件列表", + "parameters": [ + { + "type": "integer", + "description": "页码(默认 1)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量(默认 20,最大 100)", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "文件名关键词(模糊匹配)", + "name": "keyword", + "in": "query" + }, + { + "type": "string", + "description": "业务分类过滤", + "name": "type", + "in": "query" + }, + { + "type": "string", + "description": "扩展名过滤", + "name": "extension", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "上传用户 ID", + "name": "user_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "查询成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/upload.listFilesResponse" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/admin/uploads/download/batch": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "传入多个文件 ID,后台实时将其打包压缩为 ZIP 流并输出,自动处理文件名重复冲突", + "consumes": [ + "application/json" + ], + "produces": [ + "application/octet-stream" + ], + "tags": [ + "admin" + ], + "summary": "批量打包下载", + "parameters": [ + { + "description": "包含文件 ID 数组 of string 的请求体", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/upload.batchDownloadRequest" + } + } + ], + "responses": { + "200": { + "description": "成功下载打包后的 ZIP", + "schema": { + "type": "file" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "打包失败", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/admin/uploads/download/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "根据文件 ID 获取文件,以附件形式 (Attachment) 强制开启客户端浏览器下载", + "produces": [ + "application/octet-stream" + ], + "tags": [ + "admin" + ], + "summary": "下载单文件", + "parameters": [ + { + "type": "string", + "description": "文件 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "图片质量 (low, medium, high, origin),默认为 origin", + "name": "quality", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功下载文件", + "schema": { + "type": "file" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "404": { + "description": "文件不存在", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "服务内部错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/admin/uploads/stats": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回系统级的总文件数、占用大小、最近 7 天新增趋势、文件类型/格式分布等数据", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取文件统计数据", + "responses": { + "200": { + "description": "获取成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/upload.fileStatsResponse" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/api/v1/admin/uploads/types": { "get": { "security": [ @@ -2669,6 +2921,52 @@ } } }, + "/api/v1/admin/uploads/{id}": { + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "将文件状态置为 deleted(软删除),不会立即清理底层存储对象", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "删除文件", + "parameters": [ + { + "type": "string", + "description": "文件 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无权操作", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "404": { + "description": "文件不存在", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/api/v1/admin/users": { "get": { "security": [ @@ -3639,285 +3937,6 @@ } } }, - "/api/v1/upload/download/batch": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "传入多个文件 ID,后台实时将其打包压缩为 ZIP 流并输出,自动处理文件名重复冲突", - "consumes": [ - "application/json" - ], - "produces": [ - "application/octet-stream" - ], - "tags": [ - "upload" - ], - "summary": "批量打包下载", - "parameters": [ - { - "description": "包含文件 ID 数组的请求体", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/upload.batchDownloadRequest" - } - } - ], - "responses": { - "200": { - "description": "成功下载打包后的 ZIP", - "schema": { - "type": "file" - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/util.ResponseAny" - } - }, - "500": { - "description": "打包失败", - "schema": { - "$ref": "#/definitions/util.ResponseAny" - } - } - } - } - }, - "/api/v1/upload/download/{id}": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "根据文件 ID 获取文件,以附件形式 (Attachment) 强制开启客户端浏览器下载", - "produces": [ - "application/octet-stream" - ], - "tags": [ - "upload" - ], - "summary": "下载单文件", - "parameters": [ - { - "type": "string", - "description": "文件 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "图片质量 (low, medium, high, origin),默认为 origin", - "name": "quality", - "in": "query" - } - ], - "responses": { - "200": { - "description": "成功下载文件", - "schema": { - "type": "file" - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/util.ResponseAny" - } - }, - "404": { - "description": "文件不存在", - "schema": { - "$ref": "#/definitions/util.ResponseAny" - } - }, - "500": { - "description": "服务内部错误", - "schema": { - "$ref": "#/definitions/util.ResponseAny" - } - } - } - } - }, - "/api/v1/upload/my": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "分页获取当前登录用户上传的文件,支持文件名关键词、业务类型、扩展名过滤", - "produces": [ - "application/json" - ], - "tags": [ - "upload" - ], - "summary": "获取我的文件列表", - "parameters": [ - { - "type": "integer", - "description": "页码(默认 1)", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "每页数量(默认 20,最大 100)", - "name": "page_size", - "in": "query" - }, - { - "type": "string", - "description": "文件名关键词(模糊匹配)", - "name": "keyword", - "in": "query" - }, - { - "type": "string", - "description": "业务分类过滤", - "name": "type", - "in": "query" - }, - { - "type": "string", - "description": "扩展名过滤", - "name": "extension", - "in": "query" - } - ], - "responses": { - "200": { - "description": "查询成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/util.ResponseAny" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/upload.listMyFilesResponse" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/util.ResponseAny" - } - } - } - } - }, - "/api/v1/upload/stats": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回当前用户的总文件数、占用大小、最近 7 天新增趋势、文件类型/格式分布等数据", - "produces": [ - "application/json" - ], - "tags": [ - "upload" - ], - "summary": "获取文件统计数据", - "responses": { - "200": { - "description": "获取成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/util.ResponseAny" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/upload.fileStatsResponse" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/util.ResponseAny" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/util.ResponseAny" - } - } - } - } - }, - "/api/v1/upload/{id}": { - "delete": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "将文件状态置为 deleted(软删除),不会立即清理底层存储对象", - "produces": [ - "application/json" - ], - "tags": [ - "upload" - ], - "summary": "删除文件", - "parameters": [ - { - "type": "string", - "description": "文件 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除成功", - "schema": { - "$ref": "#/definitions/util.ResponseAny" - } - }, - "403": { - "description": "无权操作", - "schema": { - "$ref": "#/definitions/util.ResponseAny" - } - }, - "404": { - "description": "文件不存在", - "schema": { - "$ref": "#/definitions/util.ResponseAny" - } - } - } - } - }, "/api/v1/user-info": { "get": { "security": [ @@ -5976,7 +5995,7 @@ } } }, - "upload.listMyFilesResponse": { + "upload.listFilesResponse": { "type": "object", "properties": { "items": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index bc9a83c7..7efe006a 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -925,7 +925,7 @@ definitions: $ref: '#/definitions/upload.distributionItem' type: array type: object - upload.listMyFilesResponse: + upload.listFilesResponse: properties: items: items: @@ -2673,6 +2673,190 @@ paths: summary: 下载并应用应用更新 tags: - admin + /api/v1/admin/uploads: + get: + description: 分页获取系统上传的文件列表,支持文件名关键词、业务类型、扩展名、上传用户ID过滤 + parameters: + - description: 页码(默认 1) + in: query + name: page + type: integer + - description: 每页数量(默认 20,最大 100) + in: query + name: page_size + type: integer + - description: 文件名关键词(模糊匹配) + in: query + name: keyword + type: string + - description: 业务分类过滤 + in: query + name: type + type: string + - description: 扩展名过滤 + in: query + name: extension + type: string + - description: 上传用户 ID + format: int64 + in: query + name: user_id + type: integer + produces: + - application/json + responses: + "200": + description: 查询成功 + schema: + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + $ref: '#/definitions/upload.listFilesResponse' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/util.ResponseAny' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/util.ResponseAny' + security: + - SessionCookie: [] + summary: 获取文件列表 + tags: + - admin + /api/v1/admin/uploads/{id}: + delete: + description: 将文件状态置为 deleted(软删除),不会立即清理底层存储对象 + parameters: + - description: 文件 ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + $ref: '#/definitions/util.ResponseAny' + "403": + description: 无权操作 + schema: + $ref: '#/definitions/util.ResponseAny' + "404": + description: 文件不存在 + schema: + $ref: '#/definitions/util.ResponseAny' + security: + - SessionCookie: [] + summary: 删除文件 + tags: + - admin + /api/v1/admin/uploads/download/{id}: + get: + description: 根据文件 ID 获取文件,以附件形式 (Attachment) 强制开启客户端浏览器下载 + parameters: + - description: 文件 ID + in: path + name: id + required: true + type: string + - description: 图片质量 (low, medium, high, origin),默认为 origin + in: query + name: quality + type: string + produces: + - application/octet-stream + responses: + "200": + description: 成功下载文件 + schema: + type: file + "400": + description: 参数错误 + schema: + $ref: '#/definitions/util.ResponseAny' + "404": + description: 文件不存在 + schema: + $ref: '#/definitions/util.ResponseAny' + "500": + description: 服务内部错误 + schema: + $ref: '#/definitions/util.ResponseAny' + security: + - SessionCookie: [] + summary: 下载单文件 + tags: + - admin + /api/v1/admin/uploads/download/batch: + post: + consumes: + - application/json + description: 传入多个文件 ID,后台实时将其打包压缩为 ZIP 流并输出,自动处理文件名重复冲突 + parameters: + - description: 包含文件 ID 数组 of string 的请求体 + in: body + name: request + required: true + schema: + $ref: '#/definitions/upload.batchDownloadRequest' + produces: + - application/octet-stream + responses: + "200": + description: 成功下载打包后的 ZIP + schema: + type: file + "400": + description: 参数错误 + schema: + $ref: '#/definitions/util.ResponseAny' + "500": + description: 打包失败 + schema: + $ref: '#/definitions/util.ResponseAny' + security: + - SessionCookie: [] + summary: 批量打包下载 + tags: + - admin + /api/v1/admin/uploads/stats: + get: + description: 返回系统级的总文件数、占用大小、最近 7 天新增趋势、文件类型/格式分布等数据 + produces: + - application/json + responses: + "200": + description: 获取成功 + schema: + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + $ref: '#/definitions/upload.fileStatsResponse' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/util.ResponseAny' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/util.ResponseAny' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/util.ResponseAny' + security: + - SessionCookie: [] + summary: 获取文件统计数据 + tags: + - admin /api/v1/admin/uploads/types: get: description: 返回数据库中所有已上传文件实际拥有的业务类型列表 @@ -3286,177 +3470,6 @@ paths: summary: 上传文件 tags: - upload - /api/v1/upload/{id}: - delete: - description: 将文件状态置为 deleted(软删除),不会立即清理底层存储对象 - parameters: - - description: 文件 ID - in: path - name: id - required: true - type: string - produces: - - application/json - responses: - "200": - description: 删除成功 - schema: - $ref: '#/definitions/util.ResponseAny' - "403": - description: 无权操作 - schema: - $ref: '#/definitions/util.ResponseAny' - "404": - description: 文件不存在 - schema: - $ref: '#/definitions/util.ResponseAny' - security: - - SessionCookie: [] - summary: 删除文件 - tags: - - upload - /api/v1/upload/download/{id}: - get: - description: 根据文件 ID 获取文件,以附件形式 (Attachment) 强制开启客户端浏览器下载 - parameters: - - description: 文件 ID - in: path - name: id - required: true - type: string - - description: 图片质量 (low, medium, high, origin),默认为 origin - in: query - name: quality - type: string - produces: - - application/octet-stream - responses: - "200": - description: 成功下载文件 - schema: - type: file - "400": - description: 参数错误 - schema: - $ref: '#/definitions/util.ResponseAny' - "404": - description: 文件不存在 - schema: - $ref: '#/definitions/util.ResponseAny' - "500": - description: 服务内部错误 - schema: - $ref: '#/definitions/util.ResponseAny' - security: - - SessionCookie: [] - summary: 下载单文件 - tags: - - upload - /api/v1/upload/download/batch: - post: - consumes: - - application/json - description: 传入多个文件 ID,后台实时将其打包压缩为 ZIP 流并输出,自动处理文件名重复冲突 - parameters: - - description: 包含文件 ID 数组的请求体 - in: body - name: request - required: true - schema: - $ref: '#/definitions/upload.batchDownloadRequest' - produces: - - application/octet-stream - responses: - "200": - description: 成功下载打包后的 ZIP - schema: - type: file - "400": - description: 参数错误 - schema: - $ref: '#/definitions/util.ResponseAny' - "500": - description: 打包失败 - schema: - $ref: '#/definitions/util.ResponseAny' - security: - - SessionCookie: [] - summary: 批量打包下载 - tags: - - upload - /api/v1/upload/my: - get: - description: 分页获取当前登录用户上传的文件,支持文件名关键词、业务类型、扩展名过滤 - parameters: - - description: 页码(默认 1) - in: query - name: page - type: integer - - description: 每页数量(默认 20,最大 100) - in: query - name: page_size - type: integer - - description: 文件名关键词(模糊匹配) - in: query - name: keyword - type: string - - description: 业务分类过滤 - in: query - name: type - type: string - - description: 扩展名过滤 - in: query - name: extension - type: string - produces: - - application/json - responses: - "200": - description: 查询成功 - schema: - allOf: - - $ref: '#/definitions/util.ResponseAny' - - properties: - data: - $ref: '#/definitions/upload.listMyFilesResponse' - type: object - "401": - description: 未登录 - schema: - $ref: '#/definitions/util.ResponseAny' - security: - - SessionCookie: [] - summary: 获取我的文件列表 - tags: - - upload - /api/v1/upload/stats: - get: - description: 返回当前用户的总文件数、占用大小、最近 7 天新增趋势、文件类型/格式分布等数据 - produces: - - application/json - responses: - "200": - description: 获取成功 - schema: - allOf: - - $ref: '#/definitions/util.ResponseAny' - - properties: - data: - $ref: '#/definitions/upload.fileStatsResponse' - type: object - "401": - description: 未登录 - schema: - $ref: '#/definitions/util.ResponseAny' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/util.ResponseAny' - security: - - SessionCookie: [] - summary: 获取文件统计数据 - tags: - - upload /api/v1/user-info: get: description: 返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。 diff --git a/frontend/components/common/admin/file-list.tsx b/frontend/components/common/admin/file-list.tsx index 42e250bd..51d89c2c 100644 --- a/frontend/components/common/admin/file-list.tsx +++ b/frontend/components/common/admin/file-list.tsx @@ -80,8 +80,8 @@ export function FileList() { // 文件列表查询 const listQuery = useQuery({ - queryKey: ["files", "my", page, pageSize, debouncedKeyword], - queryFn: () => UploadService.listMyFiles(page, pageSize, debouncedKeyword || undefined), + queryKey: ["files", "all", page, pageSize, debouncedKeyword], + queryFn: () => UploadService.listUploads(page, pageSize, debouncedKeyword || undefined), }) const files = listQuery.data?.items ?? [] diff --git a/frontend/components/common/admin/files.tsx b/frontend/components/common/admin/files.tsx index 550f6433..cd958957 100644 --- a/frontend/components/common/admin/files.tsx +++ b/frontend/components/common/admin/files.tsx @@ -22,10 +22,10 @@ export function FilesMain() {

- 文件管理 + 存储管理

- 管理您上传的所有文件,支持下载、数据统计与批量操作 + 管理系统存储的所有文件,支持下载、数据统计与批量操作

diff --git a/frontend/components/layout/sidebar.tsx b/frontend/components/layout/sidebar.tsx index 5077716c..ab352db0 100644 --- a/frontend/components/layout/sidebar.tsx +++ b/frontend/components/layout/sidebar.tsx @@ -71,8 +71,8 @@ const data = { ], admin: [ { title: "用户管理", url: "/admin/users", icon: UserRound }, - { title: "文件管理", url: "/admin/files", icon: FolderOpen }, { title: "任务管理", url: "/admin/tasks", icon: Layers }, + { title: "存储管理", url: "/admin/files", icon: FolderOpen }, { title: "数据管理", url: "/admin/database", icon: Database }, { title: "系统日志", url: "/admin/logs", icon: Terminal }, { title: "系统配置", url: "/admin/system", icon: ShieldCheck }, diff --git a/frontend/lib/services/upload/upload.service.ts b/frontend/lib/services/upload/upload.service.ts index 0ef7da5d..4301b57d 100644 --- a/frontend/lib/services/upload/upload.service.ts +++ b/frontend/lib/services/upload/upload.service.ts @@ -1,6 +1,7 @@ import {BaseService} from '../core/base.service' import type {ListUploadsResponse, Upload, UploadImageResponse, FileStatsResponse} from './types' import type {InternalAxiosRequestConfig} from 'axios' +import apiClient from '../core/api-client' export type ImageQuality = 'low' | 'medium' | 'high' | 'origin' @@ -35,7 +36,7 @@ export function formatFileSize(bytes: number): string { * 处理文件上传相关的 API 请求 */ export class UploadService extends BaseService { - protected static readonly basePath = '/api/v1/upload' + protected static readonly basePath = '/api/v1/admin/uploads' /** * 通用文件上传 @@ -59,20 +60,21 @@ export class UploadService extends BaseService { formData.append('access_mode', String(accessMode)) } - return this.post('', formData, { + const response = await apiClient.post<{ data: Upload }>('/api/v1/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' }, } as InternalAxiosRequestConfig) + return response.data.data } /** - * 获取我的文件列表 + * 获取文件列表 * @param page - 页码(1-based) * @param pageSize - 每页数量 * @param keyword - 搜索关键词(文件名模糊) * @param type - 业务分类过滤 * @param extension - 扩展名过滤 */ - static async listMyFiles( + static async listUploads( page = 1, pageSize = 20, keyword?: string, @@ -83,7 +85,7 @@ export class UploadService extends BaseService { if (keyword) params.keyword = keyword if (type) params.type = type if (extension) params.extension = extension - return this.get('/my', params) + return this.get('', params) } /** @@ -104,7 +106,7 @@ export class UploadService extends BaseService { * 获取单文件下载 URL(触发 attachment 下载) */ static getDownloadUrl(id: string): string { - return `/api/v1/upload/download/${id}` + return `/api/v1/admin/uploads/download/${id}` } /** diff --git a/internal/apps/upload/file_management.go b/internal/apps/upload/file_management.go index 925c4a65..7f7f2adf 100644 --- a/internal/apps/upload/file_management.go +++ b/internal/apps/upload/file_management.go @@ -10,7 +10,6 @@ import ( "strconv" "strings" - "github.com/Rain-kl/Wavelet/internal/apps/oauth" "github.com/Rain-kl/Wavelet/internal/db" "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/util" @@ -18,40 +17,42 @@ import ( "gorm.io/gorm" ) -type listMyFilesRequest struct { +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 listMyFilesResponse struct { +type listFilesResponse struct { Total int64 `json:"total"` Page int `json:"page"` PageSize int `json:"page_size"` Items []model.Upload `json:"items"` } -// ListMyFiles 获取当前用户上传的文件列表 -// @Summary 获取我的文件列表 -// @Description 分页获取当前登录用户上传的文件,支持文件名关键词、业务类型、扩展名过滤 -// @Tags upload +// 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 uint64 false "上传用户 ID" // @Security SessionCookie -// @Success 200 {object} util.ResponseAny{data=listMyFilesResponse} "查询成功" +// @Success 200 {object} util.ResponseAny{data=listFilesResponse} "查询成功" // @Failure 401 {object} util.ResponseAny "未登录" -// @Router /api/v1/upload/my [get] -func ListMyFiles(c *gin.Context) { - currUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey) +// @Failure 403 {object} util.ResponseAny "无管理员权限" +// @Router /api/v1/admin/uploads [get] +func ListFiles(c *gin.Context) { ctx := c.Request.Context() - var req listMyFilesRequest + var req listFilesRequest if err := c.ShouldBindQuery(&req); err != nil { c.JSON(http.StatusOK, util.Err(ErrInvalidParams)) return @@ -64,8 +65,11 @@ func ListMyFiles(c *gin.Context) { } query := db.DB(ctx).Model(&model.Upload{}). - Where("user_id = ? AND status != ?", currUser.ID, model.UploadStatusDeleted) + Where("status != ?", model.UploadStatusDeleted) + if req.UserID != 0 { + query = query.Where("user_id = ?", req.UserID) + } if req.Keyword != "" { query = query.Where("LOWER(file_name) LIKE ?", "%"+strings.ToLower(req.Keyword)+"%") } @@ -89,7 +93,7 @@ func ListMyFiles(c *gin.Context) { return } - c.JSON(http.StatusOK, util.OK(listMyFilesResponse{ + c.JSON(http.StatusOK, util.OK(listFilesResponse{ Total: total, Page: req.Page, PageSize: req.PageSize, @@ -100,16 +104,15 @@ func ListMyFiles(c *gin.Context) { // DeleteFile 软删除文件记录 // @Summary 删除文件 // @Description 将文件状态置为 deleted(软删除),不会立即清理底层存储对象 -// @Tags upload +// @Tags admin // @Produce json // @Param id path string true "文件 ID" // @Security SessionCookie // @Success 200 {object} util.ResponseAny "删除成功" // @Failure 403 {object} util.ResponseAny "无权操作" // @Failure 404 {object} util.ResponseAny "文件不存在" -// @Router /api/v1/upload/{id} [delete] +// @Router /api/v1/admin/uploads/{id} [delete] func DeleteFile(c *gin.Context) { - currUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey) ctx := c.Request.Context() if storageReadOnly(ctx) { c.JSON(http.StatusConflict, util.Err(ErrStorageReadOnly)) @@ -131,10 +134,6 @@ func DeleteFile(c *gin.Context) { c.JSON(http.StatusOK, util.Err(ErrQueryUploadRecordFailed)) return } - if upload.UserID != currUser.ID && !currUser.IsAdmin { - c.AbortWithStatus(http.StatusForbidden) - return - } if err := db.DB(ctx).Model(&upload).Update("status", model.UploadStatusDeleted).Error; err != nil { c.JSON(http.StatusOK, util.Err(ErrDeleteFileFailed)) return diff --git a/internal/apps/upload/file_server.go b/internal/apps/upload/file_server.go index d7eb4754..fed744bf 100644 --- a/internal/apps/upload/file_server.go +++ b/internal/apps/upload/file_server.go @@ -292,6 +292,9 @@ func checkPrivateFileOwner(c *gin.Context, ownerID uint64) error { return err } } + if currUser.IsAdmin { + return nil + } if currUser.ID != ownerID { return errors.New("forbidden: cross-user access denied") } diff --git a/internal/apps/upload/routers.go b/internal/apps/upload/routers.go index 2a38e0cc..cd2bee77 100644 --- a/internal/apps/upload/routers.go +++ b/internal/apps/upload/routers.go @@ -189,7 +189,7 @@ func UploadFile(c *gin.Context) { // DownloadFile 通用单文件下载接口 // @Summary 下载单文件 // @Description 根据文件 ID 获取文件,以附件形式 (Attachment) 强制开启客户端浏览器下载 -// @Tags upload +// @Tags admin // @Produce octet-stream // @Param id path string true "文件 ID" // @Param quality query string false "图片质量 (low, medium, high, origin),默认为 origin" @@ -198,7 +198,7 @@ func UploadFile(c *gin.Context) { // @Failure 400 {object} util.ResponseAny "参数错误" // @Failure 404 {object} util.ResponseAny "文件不存在" // @Failure 500 {object} util.ResponseAny "服务内部错误" -// @Router /api/v1/upload/download/{id} [get] +// @Router /api/v1/admin/uploads/download/{id} [get] func DownloadFile(c *gin.Context) { upload, err := getUploadRecordByID(c) if err != nil { @@ -241,15 +241,15 @@ func DownloadFile(c *gin.Context) { // BatchDownloadFiles 批量打包 ZIP 下载接口 // @Summary 批量打包下载 // @Description 传入多个文件 ID,后台实时将其打包压缩为 ZIP 流并输出,自动处理文件名重复冲突 -// @Tags upload +// @Tags admin // @Accept json // @Produce octet-stream -// @Param request body upload.batchDownloadRequest true "包含文件 ID 数组的请求体" +// @Param request body upload.batchDownloadRequest true "包含文件 ID 数组 of string 的请求体" // @Security SessionCookie // @Success 200 {file} file "成功下载打包后的 ZIP" // @Failure 400 {object} util.ResponseAny "参数错误" // @Failure 500 {object} util.ResponseAny "打包失败" -// @Router /api/v1/upload/download/batch [post] +// @Router /api/v1/admin/uploads/download/batch [post] func BatchDownloadFiles(c *gin.Context) { ctx := c.Request.Context() diff --git a/internal/apps/upload/routers_test.go b/internal/apps/upload/routers_test.go index 1106722e..550e2bd8 100644 --- a/internal/apps/upload/routers_test.go +++ b/internal/apps/upload/routers_test.go @@ -36,21 +36,30 @@ type testResponse struct { func setupTestRouter(authUser *model.User) *gin.Engine { gin.SetMode(gin.TestMode) r := gin.New() - uploadGroup := r.Group("/api/v1/upload") - // Mock authentication middleware - uploadGroup.Use(func(c *gin.Context) { + authMiddleware := func(c *gin.Context) { if authUser != nil { util.SetToContext(c, oauth.UserObjKey, authUser) } c.Next() - }) + } + + uploadGroup := r.Group("/api/v1/upload") + uploadGroup.Use(authMiddleware) + { + uploadGroup.POST("", UploadFile) + } + + 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) + } - uploadGroup.POST("", UploadFile) - uploadGroup.GET("/my", ListMyFiles) - uploadGroup.GET("/stats", GetFileStats) - uploadGroup.GET("/download/:id", DownloadFile) - uploadGroup.POST("/download/batch", BatchDownloadFiles) return r } @@ -356,7 +365,7 @@ func TestDownloadFile(t *testing.T) { dbConn.Create(&localUpload) t.Run("download file successfully", func(t *testing.T) { - req, _ := http.NewRequest("GET", "/api/v1/upload/download/2001", nil) + req, _ := http.NewRequest("GET", "/api/v1/admin/uploads/download/2001", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) @@ -381,7 +390,7 @@ func TestDownloadFile(t *testing.T) { }) t.Run("download non-existent file", func(t *testing.T) { - req, _ := http.NewRequest("GET", "/api/v1/upload/download/9999", nil) + req, _ := http.NewRequest("GET", "/api/v1/admin/uploads/download/9999", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) @@ -391,7 +400,7 @@ func TestDownloadFile(t *testing.T) { }) } -func TestListMyFiles(t *testing.T) { +func TestListFiles(t *testing.T) { dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) defer cleanup() @@ -451,7 +460,7 @@ func TestListMyFiles(t *testing.T) { } t.Run("returns requested page", func(t *testing.T) { - req, _ := http.NewRequest("GET", "/api/v1/upload/my?page=2&page_size=2", nil) + req, _ := http.NewRequest("GET", "/api/v1/admin/uploads?page=2&page_size=2", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) @@ -460,29 +469,29 @@ func TestListMyFiles(t *testing.T) { t.Fatalf("failed to parse response: %v", err) } if resp.ErrorMsg != "" { - t.Fatalf("ListMyFiles() error = %q, want empty", resp.ErrorMsg) + t.Fatalf("ListFiles() error = %q, want empty", resp.ErrorMsg) } - var got listMyFilesResponse + 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("ListMyFiles(page=2).Page = %d, want 2", got.Page) + t.Errorf("ListFiles(page=2).Page = %d, want 2", got.Page) } if got.PageSize != 2 { - t.Errorf("ListMyFiles(page_size=2).PageSize = %d, want 2", got.PageSize) + t.Errorf("ListFiles(page_size=2).PageSize = %d, want 2", got.PageSize) } - if got.Total != 3 { - t.Errorf("ListMyFiles().Total = %d, want 3", got.Total) + if got.Total != 4 { + t.Errorf("ListFiles().Total = %d, want 4", got.Total) } - if len(got.Items) != 1 { - t.Fatalf("ListMyFiles(page=2, page_size=2) returned %d items, want 1", len(got.Items)) + 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/upload/my?keyword=photo", nil) + req, _ := http.NewRequest("GET", "/api/v1/admin/uploads?keyword=photo", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) @@ -491,21 +500,46 @@ func TestListMyFiles(t *testing.T) { t.Fatalf("failed to parse response: %v", err) } if resp.ErrorMsg != "" { - t.Fatalf("ListMyFiles(keyword=photo) error = %q, want empty", resp.ErrorMsg) + t.Fatalf("ListFiles(keyword=photo) error = %q, want empty", resp.ErrorMsg) } - var got listMyFilesResponse + 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("ListMyFiles(keyword=photo).Total = %d, want 1", got.Total) + t.Errorf("ListFiles(keyword=photo).Total = %d, want 1", got.Total) } if len(got.Items) != 1 { - t.Fatalf("ListMyFiles(keyword=photo) returned %d items, want 1", len(got.Items)) + t.Fatalf("ListFiles(keyword=photo) returned %d items, want 1", len(got.Items)) } if got.Items[0].FileName != "Second-Photo.PNG" { - t.Errorf("ListMyFiles(keyword=photo).Items[0].FileName = %q, want %q", 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)) } }) } @@ -573,7 +607,7 @@ func TestBatchDownloadFiles(t *testing.T) { reqBody, _ := json.Marshal(batchDownloadRequest{ IDs: []string{"3001", "3002", "3003"}, }) - req, _ := http.NewRequest("POST", "/api/v1/upload/download/batch", bytes.NewReader(reqBody)) + req, _ := http.NewRequest("POST", "/api/v1/admin/uploads/download/batch", bytes.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() @@ -688,7 +722,7 @@ func TestUploadAccessModeAccessControl(t *testing.T) { // 3. Verify accessing private file as user1 (owner) succeeds wAccessOwner := httptest.NewRecorder() - reqAccessOwner, _ := http.NewRequest("GET", "/api/v1/upload/download/"+strconv.FormatUint(upload1.ID, 10), nil) + 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) @@ -697,7 +731,7 @@ func TestUploadAccessModeAccessControl(t *testing.T) { // 4. Verify accessing private file as user2 (non-owner) fails routerUser2 := setupTestRouter(user2) wAccessOther := httptest.NewRecorder() - reqAccessOther, _ := http.NewRequest("GET", "/api/v1/upload/download/"+strconv.FormatUint(upload1.ID, 10), nil) + 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) @@ -705,7 +739,7 @@ func TestUploadAccessModeAccessControl(t *testing.T) { // 5. Verify accessing public file as user2 (non-owner) succeeds wAccessPublic := httptest.NewRecorder() - reqAccessPublic, _ := http.NewRequest("GET", "/api/v1/upload/download/"+strconv.FormatUint(upload2.ID, 10), nil) + 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) @@ -768,7 +802,7 @@ func TestGetFileStats(t *testing.T) { } } - req, _ := http.NewRequest("GET", "/api/v1/upload/stats", nil) + req, _ := http.NewRequest("GET", "/api/v1/admin/uploads/stats", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) diff --git a/internal/apps/upload/stats.go b/internal/apps/upload/stats.go index 7c51ae97..12e14fe3 100644 --- a/internal/apps/upload/stats.go +++ b/internal/apps/upload/stats.go @@ -8,7 +8,6 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/apps/oauth" "github.com/Rain-kl/Wavelet/internal/db" "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/util" @@ -35,20 +34,19 @@ type fileStatsResponse struct { Types []distributionItem `json:"types"` } -// GetFileStats 获取当前登录用户的文件统计数据 +// GetFileStats 获取系统上传的文件统计数据 // @Summary 获取文件统计数据 -// @Description 返回当前用户的总文件数、占用大小、最近 7 天新增趋势、文件类型/格式分布等数据 -// @Tags upload +// @Description 返回系统级的总文件数、占用大小、最近 7 天新增趋势、文件类型/格式分布等数据 +// @Tags admin // @Produce json // @Security SessionCookie // @Success 200 {object} util.ResponseAny{data=fileStatsResponse} "获取成功" // @Failure 401 {object} util.ResponseAny "未登录" +// @Failure 403 {object} util.ResponseAny "无管理员权限" // @Failure 500 {object} util.ResponseAny "内部错误" -// @Router /api/v1/upload/stats [get] +// @Router /api/v1/admin/uploads/stats [get] func GetFileStats(c *gin.Context) { - currUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey) ctx := c.Request.Context() - userID := currUser.ID // 1. 获取总文件数与总文件大小 var summary struct { @@ -57,7 +55,7 @@ func GetFileStats(c *gin.Context) { } err := db.DB(ctx).Model(&model.Upload{}). Select("COUNT(*) as total_count, COALESCE(SUM(file_size), 0) as total_size"). - Where("user_id = ? AND status != ?", userID, model.UploadStatusDeleted). + Where("status != ?", model.UploadStatusDeleted). Scan(&summary).Error if err != nil { c.JSON(http.StatusOK, util.Err(err.Error())) @@ -73,7 +71,7 @@ func GetFileStats(c *gin.Context) { var typeRaw []rawDist err = db.DB(ctx).Model(&model.Upload{}). Select("type as key, COUNT(*) as count, COALESCE(SUM(file_size), 0) as size"). - Where("user_id = ? AND status != ?", userID, model.UploadStatusDeleted). + Where("status != ?", model.UploadStatusDeleted). Group("type"). Scan(&typeRaw).Error if err != nil { @@ -103,7 +101,7 @@ func GetFileStats(c *gin.Context) { var fileRaws []fileCategoryRaw err = db.DB(ctx).Model(&model.Upload{}). Select("extension, mime_type, file_size"). - Where("user_id = ? AND status != ?", userID, model.UploadStatusDeleted). + Where("status != ?", model.UploadStatusDeleted). Scan(&fileRaws).Error if err != nil { c.JSON(http.StatusOK, util.Err(err.Error())) @@ -144,7 +142,7 @@ func GetFileStats(c *gin.Context) { startTime := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, -6) err = db.DB(ctx).Model(&model.Upload{}). Select("created_at, file_size"). - Where("user_id = ? AND status != ? AND created_at >= ?", userID, model.UploadStatusDeleted, startTime). + Where("status != ? AND created_at >= ?", model.UploadStatusDeleted, startTime). Scan(&trendRaws).Error if err != nil { c.JSON(http.StatusOK, util.Err(err.Error())) diff --git a/internal/router/router.go b/internal/router/router.go index 1d2c416b..07ec0ec6 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -214,11 +214,6 @@ func registerRoutes(r *gin.Engine) { uploadRouter.Use(oauth.LoginRequired()) { uploadRouter.POST("", upload.UploadFile) - uploadRouter.GET("/my", upload.ListMyFiles) - uploadRouter.GET("/stats", upload.GetFileStats) - uploadRouter.DELETE("/:id", upload.DeleteFile) - uploadRouter.GET("/download/:id", upload.DownloadFile) - uploadRouter.POST("/download/batch", upload.BatchDownloadFiles) } // Config (public) @@ -282,7 +277,15 @@ func registerRoutes(r *gin.Engine) { adminRouter.DELETE("/users/:id", admin_user.DeleteUser) // Uploads - adminRouter.GET("/uploads/types", upload.GetDistinctUploadTypes) + adminUploadsRouter := adminRouter.Group("/uploads") + { + adminUploadsRouter.GET("", upload.ListFiles) + adminUploadsRouter.GET("/stats", upload.GetFileStats) + adminUploadsRouter.DELETE("/:id", upload.DeleteFile) + adminUploadsRouter.GET("/download/:id", upload.DownloadFile) + adminUploadsRouter.POST("/download/batch", upload.BatchDownloadFiles) + adminUploadsRouter.GET("/types", upload.GetDistinctUploadTypes) + } // System Config adminRouter.POST("/system-configs", system_config.CreateSystemConfig)