init: 初始化项目

This commit is contained in:
0264408
2026-03-10 16:26:48 +08:00
commit 57e0ef2cf6
79 changed files with 8943 additions and 0 deletions

8
.env.dev Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

Binary file not shown.

312
docs/ASSET_API.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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=

View 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")
}

View 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)
}

View 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")
}

View 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}
}

View 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")
}

View 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)
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)
}

View 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
}

View 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
}

BIN
nebula.db Normal file

Binary file not shown.

110
pkg/util/version.go Normal file
View File

@@ -0,0 +1,110 @@
package util
import (
"strconv"
"strings"
)
// CompareVersion 比较两个语义化版本号
// 返回值1 表示 v1 > v2-1 表示 v1 < v20 表示 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
* text=auto eol=lf

39
web/.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

26
web/eslint.config.ts Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

2
web/pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
onlyBuiltDependencies:
- esbuild

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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
@import "tailwindcss";

31
web/src/types/api.ts Normal file
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

28
web/tsconfig.node.json Normal file
View 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
View 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))
},
},
})