refactor platform logic and add gasorder domain

This commit is contained in:
david
2026-07-28 10:42:26 +08:00
parent c8862eadfb
commit 37cbf6e3ce
44 changed files with 1636 additions and 1446 deletions

View File

@@ -0,0 +1,222 @@
// Package platform 提供平台总后台的同步 HTTP 业务逻辑。
package common
import (
"errors"
"reflect"
"strings"
"git.apinb.com/bsm-sdk/core/errcode"
"git.apinb.com/bsm-sdk/core/infra"
"git.apinb.com/bsm-sdk/core/utils"
"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"
)
// FilterFields keeps only explicitly allowed persistence fields.
func FilterFields(values map[string]any, allowedFields []string) gin.H {
allowed := make(map[string]struct{}, len(allowedFields))
for _, field := range allowedFields {
allowed[field] = struct{}{}
}
filtered := make(gin.H, len(allowed))
for field, value := range values {
if _, ok := allowed[field]; ok {
filtered[field] = value
}
}
return filtered
}
// UpdateRecordStatus 更新主表状态,停用和归档均保留历史记录。
func UpdateRecordStatus(ctx *gin.Context, model any) {
var request struct {
Status string `json:"status" binding:"required,max=32"`
}
if err := ctx.ShouldBindJSON(&request); err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
UpdateAllowedByIdentity(ctx, model, gin.H{"status": request.Status}, []string{"status"})
}
// ArchiveRecord 通过 archived 状态实现逻辑删除,不物理删除主数据。
func ArchiveRecord(ctx *gin.Context, model any) {
UpdateAllowedByIdentity(ctx, model, gin.H{"status": "archived"}, []string{"status"})
}
func NewEntity(status string) models.Entity {
return models.Entity{Identity: models.NewIdentity(), Status: status, Version: 1}
}
func ListPage[T any](ctx *gin.Context) {
page, size := PageSize(ctx)
var list []T
var total int64
model := new(T)
databaseQuery := ApplyKeywordFilter(ctx, impl.DBService.Model(model), model)
if err := databaseQuery.Count(&total).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
if err := databaseQuery.Order("created_at desc").Offset((page - 1) * size).Limit(size).Find(&list).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
response, err := PublicResourceResponse(list)
if err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, gin.H{"total": total, "list": ProtectPreciseLocation(ctx, model, response)})
}
var keywordSafeColumns = map[string]bool{
"code": true, "name": true, "username": true, "display_name": true,
"role_code": true, "delivery_code": true, "work_status": true,
"credential_type": true, "device_no": true, "model": true,
"online_status": true, "rule_code": true, "action": true,
"event_code": true, "title": true, "result": true,
"product_code": true, "value": true, "order_no": true,
"channel": true, "settlement_no": true, "subject_type": true,
"content_type": true, "publish_status": true, "template_code": true,
"ticket_no": true, "category": true, "priority": true,
"platform_role_code": true, "data_scope": true, "menu_code": true,
"path": true, "resource_type": true,
"owner_type": true, "owner_identity": true, "payment_no": true,
"record_no": true, "request_no": true, "refund_no": true,
"cash_no": true, "trade_no": true, "trade_type": true,
"pay_channel": true, "payment_type": true,
"contract_no": true, "creator_type": true, "from_status": true,
"to_status": true, "confirm_type": true, "product_type_name": true,
}
func ApplyKeywordFilter(ctx *gin.Context, query *gorm.DB, model any) *gorm.DB {
keyword := strings.ToLower(strings.TrimSpace(ctx.Query("keyword")))
if keyword == "" {
return query
}
columns := keywordColumns(model)
if len(columns) == 0 {
return query
}
conditions := make([]string, 0, len(columns))
arguments := make([]any, 0, len(columns))
for _, column := range columns {
conditions = append(conditions, `LOWER("`+column+`") LIKE ?`)
arguments = append(arguments, "%"+keyword+"%")
}
return query.Where("("+strings.Join(conditions, " OR ")+")", arguments...)
}
func keywordColumns(model any) []string {
modelType := reflect.TypeOf(model)
for modelType.Kind() == reflect.Pointer {
modelType = modelType.Elem()
}
columns := make([]string, 0)
for index := 0; index < modelType.NumField(); index++ {
field := modelType.Field(index)
if field.Anonymous || field.Type.Kind() != reflect.String {
continue
}
column := gormColumn(field.Tag.Get("gorm"))
if keywordSafeColumns[column] && !isSensitiveKeywordColumn(model, column) {
columns = append(columns, column)
}
}
return columns
}
func isSensitiveKeywordColumn(model any, column string) bool {
switch model.(type) {
case *models.UserAccount, *models.StaffAccount:
return column == "name"
default:
return false
}
}
func gormColumn(tag string) string {
for _, part := range strings.Split(tag, ";") {
if strings.HasPrefix(part, "column:") {
return strings.TrimPrefix(part, "column:")
}
}
return ""
}
func GetByIdentity[T any](ctx *gin.Context) {
var data T
if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&data).Error; err != nil {
RespondRecordError(ctx, err)
return
}
response, err := PublicResourceResponse(data)
if err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, ProtectPreciseLocation(ctx, new(T), response))
}
func UpdateAllowedByIdentity(ctx *gin.Context, model any, values map[string]any, allowedFields []string) {
values = FilterFields(values, allowedFields)
if len(values) == 0 {
infra.Response.Success(ctx, gin.H{"updated": false})
return
}
result := impl.DBService.Model(model).Where("identity = ?", ctx.Param("identity")).Updates(values)
if result.Error != nil {
infra.Response.Error(ctx, result.Error)
return
}
if result.RowsAffected == 0 {
infra.Response.Error(ctx, errcode.ErrRecordNotFound)
return
}
infra.Response.Success(ctx, gin.H{"updated": true})
}
func UpdateByIdentity(ctx *gin.Context, model any, values map[string]any) {
result := impl.DBService.Model(model).Where("identity = ?", ctx.Param("identity")).Updates(values)
if result.Error != nil {
infra.Response.Error(ctx, result.Error)
return
}
if result.RowsAffected == 0 {
infra.Response.Error(ctx, errcode.ErrRecordNotFound)
return
}
infra.Response.Success(ctx, gin.H{"updated": true})
}
func RespondRecordError(ctx *gin.Context, err error) {
if errors.Is(err, gorm.ErrRecordNotFound) {
infra.Response.Error(ctx, errcode.ErrRecordNotFound)
return
}
infra.Response.Error(ctx, err)
}
func PageSize(ctx *gin.Context) (int, int) {
page := utils.String2Int(ctx.DefaultQuery("page", "1"))
size := utils.String2Int(ctx.DefaultQuery("size", "20"))
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
return page, size
}
func MaskPhone(phone string) string {
if len(phone) < 7 {
return "***"
}
return phone[:3] + "****" + phone[len(phone)-4:]
}

