diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore new file mode 100644 index 0000000..d20c0fe --- /dev/null +++ b/.codegraph/.gitignore @@ -0,0 +1,5 @@ +# CodeGraph data files — local to each machine, not for committing. +# Ignore everything in .codegraph/ except this file itself, so transient +# files (the database, daemon.pid, sockets, logs) never show up in git. +* +!.gitignore diff --git a/README.md b/README.md new file mode 100644 index 0000000..cc0eacd --- /dev/null +++ b/README.md @@ -0,0 +1,202 @@ +# 文件存储系统 (FileStorageSystem) + +一个类似七牛云的文件存储系统,提供用户注册、项目隔离存储、API文件操作等功能。 + +## 环境配置 + +### 前端环境配置 + +#### 开发环境 (.env.development) +``` +VITE_API_BASE_URL=http://localhost:8100/api/v1 +VITE_APP_TITLE=云泽文件存储云平台 (开发) +VITE_APP_ENV=development +``` + +#### 生产环境 (.env.production) +``` +VITE_API_BASE_URL=https://file.yunzer.cn/api/v1 +VITE_APP_TITLE=云泽文件存储云平台 +VITE_APP_ENV=production +``` + +### 后端配置 + +#### 配置文件 (go/config.yaml) +```yaml +server: + port: 8100 + mode: debug + domain: https://file.yunzer.cn +``` + +#### 环境变量 +- `FSS_SERVER_PORT`: 服务器端口 +- `FSS_SERVER_MODE`: 运行模式 (debug/release) +- `FSS_SERVER_DOMAIN`: 生产环境域名 +- `FSS_JWT_SECRET`: JWT密钥 +- `FSS_JWT_EXPIRE`: JWT过期时间 + +## 功能概述 + +### 1. 用户系统 +- **用户注册/登录**:支持邮箱/手机号注册,JWT认证 +- **API密钥管理**:每个用户可生成多个API Key,用于程序化访问 +- **用户信息管理**:个人资料、密码修改、账户设置 +- **角色权限**:基于角色的访问控制(RBAC),灵活配置权限 +- **账户安全**:登录失败锁定、密码强度要求 + +### 2. 项目管理 +- **项目创建**:用户可创建多个独立项目(如项目A、项目B) +- **项目隔离**:每个项目拥有独立的存储空间和文件命名空间 +- **项目配额**:可设置每个项目的存储容量限制 +- **项目成员**(可选):支持项目级别的权限管理 + +### 3. 文件管理 +- **文件上传**:支持单文件/批量上传,断点续传 +- **文件下载**:直接下载或生成临时下载链接 +- **文件删除**:软删除/硬删除,回收站机制 +- **文件元数据**:文件名、大小、类型、上传时间、MD5校验 +- **文件目录**:支持虚拟目录结构组织文件 +- **文件访问控制**:公开/私有文件设置,访问密码保护 +- **临时链接**:生成带过期时间的临时访问URL +- **文件预览**:图片、视频、文档在线预览(可选) + +### 4. API接口 +- **RESTful API**:标准REST风格接口 +- **认证方式**:API Key + Secret签名认证 +- **SDK支持**:提供Go/Python/JavaScript SDK(后期) + +### 5. 存储引擎 +- **本地存储**:文件存储在本地文件系统 +- **元数据存储**:SQLite数据库存储文件元数据、用户信息等 +- **文件去重**:基于MD5的文件去重,节省存储空间 +- **存储统计**:项目/用户级别的存储使用统计 +- **流量统计**:记录上传/下载流量,便于计费分析 +- **自定义域名**:支持绑定自定义域名访问文件(可选) + +### 6. 安全特性 +- **API认证**:HMAC-SHA256签名认证 +- **访问控制**:项目级别的文件访问隔离 +- **文件限制**:可配置的文件大小、类型限制 + +### 7. 系统管理 +- **系统设置**:站点名称、站点描述、Logo配置、备案信息等 +- **注册开关**:控制是否开放用户注册功能 +- **站点开关**:站点维护模式,临时关闭站点访问 +- **操作日志**:记录用户关键操作(登录、文件操作、项目管理等) +- **系统日志**:记录系统运行日志(错误、警告、调试信息) +- **存储配置**:默认存储配额、文件大小限制、允许的文件类型 +- **邮件配置**:SMTP设置,用于发送通知邮件(可选) +- **用户管理**:用户列表、封禁/解封、角色分配、密码重置 +- **角色管理**:创建/编辑/删除角色,配置角色权限 +- **权限管理**:查看系统权限点,按模块分组 +- **API限流**:请求频率限制,防止滥用 +- **Webhook回调**:文件上传/删除等事件的HTTP回调通知 +- **数据备份**:数据库和文件的手动/自动备份 + +## 技术架构 + +### 后端技术栈 +- **语言**:Go 1.21+ +- **Web框架**:Gin (高性能HTTP框架) +- **数据库**:SQLite (嵌入式数据库) +- **ORM**:GORM (Go ORM库) +- **配置管理**:Viper +- **日志**:Zap (高性能日志库) +- **认证**:JWT + API Key签名 +- **限流**:golang.org/x/time/rate (令牌桶限流) + +### 前端技术栈 +- **框架**:Vue 3 + TypeScript +- **UI组件**:Element Plus +- **构建工具**:Vite +- **HTTP客户端**:Axios +- **状态管理**:Pinia + +## 快速开始 + +### 环境要求 +- Go 1.21+ +- SQLite 3 +- Node.js 16+ +- npm 或 yarn + +### 后端安装和运行 +```bash +# 进入Go后端目录 +cd go + +# 安装依赖 +go mod tidy + +# 运行程序 +go run main.go +``` + +### 前端安装和运行 +```bash +# 进入前端目录 +cd backend + +# 安装依赖 +npm install + +# 启动开发服务器 +npm run dev +``` + +### 配置文件 + +#### 后端配置 (go/config.yaml) +```yaml +server: + port: 8100 + mode: debug + domain: https://file.yunzer.cn + +database: + driver: sqlite + source: ./storage/data.db + +storage: + root: ./storage/files + backups: ./storage/backups + +admin: + username: admin + password: admin123 # 首次运行后请修改 + email: admin@example.com + role: super_admin + +jwt: + secret: please-change-this-secret-key # 生产环境请修改为随机密钥 + expire: 24h + +log: + level: info + dir: ./storage/logs + max_days: 7 +``` + +#### 前端配置 +- 开发环境:`.env.development` +- 生产环境:`.env.production` + +## 详细文档 + +更多详细信息请查看 [docs/README.md](docs/README.md) + +## 注意事项 + +1. **安全性**:生产环境需要修改JWT密钥,启用HTTPS +2. **性能**:大量文件建议使用对象存储服务(如MinIO、阿里云OSS) +3. **备份**:定期备份SQLite数据库和文件存储目录 +4. **监控**:建议添加Prometheus监控指标 +5. **日志管理**:定期清理过期日志,避免数据库过大 +6. **站点维护**:开启维护模式时,确保管理员仍可访问后台 +7. **权限控制**:管理员账户需在配置文件中预设,避免权限滥用 + +## 许可证 + +MIT License \ No newline at end of file diff --git a/backend/.env.development b/backend/.env.development new file mode 100644 index 0000000..9d57d8a --- /dev/null +++ b/backend/.env.development @@ -0,0 +1,4 @@ +# 开发环境配置(本地调试) +VITE_API_BASE_URL=http://localhost:8100/api/v1 +VITE_APP_TITLE=云泽文件存储云平台 (开发) +VITE_APP_ENV=development \ No newline at end of file diff --git a/backend/.env.production b/backend/.env.production new file mode 100644 index 0000000..b021c48 --- /dev/null +++ b/backend/.env.production @@ -0,0 +1,4 @@ +# 生产环境配置 +VITE_API_BASE_URL=https://file.yunzer.cn/api/v1 +VITE_APP_TITLE=云泽文件存储云平台 +VITE_APP_ENV=production \ No newline at end of file diff --git a/backend/index.html b/backend/index.html index 03aaa0f..319f97b 100644 --- a/backend/index.html +++ b/backend/index.html @@ -1,10 +1,10 @@ - + - backend + 云泽文件存储云平台
diff --git a/backend/package-lock.json b/backend/package-lock.json index 139cf50..5350916 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -8,7 +8,11 @@ "name": "backend", "version": "0.0.0", "dependencies": { - "vue": "^3.5.40" + "@element-plus/icons-vue": "^2.3.2", + "axios": "^1.19.0", + "element-plus": "^2.14.5", + "vue": "^3.5.40", + "vue-router": "^4.6.4" }, "devDependencies": { "@types/node": "^24.13.3", @@ -65,6 +69,49 @@ "node": ">=6.9.0" } }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -81,6 +128,17 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.5", "resolved": "https://registry.npmmirror.com/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz", @@ -343,6 +401,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.25", + "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.25.tgz", + "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/node": { "version": "24.13.3", "resolved": "https://registry.npmmirror.com/@types/node/-/node-24.13.3.tgz", @@ -353,6 +426,12 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-vue": { "version": "6.0.8", "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz", @@ -449,6 +528,12 @@ "@vue/shared": "3.5.41" } }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, "node_modules/@vue/language-core": { "version": "3.3.11", "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-3.3.11.tgz", @@ -532,6 +617,56 @@ } } }, + "node_modules/@vueuse/core": { + "version": "14.4.0", + "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-14.4.0.tgz", + "integrity": "sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.4.0", + "@vueuse/shared": "14.4.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.4.0", + "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.4.0.tgz", + "integrity": "sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.4.0", + "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.4.0.tgz", + "integrity": "sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/alien-signals": { "version": "3.2.1", "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-3.2.1.tgz", @@ -539,12 +674,93 @@ "dev": true, "license": "MIT" }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.19.0", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", @@ -555,6 +771,46 @@ "node": ">=8" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/element-plus": { + "version": "2.14.5", + "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.14.5.tgz", + "integrity": "sha512-bghYy/S+qg87enHPXELirhEdDqsVAUGcGpbGIeG8dz0kwpIkGz7gYsifulBshXX74iRtHib85XWQj0uSH2A1Yg==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.8.0", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8", + "@types/lodash": "^4.17.24", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "14.4.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.20", + "lodash": "^4.18.1", + "lodash-es": "^4.18.1", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.3.9" + }, + "peerDependencies": { + "vue": "^3.3.7" + } + }, "node_modules/entities": { "version": "7.0.1", "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", @@ -567,6 +823,51 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", @@ -591,6 +892,42 @@ } } }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", @@ -606,6 +943,116 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.33.0.tgz", @@ -867,6 +1314,29 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", @@ -876,6 +1346,48 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/muggle-string": { "version": "0.4.1", "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", @@ -901,6 +1413,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", @@ -955,6 +1473,15 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/rolldown": { "version": "1.2.5", "resolved": "https://registry.npmmirror.com/rolldown/-/rolldown-1.2.5.tgz", @@ -1142,6 +1669,27 @@ } } }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/vue-component-type-helpers/-/vue-component-type-helpers-3.3.11.tgz", + "integrity": "sha512-LwcxzeliO9fkQcpJG0PoX8X5kmAhKmH9wkpDLxNabwzkQ9Zeib2YVHwFV4pcWmMLfXVfjr/dSV+DaJ3cIPgSNA==", + "license": "MIT" + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, "node_modules/vue-tsc": { "version": "3.3.11", "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-3.3.11.tgz", diff --git a/backend/package.json b/backend/package.json index f5b2ef5..38fa1e4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,7 +9,11 @@ "preview": "vite preview" }, "dependencies": { - "vue": "^3.5.40" + "@element-plus/icons-vue": "^2.3.2", + "axios": "^1.19.0", + "element-plus": "^2.14.5", + "vue": "^3.5.40", + "vue-router": "^4.6.4" }, "devDependencies": { "@types/node": "^24.13.3", diff --git a/backend/src/App.vue b/backend/src/App.vue index 388b05e..c048298 100644 --- a/backend/src/App.vue +++ b/backend/src/App.vue @@ -1,7 +1,6 @@ diff --git a/backend/src/api/index.ts b/backend/src/api/index.ts new file mode 100644 index 0000000..8501247 --- /dev/null +++ b/backend/src/api/index.ts @@ -0,0 +1,330 @@ +import http from './request' +import type { ApiResp, PageData } from './request' + +// ---------- 类型定义 ---------- + +export interface Role { + id: number + name: string + code: string + description: string + is_system: boolean + status: number + permissions?: Permission[] +} + +export interface Permission { + id: number + name: string + code: string + module: string + description: string +} + +export interface User { + id: number + username: string + email: string + role_id: number + role?: Role + status: number + storage_used: number + storage_limit: number + last_login_at?: string + last_login_ip?: string + created_at?: string +} + +export interface Project { + id: number + user_id: number + name: string + description: string + storage_used: number + storage_limit: number + status: number + created_at?: string +} + +export interface FileItem { + id: number + project_id: number + user_id: number + filename: string + size: number + mime_type: string + md5: string + visibility: number + download_count: number + status: number + created_at?: string + project?: Project +} + +export interface TempLink { + token: string + url: string + expires_at: string + has_password: boolean + max_count: number +} + +export interface APIKey { + id: number + access_key: string + name: string + status: number + last_used_at?: string + created_at?: string +} + +export interface Webhook { + id: number + project_id?: number + url: string + events: string + status: number + created_at?: string +} + +export interface OperationLog { + id: number + user_id?: number + username: string + action: string + resource_type: string + resource_id: number + resource_name: string + ip: string + request_path: string + request_method: string + status: number + error_message: string + created_at?: string +} + +export interface SystemLog { + id: number + level: string + module: string + message: string + request_id: string + ip: string + created_at?: string +} + +export interface SystemSetting { + id: number + key: string + value: string + description: string + group_name: string +} + +// ---------- 认证 ---------- + +export const authApi = { + register(data: { username: string; email: string; password: string }) { + return http.post('/auth/register', data) + }, + login(data: { account: string; password: string }) { + return http.post>('/auth/login', data) + }, + createAPIKey(name: string) { + return http.post>('/auth/apikeys', { name }) + }, + listAPIKeys() { + return http.get>('/auth/apikeys') + }, + deleteAPIKey(id: number) { + return http.delete(`/auth/apikeys/${id}`) + }, +} + +// ---------- 用户资料 ---------- + +export const userApi = { + getProfile() { + return http.get>('/user/profile') + }, + updateProfile(email: string) { + return http.put('/user/profile', { email }) + }, + changePassword(oldPassword: string, newPassword: string) { + return http.put('/user/password', { + old_password: oldPassword, + new_password: newPassword, + }) + }, +} + +// ---------- 项目 ---------- + +export const projectApi = { + create(data: { name: string; description?: string; storage_limit?: number }) { + return http.post>('/projects', data) + }, + list(params: { page?: number; page_size?: number; keyword?: string }) { + return http.get>>('/projects', { params }) + }, + get(id: number) { + return http.get>(`/projects/${id}`) + }, + update(id: number, data: { name?: string; description?: string; storage_limit?: number }) { + return http.put>(`/projects/${id}`, data) + }, + remove(id: number) { + return http.delete(`/projects/${id}`) + }, +} + +// ---------- 文件 ---------- + +export interface FileListParams { + project_id?: number + filename?: string + prefix?: string + trash?: boolean + page?: number + page_size?: number +} + +export const fileApi = { + upload(formData: FormData) { + return http.post>('/files/upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 300000, + }) + }, + list(params: FileListParams) { + return http.get>>('/files', { params }) + }, + info(id: number) { + return http.get>(`/files/${id}`) + }, + // 下载为blob(需认证头) + download(id: number) { + return http.get(`/files/${id}/download`, { responseType: 'blob', timeout: 300000 }) + }, + remove(id: number, permanent = false) { + return http.delete(`/files/${id}`, { params: permanent ? { permanent: 'true' } : {} }) + }, + restore(id: number) { + return http.post(`/files/${id}/restore`) + }, + createTempLink(id: number, data: { password?: string; max_count?: number; expires_in?: number }) { + return http.post>(`/files/${id}/temp-link`, data) + }, +} + +// ---------- 统计 ---------- + +export const statsApi = { + user() { + return http.get('/stats/user') + }, + project(id: number) { + return http.get(`/stats/project/${id}`) + }, + traffic(days = 30) { + return http.get('/stats/traffic', { params: { days } }) + }, +} + +// ---------- Webhook ---------- + +export const webhookApi = { + create(data: { url: string; events: string; project_id?: number; secret?: string }) { + return http.post>('/webhooks', data) + }, + list() { + return http.get>('/webhooks') + }, + update(id: number, data: { url?: string; events?: string; status?: number }) { + return http.put>(`/webhooks/${id}`, data) + }, + remove(id: number) { + return http.delete(`/webhooks/${id}`) + }, +} + +// ---------- 管理后台 ---------- + +export const adminApi = { + // 用户管理 + listUsers(params: { page?: number; page_size?: number; keyword?: string; role_id?: number; status?: string }) { + return http.get>>('/admin/users', { params }) + }, + createUser(data: { username: string; email: string; password: string; role_id?: number; storage_limit?: number }) { + return http.post>('/admin/users', data) + }, + updateUser(id: number, data: { email?: string; role_id?: number; storage_limit?: number }) { + return http.put>(`/admin/users/${id}`, data) + }, + deleteUser(id: number) { + return http.delete(`/admin/users/${id}`) + }, + setUserStatus(id: number, status: number) { + return http.put(`/admin/users/${id}/status`, { status }) + }, + assignRole(id: number, roleId: number) { + return http.put(`/admin/users/${id}/role`, { role_id: roleId }) + }, + resetPassword(id: number, password: string) { + return http.post(`/admin/users/${id}/reset-password`, { password }) + }, + // 角色权限 + listRoles() { + return http.get>('/admin/roles') + }, + createRole(data: { name: string; code: string; description?: string; permissions?: number[] }) { + return http.post>('/admin/roles', data) + }, + updateRole(id: number, data: { name?: string; description?: string; permissions?: number[] }) { + return http.put>(`/admin/roles/${id}`, data) + }, + deleteRole(id: number) { + return http.delete(`/admin/roles/${id}`) + }, + setRolePermissions(id: number, permissions: number[]) { + return http.put(`/admin/roles/${id}/permissions`, { permissions }) + }, + listPermissions() { + return http.get>('/admin/permissions') + }, + // 系统设置 + getSettings() { + return http.get>>('/admin/settings') + }, + updateSettings(values: Record) { + return http.put('/admin/settings', values) + }, + // 日志 + listOperationLogs(params: { page?: number; page_size?: number; username?: string; action?: string; days?: number }) { + return http.get>>('/admin/logs/operation', { params }) + }, + listSystemLogs(params: { page?: number; page_size?: number; level?: string; module?: string; days?: number }) { + return http.get>>('/admin/logs/system', { params }) + }, + cleanupLogs(days: number) { + return http.delete('/admin/logs/cleanup', { params: { days } }) + }, + // 备份 + backup() { + return http.post>('/admin/backup') + }, + listBackups() { + return http.get>('/admin/backups') + }, + downloadBackup(name: string) { + return http.get('/admin/backups/download', { params: { name }, responseType: 'blob', timeout: 300000 }) + }, +} + +// ---------- 站点状态(公开) ---------- + +export const siteApi = { + status() { + return http.get('/status') + }, +} diff --git a/backend/src/api/request.ts b/backend/src/api/request.ts new file mode 100644 index 0000000..67ec30a --- /dev/null +++ b/backend/src/api/request.ts @@ -0,0 +1,62 @@ +import axios, { AxiosError } from 'axios' +import { ElMessage } from 'element-plus' +import { clearSession } from '../store/user' + +// 后端统一响应结构 +export interface ApiResp { + code: number + message: string + data?: T +} + +export interface PageData { + list: T[] + total: number + page: number + page_size: number +} + +const http = axios.create({ + baseURL: import.meta.env.VITE_API_BASE_URL || '/api/v1', + timeout: 30000, +}) + +// 请求拦截:注入JWT +http.interceptors.request.use((config) => { + const token = localStorage.getItem('token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config +}) + +// 响应拦截:统一错误提示,401跳转登录 +http.interceptors.response.use( + (resp): any => { + // 文件下载等二进制响应直接返回 + if (resp.config.responseType === 'blob') { + return resp + } + const body = resp.data as ApiResp + if (body.code !== 0) { + ElMessage.error(body.message || '请求失败') + return Promise.reject(new Error(body.message)) + } + return body + }, + (error: AxiosError) => { + const msg = error.response?.data?.message || error.message || '网络错误' + if (error.response?.status === 401) { + clearSession() + if (location.hash !== '#/login') { + location.hash = '#/login' + } + ElMessage.error(msg || '登录已过期,请重新登录') + } else { + ElMessage.error(msg) + } + return Promise.reject(error) + }, +) + +export default http diff --git a/backend/src/main.ts b/backend/src/main.ts index 2425c0f..2aaf1d6 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,5 +1,19 @@ import { createApp } from 'vue' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import zhCn from 'element-plus/es/locale/lang/zh-cn' +import * as ElementPlusIconsVue from '@element-plus/icons-vue' import './style.css' import App from './App.vue' +import router from './router' -createApp(App).mount('#app') +const app = createApp(App) + +// 注册全部Element Plus图标 +for (const [key, component] of Object.entries(ElementPlusIconsVue)) { + app.component(key, component) +} + +app.use(ElementPlus, { locale: zhCn }) +app.use(router) +app.mount('#app') diff --git a/backend/src/router/index.ts b/backend/src/router/index.ts new file mode 100644 index 0000000..8ebfd63 --- /dev/null +++ b/backend/src/router/index.ts @@ -0,0 +1,72 @@ +import { createRouter, createWebHashHistory } from 'vue-router' +import { isLoggedIn, isAdmin } from '../store/user' + +const router = createRouter({ + history: createWebHashHistory(), + routes: [ + { path: '/login', name: 'login', component: () => import('../views/Login.vue') }, + { + path: '/', + component: () => import('../views/Layout.vue'), + meta: { requiresAuth: true }, + children: [ + { path: '', redirect: '/dashboard' }, + { path: 'dashboard', name: 'dashboard', component: () => import('../views/Dashboard.vue') }, + { path: 'projects', name: 'projects', component: () => import('../views/Projects.vue') }, + { path: 'files', name: 'files', component: () => import('../views/Files.vue') }, + { path: 'profile', name: 'profile', component: () => import('../views/Profile.vue') }, + { + path: 'admin/users', + name: 'admin-users', + component: () => import('../views/admin/Users.vue'), + meta: { requiresAdmin: true }, + }, + { + path: 'admin/roles', + name: 'admin-roles', + component: () => import('../views/admin/Roles.vue'), + meta: { requiresAdmin: true }, + }, + { + path: 'admin/settings', + name: 'admin-settings', + component: () => import('../views/admin/Settings.vue'), + meta: { requiresAdmin: true }, + }, + { + path: 'admin/logs', + name: 'admin-logs', + component: () => import('../views/admin/Logs.vue'), + meta: { requiresAdmin: true }, + }, + { + path: 'admin/backups', + name: 'admin-backups', + component: () => import('../views/admin/Backups.vue'), + meta: { requiresAdmin: true }, + }, + { + path: 'admin/docs', + name: 'admin-docs', + component: () => import('../views/admin/Docs.vue'), + meta: { requiresAdmin: true }, + }, + ], + }, + ], +}) + +// 全局守卫:登录态与管理员权限 +router.beforeEach((to) => { + if (to.meta.requiresAuth && !isLoggedIn.value) { + return { path: '/login', query: { redirect: to.fullPath } } + } + if (to.meta.requiresAdmin && !isAdmin.value) { + return { path: '/dashboard' } + } + if (to.path === '/login' && isLoggedIn.value) { + return { path: '/dashboard' } + } +}) + +export default router diff --git a/backend/src/store/user.ts b/backend/src/store/user.ts new file mode 100644 index 0000000..e22c3df --- /dev/null +++ b/backend/src/store/user.ts @@ -0,0 +1,34 @@ +import { reactive, computed } from 'vue' +import type { User } from '../api' + +// 轻量全局会话状态 +export const session = reactive<{ token: string; user: User | null }>({ + token: localStorage.getItem('token') || '', + user: JSON.parse(localStorage.getItem('user') || 'null'), +}) + +export const isLoggedIn = computed(() => !!session.token) +export const isAdmin = computed(() => { + const code = session.user?.role?.code + return code === 'super_admin' || code === 'admin' +}) +export const isSuperAdmin = computed(() => session.user?.role?.code === 'super_admin') + +export function setSession(token: string, user: User) { + session.token = token + session.user = user + localStorage.setItem('token', token) + localStorage.setItem('user', JSON.stringify(user)) +} + +export function updateUser(user: User) { + session.user = user + localStorage.setItem('user', JSON.stringify(user)) +} + +export function clearSession() { + session.token = '' + session.user = null + localStorage.removeItem('token') + localStorage.removeItem('user') +} diff --git a/backend/src/style.css b/backend/src/style.css index 527d4fb..9ec92b0 100644 --- a/backend/src/style.css +++ b/backend/src/style.css @@ -1,296 +1,34 @@ -:root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); - --social-bg: rgba(244, 243, 236, 0.5); - --shadow: - rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; - - --sans: system-ui, 'Segoe UI', Roboto, sans-serif; - --heading: system-ui, 'Segoe UI', Roboto, sans-serif; - --mono: ui-monospace, Consolas, monospace; - - font: 18px/145% var(--sans); - letter-spacing: 0.18px; - color-scheme: light dark; - color: var(--text); - background: var(--bg); - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - - @media (max-width: 1024px) { - font-size: 16px; - } -} - -@media (prefers-color-scheme: dark) { - :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #c084fc; - --accent-bg: rgba(192, 132, 252, 0.15); - --accent-border: rgba(192, 132, 252, 0.5); - --social-bg: rgba(47, 48, 58, 0.5); - --shadow: - rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; - } - - #social .button-icon { - filter: invert(1) brightness(2); - } -} - -body { +* { margin: 0; -} - -h1, -h2 { - font-family: var(--heading); - font-weight: 500; - color: var(--text-h); -} - -h1 { - font-size: 56px; - letter-spacing: -1.68px; - margin: 32px 0; - @media (max-width: 1024px) { - font-size: 36px; - margin: 20px 0; - } -} -h2 { - font-size: 24px; - line-height: 118%; - letter-spacing: -0.24px; - margin: 0 0 8px; - @media (max-width: 1024px) { - font-size: 20px; - } -} -p { - margin: 0; -} - -code, -.counter { - font-family: var(--mono); - display: inline-flex; - border-radius: 4px; - color: var(--text-h); -} - -code { - font-size: 15px; - line-height: 135%; - padding: 4px 8px; - background: var(--code-bg); -} - -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; - margin-bottom: 24px; - - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } -} - -.hero { - position: relative; - - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } - - .base { - width: 170px; - position: relative; - z-index: 0; - } - - .framework, - .vite { - position: absolute; - } - - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) - scale(1.4); - } - - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) - scale(0.8); - } -} - -#app { - width: 1126px; - max-width: 100%; - margin: 0 auto; - text-align: center; - border-inline: 1px solid var(--border); - min-height: 100svh; - display: flex; - flex-direction: column; + padding: 0; box-sizing: border-box; } -#center { +html, +body, +#app { + height: 100%; + font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif; + background-color: #f5f7fa; + color: #303133; +} + +.page-card { + background: #fff; + border-radius: 8px; + padding: 20px; +} + +.page-toolbar { display: flex; - flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; - flex-grow: 1; - - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + flex-wrap: wrap; + gap: 10px; } -#next-steps { - display: flex; - border-top: 1px solid var(--border); - text-align: left; - - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } - } - - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } - - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; - } -} - -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } -} - -#next-steps ul { - list-style: none; - padding: 0; - display: flex; - gap: 8px; - margin: 32px 0 0; - - .logo { - height: 18px; - } - - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; - - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } - } - - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; - - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } - } -} - -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } -} - -.ticks { - position: relative; - width: 100%; - - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; - } - - &::before { - left: 0; - border-left-color: var(--border); - } - &::after { - right: 0; - border-right-color: var(--border); - } +.mono { + font-family: Consolas, Monaco, monospace; + font-size: 12px; } diff --git a/backend/src/utils/format.ts b/backend/src/utils/format.ts new file mode 100644 index 0000000..cee45c2 --- /dev/null +++ b/backend/src/utils/format.ts @@ -0,0 +1,39 @@ +// 字节数转可读大小 +export function formatSize(bytes: number): string { + if (!bytes || bytes < 0) return '0 B' + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + let i = 0 + let n = bytes + while (n >= 1024 && i < units.length - 1) { + n /= 1024 + i++ + } + return `${n >= 100 || i === 0 ? Math.round(n) : n.toFixed(1)} ${units[i]}` +} + +// 时间格式化 +export function formatTime(s?: string): string { + if (!s) return '-' + const d = new Date(s) + if (isNaN(d.getTime())) return s + const pad = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` +} + +// 下载blob响应 +export function saveBlob(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + a.remove() + URL.revokeObjectURL(url) +} + +// 文件名基础名(去除虚拟目录) +export function baseName(filename: string): string { + const idx = filename.lastIndexOf('/') + return idx >= 0 ? filename.slice(idx + 1) : filename +} diff --git a/backend/src/views/Dashboard.vue b/backend/src/views/Dashboard.vue new file mode 100644 index 0000000..2f71ed5 --- /dev/null +++ b/backend/src/views/Dashboard.vue @@ -0,0 +1,151 @@ + + + + + diff --git a/backend/src/views/Files.vue b/backend/src/views/Files.vue new file mode 100644 index 0000000..8c158d3 --- /dev/null +++ b/backend/src/views/Files.vue @@ -0,0 +1,439 @@ + + + + + diff --git a/backend/src/views/Layout.vue b/backend/src/views/Layout.vue new file mode 100644 index 0000000..0a8a1b3 --- /dev/null +++ b/backend/src/views/Layout.vue @@ -0,0 +1,196 @@ + + + + + diff --git a/backend/src/views/Login.vue b/backend/src/views/Login.vue new file mode 100644 index 0000000..6766056 --- /dev/null +++ b/backend/src/views/Login.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/backend/src/views/Profile.vue b/backend/src/views/Profile.vue new file mode 100644 index 0000000..89d8bce --- /dev/null +++ b/backend/src/views/Profile.vue @@ -0,0 +1,396 @@ + + + + + diff --git a/backend/src/views/Projects.vue b/backend/src/views/Projects.vue new file mode 100644 index 0000000..5738516 --- /dev/null +++ b/backend/src/views/Projects.vue @@ -0,0 +1,186 @@ + + + diff --git a/backend/src/views/admin/Backups.vue b/backend/src/views/admin/Backups.vue new file mode 100644 index 0000000..abd047c --- /dev/null +++ b/backend/src/views/admin/Backups.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/backend/src/views/admin/Docs.vue b/backend/src/views/admin/Docs.vue new file mode 100644 index 0000000..0ef8192 --- /dev/null +++ b/backend/src/views/admin/Docs.vue @@ -0,0 +1,285 @@ + + + + + diff --git a/backend/src/views/admin/Logs.vue b/backend/src/views/admin/Logs.vue new file mode 100644 index 0000000..c61d042 --- /dev/null +++ b/backend/src/views/admin/Logs.vue @@ -0,0 +1,217 @@ + + + diff --git a/backend/src/views/admin/Roles.vue b/backend/src/views/admin/Roles.vue new file mode 100644 index 0000000..9ca13d6 --- /dev/null +++ b/backend/src/views/admin/Roles.vue @@ -0,0 +1,237 @@ + + + + + diff --git a/backend/src/views/admin/Settings.vue b/backend/src/views/admin/Settings.vue new file mode 100644 index 0000000..ca7810a --- /dev/null +++ b/backend/src/views/admin/Settings.vue @@ -0,0 +1,230 @@ + + + + + diff --git a/backend/src/views/admin/Users.vue b/backend/src/views/admin/Users.vue new file mode 100644 index 0000000..a1f8eb3 --- /dev/null +++ b/backend/src/views/admin/Users.vue @@ -0,0 +1,266 @@ + + + diff --git a/backend/vite.config.ts b/backend/vite.config.ts index bbcf80c..7e49a85 100644 --- a/backend/vite.config.ts +++ b/backend/vite.config.ts @@ -1,7 +1,22 @@ -import { defineConfig } from 'vite' +import { defineConfig, loadEnv } from 'vite' import vue from '@vitejs/plugin-vue' // https://vite.dev/config/ -export default defineConfig({ - plugins: [vue()], +export default defineConfig(({ mode }) => { + // 根据当前工作目录中的不同环境变量文件加载环境变量 + const env = loadEnv(mode, process.cwd()) + + return { + plugins: [vue()], + server: { + port: 5173, + proxy: { + // 开发环境代理到Go后端 + '/api': { + target: env.VITE_API_BASE_URL || 'http://127.0.0.1:8100', + changeOrigin: true, + }, + }, + }, + } }) diff --git a/docs/README.md b/docs/README.md index a1bc364..993025a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -397,6 +397,28 @@ CREATE INDEX idx_system_logs_created ON system_logs(created_at); ## API设计 +### 基础信息 +- **API基础地址**: + - 开发环境:`http://localhost:8100/api/v1` + - 生产环境:`https://file.yunzer.cn/api/v1` +- **响应格式**:`{"code": 0, "message": "ok", "data": ...}` +- **认证方式**:JWT Bearer Token 或 API Key 签名 +- **分页格式**:`{"list": [], "total": 0, "page": 1, "page_size": 20}` + +### 域名配置说明 +系统支持通过配置文件设置域名,用于生成完整的URL: + +```yaml +# config.yaml +server: + domain: https://file.yunzer.cn # 生产环境域名 +``` + +生成的完整URL示例: +- 文件下载:`https://file.yunzer.cn/api/v1/files/1/download` +- 临时链接:`https://file.yunzer.cn/api/v1/files/temp/abc123` +- 站点状态:`https://file.yunzer.cn/api/v1/status` + ### 认证相关 - `POST /api/v1/auth/register` - 用户注册 - `POST /api/v1/auth/login` - 用户登录 @@ -501,8 +523,22 @@ CREATE INDEX idx_system_logs_created ON system_logs(created_at); ### 环境要求 - Go 1.21+ - SQLite 3 +- Node.js 16+ +- npm 或 yarn -### 安装和运行 +### 域名配置 + +#### 开发环境 +- 后端服务地址:`http://localhost:8100` +- 前端服务地址:`http://localhost:5173` +- API基础地址:`http://localhost:8100/api/v1` + +#### 生产环境 +- 域名:`https://file.yunzer.cn` +- API基础地址:`https://file.yunzer.cn/api/v1` +- 建议配置Nginx反向代理,将80/8080端口重定向到443端口 + +### 后端安装和运行 ```bash # 进入Go后端目录 cd go @@ -514,12 +550,26 @@ go mod tidy go run main.go ``` +### 前端安装和运行 +```bash +# 进入前端目录 +cd backend + +# 安装依赖 +npm install + +# 启动开发服务器 +npm run dev +``` + ### 配置文件 + +#### 后端配置 (go/config.yaml) ```yaml -# config.yaml server: - port: 8080 - mode: debug # debug/release + port: 8100 + mode: debug + domain: https://file.yunzer.cn # 生产环境域名 database: driver: sqlite @@ -527,17 +577,87 @@ database: storage: root: ./storage/files - max_size: 100MB # 单文件最大限制 + backups: ./storage/backups admin: username: admin password: admin123 # 首次运行后请修改 email: admin@example.com - role: super_admin # 默认角色 + role: super_admin jwt: - secret: your-secret-key # 请修改为随机密钥 - expire: 24h # Token过期时间 + secret: please-change-this-secret-key # 生产环境请修改为随机密钥 + expire: 24h + +log: + level: info + dir: ./storage/logs + max_days: 7 +``` + +#### 前端配置 +##### 开发环境 (.env.development) +``` +VITE_API_BASE_URL=http://localhost:8100/api/v1 +VITE_APP_TITLE=云泽文件存储云平台 (开发) +VITE_APP_ENV=development +``` + +##### 生产环境 (.env.production) +``` +VITE_API_BASE_URL=https://file.yunzer.cn/api/v1 +VITE_APP_TITLE=云泽文件存储云平台 +VITE_APP_ENV=production +``` + +### Nginx配置示例(生产环境) +```nginx +server { + listen 80; + server_name file.yunzer.cn; + + # 重定向到HTTPS + return 301 https://$server_name$request_uri; +} + +server { + listen 443 ssl http2; + server_name file.yunzer.cn; + + # SSL证书配置 + ssl_certificate /path/to/your/certificate.crt; + ssl_certificate_key /path/to/your/private.key; + ssl_protocols TLSv1.2 TLSv1.3; + + # 前端静态文件 + location / { + root /path/to/your/frontend/dist; + try_files $uri $uri/ /index.html; + } + + # API代理 + location /api/ { + proxy_pass http://127.0.0.1:8100; + 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 60s; + proxy_read_timeout 60s; + proxy_send_timeout 60s; + } + + # 文件上传大小限制 + client_max_body_size 100M; + + # 静态资源缓存 + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} ``` ## 注意事项 diff --git a/go/config.yaml b/go/config.yaml new file mode 100644 index 0000000..b2c29cd --- /dev/null +++ b/go/config.yaml @@ -0,0 +1,28 @@ +# FileStorageSystem 配置文件 +server: + port: 8100 + mode: debug # debug/release + domain: https://file.yunzer.cn # 生产环境域名 + +database: + driver: sqlite + source: ./storage/data.db + +storage: + root: ./storage/files # 文件存储根目录 + backups: ./storage/backups # 数据库备份目录 + +admin: + username: admin + password: admin123 # 首次运行后请修改 + email: admin@example.com + role: super_admin # 默认角色 + +jwt: + secret: please-change-this-secret-key # 生产环境请修改为随机密钥 + expire: 24h # Token过期时间 + +log: + level: info + dir: ./storage/logs + max_days: 7 diff --git a/go/fss.exe b/go/fss.exe new file mode 100644 index 0000000..46336af Binary files /dev/null and b/go/fss.exe differ diff --git a/go/go.mod b/go/go.mod new file mode 100644 index 0000000..00b38e5 --- /dev/null +++ b/go/go.mod @@ -0,0 +1,66 @@ +module filestoragesystem + +go 1.21 + +require ( + github.com/gin-gonic/gin v1.10.0 + github.com/glebarez/sqlite v1.11.0 + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/spf13/viper v1.19.0 + go.uber.org/zap v1.27.0 + golang.org/x/crypto v0.26.0 + golang.org/x/time v0.6.0 + gorm.io/gorm v1.25.11 +) + +require ( + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/google/uuid v1.4.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sys v0.23.0 // indirect + golang.org/x/text v0.17.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect +) diff --git a/go/go.sum b/go/go.sum new file mode 100644 index 0000000..3f34ee0 --- /dev/null +++ b/go/go.sum @@ -0,0 +1,163 @@ +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= +github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= +golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM= +golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= +golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg= +gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/go/internal/config/config.go b/go/internal/config/config.go new file mode 100644 index 0000000..73070e3 --- /dev/null +++ b/go/internal/config/config.go @@ -0,0 +1,139 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/viper" +) + +// Config 全局配置结构 +type Config struct { + Server ServerConfig `mapstructure:"server"` + Database DatabaseConfig `mapstructure:"database"` + Storage StorageConfig `mapstructure:"storage"` + Admin AdminConfig `mapstructure:"admin"` + JWT JWTConfig `mapstructure:"jwt"` + Log LogConfig `mapstructure:"log"` +} + +type ServerConfig struct { + Port string `mapstructure:"port"` + Mode string `mapstructure:"mode"` // debug / release / test + Domain string `mapstructure:"domain"` // 生产环境域名 +} + +type DatabaseConfig struct { + Driver string `mapstructure:"driver"` + Source string `mapstructure:"source"` +} + +type StorageConfig struct { + Root string `mapstructure:"root"` // 文件存储根目录 + Backups string `mapstructure:"backups"` // 备份目录 +} + +type AdminConfig struct { + Username string `mapstructure:"username"` + Password string `mapstructure:"password"` + Email string `mapstructure:"email"` + Role string `mapstructure:"role"` +} + +type JWTConfig struct { + Secret string `mapstructure:"secret"` + Expire string `mapstructure:"expire"` // 如 24h +} + +type LogConfig struct { + Level string `mapstructure:"level"` // debug/info/warn/error + Dir string `mapstructure:"dir"` // 日志文件目录,为空则仅输出到控制台 + MaxDays int `mapstructure:"max_days"` // 日志保留天数 +} + +var cfg *Config + +// Load 加载配置文件,不存在时使用默认值并生成示例配置 +func Load(path string) (*Config, error) { + v := viper.New() + setDefaults(v) + + // 设置环境变量前缀 + v.SetEnvPrefix("FSS") + v.AutomaticEnv() + + if path == "" { + path = "config.yaml" + } + + // 尝试加载配置文件 + if _, err := os.Stat(path); err == nil { + v.SetConfigFile(path) + if err := v.ReadInConfig(); err != nil { + return nil, fmt.Errorf("读取配置文件失败: %w", err) + } + } else { + // 配置文件不存在时生成一份默认配置 + _ = v.SafeWriteConfigAs(path) + } + + c := &Config{} + if err := v.Unmarshal(c); err != nil { + return nil, fmt.Errorf("解析配置失败: %w", err) + } + cfg = c + return c, nil +} + +func setDefaults(v *viper.Viper) { + v.SetDefault("server.port", "8100") + v.SetDefault("server.mode", "debug") + v.SetDefault("server.domain", "https://file.yunzer.cn") + + v.SetDefault("database.driver", "sqlite") + v.SetDefault("database.source", "./storage/data.db") + + v.SetDefault("storage.root", "./storage/files") + v.SetDefault("storage.backups", "./storage/backups") + + v.SetDefault("admin.username", "admin") + v.SetDefault("admin.password", "admin123") + v.SetDefault("admin.email", "admin@example.com") + v.SetDefault("admin.role", "super_admin") + + v.SetDefault("jwt.secret", "please-change-this-secret-key") + v.SetDefault("jwt.expire", "24h") + + v.SetDefault("log.level", "info") + v.SetDefault("log.dir", "./storage/logs") + v.SetDefault("log.max_days", 7) +} + +// Get 获取全局配置 +func Get() *Config { + if cfg == nil { + c, _ := Load("") + cfg = c + } + return cfg +} + +// EnsureDirs 确保所有存储相关目录存在 +func (c *Config) EnsureDirs() error { + dirs := []string{ + filepath.Dir(c.Database.Source), + c.Storage.Root, + c.Storage.Backups, + c.Log.Dir, + } + for _, d := range dirs { + if d == "" { + continue + } + if err := os.MkdirAll(d, 0o755); err != nil { + return fmt.Errorf("创建目录 %s 失败: %w", d, err) + } + } + return nil +} diff --git a/go/internal/controller/admin.go b/go/internal/controller/admin.go new file mode 100644 index 0000000..c30ece9 --- /dev/null +++ b/go/internal/controller/admin.go @@ -0,0 +1,354 @@ +package controller + +import ( + "github.com/gin-gonic/gin" + + "filestoragesystem/internal/repository" + "filestoragesystem/internal/utils" +) + +// AdminController 系统管理控制器 +type AdminController struct { + *Base +} + +// NewAdminController 创建系统管理控制器 +func NewAdminController(b *Base) *AdminController { return &AdminController{Base: b} } + +// ---- 用户管理 ---- + +// ListUsers 用户列表 +func (ctl *AdminController) ListUsers(c *gin.Context) { + page, pageSize := utils.ParsePage(c) + users, total, err := ctl.svc.User.AdminListUsers( + c.Query("keyword"), c.Query("role_id"), c.Query("status"), page, pageSize) + if err != nil { + utils.ServerError(c, err.Error()) + return + } + utils.OKPage(c, users, total, page, pageSize) +} + +// GetUser 用户详情 +func (ctl *AdminController) GetUser(c *gin.Context) { + id, ok := paramID(c) + if !ok { + return + } + u, err := ctl.svc.User.AdminGetUser(id) + if err != nil { + utils.NotFound(c, "用户不存在") + return + } + utils.OK(c, u) +} + +type adminCreateUserReq struct { + Username string `json:"username" binding:"required"` + Email string `json:"email" binding:"required"` + Password string `json:"password" binding:"required"` + RoleID uint `json:"role_id"` + StorageLimit int64 `json:"storage_limit"` +} + +// CreateUser 创建用户 +func (ctl *AdminController) CreateUser(c *gin.Context) { + var req adminCreateUserReq + if !bindJSON(c, &req) { + return + } + u, err := ctl.svc.User.AdminCreateUser(req.Username, req.Email, req.Password, req.RoleID, req.StorageLimit) + if err != nil { + ctl.svc.RecordOpLog(ctl.opCtx(c), "create", "user", 0, req.Username, false, err.Error()) + utils.FailMsg(c, err.Error()) + return + } + ctl.svc.RecordOpLog(ctl.opCtx(c), "create", "user", u.ID, u.Username, true, "") + utils.OK(c, u) +} + +type adminUpdateUserReq struct { + Email string `json:"email"` + RoleID uint `json:"role_id"` + StorageLimit int64 `json:"storage_limit"` +} + +// UpdateUser 编辑用户 +func (ctl *AdminController) UpdateUser(c *gin.Context) { + id, ok := paramID(c) + if !ok { + return + } + var req adminUpdateUserReq + if !bindJSON(c, &req) { + return + } + u, err := ctl.svc.User.AdminUpdateUser(id, req.Email, req.RoleID, req.StorageLimit) + if err != nil { + utils.FailMsg(c, err.Error()) + return + } + ctl.svc.RecordOpLog(ctl.opCtx(c), "update", "user", id, u.Username, true, "") + utils.OK(c, u) +} + +// DeleteUser 删除用户 +func (ctl *AdminController) DeleteUser(c *gin.Context) { + id, ok := paramID(c) + if !ok { + return + } + if err := ctl.svc.User.AdminDeleteUser(id); err != nil { + utils.FailMsg(c, err.Error()) + return + } + ctl.svc.RecordOpLog(ctl.opCtx(c), "delete", "user", id, "", true, "") + utils.OKMsg(c, "用户已删除") +} + +type setUserStatusReq struct { + Status int8 `json:"status"` // 1启用 0禁用(不加required,0为合法值) +} + +// SetUserStatus 启用/禁用用户 +func (ctl *AdminController) SetUserStatus(c *gin.Context) { + id, ok := paramID(c) + if !ok { + return + } + var req setUserStatusReq + if !bindJSON(c, &req) { + return + } + if req.Status != 0 && req.Status != 1 { + utils.FailMsg(c, "status仅支持0或1") + return + } + if err := ctl.svc.User.AdminSetUserStatus(id, req.Status); err != nil { + utils.FailMsg(c, err.Error()) + return + } + ctl.svc.RecordOpLog(ctl.opCtx(c), "update", "user", id, "status", true, "") + utils.OKMsg(c, "状态已更新") +} + +type assignRoleReq struct { + RoleID uint `json:"role_id" binding:"required"` +} + +// AssignRole 修改用户角色 +func (ctl *AdminController) AssignRole(c *gin.Context) { + id, ok := paramID(c) + if !ok { + return + } + var req assignRoleReq + if !bindJSON(c, &req) { + return + } + if err := ctl.svc.User.AdminAssignRole(id, req.RoleID); err != nil { + utils.FailMsg(c, err.Error()) + return + } + ctl.svc.RecordOpLog(ctl.opCtx(c), "update", "user", id, "role", true, "") + utils.OKMsg(c, "角色已更新") +} + +type resetPasswordReq struct { + Password string `json:"password" binding:"required"` +} + +// ResetPassword 重置用户密码 +func (ctl *AdminController) ResetPassword(c *gin.Context) { + id, ok := paramID(c) + if !ok { + return + } + var req resetPasswordReq + if !bindJSON(c, &req) { + return + } + if err := ctl.svc.User.AdminResetPassword(id, req.Password); err != nil { + utils.FailMsg(c, err.Error()) + return + } + ctl.svc.RecordOpLog(ctl.opCtx(c), "update", "user", id, "password", true, "") + utils.OKMsg(c, "密码已重置") +} + +// ---- 系统设置 ---- + +// GetSettings 获取全部设置 +func (ctl *AdminController) GetSettings(c *gin.Context) { + groups, err := ctl.svc.Admin.GetSettings() + if err != nil { + utils.ServerError(c, err.Error()) + return + } + utils.OK(c, groups) +} + +// GetSettingsByGroup 按分组获取设置 +func (ctl *AdminController) GetSettingsByGroup(c *gin.Context) { + group := c.Param("group") + settings, err := ctl.svc.Admin.GetSettingsByGroup(group) + if err != nil { + utils.ServerError(c, err.Error()) + return + } + utils.OK(c, settings) +} + +// UpdateSettings 批量更新设置 +func (ctl *AdminController) UpdateSettings(c *gin.Context) { + var req map[string]string + if !bindJSON(c, &req) { + return + } + if err := ctl.svc.Admin.UpdateSettings(req); err != nil { + utils.FailMsg(c, err.Error()) + return + } + ctl.svc.RecordOpLog(ctl.opCtx(c), "update", "system", 0, "settings", true, "") + utils.OKMsg(c, "设置已更新") +} + +// UpdateSettingsByGroup 按分组更新设置 +func (ctl *AdminController) UpdateSettingsByGroup(c *gin.Context) { + var req map[string]string + if !bindJSON(c, &req) { + return + } + if err := ctl.svc.Admin.UpdateSettings(req); err != nil { + utils.FailMsg(c, err.Error()) + return + } + ctl.svc.RecordOpLog(ctl.opCtx(c), "update", "system", 0, "settings:"+c.Param("group"), true, "") + utils.OKMsg(c, "设置已更新") +} + +// ---- 日志管理 ---- + +// ListOperationLogs 操作日志列表 +func (ctl *AdminController) ListOperationLogs(c *gin.Context) { + page, pageSize := utils.ParsePage(c) + f := repository.OpLogFilter{ + Username: c.Query("username"), + Action: c.Query("action"), + Status: c.Query("status"), + Days: parseIntQuery(c, "days", 0), + Page: page, + PageSize: pageSize, + } + if uid := parseUintQuery(c, "user_id"); uid > 0 { + f.UserID = &uid + } + logs, total, err := ctl.svc.Admin.ListOperationLogs(f) + if err != nil { + utils.ServerError(c, err.Error()) + return + } + utils.OKPage(c, logs, total, page, pageSize) +} + +// GetOperationLog 操作日志详情 +func (ctl *AdminController) GetOperationLog(c *gin.Context) { + id, ok := paramID(c) + if !ok { + return + } + l, err := ctl.svc.Admin.GetOperationLog(id) + if err != nil { + utils.NotFound(c, "日志不存在") + return + } + utils.OK(c, l) +} + +// ListSystemLogs 系统日志列表 +func (ctl *AdminController) ListSystemLogs(c *gin.Context) { + page, pageSize := utils.ParsePage(c) + f := repository.SysLogFilter{ + Level: c.Query("level"), + Module: c.Query("module"), + Days: parseIntQuery(c, "days", 0), + Page: page, + PageSize: pageSize, + } + logs, total, err := ctl.svc.Admin.ListSystemLogs(f) + if err != nil { + utils.ServerError(c, err.Error()) + return + } + utils.OKPage(c, logs, total, page, pageSize) +} + +// GetSystemLog 系统日志详情 +func (ctl *AdminController) GetSystemLog(c *gin.Context) { + id, ok := paramID(c) + if !ok { + return + } + l, err := ctl.svc.Admin.GetSystemLog(id) + if err != nil { + utils.NotFound(c, "日志不存在") + return + } + utils.OK(c, l) +} + +// CleanupLogs 清理历史日志 +func (ctl *AdminController) CleanupLogs(c *gin.Context) { + days := parseIntQuery(c, "days", 30) + opDeleted, sysDeleted, err := ctl.svc.Admin.CleanupLogs(days) + if err != nil { + utils.ServerError(c, err.Error()) + return + } + ctl.svc.RecordOpLog(ctl.opCtx(c), "delete", "system", 0, "logs", true, "") + utils.OK(c, gin.H{ + "days": days, + "operation_logs_deleted": opDeleted, + "system_logs_deleted": sysDeleted, + }) +} + +// ---- 数据备份 ---- + +// Backup 手动备份数据库 +func (ctl *AdminController) Backup(c *gin.Context) { + name, err := ctl.svc.Admin.BackupDatabase() + if err != nil { + utils.ServerError(c, err.Error()) + return + } + ctl.svc.RecordOpLog(ctl.opCtx(c), "create", "system", 0, "backup:"+name, true, "") + utils.OK(c, gin.H{"name": name}) +} + +// ListBackups 备份列表 +func (ctl *AdminController) ListBackups(c *gin.Context) { + list, err := ctl.svc.Admin.ListBackups() + if err != nil { + utils.ServerError(c, err.Error()) + return + } + utils.OK(c, list) +} + +// DownloadBackup 下载备份文件 +func (ctl *AdminController) DownloadBackup(c *gin.Context) { + name := c.Query("name") + path, err := ctl.svc.Admin.BackupPath(name) + if err != nil { + utils.NotFound(c, err.Error()) + return + } + ctl.svc.RecordOpLog(ctl.opCtx(c), "download", "system", 0, "backup:"+name, true, "") + c.FileAttachment(path, name) +} + +// SiteStatus 公开站点状态接口 +func (ctl *AdminController) SiteStatus(c *gin.Context) { + utils.OK(c, ctl.svc.Admin.SiteStatus()) +} diff --git a/go/internal/controller/auth.go b/go/internal/controller/auth.go new file mode 100644 index 0000000..4148760 --- /dev/null +++ b/go/internal/controller/auth.go @@ -0,0 +1,127 @@ +package controller + +import ( + "github.com/gin-gonic/gin" + + "filestoragesystem/internal/middleware" + "filestoragesystem/internal/utils" +) + +// AuthController 认证控制器 +type AuthController struct { + *Base +} + +// NewAuthController 创建认证控制器 +func NewAuthController(b *Base) *AuthController { return &AuthController{Base: b} } + +type registerReq struct { + Username string `json:"username" binding:"required"` + Email string `json:"email" binding:"required"` + Password string `json:"password" binding:"required"` +} + +// Register 用户注册 +func (ctl *AuthController) Register(c *gin.Context) { + var req registerReq + if !bindJSON(c, &req) { + return + } + u, err := ctl.svc.Auth.Register(req.Username, req.Email, req.Password) + if err != nil { + ctl.svc.RecordOpLog(ctl.opCtx(c), "create", "user", 0, req.Username, false, err.Error()) + utils.FailMsg(c, err.Error()) + return + } + ctl.svc.RecordOpLog(ctl.opCtx(c), "create", "user", u.ID, u.Username, true, "") + utils.OK(c, gin.H{"id": u.ID, "username": u.Username, "email": u.Email}) +} + +type loginReq struct { + Account string `json:"account" binding:"required"` // 用户名或邮箱 + Password string `json:"password" binding:"required"` +} + +// Login 用户登录 +func (ctl *AuthController) Login(c *gin.Context) { + var req loginReq + if !bindJSON(c, &req) { + return + } + ip := utils.ClientIP(c) + u, token, err := ctl.svc.Auth.Login(req.Account, req.Password, ip) + if err != nil { + ctl.svc.RecordOpLog(ctl.opCtx(c), "login", "user", 0, req.Account, false, err.Error()) + utils.Unauthorized(c, err.Error()) + return + } + oc := ctl.opCtx(c) + uid := u.ID + oc.UserID = &uid + oc.Username = u.Username + ctl.svc.RecordOpLog(oc, "login", "user", u.ID, u.Username, true, "") + + utils.OK(c, gin.H{ + "token": token, + "user": gin.H{ + "id": u.ID, + "username": u.Username, + "email": u.Email, + "role": u.Role, + "storage_used": u.StorageUsed, + "storage_limit": u.StorageLimit, + }, + }) +} + +type createAPIKeyReq struct { + Name string `json:"name"` +} + +// CreateAPIKey 生成API密钥 +func (ctl *AuthController) CreateAPIKey(c *gin.Context) { + var req createAPIKeyReq + if !bindJSON(c, &req) { + return + } + userID := middleware.CurrentUserID(c) + key, secret, err := ctl.svc.Auth.CreateAPIKey(userID, req.Name) + if err != nil { + utils.FailMsg(c, err.Error()) + return + } + ctl.svc.RecordOpLog(ctl.opCtx(c), "create", "apikey", key.ID, key.Name, true, "") + utils.OK(c, gin.H{ + "id": key.ID, + "access_key": key.AccessKey, + "secret_key": secret, // 仅创建时返回一次 + "name": key.Name, + "notice": "secret_key仅在创建时展示,请妥善保存", + }) +} + +// ListAPIKeys 密钥列表 +func (ctl *AuthController) ListAPIKeys(c *gin.Context) { + userID := middleware.CurrentUserID(c) + keys, err := ctl.svc.Auth.ListAPIKeys(userID) + if err != nil { + utils.ServerError(c, err.Error()) + return + } + utils.OK(c, keys) +} + +// DeleteAPIKey 删除密钥 +func (ctl *AuthController) DeleteAPIKey(c *gin.Context) { + id, ok := paramID(c) + if !ok { + return + } + userID := middleware.CurrentUserID(c) + if err := ctl.svc.Auth.DeleteAPIKey(userID, id); err != nil { + utils.FailMsg(c, err.Error()) + return + } + ctl.svc.RecordOpLog(ctl.opCtx(c), "delete", "apikey", id, "", true, "") + utils.OKMsg(c, "密钥已删除") +} diff --git a/go/internal/controller/base.go b/go/internal/controller/base.go new file mode 100644 index 0000000..da2f1dd --- /dev/null +++ b/go/internal/controller/base.go @@ -0,0 +1,71 @@ +package controller + +import ( + "strconv" + + "github.com/gin-gonic/gin" + + "filestoragesystem/internal/middleware" + "filestoragesystem/internal/service" + "filestoragesystem/internal/utils" +) + +// Base 控制器基类 +type Base struct { + svc *service.Services +} + +// NewBase 创建基类 +func NewBase(svc *service.Services) *Base { return &Base{svc: svc} } + +// opCtx 从请求上下文提取操作日志上下文 +func (b *Base) opCtx(c *gin.Context) *service.OpLogContext { + ctx := &service.OpLogContext{ + IP: utils.ClientIP(c), + UserAgent: c.GetHeader("User-Agent"), + Path: c.Request.URL.Path, + Method: c.Request.Method, + Username: c.GetString(middleware.CtxUsername), + } + if uid := middleware.CurrentUserID(c); uid > 0 { + ctx.UserID = &uid + } + return ctx +} + +// bindJSON 绑定JSON请求体 +func bindJSON(c *gin.Context, obj interface{}) bool { + if err := c.ShouldBindJSON(obj); err != nil { + utils.FailMsg(c, "参数错误: "+err.Error()) + return false + } + return true +} + +// paramID 解析路径参数ID +func paramID(c *gin.Context) (uint, bool) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + utils.FailMsg(c, "无效的资源ID") + return 0, false + } + return uint(id), true +} + +// parseUintQuery 解析整型query参数 +func parseUintQuery(c *gin.Context, key string) uint { + v, err := strconv.ParseUint(c.Query(key), 10, 64) + if err != nil { + return 0 + } + return uint(v) +} + +// parseIntQuery 解析整型query参数(可为负) +func parseIntQuery(c *gin.Context, key string, def int) int { + n, err := strconv.Atoi(c.Query(key)) + if err != nil { + return def + } + return n +} diff --git a/go/internal/controller/file.go b/go/internal/controller/file.go new file mode 100644 index 0000000..cb1cd79 --- /dev/null +++ b/go/internal/controller/file.go @@ -0,0 +1,194 @@ +package controller + +import ( + "fmt" + "net/http" + "net/url" + "path/filepath" + + "github.com/gin-gonic/gin" + + "filestoragesystem/internal/middleware" + "filestoragesystem/internal/repository" + "filestoragesystem/internal/utils" +) + +// FileController 文件控制器 +type FileController struct { + *Base +} + +// NewFileController 创建文件控制器 +func NewFileController(b *Base) *FileController { return &FileController{Base: b} } + +// Upload 上传文件 +func (ctl *FileController) Upload(c *gin.Context) { + fh, err := c.FormFile("file") + if err != nil { + utils.FailMsg(c, "缺少上传文件字段file") + return + } + userID := middleware.CurrentUserID(c) + f, err := ctl.svc.File.Upload(c, userID, fh) + if err != nil { + ctl.svc.RecordOpLog(ctl.opCtx(c), "upload", "file", 0, fh.Filename, false, err.Error()) + utils.FailMsg(c, err.Error()) + return + } + ctl.svc.RecordOpLog(ctl.opCtx(c), "upload", "file", f.ID, f.Filename, true, "") + utils.OK(c, f) +} + +// List 文件列表(支持project_id/filename/prefix/trash过滤) +func (ctl *FileController) List(c *gin.Context) { + userID := middleware.CurrentUserID(c) + page, pageSize := utils.ParsePage(c) + filter := repository.FileListFilter{ + ProjectID: parseUintQuery(c, "project_id"), + Filename: c.Query("filename"), + Prefix: utils.SanitizePath(c.Query("prefix")), + Trashed: c.Query("trash") == "true", + Page: page, + PageSize: pageSize, + } + if v := parseUintQuery(c, "visibility"); v > 0 { + filter.Visibility = int8(v) + } + files, total, err := ctl.svc.File.List(userID, middleware.IsAdmin(c), filter) + if err != nil { + utils.ServerError(c, err.Error()) + return + } + utils.OKPage(c, files, total, page, pageSize) +} + +// GetInfo 获取文件信息 +func (ctl *FileController) GetInfo(c *gin.Context) { + id, ok := paramID(c) + if !ok { + return + } + userID := middleware.CurrentUserID(c) + f, err := ctl.svc.File.GetInfo(userID, id, middleware.IsAdmin(c)) + if err != nil { + utils.FailMsg(c, err.Error()) + return + } + utils.OK(c, f) +} + +// Download 下载文件 +func (ctl *FileController) Download(c *gin.Context) { + id, ok := paramID(c) + if !ok { + return + } + userID := middleware.CurrentUserID(c) + f, fp, err := ctl.svc.File.Open(c, userID, id, middleware.IsAdmin(c)) + if err != nil { + utils.FailMsg(c, err.Error()) + return + } + defer fp.Close() + + ctl.svc.File.RecordDownload(c, f) + ctl.svc.RecordOpLog(ctl.opCtx(c), "download", "file", f.ID, f.Filename, true, "") + + c.Header("Content-Disposition", + fmt.Sprintf(`attachment; filename*=UTF-8''%s`, url.PathEscape(filepath.Base(f.Filename)))) + c.Header("Content-Type", f.MimeType) + http.ServeContent(c.Writer, c.Request, filepath.Base(f.Filename), f.UpdatedAt, fp) +} + +// Delete 删除文件(permanent=true硬删除,否则进入回收站) +func (ctl *FileController) Delete(c *gin.Context) { + id, ok := paramID(c) + if !ok { + return + } + userID := middleware.CurrentUserID(c) + permanent := c.Query("permanent") == "true" + f, _ := ctl.svc.File.GetInfo(userID, id, middleware.IsAdmin(c)) + if err := ctl.svc.File.Delete(userID, id, middleware.IsAdmin(c), permanent); err != nil { + utils.FailMsg(c, err.Error()) + return + } + name := "" + if f != nil { + name = f.Filename + } + ctl.svc.RecordOpLog(ctl.opCtx(c), "delete", "file", id, name, true, "") + if permanent { + utils.OKMsg(c, "文件已彻底删除") + } else { + utils.OKMsg(c, "文件已移入回收站") + } +} + +// Restore 恢复回收站文件 +func (ctl *FileController) Restore(c *gin.Context) { + id, ok := paramID(c) + if !ok { + return + } + userID := middleware.CurrentUserID(c) + if err := ctl.svc.File.Restore(userID, id, middleware.IsAdmin(c)); err != nil { + utils.FailMsg(c, err.Error()) + return + } + ctl.svc.RecordOpLog(ctl.opCtx(c), "update", "file", id, "restore", true, "") + utils.OKMsg(c, "文件已恢复") +} + +type tempLinkReq struct { + Password string `json:"password"` + MaxCount int `json:"max_count"` + ExpiresIn int64 `json:"expires_in"` // 秒,0使用默认 +} + +// CreateTempLink 生成临时访问链接 +func (ctl *FileController) CreateTempLink(c *gin.Context) { + id, ok := paramID(c) + if !ok { + return + } + var req tempLinkReq + if !bindJSON(c, &req) { + return + } + userID := middleware.CurrentUserID(c) + link, err := ctl.svc.File.CreateTempLink(userID, id, middleware.IsAdmin(c), req.Password, req.MaxCount, req.ExpiresIn) + if err != nil { + utils.FailMsg(c, err.Error()) + return + } + ctl.svc.RecordOpLog(ctl.opCtx(c), "create", "file", id, "temp_link", true, "") + utils.OK(c, gin.H{ + "token": link.Token, + "url": "/api/v1/files/temp/" + link.Token, + "expires_at": link.ExpiresAt, + "has_password": link.HasPwd, + "max_count": link.MaxCount, + }) +} + +// TempAccess 通过临时链接访问文件(公开接口,支持password参数) +func (ctl *FileController) TempAccess(c *gin.Context) { + token := c.Param("token") + password := c.Query("password") + if password == "" { + password = c.GetHeader("X-Link-Password") + } + f, fp, err := ctl.svc.File.ResolveTempLink(token, password) + if err != nil { + utils.FailMsg(c, err.Error()) + return + } + defer fp.Close() + + ctl.svc.File.RecordDownload(c, f) + c.Header("Content-Disposition", + fmt.Sprintf(`inline; filename*=UTF-8''%s`, url.PathEscape(filepath.Base(f.Filename)))) + c.Header("Content-Type", f.MimeType) + http.ServeContent(c.Writer, c.Request, filepath.Base(f.Filename), f.UpdatedAt, fp) +} diff --git a/go/internal/controller/project.go b/go/internal/controller/project.go new file mode 100644 index 0000000..d8cd63b --- /dev/null +++ b/go/internal/controller/project.go @@ -0,0 +1,113 @@ +package controller + +import ( + "github.com/gin-gonic/gin" + + "filestoragesystem/internal/middleware" + "filestoragesystem/internal/utils" +) + +// ProjectController 项目控制器 +type ProjectController struct { + *Base +} + +// NewProjectController 创建项目控制器 +func NewProjectController(b *Base) *ProjectController { return &ProjectController{Base: b} } + +type createProjectReq struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` + StorageLimit int64 `json:"storage_limit"` // 0使用默认 +} + +// Create 创建项目 +func (ctl *ProjectController) Create(c *gin.Context) { + var req createProjectReq + if !bindJSON(c, &req) { + return + } + userID := middleware.CurrentUserID(c) + p, err := ctl.svc.Project.Create(userID, req.Name, req.Description, req.StorageLimit) + if err != nil { + ctl.svc.RecordOpLog(ctl.opCtx(c), "create", "project", 0, req.Name, false, err.Error()) + utils.FailMsg(c, err.Error()) + return + } + ctl.svc.RecordOpLog(ctl.opCtx(c), "create", "project", p.ID, p.Name, true, "") + utils.OK(c, p) +} + +// List 项目列表 +func (ctl *ProjectController) List(c *gin.Context) { + userID := middleware.CurrentUserID(c) + page, pageSize := utils.ParsePage(c) + all := c.Query("all") == "true" && middleware.IsAdmin(c) + projects, total, err := ctl.svc.Project.List(userID, c.Query("keyword"), all, page, pageSize) + if err != nil { + utils.ServerError(c, err.Error()) + return + } + utils.OKPage(c, projects, total, page, pageSize) +} + +// Get 项目详情 +func (ctl *ProjectController) Get(c *gin.Context) { + id, ok := paramID(c) + if !ok { + return + } + userID := middleware.CurrentUserID(c) + p, err := ctl.svc.Project.Get(userID, id, middleware.IsAdmin(c)) + if err != nil { + utils.FailMsg(c, err.Error()) + return + } + utils.OK(c, p) +} + +type updateProjectReq struct { + Name string `json:"name"` + Description string `json:"description"` + StorageLimit int64 `json:"storage_limit"` +} + +// Update 更新项目 +func (ctl *ProjectController) Update(c *gin.Context) { + id, ok := paramID(c) + if !ok { + return + } + var req updateProjectReq + if !bindJSON(c, &req) { + return + } + userID := middleware.CurrentUserID(c) + p, err := ctl.svc.Project.Update(userID, id, middleware.IsAdmin(c), req.Name, req.Description, req.StorageLimit) + if err != nil { + utils.FailMsg(c, err.Error()) + return + } + ctl.svc.RecordOpLog(ctl.opCtx(c), "update", "project", id, p.Name, true, "") + utils.OK(c, p) +} + +// Delete 删除项目 +func (ctl *ProjectController) Delete(c *gin.Context) { + id, ok := paramID(c) + if !ok { + return + } + userID := middleware.CurrentUserID(c) + p, _ := ctl.svc.Project.Get(userID, id, middleware.IsAdmin(c)) + if err := ctl.svc.Project.Delete(userID, id, middleware.IsAdmin(c)); err != nil { + utils.FailMsg(c, err.Error()) + return + } + name := "" + if p != nil { + name = p.Name + } + ctl.svc.RecordOpLog(ctl.opCtx(c), "delete", "project", id, name, true, "") + utils.OKMsg(c, "项目已删除") +} diff --git a/go/internal/controller/role.go b/go/internal/controller/role.go new file mode 100644 index 0000000..66278f6 --- /dev/null +++ b/go/internal/controller/role.go @@ -0,0 +1,136 @@ +package controller + +import ( + "github.com/gin-gonic/gin" + + "filestoragesystem/internal/utils" +) + +// RoleController 角色权限控制器 +type RoleController struct { + *Base +} + +// NewRoleController 创建角色控制器 +func NewRoleController(b *Base) *RoleController { return &RoleController{Base: b} } + +// ListRoles 角色列表 +func (ctl *RoleController) ListRoles(c *gin.Context) { + roles, err := ctl.svc.Role.ListRoles() + if err != nil { + utils.ServerError(c, err.Error()) + return + } + utils.OK(c, roles) +} + +// GetRole 角色详情 +func (ctl *RoleController) GetRole(c *gin.Context) { + id, ok := paramID(c) + if !ok { + return + } + role, err := ctl.svc.Role.GetRole(id) + if err != nil { + utils.NotFound(c, "角色不存在") + return + } + utils.OK(c, role) +} + +type createRoleReq struct { + Name string `json:"name" binding:"required"` + Code string `json:"code" binding:"required"` + Description string `json:"description"` + Permissions []uint `json:"permissions"` +} + +// CreateRole 创建角色 +func (ctl *RoleController) CreateRole(c *gin.Context) { + var req createRoleReq + if !bindJSON(c, &req) { + return + } + role, err := ctl.svc.Role.CreateRole(req.Name, req.Code, req.Description, req.Permissions) + if err != nil { + utils.FailMsg(c, err.Error()) + return + } + ctl.svc.RecordOpLog(ctl.opCtx(c), "create", "role", role.ID, role.Name, true, "") + utils.OK(c, role) +} + +type updateRoleReq struct { + Name string `json:"name"` + Description string `json:"description"` + Permissions []uint `json:"permissions"` +} + +// UpdateRole 编辑角色 +func (ctl *RoleController) UpdateRole(c *gin.Context) { + id, ok := paramID(c) + if !ok { + return + } + var req updateRoleReq + if !bindJSON(c, &req) { + return + } + role, err := ctl.svc.Role.UpdateRole(id, req.Name, req.Description, req.Permissions) + if err != nil { + utils.FailMsg(c, err.Error()) + return + } + ctl.svc.RecordOpLog(ctl.opCtx(c), "update", "role", id, role.Name, true, "") + utils.OK(c, role) +} + +// DeleteRole 删除角色 +func (ctl *RoleController) DeleteRole(c *gin.Context) { + id, ok := paramID(c) + if !ok { + return + } + if err := ctl.svc.Role.DeleteRole(id); err != nil { + utils.FailMsg(c, err.Error()) + return + } + ctl.svc.RecordOpLog(ctl.opCtx(c), "delete", "role", id, "", true, "") + utils.OKMsg(c, "角色已删除") +} + +type setPermissionsReq struct { + Permissions []uint `json:"permissions"` +} + +// SetPermissions 配置角色权限 +func (ctl *RoleController) SetPermissions(c *gin.Context) { + id, ok := paramID(c) + if !ok { + return + } + var req setPermissionsReq + if !bindJSON(c, &req) { + return + } + if err := ctl.svc.Role.SetPermissions(id, req.Permissions); err != nil { + utils.FailMsg(c, err.Error()) + return + } + ctl.svc.RecordOpLog(ctl.opCtx(c), "update", "role", id, "permissions", true, "") + utils.OKMsg(c, "角色权限已更新") +} + +// ListPermissions 权限列表(按模块分组) +func (ctl *RoleController) ListPermissions(c *gin.Context) { + groups, order, err := ctl.svc.Role.PermissionsGrouped() + if err != nil { + utils.ServerError(c, err.Error()) + return + } + result := make([]gin.H, 0, len(order)) + for _, module := range order { + result = append(result, gin.H{"module": module, "permissions": groups[module]}) + } + utils.OK(c, result) +} diff --git a/go/internal/controller/stats.go b/go/internal/controller/stats.go new file mode 100644 index 0000000..00b9189 --- /dev/null +++ b/go/internal/controller/stats.go @@ -0,0 +1,54 @@ +package controller + +import ( + "github.com/gin-gonic/gin" + + "filestoragesystem/internal/middleware" + "filestoragesystem/internal/utils" +) + +// StatsController 统计控制器 +type StatsController struct { + *Base +} + +// NewStatsController 创建统计控制器 +func NewStatsController(b *Base) *StatsController { return &StatsController{Base: b} } + +// UserStats 用户存储统计 +func (ctl *StatsController) UserStats(c *gin.Context) { + userID := middleware.CurrentUserID(c) + stats, err := ctl.svc.Stats.UserStats(userID) + if err != nil { + utils.ServerError(c, err.Error()) + return + } + utils.OK(c, stats) +} + +// ProjectStats 项目存储统计 +func (ctl *StatsController) ProjectStats(c *gin.Context) { + id, ok := paramID(c) + if !ok { + return + } + userID := middleware.CurrentUserID(c) + stats, err := ctl.svc.Stats.ProjectStats(userID, id, middleware.IsAdmin(c)) + if err != nil { + utils.FailMsg(c, err.Error()) + return + } + utils.OK(c, stats) +} + +// TrafficStats 流量统计 +func (ctl *StatsController) TrafficStats(c *gin.Context) { + userID := middleware.CurrentUserID(c) + days := parseIntQuery(c, "days", 30) + stats, err := ctl.svc.Stats.TrafficStats(userID, days) + if err != nil { + utils.ServerError(c, err.Error()) + return + } + utils.OK(c, stats) +} diff --git a/go/internal/controller/user.go b/go/internal/controller/user.go new file mode 100644 index 0000000..dfea60c --- /dev/null +++ b/go/internal/controller/user.go @@ -0,0 +1,67 @@ +package controller + +import ( + "github.com/gin-gonic/gin" + + "filestoragesystem/internal/middleware" + "filestoragesystem/internal/utils" +) + +// UserController 用户管理控制器(个人资料) +type UserController struct { + *Base +} + +// NewUserController 创建用户控制器 +func NewUserController(b *Base) *UserController { return &UserController{Base: b} } + +// GetProfile 获取个人资料 +func (ctl *UserController) GetProfile(c *gin.Context) { + userID := middleware.CurrentUserID(c) + u, err := ctl.svc.User.GetProfile(userID) + if err != nil { + utils.NotFound(c, "用户不存在") + return + } + utils.OK(c, u) +} + +type updateProfileReq struct { + Email string `json:"email"` +} + +// UpdateProfile 更新个人资料 +func (ctl *UserController) UpdateProfile(c *gin.Context) { + var req updateProfileReq + if !bindJSON(c, &req) { + return + } + userID := middleware.CurrentUserID(c) + if err := ctl.svc.User.UpdateProfile(userID, req.Email); err != nil { + utils.FailMsg(c, err.Error()) + return + } + ctl.svc.RecordOpLog(ctl.opCtx(c), "update", "user", userID, "", true, "") + utils.OKMsg(c, "资料已更新") +} + +type changePasswordReq struct { + OldPassword string `json:"old_password" binding:"required"` + NewPassword string `json:"new_password" binding:"required"` +} + +// ChangePassword 修改密码 +func (ctl *UserController) ChangePassword(c *gin.Context) { + var req changePasswordReq + if !bindJSON(c, &req) { + return + } + userID := middleware.CurrentUserID(c) + if err := ctl.svc.User.ChangePassword(userID, req.OldPassword, req.NewPassword); err != nil { + ctl.svc.RecordOpLog(ctl.opCtx(c), "update", "user", userID, "password", false, err.Error()) + utils.FailMsg(c, err.Error()) + return + } + ctl.svc.RecordOpLog(ctl.opCtx(c), "update", "user", userID, "password", true, "") + utils.OKMsg(c, "密码已修改") +} diff --git a/go/internal/controller/webhook.go b/go/internal/controller/webhook.go new file mode 100644 index 0000000..e08160a --- /dev/null +++ b/go/internal/controller/webhook.go @@ -0,0 +1,92 @@ +package controller + +import ( + "github.com/gin-gonic/gin" + + "filestoragesystem/internal/middleware" + "filestoragesystem/internal/utils" +) + +// WebhookController Webhook控制器 +type WebhookController struct { + *Base +} + +// NewWebhookController 创建Webhook控制器 +func NewWebhookController(b *Base) *WebhookController { return &WebhookController{Base: b} } + +type createWebhookReq struct { + URL string `json:"url" binding:"required"` + ProjectID *uint `json:"project_id"` + Secret string `json:"secret"` + Events string `json:"events" binding:"required"` // 如 file.upload,file.delete + Status int8 `json:"status"` +} + +// Create 创建webhook +func (ctl *WebhookController) Create(c *gin.Context) { + var req createWebhookReq + if !bindJSON(c, &req) { + return + } + userID := middleware.CurrentUserID(c) + w, err := ctl.svc.Webhook.Create(userID, req.ProjectID, req.URL, req.Secret, req.Events, req.Status) + if err != nil { + utils.FailMsg(c, err.Error()) + return + } + ctl.svc.RecordOpLog(ctl.opCtx(c), "create", "webhook", w.ID, w.URL, true, "") + utils.OK(c, w) +} + +// List webhook列表 +func (ctl *WebhookController) List(c *gin.Context) { + userID := middleware.CurrentUserID(c) + hooks, err := ctl.svc.Webhook.List(userID) + if err != nil { + utils.ServerError(c, err.Error()) + return + } + utils.OK(c, hooks) +} + +type updateWebhookReq struct { + URL string `json:"url"` + Events string `json:"events"` + Status int8 `json:"status"` +} + +// Update 更新webhook +func (ctl *WebhookController) Update(c *gin.Context) { + id, ok := paramID(c) + if !ok { + return + } + var req updateWebhookReq + if !bindJSON(c, &req) { + return + } + userID := middleware.CurrentUserID(c) + w, err := ctl.svc.Webhook.Update(userID, id, req.URL, req.Events, req.Status) + if err != nil { + utils.FailMsg(c, err.Error()) + return + } + ctl.svc.RecordOpLog(ctl.opCtx(c), "update", "webhook", id, w.URL, true, "") + utils.OK(c, w) +} + +// Delete 删除webhook +func (ctl *WebhookController) Delete(c *gin.Context) { + id, ok := paramID(c) + if !ok { + return + } + userID := middleware.CurrentUserID(c) + if err := ctl.svc.Webhook.Delete(userID, id); err != nil { + utils.FailMsg(c, err.Error()) + return + } + ctl.svc.RecordOpLog(ctl.opCtx(c), "delete", "webhook", id, "", true, "") + utils.OKMsg(c, "webhook已删除") +} diff --git a/go/internal/middleware/auth.go b/go/internal/middleware/auth.go new file mode 100644 index 0000000..64c8a30 --- /dev/null +++ b/go/internal/middleware/auth.go @@ -0,0 +1,109 @@ +package middleware + +import ( + "strings" + + "github.com/gin-gonic/gin" + + "filestoragesystem/internal/model" + "filestoragesystem/internal/service" + "filestoragesystem/internal/utils" + "filestoragesystem/pkg/apperr" +) + +// 上下文键 +const ( + CtxUser = "authUser" // *model.User + CtxUserID = "authUserID" // uint + CtxUsername = "authUsername" // string + CtxRoleCode = "authRoleCode" // string + CtxPermissions = "authPermissions" // map[string]bool +) + +// Auth 认证中间件:支持 JWT Bearer 与 API Key HMAC-SHA256签名两种方式 +func Auth(authSvc *service.AuthService) gin.HandlerFunc { + return func(c *gin.Context) { + var user *model.User + + // 1. JWT Bearer Token + header := c.GetHeader("Authorization") + if strings.HasPrefix(header, "Bearer ") { + claims, err := utils.ParseToken(strings.TrimPrefix(header, "Bearer ")) + if err != nil { + utils.Unauthorized(c, "Token无效或已过期") + return + } + u, err := authSvc.LoadUserByID(claims.UserID) + if err != nil { + utils.Unauthorized(c, apperr.ErrUnauthorized.Error()) + return + } + user = u + } else if ak := c.GetHeader("X-Access-Key"); ak != "" { + // 2. API Key + Secret HMAC-SHA256签名 + u, err := authSvc.VerifyAPIKeySignature( + ak, + c.GetHeader("X-Timestamp"), + c.GetHeader("X-Nonce"), + c.GetHeader("X-Signature"), + c.Request.Method, + c.Request.URL.Path, + ) + if err != nil { + utils.Unauthorized(c, err.Error()) + return + } + user = u + } else { + utils.Unauthorized(c, "缺少认证信息") + return + } + + if user == nil { + utils.Unauthorized(c, apperr.ErrUnauthorized.Error()) + return + } + + c.Set(CtxUser, user) + c.Set(CtxUserID, user.ID) + c.Set(CtxUsername, user.Username) + roleCode := "" + if user.Role != nil { + roleCode = user.Role.Code + } + c.Set(CtxRoleCode, roleCode) + c.Set(CtxPermissions, service.PermissionCodesOf(user)) + c.Next() + } +} + +// CurrentUser 从上下文获取当前用户 +func CurrentUser(c *gin.Context) *model.User { + if v, ok := c.Get(CtxUser); ok { + if u, ok := v.(*model.User); ok { + return u + } + } + return nil +} + +// CurrentUserID 当前用户ID +func CurrentUserID(c *gin.Context) uint { + if v, ok := c.Get(CtxUserID); ok { + if id, ok := v.(uint); ok { + return id + } + } + return 0 +} + +// IsSuperAdmin 是否超级管理员 +func IsSuperAdmin(c *gin.Context) bool { + return c.GetString(CtxRoleCode) == "super_admin" +} + +// IsAdmin 是否管理员(super_admin或admin) +func IsAdmin(c *gin.Context) bool { + rc := c.GetString(CtxRoleCode) + return rc == "super_admin" || rc == "admin" +} diff --git a/go/internal/middleware/cors.go b/go/internal/middleware/cors.go new file mode 100644 index 0000000..475448e --- /dev/null +++ b/go/internal/middleware/cors.go @@ -0,0 +1,27 @@ +package middleware + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// CORS 跨域中间件 +func CORS() gin.HandlerFunc { + return func(c *gin.Context) { + origin := c.GetHeader("Origin") + if origin != "" { + c.Header("Access-Control-Allow-Origin", origin) + c.Header("Access-Control-Allow-Credentials", "true") + c.Header("Access-Control-Allow-Headers", + "Origin, Content-Type, Accept, Authorization, X-Access-Key, X-Timestamp, X-Nonce, X-Signature, X-Request-Id") + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH") + c.Header("Access-Control-Max-Age", "86400") + } + if c.Request.Method == http.MethodOptions { + c.AbortWithStatus(http.StatusNoContent) + return + } + c.Next() + } +} diff --git a/go/internal/middleware/logger.go b/go/internal/middleware/logger.go new file mode 100644 index 0000000..3076a79 --- /dev/null +++ b/go/internal/middleware/logger.go @@ -0,0 +1,60 @@ +package middleware + +import ( + "time" + + "github.com/gin-gonic/gin" + + "filestoragesystem/internal/utils" +) + +// RequestID 请求ID中间件 +func RequestID() gin.HandlerFunc { + return func(c *gin.Context) { + id := c.GetHeader("X-Request-Id") + if id == "" { + id = utils.RandomKey(8) + } + c.Set("requestID", id) + c.Header("X-Request-Id", id) + c.Next() + } +} + +// Logger 访问日志中间件 +func Logger() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + path := c.Request.URL.Path + + c.Next() + + latency := time.Since(start) + status := c.Writer.Status() + + args := []interface{}{ + "status", status, + "method", c.Request.Method, + "path", path, + "ip", utils.ClientIP(c), + "latency", latency.String(), + "request_id", c.GetString("requestID"), + } + if uid := CurrentUserID(c); uid > 0 { + args = append(args, "user_id", uid) + } + if err := c.Errors.ByType(gin.ErrorTypePrivate).Last(); err != nil { + args = append(args, "error", err.Error()) + } + + sugar := utils.Logger().Sugar() + switch { + case status >= 500: + sugar.Errorw("access", args...) + case status >= 400: + sugar.Warnw("access", args...) + default: + sugar.Infow("access", args...) + } + } +} diff --git a/go/internal/middleware/permission.go b/go/internal/middleware/permission.go new file mode 100644 index 0000000..53fdc5d --- /dev/null +++ b/go/internal/middleware/permission.go @@ -0,0 +1,28 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" + + "filestoragesystem/internal/utils" +) + +// RequirePermission 权限检查中间件;super_admin拥有全部权限 +func RequirePermission(code string) gin.HandlerFunc { + return func(c *gin.Context) { + perms, ok := c.Get(CtxPermissions) + if !ok { + utils.Unauthorized(c, "未认证") + return + } + m, ok := perms.(map[string]bool) + if !ok { + utils.Forbidden(c, "权限信息异常") + return + } + if m["*"] || m[code] { + c.Next() + return + } + utils.Forbidden(c, "没有操作权限: "+code) + } +} diff --git a/go/internal/middleware/rate_limit.go b/go/internal/middleware/rate_limit.go new file mode 100644 index 0000000..d1f434c --- /dev/null +++ b/go/internal/middleware/rate_limit.go @@ -0,0 +1,69 @@ +package middleware + +import ( + "fmt" + "net/http" + "sync" + "time" + + "github.com/gin-gonic/gin" + "golang.org/x/time/rate" + + "filestoragesystem/internal/repository" + "filestoragesystem/internal/utils" +) + +// RateLimit 令牌桶限流中间件 +// 限额从系统设置api_rate_limit读取(次/分钟),按用户ID(已认证)或IP进行限制 +func RateLimit(settingRepo *repository.SettingRepo) gin.HandlerFunc { + var mu sync.Mutex + buckets := make(map[string]*rate.Limiter) + lastClean := time.Now() + + getLimit := func() int { + if v, err := settingRepo.GetValue("api_rate_limit"); err == nil { + var n int + if _, e := fmt.Sscanf(v, "%d", &n); e == nil && n > 0 { + return n + } + } + return 100 + } + + return func(c *gin.Context) { + // 管理员后台不限流 + if IsAdmin(c) { + c.Next() + return + } + + key := utils.ClientIP(c) + if uid := CurrentUserID(c); uid > 0 { + key = fmt.Sprintf("user:%d", uid) + } + + limit := getLimit() + mu.Lock() + // 定期清理空闲桶 + if time.Since(lastClean) > 10*time.Minute { + buckets = make(map[string]*rate.Limiter) + lastClean = time.Now() + } + limiter, ok := buckets[key] + if !ok { + // 每分钟limit次:速率=limit/60每秒,突发=limit + limiter = rate.NewLimiter(rate.Limit(float64(limit)/60.0), limit) + buckets[key] = limiter + } + mu.Unlock() + + if !limiter.Allow() { + c.Header("Retry-After", "60") + c.AbortWithStatusJSON(http.StatusTooManyRequests, utils.Response{ + Code: 429, Message: "请求过于频繁,请稍后再试", + }) + return + } + c.Next() + } +} diff --git a/go/internal/middleware/site.go b/go/internal/middleware/site.go new file mode 100644 index 0000000..2ab9267 --- /dev/null +++ b/go/internal/middleware/site.go @@ -0,0 +1,42 @@ +package middleware + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "filestoragesystem/internal/service" + "filestoragesystem/internal/utils" +) + +// SiteStatus 站点状态中间件 +// 站点关闭(维护模式)时:放行状态接口/登录接口/管理员请求,其余返回503 +func SiteStatus(adminSvc *service.AdminService) gin.HandlerFunc { + return func(c *gin.Context) { + if adminSvc.IsSiteEnabled() { + c.Next() + return + } + + // 放行的路径 + path := c.Request.URL.Path + if path == "/api/v1/status" || path == "/api/v1/auth/login" { + c.Next() + return + } + + // 已认证的管理员放行(需在Auth之后挂载) + if IsAdmin(c) { + c.Next() + return + } + + msg := "系统维护中,请稍后再访问" + if m, ok := adminSvc.SiteStatus()["maintenance_msg"].(string); ok && m != "" { + msg = m + } + c.AbortWithStatusJSON(http.StatusServiceUnavailable, utils.Response{ + Code: http.StatusServiceUnavailable, Message: msg, + }) + } +} diff --git a/go/internal/model/models.go b/go/internal/model/models.go new file mode 100644 index 0000000..9903c93 --- /dev/null +++ b/go/internal/model/models.go @@ -0,0 +1,169 @@ +package model + +import "time" + +// Role 角色 +type Role struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:50;uniqueIndex;not null" json:"name"` + Code string `gorm:"size:50;uniqueIndex;not null" json:"code"` + Description string `gorm:"size:255" json:"description"` + IsSystem bool `gorm:"default:false" json:"is_system"` + Status int8 `gorm:"default:1" json:"status"` // 1:启用 0:禁用 + Permissions []Permission `gorm:"many2many:role_permissions;" json:"permissions,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Permission 权限 +type Permission struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:50;not null" json:"name"` + Code string `gorm:"size:100;uniqueIndex;not null" json:"code"` + Module string `gorm:"size:50;not null" json:"module"` + Description string `gorm:"size:255" json:"description"` + CreatedAt time.Time `json:"created_at"` +} + +// User 用户 +type User struct { + ID uint `gorm:"primaryKey" json:"id"` + Username string `gorm:"size:50;uniqueIndex;not null" json:"username"` + Email string `gorm:"size:100;uniqueIndex;not null" json:"email"` + PasswordHash string `gorm:"size:255;not null" json:"-"` + RoleID uint `gorm:"default:1" json:"role_id"` + Role *Role `gorm:"foreignKey:RoleID" json:"role,omitempty"` + Status int8 `gorm:"default:1" json:"status"` // 1:正常 0:禁用 + StorageUsed int64 `gorm:"default:0" json:"storage_used"` + StorageLimit int64 `gorm:"default:10737418240" json:"storage_limit"` + LastLoginAt *time.Time `json:"last_login_at"` + LastLoginIP string `gorm:"size:50" json:"last_login_ip"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// APIKey API密钥 +type APIKey struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"index;not null" json:"user_id"` + AccessKey string `gorm:"size:32;uniqueIndex;not null" json:"access_key"` + SecretKey string `gorm:"size:64;not null" json:"-"` + Name string `gorm:"size:50" json:"name"` + Status int8 `gorm:"default:1" json:"status"` + LastUsedAt *time.Time `json:"last_used_at"` + CreatedAt time.Time `json:"created_at"` +} + +// Project 项目 +type Project struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"index;not null" json:"user_id"` + Name string `gorm:"size:100;not null;uniqueIndex:idx_user_project" json:"name"` + Description string `json:"description"` + StorageUsed int64 `gorm:"default:0" json:"storage_used"` + StorageLimit int64 `gorm:"default:5368709120" json:"storage_limit"` + Status int8 `gorm:"default:1" json:"status"` // 1:正常 0:禁用 + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// File 文件 +type File struct { + ID uint `gorm:"primaryKey" json:"id"` + ProjectID uint `gorm:"index;not null" json:"project_id"` + UserID uint `gorm:"index;not null" json:"user_id"` + Filename string `gorm:"size:500;not null" json:"filename"` // 原始文件名(可含虚拟目录) + StoredPath string `gorm:"size:500;not null" json:"-"` // 物理存储路径 + Size int64 `gorm:"not null" json:"size"` + MimeType string `gorm:"size:100" json:"mime_type"` + MD5 string `gorm:"size:32;index" json:"md5"` + StorageType int8 `gorm:"default:1" json:"storage_type"` // 1:本地存储 + Visibility int8 `gorm:"default:1" json:"visibility"` // 1:私有 2:公开 + DownloadCount int64 `gorm:"default:0" json:"download_count"` + Status int8 `gorm:"default:1;index" json:"status"` // 1:正常 0:已删除(回收站) + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt *time.Time `json:"deleted_at"` + Project *Project `gorm:"foreignKey:ProjectID" json:"project,omitempty"` +} + +// TempLink 临时访问链接 +type TempLink struct { + ID uint `gorm:"primaryKey" json:"id"` + FileID uint `gorm:"index;not null" json:"file_id"` + UserID uint `gorm:"not null" json:"user_id"` + Token string `gorm:"size:64;uniqueIndex;not null" json:"token"` + Password string `gorm:"size:50" json:"-"` + HasPwd bool `gorm:"-" json:"has_password"` + MaxCount int `gorm:"default:0" json:"max_count"` // 0为不限 + UsedCount int `gorm:"default:0" json:"used_count"` + ExpiresAt time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` +} + +// TrafficLog 流量日志 +type TrafficLog struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"index;not null" json:"user_id"` + ProjectID uint `json:"project_id"` + FileID uint `json:"file_id"` + Type int8 `gorm:"not null" json:"type"` // 1:上传 2:下载 + Size int64 `gorm:"not null" json:"size"` + IP string `gorm:"size:50" json:"ip"` + CreatedAt time.Time `gorm:"index" json:"created_at"` +} + +// Webhook Webhook回调配置 +type Webhook struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"index;not null" json:"user_id"` + ProjectID *uint `json:"project_id"` // 为空则为用户级别 + URL string `gorm:"size:500;not null" json:"url"` + Secret string `gorm:"size:64" json:"-"` + Events string `gorm:"size:255;not null" json:"events"` // 逗号分隔: file.upload,file.delete + Status int8 `gorm:"default:1" json:"status"` + LastTriggeredAt *time.Time `json:"last_triggered_at"` + CreatedAt time.Time `json:"created_at"` +} + +// SystemSetting 系统设置 +type SystemSetting struct { + ID uint `gorm:"primaryKey" json:"id"` + Key string `gorm:"column:key;size:100;uniqueIndex;not null" json:"key"` + Value string `json:"value"` + Description string `gorm:"size:255" json:"description"` + GroupName string `gorm:"size:50;default:general" json:"group_name"` + UpdatedAt time.Time `json:"updated_at"` +} + +// OperationLog 操作日志 +type OperationLog struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID *uint `gorm:"index" json:"user_id"` + Username string `gorm:"size:50" json:"username"` + Action string `gorm:"size:50;index;not null" json:"action"` // login/logout/upload/download/delete/create/update + ResourceType string `gorm:"size:50" json:"resource_type"` + ResourceID uint `json:"resource_id"` + ResourceName string `gorm:"size:255" json:"resource_name"` + IP string `gorm:"size:50" json:"ip"` + UserAgent string `gorm:"size:500" json:"user_agent"` + RequestPath string `gorm:"size:500" json:"request_path"` + RequestMethod string `gorm:"size:10" json:"request_method"` + Status int8 `gorm:"default:1" json:"status"` // 1:成功 0:失败 + ErrorMessage string `json:"error_message"` + ExtraData string `json:"extra_data"` + CreatedAt time.Time `gorm:"index" json:"created_at"` +} + +// SystemLog 系统日志 +type SystemLog struct { + ID uint `gorm:"primaryKey" json:"id"` + Level string `gorm:"size:10;index;not null" json:"level"` // debug/info/warn/error/fatal + Module string `gorm:"size:50;index" json:"module"` + Message string `gorm:"not null" json:"message"` + StackTrace string `json:"stack_trace"` + RequestID string `gorm:"size:50" json:"request_id"` + IP string `gorm:"size:50" json:"ip"` + ExtraData string `json:"extra_data"` + CreatedAt time.Time `gorm:"index" json:"created_at"` +} diff --git a/go/internal/model/seed.go b/go/internal/model/seed.go new file mode 100644 index 0000000..ef2131f --- /dev/null +++ b/go/internal/model/seed.go @@ -0,0 +1,159 @@ +package model + +import ( + "log" + + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// AutoMigrate 自动迁移所有表 +func AutoMigrate(db *gorm.DB) error { + return db.AutoMigrate( + &Role{}, + &Permission{}, + &User{}, + &APIKey{}, + &Project{}, + &File{}, + &TempLink{}, + &TrafficLog{}, + &Webhook{}, + &SystemSetting{}, + &OperationLog{}, + &SystemLog{}, + ) +} + +var defaultRoles = []Role{ + {Name: "超级管理员", Code: "super_admin", Description: "系统超级管理员,拥有所有权限", IsSystem: true}, + {Name: "管理员", Code: "admin", Description: "系统管理员,可管理用户和内容", IsSystem: true}, + {Name: "普通用户", Code: "user", Description: "普通用户,管理自己的项目和文件", IsSystem: true}, +} + +var defaultPermissions = []Permission{ + // 用户管理 + {Name: "查看用户列表", Code: "user:list", Module: "user", Description: "查看所有用户列表"}, + {Name: "查看用户详情", Code: "user:detail", Module: "user", Description: "查看用户详细信息"}, + {Name: "创建用户", Code: "user:create", Module: "user", Description: "创建新用户"}, + {Name: "编辑用户", Code: "user:edit", Module: "user", Description: "编辑用户信息"}, + {Name: "删除用户", Code: "user:delete", Module: "user", Description: "删除用户"}, + {Name: "禁用用户", Code: "user:disable", Module: "user", Description: "禁用/启用用户"}, + {Name: "分配角色", Code: "user:assign_role", Module: "user", Description: "修改用户角色"}, + {Name: "重置密码", Code: "user:reset_password", Module: "user", Description: "重置用户密码"}, + // 项目管理 + {Name: "查看项目列表", Code: "project:list", Module: "project", Description: "查看项目列表"}, + {Name: "创建项目", Code: "project:create", Module: "project", Description: "创建新项目"}, + {Name: "编辑项目", Code: "project:edit", Module: "project", Description: "编辑项目信息"}, + {Name: "删除项目", Code: "project:delete", Module: "project", Description: "删除项目"}, + // 文件管理 + {Name: "上传文件", Code: "file:upload", Module: "file", Description: "上传文件"}, + {Name: "下载文件", Code: "file:download", Module: "file", Description: "下载文件"}, + {Name: "删除文件", Code: "file:delete", Module: "file", Description: "删除文件"}, + {Name: "查看文件列表", Code: "file:list", Module: "file", Description: "查看文件列表"}, + // 系统管理 + {Name: "查看系统设置", Code: "system:settings:view", Module: "system", Description: "查看系统设置"}, + {Name: "修改系统设置", Code: "system:settings:edit", Module: "system", Description: "修改系统设置"}, + {Name: "查看操作日志", Code: "system:logs:operation", Module: "system", Description: "查看操作日志"}, + {Name: "查看系统日志", Code: "system:logs:system", Module: "system", Description: "查看系统日志"}, + {Name: "清理日志", Code: "system:logs:cleanup", Module: "system", Description: "清理历史日志"}, +} + +// admin角色不拥有的权限 +var adminExcluded = map[string]bool{ + "user:delete": true, + "user:assign_role": true, + "system:settings:edit": true, +} + +// 普通用户拥有的权限 +var userPerms = map[string]bool{ + "project:list": true, "project:create": true, "project:edit": true, "project:delete": true, + "file:upload": true, "file:download": true, "file:delete": true, "file:list": true, +} + +var defaultSettings = []SystemSetting{ + {Key: "site_name", Value: "云泽文件存储云平台", Description: "站点名称", GroupName: "site"}, + {Key: "site_description", Value: "高效、安全的文件存储服务", Description: "站点描述", GroupName: "site"}, + {Key: "site_logo", Value: "/assets/logo.png", Description: "站点Logo路径", GroupName: "site"}, + {Key: "site_icp", Value: "", Description: "ICP备案号", GroupName: "site"}, + {Key: "site_copyright", Value: "© 2024 FileStorageSystem", Description: "版权信息", GroupName: "site"}, + {Key: "register_enabled", Value: "true", Description: "是否开放注册", GroupName: "feature"}, + {Key: "site_enabled", Value: "true", Description: "站点是否启用", GroupName: "feature"}, + {Key: "site_maintenance_msg", Value: "系统维护中,请稍后再访问", Description: "维护提示信息", GroupName: "feature"}, + {Key: "default_storage_limit", Value: "10737418240", Description: "默认用户存储配额(字节)", GroupName: "storage"}, + {Key: "default_project_limit", Value: "5368709120", Description: "默认项目存储配额(字节)", GroupName: "storage"}, + {Key: "max_file_size", Value: "104857600", Description: "单文件最大限制(字节)", GroupName: "storage"}, + {Key: "allowed_file_types", Value: "*", Description: "允许的文件类型(*表示所有)", GroupName: "storage"}, + {Key: "api_rate_limit", Value: "100", Description: "API请求频率限制(次/分钟)", GroupName: "api"}, + {Key: "temp_link_max_age", Value: "3600", Description: "临时链接默认有效期(秒)", GroupName: "api"}, + {Key: "webhook_enabled", Value: "true", Description: "是否启用Webhook回调", GroupName: "api"}, +} + +// SeedData 初始化种子数据(幂等) +func SeedData(db *gorm.DB) error { + db.Logger = db.Logger.LogMode(logger.Warn) + + // 1. 角色 + for i := range defaultRoles { + var r Role + if err := db.Where("code = ?", defaultRoles[i].Code).First(&r).Error; err != nil { + if err := db.Create(&defaultRoles[i]).Error; err != nil { + return err + } + } + } + + // 2. 权限 + for i := range defaultPermissions { + var p Permission + if err := db.Where("code = ?", defaultPermissions[i].Code).First(&p).Error; err != nil { + if err := db.Create(&defaultPermissions[i]).Error; err != nil { + return err + } + } + } + + // 3. 角色权限关联 + var perms []Permission + if err := db.Find(&perms).Error; err != nil { + return err + } + rolePerms := map[string]func(code string) bool{ + "super_admin": func(string) bool { return true }, + "admin": func(code string) bool { return !adminExcluded[code] }, + "user": func(code string) bool { return userPerms[code] }, + } + for roleCode, match := range rolePerms { + var role Role + if err := db.Where("code = ?", roleCode).First(&role).Error; err != nil { + continue + } + for _, p := range perms { + if !match(p.Code) { + continue + } + var count int64 + db.Table("role_permissions"). + Where("role_id = ? AND permission_id = ?", role.ID, p.ID). + Count(&count) + if count == 0 { + if err := db.Exec("INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)", role.ID, p.ID).Error; err != nil { + log.Printf("插入角色权限失败: %v", err) + } + } + } + } + + // 4. 系统设置 + for i := range defaultSettings { + var s SystemSetting + if err := db.Where("`key` = ?", defaultSettings[i].Key).First(&s).Error; err != nil { + if err := db.Create(&defaultSettings[i]).Error; err != nil { + return err + } + } + } + + return nil +} diff --git a/go/internal/repository/file.go b/go/internal/repository/file.go new file mode 100644 index 0000000..e57a607 --- /dev/null +++ b/go/internal/repository/file.go @@ -0,0 +1,220 @@ +package repository + +import ( + "time" + + "gorm.io/gorm" + + "filestoragesystem/internal/model" +) + +// FileListFilter 文件列表过滤条件 +type FileListFilter struct { + UserID uint + ProjectID uint + Filename string // 模糊匹配 + Prefix string // 虚拟目录前缀 + Trashed bool // 查询回收站 + Visibility int8 // 0为不过滤 + Page int + PageSize int +} + +// FileRepo 文件数据访问 +type FileRepo struct{ DB *gorm.DB } + +func (r *FileRepo) Create(f *model.File) error { return r.DB.Create(f).Error } + +func (r *FileRepo) FindByID(id uint) (*model.File, error) { + var f model.File + err := r.DB.First(&f, id).Error + return &f, err +} + +// List 分页查询文件 +func (r *FileRepo) List(filter FileListFilter) ([]model.File, int64, error) { + q := r.DB.Model(&model.File{}) + if filter.UserID > 0 { + q = q.Where("user_id = ?", filter.UserID) + } + if filter.ProjectID > 0 { + q = q.Where("project_id = ?", filter.ProjectID) + } + if filter.Filename != "" { + q = q.Where("filename LIKE ?", "%"+filter.Filename+"%") + } + if filter.Prefix != "" { + q = q.Where("(filename = ? OR filename LIKE ?)", filter.Prefix, filter.Prefix+"/%") + } + if filter.Trashed { + q = q.Where("status = 0") + } else { + q = q.Where("status = 1") + } + if filter.Visibility > 0 { + q = q.Where("visibility = ?", filter.Visibility) + } + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + var files []model.File + err := q.Preload("Project").Order("id DESC"). + Offset((filter.Page - 1) * filter.PageSize).Limit(filter.PageSize).Find(&files).Error + return files, total, err +} + +func (r *FileRepo) Update(f *model.File) error { return r.DB.Save(f).Error } + +// SoftDelete 软删除(进入回收站) +func (r *FileRepo) SoftDelete(id uint) error { + now := time.Now() + return r.DB.Model(&model.File{}).Where("id = ?", id). + Updates(map[string]interface{}{"status": 0, "deleted_at": now}).Error +} + +// Restore 从回收站恢复 +func (r *FileRepo) Restore(id uint) error { + return r.DB.Model(&model.File{}).Where("id = ?", id). + Updates(map[string]interface{}{"status": 1, "deleted_at": nil}).Error +} + +func (r *FileRepo) Delete(id uint) error { return r.DB.Delete(&model.File{}, id).Error } + +// CountByStoredPath 统计引用同一物理文件的记录数(用于去重删除) +func (r *FileRepo) CountByStoredPath(storedPath string) (int64, error) { + var n int64 + err := r.DB.Model(&model.File{}).Where("stored_path = ?", storedPath).Count(&n).Error + return n, err +} + +// FindByMD5AndPath 基于MD5查找可复用的物理文件记录 +func (r *FileRepo) FindByMD5AndPath(md5 string) (*model.File, error) { + var f model.File + err := r.DB.Where("md5 = ? AND stored_path != ''", md5).Order("id ASC").First(&f).Error + return &f, err +} + +// ListByProject 项目下所有文件(用于项目删除级联) +func (r *FileRepo) ListByProject(projectID uint) ([]model.File, error) { + var files []model.File + err := r.DB.Where("project_id = ?", projectID).Find(&files).Error + return files, err +} + +// ListByUser 用户所有文件(用于用户删除级联) +func (r *FileRepo) ListByUser(userID uint) ([]model.File, error) { + var files []model.File + err := r.DB.Where("user_id = ?", userID).Find(&files).Error + return files, err +} + +// StatsByProject 项目文件统计 +func (r *FileRepo) StatsByProject(projectID uint) (count int64, size int64, err error) { + var res struct { + Count int64 `json:"count"` + Size int64 `json:"size"` + } + err = r.DB.Model(&model.File{}). + Where("project_id = ? AND status = 1", projectID). + Select("COUNT(*) as count, COALESCE(SUM(size),0) as size"). + Scan(&res).Error + return res.Count, res.Size, err +} + +// StatsByUser 用户文件统计 +func (r *FileRepo) StatsByUser(userID uint) (count int64, size int64, err error) { + var res struct { + Count int64 `json:"count"` + Size int64 `json:"size"` + } + err = r.DB.Model(&model.File{}). + Where("user_id = ? AND status = 1", userID). + Select("COUNT(*) as count, COALESCE(SUM(size),0) as size"). + Scan(&res).Error + return res.Count, res.Size, err +} + +// IncrDownloadCount 下载计数 +func (r *FileRepo) IncrDownloadCount(id uint) error { + return r.DB.Model(&model.File{}).Where("id = ?", id). + UpdateColumn("download_count", gorm.Expr("download_count + 1")).Error +} + +// ---- 临时链接 ---- + +// TempLinkRepo 临时链接数据访问 +type TempLinkRepo struct{ DB *gorm.DB } + +func (r *TempLinkRepo) Create(l *model.TempLink) error { return r.DB.Create(l).Error } + +func (r *TempLinkRepo) FindByToken(token string) (*model.TempLink, error) { + var l model.TempLink + err := r.DB.Where("token = ?", token).First(&l).Error + return &l, err +} + +func (r *TempLinkRepo) IncrUsed(id uint) error { + return r.DB.Model(&model.TempLink{}).Where("id = ?", id). + UpdateColumn("used_count", gorm.Expr("used_count + 1")).Error +} + +func (r *TempLinkRepo) DeleteByFile(fileID uint) error { + return r.DB.Where("file_id = ?", fileID).Delete(&model.TempLink{}).Error +} + +// ---- 流量日志 ---- + +// TrafficRepo 流量日志数据访问 +type TrafficRepo struct{ DB *gorm.DB } + +func (r *TrafficRepo) Create(t *model.TrafficLog) error { return r.DB.Create(t).Error } + +// DailyStats 按天/类型聚合流量 +func (r *TrafficRepo) DailyStats(userID uint, days int) ([]map[string]interface{}, error) { + var rows []map[string]interface{} + err := r.DB.Model(&model.TrafficLog{}). + Select("date(created_at) as date, type, COALESCE(SUM(size),0) as size, COUNT(*) as count"). + Where("user_id = ? AND created_at >= date('now', ?)", userID, "-"+itoa(days)+" day"). + Group("date(created_at), type"). + Order("date DESC"). + Find(&rows).Error + return rows, err +} + +// SumByUser 汇总用户上传/下载流量 +func (r *TrafficRepo) SumByUser(userID uint, days int) (upload, download int64, err error) { + var res []struct { + Type int64 `json:"type"` + Size int64 `json:"size"` + } + q := r.DB.Model(&model.TrafficLog{}). + Select("type, COALESCE(SUM(size),0) as size"). + Where("user_id = ?", userID) + if days > 0 { + q = q.Where("created_at >= date('now', ?)", "-"+itoa(days)+" day") + } + err = q.Group("type").Scan(&res).Error + for _, r := range res { + if r.Type == 1 { + upload = r.Size + } else if r.Type == 2 { + download = r.Size + } + } + return upload, download, err +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var buf [12]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + return string(buf[i:]) +} diff --git a/go/internal/repository/project.go b/go/internal/repository/project.go new file mode 100644 index 0000000..29bc48c --- /dev/null +++ b/go/internal/repository/project.go @@ -0,0 +1,60 @@ +package repository + +import ( + "gorm.io/gorm" + + "filestoragesystem/internal/model" +) + +// ProjectRepo 项目数据访问 +type ProjectRepo struct{ DB *gorm.DB } + +func (r *ProjectRepo) Create(p *model.Project) error { return r.DB.Create(p).Error } + +func (r *ProjectRepo) FindByID(id uint) (*model.Project, error) { + var p model.Project + err := r.DB.First(&p, id).Error + return &p, err +} + +func (r *ProjectRepo) FindByName(userID uint, name string) (*model.Project, error) { + var p model.Project + err := r.DB.Where("user_id = ? AND name = ?", userID, name).First(&p).Error + return &p, err +} + +// List 分页查询项目;keyword匹配名称;all为true时跨用户(管理员) +func (r *ProjectRepo) List(userID uint, keyword string, all bool, page, pageSize int) ([]model.Project, int64, error) { + q := r.DB.Model(&model.Project{}) + if !all { + q = q.Where("user_id = ?", userID) + } + if keyword != "" { + q = q.Where("name LIKE ?", "%"+keyword+"%") + } + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + var projects []model.Project + err := q.Order("id DESC"). + Offset((page - 1) * pageSize).Limit(pageSize).Find(&projects).Error + return projects, total, err +} + +func (r *ProjectRepo) Update(p *model.Project) error { return r.DB.Save(p).Error } + +func (r *ProjectRepo) Delete(id uint) error { return r.DB.Delete(&model.Project{}, id).Error } + +// UpdateStorageUsed 增减项目已用存储 +func (r *ProjectRepo) UpdateStorageUsed(id uint, delta int64) error { + return r.DB.Model(&model.Project{}).Where("id = ?", id). + UpdateColumn("storage_used", gorm.Expr("storage_used + ?", delta)).Error +} + +// ListByUser 用户全部项目(用于统计) +func (r *ProjectRepo) ListByUser(userID uint) ([]model.Project, error) { + var projects []model.Project + err := r.DB.Where("user_id = ?", userID).Find(&projects).Error + return projects, err +} diff --git a/go/internal/repository/role.go b/go/internal/repository/role.go new file mode 100644 index 0000000..ca12968 --- /dev/null +++ b/go/internal/repository/role.go @@ -0,0 +1,79 @@ +package repository + +import ( + "gorm.io/gorm" + + "filestoragesystem/internal/model" +) + +// RoleRepo 角色数据访问 +type RoleRepo struct{ DB *gorm.DB } + +func (r *RoleRepo) Create(role *model.Role) error { return r.DB.Create(role).Error } + +func (r *RoleRepo) FindByID(id uint) (*model.Role, error) { + var role model.Role + err := r.DB.Preload("Permissions").First(&role, id).Error + return &role, err +} + +func (r *RoleRepo) FindByCode(code string) (*model.Role, error) { + var role model.Role + err := r.DB.Where("code = ?", code).First(&role).Error + return &role, err +} + +func (r *RoleRepo) List() ([]model.Role, error) { + var roles []model.Role + err := r.DB.Preload("Permissions").Order("id ASC").Find(&roles).Error + return roles, err +} + +func (r *RoleRepo) Update(role *model.Role) error { return r.DB.Save(role).Error } + +func (r *RoleRepo) Delete(id uint) error { return r.DB.Delete(&model.Role{}, id).Error } + +// ReplacePermissions 覆盖式更新角色权限 +func (r *RoleRepo) ReplacePermissions(roleID uint, permIDs []uint) error { + return r.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Exec("DELETE FROM role_permissions WHERE role_id = ?", roleID).Error; err != nil { + return err + } + for _, pid := range permIDs { + if err := tx.Exec("INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)", roleID, pid).Error; err != nil { + return err + } + } + return nil + }) +} + +// CountUsersByRole 统计角色下的用户数 +func (r *RoleRepo) CountUsersByRole(roleID uint) (int64, error) { + var n int64 + err := r.DB.Model(&model.User{}).Where("role_id = ?", roleID).Count(&n).Error + return n, err +} + +// PermissionRepo 权限数据访问 +type PermissionRepo struct{ DB *gorm.DB } + +func (r *PermissionRepo) List() ([]model.Permission, error) { + var perms []model.Permission + err := r.DB.Order("module ASC, id ASC").Find(&perms).Error + return perms, err +} + +func (r *PermissionRepo) ListByIDs(ids []uint) ([]model.Permission, error) { + var perms []model.Permission + err := r.DB.Where("id IN ?", ids).Find(&perms).Error + return perms, err +} + +func (r *PermissionRepo) ListByRole(roleID uint) ([]model.Permission, error) { + var perms []model.Permission + err := r.DB. + Joins("JOIN role_permissions rp ON rp.permission_id = permissions.id"). + Where("rp.role_id = ?", roleID).Find(&perms).Error + return perms, err +} diff --git a/go/internal/repository/system.go b/go/internal/repository/system.go new file mode 100644 index 0000000..edf9c4f --- /dev/null +++ b/go/internal/repository/system.go @@ -0,0 +1,195 @@ +package repository + +import ( + "time" + + "gorm.io/gorm" + + "filestoragesystem/internal/model" +) + +// SettingRepo 系统设置数据访问 +type SettingRepo struct{ DB *gorm.DB } + +// GetAll 获取全部设置 +func (r *SettingRepo) GetAll() ([]model.SystemSetting, error) { + var settings []model.SystemSetting + err := r.DB.Order("group_name ASC, id ASC").Find(&settings).Error + return settings, err +} + +// GetByGroup 按分组获取设置 +func (r *SettingRepo) GetByGroup(group string) ([]model.SystemSetting, error) { + var settings []model.SystemSetting + err := r.DB.Where("group_name = ?", group).Find(&settings).Error + return settings, err +} + +// GetValue 获取指定键的值 +func (r *SettingRepo) GetValue(key string) (string, error) { + var s model.SystemSetting + err := r.DB.Where("`key` = ?", key).First(&s).Error + if err != nil { + return "", err + } + return s.Value, nil +} + +// UpdateValues 批量更新键值 +func (r *SettingRepo) UpdateValues(values map[string]string) error { + return r.DB.Transaction(func(tx *gorm.DB) error { + for k, v := range values { + if err := tx.Model(&model.SystemSetting{}). + Where("`key` = ?", k). + Updates(map[string]interface{}{"value": v, "updated_at": time.Now()}).Error; err != nil { + return err + } + } + return nil + }) +} + +// ---- 操作日志 ---- + +// OpLogRepo 操作日志数据访问 +type OpLogRepo struct{ DB *gorm.DB } + +func (r *OpLogRepo) Create(l *model.OperationLog) error { return r.DB.Create(l).Error } + +func (r *OpLogRepo) FindByID(id uint) (*model.OperationLog, error) { + var l model.OperationLog + err := r.DB.First(&l, id).Error + return &l, err +} + +// OpLogFilter 操作日志过滤 +type OpLogFilter struct { + UserID *uint + Username string + Action string + Status string // "1"/"0" + Days int + Page int + PageSize int +} + +func (r *OpLogRepo) List(f OpLogFilter) ([]model.OperationLog, int64, error) { + q := r.DB.Model(&model.OperationLog{}) + if f.UserID != nil { + q = q.Where("user_id = ?", *f.UserID) + } + if f.Username != "" { + q = q.Where("username LIKE ?", "%"+f.Username+"%") + } + if f.Action != "" { + q = q.Where("action = ?", f.Action) + } + if f.Status != "" { + q = q.Where("status = ?", f.Status) + } + if f.Days > 0 { + q = q.Where("created_at >= date('now', ?)", "-"+itoa(f.Days)+" day") + } + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + var logs []model.OperationLog + err := q.Order("id DESC"). + Offset((f.Page - 1) * f.PageSize).Limit(f.PageSize).Find(&logs).Error + return logs, total, err +} + +// DeleteBefore 删除指定天数前的日志,返回删除条数 +func (r *OpLogRepo) DeleteBefore(days int) (int64, error) { + res := r.DB.Where("created_at < date('now', ?)", "-"+itoa(days)+" day"). + Delete(&model.OperationLog{}) + return res.RowsAffected, res.Error +} + +// ---- 系统日志 ---- + +// SysLogRepo 系统日志数据访问 +type SysLogRepo struct{ DB *gorm.DB } + +func (r *SysLogRepo) FindByID(id uint) (*model.SystemLog, error) { + var l model.SystemLog + err := r.DB.First(&l, id).Error + return &l, err +} + +// SysLogFilter 系统日志过滤 +type SysLogFilter struct { + Level string + Module string + Days int + Page int + PageSize int +} + +func (r *SysLogRepo) List(f SysLogFilter) ([]model.SystemLog, int64, error) { + q := r.DB.Model(&model.SystemLog{}) + if f.Level != "" { + q = q.Where("level = ?", f.Level) + } + if f.Module != "" { + q = q.Where("module = ?", f.Module) + } + if f.Days > 0 { + q = q.Where("created_at >= date('now', ?)", "-"+itoa(f.Days)+" day") + } + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + var logs []model.SystemLog + err := q.Order("id DESC"). + Offset((f.Page - 1) * f.PageSize).Limit(f.PageSize).Find(&logs).Error + return logs, total, err +} + +// DeleteBefore 删除指定天数前的日志 +func (r *SysLogRepo) DeleteBefore(days int) (int64, error) { + res := r.DB.Where("created_at < date('now', ?)", "-"+itoa(days)+" day"). + Delete(&model.SystemLog{}) + return res.RowsAffected, res.Error +} + +// ---- Webhook ---- + +// WebhookRepo Webhook数据访问 +type WebhookRepo struct{ DB *gorm.DB } + +func (r *WebhookRepo) Create(w *model.Webhook) error { return r.DB.Create(w).Error } + +func (r *WebhookRepo) FindByID(id uint) (*model.Webhook, error) { + var w model.Webhook + err := r.DB.First(&w, id).Error + return &w, err +} + +func (r *WebhookRepo) Update(w *model.Webhook) error { return r.DB.Save(w).Error } + +func (r *WebhookRepo) Delete(id, userID uint) error { + return r.DB.Where("id = ? AND user_id = ?", id, userID).Delete(&model.Webhook{}).Error +} + +// ListByUser 用户webhook列表 +func (r *WebhookRepo) ListByUser(userID uint) ([]model.Webhook, error) { + var hooks []model.Webhook + err := r.DB.Where("user_id = ?", userID).Order("id DESC").Find(&hooks).Error + return hooks, err +} + +// ListActive 查询匹配事件的启用webhook:projectID为0表示仅用户级别 +func (r *WebhookRepo) ListActive(userID uint, projectID uint) ([]model.Webhook, error) { + var hooks []model.Webhook + err := r.DB.Where("user_id = ? AND status = 1 AND (project_id IS NULL OR project_id = 0 OR project_id = ?)", + userID, projectID).Find(&hooks).Error + return hooks, err +} + +func (r *WebhookRepo) Touch(id uint) error { + return r.DB.Model(&model.Webhook{}).Where("id = ?", id). + UpdateColumn("last_triggered_at", gorm.Expr("CURRENT_TIMESTAMP")).Error +} diff --git a/go/internal/repository/user.go b/go/internal/repository/user.go new file mode 100644 index 0000000..b486009 --- /dev/null +++ b/go/internal/repository/user.go @@ -0,0 +1,108 @@ +package repository + +import ( + "gorm.io/gorm" + + "filestoragesystem/internal/model" +) + +// UserRepo 用户数据访问 +type UserRepo struct{ DB *gorm.DB } + +func (r *UserRepo) Create(u *model.User) error { return r.DB.Create(u).Error } + +func (r *UserRepo) FindByID(id uint) (*model.User, error) { + var u model.User + err := r.DB.Preload("Role.Permissions").First(&u, id).Error + return &u, err +} + +func (r *UserRepo) FindByUsernameOrEmail(account string) (*model.User, error) { + var u model.User + err := r.DB.Preload("Role.Permissions"). + Where("username = ? OR email = ?", account, account).First(&u).Error + return &u, err +} + +func (r *UserRepo) FindByUsername(username string) (*model.User, error) { + var u model.User + err := r.DB.Where("username = ?", username).First(&u).Error + return &u, err +} + +func (r *UserRepo) FindByEmail(email string) (*model.User, error) { + var u model.User + err := r.DB.Where("email = ?", email).First(&u).Error + return &u, err +} + +// List 分页查询用户,keyword匹配用户名/邮箱,roleID/status可选 +func (r *UserRepo) List(keyword, roleID, status string, page, pageSize int) ([]model.User, int64, error) { + q := r.DB.Model(&model.User{}) + if keyword != "" { + like := "%" + keyword + "%" + q = q.Where("username LIKE ? OR email LIKE ?", like, like) + } + if roleID != "" { + q = q.Where("role_id = ?", roleID) + } + if status != "" { + q = q.Where("status = ?", status) + } + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + var users []model.User + err := q.Preload("Role").Order("id DESC"). + Offset((page - 1) * pageSize).Limit(pageSize).Find(&users).Error + return users, total, err +} + +func (r *UserRepo) Update(u *model.User) error { return r.DB.Save(u).Error } + +func (r *UserRepo) UpdateFields(id uint, fields map[string]interface{}) error { + return r.DB.Model(&model.User{}).Where("id = ?", id).Updates(fields).Error +} + +func (r *UserRepo) Delete(id uint) error { return r.DB.Delete(&model.User{}, id).Error } + +// UpdateStorageUsed 增减用户已用存储(delta可正可负) +func (r *UserRepo) UpdateStorageUsed(id uint, delta int64) error { + return r.DB.Model(&model.User{}).Where("id = ?", id). + UpdateColumn("storage_used", gorm.Expr("storage_used + ?", delta)).Error +} + +// ---- API Key ---- + +// APIKeyRepo API密钥数据访问 +type APIKeyRepo struct{ DB *gorm.DB } + +func (r *APIKeyRepo) Create(k *model.APIKey) error { return r.DB.Create(k).Error } + +func (r *APIKeyRepo) FindByID(id uint) (*model.APIKey, error) { + var k model.APIKey + err := r.DB.First(&k, id).Error + return &k, err +} + +func (r *APIKeyRepo) FindByAccessKey(accessKey string) (*model.APIKey, error) { + var k model.APIKey + err := r.DB.Where("access_key = ?", accessKey).First(&k).Error + return &k, err +} + +func (r *APIKeyRepo) ListByUser(userID uint) ([]model.APIKey, error) { + var keys []model.APIKey + err := r.DB.Where("user_id = ?", userID).Order("id DESC").Find(&keys).Error + return keys, err +} + +func (r *APIKeyRepo) Delete(id, userID uint) error { + return r.DB.Where("id = ? AND user_id = ?", id, userID).Delete(&model.APIKey{}).Error +} + +func (r *APIKeyRepo) Touch(id uint) error { + return r.DB.Model(&model.APIKey{}).Where("id = ?", id). + UpdateColumn("last_used_at", gorm.Expr("CURRENT_TIMESTAMP")).Error +} diff --git a/go/internal/router/router.go b/go/internal/router/router.go new file mode 100644 index 0000000..e321501 --- /dev/null +++ b/go/internal/router/router.go @@ -0,0 +1,155 @@ +package router + +import ( + "os" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "filestoragesystem/internal/config" + "filestoragesystem/internal/controller" + "filestoragesystem/internal/middleware" + "filestoragesystem/internal/repository" + "filestoragesystem/internal/service" +) + +// New 创建gin引擎并注册全部路由 +func New(cfg *config.Config, db *gorm.DB, svc *service.Services) *gin.Engine { + gin.SetMode(cfg.Server.Mode) + + r := gin.New() + r.Use(gin.Recovery()) + r.Use(middleware.RequestID()) + r.Use(middleware.CORS()) + r.Use(middleware.Logger()) + + // 健康检查 + r.GET("/health", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + // 托管前端静态资源(前端为hash路由,全部路径回退到index.html) + dist := "../backend/dist" + if _, err := os.Stat(dist); err == nil { + r.Static("/assets", dist+"/assets") + r.StaticFile("/favicon.svg", dist+"/favicon.svg") + r.NoRoute(func(c *gin.Context) { + c.File(dist + "/index.html") + }) + } + + api := r.Group("/api/v1") + + // 公开接口 + api.GET("/status", controller.NewAdminController(controller.NewBase(svc)).SiteStatus) + + authCtl := controller.NewAuthController(controller.NewBase(svc)) + api.POST("/auth/register", authCtl.Register) + api.POST("/auth/login", authCtl.Login) + + fileCtl := controller.NewFileController(controller.NewBase(svc)) + api.GET("/files/temp/:token", fileCtl.TempAccess) // 临时链接公开访问 + + // 认证接口 + authed := api.Group("") + authed.Use(middleware.Auth(svc.Auth)) + authed.Use(middleware.RateLimit(&repository.SettingRepo{DB: db})) + authed.Use(middleware.SiteStatus(svc.Admin)) + + // API密钥管理 + authed.POST("/auth/apikeys", authCtl.CreateAPIKey) + authed.GET("/auth/apikeys", authCtl.ListAPIKeys) + authed.DELETE("/auth/apikeys/:id", authCtl.DeleteAPIKey) + + // 个人资料 + userCtl := controller.NewUserController(controller.NewBase(svc)) + authed.GET("/user/profile", userCtl.GetProfile) + authed.PUT("/user/profile", userCtl.UpdateProfile) + authed.PUT("/user/password", userCtl.ChangePassword) + + // 项目管理 + projectCtl := controller.NewProjectController(controller.NewBase(svc)) + authed.POST("/projects", projectCtl.Create) + authed.GET("/projects", projectCtl.List) + authed.GET("/projects/:id", projectCtl.Get) + authed.PUT("/projects/:id", projectCtl.Update) + authed.DELETE("/projects/:id", projectCtl.Delete) + + // 文件操作 + authed.POST("/files/upload", fileCtl.Upload) + authed.GET("/files", fileCtl.List) + authed.GET("/files/:id", fileCtl.GetInfo) + authed.GET("/files/:id/download", fileCtl.Download) + authed.DELETE("/files/:id", fileCtl.Delete) + authed.POST("/files/:id/restore", fileCtl.Restore) + authed.POST("/files/:id/temp-link", fileCtl.CreateTempLink) + + // 统计 + statsCtl := controller.NewStatsController(controller.NewBase(svc)) + authed.GET("/stats/user", statsCtl.UserStats) + authed.GET("/stats/project/:id", statsCtl.ProjectStats) + authed.GET("/stats/traffic", statsCtl.TrafficStats) + + // Webhook管理 + webhookCtl := controller.NewWebhookController(controller.NewBase(svc)) + authed.POST("/webhooks", webhookCtl.Create) + authed.GET("/webhooks", webhookCtl.List) + authed.PUT("/webhooks/:id", webhookCtl.Update) + authed.DELETE("/webhooks/:id", webhookCtl.Delete) + + // 管理后台 + adminCtl := controller.NewAdminController(controller.NewBase(svc)) + roleCtl := controller.NewRoleController(controller.NewBase(svc)) + + admin := authed.Group("/admin") + // 用户管理(需要对应权限) + users := admin.Group("/users") + { + users.GET("", middleware.RequirePermission("user:list"), adminCtl.ListUsers) + users.GET("/:id", middleware.RequirePermission("user:detail"), adminCtl.GetUser) + users.POST("", middleware.RequirePermission("user:create"), adminCtl.CreateUser) + users.PUT("/:id", middleware.RequirePermission("user:edit"), adminCtl.UpdateUser) + users.DELETE("/:id", middleware.RequirePermission("user:delete"), adminCtl.DeleteUser) + users.PUT("/:id/status", middleware.RequirePermission("user:disable"), adminCtl.SetUserStatus) + users.PUT("/:id/role", middleware.RequirePermission("user:assign_role"), adminCtl.AssignRole) + users.POST("/:id/reset-password", middleware.RequirePermission("user:reset_password"), adminCtl.ResetPassword) + } + + // 角色权限管理(超级管理员) + roles := admin.Group("/roles") + { + roles.GET("", middleware.RequirePermission("user:assign_role"), roleCtl.ListRoles) + roles.GET("/:id", middleware.RequirePermission("user:assign_role"), roleCtl.GetRole) + roles.POST("", middleware.RequirePermission("user:assign_role"), roleCtl.CreateRole) + roles.PUT("/:id", middleware.RequirePermission("user:assign_role"), roleCtl.UpdateRole) + roles.DELETE("/:id", middleware.RequirePermission("user:assign_role"), roleCtl.DeleteRole) + roles.PUT("/:id/permissions", middleware.RequirePermission("user:assign_role"), roleCtl.SetPermissions) + } + admin.GET("/permissions", middleware.RequirePermission("user:assign_role"), roleCtl.ListPermissions) + + // 系统设置 + settings := admin.Group("/settings") + { + settings.GET("", middleware.RequirePermission("system:settings:view"), adminCtl.GetSettings) + settings.PUT("", middleware.RequirePermission("system:settings:edit"), adminCtl.UpdateSettings) + settings.GET("/:group", middleware.RequirePermission("system:settings:view"), adminCtl.GetSettingsByGroup) + settings.PUT("/:group", middleware.RequirePermission("system:settings:edit"), adminCtl.UpdateSettingsByGroup) + } + + // 日志管理 + logs := admin.Group("/logs") + { + logs.GET("/operation", middleware.RequirePermission("system:logs:operation"), adminCtl.ListOperationLogs) + logs.GET("/operation/:id", middleware.RequirePermission("system:logs:operation"), adminCtl.GetOperationLog) + logs.GET("/system", middleware.RequirePermission("system:logs:system"), adminCtl.ListSystemLogs) + logs.GET("/system/:id", middleware.RequirePermission("system:logs:system"), adminCtl.GetSystemLog) + logs.DELETE("/cleanup", middleware.RequirePermission("system:logs:cleanup"), adminCtl.CleanupLogs) + } + + // 数据备份 + admin.POST("/backup", middleware.RequirePermission("system:settings:edit"), adminCtl.Backup) + admin.GET("/backups", middleware.RequirePermission("system:settings:view"), adminCtl.ListBackups) + admin.GET("/backups/download", middleware.RequirePermission("system:settings:view"), adminCtl.DownloadBackup) + + return r +} diff --git a/go/internal/service/admin.go b/go/internal/service/admin.go new file mode 100644 index 0000000..2f51e74 --- /dev/null +++ b/go/internal/service/admin.go @@ -0,0 +1,214 @@ +package service + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "gorm.io/gorm" + + "filestoragesystem/internal/config" + "filestoragesystem/internal/model" + "filestoragesystem/internal/repository" + "filestoragesystem/internal/utils" + "filestoragesystem/pkg/apperr" +) + +// AdminService 系统管理服务(设置/日志/备份) +type AdminService struct { + cfg *config.Config + db *gorm.DB + settingRepo *repository.SettingRepo + opLogRepo *repository.OpLogRepo + sysLogRepo *repository.SysLogRepo +} + +// ---- 系统设置 ---- + +// GetSettings 获取全部设置(按分组组织) +func (s *AdminService) GetSettings() (map[string][]model.SystemSetting, error) { + settings, err := s.settingRepo.GetAll() + if err != nil { + return nil, err + } + groups := make(map[string][]model.SystemSetting) + for _, st := range settings { + groups[st.GroupName] = append(groups[st.GroupName], st) + } + return groups, nil +} + +// GetSettingsByGroup 按分组获取设置 +func (s *AdminService) GetSettingsByGroup(group string) ([]model.SystemSetting, error) { + return s.settingRepo.GetByGroup(group) +} + +// UpdateSettings 批量更新设置(仅更新已存在的键) +func (s *AdminService) UpdateSettings(values map[string]string) error { + if len(values) == 0 { + return nil + } + existing := make(map[string]bool) + all, err := s.settingRepo.GetAll() + if err != nil { + return err + } + for _, st := range all { + existing[st.Key] = true + } + valid := make(map[string]string) + for k, v := range values { + if existing[k] { + valid[k] = v + } + } + if len(valid) == 0 { + return fmt.Errorf("没有可更新的有效设置项") + } + return s.settingRepo.UpdateValues(valid) +} + +// ---- 日志 ---- + +// ListOperationLogs 操作日志列表 +func (s *AdminService) ListOperationLogs(f repository.OpLogFilter) ([]model.OperationLog, int64, error) { + if f.Page <= 0 { + f.Page = 1 + } + if f.PageSize <= 0 { + f.PageSize = 20 + } + return s.opLogRepo.List(f) +} + +// GetOperationLog 操作日志详情 +func (s *AdminService) GetOperationLog(id uint) (*model.OperationLog, error) { + l, err := s.opLogRepo.FindByID(id) + if err != nil { + return nil, apperr.ErrNotFound + } + return l, nil +} + +// ListSystemLogs 系统日志列表 +func (s *AdminService) ListSystemLogs(f repository.SysLogFilter) ([]model.SystemLog, int64, error) { + if f.Page <= 0 { + f.Page = 1 + } + if f.PageSize <= 0 { + f.PageSize = 20 + } + return s.sysLogRepo.List(f) +} + +// GetSystemLog 系统日志详情 +func (s *AdminService) GetSystemLog(id uint) (*model.SystemLog, error) { + l, err := s.sysLogRepo.FindByID(id) + if err != nil { + return nil, apperr.ErrNotFound + } + return l, nil +} + +// CleanupLogs 清理days天前的日志 +func (s *AdminService) CleanupLogs(days int) (opDeleted, sysDeleted int64, err error) { + if days <= 0 { + days = 30 + } + opDeleted, err = s.opLogRepo.DeleteBefore(days) + if err != nil { + return + } + sysDeleted, err = s.sysLogRepo.DeleteBefore(days) + utils.Info("system", "清理%d天前日志:操作日志%d条,系统日志%d条", days, opDeleted, sysDeleted) + return +} + +// ---- 备份 ---- + +// BackupDatabase 使用VACUUM INTO备份数据库 +func (s *AdminService) BackupDatabase() (string, error) { + if err := os.MkdirAll(s.cfg.Storage.Backups, 0o755); err != nil { + return "", err + } + name := fmt.Sprintf("backup-%s.db", time.Now().Format("20060102-150405")) + target := filepath.Join(s.cfg.Storage.Backups, name) + // SQLite安全备份:VACUUM INTO + quoted := strings.ReplaceAll(target, "'", "''") + if err := s.db.Exec(fmt.Sprintf("VACUUM INTO '%s'", quoted)).Error; err != nil { + utils.Error("system", "数据库备份失败: %v", err) + return "", fmt.Errorf("备份失败: %w", err) + } + utils.Info("system", "数据库备份完成: %s", name) + return name, nil +} + +// ListBackups 备份列表 +func (s *AdminService) ListBackups() ([]map[string]interface{}, error) { + entries, err := os.ReadDir(s.cfg.Storage.Backups) + if err != nil { + return []map[string]interface{}{}, nil + } + var list []map[string]interface{} + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".db") { + continue + } + info, _ := e.Info() + list = append(list, map[string]interface{}{ + "name": e.Name(), + "size": info.Size(), + "time": info.ModTime().Format(time.RFC3339), + }) + } + sort.Slice(list, func(i, j int) bool { + return list[i]["name"].(string) > list[j]["name"].(string) + }) + return list, nil +} + +// BackupPath 校验备份文件名并返回其物理路径(供下载) +func (s *AdminService) BackupPath(name string) (string, error) { + // 只允许 backup-*.db 格式,防止路径穿越 + if strings.ContainsAny(name, "/\\") || strings.Contains(name, "..") || !strings.HasPrefix(name, "backup-") || !strings.HasSuffix(name, ".db") { + return "", fmt.Errorf("非法的备份文件名") + } + p := filepath.Join(s.cfg.Storage.Backups, name) + if _, err := os.Stat(p); err != nil { + return "", fmt.Errorf("备份文件不存在") + } + return p, nil +} + +// ---- 站点状态 ---- + +// SiteStatus 公开的站点状态信息 +func (s *AdminService) SiteStatus() map[string]interface{} { + get := func(k, def string) string { + if v, err := s.settingRepo.GetValue(k); err == nil { + return v + } + return def + } + return map[string]interface{}{ + "site_name": get("site_name", "云泽文件存储云平台"), + "site_description": get("site_description", ""), + "site_logo": get("site_logo", ""), + "site_icp": get("site_icp", ""), + "site_copyright": get("site_copyright", ""), + "site_enabled": get("site_enabled", "true") == "true", + "register_enabled": get("register_enabled", "true") == "true", + "maintenance_msg": get("site_maintenance_msg", ""), + } +} + +// IsSiteEnabled 站点是否开启 +func (s *AdminService) IsSiteEnabled() bool { + if v, err := s.settingRepo.GetValue("site_enabled"); err == nil { + return v == "true" || v == "1" + } + return true +} diff --git a/go/internal/service/auth.go b/go/internal/service/auth.go new file mode 100644 index 0000000..4399db2 --- /dev/null +++ b/go/internal/service/auth.go @@ -0,0 +1,346 @@ +package service + +import ( + "fmt" + "strings" + "sync" + "time" + "unicode" + + "gorm.io/gorm" + + "filestoragesystem/internal/config" + "filestoragesystem/internal/model" + "filestoragesystem/internal/repository" + "filestoragesystem/internal/utils" + "filestoragesystem/pkg/apperr" +) + +const ( + maxLoginFails = 5 // 最大登录失败次数 + lockDuration = 15 * time.Minute // 锁定时长 + signTimeWindow = 5 * time.Minute // 签名时间戳有效窗口 +) + +// AuthService 认证服务 +type AuthService struct { + cfg *config.Config + userRepo *repository.UserRepo + apiKeyRepo *repository.APIKeyRepo + roleRepo *repository.RoleRepo + settingRepo *repository.SettingRepo + opLogRepo *repository.OpLogRepo + + mu sync.Mutex + loginFails map[string]*loginState // 登录失败状态 + nonces map[string]time.Time // 防重放nonce +} + +type loginState struct { + fails int + lockedUntil time.Time +} + +// ensureState 惰性初始化登录失败与nonce缓存 +func (s *AuthService) ensureState() { + s.mu.Lock() + defer s.mu.Unlock() + if s.loginFails == nil { + s.loginFails = make(map[string]*loginState) + } + if s.nonces == nil { + s.nonces = make(map[string]time.Time) + } +} + +// Register 用户注册 +func (s *AuthService) Register(username, email, password string) (*model.User, error) { + // 注册开关 + if v, err := s.settingRepo.GetValue("register_enabled"); err == nil && v != "true" { + return nil, apperr.ErrRegisterClosed + } + username = strings.TrimSpace(username) + email = strings.TrimSpace(strings.ToLower(email)) + + if len(username) < 3 || len(username) > 50 { + return nil, fmt.Errorf("用户名长度需在3-50之间") + } + for _, ch := range username { + if !unicode.IsLetter(ch) && !unicode.IsDigit(ch) && ch != '_' && ch != '-' && ch < 0x80 { + return nil, fmt.Errorf("用户名仅支持字母、数字、下划线和横线") + } + } + if !utils.IsEmail(email) { + return nil, fmt.Errorf("邮箱格式不正确") + } + if !utils.IsValidPassword(password) { + return nil, fmt.Errorf("密码至少8位且需包含字母和数字") + } + if _, err := s.userRepo.FindByUsername(username); err == nil { + return nil, apperr.ErrUserExists + } + if _, err := s.userRepo.FindByEmail(email); err == nil { + return nil, apperr.ErrUserExists + } + + // 默认角色与配额 + role, err := s.roleRepo.FindByCode("user") + if err != nil { + return nil, fmt.Errorf("默认角色不存在: %w", err) + } + limit := int64(10737418240) + if v, err := s.settingRepo.GetValue("default_storage_limit"); err == nil { + var n int64 + if _, err := fmt.Sscanf(v, "%d", &n); err == nil && n > 0 { + limit = n + } + } + + hash, err := utils.HashPassword(password) + if err != nil { + return nil, err + } + u := &model.User{ + Username: username, Email: email, PasswordHash: hash, + RoleID: role.ID, Status: 1, StorageLimit: limit, + } + if err := s.userRepo.Create(u); err != nil { + return nil, err + } + utils.Info("auth", "新用户注册: %s", username) + return u, nil +} + +// Login 用户登录,成功返回用户与JWT token +func (s *AuthService) Login(account, password, ip string) (*model.User, string, error) { + s.ensureState() + account = strings.TrimSpace(account) + + // 锁定检查 + s.mu.Lock() + if st, ok := s.loginFails[account]; ok { + if time.Now().Before(st.lockedUntil) { + s.mu.Unlock() + return nil, "", apperr.ErrAccountLocked + } + } + s.mu.Unlock() + + u, err := s.userRepo.FindByUsernameOrEmail(account) + if err != nil { + s.recordLoginFail(account) + return nil, "", apperr.ErrPasswordWrong + } + if u.Status != 1 { + return nil, "", apperr.ErrUserDisabled + } + if !utils.CheckPassword(u.PasswordHash, password) { + s.recordLoginFail(account) + return nil, "", apperr.ErrPasswordWrong + } + + // 登录成功:清除失败计数 + s.mu.Lock() + delete(s.loginFails, account) + s.mu.Unlock() + + now := time.Now() + _ = s.userRepo.UpdateFields(u.ID, map[string]interface{}{ + "last_login_at": now, "last_login_ip": ip, + }) + + token, err := utils.GenerateToken(u.ID, u.Username, roleCodeOf(u)) + if err != nil { + return nil, "", err + } + return u, token, nil +} + +func (s *AuthService) recordLoginFail(account string) { + s.mu.Lock() + defer s.mu.Unlock() + if s.loginFails == nil { + s.loginFails = make(map[string]*loginState) + } + st, ok := s.loginFails[account] + if !ok { + st = &loginState{} + s.loginFails[account] = st + } + st.fails++ + if st.fails >= maxLoginFails { + st.lockedUntil = time.Now().Add(lockDuration) + st.fails = 0 + utils.Warn("auth", "账户 %s 因连续登录失败被锁定%d分钟", account, int(lockDuration.Minutes())) + } +} + +// roleCodeOf 用户角色编码(内存缓存于User.Role) +func roleCodeOf(u *model.User) string { + if u.Role != nil { + return u.Role.Code + } + return "" +} + +// CreateAPIKey 为用户生成API密钥,secret仅此一次返回 +func (s *AuthService) CreateAPIKey(userID uint, name string) (*model.APIKey, string, error) { + u, err := s.userRepo.FindByID(userID) + if err != nil { + return nil, "", apperr.ErrNotFound + } + key := &model.APIKey{ + UserID: userID, + AccessKey: utils.RandomKey(32), + SecretKey: utils.RandomKey(64), + Name: name, + Status: 1, + } + if key.Name == "" { + key.Name = u.Username + "的密钥" + } + if err := s.apiKeyRepo.Create(key); err != nil { + return nil, "", err + } + utils.Info("auth", "用户 %s 创建API密钥: %s", u.Username, key.AccessKey) + return key, key.SecretKey, nil +} + +// ListAPIKeys 用户密钥列表 +func (s *AuthService) ListAPIKeys(userID uint) ([]model.APIKey, error) { + return s.apiKeyRepo.ListByUser(userID) +} + +// DeleteAPIKey 删除密钥 +func (s *AuthService) DeleteAPIKey(userID, id uint) error { + if err := s.apiKeyRepo.Delete(id, userID); err != nil { + return apperr.ErrNotFound + } + return nil +} + +// VerifyAPIKeySignature 校验API Key HMAC-SHA256签名,返回所属用户 +// 签名串: accessKey + "\n" + timestamp + "\n" + nonce + "\n" + method + "\n" + path +func (s *AuthService) VerifyAPIKeySignature(accessKey, timestamp, nonce, signature, method, path string) (*model.User, error) { + s.ensureState() + + // 时间窗口 + var ts int64 + if _, err := fmt.Sscanf(timestamp, "%d", &ts); err != nil { + return nil, apperr.ErrSignInvalid + } + t := time.Unix(ts, 0) + if diff := time.Since(t); diff > signTimeWindow || diff < -signTimeWindow { + return nil, apperr.ErrSignExpired + } + + // 防重放 + s.mu.Lock() + if s.nonces == nil { + s.nonces = make(map[string]time.Time) + } + if _, used := s.nonces[nonce]; used { + s.mu.Unlock() + return nil, apperr.ErrSignInvalid + } + s.nonces[nonce] = time.Now() + // 顺带清理过期nonce + if len(s.nonces) > 10000 { + cutoff := time.Now().Add(-signTimeWindow * 2) + for k, v := range s.nonces { + if v.Before(cutoff) { + delete(s.nonces, k) + } + } + } + s.mu.Unlock() + + key, err := s.apiKeyRepo.FindByAccessKey(accessKey) + if err != nil { + return nil, apperr.ErrSignInvalid + } + if key.Status != 1 { + return nil, apperr.ErrForbidden + } + + message := strings.Join([]string{accessKey, timestamp, nonce, method, path}, "\n") + expect := utils.HMACSHA256(key.SecretKey, message) + if !strings.EqualFold(expect, signature) { + return nil, apperr.ErrSignInvalid + } + + _ = s.apiKeyRepo.Touch(key.ID) + + u, err := s.userRepo.FindByID(key.UserID) + if err != nil { + return nil, apperr.ErrNotFound + } + if u.Status != 1 { + return nil, apperr.ErrUserDisabled + } + return u, nil +} + +// LoadUserByID 加载用户(含角色权限) +func (s *AuthService) LoadUserByID(id uint) (*model.User, error) { + u, err := s.userRepo.FindByID(id) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, apperr.ErrNotFound + } + return nil, err + } + if u.Status != 1 { + return nil, apperr.ErrUserDisabled + } + return u, nil +} + +// PermissionCodesOf 用户拥有的权限编码集合 +func PermissionCodesOf(u *model.User) map[string]bool { + codes := make(map[string]bool) + if u == nil || u.Role == nil { + return codes + } + if u.Role.Code == "super_admin" { + codes["*"] = true + return codes + } + for _, p := range u.Role.Permissions { + codes[p.Code] = true + } + return codes +} + +// BootstrapAdmin 按配置确保管理员账户存在 +func (s *AuthService) BootstrapAdmin() error { + ac := s.cfg.Admin + if ac.Username == "" { + return nil + } + u, err := s.userRepo.FindByUsername(ac.Username) + if err == nil { + // 已存在:确保其为配置的角色 + if role, rerr := s.roleRepo.FindByCode(ac.Role); rerr == nil && u.RoleID != role.ID { + _ = s.userRepo.UpdateFields(u.ID, map[string]interface{}{"role_id": role.ID, "status": 1}) + } + return nil + } + role, err := s.roleRepo.FindByCode(ac.Role) + if err != nil { + return fmt.Errorf("配置的管理员角色 %s 不存在: %w", ac.Role, err) + } + hash, err := utils.HashPassword(ac.Password) + if err != nil { + return err + } + admin := &model.User{ + Username: ac.Username, Email: ac.Email, PasswordHash: hash, + RoleID: role.ID, Status: 1, StorageLimit: 1 << 40, + } + if err := s.userRepo.Create(admin); err != nil { + return err + } + utils.Info("auth", "已创建管理员账户: %s", ac.Username) + return nil +} diff --git a/go/internal/service/file.go b/go/internal/service/file.go new file mode 100644 index 0000000..1d548dd --- /dev/null +++ b/go/internal/service/file.go @@ -0,0 +1,423 @@ +package service + +import ( + "crypto/md5" + "encoding/hex" + "fmt" + "io" + "mime" + "mime/multipart" + "os" + "path/filepath" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "filestoragesystem/internal/config" + "filestoragesystem/internal/model" + "filestoragesystem/internal/repository" + "filestoragesystem/internal/utils" + "filestoragesystem/pkg/apperr" +) + +// FileService 文件服务 +type FileService struct { + cfg *config.Config + fileRepo *repository.FileRepo + projectRepo *repository.ProjectRepo + userRepo *repository.UserRepo + tempLinkRepo *repository.TempLinkRepo + trafficRepo *repository.TrafficRepo + settingRepo *repository.SettingRepo + opLogRepo *repository.OpLogRepo + webhookSvc *WebhookService +} + +// settingInt 读取数字型设置 +func (s *FileService) settingInt(key string, def int64) int64 { + if v, err := s.settingRepo.GetValue(key); err == nil { + var n int64 + if _, e := fmt.Sscanf(v, "%d", &n); e == nil && n > 0 { + return n + } + } + return def +} + +// settingBool 读取布尔型设置 +func (s *FileService) settingBool(key string, def bool) bool { + if v, err := s.settingRepo.GetValue(key); err == nil { + return v == "true" || v == "1" + } + return def +} + +// Upload 上传文件:校验配额/类型/大小,MD5去重,落盘,记录统计 +func (s *FileService) Upload(c *gin.Context, userID uint, fh *multipart.FileHeader) (*model.File, error) { + projectID, _ := strconvUint(c.PostForm("project_id")) + if projectID == 0 { + return nil, fmt.Errorf("缺少项目ID") + } + + project, err := s.projectRepo.FindByID(projectID) + if err != nil || project.UserID != userID { + return nil, apperr.ErrForbidden + } + if project.Status != 1 { + return nil, fmt.Errorf("项目已被禁用") + } + + src, err := fh.Open() + if err != nil { + return nil, err + } + defer src.Close() + + size := fh.Size + // 大小限制 + maxSize := s.settingInt("max_file_size", 104857600) + if size > maxSize { + return nil, apperr.ErrFileTooLarge + } + // 类型限制 + allowed := "*" + if v, err := s.settingRepo.GetValue("allowed_file_types"); err == nil { + allowed = v + } + if !s.checkTypeAllowed(fh.Filename, allowed) { + return nil, apperr.ErrFileTypeDenied + } + // 配额 + if project.StorageUsed+size > project.StorageLimit { + return nil, apperr.ErrQuotaExceeded + } + user, err := s.userRepo.FindByID(userID) + if err != nil { + return nil, apperr.ErrNotFound + } + if user.StorageUsed+size > user.StorageLimit { + return nil, apperr.ErrQuotaExceeded + } + + // 虚拟路径 + 文件名 + virtualPath := utils.SanitizePath(c.PostForm("path")) + baseName := filepath.Base(utils.SanitizePath(fh.Filename)) + if baseName == "" || baseName == "." || baseName == "/" { + baseName = "unnamed" + } + filename := baseName + if virtualPath != "" { + filename = virtualPath + "/" + baseName + } + visibility := int8(1) + if c.PostForm("visibility") == "2" || c.PostForm("visibility") == "public" { + visibility = 2 + } + + // 写入临时文件并计算MD5 + tmpFile, err := os.CreateTemp("", "fss-upload-*") + if err != nil { + return nil, err + } + tmpPath := tmpFile.Name() + defer os.Remove(tmpPath) + + hasher := md5.New() + if _, err := io.Copy(io.MultiWriter(tmpFile, hasher), src); err != nil { + tmpFile.Close() + return nil, err + } + tmpFile.Close() + md5sum := hex.EncodeToString(hasher.Sum(nil)) + + // 去重:相同MD5复用已有物理文件 + storedRel := "" + if exist, err := s.fileRepo.FindByMD5AndPath(md5sum); err == nil && exist.StoredPath != "" { + if _, serr := os.Stat(filepath.Join(s.cfg.Storage.Root, exist.StoredPath)); serr == nil { + storedRel = exist.StoredPath + } + } + + // 无可复用文件则保存到 blob// + if storedRel == "" { + blobRel := filepath.ToSlash(filepath.Join("blobs", md5sum[:2], md5sum)) + blobAbs := filepath.Join(s.cfg.Storage.Root, blobRel) + if err := os.MkdirAll(filepath.Dir(blobAbs), 0o755); err != nil { + return nil, err + } + if err := os.Rename(tmpPath, blobAbs); err != nil { + // 跨盘时回退为复制 + if cerr := copyFile(tmpPath, blobAbs); cerr != nil { + return nil, cerr + } + } + storedRel = blobRel + } + + mimeType := fh.Header.Get("Content-Type") + if mimeType == "" || mimeType == "application/octet-stream" { + if t := mime.TypeByExtension(strings.ToLower(filepath.Ext(baseName))); t != "" { + mimeType = t + } + } + + f := &model.File{ + ProjectID: projectID, + UserID: userID, + Filename: filename, + StoredPath: storedRel, + Size: size, + MimeType: mimeType, + MD5: md5sum, + Visibility: visibility, + Status: 1, + } + if err := s.fileRepo.Create(f); err != nil { + return nil, err + } + + // 更新配额、流量、webhook + _ = s.projectRepo.UpdateStorageUsed(projectID, size) + _ = s.userRepo.UpdateStorageUsed(userID, size) + _ = s.trafficRepo.Create(&model.TrafficLog{ + UserID: userID, ProjectID: projectID, FileID: f.ID, + Type: 1, Size: size, IP: utils.ClientIP(c), CreatedAt: time.Now(), + }) + s.webhookSvc.Dispatch(userID, projectID, "file.upload", map[string]interface{}{ + "file_id": f.ID, "filename": f.Filename, "size": f.Size, "md5": f.MD5, "project_id": projectID, + }) + utils.Info("storage", "用户#%d 上传文件 %s (%d字节)", userID, f.Filename, f.Size) + return f, nil +} + +func (s *FileService) checkTypeAllowed(filename, allowed string) bool { + allowed = strings.TrimSpace(allowed) + if allowed == "" || allowed == "*" { + return true + } + ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), ".")) + if ext == "" { + return true // 无扩展名不限制 + } + for _, t := range strings.Split(allowed, ",") { + t = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(t, "."))) + if t == "*" || t == ext { + return true + } + } + return false +} + +// GetInfo 获取文件信息(所有者或管理员) +func (s *FileService) GetInfo(userID uint, id uint, isAdmin bool) (*model.File, error) { + f, err := s.fileRepo.FindByID(id) + if err != nil { + return nil, apperr.ErrNotFound + } + if !isAdmin && f.UserID != userID { + return nil, apperr.ErrForbidden + } + return f, nil +} + +// List 文件列表 +func (s *FileService) List(userID uint, isAdmin bool, filter repository.FileListFilter) ([]model.File, int64, error) { + if !isAdmin { + filter.UserID = userID + } + if filter.Page <= 0 { + filter.Page = 1 + } + if filter.PageSize <= 0 { + filter.PageSize = 20 + } + return s.fileRepo.List(filter) +} + +// Open 打开文件物理句柄并校验访问权,记录流量 +func (s *FileService) Open(c *gin.Context, userID uint, id uint, isAdmin bool) (*model.File, *os.File, error) { + f, err := s.GetInfo(userID, id, isAdmin) + if err != nil { + return nil, nil, err + } + if f.Status != 1 { + return nil, nil, apperr.ErrFileDeleted + } + abs := filepath.Join(s.cfg.Storage.Root, f.StoredPath) + fp, err := os.Open(abs) + if err != nil { + utils.Error("storage", "打开文件失败 path=%s err=%v", abs, err) + return nil, nil, fmt.Errorf("文件读取失败") + } + return f, fp, nil +} + +// RecordDownload 记录下载行为 +func (s *FileService) RecordDownload(c *gin.Context, f *model.File) { + _ = s.fileRepo.IncrDownloadCount(f.ID) + _ = s.trafficRepo.Create(&model.TrafficLog{ + UserID: f.UserID, ProjectID: f.ProjectID, FileID: f.ID, + Type: 2, Size: f.Size, IP: utils.ClientIP(c), CreatedAt: time.Now(), + }) +} + +// Delete 删除文件;permanent=true时硬删除 +func (s *FileService) Delete(userID uint, id uint, isAdmin bool, permanent bool) error { + f, err := s.GetInfo(userID, id, isAdmin) + if err != nil { + return err + } + + if !permanent { + if f.Status == 0 { + return apperr.ErrFileDeleted + } + if err := s.fileRepo.SoftDelete(id); err != nil { + return err + } + // 回收站不占配额 + _ = s.projectRepo.UpdateStorageUsed(f.ProjectID, -f.Size) + _ = s.userRepo.UpdateStorageUsed(f.UserID, -f.Size) + return nil + } + + // 硬删除 + if err := s.fileRepo.Delete(id); err != nil { + return err + } + _ = s.tempLinkRepo.DeleteByFile(id) + // 若处于回收站,配额已扣减过 + if f.Status == 1 { + _ = s.projectRepo.UpdateStorageUsed(f.ProjectID, -f.Size) + _ = s.userRepo.UpdateStorageUsed(f.UserID, -f.Size) + } + // 物理文件无其他引用时删除 + if n, err := s.fileRepo.CountByStoredPath(f.StoredPath); err == nil && n == 0 { + _ = os.Remove(filepath.Join(s.cfg.Storage.Root, f.StoredPath)) + } + s.webhookSvc.Dispatch(f.UserID, f.ProjectID, "file.delete", map[string]interface{}{ + "file_id": f.ID, "filename": f.Filename, "permanent": permanent, + }) + return nil +} + +// Restore 从回收站恢复文件 +func (s *FileService) Restore(userID uint, id uint, isAdmin bool) error { + f, err := s.GetInfo(userID, id, isAdmin) + if err != nil { + return err + } + if f.Status != 0 { + return fmt.Errorf("文件不在回收站中") + } + // 恢复前检查配额 + project, err := s.projectRepo.FindByID(f.ProjectID) + if err == nil { + if project.StorageUsed+f.Size > project.StorageLimit { + return apperr.ErrQuotaExceeded + } + } + user, err := s.userRepo.FindByID(f.UserID) + if err == nil && user.StorageUsed+f.Size > user.StorageLimit { + return apperr.ErrQuotaExceeded + } + if err := s.fileRepo.Restore(id); err != nil { + return err + } + _ = s.projectRepo.UpdateStorageUsed(f.ProjectID, f.Size) + _ = s.userRepo.UpdateStorageUsed(f.UserID, f.Size) + return nil +} + +// CreateTempLink 生成临时访问链接 +func (s *FileService) CreateTempLink(userID uint, id uint, isAdmin bool, password string, maxCount int, expiresInSeconds int64) (*model.TempLink, error) { + f, err := s.GetInfo(userID, id, isAdmin) + if err != nil { + return nil, err + } + if f.Status != 1 { + return nil, apperr.ErrFileDeleted + } + if expiresInSeconds <= 0 { + expiresInSeconds = s.settingInt("temp_link_max_age", 3600) + } + if expiresInSeconds > 7*24*3600 { + expiresInSeconds = 7 * 24 * 3600 + } + link := &model.TempLink{ + FileID: f.ID, + UserID: userID, + Token: utils.RandomKey(48), + MaxCount: maxCount, + ExpiresAt: time.Now().Add(time.Duration(expiresInSeconds) * time.Second), + } + if password != "" { + link.Password = password + } + if err := s.tempLinkRepo.Create(link); err != nil { + return nil, err + } + link.HasPwd = link.Password != "" + return link, nil +} + +// ResolveTempLink 通过token解析临时链接(校验有效期/次数/密码),返回文件与物理句柄 +func (s *FileService) ResolveTempLink(token, password string) (*model.File, *os.File, error) { + link, err := s.tempLinkRepo.FindByToken(token) + if err != nil { + return nil, nil, apperr.ErrLinkExpired + } + if time.Now().After(link.ExpiresAt) { + return nil, nil, apperr.ErrLinkExpired + } + if link.MaxCount > 0 && link.UsedCount >= link.MaxCount { + return nil, nil, apperr.ErrLinkExpired + } + if link.Password != "" && link.Password != password { + return nil, nil, apperr.ErrLinkPassword + } + f, err := s.fileRepo.FindByID(link.FileID) + if err != nil || f.Status != 1 { + return nil, nil, apperr.ErrNotFound + } + fp, err := os.Open(filepath.Join(s.cfg.Storage.Root, f.StoredPath)) + if err != nil { + return nil, nil, fmt.Errorf("文件读取失败") + } + _ = s.tempLinkRepo.IncrUsed(link.ID) + return f, fp, nil +} + +// ---- 辅助函数 ---- + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + if _, err := io.Copy(out, in); err != nil { + return err + } + return out.Sync() +} + +func strconvUint(s string) (uint, error) { + var n uint + if s == "" { + return 0, fmt.Errorf("empty") + } + for _, ch := range s { + if ch < '0' || ch > '9' { + return 0, fmt.Errorf("invalid") + } + n = n*10 + uint(ch-'0') + } + return n, nil +} diff --git a/go/internal/service/project.go b/go/internal/service/project.go new file mode 100644 index 0000000..7ffbfdb --- /dev/null +++ b/go/internal/service/project.go @@ -0,0 +1,110 @@ +package service + +import ( + "fmt" + "os" + "path/filepath" + + "filestoragesystem/internal/config" + "filestoragesystem/internal/model" + "filestoragesystem/internal/repository" + "filestoragesystem/pkg/apperr" +) + +// ProjectService 项目服务 +type ProjectService struct { + cfg *config.Config + projectRepo *repository.ProjectRepo + fileRepo *repository.FileRepo + settingRepo *repository.SettingRepo + opLogRepo *repository.OpLogRepo + webhookSvc *WebhookService +} + +// Create 创建项目 +func (s *ProjectService) Create(userID uint, name, description string, storageLimit int64) (*model.Project, error) { + if name == "" || len(name) > 100 { + return nil, fmt.Errorf("项目名称不能为空且不超过100字符") + } + if _, err := s.projectRepo.FindByName(userID, name); err == nil { + return nil, apperr.ErrProjectExists + } + if storageLimit <= 0 { + storageLimit = 5368709120 // 5GB + if v, err := s.settingRepo.GetValue("default_project_limit"); err == nil { + var n int64 + if _, e := fmt.Sscanf(v, "%d", &n); e == nil && n > 0 { + storageLimit = n + } + } + } + p := &model.Project{ + UserID: userID, Name: name, Description: description, + StorageLimit: storageLimit, Status: 1, + } + if err := s.projectRepo.Create(p); err != nil { + return nil, err + } + return p, nil +} + +// List 项目列表,all=true时管理员查看全部 +func (s *ProjectService) List(userID uint, keyword string, all bool, page, pageSize int) ([]model.Project, int64, error) { + return s.projectRepo.List(userID, keyword, all, page, pageSize) +} + +// Get 项目详情(所有者或管理员) +func (s *ProjectService) Get(userID uint, id uint, isAdmin bool) (*model.Project, error) { + p, err := s.projectRepo.FindByID(id) + if err != nil { + return nil, apperr.ErrNotFound + } + if !isAdmin && p.UserID != userID { + return nil, apperr.ErrForbidden + } + return p, nil +} + +// Update 更新项目 +func (s *ProjectService) Update(userID uint, id uint, isAdmin bool, name, description string, storageLimit int64) (*model.Project, error) { + p, err := s.Get(userID, id, isAdmin) + if err != nil { + return nil, err + } + if name != "" && name != p.Name { + if _, err := s.projectRepo.FindByName(userID, name); err == nil { + return nil, apperr.ErrProjectExists + } + p.Name = name + } + if description != "" { + p.Description = description + } + if storageLimit > 0 { + p.StorageLimit = storageLimit + } + if err := s.projectRepo.Update(p); err != nil { + return nil, err + } + return p, nil +} + +// Delete 删除项目及其全部文件(硬删除) +func (s *ProjectService) Delete(userID uint, id uint, isAdmin bool) error { + if _, err := s.Get(userID, id, isAdmin); err != nil { + return err + } + // 删除物理文件 + files, _ := s.fileRepo.ListByProject(id) + seen := make(map[string]bool) + for _, f := range files { + if f.StoredPath != "" && !seen[f.StoredPath] { + seen[f.StoredPath] = true + _ = os.Remove(filepath.Join(s.cfg.Storage.Root, f.StoredPath)) + } + } + _ = s.fileRepo.DB.Exec("DELETE FROM files WHERE project_id = ?", id).Error + _ = s.fileRepo.DB.Exec("DELETE FROM temp_links WHERE file_id IN (SELECT id FROM files WHERE project_id = ?)", id).Error + + return s.projectRepo.Delete(id) +} diff --git a/go/internal/service/role.go b/go/internal/service/role.go new file mode 100644 index 0000000..fd5de34 --- /dev/null +++ b/go/internal/service/role.go @@ -0,0 +1,136 @@ +package service + +import ( + "fmt" + + "filestoragesystem/internal/model" + "filestoragesystem/internal/repository" + "filestoragesystem/pkg/apperr" +) + +// RoleService 角色权限服务 +type RoleService struct { + roleRepo *repository.RoleRepo + permRepo *repository.PermissionRepo +} + +// ListRoles 角色列表 +func (s *RoleService) ListRoles() ([]model.Role, error) { + return s.roleRepo.List() +} + +// GetRole 角色详情(含权限) +func (s *RoleService) GetRole(id uint) (*model.Role, error) { + r, err := s.roleRepo.FindByID(id) + if err != nil { + return nil, apperr.ErrNotFound + } + return r, nil +} + +// CreateRole 创建角色 +func (s *RoleService) CreateRole(name, code, description string, permIDs []uint) (*model.Role, error) { + if name == "" || code == "" { + return nil, fmt.Errorf("角色名称和编码不能为空") + } + if _, err := s.roleRepo.FindByCode(code); err == nil { + return nil, fmt.Errorf("角色编码已存在") + } + role := &model.Role{ + Name: name, Code: code, Description: description, + IsSystem: false, Status: 1, + Permissions: s.mustPerms(permIDs), + } + if err := s.roleRepo.Create(role); err != nil { + return nil, err + } + return role, nil +} + +// UpdateRole 编辑角色(系统角色仅允许改描述与权限) +func (s *RoleService) UpdateRole(id uint, name, description string, permIDs []uint) (*model.Role, error) { + role, err := s.roleRepo.FindByID(id) + if err != nil { + return nil, apperr.ErrNotFound + } + if role.Code == "super_admin" { + return nil, apperr.ErrSystemRole + } + if name != "" && !role.IsSystem { + role.Name = name + } + if description != "" { + role.Description = description + } + if err := s.roleRepo.Update(role); err != nil { + return nil, err + } + if permIDs != nil { + if err := s.roleRepo.ReplacePermissions(id, permIDs); err != nil { + return nil, err + } + } + return s.roleRepo.FindByID(id) +} + +// DeleteRole 删除角色(系统角色不可删,有用户的角色不可删) +func (s *RoleService) DeleteRole(id uint) error { + role, err := s.roleRepo.FindByID(id) + if err != nil { + return apperr.ErrNotFound + } + if role.IsSystem { + return apperr.ErrSystemRole + } + if n, _ := s.roleRepo.CountUsersByRole(id); n > 0 { + return fmt.Errorf("该角色下仍有 %d 个用户,无法删除", n) + } + return s.roleRepo.Delete(id) +} + +// SetPermissions 配置角色权限 +func (s *RoleService) SetPermissions(id uint, permIDs []uint) error { + role, err := s.roleRepo.FindByID(id) + if err != nil { + return apperr.ErrNotFound + } + if role.Code == "super_admin" { + return apperr.ErrSystemRole + } + return s.roleRepo.ReplacePermissions(id, permIDs) +} + +// ListPermissions 全部权限 +func (s *RoleService) ListPermissions() ([]model.Permission, error) { + return s.permRepo.List() +} + +// PermissionsGrouped 按模块分组权限 +func (s *RoleService) PermissionsGrouped() (map[string][]model.Permission, []string, error) { + perms, err := s.permRepo.List() + if err != nil { + return nil, nil, err + } + groups := make(map[string][]model.Permission) + var order []string + seen := make(map[string]bool) + for _, p := range perms { + if !seen[p.Module] { + seen[p.Module] = true + order = append(order, p.Module) + } + groups[p.Module] = append(groups[p.Module], p) + } + return groups, order, nil +} + +func (s *RoleService) mustPerms(ids []uint) []model.Permission { + if len(ids) == 0 { + return nil + } + perms, err := s.permRepo.ListByIDs(ids) + if err != nil { + return nil + } + return perms +} diff --git a/go/internal/service/service.go b/go/internal/service/service.go new file mode 100644 index 0000000..68b1901 --- /dev/null +++ b/go/internal/service/service.go @@ -0,0 +1,116 @@ +package service + +import ( + "time" + + "gorm.io/gorm" + + "filestoragesystem/internal/config" + "filestoragesystem/internal/model" + "filestoragesystem/internal/repository" +) + +// OpLogContext 操作日志上下文(由controller从请求中提取) +type OpLogContext struct { + UserID *uint + Username string + IP string + UserAgent string + Path string + Method string +} + +// Services 服务容器 +type Services struct { + cfg *config.Config + db *gorm.DB + Auth *AuthService + User *UserService + Project *ProjectService + File *FileService + Role *RoleService + Admin *AdminService + Stats *StatsService + Webhook *WebhookService + opLogRepo *repository.OpLogRepo +} + +// NewServices 创建服务容器并初始化各服务 +func NewServices(cfg *config.Config, db *gorm.DB) *Services { + userRepo := &repository.UserRepo{DB: db} + roleRepo := &repository.RoleRepo{DB: db} + permRepo := &repository.PermissionRepo{DB: db} + projectRepo := &repository.ProjectRepo{DB: db} + fileRepo := &repository.FileRepo{DB: db} + tempLinkRepo := &repository.TempLinkRepo{DB: db} + trafficRepo := &repository.TrafficRepo{DB: db} + settingRepo := &repository.SettingRepo{DB: db} + opLogRepo := &repository.OpLogRepo{DB: db} + sysLogRepo := &repository.SysLogRepo{DB: db} + webhookRepo := &repository.WebhookRepo{DB: db} + apiKeyRepo := &repository.APIKeyRepo{DB: db} + + s := &Services{cfg: cfg, db: db, opLogRepo: opLogRepo} + + s.Auth = &AuthService{ + cfg: cfg, userRepo: userRepo, apiKeyRepo: apiKeyRepo, + roleRepo: roleRepo, settingRepo: settingRepo, opLogRepo: opLogRepo, + } + s.User = &UserService{ + cfg: cfg, userRepo: userRepo, roleRepo: roleRepo, + settingRepo: settingRepo, opLogRepo: opLogRepo, fileRepo: fileRepo, projectRepo: projectRepo, + } + s.Project = &ProjectService{ + cfg: cfg, projectRepo: projectRepo, fileRepo: fileRepo, + settingRepo: settingRepo, opLogRepo: opLogRepo, webhookSvc: nil, + } + s.Webhook = &WebhookService{cfg: cfg, webhookRepo: webhookRepo, settingRepo: settingRepo} + s.File = &FileService{ + cfg: cfg, fileRepo: fileRepo, projectRepo: projectRepo, userRepo: userRepo, + tempLinkRepo: tempLinkRepo, trafficRepo: trafficRepo, + settingRepo: settingRepo, opLogRepo: opLogRepo, webhookSvc: s.Webhook, + } + s.Role = &RoleService{roleRepo: roleRepo, permRepo: permRepo} + s.Admin = &AdminService{ + cfg: cfg, db: db, settingRepo: settingRepo, + opLogRepo: opLogRepo, sysLogRepo: sysLogRepo, + } + s.Stats = &StatsService{ + cfg: cfg, userRepo: userRepo, projectRepo: projectRepo, + fileRepo: fileRepo, trafficRepo: trafficRepo, + } + s.Project.webhookSvc = s.Webhook + return s +} + +// RecordOpLog 记录操作日志(异步容忍失败) +func (s *Services) RecordOpLog(ctx *OpLogContext, action, resourceType string, resourceID uint, resourceName string, success bool, errMsg string) { + log := model.OperationLog{ + Action: action, + ResourceType: resourceType, + ResourceID: resourceID, + ResourceName: resourceName, + IP: ctx.IP, + UserAgent: truncate(ctx.UserAgent, 500), + RequestPath: truncate(ctx.Path, 500), + RequestMethod: ctx.Method, + Status: 1, + ErrorMessage: errMsg, + CreatedAt: time.Now(), + } + if ctx.UserID != nil { + log.UserID = ctx.UserID + } + log.Username = ctx.Username + if !success { + log.Status = 0 + } + _ = s.opLogRepo.Create(&log) +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] +} diff --git a/go/internal/service/stats.go b/go/internal/service/stats.go new file mode 100644 index 0000000..f5f486b --- /dev/null +++ b/go/internal/service/stats.go @@ -0,0 +1,109 @@ +package service + +import ( + "filestoragesystem/internal/config" + "filestoragesystem/internal/repository" + "filestoragesystem/pkg/apperr" +) + +// StatsService 存储与流量统计服务 +type StatsService struct { + cfg *config.Config + userRepo *repository.UserRepo + projectRepo *repository.ProjectRepo + fileRepo *repository.FileRepo + trafficRepo *repository.TrafficRepo +} + +// UserStats 用户存储统计:总体 + 项目明细 + 流量汇总 +func (s *StatsService) UserStats(userID uint) (map[string]interface{}, error) { + user, err := s.userRepo.FindByID(userID) + if err != nil { + return nil, err + } + fileCount, totalSize, err := s.fileRepo.StatsByUser(userID) + if err != nil { + return nil, err + } + + projects, err := s.projectRepo.ListByUser(userID) + if err != nil { + return nil, err + } + projectStats := make([]map[string]interface{}, 0, len(projects)) + for _, p := range projects { + count, size, err := s.fileRepo.StatsByProject(p.ID) + if err != nil { + continue + } + projectStats = append(projectStats, map[string]interface{}{ + "project_id": p.ID, + "name": p.Name, + "file_count": count, + "storage_used": size, + "storage_limit": p.StorageLimit, + }) + } + + upload30, download30, _ := s.trafficRepo.SumByUser(userID, 30) + uploadAll, downloadAll, _ := s.trafficRepo.SumByUser(userID, 0) + + return map[string]interface{}{ + "storage_used": user.StorageUsed, + "storage_limit": user.StorageLimit, + "file_count": fileCount, + "total_size": totalSize, + "project_count": len(projects), + "projects": projectStats, + "traffic": map[string]interface{}{ + "upload_30d": upload30, + "download_30d": download30, + "upload_total": uploadAll, + "download_total": downloadAll, + }, + }, nil +} + +// ProjectStats 项目存储统计 +func (s *StatsService) ProjectStats(userID uint, projectID uint, isAdmin bool) (map[string]interface{}, error) { + p, err := s.projectRepo.FindByID(projectID) + if err != nil { + return nil, err + } + if !isAdmin && p.UserID != userID { + return nil, apperr.ErrForbidden + } + count, size, err := s.fileRepo.StatsByProject(projectID) + if err != nil { + return nil, err + } + var trashed int64 + s.fileRepo.DB.Table("files").Where("project_id = ? AND status = 0", projectID).Count(&trashed) + + return map[string]interface{}{ + "project_id": p.ID, + "name": p.Name, + "file_count": count, + "storage_used": size, + "storage_limit": p.StorageLimit, + "trash_count": trashed, + }, nil +} + +// TrafficStats 流量统计:按天聚合 + 汇总 +func (s *StatsService) TrafficStats(userID uint, days int) (map[string]interface{}, error) { + if days <= 0 { + days = 30 + } + daily, err := s.trafficRepo.DailyStats(userID, days) + if err != nil { + return nil, err + } + upload, download, _ := s.trafficRepo.SumByUser(userID, days) + return map[string]interface{}{ + "days": days, + "daily": daily, + "upload": upload, + "download": download, + }, nil +} diff --git a/go/internal/service/user.go b/go/internal/service/user.go new file mode 100644 index 0000000..4aa94c1 --- /dev/null +++ b/go/internal/service/user.go @@ -0,0 +1,258 @@ +package service + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "gorm.io/gorm" + + "filestoragesystem/internal/config" + "filestoragesystem/internal/model" + "filestoragesystem/internal/repository" + "filestoragesystem/internal/utils" + "filestoragesystem/pkg/apperr" +) + +// UserService 用户管理服务(个人资料 + 管理员用户管理) +type UserService struct { + cfg *config.Config + userRepo *repository.UserRepo + roleRepo *repository.RoleRepo + settingRepo *repository.SettingRepo + opLogRepo *repository.OpLogRepo + fileRepo *repository.FileRepo + projectRepo *repository.ProjectRepo +} + +// GetProfile 获取个人资料 +func (s *UserService) GetProfile(userID uint) (*model.User, error) { + u, err := s.userRepo.FindByID(userID) + if err != nil { + return nil, apperr.ErrNotFound + } + return u, nil +} + +// UpdateProfile 更新个人资料(邮箱) +func (s *UserService) UpdateProfile(userID uint, email string) error { + email = strings.TrimSpace(strings.ToLower(email)) + if email != "" && !utils.IsEmail(email) { + return fmt.Errorf("邮箱格式不正确") + } + if email != "" { + if other, err := s.userRepo.FindByEmail(email); err == nil && other.ID != userID { + return apperr.ErrUserExists + } + return s.userRepo.UpdateFields(userID, map[string]interface{}{"email": email}) + } + return nil +} + +// ChangePassword 修改自己的密码 +func (s *UserService) ChangePassword(userID uint, oldPwd, newPwd string) error { + if !utils.IsValidPassword(newPwd) { + return fmt.Errorf("新密码至少8位且需包含字母和数字") + } + u, err := s.userRepo.FindByID(userID) + if err != nil { + return apperr.ErrNotFound + } + if !utils.CheckPassword(u.PasswordHash, oldPwd) { + return fmt.Errorf("原密码错误") + } + hash, err := utils.HashPassword(newPwd) + if err != nil { + return err + } + return s.userRepo.UpdateFields(userID, map[string]interface{}{"password_hash": hash}) +} + +// ---- 管理员操作 ---- + +// AdminListUsers 用户列表 +func (s *UserService) AdminListUsers(keyword, roleID, status string, page, pageSize int) ([]model.User, int64, error) { + return s.userRepo.List(keyword, roleID, status, page, pageSize) +} + +// AdminGetUser 用户详情 +func (s *UserService) AdminGetUser(id uint) (*model.User, error) { + u, err := s.userRepo.FindByID(id) + if err != nil { + return nil, apperr.ErrNotFound + } + return u, nil +} + +// AdminCreateUser 管理员创建用户 +func (s *UserService) AdminCreateUser(username, email, password string, roleID uint, storageLimit int64) (*model.User, error) { + username = strings.TrimSpace(username) + email = strings.TrimSpace(strings.ToLower(email)) + if len(username) < 3 { + return nil, fmt.Errorf("用户名至少3个字符") + } + if !utils.IsEmail(email) { + return nil, fmt.Errorf("邮箱格式不正确") + } + if !utils.IsValidPassword(password) { + return nil, fmt.Errorf("密码至少8位且需包含字母和数字") + } + if _, err := s.userRepo.FindByUsername(username); err == nil { + return nil, apperr.ErrUserExists + } + if _, err := s.userRepo.FindByEmail(email); err == nil { + return nil, apperr.ErrUserExists + } + role, err := s.roleRepo.FindByID(roleID) + if err != nil { + return nil, fmt.Errorf("角色不存在") + } + if storageLimit <= 0 { + storageLimit = 10737418240 + if v, err := s.settingRepo.GetValue("default_storage_limit"); err == nil { + var n int64 + if _, e := fmt.Sscanf(v, "%d", &n); e == nil && n > 0 { + storageLimit = n + } + } + } + hash, err := utils.HashPassword(password) + if err != nil { + return nil, err + } + u := &model.User{ + Username: username, Email: email, PasswordHash: hash, + RoleID: role.ID, Status: 1, StorageLimit: storageLimit, + } + if err := s.userRepo.Create(u); err != nil { + return nil, err + } + utils.Info("user", "管理员创建用户: %s", username) + return u, nil +} + +// AdminUpdateUser 编辑用户(邮箱/角色/配额) +func (s *UserService) AdminUpdateUser(id uint, email string, roleID uint, storageLimit int64) (*model.User, error) { + u, err := s.userRepo.FindByID(id) + if err != nil { + return nil, apperr.ErrNotFound + } + updates := map[string]interface{}{} + if email = strings.TrimSpace(strings.ToLower(email)); email != "" { + if !utils.IsEmail(email) { + return nil, fmt.Errorf("邮箱格式不正确") + } + if other, err := s.userRepo.FindByEmail(email); err == nil && other.ID != id { + return nil, apperr.ErrUserExists + } + updates["email"] = email + } + if roleID > 0 { + role, err := s.roleRepo.FindByID(roleID) + if err != nil { + return nil, fmt.Errorf("角色不存在") + } + // 防止移除最后一个超管 + if u.Role != nil && u.Role.Code == "super_admin" && role.Code != "super_admin" { + if n, _ := s.roleRepo.CountUsersByRole(u.RoleID); n <= 1 { + return nil, fmt.Errorf("系统至少需要保留一个超级管理员") + } + } + updates["role_id"] = role.ID + } + if storageLimit > 0 { + updates["storage_limit"] = storageLimit + } + if len(updates) == 0 { + return u, nil + } + if err := s.userRepo.UpdateFields(id, updates); err != nil { + return nil, err + } + return s.userRepo.FindByID(id) +} + +// AdminSetUserStatus 启用/禁用用户 +func (s *UserService) AdminSetUserStatus(id uint, status int8) error { + u, err := s.userRepo.FindByID(id) + if err != nil { + return apperr.ErrNotFound + } + if u.Role != nil && u.Role.Code == "super_admin" && status == 0 { + if n, _ := s.roleRepo.CountUsersByRole(u.RoleID); n <= 1 { + return fmt.Errorf("不能禁用最后一个超级管理员") + } + } + return s.userRepo.UpdateFields(id, map[string]interface{}{"status": status}) +} + +// AdminAssignRole 修改用户角色 +func (s *UserService) AdminAssignRole(id, roleID uint) error { + role, err := s.roleRepo.FindByID(roleID) + if err != nil { + return fmt.Errorf("角色不存在") + } + u, err := s.userRepo.FindByID(id) + if err != nil { + return apperr.ErrNotFound + } + if u.Role != nil && u.Role.Code == "super_admin" && role.Code != "super_admin" { + if n, _ := s.roleRepo.CountUsersByRole(u.RoleID); n <= 1 { + return fmt.Errorf("系统至少需要保留一个超级管理员") + } + } + return s.userRepo.UpdateFields(id, map[string]interface{}{"role_id": role.ID}) +} + +// AdminResetPassword 重置用户密码 +func (s *UserService) AdminResetPassword(id uint, newPassword string) error { + if !utils.IsValidPassword(newPassword) { + return fmt.Errorf("密码至少8位且需包含字母和数字") + } + hash, err := utils.HashPassword(newPassword) + if err != nil { + return err + } + return s.userRepo.UpdateFields(id, map[string]interface{}{"password_hash": hash}) +} + +// AdminDeleteUser 删除用户及其全部数据 +func (s *UserService) AdminDeleteUser(id uint) error { + u, err := s.userRepo.FindByID(id) + if err != nil { + if err == gorm.ErrRecordNotFound { + return apperr.ErrNotFound + } + return err + } + if u.Role != nil && u.Role.Code == "super_admin" { + if n, _ := s.roleRepo.CountUsersByRole(u.RoleID); n <= 1 { + return fmt.Errorf("不能删除最后一个超级管理员") + } + } + + // 删除物理文件 + files, _ := s.fileRepo.ListByUser(id) + seen := make(map[string]bool) + for _, f := range files { + if f.StoredPath != "" && !seen[f.StoredPath] { + seen[f.StoredPath] = true + _ = os.Remove(filepath.Join(s.cfg.Storage.Root, f.StoredPath)) + } + } + + // 级联清理(文件/项目/密钥由外键无级联,手动删除) + _ = s.fileRepo.DB.Exec("DELETE FROM files WHERE user_id = ?", id).Error + _ = s.fileRepo.DB.Exec("DELETE FROM temp_links WHERE user_id = ?", id).Error + _ = s.fileRepo.DB.Exec("DELETE FROM projects WHERE user_id = ?", id).Error + _ = s.fileRepo.DB.Exec("DELETE FROM api_keys WHERE user_id = ?", id).Error + _ = s.fileRepo.DB.Exec("DELETE FROM webhooks WHERE user_id = ?", id).Error + _ = s.fileRepo.DB.Exec("DELETE FROM traffic_logs WHERE user_id = ?", id).Error + + if err := s.userRepo.Delete(id); err != nil { + return err + } + utils.Info("user", "管理员删除用户: %s", u.Username) + return nil +} diff --git a/go/internal/service/webhook.go b/go/internal/service/webhook.go new file mode 100644 index 0000000..66bd5fd --- /dev/null +++ b/go/internal/service/webhook.go @@ -0,0 +1,170 @@ +package service + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "filestoragesystem/internal/config" + "filestoragesystem/internal/model" + "filestoragesystem/internal/repository" + "filestoragesystem/internal/utils" + "filestoragesystem/pkg/apperr" +) + +// WebhookService Webhook服务 +type WebhookService struct { + cfg *config.Config + webhookRepo *repository.WebhookRepo + settingRepo *repository.SettingRepo +} + +// Create 创建webhook +func (s *WebhookService) Create(userID uint, projectID *uint, url, secret, events string, status int8) (*model.Webhook, error) { + if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { + return nil, fmt.Errorf("回调地址必须以http://或https://开头") + } + events = normalizeEvents(events) + if events == "" { + return nil, fmt.Errorf("至少订阅一个事件") + } + if projectID != nil && *projectID > 0 { + // 校验项目归属 + var count int64 + s.webhookRepo.DB.Model(&model.Project{}).Where("id = ? AND user_id = ?", *projectID, userID).Count(&count) + if count == 0 { + return nil, apperr.ErrForbidden + } + } + if secret == "" { + secret = utils.RandomKey(32) + } + if status == 0 { + status = 1 + } + w := &model.Webhook{ + UserID: userID, ProjectID: projectID, URL: url, + Secret: secret, Events: events, Status: status, + } + if err := s.webhookRepo.Create(w); err != nil { + return nil, err + } + return w, nil +} + +// List 用户webhook列表 +func (s *WebhookService) List(userID uint) ([]model.Webhook, error) { + return s.webhookRepo.ListByUser(userID) +} + +// Update 更新webhook +func (s *WebhookService) Update(userID, id uint, url, events string, status int8) (*model.Webhook, error) { + w, err := s.webhookRepo.FindByID(id) + if err != nil || w.UserID != userID { + return nil, apperr.ErrNotFound + } + if url != "" { + if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { + return nil, fmt.Errorf("回调地址必须以http://或https://开头") + } + w.URL = url + } + if events != "" { + events = normalizeEvents(events) + if events == "" { + return nil, fmt.Errorf("至少订阅一个事件") + } + w.Events = events + } + if status == 0 || status == 1 { + w.Status = status + } + if err := s.webhookRepo.Update(w); err != nil { + return nil, err + } + return w, nil +} + +// Delete 删除webhook +func (s *WebhookService) Delete(userID, id uint) error { + if err := s.webhookRepo.Delete(id, userID); err != nil { + return apperr.ErrNotFound + } + return nil +} + +func normalizeEvents(events string) string { + seen := make(map[string]bool) + parts := make([]string, 0, 4) + for _, e := range strings.Split(events, ",") { + e = strings.TrimSpace(e) + if e == "" || seen[e] { + continue + } + seen[e] = true + parts = append(parts, e) + } + return strings.Join(parts, ",") +} + +func matchEvent(events, event string) bool { + for _, e := range strings.Split(events, ",") { + if strings.TrimSpace(e) == event { + return true + } + } + return false +} + +// Dispatch 异步派发事件回调 +func (s *WebhookService) Dispatch(userID, projectID uint, event string, payload map[string]interface{}) { + if v, err := s.settingRepo.GetValue("webhook_enabled"); err == nil && v != "true" { + return + } + hooks, err := s.webhookRepo.ListActive(userID, projectID) + if err != nil { + return + } + if len(hooks) == 0 { + return + } + + body, _ := json.Marshal(map[string]interface{}{ + "event": event, + "timestamp": time.Now().Unix(), + "data": payload, + }) + + for _, hook := range hooks { + if !matchEvent(hook.Events, event) { + continue + } + go s.deliver(hook, body) + } +} + +func (s *WebhookService) deliver(hook model.Webhook, body []byte) { + client := &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequest(http.MethodPost, hook.URL, bytes.NewReader(body)) + if err != nil { + utils.Warn("webhook", "构建回调请求失败 url=%s err=%v", hook.URL, err) + return + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Webhook-Event", "filestoragesystem") + req.Header.Set("X-Webhook-Signature", utils.HMACSHA256(hook.Secret, string(body))) + + resp, err := client.Do(req) + if err != nil { + utils.Warn("webhook", "回调发送失败 url=%s err=%v", hook.URL, err) + } else { + _ = resp.Body.Close() + if resp.StatusCode >= 300 { + utils.Warn("webhook", "回调响应异常 url=%s status=%d", hook.URL, resp.StatusCode) + } + } + _ = s.webhookRepo.Touch(hook.ID) +} diff --git a/go/internal/utils/logger.go b/go/internal/utils/logger.go new file mode 100644 index 0000000..708fbb2 --- /dev/null +++ b/go/internal/utils/logger.go @@ -0,0 +1,142 @@ +package utils + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "gorm.io/gorm" + + "filestoragesystem/internal/model" +) + +var logger *zap.Logger + +// Logger 获取全局日志对象 +func Logger() *zap.Logger { + if logger == nil { + logger, _ = zap.NewProduction() + } + return logger +} + +// InitLogger 初始化zap日志(控制台 + 可选文件) +func InitLogger(level, dir string) (*zap.Logger, error) { + lvl := zapcore.InfoLevel + _ = lvl.UnmarshalText([]byte(level)) + + encoderCfg := zap.NewProductionEncoderConfig() + encoderCfg.EncodeTime = zapcore.ISO8601TimeEncoder + encoderCfg.EncodeLevel = zapcore.CapitalLevelEncoder + + cores := make([]zapcore.Core, 0, 3) + + // 控制台 + consoleSyncer := zapcore.AddSync(os.Stdout) + consoleEncoder := zapcore.NewConsoleEncoder(encoderCfg) + cores = append(cores, zapcore.NewCore(consoleEncoder, consoleSyncer, lvl)) + + // 文件 + if dir != "" { + if err := os.MkdirAll(dir, 0o755); err == nil { + fileSyncer := zapcore.AddSync(&dateRollFile{dir: dir}) + fileEncoder := zapcore.NewJSONEncoder(encoderCfg) + cores = append(cores, zapcore.NewCore(fileEncoder, fileSyncer, lvl)) + } + } + + logger = zap.New(zapcore.NewTee(cores...), zap.AddCaller(), zap.AddCallerSkip(1)) + return logger, nil +} + +// dateRollFile 按天滚动的日志文件 +type dateRollFile struct { + dir string + f *os.File + day string +} + +func (d *dateRollFile) Write(p []byte) (int, error) { + today := time.Now().Format("2006-01-02") + if d.f == nil || d.day != today { + if d.f != nil { + _ = d.f.Close() + } + path := filepath.Join(d.dir, "app-"+today+".log") + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return 0, err + } + d.f, d.day = f, today + } + return d.f.Write(p) +} + +func (d *dateRollFile) Sync() error { + if d.f != nil { + return d.f.Sync() + } + return nil +} + +// ---- 系统日志异步落库 ---- + +var sysLogDB *gorm.DB +var sysLogCh chan model.SystemLog + +// InitSystemLog 初始化系统日志异步写入器 +func InitSystemLog(db *gorm.DB) { + sysLogDB = db + sysLogCh = make(chan model.SystemLog, 256) + go func() { + for entry := range sysLogCh { + if err := sysLogDB.Create(&entry).Error; err != nil { + Logger().Error("写入系统日志失败", zap.Error(err)) + } + } + }() +} + +// SystemLog 异步记录一条系统日志 +func SystemLog(level, module, message string) { + if sysLogCh == nil { + return + } + select { + case sysLogCh <- model.SystemLog{Level: level, Module: module, Message: message, CreatedAt: time.Now()}: + default: + // 队列满则丢弃,避免阻塞业务 + } +} + +// SystemLogDetail 异步记录带详情的系统日志 +func SystemLogDetail(level, module, message, requestID, ip, extra string) { + if sysLogCh == nil { + return + } + select { + case sysLogCh <- model.SystemLog{ + Level: level, Module: module, Message: message, + RequestID: requestID, IP: ip, ExtraData: extra, CreatedAt: time.Now(), + }: + default: + } +} + +// Info 记录info级别系统日志 +func Info(module, format string, args ...interface{}) { + SystemLog("info", module, fmt.Sprintf(format, args...)) +} + +// Warn 记录warn级别系统日志 +func Warn(module, format string, args ...interface{}) { + SystemLog("warn", module, fmt.Sprintf(format, args...)) +} + +// Error 记录error级别系统日志 +func Error(module, format string, args ...interface{}) { + SystemLog("error", module, fmt.Sprintf(format, args...)) +} diff --git a/go/internal/utils/request.go b/go/internal/utils/request.go new file mode 100644 index 0000000..820b55a --- /dev/null +++ b/go/internal/utils/request.go @@ -0,0 +1,133 @@ +package utils + +import ( + "strings" + + "github.com/gin-gonic/gin" +) + +// ClientIP 获取客户端真实IP +func ClientIP(c *gin.Context) string { + // 优先取反向代理头 + for _, header := range []string{"X-Real-IP", "X-Forwarded-For"} { + if v := c.GetHeader(header); v != "" { + ips := strings.Split(v, ",") + ip := strings.TrimSpace(ips[0]) + if ip != "" { + return ip + } + } + } + return c.ClientIP() +} + +// SanitizePath 清理虚拟路径,防止路径穿越 +func SanitizePath(p string) string { + p = strings.ReplaceAll(p, "\\", "/") + parts := make([]string, 0, 8) + for _, seg := range strings.Split(p, "/") { + seg = strings.TrimSpace(seg) + if seg == "" || seg == "." { + continue + } + if seg == ".." { + if len(parts) > 0 { + parts = parts[:len(parts)-1] + } + continue + } + parts = append(parts, seg) + } + return strings.Join(parts, "/") +} + +// IsValidPassword 检查密码强度:至少8位,包含字母和数字 +func IsValidPassword(pwd string) bool { + if len(pwd) < 8 { + return false + } + hasLetter, hasDigit := false, false + for _, ch := range pwd { + switch { + case ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z': + hasLetter = true + case ch >= '0' && ch <= '9': + hasDigit = true + } + } + return hasLetter && hasDigit +} + +// IsEmail 简单校验邮箱格式 +func IsEmail(s string) bool { + at := strings.IndexByte(s, '@') + if at <= 0 || at == len(s)-1 { + return false + } + domain := s[at+1:] + return strings.Contains(domain, ".") && strings.IndexByte(domain, '.') != 0 && strings.IndexByte(domain, '.') != len(domain)-1 +} + +// HumanSize 将字节数转为可读大小 +func HumanSize(n int64) string { + const unit = 1024 + if n < unit { + return itoa(n) + " B" + } + div, exp := int64(unit), 0 + for m := n / unit; m >= unit && exp < 4; m /= unit { + div *= unit + exp++ + } + val := float64(n) / float64(div) + s := trimFloat(val) + units := []string{"KB", "MB", "GB", "TB", "PB"} + return s + " " + units[exp] +} + +func itoa(n int64) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + var buf [20]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} + +func trimFloat(f float64) string { + // 保留两位小数并去掉末尾多余的0 + s := itoa(int64(f)) + frac := f - float64(int64(f)) + if frac < 0.005 { + return s + } + d1 := int64(frac * 10) + if d1 > 9 { + d1 = 9 + } + frac = frac*10 - float64(d1) + d2 := int64(frac * 10) + if d2 > 9 { + d2 = 9 + } + if d2 < 5 { + if d1 == 0 { + return s + } + return s + "." + itoa(d1) + } + return s + "." + itoa(d1) + itoa(d2) +} diff --git a/go/internal/utils/response.go b/go/internal/utils/response.go new file mode 100644 index 0000000..2a4ff3c --- /dev/null +++ b/go/internal/utils/response.go @@ -0,0 +1,96 @@ +package utils + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" +) + +// 统一响应码 +const ( + CodeSuccess = 0 + CodeError = 1 + CodeParamError = 400 + CodeUnauthorized = 401 + CodeForbidden = 403 + CodeNotFound = 404 + CodeServerErr = 500 +) + +// Response 统一响应结构 +type Response struct { + Code int `json:"code"` + Message string `json:"message"` + Data interface{} `json:"data,omitempty"` +} + +// PageData 分页响应结构 +type PageData struct { + List interface{} `json:"list"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +// OK 成功响应 +func OK(c *gin.Context, data interface{}) { + c.JSON(http.StatusOK, Response{Code: CodeSuccess, Message: "success", Data: data}) +} + +// OKMsg 成功响应(仅消息) +func OKMsg(c *gin.Context, msg string) { + c.JSON(http.StatusOK, Response{Code: CodeSuccess, Message: msg}) +} + +// OKPage 分页成功响应 +func OKPage(c *gin.Context, list interface{}, total int64, page, pageSize int) { + c.JSON(http.StatusOK, Response{ + Code: CodeSuccess, + Message: "success", + Data: PageData{List: list, Total: total, Page: page, PageSize: pageSize}, + }) +} + +// Fail 失败响应 +func Fail(c *gin.Context, httpCode, code int, msg string) { + c.JSON(httpCode, Response{Code: code, Message: msg}) +} + +// FailMsg 默认400失败响应 +func FailMsg(c *gin.Context, msg string) { + Fail(c, http.StatusBadRequest, CodeError, msg) +} + +// Unauthorized 401 +func Unauthorized(c *gin.Context, msg string) { + c.AbortWithStatusJSON(http.StatusUnauthorized, Response{Code: CodeUnauthorized, Message: msg}) +} + +// Forbidden 403 +func Forbidden(c *gin.Context, msg string) { + c.AbortWithStatusJSON(http.StatusForbidden, Response{Code: CodeForbidden, Message: msg}) +} + +// NotFound 404 +func NotFound(c *gin.Context, msg string) { + c.JSON(http.StatusNotFound, Response{Code: CodeNotFound, Message: msg}) +} + +// ServerError 500 +func ServerError(c *gin.Context, msg string) { + c.JSON(http.StatusInternalServerError, Response{Code: CodeServerErr, Message: msg}) +} + +// ParsePage 解析分页参数 +func ParsePage(c *gin.Context) (page, pageSize int) { + page = 1 + pageSize = 20 + if v, err := strconv.Atoi(c.Query("page")); err == nil && v > 0 { + page = v + } + if v, err := strconv.Atoi(c.Query("page_size")); err == nil && v > 0 && v <= 100 { + pageSize = v + } + return +} diff --git a/go/internal/utils/security.go b/go/internal/utils/security.go new file mode 100644 index 0000000..f9a426a --- /dev/null +++ b/go/internal/utils/security.go @@ -0,0 +1,100 @@ +package utils + +import ( + "crypto/hmac" + "crypto/md5" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" +) + +var jwtSecret = "please-change-this-secret-key" +var jwtExpire = 7 * time.Hour + +// InitJWT 初始化JWT参数 +func InitJWT(secret, expire string) { + if secret != "" { + jwtSecret = secret + } + if d, err := time.ParseDuration(expire); err == nil && d > 0 { + jwtExpire = d + } +} + +// JWTClaims 自定义Claims +type JWTClaims struct { + UserID uint `json:"user_id"` + Username string `json:"username"` + RoleCode string `json:"role_code"` + jwt.RegisteredClaims +} + +// GenerateToken 生成JWT Token +func GenerateToken(userID uint, username, roleCode string) (string, error) { + claims := JWTClaims{ + UserID: userID, + Username: username, + RoleCode: roleCode, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(jwtExpire)), + IssuedAt: jwt.NewNumericDate(time.Now()), + Issuer: "filestoragesystem", + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(jwtSecret)) +} + +// ParseToken 解析并校验JWT Token +func ParseToken(tokenStr string) (*JWTClaims, error) { + token, err := jwt.ParseWithClaims(tokenStr, &JWTClaims{}, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("非法的签名算法") + } + return []byte(jwtSecret), nil + }) + if err != nil { + return nil, err + } + if claims, ok := token.Claims.(*JWTClaims); ok && token.Valid { + return claims, nil + } + return nil, errors.New("无效的Token") +} + +// HashPassword bcrypt加密密码 +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(bytes), err +} + +// CheckPassword 校验密码 +func CheckPassword(hash, password string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil +} + +// MD5 计算字节串的MD5 +func MD5(data []byte) string { + h := md5.New() + h.Write(data) + return hex.EncodeToString(h.Sum(nil)) +} + +// RandomKey 生成指定长度的随机hex字符串 +func RandomKey(n int) string { + b := make([]byte, (n+1)/2) + _, _ = rand.Read(b) + return hex.EncodeToString(b)[:n] +} + +// HMACSHA256 计算HMAC-SHA256签名(hex编码) +func HMACSHA256(secret, message string) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(message)) + return hex.EncodeToString(mac.Sum(nil)) +} diff --git a/go/main.go b/go/main.go new file mode 100644 index 0000000..2b70a86 --- /dev/null +++ b/go/main.go @@ -0,0 +1,88 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/glebarez/sqlite" + "go.uber.org/zap" + "gorm.io/gorm" + + "filestoragesystem/internal/config" + "filestoragesystem/internal/model" + "filestoragesystem/internal/router" + "filestoragesystem/internal/service" + "filestoragesystem/internal/utils" +) + +func main() { + // 1. 加载配置 + cfg, err := config.Load("config.yaml") + if err != nil { + fmt.Println("加载配置失败:", err) + os.Exit(1) + } + if err := cfg.EnsureDirs(); err != nil { + fmt.Println("初始化目录失败:", err) + os.Exit(1) + } + + // 2. 初始化日志 + log, err := utils.InitLogger(cfg.Log.Level, cfg.Log.Dir) + if err != nil { + fmt.Println("初始化日志失败:", err) + os.Exit(1) + } + defer func() { _ = log.Sync() }() + zap.ReplaceGlobals(log) + + // 3. 连接数据库 + if cfg.Database.Driver != "sqlite" { + log.Warn("当前仅支持sqlite驱动,将按sqlite处理", zap.String("driver", cfg.Database.Driver)) + } + db, err := gorm.Open(sqlite.Open(cfg.Database.Source), &gorm.Config{}) + if err != nil { + log.Fatal("连接数据库失败", zap.Error(err)) + } + sqlDB, err := db.DB() + if err == nil { + sqlDB.SetMaxOpenConns(10) + } + + // 4. 迁移与种子数据 + if err := model.AutoMigrate(db); err != nil { + log.Fatal("数据库迁移失败", zap.Error(err)) + } + if err := model.SeedData(db); err != nil { + log.Fatal("初始化种子数据失败", zap.Error(err)) + } + + // 5. 系统日志异步落库 + utils.InitSystemLog(db) + + // 6. JWT参数 + utils.InitJWT(cfg.JWT.Secret, cfg.JWT.Expire) + + // 7. 服务与路由 + svc := service.NewServices(cfg, db) + + // 8. 引导管理员账户 + if err := svc.Auth.BootstrapAdmin(); err != nil { + log.Error("初始化管理员账户失败", zap.Error(err)) + } + + // 9. 启动HTTP服务 + r := router.New(cfg, db, svc) + addr := fmt.Sprintf(":%s", cfg.Server.Port) + absRoot, _ := filepath.Abs(cfg.Storage.Root) + log.Info("FileStorageSystem 启动", + zap.String("addr", addr), + zap.String("mode", cfg.Server.Mode), + zap.String("domain", cfg.Server.Domain), + zap.String("storage", absRoot), + ) + if err := r.Run(addr); err != nil { + log.Fatal("服务启动失败", zap.Error(err)) + } +} diff --git a/go/pkg/apperr/errors.go b/go/pkg/apperr/errors.go new file mode 100644 index 0000000..2e2a917 --- /dev/null +++ b/go/pkg/apperr/errors.go @@ -0,0 +1,27 @@ +package apperr + +import "errors" + +// 通用业务错误 +var ( + ErrUnauthorized = errors.New("未认证或认证已过期") + ErrForbidden = errors.New("没有操作权限") + ErrNotFound = errors.New("资源不存在") + ErrParamInvalid = errors.New("参数错误") + ErrUserExists = errors.New("用户名或邮箱已存在") + ErrUserDisabled = errors.New("账户已被禁用") + ErrPasswordWrong = errors.New("用户名或密码错误") + ErrAccountLocked = errors.New("失败次数过多,账户已临时锁定") + ErrRegisterClosed = errors.New("当前未开放注册") + ErrProjectExists = errors.New("同名项目已存在") + ErrQuotaExceeded = errors.New("存储空间不足,超出配额限制") + ErrFileTooLarge = errors.New("文件大小超出限制") + ErrFileTypeDenied = errors.New("文件类型不被允许") + ErrFileDeleted = errors.New("文件已在回收站中") + ErrLinkExpired = errors.New("链接已过期或失效") + ErrLinkPassword = errors.New("访问密码错误") + ErrSignInvalid = errors.New("API签名校验失败") + ErrSignExpired = errors.New("API签名时间戳已过期") + ErrSystemRole = errors.New("系统内置角色不允许此操作") + ErrMaintainMode = errors.New("站点维护中") +) diff --git a/go/storage/data.db b/go/storage/data.db new file mode 100644 index 0000000..613a38a Binary files /dev/null and b/go/storage/data.db differ diff --git a/go/storage/logs/app-2026-08-23.log b/go/storage/logs/app-2026-08-23.log new file mode 100644 index 0000000..c64aa48 --- /dev/null +++ b/go/storage/logs/app-2026-08-23.log @@ -0,0 +1,89 @@ +{"level":"INFO","ts":"2026-08-23T00:08:16.874+0800","caller":"runtime/proc.go:285","msg":"FileStorageSystem 启动","addr":":8080","mode":"debug","storage":"E:\\Demo\\Go\\filestoragesystem\\go\\storage\\files"} +{"level":"INFO","ts":"2026-08-23T00:08:48.475+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/status","ip":"127.0.0.1","latency":"503.8µs","request_id":"0a3a9bb5"} +{"level":"INFO","ts":"2026-08-23T00:08:48.715+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/","ip":"127.0.0.1","latency":"76.1845ms","request_id":"ac79f6b9"} +{"level":"INFO","ts":"2026-08-23T00:08:56.736+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"POST","path":"/api/v1/auth/login","ip":"127.0.0.1","latency":"249.5619ms","request_id":"aa4c5097"} +{"level":"INFO","ts":"2026-08-23T00:08:56.742+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/assets/Layout-C5a0sB21.js","ip":"127.0.0.1","latency":"0s","request_id":"9289e0e1"} +{"level":"INFO","ts":"2026-08-23T00:08:56.743+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/assets/Layout-Btlzh6Ru.css","ip":"127.0.0.1","latency":"0s","request_id":"04f4b068"} +{"level":"INFO","ts":"2026-08-23T00:08:56.743+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/assets/format-D7FCopCD.js","ip":"127.0.0.1","latency":"0s","request_id":"0ff50408"} +{"level":"INFO","ts":"2026-08-23T00:08:56.743+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/assets/Dashboard-BqiLLE7e.css","ip":"127.0.0.1","latency":"0s","request_id":"b386efdb"} +{"level":"INFO","ts":"2026-08-23T00:08:56.743+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/assets/Dashboard-lW8DgIjb.js","ip":"127.0.0.1","latency":"576.6µs","request_id":"544a298f"} +{"level":"INFO","ts":"2026-08-23T00:09:04.615+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/","ip":"127.0.0.1","latency":"0s","request_id":"6a9f5d3c"} +{"level":"INFO","ts":"2026-08-23T00:09:04.625+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/assets/rolldown-runtime-hePW80VL.js","ip":"127.0.0.1","latency":"0s","request_id":"cd197ae7"} +{"level":"INFO","ts":"2026-08-23T00:09:04.626+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/assets/user-r2t_U4TE.js","ip":"127.0.0.1","latency":"503.1µs","request_id":"160e146a"} +{"level":"INFO","ts":"2026-08-23T00:09:04.629+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/assets/index-BblkT4KS.css","ip":"127.0.0.1","latency":"2.9257ms","request_id":"d3a10523"} +{"level":"INFO","ts":"2026-08-23T00:09:04.629+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/assets/message-3Cyqmk3W.js","ip":"127.0.0.1","latency":"2.9257ms","request_id":"b5f830c2"} +{"level":"INFO","ts":"2026-08-23T00:09:04.634+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/assets/index-BpBRyeZq.js","ip":"127.0.0.1","latency":"8.4495ms","request_id":"5b738cd0"} +{"level":"INFO","ts":"2026-08-23T00:09:04.680+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/assets/Layout-eCLSt2ht.css","ip":"127.0.0.1","latency":"0s","request_id":"05cbfd3d"} +{"level":"INFO","ts":"2026-08-23T00:09:04.680+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/assets/Layout-DoUztMxi.js","ip":"127.0.0.1","latency":"0s","request_id":"c40b1000"} +{"level":"INFO","ts":"2026-08-23T00:09:04.681+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/assets/Dashboard-BDi2x5O-.js","ip":"127.0.0.1","latency":"0s","request_id":"284dd8dc"} +{"level":"INFO","ts":"2026-08-23T00:09:04.681+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":304,"method":"GET","path":"/assets/format-D7FCopCD.js","ip":"127.0.0.1","latency":"0s","request_id":"b3637089"} +{"level":"INFO","ts":"2026-08-23T00:09:04.681+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/assets/api-BkpNvY-0.js","ip":"127.0.0.1","latency":"0s","request_id":"f7a4a0bb"} +{"level":"INFO","ts":"2026-08-23T00:09:04.683+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":304,"method":"GET","path":"/assets/Dashboard-BqiLLE7e.css","ip":"127.0.0.1","latency":"0s","request_id":"1d9c49a3"} +{"level":"INFO","ts":"2026-08-23T00:09:04.753+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/stats/traffic","ip":"127.0.0.1","latency":"1.5555ms","request_id":"df63fce4","user_id":1} +{"level":"INFO","ts":"2026-08-23T00:09:04.754+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/stats/user","ip":"127.0.0.1","latency":"2.5812ms","request_id":"293f810c","user_id":1} +{"level":"INFO","ts":"2026-08-23T00:09:11.050+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/status","ip":"127.0.0.1","latency":"503.2µs","request_id":"39b12f84"} +{"level":"INFO","ts":"2026-08-23T00:09:16.606+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/assets/Settings-BIKcoGkF.css","ip":"127.0.0.1","latency":"0s","request_id":"94bba64e"} +{"level":"INFO","ts":"2026-08-23T00:09:16.606+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/assets/Settings-BRuY3Ydx.js","ip":"127.0.0.1","latency":"0s","request_id":"9369b4fc"} +{"level":"INFO","ts":"2026-08-23T00:09:16.627+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/admin/settings","ip":"127.0.0.1","latency":"1.0135ms","request_id":"8d967e74","user_id":1} +{"level":"INFO","ts":"2026-08-23T00:09:55.126+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/assets/Profile-Ck4Ovzla.css","ip":"127.0.0.1","latency":"0s","request_id":"66c6d0ce"} +{"level":"INFO","ts":"2026-08-23T00:09:55.126+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/assets/Profile-ZM-swGtq.js","ip":"127.0.0.1","latency":"0s","request_id":"3cd007c4"} +{"level":"INFO","ts":"2026-08-23T00:09:55.166+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/user/profile","ip":"127.0.0.1","latency":"1.0271ms","request_id":"2fc1945c","user_id":1} +{"level":"INFO","ts":"2026-08-23T00:09:55.176+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/webhooks","ip":"127.0.0.1","latency":"503.4µs","request_id":"3eb1a053","user_id":1} +{"level":"INFO","ts":"2026-08-23T00:09:55.177+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/projects","ip":"127.0.0.1","latency":"1.0234ms","request_id":"f867d686","user_id":1} +{"level":"INFO","ts":"2026-08-23T00:09:55.178+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/auth/apikeys","ip":"127.0.0.1","latency":"2.5291ms","request_id":"3b10db1e","user_id":1} +{"level":"WARN","ts":"2026-08-23T00:10:06.221+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":400,"method":"PUT","path":"/api/v1/user/password","ip":"127.0.0.1","latency":"94.568ms","request_id":"77fd93a9","user_id":1} +{"level":"INFO","ts":"2026-08-23T00:10:14.700+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"PUT","path":"/api/v1/user/password","ip":"127.0.0.1","latency":"260.8752ms","request_id":"fefb557f","user_id":1} +{"level":"INFO","ts":"2026-08-23T00:10:14.704+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/assets/Login-CSTMQy1g.js","ip":"127.0.0.1","latency":"0s","request_id":"53e83a7f"} +{"level":"INFO","ts":"2026-08-23T00:10:14.704+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/assets/Login-CYddjswm.css","ip":"127.0.0.1","latency":"0s","request_id":"6871994d"} +{"level":"INFO","ts":"2026-08-23T00:10:14.722+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/status","ip":"127.0.0.1","latency":"502.4µs","request_id":"6ee21e66"} +{"level":"INFO","ts":"2026-08-23T00:10:22.157+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"POST","path":"/api/v1/auth/login","ip":"127.0.0.1","latency":"230.735ms","request_id":"c7851d1a"} +{"level":"INFO","ts":"2026-08-23T00:10:22.181+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/stats/traffic","ip":"127.0.0.1","latency":"1.0133ms","request_id":"07d00621","user_id":1} +{"level":"INFO","ts":"2026-08-23T00:10:22.183+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/stats/user","ip":"127.0.0.1","latency":"2.7351ms","request_id":"7fe200c1","user_id":1} +{"level":"INFO","ts":"2026-08-23T00:10:31.298+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/admin/settings","ip":"127.0.0.1","latency":"503.7µs","request_id":"342cfc85","user_id":1} +{"level":"INFO","ts":"2026-08-23T00:10:33.455+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/assets/Logs-BkPCLqi2.js","ip":"127.0.0.1","latency":"502.8µs","request_id":"72dbd460"} +{"level":"INFO","ts":"2026-08-23T00:10:33.503+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/admin/logs/system","ip":"127.0.0.1","latency":"1.009ms","request_id":"db28fc57","user_id":1} +{"level":"INFO","ts":"2026-08-23T00:10:33.504+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/admin/logs/operation","ip":"127.0.0.1","latency":"2.0533ms","request_id":"923640d5","user_id":1} +{"level":"INFO","ts":"2026-08-23T00:10:35.774+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/assets/Backups-Xd3Jdlbe.css","ip":"127.0.0.1","latency":"0s","request_id":"491d7bb8"} +{"level":"INFO","ts":"2026-08-23T00:10:35.774+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/assets/Backups-CJ9KxCza.js","ip":"127.0.0.1","latency":"0s","request_id":"24715045"} +{"level":"INFO","ts":"2026-08-23T00:10:35.797+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/admin/backups","ip":"127.0.0.1","latency":"1.0793ms","request_id":"d5da8422","user_id":1} +{"level":"INFO","ts":"2026-08-23T00:10:38.590+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/assets/Files-Cdgr0mkT.css","ip":"127.0.0.1","latency":"0s","request_id":"385d9f99"} +{"level":"INFO","ts":"2026-08-23T00:10:38.590+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/assets/Files-PDZGx5FS.js","ip":"127.0.0.1","latency":"0s","request_id":"efa1f33b"} +{"level":"INFO","ts":"2026-08-23T00:10:38.614+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/projects","ip":"127.0.0.1","latency":"527.6µs","request_id":"3a81d484","user_id":1} +{"level":"INFO","ts":"2026-08-23T00:10:40.726+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/assets/Projects-pItre2FN.js","ip":"127.0.0.1","latency":"0s","request_id":"6f5c0d8f"} +{"level":"INFO","ts":"2026-08-23T00:10:40.738+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/projects","ip":"127.0.0.1","latency":"1.0336ms","request_id":"d1230e50","user_id":1} +{"level":"INFO","ts":"2026-08-23T00:10:41.777+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/stats/user","ip":"127.0.0.1","latency":"1.2307ms","request_id":"3d238d22","user_id":1} +{"level":"INFO","ts":"2026-08-23T00:10:41.778+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/stats/traffic","ip":"127.0.0.1","latency":"1.0325ms","request_id":"9d09b885","user_id":1} +{"level":"INFO","ts":"2026-08-23T00:10:44.417+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/user/profile","ip":"127.0.0.1","latency":"1.0173ms","request_id":"11f1e1a7","user_id":1} +{"level":"INFO","ts":"2026-08-23T00:10:44.424+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/auth/apikeys","ip":"127.0.0.1","latency":"576.6µs","request_id":"ab83f292","user_id":1} +{"level":"INFO","ts":"2026-08-23T00:10:44.425+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/webhooks","ip":"127.0.0.1","latency":"1.6235ms","request_id":"17b9dfde","user_id":1} +{"level":"INFO","ts":"2026-08-23T00:10:44.427+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/projects","ip":"127.0.0.1","latency":"3.19ms","request_id":"ea441b29","user_id":1} +{"level":"INFO","ts":"2026-08-23T00:10:45.886+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/assets/Users-B8KtO8D_.js","ip":"127.0.0.1","latency":"0s","request_id":"8b1a9e89"} +{"level":"INFO","ts":"2026-08-23T00:10:45.908+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/admin/users","ip":"127.0.0.1","latency":"503.3µs","request_id":"9ecfa71c","user_id":1} +{"level":"INFO","ts":"2026-08-23T00:10:45.909+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/admin/roles","ip":"127.0.0.1","latency":"2.0553ms","request_id":"c328cc81","user_id":1} +{"level":"INFO","ts":"2026-08-23T00:10:46.318+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/assets/Roles-Bf3nS16n.js","ip":"127.0.0.1","latency":"0s","request_id":"61d8699e"} +{"level":"INFO","ts":"2026-08-23T00:10:46.318+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/assets/Roles-CmYeutUg.css","ip":"127.0.0.1","latency":"0s","request_id":"c035ba4a"} +{"level":"INFO","ts":"2026-08-23T00:10:46.331+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/admin/roles","ip":"127.0.0.1","latency":"1.2849ms","request_id":"55d8686f","user_id":1} +{"level":"INFO","ts":"2026-08-23T00:10:46.332+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/admin/permissions","ip":"127.0.0.1","latency":"2.3374ms","request_id":"69b75799","user_id":1} +{"level":"INFO","ts":"2026-08-23T00:11:00.741+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"PUT","path":"/api/v1/admin/roles/2","ip":"127.0.0.1","latency":"277.8926ms","request_id":"1b2b28f0","user_id":1} +{"level":"INFO","ts":"2026-08-23T00:11:00.748+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/admin/roles","ip":"127.0.0.1","latency":"1.0149ms","request_id":"5beb030f","user_id":1} +{"level":"INFO","ts":"2026-08-23T00:11:15.091+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/admin/settings","ip":"127.0.0.1","latency":"602.5µs","request_id":"534f4f9b","user_id":1} +{"level":"INFO","ts":"2026-08-23T00:12:11.612+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/status","ip":"127.0.0.1","latency":"1.0572ms","request_id":"56aab71e"} +{"level":"INFO","ts":"2026-08-23T00:12:12.806+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/status","ip":"127.0.0.1","latency":"1.1809ms","request_id":"1a46d2e2"} +{"level":"INFO","ts":"2026-08-23T00:12:13.878+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/status","ip":"127.0.0.1","latency":"513.9µs","request_id":"d710d19b"} +{"level":"INFO","ts":"2026-08-23T00:12:16.962+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/status","ip":"127.0.0.1","latency":"661.1µs","request_id":"b79760fe"} +{"level":"INFO","ts":"2026-08-23T00:12:24.991+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/user/profile","ip":"127.0.0.1","latency":"1.0185ms","request_id":"fbcc9872","user_id":1} +{"level":"INFO","ts":"2026-08-23T00:12:24.998+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/auth/apikeys","ip":"127.0.0.1","latency":"1.0175ms","request_id":"bda576fc","user_id":1} +{"level":"INFO","ts":"2026-08-23T00:12:24.998+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/webhooks","ip":"127.0.0.1","latency":"1.5251ms","request_id":"d7c971b9","user_id":1} +{"level":"INFO","ts":"2026-08-23T00:12:25.001+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/projects","ip":"127.0.0.1","latency":"3.6629ms","request_id":"7827beec","user_id":1} +{"level":"INFO","ts":"2026-08-23T00:12:44.648+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"POST","path":"/api/v1/auth/apikeys","ip":"127.0.0.1","latency":"305.4539ms","request_id":"fb375879","user_id":1} +{"level":"INFO","ts":"2026-08-23T00:12:44.657+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/auth/apikeys","ip":"127.0.0.1","latency":"592.9µs","request_id":"ec73f901","user_id":1} +{"level":"INFO","ts":"2026-08-23T00:16:48.332+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/status","ip":"127.0.0.1","latency":"1.0228ms","request_id":"ead573e6"} +{"level":"INFO","ts":"2026-08-23T00:20:01.015+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/status","ip":"127.0.0.1","latency":"1.0216ms","request_id":"f37b65ae"} +{"level":"INFO","ts":"2026-08-23T00:22:48.129+0800","caller":"runtime/proc.go:285","msg":"FileStorageSystem 启动","addr":":8080","mode":"debug","storage":"E:\\Demo\\Go\\filestoragesystem\\go\\storage\\files"} +{"level":"INFO","ts":"2026-08-23T00:23:29.028+0800","caller":"runtime/proc.go:285","msg":"FileStorageSystem 启动","addr":":8100","mode":"debug","storage":"E:\\Demo\\Go\\filestoragesystem\\go\\storage\\files"} +{"level":"INFO","ts":"2026-08-23T00:30:51.991+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/status","ip":"::1","latency":"1.0179ms","request_id":"55f0ce61"} +{"level":"WARN","ts":"2026-08-23T00:30:53.369+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":401,"method":"POST","path":"/api/v1/auth/login","ip":"::1","latency":"119.3334ms","request_id":"2519f5dc"} +{"level":"WARN","ts":"2026-08-23T00:31:01.711+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":401,"method":"POST","path":"/api/v1/auth/login","ip":"::1","latency":"93.7763ms","request_id":"15cf2093"} +{"level":"INFO","ts":"2026-08-23T00:31:06.530+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"POST","path":"/api/v1/auth/login","ip":"::1","latency":"257.1467ms","request_id":"87b574dc"} +{"level":"INFO","ts":"2026-08-23T00:31:06.669+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/stats/traffic","ip":"::1","latency":"1.0274ms","request_id":"af7e5a49","user_id":1} +{"level":"INFO","ts":"2026-08-23T00:31:06.670+0800","caller":"gin@v1.10.0/context.go:185","msg":"access","status":200,"method":"GET","path":"/api/v1/stats/user","ip":"::1","latency":"2.627ms","request_id":"ed0cb391","user_id":1}