init: 初始化项目
This commit is contained in:
8
.env.dev
Normal file
8
.env.dev
Normal file
@@ -0,0 +1,8 @@
|
||||
# 开发环境配置
|
||||
SERVER_ADDRESS=:9050
|
||||
SERVER_MODE=dev
|
||||
DATABASE_DSN=nebula.db
|
||||
STORAGE_TYPE=local
|
||||
STORAGE_BASE_PATH=./uploads
|
||||
STORAGE_BASE_URL=http://localhost:9050/files
|
||||
JWT_SECRET=dev-secret-key
|
||||
19
.env.example
Normal file
19
.env.example
Normal file
@@ -0,0 +1,19 @@
|
||||
# 服务器配置
|
||||
SERVER_ADDRESS=:9050
|
||||
SERVER_MODE=dev # dev 或 prod
|
||||
|
||||
# 数据库配置
|
||||
DATABASE_DSN=nebula.db
|
||||
|
||||
# 存储配置
|
||||
STORAGE_TYPE=local
|
||||
STORAGE_BASE_PATH=./uploads
|
||||
STORAGE_BASE_URL=http://localhost:9050/files
|
||||
|
||||
# JWT 配置
|
||||
JWT_SECRET=your-secret-key-change-in-production
|
||||
JWT_ACCESS_TOKEN_DURATION=7200 # 2小时(秒)
|
||||
JWT_REFRESH_TOKEN_DURATION=604800 # 7天(秒)
|
||||
|
||||
# 前端配置(生产环境)
|
||||
# FRONTEND_PATH=./web/dist
|
||||
9
.env.prod
Normal file
9
.env.prod
Normal file
@@ -0,0 +1,9 @@
|
||||
# 生产环境配置
|
||||
SERVER_ADDRESS=:9050
|
||||
SERVER_MODE=prod
|
||||
DATABASE_DSN=nebula.db
|
||||
STORAGE_TYPE=local
|
||||
STORAGE_BASE_PATH=./uploads
|
||||
STORAGE_BASE_URL=http://localhost:9050/files
|
||||
JWT_SECRET=your-secret-key-change-in-production
|
||||
FRONTEND_PATH=./web/dist
|
||||
104
README.md
Normal file
104
README.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Nebula
|
||||
|
||||
Nebula 是一个用于分发桌面/客户端应用版本的全栈项目,后端使用 Go (Gin + GORM) 暴露应用、版本、资产、更新检查与 JWT 身份验证接口,前端使用 Vue 3 + Vite 构建管理控制台。
|
||||
|
||||
## 🔍 项目概览
|
||||
|
||||
- **API 服务** (`cmd/nebula-server` + `internal/`): 提供应用、版本、资产、更新校验、认证等 REST API,并支持本地文件存储。
|
||||
- **Web 控制台** (`web/`): Vue 3 + TypeScript + Pinia + Vue Router,负责登录、版本管理等 UI。
|
||||
- **文档** (`docs/`): 包含资产接口、JWT、JSON Key、发布 API 以及更新检查规格。
|
||||
- **脚本** (`scripts/`): 统一封装 dev/build/start 脚本(.sh/.bat)。
|
||||
|
||||
```
|
||||
.
|
||||
├── cmd/nebula-server # API 服务入口
|
||||
├── internal/ # 领域服务、API 路由、存储、认证等
|
||||
├── web/ # Vue 3 前端应用
|
||||
├── docs/ # API 与协议文档
|
||||
├── scripts/ # 本地开发与构建脚本
|
||||
├── config*.yaml # 运行配置 (默认、dev、prod)
|
||||
├── uploads/ # 本地存储文件输出目录
|
||||
└── go.mod # Go 依赖定义
|
||||
```
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 准备依赖
|
||||
|
||||
- Go 1.21+(`go.mod` 设定 1.25,使用最新稳定版即可)
|
||||
- Node.js 20+ 与 [pnpm](https://pnpm.io/)(前端)
|
||||
|
||||
### 2. 配置后端
|
||||
|
||||
1. 复制并调整配置:`cp config.dev.yaml config.yaml`(Windows 可使用 `copy`)。
|
||||
2. 启动 API 服务:
|
||||
- 开发模式:`go run ./cmd/nebula-server`
|
||||
- 生产构建:`go build -o bin/nebula-server ./cmd/nebula-server`
|
||||
3. 默认会初始化本地 SQLite 数据库、文件存储与 JWT 认证。
|
||||
|
||||
### 3. 启动前端
|
||||
|
||||
```bash
|
||||
cd web
|
||||
pnpm install
|
||||
pnpm dev # 开发热更新
|
||||
pnpm build # 生产构建,产物位于 web/dist
|
||||
```
|
||||
|
||||
也可以使用 `scripts/dev.(sh|bat)`、`scripts/build.(sh|bat)`、`scripts/start.(sh|bat)` 进行一键操作。
|
||||
|
||||
## 📚 文档资源
|
||||
|
||||
- `docs/ASSET_API.md` – 资产上传/管理接口规格
|
||||
- `docs/AUTH_JWT.md` – JWT 身份认证说明
|
||||
- `docs/JSON_KEY_FORMAT.md` – JSON Key 结构
|
||||
- `docs/RELEASE_API.md` – 发布版本 API
|
||||
- `docs/UPDATE_CHECK.md` – 客户端更新检查流程
|
||||
|
||||
## 📦 功能实现情况
|
||||
|
||||
### 后端 API 与服务
|
||||
|
||||
| 模块 | 功能 | 状态 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 认证 | 管理员/普通用户登录、Refresh、Profile、改密 | ✅ 已落地 | `AuthHandler.Login/RefreshToken/GetProfile/ChangePassword` 已通过 `JWTMiddleware` 保护,并支持配置中的默认管理员。 |
|
||||
| 认证 | 自助注册 | 🟡 未启用 | `AuthHandler.Register` 与 `AuthService.Register` 已实现,但 `/api/auth/register` 在路由中被注释以限制注册,`docs/AUTH_JWT.md` 需同步说明。 |
|
||||
| 应用 | CRUD | ✅ 已落地 | `AppService` + `AppHandler` 覆盖列表/详情/创建/更新/删除。 |
|
||||
| 版本 | CRUD + 最新版本查询 | ✅ 基本可用 | `ReleaseService` 支持按渠道筛选及 `GetLatest`,但 Delete 目前不会清理关联资源(见 TODO #3)。 |
|
||||
| 资源 | 列表/详情/外链创建/上传/下载 | ✅ 已落地 | `AssetService` 负责校验和计算、本地存储、下载路径转换。 |
|
||||
| 资源 | 数字签名与删除日志 | 🟡 待补完 | 上传时 `Signature` 仍为空,删除文件失败只留下 TODO(见 TODO #1/#2)。 |
|
||||
| 更新检查 | `/api/check-update` | ✅ 已落地 | `updater.CheckUpdate` 使用语义化版本比较和校验和返回。 |
|
||||
| 存储 | LocalStorage | ✅ 已落地 | file save/delete/url/exists 已实现并在 `main.go` 注入。 |
|
||||
| 存储 | OSS / S3 适配器 | ⏳ 预留接口 | `storage.Storage` 提供接口,配置项存在但尚无实现分支。 |
|
||||
| 中间件 | AdminMiddleware | ⚠️ 未接入 | 中间件已实现,但目前所有受保护路由仅做 JWT 校验,未强制管理员角色。 |
|
||||
|
||||
### 前端 Web 控制台
|
||||
|
||||
| 功能 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| 登录页 | ✅ 可用 | `view/auth/login.vue` 已接入 `api.login` 与 Pinia 存储,并写入 localStorage。 |
|
||||
| Token 刷新 | 🟡 未使用 | `api.refreshToken` 定义但尚未在 store/拦截器中调用,实际会在 Access Token 过期后直接失败。 |
|
||||
| 仪表盘数据 | 🟡 静态占位 | `view/home/index.vue` 的统计卡片与最近动态均为硬编码示例,尚未调用后端。 |
|
||||
| 导航/路由 | ⚠️ 不完整 | 路由仅注册 `/` 与 `/login`,但 `layout/default.vue` 的“应用管理”“系统设置”菜单会指向不存在的路由。 |
|
||||
| 应用/版本/资产管理 UI | 🔲 未实现 | 目前没有对应的列表、表单或上传界面,API 只能通过 Postman/curl 使用。 |
|
||||
| 注册/忘记密码 UI | 🔲 未实现 | 登录页仅展示提示文字,未提供表单或链接。 |
|
||||
|
||||
## ✅ 当前 TODO 列表
|
||||
|
||||
| # | 模块 | 文件 | 待办 | 建议思路 |
|
||||
|---|------|------|------|----------|
|
||||
| 1 | 资产上传 | `internal/asset/service.go` (行 133) | `Signature` 字段目前为空,需要实现文件签名生成与校验。 | 生成私钥签名或哈希签名存入字段,并在客户端校验;可考虑与 release key 绑定。 |
|
||||
| 2 | 资产删除 | `internal/asset/service.go` (行 226) | 删除存储文件失败时仅注释提醒,需要记录日志。 | 注入 logger(`pkg/logger`)并输出错误详情,便于排查存储清理问题。 |
|
||||
| 3 | 版本删除 | `internal/release/service.go` (行 137) | 删除 Release 时尚未同步删除关联 Assets。 | 在删除前查询并删除对应资产(含文件),或配置数据库外键级联,确保无孤立文件。 |
|
||||
|
||||
> 若新增 TODO,请同步更新此表,保持 README 成为 backlog 的单一事实来源。
|
||||
|
||||
## 🧪 测试与质量
|
||||
|
||||
- 后端可使用 `go test ./...` 运行单元测试。
|
||||
- 前端使用 `pnpm test`(若添加测试工具)或 `pnpm lint` 保持规范。
|
||||
|
||||
## 🗺️ 后续展望
|
||||
|
||||
- 为资产签名、删除链路补全 TODO 后,可引入对象存储(S3、OSS 等)与 CI/CD 流水线。
|
||||
- 可基于 `docs/UPDATE_CHECK.md` 实现自动更新客户端 SDK。
|
||||
76
cmd/nebula-server/main.go
Normal file
76
cmd/nebula-server/main.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"nebula/internal/api"
|
||||
"nebula/internal/auth"
|
||||
"nebula/internal/config"
|
||||
"nebula/internal/db"
|
||||
"nebula/internal/storage"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
|
||||
database := db.Init(cfg.Database.DSN)
|
||||
|
||||
// 初始化存储
|
||||
var stor storage.Storage
|
||||
var err error
|
||||
|
||||
switch cfg.Storage.Type {
|
||||
case "local":
|
||||
stor, err = storage.NewLocalStorage(cfg.Storage.BasePath, cfg.Storage.BaseURL)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to initialize storage: %v", err)
|
||||
}
|
||||
default:
|
||||
log.Fatalf("unsupported storage type: %s", cfg.Storage.Type)
|
||||
}
|
||||
|
||||
// 初始化 JWT 服务
|
||||
jwtService := auth.NewJWTService(
|
||||
cfg.JWT.Secret,
|
||||
cfg.GetAccessTokenDuration(),
|
||||
cfg.GetRefreshTokenDuration(),
|
||||
)
|
||||
|
||||
// 初始化认证服务(使用配置的管理员账号)
|
||||
authService := auth.NewAuthService(
|
||||
database,
|
||||
jwtService,
|
||||
cfg.Admin.Username,
|
||||
cfg.Admin.Password,
|
||||
)
|
||||
|
||||
r := gin.Default()
|
||||
|
||||
// 静态文件服务 - 用于提供文件下载
|
||||
r.Static("/files", cfg.Storage.BasePath)
|
||||
|
||||
// 注册 API 路由
|
||||
api.RegisterRoutes(r, database, stor, jwtService, authService)
|
||||
|
||||
// 生产环境:提供前端静态资源
|
||||
if cfg.Frontend.Enabled {
|
||||
r.Static("/assets", cfg.Frontend.Path+"/assets")
|
||||
r.StaticFile("/favicon.ico", cfg.Frontend.Path+"/favicon.ico")
|
||||
|
||||
// 处理 SPA 路由 - 所有未匹配的路由都返回 index.html
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
c.File(cfg.Frontend.Path + "/index.html")
|
||||
})
|
||||
|
||||
log.Printf("Frontend static files enabled at %s", cfg.Frontend.Path)
|
||||
log.Printf("Frontend available at http://%s", cfg.Server.Address)
|
||||
} else {
|
||||
log.Printf("Running in dev mode - frontend should be served by Vite dev server")
|
||||
}
|
||||
|
||||
log.Printf("Server starting on %s (mode: %s)", cfg.Server.Address, cfg.Server.Mode)
|
||||
log.Printf("Admin username: %s", cfg.Admin.Username)
|
||||
log.Printf("API available at http://%s/api", cfg.Server.Address)
|
||||
r.Run(cfg.Server.Address)
|
||||
}
|
||||
25
config.dev.yaml
Normal file
25
config.dev.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
server:
|
||||
address: ":9050"
|
||||
mode: "dev"
|
||||
|
||||
database:
|
||||
dsn: "nebula.db"
|
||||
|
||||
storage:
|
||||
type: "local"
|
||||
basePath: "./uploads"
|
||||
baseUrl: "http://localhost:9050/files"
|
||||
|
||||
jwt:
|
||||
secret: "dev-secret-key"
|
||||
accessTokenDuration: 7200
|
||||
refreshTokenDuration: 604800
|
||||
|
||||
frontend:
|
||||
enabled: false
|
||||
path: "./web/dist"
|
||||
|
||||
# 开发环境管理员账号
|
||||
admin:
|
||||
username: "admin"
|
||||
password: "123456"
|
||||
25
config.prod.yaml
Normal file
25
config.prod.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
server:
|
||||
address: ":9050"
|
||||
mode: "prod"
|
||||
|
||||
database:
|
||||
dsn: "nebula.db"
|
||||
|
||||
storage:
|
||||
type: "local"
|
||||
basePath: "./uploads"
|
||||
baseUrl: "http://localhost:9050/files"
|
||||
|
||||
jwt:
|
||||
secret: "change-this-secret-in-production"
|
||||
accessTokenDuration: 7200
|
||||
refreshTokenDuration: 604800
|
||||
|
||||
frontend:
|
||||
enabled: true
|
||||
path: "./web/dist"
|
||||
|
||||
# ⚠️ 生产环境管理员账号 - 请务必修改密码!
|
||||
admin:
|
||||
username: "admin"
|
||||
password: "CHANGE_THIS_PASSWORD"
|
||||
25
config.yaml
Normal file
25
config.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
server:
|
||||
address: ":9050"
|
||||
mode: "dev" # dev or prod
|
||||
|
||||
database:
|
||||
dsn: "nebula.db"
|
||||
|
||||
storage:
|
||||
type: "local" # local, oss, s3
|
||||
basePath: "./uploads"
|
||||
baseUrl: "http://localhost:9050/files"
|
||||
|
||||
jwt:
|
||||
secret: "dev-secret-key-change-in-production"
|
||||
accessTokenDuration: 7200 # 2 hours in seconds
|
||||
refreshTokenDuration: 604800 # 7 days in seconds
|
||||
|
||||
frontend:
|
||||
enabled: false # Will be set to true in prod mode
|
||||
path: "./web/dist"
|
||||
|
||||
# 管理员账号配置
|
||||
admin:
|
||||
username: "admin"
|
||||
password: "123456" # ⚠️ 生产环境请务必修改密码!
|
||||
BIN
dist/nebula-server.exe
vendored
Normal file
BIN
dist/nebula-server.exe
vendored
Normal file
Binary file not shown.
312
docs/ASSET_API.md
Normal file
312
docs/ASSET_API.md
Normal file
@@ -0,0 +1,312 @@
|
||||
# Asset API 使用示例
|
||||
|
||||
服务器地址: http://localhost:9050
|
||||
|
||||
## 📦 存储架构
|
||||
|
||||
### 可扩展的存储接口设计
|
||||
|
||||
项目采用接口化设计,支持多种存储后端:
|
||||
|
||||
```go
|
||||
type Storage interface {
|
||||
Save(filename string, content io.Reader) (string, error)
|
||||
Delete(path string) error
|
||||
GetURL(path string) string
|
||||
Exists(path string) bool
|
||||
}
|
||||
```
|
||||
|
||||
**当前实现:**
|
||||
- ✅ **LocalStorage** - 本地文件系统存储
|
||||
- 🔲 **OSSStorage** - 阿里云 OSS(预留)
|
||||
- 🔲 **S3Storage** - AWS S3(预留)
|
||||
|
||||
**切换存储方式只需:**
|
||||
1. 实现 `Storage` 接口
|
||||
2. 在配置中修改 `STORAGE_TYPE`
|
||||
3. 无需修改业务代码
|
||||
|
||||
### 配置
|
||||
|
||||
通过环境变量配置存储:
|
||||
|
||||
```bash
|
||||
# 本地存储(默认)
|
||||
STORAGE_TYPE=local
|
||||
STORAGE_BASE_PATH=./uploads
|
||||
STORAGE_BASE_URL=http://localhost:9050/files
|
||||
|
||||
# 未来支持
|
||||
# STORAGE_TYPE=oss
|
||||
# STORAGE_TYPE=s3
|
||||
```
|
||||
|
||||
## 📝 API 端点
|
||||
|
||||
### 1. 上传文件并创建资源
|
||||
|
||||
**重要:** 这是最常用的方式,上传文件并自动创建记录。
|
||||
|
||||
```bash
|
||||
# 使用 curl
|
||||
curl -X POST http://localhost:9050/api/releases/1/assets/upload \
|
||||
-F "file=@path/to/app.exe" \
|
||||
-F "platform=windows" \
|
||||
-F "arch=amd64"
|
||||
|
||||
# PowerShell 示例
|
||||
$file = "D:\app.exe"
|
||||
$uri = "http://localhost:9050/api/releases/1/assets/upload"
|
||||
|
||||
$form = @{
|
||||
file = Get-Item -Path $file
|
||||
platform = "windows"
|
||||
arch = "amd64"
|
||||
}
|
||||
|
||||
Invoke-RestMethod -Uri $uri -Method Post -Form $form
|
||||
```
|
||||
|
||||
**支持的文件类型:**
|
||||
- Windows: `.exe`, `.msi`, `.zip`
|
||||
- macOS: `.dmg`, `.pkg`, `.app`
|
||||
- Linux: `.deb`, `.rpm`, `.appimage`, `.tar.gz`
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"releaseId": 1,
|
||||
"platform": "windows",
|
||||
"arch": "amd64",
|
||||
"url": "http://localhost:9050/files/releases/1/windows-amd64/app.exe",
|
||||
"checksum": "sha256哈希值",
|
||||
"createdAt": "2026-03-10T12:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 获取所有资源列表
|
||||
|
||||
```bash
|
||||
curl http://localhost:9050/api/assets
|
||||
|
||||
# PowerShell
|
||||
Invoke-RestMethod -Uri "http://localhost:9050/api/assets"
|
||||
```
|
||||
|
||||
### 3. 获取指定版本的资源列表
|
||||
|
||||
```bash
|
||||
curl http://localhost:9050/api/releases/1/assets
|
||||
|
||||
# PowerShell
|
||||
Invoke-RestMethod -Uri "http://localhost:9050/api/releases/1/assets"
|
||||
```
|
||||
|
||||
### 4. 获取单个资源详情
|
||||
|
||||
```bash
|
||||
curl http://localhost:9050/api/assets/1
|
||||
|
||||
# PowerShell
|
||||
Invoke-RestMethod -Uri "http://localhost:9050/api/assets/1"
|
||||
```
|
||||
|
||||
### 5. 创建资源记录(外部 URL)
|
||||
|
||||
如果文件托管在其他地方(如 GitHub Releases、CDN),可以只创建记录:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:9050/api/assets \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"releaseId": 1,
|
||||
"platform": "windows",
|
||||
"arch": "amd64",
|
||||
"url": "https://github.com/user/repo/releases/download/v1.0.0/app.exe",
|
||||
"checksum": "sha256...",
|
||||
"signature": "签名数据"
|
||||
}'
|
||||
|
||||
# PowerShell
|
||||
$body = @{
|
||||
releaseId = 1
|
||||
platform = "windows"
|
||||
arch = "amd64"
|
||||
url = "https://github.com/user/repo/releases/download/v1.0.0/app.exe"
|
||||
checksum = "sha256..."
|
||||
} | ConvertTo-Json
|
||||
|
||||
Invoke-RestMethod -Uri "http://localhost:9050/api/assets" `
|
||||
-Method Post -Body $body -ContentType "application/json"
|
||||
```
|
||||
|
||||
### 6. 更新资源信息
|
||||
|
||||
```bash
|
||||
curl -X PUT http://localhost:9050/api/assets/1 \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"platform": "windows",
|
||||
"arch": "arm64",
|
||||
"signature": "新签名"
|
||||
}'
|
||||
|
||||
# PowerShell
|
||||
$body = @{
|
||||
platform = "windows"
|
||||
arch = "arm64"
|
||||
signature = "新签名"
|
||||
} | ConvertTo-Json
|
||||
|
||||
Invoke-RestMethod -Uri "http://localhost:9050/api/assets/1" `
|
||||
-Method Put -Body $body -ContentType "application/json"
|
||||
```
|
||||
|
||||
### 7. 删除资源
|
||||
|
||||
**注意:** 会同时删除文件和数据库记录。
|
||||
|
||||
```bash
|
||||
curl -X DELETE http://localhost:9050/api/assets/1
|
||||
|
||||
# PowerShell
|
||||
Invoke-RestMethod -Uri "http://localhost:9050/api/assets/1" -Method Delete
|
||||
```
|
||||
|
||||
### 8. 下载资源文件
|
||||
|
||||
```bash
|
||||
# 方式1:通过API端点下载
|
||||
curl http://localhost:9050/api/assets/1/download -o app.exe
|
||||
|
||||
# 方式2:直接访问文件URL
|
||||
curl http://localhost:9050/files/releases/1/windows-amd64/app.exe -o app.exe
|
||||
|
||||
# PowerShell - 方式1
|
||||
Invoke-WebRequest -Uri "http://localhost:9050/api/assets/1/download" -OutFile "app.exe"
|
||||
|
||||
# PowerShell - 方式2(推荐,支持断点续传)
|
||||
Invoke-WebRequest -Uri "http://localhost:9050/files/releases/1/windows-amd64/app.exe" -OutFile "app.exe"
|
||||
```
|
||||
|
||||
## 🔄 完整工作流程
|
||||
|
||||
```powershell
|
||||
# 1. 创建应用
|
||||
$app = Invoke-RestMethod -Method POST -Uri "http://localhost:9050/api/apps" `
|
||||
-ContentType "application/json" `
|
||||
-Body '{"id":"my-app","name":"我的应用","description":"测试"}'
|
||||
|
||||
# 2. 创建版本
|
||||
$release = Invoke-RestMethod -Method POST -Uri "http://localhost:9050/api/releases" `
|
||||
-ContentType "application/json" `
|
||||
-Body '{"appID":"my-app","version":"1.0.0","notes":"初始版本","channel":"stable","pubDate":"2026-03-10T10:00:00Z"}'
|
||||
|
||||
# 3. 上传 Windows 版本
|
||||
$winForm = @{
|
||||
file = Get-Item -Path "D:\builds\app-windows.exe"
|
||||
platform = "windows"
|
||||
arch = "amd64"
|
||||
}
|
||||
$winAsset = Invoke-RestMethod -Uri "http://localhost:9050/api/releases/1/assets/upload" `
|
||||
-Method Post -Form $winForm
|
||||
|
||||
# 4. 上传 macOS 版本
|
||||
$macForm = @{
|
||||
file = Get-Item -Path "D:\builds\app-macos.dmg"
|
||||
platform = "darwin"
|
||||
arch = "arm64"
|
||||
}
|
||||
$macAsset = Invoke-RestMethod -Uri "http://localhost:9050/api/releases/1/assets/upload" `
|
||||
-Method Post -Form $macForm
|
||||
|
||||
# 5. 上传 Linux 版本
|
||||
$linuxForm = @{
|
||||
file = Get-Item -Path "D:\builds\app-linux"
|
||||
platform = "linux"
|
||||
arch = "amd64"
|
||||
}
|
||||
$linuxAsset = Invoke-RestMethod -Uri "http://localhost:9050/api/releases/1/assets/upload" `
|
||||
-Method Post -Form $linuxForm
|
||||
|
||||
# 6. 查看该版本的所有资源
|
||||
$assets = Invoke-RestMethod -Uri "http://localhost:9050/api/releases/1/assets"
|
||||
$assets.data | Format-Table id, platform, arch, url
|
||||
```
|
||||
|
||||
## 📊 API 路由列表
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | /api/assets | 获取所有资源列表 |
|
||||
| GET | /api/assets/:id | 获取单个资源详情 |
|
||||
| POST | /api/assets | 创建资源记录(外部URL) |
|
||||
| PUT | /api/assets/:id | 更新资源信息 |
|
||||
| DELETE | /api/assets/:id | 删除资源(含文件) |
|
||||
| GET | /api/assets/:id/download | 下载资源文件 |
|
||||
| GET | /api/releases/:id/assets | 获取版本的资源列表 |
|
||||
| POST | /api/releases/:id/assets/upload | 上传文件并创建资源 |
|
||||
| GET | /files/* | 静态文件访问 |
|
||||
|
||||
## 🔧 文件存储结构
|
||||
|
||||
```
|
||||
uploads/
|
||||
└── releases/
|
||||
└── {releaseID}/
|
||||
├── windows-amd64/
|
||||
│ └── app.exe
|
||||
├── darwin-arm64/
|
||||
│ └── app.dmg
|
||||
└── linux-amd64/
|
||||
└── app
|
||||
```
|
||||
|
||||
## 🔐 安全特性
|
||||
|
||||
1. **文件校验和**:自动计算 SHA256,确保文件完整性
|
||||
2. **文件类型限制**:只允许特定扩展名的文件
|
||||
3. **平台唯一性**:同一版本的同一平台+架构只能有一个资源
|
||||
4. **级联删除**:删除资源时自动删除文件
|
||||
|
||||
## 🚀 扩展到云存储
|
||||
|
||||
未来要切换到 OSS/S3,只需:
|
||||
|
||||
1. **实现新的存储适配器**
|
||||
|
||||
```go
|
||||
// internal/storage/oss.go
|
||||
type OSSStorage struct {
|
||||
client *oss.Client
|
||||
bucket string
|
||||
}
|
||||
|
||||
func (s *OSSStorage) Save(filename string, content io.Reader) (string, error) {
|
||||
// OSS 上传逻辑
|
||||
}
|
||||
// ... 实现其他接口方法
|
||||
```
|
||||
|
||||
2. **在 main.go 中添加分支**
|
||||
|
||||
```go
|
||||
switch cfg.Storage.Type {
|
||||
case "local":
|
||||
stor, err = storage.NewLocalStorage(...)
|
||||
case "oss":
|
||||
stor, err = storage.NewOSSStorage(...)
|
||||
case "s3":
|
||||
stor, err = storage.NewS3Storage(...)
|
||||
}
|
||||
```
|
||||
|
||||
3. **业务代码无需修改** ✨
|
||||
|
||||
所有 Service 和 Handler 代码都基于接口编程,自动支持新的存储方式!
|
||||
405
docs/AUTH_JWT.md
Normal file
405
docs/AUTH_JWT.md
Normal file
@@ -0,0 +1,405 @@
|
||||
# JWT 认证系统文档
|
||||
|
||||
## 🔐 认证架构
|
||||
|
||||
Nebula 项目使用 **JWT (JSON Web Token)** 认证系统,提供完整的用户认证和授权功能。
|
||||
|
||||
### 核心特性
|
||||
|
||||
- ✅ **JWT 双 Token 机制**(Access Token + Refresh Token)
|
||||
- ✅ **密码加密存储**(bcrypt)
|
||||
- ✅ **灵活的 Token 过期时间配置**
|
||||
- ✅ **中间件保护路由**
|
||||
- ✅ **角色权限支持**(user/admin)
|
||||
- ✅ **用户注册和登录**
|
||||
- ✅ **修改密码**
|
||||
- ✅ **Token 刷新**
|
||||
|
||||
## 📝 API 端点
|
||||
|
||||
### 1. 用户注册
|
||||
|
||||
```http
|
||||
POST /api/auth/register
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"username": "testuser",
|
||||
"email": "test@example.com",
|
||||
"password": "password123"
|
||||
}
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"user": {
|
||||
"id": "uuid",
|
||||
"username": "testuser",
|
||||
"email": "test@example.com",
|
||||
"role": "user",
|
||||
"createdAt": "2026-03-10T12:00:00Z",
|
||||
"updatedAt": "2026-03-10T12:00:00Z"
|
||||
},
|
||||
"tokens": {
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIs...",
|
||||
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
|
||||
"expires_in": 7200
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 用户登录
|
||||
|
||||
```http
|
||||
POST /api/auth/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"username": "testuser",
|
||||
"password": "password123"
|
||||
}
|
||||
```
|
||||
|
||||
**响应:** 同注册响应
|
||||
|
||||
### 3. 刷新 Token
|
||||
|
||||
```http
|
||||
POST /api/auth/refresh
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"refresh_token": "eyJhbGciOiJIUzI1NiIs..."
|
||||
}
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"access_token": "new_access_token",
|
||||
"refresh_token": "new_refresh_token",
|
||||
"expires_in": 7200
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 获取当前用户信息
|
||||
|
||||
```http
|
||||
GET /api/auth/profile
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"id": "uuid",
|
||||
"username": "testuser",
|
||||
"email": "test@example.com",
|
||||
"role": "user",
|
||||
"createdAt": "2026-03-10T12:00:00Z",
|
||||
"updatedAt": "2026-03-10T12:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 修改密码
|
||||
|
||||
```http
|
||||
POST /api/auth/change-password
|
||||
Authorization: Bearer <access_token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"old_password": "password123",
|
||||
"new_password": "newpassword456"
|
||||
}
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "password changed successfully"
|
||||
}
|
||||
```
|
||||
|
||||
## 🧪 PowerShell 测试示例
|
||||
|
||||
### 完整测试流程
|
||||
|
||||
```powershell
|
||||
# 1. 注册用户
|
||||
$registerBody = @{
|
||||
username = "testuser"
|
||||
email = "test@example.com"
|
||||
password = "password123"
|
||||
} | ConvertTo-Json
|
||||
|
||||
$registerResponse = Invoke-RestMethod -Uri "http://localhost:9050/api/auth/register" `
|
||||
-Method Post -Body $registerBody -ContentType "application/json"
|
||||
|
||||
Write-Host "✅ 注册成功!"
|
||||
Write-Host "用户 ID: $($registerResponse.data.user.id)"
|
||||
Write-Host "Access Token: $($registerResponse.data.tokens.access_token.Substring(0, 20))..."
|
||||
|
||||
# 保存 token
|
||||
$accessToken = $registerResponse.data.tokens.access_token
|
||||
$refreshToken = $registerResponse.data.tokens.refresh_token
|
||||
|
||||
# 2. 登录(测试)
|
||||
$loginBody = @{
|
||||
username = "testuser"
|
||||
password = "password123"
|
||||
} | ConvertTo-Json
|
||||
|
||||
$loginResponse = Invoke-RestMethod -Uri "http://localhost:9050/api/auth/login" `
|
||||
-Method Post -Body $loginBody -ContentType "application/json"
|
||||
|
||||
Write-Host "✅ 登录成功!"
|
||||
|
||||
# 3. 获取用户信息
|
||||
$headers = @{
|
||||
Authorization = "Bearer $accessToken"
|
||||
}
|
||||
|
||||
$profile = Invoke-RestMethod -Uri "http://localhost:9050/api/auth/profile" `
|
||||
-Method Get -Headers $headers
|
||||
|
||||
Write-Host "✅ 获取用户信息成功!"
|
||||
Write-Host "用户名: $($profile.data.username)"
|
||||
Write-Host "邮箱: $($profile.data.email)"
|
||||
|
||||
# 4. 创建应用(测试认证保护)
|
||||
$appBody = @{
|
||||
id = "test-app"
|
||||
name = "测试应用"
|
||||
description = "需要认证才能创建"
|
||||
} | ConvertTo-Json
|
||||
|
||||
$app = Invoke-RestMethod -Uri "http://localhost:9050/api/apps" `
|
||||
-Method Post -Body $appBody -ContentType "application/json" -Headers $headers
|
||||
|
||||
Write-Host "✅ 创建应用成功(认证有效)!"
|
||||
|
||||
# 5. 刷新 Token
|
||||
Start-Sleep -Seconds 2
|
||||
$refreshBody = @{
|
||||
refresh_token = $refreshToken
|
||||
} | ConvertTo-Json
|
||||
|
||||
$newTokens = Invoke-RestMethod -Uri "http://localhost:9050/api/auth/refresh" `
|
||||
-Method Post -Body $refreshBody -ContentType "application/json"
|
||||
|
||||
Write-Host "✅ Token 刷新成功!"
|
||||
|
||||
# 6. 修改密码
|
||||
$changePasswordBody = @{
|
||||
old_password = "password123"
|
||||
new_password = "newpassword456"
|
||||
} | ConvertTo-Json
|
||||
|
||||
$changeResult = Invoke-RestMethod -Uri "http://localhost:9050/api/auth/change-password" `
|
||||
-Method Post -Body $changePasswordBody -ContentType "application/json" -Headers $headers
|
||||
|
||||
Write-Host "✅ 密码修改成功!"
|
||||
|
||||
# 7. 测试未认证访问(应该失败)
|
||||
try {
|
||||
Invoke-RestMethod -Uri "http://localhost:9050/api/apps" -Method Get
|
||||
} catch {
|
||||
Write-Host "❌ 未认证访问被拒绝(正确)"
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 配置
|
||||
|
||||
### 环境变量
|
||||
|
||||
```bash
|
||||
# JWT 密钥(生产环境必须修改!)
|
||||
JWT_SECRET=your-secret-key-change-in-production
|
||||
|
||||
# Access Token 过期时间(默认 2 小时)
|
||||
JWT_ACCESS_TOKEN_DURATION=7200 # 秒
|
||||
# 或者
|
||||
JWT_ACCESS_TOKEN_DURATION=2h
|
||||
|
||||
# Refresh Token 过期时间(默认 7 天)
|
||||
JWT_REFRESH_TOKEN_DURATION=604800 # 秒
|
||||
# 或者
|
||||
JWT_REFRESH_TOKEN_DURATION=168h
|
||||
```
|
||||
|
||||
### 推荐配置
|
||||
|
||||
**开发环境:**
|
||||
- Access Token: 2 小时
|
||||
- Refresh Token: 7 天
|
||||
|
||||
**生产环境:**
|
||||
- Access Token: 15 分钟 - 1 小时
|
||||
- Refresh Token: 30 天
|
||||
- 强密钥(256位以上)
|
||||
|
||||
## 🛡️ 安全特性
|
||||
|
||||
### 1. 密码加密
|
||||
|
||||
```go
|
||||
// 使用 bcrypt 加密
|
||||
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
|
||||
// 验证密码
|
||||
bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
|
||||
```
|
||||
|
||||
### 2. Token 验证
|
||||
|
||||
- ✅ 签名验证
|
||||
- ✅ 过期时间检查
|
||||
- ✅ 算法验证(HMAC-SHA256)
|
||||
|
||||
### 3. 中间件保护
|
||||
|
||||
```go
|
||||
// 需要认证
|
||||
authRequired.Use(auth.JWTMiddleware(jwtService))
|
||||
|
||||
// 需要管理员权限
|
||||
adminRequired.Use(auth.JWTMiddleware(jwtService), auth.AdminMiddleware())
|
||||
```
|
||||
|
||||
## 📊 路由保护状态
|
||||
|
||||
### 公开路由(无需认证)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | /api/auth/register | 用户注册 |
|
||||
| POST | /api/auth/login | 用户登录 |
|
||||
| POST | /api/auth/refresh | 刷新 Token |
|
||||
| GET | /api/check-update | 检查更新 |
|
||||
|
||||
### 需要认证的路由
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | /api/auth/profile | 获取用户信息 |
|
||||
| POST | /api/auth/change-password | 修改密码 |
|
||||
| GET/POST/PUT/DELETE | /api/apps/* | 应用管理 |
|
||||
| GET/POST/PUT/DELETE | /api/releases/* | 版本管理 |
|
||||
| GET/POST/PUT/DELETE | /api/assets/* | 资源管理 |
|
||||
|
||||
## 🔄 Token 刷新流程
|
||||
|
||||
```
|
||||
Client Server
|
||||
| |
|
||||
|-------- Request API --------->| (Access Token)
|
||||
|<----- 401 Token Expired ------| ❌
|
||||
| |
|
||||
|--- Refresh Token Request ---->| (Refresh Token)
|
||||
|<--- New Access Token ---------| ✅
|
||||
| |
|
||||
|-------- Request API --------->| (New Access Token)
|
||||
|<-------- Success -------------| ✅
|
||||
```
|
||||
|
||||
## 💡 客户端集成示例
|
||||
|
||||
### JavaScript/TypeScript
|
||||
|
||||
```typescript
|
||||
class AuthService {
|
||||
private accessToken: string | null = null;
|
||||
private refreshToken: string | null = null;
|
||||
|
||||
async login(username: string, password: string) {
|
||||
const response = await fetch('http://localhost:9050/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.code === 0) {
|
||||
this.accessToken = data.data.tokens.access_token;
|
||||
this.refreshToken = data.data.tokens.refresh_token;
|
||||
|
||||
// 保存到 localStorage
|
||||
localStorage.setItem('access_token', this.accessToken);
|
||||
localStorage.setItem('refresh_token', this.refreshToken);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async request(url: string, options: RequestInit = {}) {
|
||||
options.headers = {
|
||||
...options.headers,
|
||||
'Authorization': `Bearer ${this.accessToken}`
|
||||
};
|
||||
|
||||
let response = await fetch(url, options);
|
||||
|
||||
// Token 过期,尝试刷新
|
||||
if (response.status === 401) {
|
||||
await this.refresh();
|
||||
options.headers['Authorization'] = `Bearer ${this.accessToken}`;
|
||||
response = await fetch(url, options);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
const response = await fetch('http://localhost:9050/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: this.refreshToken })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.code === 0) {
|
||||
this.accessToken = data.data.access_token;
|
||||
this.refreshToken = data.data.refresh_token;
|
||||
localStorage.setItem('access_token', this.accessToken);
|
||||
localStorage.setItem('refresh_token', this.refreshToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🚀 扩展功能
|
||||
|
||||
### 未来可添加
|
||||
|
||||
1. **Token 黑名单**:退出登录时将 token 加入黑名单
|
||||
2. **双因素认证(2FA)**:增强安全性
|
||||
3. **OAuth2 第三方登录**:支持 GitHub、Google 等
|
||||
4. **权限细粒度控制**:基于资源的权限管理
|
||||
5. **登录日志**:记录登录历史和异常
|
||||
6. **IP 白名单**:限制访问来源
|
||||
7. **账号锁定**:多次登录失败后锁定
|
||||
|
||||
## ✅ 测试验证
|
||||
|
||||
1. ✅ 注册新用户
|
||||
2. ✅ 登录获取 token
|
||||
3. ✅ 使用 token 访问受保护的 API
|
||||
4. ✅ 刷新 token
|
||||
5. ✅ 修改密码
|
||||
6. ✅ 未认证访问被拒绝
|
||||
|
||||
完整的 JWT 认证系统已经实现并运行正常!🎉
|
||||
164
docs/JSON_KEY_FORMAT.md
Normal file
164
docs/JSON_KEY_FORMAT.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# JSON Key 格式验证
|
||||
|
||||
## ✅ 已统一为小驼峰(camelCase)格式
|
||||
|
||||
### 修改的文件
|
||||
|
||||
1. **`internal/auth/jwt.go`**
|
||||
- `user_id` → `userId`
|
||||
- `access_token` → `accessToken`
|
||||
- `refresh_token` → `refreshToken`
|
||||
- `expires_in` → `expiresIn`
|
||||
|
||||
2. **`internal/auth/service.go`**
|
||||
- `refresh_token` → `refreshToken`
|
||||
|
||||
3. **`internal/api/handler/auth.go`**
|
||||
- `old_password` → `oldPassword`
|
||||
- `new_password` → `newPassword`
|
||||
|
||||
### 统一的 JSON Key 规范
|
||||
|
||||
| Go 结构体字段 | JSON Key (小驼峰) |
|
||||
|--------------|------------------|
|
||||
| ID | id |
|
||||
| UserID | userId |
|
||||
| Username | username |
|
||||
| Email | email |
|
||||
| Role | role |
|
||||
| CreatedAt | createdAt |
|
||||
| UpdatedAt | updatedAt |
|
||||
| AccessToken | accessToken |
|
||||
| RefreshToken | refreshToken |
|
||||
| ExpiresIn | expiresIn |
|
||||
| OldPassword | oldPassword |
|
||||
| NewPassword | newPassword |
|
||||
| AppID | appId (如果有) |
|
||||
| ReleaseID | releaseId |
|
||||
| Platform | platform |
|
||||
| Arch | arch |
|
||||
| URL | url |
|
||||
| Signature | signature |
|
||||
| Checksum | checksum |
|
||||
|
||||
### API 响应示例
|
||||
|
||||
**注册/登录响应:**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"user": {
|
||||
"id": "uuid",
|
||||
"username": "testuser",
|
||||
"email": "test@example.com",
|
||||
"role": "user",
|
||||
"createdAt": "2026-03-10T12:00:00Z",
|
||||
"updatedAt": "2026-03-10T12:00:00Z"
|
||||
},
|
||||
"tokens": {
|
||||
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
|
||||
"refreshToken": "eyJhbGciOiJIUzI1NiIs...",
|
||||
"expiresIn": 7200
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**刷新 Token 请求:**
|
||||
```json
|
||||
{
|
||||
"refreshToken": "eyJhbGciOiJIUzI1NiIs..."
|
||||
}
|
||||
```
|
||||
|
||||
**刷新 Token 响应:**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"accessToken": "new_token...",
|
||||
"refreshToken": "new_refresh...",
|
||||
"expiresIn": 7200
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**修改密码请求:**
|
||||
```json
|
||||
{
|
||||
"oldPassword": "oldpass123",
|
||||
"newPassword": "newpass456"
|
||||
}
|
||||
```
|
||||
|
||||
**资源响应:**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"releaseId": 1,
|
||||
"platform": "windows",
|
||||
"arch": "amd64",
|
||||
"url": "http://localhost:9050/files/...",
|
||||
"signature": "...",
|
||||
"checksum": "sha256...",
|
||||
"createdAt": "2026-03-10T12:00:00Z",
|
||||
"updatedAt": "2026-03-10T12:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 测试验证
|
||||
|
||||
```powershell
|
||||
# 1. 注册用户(验证 camelCase)
|
||||
$body = @{
|
||||
username = "testuser"
|
||||
email = "test@example.com"
|
||||
password = "password123"
|
||||
} | ConvertTo-Json
|
||||
|
||||
$response = Invoke-RestMethod -Uri "http://localhost:9050/api/auth/register" `
|
||||
-Method Post -Body $body -ContentType "application/json"
|
||||
|
||||
# 检查响应格式
|
||||
Write-Host "✅ Access Token 字段: accessToken"
|
||||
Write-Host "✅ Refresh Token 字段: refreshToken"
|
||||
Write-Host "✅ Expires In 字段: expiresIn"
|
||||
Write-Host "✅ Created At 字段: createdAt"
|
||||
Write-Host "✅ Updated At 字段: updatedAt"
|
||||
|
||||
# 2. 刷新 Token(验证 camelCase)
|
||||
$refreshBody = @{
|
||||
refreshToken = $response.data.tokens.refreshToken
|
||||
} | ConvertTo-Json
|
||||
|
||||
$newTokens = Invoke-RestMethod -Uri "http://localhost:9050/api/auth/refresh" `
|
||||
-Method Post -Body $refreshBody -ContentType "application/json"
|
||||
|
||||
Write-Host "✅ 刷新 Token 成功,字段格式正确"
|
||||
|
||||
# 3. 修改密码(验证 camelCase)
|
||||
$headers = @{
|
||||
Authorization = "Bearer $($response.data.tokens.accessToken)"
|
||||
}
|
||||
|
||||
$changePasswordBody = @{
|
||||
oldPassword = "password123"
|
||||
newPassword = "newpassword456"
|
||||
} | ConvertTo-Json
|
||||
|
||||
$result = Invoke-RestMethod -Uri "http://localhost:9050/api/auth/change-password" `
|
||||
-Method Post -Body $changePasswordBody -ContentType "application/json" -Headers $headers
|
||||
|
||||
Write-Host "✅ 修改密码成功,字段格式正确"
|
||||
```
|
||||
|
||||
## ✅ 统一完成
|
||||
|
||||
所有 JSON key 已统一为小驼峰格式(camelCase),符合前端 JavaScript/TypeScript 的命名规范。
|
||||
138
docs/RELEASE_API.md
Normal file
138
docs/RELEASE_API.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# Release API 使用示例
|
||||
|
||||
服务器地址: http://localhost:9050
|
||||
|
||||
## 1. 创建应用(前置步骤)
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:9050/api/apps \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"id": "my-app",
|
||||
"name": "我的应用",
|
||||
"description": "这是一个测试应用"
|
||||
}'
|
||||
```
|
||||
|
||||
## 2. 创建版本发布
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:9050/api/releases \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"appID": "my-app",
|
||||
"version": "1.0.0",
|
||||
"notes": "第一个正式版本\n- 新增功能A\n- 修复bug B",
|
||||
"channel": "stable",
|
||||
"pubDate": "2026-03-10T10:00:00Z"
|
||||
}'
|
||||
```
|
||||
|
||||
## 3. 获取所有版本列表
|
||||
|
||||
```bash
|
||||
curl http://localhost:9050/api/releases
|
||||
```
|
||||
|
||||
## 4. 获取指定应用的版本列表
|
||||
|
||||
```bash
|
||||
curl http://localhost:9050/api/apps/my-app/releases
|
||||
```
|
||||
|
||||
## 5. 按渠道筛选版本
|
||||
|
||||
```bash
|
||||
# 获取 stable 渠道的版本
|
||||
curl "http://localhost:9050/api/apps/my-app/releases?channel=stable"
|
||||
```
|
||||
|
||||
## 6. 获取最新版本
|
||||
|
||||
```bash
|
||||
curl http://localhost:9050/api/apps/my-app/releases/latest
|
||||
|
||||
# 获取指定渠道的最新版本
|
||||
curl "http://localhost:9050/api/apps/my-app/releases/latest?channel=stable"
|
||||
```
|
||||
|
||||
## 7. 获取单个版本详情
|
||||
|
||||
```bash
|
||||
curl http://localhost:9050/api/releases/1
|
||||
```
|
||||
|
||||
## 8. 更新版本信息
|
||||
|
||||
```bash
|
||||
curl -X PUT http://localhost:9050/api/releases/1 \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"version": "1.0.1",
|
||||
"notes": "更新内容",
|
||||
"channel": "stable",
|
||||
"pubDate": "2026-03-10T12:00:00Z"
|
||||
}'
|
||||
```
|
||||
|
||||
## 9. 删除版本
|
||||
|
||||
```bash
|
||||
curl -X DELETE http://localhost:9050/api/releases/1
|
||||
```
|
||||
|
||||
## 完整测试流程
|
||||
|
||||
```powershell
|
||||
# 1. 创建应用
|
||||
Invoke-RestMethod -Method POST -Uri "http://localhost:9050/api/apps" `
|
||||
-ContentType "application/json" `
|
||||
-Body '{"id":"test-app","name":"测试应用","description":"测试用"}'
|
||||
|
||||
# 2. 创建第一个版本
|
||||
Invoke-RestMethod -Method POST -Uri "http://localhost:9050/api/releases" `
|
||||
-ContentType "application/json" `
|
||||
-Body '{"appID":"test-app","version":"1.0.0","notes":"初始版本","channel":"stable","pubDate":"2026-03-10T10:00:00Z"}'
|
||||
|
||||
# 3. 创建第二个版本
|
||||
Invoke-RestMethod -Method POST -Uri "http://localhost:9050/api/releases" `
|
||||
-ContentType "application/json" `
|
||||
-Body '{"appID":"test-app","version":"1.1.0","notes":"功能更新","channel":"stable","pubDate":"2026-03-10T12:00:00Z"}'
|
||||
|
||||
# 4. 查看该应用的所有版本
|
||||
Invoke-RestMethod -Uri "http://localhost:9050/api/apps/test-app/releases"
|
||||
|
||||
# 5. 获取最新版本
|
||||
Invoke-RestMethod -Uri "http://localhost:9050/api/apps/test-app/releases/latest"
|
||||
```
|
||||
|
||||
## API 路由列表
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | /api/releases | 获取所有版本列表 |
|
||||
| GET | /api/releases/:id | 获取单个版本详情 |
|
||||
| POST | /api/releases | 创建新版本 |
|
||||
| PUT | /api/releases/:id | 更新版本信息 |
|
||||
| DELETE | /api/releases/:id | 删除版本 |
|
||||
| GET | /api/apps/:id/releases | 获取指定应用的版本列表 |
|
||||
| GET | /api/apps/:id/releases/latest | 获取应用的最新版本 |
|
||||
|
||||
## 响应格式
|
||||
|
||||
成功响应:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
错误响应:
|
||||
```json
|
||||
{
|
||||
"code": 500,
|
||||
"msg": "错误信息"
|
||||
}
|
||||
```
|
||||
267
docs/UPDATE_CHECK.md
Normal file
267
docs/UPDATE_CHECK.md
Normal file
@@ -0,0 +1,267 @@
|
||||
# 更新检查 API 文档
|
||||
|
||||
## 🔧 修复内容
|
||||
|
||||
### 已修复的 Bug
|
||||
|
||||
1. ✅ **版本比较逻辑错误**
|
||||
- 之前:使用字符串比较(`"1.10.0" < "1.9.0"` 错误)
|
||||
- 现在:实现语义化版本号比较(semver)
|
||||
|
||||
2. ✅ **Asset 查询错误处理缺失**
|
||||
- 之前:查询失败时返回空 URL
|
||||
- 现在:正确处理错误并返回明确的错误信息
|
||||
|
||||
3. ✅ **参数验证缺失**
|
||||
- 现在:验证所有必需参数(app, version, platform, arch)
|
||||
|
||||
4. ✅ **排序逻辑优化**
|
||||
- 之前:按 version 字符串排序
|
||||
- 现在:按 pub_date 排序,更准确
|
||||
|
||||
5. ✅ **响应格式统一**
|
||||
- 现在使用统一的响应格式,并添加 checksum 字段
|
||||
|
||||
## 📝 API 使用
|
||||
|
||||
### 端点
|
||||
|
||||
```
|
||||
GET /api/check-update
|
||||
```
|
||||
|
||||
### 请求参数
|
||||
|
||||
| 参数 | 类型 | 必需 | 说明 |
|
||||
|------|------|------|------|
|
||||
| app | string | ✅ | 应用 ID |
|
||||
| version | string | ✅ | 当前版本号 |
|
||||
| platform | string | ✅ | 平台(windows/darwin/linux) |
|
||||
| arch | string | ✅ | 架构(amd64/arm64/386) |
|
||||
|
||||
### 响应格式
|
||||
|
||||
**有更新:**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": {
|
||||
"update": true,
|
||||
"version": "1.2.0",
|
||||
"notes": "更新说明\n- 新功能\n- Bug修复",
|
||||
"url": "http://localhost:9050/files/releases/2/windows-amd64/app.exe",
|
||||
"checksum": "sha256哈希值"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**无需更新:**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": {
|
||||
"update": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**错误响应:**
|
||||
```json
|
||||
{
|
||||
"code": 500,
|
||||
"msg": "no asset found for this platform and architecture"
|
||||
}
|
||||
```
|
||||
|
||||
## 🧪 测试示例
|
||||
|
||||
### PowerShell 测试
|
||||
|
||||
```powershell
|
||||
# 测试更新检查(需要更新)
|
||||
$params = @{
|
||||
app = "test-app"
|
||||
version = "1.0.0"
|
||||
platform = "windows"
|
||||
arch = "amd64"
|
||||
}
|
||||
$query = ($params.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join "&"
|
||||
Invoke-RestMethod -Uri "http://localhost:9050/api/check-update?$query"
|
||||
|
||||
# 测试更新检查(已是最新)
|
||||
$params = @{
|
||||
app = "test-app"
|
||||
version = "2.0.0"
|
||||
platform = "windows"
|
||||
arch = "amd64"
|
||||
}
|
||||
$query = ($params.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join "&"
|
||||
Invoke-RestMethod -Uri "http://localhost:9050/api/check-update?$query"
|
||||
|
||||
# 测试不存在的平台
|
||||
$params = @{
|
||||
app = "test-app"
|
||||
version = "1.0.0"
|
||||
platform = "windows"
|
||||
arch = "arm" # 假设没有这个架构的版本
|
||||
}
|
||||
$query = ($params.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join "&"
|
||||
Invoke-RestMethod -Uri "http://localhost:9050/api/check-update?$query"
|
||||
```
|
||||
|
||||
### curl 测试
|
||||
|
||||
```bash
|
||||
# 测试更新检查
|
||||
curl "http://localhost:9050/api/check-update?app=test-app&version=1.0.0&platform=windows&arch=amd64"
|
||||
|
||||
# 测试参数缺失
|
||||
curl "http://localhost:9050/api/check-update?app=test-app"
|
||||
```
|
||||
|
||||
## 📦 版本号比较规则
|
||||
|
||||
实现了完整的语义化版本号(semver)比较:
|
||||
|
||||
### 支持的版本格式
|
||||
|
||||
- `1.0.0` - 标准格式
|
||||
- `v1.0.0` - 带 v 前缀
|
||||
- `1.0.0-beta.1` - 预发布版本
|
||||
- `1.0.0+build.123` - 构建元数据
|
||||
|
||||
### 比较规则
|
||||
|
||||
1. **主版本号优先**:`2.0.0 > 1.9.9`
|
||||
2. **次版本号次之**:`1.10.0 > 1.9.0`
|
||||
3. **修订号最后**:`1.0.10 > 1.0.9`
|
||||
4. **预发布版本**:`1.0.0 > 1.0.0-rc.1 > 1.0.0-beta.1 > 1.0.0-alpha.1`
|
||||
|
||||
### 示例
|
||||
|
||||
```go
|
||||
// pkg/util/version.go
|
||||
|
||||
CompareVersion("1.10.0", "1.9.0") // 返回 1 (1.10.0 > 1.9.0)
|
||||
CompareVersion("2.0.0", "1.99.99") // 返回 1 (2.0.0 > 1.99.99)
|
||||
CompareVersion("1.0.0", "1.0.0-beta") // 返回 1 (正式版 > 预发布版)
|
||||
CompareVersion("v1.0.0", "1.0.0") // 返回 0 (相等)
|
||||
|
||||
IsNewerVersion("1.0.0", "1.1.0") // 返回 true
|
||||
IsNewerVersion("1.1.0", "1.0.0") // 返回 false
|
||||
```
|
||||
|
||||
## 🔄 完整更新流程
|
||||
|
||||
### 1. 准备数据
|
||||
|
||||
```powershell
|
||||
# 创建应用
|
||||
Invoke-RestMethod -Method POST -Uri "http://localhost:9050/api/apps" `
|
||||
-ContentType "application/json" `
|
||||
-Body '{"id":"my-app","name":"我的应用","description":"测试"}'
|
||||
|
||||
# 创建版本 1.0.0
|
||||
Invoke-RestMethod -Method POST -Uri "http://localhost:9050/api/releases" `
|
||||
-ContentType "application/json" `
|
||||
-Body '{"appID":"my-app","version":"1.0.0","notes":"初始版本","channel":"stable","pubDate":"2026-03-10T10:00:00Z"}'
|
||||
|
||||
# 上传 1.0.0 的 Windows 版本
|
||||
$form = @{
|
||||
file = Get-Item -Path "D:\app-v1.0.0.exe"
|
||||
platform = "windows"
|
||||
arch = "amd64"
|
||||
}
|
||||
Invoke-RestMethod -Uri "http://localhost:9050/api/releases/1/assets/upload" -Method Post -Form $form
|
||||
|
||||
# 创建版本 1.1.0
|
||||
Invoke-RestMethod -Method POST -Uri "http://localhost:9050/api/releases" `
|
||||
-ContentType "application/json" `
|
||||
-Body '{"appID":"my-app","version":"1.1.0","notes":"新增功能","channel":"stable","pubDate":"2026-03-11T10:00:00Z"}'
|
||||
|
||||
# 上传 1.1.0 的 Windows 版本
|
||||
$form = @{
|
||||
file = Get-Item -Path "D:\app-v1.1.0.exe"
|
||||
platform = "windows"
|
||||
arch = "amd64"
|
||||
}
|
||||
Invoke-RestMethod -Uri "http://localhost:9050/api/releases/2/assets/upload" -Method Post -Form $form
|
||||
```
|
||||
|
||||
### 2. 客户端检查更新
|
||||
|
||||
```powershell
|
||||
# 用户当前使用 1.0.0,检查更新
|
||||
$response = Invoke-RestMethod -Uri "http://localhost:9050/api/check-update?app=my-app&version=1.0.0&platform=windows&arch=amd64"
|
||||
|
||||
if ($response.data.update) {
|
||||
Write-Host "发现新版本: $($response.data.version)"
|
||||
Write-Host "更新说明: $($response.data.notes)"
|
||||
Write-Host "下载地址: $($response.data.url)"
|
||||
Write-Host "校验和: $($response.data.checksum)"
|
||||
|
||||
# 下载更新
|
||||
Invoke-WebRequest -Uri $response.data.url -OutFile "app-update.exe"
|
||||
|
||||
# 验证校验和(可选)
|
||||
$hash = (Get-FileHash -Path "app-update.exe" -Algorithm SHA256).Hash
|
||||
if ($hash.ToLower() -eq $response.data.checksum) {
|
||||
Write-Host "✅ 文件校验通过"
|
||||
} else {
|
||||
Write-Host "❌ 文件校验失败"
|
||||
}
|
||||
} else {
|
||||
Write-Host "✅ 当前已是最新版本"
|
||||
}
|
||||
```
|
||||
|
||||
## 🛡️ 错误处理
|
||||
|
||||
所有可能的错误情况:
|
||||
|
||||
| 错误信息 | 原因 | 解决方法 |
|
||||
|---------|------|---------|
|
||||
| `app is required` | 缺少 app 参数 | 提供应用 ID |
|
||||
| `version is required` | 缺少 version 参数 | 提供当前版本号 |
|
||||
| `platform is required` | 缺少 platform 参数 | 提供平台信息 |
|
||||
| `arch is required` | 缺少 arch 参数 | 提供架构信息 |
|
||||
| `no release found for this app` | 应用没有发布版本 | 先创建版本发布 |
|
||||
| `no asset found for this platform and architecture` | 没有对应平台的安装包 | 上传对应平台的资源 |
|
||||
| `asset URL is empty` | 资源记录存在但 URL 为空 | 检查资源数据完整性 |
|
||||
|
||||
## ✅ 改进总结
|
||||
|
||||
### 修复前的问题
|
||||
|
||||
```go
|
||||
// ❌ 错误的版本比较
|
||||
if req.Version >= latest.Version { // "1.10.0" < "1.9.0" !
|
||||
return &CheckResponse{Update: false}, nil
|
||||
}
|
||||
|
||||
// ❌ 没有错误处理
|
||||
db.Where(...).First(&asset) // 忽略了错误
|
||||
return &CheckResponse{URL: asset.URL} // URL 可能为空
|
||||
```
|
||||
|
||||
### 修复后
|
||||
|
||||
```go
|
||||
// ✅ 正确的版本比较
|
||||
if !util.IsNewerVersion(req.Version, latest.Version) {
|
||||
return &CheckResponse{Update: false}, nil
|
||||
}
|
||||
|
||||
// ✅ 完整的错误处理
|
||||
err = db.Where(...).First(&ast).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("no asset found...")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
```
|
||||
|
||||
现在更新检查功能已经完全可靠!🎉
|
||||
52
go.mod
Normal file
52
go.mod
Normal file
@@ -0,0 +1,52 @@
|
||||
module nebula
|
||||
|
||||
go 1.25.7
|
||||
|
||||
require (
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/gin-gonic/gin v1.12.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||
github.com/glebarez/sqlite v1.11.0 // 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.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/uuid v1.6.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.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.34 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
golang.org/x/arch v0.25.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gorm.io/driver/sqlite v1.6.0 // indirect
|
||||
gorm.io/gorm v1.31.1 // indirect
|
||||
modernc.org/libc v1.69.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.46.1 // indirect
|
||||
)
|
||||
110
go.sum
Normal file
110
go.sum
Normal file
@@ -0,0 +1,110 @@
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
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/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/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||
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/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.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
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.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
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/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/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
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/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.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.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
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.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
|
||||
golang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
modernc.org/libc v1.69.0 h1:YQJ5QMSReTgQ3QFmI0dudfjXIjCcYTUxcH8/9P9f0D8=
|
||||
modernc.org/libc v1.69.0/go.mod h1:YfLLduUEbodNV2xLU5JOnRHBTAHVHsVW3bVYGw0ZCV4=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
77
internal/api/handler/app.go
Normal file
77
internal/api/handler/app.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"nebula/internal/api/response"
|
||||
"nebula/internal/app"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AppHandler struct {
|
||||
service *app.AppService
|
||||
}
|
||||
|
||||
func NewAppHandler(service *app.AppService) *AppHandler {
|
||||
return &AppHandler{service: service}
|
||||
}
|
||||
|
||||
func (h *AppHandler) List(c *gin.Context) {
|
||||
apps, err := h.service.List()
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Ok(c, apps)
|
||||
}
|
||||
|
||||
func (h *AppHandler) Get(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
app, err := h.service.Get(id)
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Ok(c, app)
|
||||
}
|
||||
|
||||
func (h *AppHandler) Create(c *gin.Context) {
|
||||
var app app.App
|
||||
if err := c.ShouldBindJSON(&app); err != nil {
|
||||
response.FailBadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
err := h.service.Create(app)
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OkMsg(c, "created")
|
||||
}
|
||||
|
||||
func (h *AppHandler) Update(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var app app.App
|
||||
if err := c.ShouldBindJSON(&app); err != nil {
|
||||
response.FailBadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
err := h.service.Update(id, map[string]any{
|
||||
"name": app.Name,
|
||||
"description": app.Description,
|
||||
})
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OkMsg(c, "updated")
|
||||
}
|
||||
|
||||
func (h *AppHandler) Delete(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
err := h.service.Delete(id)
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OkMsg(c, "deleted")
|
||||
}
|
||||
212
internal/api/handler/asset.go
Normal file
212
internal/api/handler/asset.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"nebula/internal/api/response"
|
||||
"nebula/internal/asset"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AssetHandler struct {
|
||||
service *asset.AssetService
|
||||
}
|
||||
|
||||
func NewAssetHandler(service *asset.AssetService) *AssetHandler {
|
||||
return &AssetHandler{service: service}
|
||||
}
|
||||
|
||||
// List 获取所有资源列表
|
||||
// GET /api/assets
|
||||
func (h *AssetHandler) List(c *gin.Context) {
|
||||
assets, err := h.service.List()
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Ok(c, assets)
|
||||
}
|
||||
|
||||
// ListByRelease 获取指定发布版本的所有资源
|
||||
// GET /api/releases/:id/assets
|
||||
func (h *AssetHandler) ListByRelease(c *gin.Context) {
|
||||
releaseID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
response.FailBadRequest(c, "invalid release id")
|
||||
return
|
||||
}
|
||||
|
||||
assets, err := h.service.ListByRelease(uint(releaseID))
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Ok(c, assets)
|
||||
}
|
||||
|
||||
// Get 获取单个资源详情
|
||||
// GET /api/assets/:id
|
||||
func (h *AssetHandler) Get(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
response.FailBadRequest(c, "invalid asset id")
|
||||
return
|
||||
}
|
||||
|
||||
ast, err := h.service.Get(uint(id))
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Ok(c, ast)
|
||||
}
|
||||
|
||||
// Upload 上传文件并创建资源
|
||||
// POST /api/releases/:id/assets/upload
|
||||
func (h *AssetHandler) Upload(c *gin.Context) {
|
||||
releaseID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
response.FailBadRequest(c, "invalid release id")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取表单参数
|
||||
platform := c.PostForm("platform")
|
||||
arch := c.PostForm("arch")
|
||||
|
||||
if platform == "" || arch == "" {
|
||||
response.FailBadRequest(c, "platform and arch are required")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取上传的文件
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
response.FailBadRequest(c, "file is required")
|
||||
return
|
||||
}
|
||||
|
||||
// 验证文件扩展名(可选)
|
||||
ext := filepath.Ext(file.Filename)
|
||||
allowedExts := map[string]bool{
|
||||
".exe": true, ".dmg": true, ".pkg": true, ".deb": true,
|
||||
".rpm": true, ".appimage": true, ".zip": true, ".tar.gz": true,
|
||||
".msi": true, ".app": true,
|
||||
}
|
||||
if !allowedExts[ext] {
|
||||
response.FailBadRequest(c, "unsupported file type: "+ext)
|
||||
return
|
||||
}
|
||||
|
||||
// 上传文件
|
||||
ast, err := h.service.Upload(uint(releaseID), platform, arch, file)
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Ok(c, ast)
|
||||
}
|
||||
|
||||
// Create 创建资源记录(用于外部 URL)
|
||||
// POST /api/assets
|
||||
func (h *AssetHandler) Create(c *gin.Context) {
|
||||
var ast asset.Asset
|
||||
if err := c.ShouldBindJSON(&ast); err != nil {
|
||||
response.FailBadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 基本验证
|
||||
if ast.ReleaseID == 0 {
|
||||
response.FailBadRequest(c, "releaseId is required")
|
||||
return
|
||||
}
|
||||
if ast.Platform == "" {
|
||||
response.FailBadRequest(c, "platform is required")
|
||||
return
|
||||
}
|
||||
if ast.Arch == "" {
|
||||
response.FailBadRequest(c, "arch is required")
|
||||
return
|
||||
}
|
||||
if ast.URL == "" {
|
||||
response.FailBadRequest(c, "url is required")
|
||||
return
|
||||
}
|
||||
|
||||
err := h.service.Create(ast)
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OkMsg(c, "created")
|
||||
}
|
||||
|
||||
// Update 更新资源信息
|
||||
// PUT /api/assets/:id
|
||||
func (h *AssetHandler) Update(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
response.FailBadRequest(c, "invalid asset id")
|
||||
return
|
||||
}
|
||||
|
||||
var ast asset.Asset
|
||||
if err := c.ShouldBindJSON(&ast); err != nil {
|
||||
response.FailBadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"platform": ast.Platform,
|
||||
"arch": ast.Arch,
|
||||
"signature": ast.Signature,
|
||||
"checksum": ast.Checksum,
|
||||
}
|
||||
|
||||
err = h.service.Update(uint(id), data)
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OkMsg(c, "updated")
|
||||
}
|
||||
|
||||
// Delete 删除资源
|
||||
// DELETE /api/assets/:id
|
||||
func (h *AssetHandler) Delete(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
response.FailBadRequest(c, "invalid asset id")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.service.Delete(uint(id))
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OkMsg(c, "deleted")
|
||||
}
|
||||
|
||||
// Download 下载资源文件
|
||||
// GET /api/assets/:id/download
|
||||
func (h *AssetHandler) Download(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
response.FailBadRequest(c, "invalid asset id")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取存储路径
|
||||
storagePath, err := h.service.GetStoragePath(uint(id))
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 返回文件(Gin 会自动处理文件下载)
|
||||
c.File(storagePath)
|
||||
}
|
||||
124
internal/api/handler/auth.go
Normal file
124
internal/api/handler/auth.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"nebula/internal/api/response"
|
||||
"nebula/internal/auth"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
service *auth.AuthService
|
||||
}
|
||||
|
||||
func NewAuthHandler(service *auth.AuthService) *AuthHandler {
|
||||
return &AuthHandler{service: service}
|
||||
}
|
||||
|
||||
// Register 用户注册
|
||||
// POST /api/auth/register
|
||||
func (h *AuthHandler) Register(c *gin.Context) {
|
||||
var req auth.RegisterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailBadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
user, tokens, err := h.service.Register(req)
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Ok(c, gin.H{
|
||||
"user": user,
|
||||
"tokens": tokens,
|
||||
})
|
||||
}
|
||||
|
||||
// Login 用户登录
|
||||
// POST /api/auth/login
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
var req auth.LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailBadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
user, tokens, err := h.service.Login(req)
|
||||
if err != nil {
|
||||
response.Fail(c, 401, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Ok(c, gin.H{
|
||||
"user": user,
|
||||
"tokens": tokens,
|
||||
})
|
||||
}
|
||||
|
||||
// RefreshToken 刷新访问令牌
|
||||
// POST /api/auth/refresh
|
||||
func (h *AuthHandler) RefreshToken(c *gin.Context) {
|
||||
var req auth.RefreshTokenRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailBadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
tokens, err := h.service.RefreshToken(req)
|
||||
if err != nil {
|
||||
response.Fail(c, 401, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Ok(c, tokens)
|
||||
}
|
||||
|
||||
// GetProfile 获取当前用户信息
|
||||
// GET /api/auth/profile
|
||||
func (h *AuthHandler) GetProfile(c *gin.Context) {
|
||||
userID, exists := auth.GetCurrentUserID(c)
|
||||
if !exists {
|
||||
response.Fail(c, 401, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.service.GetUserByID(userID)
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Ok(c, user)
|
||||
}
|
||||
|
||||
// ChangePasswordRequest 修改密码请求
|
||||
type ChangePasswordRequest struct {
|
||||
OldPassword string `json:"oldPassword" binding:"required"`
|
||||
NewPassword string `json:"newPassword" binding:"required,min=6"`
|
||||
}
|
||||
|
||||
// ChangePassword 修改密码
|
||||
// POST /api/auth/change-password
|
||||
func (h *AuthHandler) ChangePassword(c *gin.Context) {
|
||||
userID, exists := auth.GetCurrentUserID(c)
|
||||
if !exists {
|
||||
response.Fail(c, 401, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
var req ChangePasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailBadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
err := h.service.ChangePassword(userID, req.OldPassword, req.NewPassword)
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.OkMsg(c, "password changed successfully")
|
||||
}
|
||||
11
internal/api/handler/handle.go
Normal file
11
internal/api/handler/handle.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package handler
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
type Handler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func New(db *gorm.DB) *Handler {
|
||||
return &Handler{db: db}
|
||||
}
|
||||
155
internal/api/handler/release.go
Normal file
155
internal/api/handler/release.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"nebula/internal/api/response"
|
||||
"nebula/internal/release"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ReleaseHandler struct {
|
||||
service *release.ReleaseService
|
||||
}
|
||||
|
||||
func NewReleaseHandler(service *release.ReleaseService) *ReleaseHandler {
|
||||
return &ReleaseHandler{service: service}
|
||||
}
|
||||
|
||||
// List 获取所有版本列表
|
||||
// GET /api/releases
|
||||
func (h *ReleaseHandler) List(c *gin.Context) {
|
||||
releases, err := h.service.List()
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Ok(c, releases)
|
||||
}
|
||||
|
||||
// ListByApp 获取指定应用的所有版本
|
||||
// GET /api/apps/:id/releases
|
||||
func (h *ReleaseHandler) ListByApp(c *gin.Context) {
|
||||
appID := c.Param("id")
|
||||
channel := c.Query("channel") // 可选的渠道过滤
|
||||
|
||||
var releases []release.Release
|
||||
var err error
|
||||
|
||||
if channel != "" {
|
||||
releases, err = h.service.ListByAppAndChannel(appID, channel)
|
||||
} else {
|
||||
releases, err = h.service.ListByApp(appID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Ok(c, releases)
|
||||
}
|
||||
|
||||
// Get 获取单个版本详情
|
||||
// GET /api/releases/:id
|
||||
func (h *ReleaseHandler) Get(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
response.FailBadRequest(c, "invalid release id")
|
||||
return
|
||||
}
|
||||
|
||||
rel, err := h.service.Get(uint(id))
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Ok(c, rel)
|
||||
}
|
||||
|
||||
// GetLatest 获取应用的最新版本
|
||||
// GET /api/apps/:id/releases/latest
|
||||
func (h *ReleaseHandler) GetLatest(c *gin.Context) {
|
||||
appID := c.Param("id")
|
||||
channel := c.Query("channel")
|
||||
|
||||
rel, err := h.service.GetLatest(appID, channel)
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Ok(c, rel)
|
||||
}
|
||||
|
||||
// Create 创建新版本
|
||||
// POST /api/releases
|
||||
func (h *ReleaseHandler) Create(c *gin.Context) {
|
||||
var rel release.Release
|
||||
if err := c.ShouldBindJSON(&rel); err != nil {
|
||||
response.FailBadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 基本验证
|
||||
if rel.AppID == "" {
|
||||
response.FailBadRequest(c, "app_id is required")
|
||||
return
|
||||
}
|
||||
if rel.Version == "" {
|
||||
response.FailBadRequest(c, "version is required")
|
||||
return
|
||||
}
|
||||
|
||||
err := h.service.Create(rel)
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OkMsg(c, "created")
|
||||
}
|
||||
|
||||
// Update 更新版本信息
|
||||
// PUT /api/releases/:id
|
||||
func (h *ReleaseHandler) Update(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
response.FailBadRequest(c, "invalid release id")
|
||||
return
|
||||
}
|
||||
|
||||
var rel release.Release
|
||||
if err := c.ShouldBindJSON(&rel); err != nil {
|
||||
response.FailBadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"version": rel.Version,
|
||||
"notes": rel.Notes,
|
||||
"channel": rel.Channel,
|
||||
"pub_date": rel.PubDate,
|
||||
}
|
||||
|
||||
err = h.service.Update(uint(id), data)
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OkMsg(c, "updated")
|
||||
}
|
||||
|
||||
// Delete 删除版本
|
||||
// DELETE /api/releases/:id
|
||||
func (h *ReleaseHandler) Delete(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
response.FailBadRequest(c, "invalid release id")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.service.Delete(uint(id))
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OkMsg(c, "deleted")
|
||||
}
|
||||
26
internal/api/handler/update.go
Normal file
26
internal/api/handler/update.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"nebula/internal/api/response"
|
||||
"nebula/internal/updater"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (h *Handler) CheckUpdate(c *gin.Context) {
|
||||
req := updater.CheckRequest{
|
||||
App: c.Query("app"),
|
||||
Version: c.Query("version"),
|
||||
Platform: c.Query("platform"),
|
||||
Arch: c.Query("arch"),
|
||||
}
|
||||
|
||||
resp, err := updater.CheckUpdate(h.db, req)
|
||||
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Ok(c, resp)
|
||||
}
|
||||
47
internal/api/response/response.go
Normal file
47
internal/api/response/response.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Result 通用 REST 返回结构
|
||||
type Result struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// 常用业务码
|
||||
const (
|
||||
CodeOk = 0
|
||||
CodeFail = 1
|
||||
CodeInvalid = 400
|
||||
CodeServer = 500
|
||||
)
|
||||
|
||||
// Ok 成功,带数据
|
||||
func Ok(c *gin.Context, data interface{}) {
|
||||
c.JSON(http.StatusOK, Result{Code: CodeOk, Message: "success", Data: data})
|
||||
}
|
||||
|
||||
// OkMsg 成功,自定义消息,无数据
|
||||
func OkMsg(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusOK, Result{Code: CodeOk, Message: message})
|
||||
}
|
||||
|
||||
// Fail 失败,业务码 + 消息
|
||||
func Fail(c *gin.Context, code int, message string) {
|
||||
c.JSON(http.StatusOK, Result{Code: code, Message: message})
|
||||
}
|
||||
|
||||
// FailBadRequest 参数错误 (HTTP 400)
|
||||
func FailBadRequest(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusBadRequest, Result{Code: CodeInvalid, Message: message})
|
||||
}
|
||||
|
||||
// FailServer 服务端错误 (HTTP 500)
|
||||
func FailServer(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusInternalServerError, Result{Code: CodeServer, Message: message})
|
||||
}
|
||||
88
internal/api/router.go
Normal file
88
internal/api/router.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"nebula/internal/api/handler"
|
||||
"nebula/internal/app"
|
||||
"nebula/internal/asset"
|
||||
"nebula/internal/auth"
|
||||
"nebula/internal/release"
|
||||
"nebula/internal/storage"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func RegisterRoutes(r *gin.Engine, db *gorm.DB, stor storage.Storage, jwtService *auth.JWTService, authService *auth.AuthService) {
|
||||
h := handler.New(db)
|
||||
appHandler := handler.NewAppHandler(app.NewService(db))
|
||||
releaseHandler := handler.NewReleaseHandler(release.NewService(db))
|
||||
assetHandler := handler.NewAssetHandler(asset.NewService(db, stor))
|
||||
authHandler := handler.NewAuthHandler(authService)
|
||||
|
||||
api := r.Group("/api")
|
||||
|
||||
{
|
||||
// 认证相关路由(公开)
|
||||
authGroup := api.Group("/auth")
|
||||
{
|
||||
// 移除注册接口 - 只允许配置的管理员登录
|
||||
// authGroup.POST("/register", authHandler.Register)
|
||||
authGroup.POST("/login", authHandler.Login)
|
||||
authGroup.POST("/refresh", authHandler.RefreshToken)
|
||||
}
|
||||
|
||||
// 需要认证的路由
|
||||
authRequired := api.Group("")
|
||||
authRequired.Use(auth.JWTMiddleware(jwtService))
|
||||
{
|
||||
// 用户相关
|
||||
authRequired.GET("/auth/profile", authHandler.GetProfile)
|
||||
authRequired.POST("/auth/change-password", authHandler.ChangePassword)
|
||||
}
|
||||
|
||||
// 更新检查(公开)
|
||||
api.GET("/check-update", h.CheckUpdate)
|
||||
|
||||
// 应用管理(需要认证)
|
||||
appGroup := api.Group("/apps")
|
||||
appGroup.Use(auth.JWTMiddleware(jwtService))
|
||||
{
|
||||
appGroup.GET("", appHandler.List)
|
||||
appGroup.POST("", appHandler.Create)
|
||||
appGroup.GET("/:id", appHandler.Get)
|
||||
appGroup.PUT("/:id", appHandler.Update)
|
||||
appGroup.DELETE("/:id", appHandler.Delete)
|
||||
|
||||
// 应用相关的版本
|
||||
appGroup.GET("/:id/releases", releaseHandler.ListByApp)
|
||||
appGroup.GET("/:id/releases/latest", releaseHandler.GetLatest)
|
||||
}
|
||||
|
||||
// 版本管理(需要认证)
|
||||
releaseGroup := api.Group("/releases")
|
||||
releaseGroup.Use(auth.JWTMiddleware(jwtService))
|
||||
{
|
||||
releaseGroup.GET("", releaseHandler.List)
|
||||
releaseGroup.POST("", releaseHandler.Create)
|
||||
releaseGroup.GET("/:id", releaseHandler.Get)
|
||||
releaseGroup.PUT("/:id", releaseHandler.Update)
|
||||
releaseGroup.DELETE("/:id", releaseHandler.Delete)
|
||||
|
||||
// 版本相关的资源
|
||||
releaseGroup.GET("/:id/assets", assetHandler.ListByRelease)
|
||||
releaseGroup.POST("/:id/assets/upload", assetHandler.Upload)
|
||||
}
|
||||
|
||||
// 资源管理(需要认证)
|
||||
assetGroup := api.Group("/assets")
|
||||
assetGroup.Use(auth.JWTMiddleware(jwtService))
|
||||
{
|
||||
assetGroup.GET("", assetHandler.List)
|
||||
assetGroup.GET("/:id", assetHandler.Get)
|
||||
assetGroup.POST("", assetHandler.Create)
|
||||
assetGroup.PUT("/:id", assetHandler.Update)
|
||||
assetGroup.DELETE("/:id", assetHandler.Delete)
|
||||
assetGroup.GET("/:id/download", assetHandler.Download)
|
||||
}
|
||||
}
|
||||
}
|
||||
11
internal/app/model.go
Normal file
11
internal/app/model.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package app
|
||||
|
||||
import "nebula/types"
|
||||
|
||||
type App struct {
|
||||
ID string `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt types.JSONTime `json:"createdAt"`
|
||||
UpdatedAt types.JSONTime `json:"updatedAt"`
|
||||
}
|
||||
38
internal/app/service.go
Normal file
38
internal/app/service.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package app
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
type AppService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB) *AppService {
|
||||
return &AppService{db: db}
|
||||
}
|
||||
|
||||
func (s *AppService) List() ([]App, error) {
|
||||
var apps []App
|
||||
err := s.db.Find(&apps).Error
|
||||
return apps, err
|
||||
}
|
||||
|
||||
func (s *AppService) Get(id string) (*App, error) {
|
||||
var app App
|
||||
err := s.db.First(&app, "id = ?", id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &app, nil
|
||||
}
|
||||
|
||||
func (s *AppService) Create(app App) error {
|
||||
return s.db.Create(&app).Error
|
||||
}
|
||||
|
||||
func (s *AppService) Update(id string, data map[string]any) error {
|
||||
return s.db.Model(&App{}).Where("id = ?", id).Updates(data).Error
|
||||
}
|
||||
|
||||
func (s *AppService) Delete(id string) error {
|
||||
return s.db.Delete(&App{}, "id = ?", id).Error
|
||||
}
|
||||
18
internal/asset/model.go
Normal file
18
internal/asset/model.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package asset
|
||||
|
||||
import "time"
|
||||
|
||||
type Asset struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ReleaseID uint `gorm:"not null" json:"releaseId"`
|
||||
Platform string `json:"platform"`
|
||||
Arch string `json:"arch"`
|
||||
StoragePath string `json:"-"` // 存储路径,不在 API 中返回
|
||||
|
||||
URL string `gorm:"not null" json:"url"`
|
||||
Signature string `json:"signature"`
|
||||
Checksum string `json:"checksum"`
|
||||
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
255
internal/asset/service.go
Normal file
255
internal/asset/service.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package asset
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"nebula/internal/storage"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AssetService struct {
|
||||
db *gorm.DB
|
||||
storage storage.Storage
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB, storage storage.Storage) *AssetService {
|
||||
return &AssetService{
|
||||
db: db,
|
||||
storage: storage,
|
||||
}
|
||||
}
|
||||
|
||||
// List 获取所有资源列表
|
||||
func (s *AssetService) List() ([]Asset, error) {
|
||||
var assets []Asset
|
||||
err := s.db.Order("created_at DESC").Find(&assets).Error
|
||||
return assets, err
|
||||
}
|
||||
|
||||
// ListByRelease 获取指定发布版本的所有资源
|
||||
func (s *AssetService) ListByRelease(releaseID uint) ([]Asset, error) {
|
||||
var assets []Asset
|
||||
err := s.db.Where("release_id = ?", releaseID).Find(&assets).Error
|
||||
return assets, err
|
||||
}
|
||||
|
||||
// Get 获取单个资源详情
|
||||
func (s *AssetService) Get(id uint) (*Asset, error) {
|
||||
var asset Asset
|
||||
err := s.db.First(&asset, id).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("asset not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &asset, nil
|
||||
}
|
||||
|
||||
// GetByReleaseAndPlatform 根据发布版本、平台和架构获取资源
|
||||
func (s *AssetService) GetByReleaseAndPlatform(releaseID uint, platform, arch string) (*Asset, error) {
|
||||
var asset Asset
|
||||
err := s.db.Where("release_id = ? AND platform = ? AND arch = ?", releaseID, platform, arch).
|
||||
First(&asset).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("asset not found for this platform and architecture")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &asset, nil
|
||||
}
|
||||
|
||||
// Upload 上传文件并创建资源记录
|
||||
func (s *AssetService) Upload(releaseID uint, platform, arch string, file *multipart.FileHeader) (*Asset, error) {
|
||||
// 检查 Release 是否存在
|
||||
var count int64
|
||||
err := s.db.Table("releases").Where("id = ?", releaseID).Count(&count).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count == 0 {
|
||||
return nil, errors.New("release not found")
|
||||
}
|
||||
|
||||
// 检查是否已存在相同平台和架构的资源
|
||||
var existing Asset
|
||||
err = s.db.Where("release_id = ? AND platform = ? AND arch = ?", releaseID, platform, arch).
|
||||
First(&existing).Error
|
||||
if err == nil {
|
||||
return nil, errors.New("asset already exists for this platform and architecture")
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 打开上传的文件
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open uploaded file: %w", err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
// 计算文件校验和
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, src); err != nil {
|
||||
return nil, fmt.Errorf("failed to calculate checksum: %w", err)
|
||||
}
|
||||
checksum := hex.EncodeToString(hash.Sum(nil))
|
||||
|
||||
// 重新打开文件用于保存(因为已经读取过了)
|
||||
src.Close()
|
||||
src, err = file.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to reopen file: %w", err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
// 构建存储路径: releases/{releaseID}/{platform}-{arch}/{filename}
|
||||
storagePath := fmt.Sprintf("releases/%d/%s-%s/%s", releaseID, platform, arch, file.Filename)
|
||||
|
||||
// 保存文件
|
||||
savedPath, err := s.storage.Save(storagePath, src)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to save file: %w", err)
|
||||
}
|
||||
|
||||
// 获取文件访问 URL
|
||||
url := s.storage.GetURL(savedPath)
|
||||
|
||||
// 创建数据库记录
|
||||
asset := Asset{
|
||||
ReleaseID: releaseID,
|
||||
Platform: platform,
|
||||
Arch: arch,
|
||||
URL: url,
|
||||
StoragePath: savedPath,
|
||||
Checksum: checksum,
|
||||
Signature: "", // TODO: 实现文件签名
|
||||
}
|
||||
|
||||
if err := s.db.Create(&asset).Error; err != nil {
|
||||
// 如果数据库操作失败,删除已上传的文件
|
||||
s.storage.Delete(savedPath)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &asset, nil
|
||||
}
|
||||
|
||||
// Create 创建资源记录(用于外部URL)
|
||||
func (s *AssetService) Create(asset Asset) error {
|
||||
// 检查 Release 是否存在
|
||||
var count int64
|
||||
err := s.db.Table("releases").Where("id = ?", asset.ReleaseID).Count(&count).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return errors.New("release not found")
|
||||
}
|
||||
|
||||
// 检查是否已存在相同平台和架构的资源
|
||||
err = s.db.Where("release_id = ? AND platform = ? AND arch = ?", asset.ReleaseID, asset.Platform, asset.Arch).
|
||||
First(&Asset{}).Error
|
||||
if err == nil {
|
||||
return errors.New("asset already exists for this platform and architecture")
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.db.Create(&asset).Error
|
||||
}
|
||||
|
||||
// Update 更新资源信息(不包括文件)
|
||||
func (s *AssetService) Update(id uint, data map[string]any) error {
|
||||
// 检查资源是否存在
|
||||
var asset Asset
|
||||
err := s.db.First(&asset, id).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New("asset not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 如果更新平台或架构,检查是否会造成重复
|
||||
newPlatform, hasPlatform := data["platform"].(string)
|
||||
newArch, hasArch := data["arch"].(string)
|
||||
|
||||
if hasPlatform || hasArch {
|
||||
checkPlatform := asset.Platform
|
||||
checkArch := asset.Arch
|
||||
if hasPlatform {
|
||||
checkPlatform = newPlatform
|
||||
}
|
||||
if hasArch {
|
||||
checkArch = newArch
|
||||
}
|
||||
|
||||
err := s.db.Where("release_id = ? AND platform = ? AND arch = ? AND id != ?",
|
||||
asset.ReleaseID, checkPlatform, checkArch, id).
|
||||
First(&Asset{}).Error
|
||||
if err == nil {
|
||||
return errors.New("asset already exists for this platform and architecture")
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return s.db.Model(&Asset{}).Where("id = ?", id).Updates(data).Error
|
||||
}
|
||||
|
||||
// Delete 删除资源(包括文件)
|
||||
func (s *AssetService) Delete(id uint) error {
|
||||
// 获取资源信息
|
||||
var asset Asset
|
||||
err := s.db.First(&asset, id).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New("asset not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 删除存储的文件
|
||||
if asset.StoragePath != "" {
|
||||
if err := s.storage.Delete(asset.StoragePath); err != nil {
|
||||
// 记录错误但继续删除数据库记录
|
||||
// TODO: 添加日志
|
||||
}
|
||||
}
|
||||
|
||||
// 删除数据库记录
|
||||
return s.db.Delete(&Asset{}, id).Error
|
||||
}
|
||||
|
||||
// GetStoragePath 获取资源的存储路径
|
||||
func (s *AssetService) GetStoragePath(id uint) (string, error) {
|
||||
var asset Asset
|
||||
err := s.db.First(&asset, id).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", errors.New("asset not found")
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
if asset.StoragePath == "" {
|
||||
return "", errors.New("storage path not available")
|
||||
}
|
||||
|
||||
// 如果是本地存储,返回完整路径
|
||||
if localStorage, ok := s.storage.(*storage.LocalStorage); ok {
|
||||
return localStorage.GetFullPath(asset.StoragePath), nil
|
||||
}
|
||||
|
||||
return asset.StoragePath, nil
|
||||
}
|
||||
131
internal/auth/jwt.go
Normal file
131
internal/auth/jwt.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// Claims JWT 声明
|
||||
type Claims struct {
|
||||
UserID string `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// TokenPair 访问令牌和刷新令牌
|
||||
type TokenPair struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
ExpiresIn int64 `json:"expiresIn"` // 秒
|
||||
}
|
||||
|
||||
// JWTService JWT 服务
|
||||
type JWTService struct {
|
||||
secret string
|
||||
accessTokenDuration time.Duration
|
||||
refreshTokenDuration time.Duration
|
||||
}
|
||||
|
||||
// NewJWTService 创建 JWT 服务
|
||||
func NewJWTService(secret string, accessTokenDuration, refreshTokenDuration time.Duration) *JWTService {
|
||||
return &JWTService{
|
||||
secret: secret,
|
||||
accessTokenDuration: accessTokenDuration,
|
||||
refreshTokenDuration: refreshTokenDuration,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateTokenPair 生成访问令牌和刷新令牌
|
||||
func (s *JWTService) GenerateTokenPair(userID, username, role string) (*TokenPair, error) {
|
||||
// 生成访问令牌
|
||||
accessToken, err := s.generateToken(userID, username, role, s.accessTokenDuration)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 生成刷新令牌(更长的过期时间,不包含敏感信息)
|
||||
refreshToken, err := s.generateToken(userID, "", "", s.refreshTokenDuration)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &TokenPair{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresIn: int64(s.accessTokenDuration.Seconds()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// generateToken 生成 token
|
||||
func (s *JWTService) generateToken(userID, username, role string, duration time.Duration) (string, error) {
|
||||
now := time.Now()
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
Role: role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(duration)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(s.secret))
|
||||
}
|
||||
|
||||
// ParseToken 解析 token
|
||||
func (s *JWTService) ParseToken(tokenString string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
// 验证签名算法
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New("unexpected signing method")
|
||||
}
|
||||
return []byte(s.secret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
|
||||
// ValidateToken 验证 token 是否有效
|
||||
func (s *JWTService) ValidateToken(tokenString string) (*Claims, error) {
|
||||
claims, err := s.ParseToken(tokenString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if time.Now().Unix() > claims.ExpiresAt.Unix() {
|
||||
return nil, errors.New("token expired")
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// RefreshAccessToken 使用刷新令牌生成新的访问令牌
|
||||
func (s *JWTService) RefreshAccessToken(refreshToken string, user *User) (*TokenPair, error) {
|
||||
// 验证刷新令牌
|
||||
claims, err := s.ValidateToken(refreshToken)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid refresh token")
|
||||
}
|
||||
|
||||
// 确保刷新令牌属于该用户
|
||||
if claims.UserID != user.ID {
|
||||
return nil, errors.New("token does not match user")
|
||||
}
|
||||
|
||||
// 生成新的 token 对
|
||||
return s.GenerateTokenPair(user.ID, user.Username, user.Role)
|
||||
}
|
||||
104
internal/auth/middleware.go
Normal file
104
internal/auth/middleware.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"nebula/internal/api/response"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// JWTMiddleware JWT 认证中间件
|
||||
func JWTMiddleware(jwtService *JWTService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
response.Fail(c, 401, "missing authorization header")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 解析 Bearer token
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
response.Fail(c, 401, "invalid authorization header format")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := parts[1]
|
||||
|
||||
// 验证 token
|
||||
claims, err := jwtService.ValidateToken(tokenString)
|
||||
if err != nil {
|
||||
response.Fail(c, 401, "invalid or expired token")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 将用户信息保存到上下文
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("username", claims.Username)
|
||||
c.Set("role", claims.Role)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// OptionalJWTMiddleware 可选的 JWT 中间件(token 无效不报错)
|
||||
func OptionalJWTMiddleware(jwtService *JWTService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader != "" {
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) == 2 && parts[0] == "Bearer" {
|
||||
tokenString := parts[1]
|
||||
if claims, err := jwtService.ValidateToken(tokenString); err == nil {
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("username", claims.Username)
|
||||
c.Set("role", claims.Role)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// AdminMiddleware 管理员权限中间件(需要先使用 JWTMiddleware)
|
||||
func AdminMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
role, exists := c.Get("role")
|
||||
if !exists || role != "admin" {
|
||||
response.Fail(c, 403, "admin access required")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// GetCurrentUserID 从上下文获取当前用户 ID
|
||||
func GetCurrentUserID(c *gin.Context) (string, bool) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
return "", false
|
||||
}
|
||||
return userID.(string), true
|
||||
}
|
||||
|
||||
// GetCurrentUsername 从上下文获取当前用户名
|
||||
func GetCurrentUsername(c *gin.Context) (string, bool) {
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
return "", false
|
||||
}
|
||||
return username.(string), true
|
||||
}
|
||||
|
||||
// GetCurrentUserRole 从上下文获取当前用户角色
|
||||
func GetCurrentUserRole(c *gin.Context) (string, bool) {
|
||||
role, exists := c.Get("role")
|
||||
if !exists {
|
||||
return "", false
|
||||
}
|
||||
return role.(string), true
|
||||
}
|
||||
29
internal/auth/model.go
Normal file
29
internal/auth/model.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string `gorm:"primaryKey" json:"id"`
|
||||
Username string `gorm:"uniqueIndex;not null" json:"username"`
|
||||
Email string `gorm:"uniqueIndex;not null" json:"email"`
|
||||
Password string `gorm:"not null" json:"-"` // 不在 JSON 中返回
|
||||
Role string `gorm:"default:'user'" json:"role"` // user, admin
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// HashPassword 加密密码
|
||||
func HashPassword(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
// CheckPassword 验证密码
|
||||
func (u *User) CheckPassword(password string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
209
internal/auth/service.go
Normal file
209
internal/auth/service.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AuthService struct {
|
||||
db *gorm.DB
|
||||
jwtService *JWTService
|
||||
adminUsername string
|
||||
adminPassword string
|
||||
}
|
||||
|
||||
func NewAuthService(db *gorm.DB, jwtService *JWTService, adminUsername, adminPassword string) *AuthService {
|
||||
return &AuthService{
|
||||
db: db,
|
||||
jwtService: jwtService,
|
||||
adminUsername: adminUsername,
|
||||
adminPassword: adminPassword,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRequest 注册请求
|
||||
type RegisterRequest struct {
|
||||
Username string `json:"username" binding:"required,min=3,max=20"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
}
|
||||
|
||||
// LoginRequest 登录请求
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
// RefreshTokenRequest 刷新令牌请求
|
||||
type RefreshTokenRequest struct {
|
||||
RefreshToken string `json:"refreshToken" binding:"required"`
|
||||
}
|
||||
|
||||
// Register 用户注册
|
||||
func (s *AuthService) Register(req RegisterRequest) (*User, *TokenPair, error) {
|
||||
// 检查用户名是否已存在
|
||||
var existingUser User
|
||||
err := s.db.Where("username = ?", req.Username).First(&existingUser).Error
|
||||
if err == nil {
|
||||
return nil, nil, errors.New("username already exists")
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// 检查邮箱是否已存在
|
||||
err = s.db.Where("email = ?", req.Email).First(&existingUser).Error
|
||||
if err == nil {
|
||||
return nil, nil, errors.New("email already exists")
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// 加密密码
|
||||
hashedPassword, err := HashPassword(req.Password)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// 创建用户
|
||||
user := User{
|
||||
ID: uuid.New().String(),
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
Password: hashedPassword,
|
||||
Role: "user", // 默认角色
|
||||
}
|
||||
|
||||
if err := s.db.Create(&user).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// 生成 token
|
||||
tokens, err := s.jwtService.GenerateTokenPair(user.ID, user.Username, user.Role)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return &user, tokens, nil
|
||||
}
|
||||
|
||||
// Login 用户登录
|
||||
func (s *AuthService) Login(req LoginRequest) (*User, *TokenPair, error) {
|
||||
// 1. 先检查是否为配置的管理员账号
|
||||
if req.Username == s.adminUsername && req.Password == s.adminPassword {
|
||||
// 管理员登录成功,创建虚拟用户对象
|
||||
adminUser := &User{
|
||||
ID: "admin",
|
||||
Username: s.adminUsername,
|
||||
Email: s.adminUsername + "@admin.local",
|
||||
Role: "admin",
|
||||
}
|
||||
|
||||
// 生成 token
|
||||
tokens, err := s.jwtService.GenerateTokenPair(adminUser.ID, adminUser.Username, adminUser.Role)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return adminUser, tokens, nil
|
||||
}
|
||||
|
||||
// 2. 如果不是管理员,查找数据库中的用户
|
||||
var user User
|
||||
err := s.db.Where("username = ? OR email = ?", req.Username, req.Username).First(&user).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil, errors.New("invalid username or password")
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
if !user.CheckPassword(req.Password) {
|
||||
return nil, nil, errors.New("invalid username or password")
|
||||
}
|
||||
|
||||
// 生成 token
|
||||
tokens, err := s.jwtService.GenerateTokenPair(user.ID, user.Username, user.Role)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return &user, tokens, nil
|
||||
}
|
||||
|
||||
// RefreshToken 刷新访问令牌
|
||||
func (s *AuthService) RefreshToken(req RefreshTokenRequest) (*TokenPair, error) {
|
||||
// 解析刷新令牌获取用户 ID
|
||||
claims, err := s.jwtService.ParseToken(req.RefreshToken)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid refresh token")
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
var user User
|
||||
err = s.db.First(&user, "id = ?", claims.UserID).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 使用刷新令牌生成新的 token 对
|
||||
tokens, err := s.jwtService.RefreshAccessToken(req.RefreshToken, &user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
// GetUserByID 根据 ID 获取用户
|
||||
func (s *AuthService) GetUserByID(userID string) (*User, error) {
|
||||
// 特殊处理管理员
|
||||
if userID == "admin" {
|
||||
return &User{
|
||||
ID: "admin",
|
||||
Username: s.adminUsername,
|
||||
Email: s.adminUsername + "@admin.local",
|
||||
Role: "admin",
|
||||
}, nil
|
||||
}
|
||||
|
||||
var user User
|
||||
err := s.db.First(&user, "id = ?", userID).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// ChangePassword 修改密码
|
||||
func (s *AuthService) ChangePassword(userID, oldPassword, newPassword string) error {
|
||||
var user User
|
||||
err := s.db.First(&user, "id = ?", userID).Error
|
||||
if err != nil {
|
||||
return errors.New("user not found")
|
||||
}
|
||||
|
||||
// 验证旧密码
|
||||
if !user.CheckPassword(oldPassword) {
|
||||
return errors.New("invalid old password")
|
||||
}
|
||||
|
||||
// 加密新密码
|
||||
hashedPassword, err := HashPassword(newPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 更新密码
|
||||
return s.db.Model(&user).Update("password", hashedPassword).Error
|
||||
}
|
||||
154
internal/config/config.go
Normal file
154
internal/config/config.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server struct {
|
||||
Address string `yaml:"address"`
|
||||
Mode string `yaml:"mode"` // dev, prod
|
||||
} `yaml:"server"`
|
||||
Database struct {
|
||||
DSN string `yaml:"dsn"`
|
||||
} `yaml:"database"`
|
||||
Storage struct {
|
||||
Type string `yaml:"type"` // local, oss, s3
|
||||
BasePath string `yaml:"basePath"` // 本地存储的基础路径
|
||||
BaseURL string `yaml:"baseUrl"` // 文件访问的 URL 前缀
|
||||
} `yaml:"storage"`
|
||||
JWT struct {
|
||||
Secret string `yaml:"secret"`
|
||||
AccessTokenDuration int `yaml:"accessTokenDuration"` // 秒
|
||||
RefreshTokenDuration int `yaml:"refreshTokenDuration"` // 秒
|
||||
} `yaml:"jwt"`
|
||||
Frontend struct {
|
||||
Enabled bool `yaml:"enabled"` // 是否启用前端静态资源服务
|
||||
Path string `yaml:"path"` // 前端静态资源路径
|
||||
} `yaml:"frontend"`
|
||||
Admin struct {
|
||||
Username string `yaml:"username"` // 管理员用户名
|
||||
Password string `yaml:"password"` // 管理员密码
|
||||
} `yaml:"admin"`
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
// 确定配置文件路径
|
||||
configFile := getConfigFile()
|
||||
|
||||
// 读取配置文件
|
||||
data, err := os.ReadFile(configFile)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to read config file %s: %v, using defaults\n", configFile, err)
|
||||
return loadDefaults()
|
||||
}
|
||||
|
||||
// 解析 YAML
|
||||
config := &Config{}
|
||||
if err := yaml.Unmarshal(data, config); err != nil {
|
||||
fmt.Printf("Warning: failed to parse config file %s: %v, using defaults\n", configFile, err)
|
||||
return loadDefaults()
|
||||
}
|
||||
|
||||
// 环境变量覆盖
|
||||
applyEnvOverrides(config)
|
||||
|
||||
// 自动设置 frontend.enabled(生产模式才启用)
|
||||
if config.Server.Mode == "prod" && !config.Frontend.Enabled {
|
||||
config.Frontend.Enabled = true
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
func getConfigFile() string {
|
||||
// 1. 优先使用环境变量指定的配置文件
|
||||
if configFile := os.Getenv("CONFIG_FILE"); configFile != "" {
|
||||
return configFile
|
||||
}
|
||||
|
||||
// 2. 根据 SERVER_MODE 选择配置文件
|
||||
mode := os.Getenv("SERVER_MODE")
|
||||
if mode == "" {
|
||||
mode = "dev"
|
||||
}
|
||||
|
||||
// 检查 config.{mode}.yaml
|
||||
modeConfig := fmt.Sprintf("config.%s.yaml", mode)
|
||||
if _, err := os.Stat(modeConfig); err == nil {
|
||||
return modeConfig
|
||||
}
|
||||
|
||||
// 3. 使用默认 config.yaml
|
||||
if _, err := os.Stat("config.yaml"); err == nil {
|
||||
return "config.yaml"
|
||||
}
|
||||
|
||||
// 4. 都不存在,使用默认配置
|
||||
return ""
|
||||
}
|
||||
|
||||
func loadDefaults() *Config {
|
||||
config := &Config{}
|
||||
config.Server.Address = ":9050"
|
||||
config.Server.Mode = "dev"
|
||||
config.Database.DSN = "nebula.db"
|
||||
config.Storage.Type = "local"
|
||||
config.Storage.BasePath = "./uploads"
|
||||
config.Storage.BaseURL = "http://localhost:9050/files"
|
||||
config.JWT.Secret = "dev-secret-key-change-in-production"
|
||||
config.JWT.AccessTokenDuration = 7200 // 2 hours
|
||||
config.JWT.RefreshTokenDuration = 604800 // 7 days
|
||||
config.Frontend.Enabled = false
|
||||
config.Frontend.Path = "./web/dist"
|
||||
config.Admin.Username = "admin"
|
||||
config.Admin.Password = "admin123" // 默认密码,生产环境必须修改
|
||||
return config
|
||||
}
|
||||
|
||||
func applyEnvOverrides(config *Config) {
|
||||
if val := os.Getenv("SERVER_ADDRESS"); val != "" {
|
||||
config.Server.Address = val
|
||||
}
|
||||
if val := os.Getenv("SERVER_MODE"); val != "" {
|
||||
config.Server.Mode = val
|
||||
}
|
||||
if val := os.Getenv("DATABASE_DSN"); val != "" {
|
||||
config.Database.DSN = val
|
||||
}
|
||||
if val := os.Getenv("STORAGE_TYPE"); val != "" {
|
||||
config.Storage.Type = val
|
||||
}
|
||||
if val := os.Getenv("STORAGE_BASE_PATH"); val != "" {
|
||||
config.Storage.BasePath = val
|
||||
}
|
||||
if val := os.Getenv("STORAGE_BASE_URL"); val != "" {
|
||||
config.Storage.BaseURL = val
|
||||
}
|
||||
if val := os.Getenv("JWT_SECRET"); val != "" {
|
||||
config.JWT.Secret = val
|
||||
}
|
||||
if val := os.Getenv("FRONTEND_PATH"); val != "" {
|
||||
config.Frontend.Path = val
|
||||
}
|
||||
if val := os.Getenv("ADMIN_USERNAME"); val != "" {
|
||||
config.Admin.Username = val
|
||||
}
|
||||
if val := os.Getenv("ADMIN_PASSWORD"); val != "" {
|
||||
config.Admin.Password = val
|
||||
}
|
||||
}
|
||||
|
||||
// GetAccessTokenDuration 返回 Access Token 有效期
|
||||
func (c *Config) GetAccessTokenDuration() time.Duration {
|
||||
return time.Duration(c.JWT.AccessTokenDuration) * time.Second
|
||||
}
|
||||
|
||||
// GetRefreshTokenDuration 返回 Refresh Token 有效期
|
||||
func (c *Config) GetRefreshTokenDuration() time.Duration {
|
||||
return time.Duration(c.JWT.RefreshTokenDuration) * time.Second
|
||||
}
|
||||
27
internal/db/db.go
Normal file
27
internal/db/db.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"nebula/internal/app"
|
||||
"nebula/internal/asset"
|
||||
"nebula/internal/auth"
|
||||
"nebula/internal/release"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func Init(dsn string) *gorm.DB {
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
db.AutoMigrate(
|
||||
&app.App{},
|
||||
&release.Release{},
|
||||
&asset.Asset{},
|
||||
&auth.User{},
|
||||
)
|
||||
|
||||
return db
|
||||
}
|
||||
14
internal/release/model.go
Normal file
14
internal/release/model.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package release
|
||||
|
||||
import "time"
|
||||
|
||||
type Release struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
AppID string `gorm:"not null"`
|
||||
Version string `gorm:"not null"`
|
||||
Notes string
|
||||
Channel string
|
||||
PubDate time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
141
internal/release/service.go
Normal file
141
internal/release/service.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package release
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ReleaseService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB) *ReleaseService {
|
||||
return &ReleaseService{db: db}
|
||||
}
|
||||
|
||||
// List 获取所有版本列表
|
||||
func (s *ReleaseService) List() ([]Release, error) {
|
||||
var releases []Release
|
||||
err := s.db.Order("created_at DESC").Find(&releases).Error
|
||||
return releases, err
|
||||
}
|
||||
|
||||
// ListByApp 获取指定应用的所有版本
|
||||
func (s *ReleaseService) ListByApp(appID string) ([]Release, error) {
|
||||
var releases []Release
|
||||
err := s.db.Where("app_id = ?", appID).Order("created_at DESC").Find(&releases).Error
|
||||
return releases, err
|
||||
}
|
||||
|
||||
// ListByAppAndChannel 获取指定应用和渠道的版本
|
||||
func (s *ReleaseService) ListByAppAndChannel(appID, channel string) ([]Release, error) {
|
||||
var releases []Release
|
||||
query := s.db.Where("app_id = ?", appID)
|
||||
if channel != "" {
|
||||
query = query.Where("channel = ?", channel)
|
||||
}
|
||||
err := query.Order("created_at DESC").Find(&releases).Error
|
||||
return releases, err
|
||||
}
|
||||
|
||||
// Get 获取单个版本详情
|
||||
func (s *ReleaseService) Get(id uint) (*Release, error) {
|
||||
var release Release
|
||||
err := s.db.First(&release, id).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("release not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &release, nil
|
||||
}
|
||||
|
||||
// GetLatest 获取应用的最新版本
|
||||
func (s *ReleaseService) GetLatest(appID string, channel string) (*Release, error) {
|
||||
var release Release
|
||||
query := s.db.Where("app_id = ?", appID)
|
||||
if channel != "" {
|
||||
query = query.Where("channel = ?", channel)
|
||||
}
|
||||
err := query.Order("pub_date DESC").First(&release).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("no release found for this app")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &release, nil
|
||||
}
|
||||
|
||||
// Create 创建新版本
|
||||
func (s *ReleaseService) Create(release Release) error {
|
||||
// 检查应用是否存在
|
||||
var count int64
|
||||
err := s.db.Table("apps").Where("id = ?", release.AppID).Count(&count).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return errors.New("app not found")
|
||||
}
|
||||
|
||||
// 检查版本是否已存在
|
||||
err = s.db.Where("app_id = ? AND version = ?", release.AppID, release.Version).First(&Release{}).Error
|
||||
if err == nil {
|
||||
return errors.New("version already exists for this app")
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.db.Create(&release).Error
|
||||
}
|
||||
|
||||
// Update 更新版本信息
|
||||
func (s *ReleaseService) Update(id uint, data map[string]any) error {
|
||||
// 检查版本是否存在
|
||||
var release Release
|
||||
err := s.db.First(&release, id).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New("release not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 如果更新版本号,检查是否重复
|
||||
if newVersion, ok := data["version"].(string); ok {
|
||||
if newVersion != release.Version {
|
||||
err := s.db.Where("app_id = ? AND version = ? AND id != ?", release.AppID, newVersion, id).
|
||||
First(&Release{}).Error
|
||||
if err == nil {
|
||||
return errors.New("version already exists for this app")
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return s.db.Model(&Release{}).Where("id = ?", id).Updates(data).Error
|
||||
}
|
||||
|
||||
// Delete 删除版本
|
||||
func (s *ReleaseService) Delete(id uint) error {
|
||||
// 检查版本是否存在
|
||||
var release Release
|
||||
err := s.db.First(&release, id).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New("release not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO: 同时删除相关的 Assets
|
||||
// 可以通过数据库外键级联删除,或者在这里手动删除
|
||||
|
||||
return s.db.Delete(&Release{}, id).Error
|
||||
}
|
||||
87
internal/storage/local.go
Normal file
87
internal/storage/local.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// LocalStorage 本地文件系统存储实现
|
||||
type LocalStorage struct {
|
||||
basePath string // 文件存储的根目录
|
||||
baseURL string // 文件访问的 URL 前缀
|
||||
}
|
||||
|
||||
// NewLocalStorage 创建本地存储实例
|
||||
func NewLocalStorage(basePath, baseURL string) (*LocalStorage, error) {
|
||||
// 确保存储目录存在
|
||||
if err := os.MkdirAll(basePath, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create storage directory: %w", err)
|
||||
}
|
||||
|
||||
return &LocalStorage{
|
||||
basePath: basePath,
|
||||
baseURL: baseURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Save 保存文件到本地
|
||||
func (s *LocalStorage) Save(filename string, content io.Reader) (string, error) {
|
||||
// 构建完整的文件路径
|
||||
fullPath := filepath.Join(s.basePath, filename)
|
||||
|
||||
// 确保目标目录存在
|
||||
dir := filepath.Dir(fullPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create directory: %w", err)
|
||||
}
|
||||
|
||||
// 创建文件
|
||||
file, err := os.Create(fullPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// 复制内容
|
||||
if _, err := io.Copy(file, content); err != nil {
|
||||
// 如果保存失败,尝试删除文件
|
||||
os.Remove(fullPath)
|
||||
return "", fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
|
||||
// 返回相对路径(存储在数据库中)
|
||||
return filename, nil
|
||||
}
|
||||
|
||||
// Delete 删除本地文件
|
||||
func (s *LocalStorage) Delete(path string) error {
|
||||
fullPath := filepath.Join(s.basePath, path)
|
||||
if err := os.Remove(fullPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to delete file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetURL 获取文件的访问 URL
|
||||
func (s *LocalStorage) GetURL(path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
// 将路径中的反斜杠替换为正斜杠(Windows 兼容)
|
||||
path = filepath.ToSlash(path)
|
||||
return s.baseURL + "/" + path
|
||||
}
|
||||
|
||||
// Exists 检查文件是否存在
|
||||
func (s *LocalStorage) Exists(path string) bool {
|
||||
fullPath := filepath.Join(s.basePath, path)
|
||||
_, err := os.Stat(fullPath)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// GetFullPath 获取文件的完整本地路径
|
||||
func (s *LocalStorage) GetFullPath(path string) string {
|
||||
return filepath.Join(s.basePath, path)
|
||||
}
|
||||
20
internal/storage/storage.go
Normal file
20
internal/storage/storage.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"io"
|
||||
)
|
||||
|
||||
// Storage 定义文件存储接口,支持本地存储、OSS、S3 等
|
||||
type Storage interface {
|
||||
// Save 保存文件,返回存储路径
|
||||
Save(filename string, content io.Reader) (string, error)
|
||||
|
||||
// Delete 删除文件
|
||||
Delete(path string) error
|
||||
|
||||
// GetURL 获取文件的访问 URL
|
||||
GetURL(path string) string
|
||||
|
||||
// Exists 检查文件是否存在
|
||||
Exists(path string) bool
|
||||
}
|
||||
84
internal/updater/service.go
Normal file
84
internal/updater/service.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"nebula/internal/asset"
|
||||
"nebula/internal/release"
|
||||
"nebula/pkg/util"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type CheckRequest struct {
|
||||
App string
|
||||
Version string
|
||||
Platform string
|
||||
Arch string
|
||||
}
|
||||
|
||||
type CheckResponse struct {
|
||||
Update bool `json:"update"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Checksum string `json:"checksum,omitempty"`
|
||||
}
|
||||
|
||||
func CheckUpdate(db *gorm.DB, req CheckRequest) (*CheckResponse, error) {
|
||||
// 参数验证
|
||||
if req.App == "" {
|
||||
return nil, errors.New("app is required")
|
||||
}
|
||||
if req.Version == "" {
|
||||
return nil, errors.New("version is required")
|
||||
}
|
||||
if req.Platform == "" {
|
||||
return nil, errors.New("platform is required")
|
||||
}
|
||||
if req.Arch == "" {
|
||||
return nil, errors.New("arch is required")
|
||||
}
|
||||
|
||||
// 查找最新版本(按发布日期排序)
|
||||
var latest release.Release
|
||||
err := db.Where("app_id = ?", req.App).
|
||||
Order("pub_date DESC").
|
||||
First(&latest).Error
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("no release found for this app")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 比较版本号
|
||||
if !util.IsNewerVersion(req.Version, latest.Version) {
|
||||
return &CheckResponse{Update: false}, nil
|
||||
}
|
||||
|
||||
// 查找对应平台和架构的资源
|
||||
var ast asset.Asset
|
||||
err = db.Where("release_id = ? AND platform = ? AND arch = ?",
|
||||
latest.ID, req.Platform, req.Arch).First(&ast).Error
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("no asset found for this platform and architecture")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 验证资源 URL 是否存在
|
||||
if ast.URL == "" {
|
||||
return nil, errors.New("asset URL is empty")
|
||||
}
|
||||
|
||||
return &CheckResponse{
|
||||
Update: true,
|
||||
Version: latest.Version,
|
||||
Notes: latest.Notes,
|
||||
URL: ast.URL,
|
||||
Checksum: ast.Checksum,
|
||||
}, nil
|
||||
}
|
||||
110
pkg/util/version.go
Normal file
110
pkg/util/version.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CompareVersion 比较两个语义化版本号
|
||||
// 返回值:1 表示 v1 > v2,-1 表示 v1 < v2,0 表示 v1 == v2
|
||||
// 支持格式:1.0.0, v1.0.0, 1.0.0-beta.1, 1.0.0+build.123
|
||||
func CompareVersion(v1, v2 string) int {
|
||||
// 移除 v 前缀
|
||||
v1 = strings.TrimPrefix(v1, "v")
|
||||
v2 = strings.TrimPrefix(v2, "v")
|
||||
|
||||
// 分离主版本号和预发布/构建元数据
|
||||
v1Main, v1Pre := splitVersion(v1)
|
||||
v2Main, v2Pre := splitVersion(v2)
|
||||
|
||||
// 比较主版本号
|
||||
result := compareMainVersion(v1Main, v2Main)
|
||||
if result != 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
// 主版本号相同,比较预发布版本
|
||||
return comparePreRelease(v1Pre, v2Pre)
|
||||
}
|
||||
|
||||
// splitVersion 分离主版本号和预发布标识
|
||||
func splitVersion(version string) (main, preRelease string) {
|
||||
// 移除构建元数据 (+build.xxx)
|
||||
if idx := strings.Index(version, "+"); idx != -1 {
|
||||
version = version[:idx]
|
||||
}
|
||||
|
||||
// 分离预发布标识 (-beta.1)
|
||||
if idx := strings.Index(version, "-"); idx != -1 {
|
||||
return version[:idx], version[idx+1:]
|
||||
}
|
||||
|
||||
return version, ""
|
||||
}
|
||||
|
||||
// compareMainVersion 比较主版本号 (major.minor.patch)
|
||||
func compareMainVersion(v1, v2 string) int {
|
||||
parts1 := strings.Split(v1, ".")
|
||||
parts2 := strings.Split(v2, ".")
|
||||
|
||||
// 补齐到相同长度
|
||||
maxLen := len(parts1)
|
||||
if len(parts2) > maxLen {
|
||||
maxLen = len(parts2)
|
||||
}
|
||||
|
||||
for i := 0; i < maxLen; i++ {
|
||||
var num1, num2 int
|
||||
|
||||
if i < len(parts1) {
|
||||
num1, _ = strconv.Atoi(parts1[i])
|
||||
}
|
||||
if i < len(parts2) {
|
||||
num2, _ = strconv.Atoi(parts2[i])
|
||||
}
|
||||
|
||||
if num1 > num2 {
|
||||
return 1
|
||||
}
|
||||
if num1 < num2 {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// comparePreRelease 比较预发布版本
|
||||
// 没有预发布标识的版本 > 有预发布标识的版本
|
||||
// 例如:1.0.0 > 1.0.0-beta
|
||||
func comparePreRelease(pre1, pre2 string) int {
|
||||
// 都没有预发布标识,相等
|
||||
if pre1 == "" && pre2 == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
// v1 是正式版,v2 是预发布版
|
||||
if pre1 == "" && pre2 != "" {
|
||||
return 1
|
||||
}
|
||||
|
||||
// v1 是预发布版,v2 是正式版
|
||||
if pre1 != "" && pre2 == "" {
|
||||
return -1
|
||||
}
|
||||
|
||||
// 都是预发布版,按字典序比较
|
||||
if pre1 < pre2 {
|
||||
return -1
|
||||
}
|
||||
if pre1 > pre2 {
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// IsNewerVersion 检查 newVer 是否比 currentVer 新
|
||||
func IsNewerVersion(currentVer, newVer string) bool {
|
||||
return CompareVersion(newVer, currentVer) > 0
|
||||
}
|
||||
33
scripts/build-prod.bat
Normal file
33
scripts/build-prod.bat
Normal file
@@ -0,0 +1,33 @@
|
||||
@echo off
|
||||
echo Building Nebula for production...
|
||||
|
||||
echo.
|
||||
echo Building frontend...
|
||||
cd web
|
||||
call pnpm build
|
||||
if errorlevel 1 (
|
||||
echo Frontend build failed!
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
cd ..
|
||||
|
||||
echo.
|
||||
echo Building backend...
|
||||
go build -o nebula-server.exe ./cmd/nebula-server
|
||||
if errorlevel 1 (
|
||||
echo Backend build failed!
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ========================================
|
||||
echo Build completed successfully!
|
||||
echo ========================================
|
||||
echo.
|
||||
echo To run in production mode:
|
||||
echo set SERVER_MODE=prod
|
||||
echo .\nebula-server.exe
|
||||
echo.
|
||||
echo Or use the start script:
|
||||
echo .\scripts\start.bat
|
||||
33
scripts/build-prod.sh
Normal file
33
scripts/build-prod.sh
Normal file
@@ -0,0 +1,33 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "Building Nebula for production..."
|
||||
|
||||
echo ""
|
||||
echo "Building frontend..."
|
||||
cd web
|
||||
pnpm build
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Frontend build failed!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd ..
|
||||
|
||||
echo ""
|
||||
echo "Building backend..."
|
||||
go build -o nebula-server ./cmd/nebula-server
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Backend build failed!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "Build completed successfully!"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
echo "To run in production mode:"
|
||||
echo " SERVER_MODE=prod ./nebula-server"
|
||||
echo ""
|
||||
echo "Or use the start script:"
|
||||
echo " ./scripts/start.sh"
|
||||
24
scripts/build.bat
Normal file
24
scripts/build.bat
Normal file
@@ -0,0 +1,24 @@
|
||||
@echo off
|
||||
setlocal
|
||||
set BINARY=nebula-server
|
||||
set OUT_DIR=dist
|
||||
set WEB_DIR=web
|
||||
|
||||
echo Building frontend...
|
||||
cd %WEB_DIR%
|
||||
call pnpm run build
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo Frontend build failed.
|
||||
exit /b 1
|
||||
)
|
||||
cd ..
|
||||
|
||||
if not exist %OUT_DIR% mkdir %OUT_DIR%
|
||||
echo Building %BINARY%...
|
||||
go build -o %OUT_DIR%\%BINARY%.exe ./cmd/nebula-server/
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo Build failed.
|
||||
exit /b 1
|
||||
)
|
||||
echo Build done: %OUT_DIR%\%BINARY%.exe
|
||||
endlocal
|
||||
16
scripts/build.sh
Normal file
16
scripts/build.sh
Normal file
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
BINARY=nebula-server
|
||||
OUT_DIR=dist
|
||||
WEB_DIR=web
|
||||
|
||||
echo "Building frontend..."
|
||||
(cd "$WEB_DIR" && pnpm run build)
|
||||
|
||||
[[ -d "$OUT_DIR" ]] || mkdir -p "$OUT_DIR"
|
||||
SUFFIX=""
|
||||
[[ "$GOOS" == "windows" ]] && SUFFIX=".exe"
|
||||
|
||||
echo "Building $BINARY..."
|
||||
go build -o "$OUT_DIR/${BINARY}${SUFFIX}" ./cmd/nebula-server/
|
||||
echo "Build done: $OUT_DIR/${BINARY}${SUFFIX}"
|
||||
14
scripts/dev.bat
Normal file
14
scripts/dev.bat
Normal file
@@ -0,0 +1,14 @@
|
||||
@echo off
|
||||
echo Starting Nebula development environment...
|
||||
|
||||
echo.
|
||||
echo Starting backend server (dev mode)...
|
||||
start "Nebula Backend" cmd /c "set SERVER_MODE=dev && go run ./cmd/nebula-server/main.go"
|
||||
|
||||
echo Waiting for backend to start...
|
||||
timeout /t 3 /nobreak >nul
|
||||
|
||||
echo.
|
||||
echo Starting frontend (Vite dev server)...
|
||||
cd web
|
||||
pnpm dev
|
||||
27
scripts/dev.sh
Normal file
27
scripts/dev.sh
Normal file
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "Starting Nebula development environment..."
|
||||
|
||||
echo ""
|
||||
echo "Starting backend server (dev mode)..."
|
||||
SERVER_MODE=dev go run ./cmd/nebula-server/main.go &
|
||||
BACKEND_PID=$!
|
||||
|
||||
echo "Backend started with PID: $BACKEND_PID"
|
||||
echo "Waiting for backend to start..."
|
||||
sleep 3
|
||||
|
||||
echo ""
|
||||
echo "Starting frontend (Vite dev server)..."
|
||||
cd web
|
||||
pnpm dev
|
||||
|
||||
# Cleanup function to kill backend when script exits
|
||||
cleanup() {
|
||||
echo ""
|
||||
echo "Shutting down backend server..."
|
||||
kill $BACKEND_PID 2>/dev/null
|
||||
exit
|
||||
}
|
||||
|
||||
trap cleanup INT TERM
|
||||
5
scripts/start.bat
Normal file
5
scripts/start.bat
Normal file
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
echo Starting Nebula in production mode...
|
||||
|
||||
set SERVER_MODE=prod
|
||||
nebula-server.exe
|
||||
5
scripts/start.sh
Normal file
5
scripts/start.sh
Normal file
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "Starting Nebula in production mode..."
|
||||
|
||||
SERVER_MODE=prod ./nebula-server
|
||||
73
types/json_time.go
Normal file
73
types/json_time.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type JSONTime time.Time
|
||||
|
||||
const TimeFormat = "2006-01-02 15:04:05"
|
||||
|
||||
// timeFormatWithFrac SQLite 等可能带小数秒
|
||||
const timeFormatWithFrac = "2006-01-02 15:04:05.999999999"
|
||||
|
||||
func (t JSONTime) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(time.Time(t).Format(TimeFormat))
|
||||
}
|
||||
|
||||
func (t *JSONTime) UnmarshalJSON(data []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
parsed, err := time.Parse(TimeFormat, s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*t = JSONTime(parsed)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Value 实现 driver.Valuer,供 GORM 写入数据库
|
||||
func (t JSONTime) Value() (driver.Value, error) {
|
||||
return time.Time(t), nil
|
||||
}
|
||||
|
||||
// Scan 实现 sql.Scanner,供 GORM 从数据库读取
|
||||
func (t *JSONTime) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*t = JSONTime(time.Time{})
|
||||
return nil
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case time.Time:
|
||||
*t = JSONTime(v)
|
||||
return nil
|
||||
case []byte:
|
||||
tm, err := parseTime(string(v))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*t = JSONTime(tm)
|
||||
return nil
|
||||
case string:
|
||||
tm, err := parseTime(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*t = JSONTime(tm)
|
||||
return nil
|
||||
default:
|
||||
*t = JSONTime(time.Time{})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func parseTime(s string) (time.Time, error) {
|
||||
if t, err := time.Parse(timeFormatWithFrac, s); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
return time.Parse(TimeFormat, s)
|
||||
}
|
||||
8
web/.editorconfig
Normal file
8
web/.editorconfig
Normal file
@@ -0,0 +1,8 @@
|
||||
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
|
||||
charset = utf-8
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
end_of_line = lf
|
||||
max_line_length = 100
|
||||
1
web/.gitattributes
vendored
Normal file
1
web/.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
39
web/.gitignore
vendored
Normal file
39
web/.gitignore
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Cypress
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Vitest
|
||||
__screenshots__/
|
||||
|
||||
# Vite
|
||||
*.timestamp-*-*.mjs
|
||||
10
web/.oxlintrc.json
Normal file
10
web/.oxlintrc.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue"],
|
||||
"env": {
|
||||
"browser": true
|
||||
},
|
||||
"categories": {
|
||||
"correctness": "error"
|
||||
}
|
||||
}
|
||||
6
web/.prettierrc.json
Normal file
6
web/.prettierrc.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
9
web/.vscode/extensions.json
vendored
Normal file
9
web/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"EditorConfig.EditorConfig",
|
||||
"oxc.oxc-vscode",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
48
web/README.md
Normal file
48
web/README.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# web
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Recommended Browser Setup
|
||||
|
||||
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
||||
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
||||
- Firefox:
|
||||
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
||||
|
||||
## Type Support for `.vue` Imports in TS
|
||||
|
||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Type-Check, Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### Lint with [ESLint](https://eslint.org/)
|
||||
|
||||
```sh
|
||||
pnpm lint
|
||||
```
|
||||
1
web/env.d.ts
vendored
Normal file
1
web/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
26
web/eslint.config.ts
Normal file
26
web/eslint.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import pluginOxlint from 'eslint-plugin-oxlint'
|
||||
import skipFormatting from 'eslint-config-prettier/flat'
|
||||
|
||||
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
|
||||
// import { configureVueProject } from '@vue/eslint-config-typescript'
|
||||
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
|
||||
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
|
||||
|
||||
export default defineConfigWithVueTs(
|
||||
{
|
||||
name: 'app/files-to-lint',
|
||||
files: ['**/*.{vue,ts,mts,tsx}'],
|
||||
},
|
||||
|
||||
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
||||
|
||||
...pluginVue.configs['flat/essential'],
|
||||
vueTsConfigs.recommended,
|
||||
|
||||
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
|
||||
|
||||
skipFormatting,
|
||||
)
|
||||
13
web/index.html
Normal file
13
web/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vite App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
48
web/package.json
Normal file
48
web/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build",
|
||||
"lint": "run-s lint:*",
|
||||
"lint:oxlint": "oxlint . --fix",
|
||||
"lint:eslint": "eslint . --fix --cache",
|
||||
"format": "prettier --write --experimental-cli src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"lucide-vue-next": "^0.577.0",
|
||||
"pinia": "^3.0.4",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"vue": "^3.5.29",
|
||||
"vue-router": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node24": "^24.0.4",
|
||||
"@types/node": "^24.11.0",
|
||||
"@vitejs/plugin-vue": "^6.0.4",
|
||||
"@vue/eslint-config-typescript": "^14.7.0",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"eslint": "^10.0.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-oxlint": "~1.50.0",
|
||||
"eslint-plugin-vue": "~10.8.0",
|
||||
"jiti": "^2.6.1",
|
||||
"naive-ui": "^2.44.1",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"oxlint": "~1.50.0",
|
||||
"prettier": "3.8.1",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-vue-devtools": "^8.0.6",
|
||||
"vue-tsc": "^3.2.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
}
|
||||
3793
web/pnpm-lock.yaml
generated
Normal file
3793
web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
2
web/pnpm-workspace.yaml
Normal file
2
web/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
BIN
web/public/favicon.ico
Normal file
BIN
web/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
30
web/src/App.vue
Normal file
30
web/src/App.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
|
||||
<template>
|
||||
<n-config-provider :locale="zhCN" :date-locale="dateZhCN">
|
||||
<div class="h-screen w-full">
|
||||
<router-view />
|
||||
</div>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
zhCN,
|
||||
dateZhCN,
|
||||
NConfigProvider
|
||||
} from 'naive-ui'
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Reset */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
152
web/src/layout/default.vue
Normal file
152
web/src/layout/default.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<n-layout class="h-screen w-full" has-sider>
|
||||
<!-- 侧边栏 -->
|
||||
<n-layout-sider
|
||||
bordered
|
||||
collapse-mode="width"
|
||||
:collapsed-width="64"
|
||||
:width="240"
|
||||
:collapsed="collapsed"
|
||||
show-trigger
|
||||
@collapse="collapsed = true"
|
||||
@expand="collapsed = false"
|
||||
class="h-full"
|
||||
>
|
||||
<div class="flex h-[64px] items-center justify-center overflow-hidden whitespace-nowrap px-4 py-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-8 w-8 flex-shrink-0 rounded-lg bg-gradient-to-br from-emerald-500 to-teal-600 flex items-center justify-center text-white font-bold">N</div>
|
||||
<span v-show="!collapsed" class="text-lg font-bold text-slate-700 transition-opacity duration-300">Nebula</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-menu
|
||||
:collapsed="collapsed"
|
||||
:collapsed-width="64"
|
||||
:collapsed-icon-size="22"
|
||||
:options="menuOptions"
|
||||
:value="activeKey"
|
||||
@update:value="handleMenuUpdate"
|
||||
/>
|
||||
</n-layout-sider>
|
||||
|
||||
<n-layout class="h-full flex flex-col">
|
||||
<!-- 顶部 Header -->
|
||||
<n-layout-header bordered class="h-16 flex items-center justify-between px-6 bg-white z-10">
|
||||
<!-- 左侧:面包屑等 -->
|
||||
<div class="flex items-center">
|
||||
<!-- 可以在这里放面包屑 -->
|
||||
</div>
|
||||
|
||||
<!-- 右侧:用户菜单 -->
|
||||
<div class="flex items-center gap-4">
|
||||
<n-dropdown :options="userOptions" @select="handleUserSelect">
|
||||
<div class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 px-2 py-1 rounded transition-colors">
|
||||
<n-avatar round size="small" class="bg-emerald-500 text-white">
|
||||
A
|
||||
</n-avatar>
|
||||
<span class="text-sm font-medium text-slate-700">Admin</span>
|
||||
</div>
|
||||
</n-dropdown>
|
||||
</div>
|
||||
</n-layout-header>
|
||||
|
||||
<!-- 主要内容区域 -->
|
||||
<n-layout-content content-style="padding: 24px; min-height: calc(100vh - 64px);" class="bg-gray-50/50">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</n-layout-content>
|
||||
</n-layout>
|
||||
</n-layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, h, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import {
|
||||
NLayout,
|
||||
NLayoutSider,
|
||||
NLayoutHeader,
|
||||
NLayoutContent,
|
||||
NMenu,
|
||||
NIcon,
|
||||
NButton,
|
||||
NAvatar,
|
||||
NDropdown,
|
||||
type MenuOption
|
||||
} from 'naive-ui'
|
||||
import { LayoutDashboard, AppWindow, Settings, LogOut } from 'lucide-vue-next'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
function renderIcon(icon: any) {
|
||||
return () => h(NIcon, null, { default: () => h(icon) })
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const collapsed = ref(false)
|
||||
|
||||
// 菜单配置
|
||||
const menuOptions: MenuOption[] = [
|
||||
{
|
||||
label: '概览',
|
||||
key: 'home',
|
||||
icon: renderIcon(LayoutDashboard)
|
||||
},
|
||||
{
|
||||
label: '应用管理', // Uncommented and updated
|
||||
key: 'apps',
|
||||
icon: renderIcon(AppWindow)
|
||||
},
|
||||
{
|
||||
label: '系统设置', // Uncommented and updated
|
||||
key: 'settings',
|
||||
icon: renderIcon(Settings)
|
||||
}
|
||||
]
|
||||
|
||||
// 当前选中的菜单项,根据路由自动匹配
|
||||
const activeKey = computed(() => {
|
||||
return (route.name as string) || 'home'
|
||||
})
|
||||
|
||||
const handleMenuUpdate = (key: string) => {
|
||||
if (key === 'home') {
|
||||
router.push('/')
|
||||
} else {
|
||||
router.push({ name: key })
|
||||
}
|
||||
}
|
||||
|
||||
// 用户下拉菜单
|
||||
const userOptions = [
|
||||
{
|
||||
label: '退出登录',
|
||||
key: 'logout',
|
||||
icon: renderIcon(LogOut)
|
||||
}
|
||||
]
|
||||
|
||||
const handleUserSelect = (key: string) => {
|
||||
if (key === 'logout') {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
14
web/src/main.ts
Normal file
14
web/src/main.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import './style/index.css'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
45
web/src/router/index.ts
Normal file
45
web/src/router/index.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/view/auth/login.vue'),
|
||||
meta: { requiresAuth: false },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layout/default.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'home',
|
||||
component: () => import('@/view/home/index.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// 路由守卫
|
||||
router.beforeEach((to, from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
const isAuthenticated = authStore.isAuthenticated()
|
||||
|
||||
if (to.meta.requiresAuth && !isAuthenticated) {
|
||||
// 需要登录但未登录,跳转到登录页
|
||||
next('/login')
|
||||
} else if (to.path === '/login' && isAuthenticated) {
|
||||
// 已登录访问登录页,跳转到首页
|
||||
next('/')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
58
web/src/stores/auth.ts
Normal file
58
web/src/stores/auth.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { User, TokenPair } from '@/types/api'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref<User | null>(null)
|
||||
const accessToken = ref<string | null>(null)
|
||||
const refreshToken = ref<string | null>(null)
|
||||
|
||||
// 初始化时从 localStorage 恢复状态
|
||||
const init = () => {
|
||||
const storedUser = localStorage.getItem('user')
|
||||
const storedAccessToken = localStorage.getItem('accessToken')
|
||||
const storedRefreshToken = localStorage.getItem('refreshToken')
|
||||
|
||||
if (storedUser && storedAccessToken) {
|
||||
user.value = JSON.parse(storedUser)
|
||||
accessToken.value = storedAccessToken
|
||||
refreshToken.value = storedRefreshToken
|
||||
}
|
||||
}
|
||||
|
||||
const login = (userData: User, tokens: TokenPair) => {
|
||||
user.value = userData
|
||||
accessToken.value = tokens.accessToken
|
||||
refreshToken.value = tokens.refreshToken
|
||||
|
||||
// 保存到 localStorage
|
||||
localStorage.setItem('user', JSON.stringify(userData))
|
||||
localStorage.setItem('accessToken', tokens.accessToken)
|
||||
localStorage.setItem('refreshToken', tokens.refreshToken)
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
user.value = null
|
||||
accessToken.value = null
|
||||
refreshToken.value = null
|
||||
|
||||
localStorage.removeItem('user')
|
||||
localStorage.removeItem('accessToken')
|
||||
localStorage.removeItem('refreshToken')
|
||||
}
|
||||
|
||||
const isAuthenticated = () => {
|
||||
return !!accessToken.value
|
||||
}
|
||||
|
||||
init()
|
||||
|
||||
return {
|
||||
user,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
login,
|
||||
logout,
|
||||
isAuthenticated,
|
||||
}
|
||||
})
|
||||
12
web/src/stores/counter.ts
Normal file
12
web/src/stores/counter.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useCounterStore = defineStore('counter', () => {
|
||||
const count = ref(0)
|
||||
const doubleCount = computed(() => count.value * 2)
|
||||
function increment() {
|
||||
count.value++
|
||||
}
|
||||
|
||||
return { count, doubleCount, increment }
|
||||
})
|
||||
1
web/src/style/index.css
Normal file
1
web/src/style/index.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
31
web/src/types/api.ts
Normal file
31
web/src/types/api.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// API 类型定义
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
username: string
|
||||
email: string
|
||||
role: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface TokenPair {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
expiresIn: number
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
user: User
|
||||
tokens: TokenPair
|
||||
}
|
||||
48
web/src/utils/api.ts
Normal file
48
web/src/utils/api.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { ApiResponse, LoginRequest, LoginResponse } from '@/types/api'
|
||||
|
||||
const API_BASE = '/api'
|
||||
|
||||
class ApiClient {
|
||||
private getAuthHeader(): HeadersInit {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||
}
|
||||
|
||||
async request<T>(url: string, options: RequestInit = {}): Promise<ApiResponse<T>> {
|
||||
const response = await fetch(`${API_BASE}${url}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...this.getAuthHeader(),
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// 认证相关
|
||||
async login(data: LoginRequest): Promise<ApiResponse<LoginResponse>> {
|
||||
return this.request<LoginResponse>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
async getProfile(): Promise<ApiResponse<any>> {
|
||||
return this.request('/auth/profile')
|
||||
}
|
||||
|
||||
async refreshToken(refreshToken: string): Promise<ApiResponse<any>> {
|
||||
return this.request('/auth/refresh', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiClient()
|
||||
195
web/src/view/auth/login.vue
Normal file
195
web/src/view/auth/login.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<div class="flex min-h-screen items-center justify-center bg-gray-100 p-4">
|
||||
<!-- 背景装饰圆 -->
|
||||
<div class="fixed top-0 left-0 w-full h-full overflow-hidden pointer-events-none z-0">
|
||||
<div class="absolute -top-[10%] -left-[5%] w-[600px] h-[600px] bg-indigo-200 rounded-full blur-[120px] opacity-40"></div>
|
||||
<div class="absolute top-[40%] -right-[5%] w-[500px] h-[500px] bg-emerald-200 rounded-full blur-[100px] opacity-40"></div>
|
||||
</div>
|
||||
|
||||
<!-- 主卡片容器 -->
|
||||
<div class="relative z-10 flex w-full max-w-5xl overflow-hidden rounded-3xl bg-white shadow-2xl shadow-slate-200/50">
|
||||
|
||||
<!-- 左侧装饰区 -->
|
||||
<div class="hidden w-1/2 flex-col justify-between bg-slate-900 p-12 text-white lg:flex relative overflow-hidden">
|
||||
<!-- 装饰背景 -->
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-slate-800 via-slate-900 to-black z-0"></div>
|
||||
<div class="absolute top-0 right-0 w-[400px] h-[400px] bg-gradient-to-br from-emerald-500/20 to-teal-500/20 rounded-full blur-[80px] pointer-events-none z-0 transform translate-x-1/3 -translate-y-1/3"></div>
|
||||
|
||||
<!-- 内容 -->
|
||||
<div class="relative z-10">
|
||||
<div class="flex items-center gap-3 mb-8">
|
||||
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-emerald-400 to-teal-600 flex items-center justify-center">
|
||||
<span class="font-bold text-white">N</span>
|
||||
</div>
|
||||
<span class="text-xl font-bold tracking-wider">NEBULA</span>
|
||||
</div>
|
||||
|
||||
<h2 class="text-4xl font-bold leading-tight mb-6">
|
||||
Manage Your Updates <br/>
|
||||
<span class="text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 to-teal-300">Efficiently & Securely</span>
|
||||
</h2>
|
||||
|
||||
<p class="text-slate-400 leading-relaxed max-w-sm">
|
||||
Nebula 是一个现代化的应用更新管理平台,为您提供稳定、高效的版本分发服务。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 mt-12 grid grid-cols-2 gap-6">
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-2xl font-bold text-white">0.0k+</h3>
|
||||
<p class="text-xs text-slate-500 uppercase tracking-wide">Active Users</p>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-2xl font-bold text-emerald-400">99.9%</h3>
|
||||
<p class="text-xs text-slate-500 uppercase tracking-wide">Uptime</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧表单区 -->
|
||||
<div class="flex w-full flex-col justify-center bg-white p-8 lg:w-1/2 lg:p-16">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-slate-800 lg:text-3xl">欢迎回来</h1>
|
||||
<p class="mt-2 text-sm text-slate-500">请输入您的账号密码登录系统</p>
|
||||
</div>
|
||||
|
||||
<n-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
size="large"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<n-form-item path="username" label="用户名">
|
||||
<n-input
|
||||
v-model:value="formData.username"
|
||||
placeholder="admin"
|
||||
:disabled="loading"
|
||||
@keydown.enter.prevent
|
||||
>
|
||||
<template #prefix>
|
||||
<n-icon><User /></n-icon>
|
||||
</template>
|
||||
</n-input>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item path="password" label="密码">
|
||||
<n-input
|
||||
v-model:value="formData.password"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
placeholder="admin123"
|
||||
:disabled="loading"
|
||||
@keydown.enter.prevent
|
||||
@keypress.enter="handleSubmit"
|
||||
>
|
||||
<template #prefix>
|
||||
<n-icon><Lock /></n-icon>
|
||||
</template>
|
||||
</n-input>
|
||||
</n-form-item>
|
||||
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="text-sm text-slate-500">
|
||||
<span class="cursor-pointer hover:text-emerald-600 transition-colors">忘记密码?</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="mb-6">
|
||||
<n-alert type="error" closable :show-icon="true" class="text-sm">
|
||||
{{ error }}
|
||||
</n-alert>
|
||||
</div>
|
||||
|
||||
<n-button
|
||||
type="primary"
|
||||
block
|
||||
size="large"
|
||||
:loading="loading"
|
||||
@click="handleSubmit"
|
||||
class="h-12 text-base font-semibold shadow-emerald-500/20 shadow-lg"
|
||||
>
|
||||
登 录
|
||||
</n-button>
|
||||
</n-form>
|
||||
|
||||
<div class="mt-8 text-center text-xs text-slate-400">
|
||||
默认测试账号:admin / admin123
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api } from '@/utils/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import {
|
||||
NForm,
|
||||
NFormItem,
|
||||
NInput,
|
||||
NButton,
|
||||
NAlert,
|
||||
NIcon,
|
||||
type FormInst,
|
||||
type FormRules
|
||||
} from 'naive-ui'
|
||||
import { User, Lock } from 'lucide-vue-next'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const formRef = ref<FormInst | null>(null)
|
||||
|
||||
const formData = ref({
|
||||
username: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const rules: FormRules = {
|
||||
username: {
|
||||
required: true,
|
||||
message: '请输入用户名',
|
||||
trigger: ['input', 'blur']
|
||||
},
|
||||
password: {
|
||||
required: true,
|
||||
message: '请输入密码',
|
||||
trigger: ['input', 'blur']
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const response = await api.login(formData.value)
|
||||
|
||||
if (response.code === 0) {
|
||||
// 保存登录状态
|
||||
authStore.login(response.data.user, response.data.tokens)
|
||||
|
||||
// 跳转到首页
|
||||
router.push('/')
|
||||
} else {
|
||||
error.value = response.message || '登录失败'
|
||||
}
|
||||
} catch (err: any) {
|
||||
error.value = err.message || '网络错误,请稍后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
113
web/src/view/home/index.vue
Normal file
113
web/src/view/home/index.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- 头部欢迎语 -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-800">仪表盘 / Dashboard</h1>
|
||||
<p class="text-slate-500 mt-1">欢迎回来,Admin</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<n-button type="primary" size="medium" class="px-6 rounded-lg shadow-sm">
|
||||
<template #icon>
|
||||
<n-icon><Plus /></n-icon>
|
||||
</template>
|
||||
发布新版本
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
<!-- 应用总数 -->
|
||||
<n-card :bordered="false" class="rounded-xl shadow-sm hover:shadow-md transition-shadow duration-300">
|
||||
<template #header>
|
||||
<span class="text-sm font-medium text-slate-500">应用总数</span>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<div class="p-2 bg-indigo-50 rounded-lg text-indigo-600">
|
||||
<n-icon size="20"><Smartphone /></n-icon>
|
||||
</div>
|
||||
</template>
|
||||
<div class="mt-2">
|
||||
<span class="text-3xl font-bold text-slate-800">12</span>
|
||||
<span class="ml-2 text-xs font-medium text-emerald-600 bg-emerald-50 px-2 py-0.5 rounded-full">+2 本周</span>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<!-- 活跃版本 -->
|
||||
<n-card :bordered="false" class="rounded-xl shadow-sm hover:shadow-md transition-shadow duration-300">
|
||||
<template #header>
|
||||
<span class="text-sm font-medium text-slate-500">活跃版本</span>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<div class="p-2 bg-emerald-50 rounded-lg text-emerald-600">
|
||||
<n-icon size="20"><Rocket /></n-icon>
|
||||
</div>
|
||||
</template>
|
||||
<div class="mt-2">
|
||||
<span class="text-3xl font-bold text-slate-800">48</span>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<!-- 下载次数 -->
|
||||
<n-card :bordered="false" class="rounded-xl shadow-sm hover:shadow-md transition-shadow duration-300">
|
||||
<template #header>
|
||||
<span class="text-sm font-medium text-slate-500">总下载量</span>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<div class="p-2 bg-blue-50 rounded-lg text-blue-600">
|
||||
<n-icon size="20"><Download /></n-icon>
|
||||
</div>
|
||||
</template>
|
||||
<div class="mt-2">
|
||||
<span class="text-3xl font-bold text-slate-800">1.2k</span>
|
||||
<span class="ml-2 text-xs font-medium text-rose-600 bg-rose-50 px-2 py-0.5 rounded-full">-5% 同比</span>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<!-- 存储用量 -->
|
||||
<n-card :bordered="false" class="rounded-xl shadow-sm hover:shadow-md transition-shadow duration-300">
|
||||
<template #header>
|
||||
<span class="text-sm font-medium text-slate-500">存储用量</span>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<div class="p-2 bg-amber-50 rounded-lg text-amber-600">
|
||||
<n-icon size="20"><HardDrive /></n-icon>
|
||||
</div>
|
||||
</template>
|
||||
<div class="mt-2">
|
||||
<span class="text-3xl font-bold text-slate-800">4.2GB</span>
|
||||
<span class="ml-2 text-xs font-medium text-slate-400">/ 10GB</span>
|
||||
</div>
|
||||
</n-card>
|
||||
</div>
|
||||
|
||||
<!-- 最近更新表格 -->
|
||||
<n-card title="最近发布动态" :bordered="false" class="rounded-xl shadow-sm mt-6">
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
:bordered="false"
|
||||
class="mt-4"
|
||||
/>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { NCard, NButton, NDataTable, NIcon } from 'naive-ui'
|
||||
import { Smartphone, Rocket, Download, HardDrive, Plus } from 'lucide-vue-next'
|
||||
|
||||
const data = ref([
|
||||
{ app: 'Nebula App', version: 'v1.0.2', time: '2026-03-09 14:00', status: '已发布' },
|
||||
{ app: 'Nebula Admin', version: 'v2.1.0', time: '2026-03-08 10:00', status: '审核中' }
|
||||
])
|
||||
|
||||
const columns = ref([
|
||||
{ title: '应用名称', key: 'app' },
|
||||
{ title: '版本', key: 'version' },
|
||||
{ title: '发布时间', key: 'time' },
|
||||
{ title: '状态', key: 'status' }
|
||||
])
|
||||
</script>
|
||||
18
web/tsconfig.app.json
Normal file
18
web/tsconfig.app.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
// Extra safety for array and object lookups, but may have false positives.
|
||||
"noUncheckedIndexedAccess": true,
|
||||
|
||||
// Path mapping for cleaner imports.
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||
// Specified here to keep it out of the root directory.
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
|
||||
}
|
||||
}
|
||||
11
web/tsconfig.json
Normal file
11
web/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
28
web/tsconfig.node.json
Normal file
28
web/tsconfig.node.json
Normal file
@@ -0,0 +1,28 @@
|
||||
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
|
||||
{
|
||||
"extends": "@tsconfig/node24/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"nightwatch.conf.*",
|
||||
"playwright.config.*",
|
||||
"eslint.config.*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
// Most tools use transpilation instead of Node.js's native type-stripping.
|
||||
// Bundler mode provides a smoother developer experience.
|
||||
"module": "preserve",
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
// Include Node.js types and avoid accidentally including other `@types/*` packages.
|
||||
"types": ["node"],
|
||||
|
||||
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
|
||||
"noEmit": true,
|
||||
|
||||
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||
// Specified here to keep it out of the root directory.
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
|
||||
}
|
||||
}
|
||||
42
web/vite.config.ts
Normal file
42
web/vite.config.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
tailwindcss(),
|
||||
],
|
||||
server: {
|
||||
port: 9040,
|
||||
open: true, // 自动打开浏览器
|
||||
proxy: {
|
||||
// 代理 API 请求到后端服务器
|
||||
'/api': {
|
||||
target: 'http://localhost:9050',
|
||||
changeOrigin: true,
|
||||
},
|
||||
// 代理文件下载请求
|
||||
'/files': {
|
||||
target: 'http://localhost:9050',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets',
|
||||
// 清理输出目录
|
||||
emptyOutDir: true,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user