Compare commits

...

41 Commits

Author SHA1 Message Date
hoteas 68283b5242 chore(.gitignore): 添加 macOS 特定文件 .DS_Store 到忽略列表 2026-06-28 03:50:18 +08:00
hoteas aa84bba69b 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 8921aa3b1c feat(logging): 增加 Seq 日志集成与标准输出重定向
- 新增 SeqWriter 支持,将日志异步推送到 Seq 平台,支持多进程实例区分
- 实现 redirectStdout 函数,重定向 os.Stdout 和标准 log 包输出,确保 fmt.Println 等输出被捕获并记录
- 更新 README 文档,增加 Seq 日志集成的说明与配置链接
- 扩展 Logger 结构,支持动态添加输出目标,提升日志记录灵活性
2026-05-18 13:12:51 +08:00
hoteas 6aa3417855 refactor(code): 优化数据聚合查询逻辑与条件处理
- 重构数据聚合查询流程,减少多次数据库查询,提升性能
- 引入聚合表达式列表和元数据结构,简化查询逻辑
- 优化条件判断,确保不必要的字段被正确过滤
- 改进区域统计逻辑,使用单次查询替代逐个计数,提高效率
- 更新搜索功能,确保请求数据的有效性
2026-05-17 04:08:54 +08:00
hoteas ce30117bb2 fix(docs): 更新人员管理标签为员工管理
- 将文档中的“人员管理”标签修改为“员工管理”,以提高术语的一致性和准确性。
2026-04-30 17:05:02 +08:00
hoteas 4817be07de feat(app): 更新优雅停机逻辑与请求超时设置
- 修改 DrainTimeout 和 ShutdownTimeout 的注释,明确其含义与默认值
- 优化日志信息,提供更清晰的停机状态反馈
- 调整 ShutdownTimeout 默认值为 10s,以增强请求处理的灵活性
- 移除冗余的调试日志代码,提升代码整洁性
2026-04-24 05:43:53 +08:00
hoteas 3ddb07a828 feat(app): 增强优雅停机逻辑与请求处理
- 新增 inFlight 原子计数器以跟踪当前处理的请求数
- 更新 initiateGracefulShutdown 方法,支持每秒检测在途请求,优化停机时长
- 修改日志信息,提供更清晰的停机状态反馈
- 更新 DrainTimeout 注释,明确最长等待时长与检测机制
2026-04-19 09:44:15 +08:00
hoteas 8d73953307 style(ui): 调整 API 调试控制台样式与按钮布局
- 修改 CSS 变量 `--r` 的值以增加圆角效果
- 更新展开/收起预期响应按钮的样式,提升用户体验
- 优化按钮宽度和布局,确保一致性与可读性
2026-04-16 21:32:19 +08:00
hoteas ee5904b08b refactor(tests): 优化测试用例结构与文件上传字段
- 移除多余的空行以提升代码整洁性
- 调整 swaggerTestCase 结构的缩进格式,增强可读性
- 更新文件上传字段的默认值,确保一致性
2026-04-15 01:51:52 +08:00
hoteas d3da365b35 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 b77e8d029d enhance(tests): 改进测试用例记录与覆盖率报告
- 在 ApiCase 中添加失败原因记录,提升测试结果的可读性
- 更新 CoverageReport 结构,增加总用例、通过用例和失败用例计数
- 优化 PrintCoverage 方法,支持详细输出未通过接口及其失败原因
- 更新 Swagger 生成逻辑,包含失败原因字段,增强调试信息
- 改进 SQL 错误计数逻辑,确保在测试模式下准确记录 SQL 错误
2026-04-13 06:45:16 +08:00
hoteas 0991555c2d refactor(logging): 迁移日志记录到 Zerolog 并优化错误处理
- 将日志记录库从 Logrus 替换为 Zerolog,提升性能和灵活性
- 更新各个模块的日志记录方式,确保一致性
- 优化错误处理逻辑,确保在发生错误时能够正确记录并传递错误信息
- 移除不再使用的错误处理字段,简化代码结构
- 更新相关文档以反映新的日志记录和错误处理机制
2026-04-13 00:38:50 +08:00
hoteas 298dbcbcb1 fix(db): 修复事务模式下的错误处理与回滚机制
- 在 `queryWithRetry` 和 `execWithRetry` 方法中添加对 `txFailed` 的状态管理,确保在 SQL 错误时正确标记事务失败
- 增加新的测试用例以验证事务模式下的错误处理逻辑,确保在 SQL 错误发生时能够正确回滚
- 优化测试数据库的初始化方法,支持事务测试场景
2026-04-12 00:53:22 +08:00
hoteas ee3db6cf20 ```
refactor(db): 优化事务错误状态管理

- 添加 txFailed 指针字段用于跟踪事务内SQL执行状态
- 确保事务错误状态在 HoTimeDB 实例间正确共享
- 当事务内SQL出错时强制回滚以维护数据一致性
```
2026-04-11 22:34:13 +08:00
hoteas 955897192a Merge branch 'master' into main-dmdb
# Conflicts:
#	db/db.go
2026-04-11 21:54:37 +08:00
hoteas 2f1b05f3d7 fix(db): 修复事务中SQL错误时的回滚机制
- 添加txFailed标志跟踪事务内的SQL错误状态
- 在事务模式下遇到SQL错误时立即标记事务失败并阻止重试
- 修改Action方法确保SQL出错时强制回滚事务
- 为事务失败场景添加完整的单元测试覆盖
- 防止在事务已回滚的情况下继续执行后续操作导致数据不一致
2026-04-11 21:53:28 +08:00
hoteas b151276eec fix(docs): 更新测试文档中的文件引用以反映正确的测试文件名
- 将测试注册文件名从 `app_test.go` 更正为 `init_test.go`,确保文档与代码一致
- 更新相关代码示例以匹配新的文件结构,提升文档的准确性和可读性
2026-04-04 04:11:05 +08:00
hoteas acc79a1e2d feat(logging): 增强日志记录功能以支持更详细的调试信息
- 在 GenerateSwagger 方法中添加更全面的调试日志,记录运行检查和端点保留情况
- 更新 Action 方法以优化测试模式下的日志缓冲,确保日志的准确性和完整性
- 删除不再使用的计划文档,简化项目结构
2026-04-04 01:57:55 +08:00
hoteas d0d0ce38aa feat(logging): 增强测试框架的日志记录功能
- 在 GenerateSwagger 方法中添加调试日志记录,记录部分运行检查和现有端点的保留情况
- 更新 Action 方法以支持测试模式下的日志缓冲,确保日志记录的准确性
- 删除不再使用的计划文档,简化项目结构
2026-04-04 01:57:10 +08:00
hoteas 22790ec1c0 feat(tests): 增强测试框架的 SQL 日志记录与覆盖率报告
- 在 ApiCase 中添加 SQL 日志记录功能,失败时输出日志以便于调试
- 更新 TestApp 以支持测试模式下的 SQL 日志缓冲,成功与失败的测试均可记录日志
- 改进 PrintCoverage 方法,优化覆盖率报告的输出逻辑,支持部分运行的接口显示
- 增加 Note 备注功能,提升测试用例的可读性与文档化效果
- 更新相关文档,详细说明新功能的使用场景与示例
2026-04-04 01:34:09 +08:00
hoteas ea8fbfbf7a feat(login): 添加 IP 限速机制以防止频繁登录尝试
- 在登录功能中实现同一 IP 1 分钟内最多尝试 10 次的限制
- 记录登录时间戳并过滤过期记录,确保登录尝试次数的准确性
- 优化未登录用户的返回信息,避免泄露敏感数据
2026-03-31 00:10:39 +08:00
hoteas df6bf94b0b feat(api): 增强 API 测试框架功能与文档
- 在 Api 结构中新增 lastResp 字段以存储最近请求的响应
- 添加 Verify 方法,支持自定义校验函数并返回 ApiCase 构建器
- 新增 Resp 方法,获取最近一次请求的响应以便于断言
- 在 TestCollector 中添加 Visited 字段,记录已调用的路径
- 更新 GenerateSwagger 方法,支持部分运行时保留未运行端点的已有数据
- 完善文档,增加用例编写范式和示例,提升测试框架的可用性与易用性
2026-03-30 01:55:07 +08:00
hoteas 34365fdaa0 feat(config): 扩展数据类型映射与数据库字段备注提取功能
- 在 ColumnDataType 中新增对 PostgreSQL 和 Oracle 数据类型的支持
- 更新 Db2JSON 方法,优化对达梦数据库表和列信息的查询逻辑
- 增强字段备注解析,支持从备注中提取提示信息并清理标签
- 更新文档,详细说明字段备注格式及前端提示的展示效果
2026-03-23 16:48:56 +08:00
hoteas 82cf5a14d2 refactor(db): 移除 Row 方法中的列名小写转换
- 在 Row 方法中删除对列名的统一小写转换,保持原始列名格式
- 简化代码逻辑,提升可读性
2026-03-21 20:51:58 +08:00
hoteas 355d19418b feat(api): 增强 API 测试框架的断言功能
- 修改 ApiCase 和 ApiResponse 结构,新增 Verify 方法以支持自定义校验函数
- 更新 Get、Post、Put、Delete 方法,允许使用 expect 参数进行更灵活的响应断言
- 添加 ExpectResult 方法,支持对响应结果的结构和类型进行后置校验
- 更新文档,详细说明新功能的使用方法和示例,提升测试框架的可用性
- 增强 Swagger 生成逻辑,支持输出新的 expect 和 verifyError 字段
2026-03-21 13:38:22 +08:00
hoteas 9c5a7630a4 refactor(db): 优化测试事务中的互斥锁使用
- 重构 queryWithRetry 和 execWithRetry 方法中的互斥锁逻辑,提升代码可读性
- 确保在测试事务中对 testMu 的锁定和解锁操作更加清晰,避免潜在的并发问题
2026-03-20 13:52:23 +08:00
hoteas 808a782db5 fix(cache): 修复 tableExists 方法以正确检测达梦数据库中的表存在性
- 修改 tableExists 方法,使用 COUNT(*) 查询当前 schema 下的表,避免因用户权限导致的误判
- 更新 ObjToFloat64、ObjToInt64 和 ObjToStr 方法,增加对多种整数和布尔类型的支持
- 在 Row 方法中处理 time.Time 类型,确保与 MySQL 兼容的字符串格式输出
2026-03-20 13:51:52 +08:00
hoteas 411c647c1a chore: 更新 .gitignore 文件,添加对 .py 文件的忽略;删除不再使用的 mysql2dm.py 文件 2026-03-20 11:54:42 +08:00
hoteas 5ad4e2e11b feat(docs): 完善达梦数据库支持文档
- 更新 .gitignore 文件,添加对 .sql 文件的忽略
- 移除 cache_db.go 中的调试日志相关代码,简化缓存操作逻辑
- 在多个文档中添加达梦 DM8 数据库的支持信息,包括配置、使用注意事项及示例
- 更新 QUICKSTART.md,明确支持的数据库类型及配置示例
- 在 ROADMAP.md 中记录达梦数据库完整支持的进展
2026-03-20 11:25:09 +08:00
hoteas b43f968b6c feat(db): 添加对达梦数据库的支持
- 在应用程序中新增对达梦数据库(DM)的配置和连接支持
- 实现 SetDmDB 函数以配置达梦数据库连接
- 更新数据库操作逻辑,支持达梦特有的 SQL 语法和功能
- 在相关文件中添加达梦数据库的处理逻辑,包括表创建、数据插入和查询
- 更新 go.mod 和 go.sum 文件以引入达梦数据库驱动
- 增强文档,详细说明达梦数据库的配置和使用方法
2026-03-20 10:46:51 +08:00
hoteas 7ab803c5cc docs: 更新 README 和 ROADMAP 文档,添加测试方法和响应字段断言改进规划
- 在 README.md 中添加测试方法说明
- 在 ROADMAP 文档中新增关于响应字段部分匹配断言的现状、改进方向及预期写法,提升文档的完整性和可读性
2026-03-16 06:53:31 +08:00
hoteas 309f113a6f feat(api): 添加备注功能以增强用例描述
- 在 Api 和 ApiCase 中新增 Note 方法,允许为用例设置可选备注
- 更新 TestRecord 结构体,包含备注字段以便记录用例信息
- 修改 Swagger 生成逻辑,支持备注字段的输出
- 更新文档,详细说明 Note 方法的使用及其在调试控制台的显示效果
2026-03-15 11:45:21 +08:00
hoteas 97a9f0964a feat(swagger): 增强 Swagger 生成逻辑与参数推断
- 新增参数推断功能,自动从测试用例中推断 API 参数的必填性、类型和示例值
- 更新 GenerateSwagger 方法,支持在生成的 API 规范中包含推断的参数信息
- 优化文档,增加对新功能的说明,包括参数推断和覆盖率字段的详细描述
- 改进调试控制台的结构,支持模块化输出和独立导航页生成
- 增强并发保护与缓存隔离机制,确保测试环境的稳定性
2026-03-15 10:36:49 +08:00
hoteas 3feaa69fe4 refactor(db): 增强测试事务和缓存管理
- 在 HoTimeDB 中新增 testMu 互斥锁,确保 testTx 的串行访问,解决并发冲突问题
- 在 BeginTestTx 中初始化 testMu,确保测试事务的安全性
- 在 HoTimeCache 中新增 DisableDbCache 方法,测试模式下禁用 DB 和 Redis 缓存,避免锁等待超时
- 更新 Select 方法,确保在 testTx 激活时跳过缓存逻辑,提升测试稳定性
- 优化 Swagger 生成逻辑,支持模块化输出和导航页生成
- 移除冗余的调试日志代码,提升代码整洁性
2026-03-14 13:06:32 +08:00
hoteas 93596ad0dc refactor(db): 增强测试事务支持和保存点管理
- 在 HoTimeDB 中新增测试事务功能,允许在测试模式下使用事务进行操作
- 实现 BeginTestTx 和 RollbackTestTx 方法,支持测试事务的开启和回滚
- 在 Action 方法中集成保存点管理,确保在测试模式下的嵌套事务处理
- 更新 README.md,添加 API 测试框架的相关说明,提升文档完整性
2026-03-14 10:19:57 +08:00
hoteas ff410b94c3 refactor(app): 优化 URL 处理和 JSON 解析逻辑
- 更新应用程序处理程序中的 URL 赋值逻辑,确保静态文件使用原始路径
- 修改缓存数据库的 JSON 解析方法,使用 JsonToObj 函数替代 json.Unmarshal,提升代码可读性和性能
- 在 Map 和 Slice 类型中新增获取四舍五入浮点数的方法,增强数据处理能力
- 在 Obj 类型中添加四舍五入功能,支持精度控制
- 改进数据库查询结果的处理逻辑,确保数据类型的准确性和一致性
- 优化日志格式设置,增强日志信息的可读性
2026-03-10 23:44:41 +08:00
hoteas c922023740 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 9949231d7c feat(app): 添加测试功能并增强错误处理
- 在应用路由中新增 test 接口用于显示测试数据
- 引入 errors 包以支持错误处理机制
- 在上下文响应处理中集成错误对象设置
- 实现 JSON 错误消息的自动转换与存储
- 扩展响应结构以同时支持 result 和 error 字段
2026-01-31 02:05:13 +08:00
hoteas 3fd0975427 refactor(log): 优化调用栈查找逻辑以提高准确性
- 注释掉批量缓存操作测试的调用,避免不必要的执行
- 改进 findCaller 函数,增加对 application.go 的优先记录逻辑
- 确保返回的调用者信息优先考虑应用层代码,提升日志信息的准确性
- 增强框架文件的过滤机制,确保更清晰的调用栈信息
2026-01-30 22:17:53 +08:00
hoteas 3d83c41905 feat(cache): 增加批量操作支持以提升性能
- 在 HoTimeCache 中新增 SessionsGet、SessionsSet 和 SessionsDelete 方法,支持批量获取、设置和删除 Session 缓存
- 优化缓存逻辑,减少数据库写入次数,提升性能
- 更新文档,详细说明批量操作的使用方法和性能对比
- 添加调试日志记录,便于追踪批量操作的执行情况
2026-01-30 17:51:43 +08:00
hoteas 27300025f3 refactor(cache): 移除调试日志并优化缓存逻辑
- 删除 CacheDb 结构体中的 debugLog 调用,清理冗余的调试日志代码
- 优化历史表的创建逻辑,简化条件检查
- 更新缓存的获取和设置方法,确保逻辑清晰且高效
- 维护代码整洁性,提升可读性和可维护性
2026-01-25 23:15:32 +08:00
675 changed files with 283745 additions and 15435 deletions
@@ -0,0 +1,299 @@
---
name: API接口测试方案
overview: hotime 框架新增链式测试 API:数据在前方法在后,Post/Get 一步完成用例+断言,TestProj 合并注册,自动生成 Swagger UI。
todos:
- id: framework-testing-helper
content: hotimev1.5/testing_helper.goTestApp、NewTestApp、SetupForTest、TestRequest/WithSession、TestResponse
status: pending
- id: framework-testing-api
content: hotimev1.5/testing_api.goTestProj/ProjTest/CtrTest、Api(JSON/Query/Form/File/WithSession→Post/Get)、RunTests
status: pending
- id: framework-testing-swagger
content: hotimev1.5/testing_swagger.goGenerateSwagger 合并所有项目生成一份 Swagger
status: pending
- id: xbc-user-test
content: xbc/app/user.go 末尾添加 UserTestinit.go 添加 ProjectTest,新增 app_test.go
status: pending
isProject: false
---
# API 接口测试 + Swagger 文档自动生成(最终版)
## 一、最终链式设计:数据在前,方法在后
每条链以 `Post/Get/Put/Delete("描述", 期望status, [期望msg])` 结尾,这个终端调用同时完成:命名用例 + 发送请求 + 校验结果 + 收集文档。
```go
// 数据 → 方法(终端操作)
a.JSON(Map{"name": "138", "password": "123"}).Post("正常登录", 0)
a.JSON(Map{"name": "138", "password": ""}).Post("密码为空", 4, "用户名或密码不能为空")
a.Get("未登录", 2, "请先登录")
a.Query(Map{"shop_id": "1"}).Get("查询列表", 0)
a.WithSession(Map{"user_id": int64(1)}).Get("已登录", 0)
a.Query(Map{"token": "abc"}).JSON(Map{"phone": "138"}).Post("混合传参", 3, "异常")
a.File("file", "a.png", imgBytes).Post("上传文件", 0)
```
### 对比之前的写法
```
之前(三步):a.PostCase("正常登录").JSON(Map{...}).Test(0)
之前(两步):a.Post("正常登录", Map{...}).Test(0)
现在(一步):a.JSON(Map{...}).Post("正常登录", 0) ← 终端操作只有一个
现在(零数据):a.Get("未登录", 2, "请先登录") ← 最简一行
```
## 二、完整场景对照
```go
// POST JSON(最常见)
a.JSON(Map{"name": "138", "password": "123"}).Post("正常登录", 0)
// GET 无参数
a.Get("未登录", 2, "请先登录")
// GET + URL 参数
a.Query(Map{"shop_id": "1", "page": "1"}).Get("查询列表", 0)
// 需登录 + GET
a.WithSession(Map{"user_id": int64(1)}).Get("已登录", 0)
// 需登录 + GET + 参数
a.WithSession(Map{"user_id": int64(1)}).Query(Map{"shop_id": "1"}).Get("查询", 0)
// 需登录 + POST JSON
a.WithSession(Map{"user_id": int64(1)}).JSON(Map{"name": "new"}).Post("更新", 0)
// 混合:URL 参数 + JSON body
a.Query(Map{"token": "abc"}).JSON(Map{"phone": "138"}).Post("混合传参", 3, "异常")
// POST Form 表单
a.Form(Map{"shop_id": "1", "id": "1"}).Post("表单提交", 0)
// 文件上传
a.File("file", "a.png", imgBytes).Post("上传文件", 0)
// 文件 + 表单字段
a.File("file", "a.png", imgBytes).Form(Map{"type": "avatar"}).Post("上传+表单", 0)
// PUT JSON
a.JSON(Map{"name": "新名字"}).Put("更新信息", 0)
// DELETE + 参数
a.Query(Map{"id": "1"}).Delete("删除", 0)
// 校验响应数据(status=0 时)
resp := a.JSON(Map{"name": "138", "password": "123"}).Post("正常登录", 0)
if resp.GetBody().GetMap("result").GetString("token") == "" {
resp.Fail("缺少 token")
}
```
## 三、完整示例:user.go
```go
var UserCtr = Ctr{
"login": func(that *Context) { /* ... */ },
"info": func(that *Context) { /* ... */ },
"token": func(that *Context) { /* ... */ },
"create": func(that *Context) { /* ... */ },
"file": func(that *Context) { /* ... */ },
}
// ========= 接口测试 =========
var UserTest = CtrTest{
"login": {"用户账号密码登录", func(a *Api) {
a.JSON(Map{"name": "13800138000", "password": "123456"}).Post("正常登录", 0)
a.JSON(Map{"name": "13800138000", "password": ""}).Post("密码为空", 4, "用户名或密码不能为空")
a.JSON(Map{"name": "1380013", "password": "123456"}).Post("手机号格式错误", 4, "手机号码格式错误")
a.JSON(Map{"name": "13800138000", "password": "wrong"}).Post("密码错误", 4, "用户名或密码错误")
}},
"info": {"获取用户信息", func(a *Api) {
a.Get("未登录", 2, "请先登录")
a.WithSession(Map{"user_id": int64(1)}).Get("已登录", 0)
}},
"token": {"获取会话Token", func(a *Api) {
a.JSON(Map{"phone": "13800138000"}).Post("正确手机号", 0)
a.JSON(Map{"phone": "138"}).Post("手机号格式错误", 3, "请输入正确的手机号")
}},
"create": {"用户注册", func(a *Api) {
phone := "138" + ObjToStr(RandX(10000000, 99999999))
defer a.DB().Delete("user", Map{"phone": phone})
a.JSON(Map{"phone": phone, "password": "123456"}).Post("正常注册", 0)
a.JSON(Map{"phone": phone, "password": "123456"}).Post("重复注册", 3, "该手机号已被注册")
a.JSON(Map{"phone": "138", "password": "123456"}).Post("手机号格式错误", 3, "请输入正确的手机号")
a.JSON(Map{"phone": "13899999999", "password": "12"}).Post("密码太短", 3, "密码不能少于6位")
}},
"forget": {"忘记密码", func(a *Api) {
a.JSON(Map{"phone": "13800138000"}).Post("无token", 3, "异常")
a.Query(Map{"token": "abc"}).JSON(Map{"phone": "138", "code": "1234"}).Post("手机号错误", 3, "请输入正确的手机号")
}},
"file": {"文件上传", func(a *Api) {
a.File("file", "test.txt", []byte("hello")).Post("未登录上传", 2, "你还没有登录")
a.WithSession(Map{"user_id": int64(1)}).File("file", "test.txt", []byte("hello")).Post("已登录上传", 0)
}},
}
```
## 四、TestProj 注册 + app_test.go
### init.go
```go
var Project = Proj{
"user": UserCtr,
"goods": GoodsCtr,
// ...
}
var ProjectTest = ProjTest{
"user": UserTest,
"goods": GoodsTest,
}
```
### app_test.go
```go
package app
import (
"os"
"testing"
. "code.hoteas.com/golang/hotime"
)
var testApp *TestApp
func TestMain(m *testing.M) {
testApp = NewTestApp("../config/config.json", TestProj{
"app": {Project, ProjectTest},
})
code := m.Run()
testApp.GenerateSwagger("小帮菜 API", "2.1.0", testApp.Config.GetString("tpt"))
os.Exit(code)
}
func TestApi(t *testing.T) {
testApp.RunTests(t)
}
```
## 五、Swagger:多项目合并为一份文档
`GenerateSwagger` 遍历 `TestProj` 中所有项目的所有测试数据,生成**一份合并的 OpenAPI JSON**。每个项目作为 Swagger 的 tag 分组:
```json
{
"tags": [
{"name": "app/user", "description": "用户接口"},
{"name": "app/goods", "description": "货品接口"},
{"name": "customer/customer", "description": "客户端接口"}
],
"paths": {
"/app/user/login": { ... },
"/app/user/info": { ... },
"/customer/customer/bindPhone": { ... }
}
}
```
多项目不会覆盖,全部汇聚在同一个文件中,按 tag 分组展示。
## 六、指定运行范围
Go 的 `-run` 参数支持按子测试层级过滤(`/` 分隔正则匹配):
```bash
# 全部
go test ./app/... -v
# 只跑 user 模块的所有接口
go test ./app/... -v -run TestApi/user
# 只跑 user 模块的 login 接口
go test ./app/... -v -run TestApi/user/login
# 只跑 login 接口的"密码为空"用例
go test ./app/... -v -run TestApi/user/login/密码为空
# 跑 user 和 goods 两个模块
go test ./app/... -v -run "TestApi/(user|goods)"
# 只跑所有模块中包含"未登录"的用例
go test ./app/... -v -run "TestApi/.*/.*未登录"
```
**不需要框架做任何额外处理**Go 的 `t.Run()` 子测试机制天然支持。
## 七、框架类型设计
```go
// === 注册类型 ===
type TestProj map[string]TestProjDef
type TestProjDef struct { Proj Proj; Tests ProjTest }
type ProjTest map[string]CtrTest
type CtrTest map[string]ApiTestDef
type ApiTestDef struct { Desc string; Func func(a *Api) }
// === Api:起点,数据设置 ===
type Api struct {
app *TestApp
path string
t interface{}
desc string
session Map
}
// 数据设置(返回 *ApiCase,开始构建)
func (a *Api) JSON(body interface{}) *ApiCase
func (a *Api) Query(params Map) *ApiCase
func (a *Api) Form(body Map) *ApiCase
func (a *Api) File(field, name string, content []byte) *ApiCase
// Session(返回新 *Api,后续调用都带此 Session
func (a *Api) WithSession(s Map) *Api
// 直接终端(无数据时直接调用)
func (a *Api) Get(desc string, status int, msg ...string) *ApiResponse
func (a *Api) Post(desc string, status int, msg ...string) *ApiResponse
func (a *Api) Put(desc string, status int, msg ...string) *ApiResponse
func (a *Api) Delete(desc string, status int, msg ...string) *ApiResponse
// 数据库
func (a *Api) DB() *HoTimeDB
// === ApiCase:链式构建器 ===
type ApiCase struct { /* api, session, query, jsonBody, formBody, file... */ }
// 继续叠加数据
func (c *ApiCase) JSON(body interface{}) *ApiCase
func (c *ApiCase) Query(params Map) *ApiCase
func (c *ApiCase) Form(body Map) *ApiCase
func (c *ApiCase) File(field, name string, content []byte) *ApiCase
func (c *ApiCase) WithSession(s Map) *ApiCase
// 终端操作(HTTP 方法 + 用例名 + 断言)
func (c *ApiCase) Get(desc string, status int, msg ...string) *ApiResponse
func (c *ApiCase) Post(desc string, status int, msg ...string) *ApiResponse
func (c *ApiCase) Put(desc string, status int, msg ...string) *ApiResponse
func (c *ApiCase) Delete(desc string, status int, msg ...string) *ApiResponse
// === ApiResponse ===
type ApiResponse struct { StatusCode int; Body Map; RawBody []byte }
func (r *ApiResponse) GetStatus() int
func (r *ApiResponse) GetResult() interface{}
func (r *ApiResponse) GetMsg() string
func (r *ApiResponse) GetBody() Map
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`
@@ -0,0 +1,291 @@
---
name: API测试框架完整实现
overview: 在 hotimev1.5 框架中实现完整的 API 测试基础设施:链式测试 API、事务回滚隔离、覆盖率追踪、Swagger 文档自动生成。
todos:
- id: db-testtx
content: db/db.go:加 testTx 字段 + BeginTestTx/RollbackTestTx 方法
status: completed
- id: db-query
content: db/query.goQuery(行64) 和 Exec(行120) 加 testTx 优先判断
status: completed
- id: db-transaction
content: db/transaction.goAction 加 testTx 传递 + SAVEPOINT 分支
status: completed
- id: testing-helper
content: 新增 testing_helper.goTestApp、NewTestApp、SetupForTest、RunTests(事务隔离)、PrintCoverage
status: completed
- id: testing-api
content: 新增 testing_api.go:注册类型 + Api/ApiCase/ApiResponse 链式 API + TestCollector
status: completed
- id: testing-swagger
content: 新增 testing_swagger.goGenerateSwagger 合并所有项目生成 Swagger
status: completed
- id: xbc-integration
content: xbc 业务层:user.go 加 UserTest、init.go 加 ProjectTest、新增 app_test.go
status: completed
isProject: false
---
# API 测试框架完整实现
## 一、事务回滚隔离(db 层改造)
核心思路:给 `HoTimeDB` 增加 `testTx` 字段,优先级 `testTx > Tx > DB`。测试模式下 `Action()` 用 MySQL `SAVEPOINT` 代替真事务,保证嵌套事务都在外层测试事务内。
### 1. [db/db.go](d:/work/hotimev1.5/db/db.go) -- 加字段 + 方法
```go
// HoTimeDB 结构体新增字段
testTx *sql.Tx // 测试事务:设置后所有操作都在此事务内
// 新增两个方法
func (that *HoTimeDB) BeginTestTx() error
func (that *HoTimeDB) RollbackTestTx() error
```
### 2. [db/query.go](d:/work/hotimev1.5/db/query.go) -- Query(行64) 和 Exec(行120) 各加一个判断
```go
// 原:if that.Tx != nil { ... } else { ... }
// 改:
if that.testTx != nil {
resl, err = that.testTx.Query(query, processedArgs...)
} else if that.Tx != nil {
resl, err = that.Tx.Query(query, processedArgs...)
} else {
resl, err = db.Query(query, processedArgs...)
}
// Exec 同理
```
### 3. [db/transaction.go](d:/work/hotimev1.5/db/transaction.go) -- Action 加 SAVEPOINT 分支
```go
func (that *HoTimeDB) Action(action func(db HoTimeDB) (isSuccess bool)) (isSuccess bool) {
db := HoTimeDB{
// ...现有字段拷贝...
testTx: that.testTx, // 新增:传递 testTx
}
// 新增:测试模式用 SAVEPOINT
if that.testTx != nil {
spName := "sp_" + Md5(ObjToStr(RandX(100000, 999999)))[:8]
_, _ = that.testTx.Exec("SAVEPOINT " + spName)
db.Tx = that.testTx
isSuccess = action(db)
if !isSuccess {
_, _ = that.testTx.Exec("ROLLBACK TO SAVEPOINT " + spName)
} else {
_, _ = that.testTx.Exec("RELEASE SAVEPOINT " + spName)
}
return isSuccess
}
// 以下原有逻辑不变...
}
```
### 3.1 testTx 并发保护(调试发现的关键问题)
**问题现象:** 测试运行时出现 `[mysql] busy buffer``bad connection` 错误,导致测试挂起。
**根因分析:** `testTx` 是单个 `*sql.Tx` 连接,MySQL 驱动不允许在同一连接上并发执行查询。业务代码中存在 `go func()` 启动的 goroutine(如 `syncGoodsToYst``stats.go` 中的统计协程),这些 goroutine 会并发访问 `testTx`,导致驱动层的 `busy buffer` 错误。
**解决方案:**`HoTimeDB` 中新增 `testMu *sync.Mutex`,在 `BeginTestTx()` 时初始化。所有通过 `testTx` 执行的 Query、Exec、SAVEPOINT 操作都通过 `testMu` 加锁保护,确保串行化访问。
```go
// db/db.go - HoTimeDB 新增字段
testMu *sync.Mutex // 保护 testTx 单连接不被并发访问
// db/db.go - BeginTestTx 中初始化
that.testMu = &sync.Mutex{}
// db/query.go - Query 中加锁
if that.testTx != nil {
if that.testMu != nil { that.testMu.Lock() }
resl, err = that.testTx.Query(query, processedArgs...)
// ... 消费 result set ...
if that.testMu != nil { that.testMu.Unlock() }
}
// db/query.go - Exec 中加锁
if that.testTx != nil {
if that.testMu != nil { that.testMu.Lock() }
resl, e = that.testTx.Exec(query, processedArgs...)
if that.testMu != nil { that.testMu.Unlock() }
}
// db/transaction.go - SAVEPOINT 操作加锁
if that.testMu != nil { that.testMu.Lock() }
_, _ = that.testTx.Exec("SAVEPOINT " + spName)
if that.testMu != nil { that.testMu.Unlock() }
```
**注意事项:**
- 生产环境不受影响(`testMu` 默认 nil,所有锁检查都有 nil 保护)
- 互斥锁确保 goroutine 中的 DB 操作排队执行而非并发冲突
- Query 锁的范围包括结果集消费(`that.Row(resl)`),防止未读完数据就发起新查询
**改动量:** 3 个文件,约 50 行核心改动 + mutex 保护约 30 行,生产代码零影响(testTx/testMu 默认 nil)。
### 3.2 测试模式下禁用 DB/Redis 缓存(调试发现的关键问题)
**问题现象:** 测试运行时出现 `Error 1205: Lock wait timeout exceeded` 错误,指向 `DELETE FROM cached` 操作。
**根因分析:** 框架的 `HoTimeCache` 支持三级缓存(memory > redis > db)。在测试模式下,DB 缓存的读写操作(如 `DELETE FROM cached`)会尝试使用独立连接,而 `testTx` 持有事务锁,导致 `Lock wait timeout`。即使走 `testTx`,缓存的并发读写也与事务隔离产生冲突。
**解决方案:**
1.`cache/cache.go` 中新增 `DisableDbCache()` 方法:
```go
func (that *HoTimeCache) DisableDbCache() {
that.dbCache = nil
that.redisCache = nil
}
```
2.`testing_helper.go``NewTestApp` 中调用:
```go
if app.HoTimeCache != nil {
app.HoTimeCache.DisableDbCache()
}
```
3.`db/crud.go``Select` 方法中,当 `testTx` 激活时跳过缓存逻辑:
```go
if that.testTx == nil {
// 原有缓存读写逻辑
}
```
**影响范围:** 测试期间仅使用 memory 缓存,不影响生产环境。Session 在测试中通过 `WithSession()` 直接注入,不依赖 DB/Redis 缓存。
### 3.3 测试模式下 Query 的重试策略调整
**问题:** `queryWithRetry``execWithRetry` 在遇到连接错误时会调用 `db.Ping()` 尝试重连,但 `testTx` 是单连接事务,Ping 会创建新连接导致 `busy buffer`
**解决:**`testTx != nil` 时,跳过 `db.Ping()` 和重试逻辑,直接返回错误。
## 二、链式测试 API
### 4. 新增 [testing_helper.go](d:/work/hotimev1.5/testing_helper.go)
- `TestApp` 结构体(包含 `*Application``projs TestProj``collector *TestCollector`
- `NewTestApp(configPath, TestProj, listeners...)` -- 初始化 Application、注册路由,不启动 HTTP 服务
- `SetupForTest(router Router)` -- 复用 `Run()` 的路由初始化逻辑
- `TestRequest(method, path, body) TestResponse` / `TestRequestWithSession(...)` -- 通过 `httptest` 发请求
- `TestResponse` 结构体
- `RunTests(t)` -- 遍历 TestProj,每个方法级别 `BeginTestTx() + defer RollbackTestTx()`
### 5. 新增 [testing_api.go](d:/work/hotimev1.5/testing_api.go)
注册类型:
- `TestProj map[string]TestProjDef`
- `TestProjDef struct { Proj Proj; Tests ProjTest }`
- `ProjTest map[string]CtrTest`
- `CtrTest map[string]ApiTestDef`
- `ApiTestDef struct { Desc string; Func func(a *Api) }`
链式 API
- `Api` -- 起点,提供 `JSON/Query/Form/File/WithSession` 数据设置和 `Get/Post/Put/Delete` 直接终端
- `ApiCase` -- 链式构建器,支持叠加多种数据类型,终端操作 `Get/Post/Put/Delete(desc, status, msg...)`
- `ApiResponse` -- 响应对象,提供 `GetStatus/GetResult/GetMsg/GetBody/Fail`
- 每个终端方法内自动记录 `TestRecord``TestCollector`
### 6. 新增 [testing_swagger.go](d:/work/hotimev1.5/testing_swagger.go)
- `GenerateSwagger(title, version, outputDir)` -- 遍历 TestProj 生成合并的 OpenAPI 3.0 JSON + Swagger UI HTML
- 利用覆盖率数据,未覆盖接口在文档中标注"暂无测试用例"
## 三、覆盖率追踪
集成在 `testing_helper.go` 中:
- `CoverageReport` / `MethodCoverage` / `TestRecord` / `TestCollector` 类型
- `PrintCoverage()` -- 对比 `Proj`(所有路由)和 `ProjTest`(有测试的路由),输出覆盖率报告
- 运行时追踪:每个 `Post/Get/Put/Delete` 终端方法自动收集 `TestRecord`(路径、用例名、通过/失败、耗时)
- 最终输出:总接口数、已覆盖数、覆盖率百分比、每个接口的用例数和通过/失败计数
## 四、业务层接入(xbc 示例)
### 7. 修改 [xbc/app/user.go](d:/work/xbc/app/user.go) -- 末尾添加 `var UserTest = CtrTest{...}`
```go
var UserTest = CtrTest{
"login": {"用户登录", func(a *Api) {
a.JSON(Map{"name": "138", "password": "123456"}).Post("正常登录", 0)
// ...
}},
"create": {"用户注册", func(a *Api) {
phone := "138" + ObjToStr(RandX(10000000, 99999999))
// 不需要 defer 清理,事务自动回滚
a.JSON(Map{"phone": phone, "password": "123456"}).Post("正常注册", 0)
a.JSON(Map{"phone": phone, "password": "123456"}).Post("重复注册", 3, "该手机号已被注册")
}},
}
```
### 8. 修改 [xbc/app/init.go](d:/work/xbc/app/init.go) -- 添加 `var ProjectTest = ProjTest{...}`
### 9. 新增 [xbc/app/app_test.go](d:/work/xbc/app/app_test.go)
```go
func TestMain(m *testing.M) {
testApp = NewTestApp("../config/config.json", TestProj{
"app": {Project, ProjectTest},
})
code := m.Run()
testApp.PrintCoverage()
testApp.GenerateSwagger("小帮菜 API", "2.1.0", testApp.Config.GetString("tpt"))
os.Exit(code)
}
func TestApi(t *testing.T) {
testApp.RunTests(t)
}
```
## 五、文件改动汇总
### hotimev1.5 框架层
- **修改** `db/db.go` -- 加 testTx + testMu 字段,BeginTestTx(初始化 mutex/RollbackTestTx
- **修改** `db/query.go` -- Query/Exec 各加 testTx 判断 + testMu 互斥锁保护 + 跳过重试逻辑
- **修改** `db/transaction.go` -- Action 加 SAVEPOINT 分支 + testTx/testMu 传递 + SAVEPOINT 操作加锁
- **修改** `db/crud.go` -- Select 中 testTx 激活时跳过缓存逻辑
- **修改** `cache/cache.go` -- 新增 DisableDbCache() 方法 + 新增 SessionsGet/SessionsSet/SessionsDelete 批量操作
- **修改** `session.go` -- 对接批量 Session 缓存操作
- **修改** `testing_helper.go` -- NewTestApp 中调用 DisableDbCache()
- **新增** `testing_helper.go` -- TestApp、RunTests、覆盖率
- **新增** `testing_api.go` -- 链式 API + TestCollector
- **新增** `testing_swagger.go` -- Swagger 生成
### xbc 业务层
- **修改** `app/user.go` -- 末尾添加 UserTest
- **修改** `app/init.go` -- 添加 ProjectTest
- **新增** `app/app_test.go` -- 测试入口(含 Swagger 生成)
- **新增** `app/*_test.go` -- 30+ 个控制器测试文件
- **新增** `wx/wx_test.go` + `wx/*_test.go` -- 微信端测试入口 + 7 个控制器测试
- **新增** `dd/dd_test.go` + `dd/*_test.go` -- 钉钉端测试入口 + 6 个控制器测试
- **新增** `customer/customer_h5_test.go` + `customer/*_test.go` -- H5 客户端测试
- **新增** `sms/sms_test.go` + `sms/sms_ctr_test.go` -- 短信服务测试
- **修改** `app/wechat.go` -- 修复 log.Println -> log.Printfgo vet 警告)
- **修改** `customer/init.go` -- 修复 log.Println -> log.Printfgo vet 警告)
## 六、已知问题与调试经验
### 问题一:busy buffer / bad connection
- **现象:** 测试运行数秒后出现 `[mysql] busy buffer`,随后 `bad connection`,测试挂起
- **根因:** 业务代码中 `go func()` 启动的 goroutine(如 `app/goods.go:syncGoodsToYst``app/stats.go` 中的统计协程)并发访问 `testTx` 单连接
- **解决:** 在 `HoTimeDB` 中新增 `testMu *sync.Mutex`,所有 testTx 的 Query/Exec/SAVEPOINT 操作加互斥锁
- **验证方式:** 通过在 query.go 中插入带时间戳和 goroutine 计数的日志,观察到操作严格串行化,无重叠
### 问题二:Lock wait timeout
- **现象:** `Error 1205: Lock wait timeout exceeded`,指向 `DELETE FROM cached`
- **根因:** `HoTimeCache` 的 DB 缓存通过独立连接操作 `cached` 表,与 `testTx` 事务锁冲突
- **解决:** 测试启动时调用 `DisableDbCache()` 禁用 DB/Redis 缓存,仅保留 memory 缓存
### 问题三:go vet 编译失败
- **现象:** `go test``go vet` 警告 `log.Println call has possible Printf formatting directive %v` 而拒绝运行
- **根因:** `app/wechat.go``customer/init.go` 中误用 `log.Println` 传入 `%v` 格式化指令
- **解决:** 改为 `log.Printf`
@@ -0,0 +1,161 @@
---
name: DBState refactor to lastError
overview: 升级 common.Error 基础类型(安全 Error() + HasError() + Unwrap()),删除 common/dbstate.go,新增 db/dberror.goDBError 嵌入 Error),用 atomic.Pointer 无锁读写。
todos:
- id: upgrade-error
content: 升级 common/error.go:添加安全 Error()、HasError()、Unwrap() 方法,更新注释
status: completed
- id: delete-dbstate
content: 删除 common/dbstate.go
status: completed
- id: add-dberror-type
content: 新增 db/dberror.goDBError 嵌入 Error),db/db.go 添加 atomic.Pointer[DBError] + GetLastError()
status: completed
- id: update-db-internals
content: 更新 db/query.go、transaction.go、crud.go、db.go 中所有 lastState 调用
status: completed
- id: update-examples
content: 更新 example/app/test.go 和 mysql.go
status: completed
- id: build-and-test
content: 编译 + 运行测试验证
status: completed
isProject: false
---
# Error 升级 + DBState 重构
## 一、升级 common.Error 基础类型
当前 [common/error.go](common/error.go) 只有 `GetError()`/`SetError()`,存在问题:
- `Error()` 方法由嵌入的 `error` 接口提供,**error 为 nil 时调用会 panic**
- 缺少 `errors.Is`/`errors.As` 支持(Go 标准错误链)
- 判断有无错误要写 `e.GetError() != nil`,不够简洁
升级后(**向后兼容,纯增量**):
```go
type Error struct {
error
mu sync.RWMutex
}
// Error 安全实现 error 接口 —— nil 时返回 "" 而非 panic
func (that *Error) Error() string {
that.mu.RLock()
defer that.mu.RUnlock()
if that.error == nil {
return ""
}
return that.error.Error()
}
// HasError 快捷判断(替代 GetError() != nil
func (that *Error) HasError() bool {
that.mu.RLock()
defer that.mu.RUnlock()
return that.error != nil
}
// Unwrap 支持 errors.Is / errors.As 标准错误链
func (that *Error) Unwrap() error {
that.mu.RLock()
defer that.mu.RUnlock()
return that.error
}
// GetError / SetError 保持不变
```
## 二、删除 common/dbstate.go
删除 `DBState``DBSnapshot` 类型,DB 专用类型不该放 common。
## 三、新增 db/dberror.go —— 嵌入 Error,框架统一
```go
package db
import . "code.hoteas.com/golang/hotime/common"
// DBError 最后一次 SQL 操作的结构化快照
// 嵌入框架 Error 类型,与 Obj 等保持一致的错误 API
type DBError struct {
Error // 嵌入 common.ErrorHasError()/GetError()/Unwrap() 直接可用
Query string
Data []interface{}
}
```
应用层使用体验与 Obj 一致:
```go
// 判断出错(与 Obj 同样的 API)
if db.GetLastError().HasError() { /* SQL 出错 */ }
// 取原始 error
rawErr := db.GetLastError().GetError()
// Go 标准错误链判断
if errors.Is(db.GetLastError(), sql.ErrNoRows) { ... }
// 结构化字段(调试/SQL 监测系统)
e := db.GetLastError()
fmt.Println(e.Query, e.Data)
```
## 四、HoTimeDBatomic.Pointer 无锁读写
```go
type HoTimeDB struct {
// ...
lastError atomic.Pointer[DBError]
}
func (db *HoTimeDB) GetLastError() *DBError {
return db.lastError.Load()
}
```
## 五、内部写入:替换所有 lastState 调用
每次 SQL 执行后创建新 DBError 并原子 Store
```go
e := &DBError{Query: query, Data: args}
e.SetError(sqlErr) // 用框架 Error 的 SetError
that.lastError.Store(e)
```
具体替换点:
- [db/query.go](db/query.go)2 处 `lastState.Set(query, args, sqlErr)` + 2 处 `lastState.SetError(err)`
- [db/db.go](db/db.go)InitDb 中 2 处 `lastState.SetError(e)`
- [db/transaction.go](db/transaction.go)3 处 `lastState.SetError(err)`
- [db/crud.go](db/crud.go)1 处 `lastState.SetError(e)`
## 六、外部调用适配
- [example/app/test.go](example/app/test.go)22 处 `GetLastState().Query` -> `GetLastError().Query`
- [example/app/mysql.go](example/app/mysql.go)3 处,同上
始终记录(成功时 `.GetError() == nil`),`.Query` 可直接安全读取。
## 七、与 Logger 的分工
- Logger`logSQL`):格式化输出(带日期、caller、级别)—— 人看的
- `lastError`:结构化原始数据(Query/Data/原始 error)—— 程序读的
- 零重复:原始 error 只存一份
## 改动文件清单
- `common/error.go` — 添加 `Error()`/`HasError()`/`Unwrap()` 方法,更新注释
- `common/dbstate.go` — 删除
- `db/dberror.go` — 新增:DBError struct(嵌入 Error
- `db/db.go``atomic.Pointer[DBError]` 字段 + `GetLastError()` 方法
- `db/query.go``lastState.Set` -> `lastError.Store`
- `db/transaction.go``lastState.SetError` -> `lastError.Store`
- `db/crud.go` — 同上
- `example/app/test.go``GetLastState()` -> `GetLastError()`
- `example/app/mysql.go` — 同上
@@ -0,0 +1,139 @@
---
name: Fix caller detection
overview: 修复三个日志/测试相关 bug:(1) 调用者智能识别漏掉 log/ 包自身 (2) 测试模式 SQL 出错不会导致用例失败 (3) 测试模式 WebConnectLog 写入已关闭的 NUL 文件
todos:
- id: fix-framework-detection
content: 在 isHoTimeFrameworkFile 中 runtime/ 检查之后,添加 log/ 前缀检查
status: completed
- id: fix-sql-error-test-fail
content: 测试模式 SQL 出错时自动让当前用例失败(db 增加错误计数 + execute 中检查)
status: completed
- id: fix-zerolog-nul-closed
content: NewTestApp 中 Init 完成后将 WebConnectLog 置为 nil,避免写入已关闭的 NUL 文件
status: completed
isProject: false
---
# 修复日志/测试三个 Bug
---
## Bug 1:调用者智能识别始终显示 log/logger.go:318
### 根因
[log/logger.go](log/logger.go) 中的 `isHoTimeFrameworkFile` 函数对 `log/` 包自身存在识别盲区。
```mermaid
flowchart TD
A["应用代码 或 db/query.go 调用 Logger.Debug()"] --> B["zerolog Event.Msg()"]
B --> C["callerMarshalFunc (log/logger.go:317)"]
C --> D["findCaller 通过 runtime.Caller(i) 逐帧遍历调用栈"]
D --> E{"isHoTimeFrameworkFile?"}
E -->|"是框架文件 -> 跳过,继续向上"| D
E -->|"非框架文件 -> 作为调用者返回"| F["显示在日志输出中"]
```
`findCaller``i=1` 时拿到 `log/logger.go:318`。经过 `shortenPath` 截断后变成 `log/logger.go`。两条检测路径都漏掉了它:
- **路径一(第 392 行)**`frameworkDirs` 包含 `"log/"`,但要求路径含 `"hotime"``shortenPath` 只保留 2 段路径,`log/logger.go` 中没有 `"hotime"`。**未命中。**
- **路径二(第 410 行)**`frameworkCoreDirs` 只有 `db/``common/``code/``cache/`**没有 `"log/"`**。**未命中。**
### 修复
在 [log/logger.go](log/logger.go) 第 387-389 行 `runtime/` 检查之后加入 `log/` 前缀检查:
```go
if strings.HasPrefix(file, "runtime/") {
return true
}
if strings.HasPrefix(file, "log/") {
return true
}
```
---
## Bug 2:测试模式 SQL 执行失败不会导致用例失败
### 根因
[db/query.go](db/query.go) `logSQL` 在测试模式下只是把 SQL 错误写入 `testLogBuf`,但 [testing_api.go](testing_api.go) 的 `execute` 中只在断言失败时才打印这些日志,**从不检查是否有 SQL 错误**。即使请求处理过程中数据库出错,只要 HTTP 响应的 status/msg 符合预期,测试就通过了。
当前流程:
```mermaid
flowchart LR
A["ServeHTTP"] --> B["检查 status/msg/result"]
B -->|"通过"| C["FlushTestLog 丢弃"]
B -->|"失败"| D["FlushTestLog 打印"]
```
### 修复
在 [db/db.go](db/db.go) 增加一个测试 SQL 错误计数器:
```go
testSQLErrCount int32 // 测试模式下 SQL 错误计数(atomic)
```
在 [db/query.go](db/query.go) `logSQL` 中,当 `err != nil``testLogBuf != nil` 时,`atomic.AddInt32(&that.testSQLErrCount, 1)`
在 [db/db.go](db/db.go) 增加两个方法:
```go
func (that *HoTimeDB) TestSQLErrorCount() int32 {
return atomic.LoadInt32(&that.testSQLErrCount)
}
func (that *HoTimeDB) ResetTestSQLErrorCount() {
atomic.StoreInt32(&that.testSQLErrCount, 0)
}
```
在 [testing_api.go](testing_api.go) `execute` 中,`ServeHTTP` 之后、断言之前,检查计数:
```go
c.api.app.Db.FlushTestLog() // 先清空请求前的残留
// ... ServeHTTP ...
sqlErrCount := c.api.app.Db.TestSQLErrorCount()
c.api.app.Db.ResetTestSQLErrorCount()
if sqlErrCount > 0 {
sqlLog := c.api.app.Db.FlushTestLog()
t.Fatalf("请求处理中有 %d 次 SQL 错误:\n%s", sqlErrCount, sqlLog)
}
```
修复后流程:
```mermaid
flowchart LR
A["ServeHTTP"] --> B{"SQL 出错?"}
B -->|"是"| C["t.Fatalf 用例直接失败"]
B -->|"否"| D["正常断言 status/msg/result"]
```
---
## Bug 3zerolog: could not write event: write NUL: file already closed
### 根因
[testing_helper.go](testing_helper.go) `NewTestApp` 中的初始化顺序:
```
1. os.Stderr = devNull (重定向到 NUL)
2. Init(configPath) (创建 WebConnectLog,其 ConsoleWriter.Out = devNull)
3. os.Stderr = origStderr (恢复真实 stderr)
4. devNull.Close() (关闭 NUL 文件)
5. app.Log.SetOutput(io.Discard) (只修复了主日志)
-- WebConnectLog 仍然引用已关闭的 devNull --
6. 测试请求触发 WebConnectLog.Info() → 写入已关闭文件 → 报错
```
### 修复
在 [testing_helper.go](testing_helper.go) `devNull.Close()` 之后、`app.Log.SetOutput(io.Discard)` 之前,加一行:
```go
app.WebConnectLog = nil
```
测试环境不需要访问日志。`application.go` 第 350 行 `if that.WebConnectLog != nil` 已有 nil 保护,置 nil 即可安全跳过。
@@ -0,0 +1,127 @@
---
name: Fix caller detection v2
overview: 重新设计 findCaller 的调用者过滤逻辑:(1) 删除 maxFrameworkDepth 提前退出 (2) 收窄 isHoTimeFrameworkFile 定义为仅跳过日志/DB执行管线,让框架业务文件(code/makecode.go、application.go、context.go 等)作为有意义的调用者返回。
todos:
- id: rewrite-findcaller
content: 重写 findCaller:删除 maxFrameworkDepth、applicationFile 跟踪,简化为跳过基础设施 + 返回第一个业务文件
status: completed
- id: rewrite-framework-check
content: 重命名 isHoTimeFrameworkFile 为 isInfrastructureFile,收窄到只匹配 log/db/cache/common/dri,移除 code/ 和 application.go 等
status: completed
isProject: false
---
# 修复调用者智能识别(v2
## 根因分析
用户看到 `[db/crud.go:149]`,实际调用者是 `code/makecode.go`。两个问题叠加:
**问题 A**: `maxFrameworkDepth = 10` 过低。zerolog 内部帧(~5) + log/logger.go(1) + db/query.go(3-4) + db/crud.go(1) 轻松达到 10,触发提前返回,根本没走到 `code/makecode.go`
**问题 B**: 即使去掉 `maxFrameworkDepth``code/makecode.go``frameworkCoreDirs` 中被 `"code/"` + `"makecode.go"` 命中,也会被跳过。最终回退到 `applicationFile`application.go)或 `lastFrameworkFile`,都不是用户想要的。
## 设计思路
用户原话:"应用层触发的就打印应用层的,如果是框架层的业务造成的就打印框架层对应位置的,但肯定不是 log/logger.go"。
核心区分:
- **基础设施文件**(应跳过):日志管线(log/、zerolog/)、DB 执行管线(db/\*)、缓存中间件(cache/\*)、Go runtime、通用工具(common/\*)、数据库驱动(dri/\*
- **业务文件**(应返回):`code/makecode.go``application.go``context.go``session.go`、所有应用层代码
跳过基础设施,返回第一个业务文件。无论这个业务文件是应用层还是框架层。
## 具体修改
### 1. [log/logger.go](log/logger.go) -- `findCaller` 简化
- **删除** `maxFrameworkDepth` 常量(第 315 行)和 `frameworkCount >= maxFrameworkDepth` 提前退出(第 343-345 行)。`i < 20` 循环上限已足够保护。
- **删除** `applicationFile` / `applicationLine` 跟踪及其回退逻辑(不再需要 application.go 特殊处理)。
- **删除** `frameworkCount` 计数器(不再需要)。
- 简化后的 `findCaller`
```go
func findCaller(origFile string, origLine int) string {
var lastInfraFile string
var lastInfraLine int
for i := 1; i < 20; i++ {
_, file, line, ok := runtime.Caller(i)
if !ok {
break
}
shortFile := shortenPath(file)
if isInfrastructureFile(shortFile) {
lastInfraFile = shortFile
lastInfraLine = line
continue
}
return fmt.Sprintf("%s:%d", shortFile, line)
}
if lastInfraFile != "" {
return fmt.Sprintf("%s:%d", lastInfraFile, lastInfraLine)
}
return fmt.Sprintf("%s:%d", shortenPath(origFile), origLine)
}
```
### 2. [log/logger.go](log/logger.go) -- 重命名 + 收窄 `isHoTimeFrameworkFile`
重命名为 `isInfrastructureFile`,仅匹配日志/DB/缓存/通用工具等"管道"文件:
```go
func isInfrastructureFile(file string) bool {
// 第三方日志库
if strings.HasPrefix(file, "zerolog/") || strings.HasPrefix(file, "zerolog@") {
return true
}
if strings.HasPrefix(file, "logrus/") || strings.HasPrefix(file, "logrus@") {
return true
}
if strings.HasPrefix(file, "runtime/") {
return true
}
// HoTime 日志包
if strings.HasPrefix(file, "log/") {
return true
}
// DB 执行管线(db/ 下全部文件)
infraPrefixes := []string{"db/", "cache/", "common/", "dri/"}
for _, prefix := range infraPrefixes {
if strings.HasPrefix(file, prefix) {
return true
}
}
// 兼容路径含 hotime 的场景(module 全路径未被 shortenPath 完全截断时)
lowerFile := strings.ToLower(file)
if strings.Contains(lowerFile, "hotime") {
infraDirs := []string{"db/", "cache/", "common/", "log/", "dri/"}
for _, dir := range infraDirs {
if strings.Contains(file, dir) {
return true
}
}
}
return false
}
```
关键变更:
- `"code/"` 从所有列表中移除 -- `code/makecode.go` 不再被跳过
- `application.go``context.go``session.go` 等单文件匹配全部移除 -- 框架业务文件不再被跳过
- `frameworkCoreDirs` 中的 `"code/"` 及其文件名(makecode.go、template.go、config.go)移除
## 修复后效果
| 调用链 | 显示结果 |
|--------|---------|
| app/user.go -> db.GetMap -> logSQL | `app/user.go:XX` |
| code/makecode.go -> db.GetList -> logSQL | `code/makecode.go:XX` |
| application.go SetConfig -> db -> logSQL | `application.go:XX` |
| context.go session -> db -> logSQL | `context.go:XX` |
@@ -0,0 +1,220 @@
---
name: HoTime TDD Skill
overview: 创建一个 HoTime 框架的 TDD(测试驱动开发)Skill,指导 AI 按照"先写测试、再写实现、运行直到通过"的流程开发接口;同时在框架层面增加测试模式下的 SQL 日志智能控制,解决 AI 因日志过多无法继续执行的问题。
todos:
- id: sql-log-buffer
content: "db/db.go: 添加 testLogBuf *bytes.Buffer + testLogBufMu sync.Mutex 字段"
status: completed
- id: sql-log-query
content: "db/query.go: 修改 Query/Exec 两处 defer SQL 日志块,有 buffer 写 buffer、无 buffer 照旧"
status: completed
- id: sql-log-execute
content: "testing_api.go execute(): 请求前创建 buffer、请求后根据 pass/fail 决定 t.Log 或丢弃"
status: completed
- id: sql-log-init
content: "testing_helper.go NewTestApp(): Init 后设 Db.Mode=0 静默启动阶段日志,再恢复原 Mode 供运行时 buffer 使用"
status: completed
- id: create-skill
content: 创建 ~/.cursor/skills/hotime-tdd-testing/SKILL.mdTDD 工作流 + 测试编写范式 + API 速查 + 运行命令
status: completed
- id: verify-test
content: 运行单接口测试验证:通过时无 SQL 日志,失败时输出 SQL 日志
status: completed
isProject: false
---
# HoTime TDD API 测试 Skill 与 SQL 日志控制
## 一、SQL 日志智能控制(框架修改)-- 失败才打印
### 问题分析
SQL 日志由 `Db.Mode` 控制([db/query.go](d:/work/hotimev1.5/db/query.go) 第 40 行 `if that.Mode != 0`),`Log``*logrus.Logger`[db/db.go](d:/work/hotimev1.5/db/db.go) 第 22 行)。当前 config `"mode": 3`,每条 SQL 都实时打印。日志来源:
- 启动阶段 `Init()` -> `SetDB()` 中的 DB 初始化/表扫描
- 测试执行中每个接口的所有 SQL
- 批量运行时日志量指数级增长,导致 AI 输出被截断
### 方案:Buffer + Flush on Failure
**策略**:SQL 日志不直接打印,写入 per-case buffer。测试通过则丢弃,失败则通过 `t.Log()` 输出(Go 的 `t.Log` 只在失败或 `-v` 时显示)。
#### Step 1: db/db.go -- 添加 buffer 字段
```go
type HoTimeDB struct {
// ... 现有字段不变 ...
testLogBuf *bytes.Buffer // 测试模式:SQL 日志写入此 buffer 而非直接打印
testLogBufMu sync.Mutex // 保护 testLogBuf 并发写入
}
```
新增 helper 方法:
```go
func (that *HoTimeDB) SetTestLogBuffer(buf *bytes.Buffer) {
that.testLogBufMu.Lock()
that.testLogBuf = buf
that.testLogBufMu.Unlock()
}
func (that *HoTimeDB) FlushTestLog() string {
that.testLogBufMu.Lock()
defer that.testLogBufMu.Unlock()
if that.testLogBuf == nil {
return ""
}
s := that.testLogBuf.String()
that.testLogBuf.Reset()
return s
}
```
#### Step 2: db/query.go -- 修改两处 defer SQL 日志
`queryWithRetry` 第 39-45 行和 `execWithRetry` 第 117-123 行的 defer 块统一改为:
```go
defer func() {
if that.Mode != 0 {
that.mu.RLock()
msg := fmt.Sprintf("SQL:%s DATA:%v ERROR:%v", that.LastQuery, that.LastData, that.LastErr.GetError())
that.mu.RUnlock()
that.testLogBufMu.Lock()
if that.testLogBuf != nil {
that.testLogBuf.WriteString(msg + "\n")
} else {
that.Log.Info(msg)
}
that.testLogBufMu.Unlock()
}
}()
```
#### Step 3: testing_api.go execute() -- 管理 buffer 生命周期
`execute()` 函数的 `t.Run(desc, func(t *testing.T) { ... })` 内部:
```go
t.Run(desc, func(t *testing.T) {
// 启用 SQL 日志缓冲
buf := &bytes.Buffer{}
c.api.app.Db.SetTestLogBuffer(buf)
start := time.Now()
req := c.buildRequest(method)
w := httptest.NewRecorder()
c.api.app.Application.ServeHTTP(w, req)
duration := time.Since(start)
// ... 原有的断言逻辑 ...
// 停止缓冲
sqlLog := c.api.app.Db.FlushTestLog()
c.api.app.Db.SetTestLogBuffer(nil)
// 失败时输出 SQL 日志
if !passed && sqlLog != "" {
t.Logf("SQL 日志:\n%s", sqlLog)
}
// ... 原有的 record 收集逻辑 ...
})
```
#### Step 4: testing_helper.go NewTestApp() -- 静默启动阶段日志
`Init()` 内部调用 `SetDB()` 时 Mode 已从 config 读取(值为 3),会打印大量启动 SQL。在 `NewTestApp``Init()` 之后立即处理:
```go
func NewTestApp(configPath string, projects TestProj, ...) *TestApp {
app := Init(configPath)
// 启动阶段产生的 SQL 日志已经打印完毕,现在保存原始 Mode
// Mode 保持原值(非 0)让运行时 SQL 仍走 defer 日志逻辑,但会被 buffer 拦截
// 启动阶段的日志无法追溯拦截,但它们是一次性的固定输出,量可控
// ... 后续逻辑不变 ...
}
```
> 启动阶段的 SQL 日志(表扫描等)是 `Init()` 内部产生的,此时 buffer 尚未设置,会直接打印。这些是一次性的固定日志,量相对可控。如果仍然太多,可在 `Init()` 调用前临时设 `app.Db.Mode = 0`,但 `Init` 返回的 app 还没有 Db 对象——所以更实际的做法是在 `NewTestApp` 中 `Init()` 之后、`SetupForTest()` 之前设置 `Db.Mode = 0` 静默后续初始化 SQL,然后在 `SetupForTest()` 完成后恢复原始 Mode。
实际代码:
```go
func NewTestApp(configPath string, projects TestProj, ...) *TestApp {
app := Init(configPath)
// 保存原始 Mode,静默后续初始化阶段的 SQL 日志
origMode := app.Db.Mode
app.Db.Mode = 0
// ... listener / router / SetupForTest / DisableDbCache 等初始化 ...
// 恢复 Mode,运行时 SQL 日志由 testLogBuf 接管
app.Db.Mode = origMode
return &TestApp{ ... }
}
```
### 效果
- `go test ./app/... -v` -- 全量运行:通过的接口零 SQL 输出,失败的接口自动输出完整 SQL 链路
- `go test -run TestApi/app/order/create -v` -- 单接口:同上,通过无输出、失败有输出
- 任何粒度都自动适配,无需手动控制
### 涉及文件
- [db/db.go](d:/work/hotimev1.5/db/db.go) -- 添加 `testLogBuf` / `testLogBufMu` 字段 + helper 方法
- [db/query.go](d:/work/hotimev1.5/db/query.go) -- 修改 `queryWithRetry``execWithRetry` 的 defer 日志块
- [testing_api.go](d:/work/hotimev1.5/testing_api.go) -- `execute()` 中管理 buffer 生命周期
- [testing_helper.go](d:/work/hotimev1.5/testing_helper.go) -- `NewTestApp()` 中静默初始化阶段 + 恢复 Mode
---
## 二、TDD Skill 创建
### 存储位置
个人 Skill`~/.cursor/skills/hotime-tdd-testing/SKILL.md`(跨项目通用)
### Skill 核心内容
指导 AI 按以下 TDD 工作流开发 HoTime 接口:
**Phase 1: 编写测试用例(先于实现)**
-`xxx_test.go` 中定义 `CtrTest`,包含:
- 错误用例(权限/参数/业务校验,覆盖所有 status != 0 路径)
- 测试数据准备(`a.DB().Insert`
- 正确请求 + 响应结构校验(第三参数类型样本)
- Verify 统一校验(响应值断言 + 数据库状态校验)
**Phase 2: 实现接口**
- 在对应的 `.go` 文件中编写 handler
**Phase 3: 运行测试直到通过**
- 运行命令:`go test ./app/... -v -run TestApi/app/{ctr}/{method}`
- 修复失败用例,循环直到全部通过
**关键参考文档**
- Skill 内直接嵌入精简版的测试 API 速查表(链式 API、Verify 用法、DB 操作等)
- 详细文档指向 [Testing_API测试框架.md](d:/work/xbc/docs/Testing_API测试框架.md)
**SQL 日志行为(框架自动处理)**
- 任何粒度运行:通过的用例零 SQL 输出,失败的用例自动输出完整 SQL 链路
- 无需手动控制,框架通过 buffer 机制自动按 pass/fail 决定输出
### Skill 文件结构
```
~/.cursor/skills/hotime-tdd-testing/
SKILL.md -- 主文件(TDD 流程 + API 速查 + 运行指令)
```
直接在 SKILL.md 内嵌入精简的 API 参考(不分离文件),保持在 500 行以内,因为测试框架文档已经在项目 docs 目录中。
@@ -0,0 +1,331 @@
---
name: JSON Path 查询支持
overview: 在现有 Dialect 接口和 WHERE 条件解析引擎上扩展,使 MySQL 和 SQLite 支持类似 PgSQL 的 `data["a"]["b"]["c"]` JSON 路径查询与操作语法,所有三种数据库共享统一的上层 API。
todos:
- id: dialect-json-interface
content: 在 db/dialect.go 的 Dialect 接口新增 JSONExtract / JSONSet / JSONContains / JSONArrayLength 四个方法
status: pending
- id: dialect-json-mysql
content: 实现 MySQLDialect 的三个 JSON 方法,处理 JSON_EXTRACT / JSON_SET / JSON_UNQUOTE 等
status: pending
- id: dialect-json-sqlite
content: 实现 SQLiteDialect 的三个 JSON 方法,使用小写 json_extract / json_set
status: pending
- id: dialect-json-pgsql
content: "实现 PostgreSQLDialect 的三个 JSON 方法,使用 -> / #>> / jsonb_set 语法"
status: pending
- id: where-json-parse
content: 新增 parseJSONPathKey()/parseJSONPathFromName() 工具函数,同时支持 col["key"] 和 col['key'] 两种引号格式,以及数字索引 [0],解析出列名、[]string 路径键、操作符后缀
status: pending
- id: where-json-cond
content: 在 db/where.go 的 varCond() 开头最优先调用 parseJSONPathKey,分发到 jsonPathCond()jsonPathCond 对现有操作符复用比较逻辑,新增 [?]/[!?]/[@]/[!@]/[len]/[len>] 等分支
status: pending
- id: crud-json-update
content: 在 db/crud.go 的 Update() SET 构建循环中检测 JSON 路径列名生成 JSONSet 表达式,在 Select() 的 Slice 字段循环中检测 JSON 路径生成 JSONExtract 表达式(修复两处 ProcessColumnNoPrefix 误处理)
status: pending
isProject: false
---
# MySQL/SQLite JSON 路径查询支持方案
## 背景与现状
现有 ORM 已有 `Dialect` 接口抽象三种数据库差异,`where.go``[operator]` 后缀语法解析条件。扩展的核心思路:**在解析 key 时识别 JSON 路径记法,委托给 Dialect 生成对应函数调用**。
## 三种数据库 JSON 能力深度对比
### 1. JSON 值提取(WHERE 比较场景)
| | MySQL 5.7+ | SQLite 3.9+ | PostgreSQL |
| ---------- | ------------------------------------------------------- | ------------------------------ | ----------------- |
| 单层提取(返回文本) | `JSON_UNQUOTE(JSON_EXTRACT(col,'$.a'))``col->>'$.a'` | `json_extract(col,'$.a')` | `col->>'a'` |
| 多层嵌套(返回文本) | `JSON_UNQUOTE(JSON_EXTRACT(col,'$.a.b'))` | `json_extract(col,'$.a.b')` | `col#>>'{a,b}'` |
| 数组索引 | `JSON_EXTRACT(col,'$.arr[0]')` | `json_extract(col,'$.arr[0]')` | `col#>>'{arr,0}'` |
| 路径格式 | `$.a.b` | `$.a.b`(与MySQL相同) | `{a,b}`#>> 方式) |
**关键差异**MySQL 的 `JSON_EXTRACT` 对字符串返回值带外层双引号(如 `"北京"` 而非 `北京`),与普通字符串 `= ?` 比较会失败,**必须包一层 `JSON_UNQUOTE()`**。SQLite 和 PostgreSQL`->>`/`#>>`)则直接返回文本,无此问题。
### 2. JSON 局部更新(UPDATE SET 场景)
| | MySQL 5.7+ | SQLite 3.9+ | PostgreSQL |
| ----- | ------------------------- | ------------------------- | ------------------------------------------ |
| 语法 | `JSON_SET(col,'$.a.b',?)` | `json_set(col,'$.a.b',?)` | `jsonb_set(col,'{a,b}',to_jsonb(?::text))` |
| 路径格式 | `$.a.b` | `$.a.b`(与MySQL相同) | `'{a,b}'` 数组字面量 |
| 值的类型 | 直接绑定参数 | 直接绑定参数 | 字符串需 `to_jsonb(?::text)`,数字可 `?::jsonb` |
| 列类型限制 | 无(TEXT 也行) | 无(TEXT 列) | **必须是 `jsonb` 列**`json` 列无 `jsonb_set` |
**关键差异**MySQL 与 SQLite 的语法几乎完全一致(函数名大小写不同而已)。PostgreSQL 只有 `jsonb_set`,要求列类型为 `jsonb`,且字符串值必须转为 JSON 格式(`to_jsonb(?::text)``'"value"'::jsonb`)。
### 3. JSON 数组包含(`[@]` 操作符)
| | MySQL 5.7+ | SQLite 3.9+ | PostgreSQL |
| ------ | --------------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------ |
| SQL 形式 | `JSON_CONTAINS(JSON_EXTRACT(col,'$.path'),JSON_QUOTE(?))` | `EXISTS(SELECT 1 FROM json_each(col,'$.path') WHERE value=?)` | `col#>'{path}' @> to_jsonb(?::text)` |
| 结构类型 | 函数调用(内联) | **EXISTS 子查询**(结构不同) | 中缀操作符(内联) |
**最大兼容难点**:SQLite 没有内联的包含函数,必须生成 EXISTS 子查询,SQL 结构与另外两种完全不同。`JSONContains()` 方法在 SQLite 下返回的是一段 `EXISTS(...)` 表达式。
### 4. 路径格式统一转换规则
ORM 内部统一使用 `[]string{"a","b","c"}` 表示路径键(数字索引用字符串 `"0"` 表示),各 Dialect 自行转换:
```
内部路径: ["address","city"]
→ MySQL/SQLite: $.address.city
→ PostgreSQL: {address,city}
内部路径: ["tags","0"](数组索引)
→ MySQL/SQLite: $.tags[0]
→ PostgreSQL: {tags,0}
```
## 统一 API 语法设计
### Key 写法(单引号/双引号两种都支持)
```go
// 双引号风格(JSON 标准,推荐,需用反引号字符串)
common.Map{ `profile["addr"]["city"]`: "北京" }
// 单引号风格(普通字符串也能写)
common.Map{ "profile['addr']['city']": "北京" }
```
两种写法解析结果完全一致,内部统一转为 `$.addr.city`
### 操作符设计:复用现有 + 新增 JSON 专属
**所有现有操作符均可接在 JSON 路径后直接使用**`=` / `>` / `<` / `>=` / `<=` / `!=` / `LIKE` / `IN` / `NOT IN` / `BETWEEN` / `IS NULL`):
```go
db.Select("user", common.Map{
`profile["age"]`: 18, // = 18
`profile["age"][>]`: 18, // > 18
`profile["age"][<>]`: []int{18, 30}, // BETWEEN
`profile["name"][~]`: "张", // LIKE %张%
`profile["tags"][0]`: "vip", // 数组第一个元素 = vip
`profile["addr"]["city"]`: "北京", // 多级嵌套
`profile["score"]`: nil, // IS NULL
})
```
**新增 JSON 专属操作符**(现有体系无对应):
| 操作符 | 含义 | MySQL | SQLite | PgSQL |
| --------- | ----------- | -------------------------------- | ------------------------ | ------------------------- |
| `[?]` | 路径存在(非NULL) | `IS NOT NULL` | `IS NOT NULL` | `? ?` |
| `[!?]` | 路径不存在 | `IS NULL` | `IS NULL` | `NOT (? ?)` |
| `[@]` | 数组/对象包含某值 | `JSON_CONTAINS(col,?,'$.path')` | `json_each` 子查询 | `col @> ?::jsonb` |
| `[!@]` | 不包含 | `NOT JSON_CONTAINS(...)` | 子查询 NOT EXISTS | `NOT (col @> ?)` |
| `[len]` | 数组长度 = ? | `JSON_LENGTH(JSON_EXTRACT(...))` | `json_array_length(...)` | `jsonb_array_length(...)` |
| `[len>]` | 数组长度 > ? | 同上 | 同上 | 同上 |
| `[len<]` | 数组长度 < ? | 同上 | 同上 | 同上 |
| `[len>=]` | 数组长度 >= ? | 同上 | 同上 | 同上 |
| `[len<=]` | 数组长度 <= ? | 同上 | 同上 | 同上 |
```go
// 新操作符使用示例
db.Select("user", common.Map{
`profile["vip"][?]`: nil, // vip 字段存在
`profile["tags"][@]`: "admin", // tags 数组包含 "admin"
`profile["friends"][len>]`: 5, // friends 数组长度 > 5
`profile["items"][len]`: 3, // items 数组长度 = 3
})
// UPDATE 局部更新 JSON 字段
db.Update("user", common.Map{
`profile["age"]`: 20,
`profile["addr"]["city"]`: "上海",
}, common.Map{"id": 1})
```
## 与现有代码的兼容性分析
### 三处具体冲突点
**冲突 1 — WHERE `varCond` 的 `[...]` 分支**`[db/where.go:225](db/where.go)`
现有逻辑:只要 key 含 `[` 且末尾是 `]` 就进分支,取末尾 3/4 字符匹配已知操作符。
```
profile["age"] → 末尾3字符 e"] → 无匹配 → handleDefaultCondition
→ ProcessColumn("profile[\"age\"]") → 被加引号 → WRONG SQL
profile["age"][>] → 末尾3字符 [>] → MATCHstrips → ProcessColumn("profile[\"age\"]")
→ 被加引号 → WRONG SQL
profile["age"][@] → 末尾3字符 [@] → 无匹配(新操作符) → handleDefaultCondition
→ ProcessColumn("profile[\"age\"][@]") → WRONG SQL
```
**冲突 2 — UPDATE `ProcessColumnNoPrefix` 调用**`[db/crud.go:629](db/crud.go)`
```go
query += processor.ProcessColumnNoPrefix(k) + "=" + vstr // 第629行
```
`k = profile["age"]``QuoteIdentifier` 加引号 → ``profile["age"]`=?` → 无效 SQL。
**冲突 3 — SELECT slice 字段**`[db/crud.go:109](db/crud.go)`
无 `.` 且无 `AS` 的字段走 `ProcessColumnNoPrefix``profile["age"]` 被当成普通列名加引号。
### 已有格式不受影响的验证
- `profile[>]` — 末尾 `[>]` 匹配现有 switch → 先 strips 再调 `ProcessColumn("profile")` → **正常**
- `user.name` — `ProcessColumn` 有 `.` 分支 → **正常**
- ``user`.name` — `stripQuotes` 后同上 → **正常**
- `"name,age"` 字符串字段 — `ProcessFieldList` 的正则不会错误匹配无 `.` 的字段 → **正常**
- `Slice{"name","age"}` — 无 JSON 路径字符 → **正常**
---
## 实现方案
### 核心设计:`parseJSONPathKey` 最先拦截
区分 JSON 路径括号和操作符括号的关键:**括号内容**
- JSON 路径段:`["xxx"]`、`['xxx']`、`[0]`(引号字符串或纯数字)
- 操作符段:`[>]`、`[@]`、`[len>]`(无引号,含符号字符)
**第一步:新增路径工具函数(`[db/where.go](db/where.go)` 顶部或独立 `jsonpath.go`**
```go
// parseJSONPathKey 解析 JSON 路径 key,识别规则:
// ^(\w[\w.]*) 列名
// ((?:\["[^"]*"\]|\['[^']*'\]|\[\d+\])+) JSON 路径段(双引号/单引号/数字索引)
// (\[.*\])?$ 可选尾部操作符 [>] [@] [len>] 等
//
// 示例:
// profile["age"][>] → col=profile, keys=["age"], op="[>]", ok=true
// profile['addr']['city'] → col=profile, keys=["addr","city"], op="", ok=true
// profile["tags"][0][@] → col=profile, keys=["tags","0"], op="[@]", ok=true
// profile[>] → ok=false(操作符括号,非 JSON 路径)
func parseJSONPathKey(k string) (col string, pathKeys []string, opSuffix string, ok bool)
```
**第二步:`varCond()` 开头最先调用**`[db/where.go](db/where.go)`
```go
func (that *HoTimeDB) varCond(k string, v interface{}) (string, []interface{}) {
// ★ 最优先:JSON 路径检测,完全绕过现有 [...] 分支
if col, pathKeys, opSuffix, ok := parseJSONPathKey(k); ok {
return that.jsonPathCond(col, pathKeys, opSuffix, v)
}
// 以下全部保持不变 ↓
...
}
```
`jsonPathCond` 根据 opSuffix 分发:
| opSuffix | 动作 |
| --------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| `""` / 现有操作符(`[>]`/`[<]`/`[>=]`/`[<=]`/`[!]`/`[~]`/`[!~]`/`[~!]`/`[<>]`/`[><]`/`[#]` 等) | 调用 `dialect.JSONExtract(col, pathKeys)` 得到提取表达式,替换原来的列名,复用现有比较逻辑 |
| `[?]` | `JSONExtract(...) IS NOT NULL` |
| `[!?]` | `JSONExtract(...) IS NULL` |
| `[@]` | `dialect.JSONContains(col, pathKeys, "?")` |
| `[!@]` | `NOT` + JSONContains |
| `[len]` | `dialect.JSONArrayLength(col, pathKeys)` + `=?` |
| `[len>]` / `[len<]` / `[len>=]` / `[len<=]` | JSONArrayLength + 对应比较符 |
**第三步:扩展 `Dialect` 接口(`[db/dialect.go](db/dialect.go)`**
新增 4 个方法,内部路径统一用 `[]string`(数字索引用字符串 `"0"`):
```go
// MySQL: JSON_UNQUOTE(JSON_EXTRACT(`col`, '$.a.b'))
// SQLite: json_extract("col", '$.a.b')
// PgSQL: "col"#>>'{a,b}'
JSONExtract(quotedColumn string, pathKeys []string) string
// MySQL: JSON_SET(`col`, '$.a.b', ?)
// SQLite: json_set("col", '$.a.b', ?)
// PgSQL: jsonb_set("col", '{a,b}', to_jsonb($N::text)) -- isStr=true
// jsonb_set("col", '{a,b}', $N::jsonb) -- isStr=false
JSONSet(quotedColumn string, pathKeys []string, placeholder string, isStr bool) string
// MySQL: JSON_CONTAINS(JSON_EXTRACT(`col`,'$.path'), JSON_QUOTE(?))
// SQLite: EXISTS (SELECT 1 FROM json_each("col", '$.path') WHERE value=?)
// PgSQL: "col"#>'{path}' @> to_jsonb($N::text)
JSONContains(quotedColumn string, pathKeys []string, placeholder string) string
// MySQL: JSON_LENGTH(JSON_EXTRACT(`col`, '$.path'))
// SQLite: json_array_length("col", '$.path')
// PgSQL: jsonb_array_length("col"#>'{path}')
JSONArrayLength(quotedColumn string, pathKeys []string) string
```
**第四步:修复 UPDATE SET`[db/crud.go:621](db/crud.go)`**
在 `[#]` 检查之后、`ProcessColumnNoPrefix` 之前增加 JSON 路径检测:
```go
} else if col, pathKeys, ok := parseJSONPathFromName(k); ok {
quotedCol := processor.ProcessColumnNoPrefix(col)
isStr := v != nil && reflect.TypeOf(v).Kind() == reflect.String
ph := that.Dialect.Placeholder(len(qs) + 1)
query += quotedCol + "=" + that.Dialect.JSONSet(quotedCol, pathKeys, ph, isStr)
qs = append(qs, v)
```
**第五步:修复 SELECT slice 字段(`[db/crud.go:101](db/crud.go)`**
在 `Slice` 字段循环中,对每个字段先做 JSON 路径检测:
```go
if col, pathKeys, ok := parseJSONPathFromName(stripAlias(k)); ok {
alias := extractAlias(k)
quotedCol := processor.ProcessColumnNoPrefix(col)
query += " " + that.Dialect.JSONExtract(quotedCol, pathKeys) + alias + " "
} else if strings.Contains(k, " AS ") || strings.Contains(k, ".") {
query += " " + processor.ProcessFieldList(k) + " "
} else {
query += " " + processor.ProcessColumnNoPrefix(k) + " "
}
```
字符串字段(如 `"profile[\"age\"] AS age, name"`)暂不处理 JSON 路径,用户可用原有原始 SQL 写法。
## 支持的能力范围
- **WHERE 条件**:复用全部现有 14 种操作符(`=` / `!=` / `>` / `<` / `>=` / `<=` / `LIKE` / `IN` / `NOT IN` / `BETWEEN` / `IS NULL` 等)+ 新增 `[?]` / `[!?]` / `[@]` / `[!@]` / `[len]` / `[len>]` / `[len<]` / `[len>=]` / `[len<=]`
- **UPDATE SET**JSON 路径局部更新,原列其他字段保持不变
- **SELECT slice 字段**`Slice{profile["age"] AS score}` 支持 JSON 路径
- **SELECT 字符串字段**:暂不处理,用户写原始 SQL 或 Slice 形式
- **数组索引**`["arr"][0]` → `$.arr[0]`
- **多级嵌套**:任意深度
- **引号兼容**`["key"]` 和 `['key']` 两种写法完全等效
- **原有格式零影响**`user.name` / ``user`.name` / `"name,age"` / `Slice{"name","age"}` 全部不变
## 兼容性注意事项
| 级别 | 问题 | 处理方式 |
| --- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| 高 | **MySQL 必须 JSON_UNQUOTE**`JSON_EXTRACT` 返回字符串含外层引号,直接 `= ?` 失败 | `MySQLDialect.JSONExtract()` 统一包 `JSON_UNQUOTE(JSON_EXTRACT(...))` |
| 高 | **PostgreSQL 只支持 `jsonb` 列**`jsonb_set` 不支持 `json` 类型列 | 文档约定:使用 JSON 路径操作的列需建为 `jsonb` 类型;或调用方在列名后加 `::jsonb` 强转 |
| 高 | **SQLite `[@]` 生成 EXISTS 子查询**:结构与 MySQL/PgSQL 完全不同 | `SQLiteDialect.JSONContains()` 返回 `EXISTS(SELECT 1 FROM json_each(...) WHERE value=?)` 片段,在 `jsonPathCond` 里直接拼入 WHERE |
| 中 | **PgSQL 更新时值的类型转换**:字符串需 `to_jsonb(?::text)`,数值/布尔用 `?::jsonb` | `JSONSet()` 接受 `isStr bool` 参数,由 `jsonPathCond` 根据 Go 值类型传入 |
| 中 | **MySQL 版本**JSON 函数需 5.7+`JSON_OVERLAPS` 需 8.0+ | 先实现 5.7 兼容的基础操作符,`[!@]` 用 `NOT JSON_CONTAINS` 实现 |
| 低 | **SQLite 版本**:需 3.9+`go-sqlite3` 默认已启用 json1 扩展 | 无需特殊处理 |
| 低 | **路径不存在返回 NULL**:三种数据库行为一致 | 调用方自行处理 NULL 判断,或用 `[?]` 操作符先做存在性检查 |
## 涉及文件
- `[db/dialect.go](db/dialect.go)` — 接口扩展 + 三种方言实现(主要改动)
- `[db/where.go](db/where.go)` — 新增 JSON 路径 key 检测与条件生成
- `[db/crud.go](db/crud.go)` — UPDATE SET 子句 JSON 路径支持
@@ -0,0 +1,424 @@
---
name: Logging System Redesign
overview: Replace logrus with zerolog, remove mode config, redesign logLevel/logFile, simplify Error to pure storage, add DBState for atomic DB state access, and provide a clean application-layer logging API.
todos:
- id: add-zerolog-dep
content: 添加 zerolog 依赖到 go.mod,移除 logrusvendor 中 wechat 等三方库自带的 logrus 不影响)
status: completed
- id: rewrite-log-package
content: 重写 log/ 包:基于 zerolog 实现 Logger 结构体、NewLogger 工厂、CallerMarshalFunc 智能调用者过滤、TemplateFileWriter 带缓冲和模板路径切换
status: completed
- id: delete-error-add-dbstate
content: 删除 common/error.goError 类型不再需要)、新增 common/dbstate.goDBState 包装 Query+Data+Error)、Logger 内置 ERROR 级别环形缓冲
status: completed
- id: update-application
content: 改造 application.go:删除 mode 配置、Application.Log 类型变更、SetConfig 中日志初始化用 logLevel 控制一切、DB 缓存始终开启、Cache-Control 跟 logLevel 联动
status: completed
- id: update-db
content: 改造 db/db.go 和 db/query.go:用 DBState 替换 LastQuery+LastData+LastErr、HoTimeDB.Log 类型变更、SQL 日志格式优化、消除重复打印
status: completed
- id: update-var-config
content: 更新 var.go 默认配置(删 mode、logLevel 默认 3)和 ConfigNote、example 配置文件
status: completed
- id: update-codegen
content: 适配 code/makecode.go 中的 Error 使用(移除 Logger 相关)
status: completed
- id: update-tests
content: 适配 testing_helper.go 和 db/transaction_test.go 中的日志创建
status: completed
isProject: false
---
# HoTime 日志系统全面重写方案
## 一、现状问题分析
### 1. logrus 已不适合继续使用
- logrus 已进入 **维护模式**,不再开发新功能,仅修复安全问题
- 性能差:依赖 mutex 锁和反射,在高并发下显著慢于现代替代品
- 当前使用版本 `v1.8.1`[go.mod](go.mod) line 13
### 2. 现有日志配置形同虚设
- `logLevel` 在 [var.go](var.go) line 59 有定义和注释,但 **从未在代码中接入 logrus**
- `mode` 承担了过多职责(0/1/2/3 四种),但代码中实际只区分 `== 0``!= 0`,且 mode 2/3 无独立分支
### 3. Error 类型与日志耦合导致重复打印
- `SetError` 内部隐式 `Warn` 打印 + `query.go` defer 再打一次 = **同一错误打印两次**
- `LastQuery`/`LastData`/`LastErr` 用不同的锁保护,存在读到不一致状态的风险
---
## 二、日志库选型:zerolog
**推荐 [zerolog](https://github.com/rs/zerolog)**
- 零内存分配,性能远超 logrus(快 5-10 倍)
- 内置 `Caller()``ConsoleWriter`(彩色)、`MultiLevelWriter`(多输出)
- 无 Go 版本要求(go.mod 保持 `go 1.16`
- 不选 `log/slog`:需 Go 1.21+,升级成本太大
---
## 三、配置参数重新设计
### 删除 `mode`
`mode` 原来控制的行为全部转移:
- **DB 缓存**`mode` 的拦截是多余的,cache 本身已是"配置即启用"设计(`cache.memory.db`/`cache.redis.db` 等控制)。删除 `mode``SetCache` 中始终挂载 `that.Db.HoTimeCache`,是否真正缓存由 cache 配置决定
- **Cache-Control**:跟 `logLevel` 联动 -- `logLevel > 0` 时设 `no-cache``logLevel == 0``public`
- **SQL 日志打印**:完全由 `logLevel` 控制(`logLevel > 0` 时打印 SQL
- **代码生成**:本来就由 `codeConfig[].mode` 独立控制,不受影响
### `logLevel` -- 保留,真正接管日志系统
- `0` = 仅 error(生产环境推荐)
- `>=1` = 全部打印(info/warn/error/debug/SQL 全开)
- **默认值 `1`**
简单二分:要么只看错误,要么全看。Cache-Control 同理:`logLevel==0``public``>0``no-cache`
### `logFile` -- 保留
路径模板不变(`a/b/c/20060102150405.txt`),应用日志写入此文件。
### `logHistory` -- 新增
Logger 错误历史缓冲条数,默认 `100`。设为 `0` 关闭历史记录。可根据设备内存情况调小。
### `webConnectLogShow` / `webConnectLogFile` -- 保留独立
保留访问日志独立的理由(业务角度):
- 访问日志量远大于应用日志,混在一起文件膨胀、查找困难
- 生产环境需要不同的轮转策略
- Nginx/Apache 都是 access.log 和 error.log 分开的行业惯例
配置保持不变:
- `webConnectLogShow`:默认 true
- `webConnectLogFile`:独立文件路径
---
## 四、新日志包设计 (`log/`)
### 核心结构
```go
package log
type Logger struct {
zl zerolog.Logger
}
func NewLogger(logLevel int, logFile string, showCaller bool) *Logger
func (l *Logger) Debug() *zerolog.Event
func (l *Logger) Info() *zerolog.Event
func (l *Logger) Warn() *zerolog.Event
func (l *Logger) Error() *zerolog.Event
func (l *Logger) Debugf(format string, v ...interface{})
func (l *Logger) Infof(format string, v ...interface{})
func (l *Logger) Warnf(format string, v ...interface{})
func (l *Logger) Errorf(format string, v ...interface{})
```
### 控制台输出格式
```
2026-04-12 15:04:05 |INFO| 用户登录成功 [app/admin/user.go:45]
2026-04-12 15:04:05 |WARN| 数据库连接超时 [app/admin/user.go:78]
2026-04-12 15:04:05 |ERROR| 支付回调失败 order_id=456 [app/payment/callback.go:32]
```
格式:`时间 | 类型标志 | 日志内容 | [代码位置]`,用 `[file:line]` 而非 logrus 的 `line="file:line"`
### 文件输出格式(JSON
```json
{"time":"2026-04-12T15:04:05+08:00","level":"info","caller":"app/admin/user.go:45","message":"用户登录成功"}
```
### 调用者智能过滤
复用并优化现有 [log/logrus.go](log/logrus.go) 中 `findCaller` / `isHoTimeFrameworkFile` 逻辑,实现为 zerolog 的 `CallerMarshalFunc`
### 文件写入优化
`TemplateFileWriter`:带缓冲的 Writer,按时间模板自动切换文件路径,保持 `time.Now().Format(path)` 语义。
---
## 五、删除 Error 类型 + DBState + Logger 错误历史
### 删除 common.Error
`common.Error` 完全删除,不再需要。原因:
- Error 存在的意义是"存错误 + 自动打印",打印已移到 Log,只剩存储
- 审查全部用法后,每个 `SetError` 调用点都可以直接用 `Log.Error()` 替代
- `InitDb` 返回 `*Error` 从未被使用(调用方都是 `_ = that.InitDb(...)`
- `ConnectFunc(err ...*Error)` 可简化为返回 error
- 需要"查最近的错误"由 Logger 错误历史替代
### Logger 错误历史(环形缓冲)
Logger 内置 ERROR 级别记录的环形缓冲(默认 100 条),打印即存储,零额外操作:
```go
type ErrorRecord struct {
Err error
Msg string
Time time.Time
Caller string
}
// Logger 内部
type Logger struct {
zl zerolog.Logger
errors []ErrorRecord // 环形缓冲
errorsMu sync.RWMutex
maxErrors int // 默认 100
}
// 每次 Log.Error() 自动存入缓冲
// 获取最近 N 条错误(不传则返回全部已存储的,不超过 maxErrors)
func (l *Logger) GetRecentErrors(n ...int) []ErrorRecord
```
缓冲大小通过配置项 `logHistory` 控制,默认 100,可根据设备内存调整。
应用层使用:
```go
// 正常打印,自动存入历史
that.Log.Error().Err(err).Msg("支付失败")
// 获取最近 10 条错误
for _, e := range that.Log.GetRecentErrors(10) {
fmt.Printf("[%s] %s: %v %s\n", e.Time.Format("15:04:05"), e.Msg, e.Err, e.Caller)
}
// 获取全部已存储的错误
all := that.Log.GetRecentErrors()
```
### DBState -- DB 操作状态原子包装(新增)
新增 [common/dbstate.go](common/dbstate.go),将 `LastQuery` + `LastData` + `LastErr` 合为一体,一把锁保护原子读写。
**核心语义**:记录最后一次 DB 操作的完整状态。每次 SQL 执行后更新,用于判断"SQL 执行失败"还是"执行成功但结果为空"(如 UPDATE 影响 0 行)。
```go
// DBSnapshot 操作状态快照(只读,安全传递)
type DBSnapshot struct {
Query string
Data []interface{}
Err error
}
type DBState struct {
query string
data []interface{}
err error
mu sync.RWMutex // 零值即可用
}
// Set 原子更新完整状态(每次 Query/Exec 执行后调用)
func (s *DBState) Set(query string, data []interface{}, err error) {
s.mu.Lock()
s.query = query
s.data = data
s.err = err
s.mu.Unlock()
}
// Get 返回一致性快照(query/data/err 保证是同一次操作的)
func (s *DBState) Get() DBSnapshot {
s.mu.RLock()
defer s.mu.RUnlock()
return DBSnapshot{Query: s.query, Data: s.data, Err: s.err}
}
// SetError 只更新错误(Ping 等无 SQL 的场景)
func (s *DBState) SetError(err error) {
s.mu.Lock()
s.err = err
s.mu.Unlock()
}
// GetError 快速判断最后操作是否出错
func (s *DBState) GetError() error {
s.mu.RLock()
defer s.mu.RUnlock()
return s.err
}
```
**使用场景**
```go
db.Exec("UPDATE stats SET count=count+1 WHERE id=?", id)
rows, _ := result.RowsAffected()
if rows == 0 {
if state := db.GetLastState(); state != nil {
// SQL 执行失败,state.Err 有具体错误
} else {
// SQL 成功,只是没有匹配的行
}
}
```
### HoTimeDB 结构体变更
`HoTimeDB` 中原来的三个独立字段合并为私有 `lastState`,通过方法暴露,同时删除 `Mode`
```go
type HoTimeDB struct {
*sql.DB
// ...
Log *log.Logger // 从 *logrus.Logger 改为新 Logger
lastState DBState // 私有,替换 LastQuery + LastData + LastErr
// 删除: Mode int
// 删除: LastQuery string
// 删除: LastData []interface{}
// 删除: LastErr *Error
}
// 唯一对外方法:nil = 成功,非 nil = 失败(附带完整上下文)
func (db *HoTimeDB) GetLastState() *DBSnapshot
```
内部逻辑:`lastState` 始终记录 query/data/err(供日志使用),但 `GetLastState()` 只在有错误时返回非 nil
```go
func (db *HoTimeDB) GetLastState() *DBSnapshot {
db.lastState.mu.RLock()
defer db.lastState.mu.RUnlock()
if db.lastState.err == nil {
return nil // 没错误,不需要关心细节
}
return &DBSnapshot{
Query: db.lastState.query,
Data: db.lastState.data,
Err: db.lastState.err,
}
}
```
应用层使用:
```go
// 一行判断 + 取详情
if state := db.GetLastState(); state != nil {
// SQL 出错了,state.Err / state.Query / state.Data 全有
that.Log.Error().Err(state.Err).Str("sql", state.Query).Msg("SQL 执行失败")
}
// nil = SQL 执行成功,不用管
```
---
## 六、DB 日志格式优化
### 修复重复打印
`SetError` 不再打印日志,`query.go` defer 统一负责 SQL 日志 -- 问题根除。
### 输出格式
**无错误时(DEBUG 级别):**
```
2026-04-12 15:04:05 |DEBUG| SQL: SELECT id,worker_id FROM `dingtalk` WHERE `state`=? DATA: [0] [dd/sync.go:63]
```
**有错误时(自动升为 ERROR 级别):**
```
2026-04-12 15:04:05 |ERROR| SQL: SELECT id,worker_id FROM `dingtalk` WHERE `state`=? DATA: [0] ERROR: connection refused [dd/sync.go:63]
```
关键改动:
- 无错误时不显示 `ERROR:<nil>`
- 有错误时日志级别自动升为 ERROR(而非一律 INFO)
- SQL 日志在 `logLevel > 0` 时输出,`logLevel == 0` 时只有 SQL 出错才打印
### 测试模式下 SQL 错误自动 fail
测试框架中 SQL 出错让测试用例直接失败报错。
---
## 七、需要修改的文件清单
### 核心改动
- [log/logrus.go](log/logrus.go) -- 全部重写为 zerolog 实现,重命名为 `log/logger.go`
- [common/error.go](common/error.go) -- **删除整个文件**
- 新增 [common/dbstate.go](common/dbstate.go) -- DBState + DBSnapshot 类型
- [application.go](application.go) -- 删除 `Error` 嵌入和所有 `Error{}` 初始化、删除 mode 逻辑、`Application.Log` 类型变更、`SetCache` 去掉 mode 判断、Cache-Control 改跟 logLevel、删除 `Db.Mode` 赋值(3 处)、`ConnectFunc` 签名简化、logLevel 接入
- [var.go](var.go) -- 删除 `"mode": 2` 默认值、ConfigNote 删除 mode 说明、logLevel 默认值改为 3 并更新说明
- [db/db.go](db/db.go) -- 删除 `Mode int` / `LastQuery` / `LastData` / `LastErr` 字段,改为 `LastState DBState` + `Log *log.Logger`,移除 logrus import
- [db/query.go](db/query.go) -- 用 `LastState.Set()` 原子写入替换分散的赋值、SQL 日志格式优化(无错不显示 ERROR、有错升 ERROR 级别)、`Mode != 0` 改为 Logger 级别过滤
- [go.mod](go.mod) -- 添加 zerolog 依赖
### 适配改动
- [db/crud.go](db/crud.go) -- `that.LastErr.SetError(e)` 改为 `that.lastState.SetError(e)`
- [db/transaction.go](db/transaction.go) -- 事务拷贝中 `LastQuery`/`LastData`/`LastErr` 改为 `lastState`
- [db/transaction_test.go](db/transaction_test.go) -- 删除 `Error{Logger: logger}`、logrus 替换
- [code/makecode.go](code/makecode.go) -- 删除嵌入的 `Error`,改用 `*Logger` 打印错误
- [cache/cache.go](cache/cache.go) -- `Init` 参数删除 `*Error`,改传 `*Logger`
- [cache/cache_redis.go](cache/cache_redis.go) -- `that.Error.SetError(err)` 全部改为 `that.Log.Error().Err(err).Msg(...)`
- [testing_helper.go](testing_helper.go) -- `app.Db.Mode` 删除、Logger 创建适配
- [context.go](context.go) -- `that.Error.SetError(...)` 改为 `that.Log.Error().Msg(...)`
- [example/app/test.go](example/app/test.go) -- `that.Db.LastQuery` 改为 `that.Db.GetLastState()` 相关访问
- [example/app/mysql.go](example/app/mysql.go) -- 同上
- [example/batch_cache_tester.go](example/batch_cache_tester.go) -- `&Error{}` 删除
- [example/config/configNote.json](example/config/configNote.json) -- 删除 mode 说明、更新 logLevel 说明
- [example/config/config.json](example/config/config.json) -- 删除 mode
---
## 八、应用层使用示例
```go
// 基本使用
that.Log.Infof("用户 %s 登录成功", name)
that.Log.Errorf("订单 %d 支付失败: %v", orderId, err)
// 链式调用(附加结构化字段)
that.Log.Info().Str("user", name).Int("age", 25).Msg("用户登录成功")
that.Log.Error().Err(err).Str("order", orderId).Msg("支付回调失败")
// 打印即存储(Log.Error 自动存入错误历史)
that.Log.Error().Err(err).Msg("数据库连接失败")
// DB 状态:nil = 成功,非 nil = 失败
if state := that.Db.GetLastState(); state != nil {
that.Log.Error().Err(state.Err).Str("sql", state.Query).Msg("SQL 失败")
}
// 调阅最近 5 条错误(调试用,包括 DB 错误在内的所有 ERROR 级别日志)
errors := that.Log.GetRecentErrors(5)
// SQL 日志由框架自动处理,logLevel>0 时输出
// 无错误: |INFO| SQL: SELECT... DATA: [1,2] [app/user.go:45]
// 有错误: |ERROR| SQL: SELECT... DATA: [1,2] ERROR: xxx [app/user.go:45]
```
---
## 九、向后兼容与影响范围
### 编译级 breaking changes
- `common.Error` 类型完全删除:所有嵌入 `Error` 或传递 `*Error` 的地方改为 `*Logger``error`
- `Application.Log``*logrus.Logger` 改为 `*log.Logger`
- `Application.Error` 字段删除
- `ConnectFunc` 签名简化(不再传递 `*Error`
- `HoTimeDB.LastQuery`/`LastData`/`LastErr`/`Mode` 删除:
- 应用层通过 `db.GetLastState()` 获取(nil=成功)
- 框架内部通过 `db.lastState.SetError(e)` 更新
- 删除 `mode` 配置
### 不受影响
- `codeConfig[].mode`(代码生成模式)完全不受影响
- `modeRouterStrict`(路由严格模式)完全不受影响
- `cache` 配置结构不受影响(`cache.db.mode` 等是缓存自己的 mode,与全局 mode 无关)
- `dri/mongodb``LastErr error` 是独立的普通 error,不受影响
@@ -0,0 +1,305 @@
---
name: objtoobj 问题修正
overview: 对 objtoobj.go 中发现的 3 个运行时 panic bug、2 个错误处理 bug、多处性能问题进行修正,同时新增销量方案所需的 ObjToRoundFloat64 函数。
todos:
- id: fix-uint8-panics
content: 修复 ObjToFloat64、ObjToInt64、ObjToStr 三处 uint8 分支的 panic bug
status: completed
- id: fix-objtomap-shadow
content: 修复 ObjToMap default 分支变量遮蔽和赋值逻辑错误
status: completed
- id: fix-objtoslice-default
content: 修复 ObjToSlice default 分支 Marshal 错误被覆盖的问题
status: completed
- id: fix-objtobool-error
content: 修复 ObjToBool 成功转换时仍报错的问题
status: completed
- id: fix-objtotime-padding
content: 修复 ObjToTime 单位数日+时间组合时补零失败的 bug
status: completed
- id: fix-objtoceilint64-double
content: 修复 ObjToCeilInt64 双重 Ceil 和多余 ObjToInt64 调用
status: completed
- id: perf-type-switch-idiom
content: 所有函数改用 switch v := obj.(type) 惯用写法,消除重复类型断言
status: completed
- id: perf-objtotime
content: ObjToTime 中 ObjToStr(tInt) 只调用一次并缓存
status: completed
- id: perf-objtoslice-string
content: ObjToSlice []string 分支类型断言只做一次
status: completed
- id: perf-objtostr-types
content: ObjToStr 补充 float32、bool 等常见类型的直接转换
status: completed
- id: perf-objtomaparray-cap
content: ObjToMapArray 预分配切片容量
status: completed
- id: add-roundfloat64
content: 新增 ObjToRoundFloat64,并在 map.go、obj.go 中新增对应 Get/To 方法
status: completed
isProject: false
---
# objtoobj.go 问题审查与修正计划
修改文件:[common/objtoobj.go](d:\work\hotimev1.5\common\objtoobj.go)
---
## 一、严重 Buguint8 vs []byte 类型混淆,可能 panic
开发者原意是处理数据库 driver 返回的 `[]uint8`(即 `[]byte`),但 Go 中 `uint8`(单字节)和 `[]uint8`/`[]byte`(字节数组)是完全不同的类型,type switch 不会互相匹配。
当前 `Row()` 方法(query.go:380)已经用 reflect 将 `[]uint8` 转为 `string`,所以正常 DB 流程不会触发这些分支。但其他非 DB 数据源可能传入 `[]byte`,此时会走 `default` 分支而非预期的 `uint8` 分支。
### Bug 1: `ObjToFloat64` — `case uint8` 应为 `case []byte`
```208:215:d:\work\hotimev1.5\common\objtoobj.go
case uint8:
value, e := strconv.ParseFloat(obj.(string), 64)
```
**修正**:改为 `case []byte`,用 `string(v)` 转换后再 ParseFloat
```go
case []byte:
value, e := strconv.ParseFloat(string(obj.([]byte)), 64)
if e != nil {
v = float64(0)
err = e
} else {
v = value
}
```
### Bug 2: `ObjToInt64` — `case uint8` 应为 `case []byte`
```279:286:d:\work\hotimev1.5\common\objtoobj.go
case uint8:
value, e := StrToInt(obj.(string))
```
**修正**:改为 `case []byte`,用 `string(v)` 转换后再 StrToInt
```go
case []byte:
value, e := StrToInt(string(obj.([]byte)))
if e != nil {
v = int64(0)
err = e
} else {
v = int64(value)
}
```
### Bug 3: `ObjToStr` — `case uint8` 应改为正确处理单字节
```341:342:d:\work\hotimev1.5\common\objtoobj.go
case uint8:
str = obj.(string)
```
`ObjToStr` 已有 `case []byte` 分支正确处理字节数组。这里的 `case uint8` 应正确处理单字节值:
**修正**:改为 `str = strconv.Itoa(int(obj.(uint8)))`
> 这三处 bug 目前未爆发,因为 `Row()` 已将 `[]uint8` 转为 `string`。但修正后能覆盖非 DB 数据源直接传入 `[]byte` 的场景,更加健壮。
---
## 二、逻辑 Bug
### Bug 4: `ObjToMap` default 分支 — 变量遮蔽导致错误丢失
```33:44:d:\work\hotimev1.5\common\objtoobj.go
default:
data, err := json.Marshal(obj)
if err != nil {
err = errors.New("没有合适的转换对象!" + err.Error())
v = nil
}
v = Map{}
e := json.Unmarshal(data, &v)
```
第 34 行的 `data, err := json.Marshal(obj)` 使用了 `:=`,创建了一个**新的局部变量 `err`**,遮蔽了第 14 行声明的外部 `err`。这意味着:
- Marshal 失败时,外部 `err` 仍然是 nil,错误信息丢失
- 第 38 行设 `v = nil`,但第 39 行又立刻把 `v` 重新赋值为 `Map{}`,然后用坏数据尝试 Unmarshal
**修正**:用 `=` 代替 `:=`,并在 Marshal 失败时 break。
### Bug 5: `ObjToSlice` default 分支 — Marshal 错误被覆盖
```86:91:d:\work\hotimev1.5\common\objtoobj.go
default:
v = Slice{}
var data []byte
data, err = json.Marshal(obj)
err = json.Unmarshal(data, &v)
```
第 89 行 `err = json.Marshal(obj)` 即使失败,第 90 行也立刻用 `err = json.Unmarshal(data, &v)` 覆盖了前一个错误。当 Marshal 失败时 `data` 为空,Unmarshal 也会失败,但丢失了原始的错误信息。
**修正**Marshal 失败时直接 break,不再尝试 Unmarshal。
### Bug 6: `ObjToBool` — 成功转换时也报错
```307:330:d:\work\hotimev1.5\common\objtoobj.go
default:
toInt := ObjToInt(obj)
if toInt != 0 {
v = true
}
err = errors.New("没有合适的转换对象!")
```
当传入 `1`int 类型)时,`ObjToInt` 成功返回 1`v = true`,但仍然设置了 `err`。逻辑上这不应该报错。
**修正**:只在 `ObjToInt` 也无法转换时才报错。
---
### Bug 7: `ObjToTime` 日期补零逻辑缺陷
```108:121:d:\work\hotimev1.5\common\objtoobj.go
timeNewStrs := strings.Split(tStr, "-")
for _, v := range timeNewStrs {
if len(v) == 1 {
v = "0" + v
}
}
```
按 `"-"` 分割 `"2006-1-2 15:04:05"` 得到 `["2006", "1", "2 15:04:05"]`。第三段 `"2 15:04:05"` 长度 > 1 不会补零,最终得到 `"2006-01-2 15:04:05"``time.Parse` 会失败。
**修正**:分割前先按空格分离日期和时间部分,分别处理后再拼接。或者改用 `strings.TrimLeft` 对每段的纯日期部分补零。
### Bug 8: `ObjToCeilInt64` 双重 Ceil + 多余 ObjToInt64
```238:241:d:\work\hotimev1.5\common\objtoobj.go
func ObjToCeilInt64(obj interface{}, e ...*Error) int64 {
f := ObjToCeilFloat64(obj, e...) // 内部已 math.Ceil
return ObjToInt64(math.Ceil(f)) // 又 Ceil + 走完整 type switch
}
```
**修正**:改为 `return int64(f)`,因为 `ObjToCeilFloat64` 已经返回了 Ceil 后的值。
---
## 三、性能问题
### P1: `ObjToTime` 重复调用 `ObjToStr(tInt)` 多达 5 次
```152:177:d:\work\hotimev1.5\common\objtoobj.go
if len(ObjToStr(tInt)) > 16 {
// ...
} else if len(ObjToStr(tInt)) > 13 {
// ...
} else if len(ObjToStr(tInt)) > 10 {
// ...
} else if len(ObjToStr(tInt)) > 9 {
// ...
} else if len(ObjToStr(tInt)) > 3 {
t, e := time.Parse("2006", ObjToStr(tInt))
```
每次 `ObjToStr(tInt)` 都会执行 `strconv.FormatInt` + 分配新字符串。应在循环外只调用一次,存入变量复用。
### P2: `ObjToSlice` 的 `[]string` 分支重复类型断言
```78:81:d:\work\hotimev1.5\common\objtoobj.go
case []string:
v = Slice{}
for i := 0; i < len(obj.([]string)); i++ {
v = append(v, obj.([]string)[i])
}
```
循环中每次 `obj.([]string)` 都做了类型断言。应先断言一次存入变量:`ss := obj.([]string)`。
### P3: `ObjToStr` 缺少 `float32`、`bool` 等常见类型直接转换
`float32`、`bool`、`int32` 等类型会走 `default` 分支用 `json.MarshalIndent`,比直接转换慢 100 倍以上。
**修正**:补充常见类型的 case:
```go
case float32:
str = strconv.FormatFloat(float64(obj.(float32)), 'f', -1, 32)
case bool:
str = strconv.FormatBool(obj.(bool))
```
### P4: `ObjToStr` default 分支使用 `json.MarshalIndent`
`MarshalIndent` 比 `Marshal` 慢 20-30%,且产生更大的输出。但由于此改动可能影响现有依赖格式化输出的调用方,标记为**可选优化**。
### P5: 所有函数应改用 `switch v := obj.(type)` 惯用写法
当前每个 case 都重复做类型断言(如 `obj.(int)`),改为 Go 惯用写法可消除所有冗余断言:
```go
// 改前
switch obj.(type) {
case int:
v = int64(obj.(int))
// 改后
switch val := obj.(type) {
case int:
v = int64(val)
```
### P6: `ObjToMapArray` 未预分配切片容量
`res := []Map{}` 应改为 `res := make([]Map, 0, len(s))`。
### P7: `StrToMap` / `StrToSlice` 参数名 `string` 遮蔽内置类型
参数名应改为 `s` 或 `jsonStr`,避免遮蔽 Go 内置 `string` 类型。
---
## 四、结合销量方案需新增的函数
根据[销量支持小数方案](d:\work\xbc.cursor\plans\销量支持小数方案_92094ec9.plan.md)第二步要求,需在本文件新增 `ObjToRoundFloat64`
```go
func ObjToRoundFloat64(obj interface{}, precision int, e ...*Error) float64 {
f := ObjToFloat64(obj, e...)
if precision < 0 {
return f
}
pow := math.Pow10(precision)
return math.Round(f*pow) / pow
}
```
同时需在 [common/map.go](d:\work\hotimev1.5\common\map.go) 新增 `GetRoundFloat64`,在 [common/obj.go](d:\work\hotimev1.5\common\obj.go) 新增 `ToRoundFloat64`。
---
## 五、修正摘要
| # | 类型 | 位置 | 问题 | 修正 |
| --- | ---- | ------------------- | ------------------------- | ---------------------------- |
| 1 | 类型混淆 | ObjToFloat64 uint8 | case uint8 应为 case []byte | 改为 case []byte + string() 解析 |
| 2 | 类型混淆 | ObjToInt64 uint8 | case uint8 应为 case []byte | 改为 case []byte + string() 解析 |
| 3 | 类型混淆 | ObjToStr uint8 | uint8 断言为 string 会 panic | 改为 strconv.Itoa(int(...)) |
| 4 | 逻辑 | ObjToMap default | := 遮蔽外部 err,v 赋值逻辑错乱 | 用 = 并加 break |
| 5 | 逻辑 | ObjToSlice default | Marshal 错误被 Unmarshal 覆盖 | 失败时 break |
| 6 | 逻辑 | ObjToBool default | 成功转换也报错 | 转换成功不报错 |
| 7 | 逻辑 | ObjToTime 日期补零 | 单位数日+时间时补不到零 | 分离日期时间部分再补零 |
| 8 | 逻辑 | ObjToCeilInt64 | 双重 Ceil + 多余 type switch | 改为 int64(f) |
| 9 | 性能 | ObjToTime | ObjToStr 重复调用 5 次 | 缓存结果 |
| 10 | 性能 | ObjToSlice []string | 循环内重复类型断言 | 断言一次复用 |
| 11 | 性能 | 所有函数 | 未用 switch v := obj.(type) | 改用惯用写法消除冗余断言 |
| 12 | 性能 | ObjToStr | 缺少 float32/bool 等常见类型 | 补充直接转换 case |
| 13 | 性能 | ObjToMapArray | 切片未预分配容量 | make([]Map, 0, len(s)) |
| 14 | 规范 | StrToMap/StrToSlice | 参数名 string 遮蔽内置类型 | 改为 s 或 jsonStr |
| 15 | 新增 | ObjToRoundFloat64 | 销量方案需要 | 新增函数 |
@@ -0,0 +1,311 @@
---
name: Row方法方案审查与修正
overview: 修改 4 个文件:common/objtoobj.go(新增 JsonToObj + 增强 ObjToMap/ObjToSlice)、common/map.go(增强 JsonToMap)、db/query.goRow 重写)、cache/cache_db.go(用 JsonToObj 替换 json.Unmarshal)。
todos:
- id: add-json-safe
content: 在 objtoobj.go 新增 JsonToObj(公共) + fixJsonNumbers(私有),增强 ObjToMap/ObjToSlice 的 4 处 json.Unmarshal
status: completed
- id: fix-json-to-map
content: 增强 map.go 的 JsonToMap,使用 UseNumber + fixJsonNumbers
status: completed
- id: fix-row-method
content: 重写 db/query.go 的 Row() + 新增辅助函数 + 更新 import
status: completed
- id: fix-cache-db
content: cache/cache_db.go 的 3 处 json.Unmarshal 改用 JsonToObj
status: completed
isProject: false
---
# Row() + JSON 反序列化类型保留修正方案
## 修改文件一览
| 文件 | 改动 |
| ------------------ | --------------------------------------------------------------------------------- |
| common/objtoobj.go | 新增 `JsonToObj` + `fixJsonNumbers`;增强 `ObjToMap``ObjToSlice` 的 4 处 json.Unmarshal |
| common/map.go | 增强 `Map.JsonToMap` |
| db/query.go | 重写 Row() + 新增辅助函数 + 更新 import |
| cache/cache_db.go | 3 处 json.Unmarshal 改用 `JsonToObj` |
---
## 文件 1common/objtoobj.go
### 新增 JsonToObj 和 fixJsonNumbers
在文件 `StrToMap` 之前(约第 378 行前)新增,需要在 import 中添加 `"bytes"`
```go
// JsonToObj 将 JSON 字符串反序列化为 interface{},保留数字原始类型。
// 标准 json.Unmarshal 会把所有 JSON 数字变成 float64,本函数保留整数为 int64、小数为 float64。
// JsonToObj(`{"id":2,"price":1.20}`) → Map{"id": int64(2), "price": float64(1.2)}
// JsonToObj(`[1, 2.5, "a"]`) → Slice{int64(1), float64(2.5), "a"}
func JsonToObj(jsonStr string) (interface{}, error) {
dec := json.NewDecoder(bytes.NewReader([]byte(jsonStr)))
dec.UseNumber()
var result interface{}
if err := dec.Decode(&result); err != nil {
return nil, err
}
return fixJsonNumbers(result), nil
}
// fixJsonNumbers 递归将 json.Number 转为 int64 或 float64
func fixJsonNumbers(v interface{}) interface{} {
switch val := v.(type) {
case json.Number:
if n, err := val.Int64(); err == nil {
return n
}
if f, err := val.Float64(); err == nil {
return f
}
return string(val)
case map[string]interface{}:
for k, item := range val {
val[k] = fixJsonNumbers(item)
}
return val
case []interface{}:
for i, item := range val {
val[i] = fixJsonNumbers(item)
}
return val
default:
return val
}
}
```
### 增强 ObjToMap2 处 json.Unmarshal
**位置 1** -- string case(约第 27-31 行):
```go
// 修改前:
case string:
v = Map{}
e := json.Unmarshal([]byte(obj.(string)), &v)
if e != nil {
err = errors.New("没有合适的转换对象!" + e.Error())
v = nil
}
// 修改后:
case string:
result, e := JsonToObj(val)
if e != nil {
err = errors.New("没有合适的转换对象!" + e.Error())
v = nil
} else if m, ok := result.(map[string]interface{}); ok {
v = m
} else {
err = errors.New("没有合适的转换对象!")
v = nil
}
```
**位置 2** -- default case(约第 34-44 行):
```go
// 修改前:
default:
data, err := json.Marshal(obj)
if err != nil { ... }
v = Map{}
e := json.Unmarshal(data, &v)
if e != nil { ... }
// 修改后:
default:
data, e2 := json.Marshal(obj)
if e2 != nil {
err = errors.New("没有合适的转换对象!" + e2.Error())
v = nil
} else {
result, e3 := JsonToObj(string(data))
if e3 != nil {
err = errors.New("没有合适的转换对象!" + e3.Error())
v = nil
} else if m, ok := result.(map[string]interface{}); ok {
v = m
} else {
err = errors.New("没有合适的转换对象!")
v = nil
}
}
```
### 增强 ObjToSlice2 处 json.Unmarshal
**位置 1** -- string case(约第 82-83 行):
```go
// 修改前:
case string:
v = Slice{}
err = json.Unmarshal([]byte(obj.(string)), &v)
// 修改后:
case string:
result, e := JsonToObj(val)
if e != nil {
err = e
} else if s, ok := result.([]interface{}); ok {
v = s
} else {
err = errors.New("没有合适的转换对象!")
}
```
**位置 2** -- default case(约第 86-91 行):
```go
// 修改前:
default:
v = Slice{}
var data []byte
data, err = json.Marshal(obj)
err = json.Unmarshal(data, &v)
// 修改后:
default:
data, e2 := json.Marshal(obj)
if e2 != nil {
err = e2
} else {
result, e3 := JsonToObj(string(data))
if e3 != nil {
err = e3
} else if s, ok := result.([]interface{}); ok {
v = s
} else {
err = errors.New("没有合适的转换对象!")
}
}
```
---
## 文件 2common/map.go
### 增强 JsonToMap(第 183-189 行)
需要在 import 中添加 `"bytes"`
```go
// 修改前:
func (that Map) JsonToMap(jsonStr string, err ...*Error) {
e := json.Unmarshal([]byte(jsonStr), &that)
if e != nil && len(err) != 0 {
err[0].SetError(e)
}
}
// 修改后:
func (that Map) JsonToMap(jsonStr string, err ...*Error) {
dec := json.NewDecoder(bytes.NewReader([]byte(jsonStr)))
dec.UseNumber()
e := dec.Decode(&that)
if e != nil {
if len(err) != 0 {
err[0].SetError(e)
}
return
}
for k, v := range that {
that[k] = fixJsonNumbers(v)
}
}
```
这里 `fixJsonNumbers` 定义在 `objtoobj.go` 中,同属 `common` 包,可以直接调用。
影响链路:
- `StrToMap(s)` → 调用 `JsonToMap(s)`**自动修复**
- `Map.JsonToMap(s)`**直接修复**
---
## 文件 3db/query.go -- Row() 重写
(内容与上一版相同,此处只列关键点)
### import 变更
移除 `"reflect"`,新增 `"math"``"strconv"`
### 新增辅助函数
`dbTypeUnknown/dbTypeInteger/dbTypeFloat/dbTypeDecimal` 常量 + `classifyDBType` + `getDecimalScale` + `getFloatScale` + `fixFloatValue` + `roundFloat` + `convertBytes`
覆盖 MySQL/SQLite/PostgreSQL 三种数据库的类型名。
### Row() 方法替换(第 362-391 行)
- `defer resl.Close()` + `resl.Err()` 检查
- `ColumnTypes()` 预分类列类型
- type switch 替代 reflect`[]byte``convertBytes``float64``fixFloatValue``float32``fixFloatValue`,其余不变
---
## 文件 4cache/cache_db.go -- DB 缓存
3 处 `json.Unmarshal` 改用 `JsonToObj`(来自 common 包,已通过 `. "code.hoteas.com/golang/hotime/common"` 导入):
**位置 1** -- `getLegacy()` 约第 359-363 行:
```go
// 修改前:
var data interface{}
err := json.Unmarshal([]byte(valueStr), &data)
// 修改后:
data, err := JsonToObj(valueStr)
```
**位置 2** -- `get()` 约第 411-416 行:
```go
// 修改前:
var data interface{}
err := json.Unmarshal([]byte(valueStr), &data)
// 修改后:
data, err := JsonToObj(valueStr)
```
**位置 3** -- `CachesGet()` 约第 594-598 行:
```go
// 修改前:
var data interface{}
err := json.Unmarshal([]byte(valueStr), &data)
// 修改后:
data, err := JsonToObj(valueStr)
```
不需要新增 import`JsonToObj` 在 common 包,cache_db.go 已 import)。
---
## 修正效果总结
| 数据路径 | 修正前 | 修正后 |
| ---------------------- | ------------------------------------- | ------------------------------------------------------ |
| DB → Row() → Map | float32/float64 原样存入,[]byte 全转 string | 按列类型精确转换:INT→int64, DECIMAL→roundFloat, FLOAT→fixFloat |
| DB 缓存 → json.Unmarshal | 所有数字→float64 | JsonToObj:整数→int64, 小数→float64 |
| ObjToMap(string) | json.Unmarshal 数字全→float64 | JsonToObj:保留 int64/float64 |
| ObjToSlice(string) | json.Unmarshal 数字全→float64 | JsonToObj:保留 int64/float64 |
| StrToMap(string) | JsonToMap → json.Unmarshal | JsonToMap → UseNumber + fixJsonNumbers |
| Map.JsonToMap(string) | json.Unmarshal | UseNumber + fixJsonNumbers |
| Redis 缓存 | ObjToStr → string → ObjToMap | ObjToMap 已增强,**自动修复** |
Redis 缓存路径:`set``ObjToStr``get` 返回 string → 调用方用 `ObjToMap` 转回 Map。由于 `ObjToMap` 的 string 分支现在使用 `JsonToObj`,Redis 缓存也**自动获得类型保留**,无需单独改 cache_redis.go。
@@ -0,0 +1,106 @@
---
name: Verify统一断言改造
overview: 在 Api 上新增 Resp() 方法让 Verify 回调内可以同时做响应值断言和数据库校验,然后更新文档将统一的 Verify 模式作为推荐范式。
todos:
- id: add-resp-method
content: "testing_api.go: Api 新增 lastResp 字段 + Resp() 方法,execute 中调 verifyFn 前设置 lastResp"
status: completed
- id: update-doc
content: "docs/Testing_API测试框架.md: 将 Verify 统一断言作为推荐范式,resp 外部断言降为兼容用法"
status: completed
isProject: false
---
# Verify 统一断言改造
## 问题
当前 `Verify(func(a *Api) error)` 的回调只能通过 `a.DB()` 做数据库校验,无法访问响应体。导致值断言必须在外部用 `resp.GetBody()` + `resp.Fail()` 完成,逻辑分散在两处。
## 方案:Api 上新增 Resp() 方法
`execute` 调用 `verifyFn` 之前,把 `resp` 存到 `Api` 上,然后通过 `a.Resp()` 暴露给回调。
### 代码改动([testing_api.go](testing_api.go)
**1. Api 结构体新增字段**(第 46-51 行附近):
```go
type Api struct {
app *TestApp
path string
t *testing.T
session Map
lastResp *ApiResponse // 新增:最近一次请求的响应
}
```
**2. 新增 Resp() 方法**
```go
// Resp 获取最近一次请求的响应(在 Verify 回调中使用)
func (a *Api) Resp() *ApiResponse {
return a.lastResp
}
```
**3. execute 中调用 verifyFn 前设置 lastResp**(第 296 行附近):
`if passed && c.verifyFn != nil` 之前加一行:
```go
c.api.lastResp = resp
```
**4. WithSession 中传递 lastResp**(可选,因为 WithSession 创建新实例时 lastResp 默认 nil 即可,Verify 内不会跨实例调用)。
### 改动影响
- Verify 的签名 `func(a *Api) error` 不变,完全向后兼容
- 现有 Verify 回调代码无需修改
- 新的 Verify 回调可以用 `a.Resp()` 访问响应体做值断言
- 外部 `resp.GetBody()` + `resp.Fail()` 仍然可用,作为兼容方式保留
### 文档改动([docs/Testing_API测试框架.md](docs/Testing_API测试框架.md)
将"用例编写范式"中的推荐模式从"Verify 做 DB 校验 + 外部 resp 做值断言"改为**统一在 Verify 内完成**
```go
resp := a.WithSession(Map{"user_id": int64(1)}).
JSON(Map{"goods_id": goodsId, "quantity": 3, "address": "XX路1号"}).
Verify(func(a *Api) error {
// 响应值断言
result := a.Resp().GetBody().GetMap("result")
if result.GetCeilInt64("id") <= 0 {
return fmt.Errorf("订单 ID 必须大于 0")
}
if result.GetString("sn") == "" {
return fmt.Errorf("订单编号 sn 不能为空")
}
// 数据库状态校验
row := a.DB().Get("order", "*", Map{"AND": Map{
"user_id": int64(1), "goods_id": goodsId,
}})
if row == nil {
return fmt.Errorf("order 表未写入订单记录")
}
if row.GetCeilInt64("state") != 0 {
return fmt.Errorf("新订单 state 期望 0, 实际 %d", row.GetCeilInt64("state"))
}
// ...
return nil
}).
Post("正常创建订单", 0, Map{
"id": int64(1), "sn": "sample", "total_price": float64(1.0),
})
```
文档中需要调整的位置:
- 快速开始的完整范例
- 用例编写范式的流程图和表格
- "正确请求与响应断言"小节(合并为"正确请求与 Verify 校验"
- "不好的写法 vs 推荐的写法"对比
- 保留 `resp.GetBody()` + `resp.Fail()` 的说明但标注为兼容用法
@@ -0,0 +1,250 @@
---
name: 优雅停机方案
overview: 在 HoTime 框架层实现优雅停机,跨平台(Windows/Linux)支持。收到信号后新请求立即返回 503 让 nginx 切流,等排空后调用 http.Server.Shutdown 等在途请求完成,exit(0) 退出,run_xbc.cmd 检测正常退出不重启。
todos:
- id: framework-core
content: hotimev1.5/application.go:新增 shuttingDown/shutdownOnce/DrainTimeout/ShutdownTimeout 字段,改 ServeHTTP,改 Run() 信号 goroutine,改 recover 保护,提取 initiateGracefulShutdown()
status: completed
- id: framework-windows
content: hotimev1.5/signal_windows.go(新建):SetConsoleCtrlHandler 处理 CTRL_CLOSE_EVENT(关窗口)
status: completed
- id: framework-other
content: hotimev1.5/signal_other.go(新建):非 Windows 平台 registerWindowsCloseHandler() 空实现
status: completed
- id: nginx-config
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 时不重启直接退出
status: completed
- id: docs
content: 新建 hotimev1.5/docs/graceful-shutdown.md 文档
status: completed
isProject: false
---
# 优雅停机方案
## 整体流程
```mermaid
sequenceDiagram
participant Op as 运维
participant OS as 操作系统
participant App as Go 进程
participant Nginx as Nginx
Op->>OS: Ctrl+C / kill / taskkill / 关窗口
OS->>App: SIGINT / SIGTERM / CTRL_CLOSE_EVENT
App->>App: shuttingDown = truesync.Once 保证只执行一次)
Note over App,Nginx: 新请求立即返回 503 + Connection:close
Nginx->>App: 新请求
App-->>Nginx: 503 Service Unavailable
Nginx->>Nginx: proxy_next_upstream http_503 → 转另一台
Note over App: 等 DrainTimeout(默认5s,让nginx完成切流)
App->>App: server.Shutdown(ShutdownTimeout=30s)
Note over App: 等所有在途请求跑完
App->>OS: os.Exit(0)
Note over Op: run_xbc.cmd 检测 exit 0 → 不重启
```
## 信号触发场景对照表
| 操作方式 | 平台 | 信号/事件 | 是否可拦截 | 备注 |
|---|---|---|---|---|
| Ctrl+C | Windows/Linux | `os.Interrupt` | ✅ | 标准中断 |
| `kill <pid>``kill -15 <pid>` | Linux | `syscall.SIGTERM` | ✅ | 推荐生产关闭方式 |
| `taskkill /PID <pid>`(无 /F | Windows | `os.Interrupt` | ✅ | 等价于 Ctrl+C |
| 关闭控制台窗口 X 按钮 | Windows | `CTRL_CLOSE_EVENT` | ✅(≤5s 限制)| Windows 给 ~5s 后强杀 |
| `taskkill /F /PID <pid>` | Windows | 强制 SIGKILL | ❌ | 无法拦截,强杀 |
| `kill -9 <pid>` | Linux | SIGKILL | ❌ | 无法拦截,强杀 |
> **关窗口说明**Windows 在触发 `CTRL_CLOSE_EVENT` 后约 5 秒会强制结束进程。框架对此事件使用更短的 drain(默认 2s)+ shutdown(默认 3s),总计 <5s,可完成正在跑的短接口;长接口(>3s)会被截断。推荐用 `taskkill /PID` 代替关窗口做计划性停机。
---
## 需要改动/新建的文件
### 1. [`hotimev1.5/application.go`](d:/work/hotimev1.5/application.go)
**`Application` struct** 新增字段:
```go
type Application struct {
// ...原有字段...
shuttingDown atomic.Bool
shutdownOnce sync.Once // 保证多路触发只执行一次
DrainTimeout time.Duration // 等 nginx 切流,默认 5s
ShutdownTimeout time.Duration // 等在途请求,默认 30s
}
```
**`ServeHTTP`** 新增停机检测:
```go
func (that *Application) ServeHTTP(w http.ResponseWriter, req *http.Request) {
if that.shuttingDown.Load() {
w.Header().Set("Connection", "close")
http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
return
}
that.handler(w, req)
}
```
**提取 `initiateGracefulShutdown(drain, shutdown time.Duration)` 方法**(被信号 goroutine 和 Windows 关窗 handler 共同调用):
```go
func (that *Application) initiateGracefulShutdown(drain, shutdown time.Duration) {
that.shutdownOnce.Do(func() {
that.shuttingDown.Store(true)
that.Log.Infof("优雅停机:等 %v 让 nginx 切流...", drain)
time.Sleep(drain)
ctx, cancel := context.WithTimeout(context.Background(), shutdown)
defer cancel()
that.Log.Infof("等进行中请求完成(最多 %v...", shutdown)
if err := that.Server.Shutdown(ctx); err != nil {
that.Log.Errorf("优雅停机超时: %v", err)
}
that.Log.Infof("服务已安全关闭")
os.Exit(0)
})
}
```
**`Run()`** 加信号监听 goroutine(在启动 ListenAndServe 之后):
```go
// 注册 Windows 关窗口处理(Linux 上是空实现)
that.registerWindowsCloseHandler()
// 跨平台:Ctrl+C / SIGTERM / kill
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
<-sigCh
drain := that.DrainTimeout
if drain == 0 { drain = 5 * time.Second }
shutdown := that.ShutdownTimeout
if shutdown == 0 { shutdown = 30 * time.Second }
that.initiateGracefulShutdown(drain, shutdown)
}()
```
**`defer recover`** 加停机保护:
```go
defer func() {
if err := recover(); err != nil {
if that.shuttingDown.Load() {
return // 停机中的 panic 不触发重启
}
that.Log.Warnf("%v", err)
that.Run(router)
}
}()
```
新增 import`"context"`, `"os/signal"`, `"sync"`, `"sync/atomic"`, `"syscall"`
---
### 2. `hotimev1.5/signal_windows.go`(新建)
```go
//go:build windows
package hotime
import (
"time"
"golang.org/x/sys/windows"
)
func (that *Application) registerWindowsCloseHandler() {
windows.SetConsoleCtrlHandler(func(ctrl uint32) bool {
// CTRL_CLOSE_EVENT=2 CTRL_LOGOFF_EVENT=5 CTRL_SHUTDOWN_EVENT=6
if ctrl == 2 || ctrl == 5 || ctrl == 6 {
that.initiateGracefulShutdown(2*time.Second, 3*time.Second)
// 阻塞此回调,让 initiateGracefulShutdown 内的 os.Exit(0) 执行
// Windows 约 5s 后强杀,这里最多等 6s 兜底
time.Sleep(6 * time.Second)
return true
}
return false
}, true)
}
```
> `golang.org/x/sys` 已在 `xbc/go.mod` 中作为间接依赖存在(v0.29.0),无需新增依赖。
---
### 3. `hotimev1.5/signal_other.go`(新建)
```go
//go:build !windows
package hotime
func (that *Application) registerWindowsCloseHandler() {}
```
---
### 4. Nginx 配置(在已有 location 块中追加)
```nginx
# 让后端返回 503 时自动切换到另一台(优雅停机核心)
proxy_next_upstream error timeout http_503;
proxy_next_upstream_tries 2;
```
已有的 `max_fails=1 fail_timeout=3s` 负责连接级失败(强杀后的端口拒绝),两者互补。
---
### 5. [`run_xbc.cmd`](d:/work/xbc/run_xbc.cmd)
`goto run_app` 前插入 exit code 判断:
```cmd
:: 优雅退出 (exit 0) 不重启
if %EXIT_CODE% EQU 0 (
echo [%date% %time%] Graceful shutdown. Not restarting. >> "%LOG_FILE%"
echo.
echo [优雅停机] 服务已安全关闭,不再自动重启。
pause
exit /b 0
)
```
---
### 6. `hotimev1.5/docs/graceful-shutdown.md`(新建)
包含:原理说明、场景对照表、nginx 配置、滚动重启步骤、参数说明(DrainTimeout/ShutdownTimeout)。
---
## 滚动重启操作步骤(Windows)
```
# 1. 找到端口 9998 的进程 PID
netstat -ano | findstr 9998
# 2. 优雅关闭该实例(不加 /F)
taskkill /PID <pid>
# 3. 等日志出现 "服务已安全关闭" 后再部署新版并启动
# 4. 再对 9999 执行同样操作
```
## 滚动重启操作步骤(Linux
```bash
# 找 PID
pgrep xbc # 或 ps aux | grep xbc
# 优雅关闭
kill <pid> # 发 SIGTERM,触发优雅停机
# 或
kill -2 <pid> # 发 SIGINT,等同 Ctrl+C
# 等日志出现 "服务已安全关闭" 再部署新版
```
@@ -0,0 +1,50 @@
---
name: 修复DM列注释读取
overview: 修复达梦数据库列注释(COMMENT)未被正确读取的问题,导致管理端所有字段显示为数据库字段名而非中文备注。
todos:
- id: fix-dm-col-comments
content: 修改 makecode.go 列信息查询,列注释改用 USER_COL_COMMENTS 替代 ALL_COL_COMMENTS
status: completed
isProject: false
---
# 修复达梦数据库列注释读取失败
## 问题分析
通过 MCP 查询 `adminDB.json` 确认:所有表的字段 label 全部等于字段名本身(如 `"id"`, `"name"`, `"phone"`),没有中文备注。但表级 label 正常(如"消息主表"、"用户表")。
用户确认数据库字段确实有备注。
### 根因
[makecode.go](d:\work\hotimev1.5\code\makecode.go) 第 170 行当前的列查询 SQL:
```sql
FROM ALL_TAB_COLUMNS c
LEFT JOIN ALL_COL_COMMENTS m
ON c.TABLE_NAME=m.TABLE_NAME AND c.COLUMN_NAME=m.COLUMN_NAME AND c.OWNER=m.OWNER
WHERE c.TABLE_NAME='...' AND c.OWNER='ZWBG'
```
`LEFT JOIN` 条件中 `c.OWNER=m.OWNER` 要求 `ALL_COL_COMMENTS``OWNER` 也等于 `'ZWBG'`。但在 DM 中,`ALL_COL_COMMENTS``OWNER` 值可能与 `ALL_TAB_COLUMNS` 不一致(DM 的 Oracle 兼容视图在 OWNER 语义上可能存在差异),导致 JOIN 匹配不上,`m.COMMENTS` 全部为 NULL。
对比:表注释用的 `ALL_TAB_COMMENTS WHERE OWNER='ZWBG'` 能正确返回中文备注,说明表级视图的 OWNER 一致,但列级视图可能不同。
## 修复方案
修改 [makecode.go](d:\work\hotimev1.5\code\makecode.go) 第 169-171 行,列注释改用 `USER_COL_COMMENTS`(自动使用当前 schema 上下文 ZWBG,无需 OWNER 过滤),列元数据继续用 `ALL_TAB_COLUMNS`(保留 OWNER 过滤防止系统表):
```go
if db.Type == "dm" || db.Type == "dameng" {
tableInfo = db.Query(`SELECT c.COLUMN_NAME AS "name", c.DATA_TYPE AS "type", m.COMMENTS AS "label", c.NULLABLE AS "must", c.DATA_DEFAULT AS "dflt_value" FROM ALL_TAB_COLUMNS c LEFT JOIN USER_COL_COMMENTS m ON c.TABLE_NAME=m.TABLE_NAME AND c.COLUMN_NAME=m.COLUMN_NAME WHERE c.TABLE_NAME='` + v.GetString("name") + `' AND c.OWNER='` + strings.ToUpper(db.DBName) + `' ORDER BY c.COLUMN_ID`)
}
```
关键变化:
- `ALL_COL_COMMENTS` 改为 `USER_COL_COMMENTS`
- 去掉 JOIN 中的 `AND c.OWNER=m.OWNER``USER_COL_COMMENTS` 没有 OWNER 列)
- WHERE 条件 `c.OWNER='ZWBG'` 保留(作用于 `ALL_TAB_COLUMNS`,防止系统表列混入)
原理:框架连接 DM 用 `?schema=ZWBG``USER_COL_COMMENTS` 自动返回当前 schema(ZWBG)的列注释,绕开了 OWNER 不一致问题。
@@ -0,0 +1,81 @@
---
name: 修复DM类型映射大小写
overview: DM 返回的列类型为大写(BIGINT、VARCHAR、INT、TIMESTAMP),但 ColumnDataType 的匹配键是小写且 strings.Contains 区分大小写,导致类型映射全部失败。
todos:
- id: fix-type-case
content: 修改 makecode.go 类型比较加 strings.ToLower 忽略大小写
status: completed
- id: add-dm-types
content: 在 config.go 的 ColumnDataType 中追加 DM 特有数据类型映射
status: completed
isProject: false
---
# 修复达梦数据库列类型映射失败
## 问题分析
通过 MCP 查询 `adminDB.json` 确认:大量字段 type 仍为 DM 原始类型(`BIGINT``VARCHAR``INT``TIMESTAMP``DECIMAL`),未映射到框架类型(`number``text``time`),导致前端编辑页无法识别控件类型。
### 根因
[config.go](d:\work\hotimev1.5\code\config.go) 第 43-59 行定义的 `ColumnDataType` 映射表键全部为小写:
```go
var ColumnDataType = map[string]string{
"int": "number",
"char": "text",
"text": "text",
"time": "time",
// ...
}
```
[makecode.go](d:\work\hotimev1.5\code\makecode.go) 第 191-194 行的匹配逻辑使用 `strings.Contains`**区分大小写**
```go
for k, v1 := range ColumnDataType {
if strings.Contains(info.GetString("type"), k) { // "BIGINT" 不包含 "int"
info["type"] = v1
break
}
}
```
DM 返回的类型全部为大写(`INT``VARCHAR``BIGINT`),而键为小写(`int``char`),`strings.Contains("BIGINT", "int")` 返回 `false`,映射全部跳过。
## 修复方案
### 修改 1:类型比较忽略大小写 - [makecode.go](d:\work\hotimev1.5\code\makecode.go)
第 192 行,将 `info.GetString("type")` 转为小写再匹配:
```go
if strings.Contains(strings.ToLower(info.GetString("type")), k) {
```
效果验证:
- `"BIGINT"` -> `"bigint"` -> contains `"int"` -> `"number"`
- `"VARCHAR"` -> `"varchar"` -> contains `"char"` -> `"text"`
- `"INT"` -> `"int"` -> contains `"int"` -> `"number"`
- `"TIMESTAMP"` -> `"timestamp"` -> contains `"time"` -> `"time"`
- `"DECIMAL"` -> `"decimal"` -> contains `"decimal"` -> `"number"`
### 修改 2:补充 DM 特有类型 - [config.go](d:\work\hotimev1.5\code\config.go)
`ColumnDataType` 中追加 DM 特有的数据类型,确保 CLOB、NUMBER 等也能被识别:
```go
var ColumnDataType = map[string]string{
// 现有 MySQL/SQLite 类型...
// DM 特有类型
"clob": "text",
"number": "number",
"numeric": "number",
"binary": "text",
"boolean": "number",
"bit": "number",
}
```
@@ -0,0 +1,99 @@
---
name: 修复Table和Edit组件
overview: 修复 Table.vue 的重复请求和搜索防抖问题,以及 Edit.vue 密码改为弹窗独立修改。
todos:
- id: fix-double-request
content: "Table.vue: 添加 _skipFormWatch 标志,修复 mounted 和 watcher 导致的重复请求"
status: completed
- id: fix-keyword-search
content: "Table.vue: 关键字搜索改为点击按钮/Enter触发,不再实时触发"
status: completed
- id: fix-password-dialog
content: "Edit.vue: 密码字段改为弹窗独立修改,主表单提交排除密码数据"
status: completed
isProject: false
---
# 修复 Table 重复请求、搜索防抖、Edit 密码弹窗
## 1. 修复重复请求 - [Table.vue](d:\work\myst-web\src\components\Table.vue)
**根因**`mounted()` 中既修改了 form 属性(触发 deep watcher),又显式调用了 `formDebounce`,两者可能各触发一次请求。
**方案**:在 data 中增加 `_skipFormWatch: true` 标志,mounted 中所有 form 初始化完成后调用一次 `formDebounce`,然后在 `$nextTick` 中将标志设为 false。watcher 中检查该标志,初始化期间跳过。
```javascript
// data 中
_skipFormWatch: true,
// watcher 中
form: {
handler() {
if (this._skipFormWatch) return;
this.formDebounce(this);
},
deep: true
}
// mounted 末尾
this.formDebounce(this);
this.$nextTick(() => { this._skipFormWatch = false; });
```
## 2. 搜索改为点击触发 - [Table.vue](d:\work\myst-web\src\components\Table.vue)
**方案**:将关键字搜索从实时触发改为点击搜索按钮触发。
- 新增 `searchKeyword: ""` data 属性,输入框 `v-model` 改为绑定 `searchKeyword`
- 搜索按钮添加 `@click="doSearch"` 事件
- `doSearch` 方法将 `searchKeyword` 赋值给 `form.keyword`(触发 watcher 进行搜索)
- 输入框支持 Enter 键触发搜索(`@keyup.enter`
- 重置按钮也同步清空 `searchKeyword`
关键模板改动:
```html
<el-input v-model="searchKeyword" @keyup.enter="doSearch" ...>
<template #append>
<el-button class="search-btn" @click="doSearch">
<el-icon><Search/></el-icon>
</el-button>
</template>
</el-input>
```
## 3. 密码改为弹窗独立修改 - [Edit.vue](d:\work\myst-web\src\components\Edit.vue)
**方案**
- 主表单中隐藏 password 类型字段,改为显示"修改密码"按钮
- 新增密码修改弹窗(`el-dialog`),包含新密码 + 确认密码两个输入
- 弹窗提交时校验两次输入一致,然后仅发送密码字段调用 `edit` API
- 主表单 `onSubmit` 提交数据时过滤掉 password 类型字段
关键改动点:
**模板** - 密码字段区域(约第55行):
```html
<!-- 原密码输入改为修改密码按钮 -->
<el-button v-if="column.type=='password'" type="warning" @click="showPasswordDialog = true">
修改密码
</el-button>
<!-- 其他非密码字段保持原样 -->
<el-input v-model="form[column.value]" show-password v-else-if="..." />
```
**data** 新增:
```javascript
showPasswordDialog: false,
passwordForm: { password: '', confirmPassword: '' },
passwordColumn: null,
```
**methods** 新增 `submitPassword` 和修改 `onSubmit`
- `submitPassword`:校验密码一致性,调用 `edit` API 仅提交密码字段
- `onSubmit`:提交时从 form 中排除 password 类型字段的数据
@@ -0,0 +1,67 @@
---
name: 修复路径和测试数据
overview: 修复测试时 swagger/config 生成到错误目录的问题(根因是 Go test CWD 在 app/ 下),并为 DM 和 MySQL 两个测试数据库创建完善的测试数据表和种子数据。
todos:
- id: fix-cwd
content: 修复 app_test.go 中的工作目录问题:添加 os.Chdir("..") 并修改 config path
status: completed
- id: cleanup-wrong-dirs
content: 清理错误生成的 example/app/tpt/ 和 example/app/config/ 目录
status: completed
- id: setup-mysql
content: 创建 setup_mysql.goMySQL DDL 建表逻辑(与 setup_dm.go 对应)
status: completed
- id: extract-seed
content: 提取通用种子数据函数 seedTestDataDM 和 MySQL 共用
status: completed
- id: update-testmain
content: 更新 app_test.go 增加 SetupMySQLDatabase 调用
status: completed
- id: sql-scripts
content: 在 example/sql/ 下提供 MySQL 和 DM 的原始 DDL+DML SQL 脚本
status: completed
- id: verify-tests
content: 运行测试验证 swagger 输出到正确位置,且 DM 测试全部通过
status: completed
isProject: false
---
# 修复 Swagger 路径问题 + 完善双数据库测试数据
## 问题 1Swagger 和 config 生成到错误目录
**根因**`go test ./app/...` 时,Go test 将工作目录设为包目录 `example/app/`。配置文件中 `"tpt": "tpt"``codeConfig` 中的 `"config/admin.json"` 等相对路径,都从 `example/app/` 解析,导致文件生成到 `example/app/tpt/``example/app/config/` 而非预期的 `example/tpt/``example/config/`
**修复方案**:在 [example/app/app_test.go](example/app/app_test.go) 的 `TestMain` 中,`NewTestApp` 之前先 `os.Chdir("..")`,将工作目录切回 `example/`,然后将配置路径从 `"../config/config.json"` 改为 `"config/config.json"`
```go
func TestMain(m *testing.M) {
os.Chdir("..") // 切回 example/ 目录,使 tpt、config 等相对路径正确解析
testApp = NewTestApp("config/config.json", ...)
...
}
```
同时清理已错误生成的 `example/app/tpt/``example/app/config/` 目录。
## 问题 2:双数据库测试数据
现有 [example/app/setup_dm.go](example/app/setup_dm.go) 仅处理 DM。需要为 MySQL 创建类似的初始化逻辑,并使种子数据逻辑可复用。
**数据库结构**(DM 和 MySQL 共用同一套表):
- `admin` — 管理员表(id, name, phone, state, password, role_id, title, create_time, modify_time
- `ctg` — 分类表(id, name, state, create_time, modify_time
- `article` — 文章表(id, title, author, content, state, click_num, sort, img, ctg_id, admin_id, create_time, modify_time
- `test_batch` — 批量测试表(id, name, title, state, create_time
- `cached` — 老版本缓存表(id, key, value, endtime, time
- `hotime_cache` — 新版缓存表(id, key, value, end_time, state, create_time, modify_time
**方案**:
1. 创建 [example/app/setup_mysql.go](example/app/setup_mysql.go):包含 MySQL DDL 建表和种子数据灌入,类似 `setup_dm.go` 的结构
2. 在 [example/app/app_test.go](example/app/app_test.go) 的 `TestMain` 中增加 `SetupMySQLDatabase(&testApp.Db)` 调用(根据 `db.Type` 自动判断是否执行)
3. 种子数据使用 ORM 的 `db.Insert()` 方法,与数据库类型无关,可直接复用现有 `setup_dm.go``seedDMData` 的逻辑,提取为通用的 `seedTestData` 函数
4. 同时提供 MySQL 和 DM 的原始 DDL/DML SQL 脚本,放在 `example/sql/` 目录下,方便手动执行
**config.json 切换方式**:当前 MySQL 的 key 是 `"mysql-"`(带尾缀表示禁用),DM 的 key 是 `"dm"`(活跃)。切换数据库只需互换后缀:`"mysql"` / `"dm-"`
@@ -0,0 +1,110 @@
---
name: 修复达梦数据库越界和引号
overview: 修复 HoTime 框架代码生成器的两个达梦(DM)数据库问题:(1) 获取表列表时越界包含了系统表;(2) SQL 查询使用了 MySQL 反引号语法导致达梦解析失败。
todos:
- id: fix-dm-table-filter
content: 修改 code/makecode.go 的达梦表列表和列信息查询,使用 ALL_TAB_COMMENTS + OWNER 过滤
status: completed
- id: fix-backtick-convert
content: 修改 db/identifier.go 的 ProcessFieldList,增加 convertQuotes 调用转换残留反引号
status: completed
- id: regen-admin-json
content: 删除/清理 myst-go/config/admin.json 中的系统表条目,重新生成配置
status: completed
isProject: false
---
# 修复达梦数据库越界获取系统表和反引号语法错误
## 问题分析
### 问题 1:越界获取非 ZWBG 数据库的表
[code/makecode.go](d:\work\hotimev1.5\code\makecode.go) 第 121-122 行:
```go
nowTables = db.Query(`SELECT TABLE_NAME AS "name", COMMENTS AS "label" FROM USER_TAB_COMMENTS WHERE TABLE_TYPE='TABLE'`)
```
使用 `SYSDBA` 用户连接达梦时,`USER_TAB_COMMENTS` 返回该用户下**所有表**,包括达梦系统表(`SYSJOBS``SYSJOBSCHEDULES``SYSALERTS``##HISTOGRAMS_TABLE` 等),导致 [admin.json](d:\work\myst-go\config\admin.json) 中混入了大量不属于 ZWBG 业务的系统表。
同样的问题存在于第 169-170 行的列信息查询(`USER_TAB_COLUMNS` / `USER_COL_COMMENTS`)。
### 问题 2:反引号导致 SQL 语法错误(遗留问题)
`makecode.go``Info`(第 725 行)和 `Search`(第 958 行)使用反引号拼接列名字符串:``col1`,`col2`,...`。
`crud.go` 的 `Select` 方法有**两种字段处理路径**
- **Slice 路径**(第 100-109 行):每个列名走 `ProcessColumnNoPrefix` -> `QuoteIdentifier`,能正确转换引号
- **String 路径**(第 93-99 行):整个字符串走 `ProcessFieldList` -> `ProcessConditionString`,三个正则都只匹配 `table.column` 格式(含 `.`),不处理单独的反引号列名
`makecode.go` 传的是拼接好的**字符串**,走的是 String 路径,所以反引号不会被自动转换,导致达梦报语法错误。
---
## 修复方案
### 修改 1:过滤达梦系统表 - [code/makecode.go](d:\work\hotimev1.5\code\makecode.go)
将 `USER_TAB_COMMENTS` / `USER_TAB_COLUMNS` 改为 `ALL_TAB_COMMENTS` / `ALL_TAB_COLUMNS`,并通过 `OWNER` 过滤为 `db.DBName`(即 `ZWBG`),确保只获取目标 schema 的业务表。
**第 121-122 行**,表列表查询改为:
```go
if db.Type == "dm" || db.Type == "dameng" {
nowTables = db.Query(`SELECT TABLE_NAME AS "name", COMMENTS AS "label" FROM ALL_TAB_COMMENTS WHERE TABLE_TYPE='TABLE' AND OWNER='` + strings.ToUpper(db.DBName) + `'`)
}
```
**第 169-170 行**,列信息查询改为:
```go
if db.Type == "dm" || db.Type == "dameng" {
tableInfo = db.Query(`SELECT c.COLUMN_NAME AS "name", c.DATA_TYPE AS "type", m.COMMENTS AS "label", c.NULLABLE AS "must", c.DATA_DEFAULT AS "dflt_value" FROM ALL_TAB_COLUMNS c LEFT JOIN ALL_COL_COMMENTS m ON c.TABLE_NAME=m.TABLE_NAME AND c.COLUMN_NAME=m.COLUMN_NAME AND c.OWNER=m.OWNER WHERE c.TABLE_NAME='` + v.GetString("name") + `' AND c.OWNER='` + strings.ToUpper(db.DBName) + `' ORDER BY c.COLUMN_ID`)
}
```
### 修改 2:修复反引号转换 - [db/identifier.go](d:\work\hotimev1.5\db\identifier.go)
`makecode.go` 的反引号属于遗留代码,理论上 `Select` 的 Slice 路径已能自动处理引号,但 `makecode.go` 传的是字符串。最小改动:在 `ProcessFieldList` 末尾加 `convertQuotes` 调用,将 `ProcessConditionString` 未能匹配到的残留反引号统一转为当前方言引号:
```go
func (p *IdentifierProcessor) ProcessFieldList(fields string) string {
if fields == "" || fields == "*" {
return fields
}
result := p.ProcessConditionString(fields)
result = p.convertQuotes(result)
return result
}
```
`convertQuotes`(第 240-248 行)已实现引号转换:达梦将反引号转为 `"`MySQL 保持不变(no-op)。这样无需修改 `makecode.go` 中的遗留反引号代码,所有走 `ProcessFieldList` 的字符串都能被正确处理。
### 修改 3:重新生成 admin.json - [config/admin.json](d:\work\myst-go\config\admin.json)
代码修复后,需要删除当前 `admin.json`(已包含系统表污染)并重启应用让其自动重新生成。需要注意的是:
- 如果 `admin.json` 中有手工定制的菜单/权限配置,**需先备份**再删除
- 或者手动从 `admin.json` 中删除以下系统表条目:`SYSJOBSCHEDULES`、`SYSJOBSTEPS`、`SYSJOBHISTORIES`、`SYSJOBHISTORIES2`、`SYSSTEPHISTORIES2`、`SYSALERTHISTORIES`、`SYSALERTNOTIFICATIONS`、`SYSALERTS`、`SYSMAILINFO`、`SYSOPERATORS`、`SYSJOBS`、`##HISTOGRAMS_TABLE`、`cached`、`hotime_cache`、`test_batch`
---
## 数据流概览
```mermaid
flowchart LR
subgraph code_gen [代码生成 - makecode.go]
A["Db2JSON()"] -->|"USER_TAB_COMMENTS (修复前)"| B["获取所有表含系统表"]
A -->|"ALL_TAB_COMMENTS + OWNER (修复后)"| C["仅获取ZWBG业务表"]
end
subgraph query [查询执行 - code.go + crud.go]
D["Info/Search 拼字段"] -->|反引号列名| E["ProcessFieldList"]
E -->|"ProcessConditionString (不匹配)"| F["反引号保留 -> DM报错"]
E -->|"+ convertQuotes (修复后)"| G["转为双引号 -> DM正常"]
end
```
@@ -0,0 +1,72 @@
---
name: 字段备注空格扩展
overview: 在 hotime 的列注释解析中,除保留「首段为系统内 label」外,将「第一个空格之后、选项/花括号规则之前」的内容单独写入新字段(如 remark),供前端展示;并澄清:管理端若列来自已有配置或 JSON,可能未经过 DB 新列分支,故不会表现为「整段 label 被丢掉」。
todos:
- id: parse-makecode
content: 在 makecode.go 中实现 SplitN + 空格拆分 + remark 字段,并统一 options/ps 解析顺序
status: pending
- id: ui-remark
content: 在 myst-web Edit/Info 中展示 column.remark(可选样式)
status: pending
- id: docs-format
content: 补充列注释格式说明文档
status: pending
isProject: false
---
# 表字段备注:空格后扩展备注(修正认知)
## 为何你能在管理端看到「完整」label
`[hotimev1.5/code/makecode.go](d:/work/hotimev1.5/code/makecode.go)` 中,**空格截断与 `:` 处理仅发生在 `if coloum == nil` 分支**(约 189–325 行),即:**仅当该列尚未在 `TableColumns` 中存在、完全由本次从库表结构推断新建列时**才会执行。
若列定义已来自 `[config/adminDB.json](d:/work/myst-go/config/adminDB.json)` 等已有配置,会走 `coloum != nil`,**不会执行**上述截断逻辑,管理端下发的 `column.label` 仍可能是你在 JSON 里写的整段字符串,因此**不会出现「整段在 UI 里被丢掉」的现象**。
你遇到的「断言/解析」问题更可能出在:**需要同时支持「系统内短 label」与「空格后的说明」两种语义**,而不是单纯丢失数据。
## 目标行为(约定)
对**数据库列注释**(以及与之对齐的生成逻辑)采用统一解析顺序,避免与现有 `:` 选项、`{...}``ps` 冲突:
1.`**SplitN(raw, ":", 2)`** 拆出「主文案」与「选项串」(替代当前对整串 `strings.Split(..., ":")`,避免备注里出现冒号时误伤)。
2. 在「主文案」中按 **第一个空格** 拆成:
- **第一段**`label`(系统内字段名/短标题,与现有一致)
- **第二段及以后**(或仅第二段,按产品定):trim 后写入新字段,建议键名 `**remark`**(若你更倾向 `note` / `labelRemark` 可再定)
3. `**{...}``ps`**:仍在**完整主文案或完整 raw** 上提取(与现有行为一致),避免破坏依赖 `ps` 的表单。
4. **选项解析**:使用 `SplitN` 的第二段,保持 `1-男,2-女` 这类格式不变。
示例:`姓名 对外展示说明 :1-男,2-女``label=姓名``remark=对外展示说明`options 从 `:1-男,2-女` 解析。
## 实现位置
| 层级 | 文件 | 内容 |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| 后端 | `[hotimev1.5/code/makecode.go](d:/work/hotimev1.5/code/makecode.go)` | 重构 218233 与 300308 行附近逻辑:先 `:` 再空格,并设置 `coloum["remark"]`;无空格时行为与现有一致 |
| 前端(可选但推荐) | `[myst-web/src/components/Edit.vue](d:/work/myst-web/src/components/Edit.vue)``[Info.vue](d:/work/myst-web/src/components/Info.vue)` | 在表单项标签旁或下方展示 `column.remark`(小字/灰色提示),无则不占位 |
| 文档 | `[myst-go/docs/](d:/work/myst-go/docs/)` 或 QUICKSTART | 简短说明列注释格式:`label 备注 :选项` |
依赖关系:`[myst-go/go.mod](d:/work/myst-go/go.mod)``replace code.hoteas.com/golang/hotime => ../hotimev1.5`,改 hotime 源码即可被 myst-go 使用。
## 兼容与回归
- **无空格**、仅 `label:选项`、仅 `{ps}`:行为与现网一致。
- **adminDB.json 手工写的长 label**:若需同样拆分,可在后续加「对已有列合并 DB 注释时补写 remark」或提供一次性脚本;首版以 **Db2JSON 新列路径****重新生成/同步列** 为主。
## 流程示意
```mermaid
flowchart LR
raw[DB列注释raw]
raw --> splitColon["SplitN 第一处冒号"]
splitColon --> mainPart[主文案]
splitColon --> optPart[选项串]
main --> splitSpace["第一处空格"]
splitSpace --> label[label]
splitSpace --> remark[remark]
raw --> psBrace["提取 ps 花括号"]
```
@@ -0,0 +1,102 @@
---
name: 字段提示括号扩展
overview: 在后端解析数据库列注释中的 `()` 内容作为字段提示(ps),修复空格截断 bug,并在前端 Table/Info/Edit 组件中展示提示信息。
todos:
- id: backend-parse
content: 后端 makecode.go:新增 () 解析,调整解析顺序(先提取括号内容,再空格/冒号截断),存入 ps 字段
status: completed
- id: backend-fix-space
content: 后端 makecode.go:修复 coloum != nil 分支中 label 未截断空格的问题
status: completed
- id: frontend-edit
content: 前端 Edit.vue / Add.vue:输入框右侧显示 column.ps 提示文字
status: completed
- id: frontend-table
content: 前端 Table.vue:列头加 el-tooltip 展示 td.ps
status: completed
- id: frontend-info
content: 前端 Info.vue:详情字段名加 tooltip 展示 column.ps
status: completed
isProject: false
---
# 字段提示:支持 () 括号 + 前端展示
## 现状问题
1. 后端已经能从 `{}` 提取 `ps`,但前端从未使用 `ps` 字段
2. `{}` 写法不直观,用户希望用更常见的 `()` 来写字段提示
3. 管理端某些列的 label 包含了空格后面的文字(未经截断)
## 数据库注释格式约定(举例)
| 注释写法 | label | ps | options |
| --------------------------- | ----- | ------- | -------------- |
| `姓名` | 姓名 | 无 | 无 |
| `状态(当前审核状态)` | 状态 | 当前审核状态 | 无 |
| `性别:1-男,2-女` | 性别 | 无 | 男/女 |
| `状态(当前审核状态):1-待审,2-通过,3-驳回` | 状态 | 当前审核状态 | 待审/通过/驳回 |
| `备注{请输入详细说明}` | 备注 | 请输入详细说明 | 无 |
| `姓名 xxx说明` | 姓名 | 无 | 无(空格后丢弃,同现有行为) |
`()``{}` 都提取到同一个 `ps` 字段(功能一致,只是写法不同)。
## 后端改动
文件:[hotimev1.5/code/makecode.go](d:/work/hotimev1.5/code/makecode.go) 约 218-233 行
当前解析顺序是:空格截断 -> 花括号提取ps -> 冒号截断。需要调整为:
1. **新增 `()` 解析**:在原有 `{}` 提取 ps 的逻辑旁边,增加对 `(``)` 的检测,提取内容同样存入 `coloum["ps"]`
2. **调整解析顺序**:先提取 `()` / `{}`(从原始注释中),再做空格截断和冒号截断,避免截断后丢失括号内容
3. **提取后清理 label**:将 `(...)``{...}` 从 label 中移除,确保 label 干净
改后伪逻辑(自然语言):
```
原始注释 = "状态(当前审核状态):1-待审,2-通过,3-驳回"
第一步:从原始注释中找 () 或 {},提取内容 → ps = "当前审核状态"
第二步:把 (...) 或 {...} 从注释中去掉 → "状态:1-待审,2-通过,3-驳回"
第三步:空格截断(取第一个空格前的部分)
第四步:冒号截断 label → label = "状态"
第五步:冒号后面解析选项 → options
```
## 前端改动
### 1. Edit.vue / Add.vue — 输入框右侧显示提示
`el-form-item` 内、输入控件后面追加一个小字提示:
```html
<span v-if="column.ps" class="field-hint">{{ column.ps }}</span>
```
样式为灰色小字,紧贴输入框右侧。
### 2. Table.vue — 列头 tooltip
`el-table-column` 加 header 插槽,当 `td.ps` 存在时用 `el-tooltip` 包裹列头文字:
```html
<template #header v-if="td.ps">
<el-tooltip :content="td.ps" placement="top">
<span>{{ td.label }}</span>
</el-tooltip>
</template>
```
`ps` 的列保持原样。
### 3. Info.vue — 字段名 tooltip
类似地,在详情页的 `el-descriptions-item` 的 label 上加 tooltip 展示 `column.ps`
## 关于"管理端空格后 label 未截断"的 bug
这个问题出在:当列已存在于配置(`coloum != nil` 分支),不会执行空格截断逻辑。如果 adminDB.json 里的 label 本身就包含空格后的文字,它会原样显示。
修复方式:在 makecode.go 的 `coloum != nil` 分支(即已有配置的列),也对 label 做同样的空格截断和 `()` / `{}` 提取处理。或者确认 adminDB.json 中的 label 是否需要手动清理。需要先确认具体是哪些列出现了这个问题。
@@ -0,0 +1,328 @@
---
name: 支持达梦数据库
overview: 在 HoTime 框架的现有 Dialect 方言体系下,新增达梦(DM8)数据库方言实现,涉及驱动引入、方言适配、连接配置、缓存层/代码生成/备份模块的 DM 分支补充。
todos:
- id: dm-driver
content: 引入达梦 Go 驱动依赖(go.mod + import),确定驱动引入方式
status: pending
- id: dm-dialect
content: 在 db/dialect.go 中实现 DMDialectQuote/Placeholder/UpsertSQL 等全部方法)
status: pending
- id: dm-init
content: 在 db/db.go 的 initDialect() 中注册 dm 类型
status: pending
- id: dm-connect
content: 在 application.go 中添加 SetDmDB() 连接函数和 SetDB() 中的 dm 分支
status: pending
- id: dm-config
content: 在 var.go 中添加 dm 数据库配置说明
status: pending
- id: dm-cache
content: 在 cache/cache_db.go 中为 tableExists/createMainTable/createHistoryTable/migrateFromCached/set 添加 dm 分支
status: pending
- id: dm-makecode
content: 在 code/makecode.go 中添加 dm 的表结构查询逻辑
status: pending
- id: dm-insert-returning
content: 修改 db/crud.go 的 Insert() 函数,当 SupportsLastInsertId()=false 时使用 RETURNING + QueryRow 获取插入 ID
status: pending
- id: dm-upsert
content: 在 db/crud.go 中新增 buildDMUpsert() 方法(MERGE INTO 语法),并在 Upsert() 中添加 dm 分支
status: pending
- id: dm-backup
content: 在 db/backup.go 中适配达梦的 DDL 导出和标识符引号
status: pending
- id: dm-query-types
content: 在 db/query.go 的 classifyDBType() 中补充达梦特有数据类型(NUMBER 等)
status: pending
- id: refactor-example
content: 重构 example/main.go,最小化启动配置,拆分到 example/app/ 目录
status: pending
- id: example-test-ctr
content: 创建 example/app/test.go(通用 ORM 测试 Ctr+ test_test.go(测试定义),含数据库无关的 CRUD/条件/JOIN/聚合/分页/批量/Upsert/事务
status: pending
- id: example-mysql-ctr
content: 创建 example/app/mysql.goMySQL 特有 Ctr+ mysql_test.go,含 DDL/SHOW TABLES/DESCRIBE/原生SQL
status: pending
- id: example-dmdb-ctr
content: 创建 example/app/dmdb.go(达梦特有 Ctr+ dmdb_test.go,含达梦 DDL/USER_TABLES/元数据/原生SQL
status: pending
- id: example-app-test
content: 创建 example/app/init.go(路由注册)+ app_test.goTestMain+TestApi),集成测试框架
status: pending
- id: example-config
content: 修改 example/config/config.jsonmysql 改为 mysql-(假注释禁用),新增 dm 配置
status: pending
isProject: false
---
# 支持达梦(DM8)数据库
## 现状分析
项目已有完善的 Dialect 接口体系([db/dialect.go](db/dialect.go)),以及 `initDialect()` 工厂方法([db/db.go](db/db.go)),当前支持 MySQL、PostgreSQL、SQLite 三种方言。需要新增达梦方言并在各处补充 `case "dm"` 分支。
```mermaid
flowchart TB
subgraph currentState [当前架构]
DialectIf["Dialect 接口<br/>(db/dialect.go)"]
MySQL["MySQLDialect"]
PG["PostgreSQLDialect"]
SQLite["SQLiteDialect"]
DialectIf --> MySQL
DialectIf --> PG
DialectIf --> SQLite
end
subgraph newState [新增]
DM["DMDialect"]
DialectIf --> DM
end
```
## 达梦 vs MySQL 关键 SQL 差异
- **标识符引号**: 达梦使用双引号 `"name"`(同 PostgreSQL),MySQL 用反引号
- **自增列**: 达梦用 `IDENTITY(1,1)`MySQL 用 `AUTO_INCREMENT`
- **占位符**: 达梦使用 `?`(同 MySQL
- **LastInsertId**: 达梦不支持 `LAST_INSERT_ID()`,需用 `RETURNING "id"` 子句获取插入 ID(同 PostgreSQL
- **Upsert**: 达梦使用 `MERGE INTO ... USING ... WHEN MATCHED ... WHEN NOT MATCHED ...`Oracle 风格)
- **表存在检查**: `SELECT TABLE_NAME FROM USER_TABLES WHERE TABLE_NAME='xxx'`
- **列信息查询**: `SELECT COLUMN_NAME, DATA_TYPE, COMMENTS FROM USER_COL_COMMENTS ...`
- **时间戳转换**: 无 `FROM_UNIXTIME`,需用 `DATEADD(SECOND, ts, TIMESTAMP '1970-01-01 00:00:00')`
- **INSERT IGNORE**: 不支持,需用 `MERGE INTO` 替代
- **DDL 导出**: 无 `SHOW CREATE TABLE`,使用系统视图组合
## 涉及修改的文件
### 1. 新增达梦 Go 驱动(go module 方式)
使用达梦官方 module 化驱动 `gitee.com/chunanyong/dm`,通过标准 go module 流程引入:
```bash
go get gitee.com/chunanyong/dm
go mod vendor
```
- [go.mod](go.mod) - 自动添加 `gitee.com/chunanyong/dm` 依赖
- [db/db.go](db/db.go) - 添加 `import _ "gitee.com/chunanyong/dm"` 并在 `initDialect()` 添加 `case "dm"`
- `vendor/` 目录通过 `go mod vendor` 自动同步
驱动注册名为 `"dm"`,连接串格式:`dm://user:password@host:port?schema=dbName`
### 2. 新增 DMDialect 方言
在 [db/dialect.go](db/dialect.go) 末尾新增 `DMDialect` 结构体,实现 `Dialect` 接口全部方法:
- `Quote` / `QuoteIdentifier` / `QuoteChar` - 使用双引号
- `Placeholder` - 返回 `?`
- `Placeholders` - 返回逗号分隔的 `?`
- `SupportsLastInsertId` - 返回 `false`(达梦不支持 MySQL 的 `LAST_INSERT_ID()`
- `ReturningClause` - 返回 `RETURNING "id"`(用于 Insert 后获取自增 ID)
- `UpsertSQL` - 生成 `MERGE INTO ... USING (SELECT ? AS col1, ...) src ON (...) WHEN MATCHED THEN UPDATE SET ... WHEN NOT MATCHED THEN INSERT ...`
### 3. 连接配置
- [application.go](application.go) - 新增 `SetDmDB()` 函数,连接串格式: `dm://user:password@host:port?schema=dbName`
- [application.go](application.go) 的 `SetDB()` 中添加 `dbDm := db.GetMap("dm")` 分支
- [var.go](var.go) - 在 `Config``"db"``ConfigNote``"db"` 下新增 `"dm"` 配置项
`var.go``ConfigNote` 新增的达梦配置说明如下:
```go
"dm": Map{
"注释": "达梦数据库配置,除prefix及主从数据库slave项,其他全部必须",
"host": "默认127.0.0.1,必须,数据库ip地址",
"name": "默认SYSDBA,必须,数据库schema名称",
"user": "默认SYSDBA,必须,数据库用户名",
"password": "默认SYSDBA,必须,数据库密码",
"port": "默认5236,必须,数据库端口",
"prefix": "默认空,非必须,数据表前缀",
"slave": Map{
"注释": "从数据库配置,dm里配置slave项即启用主从读写,减少数据库压力",
"host": "默认127.0.0.1,必须,数据库ip地址",
"name": "默认SYSDBA,必须,数据库schema名称",
"user": "默认SYSDBA,必须,数据库用户名",
"password": "默认SYSDBA,必须,数据库密码",
"port": "默认5236,必须,数据库端口",
},
},
```
### 4. Insert 返回 ID 适配(关键兼容点)
当前 [db/crud.go](db/crud.go) 的 `Insert()` 直接调用 `res.LastInsertId()` 获取插入 ID**但达梦不支持 `LAST_INSERT_ID()`**。Dialect 接口已预留了 `SupportsLastInsertId()``ReturningClause()` 两个方法但 Insert() 未使用。
修改 `Insert()` 函数逻辑:
```go
if that.Dialect != nil && !that.Dialect.SupportsLastInsertId() {
// 达梦/PostgreSQL: 用 RETURNING 子句 + QueryRow 获取 ID
returningClause := that.Dialect.ReturningClause("id")
query = strings.TrimSuffix(query, ";") + returningClause + ";"
var insertedId int64
err := that.QueryRow(query, values...).Scan(&insertedId)
// ...
id = insertedId
} else {
// MySQL/SQLite: 继续使用 Exec + LastInsertId
res, err := that.Exec(query, values...)
id1, _ := res.LastInsertId()
id = id1
}
```
同理,`Upsert()` 函数也需在 `switch dbType` 中增加 `case "dm"` 分支,调用新增的 `buildDMUpsert()` 方法生成 `MERGE INTO` 语法。
**RowsAffected 无需额外处理**`Update()``Delete()` 使用的 `res.RowsAffected()` 在达梦中正常工作。
### 5. 缓存层适配
[cache/cache_db.go](cache/cache_db.go) 中需在以下函数增加 `case "dm"` 分支:
- `tableExists()` - 使用 `SELECT TABLE_NAME FROM USER_TABLES WHERE TABLE_NAME='...'`
- `createMainTable()` - 达梦建表 DDL`IDENTITY(1,1)` 代替 `AUTO_INCREMENT`
- `createHistoryTable()` - 同上
- `migrateFromCached()` - 使用 `MERGE INTO` 代替 `INSERT IGNORE`,用 `DATEADD` 代替 `FROM_UNIXTIME`
- `set()` - 使用 `MERGE INTO` 实现 upsert
### 6. 代码生成适配
[code/makecode.go](code/makecode.go) 第 160-165 行,增加 `case "dm"` 分支:
```go
if db.Type == "dm" {
tableInfo = db.Select("USER_TAB_COLUMNS",
"COLUMN_NAME AS name, DATA_TYPE AS type, COMMENTS AS label, NULLABLE AS must, DATA_DEFAULT AS dflt_value",
Map{"TABLE_NAME": v.GetString("name")})
}
```
(注:需要 JOIN `USER_COL_COMMENTS` 获取列注释)
### 7. 备份模块适配
[db/backup.go](db/backup.go) 中:
- `backupDdl()` - `SHOW CREATE TABLE` 替换为通过系统视图(`USER_CONS_COLUMNS` + `USER_TAB_COLUMNS`)拼接 DDL
- `backupSave()` / `backupCol()` - 将反引号替换为通过 Dialect 的 `Quote()` 方法动态生成
### 8. initDialect 注册
[db/db.go](db/db.go) 的 `initDialect()` 添加:
```go
case "dm", "dameng":
that.Dialect = &DMDialect{}
```
## 驱动引入与 vendor 更新流程
采用标准 go module 方式,驱动包为 `gitee.com/chunanyong/dm`(达梦官方维护的 module 化版本):
```bash
# 1. 添加依赖到 go.mod
go get gitee.com/chunanyong/dm
# 2. 同步到 vendor 目录
go mod vendor
```
`go mod vendor` 会自动将 `gitee.com/chunanyong/dm` 及其传递依赖(`github.com/golang/snappy``golang.org/x/text`)下载并复制到 `vendor/` 目录。后续只需提交 `go.mod``go.sum``vendor/` 的变更即可。
### 9. 数据类型映射补充
[db/query.go](db/query.go) 的 `classifyDBType()` 函数(第 388-417 行)需要补充达梦特有类型:
- `NUMBER` -> `dbTypeDecimal`(达梦/Oracle 的通用数值类型)
- `VARCHAR2` -> `dbTypeUnknown`(字符串类型,保持默认即可)
```go
case "DECIMAL", "NUMERIC", "NEWDECIMAL", "NUMBER":
return dbTypeDecimal
```
### 10. 重构 example 目录 + 集成测试框架
**当前问题**[example/main.go](example/main.go) 有 1427 行,所有测试逻辑堆在一个文件。
**重构后目录结构**
```
example/
main.go -- 最小化:只有 Init + Run + 路由引用
config/
config.json -- mysql- (假注释禁用) + dm (启用)
app/
init.go -- Project 路由注册
test.go -- TestCtr: 通用 ORM 测试(数据库无关)
test_test.go -- TestTest: 测试定义
mysql.go -- MysqlCtr: MySQL 特有测试
mysql_test.go -- MysqlTest: MySQL 测试定义
dmdb.go -- DmdbCtr: 达梦特有测试
dmdb_test.go -- DmdbTest: 达梦测试定义
cache.go -- CacheCtr: 缓存测试(共用)
cache_test.go -- CacheTest: 缓存测试定义
app_test.go -- TestMain + TestApi + ProjectTest
```
**拆分原则**
- **TestCtr**(通用 ORM 测试,数据库无关):`init`(根据 `that.Db.Type` 建表)、`crud``condition``chain``join``aggregate``pagination``batch``upsert``transaction`
- **MysqlCtr**MySQL 特有):`tables`SHOW TABLES)、`describe`DESCRIBE)、`rawsql`(反引号原生 SQL
- **DmdbCtr**(达梦特有):`tables`USER_TABLES)、`describe`USER_TAB_COLUMNS)、`rawsql`(双引号原生 SQL
- **CacheCtr**(缓存测试,共用):`all``compat``batch`
**main.go 最小化**
```go
package main
import (
. "code.hoteas.com/golang/hotime"
"code.hoteas.com/golang/hotime/example/app"
)
func main() {
appIns := Init("config/config.json")
appIns.Run(Router{"app": app.Project})
}
```
**config.json 修改**`mysql` 改为 `mysql-` 假注释禁用,新增 `dm`):
```json
{
"db": {
"mysql-": {
"host": "192.168.6.253",
"name": "dgs-cms2601301",
"password": "dasda8454456",
"port": "3306",
"user": "root"
},
"dm": {
"host": "172.31.31.5",
"port": "5236",
"user": "SYSDBA",
"password": "SC_sysdba2026",
"name": "SYSDBA"
}
},
"mode": 2,
"port": "8081"
}
```
**测试框架集成**`app_test.go`),运行 `go test ./example/app/... -v`
## 注意事项
- 达梦默认对标识符大小写敏感(默认转大写),建表和查询时需注意统一使用双引号包裹
- 达梦的 `MERGE INTO` 语法与 Oracle 基本一致,这是替代 MySQL `ON DUPLICATE KEY UPDATE``INSERT IGNORE` 的标准方式
- 达梦默认端口为 5236,默认管理员账户为 SYSDBA
- 达梦的 `NOW()` 函数在 DM8 中可用,`SYSDATE``CURRENT_TIMESTAMP` 也可用
- 达梦支持 `LIMIT` 语法,分页查询无需额外适配
- 达梦 Go 驱动使用 `?` 作为参数占位符,与 MySQL/SQLite 一致
@@ -0,0 +1,53 @@
---
name: 整理推荐写法风格
overview: 将全文散落的"推荐/不推荐"对比块统一清理,文档全程只陈述正确做法,在「用例编写范式」末尾新增「简写支持说明」小节,介绍框架支持的简写行为,并以一句忠告收尾。
todos:
- id: clean-error-case-block
content: 错误用例代码块:去掉 `// 推荐:` 前缀和整个「不推荐」代码段(第 246-256 行)
status: completed
- id: replace-bad-vs-good
content: 「不好的写法 vs 推荐的写法」整节替换为「简写支持说明」,说明框架支持的简写行为+场景,以忠告句收尾(第 412-458 行)
status: completed
- id: clean-apiresponse-labels
content: ApiResponse 去掉「推荐方式」标签,「兼容方式」改为自然描述(第 595-613 行)
status: completed
isProject: false
---
# 整理推荐写法风格
目标文件:[docs/Testing_API测试框架.md](docs/Testing_API测试框架.md)
## 三处改动
### 1. 第 246-256 行 — 错误用例编写规范代码块
当前:代码块里有 `// 推荐:...``// 不推荐:...` 两段
改后:只保留完整写法的代码,去掉 `// 推荐:` 注释前缀和整个"不推荐"代码段。
```go
a.Post("未登录访问", 2, "请先登录")
a.JSON(Map{"quantity": 3}).Post("缺少商品ID", 3, "请选择商品")
a.JSON(Map{"goods_id": int64(1)}).Post("缺少数量", 3, "请填写购买数量")
a.JSON(Map{"goods_id": int64(999)}).Post("商品不存在", 4, "商品不存在")
```
### 2. 第 412-458 行 — 「不好的写法 vs 推荐的写法」整节
当前:三组 bad/good 对比块(省略 msg、只校验 status、硬编码 ID
改后:整节替换为「简写支持说明」,结构如下:
- **标题**`### 简写支持说明`
- **说明框架支持的三种简写**,每种说明行为(不是批评,是告知机制):
- 省略第三参数:`a.Post("请先登录", 2)` — desc 自动作为期望 msg,适合 desc 与 msg 完全一致的场景
- 省略 expect`a.Post("创建成功", 0)` — 仅断言 status=0,不校验 result 内容或数据库状态
- 省略 Verify:适合纯查询、无副作用的接口,只需验证格式即可
- **忠告**(一行):`> 对于涉及数据写入、状态变更、副作用的接口,省略过程校验等于充分不测试。`
### 3. 第 595-613 行 — ApiResponse「推荐方式 / 兼容方式」标签
当前:用「推荐方式」和「兼容方式」两个标签
改后:去掉「推荐方式 —」标签,直接展示 Verify 用法;「兼容方式」改为:`也可以通过返回值在外部断言(适合不需要 DB 校验的简单场景):`
@@ -0,0 +1,41 @@
---
name: 测试框架文档编写
overview: 在 docs/ 目录新增 API 测试框架文档,并在 README.md 文档表格中增加入口链接,保持与现有文档风格一致。
todos:
- id: docs-testing
content: 新增 docs/Testing_API测试框架.md 文档
status: completed
- id: readme-entry
content: README.md 文档表格增加测试框架入口
status: completed
isProject: false
---
# API 测试框架文档
## 改动文件
### 1. 新增 `docs/Testing_API测试框架.md`
参照现有文档风格(如 QUICKSTART.md、HoTimeDB_API参考.md),包含以下章节:
- 概述(事务回滚隔离 + 链式 API + 覆盖率 + Swagger
- 快速开始(3 步接入示例)
- 核心类型(TestProj/TestProjDef/ProjTest/CtrTest/ApiTestDef
- 链式 API 参考(Api/ApiCase/ApiResponse 的所有方法)
- 完整场景对照表(GET/POST/JSON/Form/File/混合/WithSession
- 事务回滚机制说明(testTx + SAVEPOINT 原理)
- 覆盖率报告(PrintCoverage 输出示例)
- Swagger 文档生成(GenerateSwagger 用法)
- 指定运行范围(go test -run 用法)
- 框架改动说明(db 层 3 文件改动概述)
### 2. 修改 [README.md](d:/work/hotimev1.5/README.md)
在文档表格(第 18-27 行)中增加一行:
```markdown
| [API 测试框架](docs/Testing_API测试框架.md) | 接口测试、事务隔离、覆盖率追踪、Swagger 文档生成 |
```
插入位置:在"改进规划"行之前,作为文档表格的倒数第二行。
@@ -0,0 +1,70 @@
---
name: 精简测试框架文档
overview: 将 Testing_API测试框架.md 从 1210 行精简到约 750-800 行,删除与"编写测试用例"无关的冗余内容,同时保留/强化全过程验证示例,防止 AI 写出只验证接口通不通的浅层测试。
todos:
- id: simplify-biz-code
content: 「快速开始 → 第一步:业务代码」改成极简示例(3-5行的伪代码),不再展开完整 OrderCtr,节约约40行
status: completed
- id: delete-framework-changes
content: 删除「框架改动说明」整节(约25行)
status: completed
- id: delete-login-compare
content: 删除「不好的写法 vs 推荐的写法」中的登录完整对比示例(约45行),前面各小节已有对比,不需要再重复一遍
status: completed
- id: trim-swagger-console
content: 缩减「API 调试控制台」,只保留 GenerateSwagger 调用方式和输出目录结构,删除14行功能特性表和侧边栏示意图(约-30行)
status: completed
- id: trim-api-spec-fields
content: 删除「api-spec.json 覆盖率字段说明」中的字段详解大表和徽章规则(约-30行),只保留 JSON 结构示例
status: completed
- id: trim-concurrency
content: 精简「并发保护与缓存隔离」,删除逐操作加锁保护表,只保留结论性描述(约-20行)
status: completed
- id: trim-apiresponse
content: 删除 ApiResponse GetBody vs Obj 的解释性注释块(约-10行)
status: completed
- id: trim-run-range
content: 删除「指定运行范围」末尾重复的子测试层级树(约-10行)
status: completed
- id: trim-binary
content: 精简「二进制与非JSON响应校验」,删除图片下载示例(与xlsx示例高度重复,约-30行)
status: completed
isProject: false
---
# 精简测试框架文档
目标:[docs/Testing_API测试框架.md](docs/Testing_API测试框架.md) 从 1210 行 → 约 750-800 行
## 核心原则调整(与初稿的区别)
- **快速开始必须完整**:第一步「业务代码」不删除,改为极简示意(3-5行伪代码),让读者明白"这里有个接口"即可,不展开完整逻辑
- **全过程验证示例必须保留并强调**:Verify + DB 校验 + 响应值断言的示例是文档核心,AI 容易偷懒只写 `Post("xxx", 0)` 不做任何校验,这种浅层用例要明确标为"不好的写法"
- **其余按实用性裁剪**
## 删除/精简内容(约 -400 行)
- **精简「第一步:业务代码」**(-40 行):OrderCtr 有50行,改成极简伪代码 + 注释说明接口做什么,让后续测试示例有上下文即可
- **删除「框架改动说明」整节**(-25 行):内部实现历史,对写测试无任何指导价值
- **删除「不好的写法 → 登录完整对比」**(-45 行):前面3个对比子节已充分说明问题,第4个只是换了场景重复
- **缩减「API 调试控制台」**(-30 行):只保留 `GenerateSwagger` 调用 + 输出目录结构,删除14行功能表和侧边栏结构图
- **删除「api-spec.json 字段详解表」**-30 行):与写测试无关
- **精简「并发保护与缓存隔离」**(-20 行):删除逐操作加锁表,保留结论
- **删除 ApiResponse GetBody vs Obj 解释块**-10 行)
- **删除「指定运行范围」末尾层级树**(-10 行):bash 示例中注释已足够
- **精简「二进制响应校验」**(-30 行):只保留 xlsx + CSV,删除重复的图片示例
## 保留/强化内容
- 概述能力表格
- **快速开始(完整4步,业务代码改极简)**
- **用例编写范式(全部4节 + 不好vs推荐对比的前3节)** ← 核心,不裁
- 核心类型
- 链式 API 参考(三张表)
- API 速查表
- 事务隔离机制
- 覆盖率报告(保留 PrintCoverage 输出示例)
- 指定运行范围(bash 示例)
- 并发保护与缓存隔离(精简版)
- 二进制响应校验(精简版)
@@ -0,0 +1,221 @@
---
name: 缓存表模式配置
overview: 为 CacheDb 添加 mode 配置项,支持 "new"(默认,只用新表)和 "compatible"(写新读老)两种模式,并更新配置说明。
todos:
- id: add-mode-field
content: 在 CacheDb 结构体中添加 Mode 字段
status: completed
- id: modify-init
content: 修改 initDbTable,根据 Mode 决定是否迁移删除老表
status: completed
dependencies:
- add-mode-field
- id: add-legacy-get
content: 添加 getLegacy 方法读取老表数据(unix时间戳格式)
status: completed
dependencies:
- add-mode-field
- id: modify-get
content: 修改 get 方法,compatible 模式下回退读取老表
status: completed
dependencies:
- add-legacy-get
- id: modify-set
content: 修改 set 方法,compatible 模式下写新表后删除老表同key记录
status: completed
dependencies:
- add-mode-field
- id: modify-delete
content: 修改 delete 方法,compatible 模式下同时删除新表和老表
status: completed
dependencies:
- add-mode-field
- id: update-cache-init
content: 在 cache.go Init 方法中读取 mode 配置
status: completed
dependencies:
- add-mode-field
- id: update-config-note
content: 在 var.go ConfigNote 中添加 mode 配置说明
status: completed
- id: add-cache-test
content: 在 example/main.go 中添加缓存测试路由
status: completed
dependencies:
- modify-get
- modify-set
- modify-delete
- update-cache-init
- id: todo-1769763169689-k7t9twp5t
content: |
QUICKSTART.md 更新:缓存配置部分需要添加 mode 和 history 配置说明
status: pending
---
# 缓存表模式配置实现计划
## 需求概述
在 [`cache/cache_db.go`](cache/cache_db.go) 中实现两种缓存表模式:
- **new**(默认):只使用新的 `hotime_cache` 表,自动迁移老表数据
- **compatible**:写入新表,读取时先查新表再查老表,老数据自然过期消亡
## 实现步骤
### 1. 修改 CacheDb 结构体
在 [`cache/cache_db.go`](cache/cache_db.go) 中添加 `Mode` 字段:
```go
type CacheDb struct {
TimeOut int64
DbSet bool
SessionSet bool
HistorySet bool
Mode string // "new"(默认) 或 "compatible"
Db HoTimeDBInterface
// ...
}
```
### 2. 修改初始化逻辑 initDbTable
- **new 模式**:创建新表、迁移老表数据,**但不删除老表**(删除交给用户手动操作,更安全)
- **compatible 模式**:创建新表,不迁移也不删除老表
两种模式都不自动删除老表,避免自动删除造成数据丢失风险
### 3. 修改 get 方法
- **new 模式**:只从新表读取
- **compatible 模式**:先从新表读取,如果没有再从老表读取
需要新增 `getLegacy` 方法来读取老表数据,该方法需要:
1. 查询老表数据
2. 检查 `endtime`unix 时间戳)是否过期
3. 如果过期:删除该条记录,返回 nil
4. 如果未过期:返回数据
### 4. 修改 set 方法(写新删老)
- **new 模式**:只写新表(老表保留但不再管理)
- **compatible 模式**:写入新表 + 删除老表中相同 key 的记录
这样可以主动加速老数据消亡,而不是等自然过期。
### 5. 修改 delete 方法
- **new 模式**:只删除新表(老表保留但不再管理)
- **compatible 模式**:同时删除新表和老表中的 key
这是必要的,否则删除新表后,下次读取会回退读到老表的数据,造成"删不掉"的问题。
需要新增 `deleteLegacy` 方法处理老表删除逻辑
### 6. 更新缓存初始化
在 [`cache/cache.go`](cache/cache.go) 的 `Init` 方法中读取 `mode` 配置:
```go
that.dbCache = &CacheDb{
// ...
Mode: db.GetString("mode"), // 读取 mode 配置
}
```
### 7. 更新配置说明
在 [`var.go`](var.go) 的 `ConfigNote` 中添加 `mode` 配置说明:
```go
"db": Map{
// ...
"mode": "默认new,非必须,new为只使用新表(自动迁移老数据),compatible为兼容模式(写新表读老表,老数据自然过期)",
}
```
### 8. 编写测试
在 [`example/main.go`](example/main.go) 中添加缓存测试路由,测试覆盖:
**new 模式测试:**
- 基础读写:set/get/delete 正常工作
- 过期测试:设置短过期时间,验证过期后读取返回 nil
- 数据迁移:验证老表数据能正确迁移到新表
- 老表保留:验证迁移后老表仍存在(不自动删除)
**compatible 模式测试:**
- 新表读写:优先从新表读取
- 老表回退:新表没有时从老表读取
- 过期检测:读取老表过期数据时返回 nil 并删除该记录
- 写新删老:写入新表后老表同 key 记录被删除
- 删除双表:删除操作同时删除新表和老表记录
- 通配删除:`key*` 格式删除测试
**边界情况测试:**
- 空值处理:nil 值的 set/get
- 不存在的 key 读取
- 重复 set 同一个 key
- 超时时间参数测试(默认/自定义)
## 架构图
```mermaid
flowchart TD
subgraph Config[配置]
ModeNew["mode: new (默认)"]
ModeCompat["mode: compatible"]
end
subgraph NewMode[new模式]
N1[初始化] --> N2[创建新表]
N2 --> N3{老表存在?}
N3 -->|是| N4[迁移数据到新表]
N4 --> N5[保留老表由人工删除]
N3 -->|否| N6[完成]
N5 --> N6
NR[读取] --> NR1[只查询新表]
NW[写入] --> NW1[只写入新表]
ND[删除] --> ND1[只删除新表记录]
end
subgraph CompatMode[compatible模式]
C1[初始化] --> C2[创建新表]
C2 --> C3[保留老表]
CR[读取] --> CR1[查询新表]
CR1 -->|未找到| CR2[查询老表]
CR2 --> CR3{过期?}
CR3 -->|是| CR4[删除老表记录]
CR4 --> CR5[返回nil]
CR3 -->|否| CR6[返回数据]
CW[写入] --> CW1[写入新表]
CW1 --> CW2[删除老表同key]
CD[删除] --> CD1[删除新表记录]
CD1 --> CD2[删除老表记录]
end
```
## 文件修改列表
| 文件 | 修改内容 |
|------|----------|
| [`cache/cache_db.go`](cache/cache_db.go) | 添加 Mode 字段、修改 initDbTable、添加 getLegacy(含过期检测删除)、修改 get、修改 set(写新删老)、修改 delete |
| [`cache/cache.go`](cache/cache.go) | 读取 mode 配置 |
| [`var.go`](var.go) | ConfigNote 添加 mode 说明 |
| [`example/main.go`](example/main.go) | 添加缓存测试路由 |
| [`example/config/config.json`](example/config/config.json) | 可选:添加 mode 配置示例 |
@@ -0,0 +1,69 @@
---
name: 补充 Note 方法示例
overview: 在 Testing_API测试框架.md 的"第二步:编写测试用例"部分补充 Note 方法的使用示例和说明,同步更新 SKILL.md。
todos:
- id: doc-step2-example
content: 文档:在第二步的完整订单示例中加入 Note 调用示范
status: completed
- id: doc-note-section
content: 文档:在用例编写范式中新增 Note 使用说明(典型场景 + 示例 + 何时必须用)
status: completed
- id: doc-hotimev15-sync
content: 同步修改 hotimev1.5 中的 Testing_API测试框架.md
status: completed
- id: skill-note-guide
content: SKILL.md:补充 Note 使用指引和更多场景示例
status: completed
isProject: false
---
# 补充 Note 方法示例到文档和 Skill
## 现状分析
目前 `Note` 方法在文档中的存在情况:
- [链式 API 参考](d:\work\xbc\docs\Testing_API测试框架.md) 第 474、494 行:仅出现在 API 表格中,一句话描述
- [API 速查表](d:\work\xbc\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`,而这是新手学习的第一个入口。
## 需要修改的内容
### 1. 文档:第二步示例中加入 Note 使用
在 [Testing_API测试框架.md](d:\work\xbc\docs\Testing_API测试框架.md) 第 80-148 行的订单创建示例中,在**正确请求**部分加入 `Note` 调用,展示以下几种典型场景:
- 在正确请求处用 `Note` 说明参数值含义(如 `quantity=3` 是故意的用于校验总价 `29.9*3=89.7`
### 2. 文档:用例编写范式中新增 Note 独立小节
在"用例编写范式"部分(第 219 行之后),在"错误用例"之前或之后,新增一个关于 Note 的说明段落或小节,覆盖以下典型使用场景:
- **参数值参考**:说明某个参数值的来源或含义,如 `Note("status: 0=待付款, 1=已付款, 2=已发货, 3=已完成")`
- **算法说明**:简要描述接口涉及的签名/加密算法,如 `Note("sign = MD5(smsProxyKey+timestamp)")`
- **接口/用例说明**:补充 desc 无法涵盖的上下文,如 `Note("需先调用 /api/cart/add 加入购物车")``Note("此接口支持批量操作,最多100条")`
- 在用例编写范式表格中增加 Note 的定位说明
### 3. 文档:同步更新 hotimev1.5 中的同名文件
[hotimev1.5/docs/Testing_API测试框架.md](d:\work\hotimev1.5\docs\Testing_API测试框架.md) 需要做相同的修改。
### 4. Skill 文件:补充 Note 指引
在 [SKILL.md](C:\Users\92597.cursor\skills\hotime-tdd-testing\SKILL.md) 中:
- 在 Phase 1 的 1.3 小节之后(或 1.2 和 1.3 之间),新增 Note 的使用指引
- 说明何时应该使用 Note(有必要的场景),给出 2-3 个典型示例
- 在 API 速查的"备注"部分补充更多场景示例
## 修改要点
- Note 不改变测试逻辑,只是给调试控制台和 api-spec.json 添加可读说明
- Note 是可选的,但在以下场景**建议必须**使用:
- 参数中有枚举值(如 status、type)需要说明各值含义
- 涉及签名/加密等算法的接口
- 接口有前置依赖或特殊调用顺序
- Note 可以放在链式调用的任意位置(`a.Note(...)``c.Note(...)`
@@ -0,0 +1,145 @@
---
name: 重写API测试框架文档
overview: 重新编撰 Testing_API测试框架.md,以"错误用例先行 → 准备数据 → 正确请求(含响应断言+DB校验)"的完整流程为核心范式,让文档成为可直接照搬的编写指南。
todos:
- id: rewrite-doc
content: 重写 docs/Testing_API测试框架.md:错误用例先行 → 模拟数据准备 → 正确请求(响应断言必须+DB校验) → 逐步讲解各环节 → API速查表 → 其余章节微调
status: completed
isProject: false
---
# 重写 API 测试框架文档
## 用户确认的编写规范
1. **顺序**:先写错误请求用例,再写正确请求用例
2. **错误用例**:三个参数必须填满 `Post("描述", status, "具体错误消息")`,因为错误码是复用的(如 status=3 可能对应多种参数校验错误)
3. **正确请求前**:用 `a.DB()` 模拟插入数据,既准备测试依赖数据,也可作为后续 DB 校验的对照
4. **响应字段断言**:正确响应情况下**必须**断言(至少覆盖重要字段),不能只写 `Post("描述", 0)`
5. **数据库校验**:只要接口改变了数据库状态,都**建议**用 Verify 校验结果
## 标准用例编写流程(范式)
```
错误用例(参数校验、权限校验等)
准备测试数据(a.DB().Insert/Update 模拟前置数据)
正确请求 + 响应结构校验(ExpectResult / 第三参数 Map
响应字段断言(resp.GetBody() 检查关键业务字段)
数据库状态校验(Verify 查库确认写入/更新结果)
```
## 核心范例(贯穿文档的"创建订单"示例)
以下为快速开始中使用的完整范例,展示从业务代码到测试用例的映射:
```go
// order_test.go
var OrderTest = CtrTest{
"create": {Desc: "创建订单", Func: func(a *Api) {
// ======== 第一步:错误用例 ========
// 错误码复用,三个参数都必须填满
a.JSON(Map{"goods_id": int64(1), "quantity": 3, "address": "XX路1号"}).
Post("未登录创建订单", 2, "请先登录")
a.WithSession(Map{"user_id": int64(1)}).
JSON(Map{"quantity": 3, "address": "XX路1号"}).
Post("缺少商品ID", 3, "请选择商品")
a.WithSession(Map{"user_id": int64(1)}).
JSON(Map{"goods_id": int64(1), "address": "XX路1号"}).
Post("缺少数量", 3, "请填写购买数量")
a.WithSession(Map{"user_id": int64(1)}).
JSON(Map{"goods_id": int64(999), "quantity": 3, "address": "XX路1号"}).
Post("商品不存在", 4, "商品不存在")
// ======== 第二步:准备测试数据 ========
// 插入一条商品数据,后面的正确请求会依赖它
goodsId := a.DB().Insert("goods", Map{
"name": "测试商品", "price": 29.9, "stock": 100, "state": 1,
})
// ======== 第三步:正确请求 + 响应断言 + DB校验 ========
resp := a.WithSession(Map{"user_id": int64(1)}).
JSON(Map{"goods_id": goodsId, "quantity": 3, "address": "XX路1号"}).
Verify(func(a *Api) error {
row := a.DB().Get("order", "*", Map{"AND": Map{
"user_id": int64(1), "goods_id": goodsId,
}})
if row == nil {
return fmt.Errorf("order 表未写入订单记录")
}
if row.GetCeilInt64("state") != 0 {
return fmt.Errorf("新订单 state 期望 0, 实际 %d", row.GetCeilInt64("state"))
}
if row.GetCeilFloat64("total_price") != 29.9*3 {
return fmt.Errorf("total_price 期望 %.2f, 实际 %.2f", 29.9*3, row.GetCeilFloat64("total_price"))
}
// 校验库存扣减
goods := a.DB().Get("goods", "stock", Map{"id": goodsId})
if goods.GetCeilInt64("stock") != 97 {
return fmt.Errorf("库存期望 97, 实际 %d", goods.GetCeilInt64("stock"))
}
return nil
}).
Post("正常创建订单", 0, Map{
"id": int64(1), "sn": "sample", "total_price": float64(1.0),
})
// 关键业务字段断言
orderId := resp.GetBody().GetMap("result").GetCeilInt64("id")
if orderId <= 0 {
resp.Fail("返回的订单 ID 必须大于 0")
}
sn := resp.GetBody().GetMap("result").GetString("sn")
if sn == "" {
resp.Fail("返回的订单编号 sn 不能为空")
}
}},
}
```
## 文档结构
### 不变章节(微调措辞)
- 概述(能力表格)
- 核心类型
- 链式 API 参考(在 expect 参数规则部分强调:错误用例建议三参数写满)
- 事务隔离机制
- 覆盖率报告
- API 调试控制台
- 指定运行范围
- 并发保护与缓存隔离
- 框架改动说明
### 重写/新增章节
#### 1. "快速开始" — 使用上述完整范例
- 先展示业务代码(handler),再展示测试代码
- 范例中完整体现五步流程
- 目录结构、注册方式、运行方式保持原有说明
#### 2. "用例编写范式" — 新增章节,替代原"完整场景对照"
按顺序逐步讲解范例中的每个环节:
- **错误用例编写规范**:为什么三参数必须写满(错误码复用)、如何覆盖权限/参数/业务错误
- **测试数据准备**`a.DB().Insert()` 的使用场景和注意事项(事务内操作,自动回滚)
- **正确请求 + 响应结构校验**:第三参数 Map 的类型样本规则、ExpectResult 后置校验
- **响应字段断言**`resp.GetBody()` / `resp.Fail()` 的用法、哪些字段必须断言
- **数据库状态校验**:Verify 的使用时机(只要改了DB就建议校验)、校验主表+关联表+字段值
- 包含"不好的写法 vs 推荐的写法"对比
#### 3. "API 速查表" — 精简版场景对照
保留各种参数组合(JSON/Form/Query/File/混合/Session 等)的快速参考,但每种只一行示例代码,不再是大段散乱的代码块。
## 改动范围
- 仅改动 `docs/Testing_API测试框架.md`,约 750-850 行
- 不涉及 Go 源码
- 不涉及其他文档
+5
View File
@@ -4,3 +4,8 @@
*.exe
/example/config
/.cursor/*.log
*.sql
*.py
# macOS
.DS_Store
+14 -1
View File
@@ -1,4 +1,9 @@
# HoTime
# 小帮菜后端
测试方法
D:\app\go1.23.1\bin\go.exe test ./dd/... -v
# HoTime
**高性能 Go Web 服务框架**
@@ -11,6 +16,9 @@
- **三级缓存** - Memory > Redis > DB,自动穿透与回填
- **Session 管理** - 内置会话管理,支持多种存储后端
- **代码生成** - 根据数据库表自动生成 CRUD 接口
- **API 测试** - 内置接口测试框架,链式 API、事务自动回滚、覆盖率报告、交互式调试控制台
- **优雅停机** - 跨平台(Windows/Linux)支持,停机时自动拒绝新请求并等待在途请求完成,配合 nginx 实现零停机滚动重启
- **结构化日志 / Seq 集成** - zerolog 驱动,异步 channel 队列推送,零阻塞接入 Seq 集中搜索,自动捕获 fmt.Println 等全量输出,支持单机多进程实例区分
- **开箱即用** - 微信支付/公众号/小程序、阿里云、腾讯云等 SDK 内置
## 文档
@@ -24,6 +32,9 @@
| [代码生成器](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) | 待改进项、设计思考、版本迭代规划 |
## 安装
@@ -59,6 +70,8 @@ go get code.hoteas.com/golang/hotime
| 内置缓存 | ✅ 三级缓存 | ❌ | ❌ | ❌ |
| Session | ✅ 内置 | ❌ 需插件 | ❌ 需插件 | ❌ 需插件 |
| 代码生成 | ✅ | ❌ | ❌ | ❌ |
| API 测试框架 | ✅ 内置 | ❌ 需插件 | ❌ 需插件 | ❌ 需插件 |
| 优雅停机 | ✅ 内置跨平台 | ❌ 需自行实现 | ❌ 需自行实现 | ✅ 内置 |
| 微信/支付集成 | ✅ 内置 | ❌ | ❌ | ❌ |
## 适用场景
+278 -87
View File
@@ -1,47 +1,105 @@
package hotime
import (
"bufio"
"context"
"database/sql"
"fmt"
"io/ioutil"
stdlog "log"
"net"
"net/http"
"net/url"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
. "code.hoteas.com/golang/hotime/cache"
"code.hoteas.com/golang/hotime/code"
. "code.hoteas.com/golang/hotime/common"
. "code.hoteas.com/golang/hotime/db"
. "code.hoteas.com/golang/hotime/log"
"database/sql"
"fmt"
"github.com/sirupsen/logrus"
"io/ioutil"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"code.hoteas.com/golang/hotime/log"
mysql "github.com/go-sql-driver/mysql"
)
type Application struct {
MakeCodeRouter map[string]*code.MakeCode
MethodRouter
Router
Error
Log *logrus.Logger
WebConnectLog *logrus.Logger
Log *log.Logger
WebConnectLog *log.Logger
Port string //端口号
TLSPort string //ssl访问端口号
connectListener []func(that *Context) bool //所有的访问监听,true按原计划继续使用,false表示有监听器处理
connectDbFunc func(err ...*Error) (master, slave *sql.DB)
connectDbFunc func() (master, slave *sql.DB)
configPath string
Config Map
Db HoTimeDB
*HoTimeCache
*http.Server
http.Handler
shuttingDown atomic.Bool // 停机标志,置为 true 后新请求立即返回 503
inFlight atomic.Int64 // 当前正在处理的请求数(不含已返回 503 的)
shutdownOnce sync.Once // 保证多路触发(信号/关窗口)只执行一次
DrainTimeout time.Duration // 已停止接收新请求后、等老请求自然结束的最长时长,默认 5s(每 1s 检测在途请求,为 0 则立即进入 Shutdown)
ShutdownTimeout time.Duration // drain 超时后再给老请求的最长容忍时间,默认 10s(到期后强制关闭)
}
func (that *Application) ServeHTTP(w http.ResponseWriter, req *http.Request) {
if that.shuttingDown.Load() {
w.Header().Set("Connection", "close")
http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
return
}
that.inFlight.Add(1)
defer that.inFlight.Add(-1)
that.handler(w, req)
}
// initiateGracefulShutdown 触发优雅停机,通过 sync.Once 保证多路触发只执行一次。
// drain: 已停止接收新请求后、等老请求自然结束的最长时长,每 1s 检测一次在途请求数,
// 为 0 则立即进入 Shutdown,避免无谓等待。
// shutdown: drain 超时后再给老请求的最长容忍时间,到期强制关闭。
func (that *Application) initiateGracefulShutdown(drain, shutdown time.Duration) {
that.shutdownOnce.Do(func() {
that.shuttingDown.Store(true)
that.Log.Infof("优雅停机:已停止接收新请求(后续请求一律返回 503),等待老请求自然结束,最多再等 %v...", drain)
// 每秒检测一次,若无在途请求立即停机,否则最多等满 drain
tick := time.NewTicker(1 * time.Second)
defer tick.Stop()
deadline := time.Now().Add(drain)
for {
n := that.inFlight.Load()
if n == 0 {
that.Log.Infof("已无在途请求,立即停机")
break
}
if time.Now().After(deadline) {
that.Log.Infof("已停止接收新请求,仍有 %d 个老请求未结束,将在最多 %v 后强制结束这些请求", n, shutdown)
break
}
that.Log.Infof("已停止接收新请求,当前仍有 %d 个老请求在执行,继续等待...", n)
<-tick.C
}
ctx, cancel := context.WithTimeout(context.Background(), shutdown)
defer cancel()
if err := that.Server.Shutdown(ctx); err != nil {
that.Log.Errorf("等待超时,强制关闭仍在执行的 %d 个请求: %v", that.inFlight.Load(), err)
} else {
that.Log.Infof("服务已安全关闭")
}
os.Exit(0)
})
}
// Run 启动实例
func (that *Application) Run(router Router) {
//如果没有设置配置自动生成配置
@@ -127,8 +185,11 @@ func (that *Application) Run(router Router) {
//异常处理
defer func() {
if err := recover(); err != nil {
if that.shuttingDown.Load() {
return // 停机过程中的 panic 不触发重启
}
//that.SetError(errors.New(fmt.Sprint(err)), LOG_FMT)
that.Log.Warn(err)
that.Log.Warnf("%v", err)
that.Run(router)
}
@@ -148,8 +209,10 @@ func (that *Application) Run(router Router) {
//启动服务
that.Server.Addr = ":" + that.Port
err := that.Server.ListenAndServe()
that.Log.Error(err)
ch <- 1
if err != nil && err != http.ErrServerClosed {
that.Log.Error().Err(err).Msg("HTTP 服务退出")
ch <- 1
}
}()
}
@@ -162,33 +225,54 @@ func (that *Application) Run(router Router) {
//启动服务
that.Server.Addr = ":" + that.TLSPort
err := that.Server.ListenAndServeTLS(that.Config.GetString("tlsCert"), that.Config.GetString("tlsKey"))
that.Log.Error(err)
ch <- 2
if err != nil && err != http.ErrServerClosed {
that.Log.Error().Err(err).Msg("HTTPS 服务退出")
ch <- 2
}
}()
}
if ObjToCeilInt(that.Port) == 0 && ObjToCeilInt(that.TLSPort) == 0 {
that.Log.Error("没有端口启用")
that.Log.Errorf("没有端口启用")
return
}
// 注册 Windows 关窗口处理(Linux/macOS 上是空实现)
that.registerWindowsCloseHandler()
// 跨平台信号监听:Ctrl+C / SIGTERMkill 命令)/ taskkill /PID
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
<-sigCh
drain := that.DrainTimeout
if drain == 0 {
drain = 5 * time.Second
}
shutdown := that.ShutdownTimeout
if shutdown == 0 {
shutdown = 10 * time.Second
}
that.initiateGracefulShutdown(drain, shutdown)
}()
value := <-ch
that.Log.Error("启动服务失败 : " + ObjToStr(value))
that.Log.Errorf("启动服务失败 : %s", ObjToStr(value))
}
// SetConnectDB 启动实例
func (that *Application) SetConnectDB(connect func(err ...*Error) (master, slave *sql.DB)) {
func (that *Application) SetConnectDB(connect func() (master, slave *sql.DB)) {
that.connectDbFunc = connect
that.Db.SetConnect(that.connectDbFunc, &that.Error)
that.Db.SetConnect(that.connectDbFunc)
}
// SetDefault 默认配置缓存和session实现
func (that *Application) SetDefault(connect func(err ...*Error) (*sql.DB, *sql.DB)) {
func (that *Application) SetDefault(connect func() (*sql.DB, *sql.DB)) {
that.SetConfig()
if connect != nil {
@@ -201,19 +285,15 @@ func (that *Application) SetDefault(connect func(err ...*Error) (*sql.DB, *sql.D
// SetCache 设置配置文件路径全路径或者相对路径
func (that *Application) SetCache() {
cacheIns := HoTimeCache{}
cacheIns.Init(that.Config.GetMap("cache"), HoTimeDBInterface(&that.Db), &that.Error)
cacheIns.Init(that.Config.GetMap("cache"), HoTimeDBInterface(&that.Db), that.Log)
that.HoTimeCache = &cacheIns
//mode生产模式开启的时候才开启数据库缓存,防止调试出问题
if that.Config.GetInt("mode") == 0 {
that.Db.HoTimeCache = &cacheIns
}
that.Db.HoTimeCache = &cacheIns
}
// SetConfig 设置配置文件路径全路径或者相对路径
func (that *Application) SetConfig(configPath ...string) {
that.Log = GetLog("", true)
that.Error = Error{Logger: that.Log}
that.Log = log.NewLogger(1, "", 100)
if len(configPath) != 0 {
that.configPath = configPath[0]
}
@@ -224,55 +304,66 @@ func (that *Application) SetConfig(configPath ...string) {
//加载配置文件
btes, err := ioutil.ReadFile(that.configPath)
that.Config = DeepCopyMap(Config).(Map)
configErr := false
if err == nil {
cmap := Map{}
//文件是否损坏
cmap.JsonToMap(string(btes), &that.Error)
cmap.JsonToMap(string(btes))
for k, v := range cmap {
that.Config[k] = v //程序配置
Config[k] = v //系统配置
that.Config[k] = v
Config[k] = v
}
} else {
that.Log.Error("配置文件不存在,或者配置出错,使用缺省默认配置")
that.Log.Errorf("配置文件不存在,或者配置出错,使用缺省默认配置")
}
if that.Error.GetError() != nil {
fmt.Println(that.Error.GetError().Error())
}
//文件如果损坏则不写入配置防止配置文件数据丢失
if that.Error.GetError() == nil {
//var configByte bytes.Buffer
//判断配置文件是否序列有变化,有则修改配置,无则不变
//fmt.Println(len(btes))
if !configErr {
configStr := that.Config.ToJsonString()
if len(btes) != 0 && configStr == string(btes) {
return
// 配置无变化,跳过写入
} else {
configNoteStr := ConfigNote.ToJsonString()
_ = os.MkdirAll(filepath.Dir(that.configPath), os.ModeDir)
err = ioutil.WriteFile(that.configPath, []byte(configStr), os.ModePerm)
if err != nil {
that.Log.Error().Err(err).Msg("写入配置文件失败")
configErr = true
}
_ = ioutil.WriteFile(filepath.Dir(that.configPath)+"/configNote.json", []byte(configNoteStr), os.ModePerm)
}
//写入配置说明
//var configNoteByte bytes.Buffer
configNoteStr := ConfigNote.ToJsonString()
//_ = json.Indent(&configNoteByte, []byte(ConfigNote.ToJsonString()), "", "\t")
_ = os.MkdirAll(filepath.Dir(that.configPath), os.ModeDir)
err = ioutil.WriteFile(that.configPath, []byte(configStr), os.ModePerm)
if err != nil {
that.Error.SetError(err)
}
_ = ioutil.WriteFile(filepath.Dir(that.configPath)+"/configNote.json", []byte(configNoteStr), os.ModePerm)
}
that.Log = GetLog(that.Config.GetString("logFile"), true)
that.Error = Error{Logger: that.Log}
logLevel := that.Config.GetCeilInt("logLevel")
logFile := that.Config.GetString("logFile")
logHistory := that.Config.GetCeilInt("logHistory")
if logHistory == 0 {
logHistory = 100
}
that.Log = log.NewLogger(logLevel, logFile, logHistory)
that.Db.Log = that.Log
if that.Config.Get("webConnectLogShow") == nil || that.Config.GetBool("webConnectLogShow") {
that.WebConnectLog = GetLog(that.Config.GetString("webConnectLogFile"), false)
that.WebConnectLog = log.NewLoggerNoCaller(1, that.Config.GetString("webConnectLogFile"), 0)
}
// 重定向 os.Stdout 和标准 log 包,捕获 fmt.Println 等绕过 HoTime Logger 的输出
redirectStdout(that.Log)
// 重定向 os.Stderr,捕获 panic 栈、MySQL driver errLog 等写 stderr 的输出
// 注意:Logger 在此之前已创建,ConsoleWriter.Out 已绑定到原始 os.Stderr,无循环风险
redirectStderr(that.Log)
// 接入 Seqinstance = ip:port,同机多进程靠端口区分,跨机器靠 IP 区分
if seqUrl := that.Config.GetString("seqUrl"); seqUrl != "" {
instance := getLocalIP() + ":" + that.Config.GetString("port")
that.Log.SetSeqWriter(seqUrl, that.Config.GetString("seqApiKey"), instance)
if that.WebConnectLog != nil {
that.WebConnectLog.SetSeqWriter(seqUrl, that.Config.GetString("seqApiKey"), instance)
}
that.Log.Infof("Seq 日志推送已启动: url=%s instance=%s", seqUrl, instance)
}
// 把 MySQL 驱动内部 errLog(连接/包错误)接入 HoTime Logger,经 SeqWriter 推送到 Seq
_ = mysql.SetLogger(&mysqlLogAdapter{l: that.Log})
}
// SetConnectListener 连接判断,返回false继续传输至控制层,true则停止传输
@@ -376,9 +467,12 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) {
ipStr = Substr(context.Req.RemoteAddr, 0, strings.Index(context.Req.RemoteAddr, ":"))
}
that.WebConnectLog.Infoln(ipStr, context.Req.Method,
"time cost:", ObjToFloat64(time.Now().UnixNano()-nowUnixTime.UnixNano())/1000000.00, "ms",
"data length:", ObjToFloat64(context.DataSize)/1000.00, "KB", context.HandlerStr)
that.WebConnectLog.Info().
Str("ip", ipStr).
Str("method", context.Req.Method).
Float64("cost_ms", ObjToFloat64(time.Now().UnixNano()-nowUnixTime.UnixNano())/1000000.00).
Float64("size_kb", ObjToFloat64(context.DataSize)/1000.00).
Msg(context.HandlerStr)
}
}()
@@ -411,8 +505,8 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) {
return
}
//url赋值
path := that.Config.GetString("tpt") + tempHandlerStr
//url赋值,静态文件必须使用原始路径(Linux文件系统大小写敏感)
path := that.Config.GetString("tpt") + context.HandlerStr
//判断是否为默认
if path[len(path)-1] == '/' {
@@ -440,7 +534,7 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) {
//设置header
delete(header, "Content-Type")
if that.Config.GetInt("mode") == 0 {
if that.Config.GetCeilInt("logLevel") == 0 {
header.Set("Cache-Control", "public")
} else {
header.Set("Cache-Control", "no-cache")
@@ -448,7 +542,7 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) {
t := strings.LastIndex(path, ".")
if t != -1 {
tt := path[t:]
tt := strings.ToLower(path[t:])
if MimeMaps[tt] != "" {
@@ -553,6 +647,67 @@ func (that *Application) crossDomain(context *Context, sessionId string) {
}
// getLocalIP 通过 UDP dial 探测方式获取本机出口 IP(不发送任何数据,无副作用)。
// 用于构建 Seq instance 字段,格式为 ip:port,支持跨机器集群识别。
// 失败时返回 "unknown"。
func getLocalIP() string {
conn, err := net.Dial("udp", "8.8.8.8:80")
if err != nil {
return "unknown"
}
defer conn.Close()
return conn.LocalAddr().(*net.UDPAddr).IP.String()
}
// redirectStdout 重定向 os.Stdout 和标准 log 包到 HoTime Logger。
// 捕获 fmt.Println/fmt.Printf 等绕过 HoTime 日志系统的输出,
// 以 INFO 级别 + source="stdout" 字段写入,后续经 SeqWriter 推送到 Seq。
func redirectStdout(l *log.Logger) {
r, w, err := os.Pipe()
if err != nil {
return
}
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)
}
}()
}
// 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)
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)
}
}()
}
// Init 初始化application
func Init(config string) *Application {
appIns := Application{}
@@ -579,7 +734,7 @@ func Init(config string) *Application {
codeMake["name"] = codeMake.GetString("table")
}
appIns.MakeCodeRouter[codeMake.GetString("name")] = &code.MakeCode{Error: appIns.Error}
appIns.MakeCodeRouter[codeMake.GetString("name")] = &code.MakeCode{Log: appIns.Log}
appIns.MakeCodeRouter[codeMake.GetString("name")].Db2JSON(&appIns.Db, codeMake)
//接入动态代码层
@@ -626,12 +781,16 @@ func SetDB(appIns *Application) {
db := appIns.Config.GetMap("db")
dbSqlite := db.GetMap("sqlite")
dbMysql := db.GetMap("mysql")
dbDm := db.GetMap("dm")
if db != nil && dbSqlite != nil {
SetSqliteDB(appIns, dbSqlite)
}
if db != nil && dbMysql != nil {
SetMysqlDB(appIns, dbMysql)
}
if db != nil && dbDm != nil {
SetDmDB(appIns, dbDm)
}
}
func SetMysqlDB(appIns *Application, config Map) {
@@ -639,45 +798,66 @@ func SetMysqlDB(appIns *Application, config Map) {
appIns.Db.DBName = config.GetString("name")
appIns.Db.Prefix = config.GetString("prefix")
appIns.Db.Log = appIns.Log
appIns.Db.Mode = appIns.Config.GetCeilInt("mode")
appIns.SetConnectDB(func(err ...*Error) (master, slave *sql.DB) {
//master数据库配置
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"
DB, e := sql.Open("mysql", query)
if e != nil && len(err) != 0 {
err[0].SetError(e)
if e != nil {
appIns.Log.Error().Err(e).Msg("MySQL 主库连接失败")
}
master = DB
//slave数据库配置
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"
DB1, e := sql.Open("mysql", query)
if e != nil && len(err) != 0 {
err[0].SetError(e)
if e != nil {
appIns.Log.Error().Err(e).Msg("MySQL 从库连接失败")
}
slave = DB1
}
return master, slave
//return DB
})
}
func SetSqliteDB(appIns *Application, config Map) {
appIns.Db.Type = "sqlite"
appIns.Db.Prefix = config.GetString("prefix")
appIns.Db.Mode = appIns.Config.GetCeilInt("mode")
appIns.Db.Log = appIns.Log
appIns.SetConnectDB(func(err ...*Error) (master, slave *sql.DB) {
appIns.SetConnectDB(func() (master, slave *sql.DB) {
db, e := sql.Open("sqlite3", config.GetString("path"))
if e != nil && len(err) != 0 {
err[0].SetError(e)
if e != nil {
appIns.Log.Error().Err(e).Msg("SQLite 连接失败")
}
master = db
return master, slave
})
}
func SetDmDB(appIns *Application, config Map) {
appIns.Db.Type = "dm"
appIns.Db.DBName = config.GetString("name")
appIns.Db.Prefix = config.GetString("prefix")
appIns.Db.Log = appIns.Log
appIns.SetConnectDB(func() (master, slave *sql.DB) {
query := "dm://" + config.GetString("user") + ":" + config.GetString("password") +
"@" + config.GetString("host") + ":" + config.GetString("port") + "?schema=" + config.GetString("name")
DB, e := sql.Open("dm", query)
if e != nil {
appIns.Log.Error().Err(e).Msg("达梦主库连接失败")
}
master = DB
configSlave := config.GetMap("slave")
if configSlave != nil {
querySlave := "dm://" + configSlave.GetString("user") + ":" + configSlave.GetString("password") +
"@" + configSlave.GetString("host") + ":" + configSlave.GetString("port") + "?schema=" + configSlave.GetString("name")
DB1, e := sql.Open("dm", querySlave)
if e != nil {
appIns.Log.Error().Err(e).Msg("达梦从库连接失败")
}
slave = DB1
}
return master, slave
})
}
@@ -711,6 +891,9 @@ func setMakeCodeListener(name string, appIns *Application) {
}
}
if context.RouterString[0] != name {
return isFinished
}
if len(context.RouterString) < 2 || len(context.RouterString) > 3 ||
!(context.Router[context.RouterString[0]] != nil &&
context.Router[context.RouterString[0]][context.RouterString[1]] != nil) {
@@ -792,3 +975,11 @@ func setMakeCodeListener(name string, appIns *Application) {
return true
})
}
// mysqlLogAdapter 将 go-sql-driver/mysql 的内部 errLog 桥接到 HoTime Logger。
// MySQL 驱动连接/包级别错误通过此适配器以 error 级别推送到 Seq。
type mysqlLogAdapter struct{ l *log.Logger }
func (a *mysqlLogAdapter) Print(v ...interface{}) {
a.l.Error().Str("source", "mysql-driver").Msg(fmt.Sprint(v...))
}
+223 -34
View File
@@ -1,15 +1,14 @@
package cache
import (
"errors"
. "code.hoteas.com/golang/hotime/common"
"code.hoteas.com/golang/hotime/log"
)
// HoTimeCache 可配置memorydbredis,默认启用memory,默认优先级为memory>redis>db,memory与数据库缓存设置项一致,
// 缓存数据填充会自动反方向反哺,加入memory缓存过期将自动从redis更新,但memory永远不会更新redis,如果是集群建议不要开启memory,配置即启用
type HoTimeCache struct {
*Error
Log *log.Logger
dbCache *CacheDb
redisCache *CacheRedis
memoryCache *CacheMemory
@@ -181,16 +180,215 @@ func (that *HoTimeCache) Cache(key string, data ...interface{}) *Obj {
return reData
}
func (that *HoTimeCache) Init(config Map, hotimeDb HoTimeDBInterface, err ...*Error) {
//防止空数据问题
// SessionsGet 批量获取 Session 缓存
// 返回 Mapkey 为缓存键,value 为缓存值
// 优先级:memory > redis > db,低优先级数据会反哺到高优先级缓存
func (that *HoTimeCache) SessionsGet(keys []string) Map {
if len(keys) == 0 {
return Map{}
}
result := make(Map, len(keys))
missingKeys := keys
// 从 memory 获取
if that.memoryCache != nil && that.memoryCache.SessionSet {
memResult := that.memoryCache.CachesGet(keys)
for k, v := range memResult {
result[k] = v
}
// 计算未命中的 keys
missingKeys = make([]string, 0)
for _, k := range keys {
if _, exists := result[k]; !exists {
missingKeys = append(missingKeys, k)
}
}
}
// 从 redis 获取未命中的
if len(missingKeys) > 0 && that.redisCache != nil && that.redisCache.SessionSet {
redisResult := that.redisCache.CachesGet(missingKeys)
// 反哺到 memory
if that.memoryCache != nil && that.memoryCache.SessionSet && len(redisResult) > 0 {
that.memoryCache.CachesSet(redisResult)
}
for k, v := range redisResult {
result[k] = v
}
// 更新未命中的 keys
newMissing := make([]string, 0)
for _, k := range missingKeys {
if _, exists := result[k]; !exists {
newMissing = append(newMissing, k)
}
}
missingKeys = newMissing
}
// 从 db 获取未命中的
if len(missingKeys) > 0 && that.dbCache != nil && that.dbCache.SessionSet {
dbResult := that.dbCache.CachesGet(missingKeys)
// 反哺到 memory 和 redis
if len(dbResult) > 0 {
if that.memoryCache != nil && that.memoryCache.SessionSet {
that.memoryCache.CachesSet(dbResult)
}
if that.redisCache != nil && that.redisCache.SessionSet {
that.redisCache.CachesSet(dbResult)
}
}
for k, v := range dbResult {
result[k] = v
}
}
return result
}
// SessionsSet 批量设置 Session 缓存
// data: Mapkey 为缓存键,value 为缓存值
func (that *HoTimeCache) SessionsSet(data Map) {
if len(data) == 0 {
return
}
if that.memoryCache != nil && that.memoryCache.SessionSet {
that.memoryCache.CachesSet(data)
}
if that.redisCache != nil && that.redisCache.SessionSet {
that.redisCache.CachesSet(data)
}
if that.dbCache != nil && that.dbCache.SessionSet {
that.dbCache.CachesSet(data)
}
}
// SessionsDelete 批量删除 Session 缓存
func (that *HoTimeCache) SessionsDelete(keys []string) {
if len(keys) == 0 {
return
}
if that.memoryCache != nil && that.memoryCache.SessionSet {
that.memoryCache.CachesDelete(keys)
}
if that.redisCache != nil && that.redisCache.SessionSet {
that.redisCache.CachesDelete(keys)
}
if that.dbCache != nil && that.dbCache.SessionSet {
that.dbCache.CachesDelete(keys)
}
}
// CachesGet 批量获取普通缓存
// 返回 Mapkey 为缓存键,value 为缓存值
// 优先级:memory > redis > db,低优先级数据会反哺到高优先级缓存
func (that *HoTimeCache) CachesGet(keys []string) Map {
if len(keys) == 0 {
return Map{}
}
result := make(Map, len(keys))
missingKeys := keys
// 从 memory 获取
if that.memoryCache != nil {
memResult := that.memoryCache.CachesGet(keys)
for k, v := range memResult {
result[k] = v
}
// 计算未命中的 keys
missingKeys = make([]string, 0)
for _, k := range keys {
if _, exists := result[k]; !exists {
missingKeys = append(missingKeys, k)
}
}
}
// 从 redis 获取未命中的
if len(missingKeys) > 0 && that.redisCache != nil {
redisResult := that.redisCache.CachesGet(missingKeys)
// 反哺到 memory
if that.memoryCache != nil && len(redisResult) > 0 {
that.memoryCache.CachesSet(redisResult)
}
for k, v := range redisResult {
result[k] = v
}
// 更新未命中的 keys
newMissing := make([]string, 0)
for _, k := range missingKeys {
if _, exists := result[k]; !exists {
newMissing = append(newMissing, k)
}
}
missingKeys = newMissing
}
// 从 db 获取未命中的
if len(missingKeys) > 0 && that.dbCache != nil {
dbResult := that.dbCache.CachesGet(missingKeys)
// 反哺到 memory 和 redis
if len(dbResult) > 0 {
if that.memoryCache != nil {
that.memoryCache.CachesSet(dbResult)
}
if that.redisCache != nil {
that.redisCache.CachesSet(dbResult)
}
}
for k, v := range dbResult {
result[k] = v
}
}
return result
}
// CachesSet 批量设置普通缓存
// data: Mapkey 为缓存键,value 为缓存值
func (that *HoTimeCache) CachesSet(data Map) {
if len(data) == 0 {
return
}
if that.memoryCache != nil {
that.memoryCache.CachesSet(data)
}
if that.redisCache != nil {
that.redisCache.CachesSet(data)
}
if that.dbCache != nil {
that.dbCache.CachesSet(data)
}
}
// CachesDelete 批量删除普通缓存
func (that *HoTimeCache) CachesDelete(keys []string) {
if len(keys) == 0 {
return
}
if that.memoryCache != nil {
that.memoryCache.CachesDelete(keys)
}
if that.redisCache != nil {
that.redisCache.CachesDelete(keys)
}
if that.dbCache != nil {
that.dbCache.CachesDelete(keys)
}
}
func (that *HoTimeCache) Init(config Map, hotimeDb HoTimeDBInterface, logger *log.Logger) {
if config == nil {
config = Map{}
}
if err[0] != nil {
that.Error = err[0]
}
that.Log = logger
that.Config = config
//memory配置初始化
memory := that.Config.GetMap("memory")
if memory == nil {
memory = Map{
@@ -199,70 +397,59 @@ func (that *HoTimeCache) Init(config Map, hotimeDb HoTimeDBInterface, err ...*Er
"sort": 0,
"timeout": 60 * 60 * 2,
}
}
if memory.Get("db") == nil {
memory["db"] = true
}
if memory.Get("session") == nil {
memory["session"] = true
}
if memory.Get("timeout") == nil {
memory["timeout"] = 60 * 60 * 2
}
that.Config["memory"] = memory
that.memoryCache = &CacheMemory{TimeOut: memory.GetCeilInt64("timeout"),
DbSet: memory.GetBool("db"), SessionSet: memory.GetBool("session")}
if err[0] != nil {
that.memoryCache.SetError(err[0])
}
DbSet: memory.GetBool("db"), SessionSet: memory.GetBool("session"),
Log: logger}
//db配置初始化
redis := that.Config.GetMap("redis")
if redis != nil {
if redis.GetString("host") == "" || redis.GetString("port") == "" {
if err[0] != nil {
err[0].SetError(errors.New("请检查redis配置host和port配置"))
if logger != nil {
logger.Error().Msg("请检查redis配置host和port配置")
}
return
}
if redis.Get("db") == nil {
redis["db"] = true
}
if redis.Get("session") == nil {
redis["session"] = true
}
if redis.Get("timeout") == nil {
redis["timeout"] = 60 * 60 * 24 * 15
}
that.Config["redis"] = redis
that.redisCache = &CacheRedis{TimeOut: redis.GetCeilInt64("timeout"),
DbSet: redis.GetBool("db"), SessionSet: redis.GetBool("session"), Host: redis.GetString("host"),
Pwd: redis.GetString("password"), Port: redis.GetCeilInt64("port")}
if err[0] != nil {
that.redisCache.SetError(err[0])
}
Pwd: redis.GetString("password"), Port: redis.GetCeilInt64("port"),
Log: logger}
}
//db配置初始化
db := that.Config.GetMap("db")
if db != nil {
if db.Get("db") == nil {
db["db"] = false
}
if db.Get("session") == nil {
db["session"] = true
}
if db.Get("timeout") == nil {
db["timeout"] = 60 * 60 * 24 * 30
}
if db.Get("mode") == nil {
db["mode"] = CacheModeCompatible
}
that.Config["db"] = db
that.dbCache = &CacheDb{
@@ -270,12 +457,14 @@ func (that *HoTimeCache) Init(config Map, hotimeDb HoTimeDBInterface, err ...*Er
DbSet: db.GetBool("db"),
SessionSet: db.GetBool("session"),
HistorySet: db.GetBool("history"),
Mode: db.GetString("mode"),
Db: hotimeDb,
}
if err[0] != nil {
that.dbCache.SetError(err[0])
Log: logger,
}
}
}
func (that *HoTimeCache) DisableDbCache() {
that.dbCache = nil
that.redisCache = nil
}
+291 -144
View File
@@ -3,42 +3,27 @@ package cache
import (
"database/sql"
"encoding/json"
"fmt"
"os"
"strings"
"time"
. "code.hoteas.com/golang/hotime/common"
"code.hoteas.com/golang/hotime/log"
)
// #region agent log
func debugLog(hypothesisId, location, message string, data map[string]interface{}) {
logEntry := fmt.Sprintf(`{"hypothesisId":"%s","location":"%s","message":"%s","data":%s,"timestamp":%d,"sessionId":"debug-session"}`,
hypothesisId, location, message, toJSON(data), time.Now().UnixMilli())
f, _ := os.OpenFile(`d:\work\hotimev1.5\.cursor\debug.log`, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if f != nil {
f.WriteString(logEntry + "\n")
f.Close()
}
}
func toJSON(data map[string]interface{}) string {
b, _ := json.Marshal(data)
return string(b)
}
// #endregion
// 表名常量
const (
CacheTableName = "hotime_cache"
CacheHistoryTableName = "hotime_cache_history"
LegacyCacheTableName = "cached" // 老版本缓存表名
DefaultCacheTimeout = 24 * 60 * 60 // 默认过期时间 24 小时
CacheModeNew = "new" // 新模式:只使用新表
CacheModeCompatible = "compatible" // 兼容模式:写新读老
)
type HoTimeDBInterface interface {
GetPrefix() string
Query(query string, args ...interface{}) []Map
Exec(query string, args ...interface{}) (sql.Result, *Error)
Exec(query string, args ...interface{}) (sql.Result, error)
Get(table string, qu ...interface{}) Map
Select(table string, qu ...interface{}) []Map
Delete(table string, data map[string]interface{}) int64
@@ -51,21 +36,14 @@ type CacheDb struct {
TimeOut int64
DbSet bool
SessionSet bool
HistorySet bool // 是否开启历史记录
HistorySet bool // 是否开启历史记录
Mode string // 缓存模式:"new"(默认,只用新表) 或 "compatible"(写新读老)
Db HoTimeDBInterface
*Error
Log *log.Logger
ContextBase
isInit bool
}
func (that *CacheDb) GetError() *Error {
return that.Error
}
func (that *CacheDb) SetError(err *Error) {
that.Error = err
}
// getTableName 获取带前缀的表名
func (that *CacheDb) getTableName() string {
return that.Db.GetPrefix() + CacheTableName
@@ -76,6 +54,24 @@ func (that *CacheDb) getHistoryTableName() string {
return that.Db.GetPrefix() + CacheHistoryTableName
}
// getLegacyTableName 获取带前缀的老版本缓存表名
func (that *CacheDb) getLegacyTableName() string {
return that.Db.GetPrefix() + LegacyCacheTableName
}
// isCompatibleMode 是否为兼容模式
func (that *CacheDb) isCompatibleMode() bool {
return that.Mode == CacheModeCompatible
}
// getEffectiveMode 获取有效模式(默认为 new)
func (that *CacheDb) getEffectiveMode() string {
if that.Mode == CacheModeCompatible {
return CacheModeCompatible
}
return CacheModeNew
}
// initDbTable 初始化数据库表
func (that *CacheDb) initDbTable() {
if that.isInit {
@@ -85,39 +81,26 @@ func (that *CacheDb) initDbTable() {
dbType := that.Db.GetType()
tableName := that.getTableName()
historyTableName := that.getHistoryTableName()
// #region agent log
debugLog("F", "cache_db.go:initDbTable", "initDbTable started", map[string]interface{}{
"dbType": dbType, "tableName": tableName, "historyTableName": historyTableName, "HistorySet": that.HistorySet,
})
// #endregion
legacyTableName := that.getLegacyTableName()
// 检查并创建主表
if !that.tableExists(tableName) {
that.createMainTable(dbType, tableName)
}
// 检查并迁移旧 cached
oldTableName := that.Db.GetPrefix() + "cached"
if that.tableExists(oldTableName) {
that.migrateFromCached(dbType, oldTableName, tableName)
// 根据模式处理老
// new 模式:迁移老表数据到新表(不删除老表,由人工删除)
// compatible 模式:不迁移,老表继续使用
if that.getEffectiveMode() == CacheModeNew {
if that.tableExists(legacyTableName) {
that.migrateFromCached(dbType, legacyTableName, tableName)
}
}
// compatible 模式不做任何处理,老表保留供读取
// 检查并创建历史表(开启历史记录时)
historyTableExists := that.tableExists(historyTableName)
// #region agent log
debugLog("F", "cache_db.go:initDbTable", "history table check", map[string]interface{}{
"HistorySet": that.HistorySet, "historyTableName": historyTableName, "exists": historyTableExists,
"shouldCreate": that.HistorySet && !historyTableExists,
})
// #endregion
if that.HistorySet && !historyTableExists {
if that.HistorySet && !that.tableExists(historyTableName) {
that.createHistoryTable(dbType, historyTableName)
// #region agent log
debugLog("F", "cache_db.go:initDbTable", "createHistoryTable called", map[string]interface{}{
"dbType": dbType, "historyTableName": historyTableName,
})
// #endregion
}
that.isInit = true
@@ -144,6 +127,15 @@ func (that *CacheDb) tableExists(tableName string) bool {
case "postgres":
res := that.Db.Query(`SELECT tablename FROM pg_tables WHERE schemaname='public' AND tablename='` + tableName + `'`)
return len(res) != 0
case "dm", "dameng":
// USER_TABLES 仅含当前登录用户(如 SYSDBA)拥有的表。
// 当连接 schema(如 ZWBG)与登录用户不同时,ZWBG 的表不会出现在
// USER_TABLES 中,导致 tableExists 误判为"不存在",进而触发重复建表失败。
// 改为直接 SELECT COUNT(*) 探测当前 schema 下的可访问性:
// 表存在则返回 1 行,表不存在则报错返回空结果(nil)。
res := that.Db.Query(`SELECT COUNT(*) as cnt FROM "` + tableName + `"`)
return len(res) > 0
}
return false
@@ -190,7 +182,21 @@ func (that *CacheDb) createMainTable(dbType, tableName string) {
"modify_time" TIMESTAMP
)`
that.Db.Exec(createSQL)
// 创建索引
that.Db.Exec(`CREATE INDEX "idx_` + tableName + `_end_time" ON "` + tableName + `" ("end_time")`)
return
case "dm", "dameng":
createSQL = `CREATE TABLE "` + tableName + `" (
"id" INT IDENTITY(1,1) PRIMARY KEY,
"key" VARCHAR(64) NOT NULL,
"value" TEXT,
"end_time" TIMESTAMP,
"state" INT DEFAULT 0,
"create_time" TIMESTAMP,
"modify_time" TIMESTAMP,
CONSTRAINT "uk_` + tableName + `_key" UNIQUE ("key")
)`
that.Db.Exec(createSQL)
that.Db.Exec(`CREATE INDEX "idx_` + tableName + `_end_time" ON "` + tableName + `" ("end_time")`)
return
}
@@ -241,7 +247,21 @@ func (that *CacheDb) createHistoryTable(dbType, tableName string) {
"modify_time" TIMESTAMP
)`
that.Db.Exec(createSQL)
// 创建索引
that.Db.Exec(`CREATE INDEX "idx_` + tableName + `_cache_id" ON "` + tableName + `" ("hotime_cache_id")`)
return
case "dm", "dameng":
createSQL = `CREATE TABLE "` + tableName + `" (
"id" INT IDENTITY(1,1) PRIMARY KEY,
"hotime_cache_id" INT,
"key" VARCHAR(64),
"value" TEXT,
"end_time" TIMESTAMP,
"state" INT DEFAULT 0,
"create_time" TIMESTAMP,
"modify_time" TIMESTAMP
)`
that.Db.Exec(createSQL)
that.Db.Exec(`CREATE INDEX "idx_` + tableName + `_cache_id" ON "` + tableName + `" ("hotime_cache_id")`)
return
}
@@ -249,14 +269,15 @@ func (that *CacheDb) createHistoryTable(dbType, tableName string) {
that.Db.Exec(createSQL)
}
// migrateFromCached 从旧 cached 表迁移数据
// migrateFromCached 从旧 cached 表迁移数据(不删除老表,由人工删除)
func (that *CacheDb) migrateFromCached(dbType, oldTableName, newTableName string) {
var migrateSQL string
switch dbType {
case "mysql":
// 去重迁移:取每个 key 的最后一条记录(id 最大)
migrateSQL = "INSERT INTO `" + newTableName + "` (`key`, `value`, `end_time`, `state`, `create_time`, `modify_time`) " +
// 使用 INSERT IGNORE 避免重复 key 冲突
migrateSQL = "INSERT IGNORE INTO `" + newTableName + "` (`key`, `value`, `end_time`, `state`, `create_time`, `modify_time`) " +
"SELECT c.`key`, c.`value`, FROM_UNIXTIME(c.`endtime`), 0, " +
"FROM_UNIXTIME(c.`time` / 1000000000), FROM_UNIXTIME(c.`time` / 1000000000) " +
"FROM `" + oldTableName + "` c " +
@@ -264,7 +285,7 @@ func (that *CacheDb) migrateFromCached(dbType, oldTableName, newTableName string
"ON c.id = m.max_id"
case "sqlite":
migrateSQL = `INSERT INTO "` + newTableName + `" ("key", "value", "end_time", "state", "create_time", "modify_time") ` +
migrateSQL = `INSERT OR IGNORE INTO "` + newTableName + `" ("key", "value", "end_time", "state", "create_time", "modify_time") ` +
`SELECT c."key", c."value", datetime(c."endtime", 'unixepoch'), 0, ` +
`datetime(c."time" / 1000000000, 'unixepoch'), datetime(c."time" / 1000000000, 'unixepoch') ` +
`FROM "` + oldTableName + `" c ` +
@@ -277,22 +298,22 @@ func (that *CacheDb) migrateFromCached(dbType, oldTableName, newTableName string
`to_timestamp(c."time" / 1000000000), to_timestamp(c."time" / 1000000000) ` +
`FROM "` + oldTableName + `" c ` +
`INNER JOIN (SELECT "key", MAX(id) as max_id FROM "` + oldTableName + `" GROUP BY "key") m ` +
`ON c.id = m.max_id`
`ON c.id = m.max_id ` +
`ON CONFLICT ("key") DO NOTHING`
case "dm", "dameng":
migrateSQL = `MERGE INTO "` + newTableName + `" t USING (` +
`SELECT c."key", c."value", DATEADD(SECOND, c."endtime", TIMESTAMP '1970-01-01 00:00:00') AS "end_time", ` +
`DATEADD(SECOND, c."time" / 1000000000, TIMESTAMP '1970-01-01 00:00:00') AS "create_time" ` +
`FROM "` + oldTableName + `" c ` +
`INNER JOIN (SELECT "key", MAX("id") AS max_id FROM "` + oldTableName + `" GROUP BY "key") m ` +
`ON c."id" = m.max_id` +
`) src ON (t."key" = src."key") ` +
`WHEN NOT MATCHED THEN INSERT ("key", "value", "end_time", "state", "create_time", "modify_time") ` +
`VALUES (src."key", src."value", src."end_time", 0, src."create_time", src."create_time")`
}
// 执行迁移
_, err := that.Db.Exec(migrateSQL)
if err.GetError() == nil {
// 迁移成功,删除旧表
var dropSQL string
switch dbType {
case "mysql":
dropSQL = "DROP TABLE `" + oldTableName + "`"
case "sqlite", "postgres":
dropSQL = `DROP TABLE "` + oldTableName + `"`
}
that.Db.Exec(dropSQL)
}
that.Db.Exec(migrateSQL)
}
// writeHistory 写入历史记录
@@ -323,14 +344,70 @@ func (that *CacheDb) writeHistory(key string) {
// 插入历史表
that.Db.Insert(historyTableName, historyData)
}
// #region agent log
logFile3, _ := os.OpenFile(`d:\work\hotimev1.5\.cursor\debug.log`, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if logFile3 != nil {
fmt.Fprintf(logFile3, `{"hypothesisId":"C","location":"cache_db.go:writeHistory","message":"history written","data":{"key":"%s","cacheId":%d},"timestamp":%d,"sessionId":"debug-session"}`+"\n", key, cached.GetInt64("id"), time.Now().UnixMilli())
logFile3.Close()
// getLegacy 从老表获取缓存(兼容模式使用)
// 老表结构:key, value, endtime(unix秒), time(纳秒时间戳)
func (that *CacheDb) getLegacy(key string) interface{} {
legacyTableName := that.getLegacyTableName()
// 检查老表是否存在
if !that.tableExists(legacyTableName) {
return nil
}
cached := that.Db.Get(legacyTableName, "*", Map{"key": key})
if cached == nil {
return nil
}
// 检查过期时间(老表使用 unix 时间戳)
endTime := cached.GetInt64("endtime")
nowUnix := time.Now().Unix()
if endTime > 0 && endTime <= nowUnix {
// 已过期,删除该条记录
that.deleteLegacy(key)
return nil
}
// 解析 value(老表 value 格式可能是 {"data": xxx} 或直接值)
valueStr := cached.GetString("value")
if valueStr == "" {
return nil
}
data, err := JsonToObj(valueStr)
if err != nil {
return nil
}
// 兼容老版本 {"data": xxx} 包装格式
if dataMap, ok := data.(map[string]interface{}); ok {
if innerData, exists := dataMap["data"]; exists {
return innerData
}
}
return data
}
// deleteLegacy 从老表删除缓存(兼容模式使用)
func (that *CacheDb) deleteLegacy(key string) {
legacyTableName := that.getLegacyTableName()
// 检查老表是否存在
if !that.tableExists(legacyTableName) {
return
}
del := strings.Index(key, "*")
// 如果通配删除
if del != -1 {
keyPrefix := Substr(key, 0, del)
that.Db.Delete(legacyTableName, Map{"key[~]": keyPrefix + "%"})
} else {
that.Db.Delete(legacyTableName, Map{"key": key})
}
// #endregion
}
// get 获取缓存
@@ -338,49 +415,32 @@ func (that *CacheDb) get(key string) interface{} {
tableName := that.getTableName()
cached := that.Db.Get(tableName, "*", Map{"key": key})
// #region agent log
logFile4, _ := os.OpenFile(`d:\work\hotimev1.5\.cursor\debug.log`, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if logFile4 != nil {
found := cached != nil
fmt.Fprintf(logFile4, `{"hypothesisId":"D","location":"cache_db.go:get","message":"get query","data":{"key":"%s","found":%t},"timestamp":%d,"sessionId":"debug-session"}`+"\n", key, found, time.Now().UnixMilli())
logFile4.Close()
}
// #endregion
if cached == nil {
return nil
if cached != nil {
// 使用字符串比较判断过期(ISO 格式天然支持)
endTime := cached.GetString("end_time")
nowTime := Time2Str(time.Now())
if endTime != "" && endTime <= nowTime {
// 惰性删除:过期只返回 nil,不立即删除
// 依赖随机清理批量删除过期数据
// 继续检查老表(如果是兼容模式)
} else {
// 直接解析 value,不再需要 {"data": value} 包装
valueStr := cached.GetString("value")
if valueStr != "" {
data, err := JsonToObj(valueStr)
if err == nil {
return data
}
}
}
}
// 使用字符串比较判断过期(ISO 格式天然支持)
endTime := cached.GetString("end_time")
nowTime := Time2Str(time.Now())
if endTime != "" && endTime <= nowTime {
// 惰性删除:过期只返回 nil,不立即删除
// 依赖随机清理批量删除过期数据
return nil
// 兼容模式:新表没有数据时,回退读取老表
if that.isCompatibleMode() {
return that.getLegacy(key)
}
// 直接解析 value,不再需要 {"data": value} 包装
valueStr := cached.GetString("value")
if valueStr == "" {
return nil
}
var data interface{}
err := json.Unmarshal([]byte(valueStr), &data)
if err != nil {
return nil
}
// #region agent log
logFile5, _ := os.OpenFile(`d:\work\hotimev1.5\.cursor\debug.log`, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if logFile5 != nil {
fmt.Fprintf(logFile5, `{"hypothesisId":"D","location":"cache_db.go:get","message":"get success","data":{"key":"%s","hasData":true},"timestamp":%d,"sessionId":"debug-session"}`+"\n", key, time.Now().UnixMilli())
logFile5.Close()
}
// #endregion
return data
return nil
}
// set 设置缓存
@@ -414,6 +474,14 @@ func (that *CacheDb) set(key string, value interface{}, endTime time.Time) {
`ON CONFLICT ("key") DO UPDATE SET "value"=EXCLUDED."value", "end_time"=EXCLUDED."end_time", "modify_time"=EXCLUDED."modify_time"`
that.Db.Exec(upsertSQL, key, string(bte), endTimeStr, nowTime, nowTime)
case "dm", "dameng":
upsertSQL := `MERGE INTO "` + tableName + `" t USING (SELECT ? AS "key", ? AS "value", ? AS "end_time", ? AS "create_time", ? AS "modify_time") src ` +
`ON (t."key" = src."key") ` +
`WHEN MATCHED THEN UPDATE SET "value"=src."value", "end_time"=src."end_time", "modify_time"=src."modify_time" ` +
`WHEN NOT MATCHED THEN INSERT ("key", "value", "end_time", "state", "create_time", "modify_time") ` +
`VALUES (src."key", src."value", src."end_time", 0, src."create_time", src."modify_time")`
that.Db.Exec(upsertSQL, key, string(bte), endTimeStr, nowTime, nowTime)
default:
// 兼容其他数据库:使用 Update + Insert
num := that.Db.Update(tableName, Map{
@@ -433,17 +501,14 @@ func (that *CacheDb) set(key string, value interface{}, endTime time.Time) {
}
}
// #region agent log
logFile2, _ := os.OpenFile(`d:\work\hotimev1.5\.cursor\debug.log`, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if logFile2 != nil {
fmt.Fprintf(logFile2, `{"hypothesisId":"B","location":"cache_db.go:set","message":"set completed","data":{"key":"%s","endTime":"%s","dbType":"%s"},"timestamp":%d,"sessionId":"debug-session"}`+"\n", key, endTimeStr, dbType, time.Now().UnixMilli())
logFile2.Close()
}
// #endregion
// 写入历史记录
that.writeHistory(key)
// 兼容模式:写新表后删除老表同 key 记录(加速老数据消亡)
if that.isCompatibleMode() {
that.deleteLegacy(key)
}
// 随机执行删除过期数据命令(5% 概率)
if Rand(1000) > 950 {
nowTimeStr := Time2Str(time.Now())
@@ -457,11 +522,16 @@ func (that *CacheDb) delete(key string) {
del := strings.Index(key, "*")
// 如果通配删除
if del != -1 {
key = Substr(key, 0, del)
that.Db.Delete(tableName, Map{"key[~]": key + "%"})
keyPrefix := Substr(key, 0, del)
that.Db.Delete(tableName, Map{"key[~]": keyPrefix + "%"})
} else {
that.Db.Delete(tableName, Map{"key": key})
}
// 兼容模式:同时删除老表中的 key(避免回退读到老数据)
if that.isCompatibleMode() {
that.deleteLegacy(key)
}
}
// Cache 缓存操作入口
@@ -473,20 +543,6 @@ func (that *CacheDb) delete(key string) {
func (that *CacheDb) Cache(key string, data ...interface{}) *Obj {
that.initDbTable()
// #region agent log
logFile, _ := os.OpenFile(`d:\work\hotimev1.5\.cursor\debug.log`, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if logFile != nil {
op := "get"
if len(data) == 1 && data[0] == nil {
op = "delete"
} else if len(data) >= 1 {
op = "set"
}
fmt.Fprintf(logFile, `{"hypothesisId":"A","location":"cache_db.go:Cache","message":"Cache called","data":{"key":"%s","operation":"%s","dataLen":%d},"timestamp":%d,"sessionId":"debug-session"}`+"\n", key, op, len(data), time.Now().UnixMilli())
logFile.Close()
}
// #endregion
// 获取缓存
if len(data) == 0 {
return &Obj{Data: that.get(key)}
@@ -508,9 +564,9 @@ func (that *CacheDb) Cache(key string, data ...interface{}) *Obj {
}
} else if len(data) >= 2 {
// 使用指定的超时时间
that.SetError(nil)
tempTimeout := ObjToInt64(data[1], that.Error)
if that.GetError() == nil && tempTimeout > 0 {
var err Error
tempTimeout := ObjToInt64(data[1], &err)
if err.GetError() == nil && tempTimeout > 0 {
timeout = tempTimeout
} else {
timeout = that.TimeOut
@@ -524,3 +580,94 @@ func (that *CacheDb) Cache(key string, data ...interface{}) *Obj {
that.set(key, data[0], endTime)
return &Obj{Data: nil}
}
// CachesGet 批量获取缓存(使用 IN 查询优化)
// 返回 Mapkey 为缓存键,value 为缓存值(不存在或过期的 key 不包含在结果中)
func (that *CacheDb) CachesGet(keys []string) Map {
that.initDbTable()
result := make(Map, len(keys))
if len(keys) == 0 {
return result
}
tableName := that.getTableName()
nowTime := Time2Str(time.Now())
// 使用 IN 查询批量获取
cachedList := that.Db.Select(tableName, "*", Map{
"key": keys,
"end_time[>]": nowTime,
})
for _, cached := range cachedList {
valueStr := cached.GetString("value")
if valueStr != "" {
data, err := JsonToObj(valueStr)
if err == nil {
result[cached.GetString("key")] = data
}
}
}
// 兼容模式:新表没有的 key,回退读取老表
if that.isCompatibleMode() {
for _, key := range keys {
if _, exists := result[key]; !exists {
legacyData := that.getLegacy(key)
if legacyData != nil {
result[key] = legacyData
}
}
}
}
return result
}
// CachesSet 批量设置缓存
// data: Mapkey 为缓存键,value 为缓存值
// timeout: 可选过期时间(秒),不传则使用默认超时时间
func (that *CacheDb) CachesSet(data Map, timeout ...int64) {
that.initDbTable()
if len(data) == 0 {
return
}
// 计算过期时间
var tim int64
if len(timeout) > 0 && timeout[0] > 0 {
tim = timeout[0]
} else {
tim = that.TimeOut
if tim == 0 {
tim = DefaultCacheTimeout
}
}
endTime := time.Now().Add(time.Duration(tim) * time.Second)
// 逐个设置(保持事务一致性和历史记录)
for key, value := range data {
that.set(key, value, endTime)
}
}
// CachesDelete 批量删除缓存
func (that *CacheDb) CachesDelete(keys []string) {
that.initDbTable()
if len(keys) == 0 {
return
}
tableName := that.getTableName()
// 使用 IN 条件批量删除
that.Db.Delete(tableName, Map{"key": keys})
// 兼容模式:同时删除老表中的 keys
if that.isCompatibleMode() {
legacyTableName := that.getLegacyTableName()
if that.tableExists(legacyTableName) {
that.Db.Delete(legacyTableName, Map{"key": keys})
}
}
}
+43 -15
View File
@@ -2,6 +2,7 @@ package cache
import (
. "code.hoteas.com/golang/hotime/common"
"code.hoteas.com/golang/hotime/log"
"strings"
"sync"
"time"
@@ -12,24 +13,12 @@ type CacheMemory struct {
TimeOut int64
DbSet bool
SessionSet bool
*Error
cache sync.Map // 替代传统的 Map
}
func (that *CacheMemory) GetError() *Error {
return that.Error
}
func (that *CacheMemory) SetError(err *Error) {
that.Error = err
Log *log.Logger
cache sync.Map
}
func (c *CacheMemory) get(key string) (res *Obj) {
res = &Obj{
Error: *c.Error,
}
res = &Obj{}
value, ok := c.cache.Load(key)
if !ok {
return res // 缓存不存在
@@ -113,3 +102,42 @@ func (c *CacheMemory) Cache(key string, data ...interface{}) *Obj {
c.set(key, data[0], expireAt)
return nil
}
// CachesGet 批量获取缓存
// 返回 Mapkey 为缓存键,value 为缓存值(不存在或过期的 key 不包含在结果中)
func (c *CacheMemory) CachesGet(keys []string) Map {
result := make(Map, len(keys))
for _, key := range keys {
obj := c.get(key)
if obj != nil && obj.Data != nil {
result[key] = obj.Data
}
}
return result
}
// CachesSet 批量设置缓存
// data: Mapkey 为缓存键,value 为缓存值
// timeout: 可选过期时间(秒),不传则使用默认超时时间
func (c *CacheMemory) CachesSet(data Map, timeout ...int64) {
now := time.Now().Unix()
expireAt := now + c.TimeOut
if len(timeout) > 0 && timeout[0] > 0 {
if timeout[0] > now {
expireAt = timeout[0]
} else {
expireAt = now + timeout[0]
}
}
for key, value := range data {
c.set(key, value, expireAt)
}
}
// CachesDelete 批量删除缓存
func (c *CacheMemory) CachesDelete(keys []string) {
for _, key := range keys {
c.delete(key)
}
}
+109 -17
View File
@@ -2,6 +2,7 @@ package cache
import (
. "code.hoteas.com/golang/hotime/common"
"code.hoteas.com/golang/hotime/log"
"github.com/garyburd/redigo/redis"
"strings"
"sync"
@@ -18,18 +19,14 @@ type CacheRedis struct {
pool *redis.Pool
tag int64
ContextBase
*Error
Log *log.Logger
initOnce sync.Once
}
func (that *CacheRedis) GetError() *Error {
return that.Error
}
func (that *CacheRedis) SetError(err *Error) {
that.Error = err
func (that *CacheRedis) logErr(err error) {
if err != nil && that.Log != nil {
that.Log.Error().Err(err).Msg("redis cache error")
}
}
// 唯一标志
@@ -97,7 +94,7 @@ func (that *CacheRedis) del(key string) {
if del != -1 {
val, err := redis.Strings(conn.Do("KEYS", key))
if err != nil {
that.Error.SetError(err)
that.logErr(err)
return
}
if len(val) == 0 {
@@ -109,12 +106,12 @@ func (that *CacheRedis) del(key string) {
}
_, err = conn.Do("EXEC")
if err != nil {
that.Error.SetError(err)
that.logErr(err)
}
} else {
_, err := conn.Do("DEL", key)
if err != nil {
that.Error.SetError(err)
that.logErr(err)
}
}
}
@@ -129,7 +126,7 @@ func (that *CacheRedis) set(key string, value string, expireSeconds int64) {
_, err := conn.Do("SET", key, value, "EX", ObjToStr(expireSeconds))
if err != nil {
that.Error.SetError(err)
that.logErr(err)
}
}
@@ -146,7 +143,7 @@ func (that *CacheRedis) get(key string) *Obj {
if err != nil {
reData.Data = nil
if !strings.Contains(err.Error(), "nil returned") {
that.Error.SetError(err)
that.logErr(err)
}
}
return reData
@@ -175,11 +172,10 @@ func (that *CacheRedis) Cache(key string, data ...interface{}) *Obj {
tim += that.TimeOut
}
if len(data) == 2 {
that.Error.SetError(nil)
tempt := ObjToInt64(data[1], that.Error)
tempt := ObjToInt64(data[1])
if tempt > tim {
tim = tempt
} else if that.GetError() == nil {
} else {
tim = tim + tempt
}
}
@@ -188,3 +184,99 @@ func (that *CacheRedis) Cache(key string, data ...interface{}) *Obj {
return reData
}
// CachesGet 批量获取缓存(使用 Redis MGET 命令优化)
// 返回 Mapkey 为缓存键,value 为缓存值(不存在的 key 不包含在结果中)
func (that *CacheRedis) CachesGet(keys []string) Map {
result := make(Map, len(keys))
if len(keys) == 0 {
return result
}
conn := that.getConn()
if conn == nil {
return result
}
defer conn.Close()
// 构建 MGET 参数
args := make([]interface{}, len(keys))
for i, key := range keys {
args[i] = key
}
values, err := redis.Strings(conn.Do("MGET", args...))
if err != nil {
if !strings.Contains(err.Error(), "nil returned") {
that.logErr(err)
}
return result
}
// 将结果映射回 Map
for i, value := range values {
if value != "" {
result[keys[i]] = value
}
}
return result
}
// CachesSet 批量设置缓存(使用 Redis pipeline 优化)
// data: Mapkey 为缓存键,value 为缓存值
// timeout: 可选过期时间(秒),不传则使用默认超时时间
func (that *CacheRedis) CachesSet(data Map, timeout ...int64) {
if len(data) == 0 {
return
}
conn := that.getConn()
if conn == nil {
return
}
defer conn.Close()
tim := that.TimeOut
if len(timeout) > 0 && timeout[0] > 0 {
if timeout[0] > tim {
tim = timeout[0]
} else {
tim = tim + timeout[0]
}
}
// 使用 pipeline 批量设置
conn.Send("MULTI")
for key, value := range data {
conn.Send("SET", key, ObjToStr(value), "EX", ObjToStr(tim))
}
_, err := conn.Do("EXEC")
if err != nil {
that.logErr(err)
}
}
// CachesDelete 批量删除缓存(使用 Redis DEL 命令批量删除)
func (that *CacheRedis) CachesDelete(keys []string) {
if len(keys) == 0 {
return
}
conn := that.getConn()
if conn == nil {
return
}
defer conn.Close()
// 构建 DEL 参数
args := make([]interface{}, len(keys))
for i, key := range keys {
args[i] = key
}
_, err := conn.Do("DEL", args...)
if err != nil {
that.logErr(err)
}
}
+3 -5
View File
@@ -5,12 +5,10 @@ import (
)
type CacheIns interface {
//set(key string, value interface{}, time int64)
//get(key string) interface{}
//delete(key string)
GetError() *Error
SetError(err *Error)
Cache(key string, data ...interface{}) *Obj
CachesGet(keys []string) Map
CachesSet(data Map, timeout ...int64)
CachesDelete(keys []string)
}
// 单条缓存数据
+120 -102
View File
@@ -10,9 +10,12 @@ import (
"os"
"sort"
"strings"
"sync"
"time"
)
var adminLoginRateLimiter sync.Map // key: ipvalue: *[]int641分钟内登录时间戳列表)
// Project 管理端项目
var TptProject = Proj{
//"user": UserCtr,
@@ -751,105 +754,100 @@ var TptProject = Proj{
//if pageSize <= 0 {
// pageSize = 20
//}
redData := Map{}
redData := Map{}
data["count"] = that.Db.Count(tableName, where)
testQu := []string{}
testQuData := that.MakeCodeRouter[hotimeName].TableColumns[tableName]
for key, _ := range testQuData {
//fmt.Println(key, ":", value)
testQu = append(testQu, key)
data["count"] = that.Db.Count(tableName, where)
testQu := []string{}
testQuData := that.MakeCodeRouter[hotimeName].TableColumns[tableName]
for key := range testQuData {
testQu = append(testQu, key)
}
sort.Strings(testQu)
// 第一步:扫描所有字段,构建聚合表达式列表和元数据
type selectOptMeta struct {
ops Slice
vals []string
}
caseParts := []string{}
selectMetas := map[string]*selectOptMeta{}
sumFieldKeys := []string{}
hasAreaId := false
for _, k := range testQu {
v := testQuData[k]
if v.GetBool("notUse") { continue }
if strings.Contains(k, "state") { continue }
if strings.Contains(k, "scope") { continue }
if strings.Contains(k, "_code") { continue }
switch v.GetString("type") {
case "select":
ops := v.GetSlice("options")
sm := &selectOptMeta{ops: ops}
for k1 := range ops {
v1 := ops.GetMap(k1)
val := v1.GetString("value")
sm.vals = append(sm.vals, val)
safeVal := strings.ReplaceAll(val, "'", "''")
alias := fmt.Sprintf("__sel__%s__%d", k, k1)
caseParts = append(caseParts,
fmt.Sprintf("SUM(CASE WHEN `%s`='%s' THEN 1 ELSE 0 END) AS `%s`", k, safeVal, alias))
}
selectMetas[k] = sm
case "number":
if strings.Contains(k, "area_id") { hasAreaId = true; continue }
if strings.Contains(k, "_id") { continue }
if k == "id" { continue }
if strings.Contains(k, "percent") { continue }
if strings.Contains(k, "exp_") { continue }
if strings.Contains(k, "side") { continue }
if strings.Contains(k, "lat") { continue }
if strings.Contains(k, "lng") { continue }
if strings.Contains(k, "distance") { continue }
alias := "__sum__" + k
caseParts = append(caseParts, fmt.Sprintf("SUM(`%s`) AS `%s`", k, alias))
sumFieldKeys = append(sumFieldKeys, k)
}
sort.Strings(testQu)
for _, k := range testQu {
v := testQuData[k]
//不可使用,未在前端展示,但在内存中保持有
if v.GetBool("notUse") {
}
continue
// 第二步:单次聚合查询替代原来 N×M+K 次串行查询
if len(caseParts) > 0 {
aggRow := that.Db.Get(tableName, strings.Join(caseParts, ","), where)
if aggRow != nil {
// 回填 select 字段各选项的 count
for k, sm := range selectMetas {
for k1 := range sm.ops {
v1 := sm.ops.GetMap(k1)
alias := fmt.Sprintf("__sel__%s__%d", k, k1)
v1["count"] = aggRow.GetCeilInt64(alias)
sm.ops[k1] = v1
}
redData[k] = sm.ops
}
if strings.Contains(k, "state") {
continue
// 回填 number 字段的 sum
for _, k := range sumFieldKeys {
alias := "__sum__" + k
redData[k] = aggRow[alias]
}
if strings.Contains(k, "scope") {
continue
}
if strings.Contains(k, "_code") {
continue
}
//检索查询
if v.GetString("type") == "select" {
ops := v.GetSlice("options")
for k1, _ := range ops {
v1 := ops.GetMap(k1)
where1 := DeepCopyMap(where).(Map)
if where1["AND"] != nil {
and := where1.GetMap("AND")
and[k] = v1.GetString("value")
} else {
where1[k] = v1.GetString("value")
where1 = Map{"AND": where1}
}
v1["count"] = that.Db.Count(tableName, where1)
ops[k1] = v1
}
redData[k] = ops
}
//检索查询
if v.GetString("type") == "number" {
if strings.Contains(k, "area_id") {
areas := that.Db.Select("area", "id,name", Map{"parent_id": 533301})
for ak, av := range areas {
where1 := DeepCopyMap(where).(Map)
if where1["AND"] != nil {
and := where1.GetMap("AND")
and["area_id"] = av.GetCeilInt64("id")
} else {
where1["area_id"] = av.GetCeilInt64("id")
where1 = Map{"AND": where1}
}
av["count"] = that.Db.Count(tableName, where1)
areas[ak] = av
}
redData["area"] = areas
continue
}
if strings.Contains(k, "_id") {
continue
}
if k == "id" {
continue
}
if strings.Contains(k, "percent") {
continue
}
if strings.Contains(k, "exp_") {
continue
}
if strings.Contains(k, "side") {
continue
}
if strings.Contains(k, "lat") {
continue
}
if strings.Contains(k, "lng") {
continue
}
if strings.Contains(k, "distance") {
continue
}
where1 := DeepCopyMap(where).(Map)
redData[k] = that.Db.Sum(tableName, k, where1)
}
}
}
// 第三步:area 分析用单次 Select + Go 侧统计替代原来逐 area Count 循环
if hasAreaId {
areas := that.Db.Select("area", "id,name", Map{"parent_id": 533301})
areaIdRows := that.Db.Select(tableName, "area_id", where)
areaCountMap := map[int64]int64{}
for _, row := range areaIdRows {
areaCountMap[row.GetCeilInt64("area_id")]++
}
for ak, av := range areas {
av["count"] = areaCountMap[av.GetCeilInt64("id")]
areas[ak] = av
}
redData["area"] = areas
}
that.Display(0, redData)
@@ -964,6 +962,26 @@ var TptProject = Proj{
that.Display(0, re)
},
"login": func(that *Context) {
// 限速:同一 IP 1分钟内最多尝试10次
ip := that.Req.Header.Get("X-Forwarded-For")
if ip == "" {
ip = that.Req.RemoteAddr
}
now := time.Now().Unix()
val, _ := adminLoginRateLimiter.LoadOrStore(ip, &[]int64{})
times := val.(*[]int64)
filtered := (*times)[:0]
for _, t := range *times {
if now-t < 60 {
filtered = append(filtered, t)
}
}
*times = append(filtered, now)
if len(*times) > 10 {
that.Display(5, "登录尝试过于频繁,请稍后再试")
return
}
hotimeName := that.RouterString[0]
fileConfig := that.MakeCodeRouter[hotimeName].FileConfig
@@ -1017,15 +1035,15 @@ var TptProject = Proj{
that.Display(4, "找不到配置文件")
return
}
if that.Session(fileConfig.GetString("table")+"_id").Data == nil {
conf := ObjToMap(string(btes))
delete(conf, "menus")
delete(conf, "tables")
//没有登录只需要返回这些信息
that.Display(0, conf)
return
}
if that.Session(fileConfig.GetString("table")+"_id").Data == nil {
conf := ObjToMap(string(btes))
//未登录只返回前端登录页所需的最少字段,避免泄露表结构等敏感信息
that.Display(0, Map{
"label": conf["label"],
"name": conf["name"],
})
return
}
//可读写配置
conf := ObjToMap(string(btes))
menus := conf.GetSlice("menus")
+18 -4
View File
@@ -42,20 +42,34 @@ var DefaultMenuParentName = "sys"
var ColumnDataType = map[string]string{
//sqlite专有类型
"real": "number",
//mysql数据类型宽泛类型
"real": "number",
"integer": "number",
//mysql数据类型
"int": "number",
"float": "number",
"double": "number",
"decimal": "number",
"integer": "number", //sqlite3
"char": "text",
"text": "text",
"blob": "text",
"date": "time",
"time": "time",
"year": "time",
"geometry": "gis", //不建议使用gis类型,建议使用其他代替
"bit": "number",
"binary": "text",
"geometry": "gis",
//postgresql数据类型
"numeric": "number",
"boolean": "number",
"serial": "number",
"json": "text",
"uuid": "text",
"bytea": "text",
"interval": "text",
"money": "number",
//达梦(DM)/Oracle数据类型
"clob": "text",
"number": "number",
}
type ColumnShow struct {
+76 -30
View File
@@ -3,7 +3,7 @@ package code
import (
. "code.hoteas.com/golang/hotime/common"
"code.hoteas.com/golang/hotime/db"
"errors"
"code.hoteas.com/golang/hotime/log"
"fmt"
"io/ioutil"
"net/http"
@@ -22,7 +22,7 @@ type MakeCode struct {
SearchColumns map[string]map[string]Map
Config Map
RuleConfig []Map
Error
Log *log.Logger
}
func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
@@ -49,7 +49,7 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
if errRule == nil {
//cmap := Map{}
//文件是否损坏
ruleLis := ObjToSlice(string(btesRule), &that.Error)
ruleLis := ObjToSlice(string(btesRule))
//cmap.JSON()
for k, _ := range ruleLis {
that.RuleConfig = append(that.RuleConfig, ruleLis.GetMap(k))
@@ -62,11 +62,11 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
_ = os.MkdirAll(filepath.Dir(config.GetString("rule")), os.ModeDir)
err = ioutil.WriteFile(config.GetString("rule"), []byte(ObjToStr(that.RuleConfig)), os.ModePerm)
if err != nil {
that.Error.SetError(err)
that.Log.Error().Err(err).Msg("写入 rule 配置文件失败")
}
}
that.Error.SetError(errors.New("rule配置文件不存在,或者配置出错,使用缺省默认配置"))
that.Log.Warnf("rule配置文件不存在配置出错,使用缺省默认配置")
}
that.IndexMenus = Map{}
@@ -118,6 +118,9 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
if db.Type == "sqlite" {
nowTables = db.Select("sqlite_master", "name", Map{"type": "table"})
}
if db.Type == "dm" || db.Type == "dameng" {
nowTables = db.Query(`SELECT TABLE_NAME AS "name", COMMENTS AS "label" FROM ALL_TAB_COMMENTS WHERE TABLE_TYPE='TABLE' AND OWNER='` + db.DBName + `'`)
}
//idSlice=append(idSlice,nowTables)
for _, v := range nowTables {
// if v.GetString("name") == "cached" {
@@ -127,6 +130,14 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
if v.GetString("label") == "" {
v["label"] = v.GetString("name")
}
if strings.HasSuffix(v.GetString("label"), "表") {
withoutTable := strings.TrimSuffix(v.GetString("label"), "表")
if len([]rune(withoutTable)) <= 3 {
v["label"] = withoutTable + "管理"
} else {
v["label"] = withoutTable
}
}
that.TableConfig[v.GetString("name")] = Map{
"label": v.GetString("label"),
"table": v.GetString("name"),
@@ -163,6 +174,9 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
if db.Type == "sqlite" {
tableInfo = db.Query("pragma table_info([" + v.GetString("name") + "]);")
}
if db.Type == "dm" || db.Type == "dameng" {
tableInfo = db.Query(`SELECT c.COLUMN_NAME AS "name", c.DATA_TYPE AS "type", m.COMMENTS AS "label", c.NULLABLE AS "must", c.DATA_DEFAULT AS "dflt_value" FROM ALL_TAB_COLUMNS c LEFT JOIN USER_COL_COMMENTS m ON c.TABLE_NAME=m.TABLE_NAME AND c.COLUMN_NAME=m.COLUMN_NAME WHERE c.TABLE_NAME='` + v.GetString("name") + `' AND c.OWNER='` + db.DBName + `' ORDER BY c.COLUMN_ID`)
}
idSlice = append(idSlice, tableInfo)
for _, info1 := range tableInfo {
@@ -175,7 +189,7 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
if coloum == nil {
//根据类型判断真实类型
for k, v1 := range ColumnDataType {
if strings.Contains(info.GetString("type"), k) {
if strings.Contains(strings.ToLower(info.GetString("type")), k) {
info["type"] = v1
break
}
@@ -201,22 +215,26 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
coloum["must"] = true
}
//备注以空格隔开,空格后的是其他备注
indexNum := strings.Index(info.GetString("label"), " ")
if indexNum > -1 {
coloum["label"] = info.GetString("label")[:indexNum]
//提取括号内的提示信息:支持 ()、()、{}
rawLabel := info.GetString("label")
for _, pair := range [][2]string{{"(", ")"}, {"", ""}, {"{", "}"}} {
start := strings.Index(rawLabel, pair[0])
end := strings.Index(rawLabel, pair[1])
if start != -1 && end > start {
coloum["ps"] = rawLabel[start+len(pair[0]) : end]
rawLabel = rawLabel[:start] + rawLabel[end+len(pair[1]):]
break
}
}
psStart := strings.Index(info.GetString("label"), "{")
psEnd := strings.Index(info.GetString("label"), "}")
if psStart != -1 && psEnd > psStart {
coloum["ps"] = info.GetString("label")[psStart+1 : psEnd]
//空格截断,空格后的内容丢弃
if idx := strings.Index(rawLabel, " "); idx > -1 {
rawLabel = rawLabel[:idx]
}
//去除数据信息,是用:号分割的
indexNum = strings.Index(coloum.GetString("label"), ":")
if indexNum > -1 {
coloum["label"] = coloum.GetString("label")[:indexNum]
//冒号截断,去除选项数据部分
if idx := strings.Index(rawLabel, ":"); idx > -1 {
rawLabel = rawLabel[:idx]
}
coloum["label"] = rawLabel
for _, ColumnName := range that.RuleConfig {
if (ColumnName.GetBool("strict") && coloum.GetString("name") == ColumnName.GetString("name")) ||
@@ -305,6 +323,25 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
}
//}
} else {
//对已有配置列也提取 ps 并清理 label
rawLabel := coloum.GetString("label")
for _, pair := range [][2]string{{"(", ")"}, {"", ""}, {"{", "}"}} {
start := strings.Index(rawLabel, pair[0])
end := strings.Index(rawLabel, pair[1])
if start != -1 && end > start {
coloum["ps"] = rawLabel[start+len(pair[0]) : end]
rawLabel = rawLabel[:start] + rawLabel[end+len(pair[1]):]
break
}
}
if idx := strings.Index(rawLabel, " "); idx > -1 {
rawLabel = rawLabel[:idx]
}
if idx := strings.Index(rawLabel, ":"); idx > -1 {
rawLabel = rawLabel[:idx]
}
coloum["label"] = rawLabel
}
//暂时不关闭参数,保证表数据完全读取到
@@ -323,7 +360,7 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
_ = os.MkdirAll(config.GetString("name"), os.ModeDir)
err = ioutil.WriteFile(config.GetString("name")+"/"+v.GetString("name")+".go", []byte(myCtr), os.ModePerm)
if err != nil {
that.Error.SetError(err)
that.Log.Error().Err(err).Msg("写入代码文件失败")
}
isMake = true
}
@@ -619,7 +656,7 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
_ = os.MkdirAll(config.GetString("name"), os.ModeDir)
err = ioutil.WriteFile(config.GetString("name")+"/init.go", []byte(myInit), os.ModePerm)
if err != nil {
that.Error.SetError(err)
that.Log.Error().Err(err).Msg("写入 init.go 失败")
}
}
@@ -630,9 +667,9 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
_ = os.MkdirAll(filepath.Dir(config.GetString("configDB")), os.ModeDir)
err = ioutil.WriteFile(config.GetString("configDB"), []byte(that.Config.ToJsonString()), os.ModePerm)
if err != nil {
that.Error.SetError(err)
that.Log.Error().Err(err).Msg("写入 configDB 文件失败")
}
that.Logger.Warn("新建")
that.Log.Infof("新建 configDB: %s", config.GetString("configDB"))
}
@@ -646,9 +683,9 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
delete(newConfig, "tables")
err = ioutil.WriteFile(config.GetString("config"), []byte(newConfig.ToJsonString()), os.ModePerm)
if err != nil {
that.Error.SetError(err)
that.Log.Error().Err(err).Msg("写入 config 文件失败")
}
that.Logger.Warn("新建")
that.Log.Infof("新建 config: %s", config.GetString("config"))
}
//fmt.Println("有新的代码生成,请重新运行")
@@ -797,7 +834,7 @@ func (that *MakeCode) Add(table string, user Map, req *http.Request) Map {
data[v.GetString("name")] = time.Now().Unix()
}
if v.GetString("type") == "time" {
data[v.GetString("name")] = time.Now().Format("2006-01-02 15-04-05")
data[v.GetString("name")] = time.Now().Format("2006-01-02 15:04:05")
}
continue
}
@@ -807,7 +844,7 @@ func (that *MakeCode) Add(table string, user Map, req *http.Request) Map {
data[v.GetString("name")] = time.Now().Unix()
}
if v.GetString("type") == "time" {
data[v.GetString("name")] = time.Now().Format("2006-01-02 15-04-05")
data[v.GetString("name")] = time.Now().Format("2006-01-02 15:04:05")
}
}
}
@@ -856,7 +893,7 @@ func (that *MakeCode) Edit(table string, req *http.Request) Map {
data[v.GetString("name")] = time.Now().Unix()
}
if v.GetString("type") == "time" {
data[v.GetString("name")] = time.Now().Format("2006-01-02 15-04-05")
data[v.GetString("name")] = time.Now().Format("2006-01-02 15:04:05")
}
}
}
@@ -1172,7 +1209,14 @@ func (that *MakeCode) Search(table string, userData Map, data Map, req *http.Req
}
if len(reqValue) != 0 && searchItem.GetString("name") == "sort" && reqValue[0] != "" {
sortMap["ORDER"] = reqValue[0]
parts := strings.Fields(reqValue[0])
if len(parts) == 2 {
col := parts[0]
dir := strings.ToUpper(parts[1])
if (dir == "ASC" || dir == "DESC") && that.TableColumns[table][col] != nil {
sortMap["ORDER"] = col + " " + dir
}
}
}
continue
@@ -1263,7 +1307,9 @@ func (that *MakeCode) Search(table string, userData Map, data Map, req *http.Req
}
if len(reqValue) != 0 && reqValue[0] != "" {
data[searchItemName] = reqValue
if that.TableColumns[table][searchItemName] != nil {
data[searchItemName] = reqValue
}
}
}
+37 -7
View File
@@ -1,29 +1,59 @@
package common
import (
"github.com/sirupsen/logrus"
"code.hoteas.com/golang/hotime/log"
"sync"
)
// Error 框架层处理错误
// Error 框架基础错误类型 —— 线程安全的 error 封装。
// 通过组合(嵌入)方式在 Obj、DBError 等结构体中使用,提供统一的错误 API。
// 也用于 ObjToXxx / Map.GetXxx 等工具函数的可选错误报告参数。
// 设置 Logger 后,SetError 会自动将非 nil 错误写入日志。
type Error struct {
Logger *logrus.Logger
error
mu sync.RWMutex
mu sync.RWMutex
Logger *log.Logger
}
// Error 安全实现 error 接口 —— 内部 error 为 nil 时返回 "" 而非 panic
func (that *Error) Error() string {
that.mu.RLock()
defer that.mu.RUnlock()
if that.error == nil {
return ""
}
return that.error.Error()
}
// HasError 快捷判断是否存在错误
func (that *Error) HasError() bool {
that.mu.RLock()
defer that.mu.RUnlock()
return that.error != nil
}
// Unwrap 支持 errors.Is / errors.As 标准错误链
func (that *Error) Unwrap() error {
that.mu.RLock()
defer that.mu.RUnlock()
return that.error
}
// GetError 获取内部 errornil 表示无错误)
func (that *Error) GetError() error {
that.mu.RLock()
defer that.mu.RUnlock()
return that.error
}
// SetError 设置内部 error(传 nil 清除错误)
// 当 Logger 已设置且 err 非 nil 时,自动写入 ERROR 级别日志
func (that *Error) SetError(err error) {
that.mu.Lock()
that.error = err
logger := that.Logger
that.mu.Unlock()
if that.Logger != nil && err != nil {
that.Logger.Warn(err)
if err != nil && logger != nil {
logger.Error().Err(err).Msg("")
}
}
+17 -4
View File
@@ -1,6 +1,7 @@
package common
import (
"bytes"
"encoding/json"
"errors"
"reflect"
@@ -181,9 +182,21 @@ func (that Map) ToJsonString() string {
}
func (that Map) JsonToMap(jsonStr string, err ...*Error) {
e := json.Unmarshal([]byte(jsonStr), &that)
if e != nil && len(err) != 0 {
err[0].SetError(e)
dec := json.NewDecoder(bytes.NewReader([]byte(jsonStr)))
dec.UseNumber()
e := dec.Decode(&that)
if e != nil {
if len(err) != 0 {
err[0].SetError(e)
}
return
}
for k, v := range that {
that[k] = fixJsonNumbers(v)
}
}
// GetRoundFloat64 获取四舍五入到 precision 位小数的 float64
func (that Map) GetRoundFloat64(key string, precision int, err ...*Error) float64 {
return ObjToRoundFloat64((that)[key], precision, err...)
}
+5
View File
@@ -101,3 +101,8 @@ func (that *Obj) ToCeilInt(err ...*Error) int {
return v
}
// ToRoundFloat64 获取四舍五入到 precision 位小数的 float64
func (that *Obj) ToRoundFloat64(precision int, err ...*Error) float64 {
return ObjToRoundFloat64(that.Data, precision, err...)
}
+245 -119
View File
@@ -1,6 +1,7 @@
package common
import (
"bytes"
"encoding/json"
"errors"
"math"
@@ -18,31 +19,40 @@ func ObjToMap(obj interface{}, e ...*Error) Map {
v = nil
err = errors.New("没有合适的转换对象!")
} else {
switch obj.(type) {
switch val := obj.(type) {
case Map:
v = obj.(Map)
v = val
case map[string]interface{}:
v = obj.(map[string]interface{})
v = Map(val)
case string:
v = Map{}
e := json.Unmarshal([]byte(obj.(string)), &v)
if e != nil {
err = errors.New("没有合适的转换对象!" + e.Error())
result, e2 := JsonToObj(val)
if e2 != nil {
err = errors.New("没有合适的转换对象!" + e2.Error())
v = nil
} else if m, ok := result.(map[string]interface{}); ok {
v = m
} else {
err = errors.New("没有合适的转换对象!")
v = nil
}
default:
data, err := json.Marshal(obj)
var data []byte
data, err = json.Marshal(obj)
if err != nil {
err = errors.New("没有合适的转换对象!" + err.Error())
v = nil
break
}
v = Map{}
e := json.Unmarshal(data, &v)
if e != nil {
err = errors.New("没有合适的转换对象!" + e.Error())
result, e2 := JsonToObj(string(data))
if e2 != nil {
err = errors.New("没有合适的转换对象!" + e2.Error())
v = nil
} else if m, ok := result.(map[string]interface{}); ok {
v = m
} else {
err = errors.New("没有合适的转换对象!")
v = nil
}
}
}
if len(e) != 0 {
@@ -53,7 +63,7 @@ func ObjToMap(obj interface{}, e ...*Error) Map {
func ObjToMapArray(obj interface{}, e ...*Error) []Map {
s := ObjToSlice(obj, e...)
res := []Map{}
res := make([]Map, 0, len(s))
for i := 0; i < len(s); i++ {
res = append(res, s.GetMap(i))
}
@@ -69,25 +79,39 @@ func ObjToSlice(obj interface{}, e ...*Error) Slice {
v = nil
err = errors.New("没有合适的转换对象!")
} else {
switch obj.(type) {
switch val := obj.(type) {
case Slice:
v = obj.(Slice)
v = val
case []interface{}:
v = obj.([]interface{})
v = Slice(val)
case []string:
v = Slice{}
for i := 0; i < len(obj.([]string)); i++ {
v = append(v, obj.([]string)[i])
v = make(Slice, len(val))
for i, s := range val {
v[i] = s
}
case string:
v = Slice{}
err = json.Unmarshal([]byte(obj.(string)), &v)
result, e2 := JsonToObj(val)
if e2 != nil {
err = e2
} else if s, ok := result.([]interface{}); ok {
v = s
} else {
err = errors.New("没有合适的转换对象!")
}
default:
v = Slice{}
var data []byte
data, err = json.Marshal(obj)
err = json.Unmarshal(data, &v)
if err != nil {
break
}
result, e2 := JsonToObj(string(data))
if e2 != nil {
err = e2
} else if s, ok := result.([]interface{}); ok {
v = s
} else {
err = errors.New("没有合适的转换对象!")
}
}
}
@@ -104,22 +128,24 @@ func ObjToTime(obj interface{}, e ...*Error) *time.Time {
//字符串类型,只支持标准mysql datetime格式
if tInt == 0 {
tStr := ObjToStr(obj)
timeNewStr := ""
timeNewStrs := strings.Split(tStr, "-")
for _, v := range timeNewStrs {
if v == "" {
continue
}
if len(v) == 1 {
v = "0" + v
}
if timeNewStr == "" {
timeNewStr = v
continue
}
timeNewStr = timeNewStr + "-" + v
// 先分离日期和时间部分,再对日期各段补零,避免时间中的数字被误处理
datePart := tStr
timePart := ""
if idx := strings.Index(tStr, " "); idx > 0 {
datePart = tStr[:idx]
timePart = tStr[idx:] // 含前导空格
}
tStr = timeNewStr
dateSegs := strings.Split(datePart, "-")
for i, seg := range dateSegs {
if seg == "" {
continue
}
if len(seg) == 1 {
dateSegs[i] = "0" + seg
}
}
tStr = strings.Join(dateSegs, "-") + timePart
if len(tStr) > 18 {
t, e := time.Parse("2006-01-02 15:04:05", tStr)
if e == nil {
@@ -146,31 +172,30 @@ func ObjToTime(obj interface{}, e ...*Error) *time.Time {
return &t
}
}
}
// 缓存字符串,避免重复调用
tIntStr := ObjToStr(tInt)
tIntLen := len(tIntStr)
//纳秒级别
if len(ObjToStr(tInt)) > 16 {
//t := time.Time{}.Add(time.Nanosecond * time.Duration(tInt))
if tIntLen > 16 {
t := time.UnixMicro(tInt / 1000)
return &t
//微秒级别
} else if len(ObjToStr(tInt)) > 13 {
//t := time.Time{}.Add(time.Microsecond * time.Duration(tInt))
} else if tIntLen > 13 {
t := time.UnixMicro(tInt)
return &t
//毫秒级别
} else if len(ObjToStr(tInt)) > 10 {
//t := time.Time{}.Add(time.Millisecond * time.Duration(tInt))
} else if tIntLen > 10 {
t := time.UnixMilli(tInt)
return &t
//秒级别
} else if len(ObjToStr(tInt)) > 9 {
//t := time.Time{}.Add(time.Second * time.Duration(tInt))
} else if tIntLen > 9 {
t := time.Unix(tInt, 0)
return &t
} else if len(ObjToStr(tInt)) > 3 {
t, e := time.Parse("2006", ObjToStr(tInt))
} else if tIntLen > 3 {
t, e := time.Parse("2006", tIntStr)
if e == nil {
return &t
}
@@ -184,37 +209,54 @@ func ObjToFloat64(obj interface{}, e ...*Error) float64 {
v := float64(0)
if obj == nil {
err = errors.New("没有合适的转换对象!")
} else {
switch obj.(type) {
switch val := obj.(type) {
case int:
v = float64(obj.(int))
v = float64(val)
case int8:
v = float64(val)
case int16:
v = float64(val)
case int32:
v = float64(val)
case int64:
v = float64(obj.(int64))
v = float64(val)
case uint:
v = float64(val)
case uint8:
v = float64(val)
case uint16:
v = float64(val)
case uint32:
v = float64(val)
case uint64:
v = float64(val)
case bool:
if val {
v = 1
}
case string:
value, e := strconv.ParseFloat(obj.(string), 64)
if e != nil {
v = float64(0)
err = e
value, e2 := strconv.ParseFloat(val, 64)
if e2 != nil {
err = e2
} else {
v = value
}
case float64:
v = obj.(float64)
v = val
case float32:
v = float64(obj.(float32))
case uint8:
value, e := strconv.ParseFloat(obj.(string), 64)
if e != nil {
v = float64(0)
err = e
// 通过 string 中转避免 float32→float64 精度膨胀(如 float32(1.2) 直接转得 1.200000047...
v, _ = strconv.ParseFloat(strconv.FormatFloat(float64(val), 'f', -1, 32), 64)
case []byte:
// 数据库 driver 有时以 []byte 返回数值字符串
value, e2 := strconv.ParseFloat(string(val), 64)
if e2 != nil {
err = e2
} else {
v = value
}
default:
v = float64(0)
err = errors.New("没有合适的转换对象!")
}
}
@@ -234,25 +276,33 @@ func ObjToFloat64(obj interface{}, e ...*Error) float64 {
return v
}
// ObjToRoundFloat64 将 obj 转为 float64 后四舍五入到 precision 位小数。
// precision < 0 时不做舍入,直接返回原始 float64。
func ObjToRoundFloat64(obj interface{}, precision int, e ...*Error) float64 {
f := ObjToFloat64(obj, e...)
if precision < 0 {
return f
}
pow := math.Pow10(precision)
return math.Round(f*pow) / pow
}
// 向上取整
func ObjToCeilInt64(obj interface{}, e ...*Error) int64 {
f := ObjToCeilFloat64(obj, e...)
return ObjToInt64(math.Ceil(f))
return int64(f)
}
// 向上取整
func ObjToCeilFloat64(obj interface{}, e ...*Error) float64 {
f := ObjToFloat64(obj, e...)
return math.Ceil(f)
}
// 向上取整
func ObjToCeilInt(obj interface{}, e ...*Error) int {
f := ObjToCeilFloat64(obj, e...)
return ObjToInt(f)
}
func ObjToInt64(obj interface{}, e ...*Error) int64 {
@@ -260,36 +310,63 @@ func ObjToInt64(obj interface{}, e ...*Error) int64 {
v := int64(0)
if obj == nil {
err = errors.New("没有合适的转换对象!")
} else {
switch obj.(type) {
switch val := obj.(type) {
case int:
v = int64(obj.(int))
v = int64(val)
case int8:
v = int64(val)
case int16:
v = int64(val)
case int32:
v = int64(val)
case int64:
v = obj.(int64)
v = val
case uint:
v = int64(val)
case uint8:
v = int64(val)
case uint16:
v = int64(val)
case uint32:
v = int64(val)
case uint64:
v = int64(val)
case bool:
if val {
v = 1
}
case string:
value, e := StrToInt(obj.(string))
if e != nil {
v = int64(0)
err = e
value, e2 := StrToInt(val)
if e2 != nil {
// fallback:处理 "1.20" 这类带小数点的整数字符串
if fv, fe := strconv.ParseFloat(val, 64); fe == nil {
v = int64(fv)
} else {
err = e2
}
} else {
v = int64(value)
}
case uint8:
value, e := StrToInt(obj.(string))
if e != nil {
v = int64(0)
err = e
case []byte:
// 数据库 driver 有时以 []byte 返回整数字符串
value, e2 := StrToInt(string(val))
if e2 != nil {
// fallback:处理 "1.20" 这类带小数点的整数字符串
if fv, fe := strconv.ParseFloat(string(val), 64); fe == nil {
v = int64(fv)
} else {
err = e2
}
} else {
v = int64(value)
}
case float64:
v = int64(obj.(float64))
v = int64(val)
case float32:
v = int64(obj.(float32))
v = int64(val)
default:
v = int64(0)
err = errors.New("没有合适的转换对象!")
}
}
@@ -309,18 +386,19 @@ func ObjToBool(obj interface{}, e ...*Error) bool {
v := false
if obj == nil {
err = errors.New("没有合适的转换对象!")
} else {
switch obj.(type) {
switch val := obj.(type) {
case bool:
v = obj.(bool)
v = val
default:
toInt := ObjToInt(obj)
if toInt != 0 {
var intErr Error
toInt := ObjToInt(obj, &intErr)
if intErr.GetError() != nil {
err = errors.New("没有合适的转换对象!")
} else if toInt != 0 {
v = true
}
err = errors.New("没有合适的转换对象!")
}
}
if len(e) != 0 {
@@ -330,55 +408,107 @@ func ObjToBool(obj interface{}, e ...*Error) bool {
}
func ObjToStr(obj interface{}) string {
// fmt.Println(reflect.ValueOf(obj).Type().String() )
str := ""
if obj == nil {
return str
}
switch obj.(type) {
switch val := obj.(type) {
case int:
str = strconv.Itoa(obj.(int))
str = strconv.Itoa(val)
case int8:
str = strconv.Itoa(int(val))
case int16:
str = strconv.Itoa(int(val))
case int32:
str = strconv.Itoa(int(val))
case uint8:
str = obj.(string)
str = strconv.Itoa(int(val))
case uint16:
str = strconv.Itoa(int(val))
case uint32:
str = strconv.FormatUint(uint64(val), 10)
case uint64:
str = strconv.FormatUint(val, 10)
case int64:
str = strconv.FormatInt(obj.(int64), 10)
str = strconv.FormatInt(val, 10)
case []byte:
str = string(obj.([]byte))
str = string(val)
case string:
str = obj.(string)
str = val
case float64:
str = strconv.FormatFloat(obj.(float64), 'f', -1, 64)
str = strconv.FormatFloat(val, 'f', -1, 64)
case float32:
str = strconv.FormatFloat(float64(val), 'f', -1, 32)
case bool:
str = strconv.FormatBool(val)
case time.Time:
// DM8等数据库驱动直接返回time.Time,格式化为MySQL兼容的字符串
str = val.Format("2006-01-02 15:04:05")
default:
strbte, err := json.MarshalIndent(obj, "", "\t")
if err == nil {
str = string(strbte)
}
}
return str
}
// 转换为Map
func StrToMap(string string) Map {
data := Map{}
data.JsonToMap(string)
// JsonToObj 将 JSON 字符串反序列化为 interface{},保留数字原始类型。
// 标准 json.Unmarshal 会把所有 JSON 数字变成 float64,本函数保留整数为 int64、小数为 float64。
//
// JsonToObj(`{"id":2,"price":1.20}`) → map{"id": int64(2), "price": float64(1.2)}
// JsonToObj(`[1, 2.5, "a"]`) → []interface{}{int64(1), float64(2.5), "a"}
func JsonToObj(jsonStr string) (interface{}, error) {
dec := json.NewDecoder(bytes.NewReader([]byte(jsonStr)))
dec.UseNumber()
var result interface{}
if err := dec.Decode(&result); err != nil {
return nil, err
}
return fixJsonNumbers(result), nil
}
// fixJsonNumbers 递归将 json.Number 转为 int64 或 float64
func fixJsonNumbers(v interface{}) interface{} {
switch val := v.(type) {
case json.Number:
if n, err := val.Int64(); err == nil {
return n
}
if f, err := val.Float64(); err == nil {
return f
}
return string(val)
case map[string]interface{}:
for k, item := range val {
val[k] = fixJsonNumbers(item)
}
return val
case []interface{}:
for i, item := range val {
val[i] = fixJsonNumbers(item)
}
return val
default:
return val
}
}
// 转换为Map
func StrToMap(s string) Map {
data := Map{}
data.JsonToMap(s)
return data
}
// 转换为Slice
func StrToSlice(string string) Slice {
data := ObjToSlice(string)
return data
func StrToSlice(s string) Slice {
return ObjToSlice(s)
}
// 字符串数组: a1,a2,a3转["a1","a2","a3"]
func StrArrayToJsonStr(a string) string {
if len(a) > 2 {
if a[0] == ',' {
a = Substr(a, 1, len(a)-1)
@@ -386,7 +516,6 @@ func StrArrayToJsonStr(a string) string {
if a[len(a)-1] == ',' {
a = Substr(a, 0, len(a)-1)
}
//a = strings.Replace(a, ",", `,`, -1)
a = `[` + a + `]`
} else {
a = "[]"
@@ -394,13 +523,11 @@ func StrArrayToJsonStr(a string) string {
return a
}
// 字符串数组: a1,a2,a3转["a1","a2","a3"]
// 字符串数组: ["a1","a2","a3"]转,a1,a2,a3,
func JsonStrToStrArray(a string) string {
//a = strings.Replace(a, `"`, "", -1)
if len(a) != 0 {
a = Substr(a, 1, len(a)-2)
}
return "," + a + ","
}
@@ -408,5 +535,4 @@ func JsonStrToStrArray(a string) string {
func StrToInt(s string) (int, error) {
i, err := strconv.Atoi(s)
return i, err
}
+917
View File
@@ -0,0 +1,917 @@
package common
import (
"math"
"testing"
"time"
)
// ─────────────────────────────────────────────
// 辅助:检查 Error 是否携带错误
// ─────────────────────────────────────────────
func hasErr(e *Error) bool {
return e.GetError() != nil
}
func newErr() *Error {
return &Error{}
}
// ─────────────────────────────────────────────
// ObjToMap
// ─────────────────────────────────────────────
func TestObjToMap(t *testing.T) {
t.Run("nil returns nil with error", func(t *testing.T) {
e := newErr()
v := ObjToMap(nil, e)
if v != nil || !hasErr(e) {
t.Errorf("nil: got %v err=%v", v, e.GetError())
}
})
t.Run("Map passthrough", func(t *testing.T) {
input := Map{"a": 1}
v := ObjToMap(input)
if v == nil || v["a"] != 1 {
t.Errorf("Map passthrough failed: %v", v)
}
})
t.Run("map[string]interface{} cast", func(t *testing.T) {
input := map[string]interface{}{"b": 2}
v := ObjToMap(input)
if v == nil || v["b"] != 2 {
t.Errorf("map[string]interface{} cast failed: %v", v)
}
})
t.Run("valid JSON string - integer preserved as int64", func(t *testing.T) {
v := ObjToMap(`{"x":42}`)
if v == nil || v["x"] != int64(42) {
t.Errorf("JSON string: got %v (type %T), want int64(42)", v["x"], v["x"])
}
})
t.Run("invalid JSON string returns nil with error", func(t *testing.T) {
e := newErr()
v := ObjToMap(`not json`, e)
if v != nil || !hasErr(e) {
t.Errorf("invalid JSON: got %v err=%v", v, e.GetError())
}
})
t.Run("struct default branch marshal+unmarshal", func(t *testing.T) {
type S struct{ Name string }
v := ObjToMap(S{Name: "hello"})
if v == nil || v["Name"] != "hello" {
t.Errorf("struct default: %v", v)
}
})
t.Run("marshal fail (channel) returns nil with error", func(t *testing.T) {
e := newErr()
ch := make(chan int)
v := ObjToMap(ch, e)
if v != nil || !hasErr(e) {
t.Errorf("channel marshal: got %v err=%v", v, e.GetError())
}
})
t.Run("default marshal OK but unmarshal to map fails (int)", func(t *testing.T) {
// json.Marshal(123) = "123", which can't unmarshal to Map
e := newErr()
v := ObjToMap(123, e)
if v != nil || !hasErr(e) {
t.Errorf("int default: got %v err=%v", v, e.GetError())
}
})
}
// ─────────────────────────────────────────────
// ObjToMapArray
// ─────────────────────────────────────────────
func TestObjToMapArray(t *testing.T) {
t.Run("nil returns empty", func(t *testing.T) {
e := newErr()
v := ObjToMapArray(nil, e)
if len(v) != 0 || !hasErr(e) {
t.Errorf("nil: got %v err=%v", v, e.GetError())
}
})
t.Run("JSON array of maps", func(t *testing.T) {
v := ObjToMapArray(`[{"a":1},{"b":2}]`)
if len(v) != 2 {
t.Errorf("expected 2 elements, got %d", len(v))
}
})
}
// ─────────────────────────────────────────────
// ObjToSlice
// ─────────────────────────────────────────────
func TestObjToSlice(t *testing.T) {
t.Run("nil returns nil with error", func(t *testing.T) {
e := newErr()
v := ObjToSlice(nil, e)
if v != nil || !hasErr(e) {
t.Errorf("nil: got %v err=%v", v, e.GetError())
}
})
t.Run("Slice passthrough", func(t *testing.T) {
input := Slice{1, 2, 3}
v := ObjToSlice(input)
if len(v) != 3 {
t.Errorf("Slice passthrough: %v", v)
}
})
t.Run("[]interface{} cast", func(t *testing.T) {
input := []interface{}{4, 5}
v := ObjToSlice(input)
if len(v) != 2 || v[0] != 4 {
t.Errorf("[]interface{} cast: %v", v)
}
})
t.Run("[]string conversion", func(t *testing.T) {
input := []string{"a", "b", "c"}
v := ObjToSlice(input)
if len(v) != 3 || v[0] != "a" {
t.Errorf("[]string: %v", v)
}
})
t.Run("valid JSON array string", func(t *testing.T) {
v := ObjToSlice(`[1,2,3]`)
if len(v) != 3 {
t.Errorf("JSON array: %v", v)
}
})
t.Run("invalid JSON string returns error", func(t *testing.T) {
e := newErr()
v := ObjToSlice(`not json`, e)
if !hasErr(e) {
t.Errorf("invalid JSON: got %v err=%v", v, e.GetError())
}
})
t.Run("default: []int marshals to JSON array", func(t *testing.T) {
v := ObjToSlice([]int{10, 20, 30})
if len(v) != 3 {
t.Errorf("[]int default: %v", v)
}
})
t.Run("default: marshal fail (channel) returns error", func(t *testing.T) {
e := newErr()
ch := make(chan int)
ObjToSlice(ch, e)
if !hasErr(e) {
t.Errorf("channel marshal: expected error")
}
})
}
// ─────────────────────────────────────────────
// ObjToTime
// ─────────────────────────────────────────────
func TestObjToTime(t *testing.T) {
mustParse := func(layout, s string) time.Time {
tt, err := time.Parse(layout, s)
if err != nil {
panic(err)
}
return tt
}
t.Run("full datetime string", func(t *testing.T) {
ts := ObjToTime("2006-01-02 15:04:05")
want := mustParse("2006-01-02 15:04:05", "2006-01-02 15:04:05")
if ts == nil || !ts.Equal(want) {
t.Errorf("full datetime: %v", ts)
}
})
t.Run("single digit day+time (bug fix)", func(t *testing.T) {
ts := ObjToTime("2006-1-2 15:04:05")
want := mustParse("2006-01-02 15:04:05", "2006-01-02 15:04:05")
if ts == nil || !ts.Equal(want) {
t.Errorf("single digit day+time: got %v, want %v", ts, want)
}
})
t.Run("datetime without seconds", func(t *testing.T) {
ts := ObjToTime("2006-01-02 15:04")
want := mustParse("2006-01-02 15:04", "2006-01-02 15:04")
if ts == nil || !ts.Equal(want) {
t.Errorf("datetime without seconds: %v", ts)
}
})
t.Run("date+hour only", func(t *testing.T) {
ts := ObjToTime("2006-01-02 15")
want := mustParse("2006-01-02 15", "2006-01-02 15")
if ts == nil || !ts.Equal(want) {
t.Errorf("date+hour: %v", ts)
}
})
t.Run("date only", func(t *testing.T) {
ts := ObjToTime("2006-01-02")
want := mustParse("2006-01-02", "2006-01-02")
if ts == nil || !ts.Equal(want) {
t.Errorf("date only: %v", ts)
}
})
t.Run("month only", func(t *testing.T) {
ts := ObjToTime("2006-01")
want := mustParse("2006-01", "2006-01")
if ts == nil || !ts.Equal(want) {
t.Errorf("month only: %v", ts)
}
})
t.Run("invalid string returns nil", func(t *testing.T) {
ts := ObjToTime("not-a-date")
if ts != nil {
t.Errorf("invalid string: expected nil, got %v", ts)
}
})
t.Run("unix second timestamp (10 digits)", func(t *testing.T) {
ts := ObjToTime(int64(1700000000)) // 10 digits
if ts == nil {
t.Error("second timestamp: got nil")
}
want := time.Unix(1700000000, 0)
if !ts.Equal(want) {
t.Errorf("second: %v vs %v", ts, want)
}
})
t.Run("unix millisecond timestamp (13 digits)", func(t *testing.T) {
ts := ObjToTime(int64(1700000000000)) // 13 digits
if ts == nil {
t.Error("millisecond timestamp: got nil")
}
want := time.UnixMilli(1700000000000)
if !ts.Equal(want) {
t.Errorf("millisecond: %v vs %v", ts, want)
}
})
t.Run("unix microsecond timestamp (16 digits)", func(t *testing.T) {
ts := ObjToTime(int64(1700000000000000)) // 16 digits
if ts == nil {
t.Error("microsecond timestamp: got nil")
}
want := time.UnixMicro(1700000000000000)
if !ts.Equal(want) {
t.Errorf("microsecond: %v vs %v", ts, want)
}
})
t.Run("nanosecond timestamp (19 digits)", func(t *testing.T) {
ts := ObjToTime(int64(1700000000000000000)) // 19 digits -> nano branch
if ts == nil {
t.Error("nanosecond timestamp: got nil")
}
want := time.UnixMicro(1700000000000000000 / 1000)
if !ts.Equal(want) {
t.Errorf("nanosecond: %v vs %v", ts, want)
}
})
t.Run("year int (4 digits)", func(t *testing.T) {
ts := ObjToTime(int64(2024))
if ts == nil {
t.Error("year int: got nil")
}
want := mustParse("2006", "2024")
if !ts.Equal(want) {
t.Errorf("year: %v vs %v", ts, want)
}
})
t.Run("small int returns nil", func(t *testing.T) {
ts := ObjToTime(int64(1))
if ts != nil {
t.Errorf("small int: expected nil, got %v", ts)
}
})
t.Run("nil returns nil", func(t *testing.T) {
ts := ObjToTime(nil)
if ts != nil {
t.Errorf("nil: expected nil, got %v", ts)
}
})
}
// ─────────────────────────────────────────────
// ObjToFloat64
// ─────────────────────────────────────────────
func TestObjToFloat64(t *testing.T) {
cases := []struct {
name string
input interface{}
want float64
wantErr bool
}{
{"nil", nil, 0, true},
{"int", 42, 42.0, false},
{"int64", int64(100), 100.0, false},
{"string valid", "3.14", 3.14, false},
{"string invalid", "abc", 0, true},
{"float64", float64(1.5), 1.5, false},
// Fix4: float32 通过 strconv 转换,消除精度膨胀噪声
{"float32 no precision inflation", float32(1.2), 1.2, false},
{"[]byte valid", []byte("2.71"), 2.71, false},
{"[]byte invalid", []byte("xyz"), 0, true},
{"unknown type", struct{}{}, 0, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newErr()
got := ObjToFloat64(c.input, e)
if math.Abs(got-c.want) > 1e-9 {
t.Errorf("value: got %v, want %v", got, c.want)
}
if hasErr(e) != c.wantErr {
t.Errorf("error: got hasErr=%v, wantErr=%v (err=%v)", hasErr(e), c.wantErr, e.GetError())
}
})
}
t.Run("NaN is caught and zeroed", func(t *testing.T) {
e := newErr()
v := ObjToFloat64(math.NaN(), e)
if v != 0 || !hasErr(e) {
t.Errorf("NaN: got %v err=%v", v, e.GetError())
}
})
t.Run("Inf is caught and zeroed", func(t *testing.T) {
e := newErr()
v := ObjToFloat64(math.Inf(1), e)
if v != 0 || !hasErr(e) {
t.Errorf("Inf: got %v err=%v", v, e.GetError())
}
})
t.Run("-Inf is caught and zeroed", func(t *testing.T) {
e := newErr()
v := ObjToFloat64(math.Inf(-1), e)
if v != 0 || !hasErr(e) {
t.Errorf("-Inf: got %v err=%v", v, e.GetError())
}
})
}
// ─────────────────────────────────────────────
// ObjToRoundFloat64
// ─────────────────────────────────────────────
func TestObjToRoundFloat64(t *testing.T) {
t.Run("precision 2 rounds correctly", func(t *testing.T) {
v := ObjToRoundFloat64(3.5000001, 2)
if v != 3.5 {
t.Errorf("got %v, want 3.5", v)
}
})
t.Run("precision 0 rounds to integer", func(t *testing.T) {
v := ObjToRoundFloat64(2.7, 0)
if v != 3.0 {
t.Errorf("got %v, want 3.0", v)
}
})
t.Run("precision -1 returns raw float", func(t *testing.T) {
v := ObjToRoundFloat64(1.23456789, -1)
if math.Abs(v-1.23456789) > 1e-9 {
t.Errorf("got %v, want ~1.23456789", v)
}
})
t.Run("string input with float noise", func(t *testing.T) {
v := ObjToRoundFloat64("3.15000001", 2)
if v != 3.15 {
t.Errorf("got %v, want 3.15", v)
}
})
}
// ─────────────────────────────────────────────
// ObjToCeil*
// ─────────────────────────────────────────────
func TestObjToCeil(t *testing.T) {
t.Run("ObjToCeilInt64 rounds up", func(t *testing.T) {
v := ObjToCeilInt64(1.1)
if v != 2 {
t.Errorf("got %d, want 2", v)
}
})
t.Run("ObjToCeilInt64 already integer", func(t *testing.T) {
v := ObjToCeilInt64(float64(3))
if v != 3 {
t.Errorf("got %d, want 3", v)
}
})
t.Run("ObjToCeilInt64 no double ceil (bug fix)", func(t *testing.T) {
// 3.0 -> Ceil(3.0)=3.0 -> int64(3), NOT Ceil(3.0) again
v := ObjToCeilInt64(float64(3))
if v != 3 {
t.Errorf("double ceil: got %d, want 3", v)
}
})
t.Run("ObjToCeilFloat64", func(t *testing.T) {
v := ObjToCeilFloat64(1.1)
if v != 2.0 {
t.Errorf("got %v, want 2.0", v)
}
})
t.Run("ObjToCeilInt", func(t *testing.T) {
v := ObjToCeilInt(2.3)
if v != 3 {
t.Errorf("got %d, want 3", v)
}
})
}
// ─────────────────────────────────────────────
// ObjToInt64
// ─────────────────────────────────────────────
func TestObjToInt64(t *testing.T) {
cases := []struct {
name string
input interface{}
want int64
wantErr bool
}{
{"nil", nil, 0, true},
{"int", 7, 7, false},
{"int64", int64(99), 99, false},
{"string valid int", "42", 42, false},
// Fix3 fallback: "1.20" 原来报错,现在截断得 1
{"string float-like", "1.20", 1, false},
{"string invalid", "abc", 0, true},
{"[]byte valid int", []byte("123"), 123, false},
// Fix3 fallback: []byte "2.70" 截断得 2
{"[]byte float-like", []byte("2.70"), 2, false},
{"[]byte invalid", []byte("xyz"), 0, true},
{"float64 truncate", float64(5.9), 5, false},
{"float32 truncate", float32(3.1), 3, false},
{"unknown type", struct{}{}, 0, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newErr()
got := ObjToInt64(c.input, e)
if got != c.want {
t.Errorf("value: got %d, want %d", got, c.want)
}
if hasErr(e) != c.wantErr {
t.Errorf("error: got hasErr=%v, wantErr=%v (err=%v)", hasErr(e), c.wantErr, e.GetError())
}
})
}
}
// ─────────────────────────────────────────────
// ObjToInt
// ─────────────────────────────────────────────
func TestObjToInt(t *testing.T) {
v := ObjToInt(10)
if v != 10 {
t.Errorf("got %d, want 10", v)
}
}
// ─────────────────────────────────────────────
// ObjToBool
// ─────────────────────────────────────────────
func TestObjToBool(t *testing.T) {
t.Run("nil returns false with error", func(t *testing.T) {
e := newErr()
v := ObjToBool(nil, e)
if v || !hasErr(e) {
t.Errorf("nil: got %v err=%v", v, e.GetError())
}
})
t.Run("bool true", func(t *testing.T) {
e := newErr()
v := ObjToBool(true, e)
if !v || hasErr(e) {
t.Errorf("bool true: got %v err=%v", v, e.GetError())
}
})
t.Run("bool false", func(t *testing.T) {
e := newErr()
v := ObjToBool(false, e)
if v || hasErr(e) {
t.Errorf("bool false: got %v err=%v", v, e.GetError())
}
})
t.Run("int 1 -> true, no error (bug fix)", func(t *testing.T) {
e := newErr()
v := ObjToBool(1, e)
if !v || hasErr(e) {
t.Errorf("int 1: got v=%v err=%v", v, e.GetError())
}
})
t.Run("int 0 -> false, no error", func(t *testing.T) {
e := newErr()
v := ObjToBool(0, e)
if v || hasErr(e) {
t.Errorf("int 0: got v=%v err=%v", v, e.GetError())
}
})
t.Run("unknown type returns false with error", func(t *testing.T) {
e := newErr()
v := ObjToBool(struct{}{}, e)
if v || !hasErr(e) {
t.Errorf("struct: got v=%v err=%v", v, e.GetError())
}
})
}
// ─────────────────────────────────────────────
// ObjToStr
// ─────────────────────────────────────────────
func TestObjToStr(t *testing.T) {
cases := []struct {
name string
input interface{}
want string
}{
{"nil", nil, ""},
{"int", 42, "42"},
{"uint8", uint8(65), "65"},
{"int64", int64(1234567890), "1234567890"},
{"[]byte", []byte("hello"), "hello"},
{"string", "world", "world"},
{"float64", float64(3.14), "3.14"},
{"float32", float32(2.5), "2.5"},
{"bool true", true, "true"},
{"bool false", false, "false"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := ObjToStr(c.input)
if got != c.want {
t.Errorf("got %q, want %q", got, c.want)
}
})
}
t.Run("default (struct) marshals to JSON", func(t *testing.T) {
type S struct{ X int }
got := ObjToStr(S{X: 1})
if got == "" {
t.Error("struct: expected non-empty JSON string")
}
})
}
// ─────────────────────────────────────────────
// StrToMap
// ─────────────────────────────────────────────
func TestStrToMap(t *testing.T) {
t.Run("valid JSON", func(t *testing.T) {
v := StrToMap(`{"k":"v"}`)
if v["k"] != "v" {
t.Errorf("StrToMap: %v", v)
}
})
t.Run("invalid JSON returns empty map", func(t *testing.T) {
v := StrToMap(`not json`)
if v == nil {
t.Error("StrToMap invalid: expected empty map, got nil")
}
})
}
// ─────────────────────────────────────────────
// StrToSlice
// ─────────────────────────────────────────────
func TestStrToSlice(t *testing.T) {
v := StrToSlice(`[1,2,3]`)
if len(v) != 3 {
t.Errorf("StrToSlice: expected 3 elements, got %d", len(v))
}
}
// ─────────────────────────────────────────────
// StrArrayToJsonStr
// ─────────────────────────────────────────────
func TestStrArrayToJsonStr(t *testing.T) {
cases := []struct {
name string
input string
want string
}{
{"empty returns []", "", "[]"},
{"short (<=2) returns []", "a", "[]"},
{"no commas", `"a","b"`, `["a","b"]`},
{"leading comma stripped", `,"a","b"`, `["a","b"]`},
{"trailing comma stripped", `"a","b",`, `["a","b"]`},
{"both commas stripped", `,"a","b",`, `["a","b"]`},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := StrArrayToJsonStr(c.input)
if got != c.want {
t.Errorf("got %q, want %q", got, c.want)
}
})
}
}
// ─────────────────────────────────────────────
// JsonStrToStrArray
// ─────────────────────────────────────────────
func TestJsonStrToStrArray(t *testing.T) {
t.Run("non-empty strips brackets", func(t *testing.T) {
got := JsonStrToStrArray(`["a","b"]`)
if got != `,"a","b",` {
t.Errorf("got %q", got)
}
})
t.Run("empty string", func(t *testing.T) {
got := JsonStrToStrArray("")
if got != ",," {
t.Errorf("got %q", got)
}
})
}
// ─────────────────────────────────────────────
// StrToInt
// ─────────────────────────────────────────────
func TestStrToInt(t *testing.T) {
t.Run("valid", func(t *testing.T) {
v, err := StrToInt("123")
if v != 123 || err != nil {
t.Errorf("got %d %v", v, err)
}
})
t.Run("invalid", func(t *testing.T) {
_, err := StrToInt("abc")
if err == nil {
t.Error("expected error")
}
})
}
// ─────────────────────────────────────────────
// JsonToObj
// ─────────────────────────────────────────────
func TestJsonToObj(t *testing.T) {
t.Run("integer preserved as int64", func(t *testing.T) {
v, err := JsonToObj(`{"id":2}`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
m, ok := v.(map[string]interface{})
if !ok {
t.Fatalf("expected map, got %T", v)
}
if m["id"] != int64(2) {
t.Errorf("got %v (type %T), want int64(2)", m["id"], m["id"])
}
})
t.Run("decimal preserved as float64 with correct precision", func(t *testing.T) {
v, err := JsonToObj(`{"price":1.20}`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
m := v.(map[string]interface{})
price, ok := m["price"].(float64)
if !ok {
t.Fatalf("expected float64, got %T", m["price"])
}
if price != 1.2 {
t.Errorf("got %v, want 1.2", price)
}
})
t.Run("large integer preserved as int64", func(t *testing.T) {
v, err := JsonToObj(`{"num":9999999}`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
m := v.(map[string]interface{})
if m["num"] != int64(9999999) {
t.Errorf("got %v (type %T), want int64(9999999)", m["num"], m["num"])
}
})
t.Run("array with mixed types", func(t *testing.T) {
v, err := JsonToObj(`[1, 2.5, "hello", true]`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
arr := v.([]interface{})
if len(arr) != 4 {
t.Fatalf("expected 4 elements, got %d", len(arr))
}
if arr[0] != int64(1) {
t.Errorf("arr[0]: got %v (%T), want int64(1)", arr[0], arr[0])
}
if arr[1] != float64(2.5) {
t.Errorf("arr[1]: got %v (%T), want float64(2.5)", arr[1], arr[1])
}
if arr[2] != "hello" {
t.Errorf("arr[2]: got %v, want hello", arr[2])
}
if arr[3] != true {
t.Errorf("arr[3]: got %v, want true", arr[3])
}
})
t.Run("nested object integers preserved", func(t *testing.T) {
v, err := JsonToObj(`{"order":{"id":5,"num":3,"price":1.20}}`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
m := v.(map[string]interface{})
order := m["order"].(map[string]interface{})
if order["id"] != int64(5) {
t.Errorf("nested id: got %v (%T), want int64(5)", order["id"], order["id"])
}
if order["num"] != int64(3) {
t.Errorf("nested num: got %v (%T), want int64(3)", order["num"], order["num"])
}
if order["price"] != float64(1.2) {
t.Errorf("nested price: got %v, want 1.2", order["price"])
}
})
t.Run("invalid JSON returns error", func(t *testing.T) {
_, err := JsonToObj(`not json`)
if err == nil {
t.Error("expected error for invalid JSON")
}
})
t.Run("empty object", func(t *testing.T) {
v, err := JsonToObj(`{}`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
m, ok := v.(map[string]interface{})
if !ok || len(m) != 0 {
t.Errorf("expected empty map, got %v", v)
}
})
t.Run("null value", func(t *testing.T) {
v, err := JsonToObj(`{"x":null}`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
m := v.(map[string]interface{})
if m["x"] != nil {
t.Errorf("expected nil, got %v", m["x"])
}
})
t.Run("zero integer", func(t *testing.T) {
v, err := JsonToObj(`{"count":0}`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
m := v.(map[string]interface{})
if m["count"] != int64(0) {
t.Errorf("got %v (%T), want int64(0)", m["count"], m["count"])
}
})
}
// ─────────────────────────────────────────────
// ObjToMap 数字类型保留(新行为)
// ─────────────────────────────────────────────
func TestObjToMapNumberPreservation(t *testing.T) {
t.Run("JSON integer -> int64", func(t *testing.T) {
v := ObjToMap(`{"id":2,"num":0}`)
if v["id"] != int64(2) {
t.Errorf("id: got %v (%T), want int64(2)", v["id"], v["id"])
}
if v["num"] != int64(0) {
t.Errorf("num: got %v (%T), want int64(0)", v["num"], v["num"])
}
})
t.Run("JSON decimal -> float64", func(t *testing.T) {
v := ObjToMap(`{"price":1.20,"rate":0.5}`)
if v["price"] != float64(1.2) {
t.Errorf("price: got %v (%T), want float64(1.2)", v["price"], v["price"])
}
if v["rate"] != float64(0.5) {
t.Errorf("rate: got %v (%T), want float64(0.5)", v["rate"], v["rate"])
}
})
t.Run("struct with int field -> int64", func(t *testing.T) {
type S struct {
Count int `json:"count"`
Name string `json:"name"`
}
v := ObjToMap(S{Count: 3, Name: "test"})
if v == nil {
t.Fatal("got nil")
}
if v["count"] != int64(3) {
t.Errorf("count: got %v (%T), want int64(3)", v["count"], v["count"])
}
})
}
// ─────────────────────────────────────────────
// ObjToSlice 数字类型保留(新行为)
// ─────────────────────────────────────────────
func TestObjToSliceNumberPreservation(t *testing.T) {
t.Run("JSON integer array -> int64 elements", func(t *testing.T) {
v := ObjToSlice(`[1, 2, 3]`)
if len(v) != 3 {
t.Fatalf("expected 3, got %d", len(v))
}
if v[0] != int64(1) {
t.Errorf("v[0]: got %v (%T), want int64(1)", v[0], v[0])
}
})
t.Run("JSON mixed array - integer and float", func(t *testing.T) {
v := ObjToSlice(`[2, 1.5, "hello"]`)
if v[0] != int64(2) {
t.Errorf("v[0]: got %v (%T), want int64(2)", v[0], v[0])
}
if v[1] != float64(1.5) {
t.Errorf("v[1]: got %v (%T), want float64(1.5)", v[1], v[1])
}
})
}
// ─────────────────────────────────────────────
// Map.JsonToMap 数字类型保留(新行为)
// ─────────────────────────────────────────────
func TestMapJsonToMapNumberPreservation(t *testing.T) {
t.Run("integer -> int64", func(t *testing.T) {
m := Map{}
m.JsonToMap(`{"id":5,"count":0}`)
if m["id"] != int64(5) {
t.Errorf("id: got %v (%T), want int64(5)", m["id"], m["id"])
}
if m["count"] != int64(0) {
t.Errorf("count: got %v (%T), want int64(0)", m["count"], m["count"])
}
})
t.Run("decimal -> float64", func(t *testing.T) {
m := Map{}
m.JsonToMap(`{"price":1.20}`)
if m["price"] != float64(1.2) {
t.Errorf("price: got %v (%T), want float64(1.2)", m["price"], m["price"])
}
})
t.Run("StrToMap integer -> int64", func(t *testing.T) {
m := StrToMap(`{"total":100,"amount":1.50}`)
if m["total"] != int64(100) {
t.Errorf("total: got %v (%T), want int64(100)", m["total"], m["total"])
}
if m["amount"] != float64(1.5) {
t.Errorf("amount: got %v (%T), want float64(1.5)", m["amount"], m["amount"])
}
})
t.Run("invalid JSON sets error", func(t *testing.T) {
e := newErr()
m := Map{}
m.JsonToMap(`not json`, e)
if !hasErr(e) {
t.Error("expected error for invalid JSON")
}
})
}
+5
View File
@@ -63,6 +63,11 @@ func (that Slice) GetFloat64(key int, err ...*Error) float64 {
return v
}
// GetRoundFloat64 获取四舍五入到 precision 位小数的 float64
func (that Slice) GetRoundFloat64(key int, precision int, err ...*Error) float64 {
return ObjToRoundFloat64((that)[key], precision, err...)
}
func (that Slice) GetSlice(key int, err ...*Error) Slice {
v := ObjToSlice((that)[key], err...)
return v
+3
View File
@@ -59,6 +59,9 @@ func (that *Context) Display(statu int, data interface{}) {
resp["result"] = temp
//兼容android等需要json转对象的服务
resp["error"] = temp
that.Application.Log.Warn().Int("status", statu).Msg(resp.ToJsonString())
} else {
resp["result"] = data
}
+344
View File
@@ -0,0 +1,344 @@
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
+64 -6
View File
@@ -14,13 +14,18 @@ func (that *HoTimeDB) backupSave(path string, tt string, code int) {
fd, _ := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644)
defer fd.Close()
q := "`"
if that.Dialect != nil {
q = that.Dialect.QuoteChar()
}
str := "\r\n"
if code == 0 || code == 2 {
str += that.backupDdl(tt)
}
if code == 0 || code == 1 {
str += "insert into `" + tt + "`\r\n\r\n("
str += "insert into " + q + tt + q + "\r\n\r\n("
str += that.backupCol(tt)
}
@@ -29,16 +34,69 @@ func (that *HoTimeDB) backupSave(path string, tt string, code int) {
// backupDdl 备份表结构(DDL
func (that *HoTimeDB) backupDdl(tt string) string {
data := that.Query("show create table " + tt)
if len(data) == 0 {
switch that.Type {
case "dm", "dameng":
return that.backupDdlDM(tt)
default:
data := that.Query("show create table " + tt)
if len(data) == 0 {
return ""
}
return ObjToStr(data[0]["Create Table"]) + ";\r\n\r\n"
}
}
// backupDdlDM 达梦数据库 DDL 导出
func (that *HoTimeDB) backupDdlDM(tt string) string {
cols := that.Query(`SELECT COLUMN_NAME, DATA_TYPE, DATA_LENGTH, DATA_PRECISION, DATA_SCALE, NULLABLE, DATA_DEFAULT FROM USER_TAB_COLUMNS WHERE TABLE_NAME='` + tt + `' ORDER BY COLUMN_ID`)
if len(cols) == 0 {
return ""
}
return ObjToStr(data[0]["Create Table"]) + ";\r\n\r\n"
ddl := "CREATE TABLE \"" + tt + "\" (\r\n"
for i, col := range cols {
name := col.GetString("COLUMN_NAME")
dtype := col.GetString("DATA_TYPE")
nullable := col.GetString("NULLABLE")
defVal := col.GetString("DATA_DEFAULT")
colDef := " \"" + name + "\" " + dtype
prec := col.GetInt("DATA_PRECISION")
scale := col.GetInt("DATA_SCALE")
length := col.GetInt("DATA_LENGTH")
if prec > 0 {
colDef += "(" + ObjToStr(prec)
if scale > 0 {
colDef += "," + ObjToStr(scale)
}
colDef += ")"
} else if length > 0 && (strings.Contains(strings.ToUpper(dtype), "CHAR") || strings.Contains(strings.ToUpper(dtype), "BINARY")) {
colDef += "(" + ObjToStr(length) + ")"
}
if nullable == "N" {
colDef += " NOT NULL"
}
if defVal != "" {
colDef += " DEFAULT " + strings.TrimSpace(defVal)
}
if i < len(cols)-1 {
colDef += ","
}
ddl += colDef + "\r\n"
}
ddl += ");\r\n\r\n"
return ddl
}
// backupCol 备份表数据
func (that *HoTimeDB) backupCol(tt string) string {
q := "`"
if that.Dialect != nil {
q = that.Dialect.QuoteChar()
}
str := ""
data := that.Select(tt, "*")
@@ -54,9 +112,9 @@ func (that *HoTimeDB) backupCol(tt string) string {
for k := range data[0] {
if tempLthData == lthCol-1 {
str += "`" + k + "`) "
str += q + k + q + ") "
} else {
str += "`" + k + "`,"
str += q + k + q + ","
}
col[tempLthData] = k
tempLthData++
+81 -20
View File
@@ -8,6 +8,10 @@ import (
. "code.hoteas.com/golang/hotime/common"
)
func (that *HoTimeDB) isCacheTable(table string) bool {
return table == "cached" || strings.HasPrefix(table, "hotime_cache")
}
// Page 设置分页参数
// page: 页码(从1开始)
// pageRow: 每页数量
@@ -135,23 +139,20 @@ func (that *HoTimeDB) Select(table string, qu ...interface{}) []Map {
qs = append(qs, resWhere...)
md5 := that.md5(query, qs...)
if that.HoTimeCache != nil && table != "cached" {
// 如果缓存有则从缓存取
if that.testTx == nil && that.HoTimeCache != nil && !that.isCacheTable(table) {
cacheData := that.HoTimeCache.Db(table + ":" + md5)
if cacheData != nil && cacheData.Data != nil {
return cacheData.ToMapArray()
}
}
// 无缓存则数据库取
res := that.Query(query, qs...)
if res == nil {
res = []Map{}
}
// 缓存
if that.HoTimeCache != nil && table != "cached" {
if that.testTx == nil && that.HoTimeCache != nil && !that.isCacheTable(table) {
_ = that.HoTimeCache.Db(table+":"+md5, res)
}
@@ -288,18 +289,30 @@ func (that *HoTimeDB) Insert(table string, data map[string]interface{}) int64 {
query := "INSERT INTO " + processor.ProcessTableName(table) + " " + queryString + "VALUES" + valueString
res, err := that.Exec(query, values...)
id := int64(0)
if err.GetError() == nil && res != nil {
id1, err := res.LastInsertId()
that.LastErr.SetError(err)
id = id1
if that.Dialect != nil && !that.Dialect.SupportsLastInsertId() {
returningClause := that.Dialect.ReturningClause("id")
query = strings.TrimSuffix(query, ";") + returningClause
rows := that.Query(query, values...)
if len(rows) > 0 {
id = rows[0].GetInt64("id")
}
} else {
res, err := that.Exec(query, values...)
if err == nil && res != nil {
id1, e := res.LastInsertId()
if e != nil {
dbErr := &DBError{}
dbErr.SetError(e)
that.lastError.Store(dbErr)
}
id = id1
}
}
// 如果插入成功,删除缓存
if id != 0 {
if that.HoTimeCache != nil && table != "cached" {
if that.HoTimeCache != nil && !that.isCacheTable(table) {
_ = that.HoTimeCache.Db(table+"*", nil)
}
}
@@ -381,13 +394,13 @@ func (that *HoTimeDB) Inserts(table string, dataList []Map) int64 {
res, err := that.Exec(query, values...)
rows64 := int64(0)
if err.GetError() == nil && res != nil {
if err == nil && res != nil {
rows64, _ = res.RowsAffected()
}
// 如果插入成功,删除缓存
if rows64 != 0 {
if that.HoTimeCache != nil && table != "cached" {
if that.HoTimeCache != nil && !that.isCacheTable(table) {
_ = that.HoTimeCache.Db(table+"*", nil)
}
}
@@ -478,6 +491,8 @@ func (that *HoTimeDB) Upsert(table string, data Map, uniqueKeys Slice, updateCol
query = that.buildPostgresUpsert(table, columns, uniqueKeyStrs, updateColumnStrs, rawValues)
case "sqlite3", "sqlite":
query = that.buildSQLiteUpsert(table, columns, uniqueKeyStrs, updateColumnStrs, rawValues)
case "dm", "dameng":
query = that.buildDMUpsert(table, columns, uniqueKeyStrs, updateColumnStrs, rawValues)
default: // mysql
query = that.buildMySQLUpsert(table, columns, uniqueKeyStrs, updateColumnStrs, rawValues)
}
@@ -485,13 +500,13 @@ func (that *HoTimeDB) Upsert(table string, data Map, uniqueKeys Slice, updateCol
res, err := that.Exec(query, values...)
rows := int64(0)
if err.GetError() == nil && res != nil {
if err == nil && res != nil {
rows, _ = res.RowsAffected()
}
// 清除缓存
if rows != 0 {
if that.HoTimeCache != nil && table != "cached" {
if that.HoTimeCache != nil && !that.isCacheTable(table) {
_ = that.HoTimeCache.Db(table+"*", nil)
}
}
@@ -611,6 +626,52 @@ func (that *HoTimeDB) buildSQLiteUpsert(table string, columns []string, uniqueKe
") DO UPDATE SET " + strings.Join(updateParts, ", ")
}
// buildDMUpsert 构建达梦的 Upsert 语句(MERGE INTO
func (that *HoTimeDB) buildDMUpsert(table string, columns []string, uniqueKeys []string, updateColumns []string, rawValues map[string]string) string {
processor := that.GetProcessor()
dialect := that.GetDialect()
quotedTable := processor.ProcessTableName(table)
srcParts := make([]string, len(columns))
for i, col := range columns {
if raw, ok := rawValues[col]; ok {
srcParts[i] = raw + " AS " + dialect.QuoteIdentifier(col)
} else {
srcParts[i] = "? AS " + dialect.QuoteIdentifier(col)
}
}
onParts := make([]string, len(uniqueKeys))
for i, key := range uniqueKeys {
qk := dialect.QuoteIdentifier(key)
onParts[i] = quotedTable + "." + qk + " = src." + qk
}
updateParts := make([]string, len(updateColumns))
for i, col := range updateColumns {
qc := dialect.QuoteIdentifier(col)
if raw, ok := rawValues[col]; ok {
updateParts[i] = qc + " = " + raw
} else {
updateParts[i] = qc + " = src." + qc
}
}
insertCols := make([]string, len(columns))
insertVals := make([]string, len(columns))
for i, col := range columns {
qc := dialect.QuoteIdentifier(col)
insertCols[i] = qc
insertVals[i] = "src." + qc
}
return "MERGE INTO " + quotedTable + " USING (SELECT " + strings.Join(srcParts, ", ") +
") src ON (" + strings.Join(onParts, " AND ") +
") WHEN MATCHED THEN UPDATE SET " + strings.Join(updateParts, ", ") +
" WHEN NOT MATCHED THEN INSERT (" + strings.Join(insertCols, ", ") +
") VALUES (" + strings.Join(insertVals, ", ") + ")"
}
// Update 更新数据
func (that *HoTimeDB) Update(table string, data Map, where Map) int64 {
processor := that.GetProcessor()
@@ -640,13 +701,13 @@ func (that *HoTimeDB) Update(table string, data Map, where Map) int64 {
res, err := that.Exec(query, qs...)
rows := int64(0)
if err.GetError() == nil && res != nil {
if err == nil && res != nil {
rows, _ = res.RowsAffected()
}
// 如果更新成功,则删除缓存
if rows != 0 {
if that.HoTimeCache != nil && table != "cached" {
if that.HoTimeCache != nil && !that.isCacheTable(table) {
_ = that.HoTimeCache.Db(table+"*", nil)
}
}
@@ -664,13 +725,13 @@ func (that *HoTimeDB) Delete(table string, data map[string]interface{}) int64 {
res, err := that.Exec(query, resWhere...)
rows := int64(0)
if err.GetError() == nil && res != nil {
if err == nil && res != nil {
rows, _ = res.RowsAffected()
}
// 如果删除成功,删除对应缓存
if rows != 0 {
if that.HoTimeCache != nil && table != "cached" {
if that.HoTimeCache != nil && !that.isCacheTable(table) {
_ = that.HoTimeCache.Db(table+"*", nil)
}
}
+124 -23
View File
@@ -1,69 +1,118 @@
package db
import (
"bytes"
"code.hoteas.com/golang/hotime/cache"
. "code.hoteas.com/golang/hotime/common"
"code.hoteas.com/golang/hotime/log"
"database/sql"
"strings"
"sync"
"sync/atomic"
_ "gitee.com/chunanyong/dm"
_ "github.com/go-sql-driver/mysql"
_ "github.com/mattn/go-sqlite3"
"github.com/sirupsen/logrus"
)
// DBError 最后一次 SQL 操作的结构化快照
// 嵌入框架 Error 类型,与 Obj 等保持一致的错误 APIHasError/GetError/Unwrap
type DBError struct {
Error
Query string
Data []interface{}
}
// HoTimeDB 数据库操作核心结构体
type HoTimeDB struct {
*sql.DB
ContextBase
DBName string
*cache.HoTimeCache
Log *logrus.Logger
Log *log.Logger
Type string // 数据库类型: mysql, sqlite3, postgres
Prefix string
LastQuery string
LastData []interface{}
ConnectFunc func(err ...*Error) (*sql.DB, *sql.DB)
LastErr *Error
ConnectFunc func() (*sql.DB, *sql.DB)
lastError atomic.Pointer[DBError] // 无锁读写,始终记录最后一次 SQL 操作
limit Slice
*sql.Tx //事务对象
SlaveDB *sql.DB // 从数据库
Mode int // mode为0生产模式,1为测试模式,2为开发模式
mu sync.RWMutex
limitMu sync.Mutex
Dialect Dialect // 数据库方言适配器
Dialect Dialect // 数据库方言适配器
testTx *sql.Tx // 测试事务:设置后所有操作都在此事务内,测试结束回滚
testMu *sync.Mutex // 保护 testTx 单连接不被并发访问
testLogBuf *bytes.Buffer // 测试模式:SQL 日志写入此 buffer 而非直接打印,失败时才输出
testLogBufMu sync.Mutex // 保护 testLogBuf 并发写入
testSQLErrCount int32 // 测试模式下 SQL 错误计数(atomic)
txFailed *bool // 事务内是否有SQL出错,出错则事务必须回滚(指针确保按值传递 HoTimeDB 时状态共享)
}
// GetLast 返回最后一次 SQL 操作的完整快照(Query + Data + Error
func (that *HoTimeDB) GetLast() *DBError {
return that.lastError.Load()
}
// GetLastError 返回最后一次 SQL 的错误(nil = 成功)
func (that *HoTimeDB) GetLastError() error {
if e := that.lastError.Load(); e != nil {
return e.GetError()
}
return nil
}
// GetLastQuery 返回最后一次执行的 SQL 语句
func (that *HoTimeDB) GetLastQuery() string {
if e := that.lastError.Load(); e != nil {
return e.Query
}
return ""
}
// GetLastData 返回最后一次 SQL 的参数
func (that *HoTimeDB) GetLastData() []interface{} {
if e := that.lastError.Load(); e != nil {
return e.Data
}
return nil
}
// SetConnect 设置数据库配置连接
func (that *HoTimeDB) SetConnect(connect func(err ...*Error) (master, slave *sql.DB), err ...*Error) {
func (that *HoTimeDB) SetConnect(connect func() (master, slave *sql.DB)) {
that.ConnectFunc = connect
_ = that.InitDb(err...)
that.InitDb()
}
// InitDb 初始化数据库连接
func (that *HoTimeDB) InitDb(err ...*Error) *Error {
if len(err) != 0 {
that.LastErr = err[0]
}
that.DB, that.SlaveDB = that.ConnectFunc(that.LastErr)
func (that *HoTimeDB) InitDb() {
that.DB, that.SlaveDB = that.ConnectFunc()
if that.DB == nil {
return that.LastErr
return
}
e := that.DB.Ping()
that.LastErr.SetError(e)
if e != nil {
dbErr := &DBError{}
dbErr.SetError(e)
that.lastError.Store(dbErr)
if that.Log != nil {
that.Log.Error().Err(e).Msg("数据库连接失败")
}
}
if that.SlaveDB != nil {
e := that.SlaveDB.Ping()
that.LastErr.SetError(e)
if e != nil {
dbErr := &DBError{}
dbErr.SetError(e)
that.lastError.Store(dbErr)
if that.Log != nil {
that.Log.Error().Err(e).Msg("从数据库连接失败")
}
}
}
// 根据数据库类型初始化方言适配器
if that.Dialect == nil {
that.initDialect()
}
return that.LastErr
}
// initDialect 根据数据库类型初始化方言
@@ -73,6 +122,8 @@ func (that *HoTimeDB) initDialect() {
that.Dialect = &PostgreSQLDialect{}
case "sqlite3", "sqlite":
that.Dialect = &SQLiteDialect{}
case "dm", "dameng":
that.Dialect = &DMDialect{}
default:
that.Dialect = &MySQLDialect{}
}
@@ -145,3 +196,53 @@ func trimQuotes(s string) string {
}
return s
}
// SetTestLogBuffer 设置测试 SQL 日志缓冲区,非 nil 时 SQL 日志写入 buffer 而非直接打印
func (that *HoTimeDB) SetTestLogBuffer(buf *bytes.Buffer) {
that.testLogBufMu.Lock()
that.testLogBuf = buf
that.testLogBufMu.Unlock()
}
// FlushTestLog 取出并清空缓冲区中的 SQL 日志,返回累积的日志文本
func (that *HoTimeDB) FlushTestLog() string {
that.testLogBufMu.Lock()
defer that.testLogBufMu.Unlock()
if that.testLogBuf == nil {
return ""
}
s := that.testLogBuf.String()
that.testLogBuf.Reset()
return s
}
// TestSQLErrorCount 返回测试模式下的 SQL 错误计数
func (that *HoTimeDB) TestSQLErrorCount() int32 {
return atomic.LoadInt32(&that.testSQLErrCount)
}
// ResetTestSQLErrorCount 重置测试 SQL 错误计数
func (that *HoTimeDB) ResetTestSQLErrorCount() {
atomic.StoreInt32(&that.testSQLErrCount, 0)
}
// BeginTestTx 开启测试事务,设置后所有 Query/Exec/Action 都在此事务内执行
func (that *HoTimeDB) BeginTestTx() error {
tx, err := that.DB.Begin()
if err != nil {
return err
}
that.testTx = tx
that.testMu = &sync.Mutex{}
return nil
}
// RollbackTestTx 回滚测试事务,清除 testTx 状态
func (that *HoTimeDB) RollbackTestTx() error {
if that.testTx != nil {
err := that.testTx.Rollback()
that.testTx = nil
return err
}
return nil
}
+94
View File
@@ -206,6 +206,100 @@ func (d *PostgreSQLDialect) UpsertSQL(table string, columns []string, uniqueKeys
strings.Join(updateParts, ", "))
}
// DMDialect 达梦数据库方言实现
type DMDialect struct{}
func (d *DMDialect) GetName() string {
return "dm"
}
func (d *DMDialect) Quote(name string) string {
if strings.Contains(name, ".") || strings.Contains(name, " ") {
return name
}
return "\"" + name + "\""
}
func (d *DMDialect) QuoteIdentifier(name string) string {
name = strings.Trim(name, "`\"")
return "\"" + name + "\""
}
func (d *DMDialect) QuoteChar() string {
return "\""
}
func (d *DMDialect) Placeholder(index int) string {
return "?"
}
func (d *DMDialect) Placeholders(count int, startIndex int) string {
if count <= 0 {
return ""
}
placeholders := make([]string, count)
for i := 0; i < count; i++ {
placeholders[i] = "?"
}
return strings.Join(placeholders, ",")
}
func (d *DMDialect) SupportsLastInsertId() bool {
return true // DM Go 驱动原生支持 IDENTITY 列的 LastInsertId
}
func (d *DMDialect) ReturningClause(column string) string {
return " RETURNING " + d.Quote(column)
}
func (d *DMDialect) UpsertSQL(table string, columns []string, uniqueKeys []string, updateColumns []string) string {
// MERGE INTO table USING (SELECT ? AS col1, ? AS col2) src
// ON (table.uk = src.uk) WHEN MATCHED THEN UPDATE SET col1 = src.col1
// WHEN NOT MATCHED THEN INSERT (col1, col2) VALUES (src.col1, src.col2)
quotedTable := d.Quote(table)
srcParts := make([]string, len(columns))
for i, col := range columns {
if strings.HasSuffix(col, "[#]") {
srcParts[i] = col
} else {
srcParts[i] = "? AS " + d.Quote(col)
}
}
onParts := make([]string, len(uniqueKeys))
for i, key := range uniqueKeys {
qk := d.Quote(key)
onParts[i] = quotedTable + "." + qk + " = src." + qk
}
updateParts := make([]string, len(updateColumns))
for i, col := range updateColumns {
if strings.HasSuffix(col, "[#]") {
updateParts[i] = col
} else {
qc := d.Quote(col)
updateParts[i] = qc + " = src." + qc
}
}
insertCols := make([]string, len(columns))
insertVals := make([]string, len(columns))
for i, col := range columns {
qc := d.Quote(col)
insertCols[i] = qc
insertVals[i] = "src." + qc
}
return fmt.Sprintf("MERGE INTO %s USING (SELECT %s) src ON (%s) WHEN MATCHED THEN UPDATE SET %s WHEN NOT MATCHED THEN INSERT (%s) VALUES (%s)",
quotedTable,
strings.Join(srcParts, ", "),
strings.Join(onParts, " AND "),
strings.Join(updateParts, ", "),
strings.Join(insertCols, ", "),
strings.Join(insertVals, ", "))
}
// SQLiteDialect SQLite 方言实现
type SQLiteDialect struct{}
+7 -14
View File
@@ -83,24 +83,19 @@ func (p *IdentifierProcessor) ProcessTableNameNoPrefix(name string) string {
// ProcessColumn 处理 table.column 格式
// 输入: "name" 或 "order.name" 或 "`order`.name" 或 "`order`.`name`"
// 输出: "`name`" 或 "`app_order`.`name`" (MySQL)
// 输出: "`name`" 或 "`app_order`.`name`" (MySQL) / "\"app_order\".\"name\"" (DM/PG)
func (p *IdentifierProcessor) ProcessColumn(name string) string {
// 检查是否包含点号
if !strings.Contains(name, ".") {
// 单独的列名,只加引号
return p.dialect.QuoteIdentifier(p.stripQuotes(name))
}
// 处理 table.column 格式
parts := p.splitTableColumn(name)
if len(parts) == 2 {
tableName := p.stripQuotes(parts[0])
columnName := p.stripQuotes(parts[1])
// 表名添加前缀
return p.dialect.QuoteIdentifier(p.prefix+tableName) + "." + p.dialect.QuoteIdentifier(columnName)
}
// 无法解析,返回原样但转换引号
return p.convertQuotes(name)
}
@@ -123,6 +118,7 @@ func (p *IdentifierProcessor) ProcessColumnNoPrefix(name string) string {
// ProcessConditionString 智能解析条件字符串(如 ON 条件)
// 输入: "user.id = order.user_id AND order.status = 1"
// 输出: "`app_user`.`id` = `app_order`.`user_id` AND `app_order`.`status` = 1" (MySQL)
// 输出: "\"app_user\".\"id\" = \"app_order\".\"user_id\" AND \"app_order\".\"status\" = 1" (DM/PG)
func (p *IdentifierProcessor) ProcessConditionString(condition string) string {
if condition == "" {
return condition
@@ -131,7 +127,6 @@ func (p *IdentifierProcessor) ProcessConditionString(condition string) string {
result := condition
// 首先处理已有完整引号的情况 `table`.`column` 或 "table"."column"
// 这些需要先处理,因为它们的格式最明确
fullyQuotedPattern := regexp.MustCompile("[`\"]([a-zA-Z_][a-zA-Z0-9_]*)[`\"]\\.[`\"]([a-zA-Z_][a-zA-Z0-9_]*)[`\"]")
result = fullyQuotedPattern.ReplaceAllStringFunc(result, func(match string) string {
parts := fullyQuotedPattern.FindStringSubmatch(match)
@@ -144,14 +139,12 @@ func (p *IdentifierProcessor) ProcessConditionString(condition string) string {
})
// 然后处理部分引号的情况 `table`.column 或 "table".column
// 注意:需要避免匹配已处理的内容(已经是双引号包裹的)
quotedTablePattern := regexp.MustCompile("[`\"]([a-zA-Z_][a-zA-Z0-9_]*)[`\"]\\.([a-zA-Z_][a-zA-Z0-9_]*)(?:[^`\"]|$)")
result = quotedTablePattern.ReplaceAllStringFunc(result, func(match string) string {
parts := quotedTablePattern.FindStringSubmatch(match)
if len(parts) >= 3 {
tableName := parts[1]
colName := parts[2]
// 保留末尾字符(如果有)
suffix := ""
if len(match) > len(parts[0])-1 {
lastChar := match[len(match)-1]
@@ -165,15 +158,14 @@ func (p *IdentifierProcessor) ProcessConditionString(condition string) string {
})
// 最后处理无引号的情况 table.column
// 使用更精确的正则,确保不匹配已处理的内容
unquotedPattern := regexp.MustCompile(`([^` + "`" + `"\w]|^)([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)([^` + "`" + `"\w(]|$)`)
result = unquotedPattern.ReplaceAllStringFunc(result, func(match string) string {
parts := unquotedPattern.FindStringSubmatch(match)
if len(parts) >= 5 {
prefix := parts[1] // 前面的边界字符
prefix := parts[1]
tableName := parts[2]
colName := parts[3]
suffix := parts[4] // 后面的边界字符
suffix := parts[4]
return prefix + p.dialect.QuoteIdentifier(p.prefix+tableName) + "." + p.dialect.QuoteIdentifier(colName) + suffix
}
return match
@@ -195,8 +187,9 @@ func (p *IdentifierProcessor) ProcessFieldList(fields string) string {
return fields
}
// 使用与 ProcessConditionString 相同的逻辑
return p.ProcessConditionString(fields)
result := p.ProcessConditionString(fields)
result = p.convertQuotes(result)
return result
}
// stripQuotes 去除标识符两端的引号(反引号或双引号)
+291 -67
View File
@@ -4,8 +4,13 @@ import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"math"
"reflect"
"strconv"
"strings"
"sync/atomic"
"time"
. "code.hoteas.com/golang/hotime/common"
)
@@ -24,52 +29,65 @@ func (that *HoTimeDB) Query(query string, args ...interface{}) []Map {
// queryWithRetry 内部查询方法,支持重试标记
func (that *HoTimeDB) queryWithRetry(query string, retried bool, args ...interface{}) []Map {
// 预处理数组占位符 ?[]
query, args = that.expandArrayPlaceholder(query, args)
// 保存调试信息(加锁保护)
that.mu.Lock()
that.LastQuery = query
that.LastData = args
that.mu.Unlock()
var sqlErr error
defer func() {
if that.Mode != 0 {
that.mu.RLock()
that.Log.Info("SQL:"+that.LastQuery, " DATA:", that.LastData, " ERROR:", that.LastErr.GetError())
that.mu.RUnlock()
}
e := &DBError{Query: query, Data: args}
e.SetError(sqlErr)
that.lastError.Store(e)
that.logSQL(query, args, sqlErr)
}()
var err error
var resl *sql.Rows
// 主从数据库切换,只有select语句有从数据库
db := that.DB
if that.SlaveDB != nil {
db = that.SlaveDB
}
if db == nil {
err = errors.New("没有初始化数据库")
that.LastErr.SetError(err)
sqlErr = errors.New("没有初始化数据库")
return nil
}
// 处理参数中的 slice 类型
processedArgs := that.processArgs(args)
if that.Tx != nil {
resl, err = that.Tx.Query(query, processedArgs...)
if that.testTx != nil {
if that.testMu != nil {
that.testMu.Lock()
}
resl, sqlErr = that.testTx.Query(query, processedArgs...)
if sqlErr != nil {
if that.txFailed != nil {
*that.txFailed = true
}
if that.testMu != nil {
that.testMu.Unlock()
}
return nil
}
result := that.Row(resl)
if that.testMu != nil {
that.testMu.Unlock()
}
return result
} else if that.Tx != nil {
resl, sqlErr = that.Tx.Query(query, processedArgs...)
} else {
resl, err = db.Query(query, processedArgs...)
resl, sqlErr = db.Query(query, processedArgs...)
}
that.LastErr.SetError(err)
if err != nil {
// 如果还没重试过,尝试 Ping 后重试一次
if !retried {
if sqlErr != nil {
if that.Tx != nil {
if that.txFailed != nil {
*that.txFailed = true
}
}
if !retried && that.Tx == nil {
if pingErr := db.Ping(); pingErr == nil {
sqlErr = nil // 重置,让 retry 的 defer 覆盖
return that.queryWithRetry(query, true, args...)
}
}
@@ -80,59 +98,95 @@ func (that *HoTimeDB) queryWithRetry(query string, retried bool, args ...interfa
}
// Exec 执行非查询 SQL
func (that *HoTimeDB) Exec(query string, args ...interface{}) (sql.Result, *Error) {
func (that *HoTimeDB) Exec(query string, args ...interface{}) (sql.Result, error) {
return that.execWithRetry(query, false, args...)
}
// execWithRetry 内部执行方法,支持重试标记
func (that *HoTimeDB) execWithRetry(query string, retried bool, args ...interface{}) (sql.Result, *Error) {
// 预处理数组占位符 ?[]
func (that *HoTimeDB) execWithRetry(query string, retried bool, args ...interface{}) (sql.Result, error) {
query, args = that.expandArrayPlaceholder(query, args)
// 保存调试信息(加锁保护)
that.mu.Lock()
that.LastQuery = query
that.LastData = args
that.mu.Unlock()
defer func() {
if that.Mode != 0 {
that.mu.RLock()
that.Log.Info("SQL: "+that.LastQuery, " DATA: ", that.LastData, " ERROR: ", that.LastErr.GetError())
that.mu.RUnlock()
}
}()
var e error
var sqlErr error
var resl sql.Result
defer func() {
e := &DBError{Query: query, Data: args}
e.SetError(sqlErr)
that.lastError.Store(e)
that.logSQL(query, args, sqlErr)
}()
if that.DB == nil {
err := errors.New("没有初始化数据库")
that.LastErr.SetError(err)
return nil, that.LastErr
sqlErr = errors.New("没有初始化数据库")
return nil, sqlErr
}
// 处理参数中的 slice 类型
processedArgs := that.processArgs(args)
if that.Tx != nil {
resl, e = that.Tx.Exec(query, processedArgs...)
if that.testTx != nil {
if that.testMu != nil {
that.testMu.Lock()
}
resl, sqlErr = that.testTx.Exec(query, processedArgs...)
if that.testMu != nil {
that.testMu.Unlock()
}
} else if that.Tx != nil {
resl, sqlErr = that.Tx.Exec(query, processedArgs...)
} else {
resl, e = that.DB.Exec(query, processedArgs...)
resl, sqlErr = that.DB.Exec(query, processedArgs...)
}
that.LastErr.SetError(e)
// 判断是否连接断开了,如果还没重试过,尝试重试一次
if e != nil {
if sqlErr != nil {
if that.testTx != nil {
if that.txFailed != nil {
*that.txFailed = true
}
return resl, sqlErr
}
if that.Tx != nil {
if that.txFailed != nil {
*that.txFailed = true
}
return resl, sqlErr
}
if !retried {
if pingErr := that.DB.Ping(); pingErr == nil {
sqlErr = nil
return that.execWithRetry(query, true, args...)
}
}
return resl, that.LastErr
return resl, sqlErr
}
return resl, that.LastErr
return resl, sqlErr
}
// logSQL 统一 SQL 日志输出(仅此一处打印,消除重复)
func (that *HoTimeDB) logSQL(query string, args []interface{}, err error) {
if err != nil {
msg := fmt.Sprintf("SQL: %s DATA: %v ERROR: %v", query, args, err)
that.testLogBufMu.Lock()
if that.testLogBuf != nil {
that.testLogBuf.WriteString(msg + "\n")
atomic.AddInt32(&that.testSQLErrCount, 1)
} else if that.Log != nil {
that.Log.Error().Msg(msg)
}
that.testLogBufMu.Unlock()
return
}
// 无错误时只在 logLevel > 0 时打印 DEBUG 级别
if that.Log != nil && that.Log.GetLevel() > 0 {
msg := fmt.Sprintf("SQL: %s DATA: %v", query, args)
that.testLogBufMu.Lock()
if that.testLogBuf != nil {
that.testLogBuf.WriteString(msg + "\n")
} else {
that.Log.Debug().Msg(msg)
}
that.testLogBufMu.Unlock()
}
}
// processArgs 处理参数中的 slice 类型
@@ -358,34 +412,204 @@ func (that *HoTimeDB) expandArrayPlaceholder(query string, args []interface{}) (
return result.String(), newArgs
}
const (
dbTypeUnknown = iota
dbTypeInteger
dbTypeFloat
dbTypeDecimal
)
// classifyDBType 将数据库类型名分类,覆盖 MySQL/SQLite/PostgreSQL
func classifyDBType(typeName string) int {
t := strings.ToUpper(strings.TrimSpace(typeName))
t = strings.TrimSuffix(strings.TrimSuffix(t, " UNSIGNED"), " SIGNED")
t = strings.TrimSpace(t)
base := t
if idx := strings.IndexByte(t, '('); idx > 0 {
base = t[:idx]
}
switch base {
case "INT", "INTEGER", "BIGINT", "TINYINT", "SMALLINT", "MEDIUMINT",
"INT2", "INT4", "INT8",
"SERIAL", "BIGSERIAL", "SMALLSERIAL",
"YEAR", "BOOL", "BOOLEAN", "OID":
return dbTypeInteger
case "DECIMAL", "NUMERIC", "NEWDECIMAL", "NUMBER":
return dbTypeDecimal
case "FLOAT", "DOUBLE", "REAL", "FLOAT4", "FLOAT8", "DOUBLE PRECISION":
return dbTypeFloat
}
if strings.Contains(base, "INT") {
return dbTypeInteger
}
if strings.Contains(base, "DECIMAL") || strings.Contains(base, "NUMERIC") {
return dbTypeDecimal
}
if strings.Contains(base, "DOUBLE") || strings.Contains(base, "FLOAT") {
return dbTypeFloat
}
return dbTypeUnknown
}
// getDecimalScale 提取 DECIMAL/NUMERIC 精度,失败返回 -1
func getDecimalScale(colType *sql.ColumnType, typeName string) int {
_, scale, ok := colType.DecimalSize()
if ok && scale >= 0 {
return int(scale)
}
// SQLite 不支持 DecimalSize,从类型名解析 "DECIMAL(10,2)" → 2
upper := strings.ToUpper(typeName)
if idx := strings.Index(upper, ","); idx > 0 {
rest := upper[idx+1:]
if end := strings.IndexByte(rest, ')'); end > 0 {
if s, err := strconv.Atoi(strings.TrimSpace(rest[:end])); err == nil {
return s
}
}
}
return -1
}
// getFloatScale 提取 FLOAT/DOUBLE 显式精度,无显式精度返回 -1 表示不截断
func getFloatScale(colType *sql.ColumnType, typeName string) int {
_, scale, ok := colType.DecimalSize()
if ok && scale > 0 && scale < 20 {
return int(scale)
}
return -1
}
// fixFloatValue 对 float64 值做类型感知精度修正
func fixFloatValue(f float64, category int, scale int) interface{} {
if math.IsNaN(f) || math.IsInf(f, 0) {
return float64(0)
}
switch category {
case dbTypeInteger:
return int64(math.Round(f))
case dbTypeDecimal:
return roundFloat(f, scale)
case dbTypeFloat:
return roundFloat(f, scale)
default:
return f
}
}
// roundFloat 使用 strconv 精确舍入,避免 f*pow 中间值产生精度误差
func roundFloat(f float64, scale int) interface{} {
if math.IsNaN(f) || math.IsInf(f, 0) {
return float64(0)
}
if scale == 0 {
// DECIMAL(10,0) 明确是整数
return math.Round(f)
}
if scale < 0 {
// 精度未知,不修正,原样返回
return f
}
s := strconv.FormatFloat(f, 'f', scale, 64)
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return f
}
return v
}
// convertBytes 对 []byte 类型的列做类型感知解析
// MySQL 文本协议和 PG 的 DECIMAL/NUMERIC 以 []byte 返回,需要按列类型解析为正确 Go 类型
func convertBytes(raw []byte, category int, scale int) interface{} {
s := string(raw)
switch category {
case dbTypeInteger:
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
return n
}
if f, err := strconv.ParseFloat(s, 64); err == nil {
return int64(math.Round(f))
}
return s
case dbTypeDecimal:
if f, err := strconv.ParseFloat(s, 64); err == nil {
return roundFloat(f, scale)
}
return s
case dbTypeFloat:
if f, err := strconv.ParseFloat(s, 64); err == nil {
return roundFloat(f, scale)
}
return s
default:
return s
}
}
// Row 数据库数据解析
func (that *HoTimeDB) Row(resl *sql.Rows) []Map {
defer resl.Close()
dest := make([]Map, 0)
strs, _ := resl.Columns()
colTypes, _ := resl.ColumnTypes()
for i := 0; resl.Next(); i++ {
lis := make(Map, 0)
a := make([]interface{}, len(strs))
colCount := len(strs)
categories := make([]int, colCount)
scales := make([]int, colCount)
if colTypes != nil {
for j := 0; j < len(colTypes) && j < colCount; j++ {
typeName := colTypes[j].DatabaseTypeName()
categories[j] = classifyDBType(typeName)
switch categories[j] {
case dbTypeDecimal:
scales[j] = getDecimalScale(colTypes[j], typeName)
case dbTypeFloat:
scales[j] = getFloatScale(colTypes[j], typeName)
}
}
}
b := make([]interface{}, len(a))
for j := 0; j < len(a); j++ {
for resl.Next() {
lis := make(Map, colCount)
a := make([]interface{}, colCount)
b := make([]interface{}, colCount)
for j := 0; j < colCount; j++ {
b[j] = &a[j]
}
err := resl.Scan(b...)
if err != nil {
that.LastErr.SetError(err)
if err := resl.Scan(b...); err != nil {
dbErr := &DBError{}
dbErr.SetError(err)
that.lastError.Store(dbErr)
return nil
}
for j := 0; j < len(a); j++ {
if a[j] != nil && reflect.ValueOf(a[j]).Type().String() == "[]uint8" {
lis[strs[j]] = string(a[j].([]byte))
} else {
lis[strs[j]] = a[j] // 取实际类型
for j := 0; j < colCount; j++ {
colKey := strs[j]
val := a[j]
if val == nil {
lis[colKey] = nil
continue
}
switch v := val.(type) {
case []byte:
lis[colKey] = convertBytes(v, categories[j], scales[j])
case float64:
lis[colKey] = fixFloatValue(v, categories[j], scales[j])
case float32:
lis[colKey] = fixFloatValue(float64(v), categories[j], scales[j])
case time.Time:
// DM8驱动直接返回time.Time,格式化为MySQL兼容字符串
lis[colKey] = v.Format("2006-01-02 15:04:05")
default:
lis[colKey] = val
}
}
dest = append(dest, lis)
}
if err := resl.Err(); err != nil {
dbErr := &DBError{}
dbErr.SetError(err)
that.lastError.Store(dbErr)
}
return dest
}
+245
View File
@@ -0,0 +1,245 @@
package db
import (
"math"
"testing"
)
// ─────────────────────────────────────────────
// classifyDBType
// ─────────────────────────────────────────────
func TestClassifyDBType(t *testing.T) {
cases := []struct {
typeName string
want int
}{
// MySQL 整数类型
{"INT", dbTypeInteger},
{"TINYINT", dbTypeInteger},
{"SMALLINT", dbTypeInteger},
{"MEDIUMINT", dbTypeInteger},
{"BIGINT", dbTypeInteger},
{"INT UNSIGNED", dbTypeInteger},
{"BIGINT UNSIGNED", dbTypeInteger},
{"TINYINT(1)", dbTypeInteger},
{"YEAR", dbTypeInteger},
{"BOOL", dbTypeInteger},
{"BOOLEAN", dbTypeInteger},
// PostgreSQL 整数类型
{"INT2", dbTypeInteger},
{"INT4", dbTypeInteger},
{"INT8", dbTypeInteger},
{"SERIAL", dbTypeInteger},
{"BIGSERIAL", dbTypeInteger},
{"SMALLSERIAL", dbTypeInteger},
{"OID", dbTypeInteger},
// DECIMAL / NUMERIC
{"DECIMAL", dbTypeDecimal},
{"DECIMAL(10,2)", dbTypeDecimal},
{"NUMERIC", dbTypeDecimal},
{"NUMERIC(8,4)", dbTypeDecimal},
{"NEWDECIMAL", dbTypeDecimal},
// FLOAT / DOUBLE
{"FLOAT", dbTypeFloat},
{"DOUBLE", dbTypeFloat},
{"REAL", dbTypeFloat},
{"FLOAT4", dbTypeFloat},
{"FLOAT8", dbTypeFloat},
{"DOUBLE PRECISION", dbTypeFloat},
// 未知类型
{"VARCHAR", dbTypeUnknown},
{"TEXT", dbTypeUnknown},
{"DATETIME", dbTypeUnknown},
{"TIMESTAMP", dbTypeUnknown},
{"BLOB", dbTypeUnknown},
{"", dbTypeUnknown},
}
for _, c := range cases {
t.Run(c.typeName, func(t *testing.T) {
got := classifyDBType(c.typeName)
if got != c.want {
t.Errorf("classifyDBType(%q) = %d, want %d", c.typeName, got, c.want)
}
})
}
}
// ─────────────────────────────────────────────
// fixFloatValue
// ─────────────────────────────────────────────
func TestFixFloatValue(t *testing.T) {
t.Run("integer category: round to int64", func(t *testing.T) {
// 这是核心场景:float64 精度噪声导致整数变为 1.9999...
got := fixFloatValue(1.9999999999999998, dbTypeInteger, 0)
if got != int64(2) {
t.Errorf("got %v (%T), want int64(2)", got, got)
}
})
t.Run("integer category: 2.0000000000001 rounds to 2", func(t *testing.T) {
got := fixFloatValue(2.0000000000001, dbTypeInteger, 0)
if got != int64(2) {
t.Errorf("got %v (%T), want int64(2)", got, got)
}
})
t.Run("decimal category: scale=2 roundtrip precision", func(t *testing.T) {
got := fixFloatValue(1.1999999999999999, dbTypeDecimal, 2)
f, ok := got.(float64)
if !ok {
t.Fatalf("expected float64, got %T", got)
}
if f != 1.2 {
t.Errorf("got %v, want 1.2", f)
}
})
t.Run("decimal category: scale=2, 1.20000000001 -> 1.2", func(t *testing.T) {
got := fixFloatValue(1.20000000001, dbTypeDecimal, 2)
f := got.(float64)
if f != 1.2 {
t.Errorf("got %v, want 1.2", f)
}
})
t.Run("decimal category: scale=-1 (unknown), no modification", func(t *testing.T) {
got := fixFloatValue(1.23456789, dbTypeDecimal, -1)
if got != 1.23456789 {
t.Errorf("got %v, want 1.23456789", got)
}
})
t.Run("decimal category: scale=0, round to whole number", func(t *testing.T) {
got := fixFloatValue(2.7, dbTypeDecimal, 0)
if got != float64(3) {
t.Errorf("got %v (%T), want float64(3)", got, got)
}
})
t.Run("float category: scale=-1 no modification", func(t *testing.T) {
got := fixFloatValue(3.14159265, dbTypeFloat, -1)
if got != 3.14159265 {
t.Errorf("got %v, want 3.14159265", got)
}
})
t.Run("unknown category: passthrough", func(t *testing.T) {
got := fixFloatValue(99.9, dbTypeUnknown, -1)
if got != 99.9 {
t.Errorf("got %v, want 99.9", got)
}
})
t.Run("NaN -> 0", func(t *testing.T) {
got := fixFloatValue(math.NaN(), dbTypeInteger, 0)
if got != float64(0) {
t.Errorf("got %v, want 0", got)
}
})
t.Run("+Inf -> 0", func(t *testing.T) {
got := fixFloatValue(math.Inf(1), dbTypeDecimal, 2)
if got != float64(0) {
t.Errorf("got %v, want 0", got)
}
})
}
// ─────────────────────────────────────────────
// roundFloat
// ─────────────────────────────────────────────
func TestRoundFloat(t *testing.T) {
t.Run("scale=2: 1.19999... -> 1.2", func(t *testing.T) {
got := roundFloat(1.1999999999999999, 2)
if got != float64(1.2) {
t.Errorf("got %v, want 1.2", got)
}
})
t.Run("scale=2: 1.20000...1 -> 1.2", func(t *testing.T) {
got := roundFloat(1.20000000001, 2)
if got != float64(1.2) {
t.Errorf("got %v, want 1.2", got)
}
})
t.Run("scale=0: round to integer float", func(t *testing.T) {
got := roundFloat(2.5, 0)
if got != float64(3) {
t.Errorf("got %v, want 3.0", got)
}
})
t.Run("scale=-1: passthrough unchanged", func(t *testing.T) {
got := roundFloat(1.23456789, -1)
if got != 1.23456789 {
t.Errorf("got %v, want 1.23456789", got)
}
})
t.Run("NaN -> 0", func(t *testing.T) {
got := roundFloat(math.NaN(), 2)
if got != float64(0) {
t.Errorf("got %v, want 0", got)
}
})
}
// ─────────────────────────────────────────────
// convertBytes
// ─────────────────────────────────────────────
func TestConvertBytes(t *testing.T) {
t.Run("integer category: '2' -> int64(2)", func(t *testing.T) {
got := convertBytes([]byte("2"), dbTypeInteger, 0)
if got != int64(2) {
t.Errorf("got %v (%T), want int64(2)", got, got)
}
})
t.Run("integer category: '2.00' -> int64(2)", func(t *testing.T) {
got := convertBytes([]byte("2.00"), dbTypeInteger, 0)
if got != int64(2) {
t.Errorf("got %v (%T), want int64(2)", got, got)
}
})
t.Run("decimal category: '1.20' scale=2 -> float64(1.2)", func(t *testing.T) {
got := convertBytes([]byte("1.20"), dbTypeDecimal, 2)
if got != float64(1.2) {
t.Errorf("got %v (%T), want float64(1.2)", got, got)
}
})
t.Run("decimal category: '0.00' scale=2 -> float64(0)", func(t *testing.T) {
got := convertBytes([]byte("0.00"), dbTypeDecimal, 2)
if got != float64(0) {
t.Errorf("got %v (%T), want float64(0)", got, got)
}
})
t.Run("float category: '3.14' scale=-1 -> float64(3.14)", func(t *testing.T) {
got := convertBytes([]byte("3.14"), dbTypeFloat, -1)
if got != float64(3.14) {
t.Errorf("got %v (%T), want float64(3.14)", got, got)
}
})
t.Run("unknown category: passthrough as string", func(t *testing.T) {
got := convertBytes([]byte("hello"), dbTypeUnknown, 0)
if got != "hello" {
t.Errorf("got %v (%T), want 'hello'", got, got)
}
})
t.Run("integer category: invalid bytes -> string fallback", func(t *testing.T) {
got := convertBytes([]byte("abc"), dbTypeInteger, 0)
if got != "abc" {
t.Errorf("got %v (%T), want 'abc'", got, got)
}
})
}
+59 -8
View File
@@ -1,12 +1,20 @@
package db
import (
"fmt"
"sync"
"sync/atomic"
)
var savepointCounter uint64
// Action 事务操作
// 如果 action 返回 true 则提交事务;返回 false 则回滚
// 测试模式下(testTx != nil),使用 SAVEPOINT 代替真事务,确保嵌套事务不会绕过外层测试事务
func (that *HoTimeDB) Action(action func(db HoTimeDB) (isSuccess bool)) (isSuccess bool) {
that.testLogBufMu.Lock()
logBuf := that.testLogBuf
that.testLogBufMu.Unlock()
db := HoTimeDB{
DB: that.DB,
ContextBase: that.ContextBase,
@@ -15,23 +23,57 @@ func (that *HoTimeDB) Action(action func(db HoTimeDB) (isSuccess bool)) (isSucce
Log: that.Log,
Type: that.Type,
Prefix: that.Prefix,
LastQuery: that.LastQuery,
LastData: that.LastData,
ConnectFunc: that.ConnectFunc,
LastErr: that.LastErr,
limit: that.limit,
Tx: that.Tx,
SlaveDB: that.SlaveDB,
Mode: that.Mode,
Dialect: that.Dialect,
mu: sync.RWMutex{},
limitMu: sync.Mutex{},
testTx: that.testTx,
testMu: that.testMu,
testLogBuf: logBuf,
}
txFailed := false
db.txFailed = &txFailed
if that.testTx != nil {
spName := fmt.Sprintf("sp_%d", atomic.AddUint64(&savepointCounter, 1))
if that.testMu != nil {
that.testMu.Lock()
}
_, _ = that.testTx.Exec("SAVEPOINT " + spName)
if that.testMu != nil {
that.testMu.Unlock()
}
db.Tx = that.testTx
isSuccess = action(db)
if txFailed || !isSuccess {
if that.testMu != nil {
that.testMu.Lock()
}
_, _ = that.testTx.Exec("ROLLBACK TO SAVEPOINT " + spName)
if that.testMu != nil {
that.testMu.Unlock()
}
return false
}
if that.testMu != nil {
that.testMu.Lock()
}
_, _ = that.testTx.Exec("RELEASE SAVEPOINT " + spName)
if that.testMu != nil {
that.testMu.Unlock()
}
return isSuccess
}
tx, err := db.Begin()
if err != nil {
that.LastErr.SetError(err)
dbErr := &DBError{}
dbErr.SetError(err)
that.lastError.Store(dbErr)
return isSuccess
}
@@ -39,10 +81,17 @@ func (that *HoTimeDB) Action(action func(db HoTimeDB) (isSuccess bool)) (isSucce
isSuccess = action(db)
if txFailed {
_ = db.Tx.Rollback()
return false
}
if !isSuccess {
err = db.Tx.Rollback()
if err != nil {
that.LastErr.SetError(err)
dbErr := &DBError{}
dbErr.SetError(err)
that.lastError.Store(dbErr)
return isSuccess
}
return isSuccess
@@ -50,7 +99,9 @@ func (that *HoTimeDB) Action(action func(db HoTimeDB) (isSuccess bool)) (isSucce
err = db.Tx.Commit()
if err != nil {
that.LastErr.SetError(err)
dbErr := &DBError{}
dbErr.SetError(err)
that.lastError.Store(dbErr)
return false
}
return true
+246
View File
@@ -0,0 +1,246 @@
package db
import (
. "code.hoteas.com/golang/hotime/common"
"code.hoteas.com/golang/hotime/log"
"database/sql"
"sync"
"testing"
)
func newTestDB(t *testing.T) *HoTimeDB {
t.Helper()
sqlDB, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatal(err)
}
db := &HoTimeDB{
DB: sqlDB,
Type: "sqlite3",
Dialect: &SQLiteDialect{},
Log: log.NewTestLogger(),
}
_, execErr := sqlDB.Exec("CREATE TABLE test_item (id INTEGER PRIMARY KEY, name TEXT, value INTEGER)")
if execErr != nil {
t.Fatal(execErr)
}
_, execErr = sqlDB.Exec("INSERT INTO test_item (id, name, value) VALUES (1, 'foo', 100)")
if execErr != nil {
t.Fatal(execErr)
}
return db
}
func newTestDBWithTestTx(t *testing.T) *HoTimeDB {
t.Helper()
db := newTestDB(t)
tx, err := db.DB.Begin()
if err != nil {
t.Fatal(err)
}
db.testTx = tx
db.testMu = &sync.Mutex{}
return db
}
func TestAction_TxFailedForceRollback(t *testing.T) {
db := newTestDB(t)
defer db.DB.Close()
result := db.Action(func(tx HoTimeDB) (isSuccess bool) {
tx.Update("test_item", Map{"value": 999}, Map{"id": 1})
tx.Exec("THIS IS INVALID SQL THAT WILL FAIL")
return true
})
if result != false {
t.Fatalf("Action should return false when SQL error occurred, got true")
}
row := db.Get("test_item", "value", Map{"id": 1})
if row == nil {
t.Fatal("failed to read test_item")
}
val := row.GetCeilInt64("value")
if val != 100 {
t.Fatalf("value should be rolled back to 100, got %d", val)
}
}
func TestAction_NormalCommit(t *testing.T) {
db := newTestDB(t)
defer db.DB.Close()
result := db.Action(func(tx HoTimeDB) (isSuccess bool) {
tx.Update("test_item", Map{"value": 200}, Map{"id": 1})
return true
})
if result != true {
t.Fatalf("Action should return true on success, got false")
}
row := db.Get("test_item", "value", Map{"id": 1})
if row == nil {
t.Fatal("failed to read test_item")
}
val := row.GetCeilInt64("value")
if val != 200 {
t.Fatalf("value should be committed to 200, got %d", val)
}
}
func TestAction_NormalRollback(t *testing.T) {
db := newTestDB(t)
defer db.DB.Close()
result := db.Action(func(tx HoTimeDB) (isSuccess bool) {
tx.Update("test_item", Map{"value": 300}, Map{"id": 1})
return false
})
if result != false {
t.Fatalf("Action should return false, got true")
}
row := db.Get("test_item", "value", Map{"id": 1})
if row == nil {
t.Fatal("failed to read test_item")
}
val := row.GetCeilInt64("value")
if val != 100 {
t.Fatalf("value should be rolled back to 100, got %d", val)
}
}
func TestAction_SqlErrorThenReturnTrue_MustRollback(t *testing.T) {
db := newTestDB(t)
defer db.DB.Close()
result := db.Action(func(tx HoTimeDB) (isSuccess bool) {
tx.Update("test_item", Map{"value": 500}, Map{"id": 1})
tx.Exec("INSERT INTO nonexistent_table (x) VALUES (1)")
tx.Update("test_item", Map{"value": 600}, Map{"id": 1})
return true
})
if result != false {
t.Fatalf("Action should return false when SQL error occurred mid-transaction, got true")
}
row := db.Get("test_item", "value", Map{"id": 1})
if row == nil {
t.Fatal("failed to read test_item")
}
val := row.GetCeilInt64("value")
if val != 100 {
t.Fatalf("value should be rolled back to 100 (original), got %d", val)
}
}
// --- testTx (SAVEPOINT) 模式测试 ---
func TestAction_TestTx_SqlErrorForceRollbackSavepoint(t *testing.T) {
db := newTestDBWithTestTx(t)
defer db.testTx.Rollback()
defer db.DB.Close()
db.testTx.Exec("UPDATE test_item SET value = 100 WHERE id = 1")
result := db.Action(func(tx HoTimeDB) (isSuccess bool) {
tx.Update("test_item", Map{"value": 888}, Map{"id": 1})
tx.Exec("THIS IS INVALID SQL")
return true
})
if result != false {
t.Fatalf("Action (testTx mode) should return false when SQL error occurred, got true")
}
db.testMu.Lock()
rows, err := db.testTx.Query("SELECT value FROM test_item WHERE id = 1")
db.testMu.Unlock()
if err != nil {
t.Fatal(err)
}
defer rows.Close()
if !rows.Next() {
t.Fatal("no row found")
}
var val int64
rows.Scan(&val)
if val != 100 {
t.Fatalf("value should be rolled back to 100 via SAVEPOINT, got %d", val)
}
}
func TestAction_TestTx_NormalCommitSavepoint(t *testing.T) {
db := newTestDBWithTestTx(t)
defer db.testTx.Rollback()
defer db.DB.Close()
result := db.Action(func(tx HoTimeDB) (isSuccess bool) {
tx.Update("test_item", Map{"value": 777}, Map{"id": 1})
return true
})
if result != true {
t.Fatalf("Action (testTx mode) should return true on success, got false")
}
db.testMu.Lock()
rows, err := db.testTx.Query("SELECT value FROM test_item WHERE id = 1")
db.testMu.Unlock()
if err != nil {
t.Fatal(err)
}
defer rows.Close()
if !rows.Next() {
t.Fatal("no row found")
}
var val int64
rows.Scan(&val)
if val != 777 {
t.Fatalf("value should be 777 after RELEASE SAVEPOINT, got %d", val)
}
}
func TestAction_TestTx_NormalRollbackSavepoint(t *testing.T) {
db := newTestDBWithTestTx(t)
defer db.testTx.Rollback()
defer db.DB.Close()
db.testTx.Exec("UPDATE test_item SET value = 100 WHERE id = 1")
result := db.Action(func(tx HoTimeDB) (isSuccess bool) {
tx.Update("test_item", Map{"value": 666}, Map{"id": 1})
return false
})
if result != false {
t.Fatalf("Action (testTx mode) should return false, got true")
}
db.testMu.Lock()
rows, err := db.testTx.Query("SELECT value FROM test_item WHERE id = 1")
db.testMu.Unlock()
if err != nil {
t.Fatal(err)
}
defer rows.Close()
if !rows.Next() {
t.Fatal("no row found")
}
var val int64
rows.Scan(&val)
if val != 100 {
t.Fatalf("value should be rolled back to 100 via SAVEPOINT, got %d", val)
}
}
+27 -27
View File
@@ -244,39 +244,12 @@ func (that *HoTimeDB) varCond(k string, v interface{}) (string, []interface{}) {
k = strings.Replace(k, "[#]", "", -1)
k = processor.ProcessColumn(k) + " "
where += " " + k + "=" + ObjToStr(v) + " "
case "[##]": // 直接添加value到sql,需要考虑防注入
where += " " + ObjToStr(v)
case "[#!]":
k = strings.Replace(k, "[#!]", "", -1)
k = processor.ProcessColumn(k) + " "
where += " " + k + "!=" + ObjToStr(v) + " "
case "[!#]":
k = strings.Replace(k, "[!#]", "", -1)
k = processor.ProcessColumn(k) + " "
where += " " + k + "!=" + ObjToStr(v) + " "
case "[~]":
k = strings.Replace(k, "[~]", "", -1)
k = processor.ProcessColumn(k) + " "
where += k + " LIKE ? "
v = "%" + ObjToStr(v) + "%"
res = append(res, v)
case "[!~]": // 左边任意
k = strings.Replace(k, "[!~]", "", -1)
k = processor.ProcessColumn(k) + " "
where += k + " LIKE ? "
v = "%" + ObjToStr(v) + ""
res = append(res, v)
case "[~!]": // 右边任意
k = strings.Replace(k, "[~!]", "", -1)
k = processor.ProcessColumn(k) + " "
where += k + " LIKE ? "
v = ObjToStr(v) + "%"
res = append(res, v)
case "[~~]": // 手动任意
k = strings.Replace(k, "[~~]", "", -1)
k = processor.ProcessColumn(k) + " "
where += k + " LIKE ? "
res = append(res, v)
default:
def = true
}
@@ -307,6 +280,33 @@ func (that *HoTimeDB) varCond(k string, v interface{}) (string, []interface{}) {
vs := ObjToSlice(v)
res = append(res, vs[0])
res = append(res, vs[1])
case "[##]":
where += " " + ObjToStr(v)
case "[#!]":
k = strings.Replace(k, "[#!]", "", -1)
k = processor.ProcessColumn(k) + " "
where += " " + k + "!=" + ObjToStr(v) + " "
case "[!#]":
k = strings.Replace(k, "[!#]", "", -1)
k = processor.ProcessColumn(k) + " "
where += " " + k + "!=" + ObjToStr(v) + " "
case "[!~]": // 左边任意
k = strings.Replace(k, "[!~]", "", -1)
k = processor.ProcessColumn(k) + " "
where += k + " LIKE ? "
v = "%" + ObjToStr(v) + ""
res = append(res, v)
case "[~!]": // 右边任意
k = strings.Replace(k, "[~!]", "", -1)
k = processor.ProcessColumn(k) + " "
where += k + " LIKE ? "
v = ObjToStr(v) + "%"
res = append(res, v)
case "[~~]": // 手动任意
k = strings.Replace(k, "[~~]", "", -1)
k = processor.ProcessColumn(k) + " "
where += k + " LIKE ? "
res = append(res, v)
default:
where, res = that.handleDefaultCondition(k, v, where, res)
}
+1 -1
View File
@@ -628,7 +628,7 @@ flow 配置:
"auth": ["show", "add", "delete", "edit", "info"]
},
{
"label": "员管理",
"label": "员管理",
"table": "admin",
"auth": ["show", "add", "delete", "edit", "info", "download"]
}
+28 -4
View File
@@ -17,7 +17,7 @@
代码生成器可以:
1. **自动读取数据库表结构** - 支持 MySQLSQLite
1. **自动读取数据库表结构** - 支持 MySQLSQLite 和达梦 DM8
2. **生成 CRUD 接口** - 增删改查、搜索、分页
3. **生成配置文件** - 表字段配置、菜单配置、权限配置
4. **智能字段识别** - 根据字段名自动识别类型和权限
@@ -148,12 +148,15 @@ func main() {
支持从数据库字段备注中提取信息:
```sql
-- 字段备注格式: 标签名:选项1-名称1,选项2-名称2 {提示信息}
-- 字段备注格式: 标签名(提示信息):选项1-名称1,选项2-名称2
-- 括号支持三种写法:()、()、{}
-- 例如:
status TINYINT COMMENT '状态:0-禁用,1-启用 {用户账号状态}'
status TINYINT COMMENT '状态(用户账号状态):0-禁用,1-启用'
parent_id INT COMMENT '父级ID(顶级为NULL'
phone VARCHAR(20) COMMENT '手机号(请输入11位手机号)'
```
生成的配置:
生成的配置(以第一行为例)
```json
{
@@ -168,6 +171,10 @@ status TINYINT COMMENT '状态:0-禁用,1-启用 {用户账号状态}'
}
```
`ps` 字段在前端的展示效果:
- 编辑/新增页面:输入框右侧灰色小字提示
- 表格/详情页面:鼠标悬停字段名时气泡提示
---
## 自定义规则
@@ -459,6 +466,23 @@ content -- 内容(自动识别为 textArea
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 '用户名';
```
---
## 相关文档
+90 -11
View File
@@ -191,6 +191,7 @@ SELECT * FROM org WHERE parent_ids LIKE '%,3,%' -- 查询id=3的所有子级
| MySQL | datetime | `2024-01-15 10:30:00` |
| SQLite | TEXT | `2024-01-15 10:30:00` |
| PostgreSQL | timestamp | `2024-01-15 10:30:00` |
| 达梦 DM8 | TIMESTAMP | `2024-01-15 10:30:00` |
---
@@ -228,15 +229,17 @@ SQLite 不支持表备注,代码生成器会使用表名作为默认显示名
字段注释支持以下格式组合:
```
显示名称:选项值 附加备注{前端提示}
显示名称(前端提示):选项值 附加备注
显示名称(前端提示):选项值 附加备注
显示名称{前端提示}:选项值 附加备注
```
| 部分 | 分隔符 | 用途 |
|------|--------|------|
| 显示名称 | 无 | 前端表单/列表的字段标签 |
| 前端提示 | `()``()``{}` | 存储到 `ps` 字段,前端用于字段说明提示(编辑页输入框右侧、表格/详情鼠标悬停气泡) |
| 选项值 | `:` 冒号 | select 类型的下拉选项(格式:`值-名称,值-名称` |
| 附加备注 | 空格 | 仅在数据库中查看,不传递给前端 |
| 前端提示 | `{}` | 存储到 `ps` 字段,用于前端显示提示文字 |
### 选择类型字段
@@ -267,28 +270,38 @@ SQLite 不支持表备注,代码生成器会使用表名作为默认显示名
`amount` decimal(10,2) COMMENT '金额 单位为元,精确到分'
```
### 前端提示({}内)
### 前端提示(括号内)
`{}` 中的内容存储到 `ps` 字段,前端可用于显示输入提示:
`()``()``{}` 中的内容存储到 `ps` 字段,前端展示为字段说明提示。推荐使用 `()``()``{}` 为兼容旧格式保留。
```sql
`phone` varchar(20) COMMENT '手机号{请输入11位手机号}'
`parent_id` int(11) COMMENT '父级ID(顶级为NULL'
`status` int(2) COMMENT '状态(当前审核状态):0-待审,1-通过,2-驳回'
`phone` varchar(20) COMMENT '手机号(请输入11位手机号)'
`email` varchar(100) COMMENT '邮箱{格式:xxx@xxx.com}'
`content` text COMMENT '内容{支持HTML格式}'
```
前端展示效果:
- **编辑/新增页面**:输入框右侧显示灰色小字提示
- **表格列表/详情页面**:鼠标悬停字段名时显示气泡提示
### 组合使用
```sql
-- 完整格式示例
`status` int(2) DEFAULT '0' COMMENT '状态:0-待审核,1-已通过,2-已拒绝 业务状态流转字段{选择当前审核状态}'
-- 推荐写法:使用圆括号
`status` int(2) DEFAULT '0' COMMENT '状态(选择当前审核状态):0-待审核,1-已通过,2-已拒绝'
`parent_id` int(11) DEFAULT NULL COMMENT '父级ID(顶级为NULL'
-- 兼容写法:使用花括号
`status` int(2) DEFAULT '0' COMMENT '状态{选择当前审核状态}:0-待审核,1-已通过,2-已拒绝'
```
解析结果:
解析结果(以第一行为例)
- `label` = "状态"
- `options` = [{name:"待审核", value:"0"}, {name:"已通过", value:"1"}, {name:"已拒绝", value:"2"}]
- `ps` = "选择当前审核状态"
- 空格后的"业务状态流转字段"不会传递给前端
- `options` = [{name:"待审核", value:"0"}, {name:"已通过", value:"1"}, {name:"已拒绝", value:"2"}]
三种括号 `()``()``{}` 功能相同,优先匹配顺序为:`()` > `()` > `{}`,同一注释中只取第一个匹配到的括号对。
### SQLite 字段备注
@@ -324,6 +337,14 @@ SQLite 不支持字段备注(COMMENT),代码生成器会使用字段名作
"modify_time" TIMESTAMP DEFAULT NULL, -- 变更时间
```
### 达梦 DM8
```sql
"state" INT DEFAULT 0, -- 状态:0-正常,1-异常,2-隐藏
"create_time" TIMESTAMP DEFAULT NULL, -- 创建日期
"modify_time" TIMESTAMP DEFAULT NULL, -- 变更时间
```
---
## 完整建表示例
@@ -392,6 +413,56 @@ CREATE TABLE "user" (
);
```
### 达梦 DM8 示例
达梦建表有以下关键差异:
- 自增主键使用 `IDENTITY(1,1)`(对应 MySQL 的 `AUTO_INCREMENT`
- 长文本使用 `CLOB`(对应 MySQL 的 `LONGTEXT`
- 标识符建议用**双引号包裹小写名称**,与框架保持一致
- `admin``user``order` 等是 DM 保留字,必须加双引号
```sql
-- 用户表
CREATE TABLE "user" (
"id" INT IDENTITY(1,1) PRIMARY KEY,
"name" VARCHAR(50) DEFAULT NULL,
"phone" VARCHAR(20) DEFAULT NULL,
"password" VARCHAR(64) DEFAULT NULL,
"org_id" INT DEFAULT NULL,
"role_id" INT DEFAULT NULL,
"avatar" VARCHAR(255) DEFAULT NULL,
"sex" INT DEFAULT 0,
"state" INT DEFAULT 0,
"create_time" TIMESTAMP DEFAULT NULL,
"modify_time" TIMESTAMP DEFAULT NULL
);
-- 组织表(树形结构)
CREATE TABLE "org" (
"id" INT IDENTITY(1,1) PRIMARY KEY,
"name" VARCHAR(100) DEFAULT NULL,
"parent_id" INT DEFAULT NULL,
"parent_ids" VARCHAR(255) DEFAULT NULL,
"level" INT DEFAULT 0,
"sort" INT DEFAULT 0,
"state" INT DEFAULT 0,
"create_time" TIMESTAMP DEFAULT NULL,
"modify_time" TIMESTAMP DEFAULT NULL
);
-- 用户组织关联表
CREATE TABLE "user_org" (
"id" INT IDENTITY(1,1) PRIMARY KEY,
"user_id" INT DEFAULT NULL,
"org_id" INT DEFAULT NULL,
"state" INT DEFAULT 0,
"create_time" TIMESTAMP DEFAULT NULL,
"modify_time" TIMESTAMP DEFAULT NULL
);
```
> **注意**:达梦中 DDL 的表名/列名大小写敏感(双引号包裹时),建议统一使用小写,与框架自动生成的 SQL 保持一致。
---
## 规范检查清单
@@ -406,3 +477,11 @@ CREATE TABLE "user" (
- [ ] 树形表包含 parent_id、parent_ids、level
- [ ] 所有表包含 state、create_time、modify_time
- [ ] 选择字段注释格式正确(`标签:值-名称`
### 达梦 DM8 额外检查项
- [ ] 建表时标识符使用**双引号包裹小写名称**(如 `"user"``"id"`
- [ ] 保留字(`admin``user``order``key` 等)必须加双引号
- [ ] 自增主键使用 `IDENTITY(1,1)` 而非 `AUTO_INCREMENT`
- [ ] 长文本字段使用 `CLOB` 而非 `LONGTEXT`
- [ ] 检测表是否存在时用 `COUNT(*)` 查询,不用 `USER_TABLES`(存在 schema 错配问题)
+72 -6
View File
@@ -107,10 +107,11 @@ affected := database.Inserts("table", []Map{
})
// 返回受影响的行数
// 支持 [#] 标记直接 SQL
// 推荐:使用服务器时间(Time2Str),避免数据库时区差异
now := Time2Str(time.Now())
affected := database.Inserts("log", []Map{
{"user_id": 1, "created_time[#]": "NOW()"},
{"user_id": 2, "created_time[#]": "NOW()"},
{"user_id": 1, "created_time": now},
{"user_id": 2, "created_time": now},
})
```
@@ -223,10 +224,20 @@ data := database.Page(page, pageSize).PageSelect("table", "fields", whereMap)
### 直接SQL
| 写法 | SQL | 说明 |
|------|-----|------|
| `"field[#]": "NOW()"` | `field = NOW()` | 直接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 条件
@@ -391,6 +402,61 @@ database := &db.HoTimeDB{
// - 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
// 检查最后的错误
@@ -509,8 +575,8 @@ result := database.Table("order").
---
*快速参考版本: 2.0*
*更新日期: 2026年1*
*快速参考版本: 2.1*
*更新日期: 2026年3*
**详细说明:**
- [HoTimeDB 使用说明](HoTimeDB_使用说明.md) - 完整教程
+243 -33
View File
@@ -2,7 +2,7 @@
## 概述
HoTimeDB是一个基于Golang实现的轻量级ORM框架,参考PHP Medoo设计,提供简洁的数据库操作接口。支持MySQL、SQLite、PostgreSQL等数据库,并集成了缓存、事务、链式查询等功能。
HoTimeDB是一个基于Golang实现的轻量级ORM框架,参考PHP Medoo设计,提供简洁的数据库操作接口。支持MySQL、SQLite、PostgreSQL、达梦(DM8等数据库,并集成了缓存、事务、链式查询等功能。
## 目录
@@ -24,6 +24,7 @@ HoTimeDB是一个基于Golang实现的轻量级ORM框架,参考PHP Medoo设计
- [事务处理](#事务处理)
- [缓存机制](#缓存机制)
- [PostgreSQL支持](#postgresql支持)
- [达梦数据库支持](#达梦数据库支持)
- [高级特性](#高级特性)
## 快速开始
@@ -48,7 +49,7 @@ func createConnection() (master, slave *sql.DB) {
// 初始化HoTimeDB
database := &db.HoTimeDB{
Type: "mysql", // 可选:mysql, sqlite3, postgres
Type: "mysql", // 可选:mysql, sqlite3, postgres, dm
}
database.SetConnect(createConnection)
```
@@ -64,7 +65,7 @@ type HoTimeDB struct {
DBName string
*cache.HoTimeCache
Log *logrus.Logger
Type string // 数据库类型:mysql, sqlite3, postgres
Type string // 数据库类型:mysql, sqlite3, postgres, dm
Prefix string // 表前缀
LastQuery string // 最后执行的SQL
LastData []interface{} // 最后的参数
@@ -212,17 +213,27 @@ user := database.Get("user", "id,name,email", common.Map{
```go
// 基本插入
id := database.Insert("user", common.Map{
"name": "张三",
"email": "zhangsan@example.com",
"age": 25,
"status": 1,
"created_time[#]": "NOW()", // [#]表示直接插入SQL函数
"name": "张三",
"email": "zhangsan@example.com",
"age": 25,
"status": 1,
"created_time": Time2Str(time.Now()), // 推荐:使用服务器时间,避免数据库时区问题
})
// 返回插入的ID
fmt.Println("插入的用户ID:", id)
```
> **时间字段最佳实践**:建议使用 `Time2Str(time.Now())` 传入服务器时间,而非 `"[#]": "NOW()"` 让数据库执行 `NOW()`。
> 原因:`NOW()` 使用数据库服务器时间,当应用服务器与数据库服务器时区不同时会产生偏差;而 `Time2Str(time.Now())` 始终使用应用服务器时间,行为一致,且跨数据库(MySQL / 达梦 / PostgreSQL)完全兼容。
>
> ```go
> import (
> "time"
> . "code.hoteas.com/golang/hotime/common" // Time2Str 在 common 包中
> )
> ```
### 批量插入(Inserts)
```go
@@ -236,10 +247,11 @@ affected := database.Inserts("user", []common.Map{
fmt.Printf("批量插入 %d 条记录\n", affected)
// 支持 [#] 标记直接插入 SQL 表达式
// 推荐:使用服务器时间(Time2Str)而非数据库函数 NOW()
now := Time2Str(time.Now())
affected := database.Inserts("log", []common.Map{
{"user_id": 1, "action": "login", "created_time[#]": "NOW()"},
{"user_id": 2, "action": "logout", "created_time[#]": "NOW()"},
{"user_id": 1, "action": "login", "created_time": now},
{"user_id": 2, "action": "logout", "created_time": now},
})
```
@@ -248,9 +260,9 @@ affected := database.Inserts("log", []common.Map{
```go
// 基本更新
affected := database.Update("user", common.Map{
"name": "李四",
"email": "lisi@example.com",
"updated_time[#]": "NOW()",
"name": "李四",
"email": "lisi@example.com",
"modify_time": Time2Str(time.Now()), // 推荐:服务器时间
}, common.Map{
"id": 1,
})
@@ -291,6 +303,12 @@ affected := database.Upsert("user",
// INSERT INTO "user" (id,name,email,login_count) VALUES ($1,$2,$3,$4)
// ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, email=EXCLUDED.email, login_count=EXCLUDED.login_count
// 达梦 DM8 生成:
// MERGE INTO "user" USING (SELECT ? AS "id", ? AS "name", ...) src
// ON ("user"."id" = src."id")
// WHEN MATCHED THEN UPDATE SET "name"=src."name", "email"=src."email", ...
// WHEN NOT MATCHED THEN INSERT ("id","name","email","login_count") VALUES (src."id", ...)
// 使用 [#] 标记直接 SQL 更新
affected := database.Upsert("user",
common.Map{
@@ -349,8 +367,8 @@ user := database.Table("user").
affected := database.Table("user").
Where("id", 1).
Update(common.Map{
"name": "新名称",
"updated_time[#]": "NOW()",
"name": "新名称",
"modify_time": Time2Str(time.Now()),
})
// 链式删除
@@ -458,14 +476,14 @@ HoTimeDB支持丰富的条件查询语法,类似于Medoo:
### 直接SQL
```go
// 直接插入SQL表达式(注意防注入
"created_time[#]": "> DATE_SUB(NOW(), INTERVAL 1 DAY)"
// 字段直接赋值(不使用参数化查询)
"update_time[#]": "NOW()"
// 直接SQL片段
// 直接SQL片段(通常用于复杂条件,时间推荐用 Go 计算
"[##]": "user.status = 1 AND user.level > 0"
// 时间范围条件:推荐在 Go 侧计算,避免跨库 NOW() 差异
"created_time[>]": Time2Str(time.Now().AddDate(0, 0, -1)) // 一天前
// 如必须用数据库函数(不跨库时可用,但不推荐)
"update_time[#]": "NOW()"
```
## JOIN操作
@@ -637,10 +655,10 @@ success := database.Action(func(tx db.HoTimeDB) bool {
// 创建订单
orderId := tx.Insert("order", common.Map{
"user_id": 1,
"amount": 100,
"status": "paid",
"created_time[#]": "NOW()",
"user_id": 1,
"amount": 100,
"status": "paid",
"created_time": Time2Str(time.Now()),
})
if orderId == 0 {
@@ -721,6 +739,182 @@ database.SetConnect(func(err ...*common.Error) (master, slave *sql.DB) {
所有这些差异由框架自动处理,无需手动调整代码。
## 达梦数据库支持
HoTimeDB 原生支持**达梦数据库 DM8**,驱动已内置于 `vendor/gitee.com/chunanyong/dm`,无需额外安装。
### 达梦配置
`config.json` 中配置:
```json
{
"db": {
"dm": {
"host": "127.0.0.1",
"port": "5236",
"user": "SYSDBA",
"password": "your_password",
"name": "TEST",
"prefix": ""
}
}
}
```
代码配置方式:
```go
import (
"code.hoteas.com/golang/hotime/db"
_ "gitee.com/chunanyong/dm" // 达梦驱动(已在 vendor 中)
)
database := &db.HoTimeDB{
Type: "dm", // 或 "dameng"
Prefix: "", // 表前缀(可选)
}
database.SetConnect(func(err ...*common.Error) (master, slave *sql.DB) {
// name 字段即为 schema(默认搜索路径)
dsn := "dm://SYSDBA:your_password@127.0.0.1:5236?schema=TEST"
master, _ = sql.Open("dm", dsn)
return master, master
})
```
> **schema 说明**`?schema=TEST` 将当前会话的默认搜索 schema 设置为 `TEST`。
> 所有不带 schema 前缀的表名均在该 schema 下查找和创建。
> 建议为应用创建独立的专用 schema,避免直接使用 `SYSDBA` 默认 schema。
### 与 MySQL/PostgreSQL 的差异对比
| 特性 | 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) |
| 文本类型 | TEXT | TEXT | TEXT / CLOB |
| 时间赋值(推荐) | `Time2Str(time.Now())` | `Time2Str(time.Now())` | `Time2Str(time.Now())` |
| 时间函数(不推荐) | `NOW()` | `NOW()` | `NOW()` |
| 分页语法 | LIMIT m, n | LIMIT n OFFSET m | LIMIT m, n 或 LIMIT n OFFSET m |
所有这些差异由框架自动处理,业务代码无需修改。
### Upsert 在达梦中的行为
达梦不支持 MySQL 的 `ON DUPLICATE KEY UPDATE` 和 PostgreSQL 的 `ON CONFLICT`,框架会自动生成 `MERGE INTO` 语句:
```go
// 调用方式与其他数据库完全一致
affected := database.Upsert("admin",
common.Map{
"name": "张三",
"phone": "13800000001",
"password": "abc123",
"role_id": 1,
},
common.Slice{"phone"}, // 唯一键(冲突检测)
common.Slice{"name", "password", "role_id"}, // 冲突时更新的字段
)
// 达梦自动生成:
// MERGE INTO "admin" USING (SELECT ? AS "name", ? AS "phone", ...) src
// ON ("admin"."phone" = src."phone")
// WHEN MATCHED THEN UPDATE SET "name" = src."name", ...
// WHEN NOT MATCHED THEN INSERT (...) VALUES (src....)
```
### 达梦注意事项
#### 1. 标识符大小写敏感
达梦对双引号包裹的标识符大小写敏感。框架统一使用**小写**创建和访问表/列,确保一致性:
```sql
-- ✅ 正确:框架自动生成,小写标识符
SELECT * FROM "admin" WHERE "id" = ?
-- ❌ 错误:大小写不一致会导致 "无效的表或视图名" 错误
SELECT * FROM "ADMIN" WHERE "ID" = ?
```
> 使用 DM 管理工具手动创建表时,建议使用双引号包裹小写名称,与框架保持一致。
#### 2. 保留字冲突
达梦有大量保留字(如 `admin``user``order``key``value` 等),直接使用这些名称作为表名或列名会报语法错误。
**框架已自动为所有标识符加双引号**,一般情况下无需手动处理。
如果在原生 SQL`Query`/`Exec`)中使用保留字,需要手动加引号:
```go
// 原生 SQL 中需要手动加双引号
results := database.Query(`SELECT "id", "name" FROM "admin" WHERE "state" = ?`, 1)
```
#### 3. schema 与 USER_TABLES 的关系
连接达梦时使用 `?schema=TEST`DM 的 `USER_TABLES` 视图只显示当前**登录用户**(如 SYSDBA)所属 schema 的表,而非当前会话 schema 的表。
框架内部已改用 `COUNT(*)` 方式直接探查表可访问性,避免 schema 错配问题。
如果在自定义代码中检测表是否存在,**不要用 USER_TABLES**,改用:
```go
// ✅ 推荐:直接探查表可访问性
res := database.Query(`SELECT COUNT(*) as cnt FROM "your_table"`)
exists := len(res) > 0
// ❌ 不推荐:USER_TABLES 可能显示当前会话 schema 之外的表
res := database.Query(`SELECT TABLE_NAME FROM USER_TABLES WHERE TABLE_NAME='your_table'`)
```
#### 4. DDL 建表建议
手动建表时,列定义与 MySQL 的区别:
```sql
-- 达梦建表示例
CREATE TABLE "admin" (
"id" INT IDENTITY(1,1) PRIMARY KEY, -- 自增(MySQL: AUTO_INCREMENT
"name" VARCHAR(100),
"phone" VARCHAR(20),
"content" CLOB, -- 长文本(MySQL: TEXT/LONGTEXT
"create_time" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
"modify_time" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "uk_admin_phone" UNIQUE ("phone")
);
-- 索引
CREATE INDEX "idx_admin_state" ON "admin" ("state");
```
#### 5. 日期时间函数差异
部分日期函数在达梦中语法不同,使用 `[##]` 直接SQL时需注意:
| 功能 | 推荐(Go侧) | MySQL `[##]`/原生SQL | 达梦 DM8 `[##]`/原生SQL |
|------|------------|---------------------|----------------------|
| 当前时间赋值 | `Time2Str(time.Now())` ✅ | `NOW()` | `NOW()` |
| 一天前(时间范围查询) | `Time2Str(time.Now().AddDate(0,0,-1))` ✅ | `DATE_SUB(NOW(), INTERVAL 1 DAY)` | `DATEADD(DAY, -1, NOW())` |
| 时间戳转字符串 | `Time2Str(time.Unix(ts, 0))` ✅ | `FROM_UNIXTIME(ts)` | 不支持,需 Go 侧处理 |
> **强烈推荐**:时间字段赋值和时间范围计算都在 Go 侧完成(使用 `Time2Str`、`time.Now().Add()` 等),传入参数化的字符串值。这样完全规避数据库时区设置差异,也无需关心各数据库函数的语法不同。
#### 6. 使用 IN 查询时的注意事项
IN 查询在达梦中与 MySQL 行为一致,框架已统一处理:
```go
// 正常使用,框架自动展开
results := database.Select("article", "*", common.Map{
"id": []int{1, 2, 3},
})
// 生成: WHERE "id" IN (?, ?, ?)
```
## 高级特性
### 调试模式
@@ -758,8 +952,8 @@ database.SetConnect(createConnection)
// 执行查询SQL
results := database.Query("SELECT * FROM user WHERE age > ? AND status = ?", 18, 1)
// 执行更新SQL
result, err := database.Exec("UPDATE user SET last_login = NOW() WHERE id = ?", 1)
// 执行更新SQL(原生 SQL 中也建议使用服务器时间)
result, err := database.Exec("UPDATE user SET last_login = ? WHERE id = ?", Time2Str(time.Now()), 1)
if err.GetError() == nil {
affected, _ := result.RowsAffected()
fmt.Println("影响行数:", affected)
@@ -783,7 +977,7 @@ if err.GetError() == nil {
| `[~~]` | 手动LIKE | `"name[~~]": "%张%"` | `name LIKE '%张%'` |
| `[<>]` | BETWEEN | `"age[<>]": [18,60]` | `age BETWEEN 18 AND 60` |
| `[><]` | NOT BETWEEN | `"age[><]": [18,25]` | `age NOT BETWEEN 18 AND 25` |
| `[#]` | 直接SQL | `"time[#]": "NOW()"` | `time = NOW()` |
| `[#]` | 直接SQL表达式 | `"balance[#]": "balance + 1"` | `balance = balance + 1` |
| `[##]` | SQL片段 | `"[##]": "a > b"` | `a > b` |
| `[#!]` | 不等于直接SQL | `"status[#!]": "1"` | `status != 1` |
| `[!#]` | 不等于直接SQL | `"status[!#]": "1"` | `status != 1` |
@@ -822,12 +1016,28 @@ A6: 使用 `nil` 作为值,查询时使用 `"field": nil` 表示 `IS NULL`。
### Q7: PostgreSQL 和 MySQL 语法有区别吗?
A7: 框架会自动处理差异(占位符、引号等),代码无需修改。
### Q8: 达梦数据库和 MySQL 的代码使用方式一样吗?
A8: 基本一样。切换数据库类型只需修改 `Type` 字段和连接字符串,业务查询代码完全不用改。唯一需要注意的是:
- 原生 SQL`Query`/`Exec`)中的标识符需要手动加双引号
- `MERGE INTO` 等 DM 特有 DDL 需要按 DM 语法书写
- 检测表是否存在时,不要用 `USER_TABLES`,改用直接 `COUNT(*)` 查询
### Q9: 表名或列名用了达梦保留字怎么办?
A9: 框架所有自动生成的 SQL 都会对标识符加双引号,无需手动处理。但在原生 SQL 中使用保留字(如 `admin``user``order`)时,必须手动加双引号,否则会报语法错误。
### Q10: 达梦数据库中 INSERT 后如何获取自增 ID?
A10: 框架的 `Insert` 方法已支持从达梦原生驱动获取 `LastInsertId`,与 MySQL 使用方式完全一致:
```go
id := database.Insert("user", common.Map{"name": "张三"})
fmt.Println("新插入的ID:", id)
```
---
*文档版本: 2.0*
*最后更新: 2026年1*
*文档版本: 2.1*
*最后更新: 2026年3*
> 本文档基于HoTimeDB源码分析生成,如有疑问请参考源码实现。该ORM框架参考了PHP Medoo的设计理念,根据Golang语言特性进行了适配和优化。
> 本文档基于HoTimeDB源码分析生成,如有疑问请参考源码实现。该ORM框架参考了PHP Medoo的设计理念,根据Golang语言特性进行了适配和优化,并新增了对达梦(DM8)国产数据库的完整支持
**更多参考:**
- [HoTimeDB API 参考](HoTimeDB_API参考.md) - API 速查手册
+92 -6
View File
@@ -86,6 +86,8 @@ func main() {
### 数据库配置
支持的数据库类型:`mysql``sqlite``postgres`PostgreSQL)、`dm`(达梦 DM8)。
```json
{
"db": {
@@ -107,12 +109,21 @@ func main() {
"sqlite": {
"path": "config/data.db",
"prefix": ""
},
"dm": {
"host": "127.0.0.1",
"port": "5236",
"name": "TEST",
"user": "SYSDBA",
"password": "your_password",
"prefix": ""
}
}
}
```
> MySQL 配置 `slave` 项即启用主从读写分离
> MySQL 配置 `slave` 项即启用主从读写分离
> 达梦 DM8 的 `name` 字段对应 schema(搜索路径),驱动已内置于 `vendor/gitee.com/chunanyong/dm`,无需额外安装
### 缓存配置
@@ -135,7 +146,9 @@ func main() {
"db": {
"db": true,
"session": true,
"timeout": 2592000
"timeout": 2592000,
"history": false,
"mode": "compatible"
}
}
}
@@ -143,6 +156,27 @@ func main() {
缓存优先级: **Memory > Redis > DB**,自动穿透与回填
#### DB 缓存配置说明
| 配置项 | 默认值 | 说明 |
|--------|--------|------|
| `db` | false | 是否缓存数据库查询 |
| `session` | true | 是否缓存 Session |
| `timeout` | 2592000 | 过期时间(秒) |
| `history` | false | 是否记录缓存历史,开启后每次新增/修改缓存都会记录到历史表 |
| `mode` | compatible | 缓存表模式,见下表 |
**缓存表模式 (mode)**
| 模式 | 说明 |
|------|------|
| `compatible` | **默认**。兼容模式:写新表,新表无数据时回退读老表;写入时自动删除老表同 key 记录;删除时同时删两表。适合从老版本平滑升级,老数据自然过期消亡 |
| `new` | 只使用新表 `hotime_cache`,启动时自动迁移老表 `cached` 数据。老表保留由人工删除,不再被读写 |
> **升级建议**:从老版本升级时,建议先使用 `compatible` 模式运行一段时间(让老数据自然过期),确认无问题后再切换到 `new` 模式
> 从老版本升级时,建议使用 `compatible` 模式平滑过渡,待老表数据消亡后切换到 `new` 模式
### 错误码配置
```json
@@ -342,6 +376,44 @@ data := that.Cache("key") // 获取
that.Cache("key", nil) // 删除
```
### 批量操作(性能优化)
当需要同时操作多个 Session 字段时,使用批量操作可显著提升性能:
```go
// SessionsSet - 批量设置(N个字段只触发1次数据库写入)
that.SessionsSet(Map{
"user_id": userId,
"username": "张三",
"login_time": time.Now().Unix(),
"role": "admin",
})
// SessionsGet - 批量获取(1次调用获取多个字段)
result := that.SessionsGet("user_id", "username", "role")
// result = Map{"user_id": 123, "username": "张三", "role": "admin"}
userId := ObjToInt64(result["user_id"], nil)
username := ObjToStr(result["username"])
// SessionsDelete - 批量删除(N个字段只触发1次数据库写入)
that.SessionsDelete("token", "temp_code", "verify_expire")
```
**性能对比**
| 操作方式 | 设置10个字段 | 数据库写入次数 |
|----------|-------------|---------------|
| 逐个调用 `Session()` | 10次调用 | **10次** |
| 使用 `SessionsSet()` | 1次调用 | **1次** |
| 操作方式 | 获取10个字段 | 缓存查询次数 |
|----------|-------------|--------------|
| 逐个调用 `Session()` | 10次调用 | **10次** |
| 使用 `SessionsGet()` | 1次调用 | **1次** |
> 💡 **最佳实践**:当一次性操作 3 个以上字段时,建议使用批量操作
三级缓存自动运作:**Memory → Redis → Database**
## 数据库操作(简要)
@@ -439,6 +511,8 @@ that.Log = Map{
package main
import (
"time"
. "code.hoteas.com/golang/hotime"
. "code.hoteas.com/golang/hotime/common"
)
@@ -481,14 +555,25 @@ func main() {
return
}
that.Session("user_id", user.GetInt64("id"))
// 使用批量设置,一次写入多个字段
that.SessionsSet(Map{
"user_id": user.GetInt64("id"),
"username": user.GetString("name"),
"login_time": time.Now().Unix(),
})
that.Display(0, Map{"user": user})
},
"info": func(that *Context) {
userId := that.Session("user_id").ToInt64()
// 使用批量获取,一次读取多个字段
sess := that.SessionsGet("user_id", "username", "login_time")
userId := ObjToInt64(sess["user_id"], nil)
user := that.Db.Get("user", "*", Map{"id": userId})
that.Display(0, Map{"user": user})
that.Display(0, Map{
"user": user,
"login_time": sess["login_time"],
})
},
"list": func(that *Context) {
@@ -513,7 +598,8 @@ func main() {
},
"logout": func(that *Context) {
that.Session("user_id", nil)
// 使用批量删除,一次清除多个字段
that.SessionsDelete("user_id", "username", "login_time")
that.Display(0, "退出成功")
},
},
+96
View File
@@ -165,11 +165,105 @@ CREATE TABLE `_operation_log` (
---
---
## 七、测试框架:响应字段断言(部分匹配)
### 现状
`Post` / `Get` 等终端方法目前只支持断言响应体中的 `status``msg` 字段:
```go
a.JSON(Map{"phone": phone, "password": "123456"}).Post("正常登录", 0)
a.JSON(Map{"phone": phone, "password": "123456"}).Post("密码为空", 4, "用户名或密码不能为空")
```
如需验证响应 `result` 内部的具体字段,只能手动写:
```go
resp := a.JSON(Map{...}).Post("查询列表", 0)
if resp.GetBody().GetMap("result").GetInt("total") != 10 {
resp.Fail("total 应为 10")
}
```
这种写法冗长、可读性差,且失败时无法显示期望值与实际值的对比。
### 改进方向
在终端方法签名中增加**可选的响应字段部分匹配参数**:
```go
// 现有签名(不变)
func (c *ApiCase) Post(desc string, status int, msg ...string) *ApiResponse
// 新增重载或扩展参数,支持传入期望的 result 字段(Map 类型)
a.JSON(Map{...}).Post("查询列表", 0, Map{"total": 10})
a.JSON(Map{...}).Post("查询列表", 0, Map{"total": 10, "list": notEmpty})
a.JSON(Map{...}).Post("正常登录", 0, Map{"token": notEmpty})
```
### 核心设计原则
**部分匹配,而非精确匹配**
- 只验证期望 Map 中列出的字段,响应中额外的字段不报错
- 避免因响应新增字段导致测试频繁失败,降低维护成本
- 精确匹配适合快照测试场景,但与"快速精准"目标相悖,不引入
### 支持的断言值类型
| 断言值 | 含义 | 示例 |
|--------|------|------|
| 具体值(string/int/bool | 字段等于该值 | `Map{"total": 10}` |
| `notEmpty`(特殊常量) | 字段存在且非空/非零 | `Map{"token": notEmpty}` |
| 嵌套 Map | 递归部分匹配子对象 | `Map{"user": Map{"id": 1}}` |
### 预期写法
```go
// 断言 total 值
a.Query(Map{"page": "1"}).Get("查询列表", 0, Map{"total": 5})
// 断言 token 字段存在且非空(不关心具体值)
resp := a.JSON(Map{"phone": "138...", "password": "123456"}).Post("正常登录", 0, Map{"token": notEmpty})
// 组合:status + msg + result 字段三合一
a.JSON(Map{"phone": ""}).Post("手机号为空", 4, "手机号不能为空") // 只验 status+msg
a.JSON(Map{"phone": "138..."}).Post("正常注册", 0, Map{"id": notEmpty}) // 验 status + result.id
```
### 失败输出格式
失败时应显示期望值和实际值对比,便于快速定位:
```
--- FAIL: TestApi/app/user/login/正常登录
期望 result.token 非空,实际值为 ""
期望 result.total = 10,实际值为 0
```
### 实现要点
- 参数类型可设计为 `...interface{}`,兼容现有 `msg ...string` 的调用方式,通过类型断言区分
- `notEmpty` 可定义为框架内的特殊常量(如 `var notEmpty = struct{}{}`
- 断言逻辑复用 `ApiResponse` 已有的 `GetBody()` 链路,递归遍历期望 Map
### 待确认
- [ ] 终端方法签名如何兼容:新增第四参数 `result Map`,还是利用 `...interface{}` 统一
- [ ] `notEmpty` 常量的导出名称和定义位置
- [ ] 嵌套 Map 递归断言是否纳入首批实现范围
- [ ] 失败输出是否需要完整响应体 dump
---
## 改进优先级
| 优先级 | 改进项 | 状态 |
|--------|--------|------|
| 高 | 备注语法优化(`\|` 分隔符) | 待设计 |
| 高 | 测试框架:响应字段部分匹配断言 | 待实现 |
| 中 | 软删除/日志表支持 | 待设计 |
| 低 | 多对多关联表增强 | 待评估 |
| 低 | 自动填充字段扩展 | 待评估 |
@@ -181,3 +275,5 @@ CREATE TABLE `_operation_log` (
| 日期 | 内容 |
|------|------|
| 2026-01-24 | 初始版本,记录分析结果和待改进项 |
| 2026-03-15 | 新增测试框架响应字段断言改进规划(七) |
| 2026-03-20 | 完成达梦(DM8)数据库完整支持:Dialect 适配(MERGE INTO Upsert、双引号标识符)、LastInsertId、保留字自动引号、schema 正确处理、代码生成器 DM 兼容、缓存表自动建表、测试框架 DM 适配 |
+121
View File
@@ -0,0 +1,121 @@
# 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 '%超时%'` |
| 日期范围 | 右上角时间选择器,支持精确到秒 |
File diff suppressed because it is too large Load Diff
+143
View File
@@ -0,0 +1,143 @@
# HoTime 框架优雅停机
## 概述
HoTime 框架内置优雅停机支持,收到关闭信号后:
1. **立即**对所有新请求返回 `503 Service Unavailable`,触发 nginx 把流量切到其他实例
2. **等待** `DrainTimeout`(默认 5 秒)让 nginx 完成流量切换
3. **等待**所有正在处理的请求完成(最多 `ShutdownTimeout`,默认 30 秒)
4.`exit(0)` 正常退出,`run_xbc.cmd` 检测到正常退出后不自动重启
---
## 触发方式对照表
| 操作 | 平台 | 是否可优雅停机 | 说明 |
|---|---|:---:|---|
| `Ctrl+C` | Windows / Linux | ✅ | 发送 SIGINT / os.Interrupt |
| `kill <pid>``kill -15 <pid>` | Linux | ✅ | **推荐**,发送 SIGTERM |
| `kill -2 <pid>` | Linux | ✅ | 等同 Ctrl+C,发送 SIGINT |
| `taskkill /PID <pid>`(不加 /F | Windows | ✅ | **推荐**,等同发送 Ctrl+C |
| 关闭控制台/exe 窗口 X 按钮 | Windows | ✅(≤5s | CTRL_CLOSE_EVENTWindows 约 5s 后强杀 |
| `taskkill /F /PID <pid>` | Windows | ❌ | 强制 SIGKILL,无法拦截 |
| `kill -9 <pid>` | Linux | ❌ | SIGKILL,无法拦截 |
> **关窗口的限制**Windows 系统在触发 CTRL_CLOSE_EVENT 后约 5 秒会强制杀死进程。
> 框架对此路径使用更短的 drain(2s) + shutdown(3s),总计 <5s。
> 如果有接口执行时间超过 3 秒,建议改用 `taskkill /PID` 做计划性停机,不受 5s 限制。
---
## 配置参数
`Application` 实例上设置(需在调用 `Run()` 前配置):
```go
appIns := Init("config/config.json")
// 可选:自定义超时,不设置则使用默认值
appIns.DrainTimeout = 5 * time.Second // 等 nginx 切流,默认 5s
appIns.ShutdownTimeout = 30 * time.Second // 等在途请求完成,默认 30s
appIns.Run(Router{ ... })
```
---
## Nginx 配置
`location` 块中添加以下两行,让后端返回 `503` 时 nginx 自动转到其他实例,客户端无感知:
```nginx
upstream api_backend {
least_conn;
keepalive 64;
server 127.0.0.1:9998 max_fails=1 fail_timeout=3s;
server 127.0.0.1:9999 max_fails=1 fail_timeout=3s;
}
server {
location /api/ {
proxy_pass http://api_backend;
# 优雅停机核心配置:后端返回 503 时自动切换到另一台
proxy_next_upstream error timeout http_503;
proxy_next_upstream_tries 2;
# 其他常规配置...
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
```
- `max_fails=1 fail_timeout=3s`:连接级失败(如强杀后端口拒绝)时标记下线 3s
- `proxy_next_upstream http_503`:应用级 503 时 nginx 自动重试下一台
---
## 滚动重启步骤
### Windows
```cmd
:: 1. 查找端口 9998 的进程 PID
netstat -ano | findstr :9998
:: 2. 优雅关闭该实例(不加 /F,发送 Ctrl+C 信号)
taskkill /PID <pid>
:: 3. 观察日志,出现"服务已安全关闭"后,部署新版并启动
:: run_xbc.cmd 检测到 exit(0) 后不会自动重启
:: 4. 对端口 9999 重复以上步骤
netstat -ano | findstr :9999
taskkill /PID <pid2>
```
### Linux
```bash
# 1. 查找进程 PID
pgrep -a xbc # 或
ps aux | grep xbc
# 2. 优雅关闭(发送 SIGTERM
kill <pid>
# 3. 观察日志确认关闭完成
tail -f logs/xbc.log # 等待"服务已安全关闭"
# 4. 部署新版并启动,再对另一个实例重复
```
---
## 工作原理
```
收到信号 / 关窗口
shuttingDown = true
▼ DrainTimeout(默认 5s
新请求 → 503 ──────────────────────► nginx 切流到另一台
server.Shutdown(ShutdownTimeout=30s)
等待所有进行中请求跑完
os.Exit(0) → run_xbc.cmd 检测到 exit(0),不重启
```
### 为何在框架层而非应用层实现
通过 `appIns.Run(Router{...})` 启动服务,信号处理与 `http.Server` 生命周期强绑定,必须在框架的 `Run()` 方法内才能正确管理。应用层无需任何修改,升级框架即自动获得优雅停机能力。
### `sync.Once` 保证幂等性
框架同时监听多路触发源(信号 goroutine + Windows 关窗口 handler),`sync.Once` 保证无论哪路先触发,`initiateGracefulShutdown` 只执行一次,不会重复调用 `Shutdown()`
+44
View File
@@ -0,0 +1,44 @@
package app
import (
"os"
"testing"
. "code.hoteas.com/golang/hotime"
)
var ProjectTest = ProjTest{
"test": TestTest,
"mysql": MysqlTest,
"dmdb": DmdbTest,
"cache": CacheTest,
"expect_demo": ExpectDemoTest,
}
var testApp *TestApp
func TestMain(m *testing.M) {
// Go test 的 CWD 是包目录 example/app/,切回 example/ 使相对路径正确
_ = os.Chdir("..")
testApp = NewTestApp("config/config.json",
TestProj{
"app": {
Proj: Project,
Tests: ProjectTest,
},
},
)
// 在测试前初始化各数据库的测试表和种子数据
SetupDatabase(&testApp.Db)
code := m.Run()
testApp.PrintCoverage()
testApp.GenerateSwagger("My API", "1.0.0", testApp.Config.GetString("tpt"))
os.Exit(code)
}
func TestApi(t *testing.T) {
testApp.RunTests(t)
}
+294
View File
@@ -0,0 +1,294 @@
package app
import (
"fmt"
"time"
. "code.hoteas.com/golang/hotime"
. "code.hoteas.com/golang/hotime/common"
)
// CacheCtr 缓存测试控制器(数据库无关)
var CacheCtr = Ctr{
"all": func(that *Context) { that.Display(0, testCacheAll(that)) },
"compat": func(that *Context) { that.Display(0, testCacheCompatible(that)) },
"batch": func(that *Context) {
that.Display(0, Map{"message": "批量缓存测试完成,请查看控制台输出和日志文件"})
},
}
func testCacheAll(that *Context) Map {
result := Map{"name": "数据库缓存测试", "tests": Slice{}}
tests := Slice{}
cacheMode := "unknown"
if that.Application.HoTimeCache != nil && that.Application.HoTimeCache.Config != nil {
dbConfig := that.Application.HoTimeCache.Config.GetMap("db")
if dbConfig != nil {
cacheMode = dbConfig.GetString("mode")
if cacheMode == "" {
cacheMode = "new"
}
}
}
result["cache_mode"] = cacheMode
testPrefix := fmt.Sprintf("cache_test_%d_", time.Now().UnixNano())
// 1. 基础读写测试
test1 := Map{"name": "1. 基础 set/get 测试"}
testKey1 := testPrefix + "basic"
testValue1 := Map{"name": "测试数据", "count": 123, "active": true}
that.Application.Cache(testKey1, testValue1)
cached1 := that.Application.Cache(testKey1)
if cached1.Data != nil {
cachedMap := cached1.ToMap()
test1["result"] = cachedMap.GetString("name") == "测试数据" && cachedMap.GetInt("count") == 123
test1["cached_value"] = cachedMap
} else {
test1["result"] = false
test1["error"] = "缓存读取返回 nil"
}
tests = append(tests, test1)
// 2. 删除缓存测试
test2 := Map{"name": "2. delete 删除缓存测试"}
testKey2 := testPrefix + "delete"
that.Application.Cache(testKey2, "删除测试值")
before := that.Application.Cache(testKey2)
beforeExists := before.Data != nil
that.Application.Cache(testKey2, nil)
after := that.Application.Cache(testKey2)
afterExists := after.Data != nil
test2["result"] = beforeExists && !afterExists
test2["before_exists"] = beforeExists
test2["after_exists"] = afterExists
tests = append(tests, test2)
// 3. 过期时间测试
test3 := Map{"name": "3. 过期时间测试(短超时)"}
testKey3 := testPrefix + "expire"
that.Application.Cache(testKey3, "短期数据", 2)
immediate := that.Application.Cache(testKey3)
immediateExists := immediate.Data != nil
test3["result"] = immediateExists
test3["immediate_exists"] = immediateExists
test3["note"] = "设置了2秒过期,可等待后再次访问验证过期"
tests = append(tests, test3)
// 4. 不存在的 key 读取测试
test4 := Map{"name": "4. 不存在的 key 读取测试"}
nonExistKey := testPrefix + "non_exist_key_" + fmt.Sprintf("%d", time.Now().UnixNano())
nonExist := that.Application.Cache(nonExistKey)
test4["result"] = nonExist.Data == nil
test4["value"] = nonExist.Data
tests = append(tests, test4)
// 5. 重复 set 同一个 key 测试
test5 := Map{"name": "5. 重复 set 同一个 key 测试"}
testKey5 := testPrefix + "repeat"
that.Application.Cache(testKey5, "第一次值")
first := that.Application.Cache(testKey5).ToStr()
that.Application.Cache(testKey5, "第二次值")
second := that.Application.Cache(testKey5).ToStr()
that.Application.Cache(testKey5, Map{"version": 3})
third := that.Application.Cache(testKey5).ToMap()
test5["result"] = first == "第一次值" && second == "第二次值" && third.GetInt("version") == 3
test5["first"] = first
test5["second"] = second
test5["third"] = third
tests = append(tests, test5)
// 6. 通配删除测试
test6 := Map{"name": "6. 通配删除测试 (key*)"}
wildcardPrefix := testPrefix + "wildcard_"
that.Application.Cache(wildcardPrefix+"a", "值A")
that.Application.Cache(wildcardPrefix+"b", "值B")
that.Application.Cache(wildcardPrefix+"c", "值C")
aExists := that.Application.Cache(wildcardPrefix+"a").Data != nil
bExists := that.Application.Cache(wildcardPrefix+"b").Data != nil
cExists := that.Application.Cache(wildcardPrefix+"c").Data != nil
allExistBefore := aExists && bExists && cExists
that.Application.Cache(wildcardPrefix+"*", nil)
aAfter := that.Application.Cache(wildcardPrefix+"a").Data != nil
bAfter := that.Application.Cache(wildcardPrefix+"b").Data != nil
cAfter := that.Application.Cache(wildcardPrefix+"c").Data != nil
allDeletedAfter := !aAfter && !bAfter && !cAfter
test6["result"] = allExistBefore && allDeletedAfter
test6["before"] = Map{"a": aExists, "b": bExists, "c": cExists}
test6["after"] = Map{"a": aAfter, "b": bAfter, "c": cAfter}
tests = append(tests, test6)
// 7. 不同数据类型测试
test7 := Map{"name": "7. 不同数据类型存储测试"}
that.Application.Cache(testPrefix+"type_string", "字符串值")
typeString := that.Application.Cache(testPrefix + "type_string").ToStr()
that.Application.Cache(testPrefix+"type_int", 12345)
typeInt := that.Application.Cache(testPrefix + "type_int").ToInt()
that.Application.Cache(testPrefix+"type_float", 3.14159)
typeFloat := that.Application.Cache(testPrefix + "type_float").ToFloat64()
that.Application.Cache(testPrefix+"type_bool", true)
typeBoolData := that.Application.Cache(testPrefix + "type_bool").Data
typeBool := typeBoolData == true || typeBoolData == "true" || typeBoolData == 1.0
that.Application.Cache(testPrefix+"type_map", Map{"key": "value", "num": 100})
typeMap := that.Application.Cache(testPrefix + "type_map").ToMap()
that.Application.Cache(testPrefix+"type_slice", Slice{1, 2, 3, "four", Map{"five": 5}})
typeSlice := that.Application.Cache(testPrefix + "type_slice").ToSlice()
test7["result"] = typeString == "字符串值" &&
typeInt == 12345 &&
typeFloat > 3.14 && typeFloat < 3.15 &&
typeBool == true &&
typeMap.GetString("key") == "value" &&
len(typeSlice) == 5
test7["string"] = typeString
test7["int"] = typeInt
test7["float"] = typeFloat
test7["bool"] = typeBool
test7["map"] = typeMap
test7["slice"] = typeSlice
tests = append(tests, test7)
// 8. 自定义超时时间测试
test8 := Map{"name": "8. 自定义超时时间参数测试"}
testKey8 := testPrefix + "custom_timeout"
that.Application.Cache(testKey8, "长期数据", 3600)
longTerm := that.Application.Cache(testKey8)
test8["result"] = longTerm.Data != nil
test8["value"] = longTerm.ToStr()
tests = append(tests, test8)
// 9. 查询缓存表状态
test9 := Map{"name": "9. 缓存表状态查询"}
prefix := that.Db.GetPrefix()
newTableName := prefix + "hotime_cache"
newCount := that.Db.Count(newTableName)
test9["new_table_count"] = newCount
test9["new_table_name"] = newTableName
test9["result"] = newCount >= 0
tests = append(tests, test9)
that.Application.Cache(testPrefix+"*", nil)
result["tests"] = tests
result["success"] = true
result["cleanup"] = "已清理所有测试缓存数据"
return result
}
func testCacheCompatible(that *Context) Map {
result := Map{
"test_name": "兼容模式白盒测试",
"timestamp": time.Now().Format("2006-01-02 15:04:05"),
}
prefix := that.Db.GetPrefix()
newTableName := prefix + "hotime_cache"
legacyTableName := prefix + "cached"
tests := Slice{}
test1 := Map{"name": "1. 查询当前缓存模式"}
cacheConfig := that.Application.Config.GetMap("cache")
dbConfig := cacheConfig.GetMap("db")
mode := dbConfig.GetString("mode")
if mode == "" {
mode = "默认(compatible)"
}
test1["mode"] = mode
test1["result"] = true
tests = append(tests, test1)
// 根据数据库类型使用不同的引号
q := "`"
if that.Db.Dialect != nil {
q = that.Db.Dialect.QuoteChar()
}
test2 := Map{"name": "2. 查询老表cached现有数据"}
legacyData := that.Db.Query("SELECT * FROM " + q + legacyTableName + q + " LIMIT 5")
test2["legacy_table"] = legacyTableName
test2["count"] = len(legacyData)
test2["data"] = legacyData
test2["result"] = true
tests = append(tests, test2)
test3 := Map{"name": "3. 查询新表hotime_cache现有数据"}
newData := that.Db.Query("SELECT * FROM " + q + newTableName + q + " LIMIT 5")
test3["new_table"] = newTableName
test3["count"] = len(newData)
test3["data"] = newData
test3["result"] = true
tests = append(tests, test3)
test4 := Map{"name": "4. 测试兼容模式老表回退读取"}
testKey4 := "test_compat_fallback_" + ObjToStr(time.Now().UnixNano())
testValue4 := Map{"admin_id": 999, "admin_name": "测试老数据"}
testValueJson4 := ObjToStr(Map{"data": testValue4})
that.Db.Insert(legacyTableName, Map{
"key": testKey4,
"value": testValueJson4,
"endtime": time.Now().Unix() + 3600,
"time": time.Now().UnixNano(),
})
test4["test_key"] = testKey4
newExists := that.Db.Get(newTableName, "*", Map{"key": testKey4})
test4["key_in_new_table"] = newExists != nil
cacheValue := that.Application.Cache(testKey4)
test4["cache_api_result"] = cacheValue.Data
test4["result"] = newExists == nil && cacheValue.Data != nil
tests = append(tests, test4)
test5 := Map{"name": "5. 测试兼容模式写新删老"}
testKey5 := "test_compat_write_" + ObjToStr(time.Now().UnixNano())
testValue5 := "兼容模式测试数据"
that.Db.Insert(legacyTableName, Map{
"key": testKey5,
"value": `{"data":"老表原始数据"}`,
"endtime": time.Now().Unix() + 3600,
"time": time.Now().UnixNano(),
})
legacyBefore := that.Db.Get(legacyTableName, "*", Map{"key": testKey5})
test5["step1_legacy_before"] = legacyBefore != nil
that.Application.Cache(testKey5, testValue5)
newAfter := that.Db.Get(newTableName, "*", Map{"key": testKey5})
test5["step2_new_after"] = newAfter != nil
if newAfter != nil {
test5["new_value"] = newAfter.GetString("value")
}
legacyAfter := that.Db.Get(legacyTableName, "*", Map{"key": testKey5})
test5["step3_legacy_after_deleted"] = legacyAfter == nil
test5["result"] = legacyBefore != nil && newAfter != nil && legacyAfter == nil
tests = append(tests, test5)
test6 := Map{"name": "6. 测试兼容模式删除(删除两表)"}
testKey6 := "test_compat_delete_" + ObjToStr(time.Now().UnixNano())
that.Db.Insert(legacyTableName, Map{
"key": testKey6,
"value": `{"data":"待删除老数据"}`,
"endtime": time.Now().Unix() + 3600,
"time": time.Now().UnixNano(),
})
that.Db.Insert(newTableName, Map{
"key": testKey6,
"value": `"待删除新数据"`,
"end_time": time.Now().Add(time.Hour).Format("2006-01-02 15:04:05"),
"state": 0,
"create_time": time.Now().Format("2006-01-02 15:04:05"),
"modify_time": time.Now().Format("2006-01-02 15:04:05"),
})
test6["before_legacy"] = that.Db.Get(legacyTableName, "*", Map{"key": testKey6}) != nil
test6["before_new"] = that.Db.Get(newTableName, "*", Map{"key": testKey6}) != nil
that.Application.Cache(testKey6, nil)
test6["after_legacy_deleted"] = that.Db.Get(legacyTableName, "*", Map{"key": testKey6}) == nil
test6["after_new_deleted"] = that.Db.Get(newTableName, "*", Map{"key": testKey6}) == nil
test6["result"] = test6.GetBool("before_legacy") && test6.GetBool("before_new") &&
test6.GetBool("after_legacy_deleted") && test6.GetBool("after_new_deleted")
tests = append(tests, test6)
that.Db.Delete(newTableName, Map{"key[~]": "test_compat_%"})
that.Db.Delete(legacyTableName, Map{"key[~]": "test_compat_%"})
result["tests"] = tests
result["success"] = true
return result
}
+89
View File
@@ -0,0 +1,89 @@
package app
import (
"fmt"
. "code.hoteas.com/golang/hotime"
. "code.hoteas.com/golang/hotime/common"
)
var CacheTest = CtrTest{
"all": {Desc: "缓存全部测试", Func: func(a *Api) {
a.Verify(func(a *Api) error {
result := a.Resp().GetBody().GetMap("result")
if result == nil {
return fmt.Errorf("result 为 nil")
}
if !result.GetBool("success") {
return fmt.Errorf("缓存测试未全部通过")
}
if result.GetString("cache_mode") == "" {
return fmt.Errorf("cache_mode 字段缺失")
}
tests := result.GetSlice("tests")
if len(tests) == 0 {
return fmt.Errorf("tests 数组为空")
}
for i := 0; i < len(tests); i++ {
item := tests.GetMap(i)
if item != nil && !item.GetBool("result") {
return fmt.Errorf("子项 '%s' 失败", item.GetString("name"))
}
}
return nil
}).Get("缓存全部测试", 0, Map{
"name": "sample",
"cache_mode": "sample",
"success": true,
"cleanup": "sample",
"tests": Slice{Map{
"name": "sample", "result": true,
}},
})
}},
// 兼容模式下的老表回退/写新删老操作在测试事务隔离下已知异常(cache API
// 使用独立连接,不在 testTx 事务内),因此只校验结构完整性
"compat": {Desc: "缓存兼容模式测试", Func: func(a *Api) {
a.Verify(func(a *Api) error {
result := a.Resp().GetBody().GetMap("result")
if result == nil {
return fmt.Errorf("result 为 nil")
}
if result.GetString("test_name") == "" {
return fmt.Errorf("test_name 字段缺失")
}
if result.GetString("timestamp") == "" {
return fmt.Errorf("timestamp 字段缺失")
}
tests := result.GetSlice("tests")
if len(tests) == 0 {
return fmt.Errorf("tests 数组为空")
}
return nil
}).Get("兼容模式测试", 0, Map{
"test_name": "sample",
"timestamp": "sample",
"success": true,
"tests": Slice{Map{
"name": "sample", "result": true,
}},
})
}},
"batch": {Desc: "批量缓存测试", Func: func(a *Api) {
a.Verify(func(a *Api) error {
result := a.Resp().GetBody().GetMap("result")
if result == nil {
return fmt.Errorf("result 为 nil")
}
msg := result.GetString("message")
if msg == "" {
return fmt.Errorf("message 字段为空")
}
return nil
}).Get("批量缓存测试", 0, Map{
"message": "sample",
})
}},
}
+88
View File
@@ -0,0 +1,88 @@
package app
import (
. "code.hoteas.com/golang/hotime"
. "code.hoteas.com/golang/hotime/common"
)
// DmdbCtr 达梦数据库特有测试控制器
var DmdbCtr = Ctr{
"tables": func(that *Context) {
if that.Db.Type != "dm" && that.Db.Type != "dameng" {
that.Display(0, Map{"skip": true, "reason": "当前非达梦数据库"})
return
}
tables := that.Db.Query(`SELECT TABLE_NAME AS "name", COMMENTS AS "label" FROM USER_TAB_COMMENTS WHERE TABLE_TYPE='TABLE'`)
that.Display(0, Map{"tables": tables})
},
"describe": func(that *Context) {
if that.Db.Type != "dm" && that.Db.Type != "dameng" {
that.Display(0, Map{"skip": true, "reason": "当前非达梦数据库"})
return
}
tableName := that.Req.FormValue("table")
if tableName == "" {
that.Display(1, "请提供 table 参数")
return
}
columns := that.Db.Query(`SELECT c.COLUMN_NAME AS "name", c.DATA_TYPE AS "type", m.COMMENTS AS "label", c.NULLABLE AS "must" FROM USER_TAB_COLUMNS c LEFT JOIN USER_COL_COMMENTS m ON c.TABLE_NAME=m.TABLE_NAME AND c.COLUMN_NAME=m.COLUMN_NAME WHERE c.TABLE_NAME='` + tableName + `' ORDER BY c.COLUMN_ID`)
data := that.Db.Select(tableName, "*", Map{"LIMIT": 10})
that.Display(0, Map{"table": tableName, "columns": columns, "sample_data": data})
},
"rawsql": func(that *Context) {
if that.Db.Type != "dm" && that.Db.Type != "dameng" {
that.Display(0, Map{"skip": true, "reason": "当前非达梦数据库"})
return
}
result := testDmRawSQL(that)
that.Display(0, result)
},
}
func testDmRawSQL(that *Context) Map {
result := Map{"name": "达梦原生SQL测试", "tests": Slice{}}
tests := Slice{}
test1 := Map{"name": "Query 原生查询 (双引号)"}
articles1 := that.Db.Query(`SELECT "id", "title", "author" FROM "article" WHERE "state" = ? LIMIT ?`, 0, 5)
test1["result"] = len(articles1) >= 0
test1["count"] = len(articles1)
tests = append(tests, test1)
test2 := Map{"name": "Exec 原生执行 (双引号)"}
testArticle := that.Db.Get("article", "id", Map{"state": 0})
if testArticle != nil {
res, err := that.Db.Exec(`UPDATE "article" SET "modify_time" = NOW() WHERE "id" = ?`, testArticle.GetInt64("id"))
if err == nil && res != nil {
affected, _ := res.RowsAffected()
test2["result"] = affected >= 0
test2["affected"] = affected
} else {
test2["result"] = false
test2["error"] = err
}
} else {
test2["result"] = true
test2["note"] = "无可用测试数据"
}
tests = append(tests, test2)
test3 := Map{"name": "USER_TABLES 查询"}
tables := that.Db.Query(`SELECT TABLE_NAME FROM USER_TABLES`)
test3["result"] = len(tables) >= 0
test3["count"] = len(tables)
tests = append(tests, test3)
test4 := Map{"name": "USER_TAB_COLUMNS 查询"}
cols := that.Db.Query(`SELECT COLUMN_NAME, DATA_TYPE FROM USER_TAB_COLUMNS WHERE TABLE_NAME='admin' ORDER BY COLUMN_ID`)
test4["result"] = len(cols) >= 0
test4["count"] = len(cols)
test4["columns"] = cols
tests = append(tests, test4)
result["tests"] = tests
result["success"] = true
return result
}
+90
View File
@@ -0,0 +1,90 @@
package app
import (
"fmt"
. "code.hoteas.com/golang/hotime"
. "code.hoteas.com/golang/hotime/common"
)
var DmdbTest = CtrTest{
"tables": {Desc: "查询达梦所有表", Func: func(a *Api) {
if a.DB().Type != "dm" && a.DB().Type != "dameng" {
return
}
a.Verify(func(a *Api) error {
result := a.Resp().GetBody().GetMap("result")
if result == nil {
return fmt.Errorf("result 为 nil")
}
tables := result.GetSlice("tables")
if tables == nil {
return fmt.Errorf("tables 字段缺失")
}
if len(tables) == 0 {
return fmt.Errorf("达梦表列表为空,至少应有测试表")
}
return nil
}).Get("USER_TABLES 查询", 0, Map{
"tables": Slice{Map{"name": "sample"}},
})
}},
"describe": {Desc: "查询达梦表结构", Func: func(a *Api) {
if a.DB().Type != "dm" && a.DB().Type != "dameng" {
return
}
// ======== 错误用例 ========
a.Get("不传table参数", 1, "请提供 table 参数")
// ======== 正确请求 + 结构校验 + Verify ========
a.Query(Map{"table": "admin"}).
Verify(func(a *Api) error {
result := a.Resp().GetBody().GetMap("result")
if result == nil {
return fmt.Errorf("result 为 nil")
}
if result.GetString("table") != "admin" {
return fmt.Errorf("table 期望 'admin', 实际 '%s'", result.GetString("table"))
}
columns := result.GetSlice("columns")
if len(columns) == 0 {
return fmt.Errorf("columns 为空,admin 表应有字段定义")
}
return nil
}).
Get("USER_TAB_COLUMNS 查询", 0, Map{
"table": "sample", "columns": Slice{Map{"name": "sample", "type": "sample"}}, "sample_data": Slice{},
})
}},
"rawsql": {Desc: "达梦原生 SQL 测试", Func: func(a *Api) {
if a.DB().Type != "dm" && a.DB().Type != "dameng" {
return
}
a.Verify(func(a *Api) error {
result := a.Resp().GetBody().GetMap("result")
if result == nil {
return fmt.Errorf("result 为 nil")
}
if !result.GetBool("success") {
return fmt.Errorf("达梦原生 SQL 测试未通过")
}
tests := result.GetSlice("tests")
if len(tests) == 0 {
return fmt.Errorf("tests 数组为空")
}
for i := 0; i < len(tests); i++ {
item := tests.GetMap(i)
if item != nil && !item.GetBool("result") {
return fmt.Errorf("子项 '%s' 失败", item.GetString("name"))
}
}
return nil
}).Get("达梦原生SQL", 0, Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample", "result": true}},
})
}},
}
+255
View File
@@ -0,0 +1,255 @@
package app
import (
"fmt"
. "code.hoteas.com/golang/hotime"
. "code.hoteas.com/golang/hotime/common"
)
var ExpectDemoTest = CtrTest{
// ─── 基础类型结构校验 + Verify 值断言 ──────────────────
"string_result": {Desc: "返回字符串-结构+值校验", Func: func(a *Api) {
a.Verify(func(a *Api) error {
result := a.Resp().GetBody().GetString("result")
if result != "操作成功" {
return fmt.Errorf("result 期望 '操作成功', 实际 '%s'", result)
}
return nil
}).Get("result是字符串", 0, "样本")
}},
"number_result": {Desc: "返回整数-结构+值校验", Func: func(a *Api) {
a.Verify(func(a *Api) error {
result := a.Resp().GetBody().GetCeilInt64("result")
if result != 42 {
return fmt.Errorf("result 期望 42, 实际 %d", result)
}
return nil
}).Get("result是整数", 0, int64(1))
}},
"float_result": {Desc: "返回小数-结构+值校验", Func: func(a *Api) {
a.Verify(func(a *Api) error {
result := a.Resp().GetBody().GetFloat64("result")
if result != 3.14 {
return fmt.Errorf("result 期望 3.14, 实际 %f", result)
}
return nil
}).Get("result是小数", 0, float64(1.0))
}},
"bool_result": {Desc: "返回布尔-结构+值校验", Func: func(a *Api) {
a.Verify(func(a *Api) error {
result := a.Resp().GetBody().GetBool("result")
if !result {
return fmt.Errorf("result 期望 true, 实际 false")
}
return nil
}).Get("result是布尔", 0, true)
}},
"map_result": {Desc: "返回对象-结构+值校验", Func: func(a *Api) {
a.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, 实际 %d", result.GetCeilInt64("id"))
}
if result.GetString("name") != "示例商品" {
return fmt.Errorf("name 期望 '示例商品', 实际 '%s'", result.GetString("name"))
}
if !result.GetBool("in_stock") {
return fmt.Errorf("in_stock 期望 true")
}
return nil
}).Get("result是Map-校验字段名+类型+值", 0, Map{
"id": int64(1), "name": "sample", "price": float64(1.0), "in_stock": true,
})
}},
"nested_result": {Desc: "返回嵌套对象-结构+值校验", Func: func(a *Api) {
a.Verify(func(a *Api) error {
result := a.Resp().GetBody().GetMap("result")
if result == nil {
return fmt.Errorf("result 为 nil")
}
if result.GetCeilInt64("id") != 100 {
return fmt.Errorf("id 期望 100, 实际 %d", result.GetCeilInt64("id"))
}
if result.GetString("title") != "测试订单" {
return fmt.Errorf("title 期望 '测试订单', 实际 '%s'", result.GetString("title"))
}
customer := result.GetMap("customer")
if customer == nil {
return fmt.Errorf("customer 为 nil")
}
if customer.GetString("name") != "张三" {
return fmt.Errorf("customer.name 期望 '张三', 实际 '%s'", customer.GetString("name"))
}
if customer.GetString("phone") != "13800138000" {
return fmt.Errorf("customer.phone 期望 '13800138000', 实际 '%s'", customer.GetString("phone"))
}
items := result.GetSlice("items")
if len(items) != 2 {
return fmt.Errorf("items 期望 2 条, 实际 %d 条", len(items))
}
firstItem := items.GetMap(0)
if firstItem.GetString("goods_name") != "苹果" {
return fmt.Errorf("items[0].goods_name 期望 '苹果', 实际 '%s'", firstItem.GetString("goods_name"))
}
delivery := result.GetMap("extra").GetMap("delivery")
if delivery == nil {
return fmt.Errorf("extra.delivery 为 nil")
}
if delivery.GetString("address") != "XX路1号" {
return fmt.Errorf("extra.delivery.address 期望 'XX路1号', 实际 '%s'", delivery.GetString("address"))
}
return nil
}).Get("深层嵌套Map+Slice+Map", 0, Map{
"id": int64(1), "title": "sample",
"customer": Map{"id": int64(1), "name": "sample", "phone": "sample"},
"items": Slice{Map{
"id": int64(1), "goods_name": "sample", "quantity": int64(1), "price": float64(1.0),
}},
"extra": Map{
"delivery": Map{"address": "sample", "fee": int64(1)},
},
})
}},
"slice_result": {Desc: "返回数组-结构+值校验", Func: func(a *Api) {
a.Verify(func(a *Api) error {
result := a.Resp().GetBody().GetSlice("result")
if len(result) != 2 {
return fmt.Errorf("result 期望 2 条, 实际 %d 条", len(result))
}
first := result.GetMap(0)
if first.GetString("name") != "标签A" {
return fmt.Errorf("result[0].name 期望 '标签A', 实际 '%s'", first.GetString("name"))
}
second := result.GetMap(1)
if second.GetString("name") != "标签B" {
return fmt.Errorf("result[1].name 期望 '标签B', 实际 '%s'", second.GetString("name"))
}
return nil
}).Get("result直接是Slice", 0, Slice{Map{"id": int64(1), "name": "sample"}})
}},
// ─── 错误先行 + 正确请求完整范式 ─────────────────────
"error_demo": {Desc: "错误先行+正确请求完整校验", Func: func(a *Api) {
// ======== 第一步:错误用例(先行) ========
a.Form(Map{"name": ""}).Post("名称为空-缺少必填字段", 3, "名称不能为空")
// ======== 第二步:正确请求 + 结构校验 + Verify 值断言 ========
a.Form(Map{"name": "测试商品"}).
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, 实际 %d", result.GetCeilInt64("id"))
}
if result.GetString("name") != "测试商品" {
return fmt.Errorf("name 期望 '测试商品', 实际 '%s'", result.GetString("name"))
}
if !result.GetBool("created") {
return fmt.Errorf("created 期望 true")
}
return nil
}).
Post("名称正确-结构+值校验", 0, Map{
"id": int64(1), "name": "sample", "created": true,
})
}},
"no_expect": {Desc: "最小正确用例-仍需值断言", Func: func(a *Api) {
a.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, 实际 %d", result.GetCeilInt64("id"))
}
if result.GetString("data") != "无预期校验" {
return fmt.Errorf("data 期望 '无预期校验', 实际 '%s'", result.GetString("data"))
}
return nil
}).Get("有Verify值断言", 0, Map{"id": int64(1), "data": "sample"})
}},
// ─── Verify 完整范式:错误 → 数据准备 → 正确请求 + 响应断言 + DB 校验 ─
"verify_demo": {Desc: "Verify完整范式演示", Func: func(a *Api) {
// ======== 第一步:错误用例 ========
a.Form(Map{"name": ""}).Post("名称为空", 3, "名称不能为空")
// ======== 第二步:准备测试数据 ========
// 先插入一条已有记录,用于后续 DB 校验时确认"新增"行为
a.DB().Insert("test_batch", Map{
"name": "existing_record", "title": "verify_demo", "state": 1,
})
// ======== 第三步:正确请求 + Verify 统一校验 ========
name := "verify_test"
a.Form(Map{"name": name}).
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"))
}
if result.GetString("name") != name {
return fmt.Errorf("name 期望 '%s', 实际 '%s'", name, result.GetString("name"))
}
// 数据库状态校验
row := a.DB().Get("test_batch", "*", Map{"AND": Map{"name": name, "title": "verify_demo"}})
if row == nil {
return fmt.Errorf("test_batch 表未找到 name=%s 的记录", name)
}
if row.GetCeilInt64("state") != 0 {
return fmt.Errorf("test_batch.state 期望 0, 实际 %d", row.GetCeilInt64("state"))
}
// 校验之前插入的记录仍然存在(验证不会误删)
existing := a.DB().Get("test_batch", "*", Map{"AND": Map{"name": "existing_record", "title": "verify_demo"}})
if existing == nil {
return fmt.Errorf("之前插入的 existing_record 丢失")
}
return nil
}).
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"})
}},
}
+13
View File
@@ -0,0 +1,13 @@
package app
import (
. "code.hoteas.com/golang/hotime"
)
var Project = Proj{
"test": TestCtr,
"mysql": MysqlCtr,
"dmdb": DmdbCtr,
"cache": CacheCtr,
"expect_demo": ExpectDemoCtr,
}
+96
View File
@@ -0,0 +1,96 @@
package app
import (
. "code.hoteas.com/golang/hotime"
. "code.hoteas.com/golang/hotime/common"
)
// MysqlCtr MySQL 特有测试控制器
var MysqlCtr = Ctr{
"tables": func(that *Context) {
if that.Db.Type != "mysql" {
that.Display(0, Map{"skip": true, "reason": "当前非 MySQL 数据库"})
return
}
tables := that.Db.Query("SHOW TABLES")
that.Display(0, Map{"tables": tables})
},
"describe": func(that *Context) {
if that.Db.Type != "mysql" {
that.Display(0, Map{"skip": true, "reason": "当前非 MySQL 数据库"})
return
}
tableName := that.Req.FormValue("table")
if tableName == "" {
that.Display(1, "请提供 table 参数")
return
}
columns := that.Db.Query("DESCRIBE " + tableName)
data := that.Db.Select(tableName, "*", Map{"LIMIT": 10})
that.Display(0, Map{"table": tableName, "columns": columns, "sample_data": data})
},
"rawsql": func(that *Context) {
if that.Db.Type != "mysql" {
that.Display(0, Map{"skip": true, "reason": "当前非 MySQL 数据库"})
return
}
result := testMysqlRawSQL(that)
that.Display(0, result)
},
}
func testMysqlRawSQL(that *Context) Map {
result := Map{"name": "MySQL原生SQL测试", "tests": Slice{}}
tests := Slice{}
test1 := Map{"name": "Query 原生查询 (反引号)"}
articles1 := that.Db.Query("SELECT id, title, author FROM `article` WHERE state = ? LIMIT ?", 0, 5)
test1["result"] = len(articles1) >= 0
test1["count"] = len(articles1)
tests = append(tests, test1)
test2 := Map{"name": "Exec 原生执行 (反引号)"}
testArticle := that.Db.Get("article", "id", Map{"state": 0})
if testArticle != nil {
res, err := that.Db.Exec("UPDATE `article` SET modify_time = NOW() WHERE id = ?", testArticle.GetInt64("id"))
if err == nil && res != nil {
affected, _ := res.RowsAffected()
test2["result"] = affected >= 0
test2["affected"] = affected
} else {
test2["result"] = false
test2["error"] = err
}
} else {
test2["result"] = true
test2["note"] = "无可用测试数据"
}
tests = append(tests, test2)
test3 := Map{"name": "IN (?) 非空数组展开"}
articles3 := that.Db.Query("SELECT id, title FROM `article` WHERE id IN (?) LIMIT 10", []int{1, 2, 3, 4, 5})
test3["result"] = len(articles3) >= 0
test3["count"] = len(articles3)
test3["lastQuery"] = that.Db.GetLastQuery()
tests = append(tests, test3)
test4 := Map{"name": "IN (?) 空数组替换为1=0"}
articles4 := that.Db.Query("SELECT id, title FROM `article` WHERE id IN (?) LIMIT 10", []int{})
test4["result"] = len(articles4) == 0
test4["count"] = len(articles4)
test4["lastQuery"] = that.Db.GetLastQuery()
tests = append(tests, test4)
test5 := Map{"name": "NOT IN (?) 空数组替换为1=1"}
articles5 := that.Db.Query("SELECT id, title FROM `article` WHERE id NOT IN (?) LIMIT 10", []int{})
test5["result"] = len(articles5) > 0
test5["count"] = len(articles5)
test5["lastQuery"] = that.Db.GetLastQuery()
tests = append(tests, test5)
result["tests"] = tests
result["success"] = true
return result
}
+90
View File
@@ -0,0 +1,90 @@
package app
import (
"fmt"
. "code.hoteas.com/golang/hotime"
. "code.hoteas.com/golang/hotime/common"
)
var MysqlTest = CtrTest{
"tables": {Desc: "查询 MySQL 所有表", Func: func(a *Api) {
if a.DB().Type != "mysql" {
return
}
a.Verify(func(a *Api) error {
result := a.Resp().GetBody().GetMap("result")
if result == nil {
return fmt.Errorf("result 为 nil")
}
tables := result.GetSlice("tables")
if tables == nil {
return fmt.Errorf("tables 字段缺失")
}
if len(tables) == 0 {
return fmt.Errorf("MySQL 表列表为空,至少应有测试表")
}
return nil
}).Get("SHOW TABLES", 0, Map{
"tables": Slice{},
})
}},
"describe": {Desc: "查询 MySQL 表结构", Func: func(a *Api) {
if a.DB().Type != "mysql" {
return
}
// ======== 错误用例 ========
a.Get("不传table参数", 1, "请提供 table 参数")
// ======== 正确请求 + 结构校验 + Verify ========
a.Query(Map{"table": "admin"}).
Verify(func(a *Api) error {
result := a.Resp().GetBody().GetMap("result")
if result == nil {
return fmt.Errorf("result 为 nil")
}
if result.GetString("table") != "admin" {
return fmt.Errorf("table 期望 'admin', 实际 '%s'", result.GetString("table"))
}
columns := result.GetSlice("columns")
if len(columns) == 0 {
return fmt.Errorf("columns 为空,admin 表应有字段定义")
}
return nil
}).
Get("DESCRIBE admin", 0, Map{
"table": "sample", "columns": Slice{}, "sample_data": Slice{},
})
}},
"rawsql": {Desc: "MySQL 原生 SQL 测试", Func: func(a *Api) {
if a.DB().Type != "mysql" {
return
}
a.Verify(func(a *Api) error {
result := a.Resp().GetBody().GetMap("result")
if result == nil {
return fmt.Errorf("result 为 nil")
}
if !result.GetBool("success") {
return fmt.Errorf("MySQL 原生 SQL 测试未通过")
}
tests := result.GetSlice("tests")
if len(tests) == 0 {
return fmt.Errorf("tests 数组为空")
}
for i := 0; i < len(tests); i++ {
item := tests.GetMap(i)
if item != nil && !item.GetBool("result") {
return fmt.Errorf("子项 '%s' 失败", item.GetString("name"))
}
}
return nil
}).Get("MySQL原生SQL", 0, Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample", "result": true}},
})
}},
}
+126
View File
@@ -0,0 +1,126 @@
package app
import (
"fmt"
. "code.hoteas.com/golang/hotime/db"
)
// SetupDMDatabase 为达梦数据库创建测试表并灌入测试数据
func SetupDMDatabase(db *HoTimeDB) {
if db.Type != "dm" && db.Type != "dameng" {
return
}
fmt.Println("[DM Setup] 开始初始化达梦数据库测试环境...")
prefix := db.GetPrefix()
createDMTables(db, prefix)
seedTestData(db, "DM")
fmt.Println("[DM Setup] 达梦数据库测试环境初始化完成")
}
func dmTableExists(db *HoTimeDB, tableName string) bool {
// COUNT(*) 即使表为空也会返回一行;若表不存在则报错返回空切片。
// 相比 USER_TABLES,这里直接检测当前 schema 下表的可访问性,避免
// 连接 schema?schema=TEST)与 USER_TABLESSYSDBA 自身 schema)错配。
res := db.Query(`SELECT COUNT(*) as cnt FROM "` + tableName + `"`)
return len(res) > 0
}
func createDMTables(db *HoTimeDB, prefix string) {
// admin 表
tbl := prefix + "admin"
if !dmTableExists(db, tbl) {
fmt.Println("[DM Setup] 创建表:", tbl)
db.Exec(`CREATE TABLE "` + tbl + `" (
"id" INT IDENTITY(1,1) PRIMARY KEY,
"name" VARCHAR(100),
"phone" VARCHAR(20),
"state" INT DEFAULT 0,
"password" VARCHAR(100),
"role_id" INT DEFAULT 0,
"title" VARCHAR(100),
"create_time" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
"modify_time" TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`)
db.Exec(`CREATE UNIQUE INDEX "uk_` + tbl + `_phone" ON "` + tbl + `" ("phone")`)
}
// ctg 分类表
tbl = prefix + "ctg"
if !dmTableExists(db, tbl) {
fmt.Println("[DM Setup] 创建表:", tbl)
db.Exec(`CREATE TABLE "` + tbl + `" (
"id" INT IDENTITY(1,1) PRIMARY KEY,
"name" VARCHAR(100),
"state" INT DEFAULT 0,
"create_time" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
"modify_time" TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`)
}
// article 文章表
tbl = prefix + "article"
if !dmTableExists(db, tbl) {
fmt.Println("[DM Setup] 创建表:", tbl)
db.Exec(`CREATE TABLE "` + tbl + `" (
"id" INT IDENTITY(1,1) PRIMARY KEY,
"title" VARCHAR(200),
"author" VARCHAR(100),
"content" CLOB,
"state" INT DEFAULT 0,
"click_num" INT DEFAULT 0,
"sort" INT DEFAULT 0,
"img" VARCHAR(500),
"ctg_id" INT DEFAULT 0,
"admin_id" INT DEFAULT 0,
"create_time" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
"modify_time" TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`)
}
// test_batch 测试批量表
tbl = prefix + "test_batch"
if !dmTableExists(db, tbl) {
fmt.Println("[DM Setup] 创建表:", tbl)
db.Exec(`CREATE TABLE "` + tbl + `" (
"id" INT IDENTITY(1,1) PRIMARY KEY,
"name" VARCHAR(100),
"title" VARCHAR(200),
"state" INT DEFAULT 0,
"create_time" TIMESTAMP
)`)
}
// cached 老版本缓存表(兼容模式测试需要)
tbl = prefix + "cached"
if !dmTableExists(db, tbl) {
fmt.Println("[DM Setup] 创建表:", tbl)
db.Exec(`CREATE TABLE "` + tbl + `" (
"id" INT IDENTITY(1,1) PRIMARY KEY,
"key" VARCHAR(64),
"value" CLOB,
"endtime" BIGINT,
"time" BIGINT
)`)
}
// hotime_cache 新版缓存表
tbl = prefix + "hotime_cache"
if !dmTableExists(db, tbl) {
fmt.Println("[DM Setup] 创建表:", tbl)
db.Exec(`CREATE TABLE "` + tbl + `" (
"id" INT IDENTITY(1,1) PRIMARY KEY,
"key" VARCHAR(64) NOT NULL,
"value" TEXT,
"end_time" TIMESTAMP,
"state" INT DEFAULT 0,
"create_time" TIMESTAMP,
"modify_time" TIMESTAMP,
CONSTRAINT "uk_` + tbl + `_key" UNIQUE ("key")
)`)
db.Exec(`CREATE INDEX "idx_` + tbl + `_end_time" ON "` + tbl + `" ("end_time")`)
}
}
+131
View File
@@ -0,0 +1,131 @@
package app
import (
"fmt"
. "code.hoteas.com/golang/hotime/db"
)
// SetupMySQLDatabase 为 MySQL 数据库创建测试表并灌入测试数据
func SetupMySQLDatabase(db *HoTimeDB) {
if db.Type != "mysql" {
return
}
fmt.Println("[MySQL Setup] 开始初始化 MySQL 数据库测试环境...")
prefix := db.GetPrefix()
createMySQLTables(db, prefix)
seedTestData(db, "MySQL")
fmt.Println("[MySQL Setup] MySQL 数据库测试环境初始化完成")
}
func mysqlTableExists(db *HoTimeDB, tableName string) bool {
rows := db.Query("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", tableName)
return len(rows) > 0
}
func createMySQLTables(db *HoTimeDB, prefix string) {
// admin 表
tbl := prefix + "admin"
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," +
"`phone` varchar(20) DEFAULT NULL," +
"`state` int(2) DEFAULT '0'," +
"`password` varchar(100) DEFAULT NULL," +
"`role_id` int(11) DEFAULT '0'," +
"`title` varchar(100) DEFAULT NULL," +
"`create_time` datetime DEFAULT NULL," +
"`modify_time` datetime DEFAULT NULL," +
"PRIMARY KEY (`id`)," +
"UNIQUE KEY `uk_phone` (`phone`)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4")
}
// ctg 分类表
tbl = prefix + "ctg"
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," +
"`state` int(2) DEFAULT '0'," +
"`create_time` datetime DEFAULT NULL," +
"`modify_time` datetime DEFAULT NULL," +
"PRIMARY KEY (`id`)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4")
}
// article 文章表
tbl = prefix + "article"
if !mysqlTableExists(db, tbl) {
fmt.Println("[MySQL Setup] 创建表:", tbl)
db.Exec("CREATE TABLE `" + tbl + "` (" +
"`id` int(11) unsigned NOT NULL AUTO_INCREMENT," +
"`title` varchar(200) DEFAULT NULL," +
"`author` varchar(100) DEFAULT NULL," +
"`content` text," +
"`state` int(2) DEFAULT '0'," +
"`click_num` int(11) DEFAULT '0'," +
"`sort` int(11) DEFAULT '0'," +
"`img` varchar(500) DEFAULT NULL," +
"`ctg_id` int(11) DEFAULT '0'," +
"`admin_id` int(11) DEFAULT '0'," +
"`create_time` datetime DEFAULT NULL," +
"`modify_time` datetime DEFAULT NULL," +
"PRIMARY KEY (`id`)," +
"KEY `idx_state` (`state`)," +
"KEY `idx_ctg_id` (`ctg_id`)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4")
}
// test_batch 测试批量表
tbl = prefix + "test_batch"
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," +
"`title` varchar(200) DEFAULT NULL," +
"`state` int(2) DEFAULT '0'," +
"`create_time` datetime DEFAULT NULL," +
"PRIMARY KEY (`id`)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4")
}
// cached 老版本缓存表(兼容模式测试需要)
tbl = prefix + "cached"
if !mysqlTableExists(db, tbl) {
fmt.Println("[MySQL Setup] 创建表:", tbl)
db.Exec("CREATE TABLE `" + tbl + "` (" +
"`id` int(11) unsigned NOT NULL AUTO_INCREMENT," +
"`key` varchar(64) DEFAULT NULL," +
"`value` text," +
"`endtime` bigint(20) DEFAULT NULL," +
"`time` bigint(20) DEFAULT NULL," +
"PRIMARY KEY (`id`)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4")
}
// hotime_cache 新版缓存表
tbl = prefix + "hotime_cache"
if !mysqlTableExists(db, tbl) {
fmt.Println("[MySQL Setup] 创建表:", tbl)
db.Exec("CREATE TABLE `" + tbl + "` (" +
"`id` int(11) unsigned NOT NULL AUTO_INCREMENT," +
"`key` varchar(64) NOT NULL," +
"`value` text," +
"`end_time` datetime DEFAULT NULL," +
"`state` int(2) DEFAULT '0'," +
"`create_time` datetime DEFAULT NULL," +
"`modify_time` datetime DEFAULT NULL," +
"PRIMARY KEY (`id`)," +
"UNIQUE KEY `uk_key` (`key`)," +
"KEY `idx_end_time` (`end_time`)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4")
}
}
+79
View File
@@ -0,0 +1,79 @@
package app
import (
"fmt"
. "code.hoteas.com/golang/hotime/db"
)
// SetupDatabase 根据数据库类型自动初始化测试表和种子数据
// 在 TestMain 中调用一次即可
func SetupDatabase(db *HoTimeDB) {
switch db.Type {
case "dm", "dameng":
SetupDMDatabase(db)
case "mysql":
SetupMySQLDatabase(db)
}
}
// seedTestData 通用种子数据灌入(DM 和 MySQL 共用)
// 使用 ORM 的 Insert 方法,与数据库方言无关
func seedTestData(db *HoTimeDB, tag string) {
// 已有数据则跳过
if db.Count("ctg") > 0 {
fmt.Printf("[%s Setup] 表中已有数据,跳过数据灌入\n", tag)
return
}
fmt.Printf("[%s Setup] 灌入测试数据...\n", tag)
// 分类数据
ctgs := []map[string]interface{}{
{"name": "新闻资讯", "state": 0},
{"name": "技术文档", "state": 0},
{"name": "公告通知", "state": 0},
{"name": "产品介绍", "state": 0},
{"name": "已归档", "state": 1},
}
for _, c := range ctgs {
c["create_time[#]"] = "NOW()"
c["modify_time[#]"] = "NOW()"
db.Insert("ctg", c)
}
// 管理员数据
admins := []map[string]interface{}{
{"name": "超级管理员", "phone": "13800000001", "state": 1, "password": "admin123", "role_id": 1, "title": "系统管理员"},
{"name": "编辑员", "phone": "13800000002", "state": 1, "password": "editor123", "role_id": 2, "title": "内容编辑"},
{"name": "审核员", "phone": "13800000003", "state": 1, "password": "review123", "role_id": 3, "title": "内容审核"},
}
for _, a := range admins {
a["create_time[#]"] = "NOW()"
a["modify_time[#]"] = "NOW()"
db.Insert("admin", a)
}
// 文章数据 — 覆盖不同状态、分类、点击数、排序
articles := []map[string]interface{}{
{"title": "数据库入门指南", "author": "管理员", "content": "国产关系型数据库管理系统入门...", "state": 0, "click_num": 128, "sort": 10, "ctg_id": 1, "admin_id": 1},
{"title": "新闻发布系统上线公告", "author": "编辑员", "content": "本系统已正式上线运行...", "state": 0, "click_num": 256, "sort": 5, "ctg_id": 1, "admin_id": 2},
{"title": "Go语言最佳实践", "author": "管理员", "content": "Go语言在后端开发中的最佳实践总结...", "state": 0, "click_num": 512, "sort": 8, "ctg_id": 2, "admin_id": 1},
{"title": "API接口设计规范", "author": "审核员", "content": "RESTful API设计的基本规范和标准...", "state": 0, "click_num": 64, "sort": 15, "ctg_id": 2, "admin_id": 3},
{"title": "系统维护通知", "author": "管理员", "content": "系统将于本周日凌晨进行维护升级...", "state": 0, "click_num": 32, "sort": 1, "ctg_id": 3, "admin_id": 1},
{"title": "产品功能更新说明", "author": "编辑员", "content": "新版本增加了以下功能...", "state": 0, "click_num": 96, "sort": 12, "ctg_id": 4, "admin_id": 2},
{"title": "数据库性能优化指南", "author": "管理员", "content": "数据库性能优化的常见方法和技巧...", "state": 0, "click_num": 1024, "sort": 3, "ctg_id": 2, "admin_id": 1},
{"title": "缓存机制深入分析", "author": "审核员", "content": "缓存在Web应用中的重要作用...", "state": 0, "click_num": 200, "sort": 7, "ctg_id": 2, "admin_id": 3},
{"title": "旧版公告(已归档)", "author": "管理员", "content": "此公告已过期归档...", "state": 1, "click_num": 10, "sort": 0, "ctg_id": 5, "admin_id": 1},
{"title": "测试文章(隐藏)", "author": "编辑员", "content": "测试用文章不公开显示...", "state": 2, "click_num": 0, "sort": 0, "ctg_id": 1, "admin_id": 2},
{"title": "高点击量文章", "author": "编辑员", "content": "这是一篇点击量很高的文章...", "state": 0, "click_num": 9999, "sort": 2, "ctg_id": 1, "admin_id": 2},
}
for _, a := range articles {
a["create_time[#]"] = "NOW()"
a["modify_time[#]"] = "NOW()"
db.Insert("article", a)
}
fmt.Printf("[%s Setup] 数据灌入完成: ctg=%d, admin=%d, article=%d\n",
tag, db.Count("ctg"), db.Count("admin"), db.Count("article"))
}
+808
View File
@@ -0,0 +1,808 @@
package app
import (
"fmt"
"time"
. "code.hoteas.com/golang/hotime"
. "code.hoteas.com/golang/hotime/common"
. "code.hoteas.com/golang/hotime/db"
)
// initTestTables 根据数据库类型初始化测试表
func initTestTables(that *Context) {
switch that.Db.Type {
case "dm", "dameng":
// 用 COUNT(*) 直接探查表可访问性,而非 USER_TABLES(后者受 schema 错配影响)
exists := that.Db.Query(`SELECT COUNT(*) as cnt FROM "test_batch"`)
if len(exists) == 0 {
that.Db.Exec(`CREATE TABLE "test_batch" (
"id" INT IDENTITY(1,1) PRIMARY KEY,
"name" VARCHAR(100),
"title" VARCHAR(200),
"state" INT DEFAULT 0,
"create_time" TIMESTAMP
)`)
}
default:
that.Db.Exec(`CREATE TABLE IF NOT EXISTS test_batch (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100),
title VARCHAR(200),
state INT DEFAULT 0,
create_time DATETIME
)`)
}
_ = that.Db.Count("admin")
_ = that.Db.Count("article")
}
// nowFunc 返回当前数据库的 NOW() 函数表达式
func nowFunc(that *Context) string {
return "NOW()"
}
// ExpectDemoCtr 用于展示 result 结构校验的各种场景
var ExpectDemoCtr = Ctr{
"string_result": func(that *Context) {
that.Display(0, "操作成功")
},
"number_result": func(that *Context) {
that.Display(0, 42)
},
"float_result": func(that *Context) {
that.Display(0, 3.14)
},
"bool_result": func(that *Context) {
that.Display(0, true)
},
"map_result": func(that *Context) {
that.Display(0, Map{
"id": 1, "name": "示例商品", "price": 19.9, "in_stock": true,
})
},
"nested_result": func(that *Context) {
that.Display(0, Map{
"id": 100, "title": "测试订单",
"customer": Map{"id": 1, "name": "张三", "phone": "13800138000"},
"items": Slice{
Map{"id": 1, "goods_name": "苹果", "quantity": 5, "price": 3.5},
Map{"id": 2, "goods_name": "香蕉", "quantity": 10, "price": 2.0},
},
"extra": Map{
"delivery": Map{"address": "XX路1号", "fee": 5},
},
})
},
"slice_result": func(that *Context) {
that.Display(0, Slice{
Map{"id": 1, "name": "标签A"},
Map{"id": 2, "name": "标签B"},
})
},
"error_demo": func(that *Context) {
name := that.Req.FormValue("name")
if name == "" {
that.Display(3, "名称不能为空")
return
}
that.Display(0, Map{"id": 1, "name": name, "created": true})
},
"no_expect": func(that *Context) {
that.Display(0, Map{"id": 1, "data": "无预期校验"})
},
"verify_demo": func(that *Context) {
name := that.Req.FormValue("name")
if name == "" {
that.Display(3, "名称不能为空")
return
}
initTestTables(that)
id := that.Db.Insert("test_batch", Map{"name": name, "title": "verify_demo", "state": 0})
that.Display(0, Map{"id": id, "name": name})
},
}
// TestCtr 通用 ORM 测试控制器(数据库无关)
var TestCtr = Ctr{
"test": func(that *Context) {
that.Display(2, "dsadasd")
},
"all": func(that *Context) {
results := Map{}
initTestTables(that)
results["1_basic_crud"] = testBasicCRUD(that)
results["2_condition_syntax"] = testConditionSyntax(that)
results["3_chain_query"] = testChainQuery(that)
results["4_join_query"] = testJoinQuery(that)
results["5_aggregate"] = testAggregate(that)
results["6_pagination"] = testPagination(that)
results["7_batch_insert"] = testInserts(that)
results["8_upsert"] = testUpsert(that)
results["9_transaction"] = testTransaction(that)
that.Display(0, results)
},
"crud": func(that *Context) { initTestTables(that); that.Display(0, testBasicCRUD(that)) },
"condition": func(that *Context) { that.Display(0, testConditionSyntax(that)) },
"chain": func(that *Context) { that.Display(0, testChainQuery(that)) },
"join": func(that *Context) { that.Display(0, testJoinQuery(that)) },
"aggregate": func(that *Context) { that.Display(0, testAggregate(that)) },
"pagination": func(that *Context) { that.Display(0, testPagination(that)) },
"batch": func(that *Context) { initTestTables(that); that.Display(0, testInserts(that)) },
"upsert": func(that *Context) { that.Display(0, testUpsert(that)) },
"transaction": func(that *Context) { initTestTables(that); that.Display(0, testTransaction(that)) },
}
func testBasicCRUD(that *Context) Map {
result := Map{"name": "基础CRUD测试", "tests": Slice{}}
tests := Slice{}
now := nowFunc(that)
insertTest := Map{"name": "Insert 插入测试 (admin表)"}
adminId := that.Db.Insert("admin", Map{
"name": "测试管理员_" + fmt.Sprintf("%d", time.Now().Unix()),
"phone": fmt.Sprintf("138%d", time.Now().Unix()%100000000),
"state": 1,
"password": "test123456",
"role_id": 1,
"title": "测试职位",
"create_time[#]": now,
"modify_time[#]": now,
})
insertTest["result"] = adminId > 0
insertTest["adminId"] = adminId
insertTest["lastQuery"] = that.Db.GetLastQuery()
tests = append(tests, insertTest)
getTest := Map{"name": "Get 获取单条记录测试"}
admin := that.Db.Get("admin", "*", Map{"id": adminId})
getTest["result"] = admin != nil && admin.GetInt64("id") == adminId
getTest["admin"] = admin
tests = append(tests, getTest)
selectTest1 := Map{"name": "Select 单条件查询测试"}
admins1 := that.Db.Select("admin", "*", Map{"state": 1, "LIMIT": 5})
selectTest1["result"] = len(admins1) >= 0
selectTest1["count"] = len(admins1)
tests = append(tests, selectTest1)
selectTest2 := Map{"name": "Select 多条件自动AND测试"}
admins2 := that.Db.Select("admin", "*", Map{
"state": 1,
"role_id[>]": 0,
"ORDER": "id DESC",
"LIMIT": 5,
})
selectTest2["result"] = true
selectTest2["count"] = len(admins2)
selectTest2["lastQuery"] = that.Db.GetLastQuery()
tests = append(tests, selectTest2)
updateTest := Map{"name": "Update 更新测试"}
affected := that.Db.Update("admin", Map{
"title": "更新后的职位",
"modify_time[#]": now,
}, Map{"id": adminId})
updateTest["result"] = affected > 0
updateTest["affected"] = affected
tests = append(tests, updateTest)
deleteTest := Map{"name": "Delete 删除测试"}
tempId := that.Db.Insert("test_batch", Map{
"name": "临时删除测试",
"title": "测试标题",
"state": 1,
"create_time[#]": now,
})
deleteAffected := that.Db.Delete("test_batch", Map{"id": tempId})
deleteTest["result"] = deleteAffected > 0
deleteTest["affected"] = deleteAffected
tests = append(tests, deleteTest)
result["tests"] = tests
result["success"] = true
return result
}
func testConditionSyntax(that *Context) Map {
result := Map{"name": "条件查询语法测试", "tests": Slice{}}
tests := Slice{}
test1 := Map{"name": "等于条件 (=)"}
articles1 := that.Db.Select("article", "id,title", Map{"state": 0, "LIMIT": 3})
test1["result"] = true
test1["count"] = len(articles1)
tests = append(tests, test1)
test2 := Map{"name": "不等于条件 ([!])"}
articles2 := that.Db.Select("article", "id,title,state", Map{"state[!]": -1, "LIMIT": 3})
test2["result"] = true
test2["count"] = len(articles2)
test2["lastQuery"] = that.Db.GetLastQuery()
tests = append(tests, test2)
test3 := Map{"name": "大于小于条件 ([>], [<])"}
articles3 := that.Db.Select("article", "id,title,click_num", Map{
"click_num[>]": 0,
"click_num[<]": 100000,
"LIMIT": 3,
})
test3["result"] = true
test3["count"] = len(articles3)
tests = append(tests, test3)
test4 := Map{"name": "大于等于小于等于条件 ([>=], [<=])"}
articles4 := that.Db.Select("article", "id,title,sort", Map{
"sort[>=]": 0,
"sort[<=]": 100,
"LIMIT": 3,
})
test4["result"] = true
test4["count"] = len(articles4)
tests = append(tests, test4)
test5 := Map{"name": "LIKE 模糊查询 ([~])"}
articles5 := that.Db.Select("article", "id,title", Map{"title[~]": "新闻", "LIMIT": 3})
test5["result"] = true
test5["count"] = len(articles5)
test5["lastQuery"] = that.Db.GetLastQuery()
tests = append(tests, test5)
test6 := Map{"name": "右模糊查询 ([~!])"}
articles6 := that.Db.Select("admin", "id,name", Map{"name[~!]": "管理", "LIMIT": 3})
test6["result"] = true
test6["count"] = len(articles6)
tests = append(tests, test6)
test7 := Map{"name": "BETWEEN 区间查询 ([<>])"}
articles7 := that.Db.Select("article", "id,title,click_num", Map{"click_num[<>]": Slice{0, 1000}, "LIMIT": 3})
test7["result"] = true
test7["count"] = len(articles7)
test7["lastQuery"] = that.Db.GetLastQuery()
tests = append(tests, test7)
test8 := Map{"name": "NOT BETWEEN 查询 ([><])"}
articles8 := that.Db.Select("article", "id,title,sort", Map{"sort[><]": Slice{-10, 0}, "LIMIT": 3})
test8["result"] = true
test8["count"] = len(articles8)
tests = append(tests, test8)
test9 := Map{"name": "IN 查询"}
articles9 := that.Db.Select("article", "id,title", Map{"id": Slice{1, 2, 3, 4, 5}, "LIMIT": 5})
test9["result"] = true
test9["count"] = len(articles9)
test9["lastQuery"] = that.Db.GetLastQuery()
tests = append(tests, test9)
test10 := Map{"name": "NOT IN 查询 ([!])"}
articles10 := that.Db.Select("article", "id,title", Map{"id[!]": Slice{1, 2, 3}, "LIMIT": 5})
test10["result"] = true
test10["count"] = len(articles10)
tests = append(tests, test10)
test11 := Map{"name": "IS NULL 查询"}
articles11 := that.Db.Select("article", "id,title,img", Map{"img": nil, "LIMIT": 3})
test11["result"] = true
test11["count"] = len(articles11)
tests = append(tests, test11)
test12 := Map{"name": "IS NOT NULL 查询 ([!])"}
articles12 := that.Db.Select("article", "id,title,create_time", Map{"create_time[!]": nil, "LIMIT": 3})
test12["result"] = true
test12["count"] = len(articles12)
tests = append(tests, test12)
// SQL 片段查询根据数据库类型适配
test13 := Map{"name": "直接 SQL 片段查询 ([##])"}
var sqlFragment string
switch that.Db.Type {
case "dm", "dameng":
sqlFragment = "\"create_time\" > DATEADD(DAY, -365, NOW())"
default:
sqlFragment = "create_time > DATE_SUB(NOW(), INTERVAL 365 DAY)"
}
articles13 := that.Db.Select("article", "id,title,create_time", Map{
"[##]": sqlFragment,
"LIMIT": 3,
})
test13["result"] = true
test13["count"] = len(articles13)
test13["lastQuery"] = that.Db.GetLastQuery()
tests = append(tests, test13)
test14 := Map{"name": "显式 AND 条件"}
articles14 := that.Db.Select("article", "id,title,state,click_num", Map{
"AND": Map{
"state": 0,
"click_num[>=]": 0,
},
"LIMIT": 3,
})
test14["result"] = true
test14["count"] = len(articles14)
tests = append(tests, test14)
test15 := Map{"name": "OR 条件"}
articles15 := that.Db.Select("article", "id,title,sort,click_num", Map{
"OR": Map{
"sort": 0,
"click_num[>]": 10,
},
"LIMIT": 5,
})
test15["result"] = true
test15["count"] = len(articles15)
tests = append(tests, test15)
test16 := Map{"name": "嵌套 AND/OR 条件"}
articles16 := that.Db.Select("article", "id,title,sort,state", Map{
"AND": Map{
"state": 0,
"OR": Map{
"sort[>=]": 0,
"click_num[>]": 0,
},
},
"LIMIT": 5,
})
test16["result"] = true
test16["count"] = len(articles16)
test16["lastQuery"] = that.Db.GetLastQuery()
tests = append(tests, test16)
result["tests"] = tests
result["success"] = true
return result
}
func testChainQuery(that *Context) Map {
result := Map{"name": "链式查询测试", "tests": Slice{}}
tests := Slice{}
test1 := Map{"name": "基本链式查询 Table().Where().Select()"}
articles1 := that.Db.Table("article").
Where("state", 0).
Select("id,title,author")
test1["result"] = len(articles1) >= 0
test1["count"] = len(articles1)
tests = append(tests, test1)
test2 := Map{"name": "链式 And 条件"}
articles2 := that.Db.Table("article").
Where("state", 0).
And("click_num[>=]", 0).
And("sort[>=]", 0).
Select("id,title,click_num")
test2["result"] = len(articles2) >= 0
test2["count"] = len(articles2)
tests = append(tests, test2)
test3 := Map{"name": "链式 Or 条件"}
articles3 := that.Db.Table("article").
Where("state", 0).
Or(Map{
"sort": 0,
"click_num[>]": 10,
}).
Select("id,title,sort,click_num")
test3["result"] = len(articles3) >= 0
test3["count"] = len(articles3)
tests = append(tests, test3)
test4 := Map{"name": "链式 Order 排序"}
articles4 := that.Db.Table("article").
Where("state", 0).
Order("create_time DESC", "id ASC").
Limit(0, 5).
Select("id,title,create_time")
test4["result"] = len(articles4) >= 0
test4["count"] = len(articles4)
tests = append(tests, test4)
test5 := Map{"name": "链式 Limit 限制"}
articles5 := that.Db.Table("article").
Where("state", 0).
Limit(0, 3).
Select("id,title")
test5["result"] = len(articles5) <= 3
test5["count"] = len(articles5)
tests = append(tests, test5)
test6 := Map{"name": "链式 Get 获取单条"}
article6 := that.Db.Table("article").
Where("state", 0).
Get("id,title,author")
test6["result"] = article6 != nil || true
test6["article"] = article6
tests = append(tests, test6)
test7 := Map{"name": "链式 Count 统计"}
count7 := that.Db.Table("article").
Where("state", 0).
Count()
test7["result"] = count7 >= 0
test7["count"] = count7
tests = append(tests, test7)
test8 := Map{"name": "链式 Page 分页"}
articles8 := that.Db.Table("article").
Where("state", 0).
Page(1, 5).
Select("id,title")
test8["result"] = len(articles8) <= 5
test8["count"] = len(articles8)
tests = append(tests, test8)
test9 := Map{"name": "链式 Group 分组"}
stats9 := that.Db.Table("article").
Where("state", 0).
Group("ctg_id").
Select("ctg_id, COUNT(*) as cnt")
test9["result"] = len(stats9) >= 0
test9["stats"] = stats9
tests = append(tests, test9)
test10 := Map{"name": "链式 Update 更新"}
testArticle := that.Db.Table("article").Where("state", 0).Get("id")
if testArticle != nil {
affected := that.Db.Table("article").
Where("id", testArticle.GetInt64("id")).
Update(Map{"modify_time[#]": nowFunc(that)})
test10["result"] = affected >= 0
test10["affected"] = affected
} else {
test10["result"] = true
test10["note"] = "无可用测试数据"
}
tests = append(tests, test10)
result["tests"] = tests
result["success"] = true
return result
}
func testJoinQuery(that *Context) Map {
result := Map{"name": "JOIN查询测试", "tests": Slice{}}
tests := Slice{}
test1 := Map{"name": "LEFT JOIN 链式查询"}
articles1 := that.Db.Table("article").
LeftJoin("ctg", "article.ctg_id = ctg.id").
Where("article.state", 0).
Limit(0, 5).
Select("article.id, article.title, ctg.name as ctg_name")
test1["result"] = len(articles1) >= 0
test1["count"] = len(articles1)
test1["data"] = articles1
tests = append(tests, test1)
test2 := Map{"name": "传统 JOIN 语法"}
articles2 := that.Db.Select("article",
Slice{
Map{"[>]ctg": "article.ctg_id = ctg.id"},
},
"article.id, article.title, ctg.name as ctg_name",
Map{
"article.state": 0,
"LIMIT": 5,
})
test2["result"] = len(articles2) >= 0
test2["count"] = len(articles2)
test2["lastQuery"] = that.Db.GetLastQuery()
tests = append(tests, test2)
test3 := Map{"name": "多表 JOIN"}
articles3 := that.Db.Table("article").
LeftJoin("ctg", "article.ctg_id = ctg.id").
LeftJoin("admin", "article.admin_id = admin.id").
Where("article.state", 0).
Limit(0, 5).
Select("article.id, article.title, ctg.name as ctg_name, admin.name as admin_name")
test3["result"] = len(articles3) >= 0
test3["count"] = len(articles3)
test3["data"] = articles3
tests = append(tests, test3)
test4 := Map{"name": "INNER JOIN"}
articles4 := that.Db.Table("article").
InnerJoin("ctg", "article.ctg_id = ctg.id").
Where("ctg.state", 0).
Limit(0, 5).
Select("article.id, article.title, ctg.name as ctg_name")
test4["result"] = len(articles4) >= 0
test4["count"] = len(articles4)
tests = append(tests, test4)
result["tests"] = tests
result["success"] = true
return result
}
func testAggregate(that *Context) Map {
result := Map{"name": "聚合函数测试", "tests": Slice{}}
tests := Slice{}
test1 := Map{"name": "Count 总数统计"}
count1 := that.Db.Count("article")
test1["result"] = count1 >= 0
test1["count"] = count1
tests = append(tests, test1)
test2 := Map{"name": "Count 条件统计"}
count2 := that.Db.Count("article", Map{"state": 0})
test2["result"] = count2 >= 0
test2["count"] = count2
tests = append(tests, test2)
test3 := Map{"name": "Sum 求和 (单字段名)"}
sum3 := that.Db.Sum("article", "click_num", Map{"state": 0})
test3["result"] = sum3 >= 0
test3["sum"] = sum3
test3["lastQuery"] = that.Db.GetLastQuery()
tests = append(tests, test3)
test4 := Map{"name": "Avg 平均值 (单字段名)"}
avg4 := that.Db.Avg("article", "click_num", Map{"state": 0})
test4["result"] = avg4 >= 0
test4["avg"] = avg4
test4["lastQuery"] = that.Db.GetLastQuery()
tests = append(tests, test4)
test5 := Map{"name": "Max 最大值 (单字段名)"}
max5 := that.Db.Max("article", "click_num", Map{"state": 0})
test5["result"] = max5 >= 0
test5["max"] = max5
test5["lastQuery"] = that.Db.GetLastQuery()
tests = append(tests, test5)
test6 := Map{"name": "Min 最小值 (单字段名)"}
min6 := that.Db.Min("article", "sort", Map{"state": 0})
test6["result"] = true
test6["min"] = min6
test6["lastQuery"] = that.Db.GetLastQuery()
tests = append(tests, test6)
test7 := Map{"name": "GROUP BY 分组统计"}
stats7 := that.Db.Select("article",
"ctg_id, COUNT(*) as article_count, AVG(click_num) as avg_clicks, SUM(click_num) as total_clicks",
Map{
"state": 0,
"GROUP": "ctg_id",
"ORDER": "article_count DESC",
"LIMIT": 10,
})
test7["result"] = len(stats7) >= 0
test7["stats"] = stats7
tests = append(tests, test7)
test8 := Map{"name": "Sum 求和 (table.column 格式)"}
sum8 := that.Db.Sum("article", "article.click_num", Map{"state": 0})
test8["result"] = sum8 >= 0
test8["sum"] = sum8
test8["match_single_field"] = sum8 == sum3
test8["lastQuery"] = that.Db.GetLastQuery()
tests = append(tests, test8)
test9 := Map{"name": "聚合函数一致性验证"}
avg9 := that.Db.Avg("article", "article.click_num", Map{"state": 0})
max10 := that.Db.Max("article", "article.click_num", Map{"state": 0})
min11 := that.Db.Min("article", "article.sort", Map{"state": 0})
allMatch := (sum8 == sum3) && (avg9 == avg4) && (max10 == max5) && (min11 == min6)
test9["result"] = allMatch
test9["summary"] = fmt.Sprintf("Sum: %v=%v, Avg: %v=%v, Max: %v=%v, Min: %v=%v",
sum3, sum8, avg4, avg9, max5, max10, min6, min11)
tests = append(tests, test9)
joinSlice := Slice{
Map{"[>]ctg": "article.ctg_id = ctg.id"},
}
test10 := Map{"name": "Sum 带 JOIN (table.column 格式)"}
sum10 := that.Db.Sum("article", "article.click_num", joinSlice, Map{"article.state": 0})
test10["result"] = sum10 >= 0
test10["sum"] = sum10
test10["lastQuery"] = that.Db.GetLastQuery()
tests = append(tests, test10)
test11 := Map{"name": "Count 带 JOIN"}
count11 := that.Db.Count("article", joinSlice, Map{"article.state": 0})
test11["result"] = count11 >= 0
test11["count"] = count11
test11["lastQuery"] = that.Db.GetLastQuery()
tests = append(tests, test11)
result["tests"] = tests
result["success"] = true
return result
}
func testPagination(that *Context) Map {
result := Map{"name": "分页查询测试", "tests": Slice{}}
tests := Slice{}
test1 := Map{"name": "PageSelect 分页查询"}
articles1 := that.Db.Page(1, 5).PageSelect("article", "*", Map{
"state": 0,
"ORDER": "id DESC",
})
test1["result"] = len(articles1) <= 5
test1["count"] = len(articles1)
tests = append(tests, test1)
test2 := Map{"name": "PageSelect 第二页"}
articles2 := that.Db.Page(2, 5).PageSelect("article", "*", Map{
"state": 0,
"ORDER": "id DESC",
})
test2["result"] = len(articles2) <= 5
test2["count"] = len(articles2)
tests = append(tests, test2)
test3 := Map{"name": "链式 Page 分页"}
articles3 := that.Db.Table("article").
Where("state", 0).
Order("id DESC").
Page(1, 3).
Select("id,title,author")
test3["result"] = len(articles3) <= 3
test3["count"] = len(articles3)
tests = append(tests, test3)
test4 := Map{"name": "Offset 偏移查询"}
articles4 := that.Db.Table("article").
Where("state", 0).
Limit(3).
Offset(2).
Select("id,title")
test4["result"] = len(articles4) <= 3
test4["count"] = len(articles4)
test4["lastQuery"] = that.Db.GetLastQuery()
tests = append(tests, test4)
result["tests"] = tests
result["success"] = true
return result
}
func testInserts(that *Context) Map {
result := Map{"name": "批量插入测试", "tests": Slice{}}
tests := Slice{}
now := nowFunc(that)
test1 := Map{"name": "Inserts 批量插入"}
timestamp := time.Now().UnixNano()
affected1 := that.Db.Inserts("test_batch", []Map{
{"name": fmt.Sprintf("批量测试1_%d", timestamp), "title": "标题1", "state": 1},
{"name": fmt.Sprintf("批量测试2_%d", timestamp), "title": "标题2", "state": 1},
{"name": fmt.Sprintf("批量测试3_%d", timestamp), "title": "标题3", "state": 1},
})
test1["result"] = affected1 >= 0
test1["affected"] = affected1
test1["lastQuery"] = that.Db.GetLastQuery()
tests = append(tests, test1)
test2 := Map{"name": "Inserts 带 [#] 标记"}
timestamp2 := time.Now().UnixNano()
affected2 := that.Db.Inserts("test_batch", []Map{
{"name": fmt.Sprintf("带时间测试1_%d", timestamp2), "title": "标题带时间1", "state": 1, "create_time[#]": now},
{"name": fmt.Sprintf("带时间测试2_%d", timestamp2), "title": "标题带时间2", "state": 1, "create_time[#]": now},
})
test2["result"] = affected2 >= 0
test2["affected"] = affected2
tests = append(tests, test2)
that.Db.Delete("test_batch", Map{"name[~]": fmt.Sprintf("_%d", timestamp)})
that.Db.Delete("test_batch", Map{"name[~]": fmt.Sprintf("_%d", timestamp2)})
result["tests"] = tests
result["success"] = true
return result
}
func testUpsert(that *Context) Map {
result := Map{"name": "Upsert测试", "tests": Slice{}}
tests := Slice{}
now := nowFunc(that)
timestamp := time.Now().Unix()
testPhone := fmt.Sprintf("199%08d", timestamp%100000000)
test1 := Map{"name": "Upsert 插入新记录 (admin表)"}
affected1 := that.Db.Upsert("admin",
Map{
"name": "Upsert测试管理员",
"phone": testPhone,
"state": 1,
"password": "test123",
"role_id": 1,
"title": "测试职位",
"create_time[#]": now,
"modify_time[#]": now,
},
Slice{"phone"},
Slice{"name", "state", "title", "modify_time"},
)
test1["result"] = affected1 >= 0
test1["affected"] = affected1
test1["lastQuery"] = that.Db.GetLastQuery()
tests = append(tests, test1)
test2 := Map{"name": "Upsert 更新已存在记录"}
affected2 := that.Db.Upsert("admin",
Map{
"name": "Upsert更新后管理员",
"phone": testPhone,
"state": 1,
"password": "updated123",
"role_id": 2,
"title": "更新后职位",
"create_time[#]": now,
"modify_time[#]": now,
},
Slice{"phone"},
Slice{"name", "title", "role_id", "modify_time"},
)
test2["result"] = affected2 >= 0
test2["affected"] = affected2
tests = append(tests, test2)
updatedAdmin := that.Db.Get("admin", "*", Map{"phone": testPhone})
test2["updatedAdmin"] = updatedAdmin
that.Db.Delete("admin", Map{"phone": testPhone})
result["tests"] = tests
result["success"] = true
return result
}
func testTransaction(that *Context) Map {
result := Map{"name": "事务测试", "tests": Slice{}}
tests := Slice{}
now := nowFunc(that)
timestamp := time.Now().Unix()
testName1 := fmt.Sprintf("事务测试_%d", timestamp)
test1 := Map{"name": "事务成功提交"}
success1 := that.Db.Action(func(tx HoTimeDB) bool {
recordId := tx.Insert("test_batch", Map{
"name": testName1,
"title": "事务提交测试",
"state": 1,
"create_time[#]": now,
})
return recordId != 0
})
test1["result"] = success1
checkRecord := that.Db.Get("test_batch", "*", Map{"name": testName1})
test1["recordExists"] = checkRecord != nil
tests = append(tests, test1)
test2 := Map{"name": "事务回滚"}
testName2 := fmt.Sprintf("事务回滚测试_%d", timestamp)
success2 := that.Db.Action(func(tx HoTimeDB) bool {
_ = tx.Insert("test_batch", Map{
"name": testName2,
"title": "事务回滚测试",
"state": 1,
"create_time[#]": now,
})
return false
})
test2["result"] = !success2
checkRecord2 := that.Db.Get("test_batch", "*", Map{"name": testName2})
test2["recordRolledBack"] = checkRecord2 == nil
tests = append(tests, test2)
that.Db.Delete("test_batch", Map{"name": testName1})
result["tests"] = tests
result["success"] = true
return result
}
+181
View File
@@ -0,0 +1,181 @@
package app
import (
"fmt"
. "code.hoteas.com/golang/hotime"
. "code.hoteas.com/golang/hotime/common"
)
var TestTest = CtrTest{
// ======== 错误接口测试 ========
"test": {Desc: "固定错误返回", Func: func(a *Api) {
a.Get("固定返回错误码2", 2, "dsadasd")
}},
// ======== ORM 全量测试 ========
"all": {Desc: "运行全部通用测试", Func: func(a *Api) {
a.Verify(func(a *Api) error {
result := a.Resp().GetBody().GetMap("result")
if result == nil {
return fmt.Errorf("result 为 nil")
}
subTests := []string{
"1_basic_crud", "2_condition_syntax", "3_chain_query",
"4_join_query", "5_aggregate", "6_pagination",
"7_batch_insert", "8_upsert", "9_transaction",
}
for _, key := range subTests {
sub := result.GetMap(key)
if sub == nil {
return fmt.Errorf("子测试 %s 缺失", key)
}
if sub.GetString("name") == "" {
return fmt.Errorf("子测试 %s 缺少 name 字段", key)
}
tests := sub.GetSlice("tests")
if len(tests) == 0 {
return fmt.Errorf("子测试 %s 的 tests 数组为空", key)
}
}
return nil
}).Get("全部测试", 0, Map{
"1_basic_crud": Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample", "result": true}},
},
"2_condition_syntax": Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample", "result": true}},
},
"3_chain_query": Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample"}},
},
"4_join_query": Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample"}},
},
"5_aggregate": Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample"}},
},
"6_pagination": Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample"}},
},
"7_batch_insert": Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample"}},
},
"8_upsert": Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample"}},
},
"9_transaction": Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample"}},
},
})
}},
// ======== 单项 ORM 测试 ========
"crud": {Desc: "基础 CRUD 测试", Func: func(a *Api) {
a.Verify(func(a *Api) error {
return verifyOrmSubTest(a, "基础CRUD测试")
}).Get("CRUD测试", 0, Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample", "result": true}},
})
}},
"condition": {Desc: "条件查询语法测试", Func: func(a *Api) {
a.Verify(func(a *Api) error {
return verifyOrmSubTest(a, "条件查询语法测试")
}).Get("条件查询", 0, Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample", "result": true}},
})
}},
"chain": {Desc: "链式查询测试", Func: func(a *Api) {
a.Verify(func(a *Api) error {
return verifyOrmSubTest(a, "链式查询测试")
}).Get("链式查询", 0, Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample"}},
})
}},
"join": {Desc: "JOIN 查询测试", Func: func(a *Api) {
a.Verify(func(a *Api) error {
return verifyOrmSubTest(a, "JOIN查询测试")
}).Get("JOIN查询", 0, Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample"}},
})
}},
"aggregate": {Desc: "聚合函数测试", Func: func(a *Api) {
a.Verify(func(a *Api) error {
return verifyOrmSubTest(a, "聚合函数测试")
}).Get("聚合函数", 0, Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample"}},
})
}},
"pagination": {Desc: "分页查询测试", Func: func(a *Api) {
a.Verify(func(a *Api) error {
return verifyOrmSubTest(a, "分页查询测试")
}).Get("分页查询", 0, Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample"}},
})
}},
"batch": {Desc: "批量插入测试", Func: func(a *Api) {
a.Verify(func(a *Api) error {
return verifyOrmSubTest(a, "批量插入测试")
}).Get("批量插入", 0, Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample"}},
})
}},
"upsert": {Desc: "Upsert 测试", Func: func(a *Api) {
a.Verify(func(a *Api) error {
return verifyOrmSubTest(a, "Upsert测试")
}).Get("Upsert测试", 0, Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample"}},
})
}},
"transaction": {Desc: "事务测试", Func: func(a *Api) {
a.Verify(func(a *Api) error {
return verifyOrmSubTest(a, "事务测试")
}).Get("事务测试", 0, Map{
"name": "sample", "success": true,
"tests": Slice{Map{"name": "sample"}},
})
}},
}
// verifyOrmSubTest 校验 ORM 自测端点的结构完整性
// 注意:这些 handler 是诊断工具,某些子项(如 table.column 格式聚合)在特定数据库下
// 预期 result=false,因此不逐项检查 result 标志,只验证结构
func verifyOrmSubTest(a *Api, testName string) error {
result := a.Resp().GetBody().GetMap("result")
if result == nil {
return fmt.Errorf("result 为 nil")
}
if result.GetString("name") == "" {
return fmt.Errorf("%s 缺少 name 字段", testName)
}
tests := result.GetSlice("tests")
if len(tests) == 0 {
return fmt.Errorf("%s tests 数组为空", testName)
}
return nil
}
+322
View File
@@ -0,0 +1,322 @@
package main
import (
"fmt"
"time"
"code.hoteas.com/golang/hotime"
"code.hoteas.com/golang/hotime/cache"
. "code.hoteas.com/golang/hotime/common"
)
// TestBatchCacheOperations 测试所有批量缓存操作
func TestBatchCacheOperations(app *hotime.Application) {
fmt.Println("\n========== 批量缓存操作测试开始 ==========")
// 测试1: 测试 CacheMemory 批量操作
fmt.Println("\n--- 测试1: CacheMemory 批量操作 ---")
testCacheMemoryBatch(app)
// 测试2: 测试 CacheDb 批量操作
fmt.Println("\n--- 测试2: CacheDb 批量操作 ---")
testCacheDbBatch(app)
// 测试3: 测试 HoTimeCache 三级缓存批量操作
fmt.Println("\n--- 测试3: HoTimeCache 三级缓存批量操作 ---")
testHoTimeCacheBatch(app)
// 测试4: 测试 SessionIns 批量操作
fmt.Println("\n--- 测试4: SessionIns 批量操作 ---")
testSessionInsBatch(app)
// 测试5: 测试缓存反哺机制
fmt.Println("\n--- 测试5: 缓存反哺机制测试 ---")
testCacheBackfill(app)
// 测试6: 测试批量操作效率(一次性写入验证)
fmt.Println("\n--- 测试6: 批量操作效率测试 ---")
testBatchEfficiency(app)
fmt.Println("\n========== 批量缓存操作测试完成 ==========")
}
// testCacheMemoryBatch 测试内存缓存批量操作
func testCacheMemoryBatch(app *hotime.Application) {
memCache := &cache.CacheMemory{TimeOut: 3600, DbSet: true, SessionSet: true}
// 测试 CachesSet
testData := Map{
"mem_key1": "value1",
"mem_key2": "value2",
"mem_key3": "value3",
}
memCache.CachesSet(testData)
// 测试 CachesGet
keys := []string{"mem_key1", "mem_key2", "mem_key3", "mem_key_not_exist"}
result := memCache.CachesGet(keys)
if len(result) != 3 {
fmt.Printf(" [FAIL] CacheMemory.CachesGet: 期望3个结果,实际%d个\n", len(result))
} else {
fmt.Println(" [PASS] CacheMemory.CachesGet: 批量获取正确")
}
// 测试 CachesDelete
memCache.CachesDelete([]string{"mem_key1", "mem_key2"})
result2 := memCache.CachesGet(keys)
if len(result2) != 1 || result2["mem_key3"] == nil {
fmt.Printf(" [FAIL] CacheMemory.CachesDelete: 删除后期望1个结果,实际%d个\n", len(result2))
} else {
fmt.Println(" [PASS] CacheMemory.CachesDelete: 批量删除正确")
}
}
// testCacheDbBatch 测试数据库缓存批量操作
func testCacheDbBatch(app *hotime.Application) {
// 使用应用的数据库连接
dbCache := &cache.CacheDb{
TimeOut: 3600,
DbSet: true,
SessionSet: true,
Mode: cache.CacheModeNew,
Db: &app.Db,
}
// 清理测试数据
dbCache.CachesDelete([]string{"db_batch_key1", "db_batch_key2", "db_batch_key3"})
// 测试 CachesSet
testData := Map{
"db_batch_key1": "db_value1",
"db_batch_key2": "db_value2",
"db_batch_key3": "db_value3",
}
dbCache.CachesSet(testData)
// 测试 CachesGet
keys := []string{"db_batch_key1", "db_batch_key2", "db_batch_key3", "db_not_exist"}
result := dbCache.CachesGet(keys)
if len(result) != 3 {
fmt.Printf(" [FAIL] CacheDb.CachesGet: 期望3个结果,实际%d个\n", len(result))
} else {
fmt.Println(" [PASS] CacheDb.CachesGet: 批量获取正确")
}
// 验证值正确性
if result["db_batch_key1"] != "db_value1" {
fmt.Printf(" [FAIL] CacheDb.CachesGet: db_batch_key1 值不正确,期望 db_value1,实际 %v\n", result["db_batch_key1"])
} else {
fmt.Println(" [PASS] CacheDb.CachesGet: 值内容正确")
}
// 测试 CachesDelete
dbCache.CachesDelete([]string{"db_batch_key1", "db_batch_key2"})
result2 := dbCache.CachesGet(keys)
if len(result2) != 1 {
fmt.Printf(" [FAIL] CacheDb.CachesDelete: 删除后期望1个结果,实际%d个\n", len(result2))
} else {
fmt.Println(" [PASS] CacheDb.CachesDelete: 批量删除正确")
}
// 清理
dbCache.CachesDelete([]string{"db_batch_key3"})
}
// testHoTimeCacheBatch 测试 HoTimeCache 三级缓存批量操作
func testHoTimeCacheBatch(app *hotime.Application) {
htCache := app.HoTimeCache
// 清理测试数据
htCache.SessionsDelete([]string{
hotime.HEAD_SESSION_ADD + "ht_batch_key1",
hotime.HEAD_SESSION_ADD + "ht_batch_key2",
hotime.HEAD_SESSION_ADD + "ht_batch_key3",
})
// 测试 SessionsSet
testData := Map{
hotime.HEAD_SESSION_ADD + "ht_batch_key1": Map{"user": "test1", "role": "admin"},
hotime.HEAD_SESSION_ADD + "ht_batch_key2": Map{"user": "test2", "role": "user"},
hotime.HEAD_SESSION_ADD + "ht_batch_key3": Map{"user": "test3", "role": "guest"},
}
htCache.SessionsSet(testData)
// 测试 SessionsGet
keys := []string{
hotime.HEAD_SESSION_ADD + "ht_batch_key1",
hotime.HEAD_SESSION_ADD + "ht_batch_key2",
hotime.HEAD_SESSION_ADD + "ht_batch_key3",
hotime.HEAD_SESSION_ADD + "ht_not_exist",
}
result := htCache.SessionsGet(keys)
if len(result) != 3 {
fmt.Printf(" [FAIL] HoTimeCache.SessionsGet: 期望3个结果,实际%d个\n", len(result))
} else {
fmt.Println(" [PASS] HoTimeCache.SessionsGet: 批量获取正确")
}
// 测试 SessionsDelete
htCache.SessionsDelete([]string{
hotime.HEAD_SESSION_ADD + "ht_batch_key1",
hotime.HEAD_SESSION_ADD + "ht_batch_key2",
})
result2 := htCache.SessionsGet(keys)
if len(result2) != 1 {
fmt.Printf(" [FAIL] HoTimeCache.SessionsDelete: 删除后期望1个结果,实际%d个\n", len(result2))
} else {
fmt.Println(" [PASS] HoTimeCache.SessionsDelete: 批量删除正确")
}
// 清理
htCache.SessionsDelete([]string{hotime.HEAD_SESSION_ADD + "ht_batch_key3"})
}
// testSessionInsBatch 测试 SessionIns 批量操作
func testSessionInsBatch(app *hotime.Application) {
// 创建一个模拟的 SessionIns
session := &hotime.SessionIns{
SessionId: "test_batch_session_" + ObjToStr(time.Now().UnixNano()),
}
session.Init(app.HoTimeCache)
// 测试 SessionsSet
testData := Map{
"field1": "value1",
"field2": 123,
"field3": Map{"nested": "data"},
}
session.SessionsSet(testData)
// 测试 SessionsGet
result := session.SessionsGet("field1", "field2", "field3", "not_exist")
if len(result) != 3 {
fmt.Printf(" [FAIL] SessionIns.SessionsGet: 期望3个结果,实际%d个\n", len(result))
} else {
fmt.Println(" [PASS] SessionIns.SessionsGet: 批量获取正确")
}
// 验证值类型
if result["field1"] != "value1" {
fmt.Printf(" [FAIL] SessionIns.SessionsGet: field1 值不正确\n")
} else {
fmt.Println(" [PASS] SessionIns.SessionsGet: 字符串值正确")
}
var convErr Error
if ObjToInt(result["field2"], &convErr) != 123 {
fmt.Printf(" [FAIL] SessionIns.SessionsGet: field2 值不正确\n")
} else {
fmt.Println(" [PASS] SessionIns.SessionsGet: 数值类型正确")
}
// 测试 SessionsDelete
session.SessionsDelete("field1", "field2")
result2 := session.SessionsGet("field1", "field2", "field3")
if len(result2) != 1 {
fmt.Printf(" [FAIL] SessionIns.SessionsDelete: 删除后期望1个结果,实际%d个\n", len(result2))
} else {
fmt.Println(" [PASS] SessionIns.SessionsDelete: 批量删除正确")
}
}
// testCacheBackfill 测试缓存反哺机制
func testCacheBackfill(app *hotime.Application) {
htCache := app.HoTimeCache
// 直接写入数据库缓存(绕过 memory)模拟只有 db 有数据的情况
dbCache := &cache.CacheDb{
TimeOut: 3600,
DbSet: true,
SessionSet: true,
Mode: cache.CacheModeNew,
Db: &app.Db,
}
testKey := "backfill_test_key_" + ObjToStr(time.Now().UnixNano())
testValue := Map{"backfill": "test_data"}
// 直接写入 db
dbCache.Cache(testKey, testValue)
// 通过 HoTimeCache 批量获取,应该触发反哺到 memory
keys := []string{testKey}
result := htCache.CachesGet(keys)
if len(result) != 1 || result[testKey] == nil {
fmt.Println(" [FAIL] 缓存反哺: 从 DB 读取失败")
} else {
fmt.Println(" [PASS] 缓存反哺: 从 DB 读取成功")
}
// 清理
htCache.CachesDelete(keys)
}
// testBatchEfficiency 测试批量操作效率
func testBatchEfficiency(app *hotime.Application) {
session := &hotime.SessionIns{
SessionId: "efficiency_test_" + ObjToStr(time.Now().UnixNano()),
}
session.Init(app.HoTimeCache)
// 记录批量设置开始时间
startTime := time.Now()
// 设置10个字段
testData := Map{}
for i := 0; i < 10; i++ {
testData[fmt.Sprintf("eff_field_%d", i)] = fmt.Sprintf("value_%d", i)
}
session.SessionsSet(testData)
batchDuration := time.Since(startTime)
// 对比单个设置
session2 := &hotime.SessionIns{
SessionId: "efficiency_test_single_" + ObjToStr(time.Now().UnixNano()),
}
session2.Init(app.HoTimeCache)
startTime2 := time.Now()
for i := 0; i < 10; i++ {
session2.Session(fmt.Sprintf("single_field_%d", i), fmt.Sprintf("value_%d", i))
}
singleDuration := time.Since(startTime2)
fmt.Printf(" 批量设置10个字段耗时: %v\n", batchDuration)
fmt.Printf(" 单个设置10个字段耗时: %v\n", singleDuration)
if batchDuration < singleDuration {
fmt.Println(" [PASS] 批量操作效率: 批量操作更快")
} else {
fmt.Println(" [WARN] 批量操作效率: 批量操作未体现优势(可能数据量太小)")
}
// 批量获取测试
startTime3 := time.Now()
keys := make([]string, 10)
for i := 0; i < 10; i++ {
keys[i] = fmt.Sprintf("eff_field_%d", i)
}
session.SessionsGet(keys...)
batchGetDuration := time.Since(startTime3)
fmt.Printf(" 批量获取10个字段耗时: %v\n", batchGetDuration)
}
// getMapKeys 获取 Map 的所有键
func getMapKeys(m Map) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
+1
View File
@@ -0,0 +1 @@
{"sessionId":"e1b6ef","hypothesisId":"D","message":"partial_run_check","data":{"proj":"app","totalPaths":30,"visitedPaths":0,"isPartialRun":false}}
+2 -890
View File
@@ -2,11 +2,7 @@ package main
import (
. "code.hoteas.com/golang/hotime"
"fmt"
"time"
. "code.hoteas.com/golang/hotime/common"
. "code.hoteas.com/golang/hotime/db"
"code.hoteas.com/golang/hotime/example/app"
)
func main() {
@@ -16,890 +12,6 @@ func main() {
})
appIns.Run(Router{
"app": {
"test": {
// 测试入口 - 运行所有测试
"all": func(that *Context) {
results := Map{}
// 初始化测试表
initTestTables(that)
// 1. 基础 CRUD 测试
results["1_basic_crud"] = testBasicCRUD(that)
// 2. 条件查询语法测试
results["2_condition_syntax"] = testConditionSyntax(that)
// 3. 链式查询测试
results["3_chain_query"] = testChainQuery(that)
// 4. JOIN 查询测试
results["4_join_query"] = testJoinQuery(that)
// 5. 聚合函数测试
results["5_aggregate"] = testAggregate(that)
// 6. 分页查询测试
results["6_pagination"] = testPagination(that)
// 7. 批量插入测试
results["7_batch_insert"] = testInserts(that)
// 8. Upsert 测试
results["8_upsert"] = testUpsert(that)
// 9. 事务测试
results["9_transaction"] = testTransaction(that)
// 10. 原生 SQL 测试
results["10_raw_sql"] = testRawSQL(that)
that.Display(0, results)
},
// 查询数据库表结构
"tables": func(that *Context) {
// 查询所有表
tables := that.Db.Query("SHOW TABLES")
that.Display(0, Map{"tables": tables})
},
// 查询指定表的结构
"describe": func(that *Context) {
tableName := that.Req.FormValue("table")
if tableName == "" {
that.Display(1, "请提供 table 参数")
return
}
// 查询表结构
columns := that.Db.Query("DESCRIBE " + tableName)
// 查询表数据(前10条)
data := that.Db.Select(tableName, Map{"LIMIT": 10})
that.Display(0, Map{"table": tableName, "columns": columns, "sample_data": data})
},
// 单独测试入口
"crud": func(that *Context) { that.Display(0, testBasicCRUD(that)) },
"condition": func(that *Context) { that.Display(0, testConditionSyntax(that)) },
"chain": func(that *Context) { that.Display(0, testChainQuery(that)) },
"join": func(that *Context) { that.Display(0, testJoinQuery(that)) },
"aggregate": func(that *Context) { that.Display(0, testAggregate(that)) },
"pagination": func(that *Context) { that.Display(0, testPagination(that)) },
"batch": func(that *Context) { that.Display(0, testInserts(that)) },
"upsert": func(that *Context) { that.Display(0, testUpsert(that)) },
"transaction": func(that *Context) { that.Display(0, testTransaction(that)) },
"rawsql": func(that *Context) { that.Display(0, testRawSQL(that)) },
},
},
"app": app.Project,
})
}
// initTestTables 初始化测试表(MySQL真实数据库)
func initTestTables(that *Context) {
// MySQL 真实数据库已有表,创建测试用的临时表
// 创建测试用的批量插入表
that.Db.Exec(`CREATE TABLE IF NOT EXISTS test_batch (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100),
title VARCHAR(200),
state INT DEFAULT 0,
create_time DATETIME
)`)
// 检查 admin 表数据(确认表存在)
_ = that.Db.Count("admin")
_ = that.Db.Count("article")
}
// ==================== 1. 基础 CRUD 测试 ====================
func testBasicCRUD(that *Context) Map {
result := Map{"name": "基础CRUD测试", "tests": Slice{}}
tests := Slice{}
// 1.1 Insert 测试 - 使用 admin 表
insertTest := Map{"name": "Insert 插入测试 (admin表)"}
adminId := that.Db.Insert("admin", Map{
"name": "测试管理员_" + fmt.Sprintf("%d", time.Now().Unix()),
"phone": fmt.Sprintf("138%d", time.Now().Unix()%100000000),
"state": 1,
"password": "test123456",
"role_id": 1,
"title": "测试职位",
"create_time[#]": "NOW()",
"modify_time[#]": "NOW()",
})
insertTest["result"] = adminId > 0
insertTest["adminId"] = adminId
insertTest["lastQuery"] = that.Db.LastQuery
tests = append(tests, insertTest)
// 1.2 Get 测试
getTest := Map{"name": "Get 获取单条记录测试"}
admin := that.Db.Get("admin", "*", Map{"id": adminId})
getTest["result"] = admin != nil && admin.GetInt64("id") == adminId
getTest["admin"] = admin
tests = append(tests, getTest)
// 1.3 Select 测试 - 单条件
selectTest1 := Map{"name": "Select 单条件查询测试"}
admins1 := that.Db.Select("admin", "*", Map{"state": 1, "LIMIT": 5})
selectTest1["result"] = len(admins1) >= 0 // 可能表中没有数据
selectTest1["count"] = len(admins1)
tests = append(tests, selectTest1)
// 1.4 Select 测试 - 多条件(自动 AND)
selectTest2 := Map{"name": "Select 多条件自动AND测试"}
admins2 := that.Db.Select("admin", "*", Map{
"state": 1,
"role_id[>]": 0,
"ORDER": "id DESC",
"LIMIT": 5,
})
selectTest2["result"] = true // 只要不报错就算成功
selectTest2["count"] = len(admins2)
selectTest2["lastQuery"] = that.Db.LastQuery
tests = append(tests, selectTest2)
// 1.5 Update 测试
updateTest := Map{"name": "Update 更新测试"}
affected := that.Db.Update("admin", Map{
"title": "更新后的职位",
"modify_time[#]": "NOW()",
}, Map{"id": adminId})
updateTest["result"] = affected > 0
updateTest["affected"] = affected
tests = append(tests, updateTest)
// 1.6 Delete 测试 - 使用 test_batch 表
deleteTest := Map{"name": "Delete 删除测试"}
// 先创建一个临时记录用于删除
tempId := that.Db.Insert("test_batch", Map{
"name": "临时删除测试",
"title": "测试标题",
"state": 1,
"create_time[#]": "NOW()",
})
deleteAffected := that.Db.Delete("test_batch", Map{"id": tempId})
deleteTest["result"] = deleteAffected > 0
deleteTest["affected"] = deleteAffected
tests = append(tests, deleteTest)
result["tests"] = tests
result["success"] = true
return result
}
// ==================== 2. 条件查询语法测试 ====================
func testConditionSyntax(that *Context) Map {
result := Map{"name": "条件查询语法测试", "tests": Slice{}}
tests := Slice{}
// 2.1 等于 (=) - 使用 article 表
test1 := Map{"name": "等于条件 (=)"}
articles1 := that.Db.Select("article", "id,title", Map{"state": 0, "LIMIT": 3})
test1["result"] = true
test1["count"] = len(articles1)
tests = append(tests, test1)
// 2.2 不等于 ([!])
test2 := Map{"name": "不等于条件 ([!])"}
articles2 := that.Db.Select("article", "id,title,state", Map{"state[!]": -1, "LIMIT": 3})
test2["result"] = true
test2["count"] = len(articles2)
test2["lastQuery"] = that.Db.LastQuery
tests = append(tests, test2)
// 2.3 大于 ([>]) 和 小于 ([<])
test3 := Map{"name": "大于小于条件 ([>], [<])"}
articles3 := that.Db.Select("article", "id,title,click_num", Map{
"click_num[>]": 0,
"click_num[<]": 100000,
"LIMIT": 3,
})
test3["result"] = true
test3["count"] = len(articles3)
tests = append(tests, test3)
// 2.4 大于等于 ([>=]) 和 小于等于 ([<=])
test4 := Map{"name": "大于等于小于等于条件 ([>=], [<=])"}
articles4 := that.Db.Select("article", "id,title,sort", Map{
"sort[>=]": 0,
"sort[<=]": 100,
"LIMIT": 3,
})
test4["result"] = true
test4["count"] = len(articles4)
tests = append(tests, test4)
// 2.5 LIKE 模糊查询 ([~])
test5 := Map{"name": "LIKE 模糊查询 ([~])"}
articles5 := that.Db.Select("article", "id,title", Map{"title[~]": "新闻", "LIMIT": 3})
test5["result"] = true
test5["count"] = len(articles5)
test5["lastQuery"] = that.Db.LastQuery
tests = append(tests, test5)
// 2.6 右模糊 ([~!])
test6 := Map{"name": "右模糊查询 ([~!])"}
articles6 := that.Db.Select("admin", "id,name", Map{"name[~!]": "管理", "LIMIT": 3})
test6["result"] = true
test6["count"] = len(articles6)
tests = append(tests, test6)
// 2.7 BETWEEN ([<>])
test7 := Map{"name": "BETWEEN 区间查询 ([<>])"}
articles7 := that.Db.Select("article", "id,title,click_num", Map{"click_num[<>]": Slice{0, 1000}, "LIMIT": 3})
test7["result"] = true
test7["count"] = len(articles7)
test7["lastQuery"] = that.Db.LastQuery
tests = append(tests, test7)
// 2.8 NOT BETWEEN ([><])
test8 := Map{"name": "NOT BETWEEN 查询 ([><])"}
articles8 := that.Db.Select("article", "id,title,sort", Map{"sort[><]": Slice{-10, 0}, "LIMIT": 3})
test8["result"] = true
test8["count"] = len(articles8)
tests = append(tests, test8)
// 2.9 IN 查询
test9 := Map{"name": "IN 查询"}
articles9 := that.Db.Select("article", "id,title", Map{"id": Slice{1, 2, 3, 4, 5}, "LIMIT": 5})
test9["result"] = true
test9["count"] = len(articles9)
test9["lastQuery"] = that.Db.LastQuery
tests = append(tests, test9)
// 2.10 NOT IN ([!])
test10 := Map{"name": "NOT IN 查询 ([!])"}
articles10 := that.Db.Select("article", "id,title", Map{"id[!]": Slice{1, 2, 3}, "LIMIT": 5})
test10["result"] = true
test10["count"] = len(articles10)
tests = append(tests, test10)
// 2.11 IS NULL
test11 := Map{"name": "IS NULL 查询"}
articles11 := that.Db.Select("article", "id,title,img", Map{"img": nil, "LIMIT": 3})
test11["result"] = true
test11["count"] = len(articles11)
tests = append(tests, test11)
// 2.12 IS NOT NULL ([!])
test12 := Map{"name": "IS NOT NULL 查询 ([!])"}
articles12 := that.Db.Select("article", "id,title,create_time", Map{"create_time[!]": nil, "LIMIT": 3})
test12["result"] = true
test12["count"] = len(articles12)
tests = append(tests, test12)
// 2.13 直接 SQL ([##] 用于 SQL 片段)
test13 := Map{"name": "直接 SQL 片段查询 ([##])"}
articles13 := that.Db.Select("article", "id,title,create_time", Map{
"[##]": "create_time > DATE_SUB(NOW(), INTERVAL 365 DAY)",
"LIMIT": 3,
})
test13["result"] = true
test13["count"] = len(articles13)
test13["lastQuery"] = that.Db.LastQuery
tests = append(tests, test13)
// 2.14 显式 AND 条件
test14 := Map{"name": "显式 AND 条件"}
articles14 := that.Db.Select("article", "id,title,state,click_num", Map{
"AND": Map{
"state": 0,
"click_num[>=]": 0,
},
"LIMIT": 3,
})
test14["result"] = true
test14["count"] = len(articles14)
tests = append(tests, test14)
// 2.15 OR 条件
test15 := Map{"name": "OR 条件"}
articles15 := that.Db.Select("article", "id,title,sort,click_num", Map{
"OR": Map{
"sort": 0,
"click_num[>]": 10,
},
"LIMIT": 5,
})
test15["result"] = true
test15["count"] = len(articles15)
tests = append(tests, test15)
// 2.16 嵌套 AND/OR 条件
test16 := Map{"name": "嵌套 AND/OR 条件"}
articles16 := that.Db.Select("article", "id,title,sort,state", Map{
"AND": Map{
"state": 0,
"OR": Map{
"sort[>=]": 0,
"click_num[>]": 0,
},
},
"LIMIT": 5,
})
test16["result"] = true
test16["count"] = len(articles16)
test16["lastQuery"] = that.Db.LastQuery
tests = append(tests, test16)
result["tests"] = tests
result["success"] = true
return result
}
// ==================== 3. 链式查询测试 ====================
func testChainQuery(that *Context) Map {
result := Map{"name": "链式查询测试", "tests": Slice{}}
tests := Slice{}
// 3.1 基本链式查询 - 使用 article 表
test1 := Map{"name": "基本链式查询 Table().Where().Select()"}
articles1 := that.Db.Table("article").
Where("state", 0).
Select("id,title,author")
test1["result"] = len(articles1) >= 0
test1["count"] = len(articles1)
tests = append(tests, test1)
// 3.2 链式 And 条件
test2 := Map{"name": "链式 And 条件"}
articles2 := that.Db.Table("article").
Where("state", 0).
And("click_num[>=]", 0).
And("sort[>=]", 0).
Select("id,title,click_num")
test2["result"] = len(articles2) >= 0
test2["count"] = len(articles2)
tests = append(tests, test2)
// 3.3 链式 Or 条件
test3 := Map{"name": "链式 Or 条件"}
articles3 := that.Db.Table("article").
Where("state", 0).
Or(Map{
"sort": 0,
"click_num[>]": 10,
}).
Select("id,title,sort,click_num")
test3["result"] = len(articles3) >= 0
test3["count"] = len(articles3)
tests = append(tests, test3)
// 3.4 链式 Order
test4 := Map{"name": "链式 Order 排序"}
articles4 := that.Db.Table("article").
Where("state", 0).
Order("create_time DESC", "id ASC").
Limit(0, 5).
Select("id,title,create_time")
test4["result"] = len(articles4) >= 0
test4["count"] = len(articles4)
tests = append(tests, test4)
// 3.5 链式 Limit
test5 := Map{"name": "链式 Limit 限制"}
articles5 := that.Db.Table("article").
Where("state", 0).
Limit(0, 3).
Select("id,title")
test5["result"] = len(articles5) <= 3
test5["count"] = len(articles5)
tests = append(tests, test5)
// 3.6 链式 Get 单条
test6 := Map{"name": "链式 Get 获取单条"}
article6 := that.Db.Table("article").
Where("state", 0).
Get("id,title,author")
test6["result"] = article6 != nil || true // 允许为空
test6["article"] = article6
tests = append(tests, test6)
// 3.7 链式 Count
test7 := Map{"name": "链式 Count 统计"}
count7 := that.Db.Table("article").
Where("state", 0).
Count()
test7["result"] = count7 >= 0
test7["count"] = count7
tests = append(tests, test7)
// 3.8 链式 Page 分页
test8 := Map{"name": "链式 Page 分页"}
articles8 := that.Db.Table("article").
Where("state", 0).
Page(1, 5).
Select("id,title")
test8["result"] = len(articles8) <= 5
test8["count"] = len(articles8)
tests = append(tests, test8)
// 3.9 链式 Group 分组
test9 := Map{"name": "链式 Group 分组"}
stats9 := that.Db.Table("article").
Where("state", 0).
Group("ctg_id").
Select("ctg_id, COUNT(*) as cnt")
test9["result"] = len(stats9) >= 0
test9["stats"] = stats9
tests = append(tests, test9)
// 3.10 链式 Update
test10 := Map{"name": "链式 Update 更新"}
// 先获取一个文章ID
testArticle := that.Db.Table("article").Where("state", 0).Get("id")
if testArticle != nil {
affected := that.Db.Table("article").
Where("id", testArticle.GetInt64("id")).
Update(Map{"modify_time[#]": "NOW()"})
test10["result"] = affected >= 0
test10["affected"] = affected
} else {
test10["result"] = true
test10["note"] = "无可用测试数据"
}
tests = append(tests, test10)
result["tests"] = tests
result["success"] = true
return result
}
// ==================== 4. JOIN 查询测试 ====================
func testJoinQuery(that *Context) Map {
result := Map{"name": "JOIN查询测试", "tests": Slice{}}
tests := Slice{}
// 4.1 LEFT JOIN 链式 - article 关联 ctg
test1 := Map{"name": "LEFT JOIN 链式查询"}
articles1 := that.Db.Table("article").
LeftJoin("ctg", "article.ctg_id = ctg.id").
Where("article.state", 0).
Limit(0, 5).
Select("article.id, article.title, ctg.name as ctg_name")
test1["result"] = len(articles1) >= 0
test1["count"] = len(articles1)
test1["data"] = articles1
tests = append(tests, test1)
// 4.2 传统 JOIN 语法
test2 := Map{"name": "传统 JOIN 语法"}
articles2 := that.Db.Select("article",
Slice{
Map{"[>]ctg": "article.ctg_id = ctg.id"},
},
"article.id, article.title, ctg.name as ctg_name",
Map{
"article.state": 0,
"LIMIT": 5,
})
test2["result"] = len(articles2) >= 0
test2["count"] = len(articles2)
test2["lastQuery"] = that.Db.LastQuery
tests = append(tests, test2)
// 4.3 多表 JOIN - article 关联 ctg 和 admin
test3 := Map{"name": "多表 JOIN"}
articles3 := that.Db.Table("article").
LeftJoin("ctg", "article.ctg_id = ctg.id").
LeftJoin("admin", "article.admin_id = admin.id").
Where("article.state", 0).
Limit(0, 5).
Select("article.id, article.title, ctg.name as ctg_name, admin.name as admin_name")
test3["result"] = len(articles3) >= 0
test3["count"] = len(articles3)
test3["data"] = articles3
tests = append(tests, test3)
// 4.4 INNER JOIN
test4 := Map{"name": "INNER JOIN"}
articles4 := that.Db.Table("article").
InnerJoin("ctg", "article.ctg_id = ctg.id").
Where("ctg.state", 0).
Limit(0, 5).
Select("article.id, article.title, ctg.name as ctg_name")
test4["result"] = len(articles4) >= 0
test4["count"] = len(articles4)
tests = append(tests, test4)
result["tests"] = tests
result["success"] = true
return result
}
// ==================== 5. 聚合函数测试 ====================
func testAggregate(that *Context) Map {
result := Map{"name": "聚合函数测试", "tests": Slice{}}
tests := Slice{}
// 5.1 Count 总数
test1 := Map{"name": "Count 总数统计"}
count1 := that.Db.Count("article")
test1["result"] = count1 >= 0
test1["count"] = count1
tests = append(tests, test1)
// 5.2 Count 带条件
test2 := Map{"name": "Count 条件统计"}
count2 := that.Db.Count("article", Map{"state": 0})
test2["result"] = count2 >= 0
test2["count"] = count2
tests = append(tests, test2)
// 5.3 Sum 求和
test3 := Map{"name": "Sum 求和"}
sum3 := that.Db.Sum("article", "click_num", Map{"state": 0})
test3["result"] = sum3 >= 0
test3["sum"] = sum3
tests = append(tests, test3)
// 5.4 Avg 平均值
test4 := Map{"name": "Avg 平均值"}
avg4 := that.Db.Avg("article", "click_num", Map{"state": 0})
test4["result"] = avg4 >= 0
test4["avg"] = avg4
tests = append(tests, test4)
// 5.5 Max 最大值
test5 := Map{"name": "Max 最大值"}
max5 := that.Db.Max("article", "click_num", Map{"state": 0})
test5["result"] = max5 >= 0
test5["max"] = max5
tests = append(tests, test5)
// 5.6 Min 最小值
test6 := Map{"name": "Min 最小值"}
min6 := that.Db.Min("article", "sort", Map{"state": 0})
test6["result"] = true // sort 可能为 0
test6["min"] = min6
tests = append(tests, test6)
// 5.7 GROUP BY 分组统计
test7 := Map{"name": "GROUP BY 分组统计"}
stats7 := that.Db.Select("article",
"ctg_id, COUNT(*) as article_count, AVG(click_num) as avg_clicks, SUM(click_num) as total_clicks",
Map{
"state": 0,
"GROUP": "ctg_id",
"ORDER": "article_count DESC",
"LIMIT": 10,
})
test7["result"] = len(stats7) >= 0
test7["stats"] = stats7
tests = append(tests, test7)
result["tests"] = tests
result["success"] = true
return result
}
// ==================== 6. 分页查询测试 ====================
func testPagination(that *Context) Map {
result := Map{"name": "分页查询测试", "tests": Slice{}}
tests := Slice{}
// 6.1 PageSelect 分页查询
test1 := Map{"name": "PageSelect 分页查询"}
articles1 := that.Db.Page(1, 5).PageSelect("article", "*", Map{
"state": 0,
"ORDER": "id DESC",
})
test1["result"] = len(articles1) <= 5
test1["count"] = len(articles1)
tests = append(tests, test1)
// 6.2 第二页
test2 := Map{"name": "PageSelect 第二页"}
articles2 := that.Db.Page(2, 5).PageSelect("article", "*", Map{
"state": 0,
"ORDER": "id DESC",
})
test2["result"] = len(articles2) <= 5
test2["count"] = len(articles2)
tests = append(tests, test2)
// 6.3 链式分页
test3 := Map{"name": "链式 Page 分页"}
articles3 := that.Db.Table("article").
Where("state", 0).
Order("id DESC").
Page(1, 3).
Select("id,title,author")
test3["result"] = len(articles3) <= 3
test3["count"] = len(articles3)
tests = append(tests, test3)
// 6.4 Offset 偏移
test4 := Map{"name": "Offset 偏移查询"}
articles4 := that.Db.Table("article").
Where("state", 0).
Limit(3).
Offset(2).
Select("id,title")
test4["result"] = len(articles4) <= 3
test4["count"] = len(articles4)
test4["lastQuery"] = that.Db.LastQuery
tests = append(tests, test4)
result["tests"] = tests
result["success"] = true
return result
}
// ==================== 7. 批量插入测试 ====================
func testInserts(that *Context) Map {
result := Map{"name": "批量插入测试", "tests": Slice{}}
tests := Slice{}
// 7.1 批量插入
test1 := Map{"name": "Inserts 批量插入"}
timestamp := time.Now().UnixNano()
affected1 := that.Db.Inserts("test_batch", []Map{
{"name": fmt.Sprintf("批量测试1_%d", timestamp), "title": "标题1", "state": 1},
{"name": fmt.Sprintf("批量测试2_%d", timestamp), "title": "标题2", "state": 1},
{"name": fmt.Sprintf("批量测试3_%d", timestamp), "title": "标题3", "state": 1},
})
test1["result"] = affected1 >= 0
test1["affected"] = affected1
test1["lastQuery"] = that.Db.LastQuery
tests = append(tests, test1)
// 7.2 带 [#] 的批量插入
test2 := Map{"name": "Inserts 带 [#] 标记"}
timestamp2 := time.Now().UnixNano()
affected2 := that.Db.Inserts("test_batch", []Map{
{"name": fmt.Sprintf("带时间测试1_%d", timestamp2), "title": "标题带时间1", "state": 1, "create_time[#]": "NOW()"},
{"name": fmt.Sprintf("带时间测试2_%d", timestamp2), "title": "标题带时间2", "state": 1, "create_time[#]": "NOW()"},
})
test2["result"] = affected2 >= 0
test2["affected"] = affected2
tests = append(tests, test2)
// 清理测试数据
that.Db.Delete("test_batch", Map{"name[~]": fmt.Sprintf("_%d", timestamp)})
that.Db.Delete("test_batch", Map{"name[~]": fmt.Sprintf("_%d", timestamp2)})
result["tests"] = tests
result["success"] = true
return result
}
// ==================== 8. Upsert 测试 ====================
func testUpsert(that *Context) Map {
result := Map{"name": "Upsert测试", "tests": Slice{}}
tests := Slice{}
// 使用 admin 表测试 UpsertMySQL ON DUPLICATE KEY UPDATE
timestamp := time.Now().Unix()
testPhone := fmt.Sprintf("199%08d", timestamp%100000000)
// 8.1 Upsert 插入新记录
test1 := Map{"name": "Upsert 插入新记录 (admin表)"}
affected1 := that.Db.Upsert("admin",
Map{
"name": "Upsert测试管理员",
"phone": testPhone,
"state": 1,
"password": "test123",
"role_id": 1,
"title": "测试职位",
"create_time[#]": "NOW()",
"modify_time[#]": "NOW()",
},
Slice{"phone"},
Slice{"name", "state", "title", "modify_time"},
)
test1["result"] = affected1 >= 0
test1["affected"] = affected1
test1["lastQuery"] = that.Db.LastQuery
tests = append(tests, test1)
// 8.2 Upsert 更新已存在记录
test2 := Map{"name": "Upsert 更新已存在记录"}
affected2 := that.Db.Upsert("admin",
Map{
"name": "Upsert更新后管理员",
"phone": testPhone,
"state": 1,
"password": "updated123",
"role_id": 2,
"title": "更新后职位",
"create_time[#]": "NOW()",
"modify_time[#]": "NOW()",
},
Slice{"phone"},
Slice{"name", "title", "role_id", "modify_time"},
)
test2["result"] = affected2 >= 0
test2["affected"] = affected2
tests = append(tests, test2)
// 验证更新结果
updatedAdmin := that.Db.Get("admin", "*", Map{"phone": testPhone})
test2["updatedAdmin"] = updatedAdmin
// 清理测试数据
that.Db.Delete("admin", Map{"phone": testPhone})
result["tests"] = tests
result["success"] = true
return result
}
// ==================== 9. 事务测试 ====================
func testTransaction(that *Context) Map {
result := Map{"name": "事务测试", "tests": Slice{}}
tests := Slice{}
// 9.1 事务成功提交
test1 := Map{"name": "事务成功提交"}
timestamp := time.Now().Unix()
testName1 := fmt.Sprintf("事务测试_%d", timestamp)
success1 := that.Db.Action(func(tx HoTimeDB) bool {
// 插入记录到 test_batch 表
recordId := tx.Insert("test_batch", Map{
"name": testName1,
"title": "事务提交测试",
"state": 1,
"create_time[#]": "NOW()",
})
return recordId != 0
})
test1["result"] = success1
// 验证数据是否存在
checkRecord := that.Db.Get("test_batch", "*", Map{"name": testName1})
test1["recordExists"] = checkRecord != nil
tests = append(tests, test1)
// 9.2 事务回滚
test2 := Map{"name": "事务回滚"}
testName2 := fmt.Sprintf("事务回滚测试_%d", timestamp)
success2 := that.Db.Action(func(tx HoTimeDB) bool {
// 插入记录
_ = tx.Insert("test_batch", Map{
"name": testName2,
"title": "事务回滚测试",
"state": 1,
"create_time[#]": "NOW()",
})
// 返回 false 触发回滚
return false
})
test2["result"] = !success2 // 期望回滚,所以 success2 应该为 false
// 验证数据是否不存在(已回滚)
checkRecord2 := that.Db.Get("test_batch", "*", Map{"name": testName2})
test2["recordRolledBack"] = checkRecord2 == nil
tests = append(tests, test2)
// 清理测试数据
that.Db.Delete("test_batch", Map{"name": testName1})
result["tests"] = tests
result["success"] = true
return result
}
// ==================== 10. 原生 SQL 测试 ====================
func testRawSQL(that *Context) Map {
result := Map{"name": "原生SQL测试", "tests": Slice{}}
tests := Slice{}
// 10.1 Query 查询 - 使用真实的 article 表
test1 := Map{"name": "Query 原生查询"}
articles1 := that.Db.Query("SELECT id, title, author FROM `article` WHERE state = ? LIMIT ?", 0, 5)
test1["result"] = len(articles1) >= 0
test1["count"] = len(articles1)
tests = append(tests, test1)
// 10.2 Exec 执行 - 使用 article 表
test2 := Map{"name": "Exec 原生执行"}
// 更新一条记录
testArticle := that.Db.Get("article", "id", Map{"state": 0})
if testArticle != nil {
res, err := that.Db.Exec("UPDATE `article` SET modify_time = NOW() WHERE id = ?", testArticle.GetInt64("id"))
if err.GetError() == nil && res != nil {
affected, _ := res.RowsAffected()
test2["result"] = affected >= 0
test2["affected"] = affected
} else {
test2["result"] = false
test2["error"] = err.GetError()
}
} else {
test2["result"] = true
test2["note"] = "无可用测试数据"
}
tests = append(tests, test2)
// ==================== IN/NOT IN 数组测试 ====================
// H1: IN (?) 配合非空数组能正确展开
test3 := Map{"name": "H1: IN (?) 非空数组展开"}
articles3 := that.Db.Query("SELECT id, title FROM `article` WHERE id IN (?) LIMIT 10", []int{1, 2, 3, 4, 5})
test3["result"] = len(articles3) >= 0
test3["count"] = len(articles3)
test3["lastQuery"] = that.Db.LastQuery
tests = append(tests, test3)
// H2: IN (?) 配合空数组替换为 1=0
test4 := Map{"name": "H2: IN (?) 空数组替换为1=0"}
articles4 := that.Db.Query("SELECT id, title FROM `article` WHERE id IN (?) LIMIT 10", []int{})
test4["result"] = len(articles4) == 0 // 空数组的IN应该返回0条
test4["count"] = len(articles4)
test4["lastQuery"] = that.Db.LastQuery
test4["expected"] = "count=0, SQL应包含1=0"
tests = append(tests, test4)
// H3: NOT IN (?) 配合空数组替换为 1=1
test5 := Map{"name": "H3: NOT IN (?) 空数组替换为1=1"}
articles5 := that.Db.Query("SELECT id, title FROM `article` WHERE id NOT IN (?) LIMIT 10", []int{})
test5["result"] = len(articles5) > 0 // NOT IN空数组应该返回记录
test5["count"] = len(articles5)
test5["lastQuery"] = that.Db.LastQuery
test5["expected"] = "count>0, SQL应包含1=1"
tests = append(tests, test5)
// H4: NOT IN (?) 配合非空数组正常展开
test6 := Map{"name": "H4: NOT IN (?) 非空数组展开"}
articles6 := that.Db.Query("SELECT id, title FROM `article` WHERE id NOT IN (?) LIMIT 10", []int{1, 2, 3})
test6["result"] = len(articles6) >= 0
test6["count"] = len(articles6)
test6["lastQuery"] = that.Db.LastQuery
tests = append(tests, test6)
// H5: 普通 ? 占位符保持原有行为
test7 := Map{"name": "H5: 普通?占位符不受影响"}
articles7 := that.Db.Query("SELECT id, title FROM `article` WHERE state = ? LIMIT ?", 0, 5)
test7["result"] = len(articles7) >= 0
test7["count"] = len(articles7)
test7["lastQuery"] = that.Db.LastQuery
tests = append(tests, test7)
// 额外测试: Select ORM方法的空数组处理
test8 := Map{"name": "ORM Select: IN空数组"}
articles8 := that.Db.Select("article", "id,title", Map{"id": []int{}, "LIMIT": 10})
test8["result"] = len(articles8) == 0
test8["count"] = len(articles8)
test8["lastQuery"] = that.Db.LastQuery
tests = append(tests, test8)
// 额外测试: Select ORM方法的NOT IN空数组处理
test9 := Map{"name": "ORM Select: NOT IN空数组"}
articles9 := that.Db.Select("article", "id,title", Map{"id[!]": []int{}, "LIMIT": 10})
test9["result"] = len(articles9) > 0 // NOT IN 空数组应返回记录
test9["count"] = len(articles9)
test9["lastQuery"] = that.Db.LastQuery
tests = append(tests, test9)
result["tests"] = tests
result["success"] = true
return result
}
+599
View File
@@ -0,0 +1,599 @@
// migrate: MySQL 8.0 → 达梦 DM8 直连迁移工具
// 用法:在 hotimev1.5 目录下执行
// go run example/migrate/main.go --config d:/work/myst-go/config/config.json
package main
import (
"database/sql"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"strings"
"time"
_ "gitee.com/chunanyong/dm"
_ "github.com/go-sql-driver/mysql"
)
// ─────────────────── 配置读取 ───────────────────
type DBConf struct {
Host string `json:"host"`
Port string `json:"port"`
Name string `json:"name"`
User string `json:"user"`
Password string `json:"password"`
Prefix string `json:"prefix"`
}
type Config struct {
DB map[string]DBConf `json:"db"`
}
func loadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var raw map[string]json.RawMessage
if err = json.Unmarshal(data, &raw); err != nil {
return nil, err
}
var dbRaw map[string]DBConf
if err = json.Unmarshal(raw["db"], &dbRaw); err != nil {
return nil, err
}
return &Config{DB: dbRaw}, nil
}
func getMySQLConf(cfg *Config) (DBConf, error) {
// 1. 精确匹配 "mysql"(启用态)
if v, ok := cfg.DB["mysql"]; ok {
return v, nil
}
// 2. 迁移后改为 "mysql-" 的情况(原始主源)
if v, ok := cfg.DB["mysql-"]; ok {
log.Printf("[注意] MySQL 配置 key=mysql- 已禁用,仍用于迁移源(host=%s db=%s", v.Host, v.Name)
return v, nil
}
// 3. 其他以 mysql 开头且不以 _ 结尾的 key(排除 mysql_ 这类备用库)
for k, v := range cfg.DB {
if strings.HasPrefix(k, "mysql") && !strings.HasSuffix(k, "_") {
log.Printf("[注意] 使用 MySQL 配置 key=%q (host=%s db=%s)", k, v.Host, v.Name)
return v, nil
}
}
return DBConf{}, fmt.Errorf("config.json 中未找到 mysql 配置(期望 key=mysql 或 mysql-")
}
func getDMConf(cfg *Config) (DBConf, error) {
for k, v := range cfg.DB {
if strings.HasPrefix(k, "dm") && !strings.HasSuffix(k, "-") {
return v, nil
}
}
for k, v := range cfg.DB {
if strings.HasPrefix(k, "dm") {
return v, nil
}
}
return DBConf{}, fmt.Errorf("config.json 中未找到 dm 配置")
}
// ─────────────────── 类型转换 ───────────────────
func mysqlTypeToDM(dataType string, charLen *int64, numPrec, numScale *int64) string {
dt := strings.ToLower(dataType)
switch dt {
case "int", "integer", "mediumint", "smallint", "tinyint", "year", "bit":
return "INT"
case "bigint":
return "BIGINT"
case "float":
return "FLOAT"
case "double", "double precision", "real":
return "DOUBLE"
case "decimal", "numeric":
p := int64(10)
s := int64(0)
if numPrec != nil {
p = *numPrec
}
if numScale != nil {
s = *numScale
}
return fmt.Sprintf("DECIMAL(%d,%d)", p, s)
case "varchar", "nvarchar":
l := int64(255)
if charLen != nil {
l = *charLen
}
if l > 32767 {
return "CLOB"
}
return fmt.Sprintf("VARCHAR(%d)", l)
case "char", "nchar":
l := int64(1)
if charLen != nil {
l = *charLen
}
return fmt.Sprintf("CHAR(%d)", l)
case "datetime", "timestamp":
return "TIMESTAMP"
case "date":
return "DATE"
case "time":
return "TIME"
case "text", "tinytext", "mediumtext", "longtext":
return "CLOB"
case "blob", "tinyblob", "mediumblob", "longblob", "binary", "varbinary":
return "BLOB"
case "json":
return "CLOB"
case "enum", "set":
return "VARCHAR(100)"
default:
return "VARCHAR(255)"
}
}
// q 用双引号包裹标识符(小写)
func q(name string) string {
return `"` + strings.ToLower(name) + `"`
}
// ─────────────────── 列信息 ───────────────────
type Column struct {
Name string
DataType string
CharLen *int64
NumPrec *int64
NumScale *int64
IsNullable bool
Default *string
Extra string
Comment string
ColKey string
}
type Index struct {
Name string
IsUnique bool
ColNames []string
}
func getColumns(mysqlDB *sql.DB, dbName, tableName string) ([]Column, error) {
rows, err := mysqlDB.Query(`
SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH,
NUMERIC_PRECISION, NUMERIC_SCALE,
IS_NULLABLE, COLUMN_DEFAULT, EXTRA, COLUMN_COMMENT, COLUMN_KEY
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA=? AND TABLE_NAME=?
ORDER BY ORDINAL_POSITION`, dbName, tableName)
if err != nil {
return nil, err
}
defer rows.Close()
var cols []Column
for rows.Next() {
var c Column
var nullable string
err = rows.Scan(&c.Name, &c.DataType, &c.CharLen, &c.NumPrec, &c.NumScale,
&nullable, &c.Default, &c.Extra, &c.Comment, &c.ColKey)
if err != nil {
return nil, err
}
c.IsNullable = (nullable == "YES")
cols = append(cols, c)
}
return cols, rows.Err()
}
func getIndexes(mysqlDB *sql.DB, dbName, tableName string) ([]Index, error) {
rows, err := mysqlDB.Query(`
SELECT INDEX_NAME, NON_UNIQUE, COLUMN_NAME, SEQ_IN_INDEX
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA=? AND TABLE_NAME=? AND INDEX_NAME!='PRIMARY'
ORDER BY INDEX_NAME, SEQ_IN_INDEX`, dbName, tableName)
if err != nil {
return nil, err
}
defer rows.Close()
indexMap := map[string]*Index{}
var order []string
for rows.Next() {
var idxName, colName string
var nonUnique, seqInIndex int
if err = rows.Scan(&idxName, &nonUnique, &colName, &seqInIndex); err != nil {
return nil, err
}
if _, ok := indexMap[idxName]; !ok {
indexMap[idxName] = &Index{Name: idxName, IsUnique: nonUnique == 0}
order = append(order, idxName)
}
indexMap[idxName].ColNames = append(indexMap[idxName].ColNames, colName)
}
var result []Index
for _, name := range order {
result = append(result, *indexMap[name])
}
return result, rows.Err()
}
// ─────────────────── DDL 生成 ───────────────────
func buildCreateTable(tableName string, cols []Column) string {
var lines []string
var pkCol string
for _, c := range cols {
dmType := mysqlTypeToDM(c.DataType, c.CharLen, c.NumPrec, c.NumScale)
isAuto := strings.Contains(strings.ToLower(c.Extra), "auto_increment")
isPK := c.ColKey == "PRI"
var parts []string
parts = append(parts, fmt.Sprintf(" %s %s", q(c.Name), dmType))
if isPK && isAuto {
parts = append(parts, "IDENTITY(1,1) NOT NULL")
pkCol = c.Name
} else {
if !c.IsNullable {
parts = append(parts, "NOT NULL")
}
if c.Default != nil {
dv := *c.Default
upper := strings.ToUpper(dv)
switch {
case upper == "CURRENT_TIMESTAMP" || upper == "NOW()":
parts = append(parts, "DEFAULT CURRENT_TIMESTAMP")
case dmType == "INT" || dmType == "BIGINT" || dmType == "FLOAT" ||
dmType == "DOUBLE" || strings.HasPrefix(dmType, "DECIMAL"):
parts = append(parts, "DEFAULT "+dv)
case dmType == "CLOB" || dmType == "BLOB":
// 不设默认值
default:
safe := strings.ReplaceAll(dv, "'", "''")
parts = append(parts, fmt.Sprintf("DEFAULT '%s'", safe))
}
}
}
lines = append(lines, strings.Join(parts, " "))
}
// 主键约束
if pkCol != "" {
lines = append(lines, fmt.Sprintf(" PRIMARY KEY (%s)", q(pkCol)))
} else {
var pkCols []string
for _, c := range cols {
if c.ColKey == "PRI" {
pkCols = append(pkCols, q(c.Name))
}
}
if len(pkCols) > 0 {
lines = append(lines, fmt.Sprintf(" PRIMARY KEY (%s)", strings.Join(pkCols, ", ")))
}
}
return fmt.Sprintf("CREATE TABLE %s (\n%s\n)", q(tableName), strings.Join(lines, ",\n"))
}
// ─────────────────── DM8 表操作 ───────────────────
func tableExistsInDM(dmDB *sql.DB, tableName string) bool {
var cnt int
err := dmDB.QueryRow(fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE ROWNUM=1`, q(tableName))).Scan(&cnt)
return err == nil
}
func createTableInDM(dmDB *sql.DB, tableName string, cols []Column, indexes []Index,
tableComment string) error {
// 如果已存在则 DROP
if tableExistsInDM(dmDB, tableName) {
log.Printf(" 表 %s 已存在,先删除 ...", tableName)
_, err := dmDB.Exec(fmt.Sprintf(`DROP TABLE %s CASCADE`, q(tableName)))
if err != nil {
return fmt.Errorf("DROP TABLE %s: %w", tableName, err)
}
}
ddl := buildCreateTable(tableName, cols)
log.Printf(" CREATE TABLE %s ...", tableName)
if _, err := dmDB.Exec(ddl); err != nil {
return fmt.Errorf("CREATE TABLE %s: %w\nDDL:\n%s", tableName, err, ddl)
}
// 表注释
if tableComment != "" {
safe := strings.ReplaceAll(tableComment, "'", "''")
stmt := fmt.Sprintf("COMMENT ON TABLE %s IS '%s'", q(tableName), safe)
if _, err := dmDB.Exec(stmt); err != nil {
log.Printf(" [警告] 表注释失败: %v", err)
}
}
// 列注释
for _, c := range cols {
if c.Comment != "" {
safe := strings.ReplaceAll(c.Comment, "'", "''")
stmt := fmt.Sprintf("COMMENT ON COLUMN %s.%s IS '%s'", q(tableName), q(c.Name), safe)
if _, err := dmDB.Exec(stmt); err != nil {
log.Printf(" [警告] 列注释 %s.%s 失败: %v", tableName, c.Name, err)
}
}
}
// 索引
for _, idx := range indexes {
idxName := tableName + "_" + idx.Name
uniqueKW := ""
if idx.IsUnique {
uniqueKW = "UNIQUE "
}
colList := make([]string, len(idx.ColNames))
for i, cn := range idx.ColNames {
colList[i] = q(cn)
}
stmt := fmt.Sprintf("CREATE %sINDEX %s ON %s (%s)",
uniqueKW, q(idxName), q(tableName), strings.Join(colList, ", "))
if _, err := dmDB.Exec(stmt); err != nil {
log.Printf(" [警告] 创建索引 %s 失败: %v", idxName, err)
}
}
return nil
}
// ─────────────────── 数据迁移 ───────────────────
// hasIdentityCol 判断列表中是否有自增主键列
func hasIdentityCol(cols []Column) bool {
for _, c := range cols {
if strings.Contains(strings.ToLower(c.Extra), "auto_increment") && c.ColKey == "PRI" {
return true
}
}
return false
}
func migrateData(mysqlDB, dmDB *sql.DB, tableName string, cols []Column, batchSize int) (int64, error) {
colNames := make([]string, len(cols))
dmColNames := make([]string, len(cols))
placeholders := make([]string, len(cols))
for i, c := range cols {
colNames[i] = "`" + c.Name + "`"
dmColNames[i] = q(c.Name)
placeholders[i] = "?"
}
insertSQL := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)",
q(tableName),
strings.Join(dmColNames, ", "),
strings.Join(placeholders, ", "))
// 统计总行数
var total int64
_ = mysqlDB.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM `%s`", tableName)).Scan(&total)
// DM8:有 IDENTITY 列时,需要 SET IDENTITY_INSERT ON 才能插入指定 ID
if hasIdentityCol(cols) {
if _, err := dmDB.Exec(fmt.Sprintf("SET IDENTITY_INSERT %s ON", q(tableName))); err != nil {
log.Printf(" [警告] SET IDENTITY_INSERT ON 失败(将继续): %v", err)
}
defer func() {
_, _ = dmDB.Exec(fmt.Sprintf("SET IDENTITY_INSERT %s OFF", q(tableName)))
}()
}
stmt, err := dmDB.Prepare(insertSQL)
if err != nil {
return 0, fmt.Errorf("Prepare INSERT %s: %w", tableName, err)
}
defer stmt.Close()
var offset int64
var migrated int64
for {
selectSQL := fmt.Sprintf("SELECT %s FROM `%s` LIMIT %d OFFSET %d",
strings.Join(colNames, ", "), tableName, batchSize, offset)
rows, err := mysqlDB.Query(selectSQL)
if err != nil {
return migrated, err
}
vals := make([]interface{}, len(cols))
valPtrs := make([]interface{}, len(cols))
for i := range vals {
valPtrs[i] = &vals[i]
}
rowCount := 0
for rows.Next() {
if err = rows.Scan(valPtrs...); err != nil {
rows.Close()
return migrated, err
}
// 处理 []byte → stringDM8 CLOB 等)
args := make([]interface{}, len(cols))
for i, v := range vals {
if b, ok := v.([]byte); ok {
args[i] = string(b)
} else {
args[i] = v
}
}
if _, err = stmt.Exec(args...); err != nil {
rows.Close()
return migrated, fmt.Errorf("INSERT row into %s: %w", tableName, err)
}
migrated++
rowCount++
}
rows.Close()
if err = rows.Err(); err != nil {
return migrated, err
}
if rowCount == 0 {
break
}
offset += int64(batchSize)
if total > 0 {
pct := float64(migrated) * 100 / float64(total)
fmt.Printf("\r 进度: %d/%d (%.1f%%)", migrated, total, pct)
}
}
if total > 0 {
fmt.Println()
}
return migrated, nil
}
// ─────────────────── 主程序 ───────────────────
var skipTables = map[string]bool{
"hotime_cache": true,
"cached": true,
}
func main() {
configPath := flag.String("config", "d:/work/myst-go/config/config.json", "配置文件路径")
batchSize := flag.Int("batch", 500, "每批迁移行数")
onlyTables := flag.String("only", "", "只迁移指定表(逗号分隔)")
flag.Parse()
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
log.SetPrefix("[migrate] ")
// 读配置
log.Printf("读取配置: %s", *configPath)
cfg, err := loadConfig(*configPath)
if err != nil {
log.Fatalf("读取配置失败: %v", err)
}
mysqlConf, err := getMySQLConf(cfg)
if err != nil {
log.Fatalf("%v", err)
}
dmConf, err := getDMConf(cfg)
if err != nil {
log.Fatalf("%v", err)
}
log.Printf("源库 MySQL : %s:%s db=%s user=%s", mysqlConf.Host, mysqlConf.Port, mysqlConf.Name, mysqlConf.User)
log.Printf("目标 DM8 : %s:%s schema=%s user=%s", dmConf.Host, dmConf.Port, dmConf.Name, dmConf.User)
// 连接 MySQL
mysqlDSN := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
mysqlConf.User, mysqlConf.Password, mysqlConf.Host, mysqlConf.Port, mysqlConf.Name)
log.Printf("连接 MySQL ...")
mysqlDB, err := sql.Open("mysql", mysqlDSN)
if err != nil {
log.Fatalf("sql.Open mysql: %v", err)
}
defer mysqlDB.Close()
if err = mysqlDB.Ping(); err != nil {
log.Fatalf("MySQL Ping 失败: %v\nDSN=%s", err, mysqlDSN)
}
log.Printf("MySQL 连接成功")
// 连接 DM8
// DSN 格式: dm://user:password@host:port?schema=SCHEMA
dmDSN := fmt.Sprintf("dm://%s:%s@%s:%s?schema=%s",
dmConf.User, dmConf.Password, dmConf.Host, dmConf.Port, dmConf.Name)
log.Printf("连接 DM8 ...")
dmDB, err := sql.Open("dm", dmDSN)
if err != nil {
log.Fatalf("sql.Open dm: %v", err)
}
defer dmDB.Close()
dmDB.SetConnMaxLifetime(5 * time.Minute)
dmDB.SetMaxOpenConns(3)
if err = dmDB.Ping(); err != nil {
log.Fatalf("DM8 Ping 失败: %v\nDSN=%s", err, dmDSN)
}
log.Printf("DM8 连接成功")
// 枚举 MySQL 表
tables, err := mysqlDB.Query(`
SELECT TABLE_NAME, IFNULL(TABLE_COMMENT,'')
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA=? AND TABLE_TYPE='BASE TABLE'
ORDER BY TABLE_NAME`, mysqlConf.Name)
if err != nil {
log.Fatalf("枚举表失败: %v", err)
}
type tableInfo struct{ name, comment string }
var allTables []tableInfo
for tables.Next() {
var ti tableInfo
tables.Scan(&ti.name, &ti.comment)
allTables = append(allTables, ti)
}
tables.Close()
// 过滤
onlySet := map[string]bool{}
if *onlyTables != "" {
for _, t := range strings.Split(*onlyTables, ",") {
onlySet[strings.TrimSpace(t)] = true
}
}
var toMigrate []tableInfo
for _, ti := range allTables {
if skipTables[ti.name] {
log.Printf("跳过(框架内置): %s", ti.name)
continue
}
if len(onlySet) > 0 && !onlySet[ti.name] {
log.Printf("跳过(未指定): %s", ti.name)
continue
}
toMigrate = append(toMigrate, ti)
}
log.Printf("共 %d 张表待迁移", len(toMigrate))
fmt.Println(strings.Repeat("─", 60))
// 逐表迁移
var totalRows int64
start := time.Now()
for i, ti := range toMigrate {
log.Printf("[%d/%d] 迁移表: %s", i+1, len(toMigrate), ti.name)
cols, err := getColumns(mysqlDB, mysqlConf.Name, ti.name)
if err != nil {
log.Fatalf(" 读取列失败 %s: %v", ti.name, err)
}
indexes, err := getIndexes(mysqlDB, mysqlConf.Name, ti.name)
if err != nil {
log.Printf(" [警告] 读取索引失败 %s: %v", ti.name, err)
}
// 建表
if err = createTableInDM(dmDB, ti.name, cols, indexes, ti.comment); err != nil {
log.Fatalf(" 建表失败 %s: %v", ti.name, err)
}
// 迁移数据
migrated, err := migrateData(mysqlDB, dmDB, ti.name, cols, *batchSize)
if err != nil {
log.Fatalf(" 数据迁移失败 %s: %v", ti.name, err)
}
totalRows += migrated
log.Printf(" ✓ %s 完成,迁移 %d 行", ti.name, migrated)
}
elapsed := time.Since(start)
fmt.Println(strings.Repeat("─", 60))
log.Printf("迁移完成!共 %d 表,%d 行,耗时 %s", len(toMigrate), totalRows, elapsed.Round(time.Millisecond))
log.Printf("下一步:确认数据无误后,config.json 中 mysql→mysql-dm-→dm(已完成)")
}
+94
View File
@@ -0,0 +1,94 @@
-- 达梦数据库测试环境初始化脚本
-- Schema: TEST(在 config.json 中配置 "dm": {"name": "TEST", ...}
-- 执行前确保已切换到 TEST Schema,或以 SYSDBA 身份执行
-- 连接串示例: dm://SYSDBA:SC_sysdba2026@172.31.18.249:5236?schema=TEST
-- ===================== DDL =====================
CREATE TABLE "admin" (
"id" INT IDENTITY(1,1) PRIMARY KEY,
"name" VARCHAR(100),
"phone" VARCHAR(20),
"state" INT DEFAULT 0,
"password" VARCHAR(100),
"role_id" INT DEFAULT 0,
"title" VARCHAR(100),
"create_time" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
"modify_time" TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX "uk_admin_phone" ON "admin" ("phone");
CREATE TABLE "ctg" (
"id" INT IDENTITY(1,1) PRIMARY KEY,
"name" VARCHAR(100),
"state" INT DEFAULT 0,
"create_time" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
"modify_time" TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE "article" (
"id" INT IDENTITY(1,1) PRIMARY KEY,
"title" VARCHAR(200),
"author" VARCHAR(100),
"content" CLOB,
"state" INT DEFAULT 0,
"click_num" INT DEFAULT 0,
"sort" INT DEFAULT 0,
"img" VARCHAR(500),
"ctg_id" INT DEFAULT 0,
"admin_id" INT DEFAULT 0,
"create_time" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
"modify_time" TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE "test_batch" (
"id" INT IDENTITY(1,1) PRIMARY KEY,
"name" VARCHAR(100),
"title" VARCHAR(200),
"state" INT DEFAULT 0,
"create_time" TIMESTAMP
);
CREATE TABLE "cached" (
"id" INT IDENTITY(1,1) PRIMARY KEY,
"key" VARCHAR(64),
"value" CLOB,
"endtime" BIGINT,
"time" BIGINT
);
CREATE TABLE "hotime_cache" (
"id" INT IDENTITY(1,1) PRIMARY KEY,
"key" VARCHAR(64) NOT NULL,
"value" TEXT,
"end_time" TIMESTAMP,
"state" INT DEFAULT 0,
"create_time" TIMESTAMP,
"modify_time" TIMESTAMP,
CONSTRAINT "uk_hotime_cache_key" UNIQUE ("key")
);
CREATE INDEX "idx_hotime_cache_end_time" ON "hotime_cache" ("end_time");
-- ===================== DML 种子数据 =====================
INSERT INTO "ctg" ("name","state","create_time","modify_time") VALUES ('新闻资讯', 0, NOW(), NOW());
INSERT INTO "ctg" ("name","state","create_time","modify_time") VALUES ('技术文档', 0, NOW(), NOW());
INSERT INTO "ctg" ("name","state","create_time","modify_time") VALUES ('公告通知', 0, NOW(), NOW());
INSERT INTO "ctg" ("name","state","create_time","modify_time") VALUES ('产品介绍', 0, NOW(), NOW());
INSERT INTO "ctg" ("name","state","create_time","modify_time") VALUES ('已归档', 1, NOW(), NOW());
INSERT INTO "admin" ("name","phone","state","password","role_id","title","create_time","modify_time") VALUES ('超级管理员','13800000001',1,'admin123', 1,'系统管理员',NOW(),NOW());
INSERT INTO "admin" ("name","phone","state","password","role_id","title","create_time","modify_time") VALUES ('编辑员', '13800000002',1,'editor123',2,'内容编辑', NOW(),NOW());
INSERT INTO "admin" ("name","phone","state","password","role_id","title","create_time","modify_time") VALUES ('审核员', '13800000003',1,'review123',3,'内容审核', NOW(),NOW());
INSERT INTO "article" ("title","author","content","state","click_num","sort","ctg_id","admin_id","create_time","modify_time") VALUES ('数据库入门指南', '管理员','国产关系型数据库管理系统入门...', 0, 128, 10,1,1,NOW(),NOW());
INSERT INTO "article" ("title","author","content","state","click_num","sort","ctg_id","admin_id","create_time","modify_time") VALUES ('新闻发布系统上线公告', '编辑员','本系统已正式上线运行...', 0, 256, 5,1,2,NOW(),NOW());
INSERT INTO "article" ("title","author","content","state","click_num","sort","ctg_id","admin_id","create_time","modify_time") VALUES ('Go语言最佳实践', '管理员','Go语言在后端开发中的最佳实践总结...',0, 512, 8,2,1,NOW(),NOW());
INSERT INTO "article" ("title","author","content","state","click_num","sort","ctg_id","admin_id","create_time","modify_time") VALUES ('API接口设计规范', '审核员','RESTful API设计的基本规范和标准...', 0, 64, 15,2,3,NOW(),NOW());
INSERT INTO "article" ("title","author","content","state","click_num","sort","ctg_id","admin_id","create_time","modify_time") VALUES ('系统维护通知', '管理员','系统将于本周日凌晨进行维护升级...', 0, 32, 1,3,1,NOW(),NOW());
INSERT INTO "article" ("title","author","content","state","click_num","sort","ctg_id","admin_id","create_time","modify_time") VALUES ('产品功能更新说明', '编辑员','新版本增加了以下功能...', 0, 96, 12,4,2,NOW(),NOW());
INSERT INTO "article" ("title","author","content","state","click_num","sort","ctg_id","admin_id","create_time","modify_time") VALUES ('数据库性能优化指南', '管理员','数据库性能优化的常见方法和技巧...', 0,1024, 3,2,1,NOW(),NOW());
INSERT INTO "article" ("title","author","content","state","click_num","sort","ctg_id","admin_id","create_time","modify_time") VALUES ('缓存机制深入分析', '审核员','缓存在Web应用中的重要作用...', 0, 200, 7,2,3,NOW(),NOW());
INSERT INTO "article" ("title","author","content","state","click_num","sort","ctg_id","admin_id","create_time","modify_time") VALUES ('旧版公告(已归档)', '管理员','此公告已过期归档...', 1, 10, 0,5,1,NOW(),NOW());
INSERT INTO "article" ("title","author","content","state","click_num","sort","ctg_id","admin_id","create_time","modify_time") VALUES ('测试文章(隐藏)', '编辑员','测试用文章不公开显示...', 2, 0, 0,1,2,NOW(),NOW());
INSERT INTO "article" ("title","author","content","state","click_num","sort","ctg_id","admin_id","create_time","modify_time") VALUES ('高点击量文章', '编辑员','这是一篇点击量很高的文章...', 0,9999, 2,1,2,NOW(),NOW());
+104
View File
@@ -0,0 +1,104 @@
-- MySQL 测试数据库初始化脚本
-- 数据库: test(在 config.json 中配置 "mysql": {"name": "test", ...}
-- 执行前确保已连接到 test 数据库:USE test;
-- ===================== DDL =====================
CREATE TABLE IF NOT EXISTS `admin` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(100) DEFAULT NULL,
`phone` varchar(20) DEFAULT NULL,
`state` int(2) DEFAULT '0',
`password` varchar(100) DEFAULT NULL,
`role_id` int(11) DEFAULT '0',
`title` varchar(100) DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`modify_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_phone` (`phone`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='管理员表';
CREATE TABLE IF NOT EXISTS `ctg` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(100) DEFAULT NULL,
`state` int(2) DEFAULT '0',
`create_time` datetime DEFAULT NULL,
`modify_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分类表';
CREATE TABLE IF NOT EXISTS `article` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`title` varchar(200) DEFAULT NULL,
`author` varchar(100) DEFAULT NULL,
`content` text,
`state` int(2) DEFAULT '0' COMMENT '0正常 1归档 2隐藏',
`click_num` int(11) DEFAULT '0',
`sort` int(11) DEFAULT '0',
`img` varchar(500) DEFAULT NULL,
`ctg_id` int(11) DEFAULT '0',
`admin_id` int(11) DEFAULT '0',
`create_time` datetime DEFAULT NULL,
`modify_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_state` (`state`),
KEY `idx_ctg_id` (`ctg_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章表';
CREATE TABLE IF NOT EXISTS `test_batch` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(100) DEFAULT NULL,
`title` varchar(200) DEFAULT NULL,
`state` int(2) DEFAULT '0',
`create_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='批量测试表';
CREATE TABLE IF NOT EXISTS `cached` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`key` varchar(64) DEFAULT NULL,
`value` text,
`endtime` bigint(20) DEFAULT NULL,
`time` bigint(20) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='老版本缓存表';
CREATE TABLE IF NOT EXISTS `hotime_cache` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`key` varchar(64) NOT NULL COMMENT '缓存键',
`value` text DEFAULT NULL COMMENT '缓存值',
`end_time` datetime DEFAULT NULL COMMENT '过期时间',
`state` int(2) DEFAULT '0',
`create_time` datetime DEFAULT NULL,
`modify_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_key` (`key`),
KEY `idx_end_time` (`end_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='缓存管理';
-- ===================== DML 种子数据 =====================
INSERT IGNORE INTO `ctg` (`name`, `state`, `create_time`, `modify_time`) VALUES
('新闻资讯', 0, NOW(), NOW()),
('技术文档', 0, NOW(), NOW()),
('公告通知', 0, NOW(), NOW()),
('产品介绍', 0, NOW(), NOW()),
('已归档', 1, NOW(), NOW());
INSERT IGNORE INTO `admin` (`name`, `phone`, `state`, `password`, `role_id`, `title`, `create_time`, `modify_time`) VALUES
('超级管理员', '13800000001', 1, 'admin123', 1, '系统管理员', NOW(), NOW()),
('编辑员', '13800000002', 1, 'editor123', 2, '内容编辑', NOW(), NOW()),
('审核员', '13800000003', 1, 'review123', 3, '内容审核', NOW(), NOW());
INSERT INTO `article` (`title`, `author`, `content`, `state`, `click_num`, `sort`, `ctg_id`, `admin_id`, `create_time`, `modify_time`) VALUES
('数据库入门指南', '管理员', '国产关系型数据库管理系统入门...', 0, 128, 10, 1, 1, NOW(), NOW()),
('新闻发布系统上线公告', '编辑员', '本系统已正式上线运行...', 0, 256, 5, 1, 2, NOW(), NOW()),
('Go语言最佳实践', '管理员', 'Go语言在后端开发中的最佳实践总结...', 0, 512, 8, 2, 1, NOW(), NOW()),
('API接口设计规范', '审核员', 'RESTful API设计的基本规范和标准...', 0, 64, 15, 2, 3, NOW(), NOW()),
('系统维护通知', '管理员', '系统将于本周日凌晨进行维护升级...', 0, 32, 1, 3, 1, NOW(), NOW()),
('产品功能更新说明', '编辑员', '新版本增加了以下功能...', 0, 96, 12, 4, 2, NOW(), NOW()),
('数据库性能优化指南', '管理员', '数据库性能优化的常见方法和技巧...', 0, 1024, 3, 2, 1, NOW(), NOW()),
('缓存机制深入分析', '审核员', '缓存在Web应用中的重要作用...', 0, 200, 7, 2, 3, NOW(), NOW()),
('旧版公告(已归档)', '管理员', '此公告已过期归档...', 1, 10, 0, 5, 1, NOW(), NOW()),
('测试文章(隐藏)', '编辑员', '测试用文章不公开显示...', 2, 0, 0, 1, 2, NOW(), NOW()),
('高点击量文章', '编辑员', '这是一篇点击量很高的文章...', 0, 9999, 2, 1, 2, NOW(), NOW());
+306
View File
@@ -0,0 +1,306 @@
{
"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
@@ -0,0 +1,535 @@
<!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
@@ -0,0 +1,19 @@
<!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>
-848
View File
@@ -1,848 +0,0 @@
package main
import (
"database/sql"
"fmt"
"log"
// 实际使用时请替换为正确的导入路径
// "code.hoteas.com/golang/hotime/cache"
// "code.hoteas.com/golang/hotime/common"
// "code.hoteas.com/golang/hotime/db"
"code.hoteas.com/golang/hotime/cache"
"code.hoteas.com/golang/hotime/common"
"code.hoteas.com/golang/hotime/db"
_ "github.com/go-sql-driver/mysql"
"github.com/sirupsen/logrus"
)
// HoTimeDB使用示例代码集合 - 修正版
// 本文件包含了各种常见场景的完整示例代码,所有条件查询语法已修正
// 示例1: 基本初始化和配置
func Example1_BasicSetup() {
// 创建数据库实例
database := &db.HoTimeDB{
Prefix: "app_", // 设置表前缀
Mode: 2, // 开发模式,输出SQL日志
Type: "mysql", // 数据库类型
}
// 设置日志
logger := logrus.New()
database.Log = logger
// 设置连接函数
database.SetConnect(func(err ...*common.Error) (master, slave *sql.DB) {
// 主数据库连接
master, dbErr := sql.Open("mysql", "root:password@tcp(localhost:3306)/testdb?charset=utf8&parseTime=true")
if dbErr != nil {
log.Fatal("数据库连接失败:", dbErr)
}
// 从数据库连接(可选,用于读写分离)
slave = master // 这里使用同一个连接,实际项目中可以连接到从库
return master, slave
})
fmt.Println("数据库初始化完成")
}
// 示例2: 基本CRUD操作(修正版)
func Example2_BasicCRUD_Fixed(db *db.HoTimeDB) {
// 创建用户
fmt.Println("=== 创建用户 ===")
userId := db.Insert("user", common.Map{
"name": "张三",
"email": "zhangsan@example.com",
"age": 25,
"status": 1,
"balance": 1000.50,
"created_time[#]": "NOW()",
})
fmt.Printf("新用户ID: %d\n", userId)
// 查询用户(单条件,可以不用AND)
fmt.Println("\n=== 查询用户 ===")
user := db.Get("user", "*", common.Map{
"id": userId,
})
if user != nil {
fmt.Printf("用户信息: %+v\n", user)
}
// 更新用户(多条件必须用AND包装)
fmt.Println("\n=== 更新用户 ===")
affected := db.Update("user", common.Map{
"name": "李四",
"age": 26,
"updated_time[#]": "NOW()",
}, common.Map{
"AND": common.Map{
"id": userId,
"status": 1, // 确保只更新正常状态的用户
},
})
fmt.Printf("更新记录数: %d\n", affected)
// 软删除用户
fmt.Println("\n=== 软删除用户 ===")
affected = db.Update("user", common.Map{
"deleted_at[#]": "NOW()",
"status": 0,
}, common.Map{
"id": userId, // 单条件,不需要AND
})
fmt.Printf("软删除记录数: %d\n", affected)
}
// 示例3: 条件查询语法(修正版)
func Example3_ConditionQuery_Fixed(db *db.HoTimeDB) {
fmt.Println("=== 条件查询语法示例 ===")
// ✅ 正确:单个条件
users1 := db.Select("user", "*", common.Map{
"status": 1,
})
fmt.Printf("活跃用户: %d个\n", len(users1))
// ✅ 正确:多个条件用AND包装
users2 := db.Select("user", "*", common.Map{
"AND": common.Map{
"status": 1,
"age[>]": 18,
"age[<=]": 60,
},
})
fmt.Printf("活跃的成年用户: %d个\n", len(users2))
// ✅ 正确:OR条件
users3 := db.Select("user", "*", common.Map{
"OR": common.Map{
"level": "vip",
"balance[>]": 5000,
},
})
fmt.Printf("VIP或高余额用户: %d个\n", len(users3))
// ✅ 正确:条件 + 特殊参数
users4 := db.Select("user", "*", common.Map{
"AND": common.Map{
"status": 1,
"age[>=]": 18,
},
"ORDER": "created_time DESC",
"LIMIT": 10,
})
fmt.Printf("最近的活跃成年用户: %d个\n", len(users4))
// ✅ 正确:复杂嵌套条件
users5 := db.Select("user", "*", common.Map{
"AND": common.Map{
"status": 1,
"OR": common.Map{
"age[<]": 30,
"level": "vip",
},
},
"ORDER": []string{"level DESC", "created_time DESC"},
"LIMIT": []int{0, 20},
})
fmt.Printf("年轻或VIP的活跃用户: %d个\n", len(users5))
// ✅ 正确:模糊查询
users6 := db.Select("user", "*", common.Map{
"AND": common.Map{
"name[~]": "张", // 姓名包含"张"
"email[~!]": "gmail", // 邮箱以gmail开头
"status": 1,
},
})
fmt.Printf("姓张的gmail活跃用户: %d个\n", len(users6))
// ✅ 正确:范围查询
users7 := db.Select("user", "*", common.Map{
"AND": common.Map{
"age[<>]": []int{18, 35}, // 年龄在18-35之间
"balance[><]": []float64{0, 100}, // 余额不在0-100之间
"status": 1,
},
})
fmt.Printf("18-35岁且余额>100的活跃用户: %d个\n", len(users7))
// ✅ 正确:IN查询
users8 := db.Select("user", "*", common.Map{
"AND": common.Map{
"id": []int{1, 2, 3, 4, 5}, // ID在指定范围内
"status[!]": []int{0, -1}, // 状态不为0或-1
},
})
fmt.Printf("指定ID的活跃用户: %d个\n", len(users8))
}
// 示例4: 链式查询操作(正确版)
func Example4_ChainQuery_Fixed(db *db.HoTimeDB) {
fmt.Println("=== 链式查询示例 ===")
// 链式查询(链式语法允许单独的Where,然后用And添加更多条件)
users := db.Table("user").
Where("status", 1). // 链式中可以单独Where
And("age[>=]", 18). // 然后用And添加条件
And("age[<=]", 60). // 再添加条件
Or(common.Map{ // 或者用Or添加OR条件组
"level": "vip",
"balance[>]": 5000,
}).
Order("created_time DESC", "id ASC"). // 排序
Limit(0, 10). // 限制结果
Select("id,name,email,age,balance,level")
fmt.Printf("链式查询到 %d 个用户\n", len(users))
for i, user := range users {
fmt.Printf("用户%d: %s (年龄:%v, 余额:%v)\n",
i+1,
user.GetString("name"),
user.Get("age"),
user.Get("balance"))
}
// 链式统计查询
count := db.Table("user").
Where("status", 1).
And("age[>=]", 18).
Count()
fmt.Printf("符合条件的用户总数: %d\n", count)
}
// 示例5: JOIN查询操作(修正版)
func Example5_JoinQuery_Fixed(db *db.HoTimeDB) {
fmt.Println("=== JOIN查询示例 ===")
// 链式JOIN查询
orders := db.Table("order").
LeftJoin("user", "order.user_id = user.id").
LeftJoin("product", "order.product_id = product.id").
Where("order.status", "paid"). // 链式中单个条件可以直接Where
And("order.created_time[>]", "2023-01-01"). // 用And添加更多条件
Order("order.created_time DESC").
Select(`
order.id as order_id,
order.amount,
order.status,
order.created_time,
user.name as user_name,
user.email as user_email,
product.title as product_title,
product.price as product_price
`)
fmt.Printf("链式JOIN查询到 %d 个订单\n", len(orders))
for _, order := range orders {
fmt.Printf("订单ID:%v, 用户:%s, 商品:%s, 金额:%v\n",
order.Get("order_id"),
order.GetString("user_name"),
order.GetString("product_title"),
order.Get("amount"))
}
// 传统JOIN语法(多个条件必须用AND包装)
orders2 := db.Select("order",
common.Slice{
common.Map{"[>]user": "order.user_id = user.id"},
common.Map{"[>]product": "order.product_id = product.id"},
},
"order.*, user.name as user_name, product.title as product_title",
common.Map{
"AND": common.Map{
"order.status": "paid",
"order.created_time[>]": "2023-01-01",
},
})
fmt.Printf("传统JOIN语法查询到 %d 个订单\n", len(orders2))
}
// 示例6: 分页查询(修正版)
func Example6_PaginationQuery_Fixed(db *db.HoTimeDB) {
fmt.Println("=== 分页查询示例 ===")
page := 2
pageSize := 10
// 获取总数(单条件)
total := db.Count("user", common.Map{
"AND": common.Map{
"status": 1,
"deleted_at": nil,
},
})
// 分页数据(链式方式)
users := db.Table("user").
Where("status", 1).
And("deleted_at", nil).
Order("created_time DESC").
Page(page, pageSize).
Select("id,name,email,created_time")
// 使用传统方式的分页查询
users2 := db.Page(page, pageSize).PageSelect("user", "*", common.Map{
"AND": common.Map{
"status": 1,
"deleted_at": nil,
},
"ORDER": "created_time DESC",
})
// 计算分页信息
totalPages := (total + pageSize - 1) / pageSize
offset := (page - 1) * pageSize
fmt.Printf("总记录数: %d\n", total)
fmt.Printf("总页数: %d\n", totalPages)
fmt.Printf("当前页: %d\n", page)
fmt.Printf("每页大小: %d\n", pageSize)
fmt.Printf("偏移量: %d\n", offset)
fmt.Printf("链式查询当前页记录数: %d\n", len(users))
fmt.Printf("传统查询当前页记录数: %d\n", len(users2))
for i, user := range users {
fmt.Printf(" %d. %s (%s) - %v\n",
offset+i+1,
user.GetString("name"),
user.GetString("email"),
user.Get("created_time"))
}
}
// 示例7: 聚合函数查询(修正版)
func Example7_AggregateQuery_Fixed(db *db.HoTimeDB) {
fmt.Println("=== 聚合函数查询示例 ===")
// 基本统计
userCount := db.Count("user")
activeUserCount := db.Count("user", common.Map{"status": 1})
totalBalance := db.Sum("user", "balance", common.Map{"status": 1})
fmt.Printf("总用户数: %d\n", userCount)
fmt.Printf("活跃用户数: %d\n", activeUserCount)
fmt.Printf("活跃用户总余额: %.2f\n", totalBalance)
// 分组统计(正确语法)
stats := db.Select("user",
"level, COUNT(*) as user_count, AVG(age) as avg_age, SUM(balance) as total_balance",
common.Map{
"status": 1, // 单条件,不需要AND
"GROUP": "level",
"ORDER": "user_count DESC",
})
fmt.Println("\n按等级分组统计:")
for _, stat := range stats {
fmt.Printf("等级:%v, 用户数:%v, 平均年龄:%v, 总余额:%v\n",
stat.Get("level"),
stat.Get("user_count"),
stat.Get("avg_age"),
stat.Get("total_balance"))
}
// 关联统计(修正版)
orderStats := db.Select("order",
common.Slice{
common.Map{"[>]user": "order.user_id = user.id"},
},
"user.level, COUNT(order.id) as order_count, SUM(order.amount) as total_amount",
common.Map{
"AND": common.Map{
"order.status": "paid",
"order.created_time[>]": "2023-01-01",
},
"GROUP": "user.level",
"ORDER": "total_amount DESC",
})
fmt.Println("\n用户等级订单统计:")
for _, stat := range orderStats {
fmt.Printf("等级:%v, 订单数:%v, 总金额:%v\n",
stat.Get("level"),
stat.Get("order_count"),
stat.Get("total_amount"))
}
}
// 示例8: 事务处理(修正版)
func Example8_Transaction_Fixed(database *db.HoTimeDB) {
fmt.Println("=== 事务处理示例 ===")
// 模拟转账操作
fromUserId := int64(1)
toUserId := int64(2)
amount := 100.0
success := database.Action(func(tx db.HoTimeDB) bool {
// 检查转出账户余额(单条件)
fromUser := tx.Get("user", "balance", common.Map{"id": fromUserId})
if fromUser == nil {
fmt.Println("转出用户不存在")
return false
}
fromBalance := fromUser.GetFloat64("balance")
if fromBalance < amount {
fmt.Println("余额不足")
return false
}
// 扣减转出账户余额(多条件必须用AND)
affected1 := tx.Update("user", common.Map{
"balance[#]": fmt.Sprintf("balance - %.2f", amount),
"updated_time[#]": "NOW()",
}, common.Map{
"AND": common.Map{
"id": fromUserId,
"balance[>=]": amount, // 再次确保余额足够
},
})
if affected1 == 0 {
fmt.Println("扣减余额失败")
return false
}
// 增加转入账户余额(单条件)
affected2 := tx.Update("user", common.Map{
"balance[#]": fmt.Sprintf("balance + %.2f", amount),
"updated_time[#]": "NOW()",
}, common.Map{
"id": toUserId,
})
if affected2 == 0 {
fmt.Println("增加余额失败")
return false
}
// 记录转账日志
logId := tx.Insert("transfer_log", common.Map{
"from_user_id": fromUserId,
"to_user_id": toUserId,
"amount": amount,
"status": "success",
"created_time[#]": "NOW()",
})
if logId == 0 {
fmt.Println("记录日志失败")
return false
}
fmt.Printf("转账成功: 用户%d -> 用户%d, 金额:%.2f\n", fromUserId, toUserId, amount)
return true
})
if success {
fmt.Println("事务执行成功")
} else {
fmt.Println("事务回滚")
if database.LastErr.GetError() != nil {
fmt.Println("错误原因:", database.LastErr.GetError())
}
}
}
// 示例9: 缓存机制(修正版)
func Example9_CacheSystem_Fixed(database *db.HoTimeDB) {
fmt.Println("=== 缓存机制示例 ===")
// 设置缓存(实际项目中需要配置缓存参数)
database.HoTimeCache = &cache.HoTimeCache{}
// 第一次查询(会缓存结果)
fmt.Println("第一次查询(会缓存)...")
users1 := database.Select("user", "*", common.Map{
"status": 1, // 单条件
"LIMIT": 10,
})
fmt.Printf("查询到 %d 个用户\n", len(users1))
// 第二次相同查询(从缓存获取)
fmt.Println("第二次相同查询(从缓存获取)...")
users2 := database.Select("user", "*", common.Map{
"status": 1, // 单条件
"LIMIT": 10,
})
fmt.Printf("查询到 %d 个用户\n", len(users2))
// 更新操作会清除缓存
fmt.Println("执行更新操作(会清除缓存)...")
affected := database.Update("user", common.Map{
"updated_time[#]": "NOW()",
}, common.Map{
"id": 1, // 单条件
})
fmt.Printf("更新 %d 条记录\n", affected)
// 再次查询(重新从数据库获取并缓存)
fmt.Println("更新后再次查询(重新缓存)...")
users3 := database.Select("user", "*", common.Map{
"status": 1, // 单条件
"LIMIT": 10,
})
fmt.Printf("查询到 %d 个用户\n", len(users3))
}
// 示例10: 性能优化技巧(修正版)
func Example10_PerformanceOptimization_Fixed(database *db.HoTimeDB) {
fmt.Println("=== 性能优化技巧示例 ===")
// IN查询优化(连续数字自动转为BETWEEN)
fmt.Println("IN查询优化示例...")
users := database.Select("user", "*", common.Map{
"id": []int{1, 2, 3, 4, 5, 10, 11, 12, 13, 20}, // 单个IN条件,会被优化
})
fmt.Printf("查询到 %d 个用户\n", len(users))
fmt.Println("执行的SQL:", database.LastQuery)
// 批量插入(使用事务)
fmt.Println("\n批量插入示例...")
success := database.Action(func(tx db.HoTimeDB) bool {
for i := 1; i <= 100; i++ {
id := tx.Insert("user_batch", common.Map{
"name": fmt.Sprintf("批量用户%d", i),
"email": fmt.Sprintf("batch%d@example.com", i),
"status": 1,
"created_time[#]": "NOW()",
})
if id == 0 {
return false
}
// 每10个用户输出一次进度
if i%10 == 0 {
fmt.Printf("已插入 %d 个用户\n", i)
}
}
return true
})
if success {
fmt.Println("批量插入完成")
} else {
fmt.Println("批量插入失败")
}
// 索引友好的查询(修正版)
fmt.Println("\n索引友好的查询...")
recentUsers := database.Select("user", "*", common.Map{
"AND": common.Map{
"created_time[>]": "2023-01-01", // 假设created_time有索引
"status": 1, // 假设status有索引
},
"ORDER": "created_time DESC", // 利用索引排序
"LIMIT": 20,
})
fmt.Printf("查询到 %d 个近期用户\n", len(recentUsers))
}
// 完整的应用示例(修正版)
func CompleteExample_Fixed() {
fmt.Println("=== HoTimeDB完整应用示例(修正版) ===")
// 初始化数据库
database := &db.HoTimeDB{
Prefix: "app_",
Mode: 1, // 测试模式
Type: "mysql",
}
// 设置连接
database.SetConnect(func(err ...*common.Error) (master, slave *sql.DB) {
// 这里使用实际的数据库连接字符串
dsn := "root:password@tcp(localhost:3306)/testdb?charset=utf8mb4&parseTime=True&loc=Local"
master, dbErr := sql.Open("mysql", dsn)
if dbErr != nil {
log.Fatal("数据库连接失败:", dbErr)
}
return master, master
})
// 用户管理系统示例
fmt.Println("\n=== 用户管理系统 ===")
// 1. 创建用户
userId := database.Insert("user", common.Map{
"name": "示例用户",
"email": "example@test.com",
"password": "hashed_password",
"age": 28,
"status": 1,
"level": "normal",
"balance": 500.00,
"created_time[#]": "NOW()",
})
fmt.Printf("创建用户成功,ID: %d\n", userId)
// 2. 用户登录更新(多条件用AND)
database.Update("user", common.Map{
"last_login[#]": "NOW()",
"login_count[#]": "login_count + 1",
}, common.Map{
"AND": common.Map{
"id": userId,
"status": 1,
},
})
// 3. 创建订单
orderId := database.Insert("order", common.Map{
"user_id": userId,
"amount": 299.99,
"status": "pending",
"created_time[#]": "NOW()",
})
fmt.Printf("创建订单成功,ID: %d\n", orderId)
// 4. 订单支付(使用事务,修正版)
paymentSuccess := database.Action(func(tx db.HoTimeDB) bool {
// 更新订单状态(单条件)
affected1 := tx.Update("order", common.Map{
"status": "paid",
"paid_time[#]": "NOW()",
}, common.Map{
"id": orderId,
})
if affected1 == 0 {
return false
}
// 扣减用户余额(多条件用AND
affected2 := tx.Update("user", common.Map{
"balance[#]": "balance - 299.99",
}, common.Map{
"AND": common.Map{
"id": userId,
"balance[>=]": 299.99, // 确保余额足够
},
})
if affected2 == 0 {
fmt.Println("余额不足或用户不存在")
return false
}
// 记录支付日志
logId := tx.Insert("payment_log", common.Map{
"user_id": userId,
"order_id": orderId,
"amount": 299.99,
"type": "order_payment",
"status": "success",
"created_time[#]": "NOW()",
})
return logId > 0
})
if paymentSuccess {
fmt.Println("订单支付成功")
} else {
fmt.Println("订单支付失败")
}
// 5. 查询用户订单列表(链式查询)
userOrders := database.Table("order").
LeftJoin("user", "order.user_id = user.id").
Where("order.user_id", userId). // 链式中单个条件可以直接Where
Order("order.created_time DESC").
Select(`
order.id,
order.amount,
order.status,
order.created_time,
order.paid_time,
user.name as user_name
`)
fmt.Printf("\n用户订单列表 (%d个订单):\n", len(userOrders))
for _, order := range userOrders {
fmt.Printf(" 订单ID:%v, 金额:%v, 状态:%s, 创建时间:%v\n",
order.Get("id"),
order.Get("amount"),
order.GetString("status"),
order.Get("created_time"))
}
// 6. 生成统计报表(修正版)
stats := database.Select("order",
common.Slice{
common.Map{"[>]user": "order.user_id = user.id"},
},
`
DATE(order.created_time) as date,
COUNT(order.id) as order_count,
SUM(order.amount) as total_amount,
AVG(order.amount) as avg_amount
`,
common.Map{
"AND": common.Map{
"order.status": "paid",
"order.created_time[>]": "2023-01-01",
},
"GROUP": "DATE(order.created_time)",
"ORDER": "date DESC",
"LIMIT": 30,
})
fmt.Printf("\n最近30天订单统计:\n")
for _, stat := range stats {
fmt.Printf("日期:%v, 订单数:%v, 总金额:%v, 平均金额:%v\n",
stat.Get("date"),
stat.Get("order_count"),
stat.Get("total_amount"),
stat.Get("avg_amount"))
}
fmt.Println("\n示例执行完成!")
}
// 语法对比示例
func SyntaxComparison() {
fmt.Println("=== HoTimeDB语法对比 ===")
// 模拟数据库对象
var db *db.HoTimeDB
fmt.Println("❌ 错误语法示例(不支持):")
fmt.Println(`
// 这样写是错误的,多个条件不能直接放在根Map中
wrongUsers := db.Select("user", "*", common.Map{
"status": 1, // ❌ 错误
"age[>]": 18, // ❌ 错误
"ORDER": "id DESC",
})
`)
fmt.Println("✅ 正确语法示例:")
fmt.Println(`
// 单个条件可以直接写
correctUsers1 := db.Select("user", "*", common.Map{
"status": 1, // ✅ 正确,单个条件
})
// 多个条件必须用AND包装
correctUsers2 := db.Select("user", "*", common.Map{
"AND": common.Map{ // ✅ 正确,多个条件用AND包装
"status": 1,
"age[>]": 18,
},
"ORDER": "id DESC", // ✅ 正确,特殊参数与条件同级
})
// OR条件
correctUsers3 := db.Select("user", "*", common.Map{
"OR": common.Map{ // ✅ 正确,OR条件
"level": "vip",
"balance[>]": 1000,
},
})
// 嵌套条件
correctUsers4 := db.Select("user", "*", common.Map{
"AND": common.Map{ // ✅ 正确,嵌套条件
"status": 1,
"OR": common.Map{
"age[<]": 30,
"level": "vip",
},
},
"ORDER": "created_time DESC",
"LIMIT": 20,
})
`)
// 实际不执行查询,只是展示语法
_ = db
}
// InsertsExample 批量插入示例
func InsertsExample(database *db.HoTimeDB) {
fmt.Println("\n=== 批量插入示例 ===")
// 批量插入用户(使用 []Map 格式)
affected := database.Inserts("user", []common.Map{
{"name": "批量用户1", "email": "batch1@example.com", "age": 25, "status": 1},
{"name": "批量用户2", "email": "batch2@example.com", "age": 30, "status": 1},
{"name": "批量用户3", "email": "batch3@example.com", "age": 28, "status": 1},
})
fmt.Printf("批量插入了 %d 条用户记录\n", affected)
// 批量插入日志(使用 [#] 标记直接 SQL)
logAffected := database.Inserts("log", []common.Map{
{"user_id": 1, "action": "login", "ip": "192.168.1.1", "created_time[#]": "NOW()"},
{"user_id": 2, "action": "logout", "ip": "192.168.1.2", "created_time[#]": "NOW()"},
{"user_id": 3, "action": "view", "ip": "192.168.1.3", "created_time[#]": "NOW()"},
})
fmt.Printf("批量插入了 %d 条日志记录\n", logAffected)
}
// UpsertExample Upsert(插入或更新)示例
func UpsertExample(database *db.HoTimeDB) {
fmt.Println("\n=== Upsert示例 ===")
// Upsert 用户数据(使用 Slice 格式)
// 如果 id 存在则更新,不存在则插入
affected := database.Upsert("user",
common.Map{
"id": 1,
"name": "张三更新",
"email": "zhang_updated@example.com",
"age": 26,
},
common.Slice{"id"}, // 唯一键
common.Slice{"name", "email", "age"}, // 冲突时更新的字段
)
fmt.Printf("Upsert 影响了 %d 行\n", affected)
// Upsert 使用 [#] 直接 SQL
affected2 := database.Upsert("user_stats",
common.Map{
"user_id": 1,
"login_count[#]": "login_count + 1",
"last_login[#]": "NOW()",
},
common.Slice{"user_id"},
common.Slice{"login_count", "last_login"},
)
fmt.Printf("统计 Upsert 影响了 %d 行\n", affected2)
// 也支持可变参数形式
affected3 := database.Upsert("user",
common.Map{"id": 2, "name": "李四", "status": 1},
common.Slice{"id"},
"name", "status", // 可变参数
)
fmt.Printf("可变参数 Upsert 影响了 %d 行\n", affected3)
}
// 运行所有修正后的示例
func RunAllFixedExamples() {
fmt.Println("开始运行HoTimeDB所有修正后的示例...")
fmt.Println("注意:实际运行时需要确保数据库连接正确,并且相关表存在")
// 展示语法对比
SyntaxComparison()
fmt.Println("请根据实际环境配置数据库连接后运行相应示例")
fmt.Println("所有示例代码已修正完毕,语法正确!")
fmt.Println("")
fmt.Println("新增功能示例说明:")
fmt.Println(" - InsertsExample(db): 批量插入示例,使用 []Map 格式")
fmt.Println(" - UpsertExample(db): 插入或更新示例")
}
func main() {
// 运行语法对比示例
RunAllFixedExamples()
}
+26 -2
View File
@@ -1,17 +1,41 @@
module code.hoteas.com/golang/hotime
go 1.16
go 1.23
require (
gitee.com/chunanyong/dm v1.8.22
github.com/360EntSecGroup-Skylar/excelize v1.4.1
github.com/garyburd/redigo v1.6.3
github.com/go-pay/gopay v1.5.78
github.com/go-sql-driver/mysql v1.6.0
github.com/mattn/go-sqlite3 v1.14.12
github.com/rs/zerolog v1.35.0
github.com/silenceper/wechat/v2 v2.1.2
github.com/sirupsen/logrus v1.8.1
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.364
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ocr v1.0.364
go.mongodb.org/mongo-driver v1.10.1
golang.org/x/net v0.0.0-20220225172249-27dd8689420f
)
require (
github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b // indirect
github.com/fatih/structs v1.1.0 // indirect
github.com/golang/snappy v0.0.1 // indirect
github.com/gomodule/redigo v1.8.5 // indirect
github.com/klauspost/compress v1.13.6 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/sirupsen/logrus v1.8.1 // indirect
github.com/spf13/cast v1.3.1 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.1.1 // indirect
github.com/xdg-go/stringprep v1.0.3 // indirect
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d // indirect
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.3.7 // indirect
)
+16 -5
View File
@@ -1,3 +1,5 @@
gitee.com/chunanyong/dm v1.8.22 h1:H7fsrnUIvEA0jlDWew7vwELry1ff+tLMIu2Fk2cIBSg=
gitee.com/chunanyong/dm v1.8.22/go.mod h1:EPRJnuPFgbyOFgJ0TRYCTGzhq+ZT4wdyaj/GW/LLcNg=
github.com/360EntSecGroup-Skylar/excelize v1.4.1 h1:l55mJb6rkkaUzOpSsgEeKYtS6/0gHwBYyfo5Jcjv/Ks=
github.com/360EntSecGroup-Skylar/excelize v1.4.1/go.mod h1:vnax29X2usfl7HHkBrX5EvSCJcmH3dT9luvxzu8iGAE=
github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b h1:L/QXpzIa3pOvUGt1D1lA5KjYhPBAN/3iWdP7xeFS9F0=
@@ -18,6 +20,7 @@ github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gomodule/redigo v1.8.5 h1:nRAxCa+SVsyjSBrtZmG/cqb6VbTmuRzpg/PoTFlpumc=
github.com/gomodule/redigo v1.8.5/go.mod h1:P9dn9mFrCBvWhGE1wpxx6fgq7BAeLBk+UUUzlpkBYO0=
github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
@@ -27,6 +30,10 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.12 h1:TJ1bhYJPV44phC+IMu1u2K/i5RriLTPe+yc68XDJ1Z0=
github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
@@ -39,6 +46,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI=
github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/silenceper/wechat/v2 v2.1.2 h1:+QfIMiYfwST2ZloTwmYp0O0p5Y1LYRZxfLWfMuSE30k=
github.com/silenceper/wechat/v2 v2.1.2/go.mod h1:0OprxYCCp2CZAKw06BBlnaczInTk2KxOLsKeiopshGg=
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
@@ -56,6 +65,7 @@ github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.364 h1:X1Jw
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.364/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ocr v1.0.364 h1:kbor60vo37v7Hu+i17gooox9Rw281fVHNna8zwtDG1w=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ocr v1.0.364/go.mod h1:LeIUBOLhc+Y5YCEpZrULPD9lgoXXV4/EmIcoEvmHz9c=
github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
@@ -69,7 +79,6 @@ go.mongodb.org/mongo-driver v1.10.1 h1:NujsPveKwHaWuKUer/ceo9DzEe7HIj1SlJ6uvXZG0
go.mongodb.org/mongo-driver v1.10.1/go.mod h1:z4XpeoU6w+9Vht+jAFyLgVrD+jGSQQe0+CBWFHNiHt8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29 h1:tkVvjkPTB7pnW3jnid7kNyAMPVWllTNOf/qKDze4p9o=
golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d h1:sK3txAijHtOK88l68nt020reeT1ZdKLIYetKl95FzVY=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
@@ -85,16 +94,18 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210309074719-68d13333faf2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -102,6 +113,6 @@ gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/h2non/gock.v1 v1.0.15 h1:SzLqcIlb/fDfg7UvukMpNcWsu7sI5tWwL+KCATZqks0=
gopkg.in/h2non/gock.v1 v1.0.15/go.mod h1:sX4zAkdYX1TRGJ2JY156cFspQn4yRWn6p9EMdODlynE=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+303
View File
@@ -0,0 +1,303 @@
package hotime
import (
"context"
"fmt"
"io/ioutil"
"net"
"net/http"
"sync"
"sync/atomic"
"testing"
"time"
"code.hoteas.com/golang/hotime/log"
)
// getFreePort 获取一个可用的随机端口
func getFreePort() (int, error) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, err
}
port := ln.Addr().(*net.TCPAddr).Port
ln.Close()
return port, nil
}
// newTestApp 创建一个用于测试的最小 Application,不依赖数据库和配置文件
func newTestApp(port int, handler http.HandlerFunc) *Application {
app := &Application{}
app.Log = log.NewLogger(1, "", 10)
app.Server = &http.Server{
Addr: fmt.Sprintf(":%d", port),
Handler: app,
}
// 用自定义 handler 替代完整的路由系统
app.Handler = handler
return app
}
// ServeHTTP 已经在 application.go 中定义,
// 但 handler 方法依赖完整初始化,这里我们直接用 http.Handler 接口。
// 因此测试 app 的 ServeHTTP 会先检查 shuttingDown,然后调用 that.handler()。
// 但 that.handler() 需要完整的路由初始化,不适合单元测试。
// 所以我们直接测试核心逻辑:shuttingDown 标志 + http.Server.Shutdown。
// TestServeHTTP_ShuttingDown 测试停机标志置为 true 后新请求返回 503
func TestServeHTTP_ShuttingDown(t *testing.T) {
port, err := getFreePort()
if err != nil {
t.Fatalf("获取空闲端口失败: %v", err)
}
var requestHandled atomic.Int32
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestHandled.Add(1)
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
})
app := &Application{}
app.Log = log.NewLogger(1, "", 10)
app.Server = &http.Server{
Addr: fmt.Sprintf(":%d", port),
}
app.Server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if app.shuttingDown.Load() {
w.Header().Set("Connection", "close")
http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
return
}
handler.ServeHTTP(w, r)
})
go app.Server.ListenAndServe()
time.Sleep(100 * time.Millisecond)
baseURL := fmt.Sprintf("http://127.0.0.1:%d", port)
client := &http.Client{Timeout: 5 * time.Second}
// --- 正常请求应该返回 200 ---
resp, err := client.Get(baseURL + "/test")
if err != nil {
t.Fatalf("正常请求失败: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("期望 200,实际 %d", resp.StatusCode)
}
resp.Body.Close()
t.Logf("[PASS] 正常请求返回 %d", resp.StatusCode)
if requestHandled.Load() != 1 {
t.Fatalf("期望 handler 被调用 1 次,实际 %d 次", requestHandled.Load())
}
// --- 设置停机标志 ---
app.shuttingDown.Store(true)
t.Log("[INFO] shuttingDown 已置为 true")
// --- 停机后请求应该返回 503 ---
resp2, err := client.Get(baseURL + "/test2")
if err != nil {
t.Fatalf("停机请求失败: %v", err)
}
if resp2.StatusCode != http.StatusServiceUnavailable {
t.Fatalf("期望 503,实际 %d", resp2.StatusCode)
}
resp2.Body.Close()
t.Logf("[PASS] 停机后请求返回 %d", resp2.StatusCode)
// handler 不应被再次调用
if requestHandled.Load() != 1 {
t.Fatalf("停机后 handler 不应被调用,但被调用了 %d 次", requestHandled.Load())
}
t.Log("[PASS] 停机后 handler 未被调用")
app.Server.Close()
}
// TestGracefulShutdown_InFlightRequestCompletes 测试优雅停机等待在途请求完成
func TestGracefulShutdown_InFlightRequestCompletes(t *testing.T) {
port, err := getFreePort()
if err != nil {
t.Fatalf("获取空闲端口失败: %v", err)
}
slowRequestStarted := make(chan struct{})
slowRequestDone := make(chan struct{})
app := &Application{}
app.Log = log.NewLogger(1, "", 10)
app.Server = &http.Server{
Addr: fmt.Sprintf(":%d", port),
}
app.Server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if app.shuttingDown.Load() {
w.Header().Set("Connection", "close")
http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
return
}
if r.URL.Path == "/slow" {
close(slowRequestStarted)
time.Sleep(2 * time.Second) // 模拟慢接口
w.WriteHeader(http.StatusOK)
w.Write([]byte("SLOW_DONE"))
close(slowRequestDone)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
})
go app.Server.ListenAndServe()
time.Sleep(100 * time.Millisecond)
baseURL := fmt.Sprintf("http://127.0.0.1:%d", port)
client := &http.Client{Timeout: 10 * time.Second}
// --- 发起慢请求(模拟在途请求)---
var slowResp *http.Response
var slowErr error
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
slowResp, slowErr = client.Get(baseURL + "/slow")
}()
// 等慢请求开始处理
<-slowRequestStarted
t.Log("[INFO] 慢请求已开始处理")
// --- 在慢请求进行中触发 Shutdown ---
app.shuttingDown.Store(true)
t.Log("[INFO] shuttingDown 已置为 true,开始 Shutdown")
shutdownDone := make(chan struct{})
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
app.Server.Shutdown(ctx)
close(shutdownDone)
}()
// --- 新请求应该返回 503(但因为 Shutdown 不接受新连接了,可能连接被拒绝)---
// Shutdown 调用后,新的 TCP 连接也会被拒绝,所以这里验证连接级别的拒绝也OK
resp, err := client.Get(baseURL + "/new")
if err != nil {
t.Logf("[PASS] Shutdown 后新请求连接被拒绝(预期行为): %v", err)
} else {
if resp.StatusCode == http.StatusServiceUnavailable {
t.Logf("[PASS] Shutdown 后新请求返回 503")
} else {
t.Logf("[INFO] Shutdown 后新请求返回 %d", resp.StatusCode)
}
resp.Body.Close()
}
// --- 等待慢请求完成 ---
wg.Wait()
if slowErr != nil {
t.Fatalf("慢请求返回错误(不应中断在途请求): %v", slowErr)
}
body, _ := ioutil.ReadAll(slowResp.Body)
slowResp.Body.Close()
if slowResp.StatusCode != http.StatusOK {
t.Fatalf("慢请求期望 200,实际 %d", slowResp.StatusCode)
}
if string(body) != "SLOW_DONE" {
t.Fatalf("慢请求期望 body='SLOW_DONE',实际 '%s'", string(body))
}
t.Logf("[PASS] 慢请求正常完成,状态=%dbody=%s", slowResp.StatusCode, string(body))
// --- 等 Shutdown 完成 ---
<-shutdownDone
t.Log("[PASS] Server.Shutdown() 在慢请求完成后返回")
// 确认慢请求确实跑完了
select {
case <-slowRequestDone:
t.Log("[PASS] 慢请求 handler 完整执行完毕")
default:
t.Fatal("慢请求 handler 未完整执行")
}
}
// TestShutdownOnce_MultipleSignals 测试多次触发只执行一次
func TestShutdownOnce_MultipleSignals(t *testing.T) {
var callCount atomic.Int32
app := &Application{}
app.Log = log.NewLogger(1, "", 10)
app.Server = &http.Server{}
// 为了避免 os.Exit(0) 导致测试进程退出,
// 我们直接测试 shutdownOnce + shuttingDown 逻辑
var once sync.Once
var shuttingDown atomic.Bool
mockShutdown := func() {
once.Do(func() {
shuttingDown.Store(true)
callCount.Add(1)
})
}
// 并发触发 10 次
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mockShutdown()
}()
}
wg.Wait()
if callCount.Load() != 1 {
t.Fatalf("期望 shutdownOnce.Do 只执行 1 次,实际 %d 次", callCount.Load())
}
if !shuttingDown.Load() {
t.Fatal("shuttingDown 应为 true")
}
t.Logf("[PASS] 10 次并发触发,shutdownOnce.Do 只执行了 %d 次", callCount.Load())
}
// TestRecoverSkipsDuringShutdown 测试停机中的 panic 不触发重启
func TestRecoverSkipsDuringShutdown(t *testing.T) {
app := &Application{}
app.Log = log.NewLogger(1, "", 10)
var restartCalled atomic.Bool
// 模拟 Run() 中的 defer recover 逻辑
testRecover := func() {
defer func() {
if err := recover(); err != nil {
if app.shuttingDown.Load() {
return // 停机中不重启
}
restartCalled.Store(true)
}
}()
panic("test panic")
}
// 未停机时,recover 应触发"重启"
restartCalled.Store(false)
testRecover()
if !restartCalled.Load() {
t.Fatal("未停机时 panic 应触发重启")
}
t.Log("[PASS] 未停机时 panic 正确触发重启逻辑")
// 停机时,recover 不应触发"重启"
app.shuttingDown.Store(true)
restartCalled.Store(false)
testRecover()
if restartCalled.Load() {
t.Fatal("停机时 panic 不应触发重启")
}
t.Log("[PASS] 停机时 panic 正确跳过重启逻辑")
}
+673
View File
@@ -0,0 +1,673 @@
package log
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/rs/zerolog"
)
// ErrorRecord 错误历史记录条目
type ErrorRecord struct {
Err error
Msg string
Time time.Time
Caller string
}
// Logger 日志核心结构体,封装 zerolog
type Logger struct {
zl zerolog.Logger
mw *multiWriter // 动态多输出,支持运行时追加 SeqWriter 等
logLevel int
errors []ErrorRecord
errIdx int // 环形缓冲写入位置
errCount int // 实际写入总数(用于判断缓冲是否满)
errorsMu sync.RWMutex
maxErrors int
}
// NewLogger 创建日志实例
// logLevel: 0=仅 error>=1=全部
// logFile: 文件路径模板(如 "logs/20060102.txt"),空则不写文件
// maxErrors: 错误历史最大条数,0 则不记录
func NewLogger(logLevel int, logFile string, maxErrors int) *Logger {
zerolog.CallerMarshalFunc = callerMarshalFunc
zerolog.TimeFieldFormat = "2006-01-02 15:04:05"
var level zerolog.Level
if logLevel == 0 {
level = zerolog.ErrorLevel
} else {
level = zerolog.DebugLevel
}
consoleWriter := zerolog.ConsoleWriter{
Out: os.Stderr,
TimeFormat: "2006-01-02 15:04:05",
NoColor: false,
FormatLevel: formatLevelColor,
FormatCaller: func(i interface{}) string {
if i == nil {
return ""
}
return fmt.Sprintf("[%s]", i)
},
}
var writers []io.Writer
writers = append(writers, consoleWriter)
if logFile != "" {
fw := &TemplateFileWriter{pathTemplate: logFile}
writers = append(writers, fw)
}
mw := &multiWriter{writers: writers}
zl := zerolog.New(mw).
Level(level).
With().
Timestamp().
Caller().
Logger()
l := &Logger{
zl: zl,
mw: mw,
logLevel: logLevel,
maxErrors: maxErrors,
}
if maxErrors > 0 {
l.errors = make([]ErrorRecord, maxErrors)
}
return l
}
// NewLoggerNoCaller 创建不带自动 Caller 字段的日志实例(用于访问日志等 caller 无意义的场景)
func NewLoggerNoCaller(logLevel int, logFile string, maxErrors int) *Logger {
zerolog.CallerMarshalFunc = callerMarshalFunc
zerolog.TimeFieldFormat = "2006-01-02 15:04:05"
var level zerolog.Level
if logLevel == 0 {
level = zerolog.ErrorLevel
} else {
level = zerolog.DebugLevel
}
consoleWriter := zerolog.ConsoleWriter{
Out: os.Stderr,
TimeFormat: "2006-01-02 15:04:05",
NoColor: false,
FormatLevel: formatLevelColor,
}
var writers []io.Writer
writers = append(writers, consoleWriter)
if logFile != "" {
fw := &TemplateFileWriter{pathTemplate: logFile}
writers = append(writers, fw)
}
mw := &multiWriter{writers: writers}
zl := zerolog.New(mw).
Level(level).
With().
Timestamp().
Logger()
l := &Logger{
zl: zl,
mw: mw,
logLevel: logLevel,
maxErrors: maxErrors,
}
if maxErrors > 0 {
l.errors = make([]ErrorRecord, maxErrors)
}
return l
}
// NewTestLogger 创建用于测试的静默 Logger(输出到 io.Discard
func NewTestLogger() *Logger {
zl := zerolog.New(io.Discard).Level(zerolog.Disabled)
return &Logger{zl: zl, logLevel: 0}
}
// SetOutput 设置日志输出(用于测试等场景)
func (l *Logger) SetOutput(w io.Writer) {
if w == io.Discard {
l.zl = zerolog.New(w).Level(zerolog.Disabled)
l.mw = nil
return
}
consoleWriter := zerolog.ConsoleWriter{
Out: w,
TimeFormat: "2006-01-02 15:04:05",
NoColor: true,
FormatLevel: formatLevelPlain,
FormatCaller: func(i interface{}) string {
if i == nil {
return ""
}
return fmt.Sprintf("[%s]", i)
},
}
mw := &multiWriter{writers: []io.Writer{consoleWriter}}
l.mw = mw
l.zl = zerolog.New(mw).
With().
Timestamp().
Caller().
Logger()
}
// GetLevel 获取当前日志等级
func (l *Logger) GetLevel() int {
return l.logLevel
}
// --- 链式调用 API ---
// 以下方法兼容两种调用风格:
// 无参数:返回 *zerolog.Event 用于链式调用,如 l.Error().Str("k","v").Msg("...")
// 有参数:直接拼接并打印日志(logrus 兼容),返回 no-op Event
func (l *Logger) Debug(args ...interface{}) *zerolog.Event {
if len(args) > 0 {
l.zl.Debug().Msg(fmt.Sprint(args...))
nop := zerolog.Nop()
return nop.Debug()
}
return l.zl.Debug()
}
func (l *Logger) Info(args ...interface{}) *zerolog.Event {
if len(args) > 0 {
l.zl.Info().Msg(fmt.Sprint(args...))
nop := zerolog.Nop()
return nop.Info()
}
return l.zl.Info()
}
func (l *Logger) Warn(args ...interface{}) *zerolog.Event {
if len(args) > 0 {
l.zl.Warn().Msg(fmt.Sprint(args...))
nop := zerolog.Nop()
return nop.Warn()
}
return l.zl.Warn()
}
func (l *Logger) Error(args ...interface{}) *zerolog.Event {
if len(args) > 0 {
msg := fmt.Sprint(args...)
if l.maxErrors > 0 {
l.recordError(msg, nil)
}
l.zl.Error().Msg(msg)
nop := zerolog.Nop()
return nop.Error()
}
if l.maxErrors > 0 {
l.recordError("", nil)
}
return l.zl.Error()
}
// RecordError 手动记录一条错误到历史(用于需要指定详情的场景)
func (l *Logger) RecordError(err error, msg string, caller string) {
if l.maxErrors <= 0 {
return
}
l.errorsMu.Lock()
defer l.errorsMu.Unlock()
l.errors[l.errIdx] = ErrorRecord{
Err: err,
Msg: msg,
Time: time.Now(),
Caller: caller,
}
l.errIdx = (l.errIdx + 1) % l.maxErrors
l.errCount++
}
func (l *Logger) recordError(msg string, err error) {
_, file, line, ok := runtime.Caller(2)
caller := ""
if ok {
caller = formatCaller(file, line)
}
l.RecordError(err, msg, caller)
}
// GetRecentErrors 获取最近 N 条错误(不传则返回全部已存储的)
func (l *Logger) GetRecentErrors(n ...int) []ErrorRecord {
if l.maxErrors <= 0 {
return nil
}
l.errorsMu.RLock()
defer l.errorsMu.RUnlock()
total := l.errCount
if total > l.maxErrors {
total = l.maxErrors
}
if total == 0 {
return nil
}
want := total
if len(n) > 0 && n[0] > 0 && n[0] < want {
want = n[0]
}
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() {
break
}
result = append(result, l.errors[idx])
}
return result
}
// --- 格式化快捷方法 ---
func (l *Logger) Debugf(format string, v ...interface{}) {
l.zl.Debug().Msgf(format, v...)
}
func (l *Logger) Infof(format string, v ...interface{}) {
l.zl.Info().Msgf(format, v...)
}
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 {
l.recordError(fmt.Sprintf(format, v...), nil)
}
l.zl.Error().Msgf(format, v...)
}
// --- 日志级别颜色 ---
func formatLevelColor(i interface{}) string {
level := strings.ToUpper(fmt.Sprintf("%s", i))
switch level {
case "DEBUG":
return fmt.Sprintf("\x1b[36m|%s|\x1b[0m", level) // cyan
case "INFO":
return fmt.Sprintf("\x1b[32m|%s|\x1b[0m", level) // green
case "WARN":
return fmt.Sprintf("\x1b[33m|%s|\x1b[0m", level) // yellow
case "ERROR":
return fmt.Sprintf("\x1b[31m|%s|\x1b[0m", level) // red
case "FATAL":
return fmt.Sprintf("\x1b[35m|%s|\x1b[0m", level) // magenta
default:
return fmt.Sprintf("|%s|", level)
}
}
func formatLevelPlain(i interface{}) string {
return fmt.Sprintf("|%s|", strings.ToUpper(fmt.Sprintf("%s", i)))
}
// --- TemplateFileWriter ---
// TemplateFileWriter 按时间模板切换文件路径的 Writer
type TemplateFileWriter struct {
pathTemplate string
mu sync.Mutex
currentPath string
file *os.File
writer *bufio.Writer
}
func (w *TemplateFileWriter) Write(p []byte) (n int, err error) {
w.mu.Lock()
defer w.mu.Unlock()
path := time.Now().Format(w.pathTemplate)
if path != w.currentPath {
if w.writer != nil {
_ = w.writer.Flush()
}
if w.file != nil {
_ = w.file.Close()
}
_ = os.MkdirAll(filepath.Dir(path), 0755)
w.file, err = os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return 0, err
}
w.writer = bufio.NewWriterSize(w.file, 4096)
w.currentPath = path
}
n, err = w.writer.Write(p)
_ = w.writer.Flush()
return
}
// --- 调用者智能过滤 ---
func callerMarshalFunc(_ uintptr, file string, line int) string {
return findCaller(file, line)
}
func findCaller(origFile string, origLine int) string {
var lastInfraFile string
var lastInfraLine int
var primaryCaller string
var appLayerCaller string
for i := 1; i < 20; i++ {
_, file, line, ok := runtime.Caller(i)
if !ok {
break
}
shortFile := shortenPath(file)
if isInfrastructureFile(shortFile) {
lastInfraFile = shortFile
lastInfraLine = line
continue
}
if primaryCaller == "" {
primaryCaller = fmt.Sprintf("%s:%d", shortFile, line)
}
if appLayerCaller == "" && !strings.Contains(strings.ToLower(file), "hotime") {
appLayerCaller = fmt.Sprintf("%s:%d", shortFile, line)
}
}
result := ""
if primaryCaller != "" {
result = primaryCaller
} else if lastInfraFile != "" {
result = fmt.Sprintf("%s:%d", lastInfraFile, lastInfraLine)
} else {
result = fmt.Sprintf("%s:%d", shortenPath(origFile), origLine)
}
if appLayerCaller != "" && result != appLayerCaller {
result += " <- " + appLayerCaller
}
return result
}
func formatCaller(file string, line int) string {
return fmt.Sprintf("%s:%d", shortenPath(file), line)
}
func shortenPath(file string) string {
n := 0
for i := len(file) - 1; i > 0; i-- {
if file[i] == '/' || file[i] == '\\' {
n++
if n >= 2 {
return file[i+1:]
}
}
}
return file
}
// --- multiWriter: 支持运行时追加输出目标 ---
// multiWriter 实现 zerolog.LevelWriter,允许在 Logger 创建后动态追加新的输出(如 SeqWriter)
type multiWriter struct {
mu sync.RWMutex
writers []io.Writer
}
func (mw *multiWriter) Write(p []byte) (int, error) {
mw.mu.RLock()
defer mw.mu.RUnlock()
for _, w := range mw.writers {
_, _ = w.Write(p)
}
return len(p), nil
}
// WriteLevel 实现 zerolog.LevelWriter,使 zerolog 的级别过滤正确传递给各子 Writer
func (mw *multiWriter) WriteLevel(l zerolog.Level, p []byte) (int, error) {
mw.mu.RLock()
defer mw.mu.RUnlock()
for _, w := range mw.writers {
if lw, ok := w.(zerolog.LevelWriter); ok {
_, _ = lw.WriteLevel(l, p)
} else {
_, _ = w.Write(p)
}
}
return len(p), nil
}
func (mw *multiWriter) add(w io.Writer) {
mw.mu.Lock()
mw.writers = append(mw.writers, w)
mw.mu.Unlock()
}
// --- SeqWriter: 异步推送到 SeqCLEF 格式)---
// SeqWriter 将 zerolog JSON 日志通过 channel 队列异步发送到 Seq。
// Write() 仅做 channel <- bytesO(1) 非阻塞,绝不阻塞 web 请求处理 goroutine。
// channel 容量 10000,满时丢弃新条目(dropped 计数),不会影响主服务。
type SeqWriter struct {
seqUrl string
apiKey string
instance string
queue chan []byte
dropped int64
client *http.Client
}
func newSeqWriter(seqUrl, apiKey, instance string) *SeqWriter {
w := &SeqWriter{
seqUrl: strings.TrimRight(seqUrl, "/"),
apiKey: apiKey,
instance: instance,
queue: make(chan []byte, 10000),
client: &http.Client{Timeout: 5 * time.Second},
}
go w.run()
return w
}
// Write 将字节塞入 channel 即返回,由后台 goroutine 消费发送
func (w *SeqWriter) Write(p []byte) (int, error) {
clef := w.toClef(p)
select {
case w.queue <- clef:
default:
atomic.AddInt64(&w.dropped, 1)
}
return len(p), nil
}
// toClef 将 zerolog JSON 字段映射到 Seq CLEF 格式
// zerolog: time/level/message(msg) → CLEF: @t/@l/@mt
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))
}
clef := make(map[string]interface{}, len(m)+3)
for k, v := range m {
clef[k] = v
}
// time → @tzerolog 格式 "2006-01-02 15:04:05" → ISO 8601
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)
} else {
clef["@t"] = t
}
} else {
clef["@t"] = time.Now().Format(time.RFC3339)
}
// level → @l
if lv, ok := m["level"].(string); ok {
delete(clef, "level")
switch lv {
case "debug":
clef["@l"] = "Debug"
case "info":
clef["@l"] = "Information"
case "warn":
clef["@l"] = "Warning"
case "error":
clef["@l"] = "Error"
case "fatal":
clef["@l"] = "Fatal"
default:
clef["@l"] = lv
}
}
// message/msg → @mt
if msg, ok := m["message"].(string); ok {
delete(clef, "message")
clef["@mt"] = msg
} else if msg, ok := m["msg"].(string); ok {
delete(clef, "msg")
clef["@mt"] = msg
}
if w.instance != "" {
clef["instance"] = w.instance
}
b, _ := json.Marshal(clef)
return b
}
// run 后台 goroutine:每 100 条或 500ms 批量 POST 到 Seq
func (w *SeqWriter) run() {
batch := make([][]byte, 0, 100)
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case entry := <-w.queue:
batch = append(batch, entry)
if len(batch) >= 100 {
w.flush(batch)
batch = batch[:0]
}
case <-ticker.C:
if len(batch) > 0 {
w.flush(batch)
batch = batch[:0]
}
}
}
}
func (w *SeqWriter) flush(batch [][]byte) {
var buf bytes.Buffer
for _, entry := range batch {
buf.Write(entry)
buf.WriteByte('\n')
}
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
}
req.Header.Set("Content-Type", "application/vnd.serilog.clef")
if w.apiKey != "" {
req.Header.Set("X-Seq-ApiKey", w.apiKey)
}
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
}
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))
}
}
// SetSeqWriter 将 Seq HTTP 推送器附加到当前 Logger(不影响已有的控制台/文件输出)
// seqUrl: Seq 服务地址,如 "http://127.0.0.1:5341"
// apiKey: Seq API Key,免费单用户版留空
// instance: 实例标识,建议用 config 的 port 字段区分同机多进程,如 "8085"
func (l *Logger) SetSeqWriter(seqUrl, apiKey, instance string) {
if seqUrl == "" || l.mw == nil {
return
}
sw := newSeqWriter(seqUrl, apiKey, instance)
l.mw.add(sw)
}
// isInfrastructureFile 判断文件是否为基础设施(日志/DB/缓存/通用工具)管线,
// 这些文件在调用栈中会被跳过,以显示真正的业务调用者。
// context.go 是 Display/View 的纯中间层,也作为基础设施跳过。
// application.go、code/makecode.go 等保留,会作为有意义的调用者返回。
func isInfrastructureFile(file string) bool {
if strings.HasPrefix(file, "zerolog/") || strings.HasPrefix(file, "zerolog@") {
return true
}
if strings.HasPrefix(file, "logrus/") || strings.HasPrefix(file, "logrus@") {
return true
}
if strings.HasPrefix(file, "runtime/") {
return true
}
if strings.HasPrefix(file, "log/") {
return true
}
infraPrefixes := []string{"db/", "cache/", "common/", "dri/"}
for _, prefix := range infraPrefixes {
if strings.HasPrefix(file, prefix) {
return true
}
}
lowerFile := strings.ToLower(file)
if strings.Contains(lowerFile, "hotime") {
infraDirs := []string{"db/", "cache/", "common/", "log/", "dri/"}
for _, dir := range infraDirs {
if strings.Contains(file, dir) {
return true
}
}
if strings.HasSuffix(file, "/context.go") || strings.HasSuffix(file, "\\context.go") {
return true
}
}
return false
}
-194
View File
@@ -1,194 +0,0 @@
package log
import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"time"
log "github.com/sirupsen/logrus"
)
func GetLog(path string, showCodeLine bool) *log.Logger {
hook := MyHook{
Field: "line",
Skip: 5,
Path: path, ShowCodeLine: showCodeLine,
}
loger := log.New()
loger.SetFormatter(&log.TextFormatter{})
loger.AddHook(&hook)
return loger
}
// MyHook ...
type MyHook struct {
Path string //存储日志的位置
ShowCodeLine bool //输出代码文件名称和日志行
Field string
Skip int
levels []log.Level
}
// Levels 只定义 error 和 panic 等级的日志,其他日志等级不会触发 hook
func (that *MyHook) Levels() []log.Level {
return log.AllLevels
}
// Fire 将异常日志写入到指定日志文件中
func (that *MyHook) Fire(entry *log.Entry) error {
if that.ShowCodeLine {
entry.Data[that.Field] = findCaller(that.Skip)
}
//不需要存储到文件
if that.Path == "" {
return nil
}
//存储到文件
logFilePath := time.Now().Format(that.Path)
err := os.MkdirAll(filepath.Dir(logFilePath), os.ModeAppend)
if err != nil {
return err
}
//os.Create(logFilePath)
f, err := os.OpenFile(logFilePath, os.O_APPEND|os.O_CREATE, 0644)
if err != nil {
return err
}
bte, _ := entry.Bytes()
_, err = f.Write(bte)
if err != nil {
return err
}
err = f.Close()
if err != nil {
return err
}
return nil
}
// 最大框架层数限制 - 超过这个层数后不再跳过,防止误过滤应用层
const maxFrameworkDepth = 10
// isHoTimeFrameworkFile 判断是否是 HoTime 框架文件
// 更精确的匹配:只有明确属于框架的文件才会被跳过
func isHoTimeFrameworkFile(file string) bool {
// 1. logrus 日志库内部文件
if strings.HasPrefix(file, "logrus/") {
return true
}
// 2. Go 运行时文件
if strings.HasPrefix(file, "runtime/") {
return true
}
// 3. HoTime 框架核心文件 - 通过包含 "hotime" 或框架特有文件名来识别
// 检查路径中是否包含 hotime 框架标识
lowerFile := strings.ToLower(file)
if strings.Contains(lowerFile, "hotime") {
// 是 hotime 框架的一部分,检查是否是核心模块
frameworkDirs := []string{"/db/", "/common/", "/code/", "/cache/", "/log/", "/dri/"}
for _, dir := range frameworkDirs {
if strings.Contains(file, dir) {
return true
}
}
// 框架核心文件(在 hotime 根目录下的 .go 文件)
if strings.HasSuffix(file, "application.go") ||
strings.HasSuffix(file, "context.go") ||
strings.HasSuffix(file, "session.go") ||
strings.HasSuffix(file, "const.go") ||
strings.HasSuffix(file, "type.go") ||
strings.HasSuffix(file, "var.go") ||
strings.HasSuffix(file, "mime.go") {
return true
}
}
// 4. 直接匹配框架核心目录(用于没有完整路径的情况)
// 只匹配 "db/xxx.go" 这种在框架核心目录下的文件
frameworkCoreDirs := []string{"db/", "common/", "code/", "cache/"}
for _, dir := range frameworkCoreDirs {
if strings.HasPrefix(file, dir) {
// 额外检查:确保不是用户项目中同名目录
// 框架文件通常有特定的文件名
frameworkFiles := []string{
"query.go", "crud.go", "where.go", "builder.go", "db.go",
"dialect.go", "aggregate.go", "transaction.go", "identifier.go",
"error.go", "func.go", "map.go", "obj.go", "slice.go",
"makecode.go", "template.go", "config.go",
"cache.go", "cache_db.go", "cache_memory.go", "cache_redis.go",
}
for _, f := range frameworkFiles {
if strings.HasSuffix(file, f) {
return true
}
}
}
}
return false
}
// 对caller进行递归查询, 直到找到非框架层产生的第一个调用.
// 遍历调用栈,跳过框架层文件,找到应用层代码
// 使用层数限制确保不会误过滤应用层同名目录
func findCaller(skip int) string {
frameworkCount := 0 // 连续框架层计数
// 遍历调用栈,找到第一个非框架文件
for i := 0; i < 20; i++ {
file, line := getCaller(skip + i)
if file == "" {
break
}
if isHoTimeFrameworkFile(file) {
frameworkCount++
// 层数限制:如果已经跳过太多层,停止跳过
if frameworkCount >= maxFrameworkDepth {
return fmt.Sprintf("%s:%d", file, line)
}
continue
}
// 找到非框架文件,返回应用层代码位置
return fmt.Sprintf("%s:%d", file, line)
}
// 如果找不到应用层,返回最初的调用者
file, line := getCaller(skip)
return fmt.Sprintf("%s:%d", file, line)
}
// 这里其实可以获取函数名称的: fnName := runtime.FuncForPC(pc).Name()
// 但是我觉得有 文件名和行号就够定位问题, 因此忽略了caller返回的第一个值:pc
// 在标准库log里面我们可以选择记录文件的全路径或者文件名, 但是在使用过程成并发最合适的,
// 因为文件的全路径往往很长, 而文件名在多个包中往往有重复, 因此这里选择多取一层, 取到文件所在的上层目录那层.
func getCaller(skip int) (string, int) {
_, file, line, ok := runtime.Caller(skip)
//fmt.Println(file)
//fmt.Println(line)
if !ok {
return "", 0
}
n := 0
for i := len(file) - 1; i > 0; i-- {
if file[i] == '/' {
n++
if n >= 2 {
file = file[i+1:]
break
}
}
}
return file, line
}
+1 -2
View File
@@ -27,8 +27,7 @@ func Run() {
}
appIns.Config["tpt"] = TptPath
appIns.SetSession(hotime.CacheIns(&hotime.CacheMemory{}), hotime.CacheIns(&hotime.CacheMemory{}))
appIns.SetCache(hotime.CacheIns(&hotime.CacheMemory{}))
appIns.SetCache()
//快捷模式
//appIns.SetDefault(func(err ...*hotime.Error) *sql.DB {
+77 -1
View File
@@ -1,9 +1,10 @@
package hotime
import (
"sync"
. "code.hoteas.com/golang/hotime/cache"
. "code.hoteas.com/golang/hotime/common"
"sync"
)
// session对象
@@ -55,6 +56,81 @@ func (that *SessionIns) Session(key string, data ...interface{}) *Obj {
return result
}
// SessionsSet 批量设置session字段,只触发一次数据库写入
// 用法:that.SessionsSet(Map{"key1": value1, "key2": value2, ...})
// 性能优化:设置N个字段只触发1次数据库写入(而非N次)
func (that *SessionIns) SessionsSet(data Map) {
if len(data) == 0 {
return
}
that.mutex.Lock()
if that.Map == nil {
that.getWithoutLock()
}
// 批量设置所有字段
for key, value := range data {
if value == nil {
delete(that.Map, key)
} else {
that.Map[key] = value
}
}
that.mutex.Unlock()
// 只触发一次数据库写入
that.setWithCopy()
}
// SessionsDelete 批量删除session字段,只触发一次数据库写入
// 用法:that.SessionsDelete("key1", "key2", ...)
func (that *SessionIns) SessionsDelete(keys ...string) {
if len(keys) == 0 {
return
}
that.mutex.Lock()
if that.Map == nil {
that.getWithoutLock()
}
// 批量删除所有字段
for _, key := range keys {
delete(that.Map, key)
}
that.mutex.Unlock()
// 只触发一次数据库写入
that.setWithCopy()
}
// SessionsGet 批量获取session字段
// 用法:result := that.SessionsGet("key1", "key2", ...)
// 返回 Mapkey 为字段名,value 为字段值(不存在的 key 不包含在结果中)
func (that *SessionIns) SessionsGet(keys ...string) Map {
if len(keys) == 0 {
return Map{}
}
that.mutex.Lock()
if that.Map == nil {
that.getWithoutLock()
}
that.mutex.Unlock()
result := make(Map, len(keys))
that.mutex.RLock()
for _, key := range keys {
if value, exists := that.Map[key]; exists {
result[key] = value
}
}
that.mutex.RUnlock()
return result
}
// getWithoutLock 内部使用,调用前需要已持有锁
func (that *SessionIns) getWithoutLock() {
that.Map = that.HoTimeCache.Session(HEAD_SESSION_ADD + that.SessionId).ToMap()
+7
View File
@@ -0,0 +1,7 @@
//go:build !windows
package hotime
// registerWindowsCloseHandler 在非 Windows 平台上是空实现。
// Linux/macOS 通过 SIGTERM (kill) 或 SIGINT (Ctrl+C) 触发优雅停机。
func (that *Application) registerWindowsCloseHandler() {}
+31
View File
@@ -0,0 +1,31 @@
//go:build windows
package hotime
import (
"syscall"
"time"
)
var kernel32 = syscall.NewLazyDLL("kernel32.dll")
var procSetConsoleCtrlHandler = kernel32.NewProc("SetConsoleCtrlHandler")
// registerWindowsCloseHandler 注册 Windows 控制台关闭事件处理器,
// 捕获用户点击控制台 X 按钮、系统注销/关机等事件,触发优雅停机。
//
// Windows 在 CTRL_CLOSE_EVENT 后约 5 秒会强制结束进程,
// 因此此路径使用更短的 drain(2s)+shutdown(3s),总计 <5s。
// 对于需要更长时间的场景,请改用 taskkill /PID 或 Ctrl+C。
func (that *Application) registerWindowsCloseHandler() {
handler := func(ctrl uintptr) uintptr {
switch ctrl {
case 2, 5, 6: // CTRL_CLOSE_EVENT=2, CTRL_LOGOFF_EVENT=5, CTRL_SHUTDOWN_EVENT=6
that.initiateGracefulShutdown(2*time.Second, 3*time.Second)
time.Sleep(6 * time.Second)
return 1 // TRUE, 已处理
}
return 0 // FALSE, 交给下一个 handler
}
procSetConsoleCtrlHandler.Call(syscall.NewCallback(handler), 1)
}
+609
View File
@@ -0,0 +1,609 @@
package hotime
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
. "code.hoteas.com/golang/hotime/common"
. "code.hoteas.com/golang/hotime/db"
)
// === 注册类型 ===
// TestProj 测试项目集合(项目名 → 定义)
type TestProj map[string]TestProjDef
// TestProjDef 单个项目的路由 + 测试定义
type TestProjDef struct {
Proj Proj
Tests ProjTest
}
// ProjTest 项目级测试(控制器名 → 控制器测试)
type ProjTest map[string]CtrTest
// CtrTest 控制器级测试(方法名 → 用例定义)
type CtrTest map[string]ApiTestDef
// ApiTestDef 单个接口的测试定义
type ApiTestDef struct {
Desc string
Func func(a *Api)
}
// === Api:测试起点 ===
// Api 测试 API 入口,由 RunTests 自动创建并注入
type Api struct {
app *TestApp
path string
t *testing.T
session Map
lastResp *ApiResponse
}
// WithSession 返回一个携带 session 的新 Api 实例
func (a *Api) WithSession(s Map) *Api {
return &Api{
app: a.app,
path: a.path,
t: a.t,
session: s,
}
}
// JSON 设置 JSON body,返回 ApiCase 构建器
func (a *Api) JSON(body interface{}) *ApiCase {
c := a.newCase()
c.jsonBody = body
return c
}
// Query 设置 URL 查询参数,返回 ApiCase 构建器
func (a *Api) Query(params Map) *ApiCase {
c := a.newCase()
c.query = params
return c
}
// Form 设置表单 body,返回 ApiCase 构建器
func (a *Api) Form(body Map) *ApiCase {
c := a.newCase()
c.formBody = body
return c
}
// File 设置上传文件,返回 ApiCase 构建器
func (a *Api) File(field, name string, content []byte) *ApiCase {
c := a.newCase()
c.fileField = field
c.fileName = name
c.fileContent = content
return c
}
// Get 直接发送 GET 请求
// expect 可选参数: string 为期望 msgMap 为期望 result 结构(按样本值类型校验)
func (a *Api) Get(desc string, status int, expect ...interface{}) *ApiResponse {
return a.newCase().Get(desc, status, expect...)
}
// Post 直接发送 POST 请求
func (a *Api) Post(desc string, status int, expect ...interface{}) *ApiResponse {
return a.newCase().Post(desc, status, expect...)
}
// Put 直接发送 PUT 请求
func (a *Api) Put(desc string, status int, expect ...interface{}) *ApiResponse {
return a.newCase().Put(desc, status, expect...)
}
// Delete 直接发送 DELETE 请求
func (a *Api) Delete(desc string, status int, expect ...interface{}) *ApiResponse {
return a.newCase().Delete(desc, status, expect...)
}
// Verify 设置请求后的校验函数,返回 ApiCase 构建器
// 在回调中可用 a.Resp() 做响应值断言 + a.DB() 做数据库校验
func (a *Api) Verify(fn func(a *Api) error) *ApiCase {
c := a.newCase()
c.verifyFn = fn
return c
}
// Note 为下一条用例设置备注,返回可继续链式的 ApiCase
func (a *Api) Note(note string) *ApiCase {
c := a.newCase()
c.note = note
return c
}
// DB 获取数据库实例(在测试事务内)
func (a *Api) DB() *HoTimeDB {
return &a.app.Db
}
// Resp 获取最近一次请求的响应,在 Verify 回调中用于响应值断言
func (a *Api) Resp() *ApiResponse {
return a.lastResp
}
func (a *Api) newCase() *ApiCase {
return &ApiCase{
api: a,
session: a.session,
}
}
// === ApiCase:链式构建器 ===
// ApiCase 测试用例构建器,支持链式调用叠加请求数据
type ApiCase struct {
api *Api
session Map
query Map
jsonBody interface{}
formBody Map
fileField string
fileName string
fileContent []byte
note string
verifyFn func(a *Api) error
}
// JSON 叠加 JSON body
func (c *ApiCase) JSON(body interface{}) *ApiCase {
c.jsonBody = body
return c
}
// Query 叠加 URL 查询参数
func (c *ApiCase) Query(params Map) *ApiCase {
if c.query == nil {
c.query = params
} else {
for k, v := range params {
c.query[k] = v
}
}
return c
}
// Form 叠加表单 body
func (c *ApiCase) Form(body Map) *ApiCase {
if c.formBody == nil {
c.formBody = body
} else {
for k, v := range body {
c.formBody[k] = v
}
}
return c
}
// File 设置上传文件
func (c *ApiCase) File(field, name string, content []byte) *ApiCase {
c.fileField = field
c.fileName = name
c.fileContent = content
return c
}
// WithSession 覆盖本次请求的 session
func (c *ApiCase) WithSession(s Map) *ApiCase {
c.session = s
return c
}
// Note 为本条用例添加可选备注,备注将显示在 swagger 调试控制台
func (c *ApiCase) Note(note string) *ApiCase {
c.note = note
return c
}
// Verify 设置请求后的自定义校验函数(如查库验证数据状态)
// 函数返回 nil 表示校验通过,返回 error 则用例失败并记录错误原因
func (c *ApiCase) Verify(fn func(a *Api) error) *ApiCase {
c.verifyFn = fn
return c
}
// Get 终端操作:GET 请求 + 断言
func (c *ApiCase) Get(desc string, status int, expect ...interface{}) *ApiResponse {
return c.execute("GET", desc, status, expect...)
}
// Post 终端操作:POST 请求 + 断言
func (c *ApiCase) Post(desc string, status int, expect ...interface{}) *ApiResponse {
return c.execute("POST", desc, status, expect...)
}
// Put 终端操作:PUT 请求 + 断言
func (c *ApiCase) Put(desc string, status int, expect ...interface{}) *ApiResponse {
return c.execute("PUT", desc, status, expect...)
}
// Delete 终端操作:DELETE 请求 + 断言
func (c *ApiCase) Delete(desc string, status int, expect ...interface{}) *ApiResponse {
return c.execute("DELETE", desc, status, expect...)
}
func (c *ApiCase) execute(method, desc string, expectStatus int, expectArgs ...interface{}) *ApiResponse {
// status!=0 时: 第一个 string → msg 校验,其余 → result 结构校验
// status==0 时: 所有参数 → result 类型/结构校验(因为成功响应没有 msg)
var expectMsg string
var expectResult interface{}
var hasExpectResult bool
for _, arg := range expectArgs {
if s, ok := arg.(string); ok && expectStatus != 0 && expectMsg == "" {
expectMsg = s
continue
}
if !hasExpectResult {
expectResult = arg
hasExpectResult = true
}
}
if expectStatus != 0 && expectMsg == "" {
expectMsg = desc
}
t := c.api.t
var resp *ApiResponse
t.Run(desc, func(t *testing.T) {
start := time.Now()
passed := true
var failReason, verifyError string
var body Map
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,
}
if c.query != nil {
record.Query = c.query
}
if c.jsonBody != nil {
record.JsonBody = c.jsonBody
}
if c.formBody != nil {
record.FormBody = c.formBody
}
if c.fileContent != nil {
record.HasFile = true
record.FileField = c.fileField
}
if len(body) > 0 {
record.ResponseBody = body
}
expectResp := Map{"status": expectStatus}
if expectStatus != 0 {
if expectMsg != "" {
errorInfo := Map{"msg": expectMsg}
errorType := c.api.app.Config.GetMap("error").GetString(ObjToStr(expectStatus))
if errorType != "" {
errorInfo["type"] = errorType
}
expectResp["result"] = errorInfo
expectResp["error"] = errorInfo
}
} else if hasExpectResult {
expectResp["result"] = expectResult
}
record.ExpectResult = expectResp
c.api.app.collector.Add(record)
}()
c.api.app.Db.ResetTestSQLErrorCount()
req := c.buildRequest(method)
w := httptest.NewRecorder()
c.api.app.Application.ServeHTTP(w, req)
body = Map{}
if len(w.Body.Bytes()) > 0 {
if obj, err := JsonToObj(w.Body.String()); err == nil {
if m, ok := obj.(map[string]interface{}); ok {
body = Map(m)
}
}
}
resp = &ApiResponse{
StatusCode: w.Code,
Body: body,
RawBody: w.Body.Bytes(),
t: t,
}
if sqlErrCount := c.api.app.Db.TestSQLErrorCount(); sqlErrCount > 0 {
passed = false
failReason = fmt.Sprintf("SQL错误: 请求处理中有 %d 次 SQL 错误", sqlErrCount)
sqlLog := c.api.app.Db.FlushTestLog()
t.Fatalf("请求处理中有 %d 次 SQL 错误:\n%s", sqlErrCount, sqlLog)
}
actualStatus := body.GetInt("status")
if actualStatus != expectStatus {
failReason = fmt.Sprintf("状态码不匹配: 期望 %d, 实际 %d", expectStatus, actualStatus)
t.Errorf("状态码不匹配: 期望 %d, 实际 %d\n响应: %s", expectStatus, actualStatus, w.Body.String())
passed = false
}
if passed && expectMsg != "" {
actualMsg := resp.GetMsg()
if actualMsg != expectMsg {
failReason = fmt.Sprintf("消息不匹配: 期望 %q, 实际 %q", expectMsg, actualMsg)
t.Errorf("消息不匹配: 期望 %q, 实际 %q\n响应: %s", expectMsg, actualMsg, w.Body.String())
passed = false
}
}
if passed && hasExpectResult {
if !validateShape(body["result"], expectResult, "result", t) {
failReason = "响应结构校验失败"
passed = false
}
}
c.api.lastResp = resp
if passed && c.verifyFn != nil {
if err := c.verifyFn(c.api); err != nil {
t.Errorf("Verify 校验失败: %s", err.Error())
passed = false
verifyError = err.Error()
failReason = "Verify: " + err.Error()
}
}
if !passed {
if sqlLog := c.api.app.Db.FlushTestLog(); sqlLog != "" {
t.Logf("SQL 日志:\n%s", sqlLog)
}
} else {
c.api.app.Db.FlushTestLog()
}
})
if resp == nil {
resp = &ApiResponse{t: t}
}
return resp
}
func (c *ApiCase) buildRequest(method string) *http.Request {
path := c.api.path
if c.query != nil {
q := url.Values{}
for k, v := range c.query {
q.Set(k, ObjToStr(v))
}
path += "?" + q.Encode()
}
var body io.Reader
var contentType string
switch {
case c.fileContent != nil:
buf := &bytes.Buffer{}
writer := multipart.NewWriter(buf)
part, _ := writer.CreateFormFile(c.fileField, c.fileName)
_, _ = part.Write(c.fileContent)
if c.formBody != nil {
for k, v := range c.formBody {
_ = writer.WriteField(k, ObjToStr(v))
}
}
_ = writer.Close()
body = buf
contentType = writer.FormDataContentType()
case c.jsonBody != nil:
jsonBytes, _ := json.Marshal(c.jsonBody)
body = bytes.NewReader(jsonBytes)
contentType = "application/json"
case c.formBody != nil:
form := url.Values{}
for k, v := range c.formBody {
form.Set(k, ObjToStr(v))
}
body = strings.NewReader(form.Encode())
contentType = "application/x-www-form-urlencoded"
}
req := httptest.NewRequest(method, path, body)
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
if c.session != nil {
sessionId := Md5("test_session_" + ObjToStr(time.Now().UnixNano()))
c.api.app.HoTimeCache.Session(HEAD_SESSION_ADD+sessionId, c.session)
req.Header.Set("Authorization", sessionId)
}
return req
}
// === ApiResponse ===
// ApiResponse 测试响应对象
type ApiResponse struct {
StatusCode int
Body Map
RawBody []byte
t *testing.T
}
// GetStatus 获取业务状态码(JSON body 中的 status 字段)
func (r *ApiResponse) GetStatus() int {
return r.Body.GetInt("status")
}
// GetResult 获取响应结果(JSON body 中的 result 字段)
func (r *ApiResponse) GetResult() interface{} {
return r.Body.Get("result")
}
// GetMsg 获取响应消息
// Display 错误格式为 {"status":N, "result":{"msg":"xxx"}},成功时无 msg
func (r *ApiResponse) GetMsg() string {
if msg := r.Body.GetString("msg"); msg != "" {
return msg
}
if result := r.Body.GetMap("result"); result != nil {
return result.GetString("msg")
}
return ""
}
// GetBody 获取完整的响应 bodyJSON 解析后的 Map,可链式 Get*)
func (r *ApiResponse) GetBody() Map {
return r.Body
}
// GetRawBody 获取原始响应字节(用于非 JSON 响应如文件下载、二进制流校验)
func (r *ApiResponse) GetRawBody() []byte {
return r.RawBody
}
// Fail 手动标记测试失败(用于自定义断言)
func (r *ApiResponse) Fail(msg string) {
if r.t != nil {
r.t.Errorf("自定义断言失败: %s\n响应: %s", msg, string(r.RawBody))
} else {
fmt.Printf("自定义断言失败: %s\n", msg)
}
}
// ExpectResult 后置校验 result 结构,expected 作为类型样本(只比类型,不比值)
// expected 可以是 Map(校验字段)、Slice(校验数组)、或 string/int64/float64 等原始类型
//
// res.ExpectResult(Map{"id": int64(1), "name": "样本"})
// res.ExpectResult("操作成功") // 校验 result 是 string 类型
// res.ExpectResult(Slice{Map{"id": int64(1)}}) // 校验 result 是数组
func (r *ApiResponse) ExpectResult(expected interface{}) *ApiResponse {
if !validateShape(r.Body["result"], expected, "result", r.t) && r.t == nil {
fmt.Printf("ExpectResult 校验失败\n")
}
return r
}
// === 结构校验 ===
// validateShape 递归校验 actual 是否符合 expected 的结构(字段名+类型大类)
func validateShape(actual, expected interface{}, path string, t *testing.T) bool {
if expected == nil {
return true
}
if actual == nil {
if t != nil {
t.Errorf("字段 %s 缺失", path)
}
return false
}
switch exp := expected.(type) {
case Map:
return validateMapShape(actual, exp, path, t)
case map[string]interface{}:
return validateMapShape(actual, Map(exp), path, t)
case Slice:
return validateSliceShape(actual, exp, path, t)
case []interface{}:
return validateSliceShape(actual, Slice(exp), path, t)
default:
if !sameTypeCategory(actual, expected) {
if t != nil {
t.Errorf("字段 %s 类型不匹配: 期望 %s, 实际 %s(%v)",
path, typeCategory(expected), typeCategory(actual), actual)
}
return false
}
return true
}
}
func validateMapShape(actual interface{}, exp Map, path string, t *testing.T) bool {
actualMap := ObjToMap(actual)
if actualMap == nil {
if t != nil {
t.Errorf("字段 %s 类型不匹配: 期望 map, 实际 %s", path, typeCategory(actual))
}
return false
}
ok := true
for field, expectedVal := range exp {
fieldPath := path + "." + field
actualVal, exists := actualMap[field]
if !exists {
if t != nil {
t.Errorf("字段 %s 缺失", fieldPath)
}
ok = false
continue
}
if !validateShape(actualVal, expectedVal, fieldPath, t) {
ok = false
}
}
return ok
}
func validateSliceShape(actual interface{}, exp Slice, path string, t *testing.T) bool {
actualSlice := ObjToSlice(actual)
if actualSlice == nil {
if t != nil {
t.Errorf("字段 %s 类型不匹配: 期望 slice, 实际 %s", path, typeCategory(actual))
}
return false
}
if len(exp) > 0 && len(actualSlice) > 0 {
return validateShape(actualSlice[0], exp[0], path+"[0]", t)
}
return true
}
func sameTypeCategory(a, b interface{}) bool {
return typeCategory(a) == typeCategory(b)
}
func typeCategory(v interface{}) string {
switch v.(type) {
case int, int8, int16, int32, int64,
uint, uint8, uint16, uint32, uint64:
return "number"
case float32, float64:
return "float"
case string:
return "string"
case bool:
return "bool"
case Map, map[string]interface{}:
return "map"
case Slice, []interface{}:
return "slice"
default:
return fmt.Sprintf("%T", v)
}
}

Some files were not shown because too many files have changed in this diff Show More