feat: v0.4.0 主题切换 + LLM 全面优化 + 登录会话 + 用户管理/LDAP
主题: - 深色/浅色/跟随系统(useTheme:本地缓存先行+远端同步+matchMedia), 侧边栏快捷切换,设置页三档即选即生效 LLM(参考 opsxdb/aiops): - llm.Client.ChatStream 流式(SSE 增量解析,透传 reasoning_content, ctx 控制超时);POST /api/ai/chat/stream(平台实时数据注入降幻觉, 未配 LLM 降级规则 JSON,指令解析 action 不变) - test-llm 支持草稿直测(保存前验证,apiKey 空/掩码回退已存) - 发布失败自动 AI 分析日志尾部(结论/根因/建议),发布记录与发布 Tab 展示分析卡片 - 设置页 Provider 预设(DeepSeek/GLM/GLM编码/Qwen/Kimi/Ollama/自定义) 自动填 endpoint+模型+获取 Key 直达链接 登录与会话: - HMAC 签名 token(7 天,密钥派生自 encryption.key); authMiddleware 保护 /api/*(豁免登录/健康/SSE日志流/入站webhook) - 登录页 /login + 未登录跳转 + 登出;401 自动清理跳登录 用户管理与 LDAP(借鉴 LokiStack): - /users 页面:本地用户 CRUD、一次性密码、角色切换、来源徽章 (本地·蓝 | LDAP导入·紫);LDAP 导入按钮仅在启用且配置齐备时显示 - LDAP 配置卡(未启用仅提示语)、测试连接草稿直测+可选测试账号 - go-ldap:管理 Bind/用户搜索(防注入)/用户 DN 直连验证; 批量导入冲突策略(已LDAP跳过/本地同名转LDAP/新建); 登录分流本地 argon2id 或 LDAP 目录验证 + 首登 JIT 建号
This commit is contained in:
1 parent
555934cc64
commit
97193eb090
24 files changed
+2422
-98
No files matched your search
+12
-9
@@ -1,16 +1,18 @@
|
||||
module flowstack
|
||||
|
||||
go 1.24
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/gin-contrib/cors v1.7.6
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
golang.org/x/crypto v0.40.0
|
||||
github.com/go-ldap/ldap/v3 v3.4.15
|
||||
golang.org/x/crypto v0.54.0
|
||||
gorm.io/gorm v1.30.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
@@ -18,12 +20,13 @@ require (
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
@@ -40,12 +43,12 @@ require (
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
golang.org/x/tools v0.34.0 // indirect
|
||||
golang.org/x/mod v0.37.0 // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/tools v0.47.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
|
||||
+40
-18
@@ -1,3 +1,7 @@
|
||||
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
|
||||
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
@@ -21,6 +25,10 @@ github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9g
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-ldap/ldap/v3 v3.4.15 h1:eRB4f4Ecn1POL1mO86+uUDlecmyWsdiJwh60bAUS4UY=
|
||||
github.com/go-ldap/ldap/v3 v3.4.15/go.mod h1:jeD0c98EdZQCKKKKe4kTWs5aCf6y+j8KuYsYLznZSQk=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@@ -38,8 +46,22 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
|
||||
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
|
||||
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
@@ -85,23 +107,23 @@ go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
|
||||
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
|
||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"flowstack/internal/database"
|
||||
"flowstack/internal/llm"
|
||||
"flowstack/internal/model"
|
||||
)
|
||||
|
||||
// buildPlatformContext 平台实时数据注入(降低 AI 幻觉):流水线概况 + 最近运行
|
||||
func buildPlatformContext() string {
|
||||
var pipelines []model.Pipeline
|
||||
database.DB.Find(&pipelines)
|
||||
var runs []model.PipelineRun
|
||||
database.DB.Order("started_at DESC").Limit(5).Find(&runs)
|
||||
running := 0
|
||||
for _, r := range runs {
|
||||
if r.Status == "running" || r.Status == "pending" {
|
||||
running++
|
||||
}
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "【平台实时数据】\n流水线总数: %d(运行中 %d)\n", len(pipelines), running)
|
||||
if len(runs) > 0 {
|
||||
b.WriteString("最近运行:\n")
|
||||
for _, r := range runs {
|
||||
fmt.Fprintf(&b, "- %s %s %s %s %s\n", r.PipelineName, r.RunID, r.Branch, r.Status, fmtDurMs(r.DurationMs))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func fmtDurMs(ms int64) string {
|
||||
if ms <= 0 {
|
||||
return "-"
|
||||
}
|
||||
return fmt.Sprintf("%ds", ms/1000)
|
||||
}
|
||||
|
||||
// AiChatStream POST /api/ai/chat/stream —— 流式对话(SSE)
|
||||
func AiChatStream(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)
|
||||
|
||||
client := llmClientFromSettings()
|
||||
if client == nil {
|
||||
// 未配置 LLM:直接返回规则结果(不流式)
|
||||
reply, action := ruleReply(ruleRes, candidates)
|
||||
c.JSON(http.StatusOK, gin.H{"reply": reply, "viaAI": false, "action": action})
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
flush := func() {
|
||||
if f, ok := c.Writer.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
emit := func(event string, data any) {
|
||||
b, _ := json.Marshal(data)
|
||||
fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, b)
|
||||
flush()
|
||||
}
|
||||
|
||||
system := fmt.Sprintf(`你是 FlowStack 发布系统的智能发布助手。用户会用自然语言描述要发布的服务。
|
||||
%s
|
||||
可发布的服务清单(流水线与 Jenkins Job 名称)如下:
|
||||
%s
|
||||
|
||||
规则:
|
||||
- 若用户意图是发布/构建服务:只输出 JSON(不要 markdown 代码块、不要多余文字):
|
||||
{"reply":"一句中文确认语","jobs":["服务名1"],"branch":"分支或留空","env":"环境或留空"}
|
||||
jobs 必须来自上面的清单按原文匹配;用户提到的服务不在清单时 jobs 留空并在 reply 中说明。
|
||||
- 其他问题(如询问状态、概念解释):用简洁中文自然语言回答,禁止编造清单中不存在的服务或字段。`,
|
||||
buildPlatformContext(), strings.Join(names, "\n"))
|
||||
|
||||
emit("start", gin.H{"viaAI": true})
|
||||
|
||||
ctx := c.Request.Context()
|
||||
full, err := client.ChatStream(ctx, system, lastUser, func(d llm.StreamDelta) {
|
||||
if d.Content != "" || d.Reasoning != "" {
|
||||
emit("delta", gin.H{"content": d.Content, "reasoning": d.Reasoning})
|
||||
}
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
// LLM 失败:降级规则结果
|
||||
reply, action := ruleReply(ruleRes, candidates)
|
||||
emit("done", gin.H{"reply": reply + "\n(AI 调用失败已降级本地规则: " + err.Error() + ")", "viaAI": false, "action": action})
|
||||
return
|
||||
}
|
||||
|
||||
reply, action := parseLLMJson(full, candidates)
|
||||
if action == nil && len(ruleRes.Jobs) > 0 {
|
||||
// LLM 没识别出发布指令但规则识别到了,合并规则结果
|
||||
_, action = ruleReply(ruleRes, candidates)
|
||||
}
|
||||
emit("done", gin.H{"reply": reply, "viaAI": true, "action": action})
|
||||
}
|
||||
|
||||
func ruleReply(ruleRes ruleParseResult, candidates []Candidate) (string, *aiReleaseAction) {
|
||||
if len(ruleRes.Jobs) == 0 {
|
||||
return "未识别到要发布的服务。请直接告诉我服务名,例如:发布 cex-agent-server、cex-agent-api 到 pre 分支。", nil
|
||||
}
|
||||
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}
|
||||
return fmt.Sprintf("识别到 %d 个服务%s,确认后开始发布。", len(jobs), branchSuffix(ruleRes.Branch)), action
|
||||
}
|
||||
|
||||
// analyzeFailure 发布失败时用 LLM 分析日志尾部(opsx-loki AnalyzeJenkins 同款 prompt)
|
||||
func analyzeFailure(run model.PipelineRun) {
|
||||
client := llmClientFromSettings()
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
var logs []model.LogEntry
|
||||
database.DB.Where("run_id = ?", run.ID).Order("timestamp DESC").Limit(100).Find(&logs)
|
||||
if len(logs) == 0 {
|
||||
return
|
||||
}
|
||||
// 倒序取出后反转为正序
|
||||
for i, j := 0, len(logs)-1; i < j; i, j = i+1, j-1 {
|
||||
logs[i], logs[j] = logs[j], logs[i]
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, l := range logs {
|
||||
b.WriteString(l.Message)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
system := `你是资深发布助手。根据流水线执行日志分析发布失败原因。用简洁中文输出,结构固定:
|
||||
1. 结论:一句话说明失败原因
|
||||
2. 失败阶段或关键步骤
|
||||
3. 根因(如编译错误、依赖缺失、测试失败、镜像推送失败、部署超时)
|
||||
4. 处理建议(1-3 条,可执行)
|
||||
不要复述整段日志。`
|
||||
user := fmt.Sprintf("流水线: %s\n分支: %s\n状态: %s\n\n日志尾部:\n%s", run.PipelineName, run.Branch, run.Status, b.String())
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
reply, err := client.Chat(ctx, system, user)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
database.DB.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Update("analysis", reply)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -10,7 +20,98 @@ import (
|
||||
"flowstack/internal/model"
|
||||
)
|
||||
|
||||
// Login POST /api/auth/login {username, password}
|
||||
const tokenTTL = 7 * 24 * time.Hour
|
||||
|
||||
// sessionSecret 从 data/encryption.key 派生会话签名密钥
|
||||
func sessionSecret() []byte {
|
||||
key := os.Getenv("FLOWSTACK_ENCRYPTION_KEY")
|
||||
if key == "" {
|
||||
if data, err := os.ReadFile(filepath.Join(database.DataDir, "encryption.key")); err == nil && len(data) == 32 {
|
||||
key = string(data)
|
||||
}
|
||||
}
|
||||
sum := sha256.Sum256([]byte("flowstack-session:" + key))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
// SignToken 签发 HMAC 无状态 token:base64(user|exp|sig)
|
||||
func SignToken(username string) (string, time.Time) {
|
||||
exp := time.Now().Add(tokenTTL)
|
||||
payload := username + "|" + strconv.FormatInt(exp.Unix(), 10)
|
||||
mac := hmac.New(sha256.New, sessionSecret())
|
||||
mac.Write([]byte(payload))
|
||||
sig := hex.EncodeToString(mac.Sum(nil))
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(payload + "|" + sig)), exp
|
||||
}
|
||||
|
||||
// VerifyToken 校验 token,返回用户名
|
||||
func VerifyToken(token string) (string, error) {
|
||||
raw, err := base64.RawURLEncoding.DecodeString(token)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("token 格式错误")
|
||||
}
|
||||
parts := strings.Split(string(raw), "|")
|
||||
if len(parts) != 3 {
|
||||
return "", fmt.Errorf("token 结构错误")
|
||||
}
|
||||
payload := parts[0] + "|" + parts[1]
|
||||
mac := hmac.New(sha256.New, sessionSecret())
|
||||
mac.Write([]byte(payload))
|
||||
expect := hex.EncodeToString(mac.Sum(nil))
|
||||
if !hmac.Equal([]byte(expect), []byte(parts[2])) {
|
||||
return "", fmt.Errorf("token 签名校验失败")
|
||||
}
|
||||
expUnix, err := strconv.ParseInt(parts[1], 10, 64)
|
||||
if err != nil || time.Now().Unix() > expUnix {
|
||||
return "", fmt.Errorf("token 已过期")
|
||||
}
|
||||
return parts[0], nil
|
||||
}
|
||||
|
||||
// AuthMiddleware 会话鉴权:仅保护 /api/*(豁免登录/入站 Webhook/SSE 日志流);SPA 与静态资源放行
|
||||
func AuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
p := c.Request.URL.Path
|
||||
if p == "/health" || !strings.HasPrefix(p, "/api/") ||
|
||||
p == "/api/auth/login" ||
|
||||
strings.HasPrefix(p, "/api/hooks/") ||
|
||||
strings.HasSuffix(p, "/logs/stream") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
auth := c.GetHeader("Authorization")
|
||||
if !strings.HasPrefix(auth, "Bearer ") {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||||
return
|
||||
}
|
||||
username, err := VerifyToken(strings.TrimPrefix(auth, "Bearer "))
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "登录已失效,请重新登录"})
|
||||
return
|
||||
}
|
||||
var user model.User
|
||||
if err := database.DB.Where("username = ?", username).First(&user).Error; err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
c.Set("username", user.Username)
|
||||
c.Set("role", user.Role)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// AdminOnly 管理员专用接口守卫
|
||||
func AdminOnly() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if c.GetString("role") != "admin" {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// Login POST /api/auth/login —— 本地 argon2id 与 LDAP 分流认证
|
||||
func Login(c *gin.Context) {
|
||||
var body struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
@@ -21,16 +122,33 @@ func Login(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 1) 本地用户:AuthSource != ldap 才走本地口令
|
||||
var user model.User
|
||||
if err := database.DB.Where("username = ?", body.Username).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
||||
hasLocal := database.DB.Where("username = ?", body.Username).First(&user).Error == nil
|
||||
if hasLocal && user.AuthSource != "ldap" {
|
||||
if !crypto.VerifyPassword(body.Password, user.Password) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
||||
return
|
||||
}
|
||||
token, exp := SignToken(user.Username)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true, "token": token, "expiresAt": exp,
|
||||
"username": user.Username, "nickname": user.Nickname, "role": user.Role,
|
||||
})
|
||||
return
|
||||
}
|
||||
if !crypto.VerifyPassword(body.Password, user.Password) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
||||
|
||||
// 2) LDAP:目录验证成功即登录(首次自动建号/同步)
|
||||
if entry, err := ldapVerifyUser(body.Username, body.Password); err == nil {
|
||||
user := syncLDAPUser(entry)
|
||||
token, exp := SignToken(user.Username)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true, "token": token, "expiresAt": exp,
|
||||
"username": user.Username, "nickname": user.Nickname, "role": user.Role,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true, "username": user.Username, "nickname": user.Nickname, "role": user.Role,
|
||||
})
|
||||
|
||||
// 统一错误,不泄露用户是否存在
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
||||
}
|
||||
@@ -212,6 +212,9 @@ func jenkinsRunEngine(hub *Hub, run model.PipelineRun, client *jenkins.Client, j
|
||||
// 发布结束后回查最新构建信息再推飞书卡片(含 buildNumber/externalURL)
|
||||
var fresh model.PipelineRun
|
||||
database.DB.First(&fresh, run.ID)
|
||||
if status == "failed" {
|
||||
go analyzeFailure(fresh)
|
||||
}
|
||||
go notifyLarkRelease(fresh, status)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -91,6 +92,7 @@ func GetSettings(c *gin.Context) {
|
||||
}
|
||||
s.LlmApiKeySet = s.LlmApiKey != ""
|
||||
s.LarkSecretSet = s.LarkSecret != ""
|
||||
s.LdapBindPasswordSet = s.LdapBindPassword != ""
|
||||
c.JSON(http.StatusOK, s)
|
||||
}
|
||||
|
||||
@@ -158,27 +160,50 @@ func UpdateSettings(c *gin.Context) {
|
||||
database.DB.First(&s, 1)
|
||||
s.LlmApiKeySet = s.LlmApiKey != ""
|
||||
s.LarkSecretSet = s.LarkSecret != ""
|
||||
s.LdapBindPasswordSet = s.LdapBindPassword != ""
|
||||
c.JSON(http.StatusOK, s)
|
||||
}
|
||||
|
||||
// TestLLM POST /api/settings/test-llm —— 用已保存配置做 ping 对话
|
||||
// TestLLM POST /api/settings/test-llm —— 支持草稿直测:body 带表单值时优先(apiKey 空=用已存)
|
||||
func TestLLM(c *gin.Context) {
|
||||
var s model.SystemSetting
|
||||
if err := database.DB.First(&s, 1).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请先保存设置"})
|
||||
return
|
||||
s = model.SystemSetting{}
|
||||
}
|
||||
if !s.LlmEnabled || s.LlmApiKey == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "AI 未启用或未配置 API Key"})
|
||||
return
|
||||
var body struct {
|
||||
LlmEnabled *bool `json:"llmEnabled"`
|
||||
LlmEndpoint string `json:"llmEndpoint"`
|
||||
LlmModel string `json:"llmModel"`
|
||||
LlmApiKey string `json:"llmApiKey"`
|
||||
}
|
||||
apiKey := s.LlmApiKey
|
||||
if crypto.IsEncrypted(apiKey) {
|
||||
if plain, err := crypto.DecryptField(apiKey); err == nil {
|
||||
apiKey = plain
|
||||
_ = c.ShouldBindJSON(&body)
|
||||
|
||||
enabled := s.LlmEnabled
|
||||
if body.LlmEnabled != nil {
|
||||
enabled = *body.LlmEnabled
|
||||
}
|
||||
endpoint := body.LlmEndpoint
|
||||
if endpoint == "" {
|
||||
endpoint = s.LlmEndpoint
|
||||
}
|
||||
model_ := body.LlmModel
|
||||
if model_ == "" {
|
||||
model_ = s.LlmModel
|
||||
}
|
||||
apiKey := body.LlmApiKey
|
||||
if apiKey == "" || strings.Contains(apiKey, "****") {
|
||||
apiKey = s.LlmApiKey
|
||||
if crypto.IsEncrypted(apiKey) {
|
||||
if plain, err := crypto.DecryptField(apiKey); err == nil {
|
||||
apiKey = plain
|
||||
}
|
||||
}
|
||||
}
|
||||
client := llm.NewClient(s.LlmEndpoint, apiKey, s.LlmModel, 128)
|
||||
if !enabled || apiKey == "" || endpoint == "" || model_ == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写启用/接口/模型/API Key(或先保存已有配置)"})
|
||||
return
|
||||
}
|
||||
client := llm.NewClient(endpoint, apiKey, model_, 128)
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
reply, err := client.Chat(ctx, "你是连通性检查助手,只回复:连接成功。", "ping")
|
||||
|
||||
@@ -134,6 +134,9 @@ func simulateRun(hub *Hub, run model.PipelineRun, stages []model.RunStage) {
|
||||
"branch": run.Branch, "durationMs": time.Since(start).Milliseconds(),
|
||||
})
|
||||
}
|
||||
if status == "failed" {
|
||||
go analyzeFailure(run)
|
||||
}
|
||||
go notifyLarkRelease(run, status)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"flowstack/internal/crypto"
|
||||
"flowstack/internal/database"
|
||||
"flowstack/internal/ldap"
|
||||
"flowstack/internal/model"
|
||||
)
|
||||
|
||||
// ---- LDAP 配置辅助 ----
|
||||
|
||||
type ldapRuntime struct {
|
||||
Host, BindDN, BindPassword, BaseDN, UserFilter, UsernameAttr, NameAttr, EmailAttr string
|
||||
Port int
|
||||
UseSSL bool
|
||||
Enabled, Configured bool
|
||||
}
|
||||
|
||||
func loadLDAP() ldapRuntime {
|
||||
var s model.SystemSetting
|
||||
database.DB.First(&s, 1)
|
||||
rt := ldapRuntime{
|
||||
Host: s.LdapHost, Port: s.LdapPort, UseSSL: s.LdapUseSSL,
|
||||
BindDN: s.LdapBindDN, BaseDN: s.LdapBaseDN,
|
||||
UserFilter: s.LdapUserFilter, UsernameAttr: s.LdapUsernameAttr,
|
||||
NameAttr: s.LdapNameAttr, EmailAttr: s.LdapEmailAttr,
|
||||
Enabled: s.LdapEnabled,
|
||||
}
|
||||
rt.Configured = rt.Host != "" && rt.BindDN != "" && rt.BaseDN != ""
|
||||
return rt
|
||||
}
|
||||
|
||||
func (rt ldapRuntime) bindPassword() string {
|
||||
var s model.SystemSetting
|
||||
database.DB.First(&s, 1)
|
||||
if s.LdapBindPassword == "" {
|
||||
return ""
|
||||
}
|
||||
if crypto.IsEncrypted(s.LdapBindPassword) {
|
||||
if plain, err := crypto.DecryptField(s.LdapBindPassword); err == nil {
|
||||
return plain
|
||||
}
|
||||
}
|
||||
return s.LdapBindPassword
|
||||
}
|
||||
|
||||
var errLDAPDisabled = errors.New("ldap disabled")
|
||||
|
||||
// ldapVerifyUser 登录用:目录搜索 + 用户 DN 直连 bind
|
||||
func ldapVerifyUser(username, password string) (*ldap.Entry, error) {
|
||||
rt := loadLDAP()
|
||||
if !rt.Enabled || !rt.Configured {
|
||||
return nil, errLDAPDisabled
|
||||
}
|
||||
entry, err := ldap.SearchOne(rt.Host, rt.Port, rt.UseSSL, rt.BindDN, rt.bindPassword(), rt.BaseDN, rt.UserFilter, rt.UsernameAttr, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ldap.BindAsUser(rt.Host, rt.Port, rt.UseSSL, entry.DN, password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// syncLDAPUser LDAP 验证通过后同步/建号(JIT)
|
||||
func syncLDAPUser(entry *ldap.Entry) model.User {
|
||||
var user model.User
|
||||
if err := database.DB.Where("username = ?", entry.Username).First(&user).Error; err == nil {
|
||||
// 已存在:本地同名转 LDAP + 同步昵称邮箱
|
||||
database.DB.Model(&user).Updates(map[string]any{
|
||||
"auth_source": "ldap", "nickname": entry.DisplayName, "email": entry.Email,
|
||||
})
|
||||
database.DB.First(&user, user.ID)
|
||||
return user
|
||||
}
|
||||
user = model.User{
|
||||
Username: entry.Username, Nickname: entry.DisplayName, Email: entry.Email,
|
||||
Role: "user", AuthSource: "ldap",
|
||||
// 占位密码:本地不可登录
|
||||
Password: mustHash("ldap-user-" + entry.Username + "-no-local-login"),
|
||||
}
|
||||
database.DB.Create(&user)
|
||||
return user
|
||||
}
|
||||
|
||||
func mustHash(s string) string {
|
||||
h, _ := crypto.HashPassword(s)
|
||||
return h
|
||||
}
|
||||
|
||||
// ---- 用户管理 ----
|
||||
|
||||
// ListUsers GET /api/users
|
||||
func ListUsers(c *gin.Context) {
|
||||
var list []model.User
|
||||
database.DB.Order("created_at ASC").Find(&list)
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
|
||||
// CreateUser POST /api/users —— 创建本地用户
|
||||
func CreateUser(c *gin.Context) {
|
||||
var body struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Nickname string `json:"nickname"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "用户名不能为空"})
|
||||
return
|
||||
}
|
||||
if body.Role != "admin" {
|
||||
body.Role = "user"
|
||||
}
|
||||
password := body.Password
|
||||
if password == "" {
|
||||
var err error
|
||||
password, err = crypto.RandomPassword(16)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
hash, err := crypto.HashPassword(password)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
user := model.User{
|
||||
Username: body.Username, Nickname: body.Nickname, Email: body.Email,
|
||||
Role: body.Role, AuthSource: "local", Password: hash,
|
||||
}
|
||||
if err := database.DB.Create(&user).Error; err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "创建失败:用户名已存在"})
|
||||
return
|
||||
}
|
||||
// 密码仅在创建时回显一次(指定密码时不回显明文,回显随机生成的)
|
||||
result := gin.H{"id": user.ID, "username": user.Username, "nickname": user.Nickname, "email": user.Email, "role": user.Role, "authSource": user.AuthSource}
|
||||
if body.Password == "" {
|
||||
result["initialPassword"] = password
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// ResetPassword POST /api/users/:id/reset-password —— LDAP 用户拒绝
|
||||
func ResetPassword(c *gin.Context) {
|
||||
var user model.User
|
||||
if err := database.DB.First(&user, c.Param("id")).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
if user.AuthSource == "ldap" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "LDAP 用户密码由目录服务管理"})
|
||||
return
|
||||
}
|
||||
password, err := crypto.RandomPassword(16)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
hash, err := crypto.HashPassword(password)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
database.DB.Model(&user).Update("password", hash)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "initialPassword": password})
|
||||
}
|
||||
|
||||
// DeleteUser DELETE /api/users/:id —— 不可删最后一个管理员/自己
|
||||
func DeleteUser(c *gin.Context) {
|
||||
var user model.User
|
||||
if err := database.DB.First(&user, c.Param("id")).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
if user.Username == c.GetString("username") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "不能删除当前登录用户"})
|
||||
return
|
||||
}
|
||||
if user.Role == "admin" {
|
||||
var adminCount int64
|
||||
database.DB.Model(&model.User{}).Where("role = ?", "admin").Count(&adminCount)
|
||||
if adminCount <= 1 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "不能删除最后一个管理员"})
|
||||
return
|
||||
}
|
||||
}
|
||||
database.DB.Delete(&user)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// UpdateUser PUT /api/users/:id —— 角色/昵称/邮箱
|
||||
func UpdateUser(c *gin.Context) {
|
||||
var user model.User
|
||||
if err := database.DB.First(&user, c.Param("id")).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
var body map[string]any
|
||||
_ = c.ShouldBindJSON(&body)
|
||||
if v, ok := body["role"].(string); ok && (v == "admin" || v == "user") {
|
||||
database.DB.Model(&user).Update("role", v)
|
||||
}
|
||||
if v, ok := body["nickname"].(string); ok {
|
||||
database.DB.Model(&user).Update("nickname", v)
|
||||
}
|
||||
if v, ok := body["email"].(string); ok {
|
||||
database.DB.Model(&user).Update("email", v)
|
||||
}
|
||||
database.DB.First(&user, user.ID)
|
||||
c.JSON(http.StatusOK, user)
|
||||
}
|
||||
|
||||
// ---- LDAP 用户导入(LokiStack 交互与冲突策略) ----
|
||||
|
||||
// LdapListUsers POST /api/ldap/users —— 导入预览(未启用返回 400)
|
||||
func LdapListUsers(c *gin.Context) {
|
||||
rt := loadLDAP()
|
||||
if !rt.Enabled || !rt.Configured {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "LDAP 未启用或未配置"})
|
||||
return
|
||||
}
|
||||
entries, err := ldap.SearchUsers(rt.Host, rt.Port, rt.UseSSL, rt.BindDN, rt.bindPassword(),
|
||||
rt.BaseDN, rt.UserFilter, rt.UsernameAttr, rt.NameAttr, rt.EmailAttr, 500)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, entries)
|
||||
}
|
||||
|
||||
// LdapImport POST /api/users/ldap-import —— 批量导入(跳过已LDAP/本地同名转LDAP/新建)
|
||||
func LdapImport(c *gin.Context) {
|
||||
var body struct {
|
||||
Users []struct {
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
Email string `json:"email"`
|
||||
} `json:"users" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || len(body.Users) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择要导入的用户"})
|
||||
return
|
||||
}
|
||||
var created, converted, skipped, failed int
|
||||
var failedUsers []string
|
||||
for _, in := range body.Users {
|
||||
if in.Username == "" {
|
||||
continue
|
||||
}
|
||||
var exist model.User
|
||||
if err := database.DB.Where("username = ?", in.Username).First(&exist).Error; err == nil {
|
||||
if exist.AuthSource == "ldap" {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
// 本地同名 → 转为 LDAP
|
||||
database.DB.Model(&exist).Updates(map[string]any{"auth_source": "ldap", "nickname": in.Nickname, "email": in.Email})
|
||||
converted++
|
||||
continue
|
||||
}
|
||||
user := model.User{
|
||||
Username: in.Username, Nickname: in.Nickname, Email: in.Email,
|
||||
Role: "user", AuthSource: "ldap",
|
||||
Password: mustHash("ldap-user-" + in.Username + "-no-local-login"),
|
||||
}
|
||||
if err := database.DB.Create(&user).Error; err != nil {
|
||||
failed++
|
||||
failedUsers = append(failedUsers, in.Username)
|
||||
continue
|
||||
}
|
||||
created++
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"created": created, "converted": converted, "skipped": skipped,
|
||||
"failed": failed, "failedUsers": failedUsers,
|
||||
})
|
||||
}
|
||||
|
||||
// TestLDAP POST /api/settings/ldap/test —— 支持草稿直测 + 可选测试账号
|
||||
func TestLDAP(c *gin.Context) {
|
||||
var s model.SystemSetting
|
||||
database.DB.First(&s, 1)
|
||||
var body struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
Host string `json:"host"`
|
||||
Port float64 `json:"port"`
|
||||
UseSSL *bool `json:"useSSL"`
|
||||
BindDN string `json:"bindDN"`
|
||||
BindPassword string `json:"bindPassword"`
|
||||
BaseDN string `json:"baseDN"`
|
||||
UserFilter string `json:"userFilter"`
|
||||
UsernameAttr string `json:"usernameAttr"`
|
||||
TestUsername string `json:"testUsername"`
|
||||
TestPassword string `json:"testPassword"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&body)
|
||||
|
||||
host := body.Host
|
||||
if host == "" {
|
||||
host = s.LdapHost
|
||||
}
|
||||
bindDN := body.BindDN
|
||||
if bindDN == "" {
|
||||
bindDN = s.LdapBindDN
|
||||
}
|
||||
password := body.BindPassword
|
||||
if password == "" {
|
||||
password = ""
|
||||
var stored string
|
||||
database.DB.Select("ldap_bind_password").First(&s, 1)
|
||||
stored = s.LdapBindPassword
|
||||
if crypto.IsEncrypted(stored) {
|
||||
if plain, err := crypto.DecryptField(stored); err == nil {
|
||||
password = plain
|
||||
}
|
||||
} else {
|
||||
password = stored
|
||||
}
|
||||
}
|
||||
baseDN := body.BaseDN
|
||||
if baseDN == "" {
|
||||
baseDN = s.LdapBaseDN
|
||||
}
|
||||
port := int(body.Port)
|
||||
if port == 0 {
|
||||
port = s.LdapPort
|
||||
}
|
||||
if port == 0 {
|
||||
port = 389
|
||||
}
|
||||
useSSL := s.LdapUseSSL
|
||||
if body.UseSSL != nil {
|
||||
useSSL = *body.UseSSL
|
||||
}
|
||||
usernameAttr := body.UsernameAttr
|
||||
if usernameAttr == "" {
|
||||
usernameAttr = s.LdapUsernameAttr
|
||||
}
|
||||
userFilter := body.UserFilter
|
||||
if userFilter == "" {
|
||||
userFilter = s.LdapUserFilter
|
||||
}
|
||||
|
||||
if host == "" || bindDN == "" || baseDN == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "请填写服务器/Bind DN/Base DN"})
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := ldap.Dial(host, port, useSSL, bindDN, password)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// 可选:测试账号级验证
|
||||
if body.TestUsername != "" && body.TestPassword != "" {
|
||||
entry, err := ldap.SearchOne(host, port, useSSL, bindDN, password, baseDN, userFilter, usernameAttr, body.TestUsername)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "测试账号搜索失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if err := ldap.BindAsUser(host, port, useSSL, entry.DN, body.TestPassword); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "测试账号密码验证失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "连接与测试账号验证成功:" + entry.DisplayName + " (" + entry.Email + ")"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "LDAP 连接成功(管理员绑定通过)"})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// Package ldap 目录服务集成(go-ldap,实现模式参考 LokiStack)。
|
||||
package ldap
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
)
|
||||
|
||||
// Entry LDAP 用户条目
|
||||
type Entry struct {
|
||||
DN string `json:"dn"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// Dial 连接并完成管理员 Bind(5s 拨号 / 10s 操作超时)
|
||||
func Dial(host string, port int, useSSL bool, bindDN, bindPassword string) (*ldap.Conn, error) {
|
||||
scheme := "ldap"
|
||||
if useSSL {
|
||||
scheme = "ldaps"
|
||||
}
|
||||
url := fmt.Sprintf("%s://%s:%d", scheme, host, port)
|
||||
conn, err := ldap.DialURL(url,
|
||||
ldap.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}),
|
||||
ldap.DialWithTLSConfig(&tls.Config{InsecureSkipVerify: true}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无法连接 LDAP 服务器 %s: %v", url, err)
|
||||
}
|
||||
conn.SetTimeout(10 * time.Second)
|
||||
if err := conn.Bind(bindDN, bindPassword); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("管理员绑定失败(检查 Bind DN/密码): %v", err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func normalizeFilter(f, def string) string {
|
||||
if strings.TrimSpace(f) == "" {
|
||||
f = def
|
||||
}
|
||||
if !strings.HasPrefix(f, "(") {
|
||||
f = "(" + f + ")"
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// SearchUsers 按 BaseDN/过滤器搜索用户(sizeLimit 截断)
|
||||
func SearchUsers(host string, port int, useSSL bool, bindDN, bindPassword, baseDN, userFilter, usernameAttr, nameAttr, emailAttr string, sizeLimit int) ([]Entry, error) {
|
||||
conn, err := Dial(host, port, useSSL, bindDN, bindPassword)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if usernameAttr == "" {
|
||||
usernameAttr = "uid"
|
||||
}
|
||||
if nameAttr == "" {
|
||||
nameAttr = "displayName"
|
||||
}
|
||||
if emailAttr == "" {
|
||||
emailAttr = "mail"
|
||||
}
|
||||
req := ldap.NewSearchRequest(baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
|
||||
sizeLimit, 0, false, normalizeFilter(userFilter, "(objectClass=inetOrgPerson)"),
|
||||
[]string{"dn", usernameAttr, nameAttr, "cn", emailAttr}, nil)
|
||||
res, err := conn.Search(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("搜索失败: %v", err)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var out []Entry
|
||||
for _, e := range res.Entries {
|
||||
username := e.GetAttributeValue(usernameAttr)
|
||||
if username == "" {
|
||||
continue // 跳过 OU 等非用户条目
|
||||
}
|
||||
if seen[e.DN] {
|
||||
continue
|
||||
}
|
||||
seen[e.DN] = true
|
||||
name := e.GetAttributeValue(nameAttr)
|
||||
if name == "" {
|
||||
name = e.GetAttributeValue("cn")
|
||||
}
|
||||
out = append(out, Entry{
|
||||
DN: e.DN, Username: username, DisplayName: name, Email: e.GetAttributeValue(emailAttr),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SearchOne 按用户名搜索单个条目(登录用,EscapeFilter 防注入)
|
||||
func SearchOne(host string, port int, useSSL bool, bindDN, bindPassword, baseDN, userFilter, usernameAttr, username string) (*Entry, error) {
|
||||
conn, err := Dial(host, port, useSSL, bindDN, bindPassword)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if usernameAttr == "" {
|
||||
usernameAttr = "uid"
|
||||
}
|
||||
filter := fmt.Sprintf("(&%s(%s=%s))", normalizeFilter(userFilter, "(objectClass=inetOrgPerson)"),
|
||||
usernameAttr, ldap.EscapeFilter(username))
|
||||
req := ldap.NewSearchRequest(baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
|
||||
2, 0, false, filter, []string{"dn", usernameAttr, "displayName", "cn", "mail"}, nil)
|
||||
res, err := conn.Search(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(res.Entries) == 0 {
|
||||
return nil, fmt.Errorf("用户不存在")
|
||||
}
|
||||
if len(res.Entries) > 1 {
|
||||
return nil, fmt.Errorf("LDAP 中存在多个匹配的用户")
|
||||
}
|
||||
e := res.Entries[0]
|
||||
name := e.GetAttributeValue("displayName")
|
||||
if name == "" {
|
||||
name = e.GetAttributeValue("cn")
|
||||
}
|
||||
return &Entry{DN: e.DN, Username: e.GetAttributeValue(usernameAttr), DisplayName: name, Email: e.GetAttributeValue("mail")}, nil
|
||||
}
|
||||
|
||||
// BindAsUser 用用户自己的 DN+密码直连校验(密码不落库)
|
||||
func BindAsUser(host string, port int, useSSL bool, userDN, password string) error {
|
||||
scheme := "ldap"
|
||||
if useSSL {
|
||||
scheme = "ldaps"
|
||||
}
|
||||
conn, err := ldap.DialURL(fmt.Sprintf("%s://%s:%d", scheme, host, port),
|
||||
ldap.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}),
|
||||
ldap.DialWithTLSConfig(&tls.Config{InsecureSkipVerify: true}),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
return conn.Bind(userDN, password)
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
@@ -41,6 +42,7 @@ type chatRequest struct {
|
||||
Messages []chatMessage `json:"messages"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
}
|
||||
|
||||
type chatResponse struct {
|
||||
@@ -111,3 +113,108 @@ func truncate(s string, n int) string {
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
|
||||
// StreamDelta 单个流式增量
|
||||
type StreamDelta struct {
|
||||
Content string // 正文增量
|
||||
Reasoning string // 推理过程增量(DeepSeek R1 类模型)
|
||||
Usage *Usage // 最后一个 chunk 可能带用量
|
||||
}
|
||||
|
||||
// Usage token 用量
|
||||
type Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
}
|
||||
|
||||
type streamChunk struct {
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
Reasoning string `json:"reasoning_content"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
} `json:"delta"`
|
||||
} `json:"choices"`
|
||||
Usage *Usage `json:"usage"`
|
||||
}
|
||||
|
||||
// ChatStream 流式对话:onDelta 逐段回调(正文/推理),返回完整正文。
|
||||
// 流式请求不设整体超时,由 ctx 控制取消。
|
||||
func (c *Client) ChatStream(ctx context.Context, system, user string, onDelta func(StreamDelta)) (string, error) {
|
||||
if c.Endpoint == "" || c.APIKey == "" || c.Model == "" {
|
||||
return "", fmt.Errorf("LLM 未配置")
|
||||
}
|
||||
payload := chatRequest{
|
||||
Model: c.Model,
|
||||
Messages: []chatMessage{
|
||||
{Role: "system", Content: system},
|
||||
{Role: "user", Content: user},
|
||||
},
|
||||
Temperature: 0.2,
|
||||
MaxTokens: c.MaxTokens,
|
||||
Stream: true,
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Endpoint+"/chat/completions", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
||||
|
||||
// 流式专用 client:Timeout 0,取消由 ctx 控制
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncate(string(data), 200))
|
||||
}
|
||||
|
||||
var full strings.Builder
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if !strings.HasPrefix(line, "data:") {
|
||||
continue
|
||||
}
|
||||
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
if data == "" || data == "[DONE]" {
|
||||
continue
|
||||
}
|
||||
var chunk streamChunk
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
continue
|
||||
}
|
||||
if len(chunk.Choices) == 0 {
|
||||
if chunk.Usage != nil && onDelta != nil {
|
||||
onDelta(StreamDelta{Usage: chunk.Usage})
|
||||
}
|
||||
continue
|
||||
}
|
||||
d := chunk.Choices[0].Delta
|
||||
if d.Content != "" || d.Reasoning != "" {
|
||||
full.WriteString(d.Content)
|
||||
if onDelta != nil {
|
||||
onDelta(StreamDelta{Content: d.Content, Reasoning: d.Reasoning})
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return full.String(), nil // 客户端主动取消:返回已收到的内容
|
||||
}
|
||||
return full.String(), err
|
||||
}
|
||||
if full.Len() == 0 {
|
||||
return "", fmt.Errorf("流式响应为空")
|
||||
}
|
||||
return full.String(), nil
|
||||
}
|
||||
@@ -8,6 +8,8 @@ type User struct {
|
||||
Username string `gorm:"uniqueIndex" json:"username"`
|
||||
Password string `json:"-"` // argon2id 哈希,绝不返回给前端
|
||||
Nickname string `json:"nickname"`
|
||||
Email string `json:"email"`
|
||||
AuthSource string `gorm:"default:local" json:"authSource"` // local | ldap
|
||||
Role string `gorm:"default:user" json:"role"` // admin | user
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
@@ -79,6 +81,7 @@ type PipelineRun struct {
|
||||
Status string `gorm:"default:pending" json:"status"` // pending|queued|running|success|failed|cancelled
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
BuildNumber int `json:"buildNumber"`
|
||||
Analysis string `gorm:"type:text" json:"analysis"` // 发布失败时的 AI 分析
|
||||
ExternalRunID string `json:"externalRunId"`
|
||||
ExternalURL string `json:"externalUrl"`
|
||||
NodesSnapshot string `json:"nodesSnapshot"`
|
||||
@@ -284,6 +287,18 @@ type SystemSetting struct {
|
||||
LarkWebhook string `json:"larkWebhook"` // 飞书群自定义机器人 Webhook
|
||||
LarkSecret string `gorm:"type:text" json:"-"` // 加签密钥(可选),enc:v1: 加密
|
||||
LarkSecretSet bool `gorm:"-" json:"larkSecretSet"`
|
||||
LdapEnabled bool `json:"ldapEnabled"`
|
||||
LdapHost string `json:"ldapHost"`
|
||||
LdapPort int `gorm:"default:389" json:"ldapPort"`
|
||||
LdapUseSSL bool `json:"ldapUseSSL"`
|
||||
LdapBindDN string `json:"ldapBindDN"`
|
||||
LdapBindPassword string `gorm:"type:text" json:"-"` // enc:v1: 加密只写
|
||||
LdapBindPasswordSet bool `gorm:"-" json:"ldapBindPasswordSet"`
|
||||
LdapBaseDN string `json:"ldapBaseDN"`
|
||||
LdapUserFilter string `json:"ldapUserFilter"`
|
||||
LdapUsernameAttr string `json:"ldapUsernameAttr"`
|
||||
LdapNameAttr string `json:"ldapNameAttr"`
|
||||
LdapEmailAttr string `json:"ldapEmailAttr"`
|
||||
Extra string `gorm:"type:text" json:"extra"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
@@ -27,6 +27,9 @@ func main() {
|
||||
hub := handler.NewHub()
|
||||
r := gin.Default()
|
||||
|
||||
// 会话鉴权:所有 /api 需登录(豁免登录/健康检查/入站 Webhook/SSE 日志流)
|
||||
r.Use(handler.AuthMiddleware())
|
||||
|
||||
// ?_clear=1 时下发 Clear-Site-Data,用于清除浏览器缓存的错误重定向等脏缓存
|
||||
// (历史 bug:/index.html 曾返回 301 → ./,浏览器永久缓存后反复触发 ERR_TOO_MANY_REDIRECTS)
|
||||
r.Use(func(c *gin.Context) {
|
||||
@@ -50,6 +53,20 @@ func main() {
|
||||
api := r.Group("/api")
|
||||
{
|
||||
api.POST("/auth/login", handler.Login)
|
||||
api.POST("/auth/logout", func(c *gin.Context) { c.JSON(200, gin.H{"success": true}) })
|
||||
|
||||
// 用户管理(管理员)
|
||||
users := api.Group("/users", handler.AdminOnly())
|
||||
{
|
||||
users.GET("", handler.ListUsers)
|
||||
users.POST("", handler.CreateUser)
|
||||
users.PUT("/:id", handler.UpdateUser)
|
||||
users.DELETE("/:id", handler.DeleteUser)
|
||||
users.POST("/:id/reset-password", handler.ResetPassword)
|
||||
users.POST("/ldap-import", handler.LdapImport)
|
||||
}
|
||||
api.POST("/ldap/users", handler.AdminOnly(), handler.LdapListUsers)
|
||||
api.POST("/settings/ldap/test", handler.AdminOnly(), handler.TestLDAP)
|
||||
|
||||
api.GET("/summary", handler.GetSummary)
|
||||
api.GET("/settings", handler.GetSettings)
|
||||
@@ -58,6 +75,7 @@ func main() {
|
||||
api.POST("/settings/test-lark", handler.TestLark)
|
||||
|
||||
api.POST("/ai/chat", handler.AiChat)
|
||||
api.POST("/ai/chat/stream", handler.AiChatStream)
|
||||
api.POST("/ai/release", handler.AiRelease(hub))
|
||||
api.POST("/batch/parse", handler.BatchParse)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head><meta charset="UTF-8"><title>FlowStack</title></head>
|
||||
<body style="font-family: -apple-system, sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background: #f6f8fa;">
|
||||
<div style="text-align: center; color: #57606a;">
|
||||
<h1 style="font-size: 20px;">FlowStack 前端尚未构建</h1>
|
||||
<h1 style="font-size: 20px;">FlowStack 发布系统</h1>
|
||||
<p>请在项目根目录执行 <code style="background:#eaeef2;padding:2px 6px;border-radius:4px;">./run.sh build</code> 生成前端产物后重新编译。</p>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
Reference in new issue
Block a user