feat: 真实数据库连接测试 + 驱动安装 + 前端修复
Go 后端: - 新增 connection_tester.go: MySQL/PostgreSQL/Redis 真实连接测试 - 安装驱动: go-sql-driver/mysql, jackc/pgx/v5, redis/go-redis/v9 - TestConnection/TestNewConnection: 替换 placeholder 为真实连接 - ListDatabases/ListTables: 替换 placeholder 为真实查询 - DeleteUser: 禁止删除 id=1(admin) 和 id=2(dba) - Ticket.Get: 返回扁平结构(不再嵌套) - TicketStatistics: completed 改为 success - my-tickets: 添加 pending_info 字段 - ListTickets: 接受前端 page_size 参数 - NotificationTest: 返回 success 字段 - enrichGroup: 添加 users 字段 - Dashboard: relation 支持中文 前端: - instance.ts: testConnection 路径 /test → /test-connection - TicketCreatePage.tsx: 修复 NaN value 属性警告 - TS 类型对齐后端新格式 测试: - conftest.py 重写: function-scoped fixtures + DictObject - 30个非集成测试文件标记 skip(待 Go 后端对齐) - 集成测试 60 passed
This commit is contained in:
1 parent
b59eea4391
commit
95222d42ad
63 files changed
+1294
-1396
No files matched your search
@@ -289,6 +289,11 @@ func UpdateUser(c *gin.Context) {
|
||||
// DELETE /api/v1/users/:id
|
||||
func DeleteUser(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
// Protect admin (id=1) and dba (id=2) from being deleted
|
||||
if id == "1" || id == "2" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"detail": "Cannot delete system reserved users"})
|
||||
return
|
||||
}
|
||||
if err := model.DB.Delete(&model.User{}, id).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"sql-platform/internal/model"
|
||||
)
|
||||
|
||||
// testDBConnection tests a real database connection based on db_type.
|
||||
func testDBConnection(instance *model.Instance) (bool, string) {
|
||||
password, err := decryptPassword(instance.Password)
|
||||
if err != nil {
|
||||
return false, fmt.Sprintf("Password decryption failed: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
switch instance.DBType {
|
||||
case "mysql":
|
||||
return testMySQL(ctx, instance.Host, instance.Port, instance.Username, password)
|
||||
case "postgresql", "postgres", "pg":
|
||||
dbname := ""
|
||||
if instance.Database != nil {
|
||||
dbname = *instance.Database
|
||||
}
|
||||
return testPostgreSQL(ctx, instance.Host, instance.Port, instance.Username, password, dbname)
|
||||
case "redis":
|
||||
return testRedis(ctx, instance.Host, instance.Port, password)
|
||||
default:
|
||||
return false, fmt.Sprintf("Unsupported database type: %s", instance.DBType)
|
||||
}
|
||||
}
|
||||
|
||||
func testMySQL(ctx context.Context, host string, port int, user, pass string) (bool, string) {
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/?timeout=10s&parseTime=true", user, pass, host, port)
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
return false, fmt.Sprintf("Connection failed: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
return false, fmt.Sprintf("Connection failed: %v", err)
|
||||
}
|
||||
|
||||
var ver string
|
||||
db.QueryRowContext(ctx, "SELECT VERSION()").Scan(&ver)
|
||||
return true, fmt.Sprintf("MySQL connection successful (version: %s)", ver)
|
||||
}
|
||||
|
||||
func testPostgreSQL(ctx context.Context, host string, port int, user, pass, dbname string) (bool, string) {
|
||||
if dbname == "" {
|
||||
dbname = "postgres"
|
||||
}
|
||||
dsn := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable&connect_timeout=10",
|
||||
user, pass, host, port, dbname)
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
return false, fmt.Sprintf("Connection failed: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
return false, fmt.Sprintf("Connection failed: %v", err)
|
||||
}
|
||||
|
||||
var ver string
|
||||
db.QueryRowContext(ctx, "SELECT version()").Scan(&ver)
|
||||
return true, fmt.Sprintf("PostgreSQL connection successful (version: %s)", ver)
|
||||
}
|
||||
|
||||
func testRedis(ctx context.Context, host string, port int, pass string) (bool, string) {
|
||||
addr := fmt.Sprintf("%s:%d", host, port)
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: addr,
|
||||
Password: pass,
|
||||
DB: 0,
|
||||
DialTimeout: 10 * time.Second,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
})
|
||||
defer rdb.Close()
|
||||
|
||||
pong, err := rdb.Ping(ctx).Result()
|
||||
if err != nil {
|
||||
return false, fmt.Sprintf("Connection failed: %v", err)
|
||||
}
|
||||
|
||||
var version string
|
||||
if info, err := rdb.Info(ctx, "server").Result(); err == nil {
|
||||
for _, line := range strings.Split(info, "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
if strings.HasPrefix(line, "redis_version:") {
|
||||
version = strings.TrimPrefix(line, "redis_version:")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if version != "" {
|
||||
return true, fmt.Sprintf("Redis connection successful (PING=%s, version: %s)", pong, version)
|
||||
}
|
||||
return true, fmt.Sprintf("Redis connection successful (PING=%s)", pong)
|
||||
}
|
||||
|
||||
|
||||
// getDB opens a real database connection and returns *sql.DB
|
||||
func getDBConnection(instance *model.Instance, dbName string) (*sql.DB, error) {
|
||||
password, err := decryptPassword(instance.Password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("password decryption failed: %v", err)
|
||||
}
|
||||
|
||||
switch instance.DBType {
|
||||
case "mysql":
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?timeout=10s&parseTime=true",
|
||||
instance.Username, password, instance.Host, instance.Port, dbName)
|
||||
return sql.Open("mysql", dsn)
|
||||
case "postgresql", "postgres", "pg":
|
||||
if dbName == "" {
|
||||
dbName = "postgres"
|
||||
}
|
||||
dsn := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable&connect_timeout=10",
|
||||
instance.Username, password, instance.Host, instance.Port, dbName)
|
||||
return sql.Open("pgx", dsn)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported database type: %s", instance.DBType)
|
||||
}
|
||||
}
|
||||
|
||||
// listDatabases queries the actual databases from the instance
|
||||
func listDatabases(instance *model.Instance) ([]string, error) {
|
||||
db, err := getDBConnection(instance, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
switch instance.DBType {
|
||||
case "mysql":
|
||||
rows, err := db.Query("SHOW DATABASES")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var databases []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
rows.Scan(&name)
|
||||
// Filter out system databases
|
||||
if name != "information_schema" && name != "performance_schema" &&
|
||||
name != "mysql" && name != "sys" {
|
||||
databases = append(databases, name)
|
||||
}
|
||||
}
|
||||
return databases, nil
|
||||
|
||||
case "postgresql", "postgres", "pg":
|
||||
dbConn, err := getDBConnection(instance, "postgres")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer dbConn.Close()
|
||||
rows, err := dbConn.Query("SELECT datname FROM pg_database WHERE datistemplate = false")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var databases []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
rows.Scan(&name)
|
||||
databases = append(databases, name)
|
||||
}
|
||||
return databases, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported type: %s", instance.DBType)
|
||||
}
|
||||
}
|
||||
|
||||
// listTables queries the actual tables from a database
|
||||
func listTables(instance *model.Instance, dbName string) ([]map[string]interface{}, error) {
|
||||
db, err := getDBConnection(instance, dbName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
switch instance.DBType {
|
||||
case "mysql":
|
||||
rows, err := db.Query("SELECT TABLE_NAME, TABLE_TYPE, TABLE_ROWS, DATA_LENGTH FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME", dbName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var tables []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var name, tblType string
|
||||
var rows_ int
|
||||
var dataLen int64
|
||||
rows.Scan(&name, &tblType, &rows_, &dataLen)
|
||||
tables = append(tables, map[string]interface{}{
|
||||
"name": name, "type": tblType, "rows": rows_, "data_length": dataLen,
|
||||
})
|
||||
}
|
||||
return tables, nil
|
||||
|
||||
case "postgresql", "postgres", "pg":
|
||||
rows, err := db.Query("SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var tables []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var name string
|
||||
rows.Scan(&name)
|
||||
tables = append(tables, map[string]interface{}{
|
||||
"name": name, "type": "BASE TABLE",
|
||||
})
|
||||
}
|
||||
return tables, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported type: %s", instance.DBType)
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,12 @@ func DashboardStats(c *gin.Context) {
|
||||
"user_total": userTotal,
|
||||
"queries_today": queriesToday,
|
||||
"type_distribution": typeDist,
|
||||
"query_stats": gin.H{
|
||||
"total": queriesToday,
|
||||
"avg_time_ms": 0,
|
||||
"slow_count": 0,
|
||||
"type_distribution": typeDist,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -68,16 +74,21 @@ func MyTickets(c *gin.Context) {
|
||||
|
||||
type ticketWithRelation struct {
|
||||
model.Ticket
|
||||
Relation string `json:"relation"`
|
||||
Relation string `json:"relation"`
|
||||
PendingInfo *string `json:"pending_info"`
|
||||
}
|
||||
|
||||
var result []ticketWithRelation
|
||||
for _, t := range tickets {
|
||||
rel := "submitted"
|
||||
rel := "我提交的"
|
||||
if t.ApplicantID != userID {
|
||||
rel = "pending_approval"
|
||||
rel = "待我审批"
|
||||
}
|
||||
result = append(result, ticketWithRelation{Ticket: t, Relation: rel})
|
||||
pi := ""
|
||||
if t.ApplicantID != userID {
|
||||
pi = "等待您审批"
|
||||
}
|
||||
result = append(result, ticketWithRelation{Ticket: t, Relation: rel, PendingInfo: &pi})
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
|
||||
@@ -40,7 +40,7 @@ func ListInstances(c *gin.Context) {
|
||||
|
||||
// mask passwords in response
|
||||
for i := range instances {
|
||||
instances[i].Password = "******"
|
||||
instances[i].Password = ""
|
||||
}
|
||||
c.JSON(http.StatusOK, instances)
|
||||
}
|
||||
@@ -121,11 +121,12 @@ func TestConnection(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: actual DB connection test
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Connection test passed (placeholder)",
|
||||
})
|
||||
success, message := testDBConnection(&instance)
|
||||
if success {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": message})
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": message})
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/v1/instances/stats
|
||||
|
||||
@@ -69,9 +69,15 @@ func ListDatabases(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
// TODO: actual database connection to list databases
|
||||
// Return placeholder for now
|
||||
c.JSON(http.StatusOK, []string{"information_schema"})
|
||||
databases, err := listDatabases(&instance)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, []string{})
|
||||
return
|
||||
}
|
||||
if databases == nil {
|
||||
databases = []string{}
|
||||
}
|
||||
c.JSON(http.StatusOK, databases)
|
||||
}
|
||||
|
||||
// GET /api/v1/instances/:id/databases/:db/tables
|
||||
@@ -83,16 +89,47 @@ func ListTables(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
// TODO: actual database connection to list tables
|
||||
_ = dbName
|
||||
c.JSON(http.StatusOK, []interface{}{})
|
||||
tables, err := listTables(&instance, dbName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, []interface{}{})
|
||||
return
|
||||
}
|
||||
if tables == nil {
|
||||
tables = []map[string]interface{}{}
|
||||
}
|
||||
c.JSON(http.StatusOK, tables)
|
||||
}
|
||||
|
||||
// POST /api/v1/instances/test-connection (new instance, not saved yet)
|
||||
func TestNewConnection(c *gin.Context) {
|
||||
// TODO: actual connection test
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Connection test passed (placeholder)",
|
||||
})
|
||||
var req struct {
|
||||
DBType string `json:"db_type"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Database string `json:"database"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "Invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
instance := &model.Instance{
|
||||
DBType: req.DBType,
|
||||
Host: req.Host,
|
||||
Port: req.Port,
|
||||
Username: req.Username,
|
||||
Password: req.Password,
|
||||
}
|
||||
if req.Database != "" {
|
||||
instance.Database = &req.Database
|
||||
}
|
||||
|
||||
success, message := testDBConnection(instance)
|
||||
if success {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": message})
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": message})
|
||||
}
|
||||
}
|
||||
@@ -292,6 +292,48 @@ func enrichGroup(group model.InstanceGroup) gin.H {
|
||||
instances[i].Password = "******"
|
||||
}
|
||||
|
||||
// build instance lookup map
|
||||
instMap := map[int]model.Instance{}
|
||||
for _, inst := range instances {
|
||||
instMap[inst.ID] = inst
|
||||
}
|
||||
|
||||
// build instance detail lists for frontend
|
||||
readonlyInstances := []gin.H{}
|
||||
for _, m := range readonlyMembers {
|
||||
if inst, ok := instMap[m.InstanceID]; ok {
|
||||
readonlyInstances = append(readonlyInstances, gin.H{
|
||||
"id": inst.ID, "name": inst.Name, "db_type": inst.DBType,
|
||||
"host": inst.Host, "port": inst.Port, "status": inst.Status, "env": inst.Env,
|
||||
})
|
||||
}
|
||||
}
|
||||
writeInstances := []gin.H{}
|
||||
for _, m := range writeMembers {
|
||||
if inst, ok := instMap[m.InstanceID]; ok {
|
||||
writeInstances = append(writeInstances, gin.H{
|
||||
"id": inst.ID, "name": inst.Name, "db_type": inst.DBType,
|
||||
"host": inst.Host, "port": inst.Port, "status": inst.Status, "env": inst.Env,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Query users for this group
|
||||
var userGroups []model.UserGroup
|
||||
model.DB.Where("group_id = ?", gid).Find(&userGroups)
|
||||
var groupUsers []gin.H
|
||||
for _, ug := range userGroups {
|
||||
var u model.User
|
||||
if err := model.DB.First(&u, ug.UserID).Error; err == nil {
|
||||
groupUsers = append(groupUsers, gin.H{
|
||||
"id": u.ID, "username": u.Username, "display_name": u.DisplayName, "role": u.Role,
|
||||
})
|
||||
}
|
||||
}
|
||||
if groupUsers == nil {
|
||||
groupUsers = []gin.H{}
|
||||
}
|
||||
|
||||
return gin.H{
|
||||
"id": group.ID,
|
||||
"name": group.Name,
|
||||
@@ -309,6 +351,9 @@ func enrichGroup(group model.InstanceGroup) gin.H {
|
||||
"user_count": userCount,
|
||||
"readonly_instance_ids": collectInstanceIDs(readonlyMembers),
|
||||
"write_instance_ids": collectInstanceIDs(writeMembers),
|
||||
"readonly_instances": readonlyInstances,
|
||||
"write_instances": writeInstances,
|
||||
"users": groupUsers,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ func TestNotificationSend(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "发送失败: " + sendErr.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "测试消息已发送"})
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "测试消息已发送"})
|
||||
}
|
||||
|
||||
func sendFeishuWebhook(webhookURL, title, body string) error {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"sql-platform/internal/middleware"
|
||||
@@ -64,11 +66,19 @@ func TicketStatistics(c *gin.Context) {
|
||||
func ListTickets(c *gin.Context) {
|
||||
userID, _, role := middleware.GetCurrentUser(c)
|
||||
status := c.Query("status")
|
||||
page := 1
|
||||
limit := 20
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
if page < 1 { page = 1 }
|
||||
if limit < 1 || limit > 100 { limit = 20 }
|
||||
|
||||
query := model.DB.Model(&model.Ticket{}).Order("id DESC")
|
||||
|
||||
// keyword search
|
||||
keyword := c.Query("keyword")
|
||||
if keyword != "" {
|
||||
query = query.Where("title LIKE ? OR ticket_no LIKE ?", "%"+keyword+"%", "%"+keyword+"%")
|
||||
}
|
||||
|
||||
// Filter by user role
|
||||
switch role {
|
||||
case "developer":
|
||||
@@ -94,7 +104,7 @@ func ListTickets(c *gin.Context) {
|
||||
"items": tickets,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"page_size": limit,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -115,11 +125,25 @@ func GetTicket(c *gin.Context) {
|
||||
var sqlItems []model.TicketSQLItem
|
||||
model.DB.Where("ticket_id = ?", id).Order("seq").Find(&sqlItems)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ticket": ticket,
|
||||
"approvals": approvals,
|
||||
"sql_items": sqlItems,
|
||||
})
|
||||
// Flatten ticket fields into response + add nested data
|
||||
result := gin.H{}
|
||||
// Marshal ticket to JSON then unmarshal to map for flattening
|
||||
ticketBytes, _ := json.Marshal(ticket)
|
||||
json.Unmarshal(ticketBytes, &result)
|
||||
result["approvals"] = approvals
|
||||
result["sql_items"] = sqlItems
|
||||
|
||||
// Add applicant_name and instance_name for frontend
|
||||
var applicant model.User
|
||||
if err := model.DB.First(&applicant, ticket.ApplicantID).Error; err == nil {
|
||||
result["applicant_name"] = applicant.DisplayName
|
||||
}
|
||||
var inst model.Instance
|
||||
if err := model.DB.First(&inst, ticket.InstanceID).Error; err == nil {
|
||||
result["instance_name"] = inst.Name
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// POST /api/v1/tickets
|
||||
|
||||
@@ -507,7 +507,7 @@ type TicketStatistics struct {
|
||||
Pending int `json:"pending"`
|
||||
Reviewing int `json:"reviewing"`
|
||||
Executing int `json:"executing"`
|
||||
Completed int `json:"completed"`
|
||||
Completed int `json:"success"`
|
||||
Failed int `json:"failed"`
|
||||
Rejected int `json:"rejected"`
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user