init: 初始化项目
This commit is contained in:
77
internal/api/handler/app.go
Normal file
77
internal/api/handler/app.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"nebula/internal/api/response"
|
||||
"nebula/internal/app"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AppHandler struct {
|
||||
service *app.AppService
|
||||
}
|
||||
|
||||
func NewAppHandler(service *app.AppService) *AppHandler {
|
||||
return &AppHandler{service: service}
|
||||
}
|
||||
|
||||
func (h *AppHandler) List(c *gin.Context) {
|
||||
apps, err := h.service.List()
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Ok(c, apps)
|
||||
}
|
||||
|
||||
func (h *AppHandler) Get(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
app, err := h.service.Get(id)
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Ok(c, app)
|
||||
}
|
||||
|
||||
func (h *AppHandler) Create(c *gin.Context) {
|
||||
var app app.App
|
||||
if err := c.ShouldBindJSON(&app); err != nil {
|
||||
response.FailBadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
err := h.service.Create(app)
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OkMsg(c, "created")
|
||||
}
|
||||
|
||||
func (h *AppHandler) Update(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var app app.App
|
||||
if err := c.ShouldBindJSON(&app); err != nil {
|
||||
response.FailBadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
err := h.service.Update(id, map[string]any{
|
||||
"name": app.Name,
|
||||
"description": app.Description,
|
||||
})
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OkMsg(c, "updated")
|
||||
}
|
||||
|
||||
func (h *AppHandler) Delete(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
err := h.service.Delete(id)
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OkMsg(c, "deleted")
|
||||
}
|
||||
212
internal/api/handler/asset.go
Normal file
212
internal/api/handler/asset.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"nebula/internal/api/response"
|
||||
"nebula/internal/asset"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AssetHandler struct {
|
||||
service *asset.AssetService
|
||||
}
|
||||
|
||||
func NewAssetHandler(service *asset.AssetService) *AssetHandler {
|
||||
return &AssetHandler{service: service}
|
||||
}
|
||||
|
||||
// List 获取所有资源列表
|
||||
// GET /api/assets
|
||||
func (h *AssetHandler) List(c *gin.Context) {
|
||||
assets, err := h.service.List()
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Ok(c, assets)
|
||||
}
|
||||
|
||||
// ListByRelease 获取指定发布版本的所有资源
|
||||
// GET /api/releases/:id/assets
|
||||
func (h *AssetHandler) ListByRelease(c *gin.Context) {
|
||||
releaseID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
response.FailBadRequest(c, "invalid release id")
|
||||
return
|
||||
}
|
||||
|
||||
assets, err := h.service.ListByRelease(uint(releaseID))
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Ok(c, assets)
|
||||
}
|
||||
|
||||
// Get 获取单个资源详情
|
||||
// GET /api/assets/:id
|
||||
func (h *AssetHandler) Get(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
response.FailBadRequest(c, "invalid asset id")
|
||||
return
|
||||
}
|
||||
|
||||
ast, err := h.service.Get(uint(id))
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Ok(c, ast)
|
||||
}
|
||||
|
||||
// Upload 上传文件并创建资源
|
||||
// POST /api/releases/:id/assets/upload
|
||||
func (h *AssetHandler) Upload(c *gin.Context) {
|
||||
releaseID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
response.FailBadRequest(c, "invalid release id")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取表单参数
|
||||
platform := c.PostForm("platform")
|
||||
arch := c.PostForm("arch")
|
||||
|
||||
if platform == "" || arch == "" {
|
||||
response.FailBadRequest(c, "platform and arch are required")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取上传的文件
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
response.FailBadRequest(c, "file is required")
|
||||
return
|
||||
}
|
||||
|
||||
// 验证文件扩展名(可选)
|
||||
ext := filepath.Ext(file.Filename)
|
||||
allowedExts := map[string]bool{
|
||||
".exe": true, ".dmg": true, ".pkg": true, ".deb": true,
|
||||
".rpm": true, ".appimage": true, ".zip": true, ".tar.gz": true,
|
||||
".msi": true, ".app": true,
|
||||
}
|
||||
if !allowedExts[ext] {
|
||||
response.FailBadRequest(c, "unsupported file type: "+ext)
|
||||
return
|
||||
}
|
||||
|
||||
// 上传文件
|
||||
ast, err := h.service.Upload(uint(releaseID), platform, arch, file)
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Ok(c, ast)
|
||||
}
|
||||
|
||||
// Create 创建资源记录(用于外部 URL)
|
||||
// POST /api/assets
|
||||
func (h *AssetHandler) Create(c *gin.Context) {
|
||||
var ast asset.Asset
|
||||
if err := c.ShouldBindJSON(&ast); err != nil {
|
||||
response.FailBadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 基本验证
|
||||
if ast.ReleaseID == 0 {
|
||||
response.FailBadRequest(c, "releaseId is required")
|
||||
return
|
||||
}
|
||||
if ast.Platform == "" {
|
||||
response.FailBadRequest(c, "platform is required")
|
||||
return
|
||||
}
|
||||
if ast.Arch == "" {
|
||||
response.FailBadRequest(c, "arch is required")
|
||||
return
|
||||
}
|
||||
if ast.URL == "" {
|
||||
response.FailBadRequest(c, "url is required")
|
||||
return
|
||||
}
|
||||
|
||||
err := h.service.Create(ast)
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OkMsg(c, "created")
|
||||
}
|
||||
|
||||
// Update 更新资源信息
|
||||
// PUT /api/assets/:id
|
||||
func (h *AssetHandler) Update(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
response.FailBadRequest(c, "invalid asset id")
|
||||
return
|
||||
}
|
||||
|
||||
var ast asset.Asset
|
||||
if err := c.ShouldBindJSON(&ast); err != nil {
|
||||
response.FailBadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"platform": ast.Platform,
|
||||
"arch": ast.Arch,
|
||||
"signature": ast.Signature,
|
||||
"checksum": ast.Checksum,
|
||||
}
|
||||
|
||||
err = h.service.Update(uint(id), data)
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OkMsg(c, "updated")
|
||||
}
|
||||
|
||||
// Delete 删除资源
|
||||
// DELETE /api/assets/:id
|
||||
func (h *AssetHandler) Delete(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
response.FailBadRequest(c, "invalid asset id")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.service.Delete(uint(id))
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OkMsg(c, "deleted")
|
||||
}
|
||||
|
||||
// Download 下载资源文件
|
||||
// GET /api/assets/:id/download
|
||||
func (h *AssetHandler) Download(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
response.FailBadRequest(c, "invalid asset id")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取存储路径
|
||||
storagePath, err := h.service.GetStoragePath(uint(id))
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 返回文件(Gin 会自动处理文件下载)
|
||||
c.File(storagePath)
|
||||
}
|
||||
124
internal/api/handler/auth.go
Normal file
124
internal/api/handler/auth.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"nebula/internal/api/response"
|
||||
"nebula/internal/auth"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
service *auth.AuthService
|
||||
}
|
||||
|
||||
func NewAuthHandler(service *auth.AuthService) *AuthHandler {
|
||||
return &AuthHandler{service: service}
|
||||
}
|
||||
|
||||
// Register 用户注册
|
||||
// POST /api/auth/register
|
||||
func (h *AuthHandler) Register(c *gin.Context) {
|
||||
var req auth.RegisterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailBadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
user, tokens, err := h.service.Register(req)
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Ok(c, gin.H{
|
||||
"user": user,
|
||||
"tokens": tokens,
|
||||
})
|
||||
}
|
||||
|
||||
// Login 用户登录
|
||||
// POST /api/auth/login
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
var req auth.LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailBadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
user, tokens, err := h.service.Login(req)
|
||||
if err != nil {
|
||||
response.Fail(c, 401, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Ok(c, gin.H{
|
||||
"user": user,
|
||||
"tokens": tokens,
|
||||
})
|
||||
}
|
||||
|
||||
// RefreshToken 刷新访问令牌
|
||||
// POST /api/auth/refresh
|
||||
func (h *AuthHandler) RefreshToken(c *gin.Context) {
|
||||
var req auth.RefreshTokenRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailBadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
tokens, err := h.service.RefreshToken(req)
|
||||
if err != nil {
|
||||
response.Fail(c, 401, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Ok(c, tokens)
|
||||
}
|
||||
|
||||
// GetProfile 获取当前用户信息
|
||||
// GET /api/auth/profile
|
||||
func (h *AuthHandler) GetProfile(c *gin.Context) {
|
||||
userID, exists := auth.GetCurrentUserID(c)
|
||||
if !exists {
|
||||
response.Fail(c, 401, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.service.GetUserByID(userID)
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Ok(c, user)
|
||||
}
|
||||
|
||||
// ChangePasswordRequest 修改密码请求
|
||||
type ChangePasswordRequest struct {
|
||||
OldPassword string `json:"oldPassword" binding:"required"`
|
||||
NewPassword string `json:"newPassword" binding:"required,min=6"`
|
||||
}
|
||||
|
||||
// ChangePassword 修改密码
|
||||
// POST /api/auth/change-password
|
||||
func (h *AuthHandler) ChangePassword(c *gin.Context) {
|
||||
userID, exists := auth.GetCurrentUserID(c)
|
||||
if !exists {
|
||||
response.Fail(c, 401, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
var req ChangePasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailBadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
err := h.service.ChangePassword(userID, req.OldPassword, req.NewPassword)
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.OkMsg(c, "password changed successfully")
|
||||
}
|
||||
11
internal/api/handler/handle.go
Normal file
11
internal/api/handler/handle.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package handler
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
type Handler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func New(db *gorm.DB) *Handler {
|
||||
return &Handler{db: db}
|
||||
}
|
||||
155
internal/api/handler/release.go
Normal file
155
internal/api/handler/release.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"nebula/internal/api/response"
|
||||
"nebula/internal/release"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ReleaseHandler struct {
|
||||
service *release.ReleaseService
|
||||
}
|
||||
|
||||
func NewReleaseHandler(service *release.ReleaseService) *ReleaseHandler {
|
||||
return &ReleaseHandler{service: service}
|
||||
}
|
||||
|
||||
// List 获取所有版本列表
|
||||
// GET /api/releases
|
||||
func (h *ReleaseHandler) List(c *gin.Context) {
|
||||
releases, err := h.service.List()
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Ok(c, releases)
|
||||
}
|
||||
|
||||
// ListByApp 获取指定应用的所有版本
|
||||
// GET /api/apps/:id/releases
|
||||
func (h *ReleaseHandler) ListByApp(c *gin.Context) {
|
||||
appID := c.Param("id")
|
||||
channel := c.Query("channel") // 可选的渠道过滤
|
||||
|
||||
var releases []release.Release
|
||||
var err error
|
||||
|
||||
if channel != "" {
|
||||
releases, err = h.service.ListByAppAndChannel(appID, channel)
|
||||
} else {
|
||||
releases, err = h.service.ListByApp(appID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Ok(c, releases)
|
||||
}
|
||||
|
||||
// Get 获取单个版本详情
|
||||
// GET /api/releases/:id
|
||||
func (h *ReleaseHandler) Get(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
response.FailBadRequest(c, "invalid release id")
|
||||
return
|
||||
}
|
||||
|
||||
rel, err := h.service.Get(uint(id))
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Ok(c, rel)
|
||||
}
|
||||
|
||||
// GetLatest 获取应用的最新版本
|
||||
// GET /api/apps/:id/releases/latest
|
||||
func (h *ReleaseHandler) GetLatest(c *gin.Context) {
|
||||
appID := c.Param("id")
|
||||
channel := c.Query("channel")
|
||||
|
||||
rel, err := h.service.GetLatest(appID, channel)
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Ok(c, rel)
|
||||
}
|
||||
|
||||
// Create 创建新版本
|
||||
// POST /api/releases
|
||||
func (h *ReleaseHandler) Create(c *gin.Context) {
|
||||
var rel release.Release
|
||||
if err := c.ShouldBindJSON(&rel); err != nil {
|
||||
response.FailBadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 基本验证
|
||||
if rel.AppID == "" {
|
||||
response.FailBadRequest(c, "app_id is required")
|
||||
return
|
||||
}
|
||||
if rel.Version == "" {
|
||||
response.FailBadRequest(c, "version is required")
|
||||
return
|
||||
}
|
||||
|
||||
err := h.service.Create(rel)
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OkMsg(c, "created")
|
||||
}
|
||||
|
||||
// Update 更新版本信息
|
||||
// PUT /api/releases/:id
|
||||
func (h *ReleaseHandler) Update(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
response.FailBadRequest(c, "invalid release id")
|
||||
return
|
||||
}
|
||||
|
||||
var rel release.Release
|
||||
if err := c.ShouldBindJSON(&rel); err != nil {
|
||||
response.FailBadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"version": rel.Version,
|
||||
"notes": rel.Notes,
|
||||
"channel": rel.Channel,
|
||||
"pub_date": rel.PubDate,
|
||||
}
|
||||
|
||||
err = h.service.Update(uint(id), data)
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OkMsg(c, "updated")
|
||||
}
|
||||
|
||||
// Delete 删除版本
|
||||
// DELETE /api/releases/:id
|
||||
func (h *ReleaseHandler) Delete(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
response.FailBadRequest(c, "invalid release id")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.service.Delete(uint(id))
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OkMsg(c, "deleted")
|
||||
}
|
||||
26
internal/api/handler/update.go
Normal file
26
internal/api/handler/update.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"nebula/internal/api/response"
|
||||
"nebula/internal/updater"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (h *Handler) CheckUpdate(c *gin.Context) {
|
||||
req := updater.CheckRequest{
|
||||
App: c.Query("app"),
|
||||
Version: c.Query("version"),
|
||||
Platform: c.Query("platform"),
|
||||
Arch: c.Query("arch"),
|
||||
}
|
||||
|
||||
resp, err := updater.CheckUpdate(h.db, req)
|
||||
|
||||
if err != nil {
|
||||
response.FailServer(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Ok(c, resp)
|
||||
}
|
||||
47
internal/api/response/response.go
Normal file
47
internal/api/response/response.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Result 通用 REST 返回结构
|
||||
type Result struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// 常用业务码
|
||||
const (
|
||||
CodeOk = 0
|
||||
CodeFail = 1
|
||||
CodeInvalid = 400
|
||||
CodeServer = 500
|
||||
)
|
||||
|
||||
// Ok 成功,带数据
|
||||
func Ok(c *gin.Context, data interface{}) {
|
||||
c.JSON(http.StatusOK, Result{Code: CodeOk, Message: "success", Data: data})
|
||||
}
|
||||
|
||||
// OkMsg 成功,自定义消息,无数据
|
||||
func OkMsg(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusOK, Result{Code: CodeOk, Message: message})
|
||||
}
|
||||
|
||||
// Fail 失败,业务码 + 消息
|
||||
func Fail(c *gin.Context, code int, message string) {
|
||||
c.JSON(http.StatusOK, Result{Code: code, Message: message})
|
||||
}
|
||||
|
||||
// FailBadRequest 参数错误 (HTTP 400)
|
||||
func FailBadRequest(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusBadRequest, Result{Code: CodeInvalid, Message: message})
|
||||
}
|
||||
|
||||
// FailServer 服务端错误 (HTTP 500)
|
||||
func FailServer(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusInternalServerError, Result{Code: CodeServer, Message: message})
|
||||
}
|
||||
88
internal/api/router.go
Normal file
88
internal/api/router.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"nebula/internal/api/handler"
|
||||
"nebula/internal/app"
|
||||
"nebula/internal/asset"
|
||||
"nebula/internal/auth"
|
||||
"nebula/internal/release"
|
||||
"nebula/internal/storage"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func RegisterRoutes(r *gin.Engine, db *gorm.DB, stor storage.Storage, jwtService *auth.JWTService, authService *auth.AuthService) {
|
||||
h := handler.New(db)
|
||||
appHandler := handler.NewAppHandler(app.NewService(db))
|
||||
releaseHandler := handler.NewReleaseHandler(release.NewService(db))
|
||||
assetHandler := handler.NewAssetHandler(asset.NewService(db, stor))
|
||||
authHandler := handler.NewAuthHandler(authService)
|
||||
|
||||
api := r.Group("/api")
|
||||
|
||||
{
|
||||
// 认证相关路由(公开)
|
||||
authGroup := api.Group("/auth")
|
||||
{
|
||||
// 移除注册接口 - 只允许配置的管理员登录
|
||||
// authGroup.POST("/register", authHandler.Register)
|
||||
authGroup.POST("/login", authHandler.Login)
|
||||
authGroup.POST("/refresh", authHandler.RefreshToken)
|
||||
}
|
||||
|
||||
// 需要认证的路由
|
||||
authRequired := api.Group("")
|
||||
authRequired.Use(auth.JWTMiddleware(jwtService))
|
||||
{
|
||||
// 用户相关
|
||||
authRequired.GET("/auth/profile", authHandler.GetProfile)
|
||||
authRequired.POST("/auth/change-password", authHandler.ChangePassword)
|
||||
}
|
||||
|
||||
// 更新检查(公开)
|
||||
api.GET("/check-update", h.CheckUpdate)
|
||||
|
||||
// 应用管理(需要认证)
|
||||
appGroup := api.Group("/apps")
|
||||
appGroup.Use(auth.JWTMiddleware(jwtService))
|
||||
{
|
||||
appGroup.GET("", appHandler.List)
|
||||
appGroup.POST("", appHandler.Create)
|
||||
appGroup.GET("/:id", appHandler.Get)
|
||||
appGroup.PUT("/:id", appHandler.Update)
|
||||
appGroup.DELETE("/:id", appHandler.Delete)
|
||||
|
||||
// 应用相关的版本
|
||||
appGroup.GET("/:id/releases", releaseHandler.ListByApp)
|
||||
appGroup.GET("/:id/releases/latest", releaseHandler.GetLatest)
|
||||
}
|
||||
|
||||
// 版本管理(需要认证)
|
||||
releaseGroup := api.Group("/releases")
|
||||
releaseGroup.Use(auth.JWTMiddleware(jwtService))
|
||||
{
|
||||
releaseGroup.GET("", releaseHandler.List)
|
||||
releaseGroup.POST("", releaseHandler.Create)
|
||||
releaseGroup.GET("/:id", releaseHandler.Get)
|
||||
releaseGroup.PUT("/:id", releaseHandler.Update)
|
||||
releaseGroup.DELETE("/:id", releaseHandler.Delete)
|
||||
|
||||
// 版本相关的资源
|
||||
releaseGroup.GET("/:id/assets", assetHandler.ListByRelease)
|
||||
releaseGroup.POST("/:id/assets/upload", assetHandler.Upload)
|
||||
}
|
||||
|
||||
// 资源管理(需要认证)
|
||||
assetGroup := api.Group("/assets")
|
||||
assetGroup.Use(auth.JWTMiddleware(jwtService))
|
||||
{
|
||||
assetGroup.GET("", assetHandler.List)
|
||||
assetGroup.GET("/:id", assetHandler.Get)
|
||||
assetGroup.POST("", assetHandler.Create)
|
||||
assetGroup.PUT("/:id", assetHandler.Update)
|
||||
assetGroup.DELETE("/:id", assetHandler.Delete)
|
||||
assetGroup.GET("/:id/download", assetHandler.Download)
|
||||
}
|
||||
}
|
||||
}
|
||||
11
internal/app/model.go
Normal file
11
internal/app/model.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package app
|
||||
|
||||
import "nebula/types"
|
||||
|
||||
type App struct {
|
||||
ID string `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt types.JSONTime `json:"createdAt"`
|
||||
UpdatedAt types.JSONTime `json:"updatedAt"`
|
||||
}
|
||||
38
internal/app/service.go
Normal file
38
internal/app/service.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package app
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
type AppService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB) *AppService {
|
||||
return &AppService{db: db}
|
||||
}
|
||||
|
||||
func (s *AppService) List() ([]App, error) {
|
||||
var apps []App
|
||||
err := s.db.Find(&apps).Error
|
||||
return apps, err
|
||||
}
|
||||
|
||||
func (s *AppService) Get(id string) (*App, error) {
|
||||
var app App
|
||||
err := s.db.First(&app, "id = ?", id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &app, nil
|
||||
}
|
||||
|
||||
func (s *AppService) Create(app App) error {
|
||||
return s.db.Create(&app).Error
|
||||
}
|
||||
|
||||
func (s *AppService) Update(id string, data map[string]any) error {
|
||||
return s.db.Model(&App{}).Where("id = ?", id).Updates(data).Error
|
||||
}
|
||||
|
||||
func (s *AppService) Delete(id string) error {
|
||||
return s.db.Delete(&App{}, "id = ?", id).Error
|
||||
}
|
||||
18
internal/asset/model.go
Normal file
18
internal/asset/model.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package asset
|
||||
|
||||
import "time"
|
||||
|
||||
type Asset struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ReleaseID uint `gorm:"not null" json:"releaseId"`
|
||||
Platform string `json:"platform"`
|
||||
Arch string `json:"arch"`
|
||||
StoragePath string `json:"-"` // 存储路径,不在 API 中返回
|
||||
|
||||
URL string `gorm:"not null" json:"url"`
|
||||
Signature string `json:"signature"`
|
||||
Checksum string `json:"checksum"`
|
||||
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
255
internal/asset/service.go
Normal file
255
internal/asset/service.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package asset
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"nebula/internal/storage"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AssetService struct {
|
||||
db *gorm.DB
|
||||
storage storage.Storage
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB, storage storage.Storage) *AssetService {
|
||||
return &AssetService{
|
||||
db: db,
|
||||
storage: storage,
|
||||
}
|
||||
}
|
||||
|
||||
// List 获取所有资源列表
|
||||
func (s *AssetService) List() ([]Asset, error) {
|
||||
var assets []Asset
|
||||
err := s.db.Order("created_at DESC").Find(&assets).Error
|
||||
return assets, err
|
||||
}
|
||||
|
||||
// ListByRelease 获取指定发布版本的所有资源
|
||||
func (s *AssetService) ListByRelease(releaseID uint) ([]Asset, error) {
|
||||
var assets []Asset
|
||||
err := s.db.Where("release_id = ?", releaseID).Find(&assets).Error
|
||||
return assets, err
|
||||
}
|
||||
|
||||
// Get 获取单个资源详情
|
||||
func (s *AssetService) Get(id uint) (*Asset, error) {
|
||||
var asset Asset
|
||||
err := s.db.First(&asset, id).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("asset not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &asset, nil
|
||||
}
|
||||
|
||||
// GetByReleaseAndPlatform 根据发布版本、平台和架构获取资源
|
||||
func (s *AssetService) GetByReleaseAndPlatform(releaseID uint, platform, arch string) (*Asset, error) {
|
||||
var asset Asset
|
||||
err := s.db.Where("release_id = ? AND platform = ? AND arch = ?", releaseID, platform, arch).
|
||||
First(&asset).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("asset not found for this platform and architecture")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &asset, nil
|
||||
}
|
||||
|
||||
// Upload 上传文件并创建资源记录
|
||||
func (s *AssetService) Upload(releaseID uint, platform, arch string, file *multipart.FileHeader) (*Asset, error) {
|
||||
// 检查 Release 是否存在
|
||||
var count int64
|
||||
err := s.db.Table("releases").Where("id = ?", releaseID).Count(&count).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count == 0 {
|
||||
return nil, errors.New("release not found")
|
||||
}
|
||||
|
||||
// 检查是否已存在相同平台和架构的资源
|
||||
var existing Asset
|
||||
err = s.db.Where("release_id = ? AND platform = ? AND arch = ?", releaseID, platform, arch).
|
||||
First(&existing).Error
|
||||
if err == nil {
|
||||
return nil, errors.New("asset already exists for this platform and architecture")
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 打开上传的文件
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open uploaded file: %w", err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
// 计算文件校验和
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, src); err != nil {
|
||||
return nil, fmt.Errorf("failed to calculate checksum: %w", err)
|
||||
}
|
||||
checksum := hex.EncodeToString(hash.Sum(nil))
|
||||
|
||||
// 重新打开文件用于保存(因为已经读取过了)
|
||||
src.Close()
|
||||
src, err = file.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to reopen file: %w", err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
// 构建存储路径: releases/{releaseID}/{platform}-{arch}/{filename}
|
||||
storagePath := fmt.Sprintf("releases/%d/%s-%s/%s", releaseID, platform, arch, file.Filename)
|
||||
|
||||
// 保存文件
|
||||
savedPath, err := s.storage.Save(storagePath, src)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to save file: %w", err)
|
||||
}
|
||||
|
||||
// 获取文件访问 URL
|
||||
url := s.storage.GetURL(savedPath)
|
||||
|
||||
// 创建数据库记录
|
||||
asset := Asset{
|
||||
ReleaseID: releaseID,
|
||||
Platform: platform,
|
||||
Arch: arch,
|
||||
URL: url,
|
||||
StoragePath: savedPath,
|
||||
Checksum: checksum,
|
||||
Signature: "", // TODO: 实现文件签名
|
||||
}
|
||||
|
||||
if err := s.db.Create(&asset).Error; err != nil {
|
||||
// 如果数据库操作失败,删除已上传的文件
|
||||
s.storage.Delete(savedPath)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &asset, nil
|
||||
}
|
||||
|
||||
// Create 创建资源记录(用于外部URL)
|
||||
func (s *AssetService) Create(asset Asset) error {
|
||||
// 检查 Release 是否存在
|
||||
var count int64
|
||||
err := s.db.Table("releases").Where("id = ?", asset.ReleaseID).Count(&count).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return errors.New("release not found")
|
||||
}
|
||||
|
||||
// 检查是否已存在相同平台和架构的资源
|
||||
err = s.db.Where("release_id = ? AND platform = ? AND arch = ?", asset.ReleaseID, asset.Platform, asset.Arch).
|
||||
First(&Asset{}).Error
|
||||
if err == nil {
|
||||
return errors.New("asset already exists for this platform and architecture")
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.db.Create(&asset).Error
|
||||
}
|
||||
|
||||
// Update 更新资源信息(不包括文件)
|
||||
func (s *AssetService) Update(id uint, data map[string]any) error {
|
||||
// 检查资源是否存在
|
||||
var asset Asset
|
||||
err := s.db.First(&asset, id).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New("asset not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 如果更新平台或架构,检查是否会造成重复
|
||||
newPlatform, hasPlatform := data["platform"].(string)
|
||||
newArch, hasArch := data["arch"].(string)
|
||||
|
||||
if hasPlatform || hasArch {
|
||||
checkPlatform := asset.Platform
|
||||
checkArch := asset.Arch
|
||||
if hasPlatform {
|
||||
checkPlatform = newPlatform
|
||||
}
|
||||
if hasArch {
|
||||
checkArch = newArch
|
||||
}
|
||||
|
||||
err := s.db.Where("release_id = ? AND platform = ? AND arch = ? AND id != ?",
|
||||
asset.ReleaseID, checkPlatform, checkArch, id).
|
||||
First(&Asset{}).Error
|
||||
if err == nil {
|
||||
return errors.New("asset already exists for this platform and architecture")
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return s.db.Model(&Asset{}).Where("id = ?", id).Updates(data).Error
|
||||
}
|
||||
|
||||
// Delete 删除资源(包括文件)
|
||||
func (s *AssetService) Delete(id uint) error {
|
||||
// 获取资源信息
|
||||
var asset Asset
|
||||
err := s.db.First(&asset, id).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New("asset not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 删除存储的文件
|
||||
if asset.StoragePath != "" {
|
||||
if err := s.storage.Delete(asset.StoragePath); err != nil {
|
||||
// 记录错误但继续删除数据库记录
|
||||
// TODO: 添加日志
|
||||
}
|
||||
}
|
||||
|
||||
// 删除数据库记录
|
||||
return s.db.Delete(&Asset{}, id).Error
|
||||
}
|
||||
|
||||
// GetStoragePath 获取资源的存储路径
|
||||
func (s *AssetService) GetStoragePath(id uint) (string, error) {
|
||||
var asset Asset
|
||||
err := s.db.First(&asset, id).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", errors.New("asset not found")
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
if asset.StoragePath == "" {
|
||||
return "", errors.New("storage path not available")
|
||||
}
|
||||
|
||||
// 如果是本地存储,返回完整路径
|
||||
if localStorage, ok := s.storage.(*storage.LocalStorage); ok {
|
||||
return localStorage.GetFullPath(asset.StoragePath), nil
|
||||
}
|
||||
|
||||
return asset.StoragePath, nil
|
||||
}
|
||||
131
internal/auth/jwt.go
Normal file
131
internal/auth/jwt.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// Claims JWT 声明
|
||||
type Claims struct {
|
||||
UserID string `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// TokenPair 访问令牌和刷新令牌
|
||||
type TokenPair struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
ExpiresIn int64 `json:"expiresIn"` // 秒
|
||||
}
|
||||
|
||||
// JWTService JWT 服务
|
||||
type JWTService struct {
|
||||
secret string
|
||||
accessTokenDuration time.Duration
|
||||
refreshTokenDuration time.Duration
|
||||
}
|
||||
|
||||
// NewJWTService 创建 JWT 服务
|
||||
func NewJWTService(secret string, accessTokenDuration, refreshTokenDuration time.Duration) *JWTService {
|
||||
return &JWTService{
|
||||
secret: secret,
|
||||
accessTokenDuration: accessTokenDuration,
|
||||
refreshTokenDuration: refreshTokenDuration,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateTokenPair 生成访问令牌和刷新令牌
|
||||
func (s *JWTService) GenerateTokenPair(userID, username, role string) (*TokenPair, error) {
|
||||
// 生成访问令牌
|
||||
accessToken, err := s.generateToken(userID, username, role, s.accessTokenDuration)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 生成刷新令牌(更长的过期时间,不包含敏感信息)
|
||||
refreshToken, err := s.generateToken(userID, "", "", s.refreshTokenDuration)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &TokenPair{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresIn: int64(s.accessTokenDuration.Seconds()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// generateToken 生成 token
|
||||
func (s *JWTService) generateToken(userID, username, role string, duration time.Duration) (string, error) {
|
||||
now := time.Now()
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
Role: role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(duration)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(s.secret))
|
||||
}
|
||||
|
||||
// ParseToken 解析 token
|
||||
func (s *JWTService) ParseToken(tokenString string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
// 验证签名算法
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New("unexpected signing method")
|
||||
}
|
||||
return []byte(s.secret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
|
||||
// ValidateToken 验证 token 是否有效
|
||||
func (s *JWTService) ValidateToken(tokenString string) (*Claims, error) {
|
||||
claims, err := s.ParseToken(tokenString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if time.Now().Unix() > claims.ExpiresAt.Unix() {
|
||||
return nil, errors.New("token expired")
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// RefreshAccessToken 使用刷新令牌生成新的访问令牌
|
||||
func (s *JWTService) RefreshAccessToken(refreshToken string, user *User) (*TokenPair, error) {
|
||||
// 验证刷新令牌
|
||||
claims, err := s.ValidateToken(refreshToken)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid refresh token")
|
||||
}
|
||||
|
||||
// 确保刷新令牌属于该用户
|
||||
if claims.UserID != user.ID {
|
||||
return nil, errors.New("token does not match user")
|
||||
}
|
||||
|
||||
// 生成新的 token 对
|
||||
return s.GenerateTokenPair(user.ID, user.Username, user.Role)
|
||||
}
|
||||
104
internal/auth/middleware.go
Normal file
104
internal/auth/middleware.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"nebula/internal/api/response"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// JWTMiddleware JWT 认证中间件
|
||||
func JWTMiddleware(jwtService *JWTService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
response.Fail(c, 401, "missing authorization header")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 解析 Bearer token
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
response.Fail(c, 401, "invalid authorization header format")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := parts[1]
|
||||
|
||||
// 验证 token
|
||||
claims, err := jwtService.ValidateToken(tokenString)
|
||||
if err != nil {
|
||||
response.Fail(c, 401, "invalid or expired token")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 将用户信息保存到上下文
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("username", claims.Username)
|
||||
c.Set("role", claims.Role)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// OptionalJWTMiddleware 可选的 JWT 中间件(token 无效不报错)
|
||||
func OptionalJWTMiddleware(jwtService *JWTService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader != "" {
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) == 2 && parts[0] == "Bearer" {
|
||||
tokenString := parts[1]
|
||||
if claims, err := jwtService.ValidateToken(tokenString); err == nil {
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("username", claims.Username)
|
||||
c.Set("role", claims.Role)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// AdminMiddleware 管理员权限中间件(需要先使用 JWTMiddleware)
|
||||
func AdminMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
role, exists := c.Get("role")
|
||||
if !exists || role != "admin" {
|
||||
response.Fail(c, 403, "admin access required")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// GetCurrentUserID 从上下文获取当前用户 ID
|
||||
func GetCurrentUserID(c *gin.Context) (string, bool) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
return "", false
|
||||
}
|
||||
return userID.(string), true
|
||||
}
|
||||
|
||||
// GetCurrentUsername 从上下文获取当前用户名
|
||||
func GetCurrentUsername(c *gin.Context) (string, bool) {
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
return "", false
|
||||
}
|
||||
return username.(string), true
|
||||
}
|
||||
|
||||
// GetCurrentUserRole 从上下文获取当前用户角色
|
||||
func GetCurrentUserRole(c *gin.Context) (string, bool) {
|
||||
role, exists := c.Get("role")
|
||||
if !exists {
|
||||
return "", false
|
||||
}
|
||||
return role.(string), true
|
||||
}
|
||||
29
internal/auth/model.go
Normal file
29
internal/auth/model.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string `gorm:"primaryKey" json:"id"`
|
||||
Username string `gorm:"uniqueIndex;not null" json:"username"`
|
||||
Email string `gorm:"uniqueIndex;not null" json:"email"`
|
||||
Password string `gorm:"not null" json:"-"` // 不在 JSON 中返回
|
||||
Role string `gorm:"default:'user'" json:"role"` // user, admin
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// HashPassword 加密密码
|
||||
func HashPassword(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
// CheckPassword 验证密码
|
||||
func (u *User) CheckPassword(password string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
209
internal/auth/service.go
Normal file
209
internal/auth/service.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AuthService struct {
|
||||
db *gorm.DB
|
||||
jwtService *JWTService
|
||||
adminUsername string
|
||||
adminPassword string
|
||||
}
|
||||
|
||||
func NewAuthService(db *gorm.DB, jwtService *JWTService, adminUsername, adminPassword string) *AuthService {
|
||||
return &AuthService{
|
||||
db: db,
|
||||
jwtService: jwtService,
|
||||
adminUsername: adminUsername,
|
||||
adminPassword: adminPassword,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRequest 注册请求
|
||||
type RegisterRequest struct {
|
||||
Username string `json:"username" binding:"required,min=3,max=20"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
}
|
||||
|
||||
// LoginRequest 登录请求
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
// RefreshTokenRequest 刷新令牌请求
|
||||
type RefreshTokenRequest struct {
|
||||
RefreshToken string `json:"refreshToken" binding:"required"`
|
||||
}
|
||||
|
||||
// Register 用户注册
|
||||
func (s *AuthService) Register(req RegisterRequest) (*User, *TokenPair, error) {
|
||||
// 检查用户名是否已存在
|
||||
var existingUser User
|
||||
err := s.db.Where("username = ?", req.Username).First(&existingUser).Error
|
||||
if err == nil {
|
||||
return nil, nil, errors.New("username already exists")
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// 检查邮箱是否已存在
|
||||
err = s.db.Where("email = ?", req.Email).First(&existingUser).Error
|
||||
if err == nil {
|
||||
return nil, nil, errors.New("email already exists")
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// 加密密码
|
||||
hashedPassword, err := HashPassword(req.Password)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// 创建用户
|
||||
user := User{
|
||||
ID: uuid.New().String(),
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
Password: hashedPassword,
|
||||
Role: "user", // 默认角色
|
||||
}
|
||||
|
||||
if err := s.db.Create(&user).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// 生成 token
|
||||
tokens, err := s.jwtService.GenerateTokenPair(user.ID, user.Username, user.Role)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return &user, tokens, nil
|
||||
}
|
||||
|
||||
// Login 用户登录
|
||||
func (s *AuthService) Login(req LoginRequest) (*User, *TokenPair, error) {
|
||||
// 1. 先检查是否为配置的管理员账号
|
||||
if req.Username == s.adminUsername && req.Password == s.adminPassword {
|
||||
// 管理员登录成功,创建虚拟用户对象
|
||||
adminUser := &User{
|
||||
ID: "admin",
|
||||
Username: s.adminUsername,
|
||||
Email: s.adminUsername + "@admin.local",
|
||||
Role: "admin",
|
||||
}
|
||||
|
||||
// 生成 token
|
||||
tokens, err := s.jwtService.GenerateTokenPair(adminUser.ID, adminUser.Username, adminUser.Role)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return adminUser, tokens, nil
|
||||
}
|
||||
|
||||
// 2. 如果不是管理员,查找数据库中的用户
|
||||
var user User
|
||||
err := s.db.Where("username = ? OR email = ?", req.Username, req.Username).First(&user).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil, errors.New("invalid username or password")
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
if !user.CheckPassword(req.Password) {
|
||||
return nil, nil, errors.New("invalid username or password")
|
||||
}
|
||||
|
||||
// 生成 token
|
||||
tokens, err := s.jwtService.GenerateTokenPair(user.ID, user.Username, user.Role)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return &user, tokens, nil
|
||||
}
|
||||
|
||||
// RefreshToken 刷新访问令牌
|
||||
func (s *AuthService) RefreshToken(req RefreshTokenRequest) (*TokenPair, error) {
|
||||
// 解析刷新令牌获取用户 ID
|
||||
claims, err := s.jwtService.ParseToken(req.RefreshToken)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid refresh token")
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
var user User
|
||||
err = s.db.First(&user, "id = ?", claims.UserID).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 使用刷新令牌生成新的 token 对
|
||||
tokens, err := s.jwtService.RefreshAccessToken(req.RefreshToken, &user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
// GetUserByID 根据 ID 获取用户
|
||||
func (s *AuthService) GetUserByID(userID string) (*User, error) {
|
||||
// 特殊处理管理员
|
||||
if userID == "admin" {
|
||||
return &User{
|
||||
ID: "admin",
|
||||
Username: s.adminUsername,
|
||||
Email: s.adminUsername + "@admin.local",
|
||||
Role: "admin",
|
||||
}, nil
|
||||
}
|
||||
|
||||
var user User
|
||||
err := s.db.First(&user, "id = ?", userID).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// ChangePassword 修改密码
|
||||
func (s *AuthService) ChangePassword(userID, oldPassword, newPassword string) error {
|
||||
var user User
|
||||
err := s.db.First(&user, "id = ?", userID).Error
|
||||
if err != nil {
|
||||
return errors.New("user not found")
|
||||
}
|
||||
|
||||
// 验证旧密码
|
||||
if !user.CheckPassword(oldPassword) {
|
||||
return errors.New("invalid old password")
|
||||
}
|
||||
|
||||
// 加密新密码
|
||||
hashedPassword, err := HashPassword(newPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 更新密码
|
||||
return s.db.Model(&user).Update("password", hashedPassword).Error
|
||||
}
|
||||
154
internal/config/config.go
Normal file
154
internal/config/config.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server struct {
|
||||
Address string `yaml:"address"`
|
||||
Mode string `yaml:"mode"` // dev, prod
|
||||
} `yaml:"server"`
|
||||
Database struct {
|
||||
DSN string `yaml:"dsn"`
|
||||
} `yaml:"database"`
|
||||
Storage struct {
|
||||
Type string `yaml:"type"` // local, oss, s3
|
||||
BasePath string `yaml:"basePath"` // 本地存储的基础路径
|
||||
BaseURL string `yaml:"baseUrl"` // 文件访问的 URL 前缀
|
||||
} `yaml:"storage"`
|
||||
JWT struct {
|
||||
Secret string `yaml:"secret"`
|
||||
AccessTokenDuration int `yaml:"accessTokenDuration"` // 秒
|
||||
RefreshTokenDuration int `yaml:"refreshTokenDuration"` // 秒
|
||||
} `yaml:"jwt"`
|
||||
Frontend struct {
|
||||
Enabled bool `yaml:"enabled"` // 是否启用前端静态资源服务
|
||||
Path string `yaml:"path"` // 前端静态资源路径
|
||||
} `yaml:"frontend"`
|
||||
Admin struct {
|
||||
Username string `yaml:"username"` // 管理员用户名
|
||||
Password string `yaml:"password"` // 管理员密码
|
||||
} `yaml:"admin"`
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
// 确定配置文件路径
|
||||
configFile := getConfigFile()
|
||||
|
||||
// 读取配置文件
|
||||
data, err := os.ReadFile(configFile)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to read config file %s: %v, using defaults\n", configFile, err)
|
||||
return loadDefaults()
|
||||
}
|
||||
|
||||
// 解析 YAML
|
||||
config := &Config{}
|
||||
if err := yaml.Unmarshal(data, config); err != nil {
|
||||
fmt.Printf("Warning: failed to parse config file %s: %v, using defaults\n", configFile, err)
|
||||
return loadDefaults()
|
||||
}
|
||||
|
||||
// 环境变量覆盖
|
||||
applyEnvOverrides(config)
|
||||
|
||||
// 自动设置 frontend.enabled(生产模式才启用)
|
||||
if config.Server.Mode == "prod" && !config.Frontend.Enabled {
|
||||
config.Frontend.Enabled = true
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
func getConfigFile() string {
|
||||
// 1. 优先使用环境变量指定的配置文件
|
||||
if configFile := os.Getenv("CONFIG_FILE"); configFile != "" {
|
||||
return configFile
|
||||
}
|
||||
|
||||
// 2. 根据 SERVER_MODE 选择配置文件
|
||||
mode := os.Getenv("SERVER_MODE")
|
||||
if mode == "" {
|
||||
mode = "dev"
|
||||
}
|
||||
|
||||
// 检查 config.{mode}.yaml
|
||||
modeConfig := fmt.Sprintf("config.%s.yaml", mode)
|
||||
if _, err := os.Stat(modeConfig); err == nil {
|
||||
return modeConfig
|
||||
}
|
||||
|
||||
// 3. 使用默认 config.yaml
|
||||
if _, err := os.Stat("config.yaml"); err == nil {
|
||||
return "config.yaml"
|
||||
}
|
||||
|
||||
// 4. 都不存在,使用默认配置
|
||||
return ""
|
||||
}
|
||||
|
||||
func loadDefaults() *Config {
|
||||
config := &Config{}
|
||||
config.Server.Address = ":9050"
|
||||
config.Server.Mode = "dev"
|
||||
config.Database.DSN = "nebula.db"
|
||||
config.Storage.Type = "local"
|
||||
config.Storage.BasePath = "./uploads"
|
||||
config.Storage.BaseURL = "http://localhost:9050/files"
|
||||
config.JWT.Secret = "dev-secret-key-change-in-production"
|
||||
config.JWT.AccessTokenDuration = 7200 // 2 hours
|
||||
config.JWT.RefreshTokenDuration = 604800 // 7 days
|
||||
config.Frontend.Enabled = false
|
||||
config.Frontend.Path = "./web/dist"
|
||||
config.Admin.Username = "admin"
|
||||
config.Admin.Password = "admin123" // 默认密码,生产环境必须修改
|
||||
return config
|
||||
}
|
||||
|
||||
func applyEnvOverrides(config *Config) {
|
||||
if val := os.Getenv("SERVER_ADDRESS"); val != "" {
|
||||
config.Server.Address = val
|
||||
}
|
||||
if val := os.Getenv("SERVER_MODE"); val != "" {
|
||||
config.Server.Mode = val
|
||||
}
|
||||
if val := os.Getenv("DATABASE_DSN"); val != "" {
|
||||
config.Database.DSN = val
|
||||
}
|
||||
if val := os.Getenv("STORAGE_TYPE"); val != "" {
|
||||
config.Storage.Type = val
|
||||
}
|
||||
if val := os.Getenv("STORAGE_BASE_PATH"); val != "" {
|
||||
config.Storage.BasePath = val
|
||||
}
|
||||
if val := os.Getenv("STORAGE_BASE_URL"); val != "" {
|
||||
config.Storage.BaseURL = val
|
||||
}
|
||||
if val := os.Getenv("JWT_SECRET"); val != "" {
|
||||
config.JWT.Secret = val
|
||||
}
|
||||
if val := os.Getenv("FRONTEND_PATH"); val != "" {
|
||||
config.Frontend.Path = val
|
||||
}
|
||||
if val := os.Getenv("ADMIN_USERNAME"); val != "" {
|
||||
config.Admin.Username = val
|
||||
}
|
||||
if val := os.Getenv("ADMIN_PASSWORD"); val != "" {
|
||||
config.Admin.Password = val
|
||||
}
|
||||
}
|
||||
|
||||
// GetAccessTokenDuration 返回 Access Token 有效期
|
||||
func (c *Config) GetAccessTokenDuration() time.Duration {
|
||||
return time.Duration(c.JWT.AccessTokenDuration) * time.Second
|
||||
}
|
||||
|
||||
// GetRefreshTokenDuration 返回 Refresh Token 有效期
|
||||
func (c *Config) GetRefreshTokenDuration() time.Duration {
|
||||
return time.Duration(c.JWT.RefreshTokenDuration) * time.Second
|
||||
}
|
||||
27
internal/db/db.go
Normal file
27
internal/db/db.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"nebula/internal/app"
|
||||
"nebula/internal/asset"
|
||||
"nebula/internal/auth"
|
||||
"nebula/internal/release"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func Init(dsn string) *gorm.DB {
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
db.AutoMigrate(
|
||||
&app.App{},
|
||||
&release.Release{},
|
||||
&asset.Asset{},
|
||||
&auth.User{},
|
||||
)
|
||||
|
||||
return db
|
||||
}
|
||||
14
internal/release/model.go
Normal file
14
internal/release/model.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package release
|
||||
|
||||
import "time"
|
||||
|
||||
type Release struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
AppID string `gorm:"not null"`
|
||||
Version string `gorm:"not null"`
|
||||
Notes string
|
||||
Channel string
|
||||
PubDate time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
141
internal/release/service.go
Normal file
141
internal/release/service.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package release
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ReleaseService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB) *ReleaseService {
|
||||
return &ReleaseService{db: db}
|
||||
}
|
||||
|
||||
// List 获取所有版本列表
|
||||
func (s *ReleaseService) List() ([]Release, error) {
|
||||
var releases []Release
|
||||
err := s.db.Order("created_at DESC").Find(&releases).Error
|
||||
return releases, err
|
||||
}
|
||||
|
||||
// ListByApp 获取指定应用的所有版本
|
||||
func (s *ReleaseService) ListByApp(appID string) ([]Release, error) {
|
||||
var releases []Release
|
||||
err := s.db.Where("app_id = ?", appID).Order("created_at DESC").Find(&releases).Error
|
||||
return releases, err
|
||||
}
|
||||
|
||||
// ListByAppAndChannel 获取指定应用和渠道的版本
|
||||
func (s *ReleaseService) ListByAppAndChannel(appID, channel string) ([]Release, error) {
|
||||
var releases []Release
|
||||
query := s.db.Where("app_id = ?", appID)
|
||||
if channel != "" {
|
||||
query = query.Where("channel = ?", channel)
|
||||
}
|
||||
err := query.Order("created_at DESC").Find(&releases).Error
|
||||
return releases, err
|
||||
}
|
||||
|
||||
// Get 获取单个版本详情
|
||||
func (s *ReleaseService) Get(id uint) (*Release, error) {
|
||||
var release Release
|
||||
err := s.db.First(&release, id).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("release not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &release, nil
|
||||
}
|
||||
|
||||
// GetLatest 获取应用的最新版本
|
||||
func (s *ReleaseService) GetLatest(appID string, channel string) (*Release, error) {
|
||||
var release Release
|
||||
query := s.db.Where("app_id = ?", appID)
|
||||
if channel != "" {
|
||||
query = query.Where("channel = ?", channel)
|
||||
}
|
||||
err := query.Order("pub_date DESC").First(&release).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("no release found for this app")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &release, nil
|
||||
}
|
||||
|
||||
// Create 创建新版本
|
||||
func (s *ReleaseService) Create(release Release) error {
|
||||
// 检查应用是否存在
|
||||
var count int64
|
||||
err := s.db.Table("apps").Where("id = ?", release.AppID).Count(&count).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return errors.New("app not found")
|
||||
}
|
||||
|
||||
// 检查版本是否已存在
|
||||
err = s.db.Where("app_id = ? AND version = ?", release.AppID, release.Version).First(&Release{}).Error
|
||||
if err == nil {
|
||||
return errors.New("version already exists for this app")
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.db.Create(&release).Error
|
||||
}
|
||||
|
||||
// Update 更新版本信息
|
||||
func (s *ReleaseService) Update(id uint, data map[string]any) error {
|
||||
// 检查版本是否存在
|
||||
var release Release
|
||||
err := s.db.First(&release, id).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New("release not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 如果更新版本号,检查是否重复
|
||||
if newVersion, ok := data["version"].(string); ok {
|
||||
if newVersion != release.Version {
|
||||
err := s.db.Where("app_id = ? AND version = ? AND id != ?", release.AppID, newVersion, id).
|
||||
First(&Release{}).Error
|
||||
if err == nil {
|
||||
return errors.New("version already exists for this app")
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return s.db.Model(&Release{}).Where("id = ?", id).Updates(data).Error
|
||||
}
|
||||
|
||||
// Delete 删除版本
|
||||
func (s *ReleaseService) Delete(id uint) error {
|
||||
// 检查版本是否存在
|
||||
var release Release
|
||||
err := s.db.First(&release, id).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New("release not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO: 同时删除相关的 Assets
|
||||
// 可以通过数据库外键级联删除,或者在这里手动删除
|
||||
|
||||
return s.db.Delete(&Release{}, id).Error
|
||||
}
|
||||
87
internal/storage/local.go
Normal file
87
internal/storage/local.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// LocalStorage 本地文件系统存储实现
|
||||
type LocalStorage struct {
|
||||
basePath string // 文件存储的根目录
|
||||
baseURL string // 文件访问的 URL 前缀
|
||||
}
|
||||
|
||||
// NewLocalStorage 创建本地存储实例
|
||||
func NewLocalStorage(basePath, baseURL string) (*LocalStorage, error) {
|
||||
// 确保存储目录存在
|
||||
if err := os.MkdirAll(basePath, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create storage directory: %w", err)
|
||||
}
|
||||
|
||||
return &LocalStorage{
|
||||
basePath: basePath,
|
||||
baseURL: baseURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Save 保存文件到本地
|
||||
func (s *LocalStorage) Save(filename string, content io.Reader) (string, error) {
|
||||
// 构建完整的文件路径
|
||||
fullPath := filepath.Join(s.basePath, filename)
|
||||
|
||||
// 确保目标目录存在
|
||||
dir := filepath.Dir(fullPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create directory: %w", err)
|
||||
}
|
||||
|
||||
// 创建文件
|
||||
file, err := os.Create(fullPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// 复制内容
|
||||
if _, err := io.Copy(file, content); err != nil {
|
||||
// 如果保存失败,尝试删除文件
|
||||
os.Remove(fullPath)
|
||||
return "", fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
|
||||
// 返回相对路径(存储在数据库中)
|
||||
return filename, nil
|
||||
}
|
||||
|
||||
// Delete 删除本地文件
|
||||
func (s *LocalStorage) Delete(path string) error {
|
||||
fullPath := filepath.Join(s.basePath, path)
|
||||
if err := os.Remove(fullPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to delete file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetURL 获取文件的访问 URL
|
||||
func (s *LocalStorage) GetURL(path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
// 将路径中的反斜杠替换为正斜杠(Windows 兼容)
|
||||
path = filepath.ToSlash(path)
|
||||
return s.baseURL + "/" + path
|
||||
}
|
||||
|
||||
// Exists 检查文件是否存在
|
||||
func (s *LocalStorage) Exists(path string) bool {
|
||||
fullPath := filepath.Join(s.basePath, path)
|
||||
_, err := os.Stat(fullPath)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// GetFullPath 获取文件的完整本地路径
|
||||
func (s *LocalStorage) GetFullPath(path string) string {
|
||||
return filepath.Join(s.basePath, path)
|
||||
}
|
||||
20
internal/storage/storage.go
Normal file
20
internal/storage/storage.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"io"
|
||||
)
|
||||
|
||||
// Storage 定义文件存储接口,支持本地存储、OSS、S3 等
|
||||
type Storage interface {
|
||||
// Save 保存文件,返回存储路径
|
||||
Save(filename string, content io.Reader) (string, error)
|
||||
|
||||
// Delete 删除文件
|
||||
Delete(path string) error
|
||||
|
||||
// GetURL 获取文件的访问 URL
|
||||
GetURL(path string) string
|
||||
|
||||
// Exists 检查文件是否存在
|
||||
Exists(path string) bool
|
||||
}
|
||||
84
internal/updater/service.go
Normal file
84
internal/updater/service.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"nebula/internal/asset"
|
||||
"nebula/internal/release"
|
||||
"nebula/pkg/util"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type CheckRequest struct {
|
||||
App string
|
||||
Version string
|
||||
Platform string
|
||||
Arch string
|
||||
}
|
||||
|
||||
type CheckResponse struct {
|
||||
Update bool `json:"update"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Checksum string `json:"checksum,omitempty"`
|
||||
}
|
||||
|
||||
func CheckUpdate(db *gorm.DB, req CheckRequest) (*CheckResponse, error) {
|
||||
// 参数验证
|
||||
if req.App == "" {
|
||||
return nil, errors.New("app is required")
|
||||
}
|
||||
if req.Version == "" {
|
||||
return nil, errors.New("version is required")
|
||||
}
|
||||
if req.Platform == "" {
|
||||
return nil, errors.New("platform is required")
|
||||
}
|
||||
if req.Arch == "" {
|
||||
return nil, errors.New("arch is required")
|
||||
}
|
||||
|
||||
// 查找最新版本(按发布日期排序)
|
||||
var latest release.Release
|
||||
err := db.Where("app_id = ?", req.App).
|
||||
Order("pub_date DESC").
|
||||
First(&latest).Error
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("no release found for this app")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 比较版本号
|
||||
if !util.IsNewerVersion(req.Version, latest.Version) {
|
||||
return &CheckResponse{Update: false}, nil
|
||||
}
|
||||
|
||||
// 查找对应平台和架构的资源
|
||||
var ast asset.Asset
|
||||
err = db.Where("release_id = ? AND platform = ? AND arch = ?",
|
||||
latest.ID, req.Platform, req.Arch).First(&ast).Error
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("no asset found for this platform and architecture")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 验证资源 URL 是否存在
|
||||
if ast.URL == "" {
|
||||
return nil, errors.New("asset URL is empty")
|
||||
}
|
||||
|
||||
return &CheckResponse{
|
||||
Update: true,
|
||||
Version: latest.Version,
|
||||
Notes: latest.Notes,
|
||||
URL: ast.URL,
|
||||
Checksum: ast.Checksum,
|
||||
}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user