Files
platforms/backend/api/internal/logic/platform/task4_resources.go

521 lines
16 KiB
Go

package platform
import (
"bytes"
"encoding/json"
"errors"
"io"
"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) }
}
// FinSettlementHandlers accepts a public subject_identity and derives the
// polymorphic storage key from its declared subject_type.
func FinSettlementHandlers() (gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc) {
fields := []string{"settlement_no", "subject_type", "subject_id", "period_start", "period_end"}
return func(ctx *gin.Context) { listResource(ctx, &models.FinSettlement{}) },
func(ctx *gin.Context) {
if err := rewriteSettlementSubject(ctx); err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
createResource(ctx, &models.FinSettlement{}, fields, nil)
},
func(ctx *gin.Context) { getResource(ctx, &models.FinSettlement{}) },
func(ctx *gin.Context) {
if err := rewriteSettlementSubject(ctx); err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
updateResource(ctx, &models.FinSettlement{}, fields, nil)
}
}
func rewriteSettlementSubject(ctx *gin.Context) error {
var input map[string]any
if err := ctx.ShouldBindJSON(&input); err != nil {
return err
}
subjectType, _ := input["subject_type"].(string)
identity, _ := input["subject_identity"].(string)
var model any
switch subjectType {
case "gas", "gas_basic":
model = &models.GasBasic{}
case "delivery", "delivery_basic":
model = &models.DeliveryBasic{}
case "staff", "staff_account":
model = &models.StaffAccount{}
default:
return errors.New("invalid settlement subject")
}
id, err := resolveIdentityID(model, identity, true)
if err != nil {
return err
}
delete(input, "subject_identity")
input["subject_id"] = id
encoded, err := json.Marshal(input)
if err != nil {
return err
}
ctx.Request.Body = io.NopCloser(bytes.NewReader(encoded))
return nil
}
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": publicResourceResponse(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, publicResourceResponse(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")
}
id, err := resolveIdentityID(relation.Model, identity, true)
if err != nil {
return nil, err
}
values[relation.Column] = id
}
return values, nil
}
// resolveIdentityID is the only boundary that converts a public identity to a
// persistence-only numeric key. Callers must never bind a client supplied ID.
func resolveIdentityID(model any, identity string, required bool) (uint64, error) {
identity = strings.TrimSpace(identity)
if identity == "" {
if required {
return 0, errors.New("missing required relation")
}
return 0, nil
}
var related struct{ ID uint64 }
if err := impl.DBService.Model(model).Select("id").Where("identity = ?", identity).First(&related).Error; err != nil {
return 0, err
}
return related.ID, 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)
}
// publicResourceResponse additionally resolves persisted relation keys into
// their public identities. It is used by list/detail endpoints so an edit form
// can round-trip the relation without ever receiving a surrogate database ID.
func publicResourceResponse(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 projectRelationIdentities(decoded)
}
var relationIdentityModels = map[string]any{
"gas_basic_id": &models.GasBasic{},
"gas_station_id": &models.GasBasic{},
"delivery_basic_id": &models.DeliveryBasic{},
"delivery_point_id": &models.DeliveryBasic{},
"user_account_id": &models.UserAccount{},
"staff_account_id": &models.StaffAccount{},
"smart_cylinder_valve_id": &models.DevSmartCylinderValve{},
"ec_category_id": &models.EcCategory{},
"ec_product_id": &models.EcProduct{},
"ec_order_id": &models.EcOrder{},
"delivery_task_id": &models.DeliveryTask{},
"delivery_track_id": &models.DeliveryTrack{},
"platform_role_id": &models.PlatformRole{},
"platform_menu_id": &models.PlatformMenu{},
"report_id": &models.Report{},
"wallet_id": &models.Wallet{},
}
var relationIdentityKeys = map[string]string{
"gas_station_id": "gas_basic_identity",
"delivery_point_id": "delivery_basic_identity",
}
func projectRelationIdentities(value any) any {
switch data := value.(type) {
case map[string]any:
for key, item := range data {
if key == "id" {
delete(data, key)
continue
}
if strings.HasSuffix(key, "_id") {
identityKey := strings.TrimSuffix(key, "_id") + "_identity"
if alias := relationIdentityKeys[key]; alias != "" {
identityKey = alias
}
model := relationIdentityModels[key]
if key == "subject_id" {
model = settlementSubjectModel(data["subject_type"])
identityKey = "subject_identity"
}
if model != nil {
data[identityKey] = relationIdentity(item, model)
}
delete(data, key)
continue
}
data[key] = projectRelationIdentities(item)
}
case []any:
for index := range data {
data[index] = projectRelationIdentities(data[index])
}
}
return value
}
func relationIdentity(value any, model any) string {
var id uint64
switch raw := value.(type) {
case float64:
id = uint64(raw)
case uint64:
id = raw
case int:
id = uint64(raw)
}
if id == 0 {
return ""
}
var related struct{ Identity string }
if err := impl.DBService.Model(model).Select("identity").Where("id = ?", id).First(&related).Error; err != nil {
return ""
}
return related.Identity
}
func settlementSubjectModel(value any) any {
subjectType, _ := value.(string)
switch subjectType {
case "gas", "gas_basic":
return &models.GasBasic{}
case "delivery", "delivery_basic":
return &models.DeliveryBasic{}
case "staff", "staff_account":
return &models.StaffAccount{}
default:
return nil
}
}
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, publicResourceResponse(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, publicResourceResponse(gin.H{"track": track, "points": points}))
}
type ecCategoryView struct {
Identity string `json:"identity"`
ParentIdentity string `json:"parent_identity,omitempty"`
Name string `json:"name"`
SortNo int `json:"sort_no"`
Status string `json:"status"`
}
func ecCategoryViews(list []models.EcCategory) ([]ecCategoryView, error) {
parents := make(map[uint64]string)
for _, item := range list {
if item.ParentID != 0 {
parents[item.ParentID] = ""
}
}
if len(parents) > 0 {
var rows []struct {
ID uint64
Identity string
}
ids := make([]uint64, 0, len(parents))
for id := range parents {
ids = append(ids, id)
}
if err := impl.DBService.Model(&models.EcCategory{}).Select("id", "identity").Where("id IN ?", ids).Find(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
parents[row.ID] = row.Identity
}
}
views := make([]ecCategoryView, 0, len(list))
for _, item := range list {
views = append(views, ecCategoryView{Identity: item.Identity, ParentIdentity: parents[item.ParentID], Name: item.Name, SortNo: item.SortNo, Status: item.Status})
}
return views, nil
}
func ListEcCategory(ctx *gin.Context) {
page, size := pageSize(ctx)
var list []models.EcCategory
var total int64
if err := impl.DBService.Model(&models.EcCategory{}).Count(&total).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
if err := impl.DBService.Order("sort_no asc, id asc").Offset((page - 1) * size).Limit(size).Find(&list).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
views, err := ecCategoryViews(list)
if err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, gin.H{"total": total, "list": views})
}
func GetEcCategory(ctx *gin.Context) {
var category models.EcCategory
if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&category).Error; err != nil {
respondRecordError(ctx, err)
return
}
views, err := ecCategoryViews([]models.EcCategory{category})
if err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, views[0])
}