Files
Logan.Li 16f1f7d65f feat(backend): unified response helpers, refresh token, security hardening
- 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
2026-10-26 17:21:00 +08:00

412 lines
11 KiB
Go

package handler
import (
"net/http"
"strconv"
"sql-platform/internal/handler/resp"
"sql-platform/internal/model"
"github.com/gin-gonic/gin"
)
// GET /api/v1/instance-groups
// (already in ghost.go as stub, replace with real implementation)
// GET /api/v1/instance-groups/:id
func GetInstanceGroup(c *gin.Context) {
id := c.Param("id")
var group model.InstanceGroup
if err := model.DB.First(&group, id).Error; err != nil {
resp.NotFound(c, "Instance group not found")
return
}
c.JSON(http.StatusOK, enrichGroup(group))
}
// POST /api/v1/instance-groups
func CreateInstanceGroup(c *gin.Context) {
var input struct {
Name string `json:"name"`
Description *string `json:"description"`
DBType string `json:"db_type"`
IsActive *bool `json:"is_active"`
ReadonlyInstanceIDs []int `json:"readonly_instance_ids"`
WriteInstanceIDs []int `json:"write_instance_ids"`
}
if err := c.ShouldBindJSON(&input); err != nil {
resp.BadRequest(c, err.Error())
return
}
desc := ""
if input.Description != nil {
desc = *input.Description
}
isActive := true
if input.IsActive != nil {
isActive = *input.IsActive
}
dbType := input.DBType
if dbType == "" {
dbType = "all"
}
group := model.InstanceGroup{
Name: input.Name,
Description: &desc,
DBType: dbType,
IsActive: &isActive,
}
if err := model.DB.Create(&group).Error; err != nil {
resp.InternalErr(c, err)
return
}
// Create instance members
for _, iid := range input.ReadonlyInstanceIDs {
model.DB.Create(&model.InstanceGroupMember{
GroupID: group.ID, InstanceID: iid, AccessType: "readonly",
})
}
for _, iid := range input.WriteInstanceIDs {
model.DB.Create(&model.InstanceGroupMember{
GroupID: group.ID, InstanceID: iid, AccessType: "write",
})
}
c.JSON(http.StatusCreated, enrichGroup(group))
}
// PUT /api/v1/instance-groups/:id
func UpdateInstanceGroup(c *gin.Context) {
id := c.Param("id")
gid, _ := strconv.Atoi(id)
var group model.InstanceGroup
if err := model.DB.First(&group, id).Error; err != nil {
resp.NotFound(c, "Instance group not found")
return
}
var input struct {
Name *string `json:"name"`
Description *string `json:"description"`
DBType *string `json:"db_type"`
Permissions *string `json:"permissions"`
IsActive *bool `json:"is_active"`
WorkflowID *int `json:"workflow_id"`
AccessType *string `json:"access_type"`
AllowedDatabases *string `json:"allowed_databases"`
ReadonlyInstanceIDs *[]int `json:"readonly_instance_ids"`
WriteInstanceIDs *[]int `json:"write_instance_ids"`
}
if err := c.ShouldBindJSON(&input); err != nil {
resp.BadRequest(c, err.Error())
return
}
updates := map[string]interface{}{}
if input.Name != nil {
updates["name"] = *input.Name
}
if input.Description != nil {
updates["description"] = *input.Description
}
if input.DBType != nil {
updates["db_type"] = *input.DBType
}
if input.Permissions != nil {
updates["permissions"] = *input.Permissions
}
if input.IsActive != nil {
updates["is_active"] = *input.IsActive
}
if input.WorkflowID != nil {
updates["workflow_id"] = *input.WorkflowID
}
if input.AccessType != nil {
updates["access_type"] = *input.AccessType
}
if input.AllowedDatabases != nil {
updates["allowed_databases"] = *input.AllowedDatabases
}
if len(updates) > 0 {
if err := model.DB.Model(&group).Updates(updates).Error; err != nil {
resp.InternalErr(c, err)
return
}
}
// Update instance members if provided
if input.ReadonlyInstanceIDs != nil || input.WriteInstanceIDs != nil {
// Clear all existing members for this group
model.DB.Where("group_id = ?", gid).Delete(&model.InstanceGroupMember{})
// Add readonly instances
if input.ReadonlyInstanceIDs != nil {
for _, iid := range *input.ReadonlyInstanceIDs {
member := model.InstanceGroupMember{
GroupID: gid,
InstanceID: iid,
AccessType: "readonly",
}
model.DB.Create(&member)
}
}
// Add write instances
if input.WriteInstanceIDs != nil {
for _, iid := range *input.WriteInstanceIDs {
member := model.InstanceGroupMember{
GroupID: gid,
InstanceID: iid,
AccessType: "write",
}
model.DB.Create(&member)
}
}
}
model.DB.First(&group, id)
c.JSON(http.StatusOK, enrichGroup(group))
}
// DELETE /api/v1/instance-groups/:id
func DeleteInstanceGroup(c *gin.Context) {
id := c.Param("id")
if err := model.DB.Delete(&model.InstanceGroup{}, id).Error; err != nil {
resp.InternalErr(c, err)
return
}
// also clean up members
model.DB.Where("group_id = ?", id).Delete(&model.InstanceGroupMember{})
c.JSON(http.StatusOK, gin.H{"message": "Instance group deleted"})
}
// POST /api/v1/instance-groups/:id/instances
func AssignGroupInstances(c *gin.Context) {
groupID, _ := strconv.Atoi(c.Param("id"))
var input struct {
InstanceIDs []int `json:"instance_ids"`
AccessType string `json:"access_type"`
}
if err := c.ShouldBindJSON(&input); err != nil {
resp.BadRequest(c, err.Error())
return
}
if input.AccessType == "" {
input.AccessType = "readonly"
}
for _, iid := range input.InstanceIDs {
member := model.InstanceGroupMember{
GroupID: groupID,
InstanceID: iid,
AccessType: input.AccessType,
}
model.DB.Where("group_id = ? AND instance_id = ? AND access_type = ?",
groupID, iid, input.AccessType).FirstOrCreate(&member)
}
c.JSON(http.StatusOK, gin.H{"message": "Instances assigned"})
}
// DELETE /api/v1/instance-groups/:id/instances/:instanceId
func RemoveGroupInstance(c *gin.Context) {
groupID := c.Param("id")
instanceID := c.Param("instanceId")
accessType := c.Query("access_type")
query := model.DB.Where("group_id = ? AND instance_id = ?", groupID, instanceID)
if accessType != "" {
query = query.Where("access_type = ?", accessType)
}
query.Delete(&model.InstanceGroupMember{})
c.JSON(http.StatusOK, gin.H{"message": "Instance removed from group"})
}
// GET /api/v1/instance-groups/:id/users
func ListGroupUsers(c *gin.Context) {
groupID := c.Param("id")
var userGroups []model.UserGroup
model.DB.Where("group_id = ?", groupID).Find(&userGroups)
c.JSON(http.StatusOK, userGroups)
}
// POST /api/v1/instance-groups/:id/users
func AssignGroupUsers(c *gin.Context) {
groupID, _ := strconv.Atoi(c.Param("id"))
var input struct {
UserIDs []int `json:"user_ids"`
Permissions []string `json:"permissions"`
}
if err := c.ShouldBindJSON(&input); err != nil {
resp.BadRequest(c, err.Error())
return
}
// delete existing
model.DB.Where("group_id = ?", groupID).Delete(&model.UserGroup{})
perms := ""
if len(input.Permissions) > 0 {
// store as JSON array string
import_json_str := "["
for i, p := range input.Permissions {
if i > 0 {
import_json_str += ","
}
import_json_str += `"` + p + `"`
}
import_json_str += "]"
perms = import_json_str
}
for _, uid := range input.UserIDs {
ug := model.UserGroup{UserID: uid, GroupID: groupID}
if perms != "" {
ug.Permissions = &perms
}
model.DB.Create(&ug)
}
c.JSON(http.StatusOK, gin.H{"message": "Users assigned"})
}
// PUT /api/v1/instance-groups/users/:userId/groups
func UpdateUserGroups(c *gin.Context) {
userID := c.Param("userId")
var input struct {
GroupIDs []int `json:"group_ids"`
}
if err := c.ShouldBindJSON(&input); err != nil {
resp.BadRequest(c, err.Error())
return
}
uid, _ := strconv.Atoi(userID)
model.DB.Where("user_id = ?", uid).Delete(&model.UserGroup{})
for _, gid := range input.GroupIDs {
model.DB.Create(&model.UserGroup{UserID: uid, GroupID: gid})
}
c.JSON(http.StatusOK, gin.H{"message": "User groups updated"})
}
// enrichGroup adds computed counts to InstanceGroup response
func enrichGroup(group model.InstanceGroup) gin.H {
gid := group.ID
var readonlyCount, writeCount, userCount int64
var readonlyMembers, writeMembers []model.InstanceGroupMember
model.DB.Where("group_id = ? AND access_type IN ?", gid, []string{"readonly", "readwrite"}).Find(&readonlyMembers)
readonlyCount = int64(len(readonlyMembers))
model.DB.Where("group_id = ? AND access_type IN ?", gid, []string{"write", "readwrite"}).Find(&writeMembers)
writeCount = int64(len(writeMembers))
model.DB.Model(&model.UserGroup{}).Where("group_id = ?", gid).Count(&userCount)
// collect instance details
allInstanceIDs := []int{}
for _, m := range readonlyMembers {
allInstanceIDs = append(allInstanceIDs, m.InstanceID)
}
for _, m := range writeMembers {
allInstanceIDs = append(allInstanceIDs, m.InstanceID)
}
// unique
seen := map[int]bool{}
uniqueIDs := []int{}
for _, id := range allInstanceIDs {
if !seen[id] {
seen[id] = true
uniqueIDs = append(uniqueIDs, id)
}
}
var instances []model.Instance
if len(uniqueIDs) > 0 {
model.DB.Where("id IN ?", uniqueIDs).Find(&instances)
}
for i := range instances {
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,
"description": group.Description,
"db_type": group.DBType,
"permissions": group.Permissions,
"is_active": group.IsActive,
"created_at": group.CreatedAt,
"updated_at": group.UpdatedAt,
"workflow_id": group.WorkflowID,
"access_type": group.AccessType,
"allowed_databases": group.AllowedDatabases,
"readonly_instance_count": readonlyCount,
"write_instance_count": writeCount,
"user_count": userCount,
"readonly_instance_ids": collectInstanceIDs(readonlyMembers),
"write_instance_ids": collectInstanceIDs(writeMembers),
"readonly_instances": readonlyInstances,
"write_instances": writeInstances,
"users": groupUsers,
}
}
func collectInstanceIDs(members []model.InstanceGroupMember) []int {
ids := []int{}
seen := map[int]bool{}
for _, m := range members {
if !seen[m.InstanceID] {
seen[m.InstanceID] = true
ids = append(ids, m.InstanceID)
}
}
return ids
}