feat: bootstrap reusable GoOmni framework

This commit is contained in:
2026-09-20 22:06:24 +08:00
commit 8b19aa79ee
60 changed files with 4923 additions and 0 deletions

View File

@@ -0,0 +1,159 @@
# 用户 CRUD 参考项目
这个示例展示如何用 GoOmni 写一个完整业务模块:参数绑定、业务规则、存储接口、分页、认证授权、统一响应、错误转换和自定义中间件。
## 运行
在 `go-omni` 根目录执行(Go 1.22 或以上):
```sh
go run ./examples/reference
```
默认监听 `127.0.0.1:8080`,可通过环境变量调整:
```sh
GO_OMNI_ADDR=127.0.0.1:8081 go run ./examples/reference
```
如果终端提示找不到 `go`,需要先把 Go 的 `bin` 目录加入 `PATH`。不要与 `examples/basic` 同时占用同一个端口。
默认使用内存存储,启动时为空,重启后数据清空。写接口需要 `Authorization: Bearer dev-token`,删除接口还需要 admin 角色;这是本地演示 Token,不可用于生产。设置 `GO_OMNI_DB_DSN` 后会改用 MySQL 存储,启动时会连接并 Ping 数据库;请先执行 [`schema.sql`](schema.sql)。
## 分层
```text
examples/reference/
├── main.go # 组装存储、认证、业务服务、中间件和路由
├── handler/
│ ├── user.go # JSON / URI / Query 参数校验、HTTP 错误转换
│ └── middleware.go # 自定义结构化请求日志
├── service/
│ └── user.go # 业务规则、输入规范化、UserRepository 接口
├── repository/
│ ├── memory.go # 并发安全的内存实现,原子保证邮箱唯一
│ └── mysql.go # database/sql + MySQL 持久化实现
├── model/
│ ├── user.go # 用户、输入、分页结果
│ └── errors.go # 业务错误
└── main_test.go # 接口全流程与并发测试
```
调用路径:HTTP → Handler → Service → Repository。
- Handler 依赖 GoOmni,示例代码不导入 Gin,也不调用 `Engine()` / `Raw()`。
- Service、Repository、Model 不依赖 GoOmni 或 Gin,通过标准 `context.Context` 传递请求取消信号。
- JSON 校验标签留在 Handler 的请求结构上;业务模型没有 HTTP 绑定标签。
- Service 返回业务错误,由 Handler 映射为 HTTP 错误;未知错误交给框架转换成通用 500 响应。
- 通过 `application.Group("/api/v1", handler.RequestLog(logger))` 挂载自定义中间件,再注册 `/users` 子路由。日志记录请求 ID、耗时和返回错误;panic 由框架恢复中间件处理。
## 接口约定
| 方法 | 路径 | 成功结果 |
| --- | --- | --- |
| GET | `/health` | 200,健康检查 |
| POST | `/api/v1/users` | 201,创建用户 |
| GET | `/api/v1/users` | 200,分页列表 |
| GET | `/api/v1/users/:id` | 200,用户详情 |
| PUT | `/api/v1/users/:id` | 200,完整替换姓名和邮箱 |
| DELETE | `/api/v1/users/:id` | 204,无响应体 |
姓名和邮箱必填。姓名最长 100 个字符,去掉首尾空白后不能为空;邮箱最长 254 字节。本示例约定整个邮箱地址不区分大小写,按小写存储并唯一校验。
`PUT` 必须同时提交姓名和邮箱;ID 和时间戳由服务生成。删除后允许重新使用邮箱,新用户获得新的 ID。删除不存在的用户返回 404。
列表默认 `page=1&pageSize=20`,每页最多 100 条,按 ID 升序;超出最后一页返回空数组并保留总数。
## 试一遍完整流程
以下命令假定刚启动一个空实例,第一条创建的用户 ID 为 1:
```sh
# 健康检查
curl -i http://127.0.0.1:8080/health
# 创建:201(开发 Token)
curl -i -X POST http://127.0.0.1:8080/api/v1/users \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer dev-token' \
-H 'X-Request-ID: demo-create' \
-d '{"name":"张三","email":"zhangsan@example.com"}'
# 查询详情和分页列表:200
curl -i http://127.0.0.1:8080/api/v1/users/1
curl -i 'http://127.0.0.1:8080/api/v1/users?page=1&pageSize=10'
# 相同邮箱(包括大小写变体):409 EMAIL_TAKEN
curl -i -X POST http://127.0.0.1:8080/api/v1/users \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer dev-token' \
-d '{"name":"另一个人","email":"ZHANGSAN@example.com"}'
# 完整修改:200
curl -i -X PUT http://127.0.0.1:8080/api/v1/users/1 \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer dev-token' \
-d '{"name":"张三(已更新)","email":"zhangsan.new@example.com"}'
# 删除:204;随后查询:404 USER_NOT_FOUND
curl -i -X DELETE http://127.0.0.1:8080/api/v1/users/1 \
-H 'Authorization: Bearer dev-token'
curl -i http://127.0.0.1:8080/api/v1/users/1
```
分页结果的 `data` 结构:
```json
{
"items": [],
"total": 0,
"page": 1,
"pageSize": 20
}
```
成功与失败都使用框架的 `code`、`message`、`requestId` 约定;删除成功只有 HTTP 204 和请求 ID 响应头。
| HTTP 状态 | code | 说明 |
| --- | --- | --- |
| 400 | `INVALID_BODY` | JSON 或必填字段、格式校验失败 |
| 400 | `INVALID_PATH` | ID 不是正整数 |
| 400 | `INVALID_QUERY` | 分页参数不合法 |
| 400 | `INVALID_USER` | 业务校验失败,例如姓名全为空白 |
| 404 | `USER_NOT_FOUND` | 用户不存在 |
| 409 | `EMAIL_TAKEN` | 邮箱已被其他用户使用 |
| 500 | `INTERNAL_ERROR` | 未预期的内部错误,响应不包含内部细节 |
## 使用数据库
参考项目已经提供 MySQL 的 `service.UserRepository` 实现;Handler 和 Service 不需要修改。自定义数据库实现也应遵循相同契约:
- 创建和修改时原子保证规范化后的邮箱唯一,例如使用数据库唯一索引;将唯一键冲突转换为 `model.ErrEmailTaken`。
- 未找到用户返回 `model.ErrUserNotFound`;修改失败不保留部分更新。
- 列表按 ID 排序、返回总数,空列表使用 `[]`。
- 使用传入的 `context.Context` 执行查询,保留创建时间,修改时更新时间。
MySQL 运行配置通过环境变量传入:
```sh
export GO_OMNI_DB_DRIVER=mysql
export GO_OMNI_DB_DSN='root:password@tcp(127.0.0.1:3306)/go_omni?parseTime=true&loc=UTC'
export GO_OMNI_DB_MAX_OPEN_CONNS=10
export GO_OMNI_DB_MAX_IDLE_CONNS=5
export GO_OMNI_DB_PING_TIMEOUT=5s
go run ./examples/reference
```
不设置 `GO_OMNI_DB_DSN` 就使用内存实现。可通过 `GO_OMNI_DEV_TOKEN` 替换本地演示 Token。数据库连接会在收到 `SIGINT` / `SIGTERM` 后随 HTTP 服务优雅退出而关闭;连接失败会阻止服务启动,不会静默回退到内存存储。
## 验证
在框架根目录执行:
```sh
go test -race ./...
go vet ./...
go build ./...
```
测试覆盖完整 CRUD、认证和角色权限、校验失败、分页边界、邮箱冲突及失败更新、删除后重用邮箱、请求 ID、日志关联、内部错误隐藏、并发唯一性、取消请求不修改数据、数据库配置解析和 HTTP 优雅退出。MySQL 测试不自动连接外部数据库。