Files
sundynix-go/service/codegen/codegen_service.go
T
2026-04-28 10:29:02 +08:00

980 lines
30 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package codegen
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"text/template"
codegenModel "sundynix-go/model/codegen"
"github.com/glebarez/sqlite"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
type CodegenService struct{}
var CodegenServiceApp = new(CodegenService)
// ----------------------------------------
// 模板:model 实体(继承 global.BaseModel
// ----------------------------------------
const modelTmpl = `package {{.PackageName}}
import "sundynix-go/global"
{{if .HasTimeField}}import "time"
{{end}}
// {{.Feature.Name}} {{.Feature.Comment}}
type {{.Feature.Name}} struct {
global.BaseModel
{{- range .Feature.Fields}}
{{.Name}} {{.Type}} ` + "`" + `gorm:"column:{{.ColumnName}}{{if .GormTag}};{{.GormTag}}{{end}}" json:"{{.JsonTag}}"` + "`" + ` // {{.Comment}}
{{- end}}
{{- range .Feature.Relations}}
{{- if eq .Type "OneToOne"}}
{{.FieldName}} *{{.TargetFeature}} ` + "`" + `gorm:"foreignKey:{{.ForeignKey}}" json:"{{lowerFirst .FieldName}}"` + "`" + `
{{- else if eq .Type "OneToMany"}}
{{.FieldName}} []{{.TargetFeature}} ` + "`" + `gorm:"foreignKey:{{.ForeignKey}}" json:"{{lowerFirst .FieldName}}"` + "`" + `
{{- else if eq .Type "ManyToMany"}}
{{.FieldName}} []{{.TargetFeature}} ` + "`" + `gorm:"many2many:{{.JoinTable}};" json:"{{lowerFirst .FieldName}}"` + "`" + `
{{- end}}
{{- end}}
}
func ({{.Feature.Name}}) TableName() string {
return "{{.Feature.TableName}}"
}
`
// ----------------------------------------
// 模板:request 结构体(每个模块一个文件,包含所有实体的请求结构体)
// ----------------------------------------
const requestTmpl = `package request
{{range .Features}}
// ---- {{.Comment}} ----
// Save{{.Name}}Req 新增{{.Comment}}请求
type Save{{.Name}}Req struct {
{{- range .Fields}}
{{.Name}} {{.Type}} ` + "`" + `json:"{{.JsonTag}}" form:"{{.JsonTag}}"{{if .Required}} binding:"required"{{end}}` + "`" + ` // {{.Comment}}
{{- end}}
}
// Update{{.Name}}Req 更新{{.Comment}}请求
type Update{{.Name}}Req struct {
Id string ` + "`" + `json:"id" binding:"required"` + "`" + ` // ID
{{- range .Fields}}
{{.Name}} {{.Type}} ` + "`" + `json:"{{.JsonTag}}" form:"{{.JsonTag}}"` + "`" + ` // {{.Comment}}
{{- end}}
}
// List{{.Name}}Req 分页查询{{.Comment}}请求
type List{{.Name}}Req struct {
Current int ` + "`" + `json:"current" form:"current"` + "`" + ` // 页码
PageSize int ` + "`" + `json:"pageSize" form:"pageSize"` + "`" + ` // 每页大小
Keyword string ` + "`" + `json:"keyword" form:"keyword"` + "`" + ` // 关键字搜索
}
{{end}}`
// ----------------------------------------
// 模板:service 层(使用 request 包的结构体)
// ----------------------------------------
const serviceTmpl = `package {{.PackageName}}
import (
"sundynix-go/global"
model "sundynix-go/model/{{.PackageName}}"
req "sundynix-go/model/{{.PackageName}}/request"
)
type {{.Feature.Name}}Service struct{}
var {{.Feature.Name}}ServiceApp = new({{.Feature.Name}}Service)
func (s *{{.Feature.Name}}Service) Save(r req.Save{{.Feature.Name}}Req) error {
data := model.{{.Feature.Name}}{
{{- range .Feature.Fields}}
{{.Name}}: r.{{.Name}},
{{- end}}
}
return global.DB.Create(&data).Error
}
func (s *{{.Feature.Name}}Service) Update(r req.Update{{.Feature.Name}}Req) error {
updates := map[string]any{
{{- range .Feature.Fields}}
"{{.ColumnName}}": r.{{.Name}},
{{- end}}
}
return global.DB.Model(&model.{{.Feature.Name}}{}).Where("id = ?", r.Id).Updates(updates).Error
}
func (s *{{.Feature.Name}}Service) Delete(ids []string) error {
return global.DB.Where("id IN ?", ids).Delete(&model.{{.Feature.Name}}{}).Error
}
func (s *{{.Feature.Name}}Service) Detail(id string) (data *model.{{.Feature.Name}}, err error) {
var record model.{{.Feature.Name}}
err = global.DB.Where("id = ?", id).First(&record).Error
return &record, err
}
func (s *{{.Feature.Name}}Service) List(r req.List{{.Feature.Name}}Req) (list []model.{{.Feature.Name}}, total int64, err error) {
if r.PageSize <= 0 {
r.PageSize = 10
}
if r.Current <= 0 {
r.Current = 1
}
db := global.DB.Model(&model.{{.Feature.Name}}{})
if r.Keyword != "" {
db = db.Where("id LIKE ?", "%"+r.Keyword+"%")
}
if err = db.Count(&total).Error; err != nil {
return
}
err = db.Limit(r.PageSize).Offset(r.PageSize * (r.Current - 1)).Find(&list).Error
return
}
`
// ----------------------------------------
// 模板:service enter.go
// ----------------------------------------
const serviceEnterTmpl = `package {{.PackageName}}
type ServiceGroup struct {
{{- range .Features}}
{{.Name}}Service
{{- end}}
}
`
// ----------------------------------------
// 模板:api handler 层(使用 request 包的结构体)
// ----------------------------------------
const apiTmpl = `package {{.PackageName}}
import (
"sundynix-go/global"
"sundynix-go/model/commom/response"
req "sundynix-go/model/{{.PackageName}}/request"
"sundynix-go/service"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
type {{.Feature.Name}}Api struct{}
var {{.LowerName}}Service = service.ServiceGroupApp.{{.PascalModuleName}}ServiceGroup.{{.Feature.Name}}Service
// Save
// @Tags {{.Feature.Comment}}管理
// @Summary 新增{{.Feature.Comment}}
// @Security BasicAuth
// @accept json
// @Produce json
// @Param data body req.Save{{.Feature.Name}}Req true "新增{{.Feature.Comment}}参数"
// @Success 200 {object} response.Response "新增成功"
// @Router /{{.RouterPrefix}}/save [post]
func (a *{{.Feature.Name}}Api) Save(c *gin.Context) {
var r req.Save{{.Feature.Name}}Req
if err := c.ShouldBindJSON(&r); err != nil {
response.FailWithMsg("参数错误:"+err.Error(), c)
return
}
if err := {{.LowerName}}Service.Save(r); err != nil {
global.Logger.Error("新增{{.Feature.Comment}}失败!", zap.Error(err))
response.FailWithMsg(err.Error(), c)
return
}
response.OkWithMsg("新增成功", c)
}
// Update
// @Tags {{.Feature.Comment}}管理
// @Summary 更新{{.Feature.Comment}}
// @Security BasicAuth
// @accept json
// @Produce json
// @Param data body req.Update{{.Feature.Name}}Req true "更新{{.Feature.Comment}}参数"
// @Success 200 {object} response.Response "更新成功"
// @Router /{{.RouterPrefix}}/update [post]
func (a *{{.Feature.Name}}Api) Update(c *gin.Context) {
var r req.Update{{.Feature.Name}}Req
if err := c.ShouldBindJSON(&r); err != nil {
response.FailWithMsg("参数错误:"+err.Error(), c)
return
}
if err := {{.LowerName}}Service.Update(r); err != nil {
global.Logger.Error("更新{{.Feature.Comment}}失败!", zap.Error(err))
response.FailWithMsg(err.Error(), c)
return
}
response.OkWithMsg("更新成功", c)
}
// Delete
// @Tags {{.Feature.Comment}}管理
// @Summary 删除{{.Feature.Comment}}(支持批量)
// @Security BasicAuth
// @accept json
// @Produce json
// @Param data body object{ids=[]string} true "id列表"
// @Success 200 {object} response.Response "删除成功"
// @Router /{{.RouterPrefix}}/delete [post]
func (a *{{.Feature.Name}}Api) Delete(c *gin.Context) {
var req struct {
Ids []string ` + "`" + `json:"ids" binding:"required,min=1"` + "`" + `
}
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMsg("参数错误:"+err.Error(), c)
return
}
if err := {{.LowerName}}Service.Delete(req.Ids); err != nil {
global.Logger.Error("删除{{.Feature.Comment}}失败!", zap.Error(err))
response.FailWithMsg(err.Error(), c)
return
}
response.OkWithMsg("删除成功", c)
}
// Detail
// @Tags {{.Feature.Comment}}管理
// @Summary 获取{{.Feature.Comment}}详情
// @Security BasicAuth
// @Produce json
// @Param id query string true "ID"
// @Success 200 {object} response.Response "获取详情成功"
// @Router /{{.RouterPrefix}}/detail [get]
func (a *{{.Feature.Name}}Api) Detail(c *gin.Context) {
id := c.Query("id")
data, err := {{.LowerName}}Service.Detail(id)
if err != nil {
global.Logger.Error("获取{{.Feature.Comment}}详情失败!", zap.Error(err))
response.FailWithMsg(err.Error(), c)
return
}
response.OkWithData(data, c)
}
// List
// @Tags {{.Feature.Comment}}管理
// @Summary 分页获取{{.Feature.Comment}}列表
// @Security BasicAuth
// @accept json
// @Produce json
// @Param data body req.List{{.Feature.Name}}Req true "分页参数"
// @Success 200 {object} response.Response{data=response.PageResult} "获取列表成功"
// @Router /{{.RouterPrefix}}/list [post]
func (a *{{.Feature.Name}}Api) List(c *gin.Context) {
var r req.List{{.Feature.Name}}Req
if err := c.ShouldBindJSON(&r); err != nil {
response.FailWithMsg("参数错误:"+err.Error(), c)
return
}
list, total, err := {{.LowerName}}Service.List(r)
if err != nil {
global.Logger.Error("获取{{.Feature.Comment}}列表失败!", zap.Error(err))
response.FailWithMsg(err.Error(), c)
return
}
response.OkWithData(response.PageResult{
List: list,
Total: total,
Page: r.Current,
PageSize: r.PageSize,
}, c)
}
`
// ----------------------------------------
// 模板:api enter.go
// ----------------------------------------
const apiEnterTmpl = `package {{.PackageName}}
type ApiGroup struct {
{{- range .Features}}
{{.Name}}Api
{{- end}}
}
`
// ----------------------------------------
// 模板:router
// ----------------------------------------
const routerTmpl = `package {{.PackageName}}
import "github.com/gin-gonic/gin"
type {{.Feature.Name}}Router struct{}
func (r *{{.Feature.Name}}Router) Init{{.Feature.Name}}Router(Router *gin.RouterGroup) {
{{.LowerName}}Router := Router.Group("{{.RouterGroup}}")
{
{{.LowerName}}Router.POST("save", {{.LowerName}}Api.Save)
{{.LowerName}}Router.POST("update", {{.LowerName}}Api.Update)
{{.LowerName}}Router.POST("delete", {{.LowerName}}Api.Delete)
{{.LowerName}}Router.GET("detail", {{.LowerName}}Api.Detail)
{{.LowerName}}Router.POST("list", {{.LowerName}}Api.List)
}
}
`
// ----------------------------------------
// 模板:router enter.go
// ----------------------------------------
const routerEnterTmpl = `package {{.PackageName}}
import v1 "sundynix-go/api/v1"
type RouterGroup struct {
{{- range .Features}}
{{.Name}}Router
{{- end}}
}
var (
{{- range .Features}}
{{lowerFirst .Name}}Api = v1.ApiGroupApp.{{.PascalModuleName}}ApiGroup.{{.Name}}Api
{{- end}}
)
`
// ----------------------------------------
// 前端模板:TypeScript 类型定义
// ----------------------------------------
const feTsTypeTmpl = `// {{.Module.Name}} 模块类型定义(自动生成,请勿手动修改)
import type { BaseModel } from '@/types'
{{range .Features}}
// ---- {{.Comment}} ----
/** {{.Comment}}实体 */
export interface {{.Name}} extends BaseModel {
{{- range .Fields}}
{{.JsonTag}}: {{goTypeToTs .Type}} // {{.Comment}}
{{- end}}
}
/** 新增{{.Comment}}请求 */
export interface Save{{.Name}}Req {
{{- range .Fields}}
{{.JsonTag}}{{if not .Required}}?{{end}}: {{goTypeToTs .Type}} // {{.Comment}}
{{- end}}
}
/** 更新{{.Comment}}请求 */
export interface Update{{.Name}}Req {
id: string
{{- range .Fields}}
{{.JsonTag}}?: {{goTypeToTs .Type}} // {{.Comment}}
{{- end}}
}
/** 分页查询{{.Comment}}请求 */
export interface List{{.Name}}Req {
current?: number
pageSize?: number
keyword?: string
}
{{end}}`
// ----------------------------------------
// 前端模板:API service (Axios)
// ----------------------------------------
const feApiTmpl = `import { get, post } from '@/lib/request'
import type {
{{.Feature.Name}},
Save{{.Feature.Name}}Req,
Update{{.Feature.Name}}Req,
List{{.Feature.Name}}Req,
} from '@/types/{{.Module.PackageName}}'
import type { PageResult } from '@/types'
const BASE = '/{{.Module.PackageName}}/{{.SnakeName}}'
export const {{lowerFirst .Feature.Name}}Api = {
save: (data: Save{{.Feature.Name}}Req) =>
post<void>(` + "`" + `${BASE}/save` + "`" + `, data),
update: (data: Update{{.Feature.Name}}Req) =>
post<void>(` + "`" + `${BASE}/update` + "`" + `, data),
delete: (ids: string[]) =>
post<void>(` + "`" + `${BASE}/delete` + "`" + `, { ids }),
detail: (id: string) =>
get<{{.Feature.Name}}>(` + "`" + `${BASE}/detail` + "`" + `, { id }),
list: (params: List{{.Feature.Name}}Req) =>
post<PageResult<{{.Feature.Name}}>>(` + "`" + `${BASE}/list` + "`" + `, params),
}
`
// ----------------------------------------
// 前端模板:React 管理页面
// ----------------------------------------
const fePageTmpl = `import { useState, useEffect } from 'react'
import { {{lowerFirst .Feature.Name}}Api } from '@/api/{{.Module.PackageName}}/{{.SnakeName}}'
import type { {{.Feature.Name}}, List{{.Feature.Name}}Req } from '@/types/{{.Module.PackageName}}'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent } from '@/components/ui/card'
import { toast } from 'sonner'
import { Plus, Pencil, Trash2, Search, RefreshCw } from 'lucide-react'
export default function {{.Feature.Name}}Page() {
const [list, setList] = useState<{{.Feature.Name}}[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(false)
const [params, setParams] = useState<List{{.Feature.Name}}Req>({ current: 1, pageSize: 10, keyword: '' })
const load = async () => {
setLoading(true)
try {
const res = await {{lowerFirst .Feature.Name}}Api.list(params)
setList(res.list)
setTotal(res.total)
} catch {}
finally { setLoading(false) }
}
useEffect(() => { load() }, [params.current])
const handleDelete = async (id: string) => {
if (!confirm('确认删除?')) return
try {
await {{lowerFirst .Feature.Name}}Api.delete([id])
toast.success('删除成功')
load()
} catch {}
}
const totalPages = Math.ceil(total / (params.pageSize ?? 10))
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold">{{.Feature.Comment}}管理</h2>
<p className="text-sm text-muted-foreground">共 {total} 条记录</p>
</div>
<Button size="sm" className="gap-1"><Plus className="h-4 w-4" /> 新增</Button>
</div>
<Card>
<CardContent className="pt-4">
<div className="flex gap-2">
<Input
placeholder="关键字搜索"
value={params.keyword}
onChange={e => setParams(p => ({ ...p, keyword: e.target.value, current: 1 }))}
className="max-w-60"
/>
<Button variant="outline" size="sm" onClick={load}><Search className="h-4 w-4 mr-1" />搜索</Button>
<Button variant="ghost" size="sm" onClick={() => setParams(p => ({ ...p, keyword: '', current: 1 }))}>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-0">
<table className="w-full text-sm">
<thead className="border-b bg-muted/40">
<tr>
{{- range .Feature.Fields}}{{if .Comment}}
<th className="px-4 py-3 text-left font-medium text-muted-foreground">{{.Comment}}</th>
{{- end}}{{end}}
<th className="px-4 py-3 text-left font-medium text-muted-foreground">创建时间</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">操作</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={ {{- .ColCount -}} } className="py-12 text-center text-muted-foreground">加载中…</td></tr>
) : list.length === 0 ? (
<tr><td colSpan={ {{- .ColCount -}} } className="py-12 text-center text-muted-foreground">暂无数据</td></tr>
) : list.map(item => (
<tr key={item.id} className="border-b last:border-b-0 hover:bg-muted/30 transition-colors">
{{- range .Feature.Fields}}{{if .JsonTag}}<td className="px-4 py-3">{String(item.{{.JsonTag}} ?? '—')}</td>{{end}}{{end}}
<td className="px-4 py-3 text-xs text-muted-foreground">{new Date(item.createdAt).toLocaleString()}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7"><Pencil className="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => handleDelete(item.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{totalPages > 1 && (
<div className="flex items-center justify-between border-t px-4 py-3">
<span className="text-xs text-muted-foreground">第 {params.current} / {totalPages} 页</span>
<div className="flex gap-1">
<Button variant="outline" size="sm" disabled={(params.current ?? 1) <= 1}
onClick={() => setParams(p => ({ ...p, current: (p.current ?? 1) - 1 }))}>上一页</Button>
<Button variant="outline" size="sm" disabled={(params.current ?? 1) >= totalPages}
onClick={() => setParams(p => ({ ...p, current: (p.current ?? 1) + 1 }))}>下一页</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
)
}
`
// ----------------------------------------
// 辅助函数
// ----------------------------------------
func lowerFirst(s string) string {
if s == "" {
return s
}
return strings.ToLower(s[:1]) + s[1:]
}
func hasTimeField(feature codegenModel.Feature) bool {
for _, f := range feature.Fields {
if f.Type == "time.Time" {
return true
}
}
return false
}
// goTypeToTs 将 Go 类型映射为 TypeScript 类型
func goTypeToTs(goType string) string {
switch goType {
case "int", "int8", "int16", "int32", "int64",
"uint", "uint8", "uint16", "uint32", "uint64",
"float32", "float64":
return "number"
case "bool":
return "boolean"
case "time.Time":
return "string"
default:
return "string"
}
}
// ----------------------------------------
// TestConnection 测试数据库连接
// ----------------------------------------
func (s *CodegenService) TestConnection(cfg codegenModel.DbConfig) error {
_, err := openDB(cfg)
return err
}
func openDB(cfg codegenModel.DbConfig) (*gorm.DB, error) {
switch strings.ToLower(cfg.DbType) {
case "mysql":
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.DbName)
return gorm.Open(mysql.Open(dsn), &gorm.Config{})
case "postgres", "postgresql":
dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable",
cfg.Host, cfg.User, cfg.Password, cfg.DbName, cfg.Port)
return gorm.Open(postgres.Open(dsn), &gorm.Config{})
case "sqlite":
return gorm.Open(sqlite.Open(cfg.DbName), &gorm.Config{})
default:
return nil, fmt.Errorf("不支持的数据库类型: %s", cfg.DbType)
}
}
// ----------------------------------------
// Preview 预览生成代码
// ----------------------------------------
func (s *CodegenService) Preview(config codegenModel.GenConfig) ([]codegenModel.PreviewFile, error) {
backendFiles, err := s.renderAll(config, config.OutputDir)
if err != nil {
return nil, err
}
if config.FrontendOutputDir != "" {
feFiles, err := s.renderFrontend(config)
if err != nil {
return nil, err
}
backendFiles = append(backendFiles, feFiles...)
}
return backendFiles, nil
}
// ----------------------------------------
// Generate 生成并写入文件
// ----------------------------------------
func (s *CodegenService) Generate(config codegenModel.GenConfig) (*codegenModel.GenResult, error) {
outputDir := config.OutputDir
if outputDir == "" {
cwd, _ := os.Getwd()
outputDir = cwd
}
// 1. 渲染后端文件
backendFiles, err := s.renderAll(config, outputDir)
if err != nil {
return nil, err
}
// 2. 自动注册(必须在写入文件之前,否则 enter.go 已存在导致误判为已有模块)
regMsg := ""
if err := s.autoRegister(outputDir, config); err != nil {
regMsg = fmt.Sprintf("⚠️ 自动注册失败: %s", err.Error())
} else {
regMsg = "✅ 自动注册成功(gorm/router/enter"
}
// 3. 写入后端文件(增量模式下跳过已存在文件)
written, skipped := writeFilesIncremental(outputDir, backendFiles, config.Overwrite)
allFiles := backendFiles
// 4. 生成前端文件(如果配置了前端输出目录)
if config.FrontendOutputDir != "" {
feFiles, err := s.renderFrontend(config)
if err != nil {
return nil, err
}
w, sk := writeFilesIncremental(config.FrontendOutputDir, feFiles, config.Overwrite)
written += w
skipped += sk
allFiles = append(allFiles, feFiles...)
}
return &codegenModel.GenResult{
OutputDir: outputDir,
Files: allFiles,
Message: fmt.Sprintf("写入 %d 个文件,跳过 %d 个已存在文件。%s", written, skipped, regMsg),
}, nil
}
// writeFilesIncremental 写入文件,支持增量模式
// overwrite=false 时,已存在的文件会被跳过
// 返回 (写入数, 跳过数)
func writeFilesIncremental(baseDir string, files []codegenModel.PreviewFile, overwrite bool) (written int, skipped int) {
for _, f := range files {
fullPath := filepath.Join(baseDir, f.FilePath)
// 增量模式下跳过已存在文件(但 AlwaysOverwrite 的聚合文件始终覆盖)
if !overwrite && !f.AlwaysOverwrite {
if _, err := os.Stat(fullPath); err == nil {
skipped++
continue
}
}
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
continue
}
if err := os.WriteFile(fullPath, []byte(f.Content), 0644); err != nil {
continue
}
written++
}
return
}
// renderFrontend 渲染前端 TypeScript / React 文件
func (s *CodegenService) renderFrontend(config codegenModel.GenConfig) ([]codegenModel.PreviewFile, error) {
var result []codegenModel.PreviewFile
funcMap := template.FuncMap{
"lowerFirst": lowerFirst,
"goTypeToTs": goTypeToTs,
"not": func(b bool) bool { return !b },
}
for _, module := range config.Modules {
// 跳过未填写包名的模块
if strings.TrimSpace(module.PackageName) == "" || strings.TrimSpace(module.Name) == "" {
continue
}
// ---- TypeScript 类型(模块级,所有实体合并到 index.ts)----
tsData := struct {
Module codegenModel.Module
Features []codegenModel.Feature
}{module, module.Features}
tsContent, err := renderTmpl("fe_ts_type", feTsTypeTmpl, funcMap, tsData)
if err != nil {
return nil, err
}
result = append(result, codegenModel.PreviewFile{
FilePath: filepath.Join("src", "types", module.PackageName, "index.ts"),
Content: tsContent,
AlwaysOverwrite: true,
})
for _, feature := range module.Features {
// 跳过未命名的实体
if strings.TrimSpace(feature.Name) == "" {
continue
}
snakeName := toSnake(feature.Name)
feData := struct {
Module codegenModel.Module
Feature codegenModel.Feature
SnakeName string
ColCount int
}{module, feature, snakeName, len(feature.Fields) + 2}
// API service
apiContent, err := renderTmpl("fe_api", feApiTmpl, funcMap, feData)
if err != nil {
return nil, err
}
result = append(result, codegenModel.PreviewFile{
FilePath: filepath.Join("src", "api", module.PackageName, snakeName+".ts"),
Content: apiContent,
})
// React page
pageContent, err := renderTmpl("fe_page", fePageTmpl, funcMap, feData)
if err != nil {
return nil, err
}
result = append(result, codegenModel.PreviewFile{
FilePath: filepath.Join("src", "pages", module.PackageName, feature.Name+"Page.tsx"),
Content: pageContent,
})
}
}
return result, nil
}
// ----------------------------------------
// renderAll 渲染所有模块的所有文件
// ----------------------------------------
func (s *CodegenService) renderAll(config codegenModel.GenConfig, outputDir string) ([]codegenModel.PreviewFile, error) {
var result []codegenModel.PreviewFile
funcMap := template.FuncMap{
"lowerFirst": lowerFirst,
}
for _, module := range config.Modules {
// 跳过未填写包名的模块
if strings.TrimSpace(module.PackageName) == "" || strings.TrimSpace(module.Name) == "" {
continue
}
pkg := module.PackageName
pascalModule := module.Name
type featureEntry struct {
Name string
PascalModuleName string
}
// 发现已存在的 feature(扫描 service/{pkg}/ 下的 *_service.go 文件)
existingFeatures := discoverExistingFeatures(outputDir, pkg)
// 合并:已有 feature + 当前配置中的 feature(去重)
featureSet := make(map[string]bool)
var allFeatureEntries []featureEntry
for _, name := range existingFeatures {
if !featureSet[name] {
featureSet[name] = true
allFeatureEntries = append(allFeatureEntries, featureEntry{name, pascalModule})
}
}
for _, f := range module.Features {
if strings.TrimSpace(f.Name) == "" {
continue
}
if !featureSet[f.Name] {
featureSet[f.Name] = true
allFeatureEntries = append(allFeatureEntries, featureEntry{f.Name, pascalModule})
}
}
enterData := struct {
PackageName string
Features []featureEntry
}{pkg, allFeatureEntries}
// service/enter.go(始终覆盖,包含所有已有+新增 feature)
svcEnter, err := renderTmpl("service_enter", serviceEnterTmpl, funcMap, enterData)
if err != nil {
return nil, err
}
result = append(result, codegenModel.PreviewFile{
FilePath: filepath.Join("service", pkg, "enter.go"),
Content: svcEnter,
AlwaysOverwrite: true,
})
// api/v1/enter.go
apiEnter, err := renderTmpl("api_enter", apiEnterTmpl, funcMap, enterData)
if err != nil {
return nil, err
}
result = append(result, codegenModel.PreviewFile{
FilePath: filepath.Join("api", "v1", pkg, "enter.go"),
Content: apiEnter,
AlwaysOverwrite: true,
})
// router/enter.go
routerEnter, err := renderTmpl("router_enter", routerEnterTmpl, funcMap, enterData)
if err != nil {
return nil, err
}
result = append(result, codegenModel.PreviewFile{
FilePath: filepath.Join("router", pkg, "enter.go"),
Content: routerEnter,
AlwaysOverwrite: true,
})
// ---- request 结构体(按 feature 分文件,增量安全)----
for _, feature := range module.Features {
if strings.TrimSpace(feature.Name) == "" {
continue
}
reqData := struct {
PackageName string
Features []codegenModel.Feature
}{pkg, []codegenModel.Feature{feature}}
reqContent, err := renderTmpl("request", requestTmpl, funcMap, reqData)
if err != nil {
return nil, err
}
result = append(result, codegenModel.PreviewFile{
FilePath: filepath.Join("model", pkg, "request", toSnake(feature.Name)+"_request.go"),
Content: reqContent,
})
}
// 每个 Feature 生成:model / service / api / router
for _, feature := range module.Features {
// 跳过未命名的实体
if strings.TrimSpace(feature.Name) == "" {
continue
}
lower := lowerFirst(feature.Name)
snakeName := toSnake(feature.Name)
routerGroup := snakeName
routerPrefix := pkg + "/" + snakeName
// ---- model ----
modelData := struct {
PackageName string
Feature codegenModel.Feature
HasTimeField bool
}{pkg, feature, hasTimeField(feature)}
modelContent, err := renderTmpl("model", modelTmpl, funcMap, modelData)
if err != nil {
return nil, err
}
result = append(result, codegenModel.PreviewFile{
FilePath: filepath.Join("model", pkg, snakeName+".go"),
Content: modelContent,
})
// ---- service ----
svcData := struct {
PackageName string
Feature codegenModel.Feature
}{pkg, feature}
svcContent, err := renderTmpl("service", serviceTmpl, funcMap, svcData)
if err != nil {
return nil, err
}
result = append(result, codegenModel.PreviewFile{
FilePath: filepath.Join("service", pkg, snakeName+"_service.go"),
Content: svcContent,
})
// ---- api handler ----
apiData := struct {
PackageName string
Feature codegenModel.Feature
LowerName string
RouterPrefix string
PascalModuleName string
}{pkg, feature, lower, routerPrefix, pascalModule}
apiContent, err := renderTmpl("api", apiTmpl, funcMap, apiData)
if err != nil {
return nil, err
}
result = append(result, codegenModel.PreviewFile{
FilePath: filepath.Join("api", "v1", pkg, snakeName+".go"),
Content: apiContent,
})
// ---- router ----
routerData := struct {
PackageName string
Feature codegenModel.Feature
LowerName string
RouterGroup string
}{pkg, feature, lower, routerGroup}
routerContent, err := renderTmpl("router", routerTmpl, funcMap, routerData)
if err != nil {
return nil, err
}
result = append(result, codegenModel.PreviewFile{
FilePath: filepath.Join("router", pkg, snakeName+"_router.go"),
Content: routerContent,
})
}
}
return result, nil
}
func renderTmpl(name, tmplStr string, funcMap template.FuncMap, data any) (string, error) {
t, err := template.New(name).Funcs(funcMap).Parse(tmplStr)
if err != nil {
return "", fmt.Errorf("解析模板 [%s] 失败: %w", name, err)
}
var buf bytes.Buffer
if err = t.Execute(&buf, data); err != nil {
return "", fmt.Errorf("渲染模板 [%s] 失败: %w", name, err)
}
return buf.String(), nil
}
// toSnake 将 PascalCase 转为 snake_case
func toSnake(s string) string {
var result []rune
for i, r := range s {
if r >= 'A' && r <= 'Z' {
if i > 0 {
result = append(result, '_')
}
result = append(result, r+32)
} else {
result = append(result, r)
}
}
return string(result)
}