Files
platforms/backend/api/internal/logic/common/resource.go

573 lines
17 KiB
Go

package common
import (
"encoding/json"
"errors"
"reflect"
"sort"
"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"
)
// 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 := ApplyKeywordFilter(ctx, ActiveRecords(impl.DBService.Model(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
}
response, err := PublicResourceResponse(list.Elem().Interface())
if err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, gin.H{"total": total, "list": ProtectPreciseLocation(ctx, model, response)})
}
func GetResource(ctx *gin.Context, model any) {
data := reflect.New(reflect.TypeOf(model).Elem())
if err := ActiveRecords(impl.DBService).Where("identity = ?", ctx.Param("identity")).First(data.Interface()).Error; err != nil {
RespondRecordError(ctx, err)
return
}
response, err := PublicResourceResponse(data.Interface())
if err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, ProtectPreciseLocation(ctx, model, response))
}
func createResource(ctx *gin.Context, model any, allowedFields []string, relations []ResourceRelation) {
values, err := PrepareResourceValues(ctx, model, allowedFields, relations)
if err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
if err := ValidateResourceValues(model, values, true); 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(StatusDraft)))
if err := impl.DBService.Create(data.Interface()).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
RespondCreatedResource(ctx, data.Interface())
}
func RespondCreatedResource(ctx *gin.Context, value any) {
response, err := PublicResourceResponse(value)
if err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, maskCreatedSensitiveFields(response))
}
func maskCreatedSensitiveFields(value any) any {
switch data := value.(type) {
case map[string]any:
safe := make(map[string]any)
for key, item := range data {
if isCreatedResponseField(key) {
safe[key] = maskCreatedSensitiveFields(item)
}
}
return safe
case []any:
safe := make([]any, len(data))
for index, item := range data {
safe[index] = maskCreatedSensitiveFields(item)
}
return safe
}
return value
}
func isCreatedResponseField(key string) bool {
switch key {
case "identity", "status", "created_at", "updated_at":
return true
default:
return strings.HasSuffix(key, "_identity")
}
}
func ProtectPreciseLocation(ctx *gin.Context, model, value any) any {
maskPersonalName := reflect.TypeOf(model) == reflect.TypeOf(&models.UserAccount{}) ||
reflect.TypeOf(model) == reflect.TypeOf(&models.StaffAccount{})
maskDisplayName := reflect.TypeOf(model) == reflect.TypeOf(&models.PlatformAccount{})
ProtectPublicFields(value, maskPersonalName, maskDisplayName, HasPreciseLocationScope(ctx))
return value
}
var sensitiveResponseFields = map[string]bool{
"avatar": true, "address": true, "credential_no": true,
"evidence_uri": true, "evidence_url": true, "file_uri": true,
"attachment_uri": true, "attachment_url": true,
"certificate_uri": true, "certificate_url": true,
"credential_uri": true, "credential_url": true,
"proof_uri": true,
}
func ProtectPublicFields(value any, maskPersonalName, maskDisplayName, retainCoordinates bool) {
switch data := value.(type) {
case map[string]any:
if phone, ok := data["phone"].(string); ok && phone != "" {
data["phone_masked"] = MaskPhone(phone)
}
delete(data, "phone")
for _, key := range []string{"contact_phone", "recipient_phone"} {
if phone, ok := data[key].(string); ok && phone != "" {
data[key+"_masked"] = MaskPhone(phone)
}
delete(data, key)
}
for _, key := range []string{"contact_name", "recipient_name"} {
if name, ok := data[key].(string); ok && name != "" {
data[key+"_masked"] = MaskPersonalNameValue(name)
}
delete(data, key)
}
for key := range sensitiveResponseFields {
delete(data, key)
}
if maskPersonalName {
if name, ok := data["name"].(string); ok && name != "" {
data["name_masked"] = MaskPersonalNameValue(name)
}
delete(data, "name")
if name, ok := data["real_name"].(string); ok && name != "" {
data["real_name_masked"] = MaskPersonalNameValue(name)
}
delete(data, "real_name")
}
if maskDisplayName {
if name, ok := data["display_name"].(string); ok && name != "" {
data["display_name_masked"] = MaskPersonalNameValue(name)
}
delete(data, "display_name")
}
if !retainCoordinates {
delete(data, "longitude")
delete(data, "latitude")
}
for _, item := range data {
ProtectPublicFields(item, maskPersonalName, maskDisplayName, retainCoordinates)
}
case []any:
for _, item := range data {
ProtectPublicFields(item, maskPersonalName, maskDisplayName, retainCoordinates)
}
}
}
func MaskPersonalNameValue(name string) string {
runes := []rune(name)
if len(runes) == 0 {
return ""
}
if len(runes) == 1 {
return "*"
}
return string(runes[0]) + strings.Repeat("*", len(runes)-1)
}
func HasPreciseLocationScope(ctx *gin.Context) bool {
claims, err := middleware.ParseAuth(ctx)
return err == nil && claims.Extend["location_scope"] == "precise"
}
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 || ValidateResourceValues(model, values, false) != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
UpdateAllowedByIdentity(ctx, model, values, append(allowedFields, relationColumns(relations)...))
}
func PrepareResourceValues(ctx *gin.Context, model any, 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
}
// ValidateResourceValues enforces invariants that database nullability and
// frontend form metadata cannot express.
func ValidateResourceValues(model any, values map[string]any, creating bool) error {
positive := func(key string) bool {
value, exists := numericValue(values[key])
return !exists || value > 0
}
nonNegative := func(key string) bool {
value, exists := numericValue(values[key])
return !exists || value >= 0
}
nonEmpty := func(key string) bool {
value, exists := values[key]
if !exists {
return !creating
}
text, ok := value.(string)
return ok && strings.TrimSpace(text) != ""
}
switch model.(type) {
case *models.EcProduct:
if !nonEmpty("product_code") || !nonEmpty("name") || !nonNegative("price_amount") || !nonNegative("stock_quantity") {
return errors.New("invalid commerce product")
}
case *models.EcCart:
if !positive("quantity") {
return errors.New("invalid cart quantity")
}
case *models.EcOrder:
if !nonEmpty("order_no") || !nonNegative("total_amount") {
return errors.New("invalid order")
}
case *models.EcOrderItem:
if !nonEmpty("product_snapshot") || !positive("quantity") || !nonNegative("sale_amount") {
return errors.New("invalid order item")
}
case *models.EcReview:
score, exists := numericValue(values["score"])
if (exists && (score < 1 || score > 5)) || !nonEmpty("content") {
return errors.New("invalid review")
}
case *models.FinPayment:
if !positive("amount") || !nonEmpty("channel") {
return errors.New("invalid payment")
}
}
return nil
}
func numericValue(value any) (float64, bool) {
switch number := value.(type) {
case float64:
return number, true
case float32:
return float64(number), true
case int:
return float64(number), true
case int64:
return float64(number), true
case uint:
return float64(number), true
case uint64:
return float64(number), true
default:
return 0, false
}
}
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 {
return nil, errors.New("invalid relation identity")
}
if strings.TrimSpace(identity) == "" && !relation.Required {
values[relation.Column] = uint64(0)
continue
}
if 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 = ? AND status <> ?", identity, StatusArchived).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, error) {
encoded, err := json.Marshal(value)
if err != nil {
return nil, err
}
var decoded any
if err := json.Unmarshal(encoded, &decoded); err != nil {
return nil, err
}
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{},
"product_type_id": &models.ProductType{},
"product_info_id": &models.ProductInfo{},
"warehouse_id": &models.ProductWarehouse{},
"ec_category_id": &models.EcCategory{},
"ec_product_id": &models.EcProduct{},
"ec_order_id": &models.EcOrder{},
"gasorder_contract_id": &models.GasorderContract{},
"gasorder_contract_product_id": &models.GasorderContractProduct{},
"gasorder_basic_id": &models.GasorderBasic{},
"gasorder_track_id": &models.GasorderTrack{},
"platform_role_id": &models.PlatformRole{},
"wallet_basic_id": &models.WalletBasic{},
"wallet_payment_id": &models.WalletPayment{},
"wallet_bank_id": &models.WalletBank{},
"related_record_id": &models.WalletRecord{},
}
var relationIdentityKeys = map[string]string{
"gas_station_id": "gas_basic_identity",
"delivery_point_id": "delivery_basic_identity",
}
type relationIdentityReference struct {
target map[string]any
identityKey string
id uint64
}
type relationIdentityGroup struct {
model any
ids []uint64
seen map[uint64]struct{}
references []relationIdentityReference
}
type relationIdentityRecord struct {
ID uint64
Identity string
}
func projectRelationIdentities(value any) (any, error) {
groups := map[string]*relationIdentityGroup{}
collectRelationIdentityReferences(value, groups)
groupKeys := make([]string, 0, len(groups))
for key := range groups {
groupKeys = append(groupKeys, key)
}
sort.Strings(groupKeys)
for _, key := range groupKeys {
group := groups[key]
var rows []relationIdentityRecord
if err := impl.DBService.Model(group.model).Select("id", "identity").Where("id IN ?", group.ids).Find(&rows).Error; err != nil {
return nil, err
}
identities := make(map[uint64]string, len(rows))
for _, row := range rows {
identities[row.ID] = row.Identity
}
for _, reference := range group.references {
identity, found := identities[reference.id]
if !found {
return nil, errors.New("related identity not found")
}
reference.target[reference.identityKey] = identity
}
}
return value, nil
}
func collectRelationIdentityReferences(value any, groups map[string]*relationIdentityGroup) {
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 {
if id, ok := responseRelationID(item); ok && id != 0 {
key := reflect.TypeOf(model).String()
group := groups[key]
if group == nil {
group = &relationIdentityGroup{model: model, seen: map[uint64]struct{}{}}
groups[key] = group
}
if _, found := group.seen[id]; !found {
group.ids = append(group.ids, id)
group.seen[id] = struct{}{}
}
group.references = append(group.references, relationIdentityReference{target: data, identityKey: identityKey, id: id})
} else {
data[identityKey] = ""
}
}
delete(data, key)
continue
}
collectRelationIdentityReferences(item, groups)
}
case []any:
for _, item := range data {
collectRelationIdentityReferences(item, groups)
}
}
}
func responseRelationID(value any) (uint64, bool) {
var id uint64
switch raw := value.(type) {
case float64:
id = uint64(raw)
case uint64:
id = raw
case int:
id = uint64(raw)
default:
return 0, false
}
return id, true
}
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
}