fear: admin添加app应用版本管理

system-features添加
This commit is contained in:
npc0-hue
2026-02-09 09:11:58 +08:00
parent 8c9e7652ec
commit df9bed2950
35 changed files with 1656 additions and 40 deletions
+299
View File
@@ -0,0 +1,299 @@
package gaia
import (
"strconv"
"github.com/flipped-aurora/gin-vue-admin/server/global"
"github.com/flipped-aurora/gin-vue-admin/server/model/common/response"
gaiaReq "github.com/flipped-aurora/gin-vue-admin/server/model/gaia/request"
"github.com/flipped-aurora/gin-vue-admin/server/service"
"github.com/flipped-aurora/gin-vue-admin/server/utils"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
type AppVersionApi struct{}
var appVersionService = service.ServiceGroupApp.GaiaServiceGroup.AppVersionService
// GetLatest 客户端获取最新版本(公开接口)
// @Tags AppVersion
// @Summary 客户端获取最新版本
// @accept application/json
// @Produce application/json
// @Param platform query string true "平台(如 win32/darwin/linux)"
// @Param arch query string false "架构(可选,未传或未匹配时取该 platform 第一个包)"
// @Param token query string false "Token(若后台配置了则必填)"
// @Success 200 {object} response.Response "获取成功"
// @Router /latest [get]
func (appVersionApi *AppVersionApi) GetLatest(c *gin.Context) {
platform := c.Query("platform")
arch := c.Query("arch")
token := c.Query("token")
if platform == "" {
response.FailWithMessage("platform is required", c)
return
}
resp, code := appVersionService.GetLatest(platform, arch, token)
if code == 401 {
response.NoAuth("token required or invalid", c)
return
}
if code == 404 {
response.FailWithMessage("no package for this platform/arch", c)
return
}
if code != 200 {
global.GVA_LOG.Error("GetLatest failed", zap.Int("code", code))
response.FailWithMessage("internal error", c)
return
}
response.OkWithData(resp, c)
}
func buildDownloadUrl(c *gin.Context) func(string) string {
prefix := global.GVA_CONFIG.System.RouterPrefix
return func(path string) string {
scheme := "https"
if c.GetHeader("X-Forwarded-Proto") != "" {
scheme = c.GetHeader("X-Forwarded-Proto")
} else if c.Request.TLS == nil {
scheme = "http"
}
host := c.Request.Host
if path == "" {
return ""
}
if path[0] != '/' {
path = "/" + path
}
if prefix != "" {
return scheme + "://" + host + prefix + path
}
return scheme + "://" + host + path
}
}
// GetTokenConfig 管理端获取全局 Token 配置
// @Tags AppVersion
// @Summary 管理端获取全局 Token 配置
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Success 200 {object} response.Response "获取成功"
// @Router /gaia/app-version/token [get]
func (appVersionApi *AppVersionApi) GetTokenConfig(c *gin.Context) {
cfg, err := appVersionService.GetTokenConfig()
if err != nil {
global.GVA_LOG.Error("获取Token配置失败!", zap.Error(err))
response.FailWithMessage("获取失败:"+err.Error(), c)
return
}
response.OkWithData(cfg, c)
}
// SetTokenConfig 管理端设置全局 Token
// @Tags AppVersion
// @Summary 管理端设置全局 Token
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param data body gaiaReq.AppVersionTokenConfig true "Token 配置"
// @Success 200 {object} response.Response "设置成功"
// @Router /gaia/app-version/token [put]
func (appVersionApi *AppVersionApi) SetTokenConfig(c *gin.Context) {
var req gaiaReq.AppVersionTokenConfig
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage(err.Error(), c)
return
}
if err := appVersionService.SetTokenConfig(req); err != nil {
global.GVA_LOG.Error("设置Token配置失败!", zap.Error(err))
response.FailWithMessage(err.Error(), c)
return
}
response.Ok(c)
}
// RevealToken 输入登录密码验证后返回明文 Token
// @Tags AppVersion
// @Summary 输入登录密码验证后返回明文 Token
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param data body gaiaReq.AppVersionTokenReveal true "密码"
// @Success 200 {object} response.Response "获取成功"
// @Router /gaia/app-version/token/reveal [post]
func (appVersionApi *AppVersionApi) RevealToken(c *gin.Context) {
var req gaiaReq.AppVersionTokenReveal
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage(err.Error(), c)
return
}
userID := utils.GetUserID(c)
token, err := appVersionService.RevealToken(userID, req.Password)
if err != nil {
global.GVA_LOG.Error("RevealToken失败!", zap.Error(err))
response.FailWithMessage(err.Error(), c)
return
}
response.OkWithData(gin.H{"token": token}, c)
}
// ListReleases 版本列表
// @Tags AppVersion
// @Summary 版本列表
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Success 200 {object} response.Response "获取成功"
// @Router /gaia/app-version/releases [get]
func (appVersionApi *AppVersionApi) ListReleases(c *gin.Context) {
list, err := appVersionService.ListReleases()
if err != nil {
global.GVA_LOG.Error("获取版本列表失败!", zap.Error(err))
response.FailWithMessage("获取失败:"+err.Error(), c)
return
}
response.OkWithData(list, c)
}
// CreateRelease 新增版本
// @Tags AppVersion
// @Summary 新增版本
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param data body gaiaReq.AppVersionReleaseCreate true "版本信息"
// @Success 200 {object} response.Response "创建成功"
// @Router /gaia/app-version/releases [post]
func (appVersionApi *AppVersionApi) CreateRelease(c *gin.Context) {
var req gaiaReq.AppVersionReleaseCreate
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage(err.Error(), c)
return
}
r, err := appVersionService.CreateRelease(req)
if err != nil {
global.GVA_LOG.Error("创建版本失败!", zap.Error(err))
response.FailWithMessage(err.Error(), c)
return
}
response.OkWithData(r, c)
}
// GetRelease 获取单个版本详情
// @Tags AppVersion
// @Summary 获取单个版本详情
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param id path int true "版本 ID"
// @Success 200 {object} response.Response "获取成功"
// @Router /gaia/app-version/releases/:id [get]
func (appVersionApi *AppVersionApi) GetRelease(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
response.FailWithMessage("无效的版本 id", c)
return
}
detail, err := appVersionService.GetReleaseByID(uint(id))
if err != nil {
global.GVA_LOG.Error("获取版本详情失败!", zap.Error(err))
response.FailWithMessage(err.Error(), c)
return
}
response.OkWithData(detail, c)
}
// UpdateRelease 更新版本信息
// @Tags AppVersion
// @Summary 更新版本信息
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param id path int true "版本 ID"
// @Param data body gaiaReq.AppVersionReleaseUpdate true "版本信息"
// @Success 200 {object} response.Response "更新成功"
// @Router /gaia/app-version/releases/:id [put]
func (appVersionApi *AppVersionApi) UpdateRelease(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
response.FailWithMessage("无效的版本 id", c)
return
}
var req gaiaReq.AppVersionReleaseUpdate
if err = c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage(err.Error(), c)
return
}
if err = appVersionService.UpdateRelease(uint(id), req); err != nil {
global.GVA_LOG.Error("更新版本失败!", zap.Error(err))
response.FailWithMessage(err.Error(), c)
return
}
response.Ok(c)
}
// UploadToRelease 上传安装包到指定版本(根据文件名自动识别平台/架构)
// @Tags AppVersion
// @Summary 上传安装包到指定版本
// @Security ApiKeyAuth
// @accept multipart/form-data
// @Produce application/json
// @Param id path int true "版本 ID"
// @Param file formData file true "安装包文件"
// @Success 200 {object} response.Response "上传成功"
// @Router /gaia/app-version/releases/:id/upload [post]
func (appVersionApi *AppVersionApi) UploadToRelease(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
response.FailWithMessage("无效的版本 id", c)
return
}
file, err := c.FormFile("file")
if err != nil {
response.FailWithMessage("请选择文件: "+err.Error(), c)
return
}
if err = appVersionService.UploadPackageToRelease(uint(id), file, buildDownloadUrl(c)); err != nil {
global.GVA_LOG.Error("上传安装包失败!", zap.Error(err))
response.FailWithMessage(err.Error(), c)
return
}
response.Ok(c)
}
// DeleteDownload 删除指定版本下某 platform/arch 的包
// @Tags AppVersion
// @Summary 删除指定版本下某 platform/arch 的包
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param id path int true "版本 ID"
// @Param platform query string true "平台"
// @Param arch query string true "架构"
// @Success 200 {object} response.Response "删除成功"
// @Router /gaia/app-version/releases/:id/download [delete]
func (appVersionApi *AppVersionApi) DeleteDownload(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
response.FailWithMessage("无效的版本 id", c)
return
}
var q gaiaReq.AppVersionDeleteQuery
if err = c.ShouldBindQuery(&q); err != nil {
response.FailWithMessage(err.Error(), c)
return
}
if err = appVersionService.DeleteDownload(uint(id), q.Platform, q.Arch); err != nil {
global.GVA_LOG.Error("删除安装包失败!", zap.Error(err))
response.FailWithMessage(err.Error(), c)
return
}
response.Ok(c)
}
+1
View File
@@ -10,6 +10,7 @@ type ApiGroup struct {
TestApi TestApi
SystemOAuth2Api SystemOAuth2Api
BatchWorkflowApi BatchWorkflowApi
AppVersionApi
} }
var ( var (
+6
View File
@@ -73,6 +73,9 @@ func RegisterTables() {
gaia.ForwardingExtend{}, // Extend Forwarding Extend gaia.ForwardingExtend{}, // Extend Forwarding Extend
gaia.BatchWorkflow{}, // Extend Batch Workflow gaia.BatchWorkflow{}, // Extend Batch Workflow
gaia.BatchWorkflowTask{}, // Extend Batch Workflow Task gaia.BatchWorkflowTask{}, // Extend Batch Workflow Task
gaia.AppVersionConfig{}, // 应用版本全局配置(Token
gaia.AppVersionRelease{}, // 应用版本发布
gaia.AppVersionDownload{}, // 应用版本各平台安装包
system.SysUserGlobalCode{}, // Extend Global Code system.SysUserGlobalCode{}, // Extend Global Code
// Extend gaia model // Extend gaia model
) )
@@ -127,6 +130,9 @@ func createPostgreSQLSequences(db *gorm.DB) {
"forwarding_extends", "forwarding_extends",
"batch_workflows", "batch_workflows",
"batch_workflow_tasks", "batch_workflow_tasks",
"app_version_config",
"app_version_releases",
"app_version_downloads",
"sys_user_global_codes", "sys_user_global_codes",
} }
+1
View File
@@ -21,5 +21,6 @@ func initBizRouter(routers ...*gin.RouterGroup) {
gaiaRouter.InitTestRouter(privateGroup, publicGroup) gaiaRouter.InitTestRouter(privateGroup, publicGroup)
gaiaRouter.InitSystemRouter(privateGroup) gaiaRouter.InitSystemRouter(privateGroup)
gaiaRouter.InitWorkflowRouter(privateGroup) gaiaRouter.InitWorkflowRouter(privateGroup)
gaiaRouter.InitAppVersionRouter(publicGroup, privateGroup)
} }
} }
@@ -0,0 +1,14 @@
package gaia
import "time"
// AppVersionConfig 应用版本全局配置(仅链接 Token,与具体版本解耦)
type AppVersionConfig struct {
Id uint `json:"id" gorm:"primarykey;column:id;comment:id"`
LinkToken *string `json:"link_token,omitempty" gorm:"column:link_token;size:255;comment:链接token,配置后GET /latest需传此token"`
UpdatedAt time.Time `json:"updated_at" gorm:"column:updated_at;comment:更新时间"`
}
func (AppVersionConfig) TableName() string {
return "app_version_config"
}
@@ -0,0 +1,21 @@
package gaia
import "time"
// Platform: darwin | win32 | linux
// Arch: x64 | arm64
// AppVersionDownload 某版本的各平台/架构安装包
type AppVersionDownload struct {
Id uint `json:"id" gorm:"primarykey;column:id;comment:id"`
ReleaseId uint `json:"release_id" gorm:"column:release_id;not null;index;comment:关联的发布id"`
Platform string `json:"platform" gorm:"column:platform;size:32;not null;comment:平台 darwin|win32|linux"`
Arch string `json:"arch" gorm:"column:arch;size:16;not null;comment:架构 x64|arm64"`
DownloadUrl string `json:"download_url" gorm:"column:download_url;size:1024;not null;comment:下载地址(完整URL或相对路径)"`
FileName string `json:"file_name" gorm:"column:file_name;size:256;comment:原始文件名"`
CreatedAt time.Time `json:"created_at" gorm:"column:created_at;comment:创建时间"`
}
func (AppVersionDownload) TableName() string {
return "app_version_downloads"
}
@@ -0,0 +1,16 @@
package gaia
import "time"
// AppVersionRelease 应用版本发布(多版本列表中的一条)
type AppVersionRelease struct {
Id uint `json:"id" gorm:"primarykey;column:id;comment:id"`
Version string `json:"version" gorm:"column:version;size:64;not null;comment:版本号"`
ReleaseNotes string `json:"release_notes" gorm:"column:release_notes;type:text;comment:更新说明"`
CreatedAt time.Time `json:"created_at" gorm:"column:created_at;comment:创建时间"`
UpdatedAt time.Time `json:"updated_at" gorm:"column:updated_at;comment:更新时间"`
}
func (AppVersionRelease) TableName() string {
return "app_version_releases"
}
@@ -0,0 +1,29 @@
package request
// AppVersionTokenConfig 仅设置链接 Token(全局)
type AppVersionTokenConfig struct {
LinkToken *string `json:"link_token,omitempty"` // 空或 "********" 不更新,"" 清除
}
// AppVersionTokenReveal 输入登录密码以查看明文 Token
type AppVersionTokenReveal struct {
Password string `json:"password" binding:"required"`
}
// AppVersionReleaseCreate 新增版本
type AppVersionReleaseCreate struct {
Version string `json:"version" binding:"required"` // 版本号
ReleaseNotes string `json:"release_notes"` // 更新说明
}
// AppVersionReleaseUpdate 更新版本信息
type AppVersionReleaseUpdate struct {
Version string `json:"version" binding:"required"`
ReleaseNotes string `json:"release_notes"`
}
// AppVersionDeleteQuery 删除某平台/架构包
type AppVersionDeleteQuery struct {
Platform string `form:"platform" binding:"required,oneof=darwin win32 linux"`
Arch string `form:"arch" binding:"required,oneof=x64 arm64"`
}
@@ -0,0 +1,42 @@
package response
// LatestVersionResponse 客户端 GET /latest 返回(Electron 更新检测)
type LatestVersionResponse struct {
Version string `json:"version"`
ReleaseNotes string `json:"releaseNotes"`
DownloadUrl string `json:"downloadUrl"`
}
// AppVersionTokenConfig 管理端获取的全局 Token 配置(脱敏)
type AppVersionTokenConfig struct {
LinkToken *string `json:"link_token,omitempty"`
}
// ReleaseListItem 版本列表项
type ReleaseListItem struct {
Id uint `json:"id"`
Version string `json:"version"`
ReleaseNotes string `json:"release_notes"`
CreatedAt string `json:"created_at"`
Downloads []DownloadItem `json:"downloads"`
}
// ReleaseDetail 单个版本详情(含包列表)
type ReleaseDetail struct {
Id uint `json:"id"`
Version string `json:"version"`
ReleaseNotes string `json:"release_notes"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Downloads []DownloadItem `json:"downloads"`
}
// DownloadItem 单个平台/架构的安装包信息
type DownloadItem struct {
Id uint `json:"id"`
Platform string `json:"platform"`
Arch string `json:"arch"`
DownloadUrl string `json:"download_url"`
FileName string `json:"file_name"`
CreatedAt string `json:"created_at,omitempty"`
}
+26
View File
@@ -0,0 +1,26 @@
package gaia
import (
"github.com/gin-gonic/gin"
)
type AppVersionRouter struct{}
// InitAppVersionRouter 公开:GET /latest、GET /releases(版本列表);鉴权:/gaia/app-version/*
func (r *AppVersionRouter) InitAppVersionRouter(publicGroup, privateGroup *gin.RouterGroup) {
publicGroup.GET("/latest", appVersionApi.GetLatest)
publicGroup.GET("/releases", appVersionApi.ListReleases) // 公开:版本列表,无需登录
appVersion := privateGroup.Group("gaia/app-version")
{
appVersion.GET("token", appVersionApi.GetTokenConfig) // 全局 Token 配置(脱敏)
appVersion.PUT("token", appVersionApi.SetTokenConfig)
appVersion.POST("token/reveal", appVersionApi.RevealToken) // 密码验证后查看明文 Token
appVersion.GET("releases", appVersionApi.ListReleases) // 版本列表
appVersion.POST("releases", appVersionApi.CreateRelease) // 新增版本
appVersion.GET("releases/:id", appVersionApi.GetRelease) // 版本详情
appVersion.PUT("releases/:id", appVersionApi.UpdateRelease) // 更新版本信息
appVersion.POST("releases/:id/upload", appVersionApi.UploadToRelease) // 上传安装包(自动识别平台/架构)
appVersion.DELETE("releases/:id/download", appVersionApi.DeleteDownload) // 删除某包
}
}
+2
View File
@@ -9,6 +9,7 @@ type RouterGroup struct {
SystemRouter SystemRouter
TestRouter TestRouter
WorkflowRouter WorkflowRouter
AppVersionRouter
} }
var ( var (
@@ -20,3 +21,4 @@ var systemApi = api.ApiGroupApp.GaiaApiGroup.SystemApi
var quotaApi = api.ApiGroupApp.GaiaApiGroup.QuotaApi var quotaApi = api.ApiGroupApp.GaiaApiGroup.QuotaApi
var testApi = api.ApiGroupApp.GaiaApiGroup.TestApi var testApi = api.ApiGroupApp.GaiaApiGroup.TestApi
var batchWorkflowApi = api.ApiGroupApp.GaiaApiGroup.BatchWorkflowApi var batchWorkflowApi = api.ApiGroupApp.GaiaApiGroup.BatchWorkflowApi
var appVersionApi = api.ApiGroupApp.GaiaApiGroup.AppVersionApi
+260
View File
@@ -0,0 +1,260 @@
package gaia
import (
"errors"
"fmt"
"mime/multipart"
"time"
"github.com/flipped-aurora/gin-vue-admin/server/global"
"github.com/flipped-aurora/gin-vue-admin/server/model/gaia"
"github.com/flipped-aurora/gin-vue-admin/server/model/gaia/request"
"github.com/flipped-aurora/gin-vue-admin/server/model/gaia/response"
"github.com/flipped-aurora/gin-vue-admin/server/model/system"
"github.com/flipped-aurora/gin-vue-admin/server/utils"
"github.com/flipped-aurora/gin-vue-admin/server/utils/upload"
"gorm.io/gorm"
)
const tokenMask = "********"
type AppVersionService struct{}
// getOrCreateConfig 获取或创建全局配置(单例)
func (s *AppVersionService) getOrCreateConfig() (*gaia.AppVersionConfig, error) {
var cfg gaia.AppVersionConfig
err := global.GVA_DB.First(&cfg).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
if err = global.GVA_DB.Create(&cfg).Error; err != nil {
return nil, err
}
return &cfg, nil
}
return nil, err
}
return &cfg, nil
}
// downloadsToItems 将下载记录转为 API 返回的 DownloadItem 列表
func (s *AppVersionService) downloadsToItems(releaseID uint) ([]response.DownloadItem, error) {
var downloads []gaia.AppVersionDownload
if err := global.GVA_DB.Where("release_id = ?", releaseID).Find(&downloads).Error; err != nil {
return nil, err
}
items := make([]response.DownloadItem, 0, len(downloads))
for _, d := range downloads {
items = append(items, response.DownloadItem{
Id: d.Id,
Platform: d.Platform,
Arch: d.Arch,
DownloadUrl: d.DownloadUrl,
FileName: d.FileName,
CreatedAt: d.CreatedAt.Format(time.RFC3339),
})
}
return items, nil
}
// GetLatest 客户端「最新版本」;token 从全局 config 校验。仅必填 platform;arch 可选:未传则取该平台第一个包,传了但无匹配则按 platform 取第一个。
func (s *AppVersionService) GetLatest(platform, arch, token string) (*response.LatestVersionResponse, int) {
cfg, err := s.getOrCreateConfig()
if err != nil {
return nil, 500
}
if cfg.LinkToken != nil && *cfg.LinkToken != "" && (token == "" || token != *cfg.LinkToken) {
return nil, 401
}
var release gaia.AppVersionRelease
if err = global.GVA_DB.Order("id DESC").First(&release).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, 404
}
return nil, 500
}
var download gaia.AppVersionDownload
if arch != "" {
err = global.GVA_DB.Where("release_id = ? AND platform = ? AND arch = ?", release.Id, platform, arch).First(&download).Error
}
if arch == "" || errors.Is(err, gorm.ErrRecordNotFound) {
err = global.GVA_DB.Where("release_id = ? AND platform = ?", release.Id, platform).First(&download).Error
}
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, 404
}
return nil, 500
}
return &response.LatestVersionResponse{
Version: release.Version,
ReleaseNotes: release.ReleaseNotes,
DownloadUrl: download.DownloadUrl,
}, 200
}
// GetTokenConfig 管理端获取全局 Token(脱敏)
func (s *AppVersionService) GetTokenConfig() (*response.AppVersionTokenConfig, error) {
cfg, err := s.getOrCreateConfig()
if err != nil {
return nil, err
}
var linkToken *string
if cfg.LinkToken != nil && *cfg.LinkToken != "" {
mask := tokenMask
linkToken = &mask
}
return &response.AppVersionTokenConfig{LinkToken: linkToken}, nil
}
// SetTokenConfig 管理端设置全局 Token
func (s *AppVersionService) SetTokenConfig(req request.AppVersionTokenConfig) error {
cfg, err := s.getOrCreateConfig()
if err != nil {
return err
}
if req.LinkToken == nil || *req.LinkToken == tokenMask {
return nil
}
if *req.LinkToken == "" {
cfg.LinkToken = nil
} else {
cfg.LinkToken = req.LinkToken
}
return global.GVA_DB.Save(cfg).Error
}
// RevealToken 验证当前用户登录密码后返回明文 Token
func (s *AppVersionService) RevealToken(userID uint, password string) (string, error) {
var user system.SysUser
if err := global.GVA_DB.Select("password").First(&user, userID).Error; err != nil {
return "", err
}
if !utils.BcryptCheck(password, user.Password) {
return "", errors.New("密码错误")
}
cfg, err := s.getOrCreateConfig()
if err != nil {
return "", err
}
if cfg.LinkToken == nil || *cfg.LinkToken == "" {
return "", nil
}
return *cfg.LinkToken, nil
}
// ListReleases 版本列表(按 id 倒序)
func (s *AppVersionService) ListReleases() ([]response.ReleaseListItem, error) {
var releases []gaia.AppVersionRelease
if err := global.GVA_DB.Order("id DESC").Find(&releases).Error; err != nil {
return nil, err
}
result := make([]response.ReleaseListItem, 0, len(releases))
for _, r := range releases {
items, err := s.downloadsToItems(r.Id)
if err != nil {
return nil, err
}
result = append(result, response.ReleaseListItem{
Id: r.Id,
Version: r.Version,
ReleaseNotes: r.ReleaseNotes,
CreatedAt: r.CreatedAt.Format(time.RFC3339),
Downloads: items,
})
}
return result, nil
}
// CreateRelease 新增版本
func (s *AppVersionService) CreateRelease(req request.AppVersionReleaseCreate) (*gaia.AppVersionRelease, error) {
release := gaia.AppVersionRelease{Version: req.Version, ReleaseNotes: req.ReleaseNotes}
if err := global.GVA_DB.Create(&release).Error; err != nil {
return nil, err
}
return &release, nil
}
// GetReleaseByID 获取单个版本详情
func (s *AppVersionService) GetReleaseByID(id uint) (*response.ReleaseDetail, error) {
var release gaia.AppVersionRelease
if err := global.GVA_DB.First(&release, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("版本不存在")
}
return nil, err
}
items, err := s.downloadsToItems(release.Id)
if err != nil {
return nil, err
}
return &response.ReleaseDetail{
Id: release.Id,
Version: release.Version,
ReleaseNotes: release.ReleaseNotes,
CreatedAt: release.CreatedAt.Format(time.RFC3339),
UpdatedAt: release.UpdatedAt.Format(time.RFC3339),
Downloads: items,
}, nil
}
// UpdateRelease 更新版本信息
func (s *AppVersionService) UpdateRelease(id uint, req request.AppVersionReleaseUpdate) error {
var release gaia.AppVersionRelease
if err := global.GVA_DB.First(&release, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("版本不存在")
}
return err
}
release.Version = req.Version
release.ReleaseNotes = req.ReleaseNotes
return global.GVA_DB.Save(&release).Error
}
// UploadPackageToRelease 上传安装包到指定版本,根据文件名自动推断 platform/arch
func (s *AppVersionService) UploadPackageToRelease(releaseID uint, file *multipart.FileHeader, buildDownloadUrl func(path string) string) error {
platform, arch := utils.InferPlatformArch(file.Filename)
if platform == "" || arch == "" {
return fmt.Errorf("无法从文件名推断平台/架构,请使用 .dmg/.exe/.deb/.AppImage 等常见安装包")
}
var release gaia.AppVersionRelease
if err := global.GVA_DB.First(&release, releaseID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("版本不存在")
}
return err
}
oss := upload.NewOss()
filePath, _, err := oss.UploadFile(file)
if err != nil {
return err
}
fullURL := buildDownloadUrl(filePath)
var download gaia.AppVersionDownload
err = global.GVA_DB.Where("release_id = ? AND platform = ? AND arch = ?", releaseID, platform, arch).First(&download).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
download = gaia.AppVersionDownload{
ReleaseId: releaseID,
Platform: platform,
Arch: arch,
DownloadUrl: fullURL,
FileName: file.Filename,
}
return global.GVA_DB.Create(&download).Error
}
return err
}
download.DownloadUrl = fullURL
download.FileName = file.Filename
return global.GVA_DB.Save(&download).Error
}
// DeleteDownload 删除指定版本下某 platform/arch 的安装包记录
func (s *AppVersionService) DeleteDownload(releaseID uint, platform, arch string) error {
return global.GVA_DB.Where("release_id = ? AND platform = ? AND arch = ?", releaseID, platform, arch).
Delete(&gaia.AppVersionDownload{}).Error
}
+2
View File
@@ -7,4 +7,6 @@ type ServiceGroup struct {
TenantsService TenantsService
TestService TestService
BatchWorkflowService BatchWorkflowService
// extned: app version
AppVersionService
} }
+7
View File
@@ -137,6 +137,13 @@ func (menuService *MenuService) AddBaseMenu(menu system.SysBaseMenu) error {
if !errors.Is(global.GVA_DB.Where("name = ?", menu.Name).First(&system.SysBaseMenu{}).Error, gorm.ErrRecordNotFound) { if !errors.Is(global.GVA_DB.Where("name = ?", menu.Name).First(&system.SysBaseMenu{}).Error, gorm.ErrRecordNotFound) {
return errors.New("存在重复name,请修改name") return errors.New("存在重复name,请修改name")
} }
// 判断是否pgsql
if global.GVA_CONFIG.System.DbType == "pgsql" {
var logMneu system.SysBaseMenu
if global.GVA_DB.Order("id desc").First(&logMneu).Error == nil {
menu.ID = logMneu.ID + 1
}
}
return global.GVA_DB.Create(&menu).Error return global.GVA_DB.Create(&menu).Error
} }
+10
View File
@@ -219,6 +219,16 @@ func (i *initApi) InitializeData(ctx context.Context) (context.Context, error) {
{ApiGroup: "批量处理工作流", Method: "POST", Path: "/gaia/workflow/batch/:id/retry-failed", Description: "仅重试失败的任务"}, {ApiGroup: "批量处理工作流", Method: "POST", Path: "/gaia/workflow/batch/:id/retry-failed", Description: "仅重试失败的任务"},
{ApiGroup: "批量处理工作流", Method: "POST", Path: "/gaia/workflow/batch/:id/resume", Description: "恢复批量处理"}, {ApiGroup: "批量处理工作流", Method: "POST", Path: "/gaia/workflow/batch/:id/resume", Description: "恢复批量处理"},
{ApiGroup: "批量处理工作流", Method: "GET", Path: "/gaia/workflow/batch/:id/download", Description: "下载结果"}, {ApiGroup: "批量处理工作流", Method: "GET", Path: "/gaia/workflow/batch/:id/download", Description: "下载结果"},
{ApiGroup: "应用版本", Method: "GET", Path: "/gaia/app-version/token", Description: "获取链接Token配置"},
{ApiGroup: "应用版本", Method: "PUT", Path: "/gaia/app-version/token", Description: "设置链接Token"},
{ApiGroup: "应用版本", Method: "POST", Path: "/gaia/app-version/token/reveal", Description: "密码验证后查看Token"},
{ApiGroup: "应用版本", Method: "GET", Path: "/gaia/app-version/token/generate", Description: "随机生成Token"},
{ApiGroup: "应用版本", Method: "GET", Path: "/gaia/app-version/releases", Description: "版本列表"},
{ApiGroup: "应用版本", Method: "POST", Path: "/gaia/app-version/releases", Description: "新增版本"},
{ApiGroup: "应用版本", Method: "GET", Path: "/gaia/app-version/releases/:id", Description: "版本详情"},
{ApiGroup: "应用版本", Method: "PUT", Path: "/gaia/app-version/releases/:id", Description: "更新版本信息"},
{ApiGroup: "应用版本", Method: "POST", Path: "/gaia/app-version/releases/:id/upload", Description: "上传安装包(自动识别平台架构)"},
{ApiGroup: "应用版本", Method: "DELETE", Path: "/gaia/app-version/releases/:id/download", Description: "删除指定平台架构包"},
// Extend Stop: batch workflow // Extend Stop: batch workflow
} }
if err := db.Create(&entities).Error; err != nil { if err := db.Create(&entities).Error; err != nil {
@@ -51,6 +51,9 @@ func (i *initMenuAuthority) InitializeData(ctx context.Context) (next context.Co
if err = db.Model(&authorities[0]).Association("SysBaseMenus").Append(menus[37:40]); err != nil { if err = db.Model(&authorities[0]).Association("SysBaseMenus").Append(menus[37:40]); err != nil {
return next, err return next, err
} }
if err = db.Model(&authorities[0]).Association("SysBaseMenus").Append(menus[40:41]); err != nil {
return next, err
}
if err = db.Model(&authorities[0]).Association("SysBaseMenus").Append(menus[2:5]); err != nil { if err = db.Model(&authorities[0]).Association("SysBaseMenus").Append(menus[2:5]); err != nil {
return next, err return next, err
} }
+43
View File
@@ -335,6 +335,49 @@ func (i *initCasbin) InitializeData(ctx context.Context) (context.Context, error
{Ptype: "p", V0: "1", V1: "/gaia/workflow/batch/:id/resume", V2: "POST"}, {Ptype: "p", V0: "1", V1: "/gaia/workflow/batch/:id/resume", V2: "POST"},
{Ptype: "p", V0: "1", V1: "/gaia/workflow/batch/:id/download", V2: "GET"}, {Ptype: "p", V0: "1", V1: "/gaia/workflow/batch/:id/download", V2: "GET"},
// Extend Stop: batch workflow // Extend Stop: batch workflow
// Extend Start: app version (应用版本与多平台包管理)
{Ptype: "p", V0: "888", V1: "/gaia/app-version/token", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/gaia/app-version/token", V2: "PUT"},
{Ptype: "p", V0: "888", V1: "/gaia/app-version/token/reveal", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/gaia/app-version/token/generate", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/gaia/app-version/releases", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/gaia/app-version/releases", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/gaia/app-version/releases/:id", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/gaia/app-version/releases/:id", V2: "PUT"},
{Ptype: "p", V0: "888", V1: "/gaia/app-version/releases/:id/upload", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/gaia/app-version/releases/:id/download", V2: "DELETE"},
{Ptype: "p", V0: "8881", V1: "/gaia/app-version/token", V2: "GET"},
{Ptype: "p", V0: "8881", V1: "/gaia/app-version/token", V2: "PUT"},
{Ptype: "p", V0: "8881", V1: "/gaia/app-version/token/reveal", V2: "POST"},
{Ptype: "p", V0: "8881", V1: "/gaia/app-version/token/generate", V2: "GET"},
{Ptype: "p", V0: "8881", V1: "/gaia/app-version/releases", V2: "GET"},
{Ptype: "p", V0: "8881", V1: "/gaia/app-version/releases", V2: "POST"},
{Ptype: "p", V0: "8881", V1: "/gaia/app-version/releases/:id", V2: "GET"},
{Ptype: "p", V0: "8881", V1: "/gaia/app-version/releases/:id", V2: "PUT"},
{Ptype: "p", V0: "8881", V1: "/gaia/app-version/releases/:id/upload", V2: "POST"},
{Ptype: "p", V0: "8881", V1: "/gaia/app-version/releases/:id/download", V2: "DELETE"},
{Ptype: "p", V0: "9528", V1: "/gaia/app-version/token", V2: "GET"},
{Ptype: "p", V0: "9528", V1: "/gaia/app-version/token", V2: "PUT"},
{Ptype: "p", V0: "9528", V1: "/gaia/app-version/token/reveal", V2: "POST"},
{Ptype: "p", V0: "9528", V1: "/gaia/app-version/token/generate", V2: "GET"},
{Ptype: "p", V0: "9528", V1: "/gaia/app-version/releases", V2: "GET"},
{Ptype: "p", V0: "9528", V1: "/gaia/app-version/releases", V2: "POST"},
{Ptype: "p", V0: "9528", V1: "/gaia/app-version/releases/:id", V2: "GET"},
{Ptype: "p", V0: "9528", V1: "/gaia/app-version/releases/:id", V2: "PUT"},
{Ptype: "p", V0: "9528", V1: "/gaia/app-version/releases/:id/upload", V2: "POST"},
{Ptype: "p", V0: "9528", V1: "/gaia/app-version/releases/:id/download", V2: "DELETE"},
{Ptype: "p", V0: "1", V1: "/gaia/app-version/token", V2: "GET"},
{Ptype: "p", V0: "1", V1: "/gaia/app-version/token", V2: "PUT"},
{Ptype: "p", V0: "1", V1: "/gaia/app-version/token/reveal", V2: "POST"},
{Ptype: "p", V0: "1", V1: "/gaia/app-version/token/generate", V2: "GET"},
{Ptype: "p", V0: "1", V1: "/gaia/app-version/releases", V2: "GET"},
{Ptype: "p", V0: "1", V1: "/gaia/app-version/releases", V2: "POST"},
{Ptype: "p", V0: "1", V1: "/gaia/app-version/releases/:id", V2: "GET"},
{Ptype: "p", V0: "1", V1: "/gaia/app-version/releases/:id", V2: "PUT"},
{Ptype: "p", V0: "1", V1: "/gaia/app-version/releases/:id/upload", V2: "POST"},
{Ptype: "p", V0: "1", V1: "/gaia/app-version/releases/:id/download", V2: "DELETE"},
// Extend Stop: app version
} }
if err := db.Create(&entities).Error; err != nil { if err := db.Create(&entities).Error; err != nil {
return ctx, errors.Wrap(err, "Casbin 表 ("+i.InitializerName()+") 数据初始化失败!") return ctx, errors.Wrap(err, "Casbin 表 ("+i.InitializerName()+") 数据初始化失败!")
+1
View File
@@ -93,6 +93,7 @@ func (i *initMenu) InitializeData(ctx context.Context) (next context.Context, er
{GVA_MODEL: global.GVA_MODEL{ID: 38}, MenuLevel: 0, Hidden: false, ParentId: 0, Path: "SystemIntegrated", Name: "SystemIntegrated", Component: "view/systemIntegrated/index.vue", Sort: 1, Meta: Meta{Title: "系统集成", Icon: "box"}}, {GVA_MODEL: global.GVA_MODEL{ID: 38}, MenuLevel: 0, Hidden: false, ParentId: 0, Path: "SystemIntegrated", Name: "SystemIntegrated", Component: "view/systemIntegrated/index.vue", Sort: 1, Meta: Meta{Title: "系统集成", Icon: "box"}},
{GVA_MODEL: global.GVA_MODEL{ID: 39}, MenuLevel: 0, Hidden: false, ParentId: 38, Path: "IntegratedDingTalk", Name: "IntegratedDingTalk", Component: "view/systemIntegrated/dingTalk/index.vue", Sort: 1, Meta: Meta{Title: "钉钉", Icon: "turn-off"}}, {GVA_MODEL: global.GVA_MODEL{ID: 39}, MenuLevel: 0, Hidden: false, ParentId: 38, Path: "IntegratedDingTalk", Name: "IntegratedDingTalk", Component: "view/systemIntegrated/dingTalk/index.vue", Sort: 1, Meta: Meta{Title: "钉钉", Icon: "turn-off"}},
{GVA_MODEL: global.GVA_MODEL{ID: 40}, MenuLevel: 0, Hidden: false, ParentId: 38, Path: "IntegratedOAuth2", Name: "IntegratedOAuth2", Component: "view/systemIntegrated/oauth2/index.vue", Sort: 2, Meta: Meta{Title: "OAuth2", Icon: "share"}}, {GVA_MODEL: global.GVA_MODEL{ID: 40}, MenuLevel: 0, Hidden: false, ParentId: 38, Path: "IntegratedOAuth2", Name: "IntegratedOAuth2", Component: "view/systemIntegrated/oauth2/index.vue", Sort: 2, Meta: Meta{Title: "OAuth2", Icon: "share"}},
{GVA_MODEL: global.GVA_MODEL{ID: 41}, MenuLevel: 0, Hidden: false, ParentId: 0, Path: "AppVersion", Name: "AppVersion", Component: "view/gaia/appVersion/index.vue", Sort: 10, Meta: Meta{Title: "版本管理", Icon: "upload-filled"}},
// 二开部分 // 二开部分
} }
if err = db.Create(&entities).Error; err != nil { if err = db.Create(&entities).Error; err != nil {
+45
View File
@@ -0,0 +1,45 @@
package utils
import "strings"
// InferPlatformArch 根据安装包文件名推断 platform 与 arch
// 返回 platform (darwin|win32|linux), arch (x64|arm64)
// Windows 安装包通常为一个 exe 同时支持 arm/amd64,统一存为 win32+x64,客户端请求 win32 时返回该包
func InferPlatformArch(filename string) (platform, arch string) {
name := strings.ToLower(filename)
// .dmg → macOS
if strings.HasSuffix(name, ".dmg") {
platform = "darwin"
if strings.Contains(name, "arm64") || strings.Contains(name, "aarch64") {
arch = "arm64"
} else if strings.Contains(name, "x64") || strings.Contains(name, "amd64") || strings.Contains(name, "x86_64") {
arch = "x64"
} else {
arch = "arm64" // 默认 Apple Silicon
}
return
}
// .exe → Windows,不区分架构,统一 x64
if strings.HasSuffix(name, ".exe") {
platform = "win32"
arch = "x64"
return
}
// .deb → Linux
if strings.HasSuffix(name, ".deb") {
platform = "linux"
if strings.Contains(name, "arm64") || strings.Contains(name, "aarch64") {
arch = "arm64"
} else {
arch = "x64" // amd64
}
return
}
// .AppImage → Linux,一般为 x64(已转小写故 .appimage
if strings.HasSuffix(name, ".appimage") {
platform = "linux"
arch = "x64"
return
}
return "", ""
}
+55
View File
@@ -0,0 +1,55 @@
import service from '@/utils/request'
/** 获取全局 Token 配置(脱敏) */
export const getAppVersionToken = () => {
return service({ url: '/gaia/app-version/token', method: 'get' })
}
/** 设置全局 Token */
export const setAppVersionToken = (data) => {
return service({ url: '/gaia/app-version/token', method: 'put', data })
}
/** 输入登录密码验证后查看明文 Token */
export const revealAppVersionToken = (data) => {
return service({ url: '/gaia/app-version/token/reveal', method: 'post', data })
}
/** 版本列表 */
export const getAppVersionReleases = () => {
return service({ url: '/gaia/app-version/releases', method: 'get' })
}
/** 新增版本 */
export const createAppVersionRelease = (data) => {
return service({ url: '/gaia/app-version/releases', method: 'post', data })
}
/** 版本详情 */
export const getAppVersionRelease = (id) => {
return service({ url: `/gaia/app-version/releases/${id}`, method: 'get' })
}
/** 更新版本信息 */
export const updateAppVersionRelease = (id, data) => {
return service({ url: `/gaia/app-version/releases/${id}`, method: 'put', data })
}
/** 上传安装包到指定版本(自动识别平台/架构),formData: file */
export const uploadAppVersionPackage = (releaseId, formData) => {
return service({
url: `/gaia/app-version/releases/${releaseId}/upload`,
method: 'post',
data: formData,
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 删除指定版本下某 platform/arch 的包 */
export const deleteAppVersionDownload = (releaseId, params) => {
return service({
url: `/gaia/app-version/releases/${releaseId}/download`,
method: 'delete',
params
})
}
+1
View File
@@ -17,6 +17,7 @@
"/src/view/example/customer/customer.vue": "Customer", "/src/view/example/customer/customer.vue": "Customer",
"/src/view/example/index.vue": "Example", "/src/view/example/index.vue": "Example",
"/src/view/example/upload/upload.vue": "Upload", "/src/view/example/upload/upload.vue": "Upload",
"/src/view/gaia/appVersion/index.vue": "AppVersion",
"/src/view/gaia/dashboard/components/accountMoneyTable.vue": "AccountMoneyTable", "/src/view/gaia/dashboard/components/accountMoneyTable.vue": "AccountMoneyTable",
"/src/view/gaia/dashboard/components/appTokenDailyQuotaNumbers.vue": "AppTokenDailyQuotaNumbers", "/src/view/gaia/dashboard/components/appTokenDailyQuotaNumbers.vue": "AppTokenDailyQuotaNumbers",
"/src/view/gaia/dashboard/components/appTokenQuota.vue": "AppTokenQuota", "/src/view/gaia/dashboard/components/appTokenQuota.vue": "AppTokenQuota",
@@ -0,0 +1,511 @@
<template>
<div class="app-version">
<div class="page-header flex flex-wrap items-center justify-between gap-4">
<div class="flex">
<p class="text-gray-500 mt-2">
版本列表按创建时间倒序客户端 GET /latest 取最新一条支持拖拽上传系统将根据文件名自动识别平台与架构<br />
本功能主要为第三方软件进行版本管理
</p>
</div>
<div class="flex gap-2">
<el-button type="primary" icon="Plus" @click="openTokenDialog">Token 配置</el-button>
<el-button type="primary" icon="Plus" @click="openAddDialog">新增版本</el-button>
</div>
</div>
<!-- 版本列表 -->
<div class="card mt-6">
<el-table v-loading="loading" :data="releases" stripe>
<el-table-column prop="created_at" label="上传时间" width="180">
<template #default="{ row }">
{{ formatTime(row.created_at) }}
</template>
</el-table-column>
<el-table-column prop="version" label="版本号" width="120" />
<el-table-column prop="release_notes" label="更新说明" min-width="200">
<template #default="{ row }">
<span class="line-clamp-2">{{ row.release_notes || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="安装包" width="220">
<template #default="{ row }">
<div class="flex flex-wrap gap-1">
<el-tag v-for="d in row.downloads" :key="d.id" size="small" type="info">
{{ platformArchLabel(d.platform, d.arch) }}
</el-tag>
<span v-if="!row.downloads?.length" class="text-gray-400">暂无</span>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="openEditDialog(row)">编辑</el-button>
<el-button type="primary" link size="small" @click="openUploadDialog(row)">上传安装包</el-button>
<el-button type="danger" link size="small" @click="removeDownloadConfirm(row)">删除包</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- Token 配置弹窗 -->
<el-dialog v-model="tokenDialogVisible" title="链接 Token 配置" width="480" @closed="tokenForm.link_token = ''; tokenVerified = false">
<div class="flex gap-2 items-center">
<el-input
v-model="tokenForm.link_token"
:type="tokenVerified ? 'text' : 'password'"
:placeholder="tokenVerified ? '留空表示不校验' : '已配置时需验证密码后查看'"
show-password
clearable
class="flex-1"
/>
<el-button type="primary" icon="Refresh" @click="randomGenerateToken">随机生成</el-button>
<el-button v-if="hasToken && !tokenVerified" type="default" @click="showPasswordVerify">查看</el-button>
</div>
<p class="text-gray-500 text-sm mt-2">配置后客户端 GET /latest 需传 token=xxx清空并保存则取消校验</p>
<template #footer>
<el-button @click="tokenDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveToken">保存</el-button>
</template>
</el-dialog>
<!-- 查看 Token 密码验证 -->
<el-dialog v-model="passwordVerifyVisible" title="验证身份" width="360" append-to-body>
<el-input
v-model="passwordVerifyValue"
type="password"
placeholder="请输入登录密码"
show-password
@keyup.enter="confirmPasswordVerify"
/>
<template #footer>
<el-button @click="passwordVerifyVisible = false">取消</el-button>
<el-button type="primary" :loading="passwordVerifyLoading" @click="confirmPasswordVerify">确定</el-button>
</template>
</el-dialog>
<!-- 新增版本对话框版本号说明拖拽上传 -->
<el-dialog
v-model="addDialogVisible"
title="新增版本"
width="560"
:close-on-click-modal="false"
@closed="resetAddForm"
>
<el-form ref="addFormRef" :model="addForm" :rules="addRules" label-width="100px">
<el-form-item label="版本号" prop="version">
<el-input v-model="addForm.version" placeholder="例如 0.0.5" />
</el-form-item>
<el-form-item label="更新说明" prop="release_notes">
<el-input v-model="addForm.release_notes" type="textarea" :rows="3" placeholder="选填" />
</el-form-item>
<el-form-item label="安装包">
<div
class="upload-drop border-2 border-dashed rounded-lg p-8 text-center transition-colors"
:class="{ 'border-primary bg-blue-50': dragOver, 'border-gray-300': !dragOver }"
@drop.prevent="onDrop"
@dragover.prevent="dragOver = true"
@dragleave="dragOver = false"
@click="triggerFileSelect"
>
<input
ref="addFileInput"
type="file"
multiple
accept=".dmg,.exe,.deb,.AppImage,.appimage"
class="hidden"
@change="onFileSelect"
>
<p class="text-gray-600">
<el-icon class="text-4xl mb-2"><UploadFilled /></el-icon><br>
拖拽文件到此处或点击选择<br>
<span class="text-sm text-gray-400">.dmg / .exe / .deb / .AppImage将自动识别平台与架构</span>
</p>
<ul v-if="addForm.files.length" class="mt-3 text-left text-sm space-y-1">
<li v-for="(f, i) in addForm.files" :key="i" class="flex items-center justify-between">
<span>{{ f.name }}</span>
<el-button type="danger" link size="small" @click.stop="removeAddFile(i)">移除</el-button>
</li>
</ul>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="addSubmitting" @click="submitAddVersion">
创建并上传
</el-button>
</template>
</el-dialog>
<!-- 编辑版本对话框 -->
<el-dialog v-model="editDialogVisible" title="编辑版本" width="520" @closed="editingRelease = null">
<el-form v-if="editingRelease" :model="editForm" label-width="100px">
<el-form-item label="版本号">
<el-input v-model="editForm.version" />
</el-form-item>
<el-form-item label="更新说明">
<el-input v-model="editForm.release_notes" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="editSubmitting" @click="submitEditVersion">保存</el-button>
</template>
</el-dialog>
<!-- 为已有版本上传安装包 -->
<el-dialog v-model="uploadDialogVisible" title="上传安装包" width="500" @closed="uploadForm.files = []; uploadForm.releaseId = null">
<p v-if="uploadForm.releaseId" class="mb-3 text-gray-600">版本 ID: {{ uploadForm.releaseId }}拖拽或选择文件将自动识别平台与架构</p>
<div
class="upload-drop border-2 border-dashed rounded-lg p-6 text-center"
:class="{ 'border-primary bg-blue-50': uploadDragOver, 'border-gray-300': !uploadDragOver }"
@drop.prevent="onUploadDrop"
@dragover.prevent="uploadDragOver = true"
@dragleave="uploadDragOver = false"
@click="uploadFileInput?.click()"
>
<input ref="uploadFileInput" type="file" multiple accept=".dmg,.exe,.deb,.AppImage,.appimage" class="hidden" @change="onUploadFileSelect">
<p class="text-gray-600">拖拽或点击选择 .dmg / .exe / .deb / .AppImage</p>
<ul v-if="uploadForm.files.length" class="mt-2 text-sm text-left space-y-1">
<li v-for="(f, i) in uploadForm.files" :key="i" class="flex justify-between">
<span>{{ f.name }}</span>
<el-button type="danger" link size="small" @click.stop="uploadForm.files.splice(i, 1)">移除</el-button>
</li>
</ul>
</div>
<template #footer>
<el-button @click="uploadDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="uploadSubmitting" @click="submitUpload">上传</el-button>
</template>
</el-dialog>
<!-- 删除包选择要删的 platform/arch -->
<el-dialog v-model="deleteDownloadVisible" title="删除安装包" width="400">
<p class="mb-3">选择要删除的安装包</p>
<el-radio-group v-model="deleteDownloadTarget">
<el-radio
v-for="d in deleteDownloadCandidates"
:key="d.platform + d.arch"
:label="d.platform + '/' + d.arch"
>
{{ platformArchLabel(d.platform, d.arch) }} - {{ d.file_name }}
</el-radio>
</el-radio-group>
<template #footer>
<el-button @click="deleteDownloadVisible = false">取消</el-button>
<el-button type="danger" :loading="deleteSubmitting" @click="confirmDeleteDownload">删除</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { UploadFilled } from '@element-plus/icons-vue'
import {
getAppVersionToken,
setAppVersionToken,
revealAppVersionToken,
getAppVersionReleases,
createAppVersionRelease,
getAppVersionRelease,
updateAppVersionRelease,
uploadAppVersionPackage,
deleteAppVersionDownload
} from '@/api/gaia/appVersion'
defineOptions({ name: 'AppVersion' })
const loading = ref(false)
const releases = ref([])
const tokenDialogVisible = ref(false)
const tokenForm = reactive({ link_token: '' })
const hasToken = ref(false) // 是否已配置过 Token(脱敏 ********
const tokenVerified = ref(false) // 是否已通过密码验证看到明文
const passwordVerifyVisible = ref(false)
const passwordVerifyValue = ref('')
const passwordVerifyLoading = ref(false)
const addDialogVisible = ref(false)
const addFormRef = ref(null)
const addForm = reactive({ version: '', release_notes: '', files: [] })
const addRules = { version: [{ required: true, message: '请输入版本号', trigger: 'blur' }] }
const addFileInput = ref(null)
const dragOver = ref(false)
const addSubmitting = ref(false)
const editDialogVisible = ref(false)
const editingRelease = ref(null)
const editForm = reactive({ version: '', release_notes: '' })
const editSubmitting = ref(false)
const uploadDialogVisible = ref(false)
const uploadFileInput = ref(null)
const uploadDragOver = ref(false)
const uploadForm = reactive({ releaseId: null, files: [] })
const uploadSubmitting = ref(false)
const deleteDownloadVisible = ref(false)
const deleteDownloadRelease = ref(null)
const deleteDownloadCandidates = ref([])
const deleteDownloadTarget = ref('')
const deleteSubmitting = ref(false)
function formatTime(t) {
if (!t) return '-'
try {
const d = new Date(t)
return d.toLocaleString('zh-CN')
} catch {
return t
}
}
function platformArchLabel(platform, arch) {
const p = { darwin: 'macOS', win32: 'Windows', linux: 'Linux' }[platform] || platform
return `${p} ${arch}`
}
async function loadReleases() {
loading.value = true
try {
const res = await getAppVersionReleases()
releases.value = res.data || []
} catch (e) {
ElMessage.error(e?.response?.data?.msg || '加载版本列表失败')
} finally {
loading.value = false
}
}
function openTokenDialog() {
tokenDialogVisible.value = true
tokenVerified.value = false
getAppVersionToken().then(res => {
const t = res.data?.link_token ?? ''
hasToken.value = t === '********'
tokenForm.link_token = hasToken.value ? '' : t
}).catch(() => {})
}
function randomGenerateToken() {
const bytes = new Uint8Array(24)
crypto.getRandomValues(bytes)
const token = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
tokenForm.link_token = token
try {
navigator.clipboard.writeText(token)
ElMessage.success('已生成并复制到剪贴板')
} catch {
ElMessage.success('已生成,请手动复制')
}
}
function showPasswordVerify() {
passwordVerifyValue.value = ''
passwordVerifyVisible.value = true
}
async function confirmPasswordVerify() {
if (!passwordVerifyValue.value.trim()) {
ElMessage.warning('请输入登录密码')
return
}
passwordVerifyLoading.value = true
try {
const res = await revealAppVersionToken({ password: passwordVerifyValue.value })
const token = res.data?.token ?? ''
tokenForm.link_token = token
tokenVerified.value = true
passwordVerifyVisible.value = false
ElMessage.success('验证成功')
} catch (e) {
ElMessage.error(e?.response?.data?.msg || '密码错误')
} finally {
passwordVerifyLoading.value = false
}
}
async function saveToken() {
try {
let payload = tokenForm.link_token
if (hasToken.value && !tokenVerified.value && payload === '') payload = '********' // 未查看且未改,不更新
else if (payload === '') payload = ''
await setAppVersionToken({ link_token: payload })
ElMessage.success('已保存')
tokenDialogVisible.value = false
} catch (e) {
ElMessage.error(e?.response?.data?.msg || '保存失败')
}
}
function openAddDialog() {
addDialogVisible.value = true
}
function resetAddForm() {
addForm.version = ''
addForm.release_notes = ''
addForm.files = []
addFormRef.value?.resetFields()
}
function triggerFileSelect() {
addFileInput.value?.click()
}
function onDrop(e) {
dragOver.value = false
const list = Array.from(e.dataTransfer?.files || []).filter(f => {
const n = f.name.toLowerCase()
return n.endsWith('.dmg') || n.endsWith('.exe') || n.endsWith('.deb') || n.endsWith('.appimage')
})
addForm.files.push(...list)
}
function onFileSelect(e) {
const list = Array.from(e.target?.files || [])
addForm.files.push(...list)
if (addFileInput.value) addFileInput.value.value = ''
}
function removeAddFile(i) {
addForm.files.splice(i, 1)
}
async function submitAddVersion() {
await addFormRef.value?.validate().catch(() => {})
if (!addForm.version.trim()) {
ElMessage.warning('请输入版本号')
return
}
addSubmitting.value = true
try {
const createRes = await createAppVersionRelease({
version: addForm.version.trim(),
release_notes: addForm.release_notes?.trim() || ''
})
const releaseId = createRes.data?.id
if (!releaseId) throw new Error('创建版本失败')
for (const file of addForm.files) {
const fd = new FormData()
fd.append('file', file)
await uploadAppVersionPackage(releaseId, fd)
}
ElMessage.success('版本已创建并上传完成')
addDialogVisible.value = false
await loadReleases()
} catch (e) {
ElMessage.error(e?.response?.data?.msg || e?.message || '操作失败')
} finally {
addSubmitting.value = false
}
}
function openUploadDialog(row) {
uploadForm.releaseId = row.id
uploadForm.files = []
uploadDialogVisible.value = true
}
function onUploadDrop(e) {
uploadDragOver.value = false
const list = Array.from(e.dataTransfer?.files || []).filter(f => {
const n = f.name.toLowerCase()
return n.endsWith('.dmg') || n.endsWith('.exe') || n.endsWith('.deb') || n.endsWith('.appimage')
})
uploadForm.files.push(...list)
}
function onUploadFileSelect(e) {
const list = Array.from(e.target?.files || [])
uploadForm.files.push(...list)
if (uploadFileInput.value) uploadFileInput.value.value = ''
}
async function submitUpload() {
if (!uploadForm.releaseId || !uploadForm.files.length) {
ElMessage.warning('请选择要上传的文件')
return
}
uploadSubmitting.value = true
try {
for (const file of uploadForm.files) {
const fd = new FormData()
fd.append('file', file)
await uploadAppVersionPackage(uploadForm.releaseId, fd)
}
ElMessage.success('上传完成')
uploadDialogVisible.value = false
await loadReleases()
} catch (e) {
ElMessage.error(e?.response?.data?.msg || '上传失败')
} finally {
uploadSubmitting.value = false
}
}
function openEditDialog(row) {
editingRelease.value = row
editForm.version = row.version
editForm.release_notes = row.release_notes || ''
editDialogVisible.value = true
}
async function submitEditVersion() {
if (!editingRelease.value) return
editSubmitting.value = true
try {
await updateAppVersionRelease(editingRelease.value.id, {
version: editForm.version,
release_notes: editForm.release_notes
})
ElMessage.success('已保存')
editDialogVisible.value = false
await loadReleases()
} catch (e) {
ElMessage.error(e?.response?.data?.msg || '保存失败')
} finally {
editSubmitting.value = false
}
}
function removeDownloadConfirm(row) {
if (!row.downloads?.length) {
ElMessage.info('该版本暂无安装包')
return
}
deleteDownloadRelease.value = row
deleteDownloadCandidates.value = row.downloads
deleteDownloadTarget.value = row.downloads[0] ? row.downloads[0].platform + '/' + row.downloads[0].arch : ''
deleteDownloadVisible.value = true
}
async function confirmDeleteDownload() {
if (!deleteDownloadRelease.value || !deleteDownloadTarget.value) return
const [platform, arch] = deleteDownloadTarget.value.split('/')
deleteSubmitting.value = true
try {
await deleteAppVersionDownload(deleteDownloadRelease.value.id, { platform, arch })
ElMessage.success('已删除')
deleteDownloadVisible.value = false
await loadReleases()
} catch (e) {
ElMessage.error(e?.response?.data?.msg || '删除失败')
} finally {
deleteSubmitting.value = false
}
}
onMounted(() => {
loadReleases()
})
</script>
<style scoped>
.app-version .card { padding: 1rem 1.5rem; border-radius: 8px; border: 1px solid var(--el-border-color); }
.upload-drop { cursor: pointer; }
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.border-primary { border-color: var(--el-color-primary); }
</style>
+3
View File
@@ -60,6 +60,9 @@ DOCUMENT_EXTENSIONS: set[str] = convert_to_lower_and_upper_set(_doc_extensions)
COOKIE_NAME_ACCESS_TOKEN = "access_token" COOKIE_NAME_ACCESS_TOKEN = "access_token"
COOKIE_NAME_REFRESH_TOKEN = "refresh_token" COOKIE_NAME_REFRESH_TOKEN = "refresh_token"
COOKIE_NAME_CSRF_TOKEN = "csrf_token" COOKIE_NAME_CSRF_TOKEN = "csrf_token"
# extend: CVE-2025-63387未授权访问 虽然这个api实际上就是个登录用的
COOKIE_NAME_LOGIN_CONFIG_TOKEN = "dify_console_login_config"
HEADER_NAME_LOGIN_CONFIG_TOKEN = "X-Login-Config-Token"
# webapp # webapp
COOKIE_NAME_WEBAPP_ACCESS_TOKEN = "webapp_access_token" COOKIE_NAME_WEBAPP_ACCESS_TOKEN = "webapp_access_token"
+78 -11
View File
@@ -1,6 +1,14 @@
from flask_restx import Resource, fields from datetime import datetime, timedelta, timezone
from werkzeug.exceptions import Unauthorized from typing import Optional
import jwt
from flask import make_response, request
from flask_restx import Resource, fields
from werkzeug.exceptions import Forbidden, Unauthorized
from configs import dify_config
from constants import COOKIE_NAME_LOGIN_CONFIG_TOKEN, HEADER_NAME_LOGIN_CONFIG_TOKEN
from libs.helper import extract_remote_ip
from libs.login import current_account_with_tenant, current_user, login_required from libs.login import current_account_with_tenant, current_user, login_required
from services.feature_service import FeatureService from services.feature_service import FeatureService
@@ -8,6 +16,51 @@ from . import console_ns
from .wraps import account_initialization_required, cloud_utm_record, setup_required from .wraps import account_initialization_required, cloud_utm_record, setup_required
def _issue_login_config_jwt(ip: str) -> str:
"""extend: CVE-2025-63387 签发 JWTpayload 含 ip 与 1h 过期。"""
payload = {
"ip": ip,
"exp": datetime.now(timezone.utc) + timedelta(hours=1),
}
return jwt.encode(payload, dify_config.SECRET_KEY, algorithm="HS256")
def _verify_login_config_token(token: Optional[str]) -> bool:
"""extend: CVE-2025-63387 校验 JWT 签名、过期时间,以及当前请求 IP 与 payload.ip 一致。"""
if not token:
return False
try:
payload = jwt.decode(token, dify_config.SECRET_KEY, algorithms=["HS256"])
except jwt.PyJWTError:
return False
return payload.get("ip") == extract_remote_ip(request)
# extend: start CVE-2025-63387未授权访问
@console_ns.route("/login_config_bootstrap")
class LoginConfigBootstrapApi(Resource):
"""extend: CVE-2025-63387未授权访问 虽然这个api实际上就是个登录用的
写入 features 相关 cookie(值为 JWT,含 ip 与 1h 过期),
同时返回 token 供前端在跨域时通过 Header 携带。
"""
@console_ns.doc("login_config_bootstrap")
@console_ns.response(200, "Success")
def get(self):
client_ip = extract_remote_ip(request)
token = _issue_login_config_jwt(client_ip)
resp = make_response({"ok": True, "token": token})
resp.set_cookie(
COOKIE_NAME_LOGIN_CONFIG_TOKEN,
value=token,
max_age=3600,
httponly=True,
samesite="Lax",
)
return resp
# extend: stop CVE-2025-63387未授权访问
@console_ns.route("/features") @console_ns.route("/features")
class FeatureApi(Resource): class FeatureApi(Resource):
@console_ns.doc("get_tenant_features") @console_ns.doc("get_tenant_features")
@@ -28,17 +81,25 @@ class FeatureApi(Resource):
return FeatureService.get_features(current_tenant_id).model_dump() return FeatureService.get_features(current_tenant_id).model_dump()
@console_ns.route("/system-features")
class SystemFeatureApi(Resource): # extend: start CVE-2025-63387未授权访问
@console_ns.doc("get_system_features") @console_ns.route("/login_config")
@console_ns.doc(description="Get system-wide feature configuration") class LoginConfigApi(Resource):
"""extend: CVE-2025-63387未授权访问 虽然这个api实际上就是个登录用的
仅当请求带有 login_config_bootstrap 写入的 cookie 时才返回登录配置,
避免未经过控制台入口的扫描直接获取系统配置。
"""
@console_ns.doc("get_login_config")
@console_ns.doc(description="Get system-wide login/feature configuration")
@console_ns.response( @console_ns.response(
200, 200,
"Success", "Success",
console_ns.model( console_ns.model(
"SystemFeatureResponse", {"features": fields.Raw(description="System feature configuration object")} "LoginConfigResponse", {"features": fields.Raw(description="System feature configuration object")}
), ),
) )
@console_ns.response(403, "Missing or invalid login_config token")
def get(self): def get(self):
"""Get system-wide feature configuration """Get system-wide feature configuration
@@ -49,10 +110,16 @@ class SystemFeatureApi(Resource):
Only non-sensitive configuration data should be returned by this endpoint. Only non-sensitive configuration data should be returned by this endpoint.
""" """
# NOTE(QuantumGhost): ideally we should access `current_user.is_authenticated` # extend: CVE-2025-63387 支持 Cookie 或 Header 携带 JWT(跨域时 Cookie 可能为 None,用 Header
# without a try-catch. However, due to the implementation of user loader (the `load_user_from_request` token = request.cookies.get(COOKIE_NAME_LOGIN_CONFIG_TOKEN) or request.headers.get(
# in api/extensions/ext_login.py), accessing `current_user.is_authenticated` will HEADER_NAME_LOGIN_CONFIG_TOKEN
# raise `Unauthorized` exception if authentication token is not provided. )
if not _verify_login_config_token(token):
raise Forbidden(
"Missing or invalid login_config token (cookie or X-Login-Config-Token); "
"call /login_config_bootstrap first."
)
# extend: stop CVE-2025-63387未授权访问
try: try:
is_authenticated = current_user.is_authenticated is_authenticated = current_user.is_authenticated
except Unauthorized: except Unauthorized:
+14 -2
View File
@@ -1,10 +1,22 @@
from configs import dify_config from configs import dify_config
from constants import HEADER_NAME_APP_CODE, HEADER_NAME_CSRF_TOKEN, HEADER_NAME_PASSPORT from constants import (
HEADER_NAME_APP_CODE,
HEADER_NAME_CSRF_TOKEN,
HEADER_NAME_LOGIN_CONFIG_TOKEN,
HEADER_NAME_PASSPORT,
)
from dify_app import DifyApp from dify_app import DifyApp
BASE_CORS_HEADERS: tuple[str, ...] = ("Content-Type", HEADER_NAME_APP_CODE, HEADER_NAME_PASSPORT) BASE_CORS_HEADERS: tuple[str, ...] = ("Content-Type", HEADER_NAME_APP_CODE, HEADER_NAME_PASSPORT)
SERVICE_API_HEADERS: tuple[str, ...] = (*BASE_CORS_HEADERS, "Authorization", "Authorization-extend", "X-App-Code") SERVICE_API_HEADERS: tuple[str, ...] = (*BASE_CORS_HEADERS, "Authorization", "Authorization-extend", "X-App-Code")
AUTHENTICATED_HEADERS: tuple[str, ...] = (*SERVICE_API_HEADERS, HEADER_NAME_CSRF_TOKEN, "Authorization-extend", "X-App-Code") # extend: CVE-2025-63387 放行 X-Login-Config-Token 以便跨域时用 Header 带 JWT
AUTHENTICATED_HEADERS: tuple[str, ...] = (
*SERVICE_API_HEADERS,
HEADER_NAME_CSRF_TOKEN,
HEADER_NAME_LOGIN_CONFIG_TOKEN,
"Authorization-extend",
"X-App-Code",
)
FILES_HEADERS: tuple[str, ...] = (*BASE_CORS_HEADERS, HEADER_NAME_CSRF_TOKEN, "Authorization-extend") FILES_HEADERS: tuple[str, ...] = (*BASE_CORS_HEADERS, HEADER_NAME_CSRF_TOKEN, "Authorization-extend")
EMBED_HEADERS: tuple[str, ...] = ("Content-Type", HEADER_NAME_APP_CODE, "Authorization-extend", "X-App-Code") EMBED_HEADERS: tuple[str, ...] = ("Content-Type", HEADER_NAME_APP_CODE, "Authorization-extend", "X-App-Code")
EXPOSED_HEADERS: tuple[str, ...] = ("X-Version", "X-Env", "X-Trace-Id") EXPOSED_HEADERS: tuple[str, ...] = ("X-Version", "X-Env", "X-Trace-Id")
+10 -15
View File
@@ -6,45 +6,40 @@
# Common Variables # Common Variables
# ------------------------------ # ------------------------------
# The backend URL of the console API (base only; entrypoint appends /console/api). # The backend URL of the console API,
# used to concatenate the authorization callback. # used to concatenate the authorization callback.
# Must be an absolute URL (e.g. https://your-domain.com). If empty, web entrypoint falls back to http://127.0.0.1:5001. # If empty, it is the same domain.
# Example: https://api.console.dify.ai # Example: https://api.console.dify.ai
# 外网需要自行配置 CONSOLE_API_URL=
CONSOLE_API_URL=http://127.0.0.1:5001
# The front-end URL of the console web, # The front-end URL of the console web,
# used to concatenate some front-end addresses and for CORS configuration use. # used to concatenate some front-end addresses and for CORS configuration use.
# If empty, it is the same domain. # If empty, it is the same domain.
# Example: https://console.dify.ai # Example: https://console.dify.ai
# 外网需要自行配置 CONSOLE_WEB_URL=
CONSOLE_WEB_URL=http://127.0.0.1:3000
# Service API Url, # Service API Url,
# used to display Service API Base Url to the front-end. # used to display Service API Base Url to the front-end.
# If empty, it is the same domain. # If empty, it is the same domain.
# Example: https://api.dify.ai # Example: https://api.dify.ai
# 外网需要自行配置 SERVICE_API_URL=
SERVICE_API_URL=http://127.0.0.1:5001
# Trigger external URL # Trigger external URL
# used to display trigger endpoint API Base URL to the front-end. # used to display trigger endpoint API Base URL to the front-end.
# Example: https://api.dify.ai # Example: https://api.dify.ai
# 外网需要自行配置 TRIGGER_URL=http://localhost
TRIGGER_URL=http://127.0.0.1:5001
# WebApp API backend Url (base only; entrypoint appends /api). # WebApp API backend Url,
# used to declare the back-end URL for the front-end API. # used to declare the back-end URL for the front-end API.
# Must be an absolute URL. If empty, web entrypoint falls back to http://127.0.0.1:5001. # If empty, it is the same domain.
# Example: https://api.app.dify.ai # Example: https://api.app.dify.ai
# 外网需要自行配置 APP_API_URL=
APP_API_URL=http://127.0.0.1:5001
# WebApp Url, # WebApp Url,
# used to display WebAPP API Base Url to the front-end. # used to display WebAPP API Base Url to the front-end.
# If empty, it is the same domain. # If empty, it is the same domain.
# Example: https://app.dify.ai # Example: https://app.dify.ai
APP_WEB_URL=http://127.0.0.1:3000 APP_WEB_URL=
# File preview or download Url prefix. # File preview or download Url prefix.
# used to display File preview or download Url to the front-end or as Multi-model inputs; # used to display File preview or download Url to the front-end or as Multi-model inputs;
@@ -0,0 +1,83 @@
-- ============================================================
-- 应用版本:菜单、API、Casbin 权限、角色-菜单关联 插入语句
-- 执行前请确认:1) 表结构已存在 2) 若 id 冲突可调整或改用 INSERT IGNORE / ON CONFLICT
-- ============================================================
-- --------------- 1. 菜单 sys_base_menus (应用版本) ---------------
-- 若已存在 id=41 可改为其他未占用 id,或先查 max(id)+1
INSERT INTO sys_base_menus (
id, created_at, updated_at, deleted_at,
menu_level, parent_id, path, name, hidden, component, sort,
active_name, keep_alive, default_menu, title, icon, close_tab
) VALUES (
41,
NOW(), NOW(), NULL,
0, 0, 'AppVersion', 'AppVersion', false, 'view/gaia/appVersion/index.vue', 10,
'', false, false, '版本管理', 'upload-filled', false
);
-- MySQL 若主键冲突可改为: INSERT IGNORE INTO ... 或 ON DUPLICATE KEY UPDATE id=id
-- PostgreSQL: INSERT ... ON CONFLICT (id) DO NOTHING;
-- --------------- 2. API sys_apis (应用版本相关 8 条) ---------------
-- id 使用自增则可不写 id 列;以下为显式 id,请按当前库最大 id 调整,避免冲突
-- 查询当前最大 id: SELECT MAX(id) FROM sys_apis;
-- 假设当前最大为 250,则从 251 开始;否则删除 id 列让库自增
INSERT INTO sys_apis (id, created_at, updated_at, deleted_at, path, description, api_group, method) VALUES
(251, NOW(), NOW(), NULL, '/gaia/app-version/token', '获取链接Token配置', '应用版本', 'GET'),
(252, NOW(), NOW(), NULL, '/gaia/app-version/token', '设置链接Token', '应用版本', 'PUT'),
(253, NOW(), NOW(), NULL, '/gaia/app-version/releases', '版本列表', '应用版本', 'GET'),
(254, NOW(), NOW(), NULL, '/gaia/app-version/releases', '新增版本', '应用版本', 'POST'),
(255, NOW(), NOW(), NULL, '/gaia/app-version/releases/:id', '版本详情', '应用版本', 'GET'),
(256, NOW(), NOW(), NULL, '/gaia/app-version/releases/:id', '更新版本信息', '应用版本', 'PUT'),
(257, NOW(), NOW(), NULL, '/gaia/app-version/releases/:id/upload', '上传安装包(自动识别平台架构)', '应用版本', 'POST'),
(258, NOW(), NOW(), NULL, '/gaia/app-version/releases/:id/download', '删除指定平台架构包', '应用版本', 'DELETE');
-- 若希望自增 id,可改为(去掉 id 列):
-- INSERT INTO sys_apis (created_at, updated_at, deleted_at, path, description, api_group, method) VALUES
-- (NOW(), NOW(), NULL, '/gaia/app-version/token', '获取链接Token配置', '应用版本', 'GET'),
-- ... 共 8 条;
-- --------------- 3. Casbin 规则 casbin_rule (角色 888/8881/9528/1 的接口权限) ---------------
INSERT INTO casbin_rule (ptype, v0, v1, v2) VALUES
('p', '888', '/gaia/app-version/token', 'GET'),
('p', '888', '/gaia/app-version/token', 'PUT'),
('p', '888', '/gaia/app-version/releases', 'GET'),
('p', '888', '/gaia/app-version/releases', 'POST'),
('p', '888', '/gaia/app-version/releases/:id', 'GET'),
('p', '888', '/gaia/app-version/releases/:id', 'PUT'),
('p', '888', '/gaia/app-version/releases/:id/upload', 'POST'),
('p', '888', '/gaia/app-version/releases/:id/download', 'DELETE'),
('p', '8881', '/gaia/app-version/token', 'GET'),
('p', '8881', '/gaia/app-version/token', 'PUT'),
('p', '8881', '/gaia/app-version/releases', 'GET'),
('p', '8881', '/gaia/app-version/releases', 'POST'),
('p', '8881', '/gaia/app-version/releases/:id', 'GET'),
('p', '8881', '/gaia/app-version/releases/:id', 'PUT'),
('p', '8881', '/gaia/app-version/releases/:id/upload', 'POST'),
('p', '8881', '/gaia/app-version/releases/:id/download', 'DELETE'),
('p', '9528', '/gaia/app-version/token', 'GET'),
('p', '9528', '/gaia/app-version/token', 'PUT'),
('p', '9528', '/gaia/app-version/releases', 'GET'),
('p', '9528', '/gaia/app-version/releases', 'POST'),
('p', '9528', '/gaia/app-version/releases/:id', 'GET'),
('p', '9528', '/gaia/app-version/releases/:id', 'PUT'),
('p', '9528', '/gaia/app-version/releases/:id/upload', 'POST'),
('p', '9528', '/gaia/app-version/releases/:id/download', 'DELETE'),
('p', '1', '/gaia/app-version/token', 'GET'),
('p', '1', '/gaia/app-version/token', 'PUT'),
('p', '1', '/gaia/app-version/releases', 'GET'),
('p', '1', '/gaia/app-version/releases', 'POST'),
('p', '1', '/gaia/app-version/releases/:id', 'GET'),
('p', '1', '/gaia/app-version/releases/:id', 'PUT'),
('p', '1', '/gaia/app-version/releases/:id/upload', 'POST'),
('p', '1', '/gaia/app-version/releases/:id/download', 'DELETE');
-- --------------- 4. 角色-菜单关联 sys_authority_menus (让角色 888 拥有「应用版本」菜单) ---------------
-- 表结构:sys_authority_authority_id, sys_base_menu_id(以你库中实际列名为准,有的项目为 authority_id, menu_id
INSERT INTO sys_authority_menus (sys_authority_authority_id, sys_base_menu_id) VALUES (888, 41);
-- 若需给 8881、9528、1 也加该菜单,可追加:
-- INSERT INTO sys_authority_menus (sys_authority_authority_id, sys_base_menu_id) VALUES (8881, 41);
-- INSERT INTO sys_authority_menus (sys_authority_authority_id, sys_base_menu_id) VALUES (9528, 41);
INSERT INTO sys_authority_menus (sys_authority_authority_id, sys_base_menu_id) VALUES (1, 41);
+2 -2
View File
@@ -7,11 +7,11 @@ NEXT_PUBLIC_BASE_PATH=
# The base URL of console application, refers to the Console base URL of WEB service if console domain is # The base URL of console application, refers to the Console base URL of WEB service if console domain is
# different from api or web app domain. # different from api or web app domain.
# example: http://cloud.dify.ai/console/api # example: http://cloud.dify.ai/console/api
NEXT_PUBLIC_API_PREFIX=http://localhost:5001/console/api NEXT_PUBLIC_API_PREFIX=http://localhost:3000/console/api
# The URL for Web APP, refers to the Web App base URL of WEB service if web app domain is different from # The URL for Web APP, refers to the Web App base URL of WEB service if web app domain is different from
# console or api domain. # console or api domain.
# example: http://udify.app/api # example: http://udify.app/api
NEXT_PUBLIC_PUBLIC_API_PREFIX=http://localhost:5001/api NEXT_PUBLIC_PUBLIC_API_PREFIX=http://localhost:3000/api
# When the frontend and backend run on different subdomains, set NEXT_PUBLIC_COOKIE_DOMAIN=1. # When the frontend and backend run on different subdomains, set NEXT_PUBLIC_COOKIE_DOMAIN=1.
NEXT_PUBLIC_COOKIE_DOMAIN= NEXT_PUBLIC_COOKIE_DOMAIN=
+23 -2
View File
@@ -53,16 +53,37 @@ const getStringConfig = (
return defaultValue return defaultValue
} }
export const API_PREFIX = getStringConfig( /**
* 在浏览器中,若配置的 API 地址为 localhost 或 127.0.0.1,则改为使用当前页面的 origin,
* 避免 127.0.0.1 与 localhost 不同源导致 cookie 无法携带(登录后 /api/login/status 拿不到 console 的 access_token)。
*/
function normalizeSameOriginApiPrefix(raw: string): string {
if (typeof globalThis.window === 'undefined' || !raw.startsWith('http'))
return raw
try {
const u = new URL(raw)
if (u.hostname === 'localhost' || u.hostname === '127.0.0.1')
return globalThis.window.location.origin + (u.pathname.replace(/\/$/, '') || '/')
return raw
}
catch {
return raw
}
}
const _apiPrefixRaw = getStringConfig(
process.env.NEXT_PUBLIC_API_PREFIX, process.env.NEXT_PUBLIC_API_PREFIX,
DatasetAttr.DATA_API_PREFIX, DatasetAttr.DATA_API_PREFIX,
'http://localhost:5001/console/api', 'http://localhost:5001/console/api',
) )
export const PUBLIC_API_PREFIX = getStringConfig( const _publicApiPrefixRaw = getStringConfig(
process.env.NEXT_PUBLIC_PUBLIC_API_PREFIX, process.env.NEXT_PUBLIC_PUBLIC_API_PREFIX,
DatasetAttr.DATA_PUBLIC_API_PREFIX, DatasetAttr.DATA_PUBLIC_API_PREFIX,
'http://localhost:5001/api', 'http://localhost:5001/api',
) )
export const API_PREFIX = normalizeSameOriginApiPrefix(_apiPrefixRaw)
export const PUBLIC_API_PREFIX = normalizeSameOriginApiPrefix(_publicApiPrefixRaw)
export const MARKETPLACE_API_PREFIX = getStringConfig( export const MARKETPLACE_API_PREFIX = getStringConfig(
process.env.NEXT_PUBLIC_MARKETPLACE_API_PREFIX, process.env.NEXT_PUBLIC_MARKETPLACE_API_PREFIX,
DatasetAttr.DATA_MARKETPLACE_API_PREFIX, DatasetAttr.DATA_MARKETPLACE_API_PREFIX,
+6 -2
View File
@@ -4,7 +4,7 @@ import type { SystemFeatures } from '@/types/feature'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { create } from 'zustand' import { create } from 'zustand'
import Loading from '@/app/components/base/loading' import Loading from '@/app/components/base/loading'
import { consoleClient } from '@/service/client' import { consoleClient, setLoginConfigToken } from '@/service/client'
import { defaultSystemFeatures } from '@/types/feature' import { defaultSystemFeatures } from '@/types/feature'
import { fetchSetupStatusWithCache } from '@/utils/setup-status' import { fetchSetupStatusWithCache } from '@/utils/setup-status'
@@ -21,8 +21,12 @@ export const useGlobalPublicStore = create<GlobalPublicStore>(set => ({
const systemFeaturesQueryKey = ['systemFeatures'] as const const systemFeaturesQueryKey = ['systemFeatures'] as const
const setupStatusQueryKey = ['setupStatus'] as const const setupStatusQueryKey = ['setupStatus'] as const
// extend: CVE-2025-63387未授权访问 — 先请求 bootstrap 拿 JWTcookie + body),跨域时用 Header 带 token 请求 login_config
async function fetchSystemFeatures() { async function fetchSystemFeatures() {
const data = await consoleClient.systemFeatures() const bootstrapRes = await consoleClient.loginConfigBootstrap()
if (bootstrapRes?.token)
setLoginConfigToken(bootstrapRes.token)
const data = await consoleClient.loginConfig()
const { setSystemFeatures } = useGlobalPublicStore.getState() const { setSystemFeatures } = useGlobalPublicStore.getState()
setSystemFeatures({ ...defaultSystemFeatures, ...data }) setSystemFeatures({ ...defaultSystemFeatures, ...data })
return data return data
+12 -2
View File
@@ -2,9 +2,19 @@ import type { SystemFeatures } from '@/types/feature'
import { type } from '@orpc/contract' import { type } from '@orpc/contract'
import { base } from '../base' import { base } from '../base'
export const systemFeaturesContract = base // extend: CVE-2025-63387未授权访问 虽然这个api实际上就是个登录用的
export const loginConfigBootstrapContract = base
.route({ .route({
path: '/system-features', path: '/login_config_bootstrap',
method: 'GET',
})
.input(type<unknown>())
.output(type<{ ok: boolean, token: string }>())
// extend: CVE-2025-63387未授权访问 虽然这个api实际上就是个登录用的 — 路径改为 login_config,需先请求 login_config_bootstrap 写入 cookie
export const loginConfigContract = base
.route({
path: '/login_config',
method: 'GET', method: 'GET',
}) })
.input(type<unknown>()) .input(type<unknown>())
+5 -2
View File
@@ -1,6 +1,7 @@
import type { InferContractRouterInputs } from '@orpc/contract' import type { InferContractRouterInputs } from '@orpc/contract'
import { bindPartnerStackContract, invoicesContract } from './console/billing' import { bindPartnerStackContract, invoicesContract } from './console/billing'
import { systemFeaturesContract } from './console/system' // extend: CVE-2025-63387未授权访问 虽然这个api实际上就是个登录用的 — 路径改为 login_config,需先请求 login_config_bootstrap 写入 cookie
import { loginConfigBootstrapContract, loginConfigContract } from './console/system'
import { trialAppDatasetsContract, trialAppInfoContract, trialAppParametersContract, trialAppWorkflowsContract } from './console/try-app' import { trialAppDatasetsContract, trialAppInfoContract, trialAppParametersContract, trialAppWorkflowsContract } from './console/try-app'
import { collectionPluginsContract, collectionsContract, searchAdvancedContract } from './marketplace' import { collectionPluginsContract, collectionsContract, searchAdvancedContract } from './marketplace'
@@ -12,8 +13,10 @@ export const marketplaceRouterContract = {
export type MarketPlaceInputs = InferContractRouterInputs<typeof marketplaceRouterContract> export type MarketPlaceInputs = InferContractRouterInputs<typeof marketplaceRouterContract>
// extend: CVE-2025-63387未授权访问 虽然这个api实际上就是个登录用的 — 路径改为 login_config,需先请求 login_config_bootstrap 写入 cookie
export const consoleRouterContract = { export const consoleRouterContract = {
systemFeatures: systemFeaturesContract, loginConfigBootstrap: loginConfigBootstrapContract,
loginConfig: loginConfigContract,
trialApps: { trialApps: {
info: trialAppInfoContract, info: trialAppInfoContract,
datasets: trialAppDatasetsContract, datasets: trialAppDatasetsContract,
+9
View File
@@ -63,6 +63,15 @@ const nextConfig: NextConfig = {
}, },
] ]
}, },
// dev 时把 /console/api 和 /api 代理到 5001
...(isDev && {
async rewrites() {
return [
{ source: '/console/api/:path*', destination: 'http://localhost:5001/console/api/:path*' },
{ source: '/api/:path*', destination: 'http://localhost:5001/api/:path*' },
]
},
}),
output: 'standalone', output: 'standalone',
compiler: { compiler: {
removeConsole: isDev ? false : { exclude: ['warn', 'error'] }, removeConsole: isDev ? false : { exclude: ['warn', 'error'] },
+14
View File
@@ -15,10 +15,23 @@ import {
} from '@/contract/router' } from '@/contract/router'
import { request } from './base' import { request } from './base'
// extend: CVE-2025-63387 跨域时 Cookie 可能为 None,用 Header 携带 JWT
let loginConfigToken: string | null = null
export function setLoginConfigToken(token: string | null) {
loginConfigToken = token
}
const getMarketplaceHeaders = () => new Headers({ const getMarketplaceHeaders = () => new Headers({
'X-Dify-Version': !IS_MARKETPLACE ? APP_VERSION : '999.0.0', 'X-Dify-Version': !IS_MARKETPLACE ? APP_VERSION : '999.0.0',
}) })
const getConsoleHeaders = () => {
const h = new Headers()
if (loginConfigToken)
h.set('X-Login-Config-Token', loginConfigToken)
return h
}
const marketplaceLink = new OpenAPILink(marketplaceRouterContract, { const marketplaceLink = new OpenAPILink(marketplaceRouterContract, {
url: MARKETPLACE_API_PREFIX, url: MARKETPLACE_API_PREFIX,
headers: () => (getMarketplaceHeaders()), headers: () => (getMarketplaceHeaders()),
@@ -40,6 +53,7 @@ export const marketplaceQuery = createTanstackQueryUtils(marketplaceClient, { pa
const consoleLink = new OpenAPILink(consoleRouterContract, { const consoleLink = new OpenAPILink(consoleRouterContract, {
url: API_PREFIX, url: API_PREFIX,
headers: () => getConsoleHeaders(),
fetch: (input, init) => { fetch: (input, init) => {
return request( return request(
input.url, input.url,
+2 -2
View File
@@ -308,9 +308,9 @@ export const fetchSupportRetrievalMethods = (url: string): Promise<RetrievalMeth
return get<RetrievalMethodsRes>(url) return get<RetrievalMethodsRes>(url)
} }
// extend: CVE-2025-63387未授权访问 虽然这个api实际上就是个登录用的 — 路径改为 login_config,需先请求 login_config_bootstrap 写入 cookie
export const getSystemFeatures = (): Promise<SystemFeatures> => { export const getSystemFeatures = (): Promise<SystemFeatures> => {
// extend: 解决登录状况不刷新 return get<SystemFeatures>(`/login_config?time=${(Math.round(new Date() / 1000)).toString()}`)
return get<SystemFeatures>(`/system-features?time=${(Math.round(new Date() / 1000)).toString()}`)
} }
export const enableModel = (url: string, body: { model: string, model_type: ModelTypeEnum }): Promise<CommonResponse> => export const enableModel = (url: string, body: { model: string, model_type: ModelTypeEnum }): Promise<CommonResponse> =>