chore(logging): 更新日志重定向与捕获功能

- 在 .gitignore 中添加调试日志文件的忽略规则,避免不必要的调试信息被提交
- 修改 application.go 中的 stdout 重定向逻辑,使用 log.CaptureStream 以支持更灵活的日志捕获
- 更新 README 文档,增加对日志重定向功能的说明
This commit is contained in:
2026-07-13 07:45:51 +08:00
parent e14c9742c1
commit 6b689f3a1b
23 changed files with 1316 additions and 1836 deletions
@@ -1,4 +1,4 @@
---
---
name: API接口测试方案
overview: hotime 框架新增链式测试 API:数据在前方法在后,Post/Get 一步完成用例+断言,TestProj 合并注册,自动生成 Swagger UI。
todos:
@@ -171,7 +171,7 @@ func TestMain(m *testing.M) {
"app": {Project, ProjectTest},
})
code := m.Run()
testApp.GenerateSwagger("小帮菜 API", "2.1.0", testApp.Config.GetString("tpt"))
testApp.GenerateSwagger("HoTime API", "2.1.0", testApp.Config.GetString("tpt"))
os.Exit(code)
}
@@ -294,6 +294,6 @@ func (r *ApiResponse) Fail(msg string) // 自定义断言失败
- **新增** `d:/work/hotimev1.5/testing_helper.go`
- **新增** `d:/work/hotimev1.5/testing_api.go`
- **新增** `d:/work/hotimev1.5/testing_swagger.go`
- **修改** `d:/work/xbc/app/user.go` -- 末尾添加 UserTest
- **修改** `d:/work/xbc/app/init.go` -- 添加 ProjectTest
- **新增** `d:/work/xbc/app/app_test.go`
- **修改** `d:/work/your-app/app/user.go` -- 末尾添加 UserTest
- **修改** `d:/work/your-app/app/init.go` -- 添加 ProjectTest
- **新增** `d:/work/your-app/app/app_test.go`
@@ -1,4 +1,4 @@
---
---
name: API测试框架完整实现
overview: 在 hotimev1.5 框架中实现完整的 API 测试基础设施:链式测试 API、事务回滚隔离、覆盖率追踪、Swagger 文档自动生成。
todos:
@@ -208,7 +208,7 @@ if that.testTx == nil {
## 四、业务层接入(xbc 示例)
### 7. 修改 [xbc/app/user.go](d:/work/xbc/app/user.go) -- 末尾添加 `var UserTest = CtrTest{...}`
### 7. 修改 [app/app/user.go](d:/work/your-app/app/user.go) -- 末尾添加 `var UserTest = CtrTest{...}`
```go
var UserTest = CtrTest{
@@ -225,9 +225,9 @@ var UserTest = CtrTest{
}
```
### 8. 修改 [xbc/app/init.go](d:/work/xbc/app/init.go) -- 添加 `var ProjectTest = ProjTest{...}`
### 8. 修改 [app/app/init.go](d:/work/your-app/app/init.go) -- 添加 `var ProjectTest = ProjTest{...}`
### 9. 新增 [xbc/app/app_test.go](d:/work/xbc/app/app_test.go)
### 9. 新增 [app/app/app_test.go](d:/work/your-app/app/app_test.go)
```go
func TestMain(m *testing.M) {
@@ -236,7 +236,7 @@ func TestMain(m *testing.M) {
})
code := m.Run()
testApp.PrintCoverage()
testApp.GenerateSwagger("小帮菜 API", "2.1.0", testApp.Config.GetString("tpt"))
testApp.GenerateSwagger("HoTime API", "2.1.0", testApp.Config.GetString("tpt"))
os.Exit(code)
}
@@ -1,4 +1,4 @@
---
---
name: HoTime TDD Skill
overview: 创建一个 HoTime 框架的 TDD(测试驱动开发)Skill,指导 AI 按照"先写测试、再写实现、运行直到通过"的流程开发接口;同时在框架层面增加测试模式下的 SQL 日志智能控制,解决 AI 因日志过多无法继续执行的问题。
todos:
@@ -203,7 +203,7 @@ func NewTestApp(configPath string, projects TestProj, ...) *TestApp {
**关键参考文档**
- Skill 内直接嵌入精简版的测试 API 速查表(链式 API、Verify 用法、DB 操作等)
- 详细文档指向 [Testing_API测试框架.md](d:/work/xbc/docs/Testing_API测试框架.md)
- 详细文档指向 [Testing_API测试框架.md](d:/work/your-app/docs/Testing_API测试框架.md)
**SQL 日志行为(框架自动处理)**
@@ -1,4 +1,4 @@
---
---
name: objtoobj 问题修正
overview: 对 objtoobj.go 中发现的 3 个运行时 panic bug、2 个错误处理 bug、多处性能问题进行修正,同时新增销量方案所需的 ObjToRoundFloat64 函数。
todos:
@@ -264,7 +264,7 @@ case int:
## 四、结合销量方案需新增的函数
根据[销量支持小数方案](d:\work\xbc.cursor\plans\销量支持小数方案_92094ec9.plan.md)第二步要求,需在本文件新增 `ObjToRoundFloat64`
根据[销量支持小数方案](d:\work\your-app.cursor\plans\销量支持小数方案_92094ec9.plan.md)第二步要求,需在本文件新增 `ObjToRoundFloat64`
```go
func ObjToRoundFloat64(obj interface{}, precision int, e ...*Error) float64 {
@@ -1,6 +1,6 @@
---
---
name: 优雅停机方案
overview: 在 HoTime 框架层实现优雅停机,跨平台(Windows/Linux)支持。收到信号后新请求立即返回 503 让 nginx 切流,等排空后调用 http.Server.Shutdown 等在途请求完成,exit(0) 退出,run_xbc.cmd 检测正常退出不重启。
overview: 在 HoTime 框架层实现优雅停机,跨平台(Windows/Linux)支持。收到信号后新请求立即返回 503 让 nginx 切流,等排空后调用 http.Server.Shutdown 等在途请求完成,exit(0) 退出,run_app.cmd 检测正常退出不重启。
todos:
- id: framework-core
content: hotimev1.5/application.go:新增 shuttingDown/shutdownOnce/DrainTimeout/ShutdownTimeout 字段,改 ServeHTTP,改 Run() 信号 goroutine,改 recover 保护,提取 initiateGracefulShutdown()
@@ -15,7 +15,7 @@ todos:
content: Nginx location 块加 proxy_next_upstream error timeout http_503 和 proxy_next_upstream_tries 2
status: completed
- id: cmd-exit-check
content: run_xbc.cmd:检测 EXIT_CODE == 0 时不重启直接退出
content: run_app.cmd:检测 EXIT_CODE == 0 时不重启直接退出
status: completed
- id: docs
content: 新建 hotimev1.5/docs/graceful-shutdown.md 文档
@@ -45,7 +45,7 @@ sequenceDiagram
App->>App: server.Shutdown(ShutdownTimeout=30s)
Note over App: 等所有在途请求跑完
App->>OS: os.Exit(0)
Note over Op: run_xbc.cmd 检测 exit 0 → 不重启
Note over Op: run_app.cmd 检测 exit 0 → 不重启
```
## 信号触发场景对照表
@@ -199,7 +199,7 @@ proxy_next_upstream_tries 2;
---
### 5. [`run_xbc.cmd`](d:/work/xbc/run_xbc.cmd)
### 5. [`run_app.cmd`](d:/work/your-app/run_app.cmd)
`goto run_app` 前插入 exit code 判断:
@@ -1,4 +1,4 @@
---
---
name: 补充 Note 方法示例
overview: 在 Testing_API测试框架.md 的"第二步:编写测试用例"部分补充 Note 方法的使用示例和说明,同步更新 SKILL.md。
todos:
@@ -23,8 +23,8 @@ isProject: false
目前 `Note` 方法在文档中的存在情况:
- [链式 API 参考](d:\work\xbc\docs\Testing_API测试框架.md) 第 474、494 行:仅出现在 API 表格中,一句话描述
- [API 速查表](d:\work\xbc\docs\Testing_API测试框架.md) 第 662-668 行:只有一个简短示例(`sign = MD5(...)`)
- [链式 API 参考](d:\work\your-app\docs\Testing_API测试框架.md) 第 474、494 行:仅出现在 API 表格中,一句话描述
- [API 速查表](d:\work\your-app\docs\Testing_API测试框架.md) 第 662-668 行:只有一个简短示例(`sign = MD5(...)`)
- [SKILL.md](C:\Users\92597.cursor\skills\hotime-tdd-testing\SKILL.md) 第 195-197 行:一行示例
**缺失**:"第二步:编写测试用例"(第 66-149 行)的完整订单示例中完全没有出现 `Note`,而这是新手学习的第一个入口。
@@ -33,7 +33,7 @@ isProject: false
### 1. 文档:第二步示例中加入 Note 使用
在 [Testing_API测试框架.md](d:\work\xbc\docs\Testing_API测试框架.md) 第 80-148 行的订单创建示例中,在**正确请求**部分加入 `Note` 调用,展示以下几种典型场景:
在 [Testing_API测试框架.md](d:\work\your-app\docs\Testing_API测试框架.md) 第 80-148 行的订单创建示例中,在**正确请求**部分加入 `Note` 调用,展示以下几种典型场景:
- 在正确请求处用 `Note` 说明参数值含义(如 `quantity=3` 是故意的用于校验总价 `29.9*3=89.7`
+4
View File
@@ -7,5 +7,9 @@
*.sql
*.py
# Cursor / agent debug NDJSON leftovers
debug-*.log
**/debug-*.log
# macOS
.DS_Store
+11 -15
View File
@@ -1,8 +1,3 @@
# 小帮菜后端
测试方法
D:\app\go1.23.1\bin\go.exe test ./dd/... -v
# HoTime
**高性能 Go Web 服务框架**
@@ -23,19 +18,20 @@ D:\app\go1.23.1\bin\go.exe test ./dd/... -v
## 文档
完整阅读路径见 [docs/README.md](docs/README.md)。
| 文档 | 说明 |
|------|------|
| [快速上手指南](docs/QUICKSTART.md) | 5 分钟入门安装配置、路由、中间件、基础数据库操作 |
| [快速上手](docs/QuickStart_快速上手.md) | 5 分钟入门安装配置、路由、中间件 |
| [HoTimeDB 使用说明](docs/HoTimeDB_使用说明.md) | 完整数据库 ORM 教程 |
| [HoTimeDB API 参考](docs/HoTimeDB_API参考.md) | 数据库 API 速查手册 |
| [Common 工具类](docs/Common_工具类使用说明.md) | Map/Slice/Obj 类型、类型转换、工具函数 |
| [代码生成器](docs/CodeGen_使用说明.md) | 自动 CRUD 代码生成、配置规则 |
| [数据库设计规范](docs/DatabaseDesign_数据库设计规范.md) | 表命名、字段命名、时间类型、必有字段规范 |
| [代码生成配置规范](docs/CodeConfig_代码生成配置规范.md) | codeConfig、菜单权限、字段规则配置说明 |
| [API 测试框架](docs/Testing_API测试框架.md) | 接口测试、事务隔离、覆盖率追踪、API 调试控制台生成 |
| [优雅停机](docs/graceful-shutdown.md) | 跨平台优雅停机、nginx 配置、滚动重启操作指南 |
| [Seq 日志集成](docs/Seq日志集成.md) | Seq 接入配置、异步队列原理、单机多进程实例区分、全量日志捕获、搜索语法速查 |
| [改进规划](docs/ROADMAP_改进规划.md) | 待改进项、设计思考、版本迭代规划 |
| [Common 工具类](docs/Common_工具类.md) | Map/Slice/Obj 类型、类型转换、工具函数 |
| [数据库设计规范](docs/DatabaseDesign_数据库设计规范.md) | 表命名、字段命名、COMMENT、必有字段(CodeGen 前置) |
| [代码生成](docs/CodeGen_代码生成.md) | 自动 CRUD 生成 + codeConfig / rule 配置 |
| [API 测试框架](docs/Testing_API测试框架.md) | 接口测试、事务隔离、覆盖率、调试控制台 |
| [优雅停机](docs/GracefulShutdown_优雅停机.md) | 跨平台优雅停机、nginx、滚动重启 |
| [Seq 日志集成](docs/Seq_日志集成.md) | Seq 接入、异步队列、多实例区分 |
| [改进规划](docs/ROADMAP_改进规划.md) | 待改进项与版本迭代规划 |
## 安装
@@ -95,7 +91,7 @@ go get code.hoteas.com/golang/hotime
## License
MIT License
Apache License 2.0
---
+19 -34
View File
@@ -1,7 +1,6 @@
package hotime
import (
"bufio"
"context"
"database/sql"
"fmt"
@@ -26,6 +25,8 @@ import (
. "code.hoteas.com/golang/hotime/db"
"code.hoteas.com/golang/hotime/log"
mysql "github.com/go-sql-driver/mysql"
logrus "github.com/sirupsen/logrus"
"github.com/rs/zerolog"
)
type Application struct {
@@ -189,7 +190,7 @@ func (that *Application) Run(router Router) {
return // 停机过程中的 panic 不触发重启
}
//that.SetError(errors.New(fmt.Sprint(err)), LOG_FMT)
that.Log.Warnf("%v", err)
that.Log.EmitRecoveredPanic(err)
that.Run(router)
}
@@ -346,7 +347,8 @@ func (that *Application) SetConfig(configPath ...string) {
}
// 重定向 os.Stdout 和标准 log 包,捕获 fmt.Println 等绕过 HoTime Logger 的输出
redirectStdout(that.Log)
stdoutW := redirectStdout(that.Log)
bridgeThirdPartyStdout(stdoutW)
// 重定向 os.Stderr,捕获 panic 栈、MySQL driver errLog 等写 stderr 的输出
// 注意:Logger 在此之前已创建,ConsoleWriter.Out 已绑定到原始 os.Stderr,无循环风险
redirectStderr(that.Log)
@@ -660,52 +662,35 @@ func getLocalIP() string {
}
// redirectStdout 重定向 os.Stdout 和标准 log 包到 HoTime Logger。
// 捕获 fmt.Println/fmt.Printf 等绕过 HoTime 日志系统的输出,
// 以 INFO 级别 + source="stdout" 字段写入,后续经 SeqWriter 推送到 Seq。
func redirectStdout(l *log.Logger) {
// 返回管道写端,供 logrus 等第三方库桥接。
func redirectStdout(l *log.Logger) *os.File {
r, w, err := os.Pipe()
if err != nil {
return
return nil
}
os.Stdout = w
stdlog.SetOutput(w)
go func() {
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := scanner.Text()
if line != "" {
l.Info().Str("source", "stdout").Msg(line)
go log.CaptureStream(l, zerolog.InfoLevel, "stdout", r)
return w
}
// bridgeThirdPartyStdout 将 import 时钉死原始 stdout 的第三方日志指到捕获管道。
func bridgeThirdPartyStdout(w *os.File) {
if w == nil {
return
}
}
if err := scanner.Err(); err != nil {
l.Errorf("[stdout redirect] scanner error: %v", err)
}
}()
logrus.SetOutput(w)
}
// redirectStderr 重定向 os.Stderr 到 HoTime Loggerlevel=error, source=stderr)。
// 捕获 Go runtime panic 栈、MySQL driver errLog 等直写 stderr 的输出。
// 必须在 Logger 创建之后调用:ConsoleWriter.Out 已绑定原始 os.Stderr 文件指针,
// 重定向后 ConsoleWriter 仍写真实终端,不会形成循环。
func redirectStderr(l *log.Logger) {
r, w, err := os.Pipe()
if err != nil {
l.Warnf("[redirectStderr] create pipe failed: %v", err)
l.EmitCaptured(zerolog.WarnLevel, "stderr-capture", fmt.Sprintf("create pipe failed: %v", err))
return
}
os.Stderr = w
go func() {
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := scanner.Text()
if line != "" {
l.Error().Str("source", "stderr").Msg(line)
}
}
if err := scanner.Err(); err != nil {
l.Warnf("[redirectStderr] scanner error: %v", err)
}
}()
go log.CaptureStream(l, zerolog.ErrorLevel, "stderr", r)
}
// Init 初始化application
File diff suppressed because it is too large Load Diff
-492
View File
@@ -1,492 +0,0 @@
# HoTime 代码生成器使用说明
`code` 包提供了 HoTime 框架的自动代码生成功能,能够根据数据库表结构自动生成 CRUD 接口代码和配置文件。
## 目录
- [功能概述](#功能概述)
- [配置说明](#配置说明)
- [使用方法](#使用方法)
- [生成规则](#生成规则)
- [自定义规则](#自定义规则)
- [生成的代码结构](#生成的代码结构)
---
## 功能概述
代码生成器可以:
1. **自动读取数据库表结构** - 支持 MySQL、SQLite 和达梦 DM8
2. **生成 CRUD 接口** - 增删改查、搜索、分页
3. **生成配置文件** - 表字段配置、菜单配置、权限配置
4. **智能字段识别** - 根据字段名自动识别类型和权限
5. **支持表关联** - 自动识别外键关系
---
## 配置说明
`config.json` 中配置代码生成:
```json
{
"codeConfig": [
{
"table": "admin",
"config": "config/admin.json",
"configDB": "config/adminDB.json",
"rule": "config/rule.json",
"name": "",
"mode": 0
}
]
}
```
### 配置项说明
| 配置项 | 必须 | 说明 |
|--------|------|------|
| `table` | ✅ | 用户表名,用于权限控制的基准表 |
| `config` | ✅ | 接口描述配置文件路径 |
| `configDB` | ❌ | 数据库结构配置输出路径,有则每次自动生成 |
| `rule` | ❌ | 字段规则配置文件,无则使用默认规则 |
| `name` | ❌ | 生成代码的包名和目录名,空则使用内嵌模式 |
| `mode` | ❌ | 0=内嵌代码模式,1=生成代码模式 |
### 运行模式
- **mode=0(内嵌模式)**:不生成独立代码文件,使用框架内置的通用控制器
- **mode=1(生成模式)**:为每张表生成独立的 Go 控制器文件
---
## 使用方法
### 1. 基础配置
```json
{
"mode": 2,
"codeConfig": [
{
"table": "admin",
"config": "config/admin.json",
"rule": "config/rule.json",
"mode": 0
}
]
}
```
### 2. 启动应用
```go
package main
import (
. "code.hoteas.com/golang/hotime"
. "code.hoteas.com/golang/hotime/common"
)
func main() {
app := Init("config/config.json")
// 代码生成器在 Init 时自动执行
// 会读取数据库结构并生成配置
app.Run(Router{
// 路由配置
})
}
```
### 3. 开发模式
`config.json` 中设置 `"mode": 2`(开发模式)时:
- 自动读取数据库表结构
- 自动生成/更新配置文件
- 自动生成代码(如果 codeConfig.mode=1
---
## 生成规则
### 默认字段规则
代码生成器内置了一套默认的字段识别规则:
| 字段名 | 列表显示 | 新增 | 编辑 | 详情 | 类型 |
|--------|----------|------|------|------|------|
| `id` | ✅ | ❌ | ❌ | ✅ | number |
| `name` | ✅ | ✅ | ✅ | ✅ | text |
| `status` | ✅ | ✅ | ✅ | ✅ | select |
| `create_time` | ❌ | ❌ | ❌ | ✅ | time |
| `modify_time` | ✅ | ❌ | ❌ | ✅ | time |
| `password` | ❌ | ✅ | ✅ | ❌ | password |
| `image/img/avatar` | ❌ | ✅ | ✅ | ✅ | image |
| `file` | ❌ | ✅ | ✅ | ✅ | file |
| `content/info` | ❌ | ✅ | ✅ | ✅ | textArea |
| `parent_id` | ✅ | ✅ | ✅ | ✅ | number |
| `parent_ids/index` | ❌ | ❌ | ❌ | ❌ | index |
| `delete` | ❌ | ❌ | ❌ | ❌ | - |
### 数据类型映射
数据库字段类型自动映射:
| 数据库类型 | 生成类型 |
|------------|----------|
| `int`, `integer`, `float`, `double`, `decimal` | number |
| `char`, `varchar`, `text`, `blob` | text |
| `date`, `datetime`, `time`, `timestamp`, `year` | time |
### 字段备注解析
支持从数据库字段备注中提取信息:
```sql
-- 字段备注格式: 标签名(提示信息):选项1-名称1,选项2-名称2
-- 括号支持三种写法:()、()、{}
-- 例如:
status TINYINT COMMENT '状态(用户账号状态):0-禁用,1-启用'
parent_id INT COMMENT '父级ID(顶级为NULL'
phone VARCHAR(20) COMMENT '手机号(请输入11位手机号)'
```
生成的配置(以第一行为例):
```json
{
"name": "status",
"label": "状态",
"type": "select",
"ps": "用户账号状态",
"options": [
{"name": "禁用", "value": "0"},
{"name": "启用", "value": "1"}
]
}
```
`ps` 字段在前端的展示效果:
- 编辑/新增页面:输入框右侧灰色小字提示
- 表格/详情页面:鼠标悬停字段名时气泡提示
---
## 自定义规则
### rule.json 配置
创建 `config/rule.json` 自定义字段规则:
```json
[
{
"name": "id",
"list": true,
"add": false,
"edit": false,
"info": true,
"must": false,
"strict": true,
"type": ""
},
{
"name": "status",
"list": true,
"add": true,
"edit": true,
"info": true,
"must": false,
"strict": false,
"type": "select"
},
{
"name": "user.special_field",
"list": true,
"add": true,
"edit": true,
"info": true,
"type": "text",
"strict": true
}
]
```
### 规则字段说明
| 字段 | 说明 |
|------|------|
| `name` | 字段名,支持 `表名.字段名` 格式精确匹配 |
| `list` | 是否在列表中显示 |
| `add` | 是否在新增表单中显示 |
| `edit` | 是否在编辑表单中显示 |
| `info` | 是否在详情中显示 |
| `must` | 是否必填 |
| `strict` | 是否严格匹配字段名(false 则模糊匹配) |
| `type` | 字段类型(覆盖自动识别) |
### 字段类型
| 类型 | 说明 |
|------|------|
| `text` | 普通文本输入 |
| `textArea` | 多行文本 |
| `number` | 数字输入 |
| `select` | 下拉选择 |
| `time` | 时间选择器 |
| `unixTime` | Unix 时间戳 |
| `image` | 图片上传 |
| `file` | 文件上传 |
| `password` | 密码输入 |
| `money` | 金额(带格式化) |
| `index` | 索引字段(不显示) |
| `tree` | 树形选择 |
| `form` | 表单配置 |
| `auth` | 权限配置 |
---
## 生成的代码结构
### 内嵌模式 (mode=0)
不生成代码文件,使用框架内置控制器,只生成配置文件:
```
config/
├── admin.json # 接口配置
├── adminDB.json # 数据库结构配置(可选)
└── rule.json # 字段规则
```
### 生成模式 (mode=1)
生成独立的控制器代码:
```
admin/ # 生成的包目录
├── init.go # 包初始化和路由注册
├── user.go # user 表控制器
├── role.go # role 表控制器
└── ... # 其他表控制器
```
### 生成的控制器结构
```go
package admin
var userCtr = Ctr{
"info": func(that *Context) {
// 查询单条记录
},
"add": func(that *Context) {
// 新增记录
},
"update": func(that *Context) {
// 更新记录
},
"remove": func(that *Context) {
// 删除记录
},
"search": func(that *Context) {
// 搜索列表(分页)
},
}
```
---
## 配置文件结构
### admin.json 示例
```json
{
"name": "admin",
"label": "管理平台",
"menus": [
{
"label": "系统管理",
"name": "sys",
"icon": "Setting",
"menus": [
{
"label": "用户管理",
"table": "user",
"auth": ["show", "add", "delete", "edit", "info", "download"]
},
{
"label": "角色管理",
"table": "role",
"auth": ["show", "add", "delete", "edit", "info"]
}
]
}
],
"tables": {
"user": {
"label": "用户",
"table": "user",
"auth": ["show", "add", "delete", "edit", "info", "download"],
"columns": [
{"name": "id", "type": "number", "label": "ID"},
{"name": "name", "type": "text", "label": "用户名"},
{"name": "status", "type": "select", "label": "状态",
"options": [{"name": "禁用", "value": "0"}, {"name": "启用", "value": "1"}]}
],
"search": [
{"type": "search", "name": "keyword", "label": "请输入关键词"},
{"type": "search", "name": "daterange", "label": "时间段"}
]
}
}
}
```
---
## 外键关联
### 自动识别
代码生成器会自动识别 `_id` 结尾的字段作为外键:
```sql
-- user 表
CREATE TABLE user (
id INT PRIMARY KEY,
name VARCHAR(50),
role_id INT, -- 自动关联 role 表
org_id INT -- 自动关联 org 表
);
```
生成的配置会包含 `link``value` 字段:
```json
{
"name": "role_id",
"type": "number",
"label": "角色",
"link": "role",
"value": "name"
}
```
### 树形结构
`parent_id` 字段会被识别为树形结构的父级关联:
```json
{
"name": "parent_id",
"type": "number",
"label": "上级",
"link": "org",
"value": "name"
}
```
---
## 权限控制
### 数据权限
配置 `flow` 实现数据权限控制:
```json
{
"flow": {
"order": {
"table": "order",
"stop": false,
"sql": {
"user_id": "id"
}
}
}
}
```
- `stop`: 是否禁止修改该表
- `sql`: 数据过滤条件,`user_id = 当前用户.id`
### 操作权限
每张表可配置的权限:
| 权限 | 说明 |
|------|------|
| `show` | 查看列表 |
| `add` | 新增 |
| `edit` | 编辑 |
| `delete` | 删除 |
| `info` | 查看详情 |
| `download` | 下载导出 |
---
## 最佳实践
### 1. 开发流程
1. 设置 `config.json``mode: 2`(开发模式)
2. 设计数据库表结构,添加字段备注
3. 启动应用,自动生成配置
4. 检查生成的配置文件,按需调整
5. 生产环境改为 `mode: 0`
### 2. 字段命名规范
```sql
-- 推荐的命名方式
id -- 主键
name -- 名称
status -- 状态(自动识别为 select)
create_time -- 创建时间
modify_time -- 修改时间
xxx_id -- 外键关联
parent_id -- 树形结构父级
avatar -- 头像(自动识别为 image
content -- 内容(自动识别为 textArea
```
### 3. 自定义扩展
如果默认规则不满足需求,可以:
1. 修改 `rule.json` 添加自定义规则
2. 使用 `mode=1` 生成代码后手动修改
3. 在生成的配置文件中直接调整字段属性
### 4. 达梦 DM8 使用注意
代码生成器已原生支持达梦 DM8,会自动读取 DM 数据库的表结构和字段备注。使用时注意:
- 表注释(COMMENT)在 DM 中通过 `COMMENT ON TABLE` 语句单独设置,建表后需额外执行
- 字段备注同样通过 `COMMENT ON COLUMN` 设置
- 由于 DM 的 `USER_TAB_COLUMNS` 视图字段名称与 MySQL `information_schema` 不同,代码生成器已做兼容处理
```sql
-- DM8 设置表备注(替代 MySQL 的 COMMENT='...' 语法)
COMMENT ON TABLE "user" IS '用户管理';
-- DM8 设置字段备注
COMMENT ON COLUMN "user"."state" IS '状态:0-正常,1-异常,2-隐藏';
COMMENT ON COLUMN "user"."name" IS '用户名';
```
---
## 相关文档
- [快速上手指南](QUICKSTART.md)
- [HoTimeDB 使用说明](HoTimeDB_使用说明.md)
- [Common 工具类使用说明](Common_工具类使用说明.md)
@@ -479,6 +479,7 @@ for _, u := range users {
## 相关文档
- [快速上手指南](QUICKSTART.md)
- [快速上手](QuickStart_快速上手.md)
- [HoTimeDB 使用说明](HoTimeDB_使用说明.md)
- [代码生成器使用说明](CodeGen_使用说明.md)
- [代码生成](CodeGen_代码生成.md)
- [文档索引](README.md)
+13 -3
View File
@@ -1,6 +1,8 @@
# 数据库设计规范
本文档定义了 HoTime 框架代码生成器所依赖的数据库设计规范。遵循这些规范可以确保代码生成器正确识别表关系、自动生成 CRUD 接口和管理后台
本文档 HoTime **CodeGen 代码生成器的输入契约**:表/字段命名、外键、注释与公共字段等规则直接决定生成结果。请严格按本文设计库表,再配合 [CodeGen_代码生成.md](CodeGen_代码生成.md) 使用代码生成
遵循这些规范可确保代码生成器正确识别表关系、自动生成 CRUD 接口和管理后台。
---
@@ -218,7 +220,7 @@ CREATE TABLE `order` (
### SQLite 表备注
SQLite 不支持表备注,代码生成器会使用表名作为默认显示名称。如需自定义,可在配置文件中手动设置 `label`(详见代码生成配置规范)。
SQLite 不支持表备注,代码生成器会使用表名作为默认显示名称。如需自定义,可在配置文件中手动设置 `label`(详见 [CodeGen_代码生成.md](CodeGen_代码生成.md))。
---
@@ -305,7 +307,7 @@ SQLite 不支持表备注,代码生成器会使用表名作为默认显示名
### SQLite 字段备注
SQLite 不支持字段备注(COMMENT),代码生成器会使用字段名作为默认显示名称。如需自定义,可在配置文件中手动设置(详见代码生成配置规范)。
SQLite 不支持字段备注(COMMENT),代码生成器会使用字段名作为默认显示名称。如需自定义,可在配置文件中手动设置(详见 [CodeGen_代码生成.md](CodeGen_代码生成.md))。
---
@@ -485,3 +487,11 @@ CREATE TABLE "user_org" (
- [ ] 自增主键使用 `IDENTITY(1,1)` 而非 `AUTO_INCREMENT`
- [ ] 长文本字段使用 `CLOB` 而非 `LONGTEXT`
- [ ] 检测表是否存在时用 `COUNT(*)` 查询,不用 `USER_TABLES`(存在 schema 错配问题)
---
## 相关文档
- [CodeGen_代码生成.md](CodeGen_代码生成.md) — 按本规范建表后运行代码生成
- [HoTimeDB_使用说明.md](HoTimeDB_使用说明.md) — 运行时 ORM(与建表规范分开)
- [文档索引](README.md)
@@ -7,7 +7,7 @@ HoTime 框架内置优雅停机支持,收到关闭信号后:
1. **立即**对所有新请求返回 `503 Service Unavailable`,触发 nginx 把流量切到其他实例
2. **等待** `DrainTimeout`(默认 5 秒)让 nginx 完成流量切换
3. **等待**所有正在处理的请求完成(最多 `ShutdownTimeout`,默认 30 秒)
4. 以 `exit(0)` 正常退出`run_xbc.cmd` 检测正常退出后不自动重启
4. 以 `exit(0)` 正常退出;若使用进程守护脚本(如 `run_app.cmd`),可检测正常退出后不自动重启
---
@@ -90,7 +90,7 @@ netstat -ano | findstr :9998
taskkill /PID <pid>
:: 3. 观察日志,出现"服务已安全关闭"后,部署新版并启动
:: run_xbc.cmd 检测到 exit(0) 后不会自动重启
:: 若使用 run_app.cmd 守护,检测到 exit(0) 后不会自动重启
:: 4. 对端口 9999 重复以上步骤
netstat -ano | findstr :9999
@@ -100,15 +100,15 @@ taskkill /PID <pid2>
### Linux
```bash
# 1. 查找进程 PID
pgrep -a xbc # 或
ps aux | grep xbc
# 1. 查找进程 PID(按你的二进制名替换)
pgrep -a <your-app> # 或
ps aux | grep <your-app>
# 2. 优雅关闭(发送 SIGTERM
kill <pid>
# 3. 观察日志确认关闭完成
tail -f logs/xbc.log # 等待"服务已安全关闭"
tail -f logs/app.log # 等待"服务已安全关闭"
# 4. 部署新版并启动,再对另一个实例重复
```
@@ -131,12 +131,12 @@ server.Shutdown(ShutdownTimeout=30s)
等待所有进行中请求跑完
os.Exit(0) → run_xbc.cmd 检测 exit(0)不重启
os.Exit(0) → 守护脚本(如 run_app.cmd)可检测 exit(0)不重启
```
### 为何在框架层而非应用层实现
通过 `appIns.Run(Router{...})` 启动服务,信号处理与 `http.Server` 生命周期强绑定,必须在框架的 `Run()` 方法内才能正确管理。应用层无需任何修改,升级框架即自动获得优雅停机能力。
通过 `appIns.Run(Router{...})` 启动服务,信号处理与 `http.Server` 生命周期强绑定,必须在框架的 `Run()` 方法内才能正确管理。应用层无需任何修改,升级框架即自动获得优雅停机能力。
### `sync.Once` 保证幂等性
+191 -559
View File
@@ -1,191 +1,13 @@
# HoTimeDB API 快速参考
## 条件查询语法规则
完整教程见 [HoTimeDB 使用说明](HoTimeDB_使用说明.md)。本文仅收录条件运算符与方法签名速查。
**新版本改进:**
- 多条件自动用 AND 连接,无需手动包装
- 关键字支持大小写(如 `LIMIT``limit` 都有效)
- 新增 `HAVING` 和独立 `OFFSET` 支持
---
```go
// ✅ 推荐:简化语法(多条件自动 AND)
Map{"status": 1, "age[>]": 18}
// 生成: WHERE `status`=? AND `age`>?
## 条件运算符
// ✅ 仍然支持:显式 AND 包装(向后兼容)
Map{
"AND": Map{
"status": 1,
"age[>]": 18,
},
}
多条件默认 AND;关键字大小写均可(`ORDER`/`order`)。
// ✅ 混合条件和特殊关键字
Map{
"status": 1,
"age[>]": 18,
"ORDER": "id DESC", // 或 "order": "id DESC"
"LIMIT": 10, // 或 "limit": 10
}
```
## 基本方法
### 数据库连接
```go
database.SetConnect(func() (master, slave *sql.DB) { ... })
database.InitDb()
```
### 链式查询构建器
```go
// 创建查询构建器
builder := database.Table("tablename")
// 设置条件
builder.Where(key, value)
builder.And(key, value) builder.And(map)
builder.Or(key, value) builder.Or(map)
// JOIN操作
builder.LeftJoin(table, condition)
builder.RightJoin(table, condition)
builder.InnerJoin(table, condition)
builder.FullJoin(table, condition)
builder.Join(map) // 通用JOIN
// 排序和分组
builder.Order(fields...)
builder.Group(fields...)
builder.Limit(args...)
builder.Having(map) // 新增
// 分页
builder.Page(page, pageSize)
builder.Offset(offset) // 新增
// 执行查询
builder.Select(fields...) // 返回 []Map
builder.Get(fields...) // 返回 Map
builder.Count() // 返回 int
builder.Update(data) // 返回 int64
builder.Delete() // 返回 int64
```
## CRUD 操作
### 查询 (Select)
```go
// 基本查询
data := database.Select("table")
data := database.Select("table", "field1,field2")
data := database.Select("table", []string{"field1", "field2"})
data := database.Select("table", "*", whereMap)
// 带JOIN查询
data := database.Select("table", joinSlice, "fields", whereMap)
```
### 获取单条 (Get)
```go
// 自动添加 LIMIT 1
row := database.Get("table", "fields", whereMap)
```
### 插入 (Insert)
```go
id := database.Insert("table", dataMap)
// 返回新插入记录的ID
```
### 批量插入 (Inserts) - 新增
```go
// 使用 []Map 格式,更直观简洁
affected := database.Inserts("table", []Map{
{"col1": "val1", "col2": "val2", "col3": "val3"},
{"col1": "val4", "col2": "val5", "col3": "val6"},
})
// 返回受影响的行数
// 推荐:使用服务器时间(Time2Str),避免数据库时区差异
now := Time2Str(time.Now())
affected := database.Inserts("log", []Map{
{"user_id": 1, "created_time": now},
{"user_id": 2, "created_time": now},
})
```
### 更新 (Update)
```go
affected := database.Update("table", dataMap, whereMap)
// 返回受影响的行数
```
### Upsert - 新增
```go
// 使用 Slice 格式
affected := database.Upsert("table",
dataMap, // 插入数据
Slice{"unique_key"}, // 唯一键
Slice{"col1", "col2"}, // 冲突时更新的字段
)
// 也支持可变参数
affected := database.Upsert("table", dataMap, Slice{"id"}, "col1", "col2")
// 返回受影响的行数
```
### 删除 (Delete)
```go
affected := database.Delete("table", whereMap)
// 返回删除的行数
```
## 聚合函数
### 计数
```go
count := database.Count("table")
count := database.Count("table", whereMap)
count := database.Count("table", joinSlice, whereMap)
```
### 求和
```go
sum := database.Sum("table", "column")
sum := database.Sum("table", "column", whereMap)
```
### 平均值 - 新增
```go
avg := database.Avg("table", "column")
avg := database.Avg("table", "column", whereMap)
```
### 最大值 - 新增
```go
max := database.Max("table", "column")
max := database.Max("table", "column", whereMap)
```
### 最小值 - 新增
```go
min := database.Min("table", "column")
min := database.Min("table", "column", whereMap)
```
## 分页查询
```go
// 设置分页
database.Page(page, pageSize)
// 分页查询
data := database.Page(page, pageSize).PageSelect("table", "fields", whereMap)
```
## 条件语法参考
### 比较操作符
| 写法 | SQL | 说明 |
|------|-----|------|
| `"field": value` | `field = ?` | 等于 |
@@ -194,389 +16,199 @@ data := database.Page(page, pageSize).PageSelect("table", "fields", whereMap)
| `"field[>=]": value` | `field >= ?` | 大于等于 |
| `"field[<]": value` | `field < ?` | 小于 |
| `"field[<=]": value` | `field <= ?` | 小于等于 |
| `"field[~]": "kw"` | `LIKE '%kw%'` | 包含 |
| `"field[~!]": "kw"` | `LIKE 'kw%'` | 开头 |
| `"field[!~]": "kw"` | `LIKE '%kw'` | 结尾 |
| `"field[~~]": "%kw%"` | `LIKE '%kw%'` | 手动 LIKE |
| `"field[<>]": [min,max]` | `BETWEEN ? AND ?` | 区间内 |
| `"field[><]": [min,max]` | `NOT BETWEEN` | 区间外 |
| `"field": [v1,v2]` | `IN (?,?)` | 集合 |
| `"field[!]": [v1,v2]` | `NOT IN` | 非集合 |
| `"field": nil` | `IS NULL` | 空 |
| `"field[!]": nil` | `IS NOT NULL` | 非空 |
| `"field[#]": "balance+1"` | `field = balance+1` | 直接表达式 |
| `"[##]": "a > b"` | `a > b` | 直接 SQL 片段 |
| `"field[#!]": "1"` | `field != 1` | 非参数化不等 |
### 模糊查询
| 写法 | SQL | 说明 |
|------|-----|------|
| `"field[~]": "keyword"` | `field LIKE '%keyword%'` | 包含 |
| `"field[~!]": "keyword"` | `field LIKE 'keyword%'` | 以...开头 |
| `"field[!~]": "keyword"` | `field LIKE '%keyword'` | 以...结尾 |
| `"field[~~]": "%keyword%"` | `field LIKE '%keyword%'` | 手动LIKE |
时间字段推荐 Go 侧传值:`"create_time": Time2Str(time.Now())`(勿用 `NOW()`)。
### 范围查询
| 写法 | SQL | 说明 |
|------|-----|------|
| `"field[<>]": [min, max]` | `field BETWEEN ? AND ?` | 区间内 |
| `"field[><]": [min, max]` | `field NOT BETWEEN ? AND ?` | 区间外 |
### 集合查询
| 写法 | SQL | 说明 |
|------|-----|------|
| `"field": [v1, v2, v3]` | `field IN (?, ?, ?)` | 在集合中 |
| `"field[!]": [v1, v2, v3]` | `field NOT IN (?, ?, ?)` | 不在集合中 |
### NULL查询
| 写法 | SQL | 说明 |
|------|-----|------|
| `"field": nil` | `field IS NULL` | 为空 |
| `"field[!]": nil` | `field IS NOT NULL` | 不为空 |
### 直接SQL
| 写法 | SQL | 说明 |
|------|-----|------|
| `"field[#]": "balance + 1"` | `field = balance + 1` | 直接SQL表达式(数值运算等) |
| `"[##]": "a > b"` | `a > b` | 直接SQL片段 |
| `"field[#!]": "1"` | `field != 1` | 不等于(不参数化) |
> **时间字段推荐写法**:不要用 `"[#]": "NOW()"`,改用 Go 侧传值:
> ```go
> // 赋值当前时间
> "create_time": Time2Str(time.Now())
>
> // 时间范围查询(一天前)
> "create_time[>]": Time2Str(time.Now().AddDate(0, 0, -1))
> ```
> 原因:`NOW()` 使用数据库时区;`Time2Str(time.Now())` 使用应用服务器时区,行为稳定且跨数据库兼容。
## 逻辑连接符
### AND 条件
```go
// 简化语法(推荐)
whereMap := Map{
"status": 1,
"age[>]": 18,
}
// 生成: WHERE `status`=? AND `age`>?
// 显式 AND(向后兼容)
whereMap := Map{
"AND": Map{
"status": 1,
"age[>]": 18,
},
}
```
### OR 条件
```go
whereMap := Map{
"OR": Map{
"status": 1,
"type": 2,
},
}
```
### 嵌套条件
```go
whereMap := Map{
"AND": Map{
"status": 1,
"OR": Map{
"age[<]": 30,
"level[>]": 5,
},
},
}
```
## JOIN 语法
### 传统语法
```go
joinSlice := Slice{
Map{"[>]profile": "user.id = profile.user_id"}, // LEFT JOIN
Map{"[<]department": "user.dept_id = department.id"}, // RIGHT JOIN
Map{"[><]role": "user.role_id = role.id"}, // INNER JOIN
Map{"[<>]group": "user.group_id = group.id"}, // FULL JOIN
}
```
### 链式语法
```go
builder.LeftJoin("profile", "user.id = profile.user_id")
builder.RightJoin("department", "user.dept_id = department.id")
builder.InnerJoin("role", "user.role_id = role.id")
builder.FullJoin("group", "user.group_id = group.id")
```
## 特殊字段语法
### ORDER BY
```go
Map{
"ORDER": []string{"created_time DESC", "id ASC"},
}
// 或
Map{
"order": "created_time DESC", // 支持小写
}
```
### GROUP BY
```go
Map{
"GROUP": []string{"department", "level"},
}
// 或
Map{
"group": "department", // 支持小写
}
```
### HAVING - 新增
```go
Map{
"GROUP": "dept_id",
"HAVING": Map{
"COUNT(*).[>]": 5,
},
}
```
### LIMIT
```go
Map{
"LIMIT": []int{10, 20}, // offset 10, limit 20
}
// 或
Map{
"limit": 20, // limit 20,支持小写
}
```
### OFFSET - 新增
```go
Map{
"LIMIT": 10,
"OFFSET": 20, // 独立的 OFFSET
}
```
## 事务处理
```go
success := database.Action(func(tx HoTimeDB) bool {
// 在这里执行数据库操作
// 返回 true 提交事务
// 返回 false 回滚事务
id := tx.Insert("table", data)
if id == 0 {
return false // 回滚
}
affected := tx.Update("table2", data2, where2)
if affected == 0 {
return false // 回滚
}
return true // 提交
})
```
## 原生SQL执行
### 查询
```go
results := database.Query("SELECT * FROM user WHERE age > ?", 18)
```
### 执行
```go
result, err := database.Exec("UPDATE user SET status = ? WHERE id = ?", 1, 100)
affected, _ := result.RowsAffected()
```
## PostgreSQL 支持 - 新增
### 逻辑与特殊关键字
```go
// 配置 PostgreSQL
database := &db.HoTimeDB{
Type: "postgres", // 设置类型
}
// 框架自动处理差异:
// - 占位符: ? -> $1, $2, $3...
// - 引号: `name` -> "name"
// - Upsert: ON DUPLICATE KEY -> ON CONFLICT
```
## 达梦数据库(DM8)支持
### 连接配置
```go
import (
_ "gitee.com/chunanyong/dm" // vendor 已内置,无需额外安装
)
database := &db.HoTimeDB{
Type: "dm", // 或 "dameng"
}
database.SetConnect(func(err ...*common.Error) (master, slave *sql.DB) {
// schema= 指定当前会话默认搜索 schema
dsn := "dm://SYSDBA:password@127.0.0.1:5236?schema=TEST"
master, _ = sql.Open("dm", dsn)
return master, master
})
```
### 各数据库差异对比
| 特性 | MySQL | PostgreSQL | 达梦 DM8 |
|------|-------|------------|---------|
| 标识符引号 | \`name\` | "name" | "name" |
| 占位符 | ? | $1, $2... | ? |
| Upsert | ON DUPLICATE KEY UPDATE | ON CONFLICT DO UPDATE | MERGE INTO...USING |
| 自增列 | AUTO_INCREMENT | SERIAL | IDENTITY(1,1) |
| 分页 | LIMIT m, n | LIMIT n OFFSET m | LIMIT m, n 或 LIMIT n OFFSET m |
框架自动处理所有差异,业务代码无需修改。
### 达梦注意事项速查
| 场景 | 说明 |
|------|------|
| 标识符大小写 | 双引号内大小写敏感,框架统一使用小写 |
| 保留字 | `admin`/`user`/`order` 等框架自动加双引号,原生SQL需手动处理 |
| 表存在检测 | 用 `COUNT(*)` 代替 `USER_TABLES`schema 错配问题) |
| `Insert` 返回ID | 原生驱动已支持 `LastInsertId()`,与 MySQL 一致 |
| 长文本类型 | DM 用 `CLOB`(对应 MySQL 的 `LONGTEXT` |
| 时间赋值 | **推荐统一用** `Time2Str(time.Now())` 传服务器时间,避免 `NOW()` 时区差异及跨库函数不兼容 |
### 原生 SQL 中的保留字
```go
// 达梦中 admin、user 等是保留字,原生 SQL 必须加双引号
results := database.Query(
`SELECT "id", "name" FROM "admin" WHERE "state" = ?`, 1)
// ORM 方法无需处理,框架自动加引号
results := database.Select("admin", "*", common.Map{"state": 1})
```
## 错误处理
```go
// 检查最后的错误
if database.LastErr.GetError() != nil {
fmt.Println("错误:", database.LastErr.GetError())
}
// 查看最后执行的SQL
fmt.Println("SQL:", database.LastQuery)
fmt.Println("参数:", database.LastData)
```
## 工具方法
### 数据库信息
```go
prefix := database.GetPrefix() // 获取表前缀
dbType := database.GetType() // 获取数据库类型
dialect := database.GetDialect() // 获取方言适配器
```
### 设置模式
```go
database.Mode = 0 // 生产模式
database.Mode = 1 // 测试模式
database.Mode = 2 // 开发模式(输出SQL日志)
```
## 常用查询模式
### 分页列表查询
```go
// 获取总数
total := database.Count("user", Map{"status": 1})
// 分页数据
users := database.Table("user").
Where("status", 1).
Order("created_time DESC").
Page(page, pageSize).
Select("id,name,email,created_time")
// 计算分页信息
totalPages := (total + pageSize - 1) / pageSize
```
### 关联查询
```go
orders := database.Table("order").
LeftJoin("user", "order.user_id = user.id").
LeftJoin("product", "order.product_id = product.id").
Where("order.status", "paid").
Select(`
order.*,
user.name as user_name,
product.title as product_title
`)
```
### 统计查询
```go
stats := database.Select("order",
"user_id, COUNT(*) as order_count, SUM(amount) as total_amount",
Map{
"status": "paid",
"created_time[>]": "2023-01-01",
"GROUP": "user_id",
"ORDER": "total_amount DESC",
})
```
### 批量操作
```go
// 批量插入(使用 []Map 格式)
affected := database.Inserts("user", []Map{
{"name": "用户1", "email": "user1@example.com", "status": 1},
{"name": "用户2", "email": "user2@example.com", "status": 1},
{"name": "用户3", "email": "user3@example.com", "status": 1},
})
// Upsert(插入或更新,使用 Slice 格式)
affected := database.Upsert("user",
Map{"id": 1, "name": "新名称", "email": "new@example.com"},
Slice{"id"},
Slice{"name", "email"},
)
```
## 链式调用完整示例
```go
// 复杂查询链式调用
result := database.Table("order").
LeftJoin("user", "order.user_id = user.id").
LeftJoin("product", "order.product_id = product.id").
Where("order.status", "paid").
And("order.created_time[>]", "2023-01-01").
And(Map{
"OR": Map{
"user.level": "vip",
"order.amount[>]": 1000,
},
}).
Group("user.id").
Having(Map{"total_amount[>]": 500}).
Order("total_amount DESC").
Page(1, 20).
Select(`
user.id,
user.name,
user.email,
COUNT(order.id) as order_count,
SUM(order.amount) as total_amount
`)
Map{"status": 1, "age[>]": 18} // 自动 AND
Map{"AND": Map{...}} / Map{"OR": Map{...}} // 显式逻辑
Map{"AND": Map{"status": 1, "OR": Map{...}}} // 嵌套
Map{"ORDER": "id DESC"} / []string{"a DESC","b"} // ORDER BY
Map{"GROUP": "dept"} / []string{"a","b"} // GROUP BY
Map{"HAVING": Map{"COUNT(*).[>]": 5}} // HAVING
Map{"LIMIT": 20} / []int{10, 20} // LIMIT / offset+limit
Map{"OFFSET": 20} // 独立 OFFSET
```
---
*快速参考版本: 2.1*
*更新日期: 2026年3月*
## 方法签名
**详细说明:**
- [HoTimeDB 使用说明](HoTimeDB_使用说明.md) - 完整教程
### 连接与元信息
| 方法 | 签名 | 说明 |
|------|------|------|
| SetConnect | `SetConnect(func() (master, slave *sql.DB))` | 设置连接并 InitDb |
| InitDb | `InitDb()` | 初始化连接与方言 |
| GetPrefix | `GetPrefix() string` | 表前缀 |
| GetType | `GetType() string` | 库类型 |
| GetDialect | `GetDialect() Dialect` | 方言适配器 |
| GetLast | `GetLast() *DBError` | 最近一次 SQL 快照 |
| GetLastError | `GetLastError() error` | 最近错误 |
| GetLastQuery | `GetLastQuery() string` | 最近 SQL |
| GetLastData | `GetLastData() []interface{}` | 最近参数 |
`Type``mysql` / `sqlite3` / `postgres` / `dm`(或 `dameng`)。
### CRUD
| 方法 | 签名 | 返回 |
|------|------|------|
| Select | `Select(table string, qu ...interface{})` | `[]Map` |
| Get | `Get(table string, qu ...interface{})` | `Map`(自动 LIMIT 1 |
| Insert | `Insert(table string, data map[string]interface{})` | `int64` 新 ID |
| Inserts | `Inserts(table string, dataList []Map)` | `int64` 影响行数 |
| Update | `Update(table string, data Map, where Map)` | `int64` |
| Upsert | `Upsert(table string, data Map, uniqueKeys Slice, updateColumns ...interface{})` | `int64` |
| Delete | `Delete(table string, data map[string]interface{})` | `int64` |
| Page | `Page(page, pageRow int) *HoTimeDB` | 链式分页 |
| PageSelect | `PageSelect(table string, qu ...interface{})` | `[]Map` |
`Select`/`Get`/`PageSelect``qu` 常见形态:字段;字段+wherejoin+字段+where。
```go
database.Select("user")
database.Select("user", "id,name")
database.Select("user", "*", where)
database.Select("user", joinSlice, "fields", where)
database.Get("user", "fields", where)
database.Insert("user", Map{"name": "a"})
database.Inserts("user", []Map{{"name": "a"}, {"name": "b"}})
database.Update("user", Map{"name": "b"}, Map{"id": 1})
database.Upsert("user", data, Slice{"id"}, Slice{"name", "email"})
database.Upsert("user", data, Slice{"id"}, "name", "email")
database.Delete("user", Map{"id": 1})
database.Page(1, 20).PageSelect("user", "id,name", where)
```
### 聚合
| 方法 | 签名 | 返回 |
|------|------|------|
| Count | `Count(table string, qu ...interface{})` | `int` |
| Sum | `Sum(table string, column string, qu ...interface{})` | `float64` |
| Avg | `Avg(table string, column string, qu ...interface{})` | `float64` |
| Max | `Max(table string, column string, qu ...interface{})` | `float64` |
| Min | `Min(table string, column string, qu ...interface{})` | `float64` |
`qu` 可为 where,或 join+where。
```go
database.Count("user")
database.Count("user", where)
database.Count("user", join, where)
database.Sum("order", "amount", where)
database.Avg("order", "amount", where)
database.Max("order", "amount", where)
database.Min("order", "amount", where)
```
### 链式构建器
```go
b := database.Table("tablename") // *HotimeDBBuilder
```
| 方法 | 签名 | 说明 |
|------|------|------|
| Where / And / Or | `(qu ...interface{}) *HotimeDBBuilder` | 键值或 Map |
| LeftJoin / RightJoin / InnerJoin / FullJoin | `(table, condition string)` | JOIN |
| Join | `(qu ...interface{})` | 通用 JOIN Map |
| Order / Group / Limit | `(qu ...interface{})` | 排序/分组/限制 |
| Having | `(qu ...interface{})` | HAVING |
| Page | `(page, pageRow int)` | 分页 |
| Offset | `(offset int)` | 偏移 |
| From | `(table string)` | 换表 |
| Select | `(qu ...interface{}) []Map` | 查询 |
| Get | `(qu ...interface{}) Map` | 单条 |
| Count | `() int` | 计数 |
| Update | `(qu ...interface{}) int64` | 更新 |
| Delete | `() int64` | 删除 |
```go
database.Table("user").Where("status", 1).Order("id DESC").Page(1, 20).Select("id,name")
database.Table("order").LeftJoin("user", "order.user_id = user.id").Where("order.status", "paid").Select("order.*, user.name")
```
### JOIN Map 写法
| 写法 | 类型 |
|------|------|
| `Map{"[>]profile": "user.id = profile.user_id"}` | LEFT |
| `Map{"[<]department": "user.dept_id = department.id"}` | RIGHT |
| `Map{"[><]role": "user.role_id = role.id"}` | INNER |
| `Map{"[<>]group": "user.group_id = group.id"}` | FULL |
### 事务与原生 SQL
| 方法 | 签名 | 说明 |
|------|------|------|
| Action | `Action(func(db HoTimeDB) bool) bool` | `true` 提交 / `false` 回滚 |
| Query | `Query(query string, args ...interface{}) []Map` | 原生查询 |
| Exec | `Exec(query string, args ...interface{}) (sql.Result, error)` | 原生执行 |
```go
ok := database.Action(func(tx HoTimeDB) bool {
if tx.Insert("a", data) == 0 { return false }
return true
})
database.Query("SELECT * FROM user WHERE age > ?", 18)
database.Exec("UPDATE user SET status = ? WHERE id = ?", 1, 100)
```
---
## 方言差异速查
| 特性 | MySQL | PostgreSQL | 达梦 DM8 |
|------|-------|------------|---------|
| 标识符引号 | \`name\` | `"name"` | `"name"` |
| 占位符 | `?` | `$1,$2...` | `?` |
| Upsert | ON DUPLICATE KEY | ON CONFLICT | MERGE INTO |
| 自增 | AUTO_INCREMENT | SERIAL | IDENTITY(1,1) |
| 分页 | LIMIT m,n | LIMIT n OFFSET m | 两者皆可 |
框架自动适配,业务代码通常无需按库分支。达梦:保留字(`admin`/`user`/`order`)ORM 自动加引号,原生 SQL 需手动双引号;时间统一用 `Time2Str(time.Now())`
```go
// Type: "postgres" | "dm" | "dameng" | "mysql" | "sqlite3"
database := &db.HoTimeDB{Type: "dm"}
database.SetConnect(func() (master, slave *sql.DB) {
master, _ = sql.Open("dm", "dm://SYSDBA:pwd@127.0.0.1:5236?schema=TEST")
return master, master
})
```
---
## 常用一行模式
```go
// 分页列表
total := database.Count("user", Map{"status": 1})
rows := database.Table("user").Where("status", 1).Order("id DESC").Page(page, size).Select("id,name")
// 分组统计
database.Select("order", "user_id, COUNT(*) c, SUM(amount) s", Map{
"status": "paid", "GROUP": "user_id", "ORDER": "s DESC",
})
// 查错
if err := database.GetLastError(); err != nil { /* ... */ }
_ = database.GetLastQuery(); _ = database.GetLastData()
```
+2
View File
@@ -1,5 +1,7 @@
# HoTimeDB ORM 使用说明书
> 完整教程见本文;方法签名与条件运算符速查见 [HoTimeDB_API参考.md](HoTimeDB_API参考.md)
## 概述
HoTimeDB是一个基于Golang实现的轻量级ORM框架,参考PHP Medoo设计,提供简洁的数据库操作接口。支持MySQL、SQLite、PostgreSQL、达梦(DM8)等数据库,并集成了缓存、事务、链式查询等功能。
@@ -1,6 +1,6 @@
# HoTime 快速上手指南
5 分钟入门 HoTime 框架。
5 分钟入门 HoTime 框架。完整文档索引见 [README.md](README.md)。
## 安装
@@ -418,79 +418,37 @@ that.SessionsDelete("token", "temp_code", "verify_expire")
## 数据库操作(简要)
### 基础 CRUD
```go
// 查询列表
users := that.Db.Select("user", "*", Map{"status": 1})
// 查询单条
user := that.Db.Get("user", "*", Map{"id": 1})
// 插入
id := that.Db.Insert("user", Map{"name": "test", "age": 18})
that.Db.Update("user", Map{"name": "new"}, Map{"id": 1})
that.Db.Delete("user", Map{"id": 1})
// 批量插入
affected := that.Db.Inserts("user", []Map{
{"name": "user1", "age": 20},
{"name": "user2", "age": 25},
})
// 更新
rows := that.Db.Update("user", Map{"name": "new"}, Map{"id": 1})
// 删除
rows := that.Db.Delete("user", Map{"id": 1})
users = that.Db.Table("user").
Where("status", 1).And("age[>]", 18).
Order("id DESC").Page(1, 10).Select("*")
```
### 链式查询
条件运算符(`[>]` / `[~]` / IN 等)、事务、缓存与方言差异见:
```go
users := that.Db.Table("user").
LeftJoin("order", "user.id=order.user_id").
Where("status", 1).
And("age[>]", 18).
Order("id DESC").
Page(1, 10).
Select("*")
```
- 教程:[HoTimeDB 使用说明](HoTimeDB_使用说明.md)
- 速查:[HoTimeDB API 参考](HoTimeDB_API参考.md)
### 条件语法速查
## 日志:业务 Log 表 vs Seq
| 语法 | 说明 | 示例 |
| 能力 | 用途 | 文档 |
|------|------|------|
| `key` | 等于 | `"id": 1` |
| `key[>]` | 大于 | `"age[>]": 18` |
| `key[<]` | 小于 | `"age[<]": 60` |
| `key[>=]` | 大于等于 | `"age[>=]": 18` |
| `key[<=]` | 小于等于 | `"age[<=]": 60` |
| `key[!]` | 不等于 | `"status[!]": 0` |
| `key[~]` | LIKE | `"name[~]": "test"` |
| `key[<>]` | BETWEEN | `"age[<>]": Slice{18, 60}` |
| `key` | IN | `"id": Slice{1, 2, 3}` |
### 事务
| `that.Log = Map{...}` | 写入业务 `logs` 表(操作审计) | 见下方示例 |
| `seqUrl` + zerolog | 结构化日志推送到 Seq 集中搜索 | [Seq 日志集成](Seq_日志集成.md) |
```go
success := that.Db.Action(func(tx db.HoTimeDB) bool {
tx.Update("user", Map{"balance[#]": "balance - 100"}, Map{"id": 1})
tx.Insert("order", Map{"user_id": 1, "amount": 100})
return true // 返回 true 提交,false 回滚
})
```
> **更多数据库操作**:参见 [HoTimeDB 使用说明](HoTimeDB_使用说明.md)
## 日志记录
```go
// 创建操作日志(自动插入 logs 表)
// 业务操作日志(自动插入 logs 表;框架会补 time、admin_id/user_id、ip 等)
that.Log = Map{
"type": "login",
"action": "用户登录",
"data": Map{"phone": phone},
}
// 框架会自动添加 time, admin_id/user_id, ip 等字段
```
## 扩展功能
@@ -611,5 +569,8 @@ func main() {
---
**下一步**
- [HoTimeDB 使用说明](HoTimeDB_使用说明.md) - 完整数据库教程
- [HoTimeDB API 参考](HoTimeDB_API参考.md) - API 速查手册
- [HoTimeDB 使用说明](HoTimeDB_使用说明.md) — ORM 教程
- [HoTimeDB API 参考](HoTimeDB_API参考.md) API 速查
- [Common 工具类](Common_工具类.md) — Map/Slice/Obj
- [API 测试框架](Testing_API测试框架.md) — 接口测试(优先 `-run` 单测)
- [文档索引](README.md) — 完整阅读路径
+34
View File
@@ -0,0 +1,34 @@
# HoTime 文档索引
推荐按路径阅读,避免同一概念在多份文档里来回找。
## 阅读路径
1. **入门** → [QuickStart_快速上手.md](QuickStart_快速上手.md)
2. **类型与工具** → [Common_工具类.md](Common_工具类.md)
3. **数据库 ORM** → [HoTimeDB_使用说明.md](HoTimeDB_使用说明.md)(教程)→ [HoTimeDB_API参考.md](HoTimeDB_API参考.md)(速查)
4. **开表 / 代码生成** → [DatabaseDesign_数据库设计规范.md](DatabaseDesign_数据库设计规范.md)(前置)→ [CodeGen_代码生成.md](CodeGen_代码生成.md)
5. **接口测试** → [Testing_API测试框架.md](Testing_API测试框架.md)(日常用 `-run` 单测;全量见文末)
6. **运维** → [GracefulShutdown_优雅停机.md](GracefulShutdown_优雅停机.md)、[Seq_日志集成.md](Seq_日志集成.md)
7. **规划** → [ROADMAP_改进规划.md](ROADMAP_改进规划.md)
## 文档一览
| 文档 | 职责 |
|------|------|
| QuickStart_快速上手 | 安装、配置、路由、中间件、最小示例 |
| Common_工具类 | Map / Slice / Obj 与工具函数 |
| HoTimeDB_使用说明 | ORM 完整教程 |
| HoTimeDB_API参考 | ORM 方法与条件语法速查 |
| DatabaseDesign_数据库设计规范 | 表/字段/COMMENT 规范(CodeGen 输入契约) |
| CodeGen_代码生成 | 生成器用法 + codeConfig / rule 配置 |
| Testing_API测试框架 | API 测试;优先 `-run`,全量沉底 |
| GracefulShutdown_优雅停机 | 优雅停机与滚动重启 |
| Seq_日志集成 | Seq 结构化日志 |
| ROADMAP_改进规划 | 待改进项 |
## 易混淆点
- **业务 `that.Log`**:写 `logs` 表;**Seq**:集中式结构化日志,见 Seq 文档。
- **HoTimeDB vs DatabaseDesign**:前者是运行时查库;后者是建表给代码生成用。
- **测试**:开发阶段不要默认 `go test ./app/...` 全量,见 Testing 文首铁律。
+2
View File
@@ -1,5 +1,7 @@
# HoTime 改进规划
> 相关规范:[DatabaseDesign](DatabaseDesign_数据库设计规范.md) · [CodeGen](CodeGen_代码生成.md) · [Testing](Testing_API测试框架.md) · [文档索引](README.md)
本文档记录 HoTime 框架的待改进项和设计思考,供后续版本迭代参考。
---
@@ -1,8 +1,7 @@
# Seq 日志集成
HoTime 框架内置 Seq 日志推送支持。通过在 `config.json` 填写 `seqUrl` 即可激活
所有框架日志(含 `fmt.Println` 等绕过 Logger 的输出)实时推送到 Seq,
通过 Seq Web UI 实现关键词、日期范围、日志级别、结构化字段等多维度搜索。
HoTime 框架内置 Seq 日志推送支持。通过在 `config.json` 填写 `seqUrl` 即可激活
**目标:用 Seq 集中检索替代翻 `logs/*.txt`**;本地 `logFile` 可并行保留。
---
@@ -87,14 +86,40 @@ Seq 中按实例筛选:
---
## stdout 全量捕获
## stdout / stderr 全量捕获
框架启动时(`SetConfig()`自动调用 `redirectStdout()`
`os.Stdout` 和标准 `log` 包重定向到 HoTime Logger
`SetConfig()` 中自动 `redirectStdout` + `redirectStderr`,并桥接 **logrus**(微信 SDK)到同一管道。
- `fmt.Println("xxx")` → 以 `INFO` 级别、`source=stdout` 字段推送到 Seq
- `log.Printf("xxx")` → 同上
- 控制台仍然能看到这些输出(输出管道由 goroutine 实时转发)
| 写法 | Seq 字段 | 说明 |
|------|----------|------|
| `fmt.Println` / `log.Println` | `source=stdout` | **不受 `logLevel` 影响**`logLevel=0` 也会进 Seq |
| 写 `os.Stderr` 的包 | `source=stderr` | 同上,Error 级别 |
| `logrus.Info`wechat | `source=stdout` | redirect 后 `logrus.SetOutput` 桥接 |
| `that.Log.Info/Error...` | 结构化字段 | 经 `multiWriter` → SeqWriter |
| MySQL driver 内部错误 | `source=mysql-driver` | `SetLogger` 适配器 |
| 框架 `recover` 到的 panic | `source=panic` + `stack` | 代码层原因与调用栈 |
单行日志上限约 **10MB**(适配 `GetReqMap` 打整包 body);超长会截断并标注 `...(truncated)`
**已知不进 Seq(文档边界):**
- 未 `recover`、进程直接崩溃的 runtime 栈(写 fd2,未做 Dup2
- 达梦驱动自有文件日志(vendor 独立写盘)
- `seqUrl` 挂上之前的极早期引导日志
---
## 捕获矩阵速查
| 来源 | 进 Seq? | 检索示例 |
|------|----------|----------|
| `that.Log.*` | 是 | `@mt like '%关键词%'` |
| `log.Println` / `fmt.Println` | 是 | `source = 'stdout'` |
| logrus / 标准 `log` | 是 | `source = 'stdout'` |
| stderr 重定向 | 是 | `source = 'stderr'` |
| recover panic | 是 | `source = 'panic'` |
| MySQL driver | 是 | `source = 'mysql-driver'` |
| 队列满丢弃 | 否(计数) | 终端可见 `[seq] queue full` |
---
@@ -116,6 +141,16 @@ Seq 提供 Windows MSI 安装包和 Docker 镜像,单机免费,无外部数
| 日志级别 | `@l = 'Error'` |
| 特定实例 | `instance = '8085'` |
| stdout 来源 | `source = 'stdout'` |
| panic 恢复 | `source = 'panic'` |
| 请求体调试 | `请求参数GetReqMap``source = 'stdout'` |
| 调用位置 | `caller like '%order.go%'` |
| 组合查询 | `@l = 'Error' and instance = '8086' and @mt like '%超时%'` |
| 日期范围 | 右上角时间选择器,支持精确到秒 |
---
## 相关文档
- [QuickStart_快速上手.md](QuickStart_快速上手.md) — 其中的 `that.Log` 是业务 logs 表,与本文 Seq 推送不同
- [GracefulShutdown_优雅停机.md](GracefulShutdown_优雅停机.md)
- [文档索引](README.md)
+125 -215
View File
@@ -2,10 +2,17 @@
不启动 HTTP 服务、自动事务回滚、链式 API、覆盖率报告、API 调试控制台生成。
> **测试执行铁律(日常开发 / AI 辅助必读)**
>
> - **只允许**用 `-run` 精确跑当前接口或当前控制器
> - **禁止**把 `go test ./app/... -v`(不带 `-run`)当作默认命令
> - 全量回归与覆盖率报告见文末「全量回归与覆盖率」,仅 CI / 发版前使用
## 目录
- [概述](#概述)
- [快速开始](#快速开始)
- [运行测试(推荐:-run 单测)](#运行测试推荐-run-单测)
- [用例编写范式](#用例编写范式)
- [错误用例编写规范](#1-错误用例编写规范)
- [测试数据准备](#2-测试数据准备)
@@ -14,14 +21,11 @@
- [简写支持说明](#简写支持说明)
- [核心类型](#核心类型)
- [链式 API 参考](#链式-api-参考)
- [API 速查表](#api-速查表)
- [事务隔离机制](#事务隔离机制)
- [覆盖率报告](#覆盖率报告)
- [api-spec.json 覆盖率字段说明](#api-specjson-覆盖率字段说明)
- [API 调试控制台](#api-调试控制台)
- [指定运行范围](#指定运行范围)
- [并发保护与缓存隔离](#并发保护与缓存隔离)
- [二进制与非 JSON 响应校验](#二进制与非-json-响应校验)
- [全量回归与覆盖率(仅 CI / 发版前)](#全量回归与覆盖率仅-ci--发版前)
---
@@ -185,6 +189,7 @@ func TestMain(m *testing.M) {
"app": {Proj: Project, Tests: ProjectTest},
})
code := m.Run()
// 以下两行适合 CI / 全量回归;日常用 -run 单测时也会执行,但覆盖率主要在全量时有意义
testApp.PrintCoverage()
testApp.GenerateSwagger("My API", "1.0.0", testApp.Config.GetString("tpt"))
os.Exit(code)
@@ -207,13 +212,30 @@ app/
└── init_test.go ← 测试注册 (ProjectTest) + TestMain + TestApi
```
### 运行测试
### 运行测试(推荐:`-run` 单测)
`RunTests` 使用 Go 标准 `t.Run()` 子测试,**日常开发请始终加 `-run`**
```bash
go test ./app/... -v
# 只跑 order/create 接口(推荐,开发默认)
go test ./app/ -v -run TestApi/app/order/create
# 只跑 order 控制器
go test ./app/ -v -run TestApi/app/order
# 只跑某一条用例
go test ./app/ -v -run "TestApi/app/order/create/正常创建订单"
# 同时跑 user 和 order
go test ./app/ -v -run "TestApi/app/(user|order)"
# 跑所有控制器中包含「未登录」的用例
go test ./app/ -v -run "TestApi/.*/.*未登录"
```
> **注意**`TestMain` 中的 `os.Chdir("..")` 确保 `go test ./app/` 的工作目录为项目根目录,使配置文件路径和模板输出路径正确。
>
> 全量 `go test ./app/... -v`(不带 `-run`)会跑完所有用例并触发覆盖率扫描,项目变大后很慢且容易牵连无关失败。**仅在 CI / 发版前使用**,说明见文末。
---
@@ -628,94 +650,29 @@ resp.ExpectResult(Map{"version": "sample", "features": Slice{}})
| `resp.ExpectResult(sample)` | `*ApiResponse` | 后置结构校验(类型样本) |
| `resp.Fail(msg)` | — | 手动标记测试失败 |
---
## API 速查表
以下为各种参数组合的快速参考。`a``*Api` 参数。
### 请求方式
### 常用写法速查
```go
// POST JSON(最常见)
a.JSON(Map{"name": "test", "password": "123"}).Post("描述", 0, Map{...})
// POST Form 表单
a.Form(Map{"shop_id": "1", "id": "5"}).Post("表单提交", 0, Map{...})
// GET 无参数
a.Get("描述", 0, Map{...})
// GET + URL 参数
a.Query(Map{"shop_id": "1", "page": "1"}).Get("查询列表", 0, Map{...})
// PUT
// POST JSON / Form / GET / PUT / DELETE
a.JSON(Map{"name": "test"}).Post("描述", 0, Map{...})
a.Form(Map{"shop_id": "1"}).Post("表单提交", 0, Map{...})
a.Query(Map{"page": "1"}).Get("查询列表", 0, Map{...})
a.Get("无参 GET", 0, Map{...})
a.JSON(Map{"name": "新名字"}).Put("更新", 0, Map{...})
// DELETE + URL 参数
a.Query(Map{"id": "5"}).Delete("删除", 0, "操作成功")
```
### 登录态
// 登录态
a.WithSession(Map{"user_id": int64(1)}).JSON(Map{...}).Post("更新", 0, Map{...})
```go
// 需登录 + GET
a.WithSession(Map{"user_id": int64(1)}).Get("已登录访问", 0, Map{...})
// 需登录 + GET + URL 参数
a.WithSession(Map{"user_id": int64(1)}).Query(Map{"shop_id": "1"}).Get("带参查询", 0, Map{...})
// 需登录 + POST JSON
a.WithSession(Map{"user_id": int64(1)}).JSON(Map{"name": "新名字"}).Post("更新信息", 0, Map{...})
```
### 混合参数
```go
// URL 参数 + JSON body
// 混合参数 / 上传
a.Query(Map{"token": "abc"}).JSON(Map{"phone": "138"}).Post("混合传参", 0, Map{...})
// 文件上传
a.File("file", "photo.png", imgBytes).Post("上传文件", 0, Map{...})
// 文件 + 表单字段
a.File("file", "photo.png", imgBytes).Form(Map{"type": "avatar"}).Post("上传头像", 0, Map{...})
```
### Verify 校验
```go
// 纯 GET + Verify(无需 Form/JSON/Query
a.Verify(func(a *Api) error {
result := a.Resp().GetBody().GetMap("result")
if result.GetString("version") == "" {
return fmt.Errorf("缺少 version 字段")
}
return nil
}).Get("获取系统信息", 0, Map{"version": "sample"})
// Form + Verify + 响应值断言 + DB 校验
a.Form(Map{"name": "测试"}).
Verify(func(a *Api) error {
result := a.Resp().GetBody().GetMap("result")
if result.GetCeilInt64("id") <= 0 {
return fmt.Errorf("id 无效")
}
row := a.DB().Get("order", "id", Map{"id": result.GetCeilInt64("id")})
if row == nil {
return fmt.Errorf("数据库未写入")
}
return nil
}).
Post("创建订单", 0, Map{"id": int64(1), "name": "sample"})
```
### 备注
```go
// 备注显示在调试控制台用例详情中(没有备注时不显示)
a.Note("sign = MD5(smsProxyKey+timestamp)").
Form(Map{"phone": "138", "code": "1234"}).Post("正常发送", 0, Map{...})
// Verify + 备注
a.Note("sign = MD5(key+timestamp)").
Form(Map{"phone": "138"}).
Verify(func(a *Api) error { /* a.Resp() / a.DB() */ return nil }).
Post("正常发送", 0, Map{...})
```
---
@@ -761,110 +718,6 @@ that.Db.Action(func(db HoTimeDB) bool {
---
## 覆盖率报告
`PrintCoverage()` 自动对比 `Proj`(所有路由)和 `ProjTest`(有测试的路由),在 `TestMain` 中调用:
```go
func TestMain(m *testing.M) {
// ...
code := m.Run()
testApp.PrintCoverage() // 在测试运行完成后输出报告
os.Exit(code)
}
```
**输出示例(全部通过):**
```
========== API 测试覆盖率报告 ==========
总接口: 42 | 已覆盖: 6 | 覆盖率: 14.3%
总用例: 16 | 通过: 16 | 未通过: 0 | 通过率: 100.0%
---------- 通过的接口 ----------
✓ /api/user/login 4 用例 (4 通过, 0 失败) 12ms
✓ /api/user/info 2 用例 (2 通过, 0 失败) 5ms
✓ /api/user/token 2 用例 (2 通过, 0 失败) 4ms
✓ /api/user/create 4 用例 (4 通过, 0 失败) 18ms
✓ /api/user/forget 2 用例 (2 通过, 0 失败) 6ms
✓ /api/user/file 2 用例 (2 通过, 0 失败) 9ms
未覆盖的接口: (36 个)
- api/goods/list
- api/goods/create
- api/order/list
- ...
========================================
```
**输出示例(有失败):**
```
========== API 测试覆盖率报告 ==========
总接口: 42 | 已覆盖: 6 | 覆盖率: 14.3%
总用例: 16 | 通过: 14 | 未通过: 2 | 通过率: 87.5%
---------- 未通过的接口 (1个) ----------
✗ /api/order/create 5 用例 (3 通过, 2 失败) 25ms
[失败] 正常创建订单 — Verify: order 表未写入订单记录
[失败] 库存扣减 — 状态码不匹配: 期望 0, 实际 4
---------- 通过的接口 ----------
✓ /api/user/login 4 用例 (4 通过, 0 失败) 12ms
✓ /api/user/info 2 用例 (2 通过, 0 失败) 5ms
...
未覆盖的接口: (36 个)
- api/goods/list
- ...
========================================
```
报告分为四个区域:
- **汇总区**:接口覆盖率 + 用例通过率,一目了然
- **未通过的接口**:失败接口单独置顶,每个失败用例附带失败原因(状态码不匹配/消息不匹配/响应结构校验失败/Verify 校验失败)
- **通过的接口**:正常通过的接口列表
- **未覆盖的接口**:尚未编写测试的接口
### api-spec.json 覆盖率字段说明
`GenerateSwagger` 生成的每个 `api-spec.json` 文件中,每条 `endpoint` 记录包含以下与测试状态相关的字段:
```json
{
"path": "/api/user/login",
"project": "api",
"ctr": "user",
"method": "POST",
"summary": "用户登录",
"tested": true,
"params": [
{ "name": "name", "required": true, "type": "string", "example": "13800138000", "in": "json" },
{ "name": "password", "required": false, "type": "string", "example": "123456", "in": "json" }
],
"cases": [
{
"name": "正常登录",
"method": "POST",
"passed": true,
"note": "备注内容(可选,未调用 .Note() 则不出现该字段)",
"query": {},
"json": { "name": "13800138000", "password": "123456" },
"response": { "status": 0, "msg": "ok", "data": {} }
},
{
"name": "密码为空",
"method": "POST",
"passed": true,
"json": { "name": "13800138000", "password": "" },
"response": { "status": 4, "msg": "用户名或密码不能为空" }
}
]
}
```
---
## API 调试控制台
`GenerateSwagger` 按模块生成独立的交互式 API 调试控制台(暗色高对比度主题),每个模块一个子目录,支持独立分发。
@@ -903,32 +756,6 @@ testApp.GenerateSwagger(
---
## 指定运行范围
`RunTests` 使用 Go 标准 `t.Run()` 子测试机制,天然支持 `-run` 参数过滤:
```bash
# 运行全部测试
go test ./app/... -v
# 只跑 order 控制器的所有接口
go test ./app/... -v -run TestApi/app/order
# 只跑 order/create 接口
go test ./app/... -v -run TestApi/app/order/create
# 只跑 create 接口中"正常创建订单"这一个用例
go test ./app/... -v -run "TestApi/app/order/create/正常创建订单"
# 同时跑 user 和 order 两个控制器
go test ./app/... -v -run "TestApi/app/(user|order)"
# 跑所有控制器中包含"未登录"的用例
go test ./app/... -v -run "TestApi/.*/.*未登录"
```
---
## 并发保护与缓存隔离
框架自动处理以下问题,编写测试时无需关心:
@@ -1027,3 +854,86 @@ a.Verify(func(a *Api) error {
> **注意**:二进制/纯文本响应不走 status 校验(JSON 解析失败后 `Body` 为空 Map`status` 默认 0),
> 所以 `Get("描述", 0)` 只是形式上通过。**真正的校验逻辑必须写在 Verify 中**,对 `RawBody` 做内容断言。
## 全量回归与覆盖率(仅 CI / 发版前)
> **本节仅适用于 CI 流水线或发版前人工回归。** 日常开发与 AI 辅助请使用文首的 `-run` 单测命令,不要执行本节命令。
### 全量命令
```bash
# 跑完当前包(或 ./app/...)下全部 API 测试——慢,且任一无关失败都会打断你
go test ./app/... -v
```
`TestMain` 里常见的收尾逻辑(全量跑完后才有意义):
```go
func TestMain(m *testing.M) {
// ...
code := m.Run()
testApp.PrintCoverage() // 全量跑完后输出覆盖率
testApp.GenerateSwagger("My API", "1.0.0", testApp.Config.GetString("tpt"))
os.Exit(code)
}
```
### 覆盖率报告
`PrintCoverage()` 自动对比 `Proj`(所有路由)和 `ProjTest`(有测试的路由):
**输出示例(全部通过):**
```
========== API 测试覆盖率报告 ==========
总接口: 42 | 已覆盖: 6 | 覆盖率: 14.3%
总用例: 16 | 通过: 16 | 未通过: 0 | 通过率: 100.0%
---------- 通过的接口 ----------
✓ /api/user/login 4 用例 (4 通过, 0 失败) 12ms
...
未覆盖的接口: (36 个)
- api/goods/list
- ...
========================================
```
**输出示例(有失败):**
```
========== API 测试覆盖率报告 ==========
总接口: 42 | 已覆盖: 6 | 覆盖率: 14.3%
总用例: 16 | 通过: 14 | 未通过: 2 | 通过率: 87.5%
---------- 未通过的接口 (1个) ----------
✗ /api/order/create 5 用例 (3 通过, 2 失败) 25ms
[失败] 正常创建订单 — Verify: order 表未写入订单记录
...
========================================
```
报告分为四个区域:
- **汇总区**:接口覆盖率 + 用例通过率
- **未通过的接口**:失败接口单独置顶,附带失败原因
- **通过的接口**:正常通过的接口列表
- **未覆盖的接口**:尚未编写测试的接口
### api-spec.json 覆盖率字段说明
`GenerateSwagger` 生成的每个 `api-spec.json` 中,每条 `endpoint` 可含:
```json
{
"path": "/api/user/login",
"tested": true,
"cases": [
{
"name": "正常登录",
"passed": true,
"note": "备注(可选)",
"json": { "name": "13800138000", "password": "123456" },
"response": { "status": 0, "msg": "ok", "data": {} }
}
]
}
```
+205
View File
@@ -0,0 +1,205 @@
package log
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
logrus "github.com/sirupsen/logrus"
"github.com/rs/zerolog"
)
// captureBuf 挂到 multiWriter,收集 EmitCaptured 写入的 JSON 行。
type captureBuf struct {
mu sync.Mutex
lines []string
}
func (c *captureBuf) Write(p []byte) (int, error) {
c.mu.Lock()
c.lines = append(c.lines, string(bytes.TrimSpace(p)))
c.mu.Unlock()
return len(p), nil
}
func (c *captureBuf) lastJSON() map[string]interface{} {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.lines) == 0 {
return nil
}
var m map[string]interface{}
_ = json.Unmarshal([]byte(c.lines[len(c.lines)-1]), &m)
return m
}
func newLoggerWithCapture(logLevel int) (*Logger, *captureBuf) {
buf := &captureBuf{}
l := NewLogger(logLevel, "", 0)
l.mw.add(buf)
return l, buf
}
func startFakeSeqServer(t *testing.T, onBody func(string)) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/events/raw" {
http.NotFound(w, r)
return
}
body, _ := io.ReadAll(r.Body)
onBody(string(body))
w.WriteHeader(http.StatusCreated)
}))
}
func TestEmitCaptured_LogLevelZero(t *testing.T) {
l, buf := newLoggerWithCapture(0)
l.EmitCaptured(zerolog.InfoLevel, "stdout", "请求参数GetReqMap /api/test")
m := buf.lastJSON()
if m == nil {
t.Fatal("expected captured line")
}
if m["source"] != "stdout" {
t.Fatalf("source=%v want stdout", m["source"])
}
if m["level"] != "info" {
t.Fatalf("level=%v want info", m["level"])
}
if !strings.Contains(m["message"].(string), "GetReqMap") {
t.Fatalf("message=%v", m["message"])
}
}
func TestEmitCaptured_StderrSource(t *testing.T) {
l, buf := newLoggerWithCapture(0)
l.EmitCaptured(zerolog.ErrorLevel, "stderr", "driver error")
m := buf.lastJSON()
if m["source"] != "stderr" {
t.Fatalf("source=%v want stderr", m["source"])
}
if m["level"] != "error" {
t.Fatalf("level=%v want error", m["level"])
}
}
func TestToClef_PlainText(t *testing.T) {
sw := &SeqWriter{instance: "127.0.0.1:8081"}
clef := sw.toClef([]byte("plain log line\n"))
var m map[string]interface{}
if err := json.Unmarshal(clef, &m); err != nil {
t.Fatal(err)
}
if m["@mt"] != "plain log line" {
t.Fatalf("@mt=%v", m["@mt"])
}
if m["source"] != "stdout" {
t.Fatalf("source=%v", m["source"])
}
if m["@l"] != "Information" {
t.Fatalf("@l=%v", m["@l"])
}
}
func TestStdoutCapture_LongLine(t *testing.T) {
l, buf := newLoggerWithCapture(1)
pr, pw := io.Pipe()
done := make(chan struct{})
go func() {
CaptureStream(l, zerolog.InfoLevel, "stdout", pr)
close(done)
}()
longLine := strings.Repeat("x", 70*1024) + "\n"
shortLine := "after-long-line\n"
if _, err := pw.Write([]byte(longLine)); err != nil {
t.Fatal(err)
}
if _, err := pw.Write([]byte(shortLine)); err != nil {
t.Fatal(err)
}
time.Sleep(100 * time.Millisecond)
buf.mu.Lock()
n := len(buf.lines)
buf.mu.Unlock()
if n < 2 {
t.Fatalf("expected >=2 captured lines, got %d", n)
}
last := buf.lastJSON()
if last == nil || !strings.Contains(last["message"].(string), "after-long-line") {
t.Fatalf("short line after long line not captured: %v", last)
}
_ = pw.Close()
<-done
}
func TestLogrusBridge_AfterRedirect(t *testing.T) {
l, buf := newLoggerWithCapture(1)
pr, pw := io.Pipe()
go CaptureStream(l, zerolog.InfoLevel, "stdout", pr)
logrus.SetOutput(pw)
logrus.SetLevel(logrus.InfoLevel)
logrus.Info("wechat-sdk-log")
time.Sleep(100 * time.Millisecond)
m := buf.lastJSON()
if m == nil || !strings.Contains(m["message"].(string), "wechat-sdk-log") {
t.Fatalf("logrus not captured: %v", m)
}
}
func TestSeqWriter_FakeServer(t *testing.T) {
var gotBody string
var mu sync.Mutex
srv := startFakeSeqServer(t, func(body string) {
mu.Lock()
gotBody = body
mu.Unlock()
})
defer srv.Close()
l := NewLogger(1, "", 0)
l.SetSeqWriter(srv.URL, "", "test:8081")
l.Info().Msg("seq-test-message")
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
mu.Lock()
b := gotBody
mu.Unlock()
if strings.Contains(b, "seq-test-message") {
return
}
time.Sleep(50 * time.Millisecond)
}
t.Fatalf("Seq did not receive message, body=%q", gotBody)
}
func TestEmitRecoveredPanic_LogFields(t *testing.T) {
l, buf := newLoggerWithCapture(0)
l.EmitRecoveredPanic("test panic reason")
m := buf.lastJSON()
if m == nil {
t.Fatal("no output")
}
if m["source"] != "panic" {
t.Fatalf("source=%v", m["source"])
}
if m["level"] != "error" {
t.Fatalf("level=%v", m["level"])
}
if !strings.Contains(m["message"].(string), "test panic reason") {
t.Fatalf("message=%v", m["message"])
}
if stack, ok := m["stack"].(string); !ok || stack == "" {
t.Fatalf("stack missing: %v", m["stack"])
}
}
+86 -4
View File
@@ -18,6 +18,27 @@ import (
"github.com/rs/zerolog"
)
const maxCaptureLine = 10 << 20 // 10MB,支持 GetReqMap 等大 body 单行日志
var (
origStderr io.Writer
origStderrOnce sync.Once
)
func saveOrigStderr() {
origStderrOnce.Do(func() {
origStderr = os.Stderr
})
}
func seqWriteStderr(format string, args ...interface{}) {
if origStderr != nil {
fmt.Fprintf(origStderr, format, args...)
} else {
fmt.Fprintf(os.Stderr, format, args...)
}
}
// ErrorRecord 错误历史记录条目
type ErrorRecord struct {
Err error
@@ -43,6 +64,7 @@ type Logger struct {
// logFile: 文件路径模板(如 "logs/20060102.txt"),空则不写文件
// maxErrors: 错误历史最大条数,0 则不记录
func NewLogger(logLevel int, logFile string, maxErrors int) *Logger {
saveOrigStderr()
zerolog.CallerMarshalFunc = callerMarshalFunc
zerolog.TimeFieldFormat = "2006-01-02 15:04:05"
@@ -97,6 +119,7 @@ func NewLogger(logLevel int, logFile string, maxErrors int) *Logger {
// NewLoggerNoCaller 创建不带自动 Caller 字段的日志实例(用于访问日志等 caller 无意义的场景)
func NewLoggerNoCaller(logLevel int, logFile string, maxErrors int) *Logger {
saveOrigStderr()
zerolog.CallerMarshalFunc = callerMarshalFunc
zerolog.TimeFieldFormat = "2006-01-02 15:04:05"
@@ -181,6 +204,62 @@ func (l *Logger) GetLevel() int {
return l.logLevel
}
// EmitCaptured 将捕获的 stdout/stderr 等输出直写 multiWriter,绕过主 Logger 的 level 过滤。
// 保证 logLevel=0 时 fmt.Println / log.Println 仍能进 Seq。
func (l *Logger) EmitCaptured(level zerolog.Level, source, msg string) {
if l == nil || l.mw == nil || msg == "" {
return
}
zl := zerolog.New(l.mw)
zl.WithLevel(level).Timestamp().Str("source", source).Msg(msg)
}
// EmitRecoveredPanic 记录 recover 到的 panic:错误原因 + 调用栈,source=panic。
func (l *Logger) EmitRecoveredPanic(err interface{}) {
if l == nil || l.mw == nil || err == nil {
return
}
zl := zerolog.New(l.mw)
zl.WithLevel(zerolog.ErrorLevel).Timestamp().
Str("source", "panic").
Str("stack", string(debugStack())).
Msg(fmt.Sprint(err))
}
func debugStack() []byte {
buf := make([]byte, 64*1024)
n := runtime.Stack(buf, false)
return buf[:n]
}
// CaptureStream 从 reader 持续读取行并写入 Logger(用于 stdout/stderr 管道)。
// 单行超过 maxCaptureLine 时截断并标注,读取出错后重启读取,避免永久失效。
func CaptureStream(l *Logger, level zerolog.Level, source string, r io.Reader) {
rd := bufio.NewReader(r)
for {
line, err := rd.ReadString('\n')
if len(line) > 0 {
msg := strings.TrimRight(line, "\r\n")
if msg != "" {
if len(msg) > maxCaptureLine {
msg = msg[:maxCaptureLine] + "...(truncated)"
}
l.EmitCaptured(level, source, msg)
}
}
if err != nil {
if err == io.EOF {
if len(line) == 0 {
return
}
continue
}
l.EmitCaptured(zerolog.ErrorLevel, source+"-capture", err.Error())
return
}
}
}
// --- 链式调用 API ---
// 以下方法兼容两种调用风格:
// 无参数:返回 *zerolog.Event 用于链式调用,如 l.Error().Str("k","v").Msg("...")
@@ -501,7 +580,10 @@ func (w *SeqWriter) Write(p []byte) (int, error) {
select {
case w.queue <- clef:
default:
atomic.AddInt64(&w.dropped, 1)
n := atomic.AddInt64(&w.dropped, 1)
if n == 1 || n%100 == 0 {
seqWriteStderr("[seq] queue full, dropped=%d\n", n)
}
}
return len(p), nil
}
@@ -600,7 +682,7 @@ func (w *SeqWriter) flush(batch [][]byte) {
}
req, err := http.NewRequest("POST", w.seqUrl+"/api/events/raw?clef", &buf)
if err != nil {
fmt.Fprintf(os.Stderr, "[seq] build request error: %v\n", err)
seqWriteStderr("[seq] build request error: %v\n", err)
return
}
req.Header.Set("Content-Type", "application/vnd.serilog.clef")
@@ -609,13 +691,13 @@ func (w *SeqWriter) flush(batch [][]byte) {
}
resp, err := w.client.Do(req)
if err != nil {
fmt.Fprintf(os.Stderr, "[seq] send error: %v (dropped=%d)\n", err, atomic.LoadInt64(&w.dropped))
seqWriteStderr("[seq] send error: %v (dropped=%d)\n", err, atomic.LoadInt64(&w.dropped))
return
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
fmt.Fprintf(os.Stderr, "[seq] server returned %d: %s\n", resp.StatusCode, string(body))
seqWriteStderr("[seq] server returned %d: %s\n", resp.StatusCode, string(body))
}
}