From 04280a7b11d90f14e1fdd36585ef4a11c03f906f Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 13 Jun 2026 12:32:42 +0800 Subject: [PATCH] feat(upload): refactor file management with statistics and multi-tab layout --- docs/docs.go | 108 ++- docs/swagger.json | 108 ++- docs/swagger.yaml | 67 +- frontend/components/common/admin/files.tsx | 909 +++++++++++++----- frontend/lib/services/upload/types.ts | 29 + .../lib/services/upload/upload.service.ts | 9 +- internal/apps/upload/routers_test.go | 112 +++ internal/apps/upload/stats.go | 228 +++++ internal/router/router.go | 17 +- 9 files changed, 1339 insertions(+), 248 deletions(-) create mode 100644 internal/apps/upload/stats.go diff --git a/docs/docs.go b/docs/docs.go index 81f39bf8..67be53ae 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -3379,7 +3379,7 @@ const docTemplate = `{ } }, "500": { - "description": "Redis 异常或构造 URL 失败", + "description": "Redis 异常 or 构造 URL 失败", "schema": { "$ref": "#/definitions/util.ResponseAny" } @@ -3830,6 +3830,55 @@ const docTemplate = `{ } } }, + "/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": [ @@ -5893,6 +5942,49 @@ const docTemplate = `{ } } }, + "upload.distributionItem": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "size": { + "type": "integer" + } + } + }, + "upload.fileStatsResponse": { + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { + "$ref": "#/definitions/upload.distributionItem" + } + }, + "total_count": { + "type": "integer" + }, + "total_size": { + "type": "integer" + }, + "trend": { + "type": "array", + "items": { + "$ref": "#/definitions/upload.trendItem" + } + }, + "types": { + "type": "array", + "items": { + "$ref": "#/definitions/upload.distributionItem" + } + } + } + }, "upload.listMyFilesResponse": { "type": "object", "properties": { @@ -5913,6 +6005,20 @@ const docTemplate = `{ } } }, + "upload.trendItem": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "date": { + "type": "string" + }, + "size": { + "type": "integer" + } + } + }, "user.changePasswordRequest": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 2470034a..cb214ff6 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -3372,7 +3372,7 @@ } }, "500": { - "description": "Redis 异常或构造 URL 失败", + "description": "Redis 异常 or 构造 URL 失败", "schema": { "$ref": "#/definitions/util.ResponseAny" } @@ -3823,6 +3823,55 @@ } } }, + "/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": [ @@ -5886,6 +5935,49 @@ } } }, + "upload.distributionItem": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "size": { + "type": "integer" + } + } + }, + "upload.fileStatsResponse": { + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { + "$ref": "#/definitions/upload.distributionItem" + } + }, + "total_count": { + "type": "integer" + }, + "total_size": { + "type": "integer" + }, + "trend": { + "type": "array", + "items": { + "$ref": "#/definitions/upload.trendItem" + } + }, + "types": { + "type": "array", + "items": { + "$ref": "#/definitions/upload.distributionItem" + } + } + } + }, "upload.listMyFilesResponse": { "type": "object", "properties": { @@ -5906,6 +5998,20 @@ } } }, + "upload.trendItem": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "date": { + "type": "string" + }, + "size": { + "type": "integer" + } + } + }, "user.changePasswordRequest": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 143e07ec..7f66c3f6 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -899,6 +899,34 @@ definitions: required: - ids type: object + upload.distributionItem: + properties: + count: + type: integer + name: + type: string + size: + type: integer + type: object + upload.fileStatsResponse: + properties: + categories: + items: + $ref: '#/definitions/upload.distributionItem' + type: array + total_count: + type: integer + total_size: + type: integer + trend: + items: + $ref: '#/definitions/upload.trendItem' + type: array + types: + items: + $ref: '#/definitions/upload.distributionItem' + type: array + type: object upload.listMyFilesResponse: properties: items: @@ -912,6 +940,15 @@ definitions: total: type: integer type: object + upload.trendItem: + properties: + count: + type: integer + date: + type: string + size: + type: integer + type: object user.changePasswordRequest: properties: new_password: @@ -3129,7 +3166,7 @@ paths: schema: $ref: '#/definitions/util.ResponseAny' "500": - description: Redis 异常或构造 URL 失败 + description: Redis 异常 or 构造 URL 失败 schema: $ref: '#/definitions/util.ResponseAny' summary: 获取登录授权地址 @@ -3394,6 +3431,34 @@ paths: 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/files.tsx b/frontend/components/common/admin/files.tsx index 932713e4..cda92b63 100644 --- a/frontend/components/common/admin/files.tsx +++ b/frontend/components/common/admin/files.tsx @@ -16,14 +16,32 @@ import { Trash2, Upload, X, + HardDrive, + Database, + TrendingUp, + Files, + Info, } from "lucide-react" import {toast} from "sonner" +import { + Area, + AreaChart, + CartesianGrid, + Cell, + Legend, + Pie, + PieChart, + ResponsiveContainer, + Tooltip as RechartsTooltip, + XAxis, + YAxis, +} from "recharts" import {Button} from "@/components/ui/button" import {Input} from "@/components/ui/input" import {Badge} from "@/components/ui/badge" import {Checkbox} from "@/components/ui/checkbox" -import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table" +import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table" import { AlertDialog, AlertDialogAction, @@ -34,7 +52,9 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog" -import {Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle,} from "@/components/ui/sheet" +import {Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle} from "@/components/ui/sheet" +import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card" +import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs" import {formatFileSize, getFileUrl, UploadService} from "@/lib/services/upload/upload.service" import type {Upload as UploadRecord} from "@/lib/services/upload/types" @@ -59,10 +79,20 @@ function formatDate(dateStr: string) { }) } +const PIE_COLORS = [ + "#3b82f6", // Blue (图片) + "#10b981", // Emerald (视频) + "#8b5cf6", // Purple (音频) + "#f59e0b", // Amber (文档) + "#ec4899", // Pink (压缩包) + "#64748b", // Slate (其他) +] + /* ─── 文件管理主组件 ────────────────────────────────────── */ export function FilesMain() { const queryClient = useQueryClient() + const [activeTab, setActiveTab] = React.useState("stats") const [keyword, setKeyword] = React.useState("") const [debouncedKeyword, setDebouncedKeyword] = React.useState("") const [selectedIds, setSelectedIds] = React.useState>(new Set()) @@ -70,6 +100,9 @@ export function FilesMain() { const [detailTarget, setDetailTarget] = React.useState(null) const [page, setPage] = React.useState(1) const pageSize = 15 // 表格视图下 15 条更紧凑 + + // 趋势图指标类型切换 (数量 vs 大小) + const [trendMetric, setTrendMetric] = React.useState<"count" | "size">("count") // 搜索防抖 React.useEffect(() => { @@ -84,6 +117,14 @@ export function FilesMain() { const listQuery = useQuery({ queryKey: ["files", "my", page, pageSize, debouncedKeyword], queryFn: () => UploadService.listMyFiles(page, pageSize, debouncedKeyword || undefined), + enabled: activeTab === "list", + }) + + // 统计信息查询 + const statsQuery = useQuery({ + queryKey: ["files", "stats"], + queryFn: () => UploadService.getFileStats(), + enabled: activeTab === "stats", }) const files = listQuery.data?.items ?? [] @@ -114,6 +155,7 @@ export function FilesMain() { mutationFn: (id: string) => UploadService.deleteFile(id), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["files", "my"] }) + void queryClient.invalidateQueries({ queryKey: ["files", "stats"] }) toast.success("文件已删除") setDeleteTarget(null) }, @@ -159,6 +201,67 @@ export function FilesMain() { a.click() } + // ─── 数据处理 ─── + const stats = statsQuery.data + const trendData = React.useMemo(() => { + if (!stats?.trend) return [] + return stats.trend.map((item) => ({ + ...item, + formattedDate: item.date.substring(5), // YYYY-MM-DD -> MM-DD + value: trendMetric === "count" ? item.count : item.size, + })) + }, [stats?.trend, trendMetric]) + + const categoryCountData = React.useMemo(() => { + if (!stats?.categories) return [] + return stats.categories + .filter((c) => c.count > 0) + .map((c) => ({ name: c.name, value: c.count })) + }, [stats?.categories]) + + const categorySizeData = React.useMemo(() => { + if (!stats?.categories) return [] + return stats.categories + .filter((c) => c.size > 0) + .map((c) => ({ name: c.name, value: c.size })) + }, [stats?.categories]) + + // 计算最大占用与最大数量文件类型 + const maxStats = React.useMemo(() => { + if (!stats?.categories || stats.categories.length === 0) { + return { maxCountName: "无", maxCount: 0, maxSizeName: "无", maxSize: 0 } + } + let maxCountName = "无" + let maxCount = 0 + let maxSizeName = "无" + let maxSize = 0 + + stats.categories.forEach((cat) => { + if (cat.count > maxCount) { + maxCount = cat.count + maxCountName = cat.name + } + if (cat.size > maxSize) { + maxSize = cat.size + maxSizeName = cat.name + } + }) + + return { maxCountName, maxCount, maxSizeName, maxSize } + }, [stats?.categories]) + + // 近 7 天趋势总新增统计 + const trendSummary = React.useMemo(() => { + if (!stats?.trend) return { count: 0, size: 0 } + return stats.trend.reduce( + (acc, curr) => ({ + count: acc.count + curr.count, + size: acc.size + curr.size, + }), + { count: 0, size: 0 } + ) + }, [stats?.trend]) + return ( - - {/* 头部 */} + {/* 顶部标题区 */}
-
-
-

- 文件管理 -

-

- 管理您上传的所有文件,支持下载与批量操作 -

-
-
- - {/* 操作按钮区 */} -
- - {selectedIds.size > 0 && ( - - - 已选 {selectedIds.size} 个 - - - - - )} - -
-
- - {/* 搜索栏 */} -
-
- - setKeyword(e.target.value)} - /> - {keyword && ( - - )} -
- {files.length > 0 && ( - - )} - {total > 0 && ( - 共 {total} 个文件 - )} -
- - {/* 文件列表 Table */} - {listQuery.isPending ? ( -
- -
- ) : files.length === 0 ? ( -
- -

- {debouncedKeyword ? "没有匹配的文件" : "您还没有上传任何文件"} +

+

+ 文件管理 +

+

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

- ) : ( -
- - - - - - - 预览 - ID - 文件名 - 路径 - 业务类别 - MIME类型 - 大小 - 上传时间 - 操作 - - - - {files.map((file) => { - const isSelected = selectedIds.has(file.id) - return ( - - - toggleSelect(file.id)} - /> - - -
- {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, "size-4.5") - )} -
-
- - {file.id} - - - {file.file_name} - - - {file.file_path} - - - - {file.type} - - - - {file.mime_type} - - - {formatFileSize(file.file_size)} - - - {formatDate(file.created_at)} - - -
- - - -
-
-
- ) - })} -
-
-
- )} +
- {/* 分页 */} - {totalPages > 1 && ( -
- - - {page} / {totalPages} - - -
- )} + + + 文件存储信息 + 文件列表 + + + {/* ──────── TAB 1: 统计看板 ──────── */} + + {statsQuery.isPending ? ( +
+ +
+ ) : !stats || stats.total_count === 0 ? ( +
+ +
+

暂无上传数据

+

上传文件后即可在此查看容量与文件分布分析。

+
+
+ ) : ( +
+ {/* 四个指标卡片 */} +
+ + + 总文件数 + + + +
{stats.total_count.toLocaleString()} 个
+

您上传的可用文件数量汇总

+
+
+ + + + 占用存储容量 + + + +
{formatFileSize(stats.total_size)}
+

文件实际在磁盘/S3中占用的总空间

+
+
+ + + + 近 7 天新增文件 + + + +
+{trendSummary.count.toLocaleString()} 个
+

最近 7 天上传成功的文件总数

+
+
+ + + + 近 7 天新增大小 + + + +
+{formatFileSize(trendSummary.size)}
+

最近 7 天上传所消耗的存储带宽

+
+
+
+ + {/* 7 天新增趋势折线图 */} + + +
+ 最近 7 天新增趋势 + 按日统计近一周期内的上传波动 +
+
+ + +
+
+ +
+ + + + + + + + + + + (trendMetric === "size" ? formatFileSize(v) : v.toString())} + /> + { + if (active && payload && payload.length) { + const data = payload[0] + return ( +
+

{data.payload.date}

+
+ + + {trendMetric === "count" ? "新增文件数: " : "新增大小: "} + + {trendMetric === "count" ? `${data.value} 个` : formatFileSize(data.value as number)} + + +
+
+ ) + } + return null + }} + /> + +
+
+
+
+
+ + {/* 两个饼图分布 */} +
+ {/* 饼图 1: 格式数量分布 */} + + + 文件格式数量分布 + 各种格式类型的文件个数占比 + + + {categoryCountData.length === 0 ? ( +
暂无分类数据
+ ) : ( +
+ + + + {categoryCountData.map((entry, index) => ( + + ))} + + { + if (active && payload && payload.length) { + const data = payload[0].payload + return ( +
+
+ + {data.name} +
+
+ {data.value} 个 +
+
+ ) + } + return null + }} + /> + { + if (!payload) return null + return ( +
+ {payload.map((entry: { value: string; color?: string }) => ( +
+ + {entry.value} +
+ ))} +
+ ) + }} + /> +
+
+
+ )} +
+ +

