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"
ReadOnly ResourceMode = "readonly"
AppendOnly ResourceMode = "append_only"
Editable ResourceMode = "editable"
)
// 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
case AppendOnly:
return method == http.MethodGet || method == http.MethodPost
case Editable:
return method == http.MethodGet || method == http.MethodPost || method == http.MethodPut || method == http.MethodPatch
case Writable:
return method == http.MethodGet || method == http.MethodPost || method == http.MethodPut || method == http.MethodPatch || method == http.MethodDelete
default:
@@ -74,7 +77,7 @@ func ExpectedResources() []ResourceContract {
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("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("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"),
@@ -89,6 +92,9 @@ func resourceContract(domain, name string, mode ResourceMode, pageKind string) R
}
func resourcePath(domain, name string) string {
if domain == "product" {
return "/" + name
}
switch name {
case "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(), "wallet", "wallet_ledger", 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) {
@@ -91,6 +93,11 @@ func TestResourceDefinitionAllowsOnlyMethodsForEachMode(t *testing.T) {
{"append-only GET", AppendOnly, http.MethodGet, true},
{"append-only POST", AppendOnly, http.MethodPost, true},
{"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 POST", Writable, http.MethodPost, true},
{"writable PUT", Writable, http.MethodPut, true},

View File

@@ -299,7 +299,14 @@ func resolveResourceRelations(input map[string]any, allowedFields []string, rela
continue
}
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")
}
id, err := resolveIdentityID(relation.Model, identity, true)
@@ -366,21 +373,23 @@ func publicResourceResponse(value any) (any, error) {
}
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{},
"wallet_id": &models.Wallet{},
"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{},
"delivery_task_id": &models.DeliveryTask{},
"delivery_track_id": &models.DeliveryTrack{},
"platform_role_id": &models.PlatformRole{},
"platform_menu_id": &models.PlatformMenu{},
"wallet_id": &models.Wallet{},
}
var relationIdentityKeys = map[string]string{