fix🐛: render every generated file before writing any, and check both

Preview, NOActionsGen and genApiToFile assigned each template's Execute
error to err and never read it - 19 times - so a template failing part-way
left a truncated file behind it and the request reported success. The
files were written through pkg.FileCreate, which returns no error at all:
when it cannot create a file it closes a nil *os.File and calls
log.Fatalln, ending the server process. Four of the directories it writes
into were created with _ = pkg.PathCreate(...).

Every template now renders into memory first; if any fails, nothing is
written and the response names the template. Files are then written
through writeGenerated, which creates the directory and returns any error
instead of exiting. NOActionsGen and genApiToFile report whether they
succeeded, so GenCode and GenApiToFile no longer follow an error response
with "Code generated successfully".
This commit is contained in:
zhangwenjian
2026-09-26 14:17:22 +08:00
parent 88b14a5e0f
commit 83064f0b37
2 changed files with 300 additions and 96 deletions
+107 -96
View File
@@ -5,6 +5,8 @@ import (
"fmt"
"go-admin/app/admin/service"
"go-admin/app/admin/service/dto"
"os"
"path/filepath"
"strconv"
"strings"
"text/template"
@@ -138,35 +140,23 @@ func (e Gen) Preview(c *gin.Context) {
// 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)
var b1 bytes.Buffer
err = t1.Execute(&b1, tab)
var b2 bytes.Buffer
err = t2.Execute(&b2, tab)
var b3 bytes.Buffer
err = t3.Execute(&b3, tab)
var b4 bytes.Buffer
err = t4.Execute(&b4, tab)
var b5 bytes.Buffer
err = t5.Execute(&b5, tab)
var b6 bytes.Buffer
err = t6.Execute(&b6, tab)
var b7 bytes.Buffer
err = t7.Execute(&b7, tab)
var b8 bytes.Buffer
err = t8.Execute(&b8, tab)
var b9 bytes.Buffer
err = t9.Execute(&b9, tab)
out, err := renderAll(tab, t1, t2, t3, t4, t5, t6, t7, t8, t9)
if err != nil {
log.Error(err)
e.Error(500, err, fmt.Sprintf("模版渲染失败!错误详情:%s", err.Error()))
return
}
mp := make(map[string]interface{})
mp["template/model.go.template"] = b1.String()
mp["template/api.go.template"] = b2.String()
mp["template/api.ts.template"] = b3.String()
mp["template/vue.go.template"] = b4.String()
mp["template/router.go.template"] = b5.String()
mp["template/dto.go.template"] = b6.String()
mp["template/service.go.template"] = b7.String()
mp["template/lang-zh.go.template"] = b8.String()
mp["template/lang-en.go.template"] = b9.String()
mp["template/model.go.template"] = string(out[0])
mp["template/api.go.template"] = string(out[1])
mp["template/api.ts.template"] = string(out[2])
mp["template/vue.go.template"] = string(out[3])
mp["template/router.go.template"] = string(out[4])
mp["template/dto.go.template"] = string(out[5])
mp["template/service.go.template"] = string(out[6])
mp["template/lang-zh.go.template"] = string(out[7])
mp["template/lang-en.go.template"] = string(out[8])
e.OK(mp, "")
}
@@ -191,7 +181,9 @@ func (e Gen) GenCode(c *gin.Context) {
table.TableId = id
tab, _ := table.Get(db, false)
e.NOActionsGen(c, tab)
if !e.NOActionsGen(c, tab) {
return
}
e.OK("", "Code generated successfully!")
}
@@ -216,18 +208,23 @@ func (e Gen) GenApiToFile(c *gin.Context) {
table.TableId = id
tab, _ := table.Get(db, false)
e.genApiToFile(c, tab)
if !e.genApiToFile(c, tab) {
return
}
e.OK("", "Code generated successfully!")
}
func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) {
// NOActionsGen renders every template for tab and writes the files, and
// reports whether it did. On false it has already written the error response,
// and nothing has been written to disk if a template failed to render.
func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) bool {
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
return false
}
// R2: see the matching call and comment in Preview above.
applyInferredColumnWidths(tab.Columns)
@@ -243,125 +240,104 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) {
if err != nil {
log.Error(err)
e.Error(500, err, fmt.Sprintf("model模版读取失败!错误详情:%s", err.Error()))
return
return false
}
t2, err := template.ParseFiles(basePath + "no_actions/apis.go.template")
if err != nil {
log.Error(err)
e.Error(500, err, fmt.Sprintf("api模版读取失败!错误详情:%s", err.Error()))
return
return false
}
t3, err := template.ParseFiles(routerFile)
if err != nil {
log.Error(err)
e.Error(500, err, fmt.Sprintf("路由模版失败!错误详情:%s", err.Error()))
return
return false
}
t4, err := template.ParseFiles(basePath + "ts.go.template")
if err != nil {
log.Error(err)
e.Error(500, err, fmt.Sprintf("ts模版解析失败!错误详情:%s", err.Error()))
return
return false
}
t5, err := template.ParseFiles(basePath + "vue.go.template")
if err != nil {
log.Error(err)
e.Error(500, err, fmt.Sprintf("vue模版解析失败!错误详情:%s", err.Error()))
return
return false
}
t6, err := template.ParseFiles(basePath + "dto.go.template")
if err != nil {
log.Error(err)
e.Error(500, err, fmt.Sprintf("dto模版解析失败失败!错误详情:%s", err.Error()))
return
return false
}
t7, err := template.ParseFiles(basePath + "no_actions/service.go.template")
if err != nil {
log.Error(err)
e.Error(500, err, fmt.Sprintf("service模版失败!错误详情:%s", err.Error()))
return
return false
}
// t8/t9 back F3/F9 (PRD 010): see the matching comment in Preview above.
t8, err := parseGenTemplate(basePath + "lang-zh.go.template")
if err != nil {
log.Error(err)
e.Error(500, err, fmt.Sprintf("zh语言包模版解析失败!错误详情:%s", err.Error()))
return
return false
}
t9, err := parseGenTemplate(basePath + "lang-en.go.template")
if err != nil {
log.Error(err)
e.Error(500, err, fmt.Sprintf("en语言包模版解析失败!错误详情:%s", err.Error()))
return
return false
}
_ = pkg.PathCreate("./app/" + tab.PackageName + "/apis/")
_ = pkg.PathCreate("./app/" + tab.PackageName + "/models/")
_ = pkg.PathCreate("./app/" + tab.PackageName + "/router/")
_ = pkg.PathCreate("./app/" + tab.PackageName + "/service/dto/")
_ = pkg.PathCreate(config.GenConfig.FrontPath + "/api/" + tab.PackageName + "/")
err = pkg.PathCreate(config.GenConfig.FrontPath + "/views/" + tab.PackageName + "/" + tab.MLTBName + "/")
out, err := renderAll(tab, t1, t2, t3, t4, t5, t6, t7, t8, t9)
if err != nil {
log.Error(err)
e.Error(500, err, fmt.Sprintf("views目录创建失败!错误详情:%s", err.Error()))
return
e.Error(500, err, fmt.Sprintf("模版渲染失败!错误详情:%s", err.Error()))
return false
}
// gen/{PackageName}/ nests under each locale so go-admin-ui's
// gen-namespace.ts (`./*/*.ts` glob, one level under gen/) picks the file
// up - a flat gen/{BusinessName}.ts would let two tables in different
// packages silently overwrite each other's translations, since
// BusinessName only has a pattern check, no uniqueness check.
err = pkg.PathCreate(config.GenConfig.FrontPath + "/lang/zh-CN/gen/" + tab.PackageName + "/")
if err != nil {
log.Error(err)
e.Error(500, err, fmt.Sprintf("zh语言包目录创建失败!错误详情:%s", err.Error()))
return
files := []struct {
path string
content []byte
}{
{"./app/" + tab.PackageName + "/models/" + tab.TBName + ".go", out[0]},
{"./app/" + tab.PackageName + "/apis/" + tab.TBName + ".go", out[1]},
{"./app/" + tab.PackageName + "/router/" + tab.TBName + ".go", out[2]},
{config.GenConfig.FrontPath + "/api/" + tab.PackageName + "/" + tab.MLTBName + ".ts", out[3]},
{config.GenConfig.FrontPath + "/views/" + tab.PackageName + "/" + tab.MLTBName + "/index.vue", out[4]},
{"./app/" + tab.PackageName + "/service/dto/" + tab.TBName + ".go", out[5]},
{"./app/" + tab.PackageName + "/service/" + tab.TBName + ".go", out[6]},
{config.GenConfig.FrontPath + "/lang/zh-CN/gen/" + tab.PackageName + "/" + tab.BusinessName + ".ts", out[7]},
{config.GenConfig.FrontPath + "/lang/en-US/gen/" + tab.PackageName + "/" + tab.BusinessName + ".ts", out[8]},
}
err = pkg.PathCreate(config.GenConfig.FrontPath + "/lang/en-US/gen/" + tab.PackageName + "/")
if err != nil {
log.Error(err)
e.Error(500, err, fmt.Sprintf("en语言包目录创建失败!错误详情:%s", err.Error()))
return
for _, f := range files {
if err := writeGenerated(f.path, f.content); err != nil {
log.Error(err)
e.Error(500, err, fmt.Sprintf("生成文件写入失败!错误详情:%s", err.Error()))
return false
}
}
var b1 bytes.Buffer
err = t1.Execute(&b1, tab)
var b2 bytes.Buffer
err = t2.Execute(&b2, tab)
var b3 bytes.Buffer
err = t3.Execute(&b3, tab)
var b4 bytes.Buffer
err = t4.Execute(&b4, tab)
var b5 bytes.Buffer
err = t5.Execute(&b5, tab)
var b6 bytes.Buffer
err = t6.Execute(&b6, tab)
var b7 bytes.Buffer
err = t7.Execute(&b7, tab)
var b8 bytes.Buffer
err = t8.Execute(&b8, tab)
var b9 bytes.Buffer
err = t9.Execute(&b9, tab)
pkg.FileCreate(b1, "./app/"+tab.PackageName+"/models/"+tab.TBName+".go")
pkg.FileCreate(b2, "./app/"+tab.PackageName+"/apis/"+tab.TBName+".go")
pkg.FileCreate(b3, "./app/"+tab.PackageName+"/router/"+tab.TBName+".go")
pkg.FileCreate(b4, config.GenConfig.FrontPath+"/api/"+tab.PackageName+"/"+tab.MLTBName+".ts")
pkg.FileCreate(b5, config.GenConfig.FrontPath+"/views/"+tab.PackageName+"/"+tab.MLTBName+"/index.vue")
pkg.FileCreate(b6, "./app/"+tab.PackageName+"/service/dto/"+tab.TBName+".go")
pkg.FileCreate(b7, "./app/"+tab.PackageName+"/service/"+tab.TBName+".go")
pkg.FileCreate(b8, config.GenConfig.FrontPath+"/lang/zh-CN/gen/"+tab.PackageName+"/"+tab.BusinessName+".ts")
pkg.FileCreate(b9, config.GenConfig.FrontPath+"/lang/en-US/gen/"+tab.PackageName+"/"+tab.BusinessName+".ts")
return true
}
func (e Gen) genApiToFile(c *gin.Context, tab tools.SysTables) {
// genApiToFile writes the migration that seeds tab's menu and APIs, and
// reports whether it did; on false the error response is already written.
func (e Gen) genApiToFile(c *gin.Context, tab tools.SysTables) bool {
err := e.MakeContext(c).
MakeOrm().
Errors
if err != nil {
e.Logger.Error(err)
e.Error(500, err, err.Error())
return
return false
}
basePath := "template/"
@@ -370,17 +346,24 @@ func (e Gen) genApiToFile(c *gin.Context, tab tools.SysTables) {
if err != nil {
e.Logger.Error(err)
e.Error(500, err, fmt.Sprintf("数据迁移模版解析失败!错误详情:%s", err.Error()))
return
return false
}
i := strconv.FormatInt(time.Now().UnixNano()/1e6, 10)
var b1 bytes.Buffer
err = t1.Execute(&b1, struct {
out, err := renderAll(struct {
tools.SysTables
GenerateTime string
}{tab, i})
pkg.FileCreate(b1, "./cmd/migrate/migration/version-local/"+i+"_migrate.go")
}{tab, i}, t1)
if err != nil {
e.Logger.Error(err)
e.Error(500, err, fmt.Sprintf("数据迁移模版渲染失败!错误详情:%s", err.Error()))
return false
}
if err := writeGenerated("./cmd/migrate/migration/version-local/"+i+"_migrate.go", out[0]); err != nil {
e.Logger.Error(err)
e.Error(500, err, fmt.Sprintf("数据迁移文件写入失败!错误详情:%s", err.Error()))
return false
}
return true
}
func (e Gen) GenMenuAndApi(c *gin.Context) {
@@ -533,3 +516,31 @@ func requireSinglePrimaryKey(tab tools.SysTables) error {
return fmt.Errorf("表 %s 是联合主键(%s),代码生成需要恰好一个主键列", tab.TBName, strings.Join(keys, ", "))
}
}
// renderAll executes each template against data and returns the outputs in
// the same order, stopping at the first template that fails. The caller
// writes nothing until every template has rendered: a template that fails
// part-way leaves a truncated file behind it, which compiles as nothing and
// at a glance looks like the real thing.
func renderAll(data any, tpls ...*template.Template) ([][]byte, error) {
out := make([][]byte, len(tpls))
for i, t := range tpls {
var b bytes.Buffer
if err := t.Execute(&b, data); err != nil {
return nil, err
}
out[i] = b.Bytes()
}
return out, nil
}
// writeGenerated writes one generated file, creating its directory first.
// It stands in for pkg.FileCreate, which returns no error at all and, when
// the file cannot be created, closes a nil file and ends the process with
// log.Fatalln - one unwritable path took the whole server down with it.
func writeGenerated(path string, content []byte) error {
if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
return err
}
return os.WriteFile(path, content, 0o644)
}
+193
View File
@@ -0,0 +1,193 @@
package tools
import (
"encoding/json"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"go-admin/app/other/models/tools"
)
type genBody struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
// runGen calls h and decodes every JSON body it wrote, so a handler that
// answers twice - an error, then a success - shows up as two bodies.
func runGen(t *testing.T, db *gorm.DB, h func(*gin.Context), params gin.Params) []genBody {
t.Helper()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
c.Params = params
if db != nil {
c.Set("db", db)
}
h(c)
var bodies []genBody
dec := json.NewDecoder(strings.NewReader(w.Body.String()))
for dec.More() {
var b genBody
if err := dec.Decode(&b); err != nil {
t.Fatalf("decoding %q: %v", w.Body.String(), err)
}
bodies = append(bodies, b)
}
return bodies
}
// genWorkspace is a working directory laid out the way the generator expects
// to find it: the real templates under template/, output written below it.
// Any template can be swapped for one that fails when executed.
func genWorkspace(t *testing.T, broken ...string) string {
t.Helper()
gin.SetMode(gin.TestMode)
root, err := filepath.Abs("../../../..")
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
src := filepath.Join(root, "template")
err = filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
dst := filepath.Join(dir, "template", strings.TrimPrefix(p, src))
if d.IsDir() {
return os.MkdirAll(dst, 0o755)
}
b, err := os.ReadFile(p)
if err != nil {
return err
}
return os.WriteFile(dst, b, 0o644)
})
if err != nil {
t.Fatal(err)
}
for _, name := range broken {
// Parses, then fails on execution: SysTables has no such field.
if err := os.WriteFile(filepath.Join(dir, "template", name), []byte("{{.NoSuchField}}"), 0o644); err != nil {
t.Fatal(err)
}
}
front := config.GenConfig.FrontPath
t.Cleanup(func() { config.GenConfig.FrontPath = front })
config.GenConfig.FrontPath = filepath.Join(dir, "ui")
t.Chdir(dir)
return dir
}
func genTable() tools.SysTables {
return tools.SysTables{
TBName: "gen_err", ClassName: "GenErr", BusinessName: "genErr", PackageName: "admin",
ModuleName: "gen-err", PkColumn: "id", PkGoField: "Id", PkJsonField: "id",
Columns: []tools.SysColumns{
{ColumnName: "id", GoField: "Id", GoType: "int", JsonField: "id", Pk: true, IsPk: "1"},
{ColumnName: "name", GoField: "Name", GoType: "string", JsonField: "name", IsInsert: "1"},
},
}
}
func writtenFiles(t *testing.T, dir string) []string {
t.Helper()
var files []string
for _, top := range []string{"app", "ui"} {
_ = filepath.WalkDir(filepath.Join(dir, top), func(p string, d fs.DirEntry, err error) error {
if err == nil && !d.IsDir() {
files = append(files, p)
}
return nil
})
}
return files
}
// A template that fails on execution used to leave its file empty and every
// other file written, with the request reporting success.
func TestNOActionsGenWritesNothingWhenATemplateFails(t *testing.T) {
dir := genWorkspace(t, "v4/no_actions/service.go.template")
var ok bool
bodies := runGen(t, nil, func(c *gin.Context) { ok = Gen{}.NOActionsGen(c, genTable()) }, nil)
if ok || len(bodies) != 1 || bodies[0].Code != 500 {
t.Fatalf("ok=%v, responses %+v; want false and one 500", ok, bodies)
}
if !strings.Contains(bodies[0].Msg, "service.go.template") {
t.Errorf("message %q does not name the template that failed", bodies[0].Msg)
}
if files := writtenFiles(t, dir); len(files) > 0 {
t.Errorf("wrote %d file(s) although a template failed: %v", len(files), files)
}
}
// pkg.FileCreate, which this used to write through, ends the process with
// log.Fatalln when it cannot create the file. Reaching the assertions at all
// is half of this test.
func TestNOActionsGenReportsAFileItCannotWrite(t *testing.T) {
dir := genWorkspace(t)
blocker := filepath.Join(dir, "app", "admin", "models")
if err := os.MkdirAll(filepath.Dir(blocker), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(blocker, nil, 0o644); err != nil { // a file where a directory must go
t.Fatal(err)
}
var ok bool
bodies := runGen(t, nil, func(c *gin.Context) { ok = Gen{}.NOActionsGen(c, genTable()) }, nil)
if ok || len(bodies) != 1 || bodies[0].Code != 500 {
t.Fatalf("ok=%v, responses %+v; want false and one 500", ok, bodies)
}
// Not a template failing first for some other reason.
if !strings.Contains(bodies[0].Msg, "生成文件写入失败") {
t.Errorf("message %q is not the write failure", bodies[0].Msg)
}
}
func genDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(new(tools.SysTables), new(tools.SysColumns)); err != nil {
t.Fatal(err)
}
return db
}
// GenCode answered the request itself after NOActionsGen had already answered
// it with an error, so the client received the error followed by
// "Code generated successfully".
func TestGenCodeAnswersOnceWhenGenerationFails(t *testing.T) {
genWorkspace(t, "v4/no_actions/service.go.template")
db := genDB(t)
table := genTable()
created, err := table.Create(db)
if err != nil {
t.Fatal(err)
}
bodies := runGen(t, db, Gen{}.GenCode, gin.Params{{Key: "tableId", Value: itoa(created.TableId)}})
if len(bodies) != 1 || bodies[0].Code != 500 {
t.Errorf("responses %+v; want exactly one, a 500", bodies)
}
}