diff --git a/docs/docs.go b/docs/docs.go index b02bbca3..64fc98e3 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -3944,6 +3944,195 @@ const docTemplate = `{ } } }, + "/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/{id}": { + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "更新当前用户本人的文件名或访问权限模式 (AccessMode)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "upload" + ], + "summary": "更新我的文件信息", + "parameters": [ + { + "type": "string", + "description": "文件 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "更新字段", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/upload.updateMyFileRequest" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.Upload" + } + } + } + ] + } + }, + "403": { + "description": "无权操作", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "404": { + "description": "文件不存在", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + }, + "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": [ @@ -6022,6 +6211,26 @@ const docTemplate = `{ } } }, + "upload.listMyFilesResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/model.Upload" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, "upload.trendItem": { "type": "object", "properties": { @@ -6036,6 +6245,22 @@ const docTemplate = `{ } } }, + "upload.updateMyFileRequest": { + "type": "object", + "properties": { + "access_mode": { + "type": "integer", + "enum": [ + 0, + 1 + ] + }, + "file_name": { + "type": "string", + "maxLength": 255 + } + } + }, "user.changePasswordRequest": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index b188c157..6528f81d 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -3937,6 +3937,195 @@ } } }, + "/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/{id}": { + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "更新当前用户本人的文件名或访问权限模式 (AccessMode)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "upload" + ], + "summary": "更新我的文件信息", + "parameters": [ + { + "type": "string", + "description": "文件 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "更新字段", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/upload.updateMyFileRequest" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.Upload" + } + } + } + ] + } + }, + "403": { + "description": "无权操作", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "404": { + "description": "文件不存在", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + }, + "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": [ @@ -6015,6 +6204,26 @@ } } }, + "upload.listMyFilesResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/model.Upload" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, "upload.trendItem": { "type": "object", "properties": { @@ -6029,6 +6238,22 @@ } } }, + "upload.updateMyFileRequest": { + "type": "object", + "properties": { + "access_mode": { + "type": "integer", + "enum": [ + 0, + 1 + ] + }, + "file_name": { + "type": "string", + "maxLength": 255 + } + } + }, "user.changePasswordRequest": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 7efe006a..a699f518 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -938,6 +938,19 @@ definitions: total: type: integer type: object + upload.listMyFilesResponse: + properties: + items: + items: + $ref: '#/definitions/model.Upload' + type: array + page: + type: integer + page_size: + type: integer + total: + type: integer + type: object upload.trendItem: properties: count: @@ -947,6 +960,17 @@ definitions: size: type: integer type: object + upload.updateMyFileRequest: + properties: + access_mode: + enum: + - 0 + - 1 + type: integer + file_name: + maxLength: 255 + type: string + type: object user.changePasswordRequest: properties: new_password: @@ -3470,6 +3494,121 @@ 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 + put: + consumes: + - application/json + description: 更新当前用户本人的文件名或访问权限模式 (AccessMode) + parameters: + - description: 文件 ID + in: path + name: id + required: true + type: string + - description: 更新字段 + in: body + name: request + required: true + schema: + $ref: '#/definitions/upload.updateMyFileRequest' + produces: + - application/json + responses: + "200": + description: 更新成功 + schema: + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + $ref: '#/definitions/model.Upload' + type: object + "403": + description: 无权操作 + schema: + $ref: '#/definitions/util.ResponseAny' + "404": + 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/user-info: get: description: 返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。 diff --git a/frontend/app/(main)/files/page.tsx b/frontend/app/(main)/files/page.tsx new file mode 100644 index 00000000..04a7f222 --- /dev/null +++ b/frontend/app/(main)/files/page.tsx @@ -0,0 +1,5 @@ +import {UserFilesMain} from "@/components/common/user/files" + +export default function UserFilesPage() { + return +} diff --git a/frontend/components/common/user/file-manager.tsx b/frontend/components/common/user/file-manager.tsx new file mode 100644 index 00000000..14708f76 --- /dev/null +++ b/frontend/components/common/user/file-manager.tsx @@ -0,0 +1,367 @@ +"use client" + +import * as React from "react" +import {useQuery, useMutation, useQueryClient} from "@tanstack/react-query" +import { + Upload, + Trash2, + Download, + Search, + FileText, + FileImage, + FileVideo, + FileAudio, + FileArchive, + Loader2, + Globe, + Lock, + Plus, + ChevronLeft, + ChevronRight +} from "lucide-react" +import {toast} from "sonner" + +import {Button} from "@/components/ui/button" +import {Input} from "@/components/ui/input" +import {Badge} from "@/components/ui/badge" +import {Card, CardContent} from "@/components/ui/card" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" + +import {formatFileSize, getFileUrl, UploadService} from "@/lib/services/upload/upload.service" +import type {Upload as UploadRecord} from "@/lib/services/upload/types" + +/* ─── 工具函数 ─────────────────────────────────────────── */ + +function getFileIcon(mimeType: string, className = "size-10") { + if (mimeType.startsWith("image/")) return + if (mimeType.startsWith("video/")) return + if (mimeType.startsWith("audio/")) return + if (mimeType.includes("zip") || mimeType.includes("tar") || mimeType.includes("gzip")) + return + return +} + +function formatDate(dateStr: string) { + return new Date(dateStr).toLocaleString("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }) +} + +export function UserFileManager() { + const queryClient = useQueryClient() + const [keyword, setKeyword] = React.useState("") + const [debouncedKeyword, setDebouncedKeyword] = React.useState("") + const [page, setPage] = React.useState(1) + const [uploading, setUploading] = React.useState(false) + const [deleteTarget, setDeleteTarget] = React.useState(null) + const [uploadAccessMode, setUploadAccessMode] = React.useState(0) // 默认私有 + const fileInputRef = React.useRef(null) + const pageSize = 12 // 每页 12 个卡片更均衡(3x4 或 4x3 布局) + + // 搜索防抖 + React.useEffect(() => { + const timer = setTimeout(() => { + setDebouncedKeyword(keyword) + setPage(1) + }, 400) + return () => clearTimeout(timer) + }, [keyword]) + + // 我的文件列表查询 + const { data, isPending } = useQuery({ + queryKey: ["user-files", page, pageSize, debouncedKeyword], + queryFn: () => UploadService.listMyUploads(page, pageSize, debouncedKeyword || undefined), + }) + + const files = data?.items ?? [] + const total = data?.total ?? 0 + const totalPages = Math.ceil(total / pageSize) + + // 上传文件 Mutation + const uploadMutation = useMutation({ + mutationFn: async (file: File) => { + return UploadService.uploadFile(file, "generic", undefined, uploadAccessMode) + }, + onMutate: () => { + setUploading(true) + }, + onSuccess: () => { + toast.success("文件上传成功") + void queryClient.invalidateQueries({ queryKey: ["user-files"] }) + }, + onError: (err: Error) => { + toast.error(err.message || "上传失败") + }, + onSettled: () => { + setUploading(false) + if (fileInputRef.current) { + fileInputRef.current.value = "" + } + } + }) + + // 删除文件 Mutation + const deleteMutation = useMutation({ + mutationFn: (id: string) => UploadService.deleteMyFile(id), + onSuccess: () => { + toast.success("文件已成功删除") + void queryClient.invalidateQueries({ queryKey: ["user-files"] }) + setDeleteTarget(null) + }, + onError: (err: Error) => { + toast.error(err.message || "删除失败") + } + }) + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) { + uploadMutation.mutate(file) + } + } + + const triggerUploadClick = () => { + fileInputRef.current?.click() + } + + const handleDownload = (file: UploadRecord) => { + const url = UploadService.getDownloadUrl(file.id) + const a = document.createElement("a") + a.href = url + a.download = file.file_name + a.click() + } + + return ( +
+ {/* 操作栏:搜索、权限选择、上传按钮 */} +
+
+ + setKeyword(e.target.value)} + className="pl-9 h-9" + /> +
+ +
+ {/* 上传权限设置 */} +
+ + +
+ + + +
+
+ + {/* 文件展示区 */} + {isPending ? ( +
+ +
+ ) : files.length === 0 ? ( + + +
+ +
+
+

还没有上传文件

+

+ {debouncedKeyword ? "未搜索到匹配的文件" : "上传您的第一个文件,支持图片、视频、文档和压缩包等格式。"} +

+
+ {!debouncedKeyword && ( + + )} +
+
+ ) : ( +
+ {files.map((file) => ( + + {/* 文件预览/图标区 */} +
+ {file.mime_type.startsWith("image/") ? ( + // eslint-disable-next-line @next/next/no-img-element + {file.file_name} { + ;(e.currentTarget as HTMLImageElement).style.display = "none" + }} + /> + ) : ( + getFileIcon(file.mime_type) + )} + + {/* 访问权限 Badge */} +
+ {file.access_mode === 0 ? ( + + + 私有 + + ) : ( + + + 公开 + + )} +
+
+ + {/* 文件详情区 */} + +
+

+ {file.file_name} +

+
+ {formatFileSize(file.file_size)} + {file.mime_type} +
+
+ +
+ {formatDate(file.created_at)} + +
+ + +
+
+
+
+ ))} +
+ )} + + {/* 分页 */} + {totalPages > 1 && ( +
+ + + {page} / {totalPages} + + +
+ )} + + {/* 删除确认 Dialog */} + !open && setDeleteTarget(null)}> + + + 确认删除文件 + + 确定要删除文件{" "} + 「{deleteTarget?.file_name}」{" "} + 吗?删除后此文件将无法恢复。 + + + + 取消 + deleteTarget && deleteMutation.mutate(deleteTarget.id)} + disabled={deleteMutation.isPending} + className="bg-destructive hover:bg-destructive/90 text-destructive-foreground" + > + {deleteMutation.isPending && } + 确认删除 + + + + +
+ ) +} diff --git a/frontend/components/common/user/files.tsx b/frontend/components/common/user/files.tsx new file mode 100644 index 00000000..13bd52f0 --- /dev/null +++ b/frontend/components/common/user/files.tsx @@ -0,0 +1,31 @@ +"use client" + +import * as React from "react" +import {motion} from "motion/react" + +import {UserFileManager} from "./file-manager" + +export function UserFilesMain() { + return ( + + {/* 顶部标题区 */} +
+
+

+ 我的文件 +

+

+ 管理您上传的个人文件,支持上传、下载、搜索与删除操作 +

+
+
+ + +
+ ) +} diff --git a/frontend/components/layout/sidebar.tsx b/frontend/components/layout/sidebar.tsx index ab352db0..63d9f376 100644 --- a/frontend/components/layout/sidebar.tsx +++ b/frontend/components/layout/sidebar.tsx @@ -68,6 +68,7 @@ import {usePublicConfig} from "@/hooks/use-public-config" const data = { navMain: [ { title: "首页", url: "/home", icon: Home }, + { title: "我的文件", url: "/files", icon: FolderOpen }, ], admin: [ { title: "用户管理", url: "/admin/users", icon: UserRound }, diff --git a/frontend/lib/services/upload/types.ts b/frontend/lib/services/upload/types.ts index 8b9cc839..20c13cc4 100644 --- a/frontend/lib/services/upload/types.ts +++ b/frontend/lib/services/upload/types.ts @@ -27,6 +27,7 @@ export interface Upload { storage_driver: string type: string status: string + access_mode: number metadata: UploadMetadata created_at: string updated_at: string diff --git a/frontend/lib/services/upload/upload.service.ts b/frontend/lib/services/upload/upload.service.ts index 4301b57d..b434bf78 100644 --- a/frontend/lib/services/upload/upload.service.ts +++ b/frontend/lib/services/upload/upload.service.ts @@ -136,4 +136,54 @@ export class UploadService extends BaseService { const result = await this.uploadFile(file, type, undefined, accessMode) return { id: result.id } } + + /** + * 获取我的文件列表 + */ + static async listMyUploads( + page = 1, + pageSize = 20, + keyword?: string, + type?: string, + extension?: string + ): Promise { + const params: Record = { page, page_size: pageSize } + if (keyword) params.keyword = keyword + if (type) params.type = type + if (extension) params.extension = extension + const response = await apiClient.get<{ data: ListUploadsResponse }>('/api/v1/upload/my', { + params + } as InternalAxiosRequestConfig) + return response.data.data + } + + + /** + * 删除我的文件 + */ + static async deleteMyFile(id: string): Promise { + const response = await apiClient.delete<{ data: void }>(`/api/v1/upload/${id}`) + return response.data.data + } + + /** + * 更新我的文件 + */ + static async updateMyFile(id: string, fileName: string, accessMode?: number): Promise { + const response = await apiClient.put<{ data: Upload }>(`/api/v1/upload/${id}`, { + file_name: fileName, + access_mode: accessMode + }) + return response.data.data + } + + /** + * 我的文件批量 ZIP 打包下载 + */ + static async batchDownloadMyFiles(ids: string[]): Promise { + const response = await apiClient.post('/api/v1/upload/download/batch', { ids }, { + responseType: 'blob', + } as InternalAxiosRequestConfig) + return response.data + } } diff --git a/internal/apps/upload/file_management.go b/internal/apps/upload/file_management.go index 7f7f2adf..8f28fc2d 100644 --- a/internal/apps/upload/file_management.go +++ b/internal/apps/upload/file_management.go @@ -10,6 +10,7 @@ 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" @@ -164,3 +165,200 @@ func GetDistinctUploadTypes(c *gin.Context) { sort.Strings(dbTypes) c.JSON(http.StatusOK, util.OK(dbTypes)) } + +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 []model.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} util.ResponseAny{data=listMyFilesResponse} "查询成功" +// @Failure 401 {object} util.ResponseAny "未登录" +// @Router /api/v1/upload/my [get] +func ListMyFiles(c *gin.Context) { + currUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey) + ctx := c.Request.Context() + + var req listMyFilesRequest + if err := c.ShouldBindQuery(&req); err != nil { + c.JSON(http.StatusOK, util.Err(ErrInvalidParams)) + return + } + if req.Page <= 0 { + req.Page = 1 + } + if req.PageSize <= 0 || req.PageSize > 100 { + req.PageSize = 20 + } + + query := db.DB(ctx).Model(&model.Upload{}). + Where("user_id = ? AND status != ?", currUser.ID, model.UploadStatusDeleted) + + if req.Keyword != "" { + query = query.Where("LOWER(file_name) LIKE ?", "%"+strings.ToLower(req.Keyword)+"%") + } + if req.Type != "" { + query = query.Where("type = ?", req.Type) + } + if req.Extension != "" { + query = query.Where("extension = ?", strings.ToLower(req.Extension)) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + c.JSON(http.StatusOK, util.Err(ErrQueryFileCountFailed)) + return + } + + var items []model.Upload + offset := (req.Page - 1) * req.PageSize + if err := query.Order("created_at DESC").Offset(offset).Limit(req.PageSize).Find(&items).Error; err != nil { + c.JSON(http.StatusOK, util.Err(ErrQueryFileListFailed)) + return + } + + c.JSON(http.StatusOK, util.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} util.ResponseAny "删除成功" +// @Failure 403 {object} util.ResponseAny "无权操作" +// @Failure 404 {object} util.ResponseAny "文件不存在" +// @Router /api/v1/upload/{id} [delete] +func DeleteMyFile(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)) + return + } + + uploadID, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusOK, util.Err(ErrInvalidFileID)) + return + } + + var upload model.Upload + if err := db.DB(ctx).Where("id = ? AND status != ?", uploadID, model.UploadStatusDeleted).First(&upload).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.AbortWithStatus(http.StatusNotFound) + return + } + c.JSON(http.StatusOK, util.Err(ErrQueryUploadRecordFailed)) + return + } + + if upload.UserID != currUser.ID { + 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 + } + c.JSON(http.StatusOK, util.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} util.ResponseAny{data=model.Upload} "更新成功" +// @Failure 403 {object} util.ResponseAny "无权操作" +// @Failure 404 {object} util.ResponseAny "文件不存在" +// @Router /api/v1/upload/{id} [put] +func UpdateMyFile(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)) + return + } + + uploadID, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusOK, util.Err(ErrInvalidFileID)) + return + } + + var req updateMyFileRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, util.Err(ErrInvalidParams)) + return + } + + var upload model.Upload + if err := db.DB(ctx).Where("id = ? AND status != ?", uploadID, model.UploadStatusDeleted).First(&upload).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.AbortWithStatus(http.StatusNotFound) + return + } + c.JSON(http.StatusOK, util.Err(ErrQueryUploadRecordFailed)) + return + } + + if upload.UserID != currUser.ID { + c.AbortWithStatus(http.StatusForbidden) + return + } + + updates := make(map[string]any) + if req.FileName != "" { + updates["file_name"] = req.FileName + } + if req.AccessMode != nil { + updates["access_mode"] = *req.AccessMode + } + + if len(updates) > 0 { + if err := db.DB(ctx).Model(&upload).Updates(updates).Error; err != nil { + c.JSON(http.StatusOK, util.Err("更新文件记录失败")) + return + } + } + + c.JSON(http.StatusOK, util.OK(upload)) +} diff --git a/internal/apps/upload/routers_test.go b/internal/apps/upload/routers_test.go index 550e2bd8..996b0138 100644 --- a/internal/apps/upload/routers_test.go +++ b/internal/apps/upload/routers_test.go @@ -48,6 +48,11 @@ func setupTestRouter(authUser *model.User) *gin.Engine { 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") @@ -855,3 +860,134 @@ func TestGetFileStats(t *testing.T) { t.Errorf("expected 1 document category, got %d", categoryMap["文档"]) } } + +func TestUserUploadManagement(t *testing.T) { + dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) + defer cleanup() + + user1 := &model.User{ID: 1001, Username: "user1"} + user2 := &model.User{ID: 1002, Username: "user2"} + + _ = dbConn.Create(user1) + _ = dbConn.Create(user2) + + router1 := setupTestRouter(user1) + router2 := setupTestRouter(user2) + + // Seed upload records + upload1 := model.Upload{ + ID: 4001, + UserID: 1001, + FileName: "user1-file.txt", + FilePath: "uploads/user1-file.txt", + FileSize: 100, + MimeType: "text/plain", + Extension: "txt", + StorageDriver: "local", + Status: model.UploadStatusUsed, + CreatedAt: time.Now(), + } + upload2 := model.Upload{ + ID: 4002, + UserID: 1002, + FileName: "user2-file.png", + FilePath: "uploads/user2-file.png", + FileSize: 200, + MimeType: "image/png", + Extension: "png", + StorageDriver: "local", + Status: model.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 model.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 model.Upload + dbConn.First(&deleted, 4001) + if deleted.Status != model.UploadStatusDeleted { + t.Errorf("expected status deleted, got %s", deleted.Status) + } + }) +} + diff --git a/internal/apps/upload/stats.go b/internal/apps/upload/stats.go index 12e14fe3..531fd979 100644 --- a/internal/apps/upload/stats.go +++ b/internal/apps/upload/stats.go @@ -14,6 +14,15 @@ import ( "github.com/gin-gonic/gin" ) +const ( + catImage = "图片" + catVideo = "视频" + catAudio = "音频" + catDocument = "文档" + catArchive = "压缩包" + catOther = "其他" +) + type trendItem struct { Date string `json:"date"` Count int64 `json:"count"` @@ -110,7 +119,7 @@ func GetFileStats(c *gin.Context) { catCount := make(map[string]int64) catSize := make(map[string]int64) - categoriesList := []string{"图片", "视频", "音频", "文档", "压缩包", "其他"} + categoriesList := []string{catImage, catVideo, catAudio, catDocument, catArchive, catOther} for _, cat := range categoriesList { catCount[cat] = 0 catSize[cat] = 0 @@ -190,21 +199,21 @@ func getFileCategory(mimeType, ext string) string { ext = strings.ToLower(ext) if strings.HasPrefix(mimeType, "image/") || isImageExtension(ext) { - return "图片" + return catImage } if strings.HasPrefix(mimeType, "video/") { - return "视频" + return catVideo } if strings.HasPrefix(mimeType, "audio/") { - return "音频" + return catAudio } if isArchiveExtension(ext) || strings.Contains(mimeType, "zip") || strings.Contains(mimeType, "tar") || strings.Contains(mimeType, "gzip") { - return "压缩包" + return catArchive } if isDocumentExtension(ext) || strings.HasPrefix(mimeType, "text/") || mimeType == "application/pdf" { - return "文档" + return catDocument } - return "其他" + return catOther } func isArchiveExtension(ext string) bool { diff --git a/internal/router/router.go b/internal/router/router.go index 07ec0ec6..7d8eb8af 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -210,11 +210,7 @@ func registerRoutes(r *gin.Engine) { } // Upload - uploadRouter := apiV1Router.Group("/upload") - uploadRouter.Use(oauth.LoginRequired()) - { - uploadRouter.POST("", upload.UploadFile) - } + registerUploadRoutes(apiV1Router) // Config (public) configRouter := apiV1Router.Group("/config") @@ -277,15 +273,7 @@ func registerRoutes(r *gin.Engine) { adminRouter.DELETE("/users/:id", admin_user.DeleteUser) // Uploads - 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) - } + registerAdminUploadRoutes(adminRouter) // System Config adminRouter.POST("/system-configs", system_config.CreateSystemConfig) @@ -325,3 +313,28 @@ func registerRoutes(r *gin.Engine) { // 注册前端静态路由(当启用 embed_frontend 编译标签时) registerFrontend(r) } + +func registerUploadRoutes(apiV1Router *gin.RouterGroup) { + uploadRouter := apiV1Router.Group("/upload") + uploadRouter.Use(oauth.LoginRequired()) + { + uploadRouter.POST("", upload.UploadFile) + uploadRouter.GET("/my", upload.ListMyFiles) + uploadRouter.DELETE("/:id", upload.DeleteMyFile) + uploadRouter.PUT("/:id", upload.UpdateMyFile) + uploadRouter.GET("/download/:id", upload.DownloadFile) + uploadRouter.POST("/download/batch", upload.BatchDownloadFiles) + } +} + +func registerAdminUploadRoutes(adminRouter *gin.RouterGroup) { + 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) + } +}