Compare commits

..

323 Commits

Author SHA1 Message Date
hoteas 332c2414bc feat(log): 访问日志携带 ua,挂 Seq 后控制台放宽到 Info+
- WebConnectLog 访问日志新增 ua 字段(User-Agent 按 rune 截断 200 字符),便于按设备归因
- SetSeqWriter 挂 Seq 后业务控制台由 Warn+ 放宽为 Info+,Debug/SQL 仍只进 Seq 与文件
- 新增 Logger.SetConsoleMinLevel,访问日志控制台单独保持 Warn+

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 01:35:45 +08:00
hoteas 853940c795 fix(makecode): Search 文本字段只走模糊匹配,避免等值叠加导致空结果
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 06:15:07 +08:00
hoteas b5b764e3f9 docs: 优雅停机示例透传 EO-Connecting-IP
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 06:05:00 +08:00
hoteas 25faa93724 feat(log): 多源 clientIP 降级与去重 ip_chain
按 EO→XFF非回环→X-Real→RemoteAddr 选取 ip;访问日志附带去重 ip_chain;登录限流改用 clientIP。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 06:04:43 +08:00
hoteas 831a7f017a feat(log): Seq 会话追踪、控制台降噪与推送质量增强
请求日志自动带 sid/request_id,支持 LogBind;挂 Seq 后控制台仅 Warn+,Seq 仍全量;并修毫秒时间戳、@m、失败重试与停机 flush。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 05:26:44 +08:00
hoteas b254606003 fix(makecode): 树查询 showself 仅在 showall 时生效
避免 parent_id 懒加载把节点自身混入子级导致无限嵌套,并补充 department 回归用例与文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 08:58:46 +08:00
hoteas 412f08a048 Merge branch 'master' of https://code.hoteas.com/golang/hotime 2026-07-22 04:41:30 +08:00
hoteas ec9d25fef6 feat(testing): 新增 AnyMsg 方法以跳过错误消息断言
- 在 Api 和 ApiCase 中新增 AnyMsg 方法,允许在状态不为 0 时跳过错误消息的断言,适用于动态内容的用例。
- 更新文档说明,介绍 AnyMsg 的用法及其链式调用示例。
- 在示例测试中添加 AnyMsg 的使用案例,展示其在处理动态错误消息时的便利性。
2026-07-22 04:38:38 +08:00
hoteas a600fa8a15 fix(mysql): SetMysqlDB 默认 charset 改为 utf8mb4
与表字符集对齐,兼容 MySQL 8.0/8.4,避免连接层 utf8mb3。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 04:16:22 +08:00
hoteas f53d406110 refactor(application): 更新 Cache-Control 逻辑与文档说明
- 简化 application.go 中的 Cache-Control 设置,固定为 public,移除 logLevel 对其的控制。
- 更新 var.go 中 logLevel 的说明,明确其不再影响静态资源的 Cache-Control。
- 修改 QuickStart 文档,反映 logLevel 的新行为与静态资源缓存策略。
2026-07-15 23:02:12 +08:00
hoteas 8a1a27b457 feat(testing): Listener 链式入口并精简测试文档运行指引
将 connect listener 并入 Test 链、删除 NewTestApp,正文只示范 -run 单测,全量命令仅保留在文末 CI 节。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 19:56:36 +08:00
hoteas 5a4fd288bb chore: 清理误提交的覆盖率产物与空配置
移除 cover/cover.out、空 .tgitconfig 与无效 worktrees.json,并补充忽略规则。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 19:21:21 +08:00
hoteas c2ce1955f9 chore: 忽略本地配置与测试生成物,停止跟踪 swagger
避免将仓根 config/tpt、*.exe 及 example/tpt/swagger 再推送到远端。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 18:16:29 +08:00
hoteas 16496a8dc0 feat(testing): Flows/FromCase、增量 swagger 与 Doc-Driven 门禁
补齐多接口流程联调与控制台体验,并落地仓内 Doc-Driven+TDD 总规则与文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 18:11:08 +08:00
hoteas 9fe4ab2758 Merge branch 'main-dmdb' into master 2026-07-13 15:21:25 +08:00
hoteas c2a59e19c9 feat(api): 新增 RunSub 支持接口测试内部分场景
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 15:21:23 +08:00
hoteas 6b689f3a1b chore(logging): 更新日志重定向与捕获功能
- 在 .gitignore 中添加调试日志文件的忽略规则,避免不必要的调试信息被提交
- 修改 application.go 中的 stdout 重定向逻辑,使用 log.CaptureStream 以支持更灵活的日志捕获
- 更新 README 文档,增加对日志重定向功能的说明
2026-07-13 07:45:51 +08:00
hoteas e14c9742c1 chore(logs): 删除过时的调试日志文件
- 移除 example/debug-e1b6ef.log 文件,清理不再需要的调试信息,保持项目整洁。
2026-07-13 07:45:40 +08:00
hoteas 8b095f87ea feat(api): 新增 AtPath 方法以支持跨接口断言
- 实现 AtPath 方法,返回指向其它 API 路径的 Api 实例,增强 API 的灵活性与可用性。
2026-06-30 06:54:02 +08:00
hoteas 1e77dde6f0 chore(.gitignore): 添加 macOS 特定文件 .DS_Store 到忽略列表 2026-06-28 03:50:18 +08:00
hoteas 7fdf5181da feat(logging): 增强日志记录功能与错误处理
- 新增 redirectStderr 函数,重定向 os.Stderr 到 HoTime Logger,捕获 panic 栈和 MySQL 驱动错误日志
- 实现 mysqlLogAdapter,将 MySQL 驱动的内部错误日志桥接到 HoTime Logger,确保错误信息以 error 级别推送
- 更新 Seq 日志集成,支持 WebConnectLog 的 SeqWriter 配置,提升日志记录的灵活性与准确性
2026-05-31 04:54:12 +08:00
hoteas 94a251ec44 feat(logging): 增加 Seq 日志集成与标准输出重定向
- 新增 SeqWriter 支持,将日志异步推送到 Seq 平台,支持多进程实例区分
- 实现 redirectStdout 函数,重定向 os.Stdout 和标准 log 包输出,确保 fmt.Println 等输出被捕获并记录
- 更新 README 文档,增加 Seq 日志集成的说明与配置链接
- 扩展 Logger 结构,支持动态添加输出目标,提升日志记录灵活性
2026-05-18 13:12:51 +08:00
hoteas ef10437a5a refactor(code): 优化数据聚合查询逻辑与条件处理
- 重构数据聚合查询流程,减少多次数据库查询,提升性能
- 引入聚合表达式列表和元数据结构,简化查询逻辑
- 优化条件判断,确保不必要的字段被正确过滤
- 改进区域统计逻辑,使用单次查询替代逐个计数,提高效率
- 更新搜索功能,确保请求数据的有效性
2026-05-17 04:08:54 +08:00
hoteas 66dee150c7 fix(docs): 更新人员管理标签为员工管理
- 将文档中的“人员管理”标签修改为“员工管理”,以提高术语的一致性和准确性。
2026-04-30 17:05:02 +08:00
hoteas f22eea72ec feat(app): 更新优雅停机逻辑与请求超时设置
- 修改 DrainTimeout 和 ShutdownTimeout 的注释,明确其含义与默认值
- 优化日志信息,提供更清晰的停机状态反馈
- 调整 ShutdownTimeout 默认值为 10s,以增强请求处理的灵活性
- 移除冗余的调试日志代码,提升代码整洁性
2026-04-24 05:43:53 +08:00
hoteas 0340bc4b8e feat(app): 增强优雅停机逻辑与请求处理
- 新增 inFlight 原子计数器以跟踪当前处理的请求数
- 更新 initiateGracefulShutdown 方法,支持每秒检测在途请求,优化停机时长
- 修改日志信息,提供更清晰的停机状态反馈
- 更新 DrainTimeout 注释,明确最长等待时长与检测机制
2026-04-19 09:44:15 +08:00
hoteas d03ed65d41 style(ui): 调整 API 调试控制台样式与按钮布局
- 修改 CSS 变量 `--r` 的值以增加圆角效果
- 更新展开/收起预期响应按钮的样式,提升用户体验
- 优化按钮宽度和布局,确保一致性与可读性
2026-04-16 21:32:19 +08:00
hoteas 10428b6da4 refactor(tests): 优化测试用例结构与文件上传字段
- 移除多余的空行以提升代码整洁性
- 调整 swaggerTestCase 结构的缩进格式,增强可读性
- 更新文件上传字段的默认值,确保一致性
2026-04-15 01:51:52 +08:00
hoteas 66f18a084a feat(app): 实现优雅停机功能
- 新增 shuttingDown 原子布尔标志和 shutdownOnce 保证单次执行
- 添加 DrainTimeout 和 ShutdownTimeout 配置参数,默认 5s 和 30s
- 修改 ServeHTTP 拦截新请求返回 503 Service Unavailable
- 实现跨平台信号处理:Windows 关窗口和 Linux SIGTERM/SIGINT
- 添加 recover 保护避免停机过程中 panic 触发重启
- 优化 HTTP 服务退出判断,排除正常关闭情况
- 新增 log.LoggerNoCaller 用于访问日志等无意义调用方场景
- 更新 README 文档增加优雅停机特性说明和文档链接
- 创建详细的优雅停机文档和测试用例
- 添加 Windows 平台特定信号处理实现
- 新增 .cursor 计划文件记录开发方案
2026-04-14 14:07:59 +08:00
hoteas 3430bdec19 enhance(tests): 改进测试用例记录与覆盖率报告
- 在 ApiCase 中添加失败原因记录,提升测试结果的可读性
- 更新 CoverageReport 结构,增加总用例、通过用例和失败用例计数
- 优化 PrintCoverage 方法,支持详细输出未通过接口及其失败原因
- 更新 Swagger 生成逻辑,包含失败原因字段,增强调试信息
- 改进 SQL 错误计数逻辑,确保在测试模式下准确记录 SQL 错误
2026-04-13 06:45:16 +08:00
hoteas 9a9b9c83ff refactor(logging): 迁移日志记录到 Zerolog 并优化错误处理
- 将日志记录库从 Logrus 替换为 Zerolog,提升性能和灵活性
- 更新各个模块的日志记录方式,确保一致性
- 优化错误处理逻辑,确保在发生错误时能够正确记录并传递错误信息
- 移除不再使用的错误处理字段,简化代码结构
- 更新相关文档以反映新的日志记录和错误处理机制
2026-04-13 00:38:50 +08:00
hoteas 86daa3153e fix(db): 修复事务模式下的错误处理与回滚机制
- 在 `queryWithRetry` 和 `execWithRetry` 方法中添加对 `txFailed` 的状态管理,确保在 SQL 错误时正确标记事务失败
- 增加新的测试用例以验证事务模式下的错误处理逻辑,确保在 SQL 错误发生时能够正确回滚
- 优化测试数据库的初始化方法,支持事务测试场景
2026-04-12 00:53:22 +08:00
hoteas 277e244e80 ```
refactor(db): 优化事务错误状态管理

- 添加 txFailed 指针字段用于跟踪事务内SQL执行状态
- 确保事务错误状态在 HoTimeDB 实例间正确共享
- 当事务内SQL出错时强制回滚以维护数据一致性
```
2026-04-11 22:34:13 +08:00
hoteas 5a1b5e4fee Merge branch 'master' into main-dmdb
# Conflicts:
#	db/db.go
2026-04-11 21:54:37 +08:00
hoteas f787421f82 fix(db): 修复事务中SQL错误时的回滚机制
- 添加txFailed标志跟踪事务内的SQL错误状态
- 在事务模式下遇到SQL错误时立即标记事务失败并阻止重试
- 修改Action方法确保SQL出错时强制回滚事务
- 为事务失败场景添加完整的单元测试覆盖
- 防止在事务已回滚的情况下继续执行后续操作导致数据不一致
2026-04-11 21:53:28 +08:00
hoteas cf8ff2b838 fix(docs): 更新测试文档中的文件引用以反映正确的测试文件名
- 将测试注册文件名从 `app_test.go` 更正为 `init_test.go`,确保文档与代码一致
- 更新相关代码示例以匹配新的文件结构,提升文档的准确性和可读性
2026-04-04 04:11:05 +08:00
hoteas f7c3a6c1c8 feat(logging): 增强日志记录功能以支持更详细的调试信息
- 在 GenerateSwagger 方法中添加更全面的调试日志,记录运行检查和端点保留情况
- 更新 Action 方法以优化测试模式下的日志缓冲,确保日志的准确性和完整性
- 删除不再使用的计划文档,简化项目结构
2026-04-04 01:57:55 +08:00
hoteas 63360f9a14 feat(logging): 增强测试框架的日志记录功能
- 在 GenerateSwagger 方法中添加调试日志记录,记录部分运行检查和现有端点的保留情况
- 更新 Action 方法以支持测试模式下的日志缓冲,确保日志记录的准确性
- 删除不再使用的计划文档,简化项目结构
2026-04-04 01:57:10 +08:00
hoteas 17e1090077 feat(tests): 增强测试框架的 SQL 日志记录与覆盖率报告
- 在 ApiCase 中添加 SQL 日志记录功能,失败时输出日志以便于调试
- 更新 TestApp 以支持测试模式下的 SQL 日志缓冲,成功与失败的测试均可记录日志
- 改进 PrintCoverage 方法,优化覆盖率报告的输出逻辑,支持部分运行的接口显示
- 增加 Note 备注功能,提升测试用例的可读性与文档化效果
- 更新相关文档,详细说明新功能的使用场景与示例
2026-04-04 01:34:09 +08:00
hoteas b3f263c965 feat(login): 添加 IP 限速机制以防止频繁登录尝试
- 在登录功能中实现同一 IP 1 分钟内最多尝试 10 次的限制
- 记录登录时间戳并过滤过期记录,确保登录尝试次数的准确性
- 优化未登录用户的返回信息,避免泄露敏感数据
2026-03-31 00:10:39 +08:00
hoteas 37a67f5810 feat(api): 增强 API 测试框架功能与文档
- 在 Api 结构中新增 lastResp 字段以存储最近请求的响应
- 添加 Verify 方法,支持自定义校验函数并返回 ApiCase 构建器
- 新增 Resp 方法,获取最近一次请求的响应以便于断言
- 在 TestCollector 中添加 Visited 字段,记录已调用的路径
- 更新 GenerateSwagger 方法,支持部分运行时保留未运行端点的已有数据
- 完善文档,增加用例编写范式和示例,提升测试框架的可用性与易用性
2026-03-30 01:55:07 +08:00
hoteas 3681564ca4 feat(config): 扩展数据类型映射与数据库字段备注提取功能
- 在 ColumnDataType 中新增对 PostgreSQL 和 Oracle 数据类型的支持
- 更新 Db2JSON 方法,优化对达梦数据库表和列信息的查询逻辑
- 增强字段备注解析,支持从备注中提取提示信息并清理标签
- 更新文档,详细说明字段备注格式及前端提示的展示效果
2026-03-23 16:48:56 +08:00
hoteas 37c6a00c0a refactor(db): 移除 Row 方法中的列名小写转换
- 在 Row 方法中删除对列名的统一小写转换,保持原始列名格式
- 简化代码逻辑,提升可读性
2026-03-21 20:51:58 +08:00
hoteas 70012032f0 feat(api): 增强 API 测试框架的断言功能
- 修改 ApiCase 和 ApiResponse 结构,新增 Verify 方法以支持自定义校验函数
- 更新 Get、Post、Put、Delete 方法,允许使用 expect 参数进行更灵活的响应断言
- 添加 ExpectResult 方法,支持对响应结果的结构和类型进行后置校验
- 更新文档,详细说明新功能的使用方法和示例,提升测试框架的可用性
- 增强 Swagger 生成逻辑,支持输出新的 expect 和 verifyError 字段
2026-03-21 13:38:22 +08:00
hoteas 813e26755f refactor(db): 优化测试事务中的互斥锁使用
- 重构 queryWithRetry 和 execWithRetry 方法中的互斥锁逻辑,提升代码可读性
- 确保在测试事务中对 testMu 的锁定和解锁操作更加清晰,避免潜在的并发问题
2026-03-20 13:52:23 +08:00
hoteas a7395b08de fix(cache): 修复 tableExists 方法以正确检测达梦数据库中的表存在性
- 修改 tableExists 方法,使用 COUNT(*) 查询当前 schema 下的表,避免因用户权限导致的误判
- 更新 ObjToFloat64、ObjToInt64 和 ObjToStr 方法,增加对多种整数和布尔类型的支持
- 在 Row 方法中处理 time.Time 类型,确保与 MySQL 兼容的字符串格式输出
2026-03-20 13:51:52 +08:00
hoteas 5e2bd765bf chore: 更新 .gitignore 文件,添加对 .py 文件的忽略;删除不再使用的 mysql2dm.py 文件 2026-03-20 11:54:42 +08:00
hoteas 6fda351585 feat(docs): 完善达梦数据库支持文档
- 更新 .gitignore 文件,添加对 .sql 文件的忽略
- 移除 cache_db.go 中的调试日志相关代码,简化缓存操作逻辑
- 在多个文档中添加达梦 DM8 数据库的支持信息,包括配置、使用注意事项及示例
- 更新 QUICKSTART.md,明确支持的数据库类型及配置示例
- 在 ROADMAP.md 中记录达梦数据库完整支持的进展
2026-03-20 11:25:09 +08:00
hoteas 7f7b585ffb feat(db): 添加对达梦数据库的支持
- 在应用程序中新增对达梦数据库(DM)的配置和连接支持
- 实现 SetDmDB 函数以配置达梦数据库连接
- 更新数据库操作逻辑,支持达梦特有的 SQL 语法和功能
- 在相关文件中添加达梦数据库的处理逻辑,包括表创建、数据插入和查询
- 更新 go.mod 和 go.sum 文件以引入达梦数据库驱动
- 增强文档,详细说明达梦数据库的配置和使用方法
2026-03-20 10:46:51 +08:00
hoteas 1546967918 docs: 更新 README 和 ROADMAP 文档,添加测试方法和响应字段断言改进规划
- 在 README.md 中添加测试方法说明
- 在 ROADMAP 文档中新增关于响应字段部分匹配断言的现状、改进方向及预期写法,提升文档的完整性和可读性
2026-03-16 06:53:31 +08:00
hoteas 47e13b532b feat(api): 添加备注功能以增强用例描述
- 在 Api 和 ApiCase 中新增 Note 方法,允许为用例设置可选备注
- 更新 TestRecord 结构体,包含备注字段以便记录用例信息
- 修改 Swagger 生成逻辑,支持备注字段的输出
- 更新文档,详细说明 Note 方法的使用及其在调试控制台的显示效果
2026-03-15 11:45:21 +08:00
hoteas 71e415dfe1 feat(swagger): 增强 Swagger 生成逻辑与参数推断
- 新增参数推断功能,自动从测试用例中推断 API 参数的必填性、类型和示例值
- 更新 GenerateSwagger 方法,支持在生成的 API 规范中包含推断的参数信息
- 优化文档,增加对新功能的说明,包括参数推断和覆盖率字段的详细描述
- 改进调试控制台的结构,支持模块化输出和独立导航页生成
- 增强并发保护与缓存隔离机制,确保测试环境的稳定性
2026-03-15 10:36:49 +08:00
hoteas 87098a9180 refactor(db): 增强测试事务和缓存管理
- 在 HoTimeDB 中新增 testMu 互斥锁,确保 testTx 的串行访问,解决并发冲突问题
- 在 BeginTestTx 中初始化 testMu,确保测试事务的安全性
- 在 HoTimeCache 中新增 DisableDbCache 方法,测试模式下禁用 DB 和 Redis 缓存,避免锁等待超时
- 更新 Select 方法,确保在 testTx 激活时跳过缓存逻辑,提升测试稳定性
- 优化 Swagger 生成逻辑,支持模块化输出和导航页生成
- 移除冗余的调试日志代码,提升代码整洁性
2026-03-14 13:06:32 +08:00
hoteas bd20af0c89 refactor(db): 增强测试事务支持和保存点管理
- 在 HoTimeDB 中新增测试事务功能,允许在测试模式下使用事务进行操作
- 实现 BeginTestTx 和 RollbackTestTx 方法,支持测试事务的开启和回滚
- 在 Action 方法中集成保存点管理,确保在测试模式下的嵌套事务处理
- 更新 README.md,添加 API 测试框架的相关说明,提升文档完整性
2026-03-14 10:19:57 +08:00
hoteas fab7931d3c refactor(app): 优化 URL 处理和 JSON 解析逻辑
- 更新应用程序处理程序中的 URL 赋值逻辑,确保静态文件使用原始路径
- 修改缓存数据库的 JSON 解析方法,使用 JsonToObj 函数替代 json.Unmarshal,提升代码可读性和性能
- 在 Map 和 Slice 类型中新增获取四舍五入浮点数的方法,增强数据处理能力
- 在 Obj 类型中添加四舍五入功能,支持精度控制
- 改进数据库查询结果的处理逻辑,确保数据类型的准确性和一致性
- 优化日志格式设置,增强日志信息的可读性
2026-03-10 23:44:41 +08:00
hoteas bf9bb5807d fix(db): 修复表列标识符处理逻辑确保聚合函数正常工作
- 修改 ProcessColumn 方法,table.column 格式不再添加反引号,只添加前缀
- 修改 ProcessColumnNoPrefix 方法,table.column 格式不加引号保持原始格式
- 更新 ProcessConditionString 方法,条件字符串中的 table.column 不加反引号
- 修复聚合函数如 Sum、Avg、Max、Min 在 table.column 格式下的正确性
- 添加完整的 table.column 格式测试用例验证功能一致性
- 确保返回的列名与原始列名一致,便于聚合函数等场景读取结果
2026-02-02 11:02:59 +08:00
hoteas 2d9e6e8e12 feat(app): 添加测试功能并增强错误处理
- 在应用路由中新增 test 接口用于显示测试数据
- 引入 errors 包以支持错误处理机制
- 在上下文响应处理中集成错误对象设置
- 实现 JSON 错误消息的自动转换与存储
- 扩展响应结构以同时支持 result 和 error 字段
2026-01-31 02:05:13 +08:00
hoteas 40eedccbd2 refactor(log): 优化调用栈查找逻辑以提高准确性
- 注释掉批量缓存操作测试的调用,避免不必要的执行
- 改进 findCaller 函数,增加对 application.go 的优先记录逻辑
- 确保返回的调用者信息优先考虑应用层代码,提升日志信息的准确性
- 增强框架文件的过滤机制,确保更清晰的调用栈信息
2026-01-30 22:17:53 +08:00
hoteas 4940232b24 feat(cache): 增加批量操作支持以提升性能
- 在 HoTimeCache 中新增 SessionsGet、SessionsSet 和 SessionsDelete 方法,支持批量获取、设置和删除 Session 缓存
- 优化缓存逻辑,减少数据库写入次数,提升性能
- 更新文档,详细说明批量操作的使用方法和性能对比
- 添加调试日志记录,便于追踪批量操作的执行情况
2026-01-30 17:51:43 +08:00
hoteas a11331e08d refactor(cache): 移除调试日志并优化缓存逻辑
- 删除 CacheDb 结构体中的 debugLog 调用,清理冗余的调试日志代码
- 优化历史表的创建逻辑,简化条件检查
- 更新缓存的获取和设置方法,确保逻辑清晰且高效
- 维护代码整洁性,提升可读性和可维护性
2026-01-25 23:15:32 +08:00
hoteas 8836e7a88d feat(cache): 增加历史记录功能和数据库设计规范
- 在 README.md 中新增数据库设计规范、代码生成配置规范和改进规划的文档链接
- 在 CacheDb 结构体中添加历史记录开关 HistorySet
- 实现历史记录的写入逻辑,记录每次缓存的新增和修改
- 更新缓存的获取和设置方法,支持历史记录的管理
- 优化数据库表的初始化和迁移逻辑,确保历史表的创建与管理
2026-01-25 05:14:18 +08:00
hoteas 431318bf8a refactor(log): 优化日志调用栈查找逻辑
- 添加最大框架层数限制防止误过滤应用层
- 新增 isHoTimeFrameworkFile 函数精确识别框架文件
- 实现更准确的框架文件过滤机制
- 替换原有的简单前缀匹配为复合条件判断
- 添加框架核心目录和文件的精确匹配规则
- 改进调用栈遍历算法提高查找准确性
2026-01-23 02:47:22 +08:00
hoteas cce765e735 test(db): 添加 OR 条件处理的单元测试并修复 WHERE 子句逻辑
- 添加了 TestWhereWithORCondition 测试函数验证 OR 条件的括号包裹
- 修复了 where.go 中条件计数变量名称从 normalCondCount 改为 condCount
- 实现了 OR/AND 组条件的括号包裹逻辑确保 SQL 语法正确
- 添加了空条件检查避免生成无效的 SQL 片段
- 更新了 .gitignore 文件添加日志文件忽略规则
2026-01-23 01:51:35 +08:00
hoteas 64c2e800b0 docs(guides): 完善框架文档并新增代码生成器说明
- 添加代码生成器完整使用说明文档,包含配置规则和自定义规则
- 新增 Common 工具类使用说明,介绍 Map/Slice/Obj 类型及转换函数
- 更新 QUICKSTART 文档中的配置项说明和数据库配置示例
- 完善请求参数获取方法,添加新版链式调用推荐用法
- 更新响应数据处理说明,包含错误码含义和自定义响应方法
- 优化中间件和 Session 操作的代码示例
- 修正路由路径参数获取的安全检查逻辑
- 更新 README 添加新文档链接索引
2026-01-22 22:13:05 +08:00
hoteas e704701752 refactor(db): 重命名批量插入方法并更新文档
- 将 BatchInsert 方法重命名为 Inserts,以更好地反映其功能
- 更新示例代码和文档,确保使用新方法名
- 删除过时的文档文件,整合 HoTimeDB 使用说明和 API 参考
- 优化 README.md,增强框架特性和安装说明的清晰度
2026-01-22 20:32:29 +08:00
hoteas 5235ef133d feat(db): 实现多数据库方言与自动表前缀支持
- 扩展 Dialect 接口添加 QuoteIdentifier 和 QuoteChar 方法
- 实现 IdentifierProcessor 结构体处理标识符转换
- 添加系统数据库列表避免对 INFORMATION_SCHEMA 等添加前缀
- 支持 database.table 格式的表名处理和前缀添加
- 在 CRUD 方法中集成新的标识符处理器
- 修改 WHERE 条件处理逻辑支持自动前缀
- 更新链式构建器的 JOIN 方法处理表名和条件
- 保持完全向后兼容性支持现有写法
2026-01-22 09:52:43 +08:00
hoteas c3b8d15b54 refactor(db): 重构数据库查询构建器以支持多数据库方言和标识符处理
- 实现了标识符处理器,统一处理表名、字段名的前缀添加和引号转换
- 添加对 MySQL、PostgreSQL、SQLite 三种数据库方言的支持
- 引入 ProcessTableName、ProcessColumn、ProcessConditionString 等方法处理标识符
- 为 HoTimeDB 添加 T() 和 C() 辅助方法用于手动构建 SQL 查询
- 重构 CRUD 操作中的表名和字段名处理逻辑,统一使用标识符处理器
- 添加完整的单元测试验证不同数据库方言下的标识符处理功能
- 优化 JOIN 操作中表名和条件字符串的处理方式
2026-01-22 09:32:01 +08:00
hoteas c4d0e16f2c feat(db): 实现多数据库方言与表名前缀支持功能
- 扩展 Dialect 接口,添加 QuoteIdentifier 和 QuoteChar 方法
- 新建 identifier.go,实现 IdentifierProcessor 及智能解析逻辑
- 在 db.go 中集成处理器,添加 T() 和 C() 辅助方法
- 修改 crud.go 中 Select/Insert/Update/Delete/buildJoin 等方法
- 修改 where.go 中 varCond 等条件处理方法
- 检查并更新 builder.go 相关功能
- 编写测试用例验证多数据库和前缀功能
- 移除调试日志文件以完成开发任务
2026-01-22 09:18:45 +08:00
hoteas ebe590ca9b chore(example): 清理调试日志并优化测试代码
- 移除大量 debugLog 调试日志调用
- 修改数据库计数检查代码,使用下划线忽略返回值
- 更新事务测试中的变量声明,统一使用下划线忽略不需要的返回值
- 添加注释说明 admin 表数据检查的目的
- 移除条件查询语法测试中的冗余调试日志
- 优化链式查询测试中的调试信息
- 清理 JOIN 查询测试部分的日志输出
- 移除聚合函数测试中的调试日志
- 删除分页查询测试中的多余日志
- 清理批量插入测试中的调试信息
- 优化 upsert 测试部分的日志输出
- 移除事务测试中的冗余调试日志
- 删除原生 SQL 测试中的大量调试日志
- 移除 IN/NOT IN 数组测试中的 region 注释块
2026-01-22 07:25:41 +08:00
hoteas 60a12a09bd feat(db): 实现数据库查询中的数组参数展开和空数组处理
- 在 Get 方法中添加无参数时的默认字段和 LIMIT 1 处理
- 实现 expandArrayPlaceholder 方法,自动展开 IN (?) 和 NOT IN (?) 中的数组参数
- 为空数组的 IN 条件生成 1=0 永假条件,NOT IN 生成 1=1 永真条件
- 在 queryWithRetry 和 execWithRetry 中集成数组占位符预处理
- 修复 where.go 中空切片条件的处理逻辑
- 添加完整的 IN/NOT IN 数组查询测试用例
- 更新 .gitignore 规则格式
2026-01-22 07:16:42 +08:00
hoteas 595275e747 remove(config): 移除配置相关JSON文件
- 删除 app.json 应用配置文件
- 删除 config.json 系统配置文件
- 删除 configNote.json 配置注释文件
- 删除 rule.json 规则配置文件
2026-01-22 06:12:26 +08:00
hoteas fdf02d4927 chore(project): 更新 .gitignore 文件
- 添加 /example/config 到忽略列表中
- 防止配置文件被提交到版本控制中
2026-01-22 06:10:51 +08:00
hoteas beebdfbbfa chore(config): 更新 .gitignore 配置
- 移除 /example/config/app.json 的忽略规则
- 移除 /example/config/ 目录的忽略规则
- 保留其他现有忽略配置不变
2026-01-22 06:09:19 +08:00
hoteas 05d1bb6b48 refactor(db): 重构数据库构建器以支持JOIN参数传递并删除示例配置文件
- 修改 Get 方法以根据是否存在 JOIN 来构建参数结构
- 修改 Count 方法以根据是否存在 JOIN 来传递参数
- 修改 Select 方法以根据是否存在 JOIN 来构建参数结构
- 在 where.go 中添加对 [##] 键的支持以直接添加 SQL 片段
- 删除 example/config/admin.json 配置文件
- 删除 example/config/config.json 配置文件
- 删除 example/config/configNote.json 配置文件
- 删除 example/config/rule.json 配置文件
- 删除 example/benchmark_test.go 压测文件
- 重构 example/main.go 文件,添加完整的 HoTimeDB 功能测试套件
- 添加调试日志功能以跟踪数据库操作流程
2026-01-22 05:31:17 +08:00
hoteas a770c52794 feat(request): 实现请求参数获取方法
- 完成 ReqParam/ReqParams 方法实现,用于获取 URL 参数并返回 *Obj
- 完成 ReqForm/ReqForms 方法实现,用于获取表单数据并返回 *Obj
- 完成 ReqJson/ReqJsons 方法实现,用于获取 JSON Body 并返回 *Obj
- 完成 ReqFile/ReqFiles 方法实现,用于获取上传文件
- 完成 ReqData/ReqDatas 方法实现,用于统一封装请求数据获取并返回 *Obj
- 更新计划文件状态,标记所有相关功能模块为已完成
2026-01-22 04:59:53 +08:00
hoteas 40f6783036 feat(cache): 优化缓存系统并重构数据库连接管理
- 将Redis连接方式改为连接池模式,提升连接复用效率
- 修复缓存注释错误,统一标识数据库缓存逻辑
- 添加数据检索结果非空验证,避免空指针异常
- 在数据库操作中添加读写锁保护,确保并发安全性
- 实现数据库查询和执行操作的重试机制,增强稳定性
- 更新配置文件中的缓存和数据库设置,优化缓存策略
- 重构README文档,补充框架特性和性能测试数据
- 添加示例路由配置,完善快速入门指南
2026-01-22 04:36:52 +08:00
hoteas 7843f7e8d6 style(cache): 格式化代码注释中的空格并添加 DISTINCT 选项
- 修复了缓存相关文件中注释开头缺少空格的问题
- 在 db/hotimedb.go 中为 vcond 数组添加了 "DISTINCT" 选项
- 注释掉了 session.go 中的并发控制代码以简化实现
2026-01-22 02:44:53 +08:00
hoteas 2ecb12bd7d 优化数据库连接配置,增强性能和稳定性 2025-08-07 17:51:31 +08:00
hoteas e5e615717a refactor(dri/baidu):优化导入路径和代码结构
- 更新了导入路径,使用了新的 "code.hoteas.com/golang/hotime/common" 路径
- 删除了未使用的变量和导入语句,简化了代码结构
2025-04-11 02:09:57 +08:00
hoteas 6db8f75ec7 refactor(cache): 重构 CacheMemory 实现
- 使用 sync.Map替代自定义 Map 结构- 优化缓存过期处理逻辑
- 改进通配符删除功能
- 随机触发缓存清理机制
2025-04-10 22:09:53 +08:00
hoteas 754668d017 增加数据库must设置 2023-04-06 11:37:03 +08:00
hoteas 544207a2e0 修复部分bug,增加DB SUM函数,以及优化IN性能,增加分析接口 2023-03-22 03:17:33 +08:00
hoteas 68ea5d5203 修复部分bug,增加DB SUM函数,以及优化IN性能,增加分析接口 2023-03-10 15:22:03 +08:00
hoteas 734d1b4be9 修复部分bug 2022-12-31 22:28:12 +08:00
hoteas c7a34990e3 修复部分bug 2022-11-17 17:17:07 +08:00
hoteas 6db22aa4fd 修复部分bug 2022-11-14 16:49:37 +08:00
hoteas f7493a1217 修复部分bug 2022-11-14 10:38:15 +08:00
hoteas 61b3c5c7cc 修复部分bug 2022-11-08 16:05:47 +08:00
hoteas f196e8a43a 修复部分bug 2022-11-07 01:41:22 +08:00
hoteas 05eb8d39db 修复部分bug 2022-11-07 01:40:24 +08:00
hoteas 2983934477 修复部分bug 2022-11-06 06:37:44 +08:00
hoteas 3d9666b630 修复部分bug 2022-11-05 01:55:11 +08:00
hoteas b8b543175d 修复部分bug 2022-11-04 03:22:00 +08:00
hoteas 3a1828be07 修复bug 2022-11-04 02:55:58 +08:00
hoteas f043597300 优化性能 2022-11-04 02:34:48 +08:00
hoteas 6542a7316a 优化性能 2022-11-04 02:25:33 +08:00
hoteas 57c0658da0 优化性能 2022-11-04 02:12:07 +08:00
hoteas 1f26cd2a50 修正bug 2022-10-21 04:28:51 +08:00
hoteas 1a7e92d524 修正bug 2022-10-20 15:49:49 +08:00
hoteas ea932a032b 时间由指针改回对象 2022-10-20 09:08:23 +08:00
hoteas 56f4911459 升级流程引擎配置文件版本 2022-10-20 09:00:44 +08:00
hoteas 735c088a0a 升级流程引擎配置文件版本 2022-10-19 21:32:34 +08:00
hoteas 80db372857 升级流程引擎配置文件版本 2022-10-19 21:29:38 +08:00
hoteas 8d1ad4499d 增加单表某字段权限及类型控制功能 2022-10-19 03:37:52 +08:00
hoteas 458cefd0ac 增加备注功能 2022-10-08 17:32:37 +08:00
hoteas 0d40fb5114 增加壁纸及壁纸地址缓存 2022-10-08 16:52:42 +08:00
hoteas 9ac5abe884 增加两个参数 2022-09-16 07:10:45 +08:00
hoteas 22a1f19e6a IP获取真实条件修改 2022-09-02 00:04:23 +08:00
hoteas 91a3fe2413 修复下载插件bug 2022-08-31 19:15:47 +08:00
hoteas b60b863af2 修复下载插件bug 2022-08-31 19:10:41 +08:00
hoteas 07637a635c 修复下载插件bug 2022-08-31 19:10:08 +08:00
hoteas 36cf708983 数据库操作bug 2022-08-31 11:20:58 +08:00
hoteas 8ab5c9f3e3 数据库操作bug 2022-08-31 11:14:45 +08:00
hoteas e4686fd77d 修正查询bug 2022-08-30 06:21:46 +08:00
hoteas 06714a1968 增加mongodb驱动,简单版本 2022-08-30 04:44:37 +08:00
hoteas 6fa8e66aaf 增加mongodb驱动,简单版本 2022-08-30 04:05:17 +08:00
hoteas c7494015dc 增加mongodb驱动,简单版本 2022-08-30 03:30:27 +08:00
hoteas 32d8c43e1f 修复重复执行context.View()的bug 2022-08-25 14:06:31 +08:00
hoteas ae6f2195b0 树状结构优化 2022-08-25 13:06:26 +08:00
hoteas 72d68d1738 树状结构优化 2022-08-25 13:02:44 +08:00
hoteas c959402250 数据库错误操作优化 2022-08-25 05:01:50 +08:00
hoteas 340a90278b 数据库错误操作优化 2022-08-25 03:48:09 +08:00
hoteas 312e92004d 数据库错误操作优化 2022-08-25 03:44:58 +08:00
hoteas ce9d8d055c 增加记录IP 2022-08-21 03:49:56 +08:00
hoteas 41f455cf4c Merge remote-tracking branch 'origin/master' 2022-08-20 22:38:40 +08:00
hoteas febd8d9805 增加记录IP 2022-08-20 22:38:15 +08:00
hoteas 60667734a7 数据库错误操作优化 2022-08-20 11:20:03 +08:00
hoteas cf29db5d5c 检索升级 2022-08-19 17:58:18 +08:00
hoteas 62a367ad5b 数据库错误操作优化 2022-08-18 13:40:02 +08:00
hoteas f5a24aa173 数据库错误操作优化 2022-08-18 13:28:02 +08:00
hoteas 67d1450848 数据库错误操作优化 2022-08-18 12:33:01 +08:00
hoteas 559e68a0b2 数据库事务操作优化 2022-08-18 11:36:23 +08:00
hoteas a596b02d43 数据库操作优化 2022-08-17 17:10:12 +08:00
hoteas 8ae4ccc126 数据库操作优化 2022-08-17 16:42:46 +08:00
hoteas 0b1116ba9e 数据库操作优化 2022-08-17 16:35:52 +08:00
hoteas e8b391b0a2 数据库操作优化,优化系统管理后台代码生成逻辑 2022-08-17 16:02:36 +08:00
hoteas 4097a44e58 数据库操作优化 2022-08-15 05:59:06 +08:00
hoteas 64daf04f35 修复登录bug 2022-08-11 10:41:35 +08:00
hoteas 97cca14b45 解决BUG 2022-08-11 10:18:48 +08:00
hoteas 78539b9dbc 修复bug 2022-08-08 04:34:26 +08:00
hoteas df5f8f158b 修复bug 2022-08-08 02:37:15 +08:00
hoteas ca947bb9e0 解决bug 2022-08-04 12:14:18 +08:00
hoteas 899c1c340d 修复bug 2022-08-04 02:41:41 +08:00
hoteas fab3d86519 修复配置bug 2022-08-03 12:07:55 +08:00
hoteas 48ee95ca67 V2.0封测 2022-08-02 03:02:57 +08:00
hoteas 9d31f2d1f8 权限管理优化 2022-08-01 18:48:08 +08:00
hoteas ffa32105f2 权限管理优化 2022-08-01 16:45:24 +08:00
hoteas 3da135e3cb 增加导出功能 2022-08-01 03:40:09 +08:00
hoteas 4e24a913c6 大岗山水电站初版完成 2022-07-28 12:08:47 +08:00
hoteas ed0944618f 大岗山水电站初版完成 2022-07-28 11:08:51 +08:00
hoteas e393d258a8 V2半封测 2022-07-27 17:09:56 +08:00
hoteas c91c610d2d 技术性调整 2022-07-26 17:37:17 +08:00
hoteas 7d74ebf4e4 技术性调整 2022-07-25 05:44:38 +08:00
hoteas abc7eaf529 技术性调整 2022-07-25 05:37:19 +08:00
hoteas a1250e15c5 技术性调整 2022-07-24 00:00:36 +08:00
hoteas f6d3aa800c 增加权限管理 2022-07-21 00:24:39 +08:00
hoteas 854df4fa7d 增加权限管理 2022-07-18 19:45:06 +08:00
hoteas 6c92a21c3b Merge remote-tracking branch 'origin/master'
# Conflicts:
#	example/config/configNote.json
#	var.go
2022-07-18 19:44:52 +08:00
hoteas 4b641cd09d 增加权限管理 2022-07-18 19:44:00 +08:00
hoteas 497c4b028f 技术性调整 2022-07-18 08:46:09 +08:00
hoteas e37b7b586b 优化日志及config 2022-07-11 19:13:20 +08:00
hoteas 15b73b3ce7 优化日志及config 2022-07-11 11:07:17 +08:00
hoteas 83acef93a9 技术性调整 2022-07-08 09:55:02 +08:00
hoteas f5af9424f0 修复数据库及时间bug 2022-06-21 00:06:34 +08:00
hoteas 5d4d1e693b 增加mysql断线重连优化 2022-06-14 11:29:54 +08:00
hoteas 88372e992e 增加time兼容性 2022-06-14 09:52:49 +08:00
hoteas 3b558b22ff 接口层复用 2022-06-09 04:05:50 +08:00
hoteas 34e032105d 接口层复用 2022-06-09 03:54:33 +08:00
hoteas 5c2e642bd1 数据库where增加[##]操作 2022-05-27 15:00:44 +08:00
hoteas 65ece0418e 增加vendor 2022-05-24 13:49:25 +08:00
hoteas c3a24eeb3c 删除example代码 2022-05-19 15:45:51 +08:00
zhoupengwei 61d5f410da 导出功能修改关联salesman关联关系 2022-05-18 17:52:49 +08:00
zhoupengwei f6f48a3f8d 科创券优化 2022-05-17 12:31:47 +08:00
zhoupengwei 771b02bd61 添source_type字段(券的来源:1-vip获得,2-认证获得,3-拉新获得),用来判断券的来源,
userinfo接口返回企业统一社会信用代码和营业执照路径
2022-05-13 18:43:35 +08:00
zhoupengwei 72955466d5 科服券列表+领券,企业认证,购买vip领券 2022-05-13 15:50:45 +08:00
zhoupengwei 8b7bf8a150 科服券列表+领券,企业认证,购买vip领券 2022-05-13 15:45:50 +08:00
zhoupengwei dbabee7a8a Merge branch 'zct-v2-dengluyang' of https://code.hoteas.com/golang/hotime into zhoupengwei 2022-05-13 15:34:53 +08:00
zhoupengwei 71fb533950 科服券列表+领券,企业认证,购买vip领券 2022-05-13 15:34:15 +08:00
hoteas cbcfd735ca 增加time类型直接转换 2022-05-13 15:31:55 +08:00
zhoupengwei e4f354d26f Merge branch 'zct-v2-dengluyang' of https://code.hoteas.com/golang/hotime into zhoupengwei 2022-05-13 09:36:46 +08:00
hoteas 20d8926d05 打印影响延迟优化 2022-05-13 09:30:09 +08:00
hoteas 0f2b47d6d9 打印影响延迟优化 2022-05-12 18:27:01 +08:00
zhoupengwei 2034e8e641 vip当日购买信息导出excel的sql调整,添加新字段:用户关联parentid时间,用户关联salesmanid时间 2022-05-11 18:19:30 +08:00
zhoupengwei f53a3108a9 Merge branch 'zct-v2-dengluyang' of https://code.hoteas.com/golang/hotime into zhoupengwei 2022-05-11 18:17:40 +08:00
hoteas 6a35466221 打印影响延迟优化 2022-05-11 14:55:28 +08:00
hoteas 243dd12747 打印影响延迟优化 2022-05-11 13:56:53 +08:00
zhoupengwei 5b453d7c5d Merge branch 'zct-v2-dengluyang' of https://code.hoteas.com/golang/hotime into zhoupengwei 2022-05-11 09:51:35 +08:00
hoteas 06e6ceab6a 对政策匹配进行优化 2022-05-11 00:44:52 +08:00
zhoupengwei 4365fa6241 vip当日购买信息导出excel 2022-05-10 19:47:20 +08:00
zhoupengwei 8fa8858b47 vip当日购买信息导出excel 2022-05-10 19:45:00 +08:00
zhoupengwei 64df6c9f03 Merge branch 'zct-v2-dengluyang' of https://code.hoteas.com/golang/hotime into zhoupengwei 2022-05-10 19:43:36 +08:00
hoteas 95ac340186 对政策匹配进行优化 2022-05-10 12:54:24 +08:00
zhoupengwei 93353f618a Merge branch 'zct-v2-dengluyang' of https://code.hoteas.com/golang/hotime into zhoupengwei
 Conflicts:
	example/app/declare.go
	example/provider/company.go
2022-05-10 09:43:03 +08:00
hoteas f2643222ca 对政策匹配进行优化 2022-05-09 19:19:15 +08:00
hoteas abecca89c5 对政策匹配进行优化 2022-05-09 15:07:01 +08:00
zhoupengwei 3a6b0f8647 Merge branch 'zct-v2' of https://code.hoteas.com/golang/hotime into zhoupengwei 2022-05-07 20:56:36 +08:00
zhoupengwei fa2545c2c8 Merge pull request '修复 bug 客户管理领域信息没存起来,高层次人才单选的形式存储的' (#1) from liyun into zct-v2
Reviewed-on: #1
2022-05-07 12:55:28 +00:00
michael eaa6a90c0e 修复 bug 客户管理领域信息没存起来,高层次人才单选的形式存储的 2022-05-07 20:47:58 +08:00
zhoupengwei a534e74f7d Merge branch 'zct-v2' of https://code.hoteas.com/golang/hotime into zhoupengwei 2022-05-07 17:11:27 +08:00
hoteas dec56b75d2 修正企服商那边不显示名称问题 2022-05-07 13:55:57 +08:00
hoteas 23b398e705 临时增加state为1企服商列表不显示企业 2022-05-07 11:08:11 +08:00
hoteas e0752218ea 增加限制条件 2022-05-07 11:02:12 +08:00
hoteas 25ab6c219d 增加限制条件 2022-05-06 13:08:13 +08:00
hoteas 4cbc84063a 增加限制条件 2022-05-06 11:35:40 +08:00
hoteas aea2e0241d 增加join的orm兼容性 2022-05-05 18:33:27 +08:00
hoteas eb41162f3b 增加U盘登录 2022-05-05 16:07:01 +08:00
zhoupengwei b37546bee2 Merge branch 'zct-v2' of https://code.hoteas.com/golang/hotime into zhoupengwei 2022-05-05 10:44:10 +08:00
hoteas f0d4efd8fc 初步版本修正 2022-05-05 00:29:30 +08:00
zhoupengwei 0fd7500e34 只在main.go中加了双//,测试是否能够提交成功 2022-05-04 21:50:52 +08:00
hoteas ce8059b0a0 调优 2022-05-03 22:40:02 +08:00
hoteas 7d36ad95a7 验证数据 2022-05-03 15:17:27 +08:00
hoteas 58da542533 基础整理 2022-05-03 09:41:18 +08:00
hoteas 0b09a13c17 基础整理 2022-05-03 08:09:25 +08:00
hoteas 062e849d44 基础整理 2022-05-02 15:42:54 +08:00
hoteas bdcec2d534 用户登录 2022-05-02 15:39:50 +08:00
hoteas 7b8abc9daa 科创地图打包 2022-04-29 17:58:14 +08:00
hoteas 7d4358af83 科创地图打包 2022-04-29 03:08:32 +08:00
hoteas cac15ade74 增加普通时间类型,以及增加富文本类型 2022-04-28 14:44:56 +08:00
hoteas 25b12142de Merge branch 'zct-wechat' into zct-manage
# Conflicts:
#	example/config/config.json
#	example/main.go
#	example/zct_manage.exe
2022-04-26 09:24:51 +08:00
hoteas 9c8190b54e 增加普通时间类型,以及增加富文本类型 2022-04-25 20:08:16 +08:00
hoteas f1244091fc 增加普通时间类型,以及增加富文本类型 2022-04-25 19:58:49 +08:00
hoteas 10b73bef8f 系统优化 2022-04-22 15:48:17 +08:00
hoteas 1113c1eb43 系统优化 2022-04-21 02:20:27 +08:00
hoteas 31955fce52 系统优化 2022-04-20 22:49:07 +08:00
hoteas cfa668a5f3 系统优化 2022-04-20 22:47:58 +08:00
hoteas 3fdc3aac2e 系统优化 2022-04-20 18:36:05 +08:00
hoteas c368619e51 系统优化 2022-04-18 06:28:01 +08:00
hoteas f1d066811d 系统优化 2022-03-29 05:31:02 +08:00
hoteas 49afe5f714 初步完成 2022-03-28 04:52:10 +08:00
hoteas cd23ed85c2 初步完成 2022-03-28 01:14:09 +08:00
hoteas 045fd3a38c 框架优化 2022-03-26 16:53:40 +08:00
hoteas 7da5f26098 框架优化 2022-03-16 09:58:24 +08:00
hoteas 02fc1d7843 框架优化 2022-03-14 16:48:19 +08:00
hoteas cb0bcdb2d1 框架优化 2022-03-13 17:02:19 +08:00
hoteas e426c285b0 框架优化 2022-03-13 05:13:32 +08:00
hoteas 716b3dfa6b 框架优化 2022-03-13 05:06:28 +08:00
hoteas 369c06724a Merge remote-tracking branch 'origin/master' into rfcb
# Conflicts:
#	code.go
2022-03-13 02:16:31 +08:00
hoteas 17ac1a9dcf 优化整体 2022-03-13 02:00:40 +08:00
hoteas 8befd195c0 优化整体 2022-03-13 01:48:54 +08:00
hoteas ac027c6e42 使用go mod 2022-03-13 01:12:29 +08:00
hoteas 7be976cdeb 使用go mod 2022-03-13 01:00:59 +08:00
hoteas b18bc1ced8 删除无关代码 2022-03-13 00:26:01 +08:00
hoteas 2b8816c54b 删除无关代码 2022-03-13 00:24:36 +08:00
hoteas cecefa1227 hotimego V1.0.1 2022-03-12 23:57:48 +08:00
hoteas 730a2e41f5 政企超链接开始集成 2022-03-12 23:52:42 +08:00
hoteas e7b9d7d100 政企超链接开始集成 2022-03-12 23:52:25 +08:00
hoteas e200e0a370 政企超链接开始集成 2022-03-06 23:55:14 +08:00
hoteas b5f6a33f07 政企超链接开始集成 2022-03-04 00:06:26 +08:00
hoteas 6512f122f5 政企超链接开始集成 2022-03-04 00:06:04 +08:00
hoteas cac0214271 政企超链接开始集成 2022-03-03 21:23:57 +08:00
hoteas e4ef9230c2 政企超链接开始集成 2022-03-02 18:19:15 +08:00
hoteas d90591af75 政企超链接开始集成 2022-03-02 12:14:07 +08:00
hoteas 91bf142782 政企超链接开始集成 2022-02-28 19:37:52 +08:00
hoteas fbe97b4461 政企超链接开始集成 2022-02-28 19:08:48 +08:00
hoteas eb25417104 政企超链接开始集成 2022-02-28 09:59:05 +08:00
hoteas 42f91d57ff 政企超链接开始集成 2022-02-28 08:53:38 +08:00
hoteas 2a98f0dcad 政企超链接开始集成 2022-02-24 06:26:36 +08:00
hoteas 32319d52f0 简阳版本 2022-02-19 21:38:35 +08:00
hoteas 3e94cf8def 简阳版本 2022-02-19 21:38:17 +08:00
hoteas 6a46d06e81 更新研发 2022-02-17 04:10:53 +08:00
hoteas d4759bb03c 更新研发 2022-02-14 19:11:38 +08:00
hoteas 9187960f66 更新研发 2022-02-14 19:10:58 +08:00
hoteas 7c2cc695b7 更新研发 2022-01-27 23:41:40 +08:00
hoteas 3b11f44915 更新研发 2022-01-26 11:45:17 +08:00
hoteas cea7e57409 更新研发 2022-01-23 05:13:19 +08:00
hoteas 29a84db736 更新研发 2022-01-22 16:12:02 +08:00
hoteas 74ec6e619a 更新研发 2022-01-20 07:15:14 +08:00
hoteas b42e73ec50 企业画像 2022-01-19 05:38:49 +08:00
hoteas f31262559d 更新研发 2022-01-17 04:47:39 +08:00
hoteas a8d7b934f3 权限模块开发 2022-01-12 17:33:20 +08:00
hoteas 865e14e121 权限模块开发 2022-01-10 13:23:39 +08:00
hoteas a3f97c04b3 修复太过于严格的bug 2022-01-03 01:42:35 +08:00
hoteas 5c5234016c 增加自适应模式 2021-12-31 02:38:51 +08:00
hoteas c222570eb2 增加自适应模式 2021-12-30 22:19:52 +08:00
hoteas 816b57d4e3 增加自适应模式 2021-12-29 00:46:07 +08:00
hoteas 8fce77b643 增加自适应模式 2021-12-28 09:26:26 +08:00
hoteas fa50191741 增加自适应模式 2021-12-27 21:30:33 +08:00
hoteas 3b3ca6facb 增加自适应模式 2021-12-27 21:30:24 +08:00
hoteas d3b355836d 增加自适应模式 2021-12-27 20:40:16 +08:00
hoteas a47473f245 优化系统 2021-12-27 18:21:41 +08:00
hoteas 9ce4f1c603 优化系统 2021-12-27 14:09:49 +08:00
hoteas 717ddeaea3 Merge branch 'master' into myhs
# Conflicts:
#	example/admin/init.go
#	example/bzyy.exe
#	example/main.go
2021-12-27 14:02:39 +08:00
hoteas d656f3689e 优化系统 2021-12-27 14:01:04 +08:00
hoteas 6e32f05087 优化系统 2021-12-27 14:00:08 +08:00
hoteas 1c31be2807 优化系统 2021-12-18 00:07:35 +08:00
hoteas 5b00a69496 优化系统 2021-12-17 14:37:41 +08:00
hoteas 55e60ac0eb 调试 2021-12-17 01:21:08 +08:00
hoteas 3ce77221f8 调试 2021-12-16 13:40:19 +08:00
hoteas 6fe5d0ba55 调试 2021-12-16 13:24:31 +08:00
hoteas d1d4d04415 出入库完成 2021-12-16 10:49:35 +08:00
hoteas 62cbf28b87 出入库完成 2021-12-15 09:24:41 +08:00
hoteas ac66331535 出入库完成 2021-12-15 05:56:31 +08:00
hoteas 9482404265 书写前端接口 2021-12-12 04:22:30 +08:00
hoteas 7b85da4c7f 书写前端接口 2021-12-11 23:53:47 +08:00
hoteas db013b9966 书写前端代码 2021-12-11 17:59:02 +08:00
hoteas 44e46982f5 ocr增加 2021-12-09 11:16:15 +08:00
hoteas 02911fd39e ocr增加 2021-12-09 10:58:53 +08:00
hoteas 3a706077dd 优化makecode 2021-11-29 03:27:07 +08:00
hoteas 219f93b5ff 优化makecode 2021-11-23 18:31:00 +08:00
hoteas fb6f55c98d 优化makecode 2021-11-18 12:22:14 +08:00
hoteas f38e6f4397 绵阳宏升代码优化 2021-11-15 03:35:13 +08:00
hoteas f62d169465 绵阳宏升代码优化 2021-11-15 03:34:45 +08:00
hoteas 96b1795f05 巴中代码提交 2021-11-15 03:32:50 +08:00
hoteas 2734e95096 初次提交 2021-11-15 03:30:21 +08:00
hoteas 101609e81b 初次提交 2021-11-15 03:28:14 +08:00
hoteas 2cea20e7c9 初次提交 2021-11-15 03:26:54 +08:00
hoteas 340043d1f0 初次提交 2021-11-13 21:06:29 +08:00
hoteas 41defc022a 初次提交 2021-11-08 09:16:28 +08:00
hoteas 830d69825d 初次提交 2021-11-08 09:15:52 +08:00
hoteas 1f2a247398 完善访问日志 2021-11-08 06:49:34 +08:00
hoteas fdab298f52 修复短信发送bug 2021-10-27 00:27:24 +08:00
hoteas 6c999990e3 增加天府通办登录 2021-10-25 23:18:27 +08:00
hoteas 4433c3efc5 修复bug 2021-10-21 23:20:58 +08:00
hoteas 024b42bfa1 修复bug 2021-10-21 23:19:32 +08:00
hoteas 3efd5bd0a0 修复bug 2021-10-21 22:52:40 +08:00
hoteas e5dfe9aa8d 修复bug 2021-10-18 04:49:55 +08:00
hoteas 62ba6e4f86 修复bug 2021-09-25 04:36:07 +08:00
hoteas faddd72fee 短信变更 2021-09-24 15:01:03 +08:00
hoteas c8c5f453d5 巴中恩阳适配 2021-09-24 10:49:07 +08:00
hoteas 49d956cc36 巴中恩阳适配 2021-09-17 04:45:43 +08:00
hoteas cdefc0e9e3 开始新增代码生成配置2.0功能,配置文件格式规定完成 2021-09-17 04:00:49 +08:00
hoteas 9513b80a24 开始新增代码生成配置2.0功能,配置文件格式规定完成 2021-09-16 04:33:10 +08:00
hoteas acb7cdd4b8 增加表关联新建功能,同时修复数据库bug 2021-09-12 06:14:01 +08:00
52 changed files with 4644 additions and 5283 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`
+32
View File
@@ -0,0 +1,32 @@
---
description: HoTime Doc-Driven + TDD 总门禁
alwaysApply: true
---
# HoTime 工作流
**不可跳过。** Doc-Driven 与 TDD 缺一不可。日常五步:①定位 → ②修改 → ③补/改行为测试并针对性跑至绿 → ④边界 → ⑤回写文档。未测绿或未回写 = **未完成**。中文回复。
## 定位
- 先锁约 **3~4 个文件**(文档 1~2 + 源码 1~2);跨模块扩到合计约 **4~6**,禁止散改全仓。本次新增/修改的**测试文件计入锁定**。先读相关 docs 再改。口径不清 **先问**。
- 入口:`docs/README.md`、仓根 `README.md`;测试细则 → `docs/Testing_API测试框架.md` / skill `hotime-tdd-testing`。
## 改与测
- 改接口、ctr、业务逻辑或测试框架:对应 `*_test` **无则补、有则改**,跑通至绿;**禁**只改实现不补测;**禁全量**;编译不算测完。
- 样板(`example` 根):`go test ./app/ -count=1 -run 'TestApi/app/<ctr>/<action>'`。可收窄到子用例;**改哪测哪**。用户要求全量或以编译代替行为测时,**明确拒绝**并仍按本条。
## 文档
- **有旧改旧**;无篇目且确有缺口才新建。禁不查就建、禁同一入口重复建档。旧文只补受影响节;只写最终态;对照源码,禁臆测。
- 路径用相对 Markdown 链接;**禁** `D:\`、`file:///`、`~/`。新建须回写 `docs/README.md`。
## 回复
- 先结论;摘要 + 文档路径 + **改动定位(文件·位置·原因)**;禁大段复述 docs。
- 凡改代码或报完成:末尾必须一行 `【门禁】锁定:… | 补测并跑绿:是/否 | 文档回写:是/否/无 | 新建or旧文:…`;缺一不可。闲聊不要求。
## 批量补文档(仅整包)
一文一题;最少 2 轮自检。日常小改不适用。
-5
View File
@@ -1,5 +0,0 @@
{
"setup-worktree": [
"npm install"
]
}
+15
View File
@@ -2,10 +2,25 @@
.idea
/example/tpt/demo/
*.exe
*.test
*.out
/cover
/coverage.out
/coverage.html
/example/config
/config/
/tpt/
example/tpt/swagger/
**/swagger/
/.cursor/*.log
*.sql
*.py
*.log
logs/
# Cursor / agent debug NDJSON leftovers
debug-*.log
**/debug-*.log
# macOS
.DS_Store
View File
+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
---
+172 -63
View File
@@ -1,9 +1,10 @@
package hotime
import (
"bufio"
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"fmt"
"io/ioutil"
stdlog "log"
@@ -26,6 +27,8 @@ import (
. "code.hoteas.com/golang/hotime/db"
"code.hoteas.com/golang/hotime/log"
mysql "github.com/go-sql-driver/mysql"
"github.com/rs/zerolog"
logrus "github.com/sirupsen/logrus"
)
type Application struct {
@@ -96,10 +99,124 @@ func (that *Application) initiateGracefulShutdown(drain, shutdown time.Duration)
} else {
that.Log.Infof("服务已安全关闭")
}
// 冲刷 Seq 队列,避免停机前后关键日志丢失
that.Log.CloseSeq()
if that.WebConnectLog != nil {
that.WebConnectLog.CloseSeq()
}
os.Exit(0)
})
}
// isLoopbackIP 判断是否为回环地址(空串视为不可用)
func isLoopbackIP(ip string) bool {
ip = strings.TrimSpace(ip)
if ip == "" {
return true
}
parsed := net.ParseIP(ip)
if parsed == nil {
return false
}
return parsed.IsLoopback()
}
// remoteHost 从 RemoteAddr 取出 host(去掉端口)
func remoteHost(req *http.Request) string {
if req == nil || req.RemoteAddr == "" {
return ""
}
host, _, err := net.SplitHostPort(req.RemoteAddr)
if err == nil && host != "" {
return host
}
return req.RemoteAddr
}
// resolveClientIP 多源识别客户端 IP。
// bestEO-Connecting-IP → XFF 第一个非回环 → 非回环 X-Real-IP → RemoteAddr
// XFF 先于 X-Real:反代误把 CDN 节点写进 X-Real-IP 时仍能落到客户端)
// chain:各源头 IP 按首次出现顺序逗号拼接,同一 IP 只保留一次
func resolveClientIP(req *http.Request) (best string, chain string) {
if req == nil {
return "", ""
}
eo := strings.TrimSpace(req.Header.Get("EO-Connecting-IP"))
xri := strings.TrimSpace(req.Header.Get("X-Real-IP"))
xffRaw := req.Header.Get("X-Forwarded-For")
ra := remoteHost(req)
var xffParts []string
if xffRaw != "" {
for _, p := range strings.Split(xffRaw, ",") {
if p = strings.TrimSpace(p); p != "" {
xffParts = append(xffParts, p)
}
}
}
seen := make(map[string]struct{}, 4)
var ordered []string
add := func(ip string) {
ip = strings.TrimSpace(ip)
if ip == "" {
return
}
if _, ok := seen[ip]; ok {
return
}
seen[ip] = struct{}{}
ordered = append(ordered, ip)
}
add(eo)
add(xri)
for _, p := range xffParts {
add(p)
}
add(ra)
chain = strings.Join(ordered, ",")
if eo != "" && !isLoopbackIP(eo) {
return eo, chain
}
for _, p := range xffParts {
if !isLoopbackIP(p) {
return p, chain
}
}
if xri != "" && !isLoopbackIP(xri) {
return xri, chain
}
if ra != "" {
return ra, chain
}
return "", chain
}
// clientIP 取客户端真实 IP(见 resolveClientIP
func clientIP(req *http.Request) string {
ip, _ := resolveClientIP(req)
return ip
}
// truncateUA 按 rune 安全截断字符串到 max 长度,避免切坏多字节字符导致 JSON 转义问题
func truncateUA(s string, max int) string {
r := []rune(s)
if len(r) <= max {
return s
}
return string(r[:max])
}
// shortID 生成 n 字节随机 hexn=6 → 12 位)
func shortID(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return Md5(strconv.Itoa(Rand(10)))[:n*2]
}
return hex.EncodeToString(b)
}
// Run 启动实例
func (that *Application) Run(router Router) {
//如果没有设置配置自动生成配置
@@ -189,7 +306,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 +463,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)
@@ -357,6 +475,8 @@ func (that *Application) SetConfig(configPath ...string) {
that.Log.SetSeqWriter(seqUrl, that.Config.GetString("seqApiKey"), instance)
if that.WebConnectLog != nil {
that.WebConnectLog.SetSeqWriter(seqUrl, that.Config.GetString("seqApiKey"), instance)
// 访问日志量大,挂 Seq 后控制台仍保持 Warn+(业务 Logger 已放宽到 Info+
that.WebConnectLog.SetConsoleMinLevel(zerolog.WarnLevel)
}
that.Log.Infof("Seq 日志推送已启动: url=%s instance=%s", seqUrl, instance)
}
@@ -436,10 +556,22 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) {
if err != nil {
unescapeUrl = req.RequestURI
}
// 请求级追踪:sid=sessionId 前 12 位(脱敏),request_id 回写响应头
requestId := shortID(6)
sid := sessionId
if len(sid) > 12 {
sid = sid[:12]
}
w.Header().Set("X-Request-Id", requestId)
reqLog := that.Log.WithFields("sid", sid, "request_id", requestId)
dbCopy := that.Db
dbCopy.Log = reqLog
//访问实例
context := Context{SessionIns: SessionIns{SessionId: sessionId, HoTimeCache: that.HoTimeCache},
Resp: w, Req: req, Application: that, RouterString: s, Config: that.Config, Db: &that.Db,
HandlerStr: unescapeUrl}
Resp: w, Req: req, Application: that, RouterString: s, Config: that.Config, Db: &dbCopy,
HandlerStr: unescapeUrl, Logger: reqLog}
//header默认设置
header := w.Header()
header.Set("Content-Type", "text/html; charset=utf-8")
@@ -453,26 +585,24 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) {
defer func() {
//是否展示日志
if that.WebConnectLog != nil {
//负载均衡优化
ipStr := ""
if req.Header.Get("X-Forwarded-For") != "" {
ipStr = req.Header.Get("X-Forwarded-For")
} else if req.Header.Get("X-Real-IP") != "" {
ipStr = req.Header.Get("X-Real-IP")
}
//负载均衡优化
if ipStr == "" {
//RemoteAddr := that.Req.RemoteAddr
ipStr = Substr(context.Req.RemoteAddr, 0, strings.Index(context.Req.RemoteAddr, ":"))
}
that.WebConnectLog.Info().
Str("ip", ipStr).
ip, ipChain := resolveClientIP(req)
evt := that.WebConnectLog.Info().
Str("ip", ip).
Str("method", context.Req.Method).
Str("sid", sid).
Str("request_id", requestId).
Float64("cost_ms", ObjToFloat64(time.Now().UnixNano()-nowUnixTime.UnixNano())/1000000.00).
Float64("size_kb", ObjToFloat64(context.DataSize)/1000.00).
Msg(context.HandlerStr)
Float64("size_kb", ObjToFloat64(context.DataSize)/1000.00)
if ipChain != "" && ipChain != ip {
evt = evt.Str("ip_chain", ipChain)
}
if country := req.Header.Get("EO-Client-IPCountry"); country != "" {
evt = evt.Str("ip_country", country)
}
if ua := req.Header.Get("User-Agent"); ua != "" {
evt = evt.Str("ua", truncateUA(ua, 200))
}
evt.Msg(context.HandlerStr)
}
}()
@@ -532,13 +662,9 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) {
return
}
//设置header
// 静态资源可缓存;logLevel 只控制日志/SQL,不再改 Cache-Control
delete(header, "Content-Type")
if that.Config.GetCeilInt("logLevel") == 0 {
header.Set("Cache-Control", "public")
} else {
header.Set("Cache-Control", "no-cache")
}
header.Set("Cache-Control", "public")
t := strings.LastIndex(path, ".")
if t != -1 {
@@ -660,52 +786,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 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
@@ -800,7 +909,7 @@ func SetMysqlDB(appIns *Application, config Map) {
appIns.Db.Log = appIns.Log
appIns.SetConnectDB(func() (master, slave *sql.DB) {
query := config.GetString("user") + ":" + config.GetString("password") +
"@tcp(" + config.GetString("host") + ":" + config.GetString("port") + ")/" + config.GetString("name") + "?charset=utf8"
"@tcp(" + config.GetString("host") + ":" + config.GetString("port") + ")/" + config.GetString("name") + "?charset=utf8mb4"
DB, e := sql.Open("mysql", query)
if e != nil {
appIns.Log.Error().Err(e).Msg("MySQL 主库连接失败")
@@ -809,7 +918,7 @@ func SetMysqlDB(appIns *Application, config Map) {
configSlave := config.GetMap("slave")
if configSlave != nil {
query := configSlave.GetString("user") + ":" + configSlave.GetString("password") +
"@tcp(" + config.GetString("host") + ":" + configSlave.GetString("port") + ")/" + configSlave.GetString("name") + "?charset=utf8"
"@tcp(" + config.GetString("host") + ":" + configSlave.GetString("port") + ")/" + configSlave.GetString("name") + "?charset=utf8mb4"
DB1, e := sql.Open("mysql", query)
if e != nil {
appIns.Log.Error().Err(e).Msg("MySQL 从库连接失败")
+91
View File
@@ -0,0 +1,91 @@
package hotime
import (
"net/http"
"testing"
)
func TestResolveClientIP_EOPreferred(t *testing.T) {
req, _ := http.NewRequest("GET", "/", nil)
req.Header.Set("EO-Connecting-IP", "171.1.2.3")
req.Header.Set("X-Real-IP", "43.1.2.3")
req.Header.Set("X-Forwarded-For", "171.1.2.3, 43.1.2.3")
req.RemoteAddr = "127.0.0.1:12345"
ip, chain := resolveClientIP(req)
if ip != "171.1.2.3" {
t.Fatalf("ip=%q want 171.1.2.3", ip)
}
// 去重:171 只出现一次
if chain != "171.1.2.3,43.1.2.3,127.0.0.1" {
t.Fatalf("chain=%q", chain)
}
}
func TestResolveClientIP_SkipLoopbackXReal(t *testing.T) {
req, _ := http.NewRequest("GET", "/", nil)
req.Header.Set("X-Real-IP", "127.0.0.1")
req.Header.Set("X-Forwarded-For", "171.1.2.3, 43.1.2.3")
req.RemoteAddr = "127.0.0.1:8080"
ip, chain := resolveClientIP(req)
if ip != "171.1.2.3" {
t.Fatalf("ip=%q want 171.1.2.3", ip)
}
if chain != "127.0.0.1,171.1.2.3,43.1.2.3" {
t.Fatalf("chain=%q", chain)
}
}
func TestResolveClientIP_XFFBeforeMisconfiguredXReal(t *testing.T) {
// 线上常见:X-Real-IP=CDNXFF=客户端,CDN
req, _ := http.NewRequest("GET", "/", nil)
req.Header.Set("X-Real-IP", "43.1.2.3")
req.Header.Set("X-Forwarded-For", "171.1.2.3, 43.1.2.3")
req.RemoteAddr = "127.0.0.1:8080"
ip, chain := resolveClientIP(req)
if ip != "171.1.2.3" {
t.Fatalf("ip=%q want 171.1.2.3", ip)
}
if chain != "43.1.2.3,171.1.2.3,127.0.0.1" {
t.Fatalf("chain=%q", chain)
}
}
func TestResolveClientIP_XRealWhenNoXFF(t *testing.T) {
req, _ := http.NewRequest("GET", "/", nil)
req.Header.Set("X-Real-IP", "203.0.113.9")
req.RemoteAddr = "10.0.0.1:80"
ip, chain := resolveClientIP(req)
if ip != "203.0.113.9" {
t.Fatalf("ip=%q want 203.0.113.9", ip)
}
if chain != "203.0.113.9,10.0.0.1" {
t.Fatalf("chain=%q", chain)
}
}
func TestResolveClientIP_DirectRemoteAddr(t *testing.T) {
req, _ := http.NewRequest("GET", "/", nil)
req.RemoteAddr = "192.168.1.8:5555"
ip, chain := resolveClientIP(req)
if ip != "192.168.1.8" {
t.Fatalf("ip=%q want 192.168.1.8", ip)
}
if chain != "192.168.1.8" {
t.Fatalf("chain=%q", chain)
}
if clientIP(req) != ip {
t.Fatalf("clientIP mismatch")
}
}
func TestResolveClientIP_NilReq(t *testing.T) {
ip, chain := resolveClientIP(nil)
if ip != "" || chain != "" {
t.Fatalf("want empty, got %q %q", ip, chain)
}
}
+1 -1
View File
@@ -963,7 +963,7 @@ var TptProject = Proj{
},
"login": func(that *Context) {
// 限速:同一 IP 1分钟内最多尝试10次
ip := that.Req.Header.Get("X-Forwarded-For")
ip := clientIP(that.Req)
if ip == "" {
ip = that.Req.RemoteAddr
}
+9 -3
View File
@@ -980,7 +980,11 @@ func (that *MakeCode) Search(table string, userData Map, data Map, req *http.Req
// }
//
reqValue := req.FormValue(v.GetString("name"))
if v.GetString("name") != "parent_id" && reqValue != "" {
// text 类字段只走下方模糊匹配([~]),此处不再生成等值条件,
// 否则等值(必不命中部分关键词)与模糊匹配在 where 中以 AND 叠加,
// 查询恒为空(见 docs/CodeGen_代码生成.md 通用 Search 筛选说明)。
if v.GetString("name") != "parent_id" && reqValue != "" &&
!strings.Contains(v.GetString("type"), "text") {
data[v.GetString("name")] = reqValue
}
//
@@ -1244,8 +1248,10 @@ func (that *MakeCode) Search(table string, userData Map, data Map, req *http.Req
} else {
where["parent_id"] = reqValue
}
//是否展示自己
if req.FormValue("showself") == "1" {
//是否展示自己:仅在 showall=1(选中节点及全部子孙)场景生效;
//普通子级懒加载(仅 parent_id=X)不得把 id=X 的行本身混入结果,
//否则前端树把节点当成自己的子级,造成无限嵌套
if req.FormValue("showself") == "1" && req.FormValue("showall") == "1" {
if len(where) == 0 {
data["OR"] = Map{"id": reqValue}
} else {
+32 -16
View File
@@ -6,19 +6,20 @@ import (
"io"
"mime/multipart"
"net/http"
"strings"
"sync"
"time"
. "code.hoteas.com/golang/hotime/common"
. "code.hoteas.com/golang/hotime/db"
htlog "code.hoteas.com/golang/hotime/log"
)
type Context struct {
*Application
Resp http.ResponseWriter
Req *http.Request
Log Map //日志有则创建
Log Map //业务 logs 表字段(有则写入),与 Logger 不同
Logger *htlog.Logger // 请求级日志(带 sid/request_id),业务与 SQL 共用
RouterString []string
Config Map
Db *HoTimeDB
@@ -60,7 +61,7 @@ func (that *Context) Display(statu int, data interface{}) {
//兼容android等需要json转对象的服务
resp["error"] = temp
that.Application.Log.Warn().Int("status", statu).Msg(resp.ToJsonString())
that.reqLogger().Warn().Int("status", statu).Msg(resp.ToJsonString())
} else {
resp["result"] = data
@@ -71,6 +72,33 @@ func (that *Context) Display(statu int, data interface{}) {
//that.Data=d;
}
// reqLogger 返回请求级 Loggernil 时回退 Application.Log
func (that *Context) reqLogger() *htlog.Logger {
if that.Logger != nil {
return that.Logger
}
if that.Application != nil {
return that.Application.Log
}
return nil
}
// LogBind 为本请求追加自定义日志字段(如 user_id),后续业务日志与 SQL 日志均携带。
// 供 main.go 的 connectListener 调用;nil 安全。
func (that *Context) LogBind(key string, value interface{}) {
if that == nil || key == "" {
return
}
base := that.reqLogger()
if base == nil {
return
}
that.Logger = base.WithFields(key, ObjToStr(value))
if that.Db != nil {
that.Db.Log = that.Logger
}
}
func (that *Context) View() {
if that.RespFunc != nil {
that.RespFunc()
@@ -87,19 +115,7 @@ func (that *Context) View() {
if that.Session("user_id").Data != nil {
that.Log["user_id"] = that.Session("user_id").ToCeilInt()
}
//负载均衡优化
ipStr := ""
if that.Req.Header.Get("X-Forwarded-For") != "" {
ipStr = that.Req.Header.Get("X-Forwarded-For")
} else if that.Req.Header.Get("X-Real-IP") != "" {
ipStr = that.Req.Header.Get("X-Real-IP")
}
//负载均衡优化
if ipStr == "" {
//RemoteAddr := that.Req.RemoteAddr
ipStr = Substr(that.Req.RemoteAddr, 0, strings.Index(that.Req.RemoteAddr, ":"))
}
that.Log["ip"] = ipStr
that.Log["ip"] = clientIP(that.Req)
that.Db.Insert("logs", that.Log)
}
-344
View File
@@ -1,344 +0,0 @@
mode: set
code.hoteas.com/golang/hotime/common/context_base.go:12.42,14.20 1 0
code.hoteas.com/golang/hotime/common/context_base.go:14.20,16.3 1 0
code.hoteas.com/golang/hotime/common/context_base.go:17.2,17.17 1 0
code.hoteas.com/golang/hotime/common/error.go:15.37,19.2 3 1
code.hoteas.com/golang/hotime/common/error.go:21.40,26.38 4 1
code.hoteas.com/golang/hotime/common/error.go:26.38,28.3 1 0
code.hoteas.com/golang/hotime/common/func.go:29.41,30.19 1 0
code.hoteas.com/golang/hotime/common/func.go:30.19,32.3 1 0
code.hoteas.com/golang/hotime/common/func.go:34.2,37.39 3 0
code.hoteas.com/golang/hotime/common/func.go:41.54,42.18 1 0
code.hoteas.com/golang/hotime/common/func.go:42.18,44.3 1 0
code.hoteas.com/golang/hotime/common/func.go:45.2,46.18 2 0
code.hoteas.com/golang/hotime/common/func.go:46.18,48.3 1 0
code.hoteas.com/golang/hotime/common/func.go:50.2,50.12 1 0
code.hoteas.com/golang/hotime/common/func.go:51.9,52.29 1 0
code.hoteas.com/golang/hotime/common/func.go:53.9,54.32 1 0
code.hoteas.com/golang/hotime/common/func.go:55.9,56.35 1 0
code.hoteas.com/golang/hotime/common/func.go:57.9,58.38 1 0
code.hoteas.com/golang/hotime/common/func.go:59.9,60.41 1 0
code.hoteas.com/golang/hotime/common/func.go:61.10,62.27 1 0
code.hoteas.com/golang/hotime/common/func.go:63.10,64.33 1 0
code.hoteas.com/golang/hotime/common/func.go:65.10,66.36 1 0
code.hoteas.com/golang/hotime/common/func.go:67.10,68.27 1 0
code.hoteas.com/golang/hotime/common/func.go:69.10,70.30 1 0
code.hoteas.com/golang/hotime/common/func.go:72.2,72.40 1 0
code.hoteas.com/golang/hotime/common/func.go:77.46,78.16 1 0
code.hoteas.com/golang/hotime/common/func.go:78.16,81.3 2 0
code.hoteas.com/golang/hotime/common/func.go:82.2,83.19 2 0
code.hoteas.com/golang/hotime/common/func.go:83.19,85.3 1 0
code.hoteas.com/golang/hotime/common/func.go:86.2,86.19 1 0
code.hoteas.com/golang/hotime/common/func.go:86.19,88.3 1 0
code.hoteas.com/golang/hotime/common/func.go:89.2,89.22 1 0
code.hoteas.com/golang/hotime/common/func.go:89.22,91.3 1 0
code.hoteas.com/golang/hotime/common/func.go:92.2,92.31 1 0
code.hoteas.com/golang/hotime/common/func.go:92.31,93.32 1 0
code.hoteas.com/golang/hotime/common/func.go:93.32,94.24 1 0
code.hoteas.com/golang/hotime/common/func.go:94.24,96.5 1 0
code.hoteas.com/golang/hotime/common/func.go:96.10,98.24 2 0
code.hoteas.com/golang/hotime/common/func.go:98.24,100.6 1 0
code.hoteas.com/golang/hotime/common/func.go:101.5,101.26 1 0
code.hoteas.com/golang/hotime/common/func.go:101.26,103.6 1 0
code.hoteas.com/golang/hotime/common/func.go:104.5,104.22 1 0
code.hoteas.com/golang/hotime/common/func.go:109.2,109.26 1 0
code.hoteas.com/golang/hotime/common/func.go:113.55,118.15 4 1
code.hoteas.com/golang/hotime/common/func.go:118.15,120.3 1 0
code.hoteas.com/golang/hotime/common/func.go:121.2,123.17 2 1
code.hoteas.com/golang/hotime/common/func.go:123.17,125.3 1 0
code.hoteas.com/golang/hotime/common/func.go:127.2,127.15 1 1
code.hoteas.com/golang/hotime/common/func.go:127.15,129.3 1 0
code.hoteas.com/golang/hotime/common/func.go:130.2,130.16 1 1
code.hoteas.com/golang/hotime/common/func.go:130.16,132.3 1 0
code.hoteas.com/golang/hotime/common/func.go:133.2,133.13 1 1
code.hoteas.com/golang/hotime/common/func.go:133.13,135.3 1 0
code.hoteas.com/golang/hotime/common/func.go:136.2,136.14 1 1
code.hoteas.com/golang/hotime/common/func.go:136.14,138.3 1 0
code.hoteas.com/golang/hotime/common/func.go:140.2,140.30 1 1
code.hoteas.com/golang/hotime/common/func.go:145.40,148.35 3 0
code.hoteas.com/golang/hotime/common/func.go:148.35,150.3 1 0
code.hoteas.com/golang/hotime/common/func.go:152.2,154.42 2 0
code.hoteas.com/golang/hotime/common/func.go:154.42,156.14 2 0
code.hoteas.com/golang/hotime/common/func.go:156.14,158.22 2 0
code.hoteas.com/golang/hotime/common/func.go:158.22,161.18 3 0
code.hoteas.com/golang/hotime/common/func.go:161.18,162.11 1 0
code.hoteas.com/golang/hotime/common/func.go:165.4,165.13 1 0
code.hoteas.com/golang/hotime/common/func.go:165.13,167.5 1 0
code.hoteas.com/golang/hotime/common/func.go:171.2,171.11 1 0
code.hoteas.com/golang/hotime/common/func.go:175.29,180.2 4 0
code.hoteas.com/golang/hotime/common/func.go:183.26,185.29 2 0
code.hoteas.com/golang/hotime/common/func.go:185.29,187.3 1 0
code.hoteas.com/golang/hotime/common/func.go:188.2,188.22 1 0
code.hoteas.com/golang/hotime/common/func.go:190.23,193.26 3 0
code.hoteas.com/golang/hotime/common/func.go:193.26,195.28 2 0
code.hoteas.com/golang/hotime/common/func.go:195.28,198.9 2 0
code.hoteas.com/golang/hotime/common/func.go:200.3,200.14 1 0
code.hoteas.com/golang/hotime/common/func.go:203.2,203.10 1 0
code.hoteas.com/golang/hotime/common/func.go:208.36,211.18 2 0
code.hoteas.com/golang/hotime/common/func.go:211.18,213.3 1 0
code.hoteas.com/golang/hotime/common/func.go:215.2,215.6 1 0
code.hoteas.com/golang/hotime/common/func.go:215.6,217.19 2 0
code.hoteas.com/golang/hotime/common/func.go:217.19,218.9 1 0
code.hoteas.com/golang/hotime/common/func.go:221.2,221.12 1 0
code.hoteas.com/golang/hotime/common/func.go:260.49,261.37 1 0
code.hoteas.com/golang/hotime/common/func.go:261.37,263.30 2 0
code.hoteas.com/golang/hotime/common/func.go:263.30,265.4 1 0
code.hoteas.com/golang/hotime/common/func.go:267.3,267.16 1 0
code.hoteas.com/golang/hotime/common/func.go:268.8,268.56 1 0
code.hoteas.com/golang/hotime/common/func.go:268.56,270.32 2 0
code.hoteas.com/golang/hotime/common/func.go:270.32,272.4 1 0
code.hoteas.com/golang/hotime/common/func.go:274.3,274.18 1 0
code.hoteas.com/golang/hotime/common/func.go:275.8,275.63 1 0
code.hoteas.com/golang/hotime/common/func.go:275.63,277.30 2 0
code.hoteas.com/golang/hotime/common/func.go:277.30,279.4 1 0
code.hoteas.com/golang/hotime/common/func.go:281.3,281.16 1 0
code.hoteas.com/golang/hotime/common/func.go:283.8,283.48 1 0
code.hoteas.com/golang/hotime/common/func.go:283.48,285.32 2 0
code.hoteas.com/golang/hotime/common/func.go:285.32,287.4 1 0
code.hoteas.com/golang/hotime/common/func.go:289.3,289.18 1 0
code.hoteas.com/golang/hotime/common/func.go:292.2,292.14 1 0
code.hoteas.com/golang/hotime/common/func.go:319.38,322.2 2 0
code.hoteas.com/golang/hotime/common/map.go:15.61,17.19 1 0
code.hoteas.com/golang/hotime/common/map.go:17.19,19.3 1 0
code.hoteas.com/golang/hotime/common/map.go:20.2,20.30 1 0
code.hoteas.com/golang/hotime/common/map.go:24.33,27.2 1 0
code.hoteas.com/golang/hotime/common/map.go:30.52,35.2 1 0
code.hoteas.com/golang/hotime/common/map.go:38.36,41.2 1 0
code.hoteas.com/golang/hotime/common/map.go:44.55,49.2 2 0
code.hoteas.com/golang/hotime/common/map.go:52.59,56.2 2 0
code.hoteas.com/golang/hotime/common/map.go:59.63,63.2 2 0
code.hoteas.com/golang/hotime/common/map.go:66.59,70.2 2 0
code.hoteas.com/golang/hotime/common/map.go:73.67,77.2 2 0
code.hoteas.com/golang/hotime/common/map.go:80.63,86.2 2 0
code.hoteas.com/golang/hotime/common/map.go:88.59,95.2 2 0
code.hoteas.com/golang/hotime/common/map.go:96.57,103.2 2 0
code.hoteas.com/golang/hotime/common/map.go:105.63,110.2 2 0
code.hoteas.com/golang/hotime/common/map.go:112.80,115.27 2 0
code.hoteas.com/golang/hotime/common/map.go:115.27,118.3 1 0
code.hoteas.com/golang/hotime/common/map.go:119.2,120.27 2 0
code.hoteas.com/golang/hotime/common/map.go:120.27,122.9 2 0
code.hoteas.com/golang/hotime/common/map.go:122.9,124.4 1 0
code.hoteas.com/golang/hotime/common/map.go:129.55,136.2 2 0
code.hoteas.com/golang/hotime/common/map.go:138.60,140.30 1 0
code.hoteas.com/golang/hotime/common/map.go:140.30,142.3 1 0
code.hoteas.com/golang/hotime/common/map.go:143.2,145.19 2 0
code.hoteas.com/golang/hotime/common/map.go:145.19,148.3 1 0
code.hoteas.com/golang/hotime/common/map.go:149.2,149.12 1 0
code.hoteas.com/golang/hotime/common/map.go:153.44,156.25 2 0
code.hoteas.com/golang/hotime/common/map.go:156.25,159.22 3 0
code.hoteas.com/golang/hotime/common/map.go:159.22,160.12 1 0
code.hoteas.com/golang/hotime/common/map.go:162.3,162.31 1 0
code.hoteas.com/golang/hotime/common/map.go:163.14,164.33 1 0
code.hoteas.com/golang/hotime/common/map.go:165.16,166.47 1 0
code.hoteas.com/golang/hotime/common/map.go:167.18,168.49 1 0
code.hoteas.com/golang/hotime/common/map.go:169.17,170.48 1 0
code.hoteas.com/golang/hotime/common/map.go:171.22,172.32 1 0
code.hoteas.com/golang/hotime/common/map.go:178.39,181.2 1 0
code.hoteas.com/golang/hotime/common/map.go:183.58,185.31 2 1
code.hoteas.com/golang/hotime/common/map.go:185.31,187.3 1 0
code.hoteas.com/golang/hotime/common/map.go:192.83,194.2 1 0
code.hoteas.com/golang/hotime/common/obj.go:12.40,14.2 1 0
code.hoteas.com/golang/hotime/common/obj.go:16.42,17.19 1 0
code.hoteas.com/golang/hotime/common/obj.go:17.19,19.3 1 0
code.hoteas.com/golang/hotime/common/obj.go:20.2,20.41 1 0
code.hoteas.com/golang/hotime/common/obj.go:23.50,24.19 1 0
code.hoteas.com/golang/hotime/common/obj.go:24.19,26.3 1 0
code.hoteas.com/golang/hotime/common/obj.go:27.2,27.42 1 0
code.hoteas.com/golang/hotime/common/obj.go:30.46,31.19 1 0
code.hoteas.com/golang/hotime/common/obj.go:31.19,33.3 1 0
code.hoteas.com/golang/hotime/common/obj.go:34.2,34.43 1 0
code.hoteas.com/golang/hotime/common/obj.go:37.50,38.19 1 0
code.hoteas.com/golang/hotime/common/obj.go:38.19,40.3 1 0
code.hoteas.com/golang/hotime/common/obj.go:41.2,41.45 1 0
code.hoteas.com/golang/hotime/common/obj.go:45.55,46.19 1 0
code.hoteas.com/golang/hotime/common/obj.go:46.19,48.3 1 0
code.hoteas.com/golang/hotime/common/obj.go:49.2,50.10 2 0
code.hoteas.com/golang/hotime/common/obj.go:54.33,57.2 1 0
code.hoteas.com/golang/hotime/common/obj.go:59.42,60.19 1 0
code.hoteas.com/golang/hotime/common/obj.go:60.19,62.3 1 0
code.hoteas.com/golang/hotime/common/obj.go:63.2,63.41 1 0
code.hoteas.com/golang/hotime/common/obj.go:66.46,67.19 1 0
code.hoteas.com/golang/hotime/common/obj.go:67.19,69.3 1 0
code.hoteas.com/golang/hotime/common/obj.go:70.2,70.43 1 0
code.hoteas.com/golang/hotime/common/obj.go:73.49,74.19 1 0
code.hoteas.com/golang/hotime/common/obj.go:74.19,76.3 1 0
code.hoteas.com/golang/hotime/common/obj.go:77.2,77.46 1 0
code.hoteas.com/golang/hotime/common/obj.go:80.38,83.2 1 0
code.hoteas.com/golang/hotime/common/obj.go:86.51,87.19 1 0
code.hoteas.com/golang/hotime/common/obj.go:87.19,89.3 1 0
code.hoteas.com/golang/hotime/common/obj.go:90.2,91.10 2 0
code.hoteas.com/golang/hotime/common/obj.go:96.47,97.19 1 0
code.hoteas.com/golang/hotime/common/obj.go:97.19,99.3 1 0
code.hoteas.com/golang/hotime/common/obj.go:100.2,101.10 2 0
code.hoteas.com/golang/hotime/common/obj.go:106.71,108.2 1 0
code.hoteas.com/golang/hotime/common/objtoobj.go:13.49,17.16 3 1
code.hoteas.com/golang/hotime/common/objtoobj.go:17.16,20.3 2 1
code.hoteas.com/golang/hotime/common/objtoobj.go:20.8,21.28 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:22.12,23.11 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:24.31,25.16 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:26.15,28.56 2 1
code.hoteas.com/golang/hotime/common/objtoobj.go:28.56,31.5 2 1
code.hoteas.com/golang/hotime/common/objtoobj.go:32.11,35.18 3 1
code.hoteas.com/golang/hotime/common/objtoobj.go:35.18,38.10 3 1
code.hoteas.com/golang/hotime/common/objtoobj.go:40.4,41.49 2 1
code.hoteas.com/golang/hotime/common/objtoobj.go:41.49,44.5 2 1
code.hoteas.com/golang/hotime/common/objtoobj.go:47.2,47.17 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:47.17,49.3 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:50.2,50.10 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:53.56,56.30 3 1
code.hoteas.com/golang/hotime/common/objtoobj.go:56.30,58.3 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:59.2,59.12 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:63.53,67.16 3 1
code.hoteas.com/golang/hotime/common/objtoobj.go:67.16,70.3 2 1
code.hoteas.com/golang/hotime/common/objtoobj.go:70.8,71.28 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:72.14,73.11 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:74.22,75.18 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:76.17,78.26 2 1
code.hoteas.com/golang/hotime/common/objtoobj.go:78.26,80.5 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:81.15,83.41 2 1
code.hoteas.com/golang/hotime/common/objtoobj.go:84.11,88.18 4 1
code.hoteas.com/golang/hotime/common/objtoobj.go:88.18,89.10 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:91.4,91.34 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:95.2,95.17 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:95.17,97.3 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:99.2,99.10 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:102.57,106.15 2 1
code.hoteas.com/golang/hotime/common/objtoobj.go:106.15,111.47 4 1
code.hoteas.com/golang/hotime/common/objtoobj.go:111.47,114.4 2 1
code.hoteas.com/golang/hotime/common/objtoobj.go:115.3,116.32 2 1
code.hoteas.com/golang/hotime/common/objtoobj.go:116.32,117.17 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:117.17,118.13 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:120.4,120.21 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:120.21,122.5 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:124.3,126.21 2 1
code.hoteas.com/golang/hotime/common/objtoobj.go:126.21,128.16 2 1
code.hoteas.com/golang/hotime/common/objtoobj.go:128.16,130.5 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:131.9,131.28 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:131.28,133.16 2 1
code.hoteas.com/golang/hotime/common/objtoobj.go:133.16,135.5 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:136.9,136.28 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:136.28,138.16 2 1
code.hoteas.com/golang/hotime/common/objtoobj.go:138.16,140.5 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:141.9,141.27 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:141.27,143.16 2 1
code.hoteas.com/golang/hotime/common/objtoobj.go:143.16,145.5 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:146.9,146.27 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:146.27,148.16 2 1
code.hoteas.com/golang/hotime/common/objtoobj.go:148.16,150.5 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:155.2,159.18 3 1
code.hoteas.com/golang/hotime/common/objtoobj.go:159.18,163.3 2 1
code.hoteas.com/golang/hotime/common/objtoobj.go:163.8,163.25 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:163.25,167.3 2 1
code.hoteas.com/golang/hotime/common/objtoobj.go:167.8,167.25 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:167.25,171.3 2 1
code.hoteas.com/golang/hotime/common/objtoobj.go:171.8,171.24 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:171.24,174.3 2 1
code.hoteas.com/golang/hotime/common/objtoobj.go:174.8,174.24 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:174.24,176.15 2 1
code.hoteas.com/golang/hotime/common/objtoobj.go:176.15,178.4 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:181.2,181.12 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:184.57,188.16 3 1
code.hoteas.com/golang/hotime/common/objtoobj.go:188.16,190.3 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:190.8,191.28 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:192.12,193.20 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:194.14,195.20 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:196.15,198.17 2 1
code.hoteas.com/golang/hotime/common/objtoobj.go:198.17,200.5 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:200.10,202.5 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:203.16,204.11 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:205.16,206.20 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:207.15,210.17 2 1
code.hoteas.com/golang/hotime/common/objtoobj.go:210.17,212.5 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:212.10,214.5 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:215.11,216.54 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:220.2,220.19 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:220.19,223.3 2 1
code.hoteas.com/golang/hotime/common/objtoobj.go:224.2,224.22 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:224.22,227.3 2 1
code.hoteas.com/golang/hotime/common/objtoobj.go:228.2,228.17 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:228.17,230.3 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:232.2,232.10 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:237.77,239.19 2 1
code.hoteas.com/golang/hotime/common/objtoobj.go:239.19,241.3 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:242.2,243.32 2 1
code.hoteas.com/golang/hotime/common/objtoobj.go:247.57,250.2 2 1
code.hoteas.com/golang/hotime/common/objtoobj.go:253.61,256.2 2 1
code.hoteas.com/golang/hotime/common/objtoobj.go:259.53,262.2 2 1
code.hoteas.com/golang/hotime/common/objtoobj.go:264.53,268.16 3 1
code.hoteas.com/golang/hotime/common/objtoobj.go:268.16,270.3 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:270.8,271.28 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:272.12,273.18 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:274.14,275.11 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:276.15,278.17 2 1
code.hoteas.com/golang/hotime/common/objtoobj.go:278.17,280.5 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:280.10,282.5 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:283.15,286.17 2 1
code.hoteas.com/golang/hotime/common/objtoobj.go:286.17,288.5 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:288.10,290.5 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:291.16,292.18 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:293.16,294.18 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:295.11,296.54 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:299.2,299.17 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:299.17,301.3 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:302.2,302.10 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:305.49,308.2 2 1
code.hoteas.com/golang/hotime/common/objtoobj.go:310.51,314.16 3 1
code.hoteas.com/golang/hotime/common/objtoobj.go:314.16,316.3 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:316.8,317.28 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:318.13,319.11 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:320.11,323.32 3 1
code.hoteas.com/golang/hotime/common/objtoobj.go:323.32,325.5 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:325.10,325.25 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:325.25,327.5 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:330.2,330.17 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:330.17,332.3 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:333.2,333.10 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:336.39,338.16 2 1
code.hoteas.com/golang/hotime/common/objtoobj.go:338.16,340.3 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:341.2,341.27 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:342.11,343.26 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:344.13,345.31 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:346.13,347.35 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:348.14,349.20 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:350.14,351.12 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:352.15,353.46 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:354.15,355.55 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:356.12,357.32 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:358.10,360.17 2 1
code.hoteas.com/golang/hotime/common/objtoobj.go:360.17,362.4 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:365.2,365.12 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:369.29,373.2 3 1
code.hoteas.com/golang/hotime/common/objtoobj.go:376.33,378.2 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:381.41,382.16 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:382.16,383.18 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:383.18,385.4 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:386.3,386.25 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:386.25,388.4 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:389.3,389.20 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:390.8,392.3 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:393.2,393.10 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:397.41,398.17 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:398.17,400.3 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:401.2,401.22 1 1
code.hoteas.com/golang/hotime/common/objtoobj.go:405.38,408.2 2 1
code.hoteas.com/golang/hotime/common/slice.go:11.60,12.19 1 0
code.hoteas.com/golang/hotime/common/slice.go:12.19,14.3 1 0
code.hoteas.com/golang/hotime/common/slice.go:15.2,15.30 1 0
code.hoteas.com/golang/hotime/common/slice.go:18.62,23.2 2 0
code.hoteas.com/golang/hotime/common/slice.go:26.54,29.2 2 0
code.hoteas.com/golang/hotime/common/slice.go:32.58,36.2 2 0
code.hoteas.com/golang/hotime/common/slice.go:39.62,43.2 2 0
code.hoteas.com/golang/hotime/common/slice.go:46.58,50.2 2 0
code.hoteas.com/golang/hotime/common/slice.go:53.66,57.2 2 0
code.hoteas.com/golang/hotime/common/slice.go:60.62,64.2 2 0
code.hoteas.com/golang/hotime/common/slice.go:67.82,69.2 1 0
code.hoteas.com/golang/hotime/common/slice.go:71.58,74.2 2 0
code.hoteas.com/golang/hotime/common/slice.go:76.56,83.2 2 0
code.hoteas.com/golang/hotime/common/slice.go:85.54,89.2 2 1
code.hoteas.com/golang/hotime/common/slice.go:91.59,93.21 1 0
code.hoteas.com/golang/hotime/common/slice.go:93.21,95.3 1 0
code.hoteas.com/golang/hotime/common/slice.go:96.2,97.19 2 0
code.hoteas.com/golang/hotime/common/slice.go:97.19,99.3 1 0
code.hoteas.com/golang/hotime/common/slice.go:100.2,100.12 1 0
code.hoteas.com/golang/hotime/common/slice.go:103.51,105.2 1 0
code.hoteas.com/golang/hotime/common/slice.go:107.41,109.2 1 0
-1257
View File
File diff suppressed because it is too large Load Diff
-757
View File
@@ -1,757 +0,0 @@
# 代码生成配置规范
本文档详细说明 HoTime 框架代码生成器的配置体系,包括 `config.json` 中的 `codeConfig`、菜单权限配置和字段规则配置。
---
## 配置体系概览
```
config.json
└── codeConfig[] # 代码生成配置数组(支持多套)
├── config # 菜单权限配置文件路径(如 admin.json
├── configDB # 数据库生成的完整配置
├── rule # 字段规则配置文件路径(如 rule.json)
├── table # 管理员表名
├── name # 生成代码的包名
└── mode # 生成模式
```
---
## 一、config.json 中的 codeConfig
### 配置结构
```json
{
"codeConfig": [
{
"config": "config/admin.json",
"configDB": "config/adminDB.json",
"mode": 0,
"name": "",
"rule": "config/rule.json",
"table": "admin"
}
]
}
```
### 配置项说明
| 字段 | 类型 | 说明 |
|------|------|------|
| config | string | 菜单权限配置文件路径,用于定义菜单结构、权限控制 |
| configDB | string | 代码生成器输出的完整配置文件(自动生成) |
| rule | string | 字段规则配置文件路径,定义字段在增删改查中的行为 |
| table | string | 管理员/用户表名,用于身份验证和权限控制 |
| name | string | 生成的代码包名,为空则不生成代码文件 |
| mode | int | 生成模式:0-仅配置不生成代码,非0-生成代码文件 |
### 多配置支持
可以配置多个独立的代码生成实例,适用于多端场景:
```json
{
"codeConfig": [
{
"config": "config/admin.json",
"table": "admin",
"rule": "config/rule.json"
},
{
"config": "config/user.json",
"table": "user",
"rule": "config/rule.json"
}
]
}
```
---
## 二、菜单权限配置(如 admin.json
### 配置文件更新机制
- **首次运行**:代码生成器会根据数据库表结构自动创建配置文件(如 admin.json
- **后续运行**:配置文件**不会自动更新**,避免覆盖手动修改的内容
- **重新生成**:如需重新生成,删除配置文件后重新运行即可
- **参考更新**:可参考 `configDB` 指定的文件(如 adminDB.json)查看最新的数据库结构变化,手动调整配置
### 完整配置结构
```json
{
"id": "唯一标识(自动生成)",
"name": "admin",
"label": "管理平台名称",
"labelConfig": { ... },
"menus": [ ... ],
"flow": { ... }
}
```
### 2.1 label 配置
定义系统显示名称:
```json
{
"label": "HoTime管理平台"
}
```
### 2.2 labelConfig 配置
定义权限的显示文字:
```json
{
"labelConfig": {
"show": "开启",
"add": "添加",
"delete": "删除",
"edit": "编辑",
"info": "查看详情",
"download": "下载清单"
}
}
```
| 操作 | 说明 |
|------|------|
| show | 显示/查看列表权限 |
| add | 添加数据权限 |
| delete | 删除数据权限 |
| edit | 编辑数据权限 |
| info | 查看详情权限 |
| download | 下载/导出权限 |
### 2.3 menus 配置
定义菜单结构,支持多级嵌套:
```json
{
"menus": [
{
"label": "系统管理",
"name": "sys",
"icon": "Setting",
"auth": ["show"],
"menus": [
{
"label": "用户管理",
"table": "user",
"auth": ["show", "add", "delete", "edit", "info", "download"]
},
{
"label": "角色管理",
"table": "role",
"auth": ["show", "add", "delete", "edit", "info"]
}
]
},
{
"label": "文章管理",
"table": "article",
"icon": "Document",
"auth": ["show", "add", "edit", "info"]
}
]
}
```
#### menus 字段说明
| 字段 | 类型 | 说明 |
|------|------|------|
| label | string | 菜单显示名称 |
| name | string | 菜单标识(用于分组,不绑定表时使用) |
| table | string | 绑定的数据表名(用于自动生成 CRUD) |
| icon | string | 菜单图标名称 |
| auth | array | 权限数组,定义该菜单/表拥有的操作权限 |
| menus | array | 子菜单数组(支持嵌套) |
#### name vs table
- **table**: 绑定数据表,拥有该表的增删查改等权限,自动生成 CRUD 接口
- **name**: 自定义功能标识,不绑定表,前端根据 name 和 auth 来显示和操作自定义内容(如首页 home、仪表盘 dashboard 等)
- 如果配置了 `menus` 子菜单,则作为分组功能,前端展开显示下级菜单
- 如果没有 `menus`,则作为独立的自定义功能入口
**注意**:目前只支持**两级菜单**,不允许更多层级嵌套。
```json
// 使用 table:绑定数据表,有增删查改权限
{ "label": "用户管理", "table": "user", "auth": ["show", "add", "edit", "delete"] }
// 使用 name:自定义功能(无子菜单)
{ "label": "首页", "name": "home", "icon": "House", "auth": ["show"] }
// 使用 name:分组功能(有子菜单)
{ "label": "系统管理", "name": "sys", "icon": "Setting", "auth": ["show"], "menus": [...] }
```
#### 自动分组规则
代码生成器在自动生成配置时,会根据表名的 `_` 分词进行自动分组:
- 表名 `sys_logs``sys_menus``sys_config` → 自动归入 `sys` 分组
- 表名 `article``article_tag` → 自动归入 `article` 分组
分组的显示名称(label)使用该分组下**第一张表的名字**,如果表有备注则使用备注名。
### 2.4 auth 配置
权限数组定义菜单/表拥有的操作权限:
```json
{
"auth": ["show", "add", "delete", "edit", "info", "download"]
}
```
#### 内置权限
| 权限 | 对应接口 | 说明 |
|------|----------|------|
| show | /search | 列表查询 |
| add | /add | 新增数据 |
| delete | /remove | 删除数据 |
| edit | /update | 编辑数据 |
| info | /info | 查看详情 |
| download | /search?download=1 | 导出数据 |
#### 自定义权限扩展
auth 数组**可以自由增删**,新增的权限项会:
- 在前端菜单/功能中显示
- 在角色管理(role)的权限设置中显示,供管理员分配
```json
// 示例:为文章表添加自定义权限
{
"label": "文章管理",
"table": "article",
"auth": ["show", "add", "edit", "delete", "info", "publish", "audit", "top"]
}
```
上例中 `publish`(发布)、`audit`(审核)、`top`(置顶)为自定义权限,前端可根据这些权限控制对应按钮的显示和操作。
### 2.5 icon 配置
菜单图标,使用 Element Plus 图标名称:
```json
{ "icon": "Setting" } // 设置图标
{ "icon": "User" } // 用户图标
{ "icon": "Document" } // 文档图标
{ "icon": "Folder" } // 文件夹图标
```
### 2.6 flow 配置
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,"
}
}
}
}
```
#### flow 字段说明
| 字段 | 类型 | 说明 |
|------|------|------|
| table | string | 表名 |
| stop | bool | 是否禁止修改自身关联的数据 |
| sql | object | 数据过滤条件,自动填充查询/操作条件 |
#### stop 配置详解
`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 等价 |
|------|------|----------|
| `"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," }
}
}
}
```
**效果**
| 表 | 查询条件 | stop 效果 |
|-----|----------|-----------|
| role | `WHERE id = 2` | 不能修改 id=2 的角色 |
| 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 示例
```json
{
"id": "74a8a59407fa7d6c7fcdc85742dbae57",
"name": "admin",
"label": "后台管理系统",
"labelConfig": {
"show": "开启",
"add": "添加",
"delete": "删除",
"edit": "编辑",
"info": "查看详情",
"download": "下载清单"
},
"menus": [
{
"label": "系统管理",
"name": "sys",
"icon": "Setting",
"auth": ["show"],
"menus": [
{
"label": "日志管理",
"table": "logs",
"auth": ["show", "download"]
},
{
"label": "角色管理",
"table": "role",
"auth": ["show", "add", "delete", "edit", "info"]
},
{
"label": "组织管理",
"table": "org",
"auth": ["show", "add", "delete", "edit", "info"]
},
{
"label": "员工管理",
"table": "admin",
"auth": ["show", "add", "delete", "edit", "info", "download"]
}
]
}
],
"flow": {
"admin": {
"table": "admin",
"stop": false,
"sql": { "role_id": "role_id" }
},
"role": {
"table": "role",
"stop": true,
"sql": { "admin_id": "id", "id": "role_id" }
},
"org": {
"table": "org",
"stop": false,
"sql": { "admin_id": "id" }
},
"logs": {
"table": "logs",
"stop": false,
"sql": {}
}
}
}
```
---
## 五、SQLite 备注替代方案
SQLite 数据库不支持表备注(TABLE COMMENT)和字段备注(COLUMN COMMENT),代码生成器会使用表名/字段名作为默认显示名称。
### 通过配置文件设置备注
利用 HoTime 的配置覆盖机制,可以在配置文件中手动设置显示名称和提示:
**步骤**
1. 首次运行,生成配置文件(如 admin.json
2. 编辑配置文件中的 `tables` 部分
### 设置表显示名称
在菜单配置中设置 `label`
```json
{
"menus": [
{
"label": "用户管理", // 手动设置表显示名称
"table": "user",
"auth": ["show", "add", "edit", "delete"]
}
]
}
```
### 设置字段显示名称和提示
`configDB` 文件(如 adminDB.json)中的 `tables.表名.columns` 部分设置:
```json
{
"tables": {
"user": {
"label": "用户管理",
"columns": [
{
"name": "id",
"label": "ID",
"type": "number"
},
{
"name": "name",
"label": "用户名",
"ps": "请输入用户名", // 前端输入提示
"must": true
},
{
"name": "phone",
"label": "手机号",
"ps": "请输入11位手机号"
},
{
"name": "status",
"label": "状态",
"type": "select",
"options": [
{"name": "正常", "value": "0"},
{"name": "禁用", "value": "1"}
]
}
]
}
}
}
```
**注意**:直接编辑的是 `configDB` 指定的文件(如 adminDB.json),该文件会在每次启动时重新生成。如需持久化修改,应将自定义的 columns 配置放入 `config` 指定的文件(如 admin.json)中。
---
## 六、配置检查清单
### codeConfig 检查
- [ ] config 文件路径正确
- [ ] rule 文件路径正确
- [ ] table 指定的管理员表存在
### 菜单权限配置检查
- [ ] 所有 table 指向的表在数据库中存在
- [ ] auth 数组包含需要的权限
- [ ] menus 结构正确(有子菜单用 name,无子菜单用 table)
- [ ] flow 配置的 sql 条件字段存在
### 字段规则配置检查
- [ ] strict=true 的规则字段名完全匹配
- [ ] type 类型与前端控件需求一致
- [ ] 敏感字段(password等)的 list 和 info 为 false
+894
View File
@@ -0,0 +1,894 @@
# HoTime 代码生成
前置依赖:生成前请先满足 [数据库设计规范](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
└── codeConfig[] # 代码生成配置数组(支持多套)
├── config # 菜单权限配置文件路径(如 admin.json
├── configDB # 数据库生成的完整配置
├── rule # 字段规则配置文件路径(如 rule.json)
├── table # 管理员表名
├── name # 生成代码的包名
└── mode # 生成模式
```
---
## 二、使用方法
### 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
{
"codeConfig": [
{
"config": "config/admin.json",
"configDB": "config/adminDB.json",
"mode": 0,
"name": "",
"rule": "config/rule.json",
"table": "admin"
}
]
}
```
#### 配置项说明
| 字段 | 类型 | 必须 | 说明 |
|------|------|------|------|
| `table` | string | ✅ | 管理员/用户表名,用于身份验证和权限控制 |
| `config` | string | ✅ | 菜单权限配置文件路径,用于定义菜单结构、权限控制 |
| `configDB` | string | ❌ | 代码生成器输出的完整配置文件(有则每次自动生成) |
| `rule` | string | ❌ | 字段规则配置文件路径,无则使用默认规则 |
| `name` | string | ❌ | 生成的代码包名和目录名,为空则不生成独立代码文件 |
| `mode` | int | ❌ | 生成模式:0=仅配置不生成代码(内嵌模式),非 0=生成代码文件 |
#### 运行模式
- **mode=0(内嵌模式)**:不生成独立代码文件,使用框架内置的通用控制器
- **mode≠0(生成模式)**:为每张表生成独立的 Go 控制器文件
#### 多配置支持
可以配置多个独立的代码生成实例,适用于多端场景:
```json
{
"codeConfig": [
{
"config": "config/admin.json",
"table": "admin",
"rule": "config/rule.json"
},
{
"config": "config/user.json",
"table": "user",
"rule": "config/rule.json"
}
]
}
```
---
### 3.2 菜单权限配置(admin.json
#### 配置文件更新机制
- **首次运行**:代码生成器会根据数据库表结构自动创建配置文件(如 admin.json
- **后续运行**:配置文件**不会自动更新**,避免覆盖手动修改的内容
- **重新生成**:如需重新生成,删除配置文件后重新运行即可
- **参考更新**:可参考 `configDB` 指定的文件(如 adminDB.json)查看最新的数据库结构变化,手动调整配置
#### 完整配置结构
```json
{
"id": "唯一标识(自动生成)",
"name": "admin",
"label": "管理平台名称",
"labelConfig": { ... },
"menus": [ ... ],
"flow": { ... }
}
```
#### label / labelConfig
```json
{
"label": "HoTime管理平台",
"labelConfig": {
"show": "开启",
"add": "添加",
"delete": "删除",
"edit": "编辑",
"info": "查看详情",
"download": "下载清单"
}
}
```
| 操作 | 说明 |
|------|------|
| show | 显示/查看列表权限 |
| add | 添加数据权限 |
| delete | 删除数据权限 |
| edit | 编辑数据权限 |
| info | 查看详情权限 |
| download | 下载/导出权限 |
#### menus 配置
定义菜单结构,支持多级嵌套(目前只支持**两级菜单**):
```json
{
"menus": [
{
"label": "系统管理",
"name": "sys",
"icon": "Setting",
"auth": ["show"],
"menus": [
{
"label": "用户管理",
"table": "user",
"auth": ["show", "add", "delete", "edit", "info", "download"]
},
{
"label": "角色管理",
"table": "role",
"auth": ["show", "add", "delete", "edit", "info"]
}
]
},
{
"label": "文章管理",
"table": "article",
"icon": "Document",
"auth": ["show", "add", "edit", "info"]
}
]
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| label | string | 菜单显示名称 |
| name | string | 菜单标识(用于分组,不绑定表时使用) |
| table | string | 绑定的数据表名(用于自动生成 CRUD) |
| icon | string | 菜单图标名称(Element Plus 图标名,如 `Setting``User``Document` |
| auth | array | 权限数组,定义该菜单/表拥有的操作权限 |
| menus | array | 子菜单数组(支持嵌套) |
**name vs table**
- **table**:绑定数据表,拥有该表的增删查改等权限,自动生成 CRUD 接口
- **name**:自定义功能标识,不绑定表,前端根据 name 和 auth 显示自定义内容(如首页 home、仪表盘 dashboard
- 配置了 `menus` 子菜单 → 作为分组
- 没有 `menus` → 作为独立自定义功能入口
```json
// 使用 table:绑定数据表
{ "label": "用户管理", "table": "user", "auth": ["show", "add", "edit", "delete"] }
// 使用 name:自定义功能(无子菜单)
{ "label": "首页", "name": "home", "icon": "House", "auth": ["show"] }
// 使用 name:分组功能(有子菜单)
{ "label": "系统管理", "name": "sys", "icon": "Setting", "auth": ["show"], "menus": [...] }
```
**自动分组规则**:代码生成器会根据表名的 `_` 分词自动分组:
- 表名 `sys_logs``sys_menus``sys_config` → 归入 `sys` 分组
- 表名 `article``article_tag` → 归入 `article` 分组
分组的显示名称(label)使用该分组下**第一张表的名字**,如果表有备注则使用备注名。
#### auth 配置
| 权限 | 对应接口 | 说明 |
|------|----------|------|
| show | /search | 列表查询 |
| add | /add | 新增数据 |
| delete | /remove | 删除数据 |
| edit | /update | 编辑数据 |
| info | /info | 查看详情 |
| download | /search?download=1 | 导出数据 |
auth 数组**可以自由增删**。新增的权限项会在前端菜单/功能中显示,并在角色管理(role)的权限设置中供管理员分配:
```json
{
"label": "文章管理",
"table": "article",
"auth": ["show", "add", "edit", "delete", "info", "publish", "audit", "top"]
}
```
上例中 `publish`(发布)、`audit`(审核)、`top`(置顶)为自定义权限。
#### flow 配置(数据权限)
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,"
}
}
}
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| table | string | 表名 |
| stop | bool | 是否禁止修改自身关联的数据 |
| sql | object | 数据过滤条件,自动填充查询/操作条件 |
**stop**:防止用户修改自己当前关联的敏感数据。例如当前用户 `role_id = 1`,配置 `stop: true` 后,不能修改 role 表中 `id = 1` 的记录,但可修改其他 role(若有权限)。典型用途:防止用户提升自己的角色权限、修改自己所属组织。
**sql**:格式为 `{ "目标表字段": "当前用户字段" }`
| 格式 | 含义 | SQL 等价 |
|------|------|----------|
| `"field": "user_field"` | 精确匹配 | `field = 用户.user_field` |
| `"field[~]": ",value,"` | 模糊匹配 | `field LIKE '%,value,%'` |
示例效果(当前用户 `{ "id": 5, "role_id": 2, "org_id": 10 }`):
| 表 | 查询条件 | stop 效果 |
|-----|----------|-----------|
| role | `WHERE id = 2` | 不能修改 id=2 的角色 |
| article | `WHERE admin_id = 5` | 可以修改自己的文章 |
| org | `WHERE parent_ids LIKE '%,10,%'` | 可以修改下级组织 |
#### 完整 admin.json 示例
```json
{
"id": "74a8a59407fa7d6c7fcdc85742dbae57",
"name": "admin",
"label": "后台管理系统",
"labelConfig": {
"show": "开启",
"add": "添加",
"delete": "删除",
"edit": "编辑",
"info": "查看详情",
"download": "下载清单"
},
"menus": [
{
"label": "系统管理",
"name": "sys",
"icon": "Setting",
"auth": ["show"],
"menus": [
{
"label": "日志管理",
"table": "logs",
"auth": ["show", "download"]
},
{
"label": "角色管理",
"table": "role",
"auth": ["show", "add", "delete", "edit", "info"]
},
{
"label": "组织管理",
"table": "org",
"auth": ["show", "add", "delete", "edit", "info"]
},
{
"label": "员工管理",
"table": "admin",
"auth": ["show", "add", "delete", "edit", "info", "download"]
}
]
}
],
"flow": {
"admin": {
"table": "admin",
"stop": false,
"sql": { "role_id": "role_id" }
},
"role": {
"table": "role",
"stop": true,
"sql": { "admin_id": "id", "id": "role_id" }
},
"org": {
"table": "org",
"stop": false,
"sql": { "admin_id": "id" }
},
"logs": {
"table": "logs",
"stop": false,
"sql": {}
}
}
}
```
> 说明:生成器还会在配置中产生 `tables`(字段列、搜索项等)。首次生成可参考 `configDB`(如 adminDB.json)中的结构;持久化自定义请写入 `config` 指定的文件(如 admin.json),因 configDB 会在每次启动时重新生成。
---
### 3.3 字段规则配置(rule.json
定义字段在增删改查操作中的默认行为。
#### 配置结构
```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
}
]
```
#### 字段属性说明
| 属性 | 类型 | 说明 |
|------|------|------|
| name | string | 字段名或关键词;支持 `表名.字段名` 精确匹配 |
| add | bool | 新增时是否显示 |
| edit | bool | 编辑时是否显示 |
| info | bool | 详情页是否显示 |
| list | bool | 列表页是否显示 |
| must | bool | 是否必填(见下方) |
| strict | bool | 是否严格匹配字段名(true=完全匹配,false=包含匹配) |
| type | string | 字段类型(影响前端控件和数据处理) |
#### 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
{
"name": "status",
"label": "状态",
"type": "select",
"ps": "用户账号状态",
"options": [
{"name": "禁用", "value": "0"},
{"name": "启用", "value": "1"}
]
}
```
`ps` 字段在前端的展示效果:
- 编辑/新增页面:输入框右侧灰色小字提示
- 表格/详情页面:鼠标悬停字段名时气泡提示
### 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)。
#### 树形表查询参数(parent_id / showself / showall
`parent_id` 列的表,通用 CRUD 的 `search` 接口支持树查询参数,语义如下(`code/makecode.go` 树节点分支):
| 请求参数组合 | WHERE 语义 | 典型场景 |
| --- | --- | --- |
| 不传 `parent_id``parent_id=0` | `id=当前用户锚点 OR parent_id IS NULL`(返回根层) | 树侧栏根节点加载 |
| `parent_id=X` | `parent_id=X`,仅直接子级 | 树节点懒加载展开 |
| `parent_id=X&showall=1` | `parent_ids LIKE '%,X,%'`,X 及其全部子孙(X 自身的 parent_ids 含 `,X,` | 列表按树节点过滤 |
| `parent_id=X&showall=1&showself=1` | 在 showall 基础上再 `OR id=X`,显式保证含 X 自身 | 管理端列表树筛选(Table.vue |
**规则**`showself=1` 仅在 `showall=1` 时生效。普通子级查询(仅 `parent_id=X`)**不会**把 id=X 的行本身混入结果——否则前端树会把节点当作自己的子级,造成无限嵌套(历史缺陷,已修复;回归用例见 `example/app/makecode_tree_test.go``go test ./app/ -count=1 -run 'TestApi/admin/department/search'`)。
#### 通用列表字段筛选语义(text 类型只走模糊匹配)
通用 CRUD 的 `search` 接口为每个 `list` 可见字段自动生成筛选条件(`code/makecode.go` `(*MakeCode).Search`),按字段最终生效的 `type`(见 [4.2 数据类型映射](#42-数据类型映射),可被 [3.3 字段规则配置(rule.json](#33-字段规则配置rulejson) 覆盖)分两种语义:
| 字段 type | 请求 `?字段名=值` 的 WHERE 语义 | 说明 |
| --- | --- | --- |
| 含 `"text"` 子串(`text`/`textArea` | `字段名 LIKE '%值%'`(模糊,仅此一条) | 前端筛选框允许只填部分关键词 |
| 其他(`number`/`select`/`time`/`money`… 等非 text | `字段名 = 值`(精确等值) | 维持现状不变 |
**规则**:文本类型字段**只生成模糊匹配条件,不再叠加同名等值条件**。历史缺陷:曾对所有 list 字段先生成等值条件 `字段名=值`,再对 text 类型**额外**叠加模糊条件 `字段名[~]=值`,两者在 where 里以 AND 叠加——前端筛选框只传部分关键词时等值条件必不命中,AND 之后整条查询恒为空(下游"文本列筛选查不出数据"的通用根因)。已修复;`keyword`+`keywordtable` 全局关键词搜索逻辑不受影响。回归用例见 `example/app/makecode_tree_test.go` 的「文本字段部分关键词命中模糊匹配」「非文本字段等值筛选行为不变」两个用例,`go test ./app/ -count=1 -run 'TestApi/admin/department/search'`
### 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
{
"tables": {
"user": {
"label": "用户管理",
"columns": [
{
"name": "name",
"label": "用户名",
"ps": "请输入用户名",
"must": true
},
{
"name": "status",
"label": "状态",
"type": "select",
"options": [
{"name": "正常", "value": "0"},
{"name": "禁用", "value": "1"}
]
}
]
}
}
}
```
**注意**`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`,避免运行时反复改写配置/代码。
---
## 五、相关文档
- [数据库设计规范](DatabaseDesign_数据库设计规范.md)(代码生成前置依赖)
- [HoTimeDB 使用说明](HoTimeDB_使用说明.md)
- [快速上手](QuickStart_快速上手.md)
- [Common 工具类](Common_工具类.md)
-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`),可检测正常退出后不自动重启
---
@@ -67,7 +67,9 @@ server {
# 其他常规配置...
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# EdgeOne 场景优先 EO-Connecting-IP;空则框架再降级到 XFF / RemoteAddr
proxy_set_header X-Real-IP $http_eo_connecting_ip;
proxy_set_header EO-Connecting-IP $http_eo_connecting_ip;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
@@ -90,7 +92,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 +102,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 +133,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()
```
+5 -1
View File
@@ -1,5 +1,7 @@
# HoTimeDB ORM 使用说明书
> 完整教程见本文;方法签名与条件运算符速查见 [HoTimeDB_API参考.md](HoTimeDB_API参考.md)
## 概述
HoTimeDB是一个基于Golang实现的轻量级ORM框架,参考PHP Medoo设计,提供简洁的数据库操作接口。支持MySQL、SQLite、PostgreSQL、达梦(DM8)等数据库,并集成了缓存、事务、链式查询等功能。
@@ -31,6 +33,8 @@ HoTimeDB是一个基于Golang实现的轻量级ORM框架,参考PHP Medoo设计
### 初始化数据库连接
`Application.SetMysqlDB` 默认 DSN 带 `charset=utf8mb4`(兼容 MySQL 8.0 / 8.4,与表字符集对齐)。
```go
import (
"code.hoteas.com/golang/hotime/db"
@@ -41,7 +45,7 @@ import (
// 创建连接函数
func createConnection() (master, slave *sql.DB) {
master, _ = sql.Open("mysql", "user:password@tcp(localhost:3306)/database")
master, _ = sql.Open("mysql", "user:password@tcp(localhost:3306)/database?charset=utf8mb4")
// slave是可选的,用于读写分离
slave = master // 或者连接到从数据库
return
@@ -1,6 +1,6 @@
# HoTime 快速上手指南
5 分钟入门 HoTime 框架。
5 分钟入门 HoTime 框架。完整文档索引见 [README.md](README.md)。
## 安装
@@ -80,7 +80,7 @@ func main() {
| `modeRouterStrict` | false | 路由大小写敏感,false=忽略大小写 |
| `crossDomain` | - | 跨域设置,空=不开启,auto=智能开启,或指定域名 |
| `logFile` | - | 日志文件路径,如 `logs/20060102.txt` |
| `logLevel` | 0 | 日志等级0=关闭,1=打印 |
| `logLevel` | 1 | 日志等级0=仅错误,>=1=全部;同时控制 SQL 日志。**不**控制静态 `Cache-Control`(静态固定 `public` |
| `webConnectLogShow` | true | 是否显示访问日志 |
| `defFile` | ["index.html"] | 目录默认访问文件 |
@@ -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) — 完整阅读路径
+36
View File
@@ -0,0 +1,36 @@
# HoTime 文档索引
本仓采用 **Doc-Driven + TDD**:行为一变必须回写 docs;改接口必须用 `-run` 精确测试验证(见 [Testing_API测试框架.md](Testing_API测试框架.md) 文首铁律与仓规则 `hotime-doc-tdd`)。
推荐按路径阅读,避免同一概念在多份文档里来回找。
## 阅读路径
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 测试、Flows、控制台;优先 `-run`;已知局限 |
| GracefulShutdown_优雅停机 | 优雅停机与滚动重启 |
| Seq_日志集成 | Seq 结构化日志 |
| ROADMAP_改进规划 | 待改进项 |
## 易混淆点
- **业务 `that.Log`**:写 `logs` 表;**Seq**:集中式结构化日志,见 Seq 文档。
- **HoTimeDB vs DatabaseDesign**:前者是运行时查库;后者是建表给代码生成用。
- **测试**:开发阶段不要默认 `go test ./app/...` 全量,见 Testing 文首铁律。
+4
View File
@@ -1,5 +1,7 @@
# HoTime 改进规划
> 相关规范:[DatabaseDesign](DatabaseDesign_数据库设计规范.md) · [CodeGen](CodeGen_代码生成.md) · [Testing](Testing_API测试框架.md) · [文档索引](README.md)
本文档记录 HoTime 框架的待改进项和设计思考,供后续版本迭代参考。
---
@@ -264,6 +266,7 @@ a.JSON(Map{"phone": "138..."}).Post("正常注册", 0, Map{"id": notEmpty}) //
|--------|--------|------|
| 高 | 备注语法优化(`\|` 分隔符) | 待设计 |
| 高 | 测试框架:响应字段部分匹配断言 | 待实现 |
| 高 | 测试框架其它缺口(DB 断言、FromCase 无模板失败、Header API 等) | 见 [Testing 已知局限](Testing_API测试框架.md#已知局限与后续) |
| 中 | 软删除/日志表支持 | 待设计 |
| 低 | 多对多关联表增强 | 待评估 |
| 低 | 自动填充字段扩展 | 待评估 |
@@ -274,6 +277,7 @@ a.JSON(Map{"phone": "138..."}).Post("正常注册", 0, Map{"id": notEmpty}) //
| 日期 | 内容 |
|------|------|
| 2026-07-13 | 测试框架文档补齐 Flows/控制台;缺口清单链到 Testing |
| 2026-01-24 | 初始版本,记录分析结果和待改进项 |
| 2026-03-15 | 新增测试框架响应字段断言改进规划(七) |
| 2026-03-20 | 完成达梦(DM8)数据库完整支持:Dialect 适配(MERGE INTO Upsert、双引号标识符)、LastInsertId、保留字自动引号、schema 正确处理、代码生成器 DM 兼容、缓存表自动建表、测试框架 DM 适配 |
+163
View File
@@ -0,0 +1,163 @@
# Seq 日志集成
HoTime 框架内置 Seq 日志推送支持。通过在 `config.json` 填写 `seqUrl` 即可激活。
**目标:用 Seq 集中检索替代翻 `logs/*.txt`**;本地 `logFile` 可并行保留。
---
## 配置项
`config.json` 中添加:
```json
"seqUrl": "http://127.0.0.1:5341",
"seqApiKey": ""
```
| 字段 | 默认值 | 说明 |
|---|---|---|
| `seqUrl` | 空(不激活) | Seq 服务地址,空值时功能静默不生效 |
| `seqApiKey` | 空 | API Key,免费单用户版留空 |
`instance` 字段由框架自动拼接为 `ip:port`(如 `192.168.1.10:8085`),无需手动填写。
激活后每个 HTTP 请求会自动:
- 生成 `request_id`12 位 hex),回写响应头 `X-Request-Id`
-`sessionId` 前 12 位作为 `sid` 写入请求级日志(脱敏,避免把登录凭据写进 Seq)
- 业务日志、SQL 日志、访问日志均携带 `sid` / `request_id`
- **控制台自动降噪**(零额外配置):业务日志(`that.Log`)控制台放宽到 **Info+**(Debug/SQL 不上控制台),访问日志(`that.WebConnectLog`)控制台保持 **Warn+**(量大不上控制台);Debug/SQL/访问日志(Info 级)仍全量进 Seq 与文件
---
## 会话追踪与 LogBind
框架在 `handler` 入口派生请求级 Logger,并浅拷贝 `Db` 将其 `Log` 指向同一 Logger,因此 **SQL 日志自动带会话字段**。字段会出现在同条日志的控制台/文件/Seq 出口上。
业务在 `SetConnectListener` 鉴权通过后绑定(xbc `main.go`):
```go
// app 鉴权通过后
context.LogBind("user_id", context.Session("user_id").ToCeilInt64())
// admin 鉴权通过后(或 session 已有 admin_id
context.LogBind("admin_id", context.Session("admin_id").ToCeilInt64())
```
`LogBind` 后,本请求后续业务日志与 SQL 日志都会带上该字段。
**不带会话字段的边界:**
- `fmt.Println` / stdout 捕获、panic、MySQL driver、定时任务等无请求上下文的日志
- 直接写 `that.Application.Log` 的旧代码(应改用 `that.Logger``that.LogBind` 后的请求级 Logger
---
## 控制台降噪(配了 seqUrl)
| Logger | 控制台行为 |
|---|---|
| 业务日志(`that.Log` | Info+Info / Warn / Error,含 `Display` 非 0 的 WarnDebug/SQL 不上控制台) |
| 访问日志(`that.WebConnectLog`) | Warn+(访问日志量大,Info 级不上控制台) |
| 出口 | 行为 |
|---|---|
| Seq | 按 `logLevel` 全量(Info/Debug/SQL/访问日志等) |
| 本地文件 | 与原先一致,不受控制台过滤影响 |
未配置 `seqUrl` 时控制台仍按 `logLevel` 全打。
框架不新增配置项;如需自定义某个 Logger 的控制台门槛,可在挂 Seq 后调用 `Logger.SetConsoleMinLevel(level zerolog.Level)`(仅影响该 Logger 的控制台出口,不影响 Seq/文件)。
---
## 客户端 IP 与地域
零配置。`ip` 选取优先级(跳过空值与回环 `127.0.0.1` / `::1`):
1. `EO-Connecting-IP`EdgeOne 真实建连 IP
2. `X-Forwarded-For` **从左到右第一个非回环**(反代误把 CDN 写入 `X-Real-IP` 时仍能落到客户端)
3. `X-Real-IP`(非回环)
4. `RemoteAddr`
访问日志另附 `ip_chain`:上述源头出现过的 IP 按**首次出现**顺序逗号拼接,**同一 IP 不重复**;若与 `ip` 相同则省略该字段。
请求头有 `EO-Client-IPCountry` 时,访问日志追加 `ip_country`(两位国家码);没有则不记。
---
## 架构原理
```
业务代码
│ that.Logger.Info().Msg("...") ← 请求级(含 sid/request_id
│ Db.Query → SQL 日志 ← 同一请求级 Logger
│ fmt.Println("...") ← 捕获后无 sid
multiWriterhotimev1.5/log/logger.go
├─ Console(挂 Seq 后:业务 Info+ / 访问日志 Warn+)→ 终端
├─ FileWriter → 本地文件(按需,全量)
└─ SeqWriter → Seq(全量)
│ Write() 只做 channel <- bytesO(1) 非阻塞
channel(容量 10000
↓ 后台 goroutine
批量打包(100 条 或 500ms
↓ HTTP POST(失败重试 1 次)
Seq 服务(CLEF 格式)
```
优雅停机时调用 `CloseSeq()` 冲刷残留批次,避免停机前后日志丢失。
---
## 字段映射(zerolog → CLEF
| zerolog 字段 | Seq CLEF 字段 | 说明 |
|---|---|---|
| `time` | `@t` | 毫秒精度 ISO 8601 |
| `level` | `@l` | Debug/Information/Warning/Error/Fatal |
| `message` / `msg` | `@m` | 消息正文(不用 `@mt`,避免 `{xxx}` 被当模板) |
| `caller` | `caller` | 调用位置 |
| `sid` | `sid` | sessionId 前 12 位 |
| `request_id` | `request_id` | 单次请求 id |
| `ip` | `ip` | 客户端最佳 IP |
| `ip_chain` | `ip_chain` | 多源去重链路(与 `ip` 不同时才有) |
| `ip_country` | `ip_country` | 有 `EO-Client-IPCountry` 时 |
| `ua` | `ua` | 访问日志携带,User-Agent 原文截断 200 字符(按 rune 安全截断) |
| 其余自定义字段 | 原字段名 | `LogBind` 追加的字段原样保留 |
| — | `instance` | `ip:port` |
| — | `source` | stdout / stderr / panic 等 |
---
## 搜索语法速查
| 目标 | 查询语句 |
|---|---|
| 按会话追踪 | `sid = 'abc123def456'` |
| 按单次请求 | `request_id = 'fedcba987654'` |
| 关键词 | 直接输入,如 `支付失败` |
| 日志级别 | `@l = 'Error'` |
| 特定实例 | `instance = '192.168.1.10:8085'` |
| 地域 | `ip_country = 'CN'` |
| stdout | `source = 'stdout'` |
| panic | `source = 'panic'` |
| 组合 | `@l = 'Error' and sid = 'abc123def456'` |
| 关键字组合其他条件 | `@Message like '%支付失败%' and @l = 'Warning' and instance = '192.168.1.10:8085'` |
---
## Seq 安装
- Windows[https://datalust.co/download/seq](https://datalust.co/download/seq)
- Docker`docker run -d --restart always --name seq -p 5341:80 -e ACCEPT_EULA=Y datalust/seq`
- 访问 `http://localhost:5341`
---
## 相关文档
- [QuickStart_快速上手.md](QuickStart_快速上手.md) — 其中的 `that.Log` 是业务 logs 表,与本文 Seq 推送不同
- [GracefulShutdown_优雅停机.md](GracefulShutdown_优雅停机.md)
- [文档索引](README.md)
-121
View File
@@ -1,121 +0,0 @@
# Seq 日志集成
HoTime 框架内置 Seq 日志推送支持。通过在 `config.json` 填写 `seqUrl` 即可激活,
所有框架日志(含 `fmt.Println` 等绕过 Logger 的输出)实时推送到 Seq,
通过 Seq Web UI 实现关键词、日期范围、日志级别、结构化字段等多维度搜索。
---
## 配置项
`config.json` 中添加:
```json
"seqUrl": "http://127.0.0.1:5341",
"seqApiKey": ""
```
| 字段 | 默认值 | 说明 |
|---|---|---|
| `seqUrl` | 空(不激活) | Seq 服务地址,空值时功能静默不生效 |
| `seqApiKey` | 空 | API Key,免费单用户版留空 |
`instance` 字段由框架自动拼接为 `ip:port`(如 `192.168.1.10:8085`),无需手动填写,同机多进程靠端口区分,跨服务器靠 IP 区分,支持未来集群扩展。
---
## 架构原理
```
业务代码
│ l.Info().Msg("...") ← HoTime Logger 正常调用路径
│ fmt.Println("...") ← 被 redirectStdout 捕获后转入同一路径
multiWriterhotimev1.5/log/logger.go
├─ ConsoleWriter → 彩色终端输出(不变)
├─ FileWriter → 本地日志文件(按需,logFile 配置)
└─ SeqWriter
│ Write() 只做 channel <- bytesO(1) 非阻塞
channel(容量 10000
↓ 后台 goroutine
批量打包(100 条 或 500ms
↓ HTTP POST
Seq 服务(CLEF 格式)
```
**关键特性:**
- `SeqWriter.Write()` 仅向 channel 投递字节即返回,**绝不阻塞** web 请求处理 goroutine
- channel 满时(Seq 宕机/网络故障)新日志被丢弃并计数,主服务完全不受影响
- HTTP POST 设 5s 超时,失败仅打印到 stderr
---
## 字段映射(zerolog → CLEF
| zerolog 字段 | Seq CLEF 字段 | 说明 |
|---|---|---|
| `time` | `@t` | 时间,自动转 ISO 8601 格式 |
| `level` | `@l` | 级别,映射为 Debug/Information/Warning/Error/Fatal |
| `message` / `msg` | `@mt` | 消息正文 |
| `caller` | `caller` | 调用位置,原样保留 |
| 其余自定义字段 | 原字段名 | 原样保留,可在 Seq 中直接查询 |
| — | `instance` | 框架自动注入,值为 `port` 配置(如 `"8085"` |
| — | `source` | fmt.Println 等捕获的输出标记为 `stdout` |
---
## 单机多进程实例区分
框架启动时自动获取本机出口 IP,拼接为 `ip:port` 格式作为 `instance`
```
单机多进程:
192.168.1.10:8085 ─┐
192.168.1.10:8086 ─┼─ HTTP CLEF ──→ Seq
192.168.1.10:8087 ─┘
多服务器集群:
192.168.1.10:8085 ─┐
192.168.1.11:8085 ─┼─ HTTP CLEF ──→ Seq(中央日志服务器)
192.168.1.12:8085 ─┘
```
Seq 中按实例筛选:
- 单台机器所有进程:`instance like '192.168.1.10%'`
- 精确到某个进程:`instance = '192.168.1.10:8085'`
---
## stdout 全量捕获
框架启动时(`SetConfig()` 中)自动调用 `redirectStdout()`
`os.Stdout` 和标准 `log` 包重定向到 HoTime Logger
- `fmt.Println("xxx")` → 以 `INFO` 级别、`source=stdout` 字段推送到 Seq
- `log.Printf("xxx")` → 同上
- 控制台仍然能看到这些输出(输出管道由 goroutine 实时转发)
---
## Seq 安装
Seq 提供 Windows MSI 安装包和 Docker 镜像,单机免费,无外部数据库依赖:
- Windows[https://datalust.co/download/seq](https://datalust.co/download/seq),安装后自动注册为 Windows 服务
- Docker`docker run -d --restart always --name seq -p 5341:80 -e ACCEPT_EULA=Y datalust/seq`
- 访问 `http://localhost:5341` 使用 Web UI
---
## 搜索语法速查
| 目标 | 查询语句 |
|---|---|
| 关键词搜索 | 直接输入,如 `支付失败` |
| 日志级别 | `@l = 'Error'` |
| 特定实例 | `instance = '8085'` |
| stdout 来源 | `source = 'stdout'` |
| 调用位置 | `caller like '%order.go%'` |
| 组合查询 | `@l = 'Error' and instance = '8086' and @mt like '%超时%'` |
| 日期范围 | 右上角时间选择器,支持精确到秒 |
+375 -366
View File
@@ -6,22 +6,18 @@
- [概述](#概述)
- [快速开始](#快速开始)
- [运行测试](#运行测试)
- [用例编写范式](#用例编写范式)
- [错误用例编写规范](#1-错误用例编写规范)
- [测试数据准备](#2-测试数据准备)
- [正确请求与响应结构校验](#3-正确请求与响应结构校验)
- [Verify 统一校验](#4-verify-统一校验)
- [简写支持说明](#简写支持说明)
- [核心类型](#核心类型)
- [业务流程联调(Flows](#业务流程联调flows)
- [优雅停测](#优雅停测)
- [链式 API 参考](#链式-api-参考)
- [API 速查表](#api-速查表)
- [事务隔离机制](#事务隔离机制)
- [覆盖率报告](#覆盖率报告)
- [api-spec.json 覆盖率字段说明](#api-specjson-覆盖率字段说明)
- [API 调试控制台](#api-调试控制台)
- [指定运行范围](#指定运行范围)
- [并发保护与缓存隔离](#并发保护与缓存隔离)
- [二进制与非 JSON 响应校验](#二进制与非-json-响应校验)
- [全量回归与覆盖率](#全量回归与覆盖率)
- [已知局限与后续](#已知局限与后续)
---
@@ -29,16 +25,20 @@
HoTime 内置 API 测试框架,核心能力:
| 能力 | 说明 |
|------|------|
| **零启动** | 基于 `net/http/httptest`,不需要启动 HTTP 服务器 |
| **事务回滚** | 每个接口方法独立开启数据库事务,测试结束自动回滚,数据库不留任何痕迹 |
| **SAVEPOINT** | 业务代码中的 `that.Db.Action()` 在测试模式下自动用 SAVEPOINT 替代真事务,嵌套事务完整可用 |
| **并发保护** | `testMu` 互斥锁自动保护 `testTx` 单连接,业务代码中的 `go func()` 协程不会导致 `busy buffer` |
| **缓存隔离** | 测试启动时自动禁用 DB/Redis 缓存,避免缓存操作与测试事务锁冲突 |
| **链式 API** | `a.JSON(...).Post("描述", 期望status)` 一行完成:数据组装 + 请求 + 断言 |
| **覆盖率** | 自动对比路由注册表与测试定义,输出哪些接口已覆盖、哪些未覆盖 |
| **调试控制台** | 按模块生成独立的交互式 API 调试控制台,支持在线测试、认证管理、参数编辑 |
| 能力 | 说明 |
| ------------- | --------------------------------------------------------------------- |
| **零启动** | 基于 `net/http/httptest`,不需要启动 HTTP 服务器 |
| **事务回滚** | 每个接口方法独立开启数据库事务,测试结束自动回滚,数据库不留任何痕迹 |
| **SAVEPOINT** | 业务代码中的 `that.Db.Action()` 在测试模式下自动用 SAVEPOINT 替代真事务,嵌套事务完整可用 |
| **并发保护** | `testMu` 互斥锁自动保护 `testTx` 单连接,业务代码中的 `go func()` 协程不会导致 `busy buffer` |
| **缓存隔离** | 测试启动时自动禁用 DB/Redis 缓存,避免缓存操作与测试事务锁冲突 |
| **链式 API** | `a.JSON(...).Post("描述", 期望status)` 一行完成:数据组装 + 请求 + 断言 |
| **Flows** | 多接口业务流程联调;`Group`/`Desc`/`Prep``FromCase` 绑定单接口用例并回写 |
| **覆盖率** | 自动对比路由注册表与测试定义(**不含 Flow 步骤**,只统计单接口 path) |
| **调试控制台** | 增量写入 swagger;流程分组 UI;发送结果 cURL 实时同步左侧参数 |
| **优雅停测** | SIGINT/SIGTERM / `RequestStop`:停收新测、跑完当前、回滚事务、保留已落盘文档 |
---
@@ -180,14 +180,20 @@ var ProjectTest = ProjTest{
var testApp *TestApp
func TestMain(m *testing.M) {
os.Chdir("..") // 确保工作目录为项目根目录
testApp = NewTestApp("config/config.json", TestProj{
"app": {Proj: Project, Tests: ProjectTest},
})
code := m.Run()
testApp.PrintCoverage()
testApp.GenerateSwagger("My API", "1.0.0", testApp.Config.GetString("tpt"))
os.Exit(code)
testApp = Test("config/config.json").
WorkDir("..").
Listener(func(ctx *Context) bool {
return false // 空实现:继续进控制器;需要鉴权/拦截时在此写
}).
Swagger("My API", "1.0.0").
Proj(TestProj{
"app": {Proj: Project, Tests: ProjectTest},
}).
Flows(FlowTest{
// "checkout": {Group: "order", Desc: "下单支付跑通", Func: checkoutFlow},
})
os.Exit(testApp.Run(m))
}
func TestApi(t *testing.T) {
@@ -195,6 +201,9 @@ func TestApi(t *testing.T) {
}
```
`Run(m)` 内部会:注册优雅停测信号 → `m.Run()` → 覆盖率 → Swagger 收尾(接口级增量写入)。
`WorkDir` / `Listener` / `Swagger` / `Flows` 均可省略;不写 `Swagger` 则不生成调试控制台。example 推荐把整条链写全便于对照。
### 推荐目录结构
```
@@ -209,11 +218,26 @@ app/
### 运行测试
`RunTests` 使用 Go 标准 `t.Run()` 子测试,用 `-run` 精确到单接口 / 单控制器 / 单用例:
```bash
go test ./app/... -v
# 单接口
go test ./app/ -v -run TestApi/app/order/create
# 单控制器
go test ./app/ -v -run TestApi/app/order
# 单用例
go test ./app/ -v -run "TestApi/app/order/create/正常创建订单"
# 多控制器(正则)
go test ./app/ -v -run "TestApi/app/(user|order)"
# 按用例名收窄(如所有「未登录」)
go test ./app/ -v -run "TestApi/.*/.*未登录"
```
> **注意**`TestMain` 中的 `os.Chdir("..")` 确保 `go test ./app/` 的工作目录为项目根目录,使配置文件路径和模板输出路径正确。
> **注意**`WorkDir("..")` 在 Init 前切换工作目录,确保上述 `-run` 命令下配置与模板输出路径正确。
---
@@ -231,14 +255,16 @@ go test ./app/... -v
Verify 统一校验(响应值断言 + 数据库状态校验,集中在一个回调中完成)
```
| 环节 | 要求 | 说明 |
|------|------|------|
| 错误用例 | **必须** | 覆盖权限、参数、业务逻辑等异常路径 |
| 测试数据准备 | 按需 | 接口依赖的前置数据用 `a.DB()` 插入 |
| Note 备注 | 按需 | 用 `.Note(...)` 补充参数含义、枚举值说明、算法描述等上下文 |
| 响应结构校验 | **必须** | 正确请求必须用第三参数校验 result 的类型和结构 |
| 环节 | 要求 | 说明 |
| --------- | ------ | ------------------------------------------------- |
| 错误用例 | **必须** | 覆盖权限、参数、业务逻辑等异常路径 |
| 测试数据准备 | 按需 | 接口依赖的前置数据用 `a.DB()` 插入 |
| Note 备注 | 按需 | 用 `.Note(...)` 补充参数含义、枚举值说明、算法描述等上下文 |
| 响应结构校验 | **必须** | 正确请求必须用第三参数校验 result 的类型和结构 |
| Verify 校验 | **必须** | 在 Verify 内用 `a.Resp()` 断言响应值 + 用 `a.DB()` 校验数据库状态 |
### 1. 错误用例编写规范
**错误用例必须写在最前面**,在正确请求之前,按接口错误码顺序依次覆盖:权限校验(status 1/2)→ 参数校验(status 3)→ 业务校验(status 4)。
@@ -276,14 +302,16 @@ resp := a.WithSession(Map{"user_id": int64(1)}).
第三参数作为**类型样本**,框架自动校验 result 的类型是否匹配,**不比较具体值**。第三参数支持所有 JSON 兼容类型:
| 样本写法 | 校验效果 |
|----------|----------|
| `int64(1)` | result 是整数类型 |
| `float64(1.0)` | result 是浮点类型 |
| `"sample"` | result 是字符串类型 |
| `true` | result 是布尔类型 |
| `Map{"id": int64(1), ...}` | result 是对象,递归校验字段名+类型 |
| `Slice{Map{"id": int64(1)}}` | result 是数组,校验首元素结构 |
| 样本写法 | 校验效果 |
| ---------------------------- | --------------------- |
| `int64(1)` | result 是整数类型 |
| `float64(1.0)` | result 是浮点类型 |
| `"sample"` | result 是字符串类型 |
| `true` | result 是布尔类型 |
| `Map{"id": int64(1), ...}` | result 是对象,递归校验字段名+类型 |
| `Slice{Map{"id": int64(1)}}` | result 是数组,校验首元素结构 |
```go
// result 是 Map 时:值只是类型样本,不比较具体值
@@ -371,30 +399,36 @@ a.WithSession(Map{"user_id": int64(1)}).
**Verify 内可用的方法:**
| 方法 | 说明 |
|------|------|
| `a.Resp()` | 获取当前请求的响应,用于值断言 |
| `a.Resp().GetBody()` | 获取完整响应体(Map |
| `a.Resp().GetBody().GetMap("result")` | 获取 result 对象 |
| `a.DB()` | 获取数据库实例(在测试事务内),用于查库校验 |
| 方法 | 说明 |
| ------------------------------------- | ---------------------- |
| `a.Resp()` | 获取当前请求的响应,用于值断言 |
| `a.Resp().GetBody()` | 获取完整响应体(Map) |
| `a.Resp().GetBody().GetMap("result")` | 获取 result 对象 |
| `a.DB()` | 获取数据库实例(在测试事务内),用于查库校验 |
**Verify 校验要点:**
| 校验对象 | 说明 | 示例 |
|----------|------|------|
| 校验对象 | 说明 | 示例 |
| ----- | ---------------------- | -------------------------------------------------------- |
| 响应关键值 | 业务字段的具体值(ID > 0、编号非空等) | `a.Resp().GetBody().GetMap("result").GetCeilInt64("id")` |
| 主表记录 | 核心数据是否写入、字段值是否正确 | `a.DB().Get("order", ...)` |
| 关联表 | 关联数据是否同步生成 | `a.DB().Count("order_goods", ...)` |
| 副作用 | 库存扣减、余额变化、状态流转 | `goods.GetCeilInt64("stock") != 97` |
| 主表记录 | 核心数据是否写入、字段值是否正确 | `a.DB().Get("order", ...)` |
| 关联表 | 关联数据是否同步生成 | `a.DB().Count("order_goods", ...)` |
| 副作用 | 库存扣减、余额变化、状态流转 | `goods.GetCeilInt64("stock") != 97` |
**适用场景对照:**
| 接口类型 | Verify 内容 |
|----------|-------------|
| 创建类(Insert) | 响应值断言 + 校验记录写入 + 关联表 |
| 接口类型 | Verify 内容 |
| ----------- | --------------------- |
| 创建类(Insert) | 响应值断言 + 校验记录写入 + 关联表 |
| 更新类(Update) | 响应值断言 + 校验字段变更 + 状态流转 |
| 删除类(Delete) | 响应值断言 + 校验记录已删除/软删除 |
| 纯查询(Select) | 响应值断言(无需 DB 校验) |
| 删除类(Delete) | 响应值断言 + 校验记录已删除/软删除 |
| 纯查询(Select) | 响应值断言(无需 DB 校验) |
### 5. Note 备注
@@ -435,13 +469,15 @@ a.WithSession(Map{"admin_id": int64(1)}).
**建议使用 Note 的场景:**
| 场景 | 示例 |
|------|------|
| 参数含枚举值(status/type/state 等) | `Note("type: 1=个人, 2=企业")` |
| 涉及签名、加密、哈希等算法 | `Note("sign = MD5(appKey+timestamp+nonce)")` |
| 测试数据有特定计算依据 | `Note("quantity=3, price=29.9, 预期 total=89.7")` |
| 接口有调用顺序或前置依赖 | `Note("需先调用 /api/order/create 创建订单")` |
| 接口有特殊行为(批量/异步/幂等等) | `Note("幂等接口,重复调用返回相同结果")` |
| 场景 | 示例 |
| --------------------------- | ----------------------------------------------- |
| 参数含枚举值(status/type/state 等) | `Note("type: 1=个人, 2=企业")` |
| 涉及签名、加密、哈希等算法 | `Note("sign = MD5(appKey+timestamp+nonce)")` |
| 测试数据有特定计算依据 | `Note("quantity=3, price=29.9, 预期 total=89.7")` |
| 接口有调用顺序或前置依赖 | `Note("需先调用 /api/order/create 创建订单")` |
| 接口有特殊行为(批量/异步/幂等等) | `Note("幂等接口,重复调用返回相同结果")` |
> Note 是可选的,但在涉及枚举值、算法、前置依赖等场景时建议使用。好的备注能让其他开发者快速理解用例意图,也让调试控制台成为自文档化的 API 手册。
@@ -451,6 +487,8 @@ a.WithSession(Map{"admin_id": int64(1)}).
**省略错误消息(第三参数)**`a.Post("请先登录", 2)` — 当 `status != 0` 且未传第三参数时,框架自动用 `desc`(第一参数)作为期望的错误消息。适合 desc 与实际 msg 完全一致的场景。
**跳过错误消息断言(AnyMsg**`a.AnyMsg().Post("无效授权码", 1)` — 当 `status != 0` 但错误 msg 含动态内容(网络拨号错误、第三方 API 回传文案等)无法全等断言时,用 `AnyMsg()` 仅断言 status、跳过 msg 校验(同时也不再用 desc 兜底匹配)。`Api``ApiCase` 均可链式调用,如 `a.JSON(...).AnyMsg().Post(...)`。样例见 `example/app/expect_demo_test.go``error_demo`
**省略 result 结构校验(expect**`a.Post("创建成功", 0)` — 仅断言 `status=0`,不校验 result 的内容和结构。适合无需关注返回结构的场景,也适用于非 JSON 响应(二进制文件、纯文本等),此时在 Verify 中通过 `GetRawBody()` 做内容校验。
**省略 Verify**:不设置 Verify 回调时,不会执行响应值断言和数据库状态校验。适合纯查询、无副作用的接口。
@@ -489,25 +527,87 @@ type ApiTestDef struct {
### TestApp
```go
// NewTestApp 创建测试应用
// configPath: 配置文件路径
// projects: TestProj 注册所有项目的路由和测试
// listeners: 可选,与 SetConnectListener 相同的请求拦截器
func NewTestApp(configPath string, projects TestProj, listeners ...func(*Context) bool) *TestApp
// Test 链式入口(延迟 Init,便于先 WorkDir
func Test(configPath string) *TestApp
func (app *TestApp) WorkDir(dir string) *TestApp
func (app *TestApp) Listener(fns ...func(*Context) bool) *TestApp // 可选:连接监听,可多次
func (app *TestApp) Swagger(args ...string) *TestApp // title / version / outputDir 均可选
func (app *TestApp) Proj(projects TestProj) *TestApp
func (app *TestApp) Flows(flows FlowTest) *TestApp // 可选:多接口业务流程
func (app *TestApp) Run(m *testing.M) int // 信号停测 + m.Run + 覆盖率 + Swagger 收尾
// RunTests 运行所有注册的测试(每个方法级别自动事务隔离)
func (app *TestApp) RunTests(t *testing.T)
// PrintCoverage 输出覆盖率报告,返回报告结构体
func (app *TestApp) PrintCoverage() CoverageReport
// GenerateSwagger 生成 API 调试控制台(api-spec.json + index.html
func (app *TestApp) GenerateSwagger(title, version, outputDir string) error
// DB 获取测试应用的数据库实例(在事务内)
func (app *TestApp) DB() *HoTimeDB
```
### 业务流程联调(Flows
与单接口 `CtrTest` 并列,用于多接口串起来的业务跑通。整条 Flow 共用一次测试事务,结束回滚。
**FlowDef 字段**
| 字段 | 说明 |
| ------- | ------------------------- |
| `Group` | 控制台侧栏分组(如 `order` |
| `Desc` | 流程短标题 |
| `Prep` | 业务说明 / 前置与数据准备(控制台「业务说明」) |
| `Func` | `func(f *Flow)` |
**Flow API**
| 方法 | 说明 |
| --------------------- | ------------------------- |
| `f.Step(name, path)` | 新步骤,返回 `*Api`(记录归入 flows |
| `f.AtPath(path)` | 同流程内换 path,不增步骤序号 |
| `f.WithSession(s)` | 后续步骤携带 session |
| `f.DB()` / `f.Resp()` | 流程事务库 / 最近一步响应 |
```go
Flows(FlowTest{
"checkout": {
Group: "order",
Desc: "下单支付跑通",
Prep: "先创建订单再支付;步骤可用 FromCase 绑定单接口验收用例。",
Func: func(f *Flow) {
f.Step("创建订单", "/app/order/create").
FromCase("正常创建").
Note("创建后取 order id 供支付步骤使用").
WithSession(Map{"admin_id": 1}).
Post("创建", 0, Map{"id": int64(1)})
orderID := f.Resp().GetBody().GetMap("result").GetCeilInt64("id")
f.Step("支付", "/app/order/pay").
WithSession(Map{"admin_id": 1}).
JSON(Map{"order_id": orderID}).
Post("支付成功", 0)
},
},
})
```
**FromCase**
- 按 path + 用例名加载请求模板:优先本轮已跑的单接口记录,否则读已有 `api-spec.json`
- 步骤跑完后回写该单接口 case 的 response / passed
- 无模板时当前为**静默不填**(见 [已知局限](#已知局限与后续));建议先用 `-run` 跑绑定的单接口再跑 flow
- 示例:`example/app/flow_test.go`
控制台:侧栏「业务流程 → group → flow 名」;步骤左栏含关联验收用例 / 备注 / 请求,右栏响应;点 path 弹窗看接口只读详情。
`-run``go test ./app/ -run "TestApi/flows/checkout" -v`
### 优雅停测
`Run(m)` 内监听 SIGINT/SIGTERM;测试代码可调 `testApp.RequestStop()` 模拟。
行为:停收后续接口/流程 → 当前子测跑完 → 回滚测试事务 → 已 flush 的 swagger **保留**(不全量 prune)。
示例:`example/app/app_test.go``TestInterruptStop`
---
## 链式 API 参考
@@ -516,47 +616,56 @@ func (app *TestApp) DB() *HoTimeDB
### Api — 测试入口
| 方法 | 说明 | 返回 |
|------|------|------|
| `a.JSON(body)` | 设置 JSON body | `*ApiCase` |
| `a.Query(params)` | 设置 URL 查询参数 | `*ApiCase` |
| `a.Form(body)` | 设置 form 表单 body | `*ApiCase` |
| `a.File(field, name, content)` | 设置上传文件 | `*ApiCase` |
| `a.Note(note)` | 为下一条用例设置备注,备注显示在调试控制台 | `*ApiCase` |
| `a.Verify(fn)` | 设置请求后的校验函数(等同于先创建 ApiCase 再 Verify,适合纯 GET 无数据场景) | `*ApiCase` |
| `a.WithSession(s)` | 返回携带 session 的新 Api | `*Api` |
| `a.Get(desc, status, expect...)` | 直接 GET 请求(无数据场景) | `*ApiResponse` |
| `a.Post(desc, status, expect...)` | 直接 POST 请求(无数据场景) | `*ApiResponse` |
| `a.Put(desc, status, expect...)` | 直接 PUT 请求 | `*ApiResponse` |
| `a.Delete(desc, status, expect...)` | 直接 DELETE 请求 | `*ApiResponse` |
| `a.DB()` | 获取数据库实例(在事务内,可用于准备/校验数据) | `*HoTimeDB` |
| `a.Resp()` | 获取最近一次请求的响应(在 Verify 回调中用于值断言) | `*ApiResponse` |
| 方法 | 说明 | 返回 |
| ----------------------------------- | ------------------------------------------------- | -------------- |
| `a.JSON(body)` | 设置 JSON body | `*ApiCase` |
| `a.Query(params)` | 设置 URL 查询参数 | `*ApiCase` |
| `a.Form(body)` | 设置 form 表单 body | `*ApiCase` |
| `a.File(field, name, content)` | 设置上传文件 | `*ApiCase` |
| `a.Note(note)` | 为下一条用例设置备注,备注显示在调试控制台 | `*ApiCase` |
| `a.Verify(fn)` | 设置请求后的校验函数(等同于先创建 ApiCase 再 Verify,适合纯 GET 无数据场景) | `*ApiCase` |
| `a.FromCase(name)` | 绑定单接口验收用例:加载其请求模板;跑通后回写 response/passed | `*ApiCase` |
| `a.WithSession(s)` | 返回携带 session 的新 Api | `*Api` |
| `a.RunSub(desc, fn)` | 接口 Func 内再分子场景,便于 `-run` 收窄 | — |
| `a.Get(desc, status, expect...)` | 直接 GET 请求(无数据场景) | `*ApiResponse` |
| `a.Post(desc, status, expect...)` | 直接 POST 请求(无数据场景) | `*ApiResponse` |
| `a.Put(desc, status, expect...)` | 直接 PUT 请求 | `*ApiResponse` |
| `a.Delete(desc, status, expect...)` | 直接 DELETE 请求 | `*ApiResponse` |
| `a.DB()` | 获取数据库实例(在事务内,可用于准备/校验数据) | `*HoTimeDB` |
| `a.Resp()` | 获取最近一次请求的响应(在 Verify 回调中用于值断言) | `*ApiResponse` |
### ApiCase — 链式构建器
`Api` 的数据设置方法返回 `*ApiCase`,支持继续叠加数据:
| 方法 | 说明 | 返回 |
|------|------|------|
| `c.JSON(body)` | 追加 JSON body | `*ApiCase` |
| `c.Query(params)` | 追加 URL 查询参数 | `*ApiCase` |
| `c.Form(body)` | 追加 form 字段 | `*ApiCase` |
| `c.File(field, name, content)` | 设置上传文件 | `*ApiCase` |
| `c.Note(note)` | 为本条用例添加可选备注,备注显示在调试控制台用例详情中 | `*ApiCase` |
| `c.WithSession(s)` | 覆盖本次请求的 session | `*ApiCase` |
| `c.Verify(fn)` | 设置请求后的统一校验函数,可用 `a.Resp()` 断言响应值 + `a.DB()` 校验数据库(详见 [Verify 统一校验](#4-verify-统一校验) | `*ApiCase` |
| `c.Get(desc, status, expect...)` | 终端:发送 GET 请求并断言 | `*ApiResponse` |
| `c.Post(desc, status, expect...)` | 终端:发送 POST 请求并断言 | `*ApiResponse` |
| `c.Put(desc, status, expect...)` | 终端:发送 PUT 请求并断言 | `*ApiResponse` |
| `c.Delete(desc, status, expect...)` | 终端:发送 DELETE 请求并断言 | `*ApiResponse` |
| 方法 | 说明 | 返回 |
| ----------------------------------- | ----------------------------------------------------------------------------------- | -------------- |
| `c.JSON(body)` | 追加 JSON body | `*ApiCase` |
| `c.Query(params)` | 追加 URL 查询参数 | `*ApiCase` |
| `c.Form(body)` | 追加 form 字段 | `*ApiCase` |
| `c.File(field, name, content)` | 设置上传文件 | `*ApiCase` |
| `c.Note(note)` | 为本条用例添加可选备注,备注显示在调试控制台用例详情中 | `*ApiCase` |
| `c.WithSession(s)` | 覆盖本次请求的 session | `*ApiCase` |
| `c.FromCase(name)` | 绑定单接口验收用例名并加载请求模板(若尚未加载) | `*ApiCase` |
| `c.Verify(fn)` | 设置请求后的统一校验函数,可用 `a.Resp()` 断言响应值 + `a.DB()` 校验数据库(详见 [Verify 统一校验](#4-verify-统一校验) | `*ApiCase` |
| `c.Get(desc, status, expect...)` | 终端:发送 GET 请求并断言 | `*ApiResponse` |
| `c.Post(desc, status, expect...)` | 终端:发送 POST 请求并断言 | `*ApiResponse` |
| `c.Put(desc, status, expect...)` | 终端:发送 PUT 请求并断言 | `*ApiResponse` |
| `c.Delete(desc, status, expect...)` | 终端:发送 DELETE 请求并断言 | `*ApiResponse` |
### 终端方法参数说明
| 参数 | 类型 | 说明 |
|------|------|------|
| `desc` | string | 用例描述,作为 `t.Run` 的子测试名称 |
| `status` | int | 期望的 JSON 响应体中 `status` 字段值(0=成功) |
| `expect` | ...interface{} | 可选,行为取决于 status(见下方说明 |
| 参数 | 类型 | 说明 |
| -------- | -------------- | -------------------------------- |
| `desc` | string | 用例描述,作为 `t.Run` 的子测试名称 |
| `status` | int | 期望的 JSON 响应体中 `status` 字段值(0=成功 |
| `expect` | ...interface{} | 可选,行为取决于 status(见下方说明) |
**expect 参数解析规则:**
@@ -574,16 +683,19 @@ func (app *TestApp) DB() *HoTimeDB
expect 参数作为**类型样本**,只比较类型大类,不比较具体值。result 不一定是 Map,可以是任意类型:
| 样本值类型 | 匹配的实际类型 | 说明 |
|-----------|--------------|------|
| `int64` / `int` 等整数 | 整数类型 | number(整数) |
| `float64` / `float32` | 浮点类型 | float(小数) |
| `string` | `string` | 字符串 |
| `bool` | `bool` | 布尔值 |
| `Map{...}` | `Map` / `map[string]interface{}` | 对象,递归校验子字段 |
| `Slice{...}` | `Slice` / `[]interface{}` | 数组,非空时校验首元素结构 |
| 样本值类型 | 匹配的实际类型 | 说明 |
| --------------------- | -------------------------------- | ------------- |
| `int64` / `int` 等整数 | 整数类型 | number(整数) |
| `float64` / `float32` | 浮点类型 | float(小数) |
| `string` | `string` | 字符串 |
| `bool` | `bool` | 布尔值 |
| `Map{...}` | `Map` / `map[string]interface{}` | 对象,递归校验子字段 |
| `Slice{...}` | `Slice` / `[]interface{}` | 数组,非空时校验首元素结构 |
校验规则:
- result 是原始类型(string、int、float 等)→ 直接比较类型大类
- result 是 Map → **多了**字段不管,**少了**预设字段报错「字段缺失」
- 字段存在但**类型不一致** → 报错「类型不匹配」
@@ -617,105 +729,42 @@ resp.ExpectResult(Map{"version": "sample", "features": Slice{}})
**ApiResponse 可用方法:**
| 方法 | 返回类型 | 说明 |
|------|---------|------|
| `resp.GetStatus()` | `int` | 业务 status |
| `resp.GetMsg()` | `string` | 业务 msg |
| `resp.GetResult()` | `interface{}` | result 字段原始值 |
| `resp.GetBody()` | `Map` | 完整 JSON 响应体,可链式 `GetString("key")``GetMap("result")` 等 |
| `resp.GetRawBody()` | `[]byte` | 原始响应字节(非 JSON 响应如文件下载、二进制流) |
| `resp.RawBody` | `[]byte` | 同上,公开字段直接访问 |
| `resp.ExpectResult(sample)` | `*ApiResponse` | 后置结构校验(类型样本) |
| `resp.Fail(msg)` | — | 手动标记测试失败 |
---
| 方法 | 返回类型 | 说明 |
| --------------------------- | -------------- | ------------------------------------------------------- |
| `resp.GetStatus()` | `int` | 业务 status |
| `resp.GetMsg()` | `string` | 业务 msg |
| `resp.GetResult()` | `interface{}` | result 字段原始值 |
| `resp.GetBody()` | `Map` | 完整 JSON 响应体,可链式 `GetString("key")``GetMap("result")` 等 |
| `resp.GetRawBody()` | `[]byte` | 原始响应字节(非 JSON 响应如文件下载、二进制流) |
| `resp.RawBody` | `[]byte` | 同上,公开字段直接访问 |
| `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{...})
```
---
@@ -753,179 +802,47 @@ that.Db.Action(func(db HoTimeDB) bool {
// 测试结束 → ROLLBACK testTx(回滚一切)
```
| 机制 | 生产模式 | 测试模式 |
|------|----------|----------|
| 机制 | 生产模式 | 测试模式 |
| ---------- | ------------------------------- | --------------------------------------- |
| `Action()` | `BEGIN` / `COMMIT` / `ROLLBACK` | `SAVEPOINT` / `RELEASE` / `ROLLBACK TO` |
| 数据持久化 | 持久化到数据库 | 测试结束全部回滚 |
| 对现有代码影响 | 无 | 无(`testTx` 默认 nil |
| 数据持久化 | 持久化到数据库 | 测试结束全部回滚 |
| 对现有代码影响 | 无 | 无(`testTx` 默认 nil |
---
## 覆盖率报告
`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 调试控制台(暗色高对比度主题),每个模块一个子目录,支持独立分发
通过链式 `.Swagger(title, version)` 启用
```go
testApp.GenerateSwagger(
"My API", // 文档标题
"2.1.0", // 版本号
testApp.Config.GetString("tpt"), // 输出目录(如 "tpt"
)
```
**增量写入**:每个接口 / 流程跑完即 flush;本轮无记录则不覆盖成「未测」;未全量跑完不 prune 已删接口;仅全量完成才清理孤儿模块。Ctrl+C / SIGTERM 走优雅停测后再收尾。
输出目录默认 `config` 中的 `tpt`
### 生成目录结构
每个模块(`TestProj` 中的项目名)生成独立子目录,同时自动生成根导航页:
```
{outputDir}/swagger/
├── index.html ← 根导航页(API 文档中心,卡片式链接到各模块)
├── api/
│ ├── api-spec.json ← api 模块的 API 规范(路由、测试用例、请求/响应数据)
│ └── index.html ← api 模块的调试控制台
├── admin/
│ ├── api-spec.json
├── index.html ← 根导航页
├── app/
│ ├── api-spec.json ← endpoints + flows
│ └── index.html
└── portal/
├── api-spec.json
└── index.html
└── ...
```
访问地址`http://localhost:8081/swagger/`(根导航),`http://localhost:8081/swagger/api/`api 模块)
访问:`http://localhost:8081/swagger/`(根导航),`http://localhost:8081/swagger/app/`(模块)
> **独立分发**:每个模块目录可以单独拷贝和部署,不依赖其他模块。根导航页自动扫描子目录生成链接。
### 控制台能力
控制台支持三级导航、关键字搜索、用例预填、全局认证(Header/URL/Cookie)、cURL 生成、覆盖率可视化等功能。
---
| 区域 | 行为 |
| --- | -------------------------------------------------------------- |
| 侧栏 | 业务流程 → group → flow 名;接口仍按 project/ctr 三级 |
| 流程页 | Prep 业务说明;步骤默认展开;左备注/关联验收用例/请求(cURL),右响应;点 path 弹窗只读详情(Esc 关闭) |
| 单接口 | 用例预填、全局认证;**发送结果 cURL 随左侧 Header/Query/JSON/Form 实时同步** |
| 发送 | 浏览器 `fetch` 打当前源站,**需已启动服务**(与 httptest 零启动测试不同) |
## 指定运行范围
`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/.*/.*未登录"
```
---
@@ -934,7 +851,7 @@ go test ./app/... -v -run "TestApi/.*/.*未登录"
框架自动处理以下问题,编写测试时无需关心:
- **并发保护**`testTx` 是单连接,框架通过 `testMu` 互斥锁自动序列化所有数据库操作,业务代码中的 `go func()` 协程不会导致 `busy buffer` 错误
- **缓存隔离**`NewTestApp` 启动时自动禁用 DB/Redis 缓存,避免与测试事务产生锁冲突;Session 通过 `WithSession()` 直接注入 Memory 缓存
- **缓存隔离**:启动时自动禁用 DB/Redis 缓存,避免与测试事务产生锁冲突;Session 通过 `WithSession()` 直接注入 Memory 缓存
### 一次性数据的处理
@@ -1017,13 +934,105 @@ a.Verify(func(a *Api) error {
### RawBody vs Body 的关系
| 场景 | `Body (Map)` | `RawBody ([]byte)` |
|------|-------------|-------------------|
| JSON 响应 | 已解析的结构化 Map | 原始 JSON 字节 |
| 二进制文件(XLSX/PNG/PDF | 空 Map(解析失败) | 完整二进制内容 |
| 纯文本(CSV/TXT/HTML | 空 Map(非 JSON) | 完整文本字节,可 `string()` 转换 |
| 空响应 | 空 Map | 空 `[]byte` |
| 场景 | `Body (Map)` | `RawBody ([]byte)` |
| ------------------- | ------------- | ---------------------- |
| JSON 响应 | 已解析的结构化 Map | 原始 JSON 字节 |
| 二进制文件(XLSX/PNG/PDF | 空 Map(解析失败) | 完整二进制内容 |
| 纯文本(CSV/TXT/HTML | 空 Map(非 JSON) | 完整文本字节,可 `string()` 转换 |
| 空响应 | 空 Map | 空 `[]byte` |
> **注意**:二进制/纯文本响应不走 status 校验(JSON 解析失败后 `Body` 为空 Map`status` 默认 0),
> 所以 `Get("描述", 0)` 只是形式上通过。**真正的校验逻辑必须写在 Verify 中**,对 `RawBody` 做内容断言。
## 全量回归与覆盖率
也支持对整个测试包做全量回归,**仅 CI / 发版前使用**:
```bash
go test ./app/... -v
```
全量跑完后 `Run` 会调用 `PrintCoverage()` 输出覆盖率报告。
### 覆盖率报告
`PrintCoverage()` 自动对比 `Proj`(所有路由)和 `ProjTest`(有测试的路由)。**Flow 步骤不计入覆盖率**(`FlowName != ""` 的记录会被跳过)。
**输出示例(全部通过):**
```
========== 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": {} }
}
]
}
```
---
## 已知局限与后续
| 优先级 | 缺口 | 说明 |
| --- | ----------------------- | ---------------------------- |
| 高 | 值级断言助手 | 结构校验有,值断言靠 Verify 手写 |
| 高 | DB 断言助手 | 无 AssertCount / AssertRow 一类 |
| 高 | FromCase 无模板显式失败 | 现静默不填,易空 body 误测 |
| 中 | 自定义 Header / Cookie API | 控制台有,测试链无 `WithHeader` |
| 中 | HTTP 状态码 / 响应头断言 | StatusCode 有写入,无公开断言 |
| 中 | 覆盖率含 Flow | PrintCoverage 跳过 Flow 记录 |
| 低 | PATCH、多文件上传 | 当前边界 |
| 低 | 框架级 Seed/Fixture | 依赖业务自写 `a.DB()` 准备 |
规划表见 [ROADMAP_改进规划.md](ROADMAP_改进规划.md)。
+118 -12
View File
@@ -1,7 +1,10 @@
package app
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
. "code.hoteas.com/golang/hotime"
@@ -18,27 +21,130 @@ var ProjectTest = ProjTest{
var testApp *TestApp
func TestMain(m *testing.M) {
// Go test 的 CWD 是包目录 example/app/,切回 example/ 使相对路径正确
_ = os.Chdir("..")
testApp = NewTestApp("config/config.json",
TestProj{
testApp = Test("config/config.json").
WorkDir("..").
Listener(func(ctx *Context) bool {
return false // 空实现:继续进控制器;需要鉴权/拦截时在此写
}).
Swagger("My API", "1.0.0").
Proj(TestProj{
"app": {
Proj: Project,
Tests: ProjectTest,
},
},
)
// 通用 CRUDcodeConfig=admin)路由由 Init 时按库表生成,这里仅挂测试
"admin": {
Proj: Proj{},
Tests: AdminDepartmentTest,
},
}).
Flows(DemoFlows)
// 在测试前初始化各数据库的测试表和种子数据
// 仅本 example:空库时灌演示数据。须连专用测试库,勿对生产库调用。
SetupDatabase(&testApp.Db)
code := m.Run()
testApp.PrintCoverage()
testApp.GenerateSwagger("My API", "1.0.0", testApp.Config.GetString("tpt"))
os.Exit(code)
os.Exit(testApp.Run(m))
}
func TestApi(t *testing.T) {
testApp.RunTests(t)
}
// TestListenerChain 验证链式 Listener 在请求路径上被调用。
// 注意:TestMain 已 Chdir 到 example/,此处不再 WorkDir。
func TestListenerChain(t *testing.T) {
hit := false
app := Test("config/config.json").
Listener(func(ctx *Context) bool {
hit = true
return false
}).
Proj(TestProj{
"app": {
Proj: Project,
Tests: ProjTest{
"expect_demo": CtrTest{
"string_result": {Desc: "触发 listener", Func: func(a *Api) {
ExpectDemoTest["string_result"].Func(a)
}},
},
},
},
})
SetupDatabase(&app.Db)
app.RunTests(t)
if !hit {
t.Fatal("Listener 未被调用")
}
_ = app.Db.RollbackTestTx()
}
// TestInterruptStop 模拟提前中断:string_result 跑完后 RequestStopzzz_should_skip 应 Skip
// swagger 中 string_result 的 cases 应保留。注意:TestMain 已 Chdir 到 example/,此处不再 WorkDir。
func TestInterruptStop(t *testing.T) {
var app *TestApp
secondRan := false
app = Test("config/config.json").
Swagger("Interrupt Demo", "1.0.0").
Proj(TestProj{
"app": {
Proj: Project,
Tests: ProjTest{
"expect_demo": CtrTest{
"string_result": {Desc: "先跑并停测", Func: func(a *Api) {
ExpectDemoTest["string_result"].Func(a)
app.RequestStop()
}},
"zzz_should_skip": {Desc: "应被跳过", Func: func(a *Api) {
secondRan = true
t.Error("zzz_should_skip 在 RequestStop 后不应执行")
}},
},
},
},
})
SetupDatabase(&app.Db)
app.RunTests(t)
if secondRan {
t.Fatal("停测后仍执行了后续接口")
}
_ = app.Db.RollbackTestTx()
// 收尾写盘(中断路径下可能未跑满 GenerateSwagger,显式补一次增量合并)
if err := app.GenerateSwagger("Interrupt Demo", "1.0.0", app.Config.GetString("tpt")); err != nil {
t.Fatalf("GenerateSwagger: %v", err)
}
specPath := filepath.Join(app.Config.GetString("tpt"), "swagger", "app", "api-spec.json")
data, err := os.ReadFile(specPath)
if err != nil {
t.Fatalf("应已生成 swagger %s: %v", specPath, err)
}
var spec struct {
Endpoints []struct {
Path string `json:"path"`
Cases json.RawMessage `json:"cases"`
} `json:"endpoints"`
}
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("解析 api-spec 失败: %v", err)
}
found := false
for _, ep := range spec.Endpoints {
if ep.Path != "/app/expect_demo/string_result" {
continue
}
found = true
if !strings.Contains(string(ep.Cases), `"name"`) {
t.Fatalf("string_result 应保留测试 cases,实际: %s", string(ep.Cases))
}
}
if !found {
t.Fatal("swagger 中应包含已跑完的 /app/expect_demo/string_result")
}
}
+5 -20
View File
@@ -146,6 +146,10 @@ var ExpectDemoTest = CtrTest{
// ======== 第一步:错误用例(先行) ========
a.Form(Map{"name": ""}).Post("名称为空-缺少必填字段", 3, "名称不能为空")
// AnyMsg:跳过错误 msg 断言(仅断言 status)。用于响应 msg 含网络错误等
// 动态内容的用例——desc 与实际 msg 不一致时,无 AnyMsg 会因 desc 兜底匹配而失败
a.Form(Map{"name": ""}).AnyMsg().Post("AnyMsg跳过msg断言-desc与实际msg不一致", 3)
// ======== 第二步:正确请求 + 结构校验 + Verify 值断言 ========
a.Form(Map{"name": "测试商品"}).
Verify(func(a *Api) error {
@@ -231,25 +235,6 @@ var ExpectDemoTest = CtrTest{
}).
Post("写入并校验DB+响应", 0, Map{"id": int64(1), "name": "sample"})
// ======== 演示故意失败的 Verify ========
a.Form(Map{"name": "verify_fail"}).
Verify(func(a *Api) error {
result := a.Resp().GetBody().GetMap("result")
if result == nil {
return fmt.Errorf("result 为 nil")
}
if result.GetCeilInt64("id") <= 0 {
return fmt.Errorf("返回的 id 必须大于 0, 实际 %d", result.GetCeilInt64("id"))
}
row := a.DB().Get("test_batch", "*", Map{"AND": Map{"name": "verify_fail", "title": "verify_demo"}})
if row == nil {
return fmt.Errorf("test_batch 表未找到 name=verify_fail 的记录")
}
if row.GetCeilInt64("state") != 99 {
return fmt.Errorf("test_batch.state 期望 99, 实际 %d(接口写入 0,故意校验 99 演示失败效果)", row.GetCeilInt64("state"))
}
return nil
}).
Post("写入并校验DB-故意失败", 0, Map{"id": int64(1), "name": "sample"})
// Verify 返回 error 会使该用例失败并写入 failReason(见覆盖率报告「未通过的接口」)
}},
}
+44
View File
@@ -0,0 +1,44 @@
package app
import (
"fmt"
. "code.hoteas.com/golang/hotime"
. "code.hoteas.com/golang/hotime/common"
)
// DemoFlows 多接口业务流程联调示例(整条 Flow 共用一次测试事务,结束回滚)
var DemoFlows = FlowTest{
"expect_chain": {
Group: "expect_demo",
Desc: "expect_demo 多接口联调",
Prep: "依次调用 string_result 与 map_result。\n步骤通过 FromCase 复用单接口验收用例的请求模板,跑通后回写该用例的响应与通过状态。",
Func: func(f *Flow) {
f.Step("字符串结果", "/app/expect_demo/string_result").
FromCase("result是字符串").
Note("校验字符串 result:期望返回「操作成功」。").
Verify(func(a *Api) error {
if a.Resp().GetBody().GetString("result") != "操作成功" {
return fmt.Errorf("result 期望 操作成功")
}
return nil
}).
Get("步骤1-字符串", 0, "样本")
f.Step("Map结果", "/app/expect_demo/map_result").
FromCase("result是Map-校验字段名+类型+值").
Note("校验对象 result:至少包含 id=1,用于串联后续步骤前的结构确认。").
Verify(func(a *Api) error {
result := a.Resp().GetBody().GetMap("result")
if result == nil {
return fmt.Errorf("result 为 nil")
}
if result.GetCeilInt64("id") != 1 {
return fmt.Errorf("id 期望 1")
}
return nil
}).
Get("步骤2-Map", 0, Map{"id": int64(1), "name": "sample", "price": float64(1.0), "in_stock": true})
},
},
}
+149
View File
@@ -0,0 +1,149 @@
package app
// 通用 CRUD 树表查询(MakeCode.Search 的 parent_id / showself / showall 语义)行为测试。
// 回归背景:parent_id=X 且 showself=1 时旧逻辑拼出 OR(parent_id=X, id=X)
// 把节点 X 自己当作 X 的子级返回,前端树无限嵌套(无限套娃)。
// 依赖 department 树表(表名避开 admin.json 遗留 flow 对 org 的 admin_id 注入;
// 见 setup_mysql.go;表需在 Init 前已存在于测试库)。
import (
"fmt"
. "code.hoteas.com/golang/hotime"
. "code.hoteas.com/golang/hotime/common"
)
// resultIds 提取 result.data 的 id 集合
func resultIds(a *Api) map[int64]bool {
ids := map[int64]bool{}
data := a.Resp().GetBody().GetMap("result").GetSlice("data")
for k := range data {
ids[data.GetMap(k).GetCeilInt64("id")] = true
}
return ids
}
var AdminDepartmentTest = ProjTest{
"department": CtrTest{
"search": {Desc: "树表通用查询:parent_id 子级懒加载不得返回自身", Func: func(a *Api) {
// ======== 错误用例 ========
a.Query(Map{"parent_id": "1", "showself": "1"}).
Get("未登录访问", 2, "你还没有登录")
// ======== 准备树数据:root → nodeX → child ========
adminId := a.DB().Insert("admin", Map{
"name": "树测管理员", "phone": "139" + ObjToStr(RandX(10000000, 99999999)),
"state": 1, "password": Md5("tree123"), "role_id": 1,
"create_time[#]": "NOW()", "modify_time[#]": "NOW()",
})
session := Map{"admin_id": adminId}
rootId := a.DB().Insert("department", Map{
"name": "树测根部门", "state": 0,
"create_time[#]": "NOW()", "modify_time[#]": "NOW()",
})
a.DB().Update("department", Map{"parent_ids": "," + ObjToStr(rootId) + ","}, Map{"id": rootId})
nodeX := a.DB().Insert("department", Map{
"name": "树测节点X", "parent_id": rootId, "state": 0,
"create_time[#]": "NOW()", "modify_time[#]": "NOW()",
})
a.DB().Update("department", Map{"parent_ids": "," + ObjToStr(rootId) + "," + ObjToStr(nodeX) + ","}, Map{"id": nodeX})
childId := a.DB().Insert("department", Map{
"name": "树测子节点", "parent_id": nodeX, "state": 0,
"create_time[#]": "NOW()", "modify_time[#]": "NOW()",
})
a.DB().Update("department", Map{"parent_ids": "," + ObjToStr(rootId) + "," + ObjToStr(nodeX) + "," + ObjToStr(childId) + ","}, Map{"id": childId})
rowSample := Map{"count": int64(1), "data": Slice{Map{"id": int64(1), "name": "sample"}}}
// ======== 核心回归:子级懒加载带 showself 不得包含节点自身 ========
a.WithSession(session).
Note("旧缺陷:OR(parent_id=X, id=X) 把 X 自己当作 X 的子级返回,树无限嵌套").
Query(Map{"parent_id": nodeX, "showself": "1", "pageSize": "50"}).
Verify(func(a *Api) error {
ids := resultIds(a)
if ids[nodeX] {
return fmt.Errorf("子级查询结果不应包含节点自身 id=%d", nodeX)
}
if !ids[childId] {
return fmt.Errorf("子级查询结果应包含直接子级 id=%d", childId)
}
return nil
}).
Get("子级懒加载showself不返回自身", 0, rowSample)
// ======== 不传 showself:同样只返回直接子级 ========
a.WithSession(session).
Query(Map{"parent_id": nodeX, "pageSize": "50"}).
Verify(func(a *Api) error {
ids := resultIds(a)
if ids[nodeX] || !ids[childId] {
return fmt.Errorf("普通子级查询应只含直接子级,实际: %v", ids)
}
return nil
}).
Get("普通子级查询只返回子级", 0, rowSample)
// ======== showall+showself(管理端列表按树筛选场景):含自身与全部子孙 ========
a.WithSession(session).
Note("Table.vue 树筛选场景:showall=1 展示选中节点及全部子孙,showself=1 保留自身").
Query(Map{"parent_id": nodeX, "showself": "1", "showall": "1", "pageSize": "50"}).
Verify(func(a *Api) error {
ids := resultIds(a)
if !ids[nodeX] {
return fmt.Errorf("showall+showself 应包含节点自身 id=%d", nodeX)
}
if !ids[childId] {
return fmt.Errorf("showall+showself 应包含子孙 id=%d", childId)
}
if ids[rootId] {
return fmt.Errorf("showall+showself 不应包含父级 id=%d", rootId)
}
return nil
}).
Get("showall加showself含自身与子孙", 0, rowSample)
// ======== 修复回归:文本字段部分关键词模糊匹配 + 非文本字段等值筛选不受影响 ========
// 背景 bugcode/makecode.go Search):文本类字段(如 name)曾同时生成等值条件
// name=值 与模糊条件 name[~]=值,二者在 where 中以 AND 叠加;前端筛选框传入
// 部分关键词时等值条件必不命中,AND 之后查询恒为空。
// 修复后:文本字段只走模糊匹配;非文本字段(如 state,规则覆盖为 select 类型)维持等值匹配现状。
textId := a.DB().Insert("department", Map{
"name": "综合行政部", "state": 5, "parent_id": nodeX,
"create_time[#]": "NOW()", "modify_time[#]": "NOW()",
})
a.DB().Update("department", Map{"parent_ids": "," + ObjToStr(rootId) + "," + ObjToStr(nodeX) + "," + ObjToStr(textId) + ","}, Map{"id": textId})
a.WithSession(session).
Note("修复前:name 等值(综合行政部≠行政)与模糊[~]同时AND叠加,传部分关键词恒查不到;修复后应命中模糊匹配").
Query(Map{"parent_id": rootId, "showself": "1", "showall": "1", "pageSize": "50", "name": "行政"}).
Verify(func(a *Api) error {
ids := resultIds(a)
if !ids[textId] {
return fmt.Errorf("文本字段部分关键词模糊查询应命中 id=%d, 实际: %v", textId, ids)
}
return nil
}).
Get("文本字段部分关键词命中模糊匹配", 0, rowSample)
a.WithSession(session).
Note("state 规则覆盖为 select 类型(非text),应维持等值匹配现状,只精确命中 state=5 的记录").
Query(Map{"parent_id": rootId, "showself": "1", "showall": "1", "pageSize": "50", "state": "5"}).
Verify(func(a *Api) error {
ids := resultIds(a)
if !ids[textId] {
return fmt.Errorf("非文本字段等值筛选应命中 id=%d, 实际: %v", textId, ids)
}
for id := range ids {
if id != textId {
return fmt.Errorf("非文本字段等值筛选不应包含其它 state 的记录,实际额外命中: id=%d", id)
}
}
return nil
}).
Get("非文本字段等值筛选行为不变", 0, rowSample)
}},
},
}
+20
View File
@@ -83,6 +83,26 @@ func createMySQLTables(db *HoTimeDB, prefix string) {
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4")
}
// department 部门表(树形结构,供通用 CRUD 树查询测试使用;
// 不叫 org 是因为 config/admin.json 遗留 flow 会给 org 注入 admin_id 过滤)
// 注意:MakeCode 路由在 Init 时按库中已有表生成,
// 全新空库首次运行建表晚于 Init/admin/department/search 需第二次运行才可用
tbl = prefix + "department"
if !mysqlTableExists(db, tbl) {
fmt.Println("[MySQL Setup] 创建表:", tbl)
db.Exec("CREATE TABLE `" + tbl + "` (" +
"`id` int(11) unsigned NOT NULL AUTO_INCREMENT," +
"`name` varchar(100) DEFAULT NULL COMMENT '部门名称'," +
"`parent_id` int(11) DEFAULT NULL COMMENT '父级ID'," +
"`parent_ids` varchar(255) DEFAULT NULL COMMENT '层级路径'," +
"`state` int(2) DEFAULT '0'," +
"`create_time` datetime DEFAULT NULL," +
"`modify_time` datetime DEFAULT NULL," +
"PRIMARY KEY (`id`)," +
"KEY `idx_parent_id` (`parent_id`)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门表'")
}
// test_batch 测试批量表
tbl = prefix + "test_batch"
if !mysqlTableExists(db, tbl) {
+3 -2
View File
@@ -145,9 +145,10 @@ func testBasicCRUD(that *Context) Map {
now := nowFunc(that)
insertTest := Map{"name": "Insert 插入测试 (admin表)"}
uniq := time.Now().UnixNano()
adminId := that.Db.Insert("admin", Map{
"name": "测试管理员_" + fmt.Sprintf("%d", time.Now().Unix()),
"phone": fmt.Sprintf("138%d", time.Now().Unix()%100000000),
"name": "测试管理员_" + fmt.Sprintf("%d", uniq),
"phone": fmt.Sprintf("139%08d", uniq%100000000),
"state": 1,
"password": "test123456",
"role_id": 1,
-1
View File
@@ -1 +0,0 @@
{"sessionId":"e1b6ef","hypothesisId":"D","message":"partial_run_check","data":{"proj":"app","totalPaths":30,"visitedPaths":0,"isPartialRun":false}}
-306
View File
@@ -1,306 +0,0 @@
{
"endpoints": [
{
"cases": null,
"ctr": "cache",
"method": "POST",
"params": [],
"path": "/app/cache/all",
"project": "app",
"summary": "缓存全部测试",
"tested": true
},
{
"cases": null,
"ctr": "cache",
"method": "POST",
"params": [],
"path": "/app/cache/batch",
"project": "app",
"summary": "批量缓存测试",
"tested": true
},
{
"cases": null,
"ctr": "cache",
"method": "POST",
"params": [],
"path": "/app/cache/compat",
"project": "app",
"summary": "缓存兼容模式测试",
"tested": true
},
{
"cases": null,
"ctr": "dmdb",
"method": "POST",
"params": [],
"path": "/app/dmdb/describe",
"project": "app",
"summary": "查询达梦表结构",
"tested": true
},
{
"cases": null,
"ctr": "dmdb",
"method": "POST",
"params": [],
"path": "/app/dmdb/rawsql",
"project": "app",
"summary": "达梦原生 SQL 测试",
"tested": true
},
{
"cases": null,
"ctr": "dmdb",
"method": "POST",
"params": [],
"path": "/app/dmdb/tables",
"project": "app",
"summary": "查询达梦所有表",
"tested": true
},
{
"cases": null,
"ctr": "expect_demo",
"method": "POST",
"params": [],
"path": "/app/expect_demo/bool_result",
"project": "app",
"summary": "返回布尔-结构+值校验",
"tested": true
},
{
"cases": null,
"ctr": "expect_demo",
"method": "POST",
"params": [],
"path": "/app/expect_demo/error_demo",
"project": "app",
"summary": "错误先行+正确请求完整校验",
"tested": true
},
{
"cases": null,
"ctr": "expect_demo",
"method": "POST",
"params": [],
"path": "/app/expect_demo/float_result",
"project": "app",
"summary": "返回小数-结构+值校验",
"tested": true
},
{
"cases": null,
"ctr": "expect_demo",
"method": "POST",
"params": [],
"path": "/app/expect_demo/map_result",
"project": "app",
"summary": "返回对象-结构+值校验",
"tested": true
},
{
"cases": null,
"ctr": "expect_demo",
"method": "POST",
"params": [],
"path": "/app/expect_demo/nested_result",
"project": "app",
"summary": "返回嵌套对象-结构+值校验",
"tested": true
},
{
"cases": null,
"ctr": "expect_demo",
"method": "POST",
"params": [],
"path": "/app/expect_demo/no_expect",
"project": "app",
"summary": "最小正确用例-仍需值断言",
"tested": true
},
{
"cases": null,
"ctr": "expect_demo",
"method": "POST",
"params": [],
"path": "/app/expect_demo/number_result",
"project": "app",
"summary": "返回整数-结构+值校验",
"tested": true
},
{
"cases": null,
"ctr": "expect_demo",
"method": "POST",
"params": [],
"path": "/app/expect_demo/slice_result",
"project": "app",
"summary": "返回数组-结构+值校验",
"tested": true
},
{
"cases": null,
"ctr": "expect_demo",
"method": "POST",
"params": [],
"path": "/app/expect_demo/string_result",
"project": "app",
"summary": "返回字符串-结构+值校验",
"tested": true
},
{
"cases": null,
"ctr": "expect_demo",
"method": "POST",
"params": [],
"path": "/app/expect_demo/verify_demo",
"project": "app",
"summary": "Verify完整范式演示",
"tested": true
},
{
"cases": null,
"ctr": "mysql",
"method": "POST",
"params": [],
"path": "/app/mysql/describe",
"project": "app",
"summary": "查询 MySQL 表结构",
"tested": true
},
{
"cases": null,
"ctr": "mysql",
"method": "POST",
"params": [],
"path": "/app/mysql/rawsql",
"project": "app",
"summary": "MySQL 原生 SQL 测试",
"tested": true
},
{
"cases": null,
"ctr": "mysql",
"method": "POST",
"params": [],
"path": "/app/mysql/tables",
"project": "app",
"summary": "查询 MySQL 所有表",
"tested": true
},
{
"cases": null,
"ctr": "test",
"method": "POST",
"params": [],
"path": "/app/test/aggregate",
"project": "app",
"summary": "聚合函数测试",
"tested": true
},
{
"cases": null,
"ctr": "test",
"method": "POST",
"params": [],
"path": "/app/test/all",
"project": "app",
"summary": "运行全部通用测试",
"tested": true
},
{
"cases": null,
"ctr": "test",
"method": "POST",
"params": [],
"path": "/app/test/batch",
"project": "app",
"summary": "批量插入测试",
"tested": true
},
{
"cases": null,
"ctr": "test",
"method": "POST",
"params": [],
"path": "/app/test/chain",
"project": "app",
"summary": "链式查询测试",
"tested": true
},
{
"cases": null,
"ctr": "test",
"method": "POST",
"params": [],
"path": "/app/test/condition",
"project": "app",
"summary": "条件查询语法测试",
"tested": true
},
{
"cases": null,
"ctr": "test",
"method": "POST",
"params": [],
"path": "/app/test/crud",
"project": "app",
"summary": "基础 CRUD 测试",
"tested": true
},
{
"cases": null,
"ctr": "test",
"method": "POST",
"params": [],
"path": "/app/test/join",
"project": "app",
"summary": "JOIN 查询测试",
"tested": true
},
{
"cases": null,
"ctr": "test",
"method": "POST",
"params": [],
"path": "/app/test/pagination",
"project": "app",
"summary": "分页查询测试",
"tested": true
},
{
"cases": null,
"ctr": "test",
"method": "POST",
"params": [],
"path": "/app/test/test",
"project": "app",
"summary": "固定错误返回",
"tested": true
},
{
"cases": null,
"ctr": "test",
"method": "POST",
"params": [],
"path": "/app/test/transaction",
"project": "app",
"summary": "事务测试",
"tested": true
},
{
"cases": null,
"ctr": "test",
"method": "POST",
"params": [],
"path": "/app/test/upsert",
"project": "app",
"summary": "Upsert 测试",
"tested": true
}
],
"title": "My API",
"version": "1.0.0"
}
-535
View File
@@ -1,535 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="utf-8"><title>API 调试控制台</title>
<style>
:root{--bg:#1a1a2e;--bg2:#16213e;--bg3:#0f3460;--fg:#e0e0e0;--fg2:#888;--accent:#4fc3f7;--green:#66bb6a;--red:#ef5350;--border:#2a2a4a;--r:12px}
*{box-sizing:border-box;margin:0;padding:0}body{display:flex;height:100vh;background:var(--bg);color:var(--fg);font:13px/1.5 -apple-system,sans-serif}
input,select,textarea,button{font:inherit}pre{margin:0}
#side{width:260px;min-width:260px;display:flex;flex-direction:column;border-right:1px solid var(--border);overflow:hidden}
.shd{padding:10px;border-bottom:1px solid var(--border)}.shd h3{font-size:13px;color:var(--accent);margin-bottom:6px}
.shd input{width:100%;padding:5px 8px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg2);color:var(--fg);font-size:12px;outline:none}
.shd input:focus{border-color:var(--accent)}.sft{display:flex;gap:2px;margin-top:5px}
.sft button{flex:1;padding:3px;border:1px solid var(--border);border-radius:var(--r);background:transparent;color:var(--fg2);font-size:11px;cursor:pointer}
.sft button.on{background:var(--bg3);color:var(--accent);border-color:var(--accent)}
#tree{flex:1;overflow-y:auto;min-height:0}#tree::-webkit-scrollbar{width:4px}#tree::-webkit-scrollbar-thumb{background:#444;border-radius:2px}
.l1{border-bottom:1px solid var(--border)}.l1h,.l2h{display:flex;align-items:center;cursor:pointer}
.l1h{padding:5px 8px;font-weight:700;color:var(--accent);font-size:13px}.l1h:hover,.l2h:hover{background:var(--bg2)}
.l2h{padding:3px 8px 3px 20px;color:#81d4fa;font-size:12px}
.ar{margin-right:4px;font-size:8px;transition:transform .1s;color:#555}.ar.o{transform:rotate(90deg);color:var(--accent)}
.cn{margin-left:auto;font-size:10px;color:#888;background:rgba(255,255,255,.06);padding:0 5px;border-radius:8px;min-width:18px;text-align:center}.sub{display:none}.sub.o{display:block}
.ep{display:flex;align-items:center;padding:3px 6px 3px 32px;cursor:pointer;gap:4px;color:var(--fg2)}
.ep:hover{background:var(--bg2);color:var(--fg)}.ep.act{background:var(--bg3);color:var(--accent)}
.ep .nm{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.mt{font-size:9px;padding:1px 4px;border-radius:2px;font-weight:700;flex-shrink:0}
.mt-get{background:#1b5e20;color:#a5d6a7}.mt-post{background:#0d47a1;color:#90caf9}.mt-put{background:#e65100;color:#ffcc80}.mt-delete{background:#b71c1c;color:#ef9a9a}
.tg{font-size:9px;padding:1px 4px;border-radius:2px;flex-shrink:0}
.tg-ok{background:#1b5e20;color:#a5d6a7}.tg-err{background:#b71c1c;color:#ef9a9a}.tg-no{background:#333;color:#555}
#main{flex:1;display:flex;flex-direction:column;overflow:hidden}
#bar{padding:5px 12px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:6px;flex-shrink:0}
#bar label{color:var(--fg2);font-size:11px}
#bar select,#bar input{padding:3px 6px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg2);color:var(--fg);font-size:12px;outline:none}
#bar input{flex:1;max-width:280px}#bar select{width:auto}
#ct{flex:1;display:flex;flex-direction:column;overflow:hidden;padding:0}
.ct-hd{padding:6px 12px;border-bottom:1px solid var(--border);flex-shrink:0}
.ehd{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.ehd .mt{font-size:13px;padding:3px 10px}.ehd .pa{font-size:15px;font-weight:600}.ehd .ds{color:var(--fg2);font-size:13px}
.ehd .nt{color:var(--red);font-size:11px;padding:2px 6px;border:1px solid var(--red);border-radius:var(--r)}
.case-cnt{font-size:11px;color:var(--fg2);padding:1px 8px;border:1px solid var(--border);border-radius:10px;white-space:nowrap;flex-shrink:0}
.pf-wrap{position:relative;flex:1;min-width:0}.pf-btn{width:100%;display:flex;align-items:center;gap:6px;padding:4px 8px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg2);cursor:pointer;font-size:12px;color:var(--fg);outline:none;box-sizing:border-box}.pf-btn:hover{border-color:#555}.pf-dd{position:absolute;top:calc(100% + 2px);left:0;right:0;background:#161625;border:1px solid var(--border);border-radius:var(--r);z-index:200;list-style:none;padding:3px 0;margin:0;max-height:200px;overflow-y:auto;display:none;box-shadow:0 4px 12px rgba(0,0,0,.5)}.pf-dd.on{display:block}.pf-opt{display:flex;align-items:center;gap:6px;padding:5px 10px;cursor:pointer;font-size:12px}.pf-opt:hover{background:var(--bg3)}.pf-opt.sel{color:var(--accent)}
.split{display:flex;flex:1;min-height:0;overflow:hidden}
.split-l{width:560px;min-width:220px;overflow-y:auto;padding:10px 12px;border-right:1px solid var(--border);flex-shrink:0}
.split-l::-webkit-scrollbar{width:4px}.split-l::-webkit-scrollbar-thumb{background:#444;border-radius:2px}
.split-r{flex:1;display:flex;flex-direction:column;min-width:0;overflow:hidden}
.rtabs{display:flex;border-bottom:1px solid var(--border);flex-shrink:0;padding:0 4px}
.rtab{padding:6px 14px;cursor:pointer;color:var(--fg2);border-bottom:2px solid transparent;font-size:12px}
.rtab:hover{color:var(--fg)}.rtab.on{color:var(--accent);border-color:var(--accent)}
.rpn{display:none;overflow-y:auto;padding:12px;flex:1}.rpn.on{display:block}
.rpn::-webkit-scrollbar{width:4px}.rpn::-webkit-scrollbar-thumb{background:#444;border-radius:2px}
.cs{border:1px solid var(--border);border-radius:var(--r);margin-bottom:6px;overflow:hidden}
.csh{padding:6px 10px;display:flex;align-items:center;gap:6px;cursor:pointer;font-size:12px;background:var(--bg2)}
.csh:hover{background:var(--bg3)}.dot{display:inline-block;width:8px;height:8px;border-radius:50%;flex-shrink:0}
.dot.ok{background:var(--green)}.dot.er{background:var(--red)}.csh .ml{margin-left:auto;color:#555;font-size:11px}
.csb{display:none;border-top:1px solid var(--border);font-size:12px}.csb.o{display:block}
.csr{display:flex}.csr>.csc{flex:1;padding:8px 10px;min-width:0}.csr>.csc+.csc{border-left:1px solid var(--border)}
.csc h5{color:var(--accent);margin:0 0 4px;font-size:11px;letter-spacing:.5px;display:flex;align-items:center}
.csc h5 .cp-sm{margin-left:auto}.csc h5:not(:first-child){margin-top:8px}
pre.j{background:#0a0a1a;padding:6px 8px;border-radius:var(--r);font-size:12px;line-height:1.4;color:#aaa;white-space:pre-wrap;word-break:break-all;max-height:260px;overflow:auto;font-family:Consolas,Monaco,monospace}
.resp-wrap{display:flex;gap:0}.resp-main{flex:1;min-width:0}
.resp-main .rsp-hd{display:flex;align-items:center;margin-bottom:3px;font-size:10px;color:var(--fg2);letter-spacing:.5px}
.resp-main .rsp-hd .cp-sm{margin-left:auto}
.exp-col-btn{flex-shrink:0;width:36px;display:flex;align-items:stretch;justify-content:center;margin:0 2px}
.exp-col-btn button{writing-mode:vertical-rl;background:var(--accent);border:none;color:#fff;font-size:10px;padding:6px 3px;border-radius:var(--r) 0 0 var(--r);cursor:pointer;user-select:none;line-height:1.2;letter-spacing:1px;opacity:.85}
.exp-col-btn button:hover{opacity:1}
.exp-panel{flex:1;min-width:0;display:none;padding-left:6px}
.resp-wrap.exp-on .exp-panel{display:block}
.exp-panel .ep-hd{display:flex;align-items:center;margin-bottom:3px;font-size:10px;color:var(--fg2);letter-spacing:.5px}
.exp-panel .ep-hd .cp-sm{margin-left:auto}
.exp-panel pre.j{border-left:2px solid var(--accent)}
pre.j.resp-j{max-height:calc(100vh - 280px);min-height:120px}
.verify-err{margin-top:8px;padding:8px 10px;background:rgba(255,77,79,0.08);border:1px solid rgba(255,77,79,0.3);border-radius:var(--r);font-size:12px;color:#ff6b6b;line-height:1.5;white-space:pre-wrap;word-break:break-all}
.verify-label{display:inline-block;background:var(--red);color:#fff;font-size:10px;padding:1px 6px;border-radius:var(--r);margin-right:8px;vertical-align:middle}
.sec{margin-top:10px;padding-top:10px;border-top:1px solid var(--border)}.sec-title{color:var(--fg2);font-size:11px;letter-spacing:.5px;margin-bottom:4px}
.row-hd{display:flex;align-items:center;margin-bottom:4px;padding:4px 8px;background:var(--bg2);border-radius:var(--r)}.row-hd .sec-title{flex:1;margin:0}
.kv{display:flex;gap:4px;margin-bottom:3px;align-items:center}.kv input{flex:1;padding:4px 6px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg2);color:var(--fg);font-size:12px;outline:none}
.kv input[type="file"]{padding:3px 4px}
.kv input:focus{border-color:var(--accent)}.kv button{padding:2px 8px;background:var(--bg3);border:1px solid var(--border);color:var(--red);border-radius:var(--r);cursor:pointer}
.addbtn{padding:2px 10px;background:var(--bg2);border:1px solid var(--border);border-radius:var(--r);color:var(--fg);cursor:pointer;font-size:10px;white-space:nowrap}
.addbtn:hover{border-color:var(--accent);color:var(--accent)}
.sbtn{padding:6px 18px;background:var(--accent);color:var(--bg);border:none;border-radius:var(--r);cursor:pointer;font-size:12px;font-weight:600;white-space:nowrap}
.sbtn:hover{opacity:.9}.sbtn:disabled{opacity:.4;cursor:not-allowed}
.curl{background:#0a0a1a;padding:6px 8px;border-radius:var(--r);font-size:11px;color:#888;white-space:pre-wrap;word-break:break-all;font-family:Consolas,Monaco,monospace}
.cp-sm{padding:1px 6px;background:var(--bg3);border:1px solid var(--border);border-radius:var(--r);color:var(--fg2);cursor:pointer;font-size:10px}
.cp-sm:hover{color:var(--accent);border-color:var(--accent)}
.pc{border:1px solid var(--border);border-radius:var(--r);margin-bottom:6px;overflow:hidden}
.pc-hd{padding:3px 8px;background:var(--bg2);color:var(--fg2);font-size:10px;letter-spacing:.5px;border-bottom:1px solid var(--border)}.pc-bd{padding:6px 8px}
.kv-ro{display:flex;gap:4px;margin-bottom:3px;align-items:center}
.kv-ro .kk{min-width:80px;max-width:140px;padding:4px 6px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg3);color:var(--accent);font-size:12px;word-break:break-all}
.kv-ro .vv{flex:1;padding:4px 6px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg2);color:var(--fg);font-size:12px;word-break:break-all}
.bd-acc{border:1px solid var(--border);border-radius:var(--r);overflow:hidden}
.bd-hd{padding:6px 8px;cursor:pointer;display:flex;align-items:center;gap:5px;color:var(--fg2);font-size:11px;background:var(--bg2);user-select:none}
.bd-hd+.bd-hd,.bd-bd+.bd-hd{border-top:1px solid var(--border)}
.bd-hd.on{color:var(--accent)}.bd-hd .ar{font-size:8px;transition:transform .15s;color:#555;margin-right:2px}.bd-hd.on .ar{transform:rotate(90deg);color:var(--accent)}
.bd-hd .addbtn{margin-left:auto}
.bd-bd{display:none;padding:8px;border-top:1px solid var(--border)}.bd-bd.on{display:block}
.bd-bd textarea{width:100%;min-height:36px;max-height:40vh;padding:5px 8px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg2);color:var(--fg);font-size:12px;font-family:Consolas,Monaco,monospace;resize:vertical;outline:none;overflow-y:auto}
.bd-bd textarea:focus{border-color:var(--accent)}
.req-star{color:var(--red);font-size:10px;font-weight:700;margin-right:2px;flex-shrink:0;line-height:1.2;align-self:center}
.jp-panel{margin-bottom:6px;border:1px solid var(--border);border-radius:var(--r);overflow:hidden}
.jp-hd{padding:3px 8px;background:var(--bg2);cursor:pointer;display:flex;align-items:center;font-size:11px;color:var(--fg2);user-select:none}
.jp-bd{padding:6px 8px;display:flex;flex-wrap:wrap;gap:4px;border-top:1px solid var(--border)}
.jp-tag{font-size:11px;padding:1px 6px;background:var(--bg3);border:1px solid var(--border);border-radius:var(--r);color:var(--fg2);display:inline-flex;align-items:center;gap:2px}
.jp-req{color:var(--accent);border-color:rgba(79,195,247,.3)}
.jp-type{color:#555;font-size:10px}
</style></head><body>
<div id="side">
<div class="shd">
<h3 id="dt"></h3>
<input id="q" placeholder="搜索接口..." oninput="render()">
<div class="sft">
<button class="on" onclick="sf('all',this)">全部</button>
<button onclick="sf('yes',this)">已测试</button>
<button onclick="sf('no',this)">未测试</button>
<button onclick="sf('pass',this)">通过</button>
<button onclick="sf('fail',this)">未通过</button>
</div>
</div>
<div id="tree"></div>
</div>
<div id="main">
<div id="bar">
<label>认证</label>
<select id="at" onchange="syncAuth()"><option value="none">无认证</option><option value="header">Header (Authorization)</option><option value="query">URL (?token=)</option><option value="cookie">Cookie</option></select>
<input id="tk" placeholder="Token(登录后返回的 32 位字符串)" oninput="syncAuth()">
</div>
<div id="ct"><div style="text-align:center;color:var(--fg2);padding:80px 20px">&#8592; 选择一个接口开始</div></div>
</div>
<script>
let D=null,F='all',C=null;
fetch('./api-spec.json').then(r=>r.json()).then(d=>{
D=d;document.getElementById('dt').textContent=d.title+' v'+d.version;document.title=d.title;render();
});
function epPass(e){if(!e.cases?.length)return false;return e.cases.every(x=>x.passed);}
function epFail(e){if(!e.cases?.length)return e.tested;return e.cases.some(x=>!x.passed);}
function render(){
if(!D)return;const q=document.getElementById('q').value.toLowerCase();const T={};
for(const e of D.endpoints){
if(F==='yes'&&!e.tested)continue;
if(F==='no'&&e.tested)continue;
if(F==='pass'&&!epPass(e))continue;
if(F==='fail'&&!epFail(e))continue;
if(q&&!e.summary.toLowerCase().includes(q)&&!e.path.toLowerCase().includes(q)&&!e.ctr.toLowerCase().includes(q))continue;
if(!T[e.project])T[e.project]={};if(!T[e.project][e.ctr])T[e.project][e.ctr]=[];T[e.project][e.ctr].push(e);
}
let h='';
for(const p of Object.keys(T).sort()){let ph='';
for(const c of Object.keys(T[p]).sort()){const es=T[p][c];let eh='';
for(const e of es){const m=e.method.toLowerCase();
let tg='';if(!e.tested)tg='<span class="tg tg-no">未测试</span>';
else if(e.cases?.length){const pc=e.cases.filter(x=>x.passed).length;tg=pc===e.cases.length?'<span class="tg tg-ok">'+pc+'/'+e.cases.length+'</span>':'<span class="tg tg-err">'+pc+'/'+e.cases.length+'</span>';}
const mn=e.path.split('/').pop();const lbl=e.tested&&e.summary!==mn?mn+' '+e.summary:e.summary;
eh+='<div class="ep'+(C?.path===e.path?' act':'')+'" data-p="'+e.path+'" onclick="go(this)"><span class="mt mt-'+m+'">'+e.method+'</span><span class="nm" title="'+e.path+'">'+lbl+'</span>'+tg+'</div>';
}
ph+='<div class="l2h" onclick="tog(this)"><span class="ar">&#9654;</span>'+c+'<span class="cn">'+es.length+'</span></div><div class="sub">'+eh+'</div>';
}
const totalEps=Object.values(T[p]).reduce((s,es)=>s+es.length,0);
h+='<div class="l1"><div class="l1h" onclick="tog(this)"><span class="ar o">&#9654;</span>'+p+'<span class="cn">'+totalEps+'</span></div><div class="sub o">'+ph+'</div></div>';
}
document.getElementById('tree').innerHTML=h||'<div style="padding:30px;color:#555;text-align:center">无匹配</div>';
}
function sf(f,b){F=f;document.querySelectorAll('.sft button').forEach(x=>x.classList.remove('on'));b.classList.add('on');render();}
function tog(e){e.querySelector('.ar').classList.toggle('o');e.nextElementSibling.classList.toggle('o');}
function go(el){C=D.endpoints.find(e=>e.path===el.dataset.p);if(!C)return;document.querySelectorAll('.ep').forEach(e=>e.classList.remove('act'));el.classList.add('act');show();}
function show(){
const e=C;if(!e)return;const m=e.method.toLowerCase();const cnt=e.cases?.length||0;
let h='<div class="ct-hd"><div class="ehd">';
h+='<span class="mt mt-'+m+'" style="font-size:13px;padding:3px 8px">'+e.method+'</span>';
h+='<span class="pa">'+esc(e.path)+'</span><span class="ds">'+esc(e.summary)+'</span>';
if(cnt)h+='<span class="case-cnt">'+cnt+' 用例</span>';
if(!e.tested)h+='<span class="nt">未测试</span>';
h+='</div></div>';
h+='<div class="split">';
h+='<div class="split-l">'+buildLeft(e)+'</div>';
h+='<div class="split-r">';
h+='<div class="rtabs"><div class="rtab on" id="rt0" onclick="rswitch(0)">用例结果</div><div class="rtab" id="rt1" onclick="rswitch(1)">发送结果</div></div>';
h+='<div class="rpn on" id="rp0"><div id="case-panel" style="padding:4px 0"></div></div>';
h+='<div class="rpn" id="rp1">'+respPanel()+'</div>';
h+='</div></div>';
document.getElementById('ct').innerHTML=h;
buildPfList(e);
let defIdx=-1;
if(cnt>0){for(let i=e.cases.length-1;i>=0;i--){if(e.cases[i].passed){defIdx=i;break;}}if(defIdx<0)defIdx=e.cases.length-1;}
pfSelect(defIdx>=0?defIdx:'');
}
function rswitch(i){
document.querySelectorAll('.rtab').forEach((t,j)=>t.classList.toggle('on',j===i));
document.querySelectorAll('.rpn').forEach((p,j)=>p.classList.toggle('on',j===i));
}
function caseCurl(e,c){
const base=window.location.origin;
let url=base+e.path;
const m=c.method||'POST';
if(c.query){const qs=Object.keys(c.query).filter(k=>c.query[k]!==undefined&&c.query[k]!=='').map(k=>encodeURIComponent(k)+'='+encodeURIComponent(c.query[k])).join('&');if(qs)url+='?'+qs;}
let cmd="curl -X "+m+" '"+url+"'";
if(c.json){cmd+="\\\n -H 'Content-Type: application/json'";cmd+="\\\n -d '"+JSON.stringify(c.json).replace(/'/g,"'\\''")+"'";}
else if(c.form){const fd=Object.keys(c.form).map(k=>"-F '"+k+"="+c.form[k]+"'").join("\\\n ");if(fd)cmd+="\\\n "+fd;}
return cmd;
}
function cases(e){
if(!e.cases?.length)return '<div style="color:#555;padding:20px">暂无测试用例</div>';
let h='';
for(let i=0;i<e.cases.length;i++){const c=e.cases[i];
h+='<div class="cs"><div class="csh" onclick="accordion(this)"><span class="ar'+(i===0?' o':'')+'">&#9654;</span><span class="dot '+(c.passed?'ok':'er')+'"></span>'+esc(c.name)+'<span class="ml">'+c.method+'</span></div>';
h+='<div class="csb'+(i===0?' o':'')+'"><div style="padding:8px 10px">';
if(c.note)h+='<div style="color:#aaa;font-size:12px;margin-bottom:8px;line-height:1.5">备注: '+esc(c.note)+'</div>';
if(!c.passed&&c.failReason)h+='<div class="verify-err"><span class="verify-label">失败原因</span>'+esc(c.failReason)+'</div>';
else if(c.verifyError)h+='<div class="verify-err"><span class="verify-label">Verify 校验失败</span>'+esc(c.verifyError)+'</div>';
const curlId='cc'+i;const respId='cr'+i;
h+='<div class="row-hd" style="margin-bottom:4px"><span class="sec-title">cURL</span><button class="cp-sm" onclick="event.stopPropagation();copyEl(\''+curlId+'\')">复制</button></div>';
h+='<div class="curl" id="'+curlId+'">'+esc(caseCurl(e,c))+'</div>';
const hasDetail=c.expect&&c.expect.result!==undefined;const expId='ce'+i;
h+='<div class="row-hd" style="margin:8px 0 4px"><span class="sec-title">响应</span><span class="'+(c.passed?'tg tg-ok':'tg tg-err')+'" style="margin-left:6px">'+(c.passed?'通过':'失败')+'</span></div>';
if(hasDetail){const vis=getExpVis();h+='<div class="resp-wrap'+(vis?' exp-on':'')+'">';
h+='<div class="resp-main"><div class="rsp-hd"><span>实际响应</span><button class="cp-sm" onclick="event.stopPropagation();copyEl(\''+respId+'\')">复制</button></div>';if(c.response)h+='<pre class="j resp-j" id="'+respId+'">'+fj(c.response)+'</pre>';else h+='<span style="color:#555" id="'+respId+'">无响应</span>';h+='</div>';
h+='<div class="exp-col-btn"><button class="exp-btn" style=" width: 100%; box-sizing: border-box; " onclick="event.stopPropagation();toggleExpect()">'+(vis?'▶ 收起 预期响应':'◀ 展开 预期响应')+'</button></div>';
h+='<div class="exp-panel"><div class="ep-hd"><span>预期响应</span><button class="cp-sm" onclick="event.stopPropagation();copyEl(\''+expId+'\')">复制</button></div><pre class="j resp-j" id="'+expId+'">'+fj(c.expect)+'</pre></div>';
h+='</div>';}
else{if(c.response)h+='<pre class="j" id="'+respId+'">'+fj(c.response)+'</pre>';else h+='<span style="color:#555" id="'+respId+'">无响应</span>';}
h+='</div></div></div>';
}
return h;
}
function accordion(el){
const bd=el.nextElementSibling;const wasOpen=bd.classList.contains('o');
const panel=el.closest('.rpn');
panel.querySelectorAll('.csb').forEach(b=>b.classList.remove('o'));
panel.querySelectorAll('.csh .ar').forEach(a=>a.classList.remove('o'));
if(!wasOpen){bd.classList.add('o');el.querySelector('.ar').classList.add('o');}
}
function respPanel(){
let r='';
r+='<div id="note-box" style="display:none;color:#aaa;font-size:12px;margin-bottom:8px;line-height:1.5"></div>';
r+='<div class="row-hd" style="margin-bottom:4px"><span class="sec-title">cURL</span><button class="cp-sm" onclick="copyEl(\'curl-box\')">复制</button></div>';
r+='<div class="curl" id="curl-box">发送请求后生成</div>';
r+='<div class="row-hd" style="margin:10px 0 4px"><span class="sec-title">响应 <span id="resp-st"></span></span><button class="cp-sm" onclick="copyEl(\'resp-body\')">复制</button></div>';
r+='<pre class="j" id="resp-body" style="min-height:60px;max-height:calc(100vh - 200px)">发送请求后显示</pre>';
return r;
}
function buildLeft(e){
const hasForm=e.cases?.some(c=>c.form)||e.params?.some(p=>p.in==='form');
const defBody=hasForm?'form':'json';
const tk=document.getElementById('tk').value||'';
const at=document.getElementById('at').value;
let l='';
l+='<div style="display:flex;align-items:center;gap:6px;margin-bottom:10px">';
l+='<span style="color:var(--fg2);font-size:11px;white-space:nowrap">测试用例选择</span>';
l+='<div class="pf-wrap"><button class="pf-btn" id="pf-btn" onclick="togglePfDd(event)"><span class="dot" id="pf-dot" style="display:none;flex-shrink:0"></span><span id="pf-label" style="flex:1;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--fg2)">选择用例...</span><span style="color:var(--fg2);font-size:10px;flex-shrink:0">▾</span></button><ul class="pf-dd" id="pf-dd"></ul></div>';
l+='<button class="sbtn" onclick="send()" id="sbtn">发送</button>';
l+='</div>';
let defH='<div class="kv"><input value="Content-Type"><input id="ct-val" value="'+(defBody==='json'?'application/json':'application/x-www-form-urlencoded')+'"><button onclick="this.parentElement.remove()">&#215;</button></div>';
if(tk&&at==='header')defH+='<div class="kv" data-auth="1"><input value="Authorization"><input value="'+esc(tk)+'"><button onclick="this.parentElement.remove()">&#215;</button></div>';
if(tk&&at==='cookie')defH+='<div class="kv" data-auth="1"><input value="Cookie"><input value="HOTIME='+esc(tk)+'"><button onclick="this.parentElement.remove()">&#215;</button></div>';
l+='<div class="sec" style="margin-top:0"><div class="row-hd"><span class="sec-title">请求头</span><button class="addbtn" onclick="addKV(\'h-rows\')">+ 添加</button></div><div id="h-rows">'+defH+'</div></div>';
let qDef='';
if(tk&&at==='query')qDef='<div class="kv" data-auth="1"><input value="token"><input value="'+esc(tk)+'"><button onclick="this.parentElement.remove()">&#215;</button></div>';
qDef+='<div class="kv"><input placeholder="key"><input placeholder="value"><button onclick="this.parentElement.remove()">&#215;</button></div>';
l+='<div class="sec"><div class="row-hd"><span class="sec-title">Query 参数</span><button class="addbtn" onclick="addKV(\'q-rows\')">+ 添加</button></div><div id="q-rows">'+qDef+'</div></div>';
l+='<div class="sec"><div class="row-hd"><span class="sec-title">文件上传</span><button class="addbtn" onclick="addFileRow()">+ 添加</button></div><div id="file-rows"><div class="kv"><input placeholder="字段名" value="" style="max-width:120px"><input type="file"><button onclick="this.parentElement.remove()">&#215;</button></div></div></div>';
let jsonPH='';
if(e.params){
const jp=e.params.filter(p=>p.in==='json');
if(jp.length){const obj={};for(const p of jp)obj[(p.required?'*':'')+p.name]=p.example!==undefined?p.example:'';jsonPH=JSON.stringify(obj,null,2);}
}
l+='<div class="sec"><div class="sec-title" style="margin-bottom:4px">请求体</div><div class="bd-acc">';
l+='<div class="bd-hd'+(defBody==='form'?' on':'')+'" data-t="form" onclick="togBody(\'form\')"><span class="ar'+(defBody==='form'?' o':'')+'">&#9654;</span>表单 (Form)<button class="addbtn" onclick="event.stopPropagation();setAcc(\'form\');addKV(\'f-rows\')">+ 添加</button></div>';
l+='<div id="bt-form" class="bd-bd'+(defBody==='form'?' on':'')+'"><div id="f-rows"><div class="kv"><input placeholder="key"><input placeholder="value"><button onclick="this.parentElement.remove()">&#215;</button></div></div></div>';
l+='<div class="bd-hd'+(defBody==='json'?' on':'')+'" data-t="json" onclick="togBody(\'json\')"><span class="ar'+(defBody==='json'?' o':'')+'">&#9654;</span>JSON</div>';
l+='<div id="bt-json" class="bd-bd'+(defBody==='json'?' on':'')+'">';
if(e.params){
const jp=e.params.filter(p=>p.in==='json');
if(jp.length){
const jpVis=localStorage.getItem('json_params_vis')!=='0';
l+='<div class="jp-panel"><div class="jp-hd" onclick="togJP()"><span class="ar jp-ar'+(jpVis?' o':'')+'">&#9654;</span><span>JSON 参数</span><span style="margin-left:auto;font-size:10px" class="jp-toggle">'+(jpVis?'收起':'展开')+'</span></div>';
l+='<div class="jp-bd"'+(jpVis?'':' style="display:none"')+'>';
for(const p of jp){
const star=p.required?'<span class="req-star">*</span>':'';
const cls=p.required?'jp-tag jp-req':'jp-tag';
l+=star+'<span class="'+cls+'">'+esc(p.name);
if(p.type)l+=' <span class="jp-type">'+p.type+'</span>';
l+='</span>';
}
l+='</div></div>';
}
}
l+='<textarea id="xb" oninput="autoH(this)" placeholder="'+(jsonPH?esc(jsonPH):'{&quot;key&quot;:&quot;value&quot;}')+'"></textarea></div>';
l+='</div></div>';
return l;
}
function addKV(id,k,v){
const row=document.createElement('div');row.className='kv';
row.innerHTML='<input placeholder="key" value="'+esc(k||'')+'"><input placeholder="value" value="'+esc(v||'')+'"><button onclick="this.parentElement.remove()">&#215;</button>';
document.getElementById(id).appendChild(row);
}
function addKVReq(id,k,v,req){
const row=document.createElement('div');row.className='kv';
const star=req?'<span class="req-star">*</span>':'';
row.innerHTML=star+'<input placeholder="key" value="'+esc(k||'')+'"><input placeholder="value" value="'+esc(v||'')+'"><button onclick="this.parentElement.remove()">&#215;</button>';
document.getElementById(id).appendChild(row);
}
function addFileRow(field){
const row=document.createElement('div');row.className='kv';
row.innerHTML='<input placeholder="字段名" value="'+esc(field||'')+'" style="max-width:120px"><input type="file"><button onclick="this.parentElement.remove()">&#215;</button>';
document.getElementById('file-rows').appendChild(row);
}
function setAcc(t){
document.querySelectorAll('.bd-hd').forEach(h=>{h.classList.toggle('on',h.dataset.t===t);const a=h.querySelector('.ar');if(a)a.classList.toggle('o',h.dataset.t===t);});
document.getElementById('bt-json').classList.toggle('on',t==='json');
document.getElementById('bt-form').classList.toggle('on',t==='form');
const ctVal=document.getElementById('ct-val');
if(ctVal)ctVal.value=t==='json'?'application/json':'application/x-www-form-urlencoded';
}
function togBody(t){
const cur=document.getElementById(t==='json'?'bt-json':'bt-form').classList.contains('on');
setAcc(cur?(t==='json'?'form':'json'):t);
}
function autoH(el){el.style.height='36px';el.style.height=Math.min(el.scrollHeight,window.innerHeight*0.4)+'px';}
function syncAuth(){
const tk=document.getElementById('tk').value;const at=document.getElementById('at').value;
const hr=document.getElementById('h-rows');const qr=document.getElementById('q-rows');
if(!hr||!qr)return;
hr.querySelectorAll('[data-auth]').forEach(e=>e.remove());
qr.querySelectorAll('[data-auth]').forEach(e=>e.remove());
if(!tk)return;
function mkAuth(k,v,parent){const d=document.createElement('div');d.className='kv';d.dataset.auth='1';d.innerHTML='<input value="'+esc(k)+'"><input value="'+esc(v)+'"><button onclick="this.parentElement.remove()">&#215;</button>';parent.insertBefore(d,parent.querySelector('.kv:last-child'));}
if(at==='header')mkAuth('Authorization',tk,hr);
if(at==='cookie')mkAuth('Cookie','HOTIME='+tk,hr);
if(at==='query')mkAuth('token',tk,qr);
}
function buildPfList(e){
const dd=document.getElementById('pf-dd');if(!dd)return;
dd.innerHTML='';
if(e.cases){
for(let i=e.cases.length-1;i>=0;i--){
const c=e.cases[i];
const li=document.createElement('li');li.className='pf-opt';li.dataset.idx=String(i);
li.innerHTML='<span class="dot '+(c.passed?'ok':'er')+'"></span><span>'+esc(c.name)+'</span>';
li.onclick=ev=>{ev.stopPropagation();pfSelect(i);};
dd.appendChild(li);
}
}
const manual=document.createElement('li');manual.className='pf-opt';manual.dataset.idx='';
manual.innerHTML='<span style="color:var(--fg2);font-style:italic">-- 手动填写 --</span>';
manual.onclick=ev=>{ev.stopPropagation();pfSelect('');};
dd.appendChild(manual);
}
function togglePfDd(ev){ev.stopPropagation();const dd=document.getElementById('pf-dd');if(dd)dd.classList.toggle('on');}
document.addEventListener('click',()=>{document.getElementById('pf-dd')?.classList.remove('on');});
function pfSelect(i){
const e=C;if(!e)return;
const idxStr=String(i);
document.querySelectorAll('.pf-opt').forEach(el=>el.classList.toggle('sel',el.dataset.idx===idxStr));
const c=(i!==''&&e.cases?.[i])?e.cases[i]:null;
const dot=document.getElementById('pf-dot');
const lbl=document.getElementById('pf-label');
if(dot&&lbl){
if(c){dot.style.display='';dot.className='dot '+(c.passed?'ok':'er');lbl.style.color='var(--fg)';lbl.textContent=c.name;}
else{dot.style.display='none';lbl.style.color='var(--fg2)';lbl.textContent='-- 手动填写 --';}
}
document.getElementById('pf-dd')?.classList.remove('on');
updateCasePanel(e,i);
pfFill(i);
}
function updateCasePanel(e,i){
const panel=document.getElementById('case-panel');if(!panel)return;
if(i===''||!e?.cases?.[i]){panel.innerHTML='<div style="color:#555;padding:16px 10px">选择一个测试用例查看响应</div>';return;}
const c=e.cases[i];
let h='<div style="padding:8px 10px">';
if(c.note)h+='<div style="color:#aaa;font-size:12px;margin-bottom:8px;line-height:1.5">备注: '+esc(c.note)+'</div>';
if(!c.passed&&c.failReason)h+='<div class="verify-err"><span class="verify-label">失败原因</span>'+esc(c.failReason)+'</div>';
else if(c.verifyError)h+='<div class="verify-err"><span class="verify-label">Verify 校验失败</span>'+esc(c.verifyError)+'</div>';
h+='<div class="row-hd" style="margin-bottom:4px"><span class="sec-title">cURL</span><span class="'+(c.passed?'tg tg-ok':'tg tg-err')+'" style="margin-left:6px">'+(c.passed?'通过':'失败')+'</span><button class="cp-sm" onclick="copyEl(\'c-curl\')" style="margin-left:auto">复制</button></div>';
h+='<div class="curl" id="c-curl">'+esc(caseCurl(e,c))+'</div>';
const hasDetail=c.expect&&c.expect.result!==undefined;
h+='<div class="row-hd" style="margin:8px 0 4px"><span class="sec-title">响应</span></div>';
if(hasDetail){const vis=getExpVis();h+='<div class="resp-wrap'+(vis?' exp-on':'')+'">';
h+='<div class="resp-main"><div class="rsp-hd"><span>实际响应</span><button class="cp-sm" onclick="copyEl(\'c-resp\')">复制</button></div>';if(c.response)h+='<pre class="j resp-j" id="c-resp">'+fj(c.response)+'</pre>';else h+='<span style="color:#555" id="c-resp">无响应</span>';h+='</div>';
h+='<div class="exp-col-btn"><button class="exp-btn" style=" width: 100%; box-sizing: border-box; " onclick="toggleExpect()">'+(vis?'▶ 收起 预期响应':'◀ 展开 预期响应')+'</button></div>';
h+='<div class="exp-panel"><div class="ep-hd"><span>预期响应</span><button class="cp-sm" onclick="copyEl(\'c-exp\')">复制</button></div><pre class="j resp-j" id="c-exp">'+fj(c.expect)+'</pre></div>';
h+='</div>';}
else{if(c.response)h+='<pre class="j" id="c-resp">'+fj(c.response)+'</pre>';else h+='<span style="color:#555" id="c-resp">无响应</span>';}
h+='</div>';
panel.innerHTML=h;
}
function pfFill(v){
const qr=document.getElementById('q-rows');
const fr=document.getElementById('f-rows');
if(qr)qr.innerHTML='';
if(v===''){
if(C?.params){
for(const p of C.params.filter(p=>p.in==='query'))addKVReq('q-rows',p.name,p.example!==undefined?String(p.example):'',p.required);
}
addKV('q-rows');
const el=document.getElementById('xb');if(el)el.value='';
if(fr){
fr.innerHTML='';
if(C?.params){for(const p of C.params.filter(p=>p.in==='form'))addKVReq('f-rows',p.name,p.example!==undefined?String(p.example):'',p.required);}
addKV('f-rows');
}
document.getElementById('file-rows').innerHTML='';addFileRow();
const nb0=document.getElementById('note-box');if(nb0){nb0.style.display='none';nb0.textContent='';}
syncAuth();return;
}
const i=parseInt(v);
if(isNaN(i)||i<0||!C?.cases?.[i])return;
const c=C.cases[i];
const nb=document.getElementById('note-box');
if(nb){if(c.note){nb.textContent='备注: '+c.note;nb.style.display='block';}else{nb.textContent='';nb.style.display='none';}}
if(c.query){
for(const[k,val]of Object.entries(c.query)){
const req=C?.params?.find(p=>p.name===k)?.required||false;
addKVReq('q-rows',k,String(val),req);
}
}
addKV('q-rows');
if(c.json){
setAcc('json');const el=document.getElementById('xb');
if(el){
const rq=new Set((C?.params||[]).filter(p=>p.required).map(p=>p.name));
let obj=c.json;
if(rq.size&&typeof obj==='object'&&obj!==null&&!Array.isArray(obj)){
const sorted={};
for(const k of Object.keys(obj).sort((a,b)=>(rq.has(b)?1:0)-(rq.has(a)?1:0)))sorted[k]=obj[k];
obj=sorted;
}
el.value=JSON.stringify(obj,null,2);autoH(el);
}
}else if(c.form){
setAcc('form');
if(fr){
fr.innerHTML='';
for(const[k,val]of Object.entries(c.form)){
const req=C?.params?.find(p=>p.name===k)?.required||false;
addKVReq('f-rows',k,String(val),req);
}
addKV('f-rows');
}
}else{
const el=document.getElementById('xb');if(el)el.value='';
if(fr){fr.innerHTML='';addKV('f-rows');}
}
document.getElementById('file-rows').innerHTML='';
if(c.hasFile&&c.fileField)addFileRow(c.fileField);
addFileRow();syncAuth();
}
function getKV(id){
const obj={};
document.querySelectorAll('#'+id+' .kv').forEach(r=>{const ins=r.querySelectorAll('input');const k=ins[0].value.trim(),v=ins[1]?.value;if(k)obj[k]=v||'';});
return Object.keys(obj).length?obj:null;
}
async function send(){
if(!C)return;const btn=document.getElementById('sbtn');btn.disabled=true;btn.textContent='请求中...';
let url=C.path;
const qp=getKV('q-rows');
if(qp){const ps=new URLSearchParams();for(const[k,v]of Object.entries(qp))ps.set(k,v);url+='?'+ps.toString();}
const token=document.getElementById('tk').value;
const authType=document.getElementById('at').value;
const opts={method:C.method,headers:{}};
if(authType==='cookie'){opts.credentials='same-origin';if(token)document.cookie='HOTIME='+token+';path=/';}
else{opts.credentials='omit';}
const ch=getKV('h-rows');if(ch)for(const[k,v]of Object.entries(ch)){
if(k.toLowerCase()==='authorization'&&token&&authType==='header')continue;
if(k.toLowerCase()==='cookie'&&token&&authType==='cookie')continue;
opts.headers[k]=v;
}
if(token&&authType==='header')opts.headers['Authorization']=token;
const fileEls=document.querySelectorAll('#file-rows .kv');
const files=[];fileEls.forEach(r=>{const fnEl=r.querySelector('input:not([type="file"])');const fi=r.querySelector('input[type="file"]');if(!fnEl||!fi)return;const fn=fnEl.value.trim();if(fn&&fi.files.length)files.push({field:fn,file:fi.files[0]});});
const isJson=document.getElementById('bt-json')?.classList.contains('on');
const bodyEl=document.getElementById('xb');const formKV=getKV('f-rows');let curlParts='';
if(files.length){
const fd=new FormData();for(const f of files)fd.append(f.field,f.file);
if(!isJson&&formKV)for(const[k,v]of Object.entries(formKV))fd.append(k,v);
opts.body=fd;delete opts.headers['Content-Type'];
for(const f of files)curlParts+=' -F "'+f.field+'=@'+f.file.name+'"';
if(!isJson&&formKV)for(const[k,v]of Object.entries(formKV))curlParts+=' -F "'+k+'='+v+'"';
}else if(isJson){
const b=bodyEl?.value?.trim();
if(b&&C.method!=='GET'){opts.body=b;curlParts=" -d '"+b+"'";}
}else if(formKV){
const fd=new FormData();for(const[k,v]of Object.entries(formKV))fd.append(k,v);opts.body=fd;delete opts.headers['Content-Type'];
for(const[k,v]of Object.entries(formKV))curlParts+=' -F "'+k+'='+v+'"';
}
let curl='curl -X '+C.method;
for(const[k,v]of Object.entries(opts.headers)){if(k&&v)curl+=" -H '"+k+": "+v+"'";}
if(token&&authType==='cookie')curl+=" --cookie 'HOTIME="+token+"'";
curl+=curlParts+' '+location.origin+url;
const curlBox=document.getElementById('curl-box');if(curlBox)curlBox.textContent=curl;
rswitch(1);
const t0=Date.now();
try{
const r=await fetch(url,opts);const ms=Date.now()-t0;const txt=await r.text();let pretty=txt;
try{pretty=JSON.stringify(JSON.parse(txt),null,2);}catch(e){}
const st=document.getElementById('resp-st');if(st)st.innerHTML='<span style="color:'+(r.ok?'var(--green)':'var(--red)')+'">HTTP '+r.status+'</span> <span style="color:#555">'+ms+'ms</span>';
const rb=document.getElementById('resp-body');if(rb)rb.textContent=pretty;
}catch(e){
const st=document.getElementById('resp-st');if(st)st.innerHTML='<span style="color:var(--red)">失败</span>';
const rb=document.getElementById('resp-body');if(rb)rb.textContent=e.message;
}
btn.disabled=false;btn.textContent='发送';
}
function copyEl(id){navigator.clipboard.writeText(document.getElementById(id).textContent).then(()=>{const b=event.target;b.textContent='已复制';setTimeout(()=>{b.textContent='复制'},1200);});}
function cpNext(btn){let el=btn.closest('h5').nextElementSibling;let t='';while(el&&el.tagName!=='H5'){if(el.tagName==='PRE')t+=(t?'\n':'')+el.textContent;el=el.nextElementSibling;}navigator.clipboard.writeText(t).then(()=>{btn.textContent='已复制';setTimeout(()=>{btn.textContent='复制'},1200);});}
function getExpVis(){const v=localStorage.getItem('expect_vis');return v===null||v==='1';}
function toggleExpect(){const vis=getExpVis();const nv=!vis;localStorage.setItem('expect_vis',nv?'1':'0');document.querySelectorAll('.resp-wrap').forEach(w=>{w.classList.toggle('exp-on',nv);});document.querySelectorAll('.exp-btn').forEach(b=>{b.textContent=nv?'▶ 收起 预期响应':'◀ 展开 预期响应';});}
function togJP(){const bd=document.querySelector('.jp-bd');const ar=document.querySelector('.jp-ar');const tg=document.querySelector('.jp-toggle');if(!bd)return;const vis=bd.style.display!=='none';bd.style.display=vis?'none':'flex';if(ar)ar.classList.toggle('o',!vis);if(tg)tg.textContent=vis?'展开':'收起';localStorage.setItem('json_params_vis',vis?'0':'1');}
function fj(o){return esc(JSON.stringify(o,null,2));}
function fjp(o,params){
if(!params||!params.length||typeof o!=='object'||o===null||Array.isArray(o))return fj(o);
const rq=new Set(params.filter(p=>p.required).map(p=>p.name));
const keys=Object.keys(o).sort((a,b)=>(rq.has(b)?1:0)-(rq.has(a)?1:0));
const lines=keys.map(k=>' "'+(rq.has(k)?'*':'')+k+'": '+JSON.stringify(o[k]));
return esc('{\n'+lines.join(',\n')+'\n}');
}
function kvList(o,params){
const rq=new Set((params||[]).filter(p=>p.required).map(p=>p.name));
let h='';
for(const[k,v]of Object.entries(o)){
const star=rq.has(k)?'<span class="req-star">*</span>':'';
h+='<div class="kv-ro">'+star+'<span class="kk">'+esc(k)+'</span><span class="vv">'+esc(String(v))+'</span></div>';
}
return h;
}
function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
</script></body></html>
-19
View File
@@ -1,19 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="utf-8"><title>API 文档中心</title>
<style>
:root{--bg:#1a1a2e;--bg2:#16213e;--bg3:#0f3460;--fg:#e0e0e0;--fg2:#888;--accent:#4fc3f7;--border:#2a2a4a}
*{box-sizing:border-box;margin:0;padding:0}
body{min-height:100vh;background:var(--bg);color:var(--fg);font:14px/1.6 -apple-system,sans-serif;display:flex;flex-direction:column;align-items:center;padding:60px 20px}
h1{font-size:24px;color:var(--accent);margin-bottom:8px}
.desc{color:var(--fg2);margin-bottom:40px;font-size:13px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:16px;width:100%;max-width:900px}
.card{display:block;background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:24px 20px;text-decoration:none;color:var(--fg);transition:all .2s}
.card:hover{border-color:var(--accent);transform:translateY(-2px);box-shadow:0 4px 20px rgba(79,195,247,.15)}
.icon{font-size:20px;font-weight:700;color:var(--accent);margin-bottom:8px}
.title{font-size:15px;font-weight:600;margin-bottom:4px}
.sub{font-size:12px;color:var(--fg2)}
</style></head><body>
<h1>API 文档中心</h1>
<div class="desc">选择一个模块查看接口文档与调试控制台</div>
<div class="grid"><a class="card" href="app/index.html"><div class="icon">App</div><div class="title">My API</div><div class="sub">app/</div></a></div>
</body></html>
+390
View File
@@ -0,0 +1,390 @@
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["@m"] != "plain log line" {
t.Fatalf("@m=%v", m["@m"])
}
if m["source"] != "stdout" {
t.Fatalf("source=%v", m["source"])
}
if m["@l"] != "Information" {
t.Fatalf("@l=%v", m["@l"])
}
}
func TestWithFields_ChildHasFieldsParentClean(t *testing.T) {
parent, buf := newLoggerWithCapture(1)
child := parent.WithFields("sid", "abc123def456", "request_id", "fedcba987654")
child.Info().Msg("child-msg")
m := buf.lastJSON()
if m == nil {
t.Fatal("no output")
}
if m["sid"] != "abc123def456" || m["request_id"] != "fedcba987654" {
t.Fatalf("child fields missing: %v", m)
}
parent.Info().Msg("parent-msg")
m2 := buf.lastJSON()
if m2["sid"] != nil || m2["request_id"] != nil {
t.Fatalf("parent polluted: %v", m2)
}
// 错误历史父子共享
parent2 := NewLogger(1, "", 10)
child2 := parent2.WithFields("sid", "x")
child2.Error("shared-err")
if n := len(parent2.GetRecentErrors()); n != 1 {
t.Fatalf("shared error store want 1 got %d", n)
}
}
func TestToClef_MillisecondAndAtM(t *testing.T) {
sw := &SeqWriter{instance: "test:1"}
in := []byte(`{"level":"info","time":"2026-07-23 04:50:01.123","message":"hello {name}"}`)
clef := sw.toClef(in)
var m map[string]interface{}
if err := json.Unmarshal(clef, &m); err != nil {
t.Fatal(err)
}
if m["@m"] != "hello {name}" {
t.Fatalf("@m=%v", m["@m"])
}
if m["@mt"] != nil {
t.Fatalf("should not have @mt: %v", m["@mt"])
}
at, ok := m["@t"].(string)
if !ok || !strings.Contains(at, ".123") {
t.Fatalf("@t missing ms: %v", m["@t"])
}
}
func TestSeqWriter_RetryOnce(t *testing.T) {
var mu sync.Mutex
calls := 0
var gotBody string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
mu.Lock()
calls++
n := calls
if n == 1 {
mu.Unlock()
w.WriteHeader(http.StatusInternalServerError)
return
}
gotBody = string(body)
mu.Unlock()
w.WriteHeader(http.StatusCreated)
}))
defer srv.Close()
l := NewLogger(1, "", 0)
l.SetSeqWriter(srv.URL, "", "test:retry")
l.Info().Msg("retry-me")
deadline := time.Now().Add(4 * time.Second)
for time.Now().Before(deadline) {
mu.Lock()
ok := calls >= 2 && strings.Contains(gotBody, "retry-me")
mu.Unlock()
if ok {
return
}
time.Sleep(50 * time.Millisecond)
}
mu.Lock()
defer mu.Unlock()
t.Fatalf("retry failed: calls=%d body=%q", calls, gotBody)
}
func TestLevelFilterWriter_WarnOnly(t *testing.T) {
buf := &captureBuf{}
w := newLevelFilterWriter(buf, zerolog.WarnLevel)
mw := &multiWriter{writers: []io.Writer{w}}
zl := zerolog.New(mw).Level(zerolog.DebugLevel)
zl.Info().Msg("info-hidden")
zl.Warn().Msg("warn-shown")
buf.mu.Lock()
n := len(buf.lines)
buf.mu.Unlock()
if n != 1 {
t.Fatalf("want 1 console line got %d: %v", n, buf.lines)
}
if !strings.Contains(buf.lines[0], "warn-shown") {
t.Fatalf("unexpected: %v", buf.lines[0])
}
}
// TestSetSeqWriter_QuietsConsoleKeepsSeqFull 验证挂 Seq 后控制台放宽到 Info+:
// Debug 不上控制台,Info/Warn 均可见,Seq 侧全量收到(含 Debug)。
func TestSetSeqWriter_QuietsConsoleKeepsSeqFull(t *testing.T) {
var mu sync.Mutex
var gotBody string
srv := startFakeSeqServer(t, func(body string) {
mu.Lock()
gotBody += body
mu.Unlock()
})
defer srv.Close()
l := NewLogger(1, "", 0)
consoleBuf := &captureBuf{}
l.console.inner = consoleBuf
l.SetSeqWriter(srv.URL, "", "test:quiet")
l.Debug().Msg("debug-to-seq-only")
l.Info().Msg("info-both")
l.Warn().Msg("warn-both")
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
mu.Lock()
ok := strings.Contains(gotBody, "debug-to-seq-only") && strings.Contains(gotBody, "info-both") && strings.Contains(gotBody, "warn-both")
mu.Unlock()
if ok {
break
}
time.Sleep(50 * time.Millisecond)
}
mu.Lock()
body := gotBody
mu.Unlock()
if !strings.Contains(body, "debug-to-seq-only") || !strings.Contains(body, "info-both") || !strings.Contains(body, "warn-both") {
t.Fatalf("Seq incomplete: %q", body)
}
consoleBuf.mu.Lock()
defer consoleBuf.mu.Unlock()
for _, line := range consoleBuf.lines {
if strings.Contains(line, "debug-to-seq-only") {
t.Fatalf("Debug leaked to console: %v", consoleBuf.lines)
}
}
foundInfo, foundWarn := false, false
for _, line := range consoleBuf.lines {
if strings.Contains(line, "info-both") {
foundInfo = true
}
if strings.Contains(line, "warn-both") {
foundWarn = true
}
}
if !foundInfo {
t.Fatalf("Info missing on console: %v", consoleBuf.lines)
}
if !foundWarn {
t.Fatalf("Warn missing on console: %v", consoleBuf.lines)
}
l.CloseSeq()
}
func TestSeqWriter_CloseFlushes(t *testing.T) {
var mu sync.Mutex
var gotBody string
srv := startFakeSeqServer(t, func(body string) {
mu.Lock()
gotBody += body
mu.Unlock()
})
defer srv.Close()
l := NewLogger(1, "", 0)
l.SetSeqWriter(srv.URL, "", "test:close")
l.Info().Msg("flush-on-close")
l.CloseSeq()
mu.Lock()
defer mu.Unlock()
if !strings.Contains(gotBody, "flush-on-close") {
t.Fatalf("Close did not flush: %q", gotBody)
}
}
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"])
}
}
+87
View File
@@ -0,0 +1,87 @@
package log
import (
"bytes"
"testing"
"github.com/rs/zerolog"
)
// TestSetSeqWriter_ConsoleMinLevelBecomesInfo 验证挂 Seq 后控制台门槛降到 Info(而非 Warn)
func TestSetSeqWriter_ConsoleMinLevelBecomesInfo(t *testing.T) {
l := NewLogger(1, "", 0)
if got := zerolog.Level(l.console.minLevel.Load()); got != zerolog.DebugLevel {
t.Fatalf("初始 console minLevel=%v want Debug", got)
}
// seqUrl 不必可达:SeqWriter 异步 channel 推送,Write 不阻塞
l.SetSeqWriter("http://127.0.0.1:1", "", "test:level")
defer l.CloseSeq()
if got := zerolog.Level(l.console.minLevel.Load()); got != zerolog.InfoLevel {
t.Fatalf("挂 Seq 后 console minLevel=%v want Info", got)
}
}
// TestSetConsoleMinLevel_OverridesToWarn 验证导出方法可将 console 门槛单独收紧到 Warn
// 用于访问日志等高噪声 Logger 在挂 Seq 后仍保持 Warn+。
func TestSetConsoleMinLevel_OverridesToWarn(t *testing.T) {
l := NewLogger(1, "", 0)
l.SetSeqWriter("http://127.0.0.1:1", "", "test:override")
defer l.CloseSeq()
l.SetConsoleMinLevel(zerolog.WarnLevel)
if got := zerolog.Level(l.console.minLevel.Load()); got != zerolog.WarnLevel {
t.Fatalf("SetConsoleMinLevel 后 minLevel=%v want Warn", got)
}
var buf bytes.Buffer
l.console.inner = &buf
if _, err := l.console.WriteLevel(zerolog.InfoLevel, []byte("info-line\n")); err != nil {
t.Fatal(err)
}
if buf.Len() != 0 {
t.Fatalf("Info 应被 Warn 门槛丢弃,实际写入: %q", buf.String())
}
if _, err := l.console.WriteLevel(zerolog.WarnLevel, []byte("warn-line\n")); err != nil {
t.Fatal(err)
}
if !bytes.Contains(buf.Bytes(), []byte("warn-line")) {
t.Fatalf("Warn 应放行,实际: %q", buf.String())
}
}
// TestLevelFilterWriter_WriteLevel_DropAndPass 直接验证 levelFilterWriter.WriteLevel
// 对低于门槛的级别丢弃(但仍返回成功),达到门槛的放行。
func TestLevelFilterWriter_WriteLevel_DropAndPass(t *testing.T) {
var buf bytes.Buffer
w := newLevelFilterWriter(&buf, zerolog.WarnLevel)
n, err := w.WriteLevel(zerolog.DebugLevel, []byte("debug-msg"))
if err != nil || n != len("debug-msg") {
t.Fatalf("Debug 丢弃返回值异常: n=%d err=%v", n, err)
}
if buf.Len() != 0 {
t.Fatalf("Debug 不应写入 inner: %q", buf.String())
}
n, err = w.WriteLevel(zerolog.InfoLevel, []byte("info-msg"))
if err != nil || n != len("info-msg") {
t.Fatalf("Info 丢弃返回值异常: n=%d err=%v", n, err)
}
if buf.Len() != 0 {
t.Fatalf("Info 不应写入 inner: %q", buf.String())
}
if _, err := w.WriteLevel(zerolog.WarnLevel, []byte("warn-msg")); err != nil {
t.Fatal(err)
}
if !bytes.Contains(buf.Bytes(), []byte("warn-msg")) {
t.Fatalf("Warn 应放行: %q", buf.String())
}
if _, err := w.WriteLevel(zerolog.ErrorLevel, []byte("error-msg")); err != nil {
t.Fatal(err)
}
if !bytes.Contains(buf.Bytes(), []byte("error-msg")) {
t.Fatalf("Error 应放行: %q", buf.String())
}
}
+286 -63
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
@@ -26,25 +47,73 @@ type ErrorRecord struct {
Caller string
}
// Logger 日志核心结构体,封装 zerolog
type Logger struct {
zl zerolog.Logger
mw *multiWriter // 动态多输出,支持运行时追加 SeqWriter 等
logLevel int
// errorStore 错误历史环形缓冲(指针共享,供 WithFields 派生子 Logger 共用)
type errorStore struct {
mu sync.RWMutex
errors []ErrorRecord
errIdx int // 环形缓冲写入位置
errCount int // 实际写入总数(用于判断缓冲是否满)
errorsMu sync.RWMutex
errIdx int
errCount int
maxErrors int
}
// Logger 日志核心结构体,封装 zerolog
type Logger struct {
zl zerolog.Logger
mw *multiWriter // 动态多输出,支持运行时追加 SeqWriter 等
console *levelFilterWriter // 控制台出口,挂 Seq 后可降到 Warn+
logLevel int
store *errorStore
seqs []*SeqWriter // 本 Logger 挂载过的 SeqWriter,供 CloseSeq 冲刷
seqsMu sync.Mutex
}
// levelFilterWriter 按级别过滤的 Writer(实现 zerolog.LevelWriter
type levelFilterWriter struct {
inner io.Writer
minLevel atomic.Int32 // zerolog.Level 存为 int32
}
func newLevelFilterWriter(inner io.Writer, min zerolog.Level) *levelFilterWriter {
w := &levelFilterWriter{inner: inner}
w.minLevel.Store(int32(min))
return w
}
func (w *levelFilterWriter) setMinLevel(min zerolog.Level) {
if w == nil {
return
}
w.minLevel.Store(int32(min))
}
func (w *levelFilterWriter) Write(p []byte) (int, error) {
// 无级别信息时按 Info 处理:低于门槛则丢弃
if zerolog.Level(w.minLevel.Load()) > zerolog.InfoLevel {
return len(p), nil
}
return w.inner.Write(p)
}
func (w *levelFilterWriter) WriteLevel(l zerolog.Level, p []byte) (int, error) {
if l < zerolog.Level(w.minLevel.Load()) {
return len(p), nil
}
if lw, ok := w.inner.(zerolog.LevelWriter); ok {
return lw.WriteLevel(l, p)
}
return w.inner.Write(p)
}
const timeFormatMS = "2006-01-02 15:04:05.000"
// NewLogger 创建日志实例
// logLevel: 0=仅 error>=1=全部
// 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"
zerolog.TimeFieldFormat = timeFormatMS
var level zerolog.Level
if logLevel == 0 {
@@ -55,7 +124,7 @@ func NewLogger(logLevel int, logFile string, maxErrors int) *Logger {
consoleWriter := zerolog.ConsoleWriter{
Out: os.Stderr,
TimeFormat: "2006-01-02 15:04:05",
TimeFormat: timeFormatMS,
NoColor: false,
FormatLevel: formatLevelColor,
FormatCaller: func(i interface{}) string {
@@ -65,9 +134,10 @@ func NewLogger(logLevel int, logFile string, maxErrors int) *Logger {
return fmt.Sprintf("[%s]", i)
},
}
console := newLevelFilterWriter(consoleWriter, zerolog.DebugLevel)
var writers []io.Writer
writers = append(writers, consoleWriter)
writers = append(writers, console)
if logFile != "" {
fw := &TemplateFileWriter{pathTemplate: logFile}
@@ -83,13 +153,14 @@ func NewLogger(logLevel int, logFile string, maxErrors int) *Logger {
Logger()
l := &Logger{
zl: zl,
mw: mw,
logLevel: logLevel,
maxErrors: maxErrors,
zl: zl,
mw: mw,
console: console,
logLevel: logLevel,
store: &errorStore{maxErrors: maxErrors},
}
if maxErrors > 0 {
l.errors = make([]ErrorRecord, maxErrors)
l.store.errors = make([]ErrorRecord, maxErrors)
}
return l
@@ -97,8 +168,9 @@ 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"
zerolog.TimeFieldFormat = timeFormatMS
var level zerolog.Level
if logLevel == 0 {
@@ -109,13 +181,14 @@ func NewLoggerNoCaller(logLevel int, logFile string, maxErrors int) *Logger {
consoleWriter := zerolog.ConsoleWriter{
Out: os.Stderr,
TimeFormat: "2006-01-02 15:04:05",
TimeFormat: timeFormatMS,
NoColor: false,
FormatLevel: formatLevelColor,
}
console := newLevelFilterWriter(consoleWriter, zerolog.DebugLevel)
var writers []io.Writer
writers = append(writers, consoleWriter)
writers = append(writers, console)
if logFile != "" {
fw := &TemplateFileWriter{pathTemplate: logFile}
@@ -130,18 +203,40 @@ func NewLoggerNoCaller(logLevel int, logFile string, maxErrors int) *Logger {
Logger()
l := &Logger{
zl: zl,
mw: mw,
logLevel: logLevel,
maxErrors: maxErrors,
zl: zl,
mw: mw,
console: console,
logLevel: logLevel,
store: &errorStore{maxErrors: maxErrors},
}
if maxErrors > 0 {
l.errors = make([]ErrorRecord, maxErrors)
l.store.errors = make([]ErrorRecord, maxErrors)
}
return l
}
// WithFields 派生子 Logger,附加成对 key,value 字段;mw/错误历史与父共享,父不受污染。
func (l *Logger) WithFields(kvs ...string) *Logger {
if l == nil {
return nil
}
ctx := l.zl.With()
for i := 0; i+1 < len(kvs); i += 2 {
if kvs[i] == "" {
continue
}
ctx = ctx.Str(kvs[i], kvs[i+1])
}
return &Logger{
zl: ctx.Logger(),
mw: l.mw,
console: l.console,
logLevel: l.logLevel,
store: l.store,
}
}
// NewTestLogger 创建用于测试的静默 Logger(输出到 io.Discard
func NewTestLogger() *Logger {
zl := zerolog.New(io.Discard).Level(zerolog.Disabled)
@@ -157,7 +252,7 @@ func (l *Logger) SetOutput(w io.Writer) {
}
consoleWriter := zerolog.ConsoleWriter{
Out: w,
TimeFormat: "2006-01-02 15:04:05",
TimeFormat: timeFormatMS,
NoColor: true,
FormatLevel: formatLevelPlain,
FormatCaller: func(i interface{}) string {
@@ -181,6 +276,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("...")
@@ -216,14 +367,14 @@ func (l *Logger) Warn(args ...interface{}) *zerolog.Event {
func (l *Logger) Error(args ...interface{}) *zerolog.Event {
if len(args) > 0 {
msg := fmt.Sprint(args...)
if l.maxErrors > 0 {
if l.store != nil && l.store.maxErrors > 0 {
l.recordError(msg, nil)
}
l.zl.Error().Msg(msg)
nop := zerolog.Nop()
return nop.Error()
}
if l.maxErrors > 0 {
if l.store != nil && l.store.maxErrors > 0 {
l.recordError("", nil)
}
return l.zl.Error()
@@ -231,19 +382,20 @@ func (l *Logger) Error(args ...interface{}) *zerolog.Event {
// RecordError 手动记录一条错误到历史(用于需要指定详情的场景)
func (l *Logger) RecordError(err error, msg string, caller string) {
if l.maxErrors <= 0 {
if l == nil || l.store == nil || l.store.maxErrors <= 0 {
return
}
l.errorsMu.Lock()
defer l.errorsMu.Unlock()
l.errors[l.errIdx] = ErrorRecord{
s := l.store
s.mu.Lock()
defer s.mu.Unlock()
s.errors[s.errIdx] = ErrorRecord{
Err: err,
Msg: msg,
Time: time.Now(),
Caller: caller,
}
l.errIdx = (l.errIdx + 1) % l.maxErrors
l.errCount++
s.errIdx = (s.errIdx + 1) % s.maxErrors
s.errCount++
}
func (l *Logger) recordError(msg string, err error) {
@@ -257,15 +409,16 @@ func (l *Logger) recordError(msg string, err error) {
// GetRecentErrors 获取最近 N 条错误(不传则返回全部已存储的)
func (l *Logger) GetRecentErrors(n ...int) []ErrorRecord {
if l.maxErrors <= 0 {
if l == nil || l.store == nil || l.store.maxErrors <= 0 {
return nil
}
l.errorsMu.RLock()
defer l.errorsMu.RUnlock()
s := l.store
s.mu.RLock()
defer s.mu.RUnlock()
total := l.errCount
if total > l.maxErrors {
total = l.maxErrors
total := s.errCount
if total > s.maxErrors {
total = s.maxErrors
}
if total == 0 {
return nil
@@ -277,13 +430,12 @@ func (l *Logger) GetRecentErrors(n ...int) []ErrorRecord {
}
result := make([]ErrorRecord, 0, want)
// 从最新的往前读
for i := 0; i < want; i++ {
idx := (l.errIdx - 1 - i + l.maxErrors) % l.maxErrors
if l.errors[idx].Time.IsZero() {
idx := (s.errIdx - 1 - i + s.maxErrors) % s.maxErrors
if s.errors[idx].Time.IsZero() {
break
}
result = append(result, l.errors[idx])
result = append(result, s.errors[idx])
}
return result
}
@@ -300,7 +452,7 @@ func (l *Logger) Warnf(format string, v ...interface{}) {
l.zl.Warn().Msgf(format, v...)
}
func (l *Logger) Errorf(format string, v ...interface{}) {
if l.maxErrors > 0 {
if l.store != nil && l.store.maxErrors > 0 {
l.recordError(fmt.Sprintf(format, v...), nil)
}
l.zl.Error().Msgf(format, v...)
@@ -481,6 +633,8 @@ type SeqWriter struct {
queue chan []byte
dropped int64
client *http.Client
closed int32
done chan struct{}
}
func newSeqWriter(seqUrl, apiKey, instance string) *SeqWriter {
@@ -490,6 +644,7 @@ func newSeqWriter(seqUrl, apiKey, instance string) *SeqWriter {
instance: instance,
queue: make(chan []byte, 10000),
client: &http.Client{Timeout: 5 * time.Second},
done: make(chan struct{}),
}
go w.run()
return w
@@ -497,24 +652,46 @@ func newSeqWriter(seqUrl, apiKey, instance string) *SeqWriter {
// Write 将字节塞入 channel 即返回,由后台 goroutine 消费发送
func (w *SeqWriter) Write(p []byte) (int, error) {
if atomic.LoadInt32(&w.closed) != 0 {
return len(p), nil
}
clef := w.toClef(p)
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
}
// Close 停止接收新条目,并在约 2s 内冲刷残留批次(幂等)
func (w *SeqWriter) Close() {
if w == nil {
return
}
if !atomic.CompareAndSwapInt32(&w.closed, 0, 1) {
return
}
close(w.queue)
select {
case <-w.done:
case <-time.After(2 * time.Second):
seqWriteStderr("[seq] Close timeout, some events may be dropped\n")
}
}
// toClef 将 zerolog JSON 字段映射到 Seq CLEF 格式
// zerolog: time/level/message(msg) → CLEF: @t/@l/@mt
// zerolog: time/level/message(msg) → CLEF: @t/@l/@m
func (w *SeqWriter) toClef(p []byte) []byte {
trimmed := bytes.TrimSpace(p)
var m map[string]interface{}
if err := json.Unmarshal(trimmed, &m); err != nil {
// 非 JSON(如 fmt.Println 直接输出),包装为纯文本日志
return []byte(fmt.Sprintf(`{"@t":%q,"@l":"Information","@mt":%q,"source":"stdout","instance":%q}`,
time.Now().Format(time.RFC3339), strings.TrimSpace(string(p)), w.instance))
return []byte(fmt.Sprintf(`{"@t":%q,"@l":"Information","@m":%q,"source":"stdout","instance":%q}`,
time.Now().Format("2006-01-02T15:04:05.000Z07:00"), strings.TrimSpace(string(p)), w.instance))
}
clef := make(map[string]interface{}, len(m)+3)
@@ -522,16 +699,18 @@ func (w *SeqWriter) toClef(p []byte) []byte {
clef[k] = v
}
// time → @tzerolog 格式 "2006-01-02 15:04:05" → ISO 8601
// time → @t优先毫秒格式,回退秒级
if t, ok := m["time"].(string); ok {
delete(clef, "time")
if pt, err := time.ParseInLocation("2006-01-02 15:04:05", t, time.Local); err == nil {
clef["@t"] = pt.Format(time.RFC3339)
if pt, err := time.ParseInLocation(timeFormatMS, t, time.Local); err == nil {
clef["@t"] = pt.Format("2006-01-02T15:04:05.000Z07:00")
} else if pt, err := time.ParseInLocation("2006-01-02 15:04:05", t, time.Local); err == nil {
clef["@t"] = pt.Format("2006-01-02T15:04:05.000Z07:00")
} else {
clef["@t"] = t
}
} else {
clef["@t"] = time.Now().Format(time.RFC3339)
clef["@t"] = time.Now().Format("2006-01-02T15:04:05.000Z07:00")
}
// level → @l
@@ -553,13 +732,13 @@ func (w *SeqWriter) toClef(p []byte) []byte {
}
}
// message/msg → @mt
// message/msg → @m(不用 @mt,避免 Seq 把 {xxx} 当消息模板)
if msg, ok := m["message"].(string); ok {
delete(clef, "message")
clef["@mt"] = msg
clef["@m"] = msg
} else if msg, ok := m["msg"].(string); ok {
delete(clef, "msg")
clef["@mt"] = msg
clef["@m"] = msg
}
if w.instance != "" {
@@ -570,14 +749,21 @@ func (w *SeqWriter) toClef(p []byte) []byte {
return b
}
// run 后台 goroutine:每 100 条或 500ms 批量 POST 到 Seq
// run 后台 goroutine:每 100 条或 500ms 批量 POST 到 Seqqueue 关闭后冲刷并退出
func (w *SeqWriter) run() {
defer close(w.done)
batch := make([][]byte, 0, 100)
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case entry := <-w.queue:
case entry, ok := <-w.queue:
if !ok {
if len(batch) > 0 {
w.flush(batch)
}
return
}
batch = append(batch, entry)
if len(batch) >= 100 {
w.flush(batch)
@@ -593,6 +779,15 @@ func (w *SeqWriter) run() {
}
func (w *SeqWriter) flush(batch [][]byte) {
if err := w.postBatch(batch); err != nil {
time.Sleep(1 * time.Second)
if err2 := w.postBatch(batch); err2 != nil {
seqWriteStderr("[seq] send failed after retry: %v (dropped=%d)\n", err2, atomic.LoadInt64(&w.dropped))
}
}
}
func (w *SeqWriter) postBatch(batch [][]byte) error {
var buf bytes.Buffer
for _, entry := range batch {
buf.Write(entry)
@@ -600,8 +795,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)
return
return err
}
req.Header.Set("Content-Type", "application/vnd.serilog.clef")
if w.apiKey != "" {
@@ -609,14 +803,14 @@ 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))
return
return err
}
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))
return fmt.Errorf("status %d: %s", resp.StatusCode, string(body))
}
return nil
}
// SetSeqWriter 将 Seq HTTP 推送器附加到当前 Logger(不影响已有的控制台/文件输出)
@@ -624,11 +818,40 @@ func (w *SeqWriter) flush(batch [][]byte) {
// apiKey: Seq API Key,免费单用户版留空
// instance: 实例标识,建议用 config 的 port 字段区分同机多进程,如 "8085"
func (l *Logger) SetSeqWriter(seqUrl, apiKey, instance string) {
if seqUrl == "" || l.mw == nil {
if l == nil || seqUrl == "" || l.mw == nil {
return
}
sw := newSeqWriter(seqUrl, apiKey, instance)
l.mw.add(sw)
l.seqsMu.Lock()
l.seqs = append(l.seqs, sw)
l.seqsMu.Unlock()
// 挂上 Seq 后控制台放宽到 Info+(Debug/SQL 不上控制台),仍全量进 Seq 与文件;
// 访问日志等高噪声 Logger 可再调用 SetConsoleMinLevel 单独收紧。
l.console.setMinLevel(zerolog.InfoLevel)
}
// SetConsoleMinLevel 单独设置本 Logger 控制台出口的最低级别,不影响 Seq/文件等其他出口。
// 用于访问日志等高噪声场景在挂 Seq 后仍保持更高的控制台门槛(如 Warn+)。
func (l *Logger) SetConsoleMinLevel(level zerolog.Level) {
if l == nil {
return
}
l.console.setMinLevel(level)
}
// CloseSeq 冲刷并关闭本 Logger 挂载的全部 SeqWriter(幂等、nil 安全)
func (l *Logger) CloseSeq() {
if l == nil {
return
}
l.seqsMu.Lock()
seqs := l.seqs
l.seqs = nil
l.seqsMu.Unlock()
for _, sw := range seqs {
sw.Close()
}
}
// isInfrastructureFile 判断文件是否为基础设施(日志/DB/缓存/通用工具)管线,
+53
View File
@@ -0,0 +1,53 @@
package hotime
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
. "code.hoteas.com/golang/hotime/cache"
. "code.hoteas.com/golang/hotime/common"
"code.hoteas.com/golang/hotime/log"
)
// TestStaticFile_CacheControlPublic_WithLogLevel1
// 静态文件在 logLevel>=1 时仍应 Cache-Control: public(与日志级别解耦)。
func TestStaticFile_CacheControlPublic_WithLogLevel1(t *testing.T) {
dir := t.TempDir()
body := []byte("<!doctype html><title>static-cache</title>")
if err := os.WriteFile(filepath.Join(dir, "hello.html"), body, 0o644); err != nil {
t.Fatalf("写静态文件失败: %v", err)
}
app := &Application{
Config: Map{
"logLevel": 1,
"tpt": dir,
"sessionName": "HOTIME",
"defFile": Slice{"index.html"},
"crossDomain": "",
},
Log: log.NewLogger(1, "", 0),
MethodRouter: MethodRouter{},
HoTimeCache: &HoTimeCache{},
}
req := httptest.NewRequest(http.MethodGet, "/hello.html", nil)
req.RequestURI = "/hello.html"
w := httptest.NewRecorder()
app.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("期望 200,实际 %d loc=%q cc=%q body=%q", w.Code, w.Header().Get("Location"), w.Header().Get("Cache-Control"), w.Body.String())
}
cc := w.Header().Get("Cache-Control")
if !strings.EqualFold(cc, "public") {
t.Fatalf("期望 Cache-Control=public,实际 %q", cc)
}
if !strings.Contains(w.Body.String(), "static-cache") {
t.Fatalf("响应体未包含静态内容: %q", w.Body.String())
}
}
+136 -19
View File
@@ -44,23 +44,85 @@ type ApiTestDef struct {
// Api 测试 API 入口,由 RunTests 自动创建并注入
type Api struct {
app *TestApp
path string
t *testing.T
session Map
lastResp *ApiResponse
app *TestApp
path string
t *testing.T
session Map
lastResp *ApiResponse
flowName string
flowGroup string
flowDesc string
flowPrep string
flowStep string
flowStepIndex int
flowParent *Flow // 业务流程内执行时回写 lastResp
}
// WithSession 返回一个携带 session 的新 Api 实例
func (a *Api) WithSession(s Map) *Api {
return &Api{
app: a.app,
path: a.path,
t: a.t,
session: s,
app: a.app,
path: a.path,
t: a.t,
session: s,
flowName: a.flowName,
flowGroup: a.flowGroup,
flowDesc: a.flowDesc,
flowPrep: a.flowPrep,
flowStep: a.flowStep,
flowStepIndex: a.flowStepIndex,
flowParent: a.flowParent,
}
}
// AtPath 返回指向其它 API 路径的 Api(跨接口断言时用)
func (a *Api) AtPath(path string) *Api {
return &Api{
app: a.app,
path: path,
t: a.t,
session: a.session,
flowName: a.flowName,
flowGroup: a.flowGroup,
flowDesc: a.flowDesc,
flowPrep: a.flowPrep,
flowStep: a.flowStep,
flowStepIndex: a.flowStepIndex,
flowParent: a.flowParent,
}
}
// RunSub 在单个接口测试 Func 内再分子场景,便于 go test -run 收窄。
func (a *Api) RunSub(desc string, fn func(a *Api)) {
if a == nil || a.t == nil {
fn(a)
return
}
a.t.Run(desc, func(t *testing.T) {
fn(&Api{
app: a.app,
path: a.path,
t: t,
session: a.session,
flowName: a.flowName,
flowGroup: a.flowGroup,
flowDesc: a.flowDesc,
flowPrep: a.flowPrep,
flowStep: a.flowStep,
flowStepIndex: a.flowStepIndex,
flowParent: a.flowParent,
})
})
}
// FromCase 绑定单接口已有验收用例:加载其请求模板;跑通后回写该 case 的响应
func (a *Api) FromCase(caseName string) *ApiCase {
c := a.newCase()
c.bindCase = caseName
c.applyCaseTemplate(a.app.findCaseTemplate(a.path, caseName))
return c
}
// JSON 设置 JSON body,返回 ApiCase 构建器
func (a *Api) JSON(body interface{}) *ApiCase {
c := a.newCase()
@@ -127,6 +189,13 @@ func (a *Api) Note(note string) *ApiCase {
return c
}
// AnyMsg 跳过错误 msg 断言(仅断言 status),用于响应 msg 含网络错误等动态内容的用例
func (a *Api) AnyMsg() *ApiCase {
c := a.newCase()
c.skipMsg = true
return c
}
// DB 获取数据库实例(在测试事务内)
func (a *Api) DB() *HoTimeDB {
return &a.app.Db
@@ -158,6 +227,35 @@ type ApiCase struct {
fileContent []byte
note string
verifyFn func(a *Api) error
bindCase string // 绑定的单接口验收用例名(FromCase)
skipMsg bool // AnyMsg:跳过错误 msg 断言(用于网络错误等动态消息)
}
// FromCase 在已有 ApiCase 上绑定验收用例名(若尚未加载模板则加载)
func (c *ApiCase) FromCase(caseName string) *ApiCase {
c.bindCase = caseName
if c.query == nil && c.jsonBody == nil && c.formBody == nil {
c.applyCaseTemplate(c.api.app.findCaseTemplate(c.api.path, caseName))
}
return c
}
func (c *ApiCase) applyCaseTemplate(tmpl *caseTemplate) {
if tmpl == nil {
return
}
if c.query == nil && tmpl.Query != nil {
c.query = tmpl.Query
}
if c.jsonBody == nil && tmpl.Json != nil {
c.jsonBody = tmpl.Json
}
if c.formBody == nil && tmpl.Form != nil {
c.formBody = tmpl.Form
}
if c.note == "" && tmpl.Note != "" {
c.note = tmpl.Note
}
}
// JSON 叠加 JSON body
@@ -210,6 +308,12 @@ func (c *ApiCase) Note(note string) *ApiCase {
return c
}
// AnyMsg 跳过错误 msg 断言(仅断言 status),可继续链式
func (c *ApiCase) AnyMsg() *ApiCase {
c.skipMsg = true
return c
}
// Verify 设置请求后的自定义校验函数(如查库验证数据状态)
// 函数返回 nil 表示校验通过,返回 error 则用例失败并记录错误原因
func (c *ApiCase) Verify(fn func(a *Api) error) *ApiCase {
@@ -253,7 +357,7 @@ func (c *ApiCase) execute(method, desc string, expectStatus int, expectArgs ...i
hasExpectResult = true
}
}
if expectStatus != 0 && expectMsg == "" {
if expectStatus != 0 && expectMsg == "" && !c.skipMsg {
expectMsg = desc
}
@@ -268,15 +372,22 @@ func (c *ApiCase) execute(method, desc string, expectStatus int, expectArgs ...i
defer func() {
record := TestRecord{
Path: c.api.path,
Desc: desc,
CaseName: desc,
Passed: passed,
Duration: time.Since(start),
Method: method,
Note: c.note,
VerifyError: verifyError,
FailReason: failReason,
Path: c.api.path,
Desc: desc,
CaseName: desc,
Passed: passed,
Duration: time.Since(start),
Method: method,
Note: c.note,
VerifyError: verifyError,
FailReason: failReason,
FlowName: c.api.flowName,
FlowGroup: c.api.flowGroup,
FlowDesc: c.api.flowDesc,
FlowPrep: c.api.flowPrep,
FlowStep: c.api.flowStep,
FlowStepIndex: c.api.flowStepIndex,
BindCase: c.bindCase,
}
if c.query != nil {
record.Query = c.query
@@ -310,6 +421,9 @@ func (c *ApiCase) execute(method, desc string, expectStatus int, expectArgs ...i
}
record.ExpectResult = expectResp
c.api.app.collector.Add(record)
if c.bindCase != "" && c.api.flowName != "" {
c.api.app.syncBoundCaseToEndpoint(record)
}
}()
c.api.app.Db.ResetTestSQLErrorCount()
@@ -364,6 +478,9 @@ func (c *ApiCase) execute(method, desc string, expectStatus int, expectArgs ...i
}
c.api.lastResp = resp
if c.api.flowParent != nil {
c.api.flowParent.SetLastResp(resp)
}
if passed && c.verifyFn != nil {
if err := c.verifyFn(c.api); err != nil {
t.Errorf("Verify 校验失败: %s", err.Error())
+96
View File
@@ -0,0 +1,96 @@
package hotime
import (
"testing"
. "code.hoteas.com/golang/hotime/common"
. "code.hoteas.com/golang/hotime/db"
)
// FlowTest 业务流程测试集合(流程名 → 定义)
type FlowTest map[string]FlowDef
// FlowDef 单条业务流程定义
type FlowDef struct {
Group string // 左侧分组,如 "user" / "expect_demo"
Desc string // 流程短标题(右侧标题)
Prep string // 业务说明与前置/数据准备(右侧描述)
Func func(f *Flow)
}
// Flow 业务流程测试入口,整条流程共用一次测试事务
type Flow struct {
app *TestApp
t *testing.T
name string
group string
desc string
prep string
session Map
stepIdx int
lastResp *ApiResponse
}
// WithSession 返回携带 session 的 Flow
func (f *Flow) WithSession(s Map) *Flow {
return &Flow{
app: f.app,
t: f.t,
name: f.name,
group: f.group,
desc: f.desc,
prep: f.prep,
session: s,
stepIdx: f.stepIdx,
}
}
// Step 开始流程中的一步,返回指向指定路径的 Api(记录会归入 flows)
func (f *Flow) Step(stepName, path string) *Api {
f.stepIdx++
return &Api{
app: f.app,
path: path,
t: f.t,
session: f.session,
flowName: f.name,
flowGroup: f.group,
flowDesc: f.desc,
flowPrep: f.prep,
flowStep: stepName,
flowStepIndex: f.stepIdx,
flowParent: f,
}
}
// AtPath 在当前流程上下文中切换路径(不增加步骤序号)
func (f *Flow) AtPath(path string) *Api {
return &Api{
app: f.app,
path: path,
t: f.t,
session: f.session,
flowName: f.name,
flowGroup: f.group,
flowDesc: f.desc,
flowPrep: f.prep,
flowStep: "",
flowStepIndex: f.stepIdx,
flowParent: f,
}
}
// DB 获取测试库(流程级事务内)
func (f *Flow) DB() *HoTimeDB {
return f.app.DB()
}
// Resp 最近一次步骤响应
func (f *Flow) Resp() *ApiResponse {
return f.lastResp
}
// SetLastResp 由框架在步骤执行后更新
func (f *Flow) SetLastResp(r *ApiResponse) {
f.lastResp = r
}
+316 -87
View File
@@ -7,8 +7,12 @@ import (
"net/http"
"net/http/httptest"
"os"
"os/signal"
"sort"
"strings"
"sync"
"sync/atomic"
"syscall"
"testing"
"time"
@@ -18,8 +22,183 @@ import (
// TestApp 测试应用,封装 Application 提供测试能力
type TestApp struct {
*Application
projs TestProj
collector *TestCollector
configPath string
listeners []func(*Context) bool
workDir string
projs TestProj
flows FlowTest
collector *TestCollector
swaggerEnabled bool
swaggerTitle string
swaggerVersion string
swaggerDir string
stopNewTests atomic.Bool
swaggerMu sync.Mutex
inited bool
initOnce sync.Once
}
// Test 创建测试应用(链式入口,延迟 Init 以便先 WorkDir
func Test(configPath string) *TestApp {
return &TestApp{
configPath: configPath,
collector: &TestCollector{},
}
}
// WorkDir 设置工作目录(在 Init 前 chdir)
func (that *TestApp) WorkDir(dir string) *TestApp {
that.workDir = dir
return that
}
// Listener 追加连接监听(可选;可多次调用或一次传多个)。
// Init 前仅暂存,ensureInit 时批量 SetConnectListenerInit 后立刻挂上。
// 返回 true 表示已处理并停止进入控制器,false 继续。
func (that *TestApp) Listener(fns ...func(*Context) bool) *TestApp {
that.listeners = append(that.listeners, fns...)
if that.inited && that.Application != nil {
for _, fn := range fns {
that.SetConnectListener(fn)
}
}
return that
}
// Swagger 启用接口级增量 Swagger 写入。
// 参数可选:Swagger() / Swagger(title) / Swagger(title, version) / Swagger(title, version, outputDir)
func (that *TestApp) Swagger(args ...string) *TestApp {
that.swaggerEnabled = true
that.swaggerTitle = "API"
that.swaggerVersion = "1.0.0"
if len(args) > 0 && args[0] != "" {
that.swaggerTitle = args[0]
}
if len(args) > 1 && args[1] != "" {
that.swaggerVersion = args[1]
}
if len(args) > 2 && args[2] != "" {
that.swaggerDir = args[2]
}
return that
}
// Proj 注册路由与单接口测试,并完成 Init
func (that *TestApp) Proj(projects TestProj) *TestApp {
that.projs = projects
that.ensureInit()
return that
}
// Flows 注册多接口业务流程测试(可选)
func (that *TestApp) Flows(flows FlowTest) *TestApp {
that.flows = flows
return that
}
// Run 执行测试:注册优雅停测信号 → m.Run → 覆盖率 → Swagger 收尾,返回 exit code
func (that *TestApp) Run(m *testing.M) int {
that.ensureInit()
that.installStopSignals()
code := m.Run()
if that.Application != nil {
_ = that.Db.RollbackTestTx()
}
that.PrintCoverage()
if that.swaggerEnabled {
dir := that.swaggerOutputDir()
if err := that.GenerateSwagger(that.swaggerTitle, that.swaggerVersion, dir); err != nil {
fmt.Printf("Swagger 收尾失败: %v\n", err)
}
}
return code
}
func (that *TestApp) swaggerOutputDir() string {
if that.swaggerDir != "" {
return that.swaggerDir
}
if that.Application != nil && that.Config != nil {
if d := that.Config.GetString("tpt"); d != "" {
return d
}
}
return "tpt"
}
func (that *TestApp) installStopSignals() {
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
<-sigCh
fmt.Println("\n收到停测信号,将在当前接口/流程结束后停止并回滚测试事务…")
that.RequestStop()
}()
}
// RequestStop 模拟优雅停测(等同收到 SIGINT):不再开启新的接口/流程,当前跑完后回滚并收尾
func (that *TestApp) RequestStop() {
that.stopNewTests.Store(true)
}
func (that *TestApp) shouldStop() bool {
return that.stopNewTests.Load()
}
func (that *TestApp) ensureInit() {
that.initOnce.Do(func() {
if that.workDir != "" {
if err := os.Chdir(that.workDir); err != nil {
fmt.Printf("WorkDir(%s) 失败: %v\n", that.workDir, err)
}
}
if that.configPath == "" {
return
}
origStdout := os.Stdout
origStderr := os.Stderr
devNull, _ := os.Open(os.DevNull)
os.Stdout = devNull
os.Stderr = devNull
app := Init(that.configPath)
os.Stdout = origStdout
os.Stderr = origStderr
devNull.Close()
app.WebConnectLog = nil
app.Log.SetOutput(io.Discard)
for _, lis := range that.listeners {
app.SetConnectListener(lis)
}
that.Application = app
if that.collector == nil {
that.collector = &TestCollector{}
}
if that.projs != nil {
router := Router{}
for projName, projDef := range that.projs {
router[projName] = projDef.Proj
}
app.SetupForTest(router)
}
if app.HoTimeCache != nil {
app.HoTimeCache.DisableDbCache()
}
app.Log.SetOutput(os.Stderr)
that.inited = true
})
}
// TestResponse httptest 的原始响应封装
@@ -59,29 +238,37 @@ type FailedCase struct {
// TestRecord 单条测试记录
type TestRecord struct {
Path string
Desc string
CaseName string
Passed bool
Duration time.Duration
Method string
Query map[string]interface{}
JsonBody interface{}
FormBody map[string]interface{}
HasFile bool
FileField string
ResponseBody map[string]interface{}
Note string
ExpectResult interface{}
VerifyError string
FailReason string
Path string
Desc string
CaseName string
Passed bool
Duration time.Duration
Method string
Query map[string]interface{}
JsonBody interface{}
FormBody map[string]interface{}
HasFile bool
FileField string
ResponseBody map[string]interface{}
Note string
ExpectResult interface{}
VerifyError string
FailReason string
FlowName string
FlowGroup string
FlowDesc string
FlowPrep string
FlowStep string
FlowStepIndex int
BindCase string // FromCase 绑定的单接口验收用例名
}
// TestCollector 线程安全的测试记录收集器
type TestCollector struct {
mu sync.Mutex
Records []TestRecord
Visited map[string]bool // 被 RunTests 实际调用过的路径(不论是否产生记录)
mu sync.Mutex
Records []TestRecord
Visited map[string]bool
VisitedFlows map[string]bool
}
func (c *TestCollector) Add(r TestRecord) {
@@ -90,7 +277,7 @@ func (c *TestCollector) Add(r TestRecord) {
c.Records = append(c.Records, r)
}
// MarkVisited 标记路径已被测试框架调用(由 RunTests 在进入方法级 t.Run 时调用)
// MarkVisited 标记路径已被测试框架调用
func (c *TestCollector) MarkVisited(path string) {
c.mu.Lock()
defer c.mu.Unlock()
@@ -100,47 +287,14 @@ func (c *TestCollector) MarkVisited(path string) {
c.Visited[path] = true
}
// NewTestApp 创建测试应用实例
// configPath: 配置文件路径(如 "../config/config.json"
// projects: 项目定义(路由 + 测试)
// listeners: 可选的请求拦截器(与 SetConnectListener 相同)
func NewTestApp(configPath string, projects TestProj, listeners ...func(*Context) bool) *TestApp {
origStdout := os.Stdout
origStderr := os.Stderr
devNull, _ := os.Open(os.DevNull)
os.Stdout = devNull
os.Stderr = devNull
app := Init(configPath)
os.Stdout = origStdout
os.Stderr = origStderr
devNull.Close()
app.WebConnectLog = nil
app.Log.SetOutput(io.Discard)
for _, lis := range listeners {
app.SetConnectListener(lis)
}
router := Router{}
for projName, projDef := range projects {
router[projName] = projDef.Proj
}
app.SetupForTest(router)
if app.HoTimeCache != nil {
app.HoTimeCache.DisableDbCache()
}
app.Log.SetOutput(os.Stderr)
return &TestApp{
Application: app,
projs: projects,
collector: &TestCollector{},
// MarkFlowVisited 标记业务流程已运行
func (c *TestCollector) MarkFlowVisited(name string) {
c.mu.Lock()
defer c.mu.Unlock()
if c.VisitedFlows == nil {
c.VisitedFlows = map[string]bool{}
}
c.VisitedFlows[name] = true
}
// SetupForTest 初始化路由(复用 Run 的路由注册逻辑,但不启动 HTTP 服务)
@@ -199,34 +353,48 @@ func (that *TestApp) TestRequest(method, path string, body *http.Request) TestRe
}
}
// RunTests 运行所有注册的测试,每个方法级别使用事务隔离
// RunTests 运行所有注册的测试;方法级事务隔离;另运行 Flows
func (that *TestApp) RunTests(t *testing.T) {
for projName, projDef := range that.projs {
projName := projName
projDef := projDef
that.ensureInit()
projNames := sortedKeys(that.projs)
for _, projName := range projNames {
projDef := that.projs[projName]
if that.shouldStop() {
t.Skip("收到停测信号,跳过剩余测试")
return
}
t.Run(projName, func(t *testing.T) {
for ctrName, ctrTest := range projDef.Tests {
ctrName := ctrName
ctrTest := ctrTest
ctrNames := sortedKeys(projDef.Tests)
for _, ctrName := range ctrNames {
ctrTest := projDef.Tests[ctrName]
if that.shouldStop() {
t.Skip("收到停测信号,跳过剩余测试")
return
}
t.Run(ctrName, func(t *testing.T) {
for methodName, apiTest := range ctrTest {
methodName := methodName
apiTest := apiTest
t.Run(methodName, func(t *testing.T) {
path := "/" + projName + "/" + ctrName + "/" + methodName
that.collector.MarkVisited(path)
err := that.Db.BeginTestTx()
if err != nil {
t.Fatal("开启测试事务失败:", err)
methodNames := sortedKeys(ctrTest)
for _, methodName := range methodNames {
apiTest := ctrTest[methodName]
if that.shouldStop() {
t.Skip("收到停测信号,跳过剩余接口")
return
}
defer that.Db.RollbackTestTx()
t.Run(methodName, func(t *testing.T) {
path := "/" + projName + "/" + ctrName + "/" + methodName
that.collector.MarkVisited(path)
methodBuf := &bytes.Buffer{}
that.Db.SetTestLogBuffer(methodBuf)
defer that.Db.SetTestLogBuffer(nil)
err := that.Db.BeginTestTx()
if err != nil {
t.Fatal("开启测试事务失败:", err)
}
defer that.Db.RollbackTestTx()
api := &Api{
methodBuf := &bytes.Buffer{}
that.Db.SetTestLogBuffer(methodBuf)
defer that.Db.SetTestLogBuffer(nil)
api := &Api{
app: that,
path: path,
t: t,
@@ -235,12 +403,73 @@ func (that *TestApp) RunTests(t *testing.T) {
api.t = t
apiTest.Func(api)
})
if that.swaggerEnabled {
that.flushEndpointSwagger(projName, path)
}
})
}
})
}
})
}
if len(that.flows) == 0 {
return
}
t.Run("flows", func(t *testing.T) {
flowNames := sortedKeys(that.flows)
for _, flowName := range flowNames {
flowDef := that.flows[flowName]
if that.shouldStop() {
t.Skip("收到停测信号,跳过剩余流程")
return
}
t.Run(flowName, func(t *testing.T) {
that.collector.MarkFlowVisited(flowName)
err := that.Db.BeginTestTx()
if err != nil {
t.Fatal("开启流程测试事务失败:", err)
}
defer that.Db.RollbackTestTx()
methodBuf := &bytes.Buffer{}
that.Db.SetTestLogBuffer(methodBuf)
defer that.Db.SetTestLogBuffer(nil)
flow := &Flow{
app: that,
t: t,
name: flowName,
group: flowDef.Group,
desc: flowDef.Desc,
prep: flowDef.Prep,
}
desc := flowDef.Desc
if desc == "" {
desc = flowName
}
t.Run(desc, func(t *testing.T) {
flow.t = t
flowDef.Func(flow)
})
if that.swaggerEnabled {
that.flushFlowSwagger(flowName)
}
})
}
})
}
func sortedKeys[M ~map[string]V, V any](m M) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// PrintCoverage 输出 API 测试覆盖率报告
@@ -250,6 +479,9 @@ func (that *TestApp) PrintCoverage() CoverageReport {
pathRecords := map[string][]TestRecord{}
that.collector.mu.Lock()
for _, r := range that.collector.Records {
if r.FlowName != "" {
continue
}
pathRecords[r.Path] = append(pathRecords[r.Path], r)
}
visited := that.collector.Visited
@@ -296,7 +528,6 @@ func (that *TestApp) PrintCoverage() CoverageReport {
}
}
// 汇总全部用例统计
for _, d := range report.Details {
report.TotalCases += d.CaseCount
report.PassedCases += d.Passed
@@ -306,7 +537,6 @@ func (that *TestApp) PrintCoverage() CoverageReport {
ranCount := len(visited)
isPartialRun := ranCount > 0 && ranCount < report.Covered
// 分拣本次运行中通过/失败的接口
var passedDetails, failedDetails []MethodCoverage
for _, d := range report.Details {
if !d.HasTest || d.CaseCount == 0 {
@@ -322,7 +552,6 @@ func (that *TestApp) PrintCoverage() CoverageReport {
}
}
// 计算本次运行的用例统计(部分运行时只统计实际运行的接口)
ranInterfaces := len(failedDetails) + len(passedDetails)
var ranTotalCases, ranPassedCases, ranFailedCases int
if isPartialRun {
+797 -121
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
package hotime
import "testing"
func TestTruncateUA_ShortUnchanged(t *testing.T) {
ua := "Mozilla/5.0 (Test)"
if got := truncateUA(ua, 200); got != ua {
t.Fatalf("短串不应被截断: got=%q want=%q", got, ua)
}
}
func TestTruncateUA_LongTruncatedTo200(t *testing.T) {
long := ""
for i := 0; i < 300; i++ {
long += "a"
}
got := truncateUA(long, 200)
if len([]rune(got)) != 200 {
t.Fatalf("截断后长度=%d want 200", len([]rune(got)))
}
if got != long[:200] {
t.Fatalf("ASCII 截断内容不符: got=%q", got)
}
}
func TestTruncateUA_MultiByteNotBroken(t *testing.T) {
// 前 199 个 ASCII + 1 个多字节字符("中"占 3 字节),max=200 时应完整保留该 rune 而非切碎字节
prefix := ""
for i := 0; i < 199; i++ {
prefix += "a"
}
long := prefix + "中" + "extra-should-be-cut"
got := truncateUA(long, 200)
runes := []rune(got)
if len(runes) != 200 {
t.Fatalf("按 rune 截断后应为 200 个字符, got=%d (%q)", len(runes), got)
}
if runes[199] != '中' {
t.Fatalf("第 200 个字符应为完整的 '中',实际=%q", string(runes[199]))
}
}
+2 -2
View File
@@ -56,12 +56,12 @@ var Config = Map{
}
var ConfigNote = Map{
"logLevel": "默认1,必须,0=仅打印错误日志,>=1=打印全部日志(debug/info/warn/error),同时控制SQL日志Cache-Control0=public>=1=no-cache",
"logLevel": "默认1,必须,0=仅打印错误日志,>=1=打印全部日志(debug/info/warn/error),同时控制 SQL 日志;不控制静态 Cache-Control静态固定 public",
"logFile": "无默认,非必须,如果需要存储日志文件时使用,保存格式为:a/b/c/20060102150405.txt,将生成:a/b/c/年月日时分秒.txt,按需设置",
"logHistory": "默认100,非必须,内存中保留最近N条错误日志,用于调试调阅,通过 Log.GetRecentErrors() 获取",
"webConnectLogShow": "默认true,非必须,访问日志如果需要web访问链接、访问ip、访问时间打印,false为关闭true开启此功能",
"webConnectLogFile": "无默认,非必须,webConnectLogShow开启之后才能使用,如果需要存储日志文件时使用,保存格式为:a/b/c/20060102150405.txt,将生成:a/b/c/年月日时分秒.txt,按需设置",
"seqUrl": "无默认,非必须,Seq 日志平台地址,如 http://127.0.0.1:5341,填写后自动将所有日志通过异步 channel 队列推送到 Seq,空值则不激活;同时自动将 fmt.Println/标准log 包的输出也一并捕获推送;instance 字段自动拼接为 ip:port(如 192.168.1.10:8085),支持单机多进程和多服务器集群",
"seqUrl": "无默认,非必须,Seq 日志平台地址,如 http://127.0.0.1:5341,填写后自动将所有日志通过异步 channel 队列推送到 Seq,空值则不激活;同时自动将 fmt.Println/标准log 包的输出也一并捕获推送;instance 字段自动拼接为 ip:port;请求日志自动带 sid(sessionId 前12位脱敏)与 request_id(并回写 X-Request-Id),可用 that.LogBind 追加 user_id 等;访问日志 ip 按 EO-Connecting-IP→XFF非回环→X-Real-IP→RemoteAddr 选取,多源去重记 ip_chain,有 EO-Client-IPCountry 时记 ip_country;配置后控制台自动仅打 Warn/ErrorSeq 与文件仍按 logLevel 全量",
"seqApiKey": "无默认,非必须,Seq API Key,单机免费版留空即可,多用户或有认证要求时填写",
//"codeConfig": Map{
// "注释": "配置即启用,非必须,默认无",