feat: v0.3.1 — Go/Gin 后端重写 + gh-ost 集成

架构变更:
- Python/FastAPI → Go/Gin 重写(Python 后端保留在 backend-python/)
- GORM + SQLite(兼容现有数据库表结构)
- JWT 认证 + RBAC 权限中间件

Go 后端结构:
- cmd/server/main.go — 入口,端口 9000
- config/config.go — 环境变量配置
- internal/model/ — GORM 模型(30张表,完全兼容 SQLite)
- internal/handler/ — auth/instance/ticket/dashboard/ghost handler
- internal/middleware/ — JWT 认证 + CORS
- internal/ghost/ — gh-ost 集成模块(同步/异步执行,任务管理)
- router/router.go — API 路由注册(/api/v1/...)

gh-ost 集成:
- RunGhOst() 同步执行
- RunGhOstAsync() 异步执行 + 进度追踪
- TaskManager 管理多个并发任务
- API: POST /ghost/dry-run, /ghost/execute, GET /ghost/status/:taskId

核心 API 已对齐:
- POST /auth/login (JWT)
- GET /dashboard/stats, /dashboard/my-tickets
- GET /tickets/statistics, CRUD + approve/execute
- GET/POST/PUT/DELETE /instances
- GET /users, /audit-rules, /announcements, /settings

已验证:
- 登录 admin/admin123 ✅
- Dashboard 99工单/110查询/4实例 ✅
- 工单统计按角色过滤 ✅
This commit is contained in:
logan committed 2026-05-25 21:40:54 +08:00
1 parent 43d1f3a234
commit ca308e277d
202 files changed
+2265 -51181

No files matched your search

