refactor product domain models

This commit is contained in:
david
2026-07-28 08:44:54 +08:00
parent 7000e90d88
commit 8d71a1fc08
15 changed files with 634 additions and 85 deletions

View File

@@ -0,0 +1,361 @@
package platform
import (
"encoding/json"
"errors"
"strings"
"time"
"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"
)
var productOwnerActions = map[string]bool{
"created": true, "warehouse": true, "assigned": true, "returned": true, "manual": true,
}
var productLifecycleStatuses = map[string]bool{
"pending": true, "in_stock": true, "in_transit": true, "in_use": true, "repairing": true, "scrapped": true,
}
func ProductInfoHandlers(relations ...ResourceRelation) (gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc) {
fields := []string{"code", "name", "params", "produced_at", "is_enabled", "status", "action", "reason", "remark"}
return func(ctx *gin.Context) { listResource(ctx, &models.ProductInfo{}) },
func(ctx *gin.Context) { createProductInfo(ctx, fields, relations) },
func(ctx *gin.Context) { getResource(ctx, &models.ProductInfo{}) },
func(ctx *gin.Context) { updateProductInfo(ctx, fields, relations) }
}
func createProductInfo(ctx *gin.Context, fields []string, relations []ResourceRelation) {
values, err := prepareResourceValues(ctx, &models.ProductInfo{}, fields, relations)
if err != nil || !validParamsText(values["params"]) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
delete(values, "action")
delete(values, "reason")
delete(values, "remark")
data := models.ProductInfo{Entity: newEntity("pending"), Params: "{}", IsEnabled: false}
if err := decodeValues(values, &data); err != nil || data.Code == "" || data.Name == "" || data.ProducedAt.IsZero() {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
data.Status = "pending"
if data.IsEnabled {
now := time.Now()
data.EnabledAt = &now
data.Status = initialProductStatus(data)
}
operatorIdentity, operatorName := productOperator(ctx)
err = impl.DBService.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&data).Error; err != nil {
return err
}
return tx.Create(newProductOwner(data, "created", "", "", operatorIdentity, operatorName, time.Now())).Error
})
if err != nil {
infra.Response.Error(ctx, err)
return
}
respondCreatedResource(ctx, &data)
}
func updateProductInfo(ctx *gin.Context, fields []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, fields, relations, false)
if err != nil || len(values) == 0 || !validParamsText(values["params"]) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
action, _ := input["action"].(string)
reason, _ := input["reason"].(string)
remark, _ := input["remark"].(string)
delete(values, "action")
delete(values, "reason")
delete(values, "remark")
operatorIdentity, operatorName := productOperator(ctx)
err = impl.DBService.Transaction(func(tx *gorm.DB) error {
var current models.ProductInfo
if err := tx.Where("identity = ?", ctx.Param("identity")).First(&current).Error; err != nil {
return err
}
if _, changingCode := values["code"]; changingCode {
delete(values, "code")
}
if status, ok := values["status"].(string); ok && !productLifecycleStatuses[status] {
return errors.New("invalid product status")
}
if enabled, ok := values["is_enabled"].(bool); ok && enabled && !current.IsEnabled {
if current.Status == "scrapped" {
return errors.New("scrapped product cannot be enabled")
}
if current.EnabledAt == nil {
now := time.Now()
values["enabled_at"] = &now
if _, supplied := values["status"]; !supplied {
preview := current
_ = decodeValues(values, &preview)
values["status"] = initialProductStatus(preview)
}
}
}
ownershipChanged := ownershipValuesChanged(current, values)
if ownershipChanged && !productOwnerActions[action] {
return errors.New("invalid ownership action")
}
if err := tx.Model(&current).Updates(values).Error; err != nil {
return err
}
if !ownershipChanged {
return nil
}
if err := tx.Where("id = ?", current.ID).First(&current).Error; err != nil {
return err
}
return tx.Create(newProductOwner(current, action, reason, remark, operatorIdentity, operatorName, time.Now())).Error
})
if err != nil {
respondRecordError(ctx, err)
return
}
infra.Response.Success(ctx, gin.H{"updated": true})
}
func ProductOwnerHandlers(relations ...ResourceRelation) (gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc) {
fields := []string{"action", "occurred_at", "reason", "remark"}
return listProductOwners,
func(ctx *gin.Context) { createProductOwner(ctx, fields, relations) },
func(ctx *gin.Context) { getResource(ctx, &models.ProductOwner{}) }
}
func listProductOwners(ctx *gin.Context) {
page, size := pageSize(ctx)
var list []models.ProductOwner
var total int64
query := applyKeywordFilter(ctx, impl.DBService.Model(&models.ProductOwner{}), &models.ProductOwner{})
if err := query.Count(&total).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
if err := query.Order("occurred_at desc, id 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": response})
}
func UpdateProductInfoStatus(ctx *gin.Context) {
var request struct {
Status string `json:"status" binding:"required,max=32"`
}
if err := ctx.ShouldBindJSON(&request); err != nil || !productLifecycleStatuses[request.Status] {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
updateAllowedByIdentity(ctx, &models.ProductInfo{}, gin.H{"status": request.Status}, []string{"status"})
}
func ProductRepairHandlers(relations ...ResourceRelation) (gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc) {
fields := []string{"repair_no", "repair_type", "started_at", "completed_at", "result", "target_status", "content", "operator", "remark"}
return func(ctx *gin.Context) { listResource(ctx, &models.ProductRepair{}) },
func(ctx *gin.Context) { createProductRepair(ctx, fields, relations) },
func(ctx *gin.Context) { getResource(ctx, &models.ProductRepair{}) },
func(ctx *gin.Context) { updateProductRepair(ctx, fields, relations) }
}
func createProductRepair(ctx *gin.Context, fields []string, relations []ResourceRelation) {
values, err := prepareResourceValues(ctx, &models.ProductRepair{}, fields, relations)
if err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
data := models.ProductRepair{Entity: newEntity("active"), Result: "pending"}
if err := decodeValues(values, &data); err != nil || data.Result != "pending" || !validRepair(data) || data.ProductInfoID == 0 || data.RepairNo == "" {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
err = impl.DBService.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&data).Error; err != nil {
return err
}
return tx.Model(&models.ProductInfo{}).Where("id = ?", data.ProductInfoID).Update("status", "repairing").Error
})
if err != nil {
infra.Response.Error(ctx, err)
return
}
respondCreatedResource(ctx, &data)
}
func updateProductRepair(ctx *gin.Context, fields []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, fields, relations, false)
if err != nil || len(values) == 0 {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
err = impl.DBService.Transaction(func(tx *gorm.DB) error {
var current models.ProductRepair
if err := tx.Where("identity = ?", ctx.Param("identity")).First(&current).Error; err != nil {
return err
}
if current.Result != "pending" {
if len(values) != 1 {
return errors.New("completed repair is locked")
}
if _, ok := values["remark"]; !ok {
return errors.New("only remark can be updated")
}
}
preview := current
if err := decodeValues(values, &preview); err != nil || !validRepair(preview) {
return errors.New("invalid repair")
}
if err := tx.Model(&current).Updates(values).Error; err != nil {
return err
}
if current.Result == "pending" && preview.Result != "pending" {
return tx.Model(&models.ProductInfo{}).Where("id = ?", preview.ProductInfoID).Update("status", preview.TargetStatus).Error
}
return nil
})
if err != nil {
respondRecordError(ctx, err)
return
}
infra.Response.Success(ctx, gin.H{"updated": true})
}
func validRepair(repair models.ProductRepair) bool {
if repair.StartedAt.IsZero() {
return false
}
switch repair.Result {
case "pending":
return repair.CompletedAt == nil
case "passed", "failed":
return repair.CompletedAt != nil && !repair.CompletedAt.Before(repair.StartedAt) &&
productLifecycleStatuses[repair.TargetStatus] && repair.TargetStatus != "repairing"
default:
return false
}
}
func createProductOwner(ctx *gin.Context, fields []string, relations []ResourceRelation) {
values, err := prepareResourceValues(ctx, &models.ProductOwner{}, fields, relations)
if err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
action, _ := values["action"].(string)
if action != "manual" {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
data := models.ProductOwner{Entity: newEntity("recorded")}
if err := decodeValues(values, &data); err != nil || data.ProductInfoID == 0 || data.OccurredAt.IsZero() {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
data.OperatorIdentity, data.OperatorName = productOperator(ctx)
if err := impl.DBService.Create(&data).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
respondCreatedResource(ctx, &data)
}
func validParamsText(value any) bool {
if value == nil {
return true
}
text, ok := value.(string)
if !ok || strings.TrimSpace(text) == "" {
return false
}
var object map[string]any
return json.Unmarshal([]byte(text), &object) == nil && object != nil
}
func decodeValues(values map[string]any, target any) error {
encoded, err := json.Marshal(values)
if err != nil {
return err
}
return json.Unmarshal(encoded, target)
}
func initialProductStatus(product models.ProductInfo) string {
if product.WarehouseID != 0 {
return "in_stock"
}
if product.UserAccountID != 0 {
return "in_use"
}
return "pending"
}
func ownershipValuesChanged(current models.ProductInfo, values map[string]any) bool {
for key, old := range map[string]uint64{
"warehouse_id": current.WarehouseID, "gas_basic_id": current.GasBasicID,
"delivery_basic_id": current.DeliveryBasicID, "user_account_id": current.UserAccountID,
} {
if raw, ok := values[key]; ok {
if next, ok := numericID(raw); ok && next != old {
return true
}
}
}
return false
}
func numericID(value any) (uint64, bool) {
switch raw := value.(type) {
case uint64:
return raw, true
case float64:
return uint64(raw), true
default:
return 0, false
}
}
func newProductOwner(product models.ProductInfo, action, reason, remark, operatorIdentity, operatorName string, occurredAt time.Time) *models.ProductOwner {
return &models.ProductOwner{
Entity: models.Entity{Identity: models.NewIdentity(), Status: "recorded", Version: 1},
ProductInfoID: product.ID, WarehouseID: product.WarehouseID, GasBasicID: product.GasBasicID,
DeliveryBasicID: product.DeliveryBasicID, UserAccountID: product.UserAccountID,
Action: action, OccurredAt: occurredAt, Reason: reason, Remark: remark,
OperatorIdentity: operatorIdentity, OperatorName: operatorName,
}
}
func productOperator(ctx *gin.Context) (string, string) {
claims, err := middleware.ParseAuth(ctx)
if err != nil {
return "", ""
}
var account models.PlatfromAccount
if err := impl.DBService.Select("display_name").Where("identity = ?", claims.Identity).First(&account).Error; err != nil {
return claims.Identity, ""
}
return claims.Identity, account.DisplayName
}

View File

@@ -0,0 +1,60 @@
package platform
import (
"testing"
"time"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
)
func TestValidParamsTextRequiresJSONObjectText(t *testing.T) {
for _, value := range []any{`{"capacity":15}`, `{}`} {
if !validParamsText(value) {
t.Fatalf("valid JSON object text was rejected: %v", value)
}
}
for _, value := range []any{"", "[]", `"text"`, "{", 1} {
if validParamsText(value) {
t.Fatalf("invalid params text was accepted: %v", value)
}
}
}
func TestInitialProductStatusPrefersActualWarehouse(t *testing.T) {
if got := initialProductStatus(models.ProductInfo{WarehouseID: 1, UserAccountID: 2}); got != "in_stock" {
t.Fatalf("warehouse product status = %s", got)
}
if got := initialProductStatus(models.ProductInfo{UserAccountID: 2}); got != "in_use" {
t.Fatalf("user product status = %s", got)
}
if got := initialProductStatus(models.ProductInfo{}); got != "pending" {
t.Fatalf("unlocated product status = %s", got)
}
}
func TestValidRepairEnforcesCompletionRules(t *testing.T) {
started := time.Now()
completed := started.Add(time.Hour)
if !validRepair(models.ProductRepair{StartedAt: started, Result: "pending"}) {
t.Fatal("pending repair was rejected")
}
if !validRepair(models.ProductRepair{StartedAt: started, CompletedAt: &completed, Result: "passed", TargetStatus: "in_stock"}) {
t.Fatal("completed repair was rejected")
}
if validRepair(models.ProductRepair{StartedAt: started, Result: "passed", TargetStatus: "in_stock"}) {
t.Fatal("completed result without completion time was accepted")
}
if validRepair(models.ProductRepair{StartedAt: completed, CompletedAt: &started, Result: "failed", TargetStatus: "in_stock"}) {
t.Fatal("completion before start was accepted")
}
}
func TestOwnershipValuesChangedOnlyForActualDifference(t *testing.T) {
current := models.ProductInfo{WarehouseID: 1, GasBasicID: 2}
if ownershipValuesChanged(current, map[string]any{"warehouse_id": uint64(1)}) {
t.Fatal("unchanged warehouse produced a history record")
}
if !ownershipValuesChanged(current, map[string]any{"gas_basic_id": uint64(3)}) {
t.Fatal("changed ownership was not detected")
}
}

View File

@@ -13,6 +13,7 @@ const (
Writable ResourceMode = "writable" Writable ResourceMode = "writable"
ReadOnly ResourceMode = "readonly" ReadOnly ResourceMode = "readonly"
AppendOnly ResourceMode = "append_only" AppendOnly ResourceMode = "append_only"
Editable ResourceMode = "editable"
) )
// ResourceContract is the expected cross-layer representation of one resource. // ResourceContract is the expected cross-layer representation of one resource.
@@ -41,6 +42,8 @@ func (d ResourceDefinition) Allows(method string) bool {
return method == http.MethodGet return method == http.MethodGet
case AppendOnly: case AppendOnly:
return method == http.MethodGet || method == http.MethodPost return method == http.MethodGet || method == http.MethodPost
case Editable:
return method == http.MethodGet || method == http.MethodPost || method == http.MethodPut || method == http.MethodPatch
case Writable: case Writable:
return method == http.MethodGet || method == http.MethodPost || method == http.MethodPut || method == http.MethodPatch || method == http.MethodDelete return method == http.MethodGet || method == http.MethodPost || method == http.MethodPut || method == http.MethodPatch || method == http.MethodDelete
default: default:
@@ -74,7 +77,7 @@ func ExpectedResources() []ResourceContract {
resourceContract("delivery", "delivery_basic", Writable, "list"), resourceContract("delivery", "delivery_account", Writable, "list"), resourceContract("delivery", "delivery_basic", Writable, "list"), resourceContract("delivery", "delivery_account", Writable, "list"),
resourceContract("staff", "staff_account", Writable, "list"), resourceContract("staff", "staff_credential", Writable, "list"), resourceContract("staff", "staff_account", Writable, "list"), resourceContract("staff", "staff_credential", Writable, "list"),
resourceContract("user", "user_account", Writable, "list"), resourceContract("user", "user_address", Writable, "list"), resourceContract("user", "user_service_relation", Writable, "list"), resourceContract("user", "user_account", Writable, "list"), resourceContract("user", "user_address", Writable, "list"), resourceContract("user", "user_service_relation", Writable, "list"),
resourceContract("device", "dev_smart_cylinder_valve", Writable, "list"), resourceContract("device", "dev_device_binding", Writable, "list"), resourceContract("device", "dev_telemetry", ReadOnly, "list"), resourceContract("product", "product_type", Editable, "list"), resourceContract("product", "product_warehouse", Editable, "list"), resourceContract("product", "product_info", Editable, "list"), resourceContract("product", "product_repair", Editable, "list"), resourceContract("product", "product_owner", AppendOnly, "list"),
resourceContract("ec", "ec_category", Writable, "list"), resourceContract("ec", "ec_product", Writable, "list"), resourceContract("ec", "ec_product_attribute", Writable, "list"), resourceContract("ec", "ec_product_image", Writable, "list"), resourceContract("ec", "ec_cart", Writable, "list"), resourceContract("ec", "ec_order", Writable, "list"), resourceContract("ec", "ec_order_item", Writable, "list"), resourceContract("ec", "ec_review", Writable, "list"), resourceContract("ec", "ec_category", Writable, "list"), resourceContract("ec", "ec_product", Writable, "list"), resourceContract("ec", "ec_product_attribute", Writable, "list"), resourceContract("ec", "ec_product_image", Writable, "list"), resourceContract("ec", "ec_cart", Writable, "list"), resourceContract("ec", "ec_order", Writable, "list"), resourceContract("ec", "ec_order_item", Writable, "list"), resourceContract("ec", "ec_review", Writable, "list"),
resourceContract("delivery", "delivery_task", Writable, "list"), resourceContract("delivery", "delivery_track", Writable, "list"), resourceContract("delivery", "delivery_track_point", ReadOnly, "list"), resourceContract("delivery", "delivery_task", Writable, "list"), resourceContract("delivery", "delivery_track", Writable, "list"), resourceContract("delivery", "delivery_track_point", ReadOnly, "list"),
resourceContract("finance", "fin_payment", Writable, "list"), resourceContract("finance", "fin_settlement", Writable, "list"), resourceContract("finance", "fin_reconciliation", Writable, "list"), resourceContract("finance", "fin_payment", Writable, "list"), resourceContract("finance", "fin_settlement", Writable, "list"), resourceContract("finance", "fin_reconciliation", Writable, "list"),
@@ -89,6 +92,9 @@ func resourceContract(domain, name string, mode ResourceMode, pageKind string) R
} }
func resourcePath(domain, name string) string { func resourcePath(domain, name string) string {
if domain == "product" {
return "/" + name
}
switch name { switch name {
case "staff_account": case "staff_account":
return "/staff/account" return "/staff/account"

View File

@@ -28,6 +28,8 @@ func TestExpectedResources(t *testing.T) {
assertContract(t, ExpectedResources(), "ec", "ec_order_item", Writable, "list") assertContract(t, ExpectedResources(), "ec", "ec_order_item", Writable, "list")
assertContract(t, ExpectedResources(), "wallet", "wallet_ledger", ReadOnly, "list") assertContract(t, ExpectedResources(), "wallet", "wallet_ledger", ReadOnly, "list")
assertContract(t, ExpectedResources(), "delivery", "delivery_track_point", ReadOnly, "list") assertContract(t, ExpectedResources(), "delivery", "delivery_track_point", ReadOnly, "list")
assertContract(t, ExpectedResources(), "product", "product_info", Editable, "list")
assertContract(t, ExpectedResources(), "product", "product_owner", AppendOnly, "list")
} }
func TestContentUsesCMSResourceAndRemovedDomainsAreAbsent(t *testing.T) { func TestContentUsesCMSResourceAndRemovedDomainsAreAbsent(t *testing.T) {
@@ -91,6 +93,11 @@ func TestResourceDefinitionAllowsOnlyMethodsForEachMode(t *testing.T) {
{"append-only GET", AppendOnly, http.MethodGet, true}, {"append-only GET", AppendOnly, http.MethodGet, true},
{"append-only POST", AppendOnly, http.MethodPost, true}, {"append-only POST", AppendOnly, http.MethodPost, true},
{"append-only PUT", AppendOnly, http.MethodPut, false}, {"append-only PUT", AppendOnly, http.MethodPut, false},
{"editable GET", Editable, http.MethodGet, true},
{"editable POST", Editable, http.MethodPost, true},
{"editable PUT", Editable, http.MethodPut, true},
{"editable PATCH", Editable, http.MethodPatch, true},
{"editable DELETE", Editable, http.MethodDelete, false},
{"writable GET", Writable, http.MethodGet, true}, {"writable GET", Writable, http.MethodGet, true},
{"writable POST", Writable, http.MethodPost, true}, {"writable POST", Writable, http.MethodPost, true},
{"writable PUT", Writable, http.MethodPut, true}, {"writable PUT", Writable, http.MethodPut, true},

View File

@@ -299,7 +299,14 @@ func resolveResourceRelations(input map[string]any, allowedFields []string, rela
continue continue
} }
identity, ok := raw.(string) identity, ok := raw.(string)
if !ok || strings.TrimSpace(identity) == "" { 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") return nil, errors.New("invalid relation identity")
} }
id, err := resolveIdentityID(relation.Model, identity, true) id, err := resolveIdentityID(relation.Model, identity, true)
@@ -366,21 +373,23 @@ func publicResourceResponse(value any) (any, error) {
} }
var relationIdentityModels = map[string]any{ var relationIdentityModels = map[string]any{
"gas_basic_id": &models.GasBasic{}, "gas_basic_id": &models.GasBasic{},
"gas_station_id": &models.GasBasic{}, "gas_station_id": &models.GasBasic{},
"delivery_basic_id": &models.DeliveryBasic{}, "delivery_basic_id": &models.DeliveryBasic{},
"delivery_point_id": &models.DeliveryBasic{}, "delivery_point_id": &models.DeliveryBasic{},
"user_account_id": &models.UserAccount{}, "user_account_id": &models.UserAccount{},
"staff_account_id": &models.StaffAccount{}, "staff_account_id": &models.StaffAccount{},
"smart_cylinder_valve_id": &models.DevSmartCylinderValve{}, "product_type_id": &models.ProductType{},
"ec_category_id": &models.EcCategory{}, "product_info_id": &models.ProductInfo{},
"ec_product_id": &models.EcProduct{}, "warehouse_id": &models.ProductWarehouse{},
"ec_order_id": &models.EcOrder{}, "ec_category_id": &models.EcCategory{},
"delivery_task_id": &models.DeliveryTask{}, "ec_product_id": &models.EcProduct{},
"delivery_track_id": &models.DeliveryTrack{}, "ec_order_id": &models.EcOrder{},
"platform_role_id": &models.PlatformRole{}, "delivery_task_id": &models.DeliveryTask{},
"platform_menu_id": &models.PlatformMenu{}, "delivery_track_id": &models.DeliveryTrack{},
"wallet_id": &models.Wallet{}, "platform_role_id": &models.PlatformRole{},
"platform_menu_id": &models.PlatformMenu{},
"wallet_id": &models.Wallet{},
} }
var relationIdentityKeys = map[string]string{ var relationIdentityKeys = map[string]string{

View File

@@ -1,18 +0,0 @@
package models
import (
"git.apinb.com/bsm-sdk/core/database"
"time"
)
// DevDeviceBinding 对应 dev_device_binding保存设备授权绑定。
type DevDeviceBinding struct {
Entity // 公共实体字段
SmartCylinderValveID uint64 `gorm:"column:smart_cylinder_valve_id;not null;index" json:"smart_cylinder_valve_id"` // smart_cylinder_valve_id 业务字段
UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` // user_account_id 业务字段
EffectiveAt time.Time `gorm:"column:effective_at;type:timestamptz;not null" json:"effective_at"` // effective_at 业务字段
ExpiredAt *time.Time `gorm:"column:expired_at;type:timestamptz" json:"expired_at"` // expired_at 业务字段
}
func init() { database.AppendMigrate(&DevDeviceBinding{}) }
func (table *DevDeviceBinding) TableName() string { return "dev_device_binding" }

View File

@@ -1,15 +0,0 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// DevSmartCylinderValve 对应 dev_smart_cylinder_valve保存智能瓶阀档案。
type DevSmartCylinderValve struct {
Entity // 公共实体字段
DeviceNo string `gorm:"column:device_no;type:varchar(64);not null;uniqueIndex" json:"device_no"` // device_no 业务字段
Model string `gorm:"column:model;type:varchar(64);not null;default:''" json:"model"` // model 业务字段
OnlineStatus string `gorm:"column:online_status;type:varchar(32);not null;default:'offline'" json:"online_status"` // online_status 业务字段
OwnerIdentity string `gorm:"column:owner_identity;type:varchar(36);not null;default:'';index" json:"owner_identity"` // owner_identity 业务字段
}
func init() { database.AppendMigrate(&DevSmartCylinderValve{}) }
func (table *DevSmartCylinderValve) TableName() string { return "dev_smart_cylinder_valve" }

View File

@@ -1,18 +0,0 @@
package models
import (
"git.apinb.com/bsm-sdk/core/database"
"time"
)
// DevTelemetry 对应 dev_telemetry保存设备遥测摘要。
type DevTelemetry struct {
Entity // 公共实体字段
SmartCylinderValveIdentity string `gorm:"column:smart_cylinder_valve_identity;type:varchar(36);not null;index" json:"smart_cylinder_valve_identity"` // smart_cylinder_valve_identity 业务字段
ReportedAt time.Time `gorm:"column:reported_at;type:timestamptz;not null;index" json:"reported_at"` // reported_at 业务字段
Payload string `gorm:"column:payload;type:text;not null;default:''" json:"payload"` // payload 业务字段
QualityFlag string `gorm:"column:quality_flag;type:varchar(32);not null;default:'normal'" json:"quality_flag"` // quality_flag 业务字段
}
func init() { database.AppendMigrate(&DevTelemetry{}) }
func (table *DevTelemetry) TableName() string { return "dev_telemetry" }

View File

@@ -0,0 +1,26 @@
package models
import (
"time"
"git.apinb.com/bsm-sdk/core/database"
)
// ProductInfo 对应 product_info保存一物一码的实体产品档案。
type ProductInfo struct {
Entity // 公共实体字段
Code string `gorm:"column:code;type:varchar(64);not null;uniqueIndex" json:"code"` // 产品唯一标识
Name string `gorm:"column:name;type:varchar(128);not null" json:"name"` // 产品名称
ProductTypeID uint64 `gorm:"column:product_type_id;not null;index" json:"product_type_id"` // 产品类型自增主键
Params string `gorm:"column:params;type:text;not null;default:'{}'" json:"params"` // 产品参数 JSON 对象文本
WarehouseID uint64 `gorm:"column:warehouse_id;not null;default:0;index" json:"warehouse_id"` // 当前实际库房自增主键
GasBasicID uint64 `gorm:"column:gas_basic_id;not null;default:0;index" json:"gas_basic_id"` // 当前归属气站自增主键
DeliveryBasicID uint64 `gorm:"column:delivery_basic_id;not null;default:0;index" json:"delivery_basic_id"` // 当前归属配送站自增主键
UserAccountID uint64 `gorm:"column:user_account_id;not null;default:0;index" json:"user_account_id"` // 当前归属用户自增主键
ProducedAt time.Time `gorm:"column:produced_at;type:timestamptz;not null" json:"produced_at"` // 生产时间
IsEnabled bool `gorm:"column:is_enabled;not null;default:false" json:"is_enabled"` // 是否启用
EnabledAt *time.Time `gorm:"column:enabled_at;type:timestamptz" json:"enabled_at"` // 首次启用时间
}
func init() { database.AppendMigrate(&ProductInfo{}) }
func (table *ProductInfo) TableName() string { return "product_info" }

View File

@@ -0,0 +1,26 @@
package models
import (
"time"
"git.apinb.com/bsm-sdk/core/database"
)
// ProductOwner 对应 product_owner保存产品库房与责任归属的不可变快照。
type ProductOwner struct {
Entity // 公共实体字段
ProductInfoID uint64 `gorm:"column:product_info_id;not null;index" json:"product_info_id"` // 产品档案自增主键
WarehouseID uint64 `gorm:"column:warehouse_id;not null;default:0;index" json:"warehouse_id"` // 当前实际库房自增主键快照
GasBasicID uint64 `gorm:"column:gas_basic_id;not null;default:0;index" json:"gas_basic_id"` // 当前归属气站自增主键快照
DeliveryBasicID uint64 `gorm:"column:delivery_basic_id;not null;default:0;index" json:"delivery_basic_id"` // 当前归属配送站自增主键快照
UserAccountID uint64 `gorm:"column:user_account_id;not null;default:0;index" json:"user_account_id"` // 当前归属用户自增主键快照
Action string `gorm:"column:action;type:varchar(32);not null" json:"action"` // 归属变更动作
OccurredAt time.Time `gorm:"column:occurred_at;type:timestamptz;not null;index" json:"occurred_at"` // 实际发生时间
Reason string `gorm:"column:reason;type:text;not null;default:''" json:"reason"` // 变更原因
Remark string `gorm:"column:remark;type:text;not null;default:''" json:"remark"` // 备注
OperatorIdentity string `gorm:"column:operator_identity;type:varchar(36);not null;default:'';index" json:"operator_identity"` // 操作者业务标识
OperatorName string `gorm:"column:operator_name;type:varchar(64);not null;default:''" json:"operator_name"` // 操作者姓名快照
}
func init() { database.AppendMigrate(&ProductOwner{}) }
func (table *ProductOwner) TableName() string { return "product_owner" }

View File

@@ -0,0 +1,25 @@
package models
import (
"time"
"git.apinb.com/bsm-sdk/core/database"
)
// ProductRepair 对应 product_repair保存产品检修过程与结果。
type ProductRepair struct {
Entity // 公共实体字段
ProductInfoID uint64 `gorm:"column:product_info_id;not null;index" json:"product_info_id"` // 产品档案自增主键
RepairNo string `gorm:"column:repair_no;type:varchar(64);not null;uniqueIndex" json:"repair_no"` // 检修单号
RepairType string `gorm:"column:repair_type;type:varchar(32);not null" json:"repair_type"` // 检修类型
StartedAt time.Time `gorm:"column:started_at;type:timestamptz;not null" json:"started_at"` // 开始时间
CompletedAt *time.Time `gorm:"column:completed_at;type:timestamptz" json:"completed_at"` // 完成时间
Result string `gorm:"column:result;type:varchar(32);not null;default:'pending'" json:"result"` // 检修结果
TargetStatus string `gorm:"column:target_status;type:varchar(32);not null;default:''" json:"target_status"` // 完成后的产品状态
Content string `gorm:"column:content;type:text;not null;default:''" json:"content"` // 检修内容
Operator string `gorm:"column:operator;type:varchar(64);not null;default:''" json:"operator"` // 检修人员
Remark string `gorm:"column:remark;type:text;not null;default:''" json:"remark"` // 备注
}
func init() { database.AppendMigrate(&ProductRepair{}) }
func (table *ProductRepair) TableName() string { return "product_repair" }

View File

@@ -0,0 +1,13 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// ProductType 对应 product_type保存实体产品的类型与规格名称。
type ProductType struct {
Entity // 公共实体字段
Code string `gorm:"column:code;type:varchar(64);not null;uniqueIndex" json:"code"` // 产品类型编码
Name string `gorm:"column:name;type:varchar(128);not null" json:"name"` // 产品类型名称
}
func init() { database.AppendMigrate(&ProductType{}) }
func (table *ProductType) TableName() string { return "product_type" }

View File

@@ -0,0 +1,17 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// ProductWarehouse 对应 product_warehouse保存独立库房档案。
type ProductWarehouse struct {
Entity // 公共实体字段
Code string `gorm:"column:code;type:varchar(64);not null;uniqueIndex" json:"code"` // 库房编码
Name string `gorm:"column:name;type:varchar(128);not null" json:"name"` // 库房名称
Address string `gorm:"column:address;type:varchar(255);not null;default:''" json:"address"` // 库房地址
Manager string `gorm:"column:manager;type:varchar(64);not null;default:''" json:"manager"` // 库房负责人
Phone string `gorm:"column:phone;type:varchar(32);not null;default:''" json:"phone"` // 联系电话
IsEnabled bool `gorm:"column:is_enabled;not null;default:false" json:"is_enabled"` // 是否启用
}
func init() { database.AppendMigrate(&ProductWarehouse{}) }
func (table *ProductWarehouse) TableName() string { return "product_warehouse" }

View File

@@ -27,7 +27,7 @@ func RegisterPlatform(serviceKey string, engine *gin.Engine) {
registerDeliveryRoute(protected) registerDeliveryRoute(protected)
registerStaffRoute(protected) registerStaffRoute(protected)
registerUserRoute(protected) registerUserRoute(protected)
registerDeviceRoute(protected) registerProductRoute(protected)
registerCommerceRoute(protected) registerCommerceRoute(protected)
registerFinanceRoute(protected) registerFinanceRoute(protected)
registerContentRoute(protected) registerContentRoute(protected)
@@ -50,13 +50,41 @@ func registerDeliveryRoute(group *gin.RouterGroup) {
registerReadOnlyResource(group, "/delivery/delivery_track_point", &models.DeliveryTrackPoint{}) registerReadOnlyResource(group, "/delivery/delivery_track_point", &models.DeliveryTrackPoint{})
} }
func registerDeviceRoute(group *gin.RouterGroup) { func registerProductRoute(group *gin.RouterGroup) {
registerRestrictedWritableResource(group, "/device/dev_smart_cylinder_valve", &models.DevSmartCylinderValve{}, []string{"device_no", "model", "online_status", "owner_identity"}) registerRestrictedNoDeleteResource(group, "/product_type", &models.ProductType{}, []string{"code", "name"})
registerRestrictedWritableResource(group, "/device/dev_device_binding", &models.DevDeviceBinding{}, []string{"effective_at", "expired_at"}, requiredRelation("smart_cylinder_valve_identity", "smart_cylinder_valve_id", &models.DevSmartCylinderValve{}), requiredRelation("user_account_identity", "user_account_id", &models.UserAccount{})) registerRestrictedNoDeleteResource(group, "/product_warehouse", &models.ProductWarehouse{}, []string{"code", "name", "address", "manager", "phone", "is_enabled"})
list, _, get, _ := platform.ResourceHandlers(&models.DevTelemetry{}, nil, nil)
telemetry := group.Group("/device/dev_telemetry") infoRelations := []platform.ResourceRelation{
telemetry.GET("", list) requiredRelation("product_type_identity", "product_type_id", &models.ProductType{}),
telemetry.GET("/:identity", get) optionalRelation("warehouse_identity", "warehouse_id", &models.ProductWarehouse{}),
optionalRelation("gas_basic_identity", "gas_basic_id", &models.GasBasic{}),
optionalRelation("delivery_basic_identity", "delivery_basic_id", &models.DeliveryBasic{}),
optionalRelation("user_account_identity", "user_account_id", &models.UserAccount{}),
}
infoList, infoCreate, infoGet, infoUpdate := platform.ProductInfoHandlers(infoRelations...)
info := group.Group("/product_info")
info.GET("", infoList)
info.POST("", infoCreate)
info.GET("/:identity", infoGet)
info.PUT("/:identity", infoUpdate)
info.PATCH("/:identity/status", platform.UpdateProductInfoStatus)
repairList, repairCreate, repairGet, repairUpdate := platform.ProductRepairHandlers(
requiredRelation("product_info_identity", "product_info_id", &models.ProductInfo{}),
)
registerNoDeleteResource(group, "/product_repair", repairList, repairCreate, repairGet, repairUpdate, &models.ProductRepair{})
ownerList, ownerCreate, ownerGet := platform.ProductOwnerHandlers(
requiredRelation("product_info_identity", "product_info_id", &models.ProductInfo{}),
optionalRelation("warehouse_identity", "warehouse_id", &models.ProductWarehouse{}),
optionalRelation("gas_basic_identity", "gas_basic_id", &models.GasBasic{}),
optionalRelation("delivery_basic_identity", "delivery_basic_id", &models.DeliveryBasic{}),
optionalRelation("user_account_identity", "user_account_id", &models.UserAccount{}),
)
owner := group.Group("/product_owner")
owner.GET("", ownerList)
owner.POST("", ownerCreate)
owner.GET("/:identity", ownerGet)
} }
func registerCommerceRoute(group *gin.RouterGroup) { func registerCommerceRoute(group *gin.RouterGroup) {
@@ -138,6 +166,20 @@ func registerRestrictedWritableResource(group *gin.RouterGroup, path string, mod
registerWritableResource(group, path, list, create, get, update, model) registerWritableResource(group, path, list, create, get, update, model)
} }
func registerNoDeleteResource(group *gin.RouterGroup, path string, list, create, get, update gin.HandlerFunc, model any) {
resource := group.Group(path)
resource.GET("", list)
resource.POST("", create)
resource.GET("/:identity", get)
resource.PUT("/:identity", update)
resource.PATCH("/:identity/status", func(ctx *gin.Context) { platform.UpdateRecordStatus(ctx, model) })
}
func registerRestrictedNoDeleteResource(group *gin.RouterGroup, path string, model any, fields []string, relations ...platform.ResourceRelation) {
list, create, get, update := platform.ResourceHandlers(model, fields, fields, relations...)
registerNoDeleteResource(group, path, list, create, get, update, model)
}
func registerReadOnlyResource(group *gin.RouterGroup, path string, model any) { func registerReadOnlyResource(group *gin.RouterGroup, path string, model any) {
list, _, get, _ := platform.ResourceHandlers(model, nil, nil) list, _, get, _ := platform.ResourceHandlers(model, nil, nil)
resource := group.Group(path) resource := group.Group(path)

View File

@@ -29,6 +29,12 @@ func TestEveryContractHasRegisteredRoute(t *testing.T) {
case platform.AppendOnly: case platform.AppendOnly:
assertRouteMethods(t, routes, path, http.MethodGet, http.MethodPost) assertRouteMethods(t, routes, path, http.MethodGet, http.MethodPost)
assertNoRouteMethods(t, routes, path, http.MethodPut, http.MethodPatch, http.MethodDelete) assertNoRouteMethods(t, routes, path, http.MethodPut, http.MethodPatch, http.MethodDelete)
case platform.Editable:
assertRouteMethods(t, routes, path, http.MethodGet, http.MethodPost)
assertRouteMethods(t, routes, path+"/:identity", http.MethodGet, http.MethodPut)
assertRouteMethods(t, routes, path+"/:identity/status", http.MethodPatch)
assertNoRouteMethods(t, routes, path, http.MethodDelete)
assertNoRouteMethods(t, routes, path+"/:identity", http.MethodDelete)
default: default:
assertRouteMethods(t, routes, path, http.MethodGet, http.MethodPost) assertRouteMethods(t, routes, path, http.MethodGet, http.MethodPost)
assertRouteMethods(t, routes, path+"/:identity", http.MethodGet, http.MethodPut, http.MethodDelete) assertRouteMethods(t, routes, path+"/:identity", http.MethodGet, http.MethodPut, http.MethodDelete)
@@ -87,7 +93,7 @@ func TestPlatformOrganizationAndAccountRoutesExposeResourceCRUD(t *testing.T) {
assertRouteMethods(t, routes, "/heqi/platform/v1/platform/platform_role/:identity/menus", http.MethodPut) assertRouteMethods(t, routes, "/heqi/platform/v1/platform/platform_role/:identity/menus", http.MethodPut)
} }
func TestPlatformDeviceCommerceAndDeliveryRoutesFollowTheirContracts(t *testing.T) { func TestPlatformProductCommerceAndDeliveryRoutesFollowTheirContracts(t *testing.T) {
engine := gin.New() engine := gin.New()
RegisterPlatform("heqi", engine) RegisterPlatform("heqi", engine)
@@ -100,7 +106,6 @@ func TestPlatformDeviceCommerceAndDeliveryRoutesFollowTheirContracts(t *testing.
} }
for _, resource := range []string{ for _, resource := range []string{
"/device/dev_smart_cylinder_valve", "/device/dev_device_binding",
"/ec/ec_category", "/ec/ec_product", "/ec/ec_product_attribute", "/ec/ec_product_image", "/ec/ec_cart", "/ec/ec_order", "/ec/ec_order_item", "/ec/ec_review", "/ec/ec_category", "/ec/ec_product", "/ec/ec_product_attribute", "/ec/ec_product_image", "/ec/ec_cart", "/ec/ec_order", "/ec/ec_order_item", "/ec/ec_review",
"/delivery/delivery_task", "/delivery/delivery_track", "/delivery/delivery_task", "/delivery/delivery_track",
} { } {
@@ -116,14 +121,17 @@ func TestPlatformDeviceCommerceAndDeliveryRoutesFollowTheirContracts(t *testing.
assertNoRouteMethods(t, routes, trackPoint+"/:identity", http.MethodPut, http.MethodDelete) assertNoRouteMethods(t, routes, trackPoint+"/:identity", http.MethodPut, http.MethodDelete)
assertNoRouteMethods(t, routes, trackPoint+"/:identity/status", http.MethodPatch) assertNoRouteMethods(t, routes, trackPoint+"/:identity/status", http.MethodPatch)
telemetry := "/heqi/platform/v1/device/dev_telemetry" for _, resource := range []string{"/product_type", "/product_warehouse", "/product_info", "/product_repair"} {
assertRouteMethods(t, routes, telemetry, http.MethodGet) path := "/heqi/platform/v1" + resource
assertRouteMethods(t, routes, telemetry+"/:identity", http.MethodGet) assertRouteMethods(t, routes, path, http.MethodGet, http.MethodPost)
for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} { assertRouteMethods(t, routes, path+"/:identity", http.MethodGet, http.MethodPut)
if routes[telemetry][method] || routes[telemetry+"/:identity"][method] { assertRouteMethods(t, routes, path+"/:identity/status", http.MethodPatch)
t.Errorf("telemetry unexpectedly permits %s", method) assertNoRouteMethods(t, routes, path+"/:identity", http.MethodDelete)
}
} }
owner := "/heqi/platform/v1/product_owner"
assertRouteMethods(t, routes, owner, http.MethodGet, http.MethodPost)
assertRouteMethods(t, routes, owner+"/:identity", http.MethodGet)
assertNoRouteMethods(t, routes, owner+"/:identity", http.MethodPut, http.MethodPatch, http.MethodDelete)
for _, resource := range []string{ for _, resource := range []string{
"/safety/safe_rule", "/safety/safe_event", "/safety/safe_inspection", "/safety/safe_rule", "/safety/safe_event", "/safety/safe_inspection",