Compare commits

..

323 Commits

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

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

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

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

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

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

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

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

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

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

- 添加 txFailed 指针字段用于跟踪事务内SQL执行状态
- 确保事务错误状态在 HoTimeDB 实例间正确共享
- 当事务内SQL出错时强制回滚以维护数据一致性
```
2026-04-11 22:34:13 +08:00
hoteas 5a1b5e4fee Merge branch 'master' into main-dmdb
# Conflicts:
#	db/db.go
2026-04-11 21:54:37 +08:00
hoteas f787421f82 fix(db): 修复事务中SQL错误时的回滚机制
- 添加txFailed标志跟踪事务内的SQL错误状态
- 在事务模式下遇到SQL错误时立即标记事务失败并阻止重试
- 修改Action方法确保SQL出错时强制回滚事务
- 为事务失败场景添加完整的单元测试覆盖
- 防止在事务已回滚的情况下继续执行后续操作导致数据不一致
2026-04-11 21:53:28 +08:00
hoteas cf8ff2b838 fix(docs): 更新测试文档中的文件引用以反映正确的测试文件名
- 将测试注册文件名从 `app_test.go` 更正为 `init_test.go`,确保文档与代码一致
- 更新相关代码示例以匹配新的文件结构,提升文档的准确性和可读性
2026-04-04 04:11:05 +08:00
hoteas f7c3a6c1c8 feat(logging): 增强日志记录功能以支持更详细的调试信息
- 在 GenerateSwagger 方法中添加更全面的调试日志,记录运行检查和端点保留情况
- 更新 Action 方法以优化测试模式下的日志缓冲,确保日志的准确性和完整性
- 删除不再使用的计划文档,简化项目结构
2026-04-04 01:57:55 +08:00
hoteas 63360f9a14 feat(logging): 增强测试框架的日志记录功能
- 在 GenerateSwagger 方法中添加调试日志记录,记录部分运行检查和现有端点的保留情况
- 更新 Action 方法以支持测试模式下的日志缓冲,确保日志记录的准确性
- 删除不再使用的计划文档,简化项目结构
2026-04-04 01:57:10 +08:00
hoteas 17e1090077 feat(tests): 增强测试框架的 SQL 日志记录与覆盖率报告
- 在 ApiCase 中添加 SQL 日志记录功能,失败时输出日志以便于调试
- 更新 TestApp 以支持测试模式下的 SQL 日志缓冲,成功与失败的测试均可记录日志
- 改进 PrintCoverage 方法,优化覆盖率报告的输出逻辑,支持部分运行的接口显示
- 增加 Note 备注功能,提升测试用例的可读性与文档化效果
- 更新相关文档,详细说明新功能的使用场景与示例
2026-04-04 01:34:09 +08:00
hoteas b3f263c965 feat(login): 添加 IP 限速机制以防止频繁登录尝试
- 在登录功能中实现同一 IP 1 分钟内最多尝试 10 次的限制
- 记录登录时间戳并过滤过期记录,确保登录尝试次数的准确性
- 优化未登录用户的返回信息,避免泄露敏感数据
2026-03-31 00:10:39 +08:00
hoteas 37a67f5810 feat(api): 增强 API 测试框架功能与文档
- 在 Api 结构中新增 lastResp 字段以存储最近请求的响应
- 添加 Verify 方法,支持自定义校验函数并返回 ApiCase 构建器
- 新增 Resp 方法,获取最近一次请求的响应以便于断言
- 在 TestCollector 中添加 Visited 字段,记录已调用的路径
- 更新 GenerateSwagger 方法,支持部分运行时保留未运行端点的已有数据
- 完善文档,增加用例编写范式和示例,提升测试框架的可用性与易用性
2026-03-30 01:55:07 +08:00
hoteas 3681564ca4 feat(config): 扩展数据类型映射与数据库字段备注提取功能
- 在 ColumnDataType 中新增对 PostgreSQL 和 Oracle 数据类型的支持
- 更新 Db2JSON 方法,优化对达梦数据库表和列信息的查询逻辑
- 增强字段备注解析,支持从备注中提取提示信息并清理标签
- 更新文档,详细说明字段备注格式及前端提示的展示效果
2026-03-23 16:48:56 +08:00
hoteas 37c6a00c0a refactor(db): 移除 Row 方法中的列名小写转换
- 在 Row 方法中删除对列名的统一小写转换,保持原始列名格式
- 简化代码逻辑,提升可读性
2026-03-21 20:51:58 +08:00
hoteas 70012032f0 feat(api): 增强 API 测试框架的断言功能
- 修改 ApiCase 和 ApiResponse 结构,新增 Verify 方法以支持自定义校验函数
- 更新 Get、Post、Put、Delete 方法,允许使用 expect 参数进行更灵活的响应断言
- 添加 ExpectResult 方法,支持对响应结果的结构和类型进行后置校验
- 更新文档,详细说明新功能的使用方法和示例,提升测试框架的可用性
- 增强 Swagger 生成逻辑,支持输出新的 expect 和 verifyError 字段
2026-03-21 13:38:22 +08:00
hoteas 813e26755f refactor(db): 优化测试事务中的互斥锁使用
- 重构 queryWithRetry 和 execWithRetry 方法中的互斥锁逻辑,提升代码可读性
- 确保在测试事务中对 testMu 的锁定和解锁操作更加清晰,避免潜在的并发问题
2026-03-20 13:52:23 +08:00
hoteas a7395b08de fix(cache): 修复 tableExists 方法以正确检测达梦数据库中的表存在性
- 修改 tableExists 方法,使用 COUNT(*) 查询当前 schema 下的表,避免因用户权限导致的误判
- 更新 ObjToFloat64、ObjToInt64 和 ObjToStr 方法,增加对多种整数和布尔类型的支持
- 在 Row 方法中处理 time.Time 类型,确保与 MySQL 兼容的字符串格式输出
2026-03-20 13:51:52 +08:00
hoteas 5e2bd765bf chore: 更新 .gitignore 文件,添加对 .py 文件的忽略;删除不再使用的 mysql2dm.py 文件 2026-03-20 11:54:42 +08:00
hoteas 6fda351585 feat(docs): 完善达梦数据库支持文档
- 更新 .gitignore 文件,添加对 .sql 文件的忽略
- 移除 cache_db.go 中的调试日志相关代码,简化缓存操作逻辑
- 在多个文档中添加达梦 DM8 数据库的支持信息,包括配置、使用注意事项及示例
- 更新 QUICKSTART.md,明确支持的数据库类型及配置示例
- 在 ROADMAP.md 中记录达梦数据库完整支持的进展
2026-03-20 11:25:09 +08:00
hoteas 7f7b585ffb feat(db): 添加对达梦数据库的支持
- 在应用程序中新增对达梦数据库(DM)的配置和连接支持
- 实现 SetDmDB 函数以配置达梦数据库连接
- 更新数据库操作逻辑,支持达梦特有的 SQL 语法和功能
- 在相关文件中添加达梦数据库的处理逻辑,包括表创建、数据插入和查询
- 更新 go.mod 和 go.sum 文件以引入达梦数据库驱动
- 增强文档,详细说明达梦数据库的配置和使用方法
2026-03-20 10:46:51 +08:00
hoteas 1546967918 docs: 更新 README 和 ROADMAP 文档,添加测试方法和响应字段断言改进规划
- 在 README.md 中添加测试方法说明
- 在 ROADMAP 文档中新增关于响应字段部分匹配断言的现状、改进方向及预期写法,提升文档的完整性和可读性
2026-03-16 06:53:31 +08:00
hoteas 47e13b532b feat(api): 添加备注功能以增强用例描述
- 在 Api 和 ApiCase 中新增 Note 方法,允许为用例设置可选备注
- 更新 TestRecord 结构体,包含备注字段以便记录用例信息
- 修改 Swagger 生成逻辑,支持备注字段的输出
- 更新文档,详细说明 Note 方法的使用及其在调试控制台的显示效果
2026-03-15 11:45:21 +08:00
hoteas 71e415dfe1 feat(swagger): 增强 Swagger 生成逻辑与参数推断
- 新增参数推断功能,自动从测试用例中推断 API 参数的必填性、类型和示例值
- 更新 GenerateSwagger 方法,支持在生成的 API 规范中包含推断的参数信息
- 优化文档,增加对新功能的说明,包括参数推断和覆盖率字段的详细描述
- 改进调试控制台的结构,支持模块化输出和独立导航页生成
- 增强并发保护与缓存隔离机制,确保测试环境的稳定性
2026-03-15 10:36:49 +08:00
hoteas 87098a9180 refactor(db): 增强测试事务和缓存管理
- 在 HoTimeDB 中新增 testMu 互斥锁,确保 testTx 的串行访问,解决并发冲突问题
- 在 BeginTestTx 中初始化 testMu,确保测试事务的安全性
- 在 HoTimeCache 中新增 DisableDbCache 方法,测试模式下禁用 DB 和 Redis 缓存,避免锁等待超时
- 更新 Select 方法,确保在 testTx 激活时跳过缓存逻辑,提升测试稳定性
- 优化 Swagger 生成逻辑,支持模块化输出和导航页生成
- 移除冗余的调试日志代码,提升代码整洁性
2026-03-14 13:06:32 +08:00
hoteas bd20af0c89 refactor(db): 增强测试事务支持和保存点管理
- 在 HoTimeDB 中新增测试事务功能,允许在测试模式下使用事务进行操作
- 实现 BeginTestTx 和 RollbackTestTx 方法,支持测试事务的开启和回滚
- 在 Action 方法中集成保存点管理,确保在测试模式下的嵌套事务处理
- 更新 README.md,添加 API 测试框架的相关说明,提升文档完整性
2026-03-14 10:19:57 +08:00
hoteas fab7931d3c refactor(app): 优化 URL 处理和 JSON 解析逻辑
- 更新应用程序处理程序中的 URL 赋值逻辑,确保静态文件使用原始路径
- 修改缓存数据库的 JSON 解析方法,使用 JsonToObj 函数替代 json.Unmarshal,提升代码可读性和性能
- 在 Map 和 Slice 类型中新增获取四舍五入浮点数的方法,增强数据处理能力
- 在 Obj 类型中添加四舍五入功能,支持精度控制
- 改进数据库查询结果的处理逻辑,确保数据类型的准确性和一致性
- 优化日志格式设置,增强日志信息的可读性
2026-03-10 23:44:41 +08:00
hoteas bf9bb5807d fix(db): 修复表列标识符处理逻辑确保聚合函数正常工作
- 修改 ProcessColumn 方法,table.column 格式不再添加反引号,只添加前缀
- 修改 ProcessColumnNoPrefix 方法,table.column 格式不加引号保持原始格式
- 更新 ProcessConditionString 方法,条件字符串中的 table.column 不加反引号
- 修复聚合函数如 Sum、Avg、Max、Min 在 table.column 格式下的正确性
- 添加完整的 table.column 格式测试用例验证功能一致性
- 确保返回的列名与原始列名一致,便于聚合函数等场景读取结果
2026-02-02 11:02:59 +08:00
hoteas 2d9e6e8e12 feat(app): 添加测试功能并增强错误处理
- 在应用路由中新增 test 接口用于显示测试数据
- 引入 errors 包以支持错误处理机制
- 在上下文响应处理中集成错误对象设置
- 实现 JSON 错误消息的自动转换与存储
- 扩展响应结构以同时支持 result 和 error 字段
2026-01-31 02:05:13 +08:00
hoteas 40eedccbd2 refactor(log): 优化调用栈查找逻辑以提高准确性
- 注释掉批量缓存操作测试的调用,避免不必要的执行
- 改进 findCaller 函数,增加对 application.go 的优先记录逻辑
- 确保返回的调用者信息优先考虑应用层代码,提升日志信息的准确性
- 增强框架文件的过滤机制,确保更清晰的调用栈信息
2026-01-30 22:17:53 +08:00
hoteas 4940232b24 feat(cache): 增加批量操作支持以提升性能
- 在 HoTimeCache 中新增 SessionsGet、SessionsSet 和 SessionsDelete 方法,支持批量获取、设置和删除 Session 缓存
- 优化缓存逻辑,减少数据库写入次数,提升性能
- 更新文档,详细说明批量操作的使用方法和性能对比
- 添加调试日志记录,便于追踪批量操作的执行情况
2026-01-30 17:51:43 +08:00
hoteas a11331e08d refactor(cache): 移除调试日志并优化缓存逻辑
- 删除 CacheDb 结构体中的 debugLog 调用,清理冗余的调试日志代码
- 优化历史表的创建逻辑,简化条件检查
- 更新缓存的获取和设置方法,确保逻辑清晰且高效
- 维护代码整洁性,提升可读性和可维护性
2026-01-25 23:15:32 +08:00
hoteas 8836e7a88d feat(cache): 增加历史记录功能和数据库设计规范
- 在 README.md 中新增数据库设计规范、代码生成配置规范和改进规划的文档链接
- 在 CacheDb 结构体中添加历史记录开关 HistorySet
- 实现历史记录的写入逻辑,记录每次缓存的新增和修改
- 更新缓存的获取和设置方法,支持历史记录的管理
- 优化数据库表的初始化和迁移逻辑,确保历史表的创建与管理
2026-01-25 05:14:18 +08:00
hoteas 431318bf8a refactor(log): 优化日志调用栈查找逻辑
- 添加最大框架层数限制防止误过滤应用层
- 新增 isHoTimeFrameworkFile 函数精确识别框架文件
- 实现更准确的框架文件过滤机制
- 替换原有的简单前缀匹配为复合条件判断
- 添加框架核心目录和文件的精确匹配规则
- 改进调用栈遍历算法提高查找准确性
2026-01-23 02:47:22 +08:00
hoteas cce765e735 test(db): 添加 OR 条件处理的单元测试并修复 WHERE 子句逻辑
- 添加了 TestWhereWithORCondition 测试函数验证 OR 条件的括号包裹
- 修复了 where.go 中条件计数变量名称从 normalCondCount 改为 condCount
- 实现了 OR/AND 组条件的括号包裹逻辑确保 SQL 语法正确
- 添加了空条件检查避免生成无效的 SQL 片段
- 更新了 .gitignore 文件添加日志文件忽略规则
2026-01-23 01:51:35 +08:00
hoteas 64c2e800b0 docs(guides): 完善框架文档并新增代码生成器说明
- 添加代码生成器完整使用说明文档,包含配置规则和自定义规则
- 新增 Common 工具类使用说明,介绍 Map/Slice/Obj 类型及转换函数
- 更新 QUICKSTART 文档中的配置项说明和数据库配置示例
- 完善请求参数获取方法,添加新版链式调用推荐用法
- 更新响应数据处理说明,包含错误码含义和自定义响应方法
- 优化中间件和 Session 操作的代码示例
- 修正路由路径参数获取的安全检查逻辑
- 更新 README 添加新文档链接索引
2026-01-22 22:13:05 +08:00
hoteas e704701752 refactor(db): 重命名批量插入方法并更新文档
- 将 BatchInsert 方法重命名为 Inserts,以更好地反映其功能
- 更新示例代码和文档,确保使用新方法名
- 删除过时的文档文件,整合 HoTimeDB 使用说明和 API 参考
- 优化 README.md,增强框架特性和安装说明的清晰度
2026-01-22 20:32:29 +08:00
hoteas 5235ef133d feat(db): 实现多数据库方言与自动表前缀支持
- 扩展 Dialect 接口添加 QuoteIdentifier 和 QuoteChar 方法
- 实现 IdentifierProcessor 结构体处理标识符转换
- 添加系统数据库列表避免对 INFORMATION_SCHEMA 等添加前缀
- 支持 database.table 格式的表名处理和前缀添加
- 在 CRUD 方法中集成新的标识符处理器
- 修改 WHERE 条件处理逻辑支持自动前缀
- 更新链式构建器的 JOIN 方法处理表名和条件
- 保持完全向后兼容性支持现有写法
2026-01-22 09:52:43 +08:00
hoteas c3b8d15b54 refactor(db): 重构数据库查询构建器以支持多数据库方言和标识符处理
- 实现了标识符处理器,统一处理表名、字段名的前缀添加和引号转换
- 添加对 MySQL、PostgreSQL、SQLite 三种数据库方言的支持
- 引入 ProcessTableName、ProcessColumn、ProcessConditionString 等方法处理标识符
- 为 HoTimeDB 添加 T() 和 C() 辅助方法用于手动构建 SQL 查询
- 重构 CRUD 操作中的表名和字段名处理逻辑,统一使用标识符处理器
- 添加完整的单元测试验证不同数据库方言下的标识符处理功能
- 优化 JOIN 操作中表名和条件字符串的处理方式
2026-01-22 09:32:01 +08:00
hoteas c4d0e16f2c feat(db): 实现多数据库方言与表名前缀支持功能
- 扩展 Dialect 接口,添加 QuoteIdentifier 和 QuoteChar 方法
- 新建 identifier.go,实现 IdentifierProcessor 及智能解析逻辑
- 在 db.go 中集成处理器,添加 T() 和 C() 辅助方法
- 修改 crud.go 中 Select/Insert/Update/Delete/buildJoin 等方法
- 修改 where.go 中 varCond 等条件处理方法
- 检查并更新 builder.go 相关功能
- 编写测试用例验证多数据库和前缀功能
- 移除调试日志文件以完成开发任务
2026-01-22 09:18:45 +08:00
hoteas ebe590ca9b chore(example): 清理调试日志并优化测试代码
- 移除大量 debugLog 调试日志调用
- 修改数据库计数检查代码,使用下划线忽略返回值
- 更新事务测试中的变量声明,统一使用下划线忽略不需要的返回值
- 添加注释说明 admin 表数据检查的目的
- 移除条件查询语法测试中的冗余调试日志
- 优化链式查询测试中的调试信息
- 清理 JOIN 查询测试部分的日志输出
- 移除聚合函数测试中的调试日志
- 删除分页查询测试中的多余日志
- 清理批量插入测试中的调试信息
- 优化 upsert 测试部分的日志输出
- 移除事务测试中的冗余调试日志
- 删除原生 SQL 测试中的大量调试日志
- 移除 IN/NOT IN 数组测试中的 region 注释块
2026-01-22 07:25:41 +08:00
hoteas 60a12a09bd feat(db): 实现数据库查询中的数组参数展开和空数组处理
- 在 Get 方法中添加无参数时的默认字段和 LIMIT 1 处理
- 实现 expandArrayPlaceholder 方法,自动展开 IN (?) 和 NOT IN (?) 中的数组参数
- 为空数组的 IN 条件生成 1=0 永假条件,NOT IN 生成 1=1 永真条件
- 在 queryWithRetry 和 execWithRetry 中集成数组占位符预处理
- 修复 where.go 中空切片条件的处理逻辑
- 添加完整的 IN/NOT IN 数组查询测试用例
- 更新 .gitignore 规则格式
2026-01-22 07:16:42 +08:00
hoteas 595275e747 remove(config): 移除配置相关JSON文件
- 删除 app.json 应用配置文件
- 删除 config.json 系统配置文件
- 删除 configNote.json 配置注释文件
- 删除 rule.json 规则配置文件
2026-01-22 06:12:26 +08:00
hoteas fdf02d4927 chore(project): 更新 .gitignore 文件
- 添加 /example/config 到忽略列表中
- 防止配置文件被提交到版本控制中
2026-01-22 06:10:51 +08:00
hoteas beebdfbbfa chore(config): 更新 .gitignore 配置
- 移除 /example/config/app.json 的忽略规则
- 移除 /example/config/ 目录的忽略规则
- 保留其他现有忽略配置不变
2026-01-22 06:09:19 +08:00
hoteas 05d1bb6b48 refactor(db): 重构数据库构建器以支持JOIN参数传递并删除示例配置文件
- 修改 Get 方法以根据是否存在 JOIN 来构建参数结构
- 修改 Count 方法以根据是否存在 JOIN 来传递参数
- 修改 Select 方法以根据是否存在 JOIN 来构建参数结构
- 在 where.go 中添加对 [##] 键的支持以直接添加 SQL 片段
- 删除 example/config/admin.json 配置文件
- 删除 example/config/config.json 配置文件
- 删除 example/config/configNote.json 配置文件
- 删除 example/config/rule.json 配置文件
- 删除 example/benchmark_test.go 压测文件
- 重构 example/main.go 文件,添加完整的 HoTimeDB 功能测试套件
- 添加调试日志功能以跟踪数据库操作流程
2026-01-22 05:31:17 +08:00
hoteas a770c52794 feat(request): 实现请求参数获取方法
- 完成 ReqParam/ReqParams 方法实现,用于获取 URL 参数并返回 *Obj
- 完成 ReqForm/ReqForms 方法实现,用于获取表单数据并返回 *Obj
- 完成 ReqJson/ReqJsons 方法实现,用于获取 JSON Body 并返回 *Obj
- 完成 ReqFile/ReqFiles 方法实现,用于获取上传文件
- 完成 ReqData/ReqDatas 方法实现,用于统一封装请求数据获取并返回 *Obj
- 更新计划文件状态,标记所有相关功能模块为已完成
2026-01-22 04:59:53 +08:00
hoteas 40f6783036 feat(cache): 优化缓存系统并重构数据库连接管理
- 将Redis连接方式改为连接池模式,提升连接复用效率
- 修复缓存注释错误,统一标识数据库缓存逻辑
- 添加数据检索结果非空验证,避免空指针异常
- 在数据库操作中添加读写锁保护,确保并发安全性
- 实现数据库查询和执行操作的重试机制,增强稳定性
- 更新配置文件中的缓存和数据库设置,优化缓存策略
- 重构README文档,补充框架特性和性能测试数据
- 添加示例路由配置,完善快速入门指南
2026-01-22 04:36:52 +08:00
hoteas 7843f7e8d6 style(cache): 格式化代码注释中的空格并添加 DISTINCT 选项
- 修复了缓存相关文件中注释开头缺少空格的问题
- 在 db/hotimedb.go 中为 vcond 数组添加了 "DISTINCT" 选项
- 注释掉了 session.go 中的并发控制代码以简化实现
2026-01-22 02:44:53 +08:00
hoteas 2ecb12bd7d 优化数据库连接配置,增强性能和稳定性 2025-08-07 17:51:31 +08:00
hoteas e5e615717a refactor(dri/baidu):优化导入路径和代码结构
- 更新了导入路径,使用了新的 "code.hoteas.com/golang/hotime/common" 路径
- 删除了未使用的变量和导入语句,简化了代码结构
2025-04-11 02:09:57 +08:00
hoteas 6db8f75ec7 refactor(cache): 重构 CacheMemory 实现
- 使用 sync.Map替代自定义 Map 结构- 优化缓存过期处理逻辑
- 改进通配符删除功能
- 随机触发缓存清理机制
2025-04-10 22:09:53 +08:00
hoteas 754668d017 增加数据库must设置 2023-04-06 11:37:03 +08:00
hoteas 544207a2e0 修复部分bug,增加DB SUM函数,以及优化IN性能,增加分析接口 2023-03-22 03:17:33 +08:00
hoteas 68ea5d5203 修复部分bug,增加DB SUM函数,以及优化IN性能,增加分析接口 2023-03-10 15:22:03 +08:00
hoteas 734d1b4be9 修复部分bug 2022-12-31 22:28:12 +08:00
hoteas c7a34990e3 修复部分bug 2022-11-17 17:17:07 +08:00
hoteas 6db22aa4fd 修复部分bug 2022-11-14 16:49:37 +08:00
hoteas f7493a1217 修复部分bug 2022-11-14 10:38:15 +08:00
hoteas 61b3c5c7cc 修复部分bug 2022-11-08 16:05:47 +08:00
hoteas f196e8a43a 修复部分bug 2022-11-07 01:41:22 +08:00
hoteas 05eb8d39db 修复部分bug 2022-11-07 01:40:24 +08:00
hoteas 2983934477 修复部分bug 2022-11-06 06:37:44 +08:00
hoteas 3d9666b630 修复部分bug 2022-11-05 01:55:11 +08:00
hoteas b8b543175d 修复部分bug 2022-11-04 03:22:00 +08:00
hoteas 3a1828be07 修复bug 2022-11-04 02:55:58 +08:00
hoteas f043597300 优化性能 2022-11-04 02:34:48 +08:00
hoteas 6542a7316a 优化性能 2022-11-04 02:25:33 +08:00
hoteas 57c0658da0 优化性能 2022-11-04 02:12:07 +08:00
hoteas 1f26cd2a50 修正bug 2022-10-21 04:28:51 +08:00
hoteas 1a7e92d524 修正bug 2022-10-20 15:49:49 +08:00
hoteas ea932a032b 时间由指针改回对象 2022-10-20 09:08:23 +08:00
hoteas 56f4911459 升级流程引擎配置文件版本 2022-10-20 09:00:44 +08:00
hoteas 735c088a0a 升级流程引擎配置文件版本 2022-10-19 21:32:34 +08:00
hoteas 80db372857 升级流程引擎配置文件版本 2022-10-19 21:29:38 +08:00
hoteas 8d1ad4499d 增加单表某字段权限及类型控制功能 2022-10-19 03:37:52 +08:00
hoteas 458cefd0ac 增加备注功能 2022-10-08 17:32:37 +08:00
hoteas 0d40fb5114 增加壁纸及壁纸地址缓存 2022-10-08 16:52:42 +08:00
hoteas 9ac5abe884 增加两个参数 2022-09-16 07:10:45 +08:00
hoteas 22a1f19e6a IP获取真实条件修改 2022-09-02 00:04:23 +08:00
hoteas 91a3fe2413 修复下载插件bug 2022-08-31 19:15:47 +08:00
hoteas b60b863af2 修复下载插件bug 2022-08-31 19:10:41 +08:00
hoteas 07637a635c 修复下载插件bug 2022-08-31 19:10:08 +08:00
hoteas 36cf708983 数据库操作bug 2022-08-31 11:20:58 +08:00
hoteas 8ab5c9f3e3 数据库操作bug 2022-08-31 11:14:45 +08:00
hoteas e4686fd77d 修正查询bug 2022-08-30 06:21:46 +08:00
hoteas 06714a1968 增加mongodb驱动,简单版本 2022-08-30 04:44:37 +08:00
hoteas 6fa8e66aaf 增加mongodb驱动,简单版本 2022-08-30 04:05:17 +08:00
hoteas c7494015dc 增加mongodb驱动,简单版本 2022-08-30 03:30:27 +08:00
hoteas 32d8c43e1f 修复重复执行context.View()的bug 2022-08-25 14:06:31 +08:00
hoteas ae6f2195b0 树状结构优化 2022-08-25 13:06:26 +08:00
hoteas 72d68d1738 树状结构优化 2022-08-25 13:02:44 +08:00
hoteas c959402250 数据库错误操作优化 2022-08-25 05:01:50 +08:00
hoteas 340a90278b 数据库错误操作优化 2022-08-25 03:48:09 +08:00
hoteas 312e92004d 数据库错误操作优化 2022-08-25 03:44:58 +08:00
hoteas ce9d8d055c 增加记录IP 2022-08-21 03:49:56 +08:00
hoteas 41f455cf4c Merge remote-tracking branch 'origin/master' 2022-08-20 22:38:40 +08:00
hoteas febd8d9805 增加记录IP 2022-08-20 22:38:15 +08:00
hoteas 60667734a7 数据库错误操作优化 2022-08-20 11:20:03 +08:00
hoteas cf29db5d5c 检索升级 2022-08-19 17:58:18 +08:00
hoteas 62a367ad5b 数据库错误操作优化 2022-08-18 13:40:02 +08:00
hoteas f5a24aa173 数据库错误操作优化 2022-08-18 13:28:02 +08:00
hoteas 67d1450848 数据库错误操作优化 2022-08-18 12:33:01 +08:00
hoteas 559e68a0b2 数据库事务操作优化 2022-08-18 11:36:23 +08:00
hoteas a596b02d43 数据库操作优化 2022-08-17 17:10:12 +08:00
hoteas 8ae4ccc126 数据库操作优化 2022-08-17 16:42:46 +08:00
hoteas 0b1116ba9e 数据库操作优化 2022-08-17 16:35:52 +08:00
hoteas e8b391b0a2 数据库操作优化,优化系统管理后台代码生成逻辑 2022-08-17 16:02:36 +08:00
hoteas 4097a44e58 数据库操作优化 2022-08-15 05:59:06 +08:00
hoteas 64daf04f35 修复登录bug 2022-08-11 10:41:35 +08:00
hoteas 97cca14b45 解决BUG 2022-08-11 10:18:48 +08:00
hoteas 78539b9dbc 修复bug 2022-08-08 04:34:26 +08:00
hoteas df5f8f158b 修复bug 2022-08-08 02:37:15 +08:00
hoteas ca947bb9e0 解决bug 2022-08-04 12:14:18 +08:00
hoteas 899c1c340d 修复bug 2022-08-04 02:41:41 +08:00
hoteas fab3d86519 修复配置bug 2022-08-03 12:07:55 +08:00
hoteas 48ee95ca67 V2.0封测 2022-08-02 03:02:57 +08:00
hoteas 9d31f2d1f8 权限管理优化 2022-08-01 18:48:08 +08:00
hoteas ffa32105f2 权限管理优化 2022-08-01 16:45:24 +08:00
hoteas 3da135e3cb 增加导出功能 2022-08-01 03:40:09 +08:00
hoteas 4e24a913c6 大岗山水电站初版完成 2022-07-28 12:08:47 +08:00
hoteas ed0944618f 大岗山水电站初版完成 2022-07-28 11:08:51 +08:00
hoteas e393d258a8 V2半封测 2022-07-27 17:09:56 +08:00
hoteas c91c610d2d 技术性调整 2022-07-26 17:37:17 +08:00
hoteas 7d74ebf4e4 技术性调整 2022-07-25 05:44:38 +08:00
hoteas abc7eaf529 技术性调整 2022-07-25 05:37:19 +08:00
hoteas a1250e15c5 技术性调整 2022-07-24 00:00:36 +08:00
hoteas f6d3aa800c 增加权限管理 2022-07-21 00:24:39 +08:00
hoteas 854df4fa7d 增加权限管理 2022-07-18 19:45:06 +08:00
hoteas 6c92a21c3b Merge remote-tracking branch 'origin/master'
# Conflicts:
#	example/config/configNote.json
#	var.go
2022-07-18 19:44:52 +08:00
hoteas 4b641cd09d 增加权限管理 2022-07-18 19:44:00 +08:00
hoteas 497c4b028f 技术性调整 2022-07-18 08:46:09 +08:00
hoteas e37b7b586b 优化日志及config 2022-07-11 19:13:20 +08:00
hoteas 15b73b3ce7 优化日志及config 2022-07-11 11:07:17 +08:00
hoteas 83acef93a9 技术性调整 2022-07-08 09:55:02 +08:00
hoteas f5af9424f0 修复数据库及时间bug 2022-06-21 00:06:34 +08:00
hoteas 5d4d1e693b 增加mysql断线重连优化 2022-06-14 11:29:54 +08:00
hoteas 88372e992e 增加time兼容性 2022-06-14 09:52:49 +08:00
hoteas 3b558b22ff 接口层复用 2022-06-09 04:05:50 +08:00
hoteas 34e032105d 接口层复用 2022-06-09 03:54:33 +08:00
hoteas 5c2e642bd1 数据库where增加[##]操作 2022-05-27 15:00:44 +08:00
hoteas 65ece0418e 增加vendor 2022-05-24 13:49:25 +08:00
hoteas c3a24eeb3c 删除example代码 2022-05-19 15:45:51 +08:00
zhoupengwei 61d5f410da 导出功能修改关联salesman关联关系 2022-05-18 17:52:49 +08:00
zhoupengwei f6f48a3f8d 科创券优化 2022-05-17 12:31:47 +08:00
zhoupengwei 771b02bd61 添source_type字段(券的来源:1-vip获得,2-认证获得,3-拉新获得),用来判断券的来源,
userinfo接口返回企业统一社会信用代码和营业执照路径
2022-05-13 18:43:35 +08:00
zhoupengwei 72955466d5 科服券列表+领券,企业认证,购买vip领券 2022-05-13 15:50:45 +08:00
zhoupengwei 8b7bf8a150 科服券列表+领券,企业认证,购买vip领券 2022-05-13 15:45:50 +08:00
zhoupengwei dbabee7a8a Merge branch 'zct-v2-dengluyang' of https://code.hoteas.com/golang/hotime into zhoupengwei 2022-05-13 15:34:53 +08:00
zhoupengwei 71fb533950 科服券列表+领券,企业认证,购买vip领券 2022-05-13 15:34:15 +08:00
hoteas cbcfd735ca 增加time类型直接转换 2022-05-13 15:31:55 +08:00
zhoupengwei e4f354d26f Merge branch 'zct-v2-dengluyang' of https://code.hoteas.com/golang/hotime into zhoupengwei 2022-05-13 09:36:46 +08:00
hoteas 20d8926d05 打印影响延迟优化 2022-05-13 09:30:09 +08:00
hoteas 0f2b47d6d9 打印影响延迟优化 2022-05-12 18:27:01 +08:00
zhoupengwei 2034e8e641 vip当日购买信息导出excel的sql调整,添加新字段:用户关联parentid时间,用户关联salesmanid时间 2022-05-11 18:19:30 +08:00
zhoupengwei f53a3108a9 Merge branch 'zct-v2-dengluyang' of https://code.hoteas.com/golang/hotime into zhoupengwei 2022-05-11 18:17:40 +08:00
hoteas 6a35466221 打印影响延迟优化 2022-05-11 14:55:28 +08:00
hoteas 243dd12747 打印影响延迟优化 2022-05-11 13:56:53 +08:00
zhoupengwei 5b453d7c5d Merge branch 'zct-v2-dengluyang' of https://code.hoteas.com/golang/hotime into zhoupengwei 2022-05-11 09:51:35 +08:00
hoteas 06e6ceab6a 对政策匹配进行优化 2022-05-11 00:44:52 +08:00
zhoupengwei 4365fa6241 vip当日购买信息导出excel 2022-05-10 19:47:20 +08:00
zhoupengwei 8fa8858b47 vip当日购买信息导出excel 2022-05-10 19:45:00 +08:00
zhoupengwei 64df6c9f03 Merge branch 'zct-v2-dengluyang' of https://code.hoteas.com/golang/hotime into zhoupengwei 2022-05-10 19:43:36 +08:00
hoteas 95ac340186 对政策匹配进行优化 2022-05-10 12:54:24 +08:00
zhoupengwei 93353f618a Merge branch 'zct-v2-dengluyang' of https://code.hoteas.com/golang/hotime into zhoupengwei
 Conflicts:
	example/app/declare.go
	example/provider/company.go
2022-05-10 09:43:03 +08:00
hoteas f2643222ca 对政策匹配进行优化 2022-05-09 19:19:15 +08:00
hoteas abecca89c5 对政策匹配进行优化 2022-05-09 15:07:01 +08:00
zhoupengwei 3a6b0f8647 Merge branch 'zct-v2' of https://code.hoteas.com/golang/hotime into zhoupengwei 2022-05-07 20:56:36 +08:00
zhoupengwei fa2545c2c8 Merge pull request '修复 bug 客户管理领域信息没存起来,高层次人才单选的形式存储的' (#1) from liyun into zct-v2
Reviewed-on: #1
2022-05-07 12:55:28 +00:00
michael eaa6a90c0e 修复 bug 客户管理领域信息没存起来,高层次人才单选的形式存储的 2022-05-07 20:47:58 +08:00
zhoupengwei a534e74f7d Merge branch 'zct-v2' of https://code.hoteas.com/golang/hotime into zhoupengwei 2022-05-07 17:11:27 +08:00
hoteas dec56b75d2 修正企服商那边不显示名称问题 2022-05-07 13:55:57 +08:00
hoteas 23b398e705 临时增加state为1企服商列表不显示企业 2022-05-07 11:08:11 +08:00
hoteas e0752218ea 增加限制条件 2022-05-07 11:02:12 +08:00
hoteas 25ab6c219d 增加限制条件 2022-05-06 13:08:13 +08:00
hoteas 4cbc84063a 增加限制条件 2022-05-06 11:35:40 +08:00
hoteas aea2e0241d 增加join的orm兼容性 2022-05-05 18:33:27 +08:00
hoteas eb41162f3b 增加U盘登录 2022-05-05 16:07:01 +08:00
zhoupengwei b37546bee2 Merge branch 'zct-v2' of https://code.hoteas.com/golang/hotime into zhoupengwei 2022-05-05 10:44:10 +08:00
hoteas f0d4efd8fc 初步版本修正 2022-05-05 00:29:30 +08:00
zhoupengwei 0fd7500e34 只在main.go中加了双//,测试是否能够提交成功 2022-05-04 21:50:52 +08:00
hoteas ce8059b0a0 调优 2022-05-03 22:40:02 +08:00
hoteas 7d36ad95a7 验证数据 2022-05-03 15:17:27 +08:00
hoteas 58da542533 基础整理 2022-05-03 09:41:18 +08:00
hoteas 0b09a13c17 基础整理 2022-05-03 08:09:25 +08:00
hoteas 062e849d44 基础整理 2022-05-02 15:42:54 +08:00
hoteas bdcec2d534 用户登录 2022-05-02 15:39:50 +08:00
hoteas 7b8abc9daa 科创地图打包 2022-04-29 17:58:14 +08:00
hoteas 7d4358af83 科创地图打包 2022-04-29 03:08:32 +08:00
hoteas cac15ade74 增加普通时间类型,以及增加富文本类型 2022-04-28 14:44:56 +08:00
hoteas 25b12142de Merge branch 'zct-wechat' into zct-manage
# Conflicts:
#	example/config/config.json
#	example/main.go
#	example/zct_manage.exe
2022-04-26 09:24:51 +08:00
hoteas 9c8190b54e 增加普通时间类型,以及增加富文本类型 2022-04-25 20:08:16 +08:00
hoteas f1244091fc 增加普通时间类型,以及增加富文本类型 2022-04-25 19:58:49 +08:00
hoteas 10b73bef8f 系统优化 2022-04-22 15:48:17 +08:00
hoteas 1113c1eb43 系统优化 2022-04-21 02:20:27 +08:00
hoteas 31955fce52 系统优化 2022-04-20 22:49:07 +08:00
hoteas cfa668a5f3 系统优化 2022-04-20 22:47:58 +08:00
hoteas 3fdc3aac2e 系统优化 2022-04-20 18:36:05 +08:00
hoteas c368619e51 系统优化 2022-04-18 06:28:01 +08:00
hoteas f1d066811d 系统优化 2022-03-29 05:31:02 +08:00
hoteas 49afe5f714 初步完成 2022-03-28 04:52:10 +08:00
hoteas cd23ed85c2 初步完成 2022-03-28 01:14:09 +08:00
hoteas 045fd3a38c 框架优化 2022-03-26 16:53:40 +08:00
hoteas 7da5f26098 框架优化 2022-03-16 09:58:24 +08:00
hoteas 02fc1d7843 框架优化 2022-03-14 16:48:19 +08:00
hoteas cb0bcdb2d1 框架优化 2022-03-13 17:02:19 +08:00
hoteas e426c285b0 框架优化 2022-03-13 05:13:32 +08:00
hoteas 716b3dfa6b 框架优化 2022-03-13 05:06:28 +08:00
hoteas 369c06724a Merge remote-tracking branch 'origin/master' into rfcb
# Conflicts:
#	code.go
2022-03-13 02:16:31 +08:00
hoteas 17ac1a9dcf 优化整体 2022-03-13 02:00:40 +08:00
hoteas 8befd195c0 优化整体 2022-03-13 01:48:54 +08:00
hoteas ac027c6e42 使用go mod 2022-03-13 01:12:29 +08:00
hoteas 7be976cdeb 使用go mod 2022-03-13 01:00:59 +08:00
hoteas b18bc1ced8 删除无关代码 2022-03-13 00:26:01 +08:00
hoteas 2b8816c54b 删除无关代码 2022-03-13 00:24:36 +08:00
hoteas cecefa1227 hotimego V1.0.1 2022-03-12 23:57:48 +08:00
hoteas 730a2e41f5 政企超链接开始集成 2022-03-12 23:52:42 +08:00
hoteas e7b9d7d100 政企超链接开始集成 2022-03-12 23:52:25 +08:00
hoteas e200e0a370 政企超链接开始集成 2022-03-06 23:55:14 +08:00
hoteas b5f6a33f07 政企超链接开始集成 2022-03-04 00:06:26 +08:00
hoteas 6512f122f5 政企超链接开始集成 2022-03-04 00:06:04 +08:00
hoteas cac0214271 政企超链接开始集成 2022-03-03 21:23:57 +08:00
hoteas e4ef9230c2 政企超链接开始集成 2022-03-02 18:19:15 +08:00
hoteas d90591af75 政企超链接开始集成 2022-03-02 12:14:07 +08:00
hoteas 91bf142782 政企超链接开始集成 2022-02-28 19:37:52 +08:00
hoteas fbe97b4461 政企超链接开始集成 2022-02-28 19:08:48 +08:00
hoteas eb25417104 政企超链接开始集成 2022-02-28 09:59:05 +08:00
hoteas 42f91d57ff 政企超链接开始集成 2022-02-28 08:53:38 +08:00
hoteas 2a98f0dcad 政企超链接开始集成 2022-02-24 06:26:36 +08:00
hoteas 32319d52f0 简阳版本 2022-02-19 21:38:35 +08:00
hoteas 3e94cf8def 简阳版本 2022-02-19 21:38:17 +08:00
hoteas 6a46d06e81 更新研发 2022-02-17 04:10:53 +08:00
hoteas d4759bb03c 更新研发 2022-02-14 19:11:38 +08:00
hoteas 9187960f66 更新研发 2022-02-14 19:10:58 +08:00
hoteas 7c2cc695b7 更新研发 2022-01-27 23:41:40 +08:00
hoteas 3b11f44915 更新研发 2022-01-26 11:45:17 +08:00
hoteas cea7e57409 更新研发 2022-01-23 05:13:19 +08:00
hoteas 29a84db736 更新研发 2022-01-22 16:12:02 +08:00
hoteas 74ec6e619a 更新研发 2022-01-20 07:15:14 +08:00
hoteas b42e73ec50 企业画像 2022-01-19 05:38:49 +08:00
hoteas f31262559d 更新研发 2022-01-17 04:47:39 +08:00
hoteas a8d7b934f3 权限模块开发 2022-01-12 17:33:20 +08:00
hoteas 865e14e121 权限模块开发 2022-01-10 13:23:39 +08:00
hoteas a3f97c04b3 修复太过于严格的bug 2022-01-03 01:42:35 +08:00
hoteas 5c5234016c 增加自适应模式 2021-12-31 02:38:51 +08:00
hoteas c222570eb2 增加自适应模式 2021-12-30 22:19:52 +08:00
hoteas 816b57d4e3 增加自适应模式 2021-12-29 00:46:07 +08:00
hoteas 8fce77b643 增加自适应模式 2021-12-28 09:26:26 +08:00
hoteas fa50191741 增加自适应模式 2021-12-27 21:30:33 +08:00
hoteas 3b3ca6facb 增加自适应模式 2021-12-27 21:30:24 +08:00
hoteas d3b355836d 增加自适应模式 2021-12-27 20:40:16 +08:00
hoteas a47473f245 优化系统 2021-12-27 18:21:41 +08:00
hoteas 9ce4f1c603 优化系统 2021-12-27 14:09:49 +08:00
hoteas 717ddeaea3 Merge branch 'master' into myhs
# Conflicts:
#	example/admin/init.go
#	example/bzyy.exe
#	example/main.go
2021-12-27 14:02:39 +08:00
hoteas d656f3689e 优化系统 2021-12-27 14:01:04 +08:00
hoteas 6e32f05087 优化系统 2021-12-27 14:00:08 +08:00
hoteas 1c31be2807 优化系统 2021-12-18 00:07:35 +08:00
hoteas 5b00a69496 优化系统 2021-12-17 14:37:41 +08:00
hoteas 55e60ac0eb 调试 2021-12-17 01:21:08 +08:00
hoteas 3ce77221f8 调试 2021-12-16 13:40:19 +08:00
hoteas 6fe5d0ba55 调试 2021-12-16 13:24:31 +08:00
hoteas d1d4d04415 出入库完成 2021-12-16 10:49:35 +08:00
hoteas 62cbf28b87 出入库完成 2021-12-15 09:24:41 +08:00
hoteas ac66331535 出入库完成 2021-12-15 05:56:31 +08:00
hoteas 9482404265 书写前端接口 2021-12-12 04:22:30 +08:00
hoteas 7b85da4c7f 书写前端接口 2021-12-11 23:53:47 +08:00
hoteas db013b9966 书写前端代码 2021-12-11 17:59:02 +08:00
hoteas 44e46982f5 ocr增加 2021-12-09 11:16:15 +08:00
hoteas 02911fd39e ocr增加 2021-12-09 10:58:53 +08:00
hoteas 3a706077dd 优化makecode 2021-11-29 03:27:07 +08:00
hoteas 219f93b5ff 优化makecode 2021-11-23 18:31:00 +08:00
hoteas fb6f55c98d 优化makecode 2021-11-18 12:22:14 +08:00
hoteas f38e6f4397 绵阳宏升代码优化 2021-11-15 03:35:13 +08:00
hoteas f62d169465 绵阳宏升代码优化 2021-11-15 03:34:45 +08:00
hoteas 96b1795f05 巴中代码提交 2021-11-15 03:32:50 +08:00
hoteas 2734e95096 初次提交 2021-11-15 03:30:21 +08:00
hoteas 101609e81b 初次提交 2021-11-15 03:28:14 +08:00
hoteas 2cea20e7c9 初次提交 2021-11-15 03:26:54 +08:00
hoteas 340043d1f0 初次提交 2021-11-13 21:06:29 +08:00
hoteas 41defc022a 初次提交 2021-11-08 09:16:28 +08:00
hoteas 830d69825d 初次提交 2021-11-08 09:15:52 +08:00
hoteas 1f2a247398 完善访问日志 2021-11-08 06:49:34 +08:00
hoteas fdab298f52 修复短信发送bug 2021-10-27 00:27:24 +08:00
hoteas 6c999990e3 增加天府通办登录 2021-10-25 23:18:27 +08:00
hoteas 4433c3efc5 修复bug 2021-10-21 23:20:58 +08:00
hoteas 024b42bfa1 修复bug 2021-10-21 23:19:32 +08:00
hoteas 3efd5bd0a0 修复bug 2021-10-21 22:52:40 +08:00
hoteas e5dfe9aa8d 修复bug 2021-10-18 04:49:55 +08:00
hoteas 62ba6e4f86 修复bug 2021-09-25 04:36:07 +08:00
hoteas faddd72fee 短信变更 2021-09-24 15:01:03 +08:00
hoteas c8c5f453d5 巴中恩阳适配 2021-09-24 10:49:07 +08:00
hoteas 49d956cc36 巴中恩阳适配 2021-09-17 04:45:43 +08:00
hoteas cdefc0e9e3 开始新增代码生成配置2.0功能,配置文件格式规定完成 2021-09-17 04:00:49 +08:00
hoteas 9513b80a24 开始新增代码生成配置2.0功能,配置文件格式规定完成 2021-09-16 04:33:10 +08:00
hoteas acb7cdd4b8 增加表关联新建功能,同时修复数据库bug 2021-09-12 06:14:01 +08:00
1205 changed files with 444073 additions and 15246 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("HoTime 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/your-app/app/user.go` -- 末尾添加 UserTest
- **修改** `d:/work/your-app/app/init.go` -- 添加 ProjectTest
- **新增** `d:/work/your-app/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. 修改 [app/app/user.go](d:/work/your-app/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. 修改 [app/app/init.go](d:/work/your-app/app/init.go) -- 添加 `var ProjectTest = ProjTest{...}`
### 9. 新增 [app/app/app_test.go](d:/work/your-app/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("HoTime 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/your-app/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\your-app.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_app.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_app.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_app.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_app.cmd`](d:/work/your-app/run_app.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,249 @@
---
name: 多数据库方言与前缀支持
overview: 为 HoTimeDB ORM 实现完整的多数据库(MySQL/PostgreSQL/SQLite)方言支持和自动表前缀功能,采用智能解析+辅助方法兜底的混合策略,保持完全向后兼容。
todos:
- id: dialect-interface
content: 扩展 Dialect 接口,添加 QuoteIdentifier 和 QuoteChar 方法
status: completed
- id: identifier-processor
content: 新建 identifier.go,实现 IdentifierProcessor 及智能解析逻辑
status: completed
- id: db-integration
content: 在 db.go 中集成处理器,添加 T() 和 C() 辅助方法
status: completed
- id: crud-update
content: 修改 crud.go 中 Select/Insert/Update/Delete/buildJoin 等方法
status: completed
- id: where-update
content: 修改 where.go 中 varCond 等条件处理方法
status: completed
- id: builder-check
content: 检查 builder.go 是否需要额外修改
status: completed
- id: testing
content: 编写测试用例验证多数据库和前缀功能
status: completed
- id: todo-1769037903242-d7aip6nh1
content: ""
status: pending
---
# HoTimeDB 多数据库方言与自动前缀支持计划(更新版)
## 目标
1. **多数据库方言支持**MySQL、PostgreSQL、SQLite 标识符引号自动转换
2. **自动表前缀**:主表、JOIN 表、ON/WHERE 条件中的表名自动添加前缀
3. **完全向后兼容**:用户现有写法无需修改
4. **辅助方法兜底**:边缘情况可用 `T()` / `C()` 精确控制
## 混合策略设计
### 各部分处理方式
| 位置 | 处理方式 | 准确度 | 说明 |
|------|---------|--------|------|
| 主表名 | 自动 | 100% | `Select("order")` 自动处理 |
| JOIN 表名 | 自动 | 100% | `[><]order` 中提取表名处理 |
| ON 条件字符串 | 智能解析 | ~95% | 正则匹配 `table.column` 模式 |
| WHERE 条件 Map | 自动 | 100% | Map 的 key 是结构化的 |
| SELECT 字段 | 智能解析 | ~95% | 同 ON 条件 |
### 辅助方法(兜底)
```go
db.T("order") // 返回 "`app_order`" (MySQL) 或 "\"app_order\"" (PG)
db.C("order", "name") // 返回 "`app_order`.`name`"
db.C("order.name") // 同上,支持点号格式
```
## 实现步骤
### 第1步:扩展 Dialect 接口([db/dialect.go](db/dialect.go)
添加新方法到 `Dialect` 接口:
```go
// QuoteIdentifier 处理单个标识符(去除已有引号,添加正确引号)
QuoteIdentifier(name string) string
// QuoteChar 返回引号字符
QuoteChar() string
```
三种方言实现:
- MySQL: 反引号 `` ` ``
- PostgreSQL/SQLite: 双引号 `"`
### 第2步:添加标识符处理器([db/identifier.go](db/identifier.go) 新文件)
```go
type IdentifierProcessor struct {
dialect Dialect
prefix string
}
// ProcessTableName 处理表名(添加前缀+引号)
// "order" → "`app_order`"
func (p *IdentifierProcessor) ProcessTableName(name string) string
// ProcessColumn 处理 table.column 格式
// "order.name" → "`app_order`.`name`"
// "`order`.name" → "`app_order`.`name`"
func (p *IdentifierProcessor) ProcessColumn(name string) string
// ProcessConditionString 智能解析条件字符串
// "user.id = order.user_id" → "`app_user`.`id` = `app_order`.`user_id`"
func (p *IdentifierProcessor) ProcessConditionString(condition string) string
// ProcessFieldList 处理字段列表字符串
// "order.id, user.name AS uname" → "`app_order`.`id`, `app_user`.`name` AS uname"
func (p *IdentifierProcessor) ProcessFieldList(fields string) string
```
**智能解析正则**
```go
// 匹配 table.column 模式,排除已有引号、函数调用等
// 模式: \b([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)\b
// 排除: `table`.column, "table".column, FUNC(), 123.456
```
### 第3步:在 HoTimeDB 中集成([db/db.go](db/db.go)
```go
// processor 缓存
var processorOnce sync.Once
var processor *IdentifierProcessor
// GetProcessor 获取标识符处理器
func (that *HoTimeDB) GetProcessor() *IdentifierProcessor
// T 辅助方法:获取带前缀和引号的表名
func (that *HoTimeDB) T(table string) string
// C 辅助方法:获取带前缀和引号的 table.column
func (that *HoTimeDB) C(args ...string) string
```
### 第4步:修改 CRUD 方法([db/crud.go](db/crud.go)
**Select 方法改动**
```go
// L112-116 原代码
if !strings.Contains(table, ".") && !strings.Contains(table, " AS ") {
query += " FROM `" + that.Prefix + table + "` "
} else {
query += " FROM " + that.Prefix + table + " "
}
// 改为
query += " FROM " + that.GetProcessor().ProcessTableName(table) + " "
// 字段列表处理(L90-107
// 如果是字符串,调用 ProcessFieldList 处理
```
**buildJoin 方法改动**L156-222):
```go
// 原代码 L186-190
table := Substr(k, 3, len(k)-3)
if !strings.Contains(table, " ") {
table = "`" + table + "`"
}
query += " LEFT JOIN " + table + " ON " + v.(string) + " "
// 改为
table := Substr(k, 3, len(k)-3)
table = that.GetProcessor().ProcessTableName(table)
onCondition := that.GetProcessor().ProcessConditionString(v.(string))
query += " LEFT JOIN " + table + " ON " + onCondition + " "
```
**Insert/Inserts/Update/Delete** 同样修改表名和字段名处理。
### 第5步:修改 WHERE 条件处理([db/where.go](db/where.go)
**varCond 方法改动**(多处):
```go
// 原代码(多处出现)
if !strings.Contains(k, ".") {
k = "`" + k + "`"
}
// 改为
k = that.GetProcessor().ProcessColumn(k)
```
需要修改的函数:
- `varCond` (L205-338)
- `handleDefaultCondition` (L340-368)
- `handlePlainField` (L370-400)
### 第6步:修改链式构建器([db/builder.go](db/builder.go)
**LeftJoin 等方法需要传递处理器**
由于 builder 持有 HoTimeDB 引用,可以直接使用:
```go
func (that *HotimeDBBuilder) LeftJoin(table, joinStr string) *HotimeDBBuilder {
// 不在这里处理,让 buildJoin 统一处理
that.Join(Map{"[>]" + table: joinStr})
return that
}
```
JOIN 的实际处理在 `crud.go``buildJoin` 中完成。
## 智能解析的边界处理
### 会自动处理的情况
- `user.id = order.user_id` → 正确处理
- `user.id=order.user_id` → 正确处理(无空格)
- `` `user`.id = order.user_id `` → 正确处理(混合格式)
- `user.id = order.user_id AND order.status = 1` → 正确处理
### 需要辅助方法的边缘情况
- 子查询中的表名
- 复杂 CASE WHEN 表达式
- 动态拼接的 SQL 片段
## 文件清单
| 文件 | 操作 | 说明 |
|------|------|------|
| [db/dialect.go](db/dialect.go) | 修改 | 扩展 Dialect 接口 |
| [db/identifier.go](db/identifier.go) | 新增 | IdentifierProcessor 实现 |
| [db/db.go](db/db.go) | 修改 | 集成处理器,添加 T()/C() 方法 |
| [db/crud.go](db/crud.go) | 修改 | 修改所有 CRUD 方法 |
| [db/where.go](db/where.go) | 修改 | 修改条件处理逻辑 |
| [db/builder.go](db/builder.go) | 检查 | 可能无需修改(buildJoin 统一处理)|
## 测试用例
1. **多数据库切换**MySQL → PostgreSQL → SQLite
2. **前缀场景**:有前缀 vs 无前缀
3. **复杂 JOIN**:多表 JOIN + 复杂 ON 条件
4. **混合写法**`order.name` + `` `user`.id `` 混用
5. **辅助方法**`T()``C()` 正确性
@@ -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,144 @@
---
name: 缓存数据库表重构
overview: 重构数据库缓存模块,将 cached 表替换为符合设计规范的 hotime_cache 表,支持 MySQL/SQLite/PostgreSQL,修复并发问题,支持可配置的历史记录功能和自动迁移。
todos:
- id: update-cache-db
content: 重构 cache_db.go:新表、UPSERT、历史记录、自动迁移、问题修复
status: completed
- id: update-cache-go
content: 修改 cache.go:添加 HistorySet 配置传递
status: completed
- id: update-makecode
content: 修改 makecode.go:跳过新表名
status: completed
---
# 缓存数据库表重构计划
## 设计概要
将原 `cached` 表替换为 `hotime_cache` 表,遵循数据库设计规范,支持:
- 多数据库:MySQL、SQLite、PostgreSQL
- 可配置的历史记录功能(仅日志,无读取接口)
- 自动迁移旧 cached 表数据
## 文件改动清单
| 文件 | 改动 | 状态 |
|------|------|------|
| [cache/cache_db.go](cache/cache_db.go) | 完全重构:新表、UPSERT、历史记录、自动迁移 | 待完成 |
| [cache/cache.go](cache/cache.go) | 添加 `HistorySet: db.GetBool("history")` 配置传递 | 待完成 |
| [code/makecode.go](code/makecode.go) | 跳过 hotime_cache 和 hotime_cache_history | 已完成 |
## 表结构设计
### 主表 `hotime_cache`
```sql
-- MySQL
CREATE TABLE `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' COMMENT '状态:0-正常,1-异常,2-隐藏',
`create_time` datetime DEFAULT NULL COMMENT '创建日期',
`modify_time` datetime DEFAULT NULL COMMENT '变更时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_key` (`key`),
KEY `idx_end_time` (`end_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='缓存管理';
-- SQLite
CREATE TABLE "hotime_cache" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"key" TEXT NOT NULL UNIQUE,
"value" TEXT,
"end_time" TEXT,
"state" INTEGER DEFAULT 0,
"create_time" TEXT,
"modify_time" TEXT
);
-- PostgreSQL
CREATE TABLE "hotime_cache" (
"id" SERIAL PRIMARY KEY,
"key" VARCHAR(64) NOT NULL UNIQUE,
"value" TEXT,
"end_time" TIMESTAMP,
"state" INTEGER DEFAULT 0,
"create_time" TIMESTAMP,
"modify_time" TIMESTAMP
);
CREATE INDEX "idx_hotime_cache_end_time" ON "hotime_cache" ("end_time");
```
### 历史表 `hotime_cache_history`(配置开启时创建,创建后永不自动删除)
与主表结构相同,但 `id` 改为 `hotime_cache_id` 作为外键关联。
## 问题修复清单
| 问题 | 原状态 | 修复方案 |
|------|--------|----------|
| key 无索引 | 全表扫描 | 唯一索引 uk_key |
| 并发竞态 | Update+Insert 可能重复 | 使用 UPSERT 语法 |
| 时间字段混乱 | time(纳秒) + endtime(秒) | 统一 datetime 格式 |
| value 长度限制 | varchar(2000) | TEXT 类型 |
| TimeOut=0 立即过期 | 无默认值 | 默认 24 小时 |
| get 时删除过期数据 | 每次写操作 | 惰性删除,只返回 nil |
| 旧表 key 重复 | 无约束 | 迁移时取最后一条 |
| value 包装冗余 | `{"data": value}` | 直接存储 |
## 主要代码改动点
### cache_db.go 改动
1. 新增常量:表名、默认过期时间 24 小时
2. 结构体添加 `HistorySet bool`
3. 使用 common 包 `Time2Str(time.Now())` 格式化时间
4. `initDbTable()`: 支持三种数据库、自动迁移、创建历史表
5. `migrateFromCached()`: 去重迁移(取最后一条)、删除旧表
6. `writeHistory()`: 查询数据写入历史表(仅日志)
7. `set()`: 使用 UPSERT、调用 writeHistory
8. `get()`: 惰性删除
9. `Cache()`: TimeOut=0 时用默认值
### cache.go 改动
第 268 行添加:`HistorySet: db.GetBool("history")`
## 配置示例
```json
{
"cache": {
"db": {
"db": true,
"session": true,
"timeout": 72000,
"history": false
}
}
}
```
## 历史记录逻辑
- 新增/修改后:查询完整数据,id 改为 hotime_cache_id,插入历史表
- 删除:不记录历史
- 历史表仅日志记录,无读取接口,人工在数据库操作
@@ -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\your-app\docs\Testing_API测试框架.md) 第 474、494 行:仅出现在 API 表格中,一句话描述
- [API 速查表](d:\work\your-app\docs\Testing_API测试框架.md) 第 662-668 行:只有一个简短示例(`sign = MD5(...)`)
- [SKILL.md](C:\Users\92597.cursor\skills\hotime-tdd-testing\SKILL.md) 第 195-197 行:一行示例
**缺失**:"第二步:编写测试用例"(第 66-149 行)的完整订单示例中完全没有出现 `Note`,而这是新手学习的第一个入口。
## 需要修改的内容
### 1. 文档:第二步示例中加入 Note 使用
在 [Testing_API测试框架.md](d:\work\your-app\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,103 @@
---
name: 规范文档创建
overview: 创建两个规范文档:一个数据库设计规范文档,一个管理后台配置规范文档(包含admin.json和rule.json的配置说明),并在README.md中添加链接。
todos:
- id: create-db-doc
content: 创建 docs/DatabaseDesign_数据库设计规范.md
status: pending
- id: create-admin-doc
content: 创建 docs/AdminConfig_管理后台配置规范.md
status: pending
- id: update-readme
content: 在 README.md 文档表格中添加两个新文档链接
status: pending
dependencies:
- create-db-doc
- create-admin-doc
---
# 创建规范文档
## 任务概述
`D:\work\hotimev1.5\docs` 目录下创建两个规范文档,并更新 README.md 添加链接。
---
## 文档一:数据库设计规范
**文件**: [docs/DatabaseDesign_数据库设计规范.md](docs/DatabaseDesign_数据库设计规范.md)
### 内容结构
| 章节 | 内容 |
|------|------|
| 表命名规则 | 不加前缀、可用简称、关联表命名(主表_关联表) |
| 字段命名规则 | 主键id、外键表名_id、全局唯一性要求、层级字段(parent_id/parent_ids/level |
| 注释规则 | select类型格式(`状态:0-正常,1-异常`)、时间用datetime |
| 必有字段 | state、create_time、modify_time |
| 示例 | 完整建表SQL示例 |
---
## 文档二:管理后台配置规范
**文件**: [docs/AdminConfig_管理后台配置规范.md](docs/AdminConfig_管理后台配置规范.md)
### 内容结构
| 章节 | 内容 |
|------|------|
| admin.json 配置 | |
| - flow配置 | 数据流控制,定义表间权限关系(sql条件、stop标志) |
| - labelConfig | 操作按钮标签(show/add/delete/edit/info/download |
| - label | 菜单/表的显示名称 |
| - menus配置 | 菜单结构(嵌套menus、icon、table、name、auth |
| - auth配置 | 权限数组(show/add/delete/edit/info/download |
| - icon配置 | 菜单图标(如Setting |
| - table/name配置 | table指定数据表,name用于分组标识 |
| - stop配置 | 不允许用户修改自身关联数据的表 |
| rule.json 配置 | |
| - 字段默认权限 | add/edit/info/list/must/strict/type 各字段含义 |
| - 内置字段规则 | id、parent_id、create_time、modify_time、password等 |
| - type类型说明 | select/time/image/file/password/textArea/auth/form等 |
---
## 更新 README.md
在文档表格中添加两个新链接:
```markdown
| [数据库设计规范](docs/DatabaseDesign_数据库设计规范.md) | 表命名、字段命名、注释规则、必有字段 |
| [管理后台配置规范](docs/AdminConfig_管理后台配置规范.md) | admin.json、rule.json 配置说明 |
```
---
## 关键参考文件
- [`code/config.go`](code/config.go): RuleConfig 默认规则定义
- [`code/makecode.go`](code/makecode.go): 外键自动关联逻辑
- [`example/config/admin.json`](example/config/admin.json): 完整配置示例
- [`example/config/rule.json`](example/config/rule.json): 字段规则示例
@@ -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 源码
- 不涉及其他文档
@@ -0,0 +1,168 @@
---
name: 集成请求参数获取方法
overview: 在 context.go 中为 Context 结构体添加五个请求参数获取方法:ReqData(统一获取)、ReqDataParamsURL参数)、ReqDataJsonJSON Body)、ReqDataForm(表单数据)、ReqFile(获取上传文件)。
todos:
- id: add-imports
content: 在 context.go 中添加 bytes、io、mime/multipart 包的导入
status: completed
- id: impl-params
content: 实现 ReqParam/ReqParams 方法(获取 URL 参数,返回 *Obj)
status: completed
dependencies:
- add-imports
- id: impl-form
content: 实现 ReqForm/ReqForms 方法(获取表单数据,返回 *Obj)
status: completed
dependencies:
- add-imports
- id: impl-json
content: 实现 ReqJson/ReqJsons 方法(获取 JSON Body,返回 *Obj
status: completed
dependencies:
- add-imports
- id: impl-file
content: 实现 ReqFile/ReqFiles 方法(获取上传文件)
status: completed
dependencies:
- add-imports
- id: impl-reqdata
content: 实现 ReqData/ReqDatas 方法(统一获取,返回 *Obj)
status: completed
dependencies:
- impl-params
- impl-form
- impl-json
---
# 集成请求参数获取方法
## 实现位置
在 [`context.go`](context.go) 中添加请求参数获取方法,**风格与 `Session("key")` 保持一致,返回 `*Obj` 支持链式调用**。
## 新增方法
### 1. ReqParam - 获取 URL 查询参数(返回 *Obj)
```go
// 获取单个参数,支持链式调用
func (that *Context) ReqParam(key string) *Obj {
// that.ReqParam("id").ToStr()
// that.ReqParam("id").ToInt()
}
// 获取所有 URL 参数
func (that *Context) ReqParams() Map
```
### 2. ReqForm - 获取表单数据(返回 *Obj)
```go
// 获取单个表单字段
func (that *Context) ReqForm(key string) *Obj {
// that.ReqForm("name").ToStr()
}
// 获取所有表单数据
func (that *Context) ReqForms() Map
```
### 3. ReqJson - 获取 JSON Body(返回 *Obj
```go
// 获取 JSON 中的单个字段
func (that *Context) ReqJson(key string) *Obj {
// that.ReqJson("data").ToMap()
// that.ReqJson("count").ToInt()
}
// 获取完整 JSON Body
func (that *Context) ReqJsons() Map
```
### 4. ReqFile - 获取上传文件
```go
func (that *Context) ReqFile(name string) (multipart.File, *multipart.FileHeader, error)
func (that *Context) ReqFiles(name string) ([]*multipart.FileHeader, error)
```
### 5. ReqData - 统一获取参数(返回 *Obj)
```go
// 统一获取(JSON > Form > URL),支持链式调用
func (that *Context) ReqData(key string) *Obj {
// that.ReqData("id").ToStr()
}
// 获取所有合并后的参数
func (that *Context) ReqDatas() Map
```
## 需要的导入
```go
import (
"bytes"
"io"
"mime/multipart"
)
```
## 关键实现细节
1. **Body 只能读取一次的问题**:读取 Body 后需要用 `io.NopCloser(bytes.NewBuffer(body))` 恢复,以便后续代码(如其他中间件)还能再次读取
2. **废弃 API 替换**:使用 `io.ReadAll` 替代已废弃的 `ioutil.ReadAll`
3. **多值参数处理**:当同一参数有多个值时(如 `?id=1&id=2`),存储为 `Slice`;单值则直接存储字符串
## 使用示例
```go
appIns.Run(Router{
"app": {
"user": {
"info": func(that *Context) {
// 链式调用获取单个参数(类似 Session 风格)
id := that.ReqData("id").ToInt() // 统一获取
name := that.ReqParam("name").ToStr() // URL 参数
age := that.ReqForm("age").ToCeilInt() // 表单参数
data := that.ReqJson("profile").ToMap() // JSON 字段
// 获取所有参数(返回 Map
allParams := that.ReqDatas() // 合并后的所有参数
urlParams := that.ReqParams() // 所有 URL 参数
formData := that.ReqForms() // 所有表单数据
jsonBody := that.ReqJsons() // 完整 JSON Body
that.Display(0, Map{"id": id, "name": name})
},
"upload": func(that *Context) {
// 获取单个上传文件
file, header, err := that.ReqFile("avatar")
if err == nil {
defer file.Close()
// header.Filename - 文件名
// header.Size - 文件大小
}
// 获取多个同名上传文件
files, err := that.ReqFiles("images")
},
},
},
})
```
## 风格对比
| 旧方式(需要类型断言) | 新方式(链式调用) |
|------------------------|-------------------|
| `req["id"].(string) `| `that.ReqData("id").ToStr()` |
| `ObjToInt(req["id"]) `| `that.ReqData("id").ToInt()` |
| 需要手动处理 nil | `*Obj` 自动处理空值 |
+32
View File
@@ -0,0 +1,32 @@
---
description: HoTime Doc-Driven + TDD 总门禁
alwaysApply: true
---
# HoTime 工作流
**不可跳过。** Doc-Driven 与 TDD 缺一不可。日常五步:①定位 → ②修改 → ③补/改行为测试并针对性跑至绿 → ④边界 → ⑤回写文档。未测绿或未回写 = **未完成**。中文回复。
## 定位
- 先锁约 **3~4 个文件**(文档 1~2 + 源码 1~2);跨模块扩到合计约 **4~6**,禁止散改全仓。本次新增/修改的**测试文件计入锁定**。先读相关 docs 再改。口径不清 **先问**。
- 入口:`docs/README.md`、仓根 `README.md`;测试细则 → `docs/Testing_API测试框架.md` / skill `hotime-tdd-testing`。
## 改与测
- 改接口、ctr、业务逻辑或测试框架:对应 `*_test` **无则补、有则改**,跑通至绿;**禁**只改实现不补测;**禁全量**;编译不算测完。
- 样板(`example` 根):`go test ./app/ -count=1 -run 'TestApi/app/<ctr>/<action>'`。可收窄到子用例;**改哪测哪**。用户要求全量或以编译代替行为测时,**明确拒绝**并仍按本条。
## 文档
- **有旧改旧**;无篇目且确有缺口才新建。禁不查就建、禁同一入口重复建档。旧文只补受影响节;只写最终态;对照源码,禁臆测。
- 路径用相对 Markdown 链接;**禁** `D:\`、`file:///`、`~/`。新建须回写 `docs/README.md`。
## 回复
- 先结论;摘要 + 文档路径 + **改动定位(文件·位置·原因)**;禁大段复述 docs。
- 凡改代码或报完成:末尾必须一行 `【门禁】锁定:… | 补测并跑绿:是/否 | 文档回写:是/否/无 | 新建or旧文:…`;缺一不可。闲聊不要求。
## 批量补文档(仅整包)
一文一题;最少 2 轮自检。日常小改不适用。
+23 -1
View File
@@ -1,4 +1,26 @@
/.idea/*
.idea
/example/config/app.json
/example/tpt/demo/
*.exe
*.test
*.out
/cover
/coverage.out
/coverage.html
/example/config
/config/
/tpt/
example/tpt/swagger/
**/swagger/
/.cursor/*.log
*.sql
*.py
*.log
logs/
# Cursor / agent debug NDJSON leftovers
debug-*.log
**/debug-*.log
# macOS
.DS_Store
+97 -5
View File
@@ -1,6 +1,98 @@
# hotime
golang web服务框架
支持数据库dbmysql、sqlite3
支持缓存cacheredismemory,数据库
自带工具类,上下文,以及session等功能
# HoTime
**高性能 Go Web 服务框架**
一个"小而全"的 Go Web 框架,内置 ORM、三级缓存、Session 管理,让你专注于业务逻辑。
## 核心特性
- **高性能** - 单机 10万+ QPS,支持百万级并发用户
- **内置 ORM** - 类 Medoo 语法,链式查询,支持 MySQL/SQLite/PostgreSQL
- **三级缓存** - Memory > Redis > DB,自动穿透与回填
- **Session 管理** - 内置会话管理,支持多种存储后端
- **代码生成** - 根据数据库表自动生成 CRUD 接口
- **API 测试** - 内置接口测试框架,链式 API、事务自动回滚、覆盖率报告、交互式调试控制台
- **优雅停机** - 跨平台(Windows/Linux)支持,停机时自动拒绝新请求并等待在途请求完成,配合 nginx 实现零停机滚动重启
- **结构化日志 / Seq 集成** - zerolog 驱动,异步 channel 队列推送,零阻塞接入 Seq 集中搜索,自动捕获 fmt.Println 等全量输出,支持单机多进程实例区分
- **开箱即用** - 微信支付/公众号/小程序、阿里云、腾讯云等 SDK 内置
## 文档
完整阅读路径见 [docs/README.md](docs/README.md)。
| 文档 | 说明 |
|------|------|
| [快速上手](docs/QuickStart_快速上手.md) | 5 分钟入门:安装配置、路由、中间件 |
| [HoTimeDB 使用说明](docs/HoTimeDB_使用说明.md) | 完整数据库 ORM 教程 |
| [HoTimeDB API 参考](docs/HoTimeDB_API参考.md) | 数据库 API 速查手册 |
| [Common 工具类](docs/Common_工具类.md) | Map/Slice/Obj 类型、类型转换、工具函数 |
| [数据库设计规范](docs/DatabaseDesign_数据库设计规范.md) | 表命名、字段命名、COMMENT、必有字段(CodeGen 前置) |
| [代码生成](docs/CodeGen_代码生成.md) | 自动 CRUD 生成 + codeConfig / rule 配置 |
| [API 测试框架](docs/Testing_API测试框架.md) | 接口测试、事务隔离、覆盖率、调试控制台 |
| [优雅停机](docs/GracefulShutdown_优雅停机.md) | 跨平台优雅停机、nginx、滚动重启 |
| [Seq 日志集成](docs/Seq_日志集成.md) | Seq 接入、异步队列、多实例区分 |
| [改进规划](docs/ROADMAP_改进规划.md) | 待改进项与版本迭代规划 |
## 安装
```bash
go get code.hoteas.com/golang/hotime
```
## 性能
| 并发数 | QPS | 成功率 | 平均延迟 |
|--------|-----|--------|----------|
| 500 | 99,960 | 100% | 5.0ms |
| **1000** | **102,489** | **100%** | **9.7ms** |
| 2000 | 75,801 | 99.99% | 26.2ms |
> 测试环境:24 核 CPUWindows 10Go 1.19.3
### 并发用户估算
| 使用场景 | 请求频率 | 可支持用户数 |
|----------|----------|--------------|
| 高频交互 | 1次/秒 | ~10万 |
| 活跃用户 | 1次/5秒 | ~50万 |
| 普通浏览 | 1次/10秒 | ~100万 |
## 框架对比
| 特性 | HoTime | Gin | Echo | Fiber |
|------|--------|-----|------|-------|
| 性能 | 100K QPS | 70K QPS | 70K QPS | 100K QPS |
| 内置ORM | ✅ | ❌ | ❌ | ❌ |
| 内置缓存 | ✅ 三级缓存 | ❌ | ❌ | ❌ |
| Session | ✅ 内置 | ❌ 需插件 | ❌ 需插件 | ❌ 需插件 |
| 代码生成 | ✅ | ❌ | ❌ | ❌ |
| API 测试框架 | ✅ 内置 | ❌ 需插件 | ❌ 需插件 | ❌ 需插件 |
| 优雅停机 | ✅ 内置跨平台 | ❌ 需自行实现 | ❌ 需自行实现 | ✅ 内置 |
| 微信/支付集成 | ✅ 内置 | ❌ | ❌ | ❌ |
## 适用场景
| 场景 | 推荐度 | 说明 |
|------|--------|------|
| 中小型后台系统 | ⭐⭐⭐⭐⭐ | 完美适配,开发效率最高 |
| 微信小程序后端 | ⭐⭐⭐⭐⭐ | 内置微信 SDK |
| 快速原型开发 | ⭐⭐⭐⭐⭐ | 代码生成 + 全功能集成 |
| 高并发 API 服务 | ⭐⭐⭐⭐ | 性能足够 |
| 大型微服务 | ⭐⭐⭐ | 建议用 Gin/Echo |
## 扩展功能
- 微信支付/公众号/小程序 - `dri/wechat/`
- 阿里云服务 - `dri/aliyun/`
- 腾讯云服务 - `dri/tencent/`
- 文件上传下载 - `dri/upload/`, `dri/download/`
- MongoDB - `dri/mongodb/`
- RSA 加解密 - `dri/rsa/`
## License
Apache License 2.0
---
**HoTime** - 让 Go Web 开发更简单、更高效
+449 -120
View File
@@ -1,47 +1,222 @@
package hotime
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"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"
"github.com/rs/zerolog"
logrus "github.com/sirupsen/logrus"
)
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("服务已安全关闭")
}
// 冲刷 Seq 队列,避免停机前后关键日志丢失
that.Log.CloseSeq()
if that.WebConnectLog != nil {
that.WebConnectLog.CloseSeq()
}
os.Exit(0)
})
}
// isLoopbackIP 判断是否为回环地址(空串视为不可用)
func isLoopbackIP(ip string) bool {
ip = strings.TrimSpace(ip)
if ip == "" {
return true
}
parsed := net.ParseIP(ip)
if parsed == nil {
return false
}
return parsed.IsLoopback()
}
// remoteHost 从 RemoteAddr 取出 host(去掉端口)
func remoteHost(req *http.Request) string {
if req == nil || req.RemoteAddr == "" {
return ""
}
host, _, err := net.SplitHostPort(req.RemoteAddr)
if err == nil && host != "" {
return host
}
return req.RemoteAddr
}
// resolveClientIP 多源识别客户端 IP。
// bestEO-Connecting-IP → XFF 第一个非回环 → 非回环 X-Real-IP → RemoteAddr
// XFF 先于 X-Real:反代误把 CDN 节点写进 X-Real-IP 时仍能落到客户端)
// chain:各源头 IP 按首次出现顺序逗号拼接,同一 IP 只保留一次
func resolveClientIP(req *http.Request) (best string, chain string) {
if req == nil {
return "", ""
}
eo := strings.TrimSpace(req.Header.Get("EO-Connecting-IP"))
xri := strings.TrimSpace(req.Header.Get("X-Real-IP"))
xffRaw := req.Header.Get("X-Forwarded-For")
ra := remoteHost(req)
var xffParts []string
if xffRaw != "" {
for _, p := range strings.Split(xffRaw, ",") {
if p = strings.TrimSpace(p); p != "" {
xffParts = append(xffParts, p)
}
}
}
seen := make(map[string]struct{}, 4)
var ordered []string
add := func(ip string) {
ip = strings.TrimSpace(ip)
if ip == "" {
return
}
if _, ok := seen[ip]; ok {
return
}
seen[ip] = struct{}{}
ordered = append(ordered, ip)
}
add(eo)
add(xri)
for _, p := range xffParts {
add(p)
}
add(ra)
chain = strings.Join(ordered, ",")
if eo != "" && !isLoopbackIP(eo) {
return eo, chain
}
for _, p := range xffParts {
if !isLoopbackIP(p) {
return p, chain
}
}
if xri != "" && !isLoopbackIP(xri) {
return xri, chain
}
if ra != "" {
return ra, chain
}
return "", chain
}
// clientIP 取客户端真实 IP(见 resolveClientIP
func clientIP(req *http.Request) string {
ip, _ := resolveClientIP(req)
return ip
}
// truncateUA 按 rune 安全截断字符串到 max 长度,避免切坏多字节字符导致 JSON 转义问题
func truncateUA(s string, max int) string {
r := []rune(s)
if len(r) <= max {
return s
}
return string(r[:max])
}
// shortID 生成 n 字节随机 hexn=6 → 12 位)
func shortID(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return Md5(strconv.Itoa(Rand(10)))[:n*2]
}
return hex.EncodeToString(b)
}
// Run 启动实例
func (that *Application) Run(router Router) {
//如果没有设置配置自动生成配置
@@ -68,18 +243,20 @@ func (that *Application) Run(router Router) {
if that.Router == nil {
that.Router = Router{}
}
for k, v := range router {
for k, _ := range router {
v := router[k]
if that.Router[k] == nil {
that.Router[k] = v
}
//直达接口层复用
for k1, v1 := range v {
for k1, _ := range v {
v1 := v[k1]
if that.Router[k][k1] == nil {
that.Router[k][k1] = v1
}
for k2, v2 := range v1 {
for k2, _ := range v1 {
v2 := v1[k2]
that.Router[k][k1][k2] = v2
}
}
@@ -125,8 +302,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.EmitRecoveredPanic(err)
that.Run(router)
}
@@ -146,8 +326,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
}
}()
}
@@ -160,33 +342,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 {
@@ -199,19 +402,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]
}
@@ -222,55 +421,69 @@ 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 的输出
stdoutW := redirectStdout(that.Log)
bridgeThirdPartyStdout(stdoutW)
// 重定向 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)
// 访问日志量大,挂 Seq 后控制台仍保持 Warn+(业务 Logger 已放宽到 Info+
that.WebConnectLog.SetConsoleMinLevel(zerolog.WarnLevel)
}
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则停止传输
@@ -283,7 +496,7 @@ func (that *Application) SetConnectListener(lis func(that *Context) (isFinished
//
//}
//序列化链接
// 序列化链接
func (that *Application) urlSer(url string) (string, []string) {
q := strings.Index(url, "?")
if q == -1 {
@@ -329,6 +542,9 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) {
if len(token) == 32 {
sessionId = token
//没有token,则查阅session
if cookie == nil || cookie.Value != sessionId {
needSetCookie = sessionId
}
} else if err == nil && cookie.Value != "" {
sessionId = cookie.Value
//session也没有则判断是否创建cookie
@@ -340,10 +556,22 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) {
if err != nil {
unescapeUrl = req.RequestURI
}
// 请求级追踪:sid=sessionId 前 12 位(脱敏),request_id 回写响应头
requestId := shortID(6)
sid := sessionId
if len(sid) > 12 {
sid = sid[:12]
}
w.Header().Set("X-Request-Id", requestId)
reqLog := that.Log.WithFields("sid", sid, "request_id", requestId)
dbCopy := that.Db
dbCopy.Log = reqLog
//访问实例
context := Context{SessionIns: SessionIns{SessionId: sessionId, HoTimeCache: that.HoTimeCache},
Resp: w, Req: req, Application: that, RouterString: s, Config: that.Config, Db: &that.Db,
HandlerStr: unescapeUrl}
Resp: w, Req: req, Application: that, RouterString: s, Config: that.Config, Db: &dbCopy,
HandlerStr: unescapeUrl, Logger: reqLog}
//header默认设置
header := w.Header()
header.Set("Content-Type", "text/html; charset=utf-8")
@@ -357,23 +585,24 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) {
defer func() {
//是否展示日志
if that.WebConnectLog != nil {
//负载均衡优化
ipStr := ""
if req.Header.Get("X-Forwarded-For") != "" {
ipStr = req.Header.Get("X-Forwarded-For")
} else if req.Header.Get("X-Real-IP") != "" {
ipStr = req.Header.Get("X-Real-IP")
ip, ipChain := resolveClientIP(req)
evt := that.WebConnectLog.Info().
Str("ip", ip).
Str("method", context.Req.Method).
Str("sid", sid).
Str("request_id", requestId).
Float64("cost_ms", ObjToFloat64(time.Now().UnixNano()-nowUnixTime.UnixNano())/1000000.00).
Float64("size_kb", ObjToFloat64(context.DataSize)/1000.00)
if ipChain != "" && ipChain != ip {
evt = evt.Str("ip_chain", ipChain)
}
//负载均衡优化
if ipStr == "" {
//RemoteAddr := that.Req.RemoteAddr
ipStr = Substr(context.Req.RemoteAddr, 0, strings.Index(context.Req.RemoteAddr, ":"))
if country := req.Header.Get("EO-Client-IPCountry"); country != "" {
evt = evt.Str("ip_country", country)
}
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)
if ua := req.Header.Get("User-Agent"); ua != "" {
evt = evt.Str("ua", truncateUA(ua, 200))
}
evt.Msg(context.HandlerStr)
}
}()
@@ -406,8 +635,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] == '/' {
@@ -433,17 +662,13 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) {
return
}
//设置header
// 静态资源可缓存;logLevel 只控制日志/SQL,不再改 Cache-Control
delete(header, "Content-Type")
if that.Config.GetInt("mode") == 0 {
header.Set("Cache-Control", "public")
} else {
header.Set("Cache-Control", "no-cache")
}
header.Set("Cache-Control", "public")
t := strings.LastIndex(path, ".")
if t != -1 {
tt := path[t:]
tt := strings.ToLower(path[t:])
if MimeMaps[tt] != "" {
@@ -462,6 +687,7 @@ func (that *Application) crossDomain(context *Context, sessionId string) {
if context.Config.GetString("crossDomain") == "" {
if sessionId != "" {
http.SetCookie(context.Resp, &http.Cookie{Name: that.Config.GetString("sessionName"), Value: sessionId, Path: "/"})
//context.Resp.Header().Set("Set-Cookie", that.Config.GetString("sessionName")+"="+sessionId+"; Path=/; SameSite=None; Secure")
}
return
@@ -491,7 +717,7 @@ func (that *Application) crossDomain(context *Context, sessionId string) {
header.Set("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE")
header.Set("Access-Control-Allow-Credentials", "true")
header.Set("Access-Control-Expose-Headers", "*")
header.Set("Access-Control-Allow-Headers", "X-Requested-With,Content-Type,Access-Token")
header.Set("Access-Control-Allow-Headers", "X-Requested-With,Content-Type,Access-Token,Authorization,Cookie,Set-Cookie")
if sessionId != "" {
//跨域允许需要设置cookie的允许跨域https才有效果
@@ -506,7 +732,8 @@ func (that *Application) crossDomain(context *Context, sessionId string) {
if (origin != "" && strings.Contains(origin, remoteHost)) || strings.Contains(refer, remoteHost) {
if sessionId != "" {
http.SetCookie(context.Resp, &http.Cookie{Name: that.Config.GetString("sessionName"), Value: sessionId, Path: "/"})
//http.SetCookie(context.Resp, &http.Cookie{Name: that.Config.GetString("sessionName"), Value: sessionId, Path: "/"})
context.Resp.Header().Set("Set-Cookie", that.Config.GetString("sessionName")+"="+sessionId+"; Path=/; SameSite=None; Secure")
}
return
@@ -537,7 +764,7 @@ func (that *Application) crossDomain(context *Context, sessionId string) {
header.Set("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE")
header.Set("Access-Control-Allow-Credentials", "true")
header.Set("Access-Control-Expose-Headers", "*")
header.Set("Access-Control-Allow-Headers", "X-Requested-With,Content-Type,Access-Token")
header.Set("Access-Control-Allow-Headers", "X-Requested-With,Content-Type,Access-Token,Authorization,Cookie,Set-Cookie")
if sessionId != "" {
//跨域允许需要设置cookie的允许跨域https才有效果
@@ -546,7 +773,51 @@ func (that *Application) crossDomain(context *Context, sessionId string) {
}
//Init 初始化application
// 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。
// 返回管道写端,供 logrus 等第三方库桥接。
func redirectStdout(l *log.Logger) *os.File {
r, w, err := os.Pipe()
if err != nil {
return nil
}
os.Stdout = w
stdlog.SetOutput(w)
go log.CaptureStream(l, zerolog.InfoLevel, "stdout", r)
return w
}
// bridgeThirdPartyStdout 将 import 时钉死原始 stdout 的第三方日志指到捕获管道。
func bridgeThirdPartyStdout(w *os.File) {
if w == nil {
return
}
logrus.SetOutput(w)
}
// redirectStderr 重定向 os.Stderr 到 HoTime Loggerlevel=error, source=stderr)。
func redirectStderr(l *log.Logger) {
r, w, err := os.Pipe()
if err != nil {
l.EmitCaptured(zerolog.WarnLevel, "stderr-capture", fmt.Sprintf("create pipe failed: %v", err))
return
}
os.Stderr = w
go log.CaptureStream(l, zerolog.ErrorLevel, "stderr", r)
}
// Init 初始化application
func Init(config string) *Application {
appIns := Application{}
//手动模式,
@@ -572,7 +843,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)
//接入动态代码层
@@ -582,15 +853,27 @@ func Init(config string) *Application {
//appIns.Router[codeMake.GetString("name")] = TptProject
appIns.Router[codeMake.GetString("name")] = Proj{}
for k2, _ := range TptProject {
appIns.Router[codeMake.GetString("name")][k2] = Ctr{}
for k3, v3 := range TptProject[k2] {
if appIns.Router[codeMake.GetString("name")][k2] == nil {
appIns.Router[codeMake.GetString("name")][k2] = Ctr{}
}
for k3, _ := range TptProject[k2] {
v3 := TptProject[k2][k3]
appIns.Router[codeMake.GetString("name")][k2][k3] = v3
}
}
for k1, _ := range appIns.MakeCodeRouter[codeMake.GetString("name")].TableColumns {
appIns.Router[codeMake.GetString("name")][k1] = appIns.Router[codeMake.GetString("name")]["hotimeCommon"]
if appIns.Router[codeMake.GetString("name")][k1] == nil {
appIns.Router[codeMake.GetString("name")][k1] = Ctr{}
}
for k2, _ := range appIns.Router[codeMake.GetString("name")]["hotimeCommon"] {
//golang毛病
v2 := appIns.Router[codeMake.GetString("name")]["hotimeCommon"][k2]
appIns.Router[codeMake.GetString("name")][k1][k2] = v2
}
}
setMakeCodeListener(codeMake.GetString("name"), &appIns)
@@ -607,12 +890,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) {
@@ -620,45 +907,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"
"@tcp(" + config.GetString("host") + ":" + config.GetString("port") + ")/" + config.GetString("name") + "?charset=utf8mb4"
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"
"@tcp(" + config.GetString("host") + ":" + configSlave.GetString("port") + ")/" + configSlave.GetString("name") + "?charset=utf8mb4"
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
})
}
@@ -692,6 +1000,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) {
@@ -728,6 +1039,16 @@ func setMakeCodeListener(name string, appIns *Application) {
context.Req.Method == "POST" {
return isFinished
}
//分析
if len(context.RouterString) == 3 && context.RouterString[2] == "analyse" &&
context.Req.Method == "GET" {
if context.Router[context.RouterString[0]][context.RouterString[1]]["analyse"] == nil {
return isFinished
}
context.Router[context.RouterString[0]][context.RouterString[1]]["analyse"](context)
}
//查询单条
if len(context.RouterString) == 3 &&
context.Req.Method == "GET" {
@@ -763,3 +1084,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...))
}
+237 -44
View File
@@ -2,13 +2,13 @@ package cache
import (
. "code.hoteas.com/golang/hotime/common"
"errors"
"code.hoteas.com/golang/hotime/log"
)
// HoTimeCache 可配置memorydbredis,默认启用memory,默认优先级为memory>redis>db,memory与数据库缓存设置项一致,
//缓存数据填充会自动反方向反哺,加入memory缓存过期将自动从redis更新,但memory永远不会更新redis,如果是集群建议不要开启memory,配置即启用
// 缓存数据填充会自动反方向反哺,加入memory缓存过期将自动从redis更新,但memory永远不会更新redis,如果是集群建议不要开启memory,配置即启用
type HoTimeCache struct {
*Error
Log *log.Logger
dbCache *CacheDb
redisCache *CacheRedis
memoryCache *CacheMemory
@@ -92,7 +92,7 @@ func (that *HoTimeCache) Db(key string, data ...interface{}) *Obj {
}
}
//redis缓存有
//db缓存有
if that.dbCache != nil && that.dbCache.DbSet {
reData = that.dbCache.Cache(key, data...)
if reData.Data != nil {
@@ -119,7 +119,7 @@ func (that *HoTimeCache) Db(key string, data ...interface{}) *Obj {
if that.redisCache != nil && that.redisCache.DbSet {
reData = that.redisCache.Cache(key, data...)
}
//redis缓存有
//db缓存有
if that.dbCache != nil && that.dbCache.DbSet {
reData = that.dbCache.Cache(key, data...)
}
@@ -132,7 +132,7 @@ func (that *HoTimeCache) Cache(key string, data ...interface{}) *Obj {
//内存缓存有
if that.memoryCache != nil {
reData = that.memoryCache.Cache(key, data...)
if reData != nil {
if reData != nil && reData.Data != nil {
return reData
}
}
@@ -140,8 +140,7 @@ func (that *HoTimeCache) Cache(key string, data ...interface{}) *Obj {
//redis缓存有
if that.redisCache != nil {
reData = that.redisCache.Cache(key, data...)
if reData.Data != nil {
if reData != nil && reData.Data != nil {
if that.memoryCache != nil {
that.memoryCache.Cache(key, reData.Data)
}
@@ -149,10 +148,10 @@ func (that *HoTimeCache) Cache(key string, data ...interface{}) *Obj {
}
}
//redis缓存有
//db缓存有
if that.dbCache != nil {
reData = that.dbCache.Cache(key, data...)
if reData.Data != nil {
if reData != nil && reData.Data != nil {
if that.memoryCache != nil {
that.memoryCache.Cache(key, reData.Data)
}
@@ -174,23 +173,222 @@ func (that *HoTimeCache) Cache(key string, data ...interface{}) *Obj {
if that.redisCache != nil {
reData = that.redisCache.Cache(key, data...)
}
//redis缓存有
//db缓存有
if that.dbCache != nil {
reData = that.dbCache.Cache(key, data...)
}
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,79 +397,74 @@ 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{TimeOut: db.GetCeilInt64("timeout"),
DbSet: db.GetBool("db"), SessionSet: db.GetBool("session"),
Db: hotimeDb}
if err[0] != nil {
that.dbCache.SetError(err[0])
that.dbCache = &CacheDb{
TimeOut: db.GetCeilInt64("timeout"),
DbSet: db.GetBool("db"),
SessionSet: db.GetBool("session"),
HistorySet: db.GetBool("history"),
Mode: db.GetString("mode"),
Db: hotimeDb,
Log: logger,
}
}
}
func (that *HoTimeCache) DisableDbCache() {
that.dbCache = nil
that.redisCache = nil
}
+587 -87
View File
@@ -1,17 +1,29 @@
package cache
import (
. "code.hoteas.com/golang/hotime/common"
"database/sql"
"encoding/json"
"strings"
"time"
. "code.hoteas.com/golang/hotime/common"
"code.hoteas.com/golang/hotime/log"
)
// 表名常量
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
@@ -24,150 +36,638 @@ type CacheDb struct {
TimeOut int64
DbSet bool
SessionSet bool
HistorySet bool // 是否开启历史记录
Mode string // 缓存模式:"new"(默认,只用新表) 或 "compatible"(写新读老)
Db HoTimeDBInterface
*Error
Log *log.Logger
ContextBase
isInit bool
}
func (that *CacheDb) GetError() *Error {
return that.Error
// getTableName 获取带前缀的表名
func (that *CacheDb) getTableName() string {
return that.Db.GetPrefix() + CacheTableName
}
func (that *CacheDb) SetError(err *Error) {
that.Error = err
// getHistoryTableName 获取带前缀的历史表名
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 {
return
}
if that.Db.GetType() == "mysql" {
dbNames := that.Db.Query("SELECT DATABASE()")
if len(dbNames) == 0 {
return
}
dbName := dbNames[0].GetString("DATABASE()")
res := that.Db.Query("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA='" + dbName + "' AND TABLE_NAME='" + that.Db.GetPrefix() + "cached'")
if len(res) != 0 {
that.isInit = true
return
}
_, e := that.Db.Exec("CREATE TABLE `" + that.Db.GetPrefix() + "cached` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `key` varchar(60) DEFAULT NULL, `value` varchar(2000) DEFAULT NULL, `time` bigint(20) DEFAULT NULL, `endtime` bigint(20) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=198740 DEFAULT CHARSET=utf8")
if e.GetError() == nil {
that.isInit = true
}
dbType := that.Db.GetType()
tableName := that.getTableName()
historyTableName := that.getHistoryTableName()
legacyTableName := that.getLegacyTableName()
// 检查并创建主表
if !that.tableExists(tableName) {
that.createMainTable(dbType, tableName)
}
if that.Db.GetType() == "sqlite" {
res := that.Db.Query(`select * from sqlite_master where type = 'table' and name = '` + that.Db.GetPrefix() + `cached'`)
if len(res) != 0 {
that.isInit = true
return
}
_, e := that.Db.Exec(`CREATE TABLE "` + that.Db.GetPrefix() + `cached" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"key" TEXT(60),
"value" TEXT(2000),
"time" integer,
"endtime" integer
);`)
if e.GetError() == nil {
that.isInit = true
// 根据模式处理老表
// new 模式:迁移老表数据到新表(不删除老表,由人工删除)
// compatible 模式:不迁移,老表继续使用
if that.getEffectiveMode() == CacheModeNew {
if that.tableExists(legacyTableName) {
that.migrateFromCached(dbType, legacyTableName, tableName)
}
}
// compatible 模式不做任何处理,老表保留供读取
// 检查并创建历史表(开启历史记录时)
if that.HistorySet && !that.tableExists(historyTableName) {
that.createHistoryTable(dbType, historyTableName)
}
that.isInit = true
}
//获取Cache键只能为string类型
func (that *CacheDb) get(key string) interface{} {
// tableExists 检查表是否存在
func (that *CacheDb) tableExists(tableName string) bool {
dbType := that.Db.GetType()
cached := that.Db.Get("cached", "*", Map{"key": key})
switch dbType {
case "mysql":
dbNames := that.Db.Query("SELECT DATABASE()")
if len(dbNames) == 0 {
return false
}
dbName := dbNames[0].GetString("DATABASE()")
res := that.Db.Query("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA='" + dbName + "' AND TABLE_NAME='" + tableName + "'")
return len(res) != 0
case "sqlite":
res := that.Db.Query(`SELECT name FROM sqlite_master WHERE type='table' AND name='` + tableName + `'`)
return len(res) != 0
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
}
// createMainTable 创建主表
func (that *CacheDb) createMainTable(dbType, tableName string) {
var createSQL string
switch dbType {
case "mysql":
createSQL = "CREATE TABLE `" + tableName + "` (" +
"`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' COMMENT '状态:0-正常,1-异常,2-隐藏'," +
"`create_time` datetime DEFAULT NULL COMMENT '创建日期'," +
"`modify_time` datetime DEFAULT NULL COMMENT '变更时间'," +
"PRIMARY KEY (`id`)," +
"UNIQUE KEY `uk_key` (`key`)," +
"KEY `idx_end_time` (`end_time`)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='缓存管理'"
case "sqlite":
createSQL = `CREATE TABLE "` + tableName + `" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"key" TEXT NOT NULL UNIQUE,
"value" TEXT,
"end_time" TEXT,
"state" INTEGER DEFAULT 0,
"create_time" TEXT,
"modify_time" TEXT
)`
case "postgres":
createSQL = `CREATE TABLE "` + tableName + `" (
"id" SERIAL PRIMARY KEY,
"key" VARCHAR(64) NOT NULL UNIQUE,
"value" TEXT,
"end_time" TIMESTAMP,
"state" INTEGER DEFAULT 0,
"create_time" TIMESTAMP,
"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
}
that.Db.Exec(createSQL)
}
// createHistoryTable 创建历史表
func (that *CacheDb) createHistoryTable(dbType, tableName string) {
var createSQL string
switch dbType {
case "mysql":
createSQL = "CREATE TABLE `" + tableName + "` (" +
"`id` int(11) unsigned NOT NULL AUTO_INCREMENT," +
"`hotime_cache_id` int(11) unsigned DEFAULT NULL COMMENT '缓存ID'," +
"`key` varchar(64) DEFAULT NULL COMMENT '缓存键'," +
"`value` text DEFAULT NULL COMMENT '缓存值'," +
"`end_time` datetime DEFAULT NULL COMMENT '过期时间'," +
"`state` int(2) DEFAULT '0' COMMENT '状态:0-正常,1-异常,2-隐藏'," +
"`create_time` datetime DEFAULT NULL COMMENT '创建日期'," +
"`modify_time` datetime DEFAULT NULL COMMENT '变更时间'," +
"PRIMARY KEY (`id`)," +
"KEY `idx_hotime_cache_id` (`hotime_cache_id`)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='缓存历史'"
case "sqlite":
createSQL = `CREATE TABLE "` + tableName + `" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"hotime_cache_id" INTEGER,
"key" TEXT,
"value" TEXT,
"end_time" TEXT,
"state" INTEGER DEFAULT 0,
"create_time" TEXT,
"modify_time" TEXT
)`
case "postgres":
createSQL = `CREATE TABLE "` + tableName + `" (
"id" SERIAL PRIMARY KEY,
"hotime_cache_id" INTEGER,
"key" VARCHAR(64),
"value" TEXT,
"end_time" TIMESTAMP,
"state" INTEGER 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
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
}
that.Db.Exec(createSQL)
}
// migrateFromCached 从旧 cached 表迁移数据(不删除老表,由人工删除)
func (that *CacheDb) migrateFromCached(dbType, oldTableName, newTableName string) {
var migrateSQL string
switch dbType {
case "mysql":
// 去重迁移:取每个 key 的最后一条记录(id 最大)
// 使用 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 " +
"INNER JOIN (SELECT `key`, MAX(id) as max_id FROM `" + oldTableName + "` GROUP BY `key`) m " +
"ON c.id = m.max_id"
case "sqlite":
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 ` +
`INNER JOIN (SELECT "key", MAX(id) as max_id FROM "` + oldTableName + `" GROUP BY "key") m ` +
`ON c.id = m.max_id`
case "postgres":
migrateSQL = `INSERT INTO "` + newTableName + `" ("key", "value", "end_time", "state", "create_time", "modify_time") ` +
`SELECT c."key", c."value", to_timestamp(c."endtime"), 0, ` +
`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 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")`
}
that.Db.Exec(migrateSQL)
}
// writeHistory 写入历史记录
func (that *CacheDb) writeHistory(key string) {
if !that.HistorySet {
return
}
tableName := that.getTableName()
historyTableName := that.getHistoryTableName()
// 查询当前数据
cached := that.Db.Get(tableName, "*", Map{"key": key})
if cached == nil {
return
}
// 构建历史记录数据
historyData := Map{
"hotime_cache_id": cached.GetInt64("id"),
"key": cached.GetString("key"),
"value": cached.GetString("value"),
"end_time": cached.GetString("end_time"),
"state": cached.GetInt("state"),
"create_time": cached.GetString("create_time"),
"modify_time": cached.GetString("modify_time"),
}
// 插入历史表
that.Db.Insert(historyTableName, historyData)
}
// 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
}
//data:=cacheMap[key];
if cached.GetInt64("endtime") <= time.Now().Unix() {
that.Db.Delete("cached", Map{"id": cached.GetString("id")})
// 检查过期时间(老表使用 unix 时间戳)
endTime := cached.GetInt64("endtime")
nowUnix := time.Now().Unix()
if endTime > 0 && endTime <= nowUnix {
// 已过期,删除该条记录
that.deleteLegacy(key)
return nil
}
data := Map{}
data.JsonToMap(cached.GetString("value"))
return data.Get("data")
}
//key value ,时间为时间戳
func (that *CacheDb) set(key string, value interface{}, tim int64) {
bte, _ := json.Marshal(Map{"data": value})
num := that.Db.Update("cached", Map{"value": string(bte), "time": time.Now().UnixNano(), "endtime": tim}, Map{"key": key})
if num == int64(0) {
that.Db.Insert("cached", Map{"value": string(bte), "time": time.Now().UnixNano(), "endtime": tim, "key": key})
// 解析 value(老表 value 格式可能是 {"data": xxx} 或直接值)
valueStr := cached.GetString("value")
if valueStr == "" {
return nil
}
//随机执行删除命令
if Rand(1000) > 950 {
that.Db.Delete("cached", Map{"endtime[<]": time.Now().Unix()})
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
}
func (that *CacheDb) delete(key string) {
// deleteLegacy 从老表删除缓存(兼容模式使用)
func (that *CacheDb) deleteLegacy(key string) {
legacyTableName := that.getLegacyTableName()
// 检查老表是否存在
if !that.tableExists(legacyTableName) {
return
}
del := strings.Index(key, "*")
//如果通配删除
// 如果通配删除
if del != -1 {
key = Substr(key, 0, del)
that.Db.Delete("cached", Map{"key": key + "%"})
keyPrefix := Substr(key, 0, del)
that.Db.Delete(legacyTableName, Map{"key[~]": keyPrefix + "%"})
} else {
that.Db.Delete("cached", Map{"key": key})
that.Db.Delete(legacyTableName, Map{"key": key})
}
}
func (that *CacheDb) Cache(key string, data ...interface{}) *Obj {
// get 获取缓存
func (that *CacheDb) get(key string) interface{} {
tableName := that.getTableName()
cached := that.Db.Get(tableName, "*", Map{"key": key})
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
}
}
}
}
// 兼容模式:新表没有数据时,回退读取老表
if that.isCompatibleMode() {
return that.getLegacy(key)
}
return nil
}
// set 设置缓存
func (that *CacheDb) set(key string, value interface{}, endTime time.Time) {
// 直接序列化 value,不再包装
bte, _ := json.Marshal(value)
nowTime := Time2Str(time.Now())
endTimeStr := Time2Str(endTime)
dbType := that.Db.GetType()
tableName := that.getTableName()
// 使用 UPSERT 语法解决并发问题
switch dbType {
case "mysql":
upsertSQL := "INSERT INTO `" + tableName + "` (`key`, `value`, `end_time`, `state`, `create_time`, `modify_time`) " +
"VALUES (?, ?, ?, 0, ?, ?) " +
"ON DUPLICATE KEY UPDATE `value`=VALUES(`value`), `end_time`=VALUES(`end_time`), `modify_time`=VALUES(`modify_time`)"
that.Db.Exec(upsertSQL, key, string(bte), endTimeStr, nowTime, nowTime)
case "sqlite":
// SQLite: INSERT OR REPLACE 会删除后插入,所以用 UPSERT 语法
upsertSQL := `INSERT INTO "` + tableName + `" ("key", "value", "end_time", "state", "create_time", "modify_time") ` +
`VALUES (?, ?, ?, 0, ?, ?) ` +
`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 "postgres":
upsertSQL := `INSERT INTO "` + tableName + `" ("key", "value", "end_time", "state", "create_time", "modify_time") ` +
`VALUES ($1, $2, $3, 0, $4, $5) ` +
`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{
"value": string(bte),
"end_time": endTimeStr,
"modify_time": nowTime,
}, Map{"key": key})
if num == 0 {
that.Db.Insert(tableName, Map{
"key": key,
"value": string(bte),
"end_time": endTimeStr,
"state": 0,
"create_time": nowTime,
"modify_time": nowTime,
})
}
}
// 写入历史记录
that.writeHistory(key)
// 兼容模式:写新表后删除老表同 key 记录(加速老数据消亡)
if that.isCompatibleMode() {
that.deleteLegacy(key)
}
// 随机执行删除过期数据命令(5% 概率)
if Rand(1000) > 950 {
nowTimeStr := Time2Str(time.Now())
that.Db.Delete(tableName, Map{"end_time[<]": nowTimeStr})
}
}
// delete 删除缓存
func (that *CacheDb) delete(key string) {
tableName := that.getTableName()
del := strings.Index(key, "*")
// 如果通配删除
if del != -1 {
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 缓存操作入口
// 用法:
// - Cache(key) - 获取缓存
// - Cache(key, value) - 设置缓存(使用默认过期时间)
// - Cache(key, value, timeout) - 设置缓存(指定过期时间,单位:秒)
// - Cache(key, nil) - 删除缓存
func (that *CacheDb) Cache(key string, data ...interface{}) *Obj {
that.initDbTable()
// 获取缓存
if len(data) == 0 {
return &Obj{Data: that.get(key)}
}
tim := time.Now().Unix()
// 删除缓存
if len(data) == 1 && data[0] == nil {
that.delete(key)
return &Obj{Data: nil}
}
// 计算过期时间
var timeout int64
if len(data) == 1 {
if that.TimeOut == 0 {
//that.Time = Config.GetInt64("cacheLongTime")
// 使用配置的 TimeOut,如果为 0 则使用默认值
timeout = that.TimeOut
if timeout == 0 {
timeout = DefaultCacheTimeout
}
tim += that.TimeOut
}
if len(data) == 2 {
that.SetError(nil)
tempt := ObjToInt64(data[1], that.Error)
if tempt > tim {
tim = tempt
} else if that.GetError() == nil {
tim = tim + tempt
} else if len(data) >= 2 {
// 使用指定的超时时间
var err Error
tempTimeout := ObjToInt64(data[1], &err)
if err.GetError() == nil && tempTimeout > 0 {
timeout = tempTimeout
} else {
timeout = that.TimeOut
if timeout == 0 {
timeout = DefaultCacheTimeout
}
}
}
that.set(key, data[0], tim)
endTime := time.Now().Add(time.Duration(timeout) * time.Second)
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})
}
}
}
+104 -119
View File
@@ -2,157 +2,142 @@ package cache
import (
. "code.hoteas.com/golang/hotime/common"
"code.hoteas.com/golang/hotime/log"
"strings"
"sync"
"time"
)
// CacheMemory 基于 sync.Map 的缓存实现
type CacheMemory struct {
TimeOut int64
DbSet bool
SessionSet bool
Map
*Error
ContextBase
mutex *sync.RWMutex
Log *log.Logger
cache sync.Map
}
func (c *CacheMemory) get(key string) (res *Obj) {
func (that *CacheMemory) GetError() *Error {
return that.Error
}
func (that *CacheMemory) SetError(err *Error) {
that.Error = err
}
//获取Cache键只能为string类型
func (that *CacheMemory) get(key string) interface{} {
that.Error.SetError(nil)
if that.Map == nil {
that.Map = Map{}
res = &Obj{}
value, ok := c.cache.Load(key)
if !ok {
return res // 缓存不存在
}
if that.Map[key] == nil {
return nil
}
data := that.Map.Get(key, that.Error).(cacheData)
if that.Error.GetError() != nil {
return nil
}
data := value.(cacheData)
// 检查是否过期
if data.time < time.Now().Unix() {
delete(that.Map, key)
return nil
c.cache.Delete(key) // 删除过期缓存
return res
}
return data.data
res.Data = data.data
return res
}
func (that *CacheMemory) refreshMap() {
func (c *CacheMemory) set(key string, value interface{}, expireAt int64) {
data := cacheData{
data: value,
time: expireAt,
}
c.cache.Store(key, data)
}
func (c *CacheMemory) delete(key string) {
if strings.Contains(key, "*") {
// 通配符删除
prefix := strings.TrimSuffix(key, "*")
c.cache.Range(func(k, v interface{}) bool {
if strings.HasPrefix(k.(string), prefix) {
c.cache.Delete(k)
}
return true
})
} else {
// 精确删除
c.cache.Delete(key)
}
}
func (c *CacheMemory) refreshMap() {
go func() {
that.mutex.Lock()
defer that.mutex.Unlock()
for key, v := range that.Map {
data := v.(cacheData)
if data.time <= time.Now().Unix() {
delete(that.Map, key)
now := time.Now().Unix()
c.cache.Range(func(key, value interface{}) bool {
data := value.(cacheData)
if data.time <= now {
c.cache.Delete(key) // 删除过期缓存
}
}
return true
})
}()
}
func (c *CacheMemory) Cache(key string, data ...interface{}) *Obj {
now := time.Now().Unix()
//key value ,时间为时间戳
func (that *CacheMemory) set(key string, value interface{}, time int64) {
that.Error.SetError(nil)
var data cacheData
if that.Map == nil {
that.Map = Map{}
// 随机触发刷新
if x := RandX(1, 100000); x > 99950 {
c.refreshMap()
}
dd := that.Map[key]
if dd == nil {
data = cacheData{}
} else {
data = dd.(cacheData)
}
data.time = time
data.data = value
that.Map.Put(key, data)
}
func (that *CacheMemory) delete(key string) {
del := strings.Index(key, "*")
//如果通配删除
if del != -1 {
key = Substr(key, 0, del)
for k, _ := range that.Map {
if strings.Index(k, key) != -1 {
delete(that.Map, k)
}
}
} else {
delete(that.Map, key)
}
}
func (that *CacheMemory) Cache(key string, data ...interface{}) *Obj {
x := RandX(1, 100000)
if x > 99950 {
that.refreshMap()
}
if that.mutex == nil {
that.mutex = &sync.RWMutex{}
}
reData := &Obj{Data: nil}
if len(data) == 0 {
that.mutex.RLock()
reData.Data = that.get(key)
that.mutex.RUnlock()
return reData
// 读操作
return c.get(key)
}
tim := time.Now().Unix()
if len(data) == 1 && data[0] == nil {
that.mutex.Lock()
that.delete(key)
that.mutex.Unlock()
return reData
// 删除操作
c.delete(key)
return nil
}
if len(data) == 1 {
tim = tim + that.TimeOut
}
// 写操作
expireAt := now + c.TimeOut
if len(data) == 2 {
that.Error.SetError(nil)
tempt := ObjToInt64(data[1], that.Error)
if tempt > tim {
tim = tempt
} else if that.Error.GetError() == nil {
tim = tim + tempt
if customExpire, ok := data[1].(int64); ok {
if customExpire > now {
expireAt = customExpire
} else {
expireAt = now + customExpire
}
}
}
that.mutex.Lock()
that.set(key, data[0], tim)
that.mutex.Unlock()
return reData
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)
}
}
+192 -80
View File
@@ -2,8 +2,10 @@ package cache
import (
. "code.hoteas.com/golang/hotime/common"
"code.hoteas.com/golang/hotime/log"
"github.com/garyburd/redigo/redis"
"strings"
"sync"
"time"
)
@@ -14,23 +16,20 @@ type CacheRedis struct {
Host string
Pwd string
Port int64
conn redis.Conn
pool *redis.Pool
tag int64
ContextBase
*Error
Log *log.Logger
initOnce sync.Once
}
func (that *CacheRedis) GetError() *Error {
return that.Error
func (that *CacheRedis) logErr(err error) {
if err != nil && that.Log != nil {
that.Log.Error().Err(err).Msg("redis cache error")
}
}
func (that *CacheRedis) SetError(err *Error) {
that.Error = err
}
//唯一标志
// 唯一标志
func (that *CacheRedis) GetTag() int64 {
if that.tag == int64(0) {
@@ -39,83 +38,112 @@ func (that *CacheRedis) GetTag() int64 {
return that.tag
}
func (that *CacheRedis) reCon() bool {
var err error
that.conn, err = redis.Dial("tcp", that.Host+":"+ObjToStr(that.Port))
if err != nil {
that.conn = nil
that.Error.SetError(err)
return false
}
if that.Pwd != "" {
_, err = that.conn.Do("AUTH", that.Pwd)
if err != nil {
that.conn = nil
that.Error.SetError(err)
return false
// initPool 初始化连接池(只执行一次)
func (that *CacheRedis) initPool() {
that.initOnce.Do(func() {
that.pool = &redis.Pool{
MaxIdle: 10, // 最大空闲连接数
MaxActive: 100, // 最大活跃连接数,0表示无限制
IdleTimeout: 5 * time.Minute, // 空闲连接超时时间
Wait: true, // 当连接池耗尽时是否等待
Dial: func() (redis.Conn, error) {
conn, err := redis.Dial("tcp", that.Host+":"+ObjToStr(that.Port),
redis.DialConnectTimeout(5*time.Second),
redis.DialReadTimeout(3*time.Second),
redis.DialWriteTimeout(3*time.Second),
)
if err != nil {
return nil, err
}
if that.Pwd != "" {
if _, err := conn.Do("AUTH", that.Pwd); err != nil {
conn.Close()
return nil, err
}
}
return conn, nil
},
TestOnBorrow: func(c redis.Conn, t time.Time) error {
if time.Since(t) < time.Minute {
return nil
}
_, err := c.Do("PING")
return err
},
}
}
return true
})
}
// getConn 从连接池获取连接
func (that *CacheRedis) getConn() redis.Conn {
that.initPool()
if that.pool == nil {
return nil
}
return that.pool.Get()
}
func (that *CacheRedis) del(key string) {
conn := that.getConn()
if conn == nil {
return
}
defer conn.Close()
del := strings.Index(key, "*")
if del != -1 {
val, err := redis.Strings(that.conn.Do("KEYS", key))
val, err := redis.Strings(conn.Do("KEYS", key))
if err != nil {
that.logErr(err)
return
}
that.conn.Send("MULTI")
for i, _ := range val {
that.conn.Send("DEL", val[i])
if len(val) == 0 {
return
}
that.conn.Do("EXEC")
} else {
_, err := that.conn.Do("DEL", key)
conn.Send("MULTI")
for i := range val {
conn.Send("DEL", val[i])
}
_, err = conn.Do("EXEC")
if err != nil {
that.Error.SetError(err)
_, err = that.conn.Do("PING")
if err != nil {
if that.reCon() {
_, err = that.conn.Do("DEL", key)
}
}
that.logErr(err)
}
} else {
_, err := conn.Do("DEL", key)
if err != nil {
that.logErr(err)
}
}
}
//key value ,时间为时间戳
func (that *CacheRedis) set(key string, value string, time int64) {
_, err := that.conn.Do("SET", key, value, "EX", ObjToStr(time))
if err != nil {
// key value ,时间为时间戳
func (that *CacheRedis) set(key string, value string, expireSeconds int64) {
conn := that.getConn()
if conn == nil {
return
}
defer conn.Close()
that.Error.SetError(err)
_, err = that.conn.Do("PING")
if err != nil {
if that.reCon() {
_, err = that.conn.Do("SET", key, value, "EX", ObjToStr(time))
}
}
_, err := conn.Do("SET", key, value, "EX", ObjToStr(expireSeconds))
if err != nil {
that.logErr(err)
}
}
func (that *CacheRedis) get(key string) *Obj {
reData := &Obj{}
conn := that.getConn()
if conn == nil {
return reData
}
defer conn.Close()
var err error
reData.Data, err = redis.String(that.conn.Do("GET", key))
reData.Data, err = redis.String(conn.Do("GET", key))
if err != nil {
reData.Data = nil
if !strings.Contains(err.Error(), "nil returned") {
that.Error.SetError(err)
_, err = that.conn.Do("PING")
if err != nil {
if that.reCon() {
reData.Data, err = redis.String(that.conn.Do("GET", key))
}
}
return reData
that.logErr(err)
}
}
return reData
@@ -123,19 +151,13 @@ func (that *CacheRedis) get(key string) *Obj {
func (that *CacheRedis) Cache(key string, data ...interface{}) *Obj {
reData := &Obj{}
if that.conn == nil {
re := that.reCon()
if !re {
return reData
}
}
//查询缓存
if len(data) == 0 {
reData = that.get(key)
return reData
}
tim := int64(0)
//删除缓存
if len(data) == 1 && data[0] == nil {
@@ -144,21 +166,16 @@ func (that *CacheRedis) Cache(key string, data ...interface{}) *Obj {
}
//添加缓存
if len(data) == 1 {
if that.TimeOut == 0 {
//that.Time = Config.GetInt64("cacheShortTime")
}
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
}
}
@@ -166,5 +183,100 @@ func (that *CacheRedis) Cache(key string, data ...interface{}) *Obj {
that.set(key, ObjToStr(data[0]), tim)
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)
}
}
+4 -6
View File
@@ -5,15 +5,13 @@ 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)
}
//单条缓存数据
// 单条缓存数据
type cacheData struct {
time int64
data interface{}
+91
View File
@@ -0,0 +1,91 @@
package hotime
import (
"net/http"
"testing"
)
func TestResolveClientIP_EOPreferred(t *testing.T) {
req, _ := http.NewRequest("GET", "/", nil)
req.Header.Set("EO-Connecting-IP", "171.1.2.3")
req.Header.Set("X-Real-IP", "43.1.2.3")
req.Header.Set("X-Forwarded-For", "171.1.2.3, 43.1.2.3")
req.RemoteAddr = "127.0.0.1:12345"
ip, chain := resolveClientIP(req)
if ip != "171.1.2.3" {
t.Fatalf("ip=%q want 171.1.2.3", ip)
}
// 去重:171 只出现一次
if chain != "171.1.2.3,43.1.2.3,127.0.0.1" {
t.Fatalf("chain=%q", chain)
}
}
func TestResolveClientIP_SkipLoopbackXReal(t *testing.T) {
req, _ := http.NewRequest("GET", "/", nil)
req.Header.Set("X-Real-IP", "127.0.0.1")
req.Header.Set("X-Forwarded-For", "171.1.2.3, 43.1.2.3")
req.RemoteAddr = "127.0.0.1:8080"
ip, chain := resolveClientIP(req)
if ip != "171.1.2.3" {
t.Fatalf("ip=%q want 171.1.2.3", ip)
}
if chain != "127.0.0.1,171.1.2.3,43.1.2.3" {
t.Fatalf("chain=%q", chain)
}
}
func TestResolveClientIP_XFFBeforeMisconfiguredXReal(t *testing.T) {
// 线上常见:X-Real-IP=CDNXFF=客户端,CDN
req, _ := http.NewRequest("GET", "/", nil)
req.Header.Set("X-Real-IP", "43.1.2.3")
req.Header.Set("X-Forwarded-For", "171.1.2.3, 43.1.2.3")
req.RemoteAddr = "127.0.0.1:8080"
ip, chain := resolveClientIP(req)
if ip != "171.1.2.3" {
t.Fatalf("ip=%q want 171.1.2.3", ip)
}
if chain != "43.1.2.3,171.1.2.3,127.0.0.1" {
t.Fatalf("chain=%q", chain)
}
}
func TestResolveClientIP_XRealWhenNoXFF(t *testing.T) {
req, _ := http.NewRequest("GET", "/", nil)
req.Header.Set("X-Real-IP", "203.0.113.9")
req.RemoteAddr = "10.0.0.1:80"
ip, chain := resolveClientIP(req)
if ip != "203.0.113.9" {
t.Fatalf("ip=%q want 203.0.113.9", ip)
}
if chain != "203.0.113.9,10.0.0.1" {
t.Fatalf("chain=%q", chain)
}
}
func TestResolveClientIP_DirectRemoteAddr(t *testing.T) {
req, _ := http.NewRequest("GET", "/", nil)
req.RemoteAddr = "192.168.1.8:5555"
ip, chain := resolveClientIP(req)
if ip != "192.168.1.8" {
t.Fatalf("ip=%q want 192.168.1.8", ip)
}
if chain != "192.168.1.8" {
t.Fatalf("chain=%q", chain)
}
if clientIP(req) != ip {
t.Fatalf("clientIP mismatch")
}
}
func TestResolveClientIP_NilReq(t *testing.T) {
ip, chain := resolveClientIP(nil)
if ip != "" || chain != "" {
t.Fatalf("want empty, got %q %q", ip, chain)
}
}
+368 -66
View File
@@ -8,10 +8,14 @@ import (
"io/ioutil"
"net/http"
"os"
"sort"
"strings"
"sync"
"time"
)
var adminLoginRateLimiter sync.Map // key: ipvalue: *[]int641分钟内登录时间戳列表)
// Project 管理端项目
var TptProject = Proj{
//"user": UserCtr,
@@ -22,16 +26,18 @@ var TptProject = Proj{
tableName := that.RouterString[1]
data := that.Db.Get(fileConfig.GetString("table"), "*", Map{"id": that.Session(fileConfig.GetString("table") + "_id").ToCeilInt()})
str, inData := that.MakeCodeRouter[hotimeName].Info(tableName, data, that.Db)
//str, inData := that.MakeCodeRouter[hotimeName].Info(tableName, data, that.Db)
str, _ := that.MakeCodeRouter[hotimeName].Info(tableName, data, that.Db)
where := Map{"id": that.RouterString[2]}
if len(inData) == 1 {
inData["id"] = where["id"]
where = Map{"AND": inData}
} else if len(inData) > 1 {
where["OR"] = inData
where = Map{"AND": where}
}
//权限控制,暂时取消
//if len(inData) == 1 {
// inData["id"] = where["id"]
// where = Map{"AND": inData}
//} else if len(inData) > 1 {
// where["OR"] = inData
// where = Map{"AND": where}
//}
re := that.Db.Get(tableName, str, where)
@@ -46,7 +52,7 @@ var TptProject = Proj{
continue
}
if (column["list"] == nil || column.GetBool("list")) && column.GetString("link") != "" {
if (column["info"] == nil || column.GetBool("info")) && column.GetString("link") != "" {
seStr := "id," + column.GetString("value")
if that.MakeCodeRouter[hotimeName].TableColumns[column.GetString("link")]["phone"] != nil {
seStr = seStr + ",phone"
@@ -218,6 +224,9 @@ var TptProject = Proj{
}
inData["parent_ids"] = parent_ids + ObjToStr(re) + ","
that.Db.Update(tableName, Map{"parent_ids": inData["parent_ids"]}, Map{"id": re})
} else {
inData["parent_ids"] = "," + ObjToStr(re) + ","
that.Db.Update(tableName, Map{"parent_ids": inData["parent_ids"]}, Map{"id": re})
}
that.Log["table_id"] = re
@@ -247,20 +256,28 @@ var TptProject = Proj{
}
conf := ObjToMap(string(btes))
stop := conf.GetSlice("stop")
for k, _ := range stop {
v := stop.GetString(k)
//不能改对应行数据
if tableName == v && data.GetCeilInt64(v+"_id") == ObjToCeilInt64(that.RouterString[2]) {
that.Display(4, "你没有权限修改当前数据")
return
//相同则
if tableName == fileConfig.GetString("table") {
flows := conf.GetMap("flow")
for k, _ := range flows {
v := flows.GetMap(k)
if v.GetBool("stop") {
//不能改自身对应的数据
if inData[k+"_id"] != nil {
//fmt.Println(inData[k+"_id"])
delete(inData, k+"_id")
//fmt.Println(inData[k+"_id"])
}
}
}
//不能改自身对应的数据
if tableName == fileConfig.GetString("table") && inData[v+"_id"] != nil {
delete(inData, v+"_id")
} else {
flow := conf.GetMap("flow").GetMap(tableName)
if flow != nil && flow.GetBool("stop") {
if data.GetCeilInt64(tableName+"_id") == ObjToCeilInt64(that.RouterString[2]) {
that.Display(4, "你没有权限修改当前数据")
return
}
}
}
if inData["auth"] != nil {
@@ -357,22 +374,28 @@ var TptProject = Proj{
}
if inData.Get("parent_id") != nil {
if _, ok := inData["parent_id"]; ok {
//1
//12 62
//123 623
Index := that.Db.Get(tableName, "parent_id,`parent_ids`", Map{"id": that.RouterString[2]})
if inData.GetCeilInt64("parent_id") != Index.GetCeilInt64("parent_id") {
parentIndex := that.Db.Get(tableName, "`parent_ids`", Map{"id": inData.Get("parent_id")})
if strings.Contains(parentIndex.GetString("parent_ids"), ","+that.RouterString[2]+",") {
that.Display(4, "不能将子级设置为父级")
return
}
parent_ids := parentIndex.GetString("parent_ids") + that.RouterString[2] + ","
childNodes := that.Db.Select(tableName, "id,`parent_ids`", Map{"parent_ids[~]": "," + that.RouterString[2] + ","})
for _, v := range childNodes {
v["parent_ids"] = strings.Replace(v.GetString("parent_ids"), Index.GetString("parent_ids"), parent_ids, -1)
that.Db.Update(tableName, Map{"parent_ids": v["parent_ids"]}, Map{"id": v.GetCeilInt("id")})
if inData.GetCeilInt64("parent_id") == 0 {
that.Db.Update(tableName, Map{"parent_ids": "," + that.RouterString[2] + ","}, Map{"id": that.RouterString[2]})
} else {
Index := that.Db.Get(tableName, "parent_id,`parent_ids`", Map{"id": that.RouterString[2]})
if inData.GetCeilInt64("parent_id") != Index.GetCeilInt64("parent_id") {
parentIndex := that.Db.Get(tableName, "`parent_ids`", Map{"id": inData.Get("parent_id")})
if strings.Contains(parentIndex.GetString("parent_ids"), ","+that.RouterString[2]+",") {
that.Display(4, "不能将子级设置为父级")
return
}
parent_ids := parentIndex.GetString("parent_ids") + that.RouterString[2] + ","
childNodes := that.Db.Select(tableName, "id,`parent_ids`", Map{"parent_ids[~]": "," + that.RouterString[2] + ","})
for _, v := range childNodes {
v["parent_ids"] = strings.Replace(v.GetString("parent_ids"), Index.GetString("parent_ids"), parent_ids, -1)
that.Db.Update(tableName, Map{"parent_ids": v["parent_ids"]}, Map{"id": v.GetCeilInt("id")})
}
}
}
@@ -392,7 +415,33 @@ var TptProject = Proj{
tableName := that.RouterString[1]
that.Log = Map{"table": tableName, "type": 3, "table_id": that.RouterString[2]}
hotimeName := that.RouterString[0]
fileConfig := that.MakeCodeRouter[hotimeName].FileConfig
//树状结构不允许修改自身的属性,修改别人的可以
btes, err := ioutil.ReadFile(fileConfig.GetString("config"))
if err != nil {
that.Display(4, "找不到权限配置文件")
return
}
conf := ObjToMap(string(btes))
data := that.Db.Get(fileConfig.GetString("table"), "*", Map{"id": that.Session(fileConfig.GetString("table") + "_id").ToCeilInt()})
//相同则
if tableName == fileConfig.GetString("table") {
if data.GetCeilInt64("id") == ObjToCeilInt64(that.RouterString[2]) {
that.Display(4, "你不能删除自己")
return
}
} else {
flow := conf.GetMap("flow").GetMap(tableName)
if flow != nil && flow.GetBool("stop") {
if data.GetCeilInt64(tableName+"_id") == ObjToCeilInt64(that.RouterString[2]) {
that.Display(4, "你没有权限修改当前数据")
return
}
}
}
re := int64(0)
//有索引则删除对应的参数
if that.MakeCodeRouter[hotimeName].TableColumns[tableName]["parent_id"] != nil {
@@ -412,8 +461,51 @@ var TptProject = Proj{
fileConfig := that.MakeCodeRouter[hotimeName].FileConfig
tableName := that.RouterString[1]
data := that.Db.Get(fileConfig.GetString("table"), "*", Map{"id": that.Session(fileConfig.GetString("table") + "_id").ToCeilInt()})
where := Map{}
columnStr, leftJoin, where := that.MakeCodeRouter[hotimeName].Search(tableName, data, that.Req, that.Db)
//树状结构不允许修改自身的属性,修改别人的可以
btes, err := ioutil.ReadFile(fileConfig.GetString("config"))
if err != nil {
that.Display(4, "找不到权限配置文件")
return
}
conf := ObjToMap(string(btes))
//相同则
//if tableName!=fileConfig.GetString("table"){
flow := conf.GetMap("flow").GetMap(tableName)
if flow != nil && flow.GetMap("sql") != nil {
sql := ObjToMap(DeepCopyMap(flow.GetMap("sql")))
for k, _ := range sql {
//for uk,_:=range data{
// if sql[uk]==nil{
// continue
// }
// uv:=data.GetString(uk)
//
// tv:=strings.Replace(sql.GetString(uk),uk,uv,-1)
// if tv!=uv{
// where[k]=tv
// break
// }
//}
if k == "parent_ids[~]" && tableName == fileConfig.GetString("table") {
where[tableName+"."+k] = strings.Replace(sql.GetString(k), "id", data.GetString("id"), -1)
continue
}
if k == "parent_ids[~]" && data[tableName+"_id"] != nil {
where[tableName+"."+k] = strings.Replace(sql.GetString(k), tableName+"_id", data.GetString(tableName+"_id"), -1)
continue
}
if data[sql.GetString(k)] != nil {
where[tableName+"."+k] = data[sql.GetString(k)]
}
}
}
//}
columnStr, where := that.MakeCodeRouter[hotimeName].Search(tableName, data, where, that.Req, that.Db)
page := ObjToInt(that.Req.FormValue("page"))
pageSize := ObjToInt(that.Req.FormValue("pageSize"))
@@ -432,12 +524,12 @@ var TptProject = Proj{
var reData []Map
//是否下载
if download == 0 {
count = that.Db.Count(tableName, leftJoin, where)
count = that.Db.Count(tableName, where)
reData = that.Db.Page(page, pageSize).
PageSelect(tableName, leftJoin, columnStr, where)
PageSelect(tableName, columnStr, where)
}
if download == 1 {
reData = that.Db.Select(tableName, leftJoin, columnStr, where)
reData = that.Db.Select(tableName, columnStr, where)
}
for _, v := range reData {
@@ -469,11 +561,11 @@ var TptProject = Proj{
column := that.MakeCodeRouter[hotimeName].TableColumns[tableName][k]
if column == nil {
break
if column == nil || column["link"] == nil || !(column["list"] == nil || column["list"] == true) || v.GetCeilInt64(column.GetString("name")) == 0 {
continue
}
if (column["list"] == nil || column["list"] == true) && column["name"] == "parent_id" && column.GetString("link") != "" {
if column["name"] == "parent_id" {
parentC := that.Db.Get(column.GetString("link"), column.GetString("value"), Map{"id": v.GetCeilInt(k)})
v[column.GetString("link")+"_"+column.GetString("name")+"_"+column.GetString("value")] = ""
@@ -482,6 +574,29 @@ var TptProject = Proj{
}
}
if column.GetString("name") != "parent_id" {
reStr := ""
if column.GetString("value") == "name" && that.MakeCodeRouter[hotimeName].TableColumns[column.GetString("link")]["nickname"] != nil {
reStr += "nickname,"
}
if that.MakeCodeRouter[hotimeName].TableColumns[column.GetString("link")]["phone"] != nil {
reStr += "phone,"
}
reStr += column.GetString("value")
joinData := that.Db.Get(column.GetString("link"), reStr, Map{"id": v.GetCeilInt64(column.GetString("name"))})
if joinData != nil {
for kj, vj := range joinData {
v[column.GetString("link")+"_"+column.GetString("name")+"_"+kj] = vj
}
}
}
//return isEnd
}
}
@@ -575,6 +690,168 @@ var TptProject = Proj{
that.Display(0, Map{"count": count, "data": reData})
},
"analyse": func(that *Context) {
hotimeName := that.RouterString[0]
//hotimeName := "super"
fileConfig := that.MakeCodeRouter[hotimeName].FileConfig
tableName := that.RouterString[1]
data := that.Db.Get(fileConfig.GetString("table"), "*", Map{"id": that.Session(fileConfig.GetString("table") + "_id").ToCeilInt()})
where := Map{}
//树状结构不允许修改自身的属性,修改别人的可以
btes, err := ioutil.ReadFile(fileConfig.GetString("config"))
if err != nil {
that.Display(4, "找不到权限配置文件")
return
}
conf := ObjToMap(string(btes))
//相同则
//if tableName!=fileConfig.GetString("table"){
flow := conf.GetMap("flow").GetMap(tableName)
if flow != nil && flow.GetMap("sql") != nil {
sql := ObjToMap(DeepCopyMap(flow.GetMap("sql")))
for k, _ := range sql {
//for uk,_:=range data{
// if sql[uk]==nil{
// continue
// }
// uv:=data.GetString(uk)
//
// tv:=strings.Replace(sql.GetString(uk),uk,uv,-1)
// if tv!=uv{
// where[k]=tv
// break
// }
//}
if k == "parent_ids[~]" && tableName == fileConfig.GetString("table") {
where[k] = strings.Replace(sql.GetString(k), "id", data.GetString("id"), -1)
continue
}
if k == "parent_ids[~]" && data[tableName+"_id"] != nil {
where[k] = strings.Replace(sql.GetString(k), tableName+"_id", data.GetString(tableName+"_id"), -1)
continue
}
if data[sql.GetString(k)] != nil {
where[k] = data[sql.GetString(k)]
}
}
}
//}
_, where = that.MakeCodeRouter[hotimeName].Search(tableName, data, where, that.Req, that.Db)
delete(where, "ORDER")
//page := ObjToInt(that.Req.FormValue("page"))
//pageSize := ObjToInt(that.Req.FormValue("pageSize"))
//download := ObjToInt(that.Req.FormValue("download"))
//
//if page < 1 {
// page = 1
//}
//
//if pageSize <= 0 {
// pageSize = 20
//}
redData := Map{}
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)
}
}
// 第二步:单次聚合查询替代原来 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
}
// 回填 number 字段的 sum
for _, k := range sumFieldKeys {
alias := "__sum__" + k
redData[k] = aggRow[alias]
}
}
}
// 第三步: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)
},
},
"hotime": Ctr{
"file": func(that *Context) {
@@ -685,6 +962,26 @@ var TptProject = Proj{
that.Display(0, re)
},
"login": func(that *Context) {
// 限速:同一 IP 1分钟内最多尝试10次
ip := clientIP(that.Req)
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
@@ -738,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")
@@ -766,11 +1063,12 @@ var TptProject = Proj{
}
linkAuthMap := that.Db.Get(v.GetString("link"), "auth", Map{"id": user.GetCeilInt(v.GetString("name"))})
linkAuth := linkAuthMap.GetMap("auth")
oldLinkAuth := linkAuthMap.GetMap("auth")
linkAuth := Map{}
//conf := ObjToMap(string(btes))
//menus := conf.GetSlice("menus")
if linkAuth == nil {
linkAuth = Map{}
if oldLinkAuth != nil {
linkAuth = oldLinkAuth
}
for k1, _ := range menus {
@@ -781,7 +1079,7 @@ var TptProject = Proj{
}
if v1["auth"] != nil {
if linkAuth[name] == nil {
if oldLinkAuth == nil {
linkAuth[name] = v1["auth"]
} else {
newAuth := Slice{}
@@ -804,7 +1102,7 @@ var TptProject = Proj{
}
if v2["auth"] != nil {
if linkAuth[name] == nil {
if oldLinkAuth == nil {
linkAuth[name] = v2["auth"]
} else {
@@ -826,32 +1124,36 @@ var TptProject = Proj{
for k1, _ := range menus {
v1 := menus.GetMap(k1)
//保证个人权限可用
if fileConfig.GetString("table") == v1.GetString("table") {
v1["auth"] = Slice{"info", "edit"}
}
name := v1.GetString("name")
if name == "" {
name = v1.GetString("table")
}
if linkAuth[name] != nil {
if len(linkAuth.GetSlice(name)) != 0 {
v1["auth"] = linkAuth[name]
} else
//保证个人权限可用
if fileConfig.GetString("table") == v1.GetString("table") {
v1["auth"] = Slice{"info", "edit"}
} else {
v1["auth"] = linkAuth[name]
}
v1menus := v1.GetSlice("menus")
for k2, _ := range v1menus {
v2 := v1menus.GetMap(k2)
//保证个人权限可用
if fileConfig.GetString("table") == v2.GetString("table") {
v2["auth"] = Slice{"info", "edit"}
}
name := v2.GetString("name")
if name == "" {
name = v2.GetString("table")
}
if linkAuth[name] != nil {
if len(linkAuth.GetSlice(name)) != 0 {
v2["auth"] = linkAuth[name]
} else if fileConfig.GetString("table") == v2.GetString("table") {
v2["auth"] = Slice{"info", "edit"}
} else {
v2["auth"] = linkAuth[name]
}
@@ -872,7 +1174,7 @@ var TptProject = Proj{
v1 := menus.GetMap(k1)
if config.GetMap("tables").GetMap(v1.GetString("table")) != nil && len(v1.GetSlice("auth")) != 0 {
config.GetMap("tables").GetMap(v1.GetString("table"))["auth"] = Slice{}
newTables[v1.GetString("name")] = config.GetMap("tables").GetMap(v1.GetString("table"))
newTables[v1.GetString("table")] = config.GetMap("tables").GetMap(v1.GetString("table"))
}
v1menus := v1.GetSlice("menus")
for k2, _ := range v1.GetSlice("menus") {
+136 -54
View File
@@ -42,79 +42,161 @@ 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 {
Name string
List bool
Edit bool
Info bool
Must bool
Name string //名称
List bool //列表权限
Edit bool //新增和编辑权限
Info bool //详情权限
Must bool //字段全匹配
Type string //空字符串表示
Strict bool //name严格匹配必须是这个词才行
}
var ColumnNameType = []ColumnShow{
//通用
{"idcard", false, true, true, false, "", false},
{"id", true, false, true, false, "", true},
{"sn", true, false, true, false, "", false},
{"parent_ids", false, false, false, false, "index", true},
{"parent_id", true, true, true, false, "", true},
{"amount", true, true, true, false, "money", true},
{"info", false, true, true, false, "textArea", false},
//"sn"{true,true,true,""},
{"status", true, true, true, false, "select", false},
{"state", true, true, true, false, "select", false},
{"sex", true, true, true, false, "select", false},
{"delete", false, false, false, false, "", false},
var RuleConfig = []Map{
{"name": "idcard", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
{"name": "id", "add": false, "list": true, "edit": false, "info": true, "must": false, "strict": true, "type": ""},
{"name": "sn", "add": false, "list": true, "edit": false, "info": true, "must": false, "strict": false, "type": ""},
{"name": "parent_ids", "add": false, "list": false, "edit": false, "info": false, "must": false, "strict": true, "type": "index"},
{"name": "index", "add": false, "list": false, "edit": false, "info": false, "must": false, "strict": true, "type": "index"},
{"lat", false, true, true, false, "", false},
{"lng", false, true, true, false, "", false},
{"latitude", false, true, true, false, "", false},
{"longitude", false, true, true, false, "", false},
{"name": "parent_id", "add": true, "list": true, "edit": true, "info": true, "must": false, "true": false, "type": ""},
{"index", false, false, false, false, "index", false},
{"name": "amount", "add": true, "list": true, "edit": true, "info": true, "must": false, "strict": true, "type": "money"},
{"password", false, true, false, false, "password", false},
{"pwd", false, true, false, false, "password", false},
{"name": "info", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": "textArea"},
{"version", false, false, false, false, "", false},
{"seq", false, true, true, false, "", false},
{"sort", false, true, true, false, "", false},
{"note", false, true, true, false, "", false},
{"description", false, true, true, false, "", false},
{"abstract", false, true, true, false, "", false},
{"content", false, true, true, false, "textArea", false},
{"address", true, true, true, false, "", false},
{"full_name", false, true, true, false, "", false},
{"create_time", false, false, true, false, "time", true},
{"modify_time", true, false, true, false, "time", true},
{"image", false, true, true, false, "image", false},
{"img", false, true, true, false, "image", false},
{"icon", false, true, true, false, "image", false},
{"avatar", false, true, true, false, "image", false},
{"file", false, true, true, false, "file", false},
{"age", false, true, true, false, "", false},
{"email", false, true, true, false, "", false},
{"time", true, true, true, false, "time", false},
{"level", false, false, true, false, "", false},
{"rule", true, true, true, false, "form", false},
{"auth", false, true, true, false, "auth", true},
{"table", true, false, true, false, "table", false},
{"table_id", true, false, true, false, "table_id", false},
{"name": "status", "add": true, "list": true, "edit": true, "info": true, "must": false, "strict": false, "type": "select"},
{"name": "state", "add": true, "list": true, "edit": true, "info": true, "must": false, "strict": false, "type": "select"},
{"name": "sex", "add": true, "list": true, "edit": true, "info": true, "must": false, "strict": false, "type": "select"},
{"name": "delete", "add": false, "list": false, "edit": false, "info": false, "must": false, "strict": false, "type": ""},
{"name": "lat", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
{"name": "lng", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
{"name": "latitude", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
{"name": "longitude", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
{"name": "password", "add": true, "list": false, "edit": true, "info": false, "must": false, "strict": false, "type": "password"},
{"name": "pwd", "add": true, "list": false, "edit": true, "info": false, "must": false, "strict": false, "type": "password"},
{"name": "version", "add": false, "list": false, "edit": false, "info": false, "must": false, "strict": false, "type": ""},
{"name": "seq", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
{"name": "sort", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
{"name": "note", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
{"name": "description", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
{"name": "abstract", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
{"name": "content", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": "textArea"},
{"name": "address", "add": true, "list": true, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
{"name": "full_name", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
{"name": "create_time", "add": false, "list": false, "edit": false, "info": true, "must": false, "strict": true, "type": "time"},
{"name": "modify_time", "add": false, "list": true, "edit": false, "info": true, "must": false, "strict": true, "type": "time"},
{"name": "image", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": "image"},
{"name": "img", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": "image"},
{"name": "avatar", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": "image"},
{"name": "icon", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": "image"},
{"name": "file", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": "file"},
{"name": "age", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
{"name": "email", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
{"name": "time", "add": true, "list": true, "edit": true, "info": true, "must": false, "strict": false, "type": "time"},
{"name": "level", "add": false, "list": false, "edit": false, "info": true, "must": false, "strict": false, "type": ""},
{"name": "rule", "add": true, "list": true, "edit": true, "info": true, "must": false, "strict": false, "type": "form"},
{"name": "auth", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": "auth"},
{"name": "table", "add": false, "list": true, "edit": false, "info": true, "must": false, "strict": false, "type": "table"},
{"name": "table_id", "add": false, "list": true, "edit": false, "info": true, "must": false, "strict": false, "type": "table_id"},
}
//var ColumnNameType = []ColumnShow{
// //通用
// {"idcard", false, true, true, false, "", false},
// {"id", true, false, true, false, "", true},
// {"sn", true, false, true, false, "", false},
// {"parent_ids", false, false, false, false, "index", true},
// {"parent_id", true, true, true, false, "", true},
// {"amount", true, true, true, false, "money", true},
// {"info", false, true, true, false, "textArea", false},
// //"sn"{true,true,true,""},
// {"status", true, true, true, false, "select", false},
// {"state", true, true, true, false, "select", false},
// {"sex", true, true, true, false, "select", false},
// {"delete", false, false, false, false, "", false},
//
// {"lat", false, true, true, false, "", false},
// {"lng", false, true, true, false, "", false},
// {"latitude", false, true, true, false, "", false},
// {"longitude", false, true, true, false, "", false},
//
// {"index", false, false, false, false, "index", false},
//
// {"password", false, true, false, false, "password", false},
// {"pwd", false, true, false, false, "password", false},
//
// {"version", false, false, false, false, "", false},
// {"seq", false, true, true, false, "", false},
// {"sort", false, true, true, false, "", false},
// {"note", false, true, true, false, "", false},
// {"description", false, true, true, false, "", false},
// {"abstract", false, true, true, false, "", false},
// {"content", false, true, true, false, "textArea", false},
// {"address", true, true, true, false, "", false},
// {"full_name", false, true, true, false, "", false},
// {"create_time", false, false, true, false, "time", true},
// {"modify_time", true, false, true, false, "time", true},
// {"image", false, true, true, false, "image", false},
// {"img", false, true, true, false, "image", false},
// {"icon", false, true, true, false, "image", false},
// {"avatar", false, true, true, false, "image", false},
// {"file", false, true, true, false, "file", false},
// {"age", false, true, true, false, "", false},
// {"email", false, true, true, false, "", false},
// {"time", true, true, true, false, "time", false},
// {"level", false, false, true, false, "", false},
// {"rule", true, true, true, false, "form", false},
// {"auth", false, true, true, false, "auth", true},
// {"table", true, false, true, false, "table", false},
// {"table_id", true, false, true, false, "table_id", false},
//}
+354 -178
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,24 +49,24 @@ 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))
}
} else {
for _, v := range ColumnNameType {
that.RuleConfig = append(that.RuleConfig, Map{"name": v.Name, "list": v.List, "edit": v.Edit, "info": v.Info, "must": v.Must, "strict": v.Strict, "type": v.Type})
for _, v := range RuleConfig {
that.RuleConfig = append(that.RuleConfig, ObjToMap(DeepCopyMap(v)))
}
if db != nil {
_ = 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{}
@@ -116,17 +116,28 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
nowTables = db.Select("INFORMATION_SCHEMA.TABLES", "TABLE_NAME as name,TABLE_COMMENT as label", Map{"TABLE_SCHEMA": db.DBName})
}
if db.Type == "sqlite" {
nowTables = db.Select("sqlite_sequence", "name")
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" {
continue
}
// if v.GetString("name") == "cached" {
// continue
// }
if that.TableConfig.GetMap(v.GetString("name")) == nil {
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"),
@@ -158,11 +169,14 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
tableInfo := make([]Map, 0)
if db.Type == "mysql" {
tableInfo = db.Select("INFORMATION_SCHEMA.COLUMNS", "COLUMN_NAME AS name,COLUMN_TYPE AS type,COLUMN_COMMENT AS label", Map{"AND": Map{"TABLE_SCHEMA": db.DBName, "TABLE_NAME": v.GetString("name")}})
tableInfo = db.Select("INFORMATION_SCHEMA.COLUMNS", "COLUMN_NAME AS name,COLUMN_TYPE AS type,COLUMN_COMMENT AS label,IS_NULLABLE AS must,COLUMN_DEFAULT AS dflt_value", Map{"AND": Map{"TABLE_SCHEMA": db.DBName, "TABLE_NAME": v.GetString("name")}, "ORDER": "ORDINAL_POSITION"})
}
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,8 +189,9 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
if coloum == nil {
//根据类型判断真实类型
for k, v1 := range ColumnDataType {
if strings.Contains(info.GetString("name"), k) || strings.Contains(info.GetString("type"), k) {
if strings.Contains(strings.ToLower(info.GetString("type")), k) {
info["type"] = v1
break
}
}
@@ -188,26 +203,43 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
//"must": false,
}
//备注以空格隔开,空格后的是其他备注
indexNum := strings.Index(info.GetString("label"), " ")
if indexNum > -1 {
coloum["label"] = info.GetString("label")[:indexNum]
}
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 info.GetString("must") == "NO" {
coloum["must"] = true
}
//去除数据信息,是用:号分割的
indexNum = strings.Index(coloum.GetString("label"), ":")
if indexNum > -1 {
coloum["label"] = coloum.GetString("label")[:indexNum]
if info["dflt_value"] != nil {
coloum["default"] = info["dflt_value"]
}
if info["pk"] != nil && info.GetCeilInt64("pk") == 1 {
coloum["must"] = true
}
//提取括号内的提示信息:支持 ()、()、{}
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
}
}
//空格截断,空格后的内容丢弃
if idx := strings.Index(rawLabel, " "); idx > -1 {
rawLabel = rawLabel[:idx]
}
//冒号截断,去除选项数据部分
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")) ||
(!ColumnName.GetBool("strict") && strings.Contains(coloum.GetString("name"), ColumnName.GetString("name"))) {
(!ColumnName.GetBool("strict") && strings.Contains(coloum.GetString("name"), ColumnName.GetString("name"))) ||
(v.GetString("name")+"."+coloum.GetString("name") == ColumnName.GetString("name")) {
//全部都不需要则不加入
if ColumnName.GetBool("edit") == false && ColumnName.GetBool("list") == false && ColumnName.GetBool("info") == false {
coloum["notUse"] = true
@@ -215,15 +247,25 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
}
coloum["info"] = ColumnName.GetBool("info")
coloum["edit"] = ColumnName.GetBool("edit")
coloum["add"] = ColumnName.GetBool("edit")
coloum["add"] = ColumnName["add"]
coloum["list"] = ColumnName.GetBool("list")
coloum["must"] = ColumnName.GetBool("must")
//coloum["must"] = ColumnName.GetBool("must")
if coloum["must"] == nil {
coloum["must"] = ColumnName.GetBool("must")
} else {
if ColumnName["must"] != nil {
coloum["must"] = ColumnName.GetBool("must")
}
}
if ColumnName.GetBool("info") {
delete(coloum, "info")
}
if ColumnName.GetBool("edit") {
delete(coloum, "edit")
}
if ColumnName["add"] == nil || ColumnName.GetBool("add") {
delete(coloum, "add")
}
if ColumnName.GetBool("list") {
@@ -281,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
}
//暂时不关闭参数,保证表数据完全读取到
@@ -299,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
}
@@ -505,7 +566,12 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
}
}
flow := Map{}
//"user": {"table": "user", "stop": true,
// "SQL": {
// "role_id": "role_id",
// "admin_id": "admin_id",
// "parent_ids": "%,user_id,%"}}
//搜索增加树节点
for fk, fv := range that.TableColumns {
for k, v := range fv {
@@ -518,12 +584,68 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
that.SearchColumns[fk][k] = sv
}
if fk == that.FileConfig.GetString("table") && v["link"] != nil && that.TableColumns[v.GetString("link")]["parent_id"] != nil {
that.Config["stop"] = append(that.Config.GetSlice("stop"), v.GetString("link"))
//if fk == that.FileConfig.GetString("table") && v["link"] != nil && that.TableColumns[v.GetString("link")]["parent_id"] != nil {
// that.Config["stop"] = append(that.Config.GetSlice("stop"), v.GetString("link"))
// //that.
//}
for _, av := range that.TableColumns[that.FileConfig.GetString("table")] {
//同字段
if av["link"] != nil && av.GetString("link") == v.GetString("link") {
if flow[fk] == nil {
flow[fk] = Map{"table": fk, "stop": false, "sql": Map{}}
}
sql := flow.GetMap(fk).GetMap("sql")
if k == "parent_id" {
sql["parent_ids[~]"] = "," + av.GetString("name") + ","
} else {
sql[k] = k
}
flow.GetMap(fk)["sql"] = sql
}
//同表
if av["link"] != nil && av.GetString("link") == fk {
if flow[fk] == nil {
flow[fk] = Map{"table": fk, "stop": false, "sql": Map{}}
}
ta := flow.GetMap(fk)
ta["stop"] = true
flow[fk] = ta
if k == "id" && that.TableColumns[fk]["parent_id"] == nil {
sql := flow.GetMap(fk).GetMap("sql")
sql[k] = av.GetString("name")
flow.GetMap(fk)["sql"] = sql
}
}
}
if k == that.FileConfig.GetString("table")+"_id" {
if flow[fk] == nil {
flow[fk] = Map{"table": fk, "stop": false, "sql": Map{}}
}
sql := flow.GetMap(fk).GetMap("sql")
sql[k] = "id"
flow.GetMap(fk)["sql"] = sql
}
//当前用户
if k == "parent_id" && that.FileConfig.GetString("table") == fk {
if flow[fk] == nil {
flow[fk] = Map{"table": fk, "stop": false, "sql": Map{}}
}
sql := flow.GetMap(fk).GetMap("sql")
sql["parent_ids[~]"] = ",id,"
flow.GetMap(fk)["sql"] = sql
}
}
}
//fmt.Println(flow)
that.Config["flow"] = flow
//fmt.Println(id, "---", that.Config.GetString("id"))
//that.Config["id"] = id
@@ -534,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 失败")
}
}
@@ -545,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"))
}
@@ -561,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("有新的代码生成,请重新运行")
@@ -664,12 +786,12 @@ func (that *MakeCode) Add(table string, user Map, req *http.Request) Map {
continue
}
if user[v.GetString("name")] != nil {
data[v.GetString("name")] = user[v.GetString("name")]
continue
}
//if user[v.GetString("name")] != nil {
//
// data[v.GetString("name")] = user[v.GetString("name")]
// continue
//
//}
if v.GetBool("must") {
return nil
@@ -679,17 +801,23 @@ func (that *MakeCode) Add(table string, user Map, req *http.Request) Map {
}
reqValue := req.FormValue(v.GetString("name"))
if (reqValue == "" || reqValue == "null") && strings.Contains(v.GetString("name"), "id") {
data[v.GetString("name")] = nil
} else {
if v.GetString("type") == "password" {
data[v.GetString("name")] = Md5(reqValue)
} else {
data[v.GetString("name")] = reqValue
}
if req.Form[v.GetString("name")] == nil {
continue
}
reqValue := req.FormValue(v.GetString("name"))
if reqValue == "null" {
data[v.GetString("name")] = nil
continue
}
if v.GetString("type") == "password" {
data[v.GetString("name")] = Md5(reqValue)
continue
}
data[v.GetString("name")] = reqValue
}
//sn则自动生成
if v.GetString("name") == "sn" {
@@ -706,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
}
@@ -716,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")
}
}
}
@@ -738,13 +866,21 @@ func (that *MakeCode) Edit(table string, req *http.Request) Map {
}
if v.Get("edit") == nil || v.GetBool("edit") {
if len(req.Form[v.GetString("name")]) == 0 {
continue
}
reqValue := req.FormValue(v.GetString("name"))
if reqValue == "null" {
data[v.GetString("name")] = nil
continue
}
if v.GetString("type") == "number" && reqValue == "" {
data[v.GetString("name")] = nil
continue
}
if v.GetString("type") == "password" {
data[v.GetString("name")] = Md5(reqValue)
} else {
@@ -757,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")
}
}
}
@@ -783,14 +919,17 @@ func (that *MakeCode) Delete(table string, req *http.Request) Map {
return data
}
func (that *MakeCode) Search(table string, userData Map, req *http.Request, db *db.HoTimeDB) (string, Map, Map) {
func (that *MakeCode) Search(table string, userData Map, data Map, req *http.Request, db *db.HoTimeDB) (string, Map) {
reStr := ""
leftJoin := Map{}
data := Map{}
//leftJoin := Map{}
//data := Map{}
if data == nil {
data = Map{}
}
keyword := Map{}
daterange := Map{}
sortMap := Map{}
var ruleData Map
//var ruleData Map
hasUser := false
keywordStr := req.FormValue("keyword")
@@ -814,87 +953,93 @@ func (that *MakeCode) Search(table string, userData Map, req *http.Request, db *
if v["list"] != false {
if v.GetString("link") != "" &&
v.GetString("name") != "parent_id" {
reStr += table + "." + v.GetString("name") + "," +
v.GetString("link") + "." + v.GetString("value") + " AS " +
v.GetString("link") + "_" + v.GetString("name") + "_" + v.GetString("value") + ","
if v.GetString("value") == "name" && that.TableColumns[v.GetString("link")]["nickname"] != nil {
reStr += v.GetString("link") + ".nickname AS " +
v.GetString("link") + "_" + v.GetString("name") + "_nickname,"
}
if that.TableColumns[v.GetString("link")]["phone"] != nil {
reStr += v.GetString("link") + ".phone AS " +
v.GetString("link") + "_" + v.GetString("name") + "_phone,"
}
leftJoin["[>]"+v.GetString("link")] =
table + "." + v.GetString("name") + "=" +
v.GetString("link") + ".id"
if v.GetString("link") == "admin" {
hasUser = true
}
reqValue := req.FormValue(v.GetString("name"))
if reqValue != "" {
data[table+"."+v.GetString("name")] = reqValue
}
} else {
reStr += table + "." + v.GetString("name") + ","
//if v.GetString("link") != "" &&
// v.GetString("name") != "parent_id" {
//
// reStr += table + "." + v.GetString("name") + ","
// //+
// // v.GetString("link") + "." + v.GetString("value") + " AS " +
// // v.GetString("link") + "_" + v.GetString("name") + "_" + v.GetString("value") + ","
//
// if v.GetString("value") == "name" && that.TableColumns[v.GetString("link")]["nickname"] != nil {
// reStr += v.GetString("link") + ".nickname AS " +
// v.GetString("link") + "_" + v.GetString("name") + "_nickname,"
// }
//
// if that.TableColumns[v.GetString("link")]["phone"] != nil {
// reStr += v.GetString("link") + ".phone AS " +
// v.GetString("link") + "_" + v.GetString("name") + "_phone,"
// }
//
// leftJoin["[>]"+v.GetString("link")] =
// table + "." + v.GetString("name") + "=" +
// v.GetString("link") + ".id"
//
// if v.GetString("link") == "admin" {
// hasUser = true
// }
//
reqValue := req.FormValue(v.GetString("name"))
// text 类字段只走下方模糊匹配([~]),此处不再生成等值条件,
// 否则等值(必不命中部分关键词)与模糊匹配在 where 中以 AND 叠加,
// 查询恒为空(见 docs/CodeGen_代码生成.md 通用 Search 筛选说明)。
if v.GetString("name") != "parent_id" && reqValue != "" &&
!strings.Contains(v.GetString("type"), "text") {
data[v.GetString("name")] = reqValue
}
//
//} else {
if v["name"] == "parent_id" && v.GetString("link") != "" {
leftJoin["[>]"+v.GetString("link")+" selfParent"] =
"selfParent.id=" +
v.GetString("link") + "." + v.GetString("name")
reStr += "selfParent." + v.GetString("value") + " AS " + v.GetString("link") + "_" + v.GetString("name") + "_" + v.GetString("value") + ","
}
reStr += "`" + v.GetString("name") + "`,"
//}
//if v["name"] == "parent_id" && v.GetString("link") != "" {
// leftJoin["[>]"+v.GetString("link")+" selfParent"] =
// "selfParent.id=" +
// v.GetString("link") + "." + v.GetString("name")
// reStr += "selfParent." + v.GetString("value") + " AS " + v.GetString("link") + "_" + v.GetString("name") + "_" + v.GetString("value") + ","
//}
//准备加入索引权限
if v.GetString("link") != "" &&
userData != nil &&
that.TableColumns[v.GetString("link")]["parent_id"] != nil {
//初始化ruleData
if ruleData == nil {
ruleData = Map{}
for _, v := range that.TableColumns[that.FileConfig.GetString("table")] {
if v.GetString("link") != "" &&
v.GetString("name") != "parent_id" &&
userData.Get(v.GetString("name")) != nil {
ruleData[v.GetString("link")] = userData.Get(v.GetString("name"))
}
}
}
if ruleData[v.GetString("link")] != nil {
parent_idsStr := ""
parent_ids := that.TableColumns[v.GetString("link")]["parent_ids"]
if parent_ids != nil {
parent_idsStr = "parent_ids[~]"
}
index := that.TableColumns[v.GetString("link")]["index"]
if index != nil {
parent_idsStr = "index[~]"
}
if v.GetString("name") == "parent_id" {
data[table+"."+parent_idsStr] = "," + ruleData.GetString(v.GetString("link")) + ","
} else {
idMap := db.Select(v.GetString("link"), "id", Map{parent_idsStr: "," + ruleData.GetString(v.GetString("link")) + ","})
ids := Slice{ruleData.GetCeilInt(v.GetString("link"))}
for _, v := range idMap {
ids = append(ids, v.GetCeilInt("id"))
}
data[table+"."+v.GetString("name")] = ids
}
}
//if ruleData == nil {
// ruleData = Map{}
// for _, v := range that.TableColumns[that.FileConfig.GetString("table")] {
// if v.GetString("link") != "" &&
// v.GetString("name") != "parent_id" &&
// userData.Get(v.GetString("name")) != nil {
// ruleData[v.GetString("link")] = userData.Get(v.GetString("name"))
// }
// }
//}
//if ruleData[v.GetString("link")] != nil {
// parent_idsStr := ""
// parent_ids := that.TableColumns[v.GetString("link")]["parent_ids"]
// if parent_ids != nil {
// parent_idsStr = "parent_ids[~]"
// }
// index := that.TableColumns[v.GetString("link")]["index"]
// if index != nil {
// parent_idsStr = "index[~]"
// }
//
// if v.GetString("name") == "parent_id" {
//
// data[table+"."+parent_idsStr] = "," + ruleData.GetString(v.GetString("link")) + ","
//
// } else {
// idMap := db.Select(v.GetString("link"), "id", Map{parent_idsStr: "," + ruleData.GetString(v.GetString("link")) + ","})
// ids := Slice{ruleData.GetCeilInt(v.GetString("link"))}
// for _, v := range idMap {
// ids = append(ids, v.GetCeilInt("id"))
// }
// data[table+"."+v.GetString("name")] = ids
// }
//
//}
}
@@ -903,7 +1048,7 @@ func (that *MakeCode) Search(table string, userData Map, req *http.Request, db *
if strings.Contains(v.GetString("type"), "text") {
reqValue := req.FormValue(v.GetString("name"))
if reqValue != "" {
data[table+"."+v.GetString("name")+"[~]"] = reqValue
data[v.GetString("name")+"[~]"] = reqValue
}
}
@@ -911,9 +1056,9 @@ func (that *MakeCode) Search(table string, userData Map, req *http.Request, db *
if v.GetString("type") == "unixtime" {
if len(req.Form[v.GetString("name")]) == 1 {
daterange[table+"."+v.GetString("name")+"[>]"] = req.FormValue("daterange")
daterange[v.GetString("name")+"[>]"] = req.FormValue("daterange")
} else if len(req.Form[v.GetString("name")]) == 2 {
daterange[table+"."+v.GetString("name")+"[<>]"] = ObjToSlice(req.Form["daterange"])
daterange[v.GetString("name")+"[<>]"] = ObjToSlice(req.Form["daterange"])
}
//fmt.Println(req.Form["daterange"])
@@ -926,12 +1071,12 @@ func (that *MakeCode) Search(table string, userData Map, req *http.Request, db *
if len(req.Form[v.GetString("name")]) == 1 {
t := time.Unix(ObjToCeilInt64(req.FormValue(v.GetString("name"))), 0).Format("2006-01-02 15:04:05")
daterange[table+"."+v.GetString("name")+"[>]"] = t
daterange[v.GetString("name")+"[>]"] = t
} else if len(req.Form[v.GetString("name")]) == 2 {
t1 := time.Unix(ObjToCeilInt64(req.Form[v.GetString("name")][0]), 0).Format("2006-01-02 15:04:05")
t2 := time.Unix(ObjToCeilInt64(req.Form[v.GetString("name")][1]), 0).Format("2006-01-02 15:04:05")
daterange[table+"."+v.GetString("name")+"[<>]"] = Slice{t1, t2}
daterange[v.GetString("name")+"[<>]"] = Slice{t1, t2}
}
}
@@ -945,9 +1090,9 @@ func (that *MakeCode) Search(table string, userData Map, req *http.Request, db *
for k, _ := range search {
searchItem := search.GetMap(k)
searchItemName := searchItem.GetString("name")
if data[table+"."+searchItemName] != nil {
delete(data, table+"."+searchItemName)
}
//if data[table+"."+searchItemName] != nil {
// delete(data, table+"."+searchItemName)
//}
reqValue := req.Form[searchItemName]
parent_idsStr := ""
if searchItem.GetString("link") != "" {
@@ -965,6 +1110,10 @@ func (that *MakeCode) Search(table string, userData Map, req *http.Request, db *
if len(reqValue) == 0 || reqValue[0] == "" {
if parent_idsStr != "" && userData[searchItem.GetString("name")] != nil {
if data[searchItemName] == nil {
continue
}
where := Map{parent_idsStr: "," + ObjToStr(userData.GetCeilInt64(searchItem.GetString("name"))) + ","}
r := db.Select(searchItem.GetString("link"), "id", where)
reqValue = []string{}
@@ -972,7 +1121,7 @@ func (that *MakeCode) Search(table string, userData Map, req *http.Request, db *
reqValue = append(reqValue, v.GetString("id"))
}
data[table+"."+searchItemName] = reqValue
data[searchItemName] = reqValue
}
@@ -995,10 +1144,10 @@ func (that *MakeCode) Search(table string, userData Map, req *http.Request, db *
if v.GetString("type") == "unixtime" {
//fmt.Println(req.Form["daterange"])
if len(req.Form["daterange"]) == 1 && req.Form["daterange"][0] != "" {
daterange[table+"."+v.GetString("name")+"[>]"] = req.FormValue("daterange")
daterange[v.GetString("name")+"[>]"] = req.FormValue("daterange")
} else if len(req.Form["daterange"]) == 2 {
daterange[table+"."+v.GetString("name")+"[<>]"] = ObjToSlice(req.Form["daterange"])
daterange[v.GetString("name")+"[<>]"] = ObjToSlice(req.Form["daterange"])
}
}
if v.GetString("type") == "time" {
@@ -1007,12 +1156,12 @@ func (that *MakeCode) Search(table string, userData Map, req *http.Request, db *
if len(req.Form["daterange"]) == 1 && req.Form["daterange"][0] != "" {
t := time.Unix(ObjToCeilInt64(req.FormValue("daterange")), 0).Format("2006-01-02 15:04:05")
daterange[table+"."+v.GetString("name")+"[>]"] = t
daterange[v.GetString("name")+"[>]"] = t
} else if len(req.Form["daterange"]) == 2 {
t1 := time.Unix(ObjToCeilInt64(req.Form["daterange"][0]), 0).Format("2006-01-02 15:04:05")
t2 := time.Unix(ObjToCeilInt64(req.Form["daterange"][1]), 0).Format("2006-01-02 15:04:05")
daterange[table+"."+v.GetString("name")+"[<>]"] = Slice{t1, t2}
daterange[v.GetString("name")+"[<>]"] = Slice{t1, t2}
}
}
@@ -1022,38 +1171,40 @@ func (that *MakeCode) Search(table string, userData Map, req *http.Request, db *
if keywordTableStr != "" {
if keywordTableStr == v.GetString("name") {
data[table+"."+keywordTableStr+"[~]"] = keywordStr
data[keywordTableStr+"[~]"] = keywordStr
}
if keywordTableStr == v.GetString("value") {
childs := db.Select(v.GetString("link"), "id", Map{v.GetString("value") + "[~]": keywordStr})
childIds := Slice{}
for _, cv := range childs {
childIds = append(childIds, cv.GetString("id"))
childIds = append(childIds, cv.GetCeilInt64("id"))
}
if len(childIds) != 0 {
data[v.GetString("link")+".id"] = childIds
data[v.GetString("name")] = childIds
}
}
continue
}
if strings.Contains(v.GetString("type"), "text") {
keyword[table+"."+v.GetString("name")+"[~]"] = keywordStr
keyword[v.GetString("name")+"[~]"] = keywordStr
}
if v.GetString("name") == "id" {
keyword[table+"."+v.GetString("name")] = keywordStr
keyword[v.GetString("name")] = keywordStr
}
if v.GetString("link") != "" &&
v.GetString("name") != "parent_id" {
childs := db.Select(v.GetString("link"), "id", Map{v.GetString("value") + "[~]": keywordStr})
childIds := Slice{}
for _, cv := range childs {
childIds = append(childIds, cv.GetString("id"))
childIds = append(childIds, cv.GetCeilInt64("id"))
}
if len(childIds) != 0 {
keyword[v.GetString("link")+".id"] = childIds
keyword[v.GetString("name")] = childIds
}
}
@@ -1062,7 +1213,14 @@ func (that *MakeCode) Search(table string, userData Map, req *http.Request, db *
}
if len(reqValue) != 0 && searchItem.GetString("name") == "sort" && reqValue[0] != "" {
sortMap["ORDER"] = table + "." + 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
@@ -1073,7 +1231,7 @@ func (that *MakeCode) Search(table string, userData Map, req *http.Request, db *
parentID := ObjToInt(req.FormValue("parent_id"))
if parentID == 0 {
parentID = userData.GetCeilInt(table + "_id")
data["OR"] = Map{table + ".id": parentID, table + ".parent_id": nil}
data["OR"] = Map{"id": parentID, "parent_id": nil}
} else {
//是否展示全部子级
where := Map{}
@@ -1081,21 +1239,23 @@ func (that *MakeCode) Search(table string, userData Map, req *http.Request, db *
for _, v := range reqValue {
if len(where) == 0 {
where[table+"."+parent_idsStr] = "," + v + ","
where[parent_idsStr] = "," + v + ","
continue
}
where = Map{"OR": where, table + "." + parent_idsStr: "," + v + ","}
where = Map{"OR": where, parent_idsStr: "," + v + ","}
}
} else {
where[table+".parent_id"] = reqValue
where["parent_id"] = reqValue
}
//是否展示自己
if req.FormValue("showself") == "1" {
//是否展示自己:仅在 showall=1(选中节点及全部子孙)场景生效;
//普通子级懒加载(仅 parent_id=X)不得把 id=X 的行本身混入结果,
//否则前端树把节点当成自己的子级,造成无限嵌套
if req.FormValue("showself") == "1" && req.FormValue("showall") == "1" {
if len(where) == 0 {
data["OR"] = Map{table + ".id": reqValue}
data["OR"] = Map{"id": reqValue}
} else {
data["OR"] = Map{"OR": where, table + ".id": reqValue}
data["OR"] = Map{"OR": where, "id": reqValue}
}
} else {
if len(where) != 0 {
@@ -1111,26 +1271,37 @@ func (that *MakeCode) Search(table string, userData Map, req *http.Request, db *
if parent_idsStr != "" {
where := Map{}
//where := Map{}
//是否全部展示
if req.FormValue("showall") != "1" {
where[table+"."+searchItem.GetString("name")] = reqValue
if len(reqValue) != 0 {
data[searchItem.GetString("name")] = reqValue
}
} else {
ls := []int64{}
for _, v := range reqValue {
if len(where) == 0 {
where[searchItem.GetString("link")+"."+parent_idsStr] = "," + v + ","
continue
ls1 := db.Select(searchItem.GetString("link"), "id", Map{"OR": Map{parent_idsStr: "," + v + ",", "id": v}})
for _, vls1 := range ls1 {
ls = append(ls, vls1.GetCeilInt64("id"))
}
where = Map{"OR": where, searchItem.GetString("link") + "." + parent_idsStr: "," + v + ","}
}
if len(ls) != 0 {
data[searchItem.GetString("name")] = ls
}
}
//用户
if userData[searchItem.GetString("name")] != nil {
where = Map{"AND": Map{searchItem.GetString("link") + "." + parent_idsStr: "," + ObjToStr(userData.GetCeilInt64(searchItem.GetString("name"))) + ",", "OR": where}}
}
data["OR"] = where
//if userData[searchItem.GetString("name")] != nil {
//
// where = Map{"AND": Map{searchItem.GetString("link") + "." + parent_idsStr: "," + ObjToStr(userData.GetCeilInt64(searchItem.GetString("name"))) + ",", "OR": where}}
//}
//if len(where)!=0{
// data["OR"] = where
//}
continue
//r := db.Select(searchItem.GetString("link"), "id", where)
@@ -1142,16 +1313,21 @@ func (that *MakeCode) Search(table string, userData Map, req *http.Request, db *
}
if len(reqValue) != 0 && reqValue[0] != "" {
data[table+"."+searchItemName] = reqValue
if that.TableColumns[table][searchItemName] != nil {
data[searchItemName] = reqValue
}
}
}
if sortMap["ORDER"] == nil {
sortMap["ORDER"] = table + ".id DESC"
sortMap["ORDER"] = "id DESC"
}
where := Map{}
//if where==nil{
// where=Map{}
//}
if len(keyword) == 1 {
for k, v := range keyword {
@@ -1202,5 +1378,5 @@ func (that *MakeCode) Search(table string, userData Map, req *http.Request, db *
}
}
return reStr, leftJoin, where
return reStr, where
}
+1 -1
View File
@@ -8,7 +8,7 @@ type ContextBase struct {
tag string
}
//唯一标志
// 唯一标志
func (that *ContextBase) GetTag() string {
if that.tag == "" {
+48 -16
View File
@@ -1,27 +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
Logger *log.Logger
}
func (that *Error) GetError() error {
return that.error
}
func (that *Error) SetError(err error) {
that.error = err
if that.Logger != nil && err != nil {
//that.Logger=log.GetLog("",false)
that.Logger.Warn(err)
// 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 err != nil && logger != nil {
logger.Error().Err(err).Msg("")
}
return
}
+3 -3
View File
@@ -38,8 +38,8 @@ func StrFirstToUpper(str string) string {
}
// 时间转字符串,第二个参数支持1-5对应显示年月日时分秒
func Time2Str(t *time.Time, qu ...interface{}) string {
if t == nil || t.Unix() < 0 {
func Time2Str(t time.Time, qu ...interface{}) string {
if t.Unix() < 0 {
return ""
}
tp := 5
@@ -141,7 +141,7 @@ func Substr(str string, start int, length int) string {
}
// IndexLastStr 获取最后出现字符串的下标
//return 找不到返回 -1
// return 找不到返回 -1
func IndexLastStr(str, sep string) int {
sepSlice := []rune(sep)
strSlice := []rune(str)
+28 -15
View File
@@ -1,6 +1,7 @@
package common
import (
"bytes"
"encoding/json"
"errors"
"reflect"
@@ -8,10 +9,10 @@ import (
"time"
)
//hotime的常用map
// hotime的常用map
type Map map[string]interface{}
//获取string
// 获取string
func (that Map) GetString(key string, err ...*Error) string {
if len(err) != 0 {
@@ -26,7 +27,7 @@ func (that *Map) Pointer() *Map {
return that
}
//增加接口
// 增加接口
func (that Map) Put(key string, value interface{}) {
//if that==nil{
// that=Map{}
@@ -34,13 +35,13 @@ func (that Map) Put(key string, value interface{}) {
that[key] = value
}
//删除接口
// 删除接口
func (that Map) Delete(key string) {
delete(that, key)
}
//获取Int
// 获取Int
func (that Map) GetInt(key string, err ...*Error) int {
v := ObjToInt((that)[key], err...)
@@ -48,35 +49,35 @@ func (that Map) GetInt(key string, err ...*Error) int {
}
//获取Int
// 获取Int
func (that Map) GetInt64(key string, err ...*Error) int64 {
v := ObjToInt64((that)[key], err...)
return v
}
//获取向上取整Int64
// 获取向上取整Int64
func (that Map) GetCeilInt64(key string, err ...*Error) int64 {
v := ObjToCeilInt64((that)[key], err...)
return v
}
//获取向上取整Int
// 获取向上取整Int
func (that Map) GetCeilInt(key string, err ...*Error) int {
v := ObjToCeilInt((that)[key], err...)
return v
}
//获取向上取整float64
// 获取向上取整float64
func (that Map) GetCeilFloat64(key string, err ...*Error) float64 {
v := ObjToCeilFloat64((that)[key], err...)
return v
}
//获取Float64
// 获取Float64
func (that Map) GetFloat64(key string, err ...*Error) float64 {
v := ObjToFloat64((that)[key], err...)
@@ -149,7 +150,7 @@ func (that Map) Get(key string, err ...*Error) interface{} {
return nil
}
//请传递指针过来
// 请传递指针过来
func (that Map) ToStruct(stct interface{}) {
data := reflect.ValueOf(stct).Elem()
@@ -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...)
}
+8 -3
View File
@@ -2,7 +2,7 @@ package common
import "time"
//对象封装方便取用
// 对象封装方便取用
type Obj struct {
Data interface{}
Error
@@ -82,7 +82,7 @@ func (that *Obj) ToObj() interface{} {
return that.Data
}
//获取向上取整Int64
// 获取向上取整Int64
func (that *Obj) ToCeilInt64(err ...*Error) int64 {
if len(err) != 0 {
that.Error = *err[0]
@@ -92,7 +92,7 @@ func (that *Obj) ToCeilInt64(err ...*Error) int64 {
}
//获取向上取整Int
// 获取向上取整Int
func (that *Obj) ToCeilInt(err ...*Error) int {
if len(err) != 0 {
that.Error = *err[0]
@@ -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...)
}
+260 -114
View File
@@ -1,14 +1,16 @@
package common
import (
"bytes"
"encoding/json"
"errors"
"math"
"strconv"
"strings"
"time"
)
//仅限于hotime.Slice
// 仅限于hotime.Slice
func ObjToMap(obj interface{}, e ...*Error) Map {
var err error
var v Map
@@ -17,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 {
@@ -52,14 +63,14 @@ 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))
}
return res
}
//仅限于hotime.Slice
// 仅限于hotime.Slice
func ObjToSlice(obj interface{}, e ...*Error) Slice {
var err error
var v Slice
@@ -68,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("没有合适的转换对象!")
}
}
}
@@ -103,6 +128,23 @@ func ObjToTime(obj interface{}, e ...*Error) *time.Time {
//字符串类型,只支持标准mysql datetime格式
if tInt == 0 {
tStr := ObjToStr(obj)
// 先分离日期和时间部分,再对日期各段补零,避免时间中的数字被误处理
datePart := tStr
timePart := ""
if idx := strings.Index(tStr, " "); idx > 0 {
datePart = tStr[:idx]
timePart = tStr[idx:] // 含前导空格
}
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)
@@ -130,27 +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
}
@@ -164,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("没有合适的转换对象!")
}
}
@@ -214,25 +276,33 @@ func ObjToFloat64(obj interface{}, e ...*Error) float64 {
return v
}
//向上取整
func ObjToCeilInt64(obj interface{}, e ...*Error) int64 {
f := ObjToCeilFloat64(obj, e...)
return ObjToInt64(math.Ceil(f))
// 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 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 {
@@ -240,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("没有合适的转换对象!")
}
}
@@ -289,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 {
@@ -310,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 {
// 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(string)
data.JsonToMap(s)
return data
}
//转换为Slice
func StrToSlice(string string) Slice {
data := ObjToSlice(string)
return data
// 转换为Slice
func StrToSlice(s string) Slice {
return ObjToSlice(s)
}
//字符串数组: a1,a2,a3转["a1","a2","a3"]
// 字符串数组: 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)
@@ -366,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 = "[]"
@@ -374,19 +523,16 @@ 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 + ","
}
//字符串转int
// 字符串转int
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
+140 -20
View File
@@ -1,19 +1,25 @@
package hotime
import (
"bytes"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"sync"
"time"
. "code.hoteas.com/golang/hotime/common"
. "code.hoteas.com/golang/hotime/db"
"encoding/json"
"net/http"
"strings"
"time"
htlog "code.hoteas.com/golang/hotime/log"
)
type Context struct {
*Application
Resp http.ResponseWriter
Req *http.Request
Log Map //日志有则创建
Log Map //业务 logs 表字段(有则写入),与 Logger 不同
Logger *htlog.Logger // 请求级日志(带 sid/request_id),业务与 SQL 共用
RouterString []string
Config Map
Db *HoTimeDB
@@ -23,6 +29,10 @@ type Context struct {
SessionIns
DataSize int
HandlerStr string //复写请求url
// 请求参数缓存
reqJsonCache Map // JSON Body 缓存
reqMu sync.Once // 确保 JSON 只解析一次
}
// Mtd 唯一标志
@@ -33,7 +43,7 @@ func (that *Context) Mtd(router [3]string) Map {
return d
}
//打印
// 打印
func (that *Context) Display(statu int, data interface{}) {
resp := Map{"status": statu}
@@ -50,6 +60,9 @@ func (that *Context) Display(statu int, data interface{}) {
resp["result"] = temp
//兼容android等需要json转对象的服务
resp["error"] = temp
that.reqLogger().Warn().Int("status", statu).Msg(resp.ToJsonString())
} else {
resp["result"] = data
}
@@ -59,6 +72,33 @@ func (that *Context) Display(statu int, data interface{}) {
//that.Data=d;
}
// reqLogger 返回请求级 Loggernil 时回退 Application.Log
func (that *Context) reqLogger() *htlog.Logger {
if that.Logger != nil {
return that.Logger
}
if that.Application != nil {
return that.Application.Log
}
return nil
}
// LogBind 为本请求追加自定义日志字段(如 user_id),后续业务日志与 SQL 日志均携带。
// 供 main.go 的 connectListener 调用;nil 安全。
func (that *Context) LogBind(key string, value interface{}) {
if that == nil || key == "" {
return
}
base := that.reqLogger()
if base == nil {
return
}
that.Logger = base.WithFields(key, ObjToStr(value))
if that.Db != nil {
that.Db.Log = that.Logger
}
}
func (that *Context) View() {
if that.RespFunc != nil {
that.RespFunc()
@@ -75,19 +115,7 @@ func (that *Context) View() {
if that.Session("user_id").Data != nil {
that.Log["user_id"] = that.Session("user_id").ToCeilInt()
}
//负载均衡优化
ipStr := ""
if that.Req.Header.Get("X-Forwarded-For") != "" {
ipStr = that.Req.Header.Get("X-Forwarded-For")
} else if that.Req.Header.Get("X-Real-IP") != "" {
ipStr = that.Req.Header.Get("X-Real-IP")
}
//负载均衡优化
if ipStr == "" {
//RemoteAddr := that.Req.RemoteAddr
ipStr = Substr(that.Req.RemoteAddr, 0, strings.Index(that.Req.RemoteAddr, ":"))
}
that.Log["ip"] = ipStr
that.Log["ip"] = clientIP(that.Req)
that.Db.Insert("logs", that.Log)
}
@@ -100,5 +128,97 @@ func (that *Context) View() {
that.DataSize = len(d)
that.RespData = nil
that.Resp.Write(d)
return
}
// ==================== 请求参数获取方法 ====================
// ReqParam 获取 URL 查询参数,返回 *Obj 支持链式调用
// 用法: that.ReqParam("id").ToInt()
func (that *Context) ReqParam(key string) *Obj {
v := that.Req.URL.Query().Get(key)
if v == "" {
return &Obj{Data: nil}
}
return &Obj{Data: v}
}
// ReqForm 获取表单参数,返回 *Obj 支持链式调用
// 用法: that.ReqForm("name").ToStr()
func (that *Context) ReqForm(key string) *Obj {
v := that.Req.FormValue(key)
if v == "" {
return &Obj{Data: nil}
}
return &Obj{Data: v}
}
// ReqJson 获取 JSON Body 中的字段,返回 *Obj 支持链式调用
// 用法: that.ReqJson("data").ToMap()
func (that *Context) ReqJson(key string) *Obj {
that.parseJsonBody()
if that.reqJsonCache == nil {
return &Obj{Data: nil}
}
return &Obj{Data: that.reqJsonCache.Get(key)}
}
// parseJsonBody 解析 JSON Body(只解析一次,并发安全)
func (that *Context) parseJsonBody() {
that.reqMu.Do(func() {
if that.Req.Body == nil {
return
}
body, err := io.ReadAll(that.Req.Body)
if err != nil || len(body) == 0 {
return
}
// 恢复 Body 以便后续代码可以再次读取
that.Req.Body = io.NopCloser(bytes.NewBuffer(body))
that.reqJsonCache = ObjToMap(string(body))
})
}
// ReqData 统一获取请求参数,优先级: JSON > Form > URL
// 用法: that.ReqData("id").ToInt()
func (that *Context) ReqData(key string) *Obj {
// 1. 优先从 JSON Body 获取
that.parseJsonBody()
if that.reqJsonCache != nil {
if v := that.reqJsonCache.Get(key); v != nil {
return &Obj{Data: v}
}
}
// 2. 其次从 Form 获取
if v := that.Req.FormValue(key); v != "" {
return &Obj{Data: v}
}
// 3. 最后从 URL 参数获取
if v := that.Req.URL.Query().Get(key); v != "" {
return &Obj{Data: v}
}
return &Obj{Data: nil}
}
// ReqFile 获取单个上传文件
// 用法: file, header, err := that.ReqFile("avatar")
func (that *Context) ReqFile(name string) (multipart.File, *multipart.FileHeader, error) {
return that.Req.FormFile(name)
}
// ReqFiles 获取多个同名上传文件(批量上传场景)
// 用法: files, err := that.ReqFiles("images")
func (that *Context) ReqFiles(name string) ([]*multipart.FileHeader, error) {
if that.Req.MultipartForm == nil {
if err := that.Req.ParseMultipartForm(32 << 20); err != nil {
return nil, err
}
}
if that.Req.MultipartForm == nil || that.Req.MultipartForm.File == nil {
return nil, http.ErrMissingFile
}
files, ok := that.Req.MultipartForm.File[name]
if !ok {
return nil, http.ErrMissingFile
}
return files, nil
}
+115
View File
@@ -0,0 +1,115 @@
package db
import (
. "code.hoteas.com/golang/hotime/common"
)
// Count 计数
func (that *HoTimeDB) Count(table string, qu ...interface{}) int {
var req = []interface{}{}
if len(qu) == 2 {
req = append(req, qu[0])
req = append(req, "COUNT(*)")
req = append(req, qu[1])
} else {
req = append(req, "COUNT(*)")
req = append(req, qu...)
}
data := that.Select(table, req...)
if len(data) == 0 {
return 0
}
res := ObjToStr(data[0]["COUNT(*)"])
count, _ := StrToInt(res)
return count
}
// Sum 求和
func (that *HoTimeDB) Sum(table string, column string, qu ...interface{}) float64 {
var req = []interface{}{}
if len(qu) == 2 {
req = append(req, qu[0])
req = append(req, "SUM("+column+")")
req = append(req, qu[1])
} else {
req = append(req, "SUM("+column+")")
req = append(req, qu...)
}
data := that.Select(table, req...)
if len(data) == 0 {
return 0
}
res := ObjToStr(data[0]["SUM("+column+")"])
sum := ObjToFloat64(res)
return sum
}
// Avg 平均值
func (that *HoTimeDB) Avg(table string, column string, qu ...interface{}) float64 {
var req = []interface{}{}
if len(qu) == 2 {
req = append(req, qu[0])
req = append(req, "AVG("+column+")")
req = append(req, qu[1])
} else {
req = append(req, "AVG("+column+")")
req = append(req, qu...)
}
data := that.Select(table, req...)
if len(data) == 0 {
return 0
}
res := ObjToStr(data[0]["AVG("+column+")"])
avg := ObjToFloat64(res)
return avg
}
// Max 最大值
func (that *HoTimeDB) Max(table string, column string, qu ...interface{}) float64 {
var req = []interface{}{}
if len(qu) == 2 {
req = append(req, qu[0])
req = append(req, "MAX("+column+")")
req = append(req, qu[1])
} else {
req = append(req, "MAX("+column+")")
req = append(req, qu...)
}
data := that.Select(table, req...)
if len(data) == 0 {
return 0
}
res := ObjToStr(data[0]["MAX("+column+")"])
max := ObjToFloat64(res)
return max
}
// Min 最小值
func (that *HoTimeDB) Min(table string, column string, qu ...interface{}) float64 {
var req = []interface{}{}
if len(qu) == 2 {
req = append(req, qu[0])
req = append(req, "MIN("+column+")")
req = append(req, qu[1])
} else {
req = append(req, "MIN("+column+")")
req = append(req, qu...)
}
data := that.Select(table, req...)
if len(data) == 0 {
return 0
}
res := ObjToStr(data[0]["MIN("+column+")"])
min := ObjToFloat64(res)
return min
}
+151
View File
@@ -0,0 +1,151 @@
package db
import (
. "code.hoteas.com/golang/hotime/common"
"os"
"strings"
)
// backupSave 保存备份
// path: 备份文件路径
// tt: 表名
// code: 备份类型 0=全部, 1=仅数据, 2=仅DDL
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 " + q + tt + q + "\r\n\r\n("
str += that.backupCol(tt)
}
_, _ = fd.Write([]byte(str))
}
// backupDdl 备份表结构(DDL
func (that *HoTimeDB) backupDdl(tt string) string {
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 ""
}
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, "*")
lthData := len(data)
if lthData == 0 {
return str
}
lthCol := len(data[0])
col := make([]string, lthCol)
tempLthData := 0
for k := range data[0] {
if tempLthData == lthCol-1 {
str += q + k + q + ") "
} else {
str += q + k + q + ","
}
col[tempLthData] = k
tempLthData++
}
str += " values"
for j := 0; j < lthData; j++ {
for m := 0; m < lthCol; m++ {
if m == 0 {
str += "("
}
v := "NULL"
if data[j][col[m]] != nil {
v = "'" + strings.Replace(ObjToStr(data[j][col[m]]), "'", `\'`, -1) + "'"
}
if m == lthCol-1 {
str += v + ") "
} else {
str += v + ","
}
}
if j == lthData-1 {
str += ";\r\n\r\n"
} else {
str += ",\r\n\r\n"
}
}
return str
}
+312
View File
@@ -0,0 +1,312 @@
package db
import (
. "code.hoteas.com/golang/hotime/common"
)
// HotimeDBBuilder 链式查询构建器
type HotimeDBBuilder struct {
HoTimeDB *HoTimeDB
table string
selects []interface{}
join Slice
where Map
lastWhere Map
page int
pageRow int
}
// Table 创建链式查询构建器
func (that *HoTimeDB) Table(table string) *HotimeDBBuilder {
return &HotimeDBBuilder{HoTimeDB: that, table: table, where: Map{}}
}
// Get 获取单条记录
func (that *HotimeDBBuilder) Get(qu ...interface{}) Map {
// 构建参数:根据是否有 JOIN 来决定参数结构
var args []interface{}
if len(that.join) > 0 {
// 有 JOIN 时:join, fields, where
if len(qu) > 0 {
args = append(args, that.join, qu[0], that.where)
} else {
args = append(args, that.join, "*", that.where)
}
} else {
// 无 JOIN 时:fields, where
if len(qu) > 0 {
args = append(args, qu[0], that.where)
} else {
args = append(args, "*", that.where)
}
}
return that.HoTimeDB.Get(that.table, args...)
}
// Count 统计数量
func (that *HotimeDBBuilder) Count() int {
// 构建参数:根据是否有 JOIN 来决定参数结构
if len(that.join) > 0 {
return that.HoTimeDB.Count(that.table, that.join, that.where)
}
return that.HoTimeDB.Count(that.table, that.where)
}
// Page 设置分页
func (that *HotimeDBBuilder) Page(page, pageRow int) *HotimeDBBuilder {
that.page = page
that.pageRow = pageRow
return that
}
// Select 查询多条记录
func (that *HotimeDBBuilder) Select(qu ...interface{}) []Map {
// 构建参数:根据是否有 JOIN 来决定参数结构
var args []interface{}
if len(that.join) > 0 {
// 有 JOIN 时:join, fields, where
if len(qu) > 0 {
args = append(args, that.join, qu[0], that.where)
} else {
args = append(args, that.join, "*", that.where)
}
} else {
// 无 JOIN 时:fields, where
if len(qu) > 0 {
args = append(args, qu[0], that.where)
} else {
args = append(args, "*", that.where)
}
}
if that.page != 0 {
return that.HoTimeDB.Page(that.page, that.pageRow).PageSelect(that.table, args...)
}
return that.HoTimeDB.Select(that.table, args...)
}
// Update 更新记录
func (that *HotimeDBBuilder) Update(qu ...interface{}) int64 {
lth := len(qu)
if lth == 0 {
return 0
}
data := Map{}
if lth == 1 {
data = ObjToMap(qu[0])
}
if lth > 1 {
for k := 1; k < lth; k++ {
data[ObjToStr(qu[k-1])] = qu[k]
k++
}
}
return that.HoTimeDB.Update(that.table, data, that.where)
}
// Delete 删除记录
func (that *HotimeDBBuilder) Delete() int64 {
return that.HoTimeDB.Delete(that.table, that.where)
}
// LeftJoin 左连接
func (that *HotimeDBBuilder) LeftJoin(table, joinStr string) *HotimeDBBuilder {
that.Join(Map{"[>]" + table: joinStr})
return that
}
// RightJoin 右连接
func (that *HotimeDBBuilder) RightJoin(table, joinStr string) *HotimeDBBuilder {
that.Join(Map{"[<]" + table: joinStr})
return that
}
// InnerJoin 内连接
func (that *HotimeDBBuilder) InnerJoin(table, joinStr string) *HotimeDBBuilder {
that.Join(Map{"[><]" + table: joinStr})
return that
}
// FullJoin 全连接
func (that *HotimeDBBuilder) FullJoin(table, joinStr string) *HotimeDBBuilder {
that.Join(Map{"[<>]" + table: joinStr})
return that
}
// Join 通用连接
func (that *HotimeDBBuilder) Join(qu ...interface{}) *HotimeDBBuilder {
lth := len(qu)
if lth == 0 {
return that
}
data := Map{}
if lth == 1 {
data = ObjToMap(qu[0])
}
if lth > 1 {
for k := 1; k < lth; k++ {
data[ObjToStr(qu[k-1])] = qu[k]
k++
}
}
if that.join == nil {
that.join = Slice{}
}
if data == nil {
return that
}
that.join = append(that.join, data)
return that
}
// And 添加 AND 条件
func (that *HotimeDBBuilder) And(qu ...interface{}) *HotimeDBBuilder {
lth := len(qu)
if lth == 0 {
return that
}
var where Map
if lth == 1 {
where = ObjToMap(qu[0])
}
if lth > 1 {
where = Map{}
for k := 1; k < lth; k++ {
where[ObjToStr(qu[k-1])] = qu[k]
k++
}
}
if where == nil {
return that
}
if that.lastWhere != nil {
that.lastWhere["AND"] = where
that.lastWhere = where
return that
}
that.lastWhere = where
that.where = Map{"AND": where}
return that
}
// Or 添加 OR 条件
func (that *HotimeDBBuilder) Or(qu ...interface{}) *HotimeDBBuilder {
lth := len(qu)
if lth == 0 {
return that
}
var where Map
if lth == 1 {
where = ObjToMap(qu[0])
}
if lth > 1 {
where = Map{}
for k := 1; k < lth; k++ {
where[ObjToStr(qu[k-1])] = qu[k]
k++
}
}
if where == nil {
return that
}
if that.lastWhere != nil {
that.lastWhere["OR"] = where
that.lastWhere = where
return that
}
that.lastWhere = where
that.where = Map{"Or": where}
return that
}
// Where 设置 WHERE 条件
func (that *HotimeDBBuilder) Where(qu ...interface{}) *HotimeDBBuilder {
lth := len(qu)
if lth == 0 {
return that
}
var where Map
if lth == 1 {
where = ObjToMap(qu[0])
}
if lth > 1 {
where = Map{}
for k := 1; k < lth; k++ {
where[ObjToStr(qu[k-1])] = qu[k]
k++
}
}
if where == nil {
return that
}
if that.lastWhere != nil {
that.lastWhere["AND"] = where
that.lastWhere = where
return that
}
that.lastWhere = where
that.where = Map{"AND": that.lastWhere}
return that
}
// From 设置表名
func (that *HotimeDBBuilder) From(table string) *HotimeDBBuilder {
that.table = table
return that
}
// Order 设置排序
func (that *HotimeDBBuilder) Order(qu ...interface{}) *HotimeDBBuilder {
that.where["ORDER"] = ObjToSlice(qu)
return that
}
// Limit 设置限制
func (that *HotimeDBBuilder) Limit(qu ...interface{}) *HotimeDBBuilder {
that.where["LIMIT"] = ObjToSlice(qu)
return that
}
// Group 设置分组
func (that *HotimeDBBuilder) Group(qu ...interface{}) *HotimeDBBuilder {
that.where["GROUP"] = ObjToSlice(qu)
return that
}
// Having 设置 HAVING 条件
func (that *HotimeDBBuilder) Having(qu ...interface{}) *HotimeDBBuilder {
lth := len(qu)
if lth == 0 {
return that
}
var having Map
if lth == 1 {
having = ObjToMap(qu[0])
}
if lth > 1 {
having = Map{}
for k := 1; k < lth; k++ {
having[ObjToStr(qu[k-1])] = qu[k]
k++
}
}
that.where["HAVING"] = having
return that
}
// Offset 设置偏移量
func (that *HotimeDBBuilder) Offset(offset int) *HotimeDBBuilder {
that.where["OFFSET"] = offset
return that
}
+740
View File
@@ -0,0 +1,740 @@
package db
import (
"reflect"
"sort"
"strings"
. "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: 每页数量
func (that *HoTimeDB) Page(page, pageRow int) *HoTimeDB {
if page < 1 {
page = 1
}
if pageRow < 1 {
pageRow = 10
}
offset := (page - 1) * pageRow
that.limitMu.Lock()
that.limit = Slice{offset, pageRow}
that.limitMu.Unlock()
return that
}
// PageSelect 分页查询
func (that *HoTimeDB) PageSelect(table string, qu ...interface{}) []Map {
that.limitMu.Lock()
limit := that.limit
that.limit = nil // 使用后清空,避免影响下次调用
that.limitMu.Unlock()
if limit == nil {
return that.Select(table, qu...)
}
// 根据参数数量处理 LIMIT 注入
switch len(qu) {
case 0:
// PageSelect("user") -> 只有表名,添加 LIMIT
qu = append(qu, "*", Map{"LIMIT": limit})
case 1:
// PageSelect("user", "*") 或 PageSelect("user", Map{...})
if reflect.ValueOf(qu[0]).Kind() == reflect.Map {
// 是 where 条件
temp := DeepCopyMap(qu[0]).(Map)
temp["LIMIT"] = limit
qu[0] = temp
} else {
// 是字段选择
qu = append(qu, Map{"LIMIT": limit})
}
case 2:
// PageSelect("user", "*", Map{...}) 或 PageSelect("user", joinSlice, "*")
if reflect.ValueOf(qu[1]).Kind() == reflect.Map {
temp := DeepCopyMap(qu[1]).(Map)
temp["LIMIT"] = limit
qu[1] = temp
} else {
// join 模式,需要追加 where
qu = append(qu, Map{"LIMIT": limit})
}
case 3:
// PageSelect("user", joinSlice, "*", Map{...})
temp := DeepCopyMap(qu[2]).(Map)
temp["LIMIT"] = limit
qu[2] = temp
}
return that.Select(table, qu...)
}
// Select 查询多条记录
func (that *HoTimeDB) Select(table string, qu ...interface{}) []Map {
query := "SELECT"
where := Map{}
qs := make([]interface{}, 0)
intQs, intWhere := 0, 1
join := false
if len(qu) == 3 {
intQs = 1
intWhere = 2
join = true
}
processor := that.GetProcessor()
if len(qu) > 0 {
if reflect.ValueOf(qu[intQs]).Type().String() == "string" {
// 字段列表字符串,使用处理器处理 table.column 格式
fieldStr := qu[intQs].(string)
if fieldStr != "*" {
fieldStr = processor.ProcessFieldList(fieldStr)
}
query += " " + fieldStr
} else {
data := ObjToSlice(qu[intQs])
for i := 0; i < len(data); i++ {
k := data.GetString(i)
if strings.Contains(k, " AS ") || strings.Contains(k, ".") {
// 处理 table.column 格式
query += " " + processor.ProcessFieldList(k) + " "
} else {
// 单独的列名
query += " " + processor.ProcessColumnNoPrefix(k) + " "
}
if i+1 != len(data) {
query = query + ", "
}
}
}
} else {
query += " *"
}
// 处理表名(添加前缀和正确的引号)
query += " FROM " + processor.ProcessTableName(table) + " "
if join {
query += that.buildJoin(qu[0])
}
if len(qu) > 1 {
where = qu[intWhere].(Map)
}
temp, resWhere := that.where(where)
query += temp + ";"
qs = append(qs, resWhere...)
md5 := that.md5(query, qs...)
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.testTx == nil && that.HoTimeCache != nil && !that.isCacheTable(table) {
_ = that.HoTimeCache.Db(table+":"+md5, res)
}
return res
}
// buildJoin 构建 JOIN 语句
func (that *HoTimeDB) buildJoin(joinData interface{}) string {
query := ""
var testQu = []string{}
testQuData := Map{}
processor := that.GetProcessor()
if reflect.ValueOf(joinData).Type().String() == "common.Map" {
testQuData = joinData.(Map)
for key := range testQuData {
testQu = append(testQu, key)
}
}
if reflect.ValueOf(joinData).Type().String() == "common.Slice" || strings.Contains(reflect.ValueOf(joinData).Type().String(), "[]") {
qu0 := ObjToSlice(joinData)
for key := range qu0 {
v := qu0.GetMap(key)
for k1, v1 := range v {
testQu = append(testQu, k1)
testQuData[k1] = v1
}
}
}
sort.Strings(testQu)
for _, k := range testQu {
v := testQuData[k]
switch Substr(k, 0, 3) {
case "[>]":
func() {
table := Substr(k, 3, len(k)-3)
// 处理表名(添加前缀和正确的引号)
table = processor.ProcessTableName(table)
// 处理 ON 条件中的 table.column
onCondition := processor.ProcessConditionString(v.(string))
query += " LEFT JOIN " + table + " ON " + onCondition + " "
}()
case "[<]":
func() {
table := Substr(k, 3, len(k)-3)
table = processor.ProcessTableName(table)
onCondition := processor.ProcessConditionString(v.(string))
query += " RIGHT JOIN " + table + " ON " + onCondition + " "
}()
}
switch Substr(k, 0, 4) {
case "[<>]":
func() {
table := Substr(k, 4, len(k)-4)
table = processor.ProcessTableName(table)
onCondition := processor.ProcessConditionString(v.(string))
query += " FULL JOIN " + table + " ON " + onCondition + " "
}()
case "[><]":
func() {
table := Substr(k, 4, len(k)-4)
table = processor.ProcessTableName(table)
onCondition := processor.ProcessConditionString(v.(string))
query += " INNER JOIN " + table + " ON " + onCondition + " "
}()
}
}
return query
}
// Get 获取单条记录
func (that *HoTimeDB) Get(table string, qu ...interface{}) Map {
if len(qu) == 0 {
// 没有参数时,添加默认字段和 LIMIT
qu = append(qu, "*", Map{"LIMIT": 1})
} else if len(qu) == 1 {
qu = append(qu, Map{"LIMIT": 1})
} else if len(qu) == 2 {
temp := qu[1].(Map)
temp["LIMIT"] = 1
qu[1] = temp
} else if len(qu) == 3 {
temp := qu[2].(Map)
temp["LIMIT"] = 1
qu[2] = temp
}
data := that.Select(table, qu...)
if len(data) == 0 {
return nil
}
return data[0]
}
// Insert 插入新数据
func (that *HoTimeDB) Insert(table string, data map[string]interface{}) int64 {
values := make([]interface{}, 0)
queryString := " ("
valueString := " ("
processor := that.GetProcessor()
lens := len(data)
tempLen := 0
for k, v := range data {
tempLen++
vstr := "?"
if Substr(k, len(k)-3, 3) == "[#]" {
k = strings.Replace(k, "[#]", "", -1)
vstr = ObjToStr(v)
if tempLen < lens {
queryString += processor.ProcessColumnNoPrefix(k) + ","
valueString += vstr + ","
} else {
queryString += processor.ProcessColumnNoPrefix(k) + ") "
valueString += vstr + ");"
}
} else {
values = append(values, v)
if tempLen < lens {
queryString += processor.ProcessColumnNoPrefix(k) + ","
valueString += "?,"
} else {
queryString += processor.ProcessColumnNoPrefix(k) + ") "
valueString += "?);"
}
}
}
query := "INSERT INTO " + processor.ProcessTableName(table) + " " + queryString + "VALUES" + valueString
id := int64(0)
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 && !that.isCacheTable(table) {
_ = that.HoTimeCache.Db(table+"*", nil)
}
}
return id
}
// Inserts 批量插入数据
// table: 表名
// dataList: 数据列表,每个元素是一个 Map
// 返回受影响的行数
//
// 示例:
//
// affected := db.Inserts("user", []Map{
// {"name": "张三", "age": 25, "email": "zhang@example.com"},
// {"name": "李四", "age": 30, "email": "li@example.com"},
// {"name": "王五", "age": 28, "email": "wang@example.com"},
// })
func (that *HoTimeDB) Inserts(table string, dataList []Map) int64 {
if len(dataList) == 0 {
return 0
}
// 从第一条数据提取所有列名(确保顺序一致)
columns := make([]string, 0)
rawValues := make(map[string]string) // 存储 [#] 标记的直接 SQL 值
for k := range dataList[0] {
realKey := k
if Substr(k, len(k)-3, 3) == "[#]" {
realKey = strings.Replace(k, "[#]", "", -1)
rawValues[realKey] = ObjToStr(dataList[0][k])
}
columns = append(columns, realKey)
}
// 排序列名以确保一致性
sort.Strings(columns)
processor := that.GetProcessor()
// 构建列名部分
quotedCols := make([]string, len(columns))
for i, col := range columns {
quotedCols[i] = processor.ProcessColumnNoPrefix(col)
}
colStr := strings.Join(quotedCols, ", ")
// 构建每行的占位符和值
placeholders := make([]string, len(dataList))
values := make([]interface{}, 0, len(dataList)*len(columns))
for i, data := range dataList {
rowPlaceholders := make([]string, len(columns))
for j, col := range columns {
// 检查是否有 [#] 标记
rawKey := col + "[#]"
if rawVal, ok := data[rawKey]; ok {
// 直接 SQL 表达式
rowPlaceholders[j] = ObjToStr(rawVal)
} else if _, isRaw := rawValues[col]; isRaw && i == 0 {
// 第一条数据中的 [#] 标记
rowPlaceholders[j] = rawValues[col]
} else if val, ok := data[col]; ok {
// 普通值
rowPlaceholders[j] = "?"
values = append(values, val)
} else {
// 字段不存在,使用 NULL
rowPlaceholders[j] = "NULL"
}
}
placeholders[i] = "(" + strings.Join(rowPlaceholders, ", ") + ")"
}
query := "INSERT INTO " + processor.ProcessTableName(table) + " (" + colStr + ") VALUES " + strings.Join(placeholders, ", ")
res, err := that.Exec(query, values...)
rows64 := int64(0)
if err == nil && res != nil {
rows64, _ = res.RowsAffected()
}
// 如果插入成功,删除缓存
if rows64 != 0 {
if that.HoTimeCache != nil && !that.isCacheTable(table) {
_ = that.HoTimeCache.Db(table+"*", nil)
}
}
return rows64
}
// Upsert 插入或更新数据
// table: 表名
// data: 要插入的数据
// uniqueKeys: 唯一键字段(用于冲突检测),支持 Slice{"id"} 或 Slice{"col1", "col2"}
// updateColumns: 冲突时要更新的字段(如果为空,则更新所有非唯一键字段)
// 返回受影响的行数
//
// 示例:
//
// affected := db.Upsert("user",
// Map{"id": 1, "name": "张三", "email": "zhang@example.com"},
// Slice{"id"}, // 唯一键
// Slice{"name", "email"}, // 冲突时更新的字段
// )
func (that *HoTimeDB) Upsert(table string, data Map, uniqueKeys Slice, updateColumns ...interface{}) int64 {
if len(data) == 0 || len(uniqueKeys) == 0 {
return 0
}
// 转换 uniqueKeys 为 []string
uniqueKeyStrs := make([]string, len(uniqueKeys))
for i, uk := range uniqueKeys {
uniqueKeyStrs[i] = ObjToStr(uk)
}
// 转换 updateColumns 为 []string
var updateColumnStrs []string
if len(updateColumns) > 0 {
// 支持两种调用方式:Upsert(table, data, Slice{"id"}, Slice{"name"}) 或 Upsert(table, data, Slice{"id"}, "name", "email")
if slice, ok := updateColumns[0].(Slice); ok {
updateColumnStrs = make([]string, len(slice))
for i, col := range slice {
updateColumnStrs[i] = ObjToStr(col)
}
} else {
updateColumnStrs = make([]string, len(updateColumns))
for i, col := range updateColumns {
updateColumnStrs[i] = ObjToStr(col)
}
}
}
// 收集列和值
columns := make([]string, 0, len(data))
values := make([]interface{}, 0, len(data))
rawValues := make(map[string]string) // 存储 [#] 标记的直接 SQL 值
for k, v := range data {
if Substr(k, len(k)-3, 3) == "[#]" {
realKey := strings.Replace(k, "[#]", "", -1)
columns = append(columns, realKey)
rawValues[realKey] = ObjToStr(v)
} else {
columns = append(columns, k)
values = append(values, v)
}
}
// 如果没有指定更新字段,则更新所有非唯一键字段
if len(updateColumnStrs) == 0 {
uniqueKeySet := make(map[string]bool)
for _, uk := range uniqueKeyStrs {
uniqueKeySet[uk] = true
}
for _, col := range columns {
if !uniqueKeySet[col] {
updateColumnStrs = append(updateColumnStrs, col)
}
}
}
// 构建 SQL
var query string
dbType := that.Type
if dbType == "" {
dbType = "mysql"
}
switch dbType {
case "postgres", "postgresql":
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)
}
res, err := that.Exec(query, values...)
rows := int64(0)
if err == nil && res != nil {
rows, _ = res.RowsAffected()
}
// 清除缓存
if rows != 0 {
if that.HoTimeCache != nil && !that.isCacheTable(table) {
_ = that.HoTimeCache.Db(table+"*", nil)
}
}
return rows
}
// buildMySQLUpsert 构建 MySQL 的 Upsert 语句
func (that *HoTimeDB) buildMySQLUpsert(table string, columns []string, uniqueKeys []string, updateColumns []string, rawValues map[string]string) string {
// INSERT INTO table (col1, col2) VALUES (?, ?)
// ON DUPLICATE KEY UPDATE col1 = VALUES(col1), col2 = VALUES(col2)
processor := that.GetProcessor()
quotedCols := make([]string, len(columns))
valueParts := make([]string, len(columns))
for i, col := range columns {
quotedCols[i] = processor.ProcessColumnNoPrefix(col)
if raw, ok := rawValues[col]; ok {
valueParts[i] = raw
} else {
valueParts[i] = "?"
}
}
updateParts := make([]string, len(updateColumns))
for i, col := range updateColumns {
quotedCol := processor.ProcessColumnNoPrefix(col)
if raw, ok := rawValues[col]; ok {
updateParts[i] = quotedCol + " = " + raw
} else {
updateParts[i] = quotedCol + " = VALUES(" + quotedCol + ")"
}
}
return "INSERT INTO " + processor.ProcessTableName(table) + " (" + strings.Join(quotedCols, ", ") +
") VALUES (" + strings.Join(valueParts, ", ") +
") ON DUPLICATE KEY UPDATE " + strings.Join(updateParts, ", ")
}
// buildPostgresUpsert 构建 PostgreSQL 的 Upsert 语句
func (that *HoTimeDB) buildPostgresUpsert(table string, columns []string, uniqueKeys []string, updateColumns []string, rawValues map[string]string) string {
// INSERT INTO table (col1, col2) VALUES ($1, $2)
// ON CONFLICT (unique_key) DO UPDATE SET col1 = EXCLUDED.col1
processor := that.GetProcessor()
dialect := that.GetDialect()
quotedCols := make([]string, len(columns))
valueParts := make([]string, len(columns))
paramIndex := 1
for i, col := range columns {
quotedCols[i] = dialect.QuoteIdentifier(col)
if raw, ok := rawValues[col]; ok {
valueParts[i] = raw
} else {
valueParts[i] = "$" + ObjToStr(paramIndex)
paramIndex++
}
}
quotedUniqueKeys := make([]string, len(uniqueKeys))
for i, key := range uniqueKeys {
quotedUniqueKeys[i] = dialect.QuoteIdentifier(key)
}
updateParts := make([]string, len(updateColumns))
for i, col := range updateColumns {
quotedCol := dialect.QuoteIdentifier(col)
if raw, ok := rawValues[col]; ok {
updateParts[i] = quotedCol + " = " + raw
} else {
updateParts[i] = quotedCol + " = EXCLUDED." + quotedCol
}
}
return "INSERT INTO " + processor.ProcessTableName(table) + " (" + strings.Join(quotedCols, ", ") +
") VALUES (" + strings.Join(valueParts, ", ") +
") ON CONFLICT (" + strings.Join(quotedUniqueKeys, ", ") +
") DO UPDATE SET " + strings.Join(updateParts, ", ")
}
// buildSQLiteUpsert 构建 SQLite 的 Upsert 语句
func (that *HoTimeDB) buildSQLiteUpsert(table string, columns []string, uniqueKeys []string, updateColumns []string, rawValues map[string]string) string {
// INSERT INTO table (col1, col2) VALUES (?, ?)
// ON CONFLICT (unique_key) DO UPDATE SET col1 = excluded.col1
processor := that.GetProcessor()
dialect := that.GetDialect()
quotedCols := make([]string, len(columns))
valueParts := make([]string, len(columns))
for i, col := range columns {
quotedCols[i] = dialect.QuoteIdentifier(col)
if raw, ok := rawValues[col]; ok {
valueParts[i] = raw
} else {
valueParts[i] = "?"
}
}
quotedUniqueKeys := make([]string, len(uniqueKeys))
for i, key := range uniqueKeys {
quotedUniqueKeys[i] = dialect.QuoteIdentifier(key)
}
updateParts := make([]string, len(updateColumns))
for i, col := range updateColumns {
quotedCol := dialect.QuoteIdentifier(col)
if raw, ok := rawValues[col]; ok {
updateParts[i] = quotedCol + " = " + raw
} else {
updateParts[i] = quotedCol + " = excluded." + quotedCol
}
}
return "INSERT INTO " + processor.ProcessTableName(table) + " (" + strings.Join(quotedCols, ", ") +
") VALUES (" + strings.Join(valueParts, ", ") +
") ON CONFLICT (" + strings.Join(quotedUniqueKeys, ", ") +
") 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()
query := "UPDATE " + processor.ProcessTableName(table) + " SET "
qs := make([]interface{}, 0)
tp := len(data)
for k, v := range data {
vstr := "?"
if Substr(k, len(k)-3, 3) == "[#]" {
k = strings.Replace(k, "[#]", "", -1)
vstr = ObjToStr(v)
} else {
qs = append(qs, v)
}
query += processor.ProcessColumnNoPrefix(k) + "=" + vstr + " "
if tp--; tp != 0 {
query += ", "
}
}
temp, resWhere := that.where(where)
query += temp + ";"
qs = append(qs, resWhere...)
res, err := that.Exec(query, qs...)
rows := int64(0)
if err == nil && res != nil {
rows, _ = res.RowsAffected()
}
// 如果更新成功,则删除缓存
if rows != 0 {
if that.HoTimeCache != nil && !that.isCacheTable(table) {
_ = that.HoTimeCache.Db(table+"*", nil)
}
}
return rows
}
// Delete 删除数据
func (that *HoTimeDB) Delete(table string, data map[string]interface{}) int64 {
processor := that.GetProcessor()
query := "DELETE FROM " + processor.ProcessTableName(table) + " "
temp, resWhere := that.where(data)
query += temp + ";"
res, err := that.Exec(query, resWhere...)
rows := int64(0)
if err == nil && res != nil {
rows, _ = res.RowsAffected()
}
// 如果删除成功,删除对应缓存
if rows != 0 {
if that.HoTimeCache != nil && !that.isCacheTable(table) {
_ = that.HoTimeCache.Db(table+"*", nil)
}
}
return rows
}
+248
View File
@@ -0,0 +1,248 @@
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"
)
// 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 *log.Logger
Type string // 数据库类型: mysql, sqlite3, postgres
Prefix string
ConnectFunc func() (*sql.DB, *sql.DB)
lastError atomic.Pointer[DBError] // 无锁读写,始终记录最后一次 SQL 操作
limit Slice
*sql.Tx //事务对象
SlaveDB *sql.DB // 从数据库
limitMu sync.Mutex
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() (master, slave *sql.DB)) {
that.ConnectFunc = connect
that.InitDb()
}
// InitDb 初始化数据库连接
func (that *HoTimeDB) InitDb() {
that.DB, that.SlaveDB = that.ConnectFunc()
if that.DB == nil {
return
}
e := that.DB.Ping()
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()
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()
}
}
// initDialect 根据数据库类型初始化方言
func (that *HoTimeDB) initDialect() {
switch that.Type {
case "postgres", "postgresql":
that.Dialect = &PostgreSQLDialect{}
case "sqlite3", "sqlite":
that.Dialect = &SQLiteDialect{}
case "dm", "dameng":
that.Dialect = &DMDialect{}
default:
that.Dialect = &MySQLDialect{}
}
}
// GetDialect 获取当前方言适配器
func (that *HoTimeDB) GetDialect() Dialect {
if that.Dialect == nil {
that.initDialect()
}
return that.Dialect
}
// SetDialect 设置方言适配器
func (that *HoTimeDB) SetDialect(dialect Dialect) {
that.Dialect = dialect
}
// GetType 获取数据库类型
func (that *HoTimeDB) GetType() string {
return that.Type
}
// GetPrefix 获取表前缀
func (that *HoTimeDB) GetPrefix() string {
return that.Prefix
}
// GetProcessor 获取标识符处理器
// 用于处理表名、字段名的前缀添加和引号转换
func (that *HoTimeDB) GetProcessor() *IdentifierProcessor {
return NewIdentifierProcessor(that.GetDialect(), that.Prefix)
}
// T 辅助方法:获取带前缀和引号的表名
// 用于手动构建 SQL 时使用
// 示例: db.T("order") 返回 "`app_order`" (MySQL) 或 "\"app_order\"" (PostgreSQL)
func (that *HoTimeDB) T(table string) string {
return that.GetProcessor().ProcessTableName(table)
}
// C 辅助方法:获取带前缀和引号的 table.column
// 支持两种调用方式:
// - db.C("order", "name") 返回 "`app_order`.`name`"
// - db.C("order.name") 返回 "`app_order`.`name`"
func (that *HoTimeDB) C(args ...string) string {
if len(args) == 0 {
return ""
}
if len(args) == 1 {
return that.GetProcessor().ProcessColumn(args[0])
}
// 两个参数: table, column
dialect := that.GetDialect()
table := args[0]
column := args[1]
// 去除已有引号
table = trimQuotes(table)
column = trimQuotes(column)
return dialect.QuoteIdentifier(that.Prefix+table) + "." + dialect.QuoteIdentifier(column)
}
// trimQuotes 去除字符串两端的引号
func trimQuotes(s string) string {
s = strings.TrimSpace(s)
if len(s) >= 2 {
if (s[0] == '`' && s[len(s)-1] == '`') || (s[0] == '"' && s[len(s)-1] == '"') {
return s[1 : len(s)-1]
}
}
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
}
+382
View File
@@ -0,0 +1,382 @@
package db
import (
"fmt"
"strings"
)
// Dialect 数据库方言接口
// 用于处理不同数据库之间的语法差异
type Dialect interface {
// Quote 对表名/字段名添加引号
// MySQL 使用反引号 `name`
// PostgreSQL 使用双引号 "name"
// SQLite 使用双引号或方括号 "name" 或 [name]
Quote(name string) string
// QuoteIdentifier 处理单个标识符(去除已有引号,添加正确引号)
// 输入可能带有反引号或双引号,会先去除再添加正确格式
QuoteIdentifier(name string) string
// QuoteChar 返回引号字符
// MySQL: `
// PostgreSQL/SQLite: "
QuoteChar() string
// Placeholder 生成占位符
// MySQL/SQLite 使用 ?
// PostgreSQL 使用 $1, $2, $3...
Placeholder(index int) string
// Placeholders 生成多个占位符,用逗号分隔
Placeholders(count int, startIndex int) string
// SupportsLastInsertId 是否支持 LastInsertId
// PostgreSQL 不支持,需要使用 RETURNING
SupportsLastInsertId() bool
// ReturningClause 生成 RETURNING 子句(用于 PostgreSQL
ReturningClause(column string) string
// UpsertSQL 生成 Upsert 语句
// MySQL: INSERT ... ON DUPLICATE KEY UPDATE ...
// PostgreSQL: INSERT ... ON CONFLICT ... DO UPDATE SET ...
// SQLite: INSERT OR REPLACE / INSERT ... ON CONFLICT ...
UpsertSQL(table string, columns []string, uniqueKeys []string, updateColumns []string) string
// GetName 获取方言名称
GetName() string
}
// MySQLDialect MySQL 方言实现
type MySQLDialect struct{}
func (d *MySQLDialect) GetName() string {
return "mysql"
}
func (d *MySQLDialect) Quote(name string) string {
// 如果已经包含点号(表.字段)或空格(别名),不添加引号
if strings.Contains(name, ".") || strings.Contains(name, " ") {
return name
}
return "`" + name + "`"
}
func (d *MySQLDialect) QuoteIdentifier(name string) string {
// 去除已有的引号(反引号和双引号)
name = strings.Trim(name, "`\"")
return "`" + name + "`"
}
func (d *MySQLDialect) QuoteChar() string {
return "`"
}
func (d *MySQLDialect) Placeholder(index int) string {
return "?"
}
func (d *MySQLDialect) 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 *MySQLDialect) SupportsLastInsertId() bool {
return true
}
func (d *MySQLDialect) ReturningClause(column string) string {
return "" // MySQL 不支持 RETURNING
}
func (d *MySQLDialect) UpsertSQL(table string, columns []string, uniqueKeys []string, updateColumns []string) string {
// INSERT INTO table (col1, col2) VALUES (?, ?)
// ON DUPLICATE KEY UPDATE col1 = VALUES(col1), col2 = VALUES(col2)
quotedCols := make([]string, len(columns))
for i, col := range columns {
quotedCols[i] = d.Quote(col)
}
placeholders := d.Placeholders(len(columns), 1)
updateParts := make([]string, len(updateColumns))
for i, col := range updateColumns {
// 检查是否是 [#] 标记的直接 SQL
if strings.HasSuffix(col, "[#]") {
// 这种情况在调用处处理
updateParts[i] = col
} else {
quotedCol := d.Quote(col)
updateParts[i] = quotedCol + " = VALUES(" + quotedCol + ")"
}
}
return fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s) ON DUPLICATE KEY UPDATE %s",
d.Quote(table),
strings.Join(quotedCols, ", "),
placeholders,
strings.Join(updateParts, ", "))
}
// PostgreSQLDialect PostgreSQL 方言实现
type PostgreSQLDialect struct{}
func (d *PostgreSQLDialect) GetName() string {
return "postgres"
}
func (d *PostgreSQLDialect) Quote(name string) string {
// 如果已经包含点号(表.字段)或空格(别名),不添加引号
if strings.Contains(name, ".") || strings.Contains(name, " ") {
return name
}
return "\"" + name + "\""
}
func (d *PostgreSQLDialect) QuoteIdentifier(name string) string {
// 去除已有的引号(反引号和双引号)
name = strings.Trim(name, "`\"")
return "\"" + name + "\""
}
func (d *PostgreSQLDialect) QuoteChar() string {
return "\""
}
func (d *PostgreSQLDialect) Placeholder(index int) string {
return fmt.Sprintf("$%d", index)
}
func (d *PostgreSQLDialect) Placeholders(count int, startIndex int) string {
if count <= 0 {
return ""
}
placeholders := make([]string, count)
for i := 0; i < count; i++ {
placeholders[i] = fmt.Sprintf("$%d", startIndex+i)
}
return strings.Join(placeholders, ",")
}
func (d *PostgreSQLDialect) SupportsLastInsertId() bool {
return false // PostgreSQL 需要使用 RETURNING
}
func (d *PostgreSQLDialect) ReturningClause(column string) string {
return " RETURNING " + d.Quote(column)
}
func (d *PostgreSQLDialect) UpsertSQL(table string, columns []string, uniqueKeys []string, updateColumns []string) string {
// INSERT INTO table (col1, col2) VALUES ($1, $2)
// ON CONFLICT (unique_key) DO UPDATE SET col1 = EXCLUDED.col1, col2 = EXCLUDED.col2
quotedCols := make([]string, len(columns))
for i, col := range columns {
quotedCols[i] = d.Quote(col)
}
placeholders := d.Placeholders(len(columns), 1)
quotedUniqueKeys := make([]string, len(uniqueKeys))
for i, key := range uniqueKeys {
quotedUniqueKeys[i] = d.Quote(key)
}
updateParts := make([]string, len(updateColumns))
for i, col := range updateColumns {
if strings.HasSuffix(col, "[#]") {
updateParts[i] = col
} else {
quotedCol := d.Quote(col)
updateParts[i] = quotedCol + " = EXCLUDED." + quotedCol
}
}
return fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s) ON CONFLICT (%s) DO UPDATE SET %s",
d.Quote(table),
strings.Join(quotedCols, ", "),
placeholders,
strings.Join(quotedUniqueKeys, ", "),
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{}
func (d *SQLiteDialect) GetName() string {
return "sqlite3"
}
func (d *SQLiteDialect) Quote(name string) string {
// 如果已经包含点号(表.字段)或空格(别名),不添加引号
if strings.Contains(name, ".") || strings.Contains(name, " ") {
return name
}
return "\"" + name + "\""
}
func (d *SQLiteDialect) QuoteIdentifier(name string) string {
// 去除已有的引号(反引号和双引号)
name = strings.Trim(name, "`\"")
return "\"" + name + "\""
}
func (d *SQLiteDialect) QuoteChar() string {
return "\""
}
func (d *SQLiteDialect) Placeholder(index int) string {
return "?"
}
func (d *SQLiteDialect) 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 *SQLiteDialect) SupportsLastInsertId() bool {
return true
}
func (d *SQLiteDialect) ReturningClause(column string) string {
return "" // SQLite 3.35+ 支持 RETURNING,但为兼容性暂不使用
}
func (d *SQLiteDialect) UpsertSQL(table string, columns []string, uniqueKeys []string, updateColumns []string) string {
// INSERT INTO table (col1, col2) VALUES (?, ?)
// ON CONFLICT (unique_key) DO UPDATE SET col1 = excluded.col1, col2 = excluded.col2
quotedCols := make([]string, len(columns))
for i, col := range columns {
quotedCols[i] = d.Quote(col)
}
placeholders := d.Placeholders(len(columns), 1)
quotedUniqueKeys := make([]string, len(uniqueKeys))
for i, key := range uniqueKeys {
quotedUniqueKeys[i] = d.Quote(key)
}
updateParts := make([]string, len(updateColumns))
for i, col := range updateColumns {
if strings.HasSuffix(col, "[#]") {
updateParts[i] = col
} else {
quotedCol := d.Quote(col)
updateParts[i] = quotedCol + " = excluded." + quotedCol
}
}
return fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s) ON CONFLICT (%s) DO UPDATE SET %s",
d.Quote(table),
strings.Join(quotedCols, ", "),
placeholders,
strings.Join(quotedUniqueKeys, ", "),
strings.Join(updateParts, ", "))
}
+441
View File
@@ -0,0 +1,441 @@
package db
import (
. "code.hoteas.com/golang/hotime/common"
"fmt"
"strings"
"testing"
)
// TestDialectQuoteIdentifier 测试方言的 QuoteIdentifier 方法
func TestDialectQuoteIdentifier(t *testing.T) {
tests := []struct {
name string
dialect Dialect
input string
expected string
}{
// MySQL 方言测试
{"MySQL simple", &MySQLDialect{}, "name", "`name`"},
{"MySQL with backticks", &MySQLDialect{}, "`name`", "`name`"},
{"MySQL with quotes", &MySQLDialect{}, "\"name\"", "`name`"},
// PostgreSQL 方言测试
{"PostgreSQL simple", &PostgreSQLDialect{}, "name", "\"name\""},
{"PostgreSQL with backticks", &PostgreSQLDialect{}, "`name`", "\"name\""},
{"PostgreSQL with quotes", &PostgreSQLDialect{}, "\"name\"", "\"name\""},
// SQLite 方言测试
{"SQLite simple", &SQLiteDialect{}, "name", "\"name\""},
{"SQLite with backticks", &SQLiteDialect{}, "`name`", "\"name\""},
{"SQLite with quotes", &SQLiteDialect{}, "\"name\"", "\"name\""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.dialect.QuoteIdentifier(tt.input)
if result != tt.expected {
t.Errorf("QuoteIdentifier(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
// TestDialectQuoteChar 测试方言的 QuoteChar 方法
func TestDialectQuoteChar(t *testing.T) {
tests := []struct {
name string
dialect Dialect
expected string
}{
{"MySQL", &MySQLDialect{}, "`"},
{"PostgreSQL", &PostgreSQLDialect{}, "\""},
{"SQLite", &SQLiteDialect{}, "\""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.dialect.QuoteChar()
if result != tt.expected {
t.Errorf("QuoteChar() = %q, want %q", result, tt.expected)
}
})
}
}
// TestIdentifierProcessorTableName 测试表名处理
func TestIdentifierProcessorTableName(t *testing.T) {
tests := []struct {
name string
dialect Dialect
prefix string
input string
expected string
}{
// MySQL 无前缀
{"MySQL no prefix", &MySQLDialect{}, "", "order", "`order`"},
{"MySQL no prefix with backticks", &MySQLDialect{}, "", "`order`", "`order`"},
// MySQL 有前缀
{"MySQL with prefix", &MySQLDialect{}, "app_", "order", "`app_order`"},
{"MySQL with prefix and backticks", &MySQLDialect{}, "app_", "`order`", "`app_order`"},
// PostgreSQL 无前缀
{"PostgreSQL no prefix", &PostgreSQLDialect{}, "", "order", "\"order\""},
// PostgreSQL 有前缀
{"PostgreSQL with prefix", &PostgreSQLDialect{}, "app_", "order", "\"app_order\""},
{"PostgreSQL with prefix and quotes", &PostgreSQLDialect{}, "app_", "\"order\"", "\"app_order\""},
// SQLite 有前缀
{"SQLite with prefix", &SQLiteDialect{}, "app_", "user", "\"app_user\""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
processor := NewIdentifierProcessor(tt.dialect, tt.prefix)
result := processor.ProcessTableName(tt.input)
if result != tt.expected {
t.Errorf("ProcessTableName(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
// TestIdentifierProcessorColumn 测试列名处理(包括 table.column 格式)
func TestIdentifierProcessorColumn(t *testing.T) {
tests := []struct {
name string
dialect Dialect
prefix string
input string
expected string
}{
// 单独列名
{"MySQL simple column", &MySQLDialect{}, "", "name", "`name`"},
{"MySQL simple column with prefix", &MySQLDialect{}, "app_", "name", "`name`"},
// table.column 格式
{"MySQL table.column no prefix", &MySQLDialect{}, "", "order.name", "`order`.`name`"},
{"MySQL table.column with prefix", &MySQLDialect{}, "app_", "order.name", "`app_order`.`name`"},
{"MySQL table.column with backticks", &MySQLDialect{}, "app_", "`order`.name", "`app_order`.`name`"},
// PostgreSQL
{"PostgreSQL table.column with prefix", &PostgreSQLDialect{}, "app_", "order.name", "\"app_order\".\"name\""},
{"PostgreSQL table.column with quotes", &PostgreSQLDialect{}, "app_", "\"order\".name", "\"app_order\".\"name\""},
// SQLite
{"SQLite table.column with prefix", &SQLiteDialect{}, "app_", "user.email", "\"app_user\".\"email\""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
processor := NewIdentifierProcessor(tt.dialect, tt.prefix)
result := processor.ProcessColumn(tt.input)
if result != tt.expected {
t.Errorf("ProcessColumn(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
// TestIdentifierProcessorConditionString 测试条件字符串处理
func TestIdentifierProcessorConditionString(t *testing.T) {
tests := []struct {
name string
dialect Dialect
prefix string
input string
contains []string // 结果应该包含这些字符串
}{
// MySQL 简单条件
{
"MySQL simple condition",
&MySQLDialect{},
"app_",
"user.id = order.user_id",
[]string{"`app_user`", "`app_order`"},
},
// MySQL 复杂条件
{
"MySQL complex condition",
&MySQLDialect{},
"app_",
"user.id = order.user_id AND order.status = 1",
[]string{"`app_user`", "`app_order`"},
},
// PostgreSQL
{
"PostgreSQL condition",
&PostgreSQLDialect{},
"app_",
"user.id = order.user_id",
[]string{"\"app_user\"", "\"app_order\""},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
processor := NewIdentifierProcessor(tt.dialect, tt.prefix)
result := processor.ProcessConditionString(tt.input)
for _, expected := range tt.contains {
if !strings.Contains(result, expected) {
t.Errorf("ProcessConditionString(%q) = %q, should contain %q", tt.input, result, expected)
}
}
})
}
}
// TestHoTimeDBHelperMethods 测试 HoTimeDB 的辅助方法 T() 和 C()
func TestHoTimeDBHelperMethods(t *testing.T) {
// 创建 MySQL 数据库实例
mysqlDB := &HoTimeDB{
Type: "mysql",
Prefix: "app_",
}
mysqlDB.initDialect()
// 测试 T() 方法
t.Run("MySQL T() method", func(t *testing.T) {
result := mysqlDB.T("order")
expected := "`app_order`"
if result != expected {
t.Errorf("T(\"order\") = %q, want %q", result, expected)
}
})
// 测试 C() 方法(两个参数)
t.Run("MySQL C() method with two args", func(t *testing.T) {
result := mysqlDB.C("order", "name")
expected := "`app_order`.`name`"
if result != expected {
t.Errorf("C(\"order\", \"name\") = %q, want %q", result, expected)
}
})
// 测试 C() 方法(一个参数,点号格式)
t.Run("MySQL C() method with dot notation", func(t *testing.T) {
result := mysqlDB.C("order.name")
expected := "`app_order`.`name`"
if result != expected {
t.Errorf("C(\"order.name\") = %q, want %q", result, expected)
}
})
// 创建 PostgreSQL 数据库实例
pgDB := &HoTimeDB{
Type: "postgres",
Prefix: "app_",
}
pgDB.initDialect()
// 测试 PostgreSQL 的 T() 方法
t.Run("PostgreSQL T() method", func(t *testing.T) {
result := pgDB.T("order")
expected := "\"app_order\""
if result != expected {
t.Errorf("T(\"order\") = %q, want %q", result, expected)
}
})
// 测试 PostgreSQL 的 C() 方法
t.Run("PostgreSQL C() method", func(t *testing.T) {
result := pgDB.C("order", "name")
expected := "\"app_order\".\"name\""
if result != expected {
t.Errorf("C(\"order\", \"name\") = %q, want %q", result, expected)
}
})
}
// TestWhereWithORCondition 测试 OR 条件处理是否正确添加括号
func TestWhereWithORCondition(t *testing.T) {
// 创建 MySQL 数据库实例
mysqlDB := &HoTimeDB{
Type: "mysql",
Prefix: "",
}
mysqlDB.initDialect()
// 测试 OR 与普通条件组合 (假设 A: 顺序问题)
t.Run("OR with normal condition", func(t *testing.T) {
data := Map{
"OR": Map{
"username": "test",
"phone": "123",
},
"state": 0,
}
where, params := mysqlDB.where(data)
fmt.Println("Test 1 - OR with normal condition:")
fmt.Println(" Generated WHERE:", where)
fmt.Println(" Params count:", len(params))
// 检查 OR 条件是否被括号包裹
if !strings.Contains(where, "(") || !strings.Contains(where, ")") {
t.Errorf("OR condition should be wrapped with parentheses, got: %s", where)
}
// 检查是否有 AND 连接
if !strings.Contains(where, "AND") {
t.Errorf("OR condition and normal condition should be connected with AND, got: %s", where)
}
})
// 测试纯 OR 条件(无其他普通条件)
t.Run("Pure OR condition", func(t *testing.T) {
data := Map{
"OR": Map{
"username": "test",
"phone": "123",
},
}
where, params := mysqlDB.where(data)
fmt.Println("Test 2 - Pure OR condition:")
fmt.Println(" Generated WHERE:", where)
fmt.Println(" Params count:", len(params))
// 检查 OR 条件内部应该用 OR 连接
if !strings.Contains(where, "OR") {
t.Errorf("OR condition should contain OR keyword, got: %s", where)
}
})
// 测试多个普通条件与 OR 组合 (假设 A)
t.Run("OR with multiple normal conditions", func(t *testing.T) {
data := Map{
"OR": Map{
"username": "test",
"phone": "123",
},
"state": 0,
"status": 1,
}
where, params := mysqlDB.where(data)
fmt.Println("Test 3 - OR with multiple normal conditions:")
fmt.Println(" Generated WHERE:", where)
fmt.Println(" Params count:", len(params))
// 应该有括号
if !strings.Contains(where, "(") {
t.Errorf("OR condition should be wrapped with parentheses, got: %s", where)
}
})
// 测试嵌套 AND/OR 条件 (假设 B, E)
t.Run("Nested AND/OR conditions", func(t *testing.T) {
data := Map{
"OR": Map{
"username": "test",
"AND": Map{
"phone": "123",
"status": 1,
},
},
"state": 0,
}
where, params := mysqlDB.where(data)
fmt.Println("Test 4 - Nested AND/OR conditions:")
fmt.Println(" Generated WHERE:", where)
fmt.Println(" Params count:", len(params))
})
// 测试空 OR 条件 (假设 C)
t.Run("Empty OR condition", func(t *testing.T) {
data := Map{
"OR": Map{},
"state": 0,
}
where, params := mysqlDB.where(data)
fmt.Println("Test 5 - Empty OR condition:")
fmt.Println(" Generated WHERE:", where)
fmt.Println(" Params count:", len(params))
})
// 测试 OR 与 LIMIT, ORDER 组合 (假设 D)
t.Run("OR with LIMIT and ORDER", func(t *testing.T) {
data := Map{
"OR": Map{
"username": "test",
"phone": "123",
},
"state": 0,
"ORDER": "id DESC",
"LIMIT": 10,
}
where, params := mysqlDB.where(data)
fmt.Println("Test 6 - OR with LIMIT and ORDER:")
fmt.Println(" Generated WHERE:", where)
fmt.Println(" Params count:", len(params))
})
// 测试同时有 OR 和 AND 关键字 (假设 E)
t.Run("Both OR and AND keywords", func(t *testing.T) {
data := Map{
"OR": Map{
"username": "test",
"phone": "123",
},
"AND": Map{
"type": 1,
"source": "web",
},
"state": 0,
}
where, params := mysqlDB.where(data)
fmt.Println("Test 7 - Both OR and AND keywords:")
fmt.Println(" Generated WHERE:", where)
fmt.Println(" Params count:", len(params))
})
// 测试普通条件在 OR 之前(排序后)(假设 A)
t.Run("Normal condition before OR alphabetically", func(t *testing.T) {
data := Map{
"OR": Map{
"username": "test",
"phone": "123",
},
"active": 1, // 'a' 在 'O' 之前
}
where, params := mysqlDB.where(data)
fmt.Println("Test 8 - Normal condition before OR (alphabetically):")
fmt.Println(" Generated WHERE:", where)
fmt.Println(" Params count:", len(params))
})
}
// 打印测试结果(用于调试)
func ExampleIdentifierProcessor() {
// MySQL 示例
mysqlProcessor := NewIdentifierProcessor(&MySQLDialect{}, "app_")
fmt.Println("MySQL:")
fmt.Println(" Table:", mysqlProcessor.ProcessTableName("order"))
fmt.Println(" Column:", mysqlProcessor.ProcessColumn("order.name"))
fmt.Println(" Condition:", mysqlProcessor.ProcessConditionString("user.id = order.user_id"))
// PostgreSQL 示例
pgProcessor := NewIdentifierProcessor(&PostgreSQLDialect{}, "app_")
fmt.Println("PostgreSQL:")
fmt.Println(" Table:", pgProcessor.ProcessTableName("order"))
fmt.Println(" Column:", pgProcessor.ProcessColumn("order.name"))
fmt.Println(" Condition:", pgProcessor.ProcessConditionString("user.id = order.user_id"))
// Output:
// MySQL:
// Table: `app_order`
// Column: `app_order`.`name`
// Condition: `app_user`.`id` = `app_order`.`user_id`
// PostgreSQL:
// Table: "app_order"
// Column: "app_order"."name"
// Condition: "app_user"."id" = "app_order"."user_id"
}
-1372
View File
File diff suppressed because it is too large Load Diff
+260
View File
@@ -0,0 +1,260 @@
package db
import (
"regexp"
"strings"
)
// IdentifierProcessor 标识符处理器
// 用于处理表名、字段名的前缀添加和引号转换
type IdentifierProcessor struct {
dialect Dialect
prefix string
}
// NewIdentifierProcessor 创建标识符处理器
func NewIdentifierProcessor(dialect Dialect, prefix string) *IdentifierProcessor {
return &IdentifierProcessor{
dialect: dialect,
prefix: prefix,
}
}
// 系统数据库列表,这些数据库不添加前缀
var systemDatabases = map[string]bool{
"INFORMATION_SCHEMA": true,
"information_schema": true,
"mysql": true,
"performance_schema": true,
"sys": true,
"pg_catalog": true,
"pg_toast": true,
}
// ProcessTableName 处理表名(添加前缀+引号)
// 输入: "order" 或 "`order`" 或 "\"order\"" 或 "INFORMATION_SCHEMA.TABLES"
// 输出: "`app_order`" (MySQL) 或 "\"app_order\"" (PostgreSQL/SQLite)
// 对于 database.table 格式,会分别处理,系统数据库不添加前缀
func (p *IdentifierProcessor) ProcessTableName(name string) string {
// 去除已有的引号
name = p.stripQuotes(name)
// 检查是否包含空格(别名情况,如 "order AS o"
if strings.Contains(name, " ") {
// 处理别名情况
parts := strings.SplitN(name, " ", 2)
tableName := p.stripQuotes(parts[0])
alias := parts[1]
// 递归处理表名部分(可能包含点号)
return p.ProcessTableName(tableName) + " " + alias
}
// 检查是否包含点号(database.table 格式)
if strings.Contains(name, ".") {
parts := p.splitTableColumn(name)
if len(parts) == 2 {
dbName := p.stripQuotes(parts[0])
tableName := p.stripQuotes(parts[1])
// 系统数据库不添加前缀
if systemDatabases[dbName] {
return p.dialect.QuoteIdentifier(dbName) + "." + p.dialect.QuoteIdentifier(tableName)
}
// 非系统数据库,只给表名添加前缀
return p.dialect.QuoteIdentifier(dbName) + "." + p.dialect.QuoteIdentifier(p.prefix+tableName)
}
}
// 添加前缀和引号
return p.dialect.QuoteIdentifier(p.prefix + name)
}
// ProcessTableNameNoPrefix 处理表名(只添加引号,不添加前缀)
// 用于已经包含前缀的情况
func (p *IdentifierProcessor) ProcessTableNameNoPrefix(name string) string {
name = p.stripQuotes(name)
if strings.Contains(name, " ") {
parts := strings.SplitN(name, " ", 2)
tableName := p.stripQuotes(parts[0])
alias := parts[1]
return p.dialect.QuoteIdentifier(tableName) + " " + alias
}
return p.dialect.QuoteIdentifier(name)
}
// ProcessColumn 处理 table.column 格式
// 输入: "name" 或 "order.name" 或 "`order`.name" 或 "`order`.`name`"
// 输出: "`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))
}
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)
}
// ProcessColumnNoPrefix 处理 table.column 格式(不添加前缀)
func (p *IdentifierProcessor) ProcessColumnNoPrefix(name string) string {
if !strings.Contains(name, ".") {
return p.dialect.QuoteIdentifier(p.stripQuotes(name))
}
parts := p.splitTableColumn(name)
if len(parts) == 2 {
tableName := p.stripQuotes(parts[0])
columnName := p.stripQuotes(parts[1])
return p.dialect.QuoteIdentifier(tableName) + "." + p.dialect.QuoteIdentifier(columnName)
}
return p.convertQuotes(name)
}
// 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
}
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)
if len(parts) == 3 {
tableName := parts[1]
colName := parts[2]
return p.dialect.QuoteIdentifier(p.prefix+tableName) + "." + p.dialect.QuoteIdentifier(colName)
}
return match
})
// 然后处理部分引号的情况 `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]
if lastChar != '`' && lastChar != '"' && !isIdentChar(lastChar) {
suffix = string(lastChar)
}
}
return p.dialect.QuoteIdentifier(p.prefix+tableName) + "." + p.dialect.QuoteIdentifier(colName) + suffix
}
return match
})
// 最后处理无引号的情况 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]
tableName := parts[2]
colName := parts[3]
suffix := parts[4]
return prefix + p.dialect.QuoteIdentifier(p.prefix+tableName) + "." + p.dialect.QuoteIdentifier(colName) + suffix
}
return match
})
return result
}
// isIdentChar 判断是否是标识符字符
func isIdentChar(c byte) bool {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'
}
// ProcessFieldList 处理字段列表字符串
// 输入: "order.id, user.name AS uname, COUNT(*)"
// 输出: "`app_order`.`id`, `app_user`.`name` AS uname, COUNT(*)" (MySQL)
func (p *IdentifierProcessor) ProcessFieldList(fields string) string {
if fields == "" || fields == "*" {
return fields
}
result := p.ProcessConditionString(fields)
result = p.convertQuotes(result)
return result
}
// stripQuotes 去除标识符两端的引号(反引号或双引号)
func (p *IdentifierProcessor) stripQuotes(name string) string {
name = strings.TrimSpace(name)
// 去除反引号
if strings.HasPrefix(name, "`") && strings.HasSuffix(name, "`") {
return name[1 : len(name)-1]
}
// 去除双引号
if strings.HasPrefix(name, "\"") && strings.HasSuffix(name, "\"") {
return name[1 : len(name)-1]
}
return name
}
// splitTableColumn 分割 table.column 格式
// 支持: table.column, `table`.column, `table`.`column`, "table".column 等
func (p *IdentifierProcessor) splitTableColumn(name string) []string {
// 先尝试按点号分割
dotIndex := -1
// 查找不在引号内的点号
inQuote := false
quoteChar := byte(0)
for i := 0; i < len(name); i++ {
c := name[i]
if c == '`' || c == '"' {
if !inQuote {
inQuote = true
quoteChar = c
} else if c == quoteChar {
inQuote = false
}
} else if c == '.' && !inQuote {
dotIndex = i
break
}
}
if dotIndex == -1 {
return []string{name}
}
return []string{name[:dotIndex], name[dotIndex+1:]}
}
// convertQuotes 将已有的引号转换为当前方言的引号格式
func (p *IdentifierProcessor) convertQuotes(name string) string {
quoteChar := p.dialect.QuoteChar()
// 替换反引号
name = strings.ReplaceAll(name, "`", quoteChar)
// 如果目标是反引号,需要替换双引号
if quoteChar == "`" {
name = strings.ReplaceAll(name, "\"", quoteChar)
}
return name
}
// GetDialect 获取方言
func (p *IdentifierProcessor) GetDialect() Dialect {
return p.dialect
}
// GetPrefix 获取前缀
func (p *IdentifierProcessor) GetPrefix() string {
return p.prefix
}
+615
View File
@@ -0,0 +1,615 @@
package db
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"math"
"reflect"
"strconv"
"strings"
"sync/atomic"
"time"
. "code.hoteas.com/golang/hotime/common"
)
// md5 生成查询的 MD5 哈希(用于缓存)
func (that *HoTimeDB) md5(query string, args ...interface{}) string {
strByte, _ := json.Marshal(args)
str := Md5(query + ":" + string(strByte))
return str
}
// Query 执行查询 SQL
func (that *HoTimeDB) Query(query string, args ...interface{}) []Map {
return that.queryWithRetry(query, false, args...)
}
// queryWithRetry 内部查询方法,支持重试标记
func (that *HoTimeDB) queryWithRetry(query string, retried bool, args ...interface{}) []Map {
query, args = that.expandArrayPlaceholder(query, args)
var sqlErr error
defer func() {
e := &DBError{Query: query, Data: args}
e.SetError(sqlErr)
that.lastError.Store(e)
that.logSQL(query, args, sqlErr)
}()
var resl *sql.Rows
db := that.DB
if that.SlaveDB != nil {
db = that.SlaveDB
}
if db == nil {
sqlErr = errors.New("没有初始化数据库")
return nil
}
processedArgs := that.processArgs(args)
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, sqlErr = db.Query(query, processedArgs...)
}
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...)
}
}
return nil
}
return that.Row(resl)
}
// Exec 执行非查询 SQL
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) {
query, args = that.expandArrayPlaceholder(query, args)
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 {
sqlErr = errors.New("没有初始化数据库")
return nil, sqlErr
}
processedArgs := that.processArgs(args)
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, sqlErr = that.DB.Exec(query, processedArgs...)
}
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, sqlErr
}
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 类型
func (that *HoTimeDB) processArgs(args []interface{}) []interface{} {
processedArgs := make([]interface{}, len(args))
copy(processedArgs, args)
for key := range processedArgs {
arg := processedArgs[key]
if arg == nil {
continue
}
argType := reflect.ValueOf(arg).Type().String()
if strings.Contains(argType, "[]") || strings.Contains(argType, "Slice") {
argLis := ObjToSlice(arg)
// 将slice转为逗号分割字符串
argStr := ""
for i := 0; i < len(argLis); i++ {
if i == len(argLis)-1 {
argStr += ObjToStr(argLis[i])
} else {
argStr += ObjToStr(argLis[i]) + ","
}
}
processedArgs[key] = argStr
}
}
return processedArgs
}
// expandArrayPlaceholder 展开 IN (?) / NOT IN (?) 中的数组参数
// 自动识别 IN/NOT IN (?) 模式,当参数是数组时展开为多个 ?
//
// 示例:
//
// db.Query("SELECT * FROM user WHERE id IN (?)", []int{1, 2, 3})
// // 展开为: SELECT * FROM user WHERE id IN (?, ?, ?) 参数: [1, 2, 3]
//
// db.Query("SELECT * FROM user WHERE id IN (?)", []int{})
// // 展开为: SELECT * FROM user WHERE 1=0 参数: [] (空集合的IN永假)
//
// db.Query("SELECT * FROM user WHERE id NOT IN (?)", []int{})
// // 展开为: SELECT * FROM user WHERE 1=1 参数: [] (空集合的NOT IN永真)
//
// db.Query("SELECT * FROM user WHERE id = ?", 1)
// // 保持不变: SELECT * FROM user WHERE id = ? 参数: [1]
func (that *HoTimeDB) expandArrayPlaceholder(query string, args []interface{}) (string, []interface{}) {
if len(args) == 0 || !strings.Contains(query, "?") {
return query, args
}
// 检查是否有数组参数
hasArray := false
for _, arg := range args {
if arg == nil {
continue
}
argType := reflect.ValueOf(arg).Type().String()
if strings.Contains(argType, "[]") || strings.Contains(argType, "Slice") {
hasArray = true
break
}
}
if !hasArray {
return query, args
}
newArgs := make([]interface{}, 0, len(args))
result := strings.Builder{}
argIndex := 0
for i := 0; i < len(query); i++ {
if query[i] == '?' && argIndex < len(args) {
arg := args[argIndex]
argIndex++
if arg == nil {
result.WriteByte('?')
newArgs = append(newArgs, nil)
continue
}
argType := reflect.ValueOf(arg).Type().String()
if strings.Contains(argType, "[]") || strings.Contains(argType, "Slice") {
// 是数组参数,检查是否在 IN (...) 或 NOT IN (...) 中
prevPart := result.String()
prevUpper := strings.ToUpper(prevPart)
// 查找最近的 NOT IN ( 模式
notInIndex := strings.LastIndex(prevUpper, " NOT IN (")
notInIndex2 := strings.LastIndex(prevUpper, " NOT IN(")
if notInIndex2 > notInIndex {
notInIndex = notInIndex2
}
// 查找最近的 IN ( 模式(但要排除 NOT IN 的情况)
inIndex := strings.LastIndex(prevUpper, " IN (")
inIndex2 := strings.LastIndex(prevUpper, " IN(")
if inIndex2 > inIndex {
inIndex = inIndex2
}
// 判断是 NOT IN 还是 IN
// 注意:" NOT IN (" 包含 " IN (",所以如果找到的 IN 位置在 NOT IN 范围内,应该优先判断为 NOT IN
isNotIn := false
matchIndex := -1
if notInIndex != -1 {
// 检查 inIndex 是否在 notInIndex 范围内(即 NOT IN 的 IN 部分)
// NOT IN ( 的 IN ( 部分从 notInIndex + 4 开始
if inIndex != -1 && inIndex >= notInIndex && inIndex <= notInIndex+5 {
// inIndex 是 NOT IN 的一部分,使用 NOT IN
isNotIn = true
matchIndex = notInIndex
} else if inIndex == -1 || notInIndex > inIndex {
// 没有独立的 IN,或 NOT IN 在 IN 之后
isNotIn = true
matchIndex = notInIndex
} else {
// 有独立的 IN 且在 NOT IN 之后
matchIndex = inIndex
}
} else if inIndex != -1 {
matchIndex = inIndex
}
// 检查 IN ( 后面是否只有空格(即当前 ? 紧跟在 IN ( 后面)
isInPattern := false
if matchIndex != -1 {
afterIn := prevPart[matchIndex:]
// 找到 ( 的位置
parenIdx := strings.Index(afterIn, "(")
if parenIdx != -1 {
afterParen := strings.TrimSpace(afterIn[parenIdx+1:])
if afterParen == "" {
isInPattern = true
}
}
}
if isInPattern {
// 在 IN (...) 或 NOT IN (...) 模式中
argList := ObjToSlice(arg)
if len(argList) == 0 {
// 空数组处理:需要找到字段名的开始位置
// 往前找最近的 AND/OR/WHERE/(,以确定条件的开始位置
truncateIndex := matchIndex
searchPart := prevUpper[:matchIndex]
// 找最近的分隔符位置
andIdx := strings.LastIndex(searchPart, " AND ")
orIdx := strings.LastIndex(searchPart, " OR ")
whereIdx := strings.LastIndex(searchPart, " WHERE ")
parenIdx := strings.LastIndex(searchPart, "(")
// 取最靠后的分隔符
sepIndex := -1
sepLen := 0
if andIdx > sepIndex {
sepIndex = andIdx
sepLen = 5 // " AND "
}
if orIdx > sepIndex {
sepIndex = orIdx
sepLen = 4 // " OR "
}
if whereIdx > sepIndex {
sepIndex = whereIdx
sepLen = 7 // " WHERE "
}
if parenIdx > sepIndex {
sepIndex = parenIdx
sepLen = 1 // "("
}
if sepIndex != -1 {
truncateIndex = sepIndex + sepLen
}
result.Reset()
result.WriteString(prevPart[:truncateIndex])
if isNotIn {
// NOT IN 空集合 = 永真
result.WriteString(" 1=1 ")
} else {
// IN 空集合 = 永假
result.WriteString(" 1=0 ")
}
// 跳过后面的 )
for j := i + 1; j < len(query); j++ {
if query[j] == ')' {
i = j
break
}
}
} else if len(argList) == 1 {
// 单元素数组
result.WriteByte('?')
newArgs = append(newArgs, argList[0])
} else {
// 多元素数组,展开为多个 ?
for j := 0; j < len(argList); j++ {
if j > 0 {
result.WriteString(", ")
}
result.WriteByte('?')
newArgs = append(newArgs, argList[j])
}
}
} else {
// 不在 IN 模式中,保持原有行为(数组会被 processArgs 转为逗号字符串)
result.WriteByte('?')
newArgs = append(newArgs, arg)
}
} else {
// 非数组参数
result.WriteByte('?')
newArgs = append(newArgs, arg)
}
} else {
result.WriteByte(query[i])
}
}
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()
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)
}
}
}
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]
}
if err := resl.Scan(b...); err != nil {
dbErr := &DBError{}
dbErr.SetError(err)
that.lastError.Store(dbErr)
return nil
}
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)
}
})
}
+108
View File
@@ -0,0 +1,108 @@
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,
DBName: that.DBName,
HoTimeCache: that.HoTimeCache,
Log: that.Log,
Type: that.Type,
Prefix: that.Prefix,
ConnectFunc: that.ConnectFunc,
limit: that.limit,
Tx: that.Tx,
SlaveDB: that.SlaveDB,
Dialect: that.Dialect,
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 {
dbErr := &DBError{}
dbErr.SetError(err)
that.lastError.Store(dbErr)
return isSuccess
}
db.Tx = tx
isSuccess = action(db)
if txFailed {
_ = db.Tx.Rollback()
return false
}
if !isSuccess {
err = db.Tx.Rollback()
if err != nil {
dbErr := &DBError{}
dbErr.SetError(err)
that.lastError.Store(dbErr)
return isSuccess
}
return isSuccess
}
err = db.Tx.Commit()
if err != nil {
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)
}
}
+555
View File
@@ -0,0 +1,555 @@
package db
import (
. "code.hoteas.com/golang/hotime/common"
"reflect"
"sort"
"strings"
)
// 条件关键字
var condition = []string{"AND", "OR"}
// 特殊关键字(支持大小写)
var vcond = []string{"GROUP", "ORDER", "LIMIT", "DISTINCT", "HAVING", "OFFSET"}
// normalizeKey 标准化关键字(转大写)
func normalizeKey(k string) string {
upper := strings.ToUpper(k)
for _, v := range vcond {
if upper == v {
return v
}
}
for _, v := range condition {
if upper == v {
return v
}
}
return k
}
// isConditionKey 判断是否是条件关键字
func isConditionKey(k string) bool {
upper := strings.ToUpper(k)
for _, v := range condition {
if upper == v {
return true
}
}
return false
}
// isVcondKey 判断是否是特殊关键字
func isVcondKey(k string) bool {
upper := strings.ToUpper(k)
for _, v := range vcond {
if upper == v {
return true
}
}
return false
}
// where 语句解析
func (that *HoTimeDB) where(data Map) (string, []interface{}) {
where := ""
res := make([]interface{}, 0)
// 标准化 Map 的 key(大小写兼容)
normalizedData := Map{}
for k, v := range data {
normalizedData[normalizeKey(k)] = v
}
data = normalizedData
// 收集所有 key 并排序
testQu := []string{}
for key := range data {
testQu = append(testQu, key)
}
sort.Strings(testQu)
// 追踪条件数量,用于自动添加 AND
condCount := 0
for _, k := range testQu {
v := data[k]
// 检查是否是 AND/OR 条件关键字
if isConditionKey(k) {
tw, ts := that.cond(strings.ToUpper(k), v.(Map))
if tw != "" && strings.TrimSpace(tw) != "" {
// 与前面的条件用 AND 连接
if condCount > 0 {
where += " AND "
}
// 用括号包裹 OR/AND 组条件
where += "(" + strings.TrimSpace(tw) + ")"
condCount++
res = append(res, ts...)
}
continue
}
// 检查是否是特殊关键字(GROUP, ORDER, LIMIT 等)
if isVcondKey(k) {
continue // 特殊关键字在后面单独处理
}
// 处理普通条件字段
// 空切片的 IN 条件应该生成永假条件(1=0),而不是跳过
if v != nil && reflect.ValueOf(v).Type().String() == "common.Slice" && len(v.(Slice)) == 0 {
// 检查是否是 NOT IN(带 [!] 后缀)- NOT IN 空数组永真,跳过即可
if !strings.HasSuffix(k, "[!]") {
// IN 空数组 -> 生成永假条件
if condCount > 0 {
where += " AND "
}
where += "1=0 "
condCount++
}
continue
}
if v != nil && strings.Contains(reflect.ValueOf(v).Type().String(), "[]") && len(ObjToSlice(v)) == 0 {
// 检查是否是 NOT IN(带 [!] 后缀)- NOT IN 空数组永真,跳过即可
if !strings.HasSuffix(k, "[!]") {
// IN 空数组 -> 生成永假条件
if condCount > 0 {
where += " AND "
}
where += "1=0 "
condCount++
}
continue
}
tv, vv := that.varCond(k, v)
if tv != "" {
// 自动添加 AND 连接符
if condCount > 0 {
where += " AND "
}
where += tv
condCount++
res = append(res, vv...)
}
}
// 添加 WHERE 关键字
// 先去除首尾空格,检查是否有实际条件内容
trimmedWhere := strings.TrimSpace(where)
if len(trimmedWhere) != 0 {
hasWhere := true
for _, v := range vcond {
if strings.Index(trimmedWhere, v) == 0 {
hasWhere = false
}
}
if hasWhere {
where = " WHERE " + trimmedWhere + " "
}
} else {
// 没有实际条件内容,重置 where
where = ""
}
// 处理特殊字符(按固定顺序:GROUP, HAVING, ORDER, LIMIT, OFFSET
specialOrder := []string{"GROUP", "HAVING", "ORDER", "LIMIT", "OFFSET", "DISTINCT"}
for _, vcondKey := range specialOrder {
v, exists := data[vcondKey]
if !exists {
continue
}
switch vcondKey {
case "GROUP":
where += " GROUP BY "
where += that.formatVcondValue(v)
case "HAVING":
// HAVING 条件处理
if havingMap, ok := v.(Map); ok {
havingWhere, havingRes := that.cond("AND", havingMap)
if havingWhere != "" {
where += " HAVING " + strings.TrimSpace(havingWhere) + " "
res = append(res, havingRes...)
}
}
case "ORDER":
where += " ORDER BY "
where += that.formatVcondValue(v)
case "LIMIT":
where += " LIMIT "
where += that.formatVcondValue(v)
case "OFFSET":
where += " OFFSET " + ObjToStr(v) + " "
case "DISTINCT":
// DISTINCT 通常在 SELECT 中处理,这里暂时忽略
}
}
return where, res
}
// formatVcondValue 格式化特殊关键字的值
func (that *HoTimeDB) formatVcondValue(v interface{}) string {
result := ""
if reflect.ValueOf(v).Type().String() == "common.Slice" || strings.Contains(reflect.ValueOf(v).Type().String(), "[]") {
vs := ObjToSlice(v)
for i := 0; i < len(vs); i++ {
result += " " + vs.GetString(i) + " "
if len(vs) != i+1 {
result += ", "
}
}
} else {
result += " " + ObjToStr(v) + " "
}
return result
}
// varCond 变量条件解析
func (that *HoTimeDB) varCond(k string, v interface{}) (string, []interface{}) {
where := ""
res := make([]interface{}, 0)
length := len(k)
processor := that.GetProcessor()
if k == "[#]" {
k = strings.Replace(k, "[#]", "", -1)
where += " " + ObjToStr(v) + " "
} else if k == "[##]" {
// 直接添加 SQL 片段(key 为 [##] 时)
where += " " + ObjToStr(v) + " "
} else if length > 0 && strings.Contains(k, "[") && k[length-1] == ']' {
def := false
switch Substr(k, length-3, 3) {
case "[>]":
k = strings.Replace(k, "[>]", "", -1)
k = processor.ProcessColumn(k) + " "
where += k + ">? "
res = append(res, v)
case "[<]":
k = strings.Replace(k, "[<]", "", -1)
k = processor.ProcessColumn(k) + " "
where += k + "<? "
res = append(res, v)
case "[!]":
k = strings.Replace(k, "[!]", "", -1)
k = processor.ProcessColumn(k) + " "
where, res = that.notIn(k, v, where, res)
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)
default:
def = true
}
if def {
switch Substr(k, length-4, 4) {
case "[>=]":
k = strings.Replace(k, "[>=]", "", -1)
k = processor.ProcessColumn(k) + " "
where += k + ">=? "
res = append(res, v)
case "[<=]":
k = strings.Replace(k, "[<=]", "", -1)
k = processor.ProcessColumn(k) + " "
where += k + "<=? "
res = append(res, v)
case "[><]":
k = strings.Replace(k, "[><]", "", -1)
k = processor.ProcessColumn(k) + " "
where += k + " NOT BETWEEN ? AND ? "
vs := ObjToSlice(v)
res = append(res, vs[0])
res = append(res, vs[1])
case "[<>]":
k = strings.Replace(k, "[<>]", "", -1)
k = processor.ProcessColumn(k) + " "
where += k + " BETWEEN ? AND ? "
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)
}
}
} else {
where, res = that.handlePlainField(k, v, where, res)
}
return where, res
}
// handleDefaultCondition 处理默认条件(带方括号但不是特殊操作符)
func (that *HoTimeDB) handleDefaultCondition(k string, v interface{}, where string, res []interface{}) (string, []interface{}) {
processor := that.GetProcessor()
k = processor.ProcessColumn(k) + " "
if reflect.ValueOf(v).Type().String() == "common.Slice" || strings.Contains(reflect.ValueOf(v).Type().String(), "[]") {
vs := ObjToSlice(v)
if len(vs) == 0 {
// IN 空数组 -> 生成永假条件
where += "1=0 "
return where, res
}
if len(vs) == 1 {
where += k + "=? "
res = append(res, vs[0])
return where, res
}
// IN 优化:连续整数转为 BETWEEN
where, res = that.optimizeInCondition(k, vs, where, res)
} else {
where += k + "=? "
res = append(res, v)
}
return where, res
}
// handlePlainField 处理普通字段(无方括号)
func (that *HoTimeDB) handlePlainField(k string, v interface{}, where string, res []interface{}) (string, []interface{}) {
processor := that.GetProcessor()
k = processor.ProcessColumn(k) + " "
if v == nil {
where += k + " IS NULL "
} else if reflect.ValueOf(v).Type().String() == "common.Slice" || strings.Contains(reflect.ValueOf(v).Type().String(), "[]") {
vs := ObjToSlice(v)
if len(vs) == 0 {
// IN 空数组 -> 生成永假条件
where += "1=0 "
return where, res
}
if len(vs) == 1 {
where += k + "=? "
res = append(res, vs[0])
return where, res
}
// IN 优化
where, res = that.optimizeInCondition(k, vs, where, res)
} else {
where += k + "=? "
res = append(res, v)
}
return where, res
}
// optimizeInCondition 优化 IN 条件(连续整数转为 BETWEEN)
func (that *HoTimeDB) optimizeInCondition(k string, vs Slice, where string, res []interface{}) (string, []interface{}) {
min := int64(0)
isMin := true
IsRange := true
num := int64(0)
isNum := true
where1 := ""
res1 := Slice{}
where2 := k + " IN ("
res2 := Slice{}
for kvs := 0; kvs <= len(vs); kvs++ {
vsv := int64(0)
if kvs < len(vs) {
vsv = vs.GetCeilInt64(kvs)
// 确保是全部是int类型
if ObjToStr(vsv) != vs.GetString(kvs) {
IsRange = false
break
}
}
if isNum {
isNum = false
num = vsv
} else {
num++
}
if isMin {
isMin = false
min = vsv
}
// 不等于则到了分路口
if num != vsv {
// between
if num-min > 1 {
if where1 != "" {
where1 += " OR " + k + " BETWEEN ? AND ? "
} else {
where1 += k + " BETWEEN ? AND ? "
}
res1 = append(res1, min)
res1 = append(res1, num-1)
} else {
where2 += "?,"
res2 = append(res2, min)
}
min = vsv
num = vsv
}
}
if IsRange {
where3 := ""
if where1 != "" {
where3 += where1
res = append(res, res1...)
}
if len(res2) == 1 {
if where3 == "" {
where3 += k + " = ? "
} else {
where3 += " OR " + k + " = ? "
}
res = append(res, res2...)
} else if len(res2) > 1 {
where2 = where2[:len(where2)-1]
if where3 == "" {
where3 += where2 + ")"
} else {
where3 += " OR " + where2 + ")"
}
res = append(res, res2...)
}
if where3 != "" {
where += "(" + where3 + ")"
}
return where, res
}
// 非连续整数,使用普通 IN
where += k + " IN ("
res = append(res, vs...)
for i := 0; i < len(vs); i++ {
if i+1 != len(vs) {
where += "?,"
} else {
where += "?) "
}
}
return where, res
}
// notIn NOT IN 条件处理
func (that *HoTimeDB) notIn(k string, v interface{}, where string, res []interface{}) (string, []interface{}) {
if v == nil {
where += k + " IS NOT NULL "
} else if reflect.ValueOf(v).Type().String() == "common.Slice" || strings.Contains(reflect.ValueOf(v).Type().String(), "[]") {
vs := ObjToSlice(v)
if len(vs) == 0 {
return where, res
}
where += k + " NOT IN ("
res = append(res, vs...)
for i := 0; i < len(vs); i++ {
if i+1 != len(vs) {
where += "?,"
} else {
where += "?) "
}
}
} else {
where += k + " !=? "
res = append(res, v)
}
return where, res
}
// cond 条件组合处理
func (that *HoTimeDB) cond(tag string, data Map) (string, []interface{}) {
where := " "
res := make([]interface{}, 0)
lens := len(data)
testQu := []string{}
for key := range data {
testQu = append(testQu, key)
}
sort.Strings(testQu)
for _, k := range testQu {
v := data[k]
x := 0
for i := 0; i < len(condition); i++ {
if condition[i] == strings.ToUpper(k) {
tw, ts := that.cond(strings.ToUpper(k), v.(Map))
if lens--; lens <= 0 {
where += "(" + tw + ") "
} else {
where += "(" + tw + ") " + tag + " "
}
res = append(res, ts...)
break
}
x++
}
if x == len(condition) {
tv, vv := that.varCond(k, v)
if tv == "" {
lens--
continue
}
res = append(res, vv...)
if lens--; lens <= 0 {
where += tv + ""
} else {
where += tv + " " + tag + " "
}
}
}
return where, res
}
+894
View File
@@ -0,0 +1,894 @@
# HoTime 代码生成
前置依赖:生成前请先满足 [数据库设计规范](DatabaseDesign_数据库设计规范.md)(表命名、外键、COMMENT、必有字段)。代码生成器依赖规范的表结构与字段备注,才能正确识别类型、选项、外键关联与显示标签。
`code` 包提供了 HoTime 框架的自动代码生成功能,能够根据数据库表结构自动生成 CRUD 接口代码和配置文件。
## 目录
- [一、功能概述](#一功能概述)
- [二、使用方法](#二使用方法)
- [三、配置体系](#三配置体系)
- [3.1 codeConfig](#31-codeconfig)
- [3.2 菜单权限配置(admin.json](#32-菜单权限配置adminjson)
- [3.3 字段规则配置(rule.json](#33-字段规则配置rulejson)
- [3.4 配置检查清单](#34-配置检查清单)
- [四、生成规则与产物](#四生成规则与产物)
- [4.1 默认字段规则](#41-默认字段规则)
- [4.2 数据类型映射](#42-数据类型映射)
- [4.3 字段备注解析](#43-字段备注解析)
- [4.4 外键关联](#44-外键关联)
- [4.5 生成的代码结构](#45-生成的代码结构)
- [4.6 多库与备注注意事项](#46-多库与备注注意事项)
- [4.7 最佳实践](#47-最佳实践)
- [五、相关文档](#五相关文档)
---
## 一、功能概述
代码生成器可以:
1. **自动读取数据库表结构** - 支持 MySQL、SQLite 和达梦 DM8
2. **生成 CRUD 接口** - 增删改查、搜索、分页
3. **生成配置文件** - 表字段配置、菜单配置、权限配置
4. **智能字段识别** - 根据字段名自动识别类型和权限
5. **支持表关联** - 自动识别外键关系
配置体系概览:
```
config.json
└── codeConfig[] # 代码生成配置数组(支持多套)
├── config # 菜单权限配置文件路径(如 admin.json
├── configDB # 数据库生成的完整配置
├── rule # 字段规则配置文件路径(如 rule.json)
├── table # 管理员表名
├── name # 生成代码的包名
└── mode # 生成模式
```
---
## 二、使用方法
### 2.1 基础配置
`config.json` 中配置开发模式与代码生成:
```json
{
"mode": 2,
"codeConfig": [
{
"table": "admin",
"config": "config/admin.json",
"rule": "config/rule.json",
"mode": 0
}
]
}
```
### 2.2 启动应用
```go
package main
import (
. "code.hoteas.com/golang/hotime"
. "code.hoteas.com/golang/hotime/common"
)
func main() {
app := Init("config/config.json")
// 代码生成器在 Init 时自动执行
// 会读取数据库结构并生成配置
app.Run(Router{
// 路由配置
})
}
```
### 2.3 开发模式
`config.json` 中设置 `"mode": 2`(开发模式)时:
- 自动读取数据库表结构
- 自动生成/更新配置文件
- 自动生成代码(如果 `codeConfig.mode=1`
### 2.4 推荐开发流程
1. 设置 `config.json``mode: 2`(开发模式)
2. 按 [数据库设计规范](DatabaseDesign_数据库设计规范.md) 设计表结构并添加字段备注
3. 启动应用,自动生成配置
4. 检查生成的配置文件,按需调整
5. 生产环境改为 `mode: 0`
---
## 三、配置体系
### 3.1 codeConfig
#### 配置结构
```json
{
"codeConfig": [
{
"config": "config/admin.json",
"configDB": "config/adminDB.json",
"mode": 0,
"name": "",
"rule": "config/rule.json",
"table": "admin"
}
]
}
```
#### 配置项说明
| 字段 | 类型 | 必须 | 说明 |
|------|------|------|------|
| `table` | string | ✅ | 管理员/用户表名,用于身份验证和权限控制 |
| `config` | string | ✅ | 菜单权限配置文件路径,用于定义菜单结构、权限控制 |
| `configDB` | string | ❌ | 代码生成器输出的完整配置文件(有则每次自动生成) |
| `rule` | string | ❌ | 字段规则配置文件路径,无则使用默认规则 |
| `name` | string | ❌ | 生成的代码包名和目录名,为空则不生成独立代码文件 |
| `mode` | int | ❌ | 生成模式:0=仅配置不生成代码(内嵌模式),非 0=生成代码文件 |
#### 运行模式
- **mode=0(内嵌模式)**:不生成独立代码文件,使用框架内置的通用控制器
- **mode≠0(生成模式)**:为每张表生成独立的 Go 控制器文件
#### 多配置支持
可以配置多个独立的代码生成实例,适用于多端场景:
```json
{
"codeConfig": [
{
"config": "config/admin.json",
"table": "admin",
"rule": "config/rule.json"
},
{
"config": "config/user.json",
"table": "user",
"rule": "config/rule.json"
}
]
}
```
---
### 3.2 菜单权限配置(admin.json
#### 配置文件更新机制
- **首次运行**:代码生成器会根据数据库表结构自动创建配置文件(如 admin.json
- **后续运行**:配置文件**不会自动更新**,避免覆盖手动修改的内容
- **重新生成**:如需重新生成,删除配置文件后重新运行即可
- **参考更新**:可参考 `configDB` 指定的文件(如 adminDB.json)查看最新的数据库结构变化,手动调整配置
#### 完整配置结构
```json
{
"id": "唯一标识(自动生成)",
"name": "admin",
"label": "管理平台名称",
"labelConfig": { ... },
"menus": [ ... ],
"flow": { ... }
}
```
#### label / labelConfig
```json
{
"label": "HoTime管理平台",
"labelConfig": {
"show": "开启",
"add": "添加",
"delete": "删除",
"edit": "编辑",
"info": "查看详情",
"download": "下载清单"
}
}
```
| 操作 | 说明 |
|------|------|
| show | 显示/查看列表权限 |
| add | 添加数据权限 |
| delete | 删除数据权限 |
| edit | 编辑数据权限 |
| info | 查看详情权限 |
| download | 下载/导出权限 |
#### menus 配置
定义菜单结构,支持多级嵌套(目前只支持**两级菜单**):
```json
{
"menus": [
{
"label": "系统管理",
"name": "sys",
"icon": "Setting",
"auth": ["show"],
"menus": [
{
"label": "用户管理",
"table": "user",
"auth": ["show", "add", "delete", "edit", "info", "download"]
},
{
"label": "角色管理",
"table": "role",
"auth": ["show", "add", "delete", "edit", "info"]
}
]
},
{
"label": "文章管理",
"table": "article",
"icon": "Document",
"auth": ["show", "add", "edit", "info"]
}
]
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| label | string | 菜单显示名称 |
| name | string | 菜单标识(用于分组,不绑定表时使用) |
| table | string | 绑定的数据表名(用于自动生成 CRUD) |
| icon | string | 菜单图标名称(Element Plus 图标名,如 `Setting``User``Document` |
| auth | array | 权限数组,定义该菜单/表拥有的操作权限 |
| menus | array | 子菜单数组(支持嵌套) |
**name vs table**
- **table**:绑定数据表,拥有该表的增删查改等权限,自动生成 CRUD 接口
- **name**:自定义功能标识,不绑定表,前端根据 name 和 auth 显示自定义内容(如首页 home、仪表盘 dashboard
- 配置了 `menus` 子菜单 → 作为分组
- 没有 `menus` → 作为独立自定义功能入口
```json
// 使用 table:绑定数据表
{ "label": "用户管理", "table": "user", "auth": ["show", "add", "edit", "delete"] }
// 使用 name:自定义功能(无子菜单)
{ "label": "首页", "name": "home", "icon": "House", "auth": ["show"] }
// 使用 name:分组功能(有子菜单)
{ "label": "系统管理", "name": "sys", "icon": "Setting", "auth": ["show"], "menus": [...] }
```
**自动分组规则**:代码生成器会根据表名的 `_` 分词自动分组:
- 表名 `sys_logs``sys_menus``sys_config` → 归入 `sys` 分组
- 表名 `article``article_tag` → 归入 `article` 分组
分组的显示名称(label)使用该分组下**第一张表的名字**,如果表有备注则使用备注名。
#### auth 配置
| 权限 | 对应接口 | 说明 |
|------|----------|------|
| show | /search | 列表查询 |
| add | /add | 新增数据 |
| delete | /remove | 删除数据 |
| edit | /update | 编辑数据 |
| info | /info | 查看详情 |
| download | /search?download=1 | 导出数据 |
auth 数组**可以自由增删**。新增的权限项会在前端菜单/功能中显示,并在角色管理(role)的权限设置中供管理员分配:
```json
{
"label": "文章管理",
"table": "article",
"auth": ["show", "add", "edit", "delete", "info", "publish", "audit", "top"]
}
```
上例中 `publish`(发布)、`audit`(审核)、`top`(置顶)为自定义权限。
#### flow 配置(数据权限)
flow 用于限制用户只能操作自己权限范围内的数据。
```json
{
"flow": {
"role": {
"table": "role",
"stop": true,
"sql": {
"id": "role_id"
}
},
"article": {
"table": "article",
"stop": false,
"sql": {
"admin_id": "id"
}
},
"org": {
"table": "org",
"stop": false,
"sql": {
"parent_ids[~]": ",org_id,"
}
}
}
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| table | string | 表名 |
| stop | bool | 是否禁止修改自身关联的数据 |
| sql | object | 数据过滤条件,自动填充查询/操作条件 |
**stop**:防止用户修改自己当前关联的敏感数据。例如当前用户 `role_id = 1`,配置 `stop: true` 后,不能修改 role 表中 `id = 1` 的记录,但可修改其他 role(若有权限)。典型用途:防止用户提升自己的角色权限、修改自己所属组织。
**sql**:格式为 `{ "目标表字段": "当前用户字段" }`
| 格式 | 含义 | SQL 等价 |
|------|------|----------|
| `"field": "user_field"` | 精确匹配 | `field = 用户.user_field` |
| `"field[~]": ",value,"` | 模糊匹配 | `field LIKE '%,value,%'` |
示例效果(当前用户 `{ "id": 5, "role_id": 2, "org_id": 10 }`):
| 表 | 查询条件 | stop 效果 |
|-----|----------|-----------|
| role | `WHERE id = 2` | 不能修改 id=2 的角色 |
| article | `WHERE admin_id = 5` | 可以修改自己的文章 |
| org | `WHERE parent_ids LIKE '%,10,%'` | 可以修改下级组织 |
#### 完整 admin.json 示例
```json
{
"id": "74a8a59407fa7d6c7fcdc85742dbae57",
"name": "admin",
"label": "后台管理系统",
"labelConfig": {
"show": "开启",
"add": "添加",
"delete": "删除",
"edit": "编辑",
"info": "查看详情",
"download": "下载清单"
},
"menus": [
{
"label": "系统管理",
"name": "sys",
"icon": "Setting",
"auth": ["show"],
"menus": [
{
"label": "日志管理",
"table": "logs",
"auth": ["show", "download"]
},
{
"label": "角色管理",
"table": "role",
"auth": ["show", "add", "delete", "edit", "info"]
},
{
"label": "组织管理",
"table": "org",
"auth": ["show", "add", "delete", "edit", "info"]
},
{
"label": "员工管理",
"table": "admin",
"auth": ["show", "add", "delete", "edit", "info", "download"]
}
]
}
],
"flow": {
"admin": {
"table": "admin",
"stop": false,
"sql": { "role_id": "role_id" }
},
"role": {
"table": "role",
"stop": true,
"sql": { "admin_id": "id", "id": "role_id" }
},
"org": {
"table": "org",
"stop": false,
"sql": { "admin_id": "id" }
},
"logs": {
"table": "logs",
"stop": false,
"sql": {}
}
}
}
```
> 说明:生成器还会在配置中产生 `tables`(字段列、搜索项等)。首次生成可参考 `configDB`(如 adminDB.json)中的结构;持久化自定义请写入 `config` 指定的文件(如 admin.json),因 configDB 会在每次启动时重新生成。
---
### 3.3 字段规则配置(rule.json
定义字段在增删改查操作中的默认行为。
#### 配置结构
```json
[
{
"name": "id",
"add": false,
"edit": false,
"info": true,
"list": true,
"must": false,
"strict": true,
"type": ""
},
{
"name": "status",
"list": true,
"add": true,
"edit": true,
"info": true,
"must": false,
"strict": false,
"type": "select"
},
{
"name": "user.special_field",
"list": true,
"add": true,
"edit": true,
"info": true,
"type": "text",
"strict": true
}
]
```
#### 字段属性说明
| 属性 | 类型 | 说明 |
|------|------|------|
| name | string | 字段名或关键词;支持 `表名.字段名` 精确匹配 |
| add | bool | 新增时是否显示 |
| edit | bool | 编辑时是否显示 |
| info | bool | 详情页是否显示 |
| list | bool | 列表页是否显示 |
| must | bool | 是否必填(见下方) |
| strict | bool | 是否严格匹配字段名(true=完全匹配,false=包含匹配) |
| type | string | 字段类型(影响前端控件和数据处理) |
#### must 必填字段规则
**自动识别**
1. **MySQL**:字段为 `NOT NULL``IS_NULLABLE='NO'`)时自动 `must=true`
2. **SQLite**:字段为主键(`pk=1`)时自动 `must=true`
**规则覆盖**`rule.json` 中的 `must` 会覆盖数据库自动识别结果。
**效果**
- 前端:`must=true` 显示必填标记(*),提交时校验
- 后端:新增时若 `must=true` 字段为空,返回「请求参数不足」
#### type 类型说明
| 类型 | 说明 | 前端控件 |
|------|------|----------|
| (空) | 普通文本 | 文本输入框 |
| text | 文本 | 文本输入框 |
| number | 数字 | 数字输入框 |
| select | 选择 | 下拉选择框(根据注释自动生成选项) |
| time | 时间(datetime | 日期时间选择器 |
| unixTime | 时间戳 | 日期时间选择器(存储为 Unix 时间戳) |
| password | 密码 | 密码输入框(自动 MD5 加密) |
| textArea | 多行文本 | 文本域 |
| image | 图片 | 图片上传 |
| file | 文件 | 文件上传 |
| money | 金额 | 金额输入框 |
| auth | 权限 | 权限树选择器 |
| form | 表单 | 动态表单 |
| index | 索引 | 隐藏字段(用于 parent_ids 等) |
| tree | 树形选择 | 树形选择器 |
| table | 动态表 | 表名选择器 |
| table_id | 动态表ID | 根据 table 字段动态关联 |
#### 内置字段规则(可在 rule.json 中覆盖)
**主键和索引**
```json
{"name": "id", "add": false, "list": true, "edit": false, "info": true, "strict": true}
{"name": "sn", "add": false, "list": true, "edit": false, "info": true}
{"name": "parent_ids", "add": false, "list": false, "edit": false, "info": false, "type": "index", "strict": true}
{"name": "index", "add": false, "list": false, "edit": false, "info": false, "type": "index", "strict": true}
```
**层级关系**
```json
{"name": "parent_id", "add": true, "list": true, "edit": true, "info": true}
{"name": "level", "add": false, "list": false, "edit": false, "info": true}
```
**时间字段**
```json
{"name": "create_time", "add": false, "list": false, "edit": false, "info": true, "type": "time", "strict": true}
{"name": "modify_time", "add": false, "list": true, "edit": false, "info": true, "type": "time", "strict": true}
{"name": "time", "add": true, "list": true, "edit": true, "info": true, "type": "time"}
```
**状态字段**
```json
{"name": "status", "add": true, "list": true, "edit": true, "info": true, "type": "select"}
{"name": "state", "add": true, "list": true, "edit": true, "info": true, "type": "select"}
{"name": "sex", "add": true, "list": true, "edit": true, "info": true, "type": "select"}
```
**敏感字段**
```json
{"name": "password", "add": true, "list": false, "edit": true, "info": false, "type": "password"}
{"name": "pwd", "add": true, "list": false, "edit": true, "info": false, "type": "password"}
{"name": "delete", "add": false, "list": false, "edit": false, "info": false}
{"name": "version", "add": false, "list": false, "edit": false, "info": false}
```
**媒体字段**
```json
{"name": "image", "add": true, "list": false, "edit": true, "info": true, "type": "image"}
{"name": "img", "add": true, "list": false, "edit": true, "info": true, "type": "image"}
{"name": "avatar", "add": true, "list": false, "edit": true, "info": true, "type": "image"}
{"name": "icon", "add": true, "list": false, "edit": true, "info": true, "type": "image"}
{"name": "file", "add": true, "list": false, "edit": true, "info": true, "type": "file"}
```
**文本字段**
```json
{"name": "info", "add": true, "list": false, "edit": true, "info": true, "type": "textArea"}
{"name": "content", "add": true, "list": false, "edit": true, "info": true, "type": "textArea"}
{"name": "description", "add": true, "list": false, "edit": true, "info": true}
{"name": "note", "add": true, "list": false, "edit": true, "info": true}
{"name": "address", "add": true, "list": true, "edit": true, "info": true}
```
**特殊字段**
```json
{"name": "amount", "add": true, "list": true, "edit": true, "info": true, "type": "money", "strict": true}
{"name": "auth", "add": true, "list": false, "edit": true, "info": true, "type": "auth", "strict": true}
{"name": "rule", "add": true, "list": true, "edit": true, "info": true, "type": "form"}
{"name": "table", "add": false, "list": true, "edit": false, "info": true, "type": "table"}
{"name": "table_id", "add": false, "list": true, "edit": false, "info": true, "type": "table_id"}
```
#### 自定义字段规则
```json
[
{
"name": "company_name",
"add": true,
"edit": true,
"info": true,
"list": true,
"must": true,
"strict": true,
"type": ""
},
{
"name": "user.nickname",
"add": true,
"edit": true,
"info": true,
"list": true,
"strict": true,
"type": ""
}
]
```
---
### 3.4 配置检查清单
#### codeConfig
- [ ] config 文件路径正确
- [ ] rule 文件路径正确
- [ ] table 指定的管理员表存在
#### 菜单权限配置
- [ ] 所有 table 指向的表在数据库中存在
- [ ] auth 数组包含需要的权限
- [ ] menus 结构正确(有子菜单用 name,无子菜单用 table)
- [ ] flow 配置的 sql 条件字段存在
#### 字段规则配置
- [ ] strict=true 的规则字段名完全匹配
- [ ] type 类型与前端控件需求一致
- [ ] 敏感字段(password 等)的 list 和 info 为 false
---
## 四、生成规则与产物
### 4.1 默认字段规则
代码生成器内置的字段识别一览(完整内置规则见 [3.3](#33-字段规则配置rulejson)):
| 字段名 | 列表显示 | 新增 | 编辑 | 详情 | 类型 |
|--------|----------|------|------|------|------|
| `id` | ✅ | ❌ | ❌ | ✅ | number |
| `name` | ✅ | ✅ | ✅ | ✅ | text |
| `status` | ✅ | ✅ | ✅ | ✅ | select |
| `create_time` | ❌ | ❌ | ❌ | ✅ | time |
| `modify_time` | ✅ | ❌ | ❌ | ✅ | time |
| `password` | ❌ | ✅ | ✅ | ❌ | password |
| `image/img/avatar` | ❌ | ✅ | ✅ | ✅ | image |
| `file` | ❌ | ✅ | ✅ | ✅ | file |
| `content/info` | ❌ | ✅ | ✅ | ✅ | textArea |
| `parent_id` | ✅ | ✅ | ✅ | ✅ | number |
| `parent_ids/index` | ❌ | ❌ | ❌ | ❌ | index |
| `delete` | ❌ | ❌ | ❌ | ❌ | - |
### 4.2 数据类型映射
| 数据库类型 | 生成类型 |
|------------|----------|
| `int`, `integer`, `float`, `double`, `decimal` | number |
| `char`, `varchar`, `text`, `blob` | text |
| `date`, `datetime`, `time`, `timestamp`, `year` | time |
### 4.3 字段备注解析
字段 COMMENT 的完整语法、选项写法与设计约定,以 [数据库设计规范](DatabaseDesign_数据库设计规范.md) 为准。此处仅说明生成器如何消费备注。
支持从数据库字段备注中提取标签、提示与选项(括号支持 `()``()``{}`):
```sql
-- 格式: 标签名(提示信息):选项1-名称1,选项2-名称2
status TINYINT COMMENT '状态(用户账号状态):0-禁用,1-启用'
parent_id INT COMMENT '父级ID(顶级为NULL'
phone VARCHAR(20) COMMENT '手机号(请输入11位手机号)'
```
生成的配置示例:
```json
{
"name": "status",
"label": "状态",
"type": "select",
"ps": "用户账号状态",
"options": [
{"name": "禁用", "value": "0"},
{"name": "启用", "value": "1"}
]
}
```
`ps` 字段在前端的展示效果:
- 编辑/新增页面:输入框右侧灰色小字提示
- 表格/详情页面:鼠标悬停字段名时气泡提示
### 4.4 外键关联
代码生成器会自动识别 `_id` 结尾的字段作为外键:
```sql
CREATE TABLE user (
id INT PRIMARY KEY,
name VARCHAR(50),
role_id INT, -- 自动关联 role 表
org_id INT -- 自动关联 org 表
);
```
生成的配置会包含 `link``value` 字段:
```json
{
"name": "role_id",
"type": "number",
"label": "角色",
"link": "role",
"value": "name"
}
```
`parent_id` 会被识别为树形结构的父级关联:
```json
{
"name": "parent_id",
"type": "number",
"label": "上级",
"link": "org",
"value": "name"
}
```
表命名、外键命名与 COMMENT 约定见 [数据库设计规范](DatabaseDesign_数据库设计规范.md)。
#### 树形表查询参数(parent_id / showself / showall
`parent_id` 列的表,通用 CRUD 的 `search` 接口支持树查询参数,语义如下(`code/makecode.go` 树节点分支):
| 请求参数组合 | WHERE 语义 | 典型场景 |
| --- | --- | --- |
| 不传 `parent_id``parent_id=0` | `id=当前用户锚点 OR parent_id IS NULL`(返回根层) | 树侧栏根节点加载 |
| `parent_id=X` | `parent_id=X`,仅直接子级 | 树节点懒加载展开 |
| `parent_id=X&showall=1` | `parent_ids LIKE '%,X,%'`,X 及其全部子孙(X 自身的 parent_ids 含 `,X,` | 列表按树节点过滤 |
| `parent_id=X&showall=1&showself=1` | 在 showall 基础上再 `OR id=X`,显式保证含 X 自身 | 管理端列表树筛选(Table.vue |
**规则**`showself=1` 仅在 `showall=1` 时生效。普通子级查询(仅 `parent_id=X`)**不会**把 id=X 的行本身混入结果——否则前端树会把节点当作自己的子级,造成无限嵌套(历史缺陷,已修复;回归用例见 `example/app/makecode_tree_test.go``go test ./app/ -count=1 -run 'TestApi/admin/department/search'`)。
#### 通用列表字段筛选语义(text 类型只走模糊匹配)
通用 CRUD 的 `search` 接口为每个 `list` 可见字段自动生成筛选条件(`code/makecode.go` `(*MakeCode).Search`),按字段最终生效的 `type`(见 [4.2 数据类型映射](#42-数据类型映射),可被 [3.3 字段规则配置(rule.json](#33-字段规则配置rulejson) 覆盖)分两种语义:
| 字段 type | 请求 `?字段名=值` 的 WHERE 语义 | 说明 |
| --- | --- | --- |
| 含 `"text"` 子串(`text`/`textArea` | `字段名 LIKE '%值%'`(模糊,仅此一条) | 前端筛选框允许只填部分关键词 |
| 其他(`number`/`select`/`time`/`money`… 等非 text | `字段名 = 值`(精确等值) | 维持现状不变 |
**规则**:文本类型字段**只生成模糊匹配条件,不再叠加同名等值条件**。历史缺陷:曾对所有 list 字段先生成等值条件 `字段名=值`,再对 text 类型**额外**叠加模糊条件 `字段名[~]=值`,两者在 where 里以 AND 叠加——前端筛选框只传部分关键词时等值条件必不命中,AND 之后整条查询恒为空(下游"文本列筛选查不出数据"的通用根因)。已修复;`keyword`+`keywordtable` 全局关键词搜索逻辑不受影响。回归用例见 `example/app/makecode_tree_test.go` 的「文本字段部分关键词命中模糊匹配」「非文本字段等值筛选行为不变」两个用例,`go test ./app/ -count=1 -run 'TestApi/admin/department/search'`
### 4.5 生成的代码结构
#### 内嵌模式 (mode=0)
不生成代码文件,使用框架内置控制器,只生成配置文件:
```
config/
├── admin.json # 接口/菜单权限配置
├── adminDB.json # 数据库结构配置(可选)
└── rule.json # 字段规则
```
#### 生成模式 (mode≠0)
生成独立的控制器代码:
```
admin/ # 生成的包目录(由 codeConfig.name 决定)
├── init.go # 包初始化和路由注册
├── user.go # user 表控制器
├── role.go # role 表控制器
└── ... # 其他表控制器
```
#### 生成的控制器结构
```go
package admin
var userCtr = Ctr{
"info": func(that *Context) {
// 查询单条记录
},
"add": func(that *Context) {
// 新增记录
},
"update": func(that *Context) {
// 更新记录
},
"remove": func(that *Context) {
// 删除记录
},
"search": func(that *Context) {
// 搜索列表(分页)
},
}
```
### 4.6 多库与备注注意事项
#### 达梦 DM8
代码生成器已原生支持达梦 DM8,会自动读取表结构和字段备注。注意:
- 表注释通过 `COMMENT ON TABLE` 单独设置,建表后需额外执行
- 字段备注通过 `COMMENT ON COLUMN` 设置
- 已对 DM 的 `USER_TAB_COLUMNS` 与 MySQL `information_schema` 差异做兼容
```sql
COMMENT ON TABLE "user" IS '用户管理';
COMMENT ON COLUMN "user"."state" IS '状态:0-正常,1-异常,2-隐藏';
COMMENT ON COLUMN "user"."name" IS '用户名';
```
#### SQLite 备注替代方案
SQLite 不支持表/字段 COMMENT,生成器会使用表名/字段名作为默认显示名称。可利用配置覆盖机制手动设置:
1. 首次运行生成配置文件(如 admin.json
2. 在菜单中设置表的 `label`
3.`tables.表名.columns` 中设置字段 `label``ps``options`
```json
{
"tables": {
"user": {
"label": "用户管理",
"columns": [
{
"name": "name",
"label": "用户名",
"ps": "请输入用户名",
"must": true
},
{
"name": "status",
"label": "状态",
"type": "select",
"options": [
{"name": "正常", "value": "0"},
{"name": "禁用", "value": "1"}
]
}
]
}
}
}
```
**注意**`configDB` 文件每次启动会重新生成。持久化修改应写入 `config` 指定的文件(如 admin.json)。
### 4.7 最佳实践
1. **字段命名**:使用规范字段名(`id``name``status``create_time``modify_time``xxx_id``parent_id``avatar``content` 等),便于自动识别。完整约定见 [数据库设计规范](DatabaseDesign_数据库设计规范.md)。
2. **自定义扩展**:默认规则不足时,可修改 `rule.json`、使用生成模式后手动改代码,或直接调整配置文件中的字段属性。
3. **生产环境**:开发用 `mode: 2`,上线改为 `mode: 0`,避免运行时反复改写配置/代码。
---
## 五、相关文档
- [数据库设计规范](DatabaseDesign_数据库设计规范.md)(代码生成前置依赖)
- [HoTimeDB 使用说明](HoTimeDB_使用说明.md)
- [快速上手](QuickStart_快速上手.md)
- [Common 工具类](Common_工具类.md)
+485
View File
@@ -0,0 +1,485 @@
# HoTime Common 工具类使用说明
`common` 包提供了 HoTime 框架的核心数据类型和工具函数,包括 `Map``Slice``Obj` 类型及丰富的类型转换函数。
## 目录
- [核心数据类型](#核心数据类型)
- [Map 类型](#map-类型)
- [Slice 类型](#slice-类型)
- [Obj 类型](#obj-类型)
- [类型转换函数](#类型转换函数)
- [工具函数](#工具函数)
- [错误处理](#错误处理)
---
## 核心数据类型
### Map 类型
`Map``map[string]interface{}` 的别名,提供了丰富的链式调用方法。
```go
import . "code.hoteas.com/golang/hotime/common"
// 创建 Map
data := Map{
"name": "张三",
"age": 25,
"score": 98.5,
"active": true,
"tags": Slice{"Go", "Web"},
}
```
#### 获取值方法
```go
// 获取字符串
name := data.GetString("name") // "张三"
// 获取整数
age := data.GetInt("age") // 25
age64 := data.GetInt64("age") // int64(25)
// 获取浮点数
score := data.GetFloat64("score") // 98.5
// 获取布尔值
active := data.GetBool("active") // true
// 获取嵌套 Map
info := data.GetMap("info") // 返回 Map 类型
// 获取 Slice
tags := data.GetSlice("tags") // 返回 Slice 类型
// 获取时间
createTime := data.GetTime("create_time") // 返回 *time.Time
// 获取原始值
raw := data.Get("name") // interface{}
```
#### 向上取整方法
```go
// 向上取整获取整数
ceilInt := data.GetCeilInt("score") // 99
ceilInt64 := data.GetCeilInt64("score") // int64(99)
ceilFloat := data.GetCeilFloat64("score") // 99.0
```
#### 操作方法
```go
// 添加/修改值
data.Put("email", "test@example.com")
// 删除值
data.Delete("email")
// 转换为 JSON 字符串
jsonStr := data.ToJsonString()
// 从 JSON 字符串解析
data.JsonToMap(`{"key": "value"}`)
```
#### 有序遍历
```go
// 按 key 字母顺序遍历
data.RangeSort(func(k string, v interface{}) bool {
fmt.Printf("%s: %v\n", k, v)
return false // 返回 true 则终止遍历
})
```
#### 转换为结构体
```go
type User struct {
Name string
Age int64
Score float64
}
var user User
data.ToStruct(&user) // 传入指针,字段名首字母大写匹配
```
---
### Slice 类型
`Slice``[]interface{}` 的别名,提供类似 Map 的链式调用方法。
```go
// 创建 Slice
list := Slice{
Map{"id": 1, "name": "Alice"},
Map{"id": 2, "name": "Bob"},
"text",
123,
}
```
#### 获取值方法
```go
// 按索引获取值(类型转换)
str := list.GetString(2) // "text"
num := list.GetInt(3) // 123
num64 := list.GetInt64(3) // int64(123)
f := list.GetFloat64(3) // 123.0
b := list.GetBool(3) // true (非0为true)
// 获取嵌套类型
item := list.GetMap(0) // Map{"id": 1, "name": "Alice"}
subList := list.GetSlice(0) // 尝试转换为 Slice
// 获取原始值
raw := list.Get(0) // interface{}
// 获取时间
t := list.GetTime(0) // *time.Time
```
#### 向上取整方法
```go
ceilInt := list.GetCeilInt(3)
ceilInt64 := list.GetCeilInt64(3)
ceilFloat := list.GetCeilFloat64(3)
```
#### 操作方法
```go
// 修改指定位置的值
list.Put(0, "new value")
// 转换为 JSON 字符串
jsonStr := list.ToJsonString()
```
---
### Obj 类型
`Obj` 是一个通用的对象包装器,用于链式类型转换,常用于 `Context` 方法的返回值。
```go
type Obj struct {
Data interface{} // 原始数据
Error // 错误信息
}
```
#### 使用示例
```go
obj := &Obj{Data: "123"}
// 链式类型转换
i := obj.ToInt() // 123
i64 := obj.ToInt64() // int64(123)
f := obj.ToFloat64() // 123.0
s := obj.ToStr() // "123"
b := obj.ToBool() // true
// 复杂类型转换
m := obj.ToMap() // 尝试转换为 Map
sl := obj.ToSlice() // 尝试转换为 Slice
arr := obj.ToMapArray() // 转换为 []Map
// 获取原始值
raw := obj.ToObj() // interface{}
// 获取时间
t := obj.ToTime() // *time.Time
// 向上取整
ceil := obj.ToCeilInt()
ceil64 := obj.ToCeilInt64()
ceilF := obj.ToCeilFloat64()
```
#### 在 Context 中的应用
```go
func handler(that *Context) {
// ReqData 返回 *Obj,支持链式调用
userId := that.ReqData("user_id").ToInt()
name := that.ReqData("name").ToStr()
// Session 也返回 *Obj
adminId := that.Session("admin_id").ToInt64()
}
```
---
## 类型转换函数
`common` 包提供了一系列全局类型转换函数。
### 基础转换
```go
// 转字符串
str := ObjToStr(123) // "123"
str := ObjToStr(3.14) // "3.14"
str := ObjToStr(Map{"a": 1}) // JSON 格式字符串
// 转整数
i := ObjToInt("123") // 123
i64 := ObjToInt64("123") // int64(123)
// 转浮点数
f := ObjToFloat64("3.14") // 3.14
// 转布尔
b := ObjToBool(1) // true
b := ObjToBool(0) // false
// 转 Map
m := ObjToMap(`{"a": 1}`) // Map{"a": 1}
m := ObjToMap(someStruct) // 结构体转 Map
// 转 Slice
s := ObjToSlice(`[1, 2, 3]`) // Slice{1, 2, 3}
// 转 []Map
arr := ObjToMapArray(slice) // []Map
```
### 向上取整转换
```go
// 向上取整后转整数
ceil := ObjToCeilInt(3.2) // 4
ceil64 := ObjToCeilInt64(3.2) // int64(4)
ceilF := ObjToCeilFloat64(3.2) // 4.0
```
### 时间转换
```go
// 自动识别多种格式
t := ObjToTime("2024-01-15 10:30:00") // *time.Time
t := ObjToTime("2024-01-15") // *time.Time
t := ObjToTime(1705298400) // Unix 秒
t := ObjToTime(1705298400000) // Unix 毫秒
t := ObjToTime(1705298400000000) // Unix 微秒
```
### 字符串转换
```go
// 字符串转 Map
m := StrToMap(`{"key": "value"}`)
// 字符串转 Slice
s := StrToSlice(`[1, 2, 3]`)
// 字符串转 int
i, err := StrToInt("123")
// 字符串数组格式转换
jsonArr := StrArrayToJsonStr("a1,a2,a3") // "[a1,a2,a3]"
strArr := JsonStrToStrArray("[a1,a2,a3]") // ",a1,a2,a3,"
```
### 错误处理
所有转换函数支持可选的错误参数:
```go
var e Error
i := ObjToInt("abc", &e)
if e.GetError() != nil {
// 处理转换错误
}
```
---
## 工具函数
### 字符串处理
```go
// 字符串截取(支持中文)
str := Substr("Hello世界", 0, 7) // "Hello世"
str := Substr("Hello", -2, 2) // "lo" (负数从末尾计算)
// 首字母大写
upper := StrFirstToUpper("hello") // "Hello"
// 查找最后出现位置
idx := IndexLastStr("a.b.c", ".") // 3
// 字符串相似度(Levenshtein 距离)
dist := StrLd("hello", "hallo", true) // 1 (忽略大小写)
```
### 时间处理
```go
// 时间转字符串
str := Time2Str(time.Now()) // "2024-01-15 10:30:00"
str := Time2Str(time.Now(), 1) // "2024-01"
str := Time2Str(time.Now(), 2) // "2024-01-15"
str := Time2Str(time.Now(), 3) // "2024-01-15 10"
str := Time2Str(time.Now(), 4) // "2024-01-15 10:30"
str := Time2Str(time.Now(), 5) // "2024-01-15 10:30:00"
// 特殊格式
str := Time2Str(time.Now(), 12) // "01-15"
str := Time2Str(time.Now(), 14) // "01-15 10:30"
str := Time2Str(time.Now(), 34) // "10:30"
str := Time2Str(time.Now(), 35) // "10:30:00"
```
### 加密与随机
```go
// MD5 加密
hash := Md5("password") // 32位小写MD5
// 随机数
r := Rand(3) // 3位随机数 (0-999)
r := RandX(10, 100) // 10-100之间的随机数
```
### 数学计算
```go
// 四舍五入保留小数
f := Round(3.14159, 2) // 3.14
f := Round(3.145, 2) // 3.15
```
### 深拷贝
```go
// 深拷贝 Map/Slice(递归复制)
original := Map{"a": Map{"b": 1}}
copied := DeepCopyMap(original).(Map)
// 修改副本不影响原始数据
copied.GetMap("a")["b"] = 2
// original["a"]["b"] 仍然是 1
```
---
## 错误处理
### Error 类型
```go
type Error struct {
Logger *logrus.Logger // 可选的日志记录器
error // 内嵌错误
}
```
### 使用示例
```go
var e Error
// 设置错误
e.SetError(errors.New("something wrong"))
// 获取错误
if err := e.GetError(); err != nil {
fmt.Println(err)
}
// 配合日志自动记录
e.Logger = logrusLogger
e.SetError(errors.New("will be logged"))
```
### 在类型转换中使用
```go
var e Error
data := Map{"count": "abc"}
count := data.GetInt("count", &e)
if e.GetError() != nil {
// 转换失败,count = 0
fmt.Println("转换失败:", e.GetError())
}
```
---
## 最佳实践
### 1. 链式调用处理请求数据
```go
func handler(that *Context) {
// 推荐:使用 Obj 链式调用
userId := that.ReqData("user_id").ToInt()
page := that.ReqData("page").ToInt()
if page < 1 {
page = 1
}
// 处理 Map 数据
user := that.Db.Get("user", "*", Map{"id": userId})
if user != nil {
name := user.GetString("name")
age := user.GetInt("age")
}
}
```
### 2. 安全的类型转换
```go
// 带错误检查的转换
var e Error
data := someMap.GetInt("key", &e)
if e.GetError() != nil {
// 使用默认值
data = 0
}
// 简单场景直接转换(失败返回零值)
data := someMap.GetInt("key") // 失败返回 0
```
### 3. 处理数据库查询结果
```go
// 查询返回 Map
user := that.Db.Get("user", "*", Map{"id": 1})
if user != nil {
name := user.GetString("name")
createTime := user.GetTime("create_time")
}
// 查询返回 []Map
users := that.Db.Select("user", "*", Map{"status": 1})
for _, u := range users {
fmt.Printf("ID: %d, Name: %s\n", u.GetInt("id"), u.GetString("name"))
}
```
---
## 相关文档
- [快速上手](QuickStart_快速上手.md)
- [HoTimeDB 使用说明](HoTimeDB_使用说明.md)
- [代码生成](CodeGen_代码生成.md)
- [文档索引](README.md)
@@ -0,0 +1,497 @@
# 数据库设计规范
本文档是 HoTime **CodeGen 代码生成器的输入契约**:表/字段命名、外键、注释与公共字段等规则直接决定生成结果。请严格按本文设计库表,再配合 [CodeGen_代码生成.md](CodeGen_代码生成.md) 使用代码生成。
遵循这些规范可确保代码生成器正确识别表关系、自动生成 CRUD 接口和管理后台。
---
## 表命名规则
### 1. 关于表名前缀
一般情况下**没必要强行添加前缀分组**,直接使用业务含义命名即可:
```
推荐:user、org、role、article
```
**可以使用前缀的场景**:当项目较大、表较多时,可以用前缀进行模块分组(如 `sys_``cms_`),代码生成器会根据 `_` 分词自动将同前缀的表归为一组。
```
sys_user、sys_role、sys_org → 自动归入 sys 分组
cms_article、cms_category → 自动归入 cms 分组
```
**使用前缀的注意事项**
- 必须遵循外键全局唯一性规则(见下文)
- 前缀分组后,关联 ID 命名会更复杂,容易产生重复
- **外键名不允许重复指向不同的表**
### 2. 可使用简称
较长的表名可以使用常见简称:
| 全称 | 简称 |
|------|------|
| organization | org |
| category | ctg |
| configuration | config |
| administrator | admin |
### 3. 关联表命名
多对多关联表使用 `主表_关联表` 格式:
```
user_org -- 用户与组织的关联
user_role -- 用户与角色的关联
article_tag -- 文章与标签的关联
```
---
## 字段命名规则
### 1. 主键字段
所有表的主键统一命名为 `id`,使用自增整数。
```sql
`id` int(11) NOT NULL AUTO_INCREMENT
```
### 2. 外键字段
外键使用 `完整表名_id` 格式:
```
user_id -- 指向 user 表
org_id -- 指向 org 表
role_id -- 指向 role 表
app_category_id -- 指向 app_category 表
```
### 3. 关联表引用
引用关联表时使用 `关联表名_id`
```
user_org_id -- 指向 user_org 表
user_role_id -- 指向 user_role 表
```
### 4. 外键全局唯一性(重要)
**每个 `xxx_id` 字段名必须全局唯一指向一张表**,代码生成器通过 `_id` 后缀自动识别外键关系。
```
✅ 正确设计:
- user_id 只能指向 user 表
- org_id 只能指向 org 表
- user_org_id 只能指向 user_org 表
- sys_user_id 只能指向 sys_user 表(如果使用前缀分组)
❌ 错误设计:
- dd_id 作为"钉钉外部系统ID",但系统中存在 dd 表
→ 代码生成器会误判为指向 dd 表的外键
- 同时存在 user 表和 sys_user 表,都使用 user_id
→ 外键名重复,代码生成器无法正确识别
```
**使用前缀分组时的外键命名**
如果使用了表名前缀(如 `sys_user`),外键应使用完整表名:
| 表名 | 外键命名 |
|------|----------|
| sys_user | sys_user_id |
| sys_org | sys_org_id |
| cms_article | cms_article_id |
**非外键业务标识字段**:避免使用 `xxx_id` 格式
| 业务含义 | 错误命名 | 正确命名 |
|----------|----------|----------|
| 设备唯一标识 | device_id | device_uuid / device_sn |
| 钉钉用户ID | dingtalk_user_id | dt_user_id / dingtalk_uid |
| 微信OpenID | wechat_id | wechat_openid |
| 外部系统编号 | external_id | external_code / external_sn |
### 5. 外键智能匹配机制
代码生成器会按以下优先级自动匹配外键关联的表:
1. **完全匹配**`user_id` → 查找 `user`
2. **带前缀的完全匹配**:如果没找到,尝试 `user_id` → 查找 `sys_user` 表(默认前缀)
3. **去除字段前缀匹配**:如果字段有前缀且找不到对应表,会去掉前缀匹配
**示例**`admin_user_id` 字段的匹配过程:
1. 先查找 `admin_user` 表 → 如果存在,关联到 `admin_user`
2. 如果不存在,去掉前缀查找 `user` 表 → 如果存在,关联到 `user`
**使用场景**:当一张表需要记录多个用户(如创建人、审核人、处理人)时:
```sql
CREATE TABLE `order` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) COMMENT '下单用户',
`audit_user_id` int(11) COMMENT '审核人',
`handle_user_id` int(11) COMMENT '处理人',
...
);
```
如果系统中没有 `audit_user``handle_user` 表,代码生成器会自动将 `audit_user_id``handle_user_id` 识别为指向 `user` 表的外键。
### 6. 关联表冗余外键
关联表应包含必要的冗余外键便于查询:
```sql
-- user_app 表(用户与应用的关联)
CREATE TABLE user_app (
id int(11) NOT NULL AUTO_INCREMENT,
user_id int(11) DEFAULT NULL COMMENT '用户ID',
app_id int(11) DEFAULT NULL COMMENT '应用ID',
app_category_id int(11) DEFAULT NULL COMMENT '应用分类ID(冗余,便于按分类查询)',
...
);
```
### 7. 层级关系字段
树形结构的表使用以下字段:
| 字段 | 类型 | 说明 |
|------|------|------|
| parent_id | int | 父级ID,顶级为 NULL 或 0 |
| parent_ids | varchar(255) | 完整层级路径,格式:`,1,3,5,`(从根到当前,逗号分隔) |
| level | int | 层级深度,从 0 开始 |
**示例**
```
id=1, parent_id=NULL, parent_ids=",1,", level=0 -- 顶级
id=3, parent_id=1, parent_ids=",1,3,", level=1 -- 一级
id=5, parent_id=3, parent_ids=",1,3,5,", level=2 -- 二级
```
`parent_ids` 的设计便于快速查询所有子级:
```sql
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` |
---
## 表注释规则
### 表备注命名建议
表备注建议使用"XX管理"格式,便于在管理后台显示:
```sql
-- MySQL 表备注示例
CREATE TABLE `user` (
...
) COMMENT='用户管理';
CREATE TABLE `article` (
...
) COMMENT='文章管理';
CREATE TABLE `order` (
...
) COMMENT='订单管理';
```
### SQLite 表备注
SQLite 不支持表备注,代码生成器会使用表名作为默认显示名称。如需自定义,可在配置文件中手动设置 `label`(详见 [CodeGen_代码生成.md](CodeGen_代码生成.md))。
---
## 字段注释规则
### 注释语法格式
字段注释支持以下格式组合:
```
显示名称(前端提示):选项值 附加备注
显示名称(前端提示):选项值 附加备注
显示名称{前端提示}:选项值 附加备注
```
| 部分 | 分隔符 | 用途 |
|------|--------|------|
| 显示名称 | 无 | 前端表单/列表的字段标签 |
| 前端提示 | `()``()``{}` | 存储到 `ps` 字段,前端用于字段说明提示(编辑页输入框右侧、表格/详情鼠标悬停气泡) |
| 选项值 | `:` 冒号 | select 类型的下拉选项(格式:`值-名称,值-名称` |
| 附加备注 | 空格 | 仅在数据库中查看,不传递给前端 |
### 选择类型字段
使用 `标签:值-名称,值-名称` 格式,代码生成器会自动解析为下拉选项:
```sql
`state` int(2) DEFAULT '0' COMMENT '状态:0-正常,1-异常,2-隐藏'
`sex` int(1) DEFAULT '0' COMMENT '性别:0-未知,1-男,2-女'
`status` int(2) DEFAULT '0' COMMENT '审核状态:0-待审核,1-已通过,2-已拒绝'
```
### 普通字段
直接使用中文说明:
```sql
`name` varchar(50) DEFAULT NULL COMMENT '名称'
`phone` varchar(20) DEFAULT NULL COMMENT '手机号'
`email` varchar(100) DEFAULT NULL COMMENT '邮箱'
```
### 附加备注(空格后)
空格后的内容**不会传递给前端**,仅供数据库设计时参考:
```sql
`user_id` int(11) COMMENT '用户ID 关联user表的主键'
`amount` decimal(10,2) COMMENT '金额 单位为元,精确到分'
```
### 前端提示(括号内)
`()``()``{}` 中的内容存储到 `ps` 字段,前端展示为字段说明提示。推荐使用 `()``()``{}` 为兼容旧格式保留。
```sql
`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}'
```
前端展示效果:
- **编辑/新增页面**:输入框右侧显示灰色小字提示
- **表格列表/详情页面**:鼠标悬停字段名时显示气泡提示
### 组合使用
```sql
-- 推荐写法:使用圆括号
`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` = "状态"
- `ps` = "选择当前审核状态"
- `options` = [{name:"待审核", value:"0"}, {name:"已通过", value:"1"}, {name:"已拒绝", value:"2"}]
三种括号 `()``()``{}` 功能相同,优先匹配顺序为:`()` > `()` > `{}`,同一注释中只取第一个匹配到的括号对。
### SQLite 字段备注
SQLite 不支持字段备注(COMMENT),代码生成器会使用字段名作为默认显示名称。如需自定义,可在配置文件中手动设置(详见 [CodeGen_代码生成.md](CodeGen_代码生成.md))。
---
## 必有字段规则
每张业务表必须包含以下三个字段:
### MySQL
```sql
`state` int(2) DEFAULT '0' COMMENT '状态:0-正常,1-异常,2-隐藏',
`create_time` datetime DEFAULT NULL COMMENT '创建日期',
`modify_time` datetime DEFAULT NULL COMMENT '变更时间',
```
### SQLite
```sql
"state" INTEGER DEFAULT 0, -- 状态:0-正常,1-异常,2-隐藏
"create_time" TEXT DEFAULT NULL, -- 创建日期
"modify_time" TEXT DEFAULT NULL, -- 变更时间
```
### PostgreSQL
```sql
"state" INTEGER DEFAULT 0, -- 状态:0-正常,1-异常,2-隐藏
"create_time" TIMESTAMP DEFAULT NULL, -- 创建日期
"modify_time" TIMESTAMP DEFAULT NULL, -- 变更时间
```
### 达梦 DM8
```sql
"state" INT DEFAULT 0, -- 状态:0-正常,1-异常,2-隐藏
"create_time" TIMESTAMP DEFAULT NULL, -- 创建日期
"modify_time" TIMESTAMP DEFAULT NULL, -- 变更时间
```
---
## 完整建表示例
### MySQL 示例
```sql
-- 用户表
CREATE TABLE `user` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`name` varchar(50) DEFAULT NULL COMMENT '用户名',
`phone` varchar(20) DEFAULT NULL COMMENT '手机号',
`password` varchar(64) DEFAULT NULL COMMENT '密码',
`org_id` int(11) DEFAULT NULL COMMENT '组织ID',
`role_id` int(11) DEFAULT NULL COMMENT '角色ID',
`avatar` varchar(255) DEFAULT NULL COMMENT '头像',
`sex` int(1) DEFAULT '0' COMMENT '性别:0-未知,1-男,2-女',
`state` int(2) DEFAULT '0' COMMENT '状态:0-正常,1-异常,2-隐藏',
`create_time` datetime DEFAULT NULL COMMENT '创建日期',
`modify_time` datetime DEFAULT NULL COMMENT '变更时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
-- 组织表(树形结构)
CREATE TABLE `org` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`name` varchar(100) DEFAULT NULL COMMENT '组织名称',
`parent_id` int(11) DEFAULT NULL COMMENT '父级ID',
`parent_ids` varchar(255) DEFAULT NULL COMMENT '层级路径',
`level` int(2) DEFAULT '0' COMMENT '层级深度',
`sort` int(5) DEFAULT '0' COMMENT '排序',
`state` int(2) DEFAULT '0' COMMENT '状态:0-正常,1-异常,2-隐藏',
`create_time` datetime DEFAULT NULL COMMENT '创建日期',
`modify_time` datetime DEFAULT NULL COMMENT '变更时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='组织表';
-- 用户组织关联表
CREATE TABLE `user_org` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_id` int(11) DEFAULT NULL COMMENT '用户ID',
`org_id` int(11) DEFAULT NULL COMMENT '组织ID',
`state` int(2) DEFAULT '0' COMMENT '状态:0-正常,1-异常,2-隐藏',
`create_time` datetime DEFAULT NULL COMMENT '创建日期',
`modify_time` datetime DEFAULT NULL COMMENT '变更时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户组织关联表';
```
### SQLite 示例
```sql
-- 用户表
CREATE TABLE "user" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"name" TEXT DEFAULT NULL,
"phone" TEXT DEFAULT NULL,
"password" TEXT DEFAULT NULL,
"org_id" INTEGER DEFAULT NULL,
"role_id" INTEGER DEFAULT NULL,
"avatar" TEXT DEFAULT NULL,
"sex" INTEGER DEFAULT 0,
"state" INTEGER DEFAULT 0,
"create_time" TEXT DEFAULT NULL,
"modify_time" TEXT DEFAULT NULL
);
```
### 达梦 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 保持一致。
---
## 规范检查清单
在设计数据库时,请确认:
- [ ] 表名无系统前缀
- [ ] 主键统一为 `id`
- [ ] 外键格式为 `表名_id`
- [ ] 所有 `xxx_id` 字段都指向实际存在的表
- [ ] 非外键业务标识不使用 `xxx_id` 格式
- [ ] 树形表包含 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 错配问题)
---
## 相关文档
- [CodeGen_代码生成.md](CodeGen_代码生成.md) — 按本规范建表后运行代码生成
- [HoTimeDB_使用说明.md](HoTimeDB_使用说明.md) — 运行时 ORM(与建表规范分开)
- [文档索引](README.md)
+145
View File
@@ -0,0 +1,145 @@
# HoTime 框架优雅停机
## 概述
HoTime 框架内置优雅停机支持,收到关闭信号后:
1. **立即**对所有新请求返回 `503 Service Unavailable`,触发 nginx 把流量切到其他实例
2. **等待** `DrainTimeout`(默认 5 秒)让 nginx 完成流量切换
3. **等待**所有正在处理的请求完成(最多 `ShutdownTimeout`,默认 30 秒)
4.`exit(0)` 正常退出;若使用进程守护脚本(如 `run_app.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;
# EdgeOne 场景优先 EO-Connecting-IP;空则框架再降级到 XFF / RemoteAddr
proxy_set_header X-Real-IP $http_eo_connecting_ip;
proxy_set_header EO-Connecting-IP $http_eo_connecting_ip;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
```
- `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_app.cmd 守护,检测到 exit(0) 后不会自动重启
:: 4. 对端口 9999 重复以上步骤
netstat -ano | findstr :9999
taskkill /PID <pid2>
```
### Linux
```bash
# 1. 查找进程 PID(按你的二进制名替换)
pgrep -a <your-app> # 或
ps aux | grep <your-app>
# 2. 优雅关闭(发送 SIGTERM
kill <pid>
# 3. 观察日志确认关闭完成
tail -f logs/app.log # 等待"服务已安全关闭"
# 4. 部署新版并启动,再对另一个实例重复
```
---
## 工作原理
```
收到信号 / 关窗口
shuttingDown = true
▼ DrainTimeout(默认 5s
新请求 → 503 ──────────────────────► nginx 切流到另一台
server.Shutdown(ShutdownTimeout=30s)
等待所有进行中请求跑完
os.Exit(0) → 守护脚本(如 run_app.cmd)可检测 exit(0) 后不重启
```
### 为何在框架层而非应用层实现
通过 `appIns.Run(Router{...})` 启动服务,信号处理与 `http.Server` 生命周期强绑定,必须在框架的 `Run()` 方法内才能正确管理。应用层无需任何修改,升级框架即自动获得优雅停机能力。
### `sync.Once` 保证幂等性
框架同时监听多路触发源(信号 goroutine + Windows 关窗口 handler),`sync.Once` 保证无论哪路先触发,`initiateGracefulShutdown` 只执行一次,不会重复调用 `Shutdown()`
+214
View File
@@ -0,0 +1,214 @@
# HoTimeDB API 快速参考
完整教程见 [HoTimeDB 使用说明](HoTimeDB_使用说明.md)。本文仅收录条件运算符与方法签名速查。
---
## 条件运算符
多条件默认 AND;关键字大小写均可(`ORDER`/`order`)。
| 写法 | SQL | 说明 |
|------|-----|------|
| `"field": value` | `field = ?` | 等于 |
| `"field[!]": value` | `field != ?` | 不等于 |
| `"field[>]": value` | `field > ?` | 大于 |
| `"field[>=]": value` | `field >= ?` | 大于等于 |
| `"field[<]": value` | `field < ?` | 小于 |
| `"field[<=]": value` | `field <= ?` | 小于等于 |
| `"field[~]": "kw"` | `LIKE '%kw%'` | 包含 |
| `"field[~!]": "kw"` | `LIKE 'kw%'` | 开头 |
| `"field[!~]": "kw"` | `LIKE '%kw'` | 结尾 |
| `"field[~~]": "%kw%"` | `LIKE '%kw%'` | 手动 LIKE |
| `"field[<>]": [min,max]` | `BETWEEN ? AND ?` | 区间内 |
| `"field[><]": [min,max]` | `NOT BETWEEN` | 区间外 |
| `"field": [v1,v2]` | `IN (?,?)` | 集合 |
| `"field[!]": [v1,v2]` | `NOT IN` | 非集合 |
| `"field": nil` | `IS NULL` | 空 |
| `"field[!]": nil` | `IS NOT NULL` | 非空 |
| `"field[#]": "balance+1"` | `field = balance+1` | 直接表达式 |
| `"[##]": "a > b"` | `a > b` | 直接 SQL 片段 |
| `"field[#!]": "1"` | `field != 1` | 非参数化不等 |
时间字段推荐 Go 侧传值:`"create_time": Time2Str(time.Now())`(勿用 `NOW()`)。
### 逻辑与特殊关键字
```go
Map{"status": 1, "age[>]": 18} // 自动 AND
Map{"AND": Map{...}} / Map{"OR": Map{...}} // 显式逻辑
Map{"AND": Map{"status": 1, "OR": Map{...}}} // 嵌套
Map{"ORDER": "id DESC"} / []string{"a DESC","b"} // ORDER BY
Map{"GROUP": "dept"} / []string{"a","b"} // GROUP BY
Map{"HAVING": Map{"COUNT(*).[>]": 5}} // HAVING
Map{"LIMIT": 20} / []int{10, 20} // LIMIT / offset+limit
Map{"OFFSET": 20} // 独立 OFFSET
```
---
## 方法签名
### 连接与元信息
| 方法 | 签名 | 说明 |
|------|------|------|
| SetConnect | `SetConnect(func() (master, slave *sql.DB))` | 设置连接并 InitDb |
| InitDb | `InitDb()` | 初始化连接与方言 |
| GetPrefix | `GetPrefix() string` | 表前缀 |
| GetType | `GetType() string` | 库类型 |
| GetDialect | `GetDialect() Dialect` | 方言适配器 |
| GetLast | `GetLast() *DBError` | 最近一次 SQL 快照 |
| GetLastError | `GetLastError() error` | 最近错误 |
| GetLastQuery | `GetLastQuery() string` | 最近 SQL |
| GetLastData | `GetLastData() []interface{}` | 最近参数 |
`Type``mysql` / `sqlite3` / `postgres` / `dm`(或 `dameng`)。
### CRUD
| 方法 | 签名 | 返回 |
|------|------|------|
| Select | `Select(table string, qu ...interface{})` | `[]Map` |
| Get | `Get(table string, qu ...interface{})` | `Map`(自动 LIMIT 1 |
| Insert | `Insert(table string, data map[string]interface{})` | `int64` 新 ID |
| Inserts | `Inserts(table string, dataList []Map)` | `int64` 影响行数 |
| Update | `Update(table string, data Map, where Map)` | `int64` |
| Upsert | `Upsert(table string, data Map, uniqueKeys Slice, updateColumns ...interface{})` | `int64` |
| Delete | `Delete(table string, data map[string]interface{})` | `int64` |
| Page | `Page(page, pageRow int) *HoTimeDB` | 链式分页 |
| PageSelect | `PageSelect(table string, qu ...interface{})` | `[]Map` |
`Select`/`Get`/`PageSelect``qu` 常见形态:字段;字段+wherejoin+字段+where。
```go
database.Select("user")
database.Select("user", "id,name")
database.Select("user", "*", where)
database.Select("user", joinSlice, "fields", where)
database.Get("user", "fields", where)
database.Insert("user", Map{"name": "a"})
database.Inserts("user", []Map{{"name": "a"}, {"name": "b"}})
database.Update("user", Map{"name": "b"}, Map{"id": 1})
database.Upsert("user", data, Slice{"id"}, Slice{"name", "email"})
database.Upsert("user", data, Slice{"id"}, "name", "email")
database.Delete("user", Map{"id": 1})
database.Page(1, 20).PageSelect("user", "id,name", where)
```
### 聚合
| 方法 | 签名 | 返回 |
|------|------|------|
| Count | `Count(table string, qu ...interface{})` | `int` |
| Sum | `Sum(table string, column string, qu ...interface{})` | `float64` |
| Avg | `Avg(table string, column string, qu ...interface{})` | `float64` |
| Max | `Max(table string, column string, qu ...interface{})` | `float64` |
| Min | `Min(table string, column string, qu ...interface{})` | `float64` |
`qu` 可为 where,或 join+where。
```go
database.Count("user")
database.Count("user", where)
database.Count("user", join, where)
database.Sum("order", "amount", where)
database.Avg("order", "amount", where)
database.Max("order", "amount", where)
database.Min("order", "amount", where)
```
### 链式构建器
```go
b := database.Table("tablename") // *HotimeDBBuilder
```
| 方法 | 签名 | 说明 |
|------|------|------|
| Where / And / Or | `(qu ...interface{}) *HotimeDBBuilder` | 键值或 Map |
| LeftJoin / RightJoin / InnerJoin / FullJoin | `(table, condition string)` | JOIN |
| Join | `(qu ...interface{})` | 通用 JOIN Map |
| Order / Group / Limit | `(qu ...interface{})` | 排序/分组/限制 |
| Having | `(qu ...interface{})` | HAVING |
| Page | `(page, pageRow int)` | 分页 |
| Offset | `(offset int)` | 偏移 |
| From | `(table string)` | 换表 |
| Select | `(qu ...interface{}) []Map` | 查询 |
| Get | `(qu ...interface{}) Map` | 单条 |
| Count | `() int` | 计数 |
| Update | `(qu ...interface{}) int64` | 更新 |
| Delete | `() int64` | 删除 |
```go
database.Table("user").Where("status", 1).Order("id DESC").Page(1, 20).Select("id,name")
database.Table("order").LeftJoin("user", "order.user_id = user.id").Where("order.status", "paid").Select("order.*, user.name")
```
### JOIN Map 写法
| 写法 | 类型 |
|------|------|
| `Map{"[>]profile": "user.id = profile.user_id"}` | LEFT |
| `Map{"[<]department": "user.dept_id = department.id"}` | RIGHT |
| `Map{"[><]role": "user.role_id = role.id"}` | INNER |
| `Map{"[<>]group": "user.group_id = group.id"}` | FULL |
### 事务与原生 SQL
| 方法 | 签名 | 说明 |
|------|------|------|
| Action | `Action(func(db HoTimeDB) bool) bool` | `true` 提交 / `false` 回滚 |
| Query | `Query(query string, args ...interface{}) []Map` | 原生查询 |
| Exec | `Exec(query string, args ...interface{}) (sql.Result, error)` | 原生执行 |
```go
ok := database.Action(func(tx HoTimeDB) bool {
if tx.Insert("a", data) == 0 { return false }
return true
})
database.Query("SELECT * FROM user WHERE age > ?", 18)
database.Exec("UPDATE user SET status = ? WHERE id = ?", 1, 100)
```
---
## 方言差异速查
| 特性 | MySQL | PostgreSQL | 达梦 DM8 |
|------|-------|------------|---------|
| 标识符引号 | \`name\` | `"name"` | `"name"` |
| 占位符 | `?` | `$1,$2...` | `?` |
| Upsert | ON DUPLICATE KEY | ON CONFLICT | MERGE INTO |
| 自增 | AUTO_INCREMENT | SERIAL | IDENTITY(1,1) |
| 分页 | LIMIT m,n | LIMIT n OFFSET m | 两者皆可 |
框架自动适配,业务代码通常无需按库分支。达梦:保留字(`admin`/`user`/`order`)ORM 自动加引号,原生 SQL 需手动双引号;时间统一用 `Time2Str(time.Now())`
```go
// Type: "postgres" | "dm" | "dameng" | "mysql" | "sqlite3"
database := &db.HoTimeDB{Type: "dm"}
database.SetConnect(func() (master, slave *sql.DB) {
master, _ = sql.Open("dm", "dm://SYSDBA:pwd@127.0.0.1:5236?schema=TEST")
return master, master
})
```
---
## 常用一行模式
```go
// 分页列表
total := database.Count("user", Map{"status": 1})
rows := database.Table("user").Where("status", 1).Order("id DESC").Page(page, size).Select("id,name")
// 分组统计
database.Select("order", "user_id, COUNT(*) c, SUM(amount) s", Map{
"status": "paid", "GROUP": "user_id", "ORDER": "s DESC",
})
// 查错
if err := database.GetLastError(); err != nil { /* ... */ }
_ = database.GetLastQuery(); _ = database.GetLastData()
```
File diff suppressed because it is too large Load Diff
+576
View File
@@ -0,0 +1,576 @@
# HoTime 快速上手指南
5 分钟入门 HoTime 框架。完整文档索引见 [README.md](README.md)。
## 安装
```bash
go get code.hoteas.com/golang/hotime
```
## 最小示例
```go
package main
import (
. "code.hoteas.com/golang/hotime"
. "code.hoteas.com/golang/hotime/common"
)
func main() {
appIns := Init("config/config.json")
appIns.Run(Router{
"app": {
"test": {
"hello": func(that *Context) {
that.Display(0, Map{"message": "Hello World"})
},
},
},
})
}
```
访问: `http://localhost:8081/app/test/hello`
## 配置文件
创建 `config/config.json`:
```json
{
"port": "8081",
"mode": 2,
"sessionName": "HOTIME",
"tpt": "tpt",
"defFile": ["index.html", "index.htm"],
"db": {
"mysql": {
"host": "localhost",
"port": "3306",
"name": "your_database",
"user": "root",
"password": "your_password",
"prefix": ""
}
},
"cache": {
"memory": {
"db": true,
"session": true,
"timeout": 7200
}
}
}
```
### 配置项说明
| 配置项 | 默认值 | 说明 |
|--------|--------|------|
| `port` | 80 | HTTP 服务端口,0 为不启用 |
| `tlsPort` | - | HTTPS 端口,需配合 tlsCert/tlsKey |
| `tlsCert` | - | HTTPS 证书路径 |
| `tlsKey` | - | HTTPS 密钥路径 |
| `mode` | 0 | 0=生产, 1=测试, 2=开发(输出SQL) |
| `tpt` | tpt | 静态文件目录 |
| `sessionName` | HOTIME | Session Cookie 名称 |
| `modeRouterStrict` | false | 路由大小写敏感,false=忽略大小写 |
| `crossDomain` | - | 跨域设置,空=不开启,auto=智能开启,或指定域名 |
| `logFile` | - | 日志文件路径,如 `logs/20060102.txt` |
| `logLevel` | 1 | 日志等级:0=仅错误,>=1=全部;同时控制 SQL 日志。**不**控制静态 `Cache-Control`(静态固定 `public` |
| `webConnectLogShow` | true | 是否显示访问日志 |
| `defFile` | ["index.html"] | 目录默认访问文件 |
### 数据库配置
支持的数据库类型:`mysql``sqlite``postgres`PostgreSQL)、`dm`(达梦 DM8)。
```json
{
"db": {
"mysql": {
"host": "127.0.0.1",
"port": "3306",
"name": "database_name",
"user": "root",
"password": "password",
"prefix": "app_",
"slave": {
"host": "127.0.0.1",
"port": "3306",
"name": "database_name",
"user": "root",
"password": "password"
}
},
"sqlite": {
"path": "config/data.db",
"prefix": ""
},
"dm": {
"host": "127.0.0.1",
"port": "5236",
"name": "TEST",
"user": "SYSDBA",
"password": "your_password",
"prefix": ""
}
}
}
```
> MySQL 配置 `slave` 项即启用主从读写分离
> 达梦 DM8 的 `name` 字段对应 schema(搜索路径),驱动已内置于 `vendor/gitee.com/chunanyong/dm`,无需额外安装
### 缓存配置
```json
{
"cache": {
"memory": {
"db": true,
"session": true,
"timeout": 7200
},
"redis": {
"host": "127.0.0.1",
"port": 6379,
"password": "",
"db": true,
"session": true,
"timeout": 1296000
},
"db": {
"db": true,
"session": true,
"timeout": 2592000,
"history": false,
"mode": "compatible"
}
}
}
```
缓存优先级: **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
{
"error": {
"1": "内部系统异常",
"2": "访问权限异常",
"3": "请求参数异常",
"4": "数据处理异常",
"5": "数据结果异常"
}
}
```
> 自定义错误码建议从 10 开始
## 路由系统
HoTime 使用三层路由结构:`模块/控制器/方法`
```go
appIns.Run(Router{
"模块名": {
"控制器名": {
"方法名": func(that *Context) {
// 处理逻辑
},
},
},
})
```
### 路由路径
```go
// 获取路由信息
module := that.RouterString[0] // 模块
controller := that.RouterString[1] // 控制器
action := that.RouterString[2] // 方法
// 完整请求路径
fullPath := that.HandlerStr // 如 /app/user/login
```
## 请求参数获取
### 新版推荐方法(支持链式调用)
```go
// 获取 URL 查询参数 (?id=1)
id := that.ReqParam("id").ToInt()
name := that.ReqParam("name").ToStr()
// 获取表单参数 (POST form-data / x-www-form-urlencoded)
username := that.ReqForm("username").ToStr()
age := that.ReqForm("age").ToInt()
// 获取 JSON Body 参数 (POST application/json)
data := that.ReqJson("data").ToMap()
items := that.ReqJson("items").ToSlice()
// 统一获取(自动判断来源,优先级: JSON > Form > URL
userId := that.ReqData("user_id").ToInt()
status := that.ReqData("status").ToStr()
```
### 类型转换方法
```go
obj := that.ReqData("key")
obj.ToStr() // 转字符串
obj.ToInt() // 转 int
obj.ToInt64() // 转 int64
obj.ToFloat64() // 转 float64
obj.ToBool() // 转 bool
obj.ToMap() // 转 Map
obj.ToSlice() // 转 Slice
obj.Data // 获取原始值(interface{}
```
### 文件上传
```go
// 单文件上传
file, header, err := that.ReqFile("avatar")
if err == nil {
defer file.Close()
// header.Filename - 文件名
// header.Size - 文件大小
}
// 多文件上传(批量)
files, err := that.ReqFiles("images")
if err == nil {
for _, fh := range files {
file, _ := fh.Open()
defer file.Close()
// 处理每个文件
}
}
```
### 传统方法(兼容)
```go
// GET/POST 参数
name := that.Req.FormValue("name")
// URL 参数
id := that.Req.URL.Query().Get("id")
// 请求头
token := that.Req.Header.Get("Authorization")
```
## 响应数据
### Display 方法
```go
// 成功响应 (status=0)
that.Display(0, Map{"user": user, "token": token})
// 输出: {"status":0, "result":{"user":..., "token":...}}
// 错误响应 (status>0)
that.Display(1, "系统内部错误")
// 输出: {"status":1, "result":{"type":"内部系统异常", "msg":"系统内部错误"}, "error":{...}}
that.Display(2, "请先登录")
// 输出: {"status":2, "result":{"type":"访问权限异常", "msg":"请先登录"}, "error":{...}}
that.Display(3, "参数不能为空")
// 输出: {"status":3, "result":{"type":"请求参数异常", "msg":"参数不能为空"}, "error":{...}}
```
### 错误码含义
| 错误码 | 类型 | 使用场景 |
|--------|------|----------|
| 0 | 成功 | 请求成功 |
| 1 | 内部系统异常 | 环境配置、文件权限等基础运行环境错误 |
| 2 | 访问权限异常 | 未登录或登录异常 |
| 3 | 请求参数异常 | 参数不足、类型错误等 |
| 4 | 数据处理异常 | 数据库操作或第三方请求返回异常 |
| 5 | 数据结果异常 | 无法返回要求的格式 |
### 自定义响应
```go
// 自定义 Header
that.Resp.Header().Set("Content-Type", "application/json")
// 直接写入
that.Resp.Write([]byte("raw data"))
// 自定义响应函数
that.RespFunc = func() {
// 自定义响应逻辑
}
```
## 中间件
```go
// 全局中间件(请求拦截)
appIns.SetConnectListener(func(that *Context) bool {
// 放行登录接口
if len(that.RouterString) >= 3 && that.RouterString[2] == "login" {
return false
}
// 检查登录状态
if that.Session("user_id").Data == nil {
that.Display(2, "请先登录")
return true // 返回 true 终止请求
}
return false // 返回 false 继续处理
})
```
## Session 与缓存
```go
// Session 操作
that.Session("user_id", 123) // 设置
userId := that.Session("user_id") // 获取 *Obj
that.Session("user_id", nil) // 删除
// 链式获取
id := that.Session("user_id").ToInt64()
name := that.Session("username").ToStr()
// 通用缓存
that.Cache("key", "value") // 设置
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**
## 数据库操作(简要)
```go
users := that.Db.Select("user", "*", Map{"status": 1})
user := that.Db.Get("user", "*", Map{"id": 1})
id := that.Db.Insert("user", Map{"name": "test", "age": 18})
that.Db.Update("user", Map{"name": "new"}, Map{"id": 1})
that.Db.Delete("user", Map{"id": 1})
users = that.Db.Table("user").
Where("status", 1).And("age[>]", 18).
Order("id DESC").Page(1, 10).Select("*")
```
条件运算符(`[>]` / `[~]` / IN 等)、事务、缓存与方言差异见:
- 教程:[HoTimeDB 使用说明](HoTimeDB_使用说明.md)
- 速查:[HoTimeDB API 参考](HoTimeDB_API参考.md)
## 日志:业务 Log 表 vs Seq
| 能力 | 用途 | 文档 |
|------|------|------|
| `that.Log = Map{...}` | 写入业务 `logs` 表(操作审计) | 见下方示例 |
| `seqUrl` + zerolog | 结构化日志推送到 Seq 集中搜索 | [Seq 日志集成](Seq_日志集成.md) |
```go
// 业务操作日志(自动插入 logs 表;框架会补 time、admin_id/user_id、ip 等)
that.Log = Map{
"type": "login",
"action": "用户登录",
"data": Map{"phone": phone},
}
```
## 扩展功能
| 功能 | 路径 | 说明 |
|------|------|------|
| 微信支付/公众号/小程序 | `dri/wechat/` | 微信全套 SDK |
| 阿里云服务 | `dri/aliyun/` | 企业认证等 |
| 腾讯云服务 | `dri/tencent/` | 企业认证等 |
| 文件上传 | `dri/upload/` | 文件上传处理 |
| 文件下载 | `dri/download/` | 文件下载处理 |
| MongoDB | `dri/mongodb/` | MongoDB 驱动 |
| RSA 加解密 | `dri/rsa/` | RSA 加解密工具 |
## 完整示例
```go
package main
import (
"time"
. "code.hoteas.com/golang/hotime"
. "code.hoteas.com/golang/hotime/common"
)
func main() {
appIns := Init("config/config.json")
// 登录检查中间件
appIns.SetConnectListener(func(that *Context) bool {
// 放行登录接口
if len(that.RouterString) >= 3 && that.RouterString[2] == "login" {
return false
}
if that.Session("user_id").Data == nil {
that.Display(2, "请先登录")
return true
}
return false
})
appIns.Run(Router{
"api": {
"user": {
"login": func(that *Context) {
phone := that.ReqData("phone").ToStr()
password := that.ReqData("password").ToStr()
if phone == "" || password == "" {
that.Display(3, "手机号和密码不能为空")
return
}
user := that.Db.Get("user", "*", Map{
"phone": phone,
"password": Md5(password),
})
if user == nil {
that.Display(3, "账号或密码错误")
return
}
// 使用批量设置,一次写入多个字段
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) {
// 使用批量获取,一次读取多个字段
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,
"login_time": sess["login_time"],
})
},
"list": func(that *Context) {
page := that.ReqData("page").ToInt()
if page == 0 {
page = 1
}
users := that.Db.Table("user").
Where("status", 1).
Order("id DESC").
Page(page, 10).
Select("id,name,phone,created_at")
total := that.Db.Count("user", Map{"status": 1})
that.Display(0, Map{
"list": users,
"total": total,
"page": page,
})
},
"logout": func(that *Context) {
// 使用批量删除,一次清除多个字段
that.SessionsDelete("user_id", "username", "login_time")
that.Display(0, "退出成功")
},
},
},
})
}
```
---
**下一步**
- [HoTimeDB 使用说明](HoTimeDB_使用说明.md) — ORM 教程
- [HoTimeDB API 参考](HoTimeDB_API参考.md) — API 速查
- [Common 工具类](Common_工具类.md) — Map/Slice/Obj
- [API 测试框架](Testing_API测试框架.md) — 接口测试(优先 `-run` 单测)
- [文档索引](README.md) — 完整阅读路径
+36
View File
@@ -0,0 +1,36 @@
# HoTime 文档索引
本仓采用 **Doc-Driven + TDD**:行为一变必须回写 docs;改接口必须用 `-run` 精确测试验证(见 [Testing_API测试框架.md](Testing_API测试框架.md) 文首铁律与仓规则 `hotime-doc-tdd`)。
推荐按路径阅读,避免同一概念在多份文档里来回找。
## 阅读路径
1. **入门** → [QuickStart_快速上手.md](QuickStart_快速上手.md)
2. **类型与工具** → [Common_工具类.md](Common_工具类.md)
3. **数据库 ORM** → [HoTimeDB_使用说明.md](HoTimeDB_使用说明.md)(教程)→ [HoTimeDB_API参考.md](HoTimeDB_API参考.md)(速查)
4. **开表 / 代码生成** → [DatabaseDesign_数据库设计规范.md](DatabaseDesign_数据库设计规范.md)(前置)→ [CodeGen_代码生成.md](CodeGen_代码生成.md)
5. **接口测试** → [Testing_API测试框架.md](Testing_API测试框架.md)(日常用 `-run` 单测;局限见文末;全量见文末)
6. **运维** → [GracefulShutdown_优雅停机.md](GracefulShutdown_优雅停机.md)、[Seq_日志集成.md](Seq_日志集成.md)
7. **规划** → [ROADMAP_改进规划.md](ROADMAP_改进规划.md)
## 文档一览
| 文档 | 职责 |
|------|------|
| QuickStart_快速上手 | 安装、配置、路由、中间件、最小示例 |
| Common_工具类 | Map / Slice / Obj 与工具函数 |
| HoTimeDB_使用说明 | ORM 完整教程 |
| HoTimeDB_API参考 | ORM 方法与条件语法速查 |
| DatabaseDesign_数据库设计规范 | 表/字段/COMMENT 规范(CodeGen 输入契约) |
| CodeGen_代码生成 | 生成器用法 + codeConfig / rule 配置 |
| Testing_API测试框架 | API 测试、Flows、控制台;优先 `-run`;已知局限 |
| GracefulShutdown_优雅停机 | 优雅停机与滚动重启 |
| Seq_日志集成 | Seq 结构化日志 |
| ROADMAP_改进规划 | 待改进项 |
## 易混淆点
- **业务 `that.Log`**:写 `logs` 表;**Seq**:集中式结构化日志,见 Seq 文档。
- **HoTimeDB vs DatabaseDesign**:前者是运行时查库;后者是建表给代码生成用。
- **测试**:开发阶段不要默认 `go test ./app/...` 全量,见 Testing 文首铁律。
+283
View File
@@ -0,0 +1,283 @@
# HoTime 改进规划
> 相关规范:[DatabaseDesign](DatabaseDesign_数据库设计规范.md) · [CodeGen](CodeGen_代码生成.md) · [Testing](Testing_API测试框架.md) · [文档索引](README.md)
本文档记录 HoTime 框架的待改进项和设计思考,供后续版本迭代参考。
---
## 一、备注语法优化
### 现状
当前字段备注语法使用空格、冒号、大括号组合:
```sql
`status` int COMMENT '状态:0-正常,1-异常 这是数据库备注{这是前端提示}'
```
### 问题
- 多种分隔符混用,不够直观
- 显示名称如需包含特殊字符可能冲突
### 改进方向
使用 `|` 作为统一分隔符,保持干净清爽:
```sql
-- 方案 A:简洁直观
`status` int COMMENT '状态|0-正常,1-异常|请选择状态'
-- 解析:显示名称|选项|提示
-- 方案 B:保持兼容,空格后内容仍作为数据库备注
`status` int COMMENT '状态|0-正常,1-异常|请选择状态 这是数据库备注'
```
### 待确认
- [ ] 是否需要保留空格后的数据库备注功能
- [ ] 如果只有提示没有选项,如何表示(如 `名称||请输入名称`
- [ ] 是否向后兼容旧语法
---
## 二、SQLite 备注支持
### 现状
SQLite 不支持表/字段备注,需要手动在配置文件中设置。
### 问题
SQLite 项目配置工作量大,不够"快速开发"。
### 待探索方案
1. **配置文件方案(当前)**:通过 admin.json 手动配置,学习成本可接受
2. **轻量级方案**:考虑是否有更简单的替代方案,但不增加复杂度
### 暂时结论
当前方案虽不完美,但符合"简单好用"原则,暂不改动。
---
## 三、软删除支持
### 现状
框架暂不支持软删除。
### 设计考量
软删除存在以下问题:
- 数据安全性:软删除的数据仍可被恢复或误用
- 查询复杂度:所有查询需要额外过滤条件
- 存储膨胀:删除的数据持续占用空间
### 待探索方案
1. **可选软删除**:通过配置决定某张表是否启用软删除
2. **日志表方案**:独立的操作日志表,只写不删不改
- 记录所有增删改操作
- 原表正常物理删除
- 需要时可从日志恢复
3. **归档表方案**:删除时移动到归档表
### 倾向方案
日志表方案更符合数据安全和审计需求:
```sql
CREATE TABLE `_operation_log` (
`id` int AUTO_INCREMENT,
`table_name` varchar(100) COMMENT '操作表名',
`record_id` int COMMENT '记录ID',
`operation` varchar(20) COMMENT '操作类型:insert,update,delete',
`old_data` text COMMENT '操作前数据(JSON',
`new_data` text COMMENT '操作后数据(JSON',
`operator_id` int COMMENT '操作人ID',
`operator_table` varchar(100) COMMENT '操作人表名',
`create_time` datetime COMMENT '操作时间',
PRIMARY KEY (`id`)
) COMMENT='操作日志';
```
### 待确认
- [ ] 是否所有表都记录日志
- [ ] 日志保留策略(永久/定期归档)
- [ ] 是否提供恢复接口
---
## 四、多对多关联表增强
### 现状
关联表(如 `user_role`)按普通表处理,生成标准 CRUD。
### 可能的增强
1. **自动识别**:只有两个 `_id` 外键的表识别为关联表
2. **专用接口**:生成关联管理接口(批量绑定/解绑)
3. **级联查询**:自动生成带关联数据的查询
### 待确认
- [ ] 是否需要此功能
- [ ] 如何保持简单性
---
## 五、自动填充字段扩展
### 现状
自动填充:
- `create_time`:新增时自动填充当前时间
- `modify_time`:新增/编辑时自动填充当前时间
### 可能的扩展
| 字段 | 填充时机 | 填充内容 |
|------|----------|----------|
| create_by | 新增 | 当前用户ID |
| modify_by | 新增/编辑 | 当前用户ID |
| create_ip | 新增 | 客户端IP |
### 待确认
- [ ] 是否需要扩展
- [ ] 字段命名规范
---
## 六、版本控制/乐观锁
### 现状
`version` 字段规则存在但未启用。
### 待确认
- [ ] 是否需要支持乐观锁
- [ ] 如果不需要,是否从默认规则中移除
---
---
## 七、测试框架:响应字段断言(部分匹配)
### 现状
`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
---
## 改进优先级
| 优先级 | 改进项 | 状态 |
|--------|--------|------|
| 高 | 备注语法优化(`\|` 分隔符) | 待设计 |
| 高 | 测试框架:响应字段部分匹配断言 | 待实现 |
| 高 | 测试框架其它缺口(DB 断言、FromCase 无模板失败、Header API 等) | 见 [Testing 已知局限](Testing_API测试框架.md#已知局限与后续) |
| 中 | 软删除/日志表支持 | 待设计 |
| 低 | 多对多关联表增强 | 待评估 |
| 低 | 自动填充字段扩展 | 待评估 |
---
## 更新记录
| 日期 | 内容 |
|------|------|
| 2026-07-13 | 测试框架文档补齐 Flows/控制台;缺口清单链到 Testing |
| 2026-01-24 | 初始版本,记录分析结果和待改进项 |
| 2026-03-15 | 新增测试框架响应字段断言改进规划(七) |
| 2026-03-20 | 完成达梦(DM8)数据库完整支持:Dialect 适配(MERGE INTO Upsert、双引号标识符)、LastInsertId、保留字自动引号、schema 正确处理、代码生成器 DM 兼容、缓存表自动建表、测试框架 DM 适配 |
+163
View File
@@ -0,0 +1,163 @@
# Seq 日志集成
HoTime 框架内置 Seq 日志推送支持。通过在 `config.json` 填写 `seqUrl` 即可激活。
**目标:用 Seq 集中检索替代翻 `logs/*.txt`**;本地 `logFile` 可并行保留。
---
## 配置项
`config.json` 中添加:
```json
"seqUrl": "http://127.0.0.1:5341",
"seqApiKey": ""
```
| 字段 | 默认值 | 说明 |
|---|---|---|
| `seqUrl` | 空(不激活) | Seq 服务地址,空值时功能静默不生效 |
| `seqApiKey` | 空 | API Key,免费单用户版留空 |
`instance` 字段由框架自动拼接为 `ip:port`(如 `192.168.1.10:8085`),无需手动填写。
激活后每个 HTTP 请求会自动:
- 生成 `request_id`12 位 hex),回写响应头 `X-Request-Id`
-`sessionId` 前 12 位作为 `sid` 写入请求级日志(脱敏,避免把登录凭据写进 Seq)
- 业务日志、SQL 日志、访问日志均携带 `sid` / `request_id`
- **控制台自动降噪**(零额外配置):业务日志(`that.Log`)控制台放宽到 **Info+**(Debug/SQL 不上控制台),访问日志(`that.WebConnectLog`)控制台保持 **Warn+**(量大不上控制台);Debug/SQL/访问日志(Info 级)仍全量进 Seq 与文件
---
## 会话追踪与 LogBind
框架在 `handler` 入口派生请求级 Logger,并浅拷贝 `Db` 将其 `Log` 指向同一 Logger,因此 **SQL 日志自动带会话字段**。字段会出现在同条日志的控制台/文件/Seq 出口上。
业务在 `SetConnectListener` 鉴权通过后绑定(xbc `main.go`):
```go
// app 鉴权通过后
context.LogBind("user_id", context.Session("user_id").ToCeilInt64())
// admin 鉴权通过后(或 session 已有 admin_id
context.LogBind("admin_id", context.Session("admin_id").ToCeilInt64())
```
`LogBind` 后,本请求后续业务日志与 SQL 日志都会带上该字段。
**不带会话字段的边界:**
- `fmt.Println` / stdout 捕获、panic、MySQL driver、定时任务等无请求上下文的日志
- 直接写 `that.Application.Log` 的旧代码(应改用 `that.Logger``that.LogBind` 后的请求级 Logger
---
## 控制台降噪(配了 seqUrl)
| Logger | 控制台行为 |
|---|---|
| 业务日志(`that.Log` | Info+Info / Warn / Error,含 `Display` 非 0 的 WarnDebug/SQL 不上控制台) |
| 访问日志(`that.WebConnectLog`) | Warn+(访问日志量大,Info 级不上控制台) |
| 出口 | 行为 |
|---|---|
| Seq | 按 `logLevel` 全量(Info/Debug/SQL/访问日志等) |
| 本地文件 | 与原先一致,不受控制台过滤影响 |
未配置 `seqUrl` 时控制台仍按 `logLevel` 全打。
框架不新增配置项;如需自定义某个 Logger 的控制台门槛,可在挂 Seq 后调用 `Logger.SetConsoleMinLevel(level zerolog.Level)`(仅影响该 Logger 的控制台出口,不影响 Seq/文件)。
---
## 客户端 IP 与地域
零配置。`ip` 选取优先级(跳过空值与回环 `127.0.0.1` / `::1`):
1. `EO-Connecting-IP`EdgeOne 真实建连 IP
2. `X-Forwarded-For` **从左到右第一个非回环**(反代误把 CDN 写入 `X-Real-IP` 时仍能落到客户端)
3. `X-Real-IP`(非回环)
4. `RemoteAddr`
访问日志另附 `ip_chain`:上述源头出现过的 IP 按**首次出现**顺序逗号拼接,**同一 IP 不重复**;若与 `ip` 相同则省略该字段。
请求头有 `EO-Client-IPCountry` 时,访问日志追加 `ip_country`(两位国家码);没有则不记。
---
## 架构原理
```
业务代码
│ that.Logger.Info().Msg("...") ← 请求级(含 sid/request_id
│ Db.Query → SQL 日志 ← 同一请求级 Logger
│ fmt.Println("...") ← 捕获后无 sid
multiWriterhotimev1.5/log/logger.go
├─ Console(挂 Seq 后:业务 Info+ / 访问日志 Warn+)→ 终端
├─ FileWriter → 本地文件(按需,全量)
└─ SeqWriter → Seq(全量)
│ Write() 只做 channel <- bytesO(1) 非阻塞
channel(容量 10000
↓ 后台 goroutine
批量打包(100 条 或 500ms
↓ HTTP POST(失败重试 1 次)
Seq 服务(CLEF 格式)
```
优雅停机时调用 `CloseSeq()` 冲刷残留批次,避免停机前后日志丢失。
---
## 字段映射(zerolog → CLEF
| zerolog 字段 | Seq CLEF 字段 | 说明 |
|---|---|---|
| `time` | `@t` | 毫秒精度 ISO 8601 |
| `level` | `@l` | Debug/Information/Warning/Error/Fatal |
| `message` / `msg` | `@m` | 消息正文(不用 `@mt`,避免 `{xxx}` 被当模板) |
| `caller` | `caller` | 调用位置 |
| `sid` | `sid` | sessionId 前 12 位 |
| `request_id` | `request_id` | 单次请求 id |
| `ip` | `ip` | 客户端最佳 IP |
| `ip_chain` | `ip_chain` | 多源去重链路(与 `ip` 不同时才有) |
| `ip_country` | `ip_country` | 有 `EO-Client-IPCountry` 时 |
| `ua` | `ua` | 访问日志携带,User-Agent 原文截断 200 字符(按 rune 安全截断) |
| 其余自定义字段 | 原字段名 | `LogBind` 追加的字段原样保留 |
| — | `instance` | `ip:port` |
| — | `source` | stdout / stderr / panic 等 |
---
## 搜索语法速查
| 目标 | 查询语句 |
|---|---|
| 按会话追踪 | `sid = 'abc123def456'` |
| 按单次请求 | `request_id = 'fedcba987654'` |
| 关键词 | 直接输入,如 `支付失败` |
| 日志级别 | `@l = 'Error'` |
| 特定实例 | `instance = '192.168.1.10:8085'` |
| 地域 | `ip_country = 'CN'` |
| stdout | `source = 'stdout'` |
| panic | `source = 'panic'` |
| 组合 | `@l = 'Error' and sid = 'abc123def456'` |
| 关键字组合其他条件 | `@Message like '%支付失败%' and @l = 'Warning' and instance = '192.168.1.10:8085'` |
---
## Seq 安装
- Windows[https://datalust.co/download/seq](https://datalust.co/download/seq)
- Docker`docker run -d --restart always --name seq -p 5341:80 -e ACCEPT_EULA=Y datalust/seq`
- 访问 `http://localhost:5341`
---
## 相关文档
- [QuickStart_快速上手.md](QuickStart_快速上手.md) — 其中的 `that.Log` 是业务 logs 表,与本文 Seq 推送不同
- [GracefulShutdown_优雅停机.md](GracefulShutdown_优雅停机.md)
- [文档索引](README.md)
File diff suppressed because it is too large Load Diff
+88
View File
@@ -1,6 +1,7 @@
package baidu
import (
. "code.hoteas.com/golang/hotime/common"
"fmt"
"io/ioutil"
"net/http"
@@ -21,6 +22,93 @@ func (that *baiduMap) Init(Ak string) {
//query
}
// from 源坐标类型:
// 1GPS标准坐标;
// 2:搜狗地图坐标;
// 3:火星坐标(gcj02),即高德地图、腾讯地图和MapABC等地图使用的坐标;
// 4:3中列举的地图坐标对应的墨卡托平面坐标;
// 5:百度地图采用的经纬度坐标(bd09ll);
// 6:百度地图采用的墨卡托平面坐标(bd09mc);
// 7:图吧地图坐标;
// 851地图坐标;
// int 1 1 否
// to
// 目标坐标类型:
// 3:火星坐标(gcj02),即高德地图、腾讯地图及MapABC等地图使用的坐标;
// 5:百度地图采用的经纬度坐标(bd09ll);
// 6:百度地图采用的墨卡托平面坐标(bd09mc);
func (that *baiduMap) Geoconv(latlngs []Map, from, to int) (Slice, error) {
client := &http.Client{}
latlngsStr := ""
for _, v := range latlngs {
if latlngsStr != "" {
latlngsStr = latlngsStr + ";" + v.GetString("lng") + "," + v.GetString("lat")
} else {
latlngsStr = v.GetString("lng") + "," + v.GetString("lat")
}
}
url := "https://api.map.baidu.com/geoconv/v1/?from=" + ObjToStr(from) + "&to=" + ObjToStr(to) + "&ak=" + that.Ak + "&coords=" + latlngsStr
reqest, err := http.NewRequest("GET", url, nil)
if err != nil {
fmt.Println("Fatal error ", err.Error())
return nil, err
}
response, err := client.Do(reqest)
defer response.Body.Close()
if err != nil {
fmt.Println("Fatal error ", err.Error())
return nil, err
}
body, err := ioutil.ReadAll(response.Body)
if err != nil {
return nil, err
}
//fmt.Println(string(body))
data := ObjToMap(string(body))
if data.GetCeilInt64("status") != 0 {
return nil, err
}
return data.GetSlice("result"), err
}
func (that *baiduMap) GetAddress(lat string, lng string) (string, error) {
client := &http.Client{}
url := "https://api.map.baidu.com/reverse_geocoding/v3/?ak=" + that.Ak + "&output=json&location=" + lat + "," + lng
reqest, err := http.NewRequest("GET", url, nil)
if err != nil {
fmt.Println("Fatal error ", err.Error())
return "", err
}
response, err := client.Do(reqest)
defer response.Body.Close()
if err != nil {
fmt.Println("Fatal error ", err.Error())
return "", err
}
body, err := ioutil.ReadAll(response.Body)
if err != nil {
return "", err
}
//fmt.Println(string(body))
return string(body), err
}
// GetPosition 获取定位列表
func (that *baiduMap) GetPosition(name string, region string) (string, error) {
+2 -2
View File
@@ -49,7 +49,7 @@ func (that *dingdongyun) SendTz(umoblie []string, tpt string, data map[string]st
return that.send(that.TzUrl, umobleStr, tpt)
}
//发送短信
// 发送短信
func (that *dingdongyun) send(mUrl string, umoblie string, content string) (bool, error) {
data_send_sms_yzm := url.Values{"apikey": {that.ApiKey}, "mobile": {umoblie}, "content": {content}}
@@ -74,7 +74,7 @@ func (that *dingdongyun) send(mUrl string, umoblie string, content string) (bool
return true, nil
}
//调用url发送短信的连接
// 调用url发送短信的连接
func (that *dingdongyun) httpsPostForm(url string, data url.Values) (string, error) {
resp, err := http.PostForm(url, data)
+2 -2
View File
@@ -9,8 +9,8 @@ import (
"github.com/silenceper/wechat/v2/officialaccount/oauth"
)
//基于此文档开发
//https://github.com/silenceper/wechat/blob/v2/doc/api/officialaccount.md
// 基于此文档开发
// https://github.com/silenceper/wechat/blob/v2/doc/api/officialaccount.md
type h5Program struct {
Memory *cache.Memory
Config *h5config.Config
+2 -2
View File
@@ -9,8 +9,8 @@ import (
"time"
)
//基于此文档开发
//https://github.com/silenceper/wechat/blob/v2/doc/api/officialaccount.md
// 基于此文档开发
// https://github.com/silenceper/wechat/blob/v2/doc/api/officialaccount.md
type wxpay struct {
client *wechat.ClientV3
ctx context.Context
+150
View File
@@ -0,0 +1,150 @@
package app
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"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) {
testApp = Test("config/config.json").
WorkDir("..").
Listener(func(ctx *Context) bool {
return false // 空实现:继续进控制器;需要鉴权/拦截时在此写
}).
Swagger("My API", "1.0.0").
Proj(TestProj{
"app": {
Proj: Project,
Tests: ProjectTest,
},
// 通用 CRUDcodeConfig=admin)路由由 Init 时按库表生成,这里仅挂测试
"admin": {
Proj: Proj{},
Tests: AdminDepartmentTest,
},
}).
Flows(DemoFlows)
// 仅本 example:空库时灌演示数据。须连专用测试库,勿对生产库调用。
SetupDatabase(&testApp.Db)
os.Exit(testApp.Run(m))
}
func TestApi(t *testing.T) {
testApp.RunTests(t)
}
// TestListenerChain 验证链式 Listener 在请求路径上被调用。
// 注意:TestMain 已 Chdir 到 example/,此处不再 WorkDir。
func TestListenerChain(t *testing.T) {
hit := false
app := Test("config/config.json").
Listener(func(ctx *Context) bool {
hit = true
return false
}).
Proj(TestProj{
"app": {
Proj: Project,
Tests: ProjTest{
"expect_demo": CtrTest{
"string_result": {Desc: "触发 listener", Func: func(a *Api) {
ExpectDemoTest["string_result"].Func(a)
}},
},
},
},
})
SetupDatabase(&app.Db)
app.RunTests(t)
if !hit {
t.Fatal("Listener 未被调用")
}
_ = app.Db.RollbackTestTx()
}
// TestInterruptStop 模拟提前中断:string_result 跑完后 RequestStopzzz_should_skip 应 Skip
// swagger 中 string_result 的 cases 应保留。注意:TestMain 已 Chdir 到 example/,此处不再 WorkDir。
func TestInterruptStop(t *testing.T) {
var app *TestApp
secondRan := false
app = Test("config/config.json").
Swagger("Interrupt Demo", "1.0.0").
Proj(TestProj{
"app": {
Proj: Project,
Tests: ProjTest{
"expect_demo": CtrTest{
"string_result": {Desc: "先跑并停测", Func: func(a *Api) {
ExpectDemoTest["string_result"].Func(a)
app.RequestStop()
}},
"zzz_should_skip": {Desc: "应被跳过", Func: func(a *Api) {
secondRan = true
t.Error("zzz_should_skip 在 RequestStop 后不应执行")
}},
},
},
},
})
SetupDatabase(&app.Db)
app.RunTests(t)
if secondRan {
t.Fatal("停测后仍执行了后续接口")
}
_ = app.Db.RollbackTestTx()
// 收尾写盘(中断路径下可能未跑满 GenerateSwagger,显式补一次增量合并)
if err := app.GenerateSwagger("Interrupt Demo", "1.0.0", app.Config.GetString("tpt")); err != nil {
t.Fatalf("GenerateSwagger: %v", err)
}
specPath := filepath.Join(app.Config.GetString("tpt"), "swagger", "app", "api-spec.json")
data, err := os.ReadFile(specPath)
if err != nil {
t.Fatalf("应已生成 swagger %s: %v", specPath, err)
}
var spec struct {
Endpoints []struct {
Path string `json:"path"`
Cases json.RawMessage `json:"cases"`
} `json:"endpoints"`
}
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("解析 api-spec 失败: %v", err)
}
found := false
for _, ep := range spec.Endpoints {
if ep.Path != "/app/expect_demo/string_result" {
continue
}
found = true
if !strings.Contains(string(ep.Cases), `"name"`) {
t.Fatalf("string_result 应保留测试 cases,实际: %s", string(ep.Cases))
}
}
if !found {
t.Fatal("swagger 中应包含已跑完的 /app/expect_demo/string_result")
}
}
+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}},
})
}},
}
+240
View File
@@ -0,0 +1,240 @@
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, "名称不能为空")
// AnyMsg:跳过错误 msg 断言(仅断言 status)。用于响应 msg 含网络错误等
// 动态内容的用例——desc 与实际 msg 不一致时,无 AnyMsg 会因 desc 兜底匹配而失败
a.Form(Map{"name": ""}).AnyMsg().Post("AnyMsg跳过msg断言-desc与实际msg不一致", 3)
// ======== 第二步:正确请求 + 结构校验 + Verify 值断言 ========
a.Form(Map{"name": "测试商品"}).
Verify(func(a *Api) error {
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 返回 error 会使该用例失败并写入 failReason(见覆盖率报告「未通过的接口」)
}},
}
+44
View File
@@ -0,0 +1,44 @@
package app
import (
"fmt"
. "code.hoteas.com/golang/hotime"
. "code.hoteas.com/golang/hotime/common"
)
// DemoFlows 多接口业务流程联调示例(整条 Flow 共用一次测试事务,结束回滚)
var DemoFlows = FlowTest{
"expect_chain": {
Group: "expect_demo",
Desc: "expect_demo 多接口联调",
Prep: "依次调用 string_result 与 map_result。\n步骤通过 FromCase 复用单接口验收用例的请求模板,跑通后回写该用例的响应与通过状态。",
Func: func(f *Flow) {
f.Step("字符串结果", "/app/expect_demo/string_result").
FromCase("result是字符串").
Note("校验字符串 result:期望返回「操作成功」。").
Verify(func(a *Api) error {
if a.Resp().GetBody().GetString("result") != "操作成功" {
return fmt.Errorf("result 期望 操作成功")
}
return nil
}).
Get("步骤1-字符串", 0, "样本")
f.Step("Map结果", "/app/expect_demo/map_result").
FromCase("result是Map-校验字段名+类型+值").
Note("校验对象 result:至少包含 id=1,用于串联后续步骤前的结构确认。").
Verify(func(a *Api) error {
result := a.Resp().GetBody().GetMap("result")
if result == nil {
return fmt.Errorf("result 为 nil")
}
if result.GetCeilInt64("id") != 1 {
return fmt.Errorf("id 期望 1")
}
return nil
}).
Get("步骤2-Map", 0, Map{"id": int64(1), "name": "sample", "price": float64(1.0), "in_stock": true})
},
},
}
+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,
}
+149
View File
@@ -0,0 +1,149 @@
package app
// 通用 CRUD 树表查询(MakeCode.Search 的 parent_id / showself / showall 语义)行为测试。
// 回归背景:parent_id=X 且 showself=1 时旧逻辑拼出 OR(parent_id=X, id=X)
// 把节点 X 自己当作 X 的子级返回,前端树无限嵌套(无限套娃)。
// 依赖 department 树表(表名避开 admin.json 遗留 flow 对 org 的 admin_id 注入;
// 见 setup_mysql.go;表需在 Init 前已存在于测试库)。
import (
"fmt"
. "code.hoteas.com/golang/hotime"
. "code.hoteas.com/golang/hotime/common"
)
// resultIds 提取 result.data 的 id 集合
func resultIds(a *Api) map[int64]bool {
ids := map[int64]bool{}
data := a.Resp().GetBody().GetMap("result").GetSlice("data")
for k := range data {
ids[data.GetMap(k).GetCeilInt64("id")] = true
}
return ids
}
var AdminDepartmentTest = ProjTest{
"department": CtrTest{
"search": {Desc: "树表通用查询:parent_id 子级懒加载不得返回自身", Func: func(a *Api) {
// ======== 错误用例 ========
a.Query(Map{"parent_id": "1", "showself": "1"}).
Get("未登录访问", 2, "你还没有登录")
// ======== 准备树数据:root → nodeX → child ========
adminId := a.DB().Insert("admin", Map{
"name": "树测管理员", "phone": "139" + ObjToStr(RandX(10000000, 99999999)),
"state": 1, "password": Md5("tree123"), "role_id": 1,
"create_time[#]": "NOW()", "modify_time[#]": "NOW()",
})
session := Map{"admin_id": adminId}
rootId := a.DB().Insert("department", Map{
"name": "树测根部门", "state": 0,
"create_time[#]": "NOW()", "modify_time[#]": "NOW()",
})
a.DB().Update("department", Map{"parent_ids": "," + ObjToStr(rootId) + ","}, Map{"id": rootId})
nodeX := a.DB().Insert("department", Map{
"name": "树测节点X", "parent_id": rootId, "state": 0,
"create_time[#]": "NOW()", "modify_time[#]": "NOW()",
})
a.DB().Update("department", Map{"parent_ids": "," + ObjToStr(rootId) + "," + ObjToStr(nodeX) + ","}, Map{"id": nodeX})
childId := a.DB().Insert("department", Map{
"name": "树测子节点", "parent_id": nodeX, "state": 0,
"create_time[#]": "NOW()", "modify_time[#]": "NOW()",
})
a.DB().Update("department", Map{"parent_ids": "," + ObjToStr(rootId) + "," + ObjToStr(nodeX) + "," + ObjToStr(childId) + ","}, Map{"id": childId})
rowSample := Map{"count": int64(1), "data": Slice{Map{"id": int64(1), "name": "sample"}}}
// ======== 核心回归:子级懒加载带 showself 不得包含节点自身 ========
a.WithSession(session).
Note("旧缺陷:OR(parent_id=X, id=X) 把 X 自己当作 X 的子级返回,树无限嵌套").
Query(Map{"parent_id": nodeX, "showself": "1", "pageSize": "50"}).
Verify(func(a *Api) error {
ids := resultIds(a)
if ids[nodeX] {
return fmt.Errorf("子级查询结果不应包含节点自身 id=%d", nodeX)
}
if !ids[childId] {
return fmt.Errorf("子级查询结果应包含直接子级 id=%d", childId)
}
return nil
}).
Get("子级懒加载showself不返回自身", 0, rowSample)
// ======== 不传 showself:同样只返回直接子级 ========
a.WithSession(session).
Query(Map{"parent_id": nodeX, "pageSize": "50"}).
Verify(func(a *Api) error {
ids := resultIds(a)
if ids[nodeX] || !ids[childId] {
return fmt.Errorf("普通子级查询应只含直接子级,实际: %v", ids)
}
return nil
}).
Get("普通子级查询只返回子级", 0, rowSample)
// ======== showall+showself(管理端列表按树筛选场景):含自身与全部子孙 ========
a.WithSession(session).
Note("Table.vue 树筛选场景:showall=1 展示选中节点及全部子孙,showself=1 保留自身").
Query(Map{"parent_id": nodeX, "showself": "1", "showall": "1", "pageSize": "50"}).
Verify(func(a *Api) error {
ids := resultIds(a)
if !ids[nodeX] {
return fmt.Errorf("showall+showself 应包含节点自身 id=%d", nodeX)
}
if !ids[childId] {
return fmt.Errorf("showall+showself 应包含子孙 id=%d", childId)
}
if ids[rootId] {
return fmt.Errorf("showall+showself 不应包含父级 id=%d", rootId)
}
return nil
}).
Get("showall加showself含自身与子孙", 0, rowSample)
// ======== 修复回归:文本字段部分关键词模糊匹配 + 非文本字段等值筛选不受影响 ========
// 背景 bugcode/makecode.go Search):文本类字段(如 name)曾同时生成等值条件
// name=值 与模糊条件 name[~]=值,二者在 where 中以 AND 叠加;前端筛选框传入
// 部分关键词时等值条件必不命中,AND 之后查询恒为空。
// 修复后:文本字段只走模糊匹配;非文本字段(如 state,规则覆盖为 select 类型)维持等值匹配现状。
textId := a.DB().Insert("department", Map{
"name": "综合行政部", "state": 5, "parent_id": nodeX,
"create_time[#]": "NOW()", "modify_time[#]": "NOW()",
})
a.DB().Update("department", Map{"parent_ids": "," + ObjToStr(rootId) + "," + ObjToStr(nodeX) + "," + ObjToStr(textId) + ","}, Map{"id": textId})
a.WithSession(session).
Note("修复前:name 等值(综合行政部≠行政)与模糊[~]同时AND叠加,传部分关键词恒查不到;修复后应命中模糊匹配").
Query(Map{"parent_id": rootId, "showself": "1", "showall": "1", "pageSize": "50", "name": "行政"}).
Verify(func(a *Api) error {
ids := resultIds(a)
if !ids[textId] {
return fmt.Errorf("文本字段部分关键词模糊查询应命中 id=%d, 实际: %v", textId, ids)
}
return nil
}).
Get("文本字段部分关键词命中模糊匹配", 0, rowSample)
a.WithSession(session).
Note("state 规则覆盖为 select 类型(非text),应维持等值匹配现状,只精确命中 state=5 的记录").
Query(Map{"parent_id": rootId, "showself": "1", "showall": "1", "pageSize": "50", "state": "5"}).
Verify(func(a *Api) error {
ids := resultIds(a)
if !ids[textId] {
return fmt.Errorf("非文本字段等值筛选应命中 id=%d, 实际: %v", textId, ids)
}
for id := range ids {
if id != textId {
return fmt.Errorf("非文本字段等值筛选不应包含其它 state 的记录,实际额外命中: id=%d", id)
}
}
return nil
}).
Get("非文本字段等值筛选行为不变", 0, rowSample)
}},
},
}
+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")`)
}
}
+151
View File
@@ -0,0 +1,151 @@
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")
}
// department 部门表(树形结构,供通用 CRUD 树查询测试使用;
// 不叫 org 是因为 config/admin.json 遗留 flow 会给 org 注入 admin_id 过滤)
// 注意:MakeCode 路由在 Init 时按库中已有表生成,
// 全新空库首次运行建表晚于 Init/admin/department/search 需第二次运行才可用
tbl = prefix + "department"
if !mysqlTableExists(db, tbl) {
fmt.Println("[MySQL Setup] 创建表:", tbl)
db.Exec("CREATE TABLE `" + tbl + "` (" +
"`id` int(11) unsigned NOT NULL AUTO_INCREMENT," +
"`name` varchar(100) DEFAULT NULL COMMENT '部门名称'," +
"`parent_id` int(11) DEFAULT NULL COMMENT '父级ID'," +
"`parent_ids` varchar(255) DEFAULT NULL COMMENT '层级路径'," +
"`state` int(2) DEFAULT '0'," +
"`create_time` datetime DEFAULT NULL," +
"`modify_time` datetime DEFAULT NULL," +
"PRIMARY KEY (`id`)," +
"KEY `idx_parent_id` (`parent_id`)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门表'")
}
// test_batch 测试批量表
tbl = prefix + "test_batch"
if !mysqlTableExists(db, tbl) {
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"))
}
+809
View File
@@ -0,0 +1,809 @@
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表)"}
uniq := time.Now().UnixNano()
adminId := that.Db.Insert("admin", Map{
"name": "测试管理员_" + fmt.Sprintf("%d", uniq),
"phone": fmt.Sprintf("139%08d", uniq%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
}
+414
View File
@@ -0,0 +1,414 @@
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"runtime"
"sync"
"sync/atomic"
"time"
)
// 压测配置
type BenchConfig struct {
URL string
Concurrency int
Duration time.Duration
Timeout time.Duration
}
// 压测结果
type BenchResult struct {
TotalRequests int64
SuccessRequests int64
FailedRequests int64
TotalDuration time.Duration
MinLatency int64 // 纳秒
MaxLatency int64
AvgLatency int64
P50Latency int64 // 50分位
P90Latency int64 // 90分位
P99Latency int64 // 99分位
QPS float64
}
// 延迟收集器
type LatencyCollector struct {
latencies []int64
mu sync.Mutex
}
func (lc *LatencyCollector) Add(latency int64) {
lc.mu.Lock()
lc.latencies = append(lc.latencies, latency)
lc.mu.Unlock()
}
func (lc *LatencyCollector) GetPercentile(p float64) int64 {
lc.mu.Lock()
defer lc.mu.Unlock()
if len(lc.latencies) == 0 {
return 0
}
// 简单排序取百分位
n := len(lc.latencies)
idx := int(float64(n) * p)
if idx >= n {
idx = n - 1
}
// 部分排序找第idx个元素
return quickSelect(lc.latencies, idx)
}
func quickSelect(arr []int64, k int) int64 {
if len(arr) == 1 {
return arr[0]
}
pivot := arr[len(arr)/2]
var left, right, equal []int64
for _, v := range arr {
if v < pivot {
left = append(left, v)
} else if v > pivot {
right = append(right, v)
} else {
equal = append(equal, v)
}
}
if k < len(left) {
return quickSelect(left, k)
} else if k < len(left)+len(equal) {
return pivot
}
return quickSelect(right, k-len(left)-len(equal))
}
func main() {
// 最大化利用CPU
runtime.GOMAXPROCS(runtime.NumCPU())
fmt.Println("==========================================")
fmt.Println(" 🔥 HoTime 极限压力测试 🔥")
fmt.Println("==========================================")
fmt.Printf("CPU 核心数: %d\n", runtime.NumCPU())
fmt.Println()
// 极限测试配置
configs := []BenchConfig{
{URL: "http://127.0.0.1:8081/app/test/hello", Concurrency: 500, Duration: 15 * time.Second, Timeout: 10 * time.Second},
{URL: "http://127.0.0.1:8081/app/test/hello", Concurrency: 1000, Duration: 15 * time.Second, Timeout: 10 * time.Second},
{URL: "http://127.0.0.1:8081/app/test/hello", Concurrency: 2000, Duration: 15 * time.Second, Timeout: 10 * time.Second},
{URL: "http://127.0.0.1:8081/app/test/hello", Concurrency: 5000, Duration: 15 * time.Second, Timeout: 10 * time.Second},
{URL: "http://127.0.0.1:8081/app/test/hello", Concurrency: 10000, Duration: 15 * time.Second, Timeout: 10 * time.Second},
}
// 检查服务
fmt.Println("正在检查服务是否可用...")
if !checkService(configs[0].URL) {
fmt.Println("❌ 服务不可用,请先启动示例应用")
return
}
fmt.Println("✅ 服务已就绪")
fmt.Println()
// 预热
fmt.Println("🔄 预热中 (5秒)...")
warmup(configs[0].URL, 100, 5*time.Second)
fmt.Println("✅ 预热完成")
fmt.Println()
var maxQPS float64
var maxConcurrency int
// 执行极限测试
for i, config := range configs {
fmt.Printf("══════════════════════════════════════════\n")
fmt.Printf("【极限测试 %d】并发数: %d, 持续时间: %v\n", i+1, config.Concurrency, config.Duration)
fmt.Printf("══════════════════════════════════════════\n")
result := runBenchmark(config)
printResult(result)
if result.QPS > maxQPS {
maxQPS = result.QPS
maxConcurrency = config.Concurrency
}
// 检查是否达到瓶颈
successRate := float64(result.SuccessRequests) / float64(result.TotalRequests) * 100
if successRate < 95 {
fmt.Println("\n⚠️ 成功率低于95%,已达到服务极限!")
break
}
if result.AvgLatency > int64(100*time.Millisecond) {
fmt.Println("\n⚠️ 平均延迟超过100ms,已达到服务极限!")
break
}
fmt.Println()
// 测试间隔
if i < len(configs)-1 {
fmt.Println("冷却 5 秒...")
time.Sleep(5 * time.Second)
fmt.Println()
}
}
// 最终报告
fmt.Println()
fmt.Println("══════════════════════════════════════════")
fmt.Println(" 📊 极限测试总结")
fmt.Println("══════════════════════════════════════════")
fmt.Printf("最高 QPS: %.2f 请求/秒\n", maxQPS)
fmt.Printf("最佳并发数: %d\n", maxConcurrency)
fmt.Println()
// 并发用户估算
estimateUsers(maxQPS)
}
func checkService(url string) bool {
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get(url)
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode == 200
}
func warmup(url string, concurrency int, duration time.Duration) {
client := &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
MaxIdleConns: concurrency * 2,
MaxIdleConnsPerHost: concurrency * 2,
},
}
done := make(chan struct{})
var wg sync.WaitGroup
for i := 0; i < concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-done:
return
default:
resp, err := client.Get(url)
if err == nil {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}
}
}
}()
}
time.Sleep(duration)
close(done)
wg.Wait()
}
func runBenchmark(config BenchConfig) BenchResult {
var (
totalRequests int64
successRequests int64
failedRequests int64
totalLatency int64
minLatency int64 = int64(time.Hour)
maxLatency int64
mu sync.Mutex
)
collector := &LatencyCollector{
latencies: make([]int64, 0, 100000),
}
// 高性能HTTP客户端
client := &http.Client{
Timeout: config.Timeout,
Transport: &http.Transport{
MaxIdleConns: config.Concurrency * 2,
MaxIdleConnsPerHost: config.Concurrency * 2,
MaxConnsPerHost: config.Concurrency * 2,
IdleConnTimeout: 90 * time.Second,
DisableKeepAlives: false,
DisableCompression: true,
},
}
done := make(chan struct{})
var wg sync.WaitGroup
startTime := time.Now()
// 启动并发
for i := 0; i < config.Concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-done:
return
default:
reqStart := time.Now()
success := makeRequest(client, config.URL)
latency := time.Since(reqStart).Nanoseconds()
atomic.AddInt64(&totalRequests, 1)
atomic.AddInt64(&totalLatency, latency)
if success {
atomic.AddInt64(&successRequests, 1)
} else {
atomic.AddInt64(&failedRequests, 1)
}
// 采样收集延迟(每100个请求采样1个,减少内存开销)
if atomic.LoadInt64(&totalRequests)%100 == 0 {
collector.Add(latency)
}
mu.Lock()
if latency < minLatency {
minLatency = latency
}
if latency > maxLatency {
maxLatency = latency
}
mu.Unlock()
}
}
}()
}
time.Sleep(config.Duration)
close(done)
wg.Wait()
totalDuration := time.Since(startTime)
result := BenchResult{
TotalRequests: totalRequests,
SuccessRequests: successRequests,
FailedRequests: failedRequests,
TotalDuration: totalDuration,
MinLatency: minLatency,
MaxLatency: maxLatency,
P50Latency: collector.GetPercentile(0.50),
P90Latency: collector.GetPercentile(0.90),
P99Latency: collector.GetPercentile(0.99),
}
if totalRequests > 0 {
result.AvgLatency = totalLatency / totalRequests
result.QPS = float64(totalRequests) / totalDuration.Seconds()
}
return result
}
func makeRequest(client *http.Client, url string) bool {
resp, err := client.Get(url)
if err != nil {
return false
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return false
}
if resp.StatusCode != 200 {
return false
}
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
return false
}
if status, ok := result["status"].(float64); !ok || status != 0 {
return false
}
return true
}
func printResult(result BenchResult) {
successRate := float64(result.SuccessRequests) / float64(result.TotalRequests) * 100
fmt.Printf("总请求数: %d\n", result.TotalRequests)
fmt.Printf("成功请求: %d\n", result.SuccessRequests)
fmt.Printf("失败请求: %d\n", result.FailedRequests)
fmt.Printf("成功率: %.2f%%\n", successRate)
fmt.Printf("总耗时: %v\n", result.TotalDuration.Round(time.Millisecond))
fmt.Printf("QPS: %.2f 请求/秒\n", result.QPS)
fmt.Println("------------------------------------------")
fmt.Printf("最小延迟: %v\n", time.Duration(result.MinLatency).Round(time.Microsecond))
fmt.Printf("平均延迟: %v\n", time.Duration(result.AvgLatency).Round(time.Microsecond))
fmt.Printf("P50延迟: %v\n", time.Duration(result.P50Latency).Round(time.Microsecond))
fmt.Printf("P90延迟: %v\n", time.Duration(result.P90Latency).Round(time.Microsecond))
fmt.Printf("P99延迟: %v\n", time.Duration(result.P99Latency).Round(time.Microsecond))
fmt.Printf("最大延迟: %v\n", time.Duration(result.MaxLatency).Round(time.Microsecond))
// 性能评级
fmt.Print("\n性能评级: ")
switch {
case result.QPS >= 200000:
fmt.Println("🏆 卓越 (QPS >= 200K)")
case result.QPS >= 100000:
fmt.Println("🚀 优秀 (QPS >= 100K)")
case result.QPS >= 50000:
fmt.Println("⭐ 良好 (QPS >= 50K)")
case result.QPS >= 20000:
fmt.Println("👍 中上 (QPS >= 20K)")
case result.QPS >= 10000:
fmt.Println("📊 中等 (QPS >= 10K)")
default:
fmt.Println("⚠️ 一般 (QPS < 10K)")
}
}
func estimateUsers(maxQPS float64) {
fmt.Println("📈 并发用户数估算(基于不同使用场景):")
fmt.Println("------------------------------------------")
scenarios := []struct {
name string
requestInterval float64 // 用户平均请求间隔(秒)
}{
{"高频交互(每秒1次请求)", 1},
{"活跃用户(每5秒1次请求)", 5},
{"普通浏览(每10秒1次请求)", 10},
{"低频访问(每30秒1次请求)", 30},
{"偶尔访问(每60秒1次请求)", 60},
}
for _, s := range scenarios {
users := maxQPS * s.requestInterval
fmt.Printf("%-30s ~%d 用户\n", s.name, int(users))
}
fmt.Println()
fmt.Println("💡 实际生产环境建议保留 30-50% 性能余量")
fmt.Printf(" 安全并发用户数: %d - %d (普通浏览场景)\n",
int(maxQPS*10*0.5), int(maxQPS*10*0.7))
}
+327
View File
@@ -0,0 +1,327 @@
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"runtime"
"sync"
"sync/atomic"
"time"
)
func main() {
runtime.GOMAXPROCS(runtime.NumCPU())
fmt.Println("==========================================")
fmt.Println(" 🔥 HoTime 极限压力测试 🔥")
fmt.Println("==========================================")
fmt.Printf("CPU 核心数: %d\n", runtime.NumCPU())
fmt.Println()
baseURL := "http://127.0.0.1:8081/app/test/hello"
// 检查服务
fmt.Println("正在检查服务是否可用...")
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get(baseURL)
if err != nil || resp.StatusCode != 200 {
fmt.Println("❌ 服务不可用,请先启动示例应用")
return
}
resp.Body.Close()
fmt.Println("✅ 服务已就绪")
fmt.Println()
// 预热
fmt.Println("🔄 预热中 (5秒)...")
doWarmup(baseURL, 100, 5*time.Second)
fmt.Println("✅ 预热完成")
fmt.Println()
// 极限测试配置
concurrencyLevels := []int{500, 1000, 2000, 5000, 10000}
testDuration := 15 * time.Second
var maxQPS float64
var maxConcurrency int
for i, concurrency := range concurrencyLevels {
fmt.Printf("══════════════════════════════════════════\n")
fmt.Printf("【极限测试 %d】并发数: %d, 持续时间: %v\n", i+1, concurrency, testDuration)
fmt.Printf("══════════════════════════════════════════\n")
total, success, failed, qps, minLat, avgLat, maxLat, p50, p90, p99 := doBenchmark(baseURL, concurrency, testDuration)
successRate := float64(success) / float64(total) * 100
fmt.Printf("总请求数: %d\n", total)
fmt.Printf("成功请求: %d\n", success)
fmt.Printf("失败请求: %d\n", failed)
fmt.Printf("成功率: %.2f%%\n", successRate)
fmt.Printf("QPS: %.2f 请求/秒\n", qps)
fmt.Println("------------------------------------------")
fmt.Printf("最小延迟: %v\n", time.Duration(minLat))
fmt.Printf("平均延迟: %v\n", time.Duration(avgLat))
fmt.Printf("P50延迟: %v\n", time.Duration(p50))
fmt.Printf("P90延迟: %v\n", time.Duration(p90))
fmt.Printf("P99延迟: %v\n", time.Duration(p99))
fmt.Printf("最大延迟: %v\n", time.Duration(maxLat))
// 性能评级
fmt.Print("\n性能评级: ")
switch {
case qps >= 200000:
fmt.Println("🏆 卓越 (QPS >= 200K)")
case qps >= 100000:
fmt.Println("🚀 优秀 (QPS >= 100K)")
case qps >= 50000:
fmt.Println("⭐ 良好 (QPS >= 50K)")
case qps >= 20000:
fmt.Println("👍 中上 (QPS >= 20K)")
case qps >= 10000:
fmt.Println("📊 中等 (QPS >= 10K)")
default:
fmt.Println("⚠️ 一般 (QPS < 10K)")
}
if qps > maxQPS {
maxQPS = qps
maxConcurrency = concurrency
}
// 检查是否达到瓶颈
if successRate < 95 {
fmt.Println("\n⚠️ 成功率低于95%,已达到服务极限!")
break
}
if avgLat > int64(100*time.Millisecond) {
fmt.Println("\n⚠️ 平均延迟超过100ms,已达到服务极限!")
break
}
fmt.Println()
if i < len(concurrencyLevels)-1 {
fmt.Println("冷却 5 秒...")
time.Sleep(5 * time.Second)
fmt.Println()
}
}
// 最终报告
fmt.Println()
fmt.Println("══════════════════════════════════════════")
fmt.Println(" 📊 极限测试总结")
fmt.Println("══════════════════════════════════════════")
fmt.Printf("最高 QPS: %.2f 请求/秒\n", maxQPS)
fmt.Printf("最佳并发数: %d\n", maxConcurrency)
fmt.Println()
// 并发用户估算
fmt.Println("📈 并发用户数估算:")
fmt.Println("------------------------------------------")
fmt.Printf("高频交互(1秒/次): ~%d 用户\n", int(maxQPS*1))
fmt.Printf("活跃用户(5秒/次): ~%d 用户\n", int(maxQPS*5))
fmt.Printf("普通浏览(10秒/次): ~%d 用户\n", int(maxQPS*10))
fmt.Printf("低频访问(30秒/次): ~%d 用户\n", int(maxQPS*30))
fmt.Println()
fmt.Println("💡 生产环境建议保留 30-50% 性能余量")
fmt.Printf(" 安全并发用户数: %d - %d (普通浏览场景)\n",
int(maxQPS*10*0.5), int(maxQPS*10*0.7))
}
func doWarmup(url string, concurrency int, duration time.Duration) {
client := &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
MaxIdleConns: concurrency * 2,
MaxIdleConnsPerHost: concurrency * 2,
},
}
done := make(chan struct{})
var wg sync.WaitGroup
for i := 0; i < concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-done:
return
default:
resp, err := client.Get(url)
if err == nil {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}
}
}
}()
}
time.Sleep(duration)
close(done)
wg.Wait()
}
func doBenchmark(url string, concurrency int, duration time.Duration) (total, success, failed int64, qps float64, minLat, avgLat, maxLat, p50, p90, p99 int64) {
var totalLatency int64
minLat = int64(time.Hour)
var mu sync.Mutex
// 采样收集器
latencies := make([]int64, 0, 50000)
var latMu sync.Mutex
client := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: concurrency * 2,
MaxIdleConnsPerHost: concurrency * 2,
MaxConnsPerHost: concurrency * 2,
IdleConnTimeout: 90 * time.Second,
DisableCompression: true,
},
}
done := make(chan struct{})
var wg sync.WaitGroup
startTime := time.Now()
for i := 0; i < concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-done:
return
default:
reqStart := time.Now()
ok := doRequest(client, url)
lat := time.Since(reqStart).Nanoseconds()
atomic.AddInt64(&total, 1)
atomic.AddInt64(&totalLatency, lat)
if ok {
atomic.AddInt64(&success, 1)
} else {
atomic.AddInt64(&failed, 1)
}
// 采样 (每50个采1个)
if atomic.LoadInt64(&total)%50 == 0 {
latMu.Lock()
latencies = append(latencies, lat)
latMu.Unlock()
}
mu.Lock()
if lat < minLat {
minLat = lat
}
if lat > maxLat {
maxLat = lat
}
mu.Unlock()
}
}
}()
}
time.Sleep(duration)
close(done)
wg.Wait()
totalDuration := time.Since(startTime)
if total > 0 {
avgLat = totalLatency / total
qps = float64(total) / totalDuration.Seconds()
}
// 计算百分位
if len(latencies) > 0 {
p50 = getPercentile(latencies, 0.50)
p90 = getPercentile(latencies, 0.90)
p99 = getPercentile(latencies, 0.99)
}
return
}
func doRequest(client *http.Client, url string) bool {
resp, err := client.Get(url)
if err != nil {
return false
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return false
}
if resp.StatusCode != 200 {
return false
}
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
return false
}
if status, ok := result["status"].(float64); !ok || status != 0 {
return false
}
return true
}
func getPercentile(arr []int64, p float64) int64 {
n := len(arr)
if n == 0 {
return 0
}
// 复制一份避免修改原数组
tmp := make([]int64, n)
copy(tmp, arr)
idx := int(float64(n) * p)
if idx >= n {
idx = n - 1
}
return quickSelect(tmp, idx)
}
func quickSelect(arr []int64, k int) int64 {
if len(arr) == 1 {
return arr[0]
}
pivot := arr[len(arr)/2]
var left, right, equal []int64
for _, v := range arr {
if v < pivot {
left = append(left, v)
} else if v > pivot {
right = append(right, v)
} else {
equal = append(equal, v)
}
}
if k < len(left) {
return quickSelect(left, k)
} else if k < len(left)+len(equal) {
return pivot
}
return quickSelect(right, k-len(left)-len(equal))
}

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