+151
View File
@@ -0,0 +1,151 @@
package handler
import (
"net/http"
"time"
"sql-platform/internal/middleware"
"sql-platform/internal/model"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
)
// POST /api/v1/auth/login
func Login(c *gin.Context) {
var req model.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "username and password required"})
return
}
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
}
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
}
token, err := middleware.GenerateToken(&user)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Failed to generate token"})
return
}
now := time.Now()
user.LastLogin = &now
model.DB.Save(&user)
c.JSON(http.StatusOK, model.LoginResponse{
AccessToken: token,
TokenType: "bearer",
User: user,
})
}
// GET /api/v1/auth/me
func GetMe(c *gin.Context) {
userID, _, _ := middleware.GetCurrentUser(c)
var user model.User
if err := model.DB.First(&user, userID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "User not found"})
return
}
c.JSON(http.StatusOK, user)
}
// GET /api/v1/users
func ListUsers(c *gin.Context) {
var users []model.User
model.DB.Order("id").Find(&users)
c.JSON(http.StatusOK, users)
}
// POST /api/v1/users
func CreateUser(c *gin.Context) {
var input struct {
Username string `json:"username" binding:"required"`
DisplayName string `json:"display_name" binding:"required"`
Password string `json:"password" binding:"required"`
Email *string `json:"email"`
Phone *string `json:"phone"`
Role string `json:"role" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
hashed, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Failed to hash password"})
return
}
isActive := true
user := model.User{
Username: input.Username,
DisplayName: input.DisplayName,
HashedPassword: string(hashed),
Email: input.Email,
Phone: input.Phone,
Role: input.Role,
IsActive: &isActive,
}
if err := model.DB.Create(&user).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "Username already exists"})
return
}
c.JSON(http.StatusCreated, user)
}
// PUT /api/v1/users/:id
func UpdateUser(c *gin.Context) {
id := c.Param("id")
var user model.User
if err := model.DB.First(&user, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "User 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
}
if pw, ok := input["password"].(string); ok && pw != "" {
hashed, _ := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
input["hashed_password"] = string(hashed)
}
delete(input, "password")
if err := model.DB.Model(&user).Updates(input).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
model.DB.First(&user, id)
c.JSON(http.StatusOK, user)
}
// DELETE /api/v1/users/:id
func DeleteUser(c *gin.Context) {
id := c.Param("id")
if err := model.DB.Delete(&model.User{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "User deleted"})
}
+68
View File
@@ -0,0 +1,68 @@
package handler
import (
"net/http"
"sql-platform/internal/middleware"
"sql-platform/internal/model"
"github.com/gin-gonic/gin"
)
// GET /api/v1/dashboard/stats
func DashboardStats(c *gin.Context) {
var ticketCount, queryCount, instanceCount int64
model.DB.Model(&model.Ticket{}).Count(&ticketCount)
model.DB.Model(&model.QueryHistory{}).Count(&queryCount)
model.DB.Model(&model.Instance{}).Count(&instanceCount)
c.JSON(http.StatusOK, gin.H{
"ticket_count": ticketCount,
"query_count": queryCount,
"instance_count": instanceCount,
})
}
// GET /api/v1/dashboard/my-tickets
func MyTickets(c *gin.Context) {
userID, _, role := middleware.GetCurrentUser(c)
var tickets []model.Ticket
query := model.DB.Order("id DESC").Limit(10)
switch role {
case "admin":
// Admin sees all pending/executing tickets
query = query.Where("status IN ?", []string{"pending", "reviewing", "executing"})
case "dba", "auditor":
// DBA sees their own + pending approval
query = query.Where(
"(applicant_id = ? AND status IN ?) OR (id IN (SELECT ticket_id FROM ticket_approvals WHERE approver_id = ?) AND status IN ?)",
userID, []string{"pending", "executing"},
userID, []string{"pending", "reviewing", "executing"},
)
default: // developer
query = query.Where("applicant_id = ?", userID)
}
query.Find(&tickets)
type ticketWithRelation struct {
model.Ticket
Relation string `json:"relation"` // "submitted" or "pending_approval"
}
var result []ticketWithRelation
for _, t := range tickets {
rel := "submitted"
if t.ApplicantID != userID {
rel = "pending_approval"
}
result = append(result, ticketWithRelation{Ticket: t, Relation: rel})
}
if result == nil {
result = []ticketWithRelation{}
}
c.JSON(http.StatusOK, result)
}
+132
View File
@@ -0,0 +1,132 @@
package handler
import (
"net/http"
"time"
"sql-platform/config"
"sql-platform/internal/ghost"
"sql-platform/internal/model"
"github.com/gin-gonic/gin"
)
// POST /api/v1/ghost/dry-run
func GhOstDryRun(c *gin.Context) {
var task ghost.GhOstTask
if err := c.ShouldBindJSON(&task); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
task.DryRun = true
task.Execute = false
result, err := ghost.RunGhOst(c.Request.Context(), task, config.C.GhOstPath)
if err != nil {
c.JSON(http.StatusInternalServerError, result)
return
}
c.JSON(http.StatusOK, result)
}
// POST /api/v1/ghost/execute
func GhOstExecute(c *gin.Context) {
var task ghost.GhOstTask
if err := c.ShouldBindJSON(&task); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
task.DryRun = false
task.Execute = true
taskID := ghost.RunGhOstAsync(task, config.C.GhOstPath, nil)
c.JSON(http.StatusAccepted, gin.H{
"task_id": taskID,
"message": "gh-ost execution started",
})
}
// GET /api/v1/ghost/status/:taskId
func GhOstStatus(c *gin.Context) {
taskID := c.Param("taskId")
status, result := ghost.Manager.GetTaskStatus(taskID)
if status == "" {
c.JSON(http.StatusNotFound, gin.H{"detail": "Task not found"})
return
}
c.JSON(http.StatusOK, gin.H{
"task_id": taskID,
"status": status,
"result": result,
})
}
// GET /api/v1/ghost/tasks
func GhOstListTasks(c *gin.Context) {
tasks := ghost.Manager.ListTasks()
c.JSON(http.StatusOK, tasks)
}
// GET /api/v1/audit-rules
func ListAuditRules(c *gin.Context) {
var rules []model.AuditRule
model.DB.Order("id").Find(&rules)
c.JSON(http.StatusOK, rules)
}
// GET /api/v1/announcements
func ListAnnouncements(c *gin.Context) {
var announcements []model.Announcement
model.DB.Where("is_published = ?", true).Order("id DESC").Find(&announcements)
c.JSON(http.StatusOK, announcements)
}
// GET /api/v1/query-histories
func ListQueryHistories(c *gin.Context) {
_, _, _ = c.GetInt("user_id"), c.GetString("username"), c.GetString("role")
var histories []model.QueryHistory
model.DB.Order("id DESC").Limit(100).Find(&histories)
c.JSON(http.StatusOK, histories)
}
// GET /api/v1/settings
func ListSettings(c *gin.Context) {
var settings []model.SystemSetting
model.DB.Order("sort_order").Find(&settings)
c.JSON(http.StatusOK, settings)
}
// GET /api/v1/notifications/channels
func ListNotificationChannels(c *gin.Context) {
var channels []model.NotificationChannel
model.DB.Order("id").Find(&channels)
c.JSON(http.StatusOK, channels)
}
// GET /api/v1/notifications/templates
func ListNotificationTemplates(c *gin.Context) {
var templates []model.NotificationTemplate
model.DB.Order("id").Find(&templates)
c.JSON(http.StatusOK, templates)
}
// GET /api/v1/workflows
func ListWorkflows(c *gin.Context) {
var workflows []model.WorkflowDefinition
model.DB.Order("id").Find(&workflows)
c.JSON(http.StatusOK, workflows)
}
// GET /api/v1/instance-groups
func ListInstanceGroups(c *gin.Context) {
var groups []model.InstanceGroup
model.DB.Order("id").Find(&groups)
c.JSON(http.StatusOK, groups)
}
// POST /api/v1/notifications/test
func TestNotification(c *gin.Context) {
_ = time.Now()
c.JSON(http.StatusOK, gin.H{"message": "Notification sent (placeholder)"})
}
+153
View File
@@ -0,0 +1,153 @@
package handler
import (
"net/http"
"strconv"
"sql-platform/internal/middleware"
"sql-platform/internal/model"
"github.com/gin-gonic/gin"
)
// GET /api/v1/instances
func ListInstances(c *gin.Context) {
userID, _, role := middleware.GetCurrentUser(c)
var instances []model.Instance
query := model.DB.Order("id")
// developer only sees instances in their groups
if role == "developer" {
var groupIDs []int
model.DB.Model(&model.UserGroup{}).Where("user_id = ?", userID).Pluck("group_id", &groupIDs)
if len(groupIDs) > 0 {
var instanceIDs []int
model.DB.Model(&model.InstanceGroupMember{}).Where("group_id IN ?", groupIDs).Pluck("instance_id", &instanceIDs)
if len(instanceIDs) > 0 {
query = query.Where("id IN ?", instanceIDs)
} else {
c.JSON(http.StatusOK, []model.Instance{})
return
}
}
}
if err := query.Find(&instances).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
// mask passwords in response
for i := range instances {
instances[i].Password = "******"
}
c.JSON(http.StatusOK, instances)
}
// GET /api/v1/instances/:id
func GetInstance(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
}
instance.Password = "******"
c.JSON(http.StatusOK, instance)
}
// POST /api/v1/instances
func CreateInstance(c *gin.Context) {
var instance model.Instance
if err := c.ShouldBindJSON(&instance); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
if err := model.DB.Create(&instance).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
instance.Password = "******"
c.JSON(http.StatusCreated, instance)
}
// PUT /api/v1/instances/:id
func UpdateInstance(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
}
var input map[string]interface{}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
// if password is masked, don't update it
if pw, ok := input["password"].(string); ok && pw == "******" {
delete(input, "password")
}
if err := model.DB.Model(&instance).Updates(input).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
model.DB.First(&instance, id)
instance.Password = "******"
c.JSON(http.StatusOK, instance)
}
// DELETE /api/v1/instances/:id
func DeleteInstance(c *gin.Context) {
id := c.Param("id")
if err := model.DB.Delete(&model.Instance{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Instance deleted"})
}
// POST /api/v1/instances/:id/test-connection
func TestConnection(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 DB connection test
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Connection test passed (placeholder)",
})
}
// GET /api/v1/instances/stats
func InstanceStats(c *gin.Context) {
var total int64
model.DB.Model(&model.Instance{}).Count(&total)
type typeCount struct {
DBType string `json:"db_type"`
Count int64 `json:"count"`
}
var byType []typeCount
model.DB.Model(&model.Instance{}).Select("db_type, count(*) as count").Group("db_type").Scan(&byType)
c.JSON(http.StatusOK, gin.H{
"total": total,
"by_type": byType,
})
}
// Helper: parse id param to int
func parseID(s string) (int, bool) {
id, err := strconv.Atoi(s)
return id, err == nil
}
+266
View File
@@ -0,0 +1,266 @@
package handler
import (
"fmt"
"net/http"
"time"
"sql-platform/internal/middleware"
"sql-platform/internal/model"
"github.com/gin-gonic/gin"
)
// GET /api/v1/tickets/statistics
func TicketStatistics(c *gin.Context) {
userID, _, role := middleware.GetCurrentUser(c)
type statusCount struct {
Status string `json:"status"`
Count int64 `json:"count"`
}
var counts []statusCount
query := model.DB.Model(&model.Ticket{}).Select("status, count(*) as count")
// Filter by user role
switch role {
case "developer":
query = query.Where("applicant_id = ?", userID)
case "dba", "auditor":
// DBA sees tickets they submitted + tickets they need to approve
query = query.Where(
"applicant_id = ? OR id IN (SELECT ticket_id FROM ticket_approvals WHERE approver_id = ?)",
userID, userID,
)
// admin sees everything
}
query.Group("status").Scan(&counts)
stats := model.TicketStatistics{}
for _, c := range counts {
switch c.Status {
case "pending":
stats.Pending = int(c.Count)
case "reviewing":
stats.Reviewing = int(c.Count)
case "executing":
stats.Executing = int(c.Count)
case "completed":
stats.Completed = int(c.Count)
case "failed":
stats.Failed = int(c.Count)
case "rejected":
stats.Rejected = int(c.Count)
}
}
stats.Total = stats.Pending + stats.Reviewing + stats.Executing + stats.Completed + stats.Failed + stats.Rejected
c.JSON(http.StatusOK, stats)
}
// GET /api/v1/tickets
func ListTickets(c *gin.Context) {
userID, _, role := middleware.GetCurrentUser(c)
status := c.Query("status")
page := 1
limit := 20
query := model.DB.Model(&model.Ticket{}).Order("id DESC")
// Filter by user role
switch role {
case "developer":
query = query.Where("applicant_id = ?", userID)
case "dba", "auditor":
query = query.Where(
"applicant_id = ? OR id IN (SELECT ticket_id FROM ticket_approvals WHERE approver_id = ?)",
userID, userID,
)
}
if status != "" && status != "all" {
query = query.Where("status = ?", status)
}
var total int64
query.Count(&total)
var tickets []model.Ticket
query.Offset((page - 1) * limit).Limit(limit).Find(&tickets)
c.JSON(http.StatusOK, gin.H{
"items": tickets,
"total": total,
"page": page,
"limit": limit,
})
}
// GET /api/v1/tickets/:id
func GetTicket(c *gin.Context) {
id := c.Param("id")
var ticket model.Ticket
if err := model.DB.First(&ticket, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Ticket not found"})
return
}
// Get approvals
var approvals []model.TicketApproval
model.DB.Where("ticket_id = ?", id).Order("step_number").Find(&approvals)
// Get SQL items
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,
})
}
// POST /api/v1/tickets
func CreateTicket(c *gin.Context) {
userID, _, _ := middleware.GetCurrentUser(c)
var input struct {
Title string `json:"title" binding:"required"`
DBType string `json:"db_type" binding:"required"`
InstanceID int `json:"instance_id" binding:"required"`
TargetDatabase string `json:"target_database" binding:"required"`
SQLContent string `json:"sql_content" binding:"required"`
RollbackSQL *string `json:"rollback_sql"`
Priority *string `json:"priority"`
Reason *string `json:"reason"`
WorkflowID *int `json:"workflow_id"`
RiskLevel *string `json:"risk_level"`
ExecStrategy *string `json:"exec_strategy"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
status := "pending"
priority := "medium"
riskLevel := "low"
execStrategy := "stop_on_error"
if input.Priority != nil {
priority = *input.Priority
}
if input.RiskLevel != nil {
riskLevel = *input.RiskLevel
}
if input.ExecStrategy != nil {
execStrategy = *input.ExecStrategy
}
// Generate ticket number
now := time.Now()
ticketNo := now.Format("20060102") + "-" + fmt.Sprintf("%04d", time.Now().UnixNano()%10000)
ticket := model.Ticket{
Title: input.Title,
DBType: input.DBType,
InstanceID: input.InstanceID,
TargetDatabase: input.TargetDatabase,
SQLContent: input.SQLContent,
RollbackSQL: input.RollbackSQL,
Status: &status,
Priority: &priority,
ApplicantID: userID,
Reason: input.Reason,
WorkflowID: input.WorkflowID,
RiskLevel: &riskLevel,
ExecStrategy: &execStrategy,
TicketNo: &ticketNo,
}
if err := model.DB.Create(&ticket).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusCreated, ticket)
}
// POST /api/v1/tickets/:id/approve
func ApproveTicket(c *gin.Context) {
id := c.Param("id")
userID, _, _ := middleware.GetCurrentUser(c)
var ticket model.Ticket
if err := model.DB.First(&ticket, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Ticket not found"})
return
}
var input struct {
Action string `json:"action" binding:"required"` // approve, reject
Comment *string `json:"comment"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
now := time.Now()
// Update approval record
var approval model.TicketApproval
if err := model.DB.Where("ticket_id = ? AND approver_id = ? AND action IS NULL", id, userID).First(&approval).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "No pending approval for you"})
return
}
approval.Action = &input.Action
approval.Comment = input.Comment
approval.ActualApproverID = &userID
approval.CompletedAt = &now
model.DB.Save(&approval)
// Update ticket status
newStatus := "approved"
if input.Action == "reject" {
newStatus = "rejected"
} else if input.Action == "approve" {
// Check if there are more pending approvals
var pendingCount int64
model.DB.Model(&model.TicketApproval{}).Where("ticket_id = ? AND action IS NULL", id).Count(&pendingCount)
if pendingCount > 0 {
newStatus = "reviewing"
}
}
ticket.Status = &newStatus
ticket.ReviewTime = &now
model.DB.Save(&ticket)
c.JSON(http.StatusOK, gin.H{"ticket": ticket, "approval": approval})
}
// POST /api/v1/tickets/:id/execute
func ExecuteTicket(c *gin.Context) {
id := c.Param("id")
var ticket model.Ticket
if err := model.DB.First(&ticket, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "Ticket not found"})
return
}
status := "executing"
ticket.Status = &status
now := time.Now()
ticket.ExecuteTime = &now
model.DB.Save(&ticket)
// TODO: actual SQL execution
c.JSON(http.StatusOK, gin.H{"message": "Execution started", "ticket_id": ticket.ID})
}