diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d843143 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,960 @@ +# HotGo 项目开发规范 + +**生成或修改任何代码前,必须完整阅读本文档。** +本规范是项目唯一事实来源,所有新增代码必须严格对齐现有模块风格,不得自创新的代码模式。 + +--- + +## 技术栈 + +| 层 | 技术 | +|----|----------------------------------------------------------------| +| 后端语言 | Go,module 名 `hotgo` | +| 后端框架 | GoFrame v2(gdb、ghttp、gcron、gerror、gctx、gerror、gjson、gvar) | +| 前端框架 | Vue 3 + Vite + TypeScript | +| UI 组件库 | **Naive UI**(优先使用,未经允许不得引入其他 UI 库) | +| 状态管理 | Pinia | +| HTTP 客户端 | 项目封装 `@/utils/http/axios`(`http.request`),禁止直接使用 axios | + +--- + +## 后端目录结构 + +``` +server/ +├── addons/ # 插件模块(每个插件独立微架构,见"插件开发"章节) +│ ├── modules/ # 隐式注册文件(每个插件一个 .go,仅含 import) +│ └── / # 插件目录,与主模块结构镜像 +├── api/admin// # 主模块 HTTP 契约:Req/Res + g.Meta 路由声明 +├── internal/ +│ ├── consts/ # 全局常量与字典选项(枚举在此定义并注册) +│ ├── global/ # 全局公用变量(谨慎添加,尽量少用) +│ ├── controller/admin/sys/ # 薄控制器,只做参数透传,不含业务逻辑 +│ ├── crons/ # 主模块定时任务注册与处理 +│ ├── queues/ # 主模块消息队列消费者注册与处理 +│ ├── logic/sys/ # 业务实现(sSysXxx),init() 注册到 service +│ ├── library/ # 内部基础库(addons、dict、hgorm、cache、queue、cron 等) +│ ├── service/ # 接口声明(ISysXxx)+ 注册函数 +│ ├── dao/ # 对外 DAO;internal/dao/internal/ 为 CLI 生成,禁止手改 +│ ├── model/ +│ │ ├── entity/ # 表结构体(CLI 生成,禁止手改) +│ │ ├── do/ # ORM 写入结构(CLI 生成,禁止手改) +│ │ └── input/sysin/ # 业务入参、出参、过滤结构 +│ └── router/ +│ ├── admin.go # 主路由,末尾调用 genrouter.Register() +│ └── genrouter/ # 每个生成模块一个文件,init() 追加到 LoginRequiredRouter +├── resource/ +│ ├── generate/default/curd/ # 主模块 CRUD 代码生成模板(参考,禁止直接修改) +│ └── generate/default/addon/ # 插件脚手架代码生成模板(参考,禁止直接修改) +└── utility/ # 通用工具:convert、excel、validate 等 +``` + +--- + +## 后端分层约定 + +### 分层职责(禁止跨层调用) + +``` +api(HTTP契约) → controller(参数透传) → service(接口) → logic(业务实现) → dao(数据访问) +``` + +- **controller 层**:不写任何业务逻辑,只转调 service +- **logic 层**:不出现 HTTP 相关代码,所有校验通过 `Filter()` 或 `g.Validator()` 完成 +- **dao 层**:`internal/dao/internal/` 由 CLI 自动生成,禁止手改 + +--- + +### 1. API 层 `api/admin//.go` + +- 每个接口一对 `XxxReq` / `XxxRes`,`g.Meta` 声明路由 +- GET 接口用 `method:"get"`,写操作用 `method:"post"` +- 请求体嵌入 `sysin.XxxInp`,响应体嵌入对应 Model 或空 `struct{}` + +```go +type ListReq struct { + g.Meta `path:"/xxxModule/list" method:"get" tags:"XXX模块" summary:"获取XXX列表"` + sysin.XxxListInp +} +type ListRes struct { + form.PageRes + List []*sysin.XxxListModel `json:"list" dc:"数据列表"` +} +``` + +### 2. Controller 层 `internal/controller/admin/sys/.go` + +- 变量:`var XxxModule = cXxxModule{}`,类型:`type cXxxModule struct{}` +- 只做参数转发和结果组装,禁止直接操作 dao 或写业务逻辑 + +```go +func (c *cXxxModule) List(ctx context.Context, req *xxxmodule.ListReq) (res *xxxmodule.ListRes, err error) { + list, totalCount, err := service.SysXxxModule().List(ctx, &req.XxxListInp) + if err != nil { + return + } + if list == nil { + list = []*sysin.XxxListModel{} + } + res = new(xxxmodule.ListRes) + res.List = list + res.PageRes.Pack(req, totalCount) + return +} +``` + +### 3. Service 层 `internal/service/sys.go` + +- 在已有文件中**追加**接口声明和注册函数,禁止新建文件 +- 声明 `ISysXxxModule` 接口,提供 `SysXxxModule()` 获取函数和 `RegisterSysXxxModule()` 注册函数 + +### 4. Logic 层 `internal/logic/sys/.go` + +- 结构体:`type sSysXxxModule struct{}` +- `init()` 里调用 `service.RegisterSysXxxModule(NewSysXxxModule())` +- `Model()` 方法:`return handler.Model(dao.XxxTable.Ctx(ctx), option...)` +- 列名引用必须用 `dao.XxxTable.Columns().FieldName`,禁止硬编码字符串字段名 +- `Edit` 方法以 `in.Id > 0` 区分更新/新增,更新用 `XxxUpdateFields`,新增用 `XxxInsertFields` +- 字段校验在 `sysin` 的 `Filter(ctx)` 方法完成,logic 层不重复校验 + +### 5. Model 层 `internal/model/input/sysin/.go` + +| 类型 | 用途 | +|------|------| +| `XxxUpdateFields` | 更新时字段白名单 | +| `XxxInsertFields` | 新增时字段白名单 | +| `XxxEditInp` | 嵌入 `entity.XxxTable`,含 `Filter()` 校验方法 | +| `XxxListInp` | 列表查询入参,嵌入 `form.PageInp` | +| `XxxListModel` | 列表返回字段 | +| `XxxViewInp` / `XxxViewModel` | 详情入参/出参 | +| `XxxDeleteInp` | 删除入参(通常含 `Ids []int64`) | +| `XxxStatusInp`、`XxxMaxSortInp` 等 | 其他操作 | + +### 6. 路由注册 `internal/router/genrouter/.go` + +```go +package genrouter + +import "hotgo/internal/controller/admin/sys" + +func init() { + LoginRequiredRouter = append(LoginRequiredRouter, sys.XxxModule) // XXX模块 +} +``` + +新建此文件即可,`genrouter/init.go` 通过 `init()` 链自动加载,无需修改 `admin.go`。 + +--- + +## 字典与枚举规范 + +**字典由后端统一维护**,前端通过固定接口拉取,禁止在前端硬编码枚举值。 + +### 后端:定义与注册(`internal/consts/` 下对应文件) + +```go +// 1. 定义常量 +const ( + XxxStatusEnabled = 1 // 启用 + XxxStatusDisable = 2 // 禁用 +) + +// 2. 定义选项(选择合适的样式函数) +var XxxStatusOptions = []*model.Option{ + dict.GenSuccessOption(XxxStatusEnabled, "启用"), + dict.GenWarningOption(XxxStatusDisable, "禁用"), +} + +// 3. 在 init() 中注册(同文件) +func init() { + dict.RegisterEnums("XxxStatusOptions", "XXX状态选项", XxxStatusOptions) +} +``` + +**样式函数选择规则:** + +| 函数 | 适用场景 | +|------|---------| +| `dict.GenSuccessOption` | 正常、启用、成功 | +| `dict.GenWarningOption` | 待处理、禁用、警告 | +| `dict.GenErrorOption` | 失败、封禁、危险 | +| `dict.GenInfoOption` | 信息类、中性状态 | +| `dict.GenPrimaryOption` | 主要操作、强调 | +| `dict.GenDefaultOption` | 默认、无特殊含义 | +| `dict.GenHashOption` | 不确定数量的动态枚举 | + +### 后端:Logic 层中使用字典 + +```go +// 获取标签文本 +label := dict.GetOptionLabel(consts.XxxStatusOptions, in.Status) + +// 校验是否为合法值 +if !dict.HasOptionKey(consts.XxxStatusOptions, in.Status) { + return gerror.New("状态值不合法") +} +``` + +### 前端:加载与使用字典 + +**加载**(在 `model.ts` 的 `loadOptions()` 中): + +```ts +export function loadOptions() { + dict.loadOptions(['XxxStatusOptions', 'sys_normal_disable']); +} +``` + +**在 `model.ts` 的 columns 中渲染字典标签:** + +```ts +import { useDictStore } from '@/store/modules/dict'; +const dict = useDictStore(); + +// columns 中 +{ + title: '状态', + key: 'status', + render(row) { + return dict.getLabel('XxxStatusOptions', row.status); + } +} +``` + +**在搜索表单 schemas 中使用字典下拉:** + +```ts +{ + field: 'status', + component: 'NSelect', + label: '状态', + componentProps: { + options: computed(() => dict.getOptions('XxxStatusOptions')), + placeholder: '请选择状态', + }, +} +``` + +--- + +## 插件(Addon)开发规范 + +> 定位:独立、临时性、工具类型的功能推荐插件化开发,例如小游戏、广告管理、文章管理、小程序、微商城等。插件之间完全隔离,方便多项目复用。 + +### 插件目录结构 + +每个插件拥有完整的独立微架构,与主模块结构镜像,但 **包路径前缀为 `hotgo/addons//`**: + +``` +server/addons// +├── main.go # 插件入口:定义 module 结构体,实现 Module 接口,init() 注册 +├── global/ +│ ├── global.go # 插件级全局变量 +│ └── init.go # Init(ctx, skeleton),GetSkeleton() 方法 +├── api/ +│ ├── admin/ # 后台接口契约(同主模块 api 写法) +│ ├── api/ # 前台 API 接口契约 +│ └── home/ # 首页接口契约(如有) +├── controller/ +│ ├── admin/sys/ # 后台薄控制器 +│ ├── api/ # 前台控制器 +│ └── home/ # 首页控制器(如有) +├── logic/ +│ ├── logic.go # 匿名导入各 logic 子包触发 init() +│ └── sys/ # 业务实现(同主模块 logic 写法) +├── service/ # 插件内接口声明与注册(独立,不与主模块 service 混用) +├── model/ +│ └── input/sysin/ # 插件内入参/出参(调用主模块服务时继承主模块 input 结构) +├── router/ +│ ├── admin.go # 注册后台路由,使用 addons.RouterPrefix() 获取路由前缀 +│ ├── api.go # 注册前台路由 +│ ├── home.go # 注册首页路由(如有) +│ ├── websocket.go # 注册 WebSocket 路由(如有) +│ └── genrouter/ +│ ├── init.go # 插件版 genrouter:Register() 使用插件前缀 +│ └── *.go # 每个生成模块一个文件,追加到插件 LoginRequiredRouter +├── consts/ # 插件内常量与字典(同主模块 consts 写法) +├── crons/ # 插件定时任务(可选) +├── queues/ # 插件消息队列消费者(可选) +└── resource/ # 插件静态资源与模板(可选) +``` + +**对应前端目录:** + +``` +web/src/ +├── api/addons// # 插件前端 API 封装 +└── views/addons// # 插件前端页面 +``` + +### 插件入口 `main.go` + +```go +package + +import ( + "context" + "github.com/gogf/gf/v2/net/ghttp" + _ "hotgo/addons//crons" + "hotgo/addons//global" + _ "hotgo/addons//logic" + _ "hotgo/addons//queues" + "hotgo/addons//router" + "hotgo/internal/library/addons" + "hotgo/internal/service" + "sync" +) + +type module struct { + skeleton *addons.Skeleton + ctx context.Context + sync.Mutex +} + +func init() { newModule() } + +func newModule() { + m := &module{ + skeleton: &addons.Skeleton{ + Label: "插件显示名", + Name: "", // 与目录名一致 + Group: 1, + Brief: "简介", + Description: "详细描述", + Author: "作者", + Version: "v1.0.0", + }, + ctx: gctx.New(), + } + addons.RegisterModule(m) +} + +func (m *module) Start(option *addons.Option) (err error) { + global.Init(m.ctx, m.skeleton) + option.Server.Group("/", func(group *ghttp.RouterGroup) { + group.Middleware(service.Middleware().Addon) + router.Admin(m.ctx, group) + router.Api(m.ctx, group) + }) + return +} +// 其余方法:Stop、Ctx、GetSkeleton、Install、Upgrade、UnInstall(默认留空) +``` + +### 插件路由前缀规则 + +``` +后台:/admin//接口路径 → 对应 controller/admin/sys/ +前台:/api//接口路径 → 对应 controller/api/ +首页:/home//接口路径 → 对应 controller/home/ +WebSocket:/socket//接口路径 +``` + +路由前缀通过 `addons.RouterPrefix(ctx, consts.AppAdmin, global.GetSkeleton().Name)` 自动生成,**禁止在路由文件里硬编码路径前缀**。 + +### 插件 genrouter 与主模块的区别 + +插件 `genrouter/init.go` 的 `Register()` 函数需要传入 `ctx` 和 `group`,并使用插件路由前缀: + +```go +// 插件 genrouter/init.go +func Register(ctx context.Context, group *ghttp.RouterGroup) { + prefix := addons.RouterPrefix(ctx, consts.AppAdmin, global.GetSkeleton().Name) + group.Group(prefix, func(group *ghttp.RouterGroup) { + group.Middleware(service.Middleware().AdminAuth) + if len(LoginRequiredRouter) > 0 { + group.Bind(LoginRequiredRouter...) + } + }) +} + +// 插件 genrouter/.go +func init() { + LoginRequiredRouter = append(LoginRequiredRouter, sys.XxxModule) +} +``` + +参考实现:`server/addons/hgexample/router/genrouter/` + +### 插件调用主模块服务 + +**在插件 input 层继承主模块 input 结构,解耦参数依赖**,避免 import cycle: + +```go +// 插件 model/input/sysin/config.go +package sysin + +import "hotgo/internal/model/input/sysin" + +type UpdateConfigInp struct { + sysin.UpdateAddonsConfigInp +} +``` + +```go +// 插件 logic 中调用主模块服务 +import isc "hotgo/internal/service" + +func (s *sSysConfig) UpdateConfigByGroup(ctx context.Context, in sysin.UpdateConfigInp) error { + in.UpdateAddonsConfigInp.AddonName = global.GetSkeleton().Name + return isc.SysAddonsConfig().UpdateConfigByGroup(ctx, in.UpdateAddonsConfigInp) +} +``` + +### 插件获取自身信息 + +```go +// 插件内部 +global.GetSkeleton() + +// 任意位置判断当前请求是否为插件请求 +contexts.IsAddonRequest(ctx) +contexts.GetAddonName(ctx) +``` + +### 插件隐式注册 + +在 `server/addons/modules/` 下新建 `.go`,内容仅含一行匿名导入: + +```go +package modules + +import _ "hotgo/addons/" +``` + +### 新增插件检查清单 + +**后端:** +- [ ] `addons//main.go` — 插件入口,实现 Module 接口 +- [ ] `addons//global/` — global.go + init.go +- [ ] `addons/modules/.go` — 隐式注册 +- [ ] `addons//service/` — 插件内接口声明(独立包) +- [ ] `addons//logic/logic.go` — 匿名导入触发 init() +- [ ] `addons//logic/sys/.go` — 业务实现 +- [ ] `addons//model/input/sysin/.go` — 入参/出参 +- [ ] `addons//api/admin//.go` — API 契约 +- [ ] `addons//controller/admin/sys/.go` — 薄控制器 +- [ ] `addons//router/admin.go` — 路由注册 +- [ ] `addons//router/genrouter/init.go` — 插件 genrouter +- [ ] `addons//router/genrouter/.go` — 模块路由追加 + +**前端:** +- [ ] `web/src/api/addons//index.ts` — API 封装 +- [ ] `web/src/views/addons//model.ts` — State、schemas、columns +- [ ] `web/src/views/addons//index.vue` — 列表页 +- [ ] `web/src/views/addons//edit.vue` — 编辑页 + +参考实现:`server/addons/hgexample/`(完整插件示例)、`server/resource/generate/default/addon/`(生成模板) + +--- + +## 标准 CRUD 接口清单 + +| 接口 | method | 说明 | +|------|--------|------| +| `/xxx/list` | GET | 分页列表 | +| `/xxx/view` | GET | 详情 | +| `/xxx/edit` | POST | 新增/编辑(id>0 为编辑) | +| `/xxx/delete` | POST | 删除(支持批量,传 ids) | +| `/xxx/status` | POST | 启用/禁用状态切换 | +| `/xxx/switch` | POST | 开关字段切换 | +| `/xxx/maxSort` | GET | 获取最大排序值 | +| `/xxx/export` | GET | 导出列表 | + +按实际需求裁剪,不强制全部实现。 + +--- + +## 前端目录结构 + +``` +web/src/ +├── api//index.ts # HTTP 请求封装 +├── views// +│ ├── index.vue # 列表页 +│ ├── edit.vue # 新增/编辑弹窗 +│ └── model.ts # State 类、rules、schemas、columns、loadOptions +├── components/ # 通用组件(BasicTable、BasicForm、BasicModal 等) +├── store/modules/dict.ts # 字典 store(loadOptions、getLabel、getOptions) +└── utils/ + ├── http/axios.ts # HTTP 封装(http.request / jumpExport) + └── dateUtil.ts # 日期工具(defRangeShortcuts 等) +``` + +--- + +## 前端编码约定 + +### API 文件 `api//index.ts` + +- 使用 `http.request`,禁止直接使用 axios +- 函数名与接口语义一致:`List`、`Edit`、`Delete`、`View`、`Status`、`Switch`、`Export`、`MaxSort` +- 导出接口用 `jumpExport('/xxx/export', params)` + +```ts +import { http, jumpExport } from '@/utils/http/axios'; + +export function List(params) { + return http.request({ url: '/xxxModule/list', method: 'get', params }); +} +export function Edit(params) { + return http.request({ url: '/xxxModule/edit', method: 'POST', params }); +} +export function Export(params) { + jumpExport('/xxxModule/export', params); +} +``` + +### model.ts 结构 + +- `class State`:属性与后端 entity 字段对应,提供合理默认值 +- `newState()`:工厂函数,支持深拷贝(`cloneDeep`) +- `rules`:Naive UI 表单校验规则 +- `schemas`:搜索栏 `FormSchema[]`(`ref` 包装) +- `columns`:表格列定义,含权限守卫的操作用 `hasPermission(['/xxx/edit'])` +- `loadOptions()`:调用 `dict.loadOptions([...])`,预加载字典数据 + +### 组件使用规范 + +**必须优先使用 Naive UI 原生组件**,能用 Naive UI 实现的禁止自行封装替代组件: + +| 场景 | 必须使用的组件 | +|------|--------------| +| 表格 | 项目封装 `BasicTable`(基于 Naive UI) | +| 表单 | 项目封装 `BasicForm`(基于 Naive UI) | +| 弹窗 | `n-modal` 或项目封装 `BasicModal` | +| 按钮 | `n-button`,类型用 `primary`/`default`/`error` 等语义值 | +| 选择器 | `n-select`(字典下拉)、`n-tree-select`(树形) | +| 开关 | `n-switch`,配合 Switch 接口 | +| 输入框 | `n-input` | +| 日期选择 | `n-date-picker`,范围选择配合 `defRangeShortcuts()` | +| 图片渲染 | `renderImage(url)` | +| 文件渲染 | `renderFile(url)` | +| 成员信息 | `renderPopoverMemberSumma(summa)` | + +**禁止行为:** +- 禁止引入 Element Plus、Ant Design Vue 等其他 UI 库 +- 禁止使用内联 style(使用 Naive UI 的 props 或 class) +- 禁止在模板里硬编码字典值文本,必须通过 `dict.getLabel()` 渲染 + +### 权限控制 + +```ts +import { usePermission } from '@/hooks/web/usePermission'; +const { hasPermission } = usePermission(); + +// 模板中 +v-if="hasPermission(['/xxxModule/edit'])" + +// columns 中开关操作 +disabled: !hasPermission(['/xxxModule/switch']) +``` + +权限标识与后端路由路径保持一致,**路由由后端菜单数据驱动,禁止在源码里硬编码路由路径**。 + +### Vue 3 代码规范 + +- 使用 Composition API(`