feat: Go后端LDAP真实对接 — AES-256-GCM密码加解密 + 登录LDAP fallback
- 新增 crypto.go: AES-256-GCM 加解密,与 Python backend 兼容 - auth.go: Login 添加 LDAP fallback(本地优先→LDAP绑定→自动创建本地用户) - ldap.go: 实现真实 LDAP 连接/用户搜索/密码验证/OU列表/用户列表 - 所有 LDAP 操作解密 admin_password 后使用 - 新增/更新密码存储时加密 - router.go: 补充 delete-user 路由 - 添加 go-ldap/v3 依赖
This commit is contained in:
1 parent
622923821b
commit
88e5f4e7ee
71 files changed
+20390
-64
No files matched your search
+8
-4
@@ -7,21 +7,25 @@ require (
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
github.com/joho/godotenv v1.5.1
|
||||
golang.org/x/crypto v0.38.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
gorm.io/driver/sqlite v1.5.7
|
||||
gorm.io/gorm v1.26.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Azure/go-ntlmssp v0.1.0 // indirect
|
||||
github.com/bytedance/sonic v1.13.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.0.0 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||
github.com/go-ldap/ldap/v3 v3.4.13 // 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.26.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // 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
|
||||
@@ -36,9 +40,9 @@ require (
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.15.0 // indirect
|
||||
golang.org/x/net v0.38.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.25.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"sql-platform/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GET /api/v1/audit-logs
|
||||
func ListAuditLogs(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
if page < 1 { page = 1 }
|
||||
if pageSize < 1 || pageSize > 100 { pageSize = 20 }
|
||||
|
||||
var total int64
|
||||
model.DB.Model(&model.AuditLog{}).Count(&total)
|
||||
|
||||
var logs []model.AuditLog
|
||||
offset := (page - 1) * pageSize
|
||||
model.DB.Order("id DESC").Offset(offset).Limit(pageSize).Find(&logs)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": logs,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
})
|
||||
}
|
||||
@@ -1,17 +1,23 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sql-platform/internal/middleware"
|
||||
"sql-platform/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// POST /api/v1/auth/login
|
||||
// Strategy: local account first, then LDAP fallback
|
||||
func Login(c *gin.Context) {
|
||||
var req model.LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -20,21 +26,57 @@ func Login(c *gin.Context) {
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := model.DB.Where("username = ?", req.Username).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid username or password"})
|
||||
return
|
||||
}
|
||||
err := model.DB.Where("username = ?", req.Username).First(&user).Error
|
||||
|
||||
if user.IsActive != nil && !*user.IsActive {
|
||||
c.JSON(http.StatusForbidden, gin.H{"detail": "Account is disabled"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.HashedPassword), []byte(req.Password)); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid username or password"})
|
||||
if err == nil {
|
||||
// ── 本地用户存在,用本地密码验证 ──
|
||||
if user.IsActive != nil && !*user.IsActive {
|
||||
c.JSON(http.StatusForbidden, gin.H{"detail": "Account is disabled"})
|
||||
return
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.HashedPassword), []byte(req.Password)); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid username or password"})
|
||||
return
|
||||
}
|
||||
} else if err == gorm.ErrRecordNotFound {
|
||||
// ── 本地不存在该用户,尝试 LDAP 认证 ──
|
||||
ldapUser, ldapInfo, ldapErr := tryLDAPAuth(req.Username, req.Password)
|
||||
if ldapErr != nil || ldapUser == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid username or password"})
|
||||
return
|
||||
}
|
||||
// LDAP 认证成功,自动创建本地用户
|
||||
isActive := true
|
||||
defaultRole := "developer"
|
||||
newUser := model.User{
|
||||
Username: ldapInfo.Username,
|
||||
DisplayName: ldapInfo.DisplayName,
|
||||
Email: ldapInfo.Email,
|
||||
HashedPassword: "-", // LDAP 用户不存本地密码
|
||||
Role: defaultRole,
|
||||
IsActive: &isActive,
|
||||
}
|
||||
if newUser.DisplayName == "" {
|
||||
newUser.DisplayName = newUser.Username
|
||||
}
|
||||
if createErr := model.DB.Create(&newUser).Error; createErr != nil {
|
||||
// 可能并发创建冲突,再查一次
|
||||
model.DB.Where("username = ?", ldapInfo.Username).First(&user)
|
||||
if user.ID == 0 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Failed to create LDAP user"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
user = newUser
|
||||
}
|
||||
// 记录 auth_source
|
||||
_ = ldapUser // used above
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Database error"})
|
||||
return
|
||||
}
|
||||
|
||||
// ── 生成 Token ──
|
||||
token, err := middleware.GenerateToken(&user)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Failed to generate token"})
|
||||
@@ -52,6 +94,106 @@ func Login(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// LDAPAuthInfo holds extracted user info from LDAP
|
||||
type LDAPAuthInfo struct {
|
||||
Username string
|
||||
DisplayName string
|
||||
Email *string
|
||||
}
|
||||
|
||||
// tryLDAPAuth attempts LDAP bind for the given username/password.
|
||||
// Returns (ldapConn, userInfo, error). Caller does NOT need to close conn.
|
||||
func tryLDAPAuth(username, password string) (*ldap.Conn, *LDAPAuthInfo, error) {
|
||||
// 1. Load LDAP config from DB
|
||||
var cfg model.LDAPConfig
|
||||
if err := model.DB.Order("id").First(&cfg).Error; err != nil {
|
||||
return nil, nil, fmt.Errorf("no LDAP config")
|
||||
}
|
||||
if cfg.LDAPHost == "" {
|
||||
return nil, nil, fmt.Errorf("LDAP not configured")
|
||||
}
|
||||
|
||||
// 2. Connect to LDAP server
|
||||
protocol := "ldap"
|
||||
if cfg.UseLDAPS != nil && *cfg.UseLDAPS {
|
||||
protocol = "ldaps"
|
||||
}
|
||||
uri := fmt.Sprintf("%s://%s:%d", protocol, cfg.LDAPHost, 389)
|
||||
if cfg.LDAPPort != nil && *cfg.LDAPPort > 0 {
|
||||
uri = fmt.Sprintf("%s://%s:%d", protocol, cfg.LDAPHost, *cfg.LDAPPort)
|
||||
}
|
||||
|
||||
conn, err := ldap.DialURL(uri)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("LDAP connect failed: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// 3. Admin bind to search user (decrypt password first)
|
||||
adminPw, _ := decryptPassword(cfg.AdminPassword)
|
||||
if err := conn.Bind(cfg.AdminDN, adminPw); err != nil {
|
||||
return nil, nil, fmt.Errorf("LDAP admin bind failed: %w", err)
|
||||
}
|
||||
|
||||
// 4. Build search filter
|
||||
baseFilter := "(objectClass=inetOrgPerson)"
|
||||
if cfg.SearchFilter != nil && *cfg.SearchFilter != "" {
|
||||
baseFilter = *cfg.SearchFilter
|
||||
}
|
||||
userFilter := fmt.Sprintf("(&%s(uid=%s))", baseFilter, ldap.EscapeFilter(username))
|
||||
|
||||
// 5. Search user
|
||||
searchReq := ldap.NewSearchRequest(
|
||||
cfg.BaseDN,
|
||||
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
|
||||
1, 10, false,
|
||||
userFilter,
|
||||
[]string{"uid", "cn", "mail", "telephoneNumber"},
|
||||
nil,
|
||||
)
|
||||
sr, err := conn.Search(searchReq)
|
||||
if err != nil || len(sr.Entries) == 0 {
|
||||
return nil, nil, fmt.Errorf("user not found in LDAP")
|
||||
}
|
||||
entry := sr.Entries[0]
|
||||
userDN := entry.DN
|
||||
|
||||
// 6. Verify password by binding as user
|
||||
if err := conn.Bind(userDN, password); err != nil {
|
||||
return nil, nil, fmt.Errorf("LDAP user bind failed: %w", err)
|
||||
}
|
||||
|
||||
// 7. Extract user info using mapping
|
||||
userMapping := map[string]string{
|
||||
"username": "uid",
|
||||
"email": "mail",
|
||||
"display_name": "cn",
|
||||
}
|
||||
if cfg.UserMapping != nil && *cfg.UserMapping != "" {
|
||||
json.Unmarshal([]byte(*cfg.UserMapping), &userMapping)
|
||||
}
|
||||
|
||||
getAttr := func(key string) string {
|
||||
if v := entry.GetAttributeValue(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
info := &LDAPAuthInfo{
|
||||
Username: getAttr(userMapping["username"]),
|
||||
DisplayName: getAttr(userMapping["display_name"]),
|
||||
}
|
||||
if email := getAttr(userMapping["email"]); email != "" {
|
||||
info.Email = &email
|
||||
}
|
||||
if info.Username == "" {
|
||||
info.Username = username
|
||||
}
|
||||
|
||||
return nil, info, nil // conn already closed by defer
|
||||
}
|
||||
|
||||
// GET /api/v1/auth/me
|
||||
func GetMe(c *gin.Context) {
|
||||
userID, _, _ := middleware.GetCurrentUser(c)
|
||||
@@ -103,7 +245,11 @@ func CreateUser(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := model.DB.Create(&user).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "Username already exists"})
|
||||
if strings.Contains(err.Error(), "UNIQUE") || strings.Contains(err.Error(), "duplicate") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "Username already exists"})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"os"
|
||||
)
|
||||
|
||||
// getSecretKey returns the AES key derived from SECRET_KEY env var.
|
||||
// Mirrors Python's crypto._get_key(): first 32 bytes of utf-8 encoded key, zero-padded.
|
||||
func getSecretKey() []byte {
|
||||
keyStr := os.Getenv("SECRET_KEY")
|
||||
if keyStr == "" {
|
||||
keyStr = "dev-secret-key-change-in-production"
|
||||
}
|
||||
raw := []byte(keyStr)
|
||||
if len(raw) >= 32 {
|
||||
return raw[:32]
|
||||
}
|
||||
padded := make([]byte, 32)
|
||||
copy(padded, raw)
|
||||
return padded
|
||||
}
|
||||
|
||||
// decryptPassword decrypts a base64-encoded AES-256-GCM ciphertext.
|
||||
// Mirrors Python's crypto.decrypt_password().
|
||||
// If the value is not valid encrypted data (e.g., plaintext), returns it as-is.
|
||||
func decryptPassword(encrypted string) (string, error) {
|
||||
raw, err := base64.StdEncoding.DecodeString(encrypted)
|
||||
if err != nil {
|
||||
return encrypted, nil // not valid base64 — plaintext
|
||||
}
|
||||
|
||||
nonceSize := 12
|
||||
if len(raw) <= nonceSize {
|
||||
return encrypted, nil
|
||||
}
|
||||
|
||||
nonce := raw[:nonceSize]
|
||||
ct := raw[nonceSize:]
|
||||
key := getSecretKey()
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
aesgcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
plaintext, err := aesgcm.Open(nil, nonce, ct, nil)
|
||||
if err != nil {
|
||||
return encrypted, nil // decryption failed — return as-is
|
||||
}
|
||||
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
// encryptPassword encrypts a plaintext password using AES-256-GCM.
|
||||
// Returns base64(nonce + ciphertext), same format as Python's encrypt_password.
|
||||
func encryptPassword(plaintext string) (string, error) {
|
||||
key := getSecretKey()
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
aesgcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
nonce := make([]byte, 12)
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ct := aesgcm.Seal(nil, nonce, []byte(plaintext), nil)
|
||||
return base64.StdEncoding.EncodeToString(append(nonce, ct...)), nil
|
||||
}
|
||||
@@ -147,7 +147,18 @@ func ListNotificationTemplates(c *gin.Context) {
|
||||
func ListWorkflows(c *gin.Context) {
|
||||
var workflows []model.WorkflowDefinition
|
||||
model.DB.Order("id").Find(&workflows)
|
||||
c.JSON(http.StatusOK, workflows)
|
||||
|
||||
type WorkflowWithSteps struct {
|
||||
model.WorkflowDefinition
|
||||
Steps []model.WorkflowStep `json:"steps"`
|
||||
}
|
||||
|
||||
result := make([]WorkflowWithSteps, len(workflows))
|
||||
for i, wf := range workflows {
|
||||
result[i] = WorkflowWithSteps{WorkflowDefinition: wf, Steps: []model.WorkflowStep{}}
|
||||
model.DB.Where("workflow_id = ?", wf.ID).Order("step_number").Find(&result[i].Steps)
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// GET /api/v1/instance-groups
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"sql-platform/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GET /api/v1/instances/:id/detail/databases
|
||||
func DetailListDatabases(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var instance model.Instance
|
||||
if err := model.DB.First(&instance, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
// TODO: actual SHOW DATABASES
|
||||
c.JSON(http.StatusOK, []string{"information_schema"})
|
||||
}
|
||||
|
||||
// GET /api/v1/instances/:id/detail/tables
|
||||
func DetailListTables(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
dbName := c.Query("database")
|
||||
var instance model.Instance
|
||||
if err := model.DB.First(&instance, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
// TODO: actual SHOW TABLES from database
|
||||
_ = dbName
|
||||
c.JSON(http.StatusOK, []interface{}{})
|
||||
}
|
||||
|
||||
// GET /api/v1/instances/:id/detail/db-schema
|
||||
func DetailDbSchema(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
dbName := c.Query("database")
|
||||
var instance model.Instance
|
||||
if err := model.DB.First(&instance, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
// TODO: actual schema introspection
|
||||
_ = dbName
|
||||
c.JSON(http.StatusOK, map[string]interface{}{})
|
||||
}
|
||||
|
||||
// GET /api/v1/instances/:id/detail/table-detail
|
||||
func DetailTableDetail(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
dbName := c.Query("database")
|
||||
tableName := c.Query("table")
|
||||
var instance model.Instance
|
||||
if err := model.DB.First(&instance, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
// TODO: actual SHOW CREATE TABLE + SHOW COLUMNS + SHOW INDEX
|
||||
_, _ = dbName, tableName
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ddl": "",
|
||||
"columns": []interface{}{},
|
||||
"indexes": []interface{}{},
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/instances/:id/detail/processlist
|
||||
func DetailProcesslist(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var instance model.Instance
|
||||
if err := model.DB.First(&instance, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
// TODO: actual SHOW PROCESSLIST
|
||||
c.JSON(http.StatusOK, []interface{}{})
|
||||
}
|
||||
|
||||
// GET /api/v1/instances/:id/detail/status
|
||||
func DetailStatus(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var instance model.Instance
|
||||
if err := model.DB.First(&instance, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
// TODO: actual SHOW GLOBAL STATUS
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"key_metrics": map[string]string{},
|
||||
"all_status": []interface{}{},
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/instances/:id/detail/processlist/:pid/kill
|
||||
func DetailKillProcess(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Process killed (placeholder)"})
|
||||
}
|
||||
|
||||
// GET /api/v1/instances/:id/detail/users
|
||||
func DetailListUsers(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var instance model.Instance
|
||||
if err := model.DB.First(&instance, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
// TODO: actual SELECT from mysql.user
|
||||
c.JSON(http.StatusOK, []interface{}{})
|
||||
}
|
||||
|
||||
// POST /api/v1/query/execute
|
||||
func ExecuteQuery(c *gin.Context) {
|
||||
var input struct {
|
||||
InstanceID int `json:"instance_id"`
|
||||
Database string `json:"database"`
|
||||
SQL string `json:"sql"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
// TODO: actual SQL execution
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"columns": []interface{}{},
|
||||
"rows": []interface{}{},
|
||||
"total": 0,
|
||||
"cost_ms": 0,
|
||||
})
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"sql-platform/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
)
|
||||
|
||||
// GET /api/v1/ldap-config
|
||||
@@ -16,9 +19,24 @@ func GetLDAPConfig(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, nil)
|
||||
return
|
||||
}
|
||||
// mask password
|
||||
configs[0].AdminPassword = "******"
|
||||
c.JSON(http.StatusOK, configs[0])
|
||||
cfg := configs[0]
|
||||
// mask password in response
|
||||
cfg.AdminPassword = "******"
|
||||
|
||||
// Parse user_mapping from JSON string to object for frontend
|
||||
type ldapResponse struct {
|
||||
model.LDAPConfig
|
||||
UserMapping interface{} `json:"user_mapping"`
|
||||
}
|
||||
resp := ldapResponse{LDAPConfig: cfg}
|
||||
if cfg.UserMapping != nil && *cfg.UserMapping != "" {
|
||||
var mapping map[string]string
|
||||
json.Unmarshal([]byte(*cfg.UserMapping), &mapping)
|
||||
resp.UserMapping = mapping
|
||||
} else {
|
||||
resp.UserMapping = map[string]string{"username": "uid", "email": "mail", "display_name": "cn"}
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// PUT /api/v1/ldap-config
|
||||
@@ -32,67 +50,309 @@ func UpdateLDAPConfig(c *gin.Context) {
|
||||
var configs []model.LDAPConfig
|
||||
model.DB.Order("id").Find(&configs)
|
||||
|
||||
// Helper to get string from input
|
||||
getStr := func(key string) string {
|
||||
if v, ok := input[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
getInt := func(key string) int {
|
||||
if v, ok := input[key].(float64); ok {
|
||||
return int(v)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
getBool := func(key string) *bool {
|
||||
if v, ok := input[key].(bool); ok {
|
||||
return &v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Serialize user_mapping to JSON string
|
||||
userMappingStr := ""
|
||||
if um, ok := input["user_mapping"]; ok {
|
||||
if bytes, err := json.Marshal(um); err == nil {
|
||||
userMappingStr = string(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
if len(configs) == 0 {
|
||||
// create new
|
||||
config := model.LDAPConfig{}
|
||||
if v, ok := input["ldap_host"].(string); ok {
|
||||
config.LDAPHost = v
|
||||
// Create new config
|
||||
cfg := model.LDAPConfig{
|
||||
LDAPHost: getStr("ldap_host"),
|
||||
AdminDN: getStr("admin_dn"),
|
||||
BaseDN: getStr("base_dn"),
|
||||
SearchFilter: func() *string { s := getStr("search_filter"); if s != "" { return &s }; return nil }(),
|
||||
UserMapping: func() *string { s := userMappingStr; if s != "" { return &s }; return nil }(),
|
||||
}
|
||||
if v, ok := input["base_dn"].(string); ok {
|
||||
config.BaseDN = v
|
||||
if port := getInt("ldap_port"); port > 0 {
|
||||
cfg.LDAPPort = &port
|
||||
}
|
||||
if v, ok := input["admin_dn"].(string); ok {
|
||||
config.AdminDN = v
|
||||
if b := getBool("use_ldaps"); b != nil {
|
||||
cfg.UseLDAPS = b
|
||||
}
|
||||
if v, ok := input["admin_password"].(string); ok && v != "******" {
|
||||
config.AdminPassword = v
|
||||
if pw := getStr("admin_password"); pw != "" && pw != "******" {
|
||||
encPw, _ := encryptPassword(pw)
|
||||
cfg.AdminPassword = encPw
|
||||
}
|
||||
if v, ok := input["search_filter"].(string); ok {
|
||||
config.SearchFilter = &v
|
||||
}
|
||||
if v, ok := input["user_mapping"].(string); ok {
|
||||
config.UserMapping = &v
|
||||
}
|
||||
if v, ok := input["ldap_port"].(float64); ok {
|
||||
port := int(v)
|
||||
config.LDAPPort = &port
|
||||
}
|
||||
if v, ok := input["use_ldaps"].(bool); ok {
|
||||
config.UseLDAPS = &v
|
||||
}
|
||||
model.DB.Create(&config)
|
||||
c.JSON(http.StatusOK, config)
|
||||
model.DB.Create(&cfg)
|
||||
cfg.AdminPassword = "******"
|
||||
c.JSON(http.StatusOK, cfg)
|
||||
} else {
|
||||
// update existing
|
||||
if pw, ok := input["admin_password"].(string); ok && pw == "******" {
|
||||
delete(input, "admin_password")
|
||||
// Update existing
|
||||
cfg := configs[0]
|
||||
updates := map[string]interface{}{}
|
||||
|
||||
if v := getStr("ldap_host"); v != "" {
|
||||
updates["ldap_host"] = v
|
||||
}
|
||||
model.DB.Model(&configs[0]).Updates(input)
|
||||
model.DB.First(&configs[0], configs[0].ID)
|
||||
configs[0].AdminPassword = "******"
|
||||
c.JSON(http.StatusOK, configs[0])
|
||||
if v := getStr("base_dn"); v != "" {
|
||||
updates["base_dn"] = v
|
||||
}
|
||||
if v := getStr("admin_dn"); v != "" {
|
||||
updates["admin_dn"] = v
|
||||
}
|
||||
if v := getStr("search_filter"); v != "" {
|
||||
updates["search_filter"] = v
|
||||
}
|
||||
if userMappingStr != "" {
|
||||
updates["user_mapping"] = userMappingStr
|
||||
}
|
||||
if v := getStr("admin_password"); v != "" && v != "******" {
|
||||
encPw, _ := encryptPassword(v)
|
||||
updates["admin_password"] = encPw
|
||||
}
|
||||
if port := getInt("ldap_port"); port > 0 {
|
||||
updates["ldap_port"] = port
|
||||
}
|
||||
if b := getBool("use_ldaps"); b != nil {
|
||||
updates["use_ldaps"] = *b
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
model.DB.Model(&cfg).Updates(updates)
|
||||
}
|
||||
model.DB.First(&cfg, cfg.ID)
|
||||
cfg.AdminPassword = "******"
|
||||
c.JSON(http.StatusOK, cfg)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/v1/ldap-config/test
|
||||
func TestLDAPConnection(c *gin.Context) {
|
||||
// TODO: actual LDAP connection test
|
||||
var req struct {
|
||||
TestUser string `json:"test_user"`
|
||||
TestPassword string `json:"test_password"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var cfg model.LDAPConfig
|
||||
if err := model.DB.Order("id").First(&cfg).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "LDAP 配置不存在,请先保存配置"})
|
||||
return
|
||||
}
|
||||
if cfg.LDAPHost == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "LDAP 未配置"})
|
||||
return
|
||||
}
|
||||
|
||||
// Connect
|
||||
protocol := "ldap"
|
||||
if cfg.UseLDAPS != nil && *cfg.UseLDAPS {
|
||||
protocol = "ldaps"
|
||||
}
|
||||
port := 389
|
||||
if cfg.LDAPPort != nil && *cfg.LDAPPort > 0 {
|
||||
port = *cfg.LDAPPort
|
||||
}
|
||||
uri := fmt.Sprintf("%s://%s:%d", protocol, cfg.LDAPHost, port)
|
||||
|
||||
conn, err := ldap.DialURL(uri)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": fmt.Sprintf("连接失败: %s", err)})
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Admin bind (decrypt password)
|
||||
adminPw, _ := decryptPassword(cfg.AdminPassword)
|
||||
if err := conn.Bind(cfg.AdminDN, adminPw); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": fmt.Sprintf("管理员绑定失败: %s", err)})
|
||||
return
|
||||
}
|
||||
|
||||
// If test_user provided, search and verify
|
||||
if req.TestUser != "" && req.TestPassword != "" && req.TestPassword != "__use_stored__" {
|
||||
baseFilter := "(objectClass=inetOrgPerson)"
|
||||
if cfg.SearchFilter != nil && *cfg.SearchFilter != "" {
|
||||
baseFilter = *cfg.SearchFilter
|
||||
}
|
||||
userFilter := fmt.Sprintf("(&%s(uid=%s))", baseFilter, ldap.EscapeFilter(req.TestUser))
|
||||
|
||||
sr, err := conn.Search(ldap.NewSearchRequest(
|
||||
cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
|
||||
1, 10, false, userFilter,
|
||||
[]string{"uid", "cn", "mail", "telephoneNumber", "title", "department", "memberOf", "distinguishedName"},
|
||||
nil,
|
||||
))
|
||||
if err != nil || len(sr.Entries) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": fmt.Sprintf("未找到用户: %s", req.TestUser)})
|
||||
return
|
||||
}
|
||||
entry := sr.Entries[0]
|
||||
userDN := entry.DN
|
||||
|
||||
// Verify password
|
||||
if err := conn.Bind(userDN, req.TestPassword); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "密码验证失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// Extract user info
|
||||
userMapping := map[string]string{"username": "uid", "email": "mail", "display_name": "cn"}
|
||||
if cfg.UserMapping != nil && *cfg.UserMapping != "" {
|
||||
json.Unmarshal([]byte(*cfg.UserMapping), &userMapping)
|
||||
}
|
||||
|
||||
userInfo := map[string]string{
|
||||
"User lookup": "successful",
|
||||
"User ID": entry.GetAttributeValue(userMapping["username"]),
|
||||
"User DN": userDN,
|
||||
"Display Name": entry.GetAttributeValue(userMapping["display_name"]),
|
||||
"Email": entry.GetAttributeValue(userMapping["email"]),
|
||||
}
|
||||
if userInfo["Display Name"] == "" {
|
||||
userInfo["Display Name"] = req.TestUser
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": fmt.Sprintf("用户 %s 验证通过", req.TestUser),
|
||||
"user_info": userInfo,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Just test server connection
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "LDAP connection test (placeholder)",
|
||||
"message": fmt.Sprintf("LDAP 服务器 %s 连接成功", cfg.LDAPHost),
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/ldap-config/ou-list
|
||||
func ListLDAPOUs(c *gin.Context) {
|
||||
// TODO: actual LDAP OU list
|
||||
c.JSON(http.StatusOK, []interface{}{})
|
||||
var cfg model.LDAPConfig
|
||||
if err := model.DB.Order("id").First(&cfg).Error; err != nil {
|
||||
c.JSON(http.StatusOK, []interface{}{})
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := dialLDAP(&cfg)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, []interface{}{})
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
adminPw, _ := decryptPassword(cfg.AdminPassword)
|
||||
if err := conn.Bind(cfg.AdminDN, adminPw); err != nil {
|
||||
c.JSON(http.StatusOK, []interface{}{})
|
||||
return
|
||||
}
|
||||
|
||||
sr, err := conn.Search(ldap.NewSearchRequest(
|
||||
cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
|
||||
100, 0, false, "(objectClass=organizationalUnit)",
|
||||
[]string{"ou", "dn", "description"},
|
||||
nil,
|
||||
))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, []interface{}{})
|
||||
return
|
||||
}
|
||||
|
||||
type OuItem struct {
|
||||
DN string `json:"dn"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
var items []OuItem
|
||||
for _, e := range sr.Entries {
|
||||
items = append(items, OuItem{
|
||||
DN: e.DN,
|
||||
Name: e.GetAttributeValue("ou"),
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, items)
|
||||
}
|
||||
|
||||
// POST /api/v1/ldap-config/user-list
|
||||
func ListLDAPUsers(c *gin.Context) {
|
||||
// TODO: actual LDAP user list
|
||||
c.JSON(http.StatusOK, []interface{}{})
|
||||
var req struct {
|
||||
OuDn string `json:"ou_dn"`
|
||||
}
|
||||
c.ShouldBindJSON(&req)
|
||||
|
||||
var cfg model.LDAPConfig
|
||||
if err := model.DB.Order("id").First(&cfg).Error; err != nil {
|
||||
c.JSON(http.StatusOK, []interface{}{})
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := dialLDAP(&cfg)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, []interface{}{})
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
adminPw2, _ := decryptPassword(cfg.AdminPassword)
|
||||
if err := conn.Bind(cfg.AdminDN, adminPw2); err != nil {
|
||||
c.JSON(http.StatusOK, []interface{}{})
|
||||
return
|
||||
}
|
||||
|
||||
searchBase := cfg.BaseDN
|
||||
if req.OuDn != "" {
|
||||
searchBase = req.OuDn
|
||||
}
|
||||
|
||||
baseFilter := "(objectClass=inetOrgPerson)"
|
||||
if cfg.SearchFilter != nil && *cfg.SearchFilter != "" {
|
||||
baseFilter = *cfg.SearchFilter
|
||||
}
|
||||
|
||||
sr, err := conn.Search(ldap.NewSearchRequest(
|
||||
searchBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
|
||||
200, 0, false, baseFilter,
|
||||
[]string{"uid", "cn", "mail", "dn"},
|
||||
nil,
|
||||
))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, []interface{}{})
|
||||
return
|
||||
}
|
||||
|
||||
type LdapUser struct {
|
||||
DN string `json:"dn"`
|
||||
UID string `json:"uid"`
|
||||
CN string `json:"cn"`
|
||||
Mail string `json:"mail"`
|
||||
}
|
||||
var users []LdapUser
|
||||
for _, e := range sr.Entries {
|
||||
users = append(users, LdapUser{
|
||||
DN: e.DN,
|
||||
UID: e.GetAttributeValue("uid"),
|
||||
CN: e.GetAttributeValue("cn"),
|
||||
Mail: e.GetAttributeValue("mail"),
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, users)
|
||||
}
|
||||
|
||||
// POST /api/v1/ldap-config/create-ou
|
||||
@@ -109,3 +369,21 @@ func CreateLDAPUser(c *gin.Context) {
|
||||
func BatchImportLDAP(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Batch import (placeholder)", "count": 0})
|
||||
}
|
||||
|
||||
// POST /api/v1/ldap-config/delete-user
|
||||
func DeleteLDAPUser(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "User deleted (placeholder)"})
|
||||
}
|
||||
|
||||
// helper: dial LDAP with config
|
||||
func dialLDAP(cfg *model.LDAPConfig) (*ldap.Conn, error) {
|
||||
protocol := "ldap"
|
||||
if cfg.UseLDAPS != nil && *cfg.UseLDAPS {
|
||||
protocol = "ldaps"
|
||||
}
|
||||
port := 389
|
||||
if cfg.LDAPPort != nil && *cfg.LDAPPort > 0 {
|
||||
port = *cfg.LDAPPort
|
||||
}
|
||||
return ldap.DialURL(fmt.Sprintf("%s://%s:%d", protocol, cfg.LDAPHost, port))
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"sql-platform/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ChannelResponse — config as JSON object for frontend
|
||||
type ChannelResponse struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ChannelType string `json:"channel_type"`
|
||||
Config map[string]interface{} `json:"config"`
|
||||
IsEnabled bool `json:"is_enabled"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func toChannelResponse(ch model.NotificationChannel) ChannelResponse {
|
||||
var cfg map[string]interface{}
|
||||
if ch.Config != "" {
|
||||
_ = json.Unmarshal([]byte(ch.Config), &cfg)
|
||||
}
|
||||
if cfg == nil {
|
||||
cfg = map[string]interface{}{}
|
||||
}
|
||||
enabled := ch.IsEnabled != nil && *ch.IsEnabled
|
||||
return ChannelResponse{
|
||||
ID: ch.ID,
|
||||
Name: ch.Name,
|
||||
ChannelType: ch.ChannelType,
|
||||
Config: cfg,
|
||||
IsEnabled: enabled,
|
||||
CreatedAt: ch.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: ch.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/v1/notifications
|
||||
func ListNotifications(c *gin.Context) {
|
||||
var channels []model.NotificationChannel
|
||||
model.DB.Order("id").Find(&channels)
|
||||
result := make([]ChannelResponse, 0, len(channels))
|
||||
for _, ch := range channels {
|
||||
result = append(result, toChannelResponse(ch))
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// POST /api/v1/notifications
|
||||
func CreateNotification(c *gin.Context) {
|
||||
var input struct {
|
||||
Name string `json:"name"`
|
||||
ChannelType string `json:"channel_type"`
|
||||
Config map[string]interface{} `json:"config"`
|
||||
IsEnabled bool `json:"is_enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "参数错误"})
|
||||
return
|
||||
}
|
||||
if input.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "名称不能为空"})
|
||||
return
|
||||
}
|
||||
cfgBytes, _ := json.Marshal(input.Config)
|
||||
enabled := input.IsEnabled
|
||||
ch := model.NotificationChannel{
|
||||
Name: input.Name,
|
||||
ChannelType: input.ChannelType,
|
||||
Config: string(cfgBytes),
|
||||
IsEnabled: &enabled,
|
||||
}
|
||||
if err := model.DB.Create(&ch).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "创建失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, toChannelResponse(ch))
|
||||
}
|
||||
|
||||
// PUT /api/v1/notifications/:id
|
||||
func UpdateNotification(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var ch model.NotificationChannel
|
||||
if err := model.DB.First(&ch, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "通知渠道不存在"})
|
||||
return
|
||||
}
|
||||
var input map[string]interface{}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "参数错误"})
|
||||
return
|
||||
}
|
||||
if name, ok := input["name"].(string); ok {
|
||||
ch.Name = name
|
||||
}
|
||||
if ct, ok := input["channel_type"].(string); ok {
|
||||
ch.ChannelType = ct
|
||||
}
|
||||
if cfg, ok := input["config"].(map[string]interface{}); ok {
|
||||
cfgBytes, _ := json.Marshal(cfg)
|
||||
ch.Config = string(cfgBytes)
|
||||
}
|
||||
if enabled, ok := input["is_enabled"].(bool); ok {
|
||||
ch.IsEnabled = &enabled
|
||||
}
|
||||
model.DB.Save(&ch)
|
||||
c.JSON(http.StatusOK, toChannelResponse(ch))
|
||||
}
|
||||
|
||||
// DELETE /api/v1/notifications/:id
|
||||
func DeleteNotification(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
model.DB.Delete(&model.NotificationChannel{}, id)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "已删除"})
|
||||
}
|
||||
|
||||
// POST /api/v1/notifications/:id/test
|
||||
func TestNotificationSend(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var ch model.NotificationChannel
|
||||
if err := model.DB.First(&ch, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "通知渠道不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
var cfg map[string]interface{}
|
||||
_ = json.Unmarshal([]byte(ch.Config), &cfg)
|
||||
webhookURL, _ := cfg["webhook_url"].(string)
|
||||
if webhookURL == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "Webhook URL 未配置"})
|
||||
return
|
||||
}
|
||||
|
||||
title := "SQL审核平台 - 通知测试"
|
||||
body := "这是一条测试消息,如果收到说明通知渠道配置正确。"
|
||||
|
||||
var sendErr error
|
||||
switch ch.ChannelType {
|
||||
case "webhook_feishu":
|
||||
sendErr = sendFeishuWebhook(webhookURL, title, body)
|
||||
case "dingtalk":
|
||||
secret, _ := cfg["secret"].(string)
|
||||
sendErr = sendDingtalkWebhook(webhookURL, secret, title, body)
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "不支持的通知类型: " + ch.ChannelType})
|
||||
return
|
||||
}
|
||||
|
||||
if sendErr != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "发送失败: " + sendErr.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "测试消息已发送"})
|
||||
}
|
||||
|
||||
func sendFeishuWebhook(webhookURL, title, body string) error {
|
||||
payload := map[string]interface{}{
|
||||
"msg_type": "interactive",
|
||||
"card": map[string]interface{}{
|
||||
"elements": []map[string]interface{}{
|
||||
{"tag": "markdown", "content": body},
|
||||
},
|
||||
},
|
||||
}
|
||||
return postJSON(webhookURL, payload)
|
||||
}
|
||||
|
||||
func sendDingtalkWebhook(webhookURL, secret, title, body string) error {
|
||||
dingBody := title + "\n\n" + body
|
||||
payload := map[string]interface{}{
|
||||
"msgtype": "markdown",
|
||||
"markdown": map[string]string{
|
||||
"title": title,
|
||||
"text": dingBody,
|
||||
},
|
||||
}
|
||||
return postJSON(webhookURL, payload)
|
||||
}
|
||||
|
||||
func postJSON(url string, payload interface{}) error {
|
||||
data, _ := json.Marshal(payload)
|
||||
resp, err := http.Post(url, "application/json", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"sql-platform/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GET /api/v1/redis/:id/info
|
||||
func RedisInfo(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var instance model.Instance
|
||||
if err := model.DB.First(&instance, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
// TODO: actual Redis INFO command
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"redis_version": "placeholder",
|
||||
"used_memory": "0",
|
||||
"connected_clients": "0",
|
||||
"db_size": 0,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/redis/:id/dbsize
|
||||
func RedisDbsize(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var instance model.Instance
|
||||
if err := model.DB.First(&instance, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
// TODO: actual DBSIZE
|
||||
c.JSON(http.StatusOK, gin.H{"db_size": 0})
|
||||
}
|
||||
|
||||
// GET /api/v1/redis/:id/keys
|
||||
func RedisScanKeys(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
_ = c.Query("db")
|
||||
_ = c.Query("pattern")
|
||||
_ = c.Query("count")
|
||||
_ = c.Query("cursor")
|
||||
_ = c.Query("type")
|
||||
|
||||
var instance model.Instance
|
||||
if err := model.DB.First(&instance, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
// TODO: actual SCAN command
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"keys": []interface{}{},
|
||||
"cursor": 0,
|
||||
"total": 0,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/redis/:id/keys/:key
|
||||
func RedisGetKeyDetail(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
key := c.Param("key")
|
||||
_ = key
|
||||
|
||||
var instance model.Instance
|
||||
if err := model.DB.First(&instance, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
// TODO: actual key detail lookup
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"key": key,
|
||||
"type": "string",
|
||||
"value": "",
|
||||
"ttl": -1,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/redis/:id/slowlog
|
||||
func RedisSlowlog(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var instance model.Instance
|
||||
if err := model.DB.First(&instance, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
// TODO: actual SLOWLOG GET
|
||||
c.JSON(http.StatusOK, []interface{}{})
|
||||
}
|
||||
|
||||
// GET /api/v1/redis/:id/clients
|
||||
func RedisClients(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var instance model.Instance
|
||||
if err := model.DB.First(&instance, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
// TODO: actual CLIENT LIST
|
||||
c.JSON(http.StatusOK, []interface{}{})
|
||||
}
|
||||
|
||||
// POST /api/v1/redis/:id/command
|
||||
func RedisExecuteCommand(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var instance model.Instance
|
||||
if err := model.DB.First(&instance, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
// TODO: actual command execution
|
||||
c.JSON(http.StatusOK, gin.H{"result": "Command execution not implemented"})
|
||||
}
|
||||
|
||||
// POST /api/v1/redis/:id/keys
|
||||
func RedisCreateKey(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
_, _ = id, c.Request.Body
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Key created (placeholder)"})
|
||||
}
|
||||
|
||||
// DELETE /api/v1/redis/:id/keys/:key
|
||||
func RedisDeleteKey(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
key := c.Param("key")
|
||||
_, _ = id, key
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Key deleted (placeholder)"})
|
||||
}
|
||||
|
||||
// POST /api/v1/workflows/:id/clone
|
||||
func CloneWorkflow(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var wf model.WorkflowDefinition
|
||||
if err := model.DB.First(&wf, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Workflow not found"})
|
||||
return
|
||||
}
|
||||
// clone
|
||||
newWf := model.WorkflowDefinition{
|
||||
Name: wf.Name + "-副本",
|
||||
Description: wf.Description,
|
||||
Category: wf.Category,
|
||||
IsActive: wf.IsActive,
|
||||
FlowConfig: wf.FlowConfig,
|
||||
}
|
||||
model.DB.Create(&newWf)
|
||||
c.JSON(http.StatusCreated, newWf)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"sql-platform/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GET /api/v1/workflows/:id
|
||||
func GetWorkflow(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var wf model.WorkflowDefinition
|
||||
if err := model.DB.First(&wf, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Workflow not found"})
|
||||
return
|
||||
}
|
||||
var steps []model.WorkflowStep
|
||||
model.DB.Where("workflow_id = ?", wf.ID).Order("step_number").Find(&steps)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": wf.ID,
|
||||
"name": wf.Name,
|
||||
"description": wf.Description,
|
||||
"category": wf.Category,
|
||||
"is_default": wf.IsDefault,
|
||||
"is_active": wf.IsActive,
|
||||
"created_at": wf.CreatedAt,
|
||||
"updated_at": wf.UpdatedAt,
|
||||
"created_by": wf.CreatedBy,
|
||||
"steps": steps,
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/workflows
|
||||
func CreateWorkflow(c *gin.Context) {
|
||||
var input struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
IsDefault *bool `json:"is_default"`
|
||||
Steps []struct {
|
||||
StepNumber int `json:"step_number"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
StepType string `json:"step_type"`
|
||||
ApproverRole string `json:"approver_role"`
|
||||
ApproverIDs string `json:"approver_ids"`
|
||||
IsRequired *bool `json:"is_required"`
|
||||
TimeoutHours *int `json:"timeout_hours"`
|
||||
AutoApproveOnTimeout *bool `json:"auto_approve_on_timeout"`
|
||||
} `json:"steps"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
active := true
|
||||
if input.IsActive != nil {
|
||||
active = *input.IsActive
|
||||
}
|
||||
wf := model.WorkflowDefinition{
|
||||
Name: input.Name,
|
||||
Description: &input.Description,
|
||||
Category: &input.Category,
|
||||
IsActive: &active,
|
||||
IsDefault: input.IsDefault,
|
||||
}
|
||||
if err := model.DB.Create(&wf).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
for _, s := range input.Steps {
|
||||
step := model.WorkflowStep{
|
||||
WorkflowID: wf.ID,
|
||||
StepNumber: s.StepNumber,
|
||||
Name: s.Name,
|
||||
Description: &s.Description,
|
||||
StepType: s.StepType,
|
||||
ApproverRole: &s.ApproverRole,
|
||||
ApproverIDs: &s.ApproverIDs,
|
||||
IsRequired: s.IsRequired,
|
||||
TimeoutHours: s.TimeoutHours,
|
||||
AutoApproveOnTimeout: s.AutoApproveOnTimeout,
|
||||
}
|
||||
model.DB.Create(&step)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"id": wf.ID, "message": "Workflow created"})
|
||||
}
|
||||
|
||||
// PUT /api/v1/workflows/:id
|
||||
func UpdateWorkflow(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var wf model.WorkflowDefinition
|
||||
if err := model.DB.First(&wf, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Workflow not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var input map[string]interface{}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
model.DB.Model(&wf).Updates(input)
|
||||
|
||||
// If steps provided, replace them
|
||||
if stepsRaw, ok := input["steps"]; ok {
|
||||
model.DB.Where("workflow_id = ?", wf.ID).Delete(&model.WorkflowStep{})
|
||||
if stepsArr, ok := stepsRaw.([]interface{}); ok {
|
||||
for i, sRaw := range stepsArr {
|
||||
if sMap, ok := sRaw.(map[string]interface{}); ok {
|
||||
step := model.WorkflowStep{
|
||||
WorkflowID: wf.ID,
|
||||
StepNumber: i + 1,
|
||||
}
|
||||
if v, ok := sMap["name"].(string); ok {
|
||||
step.Name = v
|
||||
}
|
||||
if v, ok := sMap["step_type"].(string); ok {
|
||||
step.StepType = v
|
||||
}
|
||||
if v, ok := sMap["approver_role"].(string); ok {
|
||||
step.ApproverRole = &v
|
||||
}
|
||||
if v, ok := sMap["approver_ids"].(string); ok {
|
||||
step.ApproverIDs = &v
|
||||
}
|
||||
if v, ok := sMap["description"].(string); ok {
|
||||
step.Description = &v
|
||||
}
|
||||
model.DB.Create(&step)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Workflow updated"})
|
||||
}
|
||||
|
||||
// DELETE /api/v1/workflows/:id
|
||||
func DeleteWorkflow(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
model.DB.Where("workflow_id = ?", id).Delete(&model.WorkflowStep{})
|
||||
model.DB.Delete(&model.WorkflowDefinition{}, id)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Workflow deleted"})
|
||||
}
|
||||
|
||||
// POST /api/v1/workflows/:id/set-default
|
||||
func SetDefaultWorkflow(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
// unset all defaults
|
||||
model.DB.Model(&model.WorkflowDefinition{}).Where("is_default = ?", true).Update("is_default", false)
|
||||
// set this one as default
|
||||
defaultVal := true
|
||||
model.DB.Model(&model.WorkflowDefinition{}).Where("id = ?", id).Update("is_default", defaultVal)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Default workflow set"})
|
||||
}
|
||||
|
||||
// POST /api/v1/users/:id/reset-password
|
||||
func ResetPassword(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var input struct {
|
||||
NewPassword string `json:"new_password" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
_, _ = id, input.NewPassword
|
||||
// TODO: hash password and update
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Password reset (placeholder)"})
|
||||
}
|
||||
|
||||
// POST /api/v1/users/batch-import
|
||||
func BatchImportUsers(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Batch import (placeholder)", "count": 0})
|
||||
}
|
||||
|
||||
// POST /api/v1/users/batch-import-ldap
|
||||
func BatchImportLdapUsers(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "LDAP import (placeholder)", "count": 0})
|
||||
}
|
||||
|
||||
// GET /api/v1/users/import-template
|
||||
func ImportTemplate(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Template download (placeholder)"})
|
||||
}
|
||||
|
||||
// POST /api/v1/users/:userId/reset-password
|
||||
func ResetUserPassword(c *gin.Context) {
|
||||
userId := c.Param("userId")
|
||||
var input struct {
|
||||
NewPassword string `json:"new_password" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
// TODO: actually hash and save
|
||||
_ = userId
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Password reset (placeholder)"})
|
||||
}
|
||||
@@ -42,6 +42,16 @@ func Setup(r *gin.Engine) {
|
||||
auth.POST("/instances/:id/test-connection", handler.TestConnection)
|
||||
auth.POST("/instances/test-connection", handler.TestNewConnection)
|
||||
|
||||
// Instance Detail (databases/tables/schema/processlist/status/users)
|
||||
auth.GET("/instances/:id/detail/databases", handler.DetailListDatabases)
|
||||
auth.GET("/instances/:id/detail/tables", handler.DetailListTables)
|
||||
auth.GET("/instances/:id/detail/db-schema", handler.DetailDbSchema)
|
||||
auth.GET("/instances/:id/detail/table-detail", handler.DetailTableDetail)
|
||||
auth.GET("/instances/:id/detail/processlist", handler.DetailProcesslist)
|
||||
auth.GET("/instances/:id/detail/status", handler.DetailStatus)
|
||||
auth.POST("/instances/:id/detail/processlist/:pid/kill", handler.DetailKillProcess)
|
||||
auth.GET("/instances/:id/detail/users", handler.DetailListUsers)
|
||||
|
||||
// Instance management (admin/dba only)
|
||||
admin := auth.Group("")
|
||||
admin.Use(middleware.RequireRole("admin", "dba"))
|
||||
@@ -69,6 +79,17 @@ func Setup(r *gin.Engine) {
|
||||
igAdmin.DELETE("/instance-groups/:id", handler.DeleteInstanceGroup)
|
||||
}
|
||||
|
||||
// Redis
|
||||
auth.GET("/redis/:id/info", handler.RedisInfo)
|
||||
auth.GET("/redis/:id/dbsize", handler.RedisDbsize)
|
||||
auth.GET("/redis/:id/keys", handler.RedisScanKeys)
|
||||
auth.GET("/redis/:id/keys/:key", handler.RedisGetKeyDetail)
|
||||
auth.GET("/redis/:id/slowlog", handler.RedisSlowlog)
|
||||
auth.GET("/redis/:id/clients", handler.RedisClients)
|
||||
auth.POST("/redis/:id/command", handler.RedisExecuteCommand)
|
||||
auth.POST("/redis/:id/keys", handler.RedisCreateKey)
|
||||
auth.DELETE("/redis/:id/keys/:key", handler.RedisDeleteKey)
|
||||
|
||||
// Users (admin only)
|
||||
userAdmin := auth.Group("")
|
||||
userAdmin.Use(middleware.RequireRole("admin"))
|
||||
@@ -77,6 +98,10 @@ func Setup(r *gin.Engine) {
|
||||
userAdmin.POST("/users", handler.CreateUser)
|
||||
userAdmin.PUT("/users/:id", handler.UpdateUser)
|
||||
userAdmin.DELETE("/users/:id", handler.DeleteUser)
|
||||
userAdmin.POST("/users/:id/reset-password", handler.ResetUserPassword)
|
||||
userAdmin.POST("/users/batch-import", handler.BatchImportUsers)
|
||||
userAdmin.POST("/users/batch-import-ldap", handler.BatchImportLdapUsers)
|
||||
userAdmin.GET("/users/import-template", handler.ImportTemplate)
|
||||
}
|
||||
|
||||
// LDAP config (admin only)
|
||||
@@ -91,6 +116,7 @@ func Setup(r *gin.Engine) {
|
||||
ldapAdmin.POST("/ldap-config/create-ou", handler.CreateLDAPOU)
|
||||
ldapAdmin.POST("/ldap-config/create-user", handler.CreateLDAPUser)
|
||||
ldapAdmin.POST("/ldap-config/batch-import-ldap", handler.BatchImportLDAP)
|
||||
ldapAdmin.POST("/ldap-config/delete-user", handler.DeleteLDAPUser)
|
||||
}
|
||||
|
||||
// Settings
|
||||
@@ -109,13 +135,29 @@ func Setup(r *gin.Engine) {
|
||||
ghostGroup.GET("/ghost/tasks", handler.GhOstListTasks)
|
||||
}
|
||||
|
||||
// Audit Logs
|
||||
auth.GET("/audit-logs", handler.ListAuditLogs)
|
||||
|
||||
// Query execution
|
||||
auth.POST("/query/execute", handler.ExecuteQuery)
|
||||
|
||||
// Read-only resources
|
||||
auth.GET("/audit-rules", handler.ListAuditRules)
|
||||
auth.GET("/announcements", handler.ListAnnouncements)
|
||||
auth.GET("/query-histories", handler.ListQueryHistories)
|
||||
auth.GET("/notifications/channels", handler.ListNotificationChannels)
|
||||
auth.GET("/notifications/templates", handler.ListNotificationTemplates)
|
||||
// Notifications CRUD (replaces old channels/templates endpoints)
|
||||
auth.GET("/notifications", handler.ListNotifications)
|
||||
auth.POST("/notifications", handler.CreateNotification)
|
||||
auth.PUT("/notifications/:id", handler.UpdateNotification)
|
||||
auth.DELETE("/notifications/:id", handler.DeleteNotification)
|
||||
auth.POST("/notifications/:id/test", handler.TestNotificationSend)
|
||||
auth.GET("/workflows", handler.ListWorkflows)
|
||||
auth.POST("/notifications/test", handler.TestNotification)
|
||||
auth.GET("/workflows/:id", handler.GetWorkflow)
|
||||
auth.POST("/workflows", handler.CreateWorkflow)
|
||||
auth.PUT("/workflows/:id", handler.UpdateWorkflow)
|
||||
auth.DELETE("/workflows/:id", handler.DeleteWorkflow)
|
||||
auth.POST("/workflows/:id/set-default", handler.SetDefaultWorkflow)
|
||||
auth.POST("/workflows/:id/clone", handler.CloneWorkflow)
|
||||
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user