feat(platform): add device safety commerce APIs
This commit is contained in:
@@ -80,16 +80,16 @@ func ExpectedResources() []ResourceContract {
|
||||
{Domain: "user", Name: "user_service_relation", Mode: Writable, PageKind: "list"},
|
||||
{Domain: "device", Name: "dev_smart_cylinder_valve", Mode: Writable, PageKind: "list"},
|
||||
{Domain: "device", Name: "dev_device_binding", Mode: Writable, PageKind: "list"},
|
||||
{Domain: "device", Name: "saf_rule", Mode: Writable, PageKind: "list"},
|
||||
{Domain: "device", Name: "saf_event", Mode: Writable, PageKind: "list"},
|
||||
{Domain: "device", Name: "saf_inspection", Mode: Writable, PageKind: "list"},
|
||||
{Domain: "commerce", Name: "ec_category", Mode: Writable, PageKind: "list"},
|
||||
{Domain: "commerce", Name: "ec_product", Mode: Writable, PageKind: "list"},
|
||||
{Domain: "commerce", Name: "ec_product_attribute", Mode: Writable, PageKind: "list"},
|
||||
{Domain: "commerce", Name: "ec_product_image", Mode: Writable, PageKind: "list"},
|
||||
{Domain: "commerce", Name: "ec_cart", Mode: Writable, PageKind: "list"},
|
||||
{Domain: "commerce", Name: "ec_order", Mode: Writable, PageKind: "list"},
|
||||
{Domain: "commerce", Name: "ec_review", Mode: Writable, PageKind: "list"},
|
||||
{Domain: "safety", Name: "saf_rule", Mode: Writable, PageKind: "list"},
|
||||
{Domain: "safety", Name: "saf_event", Mode: Writable, PageKind: "list"},
|
||||
{Domain: "safety", Name: "saf_inspection", Mode: Writable, PageKind: "list"},
|
||||
{Domain: "ec", Name: "ec_category", Mode: Writable, PageKind: "list"},
|
||||
{Domain: "ec", Name: "ec_product", Mode: Writable, PageKind: "list"},
|
||||
{Domain: "ec", Name: "ec_product_attribute", Mode: Writable, PageKind: "list"},
|
||||
{Domain: "ec", Name: "ec_product_image", Mode: Writable, PageKind: "list"},
|
||||
{Domain: "ec", Name: "ec_cart", Mode: Writable, PageKind: "list"},
|
||||
{Domain: "ec", Name: "ec_order", Mode: Writable, PageKind: "list"},
|
||||
{Domain: "ec", Name: "ec_review", Mode: Writable, PageKind: "list"},
|
||||
{Domain: "delivery", Name: "delivery_task", Mode: Writable, PageKind: "list"},
|
||||
{Domain: "delivery", Name: "delivery_track", Mode: Writable, PageKind: "list"},
|
||||
{Domain: "delivery", Name: "delivery_track_point", Mode: Writable, PageKind: "list"},
|
||||
@@ -113,6 +113,6 @@ func ExpectedResources() []ResourceContract {
|
||||
{Domain: "audit", Name: "aud_operation_log", Mode: ReadOnly, PageKind: "list"},
|
||||
{Domain: "audit", Name: "aud_export_log", Mode: ReadOnly, PageKind: "list"},
|
||||
{Domain: "audit", Name: "aud_approval", Mode: ReadOnly, PageKind: "list"},
|
||||
{Domain: "device", Name: "saf_event_disposal", Mode: AppendOnly, PageKind: "list"},
|
||||
{Domain: "safety", Name: "saf_event_disposal", Mode: AppendOnly, PageKind: "list"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,12 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.apinb.com/bsm-sdk/core/errcode"
|
||||
"git.apinb.com/bsm-sdk/core/types"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
@@ -21,7 +24,7 @@ import (
|
||||
|
||||
func TestExpectedResources(t *testing.T) {
|
||||
assertContract(t, ExpectedResources(), "gas", "gas_basic", Writable, "list")
|
||||
assertContract(t, ExpectedResources(), "device", "saf_event", Writable, "list")
|
||||
assertContract(t, ExpectedResources(), "safety", "saf_event", Writable, "list")
|
||||
assertContract(t, ExpectedResources(), "wallet", "wallet_ledger", ReadOnly, "list")
|
||||
}
|
||||
|
||||
@@ -159,6 +162,76 @@ func TestPlatformRoleStatusAndArchiveReturnNotFoundWhenUpdateAffectsZeroRows(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEcOrderReturnsOrderItems(t *testing.T) {
|
||||
_, mock := setupPlatformRoleDatabase(t)
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "ec_order" WHERE identity = $1 ORDER BY "ec_order"."id" LIMIT $2`)).
|
||||
WithArgs("order-a", 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "order_no", "user_account_id", "gas_station_id", "delivery_point_id", "total_amount"}).
|
||||
AddRow(uint64(1), "order-a", nil, nil, "enabled", 1, "O-1", uint64(2), uint64(3), uint64(4), int64(500)))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "ec_order_item" WHERE ec_order_id = $1 ORDER BY id asc`)).
|
||||
WithArgs(uint64(1)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "ec_order_id", "ec_product_id", "product_snapshot", "quantity", "sale_amount"}).
|
||||
AddRow(uint64(2), "item-a", nil, nil, "enabled", 1, uint64(1), uint64(5), `{}`, 2, int64(500)))
|
||||
|
||||
ctx, recorder := updateContext(http.MethodGet, "/ec/ec_order/order-a", "order-a", nil)
|
||||
GetEcOrder(ctx)
|
||||
|
||||
assertResponseCode(t, recorder, 0)
|
||||
if !strings.Contains(recorder.Body.String(), `"items"`) || !strings.Contains(recorder.Body.String(), `"item-a"`) {
|
||||
t.Fatalf("order detail omitted its items: %s", recorder.Body.String())
|
||||
}
|
||||
assertMockExpectations(t, mock)
|
||||
}
|
||||
|
||||
func TestGetDeliveryTrackOrdersAndMasksPointsWithoutPreciseLocationScope(t *testing.T) {
|
||||
_, mock := setupPlatformRoleDatabase(t)
|
||||
now := time.Now().UTC()
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "delivery_track" WHERE identity = $1 ORDER BY "delivery_track"."id" LIMIT $2`)).
|
||||
WithArgs("track-a", 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "delivery_task_id", "started_at", "completed_at"}).
|
||||
AddRow(uint64(7), "track-a", nil, nil, "enabled", 1, uint64(8), now, nil))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "delivery_track_point" WHERE delivery_track_id = $1 ORDER BY occurred_at asc`)).
|
||||
WithArgs(uint64(7)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "delivery_track_id", "point_type", "occurred_at", "longitude", "latitude"}).
|
||||
AddRow(uint64(9), "point-a", nil, nil, "enabled", 1, uint64(7), "arrival", now, "120.123", "30.456"))
|
||||
|
||||
ctx, recorder := updateContext(http.MethodGet, "/delivery/delivery_track/track-a", "track-a", nil)
|
||||
GetDeliveryTrack(ctx)
|
||||
|
||||
assertResponseCode(t, recorder, 0)
|
||||
if strings.Contains(recorder.Body.String(), "120.123") || strings.Contains(recorder.Body.String(), "30.456") {
|
||||
t.Fatalf("unauthorized response exposed precise coordinates: %s", recorder.Body.String())
|
||||
}
|
||||
assertMockExpectations(t, mock)
|
||||
}
|
||||
|
||||
func TestDisposeSafetyEventUpdatesEventAndAppendsOperatorActionTransactionally(t *testing.T) {
|
||||
_, mock := setupPlatformRoleDatabase(t)
|
||||
now := time.Now().UTC()
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "saf_event" WHERE identity = $1 ORDER BY "saf_event"."id" LIMIT $2`)).
|
||||
WithArgs("event-a", 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "event_code", "level", "title", "smart_cylinder_valve_identity", "sla_at"}).
|
||||
AddRow(uint64(3), "event-a", now, now, "open", 1, "E-1", 2, "alarm", "valve-a", nil))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`UPDATE "saf_event" SET "status"=$1,"updated_at"=$2 WHERE identity = $3`)).
|
||||
WithArgs("disposed", sqlmock.AnyArg(), "event-a").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO "saf_event_disposal" ("identity","created_at","updated_at","status","version","saf_event_identity","action","reason","operator_identity") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING "id"`)).
|
||||
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), "enabled", 1, "event-a", "close", "resolved", "operator-a").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uint64(1)))
|
||||
mock.ExpectCommit()
|
||||
|
||||
ctx, recorder := updateContext(http.MethodPost, "/safety/saf_event/event-a/disposals", "event-a", []byte(`{"action":"close","reason":"resolved"}`))
|
||||
ctx.Set("Auth", &types.JwtClaims{Identity: "operator-a"})
|
||||
DisposeSafetyEvent(ctx)
|
||||
|
||||
assertResponseCode(t, recorder, 0)
|
||||
if !strings.Contains(recorder.Body.String(), `"operator_identity":"operator-a"`) {
|
||||
t.Fatalf("disposal omitted its operator: %s", recorder.Body.String())
|
||||
}
|
||||
assertMockExpectations(t, mock)
|
||||
}
|
||||
|
||||
func TestReplacePlatformRoleMenusAllowsAnEmptySetToClearAssignmentsTransactionally(t *testing.T) {
|
||||
_, mock := setupPlatformRoleDatabase(t)
|
||||
mock.ExpectBegin()
|
||||
|
||||
175
backend/api/internal/logic/platform/task4_resources.go
Normal file
175
backend/api/internal/logic/platform/task4_resources.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
return func(ctx *gin.Context) { listResource(ctx, model) },
|
||||
func(ctx *gin.Context) { createResource(ctx, model, createFields) },
|
||||
func(ctx *gin.Context) { getResource(ctx, model) },
|
||||
func(ctx *gin.Context) { updateResource(ctx, model, updateFields) }
|
||||
}
|
||||
|
||||
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": 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, data.Interface())
|
||||
}
|
||||
|
||||
func createResource(ctx *gin.Context, model any, allowedFields []string) {
|
||||
var input map[string]any
|
||||
if err := ctx.ShouldBindJSON(&input); 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)
|
||||
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, data.Interface())
|
||||
}
|
||||
|
||||
func updateResource(ctx *gin.Context, model any, allowedFields []string) {
|
||||
var input map[string]any
|
||||
if err := ctx.ShouldBindJSON(&input); err != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
updateAllowedByIdentity(ctx, model, input, allowedFields)
|
||||
}
|
||||
|
||||
// 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, 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, 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, gin.H{"track": track, "points": points})
|
||||
}
|
||||
Reference in New Issue
Block a user