The budget is one number in config/settings.yml. The deadlines that have to cover it are in four other files, none of which anybody edits while thinking about shutdown - so raising the budget passes every test, deploys, and has the cleanup callbacks killed on the next release. Two checks share one arithmetic and one five-second margin. shutdown-budget-overruns-grace compares preStop + drain + server + cleanup against terminationGracePeriodSeconds in the shipped manifest. Those two files are not merely adjacent examples: scripts/k8s/prerun.sh builds the settings-admin ConfigMap out of config/settings.yml and the Deployment mounts it, so the manifest deploys that file. docker-stop-cuts-shutdown-short covers the three ways this container is stopped: `docker stop` in the release workflow, the same in the Makefile, and stop_grace_period on a compose service that runs this repository's own image. A service running a database is not this process and is left alone. The duration is parsed rather than scanned for digits - compose accepts 1m30s, and reading the first number out of it would call ninety seconds one. All three spellings of the deadline are read: --timeout, the deprecated --time, and the short -t. A deadline the check cannot read is reported as no deadline at all, so recognising only one of them would call a correct command broken and send whoever fixed it towards the spelling docker is retiring. The message quotes the flag back in the spelling it was written in, for the same reason: suggesting a flag the line does not use is how a tool teaches people to disbelieve it. What neither covers is `docker rm -f`, which has no deadline to compare against: it is SIGKILL by definition. That gap is deliberate, and it is why the previous commit changed the one place that used it on a container that might still be running. Both report at two levels. A budget that already overruns is an ERROR; one that fits with nothing to spare is a WARN, because it works today and failing the build on a working configuration is how a project teaches people to ignore its warnings. The two are exclusive: an overrun satisfies the headroom condition as well, and an ERROR that always drags a duplicate WARN behind it teaches the same lesson. preStop is in the sum although the shipped manifest has no hook. That is the point - a hook added later is spent before the process is told anything, and a self-check that could not see it would understate the real budget by however long somebody set it to, which is worse than not checking. A hook whose duration cannot be read is reported rather than counted as zero. The fallbacks for fields the settings file leaves out are read from the constants in the scanned tree, not copied here; if they are renamed the run stops instead of going quiet with the wrong numbers. The wording differs by audience on purpose. At run time this is somebody else's deployment under constraints the process cannot see, so the log states a minimum. These checks read files this repository owns, where there is standing to ask for headroom, so they name a target. The table in AGENTS.md is relisted while it is being touched: the two new checks, plus datascope-route-unguarded, which has been missing since it was added. The hard-coded count is gone - it said seven and there were ten, which is what a written-down count does. AGENTS.md and docs/contract.md both sent readers to `go run ./tools/checksilent -h` for the list of checks; that prints command-line flags and has never printed a check, so both now point at runChecks. The yaml parser moves from an indirect requirement to a direct one - it was already in the module graph - and tidy drops four go.sum lines left over from two older releases of core.
12 KiB
AGENTS.md — go-admin 后端
给 AI 编码工具与新贡献者的约定。只写"不遵守就会出错"的规则;技术栈版本以
go.mod为准,命令以Makefile为准,此处不复述,避免与代码脱节。标准 CRUD 模块的完整写法见
app/demo/—— 那是可编译、有测试、CI 会跑的参照物。 本文与它冲突时,以app/demo/为准。
分层
Router → Api → Service → Model
路由注册 参数绑定 业务逻辑 GORM 结构体
中间件链 调用 Service 操作数据库 TableName()
对应目录:app/{模块}/router|apis|service|models,DTO 位于 service/dto。
不可跨层:Api 不直接操作 Orm,Service 不接触 gin.Context。
优先使用通用 Action
单表 CRUD 不要手写 Handler 与 Service。common/actions 提供的五个
Action 已覆盖参数绑定、数据权限过滤、操作人注入、分页与错误响应:
r := v1.Group("/demo-product").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
{
m := &models.DemoProduct{}
r.GET("", actions.PermissionAction(), actions.IndexAction(m, new(dto.DemoProductSearch), func() interface{} {
list := make([]models.DemoProduct, 0); return &list
}))
r.GET("/:id", actions.PermissionAction(), actions.ViewAction(new(dto.DemoProductById), func() interface{} {
return &models.DemoProduct{}
}))
r.POST("", actions.CreateAction(new(dto.DemoProductControl)))
r.PUT("/:id", actions.PermissionAction(), actions.UpdateAction(new(dto.DemoProductControl)))
r.DELETE("", actions.PermissionAction(), actions.DeleteAction(new(dto.DemoProductById)))
}
这样一个模块只需 model + dto + router 三个文件,完整示例见 app/demo/。
使用通用 Action 的前提:
- Model 实现
models.ActiveRecord(Generate/GetId/TableName) - 列表 DTO 实现
dto.Index,增改删 DTO 实现dto.Control - 所有
Generate()必须返回副本 —— Action 在并发请求间复用实例, 就地返回会串数据(app/demo的测试锁定了这一点) - 详情/删除 DTO 内嵌
dto.ObjectById即可继承Bind与GetId,无需重写
仅当业务超出单表 CRUD(跨表事务、外部调用、复杂校验)时才自行编写 Handler 与 Service,写法见下。
Api 层(仅在通用 Action 不适用时)
结构体嵌入 api.Api,链式初始化后必须检查 Errors:
func (e SysPost) GetPage(c *gin.Context) {
s := service.SysPost{}
req := dto.SysPostPageReq{}
err := e.MakeContext(c).MakeOrm().Bind(&req, binding.Form).MakeService(&s.Service).Errors
if err != nil {
e.Logger.Error(err)
e.Error(500, err, err.Error())
return
}
// ... 调用 s.GetPage(...)
e.PageOK(list, int(count), req.GetPageIndex(), req.GetPageSize(), "查询成功")
}
响应一律走 e.OK / e.PageOK / e.Error,不要自行 c.JSON。
Service 层(仅在通用 Action 不适用时)
结构体嵌入 service.Service(持有 Orm 与 Log)。查询通过 Scopes 组合:
err = e.Orm.Model(&data).Scopes(
cDto.MakeCondition(c.GetNeedSearch()), // 由 search tag 生成 WHERE
cDto.Paginate(c.GetPageSize(), c.GetPageIndex()),
actions.Permission(data.TableName(), p), // 数据权限,列表/详情必须带
).Find(list).Limit(-1).Offset(-1).Count(count).Error
遗漏 actions.Permission 会使数据权限配置静默失效 —— 这是最容易出的错。
错误一律 return err 向上传递,日志用 e.Log.Errorf,不使用 panic。
DTO
搜索条件由 tag 声明,MakeCondition 据此拼 SQL:
type SysPostPageReq struct {
dto.Pagination `search:"-"`
PostName string `form:"postName" search:"type:contains;column:post_name;table:sys_post"`
}
func (m *SysPostPageReq) GetNeedSearch() interface{} { return *m }
type 可选:exact iexact contains gt gte lt lte order left(联表)。
Model
type SysPost struct {
PostId int `gorm:"primaryKey;autoIncrement" json:"postId"`
// ... 业务字段
models.ControlBy // CreateBy / UpdateBy
models.ModelTime // CreatedAt / UpdatedAt / DeletedAt
}
func (SysPost) TableName() string { return "sys_post" }
TableName() 必须显式声明(GORM 配置了 SingularTable,不会自动推导复数)。
公共契约面
第三方应用(app/ 下的业务模块)可以稳定依赖哪些包、路由与迁移怎么注册、
哪些约束是硬的,见 docs/contract.md。
两条与主仓贡献者直接相关的:
common/、core/不得 importapp/——make checksilent在 CI 里守着,违反即红。- 从 core 契约包声明出来的类型必须写成别名(
type X = pkg.Y,不是type X pkg.Y) ——contract-shim-alias检查守着。defined type 会丢掉整个方法集, 而且不一定在本仓编译失败,理由见docs/contract.md末节。 - 注册类 API(
AppRouters/sdk.Runtime.SetAppRouters/migration.ForApp) 必须在runStartupHooks()之前调用完 ——init()是最省事的位置, 但约束的是顺序,不是写在哪个函数里;晚到的注册会被丢弃并只记一条 ERROR。
路由注册
通过 init() 自注册,不在中心文件手工添加:
func init() { routerCheckRole = append(routerCheckRole, registerSysPostRouter) }
func registerSysPostRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
api := apis.SysPost{}
r := v1.Group("/post").
Use(authMiddleware.MiddlewareFunc()).
Use(middleware.AuthCheckRole()). // Casbin 鉴权
Use(actions.PermissionAction()) // 注入数据权限
{ r.GET("", api.GetPage); r.POST("", api.Insert); /* ... */ }
}
新增路由文件后,需确认 cmd/api/ 中已用 _ 导入该包。
命名
| 对象 | 规则 | 示例 |
|---|---|---|
| 数据表 | sys_ 前缀 + 下划线 |
sys_post |
| API 路径 | /api/v1/ + kebab-case |
/api/v1/sys-user |
| DTO | {Model}{Action}Req |
SysPostPageReq |
| 权限标识 | 模块:资源:操作 |
admin:sysPost:add |
权限标识需与前端 v-permisaction 一致,并写入 sys_menu 种子数据——完整可运行的
参照见 cmd/migrate/migration/version/1786700001000_demo_menu.go(sys_api /
sys_menu / sys_menu_api_rule / casbin_rule 四张表如何配齐,用的是幂等 upsert,
可以直接照抄结构)。
Swagger
Handler 必须带完整注解,go generate 会据此生成文档:
// @Summary 岗位列表
// @Tags 岗位
// @Success 200 {object} response.Response
// @Router /api/v1/post [get]
// @Security Bearer
本地运行
配置 driver: sqlite3 时必须带构建标签,否则启动即 panic:
go run -tags sqlite3 . migrate -c config/settings.sqlite.yml
go run -tags sqlite3 . server -c config/settings.sqlite.yml
原因:common/database/open.go 带 //go:build !sqlite3,不加标签时编进的是
不含 sqlite3 的版本,opens["sqlite3"] 为 nil,调用时在 nil 函数上崩溃。
报错信息不会提到构建标签,容易误判成环境损坏。MySQL / PostgreSQL 无此问题。
对应 Makefile 的 build-sqlite 目标。
数据库迁移
文件名前 13 位为毫秒时间戳版本号,不合规的名字会在启动时 panic 并报出该文件名。
已执行过的迁移文件不可修改 ——
sys_migration 表按版本号去重,改动不会重跑,只能新增一个迁移来修正。
放哪个目录取决于身份:
| 目录 | 用途 | 是否入库 |
|---|---|---|
version/ |
框架自带迁移,随仓库分发给所有使用者 | 是 |
version-local/ |
使用者自己项目的迁移 | 否(已在 .gitignore) |
向本仓库提交迁移必须放 version/ —— 放进 version-local/ 会被忽略掉,
git status 看不到,PR 里也不会出现。两个目录的包名分别是 version 与
version_local(后者与目录名不一致,因为标识符不能含连字符)。
写种子数据用哪个 models 包
1786700003000 之后新增的迁移,种子数据要用 app/ 下的运行时模型
(如 app/admin/models.SysApi、SysMenu),不要用 cmd/migrate/migration/models。
后者的 ModelTime 声明的是可空的 gorm.DeletedAt,这对它之前的迁移是对的(那正是
当时列的形状),转换之后就不再成立,两个方向都会出问题:
- 写:往 NOT NULL 列里塞 NULL,第一条 insert 就
NOT NULL constraint failed - 读:GORM 拼
WHERE deleted_at IS NULL,而活跃行存的是0,静默查不到—— 照抄demo_menu.go的授权段落会因此跳过授权,菜单建好、权限没授、迁移仍记为成功
干净库跑不出这个问题,今天所有用该包的迁移都排在转换之前。完整推导见
schema_coverage_test.go 里 TestPostConversionMigrationsAvoidFrozenSeedModels
的注释,那个测试也守着这条边界。
静默失败校验
make checksilent 逐条检查那些不报错、不记日志、行为悄悄变得不对的问题,
CI 会跑,命中 ERROR 即失败。这里不写条数——写死的数字会悄悄过时,
真正的清单是 tools/checksilent/checks.go 里 runChecks 跑的那几个:
| 检查 | 级别 | 静默后果 |
|---|---|---|
modeltime-mix |
ERROR | 两个 ModelTime 混用,整张表查不到数据 |
menu-sort-overflow |
ERROR | 菜单 sort 超 127,MySQL tinyint 拒绝写入,迁移中断 |
config-value-truncation |
ERROR | sys_config.config_value 超 255 字符被静默截断 |
menu-id-collision |
ERROR | 两个模块硬编码同一菜单 ID,互相覆盖 |
contract-import-boundary |
ERROR | 契约包 import app/,应用无法独立编译 |
contract-shim-alias |
ERROR | 契约薄壳写成 defined type 而非别名,方法集丢失,本仓可能照常编译、第三方应用编译不过 |
datascope-route-unguarded |
ERROR | handler 读调用方的数据权限,而注册它的路由组没装提供权限的中间件。取不到时拿到零值、走 fail-closed 分支,查询被塞进 1 = 0:接口对确实存在的行返回「查不到」,且只在 enabledp: true 的部署上出现 |
shutdown-budget-overruns-grace |
ERROR / WARN | settings.yml 的 extend.shutdown 预算(含清单里的 preStop)放不进自带 k8s 清单的 terminationGracePeriodSeconds,SIGKILL 在清理回调跑到一半时到达 |
docker-stop-cuts-shutdown-short |
ERROR / WARN | 停止容器的两条路径——脚本/工作流里的 docker stop,和 docker-compose.yml 的 stop_grace_period——没写或写得不够关闭预算用。两边默认都是 10 秒,而这个数字离命令很远,调大预算的人不会想起它 |
menu-name-mismatch |
WARN | 菜单名与前端组件 name 不一致,keep-alive 缓存静默失效 |
两条关闭预算检查分两级,用的是同一条算术和同一个 5 秒边际:真的超限报 ERROR, 放得进但余量不足 5 秒报 WARN。余量不足做 WARN 不做 ERROR,是因为那是个技术上 跑得通的配置——一条在正确配置下也会响的 ERROR,训练的是忽略它。
最后一条要跨仓库比对,只能做正则启发式,因此是 WARN,不影响退出码, 且默认跳过;要跑它得指定前端目录:
make checksilent UI_DIR=../go-admin-ui/src
升级门槛:连续 2 个发版周期零误报后转为 ERROR。
提交规范
格式 type+emoji: 描述:
feat✨ fix🐛 style💄 docs📝 perf👌 test✅ refactor🎨 chore🔧
一个提交只做一件事。改动跨越多个语义时拆分提交,不要混在一起。
红线
- 不使用全局 DB 变量,一律用
e.Orm(来自请求上下文,多租户依赖它) - 不在 Service 中引用
gin.Context - 生产部署前确认
mode: prod且已修改jwt.secret(dev 模式下 token 几乎不过期) - 不提交
config/settings.yml中的真实凭据