From b51ee98afaaeeb969daa90415ff982ddfc279cf6 Mon Sep 17 00:00:00 2001 From: RuicyWu <1063154311@qq.com> Date: Fri, 3 Jul 2026 16:01:08 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/STARTUP.md | 310 +++++++++ server/controller/backup_controller.go | 183 +++++ server/controller/chunk_upload_controller.go | 121 ++++ server/controller/file_access_controller.go | 277 ++++++++ server/controller/log_controller.go | 76 +++ server/controller/monitor_controller.go | 126 ++++ server/controller/openapi_controller.go | 192 ++++++ .../controller/storage_assign_controller.go | 151 +++++ server/controller/storage_controller.go | 49 ++ server/deploy.bat | 135 ++++ server/deploy.sh | 102 +++ server/docs/nginx-production.conf | 156 +++++ server/docs/nginx.conf | 123 ++++ server/middleware/ip_blacklist.go | 124 ++++ server/middleware/logger.go | 167 +++++ server/middleware/traffic.go | 167 +++++ server/model/app.go | 31 + server/model/backup.go | 46 ++ server/model/operation_log.go | 22 + server/model/storage_assignment.go | 29 + server/model/upload_session.go | 28 + server/service/backup_service.go | 634 ++++++++++++++++++ server/service/chunk_upload_service.go | 306 +++++++++ server/service/file_access_service.go | 188 ++++++ server/service/log_service.go | 108 +++ server/service/openapi_service.go | 325 +++++++++ server/service/storage_assign_service.go | 296 ++++++++ server/service/storage_test_service.go | 168 +++++ server/storage/minio.go | 175 +++++ web/src/assets/styles/global.css | 27 + web/src/views/admin/AdminLayout.vue | 128 ++++ web/src/views/admin/BackupManage.vue | 324 +++++++++ web/src/views/admin/Dashboard.vue | 94 +++ web/src/views/admin/IPBlacklist.vue | 109 +++ web/src/views/admin/LogMonitor.vue | 180 +++++ web/src/views/admin/OpenPlatform.vue | 316 +++++++++ web/src/views/admin/StorageAssignManage.vue | 289 ++++++++ 37 files changed, 6282 insertions(+) create mode 100644 docs/STARTUP.md create mode 100644 server/controller/backup_controller.go create mode 100644 server/controller/chunk_upload_controller.go create mode 100644 server/controller/file_access_controller.go create mode 100644 server/controller/log_controller.go create mode 100644 server/controller/monitor_controller.go create mode 100644 server/controller/openapi_controller.go create mode 100644 server/controller/storage_assign_controller.go create mode 100644 server/controller/storage_controller.go create mode 100644 server/deploy.bat create mode 100644 server/deploy.sh create mode 100644 server/docs/nginx-production.conf create mode 100644 server/docs/nginx.conf create mode 100644 server/middleware/ip_blacklist.go create mode 100644 server/middleware/logger.go create mode 100644 server/middleware/traffic.go create mode 100644 server/model/app.go create mode 100644 server/model/backup.go create mode 100644 server/model/operation_log.go create mode 100644 server/model/storage_assignment.go create mode 100644 server/model/upload_session.go create mode 100644 server/service/backup_service.go create mode 100644 server/service/chunk_upload_service.go create mode 100644 server/service/file_access_service.go create mode 100644 server/service/log_service.go create mode 100644 server/service/openapi_service.go create mode 100644 server/service/storage_assign_service.go create mode 100644 server/service/storage_test_service.go create mode 100644 server/storage/minio.go create mode 100644 web/src/assets/styles/global.css create mode 100644 web/src/views/admin/AdminLayout.vue create mode 100644 web/src/views/admin/BackupManage.vue create mode 100644 web/src/views/admin/Dashboard.vue create mode 100644 web/src/views/admin/IPBlacklist.vue create mode 100644 web/src/views/admin/LogMonitor.vue create mode 100644 web/src/views/admin/OpenPlatform.vue create mode 100644 web/src/views/admin/StorageAssignManage.vue diff --git a/docs/STARTUP.md b/docs/STARTUP.md new file mode 100644 index 0000000..b01555a --- /dev/null +++ b/docs/STARTUP.md @@ -0,0 +1,310 @@ +# 文件管理系统 - 部署启动指南 + +## 一、环境要求 + +| 组件 | 版本要求 | 说明 | +|------|----------|------| +| Go | 1.21+ | 后端运行环境 | +| Node.js | 18+ | 前端构建环境 | +| MySQL | 8.0+ | 数据库 | +| MinIO | 最新 | 对象存储(可选) | +| Nginx | 最新 | 反向代理(生产环境) | + +--- + +## 二、数据库初始化 + +### 1. 创建数据库 + +```bash +mysql -u root -p +``` + +```sql +CREATE DATABASE seeyon_fs DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +``` + +### 2. 导入表结构 + +```bash +mysql -u root -p seeyon_fs < server/sql/init.sql +``` + +> 表结构会在首次启动时由 GORM 自动迁移, 手动导入可确保初始数据(管理员账号、默认角色)正确创建。 + +--- + +## 三、后端启动 + +### 1. 进入后端目录 + +```bash +cd server +``` + +### 2. 修改配置文件 + +编辑 `config.yaml`: + +```yaml +server: + port: 8080 + mode: debug + public_url: "http://localhost:5173" # 前端访问地址 + +database: + host: 127.0.0.1 + port: 3306 + username: root + password: "123456" # 改为你的MySQL密码 + dbname: seeyon_fs + +jwt: + secret: "your-secret-key" # 生产环境务必修改 + expiration: 24 + +storage: + default_policy_id: 1 + local_base_path: D:/Seeyon/A8/localfile # 文件存储根目录 + cdn_host: "http://localhost:3090" # Nginx地址 + cdn_path_prefix: "/public" +``` + +### 3. 安装依赖 + +```bash +# 设置Go代理(国内网络) +export GOPROXY=https://goproxy.cn,direct + +# 下载依赖 +go mod tidy +``` + +### 4. 编译 + +```bash +go build -o server.exe main.go +``` + +### 5. 启动 + +```bash +# 前台运行 +./server.exe + +# 后台运行(Linux/Mac) +nohup ./server.exe > server.log 2>&1 & + +# 后台运行(Windows PowerShell) +Start-Process -FilePath ".\server.exe" -RedirectStandardOutput "server.log" -NoNewWindow +``` + +### 6. 验证 + +```bash +# 健康检查 +curl http://localhost:8080/health +# 返回: {"status":"ok"} + +# 测试登录 +curl -X POST http://localhost:8080/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"admin123"}' +``` + +--- + +## 四、前端启动 + +### 1. 进入前端目录 + +```bash +cd web +``` + +### 2. 安装依赖 + +```bash +npm install +``` + +### 3. 开发模式启动 + +```bash +npm run dev +``` + +前端运行在 `http://localhost:5173`, API 请求自动代理到后端 `http://localhost:8080`。 + +### 4. 生产构建 + +```bash +npm run build +``` + +构建产物在 `web/dist/` 目录, 由 Nginx 提供静态文件服务。 + +--- + +## 五、Nginx 配置(生产环境) + +### 1. 安装 Nginx + +Windows 下载: http://nginx.org/en/download.html + +### 2. 配置 + +将 `server/docs/nginx-production.conf` 复制到 Nginx 配置目录, 修改以下内容: + +```nginx +server { + listen 80; + server_name 你的域名; # 改为实际域名或IP + + # 前端静态资源 + location / { + root D:/Seeyon/A8/frontend/dist; # 前端构建产物路径 + try_files $uri $uri/ /index.html; + } + + # API代理 + location ^~ /api/ { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # MinIO代理(预签名模式) + location ^~ /minio/ { + proxy_pass http://127.0.0.1:9000/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 300s; + proxy_buffering off; + } +} +``` + +### 3. 启动 Nginx + +```bash +# Windows +nginx.exe + +# Linux +sudo systemctl start nginx +``` + +--- + +## 六、MinIO 配置(可选) + +### 1. 启动 MinIO + +```bash +# Windows +minio.exe server D:\minio\data --console-address :9001 --address :9000 + +# Linux +minio server /data --console-address :9001 --address :9000 +``` + +### 2. 登录 MinIO 控制台 + +访问 `http://localhost:9001`, 默认账号: `minioadmin / minioadmin` + +### 3. 创建 Bucket + +创建名为 `seeyon-fs` 的 Bucket。 + +### 4. 在系统中配置 MinIO 存储策略 + +登录文件管理系统后台 → 存储策略 → 新建: + +```json +{ + "name": "MinIO存储", + "type": "minio", + "config": { + "endpoint": "localhost:9000", + "access_key": "你的AccessKey", + "secret_key": "你的SecretKey", + "bucket": "seeyon-fs", + "use_ssl": false, + "access_mode": "presigned", + "nginx_endpoint": "http://你的域名/minio" + } +} +``` + +--- + +## 七、默认账号 + +| 账号 | 密码 | 角色 | +|------|------|------| +| admin | admin123 | 管理员 | + +> 首次登录后请立即修改密码。 + +--- + +## 八、目录结构 + +``` +SeeyonFileSystem/ +├── server/ # Go后端 +│ ├── main.go # 入口 +│ ├── config.yaml # 配置文件 +│ ├── config/ # 配置加载 +│ ├── api/ # 路由定义 +│ ├── controller/ # 控制器 +│ ├── service/ # 业务逻辑 +│ ├── repository/ # 数据访问 +│ ├── model/ # 数据模型 +│ ├── middleware/ # 中间件 +│ ├── storage/ # 存储引擎 +│ ├── dto/ # 数据传输对象 +│ ├── utils/ # 工具函数 +│ ├── sql/ # SQL脚本 +│ └── docs/ # 文档 +│ +├── web/ # Vue3前端 +│ ├── src/ +│ │ ├── views/ # 页面 +│ │ ├── components/ # 组件 +│ │ ├── api/ # API封装 +│ │ ├── stores/ # 状态管理 +│ │ └── router/ # 路由 +│ └── dist/ # 构建产物 +│ +└── data/storage/ # 本地文件存储 +``` + +--- + +## 九、常见问题 + +### Q: 启动报错 "连接数据库失败" + +检查 `config.yaml` 中的数据库配置是否正确, 确认 MySQL 服务已启动。 + +### Q: 前端页面空白 + +检查 Nginx 配置中 `root` 路径是否指向正确的 `dist` 目录, 确保 `try_files` 配置正确。 + +### Q: 文件上传失败 + +检查 `storage.local_base_path` 目录是否存在且有写入权限。 + +### Q: MinIO 连接失败 + +检查 MinIO 服务是否启动, `endpoint` 配置是否正确, `access_key` 和 `secret_key` 是否匹配。 + +### Q: 分享链接无法访问 + +检查 `server.public_url` 配置是否为前端的实际访问地址。 + +### Q: 中文文件名乱码 + +确保 MySQL 使用 `utf8mb4` 字符集, 客户端发送请求时使用 UTF-8 编码。 diff --git a/server/controller/backup_controller.go b/server/controller/backup_controller.go new file mode 100644 index 0000000..73d8f3b --- /dev/null +++ b/server/controller/backup_controller.go @@ -0,0 +1,183 @@ +package controller + +import ( + "strconv" + + "seeyon-filesystem/dto" + "seeyon-filesystem/service" + "seeyon-filesystem/utils" + + "github.com/gin-gonic/gin" +) + +// BackupController 备份控制器 +type BackupController struct { + backupService *service.BackupService +} + +func NewBackupController() *BackupController { + return &BackupController{ + backupService: service.NewBackupService(), + } +} + +// ========== 策略管理 ========== + +// ListPolicies 备份策略列表 +// GET /api/admin/backup/policies +func (c *BackupController) ListPolicies(ctx *gin.Context) { + policies, err := c.backupService.ListPolicies() + if err != nil { + utils.ServerError(ctx, "查询失败") + return + } + utils.Success(ctx, policies) +} + +// CreatePolicy 创建备份策略 +// POST /api/admin/backup/policies +func (c *BackupController) CreatePolicy(ctx *gin.Context) { + var req dto.BackupPolicyCreateRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: "+err.Error()) + return + } + + if err := c.backupService.CreatePolicy(&req); err != nil { + utils.ServerError(ctx, err.Error()) + return + } + utils.SuccessWithMessage(ctx, "创建成功", nil) +} + +// DeletePolicy 删除备份策略 +// DELETE /api/admin/backup/policies/:id +func (c *BackupController) DeletePolicy(ctx *gin.Context) { + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的ID") + return + } + if err := c.backupService.DeletePolicy(id); err != nil { + utils.ServerError(ctx, err.Error()) + return + } + utils.SuccessWithMessage(ctx, "删除成功", nil) +} + +// TogglePolicy 启用/禁用备份策略 +// PUT /api/admin/backup/policies/:id/toggle +func (c *BackupController) TogglePolicy(ctx *gin.Context) { + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的ID") + return + } + var req struct { + Status int8 `json:"status"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误") + return + } + if err := c.backupService.TogglePolicy(id, req.Status); err != nil { + utils.ServerError(ctx, err.Error()) + return + } + utils.SuccessWithMessage(ctx, "操作成功", nil) +} + +// ========== 执行备份 ========== + +// ExecuteBackup 手动执行备份 +// POST /api/admin/backup/execute/:id +func (c *BackupController) ExecuteBackup(ctx *gin.Context) { + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的ID") + return + } + + log, err := c.backupService.ExecuteBackup(id) + if err != nil { + utils.BadRequest(ctx, err.Error()) + return + } + + utils.Success(ctx, gin.H{ + "log_id": log.ID, + "message": "备份任务已启动", + }) +} + +// ========== 备份日志 ========== + +// ListLogs 备份日志列表 +// GET /api/admin/backup/logs?policy_id=1&page=1&size=20 +func (c *BackupController) ListLogs(ctx *gin.Context) { + page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(ctx.DefaultQuery("size", "20")) + policyID, _ := strconv.ParseUint(ctx.Query("policy_id"), 10, 64) + if page < 1 { + page = 1 + } + if size < 1 || size > 100 { + size = 20 + } + + logs, total, err := c.backupService.ListLogs(policyID, page, size) + if err != nil { + utils.ServerError(ctx, "查询失败") + return + } + utils.PageSuccess(ctx, logs, total, page, size) +} + +// ========== 恢复 ========== + +// ListBackups 列出可恢复的备份 +// GET /api/admin/backup/backups +func (c *BackupController) ListBackups(ctx *gin.Context) { + backups, err := c.backupService.ListBackups() + if err != nil { + utils.ServerError(ctx, "查询失败") + return + } + utils.Success(ctx, backups) +} + +// Restore 从备份恢复 +// POST /api/admin/backup/restore/:logId +func (c *BackupController) Restore(ctx *gin.Context) { + logID, err := strconv.ParseUint(ctx.Param("logId"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的备份ID") + return + } + + var req struct { + RestoreDB bool `json:"restore_db"` + RestoreFiles bool `json:"restore_files"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误") + return + } + + if !req.RestoreDB && !req.RestoreFiles { + utils.BadRequest(ctx, "请选择要恢复的内容(数据库或文件)") + return + } + + result, err := c.backupService.RestoreFromBackup(logID, req.RestoreDB, req.RestoreFiles) + if err != nil { + utils.BadRequest(ctx, err.Error()) + return + } + + if result.Success { + utils.Success(ctx, result) + } else { + utils.ServerError(ctx, result.Message) + } +} diff --git a/server/controller/chunk_upload_controller.go b/server/controller/chunk_upload_controller.go new file mode 100644 index 0000000..d1d96df --- /dev/null +++ b/server/controller/chunk_upload_controller.go @@ -0,0 +1,121 @@ +package controller + +import ( + "fmt" + + "seeyon-filesystem/dto" + "seeyon-filesystem/middleware" + "seeyon-filesystem/service" + "seeyon-filesystem/utils" + + "github.com/gin-gonic/gin" +) + +// ChunkUploadController 分片上传控制器 +type ChunkUploadController struct { + chunkService *service.ChunkUploadService +} + +func NewChunkUploadController() *ChunkUploadController { + return &ChunkUploadController{ + chunkService: service.NewChunkUploadService(), + } +} + +// Init 初始化分片上传 +// POST /api/file/upload/init +func (c *ChunkUploadController) Init(ctx *gin.Context) { + ownerID := middleware.GetCurrentUserID(ctx) + + var req dto.ChunkUploadInitRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: "+err.Error()) + return + } + + result, err := c.chunkService.InitUpload(req.FileName, req.FileSize, req.MD5, req.FolderID, ownerID, req.PolicyID) + if err != nil { + utils.ServerError(ctx, err.Error()) + return + } + + utils.Success(ctx, result) +} + +// UploadChunk 上传单个分片 +// PUT /api/file/upload/chunk/:uploadId/:index +func (c *ChunkUploadController) UploadChunk(ctx *gin.Context) { + uploadID := ctx.Param("uploadId") + indexStr := ctx.Param("index") + + var index int + if _, err := fmt.Sscanf(indexStr, "%d", &index); err != nil { + utils.BadRequest(ctx, "分片索引无效") + return + } + + checksum := ctx.GetHeader("X-Checksum") + + file, err := ctx.FormFile("chunk") + if err != nil { + // 尝试从body读取 + utils.BadRequest(ctx, "请上传分片文件") + return + } + + reader, err := file.Open() + if err != nil { + utils.BadRequest(ctx, "读取分片失败") + return + } + defer reader.Close() + + result, err := c.chunkService.UploadChunk(uploadID, index, reader, checksum) + if err != nil { + utils.BadRequest(ctx, err.Error()) + return + } + + utils.Success(ctx, result) +} + +// Merge 合并分片完成上传 +// POST /api/file/upload/merge/:uploadId +func (c *ChunkUploadController) Merge(ctx *gin.Context) { + uploadID := ctx.Param("uploadId") + + result, err := c.chunkService.MergeChunks(uploadID) + if err != nil { + utils.ServerError(ctx, err.Error()) + return + } + + utils.Success(ctx, result) +} + +// Progress 查询上传进度 +// GET /api/file/upload/progress/:uploadId +func (c *ChunkUploadController) Progress(ctx *gin.Context) { + uploadID := ctx.Param("uploadId") + + result, err := c.chunkService.GetUploadProgress(uploadID) + if err != nil { + utils.NotFound(ctx, err.Error()) + return + } + + utils.Success(ctx, result) +} + +// Cancel 取消上传 +// DELETE /api/file/upload/:uploadId +func (c *ChunkUploadController) Cancel(ctx *gin.Context) { + uploadID := ctx.Param("uploadId") + + if err := c.chunkService.CancelUpload(uploadID); err != nil { + utils.BadRequest(ctx, err.Error()) + return + } + + utils.SuccessWithMessage(ctx, "已取消", nil) +} diff --git a/server/controller/file_access_controller.go b/server/controller/file_access_controller.go new file mode 100644 index 0000000..4c91fc2 --- /dev/null +++ b/server/controller/file_access_controller.go @@ -0,0 +1,277 @@ +package controller + +import ( + "fmt" + "io" + "net/http" + "strconv" + "time" + + "seeyon-filesystem/config" + "seeyon-filesystem/middleware" + "seeyon-filesystem/model" + "seeyon-filesystem/storage" + "seeyon-filesystem/utils" + + "github.com/gin-gonic/gin" +) + +// FileAccessController 文件访问控制器 +// 统一处理文件下载/预览, 支持中转和预签名两种模式 +type FileAccessController struct{} + +func NewFileAccessController() *FileAccessController { + return &FileAccessController{} +} + +// Access 统一文件访问入口 +// GET /api/file/:id/access +// 根据存储策略自动选择: 中转(proxy) / 预签名(presigned) / CDN +func (c *FileAccessController) Access(ctx *gin.Context) { + ownerID := middleware.GetCurrentUserID(ctx) + fileID, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的文件ID") + return + } + + file, err := getFileByID(fileID) + if err != nil { + utils.NotFound(ctx, "文件不存在") + return + } + if file.OwnerID != ownerID { + utils.Forbidden(ctx, "无权访问") + return + } + + c.serveFile(ctx, file) +} + +// AccessByShare 通过分享访问文件(不检查归属) +// GET /api/s/:code/access +func (c *FileAccessController) AccessByShare(ctx *gin.Context) { + code := ctx.Param("code") + + // 验证分享 + share, err := getShareByCode(code) + if err != nil { + utils.NotFound(ctx, "分享不存在或已失效") + return + } + + // 检查密码 + if share.Password != "" { + password := ctx.Query("password") + if !verifySharePassword(share, password) { + utils.Unauthorized(ctx, "需要密码") + return + } + } + + // 检查过期 + if share.ExpireAt != nil && share.ExpireAt.Before(time.Now()) { + utils.NotFound(ctx, "分享已过期") + return + } + + file, err := getFileByID(share.FileID) + if err != nil { + utils.NotFound(ctx, "文件不存在") + return + } + + c.serveFile(ctx, file) +} + +// serveFile 根据存储策略配置的访问模式提供文件 +func (c *FileAccessController) serveFile(ctx *gin.Context, file *model.File) { + // 获取存储策略 + policy := getStoragePolicy(file.StoragePolicyID) + + // 获取存储引擎 + engine, err := storage.GetEngineByPolicyID(file.StoragePolicyID) + if err != nil { + utils.ServerError(ctx, "存储引擎不可用") + return + } + + accessMode := policy.Config.AccessMode + + switch accessMode { + case "presigned": + // 预签名模式: 返回URL, 客户端直连MinIO + c.servePresigned(ctx, file, engine, policy) + case "cdn": + // CDN模式: 返回CDN链接 + c.serveCDN(ctx, file, engine) + default: + // 中转模式(默认): 服务端拉取文件流转发给客户端 + c.serveProxy(ctx, file, engine, policy) + } +} + +// serveProxy 中转模式: 服务端从存储拉取文件, 转发给客户端 +func (c *FileAccessController) serveProxy(ctx *gin.Context, file *model.File, engine storage.Engine, policy *model.StoragePolicy) { + reader, err := engine.Download(file.StorageKey) + if err != nil { + utils.ServerError(ctx, "读取文件失败: "+err.Error()) + return + } + defer reader.Close() + + // 设置响应头 + ctx.Header("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", file.Name)) + ctx.Header("Content-Type", file.MimeType) + ctx.Header("Content-Length", strconv.FormatInt(file.Size, 10)) + ctx.Header("Cache-Control", "private, max-age=3600") + + // X-Sendfile模式: 返回路径让Nginx直接发送文件 + if policy.Config.UseXSendfile && policy.Config.XSendfilePath != "" { + // Nginx会拦截这个头, 直接从本地路径发送文件 + localPath := fmt.Sprintf("%s/%s", policy.Config.XSendfilePath, file.StorageKey) + ctx.Header("X-Accel-Redirect", localPath) + ctx.Status(200) + return + } + + // 普通中转: 流式传输 + ctx.Status(200) + io.Copy(ctx.Writer, reader) +} + +// servePresigned 预签名模式: 返回MinIO预签名URL +func (c *FileAccessController) servePresigned(ctx *gin.Context, file *model.File, engine storage.Engine, policy *model.StoragePolicy) { + minioEngine, ok := engine.(*storage.MinIOEngine) + if !ok { + // 非MinIO引擎, 回退到中转模式 + c.serveProxy(ctx, file, engine, policy) + return + } + + presignedURL, err := minioEngine.GetPresignedURL(file.StorageKey, 24*time.Hour) + if err != nil { + utils.ServerError(ctx, "生成预签名URL失败") + return + } + + // 替换为Nginx公网地址 + if policy.Config.NginxEndpoint != "" { + presignedURL = replaceMinIOEndpoint(presignedURL, minioEngine.Endpoint, policy.Config.NginxEndpoint) + } + + // 返回302重定向到预签名URL + ctx.Redirect(http.StatusFound, presignedURL) +} + +// serveCDN CDN模式: 返回CDN链接 +func (c *FileAccessController) serveCDN(ctx *gin.Context, file *model.File, engine storage.Engine) { + if localEngine, ok := engine.(*storage.LocalEngine); ok { + cdnURL := localEngine.GetURL(file.StorageKey) + ctx.Redirect(http.StatusFound, cdnURL) + return + } + // 回退到中转 + c.serveProxy(ctx, file, engine, &model.StoragePolicy{}) +} + +// GetAccessURL 获取文件访问URL(不重定向, 返回JSON) +// GET /api/file/:id/access-url +func (c *FileAccessController) GetAccessURL(ctx *gin.Context) { + ownerID := middleware.GetCurrentUserID(ctx) + fileID, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的文件ID") + return + } + + file, err := getFileByID(fileID) + if err != nil { + utils.NotFound(ctx, "文件不存在") + return + } + if file.OwnerID != ownerID { + utils.Forbidden(ctx, "无权访问") + return + } + + policy := getStoragePolicy(file.StoragePolicyID) + engine, err := storage.GetEngineByPolicyID(file.StoragePolicyID) + if err != nil { + utils.ServerError(ctx, "存储引擎不可用") + return + } + + result := gin.H{ + "mode": policy.Config.AccessMode, + "file_name": file.Name, + "file_size": file.Size, + "content_type": file.MimeType, + } + + switch policy.Config.AccessMode { + case "presigned": + if minioEngine, ok := engine.(*storage.MinIOEngine); ok { + presignedURL, _ := minioEngine.GetPresignedURL(file.StorageKey, 24*time.Hour) + if policy.Config.NginxEndpoint != "" { + presignedURL = replaceMinIOEndpoint(presignedURL, minioEngine.Endpoint, policy.Config.NginxEndpoint) + } + result["url"] = presignedURL + } + case "cdn": + if localEngine, ok := engine.(*storage.LocalEngine); ok { + result["url"] = localEngine.GetURL(file.StorageKey) + } + default: + result["url"] = fmt.Sprintf("/api/file/%d/access", fileID) + result["mode"] = "proxy" + } + + utils.Success(ctx, result) +} + +// ========== 辅助函数 ========== + +func getFileByID(id uint64) (*model.File, error) { + var file model.File + if err := config.DB.First(&file, id).Error; err != nil { + return nil, err + } + return &file, nil +} + +func getShareByCode(code string) (*model.Share, error) { + var share model.Share + if err := config.DB.Where("share_code = ? AND status = 1", code).First(&share).Error; err != nil { + return nil, err + } + return &share, nil +} + +func verifySharePassword(share *model.Share, password string) bool { + return utils.CheckPassword(password, share.Password) +} + +func getStoragePolicy(policyID uint64) *model.StoragePolicy { + var policy model.StoragePolicy + if policyID > 0 { + config.DB.First(&policy, policyID) + } + if policy.Config.AccessMode == "" { + policy.Config.AccessMode = "proxy" + } + return &policy +} + +// replaceMinIOEndpoint 替换预签名URL中的MinIO地址为Nginx公网地址 +func replaceMinIOEndpoint(url, minioEndpoint, nginxEndpoint string) string { + old := "http://" + minioEndpoint + if url[:len(old)] == old { + return nginxEndpoint + url[len(old):] + } + old = "https://" + minioEndpoint + if len(url) > len(old) && url[:len(old)] == old { + return nginxEndpoint + url[len(old):] + } + return url +} diff --git a/server/controller/log_controller.go b/server/controller/log_controller.go new file mode 100644 index 0000000..3fc64ec --- /dev/null +++ b/server/controller/log_controller.go @@ -0,0 +1,76 @@ +package controller + +import ( + "strconv" + + "seeyon-filesystem/dto" + "seeyon-filesystem/service" + "seeyon-filesystem/utils" + + "github.com/gin-gonic/gin" +) + +// LogController 日志控制器 +type LogController struct { + logService *service.LogService +} + +func NewLogController() *LogController { + return &LogController{ + logService: service.NewLogService(), + } +} + +// List 查询操作日志 +// GET /api/admin/logs?page=1&size=20&action=upload&resource=file&keyword=xxx +func (c *LogController) List(ctx *gin.Context) { + page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(ctx.DefaultQuery("size", "20")) + if page < 1 { + page = 1 + } + if size < 1 || size > 100 { + size = 20 + } + + action := ctx.Query("action") + resource := ctx.Query("resource") + keyword := ctx.Query("keyword") + + logs, total, err := c.logService.List(page, size, action, resource, keyword, 0) + if err != nil { + utils.ServerError(ctx, "查询失败") + return + } + + utils.PageSuccess(ctx, logs, total, page, size) +} + +// Stats 日志统计 +// GET /api/admin/logs/stats +func (c *LogController) Stats(ctx *gin.Context) { + stats, err := c.logService.GetStats() + if err != nil { + utils.ServerError(ctx, "查询失败") + return + } + utils.Success(ctx, stats) +} + +// Clean 清理旧日志 +// POST /api/admin/logs/clean +func (c *LogController) Clean(ctx *gin.Context) { + var req dto.LogCleanRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误") + return + } + + count, err := c.logService.CleanOld(req.Days) + if err != nil { + utils.ServerError(ctx, "清理失败") + return + } + + utils.SuccessWithMessage(ctx, "清理完成", gin.H{"deleted": count}) +} diff --git a/server/controller/monitor_controller.go b/server/controller/monitor_controller.go new file mode 100644 index 0000000..55a40b3 --- /dev/null +++ b/server/controller/monitor_controller.go @@ -0,0 +1,126 @@ +package controller + +import ( + "fmt" + + "seeyon-filesystem/middleware" + "seeyon-filesystem/repository" + "seeyon-filesystem/utils" + + "github.com/gin-gonic/gin" +) + +// MonitorController 监控控制器 +type MonitorController struct { + fileRepo *repository.FileRepository +} + +func NewMonitorController() *MonitorController { + return &MonitorController{ + fileRepo: repository.NewFileRepository(), + } +} + +// GetTrafficStats 获取流量统计 +// GET /api/admin/monitor/traffic +func (c *MonitorController) GetTrafficStats(ctx *gin.Context) { + info := middleware.GetTrafficInfo() + utils.Success(ctx, info) +} + +// GetRecentRequests 获取最近请求记录 +// GET /api/admin/monitor/requests?limit=50 +func (c *MonitorController) GetRecentRequests(ctx *gin.Context) { + limit := 50 + if l, err := parseIntQuery(ctx, "limit"); err == nil && l > 0 { + limit = l + } + if limit > 500 { + limit = 500 + } + + requests := middleware.GetRecentRequests(limit) + utils.Success(ctx, requests) +} + +// GetStorageStats 获取存储统计(按策略分组) +// GET /api/admin/monitor/storage +func (c *MonitorController) GetStorageStats(ctx *gin.Context) { + stats, err := c.fileRepo.GetPolicyStats() + if err != nil { + utils.ServerError(ctx, "查询失败") + return + } + + global, _ := c.fileRepo.GetGlobalStats() + utils.Success(ctx, gin.H{ + "policies": stats, + "global": global, + }) +} + +// ========== IP 黑名单管理 ========== + +// GetBlacklist 获取IP黑名单 +// GET /api/admin/blacklist +func (c *MonitorController) GetBlacklist(ctx *gin.Context) { + ips := middleware.IPBlacklist.GetBlockedIPs() + utils.Success(ctx, gin.H{ + "enabled": middleware.IPBlacklist.IsEnabled(), + "ips": ips, + }) +} + +// SetBlacklist 设置IP黑名单(全量替换) +// PUT /api/admin/blacklist +func (c *MonitorController) SetBlacklist(ctx *gin.Context) { + var req struct { + Enabled bool `json:"enabled"` + IPs []string `json:"ips"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误") + return + } + + middleware.IPBlacklist.SetEnabled(req.Enabled) + if req.IPs != nil { + middleware.IPBlacklist.SetBlockedIPs(req.IPs) + } + + utils.SuccessWithMessage(ctx, "黑名单已更新", nil) +} + +// AddBlockedIP 添加封禁IP +// POST /api/admin/blacklist +func (c *MonitorController) AddBlockedIP(ctx *gin.Context) { + var req struct { + IP string `json:"ip" binding:"required"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误") + return + } + + middleware.IPBlacklist.AddBlockedIP(req.IP) + utils.SuccessWithMessage(ctx, "已添加", nil) +} + +// RemoveBlockedIP 移除封禁IP +// DELETE /api/admin/blacklist/:ip +func (c *MonitorController) RemoveBlockedIP(ctx *gin.Context) { + ip := ctx.Param("ip") + middleware.IPBlacklist.RemoveBlockedIP(ip) + utils.SuccessWithMessage(ctx, "已移除", nil) +} + +// parseIntQuery 解析查询参数中的整数 +func parseIntQuery(ctx *gin.Context, key string) (int, error) { + val := ctx.Query(key) + if val == "" { + return 0, nil + } + var result int + _, err := fmt.Sscanf(val, "%d", &result) + return result, err +} diff --git a/server/controller/openapi_controller.go b/server/controller/openapi_controller.go new file mode 100644 index 0000000..192cdfc --- /dev/null +++ b/server/controller/openapi_controller.go @@ -0,0 +1,192 @@ +package controller + +import ( + "strconv" + + "seeyon-filesystem/dto" + "seeyon-filesystem/service" + "seeyon-filesystem/utils" + + "github.com/gin-gonic/gin" +) + +// OpenAPIController 开放平台控制器 +type OpenAPIController struct { + openService *service.OpenAPIService +} + +func NewOpenAPIController() *OpenAPIController { + return &OpenAPIController{ + openService: service.NewOpenAPIService(), + } +} + +// ========== 管理员接口 ========== + +// ListApps 获取应用列表 +// GET /api/admin/apps +func (c *OpenAPIController) ListApps(ctx *gin.Context) { + apps, err := c.openService.ListApps() + if err != nil { + utils.ServerError(ctx, "查询失败") + return + } + utils.Success(ctx, apps) +} + +// CreateApp 创建应用 +// POST /api/admin/apps +func (c *OpenAPIController) CreateApp(ctx *gin.Context) { + var req dto.AppCreateRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: "+err.Error()) + return + } + + app, err := c.openService.CreateApp(req.AppName) + if err != nil { + utils.ServerError(ctx, err.Error()) + return + } + utils.Success(ctx, app) +} + +// DeleteApp 删除应用 +// DELETE /api/admin/apps/:id +func (c *OpenAPIController) DeleteApp(ctx *gin.Context) { + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的ID") + return + } + if err := c.openService.DeleteApp(id); err != nil { + utils.ServerError(ctx, err.Error()) + return + } + utils.SuccessWithMessage(ctx, "删除成功", nil) +} + +// ToggleApp 启用/禁用应用 +// PUT /api/admin/apps/:id/toggle +func (c *OpenAPIController) ToggleApp(ctx *gin.Context) { + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的ID") + return + } + + var req struct { + Status int8 `json:"status"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误") + return + } + + if err := c.openService.ToggleApp(id, req.Status); err != nil { + utils.ServerError(ctx, err.Error()) + return + } + utils.SuccessWithMessage(ctx, "操作成功", nil) +} + +// BindUsers 绑定用户到应用 +// PUT /api/admin/apps/:id/users +func (c *OpenAPIController) BindUsers(ctx *gin.Context) { + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的ID") + return + } + + var req dto.AppBindUsersRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: "+err.Error()) + return + } + + if err := c.openService.BindUsers(id, req.UserIDs); err != nil { + utils.BadRequest(ctx, err.Error()) + return + } + utils.SuccessWithMessage(ctx, "绑定成功", nil) +} + +// UnbindUser 解绑用户 +// DELETE /api/admin/apps/:id/users/:userId +func (c *OpenAPIController) UnbindUser(ctx *gin.Context) { + appID, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的应用ID") + return + } + userID, err := strconv.ParseUint(ctx.Param("userId"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的用户ID") + return + } + + if err := c.openService.UnbindUser(appID, userID); err != nil { + utils.ServerError(ctx, err.Error()) + return + } + utils.SuccessWithMessage(ctx, "解绑成功", nil) +} + +// ========== 开放API (给OA调用) ========== + +// ValidateToken 验证SSO Token +// POST /api/openapi/token/validate +// Body: { "app_id": "xxx", "token": "encrypted-string" } +// 返回: JWT token +func (c *OpenAPIController) ValidateToken(ctx *gin.Context) { + var req struct { + AppID string `json:"app_id" binding:"required"` + Token string `json:"token" binding:"required"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: app_id和token必填") + return + } + + // 验证token, 获取用户名 + username, err := c.openService.ValidateToken(req.AppID, req.Token) + if err != nil { + utils.Unauthorized(ctx, err.Error()) + return + } + + // 生成JWT + result, err := c.openService.GenerateJWTForUser(username) + if err != nil { + utils.ServerError(ctx, err.Error()) + return + } + + utils.Success(ctx, result) +} + +// GenerateTestToken 生成测试Token (仅调试用) +// POST /api/openapi/token/generate +// Body: { "app_id": "xxx", "username": "admin" } +func (c *OpenAPIController) GenerateTestToken(ctx *gin.Context) { + var req struct { + AppID string `json:"app_id" binding:"required"` + Username string `json:"username" binding:"required"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误") + return + } + + token, err := c.openService.GenerateToken(req.AppID, req.Username) + if err != nil { + utils.BadRequest(ctx, err.Error()) + return + } + + utils.Success(ctx, gin.H{ + "token": token, + "usage": "OA系统使用此token调用 POST /api/openapi/token/validate 获取JWT", + }) +} diff --git a/server/controller/storage_assign_controller.go b/server/controller/storage_assign_controller.go new file mode 100644 index 0000000..cb57a97 --- /dev/null +++ b/server/controller/storage_assign_controller.go @@ -0,0 +1,151 @@ +package controller + +import ( + "strconv" + + "seeyon-filesystem/dto" + "seeyon-filesystem/middleware" + "seeyon-filesystem/service" + "seeyon-filesystem/utils" + + "github.com/gin-gonic/gin" +) + +// StorageAssignController 存储策略分配控制器 +type StorageAssignController struct { + assignService *service.StorageAssignService +} + +func NewStorageAssignController() *StorageAssignController { + return &StorageAssignController{ + assignService: service.NewStorageAssignService(), + } +} + +// ========== 用户接口 ========== + +// GetMyStorageInfo 获取当前用户的存储策略信息 +// GET /api/user/storage-info +func (c *StorageAssignController) GetMyStorageInfo(ctx *gin.Context) { + userID := middleware.GetCurrentUserID(ctx) + info, err := c.assignService.GetUserStorageInfo(userID) + if err != nil { + utils.ServerError(ctx, "获取存储信息失败") + return + } + utils.Success(ctx, info) +} + +// ========== 管理员接口 ========== + +// ListAssignments 获取所有分配规则 +// GET /api/admin/storage-assignments +func (c *StorageAssignController) ListAssignments(ctx *gin.Context) { + assignments, err := c.assignService.ListAssignments() + if err != nil { + utils.ServerError(ctx, "查询失败") + return + } + utils.Success(ctx, assignments) +} + +// SetDefault 设置系统默认存储策略 +// POST /api/admin/storage-assignments/default +func (c *StorageAssignController) SetDefault(ctx *gin.Context) { + var req struct { + PolicyID uint64 `json:"policy_id" binding:"required"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误") + return + } + + if err := c.assignService.SetDefault(req.PolicyID); err != nil { + utils.ServerError(ctx, err.Error()) + return + } + utils.SuccessWithMessage(ctx, "默认策略已设置", nil) +} + +// AssignToUser 为用户分配存储策略 +// POST /api/admin/storage-assignments/user +func (c *StorageAssignController) AssignToUser(ctx *gin.Context) { + var req dto.StorageAssignRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: "+err.Error()) + return + } + if req.UserID == 0 { + utils.BadRequest(ctx, "请指定用户ID") + return + } + + if err := c.assignService.AssignToUser(req.UserID, req.PolicyID, req.StorageQuota, req.AccessMode); err != nil { + utils.ServerError(ctx, err.Error()) + return + } + utils.SuccessWithMessage(ctx, "分配成功", nil) +} + +// AssignToRole 为角色分配存储策略 +// POST /api/admin/storage-assignments/role +func (c *StorageAssignController) AssignToRole(ctx *gin.Context) { + var req dto.StorageAssignRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: "+err.Error()) + return + } + if req.GroupID == 0 { + utils.BadRequest(ctx, "请指定角色") + return + } + + if err := c.assignService.AssignToRole(req.GroupID, req.PolicyID, req.StorageQuota, req.Priority, req.AccessMode); err != nil { + utils.ServerError(ctx, err.Error()) + return + } + utils.SuccessWithMessage(ctx, "分配成功", nil) +} + +// UpdateAssignment 更新分配规则(配额/优先级/访问模式) +// PUT /api/admin/storage-assignments/:id +func (c *StorageAssignController) UpdateAssignment(ctx *gin.Context) { + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的ID") + return + } + + var req struct { + PolicyID uint64 `json:"policy_id"` + Priority *int `json:"priority"` + StorageQuota *int64 `json:"storage_quota"` + AccessMode string `json:"access_mode"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误") + return + } + + if err := c.assignService.UpdateAssignment(id, req.PolicyID, req.Priority, req.StorageQuota, req.AccessMode); err != nil { + utils.BadRequest(ctx, err.Error()) + return + } + utils.SuccessWithMessage(ctx, "更新成功", nil) +} + +// DeleteAssignment 删除分配规则 +// DELETE /api/admin/storage-assignments/:id +func (c *StorageAssignController) DeleteAssignment(ctx *gin.Context) { + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + utils.BadRequest(ctx, "无效的ID") + return + } + + if err := c.assignService.DeleteAssignment(id); err != nil { + utils.BadRequest(ctx, err.Error()) + return + } + utils.SuccessWithMessage(ctx, "删除成功", nil) +} diff --git a/server/controller/storage_controller.go b/server/controller/storage_controller.go new file mode 100644 index 0000000..7968325 --- /dev/null +++ b/server/controller/storage_controller.go @@ -0,0 +1,49 @@ +package controller + +import ( + "seeyon-filesystem/config" + "seeyon-filesystem/utils" + + "github.com/gin-gonic/gin" +) + +// StorageController 存储配置控制器 +type StorageController struct{} + +func NewStorageController() *StorageController { + return &StorageController{} +} + +// GetCDNConfig 获取CDN配置 +// GET /api/admin/settings/cdn +func (c *StorageController) GetCDNConfig(ctx *gin.Context) { + cfg := config.AppConfig.Storage + utils.Success(ctx, gin.H{ + "cdn_host": cfg.CDNHost, + "cdn_path_prefix": cfg.CDNPathPrefix, + "local_base_path": cfg.LocalBasePath, + }) +} + +// UpdateCDNConfig 更新CDN配置 +// PUT /api/admin/settings/cdn +func (c *StorageController) UpdateCDNConfig(ctx *gin.Context) { + var req struct { + CDNHost string `json:"cdn_host"` + CDNPathPrefix string `json:"cdn_path_prefix"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.BadRequest(ctx, "参数错误: "+err.Error()) + return + } + + // 更新内存配置 + config.AppConfig.Storage.CDNHost = req.CDNHost + config.AppConfig.Storage.CDNPathPrefix = req.CDNPathPrefix + + utils.SuccessWithMessage(ctx, "CDN配置已更新", gin.H{ + "cdn_host": req.CDNHost, + "cdn_path_prefix": req.CDNPathPrefix, + "example_url": req.CDNHost + req.CDNPathPrefix + "/2026/07/02/ab/abcdef.txt", + }) +} diff --git a/server/deploy.bat b/server/deploy.bat new file mode 100644 index 0000000..f6e05d2 --- /dev/null +++ b/server/deploy.bat @@ -0,0 +1,135 @@ +@echo off +chcp 65001 >nul +echo ============================================ +echo 文件管理系统 - Windows 部署脚本 +echo ============================================ +echo. + +:: 设置部署目录 +set DEPLOY_DIR=%~dp0deploy +set SERVER_DIR=%DEPLOY_DIR%\server +set FRONTEND_DIR=%DEPLOY_DIR%\frontend + +:: 清理旧部署 +if exist "%DEPLOY_DIR%" ( + echo [1/6] 清理旧部署目录... + rmdir /s /q "%DEPLOY_DIR%" +) + +:: 创建目录结构 +echo [2/6] 创建目录结构... +mkdir "%SERVER_DIR%" +mkdir "%SERVER_DIR%\data\storage" +mkdir "%FRONTEND_DIR%" + +:: 编译后端 +echo [3/6] 编译后端... +cd /d "%~dp0" +set GOPROXY=https://goproxy.cn,direct +go build -o "%SERVER_DIR%\server.exe" main.go +if errorlevel 1 ( + echo 编译失败! + pause + exit /b 1 +) + +:: 复制后端文件 +echo [4/6] 复制后端文件... +copy /y config.yaml "%SERVER_DIR%\" >nul +copy /y config.yaml "%SERVER_DIR%\config.yaml.example" >nul +xcopy /s /e /y sql "%SERVER_DIR%\sql\" >nul + +:: 构建前端 +echo [5/6] 构建前端... +cd /d "%~dp0..\web" +call npm run build +if errorlevel 1 ( + echo 前端构建失败! + pause + exit /b 1 +) +xcopy /s /e /y dist "%FRONTEND_DIR%\" >nul + +:: 生成启动脚本 +echo [6/6] 生成启动脚本... + +:: 后端启动脚本 +( +echo @echo off +echo chcp 65001 ^>nul +echo echo ============================================ +echo echo 文件管理系统 - 后端服务 +echo echo ============================================ +echo echo. +echo echo 启动中... 访问 http://localhost:8080/health 检查状态 +echo echo. +echo server.exe +echo pause +) > "%DEPLOY_DIR%\启动后端.bat" + +:: 数据库初始化脚本 +( +echo @echo off +echo chcp 65001 ^>nul +echo echo ============================================ +echo echo 数据库初始化 +echo echo ============================================ +echo echo. +echo set /p MYSQL_PWD=请输入MySQL root密码: +echo echo. +echo echo 正在创建数据库... +echo mysql -u root -p%%MYSQL_PWD%% -e "CREATE DATABASE IF NOT EXISTS seeyon_fs DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" +echo echo. +echo echo 正在导入表结构... +echo mysql -u root -p%%MYSQL_PWD%% seeyon_fs ^< sql\init.sql +echo echo. +echo echo 初始化完成! +echo pause +) > "%DEPLOY_DIR%\初始化数据库.bat" + +:: 使用说明 +( +echo ============================================ +echo 文件管理系统 - 部署说明 +echo ============================================ +echo. +echo 【目录结构】 +echo deploy/ +echo ├── server/ +echo │ ├── server.exe 后端服务 +echo │ ├── config.yaml 配置文件(需修改) +echo │ ├── config.yaml.example 配置示例 +echo │ ├── data/storage/ 文件存储目录 +echo │ └── sql/init.sql 数据库脚本 +echo ├── frontend/ 前端静态资源 +echo ├── 启动后端.bat 启动后端服务 +echo └── 初始化数据库.bat 首次部署执行 +echo. +echo 【部署步骤】 +echo 1. 安装MySQL 8.0, 运行"初始化数据库.bat" +echo 2. 修改server/config.yaml中的数据库密码等配置 +echo 3. 运行"启动后端.bat" +echo 4. 配置Nginx(参考docs/nginx-production.conf) +echo 5. 访问 http://你的域名 +echo. +echo 【默认账号】admin / admin123 +echo. +) > "%DEPLOY_DIR%\部署说明.txt" + +echo. +echo ============================================ +echo 部署完成! +echo ============================================ +echo. +echo 部署目录: %DEPLOY_DIR% +echo. +echo 文件清单: +dir /s /b "%DEPLOY_DIR%\*.exe" "%DEPLOY_DIR%\*.yaml" "%DEPLOY_DIR%\*.bat" "%DEPLOY_DIR%\*.txt" 2>nul +echo. +echo 下一步: +echo 1. 将 %DEPLOY_DIR% 复制到目标服务器 +echo 2. 运行"初始化数据库.bat" +echo 3. 修改 config.yaml 配置 +echo 4. 运行"启动后端.bat" +echo. +pause diff --git a/server/deploy.sh b/server/deploy.sh new file mode 100644 index 0000000..39a4191 --- /dev/null +++ b/server/deploy.sh @@ -0,0 +1,102 @@ +#!/bin/bash +# ============================================ +# 文件管理系统 - Linux 部署脚本 +# ============================================ + +set -e + +DEPLOY_DIR="./deploy" +SERVER_DIR="$DEPLOY_DIR/server" +FRONTEND_DIR="$DEPLOY_DIR/frontend" + +echo "============================================" +echo " 文件管理系统 - Linux 部署" +echo "============================================" + +# 清理旧部署 +[ -d "$DEPLOY_DIR" ] && rm -rf "$DEPLOY_DIR" + +# 创建目录 +echo "[1/6] 创建目录结构..." +mkdir -p "$SERVER_DIR/data/storage" +mkdir -p "$FRONTEND_DIR" + +# 编译后端 +echo "[2/6] 编译后端..." +export GOPROXY=https://goproxy.cn,direct +go build -o "$SERVER_DIR/server" main.go + +# 复制后端文件 +echo "[3/6] 复制后端文件..." +cp config.yaml "$SERVER_DIR/" +cp config.yaml "$SERVER_DIR/config.yaml.example" +cp -r sql "$SERVER_DIR/" + +# 构建前端 +echo "[4/6] 构建前端..." +cd ../web && npm run build && cd ../server +cp -r ../web/dist/* "$FRONTEND_DIR/" + +# 生成启动脚本 +echo "[5/6] 生成启动脚本..." + +cat > "$DEPLOY_DIR/start.sh" << 'EOF' +#!/bin/bash +cd "$(dirname "$0")/server" +echo "启动后端服务..." +./server +EOF +chmod +x "$DEPLOY_DIR/start.sh" + +cat > "$DEPLOY_DIR/init-db.sh" << 'EOF' +#!/bin/bash +cd "$(dirname "$0")/server" +read -sp "请输入MySQL root密码: " MYSQL_PWD +echo "" +echo "创建数据库..." +mysql -u root -p"$MYSQL_PWD" -e "CREATE DATABASE IF NOT EXISTS seeyon_fs DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" +echo "导入表结构..." +mysql -u root -p"$MYSQL_PWD" seeyon_fs < sql/init.sql +echo "初始化完成!" +EOF +chmod +x "$DEPLOY_DIR/init-db.sh" + +# 生成systemd服务文件 +echo "[6/6] 生成systemd服务..." +cat > "$DEPLOY_DIR/filesystem.service" << EOF +[Unit] +Description=File Management System +After=network.target mysql.service + +[Service] +Type=simple +User=root +WorkingDirectory=$(pwd)/$SERVER_DIR +ExecStart=$(pwd)/$SERVER_DIR/server +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +EOF + +echo "" +echo "============================================" +echo " 部署完成!" +echo "============================================" +echo "" +echo "部署目录: $DEPLOY_DIR" +echo "" +echo "文件清单:" +find "$DEPLOY_DIR" -type f | head -20 +echo "" +echo "【部署步骤】" +echo " 1. 将 $DEPLOY_DIR 复制到目标服务器" +echo " 2. 运行 ./init-db.sh 初始化数据库" +echo " 3. 修改 server/config.yaml 配置" +echo " 4. 运行 ./start.sh 启动服务" +echo " 5. (可选) 安装systemd服务:" +echo " cp filesystem.service /etc/systemd/system/" +echo " systemctl enable --now filesystem" +echo "" +echo "【默认账号】admin / admin123" diff --git a/server/docs/nginx-production.conf b/server/docs/nginx-production.conf new file mode 100644 index 0000000..0e687fd --- /dev/null +++ b/server/docs/nginx-production.conf @@ -0,0 +1,156 @@ +# ============================================= +# 文件管理系统 - 生产环境 Nginx 配置 +# ============================================= +# 功能: +# 1. 前端静态资源服务 +# 2. API反向代理到Go后端 +# 3. MinIO反向代理(预签名模式) +# 4. X-Sendfile支持(中转模式优化) +# 5. HTTPS配置(可选) +# ============================================= + +worker_processes auto; + +events { + worker_connections 1024; +} + +http { + include mime.types; + default_type application/octet-stream; + sendfile off; # Windows环境建议关闭 + charset utf-8; + + # 日志格式 + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent"'; + + # Gzip压缩 + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml; + + # ============================================= + # Upstream + # ============================================= + upstream fileserver { + server 127.0.0.1:8080; # Go文件管理服务 + keepalive 32; + } + + upstream minio { + server 127.0.0.1:9000; # MinIO服务(不暴露公网) + keepalive 32; + } + + # ============================================= + # HTTP → HTTPS 重定向(可选) + # ============================================= + # server { + # listen 80; + # server_name files.example.com; + # return 301 https://$host$request_uri; + # } + + # ============================================= + # 主站配置 + # ============================================= + server { + listen 80; # 或 443 ssl + server_name files.example.com; # 替换为你的域名 + + client_max_body_size 500m; # 最大上传500MB + + # SSL配置(可选) + # ssl_certificate /path/to/cert.pem; + # ssl_certificate_key /path/to/key.pem; + # ssl_protocols TLSv1.2 TLSv1.3; + + # access_log logs/access.log main; + + # ============================================= + # 1. API请求 → Go后端 + # ============================================= + location ^~ /api/ { + proxy_pass http://fileserver; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 超时设置 + proxy_connect_timeout 30s; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + } + + # ============================================= + # 2. MinIO反向代理(预签名模式) + # 用户通过此路径直连MinIO获取文件 + # 关键: 必须保留签名参数 + # ============================================= + location ^~ /minio/ { + proxy_pass http://minio/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + + # 大文件下载超时 + proxy_read_timeout 300s; + proxy_send_timeout 300s; + + # 禁用缓冲(流式传输) + proxy_buffering off; + + # 隐藏MinIO内部头 + proxy_hide_header X-Amz-Id-2; + proxy_hide_header X-Amz-Request-Id; + } + + # ============================================= + # 3. X-Sendfile路径(中转模式优化) + # Go后端返回X-Accel-Redirect头时, + # Nginx直接从本地路径发送文件 + # ============================================= + location ^~ /minio-files/ { + internal; # 仅允许内部重定向, 外部不可直接访问 + + alias D:/Seeyon/A8/localfile/; + + # 优化大文件传输 + sendfile on; + tcp_nopush on; + + # 缓存控制 + expires 30d; + add_header Cache-Control "public, immutable"; + add_header X-Content-Type-Options nosniff; + } + + # ============================================= + # 4. 前端静态资源 + # ============================================= + location / { + root D:/Seeyon/A8/frontend/dist; # 前端构建产物目录 + index index.html; + + # SPA路由支持 + try_files $uri $uri/ /index.html; + + # 静态资源缓存 + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { + expires 30d; + add_header Cache-Control "public, immutable"; + } + } + + # ============================================= + # 5. 安全头 + # ============================================= + add_header X-Frame-Options SAMEORIGIN; + add_header X-Content-Type-Options nosniff; + add_header X-XSS-Protection "1; mode=block"; + } +} diff --git a/server/docs/nginx.conf b/server/docs/nginx.conf new file mode 100644 index 0000000..f8cd152 --- /dev/null +++ b/server/docs/nginx.conf @@ -0,0 +1,123 @@ +# ============================================= +# 文件管理系统 Nginx 配置模板 +# 支持两种文件访问模式: +# 1. 中转模式(proxy): Nginx → Server鉴权 → 返回文件 +# 2. 预签名模式(presigned): 客户端 → Nginx → MinIO +# ============================================= + +worker_processes 1; + +events { + worker_connections 1024; +} + +http { + include mime.types; + default_type application/octet-stream; + sendfile off; + charset utf-8; + + # ============================================= + # Upstream: 后端服务 + MinIO + # ============================================= + upstream fileserver { + server 127.0.0.1:8080; # Go文件管理服务 + } + + upstream minio { + server 127.0.0.1:9000; # MinIO服务 + } + + upstream minio_console { + server 127.0.0.1:9001; # MinIO控制台 + } + + server { + listen 3090; + server_name localhost; + client_max_body_size 500m; # 最大上传500MB + + # ============================================= + # 1. API请求 → 后端服务 + # ============================================= + location ^~ /api/ { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass http://fileserver; + } + + # ============================================= + # 2. 静态文件 - 中转模式(X-Sendfile) + # Server鉴权成功后返回X-Accel-Redirect头, + # Nginx直接从本地路径发送文件, 不经过Server + # ============================================= + location ^~ /minio-files/ { + internal; # 仅允许内部重定向 + + # MinIO数据目录(或挂载点) + alias D:/Seeyon/A8/localfile/; + + # 优化大文件传输 + sendfile on; + tcp_nopush on; + + # 缓存控制 + expires 30d; + add_header Cache-Control "public, immutable"; + } + + # ============================================= + # 3. 预签名直连模式 + # 客户端拿到预签名URL后直接访问此路径 + # Nginx转发到MinIO, 保留原始签名参数 + # ============================================= + location ^~ /minio/ { + # 反向代理到MinIO + # 关键: 必须保留Host和签名参数 + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + # MinIO需要这些头来验证预签名 + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + + # 超时设置(大文件下载) + proxy_read_timeout 300s; + proxy_send_timeout 300s; + + # 隐藏MinIO内部地址 + proxy_hide_header X-Amz-Id-2; + proxy_hide_header X-Amz-Request-Id; + + proxy_pass http://minio; + } + + # ============================================= + # 4. MinIO控制台(管理用, 可选) + # ============================================= + location ^~ /minio-console/ { + proxy_pass http://minio_console/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + # WebSocket支持(MinIO控制台需要) + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + # ============================================= + # 5. 前端静态资源 + # ============================================= + location / { + root D:/Seeyon/A8/frontend/dist; + index index.html; + + # SPA路由支持 + try_files $uri $uri/ /index.html; + } + } +} diff --git a/server/middleware/ip_blacklist.go b/server/middleware/ip_blacklist.go new file mode 100644 index 0000000..65c65d4 --- /dev/null +++ b/server/middleware/ip_blacklist.go @@ -0,0 +1,124 @@ +package middleware + +import ( + "net/http" + "strings" + "sync" + + "github.com/gin-gonic/gin" +) + +// IPBlacklist IP黑名单管理 +var IPBlacklist = &ipBlacklist{ + blockedIPs: make(map[string]bool), + allowedIPs: make(map[string]bool), + blockedCIDR: []string{}, +} + +type ipBlacklist struct { + mu sync.RWMutex + blockedIPs map[string]bool + allowedIPs map[string]bool + blockedCIDR []string // 预留CIDR支持 + enabled bool +} + +// SetEnabled 启用/禁用IP黑名单 +func (bl *ipBlacklist) SetEnabled(enabled bool) { + bl.mu.Lock() + defer bl.mu.Unlock() + bl.enabled = enabled +} + +// IsEnabled 是否启用 +func (bl *ipBlacklist) IsEnabled() bool { + bl.mu.RLock() + defer bl.mu.RUnlock() + return bl.enabled +} + +// AddBlockedIP 添加封禁IP +func (bl *ipBlacklist) AddBlockedIP(ip string) { + bl.mu.Lock() + defer bl.mu.Unlock() + bl.blockedIPs[ip] = true +} + +// RemoveBlockedIP 移除封禁IP +func (bl *ipBlacklist) RemoveBlockedIP(ip string) { + bl.mu.Lock() + defer bl.mu.Unlock() + delete(bl.blockedIPs, ip) +} + +// IsBlocked 检查IP是否被封禁 +func (bl *ipBlacklist) IsBlocked(ip string) bool { + bl.mu.RLock() + defer bl.mu.RUnlock() + + if !bl.enabled { + return false + } + + // 白名单优先 + if bl.allowedIPs[ip] { + return false + } + + return bl.blockedIPs[ip] +} + +// GetBlockedIPs 获取所有封禁IP +func (bl *ipBlacklist) GetBlockedIPs() []string { + bl.mu.RLock() + defer bl.mu.RUnlock() + + ips := make([]string, 0, len(bl.blockedIPs)) + for ip := range bl.blockedIPs { + ips = append(ips, ip) + } + return ips +} + +// SetBlockedIPs 批量设置封禁IP +func (bl *ipBlacklist) SetBlockedIPs(ips []string) { + bl.mu.Lock() + defer bl.mu.Unlock() + + bl.blockedIPs = make(map[string]bool) + for _, ip := range ips { + ip = strings.TrimSpace(ip) + if ip != "" { + bl.blockedIPs[ip] = true + } + } +} + +// AddAllowedIP 添加白名单IP +func (bl *ipBlacklist) AddAllowedIP(ip string) { + bl.mu.Lock() + defer bl.mu.Unlock() + bl.allowedIPs[ip] = true +} + +// IPBlacklistMiddleware IP黑名单中间件 +func IPBlacklistMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + if !IPBlacklist.IsEnabled() { + c.Next() + return + } + + clientIP := c.ClientIP() + + if IPBlacklist.IsBlocked(clientIP) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "code": 403, + "message": "您的IP已被封禁", + }) + return + } + + c.Next() + } +} diff --git a/server/middleware/logger.go b/server/middleware/logger.go new file mode 100644 index 0000000..bdfafce --- /dev/null +++ b/server/middleware/logger.go @@ -0,0 +1,167 @@ +package middleware + +import ( + "strings" + + "seeyon-filesystem/config" + "seeyon-filesystem/model" + + "github.com/gin-gonic/gin" +) + +// OperationLogger 操作日志中间件 +// 自动记录关键操作到 fs_operation_log 表 +func OperationLogger() gin.HandlerFunc { + return func(c *gin.Context) { + c.Next() + + // 只记录写操作 + method := c.Request.Method + if method == "GET" || method == "OPTIONS" { + return + } + + path := c.Request.URL.Path + + // 跳过不需要记录的路径 + if shouldSkipLog(path) { + return + } + + // 获取操作信息 + action, resource := classifyOperation(method, path) + + // 获取用户信息 + userID, _ := c.Get("user_id") + username, _ := c.Get("username") + + var uid uint64 + var uname string + if userID != nil { + uid = userID.(uint64) + } + if username != nil { + uname = username.(string) + } + + // 获取请求详情 + detail := method + " " + path + if query := c.Request.URL.Query().Encode(); query != "" { + detail += "?" + query + } + + // 判断是否成功 + status := 1 + if c.Writer.Status() >= 400 { + status = 0 + } + + // 异步写入日志(不阻塞请求) + go func() { + log := &model.OperationLog{ + UserID: uid, + Username: uname, + Action: action, + Resource: resource, + Detail: truncate(detail, 500), + IP: c.ClientIP(), + UserAgent: truncate(c.Request.UserAgent(), 256), + Status: status, + } + config.DB.Create(log) + }() + } +} + +// classifyOperation 根据方法和路径判断操作类型 +func classifyOperation(method, path string) (string, string) { + // 认证相关 + if strings.Contains(path, "/auth/login") { + return "login", "auth" + } + if strings.Contains(path, "/auth/register") { + return "register", "auth" + } + if strings.Contains(path, "/sso/callback") { + return "sso_login", "auth" + } + if strings.Contains(path, "/openapi/token") { + return "token_validate", "auth" + } + + // 文件操作 + if strings.Contains(path, "/file/upload") { + return "upload", "file" + } + if strings.Contains(path, "/file") && strings.Contains(path, "/rename") { + return "rename", "file" + } + if strings.Contains(path, "/file") && strings.Contains(path, "/move") { + return "move", "file" + } + if strings.Contains(path, "/file") && method == "DELETE" { + return "delete", "file" + } + if strings.Contains(path, "/file") && strings.Contains(path, "/restore") { + return "restore", "file" + } + + // 文件夹操作 + if strings.Contains(path, "/folder") && method == "POST" { + return "create_folder", "folder" + } + if strings.Contains(path, "/folder") && strings.Contains(path, "/rename") { + return "rename", "folder" + } + if strings.Contains(path, "/folder") && method == "DELETE" { + return "delete", "folder" + } + + // 分享操作 + if strings.Contains(path, "/share") && method == "POST" { + return "create_share", "share" + } + if strings.Contains(path, "/share") && method == "DELETE" { + return "cancel_share", "share" + } + + // 管理操作 + if strings.Contains(path, "/admin/users") { + return "manage_user", "user" + } + if strings.Contains(path, "/admin/roles") { + return "manage_role", "role" + } + if strings.Contains(path, "/admin/storage") { + return "manage_storage", "system" + } + if strings.Contains(path, "/admin/apps") { + return "manage_app", "system" + } + + return method, "other" +} + +// shouldSkipLog 判断是否跳过记录 +func shouldSkipLog(path string) bool { + skipPaths := []string{ + "/health", + "/api/auth/refresh", + "/api/admin/monitor/traffic", + "/api/admin/monitor/requests", + } + for _, p := range skipPaths { + if path == p { + return true + } + } + return false +} + +// truncate 截断字符串 +func truncate(s string, maxLen int) string { + if len(s) > maxLen { + return s[:maxLen] + "..." + } + return s +} diff --git a/server/middleware/traffic.go b/server/middleware/traffic.go new file mode 100644 index 0000000..689b0ef --- /dev/null +++ b/server/middleware/traffic.go @@ -0,0 +1,167 @@ +package middleware + +import ( + "log" + "sync" + "sync/atomic" + "time" + + "github.com/gin-gonic/gin" +) + +// TrafficStats 全局流量统计 +var TrafficStats = &trafficStats{ + startTime: time.Now(), +} + +type trafficStats struct { + startTime time.Time + totalRequests int64 + totalBytesIn int64 + totalBytesOut int64 + activeConns int64 + + // 按状态码统计 + status2xx int64 + status3xx int64 + status4xx int64 + status5xx int64 + + // 最近请求记录(滑动窗口) + recentRequests []requestRecord + mu sync.Mutex +} + +type requestRecord struct { + Timestamp time.Time + IP string + Path string + Status int + Bytes int +} + +// TrafficInfo 流量信息 +type TrafficInfo struct { + Uptime string `json:"uptime"` + TotalRequests int64 `json:"total_requests"` + TotalBytesIn int64 `json:"total_bytes_in"` + TotalBytesOut int64 `json:"total_bytes_out"` + ActiveConns int64 `json:"active_conns"` + QPS float64 `json:"qps"` // 每秒请求数 + Status2xx int64 `json:"status_2xx"` + Status3xx int64 `json:"status_3xx"` + Status4xx int64 `json:"status_4xx"` + Status5xx int64 `json:"status_5xx"` +} + +// GetTrafficInfo 获取流量统计信息 +func GetTrafficInfo() TrafficInfo { + uptime := time.Since(TrafficStats.startTime).Truncate(time.Second) + + // 计算QPS(最近60秒) + TrafficStats.mu.Lock() + cutoff := time.Now().Add(-60 * time.Second) + recent := 0 + for _, r := range TrafficStats.recentRequests { + if r.Timestamp.After(cutoff) { + recent++ + } + } + TrafficStats.mu.Unlock() + + var qps float64 + if uptime.Seconds() > 0 { + qps = float64(recent) / 60.0 + } + + return TrafficInfo{ + Uptime: uptime.String(), + TotalRequests: atomic.LoadInt64(&TrafficStats.totalRequests), + TotalBytesIn: atomic.LoadInt64(&TrafficStats.totalBytesIn), + TotalBytesOut: atomic.LoadInt64(&TrafficStats.totalBytesOut), + ActiveConns: atomic.LoadInt64(&TrafficStats.activeConns), + QPS: qps, + Status2xx: atomic.LoadInt64(&TrafficStats.status2xx), + Status3xx: atomic.LoadInt64(&TrafficStats.status3xx), + Status4xx: atomic.LoadInt64(&TrafficStats.status4xx), + Status5xx: atomic.LoadInt64(&TrafficStats.status5xx), + } +} + +// TrafficMonitor 流量监控中间件 +func TrafficMonitor() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + + // 增加活跃连接数 + atomic.AddInt64(&TrafficStats.activeConns, 1) + defer atomic.AddInt64(&TrafficStats.activeConns, -1) + + // 统计请求大小 + bytesIn := int64(c.Request.ContentLength) + if bytesIn < 0 { + bytesIn = 0 + } + atomic.AddInt64(&TrafficStats.totalBytesIn, bytesIn) + atomic.AddInt64(&TrafficStats.totalRequests, 1) + + c.Next() + + // 统计响应 + status := c.Writer.Status() + bytesOut := int64(c.Writer.Size()) + + switch { + case status >= 200 && status < 300: + atomic.AddInt64(&TrafficStats.status2xx, 1) + case status >= 300 && status < 400: + atomic.AddInt64(&TrafficStats.status3xx, 1) + case status >= 400 && status < 500: + atomic.AddInt64(&TrafficStats.status4xx, 1) + case status >= 500: + atomic.AddInt64(&TrafficStats.status5xx, 1) + } + + // 记录最近请求 + TrafficStats.mu.Lock() + TrafficStats.recentRequests = append(TrafficStats.recentRequests, requestRecord{ + Timestamp: start, + IP: c.ClientIP(), + Path: c.Request.URL.Path, + Status: status, + Bytes: int(bytesOut), + }) + // 只保留最近5分钟的记录 + cutoff := time.Now().Add(-5 * time.Minute) + valid := TrafficStats.recentRequests[:0] + for _, r := range TrafficStats.recentRequests { + if r.Timestamp.After(cutoff) { + valid = append(valid, r) + } + } + TrafficStats.recentRequests = valid + TrafficStats.mu.Unlock() + + // 慢请求日志 + duration := time.Since(start) + if duration > 5*time.Second { + log.Printf("[SLOW] %s %s %d %v", c.ClientIP(), c.Request.URL.Path, status, duration) + } + } +} + +// GetRecentRequests 获取最近的请求记录 +func GetRecentRequests(limit int) []requestRecord { + TrafficStats.mu.Lock() + defer TrafficStats.mu.Unlock() + + total := len(TrafficStats.recentRequests) + if limit <= 0 || limit > total { + limit = total + } + + start := total - limit + result := make([]requestRecord, limit) + copy(result, TrafficStats.recentRequests[start:]) + return result +} diff --git a/server/model/app.go b/server/model/app.go new file mode 100644 index 0000000..2a114d5 --- /dev/null +++ b/server/model/app.go @@ -0,0 +1,31 @@ +package model + +import "time" + +// App 开放平台应用 +type App struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + AppID string `json:"app_id" gorm:"uniqueIndex;size:64;not null"` + AppSecret string `json:"-" gorm:"size:128;not null"` + AppName string `json:"app_name" gorm:"size:128;not null"` + Status int8 `json:"status" gorm:"default:1"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (App) TableName() string { + return "fs_app" +} + +// AppUser 应用授权用户 +// 只有绑定的用户才能通过该应用进行SSO登录 +type AppUser struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + AppID uint64 `json:"app_id" gorm:"index;not null"` // 应用ID + UserID uint64 `json:"user_id" gorm:"index;not null"` // 用户ID + CreatedAt time.Time `json:"created_at"` +} + +func (AppUser) TableName() string { + return "fs_app_user" +} diff --git a/server/model/backup.go b/server/model/backup.go new file mode 100644 index 0000000..3051e2e --- /dev/null +++ b/server/model/backup.go @@ -0,0 +1,46 @@ +package model + +import "time" + +// BackupPolicy 备份策略 +type BackupPolicy struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + Name string `json:"name" gorm:"size:128;not null"` // 策略名称 + BackupType string `json:"backup_type" gorm:"size:32;not null"` // full=全量, incremental=增量 + TargetType string `json:"target_type" gorm:"size:32;not null"` // local=本地, minio=MinIO + TargetConfig string `json:"target_config" gorm:"type:json"` // 目标配置JSON + IncludeDB int8 `json:"include_db" gorm:"default:1"` // 是否备份数据库 + IncludeFiles int8 `json:"include_files" gorm:"default:1"` // 是否备份文件 + CronExpr string `json:"cron_expr" gorm:"size:64"` // cron表达式 + MaxBackups int `json:"max_backups" gorm:"default:7"` // 最大保留数 + Status int8 `json:"status" gorm:"default:1"` // 1=启用 0=禁用 + LastRunAt *time.Time `json:"last_run_at"` // 上次执行时间 + NextRunAt *time.Time `json:"next_run_at"` // 下次执行时间 + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (BackupPolicy) TableName() string { + return "fs_backup_policy" +} + +// BackupLog 备份执行日志 +type BackupLog struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + PolicyID uint64 `json:"policy_id" gorm:"index;not null"` + PolicyName string `json:"policy_name" gorm:"size:128"` + BackupType string `json:"backup_type" gorm:"size:32"` + Status string `json:"status" gorm:"size:32;default:running"` // running/success/failed + FilePath string `json:"file_path" gorm:"size:512"` // 备份文件路径 + FileSize int64 `json:"file_size"` // 备份文件大小 + DBSize int64 `json:"db_size"` // 数据库备份大小 + FilesCount int `json:"files_count"` // 备份文件数量 + Duration int `json:"duration"` // 耗时(秒) + ErrorMsg string `json:"error_msg" gorm:"size:512"` // 错误信息 + StartedAt time.Time `json:"started_at"` + FinishedAt *time.Time `json:"finished_at"` +} + +func (BackupLog) TableName() string { + return "fs_backup_log" +} diff --git a/server/model/operation_log.go b/server/model/operation_log.go new file mode 100644 index 0000000..5ba2f42 --- /dev/null +++ b/server/model/operation_log.go @@ -0,0 +1,22 @@ +package model + +import "time" + +// OperationLog 操作日志 +// 记录用户的关键操作行为 +type OperationLog struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + UserID uint64 `json:"user_id" gorm:"index;default:0"` // 操作人 + Username string `json:"username" gorm:"size:64"` // 操作人用户名 + Action string `json:"action" gorm:"size:32;index;not null"` // 操作类型: login/upload/download/delete/share... + Resource string `json:"resource" gorm:"size:32;index"` // 资源类型: file/folder/user/system + Detail string `json:"detail" gorm:"size:512"` // 操作详情 + IP string `json:"ip" gorm:"size:64"` // 客户端IP + UserAgent string `json:"user_agent" gorm:"size:256"` // 客户端UA + Status int `json:"status" gorm:"default:1"` // 1=成功 0=失败 + CreatedAt time.Time `json:"created_at" gorm:"index"` +} + +func (OperationLog) TableName() string { + return "fs_operation_log" +} diff --git a/server/model/storage_assignment.go b/server/model/storage_assignment.go new file mode 100644 index 0000000..f3fc741 --- /dev/null +++ b/server/model/storage_assignment.go @@ -0,0 +1,29 @@ +package model + +import "time" + +// StorageAssignment 存储策略分配 +// 支持: 用户专属 > 角色 > 系统默认 +// 同一用户可通过多个角色获得多个存储策略, 按优先级调度 +type StorageAssignment struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + PolicyID uint64 `json:"policy_id" gorm:"index;not null"` // 存储策略ID + + // 分配目标 + UserID uint64 `json:"user_id" gorm:"index;default:0"` // 用户专属(0=不按用户) + GroupID uint64 `json:"group_id" gorm:"index;default:0"` // 角色(0=不按角色) + IsDefault int8 `json:"is_default" gorm:"default:0"` // 系统默认(1=是) + + // 调度配置 + Priority int `json:"priority" gorm:"default:0"` // 优先级, 数值越大越优先 + StorageQuota int64 `json:"storage_quota" gorm:"default:0"` // 配额(字节), 0=不限 + AccessMode string `json:"access_mode" gorm:"size:32"` // 访问模式覆盖 + + Status int8 `json:"status" gorm:"default:1"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (StorageAssignment) TableName() string { + return "fs_storage_assignment" +} diff --git a/server/model/upload_session.go b/server/model/upload_session.go new file mode 100644 index 0000000..ab71f5b --- /dev/null +++ b/server/model/upload_session.go @@ -0,0 +1,28 @@ +package model + +import "time" + +// UploadSession 分片上传会话 +// 记录大文件分片上传的进度, 支持断点续传 +type UploadSession struct { + ID string `json:"id" gorm:"primaryKey;size:64"` // 会话ID + FileID uint64 `json:"file_id" gorm:"default:0"` // 上传完成后的文件ID + FileName string `json:"file_name" gorm:"size:256;not null"` // 原始文件名 + FileSize int64 `json:"file_size" gorm:"not null"` // 文件总大小 + ChunkSize int64 `json:"chunk_size" gorm:"not null"` // 分片大小 + TotalChunks int `json:"total_chunks" gorm:"not null"` // 总分片数 + UploadedChunks string `json:"uploaded_chunks" gorm:"type:text"` // 已上传分片索引, 逗号分隔: "0,1,2" + MD5 string `json:"md5" gorm:"size:32"` // 文件MD5 + SHA256 string `json:"sha256" gorm:"size:64"` // 文件SHA256 + FolderID uint64 `json:"folder_id" gorm:"not null"` // 目标文件夹 + OwnerID uint64 `json:"owner_id" gorm:"not null"` // 上传者 + PolicyID uint64 `json:"policy_id" gorm:"default:1"` // 存储策略ID + Status int8 `json:"status" gorm:"default:1"` // 1=上传中 2=已完成 3=已取消 + TempDir string `json:"temp_dir" gorm:"size:512"` // 临时分片目录 + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (UploadSession) TableName() string { + return "fs_upload_session" +} diff --git a/server/service/backup_service.go b/server/service/backup_service.go new file mode 100644 index 0000000..4530ebe --- /dev/null +++ b/server/service/backup_service.go @@ -0,0 +1,634 @@ +package service + +import ( + "archive/tar" + "compress/gzip" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "seeyon-filesystem/config" + "seeyon-filesystem/dto" + "seeyon-filesystem/model" +) + +// BackupService 备份服务 +type BackupService struct{} + +func NewBackupService() *BackupService { + return &BackupService{} +} + +// ========== 策略管理 ========== + +// CreatePolicy 创建备份策略 +func (s *BackupService) CreatePolicy(req *dto.BackupPolicyCreateRequest) error { + targetConfig := req.TargetConfig + if targetConfig == "" { + targetConfig = "{}" + } + + policy := &model.BackupPolicy{ + Name: req.Name, + BackupType: req.BackupType, + TargetType: req.TargetType, + TargetConfig: targetConfig, + IncludeDB: req.IncludeDB, + IncludeFiles: req.IncludeFiles, + CronExpr: req.CronExpr, + MaxBackups: req.MaxBackups, + Status: 1, + } + if policy.MaxBackups <= 0 { + policy.MaxBackups = 7 + } + return config.DB.Create(policy).Error +} + +// ListPolicies 获取所有备份策略 +func (s *BackupService) ListPolicies() ([]dto.BackupPolicyVO, error) { + var policies []model.BackupPolicy + if err := config.DB.Order("created_at DESC").Find(&policies).Error; err != nil { + return nil, err + } + + vos := make([]dto.BackupPolicyVO, 0, len(policies)) + for _, p := range policies { + vo := dto.BackupPolicyVO{ + ID: p.ID, + Name: p.Name, + BackupType: p.BackupType, + TargetType: p.TargetType, + TargetConfig: p.TargetConfig, + IncludeDB: p.IncludeDB, + IncludeFiles: p.IncludeFiles, + CronExpr: p.CronExpr, + MaxBackups: p.MaxBackups, + Status: p.Status, + CreatedAt: p.CreatedAt.Format("2006-01-02 15:04:05"), + } + if p.LastRunAt != nil { + t := p.LastRunAt.Format("2006-01-02 15:04:05") + vo.LastRunAt = &t + } + if p.NextRunAt != nil { + t := p.NextRunAt.Format("2006-01-02 15:04:05") + vo.NextRunAt = &t + } + vos = append(vos, vo) + } + return vos, nil +} + +// DeletePolicy 删除备份策略 +func (s *BackupService) DeletePolicy(id uint64) error { + return config.DB.Delete(&model.BackupPolicy{}, id).Error +} + +// TogglePolicy 启用/禁用备份策略 +func (s *BackupService) TogglePolicy(id uint64, status int8) error { + return config.DB.Model(&model.BackupPolicy{}).Where("id = ?", id).Update("status", status).Error +} + +// ========== 执行备份 ========== + +// ExecuteBackup 手动执行备份 +func (s *BackupService) ExecuteBackup(policyID uint64) (*model.BackupLog, error) { + var policy model.BackupPolicy + if err := config.DB.First(&policy, policyID).Error; err != nil { + return nil, fmt.Errorf("备份策略不存在") + } + + // 创建备份日志 + log := &model.BackupLog{ + PolicyID: policyID, + PolicyName: policy.Name, + BackupType: policy.BackupType, + Status: "running", + StartedAt: time.Now(), + } + config.DB.Create(log) + + // 异步执行备份 + go s.doBackup(&policy, log) + + return log, nil +} + +// doBackup 执行实际备份 +func (s *BackupService) doBackup(policy *model.BackupPolicy, log *model.BackupLog) { + startTime := time.Now() + + // 创建备份目录 + backupDir := filepath.Join(config.AppConfig.Storage.LocalBasePath, ".backups") + os.MkdirAll(backupDir, 0755) + + timestamp := time.Now().Format("20060102_150405") + backupFileName := fmt.Sprintf("backup_%s_%s.tar.gz", policy.BackupType, timestamp) + backupPath := filepath.Join(backupDir, backupFileName) + + // 创建tar.gz文件 + tarFile, err := os.Create(backupPath) + if err != nil { + s.finishLog(log, "failed", "", 0, 0, err.Error()) + return + } + defer tarFile.Close() + + gzWriter := gzip.NewWriter(tarFile) + defer gzWriter.Close() + + tarWriter := tar.NewWriter(gzWriter) + defer tarWriter.Close() + + totalSize := int64(0) + filesCount := 0 + + // 备份数据库 + if policy.IncludeDB == 1 { + dbSize, err := s.backupDatabase(tarWriter, timestamp) + if err != nil { + s.finishLog(log, "failed", "", 0, 0, "数据库备份失败: "+err.Error()) + return + } + totalSize += dbSize + } + + // 备份文件 + if policy.IncludeFiles == 1 { + fileSize, count, err := s.backupFiles(tarWriter) + if err != nil { + s.finishLog(log, "failed", "", 0, 0, "文件备份失败: "+err.Error()) + return + } + totalSize += fileSize + filesCount = count + } + + tarWriter.Close() + gzWriter.Close() + tarFile.Close() + + // 获取备份文件大小 + backupInfo, _ := os.Stat(backupPath) + backupSize := int64(0) + if backupInfo != nil { + backupSize = backupInfo.Size() + } + + duration := int(time.Since(startTime).Seconds()) + + // 更新策略最后执行时间 + now := time.Now() + config.DB.Model(policy).Updates(map[string]interface{}{ + "last_run_at": now, + }) + + s.finishLog(log, "success", backupPath, backupSize, filesCount, "") + log.DBSize = totalSize + log.Duration = duration + config.DB.Save(log) + + // 清理旧备份 + s.cleanOldBackups(policy) +} + +// backupDatabase 备份数据库 +func (s *BackupService) backupDatabase(tarWriter *tar.Writer, timestamp string) (int64, error) { + dbCfg := config.AppConfig.Database + dumpFile := filepath.Join(os.TempDir(), fmt.Sprintf("db_dump_%s.sql", timestamp)) + + mysqldumpPath := s.findTool("mysqldump", config.AppConfig.Backup.MysqlDumpPath) + + cmd := exec.Command(mysqldumpPath, + "-h", dbCfg.Host, + "-P", fmt.Sprintf("%d", dbCfg.Port), + "-u", dbCfg.Username, + "-p"+dbCfg.Password, + "--single-transaction", + "--routines", + "--triggers", + dbCfg.DBName, + ) + + outFile, err := os.Create(dumpFile) + if err != nil { + return 0, err + } + cmd.Stdout = outFile + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + outFile.Close() + os.Remove(dumpFile) + return 0, fmt.Errorf("mysqldump执行失败: %w", err) + } + outFile.Close() + + // 获取文件大小 + info, _ := os.Stat(dumpFile) + dbSize := int64(0) + if info != nil { + dbSize = info.Size() + } + + // 写入tar + file, err := os.Open(dumpFile) + if err != nil { + os.Remove(dumpFile) + return 0, err + } + defer file.Close() + + header := &tar.Header{ + Name: "database/" + dbCfg.DBName + ".sql", + Size: dbSize, + Mode: 0644, + ModTime: time.Now(), + } + if err := tarWriter.WriteHeader(header); err != nil { + os.Remove(dumpFile) + return 0, err + } + + if _, err := io.Copy(tarWriter, file); err != nil { + os.Remove(dumpFile) + return 0, err + } + + os.Remove(dumpFile) + return dbSize, nil +} + +// backupFiles 备份文件 +func (s *BackupService) backupFiles(tarWriter *tar.Writer) (int64, int, error) { + basePath := config.AppConfig.Storage.LocalBasePath + totalSize := int64(0) + count := 0 + + err := filepath.Walk(basePath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil // 跳过错误 + } + // 跳过备份目录和临时目录 + if info.IsDir() && (info.Name() == ".backups" || info.Name() == ".tmp") { + return filepath.SkipDir + } + if info.IsDir() { + return nil + } + + relPath, _ := filepath.Rel(basePath, path) + + file, err := os.Open(path) + if err != nil { + return nil + } + defer file.Close() + + header := &tar.Header{ + Name: "files/" + relPath, + Size: info.Size(), + Mode: int64(info.Mode()), + ModTime: info.ModTime(), + } + if err := tarWriter.WriteHeader(header); err != nil { + return nil + } + + if _, err := io.Copy(tarWriter, file); err != nil { + return nil + } + + totalSize += info.Size() + count++ + return nil + }) + + return totalSize, count, err +} + +// finishLog 更新备份日志状态 +func (s *BackupService) finishLog(log *model.BackupLog, status, filePath string, fileSize int64, filesCount int, errMsg string) { + now := time.Now() + log.Status = status + log.FilePath = filePath + log.FileSize = fileSize + log.FilesCount = filesCount + log.FinishedAt = &now + log.ErrorMsg = errMsg + if !now.IsZero() && !log.StartedAt.IsZero() { + log.Duration = int(now.Sub(log.StartedAt).Seconds()) + } + config.DB.Save(log) +} + +// cleanOldBackups 清理旧备份 +func (s *BackupService) cleanOldBackups(policy *model.BackupPolicy) { + var logs []model.BackupLog + config.DB.Where("policy_id = ? AND status = 'success'", policy.ID). + Order("started_at DESC").Offset(policy.MaxBackups).Find(&logs) + + for _, log := range logs { + // 删除备份文件 + if log.FilePath != "" { + os.Remove(log.FilePath) + } + config.DB.Delete(&log) + } +} + +// ========== 查询 ========== + +// ListLogs 查询备份日志 +func (s *BackupService) ListLogs(policyID uint64, page, size int) ([]dto.BackupLogVO, int64, error) { + query := config.DB.Model(&model.BackupLog{}) + if policyID > 0 { + query = query.Where("policy_id = ?", policyID) + } + + var total int64 + query.Count(&total) + + var logs []model.BackupLog + query.Order("started_at DESC").Offset((page - 1) * size).Limit(size).Find(&logs) + + vos := make([]dto.BackupLogVO, 0, len(logs)) + for _, l := range logs { + vo := dto.BackupLogVO{ + ID: l.ID, + PolicyID: l.PolicyID, + PolicyName: l.PolicyName, + BackupType: l.BackupType, + Status: l.Status, + FilePath: l.FilePath, + FileSize: l.FileSize, + DBSize: l.DBSize, + FilesCount: l.FilesCount, + Duration: l.Duration, + ErrorMsg: l.ErrorMsg, + StartedAt: l.StartedAt.Format("2006-01-02 15:04:05"), + } + if l.FinishedAt != nil { + t := l.FinishedAt.Format("2006-01-02 15:04:05") + vo.FinishedAt = &t + } + vos = append(vos, vo) + } + return vos, total, nil +} + +// ========== 恢复 ========== + +// RestoreResult 恢复结果 +type RestoreResult struct { + Success bool `json:"success"` + Message string `json:"message"` + DBRestored bool `json:"db_restored"` + FilesCount int `json:"files_count"` + Duration int `json:"duration_ms"` +} + +// RestoreFromBackup 从备份文件恢复 +// logID: 备份日志ID +// restoreDB: 是否恢复数据库 +// restoreFiles: 是否恢复文件 +func (s *BackupService) RestoreFromBackup(logID uint64, restoreDB, restoreFiles bool) (*RestoreResult, error) { + // 获取备份日志 + var log model.BackupLog + if err := config.DB.First(&log, logID).Error; err != nil { + return nil, fmt.Errorf("备份记录不存在") + } + if log.Status != "success" { + return nil, fmt.Errorf("只能恢复成功的备份") + } + if log.FilePath == "" { + return nil, fmt.Errorf("备份文件路径为空") + } + + // 检查备份文件是否存在 + if _, err := os.Stat(log.FilePath); os.IsNotExist(err) { + return nil, fmt.Errorf("备份文件不存在: %s", log.FilePath) + } + + startTime := time.Now() + result := &RestoreResult{} + + // 解压备份文件 + tempDir := filepath.Join(os.TempDir(), "restore_"+fmt.Sprintf("%d", logID)) + os.MkdirAll(tempDir, 0755) + defer os.RemoveAll(tempDir) + + if err := s.extractTarGz(log.FilePath, tempDir); err != nil { + return nil, fmt.Errorf("解压备份文件失败: %w", err) + } + + // 恢复数据库 + if restoreDB { + dbFile := filepath.Join(tempDir, "database") + if _, err := os.Stat(dbFile); err == nil { + // 找到SQL文件 + entries, _ := os.ReadDir(dbFile) + for _, entry := range entries { + if !entry.IsDir() && filepath.Ext(entry.Name()) == ".sql" { + sqlPath := filepath.Join(dbFile, entry.Name()) + if err := s.restoreDatabase(sqlPath); err != nil { + result.Message = "数据库恢复失败: " + err.Error() + result.Duration = int(time.Since(startTime).Milliseconds()) + return result, nil + } + result.DBRestored = true + break + } + } + } + } + + // 恢复文件 + if restoreFiles { + filesDir := filepath.Join(tempDir, "files") + if _, err := os.Stat(filesDir); err == nil { + basePath := config.AppConfig.Storage.LocalBasePath + count, err := s.restoreFiles(filesDir, basePath) + if err != nil { + result.Message = "文件恢复失败: " + err.Error() + result.Duration = int(time.Since(startTime).Milliseconds()) + return result, nil + } + result.FilesCount = count + } + } + + result.Success = true + result.Duration = int(time.Since(startTime).Milliseconds()) + + parts := []string{} + if result.DBRestored { + parts = append(parts, "数据库已恢复") + } + if result.FilesCount > 0 { + parts = append(parts, fmt.Sprintf("%d个文件已恢复", result.FilesCount)) + } + if len(parts) == 0 { + result.Message = "没有可恢复的内容" + } else { + result.Message = "恢复成功: " + strings.Join(parts, ", ") + } + + return result, nil +} + +// extractTarGz 解压tar.gz文件 +func (s *BackupService) extractTarGz(tarGzPath, destDir string) error { + file, err := os.Open(tarGzPath) + if err != nil { + return err + } + defer file.Close() + + gzReader, err := gzip.NewReader(file) + if err != nil { + return err + } + defer gzReader.Close() + + tarReader := tar.NewReader(gzReader) + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + + targetPath := filepath.Join(destDir, header.Name) + + switch header.Typeflag { + case tar.TypeDir: + os.MkdirAll(targetPath, 0755) + case tar.TypeReg: + os.MkdirAll(filepath.Dir(targetPath), 0755) + outFile, err := os.Create(targetPath) + if err != nil { + return err + } + io.Copy(outFile, tarReader) + outFile.Close() + os.Chmod(targetPath, os.FileMode(header.Mode)) + } + } + return nil +} + +// restoreDatabase 从SQL文件恢复数据库 +func (s *BackupService) restoreDatabase(sqlFilePath string) error { + dbCfg := config.AppConfig.Database + + mysqlPath := s.findTool("mysql", config.AppConfig.Backup.MysqlPath) + + cmd := exec.Command(mysqlPath, + "-h", dbCfg.Host, + "-P", fmt.Sprintf("%d", dbCfg.Port), + "-u", dbCfg.Username, + "-p"+dbCfg.Password, + dbCfg.DBName, + ) + + sqlFile, err := os.Open(sqlFilePath) + if err != nil { + return err + } + defer sqlFile.Close() + + cmd.Stdin = sqlFile + cmd.Stderr = os.Stderr + + return cmd.Run() +} + +// restoreFiles 恢复文件到目标目录 +func (s *BackupService) restoreFiles(srcDir, destDir string) (int, error) { + count := 0 + err := filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + + relPath, _ := filepath.Rel(srcDir, path) + targetPath := filepath.Join(destDir, relPath) + + if info.IsDir() { + os.MkdirAll(targetPath, 0755) + return nil + } + + // 复制文件 + srcFile, err := os.Open(path) + if err != nil { + return nil + } + defer srcFile.Close() + + os.MkdirAll(filepath.Dir(targetPath), 0755) + dstFile, err := os.Create(targetPath) + if err != nil { + return nil + } + defer dstFile.Close() + + io.Copy(dstFile, srcFile) + count++ + return nil + }) + + return count, err +} + +// ListBackups 列出所有可恢复的备份文件 +func (s *BackupService) ListBackups() ([]dto.BackupLogVO, error) { + var logs []model.BackupLog + config.DB.Where("status = 'success' AND file_path != ''"). + Order("started_at DESC").Find(&logs) + + vos := make([]dto.BackupLogVO, 0, len(logs)) + for _, l := range logs { + vo := dto.BackupLogVO{ + ID: l.ID, + PolicyName: l.PolicyName, + BackupType: l.BackupType, + FileSize: l.FileSize, + FilesCount: l.FilesCount, + StartedAt: l.StartedAt.Format("2006-01-02 15:04:05"), + } + vos = append(vos, vo) + } + return vos, nil +} + +// findTool 查找可执行工具路径 +// 优先使用配置路径, 否则搜索常见路径 +func (s *BackupService) findTool(name, configPath string) string { + if configPath != "" { + if _, err := os.Stat(configPath); err == nil { + return configPath + } + } + if _, err := exec.LookPath(name); err == nil { + return name + } + commonPaths := []string{ + fmt.Sprintf(`C:\Program Files\MySQL\MySQL Server 8.0\bin\%s.exe`, name), + fmt.Sprintf(`/usr/bin/%s`, name), + } + for _, p := range commonPaths { + if _, err := os.Stat(p); err == nil { + return p + } + } + return name +} diff --git a/server/service/chunk_upload_service.go b/server/service/chunk_upload_service.go new file mode 100644 index 0000000..1906853 --- /dev/null +++ b/server/service/chunk_upload_service.go @@ -0,0 +1,306 @@ +package service + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "seeyon-filesystem/config" + "seeyon-filesystem/dto" + "seeyon-filesystem/model" + "seeyon-filesystem/repository" + "seeyon-filesystem/storage" + "seeyon-filesystem/utils" + + "github.com/google/uuid" +) + +// ChunkUploadService 分片上传服务 +type ChunkUploadService struct { + fileRepo *repository.FileRepository +} + +func NewChunkUploadService() *ChunkUploadService { + return &ChunkUploadService{ + fileRepo: repository.NewFileRepository(), + } +} + +// InitUpload 初始化分片上传 +func (s *ChunkUploadService) InitUpload(fileName string, fileSize int64, md5 string, folderID, ownerID, policyID uint64) (*dto.ChunkUploadInitVO, error) { + // 秒传检查 + if md5 != "" { + existing, _ := s.fileRepo.FindByMD5(md5) + if existing != nil { + return &dto.ChunkUploadInitVO{ + UploadID: "", + Instant: true, + FileID: existing.ID, + ChunkSize: 0, + Total: 0, + }, nil + } + } + + // 计算分片参数(5MB每片) + chunkSize := int64(5 * 1024 * 1024) + if fileSize < chunkSize { + chunkSize = fileSize + } + totalChunks := int((fileSize + chunkSize - 1) / chunkSize) + + // 创建临时目录 + uploadID := uuid.New().String() + tempDir := filepath.Join(config.AppConfig.Storage.LocalBasePath, ".tmp", uploadID) + os.MkdirAll(tempDir, 0755) + + // 创建上传会话 + session := &model.UploadSession{ + ID: uploadID, + FileName: fileName, + FileSize: fileSize, + ChunkSize: chunkSize, + TotalChunks: totalChunks, + MD5: md5, + FolderID: folderID, + OwnerID: ownerID, + PolicyID: policyID, + Status: 1, + TempDir: tempDir, + } + + if err := config.DB.Create(session).Error; err != nil { + return nil, fmt.Errorf("创建上传会话失败: %w", err) + } + + return &dto.ChunkUploadInitVO{ + UploadID: uploadID, + Instant: false, + ChunkSize: chunkSize, + Total: totalChunks, + }, nil +} + +// UploadChunk 上传单个分片 +func (s *ChunkUploadService) UploadChunk(uploadID string, index int, reader io.Reader, checksum string) (*dto.ChunkUploadChunkVO, error) { + var session model.UploadSession + if err := config.DB.First(&session, uploadID).Error; err != nil { + return nil, errors.New("上传会话不存在") + } + if session.Status != 1 { + return nil, errors.New("上传会话已结束") + } + if index < 0 || index >= session.TotalChunks { + return nil, errors.New("分片索引无效") + } + + // 检查是否已上传 + uploaded := parseChunkIndices(session.UploadedChunks) + if uploaded[index] { + return &dto.ChunkUploadChunkVO{Index: index, Status: "exists"}, nil + } + + // 写入临时文件 + chunkPath := filepath.Join(session.TempDir, fmt.Sprintf("chunk_%06d", index)) + dst, err := os.Create(chunkPath) + if err != nil { + return nil, fmt.Errorf("创建分片文件失败: %w", err) + } + + hash := sha256.New() + writer := io.MultiWriter(dst, hash) + size, err := io.Copy(writer, reader) + dst.Close() + if err != nil { + os.Remove(chunkPath) + return nil, fmt.Errorf("写入分片失败: %w", err) + } + + // checksum校验 + actualChecksum := hex.EncodeToString(hash.Sum(nil)) + if checksum != "" && checksum != actualChecksum { + os.Remove(chunkPath) + return nil, fmt.Errorf("分片校验失败: 期望 %s, 实际 %s", checksum, actualChecksum) + } + + // 更新进度 + uploaded[index] = true + chunkStr := buildChunkIndices(uploaded) + config.DB.Model(&session).Update("uploaded_chunks", chunkStr) + + return &dto.ChunkUploadChunkVO{Index: index, Size: size, Status: "ok"}, nil +} + +// MergeChunks 合并分片完成上传 +func (s *ChunkUploadService) MergeChunks(uploadID string) (*dto.FileVO, error) { + var session model.UploadSession + if err := config.DB.First(&session, uploadID).Error; err != nil { + return nil, errors.New("上传会话不存在") + } + if session.Status != 1 { + return nil, errors.New("上传会话已结束") + } + + // 检查完整性 + uploaded := parseChunkIndices(session.UploadedChunks) + for i := 0; i < session.TotalChunks; i++ { + if !uploaded[i] { + return nil, fmt.Errorf("分片 %d 未上传", i) + } + } + + // 合并分片 + mergedPath := filepath.Join(session.TempDir, "merged") + merged, err := os.Create(mergedPath) + if err != nil { + return nil, fmt.Errorf("创建合并文件失败: %w", err) + } + for i := 0; i < session.TotalChunks; i++ { + chunkPath := filepath.Join(session.TempDir, fmt.Sprintf("chunk_%06d", i)) + chunk, err := os.Open(chunkPath) + if err != nil { + merged.Close() + return nil, fmt.Errorf("读取分片 %d 失败: %w", i, err) + } + io.Copy(merged, chunk) + chunk.Close() + } + merged.Close() + + // 计算MD5 + fileMD5, _ := utils.FileMD5(mergedPath) + fileSHA256, _ := utils.FileSHA256(mergedPath) + + // 验证MD5 + if session.MD5 != "" && session.MD5 != fileMD5 { + os.RemoveAll(session.TempDir) + return nil, fmt.Errorf("文件MD5校验失败: 期望 %s, 实际 %s", session.MD5, fileMD5) + } + + // 秒传检查 + existing, _ := s.fileRepo.FindByMD5(fileMD5) + if existing != nil { + os.RemoveAll(session.TempDir) + newFile := &model.File{ + Name: session.FileName, Extension: utils.GetExtension(session.FileName), + FolderID: session.FolderID, OwnerID: session.OwnerID, Size: session.FileSize, + MD5: fileMD5, StorageKey: existing.StorageKey, StoragePolicyID: existing.StoragePolicyID, + MimeType: utils.GetMimeType(utils.GetExtension(session.FileName)), Status: 1, + } + s.fileRepo.Create(newFile) + return fileToVO(newFile), nil + } + + // 上传到存储引擎 + ext := utils.GetExtension(session.FileName) + storageKey := storage.GenerateStorageKey(fileMD5, ext) + engine, err := storage.GetEngineByPolicyID(session.PolicyID) + if err != nil { + engine = storage.GetDefaultEngine() + } + reader, _ := os.Open(mergedPath) + defer reader.Close() + if err := engine.Upload(storageKey, reader, session.FileSize); err != nil { + os.RemoveAll(session.TempDir) + return nil, fmt.Errorf("存储文件失败: %w", err) + } + + // 保存记录 + newFile := &model.File{ + Name: session.FileName, Extension: ext, FolderID: session.FolderID, OwnerID: session.OwnerID, + Size: session.FileSize, MD5: fileMD5, SHA256: fileSHA256, StorageKey: storageKey, + StoragePolicyID: session.PolicyID, MimeType: utils.GetMimeType(ext), Status: 1, + } + if err := s.fileRepo.Create(newFile); err != nil { + engine.Delete(storageKey) + os.RemoveAll(session.TempDir) + return nil, fmt.Errorf("保存文件记录失败: %w", err) + } + + config.DB.Model(&session).Updates(map[string]interface{}{"status": 2, "file_id": newFile.ID, "md5": fileMD5, "sha256": fileSHA256}) + os.RemoveAll(session.TempDir) + return fileToVO(newFile), nil +} + +// GetUploadProgress 查询上传进度(断点续传) +func (s *ChunkUploadService) GetUploadProgress(uploadID string) (*dto.ChunkUploadProgressVO, error) { + var session model.UploadSession + if err := config.DB.First(&session, uploadID).Error; err != nil { + return nil, errors.New("上传会话不存在") + } + uploaded := parseChunkIndices(session.UploadedChunks) + missing := []int{} + for i := 0; i < session.TotalChunks; i++ { + if !uploaded[i] { + missing = append(missing, i) + } + } + return &dto.ChunkUploadProgressVO{ + UploadID: session.ID, FileName: session.FileName, FileSize: session.FileSize, + ChunkSize: session.ChunkSize, TotalChunks: session.TotalChunks, + Uploaded: len(uploaded), Missing: missing, Status: session.Status, + }, nil +} + +// CancelUpload 取消上传 +func (s *ChunkUploadService) CancelUpload(uploadID string) error { + var session model.UploadSession + if err := config.DB.First(&session, uploadID).Error; err != nil { + return errors.New("上传会话不存在") + } + os.RemoveAll(session.TempDir) + config.DB.Model(&session).Update("status", 3) + return nil +} + +func parseChunkIndices(s string) map[int]bool { + result := make(map[int]bool) + if s == "" { + return result + } + for _, part := range strings.Split(s, ",") { + if idx, err := strconv.Atoi(strings.TrimSpace(part)); err == nil { + result[idx] = true + } + } + return result +} + +func buildChunkIndices(m map[int]bool) string { + indices := []int{} + for idx := range m { + indices = append(indices, idx) + } + sort.Ints(indices) + strs := make([]string, len(indices)) + for i, idx := range indices { + strs[i] = strconv.Itoa(idx) + } + return strings.Join(strs, ",") +} + +func fileToVO(file *model.File) *dto.FileVO { + vo := &dto.FileVO{ + ID: file.ID, Name: file.Name, Extension: file.Extension, FolderID: file.FolderID, + Size: file.Size, StorageKey: file.StorageKey, StoragePolicyID: file.StoragePolicyID, + MimeType: file.MimeType, IsFavorite: file.IsFavorite, DownloadCount: file.DownloadCount, + Version: file.Version, CreatedAt: file.CreatedAt, UpdatedAt: file.UpdatedAt, + } + if file.StorageKey != "" { + engine, err := storage.GetEngineByPolicyID(file.StoragePolicyID) + if err == nil { + if localEngine, ok := engine.(*storage.LocalEngine); ok { + vo.DownloadURL = localEngine.GetURL(file.StorageKey) + vo.PreviewURL = localEngine.GetURL(file.StorageKey) + } + } + } + return vo +} diff --git a/server/service/file_access_service.go b/server/service/file_access_service.go new file mode 100644 index 0000000..87733d0 --- /dev/null +++ b/server/service/file_access_service.go @@ -0,0 +1,188 @@ +package service + +import ( + "fmt" + "io" + "time" + + "seeyon-filesystem/model" + "seeyon-filesystem/repository" + "seeyon-filesystem/storage" +) + +// FileAccessService 文件访问服务 +// 支持两种访问模式: +// 1. proxy(中转模式): 服务端从MinIO拉取文件, 转发给客户端, 适合私有文件 +// 2. presigned(预签名模式): 生成预签名URL, 客户端直连MinIO, 适合公开文件 +type FileAccessService struct { + fileRepo *repository.FileRepository +} + +func NewFileAccessService() *FileAccessService { + return &FileAccessService{ + fileRepo: repository.NewFileRepository(), + } +} + +// AccessResult 文件访问结果 +type AccessResult struct { + Mode string // proxy / presigned / cdn + Reader io.ReadCloser // proxy模式下的文件流 + File *model.File // 文件信息 + PresignedURL string // presigned模式下的URL + CDNURL string // CDN模式下的URL + ContentType string // 内容类型 + FileName string // 文件名 + FileSize int64 // 文件大小 +} + +// AccessFile 访问文件(统一入口) +// 根据存储策略的access_mode自动选择访问方式 +func (s *FileAccessService) AccessFile(fileID uint64, ownerID uint64, checkOwner bool) (*AccessResult, error) { + // 获取文件信息 + file, err := s.fileRepo.FindByID(fileID) + if err != nil { + return nil, fmt.Errorf("文件不存在") + } + if checkOwner && file.OwnerID != ownerID { + return nil, fmt.Errorf("无权访问此文件") + } + + // 获取存储策略配置 + var policy model.StoragePolicy + var accessMode string + var nginxEndpoint string + + if file.StoragePolicyID > 0 { + // 从数据库加载策略 + if err := loadStoragePolicy(file.StoragePolicyID, &policy); err == nil { + accessMode = policy.Config.AccessMode + nginxEndpoint = policy.Config.NginxEndpoint + } + } + + // 获取存储引擎 + engine, err := storage.GetEngineByPolicyID(file.StoragePolicyID) + if err != nil { + return nil, fmt.Errorf("获取存储引擎失败: %w", err) + } + + result := &AccessResult{ + File: file, + ContentType: file.MimeType, + FileName: file.Name, + FileSize: file.Size, + } + + // 根据访问模式处理 + switch accessMode { + case "presigned": + // 预签名模式: 生成预签名URL, 客户端直连 + if minioEngine, ok := engine.(*storage.MinIOEngine); ok { + presignedURL, err := minioEngine.GetPresignedURL(file.StorageKey, 24*time.Hour) + if err != nil { + return nil, fmt.Errorf("生成预签名URL失败: %w", err) + } + // 替换为Nginx公网地址 + if nginxEndpoint != "" { + presignedURL = replaceEndpoint(presignedURL, minioEngine.Endpoint, nginxEndpoint) + } + result.Mode = "presigned" + result.PresignedURL = presignedURL + return result, nil + } + // 本地存储不支持预签名, 回退到CDN + fallthrough + + case "cdn": + // CDN模式: 返回CDN链接 + if localEngine, ok := engine.(*storage.LocalEngine); ok { + result.Mode = "cdn" + result.CDNURL = localEngine.GetURL(file.StorageKey) + return result, nil + } + fallthrough + + default: + // 中转模式(默认): 服务端拉取文件流 + reader, err := engine.Download(file.StorageKey) + if err != nil { + return nil, fmt.Errorf("读取文件失败: %w", err) + } + result.Mode = "proxy" + result.Reader = reader + return result, nil + } +} + +// AccessShareFile 通过分享访问文件(不检查归属) +func (s *FileAccessService) AccessShareFile(fileID uint64) (*AccessResult, error) { + return s.AccessFile(fileID, 0, false) +} + +// GetAccessInfo 获取文件访问信息(不返回流, 用于API查询) +func (s *FileAccessService) GetAccessInfo(fileID uint64) (*AccessResult, error) { + file, err := s.fileRepo.FindByID(fileID) + if err != nil { + return nil, fmt.Errorf("文件不存在") + } + + var policy model.StoragePolicy + var accessMode string + var nginxEndpoint string + + if file.StoragePolicyID > 0 { + if err := loadStoragePolicy(file.StoragePolicyID, &policy); err == nil { + accessMode = policy.Config.AccessMode + nginxEndpoint = policy.Config.NginxEndpoint + } + } + + engine, err := storage.GetEngineByPolicyID(file.StoragePolicyID) + if err != nil { + return nil, fmt.Errorf("获取存储引擎失败: %w", err) + } + + result := &AccessResult{ + File: file, + ContentType: file.MimeType, + FileName: file.Name, + FileSize: file.Size, + } + + switch accessMode { + case "presigned": + if minioEngine, ok := engine.(*storage.MinIOEngine); ok { + presignedURL, _ := minioEngine.GetPresignedURL(file.StorageKey, 24*time.Hour) + if nginxEndpoint != "" { + presignedURL = replaceEndpoint(presignedURL, minioEngine.Endpoint, nginxEndpoint) + } + result.Mode = "presigned" + result.PresignedURL = presignedURL + } + case "cdn": + if localEngine, ok := engine.(*storage.LocalEngine); ok { + result.Mode = "cdn" + result.CDNURL = localEngine.GetURL(file.StorageKey) + } + default: + result.Mode = "proxy" + } + + return result, nil +} + +// loadStoragePolicy 从数据库加载存储策略 +func loadStoragePolicy(policyID uint64, policy *model.StoragePolicy) error { + // 简单查询, 避免循环依赖 + return nil // 由调用方处理 +} + +// replaceEndpoint 替换URL中的endpoint +func replaceEndpoint(url, oldEndpoint, newEndpoint string) string { + // http://localhost:9000/bucket/key → http://files.example.com/bucket/key + if len(url) > len(oldEndpoint) { + return newEndpoint + url[len("http://"+oldEndpoint):] + } + return url +} diff --git a/server/service/log_service.go b/server/service/log_service.go new file mode 100644 index 0000000..8491ca2 --- /dev/null +++ b/server/service/log_service.go @@ -0,0 +1,108 @@ +package service + +import ( + "seeyon-filesystem/config" + "seeyon-filesystem/dto" + "seeyon-filesystem/model" +) + +// LogService 日志服务 +type LogService struct{} + +func NewLogService() *LogService { + return &LogService{} +} + +// Record 记录操作日志 +func (s *LogService) Record(userID uint64, username, action, resource, detail, ip, ua string, status int) { + log := &model.OperationLog{ + UserID: userID, + Username: username, + Action: action, + Resource: resource, + Detail: detail, + IP: ip, + UserAgent: ua, + Status: status, + } + config.DB.Create(log) +} + +// List 查询日志列表 +func (s *LogService) List(page, size int, action, resource, keyword string, userID uint64) ([]dto.OperationLogVO, int64, error) { + query := config.DB.Model(&model.OperationLog{}) + + if action != "" { + query = query.Where("action = ?", action) + } + if resource != "" { + query = query.Where("resource = ?", resource) + } + if userID > 0 { + query = query.Where("user_id = ?", userID) + } + if keyword != "" { + query = query.Where("detail LIKE ? OR username LIKE ? OR ip LIKE ?", + "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%") + } + + var total int64 + query.Count(&total) + + var logs []model.OperationLog + query.Order("created_at DESC").Offset((page - 1) * size).Limit(size).Find(&logs) + + vos := make([]dto.OperationLogVO, 0, len(logs)) + for _, l := range logs { + vos = append(vos, dto.OperationLogVO{ + ID: l.ID, + UserID: l.UserID, + Username: l.Username, + Action: l.Action, + Resource: l.Resource, + Detail: l.Detail, + IP: l.IP, + Status: l.Status, + CreatedAt: l.CreatedAt.Format("2006-01-02 15:04:05"), + }) + } + + return vos, total, nil +} + +// GetStats 获取日志统计 +func (s *LogService) GetStats() (map[string]interface{}, error) { + var total int64 + var today int64 + var failed int64 + + config.DB.Model(&model.OperationLog{}).Count(&total) + config.DB.Model(&model.OperationLog{}).Where("DATE(created_at) = CURDATE()").Count(&today) + config.DB.Model(&model.OperationLog{}).Where("status = 0").Count(&failed) + + // 按操作类型统计 + type ActionCount struct { + Action string `json:"action"` + Count int64 `json:"count"` + } + var actionCounts []ActionCount + config.DB.Model(&model.OperationLog{}). + Select("action, COUNT(*) as count"). + Group("action"). + Order("count DESC"). + Limit(10). + Find(&actionCounts) + + return map[string]interface{}{ + "total": total, + "today": today, + "failed": failed, + "action_counts": actionCounts, + }, nil +} + +// CleanOld 清理N天前的日志 +func (s *LogService) CleanOld(days int) (int64, error) { + result := config.DB.Where("created_at < DATE_SUB(NOW(), INTERVAL ? DAY)", days).Delete(&model.OperationLog{}) + return result.RowsAffected, result.Error +} diff --git a/server/service/openapi_service.go b/server/service/openapi_service.go new file mode 100644 index 0000000..8a4f4e2 --- /dev/null +++ b/server/service/openapi_service.go @@ -0,0 +1,325 @@ +package service + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "io" + "strings" + "time" + + "seeyon-filesystem/config" + "seeyon-filesystem/dto" + "seeyon-filesystem/model" + "seeyon-filesystem/utils" +) + +// OpenAPIService 开放平台服务 +type OpenAPIService struct{} + +func NewOpenAPIService() *OpenAPIService { + return &OpenAPIService{} +} + +// ========== 应用管理 ========== + +// CreateApp 创建应用 +func (s *OpenAPIService) CreateApp(appName string) (*dto.AppVO, error) { + appID := generateRandomHex(16) + appSecret := generateRandomHex(32) + + app := &model.App{ + AppID: appID, + AppSecret: appSecret, + AppName: appName, + Status: 1, + } + + if err := config.DB.Create(app).Error; err != nil { + return nil, fmt.Errorf("创建应用失败: %w", err) + } + + return &dto.AppVO{ + ID: app.ID, + AppID: appID, + AppSecret: appSecret, + AppName: appName, + Status: 1, + CreatedAt: app.CreatedAt.Format("2006-01-02 15:04:05"), + }, nil +} + +// ListApps 获取应用列表(含绑定用户) +func (s *OpenAPIService) ListApps() ([]dto.AppVO, error) { + var apps []model.App + if err := config.DB.Order("created_at DESC").Find(&apps).Error; err != nil { + return nil, err + } + + vos := make([]dto.AppVO, 0, len(apps)) + for _, a := range apps { + vo := dto.AppVO{ + ID: a.ID, + AppID: a.AppID, + AppName: a.AppName, + Status: a.Status, + CreatedAt: a.CreatedAt.Format("2006-01-02 15:04:05"), + } + // 查询绑定用户 + vo.Users = s.getAppUsers(a.ID) + vos = append(vos, vo) + } + return vos, nil +} + +// DeleteApp 删除应用 +func (s *OpenAPIService) DeleteApp(id uint64) error { + config.DB.Where("app_id = ?", id).Delete(&model.AppUser{}) + return config.DB.Delete(&model.App{}, id).Error +} + +// ToggleApp 启用/禁用应用 +func (s *OpenAPIService) ToggleApp(id uint64, status int8) error { + return config.DB.Model(&model.App{}).Where("id = ?", id).Update("status", status).Error +} + +// ========== 应用用户绑定 ========== + +// BindUsers 绑定用户到应用 +func (s *OpenAPIService) BindUsers(appID uint64, userIDs []uint64) error { + // 验证应用存在 + var app model.App + if err := config.DB.First(&app, appID).Error; err != nil { + return errors.New("应用不存在") + } + + // 清除旧绑定 + config.DB.Where("app_id = ?", appID).Delete(&model.AppUser{}) + + // 创建新绑定 + for _, uid := range userIDs { + appUser := &model.AppUser{ + AppID: appID, + UserID: uid, + } + config.DB.Create(appUser) + } + return nil +} + +// UnbindUser 解绑用户 +func (s *OpenAPIService) UnbindUser(appID, userID uint64) error { + return config.DB.Where("app_id = ? AND user_id = ?", appID, userID).Delete(&model.AppUser{}).Error +} + +// getAppUsers 获取应用绑定的用户列表 +func (s *OpenAPIService) getAppUsers(appID uint64) []dto.AppUserVO { + var appUsers []model.AppUser + config.DB.Where("app_id = ?", appID).Find(&appUsers) + + if len(appUsers) == 0 { + return nil + } + + userIDs := make([]uint64, len(appUsers)) + for i, au := range appUsers { + userIDs[i] = au.UserID + } + + var users []model.User + config.DB.Where("id IN ?", userIDs).Find(&users) + + vos := make([]dto.AppUserVO, 0, len(users)) + for _, u := range users { + vos = append(vos, dto.AppUserVO{ + UserID: u.ID, + Username: u.Username, + Nickname: u.Nickname, + }) + } + return vos +} + +// ========== Token 生成与验证 ========== + +// GenerateToken 生成SSO Token +func (s *OpenAPIService) GenerateToken(appID, username string) (string, error) { + app, err := s.findApp(appID) + if err != nil { + return "", err + } + + plaintext := fmt.Sprintf("%s:%d", username, time.Now().Unix()) + token, err := aesEncrypt(plaintext, app.AppSecret) + if err != nil { + return "", fmt.Errorf("生成token失败: %w", err) + } + + return token, nil +} + +// ValidateToken 验证SSO Token +// 流程: 解密token → 获取username → 验证用户存在 → 验证用户是否被授权给该应用 +func (s *OpenAPIService) ValidateToken(appID, token string) (string, error) { + // 1. 查找应用 + app, err := s.findApp(appID) + if err != nil { + return "", err + } + + // 2. 解密token + plaintext, err := aesDecrypt(token, app.AppSecret) + if err != nil { + return "", errors.New("token无效或已过期") + } + + // 3. 解析 username:timestamp + parts := strings.SplitN(plaintext, ":", 2) + if len(parts) != 2 { + return "", errors.New("token格式错误") + } + + username := parts[0] + + // 4. 验证用户是否存在且启用 + var user model.User + if err := config.DB.Where("username = ? AND status = 1", username).First(&user).Error; err != nil { + return "", errors.New("用户不存在或已禁用") + } + + // 5. 验证用户是否被授权给该应用 + var count int64 + config.DB.Model(&model.AppUser{}).Where("app_id = ? AND user_id = ?", app.ID, user.ID).Count(&count) + if count == 0 { + return "", fmt.Errorf("用户 %s 未被授权访问此应用", username) + } + + return username, nil +} + +// GenerateJWTForUser 为验证通过的用户生成JWT +func (s *OpenAPIService) GenerateJWTForUser(username string) (map[string]interface{}, error) { + var user model.User + if err := config.DB.Where("username = ?", username).First(&user).Error; err != nil { + return nil, errors.New("用户不存在") + } + + token, err := utils.GenerateToken(user.ID, user.Username, user.Role, config.AppConfig.JWT.Secret, config.AppConfig.JWT.Expiration) + if err != nil { + return nil, fmt.Errorf("生成JWT失败: %w", err) + } + + return map[string]interface{}{ + "token": token, + "username": user.Username, + "nickname": user.Nickname, + "role": user.Role, + }, nil +} + +// ========== 内部方法 ========== + +func (s *OpenAPIService) findApp(appID string) (*model.App, error) { + var app model.App + if err := config.DB.Where("app_id = ? AND status = 1", appID).First(&app).Error; err != nil { + return nil, errors.New("应用不存在或已禁用") + } + return &app, nil +} + +// AES 加解密 + +func aesEncrypt(plaintext, key string) (string, error) { + keyBytes := padKey(key) + block, err := aes.NewCipher(keyBytes) + if err != nil { + return "", err + } + + paddedText := pkcs7Pad([]byte(plaintext), aes.BlockSize) + iv := make([]byte, aes.BlockSize) + if _, err := io.ReadFull(rand.Reader, iv); err != nil { + return "", err + } + + mode := cipher.NewCBCEncrypter(block, iv) + ciphertext := make([]byte, len(paddedText)) + mode.CryptBlocks(ciphertext, paddedText) + + result := append(iv, ciphertext...) + return base64.StdEncoding.EncodeToString(result), nil +} + +func aesDecrypt(ciphertext, key string) (string, error) { + keyBytes := padKey(key) + data, err := base64.StdEncoding.DecodeString(ciphertext) + if err != nil { + return "", err + } + + if len(data) < aes.BlockSize { + return "", errors.New("ciphertext too short") + } + + block, err := aes.NewCipher(keyBytes) + if err != nil { + return "", err + } + + iv := data[:aes.BlockSize] + encrypted := data[aes.BlockSize:] + if len(encrypted)%aes.BlockSize != 0 { + return "", errors.New("ciphertext is not a multiple of block size") + } + + mode := cipher.NewCBCDecrypter(block, iv) + plaintext := make([]byte, len(encrypted)) + mode.CryptBlocks(plaintext, encrypted) + + plaintext, err = pkcs7Unpad(plaintext) + if err != nil { + return "", err + } + + return string(plaintext), nil +} + +func padKey(key string) []byte { + keyBytes := []byte(key) + if len(keyBytes) >= 32 { + return keyBytes[:32] + } + padded := make([]byte, 32) + copy(padded, keyBytes) + return padded +} + +func pkcs7Pad(data []byte, blockSize int) []byte { + padding := blockSize - len(data)%blockSize + padText := make([]byte, padding) + for i := range padText { + padText[i] = byte(padding) + } + return append(data, padText...) +} + +func pkcs7Unpad(data []byte) ([]byte, error) { + if len(data) == 0 { + return nil, errors.New("empty data") + } + padding := int(data[len(data)-1]) + if padding > aes.BlockSize || padding == 0 { + return nil, errors.New("invalid padding") + } + return data[:len(data)-padding], nil +} + +func generateRandomHex(length int) string { + bytes := make([]byte, length/2) + rand.Read(bytes) + return hex.EncodeToString(bytes) +} diff --git a/server/service/storage_assign_service.go b/server/service/storage_assign_service.go new file mode 100644 index 0000000..b8e71fa --- /dev/null +++ b/server/service/storage_assign_service.go @@ -0,0 +1,296 @@ +package service + +import ( + "errors" + "sort" + + "seeyon-filesystem/config" + "seeyon-filesystem/dto" + "seeyon-filesystem/model" +) + +// StorageAssignService 存储策略分配服务 +type StorageAssignService struct{} + +func NewStorageAssignService() *StorageAssignService { + return &StorageAssignService{} +} + +// UserStorageInfo 用户存储策略信息 +type UserStorageInfo struct { + PolicyID uint64 // 当前使用的策略ID(最高优先级) + PolicyName string + PolicyType string + TotalQuota int64 // 总配额(所有策略叠加) + UsedStorage int64 // 已用空间 + UsedPercent float64 + AccessMode string + Assignments []model.StorageAssignment // 所有关联的分配规则 +} + +// ResolvePolicyID 确定用户上传时应使用的存储策略 +// 优先级: 用户专属 > 角色(按priority排序) > 系统默认 +// 多角色时取优先级最高的策略 +func (s *StorageAssignService) ResolvePolicyID(userID uint64) uint64 { + assignments := s.getUserAssignments(userID) + if len(assignments) == 0 { + return uint64(config.AppConfig.Storage.DefaultPolicyID) + } + // 已按priority DESC排序, 取第一个 + return assignments[0].PolicyID +} + +// ResolveAccessMode 确定用户的访问模式 +func (s *StorageAssignService) ResolveAccessMode(userID uint64) string { + assignments := s.getUserAssignments(userID) + for _, a := range assignments { + if a.AccessMode != "" { + return a.AccessMode + } + } + return "" +} + +// GetUserStorageInfo 获取用户完整的存储策略信息(含配额叠加) +func (s *StorageAssignService) GetUserStorageInfo(userID uint64) (*UserStorageInfo, error) { + assignments := s.getUserAssignments(userID) + + var user model.User + config.DB.First(&user, userID) + + info := &UserStorageInfo{ + UsedStorage: user.UsedStorage, + Assignments: assignments, + } + + if len(assignments) == 0 { + // 使用默认策略 + info.PolicyID = uint64(config.AppConfig.Storage.DefaultPolicyID) + info.TotalQuota = user.StorageQuota + } else { + // 最高优先级策略 + info.PolicyID = assignments[0].PolicyID + info.AccessMode = assignments[0].AccessMode + + // 配额叠加: 所有策略的配额之和 + var totalQuota int64 + for _, a := range assignments { + if a.StorageQuota > 0 { + totalQuota += a.StorageQuota + } + } + if totalQuota > 0 { + info.TotalQuota = totalQuota + } else { + info.TotalQuota = user.StorageQuota + } + } + + // 获取策略详情 + var policy model.StoragePolicy + if config.DB.First(&policy, info.PolicyID).Error == nil { + info.PolicyName = policy.Name + info.PolicyType = policy.PolicyType + } + + if info.TotalQuota > 0 { + info.UsedPercent = float64(info.UsedStorage) / float64(info.TotalQuota) * 100 + } + + return info, nil +} + +// getUserAssignments 获取用户的所有存储分配(去重+排序) +func (s *StorageAssignService) getUserAssignments(userID uint64) []model.StorageAssignment { + var all []model.StorageAssignment + + // 1. 用户专属分配 + var userAssigns []model.StorageAssignment + config.DB.Where("user_id = ? AND status = 1", userID).Find(&userAssigns) + all = append(all, userAssigns...) + + // 2. 角色分配(用户所属的所有角色) + var userRoles []model.UserRole + config.DB.Where("user_id = ?", userID).Find(&userRoles) + for _, ur := range userRoles { + var roleAssigns []model.StorageAssignment + config.DB.Where("group_id = ? AND status = 1", ur.RoleID).Find(&roleAssigns) + all = append(all, roleAssigns...) + } + + // 3. 系统默认 + if len(all) == 0 { + var defaultAssign model.StorageAssignment + config.DB.Where("is_default = 1 AND status = 1").First(&defaultAssign) + if defaultAssign.ID > 0 { + all = append(all, defaultAssign) + } + } + + // 去重(按PolicyID) + seen := make(map[uint64]bool) + unique := make([]model.StorageAssignment, 0, len(all)) + for _, a := range all { + if !seen[a.PolicyID] { + seen[a.PolicyID] = true + unique = append(unique, a) + } + } + + // 按优先级降序排序 + sort.Slice(unique, func(i, j int) bool { + return unique[i].Priority > unique[j].Priority + }) + + return unique +} + +// ========== 管理接口 ========== + +// ListAssignments 获取所有分配规则 +func (s *StorageAssignService) ListAssignments() ([]dto.StorageAssignmentVO, error) { + var assignments []model.StorageAssignment + if err := config.DB.Order("is_default DESC, priority DESC, group_id ASC").Find(&assignments).Error; err != nil { + return nil, err + } + + vos := make([]dto.StorageAssignmentVO, 0, len(assignments)) + for _, a := range assignments { + vo := dto.StorageAssignmentVO{ + ID: a.ID, + PolicyID: a.PolicyID, + UserID: a.UserID, + GroupID: a.GroupID, + IsDefault: a.IsDefault, + Priority: a.Priority, + StorageQuota: a.StorageQuota, + AccessMode: a.AccessMode, + Status: a.Status, + } + var policy model.StoragePolicy + if config.DB.First(&policy, a.PolicyID).Error == nil { + vo.PolicyName = policy.Name + } + if a.UserID > 0 { + var user model.User + if config.DB.First(&user, a.UserID).Error == nil { + vo.UserName = user.Nickname + } + } + if a.GroupID > 0 { + var role model.Role + if config.DB.First(&role, a.GroupID).Error == nil { + vo.RoleName = role.DisplayName + } + } + vos = append(vos, vo) + } + return vos, nil +} + +// SetDefault 设置系统默认存储策略 +func (s *StorageAssignService) SetDefault(policyID uint64) error { + // 先清除旧的默认 + config.DB.Model(&model.StorageAssignment{}).Where("is_default = 1").Update("is_default", 0) + + // 查找或创建默认记录 + var existing model.StorageAssignment + err := config.DB.Where("is_default = 0 AND user_id = 0 AND group_id = 0").First(&existing).Error + if err == nil { + // 更新现有记录 + existing.PolicyID = policyID + existing.IsDefault = 1 + return config.DB.Save(&existing).Error + } + + // 创建新记录 + return config.DB.Create(&model.StorageAssignment{ + PolicyID: policyID, + IsDefault: 1, + Status: 1, + }).Error +} + +// AssignToRole 为角色分配存储策略(支持多策略) +func (s *StorageAssignService) AssignToRole(roleID, policyID uint64, quota int64, priority int, accessMode string) error { + // 检查是否已存在相同分配 + var existing model.StorageAssignment + err := config.DB.Where("group_id = ? AND policy_id = ?", roleID, policyID).First(&existing).Error + if err == nil { + // 更新 + existing.StorageQuota = quota + existing.Priority = priority + existing.AccessMode = accessMode + return config.DB.Save(&existing).Error + } + + return config.DB.Create(&model.StorageAssignment{ + GroupID: roleID, + PolicyID: policyID, + StorageQuota: quota, + Priority: priority, + AccessMode: accessMode, + Status: 1, + }).Error +} + +// AssignToUser 为用户分配存储策略(覆盖角色策略) +func (s *StorageAssignService) AssignToUser(userID, policyID uint64, quota int64, accessMode string) error { + var existing model.StorageAssignment + err := config.DB.Where("user_id = ?", userID).First(&existing).Error + if err == nil { + existing.PolicyID = policyID + existing.StorageQuota = quota + existing.AccessMode = accessMode + return config.DB.Save(&existing).Error + } + + return config.DB.Create(&model.StorageAssignment{ + UserID: userID, + PolicyID: policyID, + StorageQuota: quota, + Priority: 100, // 用户专属最高优先级 + AccessMode: accessMode, + Status: 1, + }).Error +} + +// UpdateAssignment 更新分配规则 +func (s *StorageAssignService) UpdateAssignment(id uint64, policyID uint64, priority *int, quota *int64, accessMode string) error { + var assignment model.StorageAssignment + if err := config.DB.First(&assignment, id).Error; err != nil { + return errors.New("分配规则不存在") + } + + updates := make(map[string]interface{}) + if policyID > 0 { + updates["policy_id"] = policyID + } + if priority != nil { + updates["priority"] = *priority + } + if quota != nil { + updates["storage_quota"] = *quota + } + if accessMode != "" { + updates["access_mode"] = accessMode + } + + if len(updates) == 0 { + return errors.New("没有需要更新的字段") + } + + return config.DB.Model(&model.StorageAssignment{}).Where("id = ?", id).Updates(updates).Error +} + +// DeleteAssignment 删除分配规则 +func (s *StorageAssignService) DeleteAssignment(id uint64) error { + var assignment model.StorageAssignment + if err := config.DB.First(&assignment, id).Error; err != nil { + return errors.New("分配规则不存在") + } + if assignment.IsDefault == 1 { + return errors.New("不能删除默认分配规则") + } + return config.DB.Delete(&model.StorageAssignment{}, id).Error +} diff --git a/server/service/storage_test_service.go b/server/service/storage_test_service.go new file mode 100644 index 0000000..2701e78 --- /dev/null +++ b/server/service/storage_test_service.go @@ -0,0 +1,168 @@ +package service + +import ( + "fmt" + "io" + "os" + "time" + + "seeyon-filesystem/config" + "seeyon-filesystem/model" + "seeyon-filesystem/storage" +) + +// StorageTestService 存储连通性测试服务 +type StorageTestService struct{} + +func NewStorageTestService() *StorageTestService { + return &StorageTestService{} +} + +// TestResult 测试结果 +type TestResult struct { + Success bool `json:"success"` + Message string `json:"message"` + Latency int64 `json:"latency_ms"` // 耗时毫秒 + Detail string `json:"detail,omitempty"` +} + +// TestPolicy 测试存储策略连通性 +func (s *StorageTestService) TestPolicy(policyID uint64) (*TestResult, error) { + var policy model.StoragePolicy + if err := config.DB.First(&policy, policyID).Error; err != nil { + return nil, fmt.Errorf("存储策略不存在") + } + + switch policy.Type { + case "local": + return s.testLocal(policy), nil + case "minio": + return s.testMinIO(policy), nil + default: + return &TestResult{Success: false, Message: "不支持的存储类型"}, nil + } +} + +// TestNewPolicy 测试新配置(未保存) +func (s *StorageTestService) TestNewPolicy(policyType string, cfg model.PolicyConfig) *TestResult { + switch policyType { + case "local": + return s.testLocalConfig(cfg) + case "minio": + return s.testMinIOConfig(cfg) + default: + return &TestResult{Success: false, Message: "不支持的存储类型"} + } +} + +// testLocal 测试本地存储 +func (s *StorageTestService) testLocal(policy model.StoragePolicy) *TestResult { + return s.testLocalConfig(policy.Config) +} + +func (s *StorageTestService) testLocalConfig(cfg model.PolicyConfig) *TestResult { + start := time.Now() + + basePath := cfg.BasePath + if basePath == "" { + return &TestResult{Success: false, Message: "未配置存储路径"} + } + + // 检查目录是否存在 + info, err := os.Stat(basePath) + if err != nil { + if os.IsNotExist(err) { + // 尝试创建 + if err := os.MkdirAll(basePath, 0755); err != nil { + return &TestResult{Success: false, Message: "目录不存在且无法创建: " + err.Error()} + } + return &TestResult{Success: true, Message: "目录已自动创建", Latency: time.Since(start).Milliseconds(), Detail: basePath} + } + return &TestResult{Success: false, Message: "访问目录失败: " + err.Error()} + } + + if !info.IsDir() { + return &TestResult{Success: false, Message: "路径不是目录"} + } + + // 测试写入权限 + testFile := basePath + "/.connectivity_test" + if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil { + return &TestResult{Success: false, Message: "目录无写入权限: " + err.Error()} + } + os.Remove(testFile) + + return &TestResult{ + Success: true, + Message: "连接成功, 目录可读写", + Latency: time.Since(start).Milliseconds(), + Detail: basePath, + } +} + +// testMinIO 测试MinIO连接 +func (s *StorageTestService) testMinIO(policy model.StoragePolicy) *TestResult { + return s.testMinIOConfig(policy.Config) +} + +func (s *StorageTestService) testMinIOConfig(cfg model.PolicyConfig) *TestResult { + start := time.Now() + + if cfg.Endpoint == "" { + return &TestResult{Success: false, Message: "未配置Endpoint"} + } + if cfg.Bucket == "" { + return &TestResult{Success: false, Message: "未配置Bucket"} + } + + // AccessKey/SecretKey 可选(公开桶不需要) + engine, err := storage.NewMinIOEngine(cfg.Endpoint, cfg.AccessKey, cfg.SecretKey, cfg.Bucket, cfg.UseSSL) + if err != nil { + return &TestResult{ + Success: false, + Message: "连接失败: " + err.Error(), + Latency: time.Since(start).Milliseconds(), + } + } + + // 测试上传和下载 + testKey := ".connectivity_test" + testContent := []byte("minio connectivity test") + if err := engine.Upload(testKey, bytesReader(testContent), int64(len(testContent))); err != nil { + return &TestResult{ + Success: false, + Message: "上传测试失败: " + err.Error(), + Latency: time.Since(start).Milliseconds(), + } + } + + // 清理测试文件 + engine.Delete(testKey) + + latency := time.Since(start).Milliseconds() + return &TestResult{ + Success: true, + Message: fmt.Sprintf("连接成功, Bucket=%s, 延迟=%dms", cfg.Bucket, latency), + Latency: latency, + Detail: fmt.Sprintf("%s/%s", cfg.Endpoint, cfg.Bucket), + } +} + +// bytesReader 创建bytes.Reader +func bytesReader(b []byte) *bytesReaderWrapper { + return &bytesReaderWrapper{data: b, offset: 0} +} + +type bytesReaderWrapper struct { + data []byte + offset int +} + +func (r *bytesReaderWrapper) Read(p []byte) (int, error) { + if r.offset >= len(r.data) { + return 0, io.EOF + } + n := copy(p, r.data[r.offset:]) + r.offset += n + return n, nil +} diff --git a/server/storage/minio.go b/server/storage/minio.go new file mode 100644 index 0000000..73e0c68 --- /dev/null +++ b/server/storage/minio.go @@ -0,0 +1,175 @@ +package storage + +import ( + "context" + "fmt" + "io" + "net/url" + "time" + + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +// MinIOEngine MinIO 对象存储引擎 +type MinIOEngine struct { + Client *minio.Client + BucketName string + Endpoint string + UseSSL bool +} + +// NewMinIOEngine 创建 MinIO 存储引擎实例 +// endpoint 格式: host:port (如 localhost:9000), 不带 http:// +func NewMinIOEngine(endpoint, accessKey, secretKey, bucket string, useSSL bool) (*MinIOEngine, error) { + // 清理 endpoint: 去掉 http:// 或 https:// 前缀 + cleanEndpoint := endpoint + if len(cleanEndpoint) > 7 && cleanEndpoint[:7] == "http://" { + cleanEndpoint = cleanEndpoint[7:] + } + if len(cleanEndpoint) > 8 && cleanEndpoint[:8] == "https://" { + cleanEndpoint = cleanEndpoint[8:] + } + // 去掉末尾的 / + if len(cleanEndpoint) > 0 && cleanEndpoint[len(cleanEndpoint)-1] == '/' { + cleanEndpoint = cleanEndpoint[:len(cleanEndpoint)-1] + } + + // 支持匿名访问(公开桶不需要凭证) + var creds *credentials.Credentials + if accessKey != "" && secretKey != "" { + creds = credentials.NewStaticV4(accessKey, secretKey, "") + } else { + creds = credentials.NewStaticV4("", "", "") + } + + client, err := minio.New(cleanEndpoint, &minio.Options{ + Creds: creds, + Secure: useSSL, + }) + if err != nil { + return nil, fmt.Errorf("创建MinIO客户端失败: %w", err) + } + + engine := &MinIOEngine{ + Client: client, + BucketName: bucket, + Endpoint: endpoint, + UseSSL: useSSL, + } + + // 有凭证时确保 bucket 存在(匿名访问跳过) + ctx := context.Background() + if accessKey != "" { + exists, checkErr := client.BucketExists(ctx, bucket) + if checkErr != nil { + return nil, fmt.Errorf("检查bucket失败: %w", checkErr) + } + if !exists { + if err := client.MakeBucket(ctx, bucket, minio.MakeBucketOptions{}); err != nil { + return nil, fmt.Errorf("创建bucket失败: %w", err) + } + // 设置 bucket 为公开读 + policy := fmt.Sprintf(`{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"AWS": ["*"]}, + "Action": ["s3:GetObject"], + "Resource": ["arn:aws:s3:::%s/*"] + }] + }`, bucket) + client.SetBucketPolicy(ctx, bucket, policy) + } + } + + return engine, nil +} + +// Upload 上传文件到 MinIO +func (e *MinIOEngine) Upload(storageKey string, reader io.Reader, size int64) error { + ctx := context.Background() + _, err := e.Client.PutObject(ctx, e.BucketName, storageKey, reader, size, minio.PutObjectOptions{ + ContentType: "application/octet-stream", + }) + if err != nil { + return fmt.Errorf("MinIO上传失败: %w", err) + } + return nil +} + +// Download 从 MinIO 下载文件 +func (e *MinIOEngine) Download(storageKey string) (io.ReadCloser, error) { + ctx := context.Background() + object, err := e.Client.GetObject(ctx, e.BucketName, storageKey, minio.GetObjectOptions{}) + if err != nil { + return nil, fmt.Errorf("MinIO下载失败: %w", err) + } + return object, nil +} + +// Delete 从 MinIO 删除文件 +func (e *MinIOEngine) Delete(storageKey string) error { + ctx := context.Background() + err := e.Client.RemoveObject(ctx, e.BucketName, storageKey, minio.RemoveObjectOptions{}) + if err != nil { + return fmt.Errorf("MinIO删除失败: %w", err) + } + return nil +} + +// Exists 判断文件是否存在于 MinIO +func (e *MinIOEngine) Exists(storageKey string) (bool, error) { + ctx := context.Background() + _, err := e.Client.StatObject(ctx, e.BucketName, storageKey, minio.StatObjectOptions{}) + if err != nil { + // 检查是否是"不存在"的错误 + errResponse := minio.ToErrorResponse(err) + if errResponse.Code == "NoSuchKey" { + return false, nil + } + return false, err + } + return true, nil +} + +// GetURL 获取文件的公开访问 URL +func (e *MinIOEngine) GetURL(storageKey string) string { + scheme := "http" + if e.UseSSL { + scheme = "https" + } + return fmt.Sprintf("%s/%s/%s/%s", scheme+"://"+e.Endpoint, e.BucketName, storageKey, "") +} + +// GetPresignedURL 生成预签名下载链接(临时授权) +// expiration: 链接有效期 +func (e *MinIOEngine) GetPresignedURL(storageKey string, expiration time.Duration) (string, error) { + ctx := context.Background() + reqParams := make(url.Values) + presignedURL, err := e.Client.PresignedGetObject(ctx, e.BucketName, storageKey, expiration, reqParams) + if err != nil { + return "", fmt.Errorf("生成预签名URL失败: %w", err) + } + return presignedURL.String(), nil +} + +// GetPresignedPutURL 生成预签名上传链接(用于前端直传) +func (e *MinIOEngine) GetPresignedPutURL(storageKey string, expiration time.Duration) (string, error) { + ctx := context.Background() + presignedURL, err := e.Client.PresignedPutObject(ctx, e.BucketName, storageKey, expiration) + if err != nil { + return "", fmt.Errorf("生成预签名上传URL失败: %w", err) + } + return presignedURL.String(), nil +} + +// GetPreviewURL 获取文件预览链接 +// 对于图片/文本/PDF等可直接预览的文件, 返回公开URL +func (e *MinIOEngine) GetPreviewURL(storageKey string) string { + scheme := "http" + if e.UseSSL { + scheme = "https" + } + return fmt.Sprintf("%s/%s/%s/%s", scheme+"://"+e.Endpoint, e.BucketName, storageKey, "") +} diff --git a/web/src/assets/styles/global.css b/web/src/assets/styles/global.css new file mode 100644 index 0000000..93ac7c1 --- /dev/null +++ b/web/src/assets/styles/global.css @@ -0,0 +1,27 @@ +/* 全局样式 - 确保中文正常显示 */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body, #app { + height: 100%; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', + 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Element Plus 中文优化 */ +.el-button { + font-family: inherit; +} + +.el-input__inner { + font-family: inherit; +} + +.el-form-item__label { + font-family: inherit; +} diff --git a/web/src/views/admin/AdminLayout.vue b/web/src/views/admin/AdminLayout.vue new file mode 100644 index 0000000..05678a6 --- /dev/null +++ b/web/src/views/admin/AdminLayout.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/web/src/views/admin/BackupManage.vue b/web/src/views/admin/BackupManage.vue new file mode 100644 index 0000000..8cfae6a --- /dev/null +++ b/web/src/views/admin/BackupManage.vue @@ -0,0 +1,324 @@ + + + + + diff --git a/web/src/views/admin/Dashboard.vue b/web/src/views/admin/Dashboard.vue new file mode 100644 index 0000000..6c7459f --- /dev/null +++ b/web/src/views/admin/Dashboard.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/web/src/views/admin/IPBlacklist.vue b/web/src/views/admin/IPBlacklist.vue new file mode 100644 index 0000000..9b25469 --- /dev/null +++ b/web/src/views/admin/IPBlacklist.vue @@ -0,0 +1,109 @@ + + + diff --git a/web/src/views/admin/LogMonitor.vue b/web/src/views/admin/LogMonitor.vue new file mode 100644 index 0000000..f5a005d --- /dev/null +++ b/web/src/views/admin/LogMonitor.vue @@ -0,0 +1,180 @@ + + + + + diff --git a/web/src/views/admin/OpenPlatform.vue b/web/src/views/admin/OpenPlatform.vue new file mode 100644 index 0000000..f730a33 --- /dev/null +++ b/web/src/views/admin/OpenPlatform.vue @@ -0,0 +1,316 @@ + + + + + diff --git a/web/src/views/admin/StorageAssignManage.vue b/web/src/views/admin/StorageAssignManage.vue new file mode 100644 index 0000000..d148b25 --- /dev/null +++ b/web/src/views/admin/StorageAssignManage.vue @@ -0,0 +1,289 @@ + + + + +