refactor(platform): remove deprecated reporting and audit modules

This commit is contained in:
2026-07-27 15:31:22 +08:00
parent e88734c0fd
commit a98fdd0fca
42 changed files with 86 additions and 674 deletions

View File

@@ -42,11 +42,8 @@ func InitPlatformAccess(database *gorm.DB) error {
{Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "ec", Name: "电商管理", Icon: "icon-shopping", Path: "/ec/product", SortNo: 80},
{Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "finance", Name: "财务管理", Icon: "icon-safe", Path: "/finance/payment", SortNo: 90},
{Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "wallet", Name: "钱包中心", Icon: "icon-wallet", Path: "/wallet/list", SortNo: 100},
{Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "report", Name: "统计报表", Icon: "icon-bar-chart", Path: "/report/list", SortNo: 110},
{Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "content", Name: "内容管理", Icon: "icon-file", Path: "/content", SortNo: 120},
{Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "notification", Name: "通知管理", Icon: "icon-notification", Path: "/notification", SortNo: 130},
{Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "customer_service", Name: "客户服务", Icon: "icon-customer-service", Path: "/customer_service", SortNo: 140},
{Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "audit", Name: "审计合规", Icon: "icon-history", Path: "/audit", SortNo: 150},
{Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "platform", Name: "平台配置", Icon: "icon-settings", Path: "/platform/account", SortNo: 160},
}
for index := range menus {

View File

@@ -27,8 +27,7 @@ func TestInitPlatformAccessSeedsEveryProtectedFrontendDomain(t *testing.T) {
domains := []string{
"dashboard", "gas", "delivery", "staff", "user", "device", "safety",
"ec", "finance", "wallet", "report", "content", "notification",
"customer_service", "audit", "platform",
"ec", "finance", "wallet", "content", "customer_service", "platform",
}
for index, domain := range domains {
menuID := uint64(index + 10)

View File

@@ -1,103 +0,0 @@
package platform
import (
"encoding/json"
"errors"
"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 errApprovalNotProcessable = errors.New("approval is not processable")
func approvalValues(status, opinion, operatorIdentity string) map[string]any {
return map[string]any{
"status": status,
"opinion": opinion,
"handler_identity": operatorIdentity,
"handled_at": time.Now().UTC(),
}
}
// ApproveAudit records the reviewer and decision without allowing an approval
// to mutate any business fields. The operation audit is created atomically with
// the approval update.
func ApproveAudit(ctx *gin.Context) {
claims, err := middleware.ParseAuth(ctx)
if err != nil {
infra.Response.Error(ctx, err)
return
}
var request struct {
Status string `json:"status" binding:"required,max=32"`
Opinion string `json:"opinion" binding:"max=2000"`
}
if err := ctx.ShouldBindJSON(&request); err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
if request.Status != "approved" && request.Status != "rejected" {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
values := approvalValues(request.Status, request.Opinion, claims.Identity)
var approval models.AuditApproval
err = impl.DBService.Transaction(func(transaction *gorm.DB) error {
if err := transaction.Where("identity = ?", ctx.Param("identity")).First(&approval).Error; err != nil {
return err
}
if approval.Status != "pending" || approval.ApplicantIdentity == claims.Identity {
return errApprovalNotProcessable
}
before, err := json.Marshal(gin.H{
"status": approval.Status, "opinion": approval.Opinion,
"handler_identity": approval.HandlerIdentity, "handled_at": approval.HandledAt,
})
if err != nil {
return err
}
if result := transaction.Model(&models.AuditApproval{}).Where("identity = ? AND status = ?", approval.Identity, "pending").Updates(values); result.Error != nil {
return result.Error
} else if result.RowsAffected == 0 {
return errApprovalNotProcessable
}
after, err := json.Marshal(values)
if err != nil {
return err
}
return transaction.Create(&models.AuditOperationLog{
Entity: newEntity("enabled"),
OperatorIdentity: claims.Identity,
Action: "approve",
ObjectType: "audit_approval",
ObjectIdentity: approval.Identity,
BeforeData: string(before),
AfterData: string(after),
}).Error
})
if err != nil {
if errors.Is(err, errApprovalNotProcessable) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
if errors.Is(err, gorm.ErrRecordNotFound) {
infra.Response.Error(ctx, errcode.ErrRecordNotFound)
return
}
infra.Response.Error(ctx, err)
return
}
approval.Status = request.Status
approval.Opinion = request.Opinion
approval.HandlerIdentity = claims.Identity
handledAt := values["handled_at"].(time.Time)
approval.HandledAt = &handledAt
infra.Response.Success(ctx, resourceResponse(approval))
}

View File

@@ -1,135 +0,0 @@
package platform
import (
"database/sql/driver"
"net/http"
"regexp"
"strings"
"testing"
"time"
"git.apinb.com/bsm-sdk/core/errcode"
"git.apinb.com/bsm-sdk/core/types"
"github.com/DATA-DOG/go-sqlmock"
"google.golang.org/grpc/status"
)
func TestApprovalValuesOnlyChangesApprovalFieldsAndRecordsOperator(t *testing.T) {
values := approvalValues("approved", "accepted", "operator-a")
if values["status"] != "approved" || values["opinion"] != "accepted" || values["handler_identity"] != "operator-a" {
t.Fatalf("approval values do not retain the approved state, opinion, and operator: %#v", values)
}
if _, ok := values["handled_at"]; !ok {
t.Fatalf("approval values do not record handling time: %#v", values)
}
if len(values) != 4 {
t.Fatalf("approval update includes fields outside its whitelist: %#v", values)
}
}
func TestApproveAuditOnlyUpdatesApprovalFieldsAndAppendsOperationAudit(t *testing.T) {
_, mock := setupPlatformRoleDatabase(t)
now := time.Now().UTC()
mock.ExpectBegin()
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "audit_approval" WHERE identity = $1 ORDER BY "audit_approval"."id" LIMIT $2`)).
WithArgs("approval-a", 1).
WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "business_type", "business_identity", "applicant_identity", "opinion", "handler_identity", "handled_at"}).
AddRow(uint64(1), "approval-a", now, now, "pending", 1, "refund", "payment-a", "applicant-a", "", "", nil))
mock.ExpectExec(regexp.QuoteMeta(`UPDATE "audit_approval" SET "handled_at"=$1,"handler_identity"=$2,"opinion"=$3,"status"=$4,"updated_at"=$5 WHERE identity = $6 AND status = $7`)).
WithArgs(sqlmock.AnyArg(), "operator-a", "accepted", "approved", sqlmock.AnyArg(), "approval-a", "pending").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO "audit_operation_log" ("identity","created_at","updated_at","status","version","operator_identity","action","object_type","object_identity","before_data","after_data") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING "id"`)).
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), "enabled", 1, "operator-a", "approve", "audit_approval", "approval-a", jsonContaining(`"status":"pending"`), jsonContaining(`"handler_identity":"operator-a"`)).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uint64(2)))
mock.ExpectCommit()
ctx, recorder := updateContext(http.MethodPost, "/audit/audit_approval/approval-a/approve", "approval-a", []byte(`{"status":"approved","opinion":"accepted","business_identity":"payment-b"}`))
ctx.Set("Auth", &types.JwtClaims{Identity: "operator-a"})
ApproveAudit(ctx)
assertMockExpectations(t, mock)
assertResponseCode(t, recorder, 0)
if !strings.Contains(recorder.Body.String(), `"handler_identity":"operator-a"`) {
t.Fatalf("approval response omitted its handler: %s", recorder.Body.String())
}
}
func TestApproveAuditRejectsStatusesOutsideApprovedAndRejected(t *testing.T) {
for _, decision := range []string{"pending", "archived"} {
t.Run(decision, func(t *testing.T) {
_, mock := setupPlatformRoleDatabase(t)
ctx, recorder := updateContext(http.MethodPost, "/audit/audit_approval/approval-a/approve", "approval-a", []byte(`{"status":"`+decision+`"}`))
ctx.Set("Auth", &types.JwtClaims{Identity: "operator-a"})
ApproveAudit(ctx)
assertResponseCode(t, recorder, int32(status.Code(errcode.ErrInvalidArgument)))
assertMockExpectations(t, mock)
})
}
}
func TestApproveAuditRejectsAlreadyHandledApproval(t *testing.T) {
_, mock := setupPlatformRoleDatabase(t)
mock.ExpectBegin()
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "audit_approval" WHERE identity = $1 ORDER BY "audit_approval"."id" LIMIT $2`)).
WithArgs("approval-a", 1).
WillReturnRows(approvalRows("approval-a", "approved", "applicant-a"))
mock.ExpectRollback()
ctx, recorder := updateContext(http.MethodPost, "/audit/audit_approval/approval-a/approve", "approval-a", []byte(`{"status":"approved"}`))
ctx.Set("Auth", &types.JwtClaims{Identity: "operator-a"})
ApproveAudit(ctx)
assertResponseCode(t, recorder, int32(status.Code(errcode.ErrInvalidArgument)))
assertMockExpectations(t, mock)
}
func TestApproveAuditRejectsTheApplicant(t *testing.T) {
_, mock := setupPlatformRoleDatabase(t)
mock.ExpectBegin()
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "audit_approval" WHERE identity = $1 ORDER BY "audit_approval"."id" LIMIT $2`)).
WithArgs("approval-a", 1).
WillReturnRows(approvalRows("approval-a", "pending", "operator-a"))
mock.ExpectRollback()
ctx, recorder := updateContext(http.MethodPost, "/audit/audit_approval/approval-a/approve", "approval-a", []byte(`{"status":"approved"}`))
ctx.Set("Auth", &types.JwtClaims{Identity: "operator-a"})
ApproveAudit(ctx)
assertResponseCode(t, recorder, int32(status.Code(errcode.ErrInvalidArgument)))
assertMockExpectations(t, mock)
}
func TestApproveAuditRejectsAConcurrentSecondDecision(t *testing.T) {
_, mock := setupPlatformRoleDatabase(t)
mock.ExpectBegin()
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "audit_approval" WHERE identity = $1 ORDER BY "audit_approval"."id" LIMIT $2`)).
WithArgs("approval-a", 1).
WillReturnRows(approvalRows("approval-a", "pending", "applicant-a"))
mock.ExpectExec(regexp.QuoteMeta(`UPDATE "audit_approval" SET "handled_at"=$1,"handler_identity"=$2,"opinion"=$3,"status"=$4,"updated_at"=$5 WHERE identity = $6 AND status = $7`)).
WithArgs(sqlmock.AnyArg(), "operator-a", "", "approved", sqlmock.AnyArg(), "approval-a", "pending").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectRollback()
ctx, recorder := updateContext(http.MethodPost, "/audit/audit_approval/approval-a/approve", "approval-a", []byte(`{"status":"approved"}`))
ctx.Set("Auth", &types.JwtClaims{Identity: "operator-a"})
ApproveAudit(ctx)
assertResponseCode(t, recorder, int32(status.Code(errcode.ErrInvalidArgument)))
assertMockExpectations(t, mock)
}
func approvalRows(identity, approvalStatus, applicant string) *sqlmock.Rows {
now := time.Now().UTC()
return sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "business_type", "business_identity", "applicant_identity", "opinion", "handler_identity", "handled_at"}).
AddRow(uint64(1), identity, now, now, approvalStatus, 1, "refund", "payment-a", applicant, "", "", nil)
}
type jsonContaining string
func (expected jsonContaining) Match(value driver.Value) bool {
actual, ok := value.(string)
return ok && strings.Contains(actual, string(expected))
}

View File

@@ -69,9 +69,7 @@ var keywordSafeColumns = map[string]bool{
"content_type": true, "publish_status": true, "template_code": true,
"ticket_no": true, "category": true, "priority": true,
"platform_role_code": true, "data_scope": true, "menu_code": true,
"path": true, "report_code": true, "report_type": true,
"stat_period": true, "dimension": true, "metric_code": true,
"scope_type": true, "business_type": true, "resource_type": true,
"path": true, "resource_type": true,
}
func applyKeywordFilter(ctx *gin.Context, query *gorm.DB, model any) *gorm.DB {
@@ -100,7 +98,7 @@ func keywordColumns(model any) []string {
columns := make([]string, 0)
for index := 0; index < modelType.NumField(); index++ {
field := modelType.Field(index)
if field.Anonymous || field.Type.Kind() != reflect.String || strings.Contains(field.Tag.Get("gorm"), "type:jsonb") {
if field.Anonymous || field.Type.Kind() != reflect.String {
continue
}
column := gormColumn(field.Tag.Get("gorm"))

View File

@@ -79,11 +79,9 @@ func ExpectedResources() []ResourceContract {
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"),
resourceContract("content", "cnt_content", Writable, "list"), resourceContract("notification", "ntf_template", Writable, "list"), resourceContract("customer_service", "cs_ticket", Writable, "list"),
resourceContract("content", "cms_content", Writable, "list"), resourceContract("customer_service", "cs_ticket", Writable, "list"),
resourceContract("platform", "platfrom_account", Writable, "list"), resourceContract("platform", "platform_role", Writable, "list"), resourceContract("platform", "platform_menu", Writable, "tree"),
resourceContract("wallet", "wallet", ReadOnly, "list"), resourceContract("wallet", "wallet_ledger", ReadOnly, "list"), resourceContract("wallet", "wallet_recharge", ReadOnly, "list"), resourceContract("wallet", "wallet_withdrawal", ReadOnly, "list"),
resourceContract("report", "report", ReadOnly, "list"), resourceContract("report", "report_item", ReadOnly, "list"), resourceContract("report", "report_metric_snapshot", ReadOnly, "list"),
resourceContract("audit", "audit_operation_log", ReadOnly, "list"), resourceContract("audit", "audit_export_log", ReadOnly, "list"), resourceContract("audit", "audit_approval", ReadOnly, "list"),
}
}

View File

@@ -30,21 +30,27 @@ func TestExpectedResources(t *testing.T) {
assertContract(t, ExpectedResources(), "delivery", "delivery_track_point", ReadOnly, "list")
}
func TestSafeAndAuditResourceContracts(t *testing.T) {
func TestContentUsesCMSResourceAndRemovedDomainsAreAbsent(t *testing.T) {
resources := ExpectedResources()
assertContract(t, resources, "content", "cms_content", Writable, "list")
for _, removed := range []string{"ntf_template", "report", "report_item", "report_metric_snapshot", "audit_operation_log", "audit_export_log", "audit_approval"} {
for _, resource := range resources {
if resource.Name == removed {
t.Fatalf("removed resource %s remains registered", removed)
}
}
}
}
func TestSafeResourceContracts(t *testing.T) {
legacySafetyPrefix := "sa" + "f_"
legacyAuditPrefix := "au" + "d_"
assertContract(t, ExpectedResources(), "safety", "safe_rule", Writable, "list")
assertContract(t, ExpectedResources(), "safety", "safe_event", Writable, "list")
assertContract(t, ExpectedResources(), "safety", "safe_inspection", Writable, "list")
assertContract(t, ExpectedResources(), "safety", "safe_event_disposal", AppendOnly, "list")
assertContract(t, ExpectedResources(), "audit", "audit_operation_log", ReadOnly, "list")
assertContract(t, ExpectedResources(), "audit", "audit_export_log", ReadOnly, "list")
assertContract(t, ExpectedResources(), "audit", "audit_approval", ReadOnly, "list")
for _, contract := range ExpectedResources() {
if (contract.Domain == "safety" && strings.HasPrefix(contract.Name, legacySafetyPrefix)) ||
(contract.Domain == "audit" && strings.HasPrefix(contract.Name, legacyAuditPrefix)) {
if contract.Domain == "safety" && strings.HasPrefix(contract.Name, legacySafetyPrefix) {
t.Fatalf("legacy resource contract %s/%s must not be registered", contract.Domain, contract.Name)
}
}
@@ -251,15 +257,15 @@ func TestPrepareResourceValuesResolvesRequiredIdentityRelationsAndRejectsInvalid
}
}
func TestPrepareResourceValuesNormalizesStringJSONBFields(t *testing.T) {
func TestPrepareResourceValuesAcceptsArbitraryTextFields(t *testing.T) {
t.Run("safety rule", func(t *testing.T) {
ctx, _ := updateContext(http.MethodPost, "/safety/safe_rule", "", []byte(`{"rule_code":"pressure-limit","threshold":{"max":10},"action":"close-valve","gray_scope":["north"]}`))
ctx, _ := updateContext(http.MethodPost, "/safety/safe_rule", "", []byte(`{"rule_code":"pressure-limit","threshold":"{invalid}","action":"close-valve","gray_scope":"north region"}`))
values, err := prepareResourceValues(ctx, &models.SafeRule{}, []string{"rule_code", "threshold", "action", "gray_scope"}, nil)
if err != nil {
t.Fatal(err)
}
if values["threshold"] != `{"max":10}` || values["gray_scope"] != `["north"]` {
t.Fatalf("jsonb values were not normalized to strings: %#v", values)
if values["threshold"] != `{invalid}` || values["gray_scope"] != `north region` {
t.Fatalf("text values were not preserved: %#v", values)
}
})
@@ -272,7 +278,7 @@ func TestPrepareResourceValuesNormalizesStringJSONBFields(t *testing.T) {
WithArgs("product-a", 1).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uint64(2)))
ctx, _ := updateContext(http.MethodPost, "/ec/ec_order_item", "", []byte(`{"ec_order_identity":"order-a","ec_product_identity":"product-a","product_snapshot":{"name":"液化气"},"quantity":2,"sale_amount":500}`))
ctx, _ := updateContext(http.MethodPost, "/ec/ec_order_item", "", []byte(`{"ec_order_identity":"order-a","ec_product_identity":"product-a","product_snapshot":"arbitrary product snapshot text","quantity":2,"sale_amount":500}`))
values, err := prepareResourceValues(ctx, &models.EcOrderItem{}, []string{"product_snapshot", "quantity", "sale_amount"}, []ResourceRelation{
{Input: "ec_order_identity", Column: "ec_order_id", Model: &models.EcOrder{}, Required: true},
{Input: "ec_product_identity", Column: "ec_product_id", Model: &models.EcProduct{}, Required: true},
@@ -280,16 +286,16 @@ func TestPrepareResourceValuesNormalizesStringJSONBFields(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if values["product_snapshot"] != `{"name":"液化气"}` {
if values["product_snapshot"] != `arbitrary product snapshot text` {
t.Fatalf("product snapshot was not normalized to a string: %#v", values)
}
assertMockExpectations(t, mock)
})
t.Run("invalid json string", func(t *testing.T) {
t.Run("arbitrary text", func(t *testing.T) {
ctx, _ := updateContext(http.MethodPost, "/safety/safe_rule", "", []byte(`{"rule_code":"pressure-limit","threshold":"{invalid}","action":"close-valve"}`))
if _, err := prepareResourceValues(ctx, &models.SafeRule{}, []string{"rule_code", "threshold", "action"}, nil); err == nil {
t.Fatal("invalid JSON string was accepted for a string/jsonb field")
if _, err := prepareResourceValues(ctx, &models.SafeRule{}, []string{"rule_code", "threshold", "action"}, nil); err != nil {
t.Fatalf("arbitrary text was rejected: %v", err)
}
})
}
@@ -344,7 +350,7 @@ func TestKeywordColumnsUseSafeTextAllowlist(t *testing.T) {
model any
want []string
}{
{"safety rule excludes jsonb", &models.SafeRule{}, []string{"rule_code", "action"}},
{"safety rule excludes arbitrary text fields", &models.SafeRule{}, []string{"rule_code", "action"}},
{"gas basic excludes sensitive fields", &models.GasBasic{}, []string{"code", "name"}},
{"user address has no searchable safe text", &models.UserAddress{}, []string{}},
}

View File

@@ -266,7 +266,7 @@ func updateResource(ctx *gin.Context, model any, allowedFields []string, relatio
return
}
values, err := resolveResourceRelations(input, allowedFields, relations, false)
if err != nil || normalizeStringJSONBFields(model, values) != nil {
if err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
@@ -283,42 +283,12 @@ func prepareResourceValues(ctx *gin.Context, model any, allowedFields []string,
return nil, errors.New("invalid resource payload")
}
values, err := resolveResourceRelations(input, allowedFields, relations, true)
if err != nil || normalizeStringJSONBFields(model, values) != nil || len(values) == 0 {
if err != nil || len(values) == 0 {
return nil, errors.New("invalid resource payload")
}
return values, nil
}
func normalizeStringJSONBFields(model any, values map[string]any) error {
modelType := reflect.TypeOf(model)
for modelType.Kind() == reflect.Pointer {
modelType = modelType.Elem()
}
for index := 0; index < modelType.NumField(); index++ {
field := modelType.Field(index)
if field.Type.Kind() != reflect.String || !strings.Contains(field.Tag.Get("gorm"), "type:jsonb") {
continue
}
column := gormColumn(field.Tag.Get("gorm"))
value, exists := values[column]
if !exists {
continue
}
if text, ok := value.(string); ok {
if !json.Valid([]byte(text)) {
return errors.New("invalid jsonb string")
}
continue
}
encoded, err := json.Marshal(value)
if err != nil || !json.Valid(encoded) {
return errors.New("invalid jsonb value")
}
values[column] = string(encoded)
}
return nil
}
func resolveResourceRelations(input map[string]any, allowedFields []string, relations []ResourceRelation, requireRelations bool) (map[string]any, error) {
values := filterFields(input, allowedFields)
for _, relation := range relations {
@@ -411,7 +381,6 @@ var relationIdentityModels = map[string]any{
"delivery_track_id": &models.DeliveryTrack{},
"platform_role_id": &models.PlatformRole{},
"platform_menu_id": &models.PlatformMenu{},
"report_id": &models.Report{},
"wallet_id": &models.Wallet{},
}

View File

@@ -1,21 +0,0 @@
package models
import (
"time"
"git.apinb.com/bsm-sdk/core/database"
)
// AuditApproval 对应 audit_approval保存审批流与复核意见。
type AuditApproval struct {
Entity // 公共实体字段
BusinessType string `gorm:"column:business_type;type:varchar(64);not null" json:"business_type"` // business_type 业务字段
BusinessIdentity string `gorm:"column:business_identity;type:varchar(36);not null;index" json:"business_identity"` // business_identity 业务字段
ApplicantIdentity string `gorm:"column:applicant_identity;type:varchar(36);not null;index" json:"applicant_identity"` // applicant_identity 业务字段
Opinion string `gorm:"column:opinion;type:text;not null;default:''" json:"opinion"` // opinion 业务字段
HandlerIdentity string `gorm:"column:handler_identity;type:varchar(36);not null;default:'';index" json:"handler_identity"` // handler_identity 业务字段
HandledAt *time.Time `gorm:"column:handled_at;type:timestamptz" json:"handled_at"` // handled_at 业务字段
}
func init() { database.AppendMigrate(&AuditApproval{}) }
func (table *AuditApproval) TableName() string { return "audit_approval" }

View File

@@ -1,19 +0,0 @@
package models
import (
"git.apinb.com/bsm-sdk/core/database"
"time"
)
// AuditExportLog 对应 audit_export_log保存敏感导出审计。
type AuditExportLog struct {
Entity // 公共实体字段
ApplicantIdentity string `gorm:"column:applicant_identity;type:varchar(36);not null;index" json:"applicant_identity"` // applicant_identity 业务字段
Purpose string `gorm:"column:purpose;type:varchar(255);not null" json:"purpose"` // purpose 业务字段
FieldScope string `gorm:"column:field_scope;type:jsonb;not null;default:'{}'" json:"field_scope"` // field_scope 业务字段
ApprovedAt *time.Time `gorm:"column:approved_at;type:timestamptz" json:"approved_at"` // approved_at 业务字段
FileURI string `gorm:"column:file_uri;type:varchar(512);not null;default:''" json:"file_uri"` // file_uri 业务字段
}
func init() { database.AppendMigrate(&AuditExportLog{}) }
func (table *AuditExportLog) TableName() string { return "audit_export_log" }

View File

@@ -1,17 +0,0 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// AuditOperationLog 对应 audit_operation_log保存不可变操作审计。
type AuditOperationLog struct {
Entity // 公共实体字段
OperatorIdentity string `gorm:"column:operator_identity;type:varchar(36);not null;index" json:"operator_identity"` // operator_identity 业务字段
Action string `gorm:"column:action;type:varchar(64);not null" json:"action"` // action 业务字段
ObjectType string `gorm:"column:object_type;type:varchar(64);not null" json:"object_type"` // object_type 业务字段
ObjectIdentity string `gorm:"column:object_identity;type:varchar(36);not null;index" json:"object_identity"` // object_identity 业务字段
BeforeData string `gorm:"column:before_data;type:jsonb;not null;default:'{}'" json:"before_data"` // before_data 业务字段
AfterData string `gorm:"column:after_data;type:jsonb;not null;default:'{}'" json:"after_data"` // after_data 业务字段
}
func init() { database.AppendMigrate(&AuditOperationLog{}) }
func (table *AuditOperationLog) TableName() string { return "audit_operation_log" }

View File

@@ -2,8 +2,8 @@ package models
import "git.apinb.com/bsm-sdk/core/database"
// CntContent 对应 cnt_content保存公告与协议内容。
type CntContent struct {
// CmsContent 对应 cms_content保存公告与协议内容。
type CmsContent struct {
Entity // 公共实体字段
ContentType string `gorm:"column:content_type;type:varchar(32);not null" json:"content_type"` // content_type 业务字段
Title string `gorm:"column:title;type:varchar(256);not null" json:"title"` // title 业务字段
@@ -12,5 +12,5 @@ type CntContent struct {
PublishStatus string `gorm:"column:publish_status;type:varchar(32);not null;default:'draft'" json:"publish_status"` // publish_status 业务字段
}
func init() { database.AppendMigrate(&CntContent{}) }
func (table *CntContent) TableName() string { return "cnt_content" }
func init() { database.AppendMigrate(&CmsContent{}) }
func (table *CmsContent) TableName() string { return "cms_content" }

View File

@@ -10,7 +10,7 @@ type DevTelemetry struct {
Entity // 公共实体字段
SmartCylinderValveIdentity string `gorm:"column:smart_cylinder_valve_identity;type:varchar(36);not null;index" json:"smart_cylinder_valve_identity"` // smart_cylinder_valve_identity 业务字段
ReportedAt time.Time `gorm:"column:reported_at;type:timestamptz;not null;index" json:"reported_at"` // reported_at 业务字段
Payload string `gorm:"column:payload;type:jsonb;not null;default:'{}'" json:"payload"` // payload 业务字段
Payload string `gorm:"column:payload;type:text;not null;default:''" json:"payload"` // payload 业务字段
QualityFlag string `gorm:"column:quality_flag;type:varchar(32);not null;default:'normal'" json:"quality_flag"` // quality_flag 业务字段
}

View File

@@ -5,11 +5,11 @@ import "git.apinb.com/bsm-sdk/core/database"
// EcOrderItem 对应 ec_order_item保存订单商品快照。
type EcOrderItem struct {
Entity // 公共实体字段
EcOrderID uint64 `gorm:"column:ec_order_id;not null;index" json:"ec_order_id"` // ec_order_id 业务字段
EcProductID uint64 `gorm:"column:ec_product_id;not null;index" json:"ec_product_id"` // ec_product_id 业务字段
ProductSnapshot string `gorm:"column:product_snapshot;type:jsonb;not null;default:'{}'" json:"product_snapshot"` // product_snapshot 业务字段
Quantity int `gorm:"column:quantity;not null;default:1" json:"quantity"` // quantity 业务字段
SaleAmount int64 `gorm:"column:sale_amount;not null;default:0" json:"sale_amount"` // sale_amount 业务字段
EcOrderID uint64 `gorm:"column:ec_order_id;not null;index" json:"ec_order_id"` // ec_order_id 业务字段
EcProductID uint64 `gorm:"column:ec_product_id;not null;index" json:"ec_product_id"` // ec_product_id 业务字段
ProductSnapshot string `gorm:"column:product_snapshot;type:text;not null;default:''" json:"product_snapshot"` // product_snapshot 业务字段
Quantity int `gorm:"column:quantity;not null;default:1" json:"quantity"` // quantity 业务字段
SaleAmount int64 `gorm:"column:sale_amount;not null;default:0" json:"sale_amount"` // sale_amount 业务字段
}
func init() { database.AppendMigrate(&EcOrderItem{}) }

View File

@@ -1,14 +0,0 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// NtfTemplate 对应 ntf_template保存通知模板。
type NtfTemplate struct {
Entity // 公共实体字段
TemplateCode string `gorm:"column:template_code;type:varchar(64);not null;uniqueIndex" json:"template_code"` // template_code 业务字段
Channel string `gorm:"column:channel;type:varchar(32);not null" json:"channel"` // channel 业务字段
Content string `gorm:"column:content;type:text;not null" json:"content"` // content 业务字段
}
func init() { database.AppendMigrate(&NtfTemplate{}) }
func (table *NtfTemplate) TableName() string { return "ntf_template" }

View File

@@ -1,18 +0,0 @@
package models
import (
"git.apinb.com/bsm-sdk/core/database"
"time"
)
// Report 对应 report保存统计报表档案。
type Report struct {
Entity // 公共实体字段
ReportCode string `gorm:"column:report_code;type:varchar(64);not null;uniqueIndex" json:"report_code"` // report_code 业务字段
ReportType string `gorm:"column:report_type;type:varchar(32);not null" json:"report_type"` // report_type 业务字段
StatPeriod string `gorm:"column:stat_period;type:varchar(64);not null" json:"stat_period"` // stat_period 业务字段
GeneratedAt time.Time `gorm:"column:generated_at;type:timestamptz;not null" json:"generated_at"` // generated_at 业务字段
}
func init() { database.AppendMigrate(&Report{}) }
func (table *Report) TableName() string { return "report" }

View File

@@ -1,15 +0,0 @@
package models
import "git.apinb.com/bsm-sdk/core/database"
// ReportItem 对应 report_item保存报表维度明细。
type ReportItem struct {
Entity // 公共实体字段
ReportID uint64 `gorm:"column:report_id;not null;index" json:"report_id"` // report_id 业务字段
Dimension string `gorm:"column:dimension;type:varchar(128);not null" json:"dimension"` // dimension 业务字段
MetricCode string `gorm:"column:metric_code;type:varchar(64);not null" json:"metric_code"` // metric_code 业务字段
MetricValue string `gorm:"column:metric_value;type:varchar(128);not null" json:"metric_value"` // metric_value 业务字段
}
func init() { database.AppendMigrate(&ReportItem{}) }
func (table *ReportItem) TableName() string { return "report_item" }

View File

@@ -1,19 +0,0 @@
package models
import (
"git.apinb.com/bsm-sdk/core/database"
"time"
)
// ReportMetricSnapshot 对应 report_metric_snapshot保存指标快照。
type ReportMetricSnapshot struct {
Entity // 公共实体字段
MetricCode string `gorm:"column:metric_code;type:varchar(64);not null;index" json:"metric_code"` // metric_code 业务字段
ScopeType string `gorm:"column:scope_type;type:varchar(32);not null" json:"scope_type"` // scope_type 业务字段
ScopeID uint64 `gorm:"column:scope_id;not null;default:0;index" json:"scope_id"` // scope_id 业务字段
StatAt time.Time `gorm:"column:stat_at;type:timestamptz;not null;index" json:"stat_at"` // stat_at 业务字段
MetricValue string `gorm:"column:metric_value;type:varchar(128);not null" json:"metric_value"` // metric_value 业务字段
}
func init() { database.AppendMigrate(&ReportMetricSnapshot{}) }
func (table *ReportMetricSnapshot) TableName() string { return "report_metric_snapshot" }

View File

@@ -7,9 +7,9 @@ type SafeRule struct {
Entity // 公共实体字段
RuleCode string `gorm:"column:rule_code;type:varchar(64);not null;uniqueIndex" json:"rule_code"` // rule_code 业务字段
VersionNo int `gorm:"column:version_no;not null;default:1" json:"version_no"` // version_no 业务字段
Threshold string `gorm:"column:threshold;type:jsonb;not null;default:'{}'" json:"threshold"` // threshold 业务字段
Threshold string `gorm:"column:threshold;type:text;not null;default:''" json:"threshold"` // threshold 业务字段
Action string `gorm:"column:action;type:varchar(64);not null" json:"action"` // action 业务字段
GrayScope string `gorm:"column:gray_scope;type:jsonb;not null;default:'{}'" json:"gray_scope"` // gray_scope 业务字段
GrayScope string `gorm:"column:gray_scope;type:text;not null;default:''" json:"gray_scope"` // gray_scope 业务字段
}
func init() { database.AppendMigrate(&SafeRule{}) }

View File

@@ -32,7 +32,6 @@ func RegisterPlatform(serviceKey string, engine *gin.Engine) {
registerCommerceRoute(protected)
registerFinanceRoute(protected)
registerContentRoute(protected)
registerAuditRoute(protected)
registerPlatformRoute(protected)
}
@@ -126,24 +125,13 @@ func registerFinanceRoute(group *gin.RouterGroup) {
registerReadOnlyResource(group, "/wallet/wallet_ledger", &models.WalletLedger{})
registerReadOnlyResource(group, "/wallet/wallet_recharge", &models.WalletRecharge{})
registerReadOnlyResource(group, "/wallet/wallet_withdrawal", &models.WalletWithdrawal{})
registerReadOnlyResource(group, "/report/report", &models.Report{})
registerReadOnlyResource(group, "/report/report_item", &models.ReportItem{})
registerReadOnlyResource(group, "/report/report_metric_snapshot", &models.ReportMetricSnapshot{})
}
func registerContentRoute(group *gin.RouterGroup) {
registerRestrictedWritableResource(group, "/content/cnt_content", &models.CntContent{}, []string{"content_type", "title", "body", "version_no", "publish_status"})
registerRestrictedWritableResource(group, "/notification/ntf_template", &models.NtfTemplate{}, []string{"template_code", "channel", "content"})
registerRestrictedWritableResource(group, "/content/cms_content", &models.CmsContent{}, []string{"content_type", "title", "body", "version_no", "publish_status"})
registerRestrictedWritableResource(group, "/customer_service/cs_ticket", &models.CsTicket{}, []string{"ticket_no", "category", "priority"}, requiredRelation("user_account_identity", "user_account_id", &models.UserAccount{}))
}
func registerAuditRoute(group *gin.RouterGroup) {
registerReadOnlyResource(group, "/audit/audit_operation_log", &models.AuditOperationLog{})
registerReadOnlyResource(group, "/audit/audit_export_log", &models.AuditExportLog{})
registerReadOnlyResource(group, "/audit/audit_approval", &models.AuditApproval{})
group.POST("/audit/audit_approval/:identity/approve", platform.ApproveAudit)
}
func registerWritableResource(group *gin.RouterGroup, path string, list, create, get, update gin.HandlerFunc, model any) {
resource := group.Group(path)
resource.GET("", list)

View File

@@ -142,7 +142,7 @@ func TestPlatformDeviceSafetyCommerceAndDeliveryRoutesFollowTheirContracts(t *te
assertNoRouteMethods(t, routes, "/heqi/platform/v1"+legacySafetyPrefix+"event/:identity/disposals", http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete)
}
func TestPlatformFinanceContentAndAuditRoutesFollowTheirContracts(t *testing.T) {
func TestPlatformFinanceContentRoutesFollowTheirContracts(t *testing.T) {
engine := gin.New()
RegisterPlatform("heqi", engine)
@@ -156,7 +156,7 @@ func TestPlatformFinanceContentAndAuditRoutesFollowTheirContracts(t *testing.T)
for _, resource := range []string{
"/finance/fin_payment", "/finance/fin_settlement", "/finance/fin_reconciliation",
"/content/cnt_content", "/notification/ntf_template", "/customer_service/cs_ticket",
"/content/cms_content", "/customer_service/cs_ticket",
} {
assertRouteMethods(t, routes, "/heqi/platform/v1"+resource, http.MethodGet, http.MethodPost)
assertRouteMethods(t, routes, "/heqi/platform/v1"+resource+"/:identity", http.MethodGet, http.MethodPut, http.MethodDelete)
@@ -165,8 +165,6 @@ func TestPlatformFinanceContentAndAuditRoutesFollowTheirContracts(t *testing.T)
for _, resource := range []string{
"/wallet/wallet", "/wallet/wallet_ledger", "/wallet/wallet_recharge", "/wallet/wallet_withdrawal",
"/report/report", "/report/report_item", "/report/report_metric_snapshot",
"/audit/audit_operation_log", "/audit/audit_export_log", "/audit/audit_approval",
} {
path := "/heqi/platform/v1" + resource
assertRouteMethods(t, routes, path, http.MethodGet)
@@ -178,15 +176,16 @@ func TestPlatformFinanceContentAndAuditRoutesFollowTheirContracts(t *testing.T)
}
}
assertRouteMethods(t, routes, "/heqi/platform/v1/audit/audit_approval/:identity/approve", http.MethodPost)
legacyAuditPrefix := "/audit/" + "au" + "d_"
for _, resource := range []string{legacyAuditPrefix + "operation_log", legacyAuditPrefix + "export_log", legacyAuditPrefix + "approval"} {
for _, resource := range []string{
"/content/cnt_content", "/notification/ntf_template",
"/report/report", "/report/report_item", "/report/report_metric_snapshot",
"/audit/audit_operation_log", "/audit/audit_export_log", "/audit/audit_approval",
} {
path := "/heqi/platform/v1" + resource
assertNoRouteMethods(t, routes, path, http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete)
assertNoRouteMethods(t, routes, path+"/:identity", http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete)
}
assertNoRouteMethods(t, routes, "/heqi/platform/v1"+legacyAuditPrefix+"approval/:identity/approve", http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete)
assertNoRouteMethods(t, routes, "/heqi/platform/v1/audit/audit_approval/:identity/approve", http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete)
}
func assertRouteMethods(t *testing.T, routes map[string]map[string]bool, path string, methods ...string) {

View File

@@ -87,7 +87,7 @@
| 配送轨迹 | `delivery_track` / `DeliveryTrack` / `delivery_track.go` | `id``identity``delivery_task_id``status``started_at``completed_at` | 用户可见简化配送轨迹 |
| 配送轨迹点 | `delivery_track_point` / `DeliveryTrackPoint` / `delivery_track_point.go` | `id``identity``delivery_track_id``point_type``occurred_at``longitude``latitude` | 精确位置与任务节点;访问受授权控制 |
### 4.5 财务、钱包、统计报表、内容与审计
### 4.5 财务、钱包与内容
| 实体 | 表 / 模型 / 文件 | 关键字段 | 说明 |
| --- | --- | --- | --- |
@@ -98,15 +98,8 @@
| 钱包流水 | `wallet_ledger` / `WalletLedger` / `wallet_ledger.go` | `id``identity``wallet_id``amount``direction``balance_after``reference_identity` | 钱包唯一资金事实流水 |
| 钱包充值 | `wallet_recharge` / `WalletRecharge` / `wallet_recharge.go` | `id``identity``wallet_id``amount``channel``status` | 钱包充值申请和支付结果 |
| 钱包提现 | `wallet_withdrawal` / `WalletWithdrawal` / `wallet_withdrawal.go` | `id``identity``wallet_id``amount``bank_account_masked``status` | 提现申请、审核与付款凭证 |
| 统计报表 | `report` / `Report` / `report.go` | `id``identity``report_code``report_type``stat_period``generated_at` | 可下载或在线查看的统计报表主档案 |
| 统计报表明细 | `report_item` / `ReportItem` / `report_item.go` | `id``identity``report_id``dimension``metric_code``metric_value` | 按组织、区域、商品、时间拆分的报表明细 |
| 内容 | `cnt_content` / `CntContent` / `cnt_content.go` | `content_type``title``version_no``publish_status` | 公告、协议、安全宣教内容 |
| 消息模板 | `ntf_template` / `NtfTemplate` / `ntf_template.go` | `template_code``channel``content``status` | 短信、推送、站内信模板 |
| 内容 | `cms_content` / `CmsContent` / `cms_content.go` | `content_type``title``version_no``publish_status` | 公告、协议、安全宣教内容 |
| 客服工单 | `cs_ticket` / `CsTicket` / `cs_ticket.go` | `id``identity``ticket_no``user_id``category``status``priority` | 咨询、投诉、回访与升级 |
| 运营指标快照 | `report_metric_snapshot` / `ReportMetricSnapshot` / `report_metric_snapshot.go` | `id``identity``metric_code``scope_type``scope_id``stat_at``metric_value` | 看板与预警计算结果 |
| 操作审计 | `aud_operation_log` / `AudOperationLog` / `aud_operation_log.go` | `operator_identity``action``object_type``object_identity``before_data``after_data` | 不可由普通管理员改写或删除 |
| 导出审计 | `aud_export_log` / `AudExportLog` / `aud_export_log.go` | `applicant_identity``purpose``field_scope``approved_at``file_uri` | 敏感导出、下载与水印记录 |
| 审批单 | `aud_approval` / `AudApproval` / `aud_approval.go` | `business_type``business_identity``applicant_identity``status` | 双人复核、审批流与意见 |
## 5. API 路由规则
@@ -193,16 +186,8 @@
| `GET` | `/wallet/wallet_ledger/:identity` | 钱包流水详情查看 |
| `GET` | `/wallet/wallet_recharge` | 钱包充值记录列表查询 |
| `GET` | `/wallet/wallet_withdrawal` | 钱包提现记录列表查询 |
| `GET` | `/report/report` | 统计报表列表查询 |
| `GET` | `/report/report/:identity` | 统计报表详情查看 |
| `GET` | `/report/report_item` | 统计报表明细列表查询 |
| `GET` | `/report/report_item/:identity` | 统计报表明细详情查看 |
| `GET/POST/PUT/DELETE` | `/content/cnt_content` | 内容 CRUD |
| `GET/POST/PUT/DELETE` | `/content/cms_content` | 内容 CRUD |
| `GET/POST/PUT/DELETE` | `/customer_service/cs_ticket` | 客服工单 CRUD |
| `GET` | `/report/report_metric_snapshot` | 运营指标看板查询 |
| `GET` | `/audit/aud_operation_log` | 操作审计查询 |
| `GET` | `/audit/aud_export_log` | 导出审计查询 |
| `POST` | `/audit/aud_approval/:identity/approve` | 审批通过或驳回 |
## 6. 前端页面规划
@@ -269,14 +254,8 @@
| 钱包中心 `WalletCards` | 钱包流水 `ListOrdered` | `/wallet/ledger` | `views/wallet/ledger` | `ListPage.vue` | `wallet_ledger` 只读列表、方向、金额和余额快照 |
| 钱包中心 `WalletCards` | 充值记录 `CirclePlus` | `/wallet/recharge` | `views/wallet/recharge` | `ListPage.vue` | `wallet_recharge` 只读列表和详情 |
| 钱包中心 `WalletCards` | 提现记录 `CircleMinus` | `/wallet/withdrawal` | `views/wallet/withdrawal` | `ListPage.vue` | `wallet_withdrawal` 只读列表和详情 |
| 统计报表 `ChartNoAxesCombined` | 报表列表 `FileBarChart` | `/report/list` | `views/report/list` | `ListPage.vue` | `report` 只读列表、周期、生成时间和下载入口 |
| 统计报表 `ChartNoAxesCombined` | 报表明细 `TableProperties` | `/report/item` | `views/report/item` | `ListPage.vue` | `report_item` 只读列表、维度和指标值 |
| 统计报表 `ChartNoAxesCombined` | 指标快照 `ChartLine` | `/report/metric-snapshot` | `views/report/metric-snapshot` | `DashboardPage.vue` | `report_metric_snapshot` 只读图表、同比和环比 |
| 内容客服 `MessagesSquare` | 内容管理 `FileText` | `/content/list` | `views/content/list` | `ListPage.vue` | `cnt_content` CRUD、类型、标题和发布状态 |
| 内容客服 `MessagesSquare` | 消息模板 `Send` | `/content/template` | `views/content/template` | `ListPage.vue` | `ntf_template` CRUD、渠道、模板编码和状态 |
| 内容客服 `MessagesSquare` | 内容管理 `FileText` | `/content/cms-content` | `views/content/cms_content` | `ListPage.vue` | `cms_content` CRUD、类型、标题和发布状态 |
| 内容客服 `MessagesSquare` | 客服工单 `Headset` | `/content/ticket` | `views/content/ticket` | `ListPage.vue` | `cs_ticket` CRUD、客户、分类、优先级和状态 |
| 审计合规 `ScrollText` | 操作审计 `History` | `/audit/operation-log` | `views/audit/operation-log` | `ListPage.vue` | `aud_operation_log` 只读检索、对象、动作和前后值摘要 |
| 审计合规 `ScrollText` | 导出审计 `FileOutput` | `/audit/export-log` | `views/audit/export-log` | `ListPage.vue` | `aud_export_log` 只读检索、用途、字段范围和导出文件 |
| 平台配置 `ShieldCheck` | 平台账户 `ContactRound` | `/platform/account` | `views/platform/account` | `ListPage.vue` | `platfrom_account` 列表、头像、角色、状态;新增/编辑抽屉 |
| 平台配置 `ShieldCheck` | 角色管理 `BadgeCheck` | `/platform/role` | `views/platform/role` | `ListPage.vue` | `platform_role` CRUD`root` 角色只读保护 |
| 平台配置 `ShieldCheck` | 菜单管理 `MenuSquare` | `/platform/menu` | `views/platform/menu` | `TreePage.vue` | `platform_menu` 树、图标、路由、排序和角色菜单授权 |

View File

@@ -141,10 +141,10 @@ platforms/
| 订单与履约 | `ord_` | `ord_order``ord_payment``ord_service_task``ord_delivery_track` |
| 调度与配送 | `dsp_` | `dsp_assignment``dsp_shift``dsp_delivery_track_point` |
| 钱包与结算 | `wal_` | `wal_wallet_ledger``wal_settlement``wal_withdrawal` |
| 内容与通知 | `cnt_``ntf_` | `cnt_article``cnt_banner``ntf_message``ntf_template` |
| 内容管理 | `cms_` | `cms_content` |
| 生产与质量 | `mfg_` | `mfg_work_order``mfg_batch``mfg_quality_check` |
| 开放接口 | `api_` | `api_product``api_client``api_subscription` |
| 审计与平台任务 | `aud_``sys_` | `aud_operation_log``sys_outbox_event``sys_dead_letter_event` |
| 平台任务 | `sys_` | `sys_outbox_event``sys_dead_letter_event` |
- 所有主表必须包含 `identity` 字段,类型为 UUID V7并作为该表的主键。UUID V7 由应用服务生成,保证时间有序性;禁止使用数据库自增主键、随机 UUID V4 或将业务编号作为主键。
- 引用主表时,外键字段命名为 `<实体名>_identity`,例如 `order_identity``service_person_identity`。业务展示编号(订单号、设备编码、站点编码等)应使用独立字段并设置唯一约束,不能替代 `identity`

View File

@@ -7,19 +7,16 @@ const resourcesSource = readFileSync('src/api/resources.ts', 'utf8');
const legacySafetyPrefix = ['sa', 'f_'].join('');
const legacyAuditPrefix = ['au', 'd_'].join('');
test('资源定义使用 safe 和 audit 前缀', () => {
test('资源定义使用 safe 前缀且不保留已删除审计资源', () => {
assert.match(resourcesSource, /define\(\s*'safe_rule',\s*'\/safety\/safe_rule'/);
assert.match(resourcesSource, /define\(\s*'safe_event',\s*'\/safety\/safe_event'/);
assert.match(resourcesSource, /define\(\s*'safe_inspection',\s*'\/safety\/safe_inspection'/);
assert.match(resourcesSource, /define\(\s*'safe_event_disposal',\s*'\/safety\/safe_event\/:identity\/disposals'/);
assert.match(resourcesSource, /define\(\s*'audit_operation_log',\s*'\/audit\/audit_operation_log'/);
assert.match(resourcesSource, /define\(\s*'audit_export_log',\s*'\/audit\/audit_export_log'/);
assert.match(resourcesSource, /define\(\s*'audit_approval',\s*'\/audit\/audit_approval'/);
assert.doesNotMatch(resourcesSource, new RegExp(`define\\('${legacySafetyPrefix}(?:rule|event|inspection|event_disposal)',`));
assert.doesNotMatch(resourcesSource, new RegExp(`action\\('${legacySafetyPrefix}event_disposal',`));
assert.doesNotMatch(resourcesSource, new RegExp(`define\\('${legacyAuditPrefix}(?:operation_log|export_log|approval)',`));
assert.doesNotMatch(resourcesSource, new RegExp(`/audit/${legacyAuditPrefix}approval/:identity/approve`));
assert.doesNotMatch(resourcesSource, /audit_(?:operation_log|export_log|approval)/);
});
test('只读页面将状态变更视为违规写操作', () => {

View File

@@ -28,13 +28,13 @@ function loadResourceForm() {
return resourceModule.exports;
}
test('资源字段声明保留数字、布尔、时间与 JSON 类型', () => {
test('资源字段声明保留数字、布尔、时间与文本类型', () => {
const resources = loadResources();
const field = (resource, key) => resources.find((item) => item.name === resource).fields.find((item) => item.key === key);
assert.equal(field('ec_product', 'price_amount').type, 'number');
assert.equal(field('ec_product_image', 'is_cover').type, 'boolean');
assert.equal(field('delivery_track', 'started_at').type, 'datetime');
assert.equal(field('dev_telemetry', 'payload').type, 'json');
assert.equal(field('dev_telemetry', 'payload').type, 'textarea');
});
test('表单载荷构造器省略空的可选关系并转换字段类型', async () => {
@@ -56,7 +56,7 @@ test('表单载荷构造器省略空的可选关系并转换字段类型', async
);
});
test('安全规则与订单明细将 JSON 字段作为有效 JSON 字符串提交', () => {
test('安全规则与订单明细的文本字段允许任意文本提交', () => {
const resources = loadResources();
const { buildResourcePayload } = loadResourceForm();
const fields = (name) => resources.find((item) => item.name === name).fields;
@@ -64,29 +64,29 @@ test('安全规则与订单明细将 JSON 字段作为有效 JSON 字符串提
assert.deepEqual(
JSON.parse(JSON.stringify(buildResourcePayload(fields('safe_rule'), {
rule_code: 'pressure-limit',
threshold: '{"max":10}',
threshold: '{invalid}',
action: 'close-valve',
gray_scope: '["north"]',
gray_scope: 'north region only',
}))),
{
rule_code: 'pressure-limit',
threshold: '{"max":10}',
threshold: '{invalid}',
action: 'close-valve',
gray_scope: '["north"]',
gray_scope: 'north region only',
},
);
assert.deepEqual(
JSON.parse(JSON.stringify(buildResourcePayload(fields('ec_order_item'), {
ec_order_identity: 'order-a',
ec_product_identity: 'product-a',
product_snapshot: '{"name":"液化气"}',
product_snapshot: 'arbitrary product snapshot text',
quantity: 2,
sale_amount: 500,
}))),
{
ec_order_identity: 'order-a',
ec_product_identity: 'product-a',
product_snapshot: '{"name":"液化气"}',
product_snapshot: 'arbitrary product snapshot text',
quantity: 2,
sale_amount: 500,
},
@@ -131,11 +131,11 @@ test('日期按 RFC3339 提交,密码只在创建时必填并提交', () => {
);
});
test('审批只读页提供同意和驳回操作', () => {
test('已删除的审批模块不再保留前端资源或页面', () => {
const source = fs.readFileSync(fromProjectRoot('src/views/shared/ReadOnlyListPage.vue'), 'utf8');
const resources = fs.readFileSync(fromProjectRoot('src/api/resources.ts'), 'utf8');
assert.match(resources, /audit_approval[\s\S]*\/audit\/audit_approval\/:identity\/approve/);
assert.match(source, /submitDetailAction/);
assert.doesNotMatch(resources, /audit_approval|audit_operation_log|audit_export_log/);
assert.equal(fs.existsSync(fromProjectRoot('src/views/audit')), false);
});
test('树页面通过资源归档接口归档节点', () => {

View File

@@ -43,12 +43,6 @@ export function buildResourcePayload(
case 'boolean':
payload[field.key] = value === true || value === 'true';
break;
case 'json':
if (typeof value !== 'string')
throw new Error(`${field.label}必须是有效 JSON`);
JSON.parse(value);
payload[field.key] = value;
break;
case 'date':
case 'datetime': {
const date = new Date(String(value));

View File

@@ -10,7 +10,6 @@ export type ResourceFieldType =
| 'boolean'
| 'date'
| 'datetime'
| 'json'
| 'textarea';
export type ResourceField = {
key: string;
@@ -175,14 +174,17 @@ const datetimeFields = new Set([
'handled_at',
'created_at',
]);
const jsonFields = new Set([
// 大文本字段按普通文本处理,不再要求或解析 JSON。
const textareaFields = new Set([
'body',
'content',
'reason',
'opinion',
'payload',
'threshold',
'gray_scope',
'product_snapshot',
'field_scope',
]);
const textareaFields = new Set(['body', 'content', 'reason', 'opinion']);
const fieldType = (key: string): ResourceFieldType => {
if (key === 'platform_role_code') return 'role-code';
if (key === 'menu_identities') return 'menu-identities';
@@ -192,7 +194,6 @@ const fieldType = (key: string): ResourceFieldType => {
if (booleanFields.has(key)) return 'boolean';
if (dateFields.has(key)) return 'date';
if (datetimeFields.has(key)) return 'datetime';
if (jsonFields.has(key)) return 'json';
if (textareaFields.has(key)) return 'textarea';
return 'text';
};
@@ -237,8 +238,7 @@ const titles: Record<string, string> = {
fin_payment: '支付记录',
fin_settlement: '财务结算',
fin_reconciliation: '财务对账',
cnt_content: '内容管理',
ntf_template: '通知模板',
cms_content: '内容管理',
cs_ticket: '客服工单',
platfrom_account: '平台账户',
platform_role: '平台角色',
@@ -247,12 +247,6 @@ const titles: Record<string, string> = {
wallet_ledger: '钱包流水',
wallet_recharge: '钱包充值',
wallet_withdrawal: '钱包提现',
report: '报表',
report_item: '报表项目',
report_metric_snapshot: '指标快照',
audit_operation_log: '操作审计',
audit_export_log: '导出审计',
audit_approval: '审批审计',
};
const define = (
name: string,
@@ -523,18 +517,13 @@ export const resources: ResourceUiDefinition[] = [
'list',
['channel!', 'bill_date!', 'difference_amount!'],
),
define('cnt_content', '/content/cnt_content', 'writable', 'list', [
define('cms_content', '/content/cms_content', 'writable', 'list', [
'content_type!',
'title!',
'body!',
'version_no',
'publish_status',
]),
define('ntf_template', '/notification/ntf_template', 'writable', 'list', [
'template_code!',
'channel!',
'content!',
]),
define('cs_ticket', '/customer_service/cs_ticket', 'writable', 'list', [
'user_account_identity!',
'ticket_no!',
@@ -592,62 +581,6 @@ export const resources: ResourceUiDefinition[] = [
'amount',
'status',
]),
define('report', '/report/report', 'readonly', 'list', [
'report_code',
'report_type',
'stat_period',
'generated_at',
'status',
]),
define('report_item', '/report/report_item', 'readonly', 'list', [
'report_identity',
'dimension',
'metric_code',
'metric_value',
]),
define(
'report_metric_snapshot',
'/report/report_metric_snapshot',
'readonly',
'list',
['metric_code', 'scope_type', 'stat_at', 'metric_value'],
),
define('audit_operation_log', '/audit/audit_operation_log', 'readonly', 'list', [
'operator_identity',
'action',
'object_identity',
'created_at',
]),
define('audit_export_log', '/audit/audit_export_log', 'readonly', 'list', [
'applicant_identity',
'purpose',
'field_scope',
'approved_at',
'file_uri',
]),
define(
'audit_approval',
'/audit/audit_approval',
'readonly',
'list',
[
'business_type',
'business_identity',
'applicant_identity',
'opinion',
'handler_identity',
'status',
'handled_at',
],
[
action('同意', '/audit/audit_approval/:identity/approve', ['opinion'], {
status: 'approved',
}),
action('驳回', '/audit/audit_approval/:identity/approve', ['opinion!'], {
status: 'rejected',
}),
],
),
];
export const resourceByName = Object.fromEntries(

View File

@@ -1,12 +0,0 @@
import { DEFAULT_LAYOUT } from '../base';
import type { AppRouteRecordRaw } from '../types';
const routes: AppRouteRecordRaw[] = [{
path: '/audit', name: 'audit', component: DEFAULT_LAYOUT,
meta: { locale: 'menu.platform.audit', requiresAuth: true, icon: 'icon-apps', order: 22 },
children: [
{ path: 'audit-operation-log', name: 'audit-audit-operation-log', component: () => import('@/views/audit/audit_operation_log/ListPage.vue'), meta: { locale: 'menu.platform.audit.audit_operation_log', requiresAuth: true, menuCode: 'audit' } },
{ path: 'audit-export-log', name: 'audit-audit-export-log', component: () => import('@/views/audit/audit_export_log/ListPage.vue'), meta: { locale: 'menu.platform.audit.audit_export_log', requiresAuth: true, menuCode: 'audit' } },
{ path: 'audit-approval', name: 'audit-audit-approval', component: () => import('@/views/audit/audit_approval/ListPage.vue'), meta: { locale: 'menu.platform.audit.audit_approval', requiresAuth: true, menuCode: 'audit' } }
],
}];
export default routes;

View File

@@ -4,7 +4,7 @@ const routes: AppRouteRecordRaw[] = [{
path: '/content', name: 'content', component: DEFAULT_LAYOUT,
meta: { locale: 'menu.platform.content', requiresAuth: true, icon: 'icon-apps', order: 17 },
children: [
{ path: 'cnt-content', name: 'content-cnt-content', component: () => import('@/views/content/cnt_content/ListPage.vue'), meta: { locale: 'menu.platform.content.cnt_content', requiresAuth: true, menuCode: 'content' } }
{ path: 'cms-content', name: 'content-cms-content', component: () => import('@/views/content/cms_content/ListPage.vue'), meta: { locale: 'menu.platform.content.cms_content', requiresAuth: true, menuCode: 'content' } }
],
}];
export default routes;

View File

@@ -1,10 +0,0 @@
import { DEFAULT_LAYOUT } from '../base';
import type { AppRouteRecordRaw } from '../types';
const routes: AppRouteRecordRaw[] = [{
path: '/notification', name: 'notification', component: DEFAULT_LAYOUT,
meta: { locale: 'menu.platform.notification', requiresAuth: true, icon: 'icon-apps', order: 18 },
children: [
{ path: 'ntf-template', name: 'notification-ntf-template', component: () => import('@/views/notification/ntf_template/ListPage.vue'), meta: { locale: 'menu.platform.notification.ntf_template', requiresAuth: true, menuCode: 'notification' } }
],
}];
export default routes;

View File

@@ -9,11 +9,8 @@ import safetyRoutes from './safety';
import ecRoutes from './ec';
import financeRoutes from './finance';
import contentRoutes from './content';
import notificationRoutes from './notification';
import customerServiceRoutes from './customer_service';
import walletRoutes from './wallet';
import reportRoutes from './report';
import auditRoutes from './audit';
const platformRoutes: AppRouteRecordRaw[] = [
{ path: '/dashboard', name: 'dashboard', component: DEFAULT_LAYOUT, meta: { locale: 'menu.dashboard', requiresAuth: true, icon: 'icon-dashboard', order: 0 }, children: [{ path: 'overview', name: 'DashboardOverview', component: () => import('@/views/dashboard/DashboardPage.vue'), meta: { locale: 'menu.platform.dashboard', requiresAuth: true, menuCode: 'dashboard' } }] },
{ path: '/platform', name: 'platform', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.config', requiresAuth: true, icon: 'icon-settings', order: 30 }, children: [
@@ -22,4 +19,4 @@ const platformRoutes: AppRouteRecordRaw[] = [
{ path: 'platform-menu', name: 'platform-platform-menu', component: () => import('@/views/platform/platform_menu/TreePage.vue'), meta: { locale: 'menu.platform.platform.platform_menu', requiresAuth: true, menuCode: 'platform' } },
] },
];
export default [...platformRoutes, ...gasRoutes, ...deliveryRoutes, ...staffRoutes, ...userRoutes, ...deviceRoutes, ...safetyRoutes, ...ecRoutes, ...financeRoutes, ...contentRoutes, ...notificationRoutes, ...customerServiceRoutes, ...walletRoutes, ...reportRoutes, ...auditRoutes];
export default [...platformRoutes, ...gasRoutes, ...deliveryRoutes, ...staffRoutes, ...userRoutes, ...deviceRoutes, ...safetyRoutes, ...ecRoutes, ...financeRoutes, ...contentRoutes, ...customerServiceRoutes, ...walletRoutes];

View File

@@ -1,12 +0,0 @@
import { DEFAULT_LAYOUT } from '../base';
import type { AppRouteRecordRaw } from '../types';
const routes: AppRouteRecordRaw[] = [{
path: '/report', name: 'report', component: DEFAULT_LAYOUT,
meta: { locale: 'menu.platform.report', requiresAuth: true, icon: 'icon-apps', order: 21 },
children: [
{ path: 'report', name: 'report-report', component: () => import('@/views/report/report/ListPage.vue'), meta: { locale: 'menu.platform.report.report', requiresAuth: true, menuCode: 'report' } },
{ path: 'report-item', name: 'report-report-item', component: () => import('@/views/report/report_item/ListPage.vue'), meta: { locale: 'menu.platform.report.report_item', requiresAuth: true, menuCode: 'report' } },
{ path: 'report-metric-snapshot', name: 'report-report-metric-snapshot', component: () => import('@/views/report/report_metric_snapshot/ListPage.vue'), meta: { locale: 'menu.platform.report.report_metric_snapshot', requiresAuth: true, menuCode: 'report' } }
],
}];
export default routes;

View File

@@ -1,6 +0,0 @@
<template><ReadOnlyListPage :definition="definition" /></template>
<script setup lang="ts">
import ReadOnlyListPage from '@/views/shared/ReadOnlyListPage.vue';
import { getResource } from '@/api/resources';
const definition = getResource('/audit/audit_approval');
</script>

View File

@@ -1,6 +0,0 @@
<template><ReadOnlyListPage :definition="definition" /></template>
<script setup lang="ts">
import ReadOnlyListPage from '@/views/shared/ReadOnlyListPage.vue';
import { getResource } from '@/api/resources';
const definition = getResource('/audit/audit_export_log');
</script>

View File

@@ -1,6 +0,0 @@
<template><ReadOnlyListPage :definition="definition" /></template>
<script setup lang="ts">
import ReadOnlyListPage from '@/views/shared/ReadOnlyListPage.vue';
import { getResource } from '@/api/resources';
const definition = getResource('/audit/audit_operation_log');
</script>

View File

@@ -2,5 +2,5 @@
<script setup lang="ts">
import CrudListPage from '@/views/shared/CrudListPage.vue';
import { getResource } from '@/api/resources';
const definition = getResource('/content/cnt_content');
const definition = getResource('/content/cms_content');
</script>

View File

@@ -1,6 +0,0 @@
<template><CrudListPage :definition="definition" /></template>
<script setup lang="ts">
import CrudListPage from '@/views/shared/CrudListPage.vue';
import { getResource } from '@/api/resources';
const definition = getResource('/notification/ntf_template');
</script>

View File

@@ -1,6 +0,0 @@
<template><ReadOnlyListPage :definition="definition" /></template>
<script setup lang="ts">
import ReadOnlyListPage from '@/views/shared/ReadOnlyListPage.vue';
import { getResource } from '@/api/resources';
const definition = getResource('/report/report');
</script>

View File

@@ -1,6 +0,0 @@
<template><ReadOnlyListPage :definition="definition" /></template>
<script setup lang="ts">
import ReadOnlyListPage from '@/views/shared/ReadOnlyListPage.vue';
import { getResource } from '@/api/resources';
const definition = getResource('/report/report_item');
</script>

View File

@@ -1,6 +0,0 @@
<template><ReadOnlyListPage :definition="definition" /></template>
<script setup lang="ts">
import ReadOnlyListPage from '@/views/shared/ReadOnlyListPage.vue';
import { getResource } from '@/api/resources';
const definition = getResource('/report/report_metric_snapshot');
</script>

View File

@@ -37,7 +37,7 @@
<a-input-number v-else-if="field.type === 'number'" v-model="form[field.key]" />
<a-date-picker v-else-if="field.type === 'date'" v-model="form[field.key]" value-format="YYYY-MM-DD" />
<a-date-picker v-else-if="field.type === 'datetime'" v-model="form[field.key]" show-time value-format="YYYY-MM-DDTHH:mm:ssZ" />
<a-textarea v-else-if="field.type === 'json' || field.type === 'textarea'" v-model="form[field.key]" :auto-size="{ minRows: 3, maxRows: 8 }" />
<a-textarea v-else-if="field.type === 'textarea'" v-model="form[field.key]" :auto-size="{ minRows: 3, maxRows: 8 }" />
<a-input-password v-else-if="field.type === 'password'" v-model="form[field.key]" />
<a-select v-else-if="field.type === 'role-code'" v-model="form[field.key]">
<a-option v-for="role in roleOptions" :key="role.role_code" :value="role.role_code">{{ role.name }}</a-option>
@@ -61,7 +61,7 @@
<a-form-item v-for="field in activeAction?.fields" :key="field.key" :label="field.label" :required="field.required">
<a-input-number v-if="field.type === 'number'" v-model="actionForm[field.key]" />
<a-switch v-else-if="field.type === 'boolean'" v-model="actionForm[field.key]" />
<a-textarea v-else-if="field.type === 'json' || field.type === 'textarea'" v-model="actionForm[field.key]" />
<a-textarea v-else-if="field.type === 'textarea'" v-model="actionForm[field.key]" />
<a-select v-else-if="field.type === 'menu-identities'" v-model="actionForm[field.key]" multiple allow-search>
<a-option v-for="menu in menuOptions" :key="menu.identity" :value="menu.identity">{{ menu.name }}</a-option>
</a-select>
@@ -124,12 +124,7 @@ const detailEntries = computed(() =>
function resetForm(data?: Row) {
for (const field of props.definition.fields) {
const value = data?.[field.key];
form[field.key] =
field.type === 'json' && value != null && typeof value !== 'string'
? JSON.stringify(value, null, 2)
: value == null
? undefined
: value;
form[field.key] = value == null ? undefined : value;
}
}

View File

@@ -23,7 +23,7 @@
<a-modal :visible="actionVisible" :title="activeAction?.name" @cancel="actionVisible = false" @ok="submitDetailAction">
<a-form :model="actionForm" layout="vertical">
<a-form-item v-for="field in activeAction?.fields" :key="field.key" :label="field.label" :required="field.required">
<a-textarea v-if="field.type === 'textarea' || field.type === 'json'" v-model="actionForm[field.key]" />
<a-textarea v-if="field.type === 'textarea'" v-model="actionForm[field.key]" />
<a-input v-else v-model="actionForm[field.key]" />
</a-form-item>
</a-form>