- new resp package (OK/Page/Fail/BadRequest/.../InternalErr): unified error
envelope {detail, code?}, sanitized 500s (full error logged server-side,
generic message to client - stops DB error leakage), pagination envelope
{items,total,page,page_size}; migrate 200+ inline c.JSON error sites
- refresh token: 7d rotation tokens (SHA-256 hashed at rest), public
POST /auth/refresh with single-use rotation, POST /auth/logout revokes
all user refresh tokens (endpoint previously missing - frontend 404)
- security: gh-ost command line password masked in task status payload;
Instance.MarshalJSON guarantees encrypted passwords never leave the API
(removes reliance on per-handler manual masking); production (DEBUG=false)
with default JWT secret now auto-generates and persists a random secret
- safe.Go recover wrapper for background goroutines (instance probe,
gh-ost async)
- delete dead service layer (zero callers)
- fix pre-existing go vet unkeyed bson.E literals
230 lines
5.8 KiB
Go
230 lines
5.8 KiB
Go
package handler
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"sql-platform/internal/handler/resp"
|
|
"sql-platform/internal/middleware"
|
|
"sql-platform/internal/model"
|
|
"sql-platform/internal/safe"
|
|
|
|
"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 {
|
|
resp.InternalErr(c, err)
|
|
return
|
|
}
|
|
|
|
// 密码出站遮蔽由 Instance.MarshalJSON 统一收口
|
|
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 {
|
|
resp.NotFound(c, "Instance not found")
|
|
return
|
|
}
|
|
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 {
|
|
resp.BadRequest(c, err.Error())
|
|
return
|
|
}
|
|
// Encrypt password before storing
|
|
if instance.Password != "" {
|
|
instance.Password = strings.TrimSpace(instance.Password)
|
|
encPw, err := encryptPassword(instance.Password)
|
|
if err == nil {
|
|
instance.Password = "enc:" + encPw
|
|
}
|
|
}
|
|
if err := model.DB.Create(&instance).Error; err != nil {
|
|
resp.InternalErr(c, err)
|
|
return
|
|
}
|
|
// Auto-detect version, cluster_mode and status after creation
|
|
probe := func() {
|
|
result := testDBConnectionFull(&instance)
|
|
updates := map[string]interface{}{}
|
|
if result.Success {
|
|
updates["status"] = "connected"
|
|
if result.Version != "" {
|
|
updates["version"] = result.Version
|
|
}
|
|
if instance.DBType == "redis" {
|
|
updates["cluster_mode"] = result.ClusterMode
|
|
}
|
|
} else {
|
|
updates["status"] = "error"
|
|
}
|
|
if len(updates) > 0 {
|
|
model.DB.Model(&model.Instance{}).Where("id = ?", instance.ID).Updates(updates)
|
|
}
|
|
}
|
|
safe.Go("instance-probe:"+strconv.Itoa(instance.ID), probe)
|
|
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 {
|
|
resp.NotFound(c, "Instance not found")
|
|
return
|
|
}
|
|
|
|
var input map[string]interface{}
|
|
if err := c.ShouldBindJSON(&input); err != nil {
|
|
resp.BadRequest(c, err.Error())
|
|
return
|
|
}
|
|
|
|
// if password is empty or masked placeholder, don't update it
|
|
if pw, ok := input["password"].(string); ok {
|
|
pw = strings.TrimSpace(pw)
|
|
if pw == "" || pw == "******" {
|
|
delete(input, "password")
|
|
} else {
|
|
// Encrypt new password
|
|
encPw, err := encryptPassword(pw)
|
|
if err == nil {
|
|
input["password"] = "enc:" + encPw
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := model.DB.Model(&instance).Updates(input).Error; err != nil {
|
|
resp.InternalErr(c, err)
|
|
return
|
|
}
|
|
|
|
model.DB.First(&instance, id)
|
|
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 {
|
|
resp.InternalErr(c, err)
|
|
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 {
|
|
resp.NotFound(c, "Instance not found")
|
|
return
|
|
}
|
|
|
|
result := testDBConnectionFull(&instance)
|
|
// Keep the persisted list status in sync with an explicit connection test.
|
|
updates := map[string]interface{}{}
|
|
if result.Success {
|
|
updates["status"] = "connected"
|
|
} else {
|
|
updates["status"] = "error"
|
|
}
|
|
if result.Success && result.Version != "" {
|
|
updates["version"] = result.Version
|
|
}
|
|
if instance.DBType == "redis" {
|
|
updates["cluster_mode"] = result.ClusterMode
|
|
}
|
|
model.DB.Model(&model.Instance{}).Where("id = ?", instance.ID).Updates(updates)
|
|
if result.Success {
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "message": result.Message, "version": result.Version, "cluster_mode": result.ClusterMode})
|
|
} else {
|
|
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": result.Message})
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// GET /api/v1/instances/:id/metrics — live metrics for an instance
|
|
func InstanceMetrics(c *gin.Context) {
|
|
id := c.Param("id")
|
|
var instance model.Instance
|
|
if err := model.DB.First(&instance, id).Error; err != nil {
|
|
resp.NotFound(c, "Instance not found")
|
|
return
|
|
}
|
|
|
|
switch instance.DBType {
|
|
case "redis":
|
|
metrics := getRedisMetrics(&instance)
|
|
c.JSON(http.StatusOK, metrics)
|
|
case "mysql":
|
|
metrics := getMySQLMetrics(&instance)
|
|
c.JSON(http.StatusOK, metrics)
|
|
case "postgresql", "postgres", "pg":
|
|
metrics := getPostgresMetrics(&instance)
|
|
c.JSON(http.StatusOK, metrics)
|
|
default:
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"status": "online",
|
|
"version": instance.Version,
|
|
})
|
|
}
|
|
}
|