269 lines
8.8 KiB
Go
269 lines
8.8 KiB
Go
package platform
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"reflect"
|
|
"strings"
|
|
|
|
"git.apinb.com/bsm-sdk/core/errcode"
|
|
"git.apinb.com/bsm-sdk/core/infra"
|
|
"git.apinb.com/bsm-sdk/core/middleware"
|
|
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
|
|
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
|
"github.com/gin-gonic/gin"
|
|
"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, 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, relations) },
|
|
func(ctx *gin.Context) { getResource(ctx, model) },
|
|
func(ctx *gin.Context) { updateResource(ctx, model, updateFields, relations) }
|
|
}
|
|
|
|
func listResource(ctx *gin.Context, model any) {
|
|
page, size := pageSize(ctx)
|
|
list := reflect.New(reflect.SliceOf(reflect.TypeOf(model).Elem()))
|
|
var total int64
|
|
query := impl.DBService.Model(model)
|
|
if err := query.Count(&total).Error; err != nil {
|
|
infra.Response.Error(ctx, err)
|
|
return
|
|
}
|
|
if err := query.Order("created_at desc").Offset((page - 1) * size).Limit(size).Find(list.Interface()).Error; err != nil {
|
|
infra.Response.Error(ctx, err)
|
|
return
|
|
}
|
|
infra.Response.Success(ctx, gin.H{"total": total, "list": resourceResponse(list.Elem().Interface())})
|
|
}
|
|
|
|
func getResource(ctx *gin.Context, model any) {
|
|
data := reflect.New(reflect.TypeOf(model).Elem())
|
|
if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(data.Interface()).Error; err != nil {
|
|
respondRecordError(ctx, err)
|
|
return
|
|
}
|
|
infra.Response.Success(ctx, resourceResponse(data.Interface()))
|
|
}
|
|
|
|
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
|
|
}
|
|
encoded, err := json.Marshal(values)
|
|
if err != nil {
|
|
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
|
return
|
|
}
|
|
data := reflect.New(reflect.TypeOf(model).Elem())
|
|
if err := json.Unmarshal(encoded, data.Interface()); err != nil {
|
|
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
|
return
|
|
}
|
|
data.Elem().FieldByName("Entity").Set(reflect.ValueOf(newEntity("draft")))
|
|
if err := impl.DBService.Create(data.Interface()).Error; err != nil {
|
|
infra.Response.Error(ctx, err)
|
|
return
|
|
}
|
|
infra.Response.Success(ctx, resourceResponse(data.Interface()))
|
|
}
|
|
|
|
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
|
|
}
|
|
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
|
|
// action record. Disposal records deliberately have no update or delete route.
|
|
func DisposeSafetyEvent(ctx *gin.Context) {
|
|
claims, err := middleware.ParseAuth(ctx)
|
|
if err != nil {
|
|
infra.Response.Error(ctx, err)
|
|
return
|
|
}
|
|
var request struct {
|
|
Action string `json:"action" binding:"required,max=64"`
|
|
Reason string `json:"reason" binding:"max=2000"`
|
|
Status string `json:"status" binding:"max=32"`
|
|
}
|
|
if err := ctx.ShouldBindJSON(&request); err != nil {
|
|
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
|
return
|
|
}
|
|
if request.Status == "" {
|
|
request.Status = "disposed"
|
|
}
|
|
var disposal models.SafEventDisposal
|
|
err = impl.DBService.Transaction(func(transaction *gorm.DB) error {
|
|
var event models.SafEvent
|
|
if err := transaction.Where("identity = ?", ctx.Param("identity")).First(&event).Error; err != nil {
|
|
return err
|
|
}
|
|
if result := transaction.Model(&models.SafEvent{}).Where("identity = ?", event.Identity).Update("status", request.Status); result.Error != nil {
|
|
return result.Error
|
|
} else if result.RowsAffected == 0 {
|
|
return gorm.ErrRecordNotFound
|
|
}
|
|
disposal = models.SafEventDisposal{
|
|
Entity: newEntity("enabled"),
|
|
SafEventIdentity: event.Identity,
|
|
Action: request.Action,
|
|
Reason: request.Reason,
|
|
OperatorIdentity: claims.Identity,
|
|
}
|
|
return transaction.Create(&disposal).Error
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
infra.Response.Error(ctx, errcode.ErrRecordNotFound)
|
|
return
|
|
}
|
|
infra.Response.Error(ctx, err)
|
|
return
|
|
}
|
|
infra.Response.Success(ctx, resourceResponse(disposal))
|
|
}
|
|
|
|
// GetEcOrder returns the order together with its immutable item snapshots.
|
|
func GetEcOrder(ctx *gin.Context) {
|
|
var order models.EcOrder
|
|
if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&order).Error; err != nil {
|
|
respondRecordError(ctx, err)
|
|
return
|
|
}
|
|
var items []models.EcOrderItem
|
|
if err := impl.DBService.Where("ec_order_id = ?", order.ID).Order("id asc").Find(&items).Error; err != nil {
|
|
infra.Response.Error(ctx, err)
|
|
return
|
|
}
|
|
infra.Response.Success(ctx, resourceResponse(gin.H{"order": order, "items": items}))
|
|
}
|
|
|
|
// GetDeliveryTrack returns time-ordered points. Precise coordinates are only
|
|
// exposed to tokens explicitly granted the location_scope=precise claim.
|
|
func GetDeliveryTrack(ctx *gin.Context) {
|
|
var track models.DeliveryTrack
|
|
if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&track).Error; err != nil {
|
|
respondRecordError(ctx, err)
|
|
return
|
|
}
|
|
var points []models.DeliveryTrackPoint
|
|
if err := impl.DBService.Where("delivery_track_id = ?", track.ID).Order("occurred_at asc").Find(&points).Error; err != nil {
|
|
infra.Response.Error(ctx, err)
|
|
return
|
|
}
|
|
precise := false
|
|
if claims, err := middleware.ParseAuth(ctx); err == nil {
|
|
precise = claims.Extend["location_scope"] == "precise"
|
|
}
|
|
if !precise {
|
|
for index := range points {
|
|
points[index].Longitude = ""
|
|
points[index].Latitude = ""
|
|
}
|
|
}
|
|
infra.Response.Success(ctx, resourceResponse(gin.H{"track": track, "points": points}))
|
|
}
|