diff --git a/backend/api/internal/logic/platform/platform.go b/backend/api/internal/logic/platform/platform.go index b90ca20..4d80900 100644 --- a/backend/api/internal/logic/platform/platform.go +++ b/backend/api/internal/logic/platform/platform.go @@ -58,10 +58,20 @@ 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, +var keywordSafeColumns = map[string]bool{ + "code": true, "name": true, "username": true, "display_name": true, + "role_code": true, "delivery_code": true, "work_status": true, + "credential_type": true, "device_no": true, "model": true, + "online_status": true, "rule_code": true, "action": true, + "event_code": true, "title": true, "result": true, + "product_code": true, "value": true, "order_no": true, + "channel": true, "settlement_no": true, "subject_type": true, + "content_type": true, "publish_status": true, "template_code": true, + "ticket_no": true, "category": true, "priority": true, + "platform_role_code": true, "data_scope": true, "menu_code": true, + "path": true, "report_code": true, "report_type": true, + "stat_period": true, "dimension": true, "metric_code": true, + "scope_type": true, "business_type": true, "resource_type": true, } func applyKeywordFilter(ctx *gin.Context, query *gorm.DB, model any) *gorm.DB { @@ -90,17 +100,26 @@ func keywordColumns(model any) []string { columns := make([]string, 0) for index := 0; index < modelType.NumField(); index++ { field := modelType.Field(index) - if field.Anonymous || field.Type.Kind() != reflect.String { + if field.Anonymous || field.Type.Kind() != reflect.String || strings.Contains(field.Tag.Get("gorm"), "type:jsonb") { continue } column := gormColumn(field.Tag.Get("gorm")) - if column != "" && !keywordExcludedColumns[column] { + if keywordSafeColumns[column] && !isSensitiveKeywordColumn(model, column) { columns = append(columns, column) } } return columns } +func isSensitiveKeywordColumn(model any, column string) bool { + switch model.(type) { + case *models.UserAccount, *models.StaffAccount: + return column == "name" + default: + return false + } +} + func gormColumn(tag string) string { for _, part := range strings.Split(tag, ";") { if strings.HasPrefix(part, "column:") { diff --git a/backend/api/internal/logic/platform/resource_test.go b/backend/api/internal/logic/platform/resource_test.go index 08b836d..f48eb24 100644 --- a/backend/api/internal/logic/platform/resource_test.go +++ b/backend/api/internal/logic/platform/resource_test.go @@ -6,6 +6,7 @@ import ( "errors" "net/http" "net/http/httptest" + "reflect" "regexp" "strings" "testing" @@ -210,7 +211,7 @@ func TestPrepareResourceValuesResolvesRequiredIdentityRelationsAndRejectsInvalid WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uint64(2))) ctx, _ := updateContext(http.MethodPost, "/ec/ec_order_item", "", []byte(`{"ec_order_identity":"order-a","ec_product_identity":"product-a","quantity":2}`)) - values, err := prepareResourceValues(ctx, []string{"quantity"}, []ResourceRelation{ + values, err := prepareResourceValues(ctx, &models.EcOrderItem{}, []string{"quantity"}, []ResourceRelation{ {Input: "ec_order_identity", Column: "ec_order_id", Model: &models.EcOrder{}, Required: true}, {Input: "ec_product_identity", Column: "ec_product_id", Model: &models.EcProduct{}, Required: true}, }) @@ -224,12 +225,55 @@ func TestPrepareResourceValuesResolvesRequiredIdentityRelationsAndRejectsInvalid for _, body := range []string{`{}`, `{"ec_order_id":1}`, `{"quantity":2}`} { ctx, _ := updateContext(http.MethodPost, "/ec/ec_order_item", "", []byte(body)) - if _, err := prepareResourceValues(ctx, []string{"quantity"}, []ResourceRelation{{Input: "ec_order_identity", Column: "ec_order_id", Model: &models.EcOrder{}, Required: true}}); err == nil { + if _, err := prepareResourceValues(ctx, &models.EcOrderItem{}, []string{"quantity"}, []ResourceRelation{{Input: "ec_order_identity", Column: "ec_order_id", Model: &models.EcOrder{}, Required: true}}); err == nil { t.Fatalf("payload %s was accepted", body) } } } +func TestPrepareResourceValuesNormalizesStringJSONBFields(t *testing.T) { + t.Run("safety rule", func(t *testing.T) { + ctx, _ := updateContext(http.MethodPost, "/safety/saf_rule", "", []byte(`{"rule_code":"pressure-limit","threshold":{"max":10},"action":"close-valve","gray_scope":["north"]}`)) + values, err := prepareResourceValues(ctx, &models.SafRule{}, []string{"rule_code", "threshold", "action", "gray_scope"}, nil) + if err != nil { + t.Fatal(err) + } + if values["threshold"] != `{"max":10}` || values["gray_scope"] != `["north"]` { + t.Fatalf("jsonb values were not normalized to strings: %#v", values) + } + }) + + t.Run("order item", func(t *testing.T) { + _, mock := setupPlatformRoleDatabase(t) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id" FROM "ec_order" WHERE identity = $1 ORDER BY "ec_order"."id" LIMIT $2`)). + WithArgs("order-a", 1). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uint64(1))) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id" FROM "ec_product" WHERE identity = $1 ORDER BY "ec_product"."id" LIMIT $2`)). + WithArgs("product-a", 1). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uint64(2))) + + ctx, _ := updateContext(http.MethodPost, "/ec/ec_order_item", "", []byte(`{"ec_order_identity":"order-a","ec_product_identity":"product-a","product_snapshot":{"name":"液化气"},"quantity":2,"sale_amount":500}`)) + values, err := prepareResourceValues(ctx, &models.EcOrderItem{}, []string{"product_snapshot", "quantity", "sale_amount"}, []ResourceRelation{ + {Input: "ec_order_identity", Column: "ec_order_id", Model: &models.EcOrder{}, Required: true}, + {Input: "ec_product_identity", Column: "ec_product_id", Model: &models.EcProduct{}, Required: true}, + }) + if err != nil { + t.Fatal(err) + } + if values["product_snapshot"] != `{"name":"液化气"}` { + t.Fatalf("product snapshot was not normalized to a string: %#v", values) + } + assertMockExpectations(t, mock) + }) + + t.Run("invalid json string", func(t *testing.T) { + ctx, _ := updateContext(http.MethodPost, "/safety/saf_rule", "", []byte(`{"rule_code":"pressure-limit","threshold":"{invalid}","action":"close-valve"}`)) + if _, err := prepareResourceValues(ctx, &models.SafRule{}, []string{"rule_code", "threshold", "action"}, nil); err == nil { + t.Fatal("invalid JSON string was accepted for a string/jsonb field") + } + }) +} + func TestCreateGasAccountResolvesGasBasicIdentityBeforePersisting(t *testing.T) { _, mock := setupPlatformRoleDatabase(t) mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id" FROM "gas_basic" WHERE identity = $1 ORDER BY "gas_basic"."id" LIMIT $2`)). @@ -274,6 +318,26 @@ func TestListGasAccountAppliesKeywordToCountAndRows(t *testing.T) { assertMockExpectations(t, mock) } +func TestKeywordColumnsUseSafeTextAllowlist(t *testing.T) { + tests := []struct { + name string + model any + want []string + }{ + {"safety rule excludes jsonb", &models.SafRule{}, []string{"rule_code", "action"}}, + {"gas basic excludes sensitive fields", &models.GasBasic{}, []string{"code", "name"}}, + {"user address has no searchable safe text", &models.UserAddress{}, []string{}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := keywordColumns(test.model) + if !reflect.DeepEqual(got, test.want) { + t.Fatalf("keywordColumns(%T) = %#v, want %#v", test.model, got, test.want) + } + }) + } +} + func TestListPlatformMenuReturnsParentIdentityWithoutParentID(t *testing.T) { _, mock := setupPlatformRoleDatabase(t) mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "platform_menu" ORDER BY sort_no asc, id asc`)). @@ -304,24 +368,30 @@ func TestResourceResponseDoesNotExposeAutoIncrementRelationIDs(t *testing.T) { } } -func TestCreatedResourceResponseMasksSensitiveFieldsAndKeepsIdentity(t *testing.T) { +func TestCreatedResourceResponseUsesSafeAllowlist(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", + "identity": "address-a", + "user_account_identity": "user-a", + "status": "draft", + "version": float64(1), + "phone": "13800138000", + "real_name": "张三", + "credential_no": "CERT-123456", + "address": "敏感详细地址", + "principal": "负责人", + "credit_code": "CREDIT-123", + "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) + if !strings.Contains(body, `"identity":"address-a"`) || !strings.Contains(body, `"user_account_identity":"user-a"`) || !strings.Contains(body, `"status":"draft"`) { + t.Fatalf("created response omitted safe public fields: %s", body) } - for _, forbidden := range []string{`"phone":`, `"real_name":`, `"credential_no":`, `"longitude":`, `"latitude":`, "张三", "CERT-123456", "120.123456", "30.456789"} { + for _, forbidden := range []string{`"phone"`, `"phone_masked"`, `"real_name"`, `"credential_no"`, `"address"`, `"principal"`, `"credit_code"`, `"longitude"`, `"latitude"`, "敏感详细地址", "负责人", "CREDIT-123", "120.123456", "30.456789"} { if strings.Contains(body, forbidden) { t.Fatalf("created response exposed sensitive field %s: %s", forbidden, body) } diff --git a/backend/api/internal/logic/platform/task4_resources.go b/backend/api/internal/logic/platform/task4_resources.go index 09a86d4..a5468f5 100644 --- a/backend/api/internal/logic/platform/task4_resources.go +++ b/backend/api/internal/logic/platform/task4_resources.go @@ -126,7 +126,7 @@ func getResource(ctx *gin.Context, model any) { } func createResource(ctx *gin.Context, model any, allowedFields []string, relations []ResourceRelation) { - values, err := prepareResourceValues(ctx, allowedFields, relations) + values, err := prepareResourceValues(ctx, model, allowedFields, relations) if err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return @@ -161,41 +161,30 @@ func respondCreatedResource(ctx *gin.Context, value any) { 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") + safe := make(map[string]any) for key, item := range data { - data[key] = maskCreatedSensitiveFields(item) + if isCreatedResponseField(key) { + safe[key] = maskCreatedSensitiveFields(item) + } } + return safe case []any: - for index := range data { - data[index] = maskCreatedSensitiveFields(data[index]) + safe := make([]any, len(data)) + for index, item := range data { + safe[index] = maskCreatedSensitiveFields(item) } + return safe } return value } -func maskSecret(value string) string { - characters := []rune(value) - if len(characters) <= 1 { - return "*" +func isCreatedResponseField(key string) bool { + switch key { + case "identity", "status", "version", "created_at", "updated_at": + return true + default: + return strings.HasSuffix(key, "_identity") } - 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 { @@ -237,7 +226,7 @@ func updateResource(ctx *gin.Context, model any, allowedFields []string, relatio return } values, err := resolveResourceRelations(input, allowedFields, relations, false) - if err != nil { + if err != nil || normalizeStringJSONBFields(model, values) != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } @@ -248,18 +237,48 @@ func updateResource(ctx *gin.Context, model any, allowedFields []string, relatio updateAllowedByIdentity(ctx, model, values, append(allowedFields, relationColumns(relations)...)) } -func prepareResourceValues(ctx *gin.Context, allowedFields []string, relations []ResourceRelation) (map[string]any, error) { +func prepareResourceValues(ctx *gin.Context, model any, allowedFields []string, relations []ResourceRelation) (map[string]any, error) { var input map[string]any if err := ctx.ShouldBindJSON(&input); err != nil || len(input) == 0 { return nil, errors.New("invalid resource payload") } values, err := resolveResourceRelations(input, allowedFields, relations, true) - if err != nil || len(values) == 0 { + if err != nil || normalizeStringJSONBFields(model, values) != nil || len(values) == 0 { return nil, errors.New("invalid resource payload") } return values, nil } +func normalizeStringJSONBFields(model any, values map[string]any) error { + modelType := reflect.TypeOf(model) + for modelType.Kind() == reflect.Pointer { + modelType = modelType.Elem() + } + for index := 0; index < modelType.NumField(); index++ { + field := modelType.Field(index) + if field.Type.Kind() != reflect.String || !strings.Contains(field.Tag.Get("gorm"), "type:jsonb") { + continue + } + column := gormColumn(field.Tag.Get("gorm")) + value, exists := values[column] + if !exists { + continue + } + if text, ok := value.(string); ok { + if !json.Valid([]byte(text)) { + return errors.New("invalid jsonb string") + } + continue + } + encoded, err := json.Marshal(value) + if err != nil || !json.Valid(encoded) { + return errors.New("invalid jsonb value") + } + values[column] = string(encoded) + } + return nil +} + func resolveResourceRelations(input map[string]any, allowedFields []string, relations []ResourceRelation, requireRelations bool) (map[string]any, error) { values := filterFields(input, allowedFields) for _, relation := range relations { diff --git a/docs/平台总后台审计报告-2026-07-27.md b/docs/平台总后台审计报告-2026-07-27.md index 496ce1a..9435a8e 100644 --- a/docs/平台总后台审计报告-2026-07-27.md +++ b/docs/平台总后台审计报告-2026-07-27.md @@ -4,7 +4,7 @@ 本次审计覆盖 46 个平台后台资源:33 个可写资源、12 个只读资源和 1 个仅追加的安全事件处置资源。后端资源契约现同时声明领域、资源名、HTTP 路径、页面类型和读写模式,并由运行时注册路由生成清单。前端审计据此逐项校验后端路由、前端资源声明、实际加载的页面组件和带菜单元数据的路由;任一层缺失均以 `领域/资源: missing ` 失败。 -终审追加识别的 8 项 Important 问题已全部处置并纳入回归验证:创建响应安全投影、轨迹点精确位置授权、关键字筛选一致性、表单字段类型、空可选关系、审批动作、树节点归档,以及 Biome/模型中文注释完整性。 +终审追加识别的 8 项 Important 问题已全部处置并纳入回归验证:创建响应安全投影、轨迹点精确位置授权、关键字筛选一致性、表单字段类型、空可选关系、审批动作、树节点归档,以及 Biome/模型中文注释完整性。终审后复核又关闭 4 项 Important:JSONB 字符串绑定、日期 RFC3339 边界、编辑密码语义,以及创建响应与关键字查询的显式安全白名单。 ## 发现与处置 @@ -15,10 +15,11 @@ | 页面存在但未被菜单路由实际加载时可能漏检 | 审计读取全部路由模块,解析页面动态导入,再确认页面通过 `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` | +| 创建接口可能回传内部关系 ID、密码散列、地址或其他未枚举字段 | 创建成功统一经过公共身份解析和显式安全白名单,只保留 `identity`、关联 `*_identity`、状态、版本及时间元数据;地址和其他业务字段默认不返回 | `TestCreateGasAccountResolvesGasBasicIdentityBeforePersisting`、`TestCreatedResourceResponseUsesSafeAllowlist` | | 轨迹点资源可写,且普通列表/详情可能泄露精确经纬度 | `delivery_track_point` 改为只读契约;仅 JWT 明确声明 `location_scope=precise` 时返回精确坐标,其他响应清空坐标 | `TestListDeliveryTrackPointsMasksCoordinatesWithoutPreciseLocationScope`、`TestGetDeliveryTrackPointReturnsCoordinatesWithPreciseLocationScope` | -| 通用关键字只影响列表或使用不安全字段,可能导致总数与结果不一致 | 根据模型字符串列生成受控条件,排除身份、状态、密码、坐标及审计载荷等敏感列,并将同一条件同时应用于 count/list | `TestListGasAccountAppliesKeywordToCountAndRows` | -| 前端表单把数字、布尔、时间和 JSON 一律按字符串提交 | 资源字段显式声明类型,由 `buildResourcePayload` 统一转换请求载荷,页面按类型选择控件 | `final-important.test.mjs` 的字段类型与载荷转换用例、`pnpm type:check` | +| 通用关键字只影响列表或使用 JSONB、身份及敏感文本字段,可能导致总数与结果不一致或扩大数据暴露面 | 仅查询显式允许的安全文本列,并按模型排除个人姓名等敏感列;同一条件同时应用于 count/list | `TestListGasAccountAppliesKeywordToCountAndRows`、`TestKeywordColumnsUseSafeTextAllowlist` | +| 前端表单缺少持久化类型边界,JSONB 字符串可能变成对象,日期不能绑定 Go `time.Time` | JSON 字段在前端校验后保留字符串,后端按模型 GORM 标签将对象/数组规范化为有效 JSON 字符串并拒绝非法文本;日期与时间统一提交 RFC3339 | `TestPrepareResourceValuesNormalizesStringJSONBFields`、`final-important.test.mjs` 的 JSON 与日期用例、`pnpm type:check` | +| 创建与编辑复用密码必填规则,编辑时可能要求或误提交密码 | 密码仅在创建模式必填并进入请求体;编辑表单隐藏密码字段,载荷边界也强制忽略密码 | `final-important.test.mjs` 的创建/编辑密码语义用例 | | 空的可选关联标识可能作为空字符串进入请求体 | 载荷边界省略空的可选 `identity` 关系,同时保留必填校验 | `final-important.test.mjs` 的空可选关系用例 | | 审批只读页缺少同意/驳回入口 | 资源定义声明详情动作,页面按审批身份提交动作及意见并刷新详情和列表 | `final-important.test.mjs` 的审批动作回归用例 | | 树页面只有新增/编辑,没有符合软删除约束的归档操作 | 增加二次确认,并调用资源归档接口写入 `status=archived`,不执行物理删除 | `final-important.test.mjs` 的树归档回归用例 | @@ -58,7 +59,7 @@ pnpm build | --- | --- | --- | | `go test ./...` | 0 | 通过 | | `go build ./cmd/main` | 0 | 通过 | -| `node --test frontend/platform_admin/scripts/final-important.test.mjs` | 0 | 通过,4 个终审回归用例;脚本按自身路径定位项目,不依赖当前工作目录 | +| `node --test frontend/platform_admin/scripts/final-important.test.mjs` | 0 | 通过,6 个终审回归用例;脚本按自身路径定位项目,不依赖当前工作目录 | | `pnpm lint` | 0 | 通过;Biome 检查 169 个文件,无错误,保留 190 个非阻断 warning 和 12 个 info | | `pnpm type:check` | 0 | 通过 | | `pnpm audit:platform` | 0 | 通过 | diff --git a/frontend/platform_admin/scripts/final-important.test.mjs b/frontend/platform_admin/scripts/final-important.test.mjs index e3d4386..d96b5f0 100644 --- a/frontend/platform_admin/scripts/final-important.test.mjs +++ b/frontend/platform_admin/scripts/final-important.test.mjs @@ -18,6 +18,16 @@ function loadResources() { return resourceModule.exports.resources; } +function loadResourceForm() { + const source = fs.readFileSync(fromProjectRoot('src/api/resource-form.ts'), '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 }); + return resourceModule.exports; +} + test('资源字段声明保留数字、布尔、时间与 JSON 类型', () => { const resources = loadResources(); const field = (resource, key) => resources.find((item) => item.name === resource).fields.find((item) => item.key === key); @@ -30,19 +40,14 @@ test('资源字段声明保留数字、布尔、时间与 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 resourceForm = loadResourceForm(); 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, { + JSON.parse(JSON.stringify(resourceForm.buildResourcePayload(fields, { gas_basic_identity: '', quantity: '2', selected: false, @@ -51,6 +56,81 @@ test('表单载荷构造器省略空的可选关系并转换字段类型', async ); }); +test('安全规则与订单明细将 JSON 字段作为有效 JSON 字符串提交', () => { + const resources = loadResources(); + const { buildResourcePayload } = loadResourceForm(); + const fields = (name) => resources.find((item) => item.name === name).fields; + + assert.deepEqual( + JSON.parse(JSON.stringify(buildResourcePayload(fields('saf_rule'), { + rule_code: 'pressure-limit', + threshold: '{"max":10}', + action: 'close-valve', + gray_scope: '["north"]', + }))), + { + rule_code: 'pressure-limit', + threshold: '{"max":10}', + action: 'close-valve', + gray_scope: '["north"]', + }, + ); + assert.deepEqual( + JSON.parse(JSON.stringify(buildResourcePayload(fields('ec_order_item'), { + ec_order_identity: 'order-a', + ec_product_identity: 'product-a', + product_snapshot: '{"name":"液化气"}', + quantity: 2, + sale_amount: 500, + }))), + { + ec_order_identity: 'order-a', + ec_product_identity: 'product-a', + product_snapshot: '{"name":"液化气"}', + quantity: 2, + sale_amount: 500, + }, + ); +}); + +test('日期按 RFC3339 提交,密码只在创建时必填并提交', () => { + const { buildResourcePayload, isResourceFieldRequired } = loadResourceForm(); + const fields = [ + { key: 'bill_date', label: '账单日期', type: 'date', required: true }, + { key: 'started_at', label: '开始时间', type: 'datetime' }, + { key: 'username', label: '用户名', type: 'text', required: true }, + { key: 'password', label: '密码', type: 'password', required: true }, + ]; + + assert.equal(isResourceFieldRequired(fields[3], 'create'), true); + assert.equal(isResourceFieldRequired(fields[3], 'edit'), false); + assert.deepEqual( + JSON.parse(JSON.stringify(buildResourcePayload(fields, { + bill_date: '2026-07-27', + started_at: '2026-07-27T10:30:00Z', + username: 'operator', + password: 'secret', + }, 'create'))), + { + bill_date: '2026-07-27T00:00:00.000Z', + started_at: '2026-07-27T10:30:00.000Z', + username: 'operator', + password: 'secret', + }, + ); + assert.deepEqual( + JSON.parse(JSON.stringify(buildResourcePayload(fields, { + bill_date: '2026-07-27', + username: 'operator', + password: 'replacement-must-not-be-sent', + }, 'edit'))), + { + bill_date: '2026-07-27T00:00:00.000Z', + username: 'operator', + }, + ); +}); + test('审批只读页提供同意和驳回操作', () => { const source = fs.readFileSync(fromProjectRoot('src/views/shared/ReadOnlyListPage.vue'), 'utf8'); const resources = fs.readFileSync(fromProjectRoot('src/api/resources.ts'), 'utf8'); diff --git a/frontend/platform_admin/src/api/resource-form.ts b/frontend/platform_admin/src/api/resource-form.ts index 7494936..e69307f 100644 --- a/frontend/platform_admin/src/api/resource-form.ts +++ b/frontend/platform_admin/src/api/resource-form.ts @@ -1,17 +1,27 @@ import type { ResourceField } from './resources'; export type ResourceFormValue = string | number | boolean | undefined; +export type ResourceFormMode = 'create' | 'edit'; export function isMissingField(value: ResourceFormValue | null): boolean { return value === '' || value === null || value === undefined; } +export function isResourceFieldRequired( + field: ResourceField, + mode: ResourceFormMode, +): boolean { + return Boolean(field.required && !(mode === 'edit' && field.type === 'password')); +} + export function buildResourcePayload( fields: ResourceField[], form: Record, + mode: ResourceFormMode = 'create', ): Record { const payload: Record = {}; for (const field of fields) { + if (mode === 'edit' && field.type === 'password') continue; const value = form[field.key]; if (isMissingField(value)) { if (!field.required) continue; @@ -30,9 +40,19 @@ export function buildResourcePayload( payload[field.key] = value === true || value === 'true'; break; case 'json': - payload[field.key] = - typeof value === 'string' ? JSON.parse(value) : value; + if (typeof value !== 'string') + throw new Error(`${field.label}必须是有效 JSON`); + JSON.parse(value); + payload[field.key] = value; break; + case 'date': + case 'datetime': { + const date = new Date(String(value)); + if (Number.isNaN(date.getTime())) + throw new Error(`${field.label}必须是有效日期`); + payload[field.key] = date.toISOString(); + break; + } default: payload[field.key] = value; } diff --git a/frontend/platform_admin/src/views/shared/CrudListPage.vue b/frontend/platform_admin/src/views/shared/CrudListPage.vue index f3fdd17..7dec315 100644 --- a/frontend/platform_admin/src/views/shared/CrudListPage.vue +++ b/frontend/platform_admin/src/views/shared/CrudListPage.vue @@ -32,7 +32,7 @@ - + @@ -69,7 +69,11 @@ 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 { + buildResourcePayload, + isMissingField, + isResourceFieldRequired, +} from '@/api/resource-form'; import type { DetailAction, ResourceUiDefinition } from '@/api/resources'; type Row = Record; @@ -91,6 +95,14 @@ const actionForm = reactive>({}); const canCreate = computed(() => props.definition.mode !== 'readonly'); const canEdit = computed(() => props.definition.mode === 'writable'); const canArchive = computed(() => props.definition.mode === 'writable'); +const formMode = computed<'create' | 'edit'>(() => + editingIdentity.value ? 'edit' : 'create', +); +const formFields = computed(() => + props.definition.fields.filter( + (field) => formMode.value === 'create' || field.type !== 'password', + ), +); const displayFields = computed(() => props.definition.fields.filter((field) => field.key !== 'status'), ); @@ -190,15 +202,21 @@ async function submitDetailAction() { async function save() { if ( - props.definition.fields.some( - (field) => field.required && isMissingField(form[field.key]), + formFields.value.some( + (field) => + isResourceFieldRequired(field, formMode.value) && + isMissingField(form[field.key]), ) ) { Message.warning('请填写必填字段'); return; } try { - const payload = buildResourcePayload(props.definition.fields, form); + const payload = buildResourcePayload( + props.definition.fields, + form, + formMode.value, + ); if (editingIdentity.value) await resourceApi.update( props.definition.resource,