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 ( const (
kFmtAssetIds = "assetIds-%d"
kAuthorizationIds = "authorizationIds" kAuthorizationIds = "authorizationIds"
kNodeIds = "nodeIds"
kAccountIds = "accountIds"
) )
var ( var (
@@ -126,7 +123,7 @@ func (c *Controller) GetAssets(ctx *gin.Context) {
if info { if info {
db = db.Select("id", "parent_id", "name", "ip", "protocols", db = db.Select("id", "parent_id", "name", "ip", "protocols",
"connectable", "authorization", "resource_id", "access_time_control", "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...) doGet(ctx, false, db, config.RESOURCE_ASSET, assetPostHooks...)

View File

@@ -1,8 +1,11 @@
package controller package controller
import ( import (
"bytes"
"context" "context"
"encoding/json"
"errors" "errors"
"io"
"net/http" "net/http"
"reflect" "reflect"
"time" "time"
@@ -245,10 +248,26 @@ func doUpdate[T model.Model](ctx *gin.Context, needAcl bool, md T, resourceType
return 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 { if err = ctx.ShouldBindBodyWithJSON(md); err != nil {
ctx.AbortWithError(http.StatusBadRequest, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": err}}) ctx.AbortWithError(http.StatusBadRequest, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": err}})
return return
} }
md.SetUpdaterId(currentUser.Uid) md.SetUpdaterId(currentUser.Uid)
for _, hook := range preHooks { 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) { if err = baseService.ExecuteInTransaction(ctx, func(tx *gorm.DB) (err error) {
omits := []string{"resource_id", "created_at", "deleted_at"} omits := []string{"resource_id", "created_at", "deleted_at"}
selects := []string{"*"} var selects []string
switch t := any(md).(type) {
case *model.Asset: // Build dynamic selects based on fields present in request body
if err = service.DefaultAuthService.HandleAuthorization(ctx, tx, model.ACTION_UPDATE, t, nil); err != nil { if len(bodyFields) > 0 {
handleRemoteErr(ctx, err) // Define allowed fields per model type
return 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"} // Build selects list based on which fields are present in body
} if len(allowedFields) > 0 {
case *model.Node: for _, field := range allowedFields {
if err = handleNodeAuthorization(ctx, tx, model.ACTION_UPDATE, t); err != nil { if _, present := bodyFields[field]; present {
handleRemoteErr(ctx, err) selects = append(selects, field)
return }
} }
case *model.Account: // Always update these fields
if cast.ToBool(ctx.Value("isAuthWithKey")) { selects = append(selects, "updated_at", "updater_id")
selects = []string{"account", "password", "phrase", "pk", "account_type"}
} }
} }
// 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 { if err = tx.Select(selects).Omit(omits...).Save(md).Error; err != nil {
return return
} }

View File

@@ -126,6 +126,8 @@ type Asset struct {
ParentId int `json:"parent_id" gorm:"column:parent_id"` ParentId int `json:"parent_id" gorm:"column:parent_id"`
Ip string `json:"ip" gorm:"column:ip"` Ip string `json:"ip" gorm:"column:ip"`
Protocols Slice[string] `json:"protocols" gorm:"column:protocols;type:text"` 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"` GatewayId int `json:"gateway_id" gorm:"column:gateway_id"`
Authorization AuthorizationMap `json:"authorization" gorm:"column:authorization;type:text"` Authorization AuthorizationMap `json:"authorization" gorm:"column:authorization;type:text"`
AccessAuth AccessAuth `json:"access_auth" gorm:"embedded;column:access_auth"` // Deprecated: Use V2 fields below 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) })) 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 // Sort by name
db = db.Order("name") db = db.Order("name")

View File

@@ -10,6 +10,7 @@ import (
"github.com/veops/oneterm/internal/model" "github.com/veops/oneterm/internal/model"
"github.com/veops/oneterm/internal/repository" "github.com/veops/oneterm/internal/repository"
"github.com/veops/oneterm/internal/schedule" "github.com/veops/oneterm/internal/schedule"
"github.com/veops/oneterm/internal/sshsrv/icons"
"gorm.io/gorm" "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 // PreprocessAssetData preprocesses asset data before saving
func (s *AssetService) PreprocessAssetData(asset *model.Asset) { func (s *AssetService) PreprocessAssetData(asset *model.Asset) {
asset.Ip = strings.TrimSpace(asset.Ip) 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 { if asset.Authorization == nil {
asset.Authorization = make(model.AuthorizationMap) asset.Authorization = make(model.AuthorizationMap)
} }

View File

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