diff --git a/app/other/apis/tools/sys_tables.go b/app/other/apis/tools/sys_tables.go index 734f3d13..e8c84fea 100644 --- a/app/other/apis/tools/sys_tables.go +++ b/app/other/apis/tools/sys_tables.go @@ -368,6 +368,21 @@ func (e SysTable) Update(c *gin.Context) { return } + // PRD 010 F10: this bind-and-save path has no field-level validation of + // its own (API契约.md §1.2/§2.1, D6) - see sys_tables_validate.go for + // what each check guards and why colWidth is sanitized in place rather + // than rejected. + if err = validateAndSanitizeColumns(data.Columns); err != nil { + log.Errorf("validate columns error, %s", err.Error()) + e.Error(500, err, err.Error()) + return + } + if err = validateBusinessNameUnique(db, data.PackageName, data.BusinessName, data.TableId); err != nil { + log.Errorf("validate businessName error, %s", err.Error()) + e.Error(500, err, err.Error()) + return + } + data.UpdateBy = 0 result, err := data.Update(db) if err != nil { diff --git a/app/other/apis/tools/sys_tables_validate.go b/app/other/apis/tools/sys_tables_validate.go new file mode 100644 index 00000000..c9353b32 --- /dev/null +++ b/app/other/apis/tools/sys_tables_validate.go @@ -0,0 +1,92 @@ +package tools + +import ( + "fmt" + "regexp" + "strings" + + "gorm.io/gorm" + + "go-admin/app/other/models/tools" +) + +// jsonFieldPattern mirrors genInfoForm.vue's businessName rule +// (`/^[a-z][A-Za-z]+$/`) - jsonField has never had a format rule of its own, +// unlike businessName/tableName/className, and API契约.md §1.2 recommends +// tightening it to the same identifier shape the other three already use. +var jsonFieldPattern = regexp.MustCompile(`^[a-z][A-Za-z]+$`) + +// colWidthMin/colWidthMax are API契约.md §2.1's suggested range for colWidth. +const ( + colWidthMin = 40 + colWidthMax = 800 +) + +// expressionMarkers flags the "meant to be evaluated" shapes API契约.md §2.1 +// says defaultValue must not carry: it is spliced into the generated +// defaultModel() as a literal and never evaluated, so anything that looks +// like a function call or a block is rejected outright rather than +// generating code that silently does nothing. +var expressionMarkers = []string{"(", ")", "{", "}", "`", ";", "=>"} + +// validateAndSanitizeColumns enforces PRD 010 F10 on the columns carried by +// a table update (sys_tables.go:357's Update handler, the one bind-and-save +// path with no field-level validation at all - see API契约.md §1.2/§2.1, +// decision D6). +// +// jsonField and defaultValue problems reject the request outright: letting +// either through would corrupt the generated i18n file silently (a +// duplicate or malformed jsonField becomes a duplicate or invalid key in +// gen/{PackageName}/{BusinessName}.ts, see the lang-zh/lang-en templates). +// An out-of-range colWidth does not reject - §2.1 says it "falls back to +// the inferred value", so this resets it to the 0 sentinel in place and lets +// R2's inference take over, the same as if the field had never been set. +func validateAndSanitizeColumns(columns []tools.SysColumns) error { + seen := make(map[string]bool, len(columns)) + for i := range columns { + col := &columns[i] + + if !jsonFieldPattern.MatchString(col.JsonField) { + return fmt.Errorf("jsonField 格式不合法:%q,须以小写字母开头且只能包含英文字母", col.JsonField) + } + if seen[col.JsonField] { + return fmt.Errorf("jsonField 在同一张表内重复:%q", col.JsonField) + } + seen[col.JsonField] = true + + if col.ColWidth != 0 && (col.ColWidth < colWidthMin || col.ColWidth > colWidthMax) { + col.ColWidth = 0 + } + + for _, marker := range expressionMarkers { + if strings.Contains(col.DefaultValue, marker) { + return fmt.Errorf("defaultValue 不允许包含表达式或函数调用内容:%q", col.DefaultValue) + } + } + } + return nil +} + +// validateBusinessNameUnique enforces PRD 010 F10's other half: two tables +// sharing (packageName, businessName) write the same generated language +// pack path, gen/{PackageName}/{BusinessName}.ts (see gen.go's +// NOActionsGen), so the second one silently overwrites the first's +// translations. tableID excludes the row being saved, so a table updating +// its own unchanged name does not trip the check on itself. +// +// G10's other concern - colliding with the built-in admin/* i18n namespace - +// does not apply here anymore: D9 moved generated keys to their own gen/ +// namespace, so this only has to guard generated tables against each other. +func validateBusinessNameUnique(db *gorm.DB, packageName, businessName string, tableID int) error { + var count int64 + err := db.Table("sys_tables"). + Where("package_name = ? AND business_name = ? AND table_id != ?", packageName, businessName, tableID). + Count(&count).Error + if err != nil { + return err + } + if count > 0 { + return fmt.Errorf("packageName=%q 下 businessName=%q 已被其它表使用", packageName, businessName) + } + return nil +} diff --git a/app/other/apis/tools/sys_tables_validate_test.go b/app/other/apis/tools/sys_tables_validate_test.go new file mode 100644 index 00000000..f2a31c77 --- /dev/null +++ b/app/other/apis/tools/sys_tables_validate_test.go @@ -0,0 +1,149 @@ +package tools + +import ( + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + + "go-admin/app/other/models/tools" +) + +func TestValidateAndSanitizeColumns_JsonFieldFormat(t *testing.T) { + cases := []struct { + name string + jsonField string + wantErr bool + }{ + {"lower camelCase", "userName", false}, + {"two-letter lowercase", "id", false}, + {"leading underscore rejected", "_id", true}, + {"leading digit rejected", "1name", true}, + {"snake_case rejected", "user_name", true}, + {"dot rejected, would break the gen/{pkg}/{biz}.ts key path", "user.name", true}, + {"empty rejected", "", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validateAndSanitizeColumns([]tools.SysColumns{{JsonField: tc.jsonField}}) + if tc.wantErr && err == nil { + t.Errorf("jsonField %q: want error, got nil", tc.jsonField) + } + if !tc.wantErr && err != nil { + t.Errorf("jsonField %q: want no error, got %v", tc.jsonField, err) + } + }) + } +} + +func TestValidateAndSanitizeColumns_JsonFieldUniqueWithinTable(t *testing.T) { + err := validateAndSanitizeColumns([]tools.SysColumns{ + {JsonField: "name"}, + {JsonField: "name"}, + }) + if err == nil { + t.Fatal("want error for a jsonField repeated in the same table, got nil") + } +} + +func TestValidateAndSanitizeColumns_ColWidthOutOfRangeIsSanitizedNotRejected(t *testing.T) { + cases := []struct { + name string + width int + want int + }{ + {"zero (unconfigured) is left alone", 0, 0}, + {"in range is left alone", 150, 150}, + {"lower bound is left alone", colWidthMin, colWidthMin}, + {"upper bound is left alone", colWidthMax, colWidthMax}, + {"too small falls back to the sentinel", colWidthMin - 1, 0}, + {"too large falls back to the sentinel", colWidthMax + 1, 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cols := []tools.SysColumns{{JsonField: "name", ColWidth: tc.width}} + if err := validateAndSanitizeColumns(cols); err != nil { + t.Fatalf("colWidth %d: want no error (out-of-range sanitizes, it does not reject), got %v", tc.width, err) + } + if cols[0].ColWidth != tc.want { + t.Errorf("colWidth %d: want sanitized to %d, got %d", tc.width, tc.want, cols[0].ColWidth) + } + }) + } +} + +func TestValidateAndSanitizeColumns_DefaultValueExpressionRejected(t *testing.T) { + cases := []struct { + name string + defaultValue string + wantErr bool + }{ + {"plain literal", "0", false}, + {"plain string literal", "active", false}, + {"empty (unconfigured)", "", false}, + {"function call rejected", "Date.now()", true}, + {"template literal rejected", "`x`", true}, + {"arrow function rejected", "() => 1", true}, + {"statement separator rejected", "1; drop", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validateAndSanitizeColumns([]tools.SysColumns{{JsonField: "name", DefaultValue: tc.defaultValue}}) + if tc.wantErr && err == nil { + t.Errorf("defaultValue %q: want error, got nil", tc.defaultValue) + } + if !tc.wantErr && err != nil { + t.Errorf("defaultValue %q: want no error, got %v", tc.defaultValue, err) + } + }) + } +} + +func newBusinessNameTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(new(tools.SysTables)); err != nil { + t.Fatalf("migrate sys_tables: %v", err) + } + return db +} + +func TestValidateBusinessNameUnique(t *testing.T) { + db := newBusinessNameTestDB(t) + + existing := tools.SysTables{TBName: "sys_widget", PackageName: "biz", BusinessName: "widget"} + if err := db.Table("sys_tables").Create(&existing).Error; err != nil { + t.Fatalf("seed: %v", err) + } + + t.Run("same package, same businessName, different table: rejected", func(t *testing.T) { + other := tools.SysTables{TBName: "sys_widget_copy", PackageName: "biz", BusinessName: "widget"} + if err := db.Table("sys_tables").Create(&other).Error; err != nil { + t.Fatalf("seed second row: %v", err) + } + // Unscoped: a plain Delete only soft-deletes (SysTables carries + // common.ModelTime), which would leave this row's businessName + // looking taken for the next subtest - production's own delete path + // (SysTables.BatchDelete) hard-deletes for the same reason. + defer db.Table("sys_tables").Unscoped().Delete(&other) + + if err := validateBusinessNameUnique(db, "biz", "widget", other.TableId); err == nil { + t.Error("want error for a businessName already used by another table in the same package, got nil") + } + }) + + t.Run("different package, same businessName: allowed", func(t *testing.T) { + if err := validateBusinessNameUnique(db, "other-pkg", "widget", 0); err != nil { + t.Errorf("want no error across different packages, got %v", err) + } + }) + + t.Run("a table checking against its own current name: allowed", func(t *testing.T) { + if err := validateBusinessNameUnique(db, "biz", "widget", existing.TableId); err != nil { + t.Errorf("want no error when the only match is the row being saved itself, got %v", err) + } + }) +}