fix: close platform admin final audit findings

This commit is contained in:
2026-07-27 12:02:08 +08:00
parent 0ba4136e1a
commit 34e8b092b3
70 changed files with 1873 additions and 348 deletions

View File

@@ -54,7 +54,7 @@ func CreateGasAccount(ctx *gin.Context) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, resourceResponse(account))
respondCreatedResource(ctx, account)
}
func UpdateGasAccount(ctx *gin.Context) {
@@ -95,7 +95,7 @@ func CreateDeliveryAccount(ctx *gin.Context) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, resourceResponse(account))
respondCreatedResource(ctx, account)
}
func UpdateDeliveryAccount(ctx *gin.Context) {

View File

@@ -37,7 +37,7 @@ func CreateDeliveryBasic(ctx *gin.Context) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, resourceResponse(delivery))
respondCreatedResource(ctx, delivery)
}
// UpdateDeliveryBasic 更新配送点基础资料。

View File

@@ -26,7 +26,7 @@ func CreateGasBasic(ctx *gin.Context) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, request)
respondCreatedResource(ctx, request)
}
// UpdateGasBasic 更新可燃气体站基础资料。

View File

@@ -3,6 +3,8 @@ package platform
import (
"errors"
"reflect"
"strings"
"git.apinb.com/bsm-sdk/core/errcode"
"git.apinb.com/bsm-sdk/core/infra"
@@ -38,7 +40,8 @@ func listPage[T any](ctx *gin.Context) {
page, size := pageSize(ctx)
var list []T
var total int64
databaseQuery := impl.DBService.Model(new(T))
model := new(T)
databaseQuery := applyKeywordFilter(ctx, impl.DBService.Model(model), model)
if err := databaseQuery.Count(&total).Error; err != nil {
infra.Response.Error(ctx, err)
return
@@ -55,6 +58,58 @@ func listPage[T any](ctx *gin.Context) {
infra.Response.Success(ctx, gin.H{"total": total, "list": response})
}
var keywordExcludedColumns = map[string]bool{
"identity": true, "status": true, "password_hash": true,
"longitude": true, "latitude": true, "payload": true,
"before_data": true, "after_data": true,
}
func applyKeywordFilter(ctx *gin.Context, query *gorm.DB, model any) *gorm.DB {
keyword := strings.ToLower(strings.TrimSpace(ctx.Query("keyword")))
if keyword == "" {
return query
}
columns := keywordColumns(model)
if len(columns) == 0 {
return query
}
conditions := make([]string, 0, len(columns))
arguments := make([]any, 0, len(columns))
for _, column := range columns {
conditions = append(conditions, `LOWER("`+column+`") LIKE ?`)
arguments = append(arguments, "%"+keyword+"%")
}
return query.Where("("+strings.Join(conditions, " OR ")+")", arguments...)
}
func keywordColumns(model any) []string {
modelType := reflect.TypeOf(model)
for modelType.Kind() == reflect.Pointer {
modelType = modelType.Elem()
}
columns := make([]string, 0)
for index := 0; index < modelType.NumField(); index++ {
field := modelType.Field(index)
if field.Anonymous || field.Type.Kind() != reflect.String {
continue
}
column := gormColumn(field.Tag.Get("gorm"))
if column != "" && !keywordExcludedColumns[column] {
columns = append(columns, column)
}
}
return columns
}
func gormColumn(tag string) string {
for _, part := range strings.Split(tag, ";") {
if strings.HasPrefix(part, "column:") {
return strings.TrimPrefix(part, "column:")
}
}
return ""
}
func getByIdentity[T any](ctx *gin.Context) {
var data T
if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&data).Error; err != nil {

View File

@@ -77,7 +77,7 @@ func ExpectedResources() []ResourceContract {
resourceContract("device", "dev_smart_cylinder_valve", Writable, "list"), resourceContract("device", "dev_device_binding", Writable, "list"), resourceContract("device", "dev_telemetry", ReadOnly, "list"),
resourceContract("safety", "saf_rule", Writable, "list"), resourceContract("safety", "saf_event", Writable, "list"), resourceContract("safety", "saf_inspection", Writable, "list"), resourceContract("safety", "saf_event_disposal", AppendOnly, "list"),
resourceContract("ec", "ec_category", Writable, "list"), resourceContract("ec", "ec_product", Writable, "list"), resourceContract("ec", "ec_product_attribute", Writable, "list"), resourceContract("ec", "ec_product_image", Writable, "list"), resourceContract("ec", "ec_cart", Writable, "list"), resourceContract("ec", "ec_order", Writable, "list"), resourceContract("ec", "ec_order_item", Writable, "list"), resourceContract("ec", "ec_review", Writable, "list"),
resourceContract("delivery", "delivery_task", Writable, "list"), resourceContract("delivery", "delivery_track", Writable, "list"), resourceContract("delivery", "delivery_track_point", 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("platform", "platfrom_account", Writable, "list"), resourceContract("platform", "platform_role", Writable, "list"), resourceContract("platform", "platform_menu", Writable, "tree"),

View File

@@ -27,6 +27,7 @@ func TestExpectedResources(t *testing.T) {
assertContract(t, ExpectedResources(), "safety", "saf_event", Writable, "list")
assertContract(t, ExpectedResources(), "ec", "ec_order_item", Writable, "list")
assertContract(t, ExpectedResources(), "wallet", "wallet_ledger", ReadOnly, "list")
assertContract(t, ExpectedResources(), "delivery", "delivery_track_point", ReadOnly, "list")
}
func TestResourceDefinitionAllowsOnlySupportedMethods(t *testing.T) {
@@ -238,10 +239,37 @@ func TestCreateGasAccountResolvesGasBasicIdentityBeforePersisting(t *testing.T)
mock.ExpectQuery(`INSERT INTO "gas_account"`).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uint64(1)))
mock.ExpectCommit()
mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id","identity" FROM "gas_basic" WHERE id IN ($1)`)).
WithArgs(uint64(8)).
WillReturnRows(sqlmock.NewRows([]string{"id", "identity"}).AddRow(uint64(8), "gas-a"))
ctx, recorder := updateContext(http.MethodPost, "/gas/gas_account", "", []byte(`{"username":"operator","password":"password-123","gas_basic_identity":"gas-a"}`))
CreateGasAccount(ctx)
assertResponseCode(t, recorder, 0)
body := recorder.Body.String()
if !strings.Contains(body, `"identity"`) || !strings.Contains(body, `"gas_basic_identity":"gas-a"`) {
t.Fatalf("create response omitted public identities: %s", body)
}
if strings.Contains(body, `"gas_basic_id"`) || strings.Contains(body, `"password_hash"`) {
t.Fatalf("create response exposed internal or sensitive fields: %s", body)
}
assertMockExpectations(t, mock)
}
func TestListGasAccountAppliesKeywordToCountAndRows(t *testing.T) {
_, mock := setupPlatformRoleDatabase(t)
keywordWhere := ` WHERE (LOWER("username") LIKE $1 OR LOWER("display_name") LIKE $2 OR LOWER("role_code") LIKE $3)`
mock.ExpectQuery(regexp.QuoteMeta(`SELECT count(*) FROM "gas_account"`+keywordWhere)).
WithArgs("%operator%", "%operator%", "%operator%").
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "gas_account"`+keywordWhere+` ORDER BY created_at desc LIMIT $4`)).
WithArgs("%operator%", "%operator%", "%operator%", 20).
WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "gas_basic_id", "username", "display_name", "password_hash", "role_code"}))
ctx, recorder := updateContext(http.MethodGet, "/gas/gas_account?keyword=Operator", "", nil)
ListGasAccount(ctx)
assertResponseCode(t, recorder, 0)
assertMockExpectations(t, mock)
}
@@ -276,6 +304,30 @@ func TestResourceResponseDoesNotExposeAutoIncrementRelationIDs(t *testing.T) {
}
}
func TestCreatedResourceResponseMasksSensitiveFieldsAndKeepsIdentity(t *testing.T) {
response := maskCreatedSensitiveFields(map[string]any{
"identity": "user-a",
"phone": "13800138000",
"real_name": "张三",
"credential_no": "CERT-123456",
"longitude": "120.123456",
"latitude": "30.456789",
})
encoded, err := json.Marshal(response)
if err != nil {
t.Fatal(err)
}
body := string(encoded)
if !strings.Contains(body, `"identity":"user-a"`) || !strings.Contains(body, `"phone_masked":"138****8000"`) {
t.Fatalf("created response omitted identity or masked phone: %s", body)
}
for _, forbidden := range []string{`"phone":`, `"real_name":`, `"credential_no":`, `"longitude":`, `"latitude":`, "张三", "CERT-123456", "120.123456", "30.456789"} {
if strings.Contains(body, forbidden) {
t.Fatalf("created response exposed sensitive field %s: %s", forbidden, body)
}
}
}
func TestListSafetyEventDisposalsReturnsOnlyTheRequestedEventHistory(t *testing.T) {
_, mock := setupPlatformRoleDatabase(t)
mock.ExpectQuery(regexp.QuoteMeta(`SELECT count(*) FROM "saf_event_disposal" WHERE saf_event_identity = $1`)).
@@ -392,6 +444,55 @@ func TestGetDeliveryTrackOrdersAndMasksPointsWithoutPreciseLocationScope(t *test
assertMockExpectations(t, mock)
}
func TestListDeliveryTrackPointsMasksCoordinatesWithoutPreciseLocationScope(t *testing.T) {
_, mock := setupPlatformRoleDatabase(t)
now := time.Now().UTC()
mock.ExpectQuery(regexp.QuoteMeta(`SELECT count(*) FROM "delivery_track_point"`)).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "delivery_track_point" ORDER BY created_at desc LIMIT $1`)).
WithArgs(20).
WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "delivery_track_id", "point_type", "occurred_at", "longitude", "latitude"}).
AddRow(uint64(9), "point-a", now, now, "enabled", 1, uint64(7), "arrival", now, "120.123456", "30.456789"))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id","identity" FROM "delivery_track" WHERE id IN ($1)`)).
WithArgs(uint64(7)).
WillReturnRows(sqlmock.NewRows([]string{"id", "identity"}).AddRow(uint64(7), "track-a"))
ctx, recorder := updateContext(http.MethodGet, "/delivery/delivery_track_point", "", nil)
listResource(ctx, &models.DeliveryTrackPoint{})
assertResponseCode(t, recorder, 0)
body := recorder.Body.String()
if strings.Contains(body, "120.123456") || strings.Contains(body, "30.456789") {
t.Fatalf("track-point list exposed precise coordinates without scope: %s", body)
}
if !strings.Contains(body, `"delivery_track_identity":"track-a"`) {
t.Fatalf("track-point list omitted its public relation identity: %s", body)
}
assertMockExpectations(t, mock)
}
func TestGetDeliveryTrackPointReturnsCoordinatesWithPreciseLocationScope(t *testing.T) {
_, mock := setupPlatformRoleDatabase(t)
now := time.Now().UTC()
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "delivery_track_point" WHERE identity = $1 ORDER BY "delivery_track_point"."id" LIMIT $2`)).
WithArgs("point-a", 1).
WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "delivery_track_id", "point_type", "occurred_at", "longitude", "latitude"}).
AddRow(uint64(9), "point-a", now, now, "enabled", 1, uint64(7), "arrival", now, "120.123456", "30.456789"))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id","identity" FROM "delivery_track" WHERE id IN ($1)`)).
WithArgs(uint64(7)).
WillReturnRows(sqlmock.NewRows([]string{"id", "identity"}).AddRow(uint64(7), "track-a"))
ctx, recorder := updateContext(http.MethodGet, "/delivery/delivery_track_point/point-a", "point-a", nil)
ctx.Set("Auth", &types.JwtClaims{Extend: map[string]string{"location_scope": "precise"}})
getResource(ctx, &models.DeliveryTrackPoint{})
assertResponseCode(t, recorder, 0)
if !strings.Contains(recorder.Body.String(), "120.123456") || !strings.Contains(recorder.Body.String(), "30.456789") {
t.Fatalf("authorized track-point detail omitted precise coordinates: %s", recorder.Body.String())
}
assertMockExpectations(t, mock)
}
func TestDisposeSafetyEventUpdatesEventAndAppendsOperatorActionTransactionally(t *testing.T) {
_, mock := setupPlatformRoleDatabase(t)
now := time.Now().UTC()

View File

@@ -35,7 +35,7 @@ func CreatePlatformRole(ctx *gin.Context) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, request)
respondCreatedResource(ctx, request)
}
// UpdatePlatformRole 更新非内置平台角色。
@@ -125,7 +125,7 @@ func CreatePlatformMenu(ctx *gin.Context) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, menu)
respondCreatedResource(ctx, menu)
}
func UpdatePlatformMenu(ctx *gin.Context) {
@@ -243,8 +243,14 @@ func ListPlatformMenu(ctx *gin.Context) {
// ListPlatfromAccount 查询平台账号列表,手机号在展示层脱敏。
func ListPlatfromAccount(ctx *gin.Context) {
page, size := pageSize(ctx)
list, total, err := models.ListPlatfromAccount(page, size)
if err != nil {
var list []models.PlatfromAccount
var total int64
query := applyKeywordFilter(ctx, impl.DBService.Model(&models.PlatfromAccount{}), &models.PlatfromAccount{})
if err := query.Count(&total).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
if err := query.Order("created_at desc").Offset((page - 1) * size).Limit(size).Find(&list).Error; err != nil {
infra.Response.Error(ctx, err)
return
}

View File

@@ -35,7 +35,7 @@ func CreateStaffCredential(ctx *gin.Context) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, resourceResponse(credential))
respondCreatedResource(ctx, credential)
}
func UpdateStaffCredential(ctx *gin.Context) {
var request staffCredentialRequest
@@ -77,7 +77,7 @@ func CreateUserAddress(ctx *gin.Context) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, resourceResponse(address))
respondCreatedResource(ctx, address)
}
func UpdateUserAddress(ctx *gin.Context) {
var request userAddressRequest
@@ -133,7 +133,7 @@ func CreateUserServiceRelation(ctx *gin.Context) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, resourceResponse(relation))
respondCreatedResource(ctx, relation)
}
func UpdateUserServiceRelation(ctx *gin.Context) {
var request userServiceRelationRequest

View File

@@ -54,7 +54,7 @@ func CreateStaff(ctx *gin.Context) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, staff)
respondCreatedResource(ctx, staff)
}
// UpdateStaff 更新服务人员档案。

View File

