feat: add producer account management

This commit is contained in:
2026-08-04 15:21:39 +08:00
parent fb59e80fd0
commit 402f050b17
21 changed files with 223 additions and 36 deletions

View File

@@ -20,7 +20,7 @@ func TestInitPlatformAccessSeedsRootRole(t *testing.T) {
t.Fatal(err)
}
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "platform_role" WHERE role_code = $1 ORDER BY "platform_role"."id" LIMIT $2`)).
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "platform_role" WHERE role_code = $1 AND "platform_role"."deleted_at" IS NULL ORDER BY "platform_role"."id" LIMIT $2`)).
WithArgs("root", 1).
WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "status", "role_code", "name", "location_scope", "is_system"}).
AddRow(uint64(1), "root-role", 1, "root", "Root", "precise", true))

View File

@@ -1,4 +1,4 @@
package common
package common
import (
"encoding/json"
@@ -408,6 +408,7 @@ var relationIdentityModels = map[string]any{
"user_account_id": &models.UserAccount{},
"staff_account_id": &models.StaffAccount{},
"product_type_id": &models.ProductType{},
"producer_account_id": &models.ProducerAccount{},
"product_info_id": &models.ProductInfo{},
"warehouse_id": &models.ProductWarehouse{},
"ec_category_id": &models.EcCategory{},
@@ -419,7 +420,7 @@ var relationIdentityModels = map[string]any{
"gasorder_track_id": &models.GasorderTrack{},
"platform_role_id": &models.PlatformRole{},
"wallet_basic_id": &models.WalletBasic{},
"payment_order_id": &models.PaymentOrder{},
"payment_order_id": &models.PaymentOrder{},
"wallet_bank_id": &models.WalletBank{},
"related_record_id": &models.WalletRecord{},
}

View File

@@ -46,9 +46,10 @@ var PlatformMenus = [][]Menu{
},
{
{Identity: "device", GroupCode: "device", Name: "智能气阀管理", Icon: "icon-common", Path: "/product", SortNo: 60, Status: common.StatusEnable},
{Identity: "product_type", ParentIdentity: "device", GroupCode: "device", Name: "类型管理", Path: "/product/product-type", SortNo: 1, Status: common.StatusEnable},
{Identity: "product_warehouse", ParentIdentity: "device", GroupCode: "device", Name: "库房管理", Path: "/product/warehouse", SortNo: 2, Status: common.StatusEnable},
{Identity: "product_info", ParentIdentity: "device", GroupCode: "device", Name: "智能气阀管理", Path: "/product/product-info", SortNo: 3, Status: common.StatusEnable},
{Identity: "producer_account", ParentIdentity: "device", GroupCode: "device", Name: "生产商管理", Path: "/product/producers", SortNo: 1, Status: common.StatusEnable},
{Identity: "product_type", ParentIdentity: "device", GroupCode: "device", Name: "类型管理", Path: "/product/product-type", SortNo: 2, Status: common.StatusEnable},
{Identity: "product_warehouse", ParentIdentity: "device", GroupCode: "device", Name: "库房管理", Path: "/product/warehouse", SortNo: 3, Status: common.StatusEnable},
{Identity: "product_info", ParentIdentity: "device", GroupCode: "device", Name: "智能气阀管理", Path: "/product/product-info", SortNo: 4, Status: common.StatusEnable},
},
{
{Identity: "gasorder", GroupCode: "gasorder", Name: "气体配送订单管理", Icon: "icon-list", Path: "/gasorder", SortNo: 70, Status: common.StatusEnable},

View File

@@ -0,0 +1,108 @@
package product
import (
"strings"
"git.apinb.com/bsm-sdk/core/errcode"
"git.apinb.com/bsm-sdk/core/infra"
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"github.com/gin-gonic/gin"
)
type producerAccountCreateRequest struct {
ProducerCode string `json:"producer_code" binding:"required,max=64"`
Name string `json:"name" binding:"required,max=128"`
CreditCode string `json:"credit_code" binding:"max=64"`
Principal string `json:"principal" binding:"max=64"`
Phone string `json:"phone" binding:"max=32"`
Address string `json:"address" binding:"max=255"`
Username string `json:"username" binding:"required,max=64"`
Password string `json:"password" binding:"required"`
DisplayName string `json:"display_name" binding:"max=64"`
RoleCode string `json:"role_code" binding:"max=64"`
Remark string `json:"remark"`
}
type producerAccountUpdateRequest struct {
Name string `json:"name" binding:"required,max=128"`
CreditCode string `json:"credit_code" binding:"max=64"`
Principal string `json:"principal" binding:"max=64"`
Phone string `json:"phone" binding:"max=32"`
Address string `json:"address" binding:"max=255"`
DisplayName string `json:"display_name" binding:"max=64"`
RoleCode string `json:"role_code" binding:"max=64"`
Password string `json:"password"`
Remark string `json:"remark"`
}
func ListProducerAccount(ctx *gin.Context) { common.ListResource(ctx, &models.ProducerAccount{}) }
func GetProducerAccount(ctx *gin.Context) { common.GetResource(ctx, &models.ProducerAccount{}) }
func CreateProducerAccount(ctx *gin.Context) {
var request producerAccountCreateRequest
if ctx.ShouldBindJSON(&request) != nil || !common.IsValidAccountPassword(request.Password) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
hash, err := common.PasswordHash(request.Password)
if err != nil {
infra.Response.Error(ctx, err)
return
}
roleCode := strings.TrimSpace(request.RoleCode)
if roleCode == "" {
roleCode = "admin"
}
data := models.ProducerAccount{Entity: common.NewEntity(common.StatusEnable), ProducerCode: strings.TrimSpace(request.ProducerCode), Name: strings.TrimSpace(request.Name), CreditCode: strings.TrimSpace(request.CreditCode), Principal: strings.TrimSpace(request.Principal), Phone: strings.TrimSpace(request.Phone), Address: strings.TrimSpace(request.Address), Username: strings.TrimSpace(request.Username), DisplayName: strings.TrimSpace(request.DisplayName), PasswordHash: hash, RoleCode: roleCode, Remark: request.Remark}
if err := impl.DBService.Create(&data).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
common.RespondCreatedResource(ctx, data)
}
func UpdateProducerAccount(ctx *gin.Context) {
var request producerAccountUpdateRequest
if ctx.ShouldBindJSON(&request) != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
values := gin.H{"name": strings.TrimSpace(request.Name), "credit_code": strings.TrimSpace(request.CreditCode), "principal": strings.TrimSpace(request.Principal), "phone": strings.TrimSpace(request.Phone), "address": strings.TrimSpace(request.Address), "display_name": strings.TrimSpace(request.DisplayName), "role_code": strings.TrimSpace(request.RoleCode), "remark": request.Remark}
if request.Password != "" {
if !common.IsValidAccountPassword(request.Password) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
hash, err := common.PasswordHash(request.Password)
if err != nil {
infra.Response.Error(ctx, err)
return
}
values["password_hash"] = hash
}
common.UpdateAllowedByIdentity(ctx, &models.ProducerAccount{}, values, []string{"name", "credit_code", "principal", "phone", "address", "display_name", "role_code", "remark", "password_hash"})
}
func DeleteProducerAccount(ctx *gin.Context) {
var producer models.ProducerAccount
if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&producer).Error; err != nil {
common.RespondRecordError(ctx, err)
return
}
var references int64
if err := impl.DBService.Unscoped().Model(&models.ProductInfo{}).Where("producer_account_id = ?", producer.ID).Count(&references).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
if references > 0 {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
if err := impl.DBService.Delete(&producer).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, gin.H{"deleted": true})
}

View File

@@ -44,7 +44,7 @@ func createProductInfo(ctx *gin.Context, fields []string, relations []common.Res
delete(values, "reason")
delete(values, "remark")
data := models.ProductInfo{Entity: common.NewEntity(common.StatusDisable), ProductStatus: common.StatusPending, Params: "{}"}
if err := decodeValues(values, &data); err != nil || data.Code == "" || data.Name == "" || data.ProducedAt.IsZero() || !validProductOwnership(data) {
if err := decodeValues(values, &data); err != nil || data.Code == "" || data.Name == "" || !validProductProducer(data) || data.ProducedAt.IsZero() || !validProductOwnership(data) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
@@ -91,7 +91,7 @@ func updateProductInfo(ctx *gin.Context, fields []string, relations []common.Res
delete(values, "code")
}
preview := current
if err := decodeValues(values, &preview); err != nil || !validProductOwnership(preview) {
if err := decodeValues(values, &preview); err != nil || !validProductProducer(preview) || !validProductOwnership(preview) {
return errors.New("product can have at most one current owner")
}
ownershipChanged := ownershipValuesChanged(current, values)
@@ -379,6 +379,10 @@ func validProductOwnership(product models.ProductInfo) bool {
return count <= 1
}
func validProductProducer(product models.ProductInfo) bool {
return product.ProducerAccountID != 0
}
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,

View File

@@ -85,3 +85,12 @@ func TestProductHasAtMostOneCurrentOwner(t *testing.T) {
t.Fatal("conflicting warehouse and user owners were accepted")
}
}
func TestProductRequiresProducer(t *testing.T) {
if validProductProducer(models.ProductInfo{}) {
t.Fatal("product without producer was accepted")
}
if !validProductProducer(models.ProductInfo{ProducerAccountID: 1}) {
t.Fatal("product with producer was rejected")
}
}

View File

@@ -1,4 +1,4 @@
package platform
package platform
import (
"net/http"
@@ -81,7 +81,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("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", ReadOnly, "list"),
resourceContract("product", "producer_account", Writable, "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", ReadOnly, "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", ReadOnly, "list"), resourceContract("ec", "ec_order", ReadOnly, "list"), resourceContract("ec", "ec_order_item", ReadOnly, "list"), resourceContract("ec", "ec_review", ReadOnly, "list"),
resourceContract("gasorder", "gasorder_contract", Managed, "list"), resourceContract("gasorder", "gasorder_contract_product", AppendOnly, "list"), resourceContract("gasorder", "gasorder_contract_revision", ReadOnly, "list"),
resourceContract("gasorder", "gasorder_basic", AppendOnly, "list"), resourceContract("gasorder", "gasorder_item", ReadOnly, "list"), resourceContract("gasorder", "gasorder_assign", ReadOnly, "list"), resourceContract("gasorder", "gasorder_status", ReadOnly, "list"),

View File

@@ -5,15 +5,17 @@ import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// Entity 是所有主表共享字段。id 是数据库自增主键identity 是应用生成的 UUID V7 业务标识。
type Entity struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` // 数据库自增主键
Identity string `gorm:"column:identity;type:varchar(36);not null;uniqueIndex" json:"identity"` // UUID V7 业务标识
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;index" json:"created_at"` // 创建时间
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null" json:"updated_at"` // 更新时间
Status int `gorm:"column:status;not null;default:0;index" json:"status"` // 通用记录状态
ID uint64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` // 数据库自增主键
Identity string `gorm:"column:identity;type:varchar(36);not null;uniqueIndex" json:"identity"` // UUID V7 业务标识
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;index" json:"created_at"` // 创建时间
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null" json:"updated_at"` // 更新时间
DeletedAt gorm.DeletedAt `gorm:"index"` // 软删除时间,非空表示记录已删除但仍保留在数据库中
Status int `gorm:"column:status;not null;default:0;index" json:"status"` // 通用记录状态
}
// NewIdentity 生成时间有序的 UUID V7 字符串,生成失败属于不可恢复的运行时错误。

View File

@@ -0,0 +1,21 @@
package models
import (
"reflect"
"testing"
"gorm.io/gorm"
)
func TestEntityDeletedAtEnablesSoftDelete(t *testing.T) {
field, ok := reflect.TypeOf(Entity{}).FieldByName("DeletedAt")
if !ok {
t.Fatal("Entity 缺少 DeletedAt 软删除字段")
}
if field.Type != reflect.TypeOf(gorm.DeletedAt{}) {
t.Fatalf("DeletedAt 类型为 %v期望 gorm.DeletedAt", field.Type)
}
if got := field.Tag.Get("gorm"); got != "index" {
t.Fatalf("DeletedAt gorm 标签为 %q期望 %q", got, "index")
}
}

View File

@@ -11,6 +11,7 @@ func TestPlatformModelTableNames(t *testing.T) {
{name: "account", table: &PlatformAccount{}, want: "platform_account"},
{name: "role", table: &PlatformRole{}, want: "platform_role"},
{name: "role menu", table: &PlatformRoleMenu{}, want: "platform_role_menu"},
{name: "producer account", table: &ProducerAccount{}, want: "producer_account"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {

View File

@@ -0,0 +1,22 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// ProducerAccount 对应 producer_account保存可登录的生产商企业主数据。
type ProducerAccount struct {
Entity // 公共实体字段
ProducerCode string `gorm:"column:producer_code;type:varchar(64);not null;uniqueIndex" json:"producer_code"` // 生产商编码
Name string `gorm:"column:name;type:varchar(128);not null" json:"name"` // 生产商名称
CreditCode string `gorm:"column:credit_code;type:varchar(64);not null;default:'';index" json:"credit_code"` // 统一社会信用代码
Principal string `gorm:"column:principal;type:varchar(64);not null;default:''" json:"principal"` // 企业负责人
Phone string `gorm:"column:phone;type:varchar(32);not null;default:''" json:"phone"` // 联系电话
Address string `gorm:"column:address;type:varchar(255);not null;default:''" json:"address"` // 企业地址
Username string `gorm:"column:username;type:varchar(64);not null;uniqueIndex" json:"username"` // 登录用户名
DisplayName string `gorm:"column:display_name;type:varchar(64);not null;default:''" json:"display_name"` // 登录展示名称
PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null" json:"-"` // 登录密码哈希
RoleCode string `gorm:"column:role_code;type:varchar(64);not null;default:'admin'" json:"role_code"` // 生产商端角色编码
Remark string `gorm:"column:remark;type:text;not null;default:''" json:"remark"` // 备注
}
func init() { database.AppendMigrate(&ProducerAccount{}) }
func (table *ProducerAccount) TableName() string { return "producer_account" }

View File

@@ -8,18 +8,19 @@ import (
// ProductInfo 对应 product_info保存一物一码的实体产品档案。
type ProductInfo struct {
Entity // 公共实体字段
ProductStatus int `gorm:"column:product_status;not null;default:10;index" json:"product_status"` // 产品业务状态
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"` // 生产时间
EnabledAt *time.Time `gorm:"column:enabled_at;type:timestamptz" json:"enabled_at"` // 首次启用时间
Entity // 公共实体字段
ProductStatus int `gorm:"column:product_status;not null;default:10;index" json:"product_status"` // 产品业务状态
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"` // 产品名称
ProducerAccountID uint64 `gorm:"column:producer_account_id;not null;default:0;index" json:"producer_account_id"` // 生产商自增主键;历史数据迁移前可为 0新建和编辑必须关联有效生产商
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"` // 生产时间
EnabledAt *time.Time `gorm:"column:enabled_at;type:timestamptz" json:"enabled_at"` // 首次启用时间
}
func init() { database.AppendMigrate(&ProductInfo{}) }

View File

@@ -107,10 +107,19 @@ func registerGasorderRoute(group *gin.RouterGroup) {
}
func registerProductRoute(group *gin.RouterGroup) {
producer := group.Group("/producer_account")
producer.GET("", product.ListProducerAccount)
producer.POST("", product.CreateProducerAccount)
producer.GET("/:identity", product.GetProducerAccount)
producer.PUT("/:identity", product.UpdateProducerAccount)
producer.PATCH("/:identity/status", func(ctx *gin.Context) { common.UpdateRecordStatus(ctx, &models.ProducerAccount{}) })
producer.DELETE("/:identity", product.DeleteProducerAccount)
registerRestrictedNoDeleteResource(group, "/product_type", &models.ProductType{}, []string{"code", "name"})
registerRestrictedNoDeleteResource(group, "/product_warehouse", &models.ProductWarehouse{}, []string{"code", "name", "address", "manager", "phone"})
infoRelations := []common.ResourceRelation{
requiredRelation("producer_account_identity", "producer_account_id", &models.ProducerAccount{}),
requiredRelation("product_type_identity", "product_type_id", &models.ProductType{}),
optionalRelation("warehouse_identity", "warehouse_id", &models.ProductWarehouse{}),
optionalRelation("gas_basic_identity", "gas_basic_id", &models.GasBasic{}),

View File

@@ -166,6 +166,10 @@ func TestPlatformProductCommerceAndDeliveryRoutesFollowTheirContracts(t *testing
assertRouteMethods(t, routes, path+"/:identity/status", http.MethodPatch)
assertNoRouteMethods(t, routes, path+"/:identity", http.MethodDelete)
}
producer := "/heqi/platform/v1/producer_account"
assertRouteMethods(t, routes, producer, http.MethodGet, http.MethodPost)
assertRouteMethods(t, routes, producer+"/:identity", http.MethodGet, http.MethodPut, http.MethodDelete)
assertRouteMethods(t, routes, producer+"/:identity/status", http.MethodPatch)
owner := "/heqi/platform/v1/product_owner"
assertRouteMethods(t, routes, owner, http.MethodGet)
assertNoRouteMethods(t, routes, owner, http.MethodPost)