diff --git a/.cursor/plans/api接口测试方案_05b9404e.plan.md b/.cursor/plans/api接口测试方案_05b9404e.plan.md index f254910..9292a50 100644 --- a/.cursor/plans/api接口测试方案_05b9404e.plan.md +++ b/.cursor/plans/api接口测试方案_05b9404e.plan.md @@ -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` diff --git a/.cursor/plans/api测试框架完整实现_ce7fcbde.plan.md b/.cursor/plans/api测试框架完整实现_ce7fcbde.plan.md index d977787..0c72674 100644 --- a/.cursor/plans/api测试框架完整实现_ce7fcbde.plan.md +++ b/.cursor/plans/api测试框架完整实现_ce7fcbde.plan.md @@ -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) } diff --git a/.cursor/plans/hotime_tdd_skill_a252ff6f.plan.md b/.cursor/plans/hotime_tdd_skill_a252ff6f.plan.md index 120caf0..dc44753 100644 --- a/.cursor/plans/hotime_tdd_skill_a252ff6f.plan.md +++ b/.cursor/plans/hotime_tdd_skill_a252ff6f.plan.md @@ -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 日志行为(框架自动处理)** diff --git a/.cursor/plans/objtoobj_问题修正_a5e6aae7.plan.md b/.cursor/plans/objtoobj_问题修正_a5e6aae7.plan.md index 9f88e38..0d33c04 100644 --- a/.cursor/plans/objtoobj_问题修正_a5e6aae7.plan.md +++ b/.cursor/plans/objtoobj_问题修正_a5e6aae7.plan.md @@ -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 { diff --git a/.cursor/plans/优雅停机方案_0b2a1432.plan.md b/.cursor/plans/优雅停机方案_0b2a1432.plan.md index e822ce5..334584f 100644 --- a/.cursor/plans/优雅停机方案_0b2a1432.plan.md +++ b/.cursor/plans/优雅停机方案_0b2a1432.plan.md @@ -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 判断: diff --git a/.cursor/plans/补充_note_方法示例_d1af0e5b.plan.md b/.cursor/plans/补充_note_方法示例_d1af0e5b.plan.md index 54dee3c..e5bc95e 100644 --- a/.cursor/plans/补充_note_方法示例_d1af0e5b.plan.md +++ b/.cursor/plans/补充_note_方法示例_d1af0e5b.plan.md @@ -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`) diff --git a/.gitignore b/.gitignore index b4f9c15..58a78c6 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,9 @@ *.sql *.py +# Cursor / agent debug NDJSON leftovers +debug-*.log +**/debug-*.log + # macOS .DS_Store diff --git a/README.md b/README.md index 1d10ba3..da7b35d 100644 --- a/README.md +++ b/README.md @@ -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 --- diff --git a/application.go b/application.go index ae669a4..b15d1b1 100644 --- a/application.go +++ b/application.go @@ -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) - } - } - if err := scanner.Err(); err != nil { - l.Errorf("[stdout redirect] scanner error: %v", err) - } - }() + go log.CaptureStream(l, zerolog.InfoLevel, "stdout", r) + return w +} + +// bridgeThirdPartyStdout 将 import 时钉死原始 stdout 的第三方日志指到捕获管道。 +func bridgeThirdPartyStdout(w *os.File) { + if w == nil { + return + } + logrus.SetOutput(w) } // redirectStderr 重定向 os.Stderr 到 HoTime Logger(level=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 diff --git a/docs/CodeConfig_代码生成配置规范.md b/docs/CodeGen_代码生成.md similarity index 50% rename from docs/CodeConfig_代码生成配置规范.md rename to docs/CodeGen_代码生成.md index 60e7311..9564602 100644 --- a/docs/CodeConfig_代码生成配置规范.md +++ b/docs/CodeGen_代码生成.md @@ -1,10 +1,41 @@ -# 代码生成配置规范 +# HoTime 代码生成 -本文档详细说明 HoTime 框架代码生成器的配置体系,包括 `config.json` 中的 `codeConfig`、菜单权限配置和字段规则配置。 +前置依赖:生成前请先满足 [数据库设计规范](DatabaseDesign_数据库设计规范.md)(表命名、外键、COMMENT、必有字段)。代码生成器依赖规范的表结构与字段备注,才能正确识别类型、选项、外键关联与显示标签。 + +`code` 包提供了 HoTime 框架的自动代码生成功能,能够根据数据库表结构自动生成 CRUD 接口代码和配置文件。 + +## 目录 + +- [一、功能概述](#一功能概述) +- [二、使用方法](#二使用方法) +- [三、配置体系](#三配置体系) + - [3.1 codeConfig](#31-codeconfig) + - [3.2 菜单权限配置(admin.json)](#32-菜单权限配置adminjson) + - [3.3 字段规则配置(rule.json)](#33-字段规则配置rulejson) + - [3.4 配置检查清单](#34-配置检查清单) +- [四、生成规则与产物](#四生成规则与产物) + - [4.1 默认字段规则](#41-默认字段规则) + - [4.2 数据类型映射](#42-数据类型映射) + - [4.3 字段备注解析](#43-字段备注解析) + - [4.4 外键关联](#44-外键关联) + - [4.5 生成的代码结构](#45-生成的代码结构) + - [4.6 多库与备注注意事项](#46-多库与备注注意事项) + - [4.7 最佳实践](#47-最佳实践) +- [五、相关文档](#五相关文档) --- -## 配置体系概览 +## 一、功能概述 + +代码生成器可以: + +1. **自动读取数据库表结构** - 支持 MySQL、SQLite 和达梦 DM8 +2. **生成 CRUD 接口** - 增删改查、搜索、分页 +3. **生成配置文件** - 表字段配置、菜单配置、权限配置 +4. **智能字段识别** - 根据字段名自动识别类型和权限 +5. **支持表关联** - 自动识别外键关系 + +配置体系概览: ``` config.json @@ -19,9 +50,71 @@ config.json --- -## 一、config.json 中的 codeConfig +## 二、使用方法 -### 配置结构 +### 2.1 基础配置 + +在 `config.json` 中配置开发模式与代码生成: + +```json +{ + "mode": 2, + "codeConfig": [ + { + "table": "admin", + "config": "config/admin.json", + "rule": "config/rule.json", + "mode": 0 + } + ] +} +``` + +### 2.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{ + // 路由配置 + }) +} +``` + +### 2.3 开发模式 + +在 `config.json` 中设置 `"mode": 2`(开发模式)时: + +- 自动读取数据库表结构 +- 自动生成/更新配置文件 +- 自动生成代码(如果 `codeConfig.mode=1`) + +### 2.4 推荐开发流程 + +1. 设置 `config.json` 中 `mode: 2`(开发模式) +2. 按 [数据库设计规范](DatabaseDesign_数据库设计规范.md) 设计表结构并添加字段备注 +3. 启动应用,自动生成配置 +4. 检查生成的配置文件,按需调整 +5. 生产环境改为 `mode: 0` + +--- + +## 三、配置体系 + +### 3.1 codeConfig + +#### 配置结构 ```json { @@ -38,18 +131,23 @@ config.json } ``` -### 配置项说明 +#### 配置项说明 -| 字段 | 类型 | 说明 | -|------|------|------| -| config | string | 菜单权限配置文件路径,用于定义菜单结构、权限控制 | -| configDB | string | 代码生成器输出的完整配置文件(自动生成) | -| rule | string | 字段规则配置文件路径,定义字段在增删改查中的行为 | -| table | string | 管理员/用户表名,用于身份验证和权限控制 | -| name | string | 生成的代码包名,为空则不生成代码文件 | -| mode | int | 生成模式:0-仅配置不生成代码,非0-生成代码文件 | +| 字段 | 类型 | 必须 | 说明 | +|------|------|------|------| +| `table` | string | ✅ | 管理员/用户表名,用于身份验证和权限控制 | +| `config` | string | ✅ | 菜单权限配置文件路径,用于定义菜单结构、权限控制 | +| `configDB` | string | ❌ | 代码生成器输出的完整配置文件(有则每次自动生成) | +| `rule` | string | ❌ | 字段规则配置文件路径,无则使用默认规则 | +| `name` | string | ❌ | 生成的代码包名和目录名,为空则不生成独立代码文件 | +| `mode` | int | ❌ | 生成模式:0=仅配置不生成代码(内嵌模式),非 0=生成代码文件 | -### 多配置支持 +#### 运行模式 + +- **mode=0(内嵌模式)**:不生成独立代码文件,使用框架内置的通用控制器 +- **mode≠0(生成模式)**:为每张表生成独立的 Go 控制器文件 + +#### 多配置支持 可以配置多个独立的代码生成实例,适用于多端场景: @@ -72,16 +170,16 @@ config.json --- -## 二、菜单权限配置(如 admin.json) +### 3.2 菜单权限配置(admin.json) -### 配置文件更新机制 +#### 配置文件更新机制 - **首次运行**:代码生成器会根据数据库表结构自动创建配置文件(如 admin.json) - **后续运行**:配置文件**不会自动更新**,避免覆盖手动修改的内容 - **重新生成**:如需重新生成,删除配置文件后重新运行即可 - **参考更新**:可参考 `configDB` 指定的文件(如 adminDB.json)查看最新的数据库结构变化,手动调整配置 -### 完整配置结构 +#### 完整配置结构 ```json { @@ -94,22 +192,11 @@ config.json } ``` -### 2.1 label 配置 - -定义系统显示名称: - -```json -{ - "label": "HoTime管理平台" -} -``` - -### 2.2 labelConfig 配置 - -定义权限的显示文字: +#### label / labelConfig ```json { + "label": "HoTime管理平台", "labelConfig": { "show": "开启", "add": "添加", @@ -130,9 +217,9 @@ config.json | info | 查看详情权限 | | download | 下载/导出权限 | -### 2.3 menus 配置 +#### menus 配置 -定义菜单结构,支持多级嵌套: +定义菜单结构,支持多级嵌套(目前只支持**两级菜单**): ```json { @@ -165,28 +252,24 @@ config.json } ``` -#### menus 字段说明 - | 字段 | 类型 | 说明 | |------|------|------| | label | string | 菜单显示名称 | | name | string | 菜单标识(用于分组,不绑定表时使用) | | table | string | 绑定的数据表名(用于自动生成 CRUD) | -| icon | string | 菜单图标名称 | +| icon | string | 菜单图标名称(Element Plus 图标名,如 `Setting`、`User`、`Document`) | | auth | array | 权限数组,定义该菜单/表拥有的操作权限 | | menus | array | 子菜单数组(支持嵌套) | -#### name vs table +**name vs table**: -- **table**: 绑定数据表,拥有该表的增删查改等权限,自动生成 CRUD 接口 -- **name**: 自定义功能标识,不绑定表,前端根据 name 和 auth 来显示和操作自定义内容(如首页 home、仪表盘 dashboard 等) - - 如果配置了 `menus` 子菜单,则作为分组功能,前端展开显示下级菜单 - - 如果没有 `menus`,则作为独立的自定义功能入口 - -**注意**:目前只支持**两级菜单**,不允许更多层级嵌套。 +- **table**:绑定数据表,拥有该表的增删查改等权限,自动生成 CRUD 接口 +- **name**:自定义功能标识,不绑定表,前端根据 name 和 auth 显示自定义内容(如首页 home、仪表盘 dashboard) + - 配置了 `menus` 子菜单 → 作为分组 + - 没有 `menus` → 作为独立自定义功能入口 ```json -// 使用 table:绑定数据表,有增删查改权限 +// 使用 table:绑定数据表 { "label": "用户管理", "table": "user", "auth": ["show", "add", "edit", "delete"] } // 使用 name:自定义功能(无子菜单) @@ -196,26 +279,14 @@ config.json { "label": "系统管理", "name": "sys", "icon": "Setting", "auth": ["show"], "menus": [...] } ``` -#### 自动分组规则 +**自动分组规则**:代码生成器会根据表名的 `_` 分词自动分组: -代码生成器在自动生成配置时,会根据表名的 `_` 分词进行自动分组: - -- 表名 `sys_logs`、`sys_menus`、`sys_config` → 自动归入 `sys` 分组 -- 表名 `article`、`article_tag` → 自动归入 `article` 分组 +- 表名 `sys_logs`、`sys_menus`、`sys_config` → 归入 `sys` 分组 +- 表名 `article`、`article_tag` → 归入 `article` 分组 分组的显示名称(label)使用该分组下**第一张表的名字**,如果表有备注则使用备注名。 -### 2.4 auth 配置 - -权限数组定义菜单/表拥有的操作权限: - -```json -{ - "auth": ["show", "add", "delete", "edit", "info", "download"] -} -``` - -#### 内置权限 +#### auth 配置 | 权限 | 对应接口 | 说明 | |------|----------|------| @@ -226,14 +297,9 @@ config.json | info | /info | 查看详情 | | download | /search?download=1 | 导出数据 | -#### 自定义权限扩展 - -auth 数组**可以自由增删**,新增的权限项会: -- 在前端菜单/功能中显示 -- 在角色管理(role)的权限设置中显示,供管理员分配 +auth 数组**可以自由增删**。新增的权限项会在前端菜单/功能中显示,并在角色管理(role)的权限设置中供管理员分配: ```json -// 示例:为文章表添加自定义权限 { "label": "文章管理", "table": "article", @@ -241,24 +307,11 @@ auth 数组**可以自由增删**,新增的权限项会: } ``` -上例中 `publish`(发布)、`audit`(审核)、`top`(置顶)为自定义权限,前端可根据这些权限控制对应按钮的显示和操作。 +上例中 `publish`(发布)、`audit`(审核)、`top`(置顶)为自定义权限。 -### 2.5 icon 配置 +#### flow 配置(数据权限) -菜单图标,使用 Element Plus 图标名称: - -```json -{ "icon": "Setting" } // 设置图标 -{ "icon": "User" } // 用户图标 -{ "icon": "Document" } // 文档图标 -{ "icon": "Folder" } // 文件夹图标 -``` - -### 2.6 flow 配置 - -flow 是一个简易的数据权限控制机制,用于限制用户只能操作自己权限范围内的数据。 - -#### 基本结构 +flow 用于限制用户只能操作自己权限范围内的数据。 ```json { @@ -288,116 +341,22 @@ flow 是一个简易的数据权限控制机制,用于限制用户只能操作 } ``` -#### flow 字段说明 - | 字段 | 类型 | 说明 | |------|------|------| | table | string | 表名 | | stop | bool | 是否禁止修改自身关联的数据 | | sql | object | 数据过滤条件,自动填充查询/操作条件 | -#### stop 配置详解 +**stop**:防止用户修改自己当前关联的敏感数据。例如当前用户 `role_id = 1`,配置 `stop: true` 后,不能修改 role 表中 `id = 1` 的记录,但可修改其他 role(若有权限)。典型用途:防止用户提升自己的角色权限、修改自己所属组织。 -`stop` 用于防止用户修改自己当前关联的敏感数据。 - -**场景示例**:当前登录用户是 admin 表的用户,其 `role_id = 1` - -```json -"role": { - "table": "role", - "stop": true, - "sql": { "id": "role_id" } -} -``` - -**效果**: -- 用户**不能修改** role 表中 `id = 1` 的这行数据(自己的角色) -- 用户**可以修改**其他 role 记录(如果有权限的话) - -**典型用途**: -- 防止用户提升自己的角色权限 -- 防止用户修改自己所属的组织 - -#### sql 配置详解 - -`sql` 用于自动填充数据过滤条件,实现数据隔离。 - -**格式**:`{ "目标表字段": "当前用户字段" }` - -**示例 1:精确匹配** - -```json -"article": { - "sql": { "admin_id": "id" } -} -``` - -**效果**:查询/操作 article 表时,自动添加条件 `WHERE admin_id = 当前用户.id` - -即:用户只能看到/操作自己创建的文章。 - -**示例 2:角色关联** - -```json -"role": { - "sql": { "id": "role_id" } -} -``` - -**效果**:查询/操作 role 表时,自动添加条件 `WHERE id = 当前用户.role_id` - -即:用户只能看到自己的角色。 - -**示例 3:树形结构(模糊匹配)** - -```json -"org": { - "sql": { "parent_ids[~]": ",org_id," } -} -``` - -**效果**:查询/操作 org 表时,自动添加条件 `WHERE parent_ids LIKE '%,用户.org_id,%'` - -即:用户只能看到自己组织及其下级组织。 - -#### sql 条件语法 +**sql**:格式为 `{ "目标表字段": "当前用户字段" }`。 | 格式 | 含义 | SQL 等价 | |------|------|----------| | `"field": "user_field"` | 精确匹配 | `field = 用户.user_field` | | `"field[~]": ",value,"` | 模糊匹配 | `field LIKE '%,value,%'` | -#### 完整示例 - -假设当前登录用户数据: -```json -{ "id": 5, "role_id": 2, "org_id": 10 } -``` - -flow 配置: -```json -{ - "flow": { - "role": { - "table": "role", - "stop": true, - "sql": { "id": "role_id" } - }, - "article": { - "table": "article", - "stop": false, - "sql": { "admin_id": "id" } - }, - "org": { - "table": "org", - "stop": false, - "sql": { "parent_ids[~]": ",org_id," } - } - } -} -``` - -**效果**: +示例效果(当前用户 `{ "id": 5, "role_id": 2, "org_id": 10 }`): | 表 | 查询条件 | stop 效果 | |-----|----------|-----------| @@ -405,192 +364,7 @@ flow 配置: | article | `WHERE admin_id = 5` | 可以修改自己的文章 | | org | `WHERE parent_ids LIKE '%,10,%'` | 可以修改下级组织 | ---- - -## 三、字段规则配置(rule.json) - -定义字段在增删改查操作中的默认行为。 - -### 配置结构 - -```json -[ - { - "name": "id", - "add": false, - "edit": false, - "info": true, - "list": true, - "must": false, - "strict": true, - "type": "" - } -] -``` - -### 字段属性说明 - -| 属性 | 类型 | 说明 | -|------|------|------| -| name | string | 字段名或字段名包含的关键词 | -| add | bool | 新增时是否显示该字段 | -| edit | bool | 编辑时是否显示该字段 | -| info | bool | 详情页是否显示该字段 | -| list | bool | 列表页是否显示该字段 | -| must | bool | 是否必填(详见下方说明) | -| strict | bool | 是否严格匹配字段名(true=完全匹配,false=包含匹配) | -| type | string | 字段类型(影响前端控件和数据处理) | - -### must 必填字段规则 - -`must` 字段用于控制前端表单的必填验证: - -**自动识别规则**: -1. **MySQL**:如果字段设置为 `NOT NULL`(即 `IS_NULLABLE='NO'`),自动设为 `must=true` -2. **SQLite**:如果字段是主键(`pk=1`),自动设为 `must=true` - -**规则配置覆盖**: -- `rule.json` 中的 `must` 设置会覆盖数据库的自动识别结果 -- 可以将数据库中 NOT NULL 的字段在规则中设为 `must=false`,反之亦然 - -**前端效果**: -- `must=true` 的字段在新增/编辑表单中显示必填标记(*) -- 提交时前端会验证必填字段 - -**后端验证**: -- 新增操作时,如果 `must=true` 的字段为空,返回"请求参数不足" - -### type 类型说明 - -| 类型 | 说明 | 前端控件 | -|------|------|----------| -| (空) | 普通文本 | 文本输入框 | -| text | 文本 | 文本输入框 | -| number | 数字 | 数字输入框 | -| select | 选择 | 下拉选择框(根据注释自动生成选项) | -| time | 时间(datetime) | 日期时间选择器 | -| unixTime | 时间戳 | 日期时间选择器(存储为 Unix 时间戳) | -| password | 密码 | 密码输入框(自动 MD5 加密) | -| textArea | 多行文本 | 文本域 | -| image | 图片 | 图片上传 | -| file | 文件 | 文件上传 | -| money | 金额 | 金额输入框 | -| auth | 权限 | 权限树选择器 | -| form | 表单 | 动态表单 | -| index | 索引 | 隐藏字段(用于 parent_ids 等) | -| table | 动态表 | 表名选择器 | -| table_id | 动态表ID | 根据 table 字段动态关联 | - -### 内置字段规则 - -以下是框架默认的字段规则,可在 `rule.json` 中覆盖: - -#### 主键和索引 - -```json -{"name": "id", "add": false, "list": true, "edit": false, "info": true, "strict": true} -{"name": "sn", "add": false, "list": true, "edit": false, "info": true} -{"name": "parent_ids", "add": false, "list": false, "edit": false, "info": false, "type": "index", "strict": true} -{"name": "index", "add": false, "list": false, "edit": false, "info": false, "type": "index", "strict": true} -``` - -#### 层级关系 - -```json -{"name": "parent_id", "add": true, "list": true, "edit": true, "info": true} -{"name": "level", "add": false, "list": false, "edit": false, "info": true} -``` - -#### 时间字段 - -```json -{"name": "create_time", "add": false, "list": false, "edit": false, "info": true, "type": "time", "strict": true} -{"name": "modify_time", "add": false, "list": true, "edit": false, "info": true, "type": "time", "strict": true} -{"name": "time", "add": true, "list": true, "edit": true, "info": true, "type": "time"} -``` - -#### 状态字段 - -```json -{"name": "status", "add": true, "list": true, "edit": true, "info": true, "type": "select"} -{"name": "state", "add": true, "list": true, "edit": true, "info": true, "type": "select"} -{"name": "sex", "add": true, "list": true, "edit": true, "info": true, "type": "select"} -``` - -#### 敏感字段 - -```json -{"name": "password", "add": true, "list": false, "edit": true, "info": false, "type": "password"} -{"name": "pwd", "add": true, "list": false, "edit": true, "info": false, "type": "password"} -{"name": "delete", "add": false, "list": false, "edit": false, "info": false} -{"name": "version", "add": false, "list": false, "edit": false, "info": false} -``` - -#### 媒体字段 - -```json -{"name": "image", "add": true, "list": false, "edit": true, "info": true, "type": "image"} -{"name": "img", "add": true, "list": false, "edit": true, "info": true, "type": "image"} -{"name": "avatar", "add": true, "list": false, "edit": true, "info": true, "type": "image"} -{"name": "icon", "add": true, "list": false, "edit": true, "info": true, "type": "image"} -{"name": "file", "add": true, "list": false, "edit": true, "info": true, "type": "file"} -``` - -#### 文本字段 - -```json -{"name": "info", "add": true, "list": false, "edit": true, "info": true, "type": "textArea"} -{"name": "content", "add": true, "list": false, "edit": true, "info": true, "type": "textArea"} -{"name": "description", "add": true, "list": false, "edit": true, "info": true} -{"name": "note", "add": true, "list": false, "edit": true, "info": true} -{"name": "address", "add": true, "list": true, "edit": true, "info": true} -``` - -#### 特殊字段 - -```json -{"name": "amount", "add": true, "list": true, "edit": true, "info": true, "type": "money", "strict": true} -{"name": "auth", "add": true, "list": false, "edit": true, "info": true, "type": "auth", "strict": true} -{"name": "rule", "add": true, "list": true, "edit": true, "info": true, "type": "form"} -{"name": "table", "add": false, "list": true, "edit": false, "info": true, "type": "table"} -{"name": "table_id", "add": false, "list": true, "edit": false, "info": true, "type": "table_id"} -``` - -### 自定义字段规则 - -在 `rule.json` 中添加项目特定的字段规则: - -```json -[ - // 项目特定规则 - { - "name": "company_name", - "add": true, - "edit": true, - "info": true, - "list": true, - "must": true, - "strict": true, - "type": "" - }, - // 表.字段 形式的精确规则 - { - "name": "user.nickname", - "add": true, - "edit": true, - "info": true, - "list": true, - "strict": true, - "type": "" - } -] -``` - ---- - -## 四、配置示例 - -### 完整的 admin.json 示例 +#### 完整 admin.json 示例 ```json { @@ -660,39 +434,396 @@ flow 配置: } ``` +> 说明:生成器还会在配置中产生 `tables`(字段列、搜索项等)。首次生成可参考 `configDB`(如 adminDB.json)中的结构;持久化自定义请写入 `config` 指定的文件(如 admin.json),因 configDB 会在每次启动时重新生成。 + --- -## 五、SQLite 备注替代方案 +### 3.3 字段规则配置(rule.json) -SQLite 数据库不支持表备注(TABLE COMMENT)和字段备注(COLUMN COMMENT),代码生成器会使用表名/字段名作为默认显示名称。 +定义字段在增删改查操作中的默认行为。 -### 通过配置文件设置备注 +#### 配置结构 -利用 HoTime 的配置覆盖机制,可以在配置文件中手动设置显示名称和提示: +```json +[ + { + "name": "id", + "add": false, + "edit": false, + "info": true, + "list": 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 + } +] +``` -**步骤**: -1. 首次运行,生成配置文件(如 admin.json) -2. 编辑配置文件中的 `tables` 部分 +#### 字段属性说明 -### 设置表显示名称 +| 属性 | 类型 | 说明 | +|------|------|------| +| name | string | 字段名或关键词;支持 `表名.字段名` 精确匹配 | +| add | bool | 新增时是否显示 | +| edit | bool | 编辑时是否显示 | +| info | bool | 详情页是否显示 | +| list | bool | 列表页是否显示 | +| must | bool | 是否必填(见下方) | +| strict | bool | 是否严格匹配字段名(true=完全匹配,false=包含匹配) | +| type | string | 字段类型(影响前端控件和数据处理) | -在菜单配置中设置 `label`: +#### must 必填字段规则 + +**自动识别**: + +1. **MySQL**:字段为 `NOT NULL`(`IS_NULLABLE='NO'`)时自动 `must=true` +2. **SQLite**:字段为主键(`pk=1`)时自动 `must=true` + +**规则覆盖**:`rule.json` 中的 `must` 会覆盖数据库自动识别结果。 + +**效果**: + +- 前端:`must=true` 显示必填标记(*),提交时校验 +- 后端:新增时若 `must=true` 字段为空,返回「请求参数不足」 + +#### type 类型说明 + +| 类型 | 说明 | 前端控件 | +|------|------|----------| +| (空) | 普通文本 | 文本输入框 | +| text | 文本 | 文本输入框 | +| number | 数字 | 数字输入框 | +| select | 选择 | 下拉选择框(根据注释自动生成选项) | +| time | 时间(datetime) | 日期时间选择器 | +| unixTime | 时间戳 | 日期时间选择器(存储为 Unix 时间戳) | +| password | 密码 | 密码输入框(自动 MD5 加密) | +| textArea | 多行文本 | 文本域 | +| image | 图片 | 图片上传 | +| file | 文件 | 文件上传 | +| money | 金额 | 金额输入框 | +| auth | 权限 | 权限树选择器 | +| form | 表单 | 动态表单 | +| index | 索引 | 隐藏字段(用于 parent_ids 等) | +| tree | 树形选择 | 树形选择器 | +| table | 动态表 | 表名选择器 | +| table_id | 动态表ID | 根据 table 字段动态关联 | + +#### 内置字段规则(可在 rule.json 中覆盖) + +**主键和索引**: + +```json +{"name": "id", "add": false, "list": true, "edit": false, "info": true, "strict": true} +{"name": "sn", "add": false, "list": true, "edit": false, "info": true} +{"name": "parent_ids", "add": false, "list": false, "edit": false, "info": false, "type": "index", "strict": true} +{"name": "index", "add": false, "list": false, "edit": false, "info": false, "type": "index", "strict": true} +``` + +**层级关系**: + +```json +{"name": "parent_id", "add": true, "list": true, "edit": true, "info": true} +{"name": "level", "add": false, "list": false, "edit": false, "info": true} +``` + +**时间字段**: + +```json +{"name": "create_time", "add": false, "list": false, "edit": false, "info": true, "type": "time", "strict": true} +{"name": "modify_time", "add": false, "list": true, "edit": false, "info": true, "type": "time", "strict": true} +{"name": "time", "add": true, "list": true, "edit": true, "info": true, "type": "time"} +``` + +**状态字段**: + +```json +{"name": "status", "add": true, "list": true, "edit": true, "info": true, "type": "select"} +{"name": "state", "add": true, "list": true, "edit": true, "info": true, "type": "select"} +{"name": "sex", "add": true, "list": true, "edit": true, "info": true, "type": "select"} +``` + +**敏感字段**: + +```json +{"name": "password", "add": true, "list": false, "edit": true, "info": false, "type": "password"} +{"name": "pwd", "add": true, "list": false, "edit": true, "info": false, "type": "password"} +{"name": "delete", "add": false, "list": false, "edit": false, "info": false} +{"name": "version", "add": false, "list": false, "edit": false, "info": false} +``` + +**媒体字段**: + +```json +{"name": "image", "add": true, "list": false, "edit": true, "info": true, "type": "image"} +{"name": "img", "add": true, "list": false, "edit": true, "info": true, "type": "image"} +{"name": "avatar", "add": true, "list": false, "edit": true, "info": true, "type": "image"} +{"name": "icon", "add": true, "list": false, "edit": true, "info": true, "type": "image"} +{"name": "file", "add": true, "list": false, "edit": true, "info": true, "type": "file"} +``` + +**文本字段**: + +```json +{"name": "info", "add": true, "list": false, "edit": true, "info": true, "type": "textArea"} +{"name": "content", "add": true, "list": false, "edit": true, "info": true, "type": "textArea"} +{"name": "description", "add": true, "list": false, "edit": true, "info": true} +{"name": "note", "add": true, "list": false, "edit": true, "info": true} +{"name": "address", "add": true, "list": true, "edit": true, "info": true} +``` + +**特殊字段**: + +```json +{"name": "amount", "add": true, "list": true, "edit": true, "info": true, "type": "money", "strict": true} +{"name": "auth", "add": true, "list": false, "edit": true, "info": true, "type": "auth", "strict": true} +{"name": "rule", "add": true, "list": true, "edit": true, "info": true, "type": "form"} +{"name": "table", "add": false, "list": true, "edit": false, "info": true, "type": "table"} +{"name": "table_id", "add": false, "list": true, "edit": false, "info": true, "type": "table_id"} +``` + +#### 自定义字段规则 + +```json +[ + { + "name": "company_name", + "add": true, + "edit": true, + "info": true, + "list": true, + "must": true, + "strict": true, + "type": "" + }, + { + "name": "user.nickname", + "add": true, + "edit": true, + "info": true, + "list": true, + "strict": true, + "type": "" + } +] +``` + +--- + +### 3.4 配置检查清单 + +#### codeConfig + +- [ ] config 文件路径正确 +- [ ] rule 文件路径正确 +- [ ] table 指定的管理员表存在 + +#### 菜单权限配置 + +- [ ] 所有 table 指向的表在数据库中存在 +- [ ] auth 数组包含需要的权限 +- [ ] menus 结构正确(有子菜单用 name,无子菜单用 table) +- [ ] flow 配置的 sql 条件字段存在 + +#### 字段规则配置 + +- [ ] strict=true 的规则字段名完全匹配 +- [ ] type 类型与前端控件需求一致 +- [ ] 敏感字段(password 等)的 list 和 info 为 false + +--- + +## 四、生成规则与产物 + +### 4.1 默认字段规则 + +代码生成器内置的字段识别一览(完整内置规则见 [3.3](#33-字段规则配置rulejson)): + +| 字段名 | 列表显示 | 新增 | 编辑 | 详情 | 类型 | +|--------|----------|------|------|------|------| +| `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` | ❌ | ❌ | ❌ | ❌ | - | + +### 4.2 数据类型映射 + +| 数据库类型 | 生成类型 | +|------------|----------| +| `int`, `integer`, `float`, `double`, `decimal` | number | +| `char`, `varchar`, `text`, `blob` | text | +| `date`, `datetime`, `time`, `timestamp`, `year` | time | + +### 4.3 字段备注解析 + +字段 COMMENT 的完整语法、选项写法与设计约定,以 [数据库设计规范](DatabaseDesign_数据库设计规范.md) 为准。此处仅说明生成器如何消费备注。 + +支持从数据库字段备注中提取标签、提示与选项(括号支持 `()`、`()`、`{}`): + +```sql +-- 格式: 标签名(提示信息):选项1-名称1,选项2-名称2 +status TINYINT COMMENT '状态(用户账号状态):0-禁用,1-启用' +parent_id INT COMMENT '父级ID(顶级为NULL)' +phone VARCHAR(20) COMMENT '手机号(请输入11位手机号)' +``` + +生成的配置示例: ```json { - "menus": [ - { - "label": "用户管理", // 手动设置表显示名称 - "table": "user", - "auth": ["show", "add", "edit", "delete"] - } + "name": "status", + "label": "状态", + "type": "select", + "ps": "用户账号状态", + "options": [ + {"name": "禁用", "value": "0"}, + {"name": "启用", "value": "1"} ] } ``` -### 设置字段显示名称和提示 +`ps` 字段在前端的展示效果: -在 `configDB` 文件(如 adminDB.json)中的 `tables.表名.columns` 部分设置: +- 编辑/新增页面:输入框右侧灰色小字提示 +- 表格/详情页面:鼠标悬停字段名时气泡提示 + +### 4.4 外键关联 + +代码生成器会自动识别 `_id` 结尾的字段作为外键: + +```sql +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" +} +``` + +表命名、外键命名与 COMMENT 约定见 [数据库设计规范](DatabaseDesign_数据库设计规范.md)。 + +### 4.5 生成的代码结构 + +#### 内嵌模式 (mode=0) + +不生成代码文件,使用框架内置控制器,只生成配置文件: + +``` +config/ +├── admin.json # 接口/菜单权限配置 +├── adminDB.json # 数据库结构配置(可选) +└── rule.json # 字段规则 +``` + +#### 生成模式 (mode≠0) + +生成独立的控制器代码: + +``` +admin/ # 生成的包目录(由 codeConfig.name 决定) +├── 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) { + // 搜索列表(分页) + }, +} +``` + +### 4.6 多库与备注注意事项 + +#### 达梦 DM8 + +代码生成器已原生支持达梦 DM8,会自动读取表结构和字段备注。注意: + +- 表注释通过 `COMMENT ON TABLE` 单独设置,建表后需额外执行 +- 字段备注通过 `COMMENT ON COLUMN` 设置 +- 已对 DM 的 `USER_TAB_COLUMNS` 与 MySQL `information_schema` 差异做兼容 + +```sql +COMMENT ON TABLE "user" IS '用户管理'; +COMMENT ON COLUMN "user"."state" IS '状态:0-正常,1-异常,2-隐藏'; +COMMENT ON COLUMN "user"."name" IS '用户名'; +``` + +#### SQLite 备注替代方案 + +SQLite 不支持表/字段 COMMENT,生成器会使用表名/字段名作为默认显示名称。可利用配置覆盖机制手动设置: + +1. 首次运行生成配置文件(如 admin.json) +2. 在菜单中设置表的 `label` +3. 在 `tables.表名.columns` 中设置字段 `label`、`ps`、`options` 等 ```json { @@ -700,22 +831,12 @@ SQLite 数据库不支持表备注(TABLE COMMENT)和字段备注(COLUMN CO "user": { "label": "用户管理", "columns": [ - { - "name": "id", - "label": "ID", - "type": "number" - }, { "name": "name", "label": "用户名", - "ps": "请输入用户名", // 前端输入提示 + "ps": "请输入用户名", "must": true }, - { - "name": "phone", - "label": "手机号", - "ps": "请输入11位手机号" - }, { "name": "status", "label": "状态", @@ -731,27 +852,19 @@ SQLite 数据库不支持表备注(TABLE COMMENT)和字段备注(COLUMN CO } ``` -**注意**:直接编辑的是 `configDB` 指定的文件(如 adminDB.json),该文件会在每次启动时重新生成。如需持久化修改,应将自定义的 columns 配置放入 `config` 指定的文件(如 admin.json)中。 +**注意**:`configDB` 文件每次启动会重新生成。持久化修改应写入 `config` 指定的文件(如 admin.json)。 + +### 4.7 最佳实践 + +1. **字段命名**:使用规范字段名(`id`、`name`、`status`、`create_time`、`modify_time`、`xxx_id`、`parent_id`、`avatar`、`content` 等),便于自动识别。完整约定见 [数据库设计规范](DatabaseDesign_数据库设计规范.md)。 +2. **自定义扩展**:默认规则不足时,可修改 `rule.json`、使用生成模式后手动改代码,或直接调整配置文件中的字段属性。 +3. **生产环境**:开发用 `mode: 2`,上线改为 `mode: 0`,避免运行时反复改写配置/代码。 --- -## 六、配置检查清单 +## 五、相关文档 -### codeConfig 检查 - -- [ ] config 文件路径正确 -- [ ] rule 文件路径正确 -- [ ] table 指定的管理员表存在 - -### 菜单权限配置检查 - -- [ ] 所有 table 指向的表在数据库中存在 -- [ ] auth 数组包含需要的权限 -- [ ] menus 结构正确(有子菜单用 name,无子菜单用 table) -- [ ] flow 配置的 sql 条件字段存在 - -### 字段规则配置检查 - -- [ ] strict=true 的规则字段名完全匹配 -- [ ] type 类型与前端控件需求一致 -- [ ] 敏感字段(password等)的 list 和 info 为 false +- [数据库设计规范](DatabaseDesign_数据库设计规范.md)(代码生成前置依赖) +- [HoTimeDB 使用说明](HoTimeDB_使用说明.md) +- [快速上手](QuickStart_快速上手.md) +- [Common 工具类](Common_工具类.md) diff --git a/docs/CodeGen_使用说明.md b/docs/CodeGen_使用说明.md deleted file mode 100644 index 02fcd3f..0000000 --- a/docs/CodeGen_使用说明.md +++ /dev/null @@ -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) diff --git a/docs/Common_工具类使用说明.md b/docs/Common_工具类.md similarity index 98% rename from docs/Common_工具类使用说明.md rename to docs/Common_工具类.md index 0c37afd..83d5327 100644 --- a/docs/Common_工具类使用说明.md +++ b/docs/Common_工具类.md @@ -479,6 +479,7 @@ for _, u := range users { ## 相关文档 -- [快速上手指南](QUICKSTART.md) +- [快速上手](QuickStart_快速上手.md) - [HoTimeDB 使用说明](HoTimeDB_使用说明.md) -- [代码生成器使用说明](CodeGen_使用说明.md) +- [代码生成](CodeGen_代码生成.md) +- [文档索引](README.md) diff --git a/docs/DatabaseDesign_数据库设计规范.md b/docs/DatabaseDesign_数据库设计规范.md index 8bd34ce..259aa0d 100644 --- a/docs/DatabaseDesign_数据库设计规范.md +++ b/docs/DatabaseDesign_数据库设计规范.md @@ -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) diff --git a/docs/graceful-shutdown.md b/docs/GracefulShutdown_优雅停机.md similarity index 84% rename from docs/graceful-shutdown.md rename to docs/GracefulShutdown_优雅停机.md index 5d7b5df..c66ec1e 100644 --- a/docs/graceful-shutdown.md +++ b/docs/GracefulShutdown_优雅停机.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 :: 3. 观察日志,出现"服务已安全关闭"后,部署新版并启动 -:: run_xbc.cmd 检测到 exit(0) 后不会自动重启 +:: 若使用 run_app.cmd 守护,检测到 exit(0) 后不会自动重启 :: 4. 对端口 9999 重复以上步骤 netstat -ano | findstr :9999 @@ -100,15 +100,15 @@ taskkill /PID ### Linux ```bash -# 1. 查找进程 PID -pgrep -a xbc # 或 -ps aux | grep xbc +# 1. 查找进程 PID(按你的二进制名替换) +pgrep -a # 或 +ps aux | grep # 2. 优雅关闭(发送 SIGTERM) kill # 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` 保证幂等性 diff --git a/docs/HoTimeDB_API参考.md b/docs/HoTimeDB_API参考.md index 49f2ea6..006b48c 100644 --- a/docs/HoTimeDB_API参考.md +++ b/docs/HoTimeDB_API参考.md @@ -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` 常见形态:字段;字段+where;join+字段+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() +``` diff --git a/docs/HoTimeDB_使用说明.md b/docs/HoTimeDB_使用说明.md index fd04f4d..80213f1 100644 --- a/docs/HoTimeDB_使用说明.md +++ b/docs/HoTimeDB_使用说明.md @@ -1,5 +1,7 @@ # HoTimeDB ORM 使用说明书 +> 完整教程见本文;方法签名与条件运算符速查见 [HoTimeDB_API参考.md](HoTimeDB_API参考.md) + ## 概述 HoTimeDB是一个基于Golang实现的轻量级ORM框架,参考PHP Medoo设计,提供简洁的数据库操作接口。支持MySQL、SQLite、PostgreSQL、达梦(DM8)等数据库,并集成了缓存、事务、链式查询等功能。 diff --git a/docs/QUICKSTART.md b/docs/QuickStart_快速上手.md similarity index 90% rename from docs/QUICKSTART.md rename to docs/QuickStart_快速上手.md index efb6581..3452002 100644 --- a/docs/QUICKSTART.md +++ b/docs/QuickStart_快速上手.md @@ -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) — 完整阅读路径 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..b2b3c13 --- /dev/null +++ b/docs/README.md @@ -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 文首铁律。 diff --git a/docs/ROADMAP_改进规划.md b/docs/ROADMAP_改进规划.md index 0aed6e4..ed535fd 100644 --- a/docs/ROADMAP_改进规划.md +++ b/docs/ROADMAP_改进规划.md @@ -1,5 +1,7 @@ # HoTime 改进规划 +> 相关规范:[DatabaseDesign](DatabaseDesign_数据库设计规范.md) · [CodeGen](CodeGen_代码生成.md) · [Testing](Testing_API测试框架.md) · [文档索引](README.md) + 本文档记录 HoTime 框架的待改进项和设计思考,供后续版本迭代参考。 --- diff --git a/docs/Seq日志集成.md b/docs/Seq_日志集成.md similarity index 62% rename from docs/Seq日志集成.md rename to docs/Seq_日志集成.md index 4e7ba2d..e8a1278 100644 --- a/docs/Seq日志集成.md +++ b/docs/Seq_日志集成.md @@ -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) diff --git a/docs/Testing_API测试框架.md b/docs/Testing_API测试框架.md index 88c425c..83b672d 100644 --- a/docs/Testing_API测试框架.md +++ b/docs/Testing_API测试框架.md @@ -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": {} } + } + ] +} +``` diff --git a/log/capture_test.go b/log/capture_test.go new file mode 100644 index 0000000..04f0e59 --- /dev/null +++ b/log/capture_test.go @@ -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"]) + } +} diff --git a/log/logger.go b/log/logger.go index 2d5c17d..318e579 100644 --- a/log/logger.go +++ b/log/logger.go @@ -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)) } }