权限模型(借鉴 Jenkins Role-Strategy 与云效,默认拒绝): - 三级权限:查看(流水线+成功/失败日志)/ 发布(+构建、批量发布、重试)/ 管理(+配置、成员);admin 全局豁免 - 权限组(管理员配置):默认手动多选流水线(免正则,操作友好);高级模式仅 * 通配符(cex-*) - 单流水线成员直授权作为例外补充;AI 批量发布逐 job 校验,越权明确拒绝 后端: - 新增 PipelineRole/PipelineMember 表与 /api/pipeline-roles、/api/pipelines/:id/members CRUD - RequirePipelineAccess/RequireRunAccess 中间件挂全流水线与运行路由;列表按可见性过滤并附 accessLevel/canRetry - /api/me 会话接口;logs/stream SSE 支持 ?token= 认证(原先完全免鉴权)并校验查看权限 - 凭证/主机/集群/构建节点/工具/Webhook/Jenkins/GitOps/插件/设置全部 AdminOnly;触发人以会话身份落库 前端: - useSession 会话 hook;Sidebar 菜单分级(资源/集成/系统管理 admin-only);admin 路由守卫 - 按钮级权限:执行/配置/删除/保存/重试按等级显隐;新页「流水线权限」与流水线成员弹窗(纯点选) - 登录页记住密码;GUIDE/API 文档补权限章节
500 lines
15 KiB
Go
500 lines
15 KiB
Go
package handler
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
|
||
"flowstack/internal/crypto"
|
||
"flowstack/internal/database"
|
||
"flowstack/internal/llm"
|
||
"flowstack/internal/model"
|
||
"flowstack/internal/parser"
|
||
)
|
||
|
||
// ============================================================
|
||
// 候选池:流水线 + Jenkins Job(60s 内存缓存)
|
||
// ============================================================
|
||
|
||
type Candidate struct {
|
||
Name string `json:"name"` // 匹配用名称(jenkins 为压平全路径)
|
||
PipelineID uint `json:"pipelineId"`
|
||
Type string `json:"type"` // native | jenkins
|
||
Source string `json:"source"` // pipeline | jenkins
|
||
ServerID uint `json:"serverId"`
|
||
}
|
||
|
||
var (
|
||
jobsCacheMu sync.Mutex
|
||
jobsCacheData []Candidate // 仅 jenkins 来源
|
||
jobsCacheAt time.Time
|
||
)
|
||
|
||
// jenkinsCandidates 从各启用的 Jenkins 服务器拉取 Job 列表(60s 缓存)
|
||
func jenkinsCandidates() []Candidate {
|
||
jobsCacheMu.Lock()
|
||
defer jobsCacheMu.Unlock()
|
||
if time.Since(jobsCacheAt) < 60*time.Second && jobsCacheData != nil {
|
||
return jobsCacheData
|
||
}
|
||
var servers []model.JenkinsServer
|
||
database.DB.Where("enabled = ?", true).Find(&servers)
|
||
var out []Candidate
|
||
for _, s := range servers {
|
||
jobs, err := clientFromServer(s).ListJobs()
|
||
if err != nil {
|
||
continue
|
||
}
|
||
for _, j := range jobs {
|
||
if j.Type == "folder" {
|
||
continue
|
||
}
|
||
out = append(out, Candidate{Name: j.Name, Type: "jenkins", Source: "jenkins", ServerID: s.ID})
|
||
}
|
||
}
|
||
jobsCacheData = out
|
||
jobsCacheAt = time.Now()
|
||
return out
|
||
}
|
||
|
||
// allCandidates 流水线优先(去重),Jenkins Job 补充
|
||
func allCandidates() []Candidate {
|
||
var pipelines []model.Pipeline
|
||
database.DB.Find(&pipelines)
|
||
var out []Candidate
|
||
seen := map[string]bool{}
|
||
for _, p := range pipelines {
|
||
out = append(out, Candidate{Name: p.Name, PipelineID: p.ID, Type: p.Type, Source: "pipeline"})
|
||
seen[strings.ToLower(p.Name)] = true
|
||
if p.JenkinsJobName != "" && p.JenkinsJobName != p.Name {
|
||
if !seen[strings.ToLower(p.JenkinsJobName)] {
|
||
out = append(out, Candidate{Name: p.JenkinsJobName, PipelineID: p.ID, Type: "jenkins", Source: "pipeline", ServerID: p.JenkinsServerID})
|
||
seen[strings.ToLower(p.JenkinsJobName)] = true
|
||
}
|
||
}
|
||
}
|
||
for _, j := range jenkinsCandidates() {
|
||
if !seen[strings.ToLower(j.Name)] {
|
||
out = append(out, j)
|
||
seen[strings.ToLower(j.Name)] = true
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// matchCandidate 精确 → 忽略大小写 → 包含
|
||
func matchCandidate(name string, candidates []Candidate) *Candidate {
|
||
lower := strings.ToLower(name)
|
||
var contains *Candidate
|
||
for i, c := range candidates {
|
||
if c.Name == name {
|
||
return &candidates[i]
|
||
}
|
||
if strings.ToLower(c.Name) == lower {
|
||
return &candidates[i]
|
||
}
|
||
if contains == nil && lower != "" && strings.Contains(strings.ToLower(c.Name), lower) {
|
||
contains = &candidates[i]
|
||
}
|
||
}
|
||
return contains
|
||
}
|
||
|
||
// ============================================================
|
||
// LLM
|
||
// ============================================================
|
||
|
||
func llmClientFromSettings() *llm.Client {
|
||
var s model.SystemSetting
|
||
if err := database.DB.First(&s, 1).Error; err != nil {
|
||
return nil
|
||
}
|
||
if !s.LlmEnabled || s.LlmApiKey == "" {
|
||
return nil
|
||
}
|
||
apiKey := s.LlmApiKey
|
||
if crypto.IsEncrypted(apiKey) {
|
||
if plain, err := crypto.DecryptField(apiKey); err == nil {
|
||
apiKey = plain
|
||
}
|
||
}
|
||
return llm.NewClient(s.LlmEndpoint, apiKey, s.LlmModel, s.LlmMaxTokens)
|
||
}
|
||
|
||
// ============================================================
|
||
// 规则解析(无 LLM 的兜底)
|
||
// ============================================================
|
||
|
||
var branchKeywords = []string{"pre", "test", "main", "master", "release", "dev", "develop", "staging", "production", "prod"}
|
||
|
||
type ruleParseResult struct {
|
||
Jobs []string
|
||
Branch string
|
||
Env string
|
||
}
|
||
|
||
// parseByRules 从自然语言/清单文本中直接匹配候选名 + 分支/环境关键词
|
||
func parseByRules(text string, candidates []Candidate) ruleParseResult {
|
||
var res ruleParseResult
|
||
lowerText := strings.ToLower(text)
|
||
|
||
// 长名优先匹配,避免短名误命中(如 api 命中 xxx-api)
|
||
sorted := make([]Candidate, len(candidates))
|
||
copy(sorted, candidates)
|
||
for i := 0; i < len(sorted); i++ {
|
||
for j := i + 1; j < len(sorted); j++ {
|
||
if len(sorted[j].Name) > len(sorted[i].Name) {
|
||
sorted[i], sorted[j] = sorted[j], sorted[i]
|
||
}
|
||
}
|
||
}
|
||
for _, c := range sorted {
|
||
if c.Name == "" {
|
||
continue
|
||
}
|
||
if strings.Contains(lowerText, strings.ToLower(c.Name)) {
|
||
res.Jobs = append(res.Jobs, c.Name)
|
||
}
|
||
}
|
||
|
||
// 分支关键词(优先匹配更长的,如 origin/pre 优于 pre)
|
||
for _, kw := range []string{"origin/pre", "origin/test", "origin/main"} {
|
||
if strings.Contains(lowerText, kw) {
|
||
res.Branch = kw
|
||
break
|
||
}
|
||
}
|
||
if res.Branch == "" {
|
||
for _, kw := range branchKeywords {
|
||
if strings.Contains(lowerText, kw) {
|
||
res.Branch = kw
|
||
break
|
||
}
|
||
}
|
||
}
|
||
for _, env := range []string{"production", "prod", "staging", "test", "dev"} {
|
||
if strings.Contains(lowerText, env) {
|
||
res.Env = env
|
||
break
|
||
}
|
||
}
|
||
return res
|
||
}
|
||
|
||
// ============================================================
|
||
// POST /api/ai/chat
|
||
// ============================================================
|
||
|
||
type ChatMsg struct {
|
||
Role string `json:"role"`
|
||
Content string `json:"content"`
|
||
}
|
||
|
||
func AiChat(c *gin.Context) {
|
||
var body struct {
|
||
Messages []ChatMsg `json:"messages" binding:"required"`
|
||
}
|
||
if err := c.ShouldBindJSON(&body); err != nil || len(body.Messages) == 0 {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "messages 不能为空"})
|
||
return
|
||
}
|
||
// 只取用户侧最后一条作为解析输入
|
||
var lastUser string
|
||
for i := len(body.Messages) - 1; i >= 0; i-- {
|
||
if body.Messages[i].Role == "user" {
|
||
lastUser = body.Messages[i].Content
|
||
break
|
||
}
|
||
}
|
||
if lastUser == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "没有用户消息"})
|
||
return
|
||
}
|
||
|
||
candidates := allCandidates()
|
||
names := make([]string, 0, len(candidates))
|
||
if len(candidates) > 200 {
|
||
candidates = candidates[:200]
|
||
}
|
||
for _, cd := range candidates {
|
||
names = append(names, cd.Name)
|
||
}
|
||
|
||
// 规则结果(兜底 + 校验基准)
|
||
ruleRes := parseByRules(lastUser, candidates)
|
||
|
||
reply := ""
|
||
viaAI := false
|
||
var action *aiReleaseAction
|
||
|
||
client := llmClientFromSettings()
|
||
if client != nil {
|
||
system := fmt.Sprintf(`你是 FlowStack 发布系统的智能发布助手。用户会用自然语言描述要发布的服务。
|
||
可发布的服务清单(流水线与 Jenkins Job 名称)如下:
|
||
%s
|
||
|
||
请从用户消息中识别:要发布哪些服务(必须来自上面的清单,按原文匹配)、目标分支(如有)、目标环境(如有)。
|
||
严格输出 JSON(不要输出其他内容、不要 markdown 代码块):
|
||
{"reply":"一句中文确认语","jobs":["服务名1"],"branch":"分支或留空","env":"环境或留空"}
|
||
jobs 为空数组时 reply 中说明未识别到服务。`, strings.Join(names, "\n"))
|
||
|
||
ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second)
|
||
defer cancel()
|
||
if out, err := client.Chat(ctx, system, lastUser); err == nil {
|
||
viaAI = true
|
||
reply, action = parseLLMJson(out, candidates)
|
||
}
|
||
}
|
||
|
||
// 兜底或 LLM 无结果:规则结果
|
||
if action == nil || len(action.Jobs) == 0 {
|
||
if len(ruleRes.Jobs) == 0 {
|
||
if reply == "" {
|
||
reply = "未识别到要发布的服务。请直接告诉我服务名,例如:发布 cex-agent-server、cex-agent-api 到 pre 分支。"
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"reply": reply, "viaAI": viaAI})
|
||
return
|
||
}
|
||
jobs := make([]aiActionJob, 0, len(ruleRes.Jobs))
|
||
for _, name := range ruleRes.Jobs {
|
||
m := matchCandidate(name, candidates)
|
||
jobs = append(jobs, aiActionJob{Name: name, Matched: m != nil, Source: sourceOf(m)})
|
||
}
|
||
action = &aiReleaseAction{Type: "release", Jobs: jobs, Branch: ruleRes.Branch, Env: ruleRes.Env}
|
||
if reply == "" {
|
||
reply = fmt.Sprintf("识别到 %d 个服务%s,确认后开始发布。", len(jobs), branchSuffix(ruleRes.Branch))
|
||
}
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"reply": reply, "viaAI": viaAI, "action": action,
|
||
})
|
||
}
|
||
|
||
type aiActionJob struct {
|
||
Name string `json:"name"`
|
||
Matched bool `json:"matched"`
|
||
Source string `json:"source"`
|
||
}
|
||
|
||
type aiReleaseAction struct {
|
||
Type string `json:"type"`
|
||
Jobs []aiActionJob `json:"jobs"`
|
||
Branch string `json:"branch,omitempty"`
|
||
Env string `json:"env,omitempty"`
|
||
}
|
||
|
||
func sourceOf(m *Candidate) string {
|
||
if m == nil {
|
||
return "unknown"
|
||
}
|
||
return m.Source
|
||
}
|
||
|
||
func branchSuffix(branch string) string {
|
||
if branch == "" {
|
||
return ""
|
||
}
|
||
return "(分支: " + branch + ")"
|
||
}
|
||
|
||
// parseLLMJson 容错解析 LLM 输出(允许 ```json 包裹),把识别出的服务匹配到候选池
|
||
func parseLLMJson(out string, candidates []Candidate) (string, *aiReleaseAction) {
|
||
cleaned := strings.TrimSpace(out)
|
||
cleaned = strings.TrimPrefix(cleaned, "```json")
|
||
cleaned = strings.TrimPrefix(cleaned, "```")
|
||
cleaned = strings.TrimSuffix(cleaned, "```")
|
||
// 截取首个 { 到最后一个 } 之间的内容
|
||
if idx := strings.Index(cleaned, "{"); idx >= 0 {
|
||
if last := strings.LastIndex(cleaned, "}"); last > idx {
|
||
cleaned = cleaned[idx : last+1]
|
||
}
|
||
}
|
||
var parsed struct {
|
||
Reply string `json:"reply"`
|
||
Jobs []string `json:"jobs"`
|
||
Branch string `json:"branch"`
|
||
Env string `json:"env"`
|
||
}
|
||
if err := json.Unmarshal([]byte(cleaned), &parsed); err != nil {
|
||
return "", nil
|
||
}
|
||
if len(parsed.Jobs) == 0 {
|
||
if parsed.Reply != "" {
|
||
return parsed.Reply, nil
|
||
}
|
||
return "", nil
|
||
}
|
||
action := &aiReleaseAction{Type: "release", Branch: parsed.Branch, Env: parsed.Env}
|
||
for _, name := range parsed.Jobs {
|
||
m := matchCandidate(name, candidates)
|
||
action.Jobs = append(action.Jobs, aiActionJob{Name: name, Matched: m != nil, Source: sourceOf(m)})
|
||
}
|
||
reply := parsed.Reply
|
||
if reply == "" {
|
||
reply = fmt.Sprintf("识别到 %d 个服务%s,确认后开始发布。", len(action.Jobs), branchSuffix(parsed.Branch))
|
||
}
|
||
return reply, action
|
||
}
|
||
|
||
// ============================================================
|
||
// POST /api/ai/release —— 解析→(自动建)→触发
|
||
// ============================================================
|
||
|
||
func AiRelease(hub *Hub) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
var body struct {
|
||
Jobs []string `json:"jobs" binding:"required"`
|
||
Branch string `json:"branch"`
|
||
Env string `json:"env"`
|
||
Parallel bool `json:"parallel"`
|
||
JenkinsServerID uint `json:"jenkinsServerId"`
|
||
AutoCreate *bool `json:"autoCreate"`
|
||
}
|
||
if err := c.ShouldBindJSON(&body); err != nil || len(body.Jobs) == 0 {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "jobs 不能为空"})
|
||
return
|
||
}
|
||
autoCreate := body.AutoCreate == nil || *body.AutoCreate
|
||
username, isAdmin := currentUser(c)
|
||
|
||
candidates := allCandidates()
|
||
type triggered struct {
|
||
Job string `json:"job"`
|
||
PipelineID uint `json:"pipelineId"`
|
||
RunID string `json:"runId"`
|
||
Created bool `json:"created"`
|
||
}
|
||
type failed struct {
|
||
Job string `json:"job"`
|
||
Reason string `json:"reason"`
|
||
}
|
||
var okList []triggered
|
||
var failList []failed
|
||
|
||
for _, name := range body.Jobs {
|
||
// 权限预检(operator 及以上):已存在的流水线按完整权限判定;
|
||
// 未导入的 Jenkins Job 按正则角色命中判定,且必须先于自动创建——避免建出请求者自己都无权可见的流水线
|
||
var existing model.Pipeline
|
||
if database.DB.Where("name = ? OR jenkins_job_name = ?", name, name).First(&existing).Error == nil {
|
||
if pipelineAccessLevel(username, isAdmin, &existing) < LevelRank(LevelOperator) {
|
||
failList = append(failList, failed{Job: name, Reason: "无该流水线的执行权限"})
|
||
continue
|
||
}
|
||
} else if !isAdmin && roleLevelForName(username, name) < LevelRank(LevelOperator) {
|
||
failList = append(failList, failed{Job: name, Reason: "无该流水线的执行权限(权限组未匹配该名称)"})
|
||
continue
|
||
}
|
||
p, created, err := resolvePipeline(name, candidates, autoCreate, body.JenkinsServerID)
|
||
if err != nil {
|
||
failList = append(failList, failed{Job: name, Reason: err.Error()})
|
||
continue
|
||
}
|
||
run, err := triggerPipelineRun(hub, *p, body.Branch, body.Env, username, "batch")
|
||
if err != nil {
|
||
failList = append(failList, failed{Job: name, Reason: "触发失败: " + err.Error()})
|
||
continue
|
||
}
|
||
okList = append(okList, triggered{Job: name, PipelineID: p.ID, RunID: run.RunID, Created: created})
|
||
if !body.Parallel {
|
||
time.Sleep(500 * time.Millisecond)
|
||
}
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"triggered": okList, "failed": failList})
|
||
}
|
||
}
|
||
|
||
// resolvePipeline 名称 → 流水线;未导入的 Jenkins Job 自动创建
|
||
func resolvePipeline(name string, candidates []Candidate, autoCreate bool, serverID uint) (*model.Pipeline, bool, error) {
|
||
var p model.Pipeline
|
||
if err := database.DB.Where("name = ? OR jenkins_job_name = ?", name, name).First(&p).Error; err == nil {
|
||
return &p, false, nil
|
||
}
|
||
if !autoCreate {
|
||
return nil, false, fmt.Errorf("未找到流水线(未开启自动创建)")
|
||
}
|
||
// 在 Jenkins 候选中确认存在并定位服务器
|
||
m := matchCandidate(name, candidates)
|
||
if m == nil || m.Source != "jenkins" {
|
||
return nil, false, fmt.Errorf("流水线与 Jenkins Job 中均未找到")
|
||
}
|
||
sid := m.ServerID
|
||
if sid == 0 {
|
||
sid = serverID
|
||
}
|
||
if sid == 0 {
|
||
return nil, false, fmt.Errorf("无法确定 Jenkins 服务器")
|
||
}
|
||
np := model.Pipeline{
|
||
Name: m.Name, Description: "AI 助手自动创建",
|
||
Type: "jenkins", SourceBranch: "main", DeployType: "host", Env: "staging",
|
||
JenkinsJobName: m.Name, JenkinsServerID: sid,
|
||
}
|
||
if err := database.DB.Create(&np).Error; err != nil {
|
||
return nil, false, fmt.Errorf("自动创建流水线失败: %s", err.Error())
|
||
}
|
||
return &np, true, nil
|
||
}
|
||
|
||
// ============================================================
|
||
// POST /api/batch/parse —— 批量发布页粘贴清单解析(纯规则)
|
||
// ============================================================
|
||
|
||
func BatchParse(c *gin.Context) {
|
||
var body struct {
|
||
Text string `json:"text" binding:"required"`
|
||
RefType string `json:"refType"`
|
||
FixedRef string `json:"fixedRef"`
|
||
}
|
||
if err := c.ShouldBindJSON(&body); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "text 不能为空"})
|
||
return
|
||
}
|
||
res, err := parser.Parse(body.Text, body.RefType, body.FixedRef)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
candidates := allCandidates()
|
||
type parsedJob struct {
|
||
parser.Job
|
||
Matched bool `json:"matched"`
|
||
Source string `json:"source"`
|
||
PipelineID uint `json:"pipelineId"`
|
||
AutoCreatable bool `json:"autoCreatable"` // Jenkins 中存在可自动创建
|
||
}
|
||
jobs := make([]parsedJob, 0, len(res.Jobs))
|
||
for _, j := range res.Jobs {
|
||
m := matchCandidate(j.Name, candidates)
|
||
pj := parsedJob{Job: j, Matched: m != nil, Source: sourceOf(m)}
|
||
if m != nil {
|
||
pj.PipelineID = m.PipelineID
|
||
} else {
|
||
// 未匹配到流水线,但 Jenkins 里有同名 Job → 可自动创建
|
||
jm := matchCandidate(j.Name, filterJenkins(candidates))
|
||
if jm != nil {
|
||
pj.AutoCreatable = true
|
||
pj.Source = "jenkins"
|
||
}
|
||
}
|
||
jobs = append(jobs, pj)
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"jobs": jobs, "param": res.ParamName, "refType": res.RefType, "count": len(jobs)})
|
||
}
|
||
|
||
func filterJenkins(candidates []Candidate) []Candidate {
|
||
var out []Candidate
|
||
for _, c := range candidates {
|
||
if c.Source == "jenkins" {
|
||
out = append(out, c)
|
||
}
|
||
}
|
||
return out
|
||
}
|