fix(platform): enforce identity relations
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"git.apinb.com/bsm-sdk/core/errcode"
|
||||
"git.apinb.com/bsm-sdk/core/infra"
|
||||
@@ -14,13 +15,22 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ResourceRelation accepts a stable external identity while retaining the
|
||||
// relation's database ID as an internal persistence detail.
|
||||
type ResourceRelation struct {
|
||||
Input string
|
||||
Column string
|
||||
Model any
|
||||
Required bool
|
||||
}
|
||||
|
||||
// ResourceHandlers supplies the common identity-based CRUD boundary used by
|
||||
// platform resources whose writable fields are explicitly declared by routes.
|
||||
func ResourceHandlers(model any, createFields, updateFields []string) (gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc) {
|
||||
func ResourceHandlers(model any, createFields, updateFields []string, relations ...ResourceRelation) (gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc) {
|
||||
return func(ctx *gin.Context) { listResource(ctx, model) },
|
||||
func(ctx *gin.Context) { createResource(ctx, model, createFields) },
|
||||
func(ctx *gin.Context) { createResource(ctx, model, createFields, relations) },
|
||||
func(ctx *gin.Context) { getResource(ctx, model) },
|
||||
func(ctx *gin.Context) { updateResource(ctx, model, updateFields) }
|
||||
func(ctx *gin.Context) { updateResource(ctx, model, updateFields, relations) }
|
||||
}
|
||||
|
||||
func listResource(ctx *gin.Context, model any) {
|
||||
@@ -36,7 +46,7 @@ func listResource(ctx *gin.Context, model any) {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, gin.H{"total": total, "list": list.Elem().Interface()})
|
||||
infra.Response.Success(ctx, gin.H{"total": total, "list": resourceResponse(list.Elem().Interface())})
|
||||
}
|
||||
|
||||
func getResource(ctx *gin.Context, model any) {
|
||||
@@ -45,16 +55,15 @@ func getResource(ctx *gin.Context, model any) {
|
||||
respondRecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, data.Interface())
|
||||
infra.Response.Success(ctx, resourceResponse(data.Interface()))
|
||||
}
|
||||
|
||||
func createResource(ctx *gin.Context, model any, allowedFields []string) {
|
||||
var input map[string]any
|
||||
if err := ctx.ShouldBindJSON(&input); err != nil {
|
||||
func createResource(ctx *gin.Context, model any, allowedFields []string, relations []ResourceRelation) {
|
||||
values, err := prepareResourceValues(ctx, allowedFields, relations)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
values := filterFields(input, allowedFields)
|
||||
encoded, err := json.Marshal(values)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
@@ -70,16 +79,100 @@ func createResource(ctx *gin.Context, model any, allowedFields []string) {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, data.Interface())
|
||||
infra.Response.Success(ctx, resourceResponse(data.Interface()))
|
||||
}
|
||||
|
||||
func updateResource(ctx *gin.Context, model any, allowedFields []string) {
|
||||
func updateResource(ctx *gin.Context, model any, allowedFields []string, relations []ResourceRelation) {
|
||||
var input map[string]any
|
||||
if err := ctx.ShouldBindJSON(&input); err != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
updateAllowedByIdentity(ctx, model, input, allowedFields)
|
||||
values, err := resolveResourceRelations(input, allowedFields, relations, false)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
if len(values) == 0 {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
updateAllowedByIdentity(ctx, model, values, append(allowedFields, relationColumns(relations)...))
|
||||
}
|
||||
|
||||
func prepareResourceValues(ctx *gin.Context, allowedFields []string, relations []ResourceRelation) (map[string]any, error) {
|
||||
var input map[string]any
|
||||
if err := ctx.ShouldBindJSON(&input); err != nil || len(input) == 0 {
|
||||
return nil, errors.New("invalid resource payload")
|
||||
}
|
||||
values, err := resolveResourceRelations(input, allowedFields, relations, true)
|
||||
if err != nil || len(values) == 0 {
|
||||
return nil, errors.New("invalid resource payload")
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func resolveResourceRelations(input map[string]any, allowedFields []string, relations []ResourceRelation, requireRelations bool) (map[string]any, error) {
|
||||
values := filterFields(input, allowedFields)
|
||||
for _, relation := range relations {
|
||||
raw, exists := input[relation.Input]
|
||||
if !exists {
|
||||
if requireRelations && relation.Required {
|
||||
return nil, errors.New("missing required relation")
|
||||
}
|
||||
continue
|
||||
}
|
||||
identity, ok := raw.(string)
|
||||
if !ok || strings.TrimSpace(identity) == "" {
|
||||
return nil, errors.New("invalid relation identity")
|
||||
}
|
||||
var related struct{ ID uint64 }
|
||||
if err := impl.DBService.Model(relation.Model).Select("id").Where("identity = ?", identity).First(&related).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values[relation.Column] = related.ID
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func relationColumns(relations []ResourceRelation) []string {
|
||||
columns := make([]string, 0, len(relations))
|
||||
for _, relation := range relations {
|
||||
columns = append(columns, relation.Column)
|
||||
}
|
||||
return columns
|
||||
}
|
||||
|
||||
// resourceResponse strips database surrogate IDs from API data. Business
|
||||
// identities are the only public relation keys accepted or returned.
|
||||
func resourceResponse(value any) any {
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return value
|
||||
}
|
||||
var decoded any
|
||||
if err := json.Unmarshal(encoded, &decoded); err != nil {
|
||||
return value
|
||||
}
|
||||
return stripInternalIDs(decoded)
|
||||
}
|
||||
|
||||
func stripInternalIDs(value any) any {
|
||||
switch data := value.(type) {
|
||||
case map[string]any:
|
||||
for key, item := range data {
|
||||
if key == "id" || strings.HasSuffix(key, "_id") {
|
||||
delete(data, key)
|
||||
continue
|
||||
}
|
||||
data[key] = stripInternalIDs(item)
|
||||
}
|
||||
case []any:
|
||||
for index := range data {
|
||||
data[index] = stripInternalIDs(data[index])
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// DisposeSafetyEvent atomically updates an event and appends its operator-owned
|
||||
@@ -130,7 +223,7 @@ func DisposeSafetyEvent(ctx *gin.Context) {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, disposal)
|
||||
infra.Response.Success(ctx, resourceResponse(disposal))
|
||||
}
|
||||
|
||||
// GetEcOrder returns the order together with its immutable item snapshots.
|
||||
@@ -145,7 +238,7 @@ func GetEcOrder(ctx *gin.Context) {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, gin.H{"order": order, "items": items})
|
||||
infra.Response.Success(ctx, resourceResponse(gin.H{"order": order, "items": items}))
|
||||
}
|
||||
|
||||
// GetDeliveryTrack returns time-ordered points. Precise coordinates are only
|
||||
@@ -171,5 +264,5 @@ func GetDeliveryTrack(ctx *gin.Context) {
|
||||
points[index].Latitude = ""
|
||||
}
|
||||
}
|
||||
infra.Response.Success(ctx, gin.H{"track": track, "points": points})
|
||||
infra.Response.Success(ctx, resourceResponse(gin.H{"track": track, "points": points}))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user