mirror of
https://github.com/YFGaia/dify-plus.git
synced 2026-06-04 10:14:00 +08:00
fear: admin添加app应用版本管理
system-features添加
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ type ApiGroup struct {
|
|||||||
TestApi
|
TestApi
|
||||||
SystemOAuth2Api
|
SystemOAuth2Api
|
||||||
BatchWorkflowApi
|
BatchWorkflowApi
|
||||||
|
AppVersionApi
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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) // 删除某包
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -7,4 +7,6 @@ type ServiceGroup struct {
|
|||||||
TenantsService
|
TenantsService
|
||||||
TestService
|
TestService
|
||||||
BatchWorkflowService
|
BatchWorkflowService
|
||||||
|
// extned: app version
|
||||||
|
AppVersionService
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()+") 数据初始化失败!")
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 "", ""
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 签发 JWT,payload 含 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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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 拿 JWT(cookie + 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
|
||||||
|
|||||||
@@ -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>())
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'] },
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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> =>
|
||||||
|
|||||||
Reference in New Issue
Block a user