fix platform workflow integrity and permissions

This commit is contained in:
david
2026-07-29 14:03:10 +08:00
parent afd9dbdeb5
commit d4799cd320
44 changed files with 797 additions and 224 deletions

View File

@@ -42,7 +42,7 @@ func UpdateRecordStatus(ctx *gin.Context, model any) {
var request struct {
Status int `json:"status" binding:"required"`
}
if err := ctx.ShouldBindJSON(&request); err != nil {
if err := ctx.ShouldBindJSON(&request); err != nil || !IsGenericRecordStatus(request.Status) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
@@ -55,7 +55,22 @@ func ArchiveRecord(ctx *gin.Context, model any) {
}
func NewEntity(status int) models.Entity {
return models.Entity{Identity: models.NewIdentity(), Status: status, Version: 1}
return models.Entity{Identity: models.NewIdentity(), Status: status}
}
// IsGenericRecordStatus reports whether status belongs to the shared record lifecycle.
func IsGenericRecordStatus(status int) bool {
switch status {
case StatusDraft, StatusEnable, StatusDisable, StatusArchived, StatusFrozen:
return true
default:
return false
}
}
// ActiveRecords excludes logically archived records from operational queries.
func ActiveRecords(query *gorm.DB) *gorm.DB {
return query.Where("status <> ?", StatusArchived)
}
func ListPage[T any](ctx *gin.Context) {
@@ -63,7 +78,7 @@ func ListPage[T any](ctx *gin.Context) {
var list []T
var total int64
model := new(T)
databaseQuery := ApplyKeywordFilter(ctx, impl.DBService.Model(model), model)
databaseQuery := ApplyKeywordFilter(ctx, ActiveRecords(impl.DBService.Model(model)), model)
if err := databaseQuery.Count(&total).Error; err != nil {
infra.Response.Error(ctx, err)
return

View File

@@ -37,7 +37,7 @@ 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)
query := ApplyKeywordFilter(ctx, ActiveRecords(impl.DBService.Model(model)), model)
if err := query.Count(&total).Error; err != nil {
infra.Response.Error(ctx, err)
return
@@ -74,6 +74,10 @@ func createResource(ctx *gin.Context, model any, allowedFields []string, relatio
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
if err := ValidateResourceValues(model, values, true); err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
encoded, err := json.Marshal(values)
if err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
@@ -123,7 +127,7 @@ func maskCreatedSensitiveFields(value any) any {
func isCreatedResponseField(key string) bool {
switch key {
case "identity", "status", "version", "created_at", "updated_at":
case "identity", "status", "created_at", "updated_at":
return true
default:
return strings.HasSuffix(key, "_identity")
@@ -226,7 +230,7 @@ func updateResource(ctx *gin.Context, model any, allowedFields []string, relatio
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
if len(values) == 0 {
if len(values) == 0 || ValidateResourceValues(model, values, false) != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
@@ -245,6 +249,74 @@ func PrepareResourceValues(ctx *gin.Context, model any, allowedFields []string,
return values, nil
}
// ValidateResourceValues enforces invariants that database nullability and
// frontend form metadata cannot express.
func ValidateResourceValues(model any, values map[string]any, creating bool) error {
positive := func(key string) bool {
value, exists := numericValue(values[key])
return !exists || value > 0
}
nonNegative := func(key string) bool {
value, exists := numericValue(values[key])
return !exists || value >= 0
}
nonEmpty := func(key string) bool {
value, exists := values[key]
if !exists {
return !creating
}
text, ok := value.(string)
return ok && strings.TrimSpace(text) != ""
}
switch model.(type) {
case *models.EcProduct:
if !nonEmpty("product_code") || !nonEmpty("name") || !nonNegative("price_amount") || !nonNegative("stock_quantity") {
return errors.New("invalid commerce product")
}
case *models.EcCart:
if !positive("quantity") {
return errors.New("invalid cart quantity")
}
case *models.EcOrder:
if !nonEmpty("order_no") || !nonNegative("total_amount") {
return errors.New("invalid order")
}
case *models.EcOrderItem:
if !nonEmpty("product_snapshot") || !positive("quantity") || !nonNegative("sale_amount") {
return errors.New("invalid order item")
}
case *models.EcReview:
score, exists := numericValue(values["score"])
if (exists && (score < 1 || score > 5)) || !nonEmpty("content") {
return errors.New("invalid review")
}
case *models.FinPayment:
if !positive("amount") || !nonEmpty("channel") {
return errors.New("invalid payment")
}
}
return nil
}
func numericValue(value any) (float64, bool) {
switch number := value.(type) {
case float64:
return number, true
case float32:
return float64(number), true
case int:
return float64(number), true
case int64:
return float64(number), true
case uint:
return float64(number), true
case uint64:
return float64(number), true
default:
return 0, false
}
}
func ResolveResourceRelations(input map[string]any, allowedFields []string, relations []ResourceRelation, requireRelations bool) (map[string]any, error) {
values := FilterFields(input, allowedFields)
for _, relation := range relations {
@@ -286,7 +358,7 @@ func ResolveIdentityID(model any, identity string, required bool) (uint64, error
return 0, nil
}
var related struct{ ID uint64 }
if err := impl.DBService.Model(model).Select("id").Where("identity = ?", identity).First(&related).Error; err != nil {
if err := impl.DBService.Model(model).Select("id").Where("identity = ? AND status <> ?", identity, StatusArchived).First(&related).Error; err != nil {
return 0, err
}
return related.ID, nil

View File

@@ -2,9 +2,13 @@ package common
import (
"net/http"
"strings"
"testing"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"github.com/DATA-DOG/go-sqlmock"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func TestFilterFieldsKeepsOnlyAllowedKeys(t *testing.T) {
@@ -14,6 +18,24 @@ func TestFilterFieldsKeepsOnlyAllowedKeys(t *testing.T) {
}
}
func TestOperationalQueriesExcludeArchivedRecords(t *testing.T) {
sqlDatabase, _, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer sqlDatabase.Close()
database, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDatabase}), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
statement := database.ToSQL(func(tx *gorm.DB) *gorm.DB {
return ActiveRecords(tx.Model(&models.EcProduct{})).Find(&[]models.EcProduct{})
})
if !strings.Contains(statement, `status <> 3`) {
t.Fatalf("archive filter missing from operational query: %s", statement)
}
}
func TestResourceResponseStripsInternalIDsRecursively(t *testing.T) {
got := ResourceResponse(map[string]any{
"id": uint64(1), "identity": "root",
@@ -56,3 +78,33 @@ func TestCommonMethodModesRemainHTTPCompatible(t *testing.T) {
t.Fatal("standard HTTP methods unavailable")
}
}
func TestGenericStatusRejectsDomainLifecycleValues(t *testing.T) {
for _, status := range []int{StatusDraft, StatusEnable, StatusDisable, StatusArchived, StatusFrozen} {
if !IsGenericRecordStatus(status) {
t.Fatalf("generic status %d was rejected", status)
}
}
if IsGenericRecordStatus(StatusCompleted) {
t.Fatal("business lifecycle status was accepted as generic entity status")
}
}
func TestCommerceAndFinanceRejectInvalidAmounts(t *testing.T) {
tests := []struct {
model any
values map[string]any
}{
{&models.EcProduct{}, map[string]any{"product_code": "p", "name": "P", "price_amount": -1.0}},
{&models.EcCart{}, map[string]any{"quantity": 0.0}},
{&models.EcOrder{}, map[string]any{"order_no": "o", "total_amount": -1.0}},
{&models.EcOrderItem{}, map[string]any{"product_snapshot": "{}", "quantity": -1.0, "sale_amount": 1.0}},
{&models.EcReview{}, map[string]any{"score": 6.0, "content": "bad"}},
{&models.FinPayment{}, map[string]any{"channel": "wallet", "amount": -1.0}},
}
for _, test := range tests {
if ValidateResourceValues(test.model, test.values, true) == nil {
t.Fatalf("%T accepted invalid values %#v", test.model, test.values)
}
}
}

View File

@@ -38,18 +38,3 @@ const (
StatusSuccess = 37 // 成功
StatusMatched = 38 // 已匹配
)
var statusNames = map[int]string{
StatusDraft: "draft", StatusEnable: "enabled", StatusDisable: "disabled", StatusArchived: "archived", StatusFrozen: "frozen",
StatusPending: "pending", StatusActive: "active", StatusExpired: "expired", StatusTerminated: "terminated",
StatusRecorded: "recorded", StatusBound: "bound", StatusCreated: "created", StatusOrdered: "ordered",
StatusAssigned: "assigned", StatusFilling: "filling", StatusReady: "ready", StatusException: "exception",
StatusCancelled: "cancelled", StatusCompleted: "completed", StatusPosted: "posted", StatusApproved: "approved",
StatusRejected: "rejected", StatusScrapped: "scrapped", StatusInStock: "in_stock", StatusInTransit: "in_transit",
StatusInUse: "in_use", StatusRepairing: "repairing", StatusOpen: "open", StatusDelivering: "delivering",
StatusAwaitingConfirmation: "awaiting_confirmation",
StatusPaid: "paid", StatusPublished: "published", StatusSuccess: "success", StatusMatched: "matched",
}
// StatusName 返回状态整数对应的稳定英文名称,供审计快照字段使用。
func StatusName(status int) string { return statusNames[status] }

View File

@@ -58,12 +58,22 @@ func Login(ctx *gin.Context) {
return
}
extend := map[string]string{"username": account.Username, "display_name": account.DisplayName}
if account.PlatformRoleCode == "root" {
extend["location_scope"] = "precise"
} else {
var role models.PlatformRole
if impl.DBService.Select("data_scope").Where("role_code = ? AND status = ?", account.PlatformRoleCode, common.StatusEnable).First(&role).Error == nil &&
role.DataScope == "precise" {
extend["location_scope"] = "precise"
}
}
accessToken, err := token.New(env.Runtime.JwtSecretKey).GenerateJwt(
0,
account.Identity,
"platform_admin",
account.PlatformRoleCode,
map[string]string{"username": account.Username, "display_name": account.DisplayName},
extend,
nil,
)
if err != nil {
@@ -99,7 +109,7 @@ func CurrentProfile(ctx *gin.Context) {
menuCodes := make([]string, 0, len(menus))
seenMenuCodes := make(map[string]bool, len(menus))
for _, menu := range menus {
codes := []string{menu.MenuCode}
codes := []string{menu.Identity}
if path := strings.Trim(menu.Path, "/"); path != "" {
codes = append(codes, strings.Split(path, "/")[0])
}

View File

@@ -1,6 +1,7 @@
package ec
import (
"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"
@@ -8,6 +9,12 @@ import (
"github.com/gin-gonic/gin"
)
type categoryRequest struct {
ParentIdentity string `json:"parent_identity"`
Name string `json:"name" binding:"required,max=128"`
SortNo int `json:"sort_no"`
}
type ecCategoryView struct {
Identity string `json:"identity"`
ParentIdentity string `json:"parent_identity,omitempty"`
@@ -50,11 +57,12 @@ func ListEcCategory(ctx *gin.Context) {
page, size := common.PageSize(ctx)
var list []models.EcCategory
var total int64
if err := impl.DBService.Model(&models.EcCategory{}).Count(&total).Error; err != nil {
query := common.ActiveRecords(impl.DBService.Model(&models.EcCategory{}))
if err := query.Count(&total).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
if err := impl.DBService.Order("sort_no asc, id asc").Offset((page - 1) * size).Limit(size).Find(&list).Error; err != nil {
if err := query.Order("sort_no asc, id asc").Offset((page - 1) * size).Limit(size).Find(&list).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
@@ -79,3 +87,66 @@ func GetEcCategory(ctx *gin.Context) {
}
infra.Response.Success(ctx, views[0])
}
func CreateEcCategory(ctx *gin.Context) {
var request categoryRequest
if err := ctx.ShouldBindJSON(&request); err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
parentID, err := common.ResolveIdentityID(&models.EcCategory{}, request.ParentIdentity, false)
if err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
category := models.EcCategory{Entity: common.NewEntity(common.StatusDraft), ParentID: parentID, Name: request.Name, SortNo: request.SortNo}
if err := impl.DBService.Create(&category).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
common.RespondCreatedResource(ctx, category)
}
func UpdateEcCategory(ctx *gin.Context) {
var request categoryRequest
if err := ctx.ShouldBindJSON(&request); err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
var category models.EcCategory
if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&category).Error; err != nil {
common.RespondRecordError(ctx, err)
return
}
parentID, err := common.ResolveIdentityID(&models.EcCategory{}, request.ParentIdentity, false)
if err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
var rows []models.EcCategory
if err := impl.DBService.Select("id", "parent_id").Find(&rows).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
parents := make(map[uint64]uint64, len(rows))
for _, row := range rows {
parents[row.ID] = row.ParentID
}
if wouldCreateCategoryCycle(category.ID, parentID, parents) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
common.UpdateAllowedByIdentity(ctx, &models.EcCategory{}, gin.H{"parent_id": parentID, "name": request.Name, "sort_no": request.SortNo}, []string{"parent_id", "name", "sort_no"})
}
func wouldCreateCategoryCycle(categoryID, parentID uint64, parents map[uint64]uint64) bool {
seen := map[uint64]bool{}
for parentID != 0 {
if parentID == categoryID || seen[parentID] {
return true
}
seen[parentID] = true
parentID = parents[parentID]
}
return false
}

View File

@@ -0,0 +1,16 @@
package ec
import "testing"
func TestCategoryHierarchyRejectsCycles(t *testing.T) {
parents := map[uint64]uint64{2: 1, 3: 2}
if !wouldCreateCategoryCycle(1, 3, parents) {
t.Fatal("moving a category under its descendant did not form a detected cycle")
}
if !wouldCreateCategoryCycle(2, 2, parents) {
t.Fatal("self-parent was accepted")
}
if wouldCreateCategoryCycle(3, 1, parents) {
t.Fatal("valid move to an ancestor was rejected")
}
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"io"
"time"
"git.apinb.com/bsm-sdk/core/errcode"
"git.apinb.com/bsm-sdk/core/infra"
@@ -40,6 +41,9 @@ func rewriteSettlementSubject(ctx *gin.Context) error {
if err := ctx.ShouldBindJSON(&input); err != nil {
return err
}
if !validSettlementPeriod(input["period_start"], input["period_end"]) {
return errors.New("invalid settlement period")
}
subjectType, _ := input["subject_type"].(string)
identity, _ := input["subject_identity"].(string)
var model any
@@ -66,3 +70,14 @@ func rewriteSettlementSubject(ctx *gin.Context) error {
ctx.Request.Body = io.NopCloser(bytes.NewReader(encoded))
return nil
}
func validSettlementPeriod(startValue, endValue any) bool {
startText, startOK := startValue.(string)
endText, endOK := endValue.(string)
if !startOK || !endOK {
return false
}
start, startErr := time.Parse(time.RFC3339, startText)
end, endErr := time.Parse(time.RFC3339, endText)
return startErr == nil && endErr == nil && end.After(start)
}

View File

@@ -0,0 +1,12 @@
package fin
import "testing"
func TestSettlementPeriodMustMoveForward(t *testing.T) {
if !validSettlementPeriod("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z") {
t.Fatal("valid settlement period was rejected")
}
if validSettlementPeriod("2026-02-01T00:00:00Z", "2026-01-01T00:00:00Z") {
t.Fatal("reversed settlement period was accepted")
}
}

View File

@@ -147,8 +147,12 @@ func CreateGasorderContract(ctx *gin.Context) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
if !deliveryBelongsToGas(deliveryID, gasID) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
contract := models.GasorderContract{
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusDraft, Version: 1},
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusDraft},
ContractNo: request.ContractNo, UserAccountID: userID, GasBasicID: gasID, DeliveryBasicID: deliveryID,
Title: request.Title, Terms: request.Terms, FileURI: request.FileURI, DefaultDeliveryFee: request.DefaultDeliveryFee,
SignedAt: request.SignedAt, EffectiveAt: request.EffectiveAt, ExpiredAt: request.ExpiredAt,
@@ -181,6 +185,12 @@ func UpdateGasorderContract(ctx *gin.Context) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
var contract models.GasorderContract
if err := impl.DBService.Select("gas_basic_id").Where("identity = ?", ctx.Param("identity")).First(&contract).Error; err != nil ||
!deliveryBelongsToGas(deliveryID, contract.GasBasicID) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
result := impl.DBService.Model(&models.GasorderContract{}).
Where("identity = ? AND status = ?", ctx.Param("identity"), common.StatusDraft).
Updates(map[string]any{"delivery_basic_id": deliveryID, "title": request.Title, "terms": request.Terms,
@@ -280,7 +290,7 @@ func changeGasorderContract(ctx *gin.Context, action string, target int) {
func contractRevision(contract models.GasorderContract, action, reason, operatorIdentity, operatorName string) *models.GasorderContractRevision {
return &models.GasorderContractRevision{
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusRecorded, Version: 1},
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusRecorded},
GasorderContractID: contract.ID, Action: action, ContractStatus: contract.Status,
EffectiveAt: contract.EffectiveAt, ExpiredAt: contract.ExpiredAt,
OperatorIdentity: operatorIdentity, OperatorName: operatorName, OccurredAt: time.Now(), Reason: reason,
@@ -318,7 +328,7 @@ func BindGasorderContractProduct(ctx *gin.Context) {
return
}
binding := models.GasorderContractProduct{
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusBound, Version: 1},
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusBound},
GasorderContractID: contract.ID, ProductInfoID: product.ID, ProductCode: product.Code,
ProductTypeName: productType.Name, ProductParams: product.Params, UnitPrice: request.UnitPrice, BoundAt: time.Now(),
}
@@ -330,10 +340,17 @@ func BindGasorderContractProduct(ctx *gin.Context) {
}
func UnbindGasorderContractProduct(ctx *gin.Context) {
var request struct {
Reason string `json:"reason" binding:"required"`
}
if err := ctx.ShouldBindJSON(&request); err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
now := time.Now()
result := impl.DBService.Model(&models.GasorderContractProduct{}).
Where("identity = ? AND unbound_at IS NULL", ctx.Param("identity")).
Updates(map[string]any{"status": "unbound", "unbound_at": &now})
Updates(gasorderUnbindUpdates(request.Reason, now))
if result.Error != nil || result.RowsAffected != 1 {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
@@ -404,13 +421,6 @@ func CreateGasorderBasic(ctx *gin.Context) {
!product.IsEnabled || product.Status == common.StatusScrapped || product.UserAccountID != contract.UserAccountID {
return errors.New("contract product is no longer eligible")
}
var activeOrderCount int64
if err := tx.Table("gasorder_item").
Joins("JOIN gasorder_basic ON gasorder_basic.id = gasorder_item.gasorder_basic_id").
Where("gasorder_item.product_info_id = ? AND gasorder_basic.status NOT IN ?", binding.ProductInfoID, []int{common.StatusCompleted, common.StatusCancelled}).
Count(&activeOrderCount).Error; err != nil || activeOrderCount != 0 {
return errors.New("contract product already has an active order")
}
productAmount += binding.UnitPrice
}
payable := productAmount + contract.DefaultDeliveryFee - request.DiscountAmount
@@ -418,7 +428,7 @@ func CreateGasorderBasic(ctx *gin.Context) {
return errors.New("invalid payable amount")
}
order = models.GasorderBasic{
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusCreated, Version: 1},
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusCreated},
OrderNo: models.NewIdentity(), RequestNo: request.RequestNo, GasorderContractID: contract.ID,
UserAccountID: contract.UserAccountID, CreatorType: request.CreatorType, CreatorID: creatorID,
CreatorIdentity: request.CreatorIdentity, GasBasicID: contract.GasBasicID, DeliveryBasicID: contract.DeliveryBasicID,
@@ -433,8 +443,9 @@ func CreateGasorderBasic(ctx *gin.Context) {
}
for _, binding := range bindings {
item := models.GasorderItem{
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusOrdered, Version: 1},
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusOrdered},
GasorderBasicID: order.ID, GasorderContractProductID: binding.ID, ProductInfoID: binding.ProductInfoID,
Active: true,
ProductCode: binding.ProductCode, ProductTypeName: binding.ProductTypeName,
ProductParams: binding.ProductParams, UnitPrice: binding.UnitPrice,
}
@@ -445,6 +456,10 @@ func CreateGasorderBasic(ctx *gin.Context) {
return tx.Create(gasorderStatusRecord(order.ID, common.StatusDraft, common.StatusCreated, "order created", operatorIdentity, operatorName)).Error
})
if err != nil {
if lookupErr := impl.DBService.Where("request_no = ?", request.RequestNo).First(&order).Error; lookupErr == nil {
common.RespondCreatedResource(ctx, order)
return
}
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
@@ -481,6 +496,9 @@ func AssignGasorderBasic(ctx *gin.Context) {
if order.Status != common.StatusCreated && order.Status != common.StatusAssigned {
return errors.New("order cannot be assigned")
}
if !validGasorderAssignment(order, delivery, staff) {
return errors.New("delivery or staff does not belong to the order organization")
}
previous := order.Status
if err := tx.Model(&order).Updates(map[string]any{
"delivery_basic_id": delivery.ID, "staff_account_id": staff.ID, "status": common.StatusAssigned,
@@ -488,7 +506,7 @@ func AssignGasorderBasic(ctx *gin.Context) {
return err
}
assignment := models.GasorderAssign{
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusRecorded, Version: 1},
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusRecorded},
GasorderBasicID: order.ID, GasBasicID: order.GasBasicID, DeliveryBasicID: delivery.ID,
StaffAccountID: staff.ID, AssignerIdentity: operatorIdentity, AssignerName: operatorName,
AssignedAt: time.Now(), Reason: request.Reason,
@@ -509,20 +527,68 @@ func AssignGasorderBasic(ctx *gin.Context) {
}
func GasorderStartFilling(ctx *gin.Context) {
transitionGasorder(ctx, common.StatusFilling, map[int]bool{common.StatusAssigned: true})
transitionGasorder(ctx, common.StatusFilling)
}
func GasorderReady(ctx *gin.Context) {
transitionGasorder(ctx, common.StatusReady, map[int]bool{common.StatusFilling: true})
transitionGasorder(ctx, common.StatusReady)
}
func GasorderCancel(ctx *gin.Context) {
transitionGasorder(ctx, common.StatusCancelled, map[int]bool{common.StatusCreated: true, common.StatusAssigned: true})
transitionGasorder(ctx, common.StatusCancelled)
}
func GasorderStartDelivering(ctx *gin.Context) {
transitionGasorder(ctx, common.StatusDelivering)
}
func GasorderAwaitingConfirmation(ctx *gin.Context) {
transitionGasorder(ctx, common.StatusAwaitingConfirmation)
}
func GasorderComplete(ctx *gin.Context) {
var request struct {
Reason string `json:"reason" binding:"required"`
ConfirmType string `json:"confirm_type" binding:"required,max=32"`
RecipientName string `json:"recipient_name" binding:"required,max=64"`
RecipientPhone string `json:"recipient_phone" binding:"max=32"`
ProofURI string `json:"proof_uri" binding:"max=512"`
Remark string `json:"remark"`
}
if err := ctx.ShouldBindJSON(&request); err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
operatorIdentity, operatorName := common.PlatformOperator(ctx)
err := impl.DBService.Transaction(func(tx *gorm.DB) error {
var order models.GasorderBasic
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("identity = ?", ctx.Param("identity")).First(&order).Error; err != nil {
return err
}
if order.Status != common.StatusAwaitingConfirmation {
return errors.New("order cannot be completed")
}
if err := tx.Model(&order).Update("status", common.StatusCompleted).Error; err != nil {
return err
}
confirm := models.GasorderConfirm{
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusRecorded},
GasorderBasicID: order.ID, ConfirmType: request.ConfirmType, RecipientName: request.RecipientName,
RecipientPhone: request.RecipientPhone, ProofURI: request.ProofURI, ConfirmedAt: time.Now(), Remark: request.Remark,
}
if err := tx.Create(&confirm).Error; err != nil {
return err
}
if err := releaseGasorderProducts(tx, order.ID); err != nil {
return err
}
return tx.Create(gasorderStatusRecord(order.ID, order.Status, common.StatusCompleted, request.Reason, operatorIdentity, operatorName)).Error
})
if err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
infra.Response.Success(ctx, gin.H{"updated": true, "status": common.StatusCompleted})
}
func GasorderException(ctx *gin.Context) {
transitionGasorder(ctx, common.StatusException, map[int]bool{
common.StatusFilling: true, common.StatusReady: true,
common.StatusDelivering: true, common.StatusAwaitingConfirmation: true,
})
transitionGasorder(ctx, common.StatusException)
}
func GasorderRecover(ctx *gin.Context) {
@@ -555,7 +621,7 @@ func GasorderRecover(ctx *gin.Context) {
infra.Response.Success(ctx, gin.H{"updated": true})
}
func transitionGasorder(ctx *gin.Context, target int, allowed map[int]bool) {
func transitionGasorder(ctx *gin.Context, target int) {
var request struct {
Reason string `json:"reason" binding:"required"`
}
@@ -569,7 +635,7 @@ func transitionGasorder(ctx *gin.Context, target int, allowed map[int]bool) {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("identity = ?", ctx.Param("identity")).First(&order).Error; err != nil {
return err
}
if !allowed[order.Status] || (target == common.StatusFilling && order.DeliveryBasicID == 0) {
if !gasorderTransitionAllowed(order.Status, target) || (target == common.StatusFilling && order.DeliveryBasicID == 0) {
return errors.New("invalid order transition")
}
updates := map[string]any{"status": target}
@@ -579,6 +645,35 @@ func transitionGasorder(ctx *gin.Context, target int, allowed map[int]bool) {
if err := tx.Model(&order).Updates(updates).Error; err != nil {
return err
}
if target == common.StatusDelivering {
var attempt int
if err := tx.Model(&models.GasorderTrack{}).Where("gasorder_basic_id = ?", order.ID).
Select("COALESCE(MAX(attempt_no), 0)").Scan(&attempt).Error; err != nil {
return err
}
track := models.GasorderTrack{
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusActive},
GasorderBasicID: order.ID, StaffAccountID: order.StaffAccountID,
AttemptNo: attempt + 1, StartedAt: time.Now(),
}
if err := tx.Create(&track).Error; err != nil {
return err
}
}
if target == common.StatusAwaitingConfirmation {
now := time.Now()
result := tx.Model(&models.GasorderTrack{}).
Where("gasorder_basic_id = ? AND completed_at IS NULL", order.ID).
Update("completed_at", &now)
if result.Error != nil || result.RowsAffected != 1 {
return errors.New("active delivery track not found")
}
}
if target == common.StatusCancelled {
if err := releaseGasorderProducts(tx, order.ID); err != nil {
return err
}
}
return tx.Create(gasorderStatusRecord(order.ID, order.Status, target, request.Reason, operatorIdentity, operatorName)).Error
})
if err != nil {
@@ -588,9 +683,58 @@ func transitionGasorder(ctx *gin.Context, target int, allowed map[int]bool) {
infra.Response.Success(ctx, gin.H{"updated": true, "status": target})
}
func gasorderTransitionAllowed(from, target int) bool {
switch target {
case common.StatusFilling:
return from == common.StatusAssigned
case common.StatusReady:
return from == common.StatusFilling
case common.StatusDelivering:
return from == common.StatusReady
case common.StatusAwaitingConfirmation:
return from == common.StatusDelivering
case common.StatusCompleted:
return from == common.StatusAwaitingConfirmation
case common.StatusCancelled:
return from == common.StatusCreated || from == common.StatusAssigned
case common.StatusException:
return from == common.StatusFilling || from == common.StatusReady ||
from == common.StatusDelivering || from == common.StatusAwaitingConfirmation
default:
return false
}
}
func gasorderUnbindUpdates(reason string, now time.Time) map[string]any {
return map[string]any{"status": common.StatusDisable, "unbound_at": &now, "unbind_reason": strings.TrimSpace(reason)}
}
func releaseGasorderProducts(tx *gorm.DB, orderID uint64) error {
return tx.Model(&models.GasorderItem{}).Where("gasorder_basic_id = ?", orderID).Update("active", false).Error
}
func validGasorderAssignment(order models.GasorderBasic, delivery models.DeliveryBasic, staff models.StaffAccount) bool {
if delivery.GasBasicID != 0 && delivery.GasBasicID != order.GasBasicID {
return false
}
if staff.DeliveryBasicID != delivery.ID {
return false
}
return staff.GasBasicID == 0 || staff.GasBasicID == order.GasBasicID
}
func deliveryBelongsToGas(deliveryID, gasID uint64) bool {
if deliveryID == 0 {
return true
}
var delivery models.DeliveryBasic
return impl.DBService.Select("gas_basic_id").First(&delivery, deliveryID).Error == nil &&
(delivery.GasBasicID == 0 || delivery.GasBasicID == gasID)
}
func gasorderStatusRecord(orderID uint64, from, to int, reason, operatorIdentity, operatorName string) *models.GasorderStatus {
return &models.GasorderStatus{
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusRecorded, Version: 1},
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusRecorded},
GasorderBasicID: orderID, FromStatus: from, ToStatus: to,
OperatorIdentity: operatorIdentity, OperatorName: operatorName, OccurredAt: time.Now(), Reason: reason,
}

View File

@@ -1,7 +1,10 @@
package gasorder
import (
"reflect"
"strings"
"testing"
"time"
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
@@ -18,6 +21,71 @@ func TestGasorderCreatorTypesCoverEveryConfirmedOrigin(t *testing.T) {
}
}
func TestUnbindWritesIntegerGenericStatusAndReason(t *testing.T) {
updates := gasorderUnbindUpdates(" contract ended ", time.Now())
status, ok := updates["status"].(int)
if !ok || status != common.StatusDisable {
t.Fatalf("unbind status = %#v, want integer disabled status", updates["status"])
}
if updates["unbind_reason"] != "contract ended" {
t.Fatalf("unbind reason was not retained: %#v", updates)
}
}
func TestGasorderCompleteStateMachine(t *testing.T) {
path := []int{
common.StatusCreated,
common.StatusAssigned,
common.StatusFilling,
common.StatusReady,
common.StatusDelivering,
common.StatusAwaitingConfirmation,
common.StatusCompleted,
}
for index := 1; index < len(path); index++ {
if index == 1 {
continue // assignment has organization validation and its own handler
}
if !gasorderTransitionAllowed(path[index-1], path[index]) {
t.Fatalf("transition %d -> %d is not reachable", path[index-1], path[index])
}
}
if gasorderTransitionAllowed(common.StatusReady, common.StatusCompleted) {
t.Fatal("state machine allows skipping delivery and confirmation")
}
}
func TestGasorderAssignmentRequiresSameOrganization(t *testing.T) {
order := models.GasorderBasic{Entity: models.Entity{ID: 1}, GasBasicID: 10}
delivery := models.DeliveryBasic{Entity: models.Entity{ID: 20}, GasBasicID: 10}
staff := models.StaffAccount{GasBasicID: 10, DeliveryBasicID: 20}
if !validGasorderAssignment(order, delivery, staff) {
t.Fatal("valid organization assignment was rejected")
}
staff.DeliveryBasicID = 21
if validGasorderAssignment(order, delivery, staff) {
t.Fatal("cross-delivery staff assignment was accepted")
}
staff.DeliveryBasicID = 20
delivery.GasBasicID = 11
if validGasorderAssignment(order, delivery, staff) {
t.Fatal("cross-gas delivery assignment was accepted")
}
}
func TestGasorderProductHasConcurrentActiveReservationConstraint(t *testing.T) {
field, ok := reflect.TypeOf(models.GasorderItem{}).FieldByName("ProductInfoID")
if !ok {
t.Fatal("ProductInfoID field missing")
}
tag := field.Tag.Get("gorm")
for _, required := range []string{"uniqueIndex:idx_active_gasorder_product", "where:active = true"} {
if !strings.Contains(tag, required) {
t.Fatalf("active reservation constraint missing %q from %q", required, tag)
}
}
}
func TestGasorderStatusRecordIsImmutableSnapshot(t *testing.T) {
record := gasorderStatusRecord(7, common.StatusAssigned, common.StatusFilling, "start filling", "operator-a", "Operator")
if record.GasorderBasicID != 7 || record.FromStatus != common.StatusAssigned || record.ToStatus != common.StatusFilling {

View File

@@ -53,10 +53,10 @@ var PlatformMenus = [][]Menu{
{Identity: "product_info", ParentIdentity: "device", MenuCode: "device", Name: "产品信息", Path: "/product/product-info", SortNo: 3, Status: common.StatusEnable},
},
{
{Identity: "gasorder", MenuCode: "delivery", Name: "气体配送订单管理", Icon: "icon-list", Path: "/gasorder", SortNo: 70, Status: common.StatusEnable},
{Identity: "gasorder_contract", ParentIdentity: "gasorder", MenuCode: "delivery", Name: "合同管理", Path: "/gasorder/contracts", SortNo: 1, Status: common.StatusEnable},
{Identity: "gasorder_basic", ParentIdentity: "gasorder", MenuCode: "delivery", Name: "配送订单", Path: "/gasorder/orders", SortNo: 2, Status: common.StatusEnable},
{Identity: "gasorder_track", ParentIdentity: "gasorder", MenuCode: "delivery", Name: "运行轨迹", Path: "/gasorder/tracks", SortNo: 3, Status: common.StatusEnable},
{Identity: "gasorder", MenuCode: "gasorder", Name: "气体配送订单管理", Icon: "icon-list", Path: "/gasorder", SortNo: 70, Status: common.StatusEnable},
{Identity: "gasorder_contract", ParentIdentity: "gasorder", MenuCode: "gasorder_contract", Name: "合同管理", Path: "/gasorder/contracts", SortNo: 1, Status: common.StatusEnable},
{Identity: "gasorder_basic", ParentIdentity: "gasorder", MenuCode: "gasorder_basic", Name: "配送订单", Path: "/gasorder/orders", SortNo: 2, Status: common.StatusEnable},
{Identity: "gasorder_track", ParentIdentity: "gasorder", MenuCode: "gasorder_track", Name: "运行轨迹", Path: "/gasorder/tracks", SortNo: 3, Status: common.StatusEnable},
},
{
{Identity: "ec", MenuCode: "ec", Name: "商城管理", Icon: "icon-gift", Path: "/ec", SortNo: 80, Status: common.StatusEnable},
@@ -127,19 +127,24 @@ func LoadPlatformMenus(roleCode string) ([]Menu, error) {
if err := impl.DBService.Where("role_code = ? AND status = ?", roleCode, common.StatusEnable).First(&role).Error; err != nil {
return nil, err
}
var codes []string
var identities []string
if err := impl.DBService.Model(&models.PlatformRoleMenu{}).
Where("platform_role_id = ?", role.ID).
Pluck("menu_code", &codes).Error; err != nil {
Pluck("menu_identity", &identities).Error; err != nil {
return nil, err
}
allowed := make(map[string]struct{}, len(codes))
for _, code := range codes {
allowed[code] = struct{}{}
allowed := make(map[string]struct{}, len(identities))
for _, identity := range identities {
allowed[identity] = struct{}{}
}
for _, menu := range menus {
if _, ok := allowed[menu.Identity]; ok && menu.ParentIdentity != "" {
allowed[menu.ParentIdentity] = struct{}{}
}
}
filtered := make([]Menu, 0, len(menus))
for _, menu := range menus {
if _, ok := allowed[menu.MenuCode]; ok {
if _, ok := allowed[menu.Identity]; ok {
filtered = append(filtered, menu)
}
}

View File

@@ -19,36 +19,35 @@ func platformMenuAllowsPath(menus []platformbase.Menu, requestPath string) bool
}
relative := strings.Trim(requestPath[index+len(marker):], "/")
resource := strings.Split(relative, "/")[0]
domain := platformRouteDomain(resource)
menuIdentity := platformRouteMenuIdentity(resource)
for _, menu := range menus {
if menu.MenuCode == domain {
return true
}
menuPath := strings.Trim(menu.Path, "/")
if menuPath != "" && strings.Split(menuPath, "/")[0] == domain {
if menu.Identity == menuIdentity {
return true
}
}
return false
}
func platformRouteDomain(resource string) string {
prefix := strings.Split(resource, "_")[0]
switch prefix {
case "product":
return "device"
case "gasorder":
return "delivery"
case "fin":
return "finance"
case "cms":
return "content"
case "cs":
return "customer_service"
case "platform":
return "platform"
func platformRouteMenuIdentity(resource string) string {
switch {
case resource == "dashboard":
return "dashboard_overview"
case strings.HasPrefix(resource, "gasorder_contract"):
return "gasorder_contract"
case resource == "gasorder_track" || resource == "gasorder_track_point":
return "gasorder_track"
case strings.HasPrefix(resource, "gasorder_"):
return "gasorder_basic"
case resource == "product_type" || resource == "product_warehouse":
return resource
case strings.HasPrefix(resource, "product_"):
return "product_info"
case strings.HasPrefix(resource, "cms_"):
return "cms_content"
case strings.HasPrefix(resource, "cs_"):
return "cs_ticket"
default:
return prefix
return resource
}
}

View File

@@ -0,0 +1,30 @@
package platform
import (
"testing"
platformbase "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/platform"
)
func TestSecondLevelMenuPermissionDoesNotGrantSibling(t *testing.T) {
menus := []platformbase.Menu{{Identity: "delivery_basic"}}
if !platformMenuAllowsPath(menus, "/heqi/platform/v1/delivery_basic") {
t.Fatal("selected second-level menu did not grant its resource")
}
if platformMenuAllowsPath(menus, "/heqi/platform/v1/delivery_account") {
t.Fatal("selected second-level menu granted a sibling resource")
}
if platformMenuAllowsPath(menus, "/heqi/platform/v1/gasorder_basic") {
t.Fatal("delivery permission leaked into gasorder")
}
}
func TestHiddenGasorderResourcesFollowOwningSecondLevelMenu(t *testing.T) {
orderMenus := []platformbase.Menu{{Identity: "gasorder_basic"}}
if !platformMenuAllowsPath(orderMenus, "/heqi/platform/v1/gasorder_confirm") {
t.Fatal("order confirmation was not covered by order menu")
}
if platformMenuAllowsPath(orderMenus, "/heqi/platform/v1/gasorder_contract") {
t.Fatal("order menu granted contract management")
}
}

View File

@@ -3,6 +3,7 @@ package platform
import (
"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/logic/common"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
@@ -15,7 +16,7 @@ func ListPlatformAccount(ctx *gin.Context) {
page, size := common.PageSize(ctx)
var list []models.PlatformAccount
var total int64
query := common.ApplyKeywordFilter(ctx, impl.DBService.Model(&models.PlatformAccount{}), &models.PlatformAccount{})
query := common.ApplyKeywordFilter(ctx, common.ActiveRecords(impl.DBService.Model(&models.PlatformAccount{})), &models.PlatformAccount{})
if err := query.Count(&total).Error; err != nil {
infra.Response.Error(ctx, err)
return
@@ -98,6 +99,15 @@ func UpdatePlatformAccount(ctx *gin.Context) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
claims, err := middleware.ParseAuth(ctx)
if err != nil {
infra.Response.Error(ctx, err)
return
}
if claims.Role != "root" && claims.Identity != ctx.Param("identity") {
infra.Response.Error(ctx, errcode.ErrPermissionDenied)
return
}
values := gin.H{"display_name": request.DisplayName, "avatar": request.Avatar, "phone": request.Phone}
if request.PlatformRoleCode != nil {
if !common.RequirePlatformRoot(ctx) {

View File

@@ -36,19 +36,21 @@ func ReplacePlatformRoleMenus(ctx *gin.Context) {
if role.IsSystem {
return errSystemPlatformRole
}
menuCodes := make(map[string]struct{}, len(request.MenuIdentities))
menuIdentities := make(map[string]struct{}, len(request.MenuIdentities))
for _, identity := range request.MenuIdentities {
menu, ok := platformbase.FindPlatformMenu(identity)
if !ok {
return gorm.ErrRecordNotFound
}
menuCodes[menu.MenuCode] = struct{}{}
if menu.ParentIdentity != "" {
menuIdentities[menu.Identity] = struct{}{}
}
}
if err := transaction.Where("platform_role_id = ?", role.ID).Delete(&models.PlatformRoleMenu{}).Error; err != nil {
return err
}
for menuCode := range menuCodes {
relation := models.PlatformRoleMenu{PlatformRoleID: role.ID, MenuCode: menuCode}
for menuIdentity := range menuIdentities {
relation := models.PlatformRoleMenu{PlatformRoleID: role.ID, MenuIdentity: menuIdentity}
if err := transaction.Create(&relation).Error; err != nil {
return err
}
@@ -79,22 +81,12 @@ func ListPlatformRoleMenuIdentities(ctx *gin.Context) {
common.RespondRecordError(ctx, err)
return
}
var codes []string
var identities []string
if err := impl.DBService.Model(&models.PlatformRoleMenu{}).
Where("platform_role_id = ?", role.ID).
Pluck("menu_code", &codes).Error; err != nil {
Pluck("menu_identity", &identities).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
assigned := make(map[string]struct{}, len(codes))
for _, code := range codes {
assigned[code] = struct{}{}
}
identities := make([]string, 0, len(codes))
for _, menu := range platformbase.AllPlatformMenus() {
if _, ok := assigned[menu.MenuCode]; ok {
identities = append(identities, menu.Identity)
}
}
infra.Response.Success(ctx, gin.H{"menu_identities": identities})
}

View File

@@ -14,6 +14,7 @@ import (
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var productOwnerActions = map[string]bool{
@@ -43,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.StatusPending), Params: "{}", IsEnabled: false}
if err := decodeValues(values, &data); err != nil || data.Code == "" || data.Name == "" || data.ProducedAt.IsZero() {
if err := decodeValues(values, &data); err != nil || data.Code == "" || data.Name == "" || data.ProducedAt.IsZero() || !validProductOwnership(data) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
@@ -67,6 +68,8 @@ func createProductInfo(ctx *gin.Context, fields []string, relations []common.Res
common.RespondCreatedResource(ctx, &data)
}
func canStartProductRepair(pendingCount int64) bool { return pendingCount == 0 }
func updateProductInfo(ctx *gin.Context, fields []string, relations []common.ResourceRelation) {
var input map[string]any
if err := ctx.ShouldBindJSON(&input); err != nil {
@@ -114,6 +117,13 @@ func updateProductInfo(ctx *gin.Context, fields []string, relations []common.Res
}
}
}
if enabled, ok := values["is_enabled"].(bool); ok && !enabled {
values["status"] = common.StatusPending
}
preview := current
if err := decodeValues(values, &preview); err != nil || !validProductOwnership(preview) {
return errors.New("product can have at most one current owner")
}
ownershipChanged := ownershipValuesChanged(current, values)
if ownershipChanged && !productOwnerActions[action] {
return errors.New("invalid ownership action")
@@ -136,18 +146,15 @@ func updateProductInfo(ctx *gin.Context, fields []string, relations []common.Res
infra.Response.Success(ctx, gin.H{"updated": true})
}
func ProductOwnerHandlers(relations ...common.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) { common.GetResource(ctx, &models.ProductOwner{}) }
func ProductOwnerHandlers() (gin.HandlerFunc, gin.HandlerFunc) {
return listProductOwners, func(ctx *gin.Context) { common.GetResource(ctx, &models.ProductOwner{}) }
}
func listProductOwners(ctx *gin.Context) {
page, size := common.PageSize(ctx)
var list []models.ProductOwner
var total int64
query := common.ApplyKeywordFilter(ctx, impl.DBService.Model(&models.ProductOwner{}), &models.ProductOwner{})
query := common.ApplyKeywordFilter(ctx, common.ActiveRecords(impl.DBService.Model(&models.ProductOwner{})), &models.ProductOwner{})
if err := query.Count(&total).Error; err != nil {
infra.Response.Error(ctx, err)
return
@@ -172,7 +179,11 @@ func UpdateProductInfoStatus(ctx *gin.Context) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
common.UpdateAllowedByIdentity(ctx, &models.ProductInfo{}, gin.H{"status": request.Status}, []string{"status"})
values := gin.H{"status": request.Status}
if request.Status == common.StatusScrapped {
values["is_enabled"] = false
}
common.UpdateAllowedByIdentity(ctx, &models.ProductInfo{}, values, []string{"status", "is_enabled"})
}
func ProductRepairHandlers(relations ...common.ResourceRelation) (gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc) {
@@ -195,6 +206,14 @@ func createProductRepair(ctx *gin.Context, fields []string, relations []common.R
return
}
err = impl.DBService.Transaction(func(tx *gorm.DB) error {
var product models.ProductInfo
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&product, data.ProductInfoID).Error; err != nil {
return err
}
var pending int64
if err := tx.Model(&models.ProductRepair{}).Where("product_info_id = ? AND result = ?", data.ProductInfoID, "pending").Count(&pending).Error; err != nil || !canStartProductRepair(pending) {
return errors.New("product already has a pending repair")
}
if err := tx.Create(&data).Error; err != nil {
return err
}
@@ -239,6 +258,19 @@ func updateProductRepair(ctx *gin.Context, fields []string, relations []common.R
return err
}
if current.Result == "pending" && preview.Result != "pending" {
var product models.ProductInfo
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&product, preview.ProductInfoID).Error; err != nil {
return err
}
var remaining int64
if err := tx.Model(&models.ProductRepair{}).
Where("product_info_id = ? AND result = ? AND id <> ?", preview.ProductInfoID, "pending", current.ID).
Count(&remaining).Error; err != nil {
return err
}
if remaining != 0 {
return nil
}
return tx.Model(&models.ProductInfo{}).Where("id = ?", preview.ProductInfoID).Update("status", preview.TargetStatus).Error
}
return nil
@@ -265,30 +297,6 @@ func validRepair(repair models.ProductRepair) bool {
}
}
func createProductOwner(ctx *gin.Context, fields []string, relations []common.ResourceRelation) {
values, err := common.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: common.NewEntity(common.StatusRecorded)}
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
}
common.RespondCreatedResource(ctx, &data)
}
func validParamsText(value any) bool {
if value == nil {
return true
@@ -319,6 +327,16 @@ func initialProductStatus(product models.ProductInfo) int {
return common.StatusPending
}
func validProductOwnership(product models.ProductInfo) bool {
count := 0
for _, id := range []uint64{product.WarehouseID, product.GasBasicID, product.DeliveryBasicID, product.UserAccountID} {
if id != 0 {
count++
}
}
return count <= 1
}
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,
@@ -357,7 +375,7 @@ func intValue(value any) (int, bool) {
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: common.StatusRecorded, Version: 1},
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusRecorded},
ProductInfoID: product.ID, WarehouseID: product.WarehouseID, GasBasicID: product.GasBasicID,
DeliveryBasicID: product.DeliveryBasicID, UserAccountID: product.UserAccountID,
Action: action, OccurredAt: occurredAt, Reason: reason, Remark: remark,

View File

@@ -1,6 +1,8 @@
package product
import (
"reflect"
"strings"
"testing"
"time"
@@ -21,6 +23,21 @@ func TestValidParamsTextRequiresJSONObjectText(t *testing.T) {
}
}
func TestOnlyOnePendingRepairCanStart(t *testing.T) {
if !canStartProductRepair(0) {
t.Fatal("first pending repair was rejected")
}
if canStartProductRepair(1) {
t.Fatal("second concurrent pending repair was accepted")
}
field, _ := reflect.TypeOf(models.ProductRepair{}).FieldByName("ProductInfoID")
tag := field.Tag.Get("gorm")
if !strings.Contains(tag, "uniqueIndex:idx_pending_product_repair") ||
!strings.Contains(tag, "where:result = 'pending'") {
t.Fatalf("database pending-repair constraint missing: %q", tag)
}
}
func TestInitialProductStatusPrefersActualWarehouse(t *testing.T) {
if got := initialProductStatus(models.ProductInfo{WarehouseID: 1, UserAccountID: 2}); got != common.StatusInStock {
t.Fatalf("warehouse product status = %d", got)
@@ -59,3 +76,12 @@ func TestOwnershipValuesChangedOnlyForActualDifference(t *testing.T) {
t.Fatal("changed ownership was not detected")
}
}
func TestProductHasAtMostOneCurrentOwner(t *testing.T) {
if !validProductOwnership(models.ProductInfo{WarehouseID: 1}) {
t.Fatal("single warehouse owner was rejected")
}
if validProductOwnership(models.ProductInfo{WarehouseID: 1, UserAccountID: 2}) {
t.Fatal("conflicting warehouse and user owners were accepted")
}
}

View File

@@ -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", AppendOnly, "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", Writable, "list"), resourceContract("ec", "ec_order", Writable, "list"), resourceContract("ec", "ec_order_item", Writable, "list"), resourceContract("ec", "ec_review", Writable, "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

@@ -7,6 +7,7 @@ import (
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type addressRequest struct {
@@ -31,7 +32,14 @@ func CreateUserAddress(ctx *gin.Context) {
return
}
address := models.UserAddress{Entity: common.NewEntity(common.StatusEnable), UserAccountID: userAccountID, Address: request.Address, Longitude: request.Longitude, Latitude: request.Latitude, IsDefault: request.IsDefault}
if err := impl.DBService.Create(&address).Error; err != nil {
if err := impl.DBService.Transaction(func(tx *gorm.DB) error {
if request.IsDefault {
if err := tx.Model(&models.UserAddress{}).Where("user_account_id = ?", userAccountID).Update("is_default", false).Error; err != nil {
return err
}
}
return tx.Create(&address).Error
}); err != nil {
infra.Response.Error(ctx, err)
return
}
@@ -48,7 +56,25 @@ func UpdateUserAddress(ctx *gin.Context) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
common.UpdateAllowedByIdentity(ctx, &models.UserAddress{}, gin.H{"user_account_id": userAccountID, "address": request.Address, "longitude": request.Longitude, "latitude": request.Latitude, "is_default": request.IsDefault}, []string{"user_account_id", "address", "longitude", "latitude", "is_default"})
err = impl.DBService.Transaction(func(tx *gorm.DB) error {
var current models.UserAddress
if err := tx.Where("identity = ?", ctx.Param("identity")).First(&current).Error; err != nil {
return err
}
if request.IsDefault {
if err := tx.Model(&models.UserAddress{}).
Where("user_account_id = ? AND id <> ?", userAccountID, current.ID).
Update("is_default", false).Error; err != nil {
return err
}
}
return tx.Model(&current).Updates(gin.H{"user_account_id": userAccountID, "address": request.Address, "longitude": request.Longitude, "latitude": request.Latitude, "is_default": request.IsDefault}).Error
})
if err != nil {
common.RespondRecordError(ctx, err)
return
}
infra.Response.Success(ctx, gin.H{"updated": true})
}
type serviceRelationRequest struct {

View File

@@ -43,7 +43,7 @@ func listWalletPage[T any](ctx *gin.Context) {
var list []T
var total int64
model := new(T)
query := common.ApplyKeywordFilter(ctx, impl.DBService.Model(model), model)
query := common.ApplyKeywordFilter(ctx, common.ActiveRecords(impl.DBService.Model(model)), model)
if err := query.Count(&total).Error; err != nil {
infra.Response.Error(ctx, err)
return
@@ -133,7 +133,7 @@ func GetOrCreateOwnerWallet(ctx *gin.Context) {
return err
}
candidate := models.WalletBasic{
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusEnable, Version: 1},
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusEnable},
OwnerType: ownerType, OwnerID: ownerID, OwnerIdentity: ownerIdentity,
}
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&candidate).Error; err != nil {
@@ -227,7 +227,7 @@ func RechargeWalletBasic(ctx *gin.Context) {
}
now := time.Now()
record = models.WalletRecord{
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusPosted, Version: 1},
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusPosted},
WalletBasicID: wallet.ID, RecordNo: models.NewIdentity(), RequestNo: request.RequestNo,
Direction: "income", TradeType: "recharge", Amount: request.Amount,
BalanceAfter: wallet.Balance, WithdrawalBalanceAfter: wallet.WithdrawalBalance,
@@ -239,6 +239,15 @@ func RechargeWalletBasic(ctx *gin.Context) {
return tx.Create(&record).Error
})
if err != nil {
if lookupErr := impl.DBService.Where("request_no = ?", request.RequestNo).First(&record).Error; lookupErr == nil {
response, responseErr := common.PublicResourceResponse(record)
if responseErr != nil {
infra.Response.Error(ctx, responseErr)
return
}
infra.Response.Success(ctx, response)
return
}
common.RespondRecordError(ctx, err)
return
}