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] }