diff --git a/app/other/apis/tools/gen.go b/app/other/apis/tools/gen.go index 02701620..cc673b78 100644 --- a/app/other/apis/tools/gen.go +++ b/app/other/apis/tools/gen.go @@ -131,6 +131,10 @@ func (e Gen) Preview(c *gin.Context) { // are not interchangeable stand-ins for each other and should not be // assumed to be. tab.MLTBName = strings.Replace(tab.TBName, "_", "-", -1) + if err := requireSinglePrimaryKey(tab); err != nil { + e.Error(500, err, err.Error()) + return + } // R2: infer a width for any column the config page left at colWidth's 0 // sentinel, before vue.go.template reads .ColWidth - see column_width.go. applyInferredColumnWidths(tab.Columns) @@ -221,6 +225,10 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) { e.Context = c log := e.GetLogger() tab.MLTBName = strings.Replace(tab.TBName, "_", "-", -1) + if err := requireSinglePrimaryKey(tab); err != nil { + e.Error(500, err, err.Error()) + return + } // R2: see the matching call and comment in Preview above. applyInferredColumnWidths(tab.Columns) @@ -502,3 +510,26 @@ func (e Gen) GenMenuAndApi(c *gin.Context) { e.OK("", "数据生成成功!") } + +// requireSinglePrimaryKey refuses a table whose primary key is not exactly one +// column. The templates address a row by a single key - one field on the +// model, one path parameter, one value per row in a delete - so a table with +// no primary key, or a composite one, has no shape they can render: generating +// it anyway writes a model whose GetId names nothing, or silently keys the +// whole table on whichever key column happened to be imported last. +func requireSinglePrimaryKey(tab tools.SysTables) error { + var keys []string + for _, c := range tab.Columns { + if c.Pk { + keys = append(keys, c.ColumnName) + } + } + switch len(keys) { + case 1: + return nil + case 0: + return fmt.Errorf("表 %s 没有主键,代码生成需要恰好一个主键列", tab.TBName) + default: + return fmt.Errorf("表 %s 是联合主键(%s),代码生成需要恰好一个主键列", tab.TBName, strings.Join(keys, ", ")) + } +} diff --git a/app/other/apis/tools/gen_primary_key_test.go b/app/other/apis/tools/gen_primary_key_test.go new file mode 100644 index 00000000..93b51bf0 --- /dev/null +++ b/app/other/apis/tools/gen_primary_key_test.go @@ -0,0 +1,385 @@ +package tools + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "text/template" + + "github.com/gin-gonic/gin" + "github.com/go-admin-team/go-admin-core/v2/sdk/config" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "go-admin/app/other/models/tools" +) + +// The importer reads tables from information_schema, which only MySQL is +// supported for, so this runs only when a MySQL DSN is set - and must run in +// CI, where one is. A suite that skipped there would report the generator +// fine for every primary key it has never been shown. +const genMySQLDSNEnv = "GO_ADMIN_TEST_MYSQL_DSN" + +// auditColumns are the columns every generated model maps through +// models.ModelTime and models.ControlBy, typed the way those structs store +// them. A fixture without them would describe a table the generated model +// cannot read or write. +const auditColumns = `name varchar(64) NOT NULL COMMENT 'name', + create_by bigint NULL, + update_by bigint NULL, + created_at datetime(3) NULL, + updated_at datetime(3) NULL, + deleted_at bigint NOT NULL DEFAULT 0` + +// pkShape is one table the importer can be pointed at. The shapes are the +// primary keys a real table has, not the one the templates were written +// for. +type pkShape struct { + Table string + Class string + Key string // key column definition, before the audit columns + Tail string // table constraints, after them + Refused bool // generation must refuse the table outright + PkColumn string + // Assigned is true when the database assigns the key; otherwise Create + // carries it and ID is the value it carries. + Assigned bool + Create string + ID string + IDJSON string + Missing string +} + +var pkShapes = []pkShape{{ + Table: "gpk_serial", Class: "GpkSerial", PkColumn: "id", + Key: "id int NOT NULL AUTO_INCREMENT PRIMARY KEY,", + Assigned: true, Create: `{"name":"alpha"}`, Missing: "987654", +}, { + Table: "gpk_order", Class: "GpkOrder", PkColumn: "order_no", + Key: "order_no int NOT NULL AUTO_INCREMENT PRIMARY KEY,", + Assigned: true, Create: `{"name":"alpha"}`, Missing: "987654", +}, { + Table: "gpk_code", Class: "GpkCode", PkColumn: "code", + Key: "code varchar(32) NOT NULL PRIMARY KEY,", + Create: `{"code":"c-1","name":"alpha"}`, ID: "c-1", IDJSON: `"c-1"`, Missing: "no-such-code", +}, { + Table: "gpk_nokey", Key: "ref int NOT NULL,", Refused: true, +}, { + Table: "gpk_pair", Key: "a int NOT NULL, b int NOT NULL,", Tail: ", PRIMARY KEY (a, b)", Refused: true, +}} + +func TestGeneratorHandlesEveryPrimaryKeyShape(t *testing.T) { + dsn := os.Getenv(genMySQLDSNEnv) + if dsn == "" { + if os.Getenv("CI") != "" { + t.Fatalf("%s is not set while CI is: the generator must not go untested", genMySQLDSNEnv) + } + t.Skipf("%s is not set; the importer only reads MySQL", genMySQLDSNEnv) + } + gin.SetMode(gin.TestMode) + root, err := filepath.Abs("../../../..") + if err != nil { + t.Fatal(err) + } + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatalf("connecting to %s: %v", genMySQLDSNEnv, err) + } + var dbName string + if err := db.Raw("SELECT DATABASE()").Scan(&dbName).Error; err != nil || dbName == "" { + t.Fatalf("reading the database name: %v", err) + } + + out := t.TempDir() + restore := [3]string{config.DatabaseConfig.Driver, config.GenConfig.DBName, config.GenConfig.FrontPath} + t.Cleanup(func() { + config.DatabaseConfig.Driver, config.GenConfig.DBName, config.GenConfig.FrontPath = restore[0], restore[1], restore[2] + }) + config.DatabaseConfig.Driver = "mysql" + config.GenConfig.DBName = dbName + config.GenConfig.FrontPath = filepath.Join(out, "ui") + + if err := db.AutoMigrate(&tools.SysTables{}, &tools.SysColumns{}); err != nil { + t.Fatalf("migrating the generator's own tables: %v", err) + } + for _, s := range pkShapes { + dropFixture(t, db, s.Table) + ddl := "CREATE TABLE " + s.Table + " (" + s.Key + auditColumns + s.Tail + ")" + if err := db.Exec(ddl).Error; err != nil { + t.Fatalf("creating %s: %v", s.Table, err) + } + t.Cleanup(func() { dropFixture(t, db, s.Table) }) + } + + // The generator resolves its templates, and writes its output, relative to + // the working directory, exactly as it does in a running server. + if err := os.Symlink(filepath.Join(root, "template"), filepath.Join(out, "template")); err != nil { + t.Fatal(err) + } + t.Chdir(out) + + generated := map[string]string{} + var runnable []pkShape + genFailed := false + for _, s := range pkShapes { + if code := callHandler(t, db, SysTable{}.Insert, "/?tables="+s.Table, nil); code != http.StatusOK { + t.Fatalf("importing %s: response code %d", s.Table, code) + } + var row tools.SysTables + if err := db.Where("table_name = ?", s.Table).First(&row).Error; err != nil { + t.Fatalf("finding %s after import: %v", s.Table, err) + } + code := callHandler(t, db, Gen{}.GenCode, "/", gin.Params{{Key: "tableId", Value: itoa(row.TableId)}}) + files := backendFiles(out, root, s.Table) + + if s.Refused { + if code == http.StatusOK { + t.Errorf("%s: generation succeeded; a table without exactly one primary key column must be refused", s.Table) + } + for _, generatedFile := range files { + if _, err := os.Stat(generatedFile); err == nil { + t.Errorf("%s: refused, but %s was written anyway", s.Table, generatedFile) + } + } + continue + } + if code != http.StatusOK { + t.Errorf("%s: generation failed with response code %d", s.Table, code) + genFailed = true + continue + } + for dst, src := range files { + generated[dst] = src + } + runnable = append(runnable, s) + } + if genFailed { + return + } + + // Compile the generated files in place, inside this module, through an + // overlay: nothing is written into the working tree, and the generated + // code is checked against the real packages it will live in. + crud := filepath.Join(out, "generated_crud_test.go") + f, err := os.Create(crud) + if err != nil { + t.Fatal(err) + } + if err := crudTest.Execute(f, runnable); err != nil { + t.Fatal(err) + } + f.Close() + generated[filepath.Join(root, "app/admin/apis/zz_generated_crud_test.go")] = crud + + overlay := filepath.Join(out, "overlay.json") + b, _ := json.Marshal(map[string]any{"Replace": generated}) + if err := os.WriteFile(overlay, b, 0o644); err != nil { + t.Fatal(err) + } + + goCmd(t, root, "vet", "-overlay="+overlay, "./app/admin/...") + ran := goCmd(t, root, "test", "-overlay="+overlay, "-count=1", "-v", "-run", "^TestGeneratedCRUD_", "./app/admin/apis/") + // A -run pattern that matches nothing also exits 0. Each table has to + // have been driven, not merely compiled. + for _, s := range runnable { + if !strings.Contains(string(ran), "--- PASS: TestGeneratedCRUD_"+s.Class+" ") { + t.Errorf("%s: TestGeneratedCRUD_%s did not run", s.Table, s.Class) + } + } +} + +func dropFixture(t *testing.T, db *gorm.DB, table string) { + t.Helper() + var ids []int + db.Model(&tools.SysTables{}).Where("table_name = ?", table).Pluck("table_id", &ids) + if len(ids) > 0 { + db.Where("table_id IN ?", ids).Delete(&tools.SysColumns{}) + db.Where("table_id IN ?", ids).Delete(&tools.SysTables{}) + } + if err := db.Exec("DROP TABLE IF EXISTS " + table).Error; err != nil { + t.Fatalf("dropping %s: %v", table, err) + } +} + +// callHandler runs one handler the way a request would reach it and returns +// the code its first response body carries. GenCode can write a second body +// after an error body, so only the first is the verdict. +func callHandler(t *testing.T, db *gorm.DB, h gin.HandlerFunc, target string, params gin.Params) int { + t.Helper() + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, target, nil) + c.Params = params + c.Set("db", db) + h(c) + var body struct { + Code int `json:"code"` + } + if err := json.NewDecoder(strings.NewReader(w.Body.String())).Decode(&body); err != nil { + t.Fatalf("decoding the response to %s: %v (body %q)", target, err, w.Body.String()) + } + return body.Code +} + +// backendFiles maps where each generated backend file belongs in this module +// to where the generator wrote it. +func backendFiles(out, root, table string) map[string]string { + m := map[string]string{} + for _, dir := range []string{"models", "apis", "router", "service", "service/dto"} { + rel := filepath.Join("app/admin", dir, table+".go") + m[filepath.Join(root, rel)] = filepath.Join(out, rel) + } + return m +} + +func goCmd(t *testing.T, dir string, args ...string) []byte { + t.Helper() + cmd := exec.Command("go", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + var exit *exec.ExitError + if errors.As(err, &exit) { + t.Fatalf("go %s failed:\n%s", strings.Join(args, " "), out) + } + if err != nil { + t.Fatalf("running go %s: %v", args[0], err) + } + t.Logf("go %s:\n%s", args[0], out) + return out +} + +func itoa(n int) string { + b, _ := json.Marshal(n) + return string(b) +} + +// crudTest drives each generated table through its generated handlers, over +// HTTP, against the fixture table. The routes use the same "/:id" pattern the +// generated routers register. +var crudTest = template.Must(template.New("crud").Parse(`package apis + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +type generatedResp struct { + Code int ` + "`json:\"code\"`" + ` + Data json.RawMessage ` + "`json:\"data\"`" + ` +} + +func generatedEngine(t *testing.T) (*gin.Engine, *gorm.DB) { + t.Helper() + db, err := gorm.Open(mysql.Open(os.Getenv("GO_ADMIN_TEST_MYSQL_DSN")), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatal(err) + } + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { c.Set("db", db.WithContext(c)); c.Next() }) + return r, db +} + +func generatedDo(r *gin.Engine, method, path, body string) generatedResp { + w := httptest.NewRecorder() + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + var res generatedResp + _ = json.NewDecoder(strings.NewReader(w.Body.String())).Decode(&res) + return res +} + +func generatedLive(db *gorm.DB, table string) int64 { + var n int64 + db.Table(table).Where("deleted_at = 0").Count(&n) + return n +} +{{range .}} +func TestGeneratedCRUD_{{.Class}}(t *testing.T) { + r, db := generatedEngine(t) + a := {{.Class}}{} + r.POST("/x", a.Insert) + r.GET("/x/:id", a.Get) + r.PUT("/x/:id", a.Update) + r.DELETE("/x", a.Delete) + + if res := generatedDo(r, http.MethodPost, "/x", ` + "`{{.Create}}`" + `); res.Code != 200 { + t.Fatalf("insert: code %d", res.Code) + } +{{- if .Assigned}} + var n int + db.Table("{{.Table}}").Select("MAX({{.PkColumn}})").Scan(&n) + id, idJSON := itoaGenerated(n), itoaGenerated(n) +{{- else}} + id, idJSON := "{{.ID}}", ` + "`{{.IDJSON}}`" + ` +{{- end}} + + t.Run("get by the path parameter", func(t *testing.T) { + res := generatedDo(r, http.MethodGet, "/x/"+url.PathEscape(id), "") + var row map[string]any + _ = json.Unmarshal(res.Data, &row) + if res.Code != 200 || row["name"] != "alpha" { + t.Errorf("code %d, data %s", res.Code, res.Data) + } + }) + t.Run("update by the path parameter updates, not inserts", func(t *testing.T) { + live := generatedLive(db, "{{.Table}}") + res := generatedDo(r, http.MethodPut, "/x/"+url.PathEscape(id), ` + "`{\"name\":\"beta\"}`" + `) + var name string + db.Table("{{.Table}}").Where("{{.PkColumn}} = ?", id).Select("name").Scan(&name) + if res.Code != 200 || name != "beta" || generatedLive(db, "{{.Table}}") != live { + t.Errorf("code %d, name %q, live rows %d want %d", res.Code, name, generatedLive(db, "{{.Table}}"), live) + } + }) + t.Run("a missing key is not found", func(t *testing.T) { + if res := generatedDo(r, http.MethodGet, "/x/{{.Missing}}", ""); res.Code == 200 { + t.Errorf("found: %s", res.Data) + } + }) + t.Run("updating a missing key does not create a row", func(t *testing.T) { + live := generatedLive(db, "{{.Table}}") + res := generatedDo(r, http.MethodPut, "/x/{{.Missing}}", ` + "`{\"name\":\"ghost\"}`" + `) + if got := generatedLive(db, "{{.Table}}"); got != live || res.Code == 200 { + t.Errorf("code %d, live rows %d want %d", res.Code, got, live) + } + }) + // "1=1" rather than a quote-breaking payload: a key handed to GORM as a + // bare string becomes the WHERE clause itself, and this one is valid SQL + // that matches every row, so the failure it exposes is a successful read. + t.Run("a key carrying SQL is a value, not a condition", func(t *testing.T) { + if res := generatedDo(r, http.MethodGet, "/x/"+url.PathEscape("1=1"), ""); res.Code == 200 { + t.Errorf("found: %s", res.Data) + } + }) + t.Run("delete by key", func(t *testing.T) { + live := generatedLive(db, "{{.Table}}") + res := generatedDo(r, http.MethodDelete, "/x", ` + "`{\"ids\":[`+idJSON+`]}`" + `) + if res.Code != 200 || generatedLive(db, "{{.Table}}") != live-1 { + t.Errorf("code %d, live rows %d want %d", res.Code, generatedLive(db, "{{.Table}}"), live-1) + } + }) +} +{{end}} +func itoaGenerated(n int) string { + b, _ := json.Marshal(n) + return string(b) +} +`)) diff --git a/template/v4/dto.go.template b/template/v4/dto.go.template index 428b89ae..7619b066 100644 --- a/template/v4/dto.go.template +++ b/template/v4/dto.go.template @@ -1,6 +1,13 @@ package dto import ( + {{- /* + $embed: the key is models.Model's own Id, so the model embeds it. + $pkType: the key's Go type, for the delete request's id list. + */ -}} + {{- $embed := false -}} + {{- $pkType := "int" -}} + {{- range .Columns -}}{{- if .Pk -}}{{- $pkType = .GoType -}}{{- if and (eq .GoField "Id") (eq .GoType "int") -}}{{- $embed = true -}}{{- end -}}{{- end -}}{{- end -}} {{- $bb := false -}} {{- range .Columns -}} {{- $z := .IsQuery -}} @@ -52,7 +59,7 @@ type {{.ClassName}}InsertReq struct { {{- range .Columns -}} {{$x := .Pk}} {{- if ($x) }} - {{.GoField}} {{.GoType}} `json:"-" comment:"{{.ColumnComment}}"` // {{.ColumnComment}} + {{.GoField}} {{.GoType}} `json:"{{if eq .GoType "int"}}-{{else}}{{.JsonField}}{{end}}" comment:"{{.ColumnComment}}"` // {{.ColumnComment}} {{- else if eq .GoField "CreatedAt" -}} {{- else if eq .GoField "UpdatedAt" -}} {{- else if eq .GoField "DeletedAt" -}} @@ -69,9 +76,13 @@ func (s *{{.ClassName}}InsertReq) Generate(model *models.{{.ClassName}}) { {{- range .Columns -}} {{$x := .Pk}} {{- if ($x) }} + {{- if $embed }} if s.{{.GoField}} == 0 { model.Model = common.Model{ {{.GoField}}: s.{{.GoField}} } } + {{- else }} + model.{{.GoField}} = s.{{.GoField}} + {{- end }} {{- else if eq .GoField "CreatedAt" -}} {{- else if eq .GoField "UpdatedAt" -}} {{- else if eq .GoField "DeletedAt" -}} @@ -92,7 +103,7 @@ type {{.ClassName}}UpdateReq struct { {{- range .Columns -}} {{$x := .Pk}} {{- if ($x) }} - {{.GoField}} {{.GoType}} `uri:"{{.JsonField}}" comment:"{{.ColumnComment}}"` // {{.ColumnComment}} + {{.GoField}} {{.GoType}} `uri:"id" comment:"{{.ColumnComment}}"` // {{.ColumnComment}} {{- else if eq .GoField "CreatedAt" -}} {{- else if eq .GoField "UpdatedAt" -}} {{- else if eq .GoField "DeletedAt" -}} @@ -109,9 +120,11 @@ func (s *{{.ClassName}}UpdateReq) Generate(model *models.{{.ClassName}}) { {{- range .Columns -}} {{$x := .Pk}} {{- if ($x) }} + {{- if $embed }} if s.{{.GoField}} == 0 { model.Model = common.Model{ {{.GoField}}: s.{{.GoField}} } } + {{- end }} {{- else if eq .GoField "CreatedAt" -}} {{- else if eq .GoField "UpdatedAt" -}} {{- else if eq .GoField "DeletedAt" -}} @@ -133,7 +146,7 @@ type {{.ClassName}}GetReq struct { {{- range .Columns -}} {{$x := .Pk}} {{- if ($x) }} - {{.GoField}} {{.GoType}} `uri:"{{.JsonField}}"` + {{.GoField}} {{.GoType}} `uri:"id"` {{- end }} {{- end }} } @@ -143,7 +156,7 @@ func (s *{{.ClassName}}GetReq) GetId() interface{} { // {{.ClassName}}DeleteReq 功能删除请求参数 type {{.ClassName}}DeleteReq struct { - Ids []int `json:"ids"` + Ids []{{$pkType}} `json:"ids"` } func (s *{{.ClassName}}DeleteReq) GetId() interface{} { diff --git a/template/v4/model.go.template b/template/v4/model.go.template index 2f56123d..ad8f4360 100644 --- a/template/v4/model.go.template +++ b/template/v4/model.go.template @@ -24,11 +24,24 @@ import ( ) +{{/* + models.Model supplies an int primary key named Id, so it stands in for the + key column only when that is exactly the key the table has. Any other key - + a different name, or a string - is declared as its own field, or GetId below + would name a field that does not exist. +*/ -}} +{{- $embed := false -}} +{{- range .Columns -}}{{- if and .Pk (eq .GoField "Id") (eq .GoType "int") -}}{{- $embed = true -}}{{- end -}}{{- end -}} type {{.ClassName}} struct { + {{- if $embed }} models.Model + {{- end }} {{ range .Columns -}} {{$x := .Pk}} {{- if ($x) }} + {{- if not $embed }} + {{.GoField}} {{.GoType}} `json:"{{.JsonField}}" gorm:"primaryKey;type:{{.ColumnType}};comment:{{- if eq .ColumnComment "" -}}{{.GoField}}{{- else -}}{{.ColumnComment}}{{end -}}"` + {{- end }} {{- else if eq .GoField "CreatedAt" -}} {{- else if eq .GoField "UpdatedAt" -}} {{- else if eq .GoField "DeletedAt" -}} diff --git a/template/v4/no_actions/service.go.template b/template/v4/no_actions/service.go.template index 9f2a1af7..5dc5c4f7 100644 --- a/template/v4/no_actions/service.go.template +++ b/template/v4/no_actions/service.go.template @@ -5,6 +5,7 @@ import ( "github.com/go-admin-team/go-admin-core/v2/sdk/service" "gorm.io/gorm" + "gorm.io/gorm/clause" "go-admin/app/{{.PackageName}}/models" "go-admin/app/{{.PackageName}}/service/dto" @@ -37,6 +38,9 @@ func (e *{{.ClassName}}) GetPage(c *dto.{{.ClassName}}GetPageReq, p *actions.Dat } // Get 获取{{.ClassName}}对象 +// +// The key is matched as a value of the model's primary-key column. Handed to +// First on its own, a string key would be read as a SQL condition. func (e *{{.ClassName}}) Get(d *dto.{{.ClassName}}GetReq, p *actions.DataPermission, model *models.{{.ClassName}}) error { var data models.{{.ClassName}} @@ -44,7 +48,8 @@ func (e *{{.ClassName}}) Get(d *dto.{{.ClassName}}GetReq, p *actions.DataPermiss Scopes( actions.Permission(data.TableName(), p), ). - First(model, d.GetId()).Error + Where(clause.Eq{Column: clause.PrimaryColumn, Value: d.GetId()}). + First(model).Error if err != nil && errors.Is(err, gorm.ErrRecordNotFound) { err = errors.New("查看对象不存在或无权查看") e.Log.Errorf("Service Get{{.ClassName}} error:%s \r\n", err) @@ -71,12 +76,25 @@ func (e *{{.ClassName}}) Insert(c *dto.{{.ClassName}}InsertReq) error { } // Update 修改{{.ClassName}}对象 +// +// The row has to be found first. Save on a model whose key is still zero +// inserts, so a row that is missing - or that p filters out - must stop here +// rather than turn the update into a new row. func (e *{{.ClassName}}) Update(c *dto.{{.ClassName}}UpdateReq, p *actions.DataPermission) error { var err error var data = models.{{.ClassName}}{} - e.Orm.Scopes( + err = e.Orm.Scopes( actions.Permission(data.TableName(), p), - ).First(&data, c.GetId()) + ). + Where(clause.Eq{Column: clause.PrimaryColumn, Value: c.GetId()}). + First(&data).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("无权更新该数据") + } + if err != nil { + e.Log.Errorf("{{.ClassName}}Service Update error:%s \r\n", err) + return err + } c.Generate(&data) db := e.Orm.Save(&data)