+ 文件类型中数量最多的是 「{maxStats.maxCountName}」,累计上传了 {maxStats.maxCount} 个文件。 +

+
+
+
+ + {/* 饼图 2: 格式容量分布 */} + + + 文件占用容量分布 + 各种格式类型的文件大小占比 + + + {categorySizeData.length === 0 ? ( +
暂无容量数据
+ ) : ( +
+ + + + {categorySizeData.map((entry, index) => ( + + ))} + + { + if (active && payload && payload.length) { + const data = payload[0].payload + return ( +
+
+ + {data.name} +
+
+ {formatFileSize(data.value)} +
+
+ ) + } + return null + }} + /> + { + if (!payload) return null + return ( +
+ {payload.map((entry: { value: string; color?: string }) => ( +
+ + {entry.value} +
+ ))} +
+ ) + }} + /> +
+
+
+ )} +
+ +

+ 文件类型中占用存储最大的是 「{maxStats.maxSizeName}」,共消耗了 {formatFileSize(maxStats.maxSize)} 存储容量。 +

+
+
+
+
+
+ )} +
+ + {/* ──────── TAB 2: 文件列表 ──────── */} + + {/* 搜索栏 */} +
+
+ + setKeyword(e.target.value)} + /> + {keyword && ( + + )} +
+ {files.length > 0 && ( + + )} + + {/* 打包下载等批量操作 */} +
+ + {selectedIds.size > 0 && ( + + + 已选 {selectedIds.size} 个 + + + + + )} + +
+ + {total > 0 && ( + 共 {total} 个文件 + )} +
+ + {/* 文件列表 Table */} + {listQuery.isPending ? ( +
+ +
+ ) : files.length === 0 ? ( +
+ +

+ {debouncedKeyword ? "没有匹配的文件" : "您还没有上传任何文件"} +

+
+ ) : ( +
+ + + + + + + 预览 + ID + 文件名 + 路径 + 业务类别 + MIME类型 + 大小 + 上传时间 + 操作 + + + + {files.map((file) => { + const isSelected = selectedIds.has(file.id) + return ( + + + toggleSelect(file.id)} + /> + + +
+ {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, "size-4.5") + )} +
+
+ + {file.id} + + + {file.file_name} + + + {file.file_path} + + + + {file.type} + + + + {file.mime_type} + + + {formatFileSize(file.file_size)} + + + {formatDate(file.created_at)} + + +
+ + + +
+
+
+ ) + })} +
+
+
+ )} + + {/* 分页 */} + {totalPages > 1 && ( +
+ + + {page} / {totalPages} + + +
+ )} +
+
{/* 详情查看 Sheet */} !open && setDetailTarget(null)}> diff --git a/frontend/lib/services/upload/types.ts b/frontend/lib/services/upload/types.ts index 3adf8975..8b9cc839 100644 --- a/frontend/lib/services/upload/types.ts +++ b/frontend/lib/services/upload/types.ts @@ -59,4 +59,33 @@ export interface ListUploadsResponse { page: number page_size: number items: Upload[] +} + +/** + * 近 7 天新增文件趋势项 + */ +export interface TrendItem { + date: string + count: number + size: number +} + +/** + * 文件分类/类型统计项 + */ +export interface DistributionItem { + name: string + count: number + size: number +} + +/** + * 文件统计信息响应 + */ +export interface FileStatsResponse { + total_count: number + total_size: number + trend: TrendItem[] + categories: DistributionItem[] + types: DistributionItem[] } \ No newline at end of file diff --git a/frontend/lib/services/upload/upload.service.ts b/frontend/lib/services/upload/upload.service.ts index 395f270a..0ef7da5d 100644 --- a/frontend/lib/services/upload/upload.service.ts +++ b/frontend/lib/services/upload/upload.service.ts @@ -1,5 +1,5 @@ import {BaseService} from '../core/base.service' -import type {ListUploadsResponse, Upload, UploadImageResponse} from './types' +import type {ListUploadsResponse, Upload, UploadImageResponse, FileStatsResponse} from './types' import type {InternalAxiosRequestConfig} from 'axios' export type ImageQuality = 'low' | 'medium' | 'high' | 'origin' @@ -86,6 +86,13 @@ export class UploadService extends BaseService { return this.get('/my', params) } + /** + * 获取当前用户文件统计信息 + */ + static async getFileStats(): Promise { + return this.get('/stats') + } + /** * 删除文件 */ diff --git a/internal/apps/upload/routers_test.go b/internal/apps/upload/routers_test.go index 23909a6f..4a9b3a02 100644 --- a/internal/apps/upload/routers_test.go +++ b/internal/apps/upload/routers_test.go @@ -17,6 +17,7 @@ import ( "strconv" "strings" "testing" + "time" "github.com/Rain-kl/Wavelet/internal/apps/oauth" "github.com/Rain-kl/Wavelet/internal/db" @@ -47,6 +48,7 @@ func setupTestRouter(authUser *model.User) *gin.Engine { uploadGroup.POST("", UploadFile) uploadGroup.GET("/my", ListMyFiles) + uploadGroup.GET("/stats", GetFileStats) uploadGroup.GET("/download/:id", DownloadFile) uploadGroup.POST("/download/batch", BatchDownloadFiles) return r @@ -710,3 +712,113 @@ func TestUploadAccessModeAccessControl(t *testing.T) { } } +func TestGetFileStats(t *testing.T) { + dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) + defer cleanup() + + authUser := &model.User{ID: 1001, Username: "test_user"} + router := setupTestRouter(authUser) + + // Insert some dummy uploads + uploads := []model.Upload{ + { + ID: 3101, + UserID: authUser.ID, + FileName: "photo.png", + FilePath: "uploads/photo.png", + FileSize: 100, + MimeType: "image/png", + Extension: "png", + StorageDriver: "local", + Type: "generic", + Status: model.UploadStatusUsed, + CreatedAt: time.Now(), + }, + { + ID: 3102, + UserID: authUser.ID, + FileName: "video.mp4", + FilePath: "uploads/video.mp4", + FileSize: 500, + MimeType: "video/mp4", + Extension: "mp4", + StorageDriver: "local", + Type: "generic", + Status: model.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", + StorageDriver: "local", + Type: "avatar", // different type + Status: model.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) + } + } + + req, _ := http.NewRequest("GET", "/api/v1/upload/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["文档"]) + } +} + diff --git a/internal/apps/upload/stats.go b/internal/apps/upload/stats.go new file mode 100644 index 00000000..7c51ae97 --- /dev/null +++ b/internal/apps/upload/stats.go @@ -0,0 +1,228 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package upload + +import ( + "net/http" + "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" + "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 upload +// @Produce json +// @Security SessionCookie +// @Success 200 {object} util.ResponseAny{data=fileStatsResponse} "获取成功" +// @Failure 401 {object} util.ResponseAny "未登录" +// @Failure 500 {object} util.ResponseAny "内部错误" +// @Router /api/v1/upload/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 { + TotalCount int64 `json:"total_count"` + TotalSize int64 `json:"total_size"` + } + 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). + Scan(&summary).Error + if err != nil { + c.JSON(http.StatusOK, util.Err(err.Error())) + return + } + + // 2. 获取业务类型分布 (Group By type) + type rawDist struct { + Key string `gorm:"column:key"` + Count int64 `gorm:"column:count"` + Size int64 `gorm:"column:size"` + } + 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). + Group("type"). + Scan(&typeRaw).Error + if err != nil { + c.JSON(http.StatusOK, util.Err(err.Error())) + return + } + + types := make([]distributionItem, 0, len(typeRaw)) + for _, tr := range typeRaw { + name := tr.Key + if name == "" { + name = "generic" + } + types = append(types, distributionItem{ + Name: name, + Count: tr.Count, + Size: tr.Size, + }) + } + + // 3. 获取所有文件的大小、后缀与MIME,用于在 Go 中内存分类统计 (避免数据库中写复杂的 JSON/String 匹配逻辑) + type fileCategoryRaw struct { + Extension string `gorm:"column:extension"` + MimeType string `gorm:"column:mime_type"` + FileSize int64 `gorm:"column:file_size"` + } + var fileRaws []fileCategoryRaw + err = db.DB(ctx).Model(&model.Upload{}). + Select("extension, mime_type, file_size"). + Where("user_id = ? AND status != ?", userID, model.UploadStatusDeleted). + Scan(&fileRaws).Error + if err != nil { + c.JSON(http.StatusOK, util.Err(err.Error())) + return + } + + catCount := make(map[string]int64) + catSize := make(map[string]int64) + categoriesList := []string{"图片", "视频", "音频", "文档", "压缩包", "其他"} + for _, cat := range categoriesList { + catCount[cat] = 0 + catSize[cat] = 0 + } + + for _, fr := range fileRaws { + cat := getFileCategory(fr.MimeType, fr.Extension) + catCount[cat]++ + catSize[cat] += fr.FileSize + } + + categories := make([]distributionItem, 0, len(categoriesList)) + for _, cat := range categoriesList { + categories = append(categories, distributionItem{ + Name: cat, + Count: catCount[cat], + Size: catSize[cat], + }) + } + + // 4. 获取近 7 天的新增文件趋势 (在 Go 中补全没有新增记录的日期为 0) + type fileTrendRaw struct { + CreatedAt time.Time `gorm:"column:created_at"` + FileSize int64 `gorm:"column:file_size"` + } + var trendRaws []fileTrendRaw + // 7天前 00:00:00 (即 6 天前 00:00:00 至今天) + now := time.Now() + 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). + Scan(&trendRaws).Error + if err != nil { + c.JSON(http.StatusOK, util.Err(err.Error())) + return + } + + trendCountMap := make(map[string]int64) + trendSizeMap := make(map[string]int64) + for i := 0; i < 7; i++ { + dStr := now.AddDate(0, 0, -i).Format("2006-01-02") + trendCountMap[dStr] = 0 + trendSizeMap[dStr] = 0 + } + + for _, tr := range trendRaws { + dStr := tr.CreatedAt.Format("2006-01-02") + if _, exists := trendCountMap[dStr]; exists { + trendCountMap[dStr]++ + trendSizeMap[dStr] += tr.FileSize + } + } + + const trendDays = 7 + trend := make([]trendItem, 0, trendDays) + for i := trendDays - 1; i >= 0; i-- { + dStr := now.AddDate(0, 0, -i).Format("2006-01-02") + trend = append(trend, trendItem{ + Date: dStr, + Count: trendCountMap[dStr], + Size: trendSizeMap[dStr], + }) + } + + c.JSON(http.StatusOK, util.OK(fileStatsResponse{ + TotalCount: summary.TotalCount, + TotalSize: summary.TotalSize, + Trend: trend, + Categories: categories, + Types: types, + })) +} + +func getFileCategory(mimeType, ext string) string { + mimeType = strings.ToLower(mimeType) + ext = strings.ToLower(ext) + + if strings.HasPrefix(mimeType, "image/") || isImageExtension(ext) { + return "图片" + } + if strings.HasPrefix(mimeType, "video/") { + return "视频" + } + if strings.HasPrefix(mimeType, "audio/") { + return "音频" + } + if isArchiveExtension(ext) || strings.Contains(mimeType, "zip") || strings.Contains(mimeType, "tar") || strings.Contains(mimeType, "gzip") { + return "压缩包" + } + if isDocumentExtension(ext) || strings.HasPrefix(mimeType, "text/") || mimeType == "application/pdf" { + return "文档" + } + return "其他" +} + +func isArchiveExtension(ext string) bool { + for _, e := range []string{"zip", "rar", "7z", "tar", "gz", "tgz", "bz2", "xz"} { + if ext == e { + return true + } + } + return false +} + +func isDocumentExtension(ext string) bool { + for _, e := range []string{"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "md", "csv", "json", "yaml", "yml", "xml"} { + if ext == e { + return true + } + } + return false +} diff --git a/internal/router/router.go b/internal/router/router.go index 5d8fcfc9..1d2c416b 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -179,8 +179,20 @@ func registerRoutes(r *gin.Engine) { } return enabled }), user.Login) - userRouter.POST("/register", user.Register) - userRouter.POST("/send-email-code", user.SendEmailCode) + userRouter.POST("/register", capApp.VerifyMiddleware(capUtil.GetDefaultManager(), "register", func() bool { + enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyCapLoginEnabled) + if err != nil { + return false + } + return enabled + }), user.Register) + userRouter.POST("/send-email-code", capApp.VerifyMiddleware(capUtil.GetDefaultManager(), "send_email_code", func() bool { + enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyCapLoginEnabled) + if err != nil { + return false + } + return enabled + }), user.SendEmailCode) userRouter.GET("/logout", user.Logout) userRouter.GET("/self", oauth.LoginRequired(), oauth.UserInfo) userRouter.POST("/change-password", oauth.LoginRequired(), user.ChangePassword) @@ -203,6 +215,7 @@ func registerRoutes(r *gin.Engine) { { 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)