更新前后端代码
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
# 开发环境配置(本地调试)
|
||||
VITE_API_BASE_URL=http://localhost:8100/api/v1
|
||||
VITE_APP_TITLE=云泽文件存储云平台 (开发)
|
||||
VITE_APP_ENV=development
|
||||
@@ -0,0 +1,4 @@
|
||||
# 生产环境配置
|
||||
VITE_API_BASE_URL=https://file.yunzer.cn/api/v1
|
||||
VITE_APP_TITLE=云泽文件存储云平台
|
||||
VITE_APP_ENV=production
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>backend</title>
|
||||
<title>云泽文件存储云平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Generated
+549
-1
@@ -8,7 +8,11 @@
|
||||
"name": "backend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"vue": "^3.5.40"
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"axios": "^1.19.0",
|
||||
"element-plus": "^2.14.5",
|
||||
"vue": "^3.5.40",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.3",
|
||||
@@ -65,6 +69,49 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ctrl/tinycolor": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz",
|
||||
"integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@element-plus/icons-vue": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz",
|
||||
"integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"vue": "^3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.8.0.tgz",
|
||||
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.8.0.tgz",
|
||||
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.8.0",
|
||||
"@floating-ui/utils": "^0.2.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.12.tgz",
|
||||
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
@@ -81,6 +128,17 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"name": "@sxzz/popperjs-es",
|
||||
"version": "2.11.8",
|
||||
"resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz",
|
||||
"integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/popperjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmmirror.com/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz",
|
||||
@@ -343,6 +401,21 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.25",
|
||||
"resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.25.tgz",
|
||||
"integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/lodash-es": {
|
||||
"version": "4.17.12",
|
||||
"resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
||||
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/lodash": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.13.3",
|
||||
"resolved": "https://registry.npmmirror.com/@types/node/-/node-24.13.3.tgz",
|
||||
@@ -353,6 +426,12 @@
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/web-bluetooth": {
|
||||
"version": "0.0.21",
|
||||
"resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
|
||||
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-vue": {
|
||||
"version": "6.0.8",
|
||||
"resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz",
|
||||
@@ -449,6 +528,12 @@
|
||||
"@vue/shared": "3.5.41"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/devtools-api": {
|
||||
"version": "6.6.4",
|
||||
"resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
|
||||
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vue/language-core": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-3.3.11.tgz",
|
||||
@@ -532,6 +617,56 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/core": {
|
||||
"version": "14.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-14.4.0.tgz",
|
||||
"integrity": "sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/web-bluetooth": "^0.0.21",
|
||||
"@vueuse/metadata": "14.4.0",
|
||||
"@vueuse/shared": "14.4.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/metadata": {
|
||||
"version": "14.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.4.0.tgz",
|
||||
"integrity": "sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/shared": {
|
||||
"version": "14.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.4.0.tgz",
|
||||
"integrity": "sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/alien-signals": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-3.2.1.tgz",
|
||||
@@ -539,12 +674,93 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/async-validator": {
|
||||
"version": "4.2.5",
|
||||
"resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz",
|
||||
"integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.19.0",
|
||||
"resolved": "https://registry.npmmirror.com/axios/-/axios-1.19.0.tgz",
|
||||
"integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.16.0",
|
||||
"form-data": "^4.0.6",
|
||||
"https-proxy-agent": "^5.0.1",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.23",
|
||||
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.23.tgz",
|
||||
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -555,6 +771,46 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/element-plus": {
|
||||
"version": "2.14.5",
|
||||
"resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.14.5.tgz",
|
||||
"integrity": "sha512-bghYy/S+qg87enHPXELirhEdDqsVAUGcGpbGIeG8dz0kwpIkGz7gYsifulBshXX74iRtHib85XWQj0uSH2A1Yg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ctrl/tinycolor": "^4.2.0",
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@floating-ui/dom": "^1.8.0",
|
||||
"@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@vueuse/core": "14.4.0",
|
||||
"async-validator": "^4.2.5",
|
||||
"dayjs": "^1.11.20",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
"lodash-unified": "^1.0.3",
|
||||
"memoize-one": "^6.0.0",
|
||||
"normalize-wheel-es": "^1.2.0",
|
||||
"vue-component-type-helpers": "^3.3.9"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.3.7"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz",
|
||||
@@ -567,6 +823,51 @@
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||
@@ -591,6 +892,42 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.6.tgz",
|
||||
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -606,6 +943,116 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||
@@ -867,6 +1314,29 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-unified": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz",
|
||||
"integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/lodash-es": "*",
|
||||
"lodash": "*",
|
||||
"lodash-es": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -876,6 +1346,48 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/memoize-one": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz",
|
||||
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/muggle-string": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz",
|
||||
@@ -901,6 +1413,12 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-wheel-es": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
|
||||
"integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/path-browserify": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||
@@ -955,6 +1473,15 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmmirror.com/rolldown/-/rolldown-1.2.5.tgz",
|
||||
@@ -1142,6 +1669,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-component-type-helpers": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmmirror.com/vue-component-type-helpers/-/vue-component-type-helpers-3.3.11.tgz",
|
||||
"integrity": "sha512-LwcxzeliO9fkQcpJG0PoX8X5kmAhKmH9wkpDLxNabwzkQ9Zeib2YVHwFV4pcWmMLfXVfjr/dSV+DaJ3cIPgSNA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vue-router": {
|
||||
"version": "4.6.4",
|
||||
"resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz",
|
||||
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^6.6.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/posva"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-tsc": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-3.3.11.tgz",
|
||||
|
||||
@@ -9,7 +9,11 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.40"
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"axios": "^1.19.0",
|
||||
"element-plus": "^2.14.5",
|
||||
"vue": "^3.5.40",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.3",
|
||||
|
||||
+1
-2
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import HelloWorld from './components/HelloWorld.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HelloWorld />
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
@@ -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')
|
||||
},
|
||||
}
|
||||
@@ -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
@@ -1,5 +1,19 @@
|
||||
import { createApp } from 'vue'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
const app = createApp(App)
|
||||
|
||||
// 注册全部Element Plus图标
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.use(ElementPlus, { locale: zhCn })
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -1,296 +1,34 @@
|
||||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
* {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
}
|
||||
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 1126px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif;
|
||||
background-color: #f5f7fa;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
.page-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
.page-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
.mono {
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 <token></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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -1,7 +1,22 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
export default defineConfig(({ mode }) => {
|
||||
// 根据当前工作目录中的不同环境变量文件加载环境变量
|
||||
const env = loadEnv(mode, process.cwd())
|
||||
|
||||
return {
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
// 开发环境代理到Go后端
|
||||
'/api': {
|
||||
target: env.VITE_API_BASE_URL || 'http://127.0.0.1:8100',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
+128
-8
@@ -397,6 +397,28 @@ CREATE INDEX idx_system_logs_created ON system_logs(created_at);
|
||||
|
||||
## API设计
|
||||
|
||||
### 基础信息
|
||||
- **API基础地址**:
|
||||
- 开发环境:`http://localhost:8100/api/v1`
|
||||
- 生产环境:`https://file.yunzer.cn/api/v1`
|
||||
- **响应格式**:`{"code": 0, "message": "ok", "data": ...}`
|
||||
- **认证方式**:JWT Bearer Token 或 API Key 签名
|
||||
- **分页格式**:`{"list": [], "total": 0, "page": 1, "page_size": 20}`
|
||||
|
||||
### 域名配置说明
|
||||
系统支持通过配置文件设置域名,用于生成完整的URL:
|
||||
|
||||
```yaml
|
||||
# config.yaml
|
||||
server:
|
||||
domain: https://file.yunzer.cn # 生产环境域名
|
||||
```
|
||||
|
||||
生成的完整URL示例:
|
||||
- 文件下载:`https://file.yunzer.cn/api/v1/files/1/download`
|
||||
- 临时链接:`https://file.yunzer.cn/api/v1/files/temp/abc123`
|
||||
- 站点状态:`https://file.yunzer.cn/api/v1/status`
|
||||
|
||||
### 认证相关
|
||||
- `POST /api/v1/auth/register` - 用户注册
|
||||
- `POST /api/v1/auth/login` - 用户登录
|
||||
@@ -501,8 +523,22 @@ CREATE INDEX idx_system_logs_created ON system_logs(created_at);
|
||||
### 环境要求
|
||||
- Go 1.21+
|
||||
- SQLite 3
|
||||
- Node.js 16+
|
||||
- npm 或 yarn
|
||||
|
||||
### 安装和运行
|
||||
### 域名配置
|
||||
|
||||
#### 开发环境
|
||||
- 后端服务地址:`http://localhost:8100`
|
||||
- 前端服务地址:`http://localhost:5173`
|
||||
- API基础地址:`http://localhost:8100/api/v1`
|
||||
|
||||
#### 生产环境
|
||||
- 域名:`https://file.yunzer.cn`
|
||||
- API基础地址:`https://file.yunzer.cn/api/v1`
|
||||
- 建议配置Nginx反向代理,将80/8080端口重定向到443端口
|
||||
|
||||
### 后端安装和运行
|
||||
```bash
|
||||
# 进入Go后端目录
|
||||
cd go
|
||||
@@ -514,12 +550,26 @@ go mod tidy
|
||||
go run main.go
|
||||
```
|
||||
|
||||
### 前端安装和运行
|
||||
```bash
|
||||
# 进入前端目录
|
||||
cd backend
|
||||
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 启动开发服务器
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 配置文件
|
||||
|
||||
#### 后端配置 (go/config.yaml)
|
||||
```yaml
|
||||
# config.yaml
|
||||
server:
|
||||
port: 8080
|
||||
mode: debug # debug/release
|
||||
port: 8100
|
||||
mode: debug
|
||||
domain: https://file.yunzer.cn # 生产环境域名
|
||||
|
||||
database:
|
||||
driver: sqlite
|
||||
@@ -527,17 +577,87 @@ database:
|
||||
|
||||
storage:
|
||||
root: ./storage/files
|
||||
max_size: 100MB # 单文件最大限制
|
||||
backups: ./storage/backups
|
||||
|
||||
admin:
|
||||
username: admin
|
||||
password: admin123 # 首次运行后请修改
|
||||
email: admin@example.com
|
||||
role: super_admin # 默认角色
|
||||
role: super_admin
|
||||
|
||||
jwt:
|
||||
secret: your-secret-key # 请修改为随机密钥
|
||||
expire: 24h # Token过期时间
|
||||
secret: please-change-this-secret-key # 生产环境请修改为随机密钥
|
||||
expire: 24h
|
||||
|
||||
log:
|
||||
level: info
|
||||
dir: ./storage/logs
|
||||
max_days: 7
|
||||
```
|
||||
|
||||
#### 前端配置
|
||||
##### 开发环境 (.env.development)
|
||||
```
|
||||
VITE_API_BASE_URL=http://localhost:8100/api/v1
|
||||
VITE_APP_TITLE=云泽文件存储云平台 (开发)
|
||||
VITE_APP_ENV=development
|
||||
```
|
||||
|
||||
##### 生产环境 (.env.production)
|
||||
```
|
||||
VITE_API_BASE_URL=https://file.yunzer.cn/api/v1
|
||||
VITE_APP_TITLE=云泽文件存储云平台
|
||||
VITE_APP_ENV=production
|
||||
```
|
||||
|
||||
### Nginx配置示例(生产环境)
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name file.yunzer.cn;
|
||||
|
||||
# 重定向到HTTPS
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name file.yunzer.cn;
|
||||
|
||||
# SSL证书配置
|
||||
ssl_certificate /path/to/your/certificate.crt;
|
||||
ssl_certificate_key /path/to/your/private.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
|
||||
# 前端静态文件
|
||||
location / {
|
||||
root /path/to/your/frontend/dist;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API代理
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8100;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 超时设置
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
}
|
||||
|
||||
# 文件上传大小限制
|
||||
client_max_body_size 100M;
|
||||
|
||||
# 静态资源缓存
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
@@ -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
Binary file not shown.
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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, "密钥已删除")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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, "项目已删除")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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, "密码已修改")
|
||||
}
|
||||
@@ -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已删除")
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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...))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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.
@@ -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}
|
||||
Reference in New Issue
Block a user