Compare commits

..

86 Commits

Author SHA1 Message Date
hoteas d6ef790010 优化整体 2022-03-13 02:00:40 +08:00
hoteas 5c64580378 优化整体 2022-03-13 01:48:54 +08:00
hoteas e49164fa81 使用go mod 2022-03-13 01:12:29 +08:00
hoteas 8265e24add 使用go mod 2022-03-13 01:00:59 +08:00
hoteas 283985df52 删除无关代码 2022-03-13 00:26:01 +08:00
hoteas 95adca04bd 删除无关代码 2022-03-13 00:24:36 +08:00
hoteas 87039524a7 政企超链接开始集成 2022-03-12 23:52:42 +08:00
hoteas 66cd21c35f 政企超链接开始集成 2022-03-12 23:52:25 +08:00
hoteas 7e64131931 政企超链接开始集成 2022-03-06 23:55:14 +08:00
hoteas e30d40cfbb 政企超链接开始集成 2022-03-04 00:06:26 +08:00
hoteas 751ed0003d 政企超链接开始集成 2022-03-04 00:06:04 +08:00
hoteas 6d9f89a1d4 政企超链接开始集成 2022-03-03 21:23:57 +08:00
hoteas bc329be03b 政企超链接开始集成 2022-03-02 18:19:15 +08:00
hoteas 4597a0a50d 政企超链接开始集成 2022-03-02 12:14:07 +08:00
hoteas 3dda86d170 政企超链接开始集成 2022-02-28 19:37:52 +08:00
hoteas d847da7591 政企超链接开始集成 2022-02-28 19:08:48 +08:00
hoteas e1eca90835 政企超链接开始集成 2022-02-28 09:59:05 +08:00
hoteas 9283b8c284 政企超链接开始集成 2022-02-28 08:53:38 +08:00
hoteas d8d9afa4d2 政企超链接开始集成 2022-02-24 06:26:36 +08:00
hoteas 4d3829c345 简阳版本 2022-02-19 21:38:35 +08:00
hoteas a23037a437 简阳版本 2022-02-19 21:38:17 +08:00
hoteas 181295e54e 更新研发 2022-02-17 04:10:53 +08:00
hoteas 8cc2499e21 更新研发 2022-02-14 19:11:38 +08:00
hoteas a2c49452b4 更新研发 2022-02-14 19:10:58 +08:00
hoteas ebf0df5ca8 更新研发 2022-01-27 23:41:40 +08:00
hoteas 73bf691ccc 更新研发 2022-01-26 11:45:17 +08:00
hoteas b2e9701826 更新研发 2022-01-23 05:13:19 +08:00
hoteas 789b0a14d1 更新研发 2022-01-22 16:12:02 +08:00
hoteas 3ee64fcd8c 更新研发 2022-01-20 07:15:14 +08:00
hoteas 527503b0d9 企业画像 2022-01-19 05:38:49 +08:00
hoteas a95b2fccd7 更新研发 2022-01-17 04:47:39 +08:00
hoteas 7e2abb43e3 权限模块开发 2022-01-12 17:33:20 +08:00
hoteas 48fd753a96 权限模块开发 2022-01-10 13:23:39 +08:00
hoteas 136232dd57 修复太过于严格的bug 2022-01-03 01:42:35 +08:00
hoteas 008f19355c 增加自适应模式 2021-12-31 02:38:51 +08:00
hoteas 1fc0a5bbbc 增加自适应模式 2021-12-30 22:19:52 +08:00
hoteas ce099b8fc8 增加自适应模式 2021-12-29 00:46:07 +08:00
hoteas 9c00ac6ba1 增加自适应模式 2021-12-28 09:26:26 +08:00
hoteas a5c002bbce 增加自适应模式 2021-12-27 21:30:33 +08:00
hoteas cf50a699a6 增加自适应模式 2021-12-27 21:30:24 +08:00
hoteas 662003c0ca 增加自适应模式 2021-12-27 20:40:16 +08:00
hoteas f24de2562a 优化系统 2021-12-27 18:21:41 +08:00
hoteas b84bd7e16f 优化系统 2021-12-27 14:09:49 +08:00
hoteas 2fb6abf53a 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 2eab29f428 优化系统 2021-12-27 14:01:04 +08:00
hoteas 84ee0d259f 优化系统 2021-12-27 14:00:08 +08:00
hoteas eccab42fc8 优化系统 2021-12-18 00:07:35 +08:00
hoteas 1465ba36d3 优化系统 2021-12-17 14:37:41 +08:00
hoteas 8380b097b2 调试 2021-12-17 01:21:08 +08:00
hoteas 6b8f901163 调试 2021-12-16 13:40:19 +08:00
hoteas bb16f0a75b 调试 2021-12-16 13:24:31 +08:00
hoteas 84dbc5d71e 出入库完成 2021-12-16 10:49:35 +08:00
hoteas 166eeee64c 出入库完成 2021-12-15 09:24:41 +08:00
hoteas 7a649fd652 出入库完成 2021-12-15 05:56:31 +08:00
hoteas 94ae568526 书写前端接口 2021-12-12 04:22:30 +08:00
hoteas 9ec597f26c 书写前端接口 2021-12-11 23:53:47 +08:00
hoteas 388bc5006d 书写前端代码 2021-12-11 17:59:02 +08:00
hoteas 0e33c2ad9d ocr增加 2021-12-09 11:16:15 +08:00
hoteas af726fbcfb ocr增加 2021-12-09 10:58:53 +08:00
hoteas 1e82a5964d 优化makecode 2021-11-29 03:27:07 +08:00
hoteas d62ba8b0cd 优化makecode 2021-11-23 18:31:00 +08:00
hoteas 320ebe25ec 优化makecode 2021-11-18 12:22:14 +08:00
hoteas 68257d1742 绵阳宏升代码优化 2021-11-15 03:35:13 +08:00
hoteas 80d167cb1b 绵阳宏升代码优化 2021-11-15 03:34:45 +08:00
hoteas cd911e8fa4 巴中代码提交 2021-11-15 03:32:50 +08:00
hoteas faa6fcb00c 初次提交 2021-11-15 03:30:21 +08:00
hoteas 836c1f461f 初次提交 2021-11-15 03:28:14 +08:00
hoteas 97b2f54383 初次提交 2021-11-15 03:26:54 +08:00
hoteas 9d5f8438c5 初次提交 2021-11-13 21:06:29 +08:00
hoteas 3f7963e7cf 初次提交 2021-11-08 09:16:28 +08:00
hoteas 269df85d27 初次提交 2021-11-08 09:15:52 +08:00
hoteas d28c6863e4 初次提交 2021-11-08 06:50:37 +08:00
hoteas bba4990ccc 完善访问日志 2021-11-08 06:49:34 +08:00
hoteas c57afd6a5d 修复短信发送bug 2021-10-27 00:27:24 +08:00
hoteas 25f355c8d0 增加天府通办登录 2021-10-25 23:18:27 +08:00
hoteas b1b0673e69 修复bug 2021-10-21 23:20:58 +08:00
hoteas 72c086d726 修复bug 2021-10-21 23:19:32 +08:00
hoteas 3810ba6c0b 修复bug 2021-10-21 22:52:40 +08:00
hoteas 5c775b4539 修复bug 2021-10-18 04:49:55 +08:00
hoteas deb3e54cc3 修复bug 2021-09-25 04:36:07 +08:00
hoteas d1a8f6c668 短信变更 2021-09-24 15:01:03 +08:00
hoteas 2291ba7402 巴中恩阳适配 2021-09-24 10:49:07 +08:00
hoteas 20390091f2 巴中恩阳适配 2021-09-17 04:45:43 +08:00
hoteas 123d819b0f 开始新增代码生成配置2.0功能,配置文件格式规定完成 2021-09-17 04:00:49 +08:00
hoteas 30e275a27d 开始新增代码生成配置2.0功能,配置文件格式规定完成 2021-09-16 04:33:10 +08:00
hoteas 1a53f587e8 增加表关联新建功能,同时修复数据库bug 2021-09-12 06:14:01 +08:00
1617 changed files with 2602 additions and 942489 deletions
@@ -1,299 +0,0 @@
---
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`
@@ -1,291 +0,0 @@
---
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`
@@ -1,161 +0,0 @@
---
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` — 同上
@@ -1,139 +0,0 @@
---
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 即可安全跳过。
@@ -1,127 +0,0 @@
---
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` |
@@ -1,220 +0,0 @@
---
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 目录中。
@@ -1,331 +0,0 @@
---
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 路径支持
@@ -1,424 +0,0 @@
---
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,不受影响
@@ -1,305 +0,0 @@
---
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 | 销量方案需要 | 新增函数 |
@@ -1,311 +0,0 @@
---
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。
@@ -1,106 +0,0 @@
---
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()` 的说明但标注为兼容用法
@@ -1,250 +0,0 @@
---
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
# 等日志出现 "服务已安全关闭" 再部署新版
```
@@ -1,50 +0,0 @@
---
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 不一致问题。
@@ -1,81 +0,0 @@
---
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",
}
```
@@ -1,99 +0,0 @@
---
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 类型字段的数据
@@ -1,67 +0,0 @@
---
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-"`
@@ -1,110 +0,0 @@
---
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
```
@@ -1,249 +0,0 @@
---
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()` 正确性
@@ -1,72 +0,0 @@
---
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 花括号"]
```
@@ -1,102 +0,0 @@
---
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 是否需要手动清理。需要先确认具体是哪些列出现了这个问题。
@@ -1,328 +0,0 @@
---
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 一致
@@ -1,53 +0,0 @@
---
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 校验的简单场景):`
@@ -1,41 +0,0 @@
---
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 文档生成 |
```
插入位置:在"改进规划"行之前,作为文档表格的倒数第二行。
@@ -1,70 +0,0 @@
---
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 示例)
- 并发保护与缓存隔离(精简版)
- 二进制响应校验(精简版)
@@ -1,144 +0,0 @@
---
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,插入历史表
- 删除:不记录历史
- 历史表仅日志记录,无读取接口,人工在数据库操作
@@ -1,221 +0,0 @@
---
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 配置示例 |
@@ -1,69 +0,0 @@
---
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(...)`
@@ -1,103 +0,0 @@
---
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): 字段规则示例
@@ -1,145 +0,0 @@
---
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 源码
- 不涉及其他文档
@@ -1,168 +0,0 @@
---
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
@@ -1,32 +0,0 @@
---
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 轮自检。日常小改不适用。
+1 -24
View File
@@ -1,26 +1,3 @@
/.idea/*
.idea
/example/tpt/demo/
*.exe
*.test
*.out
/cover
/coverage.out
/coverage.html
/example/config
/config/
/tpt/
example/tpt/swagger/
**/swagger/
/.cursor/*.log
*.sql
*.py
*.log
logs/
# Cursor / agent debug NDJSON leftovers
debug-*.log
**/debug-*.log
# macOS
.DS_Store
/example/config/app.json
+5 -97
View File
@@ -1,98 +1,6 @@
# HoTime
# hotime
golang web服务框架
支持数据库dbmysql、sqlite3
支持缓存cacheredismemory,数据库
自带工具类,上下文,以及session等功能
**高性能 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 开发更简单、更高效
+198 -451
View File
@@ -1,143 +1,49 @@
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"
mysql "github.com/go-sql-driver/mysql"
"github.com/rs/zerolog"
logrus "github.com/sirupsen/logrus"
. "code.hoteas.com/golang/hotime/log"
"database/sql"
"github.com/sirupsen/logrus"
"io"
"io/ioutil"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
type Application struct {
MakeCodeRouter map[string]*code.MakeCode
*code.MakeCode
MakeCodeRouter Router
MethodRouter
Router
Log *log.Logger
WebConnectLog *log.Logger
ContextBase
Error
Log *logrus.Logger
WebConnectLog *logrus.Logger
Port string //端口号
TLSPort string //ssl访问端口号
connectListener []func(that *Context) bool //所有的访问监听,true按原计划继续使用,false表示有监听器处理
connectDbFunc func() (master, slave *sql.DB)
connectDbFunc func(err ...*Error) (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)
})
}
// clientIP 取客户端真实 IPX-Real-IP → X-Forwarded-For 第一段 → RemoteAddr
func clientIP(req *http.Request) string {
if req == nil {
return ""
}
if ip := strings.TrimSpace(req.Header.Get("X-Real-IP")); ip != "" {
return ip
}
if xff := req.Header.Get("X-Forwarded-For"); xff != "" {
if i := strings.Index(xff, ","); i >= 0 {
return strings.TrimSpace(xff[:i])
}
return strings.TrimSpace(xff)
}
host, _, err := net.SplitHostPort(req.RemoteAddr)
if err == nil && host != "" {
return host
}
return req.RemoteAddr
}
// 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) {
//如果没有设置配置自动生成配置
@@ -164,24 +70,8 @@ func (that *Application) Run(router Router) {
if that.Router == nil {
that.Router = Router{}
}
for k, _ := range router {
v := router[k]
if that.Router[k] == nil {
that.Router[k] = v
}
//直达接口层复用
for k1, _ := range v {
v1 := v[k1]
if that.Router[k][k1] == nil {
that.Router[k][k1] = v1
}
for k2, _ := range v1 {
v2 := v1[k2]
that.Router[k][k1][k2] = v2
}
}
for k, v := range router {
that.Router[k] = v
}
//重新设置MethodRouter//直达路由
that.MethodRouter = MethodRouter{}
@@ -223,11 +113,8 @@ 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.EmitRecoveredPanic(err)
that.Log.Warn(err)
that.Run(router)
}
@@ -247,10 +134,8 @@ func (that *Application) Run(router Router) {
//启动服务
that.Server.Addr = ":" + that.Port
err := that.Server.ListenAndServe()
if err != nil && err != http.ErrServerClosed {
that.Log.Error().Err(err).Msg("HTTP 服务退出")
ch <- 1
}
that.Log.Error(err)
ch <- 1
}()
}
@@ -263,54 +148,33 @@ func (that *Application) Run(router Router) {
//启动服务
that.Server.Addr = ":" + that.TLSPort
err := that.Server.ListenAndServeTLS(that.Config.GetString("tlsCert"), that.Config.GetString("tlsKey"))
if err != nil && err != http.ErrServerClosed {
that.Log.Error().Err(err).Msg("HTTPS 服务退出")
ch <- 2
}
that.Log.Error(err)
ch <- 2
}()
}
if ObjToCeilInt(that.Port) == 0 && ObjToCeilInt(that.TLSPort) == 0 {
that.Log.Errorf("没有端口启用")
that.Log.Error("没有端口启用")
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.Errorf("启动服务失败 : %s", ObjToStr(value))
that.Log.Error("启动服务失败 : " + ObjToStr(value))
}
// SetConnectDB 启动实例
func (that *Application) SetConnectDB(connect func() (master, slave *sql.DB)) {
func (that *Application) SetConnectDB(connect func(err ...*Error) (master, slave *sql.DB)) {
that.connectDbFunc = connect
that.Db.SetConnect(that.connectDbFunc)
that.Db.SetConnect(that.connectDbFunc, &that.Error)
}
// SetDefault 默认配置缓存和session实现
func (that *Application) SetDefault(connect func() (*sql.DB, *sql.DB)) {
func (that *Application) SetDefault(connect func(err ...*Error) (*sql.DB, *sql.DB)) {
that.SetConfig()
if connect != nil {
@@ -323,15 +187,19 @@ func (that *Application) SetDefault(connect func() (*sql.DB, *sql.DB)) {
// SetCache 设置配置文件路径全路径或者相对路径
func (that *Application) SetCache() {
cacheIns := HoTimeCache{}
cacheIns.Init(that.Config.GetMap("cache"), HoTimeDBInterface(&that.Db), that.Log)
cacheIns.Init(that.Config.GetMap("cache"), HoTimeDBInterface(&that.Db), &that.Error)
that.HoTimeCache = &cacheIns
that.Db.HoTimeCache = &cacheIns
//mode生产模式开启的时候才开启数据库缓存,防止调试出问题
if that.Config.GetInt("mode") == 0 {
that.Db.HoTimeCache = &cacheIns
}
}
// SetConfig 设置配置文件路径全路径或者相对路径
func (that *Application) SetConfig(configPath ...string) {
that.Log = log.NewLogger(1, "", 100)
that.Log = GetLog("", true)
that.Error = Error{Logger: that.Log}
if len(configPath) != 0 {
that.configPath = configPath[0]
}
@@ -342,71 +210,55 @@ 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))
//文件是否损坏
cmap.JsonToMap(string(btes), &that.Error)
for k, v := range cmap {
that.Config[k] = v
Config[k] = v
that.Config[k] = v //程序配置
Config[k] = v //系统配置
}
} else {
that.Log.Errorf("配置文件不存在,或者配置出错,使用缺省默认配置")
that.Log.Error("配置文件不存在,或者配置出错,使用缺省默认配置")
}
if !configErr {
that.Log = GetLog(that.Config.GetString("logFile"), true)
that.Error = Error{Logger: that.Log}
if that.Config.Get("webConnectLogShow") == nil || that.Config.GetBool("webConnectLogShow") {
that.WebConnectLog = GetLog(that.Config.GetString("webConnectLogFile"), false)
}
//文件如果损坏则不写入配置防止配置文件数据丢失
if that.Error.GetError() == nil {
//var configByte bytes.Buffer
//判断配置文件是否序列有变化,有则修改配置,无则不变
//fmt.Println(len(btes))
configStr := that.Config.ToJsonString()
if len(btes) != 0 && configStr == string(btes) {
// 配置无变化,跳过写入
} 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)
return
}
}
//写入配置说明
//var configNoteByte bytes.Buffer
configNoteStr := ConfigNote.ToJsonString()
//_ = json.Indent(&configNoteByte, []byte(ConfigNote.ToJsonString()), "", "\t")
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 = 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)
_ = os.MkdirAll(filepath.Dir(that.configPath), os.ModeDir)
err = ioutil.WriteFile(that.configPath, []byte(configStr), os.ModePerm)
if err != nil {
that.Error.SetError(err)
}
that.Log.Infof("Seq 日志推送已启动: url=%s instance=%s", seqUrl, instance)
}
_ = ioutil.WriteFile(filepath.Dir(that.configPath)+"/configNote.json", []byte(configNoteStr), os.ModePerm)
// 把 MySQL 驱动内部 errLog(连接/包错误)接入 HoTime Logger,经 SeqWriter 推送到 Seq
_ = mysql.SetLogger(&mysqlLogAdapter{l: that.Log})
}
}
// SetConnectListener 连接判断,返回false继续传输至控制层,true则停止传输
func (that *Application) SetConnectListener(lis func(that *Context) (isFinished bool)) {
// SetConnectListener 连接判断,返回true继续传输至控制层,false则停止传输
func (that *Application) SetConnectListener(lis func(that *Context) bool) {
that.connectListener = append(that.connectListener, lis)
}
@@ -415,7 +267,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 {
@@ -461,9 +313,6 @@ 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
@@ -475,22 +324,10 @@ 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: &dbCopy,
HandlerStr: unescapeUrl, Logger: reqLog}
Resp: w, Req: req, Application: that, RouterString: s, Config: that.Config, Db: &that.Db,
HandlerStr: unescapeUrl}
//header默认设置
header := w.Header()
header.Set("Content-Type", "text/html; charset=utf-8")
@@ -504,32 +341,33 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) {
defer func() {
//是否展示日志
if that.WebConnectLog != nil {
evt := that.WebConnectLog.Info().
Str("ip", clientIP(req)).
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 country := req.Header.Get("EO-Client-IPCountry"); country != "" {
evt = evt.Str("ip_country", country)
ipStr := Substr(context.Req.RemoteAddr, 0, strings.Index(context.Req.RemoteAddr, ":"))
//负载均衡优化
if ipStr == "127.0.0.1" {
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")
}
}
evt.Msg(context.HandlerStr)
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)
}
}()
//访问拦截true继续false暂停
connectListenerLen := len(that.connectListener) - 1
connectListenerLen := len(that.connectListener)
if connectListenerLen != 0 {
for i := 0; i < connectListenerLen; i++ {
for true {
if connectListenerLen < 0 {
break
if !that.connectListener[i](&context) {
context.View()
return
}
}
if that.connectListener[connectListenerLen](&context) {
context.View()
return
}
connectListenerLen--
}
//接口服务
@@ -547,8 +385,8 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) {
return
}
//url赋值,静态文件必须使用原始路径(Linux文件系统大小写敏感)
path := that.Config.GetString("tpt") + context.HandlerStr
//url赋值
path := that.Config.GetString("tpt") + tempHandlerStr
//判断是否为默认
if path[len(path)-1] == '/' {
@@ -574,13 +412,17 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) {
return
}
// 静态资源可缓存;logLevel 只控制日志/SQL,不再改 Cache-Control
//设置header
delete(header, "Content-Type")
header.Set("Cache-Control", "public")
if that.Config.GetInt("mode") == 0 {
header.Set("Cache-Control", "public")
} else {
header.Set("Cache-Control", "no-cache")
}
t := strings.LastIndex(path, ".")
if t != -1 {
tt := strings.ToLower(path[t:])
tt := path[t:]
if MimeMaps[tt] != "" {
@@ -599,7 +441,6 @@ 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
@@ -629,7 +470,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,Authorization,Cookie,Set-Cookie")
header.Set("Access-Control-Allow-Headers", "X-Requested-With,Content-Type,Access-Token")
if sessionId != "" {
//跨域允许需要设置cookie的允许跨域https才有效果
@@ -644,8 +485,7 @@ 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: "/"})
context.Resp.Header().Set("Set-Cookie", that.Config.GetString("sessionName")+"="+sessionId+"; Path=/; SameSite=None; Secure")
http.SetCookie(context.Resp, &http.Cookie{Name: that.Config.GetString("sessionName"), Value: sessionId, Path: "/"})
}
return
@@ -676,7 +516,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,Authorization,Cookie,Set-Cookie")
header.Set("Access-Control-Allow-Headers", "X-Requested-With,Content-Type,Access-Token")
if sessionId != "" {
//跨域允许需要设置cookie的允许跨域https才有效果
@@ -685,116 +525,46 @@ func (that *Application) crossDomain(context *Context, sessionId string) {
}
// getLocalIP 通过 UDP dial 探测方式获取本机出口 IP(不发送任何数据,无副作用)。
// 用于构建 Seq instance 字段,格式为 ip:port,支持跨机器集群识别。
// 失败时返回 "unknown"。
func getLocalIP() string {
conn, err := net.Dial("udp", "8.8.8.8:80")
if err != nil {
return "unknown"
}
defer conn.Close()
return conn.LocalAddr().(*net.UDPAddr).IP.String()
}
// redirectStdout 重定向 os.Stdout 和标准 log 包到 HoTime Logger。
// 返回管道写端,供 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 {
//Init 初始化application
func Init(config string) Application {
appIns := Application{}
//手动模式,
appIns.SetConfig(config)
SetDB(&appIns)
appIns.SetCache()
codeConfig := appIns.Config.GetSlice("codeConfig")
appIns.MakeCode = &code.MakeCode{}
codeConfig := appIns.Config.GetMap("codeConfig")
appIns.MakeCodeRouter = Router{}
if codeConfig != nil {
for k, _ := range codeConfig {
codeMake := codeConfig.GetMap(k)
if codeMake == nil {
continue
if appIns.Config.GetInt("mode") == 2 {
appIns.MakeCode.Db2JSON(k, codeConfig.GetString(k), &appIns.Db, true)
appIns.MakeCodeRouter[k] = Proj{}
} else if appIns.Config.GetInt("mode") == 3 {
appIns.MakeCode.Db2JSON(k, codeConfig.GetString(k), &appIns.Db, false)
appIns.MakeCodeRouter[k] = Proj{}
} else {
appIns.MakeCode.Db2JSON(k, codeConfig.GetString(k), nil, false)
appIns.MakeCodeRouter[k] = Proj{}
}
//codeMake["table"] = k
if appIns.MakeCodeRouter == nil {
appIns.MakeCodeRouter = map[string]*code.MakeCode{}
}
if codeMake.GetString("name") == "" {
codeMake["name"] = codeMake.GetString("table")
}
appIns.MakeCodeRouter[codeMake.GetString("name")] = &code.MakeCode{Log: appIns.Log}
appIns.MakeCodeRouter[codeMake.GetString("name")].Db2JSON(&appIns.Db, codeMake)
//接入动态代码层
if appIns.Router == nil {
appIns.Router = Router{}
}
//appIns.Router[codeMake.GetString("name")] = TptProject
appIns.Router[codeMake.GetString("name")] = Proj{}
for k2, _ := range TptProject {
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
}
appIns.Router[k] = TptProject
for k1, _ := range appIns.MakeCode.TableColumns {
appIns.Router[k][k1] = appIns.Router[k]["hotimeCommon"]
}
for k1, _ := range appIns.MakeCodeRouter[codeMake.GetString("name")].TableColumns {
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)
setMakeCodeLintener(k, &appIns)
}
}
return &appIns
return appIns
}
// SetDB 智能数据库设置
@@ -802,140 +572,136 @@ 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) {
appIns.Db.Type = "mysql"
appIns.Db.DBName = config.GetString("name")
appIns.Db.Prefix = config.GetString("prefix")
appIns.Db.Log = appIns.Log
appIns.SetConnectDB(func() (master, slave *sql.DB) {
appIns.SetConnectDB(func(err ...*Error) (master, slave *sql.DB) {
//master数据库配置
query := config.GetString("user") + ":" + config.GetString("password") +
"@tcp(" + config.GetString("host") + ":" + config.GetString("port") + ")/" + config.GetString("name") + "?charset=utf8mb4"
"@tcp(" + config.GetString("host") + ":" + config.GetString("port") + ")/" + config.GetString("name") + "?charset=utf8"
DB, e := sql.Open("mysql", query)
if e != nil {
appIns.Log.Error().Err(e).Msg("MySQL 主库连接失败")
if e != nil && len(err) != 0 {
err[0].SetError(e)
}
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=utf8mb4"
"@tcp(" + config.GetString("host") + ":" + configSlave.GetString("port") + ")/" + configSlave.GetString("name") + "?charset=utf8"
DB1, e := sql.Open("mysql", query)
if e != nil {
appIns.Log.Error().Err(e).Msg("MySQL 从库连接失败")
if e != nil && len(err) != 0 {
err[0].SetError(e)
}
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.Log = appIns.Log
appIns.SetConnectDB(func() (master, slave *sql.DB) {
appIns.SetConnectDB(func(err ...*Error) (master, slave *sql.DB) {
db, e := sql.Open("sqlite3", config.GetString("path"))
if e != nil {
appIns.Log.Error().Err(e).Msg("SQLite 连接失败")
if e != nil && len(err) != 0 {
err[0].SetError(e)
}
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
})
}
func setMakeCodeListener(name string, appIns *Application) {
appIns.SetConnectListener(func(context *Context) (isFinished bool) {
codeIns := appIns.MakeCodeRouter[name]
func setMakeCodeLintener(name string, appIns *Application) {
appIns.SetConnectListener(func(context *Context) bool {
if len(context.RouterString) < 2 || appIns.MakeCodeRouter[context.RouterString[0]] == nil {
return isFinished
return true
}
if len(context.RouterString) > 1 && context.RouterString[0] == name {
if context.RouterString[1] == "hotime" && context.RouterString[2] == "login" {
return isFinished
}
if context.RouterString[1] == "hotime" && context.RouterString[2] == "logout" {
return isFinished
}
if context.RouterString[1] == "hotime" && context.RouterString[2] == "config" {
return isFinished
}
if context.RouterString[1] == "hotime" && context.RouterString[2] == "wallpaper" {
return isFinished
}
if context.Session(codeIns.FileConfig.GetString("table")+"_id").Data == nil {
context.Display(2, "你还没有登录")
return true
}
if context.RouterString[1] == "hotime" && context.RouterString[2] == "logout" {
return true
}
if context.Session(name+"_id").Data == nil {
context.Display(2, "你还没有登录")
return false
}
}
if context.RouterString[0] != name {
return isFinished
//文件上传接口
if len(context.RouterString) == 1 && context.RouterString[0] == "file" && context.Req.Method == "POST" {
if context.Session(name+"_id").Data == nil {
context.Display(2, "你还没有登录")
return false
}
//读取网络文件
fi, fheader, err := context.Req.FormFile("file")
if err != nil {
context.Display(3, err)
return false
}
filePath := context.Config.GetString("filePath")
if filePath == "" {
filePath = "file/2006/01/02/"
}
path := time.Now().Format(filePath)
e := os.MkdirAll(context.Config.GetString("tpt")+"/"+path, os.ModeDir)
if e != nil {
context.Display(3, e)
return false
}
filePath = path + Md5(ObjToStr(RandX(100000, 9999999))) + fheader.Filename[strings.LastIndex(fheader.Filename, "."):]
newFile, e := os.Create(context.Config.GetString("tpt") + "/" + filePath)
if e != nil {
context.Display(3, e)
return false
}
_, e = io.Copy(newFile, fi)
if e != nil {
context.Display(3, e)
return false
}
context.Display(0, filePath)
return false
}
if len(context.RouterString) < 2 || len(context.RouterString) > 3 ||
!(context.Router[context.RouterString[0]] != nil &&
context.Router[context.RouterString[0]][context.RouterString[1]] != nil) {
return isFinished
return true
}
//排除无效操作
if len(context.RouterString) == 2 &&
context.Req.Method != "GET" &&
context.Req.Method != "POST" {
return isFinished
return true
}
//排除已有接口的无效操作
if len(context.RouterString) == 3 && context.Router[context.RouterString[0]] != nil && context.Router[context.RouterString[0]][context.RouterString[1]] != nil && context.Router[context.RouterString[0]][context.RouterString[1]][context.RouterString[2]] != nil {
return isFinished
}
//列表检索
if len(context.RouterString) == 2 &&
context.Req.Method == "GET" {
if context.Router[context.RouterString[0]][context.RouterString[1]]["search"] == nil {
return isFinished
return true
}
context.Router[context.RouterString[0]][context.RouterString[1]]["search"](context)
}
@@ -949,24 +715,14 @@ func setMakeCodeListener(name string, appIns *Application) {
}
if len(context.RouterString) == 3 &&
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)
return true
}
//查询单条
if len(context.RouterString) == 3 &&
context.Req.Method == "GET" {
if context.Router[context.RouterString[0]][context.RouterString[1]]["info"] == nil {
return isFinished
return true
}
context.Router[context.RouterString[0]][context.RouterString[1]]["info"](context)
@@ -991,16 +747,7 @@ func setMakeCodeListener(name string, appIns *Application) {
context.Router[context.RouterString[0]][context.RouterString[1]]["remove"](context)
}
//context.View()
return true
context.View()
return false
})
}
// 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...))
}
+43 -236
View File
@@ -2,13 +2,13 @@ package cache
import (
. "code.hoteas.com/golang/hotime/common"
"code.hoteas.com/golang/hotime/log"
"errors"
)
// HoTimeCache 可配置memorydbredis,默认启用memory,默认优先级为memory>redis>db,memory与数据库缓存设置项一致,
// 缓存数据填充会自动反方向反哺,加入memory缓存过期将自动从redis更新,但memory永远不会更新redis,如果是集群建议不要开启memory,配置即启用
//缓存数据填充会自动反方向反哺,加入memory缓存过期将自动从redis更新,但memory永远不会更新redis,如果是集群建议不要开启memory,配置即启用
type HoTimeCache struct {
Log *log.Logger
*Error
dbCache *CacheDb
redisCache *CacheRedis
memoryCache *CacheMemory
@@ -92,7 +92,7 @@ func (that *HoTimeCache) Db(key string, data ...interface{}) *Obj {
}
}
//db缓存有
//redis缓存有
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...)
}
//db缓存有
//redis缓存有
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 && reData.Data != nil {
if reData != nil {
return reData
}
}
@@ -140,7 +140,8 @@ func (that *HoTimeCache) Cache(key string, data ...interface{}) *Obj {
//redis缓存有
if that.redisCache != nil {
reData = that.redisCache.Cache(key, data...)
if reData != nil && reData.Data != nil {
if reData.Data != nil {
if that.memoryCache != nil {
that.memoryCache.Cache(key, reData.Data)
}
@@ -148,10 +149,10 @@ func (that *HoTimeCache) Cache(key string, data ...interface{}) *Obj {
}
}
//db缓存有
//redis缓存有
if that.dbCache != nil {
reData = that.dbCache.Cache(key, data...)
if reData != nil && reData.Data != nil {
if reData.Data != nil {
if that.memoryCache != nil {
that.memoryCache.Cache(key, reData.Data)
}
@@ -173,222 +174,23 @@ func (that *HoTimeCache) Cache(key string, data ...interface{}) *Obj {
if that.redisCache != nil {
reData = that.redisCache.Cache(key, data...)
}
//db缓存有
//redis缓存有
if that.dbCache != nil {
reData = that.dbCache.Cache(key, data...)
}
return reData
}
// 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) {
func (that *HoTimeCache) Init(config Map, hotimeDb HoTimeDBInterface, err ...*Error) {
//防止空数据问题
if config == nil {
config = Map{}
}
that.Log = logger
if err[0] != nil {
that.Error = err[0]
}
that.Config = config
//memory配置初始化
memory := that.Config.GetMap("memory")
if memory == nil {
memory = Map{
@@ -397,74 +199,79 @@ func (that *HoTimeCache) Init(config Map, hotimeDb HoTimeDBInterface, logger *lo
"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"),
Log: logger}
DbSet: memory.GetBool("db"), SessionSet: memory.GetBool("session")}
if err[0] != nil {
that.memoryCache.SetError(err[0])
}
//db配置初始化
redis := that.Config.GetMap("redis")
if redis != nil {
if redis.GetString("host") == "" || redis.GetString("port") == "" {
if logger != nil {
logger.Error().Msg("请检查redis配置host和port配置")
if err[0] != nil {
err[0].SetError(errors.New("请检查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"),
Log: logger}
Pwd: redis.GetString("password"), Port: redis.GetCeilInt64("port")}
if err[0] != nil {
that.redisCache.SetError(err[0])
}
}
//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"),
HistorySet: db.GetBool("history"),
Mode: db.GetString("mode"),
Db: hotimeDb,
Log: logger,
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])
}
}
}
func (that *HoTimeCache) DisableDbCache() {
that.dbCache = nil
that.redisCache = nil
}
+88 -588
View File
@@ -1,29 +1,17 @@
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
@@ -36,638 +24,150 @@ type CacheDb struct {
TimeOut int64
DbSet bool
SessionSet bool
HistorySet bool // 是否开启历史记录
Mode string // 缓存模式:"new"(默认,只用新表) 或 "compatible"(写新读老)
Db HoTimeDBInterface
Log *log.Logger
*Error
ContextBase
isInit bool
}
// getTableName 获取带前缀的表名
func (that *CacheDb) getTableName() string {
return that.Db.GetPrefix() + CacheTableName
func (that *CacheDb) GetError() *Error {
return that.Error
}
// getHistoryTableName 获取带前缀的历史表名
func (that *CacheDb) getHistoryTableName() string {
return that.Db.GetPrefix() + CacheHistoryTableName
func (that *CacheDb) SetError(err *Error) {
that.Error = err
}
// 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" {
dbType := that.Db.GetType()
tableName := that.getTableName()
historyTableName := that.getHistoryTableName()
legacyTableName := that.getLegacyTableName()
// 检查并创建主表
if !that.tableExists(tableName) {
that.createMainTable(dbType, tableName)
}
// 根据模式处理老表
// 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
}
// tableExists 检查表是否存在
func (that *CacheDb) tableExists(tableName string) bool {
dbType := that.Db.GetType()
switch dbType {
case "mysql":
dbNames := that.Db.Query("SELECT DATABASE()")
if len(dbNames) == 0 {
return false
return
}
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
}
// 检查过期时间(老表使用 unix 时间戳)
endTime := cached.GetInt64("endtime")
nowUnix := time.Now().Unix()
if endTime > 0 && endTime <= nowUnix {
// 已过期,删除该条记录
that.deleteLegacy(key)
return nil
}
// 解析 value(老表 value 格式可能是 {"data": xxx} 或直接值)
valueStr := cached.GetString("value")
if valueStr == "" {
return nil
}
data, err := JsonToObj(valueStr)
if err != nil {
return nil
}
// 兼容老版本 {"data": xxx} 包装格式
if dataMap, ok := data.(map[string]interface{}); ok {
if innerData, exists := dataMap["data"]; exists {
return innerData
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, `ckey` varchar(60) DEFAULT NULL, `cvalue` 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
}
}
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,
"ckey" TEXT(60),
"cvalue" TEXT(2000),
"time" integer,
"endtime" integer
);`)
if e.GetError() == nil {
that.isInit = true
}
}
return data
}
// deleteLegacy 从老表删除缓存(兼容模式使用)
func (that *CacheDb) deleteLegacy(key string) {
legacyTableName := that.getLegacyTableName()
// 检查老表是否存在
if !that.tableExists(legacyTableName) {
return
}
del := strings.Index(key, "*")
// 如果通配删除
if del != -1 {
keyPrefix := Substr(key, 0, del)
that.Db.Delete(legacyTableName, Map{"key[~]": keyPrefix + "%"})
} else {
that.Db.Delete(legacyTableName, Map{"key": key})
}
}
// get 获取缓存
//获取Cache键只能为string类型
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
}
}
}
cached := that.Db.Get("cached", "*", Map{"ckey": 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")})
return nil
}
// 兼容模式:新表没有数据时,回退读取老表
if that.isCompatibleMode() {
return that.getLegacy(key)
}
data := Map{}
data.JsonToMap(cached.GetString("cvalue"))
return nil
return data.Get("data")
}
// set 设置缓存
func (that *CacheDb) set(key string, value interface{}, endTime time.Time) {
// 直接序列化 value,不再包装
bte, _ := json.Marshal(value)
nowTime := Time2Str(time.Now())
endTimeStr := Time2Str(endTime)
//key value ,时间为时间戳
func (that *CacheDb) set(key string, value interface{}, tim int64) {
dbType := that.Db.GetType()
tableName := that.getTableName()
bte, _ := json.Marshal(Map{"data": value})
// 使用 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,
})
}
num := that.Db.Update("cached", Map{"cvalue": string(bte), "time": time.Now().UnixNano(), "endtime": tim}, Map{"ckey": key})
if num == int64(0) {
that.Db.Insert("cached", Map{"cvalue": string(bte), "time": time.Now().UnixNano(), "endtime": tim, "ckey": key})
}
// 写入历史记录
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})
that.Db.Delete("cached", Map{"endtime[<]": time.Now().Unix()})
}
}
// 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)
del := strings.Index(key, "*")
//如果通配删除
if del != -1 {
key = Substr(key, 0, del)
that.Db.Delete("cached", Map{"ckey": key + "%"})
} else {
that.Db.Delete("cached", Map{"ckey": 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 {
// 使用配置的 TimeOut,如果为 0 则使用默认值
timeout = that.TimeOut
if timeout == 0 {
timeout = DefaultCacheTimeout
if that.TimeOut == 0 {
//that.Time = Config.GetInt64("cacheLongTime")
}
} 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
}
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
}
}
endTime := time.Now().Add(time.Duration(timeout) * time.Second)
that.set(key, data[0], endTime)
that.set(key, data[0], tim)
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})
}
}
}
+124 -109
View File
@@ -2,142 +2,157 @@ 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
Log *log.Logger
cache sync.Map
Map
*Error
ContextBase
mutex *sync.RWMutex
}
func (c *CacheMemory) get(key string) (res *Obj) {
res = &Obj{}
value, ok := c.cache.Load(key)
if !ok {
return res // 缓存不存在
}
func (that *CacheMemory) GetError() *Error {
data := value.(cacheData)
return that.Error
// 检查是否过期
if data.time < time.Now().Unix() {
c.cache.Delete(key) // 删除过期缓存
return res
}
res.Data = data.data
return res
}
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() {
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()
// 随机触发刷新
if x := RandX(1, 100000); x > 99950 {
c.refreshMap()
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{}
}
if len(data) == 0 {
// 读操作
return c.get(key)
if that.Map[key] == nil {
return nil
}
if len(data) == 1 && data[0] == nil {
// 删除操作
c.delete(key)
data := that.Map.Get(key, that.Error).(cacheData)
if that.Error.GetError() != nil {
return nil
}
// 写操作
expireAt := now + c.TimeOut
if len(data) == 2 {
if customExpire, ok := data[1].(int64); ok {
if customExpire > now {
expireAt = customExpire
} else {
expireAt = now + customExpire
if data.time < time.Now().Unix() {
delete(that.Map, key)
return nil
}
return data.data
}
func (that *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)
}
}
}
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
//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{}
}
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
}
tim := time.Now().Unix()
if len(data) == 1 && data[0] == nil {
that.mutex.Lock()
that.delete(key)
that.mutex.Unlock()
return reData
}
if len(data) == 1 {
tim = tim + that.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
}
}
return result
}
that.mutex.Lock()
that.set(key, data[0], tim)
that.mutex.Unlock()
return reData
// 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)
}
}
+78 -190
View File
@@ -2,10 +2,8 @@ package cache
import (
. "code.hoteas.com/golang/hotime/common"
"code.hoteas.com/golang/hotime/log"
"github.com/garyburd/redigo/redis"
"strings"
"sync"
"time"
)
@@ -16,20 +14,23 @@ type CacheRedis struct {
Host string
Pwd string
Port int64
pool *redis.Pool
conn redis.Conn
tag int64
ContextBase
Log *log.Logger
initOnce sync.Once
*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) GetError() *Error {
return that.Error
}
// 唯一标志
func (that *CacheRedis) SetError(err *Error) {
that.Error = err
}
//唯一标志
func (that *CacheRedis) GetTag() int64 {
if that.tag == int64(0) {
@@ -38,112 +39,83 @@ func (that *CacheRedis) GetTag() int64 {
return that.tag
}
// 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
},
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
}
})
}
// getConn 从连接池获取连接
func (that *CacheRedis) getConn() redis.Conn {
that.initPool()
if that.pool == nil {
return nil
}
return that.pool.Get()
}
return true
}
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(conn.Do("KEYS", key))
val, err := redis.Strings(that.conn.Do("KEYS", key))
if err != nil {
that.logErr(err)
return
}
if len(val) == 0 {
return
}
conn.Send("MULTI")
for i := range val {
conn.Send("DEL", val[i])
}
_, err = conn.Do("EXEC")
if err != nil {
that.logErr(err)
that.conn.Send("MULTI")
for i, _ := range val {
that.conn.Send("DEL", val[i])
}
that.conn.Do("EXEC")
} else {
_, err := conn.Do("DEL", key)
_, err := that.conn.Do("DEL", key)
if err != nil {
that.logErr(err)
that.Error.SetError(err)
_, err = that.conn.Do("PING")
if err != nil {
if that.reCon() {
_, err = that.conn.Do("DEL", key)
}
}
}
}
}
// key value ,时间为时间戳
func (that *CacheRedis) set(key string, value string, expireSeconds int64) {
conn := that.getConn()
if conn == nil {
return
}
defer conn.Close()
_, err := conn.Do("SET", key, value, "EX", ObjToStr(expireSeconds))
//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 {
that.logErr(err)
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))
}
}
}
}
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(conn.Do("GET", key))
reData.Data, err = redis.String(that.conn.Do("GET", key))
if err != nil {
reData.Data = nil
if !strings.Contains(err.Error(), "nil returned") {
that.logErr(err)
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
}
}
return reData
@@ -151,13 +123,19 @@ 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 {
@@ -166,16 +144,21 @@ 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 {
tempt := ObjToInt64(data[1])
that.Error.SetError(nil)
tempt := ObjToInt64(data[1], that.Error)
if tempt > tim {
tim = tempt
} else {
} else if that.GetError() == nil {
tim = tim + tempt
}
}
@@ -183,100 +166,5 @@ 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)
}
}
+6 -4
View File
@@ -5,13 +5,15 @@ 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{}
+158 -1204
View File
File diff suppressed because it is too large Load Diff
+51 -150
View File
@@ -5,20 +5,12 @@ import (
)
var Config = Map{
"name": "HoTimeDashBoard",
//"id": "2f92h3herh23rh2y8",
"name": "HoTimeDashBoard",
"id": "2f92h3herh23rh2y8",
"label": "HoTime管理平台",
"stop": Slice{"role", "org"}, //不更新的,同时不允许修改用户自身对应的表数据
"labelConfig": Map{
"show": "开启",
"add": "添加",
"delete": "删除",
"edit": "编辑",
"info": "查看详情",
"download": "下载清单",
},
"menus": []Map{
//{"label": "平台首页", "name": "HelloWorld", "icon": "el-icon-s-home"},
{"label": "平台首页", "name": "HelloWorld", "icon": "el-icon-s-home"},
//{"label": "测试表格", "table": "table", "icon": "el-icon-suitcase"},
//{"label": "系统管理", "name": "setting", "icon": "el-icon-setting",
// "menus": []Map{
@@ -42,9 +34,8 @@ var DefaultMenuParentName = "sys"
var ColumnDataType = map[string]string{
//sqlite专有类型
"real": "number",
"integer": "number",
//mysql数据类型
"real": "number",
//mysql数据类型宽泛类型
"int": "number",
"float": "number",
"double": "number",
@@ -55,148 +46,58 @@ var ColumnDataType = map[string]string{
"date": "time",
"time": "time",
"year": "time",
"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",
"geometry": "gis", //不建议使用gis类型,建议使用其他代替
}
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 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"},
var ColumnNameType = []ColumnShow{
//通用
{"idcard", false, true, true, false, "", false},
{"id", true, false, true, false, "", true},
{"parent_id", true, true, true, false, "", true},
//"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},
{"name": "parent_id", "add": true, "list": true, "edit": true, "info": true, "must": false, "true": false, "type": ""},
{"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": "amount", "add": true, "list": true, "edit": true, "info": true, "must": false, "strict": true, "type": "money"},
{"name": "info", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": "textArea"},
{"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"},
{"index", false, false, false, false, "index", false},
{"password", false, true, false, false, "password", false},
{"pwd", false, true, false, false, "password", false},
{"info", false, true, true, false, "", 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, "", false},
{"address", false, 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, true, "time", false},
{"level", false, false, true, false, "", false},
{"rule", true, true, true, false, "form", false},
}
//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},
//}
+372 -867
View File
File diff suppressed because it is too large Load Diff
+10 -16
View File
@@ -15,7 +15,6 @@ var Project = Proj{
{{tablesCtr}}
"hotime":Ctr{
"login": func(that *Context) {
name := that.Req.FormValue("name")
password := that.Req.FormValue("password")
if name == "" || password == "" {
@@ -37,9 +36,8 @@ var Project = Proj{
that.Display(0, "退出登录成功")
},
"info": func(that *Context) {
hotimeName := that.RouterString[0]
data := that.Db.Get("admin", "*", Map{"id": that.Session("admin_id").ToCeilInt()})
str, inData := that.MakeCodeRouter[hotimeName].Info("admin", data, that.Db)
str, inData := that.MakeCode.Info("admin", data, that.Db)
where := Map{"id": that.Session("admin_id").ToCeilInt()}
if len(inData) ==1 {
inData["id"] =where["id"]
@@ -54,7 +52,7 @@ var Project = Proj{
return
}
for k, v := range re {
column := that.MakeCodeRouter[hotimeName].TableColumns["admin"][k]
column := that.MakeCode.TableColumns["admin"][k]
if column == nil {
continue
}
@@ -78,9 +76,8 @@ import (
var {{table}}Ctr = Ctr{
"info": func(that *Context) {
hotimeName := that.RouterString[0]
data := that.Db.Get("admin", "*", Map{"id": that.Session("admin_id").ToCeilInt()})
str, inData := that.MakeCodeRouter[hotimeName].Info(that.RouterString[1], data, that.Db)
str, inData := that.MakeCode.Info(that.RouterString[1], data, that.Db)
where := Map{"id": that.RouterString[2]}
if len(inData) ==1 {
@@ -99,7 +96,7 @@ var {{table}}Ctr = Ctr{
}
for k, v := range re {
column := that.MakeCodeRouter[hotimeName].TableColumns[that.RouterString[1]][k]
column := that.MakeCode.TableColumns[that.RouterString[1]][k]
if column == nil {
continue
}
@@ -111,8 +108,7 @@ var {{table}}Ctr = Ctr{
that.Display(0, re)
},
"add": func(that *Context) {
hotimeName := that.RouterString[0]
inData := that.MakeCodeRouter[hotimeName].Add(that.RouterString[1], that.Req)
inData := that.MakeCode.Add(that.RouterString[1], that.Req)
if inData == nil {
that.Display(3, "请求参数不足")
return
@@ -138,8 +134,7 @@ var {{table}}Ctr = Ctr{
that.Display(0, re)
},
"update": func(that *Context) {
hotimeName := that.RouterString[0]
inData := that.MakeCodeRouter[hotimeName].Edit(that.RouterString[1], that.Req)
inData := that.MakeCode.Edit(that.RouterString[1], that.Req)
if inData == nil {
that.Display(3, "没有找到要更新的数据")
return
@@ -170,8 +165,7 @@ var {{table}}Ctr = Ctr{
that.Display(0, re)
},
"remove": func(that *Context) {
hotimeName := that.RouterString[0]
inData := that.MakeCodeRouter[hotimeName].Delete(that.RouterString[1], that.Req)
inData := that.MakeCode.Delete(that.RouterString[1], that.Req)
if inData == nil {
that.Display(3, "请求参数不足")
return
@@ -192,10 +186,10 @@ var {{table}}Ctr = Ctr{
},
"search": func(that *Context) {
hotimeName := that.RouterString[0]
data := that.Db.Get("admin", "*", Map{"id": that.Session("admin_id").ToCeilInt()})
columnStr, leftJoin, where := that.MakeCodeRouter[hotimeName].Search(that.RouterString[1], data, that.Req, that.Db)
columnStr, leftJoin, where := that.MakeCode.Search(that.RouterString[1], data, that.Req, that.Db)
page := ObjToInt(that.Req.FormValue("page"))
pageSize := ObjToInt(that.Req.FormValue("pageSize"))
@@ -214,7 +208,7 @@ var {{table}}Ctr = Ctr{
for _, v := range reData {
for k, _ := range v {
column := that.MakeCodeRouter[hotimeName].TableColumns[that.RouterString[1]][k]
column := that.MakeCode.TableColumns[that.RouterString[1]][k]
if column == nil {
continue
}
+1 -1
View File
@@ -8,7 +8,7 @@ type ContextBase struct {
tag string
}
// 唯一标志
//唯一标志
func (that *ContextBase) GetTag() string {
if that.tag == "" {
+10 -42
View File
@@ -1,59 +1,27 @@
package common
import (
"code.hoteas.com/golang/hotime/log"
"sync"
"github.com/sirupsen/logrus"
)
// Error 框架基础错误类型 —— 线程安全的 error 封装。
// 通过组合(嵌入)方式在 Obj、DBError 等结构体中使用,提供统一的错误 API。
// 也用于 ObjToXxx / Map.GetXxx 等工具函数的可选错误报告参数。
// 设置 Logger 后,SetError 会自动将非 nil 错误写入日志。
// Error 框架层处理错误
type Error struct {
Logger *logrus.Logger
error
mu sync.RWMutex
Logger *log.Logger
}
// Error 安全实现 error 接口 —— 内部 error 为 nil 时返回 "" 而非 panic
func (that *Error) Error() string {
that.mu.RLock()
defer that.mu.RUnlock()
if that.error == nil {
return ""
}
return that.error.Error()
}
// HasError 快捷判断是否存在错误
func (that *Error) HasError() bool {
that.mu.RLock()
defer that.mu.RUnlock()
return that.error != nil
}
// Unwrap 支持 errors.Is / errors.As 标准错误链
func (that *Error) Unwrap() error {
that.mu.RLock()
defer that.mu.RUnlock()
return that.error
}
// GetError 获取内部 errornil 表示无错误)
func (that *Error) GetError() error {
that.mu.RLock()
defer that.mu.RUnlock()
return that.error
}
// SetError 设置内部 error(传 nil 清除错误)
// 当 Logger 已设置且 err 非 nil 时,自动写入 ERROR 级别日志
func (that *Error) SetError(err error) {
that.mu.Lock()
that.error = err
logger := that.Logger
that.mu.Unlock()
if err != nil && logger != nil {
logger.Error().Err(err).Msg("")
if that.Logger != nil && err != nil {
//that.Logger=log.GetLog("",false)
that.Logger.Warn(err)
}
return
}
+1 -38
View File
@@ -5,7 +5,6 @@ import (
"encoding/hex"
"math"
"strings"
"time"
)
//安全锁
@@ -37,42 +36,6 @@ func StrFirstToUpper(str string) string {
return strings.ToUpper(first) + other
}
// 时间转字符串,第二个参数支持1-5对应显示年月日时分秒
func Time2Str(t time.Time, qu ...interface{}) string {
if t.Unix() < 0 {
return ""
}
tp := 5
if len(qu) != 0 {
tp = (qu[0]).(int)
}
switch tp {
case 1:
return t.Format("2006-01")
case 2:
return t.Format("2006-01-02")
case 3:
return t.Format("2006-01-02 15")
case 4:
return t.Format("2006-01-02 15:04")
case 5:
return t.Format("2006-01-02 15:04:05")
case 12:
return t.Format("01-02")
case 14:
return t.Format("01-02 15:04")
case 15:
return t.Format("01-02 15:04:05")
case 34:
return t.Format("15:04")
case 35:
return t.Format("15:04:05")
}
return t.Format("2006-01-02 15:04:05")
}
// StrLd 相似度计算 ld compares two strings and returns the levenshtein distance between them.
func StrLd(s, t string, ignoreCase bool) int {
if ignoreCase {
@@ -141,7 +104,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)
+14 -53
View File
@@ -1,18 +1,15 @@
package common
import (
"bytes"
"encoding/json"
"errors"
"reflect"
"sort"
"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 {
@@ -27,7 +24,7 @@ func (that *Map) Pointer() *Map {
return that
}
// 增加接口
//增加接口
func (that Map) Put(key string, value interface{}) {
//if that==nil{
// that=Map{}
@@ -35,13 +32,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...)
@@ -49,35 +46,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...)
@@ -103,30 +100,6 @@ func (that Map) GetBool(key string, err ...*Error) bool {
}
func (that Map) GetTime(key string, err ...*Error) *time.Time {
v := ObjToTime((that)[key], err...)
return v
}
func (that Map) RangeSort(callback func(k string, v interface{}) (isEnd bool)) {
testQu := []string{}
//testQuData:= qu[0].(Map)
for key, _ := range that {
//fmt.Println(key, ":", value)
testQu = append(testQu, key)
}
sort.Strings(testQu)
for _, k := range testQu {
re := callback(k, that[k])
if re {
return
}
}
}
func (that Map) GetMap(key string, err ...*Error) Map {
//var data Slice
@@ -150,7 +123,7 @@ func (that Map) Get(key string, err ...*Error) interface{} {
return nil
}
// 请传递指针过来
//请传递指针过来
func (that Map) ToStruct(stct interface{}) {
data := reflect.ValueOf(stct).Elem()
@@ -182,21 +155,9 @@ func (that Map) ToJsonString() string {
}
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
e := json.Unmarshal([]byte(jsonStr), &that)
if e != nil && len(err) != 0 {
err[0].SetError(e)
}
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...)
}
+3 -17
View File
@@ -1,8 +1,6 @@
package common
import "time"
// 对象封装方便取用
//对象封装方便取用
type Obj struct {
Data interface{}
Error
@@ -20,13 +18,6 @@ func (that *Obj) ToInt(err ...Error) int {
return ObjToInt(that.Data, &that.Error)
}
func (that *Obj) ToTime(err ...Error) *time.Time {
if len(err) != 0 {
that.Error = err[0]
}
return ObjToTime(that.Data, &that.Error)
}
func (that *Obj) ToInt64(err ...Error) int64 {
if len(err) != 0 {
that.Error = err[0]
@@ -82,7 +73,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 +83,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,8 +92,3 @@ 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...)
}
+101 -310
View File
@@ -1,16 +1,13 @@
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
@@ -19,40 +16,31 @@ func ObjToMap(obj interface{}, e ...*Error) Map {
v = nil
err = errors.New("没有合适的转换对象!")
} else {
switch val := obj.(type) {
switch obj.(type) {
case Map:
v = val
v = obj.(Map)
case map[string]interface{}:
v = Map(val)
v = obj.(map[string]interface{})
case string:
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 = Map{}
e := json.Unmarshal([]byte(obj.(string)), &v)
if e != nil {
err = errors.New("没有合适的转换对象!" + e.Error())
v = nil
}
default:
var data []byte
data, err = json.Marshal(obj)
data, err := json.Marshal(obj)
if err != nil {
err = errors.New("没有合适的转换对象!" + err.Error())
v = nil
break
}
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 = Map{}
e := json.Unmarshal(data, &v)
if e != nil {
err = errors.New("没有合适的转换对象!" + e.Error())
v = nil
}
}
}
if len(e) != 0 {
@@ -63,14 +51,14 @@ func ObjToMap(obj interface{}, e ...*Error) Map {
func ObjToMapArray(obj interface{}, e ...*Error) []Map {
s := ObjToSlice(obj, e...)
res := make([]Map, 0, len(s))
res := []Map{}
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
@@ -79,39 +67,25 @@ func ObjToSlice(obj interface{}, e ...*Error) Slice {
v = nil
err = errors.New("没有合适的转换对象!")
} else {
switch val := obj.(type) {
switch obj.(type) {
case Slice:
v = val
v = obj.(Slice)
case []interface{}:
v = Slice(val)
v = obj.([]interface{})
case []string:
v = make(Slice, len(val))
for i, s := range val {
v[i] = s
v = Slice{}
for i := 0; i < len(obj.([]string)); i++ {
v = append(v, obj.([]string)[i])
}
case string:
result, e2 := JsonToObj(val)
if e2 != nil {
err = e2
} else if s, ok := result.([]interface{}); ok {
v = s
} else {
err = errors.New("没有合适的转换对象!")
}
v = Slice{}
err = json.Unmarshal([]byte(obj.(string)), &v)
default:
v = Slice{}
var data []byte
data, err = json.Marshal(obj)
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("没有合适的转换对象!")
}
err = json.Unmarshal(data, &v)
}
}
@@ -122,141 +96,42 @@ func ObjToSlice(obj interface{}, e ...*Error) Slice {
return v
}
func ObjToTime(obj interface{}, e ...*Error) *time.Time {
tInt := ObjToInt64(obj)
//字符串类型,只支持标准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)
if e == nil {
return &t
}
} else if len(tStr) > 15 {
t, e := time.Parse("2006-01-02 15:04", tStr)
if e == nil {
return &t
}
} else if len(tStr) > 12 {
t, e := time.Parse("2006-01-02 15", tStr)
if e == nil {
return &t
}
} else if len(tStr) > 9 {
t, e := time.Parse("2006-01-02", tStr)
if e == nil {
return &t
}
} else if len(tStr) > 6 {
t, e := time.Parse("2006-01", tStr)
if e == nil {
return &t
}
}
}
// 缓存字符串,避免重复调用
tIntStr := ObjToStr(tInt)
tIntLen := len(tIntStr)
//纳秒级别
if tIntLen > 16 {
t := time.UnixMicro(tInt / 1000)
return &t
//微秒级别
} else if tIntLen > 13 {
t := time.UnixMicro(tInt)
return &t
//毫秒级别
} else if tIntLen > 10 {
t := time.UnixMilli(tInt)
return &t
//秒级别
} else if tIntLen > 9 {
t := time.Unix(tInt, 0)
return &t
} else if tIntLen > 3 {
t, e := time.Parse("2006", tIntStr)
if e == nil {
return &t
}
}
return nil
}
func ObjToFloat64(obj interface{}, e ...*Error) float64 {
var err error
v := float64(0)
if obj == nil {
err = errors.New("没有合适的转换对象!")
} else {
switch val := obj.(type) {
switch obj.(type) {
case int:
v = float64(val)
case int8:
v = float64(val)
case int16:
v = float64(val)
case int32:
v = float64(val)
v = float64(obj.(int))
case 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
}
v = float64(obj.(int64))
case string:
value, e2 := strconv.ParseFloat(val, 64)
if e2 != nil {
err = e2
value, e := strconv.ParseFloat(obj.(string), 64)
if e != nil {
v = float64(0)
err = e
} else {
v = value
}
case float64:
v = val
v = obj.(float64)
case float32:
// 通过 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
v = float64(obj.(float32))
case uint8:
value, e := strconv.ParseFloat(obj.(string), 64)
if e != nil {
v = float64(0)
err = e
} else {
v = value
}
default:
v = float64(0)
err = errors.New("没有合适的转换对象!")
}
}
@@ -276,33 +151,25 @@ func ObjToFloat64(obj interface{}, e ...*Error) float64 {
return v
}
// ObjToRoundFloat64 将 obj 转为 float64 后四舍五入到 precision 位小数。
// precision < 0 时不做舍入,直接返回原始 float64。
func ObjToRoundFloat64(obj interface{}, precision int, e ...*Error) float64 {
f := ObjToFloat64(obj, e...)
if precision < 0 {
return f
}
pow := math.Pow10(precision)
return math.Round(f*pow) / pow
}
// 向上取整
//向上取整
func ObjToCeilInt64(obj interface{}, e ...*Error) int64 {
f := ObjToCeilFloat64(obj, e...)
return int64(f)
return ObjToInt64(math.Ceil(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 {
@@ -310,63 +177,36 @@ func ObjToInt64(obj interface{}, e ...*Error) int64 {
v := int64(0)
if obj == nil {
err = errors.New("没有合适的转换对象!")
} else {
switch val := obj.(type) {
switch obj.(type) {
case int:
v = int64(val)
case int8:
v = int64(val)
case int16:
v = int64(val)
case int32:
v = int64(val)
v = int64(obj.(int))
case 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
}
v = obj.(int64)
case string:
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
}
value, e := StrToInt(obj.(string))
if e != nil {
v = int64(0)
err = e
} else {
v = int64(value)
}
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
}
case uint8:
value, e := StrToInt(obj.(string))
if e != nil {
v = int64(0)
err = e
} else {
v = int64(value)
}
case float64:
v = int64(val)
v = int64(obj.(float64))
case float32:
v = int64(val)
v = int64(obj.(float32))
default:
v = int64(0)
err = errors.New("没有合适的转换对象!")
}
}
@@ -386,19 +226,18 @@ func ObjToBool(obj interface{}, e ...*Error) bool {
v := false
if obj == nil {
err = errors.New("没有合适的转换对象!")
} else {
switch val := obj.(type) {
switch obj.(type) {
case bool:
v = val
v = obj.(bool)
default:
var intErr Error
toInt := ObjToInt(obj, &intErr)
if intErr.GetError() != nil {
err = errors.New("没有合适的转换对象!")
} else if toInt != 0 {
toInt := ObjToInt(obj)
if toInt != 0 {
v = true
}
err = errors.New("没有合适的转换对象!")
}
}
if len(e) != 0 {
@@ -408,107 +247,55 @@ 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 val := obj.(type) {
switch obj.(type) {
case 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))
str = strconv.Itoa(obj.(int))
case uint8:
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)
str = obj.(string)
case int64:
str = strconv.FormatInt(val, 10)
str = strconv.FormatInt(obj.(int64), 10)
case []byte:
str = string(val)
str = string(obj.([]byte))
case string:
str = val
str = obj.(string)
case float64:
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")
str = strconv.FormatFloat(obj.(float64), 'f', -1, 64)
default:
strbte, err := json.MarshalIndent(obj, "", "\t")
if err == nil {
str = string(strbte)
}
}
return str
}
// 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 {
//转换为Map
func StrToMap(string string) Map {
data := Map{}
data.JsonToMap(s)
data.JsonToMap(string)
return data
}
// 转换为Slice
func StrToSlice(s string) Slice {
return ObjToSlice(s)
//转换为Slice
func StrToSlice(string string) Slice {
data := ObjToSlice(string)
return data
}
// 字符串数组: 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)
@@ -516,6 +303,7 @@ 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 = "[]"
@@ -523,16 +311,19 @@ 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
@@ -1,917 +0,0 @@
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")
}
})
}
-13
View File
@@ -2,7 +2,6 @@ package common
import (
"errors"
"time"
)
type Slice []interface{}
@@ -15,13 +14,6 @@ func (that Slice) GetString(key int, err ...*Error) string {
return ObjToStr((that)[key])
}
func (that Slice) GetTime(key int, err ...*Error) *time.Time {
v := ObjToTime((that)[key], err...)
return v
}
// GetInt 获取Int
func (that Slice) GetInt(key int, err ...*Error) int {
v := ObjToInt((that)[key], err...)
@@ -63,11 +55,6 @@ 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
+7 -159
View File
@@ -1,38 +1,25 @@
package hotime
import (
"bytes"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"sync"
"time"
. "code.hoteas.com/golang/hotime/cache"
. "code.hoteas.com/golang/hotime/common"
. "code.hoteas.com/golang/hotime/db"
htlog "code.hoteas.com/golang/hotime/log"
"encoding/json"
"net/http"
)
type Context struct {
*Application
Resp http.ResponseWriter
Req *http.Request
Log Map //业务 logs 表字段(有则写入),与 Logger 不同
Logger *htlog.Logger // 请求级日志(带 sid/request_id),业务与 SQL 共用
RouterString []string
Config Map
Db *HoTimeDB
RespData Map
RespFunc func()
//CacheIns
CacheIns
SessionIns
DataSize int
HandlerStr string //复写请求url
// 请求参数缓存
reqJsonCache Map // JSON Body 缓存
reqMu sync.Once // 确保 JSON 只解析一次
}
// Mtd 唯一标志
@@ -43,7 +30,7 @@ func (that *Context) Mtd(router [3]string) Map {
return d
}
// 打印
//打印
func (that *Context) Display(statu int, data interface{}) {
resp := Map{"status": statu}
@@ -60,9 +47,6 @@ 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
}
@@ -72,153 +56,17 @@ 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()
}
if that.RespData == nil {
return
}
//创建日志
if that.Log != nil {
that.Log["time"] = time.Now().Format("2006-01-02 15:04")
if that.Session("admin_id").Data != nil {
that.Log["admin_id"] = that.Session("admin_id").ToCeilInt()
}
if that.Session("user_id").Data != nil {
that.Log["user_id"] = that.Session("user_id").ToCeilInt()
}
that.Log["ip"] = clientIP(that.Req)
that.Db.Insert("logs", that.Log)
}
d, err := json.Marshal(that.RespData)
if err != nil {
that.Display(1, err.Error())
that.View()
return
}
that.DataSize = len(d)
that.RespData = nil
that.Resp.Write(d)
}
// ==================== 请求参数获取方法 ====================
// 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
return
}
-115
View File
@@ -1,115 +0,0 @@
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
@@ -1,151 +0,0 @@
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
@@ -1,312 +0,0 @@
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
@@ -1,740 +0,0 @@
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
@@ -1,248 +0,0 @@
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
@@ -1,382 +0,0 @@
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
@@ -1,441 +0,0 @@
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"
}
+1032
View File
File diff suppressed because it is too large Load Diff
-260
View File
@@ -1,260 +0,0 @@
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
@@ -1,615 +0,0 @@
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
@@ -1,245 +0,0 @@
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
@@ -1,108 +0,0 @@
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
@@ -1,246 +0,0 @@
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
@@ -1,555 +0,0 @@
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
}
-883
View File
@@ -1,883 +0,0 @@
# 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'`)。
### 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
@@ -1,485 +0,0 @@
# 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)
@@ -1,497 +0,0 @@
# 数据库设计规范
本文档是 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)
-143
View File
@@ -1,143 +0,0 @@
# 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;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
```
- `max_fails=1 fail_timeout=3s`:连接级失败(如强杀后端口拒绝)时标记下线 3s
- `proxy_next_upstream http_503`:应用级 503 时 nginx 自动重试下一台
---
## 滚动重启步骤
### Windows
```cmd
:: 1. 查找端口 9998 的进程 PID
netstat -ano | findstr :9998
:: 2. 优雅关闭该实例(不加 /F,发送 Ctrl+C 信号)
taskkill /PID <pid>
:: 3. 观察日志,出现"服务已安全关闭"后,部署新版并启动
:: 若使用 run_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
@@ -1,214 +0,0 @@
# 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
@@ -1,576 +0,0 @@
# 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
@@ -1,36 +0,0 @@
# 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
@@ -1,283 +0,0 @@
# 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 适配 |
-150
View File
@@ -1,150 +0,0 @@
# 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`
- **控制台自动降噪为 Warn+**Info/Debug/SQL/访问日志仍进 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)
| 出口 | 行为 |
|---|---|
| 控制台 | 仅 Warn / Error(含 `Display` 非 0 的 Warn |
| Seq | 按 `logLevel` 全量(Info/Debug/SQL/访问日志等) |
| 本地文件 | 与原先一致,不受控制台过滤影响 |
未配置 `seqUrl` 时控制台仍按 `logLevel` 全打。
---
## 客户端 IP 与地域
零配置,优先级:
1. `X-Real-IP`EdgeOne / 反代透传的真实 IP
2. `X-Forwarded-For` **第一段**
3. `RemoteAddr`
请求头有 `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 后 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_country` | `ip_country` | 有 `EO-Client-IPCountry` 时 |
| 其余自定义字段 | 原字段名 | `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'` |
---
## 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
-8
View File
@@ -58,14 +58,6 @@ func (that *company) GetCompanyOtherAll(name string) Map {
return res
}
// GetCompanyBaseInfo 获取企业基础信息
func (that *company) GetCompanyList(name string) (Map, error) {
url := "/fuzzyQueryCompanyInfo/"
body, err := that.basePost(url, name)
return ObjToMap(body), err
}
// GetCompanyBaseInfo 获取企业基础信息
func (that *company) GetCompanyBaseInfo(name string) (Map, error) {
url := "/getCompanyBaseInfo/"
-88
View File
@@ -1,7 +1,6 @@
package baidu
import (
. "code.hoteas.com/golang/hotime/common"
"fmt"
"io/ioutil"
"net/http"
@@ -22,93 +21,6 @@ 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)
+3 -3
View File
@@ -18,13 +18,13 @@ func Down(url, path, name string, e ...*Error) bool {
}
out, err := os.Create(path + name)
if err != nil && len(e) != 0 {
if err != nil && e[0] != nil {
e[0].SetError(err)
return false
}
defer out.Close()
resp, err := http.Get(url)
if err != nil && len(e) != 0 {
if err != nil && e[0] != nil {
e[0].SetError(err)
return false
}
@@ -32,7 +32,7 @@ func Down(url, path, name string, e ...*Error) bool {
pix, err := ioutil.ReadAll(resp.Body)
_, err = io.Copy(out, bytes.NewReader(pix))
if err != nil && len(e) != 0 {
if err != nil && e[0] != nil {
e[0].SetError(err)
return false
}
-149
View File
@@ -1,149 +0,0 @@
package mongodb
import (
. "code.hoteas.com/golang/hotime/common"
"context"
"fmt"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
type MongoDb struct {
Client *mongo.Client
Ctx context.Context
DataBase *mongo.Database
Connect *mongo.Collection
LastErr error
}
func GetMongoDb(database, url string) (*MongoDb, error) {
db := MongoDb{}
clientOptions := options.Client().ApplyURI(url)
db.Ctx = context.TODO()
// Connect to MongoDb
var err error
db.Client, err = mongo.Connect(db.Ctx, clientOptions)
if err != nil {
return nil, err
}
// Check the connection
err = db.Client.Ping(db.Ctx, nil)
if err != nil {
return nil, err
}
fmt.Println("Connected to MongoDb!")
//databases, err := db.Client.ListDatabaseNames(db.Ctx, bson.M{})
//if err != nil {
// return nil, err
//}
//fmt.Println(databases)
db.DataBase = db.Client.Database(database)
return &db, nil
}
func (that *MongoDb) Insert(table string, data interface{}) string {
collection := that.DataBase.Collection(table)
re, err := collection.InsertOne(that.Ctx, data)
if err != nil {
that.LastErr = err
return ""
}
return ObjToStr(re.InsertedID)
}
func (that *MongoDb) InsertMany(table string, data ...interface{}) Slice {
collection := that.DataBase.Collection(table)
re, err := collection.InsertMany(that.Ctx, data)
if err != nil {
that.LastErr = err
return Slice{}
}
return ObjToSlice(re.InsertedIDs)
}
func (that *MongoDb) Update(table string, data Map, where Map) int64 {
collection := that.DataBase.Collection(table)
re, err := collection.UpdateMany(that.Ctx, where, data)
if err != nil {
that.LastErr = err
return 0
}
return re.ModifiedCount
}
func (that *MongoDb) Delete(table string, where Map) int64 {
collection := that.DataBase.Collection(table)
re, err := collection.DeleteMany(that.Ctx, where)
if err != nil {
that.LastErr = err
return 0
}
return re.DeletedCount
}
func (that *MongoDb) Get(table string, where Map) Map {
results := []Map{}
var cursor *mongo.Cursor
var err error
collection := that.DataBase.Collection(table)
if cursor, err = collection.Find(that.Ctx, where, options.Find().SetSkip(0), options.Find().SetLimit(2)); err != nil {
that.LastErr = err
return nil
}
//延迟关闭游标
defer func() {
if err := cursor.Close(that.Ctx); err != nil {
that.LastErr = err
}
}()
//这里的结果遍历可以使用另外一种更方便的方式:
if err = cursor.All(that.Ctx, &results); err != nil {
that.LastErr = err
return nil
}
if len(results) > 0 {
return results[0]
}
return nil
}
func (that MongoDb) Select(table string, where Map, page, pageRow int64) []Map {
page = (page - 1) * pageRow
if page < 0 {
page = 0
}
results := []Map{}
var cursor *mongo.Cursor
var err error
collection := that.DataBase.Collection(table)
if cursor, err = collection.Find(that.Ctx, where, options.Find().SetSkip(page), options.Find().SetLimit(pageRow)); err != nil {
that.LastErr = err
return results
}
//延迟关闭游标
defer func() {
if err := cursor.Close(that.Ctx); err != nil {
that.LastErr = err
}
}()
//这里的结果遍历可以使用另外一种更方便的方式:
if err = cursor.All(that.Ctx, &results); err != nil {
that.LastErr = err
return results
}
return results
}
+2 -2
View File
@@ -24,8 +24,8 @@ func (that *tencent) Init(secretId, secretKey string) {
that.secretId = secretId
that.secretKey = secretKey
that.credential = common.NewCredential(
that.secretId,
that.secretKey,
"AKIDOgT8cKCQksnY7yKATaYO7j9ORJzSYohP",
"GNXgjdN4czA9ya0FNMApVJzTmsmU0KSN",
)
}
-79
View File
@@ -1,79 +0,0 @@
package wechat
import (
"github.com/silenceper/wechat/v2"
"github.com/silenceper/wechat/v2/cache"
"github.com/silenceper/wechat/v2/officialaccount"
h5config "github.com/silenceper/wechat/v2/officialaccount/config"
"github.com/silenceper/wechat/v2/officialaccount/js"
"github.com/silenceper/wechat/v2/officialaccount/oauth"
)
// 基于此文档开发
// https://github.com/silenceper/wechat/blob/v2/doc/api/officialaccount.md
type h5Program struct {
Memory *cache.Memory
Config *h5config.Config
*officialaccount.OfficialAccount
weixin *wechat.Wechat //微信登录实例
}
var H5Program = h5Program{}
// Init 初始化
func (that *h5Program) Init(appid string, appsecret string) {
that.weixin = wechat.NewWechat()
that.Memory = cache.NewMemory()
that.Config = &h5config.Config{
AppID: appid,
AppSecret: appsecret,
//Token: "xxx",
//EncodingAESKey: "xxxx",
Cache: that.Memory,
}
that.OfficialAccount = that.weixin.GetOfficialAccount(that.Config)
}
// GetUserInfo 获取用户信息
func (that *h5Program) GetUserInfo(code string) (appid string, resToken oauth.ResAccessToken, userInfo oauth.UserInfo, err error) {
auth := that.GetOauth()
//weixin.GetOpenPlatform()
resToken, err = auth.GetUserAccessToken(code)
if err != nil {
return auth.AppID, resToken, userInfo, err
}
//getUserInfo
userInfo, err = auth.GetUserInfo(resToken.AccessToken, resToken.OpenID, "")
if err != nil {
return auth.AppID, resToken, userInfo, err
}
return auth.AppID, resToken, userInfo, err
}
// GetSignUrl js url签名
func (that *h5Program) GetSignUrl(signUrl string) (*js.Config, error) {
js := that.OfficialAccount.GetJs()
cfg1, e := js.GetConfig(signUrl)
if e != nil {
return nil, e
}
return cfg1, nil
}
// GetSignUrl js url签名
//func (that *h5Program) GetJsPay(signUrl string) (*js.Config, error) {
// //
// //js := that.OfficialAccount().GetJs()
// //
// //cfg1, e := js.GetConfig(signUrl)
// //if e != nil {
// // return nil, e
// //}
//
// return cfg1, nil
//}
-66
View File
@@ -1,66 +0,0 @@
package wechat
import (
"errors"
"github.com/silenceper/wechat/v2"
"github.com/silenceper/wechat/v2/cache"
"github.com/silenceper/wechat/v2/miniprogram"
"github.com/silenceper/wechat/v2/miniprogram/auth"
"github.com/silenceper/wechat/v2/miniprogram/config"
"github.com/silenceper/wechat/v2/miniprogram/encryptor"
)
type miniProgram struct {
Memory *cache.Memory
Config *config.Config
weixin *wechat.Wechat //微信登录实例
*miniprogram.MiniProgram
}
var MiniProgram = miniProgram{}
// Init 初始化
func (that *miniProgram) Init(appid string, appsecret string) {
that.weixin = wechat.NewWechat()
that.Memory = cache.NewMemory()
that.Config = &config.Config{
AppID: appid,
AppSecret: appsecret,
//Token: "xxx",
//EncodingAESKey: "xxxx",
Cache: that.Memory,
}
that.MiniProgram = that.weixin.GetMiniProgram(that.Config)
}
func (that *miniProgram) GetBaseUserInfo(code string) (appid string, re auth.ResCode2Session, err error) {
appid = that.Config.AppID
a := that.GetAuth()
re, err = a.Code2Session(code)
if err != nil {
return appid, re, err
}
return appid, re, err
}
func (that *miniProgram) GetPhoneNumber(sessionkey, encryptedData, iv string) (appid string, re *encryptor.PlainData, err error) {
appid = that.Config.AppID
if sessionkey == "" || encryptedData == "" || iv == "" {
return appid, re, errors.New("参数不足")
}
eny := that.GetEncryptor()
re, err = eny.Decrypt(sessionkey, encryptedData, iv)
if err != nil {
return appid, re, err
}
return appid, re, err
}
-142
View File
@@ -1,142 +0,0 @@
package wechat
import (
"context"
"fmt"
"github.com/go-pay/gopay"
"github.com/go-pay/gopay/wechat/v3"
"net/http"
"time"
)
// 基于此文档开发
// https://github.com/silenceper/wechat/blob/v2/doc/api/officialaccount.md
type wxpay struct {
client *wechat.ClientV3
ctx context.Context
apiV3Key string
MchId string
}
var WxPay = wxpay{}
// Init 初始化
func (that *wxpay) Init(MchId, SerialNo, APIv3Key, PrivateKey string) {
client, err := wechat.NewClientV3(MchId, SerialNo, APIv3Key, PrivateKey)
if err != nil {
//xlog.Error(err)
fmt.Println(err)
return
}
that.client = client
that.apiV3Key = APIv3Key
that.MchId = MchId
// 设置微信平台API证书和序列号(如开启自动验签,请忽略此步骤)
//client.SetPlatformCert([]byte(""), "")
that.ctx = context.Background()
// 启用自动同步返回验签,并定时更新微信平台API证书(开启自动验签时,无需单独设置微信平台API证书和序列号)
err = client.AutoVerifySign()
if err != nil {
//xlog.Error(err)
fmt.Println(err)
return
}
// 打开Debug开关,输出日志,默认是关闭的
client.DebugSwitch = gopay.DebugOn
}
// GetUserInfo 获取用户信息
func (that *wxpay) GetJsOrder(money int64, appid, openid, name, tradeNo, notifyUrl string) (jsApiParams *wechat.JSAPIPayParams, err error) {
fmt.Println("dasdas", money, appid, name, tradeNo, notifyUrl)
PrepayId, err := that.getPrepayId(money, appid, that.MchId, openid, name, tradeNo, notifyUrl)
if err != nil {
return nil, err
}
//小程序
jsapi, err := that.client.PaySignOfJSAPI(appid, PrepayId)
return jsapi, err
}
func (that *wxpay) CallbackJsOrder(req *http.Request) (*wechat.V3DecryptResult, error) {
notifyReq, err := wechat.V3ParseNotify(req)
if err != nil {
//xlog.Error(err)
return nil, err
}
// wxPublicKey 通过 client.WxPublicKey() 获取
err = notifyReq.VerifySignByPK(that.client.WxPublicKey())
if err != nil {
//xlog.Error(err)
return nil, err
}
// ========异步通知敏感信息解密========
// 普通支付通知解密
result, err := notifyReq.DecryptCipherText(that.apiV3Key)
//that.client.V3TransactionQueryOrder(that.ctx,result.BankType,result.OR)
return result, err
// 合单支付通知解密
//result, err := notifyReq.DecryptCombineCipherText(apiV3Key)
//// 退款通知解密
//result, err := notifyReq.DecryptRefundCipherText(apiV3Key)
// ========异步通知应答========
// 退款通知http应答码为200且返回状态码为SUCCESS才会当做商户接收成功,否则会重试。
// 注意:重试过多会导致微信支付端积压过多通知而堵塞,影响其他正常通知。
// 此写法是 gin 框架返回微信的写法
//c.JSON(http.StatusOK, &wechat.V3NotifyRsp{Code: gopay.SUCCESS, Message: "成功"})
//
//// 此写法是 echo 框架返回微信的写法
//return c.JSON(http.StatusOK, &wechat.V3NotifyRsp{Code: gopay.SUCCESS, Message: "成功"})
}
// GetUserInfo 获取用户信息
//func (that *wxpay) GetMiniOrder(money int64,appid,name,tradeNo,notifyUrl string) (jsApiParams *wechat.AppletParams,err error){
//
// PrepayId,err:=that.getPrepayId(money,name,tradeNo,notifyUrl)
// if err!=nil{
// return nil,err
// }
//
// //小程序
// applet, err := that.client.PaySignOfApplet(appid,PrepayId)
//
// return applet,err
//}
func (that *wxpay) getPrepayId(money int64, appid, mchid, openid, name, tradeNo, notifyUrl string) (prepayid string, err error) {
expire := time.Now().Add(10 * time.Minute).Format(time.RFC3339)
// 初始化 BodyMap
bm := make(gopay.BodyMap)
bm.Set("appid", appid).
Set("mchid", mchid).
//Set("sub_mchid", "sub_mchid").
Set("description", name).
Set("out_trade_no", tradeNo).
Set("time_expire", expire).
Set("notify_url", notifyUrl).
SetBodyMap("amount", func(bm gopay.BodyMap) {
bm.Set("total", money).
Set("currency", "CNY")
}).
SetBodyMap("payer", func(bm gopay.BodyMap) {
bm.Set("openid", openid)
})
//ctx:=context.Context()
wxRsp, err := that.client.V3TransactionJsapi(that.ctx, bm)
fmt.Println("获取PrepayId", wxRsp, err)
if err != nil {
//xlog.Error(err)
return "", err
}
return wxRsp.Response.PrepayId, nil
}
-150
View File
@@ -1,150 +0,0 @@
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
@@ -1,294 +0,0 @@
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
@@ -1,89 +0,0 @@
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
@@ -1,88 +0,0 @@
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
@@ -1,90 +0,0 @@
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
@@ -1,240 +0,0 @@
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
@@ -1,44 +0,0 @@
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
@@ -1,13 +0,0 @@
package app
import (
. "code.hoteas.com/golang/hotime"
)
var Project = Proj{
"test": TestCtr,
"mysql": MysqlCtr,
"dmdb": DmdbCtr,
"cache": CacheCtr,
"expect_demo": ExpectDemoCtr,
}
-109
View File
@@ -1,109 +0,0 @@
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)
}},
},
}
-96
View File
@@ -1,96 +0,0 @@
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
@@ -1,90 +0,0 @@
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
@@ -1,126 +0,0 @@
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
@@ -1,151 +0,0 @@
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
@@ -1,79 +0,0 @@
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"))
}

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