更新前后端代码

This commit is contained in:
2026-08-23 00:48:10 +08:00
parent 1b5468995f
commit 5c1313e4f6
74 changed files with 9995 additions and 306 deletions
+5
View File
@@ -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
+202
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
# 开发环境配置(本地调试)
VITE_API_BASE_URL=http://localhost:8100/api/v1
VITE_APP_TITLE=云泽文件存储云平台 (开发)
VITE_APP_ENV=development
+4
View File
@@ -0,0 +1,4 @@
# 生产环境配置
VITE_API_BASE_URL=https://file.yunzer.cn/api/v1
VITE_APP_TITLE=云泽文件存储云平台
VITE_APP_ENV=production
+2 -2
View File
@@ -1,10 +1,10 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>backend</title> <title>云泽文件存储云平台</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+549 -1
View File
@@ -8,7 +8,11 @@
"name": "backend", "name": "backend",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "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": { "devDependencies": {
"@types/node": "^24.13.3", "@types/node": "^24.13.3",
@@ -65,6 +69,49 @@
"node": ">=6.9.0" "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": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5", "version": "1.5.5",
"resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
@@ -81,6 +128,17 @@
"url": "https://github.com/sponsors/Boshen" "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": { "node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmmirror.com/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz", "resolved": "https://registry.npmmirror.com/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz",
@@ -343,6 +401,21 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/node": {
"version": "24.13.3", "version": "24.13.3",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-24.13.3.tgz", "resolved": "https://registry.npmmirror.com/@types/node/-/node-24.13.3.tgz",
@@ -353,6 +426,12 @@
"undici-types": "~7.18.0" "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": { "node_modules/@vitejs/plugin-vue": {
"version": "6.0.8", "version": "6.0.8",
"resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz", "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz",
@@ -449,6 +528,12 @@
"@vue/shared": "3.5.41" "@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": { "node_modules/@vue/language-core": {
"version": "3.3.11", "version": "3.3.11",
"resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-3.3.11.tgz", "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": { "node_modules/alien-signals": {
"version": "3.2.1", "version": "3.2.1",
"resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-3.2.1.tgz", "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-3.2.1.tgz",
@@ -539,12 +674,93 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT" "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": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -555,6 +771,46 @@
"node": ">=8" "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": { "node_modules/entities": {
"version": "7.0.1", "version": "7.0.1",
"resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz",
@@ -567,6 +823,51 @@
"url": "https://github.com/fb55/entities?sponsor=1" "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": { "node_modules/estree-walker": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", "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": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", "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": "^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": { "node_modules/lightningcss": {
"version": "1.33.0", "version": "1.33.0",
"resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.33.0.tgz", "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.33.0.tgz",
@@ -867,6 +1314,29 @@
"url": "https://opencollective.com/parcel" "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": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
@@ -876,6 +1346,48 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@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": { "node_modules/muggle-string": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", "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": "^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": { "node_modules/path-browserify": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
@@ -955,6 +1473,15 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/rolldown": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmmirror.com/rolldown/-/rolldown-1.2.5.tgz", "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": { "node_modules/vue-tsc": {
"version": "3.3.11", "version": "3.3.11",
"resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-3.3.11.tgz", "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-3.3.11.tgz",
+5 -1
View File
@@ -9,7 +9,11 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "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": { "devDependencies": {
"@types/node": "^24.13.3", "@types/node": "^24.13.3",
+1 -2
View File
@@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import HelloWorld from './components/HelloWorld.vue'
</script> </script>
<template> <template>
<HelloWorld /> <router-view />
</template> </template>
+330
View File
@@ -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<any, ApiResp>('/auth/register', data)
},
login(data: { account: string; password: string }) {
return http.post<any, ApiResp<{ token: string; user: User }>>('/auth/login', data)
},
createAPIKey(name: string) {
return http.post<any, ApiResp<APIKey & { secret_key: string }>>('/auth/apikeys', { name })
},
listAPIKeys() {
return http.get<any, ApiResp<APIKey[]>>('/auth/apikeys')
},
deleteAPIKey(id: number) {
return http.delete<any, ApiResp>(`/auth/apikeys/${id}`)
},
}
// ---------- 用户资料 ----------
export const userApi = {
getProfile() {
return http.get<any, ApiResp<User>>('/user/profile')
},
updateProfile(email: string) {
return http.put<any, ApiResp>('/user/profile', { email })
},
changePassword(oldPassword: string, newPassword: string) {
return http.put<any, ApiResp>('/user/password', {
old_password: oldPassword,
new_password: newPassword,
})
},
}
// ---------- 项目 ----------
export const projectApi = {
create(data: { name: string; description?: string; storage_limit?: number }) {
return http.post<any, ApiResp<Project>>('/projects', data)
},
list(params: { page?: number; page_size?: number; keyword?: string }) {
return http.get<any, ApiResp<PageData<Project>>>('/projects', { params })
},
get(id: number) {
return http.get<any, ApiResp<Project>>(`/projects/${id}`)
},
update(id: number, data: { name?: string; description?: string; storage_limit?: number }) {
return http.put<any, ApiResp<Project>>(`/projects/${id}`, data)
},
remove(id: number) {
return http.delete<any, ApiResp>(`/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<any, ApiResp<FileItem>>('/files/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 300000,
})
},
list(params: FileListParams) {
return http.get<any, ApiResp<PageData<FileItem>>>('/files', { params })
},
info(id: number) {
return http.get<any, ApiResp<FileItem>>(`/files/${id}`)
},
// 下载为blob(需认证头)
download(id: number) {
return http.get(`/files/${id}/download`, { responseType: 'blob', timeout: 300000 })
},
remove(id: number, permanent = false) {
return http.delete<any, ApiResp>(`/files/${id}`, { params: permanent ? { permanent: 'true' } : {} })
},
restore(id: number) {
return http.post<any, ApiResp>(`/files/${id}/restore`)
},
createTempLink(id: number, data: { password?: string; max_count?: number; expires_in?: number }) {
return http.post<any, ApiResp<TempLink>>(`/files/${id}/temp-link`, data)
},
}
// ---------- 统计 ----------
export const statsApi = {
user() {
return http.get<any, ApiResp>('/stats/user')
},
project(id: number) {
return http.get<any, ApiResp>(`/stats/project/${id}`)
},
traffic(days = 30) {
return http.get<any, ApiResp>('/stats/traffic', { params: { days } })
},
}
// ---------- Webhook ----------
export const webhookApi = {
create(data: { url: string; events: string; project_id?: number; secret?: string }) {
return http.post<any, ApiResp<Webhook>>('/webhooks', data)
},
list() {
return http.get<any, ApiResp<Webhook[]>>('/webhooks')
},
update(id: number, data: { url?: string; events?: string; status?: number }) {
return http.put<any, ApiResp<Webhook>>(`/webhooks/${id}`, data)
},
remove(id: number) {
return http.delete<any, ApiResp>(`/webhooks/${id}`)
},
}
// ---------- 管理后台 ----------
export const adminApi = {
// 用户管理
listUsers(params: { page?: number; page_size?: number; keyword?: string; role_id?: number; status?: string }) {
return http.get<any, ApiResp<PageData<User>>>('/admin/users', { params })
},
createUser(data: { username: string; email: string; password: string; role_id?: number; storage_limit?: number }) {
return http.post<any, ApiResp<User>>('/admin/users', data)
},
updateUser(id: number, data: { email?: string; role_id?: number; storage_limit?: number }) {
return http.put<any, ApiResp<User>>(`/admin/users/${id}`, data)
},
deleteUser(id: number) {
return http.delete<any, ApiResp>(`/admin/users/${id}`)
},
setUserStatus(id: number, status: number) {
return http.put<any, ApiResp>(`/admin/users/${id}/status`, { status })
},
assignRole(id: number, roleId: number) {
return http.put<any, ApiResp>(`/admin/users/${id}/role`, { role_id: roleId })
},
resetPassword(id: number, password: string) {
return http.post<any, ApiResp>(`/admin/users/${id}/reset-password`, { password })
},
// 角色权限
listRoles() {
return http.get<any, ApiResp<Role[]>>('/admin/roles')
},
createRole(data: { name: string; code: string; description?: string; permissions?: number[] }) {
return http.post<any, ApiResp<Role>>('/admin/roles', data)
},
updateRole(id: number, data: { name?: string; description?: string; permissions?: number[] }) {
return http.put<any, ApiResp<Role>>(`/admin/roles/${id}`, data)
},
deleteRole(id: number) {
return http.delete<any, ApiResp>(`/admin/roles/${id}`)
},
setRolePermissions(id: number, permissions: number[]) {
return http.put<any, ApiResp>(`/admin/roles/${id}/permissions`, { permissions })
},
listPermissions() {
return http.get<any, ApiResp<{ module: string; permissions: Permission[] }[]>>('/admin/permissions')
},
// 系统设置
getSettings() {
return http.get<any, ApiResp<Record<string, SystemSetting[]>>>('/admin/settings')
},
updateSettings(values: Record<string, string>) {
return http.put<any, ApiResp>('/admin/settings', values)
},
// 日志
listOperationLogs(params: { page?: number; page_size?: number; username?: string; action?: string; days?: number }) {
return http.get<any, ApiResp<PageData<OperationLog>>>('/admin/logs/operation', { params })
},
listSystemLogs(params: { page?: number; page_size?: number; level?: string; module?: string; days?: number }) {
return http.get<any, ApiResp<PageData<SystemLog>>>('/admin/logs/system', { params })
},
cleanupLogs(days: number) {
return http.delete<any, ApiResp>('/admin/logs/cleanup', { params: { days } })
},
// 备份
backup() {
return http.post<any, ApiResp<{ name: string }>>('/admin/backup')
},
listBackups() {
return http.get<any, ApiResp<{ name: string; size: number; time: string }[]>>('/admin/backups')
},
downloadBackup(name: string) {
return http.get('/admin/backups/download', { params: { name }, responseType: 'blob', timeout: 300000 })
},
}
// ---------- 站点状态(公开) ----------
export const siteApi = {
status() {
return http.get<any, ApiResp>('/status')
},
}
+62
View File
@@ -0,0 +1,62 @@
import axios, { AxiosError } from 'axios'
import { ElMessage } from 'element-plus'
import { clearSession } from '../store/user'
// 后端统一响应结构
export interface ApiResp<T = any> {
code: number
message: string
data?: T
}
export interface PageData<T = any> {
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<ApiResp>) => {
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
+15 -1
View File
@@ -1,5 +1,19 @@
import { createApp } from 'vue' 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 './style.css'
import App from './App.vue' 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')
+72
View File
@@ -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
+34
View File
@@ -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')
}
+23 -285
View File
@@ -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; 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;
box-sizing: border-box;
}
#center {
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;
}
}
#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; 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; box-sizing: border-box;
} }
}
html,
body,
#app {
height: 100%;
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif;
background-color: #f5f7fa;
color: #303133;
} }
#spacer { .page-card {
height: 88px; background: #fff;
border-top: 1px solid var(--border); border-radius: 8px;
@media (max-width: 1024px) { padding: 20px;
height: 48px;
}
} }
.ticks { .page-toolbar {
position: relative; display: flex;
width: 100%; justify-content: space-between;
align-items: center;
&::before, margin-bottom: 16px;
&::after { flex-wrap: wrap;
content: ''; gap: 10px;
position: absolute;
top: -4.5px;
border: 5px solid transparent;
} }
&::before { .mono {
left: 0; font-family: Consolas, Monaco, monospace;
border-left-color: var(--border); font-size: 12px;
}
&::after {
right: 0;
border-right-color: var(--border);
}
} }
+39
View File
@@ -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
}
+151
View File
@@ -0,0 +1,151 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { statsApi } from '../api'
import { formatSize } from '../utils/format'
const loading = ref(false)
const userStats = ref<any>(null)
const traffic = ref<any>(null)
async function load() {
loading.value = true
try {
const [us, tr] = await Promise.all([statsApi.user(), statsApi.traffic(30)])
userStats.value = us.data
traffic.value = tr.data
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<template>
<div v-loading="loading">
<el-row :gutter="16">
<el-col :span="6">
<div class="page-card stat-card">
<div class="stat-icon" style="background: #ecf5ff; color: #409eff"><el-icon :size="26"><Box /></el-icon></div>
<div>
<div class="stat-value">{{ formatSize(userStats?.storage_used || 0) }}</div>
<div class="stat-label">已用存储 / {{ formatSize(userStats?.storage_limit || 0) }}</div>
</div>
</div>
</el-col>
<el-col :span="6">
<div class="page-card stat-card">
<div class="stat-icon" style="background: #f0f9eb; color: #67c23a"><el-icon :size="26"><Files /></el-icon></div>
<div>
<div class="stat-value">{{ userStats?.file_count || 0 }}</div>
<div class="stat-label">文件总数({{ formatSize(userStats?.total_size || 0) }})</div>
</div>
</div>
</el-col>
<el-col :span="6">
<div class="page-card stat-card">
<div class="stat-icon" style="background: #fdf6ec; color: #e6a23c"><el-icon :size="26"><FolderOpened /></el-icon></div>
<div>
<div class="stat-value">{{ userStats?.project_count || 0 }}</div>
<div class="stat-label">项目数量</div>
</div>
</div>
</el-col>
<el-col :span="6">
<div class="page-card stat-card">
<div class="stat-icon" style="background: #fef0f0; color: #f56c6c"><el-icon :size="26"><TrendCharts /></el-icon></div>
<div>
<div class="stat-value">{{ formatSize(userStats?.traffic?.upload_30d || 0) }}</div>
<div class="stat-label">30天上传 / {{ formatSize(userStats?.traffic?.download_30d || 0) }} 下载</div>
</div>
</div>
</el-col>
</el-row>
<el-row :gutter="16" style="margin-top: 16px">
<el-col :span="12">
<div class="page-card">
<h3 class="card-title">项目存储分布</h3>
<el-table :data="userStats?.projects || []" empty-text="暂无项目" size="small">
<el-table-column prop="name" label="项目" min-width="120" />
<el-table-column label="文件数" width="90">
<template #default="{ row }">{{ row.file_count }}</template>
</el-table-column>
<el-table-column label="已用 / 配额" min-width="180">
<template #default="{ row }">
<el-progress
:percentage="row.storage_limit ? Math.min(100, Math.round(row.storage_used / row.storage_limit * 100)) : 0"
:stroke-width="10"
>
<span class="mono">{{ formatSize(row.storage_used) }} / {{ formatSize(row.storage_limit) }}</span>
</el-progress>
</template>
</el-table-column>
</el-table>
</div>
</el-col>
<el-col :span="12">
<div class="page-card">
<h3 class="card-title">近30天流量明细</h3>
<el-table :data="traffic?.daily || []" empty-text="暂无流量记录" size="small" max-height="320">
<el-table-column label="日期" min-width="110">
<template #default="{ row }">{{ row.date }}</template>
</el-table-column>
<el-table-column label="类型" width="80">
<template #default="{ row }">
<el-tag :type="row.type === 1 ? 'success' : 'primary'" size="small">
{{ row.type === 1 ? '上传' : '下载' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="次数" width="80">
<template #default="{ row }">{{ row.count }}</template>
</el-table-column>
<el-table-column label="流量" min-width="100">
<template #default="{ row }">{{ formatSize(Number(row.size)) }}</template>
</el-table-column>
</el-table>
<div class="traffic-summary">
汇总:上传 {{ formatSize(traffic?.upload || 0) }},下载 {{ formatSize(traffic?.download || 0) }}
</div>
</div>
</el-col>
</el-row>
</div>
</template>
<style scoped>
.stat-card {
display: flex;
align-items: center;
gap: 14px;
}
.stat-icon {
width: 52px;
height: 52px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-value {
font-size: 20px;
font-weight: 600;
}
.stat-label {
font-size: 12px;
color: #909399;
margin-top: 2px;
}
.card-title {
font-size: 15px;
margin-bottom: 12px;
}
.traffic-summary {
margin-top: 10px;
font-size: 13px;
color: #606266;
}
</style>
+439
View File
@@ -0,0 +1,439 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { fileApi, projectApi, type FileItem, type Project, type TempLink } from '../api'
import { formatSize, formatTime, saveBlob, baseName } from '../utils/format'
const route = useRoute()
// ---------- 状态 ----------
const projects = ref<Project[]>([])
const projectId = ref<number>(0)
const tab = ref<'files' | 'trash'>('files')
const currentPath = ref('')
const loading = ref(false)
const allFiles = ref<FileItem[]>([])
const page = ref(1)
const pageSize = ref(20)
// 面包屑段
const pathSegments = computed(() => (currentPath.value ? currentPath.value.split('/') : []))
// 按当前目录分组:子目录 + 直属文件
interface DirEntry { type: 'dir'; name: string }
interface FileEntry { type: 'file'; file: FileItem }
type Entry = DirEntry | FileEntry
const entries = computed<Entry[]>(() => {
const dirs = new Map<string, DirEntry>()
const files: FileEntry[] = []
for (const f of allFiles.value) {
let rel = f.filename
if (currentPath.value) {
if (!rel.startsWith(currentPath.value + '/')) continue
rel = rel.slice(currentPath.value.length + 1)
}
const idx = rel.indexOf('/')
if (idx > 0) {
const dirName = rel.slice(0, idx)
if (!dirs.has(dirName)) dirs.set(dirName, { type: 'dir', name: dirName })
} else {
files.push({ type: 'file', file: f })
}
}
return [...dirs.values(), ...files]
})
const pagedEntries = computed(() =>
entries.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value),
)
// ---------- 加载 ----------
async function loadProjects() {
const r = await projectApi.list({ page: 1, page_size: 100 })
projects.value = r.data!.list || []
const qid = Number(route.query.project_id)
if (qid && projects.value.some((p) => p.id === qid)) {
projectId.value = qid
} else if (!projectId.value && projects.value.length) {
projectId.value = projects.value[0].id
}
}
async function loadFiles() {
if (!projectId.value) {
allFiles.value = []
return
}
loading.value = true
try {
const r = await fileApi.list({
project_id: projectId.value,
prefix: currentPath.value || undefined,
trash: tab.value === 'trash',
page: 1,
page_size: 200,
})
allFiles.value = r.data!.list || []
} finally {
loading.value = false
}
}
watch(projectId, () => {
currentPath.value = ''
page.value = 1
loadFiles()
})
watch(tab, () => {
currentPath.value = ''
page.value = 1
loadFiles()
})
function enterDir(name: string) {
currentPath.value = currentPath.value ? `${currentPath.value}/${name}` : name
page.value = 1
}
function goRoot() {
currentPath.value = ''
page.value = 1
}
function goSegment(index: number) {
currentPath.value = pathSegments.value.slice(0, index + 1).join('/')
page.value = 1
}
// ---------- 上传 ----------
const uploadVisible = ref(false)
const uploadVisibility = ref(1)
const uploadList = ref<File[]>([])
const uploading = ref(false)
function openUpload() {
if (!projectId.value) {
ElMessage.warning('请先创建项目')
return
}
uploadList.value = []
uploadVisibility.value = 1
uploadVisible.value = true
}
function onPickFiles(e: Event) {
const input = e.target as HTMLInputElement
uploadList.value = [...(input.files || [])]
input.value = ''
}
async function doUpload() {
if (!uploadList.value.length) {
ElMessage.warning('请选择要上传的文件')
return
}
uploading.value = true
let ok = 0
for (const f of uploadList.value) {
const fd = new FormData()
fd.append('file', f)
fd.append('project_id', String(projectId.value))
if (currentPath.value) fd.append('path', currentPath.value)
fd.append('visibility', String(uploadVisibility.value))
try {
await fileApi.upload(fd)
ok++
} catch {
/* 拦截器已提示 */
}
}
uploading.value = false
uploadVisible.value = false
if (ok) {
ElMessage.success(`成功上传 ${ok} 个文件`)
loadFiles()
}
}
// ---------- 文件操作 ----------
async function download(f: FileItem) {
const resp: any = await fileApi.download(f.id)
saveBlob(resp.data, baseName(f.filename))
}
async function softDelete(f: FileItem) {
await fileApi.remove(f.id)
ElMessage.success('已移入回收站')
loadFiles()
}
async function restore(f: FileItem) {
await fileApi.restore(f.id)
ElMessage.success('文件已恢复')
loadFiles()
}
async function hardDelete(f: FileItem) {
try {
await ElMessageBox.confirm(`彻底删除「${baseName(f.filename)}」?此操作不可恢复。`, '彻底删除', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
} catch {
return
}
await fileApi.remove(f.id, true)
ElMessage.success('已彻底删除')
loadFiles()
}
// ---------- 临时链接 ----------
const linkVisible = ref(false)
const linkForm = ref({ password: '', max_count: 10, expires_in: 3600 })
const linkTarget = ref<FileItem | null>(null)
const linkResult = ref<TempLink | null>(null)
const linkSubmitting = ref(false)
function openLinkDialog(f: FileItem) {
linkTarget.value = f
linkResult.value = null
linkForm.value = { password: '', max_count: 10, expires_in: 3600 }
linkVisible.value = true
}
async function createLink() {
if (!linkTarget.value) return
linkSubmitting.value = true
try {
const r = await fileApi.createTempLink(linkTarget.value.id, {
password: linkForm.value.password || undefined,
max_count: linkForm.value.max_count || 0,
expires_in: linkForm.value.expires_in,
})
linkResult.value = r.data!
} finally {
linkSubmitting.value = false
}
}
const fullLinkUrl = computed(() => (linkResult.value ? location.origin + linkResult.value.url : ''))
function copyLink() {
navigator.clipboard.writeText(fullLinkUrl.value).then(
() => ElMessage.success('链接已复制'),
() => ElMessage.warning('复制失败,请手动复制'),
)
}
// ---------- 详情 ----------
const detailVisible = ref(false)
const detail = ref<FileItem | null>(null)
function openDetail(f: FileItem) {
detail.value = f
detailVisible.value = true
}
onMounted(async () => {
await loadProjects()
loadFiles()
})
</script>
<template>
<div class="page-card">
<div class="page-toolbar">
<div class="toolbar-left">
<el-select v-model="projectId" placeholder="选择项目" style="width: 220px">
<el-option v-for="p in projects" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
<el-radio-group v-model="tab" style="margin-left: 12px">
<el-radio-button value="files">文件</el-radio-button>
<el-radio-button value="trash">回收站</el-radio-button>
</el-radio-group>
</div>
<el-button v-if="tab === 'files'" type="primary" @click="openUpload">
<el-icon><Upload /></el-icon>上传文件
</el-button>
</div>
<!-- 面包屑 -->
<el-breadcrumb v-if="tab === 'files'" separator="/" style="margin-bottom: 12px">
<el-breadcrumb-item>
<el-link :underline="false" type="primary" @click="goRoot">根目录</el-link>
</el-breadcrumb-item>
<el-breadcrumb-item v-for="(seg, i) in pathSegments" :key="i">
<el-link :underline="false" type="primary" @click="goSegment(i)">{{ seg }}</el-link>
</el-breadcrumb-item>
</el-breadcrumb>
<el-table :data="pagedEntries" v-loading="loading" empty-text="暂无内容" row-key="name">
<el-table-column label="名称" min-width="260">
<template #default="{ row }">
<span v-if="row.type === 'dir'" class="dir-entry" @click="enterDir(row.name)">
<el-icon color="#e6a23c"><Folder /></el-icon>{{ row.name }}
</span>
<span v-else class="file-entry" @click="openDetail(row.file)">
<el-icon color="#409eff"><Document /></el-icon>{{ baseName(row.file.filename) }}
</span>
</template>
</el-table-column>
<el-table-column label="大小" width="110">
<template #default="{ row }">{{ row.type === 'file' ? formatSize(row.file.size) : '-' }}</template>
</el-table-column>
<el-table-column label="可见性" width="90">
<template #default="{ row }">
<template v-if="row.type === 'file'">
<el-tag size="small" :type="row.file.visibility === 2 ? 'success' : 'info'">
{{ row.file.visibility === 2 ? '公开' : '私有' }}
</el-tag>
</template>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="下载次数" width="90">
<template #default="{ row }">{{ row.type === 'file' ? row.file.download_count : '-' }}</template>
</el-table-column>
<el-table-column label="上传时间" width="160">
<template #default="{ row }">{{ row.type === 'file' ? formatTime(row.file.created_at) : '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }">
<template v-if="row.type === 'file'">
<template v-if="tab === 'files'">
<el-button link type="primary" @click="download(row.file)">下载</el-button>
<el-button link type="primary" @click="openLinkDialog(row.file)">临时链接</el-button>
<el-button link type="danger" @click="softDelete(row.file)">删除</el-button>
</template>
<template v-else>
<el-button link type="primary" @click="restore(row.file)">恢复</el-button>
<el-button link type="danger" @click="hardDelete(row.file)">彻底删除</el-button>
</template>
</template>
</template>
</el-table-column>
</el-table>
<el-pagination
v-if="entries.length > pageSize"
style="margin-top: 16px; justify-content: flex-end"
layout="total, prev, pager, next"
:total="entries.length"
:page-size="pageSize"
v-model:current-page="page"
/>
<!-- 上传对话框 -->
<el-dialog v-model="uploadVisible" title="上传文件" width="500px">
<el-form label-width="90px">
<el-form-item label="目标目录">
<el-input :model-value="currentPath || '根目录'" disabled />
</el-form-item>
<el-form-item label="选择文件">
<input type="file" multiple @change="onPickFiles" />
</el-form-item>
<el-form-item v-if="uploadList.length" label="已选择">
<div class="picked-list">
<div v-for="(f, i) in uploadList" :key="i" class="mono">
{{ f.name }}({{ formatSize(f.size) }})
</div>
</div>
</el-form-item>
<el-form-item label="可见性">
<el-radio-group v-model="uploadVisibility">
<el-radio :value="1">私有</el-radio>
<el-radio :value="2">公开</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="uploadVisible = false">取消</el-button>
<el-button type="primary" :loading="uploading" @click="doUpload">开始上传</el-button>
</template>
</el-dialog>
<!-- 临时链接对话框 -->
<el-dialog v-model="linkVisible" title="生成临时访问链接" width="520px">
<template v-if="!linkResult">
<el-form label-width="100px">
<el-form-item label="访问密码">
<el-input v-model="linkForm.password" placeholder="留空则无需密码" maxlength="50" />
</el-form-item>
<el-form-item label="最大访问次数">
<el-input-number v-model="linkForm.max_count" :min="0" :max="10000" />
<span style="margin-left: 8px; color: #909399">0 为不限次数</span>
</el-form-item>
<el-form-item label="有效期(秒)">
<el-input-number v-model="linkForm.expires_in" :min="60" :max="604800" :step="3600" />
</el-form-item>
</el-form>
</template>
<template v-else>
<el-alert type="success" :closable="false" title="链接已生成" style="margin-bottom: 14px" />
<el-form label-width="100px">
<el-form-item label="访问链接">
<el-input :model-value="fullLinkUrl" readonly class="mono" />
</el-form-item>
<el-form-item label="过期时间">
<span>{{ formatTime(linkResult.expires_at) }}</span>
</el-form-item>
<el-form-item v-if="linkResult.has_password" label="访问密码">
<span class="mono">{{ linkForm.password }}</span>
</el-form-item>
</el-form>
</template>
<template #footer>
<template v-if="!linkResult">
<el-button @click="linkVisible = false">取消</el-button>
<el-button type="primary" :loading="linkSubmitting" @click="createLink">生成</el-button>
</template>
<template v-else>
<el-button @click="copyLink">复制链接</el-button>
<el-button type="primary" @click="linkVisible = false">关闭</el-button>
</template>
</template>
</el-dialog>
<!-- 文件详情抽屉 -->
<el-drawer v-model="detailVisible" title="文件详情" size="420px">
<template v-if="detail">
<el-descriptions :column="1" border>
<el-descriptions-item label="文件名">{{ detail.filename }}</el-descriptions-item>
<el-descriptions-item label="大小">{{ formatSize(detail.size) }}</el-descriptions-item>
<el-descriptions-item label="MIME类型">{{ detail.mime_type || '-' }}</el-descriptions-item>
<el-descriptions-item label="MD5">
<span class="mono">{{ detail.md5 }}</span>
</el-descriptions-item>
<el-descriptions-item label="可见性">{{ detail.visibility === 2 ? '公开' : '私有' }}</el-descriptions-item>
<el-descriptions-item label="下载次数">{{ detail.download_count }}</el-descriptions-item>
<el-descriptions-item label="上传时间">{{ formatTime(detail.created_at) }}</el-descriptions-item>
</el-descriptions>
</template>
</el-drawer>
</div>
</template>
<style scoped>
.toolbar-left {
display: flex;
align-items: center;
}
.dir-entry,
.file-entry {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.dir-entry:hover {
color: #409eff;
}
.picked-list {
max-height: 140px;
overflow-y: auto;
line-height: 1.8;
}
</style>
+196
View File
@@ -0,0 +1,196 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { session, isAdmin, clearSession } from '../store/user'
import { formatSize } from '../utils/format'
const route = useRoute()
const router = useRouter()
const activeMenu = computed(() => route.path)
const usagePercent = computed(() => {
const u = session.user
if (!u || !u.storage_limit) return 0
return Math.min(100, Math.round((u.storage_used / u.storage_limit) * 100))
})
function logout() {
clearSession()
router.push('/login')
}
</script>
<template>
<el-container class="layout">
<el-aside width="220px" class="aside">
<div class="logo">
<el-icon :size="24"><Box /></el-icon>
<span>云泽文件存储云平台</span>
</div>
<el-menu :default-active="activeMenu" router class="menu" background-color="#1f2d3d" text-color="#bfcbd9" active-text-color="#409eff">
<el-menu-item index="/dashboard">
<el-icon><DataAnalysis /></el-icon>
<span>概览</span>
</el-menu-item>
<el-menu-item index="/projects">
<el-icon><FolderOpened /></el-icon>
<span>项目管理</span>
</el-menu-item>
<el-menu-item index="/files">
<el-icon><Files /></el-icon>
<span>文件管理</span>
</el-menu-item>
<el-menu-item index="/profile">
<el-icon><User /></el-icon>
<span>个人中心</span>
</el-menu-item>
<template v-if="isAdmin">
<el-divider style="border-color: #3a4a5d; margin: 10px 16px" />
<div class="menu-group-title">系统管理</div>
<el-menu-item index="/admin/users">
<el-icon><Avatar /></el-icon>
<span>用户管理</span>
</el-menu-item>
<el-menu-item index="/admin/roles">
<el-icon><Lock /></el-icon>
<span>角色权限</span>
</el-menu-item>
<el-menu-item index="/admin/settings">
<el-icon><Setting /></el-icon>
<span>系统设置</span>
</el-menu-item>
<el-menu-item index="/admin/logs">
<el-icon><Document /></el-icon>
<span>日志管理</span>
</el-menu-item>
<el-menu-item index="/admin/backups">
<el-icon><Coin /></el-icon>
<span>数据备份</span>
</el-menu-item>
<el-menu-item index="/admin/docs">
<el-icon><Notebook /></el-icon>
<span>使用说明和开发说明</span>
</el-menu-item>
</template>
</el-menu>
</el-aside>
<el-container>
<el-header class="header">
<div class="header-left">
<span class="env-tag">{{ session.user?.role?.name || '用户' }}</span>
</div>
<el-dropdown @command="(cmd: string) => cmd === 'logout' ? logout() : router.push('/profile')">
<span class="user-info">
<el-avatar :size="30">{{ session.user?.username?.charAt(0)?.toUpperCase() }}</el-avatar>
<span class="username">{{ session.user?.username }}</span>
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">
<el-icon><User /></el-icon>个人中心
</el-dropdown-item>
<el-dropdown-item command="logout" divided>
<el-icon><SwitchButton /></el-icon>退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-header>
<el-main class="main">
<div class="usage-bar" v-if="session.user">
<span class="usage-label">存储空间</span>
<el-progress
:percentage="usagePercent"
:stroke-width="8"
style="flex: 1; margin: 0 12px"
:color="usagePercent > 90 ? '#f56c6c' : '#409eff'"
/>
<span class="usage-text">
{{ formatSize(session.user.storage_used) }} / {{ formatSize(session.user.storage_limit) }}
</span>
</div>
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<style scoped>
.layout {
height: 100%;
}
.aside {
background-color: #1f2d3d;
display: flex;
flex-direction: column;
}
.logo {
height: 60px;
display: flex;
align-items: center;
gap: 10px;
color: #fff;
font-size: 15px;
font-weight: 600;
padding: 0 16px;
white-space: nowrap;
}
.menu {
border-right: none;
flex: 1;
}
.menu-group-title {
color: #6b7d92;
font-size: 12px;
padding: 4px 24px 6px;
}
.header {
background: #fff;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
}
.env-tag {
background: #ecf5ff;
color: #409eff;
padding: 4px 12px;
border-radius: 12px;
font-size: 13px;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
color: #303133;
}
.username {
font-size: 14px;
}
.main {
padding: 16px;
overflow-y: auto;
}
.usage-bar {
display: flex;
align-items: center;
background: #fff;
border-radius: 8px;
padding: 10px 16px;
margin-bottom: 16px;
font-size: 13px;
}
.usage-label {
color: #606266;
}
.usage-text {
color: #909399;
white-space: nowrap;
}
</style>
+170
View File
@@ -0,0 +1,170 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { authApi, siteApi } from '../api'
import { setSession } from '../store/user'
const router = useRouter()
const route = useRoute()
const mode = ref<'login' | 'register'>('login')
const loading = ref(false)
const siteName = ref('云泽文件存储云平台')
const registerEnabled = ref(true)
siteApi.status().then((r) => {
if (r.data?.site_name) siteName.value = r.data.site_name
registerEnabled.value = r.data?.register_enabled !== false
}).catch(() => {})
const loginForm = reactive({ account: '', password: '' })
const regForm = reactive({ username: '', email: '', password: '', confirm: '' })
async function doLogin() {
if (!loginForm.account || !loginForm.password) {
ElMessage.warning('请输入账号和密码')
return
}
loading.value = true
try {
const r = await authApi.login({ account: loginForm.account, password: loginForm.password })
setSession(r.data!.token, r.data!.user)
ElMessage.success(`欢迎,${r.data!.user.username}`)
router.push((route.query.redirect as string) || '/dashboard')
} catch {
/* 拦截器已提示 */
} finally {
loading.value = false
}
}
async function doRegister() {
if (!regForm.username || !regForm.email || !regForm.password) {
ElMessage.warning('请填写完整注册信息')
return
}
if (regForm.password !== regForm.confirm) {
ElMessage.warning('两次输入的密码不一致')
return
}
loading.value = true
try {
await authApi.register({
username: regForm.username,
email: regForm.email,
password: regForm.password,
})
ElMessage.success('注册成功,请登录')
mode.value = 'login'
loginForm.account = regForm.username
} catch {
/* 拦截器已提示 */
} finally {
loading.value = false
}
}
</script>
<template>
<div class="login-page">
<div class="login-card">
<div class="login-header">
<el-icon :size="40" color="#409eff"><Box /></el-icon>
<h1>{{ siteName }}</h1>
<p>安全、高效的私有云文件存储服务</p>
</div>
<el-tabs v-model="mode" class="login-tabs">
<el-tab-pane label="登录" name="login">
<el-form @submit.prevent="doLogin">
<el-form-item>
<el-input
v-model="loginForm.account"
placeholder="用户名或邮箱"
size="large"
:prefix-icon="'User'"
/>
</el-form-item>
<el-form-item>
<el-input
v-model="loginForm.password"
type="password"
placeholder="密码"
size="large"
show-password
:prefix-icon="'Lock'"
@keyup.enter="doLogin"
/>
</el-form-item>
<el-button type="primary" size="large" :loading="loading" style="width: 100%" @click="doLogin">
登 录
</el-button>
</el-form>
</el-tab-pane>
<el-tab-pane v-if="registerEnabled" label="注册" name="register">
<el-form @submit.prevent="doRegister">
<el-form-item>
<el-input v-model="regForm.username" placeholder="用户名(3-50位字母数字)" size="large" />
</el-form-item>
<el-form-item>
<el-input v-model="regForm.email" placeholder="邮箱" size="large" />
</el-form-item>
<el-form-item>
<el-input
v-model="regForm.password"
type="password"
placeholder="密码(至少8位,含字母和数字)"
size="large"
show-password
/>
</el-form-item>
<el-form-item>
<el-input
v-model="regForm.confirm"
type="password"
placeholder="确认密码"
size="large"
show-password
@keyup.enter="doRegister"
/>
</el-form-item>
<el-button type="primary" size="large" :loading="loading" style="width: 100%" @click="doRegister">
注 册
</el-button>
</el-form>
</el-tab-pane>
</el-tabs>
</div>
</div>
</template>
<style scoped>
.login-page {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1f3b73 0%, #2d5aa0 50%, #409eff 100%);
}
.login-card {
width: 420px;
background: #fff;
border-radius: 12px;
padding: 40px 36px 28px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
}
.login-header {
text-align: center;
margin-bottom: 18px;
}
.login-header h1 {
margin: 10px 0 4px;
font-size: 22px;
}
.login-header p {
color: #909399;
font-size: 13px;
}
</style>
+396
View File
@@ -0,0 +1,396 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { userApi, authApi, webhookApi, type User, type APIKey, type Webhook, type Project } from '../api'
import { projectApi } from '../api'
import { formatSize, formatTime } from '../utils/format'
import { session, updateUser, clearSession } from '../store/user'
import { useRouter } from 'vue-router'
const router = useRouter()
const tab = ref('info')
// ---------- 基本资料 ----------
const profile = ref<User | null>(null)
const email = ref('')
const savingProfile = ref(false)
async function loadProfile() {
const r = await userApi.getProfile()
profile.value = r.data!
email.value = r.data!.email
}
async function saveProfile() {
if (!email.value.trim()) {
ElMessage.warning('请输入邮箱')
return
}
savingProfile.value = true
try {
await userApi.updateProfile(email.value.trim())
ElMessage.success('资料已更新')
await loadProfile()
if (session.user && profile.value) {
updateUser({ ...session.user, email: profile.value.email })
}
} catch {
/* 拦截器已提示 */
} finally {
savingProfile.value = false
}
}
// ---------- 修改密码 ----------
const pwdForm = reactive({ old: '', new: '', confirm: '' })
const savingPwd = ref(false)
async function changePassword() {
if (!pwdForm.old || !pwdForm.new) {
ElMessage.warning('请填写完整')
return
}
if (pwdForm.new !== pwdForm.confirm) {
ElMessage.warning('两次输入的新密码不一致')
return
}
savingPwd.value = true
try {
await userApi.changePassword(pwdForm.old, pwdForm.new)
ElMessage.success('密码已修改,请重新登录')
pwdForm.old = pwdForm.new = pwdForm.confirm = ''
clearSession()
router.push('/login')
} catch {
/* 拦截器已提示 */
} finally {
savingPwd.value = false
}
}
// ---------- API Key ----------
const keys = ref<APIKey[]>([])
const loadingKeys = ref(false)
const newKeyDialog = ref(false)
const newKeyName = ref('')
const createdKey = ref<{ access_key: string; secret_key: string } | null>(null)
const creatingKey = ref(false)
async function loadKeys() {
loadingKeys.value = true
try {
const r = await authApi.listAPIKeys()
keys.value = r.data || []
} finally {
loadingKeys.value = false
}
}
async function createKey() {
if (!newKeyName.value.trim()) {
ElMessage.warning('请输入密钥名称')
return
}
creatingKey.value = true
try {
const r = await authApi.createAPIKey(newKeyName.value.trim())
createdKey.value = { access_key: r.data!.access_key, secret_key: r.data!.secret_key }
newKeyName.value = ''
loadKeys()
} catch {
/* 拦截器已提示 */
} finally {
creatingKey.value = false
}
}
async function removeKey(k: APIKey) {
try {
await ElMessageBox.confirm(`确定删除密钥「${k.name}」?使用该密钥的客户端将无法访问。`, '删除 API Key', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
} catch {
return
}
await authApi.deleteAPIKey(k.id)
ElMessage.success('密钥已删除')
loadKeys()
}
function copyText(text: string) {
navigator.clipboard?.writeText(text).then(
() => ElMessage.success('已复制到剪贴板'),
() => ElMessage.warning('复制失败,请手动复制'),
)
}
// ---------- Webhook ----------
const hooks = ref<Webhook[]>([])
const projects = ref<Project[]>([])
const loadingHooks = ref(false)
const hookDialog = ref(false)
const hookForm = reactive({ url: '', events: [] as string[], project_id: undefined as number | undefined })
const savingHook = ref(false)
const eventOptions = ['file.upload', 'file.delete', 'file.restore', 'project.create', 'project.delete']
async function loadHooks() {
loadingHooks.value = true
try {
const r = await webhookApi.list()
hooks.value = r.data || []
} finally {
loadingHooks.value = false
}
}
function openHookDialog() {
hookForm.url = ''
hookForm.events = ['file.upload', 'file.delete']
hookForm.project_id = undefined
hookDialog.value = true
}
async function saveHook() {
if (!hookForm.url.startsWith('http')) {
ElMessage.warning('请输入合法的 http(s) 回调地址')
return
}
if (hookForm.events.length === 0) {
ElMessage.warning('请至少选择一个事件')
return
}
savingHook.value = true
try {
await webhookApi.create({
url: hookForm.url,
events: hookForm.events.join(','),
project_id: hookForm.project_id,
})
ElMessage.success('Webhook 已创建')
hookDialog.value = false
loadHooks()
} catch {
/* 拦截器已提示 */
} finally {
savingHook.value = false
}
}
async function toggleHook(h: Webhook) {
await webhookApi.update(h.id, { status: h.status === 1 ? 0 : 1 })
ElMessage.success(h.status === 1 ? '已停用' : '已启用')
loadHooks()
}
async function removeHook(h: Webhook) {
try {
await ElMessageBox.confirm(`确定删除该 Webhook?`, '删除 Webhook', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
} catch {
return
}
await webhookApi.remove(h.id)
ElMessage.success('已删除')
loadHooks()
}
onMounted(async () => {
await loadProfile()
loadKeys()
loadHooks()
projectApi.list({ page: 1, page_size: 100 }).then((r) => (projects.value = r.data!.list || []))
})
</script>
<template>
<div class="page-card">
<el-tabs v-model="tab">
<!-- 基本资料 -->
<el-tab-pane label="基本资料" name="info">
<el-descriptions :column="1" border style="max-width: 560px; margin-bottom: 20px">
<el-descriptions-item label="用户名">{{ profile?.username }}</el-descriptions-item>
<el-descriptions-item label="角色">
<el-tag size="small">{{ profile?.role?.name || '-' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="存储用量">
{{ formatSize(profile?.storage_used || 0) }} / {{ formatSize(profile?.storage_limit || 0) }}
</el-descriptions-item>
<el-descriptions-item label="最近登录">
{{ formatTime(profile?.last_login_at) }}({{ profile?.last_login_ip || '-' }})
</el-descriptions-item>
<el-descriptions-item label="注册时间">{{ formatTime(profile?.created_at) }}</el-descriptions-item>
</el-descriptions>
<el-form label-width="80px" style="max-width: 480px">
<el-form-item label="邮箱">
<el-input v-model="email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="savingProfile" @click="saveProfile">保存修改</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
<!-- 修改密码 -->
<el-tab-pane label="修改密码" name="password">
<el-form label-width="100px" style="max-width: 480px">
<el-form-item label="当前密码" required>
<el-input v-model="pwdForm.old" type="password" show-password placeholder="请输入当前密码" />
</el-form-item>
<el-form-item label="新密码" required>
<el-input v-model="pwdForm.new" type="password" show-password placeholder="8位以上,含大小写字母和数字" />
</el-form-item>
<el-form-item label="确认新密码" required>
<el-input v-model="pwdForm.confirm" type="password" show-password placeholder="再次输入新密码" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="savingPwd" @click="changePassword">确认修改</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
<!-- API Key -->
<el-tab-pane label="API Key" name="apikeys">
<div class="page-toolbar">
<span class="tab-tip">用于客户端通过 HMAC 签名方式调用 API,替代 JWT 令牌</span>
<el-button type="primary" @click="newKeyDialog = true">
<el-icon><Plus /></el-icon>新建密钥
</el-button>
</div>
<el-table :data="keys" v-loading="loadingKeys" empty-text="暂无 API Key">
<el-table-column prop="name" label="名称" min-width="140" />
<el-table-column label="Access Key" min-width="240">
<template #default="{ row }">
<span class="mono">{{ row.access_key }}</span>
<el-button link type="primary" size="small" style="margin-left: 6px" @click="copyText(row.access_key)">复制</el-button>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
{{ row.status === 1 ? '正常' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="最近使用" width="160">
<template #default="{ row }">{{ formatTime(row.last_used_at) }}</template>
</el-table-column>
<el-table-column label="创建时间" width="160">
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right">
<template #default="{ row }">
<el-button link type="danger" @click="removeKey(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- Webhook -->
<el-tab-pane label="Webhook" name="webhook">
<div class="page-toolbar">
<span class="tab-tip">文件/项目事件发生时,系统将 POST 通知回调地址</span>
<el-button type="primary" @click="openHookDialog">
<el-icon><Plus /></el-icon>新建 Webhook
</el-button>
</div>
<el-table :data="hooks" v-loading="loadingHooks" empty-text="暂无 Webhook">
<el-table-column prop="url" label="回调地址" min-width="240" show-overflow-tooltip>
<template #default="{ row }"><span class="mono">{{ row.url }}</span></template>
</el-table-column>
<el-table-column label="订阅事件" min-width="200">
<template #default="{ row }">
<el-tag v-for="e in row.events.split(',')" :key="e" size="small" style="margin-right: 4px">{{ e }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
{{ row.status === 1 ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" width="160">
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="toggleHook(row)">{{ row.status === 1 ? '停用' : '启用' }}</el-button>
<el-button link type="danger" @click="removeHook(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<!-- 新建 API Key -->
<el-dialog v-model="newKeyDialog" title="新建 API Key" width="480px" @closed="createdKey = null">
<template v-if="!createdKey">
<el-form label-width="90px">
<el-form-item label="密钥名称" required>
<el-input v-model="newKeyName" placeholder="例如:ci-deploy" maxlength="50" />
</el-form-item>
</el-form>
</template>
<template v-else>
<el-alert type="warning" :closable="false" show-icon style="margin-bottom: 16px">
Secret Key 仅本次显示,请立即保存!
</el-alert>
<el-form label-width="100px">
<el-form-item label="Access Key">
<el-input :model-value="createdKey.access_key" readonly>
<template #append>
<el-button @click="copyText(createdKey!.access_key)">复制</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item label="Secret Key">
<el-input :model-value="createdKey.secret_key" readonly>
<template #append>
<el-button @click="copyText(createdKey!.secret_key)">复制</el-button>
</template>
</el-input>
</el-form-item>
</el-form>
</template>
<template #footer>
<el-button @click="newKeyDialog = false">{{ createdKey ? '已保存,关闭' : '取消' }}</el-button>
<el-button v-if="!createdKey" type="primary" :loading="creatingKey" @click="createKey">创建</el-button>
</template>
</el-dialog>
<!-- 新建 Webhook -->
<el-dialog v-model="hookDialog" title="新建 Webhook" width="520px">
<el-form label-width="100px">
<el-form-item label="回调地址" required>
<el-input v-model="hookForm.url" placeholder="https://example.com/callback" />
</el-form-item>
<el-form-item label="订阅事件" required>
<el-checkbox-group v-model="hookForm.events">
<el-checkbox v-for="e in eventOptions" :key="e" :value="e">{{ e }}</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="限定项目">
<el-select v-model="hookForm.project_id" placeholder="全部项目(可选)" clearable style="width: 100%">
<el-option v-for="p in projects" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="hookDialog = false">取消</el-button>
<el-button type="primary" :loading="savingHook" @click="saveHook">创建</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.tab-tip {
color: #909399;
font-size: 13px;
}
</style>
+186
View File
@@ -0,0 +1,186 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { projectApi, type Project } from '../api'
import { formatSize, formatTime } from '../utils/format'
const router = useRouter()
const loading = ref(false)
const list = ref<Project[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const keyword = ref('')
const dialogVisible = ref(false)
const editing = ref<Project | null>(null)
const form = reactive({ name: '', description: '', storage_limit: 0 })
const submitting = ref(false)
async function load() {
loading.value = true
try {
const r = await projectApi.list({ page: page.value, page_size: pageSize.value, keyword: keyword.value || undefined })
list.value = r.data!.list || []
total.value = r.data!.total
} finally {
loading.value = false
}
}
function openCreate() {
editing.value = null
form.name = ''
form.description = ''
form.storage_limit = 0
dialogVisible.value = true
}
function openEdit(p: Project) {
editing.value = p
form.name = p.name
form.description = p.description
form.storage_limit = p.storage_limit / (1024 * 1024) // 转为MB编辑
dialogVisible.value = true
}
async function submit() {
if (!form.name.trim()) {
ElMessage.warning('请输入项目名称')
return
}
submitting.value = true
const limitBytes = form.storage_limit > 0 ? Math.round(form.storage_limit * 1024 * 1024) : 0
try {
if (editing.value) {
await projectApi.update(editing.value.id, {
name: form.name,
description: form.description,
storage_limit: limitBytes,
})
ElMessage.success('项目已更新')
} else {
await projectApi.create({
name: form.name,
description: form.description,
storage_limit: limitBytes || undefined,
})
ElMessage.success('项目已创建')
}
dialogVisible.value = false
load()
} catch {
/* 拦截器已提示 */
} finally {
submitting.value = false
}
}
async function remove(p: Project) {
try {
await ElMessageBox.confirm(
`确定删除项目「${p.name}」?项目下所有文件将被一并删除且不可恢复。`,
'删除项目',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
)
} catch {
return
}
await projectApi.remove(p.id)
ElMessage.success('项目已删除')
load()
}
function goFiles(p: Project) {
router.push({ path: '/files', query: { project_id: p.id } })
}
onMounted(load)
</script>
<template>
<div class="page-card">
<div class="page-toolbar">
<el-input
v-model="keyword"
placeholder="搜索项目名称"
clearable
style="width: 240px"
@keyup.enter="page = 1; load()"
@clear="page = 1; load()"
>
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button type="primary" @click="openCreate">
<el-icon><Plus /></el-icon>新建项目
</el-button>
</div>
<el-table :data="list" v-loading="loading" empty-text="暂无项目,点击右上角新建">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column label="项目名称" min-width="160">
<template #default="{ row }">
<el-link type="primary" @click="goFiles(row)">{{ row.name }}</el-link>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="180" show-overflow-tooltip />
<el-table-column label="存储用量" min-width="200">
<template #default="{ row }">
<el-progress
:percentage="row.storage_limit ? Math.min(100, Math.round(row.storage_used / row.storage_limit * 100)) : 0"
:stroke-width="10"
>
<span class="mono">{{ formatSize(row.storage_used) }} / {{ formatSize(row.storage_limit) }}</span>
</el-progress>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
{{ row.status === 1 ? '正常' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" width="160">
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="goFiles(row)">文件</el-button>
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-if="total > pageSize"
style="margin-top: 16px; justify-content: flex-end"
layout="total, prev, pager, next"
:total="total"
:page-size="pageSize"
v-model:current-page="page"
@current-change="load"
/>
<el-dialog v-model="dialogVisible" :title="editing ? '编辑项目' : '新建项目'" width="460px">
<el-form label-width="90px">
<el-form-item label="项目名称" required>
<el-input v-model="form.name" placeholder="请输入项目名称" maxlength="100" />
</el-form-item>
<el-form-item label="项目描述">
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="选填" />
</el-form-item>
<el-form-item label="存储配额">
<el-input-number v-model="form.storage_limit" :min="0" :step="512" style="width: 180px" />
<span style="margin-left: 8px; color: #909399">MB(0 表示使用系统默认)</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
+85
View File
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { adminApi } from '../../api'
import { formatSize, formatTime, saveBlob } from '../../utils/format'
const loading = ref(false)
const creating = ref(false)
const list = ref<{ name: string; size: number; time: string }[]>([])
async function load() {
loading.value = true
try {
const r = await adminApi.listBackups()
list.value = r.data || []
} finally {
loading.value = false
}
}
async function create() {
creating.value = true
try {
const r = await adminApi.backup()
ElMessage.success(`备份完成:${r.data!.name}`)
load()
} catch {
/* 拦截器已提示 */
} finally {
creating.value = false
}
}
async function download(b: { name: string }) {
try {
const resp = await adminApi.downloadBackup(b.name)
saveBlob(resp.data, b.name)
} catch {
/* 拦截器已提示 */
}
}
onMounted(load)
</script>
<template>
<div class="page-card">
<div class="page-toolbar">
<span class="page-tip">备份文件保存在服务器 storage/backups 目录,建议定期备份</span>
<div style="display: flex; gap: 10px">
<el-button @click="load">
<el-icon><Refresh /></el-icon>刷新
</el-button>
<el-button type="primary" :loading="creating" @click="create">
<el-icon><FolderAdd /></el-icon>立即备份
</el-button>
</div>
</div>
<el-table :data="list" v-loading="loading" empty-text="暂无备份记录">
<el-table-column type="index" label="#" width="70" />
<el-table-column label="备份文件" min-width="280">
<template #default="{ row }"><span class="mono">{{ row.name }}</span></template>
</el-table-column>
<el-table-column label="大小" width="140">
<template #default="{ row }">{{ formatSize(row.size) }}</template>
</el-table-column>
<el-table-column label="备份时间" width="200">
<template #default="{ row }">{{ formatTime(row.time) }}</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="download(row)">下载</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<style scoped>
.page-tip {
color: #909399;
font-size: 13px;
}
</style>
+285
View File
@@ -0,0 +1,285 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
const tab = ref('usage')
function copyText(text: string) {
navigator.clipboard?.writeText(text).then(
() => ElMessage.success('已复制到剪贴板'),
() => ElMessage.warning('复制失败,请手动复制'),
)
}
// 常见HTTP状态码说明
const httpCodes = [
{ code: '200', scene: '成功(业务错误通过 code 字段区分)' },
{ code: '401', scene: '未登录 / Token过期 / 签名错误' },
{ code: '403', scene: '无权限(角色权限不足)' },
{ code: '404', scene: '资源不存在' },
{ code: '429', scene: '触发限流(系统设置中的 API 请求频率限制)' },
{ code: '503', scene: '站点维护中' },
]
// 签名请求头说明
const signHeaders = [
{ header: 'X-Access-Key', desc: '创建 API Key 时返回的 access_key' },
{ header: 'X-Timestamp', desc: '当前秒级 Unix 时间戳(±5分钟有效)' },
{ header: 'X-Nonce', desc: '随机字符串(16位以上,防重放,同一 nonce 仅可用一次)' },
{ header: 'X-Signature', desc: 'HMAC-SHA256 签名(算法见下)' },
]
// 常用对接接口清单
const apiTable = [
{ method: 'POST', path: '/auth/login', desc: '登录,返回 JWT token 与用户信息', auth: '公开' },
{ method: 'POST', path: '/files/upload', desc: '上传文件(multipart:file/project_id/path/visibility)', auth: 'JWT或签名' },
{ method: 'GET', path: '/files', desc: '文件列表(支持 project_id/prefix/trash 过滤)', auth: 'JWT或签名' },
{ method: 'GET', path: '/files/:id/download', desc: '下载文件(二进制流)', auth: 'JWT或签名' },
{ method: 'POST', path: '/files/:id/temp-link', desc: '生成临时分享链接', auth: 'JWT或签名' },
{ method: 'GET', path: '/files/temp/:token', desc: '临时链接公开访问(可带 password 参数)', auth: '公开' },
{ method: 'GET', path: '/stats/user', desc: '用户存储/文件统计', auth: 'JWT或签名' },
{ method: 'GET', path: '/stats/traffic', desc: '近N天上传统计(?days=30)', auth: 'JWT或签名' },
{ method: 'POST', path: '/admin/backup', desc: '触发数据库备份', auth: '管理员 system:settings:edit' },
{ method: 'GET', path: '/admin/backups', desc: '备份列表(name/size/time)', auth: '管理员 system:settings:view' },
{ method: 'GET', path: '/admin/backups/download', desc: '下载备份文件(?name=备份文件名)', auth: '管理员 system:settings:view' },
{ method: 'GET', path: '/status', desc: '站点状态(站点名/注册开关等)', auth: '公开' },
]
const curlExample = `# 1. 登录获取JWT
curl -X POST https://file.yunzer.cn/api/v1/auth/login \\
-H "Content-Type: application/json" \\
-d '{"account":"admin","password":"admin123"}'
# 2. 携带JWT调用接口
curl https://file.yunzer.cn/api/v1/admin/backups \\
-H "Authorization: Bearer <token>"
# 3. 下载备份文件
curl -OJ "https://file.yunzer.cn/api/v1/admin/backups/download?name=backup-20260823-000240.db" \\
-H "Authorization: Bearer <token>"`
const pythonExample = `import time, uuid, hmac, hashlib, requests
BASE = "https://file.yunzer.cn/api/v1" # 生产环境域名;本地开发调试可改为 http://localhost:8100/api/v1
ACCESS_KEY = "你的access_key"
SECRET_KEY = "你的secret_key" # 创建密钥时仅显示一次
def signed_get(path):
ts = str(int(time.time()))
nonce = uuid.uuid4().hex[:16]
# 签名串: accessKey\\ntimestamp\\nnonce\\nMETHOD\\npath
msg = "\\n".join([ACCESS_KEY, ts, nonce, "GET", path])
sig = hmac.new(SECRET_KEY.encode(), msg.encode(), hashlib.sha256).hexdigest()
headers = {
"X-Access-Key": ACCESS_KEY,
"X-Timestamp": ts,
"X-Nonce": nonce,
"X-Signature": sig,
}
return requests.get(BASE + path, headers=headers)
# 列出备份
print(signed_get("/admin/backups").json())
# 下载备份(二进制流)
r = signed_get("/admin/backups/download?name=backup-xxx.db")
open("backup.db", "wb").write(r.content)`
</script>
<template>
<div class="page-card">
<el-tabs v-model="tab">
<!-- ==================== 使用说明 ==================== -->
<el-tab-pane label="使用说明" name="usage">
<div class="doc-body">
<section>
<h2>1. 账号与角色</h2>
<ul>
<li>系统内置三种角色:<b>超级管理员(super_admin)</b>拥有全部权限;<b>管理员(admin)</b>可访问管理后台;<b>普通用户(user)</b>仅能使用个人功能。</li>
<li>初始超级管理员账号见 <code>go/config.yaml</code> 的 <code>admin</code> 配置段,首次登录后请立即在「个人中心 → 修改密码」中修改。</li>
<li>密码要求:至少 8 位,且包含大写字母、小写字母和数字。</li>
<li>连续 5 次登录失败将锁定 15 分钟(防暴力破解)。</li>
</ul>
</section>
<section>
<h2>2. 项目与文件</h2>
<ul>
<li>文件必须归属于某个项目,项目之间数据完全隔离。先在「项目管理」创建项目,再到「文件管理」上传。</li>
<li>上传时可指定<b>虚拟目录</b>(如 <code>docs/2026</code>)与<b>可见性</b>;相同内容(MD5 一致)的文件自动去重,只占用一份物理存储。</li>
<li><b>临时链接</b>:为文件生成对外分享链接,可设置提取密码、最大访问次数、有效期,无需登录即可访问。</li>
<li><b>回收站</b>:普通删除进入回收站并释放配额,可随时恢复;「彻底删除」不可恢复,且仅在无其他文件引用时才清理物理文件。</li>
<li>删除项目会级联删除项目下所有文件,请谨慎操作。</li>
</ul>
</section>
<section>
<h2>3. 存储配额</h2>
<ul>
<li>用户与项目各有存储配额,超出配额将无法上传。默认值在「系统设置 → 存储与上传限制」中配置(可用页面顶部的字节计算器换算)。</li>
<li>管理员可在「用户管理 / 项目管理」中为单个用户或项目单独调整配额。</li>
</ul>
</section>
<section>
<h2>4. API Key 与 Webhook</h2>
<ul>
<li><b>API Key</b>:在「个人中心 → API Key」创建,用于程序化调用接口(HMAC 签名认证,替代 JWT)。<b>Secret Key 仅在创建时显示一次</b>,请立即保存。</li>
<li><b>Webhook</b>:在「个人中心 → Webhook」订阅文件/项目事件,事件发生时系统会向回调地址发送 POST 通知。详见「开发说明」。</li>
</ul>
</section>
<section>
<h2>5. 管理后台</h2>
<ul>
<li><b>用户管理</b>:创建/编辑/删除用户,启用或禁用账号(禁用后无法登录),重置密码,分配角色。</li>
<li><b>角色权限</b>:自定义角色并按模块勾选权限;系统内置角色不可删除。</li>
<li><b>系统设置</b>:站点信息、开放注册开关、站点维护模式(关闭后仅管理员可访问)、上传限制、限流等。</li>
<li><b>日志管理</b>:操作日志记录用户行为,系统日志记录运行状态;支持按条件筛选与按天数清理。</li>
</ul>
</section>
<section>
<h2>6. 数据备份</h2>
<ul>
<li>点击「立即备份」使用 SQLite <code>VACUUM INTO</code> 生成一致性快照,文件保存在服务器 <code>go/storage/backups/</code> 目录,命名格式 <code>backup-年月日-时分秒.db</code>。</li>
<li>列表支持直接<b>下载</b>备份文件,建议下载后异地保存。</li>
<li><b>恢复步骤</b>:① 停止服务 → ② 将备份文件覆盖 <code>go/storage/filestoragesystem.db</code>(重命名为该文件名)→ ③ 重新启动服务。</li>
<li>建议:重要环境配置每日定时备份(可用 API 触发,见开发说明),并保留最近 7 份以上。</li>
</ul>
</section>
</div>
</el-tab-pane>
<!-- ==================== 开发说明 ==================== -->
<el-tab-pane label="开发说明" name="dev">
<div class="doc-body">
<section>
<h2>1. 基础约定</h2>
<ul>
<li>接口基地址:生产环境 <code>https://file.yunzer.cn/api/v1</code>;开发环境 <code>http://localhost:8100/api/v1</code></li>
<li>统一响应结构:<code>{ "code": 0, "message": "ok", "data": ... }</code>,<code>code=0</code> 表示成功,非 0 为错误码。</li>
<li>分页响应:<code>data = { "list": [], "total": 0, "page": 1, "page_size": 20 }</code></li>
<li>请求与响应均为 UTF-8;JSON 接口请携带 <code>Content-Type: application/json</code>。</li>
</ul>
<el-table :data="httpCodes" size="small" style="max-width: 640px">
<el-table-column prop="code" label="HTTP状态" width="110" />
<el-table-column prop="scene" label="场景" />
</el-table>
</section>
<section>
<h2>2. 认证方式</h2>
<h3>方式一:JWT(适合网页/短时任务)</h3>
<p>调用 <code>POST /auth/login</code> 获取 <code>token</code>,后续请求携带 <code>Authorization: Bearer &lt;token&gt;</code>,默认 24 小时过期。</p>
<h3>方式二:API Key 签名(推荐系统对接使用)</h3>
<p>请求头携带 4 个字段:</p>
<el-table :data="signHeaders" size="small" style="max-width: 760px">
<el-table-column prop="header" label="请求头" width="150" />
<el-table-column prop="desc" label="说明" />
</el-table>
<p>签名算法:</p>
<pre class="code-block">签名串 = accessKey + "\n" + timestamp + "\n" + nonce + "\n" + HTTP方法(大写) + "\n" + 请求路径(不含域名和查询参数)
X-Signature = HMAC-SHA256( secretKey, 签名串 ) 的十六进制小写字符串</pre>
<el-alert type="warning" :closable="false" show-icon style="margin: 10px 0">
注意事项:timestamp 为秒级时间戳,允许 ±5 分钟偏差;nonce 单次有效(防重放);路径示例:<code>/api/v1/files</code>(不含 <code>?参数</code>)。
</el-alert>
</section>
<section>
<h2>3. 对接示例</h2>
<h3>curl(JWT 方式)</h3>
<div class="code-wrap">
<pre class="code-block">{{ curlExample }}</pre>
<el-button size="small" class="copy-btn" @click="copyText(curlExample)">复制</el-button>
</div>
<h3>Python(API Key 签名方式)</h3>
<div class="code-wrap">
<pre class="code-block">{{ pythonExample }}</pre>
<el-button size="small" class="copy-btn" @click="copyText(pythonExample)">复制</el-button>
</div>
</section>
<section>
<h2>4. 常用对接接口</h2>
<el-table :data="apiTable" size="small" border>
<el-table-column prop="method" label="方法" width="80" />
<el-table-column prop="path" label="路径" min-width="240">
<template #default="{ row }"><span class="mono">{{ row.path }}</span></template>
</el-table-column>
<el-table-column prop="desc" label="说明" min-width="260" />
<el-table-column prop="auth" label="认证/权限" width="200" />
</el-table>
</section>
<section>
<h2>5. Webhook 回调</h2>
<p>订阅事件触发后,系统向回调地址发送 <code>POST</code> 请求(<code>Content-Type: application/json</code>,超时 10 秒):</p>
<pre class="code-block">{
"event": "file.upload",
"timestamp": 1755900000,
"data": { "file_id": 1, "filename": "a.png", "project_id": 1 }
}</pre>
<p>可订阅事件:<code>file.upload</code>、<code>file.delete</code>、<code>file.restore</code>、<code>project.create</code>、<code>project.delete</code>。回调处理应尽快返回 200,耗时逻辑请异步处理。</p>
</section>
</div>
</el-tab-pane>
</el-tabs>
</div>
</template>
<style scoped>
.doc-body {
max-width: 960px;
line-height: 1.8;
font-size: 14px;
}
.doc-body section {
margin-bottom: 28px;
}
.doc-body h2 {
font-size: 17px;
margin-bottom: 10px;
padding-left: 10px;
border-left: 4px solid #409eff;
}
.doc-body h3 {
font-size: 14px;
margin: 12px 0 6px;
color: #303133;
}
.doc-body ul {
padding-left: 22px;
color: #606266;
}
.doc-body li {
margin-bottom: 4px;
}
.doc-body code {
background: #f4f4f5;
color: #e6a23c;
padding: 1px 6px;
border-radius: 4px;
font-family: Consolas, Monaco, monospace;
font-size: 12.5px;
}
.code-wrap {
position: relative;
}
.code-block {
background: #1f2d3d;
color: #dcdfe6;
padding: 14px 16px;
border-radius: 8px;
font-family: Consolas, Monaco, monospace;
font-size: 12.5px;
line-height: 1.7;
overflow-x: auto;
white-space: pre;
margin: 6px 0;
}
.copy-btn {
position: absolute;
top: 12px;
right: 12px;
}
</style>
+217
View File
@@ -0,0 +1,217 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { adminApi, type OperationLog, type SystemLog } from '../../api'
import { formatTime } from '../../utils/format'
const tab = ref('operation')
// ---------- 操作日志 ----------
const opLoading = ref(false)
const opList = ref<OperationLog[]>([])
const opTotal = ref(0)
const opPage = ref(1)
const opPageSize = ref(20)
const opFilter = ref({ username: '', action: '', days: 0 })
async function loadOpLogs() {
opLoading.value = true
try {
const r = await adminApi.listOperationLogs({
page: opPage.value,
page_size: opPageSize.value,
username: opFilter.value.username || undefined,
action: opFilter.value.action || undefined,
days: opFilter.value.days || undefined,
})
opList.value = r.data!.list || []
opTotal.value = r.data!.total
} finally {
opLoading.value = false
}
}
const actionOptions = ['login', 'register', 'create', 'update', 'delete', 'upload', 'download', 'restore']
// ---------- 系统日志 ----------
const sysLoading = ref(false)
const sysList = ref<SystemLog[]>([])
const sysTotal = ref(0)
const sysPage = ref(1)
const sysPageSize = ref(20)
const sysFilter = ref({ level: '', module: '', days: 0 })
async function loadSysLogs() {
sysLoading.value = true
try {
const r = await adminApi.listSystemLogs({
page: sysPage.value,
page_size: sysPageSize.value,
level: sysFilter.value.level || undefined,
module: sysFilter.value.module || undefined,
days: sysFilter.value.days || undefined,
})
sysList.value = r.data!.list || []
sysTotal.value = r.data!.total
} finally {
sysLoading.value = false
}
}
const levelType = (level: string) =>
level === 'error' ? 'danger' : level === 'warn' ? 'warning' : level === 'info' ? 'primary' : 'info'
// ---------- 清理 ----------
const cleanDays = ref(30)
async function cleanup() {
try {
await ElMessageBox.confirm(`确定清理 ${cleanDays.value} 天前的全部日志?该操作不可恢复。`, '清理日志', {
type: 'warning',
confirmButtonText: '清理',
cancelButtonText: '取消',
})
} catch {
return
}
await adminApi.cleanupLogs(cleanDays.value)
ElMessage.success('日志已清理')
loadOpLogs()
loadSysLogs()
}
onMounted(() => {
loadOpLogs()
loadSysLogs()
})
</script>
<template>
<div class="page-card">
<el-tabs v-model="tab">
<!-- 操作日志 -->
<el-tab-pane label="操作日志" name="operation">
<div class="page-toolbar">
<div style="display: flex; gap: 10px; flex-wrap: wrap">
<el-input
v-model="opFilter.username"
placeholder="用户名"
clearable
style="width: 150px"
@keyup.enter="opPage = 1; loadOpLogs()"
@clear="opPage = 1; loadOpLogs()"
/>
<el-select v-model="opFilter.action" placeholder="操作类型" clearable style="width: 140px" @change="opPage = 1; loadOpLogs()">
<el-option v-for="a in actionOptions" :key="a" :label="a" :value="a" />
</el-select>
<el-select v-model="opFilter.days" placeholder="时间范围" clearable style="width: 140px" @change="opPage = 1; loadOpLogs()">
<el-option label="最近1天" :value="1" />
<el-option label="最近7天" :value="7" />
<el-option label="最近30天" :value="30" />
</el-select>
<el-button @click="opPage = 1; loadOpLogs()">
<el-icon><Search /></el-icon>查询
</el-button>
</div>
</div>
<el-table :data="opList" v-loading="opLoading" empty-text="暂无日志">
<el-table-column prop="username" label="用户" width="120" />
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-tag size="small" :type="row.action === 'delete' ? 'danger' : row.action === 'create' ? 'success' : 'primary'">
{{ row.action }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="resource_type" label="资源类型" width="100" />
<el-table-column prop="resource_name" label="资源名称" min-width="140" show-overflow-tooltip />
<el-table-column label="请求" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span class="mono">{{ row.request_method }} {{ row.request_path }}</span>
</template>
</el-table-column>
<el-table-column label="结果" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
{{ row.status === 1 ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="ip" label="IP" width="130" />
<el-table-column label="时间" width="160">
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
</el-table-column>
</el-table>
<el-pagination
v-if="opTotal > opPageSize"
style="margin-top: 16px; justify-content: flex-end"
layout="total, prev, pager, next"
:total="opTotal"
:page-size="opPageSize"
v-model:current-page="opPage"
@current-change="loadOpLogs"
/>
</el-tab-pane>
<!-- 系统日志 -->
<el-tab-pane label="系统日志" name="system">
<div class="page-toolbar">
<div style="display: flex; gap: 10px; flex-wrap: wrap">
<el-select v-model="sysFilter.level" placeholder="级别" clearable style="width: 120px" @change="sysPage = 1; loadSysLogs()">
<el-option label="info" value="info" />
<el-option label="warn" value="warn" />
<el-option label="error" value="error" />
</el-select>
<el-input
v-model="sysFilter.module"
placeholder="模块"
clearable
style="width: 150px"
@keyup.enter="sysPage = 1; loadSysLogs()"
@clear="sysPage = 1; loadSysLogs()"
/>
<el-select v-model="sysFilter.days" placeholder="时间范围" clearable style="width: 140px" @change="sysPage = 1; loadSysLogs()">
<el-option label="最近1天" :value="1" />
<el-option label="最近7天" :value="7" />
<el-option label="最近30天" :value="30" />
</el-select>
<el-button @click="sysPage = 1; loadSysLogs()">
<el-icon><Search /></el-icon>查询
</el-button>
</div>
<div style="display: flex; gap: 8px; align-items: center">
<el-input-number v-model="cleanDays" :min="1" :max="365" style="width: 120px" />
<span style="color: #909399; font-size: 13px">天前</span>
<el-button type="danger" plain @click="cleanup">
<el-icon><Delete /></el-icon>清理日志
</el-button>
</div>
</div>
<el-table :data="sysList" v-loading="sysLoading" empty-text="暂无日志">
<el-table-column label="级别" width="90">
<template #default="{ row }">
<el-tag :type="levelType(row.level)" size="small">{{ row.level }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="module" label="模块" width="120" />
<el-table-column prop="message" label="内容" min-width="320" show-overflow-tooltip />
<el-table-column prop="ip" label="IP" width="130" />
<el-table-column label="时间" width="160">
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
</el-table-column>
</el-table>
<el-pagination
v-if="sysTotal > sysPageSize"
style="margin-top: 16px; justify-content: flex-end"
layout="total, prev, pager, next"
:total="sysTotal"
:page-size="sysPageSize"
v-model:current-page="sysPage"
@current-change="loadSysLogs"
/>
</el-tab-pane>
</el-tabs>
</div>
</template>
+237
View File
@@ -0,0 +1,237 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { adminApi, type Role, type Permission } from '../../api'
const loading = ref(false)
const roles = ref<Role[]>([])
const groups = ref<{ module: string; permissions: Permission[] }[]>([])
const dialogVisible = ref(false)
const editing = ref<Role | null>(null)
const submitting = ref(false)
const form = reactive({ name: '', code: '', description: '' })
const checked = ref<number[]>([])
const allPermIds = computed(() => groups.value.flatMap((g) => g.permissions.map((p) => p.id)))
async function load() {
loading.value = true
try {
const r = await adminApi.listRoles()
roles.value = r.data || []
} finally {
loading.value = false
}
}
function openCreate() {
editing.value = null
form.name = ''
form.code = ''
form.description = ''
checked.value = []
dialogVisible.value = true
}
function openEdit(r: Role) {
editing.value = r
form.name = r.name
form.code = r.code
form.description = r.description
checked.value = (r.permissions || []).map((p) => p.id)
dialogVisible.value = true
}
async function submit() {
if (!form.name.trim() || (!editing.value && !form.code.trim())) {
ElMessage.warning('请填写角色名称和编码')
return
}
submitting.value = true
try {
if (editing.value) {
await adminApi.updateRole(editing.value.id, {
name: form.name,
description: form.description,
permissions: checked.value,
})
ElMessage.success('角色已更新')
} else {
await adminApi.createRole({
name: form.name,
code: form.code.trim(),
description: form.description,
permissions: checked.value,
})
ElMessage.success('角色已创建')
}
dialogVisible.value = false
load()
} catch {
/* 拦截器已提示 */
} finally {
submitting.value = false
}
}
async function remove(r: Role) {
try {
await ElMessageBox.confirm(`确定删除角色「${r.name}」?`, '删除角色', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
} catch {
return
}
await adminApi.deleteRole(r.id)
ElMessage.success('角色已删除')
load()
}
function toggleModule(g: { permissions: Permission[] }, on: boolean) {
const ids = g.permissions.map((p) => p.id)
checked.value = on
? Array.from(new Set([...checked.value, ...ids]))
: checked.value.filter((id) => !ids.includes(id))
}
onMounted(async () => {
load()
const r = await adminApi.listPermissions()
groups.value = r.data || []
})
</script>
<template>
<div class="page-card">
<div class="page-toolbar">
<span class="page-tip">角色的权限决定用户可访问的后台功能,超级管理员拥有全部权限</span>
<el-button type="primary" @click="openCreate">
<el-icon><Plus /></el-icon>新建角色
</el-button>
</div>
<el-table :data="roles" v-loading="loading" empty-text="暂无数据">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="角色名称" min-width="130" />
<el-table-column label="角色编码" min-width="140">
<template #default="{ row }"><span class="mono">{{ row.code }}</span></template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
<el-table-column label="权限数" width="90">
<template #default="{ row }">
<el-tag size="small" type="info">{{ row.code === 'super_admin' ? '全部' : (row.permissions?.length || 0) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="类型" width="100">
<template #default="{ row }">
<el-tag :type="row.is_system ? 'warning' : 'success'" size="small">
{{ row.is_system ? '系统' : '自定义' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)">{{ row.is_system ? '配置权限' : '编辑' }}</el-button>
<el-button v-if="!row.is_system" link type="danger" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 新建/编辑角色 -->
<el-dialog v-model="dialogVisible" :title="editing ? `编辑角色 - ${form.name}` : '新建角色'" width="640px" top="6vh">
<el-form label-width="90px">
<el-form-item label="角色名称" required>
<el-input v-model="form.name" placeholder="例如:运营人员" maxlength="50" :disabled="editing?.is_system" />
</el-form-item>
<el-form-item v-if="!editing" label="角色编码" required>
<el-input v-model="form.code" placeholder="例如:operator(英文,唯一)" maxlength="50" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="选填" />
</el-form-item>
<el-form-item label="权限配置">
<div class="perm-box">
<el-alert
v-if="editing?.code === 'super_admin'"
type="info"
:closable="false"
title="超级管理员自动拥有全部权限,无需配置"
style="margin-bottom: 10px"
/>
<template v-else>
<div class="perm-actions">
<el-checkbox
:model-value="checked.length === allPermIds.length && allPermIds.length > 0"
:indeterminate="checked.length > 0 && checked.length < allPermIds.length"
@change="(v: any) => (checked = v ? [...allPermIds] : [])"
>
全选
</el-checkbox>
<span class="perm-count">已选 {{ checked.length }} / {{ allPermIds.length }}</span>
</div>
<div v-for="g in groups" :key="g.module" class="perm-group">
<div class="perm-group-header">
<el-checkbox
:model-value="g.permissions.every((p) => checked.includes(p.id))"
:indeterminate="g.permissions.some((p) => checked.includes(p.id)) && !g.permissions.every((p) => checked.includes(p.id))"
@change="(v: any) => toggleModule(g, !!v)"
>
<b>{{ g.module }}</b>
</el-checkbox>
</div>
<el-checkbox-group v-model="checked" class="perm-items">
<el-checkbox v-for="p in g.permissions" :key="p.id" :value="p.id" :title="p.description">
{{ p.name }}
</el-checkbox>
</el-checkbox-group>
</div>
</template>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.page-tip {
color: #909399;
font-size: 13px;
}
.perm-box {
width: 100%;
max-height: 360px;
overflow-y: auto;
border: 1px solid #e4e7ed;
border-radius: 6px;
padding: 12px;
}
.perm-actions {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 8px;
border-bottom: 1px dashed #e4e7ed;
margin-bottom: 8px;
}
.perm-count {
color: #909399;
font-size: 12px;
}
.perm-group {
margin-bottom: 10px;
}
.perm-items {
padding-left: 24px;
display: flex;
flex-wrap: wrap;
gap: 2px 0;
}
</style>
+230
View File
@@ -0,0 +1,230 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { adminApi, type SystemSetting } from '../../api'
const loading = ref(false)
const saving = ref(false)
const groups = ref<{ name: string; settings: SystemSetting[] }[]>([])
const activeGroups = ref<string[]>([])
// key -> 编辑值
const values = ref<Record<string, string>>({})
// 布尔开关类设置(值为 true/false 字符串)
const boolKeys = ['site_enabled', 'register_enabled', 'webhook_enabled']
const groupLabels: Record<string, string> = {
site: '站点信息',
feature: '功能开关',
storage: '存储与上传限制',
api: 'API 与集成',
}
// ---------- 字节计算器 ----------
const calcAmount = ref(1)
const calcUnit = ref('GB')
const calcResult = ref<number | null>(null)
const unitFactor: Record<string, number> = {
B: 1,
KB: 1024,
MB: 1024 ** 2,
GB: 1024 ** 3,
TB: 1024 ** 4,
}
// 支持一键填入的字节类设置项
const byteTargets = [
{ key: 'default_storage_limit', label: '默认用户存储配额' },
{ key: 'default_project_limit', label: '默认项目存储配额' },
{ key: 'max_file_size', label: '单文件最大限制' },
]
const byteKeys = byteTargets.map((t) => t.key)
function calcBytes() {
calcResult.value = Math.round((calcAmount.value || 0) * unitFactor[calcUnit.value])
}
function applyTo(key: string) {
if (calcResult.value === null) calcBytes()
values.value[key] = String(calcResult.value)
ElMessage.success('已填入,请点击“保存设置”生效')
}
// 字节值转可读文本(输入框旁提示)
function humanBytes(v?: string): string {
const n = Number(v)
if (!v || isNaN(n) || n <= 0) return ''
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let i = 0
let x = n
while (x >= 1024 && i < units.length - 1) {
x /= 1024
i++
}
const s = x >= 100 || i === 0 ? Math.round(x).toString() : x.toFixed(1)
return `≈ ${s} ${units[i]}`
}
async function load() {
loading.value = true
try {
const r = await adminApi.getSettings()
const data = r.data || {}
groups.value = Object.entries(data).map(([name, settings]) => ({
name,
settings: settings as SystemSetting[],
}))
activeGroups.value = groups.value.map((g) => g.name)
const v: Record<string, string> = {}
for (const g of groups.value) {
for (const s of g.settings) {
v[s.key] = s.value
}
}
values.value = v
} finally {
loading.value = false
}
}
async function save() {
saving.value = true
try {
await adminApi.updateSettings({ ...values.value })
ElMessage.success('设置已保存')
load()
} catch {
/* 拦截器已提示 */
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<template>
<div class="page-card" v-loading="loading">
<div class="page-toolbar">
<span class="page-tip">修改系统全局配置,保存后立即生效</span>
<el-button type="primary" :loading="saving" @click="save">
<el-icon><Check /></el-icon>保存设置
</el-button>
</div>
<!-- 字节计算器 -->
<div class="calc-card">
<div class="calc-title">
<el-icon><Cpu /></el-icon>字节计算器
<span class="calc-sub">填写容量大小,转换后可一键填入存储类设置项</span>
</div>
<div class="calc-row">
<el-input-number v-model="calcAmount" :min="0" :step="1" style="width: 160px" />
<el-select v-model="calcUnit" style="width: 90px" @change="calcResult = null">
<el-option v-for="u in ['B', 'KB', 'MB', 'GB', 'TB']" :key="u" :label="u" :value="u" />
</el-select>
<el-button type="primary" @click="calcBytes">
<el-icon><Sort /></el-icon>转换为字节
</el-button>
<span v-if="calcResult !== null" class="calc-result mono">{{ calcResult }} 字节</span>
</div>
<div v-if="calcResult !== null" class="calc-apply">
<span class="calc-apply-label">填入到:</span>
<el-button v-for="t in byteTargets" :key="t.key" size="small" @click="applyTo(t.key)">
{{ t.label }}
</el-button>
</div>
</div>
<el-collapse v-model="activeGroups" class="settings-collapse">
<el-collapse-item
v-for="g in groups"
:key="g.name"
:name="g.name"
:title="groupLabels[g.name] || g.name"
>
<el-form label-width="200px" label-position="left" style="max-width: 760px">
<el-form-item v-for="s in g.settings" :key="s.id" :label="s.description || s.key">
<el-switch
v-if="boolKeys.includes(s.key)"
:model-value="values[s.key] === 'true'"
@change="(v: any) => (values[s.key] = v ? 'true' : 'false')"
/>
<el-input
v-else
v-model="values[s.key]"
:placeholder="s.key"
style="max-width: 420px"
/>
<span v-if="byteKeys.includes(s.key)" class="setting-hint">{{ humanBytes(values[s.key]) }}</span>
<span class="setting-key mono">{{ s.key }}</span>
</el-form-item>
</el-form>
</el-collapse-item>
</el-collapse>
</div>
</template>
<style scoped>
.page-tip {
color: #909399;
font-size: 13px;
}
.setting-key {
margin-left: 10px;
color: #c0c4cc;
}
.setting-hint {
margin-left: 10px;
color: #67c23a;
font-size: 13px;
white-space: nowrap;
}
.calc-card {
background: #f0f7ff;
border: 1px solid #d9ecff;
border-radius: 8px;
padding: 14px 16px;
margin-bottom: 16px;
}
.calc-title {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
font-size: 14px;
color: #303133;
margin-bottom: 12px;
}
.calc-sub {
font-weight: 400;
font-size: 12px;
color: #909399;
margin-left: 6px;
}
.calc-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.calc-result {
color: #409eff;
font-weight: 600;
font-size: 15px;
}
.calc-apply {
margin-top: 10px;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.calc-apply-label {
color: #606266;
font-size: 13px;
}
.settings-collapse :deep(.el-collapse-item__header) {
font-weight: 600;
}
</style>
+266
View File
@@ -0,0 +1,266 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { adminApi, type User, type Role } from '../../api'
import { formatSize, formatTime } from '../../utils/format'
import { session } from '../../store/user'
const loading = ref(false)
const list = ref<User[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const keyword = ref('')
const roleFilter = ref<number | undefined>()
const statusFilter = ref<string>('')
const roles = ref<Role[]>([])
const dialogVisible = ref(false)
const editing = ref<User | null>(null)
const submitting = ref(false)
const form = reactive({ username: '', email: '', password: '', role_id: 3, storage_limit: 0 })
const resetVisible = ref(false)
const resetTarget = ref<User | null>(null)
const resetPwd = ref('')
async function load() {
loading.value = true
try {
const r = await adminApi.listUsers({
page: page.value,
page_size: pageSize.value,
keyword: keyword.value || undefined,
role_id: roleFilter.value,
status: statusFilter.value || undefined,
})
list.value = r.data!.list || []
total.value = r.data!.total
} finally {
loading.value = false
}
}
function openCreate() {
editing.value = null
form.username = ''
form.email = ''
form.password = ''
form.role_id = 3
form.storage_limit = 0
dialogVisible.value = true
}
function openEdit(u: User) {
editing.value = u
form.email = u.email
form.role_id = u.role_id
form.storage_limit = u.storage_limit ? u.storage_limit / (1024 * 1024) : 0
dialogVisible.value = true
}
async function submit() {
if (!editing.value && (!form.username.trim() || !form.email.trim() || !form.password)) {
ElMessage.warning('请完整填写用户名、邮箱和密码')
return
}
submitting.value = true
const limitBytes = form.storage_limit > 0 ? Math.round(form.storage_limit * 1024 * 1024) : 0
try {
if (editing.value) {
await adminApi.updateUser(editing.value.id, {
email: form.email,
role_id: form.role_id,
storage_limit: limitBytes,
})
ElMessage.success('用户已更新')
} else {
await adminApi.createUser({
username: form.username.trim(),
email: form.email.trim(),
password: form.password,
role_id: form.role_id,
storage_limit: limitBytes || undefined,
})
ElMessage.success('用户已创建')
}
dialogVisible.value = false
load()
} catch {
/* 拦截器已提示 */
} finally {
submitting.value = false
}
}
async function toggleStatus(u: User) {
const next = u.status === 1 ? 0 : 1
await adminApi.setUserStatus(u.id, next)
ElMessage.success(next === 1 ? '用户已启用' : '用户已禁用')
load()
}
function openReset(u: User) {
resetTarget.value = u
resetPwd.value = ''
resetVisible.value = true
}
async function doReset() {
if (!resetPwd.value) {
ElMessage.warning('请输入新密码')
return
}
await adminApi.resetPassword(resetTarget.value!.id, resetPwd.value)
ElMessage.success('密码已重置')
resetVisible.value = false
}
async function remove(u: User) {
try {
await ElMessageBox.confirm(`确定删除用户「${u.username}」?其名下项目与文件将被一并删除。`, '删除用户', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
} catch {
return
}
await adminApi.deleteUser(u.id)
ElMessage.success('用户已删除')
load()
}
onMounted(async () => {
load()
const r = await adminApi.listRoles()
roles.value = r.data || []
})
</script>
<template>
<div class="page-card">
<div class="page-toolbar">
<div style="display: flex; gap: 10px; flex-wrap: wrap">
<el-input
v-model="keyword"
placeholder="搜索用户名/邮箱"
clearable
style="width: 220px"
@keyup.enter="page = 1; load()"
@clear="page = 1; load()"
>
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-select v-model="roleFilter" placeholder="角色" clearable style="width: 140px" @change="page = 1; load()">
<el-option v-for="r in roles" :key="r.id" :label="r.name" :value="r.id" />
</el-select>
<el-select v-model="statusFilter" placeholder="状态" clearable style="width: 120px" @change="page = 1; load()">
<el-option label="正常" value="1" />
<el-option label="禁用" value="0" />
</el-select>
</div>
<el-button type="primary" @click="openCreate">
<el-icon><Plus /></el-icon>新建用户
</el-button>
</div>
<el-table :data="list" v-loading="loading" empty-text="暂无数据">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="username" label="用户名" min-width="120" />
<el-table-column prop="email" label="邮箱" min-width="180" show-overflow-tooltip />
<el-table-column label="角色" width="110">
<template #default="{ row }">
<el-tag :type="row.role?.code === 'super_admin' ? 'danger' : row.role?.code === 'admin' ? 'warning' : 'info'" size="small">
{{ row.role?.name || '-' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="存储用量" width="150">
<template #default="{ row }">
<span class="mono">{{ formatSize(row.storage_used) }} / {{ formatSize(row.storage_limit) }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
{{ row.status === 1 ? '正常' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="最近登录" width="160">
<template #default="{ row }">{{ formatTime(row.last_login_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }">
<template v-if="row.id !== session.user?.id">
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link :type="row.status === 1 ? 'warning' : 'success'" @click="toggleStatus(row)">
{{ row.status === 1 ? '禁用' : '启用' }}
</el-button>
<el-button link type="primary" @click="openReset(row)">重置密码</el-button>
<el-button link type="danger" @click="remove(row)">删除</el-button>
</template>
<span v-else style="color: #909399; font-size: 12px">当前账号</span>
</template>
</el-table-column>
</el-table>
<el-pagination
v-if="total > pageSize"
style="margin-top: 16px; justify-content: flex-end"
layout="total, prev, pager, next"
:total="total"
:page-size="pageSize"
v-model:current-page="page"
@current-change="load"
/>
<!-- 新建/编辑用户 -->
<el-dialog v-model="dialogVisible" :title="editing ? '编辑用户' : '新建用户'" width="480px">
<el-form label-width="100px">
<el-form-item v-if="!editing" label="用户名" required>
<el-input v-model="form.username" placeholder="请输入用户名" maxlength="50" />
</el-form-item>
<el-form-item v-else label="用户名">
<el-input :model-value="editing.username" disabled />
</el-form-item>
<el-form-item label="邮箱" required>
<el-input v-model="form.email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item v-if="!editing" label="密码" required>
<el-input v-model="form.password" type="password" show-password placeholder="8位以上,含大小写字母和数字" />
</el-form-item>
<el-form-item label="角色">
<el-select v-model="form.role_id" style="width: 100%">
<el-option v-for="r in roles" :key="r.id" :label="r.name" :value="r.id" />
</el-select>
</el-form-item>
<el-form-item label="存储配额">
<el-input-number v-model="form.storage_limit" :min="0" :step="512" style="width: 180px" />
<span style="margin-left: 8px; color: #909399">MB(0 表示系统默认)</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submit">确定</el-button>
</template>
</el-dialog>
<!-- 重置密码 -->
<el-dialog v-model="resetVisible" title="重置密码" width="420px">
<el-form label-width="90px">
<el-form-item label="用户">
<el-input :model-value="resetTarget?.username" disabled />
</el-form-item>
<el-form-item label="新密码" required>
<el-input v-model="resetPwd" type="password" show-password placeholder="请输入新密码" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="resetVisible = false">取消</el-button>
<el-button type="primary" @click="doReset">确定</el-button>
</template>
</el-dialog>
</div>
</template>
+17 -2
View File
@@ -1,7 +1,22 @@
import { defineConfig } from 'vite' import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig(({ mode }) => {
// 根据当前工作目录中的不同环境变量文件加载环境变量
const env = loadEnv(mode, process.cwd())
return {
plugins: [vue()], plugins: [vue()],
server: {
port: 5173,
proxy: {
// 开发环境代理到Go后端
'/api': {
target: env.VITE_API_BASE_URL || 'http://127.0.0.1:8100',
changeOrigin: true,
},
},
},
}
}) })
+128 -8
View File
@@ -397,6 +397,28 @@ CREATE INDEX idx_system_logs_created ON system_logs(created_at);
## API设计 ## 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/register` - 用户注册
- `POST /api/v1/auth/login` - 用户登录 - `POST /api/v1/auth/login` - 用户登录
@@ -501,8 +523,22 @@ CREATE INDEX idx_system_logs_created ON system_logs(created_at);
### 环境要求 ### 环境要求
- Go 1.21+ - Go 1.21+
- SQLite 3 - 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 ```bash
# 进入Go后端目录 # 进入Go后端目录
cd go cd go
@@ -514,12 +550,26 @@ go mod tidy
go run main.go go run main.go
``` ```
### 前端安装和运行
```bash
# 进入前端目录
cd backend
# 安装依赖
npm install
# 启动开发服务器
npm run dev
```
### 配置文件 ### 配置文件
#### 后端配置 (go/config.yaml)
```yaml ```yaml
# config.yaml
server: server:
port: 8080 port: 8100
mode: debug # debug/release mode: debug
domain: https://file.yunzer.cn # 生产环境域名
database: database:
driver: sqlite driver: sqlite
@@ -527,17 +577,87 @@ database:
storage: storage:
root: ./storage/files root: ./storage/files
max_size: 100MB # 单文件最大限制 backups: ./storage/backups
admin: admin:
username: admin username: admin
password: admin123 # 首次运行后请修改 password: admin123 # 首次运行后请修改
email: admin@example.com email: admin@example.com
role: super_admin # 默认角色 role: super_admin
jwt: jwt:
secret: your-secret-key # 请修改为随机密钥 secret: please-change-this-secret-key # 生产环境请修改为随机密钥
expire: 24h # Token过期时间 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";
}
}
``` ```
## 注意事项 ## 注意事项
+28
View File
@@ -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
BIN
View File
Binary file not shown.
+66
View File
@@ -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
)
+163
View File
@@ -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=
+139
View File
@@ -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
}
+354
View File
@@ -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())
}
+127
View File
@@ -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, "密钥已删除")
}
+71
View File
@@ -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
}
+194
View File
@@ -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)
}
+113
View File
@@ -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, "项目已删除")
}
+136
View File
@@ -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)
}
+54
View File
@@ -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)
}
+67
View File
@@ -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, "密码已修改")
}
+92
View File
@@ -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已删除")
}
+109
View File
@@ -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"
}
+27
View File
@@ -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()
}
}
+60
View File
@@ -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...)
}
}
}
+28
View File
@@ -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)
}
}
+69
View File
@@ -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()
}
}
+42
View File
@@ -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,
})
}
}
+169
View File
@@ -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"`
}
+159
View File
@@ -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
}
+220
View File
@@ -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:])
}
+60
View File
@@ -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
}
+79
View File
@@ -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
}
+195
View File
@@ -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
}
+108
View File
@@ -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
}
+155
View File
@@ -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
}
+214
View File
@@ -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
}
+346
View File
@@ -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
}
+423
View File
@@ -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/<md5前2位>/<md5>
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
}
+110
View File
@@ -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)
}
+136
View File
@@ -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
}
+116
View File
@@ -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]
}
+109
View File
@@ -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
}
+258
View File
@@ -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
}
+170
View File
@@ -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)
}
+142
View File
@@ -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...))
}
+133
View File
@@ -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)
}
+96
View File
@@ -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
}
+100
View File
@@ -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))
}
+88
View File
@@ -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))
}
}
+27
View File
@@ -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("站点维护中")
)
Binary file not shown.
+89
View File
@@ -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}