@@ -94,7 +94,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 := impl.DBService.Model(model)
query := applyKeywordFilter(ctx, impl.DBService.Model(model), model)
if err := query.Count(&total).Error; err != nil {
infra.Response.Error(ctx, err)
return
@@ -108,7 +108,7 @@ func listResource(ctx *gin.Context, model any) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, gin.H{"total": total, "list": response})
infra.Response.Success(ctx, gin.H{"total": total, "list": protectPreciseLocation(ctx, model, response)})
}
func getResource(ctx *gin.Context, model any) {
@@ -122,7 +122,7 @@ func getResource(ctx *gin.Context, model any) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, response)
infra.Response.Success(ctx, protectPreciseLocation(ctx, model, response))
}
func createResource(ctx *gin.Context, model any, allowedFields []string, relations []ResourceRelation) {
@@ -146,7 +146,88 @@ func createResource(ctx *gin.Context, model any, allowedFields []string, relatio
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, resourceResponse(data.Interface()))
respondCreatedResource(ctx, data.Interface())
}
func respondCreatedResource(ctx *gin.Context, value any) {
response, err := publicResourceResponse(value)
if err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, maskCreatedSensitiveFields(response))
}
func maskCreatedSensitiveFields(value any) any {
switch data := value.(type) {
case map[string]any:
if phone, ok := data["phone"].(string); ok {
data["phone_masked"] = maskPhone(phone)
delete(data, "phone")
}
if realName, ok := data["real_name"].(string); ok {
data["real_name_masked"] = maskSecret(realName)
delete(data, "real_name")
}
if credentialNo, ok := data["credential_no"].(string); ok {
data["credential_no_masked"] = maskSecret(credentialNo)
delete(data, "credential_no")
}
delete(data, "longitude")
delete(data, "latitude")
for key, item := range data {
data[key] = maskCreatedSensitiveFields(item)
}
case []any:
for index := range data {
data[index] = maskCreatedSensitiveFields(data[index])
}
}
return value
}
func maskSecret(value string) string {
characters := []rune(value)
if len(characters) <= 1 {
return "*"
}
visible := 1
if len(characters) > 4 {
visible = 4
}
return strings.Repeat("*", len(characters)-visible) + string(characters[len(characters)-visible:])
}
func protectPreciseLocation(ctx *gin.Context, model, value any) any {
if reflect.TypeOf(model) != reflect.TypeOf(&models.DeliveryTrackPoint{}) || hasPreciseLocationScope(ctx) {
return value
}
clearCoordinateFields(value)
return value
}
func hasPreciseLocationScope(ctx *gin.Context) bool {
claims, err := middleware.ParseAuth(ctx)
return err == nil && claims.Extend["location_scope"] == "precise"
}
func clearCoordinateFields(value any) {
switch data := value.(type) {
case map[string]any:
if _, ok := data["longitude"]; ok {
data["longitude"] = ""
}
if _, ok := data["latitude"]; ok {
data["latitude"] = ""
}
for _, item := range data {
clearCoordinateFields(item)
}
case []any:
for _, item := range data {
clearCoordinateFields(item)
}
}
}
func updateResource(ctx *gin.Context, model any, allowedFields []string, relations []ResourceRelation) {
@@ -521,11 +602,7 @@ func GetDeliveryTrack(ctx *gin.Context) {
infra.Response.Error(ctx, err)
return
}
precise := false
if claims, err := middleware.ParseAuth(ctx); err == nil {
precise = claims.Extend["location_scope"] == "precise"
}
if !precise {
if !hasPreciseLocationScope(ctx) {
for index := range points {
points[index].Longitude = ""
points[index].Latitude = ""

View File

@@ -38,7 +38,7 @@ func CreateUser(ctx *gin.Context) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, user)
respondCreatedResource(ctx, user)
}
// UpdateUser 更新业主客户档案。

View File

@@ -8,13 +8,13 @@ import (
// AudApproval 对应 aud_approval保存审批流与复核意见。
type AudApproval struct {
Entity
BusinessType string `gorm:"column:business_type;type:varchar(64);not null" json:"business_type"`
BusinessIdentity string `gorm:"column:business_identity;type:varchar(36);not null;index" json:"business_identity"`
ApplicantIdentity string `gorm:"column:applicant_identity;type:varchar(36);not null;index" json:"applicant_identity"`
Opinion string `gorm:"column:opinion;type:text;not null;default:''" json:"opinion"`
HandlerIdentity string `gorm:"column:handler_identity;type:varchar(36);not null;default:'';index" json:"handler_identity"`
HandledAt *time.Time `gorm:"column:handled_at;type:timestamptz" json:"handled_at"`
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(&AudApproval{}) }

View File

@@ -7,12 +7,12 @@ import (
// AudExportLog 对应 aud_export_log保存敏感导出审计。
type AudExportLog struct {
Entity
ApplicantIdentity string `gorm:"column:applicant_identity;type:varchar(36);not null;index" json:"applicant_identity"`
Purpose string `gorm:"column:purpose;type:varchar(255);not null" json:"purpose"`
FieldScope string `gorm:"column:field_scope;type:jsonb;not null;default:'{}'" json:"field_scope"`
ApprovedAt *time.Time `gorm:"column:approved_at;type:timestamptz" json:"approved_at"`
FileURI string `gorm:"column:file_uri;type:varchar(512);not null;default:''" json:"file_uri"`
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(&AudExportLog{}) }

View File

@@ -4,13 +4,13 @@ import "git.apinb.com/bsm-sdk/core/database"
// AudOperationLog 对应 aud_operation_log保存不可变操作审计。
type AudOperationLog struct {
Entity
OperatorIdentity string `gorm:"column:operator_identity;type:varchar(36);not null;index" json:"operator_identity"`
Action string `gorm:"column:action;type:varchar(64);not null" json:"action"`
ObjectType string `gorm:"column:object_type;type:varchar(64);not null" json:"object_type"`
ObjectIdentity string `gorm:"column:object_identity;type:varchar(36);not null;index" json:"object_identity"`
BeforeData string `gorm:"column:before_data;type:jsonb;not null;default:'{}'" json:"before_data"`
AfterData string `gorm:"column:after_data;type:jsonb;not null;default:'{}'" json:"after_data"`
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(&AudOperationLog{}) }

View File

@@ -4,12 +4,12 @@ import "git.apinb.com/bsm-sdk/core/database"
// CntContent 对应 cnt_content保存公告与协议内容。
type CntContent struct {
Entity
ContentType string `gorm:"column:content_type;type:varchar(32);not null" json:"content_type"`
Title string `gorm:"column:title;type:varchar(256);not null" json:"title"`
Body string `gorm:"column:body;type:text;not null;default:''" json:"body"`
VersionNo int `gorm:"column:version_no;not null;default:1" json:"version_no"`
PublishStatus string `gorm:"column:publish_status;type:varchar(32);not null;default:'draft'" json:"publish_status"`
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 业务字段
Body string `gorm:"column:body;type:text;not null;default:''" json:"body"` // body 业务字段
VersionNo int `gorm:"column:version_no;not null;default:1" json:"version_no"` // version_no 业务字段
PublishStatus string `gorm:"column:publish_status;type:varchar(32);not null;default:'draft'" json:"publish_status"` // publish_status 业务字段
}
func init() { database.AppendMigrate(&CntContent{}) }

View File

@@ -0,0 +1,53 @@
package models
import (
"go/ast"
"go/parser"
"go/token"
"os"
"regexp"
"testing"
)
var chineseText = regexp.MustCompile(`[\p{Han}]`)
func TestEveryModelFieldHasChineseComment(t *testing.T) {
packages, err := parser.ParseDir(token.NewFileSet(), ".", func(info os.FileInfo) bool {
return info.Name() != "comments_test.go"
}, parser.ParseComments)
if err != nil {
t.Fatal(err)
}
for _, file := range packages["models"].Files {
ast.Inspect(file, func(node ast.Node) bool {
typeSpec, ok := node.(*ast.TypeSpec)
if !ok {
return true
}
structType, ok := typeSpec.Type.(*ast.StructType)
if !ok {
return false
}
for _, field := range structType.Fields.List {
name := "embedded field"
if len(field.Names) > 0 {
name = field.Names[0].Name
if !ast.IsExported(name) {
continue
}
}
comment := ""
if field.Doc != nil {
comment += field.Doc.Text()
}
if field.Comment != nil {
comment += field.Comment.Text()
}
if !chineseText.MatchString(comment) {
t.Errorf("%s.%s 缺少中文字段注释", typeSpec.Name.Name, name)
}
}
return false
})
}
}

View File

@@ -4,11 +4,11 @@ import "git.apinb.com/bsm-sdk/core/database"
// CsTicket 对应 cs_ticket保存客服工单。
type CsTicket struct {
Entity
TicketNo string `gorm:"column:ticket_no;type:varchar(64);not null;uniqueIndex" json:"ticket_no"`
UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"`
Category string `gorm:"column:category;type:varchar(64);not null" json:"category"`
Priority string `gorm:"column:priority;type:varchar(16);not null;default:'normal'" json:"priority"`
Entity // 公共实体字段
TicketNo string `gorm:"column:ticket_no;type:varchar(64);not null;uniqueIndex" json:"ticket_no"` // ticket_no 业务字段
UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` // user_account_id 业务字段
Category string `gorm:"column:category;type:varchar(64);not null" json:"category"` // category 业务字段
Priority string `gorm:"column:priority;type:varchar(16);not null;default:'normal'" json:"priority"` // priority 业务字段
}
func init() { database.AppendMigrate(&CsTicket{}) }

View File

@@ -4,12 +4,12 @@ import "git.apinb.com/bsm-sdk/core/database"
// DeliveryAccount 对应 delivery_account保存配送点登录账户。
type DeliveryAccount struct {
Entity
DeliveryBasicID uint64 `gorm:"column:delivery_basic_id;not null;index" json:"delivery_basic_id"`
Username string `gorm:"column:username;type:varchar(64);not null;uniqueIndex" json:"username"`
DisplayName string `gorm:"column:display_name;type:varchar(64);not null;default:''" json:"display_name"`
PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null" json:"-"`
RoleCode string `gorm:"column:role_code;type:varchar(64);not null" json:"role_code"`
Entity // 公共实体字段
DeliveryBasicID uint64 `gorm:"column:delivery_basic_id;not null;index" json:"delivery_basic_id"` // delivery_basic_id 业务字段
Username string `gorm:"column:username;type:varchar(64);not null;uniqueIndex" json:"username"` // username 业务字段
DisplayName string `gorm:"column:display_name;type:varchar(64);not null;default:''" json:"display_name"` // display_name 业务字段
PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null" json:"-"` // PasswordHash 业务字段
RoleCode string `gorm:"column:role_code;type:varchar(64);not null" json:"role_code"` // role_code 业务字段
}
func init() { database.AppendMigrate(&DeliveryAccount{}) }

View File

@@ -4,7 +4,7 @@ import "git.apinb.com/bsm-sdk/core/database"
// DeliveryBasic 对应 delivery_basic保存配送点主档案。
type DeliveryBasic struct {
Entity
Entity // 公共实体字段
DeliveryCode string `gorm:"column:delivery_code;type:varchar(32);not null;uniqueIndex" json:"delivery_code"` // 配送点编码
GasBasicID uint64 `gorm:"column:gas_basic_id;not null;default:0;index" json:"gas_basic_id"` // 所属可燃气体站自增主键0 表示平台直属
Name string `gorm:"column:name;type:varchar(128);not null" json:"name"` // 配送点名称

View File

@@ -4,10 +4,10 @@ import "git.apinb.com/bsm-sdk/core/database"
// DeliveryTask 对应 delivery_task保存配送履约任务。
type DeliveryTask struct {
Entity
EcOrderID uint64 `gorm:"column:ec_order_id;not null;index" json:"ec_order_id"`
StaffAccountID uint64 `gorm:"column:staff_account_id;not null;default:0;index" json:"staff_account_id"`
DeliveryPointID uint64 `gorm:"column:delivery_point_id;not null;index" json:"delivery_point_id"`
Entity // 公共实体字段
EcOrderID uint64 `gorm:"column:ec_order_id;not null;index" json:"ec_order_id"` // ec_order_id 业务字段
StaffAccountID uint64 `gorm:"column:staff_account_id;not null;default:0;index" json:"staff_account_id"` // staff_account_id 业务字段
DeliveryPointID uint64 `gorm:"column:delivery_point_id;not null;index" json:"delivery_point_id"` // delivery_point_id 业务字段
}
func init() { database.AppendMigrate(&DeliveryTask{}) }

View File

@@ -7,10 +7,10 @@ import (
// DeliveryTrack 对应 delivery_track保存配送轨迹摘要。
type DeliveryTrack struct {
Entity
DeliveryTaskID uint64 `gorm:"column:delivery_task_id;not null;index" json:"delivery_task_id"`
StartedAt *time.Time `gorm:"column:started_at;type:timestamptz" json:"started_at"`
CompletedAt *time.Time `gorm:"column:completed_at;type:timestamptz" json:"completed_at"`
Entity // 公共实体字段
DeliveryTaskID uint64 `gorm:"column:delivery_task_id;not null;index" json:"delivery_task_id"` // delivery_task_id 业务字段
StartedAt *time.Time `gorm:"column:started_at;type:timestamptz" json:"started_at"` // started_at 业务字段
CompletedAt *time.Time `gorm:"column:completed_at;type:timestamptz" json:"completed_at"` // completed_at 业务字段
}
func init() { database.AppendMigrate(&DeliveryTrack{}) }

View File

@@ -7,12 +7,12 @@ import (
// DeliveryTrackPoint 对应 delivery_track_point保存配送节点和位置。
type DeliveryTrackPoint struct {
Entity
DeliveryTrackID uint64 `gorm:"column:delivery_track_id;not null;index" json:"delivery_track_id"`
PointType string `gorm:"column:point_type;type:varchar(32);not null" json:"point_type"`
OccurredAt time.Time `gorm:"column:occurred_at;type:timestamptz;not null" json:"occurred_at"`
Longitude string `gorm:"column:longitude;type:varchar(32);not null;default:''" json:"longitude"`
Latitude string `gorm:"column:latitude;type:varchar(32);not null;default:''" json:"latitude"`
Entity // 公共实体字段
DeliveryTrackID uint64 `gorm:"column:delivery_track_id;not null;index" json:"delivery_track_id"` // delivery_track_id 业务字段
PointType string `gorm:"column:point_type;type:varchar(32);not null" json:"point_type"` // point_type 业务字段
OccurredAt time.Time `gorm:"column:occurred_at;type:timestamptz;not null" json:"occurred_at"` // occurred_at 业务字段
Longitude string `gorm:"column:longitude;type:varchar(32);not null;default:''" json:"longitude"` // longitude 业务字段
Latitude string `gorm:"column:latitude;type:varchar(32);not null;default:''" json:"latitude"` // latitude 业务字段
}
func init() { database.AppendMigrate(&DeliveryTrackPoint{}) }

View File

@@ -7,11 +7,11 @@ import (
// DevDeviceBinding 对应 dev_device_binding保存设备授权绑定。
type DevDeviceBinding struct {
Entity
SmartCylinderValveID uint64 `gorm:"column:smart_cylinder_valve_id;not null;index" json:"smart_cylinder_valve_id"`
UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"`
EffectiveAt time.Time `gorm:"column:effective_at;type:timestamptz;not null" json:"effective_at"`
ExpiredAt *time.Time `gorm:"column:expired_at;type:timestamptz" json:"expired_at"`
Entity // 公共实体字段
SmartCylinderValveID uint64 `gorm:"column:smart_cylinder_valve_id;not null;index" json:"smart_cylinder_valve_id"` // smart_cylinder_valve_id 业务字段
UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` // user_account_id 业务字段
EffectiveAt time.Time `gorm:"column:effective_at;type:timestamptz;not null" json:"effective_at"` // effective_at 业务字段
ExpiredAt *time.Time `gorm:"column:expired_at;type:timestamptz" json:"expired_at"` // expired_at 业务字段
}
func init() { database.AppendMigrate(&DevDeviceBinding{}) }

View File

@@ -4,11 +4,11 @@ import "git.apinb.com/bsm-sdk/core/database"
// DevSmartCylinderValve 对应 dev_smart_cylinder_valve保存智能瓶阀档案。
type DevSmartCylinderValve struct {
Entity
DeviceNo string `gorm:"column:device_no;type:varchar(64);not null;uniqueIndex" json:"device_no"`
Model string `gorm:"column:model;type:varchar(64);not null;default:''" json:"model"`
OnlineStatus string `gorm:"column:online_status;type:varchar(32);not null;default:'offline'" json:"online_status"`
OwnerIdentity string `gorm:"column:owner_identity;type:varchar(36);not null;default:'';index" json:"owner_identity"`
Entity // 公共实体字段
DeviceNo string `gorm:"column:device_no;type:varchar(64);not null;uniqueIndex" json:"device_no"` // device_no 业务字段
Model string `gorm:"column:model;type:varchar(64);not null;default:''" json:"model"` // model 业务字段
OnlineStatus string `gorm:"column:online_status;type:varchar(32);not null;default:'offline'" json:"online_status"` // online_status 业务字段
OwnerIdentity string `gorm:"column:owner_identity;type:varchar(36);not null;default:'';index" json:"owner_identity"` // owner_identity 业务字段
}
func init() { database.AppendMigrate(&DevSmartCylinderValve{}) }

View File

@@ -7,11 +7,11 @@ import (
// DevTelemetry 对应 dev_telemetry保存设备遥测摘要。
type DevTelemetry struct {
Entity
SmartCylinderValveIdentity string `gorm:"column:smart_cylinder_valve_identity;type:varchar(36);not null;index" json:"smart_cylinder_valve_identity"`
ReportedAt time.Time `gorm:"column:reported_at;type:timestamptz;not null;index" json:"reported_at"`
Payload string `gorm:"column:payload;type:jsonb;not null;default:'{}'" json:"payload"`
QualityFlag string `gorm:"column:quality_flag;type:varchar(32);not null;default:'normal'" json:"quality_flag"`
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 业务字段
QualityFlag string `gorm:"column:quality_flag;type:varchar(32);not null;default:'normal'" json:"quality_flag"` // quality_flag 业务字段
}
func init() { database.AppendMigrate(&DevTelemetry{}) }

View File

@@ -4,11 +4,11 @@ import "git.apinb.com/bsm-sdk/core/database"
// EcCart 对应 ec_cart保存用户购物车明细。
type EcCart struct {
Entity
UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"`
EcProductID uint64 `gorm:"column:ec_product_id;not null;index" json:"ec_product_id"`
Quantity int `gorm:"column:quantity;not null;default:1" json:"quantity"`
Selected bool `gorm:"column:selected;not null;default:true" json:"selected"`
Entity // 公共实体字段
UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` // user_account_id 业务字段
EcProductID uint64 `gorm:"column:ec_product_id;not null;index" json:"ec_product_id"` // ec_product_id 业务字段
Quantity int `gorm:"column:quantity;not null;default:1" json:"quantity"` // quantity 业务字段
Selected bool `gorm:"column:selected;not null;default:true" json:"selected"` // selected 业务字段
}
func init() { database.AppendMigrate(&EcCart{}) }

View File

@@ -4,10 +4,10 @@ import "git.apinb.com/bsm-sdk/core/database"
// EcCategory 对应 ec_category保存商品分类树。
type EcCategory struct {
Entity
ParentID uint64 `gorm:"column:parent_id;not null;default:0;index" json:"parent_id"`
Name string `gorm:"column:name;type:varchar(128);not null" json:"name"`
SortNo int `gorm:"column:sort_no;not null;default:0" json:"sort_no"`
Entity // 公共实体字段
ParentID uint64 `gorm:"column:parent_id;not null;default:0;index" json:"parent_id"` // parent_id 业务字段
Name string `gorm:"column:name;type:varchar(128);not null" json:"name"` // name 业务字段
SortNo int `gorm:"column:sort_no;not null;default:0" json:"sort_no"` // sort_no 业务字段
}
func init() { database.AppendMigrate(&EcCategory{}) }

View File

@@ -4,12 +4,12 @@ import "git.apinb.com/bsm-sdk/core/database"
// EcOrder 对应 ec_order保存电商订单与组织快照。
type EcOrder struct {
Entity
OrderNo string `gorm:"column:order_no;type:varchar(64);not null;uniqueIndex" json:"order_no"`
UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"`
GasStationID uint64 `gorm:"column:gas_station_id;not null;default:0;index" json:"gas_station_id"`
DeliveryPointID uint64 `gorm:"column:delivery_point_id;not null;default:0;index" json:"delivery_point_id"`
TotalAmount int64 `gorm:"column:total_amount;not null;default:0" json:"total_amount"`
Entity // 公共实体字段
OrderNo string `gorm:"column:order_no;type:varchar(64);not null;uniqueIndex" json:"order_no"` // order_no 业务字段
UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` // user_account_id 业务字段
GasStationID uint64 `gorm:"column:gas_station_id;not null;default:0;index" json:"gas_station_id"` // gas_station_id 业务字段
DeliveryPointID uint64 `gorm:"column:delivery_point_id;not null;default:0;index" json:"delivery_point_id"` // delivery_point_id 业务字段
TotalAmount int64 `gorm:"column:total_amount;not null;default:0" json:"total_amount"` // total_amount 业务字段
}
func init() { database.AppendMigrate(&EcOrder{}) }

View File

@@ -4,12 +4,12 @@ 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"`
EcProductID uint64 `gorm:"column:ec_product_id;not null;index" json:"ec_product_id"`
ProductSnapshot string `gorm:"column:product_snapshot;type:jsonb;not null;default:'{}'" json:"product_snapshot"`
Quantity int `gorm:"column:quantity;not null;default:1" json:"quantity"`
SaleAmount int64 `gorm:"column:sale_amount;not null;default:0" json:"sale_amount"`
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 业务字段
}
func init() { database.AppendMigrate(&EcOrderItem{}) }

View File

@@ -4,12 +4,12 @@ import "git.apinb.com/bsm-sdk/core/database"
// EcProduct 对应 ec_product保存可燃气体商品与服务。
type EcProduct struct {
Entity
EcCategoryID uint64 `gorm:"column:ec_category_id;not null;index" json:"ec_category_id"`
ProductCode string `gorm:"column:product_code;type:varchar(64);not null;uniqueIndex" json:"product_code"`
Name string `gorm:"column:name;type:varchar(128);not null" json:"name"`
PriceAmount int64 `gorm:"column:price_amount;not null;default:0" json:"price_amount"`
StockQuantity int `gorm:"column:stock_quantity;not null;default:0" json:"stock_quantity"`
Entity // 公共实体字段
EcCategoryID uint64 `gorm:"column:ec_category_id;not null;index" json:"ec_category_id"` // ec_category_id 业务字段
ProductCode string `gorm:"column:product_code;type:varchar(64);not null;uniqueIndex" json:"product_code"` // product_code 业务字段
Name string `gorm:"column:name;type:varchar(128);not null" json:"name"` // name 业务字段
PriceAmount int64 `gorm:"column:price_amount;not null;default:0" json:"price_amount"` // price_amount 业务字段
StockQuantity int `gorm:"column:stock_quantity;not null;default:0" json:"stock_quantity"` // stock_quantity 业务字段
}
func init() { database.AppendMigrate(&EcProduct{}) }

View File

@@ -4,11 +4,11 @@ import "git.apinb.com/bsm-sdk/core/database"
// EcProductAttribute 对应 ec_product_attribute保存商品属性。
type EcProductAttribute struct {
Entity
EcProductID uint64 `gorm:"column:ec_product_id;not null;index" json:"ec_product_id"`
Name string `gorm:"column:name;type:varchar(64);not null" json:"name"`
Value string `gorm:"column:value;type:varchar(255);not null" json:"value"`
SortNo int `gorm:"column:sort_no;not null;default:0" json:"sort_no"`
Entity // 公共实体字段
EcProductID uint64 `gorm:"column:ec_product_id;not null;index" json:"ec_product_id"` // ec_product_id 业务字段
Name string `gorm:"column:name;type:varchar(64);not null" json:"name"` // name 业务字段
Value string `gorm:"column:value;type:varchar(255);not null" json:"value"` // value 业务字段
SortNo int `gorm:"column:sort_no;not null;default:0" json:"sort_no"` // sort_no 业务字段
}
func init() { database.AppendMigrate(&EcProductAttribute{}) }

View File

@@ -4,11 +4,11 @@ import "git.apinb.com/bsm-sdk/core/database"
// EcProductImage 对应 ec_product_image保存商品受控图片资源。
type EcProductImage struct {
Entity
EcProductID uint64 `gorm:"column:ec_product_id;not null;index" json:"ec_product_id"`
ImageURI string `gorm:"column:image_uri;type:varchar(512);not null" json:"image_uri"`
SortNo int `gorm:"column:sort_no;not null;default:0" json:"sort_no"`
IsCover bool `gorm:"column:is_cover;not null;default:false" json:"is_cover"`
Entity // 公共实体字段
EcProductID uint64 `gorm:"column:ec_product_id;not null;index" json:"ec_product_id"` // ec_product_id 业务字段
ImageURI string `gorm:"column:image_uri;type:varchar(512);not null" json:"image_uri"` // image_uri 业务字段
SortNo int `gorm:"column:sort_no;not null;default:0" json:"sort_no"` // sort_no 业务字段
IsCover bool `gorm:"column:is_cover;not null;default:false" json:"is_cover"` // is_cover 业务字段
}
func init() { database.AppendMigrate(&EcProductImage{}) }

View File

@@ -4,12 +4,12 @@ import "git.apinb.com/bsm-sdk/core/database"
// EcReview 对应 ec_review保存商品评论与审核状态。
type EcReview struct {
Entity
EcOrderID uint64 `gorm:"column:ec_order_id;not null;index" json:"ec_order_id"`
EcProductID uint64 `gorm:"column:ec_product_id;not null;index" json:"ec_product_id"`
UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"`
Score int `gorm:"column:score;not null;default:5" json:"score"`
Content string `gorm:"column:content;type:text;not null;default:''" json:"content"`
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 业务字段
UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` // user_account_id 业务字段
Score int `gorm:"column:score;not null;default:5" json:"score"` // score 业务字段
Content string `gorm:"column:content;type:text;not null;default:''" json:"content"` // content 业务字段
}
func init() { database.AppendMigrate(&EcReview{}) }

View File

@@ -7,11 +7,11 @@ import (
// FinPayment 对应 fin_payment保存支付与退款记录。
type FinPayment struct {
Entity
EcOrderID uint64 `gorm:"column:ec_order_id;not null;index" json:"ec_order_id"`
Channel string `gorm:"column:channel;type:varchar(32);not null" json:"channel"`
Amount int64 `gorm:"column:amount;not null;default:0" json:"amount"`
PaidAt *time.Time `gorm:"column:paid_at;type:timestamptz" json:"paid_at"`
Entity // 公共实体字段
EcOrderID uint64 `gorm:"column:ec_order_id;not null;index" json:"ec_order_id"` // ec_order_id 业务字段
Channel string `gorm:"column:channel;type:varchar(32);not null" json:"channel"` // channel 业务字段
Amount int64 `gorm:"column:amount;not null;default:0" json:"amount"` // amount 业务字段
PaidAt *time.Time `gorm:"column:paid_at;type:timestamptz" json:"paid_at"` // paid_at 业务字段
}
func init() { database.AppendMigrate(&FinPayment{}) }

View File

@@ -7,10 +7,10 @@ import (
// FinReconciliation 对应 fin_reconciliation保存渠道对账记录。
type FinReconciliation struct {
Entity
Channel string `gorm:"column:channel;type:varchar(32);not null;index" json:"channel"`
BillDate time.Time `gorm:"column:bill_date;type:date;not null" json:"bill_date"`
DifferenceAmount int64 `gorm:"column:difference_amount;not null;default:0" json:"difference_amount"`
Entity // 公共实体字段
Channel string `gorm:"column:channel;type:varchar(32);not null;index" json:"channel"` // channel 业务字段
BillDate time.Time `gorm:"column:bill_date;type:date;not null" json:"bill_date"` // bill_date 业务字段
DifferenceAmount int64 `gorm:"column:difference_amount;not null;default:0" json:"difference_amount"` // difference_amount 业务字段
}
func init() { database.AppendMigrate(&FinReconciliation{}) }

View File

@@ -7,12 +7,12 @@ import (
// FinSettlement 对应 fin_settlement保存结算单。
type FinSettlement struct {
Entity
SettlementNo string `gorm:"column:settlement_no;type:varchar(64);not null;uniqueIndex" json:"settlement_no"`
SubjectType string `gorm:"column:subject_type;type:varchar(32);not null" json:"subject_type"`
SubjectID uint64 `gorm:"column:subject_id;not null;index" json:"subject_id"`
PeriodStart time.Time `gorm:"column:period_start;type:timestamptz;not null" json:"period_start"`
PeriodEnd time.Time `gorm:"column:period_end;type:timestamptz;not null" json:"period_end"`
Entity // 公共实体字段
SettlementNo string `gorm:"column:settlement_no;type:varchar(64);not null;uniqueIndex" json:"settlement_no"` // settlement_no 业务字段
SubjectType string `gorm:"column:subject_type;type:varchar(32);not null" json:"subject_type"` // subject_type 业务字段
SubjectID uint64 `gorm:"column:subject_id;not null;index" json:"subject_id"` // subject_id 业务字段
PeriodStart time.Time `gorm:"column:period_start;type:timestamptz;not null" json:"period_start"` // period_start 业务字段
PeriodEnd time.Time `gorm:"column:period_end;type:timestamptz;not null" json:"period_end"` // period_end 业务字段
}
func init() { database.AppendMigrate(&FinSettlement{}) }

View File

@@ -4,7 +4,7 @@ import "git.apinb.com/bsm-sdk/core/database"
// GasAccount 对应 gas_account保存可燃气体站登录账户。
type GasAccount struct {
Entity
Entity // 公共实体字段
GasBasicID uint64 `gorm:"column:gas_basic_id;not null;index" json:"gas_basic_id"` // 可燃气体站主键
Username string `gorm:"column:username;type:varchar(64);not null;uniqueIndex" json:"username"` // 登录名称
DisplayName string `gorm:"column:display_name;type:varchar(64);not null;default:''" json:"display_name"` // 展示名称

View File

@@ -4,7 +4,7 @@ import "git.apinb.com/bsm-sdk/core/database"
// GasBasic 对应 gas_basic保存可燃气体站的主体主档案。
type GasBasic struct {
Entity
Entity // 公共实体字段
Code string `gorm:"column:code;type:varchar(32);not null;uniqueIndex" json:"code"` // 站点编码
Name string `gorm:"column:name;type:varchar(128);not null" json:"name"` // 站点名称
CreditCode string `gorm:"column:credit_code;type:varchar(64);not null;default:''" json:"credit_code"` // 统一社会信用代码

View File

@@ -4,10 +4,10 @@ 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"`
Channel string `gorm:"column:channel;type:varchar(32);not null" json:"channel"`
Content string `gorm:"column:content;type:text;not null" json:"content"`
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{}) }

View File

@@ -4,7 +4,7 @@ import "git.apinb.com/bsm-sdk/core/database"
// PlatformMenu 对应 platform_menu定义平台总后台的菜单树和访问路由。
type PlatformMenu struct {
Entity
Entity // 公共实体字段
ParentID uint64 `gorm:"column:parent_id;not null;default:0;index" json:"parent_id"` // 父菜单自增主键,顶级菜单为 0
MenuCode string `gorm:"column:menu_code;type:varchar(64);not null;uniqueIndex" json:"menu_code"` // 菜单编码
Name string `gorm:"column:name;type:varchar(64);not null" json:"name"` // 菜单名称

View File

@@ -4,7 +4,7 @@ import "git.apinb.com/bsm-sdk/core/database"
// PlatformRole 对应 platform_role定义平台总后台的数据范围与菜单权限角色。
type PlatformRole struct {
Entity
Entity // 公共实体字段
RoleCode string `gorm:"column:role_code;type:varchar(64);not null;uniqueIndex" json:"role_code"` // 角色编码
Name string `gorm:"column:name;type:varchar(64);not null" json:"name"` // 角色名称
DataScope string `gorm:"column:data_scope;type:varchar(32);not null;default:'global'" json:"data_scope"` // 数据权限范围

View File

@@ -4,7 +4,7 @@ import "git.apinb.com/bsm-sdk/core/database"
// PlatfromAccount 对应 platfrom_account表示平台总后台登录账号。
type PlatfromAccount struct {
Entity
Entity // 公共实体字段
Username string `gorm:"column:username;type:varchar(64);uniqueIndex;not null" json:"username"` // 登录用户名
DisplayName string `gorm:"column:display_name;type:varchar(64);not null;default:''" json:"display_name"` // 用户展示名称
Avatar string `gorm:"column:avatar;type:varchar(512);not null;default:''" json:"avatar"` // 头像资源地址

View File

@@ -7,11 +7,11 @@ import (
// Report 对应 report保存统计报表档案。
type Report struct {
Entity
ReportCode string `gorm:"column:report_code;type:varchar(64);not null;uniqueIndex" json:"report_code"`
ReportType string `gorm:"column:report_type;type:varchar(32);not null" json:"report_type"`
StatPeriod string `gorm:"column:stat_period;type:varchar(64);not null" json:"stat_period"`
GeneratedAt time.Time `gorm:"column:generated_at;type:timestamptz;not null" json:"generated_at"`
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{}) }

View File

@@ -4,11 +4,11 @@ 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"`
Dimension string `gorm:"column:dimension;type:varchar(128);not null" json:"dimension"`
MetricCode string `gorm:"column:metric_code;type:varchar(64);not null" json:"metric_code"`
MetricValue string `gorm:"column:metric_value;type:varchar(128);not null" json:"metric_value"`
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{}) }

View File

@@ -7,12 +7,12 @@ import (
// ReportMetricSnapshot 对应 report_metric_snapshot保存指标快照。
type ReportMetricSnapshot struct {
Entity
MetricCode string `gorm:"column:metric_code;type:varchar(64);not null;index" json:"metric_code"`
ScopeType string `gorm:"column:scope_type;type:varchar(32);not null" json:"scope_type"`
ScopeID uint64 `gorm:"column:scope_id;not null;default:0;index" json:"scope_id"`
StatAt time.Time `gorm:"column:stat_at;type:timestamptz;not null;index" json:"stat_at"`
MetricValue string `gorm:"column:metric_value;type:varchar(128);not null" json:"metric_value"`
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{}) }

View File

@@ -7,12 +7,12 @@ import (
// SafEvent 对应 saf_event保存安全事件统一入口。
type SafEvent struct {
Entity
EventCode string `gorm:"column:event_code;type:varchar(64);not null;uniqueIndex" json:"event_code"`
Level int `gorm:"column:level;not null;default:3" json:"level"`
Title string `gorm:"column:title;type:varchar(256);not null;default:''" json:"title"`
SmartCylinderValveIdentity string `gorm:"column:smart_cylinder_valve_identity;type:varchar(36);not null;default:'';index" json:"smart_cylinder_valve_identity"`
SLAAt *time.Time `gorm:"column:sla_at;type:timestamptz" json:"sla_at"`
Entity // 公共实体字段
EventCode string `gorm:"column:event_code;type:varchar(64);not null;uniqueIndex" json:"event_code"` // event_code 业务字段
Level int `gorm:"column:level;not null;default:3" json:"level"` // level 业务字段
Title string `gorm:"column:title;type:varchar(256);not null;default:''" json:"title"` // title 业务字段
SmartCylinderValveIdentity string `gorm:"column:smart_cylinder_valve_identity;type:varchar(36);not null;default:'';index" json:"smart_cylinder_valve_identity"` // smart_cylinder_valve_identity 业务字段
SLAAt *time.Time `gorm:"column:sla_at;type:timestamptz" json:"sla_at"` // sla_at 业务字段
}
func init() { database.AppendMigrate(&SafEvent{}) }

View File

@@ -4,11 +4,11 @@ import "git.apinb.com/bsm-sdk/core/database"
// SafEventDisposal 对应 saf_event_disposal保存安全处置记录。
type SafEventDisposal struct {
Entity
SafEventIdentity string `gorm:"column:saf_event_identity;type:varchar(36);not null;index" json:"saf_event_identity"`
Action string `gorm:"column:action;type:varchar(64);not null" json:"action"`
Reason string `gorm:"column:reason;type:text;not null;default:''" json:"reason"`
OperatorIdentity string `gorm:"column:operator_identity;type:varchar(36);not null;default:''" json:"operator_identity"`
Entity // 公共实体字段
SafEventIdentity string `gorm:"column:saf_event_identity;type:varchar(36);not null;index" json:"saf_event_identity"` // saf_event_identity 业务字段
Action string `gorm:"column:action;type:varchar(64);not null" json:"action"` // action 业务字段
Reason string `gorm:"column:reason;type:text;not null;default:''" json:"reason"` // reason 业务字段
OperatorIdentity string `gorm:"column:operator_identity;type:varchar(36);not null;default:''" json:"operator_identity"` // operator_identity 业务字段
}
func init() { database.AppendMigrate(&SafEventDisposal{}) }

View File

@@ -4,11 +4,11 @@ import "git.apinb.com/bsm-sdk/core/database"
// SafInspection 对应 saf_inspection保存安检与复检记录。
type SafInspection struct {
Entity
UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"`
StaffAccountID uint64 `gorm:"column:staff_account_id;not null;index" json:"staff_account_id"`
Result string `gorm:"column:result;type:varchar(32);not null" json:"result"`
EvidenceURI string `gorm:"column:evidence_uri;type:varchar(512);not null;default:''" json:"evidence_uri"`
Entity // 公共实体字段
UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` // user_account_id 业务字段
StaffAccountID uint64 `gorm:"column:staff_account_id;not null;index" json:"staff_account_id"` // staff_account_id 业务字段
Result string `gorm:"column:result;type:varchar(32);not null" json:"result"` // result 业务字段
EvidenceURI string `gorm:"column:evidence_uri;type:varchar(512);not null;default:''" json:"evidence_uri"` // evidence_uri 业务字段
}
func init() { database.AppendMigrate(&SafInspection{}) }

View File

@@ -4,12 +4,12 @@ import "git.apinb.com/bsm-sdk/core/database"
// SafRule 对应 saf_rule保存安全规则。
type SafRule struct {
Entity
RuleCode string `gorm:"column:rule_code;type:varchar(64);not null;uniqueIndex" json:"rule_code"`
VersionNo int `gorm:"column:version_no;not null;default:1" json:"version_no"`
Threshold string `gorm:"column:threshold;type:jsonb;not null;default:'{}'" json:"threshold"`
Action string `gorm:"column:action;type:varchar(64);not null" json:"action"`
GrayScope string `gorm:"column:gray_scope;type:jsonb;not null;default:'{}'" json:"gray_scope"`
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 业务字段
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 业务字段
}
func init() { database.AppendMigrate(&SafRule{}) }

View File

@@ -4,7 +4,7 @@ import "git.apinb.com/bsm-sdk/core/database"
// StaffAccount 对应 staff_account是服务人员唯一的档案和 App 登录账户。
type StaffAccount struct {
Entity
Entity // 公共实体字段
Username string `gorm:"column:username;type:varchar(64);not null;uniqueIndex" json:"username"` // 登录名称
PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null" json:"-"` // 密码哈希
Name string `gorm:"column:name;type:varchar(64);not null" json:"name"` // 人员姓名

View File

@@ -7,11 +7,11 @@ import (
// StaffCredential 对应 staff_credential保存人员资质。
type StaffCredential struct {
Entity
StaffAccountID uint64 `gorm:"column:staff_account_id;not null;index" json:"staff_account_id"`
CredentialType string `gorm:"column:credential_type;type:varchar(64);not null" json:"credential_type"`
CredentialNo string `gorm:"column:credential_no;type:varchar(128);not null;default:''" json:"credential_no"`
ExpiredAt *time.Time `gorm:"column:expired_at;type:timestamptz" json:"expired_at"`
Entity // 公共实体字段
StaffAccountID uint64 `gorm:"column:staff_account_id;not null;index" json:"staff_account_id"` // staff_account_id 业务字段
CredentialType string `gorm:"column:credential_type;type:varchar(64);not null" json:"credential_type"` // credential_type 业务字段
CredentialNo string `gorm:"column:credential_no;type:varchar(128);not null;default:''" json:"credential_no"` // credential_no 业务字段
ExpiredAt *time.Time `gorm:"column:expired_at;type:timestamptz" json:"expired_at"` // expired_at 业务字段
}
func init() { database.AppendMigrate(&StaffCredential{}) }

View File

@@ -4,7 +4,7 @@ import "git.apinb.com/bsm-sdk/core/database"
// UserAccount 对应 user_account是业主客户唯一的档案和用户端登录账户。
type UserAccount struct {
Entity
Entity // 公共实体字段
Username string `gorm:"column:username;type:varchar(64);not null;uniqueIndex" json:"username"` // 登录名称
PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null" json:"-"` // 密码哈希
Name string `gorm:"column:name;type:varchar(64);not null" json:"name"` // 客户姓名

View File

@@ -4,12 +4,12 @@ import "git.apinb.com/bsm-sdk/core/database"
// UserAddress 对应 user_address保存用户地址。
type UserAddress struct {
Entity
UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"`
Address string `gorm:"column:address;type:varchar(255);not null" json:"address"`
Longitude string `gorm:"column:longitude;type:varchar(32);not null;default:''" json:"longitude"`
Latitude string `gorm:"column:latitude;type:varchar(32);not null;default:''" json:"latitude"`
IsDefault bool `gorm:"column:is_default;not null;default:false" json:"is_default"`
Entity // 公共实体字段
UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` // user_account_id 业务字段
Address string `gorm:"column:address;type:varchar(255);not null" json:"address"` // address 业务字段
Longitude string `gorm:"column:longitude;type:varchar(32);not null;default:''" json:"longitude"` // longitude 业务字段
Latitude string `gorm:"column:latitude;type:varchar(32);not null;default:''" json:"latitude"` // latitude 业务字段
IsDefault bool `gorm:"column:is_default;not null;default:false" json:"is_default"` // is_default 业务字段
}
func init() { database.AppendMigrate(&UserAddress{}) }

View File

@@ -4,11 +4,11 @@ import "git.apinb.com/bsm-sdk/core/database"
// UserServiceRelation 对应 user_service_relation保存用户服务归属快照。
type UserServiceRelation struct {
Entity
UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"`
GasBasicID uint64 `gorm:"column:gas_basic_id;not null;default:0;index" json:"gas_basic_id"`
DeliveryBasicID uint64 `gorm:"column:delivery_basic_id;not null;default:0;index" json:"delivery_basic_id"`
StaffAccountID uint64 `gorm:"column:staff_account_id;not null;default:0;index" json:"staff_account_id"`
Entity // 公共实体字段
UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` // user_account_id 业务字段
GasBasicID uint64 `gorm:"column:gas_basic_id;not null;default:0;index" json:"gas_basic_id"` // gas_basic_id 业务字段
DeliveryBasicID uint64 `gorm:"column:delivery_basic_id;not null;default:0;index" json:"delivery_basic_id"` // delivery_basic_id 业务字段
StaffAccountID uint64 `gorm:"column:staff_account_id;not null;default:0;index" json:"staff_account_id"` // staff_account_id 业务字段
}
func init() { database.AppendMigrate(&UserServiceRelation{}) }

View File

@@ -4,11 +4,11 @@ import "git.apinb.com/bsm-sdk/core/database"
// Wallet 对应 wallet保存余额账户。
type Wallet struct {
Entity
OwnerType string `gorm:"column:owner_type;type:varchar(32);not null" json:"owner_type"`
OwnerID uint64 `gorm:"column:owner_id;not null;index" json:"owner_id"`
BalanceAmount int64 `gorm:"column:balance_amount;not null;default:0" json:"balance_amount"`
FrozenAmount int64 `gorm:"column:frozen_amount;not null;default:0" json:"frozen_amount"`
Entity // 公共实体字段
OwnerType string `gorm:"column:owner_type;type:varchar(32);not null" json:"owner_type"` // owner_type 业务字段
OwnerID uint64 `gorm:"column:owner_id;not null;index" json:"owner_id"` // owner_id 业务字段
BalanceAmount int64 `gorm:"column:balance_amount;not null;default:0" json:"balance_amount"` // balance_amount 业务字段
FrozenAmount int64 `gorm:"column:frozen_amount;not null;default:0" json:"frozen_amount"` // frozen_amount 业务字段
}
func init() { database.AppendMigrate(&Wallet{}) }

View File

@@ -4,12 +4,12 @@ import "git.apinb.com/bsm-sdk/core/database"
// WalletLedger 对应 wallet_ledger保存不可变资金流水。
type WalletLedger struct {
Entity
WalletID uint64 `gorm:"column:wallet_id;not null;index" json:"wallet_id"`
Amount int64 `gorm:"column:amount;not null" json:"amount"`
Direction string `gorm:"column:direction;type:varchar(16);not null" json:"direction"`
BalanceAfter int64 `gorm:"column:balance_after;not null" json:"balance_after"`
ReferenceIdentity string `gorm:"column:reference_identity;type:varchar(36);not null;default:'';index" json:"reference_identity"`
Entity // 公共实体字段
WalletID uint64 `gorm:"column:wallet_id;not null;index" json:"wallet_id"` // wallet_id 业务字段
Amount int64 `gorm:"column:amount;not null" json:"amount"` // amount 业务字段
Direction string `gorm:"column:direction;type:varchar(16);not null" json:"direction"` // direction 业务字段
BalanceAfter int64 `gorm:"column:balance_after;not null" json:"balance_after"` // balance_after 业务字段
ReferenceIdentity string `gorm:"column:reference_identity;type:varchar(36);not null;default:'';index" json:"reference_identity"` // reference_identity 业务字段
}
func init() { database.AppendMigrate(&WalletLedger{}) }

View File

@@ -4,10 +4,10 @@ import "git.apinb.com/bsm-sdk/core/database"
// WalletRecharge 对应 wallet_recharge保存充值记录。
type WalletRecharge struct {
Entity
WalletID uint64 `gorm:"column:wallet_id;not null;index" json:"wallet_id"`
Amount int64 `gorm:"column:amount;not null" json:"amount"`
Channel string `gorm:"column:channel;type:varchar(32);not null" json:"channel"`
Entity // 公共实体字段
WalletID uint64 `gorm:"column:wallet_id;not null;index" json:"wallet_id"` // wallet_id 业务字段
Amount int64 `gorm:"column:amount;not null" json:"amount"` // amount 业务字段
Channel string `gorm:"column:channel;type:varchar(32);not null" json:"channel"` // channel 业务字段
}
func init() { database.AppendMigrate(&WalletRecharge{}) }

View File

@@ -4,10 +4,10 @@ import "git.apinb.com/bsm-sdk/core/database"
// WalletWithdrawal 对应 wallet_withdrawal保存提现记录。
type WalletWithdrawal struct {
Entity
WalletID uint64 `gorm:"column:wallet_id;not null;index" json:"wallet_id"`
Amount int64 `gorm:"column:amount;not null" json:"amount"`
BankAccountMasked string `gorm:"column:bank_account_masked;type:varchar(128);not null;default:''" json:"bank_account_masked"`
Entity // 公共实体字段
WalletID uint64 `gorm:"column:wallet_id;not null;index" json:"wallet_id"` // wallet_id 业务字段
Amount int64 `gorm:"column:amount;not null" json:"amount"` // amount 业务字段
BankAccountMasked string `gorm:"column:bank_account_masked;type:varchar(128);not null;default:''" json:"bank_account_masked"` // bank_account_masked 业务字段
}
func init() { database.AppendMigrate(&WalletWithdrawal{}) }

View File

@@ -48,7 +48,7 @@ func registerDeliveryRoute(group *gin.RouterGroup) {
trackRelations := []platform.ResourceRelation{requiredRelation("delivery_task_identity", "delivery_task_id", &models.DeliveryTask{})}
list, create, _, update := platform.ResourceHandlers(&models.DeliveryTrack{}, []string{"started_at", "completed_at"}, []string{"started_at", "completed_at"}, trackRelations...)
registerWritableResource(group, "/delivery/delivery_track", list, create, platform.GetDeliveryTrack, update, &models.DeliveryTrack{})
registerRestrictedWritableResource(group, "/delivery/delivery_track_point", &models.DeliveryTrackPoint{}, []string{"point_type", "occurred_at", "longitude", "latitude"}, requiredRelation("delivery_track_identity", "delivery_track_id", &models.DeliveryTrack{}))
registerReadOnlyResource(group, "/delivery/delivery_track_point", &models.DeliveryTrackPoint{})
}
func registerDeviceRoute(group *gin.RouterGroup) {

View File

@@ -102,13 +102,20 @@ func TestPlatformDeviceSafetyCommerceAndDeliveryRoutesFollowTheirContracts(t *te
"/device/dev_smart_cylinder_valve", "/device/dev_device_binding",
"/safety/saf_rule", "/safety/saf_event", "/safety/saf_inspection",
"/ec/ec_category", "/ec/ec_product", "/ec/ec_product_attribute", "/ec/ec_product_image", "/ec/ec_cart", "/ec/ec_order", "/ec/ec_order_item", "/ec/ec_review",
"/delivery/delivery_task", "/delivery/delivery_track", "/delivery/delivery_track_point",
"/delivery/delivery_task", "/delivery/delivery_track",
} {
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)
assertRouteMethods(t, routes, "/heqi/platform/v1"+resource+"/:identity/status", http.MethodPatch)
}
trackPoint := "/heqi/platform/v1/delivery/delivery_track_point"
assertRouteMethods(t, routes, trackPoint, http.MethodGet)
assertRouteMethods(t, routes, trackPoint+"/:identity", http.MethodGet)
assertNoRouteMethods(t, routes, trackPoint, http.MethodPost)
assertNoRouteMethods(t, routes, trackPoint+"/:identity", http.MethodPut, http.MethodDelete)
assertNoRouteMethods(t, routes, trackPoint+"/:identity/status", http.MethodPatch)
telemetry := "/heqi/platform/v1/device/dev_telemetry"
assertRouteMethods(t, routes, telemetry, http.MethodGet)
assertRouteMethods(t, routes, telemetry+"/:identity", http.MethodGet)

View File

@@ -0,0 +1,73 @@
# Platform Admin Final Important Fixes Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 修复平台总后台终审的 8 项 Important 问题,并以回归测试、全量校验和提交记录证明修复结果。
**Architecture:** 后端统一在资源边界完成响应投影、关键字查询和精确位置授权,避免各页面或处理器自行绕过安全规则。前端以类型化字段定义驱动表单控件和请求载荷,并为审批与树归档提供明确交互;模型注释通过 AST 审计守住完整性。
**Tech Stack:** Go 1.26、Gin、GORM、Vue 3、TypeScript、Arco Design Vue、Node test runner、Biome 2.5、pnpm。
## Global Constraints
- HTTP 仅公开 `identity``<实体>_identity`,禁止公开数据库自增 ID。
- 精确轨迹坐标必须由显式短期授权声明控制,普通列表和详情必须脱敏。
- 可选关系为空时不进入请求体;数字、布尔、时间和 JSON 字段保持正确类型。
- 归档为 `status=archived`,不得物理删除。
- 所有模型字段必须有中文注释。
---
### Task 1: 后端安全边界与筛选
**Files:**
- Modify: `backend/api/internal/logic/platform/task4_resources.go`
- Modify: `backend/api/internal/logic/platform/platform.go`
- Modify: `backend/api/internal/logic/platform/resource.go`
- Modify: `backend/api/internal/routers/platform.go`
- Test: `backend/api/internal/logic/platform/resource_test.go`
- Test: `backend/api/internal/routers/platform_test.go`
**Interfaces:**
- Consumes: Gin 查询参数、JWT `location_scope`、资源关系定义。
- Produces: 创建响应公共投影、轨迹点脱敏处理器、通用 `keyword` 过滤。
- [ ] **Step 1: 写创建响应、轨迹点授权和关键字筛选失败测试。**
- [ ] **Step 2: 运行定向 Go 测试并确认按预期失败。**
- [ ] **Step 3: 统一创建响应投影,轨迹点改为只读授权处理器,并把安全关键字条件同时用于 count/list。**
- [ ] **Step 4: 运行定向 Go 测试并确认通过。**
### Task 2: 前端类型化表单、审批和树归档
**Files:**
- Modify: `frontend/platform_admin/src/api/resources.ts`
- Create: `frontend/platform_admin/src/api/resource-form.ts`
- Modify: `frontend/platform_admin/src/views/shared/CrudListPage.vue`
- Modify: `frontend/platform_admin/src/views/shared/ReadOnlyListPage.vue`
- Modify: `frontend/platform_admin/src/views/shared/TreePage.vue`
- Test: `frontend/platform_admin/scripts/final-important.test.mjs`
**Interfaces:**
- Consumes: `ResourceField{type, required}` 与当前表单值。
- Produces: `buildResourcePayload(fields, form)`,以及审批 POST 和树归档交互。
- [ ] **Step 1: 写字段类型、空可选关系、审批动作和树归档失败测试。**
- [ ] **Step 2: 运行 Node 测试并确认按预期失败。**
- [ ] **Step 3: 实现类型化控件/载荷、审批详情动作和树归档确认。**
- [ ] **Step 4: 运行 Node 测试并确认通过。**
### Task 3: Biome 与模型中文注释
**Files:**
- Modify: `frontend/platform_admin/biome.json`
- Modify: `backend/api/internal/models/*.go`
- Test: `backend/api/internal/models/comments_test.go`
**Interfaces:**
- Consumes: Biome 配置与 Go AST。
- Produces: 不依赖缺失本地 ignore 文件的 lint 配置,以及每个导出模型字段的中文注释。
- [ ] **Step 1: 复现 Biome ignore-file 配置错误并写模型注释 AST 失败测试。**
- [ ] **Step 2: 修正 Biome VCS ignore 配置,为全部模型字段补中文注释。**
- [ ] **Step 3: 运行 `go test ./...`、Node 测试、`pnpm audit:platform`、`pnpm lint`、`pnpm type:check` 和 `pnpm build`。**
- [ ] **Step 4: 复核差异并提交。**

View File

@@ -2,7 +2,9 @@
## 范围与结论
本次审计覆盖 46 个平台后台资源34 个可写资源、11 个只读资源和 1 个仅追加的安全事件处置资源。后端资源契约现同时声明领域、资源名、HTTP 路径、页面类型和读写模式,并由运行时注册路由生成清单。前端审计据此逐项校验后端路由、前端资源声明、实际加载的页面组件和带菜单元数据的路由;任一层缺失均以 `领域/资源: missing <layer>` 失败。
本次审计覆盖 46 个平台后台资源33 个可写资源、12 个只读资源和 1 个仅追加的安全事件处置资源。后端资源契约现同时声明领域、资源名、HTTP 路径、页面类型和读写模式,并由运行时注册路由生成清单。前端审计据此逐项校验后端路由、前端资源声明、实际加载的页面组件和带菜单元数据的路由;任一层缺失均以 `领域/资源: missing <layer>` 失败。
终审追加识别的 8 项 Important 问题已全部处置并纳入回归验证:创建响应安全投影、轨迹点精确位置授权、关键字筛选一致性、表单字段类型、空可选关系、审批动作、树节点归档,以及 Biome/模型中文注释完整性。
## 发现与处置
@@ -13,6 +15,14 @@
| 页面存在但未被菜单路由实际加载时可能漏检 | 审计读取全部路由模块,解析页面动态导入,再确认页面通过 `getResource(契约路径)` 使用对应资源且路由具有 `menu.platform.*` 元数据 | `audit-check.test.mjs` 的菜单映射用例 |
| API 或页面可能展示、提交数据库自增 `id` / `*_id` | 对 `src/api``src/views` 全量扫描;详情页的显式过滤逻辑被识别为防护而非泄漏 | `pnpm audit:platform` |
| 仅追加处置和只读状态写入存在审计盲区 | 处置资源必须由 `saf_event` 详情动作触发、不可拥有独立页面;后端仅允许 GET/POST且按事件标识查询处置历史只读资源扫描 API、路由和页面中的 `updateStatus` | 6 个 `audit-check.test.mjs` 用例及逐契约路由方法测试 |
| 创建接口可能回传内部关系 ID、密码散列或原始个人敏感信息 | 创建成功统一经过公共身份投影和敏感字段遮罩,只保留 `identity`、关联 `*_identity` 与脱敏结果 | `TestCreateGasAccountResolvesGasBasicIdentityBeforePersisting``TestCreatedResourceResponseMasksSensitiveFieldsAndKeepsIdentity` |
| 轨迹点资源可写,且普通列表/详情可能泄露精确经纬度 | `delivery_track_point` 改为只读契约;仅 JWT 明确声明 `location_scope=precise` 时返回精确坐标,其他响应清空坐标 | `TestListDeliveryTrackPointsMasksCoordinatesWithoutPreciseLocationScope``TestGetDeliveryTrackPointReturnsCoordinatesWithPreciseLocationScope` |
| 通用关键字只影响列表或使用不安全字段,可能导致总数与结果不一致 | 根据模型字符串列生成受控条件,排除身份、状态、密码、坐标及审计载荷等敏感列,并将同一条件同时应用于 count/list | `TestListGasAccountAppliesKeywordToCountAndRows` |
| 前端表单把数字、布尔、时间和 JSON 一律按字符串提交 | 资源字段显式声明类型,由 `buildResourcePayload` 统一转换请求载荷,页面按类型选择控件 | `final-important.test.mjs` 的字段类型与载荷转换用例、`pnpm type:check` |
| 空的可选关联标识可能作为空字符串进入请求体 | 载荷边界省略空的可选 `identity` 关系,同时保留必填校验 | `final-important.test.mjs` 的空可选关系用例 |
| 审批只读页缺少同意/驳回入口 | 资源定义声明详情动作,页面按审批身份提交动作及意见并刷新详情和列表 | `final-important.test.mjs` 的审批动作回归用例 |
| 树页面只有新增/编辑,没有符合软删除约束的归档操作 | 增加二次确认,并调用资源归档接口写入 `status=archived`,不执行物理删除 | `final-important.test.mjs` 的树归档回归用例 |
| Biome 依赖 worktree 中不存在的 ignore 文件,模型字段中文注释不完整 | 禁用错误的 VCS ignore 读取;新增 Go AST 测试,要求所有导出模型字段具备中文注释 | `TestEveryModelFieldHasChineseComment``pnpm lint` |
## 身份字段与保留理由
@@ -33,12 +43,13 @@
cd backend/api
go test ./...
go build ./cmd/main
go test ./internal/routers -run '^TestEveryContractHasRegisteredRoute$' -v
cd ../../frontend/platform_admin
node --test scripts/audit-check.test.mjs
pnpm type:check
cd ../..
node --test frontend/platform_admin/scripts/final-important.test.mjs
cd frontend/platform_admin
pnpm lint
pnpm type:check
pnpm audit:platform
pnpm build
```
@@ -47,9 +58,8 @@ pnpm build
| --- | --- | --- |
| `go test ./...` | 0 | 通过 |
| `go build ./cmd/main` | 0 | 通过 |
| `go test ./internal/routers -run '^TestEveryContractHasRegisteredRoute$' -v` | 0 | 通过 |
| `node --test scripts/audit-check.test.mjs` | 0 | 通过6 个审计用例 |
| `node --test frontend/platform_admin/scripts/final-important.test.mjs` | 0 | 通过4 个终审回归用例;脚本按自身路径定位项目,不依赖当前工作目录 |
| `pnpm lint` | 0 | 通过Biome 检查 169 个文件,无错误,保留 190 个非阻断 warning 和 12 个 info |
| `pnpm type:check` | 0 | 通过 |
| `pnpm audit:platform` | 0 | 通过 |
| `pnpm build` | 0 | 通过 |
| `pnpm lint` | 1 | 阻塞Biome 配置在本目录要求 `.gitignore`。以 `--vcs-root=../..` 启动后发现 187 个既有全仓格式/未使用变量错误;未将无关全仓格式化混入本次审计修复。 |

View File

@@ -3,7 +3,7 @@
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": false,

View File

@@ -12,8 +12,8 @@
"preview": "pnpm run build && vite preview --host",
"type:check": "vue-tsc -p tsconfig.build.json --noEmit --skipLibCheck",
"audit:platform": "node scripts/audit-check.mjs",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"lint": "biome lint .",
"lint:fix": "biome lint --write .",
"format": "biome format --write ."
},
"dependencies": {

View File

@@ -0,0 +1,65 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import vm from 'node:vm';
import ts from 'typescript';
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const fromProjectRoot = (...segments) => path.join(projectRoot, ...segments);
function loadResources() {
const compiled = ts.transpileModule(fs.readFileSync(fromProjectRoot('src/api/resources.ts'), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const resourceModule = { exports: {} };
vm.runInNewContext(compiled, { module: resourceModule, exports: resourceModule.exports });
return resourceModule.exports.resources;
}
test('资源字段声明保留数字、布尔、时间与 JSON 类型', () => {
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');
});
test('表单载荷构造器省略空的可选关系并转换字段类型', async () => {
const resourceFormPath = fromProjectRoot('src/api/resource-form.ts');
assert.ok(fs.existsSync(resourceFormPath), 'resource-form.ts should define the payload boundary');
const source = fs.readFileSync(resourceFormPath, 'utf8');
const compiled = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const resourceModule = { exports: {} };
vm.runInNewContext(compiled, { module: resourceModule, exports: resourceModule.exports });
const fields = [
{ key: 'gas_basic_identity', label: '气站', type: 'identity' },
{ key: 'quantity', label: '数量', type: 'number' },
{ key: 'selected', label: '选中', type: 'boolean' },
];
assert.deepEqual(
JSON.parse(JSON.stringify(resourceModule.exports.buildResourcePayload(fields, {
gas_basic_identity: '',
quantity: '2',
selected: false,
}))),
{ quantity: 2, selected: false },
);
});
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, /aud_approval[\s\S]*\/audit\/aud_approval\/:identity\/approve/);
assert.match(source, /submitDetailAction/);
});
test('树页面通过资源归档接口归档节点', () => {
const source = fs.readFileSync(fromProjectRoot('src/views/shared/TreePage.vue'), 'utf8');
assert.match(source, /resourceApi\.archive/);
assert.match(source, /Modal\.(warning|confirm)/);
});

View File

@@ -0,0 +1,41 @@
import type { ResourceField } from './resources';
export type ResourceFormValue = string | number | boolean | undefined;
export function isMissingField(value: ResourceFormValue | null): boolean {
return value === '' || value === null || value === undefined;
}
export function buildResourcePayload(
fields: ResourceField[],
form: Record<string, ResourceFormValue>,
): Record<string, unknown> {
const payload: Record<string, unknown> = {};
for (const field of fields) {
const value = form[field.key];
if (isMissingField(value)) {
if (!field.required) continue;
payload[field.key] = value;
continue;
}
switch (field.type) {
case 'number': {
const number = Number(value);
if (!Number.isFinite(number))
throw new Error(`${field.label}必须是数字`);
payload[field.key] = number;
break;
}
case 'boolean':
payload[field.key] = value === true || value === 'true';
break;
case 'json':
payload[field.key] =
typeof value === 'string' ? JSON.parse(value) : value;
break;
default:
payload[field.key] = value;
}
}
return payload;
}

View File

@@ -1,69 +1,644 @@
export type ResourceMode = 'writable' | 'readonly' | 'append_only';
export type ResourcePageKind = 'list' | 'tree';
export type ResourceField = { key: string; label: string; required?: boolean };
export type DetailAction = { name: string; resource: string; fields: ResourceField[] };
export type ResourceUiDefinition = { key: string; name: string; resource: string; title: string; mode: ResourceMode; pageKind: ResourcePageKind; fields: ResourceField[]; requiredIdentities: string[]; detailActions?: DetailAction[] };
const labels: Record<string, string> = { code: '编码', name: '名称', credit_code: '统一信用代码', principal: '负责人', address: '地址', longitude: '经度', latitude: '纬度', username: '用户名', password: '密码', display_name: '显示名称', role_code: '角色编码', delivery_code: '配送编码', phone: '联系电话', avatar: '头像', work_status: '工作状态', credential_type: '资质类型', credential_no: '资质编号', expired_at: '到期时间', real_name: '实名姓名', is_default: '默认地址', device_no: '设备编号', model: '设备型号', online_status: '在线状态', effective_at: '生效时间', recorded_at: '采集时间', payload: '遥测数据', rule_code: '规则编码', version_no: '版本号', threshold: '阈值', action: '处置动作', gray_scope: '灰度范围', event_code: '事件编码', level: '事件等级', title: '标题', sla_at: '处置时限', result: '检查结果', evidence_uri: '凭证地址', reason: '处置原因', sort_no: '排序号', product_code: '商品编码', price_amount: '售价', stock_quantity: '库存', value: '属性值', image_uri: '图片地址', is_cover: '封面图', quantity: '数量', selected: '是否选中', order_no: '订单号', total_amount: '订单金额', product_snapshot: '商品快照', sale_amount: '成交金额', score: '评分', channel: '渠道', amount: '金额', paid_at: '支付时间', settlement_no: '结算单号', subject_type: '结算对象类型', period_start: '结算开始时间', period_end: '结算结束时间', bill_date: '账单日期', difference_amount: '差异金额', content_type: '内容类型', body: '正文', publish_status: '发布状态', template_code: '模板编码', content: '内容', ticket_no: '工单号', category: '分类', priority: '优先级', platform_role_code: '平台角色', data_scope: '数据范围', menu_code: '菜单编码', icon: '图标', path: '路径', balance_amount: '余额', change_amount: '变动金额', balance_after: '变动后余额', report_code: '报表编码', metric_code: '指标编码', captured_at: '采集时间', operator_identity: '操作人标识', resource_type: '资源类型', handled_at: '处理时间', status: '状态' };
const field = (value: string): ResourceField => { const key = value.replace(/!$/, ''); return { key, label: labels[key] ?? (key.endsWith('_identity') ? '关联业务标识' : '业务字段'), required: value.endsWith('!') || undefined }; };
const titles: Record<string, string> = { gas_basic: '气站管理', gas_account: '气站账户', delivery_basic: '配送点管理', delivery_account: '配送账户', delivery_task: '配送任务', delivery_track: '配送轨迹', delivery_track_point: '轨迹点', staff_account: '服务人员', staff_credential: '人员资质', user_account: '用户账户', user_address: '用户地址', user_service_relation: '用户服务关系', dev_smart_cylinder_valve: '智能钢瓶阀', dev_device_binding: '设备绑定', dev_telemetry: '设备遥测', saf_rule: '安全规则', saf_event: '安全事件', saf_inspection: '安全检查', saf_event_disposal: '事件处置', ec_category: '商品分类', ec_product: '商品管理', ec_product_attribute: '商品属性', ec_product_image: '商品图片', ec_cart: '购物车', ec_order: '订单管理', ec_order_item: '订单明细', ec_review: '商品评价', fin_payment: '支付记录', fin_settlement: '财务结算', fin_reconciliation: '财务对账', cnt_content: '内容管理', ntf_template: '通知模板', cs_ticket: '客服工单', platfrom_account: '平台账户', platform_role: '平台角色', platform_menu: '平台菜单', wallet: '钱包', wallet_ledger: '钱包流水', wallet_recharge: '钱包充值', wallet_withdrawal: '钱包提现', report: '报表', report_item: '报表项目', report_metric_snapshot: '指标快照', aud_operation_log: '操作审计', aud_export_log: '导出审计', aud_approval: '审批审计' };
const define = (name: string, resource: string, mode: ResourceMode, pageKind: ResourcePageKind, keys: string[], detailActions?: DetailAction[]): ResourceUiDefinition => {
const fields = keys.map(field);
return { key: name.replace(/_/g, '-'), name, resource, title: titles[name] ?? '业务资源', mode, pageKind, fields, requiredIdentities: fields.filter((item) => item.required && item.key.endsWith('_identity')).map((item) => item.key), ...(detailActions ? { detailActions } : {}) };
export type ResourceFieldType =
| 'text'
| 'password'
| 'identity'
| 'number'
| 'boolean'
| 'date'
| 'datetime'
| 'json'
| 'textarea';
export type ResourceField = {
key: string;
label: string;
type: ResourceFieldType;
required?: boolean;
};
const action = (name: string, resource: string, keys: string[]): DetailAction => ({ name, resource, fields: keys.map(field) });
export type DetailAction = {
name: string;
resource: string;
fields: ResourceField[];
payload?: Record<string, unknown>;
};
export type ResourceUiDefinition = {
key: string;
name: string;
resource: string;
title: string;
mode: ResourceMode;
pageKind: ResourcePageKind;
fields: ResourceField[];
requiredIdentities: string[];
detailActions?: DetailAction[];
};
const labels: Record<string, string> = {
code: '编码',
name: '名称',
credit_code: '统一信用代码',
principal: '负责人',
address: '地址',
longitude: '经度',
latitude: '纬度',
username: '用户名',
password: '密码',
display_name: '显示名称',
role_code: '角色编码',
delivery_code: '配送编码',
phone: '联系电话',
avatar: '头像',
work_status: '工作状态',
credential_type: '资质类型',
credential_no: '资质编号',
expired_at: '到期时间',
real_name: '实名姓名',
is_default: '默认地址',
device_no: '设备编号',
model: '设备型号',
online_status: '在线状态',
effective_at: '生效时间',
reported_at: '上报时间',
payload: '遥测数据',
rule_code: '规则编码',
version_no: '版本号',
threshold: '阈值',
action: '处置动作',
gray_scope: '灰度范围',
event_code: '事件编码',
level: '事件等级',
title: '标题',
sla_at: '处置时限',
result: '检查结果',
evidence_uri: '凭证地址',
reason: '处置原因',
sort_no: '排序号',
product_code: '商品编码',
price_amount: '售价',
stock_quantity: '库存',
value: '属性值',
image_uri: '图片地址',
is_cover: '封面图',
quantity: '数量',
selected: '是否选中',
order_no: '订单号',
total_amount: '订单金额',
product_snapshot: '商品快照',
sale_amount: '成交金额',
score: '评分',
channel: '渠道',
amount: '金额',
paid_at: '支付时间',
settlement_no: '结算单号',
subject_type: '结算对象类型',
period_start: '结算开始时间',
period_end: '结算结束时间',
bill_date: '账单日期',
difference_amount: '差异金额',
content_type: '内容类型',
body: '正文',
publish_status: '发布状态',
template_code: '模板编码',
content: '内容',
ticket_no: '工单号',
category: '分类',
priority: '优先级',
platform_role_code: '平台角色',
data_scope: '数据范围',
menu_code: '菜单编码',
icon: '图标',
path: '路径',
balance_amount: '余额',
frozen_amount: '冻结金额',
balance_after: '变动后余额',
report_code: '报表编码',
report_type: '报表类型',
stat_period: '统计周期',
generated_at: '生成时间',
dimension: '维度',
metric_code: '指标编码',
metric_value: '指标值',
scope_type: '范围类型',
stat_at: '统计时间',
applicant_identity: '申请人标识',
business_type: '业务类型',
business_identity: '业务标识',
handler_identity: '处理人标识',
opinion: '审批意见',
purpose: '用途',
field_scope: '字段范围',
approved_at: '批准时间',
file_uri: '文件地址',
operator_identity: '操作人标识',
object_identity: '对象标识',
resource_type: '资源类型',
handled_at: '处理时间',
created_at: '创建时间',
status: '状态',
};
const numberFields = new Set([
'version_no',
'level',
'sort_no',
'price_amount',
'stock_quantity',
'quantity',
'total_amount',
'sale_amount',
'score',
'amount',
'difference_amount',
'balance_amount',
'frozen_amount',
'balance_after',
]);
const booleanFields = new Set(['is_default', 'is_cover', 'selected']);
const dateFields = new Set(['bill_date']);
const datetimeFields = new Set([
'expired_at',
'effective_at',
'reported_at',
'sla_at',
'occurred_at',
'started_at',
'completed_at',
'paid_at',
'period_start',
'period_end',
'generated_at',
'stat_at',
'approved_at',
'handled_at',
'created_at',
]);
const jsonFields = new Set([
'payload',
'threshold',
'gray_scope',
'product_snapshot',
'field_scope',
]);
const textareaFields = new Set(['body', 'content', 'reason', 'opinion']);
const fieldType = (key: string): ResourceFieldType => {
if (key.endsWith('_identity')) return 'identity';
if (key === 'password') return 'password';
if (numberFields.has(key)) return 'number';
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';
};
const field = (value: string): ResourceField => {
const key = value.replace(/!$/, '');
return {
key,
label:
labels[key] ?? (key.endsWith('_identity') ? '关联业务标识' : '业务字段'),
type: fieldType(key),
required: value.endsWith('!') || undefined,
};
};
const titles: Record<string, string> = {
gas_basic: '气站管理',
gas_account: '气站账户',
delivery_basic: '配送点管理',
delivery_account: '配送账户',
delivery_task: '配送任务',
delivery_track: '配送轨迹',
delivery_track_point: '轨迹点',
staff_account: '服务人员',
staff_credential: '人员资质',
user_account: '用户账户',
user_address: '用户地址',
user_service_relation: '用户服务关系',
dev_smart_cylinder_valve: '智能钢瓶阀',
dev_device_binding: '设备绑定',
dev_telemetry: '设备遥测',
saf_rule: '安全规则',
saf_event: '安全事件',
saf_inspection: '安全检查',
saf_event_disposal: '事件处置',
ec_category: '商品分类',
ec_product: '商品管理',
ec_product_attribute: '商品属性',
ec_product_image: '商品图片',
ec_cart: '购物车',
ec_order: '订单管理',
ec_order_item: '订单明细',
ec_review: '商品评价',
fin_payment: '支付记录',
fin_settlement: '财务结算',
fin_reconciliation: '财务对账',
cnt_content: '内容管理',
ntf_template: '通知模板',
cs_ticket: '客服工单',
platfrom_account: '平台账户',
platform_role: '平台角色',
platform_menu: '平台菜单',
wallet: '钱包',
wallet_ledger: '钱包流水',
wallet_recharge: '钱包充值',
wallet_withdrawal: '钱包提现',
report: '报表',
report_item: '报表项目',
report_metric_snapshot: '指标快照',
aud_operation_log: '操作审计',
aud_export_log: '导出审计',
aud_approval: '审批审计',
};
const define = (
name: string,
resource: string,
mode: ResourceMode,
pageKind: ResourcePageKind,
keys: string[],
detailActions?: DetailAction[],
): ResourceUiDefinition => {
const fields = keys.map(field);
return {
key: name.replace(/_/g, '-'),
name,
resource,
title: titles[name] ?? '业务资源',
mode,
pageKind,
fields,
requiredIdentities: fields
.filter((item) => item.required && item.key.endsWith('_identity'))
.map((item) => item.key),
...(detailActions ? { detailActions } : {}),
};
};
const action = (
name: string,
resource: string,
keys: string[],
payload?: Record<string, unknown>,
): DetailAction => ({
name,
resource,
fields: keys.map(field),
...(payload ? { payload } : {}),
});
/** Exact UI contract for every backend ExpectedResources entry. */
export const resources: ResourceUiDefinition[] = [
define('gas_basic', '/gas/gas_basic', 'writable', 'list', ['code!', 'name!', 'credit_code', 'principal', 'address', 'longitude', 'latitude']),
define('gas_account', '/gas/gas_account', 'writable', 'list', ['username!', 'password!', 'display_name', 'role_code', 'gas_basic_identity!']),
define('delivery_basic', '/delivery/delivery_basic', 'writable', 'list', ['delivery_code!', 'name!', 'gas_basic_identity', 'principal', 'address']),
define('delivery_account', '/delivery/delivery_account', 'writable', 'list', ['username!', 'password!', 'display_name', 'role_code', 'delivery_basic_identity!']),
define('delivery_task', '/delivery/delivery_task', 'writable', 'list', ['ec_order_identity!', 'delivery_basic_identity!', 'staff_account_identity']),
define('delivery_track', '/delivery/delivery_track', 'writable', 'list', ['delivery_task_identity!', 'started_at', 'completed_at']),
define('delivery_track_point', '/delivery/delivery_track_point', 'writable', 'list', ['delivery_track_identity!', 'point_type!', 'occurred_at!', 'longitude!', 'latitude!']),
define('staff_account', '/staff/account', 'writable', 'list', ['username!', 'password!', 'name!', 'phone', 'avatar', 'role_code', 'gas_basic_identity', 'delivery_basic_identity', 'work_status']),
define('staff_credential', '/staff/credential', 'writable', 'list', ['staff_account_identity!', 'credential_type!', 'credential_no', 'expired_at']),
define('user_account', '/user/account', 'writable', 'list', ['username!', 'password!', 'name!', 'phone', 'avatar', 'real_name']),
define('user_address', '/user/address', 'writable', 'list', ['user_account_identity!', 'address!', 'longitude', 'latitude', 'is_default']),
define('user_service_relation', '/user/service_relation', 'writable', 'list', ['user_account_identity!', 'gas_basic_identity', 'delivery_basic_identity', 'staff_account_identity']),
define('dev_smart_cylinder_valve', '/device/dev_smart_cylinder_valve', 'writable', 'list', ['device_no!', 'model', 'online_status', 'owner_identity']),
define('dev_device_binding', '/device/dev_device_binding', 'writable', 'list', ['smart_cylinder_valve_identity!', 'user_account_identity!', 'effective_at', 'expired_at']),
define('dev_telemetry', '/device/dev_telemetry', 'readonly', 'list', ['smart_cylinder_valve_identity', 'recorded_at', 'payload']),
define('saf_rule', '/safety/saf_rule', 'writable', 'list', ['rule_code!', 'version_no', 'threshold', 'action', 'gray_scope']),
define('saf_event', '/safety/saf_event', 'writable', 'list', ['event_code!', 'level!', 'title!', 'smart_cylinder_valve_identity!', 'sla_at'], [action('saf_event_disposal', '/safety/saf_event/:identity/disposals', ['action!', 'reason!'])]),
define('saf_inspection', '/safety/saf_inspection', 'writable', 'list', ['user_account_identity!', 'staff_account_identity!', 'result!', 'evidence_uri']),
define('saf_event_disposal', '/safety/saf_event/:identity/disposals', 'append_only', 'list', ['action!', 'reason!']),
define('ec_category', '/ec/ec_category', 'writable', 'tree', ['parent_identity', 'name!', 'sort_no']),
define('ec_product', '/ec/ec_product', 'writable', 'list', ['ec_category_identity!', 'product_code!', 'name!', 'price_amount!', 'stock_quantity']),
define('ec_product_attribute', '/ec/ec_product_attribute', 'writable', 'list', ['ec_product_identity!', 'name!', 'value!', 'sort_no']),
define('ec_product_image', '/ec/ec_product_image', 'writable', 'list', ['ec_product_identity!', 'image_uri!', 'sort_no', 'is_cover']),
define('ec_cart', '/ec/ec_cart', 'writable', 'list', ['user_account_identity!', 'ec_product_identity!', 'quantity!', 'selected']),
define('ec_order', '/ec/ec_order', 'writable', 'list', ['user_account_identity!', 'gas_basic_identity', 'delivery_basic_identity', 'order_no!', 'total_amount!']),
define('ec_order_item', '/ec/ec_order_item', 'writable', 'list', ['ec_order_identity!', 'ec_product_identity!', 'product_snapshot!', 'quantity!', 'sale_amount!']),
define('ec_review', '/ec/ec_review', 'writable', 'list', ['ec_order_identity!', 'ec_product_identity!', 'user_account_identity!', 'score!', 'content!']),
define('fin_payment', '/finance/fin_payment', 'writable', 'list', ['ec_order_identity!', 'channel!', 'amount!', 'paid_at']),
define('fin_settlement', '/finance/fin_settlement', 'writable', 'list', ['settlement_no!', 'subject_type!', 'subject_identity!', 'period_start!', 'period_end!']),
define('fin_reconciliation', '/finance/fin_reconciliation', 'writable', 'list', ['channel!', 'bill_date!', 'difference_amount!']),
define('cnt_content', '/content/cnt_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!', 'category!', 'priority!']),
define('platfrom_account', '/platform/platfrom_account', 'writable', 'list', ['username!', 'password!', 'display_name', 'avatar', 'platform_role_code', 'phone']),
define('platform_role', '/platform/platform_role', 'writable', 'list', ['role_code!', 'name!', 'data_scope!']),
define('platform_menu', '/platform/platform_menu', 'writable', 'tree', ['parent_identity', 'menu_code!', 'name!', 'icon', 'path', 'sort_no']),
define('wallet', '/wallet/wallet', 'readonly', 'list', ['owner_identity', 'balance_amount', 'status']),
define('wallet_ledger', '/wallet/wallet_ledger', 'readonly', 'list', ['wallet_identity', 'change_amount', 'balance_after']),
define('wallet_recharge', '/wallet/wallet_recharge', 'readonly', 'list', ['wallet_identity', 'amount', 'status']),
define('wallet_withdrawal', '/wallet/wallet_withdrawal', 'readonly', 'list', ['wallet_identity', 'amount', 'status']),
define('report', '/report/report', 'readonly', 'list', ['report_code', 'name', 'status']),
define('report_item', '/report/report_item', 'readonly', 'list', ['report_identity', 'metric_code', 'value']),
define('report_metric_snapshot', '/report/report_metric_snapshot', 'readonly', 'list', ['report_identity', 'metric_code', 'value', 'captured_at']),
define('aud_operation_log', '/audit/aud_operation_log', 'readonly', 'list', ['operator_identity', 'action', 'object_identity', 'created_at']),
define('aud_export_log', '/audit/aud_export_log', 'readonly', 'list', ['operator_identity', 'resource_type', 'created_at']),
define('aud_approval', '/audit/aud_approval', 'readonly', 'list', ['operator_identity', 'status', 'handled_at']),
define('gas_basic', '/gas/gas_basic', 'writable', 'list', [
'code!',
'name!',
'credit_code',
'principal',
'address',
'longitude',
'latitude',
]),
define('gas_account', '/gas/gas_account', 'writable', 'list', [
'username!',
'password!',
'display_name',
'role_code',
'gas_basic_identity!',
]),
define('delivery_basic', '/delivery/delivery_basic', 'writable', 'list', [
'delivery_code!',
'name!',
'gas_basic_identity',
'principal',
'address',
]),
define('delivery_account', '/delivery/delivery_account', 'writable', 'list', [
'username!',
'password!',
'display_name',
'role_code',
'delivery_basic_identity!',
]),
define('delivery_task', '/delivery/delivery_task', 'writable', 'list', [
'ec_order_identity!',
'delivery_basic_identity!',
'staff_account_identity',
]),
define('delivery_track', '/delivery/delivery_track', 'writable', 'list', [
'delivery_task_identity!',
'started_at',
'completed_at',
]),
define(
'delivery_track_point',
'/delivery/delivery_track_point',
'readonly',
'list',
[
'delivery_track_identity',
'point_type',
'occurred_at',
'longitude',
'latitude',
],
),
define('staff_account', '/staff/account', 'writable', 'list', [
'username!',
'password!',
'name!',
'phone',
'avatar',
'role_code',
'gas_basic_identity',
'delivery_basic_identity',
'work_status',
]),
define('staff_credential', '/staff/credential', 'writable', 'list', [
'staff_account_identity!',
'credential_type!',
'credential_no',
'expired_at',
]),
define('user_account', '/user/account', 'writable', 'list', [
'username!',
'password!',
'name!',
'phone',
'avatar',
'real_name',
]),
define('user_address', '/user/address', 'writable', 'list', [
'user_account_identity!',
'address!',
'longitude',
'latitude',
'is_default',
]),
define(
'user_service_relation',
'/user/service_relation',
'writable',
'list',
[
'user_account_identity!',
'gas_basic_identity',
'delivery_basic_identity',
'staff_account_identity',
],
),
define(
'dev_smart_cylinder_valve',
'/device/dev_smart_cylinder_valve',
'writable',
'list',
['device_no!', 'model', 'online_status', 'owner_identity'],
),
define(
'dev_device_binding',
'/device/dev_device_binding',
'writable',
'list',
[
'smart_cylinder_valve_identity!',
'user_account_identity!',
'effective_at',
'expired_at',
],
),
define('dev_telemetry', '/device/dev_telemetry', 'readonly', 'list', [
'smart_cylinder_valve_identity',
'reported_at',
'payload',
]),
define('saf_rule', '/safety/saf_rule', 'writable', 'list', [
'rule_code!',
'version_no',
'threshold',
'action',
'gray_scope',
]),
define(
'saf_event',
'/safety/saf_event',
'writable',
'list',
[
'event_code!',
'level!',
'title!',
'smart_cylinder_valve_identity!',
'sla_at',
],
[
action('saf_event_disposal', '/safety/saf_event/:identity/disposals', [
'action!',
'reason!',
]),
],
),
define('saf_inspection', '/safety/saf_inspection', 'writable', 'list', [
'user_account_identity!',
'staff_account_identity!',
'result!',
'evidence_uri',
]),
define(
'saf_event_disposal',
'/safety/saf_event/:identity/disposals',
'append_only',
'list',
['action!', 'reason!'],
),
define('ec_category', '/ec/ec_category', 'writable', 'tree', [
'parent_identity',
'name!',
'sort_no',
]),
define('ec_product', '/ec/ec_product', 'writable', 'list', [
'ec_category_identity!',
'product_code!',
'name!',
'price_amount!',
'stock_quantity',
]),
define(
'ec_product_attribute',
'/ec/ec_product_attribute',
'writable',
'list',
['ec_product_identity!', 'name!', 'value!', 'sort_no'],
),
define('ec_product_image', '/ec/ec_product_image', 'writable', 'list', [
'ec_product_identity!',
'image_uri!',
'sort_no',
'is_cover',
]),
define('ec_cart', '/ec/ec_cart', 'writable', 'list', [
'user_account_identity!',
'ec_product_identity!',
'quantity!',
'selected',
]),
define('ec_order', '/ec/ec_order', 'writable', 'list', [
'user_account_identity!',
'gas_basic_identity',
'delivery_basic_identity',
'order_no!',
'total_amount!',
]),
define('ec_order_item', '/ec/ec_order_item', 'writable', 'list', [
'ec_order_identity!',
'ec_product_identity!',
'product_snapshot!',
'quantity!',
'sale_amount!',
]),
define('ec_review', '/ec/ec_review', 'writable', 'list', [
'ec_order_identity!',
'ec_product_identity!',
'user_account_identity!',
'score!',
'content!',
]),
define('fin_payment', '/finance/fin_payment', 'writable', 'list', [
'ec_order_identity!',
'channel!',
'amount!',
'paid_at',
]),
define('fin_settlement', '/finance/fin_settlement', 'writable', 'list', [
'settlement_no!',
'subject_type!',
'subject_identity!',
'period_start!',
'period_end!',
]),
define(
'fin_reconciliation',
'/finance/fin_reconciliation',
'writable',
'list',
['channel!', 'bill_date!', 'difference_amount!'],
),
define('cnt_content', '/content/cnt_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!',
'category!',
'priority!',
]),
define('platfrom_account', '/platform/platfrom_account', 'writable', 'list', [
'username!',
'password!',
'display_name',
'avatar',
'platform_role_code',
'phone',
]),
define('platform_role', '/platform/platform_role', 'writable', 'list', [
'role_code!',
'name!',
'data_scope!',
]),
define('platform_menu', '/platform/platform_menu', 'writable', 'tree', [
'parent_identity',
'menu_code!',
'name!',
'icon',
'path',
'sort_no',
]),
define('wallet', '/wallet/wallet', 'readonly', 'list', [
'owner_identity',
'balance_amount',
'frozen_amount',
'status',
]),
define('wallet_ledger', '/wallet/wallet_ledger', 'readonly', 'list', [
'wallet_identity',
'amount',
'balance_after',
]),
define('wallet_recharge', '/wallet/wallet_recharge', 'readonly', 'list', [
'wallet_identity',
'amount',
'status',
]),
define('wallet_withdrawal', '/wallet/wallet_withdrawal', 'readonly', 'list', [
'wallet_identity',
'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('aud_operation_log', '/audit/aud_operation_log', 'readonly', 'list', [
'operator_identity',
'action',
'object_identity',
'created_at',
]),
define('aud_export_log', '/audit/aud_export_log', 'readonly', 'list', [
'applicant_identity',
'purpose',
'field_scope',
'approved_at',
'file_uri',
]),
define(
'aud_approval',
'/audit/aud_approval',
'readonly',
'list',
[
'business_type',
'business_identity',
'applicant_identity',
'opinion',
'handler_identity',
'status',
'handled_at',
],
[
action('同意', '/audit/aud_approval/:identity/approve', ['opinion'], {
status: 'approved',
}),
action('驳回', '/audit/aud_approval/:identity/approve', ['opinion!'], {
status: 'rejected',
}),
],
),
];
export const resourceByName = Object.fromEntries(resources.map((definition) => [definition.resource, definition])) as Record<string, ResourceUiDefinition>;
export const resourceByName = Object.fromEntries(
resources.map((definition) => [definition.resource, definition]),
) as Record<string, ResourceUiDefinition>;
export function getResource(resourcePath: string): ResourceUiDefinition {
const definition = resourceByName[resourcePath];
if (!definition) throw new Error('Unknown resource: ' + resourcePath);

View File

@@ -1,37 +1,256 @@
<template>
<a-card :title="definition.title" :bordered="false">
<template #extra><a-space><a-button @click="load">刷新</a-button><a-button v-if="canCreate" type="primary" @click="openCreate">新建</a-button></a-space></template>
<a-form :model="filters" layout="inline" class="filters" @submit.prevent="load"><a-form-item label="关键字"><a-input v-model="filters.keyword" allow-clear placeholder="服务端筛选" /></a-form-item><a-button type="primary" html-type="submit">查询</a-button></a-form>
<a-table :data="list" :loading="loading" :pagination="false" row-key="identity"><template #columns><a-table-column title="业务标识" data-index="identity" :width="220" ellipsis tooltip /><a-table-column v-for="field in displayFields" :key="field.key" :title="field.label" :data-index="field.key" ellipsis tooltip /><a-table-column title="操作" :width="190" fixed="right"><template #cell="{ record }"><a-space><a-button size="mini" @click="openDetail(record)">详情</a-button><a-button v-if="canEdit" size="mini" @click="openEdit(record)">编辑</a-button><a-button v-if="canArchive" size="mini" status="danger" @click="confirmArchive(record)">归档</a-button></a-space></template></a-table-column></template></a-table>
<template #extra>
<a-space>
<a-button @click="load">刷新</a-button>
<a-button v-if="canCreate" type="primary" @click="openCreate">新建</a-button>
</a-space>
</template>
<a-form :model="filters" layout="inline" class="filters" @submit.prevent="load">
<a-form-item label="关键字">
<a-input v-model="filters.keyword" allow-clear placeholder="服务端筛选" />
</a-form-item>
<a-button type="primary" html-type="submit">查询</a-button>
</a-form>
<a-table :data="list" :loading="loading" :pagination="false" row-key="identity">
<template #columns>
<a-table-column title="业务标识" data-index="identity" :width="220" ellipsis tooltip />
<a-table-column v-for="field in displayFields" :key="field.key" :title="field.label" :data-index="field.key" ellipsis tooltip />
<a-table-column title="操作" :width="190" fixed="right">
<template #cell="{ record }">
<a-space>
<a-button size="mini" @click="openDetail(record)">详情</a-button>
<a-button v-if="canEdit" size="mini" @click="openEdit(record)">编辑</a-button>
<a-button v-if="canArchive" size="mini" status="danger" @click="confirmArchive(record)">归档</a-button>
</a-space>
</template>
</a-table-column>
</template>
</a-table>
<div class="pagination"><a-pagination :total="total" :current="page" :page-size="pageSize" show-total @change="changePage" /></div>
</a-card>
<a-drawer :visible="formVisible" :title="editingIdentity ? `编辑${definition.title}` : `新建${definition.title}`" :width="480" @cancel="formVisible = false" @ok="save"><a-form :model="form" layout="vertical"><a-form-item v-for="field in definition.fields" :key="field.key" :label="field.label" :required="field.required"><a-input v-model="form[field.key]" :placeholder="`请输入${field.label}`" /></a-form-item></a-form></a-drawer>
<a-drawer :visible="detailVisible" title="详情" :width="540" @cancel="detailVisible = false"><a-descriptions :column="1" bordered><a-descriptions-item v-for="[key, value] in detailEntries" :key="key" :label="key">{{ value ?? '-' }}</a-descriptions-item></a-descriptions><a-space class="detail-actions"><a-button v-for="action in definition.detailActions" :key="action.name" type="primary" @click="openDetailAction(action)">{{ action.name }}</a-button></a-space></a-drawer>
<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-input v-model="actionForm[field.key]" /></a-form-item></a-form></a-modal>
<a-drawer :visible="formVisible" :title="editingIdentity ? `编辑${definition.title}` : `新建${definition.title}`" :width="480" @cancel="formVisible = false" @ok="save">
<a-form :model="form" layout="vertical">
<a-form-item v-for="field in definition.fields" :key="field.key" :label="field.label" :required="field.required">
<a-switch v-if="field.type === 'boolean'" v-model="form[field.key]" />
<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-input-password v-else-if="field.type === 'password'" v-model="form[field.key]" />
<a-input v-else v-model="form[field.key]" :placeholder="`请输入${field.label}`" />
</a-form-item>
</a-form>
</a-drawer>
<a-drawer :visible="detailVisible" title="详情" :width="540" @cancel="detailVisible = false">
<a-descriptions :column="1" bordered>
<a-descriptions-item v-for="[key, value] in detailEntries" :key="key" :label="key">{{ value ?? '-' }}</a-descriptions-item>
</a-descriptions>
<a-space class="detail-actions">
<a-button v-for="action in definition.detailActions" :key="action.name" type="primary" @click="openDetailAction(action)">{{ action.name }}</a-button>
</a-space>
</a-drawer>
<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-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-input v-else v-model="actionForm[field.key]" />
</a-form-item>
</a-form>
</a-modal>
</template>
<script setup lang="ts">
import { Message, Modal } from '@arco-design/web-vue';
import { computed, onMounted, reactive, ref } from 'vue';
import { resourceApi } from '@/api/resource';
import { buildResourcePayload, isMissingField } from '@/api/resource-form';
import type { DetailAction, ResourceUiDefinition } from '@/api/resources';
type Row = Record<string, unknown>;
const props = defineProps<{ definition: ResourceUiDefinition }>();
const loading = ref(false); const page = ref(1); const pageSize = 20; const total = ref(0); const list = ref<Row[]>([]); const filters = reactive({ keyword: '' });
const formVisible = ref(false); const detailVisible = ref(false); const editingIdentity = ref(''); const form = reactive<Record<string, string>>({}); const detail = ref<Row>({});
const actionVisible = ref(false); const activeAction = ref<DetailAction>(); const actionForm = reactive<Record<string, string>>({});
const canCreate = computed(() => props.definition.mode !== 'readonly'); const canEdit = computed(() => props.definition.mode === 'writable'); const canArchive = computed(() => props.definition.mode === 'writable');
const displayFields = computed(() => props.definition.fields.filter((field) => field.key !== 'status'));
const detailEntries = computed(() => Object.entries(detail.value).filter(([key]) => key !== 'id' && !key.endsWith('_id')));
function resetForm(data?: Row) { props.definition.fields.forEach((field) => { form[field.key] = data?.[field.key] == null ? '' : String(data[field.key]); }); }
async function load() { loading.value = true; try { const result = await resourceApi.list<Row>(props.definition.resource, page.value, pageSize, filters.keyword ? { keyword: filters.keyword } : {}); list.value = result.list; total.value = result.total; } catch (error) { Message.error((error as Error).message); } finally { loading.value = false; } }
function openCreate() { editingIdentity.value = ''; resetForm(); formVisible.value = true; }
function openEdit(row: Row) { editingIdentity.value = String(row.identity ?? ''); resetForm(row); formVisible.value = true; }
async function openDetail(row: Row) { try { detail.value = await resourceApi.detail<Row>(props.definition.resource, String(row.identity)); detailVisible.value = true; } catch (error) { Message.error((error as Error).message); } }
function openDetailAction(action: DetailAction) { activeAction.value = action; action.fields.forEach((field) => { actionForm[field.key] = ''; }); actionVisible.value = true; }
async function submitDetailAction() { const action = activeAction.value; if (!action) return; if (action.fields.some((field) => field.required && !actionForm[field.key])) { Message.warning('请填写必填字段'); return; } try { await resourceApi.create(action.resource.replace(':identity', String(detail.value.identity)), actionForm); Message.success('操作成功'); actionVisible.value = false; await openDetail(detail.value); } catch (error) { Message.error((error as Error).message); } }
async function save() { if (props.definition.fields.some((field) => field.required && !form[field.key])) { Message.warning('请填写必填字段'); return; } try { if (editingIdentity.value) await resourceApi.update(props.definition.resource, editingIdentity.value, form); else await resourceApi.create(props.definition.resource, form); Message.success('保存成功'); formVisible.value = false; await load(); } catch (error) { Message.error((error as Error).message); } }
function confirmArchive(row: Row) { Modal.warning({ title: '确认归档', content: '归档后该记录将不再参与日常业务。', onOk: async () => { try { await resourceApi.archive(props.definition.resource, String(row.identity)); Message.success('已归档'); await load(); } catch (error) { Message.error((error as Error).message); } } }); }
async function changePage(next: number) { page.value = next; await load(); }
const loading = ref(false);
const page = ref(1);
const pageSize = 20;
const total = ref(0);
const list = ref<Row[]>([]);
const filters = reactive({ keyword: '' });
const formVisible = ref(false);
const detailVisible = ref(false);
const editingIdentity = ref('');
const form = reactive<Record<string, any>>({});
const detail = ref<Row>({});
const actionVisible = ref(false);
const activeAction = ref<DetailAction>();
const actionForm = reactive<Record<string, any>>({});
const canCreate = computed(() => props.definition.mode !== 'readonly');
const canEdit = computed(() => props.definition.mode === 'writable');
const canArchive = computed(() => props.definition.mode === 'writable');
const displayFields = computed(() =>
props.definition.fields.filter((field) => field.key !== 'status'),
);
const detailEntries = computed(() =>
Object.entries(detail.value).filter(
([key]) => key !== 'id' && !key.endsWith('_id'),
),
);
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;
}
}
async function load() {
loading.value = true;
try {
const result = await resourceApi.list<Row>(
props.definition.resource,
page.value,
pageSize,
filters.keyword ? { keyword: filters.keyword } : {},
);
list.value = result.list;
total.value = result.total;
} catch (error) {
Message.error((error as Error).message);
} finally {
loading.value = false;
}
}
function openCreate() {
editingIdentity.value = '';
resetForm();
formVisible.value = true;
}
function openEdit(row: Row) {
editingIdentity.value = String(row.identity ?? '');
resetForm(row);
formVisible.value = true;
}
async function openDetail(row: Row) {
try {
detail.value = await resourceApi.detail<Row>(
props.definition.resource,
String(row.identity),
);
detailVisible.value = true;
} catch (error) {
Message.error((error as Error).message);
}
}
function openDetailAction(action: DetailAction) {
activeAction.value = action;
for (const field of action.fields) actionForm[field.key] = undefined;
actionVisible.value = true;
}
async function submitDetailAction() {
const action = activeAction.value;
if (!action) return;
if (
action.fields.some(
(field) => field.required && isMissingField(actionForm[field.key]),
)
) {
Message.warning('请填写必填字段');
return;
}
try {
const payload = {
...action.payload,
...buildResourcePayload(action.fields, actionForm),
};
await resourceApi.create(
action.resource.replace(':identity', String(detail.value.identity)),
payload,
);
Message.success('操作成功');
actionVisible.value = false;
await openDetail(detail.value);
} catch (error) {
Message.error((error as Error).message);
}
}
async function save() {
if (
props.definition.fields.some(
(field) => field.required && isMissingField(form[field.key]),
)
) {
Message.warning('请填写必填字段');
return;
}
try {
const payload = buildResourcePayload(props.definition.fields, form);
if (editingIdentity.value)
await resourceApi.update(
props.definition.resource,
editingIdentity.value,
payload,
);
else await resourceApi.create(props.definition.resource, payload);
Message.success('保存成功');
formVisible.value = false;
await load();
} catch (error) {
Message.error((error as Error).message);
}
}
function confirmArchive(row: Row) {
Modal.warning({
title: '确认归档',
content: '归档后该记录将不再参与日常业务。',
onOk: async () => {
try {
await resourceApi.archive(
props.definition.resource,
String(row.identity),
);
Message.success('已归档');
await load();
} catch (error) {
Message.error((error as Error).message);
}
},
});
}
async function changePage(next: number) {
page.value = next;
await load();
}
onMounted(load);
</script>
<style scoped lang="less">.filters { margin-bottom: 16px; } .pagination { display: flex; justify-content: flex-end; margin-top: 16px; }</style>
<style scoped lang="less">
.filters {
margin-bottom: 16px;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.detail-actions {
margin-top: 16px;
}
</style>

View File

@@ -1,25 +1,147 @@
<template>
<a-card :title="definition.title" :bordered="false">
<template #extra><a-button @click="load">刷新</a-button></template>
<a-form :model="filters" layout="inline" class="filters" @submit.prevent="load"><a-form-item label="关键字"><a-input v-model="filters.keyword" allow-clear placeholder="服务端筛选" /></a-form-item><a-button type="primary" html-type="submit">查询</a-button></a-form>
<a-table :data="list" :loading="loading" :pagination="false" row-key="identity"><template #columns><a-table-column title="业务标识" data-index="identity" :width="220" ellipsis tooltip /><a-table-column v-for="field in displayFields" :key="field.key" :title="field.label" :data-index="field.key" ellipsis tooltip /><a-table-column title="操作" :width="90"><template #cell="{ record }"><a-button size="mini" @click="openDetail(record)">详情</a-button></template></a-table-column></template></a-table>
<a-form :model="filters" layout="inline" class="filters" @submit.prevent="load">
<a-form-item label="关键字"><a-input v-model="filters.keyword" allow-clear placeholder="服务端筛选" /></a-form-item>
<a-button type="primary" html-type="submit">查询</a-button>
</a-form>
<a-table :data="list" :loading="loading" :pagination="false" row-key="identity">
<template #columns>
<a-table-column title="业务标识" data-index="identity" :width="220" ellipsis tooltip />
<a-table-column v-for="field in displayFields" :key="field.key" :title="field.label" :data-index="field.key" ellipsis tooltip />
<a-table-column title="操作" :width="90"><template #cell="{ record }"><a-button size="mini" @click="openDetail(record)">详情</a-button></template></a-table-column>
</template>
</a-table>
<div class="pagination"><a-pagination :total="total" :current="page" :page-size="pageSize" show-total @change="changePage" /></div>
</a-card>
<a-drawer :visible="detailVisible" title="详情" :width="540" @cancel="detailVisible = false"><a-descriptions :column="1" bordered><a-descriptions-item v-for="[key, value] in detailEntries" :key="key" :label="key">{{ value ?? '-' }}</a-descriptions-item></a-descriptions></a-drawer>
<a-drawer :visible="detailVisible" title="详情" :width="540" @cancel="detailVisible = false">
<a-descriptions :column="1" bordered><a-descriptions-item v-for="[key, value] in detailEntries" :key="key" :label="key">{{ value ?? '-' }}</a-descriptions-item></a-descriptions>
<a-space v-if="definition.detailActions?.length" class="detail-actions">
<a-button v-for="action in definition.detailActions" :key="action.name" :status="action.payload?.status === 'rejected' ? 'danger' : 'normal'" type="primary" @click="openDetailAction(action)">{{ action.name }}</a-button>
</a-space>
</a-drawer>
<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-input v-else v-model="actionForm[field.key]" />
</a-form-item>
</a-form>
</a-modal>
</template>
<script setup lang="ts">
import { Message } from '@arco-design/web-vue';
import { computed, onMounted, reactive, ref } from 'vue';
import { resourceApi } from '@/api/resource';
import type { ResourceUiDefinition } from '@/api/resources';
import { buildResourcePayload, isMissingField } from '@/api/resource-form';
import type { DetailAction, ResourceUiDefinition } from '@/api/resources';
type Row = Record<string, unknown>;
const props = defineProps<{ definition: ResourceUiDefinition }>();
const loading = ref(false); const page = ref(1); const pageSize = 20; const total = ref(0); const list = ref<Row[]>([]); const filters = reactive({ keyword: '' }); const detail = ref<Row>({}); const detailVisible = ref(false);
const displayFields = computed(() => props.definition.fields.filter((field) => field.key !== 'status'));
const detailEntries = computed(() => Object.entries(detail.value).filter(([key]) => key !== 'id' && !key.endsWith('_id')));
async function load() { loading.value = true; try { const result = await resourceApi.list<Row>(props.definition.resource, page.value, pageSize, filters.keyword ? { keyword: filters.keyword } : {}); list.value = result.list; total.value = result.total; } catch (error) { Message.error((error as Error).message); } finally { loading.value = false; } }
async function openDetail(row: Row) { try { detail.value = await resourceApi.detail<Row>(props.definition.resource, String(row.identity)); detailVisible.value = true; } catch (error) { Message.error((error as Error).message); } }
async function changePage(next: number) { page.value = next; await load(); }
const loading = ref(false);
const page = ref(1);
const pageSize = 20;
const total = ref(0);
const list = ref<Row[]>([]);
const filters = reactive({ keyword: '' });
const detail = ref<Row>({});
const detailVisible = ref(false);
const actionVisible = ref(false);
const activeAction = ref<DetailAction>();
const actionForm = reactive<Record<string, any>>({});
const displayFields = computed(() =>
props.definition.fields.filter((field) => field.key !== 'status'),
);
const detailEntries = computed(() =>
Object.entries(detail.value).filter(
([key]) => key !== 'id' && !key.endsWith('_id'),
),
);
async function load() {
loading.value = true;
try {
const result = await resourceApi.list<Row>(
props.definition.resource,
page.value,
pageSize,
filters.keyword ? { keyword: filters.keyword } : {},
);
list.value = result.list;
total.value = result.total;
} catch (error) {
Message.error((error as Error).message);
} finally {
loading.value = false;
}
}
async function openDetail(row: Row) {
try {
detail.value = await resourceApi.detail<Row>(
props.definition.resource,
String(row.identity),
);
detailVisible.value = true;
} catch (error) {
Message.error((error as Error).message);
}
}
function openDetailAction(action: DetailAction) {
activeAction.value = action;
for (const field of action.fields) actionForm[field.key] = undefined;
actionVisible.value = true;
}
async function submitDetailAction() {
const action = activeAction.value;
if (!action) return;
if (
action.fields.some(
(field) => field.required && isMissingField(actionForm[field.key]),
)
) {
Message.warning('请填写必填字段');
return;
}
try {
const payload = {
...action.payload,
...buildResourcePayload(action.fields, actionForm),
};
await resourceApi.create(
action.resource.replace(':identity', String(detail.value.identity)),
payload,
);
Message.success('审批完成');
actionVisible.value = false;
await openDetail(detail.value);
await load();
} catch (error) {
Message.error((error as Error).message);
}
}
async function changePage(next: number) {
page.value = next;
await load();
}
onMounted(load);
</script>
<style scoped lang="less">.filters { margin-bottom: 16px; } .pagination { display: flex; justify-content: flex-end; margin-top: 16px; }</style>
<style scoped lang="less">
.filters {
margin-bottom: 16px;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.detail-actions {
margin-top: 16px;
}
</style>

View File

@@ -1,17 +1,138 @@
<template><a-card :title="definition.title" :bordered="false"><template #extra><a-space><a-button @click="load">刷新</a-button><a-button v-if="canWrite" type="primary" @click="openCreate">新增</a-button></a-space></template><a-tree :data="tree" :loading="loading" :field-names="{ key: 'identity', title: 'name', children: 'children' }"><template #title="node"><a-space>{{ node.title }}<a-button v-if="canWrite" size="mini" @click.stop="openEdit(node)">编辑</a-button></a-space></template></a-tree></a-card><a-drawer :visible="formVisible" :title="editingIdentity ? '编辑' + definition.title : '新增' + definition.title" :width="480" @cancel="formVisible = false" @ok="save"><a-form :model="form" layout="vertical"><a-form-item v-for="field in definition.fields" :key="field.key" :label="field.label" :required="field.required"><a-input v-model="form[field.key]" /></a-form-item></a-form></a-drawer></template>
<template>
<a-card :title="definition.title" :bordered="false">
<template #extra>
<a-space>
<a-button @click="load">刷新</a-button>
<a-button v-if="canWrite" type="primary" @click="openCreate">新增</a-button>
</a-space>
</template>
<a-tree :data="tree" :loading="loading" :field-names="{ key: 'identity', title: 'name', children: 'children' }">
<template #title="node">
<a-space>
{{ node.title }}
<a-button v-if="canWrite" size="mini" @click.stop="openEdit(node)">编辑</a-button>
<a-button v-if="canWrite" size="mini" status="danger" @click.stop="confirmArchive(node)">归档</a-button>
</a-space>
</template>
</a-tree>
</a-card>
<a-drawer :visible="formVisible" :title="editingIdentity ? `编辑${definition.title}` : `新增${definition.title}`" :width="480" @cancel="formVisible = false" @ok="save">
<a-form :model="form" layout="vertical">
<a-form-item v-for="field in definition.fields" :key="field.key" :label="field.label" :required="field.required">
<a-input-number v-if="field.type === 'number'" v-model="form[field.key]" />
<a-switch v-else-if="field.type === 'boolean'" v-model="form[field.key]" />
<a-input v-else v-model="form[field.key]" />
</a-form-item>
</a-form>
</a-drawer>
</template>
<script setup lang="ts">
import { Message } from '@arco-design/web-vue';
import { Message, Modal } from '@arco-design/web-vue';
import { computed, onMounted, reactive, ref } from 'vue';
import { resourceApi } from '@/api/resource';
import { buildResourcePayload, isMissingField } from '@/api/resource-form';
import type { ResourceUiDefinition } from '@/api/resources';
type Node = Record<string, unknown> & { identity: string; parent_identity?: string; children: Node[] };
const props = defineProps<{ definition: ResourceUiDefinition }>(); const loading = ref(false); const list = ref<Node[]>([]); const formVisible = ref(false); const editingIdentity = ref(''); const form = reactive<Record<string, string>>({});
type Node = Record<string, unknown> & {
identity: string;
parent_identity?: string;
children: Node[];
};
const props = defineProps<{ definition: ResourceUiDefinition }>();
const loading = ref(false);
const list = ref<Node[]>([]);
const formVisible = ref(false);
const editingIdentity = ref('');
const form = reactive<Record<string, any>>({});
const canWrite = computed(() => props.definition.mode === 'writable');
const tree = computed(() => { const byIdentity = new Map<string, Node>(); const roots: Node[] = []; list.value.forEach((item) => byIdentity.set(item.identity, { ...item, children: [] })); byIdentity.forEach((item) => { const parent = item.parent_identity ? byIdentity.get(item.parent_identity) : undefined; if (parent) parent.children.push(item); else roots.push(item); }); return roots; });
function reset(data?: Node) { props.definition.fields.forEach((field) => { form[field.key] = data?.[field.key] == null ? '' : String(data[field.key]); }); }
function openCreate() { editingIdentity.value = ''; reset(); formVisible.value = true; }
function openEdit(node: Node) { editingIdentity.value = node.identity; reset(node); formVisible.value = true; }
async function save() { if (props.definition.fields.some((field) => field.required && !form[field.key])) { Message.warning('请填写必填字段'); return; } try { if (editingIdentity.value) await resourceApi.update(props.definition.resource, editingIdentity.value, form); else await resourceApi.create(props.definition.resource, form); formVisible.value = false; await load(); } catch (error) { Message.error((error as Error).message); } }
async function load() { loading.value = true; try { list.value = (await resourceApi.list<Node>(props.definition.resource, 1, 500)).list; } catch (error) { Message.error((error as Error).message); } finally { loading.value = false; } }
const tree = computed(() => {
const byIdentity = new Map<string, Node>();
const roots: Node[] = [];
for (const item of list.value)
byIdentity.set(item.identity, { ...item, children: [] });
for (const item of byIdentity.values()) {
const parent = item.parent_identity
? byIdentity.get(item.parent_identity)
: undefined;
if (parent) parent.children.push(item);
else roots.push(item);
}
return roots;
});
function reset(data?: Node) {
for (const field of props.definition.fields) {
const value = data?.[field.key];
form[field.key] = value == null ? undefined : value;
}
}
function openCreate() {
editingIdentity.value = '';
reset();
formVisible.value = true;
}
function openEdit(node: Node) {
editingIdentity.value = node.identity;
reset(node);
formVisible.value = true;
}
async function save() {
if (
props.definition.fields.some(
(field) => field.required && isMissingField(form[field.key]),
)
) {
Message.warning('请填写必填字段');
return;
}
try {
const payload = buildResourcePayload(props.definition.fields, form);
if (editingIdentity.value)
await resourceApi.update(
props.definition.resource,
editingIdentity.value,
payload,
);
else await resourceApi.create(props.definition.resource, payload);
formVisible.value = false;
await load();
} catch (error) {
Message.error((error as Error).message);
}
}
function confirmArchive(node: Node) {
Modal.warning({
title: '确认归档',
content: `归档“${String(node.name ?? node.identity)}”后,其历史数据仍会保留。`,
onOk: async () => {
try {
await resourceApi.archive(props.definition.resource, node.identity);
Message.success('已归档');
await load();
} catch (error) {
Message.error((error as Error).message);
}
},
});
}
async function load() {
loading.value = true;
try {
list.value = (
await resourceApi.list<Node>(props.definition.resource, 1, 500)
).list;
} catch (error) {
Message.error((error as Error).message);
} finally {
loading.value = false;
}
}
onMounted(load);
</script>