这是一个系列 Blog作者将以一个 PHP 全栈工程师的身份利用 AI 工具claude code、codex、deepseek、豆包等从零开始学习 golang 语言并最终完成 ai-go-admingithub | gitee开源项目的制作全程记录分享。在上一期我们进行了 “前端上传组件实现”本期将完成基类改造基类改造基类严格来讲不是 go 里边的概念在本 blog 中使用它范指 基控制器、基服务、基仓储接下来就是做出第一个后台的管理功能了在此之前对基类进行完善这是一个及其需要细心和周全考虑的工作各大基类的每一行代码都需要是最佳实践扩展性拉满性能拉满。基类改造 前端表格组件一起作者整整花了将近两周时间基类改造情况如下每一个小节一般就是提交给 AI 的一个需求一般一个需要需要多轮对话才能完成然后边修改边人工review最终再完成测试基仓储改造利用 gorm.Statement.Schema 检查字段是否存在必需特意写明利用gorm.Statement.Schema因为直接告诉AI需要一个检查字段是否存在的方法的话它会根据当前模型写一大堆代码预解析出Schema并缓存我测试了一下这种方案还是各大 AI 模型的首选方案实际上当然是没有必要的直接伸手向GORM拿就行了。最终检查字段是否存在方法的签名为func (r *Repository[T]) FieldExists(field string) (bool, error)获取主键字段方法依旧是利用gorm.Statement.Schema获取主键字段调用GORM Statement.Parse方法解析模型数据该函数内部会复用GORM的Schema缓存。复合主键模型返回GORM识别的优先主键字段最终方法签名为func (r *Repository[T]) PrimaryKeyField() (string, error)代码如下// FieldExists 检查当前模型是否包含指定字段// 同时支持 Go 字段名和数据库列名Statement.Parse 会复用 GORM 的 Schema 缓存func(r*Repository[T])FieldExists(fieldstring)(bool,error){fieldstrings.TrimSpace(field)iffield{returnfalse,nil}stmt:gorm.Statement{DB:r.DB()}iferr:stmt.Parse(new(T));err!nil{returnfalse,err}returnstmt.Schema.LookUpField(field)!nil,nil}增加 Count 方法此方法用于获取数据条数签名为func (r *Repository[T]) Count(c *gin.Context, opts Options) (int64, error)使用opts.Scopes忽略opts中的分页、排序、字段选择等。已有方法改造先定义一个所有方法通用的Options结构体// Options 通用仓库操作选项各选项可按需使用typeOptionsstruct{Omit[]string// 排除出入库字段将在 Create、Update、List 等方法中应用至 GORM 的 Select 方法Select[]string// 选择出入库字段其余同上Scopes[]func(*gorm.Statement)// 将在 List 和 Get 等方法中直接传递给 GORM 的 Scopes 方法可以自定义 查询、排序、分页 等等PrimaryKeystring// 主键值提供则会在 Get 方法中作为 Where 的参数}基仓储 List 改造丰富入参和功能如下// 原来的func(r*Repository[T])List(c*gin.Context,scopes...func(*gorm.Statement))([]T,error)// 改为func(r*Repository[T])List(c*gin.Context,opts Options)([]T,error)即将scopes参数改为optsopts里边可以传递scopes并且额外还可以传递排序字段、选择排除字段等选项未来要扩展也很方便List方法内根据opts确定如何读取数据另外对于Select和Omit字段我并没有使用FieldExists方法对字段名逐一确定是否存在开发者需自行对传入的字段名负责程序不会对未知字段报警。基仓储 Create / Update / Get 改造仓储层的Create / Update / Get方法也需要增加opts Options参数并应用上这些配置。在Create / Update方法内Select / Omit配置决定入库字段。在Get方法内不仅可以使用Select / Omit来决定出库字段也支持scopes和PrimaryKey主键值选项若提供了PrimaryKeyGet会拼接类似这样的查询条件Where(pk ?, opts.PrimaryKey)同时并不会忽略scopes。基控制器、基服务改造首先是整体规划的改变比如控制器要支持更多的请求数据本次改造支持了排序、分页、多字段条件查询等特别是服务层的List方法首先看基仓储的List方法它太简单了如下func(r*Repository[T])List(c*gin.Context,opts Options)([]T,error){q:gorm.G[T](r.DB()).Scopes(opts.Scopes...)// 出库字段的选择与忽略iflen(opts.Select)0{qq.Select(strings.Join(opts.Select,,))}iflen(opts.Omit)0{qq.Omit(opts.Omit...)}returnq.Find(c.Request.Context())}可以看到仓储层主要是接受了一个Scopes函数切片这意味着我们前端传递的排序、分页、多字段条件查询等都需要在服务层构建为Scopes然后直接传递给仓储层使用Select和Omit逻辑为和其他方法保持统一并未使用Scopes的方式定义。基控制器接受主键的方式优化旧的主键接受固定接受为int类型如下id,err:strconv.ParseUint(c.Param(id),10,64)新的接受方式首先变量改名为更加通用的pk然后直接接受为string并以string类型直接传递至 服务、仓储 等各层如下pk:c.Param(pk)只在需要做比较、计算时再根据当前模型的主键类型转为 int 即可且一般无需转换因为对于 GORM 来说主键传的是 int 或 string生成的 SQL 都是一样的。服务层 List 方法响应字段增加list接口目前直接响应单个data字段内容是对应模型中的数据需要改为data.list数据、data.total数据条数使用仓储层的Count方法分页支持基控制器额外接受page和limit参数传递给服务层由服务层建立一个Paginate Scopes最终传递给仓储的List方法该方法支持Scopes能直接使用分页器。排序支持基本同上。多字段条件查询支持对应基服务internal/service/base.go的BuildWhereScopes方法最终会返回多个scopes。前端传递的查询条件是这样的{wheres:[{field:username,operator:NOT ILIKE,value:test,}]}BuildWhereScopes会根据以上前端传值组装where实现起来非常简单主要是operator它代表 SQL 运算符号需要支持很多比如,,,LIKE,ILIKE,IS NULL等这里有一个小坑,,这类符号在传输时很可能被转义掉这里需要写一个转换函数如下// GetOperatorByAlias 符号类运算符别名 → SQL 运算符funcGetOperatorByAlias(opstring)string{switchop{caseeq,:returncasene:return!casegt:returncasegte:returncaselt:returncaselte:returndefault:returnop// LIKE、IN、NOT IN 等单词运算符直接透传}}然后前端使用operator: eq、operator: ne等就行了不要使用原始符号。未来还会再次完善支持字段白名单、操作符号白名单、OR 条件等测试IS NOT NULL 无效在人工测试中找到一个review没发现的问题list接口筛选数据时操作符使用IS NOT NULL无效查看了代码发现 AI 居然犯了个很低级的错误switchop{caseIS NULL,IS NOT NULL:scopesappend(scopes,func(stmt*gorm.Statement){stmt.Where(w.Field IS NULL)})}操作符号为IS NULL和IS NOT NULL时都是固定的stmt.Where(w.Field IS NULL)改为stmt.Where(w.Field op)才对。不兼容范围查询switchop{caseBETWEEN:scopesappend(scopes,func(stmt*gorm.Statement){stmt.Where(w.Field BETWEEN ? AND ?,w.Value)})}如上w.Value只有一个但是BETWEEN需要一个范围值解决方案也很简单前端传递数组或简单的以,分割多个值后端这边再切一下即可。