refactor(backend): simplify asset partial update implementation

This commit is contained in:
pycook
2025-10-29 22:42:47 +08:00
parent f9d44ee215
commit 07e2d58c34
6 changed files with 94 additions and 22 deletions

View File

@@ -19,10 +19,7 @@ import (
)
const (
kFmtAssetIds = "assetIds-%d"
kAuthorizationIds = "authorizationIds"
kNodeIds = "nodeIds"
kAccountIds = "accountIds"
)
var (
@@ -126,7 +123,7 @@ func (c *Controller) GetAssets(ctx *gin.Context) {
if info {
db = db.Select("id", "parent_id", "name", "ip", "protocols",
"connectable", "authorization", "resource_id", "access_time_control",
"asset_command_control", "web_config", "gateway_id")
"asset_command_control", "web_config", "gateway_id", "ci_id", "ci_type_id")
}
doGet(ctx, false, db, config.RESOURCE_ASSET, assetPostHooks...)

View File

@@ -1,8 +1,11 @@
package controller
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"reflect"
"time"
@@ -245,10 +248,26 @@ func doUpdate[T model.Model](ctx *gin.Context, needAcl bool, md T, resourceType
return
}
// Read request body for partial update field detection
bodyBytes, err := io.ReadAll(ctx.Request.Body)
if err != nil {
ctx.AbortWithError(http.StatusBadRequest, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": err}})
return
}
// Restore body for binding
ctx.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
// Parse body to extract field keys for partial update
var bodyFields map[string]any
if len(bodyBytes) > 0 {
json.Unmarshal(bodyBytes, &bodyFields)
}
if err = ctx.ShouldBindBodyWithJSON(md); err != nil {
ctx.AbortWithError(http.StatusBadRequest, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": err}})
return
}
md.SetUpdaterId(currentUser.Uid)
for _, hook := range preHooks {
@@ -289,27 +308,55 @@ func doUpdate[T model.Model](ctx *gin.Context, needAcl bool, md T, resourceType
if err = baseService.ExecuteInTransaction(ctx, func(tx *gorm.DB) (err error) {
omits := []string{"resource_id", "created_at", "deleted_at"}
selects := []string{"*"}
switch t := any(md).(type) {
case *model.Asset:
if err = service.DefaultAuthService.HandleAuthorization(ctx, tx, model.ACTION_UPDATE, t, nil); err != nil {
handleRemoteErr(ctx, err)
return
var selects []string
// Build dynamic selects based on fields present in request body
if len(bodyFields) > 0 {
// Define allowed fields per model type
var allowedFields []string
switch t := any(md).(type) {
case *model.Asset:
if err = service.DefaultAuthService.HandleAuthorization(ctx, tx, model.ACTION_UPDATE, t, nil); err != nil {
handleRemoteErr(ctx, err)
return
}
// Define allowed fields based on auth type
if cast.ToBool(ctx.Value("isAuthWithKey")) {
// API Key auth: Only sync-related fields
allowedFields = []string{"ip", "protocols", "authorization", "parent_id", "comment", "name", "ci_id", "ci_type_id"}
} else {
// Normal auth: All asset fields
allowedFields = []string{"name", "ip", "gateway_id", "protocols", "authorization", "parent_id",
"comment", "connectable", "access_time_control", "asset_command_control", "web_config", "ci_id", "ci_type_id"}
}
case *model.Node:
if err = handleNodeAuthorization(ctx, tx, model.ACTION_UPDATE, t); err != nil {
handleRemoteErr(ctx, err)
return
}
case *model.Account:
// For accounts, allow selective field updates for sensitive data
allowedFields = []string{"name", "account", "account_type", "password", "pk", "phrase"}
}
if cast.ToBool(ctx.Value("isAuthWithKey")) {
selects = []string{"ip", "protocols", "authorization"}
}
case *model.Node:
if err = handleNodeAuthorization(ctx, tx, model.ACTION_UPDATE, t); err != nil {
handleRemoteErr(ctx, err)
return
}
case *model.Account:
if cast.ToBool(ctx.Value("isAuthWithKey")) {
selects = []string{"account", "password", "phrase", "pk", "account_type"}
// Build selects list based on which fields are present in body
if len(allowedFields) > 0 {
for _, field := range allowedFields {
if _, present := bodyFields[field]; present {
selects = append(selects, field)
}
}
// Always update these fields
selects = append(selects, "updated_at", "updater_id")
}
}
// If no fields specified or no allowed fields matched, update all fields
if len(selects) == 0 {
selects = []string{"*"}
}
if err = tx.Select(selects).Omit(omits...).Save(md).Error; err != nil {
return
}

View File

@@ -126,6 +126,8 @@ type Asset struct {
ParentId int `json:"parent_id" gorm:"column:parent_id"`
Ip string `json:"ip" gorm:"column:ip"`
Protocols Slice[string] `json:"protocols" gorm:"column:protocols;type:text"`
CIId int `json:"ci_id" gorm:"column:ci_id;index"`
CITypeId int `json:"ci_type_id" gorm:"column:ci_type_id"`
GatewayId int `json:"gateway_id" gorm:"column:gateway_id"`
Authorization AuthorizationMap `json:"authorization" gorm:"column:authorization;type:text"`
AccessAuth AccessAuth `json:"access_auth" gorm:"embedded;column:access_auth"` // Deprecated: Use V2 fields below

View File

@@ -63,6 +63,12 @@ func (r *assetRepository) BuildQuery(ctx *gin.Context) (*gorm.DB, error) {
func(s string, _ int) int { return cast.ToInt(s) }))
}
// Filter by CMDB CI ID
db = dbpkg.FilterEqual(ctx, db, "ci_id")
// Filter by CMDB CI Type ID
db = dbpkg.FilterEqual(ctx, db, "ci_type_id")
// Sort by name
db = db.Order("name")

View File

@@ -10,6 +10,7 @@ import (
"github.com/veops/oneterm/internal/model"
"github.com/veops/oneterm/internal/repository"
"github.com/veops/oneterm/internal/schedule"
"github.com/veops/oneterm/internal/sshsrv/icons"
"gorm.io/gorm"
)
@@ -33,7 +34,22 @@ func (s *AssetService) GetById(ctx context.Context, id int) (*model.Asset, error
// PreprocessAssetData preprocesses asset data before saving
func (s *AssetService) PreprocessAssetData(asset *model.Asset) {
asset.Ip = strings.TrimSpace(asset.Ip)
asset.Protocols = lo.Map(asset.Protocols, func(s string, _ int) string { return strings.TrimSpace(s) })
// Normalize protocols: add default ports if not specified
asset.Protocols = lo.Map(asset.Protocols, func(p string, _ int) string {
p = strings.TrimSpace(p)
// If protocol already has port (contains ':'), keep as-is
if strings.Contains(p, ":") {
return p
}
// Otherwise, add default port
if defaultPort := icons.GetDefaultPort(p); defaultPort != "" {
return p + ":" + defaultPort
}
// If no default port available, keep as-is
return p
})
if asset.Authorization == nil {
asset.Authorization = make(model.AuthorizationMap)
}

View File

@@ -51,6 +51,10 @@ func GetDefaultPort(protocol string) string {
switch protocol {
case "ssh":
return "22"
case "rdp":
return "3389"
case "vnc":
return "5900"
case "mysql":
return "3306"
case "redis":