View File

@@ -0,0 +1,501 @@
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, 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 := 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
}
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
}
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", "version", "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.PlatfromAccount{})
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 {
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
}
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 = ?", 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, 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{},
"platform_menu_id": &models.PlatformMenu{},
"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
}

View File

@@ -0,0 +1,58 @@
package common
import (
"net/http"
"testing"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
)
func TestFilterFieldsKeepsOnlyAllowedKeys(t *testing.T) {
got := FilterFields(map[string]any{"name": "n", "password_hash": "x"}, []string{"name"})
if len(got) != 1 || got["name"] != "n" {
t.Fatalf("unexpected filtered fields: %#v", got)
}
}
func TestResourceResponseStripsInternalIDsRecursively(t *testing.T) {
got := ResourceResponse(map[string]any{
"id": uint64(1), "identity": "root",
"child": map[string]any{"gasorder_basic_id": uint64(2), "identity": "child"},
}).(map[string]any)
if _, exists := got["id"]; exists {
t.Fatal("root database ID was exposed")
}
child := got["child"].(map[string]any)
if _, exists := child["gasorder_basic_id"]; exists {
t.Fatal("relation database ID was exposed")
}
}
func TestPublicFieldProtectionMasksGasorderContacts(t *testing.T) {
value := map[string]any{"contact_name": "张三", "contact_phone": "13800138000"}
ProtectPublicFields(value, false, false, false)
if _, exists := value["contact_name"]; exists {
t.Fatal("contact name remains public")
}
if _, exists := value["contact_phone"]; exists {
t.Fatal("contact phone remains public")
}
}
func TestResourceRelationOptionalEmptyIdentityClearsRelation(t *testing.T) {
values, err := ResolveResourceRelations(
map[string]any{"warehouse_identity": ""},
nil,
[]ResourceRelation{{Input: "warehouse_identity", Column: "warehouse_id", Model: &models.ProductWarehouse{}}},
false,
)
if err != nil || values["warehouse_id"] != uint64(0) {
t.Fatalf("optional relation clear = (%#v, %v)", values, err)
}
}
func TestCommonMethodModesRemainHTTPCompatible(t *testing.T) {
if http.MethodGet == "" || http.MethodPost == "" {
t.Fatal("standard HTTP methods unavailable")
}
}