Compare commits
312 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a4fd288bb | |||
| c2ce1955f9 | |||
| 16496a8dc0 | |||
| 9fe4ab2758 | |||
| c2a59e19c9 | |||
| 6b689f3a1b | |||
| e14c9742c1 | |||
| 8b095f87ea | |||
| 1e77dde6f0 | |||
| 7fdf5181da | |||
| 94a251ec44 | |||
| ef10437a5a | |||
| 66dee150c7 | |||
| f22eea72ec | |||
| 0340bc4b8e | |||
| d03ed65d41 | |||
| 10428b6da4 | |||
| 66f18a084a | |||
| 3430bdec19 | |||
| 9a9b9c83ff | |||
| 86daa3153e | |||
| 277e244e80 | |||
| 5a1b5e4fee | |||
| f787421f82 | |||
| cf8ff2b838 | |||
| f7c3a6c1c8 | |||
| 63360f9a14 | |||
| 17e1090077 | |||
| b3f263c965 | |||
| 37a67f5810 | |||
| 3681564ca4 | |||
| 37c6a00c0a | |||
| 70012032f0 | |||
| 813e26755f | |||
| a7395b08de | |||
| 5e2bd765bf | |||
| 6fda351585 | |||
| 7f7b585ffb | |||
| 1546967918 | |||
| 47e13b532b | |||
| 71e415dfe1 | |||
| 87098a9180 | |||
| bd20af0c89 | |||
| fab7931d3c | |||
| bf9bb5807d | |||
| 2d9e6e8e12 | |||
| 40eedccbd2 | |||
| 4940232b24 | |||
| a11331e08d | |||
| 8836e7a88d | |||
| 431318bf8a | |||
| cce765e735 | |||
| 64c2e800b0 | |||
| e704701752 | |||
| 5235ef133d | |||
| c3b8d15b54 | |||
| c4d0e16f2c | |||
| ebe590ca9b | |||
| 60a12a09bd | |||
| 595275e747 | |||
| fdf02d4927 | |||
| beebdfbbfa | |||
| 05d1bb6b48 | |||
| a770c52794 | |||
| 40f6783036 | |||
| 7843f7e8d6 | |||
| 2ecb12bd7d | |||
| e5e615717a | |||
| 6db8f75ec7 | |||
| 754668d017 | |||
| 544207a2e0 | |||
| 68ea5d5203 | |||
| 734d1b4be9 | |||
| c7a34990e3 | |||
| 6db22aa4fd | |||
| f7493a1217 | |||
| 61b3c5c7cc | |||
| f196e8a43a | |||
| 05eb8d39db | |||
| 2983934477 | |||
| 3d9666b630 | |||
| b8b543175d | |||
| 3a1828be07 | |||
| f043597300 | |||
| 6542a7316a | |||
| 57c0658da0 | |||
| 1f26cd2a50 | |||
| 1a7e92d524 | |||
| ea932a032b | |||
| 56f4911459 | |||
| 735c088a0a | |||
| 80db372857 | |||
| 8d1ad4499d | |||
| 458cefd0ac | |||
| 0d40fb5114 | |||
| 9ac5abe884 | |||
| 22a1f19e6a | |||
| 91a3fe2413 | |||
| b60b863af2 | |||
| 07637a635c | |||
| 36cf708983 | |||
| 8ab5c9f3e3 | |||
| e4686fd77d | |||
| 06714a1968 | |||
| 6fa8e66aaf | |||
| c7494015dc | |||
| 32d8c43e1f | |||
| ae6f2195b0 | |||
| 72d68d1738 | |||
| c959402250 | |||
| 340a90278b | |||
| 312e92004d | |||
| ce9d8d055c | |||
| 41f455cf4c | |||
| febd8d9805 | |||
| 60667734a7 | |||
| cf29db5d5c | |||
| 62a367ad5b | |||
| f5a24aa173 | |||
| 67d1450848 | |||
| 559e68a0b2 | |||
| a596b02d43 | |||
| 8ae4ccc126 | |||
| 0b1116ba9e | |||
| e8b391b0a2 | |||
| 4097a44e58 | |||
| 64daf04f35 | |||
| 97cca14b45 | |||
| 78539b9dbc | |||
| df5f8f158b | |||
| ca947bb9e0 | |||
| 899c1c340d | |||
| fab3d86519 | |||
| 48ee95ca67 | |||
| 9d31f2d1f8 | |||
| ffa32105f2 | |||
| 3da135e3cb | |||
| 4e24a913c6 | |||
| ed0944618f | |||
| e393d258a8 | |||
| c91c610d2d | |||
| 7d74ebf4e4 | |||
| abc7eaf529 | |||
| a1250e15c5 | |||
| f6d3aa800c | |||
| 854df4fa7d | |||
| 6c92a21c3b | |||
| 4b641cd09d | |||
| 497c4b028f | |||
| e37b7b586b | |||
| 15b73b3ce7 | |||
| 83acef93a9 | |||
| f5af9424f0 | |||
| 5d4d1e693b | |||
| 88372e992e | |||
| 3b558b22ff | |||
| 34e032105d | |||
| 5c2e642bd1 | |||
| 65ece0418e | |||
| c3a24eeb3c | |||
| 61d5f410da | |||
| f6f48a3f8d | |||
| 771b02bd61 | |||
| 72955466d5 | |||
| 8b7bf8a150 | |||
| dbabee7a8a | |||
| 71fb533950 | |||
| cbcfd735ca | |||
| e4f354d26f | |||
| 20d8926d05 | |||
| 0f2b47d6d9 | |||
| 2034e8e641 | |||
| f53a3108a9 | |||
| 6a35466221 | |||
| 243dd12747 | |||
| 5b453d7c5d | |||
| 06e6ceab6a | |||
| 4365fa6241 | |||
| 8fa8858b47 | |||
| 64df6c9f03 | |||
| 95ac340186 | |||
| 93353f618a | |||
| f2643222ca | |||
| abecca89c5 | |||
| 3a6b0f8647 | |||
| fa2545c2c8 | |||
| eaa6a90c0e | |||
| a534e74f7d | |||
| dec56b75d2 | |||
| 23b398e705 | |||
| e0752218ea | |||
| 25ab6c219d | |||
| 4cbc84063a | |||
| aea2e0241d | |||
| eb41162f3b | |||
| b37546bee2 | |||
| f0d4efd8fc | |||
| 0fd7500e34 | |||
| ce8059b0a0 | |||
| 7d36ad95a7 | |||
| 58da542533 | |||
| 0b09a13c17 | |||
| 062e849d44 | |||
| bdcec2d534 | |||
| 7b8abc9daa | |||
| 7d4358af83 | |||
| cac15ade74 | |||
| 25b12142de | |||
| 9c8190b54e | |||
| f1244091fc | |||
| 10b73bef8f | |||
| 1113c1eb43 | |||
| 31955fce52 | |||
| cfa668a5f3 | |||
| 3fdc3aac2e | |||
| c368619e51 | |||
| f1d066811d | |||
| 49afe5f714 | |||
| cd23ed85c2 | |||
| 045fd3a38c | |||
| 7da5f26098 | |||
| 02fc1d7843 | |||
| cb0bcdb2d1 | |||
| e426c285b0 | |||
| 716b3dfa6b | |||
| 369c06724a | |||
| 17ac1a9dcf | |||
| 8befd195c0 | |||
| ac027c6e42 | |||
| 7be976cdeb | |||
| b18bc1ced8 | |||
| 2b8816c54b | |||
| cecefa1227 | |||
| 730a2e41f5 | |||
| e7b9d7d100 | |||
| e200e0a370 | |||
| b5f6a33f07 | |||
| 6512f122f5 | |||
| cac0214271 | |||
| e4ef9230c2 | |||
| d90591af75 | |||
| 91bf142782 | |||
| fbe97b4461 | |||
| eb25417104 | |||
| 42f91d57ff | |||
| 2a98f0dcad | |||
| 32319d52f0 | |||
| 3e94cf8def | |||
| 6a46d06e81 | |||
| d4759bb03c | |||
| 9187960f66 | |||
| 7c2cc695b7 | |||
| 3b11f44915 | |||
| cea7e57409 | |||
| 29a84db736 | |||
| 74ec6e619a | |||
| b42e73ec50 | |||
| f31262559d | |||
| a8d7b934f3 | |||
| 865e14e121 | |||
| a3f97c04b3 | |||
| 5c5234016c | |||
| c222570eb2 | |||
| 816b57d4e3 | |||
| 8fce77b643 | |||
| fa50191741 | |||
| 3b3ca6facb | |||
| d3b355836d | |||
| a47473f245 | |||
| 9ce4f1c603 | |||
| 717ddeaea3 | |||
| d656f3689e | |||
| 6e32f05087 | |||
| 1c31be2807 | |||
| 5b00a69496 | |||
| 55e60ac0eb | |||
| 3ce77221f8 | |||
| 6fe5d0ba55 | |||
| d1d4d04415 | |||
| 62cbf28b87 | |||
| ac66331535 | |||
| 9482404265 | |||
| 7b85da4c7f | |||
| db013b9966 | |||
| 44e46982f5 | |||
| 02911fd39e | |||
| 3a706077dd | |||
| 219f93b5ff | |||
| fb6f55c98d | |||
| f38e6f4397 | |||
| f62d169465 | |||
| 96b1795f05 | |||
| 2734e95096 | |||
| 101609e81b | |||
| 2cea20e7c9 | |||
| 340043d1f0 | |||
| 41defc022a | |||
| 830d69825d | |||
| 1f2a247398 | |||
| fdab298f52 | |||
| 6c999990e3 | |||
| 4433c3efc5 | |||
| 024b42bfa1 | |||
| 3efd5bd0a0 | |||
| e5dfe9aa8d | |||
| 62ba6e4f86 | |||
| faddd72fee | |||
| c8c5f453d5 | |||
| 49d956cc36 | |||
| cdefc0e9e3 | |||
| 9513b80a24 | |||
| acb7cdd4b8 |
@@ -0,0 +1,299 @@
|
||||
---
|
||||
name: API接口测试方案
|
||||
overview: hotime 框架新增链式测试 API:数据在前方法在后,Post/Get 一步完成用例+断言,TestProj 合并注册,自动生成 Swagger UI。
|
||||
todos:
|
||||
- id: framework-testing-helper
|
||||
content: hotimev1.5/testing_helper.go:TestApp、NewTestApp、SetupForTest、TestRequest/WithSession、TestResponse
|
||||
status: pending
|
||||
- id: framework-testing-api
|
||||
content: hotimev1.5/testing_api.go:TestProj/ProjTest/CtrTest、Api(JSON/Query/Form/File/WithSession→Post/Get)、RunTests
|
||||
status: pending
|
||||
- id: framework-testing-swagger
|
||||
content: hotimev1.5/testing_swagger.go:GenerateSwagger 合并所有项目生成一份 Swagger
|
||||
status: pending
|
||||
- id: xbc-user-test
|
||||
content: xbc/app/user.go 末尾添加 UserTest,init.go 添加 ProjectTest,新增 app_test.go
|
||||
status: pending
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# API 接口测试 + Swagger 文档自动生成(最终版)
|
||||
|
||||
## 一、最终链式设计:数据在前,方法在后
|
||||
|
||||
每条链以 `Post/Get/Put/Delete("描述", 期望status, [期望msg])` 结尾,这个终端调用同时完成:命名用例 + 发送请求 + 校验结果 + 收集文档。
|
||||
|
||||
```go
|
||||
// 数据 → 方法(终端操作)
|
||||
a.JSON(Map{"name": "138", "password": "123"}).Post("正常登录", 0)
|
||||
a.JSON(Map{"name": "138", "password": ""}).Post("密码为空", 4, "用户名或密码不能为空")
|
||||
a.Get("未登录", 2, "请先登录")
|
||||
a.Query(Map{"shop_id": "1"}).Get("查询列表", 0)
|
||||
a.WithSession(Map{"user_id": int64(1)}).Get("已登录", 0)
|
||||
a.Query(Map{"token": "abc"}).JSON(Map{"phone": "138"}).Post("混合传参", 3, "异常")
|
||||
a.File("file", "a.png", imgBytes).Post("上传文件", 0)
|
||||
```
|
||||
|
||||
### 对比之前的写法
|
||||
|
||||
```
|
||||
之前(三步):a.PostCase("正常登录").JSON(Map{...}).Test(0)
|
||||
之前(两步):a.Post("正常登录", Map{...}).Test(0)
|
||||
现在(一步):a.JSON(Map{...}).Post("正常登录", 0) ← 终端操作只有一个
|
||||
现在(零数据):a.Get("未登录", 2, "请先登录") ← 最简一行
|
||||
```
|
||||
|
||||
## 二、完整场景对照
|
||||
|
||||
```go
|
||||
// POST JSON(最常见)
|
||||
a.JSON(Map{"name": "138", "password": "123"}).Post("正常登录", 0)
|
||||
|
||||
// GET 无参数
|
||||
a.Get("未登录", 2, "请先登录")
|
||||
|
||||
// GET + URL 参数
|
||||
a.Query(Map{"shop_id": "1", "page": "1"}).Get("查询列表", 0)
|
||||
|
||||
// 需登录 + GET
|
||||
a.WithSession(Map{"user_id": int64(1)}).Get("已登录", 0)
|
||||
|
||||
// 需登录 + GET + 参数
|
||||
a.WithSession(Map{"user_id": int64(1)}).Query(Map{"shop_id": "1"}).Get("查询", 0)
|
||||
|
||||
// 需登录 + POST JSON
|
||||
a.WithSession(Map{"user_id": int64(1)}).JSON(Map{"name": "new"}).Post("更新", 0)
|
||||
|
||||
// 混合:URL 参数 + JSON body
|
||||
a.Query(Map{"token": "abc"}).JSON(Map{"phone": "138"}).Post("混合传参", 3, "异常")
|
||||
|
||||
// POST Form 表单
|
||||
a.Form(Map{"shop_id": "1", "id": "1"}).Post("表单提交", 0)
|
||||
|
||||
// 文件上传
|
||||
a.File("file", "a.png", imgBytes).Post("上传文件", 0)
|
||||
|
||||
// 文件 + 表单字段
|
||||
a.File("file", "a.png", imgBytes).Form(Map{"type": "avatar"}).Post("上传+表单", 0)
|
||||
|
||||
// PUT JSON
|
||||
a.JSON(Map{"name": "新名字"}).Put("更新信息", 0)
|
||||
|
||||
// DELETE + 参数
|
||||
a.Query(Map{"id": "1"}).Delete("删除", 0)
|
||||
|
||||
// 校验响应数据(status=0 时)
|
||||
resp := a.JSON(Map{"name": "138", "password": "123"}).Post("正常登录", 0)
|
||||
if resp.GetBody().GetMap("result").GetString("token") == "" {
|
||||
resp.Fail("缺少 token")
|
||||
}
|
||||
```
|
||||
|
||||
## 三、完整示例:user.go
|
||||
|
||||
```go
|
||||
var UserCtr = Ctr{
|
||||
"login": func(that *Context) { /* ... */ },
|
||||
"info": func(that *Context) { /* ... */ },
|
||||
"token": func(that *Context) { /* ... */ },
|
||||
"create": func(that *Context) { /* ... */ },
|
||||
"file": func(that *Context) { /* ... */ },
|
||||
}
|
||||
|
||||
// ========= 接口测试 =========
|
||||
var UserTest = CtrTest{
|
||||
"login": {"用户账号密码登录", func(a *Api) {
|
||||
a.JSON(Map{"name": "13800138000", "password": "123456"}).Post("正常登录", 0)
|
||||
a.JSON(Map{"name": "13800138000", "password": ""}).Post("密码为空", 4, "用户名或密码不能为空")
|
||||
a.JSON(Map{"name": "1380013", "password": "123456"}).Post("手机号格式错误", 4, "手机号码格式错误")
|
||||
a.JSON(Map{"name": "13800138000", "password": "wrong"}).Post("密码错误", 4, "用户名或密码错误")
|
||||
}},
|
||||
"info": {"获取用户信息", func(a *Api) {
|
||||
a.Get("未登录", 2, "请先登录")
|
||||
a.WithSession(Map{"user_id": int64(1)}).Get("已登录", 0)
|
||||
}},
|
||||
"token": {"获取会话Token", func(a *Api) {
|
||||
a.JSON(Map{"phone": "13800138000"}).Post("正确手机号", 0)
|
||||
a.JSON(Map{"phone": "138"}).Post("手机号格式错误", 3, "请输入正确的手机号")
|
||||
}},
|
||||
"create": {"用户注册", func(a *Api) {
|
||||
phone := "138" + ObjToStr(RandX(10000000, 99999999))
|
||||
defer a.DB().Delete("user", Map{"phone": phone})
|
||||
|
||||
a.JSON(Map{"phone": phone, "password": "123456"}).Post("正常注册", 0)
|
||||
a.JSON(Map{"phone": phone, "password": "123456"}).Post("重复注册", 3, "该手机号已被注册")
|
||||
a.JSON(Map{"phone": "138", "password": "123456"}).Post("手机号格式错误", 3, "请输入正确的手机号")
|
||||
a.JSON(Map{"phone": "13899999999", "password": "12"}).Post("密码太短", 3, "密码不能少于6位")
|
||||
}},
|
||||
"forget": {"忘记密码", func(a *Api) {
|
||||
a.JSON(Map{"phone": "13800138000"}).Post("无token", 3, "异常")
|
||||
a.Query(Map{"token": "abc"}).JSON(Map{"phone": "138", "code": "1234"}).Post("手机号错误", 3, "请输入正确的手机号")
|
||||
}},
|
||||
"file": {"文件上传", func(a *Api) {
|
||||
a.File("file", "test.txt", []byte("hello")).Post("未登录上传", 2, "你还没有登录")
|
||||
a.WithSession(Map{"user_id": int64(1)}).File("file", "test.txt", []byte("hello")).Post("已登录上传", 0)
|
||||
}},
|
||||
}
|
||||
```
|
||||
|
||||
## 四、TestProj 注册 + app_test.go
|
||||
|
||||
### init.go
|
||||
|
||||
```go
|
||||
var Project = Proj{
|
||||
"user": UserCtr,
|
||||
"goods": GoodsCtr,
|
||||
// ...
|
||||
}
|
||||
|
||||
var ProjectTest = ProjTest{
|
||||
"user": UserTest,
|
||||
"goods": GoodsTest,
|
||||
}
|
||||
```
|
||||
|
||||
### app_test.go
|
||||
|
||||
```go
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
)
|
||||
|
||||
var testApp *TestApp
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
testApp = NewTestApp("../config/config.json", TestProj{
|
||||
"app": {Project, ProjectTest},
|
||||
})
|
||||
code := m.Run()
|
||||
testApp.GenerateSwagger("HoTime API", "2.1.0", testApp.Config.GetString("tpt"))
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func TestApi(t *testing.T) {
|
||||
testApp.RunTests(t)
|
||||
}
|
||||
```
|
||||
|
||||
## 五、Swagger:多项目合并为一份文档
|
||||
|
||||
`GenerateSwagger` 遍历 `TestProj` 中所有项目的所有测试数据,生成**一份合并的 OpenAPI JSON**。每个项目作为 Swagger 的 tag 分组:
|
||||
|
||||
```json
|
||||
{
|
||||
"tags": [
|
||||
{"name": "app/user", "description": "用户接口"},
|
||||
{"name": "app/goods", "description": "货品接口"},
|
||||
{"name": "customer/customer", "description": "客户端接口"}
|
||||
],
|
||||
"paths": {
|
||||
"/app/user/login": { ... },
|
||||
"/app/user/info": { ... },
|
||||
"/customer/customer/bindPhone": { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
多项目不会覆盖,全部汇聚在同一个文件中,按 tag 分组展示。
|
||||
|
||||
## 六、指定运行范围
|
||||
|
||||
Go 的 `-run` 参数支持按子测试层级过滤(`/` 分隔正则匹配):
|
||||
|
||||
```bash
|
||||
# 全部
|
||||
go test ./app/... -v
|
||||
|
||||
# 只跑 user 模块的所有接口
|
||||
go test ./app/... -v -run TestApi/user
|
||||
|
||||
# 只跑 user 模块的 login 接口
|
||||
go test ./app/... -v -run TestApi/user/login
|
||||
|
||||
# 只跑 login 接口的"密码为空"用例
|
||||
go test ./app/... -v -run TestApi/user/login/密码为空
|
||||
|
||||
# 跑 user 和 goods 两个模块
|
||||
go test ./app/... -v -run "TestApi/(user|goods)"
|
||||
|
||||
# 只跑所有模块中包含"未登录"的用例
|
||||
go test ./app/... -v -run "TestApi/.*/.*未登录"
|
||||
```
|
||||
|
||||
**不需要框架做任何额外处理**,Go 的 `t.Run()` 子测试机制天然支持。
|
||||
|
||||
## 七、框架类型设计
|
||||
|
||||
```go
|
||||
// === 注册类型 ===
|
||||
type TestProj map[string]TestProjDef
|
||||
type TestProjDef struct { Proj Proj; Tests ProjTest }
|
||||
type ProjTest map[string]CtrTest
|
||||
type CtrTest map[string]ApiTestDef
|
||||
type ApiTestDef struct { Desc string; Func func(a *Api) }
|
||||
|
||||
// === Api:起点,数据设置 ===
|
||||
type Api struct {
|
||||
app *TestApp
|
||||
path string
|
||||
t interface{}
|
||||
desc string
|
||||
session Map
|
||||
}
|
||||
|
||||
// 数据设置(返回 *ApiCase,开始构建)
|
||||
func (a *Api) JSON(body interface{}) *ApiCase
|
||||
func (a *Api) Query(params Map) *ApiCase
|
||||
func (a *Api) Form(body Map) *ApiCase
|
||||
func (a *Api) File(field, name string, content []byte) *ApiCase
|
||||
|
||||
// Session(返回新 *Api,后续调用都带此 Session)
|
||||
func (a *Api) WithSession(s Map) *Api
|
||||
|
||||
// 直接终端(无数据时直接调用)
|
||||
func (a *Api) Get(desc string, status int, msg ...string) *ApiResponse
|
||||
func (a *Api) Post(desc string, status int, msg ...string) *ApiResponse
|
||||
func (a *Api) Put(desc string, status int, msg ...string) *ApiResponse
|
||||
func (a *Api) Delete(desc string, status int, msg ...string) *ApiResponse
|
||||
|
||||
// 数据库
|
||||
func (a *Api) DB() *HoTimeDB
|
||||
|
||||
// === ApiCase:链式构建器 ===
|
||||
type ApiCase struct { /* api, session, query, jsonBody, formBody, file... */ }
|
||||
|
||||
// 继续叠加数据
|
||||
func (c *ApiCase) JSON(body interface{}) *ApiCase
|
||||
func (c *ApiCase) Query(params Map) *ApiCase
|
||||
func (c *ApiCase) Form(body Map) *ApiCase
|
||||
func (c *ApiCase) File(field, name string, content []byte) *ApiCase
|
||||
func (c *ApiCase) WithSession(s Map) *ApiCase
|
||||
|
||||
// 终端操作(HTTP 方法 + 用例名 + 断言)
|
||||
func (c *ApiCase) Get(desc string, status int, msg ...string) *ApiResponse
|
||||
func (c *ApiCase) Post(desc string, status int, msg ...string) *ApiResponse
|
||||
func (c *ApiCase) Put(desc string, status int, msg ...string) *ApiResponse
|
||||
func (c *ApiCase) Delete(desc string, status int, msg ...string) *ApiResponse
|
||||
|
||||
// === ApiResponse ===
|
||||
type ApiResponse struct { StatusCode int; Body Map; RawBody []byte }
|
||||
func (r *ApiResponse) GetStatus() int
|
||||
func (r *ApiResponse) GetResult() interface{}
|
||||
func (r *ApiResponse) GetMsg() string
|
||||
func (r *ApiResponse) GetBody() Map
|
||||
func (r *ApiResponse) Fail(msg string) // 自定义断言失败
|
||||
```
|
||||
|
||||
## 八、文件清单
|
||||
|
||||
- **新增** `d:/work/hotimev1.5/testing_helper.go`
|
||||
- **新增** `d:/work/hotimev1.5/testing_api.go`
|
||||
- **新增** `d:/work/hotimev1.5/testing_swagger.go`
|
||||
- **修改** `d:/work/your-app/app/user.go` -- 末尾添加 UserTest
|
||||
- **修改** `d:/work/your-app/app/init.go` -- 添加 ProjectTest
|
||||
- **新增** `d:/work/your-app/app/app_test.go`
|
||||
@@ -0,0 +1,291 @@
|
||||
---
|
||||
name: API测试框架完整实现
|
||||
overview: 在 hotimev1.5 框架中实现完整的 API 测试基础设施:链式测试 API、事务回滚隔离、覆盖率追踪、Swagger 文档自动生成。
|
||||
todos:
|
||||
- id: db-testtx
|
||||
content: db/db.go:加 testTx 字段 + BeginTestTx/RollbackTestTx 方法
|
||||
status: completed
|
||||
- id: db-query
|
||||
content: db/query.go:Query(行64) 和 Exec(行120) 加 testTx 优先判断
|
||||
status: completed
|
||||
- id: db-transaction
|
||||
content: db/transaction.go:Action 加 testTx 传递 + SAVEPOINT 分支
|
||||
status: completed
|
||||
- id: testing-helper
|
||||
content: 新增 testing_helper.go:TestApp、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.go:GenerateSwagger 合并所有项目生成 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.Printf(go vet 警告)
|
||||
- **修改** `customer/init.go` -- 修复 log.Println -> log.Printf(go vet 警告)
|
||||
|
||||
## 六、已知问题与调试经验
|
||||
|
||||
### 问题一:busy buffer / bad connection
|
||||
- **现象:** 测试运行数秒后出现 `[mysql] busy buffer`,随后 `bad connection`,测试挂起
|
||||
- **根因:** 业务代码中 `go func()` 启动的 goroutine(如 `app/goods.go:syncGoodsToYst`、`app/stats.go` 中的统计协程)并发访问 `testTx` 单连接
|
||||
- **解决:** 在 `HoTimeDB` 中新增 `testMu *sync.Mutex`,所有 testTx 的 Query/Exec/SAVEPOINT 操作加互斥锁
|
||||
- **验证方式:** 通过在 query.go 中插入带时间戳和 goroutine 计数的日志,观察到操作严格串行化,无重叠
|
||||
|
||||
### 问题二:Lock wait timeout
|
||||
- **现象:** `Error 1205: Lock wait timeout exceeded`,指向 `DELETE FROM cached`
|
||||
- **根因:** `HoTimeCache` 的 DB 缓存通过独立连接操作 `cached` 表,与 `testTx` 事务锁冲突
|
||||
- **解决:** 测试启动时调用 `DisableDbCache()` 禁用 DB/Redis 缓存,仅保留 memory 缓存
|
||||
|
||||
### 问题三:go vet 编译失败
|
||||
- **现象:** `go test` 因 `go vet` 警告 `log.Println call has possible Printf formatting directive %v` 而拒绝运行
|
||||
- **根因:** `app/wechat.go` 和 `customer/init.go` 中误用 `log.Println` 传入 `%v` 格式化指令
|
||||
- **解决:** 改为 `log.Printf`
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
---
|
||||
name: DBState refactor to lastError
|
||||
overview: 升级 common.Error 基础类型(安全 Error() + HasError() + Unwrap()),删除 common/dbstate.go,新增 db/dberror.go(DBError 嵌入 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.go(DBError 嵌入 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.Error:HasError()/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)
|
||||
```
|
||||
|
||||
## 四、HoTimeDB:atomic.Pointer 无锁读写
|
||||
|
||||
```go
|
||||
type HoTimeDB struct {
|
||||
// ...
|
||||
lastError atomic.Pointer[DBError]
|
||||
}
|
||||
|
||||
func (db *HoTimeDB) GetLastError() *DBError {
|
||||
return db.lastError.Load()
|
||||
}
|
||||
```
|
||||
|
||||
## 五、内部写入:替换所有 lastState 调用
|
||||
|
||||
每次 SQL 执行后创建新 DBError 并原子 Store:
|
||||
|
||||
```go
|
||||
e := &DBError{Query: query, Data: args}
|
||||
e.SetError(sqlErr) // 用框架 Error 的 SetError
|
||||
that.lastError.Store(e)
|
||||
```
|
||||
|
||||
具体替换点:
|
||||
|
||||
- [db/query.go](db/query.go):2 处 `lastState.Set(query, args, sqlErr)` + 2 处 `lastState.SetError(err)`
|
||||
- [db/db.go](db/db.go):InitDb 中 2 处 `lastState.SetError(e)`
|
||||
- [db/transaction.go](db/transaction.go):3 处 `lastState.SetError(err)`
|
||||
- [db/crud.go](db/crud.go):1 处 `lastState.SetError(e)`
|
||||
|
||||
## 六、外部调用适配
|
||||
|
||||
- [example/app/test.go](example/app/test.go):22 处 `GetLastState().Query` -> `GetLastError().Query`
|
||||
- [example/app/mysql.go](example/app/mysql.go):3 处,同上
|
||||
|
||||
始终记录(成功时 `.GetError() == nil`),`.Query` 可直接安全读取。
|
||||
|
||||
## 七、与 Logger 的分工
|
||||
|
||||
- Logger(`logSQL`):格式化输出(带日期、caller、级别)—— 人看的
|
||||
- `lastError`:结构化原始数据(Query/Data/原始 error)—— 程序读的
|
||||
- 零重复:原始 error 只存一份
|
||||
|
||||
## 改动文件清单
|
||||
|
||||
- `common/error.go` — 添加 `Error()`/`HasError()`/`Unwrap()` 方法,更新注释
|
||||
- `common/dbstate.go` — 删除
|
||||
- `db/dberror.go` — 新增:DBError struct(嵌入 Error)
|
||||
- `db/db.go` — `atomic.Pointer[DBError]` 字段 + `GetLastError()` 方法
|
||||
- `db/query.go` — `lastState.Set` -> `lastError.Store`
|
||||
- `db/transaction.go` — `lastState.SetError` -> `lastError.Store`
|
||||
- `db/crud.go` — 同上
|
||||
- `example/app/test.go` — `GetLastState()` -> `GetLastError()`
|
||||
- `example/app/mysql.go` — 同上
|
||||
@@ -0,0 +1,139 @@
|
||||
---
|
||||
name: Fix caller detection
|
||||
overview: 修复三个日志/测试相关 bug:(1) 调用者智能识别漏掉 log/ 包自身 (2) 测试模式 SQL 出错不会导致用例失败 (3) 测试模式 WebConnectLog 写入已关闭的 NUL 文件
|
||||
todos:
|
||||
- id: fix-framework-detection
|
||||
content: 在 isHoTimeFrameworkFile 中 runtime/ 检查之后,添加 log/ 前缀检查
|
||||
status: completed
|
||||
- id: fix-sql-error-test-fail
|
||||
content: 测试模式 SQL 出错时自动让当前用例失败(db 增加错误计数 + execute 中检查)
|
||||
status: completed
|
||||
- id: fix-zerolog-nul-closed
|
||||
content: NewTestApp 中 Init 完成后将 WebConnectLog 置为 nil,避免写入已关闭的 NUL 文件
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# 修复日志/测试三个 Bug
|
||||
|
||||
---
|
||||
|
||||
## Bug 1:调用者智能识别始终显示 log/logger.go:318
|
||||
|
||||
### 根因
|
||||
|
||||
[log/logger.go](log/logger.go) 中的 `isHoTimeFrameworkFile` 函数对 `log/` 包自身存在识别盲区。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["应用代码 或 db/query.go 调用 Logger.Debug()"] --> B["zerolog Event.Msg()"]
|
||||
B --> C["callerMarshalFunc (log/logger.go:317)"]
|
||||
C --> D["findCaller 通过 runtime.Caller(i) 逐帧遍历调用栈"]
|
||||
D --> E{"isHoTimeFrameworkFile?"}
|
||||
E -->|"是框架文件 -> 跳过,继续向上"| D
|
||||
E -->|"非框架文件 -> 作为调用者返回"| F["显示在日志输出中"]
|
||||
```
|
||||
|
||||
`findCaller` 在 `i=1` 时拿到 `log/logger.go:318`。经过 `shortenPath` 截断后变成 `log/logger.go`。两条检测路径都漏掉了它:
|
||||
|
||||
- **路径一(第 392 行)**:`frameworkDirs` 包含 `"log/"`,但要求路径含 `"hotime"`。`shortenPath` 只保留 2 段路径,`log/logger.go` 中没有 `"hotime"`。**未命中。**
|
||||
- **路径二(第 410 行)**:`frameworkCoreDirs` 只有 `db/`、`common/`、`code/`、`cache/`,**没有 `"log/"`**。**未命中。**
|
||||
|
||||
### 修复
|
||||
|
||||
在 [log/logger.go](log/logger.go) 第 387-389 行 `runtime/` 检查之后加入 `log/` 前缀检查:
|
||||
|
||||
```go
|
||||
if strings.HasPrefix(file, "runtime/") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(file, "log/") {
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bug 2:测试模式 SQL 执行失败不会导致用例失败
|
||||
|
||||
### 根因
|
||||
|
||||
[db/query.go](db/query.go) `logSQL` 在测试模式下只是把 SQL 错误写入 `testLogBuf`,但 [testing_api.go](testing_api.go) 的 `execute` 中只在断言失败时才打印这些日志,**从不检查是否有 SQL 错误**。即使请求处理过程中数据库出错,只要 HTTP 响应的 status/msg 符合预期,测试就通过了。
|
||||
|
||||
当前流程:
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["ServeHTTP"] --> B["检查 status/msg/result"]
|
||||
B -->|"通过"| C["FlushTestLog 丢弃"]
|
||||
B -->|"失败"| D["FlushTestLog 打印"]
|
||||
```
|
||||
|
||||
### 修复
|
||||
|
||||
在 [db/db.go](db/db.go) 增加一个测试 SQL 错误计数器:
|
||||
|
||||
```go
|
||||
testSQLErrCount int32 // 测试模式下 SQL 错误计数(atomic)
|
||||
```
|
||||
|
||||
在 [db/query.go](db/query.go) `logSQL` 中,当 `err != nil` 且 `testLogBuf != nil` 时,`atomic.AddInt32(&that.testSQLErrCount, 1)`。
|
||||
|
||||
在 [db/db.go](db/db.go) 增加两个方法:
|
||||
|
||||
```go
|
||||
func (that *HoTimeDB) TestSQLErrorCount() int32 {
|
||||
return atomic.LoadInt32(&that.testSQLErrCount)
|
||||
}
|
||||
func (that *HoTimeDB) ResetTestSQLErrorCount() {
|
||||
atomic.StoreInt32(&that.testSQLErrCount, 0)
|
||||
}
|
||||
```
|
||||
|
||||
在 [testing_api.go](testing_api.go) `execute` 中,`ServeHTTP` 之后、断言之前,检查计数:
|
||||
|
||||
```go
|
||||
c.api.app.Db.FlushTestLog() // 先清空请求前的残留
|
||||
// ... ServeHTTP ...
|
||||
sqlErrCount := c.api.app.Db.TestSQLErrorCount()
|
||||
c.api.app.Db.ResetTestSQLErrorCount()
|
||||
if sqlErrCount > 0 {
|
||||
sqlLog := c.api.app.Db.FlushTestLog()
|
||||
t.Fatalf("请求处理中有 %d 次 SQL 错误:\n%s", sqlErrCount, sqlLog)
|
||||
}
|
||||
```
|
||||
|
||||
修复后流程:
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["ServeHTTP"] --> B{"SQL 出错?"}
|
||||
B -->|"是"| C["t.Fatalf 用例直接失败"]
|
||||
B -->|"否"| D["正常断言 status/msg/result"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bug 3:zerolog: could not write event: write NUL: file already closed
|
||||
|
||||
### 根因
|
||||
|
||||
[testing_helper.go](testing_helper.go) `NewTestApp` 中的初始化顺序:
|
||||
|
||||
```
|
||||
1. os.Stderr = devNull (重定向到 NUL)
|
||||
2. Init(configPath) (创建 WebConnectLog,其 ConsoleWriter.Out = devNull)
|
||||
3. os.Stderr = origStderr (恢复真实 stderr)
|
||||
4. devNull.Close() (关闭 NUL 文件)
|
||||
5. app.Log.SetOutput(io.Discard) (只修复了主日志)
|
||||
-- WebConnectLog 仍然引用已关闭的 devNull --
|
||||
6. 测试请求触发 WebConnectLog.Info() → 写入已关闭文件 → 报错
|
||||
```
|
||||
|
||||
### 修复
|
||||
|
||||
在 [testing_helper.go](testing_helper.go) `devNull.Close()` 之后、`app.Log.SetOutput(io.Discard)` 之前,加一行:
|
||||
|
||||
```go
|
||||
app.WebConnectLog = nil
|
||||
```
|
||||
|
||||
测试环境不需要访问日志。`application.go` 第 350 行 `if that.WebConnectLog != nil` 已有 nil 保护,置 nil 即可安全跳过。
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
name: Fix caller detection v2
|
||||
overview: 重新设计 findCaller 的调用者过滤逻辑:(1) 删除 maxFrameworkDepth 提前退出 (2) 收窄 isHoTimeFrameworkFile 定义为仅跳过日志/DB执行管线,让框架业务文件(code/makecode.go、application.go、context.go 等)作为有意义的调用者返回。
|
||||
todos:
|
||||
- id: rewrite-findcaller
|
||||
content: 重写 findCaller:删除 maxFrameworkDepth、applicationFile 跟踪,简化为跳过基础设施 + 返回第一个业务文件
|
||||
status: completed
|
||||
- id: rewrite-framework-check
|
||||
content: 重命名 isHoTimeFrameworkFile 为 isInfrastructureFile,收窄到只匹配 log/db/cache/common/dri,移除 code/ 和 application.go 等
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# 修复调用者智能识别(v2)
|
||||
|
||||
## 根因分析
|
||||
|
||||
用户看到 `[db/crud.go:149]`,实际调用者是 `code/makecode.go`。两个问题叠加:
|
||||
|
||||
**问题 A**: `maxFrameworkDepth = 10` 过低。zerolog 内部帧(~5) + log/logger.go(1) + db/query.go(3-4) + db/crud.go(1) 轻松达到 10,触发提前返回,根本没走到 `code/makecode.go`。
|
||||
|
||||
**问题 B**: 即使去掉 `maxFrameworkDepth`,`code/makecode.go` 在 `frameworkCoreDirs` 中被 `"code/"` + `"makecode.go"` 命中,也会被跳过。最终回退到 `applicationFile`(application.go)或 `lastFrameworkFile`,都不是用户想要的。
|
||||
|
||||
## 设计思路
|
||||
|
||||
用户原话:"应用层触发的就打印应用层的,如果是框架层的业务造成的就打印框架层对应位置的,但肯定不是 log/logger.go"。
|
||||
|
||||
核心区分:
|
||||
- **基础设施文件**(应跳过):日志管线(log/、zerolog/)、DB 执行管线(db/\*)、缓存中间件(cache/\*)、Go runtime、通用工具(common/\*)、数据库驱动(dri/\*)
|
||||
- **业务文件**(应返回):`code/makecode.go`、`application.go`、`context.go`、`session.go`、所有应用层代码
|
||||
|
||||
跳过基础设施,返回第一个业务文件。无论这个业务文件是应用层还是框架层。
|
||||
|
||||
## 具体修改
|
||||
|
||||
### 1. [log/logger.go](log/logger.go) -- `findCaller` 简化
|
||||
|
||||
- **删除** `maxFrameworkDepth` 常量(第 315 行)和 `frameworkCount >= maxFrameworkDepth` 提前退出(第 343-345 行)。`i < 20` 循环上限已足够保护。
|
||||
- **删除** `applicationFile` / `applicationLine` 跟踪及其回退逻辑(不再需要 application.go 特殊处理)。
|
||||
- **删除** `frameworkCount` 计数器(不再需要)。
|
||||
- 简化后的 `findCaller`:
|
||||
|
||||
```go
|
||||
func findCaller(origFile string, origLine int) string {
|
||||
var lastInfraFile string
|
||||
var lastInfraLine int
|
||||
|
||||
for i := 1; i < 20; i++ {
|
||||
_, file, line, ok := runtime.Caller(i)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
shortFile := shortenPath(file)
|
||||
|
||||
if isInfrastructureFile(shortFile) {
|
||||
lastInfraFile = shortFile
|
||||
lastInfraLine = line
|
||||
continue
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s:%d", shortFile, line)
|
||||
}
|
||||
|
||||
if lastInfraFile != "" {
|
||||
return fmt.Sprintf("%s:%d", lastInfraFile, lastInfraLine)
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", shortenPath(origFile), origLine)
|
||||
}
|
||||
```
|
||||
|
||||
### 2. [log/logger.go](log/logger.go) -- 重命名 + 收窄 `isHoTimeFrameworkFile`
|
||||
|
||||
重命名为 `isInfrastructureFile`,仅匹配日志/DB/缓存/通用工具等"管道"文件:
|
||||
|
||||
```go
|
||||
func isInfrastructureFile(file string) bool {
|
||||
// 第三方日志库
|
||||
if strings.HasPrefix(file, "zerolog/") || strings.HasPrefix(file, "zerolog@") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(file, "logrus/") || strings.HasPrefix(file, "logrus@") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(file, "runtime/") {
|
||||
return true
|
||||
}
|
||||
// HoTime 日志包
|
||||
if strings.HasPrefix(file, "log/") {
|
||||
return true
|
||||
}
|
||||
|
||||
// DB 执行管线(db/ 下全部文件)
|
||||
infraPrefixes := []string{"db/", "cache/", "common/", "dri/"}
|
||||
for _, prefix := range infraPrefixes {
|
||||
if strings.HasPrefix(file, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容路径含 hotime 的场景(module 全路径未被 shortenPath 完全截断时)
|
||||
lowerFile := strings.ToLower(file)
|
||||
if strings.Contains(lowerFile, "hotime") {
|
||||
infraDirs := []string{"db/", "cache/", "common/", "log/", "dri/"}
|
||||
for _, dir := range infraDirs {
|
||||
if strings.Contains(file, dir) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
```
|
||||
|
||||
关键变更:
|
||||
- `"code/"` 从所有列表中移除 -- `code/makecode.go` 不再被跳过
|
||||
- `application.go`、`context.go`、`session.go` 等单文件匹配全部移除 -- 框架业务文件不再被跳过
|
||||
- `frameworkCoreDirs` 中的 `"code/"` 及其文件名(makecode.go、template.go、config.go)移除
|
||||
|
||||
## 修复后效果
|
||||
|
||||
| 调用链 | 显示结果 |
|
||||
|--------|---------|
|
||||
| app/user.go -> db.GetMap -> logSQL | `app/user.go:XX` |
|
||||
| code/makecode.go -> db.GetList -> logSQL | `code/makecode.go:XX` |
|
||||
| application.go SetConfig -> db -> logSQL | `application.go:XX` |
|
||||
| context.go session -> db -> logSQL | `context.go:XX` |
|
||||
@@ -0,0 +1,220 @@
|
||||
---
|
||||
name: HoTime TDD Skill
|
||||
overview: 创建一个 HoTime 框架的 TDD(测试驱动开发)Skill,指导 AI 按照"先写测试、再写实现、运行直到通过"的流程开发接口;同时在框架层面增加测试模式下的 SQL 日志智能控制,解决 AI 因日志过多无法继续执行的问题。
|
||||
todos:
|
||||
- id: sql-log-buffer
|
||||
content: "db/db.go: 添加 testLogBuf *bytes.Buffer + testLogBufMu sync.Mutex 字段"
|
||||
status: completed
|
||||
- id: sql-log-query
|
||||
content: "db/query.go: 修改 Query/Exec 两处 defer SQL 日志块,有 buffer 写 buffer、无 buffer 照旧"
|
||||
status: completed
|
||||
- id: sql-log-execute
|
||||
content: "testing_api.go execute(): 请求前创建 buffer、请求后根据 pass/fail 决定 t.Log 或丢弃"
|
||||
status: completed
|
||||
- id: sql-log-init
|
||||
content: "testing_helper.go NewTestApp(): Init 后设 Db.Mode=0 静默启动阶段日志,再恢复原 Mode 供运行时 buffer 使用"
|
||||
status: completed
|
||||
- id: create-skill
|
||||
content: 创建 ~/.cursor/skills/hotime-tdd-testing/SKILL.md:TDD 工作流 + 测试编写范式 + API 速查 + 运行命令
|
||||
status: completed
|
||||
- id: verify-test
|
||||
content: 运行单接口测试验证:通过时无 SQL 日志,失败时输出 SQL 日志
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# HoTime TDD API 测试 Skill 与 SQL 日志控制
|
||||
|
||||
## 一、SQL 日志智能控制(框架修改)-- 失败才打印
|
||||
|
||||
### 问题分析
|
||||
|
||||
SQL 日志由 `Db.Mode` 控制([db/query.go](d:/work/hotimev1.5/db/query.go) 第 40 行 `if that.Mode != 0`),`Log` 是 `*logrus.Logger`([db/db.go](d:/work/hotimev1.5/db/db.go) 第 22 行)。当前 config `"mode": 3`,每条 SQL 都实时打印。日志来源:
|
||||
|
||||
- 启动阶段 `Init()` -> `SetDB()` 中的 DB 初始化/表扫描
|
||||
- 测试执行中每个接口的所有 SQL
|
||||
- 批量运行时日志量指数级增长,导致 AI 输出被截断
|
||||
|
||||
### 方案:Buffer + Flush on Failure
|
||||
|
||||
**策略**:SQL 日志不直接打印,写入 per-case buffer。测试通过则丢弃,失败则通过 `t.Log()` 输出(Go 的 `t.Log` 只在失败或 `-v` 时显示)。
|
||||
|
||||
#### Step 1: db/db.go -- 添加 buffer 字段
|
||||
|
||||
```go
|
||||
type HoTimeDB struct {
|
||||
// ... 现有字段不变 ...
|
||||
testLogBuf *bytes.Buffer // 测试模式:SQL 日志写入此 buffer 而非直接打印
|
||||
testLogBufMu sync.Mutex // 保护 testLogBuf 并发写入
|
||||
}
|
||||
```
|
||||
|
||||
新增 helper 方法:
|
||||
|
||||
```go
|
||||
func (that *HoTimeDB) SetTestLogBuffer(buf *bytes.Buffer) {
|
||||
that.testLogBufMu.Lock()
|
||||
that.testLogBuf = buf
|
||||
that.testLogBufMu.Unlock()
|
||||
}
|
||||
|
||||
func (that *HoTimeDB) FlushTestLog() string {
|
||||
that.testLogBufMu.Lock()
|
||||
defer that.testLogBufMu.Unlock()
|
||||
if that.testLogBuf == nil {
|
||||
return ""
|
||||
}
|
||||
s := that.testLogBuf.String()
|
||||
that.testLogBuf.Reset()
|
||||
return s
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 2: db/query.go -- 修改两处 defer SQL 日志
|
||||
|
||||
`queryWithRetry` 第 39-45 行和 `execWithRetry` 第 117-123 行的 defer 块统一改为:
|
||||
|
||||
```go
|
||||
defer func() {
|
||||
if that.Mode != 0 {
|
||||
that.mu.RLock()
|
||||
msg := fmt.Sprintf("SQL:%s DATA:%v ERROR:%v", that.LastQuery, that.LastData, that.LastErr.GetError())
|
||||
that.mu.RUnlock()
|
||||
that.testLogBufMu.Lock()
|
||||
if that.testLogBuf != nil {
|
||||
that.testLogBuf.WriteString(msg + "\n")
|
||||
} else {
|
||||
that.Log.Info(msg)
|
||||
}
|
||||
that.testLogBufMu.Unlock()
|
||||
}
|
||||
}()
|
||||
```
|
||||
|
||||
#### Step 3: testing_api.go execute() -- 管理 buffer 生命周期
|
||||
|
||||
在 `execute()` 函数的 `t.Run(desc, func(t *testing.T) { ... })` 内部:
|
||||
|
||||
```go
|
||||
t.Run(desc, func(t *testing.T) {
|
||||
// 启用 SQL 日志缓冲
|
||||
buf := &bytes.Buffer{}
|
||||
c.api.app.Db.SetTestLogBuffer(buf)
|
||||
|
||||
start := time.Now()
|
||||
req := c.buildRequest(method)
|
||||
w := httptest.NewRecorder()
|
||||
c.api.app.Application.ServeHTTP(w, req)
|
||||
duration := time.Since(start)
|
||||
|
||||
// ... 原有的断言逻辑 ...
|
||||
|
||||
// 停止缓冲
|
||||
sqlLog := c.api.app.Db.FlushTestLog()
|
||||
c.api.app.Db.SetTestLogBuffer(nil)
|
||||
|
||||
// 失败时输出 SQL 日志
|
||||
if !passed && sqlLog != "" {
|
||||
t.Logf("SQL 日志:\n%s", sqlLog)
|
||||
}
|
||||
// ... 原有的 record 收集逻辑 ...
|
||||
})
|
||||
```
|
||||
|
||||
#### Step 4: testing_helper.go NewTestApp() -- 静默启动阶段日志
|
||||
|
||||
`Init()` 内部调用 `SetDB()` 时 Mode 已从 config 读取(值为 3),会打印大量启动 SQL。在 `NewTestApp` 中 `Init()` 之后立即处理:
|
||||
|
||||
```go
|
||||
func NewTestApp(configPath string, projects TestProj, ...) *TestApp {
|
||||
app := Init(configPath)
|
||||
|
||||
// 启动阶段产生的 SQL 日志已经打印完毕,现在保存原始 Mode
|
||||
// Mode 保持原值(非 0)让运行时 SQL 仍走 defer 日志逻辑,但会被 buffer 拦截
|
||||
// 启动阶段的日志无法追溯拦截,但它们是一次性的固定输出,量可控
|
||||
|
||||
// ... 后续逻辑不变 ...
|
||||
}
|
||||
```
|
||||
|
||||
> 启动阶段的 SQL 日志(表扫描等)是 `Init()` 内部产生的,此时 buffer 尚未设置,会直接打印。这些是一次性的固定日志,量相对可控。如果仍然太多,可在 `Init()` 调用前临时设 `app.Db.Mode = 0`,但 `Init` 返回的 app 还没有 Db 对象——所以更实际的做法是在 `NewTestApp` 中 `Init()` 之后、`SetupForTest()` 之前设置 `Db.Mode = 0` 静默后续初始化 SQL,然后在 `SetupForTest()` 完成后恢复原始 Mode。
|
||||
|
||||
实际代码:
|
||||
|
||||
```go
|
||||
func NewTestApp(configPath string, projects TestProj, ...) *TestApp {
|
||||
app := Init(configPath)
|
||||
|
||||
// 保存原始 Mode,静默后续初始化阶段的 SQL 日志
|
||||
origMode := app.Db.Mode
|
||||
app.Db.Mode = 0
|
||||
|
||||
// ... listener / router / SetupForTest / DisableDbCache 等初始化 ...
|
||||
|
||||
// 恢复 Mode,运行时 SQL 日志由 testLogBuf 接管
|
||||
app.Db.Mode = origMode
|
||||
|
||||
return &TestApp{ ... }
|
||||
}
|
||||
```
|
||||
|
||||
### 效果
|
||||
|
||||
- `go test ./app/... -v` -- 全量运行:通过的接口零 SQL 输出,失败的接口自动输出完整 SQL 链路
|
||||
- `go test -run TestApi/app/order/create -v` -- 单接口:同上,通过无输出、失败有输出
|
||||
- 任何粒度都自动适配,无需手动控制
|
||||
|
||||
### 涉及文件
|
||||
|
||||
- [db/db.go](d:/work/hotimev1.5/db/db.go) -- 添加 `testLogBuf` / `testLogBufMu` 字段 + helper 方法
|
||||
- [db/query.go](d:/work/hotimev1.5/db/query.go) -- 修改 `queryWithRetry` 和 `execWithRetry` 的 defer 日志块
|
||||
- [testing_api.go](d:/work/hotimev1.5/testing_api.go) -- `execute()` 中管理 buffer 生命周期
|
||||
- [testing_helper.go](d:/work/hotimev1.5/testing_helper.go) -- `NewTestApp()` 中静默初始化阶段 + 恢复 Mode
|
||||
|
||||
---
|
||||
|
||||
## 二、TDD Skill 创建
|
||||
|
||||
### 存储位置
|
||||
|
||||
个人 Skill:`~/.cursor/skills/hotime-tdd-testing/SKILL.md`(跨项目通用)
|
||||
|
||||
### Skill 核心内容
|
||||
|
||||
指导 AI 按以下 TDD 工作流开发 HoTime 接口:
|
||||
|
||||
**Phase 1: 编写测试用例(先于实现)**
|
||||
|
||||
- 在 `xxx_test.go` 中定义 `CtrTest`,包含:
|
||||
- 错误用例(权限/参数/业务校验,覆盖所有 status != 0 路径)
|
||||
- 测试数据准备(`a.DB().Insert`)
|
||||
- 正确请求 + 响应结构校验(第三参数类型样本)
|
||||
- Verify 统一校验(响应值断言 + 数据库状态校验)
|
||||
|
||||
**Phase 2: 实现接口**
|
||||
|
||||
- 在对应的 `.go` 文件中编写 handler
|
||||
|
||||
**Phase 3: 运行测试直到通过**
|
||||
|
||||
- 运行命令:`go test ./app/... -v -run TestApi/app/{ctr}/{method}`
|
||||
- 修复失败用例,循环直到全部通过
|
||||
|
||||
**关键参考文档**
|
||||
|
||||
- Skill 内直接嵌入精简版的测试 API 速查表(链式 API、Verify 用法、DB 操作等)
|
||||
- 详细文档指向 [Testing_API测试框架.md](d:/work/your-app/docs/Testing_API测试框架.md)
|
||||
|
||||
**SQL 日志行为(框架自动处理)**
|
||||
|
||||
- 任何粒度运行:通过的用例零 SQL 输出,失败的用例自动输出完整 SQL 链路
|
||||
- 无需手动控制,框架通过 buffer 机制自动按 pass/fail 决定输出
|
||||
|
||||
### Skill 文件结构
|
||||
|
||||
```
|
||||
~/.cursor/skills/hotime-tdd-testing/
|
||||
SKILL.md -- 主文件(TDD 流程 + API 速查 + 运行指令)
|
||||
```
|
||||
|
||||
直接在 SKILL.md 内嵌入精简的 API 参考(不分离文件),保持在 500 行以内,因为测试框架文档已经在项目 docs 目录中。
|
||||
@@ -0,0 +1,331 @@
|
||||
---
|
||||
name: JSON Path 查询支持
|
||||
overview: 在现有 Dialect 接口和 WHERE 条件解析引擎上扩展,使 MySQL 和 SQLite 支持类似 PgSQL 的 `data["a"]["b"]["c"]` JSON 路径查询与操作语法,所有三种数据库共享统一的上层 API。
|
||||
todos:
|
||||
- id: dialect-json-interface
|
||||
content: 在 db/dialect.go 的 Dialect 接口新增 JSONExtract / JSONSet / JSONContains / JSONArrayLength 四个方法
|
||||
status: pending
|
||||
- id: dialect-json-mysql
|
||||
content: 实现 MySQLDialect 的三个 JSON 方法,处理 JSON_EXTRACT / JSON_SET / JSON_UNQUOTE 等
|
||||
status: pending
|
||||
- id: dialect-json-sqlite
|
||||
content: 实现 SQLiteDialect 的三个 JSON 方法,使用小写 json_extract / json_set
|
||||
status: pending
|
||||
- id: dialect-json-pgsql
|
||||
content: "实现 PostgreSQLDialect 的三个 JSON 方法,使用 -> / #>> / jsonb_set 语法"
|
||||
status: pending
|
||||
- id: where-json-parse
|
||||
content: 新增 parseJSONPathKey()/parseJSONPathFromName() 工具函数,同时支持 col["key"] 和 col['key'] 两种引号格式,以及数字索引 [0],解析出列名、[]string 路径键、操作符后缀
|
||||
status: pending
|
||||
- id: where-json-cond
|
||||
content: 在 db/where.go 的 varCond() 开头最优先调用 parseJSONPathKey,分发到 jsonPathCond();jsonPathCond 对现有操作符复用比较逻辑,新增 [?]/[!?]/[@]/[!@]/[len]/[len>] 等分支
|
||||
status: pending
|
||||
- id: crud-json-update
|
||||
content: 在 db/crud.go 的 Update() SET 构建循环中检测 JSON 路径列名生成 JSONSet 表达式,在 Select() 的 Slice 字段循环中检测 JSON 路径生成 JSONExtract 表达式(修复两处 ProcessColumnNoPrefix 误处理)
|
||||
status: pending
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# MySQL/SQLite JSON 路径查询支持方案
|
||||
|
||||
## 背景与现状
|
||||
|
||||
现有 ORM 已有 `Dialect` 接口抽象三种数据库差异,`where.go` 用 `[operator]` 后缀语法解析条件。扩展的核心思路:**在解析 key 时识别 JSON 路径记法,委托给 Dialect 生成对应函数调用**。
|
||||
|
||||
## 三种数据库 JSON 能力深度对比
|
||||
|
||||
### 1. JSON 值提取(WHERE 比较场景)
|
||||
|
||||
|
||||
| | MySQL 5.7+ | SQLite 3.9+ | PostgreSQL |
|
||||
| ---------- | ------------------------------------------------------- | ------------------------------ | ----------------- |
|
||||
| 单层提取(返回文本) | `JSON_UNQUOTE(JSON_EXTRACT(col,'$.a'))` 或 `col->>'$.a'` | `json_extract(col,'$.a')` | `col->>'a'` |
|
||||
| 多层嵌套(返回文本) | `JSON_UNQUOTE(JSON_EXTRACT(col,'$.a.b'))` | `json_extract(col,'$.a.b')` | `col#>>'{a,b}'` |
|
||||
| 数组索引 | `JSON_EXTRACT(col,'$.arr[0]')` | `json_extract(col,'$.arr[0]')` | `col#>>'{arr,0}'` |
|
||||
| 路径格式 | `$.a.b` | `$.a.b`(与MySQL相同) | `{a,b}`(#>> 方式) |
|
||||
|
||||
|
||||
**关键差异**:MySQL 的 `JSON_EXTRACT` 对字符串返回值带外层双引号(如 `"北京"` 而非 `北京`),与普通字符串 `= ?` 比较会失败,**必须包一层 `JSON_UNQUOTE()`**。SQLite 和 PostgreSQL(`->>`/`#>>`)则直接返回文本,无此问题。
|
||||
|
||||
### 2. JSON 局部更新(UPDATE SET 场景)
|
||||
|
||||
|
||||
| | MySQL 5.7+ | SQLite 3.9+ | PostgreSQL |
|
||||
| ----- | ------------------------- | ------------------------- | ------------------------------------------ |
|
||||
| 语法 | `JSON_SET(col,'$.a.b',?)` | `json_set(col,'$.a.b',?)` | `jsonb_set(col,'{a,b}',to_jsonb(?::text))` |
|
||||
| 路径格式 | `$.a.b` | `$.a.b`(与MySQL相同) | `'{a,b}'` 数组字面量 |
|
||||
| 值的类型 | 直接绑定参数 | 直接绑定参数 | 字符串需 `to_jsonb(?::text)`,数字可 `?::jsonb` |
|
||||
| 列类型限制 | 无(TEXT 也行) | 无(TEXT 列) | **必须是 `jsonb` 列**,`json` 列无 `jsonb_set` |
|
||||
|
||||
|
||||
**关键差异**:MySQL 与 SQLite 的语法几乎完全一致(函数名大小写不同而已)。PostgreSQL 只有 `jsonb_set`,要求列类型为 `jsonb`,且字符串值必须转为 JSON 格式(`to_jsonb(?::text)` 或 `'"value"'::jsonb`)。
|
||||
|
||||
### 3. JSON 数组包含(`[@]` 操作符)
|
||||
|
||||
|
||||
| | MySQL 5.7+ | SQLite 3.9+ | PostgreSQL |
|
||||
| ------ | --------------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------ |
|
||||
| SQL 形式 | `JSON_CONTAINS(JSON_EXTRACT(col,'$.path'),JSON_QUOTE(?))` | `EXISTS(SELECT 1 FROM json_each(col,'$.path') WHERE value=?)` | `col#>'{path}' @> to_jsonb(?::text)` |
|
||||
| 结构类型 | 函数调用(内联) | **EXISTS 子查询**(结构不同) | 中缀操作符(内联) |
|
||||
|
||||
|
||||
**最大兼容难点**:SQLite 没有内联的包含函数,必须生成 EXISTS 子查询,SQL 结构与另外两种完全不同。`JSONContains()` 方法在 SQLite 下返回的是一段 `EXISTS(...)` 表达式。
|
||||
|
||||
### 4. 路径格式统一转换规则
|
||||
|
||||
ORM 内部统一使用 `[]string{"a","b","c"}` 表示路径键(数字索引用字符串 `"0"` 表示),各 Dialect 自行转换:
|
||||
|
||||
```
|
||||
内部路径: ["address","city"]
|
||||
→ MySQL/SQLite: $.address.city
|
||||
→ PostgreSQL: {address,city}
|
||||
|
||||
内部路径: ["tags","0"](数组索引)
|
||||
→ MySQL/SQLite: $.tags[0]
|
||||
→ PostgreSQL: {tags,0}
|
||||
```
|
||||
|
||||
## 统一 API 语法设计
|
||||
|
||||
### Key 写法(单引号/双引号两种都支持)
|
||||
|
||||
```go
|
||||
// 双引号风格(JSON 标准,推荐,需用反引号字符串)
|
||||
common.Map{ `profile["addr"]["city"]`: "北京" }
|
||||
|
||||
// 单引号风格(普通字符串也能写)
|
||||
common.Map{ "profile['addr']['city']": "北京" }
|
||||
```
|
||||
|
||||
两种写法解析结果完全一致,内部统一转为 `$.addr.city`。
|
||||
|
||||
### 操作符设计:复用现有 + 新增 JSON 专属
|
||||
|
||||
**所有现有操作符均可接在 JSON 路径后直接使用**(`=` / `>` / `<` / `>=` / `<=` / `!=` / `LIKE` / `IN` / `NOT IN` / `BETWEEN` / `IS NULL`):
|
||||
|
||||
```go
|
||||
db.Select("user", common.Map{
|
||||
`profile["age"]`: 18, // = 18
|
||||
`profile["age"][>]`: 18, // > 18
|
||||
`profile["age"][<>]`: []int{18, 30}, // BETWEEN
|
||||
`profile["name"][~]`: "张", // LIKE %张%
|
||||
`profile["tags"][0]`: "vip", // 数组第一个元素 = vip
|
||||
`profile["addr"]["city"]`: "北京", // 多级嵌套
|
||||
`profile["score"]`: nil, // IS NULL
|
||||
})
|
||||
```
|
||||
|
||||
**新增 JSON 专属操作符**(现有体系无对应):
|
||||
|
||||
|
||||
| 操作符 | 含义 | MySQL | SQLite | PgSQL |
|
||||
| --------- | ----------- | -------------------------------- | ------------------------ | ------------------------- |
|
||||
| `[?]` | 路径存在(非NULL) | `IS NOT NULL` | `IS NOT NULL` | `? ?` |
|
||||
| `[!?]` | 路径不存在 | `IS NULL` | `IS NULL` | `NOT (? ?)` |
|
||||
| `[@]` | 数组/对象包含某值 | `JSON_CONTAINS(col,?,'$.path')` | `json_each` 子查询 | `col @> ?::jsonb` |
|
||||
| `[!@]` | 不包含 | `NOT JSON_CONTAINS(...)` | 子查询 NOT EXISTS | `NOT (col @> ?)` |
|
||||
| `[len]` | 数组长度 = ? | `JSON_LENGTH(JSON_EXTRACT(...))` | `json_array_length(...)` | `jsonb_array_length(...)` |
|
||||
| `[len>]` | 数组长度 > ? | 同上 | 同上 | 同上 |
|
||||
| `[len<]` | 数组长度 < ? | 同上 | 同上 | 同上 |
|
||||
| `[len>=]` | 数组长度 >= ? | 同上 | 同上 | 同上 |
|
||||
| `[len<=]` | 数组长度 <= ? | 同上 | 同上 | 同上 |
|
||||
|
||||
|
||||
```go
|
||||
// 新操作符使用示例
|
||||
db.Select("user", common.Map{
|
||||
`profile["vip"][?]`: nil, // vip 字段存在
|
||||
`profile["tags"][@]`: "admin", // tags 数组包含 "admin"
|
||||
`profile["friends"][len>]`: 5, // friends 数组长度 > 5
|
||||
`profile["items"][len]`: 3, // items 数组长度 = 3
|
||||
})
|
||||
|
||||
// UPDATE 局部更新 JSON 字段
|
||||
db.Update("user", common.Map{
|
||||
`profile["age"]`: 20,
|
||||
`profile["addr"]["city"]`: "上海",
|
||||
}, common.Map{"id": 1})
|
||||
```
|
||||
|
||||
## 与现有代码的兼容性分析
|
||||
|
||||
### 三处具体冲突点
|
||||
|
||||
**冲突 1 — WHERE `varCond` 的 `[...]` 分支**(`[db/where.go:225](db/where.go)`)
|
||||
|
||||
现有逻辑:只要 key 含 `[` 且末尾是 `]` 就进分支,取末尾 3/4 字符匹配已知操作符。
|
||||
|
||||
```
|
||||
profile["age"] → 末尾3字符 e"] → 无匹配 → handleDefaultCondition
|
||||
→ ProcessColumn("profile[\"age\"]") → 被加引号 → WRONG SQL
|
||||
|
||||
profile["age"][>] → 末尾3字符 [>] → MATCH,strips → ProcessColumn("profile[\"age\"]")
|
||||
→ 被加引号 → WRONG SQL
|
||||
|
||||
profile["age"][@] → 末尾3字符 [@] → 无匹配(新操作符) → handleDefaultCondition
|
||||
→ ProcessColumn("profile[\"age\"][@]") → WRONG SQL
|
||||
```
|
||||
|
||||
**冲突 2 — UPDATE `ProcessColumnNoPrefix` 调用**(`[db/crud.go:629](db/crud.go)`)
|
||||
|
||||
```go
|
||||
query += processor.ProcessColumnNoPrefix(k) + "=" + vstr // 第629行
|
||||
```
|
||||
|
||||
`k = profile["age"]` → `QuoteIdentifier` 加引号 → ``profile["age"]`=?` → 无效 SQL。
|
||||
|
||||
**冲突 3 — SELECT slice 字段**(`[db/crud.go:109](db/crud.go)`)
|
||||
|
||||
无 `.` 且无 `AS` 的字段走 `ProcessColumnNoPrefix`,`profile["age"]` 被当成普通列名加引号。
|
||||
|
||||
### 已有格式不受影响的验证
|
||||
|
||||
- `profile[>]` — 末尾 `[>]` 匹配现有 switch → 先 strips 再调 `ProcessColumn("profile")` → **正常**
|
||||
- `user.name` — `ProcessColumn` 有 `.` 分支 → **正常**
|
||||
- ``user`.name` — `stripQuotes` 后同上 → **正常**
|
||||
- `"name,age"` 字符串字段 — `ProcessFieldList` 的正则不会错误匹配无 `.` 的字段 → **正常**
|
||||
- `Slice{"name","age"}` — 无 JSON 路径字符 → **正常**
|
||||
|
||||
---
|
||||
|
||||
## 实现方案
|
||||
|
||||
### 核心设计:`parseJSONPathKey` 最先拦截
|
||||
|
||||
区分 JSON 路径括号和操作符括号的关键:**括号内容**
|
||||
|
||||
- JSON 路径段:`["xxx"]`、`['xxx']`、`[0]`(引号字符串或纯数字)
|
||||
- 操作符段:`[>]`、`[@]`、`[len>]`(无引号,含符号字符)
|
||||
|
||||
**第一步:新增路径工具函数(`[db/where.go](db/where.go)` 顶部或独立 `jsonpath.go`)**
|
||||
|
||||
```go
|
||||
// parseJSONPathKey 解析 JSON 路径 key,识别规则:
|
||||
// ^(\w[\w.]*) 列名
|
||||
// ((?:\["[^"]*"\]|\['[^']*'\]|\[\d+\])+) JSON 路径段(双引号/单引号/数字索引)
|
||||
// (\[.*\])?$ 可选尾部操作符 [>] [@] [len>] 等
|
||||
//
|
||||
// 示例:
|
||||
// profile["age"][>] → col=profile, keys=["age"], op="[>]", ok=true
|
||||
// profile['addr']['city'] → col=profile, keys=["addr","city"], op="", ok=true
|
||||
// profile["tags"][0][@] → col=profile, keys=["tags","0"], op="[@]", ok=true
|
||||
// profile[>] → ok=false(操作符括号,非 JSON 路径)
|
||||
func parseJSONPathKey(k string) (col string, pathKeys []string, opSuffix string, ok bool)
|
||||
```
|
||||
|
||||
**第二步:`varCond()` 开头最先调用**(`[db/where.go](db/where.go)`)
|
||||
|
||||
```go
|
||||
func (that *HoTimeDB) varCond(k string, v interface{}) (string, []interface{}) {
|
||||
// ★ 最优先:JSON 路径检测,完全绕过现有 [...] 分支
|
||||
if col, pathKeys, opSuffix, ok := parseJSONPathKey(k); ok {
|
||||
return that.jsonPathCond(col, pathKeys, opSuffix, v)
|
||||
}
|
||||
// 以下全部保持不变 ↓
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
`jsonPathCond` 根据 opSuffix 分发:
|
||||
|
||||
|
||||
| opSuffix | 动作 |
|
||||
| --------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| `""` / 现有操作符(`[>]`/`[<]`/`[>=]`/`[<=]`/`[!]`/`[~]`/`[!~]`/`[~!]`/`[<>]`/`[><]`/`[#]` 等) | 调用 `dialect.JSONExtract(col, pathKeys)` 得到提取表达式,替换原来的列名,复用现有比较逻辑 |
|
||||
| `[?]` | `JSONExtract(...) IS NOT NULL` |
|
||||
| `[!?]` | `JSONExtract(...) IS NULL` |
|
||||
| `[@]` | `dialect.JSONContains(col, pathKeys, "?")` |
|
||||
| `[!@]` | `NOT` + JSONContains |
|
||||
| `[len]` | `dialect.JSONArrayLength(col, pathKeys)` + `=?` |
|
||||
| `[len>]` / `[len<]` / `[len>=]` / `[len<=]` | JSONArrayLength + 对应比较符 |
|
||||
|
||||
|
||||
**第三步:扩展 `Dialect` 接口(`[db/dialect.go](db/dialect.go)`)**
|
||||
|
||||
新增 4 个方法,内部路径统一用 `[]string`(数字索引用字符串 `"0"`):
|
||||
|
||||
```go
|
||||
// MySQL: JSON_UNQUOTE(JSON_EXTRACT(`col`, '$.a.b'))
|
||||
// SQLite: json_extract("col", '$.a.b')
|
||||
// PgSQL: "col"#>>'{a,b}'
|
||||
JSONExtract(quotedColumn string, pathKeys []string) string
|
||||
|
||||
// MySQL: JSON_SET(`col`, '$.a.b', ?)
|
||||
// SQLite: json_set("col", '$.a.b', ?)
|
||||
// PgSQL: jsonb_set("col", '{a,b}', to_jsonb($N::text)) -- isStr=true
|
||||
// jsonb_set("col", '{a,b}', $N::jsonb) -- isStr=false
|
||||
JSONSet(quotedColumn string, pathKeys []string, placeholder string, isStr bool) string
|
||||
|
||||
// MySQL: JSON_CONTAINS(JSON_EXTRACT(`col`,'$.path'), JSON_QUOTE(?))
|
||||
// SQLite: EXISTS (SELECT 1 FROM json_each("col", '$.path') WHERE value=?)
|
||||
// PgSQL: "col"#>'{path}' @> to_jsonb($N::text)
|
||||
JSONContains(quotedColumn string, pathKeys []string, placeholder string) string
|
||||
|
||||
// MySQL: JSON_LENGTH(JSON_EXTRACT(`col`, '$.path'))
|
||||
// SQLite: json_array_length("col", '$.path')
|
||||
// PgSQL: jsonb_array_length("col"#>'{path}')
|
||||
JSONArrayLength(quotedColumn string, pathKeys []string) string
|
||||
```
|
||||
|
||||
**第四步:修复 UPDATE SET(`[db/crud.go:621](db/crud.go)`)**
|
||||
|
||||
在 `[#]` 检查之后、`ProcessColumnNoPrefix` 之前增加 JSON 路径检测:
|
||||
|
||||
```go
|
||||
} else if col, pathKeys, ok := parseJSONPathFromName(k); ok {
|
||||
quotedCol := processor.ProcessColumnNoPrefix(col)
|
||||
isStr := v != nil && reflect.TypeOf(v).Kind() == reflect.String
|
||||
ph := that.Dialect.Placeholder(len(qs) + 1)
|
||||
query += quotedCol + "=" + that.Dialect.JSONSet(quotedCol, pathKeys, ph, isStr)
|
||||
qs = append(qs, v)
|
||||
```
|
||||
|
||||
**第五步:修复 SELECT slice 字段(`[db/crud.go:101](db/crud.go)`)**
|
||||
|
||||
在 `Slice` 字段循环中,对每个字段先做 JSON 路径检测:
|
||||
|
||||
```go
|
||||
if col, pathKeys, ok := parseJSONPathFromName(stripAlias(k)); ok {
|
||||
alias := extractAlias(k)
|
||||
quotedCol := processor.ProcessColumnNoPrefix(col)
|
||||
query += " " + that.Dialect.JSONExtract(quotedCol, pathKeys) + alias + " "
|
||||
} else if strings.Contains(k, " AS ") || strings.Contains(k, ".") {
|
||||
query += " " + processor.ProcessFieldList(k) + " "
|
||||
} else {
|
||||
query += " " + processor.ProcessColumnNoPrefix(k) + " "
|
||||
}
|
||||
```
|
||||
|
||||
字符串字段(如 `"profile[\"age\"] AS age, name"`)暂不处理 JSON 路径,用户可用原有原始 SQL 写法。
|
||||
|
||||
## 支持的能力范围
|
||||
|
||||
- **WHERE 条件**:复用全部现有 14 种操作符(`=` / `!=` / `>` / `<` / `>=` / `<=` / `LIKE` / `IN` / `NOT IN` / `BETWEEN` / `IS NULL` 等)+ 新增 `[?]` / `[!?]` / `[@]` / `[!@]` / `[len]` / `[len>]` / `[len<]` / `[len>=]` / `[len<=]`
|
||||
- **UPDATE SET**:JSON 路径局部更新,原列其他字段保持不变
|
||||
- **SELECT slice 字段**:`Slice{profile["age"] AS score}` 支持 JSON 路径
|
||||
- **SELECT 字符串字段**:暂不处理,用户写原始 SQL 或 Slice 形式
|
||||
- **数组索引**:`["arr"][0]` → `$.arr[0]`
|
||||
- **多级嵌套**:任意深度
|
||||
- **引号兼容**:`["key"]` 和 `['key']` 两种写法完全等效
|
||||
- **原有格式零影响**:`user.name` / ``user`.name` / `"name,age"` / `Slice{"name","age"}` 全部不变
|
||||
|
||||
## 兼容性注意事项
|
||||
|
||||
|
||||
| 级别 | 问题 | 处理方式 |
|
||||
| --- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||
| 高 | **MySQL 必须 JSON_UNQUOTE**:`JSON_EXTRACT` 返回字符串含外层引号,直接 `= ?` 失败 | `MySQLDialect.JSONExtract()` 统一包 `JSON_UNQUOTE(JSON_EXTRACT(...))` |
|
||||
| 高 | **PostgreSQL 只支持 `jsonb` 列**:`jsonb_set` 不支持 `json` 类型列 | 文档约定:使用 JSON 路径操作的列需建为 `jsonb` 类型;或调用方在列名后加 `::jsonb` 强转 |
|
||||
| 高 | **SQLite `[@]` 生成 EXISTS 子查询**:结构与 MySQL/PgSQL 完全不同 | `SQLiteDialect.JSONContains()` 返回 `EXISTS(SELECT 1 FROM json_each(...) WHERE value=?)` 片段,在 `jsonPathCond` 里直接拼入 WHERE |
|
||||
| 中 | **PgSQL 更新时值的类型转换**:字符串需 `to_jsonb(?::text)`,数值/布尔用 `?::jsonb` | `JSONSet()` 接受 `isStr bool` 参数,由 `jsonPathCond` 根据 Go 值类型传入 |
|
||||
| 中 | **MySQL 版本**:JSON 函数需 5.7+,`JSON_OVERLAPS` 需 8.0+ | 先实现 5.7 兼容的基础操作符,`[!@]` 用 `NOT JSON_CONTAINS` 实现 |
|
||||
| 低 | **SQLite 版本**:需 3.9+,`go-sqlite3` 默认已启用 json1 扩展 | 无需特殊处理 |
|
||||
| 低 | **路径不存在返回 NULL**:三种数据库行为一致 | 调用方自行处理 NULL 判断,或用 `[?]` 操作符先做存在性检查 |
|
||||
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `[db/dialect.go](db/dialect.go)` — 接口扩展 + 三种方言实现(主要改动)
|
||||
- `[db/where.go](db/where.go)` — 新增 JSON 路径 key 检测与条件生成
|
||||
- `[db/crud.go](db/crud.go)` — UPDATE SET 子句 JSON 路径支持
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
---
|
||||
name: Logging System Redesign
|
||||
overview: Replace logrus with zerolog, remove mode config, redesign logLevel/logFile, simplify Error to pure storage, add DBState for atomic DB state access, and provide a clean application-layer logging API.
|
||||
todos:
|
||||
- id: add-zerolog-dep
|
||||
content: 添加 zerolog 依赖到 go.mod,移除 logrus(vendor 中 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.go(Error 类型不再需要)、新增 common/dbstate.go(DBState 包装 Query+Data+Error)、Logger 内置 ERROR 级别环形缓冲
|
||||
status: completed
|
||||
- id: update-application
|
||||
content: 改造 application.go:删除 mode 配置、Application.Log 类型变更、SetConfig 中日志初始化用 logLevel 控制一切、DB 缓存始终开启、Cache-Control 跟 logLevel 联动
|
||||
status: completed
|
||||
- id: update-db
|
||||
content: 改造 db/db.go 和 db/query.go:用 DBState 替换 LastQuery+LastData+LastErr、HoTimeDB.Log 类型变更、SQL 日志格式优化、消除重复打印
|
||||
status: completed
|
||||
- id: update-var-config
|
||||
content: 更新 var.go 默认配置(删 mode、logLevel 默认 3)和 ConfigNote、example 配置文件
|
||||
status: completed
|
||||
- id: update-codegen
|
||||
content: 适配 code/makecode.go 中的 Error 使用(移除 Logger 相关)
|
||||
status: completed
|
||||
- id: update-tests
|
||||
content: 适配 testing_helper.go 和 db/transaction_test.go 中的日志创建
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# HoTime 日志系统全面重写方案
|
||||
|
||||
## 一、现状问题分析
|
||||
|
||||
### 1. logrus 已不适合继续使用
|
||||
- logrus 已进入 **维护模式**,不再开发新功能,仅修复安全问题
|
||||
- 性能差:依赖 mutex 锁和反射,在高并发下显著慢于现代替代品
|
||||
- 当前使用版本 `v1.8.1`([go.mod](go.mod) line 13)
|
||||
|
||||
### 2. 现有日志配置形同虚设
|
||||
- `logLevel` 在 [var.go](var.go) line 59 有定义和注释,但 **从未在代码中接入 logrus**
|
||||
- `mode` 承担了过多职责(0/1/2/3 四种),但代码中实际只区分 `== 0` 和 `!= 0`,且 mode 2/3 无独立分支
|
||||
|
||||
### 3. Error 类型与日志耦合导致重复打印
|
||||
- `SetError` 内部隐式 `Warn` 打印 + `query.go` defer 再打一次 = **同一错误打印两次**
|
||||
- `LastQuery`/`LastData`/`LastErr` 用不同的锁保护,存在读到不一致状态的风险
|
||||
|
||||
---
|
||||
|
||||
## 二、日志库选型:zerolog
|
||||
|
||||
**推荐 [zerolog](https://github.com/rs/zerolog)**:
|
||||
- 零内存分配,性能远超 logrus(快 5-10 倍)
|
||||
- 内置 `Caller()`、`ConsoleWriter`(彩色)、`MultiLevelWriter`(多输出)
|
||||
- 无 Go 版本要求(go.mod 保持 `go 1.16`)
|
||||
- 不选 `log/slog`:需 Go 1.21+,升级成本太大
|
||||
|
||||
---
|
||||
|
||||
## 三、配置参数重新设计
|
||||
|
||||
### 删除 `mode`
|
||||
|
||||
`mode` 原来控制的行为全部转移:
|
||||
- **DB 缓存**:`mode` 的拦截是多余的,cache 本身已是"配置即启用"设计(`cache.memory.db`/`cache.redis.db` 等控制)。删除 `mode` 后 `SetCache` 中始终挂载 `that.Db.HoTimeCache`,是否真正缓存由 cache 配置决定
|
||||
- **Cache-Control**:跟 `logLevel` 联动 -- `logLevel > 0` 时设 `no-cache`,`logLevel == 0` 时 `public`
|
||||
- **SQL 日志打印**:完全由 `logLevel` 控制(`logLevel > 0` 时打印 SQL)
|
||||
- **代码生成**:本来就由 `codeConfig[].mode` 独立控制,不受影响
|
||||
|
||||
### `logLevel` -- 保留,真正接管日志系统
|
||||
|
||||
- `0` = 仅 error(生产环境推荐)
|
||||
- `>=1` = 全部打印(info/warn/error/debug/SQL 全开)
|
||||
- **默认值 `1`**
|
||||
|
||||
简单二分:要么只看错误,要么全看。Cache-Control 同理:`logLevel==0` 时 `public`,`>0` 时 `no-cache`。
|
||||
|
||||
### `logFile` -- 保留
|
||||
|
||||
路径模板不变(`a/b/c/20060102150405.txt`),应用日志写入此文件。
|
||||
|
||||
### `logHistory` -- 新增
|
||||
|
||||
Logger 错误历史缓冲条数,默认 `100`。设为 `0` 关闭历史记录。可根据设备内存情况调小。
|
||||
|
||||
### `webConnectLogShow` / `webConnectLogFile` -- 保留独立
|
||||
|
||||
保留访问日志独立的理由(业务角度):
|
||||
- 访问日志量远大于应用日志,混在一起文件膨胀、查找困难
|
||||
- 生产环境需要不同的轮转策略
|
||||
- Nginx/Apache 都是 access.log 和 error.log 分开的行业惯例
|
||||
|
||||
配置保持不变:
|
||||
- `webConnectLogShow`:默认 true
|
||||
- `webConnectLogFile`:独立文件路径
|
||||
|
||||
---
|
||||
|
||||
## 四、新日志包设计 (`log/`)
|
||||
|
||||
### 核心结构
|
||||
|
||||
```go
|
||||
package log
|
||||
|
||||
type Logger struct {
|
||||
zl zerolog.Logger
|
||||
}
|
||||
|
||||
func NewLogger(logLevel int, logFile string, showCaller bool) *Logger
|
||||
|
||||
func (l *Logger) Debug() *zerolog.Event
|
||||
func (l *Logger) Info() *zerolog.Event
|
||||
func (l *Logger) Warn() *zerolog.Event
|
||||
func (l *Logger) Error() *zerolog.Event
|
||||
|
||||
func (l *Logger) Debugf(format string, v ...interface{})
|
||||
func (l *Logger) Infof(format string, v ...interface{})
|
||||
func (l *Logger) Warnf(format string, v ...interface{})
|
||||
func (l *Logger) Errorf(format string, v ...interface{})
|
||||
```
|
||||
|
||||
### 控制台输出格式
|
||||
|
||||
```
|
||||
2026-04-12 15:04:05 |INFO| 用户登录成功 [app/admin/user.go:45]
|
||||
2026-04-12 15:04:05 |WARN| 数据库连接超时 [app/admin/user.go:78]
|
||||
2026-04-12 15:04:05 |ERROR| 支付回调失败 order_id=456 [app/payment/callback.go:32]
|
||||
```
|
||||
|
||||
格式:`时间 | 类型标志 | 日志内容 | [代码位置]`,用 `[file:line]` 而非 logrus 的 `line="file:line"`。
|
||||
|
||||
### 文件输出格式(JSON)
|
||||
|
||||
```json
|
||||
{"time":"2026-04-12T15:04:05+08:00","level":"info","caller":"app/admin/user.go:45","message":"用户登录成功"}
|
||||
```
|
||||
|
||||
### 调用者智能过滤
|
||||
|
||||
复用并优化现有 [log/logrus.go](log/logrus.go) 中 `findCaller` / `isHoTimeFrameworkFile` 逻辑,实现为 zerolog 的 `CallerMarshalFunc`。
|
||||
|
||||
### 文件写入优化
|
||||
|
||||
`TemplateFileWriter`:带缓冲的 Writer,按时间模板自动切换文件路径,保持 `time.Now().Format(path)` 语义。
|
||||
|
||||
---
|
||||
|
||||
## 五、删除 Error 类型 + DBState + Logger 错误历史
|
||||
|
||||
### 删除 common.Error
|
||||
|
||||
`common.Error` 完全删除,不再需要。原因:
|
||||
|
||||
- Error 存在的意义是"存错误 + 自动打印",打印已移到 Log,只剩存储
|
||||
- 审查全部用法后,每个 `SetError` 调用点都可以直接用 `Log.Error()` 替代
|
||||
- `InitDb` 返回 `*Error` 从未被使用(调用方都是 `_ = that.InitDb(...)`)
|
||||
- `ConnectFunc(err ...*Error)` 可简化为返回 error
|
||||
- 需要"查最近的错误"由 Logger 错误历史替代
|
||||
|
||||
### Logger 错误历史(环形缓冲)
|
||||
|
||||
Logger 内置 ERROR 级别记录的环形缓冲(默认 100 条),打印即存储,零额外操作:
|
||||
|
||||
```go
|
||||
type ErrorRecord struct {
|
||||
Err error
|
||||
Msg string
|
||||
Time time.Time
|
||||
Caller string
|
||||
}
|
||||
|
||||
// Logger 内部
|
||||
type Logger struct {
|
||||
zl zerolog.Logger
|
||||
errors []ErrorRecord // 环形缓冲
|
||||
errorsMu sync.RWMutex
|
||||
maxErrors int // 默认 100
|
||||
}
|
||||
|
||||
// 每次 Log.Error() 自动存入缓冲
|
||||
// 获取最近 N 条错误(不传则返回全部已存储的,不超过 maxErrors)
|
||||
func (l *Logger) GetRecentErrors(n ...int) []ErrorRecord
|
||||
```
|
||||
|
||||
缓冲大小通过配置项 `logHistory` 控制,默认 100,可根据设备内存调整。
|
||||
|
||||
应用层使用:
|
||||
```go
|
||||
// 正常打印,自动存入历史
|
||||
that.Log.Error().Err(err).Msg("支付失败")
|
||||
|
||||
// 获取最近 10 条错误
|
||||
for _, e := range that.Log.GetRecentErrors(10) {
|
||||
fmt.Printf("[%s] %s: %v %s\n", e.Time.Format("15:04:05"), e.Msg, e.Err, e.Caller)
|
||||
}
|
||||
|
||||
// 获取全部已存储的错误
|
||||
all := that.Log.GetRecentErrors()
|
||||
```
|
||||
|
||||
### DBState -- DB 操作状态原子包装(新增)
|
||||
|
||||
新增 [common/dbstate.go](common/dbstate.go),将 `LastQuery` + `LastData` + `LastErr` 合为一体,一把锁保护原子读写。
|
||||
|
||||
**核心语义**:记录最后一次 DB 操作的完整状态。每次 SQL 执行后更新,用于判断"SQL 执行失败"还是"执行成功但结果为空"(如 UPDATE 影响 0 行)。
|
||||
|
||||
```go
|
||||
// DBSnapshot 操作状态快照(只读,安全传递)
|
||||
type DBSnapshot struct {
|
||||
Query string
|
||||
Data []interface{}
|
||||
Err error
|
||||
}
|
||||
|
||||
type DBState struct {
|
||||
query string
|
||||
data []interface{}
|
||||
err error
|
||||
mu sync.RWMutex // 零值即可用
|
||||
}
|
||||
|
||||
// Set 原子更新完整状态(每次 Query/Exec 执行后调用)
|
||||
func (s *DBState) Set(query string, data []interface{}, err error) {
|
||||
s.mu.Lock()
|
||||
s.query = query
|
||||
s.data = data
|
||||
s.err = err
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Get 返回一致性快照(query/data/err 保证是同一次操作的)
|
||||
func (s *DBState) Get() DBSnapshot {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return DBSnapshot{Query: s.query, Data: s.data, Err: s.err}
|
||||
}
|
||||
|
||||
// SetError 只更新错误(Ping 等无 SQL 的场景)
|
||||
func (s *DBState) SetError(err error) {
|
||||
s.mu.Lock()
|
||||
s.err = err
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// GetError 快速判断最后操作是否出错
|
||||
func (s *DBState) GetError() error {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.err
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**使用场景**:
|
||||
```go
|
||||
db.Exec("UPDATE stats SET count=count+1 WHERE id=?", id)
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
if state := db.GetLastState(); state != nil {
|
||||
// SQL 执行失败,state.Err 有具体错误
|
||||
} else {
|
||||
// SQL 成功,只是没有匹配的行
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### HoTimeDB 结构体变更
|
||||
|
||||
`HoTimeDB` 中原来的三个独立字段合并为私有 `lastState`,通过方法暴露,同时删除 `Mode`:
|
||||
|
||||
```go
|
||||
type HoTimeDB struct {
|
||||
*sql.DB
|
||||
// ...
|
||||
Log *log.Logger // 从 *logrus.Logger 改为新 Logger
|
||||
lastState DBState // 私有,替换 LastQuery + LastData + LastErr
|
||||
// 删除: Mode int
|
||||
// 删除: LastQuery string
|
||||
// 删除: LastData []interface{}
|
||||
// 删除: LastErr *Error
|
||||
}
|
||||
|
||||
// 唯一对外方法:nil = 成功,非 nil = 失败(附带完整上下文)
|
||||
func (db *HoTimeDB) GetLastState() *DBSnapshot
|
||||
```
|
||||
|
||||
内部逻辑:`lastState` 始终记录 query/data/err(供日志使用),但 `GetLastState()` 只在有错误时返回非 nil:
|
||||
```go
|
||||
func (db *HoTimeDB) GetLastState() *DBSnapshot {
|
||||
db.lastState.mu.RLock()
|
||||
defer db.lastState.mu.RUnlock()
|
||||
if db.lastState.err == nil {
|
||||
return nil // 没错误,不需要关心细节
|
||||
}
|
||||
return &DBSnapshot{
|
||||
Query: db.lastState.query,
|
||||
Data: db.lastState.data,
|
||||
Err: db.lastState.err,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
应用层使用:
|
||||
```go
|
||||
// 一行判断 + 取详情
|
||||
if state := db.GetLastState(); state != nil {
|
||||
// SQL 出错了,state.Err / state.Query / state.Data 全有
|
||||
that.Log.Error().Err(state.Err).Str("sql", state.Query).Msg("SQL 执行失败")
|
||||
}
|
||||
// nil = SQL 执行成功,不用管
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、DB 日志格式优化
|
||||
|
||||
### 修复重复打印
|
||||
|
||||
`SetError` 不再打印日志,`query.go` defer 统一负责 SQL 日志 -- 问题根除。
|
||||
|
||||
### 输出格式
|
||||
|
||||
**无错误时(DEBUG 级别):**
|
||||
```
|
||||
2026-04-12 15:04:05 |DEBUG| SQL: SELECT id,worker_id FROM `dingtalk` WHERE `state`=? DATA: [0] [dd/sync.go:63]
|
||||
```
|
||||
|
||||
**有错误时(自动升为 ERROR 级别):**
|
||||
```
|
||||
2026-04-12 15:04:05 |ERROR| SQL: SELECT id,worker_id FROM `dingtalk` WHERE `state`=? DATA: [0] ERROR: connection refused [dd/sync.go:63]
|
||||
```
|
||||
|
||||
关键改动:
|
||||
- 无错误时不显示 `ERROR:<nil>`
|
||||
- 有错误时日志级别自动升为 ERROR(而非一律 INFO)
|
||||
- SQL 日志在 `logLevel > 0` 时输出,`logLevel == 0` 时只有 SQL 出错才打印
|
||||
|
||||
### 测试模式下 SQL 错误自动 fail
|
||||
|
||||
测试框架中 SQL 出错让测试用例直接失败报错。
|
||||
|
||||
---
|
||||
|
||||
## 七、需要修改的文件清单
|
||||
|
||||
### 核心改动
|
||||
|
||||
- [log/logrus.go](log/logrus.go) -- 全部重写为 zerolog 实现,重命名为 `log/logger.go`
|
||||
- [common/error.go](common/error.go) -- **删除整个文件**
|
||||
- 新增 [common/dbstate.go](common/dbstate.go) -- DBState + DBSnapshot 类型
|
||||
- [application.go](application.go) -- 删除 `Error` 嵌入和所有 `Error{}` 初始化、删除 mode 逻辑、`Application.Log` 类型变更、`SetCache` 去掉 mode 判断、Cache-Control 改跟 logLevel、删除 `Db.Mode` 赋值(3 处)、`ConnectFunc` 签名简化、logLevel 接入
|
||||
- [var.go](var.go) -- 删除 `"mode": 2` 默认值、ConfigNote 删除 mode 说明、logLevel 默认值改为 3 并更新说明
|
||||
- [db/db.go](db/db.go) -- 删除 `Mode int` / `LastQuery` / `LastData` / `LastErr` 字段,改为 `LastState DBState` + `Log *log.Logger`,移除 logrus import
|
||||
- [db/query.go](db/query.go) -- 用 `LastState.Set()` 原子写入替换分散的赋值、SQL 日志格式优化(无错不显示 ERROR、有错升 ERROR 级别)、`Mode != 0` 改为 Logger 级别过滤
|
||||
- [go.mod](go.mod) -- 添加 zerolog 依赖
|
||||
|
||||
### 适配改动
|
||||
|
||||
- [db/crud.go](db/crud.go) -- `that.LastErr.SetError(e)` 改为 `that.lastState.SetError(e)`
|
||||
- [db/transaction.go](db/transaction.go) -- 事务拷贝中 `LastQuery`/`LastData`/`LastErr` 改为 `lastState`
|
||||
- [db/transaction_test.go](db/transaction_test.go) -- 删除 `Error{Logger: logger}`、logrus 替换
|
||||
- [code/makecode.go](code/makecode.go) -- 删除嵌入的 `Error`,改用 `*Logger` 打印错误
|
||||
- [cache/cache.go](cache/cache.go) -- `Init` 参数删除 `*Error`,改传 `*Logger`
|
||||
- [cache/cache_redis.go](cache/cache_redis.go) -- `that.Error.SetError(err)` 全部改为 `that.Log.Error().Err(err).Msg(...)`
|
||||
- [testing_helper.go](testing_helper.go) -- `app.Db.Mode` 删除、Logger 创建适配
|
||||
- [context.go](context.go) -- `that.Error.SetError(...)` 改为 `that.Log.Error().Msg(...)`
|
||||
- [example/app/test.go](example/app/test.go) -- `that.Db.LastQuery` 改为 `that.Db.GetLastState()` 相关访问
|
||||
- [example/app/mysql.go](example/app/mysql.go) -- 同上
|
||||
- [example/batch_cache_tester.go](example/batch_cache_tester.go) -- `&Error{}` 删除
|
||||
- [example/config/configNote.json](example/config/configNote.json) -- 删除 mode 说明、更新 logLevel 说明
|
||||
- [example/config/config.json](example/config/config.json) -- 删除 mode
|
||||
|
||||
---
|
||||
|
||||
## 八、应用层使用示例
|
||||
|
||||
```go
|
||||
// 基本使用
|
||||
that.Log.Infof("用户 %s 登录成功", name)
|
||||
that.Log.Errorf("订单 %d 支付失败: %v", orderId, err)
|
||||
|
||||
// 链式调用(附加结构化字段)
|
||||
that.Log.Info().Str("user", name).Int("age", 25).Msg("用户登录成功")
|
||||
that.Log.Error().Err(err).Str("order", orderId).Msg("支付回调失败")
|
||||
|
||||
// 打印即存储(Log.Error 自动存入错误历史)
|
||||
that.Log.Error().Err(err).Msg("数据库连接失败")
|
||||
|
||||
// DB 状态:nil = 成功,非 nil = 失败
|
||||
if state := that.Db.GetLastState(); state != nil {
|
||||
that.Log.Error().Err(state.Err).Str("sql", state.Query).Msg("SQL 失败")
|
||||
}
|
||||
|
||||
// 调阅最近 5 条错误(调试用,包括 DB 错误在内的所有 ERROR 级别日志)
|
||||
errors := that.Log.GetRecentErrors(5)
|
||||
|
||||
// SQL 日志由框架自动处理,logLevel>0 时输出
|
||||
// 无错误: |INFO| SQL: SELECT... DATA: [1,2] [app/user.go:45]
|
||||
// 有错误: |ERROR| SQL: SELECT... DATA: [1,2] ERROR: xxx [app/user.go:45]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、向后兼容与影响范围
|
||||
|
||||
### 编译级 breaking changes
|
||||
|
||||
- `common.Error` 类型完全删除:所有嵌入 `Error` 或传递 `*Error` 的地方改为 `*Logger` 或 `error`
|
||||
- `Application.Log` 从 `*logrus.Logger` 改为 `*log.Logger`
|
||||
- `Application.Error` 字段删除
|
||||
- `ConnectFunc` 签名简化(不再传递 `*Error`)
|
||||
- `HoTimeDB.LastQuery`/`LastData`/`LastErr`/`Mode` 删除:
|
||||
- 应用层通过 `db.GetLastState()` 获取(nil=成功)
|
||||
- 框架内部通过 `db.lastState.SetError(e)` 更新
|
||||
- 删除 `mode` 配置
|
||||
|
||||
### 不受影响
|
||||
|
||||
- `codeConfig[].mode`(代码生成模式)完全不受影响
|
||||
- `modeRouterStrict`(路由严格模式)完全不受影响
|
||||
- `cache` 配置结构不受影响(`cache.db.mode` 等是缓存自己的 mode,与全局 mode 无关)
|
||||
- `dri/mongodb` 的 `LastErr error` 是独立的普通 error,不受影响
|
||||
@@ -0,0 +1,305 @@
|
||||
---
|
||||
name: objtoobj 问题修正
|
||||
overview: 对 objtoobj.go 中发现的 3 个运行时 panic bug、2 个错误处理 bug、多处性能问题进行修正,同时新增销量方案所需的 ObjToRoundFloat64 函数。
|
||||
todos:
|
||||
- id: fix-uint8-panics
|
||||
content: 修复 ObjToFloat64、ObjToInt64、ObjToStr 三处 uint8 分支的 panic bug
|
||||
status: completed
|
||||
- id: fix-objtomap-shadow
|
||||
content: 修复 ObjToMap default 分支变量遮蔽和赋值逻辑错误
|
||||
status: completed
|
||||
- id: fix-objtoslice-default
|
||||
content: 修复 ObjToSlice default 分支 Marshal 错误被覆盖的问题
|
||||
status: completed
|
||||
- id: fix-objtobool-error
|
||||
content: 修复 ObjToBool 成功转换时仍报错的问题
|
||||
status: completed
|
||||
- id: fix-objtotime-padding
|
||||
content: 修复 ObjToTime 单位数日+时间组合时补零失败的 bug
|
||||
status: completed
|
||||
- id: fix-objtoceilint64-double
|
||||
content: 修复 ObjToCeilInt64 双重 Ceil 和多余 ObjToInt64 调用
|
||||
status: completed
|
||||
- id: perf-type-switch-idiom
|
||||
content: 所有函数改用 switch v := obj.(type) 惯用写法,消除重复类型断言
|
||||
status: completed
|
||||
- id: perf-objtotime
|
||||
content: ObjToTime 中 ObjToStr(tInt) 只调用一次并缓存
|
||||
status: completed
|
||||
- id: perf-objtoslice-string
|
||||
content: ObjToSlice []string 分支类型断言只做一次
|
||||
status: completed
|
||||
- id: perf-objtostr-types
|
||||
content: ObjToStr 补充 float32、bool 等常见类型的直接转换
|
||||
status: completed
|
||||
- id: perf-objtomaparray-cap
|
||||
content: ObjToMapArray 预分配切片容量
|
||||
status: completed
|
||||
- id: add-roundfloat64
|
||||
content: 新增 ObjToRoundFloat64,并在 map.go、obj.go 中新增对应 Get/To 方法
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# objtoobj.go 问题审查与修正计划
|
||||
|
||||
修改文件:[common/objtoobj.go](d:\work\hotimev1.5\common\objtoobj.go)
|
||||
|
||||
---
|
||||
|
||||
## 一、严重 Bug(uint8 vs []byte 类型混淆,可能 panic)
|
||||
|
||||
开发者原意是处理数据库 driver 返回的 `[]uint8`(即 `[]byte`),但 Go 中 `uint8`(单字节)和 `[]uint8`/`[]byte`(字节数组)是完全不同的类型,type switch 不会互相匹配。
|
||||
|
||||
当前 `Row()` 方法(query.go:380)已经用 reflect 将 `[]uint8` 转为 `string`,所以正常 DB 流程不会触发这些分支。但其他非 DB 数据源可能传入 `[]byte`,此时会走 `default` 分支而非预期的 `uint8` 分支。
|
||||
|
||||
### Bug 1: `ObjToFloat64` — `case uint8` 应为 `case []byte`
|
||||
|
||||
```208:215:d:\work\hotimev1.5\common\objtoobj.go
|
||||
case uint8:
|
||||
value, e := strconv.ParseFloat(obj.(string), 64)
|
||||
```
|
||||
|
||||
**修正**:改为 `case []byte`,用 `string(v)` 转换后再 ParseFloat:
|
||||
|
||||
```go
|
||||
case []byte:
|
||||
value, e := strconv.ParseFloat(string(obj.([]byte)), 64)
|
||||
if e != nil {
|
||||
v = float64(0)
|
||||
err = e
|
||||
} else {
|
||||
v = value
|
||||
}
|
||||
```
|
||||
|
||||
### Bug 2: `ObjToInt64` — `case uint8` 应为 `case []byte`
|
||||
|
||||
```279:286:d:\work\hotimev1.5\common\objtoobj.go
|
||||
case uint8:
|
||||
value, e := StrToInt(obj.(string))
|
||||
```
|
||||
|
||||
**修正**:改为 `case []byte`,用 `string(v)` 转换后再 StrToInt:
|
||||
|
||||
```go
|
||||
case []byte:
|
||||
value, e := StrToInt(string(obj.([]byte)))
|
||||
if e != nil {
|
||||
v = int64(0)
|
||||
err = e
|
||||
} else {
|
||||
v = int64(value)
|
||||
}
|
||||
```
|
||||
|
||||
### Bug 3: `ObjToStr` — `case uint8` 应改为正确处理单字节
|
||||
|
||||
```341:342:d:\work\hotimev1.5\common\objtoobj.go
|
||||
case uint8:
|
||||
str = obj.(string)
|
||||
```
|
||||
|
||||
`ObjToStr` 已有 `case []byte` 分支正确处理字节数组。这里的 `case uint8` 应正确处理单字节值:
|
||||
|
||||
**修正**:改为 `str = strconv.Itoa(int(obj.(uint8)))`
|
||||
|
||||
> 这三处 bug 目前未爆发,因为 `Row()` 已将 `[]uint8` 转为 `string`。但修正后能覆盖非 DB 数据源直接传入 `[]byte` 的场景,更加健壮。
|
||||
|
||||
---
|
||||
|
||||
## 二、逻辑 Bug
|
||||
|
||||
### Bug 4: `ObjToMap` default 分支 — 变量遮蔽导致错误丢失
|
||||
|
||||
```33:44:d:\work\hotimev1.5\common\objtoobj.go
|
||||
default:
|
||||
data, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
err = errors.New("没有合适的转换对象!" + err.Error())
|
||||
v = nil
|
||||
}
|
||||
v = Map{}
|
||||
e := json.Unmarshal(data, &v)
|
||||
```
|
||||
|
||||
第 34 行的 `data, err := json.Marshal(obj)` 使用了 `:=`,创建了一个**新的局部变量 `err`**,遮蔽了第 14 行声明的外部 `err`。这意味着:
|
||||
|
||||
- Marshal 失败时,外部 `err` 仍然是 nil,错误信息丢失
|
||||
- 第 38 行设 `v = nil`,但第 39 行又立刻把 `v` 重新赋值为 `Map{}`,然后用坏数据尝试 Unmarshal
|
||||
|
||||
**修正**:用 `=` 代替 `:=`,并在 Marshal 失败时 break。
|
||||
|
||||
### Bug 5: `ObjToSlice` default 分支 — Marshal 错误被覆盖
|
||||
|
||||
```86:91:d:\work\hotimev1.5\common\objtoobj.go
|
||||
default:
|
||||
v = Slice{}
|
||||
var data []byte
|
||||
data, err = json.Marshal(obj)
|
||||
err = json.Unmarshal(data, &v)
|
||||
```
|
||||
|
||||
第 89 行 `err = json.Marshal(obj)` 即使失败,第 90 行也立刻用 `err = json.Unmarshal(data, &v)` 覆盖了前一个错误。当 Marshal 失败时 `data` 为空,Unmarshal 也会失败,但丢失了原始的错误信息。
|
||||
|
||||
**修正**:Marshal 失败时直接 break,不再尝试 Unmarshal。
|
||||
|
||||
### Bug 6: `ObjToBool` — 成功转换时也报错
|
||||
|
||||
```307:330:d:\work\hotimev1.5\common\objtoobj.go
|
||||
default:
|
||||
toInt := ObjToInt(obj)
|
||||
if toInt != 0 {
|
||||
v = true
|
||||
}
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
```
|
||||
|
||||
当传入 `1`(int 类型)时,`ObjToInt` 成功返回 1,`v = true`,但仍然设置了 `err`。逻辑上这不应该报错。
|
||||
|
||||
**修正**:只在 `ObjToInt` 也无法转换时才报错。
|
||||
|
||||
---
|
||||
|
||||
### Bug 7: `ObjToTime` 日期补零逻辑缺陷
|
||||
|
||||
```108:121:d:\work\hotimev1.5\common\objtoobj.go
|
||||
timeNewStrs := strings.Split(tStr, "-")
|
||||
for _, v := range timeNewStrs {
|
||||
if len(v) == 1 {
|
||||
v = "0" + v
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
按 `"-"` 分割 `"2006-1-2 15:04:05"` 得到 `["2006", "1", "2 15:04:05"]`。第三段 `"2 15:04:05"` 长度 > 1 不会补零,最终得到 `"2006-01-2 15:04:05"`,`time.Parse` 会失败。
|
||||
|
||||
**修正**:分割前先按空格分离日期和时间部分,分别处理后再拼接。或者改用 `strings.TrimLeft` 对每段的纯日期部分补零。
|
||||
|
||||
### Bug 8: `ObjToCeilInt64` 双重 Ceil + 多余 ObjToInt64
|
||||
|
||||
```238:241:d:\work\hotimev1.5\common\objtoobj.go
|
||||
func ObjToCeilInt64(obj interface{}, e ...*Error) int64 {
|
||||
f := ObjToCeilFloat64(obj, e...) // 内部已 math.Ceil
|
||||
return ObjToInt64(math.Ceil(f)) // 又 Ceil + 走完整 type switch
|
||||
}
|
||||
```
|
||||
|
||||
**修正**:改为 `return int64(f)`,因为 `ObjToCeilFloat64` 已经返回了 Ceil 后的值。
|
||||
|
||||
---
|
||||
|
||||
## 三、性能问题
|
||||
|
||||
### P1: `ObjToTime` 重复调用 `ObjToStr(tInt)` 多达 5 次
|
||||
|
||||
```152:177:d:\work\hotimev1.5\common\objtoobj.go
|
||||
if len(ObjToStr(tInt)) > 16 {
|
||||
// ...
|
||||
} else if len(ObjToStr(tInt)) > 13 {
|
||||
// ...
|
||||
} else if len(ObjToStr(tInt)) > 10 {
|
||||
// ...
|
||||
} else if len(ObjToStr(tInt)) > 9 {
|
||||
// ...
|
||||
} else if len(ObjToStr(tInt)) > 3 {
|
||||
t, e := time.Parse("2006", ObjToStr(tInt))
|
||||
```
|
||||
|
||||
每次 `ObjToStr(tInt)` 都会执行 `strconv.FormatInt` + 分配新字符串。应在循环外只调用一次,存入变量复用。
|
||||
|
||||
### P2: `ObjToSlice` 的 `[]string` 分支重复类型断言
|
||||
|
||||
```78:81:d:\work\hotimev1.5\common\objtoobj.go
|
||||
case []string:
|
||||
v = Slice{}
|
||||
for i := 0; i < len(obj.([]string)); i++ {
|
||||
v = append(v, obj.([]string)[i])
|
||||
}
|
||||
```
|
||||
|
||||
循环中每次 `obj.([]string)` 都做了类型断言。应先断言一次存入变量:`ss := obj.([]string)`。
|
||||
|
||||
### P3: `ObjToStr` 缺少 `float32`、`bool` 等常见类型直接转换
|
||||
|
||||
`float32`、`bool`、`int32` 等类型会走 `default` 分支用 `json.MarshalIndent`,比直接转换慢 100 倍以上。
|
||||
|
||||
**修正**:补充常见类型的 case:
|
||||
|
||||
```go
|
||||
case float32:
|
||||
str = strconv.FormatFloat(float64(obj.(float32)), 'f', -1, 32)
|
||||
case bool:
|
||||
str = strconv.FormatBool(obj.(bool))
|
||||
```
|
||||
|
||||
### P4: `ObjToStr` default 分支使用 `json.MarshalIndent`
|
||||
|
||||
`MarshalIndent` 比 `Marshal` 慢 20-30%,且产生更大的输出。但由于此改动可能影响现有依赖格式化输出的调用方,标记为**可选优化**。
|
||||
|
||||
### P5: 所有函数应改用 `switch v := obj.(type)` 惯用写法
|
||||
|
||||
当前每个 case 都重复做类型断言(如 `obj.(int)`),改为 Go 惯用写法可消除所有冗余断言:
|
||||
|
||||
```go
|
||||
// 改前
|
||||
switch obj.(type) {
|
||||
case int:
|
||||
v = int64(obj.(int))
|
||||
// 改后
|
||||
switch val := obj.(type) {
|
||||
case int:
|
||||
v = int64(val)
|
||||
```
|
||||
|
||||
### P6: `ObjToMapArray` 未预分配切片容量
|
||||
|
||||
`res := []Map{}` 应改为 `res := make([]Map, 0, len(s))`。
|
||||
|
||||
### P7: `StrToMap` / `StrToSlice` 参数名 `string` 遮蔽内置类型
|
||||
|
||||
参数名应改为 `s` 或 `jsonStr`,避免遮蔽 Go 内置 `string` 类型。
|
||||
|
||||
---
|
||||
|
||||
## 四、结合销量方案需新增的函数
|
||||
|
||||
根据[销量支持小数方案](d:\work\your-app.cursor\plans\销量支持小数方案_92094ec9.plan.md)第二步要求,需在本文件新增 `ObjToRoundFloat64`:
|
||||
|
||||
```go
|
||||
func ObjToRoundFloat64(obj interface{}, precision int, e ...*Error) float64 {
|
||||
f := ObjToFloat64(obj, e...)
|
||||
if precision < 0 {
|
||||
return f
|
||||
}
|
||||
pow := math.Pow10(precision)
|
||||
return math.Round(f*pow) / pow
|
||||
}
|
||||
```
|
||||
|
||||
同时需在 [common/map.go](d:\work\hotimev1.5\common\map.go) 新增 `GetRoundFloat64`,在 [common/obj.go](d:\work\hotimev1.5\common\obj.go) 新增 `ToRoundFloat64`。
|
||||
|
||||
---
|
||||
|
||||
## 五、修正摘要
|
||||
|
||||
|
||||
| # | 类型 | 位置 | 问题 | 修正 |
|
||||
| --- | ---- | ------------------- | ------------------------- | ---------------------------- |
|
||||
| 1 | 类型混淆 | ObjToFloat64 uint8 | case uint8 应为 case []byte | 改为 case []byte + string() 解析 |
|
||||
| 2 | 类型混淆 | ObjToInt64 uint8 | case uint8 应为 case []byte | 改为 case []byte + string() 解析 |
|
||||
| 3 | 类型混淆 | ObjToStr uint8 | uint8 断言为 string 会 panic | 改为 strconv.Itoa(int(...)) |
|
||||
| 4 | 逻辑 | ObjToMap default | := 遮蔽外部 err,v 赋值逻辑错乱 | 用 = 并加 break |
|
||||
| 5 | 逻辑 | ObjToSlice default | Marshal 错误被 Unmarshal 覆盖 | 失败时 break |
|
||||
| 6 | 逻辑 | ObjToBool default | 成功转换也报错 | 转换成功不报错 |
|
||||
| 7 | 逻辑 | ObjToTime 日期补零 | 单位数日+时间时补不到零 | 分离日期时间部分再补零 |
|
||||
| 8 | 逻辑 | ObjToCeilInt64 | 双重 Ceil + 多余 type switch | 改为 int64(f) |
|
||||
| 9 | 性能 | ObjToTime | ObjToStr 重复调用 5 次 | 缓存结果 |
|
||||
| 10 | 性能 | ObjToSlice []string | 循环内重复类型断言 | 断言一次复用 |
|
||||
| 11 | 性能 | 所有函数 | 未用 switch v := obj.(type) | 改用惯用写法消除冗余断言 |
|
||||
| 12 | 性能 | ObjToStr | 缺少 float32/bool 等常见类型 | 补充直接转换 case |
|
||||
| 13 | 性能 | ObjToMapArray | 切片未预分配容量 | make([]Map, 0, len(s)) |
|
||||
| 14 | 规范 | StrToMap/StrToSlice | 参数名 string 遮蔽内置类型 | 改为 s 或 jsonStr |
|
||||
| 15 | 新增 | ObjToRoundFloat64 | 销量方案需要 | 新增函数 |
|
||||
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
---
|
||||
name: Row方法方案审查与修正
|
||||
overview: 修改 4 个文件:common/objtoobj.go(新增 JsonToObj + 增强 ObjToMap/ObjToSlice)、common/map.go(增强 JsonToMap)、db/query.go(Row 重写)、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` |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 文件 1:common/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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 增强 ObjToMap(2 处 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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 增强 ObjToSlice(2 处 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("没有合适的转换对象!")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 文件 2:common/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)` → **直接修复**
|
||||
|
||||
---
|
||||
|
||||
## 文件 3:db/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`,其余不变
|
||||
|
||||
---
|
||||
|
||||
## 文件 4:cache/cache_db.go -- DB 缓存
|
||||
|
||||
3 处 `json.Unmarshal` 改用 `JsonToObj`(来自 common 包,已通过 `. "code.hoteas.com/golang/hotime/common"` 导入):
|
||||
|
||||
**位置 1** -- `getLegacy()` 约第 359-363 行:
|
||||
|
||||
```go
|
||||
// 修改前:
|
||||
var data interface{}
|
||||
err := json.Unmarshal([]byte(valueStr), &data)
|
||||
|
||||
// 修改后:
|
||||
data, err := JsonToObj(valueStr)
|
||||
```
|
||||
|
||||
**位置 2** -- `get()` 约第 411-416 行:
|
||||
|
||||
```go
|
||||
// 修改前:
|
||||
var data interface{}
|
||||
err := json.Unmarshal([]byte(valueStr), &data)
|
||||
|
||||
// 修改后:
|
||||
data, err := JsonToObj(valueStr)
|
||||
```
|
||||
|
||||
**位置 3** -- `CachesGet()` 约第 594-598 行:
|
||||
|
||||
```go
|
||||
// 修改前:
|
||||
var data interface{}
|
||||
err := json.Unmarshal([]byte(valueStr), &data)
|
||||
|
||||
// 修改后:
|
||||
data, err := JsonToObj(valueStr)
|
||||
```
|
||||
|
||||
不需要新增 import(`JsonToObj` 在 common 包,cache_db.go 已 import)。
|
||||
|
||||
---
|
||||
|
||||
## 修正效果总结
|
||||
|
||||
|
||||
| 数据路径 | 修正前 | 修正后 |
|
||||
| ---------------------- | ------------------------------------- | ------------------------------------------------------ |
|
||||
| DB → Row() → Map | float32/float64 原样存入,[]byte 全转 string | 按列类型精确转换:INT→int64, DECIMAL→roundFloat, FLOAT→fixFloat |
|
||||
| DB 缓存 → json.Unmarshal | 所有数字→float64 | JsonToObj:整数→int64, 小数→float64 |
|
||||
| ObjToMap(string) | json.Unmarshal 数字全→float64 | JsonToObj:保留 int64/float64 |
|
||||
| ObjToSlice(string) | json.Unmarshal 数字全→float64 | JsonToObj:保留 int64/float64 |
|
||||
| StrToMap(string) | JsonToMap → json.Unmarshal | JsonToMap → UseNumber + fixJsonNumbers |
|
||||
| Map.JsonToMap(string) | json.Unmarshal | UseNumber + fixJsonNumbers |
|
||||
| Redis 缓存 | ObjToStr → string → ObjToMap | ObjToMap 已增强,**自动修复** |
|
||||
|
||||
|
||||
Redis 缓存路径:`set` 用 `ObjToStr` → `get` 返回 string → 调用方用 `ObjToMap` 转回 Map。由于 `ObjToMap` 的 string 分支现在使用 `JsonToObj`,Redis 缓存也**自动获得类型保留**,无需单独改 cache_redis.go。
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
name: Verify统一断言改造
|
||||
overview: 在 Api 上新增 Resp() 方法让 Verify 回调内可以同时做响应值断言和数据库校验,然后更新文档将统一的 Verify 模式作为推荐范式。
|
||||
todos:
|
||||
- id: add-resp-method
|
||||
content: "testing_api.go: Api 新增 lastResp 字段 + Resp() 方法,execute 中调 verifyFn 前设置 lastResp"
|
||||
status: completed
|
||||
- id: update-doc
|
||||
content: "docs/Testing_API测试框架.md: 将 Verify 统一断言作为推荐范式,resp 外部断言降为兼容用法"
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# Verify 统一断言改造
|
||||
|
||||
## 问题
|
||||
|
||||
当前 `Verify(func(a *Api) error)` 的回调只能通过 `a.DB()` 做数据库校验,无法访问响应体。导致值断言必须在外部用 `resp.GetBody()` + `resp.Fail()` 完成,逻辑分散在两处。
|
||||
|
||||
## 方案:Api 上新增 Resp() 方法
|
||||
|
||||
在 `execute` 调用 `verifyFn` 之前,把 `resp` 存到 `Api` 上,然后通过 `a.Resp()` 暴露给回调。
|
||||
|
||||
### 代码改动([testing_api.go](testing_api.go))
|
||||
|
||||
**1. Api 结构体新增字段**(第 46-51 行附近):
|
||||
|
||||
```go
|
||||
type Api struct {
|
||||
app *TestApp
|
||||
path string
|
||||
t *testing.T
|
||||
session Map
|
||||
lastResp *ApiResponse // 新增:最近一次请求的响应
|
||||
}
|
||||
```
|
||||
|
||||
**2. 新增 Resp() 方法**:
|
||||
|
||||
```go
|
||||
// Resp 获取最近一次请求的响应(在 Verify 回调中使用)
|
||||
func (a *Api) Resp() *ApiResponse {
|
||||
return a.lastResp
|
||||
}
|
||||
```
|
||||
|
||||
**3. execute 中调用 verifyFn 前设置 lastResp**(第 296 行附近):
|
||||
|
||||
在 `if passed && c.verifyFn != nil` 之前加一行:
|
||||
|
||||
```go
|
||||
c.api.lastResp = resp
|
||||
```
|
||||
|
||||
**4. WithSession 中传递 lastResp**(可选,因为 WithSession 创建新实例时 lastResp 默认 nil 即可,Verify 内不会跨实例调用)。
|
||||
|
||||
### 改动影响
|
||||
|
||||
- Verify 的签名 `func(a *Api) error` 不变,完全向后兼容
|
||||
- 现有 Verify 回调代码无需修改
|
||||
- 新的 Verify 回调可以用 `a.Resp()` 访问响应体做值断言
|
||||
- 外部 `resp.GetBody()` + `resp.Fail()` 仍然可用,作为兼容方式保留
|
||||
|
||||
### 文档改动([docs/Testing_API测试框架.md](docs/Testing_API测试框架.md))
|
||||
|
||||
将"用例编写范式"中的推荐模式从"Verify 做 DB 校验 + 外部 resp 做值断言"改为**统一在 Verify 内完成**:
|
||||
|
||||
```go
|
||||
resp := a.WithSession(Map{"user_id": int64(1)}).
|
||||
JSON(Map{"goods_id": goodsId, "quantity": 3, "address": "XX路1号"}).
|
||||
Verify(func(a *Api) error {
|
||||
// 响应值断言
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result.GetCeilInt64("id") <= 0 {
|
||||
return fmt.Errorf("订单 ID 必须大于 0")
|
||||
}
|
||||
if result.GetString("sn") == "" {
|
||||
return fmt.Errorf("订单编号 sn 不能为空")
|
||||
}
|
||||
|
||||
// 数据库状态校验
|
||||
row := a.DB().Get("order", "*", Map{"AND": Map{
|
||||
"user_id": int64(1), "goods_id": goodsId,
|
||||
}})
|
||||
if row == nil {
|
||||
return fmt.Errorf("order 表未写入订单记录")
|
||||
}
|
||||
if row.GetCeilInt64("state") != 0 {
|
||||
return fmt.Errorf("新订单 state 期望 0, 实际 %d", row.GetCeilInt64("state"))
|
||||
}
|
||||
// ...
|
||||
return nil
|
||||
}).
|
||||
Post("正常创建订单", 0, Map{
|
||||
"id": int64(1), "sn": "sample", "total_price": float64(1.0),
|
||||
})
|
||||
```
|
||||
|
||||
文档中需要调整的位置:
|
||||
|
||||
- 快速开始的完整范例
|
||||
- 用例编写范式的流程图和表格
|
||||
- "正确请求与响应断言"小节(合并为"正确请求与 Verify 校验")
|
||||
- "不好的写法 vs 推荐的写法"对比
|
||||
- 保留 `resp.GetBody()` + `resp.Fail()` 的说明但标注为兼容用法
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
---
|
||||
name: 优雅停机方案
|
||||
overview: 在 HoTime 框架层实现优雅停机,跨平台(Windows/Linux)支持。收到信号后新请求立即返回 503 让 nginx 切流,等排空后调用 http.Server.Shutdown 等在途请求完成,exit(0) 退出,run_app.cmd 检测正常退出不重启。
|
||||
todos:
|
||||
- id: framework-core
|
||||
content: hotimev1.5/application.go:新增 shuttingDown/shutdownOnce/DrainTimeout/ShutdownTimeout 字段,改 ServeHTTP,改 Run() 信号 goroutine,改 recover 保护,提取 initiateGracefulShutdown()
|
||||
status: completed
|
||||
- id: framework-windows
|
||||
content: hotimev1.5/signal_windows.go(新建):SetConsoleCtrlHandler 处理 CTRL_CLOSE_EVENT(关窗口)
|
||||
status: completed
|
||||
- id: framework-other
|
||||
content: hotimev1.5/signal_other.go(新建):非 Windows 平台 registerWindowsCloseHandler() 空实现
|
||||
status: completed
|
||||
- id: nginx-config
|
||||
content: Nginx location 块加 proxy_next_upstream error timeout http_503 和 proxy_next_upstream_tries 2
|
||||
status: completed
|
||||
- id: cmd-exit-check
|
||||
content: run_app.cmd:检测 EXIT_CODE == 0 时不重启直接退出
|
||||
status: completed
|
||||
- id: docs
|
||||
content: 新建 hotimev1.5/docs/graceful-shutdown.md 文档
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# 优雅停机方案
|
||||
|
||||
## 整体流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Op as 运维
|
||||
participant OS as 操作系统
|
||||
participant App as Go 进程
|
||||
participant Nginx as Nginx
|
||||
|
||||
Op->>OS: Ctrl+C / kill / taskkill / 关窗口
|
||||
OS->>App: SIGINT / SIGTERM / CTRL_CLOSE_EVENT
|
||||
App->>App: shuttingDown = true(sync.Once 保证只执行一次)
|
||||
Note over App,Nginx: 新请求立即返回 503 + Connection:close
|
||||
Nginx->>App: 新请求
|
||||
App-->>Nginx: 503 Service Unavailable
|
||||
Nginx->>Nginx: proxy_next_upstream http_503 → 转另一台
|
||||
Note over App: 等 DrainTimeout(默认5s,让nginx完成切流)
|
||||
App->>App: server.Shutdown(ShutdownTimeout=30s)
|
||||
Note over App: 等所有在途请求跑完
|
||||
App->>OS: os.Exit(0)
|
||||
Note over Op: run_app.cmd 检测 exit 0 → 不重启
|
||||
```
|
||||
|
||||
## 信号触发场景对照表
|
||||
|
||||
| 操作方式 | 平台 | 信号/事件 | 是否可拦截 | 备注 |
|
||||
|---|---|---|---|---|
|
||||
| Ctrl+C | Windows/Linux | `os.Interrupt` | ✅ | 标准中断 |
|
||||
| `kill <pid>` 或 `kill -15 <pid>` | Linux | `syscall.SIGTERM` | ✅ | 推荐生产关闭方式 |
|
||||
| `taskkill /PID <pid>`(无 /F) | Windows | `os.Interrupt` | ✅ | 等价于 Ctrl+C |
|
||||
| 关闭控制台窗口 X 按钮 | Windows | `CTRL_CLOSE_EVENT` | ✅(≤5s 限制)| Windows 给 ~5s 后强杀 |
|
||||
| `taskkill /F /PID <pid>` | Windows | 强制 SIGKILL | ❌ | 无法拦截,强杀 |
|
||||
| `kill -9 <pid>` | Linux | SIGKILL | ❌ | 无法拦截,强杀 |
|
||||
|
||||
> **关窗口说明**:Windows 在触发 `CTRL_CLOSE_EVENT` 后约 5 秒会强制结束进程。框架对此事件使用更短的 drain(默认 2s)+ shutdown(默认 3s),总计 <5s,可完成正在跑的短接口;长接口(>3s)会被截断。推荐用 `taskkill /PID` 代替关窗口做计划性停机。
|
||||
|
||||
---
|
||||
|
||||
## 需要改动/新建的文件
|
||||
|
||||
### 1. [`hotimev1.5/application.go`](d:/work/hotimev1.5/application.go)
|
||||
|
||||
**`Application` struct** 新增字段:
|
||||
```go
|
||||
type Application struct {
|
||||
// ...原有字段...
|
||||
shuttingDown atomic.Bool
|
||||
shutdownOnce sync.Once // 保证多路触发只执行一次
|
||||
DrainTimeout time.Duration // 等 nginx 切流,默认 5s
|
||||
ShutdownTimeout time.Duration // 等在途请求,默认 30s
|
||||
}
|
||||
```
|
||||
|
||||
**`ServeHTTP`** 新增停机检测:
|
||||
```go
|
||||
func (that *Application) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
if that.shuttingDown.Load() {
|
||||
w.Header().Set("Connection", "close")
|
||||
http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
that.handler(w, req)
|
||||
}
|
||||
```
|
||||
|
||||
**提取 `initiateGracefulShutdown(drain, shutdown time.Duration)` 方法**(被信号 goroutine 和 Windows 关窗 handler 共同调用):
|
||||
```go
|
||||
func (that *Application) initiateGracefulShutdown(drain, shutdown time.Duration) {
|
||||
that.shutdownOnce.Do(func() {
|
||||
that.shuttingDown.Store(true)
|
||||
that.Log.Infof("优雅停机:等 %v 让 nginx 切流...", drain)
|
||||
time.Sleep(drain)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shutdown)
|
||||
defer cancel()
|
||||
that.Log.Infof("等进行中请求完成(最多 %v)...", shutdown)
|
||||
if err := that.Server.Shutdown(ctx); err != nil {
|
||||
that.Log.Errorf("优雅停机超时: %v", err)
|
||||
}
|
||||
that.Log.Infof("服务已安全关闭")
|
||||
os.Exit(0)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**`Run()`** 加信号监听 goroutine(在启动 ListenAndServe 之后):
|
||||
```go
|
||||
// 注册 Windows 关窗口处理(Linux 上是空实现)
|
||||
that.registerWindowsCloseHandler()
|
||||
|
||||
// 跨平台:Ctrl+C / SIGTERM / kill
|
||||
go func() {
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||
<-sigCh
|
||||
drain := that.DrainTimeout
|
||||
if drain == 0 { drain = 5 * time.Second }
|
||||
shutdown := that.ShutdownTimeout
|
||||
if shutdown == 0 { shutdown = 30 * time.Second }
|
||||
that.initiateGracefulShutdown(drain, shutdown)
|
||||
}()
|
||||
```
|
||||
|
||||
**`defer recover`** 加停机保护:
|
||||
```go
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
if that.shuttingDown.Load() {
|
||||
return // 停机中的 panic 不触发重启
|
||||
}
|
||||
that.Log.Warnf("%v", err)
|
||||
that.Run(router)
|
||||
}
|
||||
}()
|
||||
```
|
||||
|
||||
新增 import:`"context"`, `"os/signal"`, `"sync"`, `"sync/atomic"`, `"syscall"`
|
||||
|
||||
---
|
||||
|
||||
### 2. `hotimev1.5/signal_windows.go`(新建)
|
||||
|
||||
```go
|
||||
//go:build windows
|
||||
|
||||
package hotime
|
||||
|
||||
import (
|
||||
"time"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func (that *Application) registerWindowsCloseHandler() {
|
||||
windows.SetConsoleCtrlHandler(func(ctrl uint32) bool {
|
||||
// CTRL_CLOSE_EVENT=2 CTRL_LOGOFF_EVENT=5 CTRL_SHUTDOWN_EVENT=6
|
||||
if ctrl == 2 || ctrl == 5 || ctrl == 6 {
|
||||
that.initiateGracefulShutdown(2*time.Second, 3*time.Second)
|
||||
// 阻塞此回调,让 initiateGracefulShutdown 内的 os.Exit(0) 执行
|
||||
// Windows 约 5s 后强杀,这里最多等 6s 兜底
|
||||
time.Sleep(6 * time.Second)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}, true)
|
||||
}
|
||||
```
|
||||
|
||||
> `golang.org/x/sys` 已在 `xbc/go.mod` 中作为间接依赖存在(v0.29.0),无需新增依赖。
|
||||
|
||||
---
|
||||
|
||||
### 3. `hotimev1.5/signal_other.go`(新建)
|
||||
|
||||
```go
|
||||
//go:build !windows
|
||||
|
||||
package hotime
|
||||
|
||||
func (that *Application) registerWindowsCloseHandler() {}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Nginx 配置(在已有 location 块中追加)
|
||||
|
||||
```nginx
|
||||
# 让后端返回 503 时自动切换到另一台(优雅停机核心)
|
||||
proxy_next_upstream error timeout http_503;
|
||||
proxy_next_upstream_tries 2;
|
||||
```
|
||||
|
||||
已有的 `max_fails=1 fail_timeout=3s` 负责连接级失败(强杀后的端口拒绝),两者互补。
|
||||
|
||||
---
|
||||
|
||||
### 5. [`run_app.cmd`](d:/work/your-app/run_app.cmd)
|
||||
|
||||
在 `goto run_app` 前插入 exit code 判断:
|
||||
|
||||
```cmd
|
||||
:: 优雅退出 (exit 0) 不重启
|
||||
if %EXIT_CODE% EQU 0 (
|
||||
echo [%date% %time%] Graceful shutdown. Not restarting. >> "%LOG_FILE%"
|
||||
echo.
|
||||
echo [优雅停机] 服务已安全关闭,不再自动重启。
|
||||
pause
|
||||
exit /b 0
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. `hotimev1.5/docs/graceful-shutdown.md`(新建)
|
||||
|
||||
包含:原理说明、场景对照表、nginx 配置、滚动重启步骤、参数说明(DrainTimeout/ShutdownTimeout)。
|
||||
|
||||
---
|
||||
|
||||
## 滚动重启操作步骤(Windows)
|
||||
|
||||
```
|
||||
# 1. 找到端口 9998 的进程 PID
|
||||
netstat -ano | findstr 9998
|
||||
|
||||
# 2. 优雅关闭该实例(不加 /F)
|
||||
taskkill /PID <pid>
|
||||
|
||||
# 3. 等日志出现 "服务已安全关闭" 后再部署新版并启动
|
||||
# 4. 再对 9999 执行同样操作
|
||||
```
|
||||
|
||||
## 滚动重启操作步骤(Linux)
|
||||
|
||||
```bash
|
||||
# 找 PID
|
||||
pgrep xbc # 或 ps aux | grep xbc
|
||||
|
||||
# 优雅关闭
|
||||
kill <pid> # 发 SIGTERM,触发优雅停机
|
||||
# 或
|
||||
kill -2 <pid> # 发 SIGINT,等同 Ctrl+C
|
||||
|
||||
# 等日志出现 "服务已安全关闭" 再部署新版
|
||||
```
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
name: 修复DM列注释读取
|
||||
overview: 修复达梦数据库列注释(COMMENT)未被正确读取的问题,导致管理端所有字段显示为数据库字段名而非中文备注。
|
||||
todos:
|
||||
- id: fix-dm-col-comments
|
||||
content: 修改 makecode.go 列信息查询,列注释改用 USER_COL_COMMENTS 替代 ALL_COL_COMMENTS
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# 修复达梦数据库列注释读取失败
|
||||
|
||||
## 问题分析
|
||||
|
||||
通过 MCP 查询 `adminDB.json` 确认:所有表的字段 label 全部等于字段名本身(如 `"id"`, `"name"`, `"phone"`),没有中文备注。但表级 label 正常(如"消息主表"、"用户表")。
|
||||
|
||||
用户确认数据库字段确实有备注。
|
||||
|
||||
### 根因
|
||||
|
||||
[makecode.go](d:\work\hotimev1.5\code\makecode.go) 第 170 行当前的列查询 SQL:
|
||||
|
||||
```sql
|
||||
FROM ALL_TAB_COLUMNS c
|
||||
LEFT JOIN ALL_COL_COMMENTS m
|
||||
ON c.TABLE_NAME=m.TABLE_NAME AND c.COLUMN_NAME=m.COLUMN_NAME AND c.OWNER=m.OWNER
|
||||
WHERE c.TABLE_NAME='...' AND c.OWNER='ZWBG'
|
||||
```
|
||||
|
||||
`LEFT JOIN` 条件中 `c.OWNER=m.OWNER` 要求 `ALL_COL_COMMENTS` 的 `OWNER` 也等于 `'ZWBG'`。但在 DM 中,`ALL_COL_COMMENTS` 的 `OWNER` 值可能与 `ALL_TAB_COLUMNS` 不一致(DM 的 Oracle 兼容视图在 OWNER 语义上可能存在差异),导致 JOIN 匹配不上,`m.COMMENTS` 全部为 NULL。
|
||||
|
||||
对比:表注释用的 `ALL_TAB_COMMENTS WHERE OWNER='ZWBG'` 能正确返回中文备注,说明表级视图的 OWNER 一致,但列级视图可能不同。
|
||||
|
||||
## 修复方案
|
||||
|
||||
修改 [makecode.go](d:\work\hotimev1.5\code\makecode.go) 第 169-171 行,列注释改用 `USER_COL_COMMENTS`(自动使用当前 schema 上下文 ZWBG,无需 OWNER 过滤),列元数据继续用 `ALL_TAB_COLUMNS`(保留 OWNER 过滤防止系统表):
|
||||
|
||||
```go
|
||||
if db.Type == "dm" || db.Type == "dameng" {
|
||||
tableInfo = db.Query(`SELECT c.COLUMN_NAME AS "name", c.DATA_TYPE AS "type", m.COMMENTS AS "label", c.NULLABLE AS "must", c.DATA_DEFAULT AS "dflt_value" FROM ALL_TAB_COLUMNS c LEFT JOIN USER_COL_COMMENTS m ON c.TABLE_NAME=m.TABLE_NAME AND c.COLUMN_NAME=m.COLUMN_NAME WHERE c.TABLE_NAME='` + v.GetString("name") + `' AND c.OWNER='` + strings.ToUpper(db.DBName) + `' ORDER BY c.COLUMN_ID`)
|
||||
}
|
||||
```
|
||||
|
||||
关键变化:
|
||||
|
||||
- `ALL_COL_COMMENTS` 改为 `USER_COL_COMMENTS`
|
||||
- 去掉 JOIN 中的 `AND c.OWNER=m.OWNER`(`USER_COL_COMMENTS` 没有 OWNER 列)
|
||||
- WHERE 条件 `c.OWNER='ZWBG'` 保留(作用于 `ALL_TAB_COLUMNS`,防止系统表列混入)
|
||||
|
||||
原理:框架连接 DM 用 `?schema=ZWBG`,`USER_COL_COMMENTS` 自动返回当前 schema(ZWBG)的列注释,绕开了 OWNER 不一致问题。
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
name: 修复DM类型映射大小写
|
||||
overview: DM 返回的列类型为大写(BIGINT、VARCHAR、INT、TIMESTAMP),但 ColumnDataType 的匹配键是小写且 strings.Contains 区分大小写,导致类型映射全部失败。
|
||||
todos:
|
||||
- id: fix-type-case
|
||||
content: 修改 makecode.go 类型比较加 strings.ToLower 忽略大小写
|
||||
status: completed
|
||||
- id: add-dm-types
|
||||
content: 在 config.go 的 ColumnDataType 中追加 DM 特有数据类型映射
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# 修复达梦数据库列类型映射失败
|
||||
|
||||
## 问题分析
|
||||
|
||||
通过 MCP 查询 `adminDB.json` 确认:大量字段 type 仍为 DM 原始类型(`BIGINT`、`VARCHAR`、`INT`、`TIMESTAMP`、`DECIMAL`),未映射到框架类型(`number`、`text`、`time`),导致前端编辑页无法识别控件类型。
|
||||
|
||||
### 根因
|
||||
|
||||
[config.go](d:\work\hotimev1.5\code\config.go) 第 43-59 行定义的 `ColumnDataType` 映射表键全部为小写:
|
||||
|
||||
```go
|
||||
var ColumnDataType = map[string]string{
|
||||
"int": "number",
|
||||
"char": "text",
|
||||
"text": "text",
|
||||
"time": "time",
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
[makecode.go](d:\work\hotimev1.5\code\makecode.go) 第 191-194 行的匹配逻辑使用 `strings.Contains`,**区分大小写**:
|
||||
|
||||
```go
|
||||
for k, v1 := range ColumnDataType {
|
||||
if strings.Contains(info.GetString("type"), k) { // "BIGINT" 不包含 "int"
|
||||
info["type"] = v1
|
||||
break
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
DM 返回的类型全部为大写(`INT`、`VARCHAR`、`BIGINT`),而键为小写(`int`、`char`),`strings.Contains("BIGINT", "int")` 返回 `false`,映射全部跳过。
|
||||
|
||||
## 修复方案
|
||||
|
||||
### 修改 1:类型比较忽略大小写 - [makecode.go](d:\work\hotimev1.5\code\makecode.go)
|
||||
|
||||
第 192 行,将 `info.GetString("type")` 转为小写再匹配:
|
||||
|
||||
```go
|
||||
if strings.Contains(strings.ToLower(info.GetString("type")), k) {
|
||||
```
|
||||
|
||||
效果验证:
|
||||
|
||||
- `"BIGINT"` -> `"bigint"` -> contains `"int"` -> `"number"`
|
||||
- `"VARCHAR"` -> `"varchar"` -> contains `"char"` -> `"text"`
|
||||
- `"INT"` -> `"int"` -> contains `"int"` -> `"number"`
|
||||
- `"TIMESTAMP"` -> `"timestamp"` -> contains `"time"` -> `"time"`
|
||||
- `"DECIMAL"` -> `"decimal"` -> contains `"decimal"` -> `"number"`
|
||||
|
||||
### 修改 2:补充 DM 特有类型 - [config.go](d:\work\hotimev1.5\code\config.go)
|
||||
|
||||
在 `ColumnDataType` 中追加 DM 特有的数据类型,确保 CLOB、NUMBER 等也能被识别:
|
||||
|
||||
```go
|
||||
var ColumnDataType = map[string]string{
|
||||
// 现有 MySQL/SQLite 类型...
|
||||
// DM 特有类型
|
||||
"clob": "text",
|
||||
"number": "number",
|
||||
"numeric": "number",
|
||||
"binary": "text",
|
||||
"boolean": "number",
|
||||
"bit": "number",
|
||||
}
|
||||
```
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
---
|
||||
name: 修复Table和Edit组件
|
||||
overview: 修复 Table.vue 的重复请求和搜索防抖问题,以及 Edit.vue 密码改为弹窗独立修改。
|
||||
todos:
|
||||
- id: fix-double-request
|
||||
content: "Table.vue: 添加 _skipFormWatch 标志,修复 mounted 和 watcher 导致的重复请求"
|
||||
status: completed
|
||||
- id: fix-keyword-search
|
||||
content: "Table.vue: 关键字搜索改为点击按钮/Enter触发,不再实时触发"
|
||||
status: completed
|
||||
- id: fix-password-dialog
|
||||
content: "Edit.vue: 密码字段改为弹窗独立修改,主表单提交排除密码数据"
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# 修复 Table 重复请求、搜索防抖、Edit 密码弹窗
|
||||
|
||||
## 1. 修复重复请求 - [Table.vue](d:\work\myst-web\src\components\Table.vue)
|
||||
|
||||
**根因**:`mounted()` 中既修改了 form 属性(触发 deep watcher),又显式调用了 `formDebounce`,两者可能各触发一次请求。
|
||||
|
||||
**方案**:在 data 中增加 `_skipFormWatch: true` 标志,mounted 中所有 form 初始化完成后调用一次 `formDebounce`,然后在 `$nextTick` 中将标志设为 false。watcher 中检查该标志,初始化期间跳过。
|
||||
|
||||
```javascript
|
||||
// data 中
|
||||
_skipFormWatch: true,
|
||||
|
||||
// watcher 中
|
||||
form: {
|
||||
handler() {
|
||||
if (this._skipFormWatch) return;
|
||||
this.formDebounce(this);
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
|
||||
// mounted 末尾
|
||||
this.formDebounce(this);
|
||||
this.$nextTick(() => { this._skipFormWatch = false; });
|
||||
```
|
||||
|
||||
## 2. 搜索改为点击触发 - [Table.vue](d:\work\myst-web\src\components\Table.vue)
|
||||
|
||||
**方案**:将关键字搜索从实时触发改为点击搜索按钮触发。
|
||||
|
||||
- 新增 `searchKeyword: ""` data 属性,输入框 `v-model` 改为绑定 `searchKeyword`
|
||||
- 搜索按钮添加 `@click="doSearch"` 事件
|
||||
- `doSearch` 方法将 `searchKeyword` 赋值给 `form.keyword`(触发 watcher 进行搜索)
|
||||
- 输入框支持 Enter 键触发搜索(`@keyup.enter`)
|
||||
- 重置按钮也同步清空 `searchKeyword`
|
||||
|
||||
关键模板改动:
|
||||
|
||||
```html
|
||||
<el-input v-model="searchKeyword" @keyup.enter="doSearch" ...>
|
||||
<template #append>
|
||||
<el-button class="search-btn" @click="doSearch">
|
||||
<el-icon><Search/></el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
```
|
||||
|
||||
## 3. 密码改为弹窗独立修改 - [Edit.vue](d:\work\myst-web\src\components\Edit.vue)
|
||||
|
||||
**方案**:
|
||||
|
||||
- 主表单中隐藏 password 类型字段,改为显示"修改密码"按钮
|
||||
- 新增密码修改弹窗(`el-dialog`),包含新密码 + 确认密码两个输入
|
||||
- 弹窗提交时校验两次输入一致,然后仅发送密码字段调用 `edit` API
|
||||
- 主表单 `onSubmit` 提交数据时过滤掉 password 类型字段
|
||||
|
||||
关键改动点:
|
||||
|
||||
**模板** - 密码字段区域(约第55行):
|
||||
|
||||
```html
|
||||
<!-- 原密码输入改为修改密码按钮 -->
|
||||
<el-button v-if="column.type=='password'" type="warning" @click="showPasswordDialog = true">
|
||||
修改密码
|
||||
</el-button>
|
||||
<!-- 其他非密码字段保持原样 -->
|
||||
<el-input v-model="form[column.value]" show-password v-else-if="..." />
|
||||
```
|
||||
|
||||
**data** 新增:
|
||||
|
||||
```javascript
|
||||
showPasswordDialog: false,
|
||||
passwordForm: { password: '', confirmPassword: '' },
|
||||
passwordColumn: null,
|
||||
```
|
||||
|
||||
**methods** 新增 `submitPassword` 和修改 `onSubmit`:
|
||||
|
||||
- `submitPassword`:校验密码一致性,调用 `edit` API 仅提交密码字段
|
||||
- `onSubmit`:提交时从 form 中排除 password 类型字段的数据
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
name: 修复路径和测试数据
|
||||
overview: 修复测试时 swagger/config 生成到错误目录的问题(根因是 Go test CWD 在 app/ 下),并为 DM 和 MySQL 两个测试数据库创建完善的测试数据表和种子数据。
|
||||
todos:
|
||||
- id: fix-cwd
|
||||
content: 修复 app_test.go 中的工作目录问题:添加 os.Chdir("..") 并修改 config path
|
||||
status: completed
|
||||
- id: cleanup-wrong-dirs
|
||||
content: 清理错误生成的 example/app/tpt/ 和 example/app/config/ 目录
|
||||
status: completed
|
||||
- id: setup-mysql
|
||||
content: 创建 setup_mysql.go:MySQL DDL 建表逻辑(与 setup_dm.go 对应)
|
||||
status: completed
|
||||
- id: extract-seed
|
||||
content: 提取通用种子数据函数 seedTestData,DM 和 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 路径问题 + 完善双数据库测试数据
|
||||
|
||||
## 问题 1:Swagger 和 config 生成到错误目录
|
||||
|
||||
**根因**:`go test ./app/...` 时,Go test 将工作目录设为包目录 `example/app/`。配置文件中 `"tpt": "tpt"` 和 `codeConfig` 中的 `"config/admin.json"` 等相对路径,都从 `example/app/` 解析,导致文件生成到 `example/app/tpt/` 和 `example/app/config/` 而非预期的 `example/tpt/` 和 `example/config/`。
|
||||
|
||||
**修复方案**:在 [example/app/app_test.go](example/app/app_test.go) 的 `TestMain` 中,`NewTestApp` 之前先 `os.Chdir("..")`,将工作目录切回 `example/`,然后将配置路径从 `"../config/config.json"` 改为 `"config/config.json"`:
|
||||
|
||||
```go
|
||||
func TestMain(m *testing.M) {
|
||||
os.Chdir("..") // 切回 example/ 目录,使 tpt、config 等相对路径正确解析
|
||||
testApp = NewTestApp("config/config.json", ...)
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
同时清理已错误生成的 `example/app/tpt/` 和 `example/app/config/` 目录。
|
||||
|
||||
## 问题 2:双数据库测试数据
|
||||
|
||||
现有 [example/app/setup_dm.go](example/app/setup_dm.go) 仅处理 DM。需要为 MySQL 创建类似的初始化逻辑,并使种子数据逻辑可复用。
|
||||
|
||||
**数据库结构**(DM 和 MySQL 共用同一套表):
|
||||
|
||||
- `admin` — 管理员表(id, name, phone, state, password, role_id, title, create_time, modify_time)
|
||||
- `ctg` — 分类表(id, name, state, create_time, modify_time)
|
||||
- `article` — 文章表(id, title, author, content, state, click_num, sort, img, ctg_id, admin_id, create_time, modify_time)
|
||||
- `test_batch` — 批量测试表(id, name, title, state, create_time)
|
||||
- `cached` — 老版本缓存表(id, key, value, endtime, time)
|
||||
- `hotime_cache` — 新版缓存表(id, key, value, end_time, state, create_time, modify_time)
|
||||
|
||||
**方案**:
|
||||
|
||||
1. 创建 [example/app/setup_mysql.go](example/app/setup_mysql.go):包含 MySQL DDL 建表和种子数据灌入,类似 `setup_dm.go` 的结构
|
||||
2. 在 [example/app/app_test.go](example/app/app_test.go) 的 `TestMain` 中增加 `SetupMySQLDatabase(&testApp.Db)` 调用(根据 `db.Type` 自动判断是否执行)
|
||||
3. 种子数据使用 ORM 的 `db.Insert()` 方法,与数据库类型无关,可直接复用现有 `setup_dm.go` 中 `seedDMData` 的逻辑,提取为通用的 `seedTestData` 函数
|
||||
4. 同时提供 MySQL 和 DM 的原始 DDL/DML SQL 脚本,放在 `example/sql/` 目录下,方便手动执行
|
||||
|
||||
**config.json 切换方式**:当前 MySQL 的 key 是 `"mysql-"`(带尾缀表示禁用),DM 的 key 是 `"dm"`(活跃)。切换数据库只需互换后缀:`"mysql"` / `"dm-"`。
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
name: 修复达梦数据库越界和引号
|
||||
overview: 修复 HoTime 框架代码生成器的两个达梦(DM)数据库问题:(1) 获取表列表时越界包含了系统表;(2) SQL 查询使用了 MySQL 反引号语法导致达梦解析失败。
|
||||
todos:
|
||||
- id: fix-dm-table-filter
|
||||
content: 修改 code/makecode.go 的达梦表列表和列信息查询,使用 ALL_TAB_COMMENTS + OWNER 过滤
|
||||
status: completed
|
||||
- id: fix-backtick-convert
|
||||
content: 修改 db/identifier.go 的 ProcessFieldList,增加 convertQuotes 调用转换残留反引号
|
||||
status: completed
|
||||
- id: regen-admin-json
|
||||
content: 删除/清理 myst-go/config/admin.json 中的系统表条目,重新生成配置
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# 修复达梦数据库越界获取系统表和反引号语法错误
|
||||
|
||||
## 问题分析
|
||||
|
||||
### 问题 1:越界获取非 ZWBG 数据库的表
|
||||
|
||||
[code/makecode.go](d:\work\hotimev1.5\code\makecode.go) 第 121-122 行:
|
||||
|
||||
```go
|
||||
nowTables = db.Query(`SELECT TABLE_NAME AS "name", COMMENTS AS "label" FROM USER_TAB_COMMENTS WHERE TABLE_TYPE='TABLE'`)
|
||||
```
|
||||
|
||||
使用 `SYSDBA` 用户连接达梦时,`USER_TAB_COMMENTS` 返回该用户下**所有表**,包括达梦系统表(`SYSJOBS`、`SYSJOBSCHEDULES`、`SYSALERTS`、`##HISTOGRAMS_TABLE` 等),导致 [admin.json](d:\work\myst-go\config\admin.json) 中混入了大量不属于 ZWBG 业务的系统表。
|
||||
|
||||
同样的问题存在于第 169-170 行的列信息查询(`USER_TAB_COLUMNS` / `USER_COL_COMMENTS`)。
|
||||
|
||||
### 问题 2:反引号导致 SQL 语法错误(遗留问题)
|
||||
|
||||
`makecode.go` 中 `Info`(第 725 行)和 `Search`(第 958 行)使用反引号拼接列名字符串:``col1`,`col2`,...`。
|
||||
|
||||
`crud.go` 的 `Select` 方法有**两种字段处理路径**:
|
||||
|
||||
- **Slice 路径**(第 100-109 行):每个列名走 `ProcessColumnNoPrefix` -> `QuoteIdentifier`,能正确转换引号
|
||||
- **String 路径**(第 93-99 行):整个字符串走 `ProcessFieldList` -> `ProcessConditionString`,三个正则都只匹配 `table.column` 格式(含 `.`),不处理单独的反引号列名
|
||||
|
||||
`makecode.go` 传的是拼接好的**字符串**,走的是 String 路径,所以反引号不会被自动转换,导致达梦报语法错误。
|
||||
|
||||
---
|
||||
|
||||
## 修复方案
|
||||
|
||||
### 修改 1:过滤达梦系统表 - [code/makecode.go](d:\work\hotimev1.5\code\makecode.go)
|
||||
|
||||
将 `USER_TAB_COMMENTS` / `USER_TAB_COLUMNS` 改为 `ALL_TAB_COMMENTS` / `ALL_TAB_COLUMNS`,并通过 `OWNER` 过滤为 `db.DBName`(即 `ZWBG`),确保只获取目标 schema 的业务表。
|
||||
|
||||
**第 121-122 行**,表列表查询改为:
|
||||
|
||||
```go
|
||||
if db.Type == "dm" || db.Type == "dameng" {
|
||||
nowTables = db.Query(`SELECT TABLE_NAME AS "name", COMMENTS AS "label" FROM ALL_TAB_COMMENTS WHERE TABLE_TYPE='TABLE' AND OWNER='` + strings.ToUpper(db.DBName) + `'`)
|
||||
}
|
||||
```
|
||||
|
||||
**第 169-170 行**,列信息查询改为:
|
||||
|
||||
```go
|
||||
if db.Type == "dm" || db.Type == "dameng" {
|
||||
tableInfo = db.Query(`SELECT c.COLUMN_NAME AS "name", c.DATA_TYPE AS "type", m.COMMENTS AS "label", c.NULLABLE AS "must", c.DATA_DEFAULT AS "dflt_value" FROM ALL_TAB_COLUMNS c LEFT JOIN ALL_COL_COMMENTS m ON c.TABLE_NAME=m.TABLE_NAME AND c.COLUMN_NAME=m.COLUMN_NAME AND c.OWNER=m.OWNER WHERE c.TABLE_NAME='` + v.GetString("name") + `' AND c.OWNER='` + strings.ToUpper(db.DBName) + `' ORDER BY c.COLUMN_ID`)
|
||||
}
|
||||
```
|
||||
|
||||
### 修改 2:修复反引号转换 - [db/identifier.go](d:\work\hotimev1.5\db\identifier.go)
|
||||
|
||||
`makecode.go` 的反引号属于遗留代码,理论上 `Select` 的 Slice 路径已能自动处理引号,但 `makecode.go` 传的是字符串。最小改动:在 `ProcessFieldList` 末尾加 `convertQuotes` 调用,将 `ProcessConditionString` 未能匹配到的残留反引号统一转为当前方言引号:
|
||||
|
||||
```go
|
||||
func (p *IdentifierProcessor) ProcessFieldList(fields string) string {
|
||||
if fields == "" || fields == "*" {
|
||||
return fields
|
||||
}
|
||||
result := p.ProcessConditionString(fields)
|
||||
result = p.convertQuotes(result)
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
`convertQuotes`(第 240-248 行)已实现引号转换:达梦将反引号转为 `"`,MySQL 保持不变(no-op)。这样无需修改 `makecode.go` 中的遗留反引号代码,所有走 `ProcessFieldList` 的字符串都能被正确处理。
|
||||
|
||||
### 修改 3:重新生成 admin.json - [config/admin.json](d:\work\myst-go\config\admin.json)
|
||||
|
||||
代码修复后,需要删除当前 `admin.json`(已包含系统表污染)并重启应用让其自动重新生成。需要注意的是:
|
||||
|
||||
- 如果 `admin.json` 中有手工定制的菜单/权限配置,**需先备份**再删除
|
||||
- 或者手动从 `admin.json` 中删除以下系统表条目:`SYSJOBSCHEDULES`、`SYSJOBSTEPS`、`SYSJOBHISTORIES`、`SYSJOBHISTORIES2`、`SYSSTEPHISTORIES2`、`SYSALERTHISTORIES`、`SYSALERTNOTIFICATIONS`、`SYSALERTS`、`SYSMAILINFO`、`SYSOPERATORS`、`SYSJOBS`、`##HISTOGRAMS_TABLE`、`cached`、`hotime_cache`、`test_batch`
|
||||
|
||||
---
|
||||
|
||||
## 数据流概览
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph code_gen [代码生成 - makecode.go]
|
||||
A["Db2JSON()"] -->|"USER_TAB_COMMENTS (修复前)"| B["获取所有表含系统表"]
|
||||
A -->|"ALL_TAB_COMMENTS + OWNER (修复后)"| C["仅获取ZWBG业务表"]
|
||||
end
|
||||
subgraph query [查询执行 - code.go + crud.go]
|
||||
D["Info/Search 拼字段"] -->|反引号列名| E["ProcessFieldList"]
|
||||
E -->|"ProcessConditionString (不匹配)"| F["反引号保留 -> DM报错"]
|
||||
E -->|"+ convertQuotes (修复后)"| G["转为双引号 -> DM正常"]
|
||||
end
|
||||
```
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: 字段备注空格扩展
|
||||
overview: 在 hotime 的列注释解析中,除保留「首段为系统内 label」外,将「第一个空格之后、选项/花括号规则之前」的内容单独写入新字段(如 remark),供前端展示;并澄清:管理端若列来自已有配置或 JSON,可能未经过 DB 新列分支,故不会表现为「整段 label 被丢掉」。
|
||||
todos:
|
||||
- id: parse-makecode
|
||||
content: 在 makecode.go 中实现 SplitN + 空格拆分 + remark 字段,并统一 options/ps 解析顺序
|
||||
status: pending
|
||||
- id: ui-remark
|
||||
content: 在 myst-web Edit/Info 中展示 column.remark(可选样式)
|
||||
status: pending
|
||||
- id: docs-format
|
||||
content: 补充列注释格式说明文档
|
||||
status: pending
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# 表字段备注:空格后扩展备注(修正认知)
|
||||
|
||||
## 为何你能在管理端看到「完整」label
|
||||
|
||||
`[hotimev1.5/code/makecode.go](d:/work/hotimev1.5/code/makecode.go)` 中,**空格截断与 `:` 处理仅发生在 `if coloum == nil` 分支**(约 189–325 行),即:**仅当该列尚未在 `TableColumns` 中存在、完全由本次从库表结构推断新建列时**才会执行。
|
||||
|
||||
若列定义已来自 `[config/adminDB.json](d:/work/myst-go/config/adminDB.json)` 等已有配置,会走 `coloum != nil`,**不会执行**上述截断逻辑,管理端下发的 `column.label` 仍可能是你在 JSON 里写的整段字符串,因此**不会出现「整段在 UI 里被丢掉」的现象**。
|
||||
|
||||
你遇到的「断言/解析」问题更可能出在:**需要同时支持「系统内短 label」与「空格后的说明」两种语义**,而不是单纯丢失数据。
|
||||
|
||||
## 目标行为(约定)
|
||||
|
||||
对**数据库列注释**(以及与之对齐的生成逻辑)采用统一解析顺序,避免与现有 `:` 选项、`{...}` 的 `ps` 冲突:
|
||||
|
||||
1. 用 `**SplitN(raw, ":", 2)`** 拆出「主文案」与「选项串」(替代当前对整串 `strings.Split(..., ":")`,避免备注里出现冒号时误伤)。
|
||||
2. 在「主文案」中按 **第一个空格** 拆成:
|
||||
- **第一段**:`label`(系统内字段名/短标题,与现有一致)
|
||||
- **第二段及以后**(或仅第二段,按产品定):trim 后写入新字段,建议键名 `**remark`**(若你更倾向 `note` / `labelRemark` 可再定)
|
||||
3. `**{...}` → `ps`**:仍在**完整主文案或完整 raw** 上提取(与现有行为一致),避免破坏依赖 `ps` 的表单。
|
||||
4. **选项解析**:使用 `SplitN` 的第二段,保持 `1-男,2-女` 这类格式不变。
|
||||
|
||||
示例:`姓名 对外展示说明 :1-男,2-女` → `label=姓名`,`remark=对外展示说明`,options 从 `:1-男,2-女` 解析。
|
||||
|
||||
## 实现位置
|
||||
|
||||
|
||||
| 层级 | 文件 | 内容 |
|
||||
| --------- | ------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| 后端 | `[hotimev1.5/code/makecode.go](d:/work/hotimev1.5/code/makecode.go)` | 重构 218–233 与 300–308 行附近逻辑:先 `:` 再空格,并设置 `coloum["remark"]`;无空格时行为与现有一致 |
|
||||
| 前端(可选但推荐) | `[myst-web/src/components/Edit.vue](d:/work/myst-web/src/components/Edit.vue)`、`[Info.vue](d:/work/myst-web/src/components/Info.vue)` | 在表单项标签旁或下方展示 `column.remark`(小字/灰色提示),无则不占位 |
|
||||
| 文档 | `[myst-go/docs/](d:/work/myst-go/docs/)` 或 QUICKSTART | 简短说明列注释格式:`label 备注 :选项` |
|
||||
|
||||
|
||||
依赖关系:`[myst-go/go.mod](d:/work/myst-go/go.mod)` 中 `replace code.hoteas.com/golang/hotime => ../hotimev1.5`,改 hotime 源码即可被 myst-go 使用。
|
||||
|
||||
## 兼容与回归
|
||||
|
||||
- **无空格**、仅 `label:选项`、仅 `{ps}`:行为与现网一致。
|
||||
- **adminDB.json 手工写的长 label**:若需同样拆分,可在后续加「对已有列合并 DB 注释时补写 remark」或提供一次性脚本;首版以 **Db2JSON 新列路径** 与 **重新生成/同步列** 为主。
|
||||
|
||||
## 流程示意
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
raw[DB列注释raw]
|
||||
raw --> splitColon["SplitN 第一处冒号"]
|
||||
splitColon --> mainPart[主文案]
|
||||
splitColon --> optPart[选项串]
|
||||
main --> splitSpace["第一处空格"]
|
||||
splitSpace --> label[label]
|
||||
splitSpace --> remark[remark]
|
||||
raw --> psBrace["提取 ps 花括号"]
|
||||
```
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
name: 字段提示括号扩展
|
||||
overview: 在后端解析数据库列注释中的 `()` 内容作为字段提示(ps),修复空格截断 bug,并在前端 Table/Info/Edit 组件中展示提示信息。
|
||||
todos:
|
||||
- id: backend-parse
|
||||
content: 后端 makecode.go:新增 () 解析,调整解析顺序(先提取括号内容,再空格/冒号截断),存入 ps 字段
|
||||
status: completed
|
||||
- id: backend-fix-space
|
||||
content: 后端 makecode.go:修复 coloum != nil 分支中 label 未截断空格的问题
|
||||
status: completed
|
||||
- id: frontend-edit
|
||||
content: 前端 Edit.vue / Add.vue:输入框右侧显示 column.ps 提示文字
|
||||
status: completed
|
||||
- id: frontend-table
|
||||
content: 前端 Table.vue:列头加 el-tooltip 展示 td.ps
|
||||
status: completed
|
||||
- id: frontend-info
|
||||
content: 前端 Info.vue:详情字段名加 tooltip 展示 column.ps
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# 字段提示:支持 () 括号 + 前端展示
|
||||
|
||||
## 现状问题
|
||||
|
||||
1. 后端已经能从 `{}` 提取 `ps`,但前端从未使用 `ps` 字段
|
||||
2. `{}` 写法不直观,用户希望用更常见的 `()` 来写字段提示
|
||||
3. 管理端某些列的 label 包含了空格后面的文字(未经截断)
|
||||
|
||||
## 数据库注释格式约定(举例)
|
||||
|
||||
|
||||
| 注释写法 | label | ps | options |
|
||||
| --------------------------- | ----- | ------- | -------------- |
|
||||
| `姓名` | 姓名 | 无 | 无 |
|
||||
| `状态(当前审核状态)` | 状态 | 当前审核状态 | 无 |
|
||||
| `性别:1-男,2-女` | 性别 | 无 | 男/女 |
|
||||
| `状态(当前审核状态):1-待审,2-通过,3-驳回` | 状态 | 当前审核状态 | 待审/通过/驳回 |
|
||||
| `备注{请输入详细说明}` | 备注 | 请输入详细说明 | 无 |
|
||||
| `姓名 xxx说明` | 姓名 | 无 | 无(空格后丢弃,同现有行为) |
|
||||
|
||||
|
||||
`()` 和 `{}` 都提取到同一个 `ps` 字段(功能一致,只是写法不同)。
|
||||
|
||||
## 后端改动
|
||||
|
||||
文件:[hotimev1.5/code/makecode.go](d:/work/hotimev1.5/code/makecode.go) 约 218-233 行
|
||||
|
||||
当前解析顺序是:空格截断 -> 花括号提取ps -> 冒号截断。需要调整为:
|
||||
|
||||
1. **新增 `()` 解析**:在原有 `{}` 提取 ps 的逻辑旁边,增加对 `(` 和 `)` 的检测,提取内容同样存入 `coloum["ps"]`
|
||||
2. **调整解析顺序**:先提取 `()` / `{}`(从原始注释中),再做空格截断和冒号截断,避免截断后丢失括号内容
|
||||
3. **提取后清理 label**:将 `(...)` 或 `{...}` 从 label 中移除,确保 label 干净
|
||||
|
||||
改后伪逻辑(自然语言):
|
||||
|
||||
```
|
||||
原始注释 = "状态(当前审核状态):1-待审,2-通过,3-驳回"
|
||||
|
||||
第一步:从原始注释中找 () 或 {},提取内容 → ps = "当前审核状态"
|
||||
第二步:把 (...) 或 {...} 从注释中去掉 → "状态:1-待审,2-通过,3-驳回"
|
||||
第三步:空格截断(取第一个空格前的部分)
|
||||
第四步:冒号截断 label → label = "状态"
|
||||
第五步:冒号后面解析选项 → options
|
||||
```
|
||||
|
||||
## 前端改动
|
||||
|
||||
### 1. Edit.vue / Add.vue — 输入框右侧显示提示
|
||||
|
||||
在 `el-form-item` 内、输入控件后面追加一个小字提示:
|
||||
|
||||
```html
|
||||
<span v-if="column.ps" class="field-hint">{{ column.ps }}</span>
|
||||
```
|
||||
|
||||
样式为灰色小字,紧贴输入框右侧。
|
||||
|
||||
### 2. Table.vue — 列头 tooltip
|
||||
|
||||
给 `el-table-column` 加 header 插槽,当 `td.ps` 存在时用 `el-tooltip` 包裹列头文字:
|
||||
|
||||
```html
|
||||
<template #header v-if="td.ps">
|
||||
<el-tooltip :content="td.ps" placement="top">
|
||||
<span>{{ td.label }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
```
|
||||
|
||||
无 `ps` 的列保持原样。
|
||||
|
||||
### 3. Info.vue — 字段名 tooltip
|
||||
|
||||
类似地,在详情页的 `el-descriptions-item` 的 label 上加 tooltip 展示 `column.ps`。
|
||||
|
||||
## 关于"管理端空格后 label 未截断"的 bug
|
||||
|
||||
这个问题出在:当列已存在于配置(`coloum != nil` 分支),不会执行空格截断逻辑。如果 adminDB.json 里的 label 本身就包含空格后的文字,它会原样显示。
|
||||
|
||||
修复方式:在 makecode.go 的 `coloum != nil` 分支(即已有配置的列),也对 label 做同样的空格截断和 `()` / `{}` 提取处理。或者确认 adminDB.json 中的 label 是否需要手动清理。需要先确认具体是哪些列出现了这个问题。
|
||||
@@ -0,0 +1,328 @@
|
||||
---
|
||||
name: 支持达梦数据库
|
||||
overview: 在 HoTime 框架的现有 Dialect 方言体系下,新增达梦(DM8)数据库方言实现,涉及驱动引入、方言适配、连接配置、缓存层/代码生成/备份模块的 DM 分支补充。
|
||||
todos:
|
||||
- id: dm-driver
|
||||
content: 引入达梦 Go 驱动依赖(go.mod + import),确定驱动引入方式
|
||||
status: pending
|
||||
- id: dm-dialect
|
||||
content: 在 db/dialect.go 中实现 DMDialect(Quote/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.go(MySQL 特有 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.go(TestMain+TestApi),集成测试框架
|
||||
status: pending
|
||||
- id: example-config
|
||||
content: 修改 example/config/config.json,mysql 改为 mysql-(假注释禁用),新增 dm 配置
|
||||
status: pending
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# 支持达梦(DM8)数据库
|
||||
|
||||
## 现状分析
|
||||
|
||||
项目已有完善的 Dialect 接口体系([db/dialect.go](db/dialect.go)),以及 `initDialect()` 工厂方法([db/db.go](db/db.go)),当前支持 MySQL、PostgreSQL、SQLite 三种方言。需要新增达梦方言并在各处补充 `case "dm"` 分支。
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph currentState [当前架构]
|
||||
DialectIf["Dialect 接口<br/>(db/dialect.go)"]
|
||||
MySQL["MySQLDialect"]
|
||||
PG["PostgreSQLDialect"]
|
||||
SQLite["SQLiteDialect"]
|
||||
DialectIf --> MySQL
|
||||
DialectIf --> PG
|
||||
DialectIf --> SQLite
|
||||
end
|
||||
subgraph newState [新增]
|
||||
DM["DMDialect"]
|
||||
DialectIf --> DM
|
||||
end
|
||||
```
|
||||
|
||||
|
||||
|
||||
## 达梦 vs MySQL 关键 SQL 差异
|
||||
|
||||
- **标识符引号**: 达梦使用双引号 `"name"`(同 PostgreSQL),MySQL 用反引号
|
||||
- **自增列**: 达梦用 `IDENTITY(1,1)`,MySQL 用 `AUTO_INCREMENT`
|
||||
- **占位符**: 达梦使用 `?`(同 MySQL)
|
||||
- **LastInsertId**: 达梦不支持 `LAST_INSERT_ID()`,需用 `RETURNING "id"` 子句获取插入 ID(同 PostgreSQL)
|
||||
- **Upsert**: 达梦使用 `MERGE INTO ... USING ... WHEN MATCHED ... WHEN NOT MATCHED ...`(Oracle 风格)
|
||||
- **表存在检查**: `SELECT TABLE_NAME FROM USER_TABLES WHERE TABLE_NAME='xxx'`
|
||||
- **列信息查询**: `SELECT COLUMN_NAME, DATA_TYPE, COMMENTS FROM USER_COL_COMMENTS ...`
|
||||
- **时间戳转换**: 无 `FROM_UNIXTIME`,需用 `DATEADD(SECOND, ts, TIMESTAMP '1970-01-01 00:00:00')`
|
||||
- **INSERT IGNORE**: 不支持,需用 `MERGE INTO` 替代
|
||||
- **DDL 导出**: 无 `SHOW CREATE TABLE`,使用系统视图组合
|
||||
|
||||
## 涉及修改的文件
|
||||
|
||||
### 1. 新增达梦 Go 驱动(go module 方式)
|
||||
|
||||
使用达梦官方 module 化驱动 `gitee.com/chunanyong/dm`,通过标准 go module 流程引入:
|
||||
|
||||
```bash
|
||||
go get gitee.com/chunanyong/dm
|
||||
go mod vendor
|
||||
```
|
||||
|
||||
- [go.mod](go.mod) - 自动添加 `gitee.com/chunanyong/dm` 依赖
|
||||
- [db/db.go](db/db.go) - 添加 `import _ "gitee.com/chunanyong/dm"` 并在 `initDialect()` 添加 `case "dm"`
|
||||
- `vendor/` 目录通过 `go mod vendor` 自动同步
|
||||
|
||||
驱动注册名为 `"dm"`,连接串格式:`dm://user:password@host:port?schema=dbName`
|
||||
|
||||
### 2. 新增 DMDialect 方言
|
||||
|
||||
在 [db/dialect.go](db/dialect.go) 末尾新增 `DMDialect` 结构体,实现 `Dialect` 接口全部方法:
|
||||
|
||||
- `Quote` / `QuoteIdentifier` / `QuoteChar` - 使用双引号
|
||||
- `Placeholder` - 返回 `?`
|
||||
- `Placeholders` - 返回逗号分隔的 `?`
|
||||
- `SupportsLastInsertId` - 返回 `false`(达梦不支持 MySQL 的 `LAST_INSERT_ID()`)
|
||||
- `ReturningClause` - 返回 `RETURNING "id"`(用于 Insert 后获取自增 ID)
|
||||
- `UpsertSQL` - 生成 `MERGE INTO ... USING (SELECT ? AS col1, ...) src ON (...) WHEN MATCHED THEN UPDATE SET ... WHEN NOT MATCHED THEN INSERT ...`
|
||||
|
||||
### 3. 连接配置
|
||||
|
||||
- [application.go](application.go) - 新增 `SetDmDB()` 函数,连接串格式: `dm://user:password@host:port?schema=dbName`
|
||||
- [application.go](application.go) 的 `SetDB()` 中添加 `dbDm := db.GetMap("dm")` 分支
|
||||
- [var.go](var.go) - 在 `Config` 的 `"db"` 和 `ConfigNote` 的 `"db"` 下新增 `"dm"` 配置项
|
||||
|
||||
`var.go` 中 `ConfigNote` 新增的达梦配置说明如下:
|
||||
|
||||
```go
|
||||
"dm": Map{
|
||||
"注释": "达梦数据库配置,除prefix及主从数据库slave项,其他全部必须",
|
||||
"host": "默认127.0.0.1,必须,数据库ip地址",
|
||||
"name": "默认SYSDBA,必须,数据库schema名称",
|
||||
"user": "默认SYSDBA,必须,数据库用户名",
|
||||
"password": "默认SYSDBA,必须,数据库密码",
|
||||
"port": "默认5236,必须,数据库端口",
|
||||
"prefix": "默认空,非必须,数据表前缀",
|
||||
"slave": Map{
|
||||
"注释": "从数据库配置,dm里配置slave项即启用主从读写,减少数据库压力",
|
||||
"host": "默认127.0.0.1,必须,数据库ip地址",
|
||||
"name": "默认SYSDBA,必须,数据库schema名称",
|
||||
"user": "默认SYSDBA,必须,数据库用户名",
|
||||
"password": "默认SYSDBA,必须,数据库密码",
|
||||
"port": "默认5236,必须,数据库端口",
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
### 4. Insert 返回 ID 适配(关键兼容点)
|
||||
|
||||
当前 [db/crud.go](db/crud.go) 的 `Insert()` 直接调用 `res.LastInsertId()` 获取插入 ID,**但达梦不支持 `LAST_INSERT_ID()`**。Dialect 接口已预留了 `SupportsLastInsertId()` 和 `ReturningClause()` 两个方法但 Insert() 未使用。
|
||||
|
||||
修改 `Insert()` 函数逻辑:
|
||||
|
||||
```go
|
||||
if that.Dialect != nil && !that.Dialect.SupportsLastInsertId() {
|
||||
// 达梦/PostgreSQL: 用 RETURNING 子句 + QueryRow 获取 ID
|
||||
returningClause := that.Dialect.ReturningClause("id")
|
||||
query = strings.TrimSuffix(query, ";") + returningClause + ";"
|
||||
var insertedId int64
|
||||
err := that.QueryRow(query, values...).Scan(&insertedId)
|
||||
// ...
|
||||
id = insertedId
|
||||
} else {
|
||||
// MySQL/SQLite: 继续使用 Exec + LastInsertId
|
||||
res, err := that.Exec(query, values...)
|
||||
id1, _ := res.LastInsertId()
|
||||
id = id1
|
||||
}
|
||||
```
|
||||
|
||||
同理,`Upsert()` 函数也需在 `switch dbType` 中增加 `case "dm"` 分支,调用新增的 `buildDMUpsert()` 方法生成 `MERGE INTO` 语法。
|
||||
|
||||
**RowsAffected 无需额外处理**:`Update()` 和 `Delete()` 使用的 `res.RowsAffected()` 在达梦中正常工作。
|
||||
|
||||
### 5. 缓存层适配
|
||||
|
||||
[cache/cache_db.go](cache/cache_db.go) 中需在以下函数增加 `case "dm"` 分支:
|
||||
|
||||
- `tableExists()` - 使用 `SELECT TABLE_NAME FROM USER_TABLES WHERE TABLE_NAME='...'`
|
||||
- `createMainTable()` - 达梦建表 DDL(`IDENTITY(1,1)` 代替 `AUTO_INCREMENT`)
|
||||
- `createHistoryTable()` - 同上
|
||||
- `migrateFromCached()` - 使用 `MERGE INTO` 代替 `INSERT IGNORE`,用 `DATEADD` 代替 `FROM_UNIXTIME`
|
||||
- `set()` - 使用 `MERGE INTO` 实现 upsert
|
||||
|
||||
### 6. 代码生成适配
|
||||
|
||||
[code/makecode.go](code/makecode.go) 第 160-165 行,增加 `case "dm"` 分支:
|
||||
|
||||
```go
|
||||
if db.Type == "dm" {
|
||||
tableInfo = db.Select("USER_TAB_COLUMNS",
|
||||
"COLUMN_NAME AS name, DATA_TYPE AS type, COMMENTS AS label, NULLABLE AS must, DATA_DEFAULT AS dflt_value",
|
||||
Map{"TABLE_NAME": v.GetString("name")})
|
||||
}
|
||||
```
|
||||
|
||||
(注:需要 JOIN `USER_COL_COMMENTS` 获取列注释)
|
||||
|
||||
### 7. 备份模块适配
|
||||
|
||||
[db/backup.go](db/backup.go) 中:
|
||||
|
||||
- `backupDdl()` - `SHOW CREATE TABLE` 替换为通过系统视图(`USER_CONS_COLUMNS` + `USER_TAB_COLUMNS`)拼接 DDL
|
||||
- `backupSave()` / `backupCol()` - 将反引号替换为通过 Dialect 的 `Quote()` 方法动态生成
|
||||
|
||||
### 8. initDialect 注册
|
||||
|
||||
[db/db.go](db/db.go) 的 `initDialect()` 添加:
|
||||
|
||||
```go
|
||||
case "dm", "dameng":
|
||||
that.Dialect = &DMDialect{}
|
||||
```
|
||||
|
||||
## 驱动引入与 vendor 更新流程
|
||||
|
||||
采用标准 go module 方式,驱动包为 `gitee.com/chunanyong/dm`(达梦官方维护的 module 化版本):
|
||||
|
||||
```bash
|
||||
# 1. 添加依赖到 go.mod
|
||||
go get gitee.com/chunanyong/dm
|
||||
|
||||
# 2. 同步到 vendor 目录
|
||||
go mod vendor
|
||||
```
|
||||
|
||||
`go mod vendor` 会自动将 `gitee.com/chunanyong/dm` 及其传递依赖(`github.com/golang/snappy`、`golang.org/x/text`)下载并复制到 `vendor/` 目录。后续只需提交 `go.mod`、`go.sum` 和 `vendor/` 的变更即可。
|
||||
|
||||
### 9. 数据类型映射补充
|
||||
|
||||
[db/query.go](db/query.go) 的 `classifyDBType()` 函数(第 388-417 行)需要补充达梦特有类型:
|
||||
|
||||
- `NUMBER` -> `dbTypeDecimal`(达梦/Oracle 的通用数值类型)
|
||||
- `VARCHAR2` -> `dbTypeUnknown`(字符串类型,保持默认即可)
|
||||
|
||||
```go
|
||||
case "DECIMAL", "NUMERIC", "NEWDECIMAL", "NUMBER":
|
||||
return dbTypeDecimal
|
||||
```
|
||||
|
||||
### 10. 重构 example 目录 + 集成测试框架
|
||||
|
||||
**当前问题**:[example/main.go](example/main.go) 有 1427 行,所有测试逻辑堆在一个文件。
|
||||
|
||||
**重构后目录结构**:
|
||||
|
||||
```
|
||||
example/
|
||||
main.go -- 最小化:只有 Init + Run + 路由引用
|
||||
config/
|
||||
config.json -- mysql- (假注释禁用) + dm (启用)
|
||||
app/
|
||||
init.go -- Project 路由注册
|
||||
test.go -- TestCtr: 通用 ORM 测试(数据库无关)
|
||||
test_test.go -- TestTest: 测试定义
|
||||
mysql.go -- MysqlCtr: MySQL 特有测试
|
||||
mysql_test.go -- MysqlTest: MySQL 测试定义
|
||||
dmdb.go -- DmdbCtr: 达梦特有测试
|
||||
dmdb_test.go -- DmdbTest: 达梦测试定义
|
||||
cache.go -- CacheCtr: 缓存测试(共用)
|
||||
cache_test.go -- CacheTest: 缓存测试定义
|
||||
app_test.go -- TestMain + TestApi + ProjectTest
|
||||
```
|
||||
|
||||
**拆分原则**:
|
||||
|
||||
- **TestCtr**(通用 ORM 测试,数据库无关):`init`(根据 `that.Db.Type` 建表)、`crud`、`condition`、`chain`、`join`、`aggregate`、`pagination`、`batch`、`upsert`、`transaction`
|
||||
- **MysqlCtr**(MySQL 特有):`tables`(SHOW TABLES)、`describe`(DESCRIBE)、`rawsql`(反引号原生 SQL)
|
||||
- **DmdbCtr**(达梦特有):`tables`(USER_TABLES)、`describe`(USER_TAB_COLUMNS)、`rawsql`(双引号原生 SQL)
|
||||
- **CacheCtr**(缓存测试,共用):`all`、`compat`、`batch`
|
||||
|
||||
**main.go 最小化**:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
"code.hoteas.com/golang/hotime/example/app"
|
||||
)
|
||||
|
||||
func main() {
|
||||
appIns := Init("config/config.json")
|
||||
appIns.Run(Router{"app": app.Project})
|
||||
}
|
||||
```
|
||||
|
||||
**config.json 修改**(`mysql` 改为 `mysql-` 假注释禁用,新增 `dm`):
|
||||
|
||||
```json
|
||||
{
|
||||
"db": {
|
||||
"mysql-": {
|
||||
"host": "192.168.6.253",
|
||||
"name": "dgs-cms2601301",
|
||||
"password": "dasda8454456",
|
||||
"port": "3306",
|
||||
"user": "root"
|
||||
},
|
||||
"dm": {
|
||||
"host": "172.31.31.5",
|
||||
"port": "5236",
|
||||
"user": "SYSDBA",
|
||||
"password": "SC_sysdba2026",
|
||||
"name": "SYSDBA"
|
||||
}
|
||||
},
|
||||
"mode": 2,
|
||||
"port": "8081"
|
||||
}
|
||||
```
|
||||
|
||||
**测试框架集成**(`app_test.go`),运行 `go test ./example/app/... -v`
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 达梦默认对标识符大小写敏感(默认转大写),建表和查询时需注意统一使用双引号包裹
|
||||
- 达梦的 `MERGE INTO` 语法与 Oracle 基本一致,这是替代 MySQL `ON DUPLICATE KEY UPDATE` 和 `INSERT IGNORE` 的标准方式
|
||||
- 达梦默认端口为 5236,默认管理员账户为 SYSDBA
|
||||
- 达梦的 `NOW()` 函数在 DM8 中可用,`SYSDATE` 和 `CURRENT_TIMESTAMP` 也可用
|
||||
- 达梦支持 `LIMIT` 语法,分页查询无需额外适配
|
||||
- 达梦 Go 驱动使用 `?` 作为参数占位符,与 MySQL/SQLite 一致
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
name: 整理推荐写法风格
|
||||
overview: 将全文散落的"推荐/不推荐"对比块统一清理,文档全程只陈述正确做法,在「用例编写范式」末尾新增「简写支持说明」小节,介绍框架支持的简写行为,并以一句忠告收尾。
|
||||
todos:
|
||||
- id: clean-error-case-block
|
||||
content: 错误用例代码块:去掉 `// 推荐:` 前缀和整个「不推荐」代码段(第 246-256 行)
|
||||
status: completed
|
||||
- id: replace-bad-vs-good
|
||||
content: 「不好的写法 vs 推荐的写法」整节替换为「简写支持说明」,说明框架支持的简写行为+场景,以忠告句收尾(第 412-458 行)
|
||||
status: completed
|
||||
- id: clean-apiresponse-labels
|
||||
content: ApiResponse 去掉「推荐方式」标签,「兼容方式」改为自然描述(第 595-613 行)
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# 整理推荐写法风格
|
||||
|
||||
目标文件:[docs/Testing_API测试框架.md](docs/Testing_API测试框架.md)
|
||||
|
||||
## 三处改动
|
||||
|
||||
### 1. 第 246-256 行 — 错误用例编写规范代码块
|
||||
|
||||
当前:代码块里有 `// 推荐:...` 和 `// 不推荐:...` 两段
|
||||
|
||||
改后:只保留完整写法的代码,去掉 `// 推荐:` 注释前缀和整个"不推荐"代码段。
|
||||
|
||||
```go
|
||||
a.Post("未登录访问", 2, "请先登录")
|
||||
a.JSON(Map{"quantity": 3}).Post("缺少商品ID", 3, "请选择商品")
|
||||
a.JSON(Map{"goods_id": int64(1)}).Post("缺少数量", 3, "请填写购买数量")
|
||||
a.JSON(Map{"goods_id": int64(999)}).Post("商品不存在", 4, "商品不存在")
|
||||
```
|
||||
|
||||
### 2. 第 412-458 行 — 「不好的写法 vs 推荐的写法」整节
|
||||
|
||||
当前:三组 bad/good 对比块(省略 msg、只校验 status、硬编码 ID)
|
||||
|
||||
改后:整节替换为「简写支持说明」,结构如下:
|
||||
|
||||
- **标题**:`### 简写支持说明`
|
||||
- **说明框架支持的三种简写**,每种说明行为(不是批评,是告知机制):
|
||||
- 省略第三参数:`a.Post("请先登录", 2)` — desc 自动作为期望 msg,适合 desc 与 msg 完全一致的场景
|
||||
- 省略 expect:`a.Post("创建成功", 0)` — 仅断言 status=0,不校验 result 内容或数据库状态
|
||||
- 省略 Verify:适合纯查询、无副作用的接口,只需验证格式即可
|
||||
- **忠告**(一行):`> 对于涉及数据写入、状态变更、副作用的接口,省略过程校验等于充分不测试。`
|
||||
|
||||
### 3. 第 595-613 行 — ApiResponse「推荐方式 / 兼容方式」标签
|
||||
|
||||
当前:用「推荐方式」和「兼容方式」两个标签
|
||||
|
||||
改后:去掉「推荐方式 —」标签,直接展示 Verify 用法;「兼容方式」改为:`也可以通过返回值在外部断言(适合不需要 DB 校验的简单场景):`
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: 测试框架文档编写
|
||||
overview: 在 docs/ 目录新增 API 测试框架文档,并在 README.md 文档表格中增加入口链接,保持与现有文档风格一致。
|
||||
todos:
|
||||
- id: docs-testing
|
||||
content: 新增 docs/Testing_API测试框架.md 文档
|
||||
status: completed
|
||||
- id: readme-entry
|
||||
content: README.md 文档表格增加测试框架入口
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# API 测试框架文档
|
||||
|
||||
## 改动文件
|
||||
|
||||
### 1. 新增 `docs/Testing_API测试框架.md`
|
||||
|
||||
参照现有文档风格(如 QUICKSTART.md、HoTimeDB_API参考.md),包含以下章节:
|
||||
|
||||
- 概述(事务回滚隔离 + 链式 API + 覆盖率 + Swagger)
|
||||
- 快速开始(3 步接入示例)
|
||||
- 核心类型(TestProj/TestProjDef/ProjTest/CtrTest/ApiTestDef)
|
||||
- 链式 API 参考(Api/ApiCase/ApiResponse 的所有方法)
|
||||
- 完整场景对照表(GET/POST/JSON/Form/File/混合/WithSession)
|
||||
- 事务回滚机制说明(testTx + SAVEPOINT 原理)
|
||||
- 覆盖率报告(PrintCoverage 输出示例)
|
||||
- Swagger 文档生成(GenerateSwagger 用法)
|
||||
- 指定运行范围(go test -run 用法)
|
||||
- 框架改动说明(db 层 3 文件改动概述)
|
||||
|
||||
### 2. 修改 [README.md](d:/work/hotimev1.5/README.md)
|
||||
|
||||
在文档表格(第 18-27 行)中增加一行:
|
||||
|
||||
```markdown
|
||||
| [API 测试框架](docs/Testing_API测试框架.md) | 接口测试、事务隔离、覆盖率追踪、Swagger 文档生成 |
|
||||
```
|
||||
|
||||
插入位置:在"改进规划"行之前,作为文档表格的倒数第二行。
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
name: 精简测试框架文档
|
||||
overview: 将 Testing_API测试框架.md 从 1210 行精简到约 750-800 行,删除与"编写测试用例"无关的冗余内容,同时保留/强化全过程验证示例,防止 AI 写出只验证接口通不通的浅层测试。
|
||||
todos:
|
||||
- id: simplify-biz-code
|
||||
content: 「快速开始 → 第一步:业务代码」改成极简示例(3-5行的伪代码),不再展开完整 OrderCtr,节约约40行
|
||||
status: completed
|
||||
- id: delete-framework-changes
|
||||
content: 删除「框架改动说明」整节(约25行)
|
||||
status: completed
|
||||
- id: delete-login-compare
|
||||
content: 删除「不好的写法 vs 推荐的写法」中的登录完整对比示例(约45行),前面各小节已有对比,不需要再重复一遍
|
||||
status: completed
|
||||
- id: trim-swagger-console
|
||||
content: 缩减「API 调试控制台」,只保留 GenerateSwagger 调用方式和输出目录结构,删除14行功能特性表和侧边栏示意图(约-30行)
|
||||
status: completed
|
||||
- id: trim-api-spec-fields
|
||||
content: 删除「api-spec.json 覆盖率字段说明」中的字段详解大表和徽章规则(约-30行),只保留 JSON 结构示例
|
||||
status: completed
|
||||
- id: trim-concurrency
|
||||
content: 精简「并发保护与缓存隔离」,删除逐操作加锁保护表,只保留结论性描述(约-20行)
|
||||
status: completed
|
||||
- id: trim-apiresponse
|
||||
content: 删除 ApiResponse GetBody vs Obj 的解释性注释块(约-10行)
|
||||
status: completed
|
||||
- id: trim-run-range
|
||||
content: 删除「指定运行范围」末尾重复的子测试层级树(约-10行)
|
||||
status: completed
|
||||
- id: trim-binary
|
||||
content: 精简「二进制与非JSON响应校验」,删除图片下载示例(与xlsx示例高度重复,约-30行)
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# 精简测试框架文档
|
||||
|
||||
目标:[docs/Testing_API测试框架.md](docs/Testing_API测试框架.md) 从 1210 行 → 约 750-800 行
|
||||
|
||||
## 核心原则调整(与初稿的区别)
|
||||
|
||||
- **快速开始必须完整**:第一步「业务代码」不删除,改为极简示意(3-5行伪代码),让读者明白"这里有个接口"即可,不展开完整逻辑
|
||||
- **全过程验证示例必须保留并强调**:Verify + DB 校验 + 响应值断言的示例是文档核心,AI 容易偷懒只写 `Post("xxx", 0)` 不做任何校验,这种浅层用例要明确标为"不好的写法"
|
||||
- **其余按实用性裁剪**
|
||||
|
||||
## 删除/精简内容(约 -400 行)
|
||||
|
||||
- **精简「第一步:业务代码」**(-40 行):OrderCtr 有50行,改成极简伪代码 + 注释说明接口做什么,让后续测试示例有上下文即可
|
||||
- **删除「框架改动说明」整节**(-25 行):内部实现历史,对写测试无任何指导价值
|
||||
- **删除「不好的写法 → 登录完整对比」**(-45 行):前面3个对比子节已充分说明问题,第4个只是换了场景重复
|
||||
- **缩减「API 调试控制台」**(-30 行):只保留 `GenerateSwagger` 调用 + 输出目录结构,删除14行功能表和侧边栏结构图
|
||||
- **删除「api-spec.json 字段详解表」**(-30 行):与写测试无关
|
||||
- **精简「并发保护与缓存隔离」**(-20 行):删除逐操作加锁表,保留结论
|
||||
- **删除 ApiResponse GetBody vs Obj 解释块**(-10 行)
|
||||
- **删除「指定运行范围」末尾层级树**(-10 行):bash 示例中注释已足够
|
||||
- **精简「二进制响应校验」**(-30 行):只保留 xlsx + CSV,删除重复的图片示例
|
||||
|
||||
## 保留/强化内容
|
||||
|
||||
- 概述能力表格
|
||||
- **快速开始(完整4步,业务代码改极简)**
|
||||
- **用例编写范式(全部4节 + 不好vs推荐对比的前3节)** ← 核心,不裁
|
||||
- 核心类型
|
||||
- 链式 API 参考(三张表)
|
||||
- API 速查表
|
||||
- 事务隔离机制
|
||||
- 覆盖率报告(保留 PrintCoverage 输出示例)
|
||||
- 指定运行范围(bash 示例)
|
||||
- 并发保护与缓存隔离(精简版)
|
||||
- 二进制响应校验(精简版)
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
---
|
||||
name: 缓存表模式配置
|
||||
overview: 为 CacheDb 添加 mode 配置项,支持 "new"(默认,只用新表)和 "compatible"(写新读老)两种模式,并更新配置说明。
|
||||
todos:
|
||||
- id: add-mode-field
|
||||
content: 在 CacheDb 结构体中添加 Mode 字段
|
||||
status: completed
|
||||
- id: modify-init
|
||||
content: 修改 initDbTable,根据 Mode 决定是否迁移删除老表
|
||||
status: completed
|
||||
dependencies:
|
||||
- add-mode-field
|
||||
- id: add-legacy-get
|
||||
content: 添加 getLegacy 方法读取老表数据(unix时间戳格式)
|
||||
status: completed
|
||||
dependencies:
|
||||
- add-mode-field
|
||||
- id: modify-get
|
||||
content: 修改 get 方法,compatible 模式下回退读取老表
|
||||
status: completed
|
||||
dependencies:
|
||||
- add-legacy-get
|
||||
- id: modify-set
|
||||
content: 修改 set 方法,compatible 模式下写新表后删除老表同key记录
|
||||
status: completed
|
||||
dependencies:
|
||||
- add-mode-field
|
||||
- id: modify-delete
|
||||
content: 修改 delete 方法,compatible 模式下同时删除新表和老表
|
||||
status: completed
|
||||
dependencies:
|
||||
- add-mode-field
|
||||
- id: update-cache-init
|
||||
content: 在 cache.go Init 方法中读取 mode 配置
|
||||
status: completed
|
||||
dependencies:
|
||||
- add-mode-field
|
||||
- id: update-config-note
|
||||
content: 在 var.go ConfigNote 中添加 mode 配置说明
|
||||
status: completed
|
||||
- id: add-cache-test
|
||||
content: 在 example/main.go 中添加缓存测试路由
|
||||
status: completed
|
||||
dependencies:
|
||||
- modify-get
|
||||
- modify-set
|
||||
- modify-delete
|
||||
- update-cache-init
|
||||
- id: todo-1769763169689-k7t9twp5t
|
||||
content: |
|
||||
QUICKSTART.md 更新:缓存配置部分需要添加 mode 和 history 配置说明
|
||||
status: pending
|
||||
---
|
||||
|
||||
# 缓存表模式配置实现计划
|
||||
|
||||
## 需求概述
|
||||
|
||||
在 [`cache/cache_db.go`](cache/cache_db.go) 中实现两种缓存表模式:
|
||||
|
||||
- **new**(默认):只使用新的 `hotime_cache` 表,自动迁移老表数据
|
||||
- **compatible**:写入新表,读取时先查新表再查老表,老数据自然过期消亡
|
||||
|
||||
## 实现步骤
|
||||
|
||||
### 1. 修改 CacheDb 结构体
|
||||
|
||||
在 [`cache/cache_db.go`](cache/cache_db.go) 中添加 `Mode` 字段:
|
||||
|
||||
```go
|
||||
type CacheDb struct {
|
||||
TimeOut int64
|
||||
DbSet bool
|
||||
SessionSet bool
|
||||
HistorySet bool
|
||||
Mode string // "new"(默认) 或 "compatible"
|
||||
Db HoTimeDBInterface
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 修改初始化逻辑 initDbTable
|
||||
|
||||
- **new 模式**:创建新表、迁移老表数据,**但不删除老表**(删除交给用户手动操作,更安全)
|
||||
- **compatible 模式**:创建新表,不迁移也不删除老表
|
||||
|
||||
两种模式都不自动删除老表,避免自动删除造成数据丢失风险
|
||||
|
||||
### 3. 修改 get 方法
|
||||
|
||||
- **new 模式**:只从新表读取
|
||||
- **compatible 模式**:先从新表读取,如果没有再从老表读取
|
||||
|
||||
需要新增 `getLegacy` 方法来读取老表数据,该方法需要:
|
||||
|
||||
1. 查询老表数据
|
||||
2. 检查 `endtime`(unix 时间戳)是否过期
|
||||
3. 如果过期:删除该条记录,返回 nil
|
||||
4. 如果未过期:返回数据
|
||||
|
||||
### 4. 修改 set 方法(写新删老)
|
||||
|
||||
- **new 模式**:只写新表(老表保留但不再管理)
|
||||
- **compatible 模式**:写入新表 + 删除老表中相同 key 的记录
|
||||
|
||||
这样可以主动加速老数据消亡,而不是等自然过期。
|
||||
|
||||
### 5. 修改 delete 方法
|
||||
|
||||
- **new 模式**:只删除新表(老表保留但不再管理)
|
||||
- **compatible 模式**:同时删除新表和老表中的 key
|
||||
|
||||
这是必要的,否则删除新表后,下次读取会回退读到老表的数据,造成"删不掉"的问题。
|
||||
|
||||
需要新增 `deleteLegacy` 方法处理老表删除逻辑
|
||||
|
||||
### 6. 更新缓存初始化
|
||||
|
||||
在 [`cache/cache.go`](cache/cache.go) 的 `Init` 方法中读取 `mode` 配置:
|
||||
|
||||
```go
|
||||
that.dbCache = &CacheDb{
|
||||
// ...
|
||||
Mode: db.GetString("mode"), // 读取 mode 配置
|
||||
}
|
||||
```
|
||||
|
||||
### 7. 更新配置说明
|
||||
|
||||
在 [`var.go`](var.go) 的 `ConfigNote` 中添加 `mode` 配置说明:
|
||||
|
||||
```go
|
||||
"db": Map{
|
||||
// ...
|
||||
"mode": "默认new,非必须,new为只使用新表(自动迁移老数据),compatible为兼容模式(写新表读老表,老数据自然过期)",
|
||||
}
|
||||
```
|
||||
|
||||
### 8. 编写测试
|
||||
|
||||
在 [`example/main.go`](example/main.go) 中添加缓存测试路由,测试覆盖:
|
||||
|
||||
**new 模式测试:**
|
||||
|
||||
- 基础读写:set/get/delete 正常工作
|
||||
- 过期测试:设置短过期时间,验证过期后读取返回 nil
|
||||
- 数据迁移:验证老表数据能正确迁移到新表
|
||||
- 老表保留:验证迁移后老表仍存在(不自动删除)
|
||||
|
||||
**compatible 模式测试:**
|
||||
|
||||
- 新表读写:优先从新表读取
|
||||
- 老表回退:新表没有时从老表读取
|
||||
- 过期检测:读取老表过期数据时返回 nil 并删除该记录
|
||||
- 写新删老:写入新表后老表同 key 记录被删除
|
||||
- 删除双表:删除操作同时删除新表和老表记录
|
||||
- 通配删除:`key*` 格式删除测试
|
||||
|
||||
**边界情况测试:**
|
||||
|
||||
- 空值处理:nil 值的 set/get
|
||||
- 不存在的 key 读取
|
||||
- 重复 set 同一个 key
|
||||
- 超时时间参数测试(默认/自定义)
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Config[配置]
|
||||
ModeNew["mode: new (默认)"]
|
||||
ModeCompat["mode: compatible"]
|
||||
end
|
||||
|
||||
subgraph NewMode[new模式]
|
||||
N1[初始化] --> N2[创建新表]
|
||||
N2 --> N3{老表存在?}
|
||||
N3 -->|是| N4[迁移数据到新表]
|
||||
N4 --> N5[保留老表由人工删除]
|
||||
N3 -->|否| N6[完成]
|
||||
N5 --> N6
|
||||
|
||||
NR[读取] --> NR1[只查询新表]
|
||||
NW[写入] --> NW1[只写入新表]
|
||||
ND[删除] --> ND1[只删除新表记录]
|
||||
end
|
||||
|
||||
subgraph CompatMode[compatible模式]
|
||||
C1[初始化] --> C2[创建新表]
|
||||
C2 --> C3[保留老表]
|
||||
|
||||
CR[读取] --> CR1[查询新表]
|
||||
CR1 -->|未找到| CR2[查询老表]
|
||||
CR2 --> CR3{过期?}
|
||||
CR3 -->|是| CR4[删除老表记录]
|
||||
CR4 --> CR5[返回nil]
|
||||
CR3 -->|否| CR6[返回数据]
|
||||
|
||||
CW[写入] --> CW1[写入新表]
|
||||
CW1 --> CW2[删除老表同key]
|
||||
|
||||
CD[删除] --> CD1[删除新表记录]
|
||||
CD1 --> CD2[删除老表记录]
|
||||
end
|
||||
```
|
||||
|
||||
## 文件修改列表
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|
||||
|------|----------|
|
||||
|
||||
| [`cache/cache_db.go`](cache/cache_db.go) | 添加 Mode 字段、修改 initDbTable、添加 getLegacy(含过期检测删除)、修改 get、修改 set(写新删老)、修改 delete |
|
||||
|
||||
| [`cache/cache.go`](cache/cache.go) | 读取 mode 配置 |
|
||||
|
||||
| [`var.go`](var.go) | ConfigNote 添加 mode 说明 |
|
||||
|
||||
| [`example/main.go`](example/main.go) | 添加缓存测试路由 |
|
||||
|
||||
| [`example/config/config.json`](example/config/config.json) | 可选:添加 mode 配置示例 |
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
name: 补充 Note 方法示例
|
||||
overview: 在 Testing_API测试框架.md 的"第二步:编写测试用例"部分补充 Note 方法的使用示例和说明,同步更新 SKILL.md。
|
||||
todos:
|
||||
- id: doc-step2-example
|
||||
content: 文档:在第二步的完整订单示例中加入 Note 调用示范
|
||||
status: completed
|
||||
- id: doc-note-section
|
||||
content: 文档:在用例编写范式中新增 Note 使用说明(典型场景 + 示例 + 何时必须用)
|
||||
status: completed
|
||||
- id: doc-hotimev15-sync
|
||||
content: 同步修改 hotimev1.5 中的 Testing_API测试框架.md
|
||||
status: completed
|
||||
- id: skill-note-guide
|
||||
content: SKILL.md:补充 Note 使用指引和更多场景示例
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# 补充 Note 方法示例到文档和 Skill
|
||||
|
||||
## 现状分析
|
||||
|
||||
目前 `Note` 方法在文档中的存在情况:
|
||||
|
||||
- [链式 API 参考](d:\work\your-app\docs\Testing_API测试框架.md) 第 474、494 行:仅出现在 API 表格中,一句话描述
|
||||
- [API 速查表](d:\work\your-app\docs\Testing_API测试框架.md) 第 662-668 行:只有一个简短示例(`sign = MD5(...)`)
|
||||
- [SKILL.md](C:\Users\92597.cursor\skills\hotime-tdd-testing\SKILL.md) 第 195-197 行:一行示例
|
||||
|
||||
**缺失**:"第二步:编写测试用例"(第 66-149 行)的完整订单示例中完全没有出现 `Note`,而这是新手学习的第一个入口。
|
||||
|
||||
## 需要修改的内容
|
||||
|
||||
### 1. 文档:第二步示例中加入 Note 使用
|
||||
|
||||
在 [Testing_API测试框架.md](d:\work\your-app\docs\Testing_API测试框架.md) 第 80-148 行的订单创建示例中,在**正确请求**部分加入 `Note` 调用,展示以下几种典型场景:
|
||||
|
||||
- 在正确请求处用 `Note` 说明参数值含义(如 `quantity=3` 是故意的用于校验总价 `29.9*3=89.7`)
|
||||
|
||||
### 2. 文档:用例编写范式中新增 Note 独立小节
|
||||
|
||||
在"用例编写范式"部分(第 219 行之后),在"错误用例"之前或之后,新增一个关于 Note 的说明段落或小节,覆盖以下典型使用场景:
|
||||
|
||||
- **参数值参考**:说明某个参数值的来源或含义,如 `Note("status: 0=待付款, 1=已付款, 2=已发货, 3=已完成")`
|
||||
- **算法说明**:简要描述接口涉及的签名/加密算法,如 `Note("sign = MD5(smsProxyKey+timestamp)")`
|
||||
- **接口/用例说明**:补充 desc 无法涵盖的上下文,如 `Note("需先调用 /api/cart/add 加入购物车")` 或 `Note("此接口支持批量操作,最多100条")`
|
||||
- 在用例编写范式表格中增加 Note 的定位说明
|
||||
|
||||
### 3. 文档:同步更新 hotimev1.5 中的同名文件
|
||||
|
||||
[hotimev1.5/docs/Testing_API测试框架.md](d:\work\hotimev1.5\docs\Testing_API测试框架.md) 需要做相同的修改。
|
||||
|
||||
### 4. Skill 文件:补充 Note 指引
|
||||
|
||||
在 [SKILL.md](C:\Users\92597.cursor\skills\hotime-tdd-testing\SKILL.md) 中:
|
||||
|
||||
- 在 Phase 1 的 1.3 小节之后(或 1.2 和 1.3 之间),新增 Note 的使用指引
|
||||
- 说明何时应该使用 Note(有必要的场景),给出 2-3 个典型示例
|
||||
- 在 API 速查的"备注"部分补充更多场景示例
|
||||
|
||||
## 修改要点
|
||||
|
||||
- Note 不改变测试逻辑,只是给调试控制台和 api-spec.json 添加可读说明
|
||||
- Note 是可选的,但在以下场景**建议必须**使用:
|
||||
- 参数中有枚举值(如 status、type)需要说明各值含义
|
||||
- 涉及签名/加密等算法的接口
|
||||
- 接口有前置依赖或特殊调用顺序
|
||||
- Note 可以放在链式调用的任意位置(`a.Note(...)` 或 `c.Note(...)`)
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
---
|
||||
name: 重写API测试框架文档
|
||||
overview: 重新编撰 Testing_API测试框架.md,以"错误用例先行 → 准备数据 → 正确请求(含响应断言+DB校验)"的完整流程为核心范式,让文档成为可直接照搬的编写指南。
|
||||
todos:
|
||||
- id: rewrite-doc
|
||||
content: 重写 docs/Testing_API测试框架.md:错误用例先行 → 模拟数据准备 → 正确请求(响应断言必须+DB校验) → 逐步讲解各环节 → API速查表 → 其余章节微调
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# 重写 API 测试框架文档
|
||||
|
||||
## 用户确认的编写规范
|
||||
|
||||
1. **顺序**:先写错误请求用例,再写正确请求用例
|
||||
2. **错误用例**:三个参数必须填满 `Post("描述", status, "具体错误消息")`,因为错误码是复用的(如 status=3 可能对应多种参数校验错误)
|
||||
3. **正确请求前**:用 `a.DB()` 模拟插入数据,既准备测试依赖数据,也可作为后续 DB 校验的对照
|
||||
4. **响应字段断言**:正确响应情况下**必须**断言(至少覆盖重要字段),不能只写 `Post("描述", 0)`
|
||||
5. **数据库校验**:只要接口改变了数据库状态,都**建议**用 Verify 校验结果
|
||||
|
||||
## 标准用例编写流程(范式)
|
||||
|
||||
```
|
||||
错误用例(参数校验、权限校验等)
|
||||
↓
|
||||
准备测试数据(a.DB().Insert/Update 模拟前置数据)
|
||||
↓
|
||||
正确请求 + 响应结构校验(ExpectResult / 第三参数 Map)
|
||||
↓
|
||||
响应字段断言(resp.GetBody() 检查关键业务字段)
|
||||
↓
|
||||
数据库状态校验(Verify 查库确认写入/更新结果)
|
||||
```
|
||||
|
||||
## 核心范例(贯穿文档的"创建订单"示例)
|
||||
|
||||
以下为快速开始中使用的完整范例,展示从业务代码到测试用例的映射:
|
||||
|
||||
```go
|
||||
// order_test.go
|
||||
var OrderTest = CtrTest{
|
||||
"create": {Desc: "创建订单", Func: func(a *Api) {
|
||||
// ======== 第一步:错误用例 ========
|
||||
// 错误码复用,三个参数都必须填满
|
||||
a.JSON(Map{"goods_id": int64(1), "quantity": 3, "address": "XX路1号"}).
|
||||
Post("未登录创建订单", 2, "请先登录")
|
||||
a.WithSession(Map{"user_id": int64(1)}).
|
||||
JSON(Map{"quantity": 3, "address": "XX路1号"}).
|
||||
Post("缺少商品ID", 3, "请选择商品")
|
||||
a.WithSession(Map{"user_id": int64(1)}).
|
||||
JSON(Map{"goods_id": int64(1), "address": "XX路1号"}).
|
||||
Post("缺少数量", 3, "请填写购买数量")
|
||||
a.WithSession(Map{"user_id": int64(1)}).
|
||||
JSON(Map{"goods_id": int64(999), "quantity": 3, "address": "XX路1号"}).
|
||||
Post("商品不存在", 4, "商品不存在")
|
||||
|
||||
// ======== 第二步:准备测试数据 ========
|
||||
// 插入一条商品数据,后面的正确请求会依赖它
|
||||
goodsId := a.DB().Insert("goods", Map{
|
||||
"name": "测试商品", "price": 29.9, "stock": 100, "state": 1,
|
||||
})
|
||||
|
||||
// ======== 第三步:正确请求 + 响应断言 + DB校验 ========
|
||||
resp := a.WithSession(Map{"user_id": int64(1)}).
|
||||
JSON(Map{"goods_id": goodsId, "quantity": 3, "address": "XX路1号"}).
|
||||
Verify(func(a *Api) error {
|
||||
row := a.DB().Get("order", "*", Map{"AND": Map{
|
||||
"user_id": int64(1), "goods_id": goodsId,
|
||||
}})
|
||||
if row == nil {
|
||||
return fmt.Errorf("order 表未写入订单记录")
|
||||
}
|
||||
if row.GetCeilInt64("state") != 0 {
|
||||
return fmt.Errorf("新订单 state 期望 0, 实际 %d", row.GetCeilInt64("state"))
|
||||
}
|
||||
if row.GetCeilFloat64("total_price") != 29.9*3 {
|
||||
return fmt.Errorf("total_price 期望 %.2f, 实际 %.2f", 29.9*3, row.GetCeilFloat64("total_price"))
|
||||
}
|
||||
// 校验库存扣减
|
||||
goods := a.DB().Get("goods", "stock", Map{"id": goodsId})
|
||||
if goods.GetCeilInt64("stock") != 97 {
|
||||
return fmt.Errorf("库存期望 97, 实际 %d", goods.GetCeilInt64("stock"))
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Post("正常创建订单", 0, Map{
|
||||
"id": int64(1), "sn": "sample", "total_price": float64(1.0),
|
||||
})
|
||||
|
||||
// 关键业务字段断言
|
||||
orderId := resp.GetBody().GetMap("result").GetCeilInt64("id")
|
||||
if orderId <= 0 {
|
||||
resp.Fail("返回的订单 ID 必须大于 0")
|
||||
}
|
||||
sn := resp.GetBody().GetMap("result").GetString("sn")
|
||||
if sn == "" {
|
||||
resp.Fail("返回的订单编号 sn 不能为空")
|
||||
}
|
||||
}},
|
||||
}
|
||||
```
|
||||
|
||||
## 文档结构
|
||||
|
||||
### 不变章节(微调措辞)
|
||||
|
||||
- 概述(能力表格)
|
||||
- 核心类型
|
||||
- 链式 API 参考(在 expect 参数规则部分强调:错误用例建议三参数写满)
|
||||
- 事务隔离机制
|
||||
- 覆盖率报告
|
||||
- API 调试控制台
|
||||
- 指定运行范围
|
||||
- 并发保护与缓存隔离
|
||||
- 框架改动说明
|
||||
|
||||
### 重写/新增章节
|
||||
|
||||
#### 1. "快速开始" — 使用上述完整范例
|
||||
|
||||
- 先展示业务代码(handler),再展示测试代码
|
||||
- 范例中完整体现五步流程
|
||||
- 目录结构、注册方式、运行方式保持原有说明
|
||||
|
||||
#### 2. "用例编写范式" — 新增章节,替代原"完整场景对照"
|
||||
|
||||
按顺序逐步讲解范例中的每个环节:
|
||||
|
||||
- **错误用例编写规范**:为什么三参数必须写满(错误码复用)、如何覆盖权限/参数/业务错误
|
||||
- **测试数据准备**:`a.DB().Insert()` 的使用场景和注意事项(事务内操作,自动回滚)
|
||||
- **正确请求 + 响应结构校验**:第三参数 Map 的类型样本规则、ExpectResult 后置校验
|
||||
- **响应字段断言**:`resp.GetBody()` / `resp.Fail()` 的用法、哪些字段必须断言
|
||||
- **数据库状态校验**:Verify 的使用时机(只要改了DB就建议校验)、校验主表+关联表+字段值
|
||||
- 包含"不好的写法 vs 推荐的写法"对比
|
||||
|
||||
#### 3. "API 速查表" — 精简版场景对照
|
||||
|
||||
保留各种参数组合(JSON/Form/Query/File/混合/Session 等)的快速参考,但每种只一行示例代码,不再是大段散乱的代码块。
|
||||
|
||||
## 改动范围
|
||||
|
||||
- 仅改动 `docs/Testing_API测试框架.md`,约 750-850 行
|
||||
- 不涉及 Go 源码
|
||||
- 不涉及其他文档
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
description: HoTime Doc-Driven + TDD 总门禁
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# HoTime 工作流
|
||||
|
||||
**不可跳过。** Doc-Driven 与 TDD 缺一不可。日常五步:①定位 → ②修改 → ③补/改行为测试并针对性跑至绿 → ④边界 → ⑤回写文档。未测绿或未回写 = **未完成**。中文回复。
|
||||
|
||||
## 定位
|
||||
|
||||
- 先锁约 **3~4 个文件**(文档 1~2 + 源码 1~2);跨模块扩到合计约 **4~6**,禁止散改全仓。本次新增/修改的**测试文件计入锁定**。先读相关 docs 再改。口径不清 **先问**。
|
||||
- 入口:`docs/README.md`、仓根 `README.md`;测试细则 → `docs/Testing_API测试框架.md` / skill `hotime-tdd-testing`。
|
||||
|
||||
## 改与测
|
||||
|
||||
- 改接口、ctr、业务逻辑或测试框架:对应 `*_test` **无则补、有则改**,跑通至绿;**禁**只改实现不补测;**禁全量**;编译不算测完。
|
||||
- 样板(`example` 根):`go test ./app/ -count=1 -run 'TestApi/app/<ctr>/<action>'`。可收窄到子用例;**改哪测哪**。用户要求全量或以编译代替行为测时,**明确拒绝**并仍按本条。
|
||||
|
||||
## 文档
|
||||
|
||||
- **有旧改旧**;无篇目且确有缺口才新建。禁不查就建、禁同一入口重复建档。旧文只补受影响节;只写最终态;对照源码,禁臆测。
|
||||
- 路径用相对 Markdown 链接;**禁** `D:\`、`file:///`、`~/`。新建须回写 `docs/README.md`。
|
||||
|
||||
## 回复
|
||||
|
||||
- 先结论;摘要 + 文档路径 + **改动定位(文件·位置·原因)**;禁大段复述 docs。
|
||||
- 凡改代码或报完成:末尾必须一行 `【门禁】锁定:… | 补测并跑绿:是/否 | 文档回写:是/否/无 | 新建or旧文:…`;缺一不可。闲聊不要求。
|
||||
|
||||
## 批量补文档(仅整包)
|
||||
|
||||
一文一题;最少 2 轮自检。日常小改不适用。
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"setup-worktree": [
|
||||
"npm install"
|
||||
]
|
||||
}
|
||||
+20
@@ -2,5 +2,25 @@
|
||||
.idea
|
||||
/example/tpt/demo/
|
||||
*.exe
|
||||
*.test
|
||||
*.out
|
||||
/cover
|
||||
/coverage.out
|
||||
/coverage.html
|
||||
/example/config
|
||||
/config/
|
||||
/tpt/
|
||||
example/tpt/swagger/
|
||||
**/swagger/
|
||||
/.cursor/*.log
|
||||
*.sql
|
||||
*.py
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Cursor / agent debug NDJSON leftovers
|
||||
debug-*.log
|
||||
**/debug-*.log
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# HoTime
|
||||
# HoTime
|
||||
|
||||
**高性能 Go Web 服务框架**
|
||||
|
||||
@@ -11,20 +11,27 @@
|
||||
- **三级缓存** - 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 分钟入门,安装配置、路由、中间件、基础数据库操作 |
|
||||
| [快速上手](docs/QuickStart_快速上手.md) | 5 分钟入门:安装配置、路由、中间件 |
|
||||
| [HoTimeDB 使用说明](docs/HoTimeDB_使用说明.md) | 完整数据库 ORM 教程 |
|
||||
| [HoTimeDB API 参考](docs/HoTimeDB_API参考.md) | 数据库 API 速查手册 |
|
||||
| [Common 工具类](docs/Common_工具类使用说明.md) | Map/Slice/Obj 类型、类型转换、工具函数 |
|
||||
| [代码生成器](docs/CodeGen_使用说明.md) | 自动 CRUD 代码生成、配置规则 |
|
||||
| [数据库设计规范](docs/DatabaseDesign_数据库设计规范.md) | 表命名、字段命名、时间类型、必有字段规范 |
|
||||
| [代码生成配置规范](docs/CodeConfig_代码生成配置规范.md) | codeConfig、菜单权限、字段规则配置说明 |
|
||||
| [改进规划](docs/ROADMAP_改进规划.md) | 待改进项、设计思考、版本迭代规划 |
|
||||
| [Common 工具类](docs/Common_工具类.md) | Map/Slice/Obj 类型、类型转换、工具函数 |
|
||||
| [数据库设计规范](docs/DatabaseDesign_数据库设计规范.md) | 表命名、字段命名、COMMENT、必有字段(CodeGen 前置) |
|
||||
| [代码生成](docs/CodeGen_代码生成.md) | 自动 CRUD 生成 + codeConfig / rule 配置 |
|
||||
| [API 测试框架](docs/Testing_API测试框架.md) | 接口测试、事务隔离、覆盖率、调试控制台 |
|
||||
| [优雅停机](docs/GracefulShutdown_优雅停机.md) | 跨平台优雅停机、nginx、滚动重启 |
|
||||
| [Seq 日志集成](docs/Seq_日志集成.md) | Seq 接入、异步队列、多实例区分 |
|
||||
| [改进规划](docs/ROADMAP_改进规划.md) | 待改进项与版本迭代规划 |
|
||||
|
||||
## 安装
|
||||
|
||||
@@ -59,6 +66,8 @@ go get code.hoteas.com/golang/hotime
|
||||
| 内置缓存 | ✅ 三级缓存 | ❌ | ❌ | ❌ |
|
||||
| Session | ✅ 内置 | ❌ 需插件 | ❌ 需插件 | ❌ 需插件 |
|
||||
| 代码生成 | ✅ | ❌ | ❌ | ❌ |
|
||||
| API 测试框架 | ✅ 内置 | ❌ 需插件 | ❌ 需插件 | ❌ 需插件 |
|
||||
| 优雅停机 | ✅ 内置跨平台 | ❌ 需自行实现 | ❌ 需自行实现 | ✅ 内置 |
|
||||
| 微信/支付集成 | ✅ 内置 | ❌ | ❌ | ❌ |
|
||||
|
||||
## 适用场景
|
||||
@@ -82,7 +91,7 @@ go get code.hoteas.com/golang/hotime
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
Apache License 2.0
|
||||
|
||||
---
|
||||
|
||||
|
||||
+263
-87
@@ -1,47 +1,106 @@
|
||||
package hotime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
stdlog "log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
. "code.hoteas.com/golang/hotime/cache"
|
||||
"code.hoteas.com/golang/hotime/code"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
. "code.hoteas.com/golang/hotime/db"
|
||||
. "code.hoteas.com/golang/hotime/log"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"github.com/sirupsen/logrus"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"code.hoteas.com/golang/hotime/log"
|
||||
mysql "github.com/go-sql-driver/mysql"
|
||||
logrus "github.com/sirupsen/logrus"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type Application struct {
|
||||
MakeCodeRouter map[string]*code.MakeCode
|
||||
MethodRouter
|
||||
Router
|
||||
Error
|
||||
Log *logrus.Logger
|
||||
WebConnectLog *logrus.Logger
|
||||
Log *log.Logger
|
||||
WebConnectLog *log.Logger
|
||||
Port string //端口号
|
||||
TLSPort string //ssl访问端口号
|
||||
connectListener []func(that *Context) bool //所有的访问监听,true按原计划继续使用,false表示有监听器处理
|
||||
connectDbFunc func(err ...*Error) (master, slave *sql.DB)
|
||||
connectDbFunc func() (master, slave *sql.DB)
|
||||
configPath string
|
||||
Config Map
|
||||
Db HoTimeDB
|
||||
*HoTimeCache
|
||||
*http.Server
|
||||
http.Handler
|
||||
shuttingDown atomic.Bool // 停机标志,置为 true 后新请求立即返回 503
|
||||
inFlight atomic.Int64 // 当前正在处理的请求数(不含已返回 503 的)
|
||||
shutdownOnce sync.Once // 保证多路触发(信号/关窗口)只执行一次
|
||||
DrainTimeout time.Duration // 已停止接收新请求后、等老请求自然结束的最长时长,默认 5s(每 1s 检测在途请求,为 0 则立即进入 Shutdown)
|
||||
ShutdownTimeout time.Duration // drain 超时后再给老请求的最长容忍时间,默认 10s(到期后强制关闭)
|
||||
}
|
||||
|
||||
func (that *Application) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
if that.shuttingDown.Load() {
|
||||
w.Header().Set("Connection", "close")
|
||||
http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
that.inFlight.Add(1)
|
||||
defer that.inFlight.Add(-1)
|
||||
that.handler(w, req)
|
||||
}
|
||||
|
||||
// initiateGracefulShutdown 触发优雅停机,通过 sync.Once 保证多路触发只执行一次。
|
||||
// drain: 已停止接收新请求后、等老请求自然结束的最长时长,每 1s 检测一次在途请求数,
|
||||
// 为 0 则立即进入 Shutdown,避免无谓等待。
|
||||
// shutdown: drain 超时后再给老请求的最长容忍时间,到期强制关闭。
|
||||
func (that *Application) initiateGracefulShutdown(drain, shutdown time.Duration) {
|
||||
that.shutdownOnce.Do(func() {
|
||||
that.shuttingDown.Store(true)
|
||||
that.Log.Infof("优雅停机:已停止接收新请求(后续请求一律返回 503),等待老请求自然结束,最多再等 %v...", drain)
|
||||
|
||||
// 每秒检测一次,若无在途请求立即停机,否则最多等满 drain
|
||||
tick := time.NewTicker(1 * time.Second)
|
||||
defer tick.Stop()
|
||||
deadline := time.Now().Add(drain)
|
||||
for {
|
||||
n := that.inFlight.Load()
|
||||
if n == 0 {
|
||||
that.Log.Infof("已无在途请求,立即停机")
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
that.Log.Infof("已停止接收新请求,仍有 %d 个老请求未结束,将在最多 %v 后强制结束这些请求", n, shutdown)
|
||||
break
|
||||
}
|
||||
that.Log.Infof("已停止接收新请求,当前仍有 %d 个老请求在执行,继续等待...", n)
|
||||
<-tick.C
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shutdown)
|
||||
defer cancel()
|
||||
if err := that.Server.Shutdown(ctx); err != nil {
|
||||
that.Log.Errorf("等待超时,强制关闭仍在执行的 %d 个请求: %v", that.inFlight.Load(), err)
|
||||
} else {
|
||||
that.Log.Infof("服务已安全关闭")
|
||||
}
|
||||
os.Exit(0)
|
||||
})
|
||||
}
|
||||
|
||||
// Run 启动实例
|
||||
func (that *Application) Run(router Router) {
|
||||
//如果没有设置配置自动生成配置
|
||||
@@ -127,8 +186,11 @@ func (that *Application) Run(router Router) {
|
||||
//异常处理
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
if that.shuttingDown.Load() {
|
||||
return // 停机过程中的 panic 不触发重启
|
||||
}
|
||||
//that.SetError(errors.New(fmt.Sprint(err)), LOG_FMT)
|
||||
that.Log.Warn(err)
|
||||
that.Log.EmitRecoveredPanic(err)
|
||||
|
||||
that.Run(router)
|
||||
}
|
||||
@@ -148,8 +210,10 @@ func (that *Application) Run(router Router) {
|
||||
//启动服务
|
||||
that.Server.Addr = ":" + that.Port
|
||||
err := that.Server.ListenAndServe()
|
||||
that.Log.Error(err)
|
||||
ch <- 1
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
that.Log.Error().Err(err).Msg("HTTP 服务退出")
|
||||
ch <- 1
|
||||
}
|
||||
|
||||
}()
|
||||
}
|
||||
@@ -162,33 +226,54 @@ func (that *Application) Run(router Router) {
|
||||
//启动服务
|
||||
that.Server.Addr = ":" + that.TLSPort
|
||||
err := that.Server.ListenAndServeTLS(that.Config.GetString("tlsCert"), that.Config.GetString("tlsKey"))
|
||||
that.Log.Error(err)
|
||||
ch <- 2
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
that.Log.Error().Err(err).Msg("HTTPS 服务退出")
|
||||
ch <- 2
|
||||
}
|
||||
|
||||
}()
|
||||
}
|
||||
if ObjToCeilInt(that.Port) == 0 && ObjToCeilInt(that.TLSPort) == 0 {
|
||||
that.Log.Error("没有端口启用")
|
||||
that.Log.Errorf("没有端口启用")
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
// 注册 Windows 关窗口处理(Linux/macOS 上是空实现)
|
||||
that.registerWindowsCloseHandler()
|
||||
|
||||
// 跨平台信号监听:Ctrl+C / SIGTERM(kill 命令)/ taskkill /PID
|
||||
go func() {
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||
<-sigCh
|
||||
drain := that.DrainTimeout
|
||||
if drain == 0 {
|
||||
drain = 5 * time.Second
|
||||
}
|
||||
shutdown := that.ShutdownTimeout
|
||||
if shutdown == 0 {
|
||||
shutdown = 10 * time.Second
|
||||
}
|
||||
that.initiateGracefulShutdown(drain, shutdown)
|
||||
}()
|
||||
|
||||
value := <-ch
|
||||
|
||||
that.Log.Error("启动服务失败 : " + ObjToStr(value))
|
||||
that.Log.Errorf("启动服务失败 : %s", ObjToStr(value))
|
||||
}
|
||||
|
||||
// SetConnectDB 启动实例
|
||||
func (that *Application) SetConnectDB(connect func(err ...*Error) (master, slave *sql.DB)) {
|
||||
func (that *Application) SetConnectDB(connect func() (master, slave *sql.DB)) {
|
||||
|
||||
that.connectDbFunc = connect
|
||||
|
||||
that.Db.SetConnect(that.connectDbFunc, &that.Error)
|
||||
that.Db.SetConnect(that.connectDbFunc)
|
||||
|
||||
}
|
||||
|
||||
// SetDefault 默认配置缓存和session实现
|
||||
func (that *Application) SetDefault(connect func(err ...*Error) (*sql.DB, *sql.DB)) {
|
||||
func (that *Application) SetDefault(connect func() (*sql.DB, *sql.DB)) {
|
||||
that.SetConfig()
|
||||
|
||||
if connect != nil {
|
||||
@@ -201,19 +286,15 @@ func (that *Application) SetDefault(connect func(err ...*Error) (*sql.DB, *sql.D
|
||||
// SetCache 设置配置文件路径全路径或者相对路径
|
||||
func (that *Application) SetCache() {
|
||||
cacheIns := HoTimeCache{}
|
||||
cacheIns.Init(that.Config.GetMap("cache"), HoTimeDBInterface(&that.Db), &that.Error)
|
||||
cacheIns.Init(that.Config.GetMap("cache"), HoTimeDBInterface(&that.Db), that.Log)
|
||||
that.HoTimeCache = &cacheIns
|
||||
//mode生产模式开启的时候才开启数据库缓存,防止调试出问题
|
||||
if that.Config.GetInt("mode") == 0 {
|
||||
that.Db.HoTimeCache = &cacheIns
|
||||
}
|
||||
that.Db.HoTimeCache = &cacheIns
|
||||
}
|
||||
|
||||
// SetConfig 设置配置文件路径全路径或者相对路径
|
||||
func (that *Application) SetConfig(configPath ...string) {
|
||||
|
||||
that.Log = GetLog("", true)
|
||||
that.Error = Error{Logger: that.Log}
|
||||
that.Log = log.NewLogger(1, "", 100)
|
||||
if len(configPath) != 0 {
|
||||
that.configPath = configPath[0]
|
||||
}
|
||||
@@ -224,55 +305,67 @@ func (that *Application) SetConfig(configPath ...string) {
|
||||
//加载配置文件
|
||||
btes, err := ioutil.ReadFile(that.configPath)
|
||||
that.Config = DeepCopyMap(Config).(Map)
|
||||
configErr := false
|
||||
if err == nil {
|
||||
|
||||
cmap := Map{}
|
||||
//文件是否损坏
|
||||
cmap.JsonToMap(string(btes), &that.Error)
|
||||
cmap.JsonToMap(string(btes))
|
||||
|
||||
for k, v := range cmap {
|
||||
that.Config[k] = v //程序配置
|
||||
Config[k] = v //系统配置
|
||||
that.Config[k] = v
|
||||
Config[k] = v
|
||||
}
|
||||
} else {
|
||||
that.Log.Error("配置文件不存在,或者配置出错,使用缺省默认配置")
|
||||
|
||||
that.Log.Errorf("配置文件不存在,或者配置出错,使用缺省默认配置")
|
||||
}
|
||||
|
||||
if that.Error.GetError() != nil {
|
||||
fmt.Println(that.Error.GetError().Error())
|
||||
}
|
||||
|
||||
//文件如果损坏则不写入配置防止配置文件数据丢失
|
||||
if that.Error.GetError() == nil {
|
||||
//var configByte bytes.Buffer
|
||||
|
||||
//判断配置文件是否序列有变化,有则修改配置,无则不变
|
||||
//fmt.Println(len(btes))
|
||||
if !configErr {
|
||||
configStr := that.Config.ToJsonString()
|
||||
if len(btes) != 0 && configStr == string(btes) {
|
||||
return
|
||||
// 配置无变化,跳过写入
|
||||
} else {
|
||||
configNoteStr := ConfigNote.ToJsonString()
|
||||
_ = os.MkdirAll(filepath.Dir(that.configPath), os.ModeDir)
|
||||
err = ioutil.WriteFile(that.configPath, []byte(configStr), os.ModePerm)
|
||||
if err != nil {
|
||||
that.Log.Error().Err(err).Msg("写入配置文件失败")
|
||||
configErr = true
|
||||
}
|
||||
_ = ioutil.WriteFile(filepath.Dir(that.configPath)+"/configNote.json", []byte(configNoteStr), os.ModePerm)
|
||||
}
|
||||
//写入配置说明
|
||||
//var configNoteByte bytes.Buffer
|
||||
configNoteStr := ConfigNote.ToJsonString()
|
||||
//_ = json.Indent(&configNoteByte, []byte(ConfigNote.ToJsonString()), "", "\t")
|
||||
|
||||
_ = os.MkdirAll(filepath.Dir(that.configPath), os.ModeDir)
|
||||
err = ioutil.WriteFile(that.configPath, []byte(configStr), os.ModePerm)
|
||||
if err != nil {
|
||||
that.Error.SetError(err)
|
||||
}
|
||||
_ = ioutil.WriteFile(filepath.Dir(that.configPath)+"/configNote.json", []byte(configNoteStr), os.ModePerm)
|
||||
|
||||
}
|
||||
|
||||
that.Log = GetLog(that.Config.GetString("logFile"), true)
|
||||
that.Error = Error{Logger: that.Log}
|
||||
logLevel := that.Config.GetCeilInt("logLevel")
|
||||
logFile := that.Config.GetString("logFile")
|
||||
logHistory := that.Config.GetCeilInt("logHistory")
|
||||
if logHistory == 0 {
|
||||
logHistory = 100
|
||||
}
|
||||
that.Log = log.NewLogger(logLevel, logFile, logHistory)
|
||||
that.Db.Log = that.Log
|
||||
if that.Config.Get("webConnectLogShow") == nil || that.Config.GetBool("webConnectLogShow") {
|
||||
that.WebConnectLog = GetLog(that.Config.GetString("webConnectLogFile"), false)
|
||||
that.WebConnectLog = log.NewLoggerNoCaller(1, that.Config.GetString("webConnectLogFile"), 0)
|
||||
}
|
||||
|
||||
// 重定向 os.Stdout 和标准 log 包,捕获 fmt.Println 等绕过 HoTime Logger 的输出
|
||||
stdoutW := redirectStdout(that.Log)
|
||||
bridgeThirdPartyStdout(stdoutW)
|
||||
// 重定向 os.Stderr,捕获 panic 栈、MySQL driver errLog 等写 stderr 的输出
|
||||
// 注意:Logger 在此之前已创建,ConsoleWriter.Out 已绑定到原始 os.Stderr,无循环风险
|
||||
redirectStderr(that.Log)
|
||||
|
||||
// 接入 Seq:instance = ip:port,同机多进程靠端口区分,跨机器靠 IP 区分
|
||||
if seqUrl := that.Config.GetString("seqUrl"); seqUrl != "" {
|
||||
instance := getLocalIP() + ":" + that.Config.GetString("port")
|
||||
that.Log.SetSeqWriter(seqUrl, that.Config.GetString("seqApiKey"), instance)
|
||||
if that.WebConnectLog != nil {
|
||||
that.WebConnectLog.SetSeqWriter(seqUrl, that.Config.GetString("seqApiKey"), instance)
|
||||
}
|
||||
that.Log.Infof("Seq 日志推送已启动: url=%s instance=%s", seqUrl, instance)
|
||||
}
|
||||
|
||||
// 把 MySQL 驱动内部 errLog(连接/包错误)接入 HoTime Logger,经 SeqWriter 推送到 Seq
|
||||
_ = mysql.SetLogger(&mysqlLogAdapter{l: that.Log})
|
||||
|
||||
}
|
||||
|
||||
// SetConnectListener 连接判断,返回false继续传输至控制层,true则停止传输
|
||||
@@ -376,9 +469,12 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) {
|
||||
ipStr = Substr(context.Req.RemoteAddr, 0, strings.Index(context.Req.RemoteAddr, ":"))
|
||||
}
|
||||
|
||||
that.WebConnectLog.Infoln(ipStr, context.Req.Method,
|
||||
"time cost:", ObjToFloat64(time.Now().UnixNano()-nowUnixTime.UnixNano())/1000000.00, "ms",
|
||||
"data length:", ObjToFloat64(context.DataSize)/1000.00, "KB", context.HandlerStr)
|
||||
that.WebConnectLog.Info().
|
||||
Str("ip", ipStr).
|
||||
Str("method", context.Req.Method).
|
||||
Float64("cost_ms", ObjToFloat64(time.Now().UnixNano()-nowUnixTime.UnixNano())/1000000.00).
|
||||
Float64("size_kb", ObjToFloat64(context.DataSize)/1000.00).
|
||||
Msg(context.HandlerStr)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -411,8 +507,8 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
//url赋值
|
||||
path := that.Config.GetString("tpt") + tempHandlerStr
|
||||
//url赋值,静态文件必须使用原始路径(Linux文件系统大小写敏感)
|
||||
path := that.Config.GetString("tpt") + context.HandlerStr
|
||||
|
||||
//判断是否为默认
|
||||
if path[len(path)-1] == '/' {
|
||||
@@ -440,7 +536,7 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) {
|
||||
|
||||
//设置header
|
||||
delete(header, "Content-Type")
|
||||
if that.Config.GetInt("mode") == 0 {
|
||||
if that.Config.GetCeilInt("logLevel") == 0 {
|
||||
header.Set("Cache-Control", "public")
|
||||
} else {
|
||||
header.Set("Cache-Control", "no-cache")
|
||||
@@ -448,7 +544,7 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) {
|
||||
|
||||
t := strings.LastIndex(path, ".")
|
||||
if t != -1 {
|
||||
tt := path[t:]
|
||||
tt := strings.ToLower(path[t:])
|
||||
|
||||
if MimeMaps[tt] != "" {
|
||||
|
||||
@@ -553,6 +649,50 @@ 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 Logger(level=error, source=stderr)。
|
||||
func redirectStderr(l *log.Logger) {
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
l.EmitCaptured(zerolog.WarnLevel, "stderr-capture", fmt.Sprintf("create pipe failed: %v", err))
|
||||
return
|
||||
}
|
||||
os.Stderr = w
|
||||
go log.CaptureStream(l, zerolog.ErrorLevel, "stderr", r)
|
||||
}
|
||||
|
||||
// Init 初始化application
|
||||
func Init(config string) *Application {
|
||||
appIns := Application{}
|
||||
@@ -579,7 +719,7 @@ func Init(config string) *Application {
|
||||
codeMake["name"] = codeMake.GetString("table")
|
||||
}
|
||||
|
||||
appIns.MakeCodeRouter[codeMake.GetString("name")] = &code.MakeCode{Error: appIns.Error}
|
||||
appIns.MakeCodeRouter[codeMake.GetString("name")] = &code.MakeCode{Log: appIns.Log}
|
||||
appIns.MakeCodeRouter[codeMake.GetString("name")].Db2JSON(&appIns.Db, codeMake)
|
||||
|
||||
//接入动态代码层
|
||||
@@ -626,12 +766,16 @@ func SetDB(appIns *Application) {
|
||||
db := appIns.Config.GetMap("db")
|
||||
dbSqlite := db.GetMap("sqlite")
|
||||
dbMysql := db.GetMap("mysql")
|
||||
dbDm := db.GetMap("dm")
|
||||
if db != nil && dbSqlite != nil {
|
||||
SetSqliteDB(appIns, dbSqlite)
|
||||
}
|
||||
if db != nil && dbMysql != nil {
|
||||
SetMysqlDB(appIns, dbMysql)
|
||||
}
|
||||
if db != nil && dbDm != nil {
|
||||
SetDmDB(appIns, dbDm)
|
||||
}
|
||||
}
|
||||
func SetMysqlDB(appIns *Application, config Map) {
|
||||
|
||||
@@ -639,45 +783,66 @@ func SetMysqlDB(appIns *Application, config Map) {
|
||||
appIns.Db.DBName = config.GetString("name")
|
||||
appIns.Db.Prefix = config.GetString("prefix")
|
||||
appIns.Db.Log = appIns.Log
|
||||
appIns.Db.Mode = appIns.Config.GetCeilInt("mode")
|
||||
appIns.SetConnectDB(func(err ...*Error) (master, slave *sql.DB) {
|
||||
//master数据库配置
|
||||
appIns.SetConnectDB(func() (master, slave *sql.DB) {
|
||||
query := config.GetString("user") + ":" + config.GetString("password") +
|
||||
"@tcp(" + config.GetString("host") + ":" + config.GetString("port") + ")/" + config.GetString("name") + "?charset=utf8"
|
||||
DB, e := sql.Open("mysql", query)
|
||||
if e != nil && len(err) != 0 {
|
||||
err[0].SetError(e)
|
||||
if e != nil {
|
||||
appIns.Log.Error().Err(e).Msg("MySQL 主库连接失败")
|
||||
}
|
||||
master = DB
|
||||
//slave数据库配置
|
||||
configSlave := config.GetMap("slave")
|
||||
if configSlave != nil {
|
||||
query := configSlave.GetString("user") + ":" + configSlave.GetString("password") +
|
||||
"@tcp(" + config.GetString("host") + ":" + configSlave.GetString("port") + ")/" + configSlave.GetString("name") + "?charset=utf8"
|
||||
DB1, e := sql.Open("mysql", query)
|
||||
if e != nil && len(err) != 0 {
|
||||
err[0].SetError(e)
|
||||
if e != nil {
|
||||
appIns.Log.Error().Err(e).Msg("MySQL 从库连接失败")
|
||||
}
|
||||
slave = DB1
|
||||
}
|
||||
|
||||
return master, slave
|
||||
//return DB
|
||||
})
|
||||
}
|
||||
func SetSqliteDB(appIns *Application, config Map) {
|
||||
|
||||
appIns.Db.Type = "sqlite"
|
||||
appIns.Db.Prefix = config.GetString("prefix")
|
||||
appIns.Db.Mode = appIns.Config.GetCeilInt("mode")
|
||||
appIns.Db.Log = appIns.Log
|
||||
appIns.SetConnectDB(func(err ...*Error) (master, slave *sql.DB) {
|
||||
appIns.SetConnectDB(func() (master, slave *sql.DB) {
|
||||
db, e := sql.Open("sqlite3", config.GetString("path"))
|
||||
if e != nil && len(err) != 0 {
|
||||
err[0].SetError(e)
|
||||
if e != nil {
|
||||
appIns.Log.Error().Err(e).Msg("SQLite 连接失败")
|
||||
}
|
||||
master = db
|
||||
return master, slave
|
||||
})
|
||||
}
|
||||
|
||||
func SetDmDB(appIns *Application, config Map) {
|
||||
appIns.Db.Type = "dm"
|
||||
appIns.Db.DBName = config.GetString("name")
|
||||
appIns.Db.Prefix = config.GetString("prefix")
|
||||
appIns.Db.Log = appIns.Log
|
||||
appIns.SetConnectDB(func() (master, slave *sql.DB) {
|
||||
query := "dm://" + config.GetString("user") + ":" + config.GetString("password") +
|
||||
"@" + config.GetString("host") + ":" + config.GetString("port") + "?schema=" + config.GetString("name")
|
||||
DB, e := sql.Open("dm", query)
|
||||
if e != nil {
|
||||
appIns.Log.Error().Err(e).Msg("达梦主库连接失败")
|
||||
}
|
||||
master = DB
|
||||
configSlave := config.GetMap("slave")
|
||||
if configSlave != nil {
|
||||
querySlave := "dm://" + configSlave.GetString("user") + ":" + configSlave.GetString("password") +
|
||||
"@" + configSlave.GetString("host") + ":" + configSlave.GetString("port") + "?schema=" + configSlave.GetString("name")
|
||||
DB1, e := sql.Open("dm", querySlave)
|
||||
if e != nil {
|
||||
appIns.Log.Error().Err(e).Msg("达梦从库连接失败")
|
||||
}
|
||||
slave = DB1
|
||||
}
|
||||
return master, slave
|
||||
})
|
||||
}
|
||||
@@ -711,6 +876,9 @@ func setMakeCodeListener(name string, appIns *Application) {
|
||||
}
|
||||
}
|
||||
|
||||
if context.RouterString[0] != name {
|
||||
return isFinished
|
||||
}
|
||||
if len(context.RouterString) < 2 || len(context.RouterString) > 3 ||
|
||||
!(context.Router[context.RouterString[0]] != nil &&
|
||||
context.Router[context.RouterString[0]][context.RouterString[1]] != nil) {
|
||||
@@ -792,3 +960,11 @@ func setMakeCodeListener(name string, appIns *Application) {
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// mysqlLogAdapter 将 go-sql-driver/mysql 的内部 errLog 桥接到 HoTime Logger。
|
||||
// MySQL 驱动连接/包级别错误通过此适配器以 error 级别推送到 Seq。
|
||||
type mysqlLogAdapter struct{ l *log.Logger }
|
||||
|
||||
func (a *mysqlLogAdapter) Print(v ...interface{}) {
|
||||
a.l.Error().Str("source", "mysql-driver").Msg(fmt.Sprint(v...))
|
||||
}
|
||||
|
||||
Vendored
+223
-34
@@ -1,15 +1,14 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
"code.hoteas.com/golang/hotime/log"
|
||||
)
|
||||
|
||||
// HoTimeCache 可配置memory,db,redis,默认启用memory,默认优先级为memory>redis>db,memory与数据库缓存设置项一致,
|
||||
// 缓存数据填充会自动反方向反哺,加入memory缓存过期将自动从redis更新,但memory永远不会更新redis,如果是集群建议不要开启memory,配置即启用
|
||||
type HoTimeCache struct {
|
||||
*Error
|
||||
Log *log.Logger
|
||||
dbCache *CacheDb
|
||||
redisCache *CacheRedis
|
||||
memoryCache *CacheMemory
|
||||
@@ -181,16 +180,215 @@ func (that *HoTimeCache) Cache(key string, data ...interface{}) *Obj {
|
||||
return reData
|
||||
}
|
||||
|
||||
func (that *HoTimeCache) Init(config Map, hotimeDb HoTimeDBInterface, err ...*Error) {
|
||||
//防止空数据问题
|
||||
// SessionsGet 批量获取 Session 缓存
|
||||
// 返回 Map,key 为缓存键,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: Map,key 为缓存键,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 批量获取普通缓存
|
||||
// 返回 Map,key 为缓存键,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: Map,key 为缓存键,value 为缓存值
|
||||
func (that *HoTimeCache) CachesSet(data Map) {
|
||||
if len(data) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if that.memoryCache != nil {
|
||||
that.memoryCache.CachesSet(data)
|
||||
}
|
||||
if that.redisCache != nil {
|
||||
that.redisCache.CachesSet(data)
|
||||
}
|
||||
if that.dbCache != nil {
|
||||
that.dbCache.CachesSet(data)
|
||||
}
|
||||
}
|
||||
|
||||
// CachesDelete 批量删除普通缓存
|
||||
func (that *HoTimeCache) CachesDelete(keys []string) {
|
||||
if len(keys) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if that.memoryCache != nil {
|
||||
that.memoryCache.CachesDelete(keys)
|
||||
}
|
||||
if that.redisCache != nil {
|
||||
that.redisCache.CachesDelete(keys)
|
||||
}
|
||||
if that.dbCache != nil {
|
||||
that.dbCache.CachesDelete(keys)
|
||||
}
|
||||
}
|
||||
|
||||
func (that *HoTimeCache) Init(config Map, hotimeDb HoTimeDBInterface, logger *log.Logger) {
|
||||
if config == nil {
|
||||
config = Map{}
|
||||
}
|
||||
if err[0] != nil {
|
||||
that.Error = err[0]
|
||||
}
|
||||
that.Log = logger
|
||||
that.Config = config
|
||||
//memory配置初始化
|
||||
|
||||
memory := that.Config.GetMap("memory")
|
||||
if memory == nil {
|
||||
memory = Map{
|
||||
@@ -199,70 +397,59 @@ func (that *HoTimeCache) Init(config Map, hotimeDb HoTimeDBInterface, err ...*Er
|
||||
"sort": 0,
|
||||
"timeout": 60 * 60 * 2,
|
||||
}
|
||||
|
||||
}
|
||||
if memory.Get("db") == nil {
|
||||
memory["db"] = true
|
||||
}
|
||||
|
||||
if memory.Get("session") == nil {
|
||||
memory["session"] = true
|
||||
}
|
||||
|
||||
if memory.Get("timeout") == nil {
|
||||
memory["timeout"] = 60 * 60 * 2
|
||||
}
|
||||
that.Config["memory"] = memory
|
||||
that.memoryCache = &CacheMemory{TimeOut: memory.GetCeilInt64("timeout"),
|
||||
DbSet: memory.GetBool("db"), SessionSet: memory.GetBool("session")}
|
||||
if err[0] != nil {
|
||||
that.memoryCache.SetError(err[0])
|
||||
}
|
||||
DbSet: memory.GetBool("db"), SessionSet: memory.GetBool("session"),
|
||||
Log: logger}
|
||||
|
||||
//db配置初始化
|
||||
redis := that.Config.GetMap("redis")
|
||||
if redis != nil {
|
||||
if redis.GetString("host") == "" || redis.GetString("port") == "" {
|
||||
if err[0] != nil {
|
||||
err[0].SetError(errors.New("请检查redis配置host和port配置"))
|
||||
if logger != nil {
|
||||
logger.Error().Msg("请检查redis配置host和port配置")
|
||||
}
|
||||
return
|
||||
}
|
||||
if redis.Get("db") == nil {
|
||||
redis["db"] = true
|
||||
}
|
||||
|
||||
if redis.Get("session") == nil {
|
||||
redis["session"] = true
|
||||
}
|
||||
|
||||
if redis.Get("timeout") == nil {
|
||||
redis["timeout"] = 60 * 60 * 24 * 15
|
||||
}
|
||||
that.Config["redis"] = redis
|
||||
that.redisCache = &CacheRedis{TimeOut: redis.GetCeilInt64("timeout"),
|
||||
DbSet: redis.GetBool("db"), SessionSet: redis.GetBool("session"), Host: redis.GetString("host"),
|
||||
Pwd: redis.GetString("password"), Port: redis.GetCeilInt64("port")}
|
||||
|
||||
if err[0] != nil {
|
||||
that.redisCache.SetError(err[0])
|
||||
}
|
||||
Pwd: redis.GetString("password"), Port: redis.GetCeilInt64("port"),
|
||||
Log: logger}
|
||||
}
|
||||
|
||||
//db配置初始化
|
||||
db := that.Config.GetMap("db")
|
||||
if db != nil {
|
||||
|
||||
if db.Get("db") == nil {
|
||||
db["db"] = false
|
||||
}
|
||||
|
||||
if db.Get("session") == nil {
|
||||
db["session"] = true
|
||||
}
|
||||
if db.Get("timeout") == nil {
|
||||
db["timeout"] = 60 * 60 * 24 * 30
|
||||
}
|
||||
if db.Get("mode") == nil {
|
||||
db["mode"] = CacheModeCompatible
|
||||
}
|
||||
that.Config["db"] = db
|
||||
|
||||
that.dbCache = &CacheDb{
|
||||
@@ -270,12 +457,14 @@ func (that *HoTimeCache) Init(config Map, hotimeDb HoTimeDBInterface, err ...*Er
|
||||
DbSet: db.GetBool("db"),
|
||||
SessionSet: db.GetBool("session"),
|
||||
HistorySet: db.GetBool("history"),
|
||||
Mode: db.GetString("mode"),
|
||||
Db: hotimeDb,
|
||||
}
|
||||
|
||||
if err[0] != nil {
|
||||
that.dbCache.SetError(err[0])
|
||||
Log: logger,
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (that *HoTimeCache) DisableDbCache() {
|
||||
that.dbCache = nil
|
||||
that.redisCache = nil
|
||||
}
|
||||
|
||||
Vendored
+291
-144
@@ -3,42 +3,27 @@ package cache
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
"code.hoteas.com/golang/hotime/log"
|
||||
)
|
||||
|
||||
// #region agent log
|
||||
func debugLog(hypothesisId, location, message string, data map[string]interface{}) {
|
||||
logEntry := fmt.Sprintf(`{"hypothesisId":"%s","location":"%s","message":"%s","data":%s,"timestamp":%d,"sessionId":"debug-session"}`,
|
||||
hypothesisId, location, message, toJSON(data), time.Now().UnixMilli())
|
||||
f, _ := os.OpenFile(`d:\work\hotimev1.5\.cursor\debug.log`, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if f != nil {
|
||||
f.WriteString(logEntry + "\n")
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
func toJSON(data map[string]interface{}) string {
|
||||
b, _ := json.Marshal(data)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// 表名常量
|
||||
const (
|
||||
CacheTableName = "hotime_cache"
|
||||
CacheHistoryTableName = "hotime_cache_history"
|
||||
LegacyCacheTableName = "cached" // 老版本缓存表名
|
||||
DefaultCacheTimeout = 24 * 60 * 60 // 默认过期时间 24 小时
|
||||
CacheModeNew = "new" // 新模式:只使用新表
|
||||
CacheModeCompatible = "compatible" // 兼容模式:写新读老
|
||||
)
|
||||
|
||||
type HoTimeDBInterface interface {
|
||||
GetPrefix() string
|
||||
Query(query string, args ...interface{}) []Map
|
||||
Exec(query string, args ...interface{}) (sql.Result, *Error)
|
||||
Exec(query string, args ...interface{}) (sql.Result, error)
|
||||
Get(table string, qu ...interface{}) Map
|
||||
Select(table string, qu ...interface{}) []Map
|
||||
Delete(table string, data map[string]interface{}) int64
|
||||
@@ -51,21 +36,14 @@ type CacheDb struct {
|
||||
TimeOut int64
|
||||
DbSet bool
|
||||
SessionSet bool
|
||||
HistorySet bool // 是否开启历史记录
|
||||
HistorySet bool // 是否开启历史记录
|
||||
Mode string // 缓存模式:"new"(默认,只用新表) 或 "compatible"(写新读老)
|
||||
Db HoTimeDBInterface
|
||||
*Error
|
||||
Log *log.Logger
|
||||
ContextBase
|
||||
isInit bool
|
||||
}
|
||||
|
||||
func (that *CacheDb) GetError() *Error {
|
||||
return that.Error
|
||||
}
|
||||
|
||||
func (that *CacheDb) SetError(err *Error) {
|
||||
that.Error = err
|
||||
}
|
||||
|
||||
// getTableName 获取带前缀的表名
|
||||
func (that *CacheDb) getTableName() string {
|
||||
return that.Db.GetPrefix() + CacheTableName
|
||||
@@ -76,6 +54,24 @@ func (that *CacheDb) getHistoryTableName() string {
|
||||
return that.Db.GetPrefix() + CacheHistoryTableName
|
||||
}
|
||||
|
||||
// getLegacyTableName 获取带前缀的老版本缓存表名
|
||||
func (that *CacheDb) getLegacyTableName() string {
|
||||
return that.Db.GetPrefix() + LegacyCacheTableName
|
||||
}
|
||||
|
||||
// isCompatibleMode 是否为兼容模式
|
||||
func (that *CacheDb) isCompatibleMode() bool {
|
||||
return that.Mode == CacheModeCompatible
|
||||
}
|
||||
|
||||
// getEffectiveMode 获取有效模式(默认为 new)
|
||||
func (that *CacheDb) getEffectiveMode() string {
|
||||
if that.Mode == CacheModeCompatible {
|
||||
return CacheModeCompatible
|
||||
}
|
||||
return CacheModeNew
|
||||
}
|
||||
|
||||
// initDbTable 初始化数据库表
|
||||
func (that *CacheDb) initDbTable() {
|
||||
if that.isInit {
|
||||
@@ -85,39 +81,26 @@ func (that *CacheDb) initDbTable() {
|
||||
dbType := that.Db.GetType()
|
||||
tableName := that.getTableName()
|
||||
historyTableName := that.getHistoryTableName()
|
||||
|
||||
// #region agent log
|
||||
debugLog("F", "cache_db.go:initDbTable", "initDbTable started", map[string]interface{}{
|
||||
"dbType": dbType, "tableName": tableName, "historyTableName": historyTableName, "HistorySet": that.HistorySet,
|
||||
})
|
||||
// #endregion
|
||||
legacyTableName := that.getLegacyTableName()
|
||||
|
||||
// 检查并创建主表
|
||||
if !that.tableExists(tableName) {
|
||||
that.createMainTable(dbType, tableName)
|
||||
}
|
||||
|
||||
// 检查并迁移旧 cached 表
|
||||
oldTableName := that.Db.GetPrefix() + "cached"
|
||||
if that.tableExists(oldTableName) {
|
||||
that.migrateFromCached(dbType, oldTableName, tableName)
|
||||
// 根据模式处理老表
|
||||
// new 模式:迁移老表数据到新表(不删除老表,由人工删除)
|
||||
// compatible 模式:不迁移,老表继续使用
|
||||
if that.getEffectiveMode() == CacheModeNew {
|
||||
if that.tableExists(legacyTableName) {
|
||||
that.migrateFromCached(dbType, legacyTableName, tableName)
|
||||
}
|
||||
}
|
||||
// compatible 模式不做任何处理,老表保留供读取
|
||||
|
||||
// 检查并创建历史表(开启历史记录时)
|
||||
historyTableExists := that.tableExists(historyTableName)
|
||||
// #region agent log
|
||||
debugLog("F", "cache_db.go:initDbTable", "history table check", map[string]interface{}{
|
||||
"HistorySet": that.HistorySet, "historyTableName": historyTableName, "exists": historyTableExists,
|
||||
"shouldCreate": that.HistorySet && !historyTableExists,
|
||||
})
|
||||
// #endregion
|
||||
if that.HistorySet && !historyTableExists {
|
||||
if that.HistorySet && !that.tableExists(historyTableName) {
|
||||
that.createHistoryTable(dbType, historyTableName)
|
||||
// #region agent log
|
||||
debugLog("F", "cache_db.go:initDbTable", "createHistoryTable called", map[string]interface{}{
|
||||
"dbType": dbType, "historyTableName": historyTableName,
|
||||
})
|
||||
// #endregion
|
||||
}
|
||||
|
||||
that.isInit = true
|
||||
@@ -144,6 +127,15 @@ func (that *CacheDb) tableExists(tableName string) bool {
|
||||
case "postgres":
|
||||
res := that.Db.Query(`SELECT tablename FROM pg_tables WHERE schemaname='public' AND tablename='` + tableName + `'`)
|
||||
return len(res) != 0
|
||||
|
||||
case "dm", "dameng":
|
||||
// USER_TABLES 仅含当前登录用户(如 SYSDBA)拥有的表。
|
||||
// 当连接 schema(如 ZWBG)与登录用户不同时,ZWBG 的表不会出现在
|
||||
// USER_TABLES 中,导致 tableExists 误判为"不存在",进而触发重复建表失败。
|
||||
// 改为直接 SELECT COUNT(*) 探测当前 schema 下的可访问性:
|
||||
// 表存在则返回 1 行,表不存在则报错返回空结果(nil)。
|
||||
res := that.Db.Query(`SELECT COUNT(*) as cnt FROM "` + tableName + `"`)
|
||||
return len(res) > 0
|
||||
}
|
||||
|
||||
return false
|
||||
@@ -190,7 +182,21 @@ func (that *CacheDb) createMainTable(dbType, tableName string) {
|
||||
"modify_time" TIMESTAMP
|
||||
)`
|
||||
that.Db.Exec(createSQL)
|
||||
// 创建索引
|
||||
that.Db.Exec(`CREATE INDEX "idx_` + tableName + `_end_time" ON "` + tableName + `" ("end_time")`)
|
||||
return
|
||||
|
||||
case "dm", "dameng":
|
||||
createSQL = `CREATE TABLE "` + tableName + `" (
|
||||
"id" INT IDENTITY(1,1) PRIMARY KEY,
|
||||
"key" VARCHAR(64) NOT NULL,
|
||||
"value" TEXT,
|
||||
"end_time" TIMESTAMP,
|
||||
"state" INT DEFAULT 0,
|
||||
"create_time" TIMESTAMP,
|
||||
"modify_time" TIMESTAMP,
|
||||
CONSTRAINT "uk_` + tableName + `_key" UNIQUE ("key")
|
||||
)`
|
||||
that.Db.Exec(createSQL)
|
||||
that.Db.Exec(`CREATE INDEX "idx_` + tableName + `_end_time" ON "` + tableName + `" ("end_time")`)
|
||||
return
|
||||
}
|
||||
@@ -241,7 +247,21 @@ func (that *CacheDb) createHistoryTable(dbType, tableName string) {
|
||||
"modify_time" TIMESTAMP
|
||||
)`
|
||||
that.Db.Exec(createSQL)
|
||||
// 创建索引
|
||||
that.Db.Exec(`CREATE INDEX "idx_` + tableName + `_cache_id" ON "` + tableName + `" ("hotime_cache_id")`)
|
||||
return
|
||||
|
||||
case "dm", "dameng":
|
||||
createSQL = `CREATE TABLE "` + tableName + `" (
|
||||
"id" INT IDENTITY(1,1) PRIMARY KEY,
|
||||
"hotime_cache_id" INT,
|
||||
"key" VARCHAR(64),
|
||||
"value" TEXT,
|
||||
"end_time" TIMESTAMP,
|
||||
"state" INT DEFAULT 0,
|
||||
"create_time" TIMESTAMP,
|
||||
"modify_time" TIMESTAMP
|
||||
)`
|
||||
that.Db.Exec(createSQL)
|
||||
that.Db.Exec(`CREATE INDEX "idx_` + tableName + `_cache_id" ON "` + tableName + `" ("hotime_cache_id")`)
|
||||
return
|
||||
}
|
||||
@@ -249,14 +269,15 @@ func (that *CacheDb) createHistoryTable(dbType, tableName string) {
|
||||
that.Db.Exec(createSQL)
|
||||
}
|
||||
|
||||
// migrateFromCached 从旧 cached 表迁移数据
|
||||
// migrateFromCached 从旧 cached 表迁移数据(不删除老表,由人工删除)
|
||||
func (that *CacheDb) migrateFromCached(dbType, oldTableName, newTableName string) {
|
||||
var migrateSQL string
|
||||
|
||||
switch dbType {
|
||||
case "mysql":
|
||||
// 去重迁移:取每个 key 的最后一条记录(id 最大)
|
||||
migrateSQL = "INSERT INTO `" + newTableName + "` (`key`, `value`, `end_time`, `state`, `create_time`, `modify_time`) " +
|
||||
// 使用 INSERT IGNORE 避免重复 key 冲突
|
||||
migrateSQL = "INSERT IGNORE INTO `" + newTableName + "` (`key`, `value`, `end_time`, `state`, `create_time`, `modify_time`) " +
|
||||
"SELECT c.`key`, c.`value`, FROM_UNIXTIME(c.`endtime`), 0, " +
|
||||
"FROM_UNIXTIME(c.`time` / 1000000000), FROM_UNIXTIME(c.`time` / 1000000000) " +
|
||||
"FROM `" + oldTableName + "` c " +
|
||||
@@ -264,7 +285,7 @@ func (that *CacheDb) migrateFromCached(dbType, oldTableName, newTableName string
|
||||
"ON c.id = m.max_id"
|
||||
|
||||
case "sqlite":
|
||||
migrateSQL = `INSERT INTO "` + newTableName + `" ("key", "value", "end_time", "state", "create_time", "modify_time") ` +
|
||||
migrateSQL = `INSERT OR IGNORE INTO "` + newTableName + `" ("key", "value", "end_time", "state", "create_time", "modify_time") ` +
|
||||
`SELECT c."key", c."value", datetime(c."endtime", 'unixepoch'), 0, ` +
|
||||
`datetime(c."time" / 1000000000, 'unixepoch'), datetime(c."time" / 1000000000, 'unixepoch') ` +
|
||||
`FROM "` + oldTableName + `" c ` +
|
||||
@@ -277,22 +298,22 @@ func (that *CacheDb) migrateFromCached(dbType, oldTableName, newTableName string
|
||||
`to_timestamp(c."time" / 1000000000), to_timestamp(c."time" / 1000000000) ` +
|
||||
`FROM "` + oldTableName + `" c ` +
|
||||
`INNER JOIN (SELECT "key", MAX(id) as max_id FROM "` + oldTableName + `" GROUP BY "key") m ` +
|
||||
`ON c.id = m.max_id`
|
||||
`ON c.id = m.max_id ` +
|
||||
`ON CONFLICT ("key") DO NOTHING`
|
||||
|
||||
case "dm", "dameng":
|
||||
migrateSQL = `MERGE INTO "` + newTableName + `" t USING (` +
|
||||
`SELECT c."key", c."value", DATEADD(SECOND, c."endtime", TIMESTAMP '1970-01-01 00:00:00') AS "end_time", ` +
|
||||
`DATEADD(SECOND, c."time" / 1000000000, TIMESTAMP '1970-01-01 00:00:00') AS "create_time" ` +
|
||||
`FROM "` + oldTableName + `" c ` +
|
||||
`INNER JOIN (SELECT "key", MAX("id") AS max_id FROM "` + oldTableName + `" GROUP BY "key") m ` +
|
||||
`ON c."id" = m.max_id` +
|
||||
`) src ON (t."key" = src."key") ` +
|
||||
`WHEN NOT MATCHED THEN INSERT ("key", "value", "end_time", "state", "create_time", "modify_time") ` +
|
||||
`VALUES (src."key", src."value", src."end_time", 0, src."create_time", src."create_time")`
|
||||
}
|
||||
|
||||
// 执行迁移
|
||||
_, err := that.Db.Exec(migrateSQL)
|
||||
if err.GetError() == nil {
|
||||
// 迁移成功,删除旧表
|
||||
var dropSQL string
|
||||
switch dbType {
|
||||
case "mysql":
|
||||
dropSQL = "DROP TABLE `" + oldTableName + "`"
|
||||
case "sqlite", "postgres":
|
||||
dropSQL = `DROP TABLE "` + oldTableName + `"`
|
||||
}
|
||||
that.Db.Exec(dropSQL)
|
||||
}
|
||||
that.Db.Exec(migrateSQL)
|
||||
}
|
||||
|
||||
// writeHistory 写入历史记录
|
||||
@@ -323,14 +344,70 @@ func (that *CacheDb) writeHistory(key string) {
|
||||
|
||||
// 插入历史表
|
||||
that.Db.Insert(historyTableName, historyData)
|
||||
}
|
||||
|
||||
// #region agent log
|
||||
logFile3, _ := os.OpenFile(`d:\work\hotimev1.5\.cursor\debug.log`, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if logFile3 != nil {
|
||||
fmt.Fprintf(logFile3, `{"hypothesisId":"C","location":"cache_db.go:writeHistory","message":"history written","data":{"key":"%s","cacheId":%d},"timestamp":%d,"sessionId":"debug-session"}`+"\n", key, cached.GetInt64("id"), time.Now().UnixMilli())
|
||||
logFile3.Close()
|
||||
// getLegacy 从老表获取缓存(兼容模式使用)
|
||||
// 老表结构:key, value, endtime(unix秒), time(纳秒时间戳)
|
||||
func (that *CacheDb) getLegacy(key string) interface{} {
|
||||
legacyTableName := that.getLegacyTableName()
|
||||
|
||||
// 检查老表是否存在
|
||||
if !that.tableExists(legacyTableName) {
|
||||
return nil
|
||||
}
|
||||
|
||||
cached := that.Db.Get(legacyTableName, "*", Map{"key": key})
|
||||
if cached == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 检查过期时间(老表使用 unix 时间戳)
|
||||
endTime := cached.GetInt64("endtime")
|
||||
nowUnix := time.Now().Unix()
|
||||
if endTime > 0 && endTime <= nowUnix {
|
||||
// 已过期,删除该条记录
|
||||
that.deleteLegacy(key)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 解析 value(老表 value 格式可能是 {"data": xxx} 或直接值)
|
||||
valueStr := cached.GetString("value")
|
||||
if valueStr == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := JsonToObj(valueStr)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 兼容老版本 {"data": xxx} 包装格式
|
||||
if dataMap, ok := data.(map[string]interface{}); ok {
|
||||
if innerData, exists := dataMap["data"]; exists {
|
||||
return innerData
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// deleteLegacy 从老表删除缓存(兼容模式使用)
|
||||
func (that *CacheDb) deleteLegacy(key string) {
|
||||
legacyTableName := that.getLegacyTableName()
|
||||
|
||||
// 检查老表是否存在
|
||||
if !that.tableExists(legacyTableName) {
|
||||
return
|
||||
}
|
||||
|
||||
del := strings.Index(key, "*")
|
||||
// 如果通配删除
|
||||
if del != -1 {
|
||||
keyPrefix := Substr(key, 0, del)
|
||||
that.Db.Delete(legacyTableName, Map{"key[~]": keyPrefix + "%"})
|
||||
} else {
|
||||
that.Db.Delete(legacyTableName, Map{"key": key})
|
||||
}
|
||||
// #endregion
|
||||
}
|
||||
|
||||
// get 获取缓存
|
||||
@@ -338,49 +415,32 @@ func (that *CacheDb) get(key string) interface{} {
|
||||
tableName := that.getTableName()
|
||||
cached := that.Db.Get(tableName, "*", Map{"key": key})
|
||||
|
||||
// #region agent log
|
||||
logFile4, _ := os.OpenFile(`d:\work\hotimev1.5\.cursor\debug.log`, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if logFile4 != nil {
|
||||
found := cached != nil
|
||||
fmt.Fprintf(logFile4, `{"hypothesisId":"D","location":"cache_db.go:get","message":"get query","data":{"key":"%s","found":%t},"timestamp":%d,"sessionId":"debug-session"}`+"\n", key, found, time.Now().UnixMilli())
|
||||
logFile4.Close()
|
||||
}
|
||||
// #endregion
|
||||
|
||||
if cached == nil {
|
||||
return nil
|
||||
if cached != nil {
|
||||
// 使用字符串比较判断过期(ISO 格式天然支持)
|
||||
endTime := cached.GetString("end_time")
|
||||
nowTime := Time2Str(time.Now())
|
||||
if endTime != "" && endTime <= nowTime {
|
||||
// 惰性删除:过期只返回 nil,不立即删除
|
||||
// 依赖随机清理批量删除过期数据
|
||||
// 继续检查老表(如果是兼容模式)
|
||||
} else {
|
||||
// 直接解析 value,不再需要 {"data": value} 包装
|
||||
valueStr := cached.GetString("value")
|
||||
if valueStr != "" {
|
||||
data, err := JsonToObj(valueStr)
|
||||
if err == nil {
|
||||
return data
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用字符串比较判断过期(ISO 格式天然支持)
|
||||
endTime := cached.GetString("end_time")
|
||||
nowTime := Time2Str(time.Now())
|
||||
if endTime != "" && endTime <= nowTime {
|
||||
// 惰性删除:过期只返回 nil,不立即删除
|
||||
// 依赖随机清理批量删除过期数据
|
||||
return nil
|
||||
// 兼容模式:新表没有数据时,回退读取老表
|
||||
if that.isCompatibleMode() {
|
||||
return that.getLegacy(key)
|
||||
}
|
||||
|
||||
// 直接解析 value,不再需要 {"data": value} 包装
|
||||
valueStr := cached.GetString("value")
|
||||
if valueStr == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var data interface{}
|
||||
err := json.Unmarshal([]byte(valueStr), &data)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// #region agent log
|
||||
logFile5, _ := os.OpenFile(`d:\work\hotimev1.5\.cursor\debug.log`, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if logFile5 != nil {
|
||||
fmt.Fprintf(logFile5, `{"hypothesisId":"D","location":"cache_db.go:get","message":"get success","data":{"key":"%s","hasData":true},"timestamp":%d,"sessionId":"debug-session"}`+"\n", key, time.Now().UnixMilli())
|
||||
logFile5.Close()
|
||||
}
|
||||
// #endregion
|
||||
|
||||
return data
|
||||
return nil
|
||||
}
|
||||
|
||||
// set 设置缓存
|
||||
@@ -414,6 +474,14 @@ func (that *CacheDb) set(key string, value interface{}, endTime time.Time) {
|
||||
`ON CONFLICT ("key") DO UPDATE SET "value"=EXCLUDED."value", "end_time"=EXCLUDED."end_time", "modify_time"=EXCLUDED."modify_time"`
|
||||
that.Db.Exec(upsertSQL, key, string(bte), endTimeStr, nowTime, nowTime)
|
||||
|
||||
case "dm", "dameng":
|
||||
upsertSQL := `MERGE INTO "` + tableName + `" t USING (SELECT ? AS "key", ? AS "value", ? AS "end_time", ? AS "create_time", ? AS "modify_time") src ` +
|
||||
`ON (t."key" = src."key") ` +
|
||||
`WHEN MATCHED THEN UPDATE SET "value"=src."value", "end_time"=src."end_time", "modify_time"=src."modify_time" ` +
|
||||
`WHEN NOT MATCHED THEN INSERT ("key", "value", "end_time", "state", "create_time", "modify_time") ` +
|
||||
`VALUES (src."key", src."value", src."end_time", 0, src."create_time", src."modify_time")`
|
||||
that.Db.Exec(upsertSQL, key, string(bte), endTimeStr, nowTime, nowTime)
|
||||
|
||||
default:
|
||||
// 兼容其他数据库:使用 Update + Insert
|
||||
num := that.Db.Update(tableName, Map{
|
||||
@@ -433,17 +501,14 @@ func (that *CacheDb) set(key string, value interface{}, endTime time.Time) {
|
||||
}
|
||||
}
|
||||
|
||||
// #region agent log
|
||||
logFile2, _ := os.OpenFile(`d:\work\hotimev1.5\.cursor\debug.log`, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if logFile2 != nil {
|
||||
fmt.Fprintf(logFile2, `{"hypothesisId":"B","location":"cache_db.go:set","message":"set completed","data":{"key":"%s","endTime":"%s","dbType":"%s"},"timestamp":%d,"sessionId":"debug-session"}`+"\n", key, endTimeStr, dbType, time.Now().UnixMilli())
|
||||
logFile2.Close()
|
||||
}
|
||||
// #endregion
|
||||
|
||||
// 写入历史记录
|
||||
that.writeHistory(key)
|
||||
|
||||
// 兼容模式:写新表后删除老表同 key 记录(加速老数据消亡)
|
||||
if that.isCompatibleMode() {
|
||||
that.deleteLegacy(key)
|
||||
}
|
||||
|
||||
// 随机执行删除过期数据命令(5% 概率)
|
||||
if Rand(1000) > 950 {
|
||||
nowTimeStr := Time2Str(time.Now())
|
||||
@@ -457,11 +522,16 @@ func (that *CacheDb) delete(key string) {
|
||||
del := strings.Index(key, "*")
|
||||
// 如果通配删除
|
||||
if del != -1 {
|
||||
key = Substr(key, 0, del)
|
||||
that.Db.Delete(tableName, Map{"key[~]": key + "%"})
|
||||
keyPrefix := Substr(key, 0, del)
|
||||
that.Db.Delete(tableName, Map{"key[~]": keyPrefix + "%"})
|
||||
} else {
|
||||
that.Db.Delete(tableName, Map{"key": key})
|
||||
}
|
||||
|
||||
// 兼容模式:同时删除老表中的 key(避免回退读到老数据)
|
||||
if that.isCompatibleMode() {
|
||||
that.deleteLegacy(key)
|
||||
}
|
||||
}
|
||||
|
||||
// Cache 缓存操作入口
|
||||
@@ -473,20 +543,6 @@ func (that *CacheDb) delete(key string) {
|
||||
func (that *CacheDb) Cache(key string, data ...interface{}) *Obj {
|
||||
that.initDbTable()
|
||||
|
||||
// #region agent log
|
||||
logFile, _ := os.OpenFile(`d:\work\hotimev1.5\.cursor\debug.log`, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if logFile != nil {
|
||||
op := "get"
|
||||
if len(data) == 1 && data[0] == nil {
|
||||
op = "delete"
|
||||
} else if len(data) >= 1 {
|
||||
op = "set"
|
||||
}
|
||||
fmt.Fprintf(logFile, `{"hypothesisId":"A","location":"cache_db.go:Cache","message":"Cache called","data":{"key":"%s","operation":"%s","dataLen":%d},"timestamp":%d,"sessionId":"debug-session"}`+"\n", key, op, len(data), time.Now().UnixMilli())
|
||||
logFile.Close()
|
||||
}
|
||||
// #endregion
|
||||
|
||||
// 获取缓存
|
||||
if len(data) == 0 {
|
||||
return &Obj{Data: that.get(key)}
|
||||
@@ -508,9 +564,9 @@ func (that *CacheDb) Cache(key string, data ...interface{}) *Obj {
|
||||
}
|
||||
} else if len(data) >= 2 {
|
||||
// 使用指定的超时时间
|
||||
that.SetError(nil)
|
||||
tempTimeout := ObjToInt64(data[1], that.Error)
|
||||
if that.GetError() == nil && tempTimeout > 0 {
|
||||
var err Error
|
||||
tempTimeout := ObjToInt64(data[1], &err)
|
||||
if err.GetError() == nil && tempTimeout > 0 {
|
||||
timeout = tempTimeout
|
||||
} else {
|
||||
timeout = that.TimeOut
|
||||
@@ -524,3 +580,94 @@ func (that *CacheDb) Cache(key string, data ...interface{}) *Obj {
|
||||
that.set(key, data[0], endTime)
|
||||
return &Obj{Data: nil}
|
||||
}
|
||||
|
||||
// CachesGet 批量获取缓存(使用 IN 查询优化)
|
||||
// 返回 Map,key 为缓存键,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: Map,key 为缓存键,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})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+43
-15
@@ -2,6 +2,7 @@ package cache
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
"code.hoteas.com/golang/hotime/log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -12,24 +13,12 @@ type CacheMemory struct {
|
||||
TimeOut int64
|
||||
DbSet bool
|
||||
SessionSet bool
|
||||
*Error
|
||||
cache sync.Map // 替代传统的 Map
|
||||
}
|
||||
|
||||
func (that *CacheMemory) GetError() *Error {
|
||||
|
||||
return that.Error
|
||||
|
||||
}
|
||||
|
||||
func (that *CacheMemory) SetError(err *Error) {
|
||||
that.Error = err
|
||||
Log *log.Logger
|
||||
cache sync.Map
|
||||
}
|
||||
func (c *CacheMemory) get(key string) (res *Obj) {
|
||||
|
||||
res = &Obj{
|
||||
Error: *c.Error,
|
||||
}
|
||||
res = &Obj{}
|
||||
value, ok := c.cache.Load(key)
|
||||
if !ok {
|
||||
return res // 缓存不存在
|
||||
@@ -113,3 +102,42 @@ func (c *CacheMemory) Cache(key string, data ...interface{}) *Obj {
|
||||
c.set(key, data[0], expireAt)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CachesGet 批量获取缓存
|
||||
// 返回 Map,key 为缓存键,value 为缓存值(不存在或过期的 key 不包含在结果中)
|
||||
func (c *CacheMemory) CachesGet(keys []string) Map {
|
||||
result := make(Map, len(keys))
|
||||
for _, key := range keys {
|
||||
obj := c.get(key)
|
||||
if obj != nil && obj.Data != nil {
|
||||
result[key] = obj.Data
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// CachesSet 批量设置缓存
|
||||
// data: Map,key 为缓存键,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)
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+109
-17
@@ -2,6 +2,7 @@ package cache
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
"code.hoteas.com/golang/hotime/log"
|
||||
"github.com/garyburd/redigo/redis"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -18,18 +19,14 @@ type CacheRedis struct {
|
||||
pool *redis.Pool
|
||||
tag int64
|
||||
ContextBase
|
||||
*Error
|
||||
Log *log.Logger
|
||||
initOnce sync.Once
|
||||
}
|
||||
|
||||
func (that *CacheRedis) GetError() *Error {
|
||||
|
||||
return that.Error
|
||||
|
||||
}
|
||||
|
||||
func (that *CacheRedis) SetError(err *Error) {
|
||||
that.Error = err
|
||||
func (that *CacheRedis) logErr(err error) {
|
||||
if err != nil && that.Log != nil {
|
||||
that.Log.Error().Err(err).Msg("redis cache error")
|
||||
}
|
||||
}
|
||||
|
||||
// 唯一标志
|
||||
@@ -97,7 +94,7 @@ func (that *CacheRedis) del(key string) {
|
||||
if del != -1 {
|
||||
val, err := redis.Strings(conn.Do("KEYS", key))
|
||||
if err != nil {
|
||||
that.Error.SetError(err)
|
||||
that.logErr(err)
|
||||
return
|
||||
}
|
||||
if len(val) == 0 {
|
||||
@@ -109,12 +106,12 @@ func (that *CacheRedis) del(key string) {
|
||||
}
|
||||
_, err = conn.Do("EXEC")
|
||||
if err != nil {
|
||||
that.Error.SetError(err)
|
||||
that.logErr(err)
|
||||
}
|
||||
} else {
|
||||
_, err := conn.Do("DEL", key)
|
||||
if err != nil {
|
||||
that.Error.SetError(err)
|
||||
that.logErr(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,7 +126,7 @@ func (that *CacheRedis) set(key string, value string, expireSeconds int64) {
|
||||
|
||||
_, err := conn.Do("SET", key, value, "EX", ObjToStr(expireSeconds))
|
||||
if err != nil {
|
||||
that.Error.SetError(err)
|
||||
that.logErr(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +143,7 @@ func (that *CacheRedis) get(key string) *Obj {
|
||||
if err != nil {
|
||||
reData.Data = nil
|
||||
if !strings.Contains(err.Error(), "nil returned") {
|
||||
that.Error.SetError(err)
|
||||
that.logErr(err)
|
||||
}
|
||||
}
|
||||
return reData
|
||||
@@ -175,11 +172,10 @@ func (that *CacheRedis) Cache(key string, data ...interface{}) *Obj {
|
||||
tim += that.TimeOut
|
||||
}
|
||||
if len(data) == 2 {
|
||||
that.Error.SetError(nil)
|
||||
tempt := ObjToInt64(data[1], that.Error)
|
||||
tempt := ObjToInt64(data[1])
|
||||
if tempt > tim {
|
||||
tim = tempt
|
||||
} else if that.GetError() == nil {
|
||||
} else {
|
||||
tim = tim + tempt
|
||||
}
|
||||
}
|
||||
@@ -188,3 +184,99 @@ func (that *CacheRedis) Cache(key string, data ...interface{}) *Obj {
|
||||
|
||||
return reData
|
||||
}
|
||||
|
||||
// CachesGet 批量获取缓存(使用 Redis MGET 命令优化)
|
||||
// 返回 Map,key 为缓存键,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: Map,key 为缓存键,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)
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+3
-5
@@ -5,12 +5,10 @@ import (
|
||||
)
|
||||
|
||||
type CacheIns interface {
|
||||
//set(key string, value interface{}, time int64)
|
||||
//get(key string) interface{}
|
||||
//delete(key string)
|
||||
GetError() *Error
|
||||
SetError(err *Error)
|
||||
Cache(key string, data ...interface{}) *Obj
|
||||
CachesGet(keys []string) Map
|
||||
CachesSet(data Map, timeout ...int64)
|
||||
CachesDelete(keys []string)
|
||||
}
|
||||
|
||||
// 单条缓存数据
|
||||
|
||||
@@ -10,9 +10,12 @@ import (
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var adminLoginRateLimiter sync.Map // key: ip,value: *[]int64(1分钟内登录时间戳列表)
|
||||
|
||||
// Project 管理端项目
|
||||
var TptProject = Proj{
|
||||
//"user": UserCtr,
|
||||
@@ -751,105 +754,100 @@ var TptProject = Proj{
|
||||
//if pageSize <= 0 {
|
||||
// pageSize = 20
|
||||
//}
|
||||
redData := Map{}
|
||||
redData := Map{}
|
||||
|
||||
data["count"] = that.Db.Count(tableName, where)
|
||||
testQu := []string{}
|
||||
testQuData := that.MakeCodeRouter[hotimeName].TableColumns[tableName]
|
||||
for key, _ := range testQuData {
|
||||
//fmt.Println(key, ":", value)
|
||||
testQu = append(testQu, key)
|
||||
data["count"] = that.Db.Count(tableName, where)
|
||||
testQu := []string{}
|
||||
testQuData := that.MakeCodeRouter[hotimeName].TableColumns[tableName]
|
||||
for key := range testQuData {
|
||||
testQu = append(testQu, key)
|
||||
}
|
||||
sort.Strings(testQu)
|
||||
|
||||
// 第一步:扫描所有字段,构建聚合表达式列表和元数据
|
||||
type selectOptMeta struct {
|
||||
ops Slice
|
||||
vals []string
|
||||
}
|
||||
caseParts := []string{}
|
||||
selectMetas := map[string]*selectOptMeta{}
|
||||
sumFieldKeys := []string{}
|
||||
hasAreaId := false
|
||||
|
||||
for _, k := range testQu {
|
||||
v := testQuData[k]
|
||||
if v.GetBool("notUse") { continue }
|
||||
if strings.Contains(k, "state") { continue }
|
||||
if strings.Contains(k, "scope") { continue }
|
||||
if strings.Contains(k, "_code") { continue }
|
||||
|
||||
switch v.GetString("type") {
|
||||
case "select":
|
||||
ops := v.GetSlice("options")
|
||||
sm := &selectOptMeta{ops: ops}
|
||||
for k1 := range ops {
|
||||
v1 := ops.GetMap(k1)
|
||||
val := v1.GetString("value")
|
||||
sm.vals = append(sm.vals, val)
|
||||
safeVal := strings.ReplaceAll(val, "'", "''")
|
||||
alias := fmt.Sprintf("__sel__%s__%d", k, k1)
|
||||
caseParts = append(caseParts,
|
||||
fmt.Sprintf("SUM(CASE WHEN `%s`='%s' THEN 1 ELSE 0 END) AS `%s`", k, safeVal, alias))
|
||||
}
|
||||
selectMetas[k] = sm
|
||||
|
||||
case "number":
|
||||
if strings.Contains(k, "area_id") { hasAreaId = true; continue }
|
||||
if strings.Contains(k, "_id") { continue }
|
||||
if k == "id" { continue }
|
||||
if strings.Contains(k, "percent") { continue }
|
||||
if strings.Contains(k, "exp_") { continue }
|
||||
if strings.Contains(k, "side") { continue }
|
||||
if strings.Contains(k, "lat") { continue }
|
||||
if strings.Contains(k, "lng") { continue }
|
||||
if strings.Contains(k, "distance") { continue }
|
||||
alias := "__sum__" + k
|
||||
caseParts = append(caseParts, fmt.Sprintf("SUM(`%s`) AS `%s`", k, alias))
|
||||
sumFieldKeys = append(sumFieldKeys, k)
|
||||
}
|
||||
sort.Strings(testQu)
|
||||
for _, k := range testQu {
|
||||
v := testQuData[k]
|
||||
//不可使用,未在前端展示,但在内存中保持有
|
||||
if v.GetBool("notUse") {
|
||||
}
|
||||
|
||||
continue
|
||||
// 第二步:单次聚合查询替代原来 N×M+K 次串行查询
|
||||
if len(caseParts) > 0 {
|
||||
aggRow := that.Db.Get(tableName, strings.Join(caseParts, ","), where)
|
||||
if aggRow != nil {
|
||||
// 回填 select 字段各选项的 count
|
||||
for k, sm := range selectMetas {
|
||||
for k1 := range sm.ops {
|
||||
v1 := sm.ops.GetMap(k1)
|
||||
alias := fmt.Sprintf("__sel__%s__%d", k, k1)
|
||||
v1["count"] = aggRow.GetCeilInt64(alias)
|
||||
sm.ops[k1] = v1
|
||||
}
|
||||
redData[k] = sm.ops
|
||||
}
|
||||
|
||||
if strings.Contains(k, "state") {
|
||||
continue
|
||||
// 回填 number 字段的 sum
|
||||
for _, k := range sumFieldKeys {
|
||||
alias := "__sum__" + k
|
||||
redData[k] = aggRow[alias]
|
||||
}
|
||||
if strings.Contains(k, "scope") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(k, "_code") {
|
||||
continue
|
||||
}
|
||||
//检索查询
|
||||
if v.GetString("type") == "select" {
|
||||
|
||||
ops := v.GetSlice("options")
|
||||
for k1, _ := range ops {
|
||||
v1 := ops.GetMap(k1)
|
||||
where1 := DeepCopyMap(where).(Map)
|
||||
if where1["AND"] != nil {
|
||||
and := where1.GetMap("AND")
|
||||
and[k] = v1.GetString("value")
|
||||
} else {
|
||||
where1[k] = v1.GetString("value")
|
||||
where1 = Map{"AND": where1}
|
||||
}
|
||||
v1["count"] = that.Db.Count(tableName, where1)
|
||||
ops[k1] = v1
|
||||
}
|
||||
redData[k] = ops
|
||||
}
|
||||
|
||||
//检索查询
|
||||
if v.GetString("type") == "number" {
|
||||
|
||||
if strings.Contains(k, "area_id") {
|
||||
areas := that.Db.Select("area", "id,name", Map{"parent_id": 533301})
|
||||
for ak, av := range areas {
|
||||
where1 := DeepCopyMap(where).(Map)
|
||||
if where1["AND"] != nil {
|
||||
and := where1.GetMap("AND")
|
||||
and["area_id"] = av.GetCeilInt64("id")
|
||||
} else {
|
||||
where1["area_id"] = av.GetCeilInt64("id")
|
||||
where1 = Map{"AND": where1}
|
||||
}
|
||||
av["count"] = that.Db.Count(tableName, where1)
|
||||
areas[ak] = av
|
||||
}
|
||||
|
||||
redData["area"] = areas
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.Contains(k, "_id") {
|
||||
continue
|
||||
}
|
||||
if k == "id" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(k, "percent") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(k, "exp_") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(k, "side") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(k, "lat") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(k, "lng") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(k, "distance") {
|
||||
continue
|
||||
}
|
||||
where1 := DeepCopyMap(where).(Map)
|
||||
redData[k] = that.Db.Sum(tableName, k, where1)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// 第三步:area 分析用单次 Select + Go 侧统计替代原来逐 area Count 循环
|
||||
if hasAreaId {
|
||||
areas := that.Db.Select("area", "id,name", Map{"parent_id": 533301})
|
||||
areaIdRows := that.Db.Select(tableName, "area_id", where)
|
||||
areaCountMap := map[int64]int64{}
|
||||
for _, row := range areaIdRows {
|
||||
areaCountMap[row.GetCeilInt64("area_id")]++
|
||||
}
|
||||
for ak, av := range areas {
|
||||
av["count"] = areaCountMap[av.GetCeilInt64("id")]
|
||||
areas[ak] = av
|
||||
}
|
||||
redData["area"] = areas
|
||||
}
|
||||
|
||||
that.Display(0, redData)
|
||||
|
||||
@@ -964,6 +962,26 @@ var TptProject = Proj{
|
||||
that.Display(0, re)
|
||||
},
|
||||
"login": func(that *Context) {
|
||||
// 限速:同一 IP 1分钟内最多尝试10次
|
||||
ip := that.Req.Header.Get("X-Forwarded-For")
|
||||
if ip == "" {
|
||||
ip = that.Req.RemoteAddr
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
val, _ := adminLoginRateLimiter.LoadOrStore(ip, &[]int64{})
|
||||
times := val.(*[]int64)
|
||||
filtered := (*times)[:0]
|
||||
for _, t := range *times {
|
||||
if now-t < 60 {
|
||||
filtered = append(filtered, t)
|
||||
}
|
||||
}
|
||||
*times = append(filtered, now)
|
||||
if len(*times) > 10 {
|
||||
that.Display(5, "登录尝试过于频繁,请稍后再试")
|
||||
return
|
||||
}
|
||||
|
||||
hotimeName := that.RouterString[0]
|
||||
fileConfig := that.MakeCodeRouter[hotimeName].FileConfig
|
||||
|
||||
@@ -1017,15 +1035,15 @@ var TptProject = Proj{
|
||||
that.Display(4, "找不到配置文件")
|
||||
return
|
||||
}
|
||||
if that.Session(fileConfig.GetString("table")+"_id").Data == nil {
|
||||
|
||||
conf := ObjToMap(string(btes))
|
||||
delete(conf, "menus")
|
||||
delete(conf, "tables")
|
||||
//没有登录只需要返回这些信息
|
||||
that.Display(0, conf)
|
||||
return
|
||||
}
|
||||
if that.Session(fileConfig.GetString("table")+"_id").Data == nil {
|
||||
conf := ObjToMap(string(btes))
|
||||
//未登录只返回前端登录页所需的最少字段,避免泄露表结构等敏感信息
|
||||
that.Display(0, Map{
|
||||
"label": conf["label"],
|
||||
"name": conf["name"],
|
||||
})
|
||||
return
|
||||
}
|
||||
//可读写配置
|
||||
conf := ObjToMap(string(btes))
|
||||
menus := conf.GetSlice("menus")
|
||||
|
||||
+18
-4
@@ -42,20 +42,34 @@ var DefaultMenuParentName = "sys"
|
||||
|
||||
var ColumnDataType = map[string]string{
|
||||
//sqlite专有类型
|
||||
"real": "number",
|
||||
//mysql数据类型宽泛类型
|
||||
"real": "number",
|
||||
"integer": "number",
|
||||
//mysql数据类型
|
||||
"int": "number",
|
||||
"float": "number",
|
||||
"double": "number",
|
||||
"decimal": "number",
|
||||
"integer": "number", //sqlite3
|
||||
"char": "text",
|
||||
"text": "text",
|
||||
"blob": "text",
|
||||
"date": "time",
|
||||
"time": "time",
|
||||
"year": "time",
|
||||
"geometry": "gis", //不建议使用gis类型,建议使用其他代替
|
||||
"bit": "number",
|
||||
"binary": "text",
|
||||
"geometry": "gis",
|
||||
//postgresql数据类型
|
||||
"numeric": "number",
|
||||
"boolean": "number",
|
||||
"serial": "number",
|
||||
"json": "text",
|
||||
"uuid": "text",
|
||||
"bytea": "text",
|
||||
"interval": "text",
|
||||
"money": "number",
|
||||
//达梦(DM)/Oracle数据类型
|
||||
"clob": "text",
|
||||
"number": "number",
|
||||
}
|
||||
|
||||
type ColumnShow struct {
|
||||
|
||||
+76
-30
@@ -3,7 +3,7 @@ package code
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
"code.hoteas.com/golang/hotime/db"
|
||||
"errors"
|
||||
"code.hoteas.com/golang/hotime/log"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
@@ -22,7 +22,7 @@ type MakeCode struct {
|
||||
SearchColumns map[string]map[string]Map
|
||||
Config Map
|
||||
RuleConfig []Map
|
||||
Error
|
||||
Log *log.Logger
|
||||
}
|
||||
|
||||
func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
|
||||
@@ -49,7 +49,7 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
|
||||
if errRule == nil {
|
||||
//cmap := Map{}
|
||||
//文件是否损坏
|
||||
ruleLis := ObjToSlice(string(btesRule), &that.Error)
|
||||
ruleLis := ObjToSlice(string(btesRule))
|
||||
//cmap.JSON()
|
||||
for k, _ := range ruleLis {
|
||||
that.RuleConfig = append(that.RuleConfig, ruleLis.GetMap(k))
|
||||
@@ -62,11 +62,11 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
|
||||
_ = os.MkdirAll(filepath.Dir(config.GetString("rule")), os.ModeDir)
|
||||
err = ioutil.WriteFile(config.GetString("rule"), []byte(ObjToStr(that.RuleConfig)), os.ModePerm)
|
||||
if err != nil {
|
||||
that.Error.SetError(err)
|
||||
that.Log.Error().Err(err).Msg("写入 rule 配置文件失败")
|
||||
}
|
||||
}
|
||||
|
||||
that.Error.SetError(errors.New("rule配置文件不存在,或者配置出错,使用缺省默认配置"))
|
||||
that.Log.Warnf("rule配置文件不存在或配置出错,使用缺省默认配置")
|
||||
}
|
||||
|
||||
that.IndexMenus = Map{}
|
||||
@@ -118,6 +118,9 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
|
||||
if db.Type == "sqlite" {
|
||||
nowTables = db.Select("sqlite_master", "name", Map{"type": "table"})
|
||||
}
|
||||
if db.Type == "dm" || db.Type == "dameng" {
|
||||
nowTables = db.Query(`SELECT TABLE_NAME AS "name", COMMENTS AS "label" FROM ALL_TAB_COMMENTS WHERE TABLE_TYPE='TABLE' AND OWNER='` + db.DBName + `'`)
|
||||
}
|
||||
//idSlice=append(idSlice,nowTables)
|
||||
for _, v := range nowTables {
|
||||
// if v.GetString("name") == "cached" {
|
||||
@@ -127,6 +130,14 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
|
||||
if v.GetString("label") == "" {
|
||||
v["label"] = v.GetString("name")
|
||||
}
|
||||
if strings.HasSuffix(v.GetString("label"), "表") {
|
||||
withoutTable := strings.TrimSuffix(v.GetString("label"), "表")
|
||||
if len([]rune(withoutTable)) <= 3 {
|
||||
v["label"] = withoutTable + "管理"
|
||||
} else {
|
||||
v["label"] = withoutTable
|
||||
}
|
||||
}
|
||||
that.TableConfig[v.GetString("name")] = Map{
|
||||
"label": v.GetString("label"),
|
||||
"table": v.GetString("name"),
|
||||
@@ -163,6 +174,9 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
|
||||
if db.Type == "sqlite" {
|
||||
tableInfo = db.Query("pragma table_info([" + v.GetString("name") + "]);")
|
||||
}
|
||||
if db.Type == "dm" || db.Type == "dameng" {
|
||||
tableInfo = db.Query(`SELECT c.COLUMN_NAME AS "name", c.DATA_TYPE AS "type", m.COMMENTS AS "label", c.NULLABLE AS "must", c.DATA_DEFAULT AS "dflt_value" FROM ALL_TAB_COLUMNS c LEFT JOIN USER_COL_COMMENTS m ON c.TABLE_NAME=m.TABLE_NAME AND c.COLUMN_NAME=m.COLUMN_NAME WHERE c.TABLE_NAME='` + v.GetString("name") + `' AND c.OWNER='` + db.DBName + `' ORDER BY c.COLUMN_ID`)
|
||||
}
|
||||
|
||||
idSlice = append(idSlice, tableInfo)
|
||||
for _, info1 := range tableInfo {
|
||||
@@ -175,7 +189,7 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
|
||||
if coloum == nil {
|
||||
//根据类型判断真实类型
|
||||
for k, v1 := range ColumnDataType {
|
||||
if strings.Contains(info.GetString("type"), k) {
|
||||
if strings.Contains(strings.ToLower(info.GetString("type")), k) {
|
||||
info["type"] = v1
|
||||
break
|
||||
}
|
||||
@@ -201,22 +215,26 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
|
||||
coloum["must"] = true
|
||||
}
|
||||
|
||||
//备注以空格隔开,空格后的是其他备注
|
||||
indexNum := strings.Index(info.GetString("label"), " ")
|
||||
if indexNum > -1 {
|
||||
coloum["label"] = info.GetString("label")[:indexNum]
|
||||
//提取括号内的提示信息:支持 ()、()、{}
|
||||
rawLabel := info.GetString("label")
|
||||
for _, pair := range [][2]string{{"(", ")"}, {"(", ")"}, {"{", "}"}} {
|
||||
start := strings.Index(rawLabel, pair[0])
|
||||
end := strings.Index(rawLabel, pair[1])
|
||||
if start != -1 && end > start {
|
||||
coloum["ps"] = rawLabel[start+len(pair[0]) : end]
|
||||
rawLabel = rawLabel[:start] + rawLabel[end+len(pair[1]):]
|
||||
break
|
||||
}
|
||||
}
|
||||
psStart := strings.Index(info.GetString("label"), "{")
|
||||
psEnd := strings.Index(info.GetString("label"), "}")
|
||||
if psStart != -1 && psEnd > psStart {
|
||||
coloum["ps"] = info.GetString("label")[psStart+1 : psEnd]
|
||||
//空格截断,空格后的内容丢弃
|
||||
if idx := strings.Index(rawLabel, " "); idx > -1 {
|
||||
rawLabel = rawLabel[:idx]
|
||||
}
|
||||
|
||||
//去除数据信息,是用:号分割的
|
||||
indexNum = strings.Index(coloum.GetString("label"), ":")
|
||||
if indexNum > -1 {
|
||||
coloum["label"] = coloum.GetString("label")[:indexNum]
|
||||
//冒号截断,去除选项数据部分
|
||||
if idx := strings.Index(rawLabel, ":"); idx > -1 {
|
||||
rawLabel = rawLabel[:idx]
|
||||
}
|
||||
coloum["label"] = rawLabel
|
||||
|
||||
for _, ColumnName := range that.RuleConfig {
|
||||
if (ColumnName.GetBool("strict") && coloum.GetString("name") == ColumnName.GetString("name")) ||
|
||||
@@ -305,6 +323,25 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
|
||||
}
|
||||
//}
|
||||
|
||||
} else {
|
||||
//对已有配置列也提取 ps 并清理 label
|
||||
rawLabel := coloum.GetString("label")
|
||||
for _, pair := range [][2]string{{"(", ")"}, {"(", ")"}, {"{", "}"}} {
|
||||
start := strings.Index(rawLabel, pair[0])
|
||||
end := strings.Index(rawLabel, pair[1])
|
||||
if start != -1 && end > start {
|
||||
coloum["ps"] = rawLabel[start+len(pair[0]) : end]
|
||||
rawLabel = rawLabel[:start] + rawLabel[end+len(pair[1]):]
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx := strings.Index(rawLabel, " "); idx > -1 {
|
||||
rawLabel = rawLabel[:idx]
|
||||
}
|
||||
if idx := strings.Index(rawLabel, ":"); idx > -1 {
|
||||
rawLabel = rawLabel[:idx]
|
||||
}
|
||||
coloum["label"] = rawLabel
|
||||
}
|
||||
|
||||
//暂时不关闭参数,保证表数据完全读取到
|
||||
@@ -323,7 +360,7 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
|
||||
_ = os.MkdirAll(config.GetString("name"), os.ModeDir)
|
||||
err = ioutil.WriteFile(config.GetString("name")+"/"+v.GetString("name")+".go", []byte(myCtr), os.ModePerm)
|
||||
if err != nil {
|
||||
that.Error.SetError(err)
|
||||
that.Log.Error().Err(err).Msg("写入代码文件失败")
|
||||
}
|
||||
isMake = true
|
||||
}
|
||||
@@ -619,7 +656,7 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
|
||||
_ = os.MkdirAll(config.GetString("name"), os.ModeDir)
|
||||
err = ioutil.WriteFile(config.GetString("name")+"/init.go", []byte(myInit), os.ModePerm)
|
||||
if err != nil {
|
||||
that.Error.SetError(err)
|
||||
that.Log.Error().Err(err).Msg("写入 init.go 失败")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -630,9 +667,9 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
|
||||
_ = os.MkdirAll(filepath.Dir(config.GetString("configDB")), os.ModeDir)
|
||||
err = ioutil.WriteFile(config.GetString("configDB"), []byte(that.Config.ToJsonString()), os.ModePerm)
|
||||
if err != nil {
|
||||
that.Error.SetError(err)
|
||||
that.Log.Error().Err(err).Msg("写入 configDB 文件失败")
|
||||
}
|
||||
that.Logger.Warn("新建")
|
||||
that.Log.Infof("新建 configDB: %s", config.GetString("configDB"))
|
||||
|
||||
}
|
||||
|
||||
@@ -646,9 +683,9 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
|
||||
delete(newConfig, "tables")
|
||||
err = ioutil.WriteFile(config.GetString("config"), []byte(newConfig.ToJsonString()), os.ModePerm)
|
||||
if err != nil {
|
||||
that.Error.SetError(err)
|
||||
that.Log.Error().Err(err).Msg("写入 config 文件失败")
|
||||
}
|
||||
that.Logger.Warn("新建")
|
||||
that.Log.Infof("新建 config: %s", config.GetString("config"))
|
||||
|
||||
}
|
||||
//fmt.Println("有新的代码生成,请重新运行")
|
||||
@@ -797,7 +834,7 @@ func (that *MakeCode) Add(table string, user Map, req *http.Request) Map {
|
||||
data[v.GetString("name")] = time.Now().Unix()
|
||||
}
|
||||
if v.GetString("type") == "time" {
|
||||
data[v.GetString("name")] = time.Now().Format("2006-01-02 15-04-05")
|
||||
data[v.GetString("name")] = time.Now().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -807,7 +844,7 @@ func (that *MakeCode) Add(table string, user Map, req *http.Request) Map {
|
||||
data[v.GetString("name")] = time.Now().Unix()
|
||||
}
|
||||
if v.GetString("type") == "time" {
|
||||
data[v.GetString("name")] = time.Now().Format("2006-01-02 15-04-05")
|
||||
data[v.GetString("name")] = time.Now().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -856,7 +893,7 @@ func (that *MakeCode) Edit(table string, req *http.Request) Map {
|
||||
data[v.GetString("name")] = time.Now().Unix()
|
||||
}
|
||||
if v.GetString("type") == "time" {
|
||||
data[v.GetString("name")] = time.Now().Format("2006-01-02 15-04-05")
|
||||
data[v.GetString("name")] = time.Now().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1172,7 +1209,14 @@ func (that *MakeCode) Search(table string, userData Map, data Map, req *http.Req
|
||||
}
|
||||
|
||||
if len(reqValue) != 0 && searchItem.GetString("name") == "sort" && reqValue[0] != "" {
|
||||
sortMap["ORDER"] = reqValue[0]
|
||||
parts := strings.Fields(reqValue[0])
|
||||
if len(parts) == 2 {
|
||||
col := parts[0]
|
||||
dir := strings.ToUpper(parts[1])
|
||||
if (dir == "ASC" || dir == "DESC") && that.TableColumns[table][col] != nil {
|
||||
sortMap["ORDER"] = col + " " + dir
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
continue
|
||||
@@ -1263,7 +1307,9 @@ func (that *MakeCode) Search(table string, userData Map, data Map, req *http.Req
|
||||
}
|
||||
|
||||
if len(reqValue) != 0 && reqValue[0] != "" {
|
||||
data[searchItemName] = reqValue
|
||||
if that.TableColumns[table][searchItemName] != nil {
|
||||
data[searchItemName] = reqValue
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+37
-7
@@ -1,29 +1,59 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"github.com/sirupsen/logrus"
|
||||
"code.hoteas.com/golang/hotime/log"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Error 框架层处理错误
|
||||
// Error 框架基础错误类型 —— 线程安全的 error 封装。
|
||||
// 通过组合(嵌入)方式在 Obj、DBError 等结构体中使用,提供统一的错误 API。
|
||||
// 也用于 ObjToXxx / Map.GetXxx 等工具函数的可选错误报告参数。
|
||||
// 设置 Logger 后,SetError 会自动将非 nil 错误写入日志。
|
||||
type Error struct {
|
||||
Logger *logrus.Logger
|
||||
error
|
||||
mu sync.RWMutex
|
||||
mu sync.RWMutex
|
||||
Logger *log.Logger
|
||||
}
|
||||
|
||||
// Error 安全实现 error 接口 —— 内部 error 为 nil 时返回 "" 而非 panic
|
||||
func (that *Error) Error() string {
|
||||
that.mu.RLock()
|
||||
defer that.mu.RUnlock()
|
||||
if that.error == nil {
|
||||
return ""
|
||||
}
|
||||
return that.error.Error()
|
||||
}
|
||||
|
||||
// HasError 快捷判断是否存在错误
|
||||
func (that *Error) HasError() bool {
|
||||
that.mu.RLock()
|
||||
defer that.mu.RUnlock()
|
||||
return that.error != nil
|
||||
}
|
||||
|
||||
// Unwrap 支持 errors.Is / errors.As 标准错误链
|
||||
func (that *Error) Unwrap() error {
|
||||
that.mu.RLock()
|
||||
defer that.mu.RUnlock()
|
||||
return that.error
|
||||
}
|
||||
|
||||
// GetError 获取内部 error(nil 表示无错误)
|
||||
func (that *Error) GetError() error {
|
||||
that.mu.RLock()
|
||||
defer that.mu.RUnlock()
|
||||
return that.error
|
||||
}
|
||||
|
||||
// SetError 设置内部 error(传 nil 清除错误)
|
||||
// 当 Logger 已设置且 err 非 nil 时,自动写入 ERROR 级别日志
|
||||
func (that *Error) SetError(err error) {
|
||||
that.mu.Lock()
|
||||
that.error = err
|
||||
logger := that.Logger
|
||||
that.mu.Unlock()
|
||||
|
||||
if that.Logger != nil && err != nil {
|
||||
that.Logger.Warn(err)
|
||||
if err != nil && logger != nil {
|
||||
logger.Error().Err(err).Msg("")
|
||||
}
|
||||
}
|
||||
|
||||
+17
-4
@@ -1,6 +1,7 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
@@ -181,9 +182,21 @@ func (that Map) ToJsonString() string {
|
||||
}
|
||||
|
||||
func (that Map) JsonToMap(jsonStr string, err ...*Error) {
|
||||
e := json.Unmarshal([]byte(jsonStr), &that)
|
||||
if e != nil && len(err) != 0 {
|
||||
err[0].SetError(e)
|
||||
dec := json.NewDecoder(bytes.NewReader([]byte(jsonStr)))
|
||||
dec.UseNumber()
|
||||
e := dec.Decode(&that)
|
||||
if e != nil {
|
||||
if len(err) != 0 {
|
||||
err[0].SetError(e)
|
||||
}
|
||||
return
|
||||
}
|
||||
for k, v := range that {
|
||||
that[k] = fixJsonNumbers(v)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// GetRoundFloat64 获取四舍五入到 precision 位小数的 float64
|
||||
func (that Map) GetRoundFloat64(key string, precision int, err ...*Error) float64 {
|
||||
return ObjToRoundFloat64((that)[key], precision, err...)
|
||||
}
|
||||
|
||||
@@ -101,3 +101,8 @@ func (that *Obj) ToCeilInt(err ...*Error) int {
|
||||
return v
|
||||
|
||||
}
|
||||
|
||||
// ToRoundFloat64 获取四舍五入到 precision 位小数的 float64
|
||||
func (that *Obj) ToRoundFloat64(precision int, err ...*Error) float64 {
|
||||
return ObjToRoundFloat64(that.Data, precision, err...)
|
||||
}
|
||||
|
||||
+245
-119
@@ -1,6 +1,7 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
@@ -18,31 +19,40 @@ func ObjToMap(obj interface{}, e ...*Error) Map {
|
||||
v = nil
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
} else {
|
||||
switch obj.(type) {
|
||||
switch val := obj.(type) {
|
||||
case Map:
|
||||
v = obj.(Map)
|
||||
v = val
|
||||
case map[string]interface{}:
|
||||
v = obj.(map[string]interface{})
|
||||
v = Map(val)
|
||||
case string:
|
||||
v = Map{}
|
||||
e := json.Unmarshal([]byte(obj.(string)), &v)
|
||||
if e != nil {
|
||||
err = errors.New("没有合适的转换对象!" + e.Error())
|
||||
result, e2 := JsonToObj(val)
|
||||
if e2 != nil {
|
||||
err = errors.New("没有合适的转换对象!" + e2.Error())
|
||||
v = nil
|
||||
} else if m, ok := result.(map[string]interface{}); ok {
|
||||
v = m
|
||||
} else {
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
v = nil
|
||||
}
|
||||
default:
|
||||
data, err := json.Marshal(obj)
|
||||
var data []byte
|
||||
data, err = json.Marshal(obj)
|
||||
if err != nil {
|
||||
err = errors.New("没有合适的转换对象!" + err.Error())
|
||||
v = nil
|
||||
break
|
||||
}
|
||||
v = Map{}
|
||||
e := json.Unmarshal(data, &v)
|
||||
if e != nil {
|
||||
err = errors.New("没有合适的转换对象!" + e.Error())
|
||||
result, e2 := JsonToObj(string(data))
|
||||
if e2 != nil {
|
||||
err = errors.New("没有合适的转换对象!" + e2.Error())
|
||||
v = nil
|
||||
} else if m, ok := result.(map[string]interface{}); ok {
|
||||
v = m
|
||||
} else {
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
v = nil
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
if len(e) != 0 {
|
||||
@@ -53,7 +63,7 @@ func ObjToMap(obj interface{}, e ...*Error) Map {
|
||||
|
||||
func ObjToMapArray(obj interface{}, e ...*Error) []Map {
|
||||
s := ObjToSlice(obj, e...)
|
||||
res := []Map{}
|
||||
res := make([]Map, 0, len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
res = append(res, s.GetMap(i))
|
||||
}
|
||||
@@ -69,25 +79,39 @@ func ObjToSlice(obj interface{}, e ...*Error) Slice {
|
||||
v = nil
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
} else {
|
||||
switch obj.(type) {
|
||||
switch val := obj.(type) {
|
||||
case Slice:
|
||||
v = obj.(Slice)
|
||||
v = val
|
||||
case []interface{}:
|
||||
v = obj.([]interface{})
|
||||
v = Slice(val)
|
||||
case []string:
|
||||
v = Slice{}
|
||||
for i := 0; i < len(obj.([]string)); i++ {
|
||||
v = append(v, obj.([]string)[i])
|
||||
v = make(Slice, len(val))
|
||||
for i, s := range val {
|
||||
v[i] = s
|
||||
}
|
||||
case string:
|
||||
v = Slice{}
|
||||
err = json.Unmarshal([]byte(obj.(string)), &v)
|
||||
|
||||
result, e2 := JsonToObj(val)
|
||||
if e2 != nil {
|
||||
err = e2
|
||||
} else if s, ok := result.([]interface{}); ok {
|
||||
v = s
|
||||
} else {
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
}
|
||||
default:
|
||||
v = Slice{}
|
||||
var data []byte
|
||||
data, err = json.Marshal(obj)
|
||||
err = json.Unmarshal(data, &v)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
result, e2 := JsonToObj(string(data))
|
||||
if e2 != nil {
|
||||
err = e2
|
||||
} else if s, ok := result.([]interface{}); ok {
|
||||
v = s
|
||||
} else {
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,22 +128,24 @@ func ObjToTime(obj interface{}, e ...*Error) *time.Time {
|
||||
//字符串类型,只支持标准mysql datetime格式
|
||||
if tInt == 0 {
|
||||
tStr := ObjToStr(obj)
|
||||
timeNewStr := ""
|
||||
timeNewStrs := strings.Split(tStr, "-")
|
||||
for _, v := range timeNewStrs {
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if len(v) == 1 {
|
||||
v = "0" + v
|
||||
}
|
||||
if timeNewStr == "" {
|
||||
timeNewStr = v
|
||||
continue
|
||||
}
|
||||
timeNewStr = timeNewStr + "-" + v
|
||||
// 先分离日期和时间部分,再对日期各段补零,避免时间中的数字被误处理
|
||||
datePart := tStr
|
||||
timePart := ""
|
||||
if idx := strings.Index(tStr, " "); idx > 0 {
|
||||
datePart = tStr[:idx]
|
||||
timePart = tStr[idx:] // 含前导空格
|
||||
}
|
||||
tStr = timeNewStr
|
||||
dateSegs := strings.Split(datePart, "-")
|
||||
for i, seg := range dateSegs {
|
||||
if seg == "" {
|
||||
continue
|
||||
}
|
||||
if len(seg) == 1 {
|
||||
dateSegs[i] = "0" + seg
|
||||
}
|
||||
}
|
||||
tStr = strings.Join(dateSegs, "-") + timePart
|
||||
|
||||
if len(tStr) > 18 {
|
||||
t, e := time.Parse("2006-01-02 15:04:05", tStr)
|
||||
if e == nil {
|
||||
@@ -146,31 +172,30 @@ func ObjToTime(obj interface{}, e ...*Error) *time.Time {
|
||||
return &t
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 缓存字符串,避免重复调用
|
||||
tIntStr := ObjToStr(tInt)
|
||||
tIntLen := len(tIntStr)
|
||||
|
||||
//纳秒级别
|
||||
if len(ObjToStr(tInt)) > 16 {
|
||||
//t := time.Time{}.Add(time.Nanosecond * time.Duration(tInt))
|
||||
if tIntLen > 16 {
|
||||
t := time.UnixMicro(tInt / 1000)
|
||||
return &t
|
||||
//微秒级别
|
||||
} else if len(ObjToStr(tInt)) > 13 {
|
||||
//t := time.Time{}.Add(time.Microsecond * time.Duration(tInt))
|
||||
} else if tIntLen > 13 {
|
||||
t := time.UnixMicro(tInt)
|
||||
return &t
|
||||
//毫秒级别
|
||||
} else if len(ObjToStr(tInt)) > 10 {
|
||||
//t := time.Time{}.Add(time.Millisecond * time.Duration(tInt))
|
||||
} else if tIntLen > 10 {
|
||||
t := time.UnixMilli(tInt)
|
||||
return &t
|
||||
//秒级别
|
||||
} else if len(ObjToStr(tInt)) > 9 {
|
||||
//t := time.Time{}.Add(time.Second * time.Duration(tInt))
|
||||
} else if tIntLen > 9 {
|
||||
t := time.Unix(tInt, 0)
|
||||
return &t
|
||||
} else if len(ObjToStr(tInt)) > 3 {
|
||||
t, e := time.Parse("2006", ObjToStr(tInt))
|
||||
} else if tIntLen > 3 {
|
||||
t, e := time.Parse("2006", tIntStr)
|
||||
if e == nil {
|
||||
return &t
|
||||
}
|
||||
@@ -184,37 +209,54 @@ func ObjToFloat64(obj interface{}, e ...*Error) float64 {
|
||||
v := float64(0)
|
||||
|
||||
if obj == nil {
|
||||
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
} else {
|
||||
|
||||
switch obj.(type) {
|
||||
switch val := obj.(type) {
|
||||
case int:
|
||||
v = float64(obj.(int))
|
||||
v = float64(val)
|
||||
case int8:
|
||||
v = float64(val)
|
||||
case int16:
|
||||
v = float64(val)
|
||||
case int32:
|
||||
v = float64(val)
|
||||
case int64:
|
||||
v = float64(obj.(int64))
|
||||
v = float64(val)
|
||||
case uint:
|
||||
v = float64(val)
|
||||
case uint8:
|
||||
v = float64(val)
|
||||
case uint16:
|
||||
v = float64(val)
|
||||
case uint32:
|
||||
v = float64(val)
|
||||
case uint64:
|
||||
v = float64(val)
|
||||
case bool:
|
||||
if val {
|
||||
v = 1
|
||||
}
|
||||
case string:
|
||||
value, e := strconv.ParseFloat(obj.(string), 64)
|
||||
if e != nil {
|
||||
v = float64(0)
|
||||
err = e
|
||||
value, e2 := strconv.ParseFloat(val, 64)
|
||||
if e2 != nil {
|
||||
err = e2
|
||||
} else {
|
||||
v = value
|
||||
}
|
||||
case float64:
|
||||
v = obj.(float64)
|
||||
v = val
|
||||
case float32:
|
||||
v = float64(obj.(float32))
|
||||
case uint8:
|
||||
value, e := strconv.ParseFloat(obj.(string), 64)
|
||||
if e != nil {
|
||||
v = float64(0)
|
||||
err = e
|
||||
// 通过 string 中转避免 float32→float64 精度膨胀(如 float32(1.2) 直接转得 1.200000047...)
|
||||
v, _ = strconv.ParseFloat(strconv.FormatFloat(float64(val), 'f', -1, 32), 64)
|
||||
case []byte:
|
||||
// 数据库 driver 有时以 []byte 返回数值字符串
|
||||
value, e2 := strconv.ParseFloat(string(val), 64)
|
||||
if e2 != nil {
|
||||
err = e2
|
||||
} else {
|
||||
v = value
|
||||
}
|
||||
default:
|
||||
v = float64(0)
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
}
|
||||
}
|
||||
@@ -234,25 +276,33 @@ func ObjToFloat64(obj interface{}, e ...*Error) float64 {
|
||||
return v
|
||||
}
|
||||
|
||||
// ObjToRoundFloat64 将 obj 转为 float64 后四舍五入到 precision 位小数。
|
||||
// precision < 0 时不做舍入,直接返回原始 float64。
|
||||
func ObjToRoundFloat64(obj interface{}, precision int, e ...*Error) float64 {
|
||||
f := ObjToFloat64(obj, e...)
|
||||
if precision < 0 {
|
||||
return f
|
||||
}
|
||||
pow := math.Pow10(precision)
|
||||
return math.Round(f*pow) / pow
|
||||
}
|
||||
|
||||
// 向上取整
|
||||
func ObjToCeilInt64(obj interface{}, e ...*Error) int64 {
|
||||
f := ObjToCeilFloat64(obj, e...)
|
||||
return ObjToInt64(math.Ceil(f))
|
||||
|
||||
return int64(f)
|
||||
}
|
||||
|
||||
// 向上取整
|
||||
func ObjToCeilFloat64(obj interface{}, e ...*Error) float64 {
|
||||
f := ObjToFloat64(obj, e...)
|
||||
return math.Ceil(f)
|
||||
|
||||
}
|
||||
|
||||
// 向上取整
|
||||
func ObjToCeilInt(obj interface{}, e ...*Error) int {
|
||||
f := ObjToCeilFloat64(obj, e...)
|
||||
return ObjToInt(f)
|
||||
|
||||
}
|
||||
|
||||
func ObjToInt64(obj interface{}, e ...*Error) int64 {
|
||||
@@ -260,36 +310,63 @@ func ObjToInt64(obj interface{}, e ...*Error) int64 {
|
||||
v := int64(0)
|
||||
|
||||
if obj == nil {
|
||||
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
} else {
|
||||
switch obj.(type) {
|
||||
switch val := obj.(type) {
|
||||
case int:
|
||||
v = int64(obj.(int))
|
||||
v = int64(val)
|
||||
case int8:
|
||||
v = int64(val)
|
||||
case int16:
|
||||
v = int64(val)
|
||||
case int32:
|
||||
v = int64(val)
|
||||
case int64:
|
||||
v = obj.(int64)
|
||||
v = val
|
||||
case uint:
|
||||
v = int64(val)
|
||||
case uint8:
|
||||
v = int64(val)
|
||||
case uint16:
|
||||
v = int64(val)
|
||||
case uint32:
|
||||
v = int64(val)
|
||||
case uint64:
|
||||
v = int64(val)
|
||||
case bool:
|
||||
if val {
|
||||
v = 1
|
||||
}
|
||||
case string:
|
||||
value, e := StrToInt(obj.(string))
|
||||
if e != nil {
|
||||
v = int64(0)
|
||||
err = e
|
||||
value, e2 := StrToInt(val)
|
||||
if e2 != nil {
|
||||
// fallback:处理 "1.20" 这类带小数点的整数字符串
|
||||
if fv, fe := strconv.ParseFloat(val, 64); fe == nil {
|
||||
v = int64(fv)
|
||||
} else {
|
||||
err = e2
|
||||
}
|
||||
} else {
|
||||
v = int64(value)
|
||||
}
|
||||
case uint8:
|
||||
value, e := StrToInt(obj.(string))
|
||||
if e != nil {
|
||||
v = int64(0)
|
||||
err = e
|
||||
case []byte:
|
||||
// 数据库 driver 有时以 []byte 返回整数字符串
|
||||
value, e2 := StrToInt(string(val))
|
||||
if e2 != nil {
|
||||
// fallback:处理 "1.20" 这类带小数点的整数字符串
|
||||
if fv, fe := strconv.ParseFloat(string(val), 64); fe == nil {
|
||||
v = int64(fv)
|
||||
} else {
|
||||
err = e2
|
||||
}
|
||||
} else {
|
||||
v = int64(value)
|
||||
}
|
||||
case float64:
|
||||
v = int64(obj.(float64))
|
||||
v = int64(val)
|
||||
case float32:
|
||||
v = int64(obj.(float32))
|
||||
v = int64(val)
|
||||
default:
|
||||
v = int64(0)
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
}
|
||||
}
|
||||
@@ -309,18 +386,19 @@ func ObjToBool(obj interface{}, e ...*Error) bool {
|
||||
v := false
|
||||
|
||||
if obj == nil {
|
||||
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
} else {
|
||||
switch obj.(type) {
|
||||
switch val := obj.(type) {
|
||||
case bool:
|
||||
v = obj.(bool)
|
||||
v = val
|
||||
default:
|
||||
toInt := ObjToInt(obj)
|
||||
if toInt != 0 {
|
||||
var intErr Error
|
||||
toInt := ObjToInt(obj, &intErr)
|
||||
if intErr.GetError() != nil {
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
} else if toInt != 0 {
|
||||
v = true
|
||||
}
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
}
|
||||
}
|
||||
if len(e) != 0 {
|
||||
@@ -330,55 +408,107 @@ func ObjToBool(obj interface{}, e ...*Error) bool {
|
||||
}
|
||||
|
||||
func ObjToStr(obj interface{}) string {
|
||||
// fmt.Println(reflect.ValueOf(obj).Type().String() )
|
||||
str := ""
|
||||
if obj == nil {
|
||||
return str
|
||||
}
|
||||
switch obj.(type) {
|
||||
switch val := obj.(type) {
|
||||
case int:
|
||||
str = strconv.Itoa(obj.(int))
|
||||
str = strconv.Itoa(val)
|
||||
case int8:
|
||||
str = strconv.Itoa(int(val))
|
||||
case int16:
|
||||
str = strconv.Itoa(int(val))
|
||||
case int32:
|
||||
str = strconv.Itoa(int(val))
|
||||
case uint8:
|
||||
str = obj.(string)
|
||||
str = strconv.Itoa(int(val))
|
||||
case uint16:
|
||||
str = strconv.Itoa(int(val))
|
||||
case uint32:
|
||||
str = strconv.FormatUint(uint64(val), 10)
|
||||
case uint64:
|
||||
str = strconv.FormatUint(val, 10)
|
||||
case int64:
|
||||
str = strconv.FormatInt(obj.(int64), 10)
|
||||
str = strconv.FormatInt(val, 10)
|
||||
case []byte:
|
||||
str = string(obj.([]byte))
|
||||
str = string(val)
|
||||
case string:
|
||||
str = obj.(string)
|
||||
str = val
|
||||
case float64:
|
||||
str = strconv.FormatFloat(obj.(float64), 'f', -1, 64)
|
||||
str = strconv.FormatFloat(val, 'f', -1, 64)
|
||||
case float32:
|
||||
str = strconv.FormatFloat(float64(val), 'f', -1, 32)
|
||||
case bool:
|
||||
str = strconv.FormatBool(val)
|
||||
case time.Time:
|
||||
// DM8等数据库驱动直接返回time.Time,格式化为MySQL兼容的字符串
|
||||
str = val.Format("2006-01-02 15:04:05")
|
||||
default:
|
||||
strbte, err := json.MarshalIndent(obj, "", "\t")
|
||||
|
||||
if err == nil {
|
||||
str = string(strbte)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
// 转换为Map
|
||||
func StrToMap(string string) Map {
|
||||
data := Map{}
|
||||
data.JsonToMap(string)
|
||||
// JsonToObj 将 JSON 字符串反序列化为 interface{},保留数字原始类型。
|
||||
// 标准 json.Unmarshal 会把所有 JSON 数字变成 float64,本函数保留整数为 int64、小数为 float64。
|
||||
//
|
||||
// JsonToObj(`{"id":2,"price":1.20}`) → map{"id": int64(2), "price": float64(1.2)}
|
||||
// JsonToObj(`[1, 2.5, "a"]`) → []interface{}{int64(1), float64(2.5), "a"}
|
||||
func JsonToObj(jsonStr string) (interface{}, error) {
|
||||
dec := json.NewDecoder(bytes.NewReader([]byte(jsonStr)))
|
||||
dec.UseNumber()
|
||||
var result interface{}
|
||||
if err := dec.Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fixJsonNumbers(result), nil
|
||||
}
|
||||
|
||||
// fixJsonNumbers 递归将 json.Number 转为 int64 或 float64
|
||||
func fixJsonNumbers(v interface{}) interface{} {
|
||||
switch val := v.(type) {
|
||||
case json.Number:
|
||||
if n, err := val.Int64(); err == nil {
|
||||
return n
|
||||
}
|
||||
if f, err := val.Float64(); err == nil {
|
||||
return f
|
||||
}
|
||||
return string(val)
|
||||
case map[string]interface{}:
|
||||
for k, item := range val {
|
||||
val[k] = fixJsonNumbers(item)
|
||||
}
|
||||
return val
|
||||
case []interface{}:
|
||||
for i, item := range val {
|
||||
val[i] = fixJsonNumbers(item)
|
||||
}
|
||||
return val
|
||||
default:
|
||||
return val
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为Map
|
||||
func StrToMap(s string) Map {
|
||||
data := Map{}
|
||||
data.JsonToMap(s)
|
||||
return data
|
||||
}
|
||||
|
||||
// 转换为Slice
|
||||
func StrToSlice(string string) Slice {
|
||||
|
||||
data := ObjToSlice(string)
|
||||
|
||||
return data
|
||||
func StrToSlice(s string) Slice {
|
||||
return ObjToSlice(s)
|
||||
}
|
||||
|
||||
// 字符串数组: a1,a2,a3转["a1","a2","a3"]
|
||||
func StrArrayToJsonStr(a string) string {
|
||||
|
||||
if len(a) > 2 {
|
||||
if a[0] == ',' {
|
||||
a = Substr(a, 1, len(a)-1)
|
||||
@@ -386,7 +516,6 @@ func StrArrayToJsonStr(a string) string {
|
||||
if a[len(a)-1] == ',' {
|
||||
a = Substr(a, 0, len(a)-1)
|
||||
}
|
||||
//a = strings.Replace(a, ",", `,`, -1)
|
||||
a = `[` + a + `]`
|
||||
} else {
|
||||
a = "[]"
|
||||
@@ -394,13 +523,11 @@ func StrArrayToJsonStr(a string) string {
|
||||
return a
|
||||
}
|
||||
|
||||
// 字符串数组: a1,a2,a3转["a1","a2","a3"]
|
||||
// 字符串数组: ["a1","a2","a3"]转,a1,a2,a3,
|
||||
func JsonStrToStrArray(a string) string {
|
||||
//a = strings.Replace(a, `"`, "", -1)
|
||||
if len(a) != 0 {
|
||||
a = Substr(a, 1, len(a)-2)
|
||||
}
|
||||
|
||||
return "," + a + ","
|
||||
}
|
||||
|
||||
@@ -408,5 +535,4 @@ func JsonStrToStrArray(a string) string {
|
||||
func StrToInt(s string) (int, error) {
|
||||
i, err := strconv.Atoi(s)
|
||||
return i, err
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,917 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 辅助:检查 Error 是否携带错误
|
||||
// ─────────────────────────────────────────────
|
||||
func hasErr(e *Error) bool {
|
||||
return e.GetError() != nil
|
||||
}
|
||||
|
||||
func newErr() *Error {
|
||||
return &Error{}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// ObjToMap
|
||||
// ─────────────────────────────────────────────
|
||||
func TestObjToMap(t *testing.T) {
|
||||
t.Run("nil returns nil with error", func(t *testing.T) {
|
||||
e := newErr()
|
||||
v := ObjToMap(nil, e)
|
||||
if v != nil || !hasErr(e) {
|
||||
t.Errorf("nil: got %v err=%v", v, e.GetError())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Map passthrough", func(t *testing.T) {
|
||||
input := Map{"a": 1}
|
||||
v := ObjToMap(input)
|
||||
if v == nil || v["a"] != 1 {
|
||||
t.Errorf("Map passthrough failed: %v", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("map[string]interface{} cast", func(t *testing.T) {
|
||||
input := map[string]interface{}{"b": 2}
|
||||
v := ObjToMap(input)
|
||||
if v == nil || v["b"] != 2 {
|
||||
t.Errorf("map[string]interface{} cast failed: %v", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid JSON string - integer preserved as int64", func(t *testing.T) {
|
||||
v := ObjToMap(`{"x":42}`)
|
||||
if v == nil || v["x"] != int64(42) {
|
||||
t.Errorf("JSON string: got %v (type %T), want int64(42)", v["x"], v["x"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid JSON string returns nil with error", func(t *testing.T) {
|
||||
e := newErr()
|
||||
v := ObjToMap(`not json`, e)
|
||||
if v != nil || !hasErr(e) {
|
||||
t.Errorf("invalid JSON: got %v err=%v", v, e.GetError())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("struct default branch marshal+unmarshal", func(t *testing.T) {
|
||||
type S struct{ Name string }
|
||||
v := ObjToMap(S{Name: "hello"})
|
||||
if v == nil || v["Name"] != "hello" {
|
||||
t.Errorf("struct default: %v", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("marshal fail (channel) returns nil with error", func(t *testing.T) {
|
||||
e := newErr()
|
||||
ch := make(chan int)
|
||||
v := ObjToMap(ch, e)
|
||||
if v != nil || !hasErr(e) {
|
||||
t.Errorf("channel marshal: got %v err=%v", v, e.GetError())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default marshal OK but unmarshal to map fails (int)", func(t *testing.T) {
|
||||
// json.Marshal(123) = "123", which can't unmarshal to Map
|
||||
e := newErr()
|
||||
v := ObjToMap(123, e)
|
||||
if v != nil || !hasErr(e) {
|
||||
t.Errorf("int default: got %v err=%v", v, e.GetError())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// ObjToMapArray
|
||||
// ─────────────────────────────────────────────
|
||||
func TestObjToMapArray(t *testing.T) {
|
||||
t.Run("nil returns empty", func(t *testing.T) {
|
||||
e := newErr()
|
||||
v := ObjToMapArray(nil, e)
|
||||
if len(v) != 0 || !hasErr(e) {
|
||||
t.Errorf("nil: got %v err=%v", v, e.GetError())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("JSON array of maps", func(t *testing.T) {
|
||||
v := ObjToMapArray(`[{"a":1},{"b":2}]`)
|
||||
if len(v) != 2 {
|
||||
t.Errorf("expected 2 elements, got %d", len(v))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// ObjToSlice
|
||||
// ─────────────────────────────────────────────
|
||||
func TestObjToSlice(t *testing.T) {
|
||||
t.Run("nil returns nil with error", func(t *testing.T) {
|
||||
e := newErr()
|
||||
v := ObjToSlice(nil, e)
|
||||
if v != nil || !hasErr(e) {
|
||||
t.Errorf("nil: got %v err=%v", v, e.GetError())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Slice passthrough", func(t *testing.T) {
|
||||
input := Slice{1, 2, 3}
|
||||
v := ObjToSlice(input)
|
||||
if len(v) != 3 {
|
||||
t.Errorf("Slice passthrough: %v", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("[]interface{} cast", func(t *testing.T) {
|
||||
input := []interface{}{4, 5}
|
||||
v := ObjToSlice(input)
|
||||
if len(v) != 2 || v[0] != 4 {
|
||||
t.Errorf("[]interface{} cast: %v", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("[]string conversion", func(t *testing.T) {
|
||||
input := []string{"a", "b", "c"}
|
||||
v := ObjToSlice(input)
|
||||
if len(v) != 3 || v[0] != "a" {
|
||||
t.Errorf("[]string: %v", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid JSON array string", func(t *testing.T) {
|
||||
v := ObjToSlice(`[1,2,3]`)
|
||||
if len(v) != 3 {
|
||||
t.Errorf("JSON array: %v", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid JSON string returns error", func(t *testing.T) {
|
||||
e := newErr()
|
||||
v := ObjToSlice(`not json`, e)
|
||||
if !hasErr(e) {
|
||||
t.Errorf("invalid JSON: got %v err=%v", v, e.GetError())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default: []int marshals to JSON array", func(t *testing.T) {
|
||||
v := ObjToSlice([]int{10, 20, 30})
|
||||
if len(v) != 3 {
|
||||
t.Errorf("[]int default: %v", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default: marshal fail (channel) returns error", func(t *testing.T) {
|
||||
e := newErr()
|
||||
ch := make(chan int)
|
||||
ObjToSlice(ch, e)
|
||||
if !hasErr(e) {
|
||||
t.Errorf("channel marshal: expected error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// ObjToTime
|
||||
// ─────────────────────────────────────────────
|
||||
func TestObjToTime(t *testing.T) {
|
||||
mustParse := func(layout, s string) time.Time {
|
||||
tt, err := time.Parse(layout, s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return tt
|
||||
}
|
||||
|
||||
t.Run("full datetime string", func(t *testing.T) {
|
||||
ts := ObjToTime("2006-01-02 15:04:05")
|
||||
want := mustParse("2006-01-02 15:04:05", "2006-01-02 15:04:05")
|
||||
if ts == nil || !ts.Equal(want) {
|
||||
t.Errorf("full datetime: %v", ts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single digit day+time (bug fix)", func(t *testing.T) {
|
||||
ts := ObjToTime("2006-1-2 15:04:05")
|
||||
want := mustParse("2006-01-02 15:04:05", "2006-01-02 15:04:05")
|
||||
if ts == nil || !ts.Equal(want) {
|
||||
t.Errorf("single digit day+time: got %v, want %v", ts, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("datetime without seconds", func(t *testing.T) {
|
||||
ts := ObjToTime("2006-01-02 15:04")
|
||||
want := mustParse("2006-01-02 15:04", "2006-01-02 15:04")
|
||||
if ts == nil || !ts.Equal(want) {
|
||||
t.Errorf("datetime without seconds: %v", ts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("date+hour only", func(t *testing.T) {
|
||||
ts := ObjToTime("2006-01-02 15")
|
||||
want := mustParse("2006-01-02 15", "2006-01-02 15")
|
||||
if ts == nil || !ts.Equal(want) {
|
||||
t.Errorf("date+hour: %v", ts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("date only", func(t *testing.T) {
|
||||
ts := ObjToTime("2006-01-02")
|
||||
want := mustParse("2006-01-02", "2006-01-02")
|
||||
if ts == nil || !ts.Equal(want) {
|
||||
t.Errorf("date only: %v", ts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("month only", func(t *testing.T) {
|
||||
ts := ObjToTime("2006-01")
|
||||
want := mustParse("2006-01", "2006-01")
|
||||
if ts == nil || !ts.Equal(want) {
|
||||
t.Errorf("month only: %v", ts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid string returns nil", func(t *testing.T) {
|
||||
ts := ObjToTime("not-a-date")
|
||||
if ts != nil {
|
||||
t.Errorf("invalid string: expected nil, got %v", ts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unix second timestamp (10 digits)", func(t *testing.T) {
|
||||
ts := ObjToTime(int64(1700000000)) // 10 digits
|
||||
if ts == nil {
|
||||
t.Error("second timestamp: got nil")
|
||||
}
|
||||
want := time.Unix(1700000000, 0)
|
||||
if !ts.Equal(want) {
|
||||
t.Errorf("second: %v vs %v", ts, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unix millisecond timestamp (13 digits)", func(t *testing.T) {
|
||||
ts := ObjToTime(int64(1700000000000)) // 13 digits
|
||||
if ts == nil {
|
||||
t.Error("millisecond timestamp: got nil")
|
||||
}
|
||||
want := time.UnixMilli(1700000000000)
|
||||
if !ts.Equal(want) {
|
||||
t.Errorf("millisecond: %v vs %v", ts, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unix microsecond timestamp (16 digits)", func(t *testing.T) {
|
||||
ts := ObjToTime(int64(1700000000000000)) // 16 digits
|
||||
if ts == nil {
|
||||
t.Error("microsecond timestamp: got nil")
|
||||
}
|
||||
want := time.UnixMicro(1700000000000000)
|
||||
if !ts.Equal(want) {
|
||||
t.Errorf("microsecond: %v vs %v", ts, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nanosecond timestamp (19 digits)", func(t *testing.T) {
|
||||
ts := ObjToTime(int64(1700000000000000000)) // 19 digits -> nano branch
|
||||
if ts == nil {
|
||||
t.Error("nanosecond timestamp: got nil")
|
||||
}
|
||||
want := time.UnixMicro(1700000000000000000 / 1000)
|
||||
if !ts.Equal(want) {
|
||||
t.Errorf("nanosecond: %v vs %v", ts, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("year int (4 digits)", func(t *testing.T) {
|
||||
ts := ObjToTime(int64(2024))
|
||||
if ts == nil {
|
||||
t.Error("year int: got nil")
|
||||
}
|
||||
want := mustParse("2006", "2024")
|
||||
if !ts.Equal(want) {
|
||||
t.Errorf("year: %v vs %v", ts, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("small int returns nil", func(t *testing.T) {
|
||||
ts := ObjToTime(int64(1))
|
||||
if ts != nil {
|
||||
t.Errorf("small int: expected nil, got %v", ts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil returns nil", func(t *testing.T) {
|
||||
ts := ObjToTime(nil)
|
||||
if ts != nil {
|
||||
t.Errorf("nil: expected nil, got %v", ts)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// ObjToFloat64
|
||||
// ─────────────────────────────────────────────
|
||||
func TestObjToFloat64(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input interface{}
|
||||
want float64
|
||||
wantErr bool
|
||||
}{
|
||||
{"nil", nil, 0, true},
|
||||
{"int", 42, 42.0, false},
|
||||
{"int64", int64(100), 100.0, false},
|
||||
{"string valid", "3.14", 3.14, false},
|
||||
{"string invalid", "abc", 0, true},
|
||||
{"float64", float64(1.5), 1.5, false},
|
||||
// Fix4: float32 通过 strconv 转换,消除精度膨胀噪声
|
||||
{"float32 no precision inflation", float32(1.2), 1.2, false},
|
||||
{"[]byte valid", []byte("2.71"), 2.71, false},
|
||||
{"[]byte invalid", []byte("xyz"), 0, true},
|
||||
{"unknown type", struct{}{}, 0, true},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
e := newErr()
|
||||
got := ObjToFloat64(c.input, e)
|
||||
if math.Abs(got-c.want) > 1e-9 {
|
||||
t.Errorf("value: got %v, want %v", got, c.want)
|
||||
}
|
||||
if hasErr(e) != c.wantErr {
|
||||
t.Errorf("error: got hasErr=%v, wantErr=%v (err=%v)", hasErr(e), c.wantErr, e.GetError())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("NaN is caught and zeroed", func(t *testing.T) {
|
||||
e := newErr()
|
||||
v := ObjToFloat64(math.NaN(), e)
|
||||
if v != 0 || !hasErr(e) {
|
||||
t.Errorf("NaN: got %v err=%v", v, e.GetError())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Inf is caught and zeroed", func(t *testing.T) {
|
||||
e := newErr()
|
||||
v := ObjToFloat64(math.Inf(1), e)
|
||||
if v != 0 || !hasErr(e) {
|
||||
t.Errorf("Inf: got %v err=%v", v, e.GetError())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("-Inf is caught and zeroed", func(t *testing.T) {
|
||||
e := newErr()
|
||||
v := ObjToFloat64(math.Inf(-1), e)
|
||||
if v != 0 || !hasErr(e) {
|
||||
t.Errorf("-Inf: got %v err=%v", v, e.GetError())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// ObjToRoundFloat64
|
||||
// ─────────────────────────────────────────────
|
||||
func TestObjToRoundFloat64(t *testing.T) {
|
||||
t.Run("precision 2 rounds correctly", func(t *testing.T) {
|
||||
v := ObjToRoundFloat64(3.5000001, 2)
|
||||
if v != 3.5 {
|
||||
t.Errorf("got %v, want 3.5", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("precision 0 rounds to integer", func(t *testing.T) {
|
||||
v := ObjToRoundFloat64(2.7, 0)
|
||||
if v != 3.0 {
|
||||
t.Errorf("got %v, want 3.0", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("precision -1 returns raw float", func(t *testing.T) {
|
||||
v := ObjToRoundFloat64(1.23456789, -1)
|
||||
if math.Abs(v-1.23456789) > 1e-9 {
|
||||
t.Errorf("got %v, want ~1.23456789", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("string input with float noise", func(t *testing.T) {
|
||||
v := ObjToRoundFloat64("3.15000001", 2)
|
||||
if v != 3.15 {
|
||||
t.Errorf("got %v, want 3.15", v)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// ObjToCeil*
|
||||
// ─────────────────────────────────────────────
|
||||
func TestObjToCeil(t *testing.T) {
|
||||
t.Run("ObjToCeilInt64 rounds up", func(t *testing.T) {
|
||||
v := ObjToCeilInt64(1.1)
|
||||
if v != 2 {
|
||||
t.Errorf("got %d, want 2", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ObjToCeilInt64 already integer", func(t *testing.T) {
|
||||
v := ObjToCeilInt64(float64(3))
|
||||
if v != 3 {
|
||||
t.Errorf("got %d, want 3", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ObjToCeilInt64 no double ceil (bug fix)", func(t *testing.T) {
|
||||
// 3.0 -> Ceil(3.0)=3.0 -> int64(3), NOT Ceil(3.0) again
|
||||
v := ObjToCeilInt64(float64(3))
|
||||
if v != 3 {
|
||||
t.Errorf("double ceil: got %d, want 3", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ObjToCeilFloat64", func(t *testing.T) {
|
||||
v := ObjToCeilFloat64(1.1)
|
||||
if v != 2.0 {
|
||||
t.Errorf("got %v, want 2.0", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ObjToCeilInt", func(t *testing.T) {
|
||||
v := ObjToCeilInt(2.3)
|
||||
if v != 3 {
|
||||
t.Errorf("got %d, want 3", v)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// ObjToInt64
|
||||
// ─────────────────────────────────────────────
|
||||
func TestObjToInt64(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input interface{}
|
||||
want int64
|
||||
wantErr bool
|
||||
}{
|
||||
{"nil", nil, 0, true},
|
||||
{"int", 7, 7, false},
|
||||
{"int64", int64(99), 99, false},
|
||||
{"string valid int", "42", 42, false},
|
||||
// Fix3 fallback: "1.20" 原来报错,现在截断得 1
|
||||
{"string float-like", "1.20", 1, false},
|
||||
{"string invalid", "abc", 0, true},
|
||||
{"[]byte valid int", []byte("123"), 123, false},
|
||||
// Fix3 fallback: []byte "2.70" 截断得 2
|
||||
{"[]byte float-like", []byte("2.70"), 2, false},
|
||||
{"[]byte invalid", []byte("xyz"), 0, true},
|
||||
{"float64 truncate", float64(5.9), 5, false},
|
||||
{"float32 truncate", float32(3.1), 3, false},
|
||||
{"unknown type", struct{}{}, 0, true},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
e := newErr()
|
||||
got := ObjToInt64(c.input, e)
|
||||
if got != c.want {
|
||||
t.Errorf("value: got %d, want %d", got, c.want)
|
||||
}
|
||||
if hasErr(e) != c.wantErr {
|
||||
t.Errorf("error: got hasErr=%v, wantErr=%v (err=%v)", hasErr(e), c.wantErr, e.GetError())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// ObjToInt
|
||||
// ─────────────────────────────────────────────
|
||||
func TestObjToInt(t *testing.T) {
|
||||
v := ObjToInt(10)
|
||||
if v != 10 {
|
||||
t.Errorf("got %d, want 10", v)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// ObjToBool
|
||||
// ─────────────────────────────────────────────
|
||||
func TestObjToBool(t *testing.T) {
|
||||
t.Run("nil returns false with error", func(t *testing.T) {
|
||||
e := newErr()
|
||||
v := ObjToBool(nil, e)
|
||||
if v || !hasErr(e) {
|
||||
t.Errorf("nil: got %v err=%v", v, e.GetError())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bool true", func(t *testing.T) {
|
||||
e := newErr()
|
||||
v := ObjToBool(true, e)
|
||||
if !v || hasErr(e) {
|
||||
t.Errorf("bool true: got %v err=%v", v, e.GetError())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bool false", func(t *testing.T) {
|
||||
e := newErr()
|
||||
v := ObjToBool(false, e)
|
||||
if v || hasErr(e) {
|
||||
t.Errorf("bool false: got %v err=%v", v, e.GetError())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("int 1 -> true, no error (bug fix)", func(t *testing.T) {
|
||||
e := newErr()
|
||||
v := ObjToBool(1, e)
|
||||
if !v || hasErr(e) {
|
||||
t.Errorf("int 1: got v=%v err=%v", v, e.GetError())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("int 0 -> false, no error", func(t *testing.T) {
|
||||
e := newErr()
|
||||
v := ObjToBool(0, e)
|
||||
if v || hasErr(e) {
|
||||
t.Errorf("int 0: got v=%v err=%v", v, e.GetError())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown type returns false with error", func(t *testing.T) {
|
||||
e := newErr()
|
||||
v := ObjToBool(struct{}{}, e)
|
||||
if v || !hasErr(e) {
|
||||
t.Errorf("struct: got v=%v err=%v", v, e.GetError())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// ObjToStr
|
||||
// ─────────────────────────────────────────────
|
||||
func TestObjToStr(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input interface{}
|
||||
want string
|
||||
}{
|
||||
{"nil", nil, ""},
|
||||
{"int", 42, "42"},
|
||||
{"uint8", uint8(65), "65"},
|
||||
{"int64", int64(1234567890), "1234567890"},
|
||||
{"[]byte", []byte("hello"), "hello"},
|
||||
{"string", "world", "world"},
|
||||
{"float64", float64(3.14), "3.14"},
|
||||
{"float32", float32(2.5), "2.5"},
|
||||
{"bool true", true, "true"},
|
||||
{"bool false", false, "false"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := ObjToStr(c.input)
|
||||
if got != c.want {
|
||||
t.Errorf("got %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("default (struct) marshals to JSON", func(t *testing.T) {
|
||||
type S struct{ X int }
|
||||
got := ObjToStr(S{X: 1})
|
||||
if got == "" {
|
||||
t.Error("struct: expected non-empty JSON string")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// StrToMap
|
||||
// ─────────────────────────────────────────────
|
||||
func TestStrToMap(t *testing.T) {
|
||||
t.Run("valid JSON", func(t *testing.T) {
|
||||
v := StrToMap(`{"k":"v"}`)
|
||||
if v["k"] != "v" {
|
||||
t.Errorf("StrToMap: %v", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid JSON returns empty map", func(t *testing.T) {
|
||||
v := StrToMap(`not json`)
|
||||
if v == nil {
|
||||
t.Error("StrToMap invalid: expected empty map, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// StrToSlice
|
||||
// ─────────────────────────────────────────────
|
||||
func TestStrToSlice(t *testing.T) {
|
||||
v := StrToSlice(`[1,2,3]`)
|
||||
if len(v) != 3 {
|
||||
t.Errorf("StrToSlice: expected 3 elements, got %d", len(v))
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// StrArrayToJsonStr
|
||||
// ─────────────────────────────────────────────
|
||||
func TestStrArrayToJsonStr(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"empty returns []", "", "[]"},
|
||||
{"short (<=2) returns []", "a", "[]"},
|
||||
{"no commas", `"a","b"`, `["a","b"]`},
|
||||
{"leading comma stripped", `,"a","b"`, `["a","b"]`},
|
||||
{"trailing comma stripped", `"a","b",`, `["a","b"]`},
|
||||
{"both commas stripped", `,"a","b",`, `["a","b"]`},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := StrArrayToJsonStr(c.input)
|
||||
if got != c.want {
|
||||
t.Errorf("got %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// JsonStrToStrArray
|
||||
// ─────────────────────────────────────────────
|
||||
func TestJsonStrToStrArray(t *testing.T) {
|
||||
t.Run("non-empty strips brackets", func(t *testing.T) {
|
||||
got := JsonStrToStrArray(`["a","b"]`)
|
||||
if got != `,"a","b",` {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty string", func(t *testing.T) {
|
||||
got := JsonStrToStrArray("")
|
||||
if got != ",," {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// StrToInt
|
||||
// ─────────────────────────────────────────────
|
||||
func TestStrToInt(t *testing.T) {
|
||||
t.Run("valid", func(t *testing.T) {
|
||||
v, err := StrToInt("123")
|
||||
if v != 123 || err != nil {
|
||||
t.Errorf("got %d %v", v, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid", func(t *testing.T) {
|
||||
_, err := StrToInt("abc")
|
||||
if err == nil {
|
||||
t.Error("expected error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// JsonToObj
|
||||
// ─────────────────────────────────────────────
|
||||
func TestJsonToObj(t *testing.T) {
|
||||
t.Run("integer preserved as int64", func(t *testing.T) {
|
||||
v, err := JsonToObj(`{"id":2}`)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
m, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected map, got %T", v)
|
||||
}
|
||||
if m["id"] != int64(2) {
|
||||
t.Errorf("got %v (type %T), want int64(2)", m["id"], m["id"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decimal preserved as float64 with correct precision", func(t *testing.T) {
|
||||
v, err := JsonToObj(`{"price":1.20}`)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
m := v.(map[string]interface{})
|
||||
price, ok := m["price"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("expected float64, got %T", m["price"])
|
||||
}
|
||||
if price != 1.2 {
|
||||
t.Errorf("got %v, want 1.2", price)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("large integer preserved as int64", func(t *testing.T) {
|
||||
v, err := JsonToObj(`{"num":9999999}`)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
m := v.(map[string]interface{})
|
||||
if m["num"] != int64(9999999) {
|
||||
t.Errorf("got %v (type %T), want int64(9999999)", m["num"], m["num"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("array with mixed types", func(t *testing.T) {
|
||||
v, err := JsonToObj(`[1, 2.5, "hello", true]`)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
arr := v.([]interface{})
|
||||
if len(arr) != 4 {
|
||||
t.Fatalf("expected 4 elements, got %d", len(arr))
|
||||
}
|
||||
if arr[0] != int64(1) {
|
||||
t.Errorf("arr[0]: got %v (%T), want int64(1)", arr[0], arr[0])
|
||||
}
|
||||
if arr[1] != float64(2.5) {
|
||||
t.Errorf("arr[1]: got %v (%T), want float64(2.5)", arr[1], arr[1])
|
||||
}
|
||||
if arr[2] != "hello" {
|
||||
t.Errorf("arr[2]: got %v, want hello", arr[2])
|
||||
}
|
||||
if arr[3] != true {
|
||||
t.Errorf("arr[3]: got %v, want true", arr[3])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nested object integers preserved", func(t *testing.T) {
|
||||
v, err := JsonToObj(`{"order":{"id":5,"num":3,"price":1.20}}`)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
m := v.(map[string]interface{})
|
||||
order := m["order"].(map[string]interface{})
|
||||
if order["id"] != int64(5) {
|
||||
t.Errorf("nested id: got %v (%T), want int64(5)", order["id"], order["id"])
|
||||
}
|
||||
if order["num"] != int64(3) {
|
||||
t.Errorf("nested num: got %v (%T), want int64(3)", order["num"], order["num"])
|
||||
}
|
||||
if order["price"] != float64(1.2) {
|
||||
t.Errorf("nested price: got %v, want 1.2", order["price"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid JSON returns error", func(t *testing.T) {
|
||||
_, err := JsonToObj(`not json`)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid JSON")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty object", func(t *testing.T) {
|
||||
v, err := JsonToObj(`{}`)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
m, ok := v.(map[string]interface{})
|
||||
if !ok || len(m) != 0 {
|
||||
t.Errorf("expected empty map, got %v", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("null value", func(t *testing.T) {
|
||||
v, err := JsonToObj(`{"x":null}`)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
m := v.(map[string]interface{})
|
||||
if m["x"] != nil {
|
||||
t.Errorf("expected nil, got %v", m["x"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("zero integer", func(t *testing.T) {
|
||||
v, err := JsonToObj(`{"count":0}`)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
m := v.(map[string]interface{})
|
||||
if m["count"] != int64(0) {
|
||||
t.Errorf("got %v (%T), want int64(0)", m["count"], m["count"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// ObjToMap 数字类型保留(新行为)
|
||||
// ─────────────────────────────────────────────
|
||||
func TestObjToMapNumberPreservation(t *testing.T) {
|
||||
t.Run("JSON integer -> int64", func(t *testing.T) {
|
||||
v := ObjToMap(`{"id":2,"num":0}`)
|
||||
if v["id"] != int64(2) {
|
||||
t.Errorf("id: got %v (%T), want int64(2)", v["id"], v["id"])
|
||||
}
|
||||
if v["num"] != int64(0) {
|
||||
t.Errorf("num: got %v (%T), want int64(0)", v["num"], v["num"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("JSON decimal -> float64", func(t *testing.T) {
|
||||
v := ObjToMap(`{"price":1.20,"rate":0.5}`)
|
||||
if v["price"] != float64(1.2) {
|
||||
t.Errorf("price: got %v (%T), want float64(1.2)", v["price"], v["price"])
|
||||
}
|
||||
if v["rate"] != float64(0.5) {
|
||||
t.Errorf("rate: got %v (%T), want float64(0.5)", v["rate"], v["rate"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("struct with int field -> int64", func(t *testing.T) {
|
||||
type S struct {
|
||||
Count int `json:"count"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
v := ObjToMap(S{Count: 3, Name: "test"})
|
||||
if v == nil {
|
||||
t.Fatal("got nil")
|
||||
}
|
||||
if v["count"] != int64(3) {
|
||||
t.Errorf("count: got %v (%T), want int64(3)", v["count"], v["count"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// ObjToSlice 数字类型保留(新行为)
|
||||
// ─────────────────────────────────────────────
|
||||
func TestObjToSliceNumberPreservation(t *testing.T) {
|
||||
t.Run("JSON integer array -> int64 elements", func(t *testing.T) {
|
||||
v := ObjToSlice(`[1, 2, 3]`)
|
||||
if len(v) != 3 {
|
||||
t.Fatalf("expected 3, got %d", len(v))
|
||||
}
|
||||
if v[0] != int64(1) {
|
||||
t.Errorf("v[0]: got %v (%T), want int64(1)", v[0], v[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("JSON mixed array - integer and float", func(t *testing.T) {
|
||||
v := ObjToSlice(`[2, 1.5, "hello"]`)
|
||||
if v[0] != int64(2) {
|
||||
t.Errorf("v[0]: got %v (%T), want int64(2)", v[0], v[0])
|
||||
}
|
||||
if v[1] != float64(1.5) {
|
||||
t.Errorf("v[1]: got %v (%T), want float64(1.5)", v[1], v[1])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Map.JsonToMap 数字类型保留(新行为)
|
||||
// ─────────────────────────────────────────────
|
||||
func TestMapJsonToMapNumberPreservation(t *testing.T) {
|
||||
t.Run("integer -> int64", func(t *testing.T) {
|
||||
m := Map{}
|
||||
m.JsonToMap(`{"id":5,"count":0}`)
|
||||
if m["id"] != int64(5) {
|
||||
t.Errorf("id: got %v (%T), want int64(5)", m["id"], m["id"])
|
||||
}
|
||||
if m["count"] != int64(0) {
|
||||
t.Errorf("count: got %v (%T), want int64(0)", m["count"], m["count"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decimal -> float64", func(t *testing.T) {
|
||||
m := Map{}
|
||||
m.JsonToMap(`{"price":1.20}`)
|
||||
if m["price"] != float64(1.2) {
|
||||
t.Errorf("price: got %v (%T), want float64(1.2)", m["price"], m["price"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("StrToMap integer -> int64", func(t *testing.T) {
|
||||
m := StrToMap(`{"total":100,"amount":1.50}`)
|
||||
if m["total"] != int64(100) {
|
||||
t.Errorf("total: got %v (%T), want int64(100)", m["total"], m["total"])
|
||||
}
|
||||
if m["amount"] != float64(1.5) {
|
||||
t.Errorf("amount: got %v (%T), want float64(1.5)", m["amount"], m["amount"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid JSON sets error", func(t *testing.T) {
|
||||
e := newErr()
|
||||
m := Map{}
|
||||
m.JsonToMap(`not json`, e)
|
||||
if !hasErr(e) {
|
||||
t.Error("expected error for invalid JSON")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -63,6 +63,11 @@ func (that Slice) GetFloat64(key int, err ...*Error) float64 {
|
||||
return v
|
||||
}
|
||||
|
||||
// GetRoundFloat64 获取四舍五入到 precision 位小数的 float64
|
||||
func (that Slice) GetRoundFloat64(key int, precision int, err ...*Error) float64 {
|
||||
return ObjToRoundFloat64((that)[key], precision, err...)
|
||||
}
|
||||
|
||||
func (that Slice) GetSlice(key int, err ...*Error) Slice {
|
||||
v := ObjToSlice((that)[key], err...)
|
||||
return v
|
||||
|
||||
@@ -59,6 +59,9 @@ func (that *Context) Display(statu int, data interface{}) {
|
||||
resp["result"] = temp
|
||||
//兼容android等需要json转对象的服务
|
||||
resp["error"] = temp
|
||||
|
||||
that.Application.Log.Warn().Int("status", statu).Msg(resp.ToJsonString())
|
||||
|
||||
} else {
|
||||
resp["result"] = data
|
||||
}
|
||||
|
||||
+64
-6
@@ -14,13 +14,18 @@ func (that *HoTimeDB) backupSave(path string, tt string, code int) {
|
||||
fd, _ := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644)
|
||||
defer fd.Close()
|
||||
|
||||
q := "`"
|
||||
if that.Dialect != nil {
|
||||
q = that.Dialect.QuoteChar()
|
||||
}
|
||||
|
||||
str := "\r\n"
|
||||
if code == 0 || code == 2 {
|
||||
str += that.backupDdl(tt)
|
||||
}
|
||||
|
||||
if code == 0 || code == 1 {
|
||||
str += "insert into `" + tt + "`\r\n\r\n("
|
||||
str += "insert into " + q + tt + q + "\r\n\r\n("
|
||||
str += that.backupCol(tt)
|
||||
}
|
||||
|
||||
@@ -29,16 +34,69 @@ func (that *HoTimeDB) backupSave(path string, tt string, code int) {
|
||||
|
||||
// backupDdl 备份表结构(DDL)
|
||||
func (that *HoTimeDB) backupDdl(tt string) string {
|
||||
data := that.Query("show create table " + tt)
|
||||
if len(data) == 0 {
|
||||
switch that.Type {
|
||||
case "dm", "dameng":
|
||||
return that.backupDdlDM(tt)
|
||||
default:
|
||||
data := that.Query("show create table " + tt)
|
||||
if len(data) == 0 {
|
||||
return ""
|
||||
}
|
||||
return ObjToStr(data[0]["Create Table"]) + ";\r\n\r\n"
|
||||
}
|
||||
}
|
||||
|
||||
// backupDdlDM 达梦数据库 DDL 导出
|
||||
func (that *HoTimeDB) backupDdlDM(tt string) string {
|
||||
cols := that.Query(`SELECT COLUMN_NAME, DATA_TYPE, DATA_LENGTH, DATA_PRECISION, DATA_SCALE, NULLABLE, DATA_DEFAULT FROM USER_TAB_COLUMNS WHERE TABLE_NAME='` + tt + `' ORDER BY COLUMN_ID`)
|
||||
if len(cols) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return ObjToStr(data[0]["Create Table"]) + ";\r\n\r\n"
|
||||
ddl := "CREATE TABLE \"" + tt + "\" (\r\n"
|
||||
for i, col := range cols {
|
||||
name := col.GetString("COLUMN_NAME")
|
||||
dtype := col.GetString("DATA_TYPE")
|
||||
nullable := col.GetString("NULLABLE")
|
||||
defVal := col.GetString("DATA_DEFAULT")
|
||||
|
||||
colDef := " \"" + name + "\" " + dtype
|
||||
prec := col.GetInt("DATA_PRECISION")
|
||||
scale := col.GetInt("DATA_SCALE")
|
||||
length := col.GetInt("DATA_LENGTH")
|
||||
if prec > 0 {
|
||||
colDef += "(" + ObjToStr(prec)
|
||||
if scale > 0 {
|
||||
colDef += "," + ObjToStr(scale)
|
||||
}
|
||||
colDef += ")"
|
||||
} else if length > 0 && (strings.Contains(strings.ToUpper(dtype), "CHAR") || strings.Contains(strings.ToUpper(dtype), "BINARY")) {
|
||||
colDef += "(" + ObjToStr(length) + ")"
|
||||
}
|
||||
|
||||
if nullable == "N" {
|
||||
colDef += " NOT NULL"
|
||||
}
|
||||
if defVal != "" {
|
||||
colDef += " DEFAULT " + strings.TrimSpace(defVal)
|
||||
}
|
||||
|
||||
if i < len(cols)-1 {
|
||||
colDef += ","
|
||||
}
|
||||
ddl += colDef + "\r\n"
|
||||
}
|
||||
ddl += ");\r\n\r\n"
|
||||
return ddl
|
||||
}
|
||||
|
||||
// backupCol 备份表数据
|
||||
func (that *HoTimeDB) backupCol(tt string) string {
|
||||
q := "`"
|
||||
if that.Dialect != nil {
|
||||
q = that.Dialect.QuoteChar()
|
||||
}
|
||||
|
||||
str := ""
|
||||
data := that.Select(tt, "*")
|
||||
|
||||
@@ -54,9 +112,9 @@ func (that *HoTimeDB) backupCol(tt string) string {
|
||||
|
||||
for k := range data[0] {
|
||||
if tempLthData == lthCol-1 {
|
||||
str += "`" + k + "`) "
|
||||
str += q + k + q + ") "
|
||||
} else {
|
||||
str += "`" + k + "`,"
|
||||
str += q + k + q + ","
|
||||
}
|
||||
col[tempLthData] = k
|
||||
tempLthData++
|
||||
|
||||
+81
-20
@@ -8,6 +8,10 @@ import (
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
func (that *HoTimeDB) isCacheTable(table string) bool {
|
||||
return table == "cached" || strings.HasPrefix(table, "hotime_cache")
|
||||
}
|
||||
|
||||
// Page 设置分页参数
|
||||
// page: 页码(从1开始)
|
||||
// pageRow: 每页数量
|
||||
@@ -135,23 +139,20 @@ func (that *HoTimeDB) Select(table string, qu ...interface{}) []Map {
|
||||
qs = append(qs, resWhere...)
|
||||
md5 := that.md5(query, qs...)
|
||||
|
||||
if that.HoTimeCache != nil && table != "cached" {
|
||||
// 如果缓存有则从缓存取
|
||||
if that.testTx == nil && that.HoTimeCache != nil && !that.isCacheTable(table) {
|
||||
cacheData := that.HoTimeCache.Db(table + ":" + md5)
|
||||
if cacheData != nil && cacheData.Data != nil {
|
||||
return cacheData.ToMapArray()
|
||||
}
|
||||
}
|
||||
|
||||
// 无缓存则数据库取
|
||||
res := that.Query(query, qs...)
|
||||
|
||||
if res == nil {
|
||||
res = []Map{}
|
||||
}
|
||||
|
||||
// 缓存
|
||||
if that.HoTimeCache != nil && table != "cached" {
|
||||
if that.testTx == nil && that.HoTimeCache != nil && !that.isCacheTable(table) {
|
||||
_ = that.HoTimeCache.Db(table+":"+md5, res)
|
||||
}
|
||||
|
||||
@@ -288,18 +289,30 @@ func (that *HoTimeDB) Insert(table string, data map[string]interface{}) int64 {
|
||||
|
||||
query := "INSERT INTO " + processor.ProcessTableName(table) + " " + queryString + "VALUES" + valueString
|
||||
|
||||
res, err := that.Exec(query, values...)
|
||||
|
||||
id := int64(0)
|
||||
if err.GetError() == nil && res != nil {
|
||||
id1, err := res.LastInsertId()
|
||||
that.LastErr.SetError(err)
|
||||
id = id1
|
||||
if that.Dialect != nil && !that.Dialect.SupportsLastInsertId() {
|
||||
returningClause := that.Dialect.ReturningClause("id")
|
||||
query = strings.TrimSuffix(query, ";") + returningClause
|
||||
rows := that.Query(query, values...)
|
||||
if len(rows) > 0 {
|
||||
id = rows[0].GetInt64("id")
|
||||
}
|
||||
} else {
|
||||
res, err := that.Exec(query, values...)
|
||||
if err == nil && res != nil {
|
||||
id1, e := res.LastInsertId()
|
||||
if e != nil {
|
||||
dbErr := &DBError{}
|
||||
dbErr.SetError(e)
|
||||
that.lastError.Store(dbErr)
|
||||
}
|
||||
id = id1
|
||||
}
|
||||
}
|
||||
|
||||
// 如果插入成功,删除缓存
|
||||
if id != 0 {
|
||||
if that.HoTimeCache != nil && table != "cached" {
|
||||
if that.HoTimeCache != nil && !that.isCacheTable(table) {
|
||||
_ = that.HoTimeCache.Db(table+"*", nil)
|
||||
}
|
||||
}
|
||||
@@ -381,13 +394,13 @@ func (that *HoTimeDB) Inserts(table string, dataList []Map) int64 {
|
||||
res, err := that.Exec(query, values...)
|
||||
|
||||
rows64 := int64(0)
|
||||
if err.GetError() == nil && res != nil {
|
||||
if err == nil && res != nil {
|
||||
rows64, _ = res.RowsAffected()
|
||||
}
|
||||
|
||||
// 如果插入成功,删除缓存
|
||||
if rows64 != 0 {
|
||||
if that.HoTimeCache != nil && table != "cached" {
|
||||
if that.HoTimeCache != nil && !that.isCacheTable(table) {
|
||||
_ = that.HoTimeCache.Db(table+"*", nil)
|
||||
}
|
||||
}
|
||||
@@ -478,6 +491,8 @@ func (that *HoTimeDB) Upsert(table string, data Map, uniqueKeys Slice, updateCol
|
||||
query = that.buildPostgresUpsert(table, columns, uniqueKeyStrs, updateColumnStrs, rawValues)
|
||||
case "sqlite3", "sqlite":
|
||||
query = that.buildSQLiteUpsert(table, columns, uniqueKeyStrs, updateColumnStrs, rawValues)
|
||||
case "dm", "dameng":
|
||||
query = that.buildDMUpsert(table, columns, uniqueKeyStrs, updateColumnStrs, rawValues)
|
||||
default: // mysql
|
||||
query = that.buildMySQLUpsert(table, columns, uniqueKeyStrs, updateColumnStrs, rawValues)
|
||||
}
|
||||
@@ -485,13 +500,13 @@ func (that *HoTimeDB) Upsert(table string, data Map, uniqueKeys Slice, updateCol
|
||||
res, err := that.Exec(query, values...)
|
||||
|
||||
rows := int64(0)
|
||||
if err.GetError() == nil && res != nil {
|
||||
if err == nil && res != nil {
|
||||
rows, _ = res.RowsAffected()
|
||||
}
|
||||
|
||||
// 清除缓存
|
||||
if rows != 0 {
|
||||
if that.HoTimeCache != nil && table != "cached" {
|
||||
if that.HoTimeCache != nil && !that.isCacheTable(table) {
|
||||
_ = that.HoTimeCache.Db(table+"*", nil)
|
||||
}
|
||||
}
|
||||
@@ -611,6 +626,52 @@ func (that *HoTimeDB) buildSQLiteUpsert(table string, columns []string, uniqueKe
|
||||
") DO UPDATE SET " + strings.Join(updateParts, ", ")
|
||||
}
|
||||
|
||||
// buildDMUpsert 构建达梦的 Upsert 语句(MERGE INTO)
|
||||
func (that *HoTimeDB) buildDMUpsert(table string, columns []string, uniqueKeys []string, updateColumns []string, rawValues map[string]string) string {
|
||||
processor := that.GetProcessor()
|
||||
dialect := that.GetDialect()
|
||||
quotedTable := processor.ProcessTableName(table)
|
||||
|
||||
srcParts := make([]string, len(columns))
|
||||
for i, col := range columns {
|
||||
if raw, ok := rawValues[col]; ok {
|
||||
srcParts[i] = raw + " AS " + dialect.QuoteIdentifier(col)
|
||||
} else {
|
||||
srcParts[i] = "? AS " + dialect.QuoteIdentifier(col)
|
||||
}
|
||||
}
|
||||
|
||||
onParts := make([]string, len(uniqueKeys))
|
||||
for i, key := range uniqueKeys {
|
||||
qk := dialect.QuoteIdentifier(key)
|
||||
onParts[i] = quotedTable + "." + qk + " = src." + qk
|
||||
}
|
||||
|
||||
updateParts := make([]string, len(updateColumns))
|
||||
for i, col := range updateColumns {
|
||||
qc := dialect.QuoteIdentifier(col)
|
||||
if raw, ok := rawValues[col]; ok {
|
||||
updateParts[i] = qc + " = " + raw
|
||||
} else {
|
||||
updateParts[i] = qc + " = src." + qc
|
||||
}
|
||||
}
|
||||
|
||||
insertCols := make([]string, len(columns))
|
||||
insertVals := make([]string, len(columns))
|
||||
for i, col := range columns {
|
||||
qc := dialect.QuoteIdentifier(col)
|
||||
insertCols[i] = qc
|
||||
insertVals[i] = "src." + qc
|
||||
}
|
||||
|
||||
return "MERGE INTO " + quotedTable + " USING (SELECT " + strings.Join(srcParts, ", ") +
|
||||
") src ON (" + strings.Join(onParts, " AND ") +
|
||||
") WHEN MATCHED THEN UPDATE SET " + strings.Join(updateParts, ", ") +
|
||||
" WHEN NOT MATCHED THEN INSERT (" + strings.Join(insertCols, ", ") +
|
||||
") VALUES (" + strings.Join(insertVals, ", ") + ")"
|
||||
}
|
||||
|
||||
// Update 更新数据
|
||||
func (that *HoTimeDB) Update(table string, data Map, where Map) int64 {
|
||||
processor := that.GetProcessor()
|
||||
@@ -640,13 +701,13 @@ func (that *HoTimeDB) Update(table string, data Map, where Map) int64 {
|
||||
res, err := that.Exec(query, qs...)
|
||||
|
||||
rows := int64(0)
|
||||
if err.GetError() == nil && res != nil {
|
||||
if err == nil && res != nil {
|
||||
rows, _ = res.RowsAffected()
|
||||
}
|
||||
|
||||
// 如果更新成功,则删除缓存
|
||||
if rows != 0 {
|
||||
if that.HoTimeCache != nil && table != "cached" {
|
||||
if that.HoTimeCache != nil && !that.isCacheTable(table) {
|
||||
_ = that.HoTimeCache.Db(table+"*", nil)
|
||||
}
|
||||
}
|
||||
@@ -664,13 +725,13 @@ func (that *HoTimeDB) Delete(table string, data map[string]interface{}) int64 {
|
||||
|
||||
res, err := that.Exec(query, resWhere...)
|
||||
rows := int64(0)
|
||||
if err.GetError() == nil && res != nil {
|
||||
if err == nil && res != nil {
|
||||
rows, _ = res.RowsAffected()
|
||||
}
|
||||
|
||||
// 如果删除成功,删除对应缓存
|
||||
if rows != 0 {
|
||||
if that.HoTimeCache != nil && table != "cached" {
|
||||
if that.HoTimeCache != nil && !that.isCacheTable(table) {
|
||||
_ = that.HoTimeCache.Db(table+"*", nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,69 +1,118 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"code.hoteas.com/golang/hotime/cache"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
"code.hoteas.com/golang/hotime/log"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
_ "gitee.com/chunanyong/dm"
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// DBError 最后一次 SQL 操作的结构化快照
|
||||
// 嵌入框架 Error 类型,与 Obj 等保持一致的错误 API(HasError/GetError/Unwrap)
|
||||
type DBError struct {
|
||||
Error
|
||||
Query string
|
||||
Data []interface{}
|
||||
}
|
||||
|
||||
// HoTimeDB 数据库操作核心结构体
|
||||
type HoTimeDB struct {
|
||||
*sql.DB
|
||||
ContextBase
|
||||
DBName string
|
||||
*cache.HoTimeCache
|
||||
Log *logrus.Logger
|
||||
Log *log.Logger
|
||||
Type string // 数据库类型: mysql, sqlite3, postgres
|
||||
Prefix string
|
||||
LastQuery string
|
||||
LastData []interface{}
|
||||
ConnectFunc func(err ...*Error) (*sql.DB, *sql.DB)
|
||||
LastErr *Error
|
||||
ConnectFunc func() (*sql.DB, *sql.DB)
|
||||
lastError atomic.Pointer[DBError] // 无锁读写,始终记录最后一次 SQL 操作
|
||||
limit Slice
|
||||
*sql.Tx //事务对象
|
||||
SlaveDB *sql.DB // 从数据库
|
||||
Mode int // mode为0生产模式,1为测试模式,2为开发模式
|
||||
mu sync.RWMutex
|
||||
limitMu sync.Mutex
|
||||
Dialect Dialect // 数据库方言适配器
|
||||
Dialect Dialect // 数据库方言适配器
|
||||
testTx *sql.Tx // 测试事务:设置后所有操作都在此事务内,测试结束回滚
|
||||
testMu *sync.Mutex // 保护 testTx 单连接不被并发访问
|
||||
testLogBuf *bytes.Buffer // 测试模式:SQL 日志写入此 buffer 而非直接打印,失败时才输出
|
||||
testLogBufMu sync.Mutex // 保护 testLogBuf 并发写入
|
||||
testSQLErrCount int32 // 测试模式下 SQL 错误计数(atomic)
|
||||
txFailed *bool // 事务内是否有SQL出错,出错则事务必须回滚(指针确保按值传递 HoTimeDB 时状态共享)
|
||||
}
|
||||
|
||||
// GetLast 返回最后一次 SQL 操作的完整快照(Query + Data + Error)
|
||||
func (that *HoTimeDB) GetLast() *DBError {
|
||||
return that.lastError.Load()
|
||||
}
|
||||
|
||||
// GetLastError 返回最后一次 SQL 的错误(nil = 成功)
|
||||
func (that *HoTimeDB) GetLastError() error {
|
||||
if e := that.lastError.Load(); e != nil {
|
||||
return e.GetError()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLastQuery 返回最后一次执行的 SQL 语句
|
||||
func (that *HoTimeDB) GetLastQuery() string {
|
||||
if e := that.lastError.Load(); e != nil {
|
||||
return e.Query
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetLastData 返回最后一次 SQL 的参数
|
||||
func (that *HoTimeDB) GetLastData() []interface{} {
|
||||
if e := that.lastError.Load(); e != nil {
|
||||
return e.Data
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetConnect 设置数据库配置连接
|
||||
func (that *HoTimeDB) SetConnect(connect func(err ...*Error) (master, slave *sql.DB), err ...*Error) {
|
||||
func (that *HoTimeDB) SetConnect(connect func() (master, slave *sql.DB)) {
|
||||
that.ConnectFunc = connect
|
||||
_ = that.InitDb(err...)
|
||||
that.InitDb()
|
||||
}
|
||||
|
||||
// InitDb 初始化数据库连接
|
||||
func (that *HoTimeDB) InitDb(err ...*Error) *Error {
|
||||
if len(err) != 0 {
|
||||
that.LastErr = err[0]
|
||||
}
|
||||
that.DB, that.SlaveDB = that.ConnectFunc(that.LastErr)
|
||||
func (that *HoTimeDB) InitDb() {
|
||||
that.DB, that.SlaveDB = that.ConnectFunc()
|
||||
if that.DB == nil {
|
||||
return that.LastErr
|
||||
return
|
||||
}
|
||||
e := that.DB.Ping()
|
||||
|
||||
that.LastErr.SetError(e)
|
||||
if e != nil {
|
||||
dbErr := &DBError{}
|
||||
dbErr.SetError(e)
|
||||
that.lastError.Store(dbErr)
|
||||
if that.Log != nil {
|
||||
that.Log.Error().Err(e).Msg("数据库连接失败")
|
||||
}
|
||||
}
|
||||
|
||||
if that.SlaveDB != nil {
|
||||
e := that.SlaveDB.Ping()
|
||||
that.LastErr.SetError(e)
|
||||
if e != nil {
|
||||
dbErr := &DBError{}
|
||||
dbErr.SetError(e)
|
||||
that.lastError.Store(dbErr)
|
||||
if that.Log != nil {
|
||||
that.Log.Error().Err(e).Msg("从数据库连接失败")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 根据数据库类型初始化方言适配器
|
||||
if that.Dialect == nil {
|
||||
that.initDialect()
|
||||
}
|
||||
|
||||
return that.LastErr
|
||||
}
|
||||
|
||||
// initDialect 根据数据库类型初始化方言
|
||||
@@ -73,6 +122,8 @@ func (that *HoTimeDB) initDialect() {
|
||||
that.Dialect = &PostgreSQLDialect{}
|
||||
case "sqlite3", "sqlite":
|
||||
that.Dialect = &SQLiteDialect{}
|
||||
case "dm", "dameng":
|
||||
that.Dialect = &DMDialect{}
|
||||
default:
|
||||
that.Dialect = &MySQLDialect{}
|
||||
}
|
||||
@@ -145,3 +196,53 @@ func trimQuotes(s string) string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// SetTestLogBuffer 设置测试 SQL 日志缓冲区,非 nil 时 SQL 日志写入 buffer 而非直接打印
|
||||
func (that *HoTimeDB) SetTestLogBuffer(buf *bytes.Buffer) {
|
||||
that.testLogBufMu.Lock()
|
||||
that.testLogBuf = buf
|
||||
that.testLogBufMu.Unlock()
|
||||
}
|
||||
|
||||
// FlushTestLog 取出并清空缓冲区中的 SQL 日志,返回累积的日志文本
|
||||
func (that *HoTimeDB) FlushTestLog() string {
|
||||
that.testLogBufMu.Lock()
|
||||
defer that.testLogBufMu.Unlock()
|
||||
if that.testLogBuf == nil {
|
||||
return ""
|
||||
}
|
||||
s := that.testLogBuf.String()
|
||||
that.testLogBuf.Reset()
|
||||
return s
|
||||
}
|
||||
|
||||
// TestSQLErrorCount 返回测试模式下的 SQL 错误计数
|
||||
func (that *HoTimeDB) TestSQLErrorCount() int32 {
|
||||
return atomic.LoadInt32(&that.testSQLErrCount)
|
||||
}
|
||||
|
||||
// ResetTestSQLErrorCount 重置测试 SQL 错误计数
|
||||
func (that *HoTimeDB) ResetTestSQLErrorCount() {
|
||||
atomic.StoreInt32(&that.testSQLErrCount, 0)
|
||||
}
|
||||
|
||||
// BeginTestTx 开启测试事务,设置后所有 Query/Exec/Action 都在此事务内执行
|
||||
func (that *HoTimeDB) BeginTestTx() error {
|
||||
tx, err := that.DB.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
that.testTx = tx
|
||||
that.testMu = &sync.Mutex{}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RollbackTestTx 回滚测试事务,清除 testTx 状态
|
||||
func (that *HoTimeDB) RollbackTestTx() error {
|
||||
if that.testTx != nil {
|
||||
err := that.testTx.Rollback()
|
||||
that.testTx = nil
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -206,6 +206,100 @@ func (d *PostgreSQLDialect) UpsertSQL(table string, columns []string, uniqueKeys
|
||||
strings.Join(updateParts, ", "))
|
||||
}
|
||||
|
||||
// DMDialect 达梦数据库方言实现
|
||||
type DMDialect struct{}
|
||||
|
||||
func (d *DMDialect) GetName() string {
|
||||
return "dm"
|
||||
}
|
||||
|
||||
func (d *DMDialect) Quote(name string) string {
|
||||
if strings.Contains(name, ".") || strings.Contains(name, " ") {
|
||||
return name
|
||||
}
|
||||
return "\"" + name + "\""
|
||||
}
|
||||
|
||||
func (d *DMDialect) QuoteIdentifier(name string) string {
|
||||
name = strings.Trim(name, "`\"")
|
||||
return "\"" + name + "\""
|
||||
}
|
||||
|
||||
func (d *DMDialect) QuoteChar() string {
|
||||
return "\""
|
||||
}
|
||||
|
||||
func (d *DMDialect) Placeholder(index int) string {
|
||||
return "?"
|
||||
}
|
||||
|
||||
func (d *DMDialect) Placeholders(count int, startIndex int) string {
|
||||
if count <= 0 {
|
||||
return ""
|
||||
}
|
||||
placeholders := make([]string, count)
|
||||
for i := 0; i < count; i++ {
|
||||
placeholders[i] = "?"
|
||||
}
|
||||
return strings.Join(placeholders, ",")
|
||||
}
|
||||
|
||||
func (d *DMDialect) SupportsLastInsertId() bool {
|
||||
return true // DM Go 驱动原生支持 IDENTITY 列的 LastInsertId
|
||||
}
|
||||
|
||||
func (d *DMDialect) ReturningClause(column string) string {
|
||||
return " RETURNING " + d.Quote(column)
|
||||
}
|
||||
|
||||
func (d *DMDialect) UpsertSQL(table string, columns []string, uniqueKeys []string, updateColumns []string) string {
|
||||
// MERGE INTO table USING (SELECT ? AS col1, ? AS col2) src
|
||||
// ON (table.uk = src.uk) WHEN MATCHED THEN UPDATE SET col1 = src.col1
|
||||
// WHEN NOT MATCHED THEN INSERT (col1, col2) VALUES (src.col1, src.col2)
|
||||
quotedTable := d.Quote(table)
|
||||
|
||||
srcParts := make([]string, len(columns))
|
||||
for i, col := range columns {
|
||||
if strings.HasSuffix(col, "[#]") {
|
||||
srcParts[i] = col
|
||||
} else {
|
||||
srcParts[i] = "? AS " + d.Quote(col)
|
||||
}
|
||||
}
|
||||
|
||||
onParts := make([]string, len(uniqueKeys))
|
||||
for i, key := range uniqueKeys {
|
||||
qk := d.Quote(key)
|
||||
onParts[i] = quotedTable + "." + qk + " = src." + qk
|
||||
}
|
||||
|
||||
updateParts := make([]string, len(updateColumns))
|
||||
for i, col := range updateColumns {
|
||||
if strings.HasSuffix(col, "[#]") {
|
||||
updateParts[i] = col
|
||||
} else {
|
||||
qc := d.Quote(col)
|
||||
updateParts[i] = qc + " = src." + qc
|
||||
}
|
||||
}
|
||||
|
||||
insertCols := make([]string, len(columns))
|
||||
insertVals := make([]string, len(columns))
|
||||
for i, col := range columns {
|
||||
qc := d.Quote(col)
|
||||
insertCols[i] = qc
|
||||
insertVals[i] = "src." + qc
|
||||
}
|
||||
|
||||
return fmt.Sprintf("MERGE INTO %s USING (SELECT %s) src ON (%s) WHEN MATCHED THEN UPDATE SET %s WHEN NOT MATCHED THEN INSERT (%s) VALUES (%s)",
|
||||
quotedTable,
|
||||
strings.Join(srcParts, ", "),
|
||||
strings.Join(onParts, " AND "),
|
||||
strings.Join(updateParts, ", "),
|
||||
strings.Join(insertCols, ", "),
|
||||
strings.Join(insertVals, ", "))
|
||||
}
|
||||
|
||||
// SQLiteDialect SQLite 方言实现
|
||||
type SQLiteDialect struct{}
|
||||
|
||||
|
||||
+7
-14
@@ -83,24 +83,19 @@ func (p *IdentifierProcessor) ProcessTableNameNoPrefix(name string) string {
|
||||
|
||||
// ProcessColumn 处理 table.column 格式
|
||||
// 输入: "name" 或 "order.name" 或 "`order`.name" 或 "`order`.`name`"
|
||||
// 输出: "`name`" 或 "`app_order`.`name`" (MySQL)
|
||||
// 输出: "`name`" 或 "`app_order`.`name`" (MySQL) / "\"app_order\".\"name\"" (DM/PG)
|
||||
func (p *IdentifierProcessor) ProcessColumn(name string) string {
|
||||
// 检查是否包含点号
|
||||
if !strings.Contains(name, ".") {
|
||||
// 单独的列名,只加引号
|
||||
return p.dialect.QuoteIdentifier(p.stripQuotes(name))
|
||||
}
|
||||
|
||||
// 处理 table.column 格式
|
||||
parts := p.splitTableColumn(name)
|
||||
if len(parts) == 2 {
|
||||
tableName := p.stripQuotes(parts[0])
|
||||
columnName := p.stripQuotes(parts[1])
|
||||
// 表名添加前缀
|
||||
return p.dialect.QuoteIdentifier(p.prefix+tableName) + "." + p.dialect.QuoteIdentifier(columnName)
|
||||
}
|
||||
|
||||
// 无法解析,返回原样但转换引号
|
||||
return p.convertQuotes(name)
|
||||
}
|
||||
|
||||
@@ -123,6 +118,7 @@ func (p *IdentifierProcessor) ProcessColumnNoPrefix(name string) string {
|
||||
// ProcessConditionString 智能解析条件字符串(如 ON 条件)
|
||||
// 输入: "user.id = order.user_id AND order.status = 1"
|
||||
// 输出: "`app_user`.`id` = `app_order`.`user_id` AND `app_order`.`status` = 1" (MySQL)
|
||||
// 输出: "\"app_user\".\"id\" = \"app_order\".\"user_id\" AND \"app_order\".\"status\" = 1" (DM/PG)
|
||||
func (p *IdentifierProcessor) ProcessConditionString(condition string) string {
|
||||
if condition == "" {
|
||||
return condition
|
||||
@@ -131,7 +127,6 @@ func (p *IdentifierProcessor) ProcessConditionString(condition string) string {
|
||||
result := condition
|
||||
|
||||
// 首先处理已有完整引号的情况 `table`.`column` 或 "table"."column"
|
||||
// 这些需要先处理,因为它们的格式最明确
|
||||
fullyQuotedPattern := regexp.MustCompile("[`\"]([a-zA-Z_][a-zA-Z0-9_]*)[`\"]\\.[`\"]([a-zA-Z_][a-zA-Z0-9_]*)[`\"]")
|
||||
result = fullyQuotedPattern.ReplaceAllStringFunc(result, func(match string) string {
|
||||
parts := fullyQuotedPattern.FindStringSubmatch(match)
|
||||
@@ -144,14 +139,12 @@ func (p *IdentifierProcessor) ProcessConditionString(condition string) string {
|
||||
})
|
||||
|
||||
// 然后处理部分引号的情况 `table`.column 或 "table".column
|
||||
// 注意:需要避免匹配已处理的内容(已经是双引号包裹的)
|
||||
quotedTablePattern := regexp.MustCompile("[`\"]([a-zA-Z_][a-zA-Z0-9_]*)[`\"]\\.([a-zA-Z_][a-zA-Z0-9_]*)(?:[^`\"]|$)")
|
||||
result = quotedTablePattern.ReplaceAllStringFunc(result, func(match string) string {
|
||||
parts := quotedTablePattern.FindStringSubmatch(match)
|
||||
if len(parts) >= 3 {
|
||||
tableName := parts[1]
|
||||
colName := parts[2]
|
||||
// 保留末尾字符(如果有)
|
||||
suffix := ""
|
||||
if len(match) > len(parts[0])-1 {
|
||||
lastChar := match[len(match)-1]
|
||||
@@ -165,15 +158,14 @@ func (p *IdentifierProcessor) ProcessConditionString(condition string) string {
|
||||
})
|
||||
|
||||
// 最后处理无引号的情况 table.column
|
||||
// 使用更精确的正则,确保不匹配已处理的内容
|
||||
unquotedPattern := regexp.MustCompile(`([^` + "`" + `"\w]|^)([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)([^` + "`" + `"\w(]|$)`)
|
||||
result = unquotedPattern.ReplaceAllStringFunc(result, func(match string) string {
|
||||
parts := unquotedPattern.FindStringSubmatch(match)
|
||||
if len(parts) >= 5 {
|
||||
prefix := parts[1] // 前面的边界字符
|
||||
prefix := parts[1]
|
||||
tableName := parts[2]
|
||||
colName := parts[3]
|
||||
suffix := parts[4] // 后面的边界字符
|
||||
suffix := parts[4]
|
||||
return prefix + p.dialect.QuoteIdentifier(p.prefix+tableName) + "." + p.dialect.QuoteIdentifier(colName) + suffix
|
||||
}
|
||||
return match
|
||||
@@ -195,8 +187,9 @@ func (p *IdentifierProcessor) ProcessFieldList(fields string) string {
|
||||
return fields
|
||||
}
|
||||
|
||||
// 使用与 ProcessConditionString 相同的逻辑
|
||||
return p.ProcessConditionString(fields)
|
||||
result := p.ProcessConditionString(fields)
|
||||
result = p.convertQuotes(result)
|
||||
return result
|
||||
}
|
||||
|
||||
// stripQuotes 去除标识符两端的引号(反引号或双引号)
|
||||
|
||||
+291
-67
@@ -4,8 +4,13 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
@@ -24,52 +29,65 @@ func (that *HoTimeDB) Query(query string, args ...interface{}) []Map {
|
||||
|
||||
// queryWithRetry 内部查询方法,支持重试标记
|
||||
func (that *HoTimeDB) queryWithRetry(query string, retried bool, args ...interface{}) []Map {
|
||||
// 预处理数组占位符 ?[]
|
||||
query, args = that.expandArrayPlaceholder(query, args)
|
||||
|
||||
// 保存调试信息(加锁保护)
|
||||
that.mu.Lock()
|
||||
that.LastQuery = query
|
||||
that.LastData = args
|
||||
that.mu.Unlock()
|
||||
var sqlErr error
|
||||
|
||||
defer func() {
|
||||
if that.Mode != 0 {
|
||||
that.mu.RLock()
|
||||
that.Log.Info("SQL:"+that.LastQuery, " DATA:", that.LastData, " ERROR:", that.LastErr.GetError())
|
||||
that.mu.RUnlock()
|
||||
}
|
||||
e := &DBError{Query: query, Data: args}
|
||||
e.SetError(sqlErr)
|
||||
that.lastError.Store(e)
|
||||
that.logSQL(query, args, sqlErr)
|
||||
}()
|
||||
|
||||
var err error
|
||||
var resl *sql.Rows
|
||||
|
||||
// 主从数据库切换,只有select语句有从数据库
|
||||
db := that.DB
|
||||
if that.SlaveDB != nil {
|
||||
db = that.SlaveDB
|
||||
}
|
||||
|
||||
if db == nil {
|
||||
err = errors.New("没有初始化数据库")
|
||||
that.LastErr.SetError(err)
|
||||
sqlErr = errors.New("没有初始化数据库")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 处理参数中的 slice 类型
|
||||
processedArgs := that.processArgs(args)
|
||||
|
||||
if that.Tx != nil {
|
||||
resl, err = that.Tx.Query(query, processedArgs...)
|
||||
if that.testTx != nil {
|
||||
if that.testMu != nil {
|
||||
that.testMu.Lock()
|
||||
}
|
||||
resl, sqlErr = that.testTx.Query(query, processedArgs...)
|
||||
if sqlErr != nil {
|
||||
if that.txFailed != nil {
|
||||
*that.txFailed = true
|
||||
}
|
||||
if that.testMu != nil {
|
||||
that.testMu.Unlock()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
result := that.Row(resl)
|
||||
if that.testMu != nil {
|
||||
that.testMu.Unlock()
|
||||
}
|
||||
return result
|
||||
} else if that.Tx != nil {
|
||||
resl, sqlErr = that.Tx.Query(query, processedArgs...)
|
||||
} else {
|
||||
resl, err = db.Query(query, processedArgs...)
|
||||
resl, sqlErr = db.Query(query, processedArgs...)
|
||||
}
|
||||
|
||||
that.LastErr.SetError(err)
|
||||
if err != nil {
|
||||
// 如果还没重试过,尝试 Ping 后重试一次
|
||||
if !retried {
|
||||
if sqlErr != nil {
|
||||
if that.Tx != nil {
|
||||
if that.txFailed != nil {
|
||||
*that.txFailed = true
|
||||
}
|
||||
}
|
||||
if !retried && that.Tx == nil {
|
||||
if pingErr := db.Ping(); pingErr == nil {
|
||||
sqlErr = nil // 重置,让 retry 的 defer 覆盖
|
||||
return that.queryWithRetry(query, true, args...)
|
||||
}
|
||||
}
|
||||
@@ -80,59 +98,95 @@ func (that *HoTimeDB) queryWithRetry(query string, retried bool, args ...interfa
|
||||
}
|
||||
|
||||
// Exec 执行非查询 SQL
|
||||
func (that *HoTimeDB) Exec(query string, args ...interface{}) (sql.Result, *Error) {
|
||||
func (that *HoTimeDB) Exec(query string, args ...interface{}) (sql.Result, error) {
|
||||
return that.execWithRetry(query, false, args...)
|
||||
}
|
||||
|
||||
// execWithRetry 内部执行方法,支持重试标记
|
||||
func (that *HoTimeDB) execWithRetry(query string, retried bool, args ...interface{}) (sql.Result, *Error) {
|
||||
// 预处理数组占位符 ?[]
|
||||
func (that *HoTimeDB) execWithRetry(query string, retried bool, args ...interface{}) (sql.Result, error) {
|
||||
query, args = that.expandArrayPlaceholder(query, args)
|
||||
|
||||
// 保存调试信息(加锁保护)
|
||||
that.mu.Lock()
|
||||
that.LastQuery = query
|
||||
that.LastData = args
|
||||
that.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
if that.Mode != 0 {
|
||||
that.mu.RLock()
|
||||
that.Log.Info("SQL: "+that.LastQuery, " DATA: ", that.LastData, " ERROR: ", that.LastErr.GetError())
|
||||
that.mu.RUnlock()
|
||||
}
|
||||
}()
|
||||
|
||||
var e error
|
||||
var sqlErr error
|
||||
var resl sql.Result
|
||||
|
||||
defer func() {
|
||||
e := &DBError{Query: query, Data: args}
|
||||
e.SetError(sqlErr)
|
||||
that.lastError.Store(e)
|
||||
that.logSQL(query, args, sqlErr)
|
||||
}()
|
||||
|
||||
if that.DB == nil {
|
||||
err := errors.New("没有初始化数据库")
|
||||
that.LastErr.SetError(err)
|
||||
return nil, that.LastErr
|
||||
sqlErr = errors.New("没有初始化数据库")
|
||||
return nil, sqlErr
|
||||
}
|
||||
|
||||
// 处理参数中的 slice 类型
|
||||
processedArgs := that.processArgs(args)
|
||||
|
||||
if that.Tx != nil {
|
||||
resl, e = that.Tx.Exec(query, processedArgs...)
|
||||
if that.testTx != nil {
|
||||
if that.testMu != nil {
|
||||
that.testMu.Lock()
|
||||
}
|
||||
resl, sqlErr = that.testTx.Exec(query, processedArgs...)
|
||||
if that.testMu != nil {
|
||||
that.testMu.Unlock()
|
||||
}
|
||||
} else if that.Tx != nil {
|
||||
resl, sqlErr = that.Tx.Exec(query, processedArgs...)
|
||||
} else {
|
||||
resl, e = that.DB.Exec(query, processedArgs...)
|
||||
resl, sqlErr = that.DB.Exec(query, processedArgs...)
|
||||
}
|
||||
|
||||
that.LastErr.SetError(e)
|
||||
// 判断是否连接断开了,如果还没重试过,尝试重试一次
|
||||
if e != nil {
|
||||
if sqlErr != nil {
|
||||
if that.testTx != nil {
|
||||
if that.txFailed != nil {
|
||||
*that.txFailed = true
|
||||
}
|
||||
return resl, sqlErr
|
||||
}
|
||||
if that.Tx != nil {
|
||||
if that.txFailed != nil {
|
||||
*that.txFailed = true
|
||||
}
|
||||
return resl, sqlErr
|
||||
}
|
||||
if !retried {
|
||||
if pingErr := that.DB.Ping(); pingErr == nil {
|
||||
sqlErr = nil
|
||||
return that.execWithRetry(query, true, args...)
|
||||
}
|
||||
}
|
||||
return resl, that.LastErr
|
||||
return resl, sqlErr
|
||||
}
|
||||
|
||||
return resl, that.LastErr
|
||||
return resl, sqlErr
|
||||
}
|
||||
|
||||
// logSQL 统一 SQL 日志输出(仅此一处打印,消除重复)
|
||||
func (that *HoTimeDB) logSQL(query string, args []interface{}, err error) {
|
||||
if err != nil {
|
||||
msg := fmt.Sprintf("SQL: %s DATA: %v ERROR: %v", query, args, err)
|
||||
that.testLogBufMu.Lock()
|
||||
if that.testLogBuf != nil {
|
||||
that.testLogBuf.WriteString(msg + "\n")
|
||||
atomic.AddInt32(&that.testSQLErrCount, 1)
|
||||
} else if that.Log != nil {
|
||||
that.Log.Error().Msg(msg)
|
||||
}
|
||||
that.testLogBufMu.Unlock()
|
||||
return
|
||||
}
|
||||
// 无错误时只在 logLevel > 0 时打印 DEBUG 级别
|
||||
if that.Log != nil && that.Log.GetLevel() > 0 {
|
||||
msg := fmt.Sprintf("SQL: %s DATA: %v", query, args)
|
||||
that.testLogBufMu.Lock()
|
||||
if that.testLogBuf != nil {
|
||||
that.testLogBuf.WriteString(msg + "\n")
|
||||
} else {
|
||||
that.Log.Debug().Msg(msg)
|
||||
}
|
||||
that.testLogBufMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// processArgs 处理参数中的 slice 类型
|
||||
@@ -358,34 +412,204 @@ func (that *HoTimeDB) expandArrayPlaceholder(query string, args []interface{}) (
|
||||
return result.String(), newArgs
|
||||
}
|
||||
|
||||
const (
|
||||
dbTypeUnknown = iota
|
||||
dbTypeInteger
|
||||
dbTypeFloat
|
||||
dbTypeDecimal
|
||||
)
|
||||
|
||||
// classifyDBType 将数据库类型名分类,覆盖 MySQL/SQLite/PostgreSQL
|
||||
func classifyDBType(typeName string) int {
|
||||
t := strings.ToUpper(strings.TrimSpace(typeName))
|
||||
t = strings.TrimSuffix(strings.TrimSuffix(t, " UNSIGNED"), " SIGNED")
|
||||
t = strings.TrimSpace(t)
|
||||
base := t
|
||||
if idx := strings.IndexByte(t, '('); idx > 0 {
|
||||
base = t[:idx]
|
||||
}
|
||||
switch base {
|
||||
case "INT", "INTEGER", "BIGINT", "TINYINT", "SMALLINT", "MEDIUMINT",
|
||||
"INT2", "INT4", "INT8",
|
||||
"SERIAL", "BIGSERIAL", "SMALLSERIAL",
|
||||
"YEAR", "BOOL", "BOOLEAN", "OID":
|
||||
return dbTypeInteger
|
||||
case "DECIMAL", "NUMERIC", "NEWDECIMAL", "NUMBER":
|
||||
return dbTypeDecimal
|
||||
case "FLOAT", "DOUBLE", "REAL", "FLOAT4", "FLOAT8", "DOUBLE PRECISION":
|
||||
return dbTypeFloat
|
||||
}
|
||||
if strings.Contains(base, "INT") {
|
||||
return dbTypeInteger
|
||||
}
|
||||
if strings.Contains(base, "DECIMAL") || strings.Contains(base, "NUMERIC") {
|
||||
return dbTypeDecimal
|
||||
}
|
||||
if strings.Contains(base, "DOUBLE") || strings.Contains(base, "FLOAT") {
|
||||
return dbTypeFloat
|
||||
}
|
||||
return dbTypeUnknown
|
||||
}
|
||||
|
||||
// getDecimalScale 提取 DECIMAL/NUMERIC 精度,失败返回 -1
|
||||
func getDecimalScale(colType *sql.ColumnType, typeName string) int {
|
||||
_, scale, ok := colType.DecimalSize()
|
||||
if ok && scale >= 0 {
|
||||
return int(scale)
|
||||
}
|
||||
// SQLite 不支持 DecimalSize,从类型名解析 "DECIMAL(10,2)" → 2
|
||||
upper := strings.ToUpper(typeName)
|
||||
if idx := strings.Index(upper, ","); idx > 0 {
|
||||
rest := upper[idx+1:]
|
||||
if end := strings.IndexByte(rest, ')'); end > 0 {
|
||||
if s, err := strconv.Atoi(strings.TrimSpace(rest[:end])); err == nil {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// getFloatScale 提取 FLOAT/DOUBLE 显式精度,无显式精度返回 -1 表示不截断
|
||||
func getFloatScale(colType *sql.ColumnType, typeName string) int {
|
||||
_, scale, ok := colType.DecimalSize()
|
||||
if ok && scale > 0 && scale < 20 {
|
||||
return int(scale)
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// fixFloatValue 对 float64 值做类型感知精度修正
|
||||
func fixFloatValue(f float64, category int, scale int) interface{} {
|
||||
if math.IsNaN(f) || math.IsInf(f, 0) {
|
||||
return float64(0)
|
||||
}
|
||||
switch category {
|
||||
case dbTypeInteger:
|
||||
return int64(math.Round(f))
|
||||
case dbTypeDecimal:
|
||||
return roundFloat(f, scale)
|
||||
case dbTypeFloat:
|
||||
return roundFloat(f, scale)
|
||||
default:
|
||||
return f
|
||||
}
|
||||
}
|
||||
|
||||
// roundFloat 使用 strconv 精确舍入,避免 f*pow 中间值产生精度误差
|
||||
func roundFloat(f float64, scale int) interface{} {
|
||||
if math.IsNaN(f) || math.IsInf(f, 0) {
|
||||
return float64(0)
|
||||
}
|
||||
if scale == 0 {
|
||||
// DECIMAL(10,0) 明确是整数
|
||||
return math.Round(f)
|
||||
}
|
||||
if scale < 0 {
|
||||
// 精度未知,不修正,原样返回
|
||||
return f
|
||||
}
|
||||
s := strconv.FormatFloat(f, 'f', scale, 64)
|
||||
v, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return f
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// convertBytes 对 []byte 类型的列做类型感知解析
|
||||
// MySQL 文本协议和 PG 的 DECIMAL/NUMERIC 以 []byte 返回,需要按列类型解析为正确 Go 类型
|
||||
func convertBytes(raw []byte, category int, scale int) interface{} {
|
||||
s := string(raw)
|
||||
switch category {
|
||||
case dbTypeInteger:
|
||||
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
return n
|
||||
}
|
||||
if f, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
return int64(math.Round(f))
|
||||
}
|
||||
return s
|
||||
case dbTypeDecimal:
|
||||
if f, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
return roundFloat(f, scale)
|
||||
}
|
||||
return s
|
||||
case dbTypeFloat:
|
||||
if f, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
return roundFloat(f, scale)
|
||||
}
|
||||
return s
|
||||
default:
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
// Row 数据库数据解析
|
||||
func (that *HoTimeDB) Row(resl *sql.Rows) []Map {
|
||||
defer resl.Close()
|
||||
|
||||
dest := make([]Map, 0)
|
||||
strs, _ := resl.Columns()
|
||||
colTypes, _ := resl.ColumnTypes()
|
||||
|
||||
for i := 0; resl.Next(); i++ {
|
||||
lis := make(Map, 0)
|
||||
a := make([]interface{}, len(strs))
|
||||
colCount := len(strs)
|
||||
categories := make([]int, colCount)
|
||||
scales := make([]int, colCount)
|
||||
if colTypes != nil {
|
||||
for j := 0; j < len(colTypes) && j < colCount; j++ {
|
||||
typeName := colTypes[j].DatabaseTypeName()
|
||||
categories[j] = classifyDBType(typeName)
|
||||
switch categories[j] {
|
||||
case dbTypeDecimal:
|
||||
scales[j] = getDecimalScale(colTypes[j], typeName)
|
||||
case dbTypeFloat:
|
||||
scales[j] = getFloatScale(colTypes[j], typeName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
b := make([]interface{}, len(a))
|
||||
for j := 0; j < len(a); j++ {
|
||||
for resl.Next() {
|
||||
lis := make(Map, colCount)
|
||||
a := make([]interface{}, colCount)
|
||||
b := make([]interface{}, colCount)
|
||||
for j := 0; j < colCount; j++ {
|
||||
b[j] = &a[j]
|
||||
}
|
||||
err := resl.Scan(b...)
|
||||
if err != nil {
|
||||
that.LastErr.SetError(err)
|
||||
if err := resl.Scan(b...); err != nil {
|
||||
dbErr := &DBError{}
|
||||
dbErr.SetError(err)
|
||||
that.lastError.Store(dbErr)
|
||||
return nil
|
||||
}
|
||||
for j := 0; j < len(a); j++ {
|
||||
if a[j] != nil && reflect.ValueOf(a[j]).Type().String() == "[]uint8" {
|
||||
lis[strs[j]] = string(a[j].([]byte))
|
||||
} else {
|
||||
lis[strs[j]] = a[j] // 取实际类型
|
||||
for j := 0; j < colCount; j++ {
|
||||
colKey := strs[j]
|
||||
val := a[j]
|
||||
if val == nil {
|
||||
lis[colKey] = nil
|
||||
continue
|
||||
}
|
||||
switch v := val.(type) {
|
||||
case []byte:
|
||||
lis[colKey] = convertBytes(v, categories[j], scales[j])
|
||||
case float64:
|
||||
lis[colKey] = fixFloatValue(v, categories[j], scales[j])
|
||||
case float32:
|
||||
lis[colKey] = fixFloatValue(float64(v), categories[j], scales[j])
|
||||
case time.Time:
|
||||
// DM8驱动直接返回time.Time,格式化为MySQL兼容字符串
|
||||
lis[colKey] = v.Format("2006-01-02 15:04:05")
|
||||
default:
|
||||
lis[colKey] = val
|
||||
}
|
||||
}
|
||||
|
||||
dest = append(dest, lis)
|
||||
}
|
||||
|
||||
if err := resl.Err(); err != nil {
|
||||
dbErr := &DBError{}
|
||||
dbErr.SetError(err)
|
||||
that.lastError.Store(dbErr)
|
||||
}
|
||||
return dest
|
||||
}
|
||||
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// classifyDBType
|
||||
// ─────────────────────────────────────────────
|
||||
func TestClassifyDBType(t *testing.T) {
|
||||
cases := []struct {
|
||||
typeName string
|
||||
want int
|
||||
}{
|
||||
// MySQL 整数类型
|
||||
{"INT", dbTypeInteger},
|
||||
{"TINYINT", dbTypeInteger},
|
||||
{"SMALLINT", dbTypeInteger},
|
||||
{"MEDIUMINT", dbTypeInteger},
|
||||
{"BIGINT", dbTypeInteger},
|
||||
{"INT UNSIGNED", dbTypeInteger},
|
||||
{"BIGINT UNSIGNED", dbTypeInteger},
|
||||
{"TINYINT(1)", dbTypeInteger},
|
||||
{"YEAR", dbTypeInteger},
|
||||
{"BOOL", dbTypeInteger},
|
||||
{"BOOLEAN", dbTypeInteger},
|
||||
|
||||
// PostgreSQL 整数类型
|
||||
{"INT2", dbTypeInteger},
|
||||
{"INT4", dbTypeInteger},
|
||||
{"INT8", dbTypeInteger},
|
||||
{"SERIAL", dbTypeInteger},
|
||||
{"BIGSERIAL", dbTypeInteger},
|
||||
{"SMALLSERIAL", dbTypeInteger},
|
||||
{"OID", dbTypeInteger},
|
||||
|
||||
// DECIMAL / NUMERIC
|
||||
{"DECIMAL", dbTypeDecimal},
|
||||
{"DECIMAL(10,2)", dbTypeDecimal},
|
||||
{"NUMERIC", dbTypeDecimal},
|
||||
{"NUMERIC(8,4)", dbTypeDecimal},
|
||||
{"NEWDECIMAL", dbTypeDecimal},
|
||||
|
||||
// FLOAT / DOUBLE
|
||||
{"FLOAT", dbTypeFloat},
|
||||
{"DOUBLE", dbTypeFloat},
|
||||
{"REAL", dbTypeFloat},
|
||||
{"FLOAT4", dbTypeFloat},
|
||||
{"FLOAT8", dbTypeFloat},
|
||||
{"DOUBLE PRECISION", dbTypeFloat},
|
||||
|
||||
// 未知类型
|
||||
{"VARCHAR", dbTypeUnknown},
|
||||
{"TEXT", dbTypeUnknown},
|
||||
{"DATETIME", dbTypeUnknown},
|
||||
{"TIMESTAMP", dbTypeUnknown},
|
||||
{"BLOB", dbTypeUnknown},
|
||||
{"", dbTypeUnknown},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.typeName, func(t *testing.T) {
|
||||
got := classifyDBType(c.typeName)
|
||||
if got != c.want {
|
||||
t.Errorf("classifyDBType(%q) = %d, want %d", c.typeName, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// fixFloatValue
|
||||
// ─────────────────────────────────────────────
|
||||
func TestFixFloatValue(t *testing.T) {
|
||||
t.Run("integer category: round to int64", func(t *testing.T) {
|
||||
// 这是核心场景:float64 精度噪声导致整数变为 1.9999...
|
||||
got := fixFloatValue(1.9999999999999998, dbTypeInteger, 0)
|
||||
if got != int64(2) {
|
||||
t.Errorf("got %v (%T), want int64(2)", got, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("integer category: 2.0000000000001 rounds to 2", func(t *testing.T) {
|
||||
got := fixFloatValue(2.0000000000001, dbTypeInteger, 0)
|
||||
if got != int64(2) {
|
||||
t.Errorf("got %v (%T), want int64(2)", got, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decimal category: scale=2 roundtrip precision", func(t *testing.T) {
|
||||
got := fixFloatValue(1.1999999999999999, dbTypeDecimal, 2)
|
||||
f, ok := got.(float64)
|
||||
if !ok {
|
||||
t.Fatalf("expected float64, got %T", got)
|
||||
}
|
||||
if f != 1.2 {
|
||||
t.Errorf("got %v, want 1.2", f)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decimal category: scale=2, 1.20000000001 -> 1.2", func(t *testing.T) {
|
||||
got := fixFloatValue(1.20000000001, dbTypeDecimal, 2)
|
||||
f := got.(float64)
|
||||
if f != 1.2 {
|
||||
t.Errorf("got %v, want 1.2", f)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decimal category: scale=-1 (unknown), no modification", func(t *testing.T) {
|
||||
got := fixFloatValue(1.23456789, dbTypeDecimal, -1)
|
||||
if got != 1.23456789 {
|
||||
t.Errorf("got %v, want 1.23456789", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decimal category: scale=0, round to whole number", func(t *testing.T) {
|
||||
got := fixFloatValue(2.7, dbTypeDecimal, 0)
|
||||
if got != float64(3) {
|
||||
t.Errorf("got %v (%T), want float64(3)", got, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("float category: scale=-1 no modification", func(t *testing.T) {
|
||||
got := fixFloatValue(3.14159265, dbTypeFloat, -1)
|
||||
if got != 3.14159265 {
|
||||
t.Errorf("got %v, want 3.14159265", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown category: passthrough", func(t *testing.T) {
|
||||
got := fixFloatValue(99.9, dbTypeUnknown, -1)
|
||||
if got != 99.9 {
|
||||
t.Errorf("got %v, want 99.9", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NaN -> 0", func(t *testing.T) {
|
||||
got := fixFloatValue(math.NaN(), dbTypeInteger, 0)
|
||||
if got != float64(0) {
|
||||
t.Errorf("got %v, want 0", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("+Inf -> 0", func(t *testing.T) {
|
||||
got := fixFloatValue(math.Inf(1), dbTypeDecimal, 2)
|
||||
if got != float64(0) {
|
||||
t.Errorf("got %v, want 0", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// roundFloat
|
||||
// ─────────────────────────────────────────────
|
||||
func TestRoundFloat(t *testing.T) {
|
||||
t.Run("scale=2: 1.19999... -> 1.2", func(t *testing.T) {
|
||||
got := roundFloat(1.1999999999999999, 2)
|
||||
if got != float64(1.2) {
|
||||
t.Errorf("got %v, want 1.2", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("scale=2: 1.20000...1 -> 1.2", func(t *testing.T) {
|
||||
got := roundFloat(1.20000000001, 2)
|
||||
if got != float64(1.2) {
|
||||
t.Errorf("got %v, want 1.2", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("scale=0: round to integer float", func(t *testing.T) {
|
||||
got := roundFloat(2.5, 0)
|
||||
if got != float64(3) {
|
||||
t.Errorf("got %v, want 3.0", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("scale=-1: passthrough unchanged", func(t *testing.T) {
|
||||
got := roundFloat(1.23456789, -1)
|
||||
if got != 1.23456789 {
|
||||
t.Errorf("got %v, want 1.23456789", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NaN -> 0", func(t *testing.T) {
|
||||
got := roundFloat(math.NaN(), 2)
|
||||
if got != float64(0) {
|
||||
t.Errorf("got %v, want 0", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// convertBytes
|
||||
// ─────────────────────────────────────────────
|
||||
func TestConvertBytes(t *testing.T) {
|
||||
t.Run("integer category: '2' -> int64(2)", func(t *testing.T) {
|
||||
got := convertBytes([]byte("2"), dbTypeInteger, 0)
|
||||
if got != int64(2) {
|
||||
t.Errorf("got %v (%T), want int64(2)", got, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("integer category: '2.00' -> int64(2)", func(t *testing.T) {
|
||||
got := convertBytes([]byte("2.00"), dbTypeInteger, 0)
|
||||
if got != int64(2) {
|
||||
t.Errorf("got %v (%T), want int64(2)", got, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decimal category: '1.20' scale=2 -> float64(1.2)", func(t *testing.T) {
|
||||
got := convertBytes([]byte("1.20"), dbTypeDecimal, 2)
|
||||
if got != float64(1.2) {
|
||||
t.Errorf("got %v (%T), want float64(1.2)", got, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decimal category: '0.00' scale=2 -> float64(0)", func(t *testing.T) {
|
||||
got := convertBytes([]byte("0.00"), dbTypeDecimal, 2)
|
||||
if got != float64(0) {
|
||||
t.Errorf("got %v (%T), want float64(0)", got, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("float category: '3.14' scale=-1 -> float64(3.14)", func(t *testing.T) {
|
||||
got := convertBytes([]byte("3.14"), dbTypeFloat, -1)
|
||||
if got != float64(3.14) {
|
||||
t.Errorf("got %v (%T), want float64(3.14)", got, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown category: passthrough as string", func(t *testing.T) {
|
||||
got := convertBytes([]byte("hello"), dbTypeUnknown, 0)
|
||||
if got != "hello" {
|
||||
t.Errorf("got %v (%T), want 'hello'", got, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("integer category: invalid bytes -> string fallback", func(t *testing.T) {
|
||||
got := convertBytes([]byte("abc"), dbTypeInteger, 0)
|
||||
if got != "abc" {
|
||||
t.Errorf("got %v (%T), want 'abc'", got, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
+59
-8
@@ -1,12 +1,20 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
var savepointCounter uint64
|
||||
|
||||
// Action 事务操作
|
||||
// 如果 action 返回 true 则提交事务;返回 false 则回滚
|
||||
// 测试模式下(testTx != nil),使用 SAVEPOINT 代替真事务,确保嵌套事务不会绕过外层测试事务
|
||||
func (that *HoTimeDB) Action(action func(db HoTimeDB) (isSuccess bool)) (isSuccess bool) {
|
||||
that.testLogBufMu.Lock()
|
||||
logBuf := that.testLogBuf
|
||||
that.testLogBufMu.Unlock()
|
||||
db := HoTimeDB{
|
||||
DB: that.DB,
|
||||
ContextBase: that.ContextBase,
|
||||
@@ -15,23 +23,57 @@ func (that *HoTimeDB) Action(action func(db HoTimeDB) (isSuccess bool)) (isSucce
|
||||
Log: that.Log,
|
||||
Type: that.Type,
|
||||
Prefix: that.Prefix,
|
||||
LastQuery: that.LastQuery,
|
||||
LastData: that.LastData,
|
||||
ConnectFunc: that.ConnectFunc,
|
||||
LastErr: that.LastErr,
|
||||
limit: that.limit,
|
||||
Tx: that.Tx,
|
||||
SlaveDB: that.SlaveDB,
|
||||
Mode: that.Mode,
|
||||
Dialect: that.Dialect,
|
||||
mu: sync.RWMutex{},
|
||||
limitMu: sync.Mutex{},
|
||||
testTx: that.testTx,
|
||||
testMu: that.testMu,
|
||||
testLogBuf: logBuf,
|
||||
}
|
||||
|
||||
txFailed := false
|
||||
db.txFailed = &txFailed
|
||||
|
||||
if that.testTx != nil {
|
||||
spName := fmt.Sprintf("sp_%d", atomic.AddUint64(&savepointCounter, 1))
|
||||
if that.testMu != nil {
|
||||
that.testMu.Lock()
|
||||
}
|
||||
_, _ = that.testTx.Exec("SAVEPOINT " + spName)
|
||||
if that.testMu != nil {
|
||||
that.testMu.Unlock()
|
||||
}
|
||||
db.Tx = that.testTx
|
||||
isSuccess = action(db)
|
||||
if txFailed || !isSuccess {
|
||||
if that.testMu != nil {
|
||||
that.testMu.Lock()
|
||||
}
|
||||
_, _ = that.testTx.Exec("ROLLBACK TO SAVEPOINT " + spName)
|
||||
if that.testMu != nil {
|
||||
that.testMu.Unlock()
|
||||
}
|
||||
return false
|
||||
}
|
||||
if that.testMu != nil {
|
||||
that.testMu.Lock()
|
||||
}
|
||||
_, _ = that.testTx.Exec("RELEASE SAVEPOINT " + spName)
|
||||
if that.testMu != nil {
|
||||
that.testMu.Unlock()
|
||||
}
|
||||
return isSuccess
|
||||
}
|
||||
|
||||
tx, err := db.Begin()
|
||||
|
||||
if err != nil {
|
||||
that.LastErr.SetError(err)
|
||||
dbErr := &DBError{}
|
||||
dbErr.SetError(err)
|
||||
that.lastError.Store(dbErr)
|
||||
return isSuccess
|
||||
}
|
||||
|
||||
@@ -39,10 +81,17 @@ func (that *HoTimeDB) Action(action func(db HoTimeDB) (isSuccess bool)) (isSucce
|
||||
|
||||
isSuccess = action(db)
|
||||
|
||||
if txFailed {
|
||||
_ = db.Tx.Rollback()
|
||||
return false
|
||||
}
|
||||
|
||||
if !isSuccess {
|
||||
err = db.Tx.Rollback()
|
||||
if err != nil {
|
||||
that.LastErr.SetError(err)
|
||||
dbErr := &DBError{}
|
||||
dbErr.SetError(err)
|
||||
that.lastError.Store(dbErr)
|
||||
return isSuccess
|
||||
}
|
||||
return isSuccess
|
||||
@@ -50,7 +99,9 @@ func (that *HoTimeDB) Action(action func(db HoTimeDB) (isSuccess bool)) (isSucce
|
||||
|
||||
err = db.Tx.Commit()
|
||||
if err != nil {
|
||||
that.LastErr.SetError(err)
|
||||
dbErr := &DBError{}
|
||||
dbErr.SetError(err)
|
||||
that.lastError.Store(dbErr)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
"code.hoteas.com/golang/hotime/log"
|
||||
"database/sql"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestDB(t *testing.T) *HoTimeDB {
|
||||
t.Helper()
|
||||
sqlDB, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db := &HoTimeDB{
|
||||
DB: sqlDB,
|
||||
Type: "sqlite3",
|
||||
Dialect: &SQLiteDialect{},
|
||||
Log: log.NewTestLogger(),
|
||||
}
|
||||
_, execErr := sqlDB.Exec("CREATE TABLE test_item (id INTEGER PRIMARY KEY, name TEXT, value INTEGER)")
|
||||
if execErr != nil {
|
||||
t.Fatal(execErr)
|
||||
}
|
||||
_, execErr = sqlDB.Exec("INSERT INTO test_item (id, name, value) VALUES (1, 'foo', 100)")
|
||||
if execErr != nil {
|
||||
t.Fatal(execErr)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func newTestDBWithTestTx(t *testing.T) *HoTimeDB {
|
||||
t.Helper()
|
||||
db := newTestDB(t)
|
||||
tx, err := db.DB.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.testTx = tx
|
||||
db.testMu = &sync.Mutex{}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestAction_TxFailedForceRollback(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
defer db.DB.Close()
|
||||
|
||||
result := db.Action(func(tx HoTimeDB) (isSuccess bool) {
|
||||
tx.Update("test_item", Map{"value": 999}, Map{"id": 1})
|
||||
|
||||
tx.Exec("THIS IS INVALID SQL THAT WILL FAIL")
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
if result != false {
|
||||
t.Fatalf("Action should return false when SQL error occurred, got true")
|
||||
}
|
||||
|
||||
row := db.Get("test_item", "value", Map{"id": 1})
|
||||
if row == nil {
|
||||
t.Fatal("failed to read test_item")
|
||||
}
|
||||
val := row.GetCeilInt64("value")
|
||||
if val != 100 {
|
||||
t.Fatalf("value should be rolled back to 100, got %d", val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAction_NormalCommit(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
defer db.DB.Close()
|
||||
|
||||
result := db.Action(func(tx HoTimeDB) (isSuccess bool) {
|
||||
tx.Update("test_item", Map{"value": 200}, Map{"id": 1})
|
||||
return true
|
||||
})
|
||||
|
||||
if result != true {
|
||||
t.Fatalf("Action should return true on success, got false")
|
||||
}
|
||||
|
||||
row := db.Get("test_item", "value", Map{"id": 1})
|
||||
if row == nil {
|
||||
t.Fatal("failed to read test_item")
|
||||
}
|
||||
val := row.GetCeilInt64("value")
|
||||
if val != 200 {
|
||||
t.Fatalf("value should be committed to 200, got %d", val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAction_NormalRollback(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
defer db.DB.Close()
|
||||
|
||||
result := db.Action(func(tx HoTimeDB) (isSuccess bool) {
|
||||
tx.Update("test_item", Map{"value": 300}, Map{"id": 1})
|
||||
return false
|
||||
})
|
||||
|
||||
if result != false {
|
||||
t.Fatalf("Action should return false, got true")
|
||||
}
|
||||
|
||||
row := db.Get("test_item", "value", Map{"id": 1})
|
||||
if row == nil {
|
||||
t.Fatal("failed to read test_item")
|
||||
}
|
||||
val := row.GetCeilInt64("value")
|
||||
if val != 100 {
|
||||
t.Fatalf("value should be rolled back to 100, got %d", val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAction_SqlErrorThenReturnTrue_MustRollback(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
defer db.DB.Close()
|
||||
|
||||
result := db.Action(func(tx HoTimeDB) (isSuccess bool) {
|
||||
tx.Update("test_item", Map{"value": 500}, Map{"id": 1})
|
||||
|
||||
tx.Exec("INSERT INTO nonexistent_table (x) VALUES (1)")
|
||||
|
||||
tx.Update("test_item", Map{"value": 600}, Map{"id": 1})
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
if result != false {
|
||||
t.Fatalf("Action should return false when SQL error occurred mid-transaction, got true")
|
||||
}
|
||||
|
||||
row := db.Get("test_item", "value", Map{"id": 1})
|
||||
if row == nil {
|
||||
t.Fatal("failed to read test_item")
|
||||
}
|
||||
val := row.GetCeilInt64("value")
|
||||
if val != 100 {
|
||||
t.Fatalf("value should be rolled back to 100 (original), got %d", val)
|
||||
}
|
||||
}
|
||||
|
||||
// --- testTx (SAVEPOINT) 模式测试 ---
|
||||
|
||||
func TestAction_TestTx_SqlErrorForceRollbackSavepoint(t *testing.T) {
|
||||
db := newTestDBWithTestTx(t)
|
||||
defer db.testTx.Rollback()
|
||||
defer db.DB.Close()
|
||||
|
||||
db.testTx.Exec("UPDATE test_item SET value = 100 WHERE id = 1")
|
||||
|
||||
result := db.Action(func(tx HoTimeDB) (isSuccess bool) {
|
||||
tx.Update("test_item", Map{"value": 888}, Map{"id": 1})
|
||||
|
||||
tx.Exec("THIS IS INVALID SQL")
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
if result != false {
|
||||
t.Fatalf("Action (testTx mode) should return false when SQL error occurred, got true")
|
||||
}
|
||||
|
||||
db.testMu.Lock()
|
||||
rows, err := db.testTx.Query("SELECT value FROM test_item WHERE id = 1")
|
||||
db.testMu.Unlock()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
t.Fatal("no row found")
|
||||
}
|
||||
var val int64
|
||||
rows.Scan(&val)
|
||||
if val != 100 {
|
||||
t.Fatalf("value should be rolled back to 100 via SAVEPOINT, got %d", val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAction_TestTx_NormalCommitSavepoint(t *testing.T) {
|
||||
db := newTestDBWithTestTx(t)
|
||||
defer db.testTx.Rollback()
|
||||
defer db.DB.Close()
|
||||
|
||||
result := db.Action(func(tx HoTimeDB) (isSuccess bool) {
|
||||
tx.Update("test_item", Map{"value": 777}, Map{"id": 1})
|
||||
return true
|
||||
})
|
||||
|
||||
if result != true {
|
||||
t.Fatalf("Action (testTx mode) should return true on success, got false")
|
||||
}
|
||||
|
||||
db.testMu.Lock()
|
||||
rows, err := db.testTx.Query("SELECT value FROM test_item WHERE id = 1")
|
||||
db.testMu.Unlock()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
t.Fatal("no row found")
|
||||
}
|
||||
var val int64
|
||||
rows.Scan(&val)
|
||||
if val != 777 {
|
||||
t.Fatalf("value should be 777 after RELEASE SAVEPOINT, got %d", val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAction_TestTx_NormalRollbackSavepoint(t *testing.T) {
|
||||
db := newTestDBWithTestTx(t)
|
||||
defer db.testTx.Rollback()
|
||||
defer db.DB.Close()
|
||||
|
||||
db.testTx.Exec("UPDATE test_item SET value = 100 WHERE id = 1")
|
||||
|
||||
result := db.Action(func(tx HoTimeDB) (isSuccess bool) {
|
||||
tx.Update("test_item", Map{"value": 666}, Map{"id": 1})
|
||||
return false
|
||||
})
|
||||
|
||||
if result != false {
|
||||
t.Fatalf("Action (testTx mode) should return false, got true")
|
||||
}
|
||||
|
||||
db.testMu.Lock()
|
||||
rows, err := db.testTx.Query("SELECT value FROM test_item WHERE id = 1")
|
||||
db.testMu.Unlock()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
t.Fatal("no row found")
|
||||
}
|
||||
var val int64
|
||||
rows.Scan(&val)
|
||||
if val != 100 {
|
||||
t.Fatalf("value should be rolled back to 100 via SAVEPOINT, got %d", val)
|
||||
}
|
||||
}
|
||||
+27
-27
@@ -244,39 +244,12 @@ func (that *HoTimeDB) varCond(k string, v interface{}) (string, []interface{}) {
|
||||
k = strings.Replace(k, "[#]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where += " " + k + "=" + ObjToStr(v) + " "
|
||||
case "[##]": // 直接添加value到sql,需要考虑防注入
|
||||
where += " " + ObjToStr(v)
|
||||
case "[#!]":
|
||||
k = strings.Replace(k, "[#!]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where += " " + k + "!=" + ObjToStr(v) + " "
|
||||
case "[!#]":
|
||||
k = strings.Replace(k, "[!#]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where += " " + k + "!=" + ObjToStr(v) + " "
|
||||
case "[~]":
|
||||
k = strings.Replace(k, "[~]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where += k + " LIKE ? "
|
||||
v = "%" + ObjToStr(v) + "%"
|
||||
res = append(res, v)
|
||||
case "[!~]": // 左边任意
|
||||
k = strings.Replace(k, "[!~]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where += k + " LIKE ? "
|
||||
v = "%" + ObjToStr(v) + ""
|
||||
res = append(res, v)
|
||||
case "[~!]": // 右边任意
|
||||
k = strings.Replace(k, "[~!]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where += k + " LIKE ? "
|
||||
v = ObjToStr(v) + "%"
|
||||
res = append(res, v)
|
||||
case "[~~]": // 手动任意
|
||||
k = strings.Replace(k, "[~~]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where += k + " LIKE ? "
|
||||
res = append(res, v)
|
||||
default:
|
||||
def = true
|
||||
}
|
||||
@@ -307,6 +280,33 @@ func (that *HoTimeDB) varCond(k string, v interface{}) (string, []interface{}) {
|
||||
vs := ObjToSlice(v)
|
||||
res = append(res, vs[0])
|
||||
res = append(res, vs[1])
|
||||
case "[##]":
|
||||
where += " " + ObjToStr(v)
|
||||
case "[#!]":
|
||||
k = strings.Replace(k, "[#!]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where += " " + k + "!=" + ObjToStr(v) + " "
|
||||
case "[!#]":
|
||||
k = strings.Replace(k, "[!#]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where += " " + k + "!=" + ObjToStr(v) + " "
|
||||
case "[!~]": // 左边任意
|
||||
k = strings.Replace(k, "[!~]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where += k + " LIKE ? "
|
||||
v = "%" + ObjToStr(v) + ""
|
||||
res = append(res, v)
|
||||
case "[~!]": // 右边任意
|
||||
k = strings.Replace(k, "[~!]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where += k + " LIKE ? "
|
||||
v = ObjToStr(v) + "%"
|
||||
res = append(res, v)
|
||||
case "[~~]": // 手动任意
|
||||
k = strings.Replace(k, "[~~]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where += k + " LIKE ? "
|
||||
res = append(res, v)
|
||||
default:
|
||||
where, res = that.handleDefaultCondition(k, v, where, res)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,468 +0,0 @@
|
||||
# HoTime 代码生成器使用说明
|
||||
|
||||
`code` 包提供了 HoTime 框架的自动代码生成功能,能够根据数据库表结构自动生成 CRUD 接口代码和配置文件。
|
||||
|
||||
## 目录
|
||||
|
||||
- [功能概述](#功能概述)
|
||||
- [配置说明](#配置说明)
|
||||
- [使用方法](#使用方法)
|
||||
- [生成规则](#生成规则)
|
||||
- [自定义规则](#自定义规则)
|
||||
- [生成的代码结构](#生成的代码结构)
|
||||
|
||||
---
|
||||
|
||||
## 功能概述
|
||||
|
||||
代码生成器可以:
|
||||
|
||||
1. **自动读取数据库表结构** - 支持 MySQL 和 SQLite
|
||||
2. **生成 CRUD 接口** - 增删改查、搜索、分页
|
||||
3. **生成配置文件** - 表字段配置、菜单配置、权限配置
|
||||
4. **智能字段识别** - 根据字段名自动识别类型和权限
|
||||
5. **支持表关联** - 自动识别外键关系
|
||||
|
||||
---
|
||||
|
||||
## 配置说明
|
||||
|
||||
在 `config.json` 中配置代码生成:
|
||||
|
||||
```json
|
||||
{
|
||||
"codeConfig": [
|
||||
{
|
||||
"table": "admin",
|
||||
"config": "config/admin.json",
|
||||
"configDB": "config/adminDB.json",
|
||||
"rule": "config/rule.json",
|
||||
"name": "",
|
||||
"mode": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 配置项说明
|
||||
|
||||
| 配置项 | 必须 | 说明 |
|
||||
|--------|------|------|
|
||||
| `table` | ✅ | 用户表名,用于权限控制的基准表 |
|
||||
| `config` | ✅ | 接口描述配置文件路径 |
|
||||
| `configDB` | ❌ | 数据库结构配置输出路径,有则每次自动生成 |
|
||||
| `rule` | ❌ | 字段规则配置文件,无则使用默认规则 |
|
||||
| `name` | ❌ | 生成代码的包名和目录名,空则使用内嵌模式 |
|
||||
| `mode` | ❌ | 0=内嵌代码模式,1=生成代码模式 |
|
||||
|
||||
### 运行模式
|
||||
|
||||
- **mode=0(内嵌模式)**:不生成独立代码文件,使用框架内置的通用控制器
|
||||
- **mode=1(生成模式)**:为每张表生成独立的 Go 控制器文件
|
||||
|
||||
---
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 1. 基础配置
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": 2,
|
||||
"codeConfig": [
|
||||
{
|
||||
"table": "admin",
|
||||
"config": "config/admin.json",
|
||||
"rule": "config/rule.json",
|
||||
"mode": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 启动应用
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
func main() {
|
||||
app := Init("config/config.json")
|
||||
|
||||
// 代码生成器在 Init 时自动执行
|
||||
// 会读取数据库结构并生成配置
|
||||
|
||||
app.Run(Router{
|
||||
// 路由配置
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 开发模式
|
||||
|
||||
在 `config.json` 中设置 `"mode": 2`(开发模式)时:
|
||||
|
||||
- 自动读取数据库表结构
|
||||
- 自动生成/更新配置文件
|
||||
- 自动生成代码(如果 codeConfig.mode=1)
|
||||
|
||||
---
|
||||
|
||||
## 生成规则
|
||||
|
||||
### 默认字段规则
|
||||
|
||||
代码生成器内置了一套默认的字段识别规则:
|
||||
|
||||
| 字段名 | 列表显示 | 新增 | 编辑 | 详情 | 类型 |
|
||||
|--------|----------|------|------|------|------|
|
||||
| `id` | ✅ | ❌ | ❌ | ✅ | number |
|
||||
| `name` | ✅ | ✅ | ✅ | ✅ | text |
|
||||
| `status` | ✅ | ✅ | ✅ | ✅ | select |
|
||||
| `create_time` | ❌ | ❌ | ❌ | ✅ | time |
|
||||
| `modify_time` | ✅ | ❌ | ❌ | ✅ | time |
|
||||
| `password` | ❌ | ✅ | ✅ | ❌ | password |
|
||||
| `image/img/avatar` | ❌ | ✅ | ✅ | ✅ | image |
|
||||
| `file` | ❌ | ✅ | ✅ | ✅ | file |
|
||||
| `content/info` | ❌ | ✅ | ✅ | ✅ | textArea |
|
||||
| `parent_id` | ✅ | ✅ | ✅ | ✅ | number |
|
||||
| `parent_ids/index` | ❌ | ❌ | ❌ | ❌ | index |
|
||||
| `delete` | ❌ | ❌ | ❌ | ❌ | - |
|
||||
|
||||
### 数据类型映射
|
||||
|
||||
数据库字段类型自动映射:
|
||||
|
||||
| 数据库类型 | 生成类型 |
|
||||
|------------|----------|
|
||||
| `int`, `integer`, `float`, `double`, `decimal` | number |
|
||||
| `char`, `varchar`, `text`, `blob` | text |
|
||||
| `date`, `datetime`, `time`, `timestamp`, `year` | time |
|
||||
|
||||
### 字段备注解析
|
||||
|
||||
支持从数据库字段备注中提取信息:
|
||||
|
||||
```sql
|
||||
-- 字段备注格式: 标签名:选项1-名称1,选项2-名称2 {提示信息}
|
||||
-- 例如:
|
||||
status TINYINT COMMENT '状态:0-禁用,1-启用 {用户账号状态}'
|
||||
```
|
||||
|
||||
生成的配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "status",
|
||||
"label": "状态",
|
||||
"type": "select",
|
||||
"ps": "用户账号状态",
|
||||
"options": [
|
||||
{"name": "禁用", "value": "0"},
|
||||
{"name": "启用", "value": "1"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 自定义规则
|
||||
|
||||
### rule.json 配置
|
||||
|
||||
创建 `config/rule.json` 自定义字段规则:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "id",
|
||||
"list": true,
|
||||
"add": false,
|
||||
"edit": false,
|
||||
"info": true,
|
||||
"must": false,
|
||||
"strict": true,
|
||||
"type": ""
|
||||
},
|
||||
{
|
||||
"name": "status",
|
||||
"list": true,
|
||||
"add": true,
|
||||
"edit": true,
|
||||
"info": true,
|
||||
"must": false,
|
||||
"strict": false,
|
||||
"type": "select"
|
||||
},
|
||||
{
|
||||
"name": "user.special_field",
|
||||
"list": true,
|
||||
"add": true,
|
||||
"edit": true,
|
||||
"info": true,
|
||||
"type": "text",
|
||||
"strict": true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 规则字段说明
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `name` | 字段名,支持 `表名.字段名` 格式精确匹配 |
|
||||
| `list` | 是否在列表中显示 |
|
||||
| `add` | 是否在新增表单中显示 |
|
||||
| `edit` | 是否在编辑表单中显示 |
|
||||
| `info` | 是否在详情中显示 |
|
||||
| `must` | 是否必填 |
|
||||
| `strict` | 是否严格匹配字段名(false 则模糊匹配) |
|
||||
| `type` | 字段类型(覆盖自动识别) |
|
||||
|
||||
### 字段类型
|
||||
|
||||
| 类型 | 说明 |
|
||||
|------|------|
|
||||
| `text` | 普通文本输入 |
|
||||
| `textArea` | 多行文本 |
|
||||
| `number` | 数字输入 |
|
||||
| `select` | 下拉选择 |
|
||||
| `time` | 时间选择器 |
|
||||
| `unixTime` | Unix 时间戳 |
|
||||
| `image` | 图片上传 |
|
||||
| `file` | 文件上传 |
|
||||
| `password` | 密码输入 |
|
||||
| `money` | 金额(带格式化) |
|
||||
| `index` | 索引字段(不显示) |
|
||||
| `tree` | 树形选择 |
|
||||
| `form` | 表单配置 |
|
||||
| `auth` | 权限配置 |
|
||||
|
||||
---
|
||||
|
||||
## 生成的代码结构
|
||||
|
||||
### 内嵌模式 (mode=0)
|
||||
|
||||
不生成代码文件,使用框架内置控制器,只生成配置文件:
|
||||
|
||||
```
|
||||
config/
|
||||
├── admin.json # 接口配置
|
||||
├── adminDB.json # 数据库结构配置(可选)
|
||||
└── rule.json # 字段规则
|
||||
```
|
||||
|
||||
### 生成模式 (mode=1)
|
||||
|
||||
生成独立的控制器代码:
|
||||
|
||||
```
|
||||
admin/ # 生成的包目录
|
||||
├── init.go # 包初始化和路由注册
|
||||
├── user.go # user 表控制器
|
||||
├── role.go # role 表控制器
|
||||
└── ... # 其他表控制器
|
||||
```
|
||||
|
||||
### 生成的控制器结构
|
||||
|
||||
```go
|
||||
package admin
|
||||
|
||||
var userCtr = Ctr{
|
||||
"info": func(that *Context) {
|
||||
// 查询单条记录
|
||||
},
|
||||
"add": func(that *Context) {
|
||||
// 新增记录
|
||||
},
|
||||
"update": func(that *Context) {
|
||||
// 更新记录
|
||||
},
|
||||
"remove": func(that *Context) {
|
||||
// 删除记录
|
||||
},
|
||||
"search": func(that *Context) {
|
||||
// 搜索列表(分页)
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 配置文件结构
|
||||
|
||||
### admin.json 示例
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "admin",
|
||||
"label": "管理平台",
|
||||
"menus": [
|
||||
{
|
||||
"label": "系统管理",
|
||||
"name": "sys",
|
||||
"icon": "Setting",
|
||||
"menus": [
|
||||
{
|
||||
"label": "用户管理",
|
||||
"table": "user",
|
||||
"auth": ["show", "add", "delete", "edit", "info", "download"]
|
||||
},
|
||||
{
|
||||
"label": "角色管理",
|
||||
"table": "role",
|
||||
"auth": ["show", "add", "delete", "edit", "info"]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"tables": {
|
||||
"user": {
|
||||
"label": "用户",
|
||||
"table": "user",
|
||||
"auth": ["show", "add", "delete", "edit", "info", "download"],
|
||||
"columns": [
|
||||
{"name": "id", "type": "number", "label": "ID"},
|
||||
{"name": "name", "type": "text", "label": "用户名"},
|
||||
{"name": "status", "type": "select", "label": "状态",
|
||||
"options": [{"name": "禁用", "value": "0"}, {"name": "启用", "value": "1"}]}
|
||||
],
|
||||
"search": [
|
||||
{"type": "search", "name": "keyword", "label": "请输入关键词"},
|
||||
{"type": "search", "name": "daterange", "label": "时间段"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 外键关联
|
||||
|
||||
### 自动识别
|
||||
|
||||
代码生成器会自动识别 `_id` 结尾的字段作为外键:
|
||||
|
||||
```sql
|
||||
-- user 表
|
||||
CREATE TABLE user (
|
||||
id INT PRIMARY KEY,
|
||||
name VARCHAR(50),
|
||||
role_id INT, -- 自动关联 role 表
|
||||
org_id INT -- 自动关联 org 表
|
||||
);
|
||||
```
|
||||
|
||||
生成的配置会包含 `link` 和 `value` 字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "role_id",
|
||||
"type": "number",
|
||||
"label": "角色",
|
||||
"link": "role",
|
||||
"value": "name"
|
||||
}
|
||||
```
|
||||
|
||||
### 树形结构
|
||||
|
||||
`parent_id` 字段会被识别为树形结构的父级关联:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "parent_id",
|
||||
"type": "number",
|
||||
"label": "上级",
|
||||
"link": "org",
|
||||
"value": "name"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 权限控制
|
||||
|
||||
### 数据权限
|
||||
|
||||
配置 `flow` 实现数据权限控制:
|
||||
|
||||
```json
|
||||
{
|
||||
"flow": {
|
||||
"order": {
|
||||
"table": "order",
|
||||
"stop": false,
|
||||
"sql": {
|
||||
"user_id": "id"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `stop`: 是否禁止修改该表
|
||||
- `sql`: 数据过滤条件,`user_id = 当前用户.id`
|
||||
|
||||
### 操作权限
|
||||
|
||||
每张表可配置的权限:
|
||||
|
||||
| 权限 | 说明 |
|
||||
|------|------|
|
||||
| `show` | 查看列表 |
|
||||
| `add` | 新增 |
|
||||
| `edit` | 编辑 |
|
||||
| `delete` | 删除 |
|
||||
| `info` | 查看详情 |
|
||||
| `download` | 下载导出 |
|
||||
|
||||
---
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 开发流程
|
||||
|
||||
1. 设置 `config.json` 中 `mode: 2`(开发模式)
|
||||
2. 设计数据库表结构,添加字段备注
|
||||
3. 启动应用,自动生成配置
|
||||
4. 检查生成的配置文件,按需调整
|
||||
5. 生产环境改为 `mode: 0`
|
||||
|
||||
### 2. 字段命名规范
|
||||
|
||||
```sql
|
||||
-- 推荐的命名方式
|
||||
id -- 主键
|
||||
name -- 名称
|
||||
status -- 状态(自动识别为 select)
|
||||
create_time -- 创建时间
|
||||
modify_time -- 修改时间
|
||||
xxx_id -- 外键关联
|
||||
parent_id -- 树形结构父级
|
||||
avatar -- 头像(自动识别为 image)
|
||||
content -- 内容(自动识别为 textArea)
|
||||
```
|
||||
|
||||
### 3. 自定义扩展
|
||||
|
||||
如果默认规则不满足需求,可以:
|
||||
|
||||
1. 修改 `rule.json` 添加自定义规则
|
||||
2. 使用 `mode=1` 生成代码后手动修改
|
||||
3. 在生成的配置文件中直接调整字段属性
|
||||
|
||||
---
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [快速上手指南](QUICKSTART.md)
|
||||
- [HoTimeDB 使用说明](HoTimeDB_使用说明.md)
|
||||
- [Common 工具类使用说明](Common_工具类使用说明.md)
|
||||
@@ -479,6 +479,7 @@ for _, u := range users {
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [快速上手指南](QUICKSTART.md)
|
||||
- [快速上手](QuickStart_快速上手.md)
|
||||
- [HoTimeDB 使用说明](HoTimeDB_使用说明.md)
|
||||
- [代码生成器使用说明](CodeGen_使用说明.md)
|
||||
- [代码生成](CodeGen_代码生成.md)
|
||||
- [文档索引](README.md)
|
||||
+103
-14
@@ -1,6 +1,8 @@
|
||||
# 数据库设计规范
|
||||
|
||||
本文档定义了 HoTime 框架代码生成器所依赖的数据库设计规范。遵循这些规范可以确保代码生成器正确识别表关系、自动生成 CRUD 接口和管理后台。
|
||||
本文档是 HoTime **CodeGen 代码生成器的输入契约**:表/字段命名、外键、注释与公共字段等规则直接决定生成结果。请严格按本文设计库表,再配合 [CodeGen_代码生成.md](CodeGen_代码生成.md) 使用代码生成。
|
||||
|
||||
遵循这些规范可确保代码生成器正确识别表关系、自动生成 CRUD 接口和管理后台。
|
||||
|
||||
---
|
||||
|
||||
@@ -191,6 +193,7 @@ SELECT * FROM org WHERE parent_ids LIKE '%,3,%' -- 查询id=3的所有子级
|
||||
| MySQL | datetime | `2024-01-15 10:30:00` |
|
||||
| SQLite | TEXT | `2024-01-15 10:30:00` |
|
||||
| PostgreSQL | timestamp | `2024-01-15 10:30:00` |
|
||||
| 达梦 DM8 | TIMESTAMP | `2024-01-15 10:30:00` |
|
||||
|
||||
---
|
||||
|
||||
@@ -217,7 +220,7 @@ CREATE TABLE `order` (
|
||||
|
||||
### SQLite 表备注
|
||||
|
||||
SQLite 不支持表备注,代码生成器会使用表名作为默认显示名称。如需自定义,可在配置文件中手动设置 `label`(详见代码生成配置规范)。
|
||||
SQLite 不支持表备注,代码生成器会使用表名作为默认显示名称。如需自定义,可在配置文件中手动设置 `label`(详见 [CodeGen_代码生成.md](CodeGen_代码生成.md))。
|
||||
|
||||
---
|
||||
|
||||
@@ -228,15 +231,17 @@ SQLite 不支持表备注,代码生成器会使用表名作为默认显示名
|
||||
字段注释支持以下格式组合:
|
||||
|
||||
```
|
||||
显示名称:选项值 附加备注{前端提示}
|
||||
显示名称(前端提示):选项值 附加备注
|
||||
显示名称(前端提示):选项值 附加备注
|
||||
显示名称{前端提示}:选项值 附加备注
|
||||
```
|
||||
|
||||
| 部分 | 分隔符 | 用途 |
|
||||
|------|--------|------|
|
||||
| 显示名称 | 无 | 前端表单/列表的字段标签 |
|
||||
| 前端提示 | `()`、`()` 或 `{}` | 存储到 `ps` 字段,前端用于字段说明提示(编辑页输入框右侧、表格/详情鼠标悬停气泡) |
|
||||
| 选项值 | `:` 冒号 | select 类型的下拉选项(格式:`值-名称,值-名称`) |
|
||||
| 附加备注 | 空格 | 仅在数据库中查看,不传递给前端 |
|
||||
| 前端提示 | `{}` | 存储到 `ps` 字段,用于前端显示提示文字 |
|
||||
|
||||
### 选择类型字段
|
||||
|
||||
@@ -267,32 +272,42 @@ SQLite 不支持表备注,代码生成器会使用表名作为默认显示名
|
||||
`amount` decimal(10,2) COMMENT '金额 单位为元,精确到分'
|
||||
```
|
||||
|
||||
### 前端提示({}内)
|
||||
### 前端提示(括号内)
|
||||
|
||||
`{}` 中的内容存储到 `ps` 字段,前端可用于显示输入提示:
|
||||
`()`、`()` 或 `{}` 中的内容存储到 `ps` 字段,前端展示为字段说明提示。推荐使用 `()` 或 `()`,`{}` 为兼容旧格式保留。
|
||||
|
||||
```sql
|
||||
`phone` varchar(20) COMMENT '手机号{请输入11位手机号}'
|
||||
`parent_id` int(11) COMMENT '父级ID(顶级为NULL)'
|
||||
`status` int(2) COMMENT '状态(当前审核状态):0-待审,1-通过,2-驳回'
|
||||
`phone` varchar(20) COMMENT '手机号(请输入11位手机号)'
|
||||
`email` varchar(100) COMMENT '邮箱{格式:xxx@xxx.com}'
|
||||
`content` text COMMENT '内容{支持HTML格式}'
|
||||
```
|
||||
|
||||
前端展示效果:
|
||||
- **编辑/新增页面**:输入框右侧显示灰色小字提示
|
||||
- **表格列表/详情页面**:鼠标悬停字段名时显示气泡提示
|
||||
|
||||
### 组合使用
|
||||
|
||||
```sql
|
||||
-- 完整格式示例
|
||||
`status` int(2) DEFAULT '0' COMMENT '状态:0-待审核,1-已通过,2-已拒绝 业务状态流转字段{选择当前审核状态}'
|
||||
-- 推荐写法:使用圆括号
|
||||
`status` int(2) DEFAULT '0' COMMENT '状态(选择当前审核状态):0-待审核,1-已通过,2-已拒绝'
|
||||
`parent_id` int(11) DEFAULT NULL COMMENT '父级ID(顶级为NULL)'
|
||||
|
||||
-- 兼容写法:使用花括号
|
||||
`status` int(2) DEFAULT '0' COMMENT '状态{选择当前审核状态}:0-待审核,1-已通过,2-已拒绝'
|
||||
```
|
||||
|
||||
解析结果:
|
||||
解析结果(以第一行为例):
|
||||
- `label` = "状态"
|
||||
- `options` = [{name:"待审核", value:"0"}, {name:"已通过", value:"1"}, {name:"已拒绝", value:"2"}]
|
||||
- `ps` = "选择当前审核状态"
|
||||
- 空格后的"业务状态流转字段"不会传递给前端
|
||||
- `options` = [{name:"待审核", value:"0"}, {name:"已通过", value:"1"}, {name:"已拒绝", value:"2"}]
|
||||
|
||||
三种括号 `()`、`()`、`{}` 功能相同,优先匹配顺序为:`()` > `()` > `{}`,同一注释中只取第一个匹配到的括号对。
|
||||
|
||||
### SQLite 字段备注
|
||||
|
||||
SQLite 不支持字段备注(COMMENT),代码生成器会使用字段名作为默认显示名称。如需自定义,可在配置文件中手动设置(详见代码生成配置规范)。
|
||||
SQLite 不支持字段备注(COMMENT),代码生成器会使用字段名作为默认显示名称。如需自定义,可在配置文件中手动设置(详见 [CodeGen_代码生成.md](CodeGen_代码生成.md))。
|
||||
|
||||
---
|
||||
|
||||
@@ -324,6 +339,14 @@ SQLite 不支持字段备注(COMMENT),代码生成器会使用字段名作
|
||||
"modify_time" TIMESTAMP DEFAULT NULL, -- 变更时间
|
||||
```
|
||||
|
||||
### 达梦 DM8
|
||||
|
||||
```sql
|
||||
"state" INT DEFAULT 0, -- 状态:0-正常,1-异常,2-隐藏
|
||||
"create_time" TIMESTAMP DEFAULT NULL, -- 创建日期
|
||||
"modify_time" TIMESTAMP DEFAULT NULL, -- 变更时间
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完整建表示例
|
||||
@@ -392,6 +415,56 @@ CREATE TABLE "user" (
|
||||
);
|
||||
```
|
||||
|
||||
### 达梦 DM8 示例
|
||||
|
||||
达梦建表有以下关键差异:
|
||||
- 自增主键使用 `IDENTITY(1,1)`(对应 MySQL 的 `AUTO_INCREMENT`)
|
||||
- 长文本使用 `CLOB`(对应 MySQL 的 `LONGTEXT`)
|
||||
- 标识符建议用**双引号包裹小写名称**,与框架保持一致
|
||||
- `admin`、`user`、`order` 等是 DM 保留字,必须加双引号
|
||||
|
||||
```sql
|
||||
-- 用户表
|
||||
CREATE TABLE "user" (
|
||||
"id" INT IDENTITY(1,1) PRIMARY KEY,
|
||||
"name" VARCHAR(50) DEFAULT NULL,
|
||||
"phone" VARCHAR(20) DEFAULT NULL,
|
||||
"password" VARCHAR(64) DEFAULT NULL,
|
||||
"org_id" INT DEFAULT NULL,
|
||||
"role_id" INT DEFAULT NULL,
|
||||
"avatar" VARCHAR(255) DEFAULT NULL,
|
||||
"sex" INT DEFAULT 0,
|
||||
"state" INT DEFAULT 0,
|
||||
"create_time" TIMESTAMP DEFAULT NULL,
|
||||
"modify_time" TIMESTAMP DEFAULT NULL
|
||||
);
|
||||
|
||||
-- 组织表(树形结构)
|
||||
CREATE TABLE "org" (
|
||||
"id" INT IDENTITY(1,1) PRIMARY KEY,
|
||||
"name" VARCHAR(100) DEFAULT NULL,
|
||||
"parent_id" INT DEFAULT NULL,
|
||||
"parent_ids" VARCHAR(255) DEFAULT NULL,
|
||||
"level" INT DEFAULT 0,
|
||||
"sort" INT DEFAULT 0,
|
||||
"state" INT DEFAULT 0,
|
||||
"create_time" TIMESTAMP DEFAULT NULL,
|
||||
"modify_time" TIMESTAMP DEFAULT NULL
|
||||
);
|
||||
|
||||
-- 用户组织关联表
|
||||
CREATE TABLE "user_org" (
|
||||
"id" INT IDENTITY(1,1) PRIMARY KEY,
|
||||
"user_id" INT DEFAULT NULL,
|
||||
"org_id" INT DEFAULT NULL,
|
||||
"state" INT DEFAULT 0,
|
||||
"create_time" TIMESTAMP DEFAULT NULL,
|
||||
"modify_time" TIMESTAMP DEFAULT NULL
|
||||
);
|
||||
```
|
||||
|
||||
> **注意**:达梦中 DDL 的表名/列名大小写敏感(双引号包裹时),建议统一使用小写,与框架自动生成的 SQL 保持一致。
|
||||
|
||||
---
|
||||
|
||||
## 规范检查清单
|
||||
@@ -406,3 +479,19 @@ CREATE TABLE "user" (
|
||||
- [ ] 树形表包含 parent_id、parent_ids、level
|
||||
- [ ] 所有表包含 state、create_time、modify_time
|
||||
- [ ] 选择字段注释格式正确(`标签:值-名称`)
|
||||
|
||||
### 达梦 DM8 额外检查项
|
||||
|
||||
- [ ] 建表时标识符使用**双引号包裹小写名称**(如 `"user"`、`"id"`)
|
||||
- [ ] 保留字(`admin`、`user`、`order`、`key` 等)必须加双引号
|
||||
- [ ] 自增主键使用 `IDENTITY(1,1)` 而非 `AUTO_INCREMENT`
|
||||
- [ ] 长文本字段使用 `CLOB` 而非 `LONGTEXT`
|
||||
- [ ] 检测表是否存在时用 `COUNT(*)` 查询,不用 `USER_TABLES`(存在 schema 错配问题)
|
||||
|
||||
---
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [CodeGen_代码生成.md](CodeGen_代码生成.md) — 按本规范建表后运行代码生成
|
||||
- [HoTimeDB_使用说明.md](HoTimeDB_使用说明.md) — 运行时 ORM(与建表规范分开)
|
||||
- [文档索引](README.md)
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
# 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_EVENT,Windows 约 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()`。
|
||||
+191
-493
@@ -1,190 +1,13 @@
|
||||
# HoTimeDB API 快速参考
|
||||
|
||||
## 条件查询语法规则
|
||||
完整教程见 [HoTimeDB 使用说明](HoTimeDB_使用说明.md)。本文仅收录条件运算符与方法签名速查。
|
||||
|
||||
**新版本改进:**
|
||||
- 多条件自动用 AND 连接,无需手动包装
|
||||
- 关键字支持大小写(如 `LIMIT` 和 `limit` 都有效)
|
||||
- 新增 `HAVING` 和独立 `OFFSET` 支持
|
||||
---
|
||||
|
||||
```go
|
||||
// ✅ 推荐:简化语法(多条件自动 AND)
|
||||
Map{"status": 1, "age[>]": 18}
|
||||
// 生成: WHERE `status`=? AND `age`>?
|
||||
## 条件运算符
|
||||
|
||||
// ✅ 仍然支持:显式 AND 包装(向后兼容)
|
||||
Map{
|
||||
"AND": Map{
|
||||
"status": 1,
|
||||
"age[>]": 18,
|
||||
},
|
||||
}
|
||||
多条件默认 AND;关键字大小写均可(`ORDER`/`order`)。
|
||||
|
||||
// ✅ 混合条件和特殊关键字
|
||||
Map{
|
||||
"status": 1,
|
||||
"age[>]": 18,
|
||||
"ORDER": "id DESC", // 或 "order": "id DESC"
|
||||
"LIMIT": 10, // 或 "limit": 10
|
||||
}
|
||||
```
|
||||
|
||||
## 基本方法
|
||||
|
||||
### 数据库连接
|
||||
```go
|
||||
database.SetConnect(func() (master, slave *sql.DB) { ... })
|
||||
database.InitDb()
|
||||
```
|
||||
|
||||
### 链式查询构建器
|
||||
```go
|
||||
// 创建查询构建器
|
||||
builder := database.Table("tablename")
|
||||
|
||||
// 设置条件
|
||||
builder.Where(key, value)
|
||||
builder.And(key, value) 或 builder.And(map)
|
||||
builder.Or(key, value) 或 builder.Or(map)
|
||||
|
||||
// JOIN操作
|
||||
builder.LeftJoin(table, condition)
|
||||
builder.RightJoin(table, condition)
|
||||
builder.InnerJoin(table, condition)
|
||||
builder.FullJoin(table, condition)
|
||||
builder.Join(map) // 通用JOIN
|
||||
|
||||
// 排序和分组
|
||||
builder.Order(fields...)
|
||||
builder.Group(fields...)
|
||||
builder.Limit(args...)
|
||||
builder.Having(map) // 新增
|
||||
|
||||
// 分页
|
||||
builder.Page(page, pageSize)
|
||||
builder.Offset(offset) // 新增
|
||||
|
||||
// 执行查询
|
||||
builder.Select(fields...) // 返回 []Map
|
||||
builder.Get(fields...) // 返回 Map
|
||||
builder.Count() // 返回 int
|
||||
builder.Update(data) // 返回 int64
|
||||
builder.Delete() // 返回 int64
|
||||
```
|
||||
|
||||
## CRUD 操作
|
||||
|
||||
### 查询 (Select)
|
||||
```go
|
||||
// 基本查询
|
||||
data := database.Select("table")
|
||||
data := database.Select("table", "field1,field2")
|
||||
data := database.Select("table", []string{"field1", "field2"})
|
||||
data := database.Select("table", "*", whereMap)
|
||||
|
||||
// 带JOIN查询
|
||||
data := database.Select("table", joinSlice, "fields", whereMap)
|
||||
```
|
||||
|
||||
### 获取单条 (Get)
|
||||
```go
|
||||
// 自动添加 LIMIT 1
|
||||
row := database.Get("table", "fields", whereMap)
|
||||
```
|
||||
|
||||
### 插入 (Insert)
|
||||
```go
|
||||
id := database.Insert("table", dataMap)
|
||||
// 返回新插入记录的ID
|
||||
```
|
||||
|
||||
### 批量插入 (Inserts) - 新增
|
||||
```go
|
||||
// 使用 []Map 格式,更直观简洁
|
||||
affected := database.Inserts("table", []Map{
|
||||
{"col1": "val1", "col2": "val2", "col3": "val3"},
|
||||
{"col1": "val4", "col2": "val5", "col3": "val6"},
|
||||
})
|
||||
// 返回受影响的行数
|
||||
|
||||
// 支持 [#] 标记直接 SQL
|
||||
affected := database.Inserts("log", []Map{
|
||||
{"user_id": 1, "created_time[#]": "NOW()"},
|
||||
{"user_id": 2, "created_time[#]": "NOW()"},
|
||||
})
|
||||
```
|
||||
|
||||
### 更新 (Update)
|
||||
```go
|
||||
affected := database.Update("table", dataMap, whereMap)
|
||||
// 返回受影响的行数
|
||||
```
|
||||
|
||||
### Upsert - 新增
|
||||
```go
|
||||
// 使用 Slice 格式
|
||||
affected := database.Upsert("table",
|
||||
dataMap, // 插入数据
|
||||
Slice{"unique_key"}, // 唯一键
|
||||
Slice{"col1", "col2"}, // 冲突时更新的字段
|
||||
)
|
||||
|
||||
// 也支持可变参数
|
||||
affected := database.Upsert("table", dataMap, Slice{"id"}, "col1", "col2")
|
||||
// 返回受影响的行数
|
||||
```
|
||||
|
||||
### 删除 (Delete)
|
||||
```go
|
||||
affected := database.Delete("table", whereMap)
|
||||
// 返回删除的行数
|
||||
```
|
||||
|
||||
## 聚合函数
|
||||
|
||||
### 计数
|
||||
```go
|
||||
count := database.Count("table")
|
||||
count := database.Count("table", whereMap)
|
||||
count := database.Count("table", joinSlice, whereMap)
|
||||
```
|
||||
|
||||
### 求和
|
||||
```go
|
||||
sum := database.Sum("table", "column")
|
||||
sum := database.Sum("table", "column", whereMap)
|
||||
```
|
||||
|
||||
### 平均值 - 新增
|
||||
```go
|
||||
avg := database.Avg("table", "column")
|
||||
avg := database.Avg("table", "column", whereMap)
|
||||
```
|
||||
|
||||
### 最大值 - 新增
|
||||
```go
|
||||
max := database.Max("table", "column")
|
||||
max := database.Max("table", "column", whereMap)
|
||||
```
|
||||
|
||||
### 最小值 - 新增
|
||||
```go
|
||||
min := database.Min("table", "column")
|
||||
min := database.Min("table", "column", whereMap)
|
||||
```
|
||||
|
||||
## 分页查询
|
||||
```go
|
||||
// 设置分页
|
||||
database.Page(page, pageSize)
|
||||
|
||||
// 分页查询
|
||||
data := database.Page(page, pageSize).PageSelect("table", "fields", whereMap)
|
||||
```
|
||||
|
||||
## 条件语法参考
|
||||
|
||||
### 比较操作符
|
||||
| 写法 | SQL | 说明 |
|
||||
|------|-----|------|
|
||||
| `"field": value` | `field = ?` | 等于 |
|
||||
@@ -193,324 +16,199 @@ data := database.Page(page, pageSize).PageSelect("table", "fields", whereMap)
|
||||
| `"field[>=]": value` | `field >= ?` | 大于等于 |
|
||||
| `"field[<]": value` | `field < ?` | 小于 |
|
||||
| `"field[<=]": value` | `field <= ?` | 小于等于 |
|
||||
| `"field[~]": "kw"` | `LIKE '%kw%'` | 包含 |
|
||||
| `"field[~!]": "kw"` | `LIKE 'kw%'` | 开头 |
|
||||
| `"field[!~]": "kw"` | `LIKE '%kw'` | 结尾 |
|
||||
| `"field[~~]": "%kw%"` | `LIKE '%kw%'` | 手动 LIKE |
|
||||
| `"field[<>]": [min,max]` | `BETWEEN ? AND ?` | 区间内 |
|
||||
| `"field[><]": [min,max]` | `NOT BETWEEN` | 区间外 |
|
||||
| `"field": [v1,v2]` | `IN (?,?)` | 集合 |
|
||||
| `"field[!]": [v1,v2]` | `NOT IN` | 非集合 |
|
||||
| `"field": nil` | `IS NULL` | 空 |
|
||||
| `"field[!]": nil` | `IS NOT NULL` | 非空 |
|
||||
| `"field[#]": "balance+1"` | `field = balance+1` | 直接表达式 |
|
||||
| `"[##]": "a > b"` | `a > b` | 直接 SQL 片段 |
|
||||
| `"field[#!]": "1"` | `field != 1` | 非参数化不等 |
|
||||
|
||||
### 模糊查询
|
||||
| 写法 | SQL | 说明 |
|
||||
|------|-----|------|
|
||||
| `"field[~]": "keyword"` | `field LIKE '%keyword%'` | 包含 |
|
||||
| `"field[~!]": "keyword"` | `field LIKE 'keyword%'` | 以...开头 |
|
||||
| `"field[!~]": "keyword"` | `field LIKE '%keyword'` | 以...结尾 |
|
||||
| `"field[~~]": "%keyword%"` | `field LIKE '%keyword%'` | 手动LIKE |
|
||||
时间字段推荐 Go 侧传值:`"create_time": Time2Str(time.Now())`(勿用 `NOW()`)。
|
||||
|
||||
### 范围查询
|
||||
| 写法 | SQL | 说明 |
|
||||
|------|-----|------|
|
||||
| `"field[<>]": [min, max]` | `field BETWEEN ? AND ?` | 区间内 |
|
||||
| `"field[><]": [min, max]` | `field NOT BETWEEN ? AND ?` | 区间外 |
|
||||
|
||||
### 集合查询
|
||||
| 写法 | SQL | 说明 |
|
||||
|------|-----|------|
|
||||
| `"field": [v1, v2, v3]` | `field IN (?, ?, ?)` | 在集合中 |
|
||||
| `"field[!]": [v1, v2, v3]` | `field NOT IN (?, ?, ?)` | 不在集合中 |
|
||||
|
||||
### NULL查询
|
||||
| 写法 | SQL | 说明 |
|
||||
|------|-----|------|
|
||||
| `"field": nil` | `field IS NULL` | 为空 |
|
||||
| `"field[!]": nil` | `field IS NOT NULL` | 不为空 |
|
||||
|
||||
### 直接SQL
|
||||
| 写法 | SQL | 说明 |
|
||||
|------|-----|------|
|
||||
| `"field[#]": "NOW()"` | `field = NOW()` | 直接SQL函数 |
|
||||
| `"[##]": "a > b"` | `a > b` | 直接SQL片段 |
|
||||
| `"field[#!]": "1"` | `field != 1` | 不等于(不参数化) |
|
||||
|
||||
## 逻辑连接符
|
||||
|
||||
### AND 条件
|
||||
```go
|
||||
// 简化语法(推荐)
|
||||
whereMap := Map{
|
||||
"status": 1,
|
||||
"age[>]": 18,
|
||||
}
|
||||
// 生成: WHERE `status`=? AND `age`>?
|
||||
|
||||
// 显式 AND(向后兼容)
|
||||
whereMap := Map{
|
||||
"AND": Map{
|
||||
"status": 1,
|
||||
"age[>]": 18,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### OR 条件
|
||||
```go
|
||||
whereMap := Map{
|
||||
"OR": Map{
|
||||
"status": 1,
|
||||
"type": 2,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 嵌套条件
|
||||
```go
|
||||
whereMap := Map{
|
||||
"AND": Map{
|
||||
"status": 1,
|
||||
"OR": Map{
|
||||
"age[<]": 30,
|
||||
"level[>]": 5,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## JOIN 语法
|
||||
|
||||
### 传统语法
|
||||
```go
|
||||
joinSlice := Slice{
|
||||
Map{"[>]profile": "user.id = profile.user_id"}, // LEFT JOIN
|
||||
Map{"[<]department": "user.dept_id = department.id"}, // RIGHT JOIN
|
||||
Map{"[><]role": "user.role_id = role.id"}, // INNER JOIN
|
||||
Map{"[<>]group": "user.group_id = group.id"}, // FULL JOIN
|
||||
}
|
||||
```
|
||||
|
||||
### 链式语法
|
||||
```go
|
||||
builder.LeftJoin("profile", "user.id = profile.user_id")
|
||||
builder.RightJoin("department", "user.dept_id = department.id")
|
||||
builder.InnerJoin("role", "user.role_id = role.id")
|
||||
builder.FullJoin("group", "user.group_id = group.id")
|
||||
```
|
||||
|
||||
## 特殊字段语法
|
||||
|
||||
### ORDER BY
|
||||
```go
|
||||
Map{
|
||||
"ORDER": []string{"created_time DESC", "id ASC"},
|
||||
}
|
||||
// 或
|
||||
Map{
|
||||
"order": "created_time DESC", // 支持小写
|
||||
}
|
||||
```
|
||||
|
||||
### GROUP BY
|
||||
```go
|
||||
Map{
|
||||
"GROUP": []string{"department", "level"},
|
||||
}
|
||||
// 或
|
||||
Map{
|
||||
"group": "department", // 支持小写
|
||||
}
|
||||
```
|
||||
|
||||
### HAVING - 新增
|
||||
```go
|
||||
Map{
|
||||
"GROUP": "dept_id",
|
||||
"HAVING": Map{
|
||||
"COUNT(*).[>]": 5,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### LIMIT
|
||||
```go
|
||||
Map{
|
||||
"LIMIT": []int{10, 20}, // offset 10, limit 20
|
||||
}
|
||||
// 或
|
||||
Map{
|
||||
"limit": 20, // limit 20,支持小写
|
||||
}
|
||||
```
|
||||
|
||||
### OFFSET - 新增
|
||||
```go
|
||||
Map{
|
||||
"LIMIT": 10,
|
||||
"OFFSET": 20, // 独立的 OFFSET
|
||||
}
|
||||
```
|
||||
|
||||
## 事务处理
|
||||
```go
|
||||
success := database.Action(func(tx HoTimeDB) bool {
|
||||
// 在这里执行数据库操作
|
||||
// 返回 true 提交事务
|
||||
// 返回 false 回滚事务
|
||||
|
||||
id := tx.Insert("table", data)
|
||||
if id == 0 {
|
||||
return false // 回滚
|
||||
}
|
||||
|
||||
affected := tx.Update("table2", data2, where2)
|
||||
if affected == 0 {
|
||||
return false // 回滚
|
||||
}
|
||||
|
||||
return true // 提交
|
||||
})
|
||||
```
|
||||
|
||||
## 原生SQL执行
|
||||
|
||||
### 查询
|
||||
```go
|
||||
results := database.Query("SELECT * FROM user WHERE age > ?", 18)
|
||||
```
|
||||
|
||||
### 执行
|
||||
```go
|
||||
result, err := database.Exec("UPDATE user SET status = ? WHERE id = ?", 1, 100)
|
||||
affected, _ := result.RowsAffected()
|
||||
```
|
||||
|
||||
## PostgreSQL 支持 - 新增
|
||||
### 逻辑与特殊关键字
|
||||
|
||||
```go
|
||||
// 配置 PostgreSQL
|
||||
database := &db.HoTimeDB{
|
||||
Type: "postgres", // 设置类型
|
||||
}
|
||||
|
||||
// 框架自动处理差异:
|
||||
// - 占位符: ? -> $1, $2, $3...
|
||||
// - 引号: `name` -> "name"
|
||||
// - Upsert: ON DUPLICATE KEY -> ON CONFLICT
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
```go
|
||||
// 检查最后的错误
|
||||
if database.LastErr.GetError() != nil {
|
||||
fmt.Println("错误:", database.LastErr.GetError())
|
||||
}
|
||||
|
||||
// 查看最后执行的SQL
|
||||
fmt.Println("SQL:", database.LastQuery)
|
||||
fmt.Println("参数:", database.LastData)
|
||||
```
|
||||
|
||||
## 工具方法
|
||||
|
||||
### 数据库信息
|
||||
```go
|
||||
prefix := database.GetPrefix() // 获取表前缀
|
||||
dbType := database.GetType() // 获取数据库类型
|
||||
dialect := database.GetDialect() // 获取方言适配器
|
||||
```
|
||||
|
||||
### 设置模式
|
||||
```go
|
||||
database.Mode = 0 // 生产模式
|
||||
database.Mode = 1 // 测试模式
|
||||
database.Mode = 2 // 开发模式(输出SQL日志)
|
||||
```
|
||||
|
||||
## 常用查询模式
|
||||
|
||||
### 分页列表查询
|
||||
```go
|
||||
// 获取总数
|
||||
total := database.Count("user", Map{"status": 1})
|
||||
|
||||
// 分页数据
|
||||
users := database.Table("user").
|
||||
Where("status", 1).
|
||||
Order("created_time DESC").
|
||||
Page(page, pageSize).
|
||||
Select("id,name,email,created_time")
|
||||
|
||||
// 计算分页信息
|
||||
totalPages := (total + pageSize - 1) / pageSize
|
||||
```
|
||||
|
||||
### 关联查询
|
||||
```go
|
||||
orders := database.Table("order").
|
||||
LeftJoin("user", "order.user_id = user.id").
|
||||
LeftJoin("product", "order.product_id = product.id").
|
||||
Where("order.status", "paid").
|
||||
Select(`
|
||||
order.*,
|
||||
user.name as user_name,
|
||||
product.title as product_title
|
||||
`)
|
||||
```
|
||||
|
||||
### 统计查询
|
||||
```go
|
||||
stats := database.Select("order",
|
||||
"user_id, COUNT(*) as order_count, SUM(amount) as total_amount",
|
||||
Map{
|
||||
"status": "paid",
|
||||
"created_time[>]": "2023-01-01",
|
||||
"GROUP": "user_id",
|
||||
"ORDER": "total_amount DESC",
|
||||
})
|
||||
```
|
||||
|
||||
### 批量操作
|
||||
```go
|
||||
// 批量插入(使用 []Map 格式)
|
||||
affected := database.Inserts("user", []Map{
|
||||
{"name": "用户1", "email": "user1@example.com", "status": 1},
|
||||
{"name": "用户2", "email": "user2@example.com", "status": 1},
|
||||
{"name": "用户3", "email": "user3@example.com", "status": 1},
|
||||
})
|
||||
|
||||
// Upsert(插入或更新,使用 Slice 格式)
|
||||
affected := database.Upsert("user",
|
||||
Map{"id": 1, "name": "新名称", "email": "new@example.com"},
|
||||
Slice{"id"},
|
||||
Slice{"name", "email"},
|
||||
)
|
||||
```
|
||||
|
||||
## 链式调用完整示例
|
||||
|
||||
```go
|
||||
// 复杂查询链式调用
|
||||
result := database.Table("order").
|
||||
LeftJoin("user", "order.user_id = user.id").
|
||||
LeftJoin("product", "order.product_id = product.id").
|
||||
Where("order.status", "paid").
|
||||
And("order.created_time[>]", "2023-01-01").
|
||||
And(Map{
|
||||
"OR": Map{
|
||||
"user.level": "vip",
|
||||
"order.amount[>]": 1000,
|
||||
},
|
||||
}).
|
||||
Group("user.id").
|
||||
Having(Map{"total_amount[>]": 500}).
|
||||
Order("total_amount DESC").
|
||||
Page(1, 20).
|
||||
Select(`
|
||||
user.id,
|
||||
user.name,
|
||||
user.email,
|
||||
COUNT(order.id) as order_count,
|
||||
SUM(order.amount) as total_amount
|
||||
`)
|
||||
Map{"status": 1, "age[>]": 18} // 自动 AND
|
||||
Map{"AND": Map{...}} / Map{"OR": Map{...}} // 显式逻辑
|
||||
Map{"AND": Map{"status": 1, "OR": Map{...}}} // 嵌套
|
||||
Map{"ORDER": "id DESC"} / []string{"a DESC","b"} // ORDER BY
|
||||
Map{"GROUP": "dept"} / []string{"a","b"} // GROUP BY
|
||||
Map{"HAVING": Map{"COUNT(*).[>]": 5}} // HAVING
|
||||
Map{"LIMIT": 20} / []int{10, 20} // LIMIT / offset+limit
|
||||
Map{"OFFSET": 20} // 独立 OFFSET
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*快速参考版本: 2.0*
|
||||
*更新日期: 2026年1月*
|
||||
## 方法签名
|
||||
|
||||
**详细说明:**
|
||||
- [HoTimeDB 使用说明](HoTimeDB_使用说明.md) - 完整教程
|
||||
### 连接与元信息
|
||||
|
||||
| 方法 | 签名 | 说明 |
|
||||
|------|------|------|
|
||||
| SetConnect | `SetConnect(func() (master, slave *sql.DB))` | 设置连接并 InitDb |
|
||||
| InitDb | `InitDb()` | 初始化连接与方言 |
|
||||
| GetPrefix | `GetPrefix() string` | 表前缀 |
|
||||
| GetType | `GetType() string` | 库类型 |
|
||||
| GetDialect | `GetDialect() Dialect` | 方言适配器 |
|
||||
| GetLast | `GetLast() *DBError` | 最近一次 SQL 快照 |
|
||||
| GetLastError | `GetLastError() error` | 最近错误 |
|
||||
| GetLastQuery | `GetLastQuery() string` | 最近 SQL |
|
||||
| GetLastData | `GetLastData() []interface{}` | 最近参数 |
|
||||
|
||||
`Type`:`mysql` / `sqlite3` / `postgres` / `dm`(或 `dameng`)。
|
||||
|
||||
### CRUD
|
||||
|
||||
| 方法 | 签名 | 返回 |
|
||||
|------|------|------|
|
||||
| Select | `Select(table string, qu ...interface{})` | `[]Map` |
|
||||
| Get | `Get(table string, qu ...interface{})` | `Map`(自动 LIMIT 1) |
|
||||
| Insert | `Insert(table string, data map[string]interface{})` | `int64` 新 ID |
|
||||
| Inserts | `Inserts(table string, dataList []Map)` | `int64` 影响行数 |
|
||||
| Update | `Update(table string, data Map, where Map)` | `int64` |
|
||||
| Upsert | `Upsert(table string, data Map, uniqueKeys Slice, updateColumns ...interface{})` | `int64` |
|
||||
| Delete | `Delete(table string, data map[string]interface{})` | `int64` |
|
||||
| Page | `Page(page, pageRow int) *HoTimeDB` | 链式分页 |
|
||||
| PageSelect | `PageSelect(table string, qu ...interface{})` | `[]Map` |
|
||||
|
||||
`Select`/`Get`/`PageSelect` 的 `qu` 常见形态:字段;字段+where;join+字段+where。
|
||||
|
||||
```go
|
||||
database.Select("user")
|
||||
database.Select("user", "id,name")
|
||||
database.Select("user", "*", where)
|
||||
database.Select("user", joinSlice, "fields", where)
|
||||
database.Get("user", "fields", where)
|
||||
database.Insert("user", Map{"name": "a"})
|
||||
database.Inserts("user", []Map{{"name": "a"}, {"name": "b"}})
|
||||
database.Update("user", Map{"name": "b"}, Map{"id": 1})
|
||||
database.Upsert("user", data, Slice{"id"}, Slice{"name", "email"})
|
||||
database.Upsert("user", data, Slice{"id"}, "name", "email")
|
||||
database.Delete("user", Map{"id": 1})
|
||||
database.Page(1, 20).PageSelect("user", "id,name", where)
|
||||
```
|
||||
|
||||
### 聚合
|
||||
|
||||
| 方法 | 签名 | 返回 |
|
||||
|------|------|------|
|
||||
| Count | `Count(table string, qu ...interface{})` | `int` |
|
||||
| Sum | `Sum(table string, column string, qu ...interface{})` | `float64` |
|
||||
| Avg | `Avg(table string, column string, qu ...interface{})` | `float64` |
|
||||
| Max | `Max(table string, column string, qu ...interface{})` | `float64` |
|
||||
| Min | `Min(table string, column string, qu ...interface{})` | `float64` |
|
||||
|
||||
`qu` 可为 where,或 join+where。
|
||||
|
||||
```go
|
||||
database.Count("user")
|
||||
database.Count("user", where)
|
||||
database.Count("user", join, where)
|
||||
database.Sum("order", "amount", where)
|
||||
database.Avg("order", "amount", where)
|
||||
database.Max("order", "amount", where)
|
||||
database.Min("order", "amount", where)
|
||||
```
|
||||
|
||||
### 链式构建器
|
||||
|
||||
```go
|
||||
b := database.Table("tablename") // *HotimeDBBuilder
|
||||
```
|
||||
|
||||
| 方法 | 签名 | 说明 |
|
||||
|------|------|------|
|
||||
| Where / And / Or | `(qu ...interface{}) *HotimeDBBuilder` | 键值或 Map |
|
||||
| LeftJoin / RightJoin / InnerJoin / FullJoin | `(table, condition string)` | JOIN |
|
||||
| Join | `(qu ...interface{})` | 通用 JOIN Map |
|
||||
| Order / Group / Limit | `(qu ...interface{})` | 排序/分组/限制 |
|
||||
| Having | `(qu ...interface{})` | HAVING |
|
||||
| Page | `(page, pageRow int)` | 分页 |
|
||||
| Offset | `(offset int)` | 偏移 |
|
||||
| From | `(table string)` | 换表 |
|
||||
| Select | `(qu ...interface{}) []Map` | 查询 |
|
||||
| Get | `(qu ...interface{}) Map` | 单条 |
|
||||
| Count | `() int` | 计数 |
|
||||
| Update | `(qu ...interface{}) int64` | 更新 |
|
||||
| Delete | `() int64` | 删除 |
|
||||
|
||||
```go
|
||||
database.Table("user").Where("status", 1).Order("id DESC").Page(1, 20).Select("id,name")
|
||||
database.Table("order").LeftJoin("user", "order.user_id = user.id").Where("order.status", "paid").Select("order.*, user.name")
|
||||
```
|
||||
|
||||
### JOIN Map 写法
|
||||
|
||||
| 写法 | 类型 |
|
||||
|------|------|
|
||||
| `Map{"[>]profile": "user.id = profile.user_id"}` | LEFT |
|
||||
| `Map{"[<]department": "user.dept_id = department.id"}` | RIGHT |
|
||||
| `Map{"[><]role": "user.role_id = role.id"}` | INNER |
|
||||
| `Map{"[<>]group": "user.group_id = group.id"}` | FULL |
|
||||
|
||||
### 事务与原生 SQL
|
||||
|
||||
| 方法 | 签名 | 说明 |
|
||||
|------|------|------|
|
||||
| Action | `Action(func(db HoTimeDB) bool) bool` | `true` 提交 / `false` 回滚 |
|
||||
| Query | `Query(query string, args ...interface{}) []Map` | 原生查询 |
|
||||
| Exec | `Exec(query string, args ...interface{}) (sql.Result, error)` | 原生执行 |
|
||||
|
||||
```go
|
||||
ok := database.Action(func(tx HoTimeDB) bool {
|
||||
if tx.Insert("a", data) == 0 { return false }
|
||||
return true
|
||||
})
|
||||
database.Query("SELECT * FROM user WHERE age > ?", 18)
|
||||
database.Exec("UPDATE user SET status = ? WHERE id = ?", 1, 100)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 方言差异速查
|
||||
|
||||
| 特性 | MySQL | PostgreSQL | 达梦 DM8 |
|
||||
|------|-------|------------|---------|
|
||||
| 标识符引号 | \`name\` | `"name"` | `"name"` |
|
||||
| 占位符 | `?` | `$1,$2...` | `?` |
|
||||
| Upsert | ON DUPLICATE KEY | ON CONFLICT | MERGE INTO |
|
||||
| 自增 | AUTO_INCREMENT | SERIAL | IDENTITY(1,1) |
|
||||
| 分页 | LIMIT m,n | LIMIT n OFFSET m | 两者皆可 |
|
||||
|
||||
框架自动适配,业务代码通常无需按库分支。达梦:保留字(`admin`/`user`/`order`)ORM 自动加引号,原生 SQL 需手动双引号;时间统一用 `Time2Str(time.Now())`。
|
||||
|
||||
```go
|
||||
// Type: "postgres" | "dm" | "dameng" | "mysql" | "sqlite3"
|
||||
database := &db.HoTimeDB{Type: "dm"}
|
||||
database.SetConnect(func() (master, slave *sql.DB) {
|
||||
master, _ = sql.Open("dm", "dm://SYSDBA:pwd@127.0.0.1:5236?schema=TEST")
|
||||
return master, master
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常用一行模式
|
||||
|
||||
```go
|
||||
// 分页列表
|
||||
total := database.Count("user", Map{"status": 1})
|
||||
rows := database.Table("user").Where("status", 1).Order("id DESC").Page(page, size).Select("id,name")
|
||||
|
||||
// 分组统计
|
||||
database.Select("order", "user_id, COUNT(*) c, SUM(amount) s", Map{
|
||||
"status": "paid", "GROUP": "user_id", "ORDER": "s DESC",
|
||||
})
|
||||
|
||||
// 查错
|
||||
if err := database.GetLastError(); err != nil { /* ... */ }
|
||||
_ = database.GetLastQuery(); _ = database.GetLastData()
|
||||
```
|
||||
|
||||
+245
-33
@@ -1,8 +1,10 @@
|
||||
# HoTimeDB ORM 使用说明书
|
||||
|
||||
> 完整教程见本文;方法签名与条件运算符速查见 [HoTimeDB_API参考.md](HoTimeDB_API参考.md)
|
||||
|
||||
## 概述
|
||||
|
||||
HoTimeDB是一个基于Golang实现的轻量级ORM框架,参考PHP Medoo设计,提供简洁的数据库操作接口。支持MySQL、SQLite、PostgreSQL等数据库,并集成了缓存、事务、链式查询等功能。
|
||||
HoTimeDB是一个基于Golang实现的轻量级ORM框架,参考PHP Medoo设计,提供简洁的数据库操作接口。支持MySQL、SQLite、PostgreSQL、达梦(DM8)等数据库,并集成了缓存、事务、链式查询等功能。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -24,6 +26,7 @@ HoTimeDB是一个基于Golang实现的轻量级ORM框架,参考PHP Medoo设计
|
||||
- [事务处理](#事务处理)
|
||||
- [缓存机制](#缓存机制)
|
||||
- [PostgreSQL支持](#postgresql支持)
|
||||
- [达梦数据库支持](#达梦数据库支持)
|
||||
- [高级特性](#高级特性)
|
||||
|
||||
## 快速开始
|
||||
@@ -48,7 +51,7 @@ func createConnection() (master, slave *sql.DB) {
|
||||
|
||||
// 初始化HoTimeDB
|
||||
database := &db.HoTimeDB{
|
||||
Type: "mysql", // 可选:mysql, sqlite3, postgres
|
||||
Type: "mysql", // 可选:mysql, sqlite3, postgres, dm
|
||||
}
|
||||
database.SetConnect(createConnection)
|
||||
```
|
||||
@@ -64,7 +67,7 @@ type HoTimeDB struct {
|
||||
DBName string
|
||||
*cache.HoTimeCache
|
||||
Log *logrus.Logger
|
||||
Type string // 数据库类型:mysql, sqlite3, postgres
|
||||
Type string // 数据库类型:mysql, sqlite3, postgres, dm
|
||||
Prefix string // 表前缀
|
||||
LastQuery string // 最后执行的SQL
|
||||
LastData []interface{} // 最后的参数
|
||||
@@ -212,17 +215,27 @@ user := database.Get("user", "id,name,email", common.Map{
|
||||
```go
|
||||
// 基本插入
|
||||
id := database.Insert("user", common.Map{
|
||||
"name": "张三",
|
||||
"email": "zhangsan@example.com",
|
||||
"age": 25,
|
||||
"status": 1,
|
||||
"created_time[#]": "NOW()", // [#]表示直接插入SQL函数
|
||||
"name": "张三",
|
||||
"email": "zhangsan@example.com",
|
||||
"age": 25,
|
||||
"status": 1,
|
||||
"created_time": Time2Str(time.Now()), // 推荐:使用服务器时间,避免数据库时区问题
|
||||
})
|
||||
|
||||
// 返回插入的ID
|
||||
fmt.Println("插入的用户ID:", id)
|
||||
```
|
||||
|
||||
> **时间字段最佳实践**:建议使用 `Time2Str(time.Now())` 传入服务器时间,而非 `"[#]": "NOW()"` 让数据库执行 `NOW()`。
|
||||
> 原因:`NOW()` 使用数据库服务器时间,当应用服务器与数据库服务器时区不同时会产生偏差;而 `Time2Str(time.Now())` 始终使用应用服务器时间,行为一致,且跨数据库(MySQL / 达梦 / PostgreSQL)完全兼容。
|
||||
>
|
||||
> ```go
|
||||
> import (
|
||||
> "time"
|
||||
> . "code.hoteas.com/golang/hotime/common" // Time2Str 在 common 包中
|
||||
> )
|
||||
> ```
|
||||
|
||||
### 批量插入(Inserts)
|
||||
|
||||
```go
|
||||
@@ -236,10 +249,11 @@ affected := database.Inserts("user", []common.Map{
|
||||
|
||||
fmt.Printf("批量插入 %d 条记录\n", affected)
|
||||
|
||||
// 支持 [#] 标记直接插入 SQL 表达式
|
||||
// 推荐:使用服务器时间(Time2Str)而非数据库函数 NOW()
|
||||
now := Time2Str(time.Now())
|
||||
affected := database.Inserts("log", []common.Map{
|
||||
{"user_id": 1, "action": "login", "created_time[#]": "NOW()"},
|
||||
{"user_id": 2, "action": "logout", "created_time[#]": "NOW()"},
|
||||
{"user_id": 1, "action": "login", "created_time": now},
|
||||
{"user_id": 2, "action": "logout", "created_time": now},
|
||||
})
|
||||
```
|
||||
|
||||
@@ -248,9 +262,9 @@ affected := database.Inserts("log", []common.Map{
|
||||
```go
|
||||
// 基本更新
|
||||
affected := database.Update("user", common.Map{
|
||||
"name": "李四",
|
||||
"email": "lisi@example.com",
|
||||
"updated_time[#]": "NOW()",
|
||||
"name": "李四",
|
||||
"email": "lisi@example.com",
|
||||
"modify_time": Time2Str(time.Now()), // 推荐:服务器时间
|
||||
}, common.Map{
|
||||
"id": 1,
|
||||
})
|
||||
@@ -291,6 +305,12 @@ affected := database.Upsert("user",
|
||||
// INSERT INTO "user" (id,name,email,login_count) VALUES ($1,$2,$3,$4)
|
||||
// ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, email=EXCLUDED.email, login_count=EXCLUDED.login_count
|
||||
|
||||
// 达梦 DM8 生成:
|
||||
// MERGE INTO "user" USING (SELECT ? AS "id", ? AS "name", ...) src
|
||||
// ON ("user"."id" = src."id")
|
||||
// WHEN MATCHED THEN UPDATE SET "name"=src."name", "email"=src."email", ...
|
||||
// WHEN NOT MATCHED THEN INSERT ("id","name","email","login_count") VALUES (src."id", ...)
|
||||
|
||||
// 使用 [#] 标记直接 SQL 更新
|
||||
affected := database.Upsert("user",
|
||||
common.Map{
|
||||
@@ -349,8 +369,8 @@ user := database.Table("user").
|
||||
affected := database.Table("user").
|
||||
Where("id", 1).
|
||||
Update(common.Map{
|
||||
"name": "新名称",
|
||||
"updated_time[#]": "NOW()",
|
||||
"name": "新名称",
|
||||
"modify_time": Time2Str(time.Now()),
|
||||
})
|
||||
|
||||
// 链式删除
|
||||
@@ -458,14 +478,14 @@ HoTimeDB支持丰富的条件查询语法,类似于Medoo:
|
||||
### 直接SQL
|
||||
|
||||
```go
|
||||
// 直接插入SQL表达式(注意防注入)
|
||||
"created_time[#]": "> DATE_SUB(NOW(), INTERVAL 1 DAY)"
|
||||
|
||||
// 字段直接赋值(不使用参数化查询)
|
||||
"update_time[#]": "NOW()"
|
||||
|
||||
// 直接SQL片段
|
||||
// 直接SQL片段(通常用于复杂条件,时间推荐用 Go 计算)
|
||||
"[##]": "user.status = 1 AND user.level > 0"
|
||||
|
||||
// 时间范围条件:推荐在 Go 侧计算,避免跨库 NOW() 差异
|
||||
"created_time[>]": Time2Str(time.Now().AddDate(0, 0, -1)) // 一天前
|
||||
|
||||
// 如必须用数据库函数(不跨库时可用,但不推荐)
|
||||
"update_time[#]": "NOW()"
|
||||
```
|
||||
|
||||
## JOIN操作
|
||||
@@ -637,10 +657,10 @@ success := database.Action(func(tx db.HoTimeDB) bool {
|
||||
|
||||
// 创建订单
|
||||
orderId := tx.Insert("order", common.Map{
|
||||
"user_id": 1,
|
||||
"amount": 100,
|
||||
"status": "paid",
|
||||
"created_time[#]": "NOW()",
|
||||
"user_id": 1,
|
||||
"amount": 100,
|
||||
"status": "paid",
|
||||
"created_time": Time2Str(time.Now()),
|
||||
})
|
||||
|
||||
if orderId == 0 {
|
||||
@@ -721,6 +741,182 @@ database.SetConnect(func(err ...*common.Error) (master, slave *sql.DB) {
|
||||
|
||||
所有这些差异由框架自动处理,无需手动调整代码。
|
||||
|
||||
## 达梦数据库支持
|
||||
|
||||
HoTimeDB 原生支持**达梦数据库 DM8**,驱动已内置于 `vendor/gitee.com/chunanyong/dm`,无需额外安装。
|
||||
|
||||
### 达梦配置
|
||||
|
||||
在 `config.json` 中配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"db": {
|
||||
"dm": {
|
||||
"host": "127.0.0.1",
|
||||
"port": "5236",
|
||||
"user": "SYSDBA",
|
||||
"password": "your_password",
|
||||
"name": "TEST",
|
||||
"prefix": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
代码配置方式:
|
||||
|
||||
```go
|
||||
import (
|
||||
"code.hoteas.com/golang/hotime/db"
|
||||
_ "gitee.com/chunanyong/dm" // 达梦驱动(已在 vendor 中)
|
||||
)
|
||||
|
||||
database := &db.HoTimeDB{
|
||||
Type: "dm", // 或 "dameng"
|
||||
Prefix: "", // 表前缀(可选)
|
||||
}
|
||||
|
||||
database.SetConnect(func(err ...*common.Error) (master, slave *sql.DB) {
|
||||
// name 字段即为 schema(默认搜索路径)
|
||||
dsn := "dm://SYSDBA:your_password@127.0.0.1:5236?schema=TEST"
|
||||
master, _ = sql.Open("dm", dsn)
|
||||
return master, master
|
||||
})
|
||||
```
|
||||
|
||||
> **schema 说明**:`?schema=TEST` 将当前会话的默认搜索 schema 设置为 `TEST`。
|
||||
> 所有不带 schema 前缀的表名均在该 schema 下查找和创建。
|
||||
> 建议为应用创建独立的专用 schema,避免直接使用 `SYSDBA` 默认 schema。
|
||||
|
||||
### 与 MySQL/PostgreSQL 的差异对比
|
||||
|
||||
| 特性 | MySQL | PostgreSQL | 达梦 DM8 |
|
||||
|------|-------|------------|---------|
|
||||
| 标识符引号 | \`name\` | "name" | "name" |
|
||||
| 占位符 | ? | $1, $2... | ? |
|
||||
| Upsert | ON DUPLICATE KEY UPDATE | ON CONFLICT DO UPDATE | MERGE INTO ... USING |
|
||||
| 自增列 | AUTO_INCREMENT | SERIAL | IDENTITY(1,1) |
|
||||
| 文本类型 | TEXT | TEXT | TEXT / CLOB |
|
||||
| 时间赋值(推荐) | `Time2Str(time.Now())` | `Time2Str(time.Now())` | `Time2Str(time.Now())` |
|
||||
| 时间函数(不推荐) | `NOW()` | `NOW()` | `NOW()` |
|
||||
| 分页语法 | LIMIT m, n | LIMIT n OFFSET m | LIMIT m, n 或 LIMIT n OFFSET m |
|
||||
|
||||
所有这些差异由框架自动处理,业务代码无需修改。
|
||||
|
||||
### Upsert 在达梦中的行为
|
||||
|
||||
达梦不支持 MySQL 的 `ON DUPLICATE KEY UPDATE` 和 PostgreSQL 的 `ON CONFLICT`,框架会自动生成 `MERGE INTO` 语句:
|
||||
|
||||
```go
|
||||
// 调用方式与其他数据库完全一致
|
||||
affected := database.Upsert("admin",
|
||||
common.Map{
|
||||
"name": "张三",
|
||||
"phone": "13800000001",
|
||||
"password": "abc123",
|
||||
"role_id": 1,
|
||||
},
|
||||
common.Slice{"phone"}, // 唯一键(冲突检测)
|
||||
common.Slice{"name", "password", "role_id"}, // 冲突时更新的字段
|
||||
)
|
||||
|
||||
// 达梦自动生成:
|
||||
// MERGE INTO "admin" USING (SELECT ? AS "name", ? AS "phone", ...) src
|
||||
// ON ("admin"."phone" = src."phone")
|
||||
// WHEN MATCHED THEN UPDATE SET "name" = src."name", ...
|
||||
// WHEN NOT MATCHED THEN INSERT (...) VALUES (src....)
|
||||
```
|
||||
|
||||
### 达梦注意事项
|
||||
|
||||
#### 1. 标识符大小写敏感
|
||||
|
||||
达梦对双引号包裹的标识符大小写敏感。框架统一使用**小写**创建和访问表/列,确保一致性:
|
||||
|
||||
```sql
|
||||
-- ✅ 正确:框架自动生成,小写标识符
|
||||
SELECT * FROM "admin" WHERE "id" = ?
|
||||
|
||||
-- ❌ 错误:大小写不一致会导致 "无效的表或视图名" 错误
|
||||
SELECT * FROM "ADMIN" WHERE "ID" = ?
|
||||
```
|
||||
|
||||
> 使用 DM 管理工具手动创建表时,建议使用双引号包裹小写名称,与框架保持一致。
|
||||
|
||||
#### 2. 保留字冲突
|
||||
|
||||
达梦有大量保留字(如 `admin`、`user`、`order`、`key`、`value` 等),直接使用这些名称作为表名或列名会报语法错误。
|
||||
**框架已自动为所有标识符加双引号**,一般情况下无需手动处理。
|
||||
|
||||
如果在原生 SQL(`Query`/`Exec`)中使用保留字,需要手动加引号:
|
||||
|
||||
```go
|
||||
// 原生 SQL 中需要手动加双引号
|
||||
results := database.Query(`SELECT "id", "name" FROM "admin" WHERE "state" = ?`, 1)
|
||||
```
|
||||
|
||||
#### 3. schema 与 USER_TABLES 的关系
|
||||
|
||||
连接达梦时使用 `?schema=TEST`,DM 的 `USER_TABLES` 视图只显示当前**登录用户**(如 SYSDBA)所属 schema 的表,而非当前会话 schema 的表。
|
||||
|
||||
框架内部已改用 `COUNT(*)` 方式直接探查表可访问性,避免 schema 错配问题。
|
||||
|
||||
如果在自定义代码中检测表是否存在,**不要用 USER_TABLES**,改用:
|
||||
|
||||
```go
|
||||
// ✅ 推荐:直接探查表可访问性
|
||||
res := database.Query(`SELECT COUNT(*) as cnt FROM "your_table"`)
|
||||
exists := len(res) > 0
|
||||
|
||||
// ❌ 不推荐:USER_TABLES 可能显示当前会话 schema 之外的表
|
||||
res := database.Query(`SELECT TABLE_NAME FROM USER_TABLES WHERE TABLE_NAME='your_table'`)
|
||||
```
|
||||
|
||||
#### 4. DDL 建表建议
|
||||
|
||||
手动建表时,列定义与 MySQL 的区别:
|
||||
|
||||
```sql
|
||||
-- 达梦建表示例
|
||||
CREATE TABLE "admin" (
|
||||
"id" INT IDENTITY(1,1) PRIMARY KEY, -- 自增(MySQL: AUTO_INCREMENT)
|
||||
"name" VARCHAR(100),
|
||||
"phone" VARCHAR(20),
|
||||
"content" CLOB, -- 长文本(MySQL: TEXT/LONGTEXT)
|
||||
"create_time" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
"modify_time" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "uk_admin_phone" UNIQUE ("phone")
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX "idx_admin_state" ON "admin" ("state");
|
||||
```
|
||||
|
||||
#### 5. 日期时间函数差异
|
||||
|
||||
部分日期函数在达梦中语法不同,使用 `[##]` 直接SQL时需注意:
|
||||
|
||||
| 功能 | 推荐(Go侧) | MySQL `[##]`/原生SQL | 达梦 DM8 `[##]`/原生SQL |
|
||||
|------|------------|---------------------|----------------------|
|
||||
| 当前时间赋值 | `Time2Str(time.Now())` ✅ | `NOW()` | `NOW()` |
|
||||
| 一天前(时间范围查询) | `Time2Str(time.Now().AddDate(0,0,-1))` ✅ | `DATE_SUB(NOW(), INTERVAL 1 DAY)` | `DATEADD(DAY, -1, NOW())` |
|
||||
| 时间戳转字符串 | `Time2Str(time.Unix(ts, 0))` ✅ | `FROM_UNIXTIME(ts)` | 不支持,需 Go 侧处理 |
|
||||
|
||||
> **强烈推荐**:时间字段赋值和时间范围计算都在 Go 侧完成(使用 `Time2Str`、`time.Now().Add()` 等),传入参数化的字符串值。这样完全规避数据库时区设置差异,也无需关心各数据库函数的语法不同。
|
||||
|
||||
#### 6. 使用 IN 查询时的注意事项
|
||||
|
||||
IN 查询在达梦中与 MySQL 行为一致,框架已统一处理:
|
||||
|
||||
```go
|
||||
// 正常使用,框架自动展开
|
||||
results := database.Select("article", "*", common.Map{
|
||||
"id": []int{1, 2, 3},
|
||||
})
|
||||
// 生成: WHERE "id" IN (?, ?, ?)
|
||||
```
|
||||
|
||||
## 高级特性
|
||||
|
||||
### 调试模式
|
||||
@@ -758,8 +954,8 @@ database.SetConnect(createConnection)
|
||||
// 执行查询SQL
|
||||
results := database.Query("SELECT * FROM user WHERE age > ? AND status = ?", 18, 1)
|
||||
|
||||
// 执行更新SQL
|
||||
result, err := database.Exec("UPDATE user SET last_login = NOW() WHERE id = ?", 1)
|
||||
// 执行更新SQL(原生 SQL 中也建议使用服务器时间)
|
||||
result, err := database.Exec("UPDATE user SET last_login = ? WHERE id = ?", Time2Str(time.Now()), 1)
|
||||
if err.GetError() == nil {
|
||||
affected, _ := result.RowsAffected()
|
||||
fmt.Println("影响行数:", affected)
|
||||
@@ -783,7 +979,7 @@ if err.GetError() == nil {
|
||||
| `[~~]` | 手动LIKE | `"name[~~]": "%张%"` | `name LIKE '%张%'` |
|
||||
| `[<>]` | BETWEEN | `"age[<>]": [18,60]` | `age BETWEEN 18 AND 60` |
|
||||
| `[><]` | NOT BETWEEN | `"age[><]": [18,25]` | `age NOT BETWEEN 18 AND 25` |
|
||||
| `[#]` | 直接SQL | `"time[#]": "NOW()"` | `time = NOW()` |
|
||||
| `[#]` | 直接SQL表达式 | `"balance[#]": "balance + 1"` | `balance = balance + 1` |
|
||||
| `[##]` | SQL片段 | `"[##]": "a > b"` | `a > b` |
|
||||
| `[#!]` | 不等于直接SQL | `"status[#!]": "1"` | `status != 1` |
|
||||
| `[!#]` | 不等于直接SQL | `"status[!#]": "1"` | `status != 1` |
|
||||
@@ -822,12 +1018,28 @@ A6: 使用 `nil` 作为值,查询时使用 `"field": nil` 表示 `IS NULL`。
|
||||
### Q7: PostgreSQL 和 MySQL 语法有区别吗?
|
||||
A7: 框架会自动处理差异(占位符、引号等),代码无需修改。
|
||||
|
||||
### Q8: 达梦数据库和 MySQL 的代码使用方式一样吗?
|
||||
A8: 基本一样。切换数据库类型只需修改 `Type` 字段和连接字符串,业务查询代码完全不用改。唯一需要注意的是:
|
||||
- 原生 SQL(`Query`/`Exec`)中的标识符需要手动加双引号
|
||||
- `MERGE INTO` 等 DM 特有 DDL 需要按 DM 语法书写
|
||||
- 检测表是否存在时,不要用 `USER_TABLES`,改用直接 `COUNT(*)` 查询
|
||||
|
||||
### Q9: 表名或列名用了达梦保留字怎么办?
|
||||
A9: 框架所有自动生成的 SQL 都会对标识符加双引号,无需手动处理。但在原生 SQL 中使用保留字(如 `admin`、`user`、`order`)时,必须手动加双引号,否则会报语法错误。
|
||||
|
||||
### Q10: 达梦数据库中 INSERT 后如何获取自增 ID?
|
||||
A10: 框架的 `Insert` 方法已支持从达梦原生驱动获取 `LastInsertId`,与 MySQL 使用方式完全一致:
|
||||
```go
|
||||
id := database.Insert("user", common.Map{"name": "张三"})
|
||||
fmt.Println("新插入的ID:", id)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*文档版本: 2.0*
|
||||
*最后更新: 2026年1月*
|
||||
*文档版本: 2.1*
|
||||
*最后更新: 2026年3月*
|
||||
|
||||
> 本文档基于HoTimeDB源码分析生成,如有疑问请参考源码实现。该ORM框架参考了PHP Medoo的设计理念,但根据Golang语言特性进行了适配和优化。
|
||||
> 本文档基于HoTimeDB源码分析生成,如有疑问请参考源码实现。该ORM框架参考了PHP Medoo的设计理念,根据Golang语言特性进行了适配和优化,并新增了对达梦(DM8)国产数据库的完整支持。
|
||||
|
||||
**更多参考:**
|
||||
- [HoTimeDB API 参考](HoTimeDB_API参考.md) - API 速查手册
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# HoTime 快速上手指南
|
||||
|
||||
5 分钟入门 HoTime 框架。
|
||||
5 分钟入门 HoTime 框架。完整文档索引见 [README.md](README.md)。
|
||||
|
||||
## 安装
|
||||
|
||||
@@ -86,6 +86,8 @@ func main() {
|
||||
|
||||
### 数据库配置
|
||||
|
||||
支持的数据库类型:`mysql`、`sqlite`、`postgres`(PostgreSQL)、`dm`(达梦 DM8)。
|
||||
|
||||
```json
|
||||
{
|
||||
"db": {
|
||||
@@ -107,12 +109,21 @@ func main() {
|
||||
"sqlite": {
|
||||
"path": "config/data.db",
|
||||
"prefix": ""
|
||||
},
|
||||
"dm": {
|
||||
"host": "127.0.0.1",
|
||||
"port": "5236",
|
||||
"name": "TEST",
|
||||
"user": "SYSDBA",
|
||||
"password": "your_password",
|
||||
"prefix": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> MySQL 配置 `slave` 项即启用主从读写分离
|
||||
> MySQL 配置 `slave` 项即启用主从读写分离
|
||||
> 达梦 DM8 的 `name` 字段对应 schema(搜索路径),驱动已内置于 `vendor/gitee.com/chunanyong/dm`,无需额外安装
|
||||
|
||||
### 缓存配置
|
||||
|
||||
@@ -135,7 +146,9 @@ func main() {
|
||||
"db": {
|
||||
"db": true,
|
||||
"session": true,
|
||||
"timeout": 2592000
|
||||
"timeout": 2592000,
|
||||
"history": false,
|
||||
"mode": "compatible"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,6 +156,27 @@ func main() {
|
||||
|
||||
缓存优先级: **Memory > Redis > DB**,自动穿透与回填
|
||||
|
||||
#### DB 缓存配置说明
|
||||
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
|--------|--------|------|
|
||||
| `db` | false | 是否缓存数据库查询 |
|
||||
| `session` | true | 是否缓存 Session |
|
||||
| `timeout` | 2592000 | 过期时间(秒) |
|
||||
| `history` | false | 是否记录缓存历史,开启后每次新增/修改缓存都会记录到历史表 |
|
||||
| `mode` | compatible | 缓存表模式,见下表 |
|
||||
|
||||
**缓存表模式 (mode)**:
|
||||
|
||||
| 模式 | 说明 |
|
||||
|------|------|
|
||||
| `compatible` | **默认**。兼容模式:写新表,新表无数据时回退读老表;写入时自动删除老表同 key 记录;删除时同时删两表。适合从老版本平滑升级,老数据自然过期消亡 |
|
||||
| `new` | 只使用新表 `hotime_cache`,启动时自动迁移老表 `cached` 数据。老表保留由人工删除,不再被读写 |
|
||||
|
||||
> **升级建议**:从老版本升级时,建议先使用 `compatible` 模式运行一段时间(让老数据自然过期),确认无问题后再切换到 `new` 模式
|
||||
|
||||
> 从老版本升级时,建议使用 `compatible` 模式平滑过渡,待老表数据消亡后切换到 `new` 模式
|
||||
|
||||
### 错误码配置
|
||||
|
||||
```json
|
||||
@@ -342,83 +376,79 @@ 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**
|
||||
|
||||
## 数据库操作(简要)
|
||||
|
||||
### 基础 CRUD
|
||||
|
||||
```go
|
||||
// 查询列表
|
||||
users := that.Db.Select("user", "*", Map{"status": 1})
|
||||
|
||||
// 查询单条
|
||||
user := that.Db.Get("user", "*", Map{"id": 1})
|
||||
|
||||
// 插入
|
||||
id := that.Db.Insert("user", Map{"name": "test", "age": 18})
|
||||
that.Db.Update("user", Map{"name": "new"}, Map{"id": 1})
|
||||
that.Db.Delete("user", Map{"id": 1})
|
||||
|
||||
// 批量插入
|
||||
affected := that.Db.Inserts("user", []Map{
|
||||
{"name": "user1", "age": 20},
|
||||
{"name": "user2", "age": 25},
|
||||
})
|
||||
|
||||
// 更新
|
||||
rows := that.Db.Update("user", Map{"name": "new"}, Map{"id": 1})
|
||||
|
||||
// 删除
|
||||
rows := that.Db.Delete("user", Map{"id": 1})
|
||||
users = that.Db.Table("user").
|
||||
Where("status", 1).And("age[>]", 18).
|
||||
Order("id DESC").Page(1, 10).Select("*")
|
||||
```
|
||||
|
||||
### 链式查询
|
||||
条件运算符(`[>]` / `[~]` / IN 等)、事务、缓存与方言差异见:
|
||||
|
||||
```go
|
||||
users := that.Db.Table("user").
|
||||
LeftJoin("order", "user.id=order.user_id").
|
||||
Where("status", 1).
|
||||
And("age[>]", 18).
|
||||
Order("id DESC").
|
||||
Page(1, 10).
|
||||
Select("*")
|
||||
```
|
||||
- 教程:[HoTimeDB 使用说明](HoTimeDB_使用说明.md)
|
||||
- 速查:[HoTimeDB API 参考](HoTimeDB_API参考.md)
|
||||
|
||||
### 条件语法速查
|
||||
## 日志:业务 Log 表 vs Seq
|
||||
|
||||
| 语法 | 说明 | 示例 |
|
||||
| 能力 | 用途 | 文档 |
|
||||
|------|------|------|
|
||||
| `key` | 等于 | `"id": 1` |
|
||||
| `key[>]` | 大于 | `"age[>]": 18` |
|
||||
| `key[<]` | 小于 | `"age[<]": 60` |
|
||||
| `key[>=]` | 大于等于 | `"age[>=]": 18` |
|
||||
| `key[<=]` | 小于等于 | `"age[<=]": 60` |
|
||||
| `key[!]` | 不等于 | `"status[!]": 0` |
|
||||
| `key[~]` | LIKE | `"name[~]": "test"` |
|
||||
| `key[<>]` | BETWEEN | `"age[<>]": Slice{18, 60}` |
|
||||
| `key` | IN | `"id": Slice{1, 2, 3}` |
|
||||
|
||||
### 事务
|
||||
| `that.Log = Map{...}` | 写入业务 `logs` 表(操作审计) | 见下方示例 |
|
||||
| `seqUrl` + zerolog | 结构化日志推送到 Seq 集中搜索 | [Seq 日志集成](Seq_日志集成.md) |
|
||||
|
||||
```go
|
||||
success := that.Db.Action(func(tx db.HoTimeDB) bool {
|
||||
tx.Update("user", Map{"balance[#]": "balance - 100"}, Map{"id": 1})
|
||||
tx.Insert("order", Map{"user_id": 1, "amount": 100})
|
||||
return true // 返回 true 提交,false 回滚
|
||||
})
|
||||
```
|
||||
|
||||
> **更多数据库操作**:参见 [HoTimeDB 使用说明](HoTimeDB_使用说明.md)
|
||||
|
||||
## 日志记录
|
||||
|
||||
```go
|
||||
// 创建操作日志(自动插入 logs 表)
|
||||
// 业务操作日志(自动插入 logs 表;框架会补 time、admin_id/user_id、ip 等)
|
||||
that.Log = Map{
|
||||
"type": "login",
|
||||
"action": "用户登录",
|
||||
"data": Map{"phone": phone},
|
||||
}
|
||||
// 框架会自动添加 time, admin_id/user_id, ip 等字段
|
||||
```
|
||||
|
||||
## 扩展功能
|
||||
@@ -439,6 +469,8 @@ that.Log = Map{
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
@@ -481,14 +513,25 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
that.Session("user_id", user.GetInt64("id"))
|
||||
// 使用批量设置,一次写入多个字段
|
||||
that.SessionsSet(Map{
|
||||
"user_id": user.GetInt64("id"),
|
||||
"username": user.GetString("name"),
|
||||
"login_time": time.Now().Unix(),
|
||||
})
|
||||
that.Display(0, Map{"user": user})
|
||||
},
|
||||
|
||||
"info": func(that *Context) {
|
||||
userId := that.Session("user_id").ToInt64()
|
||||
// 使用批量获取,一次读取多个字段
|
||||
sess := that.SessionsGet("user_id", "username", "login_time")
|
||||
userId := ObjToInt64(sess["user_id"], nil)
|
||||
|
||||
user := that.Db.Get("user", "*", Map{"id": userId})
|
||||
that.Display(0, Map{"user": user})
|
||||
that.Display(0, Map{
|
||||
"user": user,
|
||||
"login_time": sess["login_time"],
|
||||
})
|
||||
},
|
||||
|
||||
"list": func(that *Context) {
|
||||
@@ -513,7 +556,8 @@ func main() {
|
||||
},
|
||||
|
||||
"logout": func(that *Context) {
|
||||
that.Session("user_id", nil)
|
||||
// 使用批量删除,一次清除多个字段
|
||||
that.SessionsDelete("user_id", "username", "login_time")
|
||||
that.Display(0, "退出成功")
|
||||
},
|
||||
},
|
||||
@@ -525,5 +569,8 @@ func main() {
|
||||
---
|
||||
|
||||
**下一步**:
|
||||
- [HoTimeDB 使用说明](HoTimeDB_使用说明.md) - 完整数据库教程
|
||||
- [HoTimeDB API 参考](HoTimeDB_API参考.md) - API 速查手册
|
||||
- [HoTimeDB 使用说明](HoTimeDB_使用说明.md) — ORM 教程
|
||||
- [HoTimeDB API 参考](HoTimeDB_API参考.md) — API 速查
|
||||
- [Common 工具类](Common_工具类.md) — Map/Slice/Obj
|
||||
- [API 测试框架](Testing_API测试框架.md) — 接口测试(优先 `-run` 单测)
|
||||
- [文档索引](README.md) — 完整阅读路径
|
||||
@@ -0,0 +1,36 @@
|
||||
# HoTime 文档索引
|
||||
|
||||
本仓采用 **Doc-Driven + TDD**:行为一变必须回写 docs;改接口必须用 `-run` 精确测试验证(见 [Testing_API测试框架.md](Testing_API测试框架.md) 文首铁律与仓规则 `hotime-doc-tdd`)。
|
||||
|
||||
推荐按路径阅读,避免同一概念在多份文档里来回找。
|
||||
|
||||
## 阅读路径
|
||||
|
||||
1. **入门** → [QuickStart_快速上手.md](QuickStart_快速上手.md)
|
||||
2. **类型与工具** → [Common_工具类.md](Common_工具类.md)
|
||||
3. **数据库 ORM** → [HoTimeDB_使用说明.md](HoTimeDB_使用说明.md)(教程)→ [HoTimeDB_API参考.md](HoTimeDB_API参考.md)(速查)
|
||||
4. **开表 / 代码生成** → [DatabaseDesign_数据库设计规范.md](DatabaseDesign_数据库设计规范.md)(前置)→ [CodeGen_代码生成.md](CodeGen_代码生成.md)
|
||||
5. **接口测试** → [Testing_API测试框架.md](Testing_API测试框架.md)(日常用 `-run` 单测;局限见文末;全量见文末)
|
||||
6. **运维** → [GracefulShutdown_优雅停机.md](GracefulShutdown_优雅停机.md)、[Seq_日志集成.md](Seq_日志集成.md)
|
||||
7. **规划** → [ROADMAP_改进规划.md](ROADMAP_改进规划.md)
|
||||
|
||||
## 文档一览
|
||||
|
||||
| 文档 | 职责 |
|
||||
|------|------|
|
||||
| QuickStart_快速上手 | 安装、配置、路由、中间件、最小示例 |
|
||||
| Common_工具类 | Map / Slice / Obj 与工具函数 |
|
||||
| HoTimeDB_使用说明 | ORM 完整教程 |
|
||||
| HoTimeDB_API参考 | ORM 方法与条件语法速查 |
|
||||
| DatabaseDesign_数据库设计规范 | 表/字段/COMMENT 规范(CodeGen 输入契约) |
|
||||
| CodeGen_代码生成 | 生成器用法 + codeConfig / rule 配置 |
|
||||
| Testing_API测试框架 | API 测试、Flows、控制台;优先 `-run`;已知局限 |
|
||||
| GracefulShutdown_优雅停机 | 优雅停机与滚动重启 |
|
||||
| Seq_日志集成 | Seq 结构化日志 |
|
||||
| ROADMAP_改进规划 | 待改进项 |
|
||||
|
||||
## 易混淆点
|
||||
|
||||
- **业务 `that.Log`**:写 `logs` 表;**Seq**:集中式结构化日志,见 Seq 文档。
|
||||
- **HoTimeDB vs DatabaseDesign**:前者是运行时查库;后者是建表给代码生成用。
|
||||
- **测试**:开发阶段不要默认 `go test ./app/...` 全量,见 Testing 文首铁律。
|
||||
@@ -1,5 +1,7 @@
|
||||
# HoTime 改进规划
|
||||
|
||||
> 相关规范:[DatabaseDesign](DatabaseDesign_数据库设计规范.md) · [CodeGen](CodeGen_代码生成.md) · [Testing](Testing_API测试框架.md) · [文档索引](README.md)
|
||||
|
||||
本文档记录 HoTime 框架的待改进项和设计思考,供后续版本迭代参考。
|
||||
|
||||
---
|
||||
@@ -165,11 +167,106 @@ CREATE TABLE `_operation_log` (
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 七、测试框架:响应字段断言(部分匹配)
|
||||
|
||||
### 现状
|
||||
|
||||
`Post` / `Get` 等终端方法目前只支持断言响应体中的 `status` 和 `msg` 字段:
|
||||
|
||||
```go
|
||||
a.JSON(Map{"phone": phone, "password": "123456"}).Post("正常登录", 0)
|
||||
a.JSON(Map{"phone": phone, "password": "123456"}).Post("密码为空", 4, "用户名或密码不能为空")
|
||||
```
|
||||
|
||||
如需验证响应 `result` 内部的具体字段,只能手动写:
|
||||
|
||||
```go
|
||||
resp := a.JSON(Map{...}).Post("查询列表", 0)
|
||||
if resp.GetBody().GetMap("result").GetInt("total") != 10 {
|
||||
resp.Fail("total 应为 10")
|
||||
}
|
||||
```
|
||||
|
||||
这种写法冗长、可读性差,且失败时无法显示期望值与实际值的对比。
|
||||
|
||||
### 改进方向
|
||||
|
||||
在终端方法签名中增加**可选的响应字段部分匹配参数**:
|
||||
|
||||
```go
|
||||
// 现有签名(不变)
|
||||
func (c *ApiCase) Post(desc string, status int, msg ...string) *ApiResponse
|
||||
|
||||
// 新增重载或扩展参数,支持传入期望的 result 字段(Map 类型)
|
||||
a.JSON(Map{...}).Post("查询列表", 0, Map{"total": 10})
|
||||
a.JSON(Map{...}).Post("查询列表", 0, Map{"total": 10, "list": notEmpty})
|
||||
a.JSON(Map{...}).Post("正常登录", 0, Map{"token": notEmpty})
|
||||
```
|
||||
|
||||
### 核心设计原则
|
||||
|
||||
**部分匹配,而非精确匹配**:
|
||||
|
||||
- 只验证期望 Map 中列出的字段,响应中额外的字段不报错
|
||||
- 避免因响应新增字段导致测试频繁失败,降低维护成本
|
||||
- 精确匹配适合快照测试场景,但与"快速精准"目标相悖,不引入
|
||||
|
||||
### 支持的断言值类型
|
||||
|
||||
| 断言值 | 含义 | 示例 |
|
||||
|--------|------|------|
|
||||
| 具体值(string/int/bool) | 字段等于该值 | `Map{"total": 10}` |
|
||||
| `notEmpty`(特殊常量) | 字段存在且非空/非零 | `Map{"token": notEmpty}` |
|
||||
| 嵌套 Map | 递归部分匹配子对象 | `Map{"user": Map{"id": 1}}` |
|
||||
|
||||
### 预期写法
|
||||
|
||||
```go
|
||||
// 断言 total 值
|
||||
a.Query(Map{"page": "1"}).Get("查询列表", 0, Map{"total": 5})
|
||||
|
||||
// 断言 token 字段存在且非空(不关心具体值)
|
||||
resp := a.JSON(Map{"phone": "138...", "password": "123456"}).Post("正常登录", 0, Map{"token": notEmpty})
|
||||
|
||||
// 组合:status + msg + result 字段三合一
|
||||
a.JSON(Map{"phone": ""}).Post("手机号为空", 4, "手机号不能为空") // 只验 status+msg
|
||||
a.JSON(Map{"phone": "138..."}).Post("正常注册", 0, Map{"id": notEmpty}) // 验 status + result.id
|
||||
```
|
||||
|
||||
### 失败输出格式
|
||||
|
||||
失败时应显示期望值和实际值对比,便于快速定位:
|
||||
|
||||
```
|
||||
--- FAIL: TestApi/app/user/login/正常登录
|
||||
期望 result.token 非空,实际值为 ""
|
||||
期望 result.total = 10,实际值为 0
|
||||
```
|
||||
|
||||
### 实现要点
|
||||
|
||||
- 参数类型可设计为 `...interface{}`,兼容现有 `msg ...string` 的调用方式,通过类型断言区分
|
||||
- `notEmpty` 可定义为框架内的特殊常量(如 `var notEmpty = struct{}{}`)
|
||||
- 断言逻辑复用 `ApiResponse` 已有的 `GetBody()` 链路,递归遍历期望 Map
|
||||
|
||||
### 待确认
|
||||
|
||||
- [ ] 终端方法签名如何兼容:新增第四参数 `result Map`,还是利用 `...interface{}` 统一
|
||||
- [ ] `notEmpty` 常量的导出名称和定义位置
|
||||
- [ ] 嵌套 Map 递归断言是否纳入首批实现范围
|
||||
- [ ] 失败输出是否需要完整响应体 dump
|
||||
|
||||
---
|
||||
|
||||
## 改进优先级
|
||||
|
||||
| 优先级 | 改进项 | 状态 |
|
||||
|--------|--------|------|
|
||||
| 高 | 备注语法优化(`\|` 分隔符) | 待设计 |
|
||||
| 高 | 测试框架:响应字段部分匹配断言 | 待实现 |
|
||||
| 高 | 测试框架其它缺口(DB 断言、FromCase 无模板失败、Header API 等) | 见 [Testing 已知局限](Testing_API测试框架.md#已知局限与后续) |
|
||||
| 中 | 软删除/日志表支持 | 待设计 |
|
||||
| 低 | 多对多关联表增强 | 待评估 |
|
||||
| 低 | 自动填充字段扩展 | 待评估 |
|
||||
@@ -180,4 +277,7 @@ CREATE TABLE `_operation_log` (
|
||||
|
||||
| 日期 | 内容 |
|
||||
|------|------|
|
||||
| 2026-07-13 | 测试框架文档补齐 Flows/控制台;缺口清单链到 Testing |
|
||||
| 2026-01-24 | 初始版本,记录分析结果和待改进项 |
|
||||
| 2026-03-15 | 新增测试框架响应字段断言改进规划(七) |
|
||||
| 2026-03-20 | 完成达梦(DM8)数据库完整支持:Dialect 适配(MERGE INTO Upsert、双引号标识符)、LastInsertId、保留字自动引号、schema 正确处理、代码生成器 DM 兼容、缓存表自动建表、测试框架 DM 适配 |
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
# 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`),无需手动填写,同机多进程靠端口区分,跨服务器靠 IP 区分,支持未来集群扩展。
|
||||
|
||||
---
|
||||
|
||||
## 架构原理
|
||||
|
||||
```
|
||||
业务代码
|
||||
│ l.Info().Msg("...") ← HoTime Logger 正常调用路径
|
||||
│ fmt.Println("...") ← 被 redirectStdout 捕获后转入同一路径
|
||||
↓
|
||||
multiWriter(hotimev1.5/log/logger.go)
|
||||
├─ ConsoleWriter → 彩色终端输出(不变)
|
||||
├─ FileWriter → 本地日志文件(按需,logFile 配置)
|
||||
└─ SeqWriter
|
||||
│ Write() 只做 channel <- bytes,O(1) 非阻塞
|
||||
↓
|
||||
channel(容量 10000)
|
||||
↓ 后台 goroutine
|
||||
批量打包(100 条 或 500ms)
|
||||
↓ HTTP POST
|
||||
Seq 服务(CLEF 格式)
|
||||
```
|
||||
|
||||
**关键特性:**
|
||||
- `SeqWriter.Write()` 仅向 channel 投递字节即返回,**绝不阻塞** web 请求处理 goroutine
|
||||
- channel 满时(Seq 宕机/网络故障)新日志被丢弃并计数,主服务完全不受影响
|
||||
- HTTP POST 设 5s 超时,失败仅打印到 stderr
|
||||
|
||||
---
|
||||
|
||||
## 字段映射(zerolog → CLEF)
|
||||
|
||||
| zerolog 字段 | Seq CLEF 字段 | 说明 |
|
||||
|---|---|---|
|
||||
| `time` | `@t` | 时间,自动转 ISO 8601 格式 |
|
||||
| `level` | `@l` | 级别,映射为 Debug/Information/Warning/Error/Fatal |
|
||||
| `message` / `msg` | `@mt` | 消息正文 |
|
||||
| `caller` | `caller` | 调用位置,原样保留 |
|
||||
| 其余自定义字段 | 原字段名 | 原样保留,可在 Seq 中直接查询 |
|
||||
| — | `instance` | 框架自动注入,值为 `port` 配置(如 `"8085"`) |
|
||||
| — | `source` | fmt.Println 等捕获的输出标记为 `stdout` |
|
||||
|
||||
---
|
||||
|
||||
## 单机多进程实例区分
|
||||
|
||||
框架启动时自动获取本机出口 IP,拼接为 `ip:port` 格式作为 `instance`:
|
||||
|
||||
```
|
||||
单机多进程:
|
||||
192.168.1.10:8085 ─┐
|
||||
192.168.1.10:8086 ─┼─ HTTP CLEF ──→ Seq
|
||||
192.168.1.10:8087 ─┘
|
||||
|
||||
多服务器集群:
|
||||
192.168.1.10:8085 ─┐
|
||||
192.168.1.11:8085 ─┼─ HTTP CLEF ──→ Seq(中央日志服务器)
|
||||
192.168.1.12:8085 ─┘
|
||||
```
|
||||
|
||||
Seq 中按实例筛选:
|
||||
- 单台机器所有进程:`instance like '192.168.1.10%'`
|
||||
- 精确到某个进程:`instance = '192.168.1.10:8085'`
|
||||
|
||||
---
|
||||
|
||||
## stdout / stderr 全量捕获
|
||||
|
||||
`SetConfig()` 中自动 `redirectStdout` + `redirectStderr`,并桥接 **logrus**(微信 SDK)到同一管道。
|
||||
|
||||
| 写法 | Seq 字段 | 说明 |
|
||||
|------|----------|------|
|
||||
| `fmt.Println` / `log.Println` | `source=stdout` | **不受 `logLevel` 影响**,`logLevel=0` 也会进 Seq |
|
||||
| 写 `os.Stderr` 的包 | `source=stderr` | 同上,Error 级别 |
|
||||
| `logrus.Info`(wechat) | `source=stdout` | redirect 后 `logrus.SetOutput` 桥接 |
|
||||
| `that.Log.Info/Error...` | 结构化字段 | 经 `multiWriter` → SeqWriter |
|
||||
| MySQL driver 内部错误 | `source=mysql-driver` | `SetLogger` 适配器 |
|
||||
| 框架 `recover` 到的 panic | `source=panic` + `stack` | 代码层原因与调用栈 |
|
||||
|
||||
单行日志上限约 **10MB**(适配 `GetReqMap` 打整包 body);超长会截断并标注 `...(truncated)`。
|
||||
|
||||
**已知不进 Seq(文档边界):**
|
||||
|
||||
- 未 `recover`、进程直接崩溃的 runtime 栈(写 fd2,未做 Dup2)
|
||||
- 达梦驱动自有文件日志(vendor 独立写盘)
|
||||
- `seqUrl` 挂上之前的极早期引导日志
|
||||
|
||||
---
|
||||
|
||||
## 捕获矩阵速查
|
||||
|
||||
| 来源 | 进 Seq? | 检索示例 |
|
||||
|------|----------|----------|
|
||||
| `that.Log.*` | 是 | `@mt like '%关键词%'` |
|
||||
| `log.Println` / `fmt.Println` | 是 | `source = 'stdout'` |
|
||||
| logrus / 标准 `log` | 是 | `source = 'stdout'` |
|
||||
| stderr 重定向 | 是 | `source = 'stderr'` |
|
||||
| recover panic | 是 | `source = 'panic'` |
|
||||
| MySQL driver | 是 | `source = 'mysql-driver'` |
|
||||
| 队列满丢弃 | 否(计数) | 终端可见 `[seq] queue full` |
|
||||
|
||||
---
|
||||
|
||||
## Seq 安装
|
||||
|
||||
Seq 提供 Windows MSI 安装包和 Docker 镜像,单机免费,无外部数据库依赖:
|
||||
|
||||
- Windows:[https://datalust.co/download/seq](https://datalust.co/download/seq),安装后自动注册为 Windows 服务
|
||||
- Docker:`docker run -d --restart always --name seq -p 5341:80 -e ACCEPT_EULA=Y datalust/seq`
|
||||
- 访问 `http://localhost:5341` 使用 Web UI
|
||||
|
||||
---
|
||||
|
||||
## 搜索语法速查
|
||||
|
||||
| 目标 | 查询语句 |
|
||||
|---|---|
|
||||
| 关键词搜索 | 直接输入,如 `支付失败` |
|
||||
| 日志级别 | `@l = 'Error'` |
|
||||
| 特定实例 | `instance = '8085'` |
|
||||
| stdout 来源 | `source = 'stdout'` |
|
||||
| panic 恢复 | `source = 'panic'` |
|
||||
| 请求体调试 | `请求参数GetReqMap` 或 `source = 'stdout'` |
|
||||
| 调用位置 | `caller like '%order.go%'` |
|
||||
| 组合查询 | `@l = 'Error' and instance = '8086' and @mt like '%超时%'` |
|
||||
| 日期范围 | 右上角时间选择器,支持精确到秒 |
|
||||
|
||||
---
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [QuickStart_快速上手.md](QuickStart_快速上手.md) — 其中的 `that.Log` 是业务 logs 表,与本文 Seq 推送不同
|
||||
- [GracefulShutdown_优雅停机.md](GracefulShutdown_优雅停机.md)
|
||||
- [文档索引](README.md)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,111 @@
|
||||
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("..").
|
||||
Swagger("My API", "1.0.0").
|
||||
Proj(TestProj{
|
||||
"app": {
|
||||
Proj: Project,
|
||||
Tests: ProjectTest,
|
||||
},
|
||||
}).
|
||||
Flows(DemoFlows)
|
||||
|
||||
// 仅本 example:空库时灌演示数据。须连专用测试库,勿对生产库调用。
|
||||
SetupDatabase(&testApp.Db)
|
||||
|
||||
os.Exit(testApp.Run(m))
|
||||
}
|
||||
|
||||
func TestApi(t *testing.T) {
|
||||
testApp.RunTests(t)
|
||||
}
|
||||
|
||||
// TestInterruptStop 模拟提前中断:string_result 跑完后 RequestStop,zzz_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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
// CacheCtr 缓存测试控制器(数据库无关)
|
||||
var CacheCtr = Ctr{
|
||||
"all": func(that *Context) { that.Display(0, testCacheAll(that)) },
|
||||
"compat": func(that *Context) { that.Display(0, testCacheCompatible(that)) },
|
||||
"batch": func(that *Context) {
|
||||
that.Display(0, Map{"message": "批量缓存测试完成,请查看控制台输出和日志文件"})
|
||||
},
|
||||
}
|
||||
|
||||
func testCacheAll(that *Context) Map {
|
||||
result := Map{"name": "数据库缓存测试", "tests": Slice{}}
|
||||
tests := Slice{}
|
||||
|
||||
cacheMode := "unknown"
|
||||
if that.Application.HoTimeCache != nil && that.Application.HoTimeCache.Config != nil {
|
||||
dbConfig := that.Application.HoTimeCache.Config.GetMap("db")
|
||||
if dbConfig != nil {
|
||||
cacheMode = dbConfig.GetString("mode")
|
||||
if cacheMode == "" {
|
||||
cacheMode = "new"
|
||||
}
|
||||
}
|
||||
}
|
||||
result["cache_mode"] = cacheMode
|
||||
|
||||
testPrefix := fmt.Sprintf("cache_test_%d_", time.Now().UnixNano())
|
||||
|
||||
// 1. 基础读写测试
|
||||
test1 := Map{"name": "1. 基础 set/get 测试"}
|
||||
testKey1 := testPrefix + "basic"
|
||||
testValue1 := Map{"name": "测试数据", "count": 123, "active": true}
|
||||
that.Application.Cache(testKey1, testValue1)
|
||||
cached1 := that.Application.Cache(testKey1)
|
||||
if cached1.Data != nil {
|
||||
cachedMap := cached1.ToMap()
|
||||
test1["result"] = cachedMap.GetString("name") == "测试数据" && cachedMap.GetInt("count") == 123
|
||||
test1["cached_value"] = cachedMap
|
||||
} else {
|
||||
test1["result"] = false
|
||||
test1["error"] = "缓存读取返回 nil"
|
||||
}
|
||||
tests = append(tests, test1)
|
||||
|
||||
// 2. 删除缓存测试
|
||||
test2 := Map{"name": "2. delete 删除缓存测试"}
|
||||
testKey2 := testPrefix + "delete"
|
||||
that.Application.Cache(testKey2, "删除测试值")
|
||||
before := that.Application.Cache(testKey2)
|
||||
beforeExists := before.Data != nil
|
||||
that.Application.Cache(testKey2, nil)
|
||||
after := that.Application.Cache(testKey2)
|
||||
afterExists := after.Data != nil
|
||||
test2["result"] = beforeExists && !afterExists
|
||||
test2["before_exists"] = beforeExists
|
||||
test2["after_exists"] = afterExists
|
||||
tests = append(tests, test2)
|
||||
|
||||
// 3. 过期时间测试
|
||||
test3 := Map{"name": "3. 过期时间测试(短超时)"}
|
||||
testKey3 := testPrefix + "expire"
|
||||
that.Application.Cache(testKey3, "短期数据", 2)
|
||||
immediate := that.Application.Cache(testKey3)
|
||||
immediateExists := immediate.Data != nil
|
||||
test3["result"] = immediateExists
|
||||
test3["immediate_exists"] = immediateExists
|
||||
test3["note"] = "设置了2秒过期,可等待后再次访问验证过期"
|
||||
tests = append(tests, test3)
|
||||
|
||||
// 4. 不存在的 key 读取测试
|
||||
test4 := Map{"name": "4. 不存在的 key 读取测试"}
|
||||
nonExistKey := testPrefix + "non_exist_key_" + fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
nonExist := that.Application.Cache(nonExistKey)
|
||||
test4["result"] = nonExist.Data == nil
|
||||
test4["value"] = nonExist.Data
|
||||
tests = append(tests, test4)
|
||||
|
||||
// 5. 重复 set 同一个 key 测试
|
||||
test5 := Map{"name": "5. 重复 set 同一个 key 测试"}
|
||||
testKey5 := testPrefix + "repeat"
|
||||
that.Application.Cache(testKey5, "第一次值")
|
||||
first := that.Application.Cache(testKey5).ToStr()
|
||||
that.Application.Cache(testKey5, "第二次值")
|
||||
second := that.Application.Cache(testKey5).ToStr()
|
||||
that.Application.Cache(testKey5, Map{"version": 3})
|
||||
third := that.Application.Cache(testKey5).ToMap()
|
||||
test5["result"] = first == "第一次值" && second == "第二次值" && third.GetInt("version") == 3
|
||||
test5["first"] = first
|
||||
test5["second"] = second
|
||||
test5["third"] = third
|
||||
tests = append(tests, test5)
|
||||
|
||||
// 6. 通配删除测试
|
||||
test6 := Map{"name": "6. 通配删除测试 (key*)"}
|
||||
wildcardPrefix := testPrefix + "wildcard_"
|
||||
that.Application.Cache(wildcardPrefix+"a", "值A")
|
||||
that.Application.Cache(wildcardPrefix+"b", "值B")
|
||||
that.Application.Cache(wildcardPrefix+"c", "值C")
|
||||
aExists := that.Application.Cache(wildcardPrefix+"a").Data != nil
|
||||
bExists := that.Application.Cache(wildcardPrefix+"b").Data != nil
|
||||
cExists := that.Application.Cache(wildcardPrefix+"c").Data != nil
|
||||
allExistBefore := aExists && bExists && cExists
|
||||
that.Application.Cache(wildcardPrefix+"*", nil)
|
||||
aAfter := that.Application.Cache(wildcardPrefix+"a").Data != nil
|
||||
bAfter := that.Application.Cache(wildcardPrefix+"b").Data != nil
|
||||
cAfter := that.Application.Cache(wildcardPrefix+"c").Data != nil
|
||||
allDeletedAfter := !aAfter && !bAfter && !cAfter
|
||||
test6["result"] = allExistBefore && allDeletedAfter
|
||||
test6["before"] = Map{"a": aExists, "b": bExists, "c": cExists}
|
||||
test6["after"] = Map{"a": aAfter, "b": bAfter, "c": cAfter}
|
||||
tests = append(tests, test6)
|
||||
|
||||
// 7. 不同数据类型测试
|
||||
test7 := Map{"name": "7. 不同数据类型存储测试"}
|
||||
that.Application.Cache(testPrefix+"type_string", "字符串值")
|
||||
typeString := that.Application.Cache(testPrefix + "type_string").ToStr()
|
||||
that.Application.Cache(testPrefix+"type_int", 12345)
|
||||
typeInt := that.Application.Cache(testPrefix + "type_int").ToInt()
|
||||
that.Application.Cache(testPrefix+"type_float", 3.14159)
|
||||
typeFloat := that.Application.Cache(testPrefix + "type_float").ToFloat64()
|
||||
that.Application.Cache(testPrefix+"type_bool", true)
|
||||
typeBoolData := that.Application.Cache(testPrefix + "type_bool").Data
|
||||
typeBool := typeBoolData == true || typeBoolData == "true" || typeBoolData == 1.0
|
||||
that.Application.Cache(testPrefix+"type_map", Map{"key": "value", "num": 100})
|
||||
typeMap := that.Application.Cache(testPrefix + "type_map").ToMap()
|
||||
that.Application.Cache(testPrefix+"type_slice", Slice{1, 2, 3, "four", Map{"five": 5}})
|
||||
typeSlice := that.Application.Cache(testPrefix + "type_slice").ToSlice()
|
||||
test7["result"] = typeString == "字符串值" &&
|
||||
typeInt == 12345 &&
|
||||
typeFloat > 3.14 && typeFloat < 3.15 &&
|
||||
typeBool == true &&
|
||||
typeMap.GetString("key") == "value" &&
|
||||
len(typeSlice) == 5
|
||||
test7["string"] = typeString
|
||||
test7["int"] = typeInt
|
||||
test7["float"] = typeFloat
|
||||
test7["bool"] = typeBool
|
||||
test7["map"] = typeMap
|
||||
test7["slice"] = typeSlice
|
||||
tests = append(tests, test7)
|
||||
|
||||
// 8. 自定义超时时间测试
|
||||
test8 := Map{"name": "8. 自定义超时时间参数测试"}
|
||||
testKey8 := testPrefix + "custom_timeout"
|
||||
that.Application.Cache(testKey8, "长期数据", 3600)
|
||||
longTerm := that.Application.Cache(testKey8)
|
||||
test8["result"] = longTerm.Data != nil
|
||||
test8["value"] = longTerm.ToStr()
|
||||
tests = append(tests, test8)
|
||||
|
||||
// 9. 查询缓存表状态
|
||||
test9 := Map{"name": "9. 缓存表状态查询"}
|
||||
prefix := that.Db.GetPrefix()
|
||||
newTableName := prefix + "hotime_cache"
|
||||
newCount := that.Db.Count(newTableName)
|
||||
test9["new_table_count"] = newCount
|
||||
test9["new_table_name"] = newTableName
|
||||
test9["result"] = newCount >= 0
|
||||
tests = append(tests, test9)
|
||||
|
||||
that.Application.Cache(testPrefix+"*", nil)
|
||||
|
||||
result["tests"] = tests
|
||||
result["success"] = true
|
||||
result["cleanup"] = "已清理所有测试缓存数据"
|
||||
return result
|
||||
}
|
||||
|
||||
func testCacheCompatible(that *Context) Map {
|
||||
result := Map{
|
||||
"test_name": "兼容模式白盒测试",
|
||||
"timestamp": time.Now().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
|
||||
prefix := that.Db.GetPrefix()
|
||||
newTableName := prefix + "hotime_cache"
|
||||
legacyTableName := prefix + "cached"
|
||||
|
||||
tests := Slice{}
|
||||
|
||||
test1 := Map{"name": "1. 查询当前缓存模式"}
|
||||
cacheConfig := that.Application.Config.GetMap("cache")
|
||||
dbConfig := cacheConfig.GetMap("db")
|
||||
mode := dbConfig.GetString("mode")
|
||||
if mode == "" {
|
||||
mode = "默认(compatible)"
|
||||
}
|
||||
test1["mode"] = mode
|
||||
test1["result"] = true
|
||||
tests = append(tests, test1)
|
||||
|
||||
// 根据数据库类型使用不同的引号
|
||||
q := "`"
|
||||
if that.Db.Dialect != nil {
|
||||
q = that.Db.Dialect.QuoteChar()
|
||||
}
|
||||
|
||||
test2 := Map{"name": "2. 查询老表cached现有数据"}
|
||||
legacyData := that.Db.Query("SELECT * FROM " + q + legacyTableName + q + " LIMIT 5")
|
||||
test2["legacy_table"] = legacyTableName
|
||||
test2["count"] = len(legacyData)
|
||||
test2["data"] = legacyData
|
||||
test2["result"] = true
|
||||
tests = append(tests, test2)
|
||||
|
||||
test3 := Map{"name": "3. 查询新表hotime_cache现有数据"}
|
||||
newData := that.Db.Query("SELECT * FROM " + q + newTableName + q + " LIMIT 5")
|
||||
test3["new_table"] = newTableName
|
||||
test3["count"] = len(newData)
|
||||
test3["data"] = newData
|
||||
test3["result"] = true
|
||||
tests = append(tests, test3)
|
||||
|
||||
test4 := Map{"name": "4. 测试兼容模式老表回退读取"}
|
||||
testKey4 := "test_compat_fallback_" + ObjToStr(time.Now().UnixNano())
|
||||
testValue4 := Map{"admin_id": 999, "admin_name": "测试老数据"}
|
||||
testValueJson4 := ObjToStr(Map{"data": testValue4})
|
||||
that.Db.Insert(legacyTableName, Map{
|
||||
"key": testKey4,
|
||||
"value": testValueJson4,
|
||||
"endtime": time.Now().Unix() + 3600,
|
||||
"time": time.Now().UnixNano(),
|
||||
})
|
||||
test4["test_key"] = testKey4
|
||||
newExists := that.Db.Get(newTableName, "*", Map{"key": testKey4})
|
||||
test4["key_in_new_table"] = newExists != nil
|
||||
cacheValue := that.Application.Cache(testKey4)
|
||||
test4["cache_api_result"] = cacheValue.Data
|
||||
test4["result"] = newExists == nil && cacheValue.Data != nil
|
||||
tests = append(tests, test4)
|
||||
|
||||
test5 := Map{"name": "5. 测试兼容模式写新删老"}
|
||||
testKey5 := "test_compat_write_" + ObjToStr(time.Now().UnixNano())
|
||||
testValue5 := "兼容模式测试数据"
|
||||
that.Db.Insert(legacyTableName, Map{
|
||||
"key": testKey5,
|
||||
"value": `{"data":"老表原始数据"}`,
|
||||
"endtime": time.Now().Unix() + 3600,
|
||||
"time": time.Now().UnixNano(),
|
||||
})
|
||||
legacyBefore := that.Db.Get(legacyTableName, "*", Map{"key": testKey5})
|
||||
test5["step1_legacy_before"] = legacyBefore != nil
|
||||
that.Application.Cache(testKey5, testValue5)
|
||||
newAfter := that.Db.Get(newTableName, "*", Map{"key": testKey5})
|
||||
test5["step2_new_after"] = newAfter != nil
|
||||
if newAfter != nil {
|
||||
test5["new_value"] = newAfter.GetString("value")
|
||||
}
|
||||
legacyAfter := that.Db.Get(legacyTableName, "*", Map{"key": testKey5})
|
||||
test5["step3_legacy_after_deleted"] = legacyAfter == nil
|
||||
test5["result"] = legacyBefore != nil && newAfter != nil && legacyAfter == nil
|
||||
tests = append(tests, test5)
|
||||
|
||||
test6 := Map{"name": "6. 测试兼容模式删除(删除两表)"}
|
||||
testKey6 := "test_compat_delete_" + ObjToStr(time.Now().UnixNano())
|
||||
that.Db.Insert(legacyTableName, Map{
|
||||
"key": testKey6,
|
||||
"value": `{"data":"待删除老数据"}`,
|
||||
"endtime": time.Now().Unix() + 3600,
|
||||
"time": time.Now().UnixNano(),
|
||||
})
|
||||
that.Db.Insert(newTableName, Map{
|
||||
"key": testKey6,
|
||||
"value": `"待删除新数据"`,
|
||||
"end_time": time.Now().Add(time.Hour).Format("2006-01-02 15:04:05"),
|
||||
"state": 0,
|
||||
"create_time": time.Now().Format("2006-01-02 15:04:05"),
|
||||
"modify_time": time.Now().Format("2006-01-02 15:04:05"),
|
||||
})
|
||||
test6["before_legacy"] = that.Db.Get(legacyTableName, "*", Map{"key": testKey6}) != nil
|
||||
test6["before_new"] = that.Db.Get(newTableName, "*", Map{"key": testKey6}) != nil
|
||||
that.Application.Cache(testKey6, nil)
|
||||
test6["after_legacy_deleted"] = that.Db.Get(legacyTableName, "*", Map{"key": testKey6}) == nil
|
||||
test6["after_new_deleted"] = that.Db.Get(newTableName, "*", Map{"key": testKey6}) == nil
|
||||
test6["result"] = test6.GetBool("before_legacy") && test6.GetBool("before_new") &&
|
||||
test6.GetBool("after_legacy_deleted") && test6.GetBool("after_new_deleted")
|
||||
tests = append(tests, test6)
|
||||
|
||||
that.Db.Delete(newTableName, Map{"key[~]": "test_compat_%"})
|
||||
that.Db.Delete(legacyTableName, Map{"key[~]": "test_compat_%"})
|
||||
|
||||
result["tests"] = tests
|
||||
result["success"] = true
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
var CacheTest = CtrTest{
|
||||
"all": {Desc: "缓存全部测试", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result == nil {
|
||||
return fmt.Errorf("result 为 nil")
|
||||
}
|
||||
if !result.GetBool("success") {
|
||||
return fmt.Errorf("缓存测试未全部通过")
|
||||
}
|
||||
if result.GetString("cache_mode") == "" {
|
||||
return fmt.Errorf("cache_mode 字段缺失")
|
||||
}
|
||||
tests := result.GetSlice("tests")
|
||||
if len(tests) == 0 {
|
||||
return fmt.Errorf("tests 数组为空")
|
||||
}
|
||||
for i := 0; i < len(tests); i++ {
|
||||
item := tests.GetMap(i)
|
||||
if item != nil && !item.GetBool("result") {
|
||||
return fmt.Errorf("子项 '%s' 失败", item.GetString("name"))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}).Get("缓存全部测试", 0, Map{
|
||||
"name": "sample",
|
||||
"cache_mode": "sample",
|
||||
"success": true,
|
||||
"cleanup": "sample",
|
||||
"tests": Slice{Map{
|
||||
"name": "sample", "result": true,
|
||||
}},
|
||||
})
|
||||
}},
|
||||
|
||||
// 兼容模式下的老表回退/写新删老操作在测试事务隔离下已知异常(cache API
|
||||
// 使用独立连接,不在 testTx 事务内),因此只校验结构完整性
|
||||
"compat": {Desc: "缓存兼容模式测试", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result == nil {
|
||||
return fmt.Errorf("result 为 nil")
|
||||
}
|
||||
if result.GetString("test_name") == "" {
|
||||
return fmt.Errorf("test_name 字段缺失")
|
||||
}
|
||||
if result.GetString("timestamp") == "" {
|
||||
return fmt.Errorf("timestamp 字段缺失")
|
||||
}
|
||||
tests := result.GetSlice("tests")
|
||||
if len(tests) == 0 {
|
||||
return fmt.Errorf("tests 数组为空")
|
||||
}
|
||||
return nil
|
||||
}).Get("兼容模式测试", 0, Map{
|
||||
"test_name": "sample",
|
||||
"timestamp": "sample",
|
||||
"success": true,
|
||||
"tests": Slice{Map{
|
||||
"name": "sample", "result": true,
|
||||
}},
|
||||
})
|
||||
}},
|
||||
|
||||
"batch": {Desc: "批量缓存测试", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result == nil {
|
||||
return fmt.Errorf("result 为 nil")
|
||||
}
|
||||
msg := result.GetString("message")
|
||||
if msg == "" {
|
||||
return fmt.Errorf("message 字段为空")
|
||||
}
|
||||
return nil
|
||||
}).Get("批量缓存测试", 0, Map{
|
||||
"message": "sample",
|
||||
})
|
||||
}},
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
// DmdbCtr 达梦数据库特有测试控制器
|
||||
var DmdbCtr = Ctr{
|
||||
"tables": func(that *Context) {
|
||||
if that.Db.Type != "dm" && that.Db.Type != "dameng" {
|
||||
that.Display(0, Map{"skip": true, "reason": "当前非达梦数据库"})
|
||||
return
|
||||
}
|
||||
tables := that.Db.Query(`SELECT TABLE_NAME AS "name", COMMENTS AS "label" FROM USER_TAB_COMMENTS WHERE TABLE_TYPE='TABLE'`)
|
||||
that.Display(0, Map{"tables": tables})
|
||||
},
|
||||
|
||||
"describe": func(that *Context) {
|
||||
if that.Db.Type != "dm" && that.Db.Type != "dameng" {
|
||||
that.Display(0, Map{"skip": true, "reason": "当前非达梦数据库"})
|
||||
return
|
||||
}
|
||||
tableName := that.Req.FormValue("table")
|
||||
if tableName == "" {
|
||||
that.Display(1, "请提供 table 参数")
|
||||
return
|
||||
}
|
||||
columns := that.Db.Query(`SELECT c.COLUMN_NAME AS "name", c.DATA_TYPE AS "type", m.COMMENTS AS "label", c.NULLABLE AS "must" FROM USER_TAB_COLUMNS c LEFT JOIN USER_COL_COMMENTS m ON c.TABLE_NAME=m.TABLE_NAME AND c.COLUMN_NAME=m.COLUMN_NAME WHERE c.TABLE_NAME='` + tableName + `' ORDER BY c.COLUMN_ID`)
|
||||
data := that.Db.Select(tableName, "*", Map{"LIMIT": 10})
|
||||
that.Display(0, Map{"table": tableName, "columns": columns, "sample_data": data})
|
||||
},
|
||||
|
||||
"rawsql": func(that *Context) {
|
||||
if that.Db.Type != "dm" && that.Db.Type != "dameng" {
|
||||
that.Display(0, Map{"skip": true, "reason": "当前非达梦数据库"})
|
||||
return
|
||||
}
|
||||
result := testDmRawSQL(that)
|
||||
that.Display(0, result)
|
||||
},
|
||||
}
|
||||
|
||||
func testDmRawSQL(that *Context) Map {
|
||||
result := Map{"name": "达梦原生SQL测试", "tests": Slice{}}
|
||||
tests := Slice{}
|
||||
|
||||
test1 := Map{"name": "Query 原生查询 (双引号)"}
|
||||
articles1 := that.Db.Query(`SELECT "id", "title", "author" FROM "article" WHERE "state" = ? LIMIT ?`, 0, 5)
|
||||
test1["result"] = len(articles1) >= 0
|
||||
test1["count"] = len(articles1)
|
||||
tests = append(tests, test1)
|
||||
|
||||
test2 := Map{"name": "Exec 原生执行 (双引号)"}
|
||||
testArticle := that.Db.Get("article", "id", Map{"state": 0})
|
||||
if testArticle != nil {
|
||||
res, err := that.Db.Exec(`UPDATE "article" SET "modify_time" = NOW() WHERE "id" = ?`, testArticle.GetInt64("id"))
|
||||
if err == nil && res != nil {
|
||||
affected, _ := res.RowsAffected()
|
||||
test2["result"] = affected >= 0
|
||||
test2["affected"] = affected
|
||||
} else {
|
||||
test2["result"] = false
|
||||
test2["error"] = err
|
||||
}
|
||||
} else {
|
||||
test2["result"] = true
|
||||
test2["note"] = "无可用测试数据"
|
||||
}
|
||||
tests = append(tests, test2)
|
||||
|
||||
test3 := Map{"name": "USER_TABLES 查询"}
|
||||
tables := that.Db.Query(`SELECT TABLE_NAME FROM USER_TABLES`)
|
||||
test3["result"] = len(tables) >= 0
|
||||
test3["count"] = len(tables)
|
||||
tests = append(tests, test3)
|
||||
|
||||
test4 := Map{"name": "USER_TAB_COLUMNS 查询"}
|
||||
cols := that.Db.Query(`SELECT COLUMN_NAME, DATA_TYPE FROM USER_TAB_COLUMNS WHERE TABLE_NAME='admin' ORDER BY COLUMN_ID`)
|
||||
test4["result"] = len(cols) >= 0
|
||||
test4["count"] = len(cols)
|
||||
test4["columns"] = cols
|
||||
tests = append(tests, test4)
|
||||
|
||||
result["tests"] = tests
|
||||
result["success"] = true
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
var DmdbTest = CtrTest{
|
||||
"tables": {Desc: "查询达梦所有表", Func: func(a *Api) {
|
||||
if a.DB().Type != "dm" && a.DB().Type != "dameng" {
|
||||
return
|
||||
}
|
||||
a.Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result == nil {
|
||||
return fmt.Errorf("result 为 nil")
|
||||
}
|
||||
tables := result.GetSlice("tables")
|
||||
if tables == nil {
|
||||
return fmt.Errorf("tables 字段缺失")
|
||||
}
|
||||
if len(tables) == 0 {
|
||||
return fmt.Errorf("达梦表列表为空,至少应有测试表")
|
||||
}
|
||||
return nil
|
||||
}).Get("USER_TABLES 查询", 0, Map{
|
||||
"tables": Slice{Map{"name": "sample"}},
|
||||
})
|
||||
}},
|
||||
|
||||
"describe": {Desc: "查询达梦表结构", Func: func(a *Api) {
|
||||
if a.DB().Type != "dm" && a.DB().Type != "dameng" {
|
||||
return
|
||||
}
|
||||
|
||||
// ======== 错误用例 ========
|
||||
a.Get("不传table参数", 1, "请提供 table 参数")
|
||||
|
||||
// ======== 正确请求 + 结构校验 + Verify ========
|
||||
a.Query(Map{"table": "admin"}).
|
||||
Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result == nil {
|
||||
return fmt.Errorf("result 为 nil")
|
||||
}
|
||||
if result.GetString("table") != "admin" {
|
||||
return fmt.Errorf("table 期望 'admin', 实际 '%s'", result.GetString("table"))
|
||||
}
|
||||
columns := result.GetSlice("columns")
|
||||
if len(columns) == 0 {
|
||||
return fmt.Errorf("columns 为空,admin 表应有字段定义")
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Get("USER_TAB_COLUMNS 查询", 0, Map{
|
||||
"table": "sample", "columns": Slice{Map{"name": "sample", "type": "sample"}}, "sample_data": Slice{},
|
||||
})
|
||||
}},
|
||||
|
||||
"rawsql": {Desc: "达梦原生 SQL 测试", Func: func(a *Api) {
|
||||
if a.DB().Type != "dm" && a.DB().Type != "dameng" {
|
||||
return
|
||||
}
|
||||
a.Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result == nil {
|
||||
return fmt.Errorf("result 为 nil")
|
||||
}
|
||||
if !result.GetBool("success") {
|
||||
return fmt.Errorf("达梦原生 SQL 测试未通过")
|
||||
}
|
||||
tests := result.GetSlice("tests")
|
||||
if len(tests) == 0 {
|
||||
return fmt.Errorf("tests 数组为空")
|
||||
}
|
||||
for i := 0; i < len(tests); i++ {
|
||||
item := tests.GetMap(i)
|
||||
if item != nil && !item.GetBool("result") {
|
||||
return fmt.Errorf("子项 '%s' 失败", item.GetString("name"))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}).Get("达梦原生SQL", 0, Map{
|
||||
"name": "sample", "success": true,
|
||||
"tests": Slice{Map{"name": "sample", "result": true}},
|
||||
})
|
||||
}},
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
var ExpectDemoTest = CtrTest{
|
||||
|
||||
// ─── 基础类型结构校验 + Verify 值断言 ──────────────────
|
||||
|
||||
"string_result": {Desc: "返回字符串-结构+值校验", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetString("result")
|
||||
if result != "操作成功" {
|
||||
return fmt.Errorf("result 期望 '操作成功', 实际 '%s'", result)
|
||||
}
|
||||
return nil
|
||||
}).Get("result是字符串", 0, "样本")
|
||||
}},
|
||||
|
||||
"number_result": {Desc: "返回整数-结构+值校验", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetCeilInt64("result")
|
||||
if result != 42 {
|
||||
return fmt.Errorf("result 期望 42, 实际 %d", result)
|
||||
}
|
||||
return nil
|
||||
}).Get("result是整数", 0, int64(1))
|
||||
}},
|
||||
|
||||
"float_result": {Desc: "返回小数-结构+值校验", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetFloat64("result")
|
||||
if result != 3.14 {
|
||||
return fmt.Errorf("result 期望 3.14, 实际 %f", result)
|
||||
}
|
||||
return nil
|
||||
}).Get("result是小数", 0, float64(1.0))
|
||||
}},
|
||||
|
||||
"bool_result": {Desc: "返回布尔-结构+值校验", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetBool("result")
|
||||
if !result {
|
||||
return fmt.Errorf("result 期望 true, 实际 false")
|
||||
}
|
||||
return nil
|
||||
}).Get("result是布尔", 0, true)
|
||||
}},
|
||||
|
||||
"map_result": {Desc: "返回对象-结构+值校验", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result == nil {
|
||||
return fmt.Errorf("result 为 nil")
|
||||
}
|
||||
if result.GetCeilInt64("id") != 1 {
|
||||
return fmt.Errorf("id 期望 1, 实际 %d", result.GetCeilInt64("id"))
|
||||
}
|
||||
if result.GetString("name") != "示例商品" {
|
||||
return fmt.Errorf("name 期望 '示例商品', 实际 '%s'", result.GetString("name"))
|
||||
}
|
||||
if !result.GetBool("in_stock") {
|
||||
return fmt.Errorf("in_stock 期望 true")
|
||||
}
|
||||
return nil
|
||||
}).Get("result是Map-校验字段名+类型+值", 0, Map{
|
||||
"id": int64(1), "name": "sample", "price": float64(1.0), "in_stock": true,
|
||||
})
|
||||
}},
|
||||
|
||||
"nested_result": {Desc: "返回嵌套对象-结构+值校验", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result == nil {
|
||||
return fmt.Errorf("result 为 nil")
|
||||
}
|
||||
if result.GetCeilInt64("id") != 100 {
|
||||
return fmt.Errorf("id 期望 100, 实际 %d", result.GetCeilInt64("id"))
|
||||
}
|
||||
if result.GetString("title") != "测试订单" {
|
||||
return fmt.Errorf("title 期望 '测试订单', 实际 '%s'", result.GetString("title"))
|
||||
}
|
||||
customer := result.GetMap("customer")
|
||||
if customer == nil {
|
||||
return fmt.Errorf("customer 为 nil")
|
||||
}
|
||||
if customer.GetString("name") != "张三" {
|
||||
return fmt.Errorf("customer.name 期望 '张三', 实际 '%s'", customer.GetString("name"))
|
||||
}
|
||||
if customer.GetString("phone") != "13800138000" {
|
||||
return fmt.Errorf("customer.phone 期望 '13800138000', 实际 '%s'", customer.GetString("phone"))
|
||||
}
|
||||
items := result.GetSlice("items")
|
||||
if len(items) != 2 {
|
||||
return fmt.Errorf("items 期望 2 条, 实际 %d 条", len(items))
|
||||
}
|
||||
firstItem := items.GetMap(0)
|
||||
if firstItem.GetString("goods_name") != "苹果" {
|
||||
return fmt.Errorf("items[0].goods_name 期望 '苹果', 实际 '%s'", firstItem.GetString("goods_name"))
|
||||
}
|
||||
delivery := result.GetMap("extra").GetMap("delivery")
|
||||
if delivery == nil {
|
||||
return fmt.Errorf("extra.delivery 为 nil")
|
||||
}
|
||||
if delivery.GetString("address") != "XX路1号" {
|
||||
return fmt.Errorf("extra.delivery.address 期望 'XX路1号', 实际 '%s'", delivery.GetString("address"))
|
||||
}
|
||||
return nil
|
||||
}).Get("深层嵌套Map+Slice+Map", 0, Map{
|
||||
"id": int64(1), "title": "sample",
|
||||
"customer": Map{"id": int64(1), "name": "sample", "phone": "sample"},
|
||||
"items": Slice{Map{
|
||||
"id": int64(1), "goods_name": "sample", "quantity": int64(1), "price": float64(1.0),
|
||||
}},
|
||||
"extra": Map{
|
||||
"delivery": Map{"address": "sample", "fee": int64(1)},
|
||||
},
|
||||
})
|
||||
}},
|
||||
|
||||
"slice_result": {Desc: "返回数组-结构+值校验", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetSlice("result")
|
||||
if len(result) != 2 {
|
||||
return fmt.Errorf("result 期望 2 条, 实际 %d 条", len(result))
|
||||
}
|
||||
first := result.GetMap(0)
|
||||
if first.GetString("name") != "标签A" {
|
||||
return fmt.Errorf("result[0].name 期望 '标签A', 实际 '%s'", first.GetString("name"))
|
||||
}
|
||||
second := result.GetMap(1)
|
||||
if second.GetString("name") != "标签B" {
|
||||
return fmt.Errorf("result[1].name 期望 '标签B', 实际 '%s'", second.GetString("name"))
|
||||
}
|
||||
return nil
|
||||
}).Get("result直接是Slice", 0, Slice{Map{"id": int64(1), "name": "sample"}})
|
||||
}},
|
||||
|
||||
// ─── 错误先行 + 正确请求完整范式 ─────────────────────
|
||||
|
||||
"error_demo": {Desc: "错误先行+正确请求完整校验", Func: func(a *Api) {
|
||||
// ======== 第一步:错误用例(先行) ========
|
||||
a.Form(Map{"name": ""}).Post("名称为空-缺少必填字段", 3, "名称不能为空")
|
||||
|
||||
// ======== 第二步:正确请求 + 结构校验 + Verify 值断言 ========
|
||||
a.Form(Map{"name": "测试商品"}).
|
||||
Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result == nil {
|
||||
return fmt.Errorf("result 为 nil")
|
||||
}
|
||||
if result.GetCeilInt64("id") != 1 {
|
||||
return fmt.Errorf("id 期望 1, 实际 %d", result.GetCeilInt64("id"))
|
||||
}
|
||||
if result.GetString("name") != "测试商品" {
|
||||
return fmt.Errorf("name 期望 '测试商品', 实际 '%s'", result.GetString("name"))
|
||||
}
|
||||
if !result.GetBool("created") {
|
||||
return fmt.Errorf("created 期望 true")
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Post("名称正确-结构+值校验", 0, Map{
|
||||
"id": int64(1), "name": "sample", "created": true,
|
||||
})
|
||||
}},
|
||||
|
||||
"no_expect": {Desc: "最小正确用例-仍需值断言", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result == nil {
|
||||
return fmt.Errorf("result 为 nil")
|
||||
}
|
||||
if result.GetCeilInt64("id") != 1 {
|
||||
return fmt.Errorf("id 期望 1, 实际 %d", result.GetCeilInt64("id"))
|
||||
}
|
||||
if result.GetString("data") != "无预期校验" {
|
||||
return fmt.Errorf("data 期望 '无预期校验', 实际 '%s'", result.GetString("data"))
|
||||
}
|
||||
return nil
|
||||
}).Get("有Verify值断言", 0, Map{"id": int64(1), "data": "sample"})
|
||||
}},
|
||||
|
||||
// ─── Verify 完整范式:错误 → 数据准备 → 正确请求 + 响应断言 + DB 校验 ─
|
||||
|
||||
"verify_demo": {Desc: "Verify完整范式演示", Func: func(a *Api) {
|
||||
// ======== 第一步:错误用例 ========
|
||||
a.Form(Map{"name": ""}).Post("名称为空", 3, "名称不能为空")
|
||||
|
||||
// ======== 第二步:准备测试数据 ========
|
||||
// 先插入一条已有记录,用于后续 DB 校验时确认"新增"行为
|
||||
a.DB().Insert("test_batch", Map{
|
||||
"name": "existing_record", "title": "verify_demo", "state": 1,
|
||||
})
|
||||
|
||||
// ======== 第三步:正确请求 + Verify 统一校验 ========
|
||||
name := "verify_test"
|
||||
a.Form(Map{"name": name}).
|
||||
Verify(func(a *Api) error {
|
||||
// 响应值断言
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result == nil {
|
||||
return fmt.Errorf("result 为 nil")
|
||||
}
|
||||
if result.GetCeilInt64("id") <= 0 {
|
||||
return fmt.Errorf("返回的 id 必须大于 0, 实际 %d", result.GetCeilInt64("id"))
|
||||
}
|
||||
if result.GetString("name") != name {
|
||||
return fmt.Errorf("name 期望 '%s', 实际 '%s'", name, result.GetString("name"))
|
||||
}
|
||||
|
||||
// 数据库状态校验
|
||||
row := a.DB().Get("test_batch", "*", Map{"AND": Map{"name": name, "title": "verify_demo"}})
|
||||
if row == nil {
|
||||
return fmt.Errorf("test_batch 表未找到 name=%s 的记录", name)
|
||||
}
|
||||
if row.GetCeilInt64("state") != 0 {
|
||||
return fmt.Errorf("test_batch.state 期望 0, 实际 %d", row.GetCeilInt64("state"))
|
||||
}
|
||||
|
||||
// 校验之前插入的记录仍然存在(验证不会误删)
|
||||
existing := a.DB().Get("test_batch", "*", Map{"AND": Map{"name": "existing_record", "title": "verify_demo"}})
|
||||
if existing == nil {
|
||||
return fmt.Errorf("之前插入的 existing_record 丢失")
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Post("写入并校验DB+响应", 0, Map{"id": int64(1), "name": "sample"})
|
||||
|
||||
// Verify 返回 error 会使该用例失败并写入 failReason(见覆盖率报告「未通过的接口」)
|
||||
}},
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
// DemoFlows 多接口业务流程联调示例(整条 Flow 共用一次测试事务,结束回滚)
|
||||
var DemoFlows = FlowTest{
|
||||
"expect_chain": {
|
||||
Group: "expect_demo",
|
||||
Desc: "expect_demo 多接口联调",
|
||||
Prep: "依次调用 string_result 与 map_result。\n步骤通过 FromCase 复用单接口验收用例的请求模板,跑通后回写该用例的响应与通过状态。",
|
||||
Func: func(f *Flow) {
|
||||
f.Step("字符串结果", "/app/expect_demo/string_result").
|
||||
FromCase("result是字符串").
|
||||
Note("校验字符串 result:期望返回「操作成功」。").
|
||||
Verify(func(a *Api) error {
|
||||
if a.Resp().GetBody().GetString("result") != "操作成功" {
|
||||
return fmt.Errorf("result 期望 操作成功")
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Get("步骤1-字符串", 0, "样本")
|
||||
|
||||
f.Step("Map结果", "/app/expect_demo/map_result").
|
||||
FromCase("result是Map-校验字段名+类型+值").
|
||||
Note("校验对象 result:至少包含 id=1,用于串联后续步骤前的结构确认。").
|
||||
Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result == nil {
|
||||
return fmt.Errorf("result 为 nil")
|
||||
}
|
||||
if result.GetCeilInt64("id") != 1 {
|
||||
return fmt.Errorf("id 期望 1")
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Get("步骤2-Map", 0, Map{"id": int64(1), "name": "sample", "price": float64(1.0), "in_stock": true})
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
)
|
||||
|
||||
var Project = Proj{
|
||||
"test": TestCtr,
|
||||
"mysql": MysqlCtr,
|
||||
"dmdb": DmdbCtr,
|
||||
"cache": CacheCtr,
|
||||
"expect_demo": ExpectDemoCtr,
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
// MysqlCtr MySQL 特有测试控制器
|
||||
var MysqlCtr = Ctr{
|
||||
"tables": func(that *Context) {
|
||||
if that.Db.Type != "mysql" {
|
||||
that.Display(0, Map{"skip": true, "reason": "当前非 MySQL 数据库"})
|
||||
return
|
||||
}
|
||||
tables := that.Db.Query("SHOW TABLES")
|
||||
that.Display(0, Map{"tables": tables})
|
||||
},
|
||||
|
||||
"describe": func(that *Context) {
|
||||
if that.Db.Type != "mysql" {
|
||||
that.Display(0, Map{"skip": true, "reason": "当前非 MySQL 数据库"})
|
||||
return
|
||||
}
|
||||
tableName := that.Req.FormValue("table")
|
||||
if tableName == "" {
|
||||
that.Display(1, "请提供 table 参数")
|
||||
return
|
||||
}
|
||||
columns := that.Db.Query("DESCRIBE " + tableName)
|
||||
data := that.Db.Select(tableName, "*", Map{"LIMIT": 10})
|
||||
that.Display(0, Map{"table": tableName, "columns": columns, "sample_data": data})
|
||||
},
|
||||
|
||||
"rawsql": func(that *Context) {
|
||||
if that.Db.Type != "mysql" {
|
||||
that.Display(0, Map{"skip": true, "reason": "当前非 MySQL 数据库"})
|
||||
return
|
||||
}
|
||||
result := testMysqlRawSQL(that)
|
||||
that.Display(0, result)
|
||||
},
|
||||
}
|
||||
|
||||
func testMysqlRawSQL(that *Context) Map {
|
||||
result := Map{"name": "MySQL原生SQL测试", "tests": Slice{}}
|
||||
tests := Slice{}
|
||||
|
||||
test1 := Map{"name": "Query 原生查询 (反引号)"}
|
||||
articles1 := that.Db.Query("SELECT id, title, author FROM `article` WHERE state = ? LIMIT ?", 0, 5)
|
||||
test1["result"] = len(articles1) >= 0
|
||||
test1["count"] = len(articles1)
|
||||
tests = append(tests, test1)
|
||||
|
||||
test2 := Map{"name": "Exec 原生执行 (反引号)"}
|
||||
testArticle := that.Db.Get("article", "id", Map{"state": 0})
|
||||
if testArticle != nil {
|
||||
res, err := that.Db.Exec("UPDATE `article` SET modify_time = NOW() WHERE id = ?", testArticle.GetInt64("id"))
|
||||
if err == nil && res != nil {
|
||||
affected, _ := res.RowsAffected()
|
||||
test2["result"] = affected >= 0
|
||||
test2["affected"] = affected
|
||||
} else {
|
||||
test2["result"] = false
|
||||
test2["error"] = err
|
||||
}
|
||||
} else {
|
||||
test2["result"] = true
|
||||
test2["note"] = "无可用测试数据"
|
||||
}
|
||||
tests = append(tests, test2)
|
||||
|
||||
test3 := Map{"name": "IN (?) 非空数组展开"}
|
||||
articles3 := that.Db.Query("SELECT id, title FROM `article` WHERE id IN (?) LIMIT 10", []int{1, 2, 3, 4, 5})
|
||||
test3["result"] = len(articles3) >= 0
|
||||
test3["count"] = len(articles3)
|
||||
test3["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, test3)
|
||||
|
||||
test4 := Map{"name": "IN (?) 空数组替换为1=0"}
|
||||
articles4 := that.Db.Query("SELECT id, title FROM `article` WHERE id IN (?) LIMIT 10", []int{})
|
||||
test4["result"] = len(articles4) == 0
|
||||
test4["count"] = len(articles4)
|
||||
test4["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, test4)
|
||||
|
||||
test5 := Map{"name": "NOT IN (?) 空数组替换为1=1"}
|
||||
articles5 := that.Db.Query("SELECT id, title FROM `article` WHERE id NOT IN (?) LIMIT 10", []int{})
|
||||
test5["result"] = len(articles5) > 0
|
||||
test5["count"] = len(articles5)
|
||||
test5["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, test5)
|
||||
|
||||
result["tests"] = tests
|
||||
result["success"] = true
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
var MysqlTest = CtrTest{
|
||||
"tables": {Desc: "查询 MySQL 所有表", Func: func(a *Api) {
|
||||
if a.DB().Type != "mysql" {
|
||||
return
|
||||
}
|
||||
a.Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result == nil {
|
||||
return fmt.Errorf("result 为 nil")
|
||||
}
|
||||
tables := result.GetSlice("tables")
|
||||
if tables == nil {
|
||||
return fmt.Errorf("tables 字段缺失")
|
||||
}
|
||||
if len(tables) == 0 {
|
||||
return fmt.Errorf("MySQL 表列表为空,至少应有测试表")
|
||||
}
|
||||
return nil
|
||||
}).Get("SHOW TABLES", 0, Map{
|
||||
"tables": Slice{},
|
||||
})
|
||||
}},
|
||||
|
||||
"describe": {Desc: "查询 MySQL 表结构", Func: func(a *Api) {
|
||||
if a.DB().Type != "mysql" {
|
||||
return
|
||||
}
|
||||
|
||||
// ======== 错误用例 ========
|
||||
a.Get("不传table参数", 1, "请提供 table 参数")
|
||||
|
||||
// ======== 正确请求 + 结构校验 + Verify ========
|
||||
a.Query(Map{"table": "admin"}).
|
||||
Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result == nil {
|
||||
return fmt.Errorf("result 为 nil")
|
||||
}
|
||||
if result.GetString("table") != "admin" {
|
||||
return fmt.Errorf("table 期望 'admin', 实际 '%s'", result.GetString("table"))
|
||||
}
|
||||
columns := result.GetSlice("columns")
|
||||
if len(columns) == 0 {
|
||||
return fmt.Errorf("columns 为空,admin 表应有字段定义")
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Get("DESCRIBE admin", 0, Map{
|
||||
"table": "sample", "columns": Slice{}, "sample_data": Slice{},
|
||||
})
|
||||
}},
|
||||
|
||||
"rawsql": {Desc: "MySQL 原生 SQL 测试", Func: func(a *Api) {
|
||||
if a.DB().Type != "mysql" {
|
||||
return
|
||||
}
|
||||
a.Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result == nil {
|
||||
return fmt.Errorf("result 为 nil")
|
||||
}
|
||||
if !result.GetBool("success") {
|
||||
return fmt.Errorf("MySQL 原生 SQL 测试未通过")
|
||||
}
|
||||
tests := result.GetSlice("tests")
|
||||
if len(tests) == 0 {
|
||||
return fmt.Errorf("tests 数组为空")
|
||||
}
|
||||
for i := 0; i < len(tests); i++ {
|
||||
item := tests.GetMap(i)
|
||||
if item != nil && !item.GetBool("result") {
|
||||
return fmt.Errorf("子项 '%s' 失败", item.GetString("name"))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}).Get("MySQL原生SQL", 0, Map{
|
||||
"name": "sample", "success": true,
|
||||
"tests": Slice{Map{"name": "sample", "result": true}},
|
||||
})
|
||||
}},
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
. "code.hoteas.com/golang/hotime/db"
|
||||
)
|
||||
|
||||
// SetupDMDatabase 为达梦数据库创建测试表并灌入测试数据
|
||||
func SetupDMDatabase(db *HoTimeDB) {
|
||||
if db.Type != "dm" && db.Type != "dameng" {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("[DM Setup] 开始初始化达梦数据库测试环境...")
|
||||
|
||||
prefix := db.GetPrefix()
|
||||
createDMTables(db, prefix)
|
||||
seedTestData(db, "DM")
|
||||
|
||||
fmt.Println("[DM Setup] 达梦数据库测试环境初始化完成")
|
||||
}
|
||||
|
||||
func dmTableExists(db *HoTimeDB, tableName string) bool {
|
||||
// COUNT(*) 即使表为空也会返回一行;若表不存在则报错返回空切片。
|
||||
// 相比 USER_TABLES,这里直接检测当前 schema 下表的可访问性,避免
|
||||
// 连接 schema(?schema=TEST)与 USER_TABLES(SYSDBA 自身 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")`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
. "code.hoteas.com/golang/hotime/db"
|
||||
)
|
||||
|
||||
// SetupMySQLDatabase 为 MySQL 数据库创建测试表并灌入测试数据
|
||||
func SetupMySQLDatabase(db *HoTimeDB) {
|
||||
if db.Type != "mysql" {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("[MySQL Setup] 开始初始化 MySQL 数据库测试环境...")
|
||||
|
||||
prefix := db.GetPrefix()
|
||||
createMySQLTables(db, prefix)
|
||||
seedTestData(db, "MySQL")
|
||||
|
||||
fmt.Println("[MySQL Setup] MySQL 数据库测试环境初始化完成")
|
||||
}
|
||||
|
||||
func mysqlTableExists(db *HoTimeDB, tableName string) bool {
|
||||
rows := db.Query("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", tableName)
|
||||
return len(rows) > 0
|
||||
}
|
||||
|
||||
func createMySQLTables(db *HoTimeDB, prefix string) {
|
||||
// admin 表
|
||||
tbl := prefix + "admin"
|
||||
if !mysqlTableExists(db, tbl) {
|
||||
fmt.Println("[MySQL Setup] 创建表:", tbl)
|
||||
db.Exec("CREATE TABLE `" + tbl + "` (" +
|
||||
"`id` int(11) unsigned NOT NULL AUTO_INCREMENT," +
|
||||
"`name` varchar(100) DEFAULT NULL," +
|
||||
"`phone` varchar(20) DEFAULT NULL," +
|
||||
"`state` int(2) DEFAULT '0'," +
|
||||
"`password` varchar(100) DEFAULT NULL," +
|
||||
"`role_id` int(11) DEFAULT '0'," +
|
||||
"`title` varchar(100) DEFAULT NULL," +
|
||||
"`create_time` datetime DEFAULT NULL," +
|
||||
"`modify_time` datetime DEFAULT NULL," +
|
||||
"PRIMARY KEY (`id`)," +
|
||||
"UNIQUE KEY `uk_phone` (`phone`)" +
|
||||
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4")
|
||||
}
|
||||
|
||||
// ctg 分类表
|
||||
tbl = prefix + "ctg"
|
||||
if !mysqlTableExists(db, tbl) {
|
||||
fmt.Println("[MySQL Setup] 创建表:", tbl)
|
||||
db.Exec("CREATE TABLE `" + tbl + "` (" +
|
||||
"`id` int(11) unsigned NOT NULL AUTO_INCREMENT," +
|
||||
"`name` varchar(100) DEFAULT NULL," +
|
||||
"`state` int(2) DEFAULT '0'," +
|
||||
"`create_time` datetime DEFAULT NULL," +
|
||||
"`modify_time` datetime DEFAULT NULL," +
|
||||
"PRIMARY KEY (`id`)" +
|
||||
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4")
|
||||
}
|
||||
|
||||
// article 文章表
|
||||
tbl = prefix + "article"
|
||||
if !mysqlTableExists(db, tbl) {
|
||||
fmt.Println("[MySQL Setup] 创建表:", tbl)
|
||||
db.Exec("CREATE TABLE `" + tbl + "` (" +
|
||||
"`id` int(11) unsigned NOT NULL AUTO_INCREMENT," +
|
||||
"`title` varchar(200) DEFAULT NULL," +
|
||||
"`author` varchar(100) DEFAULT NULL," +
|
||||
"`content` text," +
|
||||
"`state` int(2) DEFAULT '0'," +
|
||||
"`click_num` int(11) DEFAULT '0'," +
|
||||
"`sort` int(11) DEFAULT '0'," +
|
||||
"`img` varchar(500) DEFAULT NULL," +
|
||||
"`ctg_id` int(11) DEFAULT '0'," +
|
||||
"`admin_id` int(11) DEFAULT '0'," +
|
||||
"`create_time` datetime DEFAULT NULL," +
|
||||
"`modify_time` datetime DEFAULT NULL," +
|
||||
"PRIMARY KEY (`id`)," +
|
||||
"KEY `idx_state` (`state`)," +
|
||||
"KEY `idx_ctg_id` (`ctg_id`)" +
|
||||
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4")
|
||||
}
|
||||
|
||||
// test_batch 测试批量表
|
||||
tbl = prefix + "test_batch"
|
||||
if !mysqlTableExists(db, tbl) {
|
||||
fmt.Println("[MySQL Setup] 创建表:", tbl)
|
||||
db.Exec("CREATE TABLE `" + tbl + "` (" +
|
||||
"`id` int(11) unsigned NOT NULL AUTO_INCREMENT," +
|
||||
"`name` varchar(100) DEFAULT NULL," +
|
||||
"`title` varchar(200) DEFAULT NULL," +
|
||||
"`state` int(2) DEFAULT '0'," +
|
||||
"`create_time` datetime DEFAULT NULL," +
|
||||
"PRIMARY KEY (`id`)" +
|
||||
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4")
|
||||
}
|
||||
|
||||
// cached 老版本缓存表(兼容模式测试需要)
|
||||
tbl = prefix + "cached"
|
||||
if !mysqlTableExists(db, tbl) {
|
||||
fmt.Println("[MySQL Setup] 创建表:", tbl)
|
||||
db.Exec("CREATE TABLE `" + tbl + "` (" +
|
||||
"`id` int(11) unsigned NOT NULL AUTO_INCREMENT," +
|
||||
"`key` varchar(64) DEFAULT NULL," +
|
||||
"`value` text," +
|
||||
"`endtime` bigint(20) DEFAULT NULL," +
|
||||
"`time` bigint(20) DEFAULT NULL," +
|
||||
"PRIMARY KEY (`id`)" +
|
||||
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4")
|
||||
}
|
||||
|
||||
// hotime_cache 新版缓存表
|
||||
tbl = prefix + "hotime_cache"
|
||||
if !mysqlTableExists(db, tbl) {
|
||||
fmt.Println("[MySQL Setup] 创建表:", tbl)
|
||||
db.Exec("CREATE TABLE `" + tbl + "` (" +
|
||||
"`id` int(11) unsigned NOT NULL AUTO_INCREMENT," +
|
||||
"`key` varchar(64) NOT NULL," +
|
||||
"`value` text," +
|
||||
"`end_time` datetime DEFAULT NULL," +
|
||||
"`state` int(2) DEFAULT '0'," +
|
||||
"`create_time` datetime DEFAULT NULL," +
|
||||
"`modify_time` datetime DEFAULT NULL," +
|
||||
"PRIMARY KEY (`id`)," +
|
||||
"UNIQUE KEY `uk_key` (`key`)," +
|
||||
"KEY `idx_end_time` (`end_time`)" +
|
||||
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
. "code.hoteas.com/golang/hotime/db"
|
||||
)
|
||||
|
||||
// SetupDatabase 根据数据库类型自动初始化测试表和种子数据
|
||||
// 在 TestMain 中调用一次即可
|
||||
func SetupDatabase(db *HoTimeDB) {
|
||||
switch db.Type {
|
||||
case "dm", "dameng":
|
||||
SetupDMDatabase(db)
|
||||
case "mysql":
|
||||
SetupMySQLDatabase(db)
|
||||
}
|
||||
}
|
||||
|
||||
// seedTestData 通用种子数据灌入(DM 和 MySQL 共用)
|
||||
// 使用 ORM 的 Insert 方法,与数据库方言无关
|
||||
func seedTestData(db *HoTimeDB, tag string) {
|
||||
// 已有数据则跳过
|
||||
if db.Count("ctg") > 0 {
|
||||
fmt.Printf("[%s Setup] 表中已有数据,跳过数据灌入\n", tag)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("[%s Setup] 灌入测试数据...\n", tag)
|
||||
|
||||
// 分类数据
|
||||
ctgs := []map[string]interface{}{
|
||||
{"name": "新闻资讯", "state": 0},
|
||||
{"name": "技术文档", "state": 0},
|
||||
{"name": "公告通知", "state": 0},
|
||||
{"name": "产品介绍", "state": 0},
|
||||
{"name": "已归档", "state": 1},
|
||||
}
|
||||
for _, c := range ctgs {
|
||||
c["create_time[#]"] = "NOW()"
|
||||
c["modify_time[#]"] = "NOW()"
|
||||
db.Insert("ctg", c)
|
||||
}
|
||||
|
||||
// 管理员数据
|
||||
admins := []map[string]interface{}{
|
||||
{"name": "超级管理员", "phone": "13800000001", "state": 1, "password": "admin123", "role_id": 1, "title": "系统管理员"},
|
||||
{"name": "编辑员", "phone": "13800000002", "state": 1, "password": "editor123", "role_id": 2, "title": "内容编辑"},
|
||||
{"name": "审核员", "phone": "13800000003", "state": 1, "password": "review123", "role_id": 3, "title": "内容审核"},
|
||||
}
|
||||
for _, a := range admins {
|
||||
a["create_time[#]"] = "NOW()"
|
||||
a["modify_time[#]"] = "NOW()"
|
||||
db.Insert("admin", a)
|
||||
}
|
||||
|
||||
// 文章数据 — 覆盖不同状态、分类、点击数、排序
|
||||
articles := []map[string]interface{}{
|
||||
{"title": "数据库入门指南", "author": "管理员", "content": "国产关系型数据库管理系统入门...", "state": 0, "click_num": 128, "sort": 10, "ctg_id": 1, "admin_id": 1},
|
||||
{"title": "新闻发布系统上线公告", "author": "编辑员", "content": "本系统已正式上线运行...", "state": 0, "click_num": 256, "sort": 5, "ctg_id": 1, "admin_id": 2},
|
||||
{"title": "Go语言最佳实践", "author": "管理员", "content": "Go语言在后端开发中的最佳实践总结...", "state": 0, "click_num": 512, "sort": 8, "ctg_id": 2, "admin_id": 1},
|
||||
{"title": "API接口设计规范", "author": "审核员", "content": "RESTful API设计的基本规范和标准...", "state": 0, "click_num": 64, "sort": 15, "ctg_id": 2, "admin_id": 3},
|
||||
{"title": "系统维护通知", "author": "管理员", "content": "系统将于本周日凌晨进行维护升级...", "state": 0, "click_num": 32, "sort": 1, "ctg_id": 3, "admin_id": 1},
|
||||
{"title": "产品功能更新说明", "author": "编辑员", "content": "新版本增加了以下功能...", "state": 0, "click_num": 96, "sort": 12, "ctg_id": 4, "admin_id": 2},
|
||||
{"title": "数据库性能优化指南", "author": "管理员", "content": "数据库性能优化的常见方法和技巧...", "state": 0, "click_num": 1024, "sort": 3, "ctg_id": 2, "admin_id": 1},
|
||||
{"title": "缓存机制深入分析", "author": "审核员", "content": "缓存在Web应用中的重要作用...", "state": 0, "click_num": 200, "sort": 7, "ctg_id": 2, "admin_id": 3},
|
||||
{"title": "旧版公告(已归档)", "author": "管理员", "content": "此公告已过期归档...", "state": 1, "click_num": 10, "sort": 0, "ctg_id": 5, "admin_id": 1},
|
||||
{"title": "测试文章(隐藏)", "author": "编辑员", "content": "测试用文章不公开显示...", "state": 2, "click_num": 0, "sort": 0, "ctg_id": 1, "admin_id": 2},
|
||||
{"title": "高点击量文章", "author": "编辑员", "content": "这是一篇点击量很高的文章...", "state": 0, "click_num": 9999, "sort": 2, "ctg_id": 1, "admin_id": 2},
|
||||
}
|
||||
for _, a := range articles {
|
||||
a["create_time[#]"] = "NOW()"
|
||||
a["modify_time[#]"] = "NOW()"
|
||||
db.Insert("article", a)
|
||||
}
|
||||
|
||||
fmt.Printf("[%s Setup] 数据灌入完成: ctg=%d, admin=%d, article=%d\n",
|
||||
tag, db.Count("ctg"), db.Count("admin"), db.Count("article"))
|
||||
}
|
||||
@@ -0,0 +1,809 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
. "code.hoteas.com/golang/hotime/db"
|
||||
)
|
||||
|
||||
// initTestTables 根据数据库类型初始化测试表
|
||||
func initTestTables(that *Context) {
|
||||
switch that.Db.Type {
|
||||
case "dm", "dameng":
|
||||
// 用 COUNT(*) 直接探查表可访问性,而非 USER_TABLES(后者受 schema 错配影响)
|
||||
exists := that.Db.Query(`SELECT COUNT(*) as cnt FROM "test_batch"`)
|
||||
if len(exists) == 0 {
|
||||
that.Db.Exec(`CREATE TABLE "test_batch" (
|
||||
"id" INT IDENTITY(1,1) PRIMARY KEY,
|
||||
"name" VARCHAR(100),
|
||||
"title" VARCHAR(200),
|
||||
"state" INT DEFAULT 0,
|
||||
"create_time" TIMESTAMP
|
||||
)`)
|
||||
}
|
||||
default:
|
||||
that.Db.Exec(`CREATE TABLE IF NOT EXISTS test_batch (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(100),
|
||||
title VARCHAR(200),
|
||||
state INT DEFAULT 0,
|
||||
create_time DATETIME
|
||||
)`)
|
||||
}
|
||||
|
||||
_ = that.Db.Count("admin")
|
||||
_ = that.Db.Count("article")
|
||||
}
|
||||
|
||||
// nowFunc 返回当前数据库的 NOW() 函数表达式
|
||||
func nowFunc(that *Context) string {
|
||||
return "NOW()"
|
||||
}
|
||||
|
||||
// ExpectDemoCtr 用于展示 result 结构校验的各种场景
|
||||
var ExpectDemoCtr = Ctr{
|
||||
"string_result": func(that *Context) {
|
||||
that.Display(0, "操作成功")
|
||||
},
|
||||
"number_result": func(that *Context) {
|
||||
that.Display(0, 42)
|
||||
},
|
||||
"float_result": func(that *Context) {
|
||||
that.Display(0, 3.14)
|
||||
},
|
||||
"bool_result": func(that *Context) {
|
||||
that.Display(0, true)
|
||||
},
|
||||
"map_result": func(that *Context) {
|
||||
that.Display(0, Map{
|
||||
"id": 1, "name": "示例商品", "price": 19.9, "in_stock": true,
|
||||
})
|
||||
},
|
||||
"nested_result": func(that *Context) {
|
||||
that.Display(0, Map{
|
||||
"id": 100, "title": "测试订单",
|
||||
"customer": Map{"id": 1, "name": "张三", "phone": "13800138000"},
|
||||
"items": Slice{
|
||||
Map{"id": 1, "goods_name": "苹果", "quantity": 5, "price": 3.5},
|
||||
Map{"id": 2, "goods_name": "香蕉", "quantity": 10, "price": 2.0},
|
||||
},
|
||||
"extra": Map{
|
||||
"delivery": Map{"address": "XX路1号", "fee": 5},
|
||||
},
|
||||
})
|
||||
},
|
||||
"slice_result": func(that *Context) {
|
||||
that.Display(0, Slice{
|
||||
Map{"id": 1, "name": "标签A"},
|
||||
Map{"id": 2, "name": "标签B"},
|
||||
})
|
||||
},
|
||||
"error_demo": func(that *Context) {
|
||||
name := that.Req.FormValue("name")
|
||||
if name == "" {
|
||||
that.Display(3, "名称不能为空")
|
||||
return
|
||||
}
|
||||
that.Display(0, Map{"id": 1, "name": name, "created": true})
|
||||
},
|
||||
"no_expect": func(that *Context) {
|
||||
that.Display(0, Map{"id": 1, "data": "无预期校验"})
|
||||
},
|
||||
"verify_demo": func(that *Context) {
|
||||
name := that.Req.FormValue("name")
|
||||
if name == "" {
|
||||
that.Display(3, "名称不能为空")
|
||||
return
|
||||
}
|
||||
initTestTables(that)
|
||||
id := that.Db.Insert("test_batch", Map{"name": name, "title": "verify_demo", "state": 0})
|
||||
that.Display(0, Map{"id": id, "name": name})
|
||||
},
|
||||
}
|
||||
|
||||
// TestCtr 通用 ORM 测试控制器(数据库无关)
|
||||
var TestCtr = Ctr{
|
||||
"test": func(that *Context) {
|
||||
that.Display(2, "dsadasd")
|
||||
},
|
||||
|
||||
"all": func(that *Context) {
|
||||
results := Map{}
|
||||
initTestTables(that)
|
||||
|
||||
results["1_basic_crud"] = testBasicCRUD(that)
|
||||
results["2_condition_syntax"] = testConditionSyntax(that)
|
||||
results["3_chain_query"] = testChainQuery(that)
|
||||
results["4_join_query"] = testJoinQuery(that)
|
||||
results["5_aggregate"] = testAggregate(that)
|
||||
results["6_pagination"] = testPagination(that)
|
||||
results["7_batch_insert"] = testInserts(that)
|
||||
results["8_upsert"] = testUpsert(that)
|
||||
results["9_transaction"] = testTransaction(that)
|
||||
|
||||
that.Display(0, results)
|
||||
},
|
||||
|
||||
"crud": func(that *Context) { initTestTables(that); that.Display(0, testBasicCRUD(that)) },
|
||||
"condition": func(that *Context) { that.Display(0, testConditionSyntax(that)) },
|
||||
"chain": func(that *Context) { that.Display(0, testChainQuery(that)) },
|
||||
"join": func(that *Context) { that.Display(0, testJoinQuery(that)) },
|
||||
"aggregate": func(that *Context) { that.Display(0, testAggregate(that)) },
|
||||
"pagination": func(that *Context) { that.Display(0, testPagination(that)) },
|
||||
"batch": func(that *Context) { initTestTables(that); that.Display(0, testInserts(that)) },
|
||||
"upsert": func(that *Context) { that.Display(0, testUpsert(that)) },
|
||||
"transaction": func(that *Context) { initTestTables(that); that.Display(0, testTransaction(that)) },
|
||||
}
|
||||
|
||||
func testBasicCRUD(that *Context) Map {
|
||||
result := Map{"name": "基础CRUD测试", "tests": Slice{}}
|
||||
tests := Slice{}
|
||||
|
||||
now := nowFunc(that)
|
||||
|
||||
insertTest := Map{"name": "Insert 插入测试 (admin表)"}
|
||||
uniq := time.Now().UnixNano()
|
||||
adminId := that.Db.Insert("admin", Map{
|
||||
"name": "测试管理员_" + fmt.Sprintf("%d", uniq),
|
||||
"phone": fmt.Sprintf("139%08d", uniq%100000000),
|
||||
"state": 1,
|
||||
"password": "test123456",
|
||||
"role_id": 1,
|
||||
"title": "测试职位",
|
||||
"create_time[#]": now,
|
||||
"modify_time[#]": now,
|
||||
})
|
||||
insertTest["result"] = adminId > 0
|
||||
insertTest["adminId"] = adminId
|
||||
insertTest["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, insertTest)
|
||||
|
||||
getTest := Map{"name": "Get 获取单条记录测试"}
|
||||
admin := that.Db.Get("admin", "*", Map{"id": adminId})
|
||||
getTest["result"] = admin != nil && admin.GetInt64("id") == adminId
|
||||
getTest["admin"] = admin
|
||||
tests = append(tests, getTest)
|
||||
|
||||
selectTest1 := Map{"name": "Select 单条件查询测试"}
|
||||
admins1 := that.Db.Select("admin", "*", Map{"state": 1, "LIMIT": 5})
|
||||
selectTest1["result"] = len(admins1) >= 0
|
||||
selectTest1["count"] = len(admins1)
|
||||
tests = append(tests, selectTest1)
|
||||
|
||||
selectTest2 := Map{"name": "Select 多条件自动AND测试"}
|
||||
admins2 := that.Db.Select("admin", "*", Map{
|
||||
"state": 1,
|
||||
"role_id[>]": 0,
|
||||
"ORDER": "id DESC",
|
||||
"LIMIT": 5,
|
||||
})
|
||||
selectTest2["result"] = true
|
||||
selectTest2["count"] = len(admins2)
|
||||
selectTest2["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, selectTest2)
|
||||
|
||||
updateTest := Map{"name": "Update 更新测试"}
|
||||
affected := that.Db.Update("admin", Map{
|
||||
"title": "更新后的职位",
|
||||
"modify_time[#]": now,
|
||||
}, Map{"id": adminId})
|
||||
updateTest["result"] = affected > 0
|
||||
updateTest["affected"] = affected
|
||||
tests = append(tests, updateTest)
|
||||
|
||||
deleteTest := Map{"name": "Delete 删除测试"}
|
||||
tempId := that.Db.Insert("test_batch", Map{
|
||||
"name": "临时删除测试",
|
||||
"title": "测试标题",
|
||||
"state": 1,
|
||||
"create_time[#]": now,
|
||||
})
|
||||
deleteAffected := that.Db.Delete("test_batch", Map{"id": tempId})
|
||||
deleteTest["result"] = deleteAffected > 0
|
||||
deleteTest["affected"] = deleteAffected
|
||||
tests = append(tests, deleteTest)
|
||||
|
||||
result["tests"] = tests
|
||||
result["success"] = true
|
||||
return result
|
||||
}
|
||||
|
||||
func testConditionSyntax(that *Context) Map {
|
||||
result := Map{"name": "条件查询语法测试", "tests": Slice{}}
|
||||
tests := Slice{}
|
||||
|
||||
test1 := Map{"name": "等于条件 (=)"}
|
||||
articles1 := that.Db.Select("article", "id,title", Map{"state": 0, "LIMIT": 3})
|
||||
test1["result"] = true
|
||||
test1["count"] = len(articles1)
|
||||
tests = append(tests, test1)
|
||||
|
||||
test2 := Map{"name": "不等于条件 ([!])"}
|
||||
articles2 := that.Db.Select("article", "id,title,state", Map{"state[!]": -1, "LIMIT": 3})
|
||||
test2["result"] = true
|
||||
test2["count"] = len(articles2)
|
||||
test2["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, test2)
|
||||
|
||||
test3 := Map{"name": "大于小于条件 ([>], [<])"}
|
||||
articles3 := that.Db.Select("article", "id,title,click_num", Map{
|
||||
"click_num[>]": 0,
|
||||
"click_num[<]": 100000,
|
||||
"LIMIT": 3,
|
||||
})
|
||||
test3["result"] = true
|
||||
test3["count"] = len(articles3)
|
||||
tests = append(tests, test3)
|
||||
|
||||
test4 := Map{"name": "大于等于小于等于条件 ([>=], [<=])"}
|
||||
articles4 := that.Db.Select("article", "id,title,sort", Map{
|
||||
"sort[>=]": 0,
|
||||
"sort[<=]": 100,
|
||||
"LIMIT": 3,
|
||||
})
|
||||
test4["result"] = true
|
||||
test4["count"] = len(articles4)
|
||||
tests = append(tests, test4)
|
||||
|
||||
test5 := Map{"name": "LIKE 模糊查询 ([~])"}
|
||||
articles5 := that.Db.Select("article", "id,title", Map{"title[~]": "新闻", "LIMIT": 3})
|
||||
test5["result"] = true
|
||||
test5["count"] = len(articles5)
|
||||
test5["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, test5)
|
||||
|
||||
test6 := Map{"name": "右模糊查询 ([~!])"}
|
||||
articles6 := that.Db.Select("admin", "id,name", Map{"name[~!]": "管理", "LIMIT": 3})
|
||||
test6["result"] = true
|
||||
test6["count"] = len(articles6)
|
||||
tests = append(tests, test6)
|
||||
|
||||
test7 := Map{"name": "BETWEEN 区间查询 ([<>])"}
|
||||
articles7 := that.Db.Select("article", "id,title,click_num", Map{"click_num[<>]": Slice{0, 1000}, "LIMIT": 3})
|
||||
test7["result"] = true
|
||||
test7["count"] = len(articles7)
|
||||
test7["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, test7)
|
||||
|
||||
test8 := Map{"name": "NOT BETWEEN 查询 ([><])"}
|
||||
articles8 := that.Db.Select("article", "id,title,sort", Map{"sort[><]": Slice{-10, 0}, "LIMIT": 3})
|
||||
test8["result"] = true
|
||||
test8["count"] = len(articles8)
|
||||
tests = append(tests, test8)
|
||||
|
||||
test9 := Map{"name": "IN 查询"}
|
||||
articles9 := that.Db.Select("article", "id,title", Map{"id": Slice{1, 2, 3, 4, 5}, "LIMIT": 5})
|
||||
test9["result"] = true
|
||||
test9["count"] = len(articles9)
|
||||
test9["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, test9)
|
||||
|
||||
test10 := Map{"name": "NOT IN 查询 ([!])"}
|
||||
articles10 := that.Db.Select("article", "id,title", Map{"id[!]": Slice{1, 2, 3}, "LIMIT": 5})
|
||||
test10["result"] = true
|
||||
test10["count"] = len(articles10)
|
||||
tests = append(tests, test10)
|
||||
|
||||
test11 := Map{"name": "IS NULL 查询"}
|
||||
articles11 := that.Db.Select("article", "id,title,img", Map{"img": nil, "LIMIT": 3})
|
||||
test11["result"] = true
|
||||
test11["count"] = len(articles11)
|
||||
tests = append(tests, test11)
|
||||
|
||||
test12 := Map{"name": "IS NOT NULL 查询 ([!])"}
|
||||
articles12 := that.Db.Select("article", "id,title,create_time", Map{"create_time[!]": nil, "LIMIT": 3})
|
||||
test12["result"] = true
|
||||
test12["count"] = len(articles12)
|
||||
tests = append(tests, test12)
|
||||
|
||||
// SQL 片段查询根据数据库类型适配
|
||||
test13 := Map{"name": "直接 SQL 片段查询 ([##])"}
|
||||
var sqlFragment string
|
||||
switch that.Db.Type {
|
||||
case "dm", "dameng":
|
||||
sqlFragment = "\"create_time\" > DATEADD(DAY, -365, NOW())"
|
||||
default:
|
||||
sqlFragment = "create_time > DATE_SUB(NOW(), INTERVAL 365 DAY)"
|
||||
}
|
||||
articles13 := that.Db.Select("article", "id,title,create_time", Map{
|
||||
"[##]": sqlFragment,
|
||||
"LIMIT": 3,
|
||||
})
|
||||
test13["result"] = true
|
||||
test13["count"] = len(articles13)
|
||||
test13["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, test13)
|
||||
|
||||
test14 := Map{"name": "显式 AND 条件"}
|
||||
articles14 := that.Db.Select("article", "id,title,state,click_num", Map{
|
||||
"AND": Map{
|
||||
"state": 0,
|
||||
"click_num[>=]": 0,
|
||||
},
|
||||
"LIMIT": 3,
|
||||
})
|
||||
test14["result"] = true
|
||||
test14["count"] = len(articles14)
|
||||
tests = append(tests, test14)
|
||||
|
||||
test15 := Map{"name": "OR 条件"}
|
||||
articles15 := that.Db.Select("article", "id,title,sort,click_num", Map{
|
||||
"OR": Map{
|
||||
"sort": 0,
|
||||
"click_num[>]": 10,
|
||||
},
|
||||
"LIMIT": 5,
|
||||
})
|
||||
test15["result"] = true
|
||||
test15["count"] = len(articles15)
|
||||
tests = append(tests, test15)
|
||||
|
||||
test16 := Map{"name": "嵌套 AND/OR 条件"}
|
||||
articles16 := that.Db.Select("article", "id,title,sort,state", Map{
|
||||
"AND": Map{
|
||||
"state": 0,
|
||||
"OR": Map{
|
||||
"sort[>=]": 0,
|
||||
"click_num[>]": 0,
|
||||
},
|
||||
},
|
||||
"LIMIT": 5,
|
||||
})
|
||||
test16["result"] = true
|
||||
test16["count"] = len(articles16)
|
||||
test16["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, test16)
|
||||
|
||||
result["tests"] = tests
|
||||
result["success"] = true
|
||||
return result
|
||||
}
|
||||
|
||||
func testChainQuery(that *Context) Map {
|
||||
result := Map{"name": "链式查询测试", "tests": Slice{}}
|
||||
tests := Slice{}
|
||||
|
||||
test1 := Map{"name": "基本链式查询 Table().Where().Select()"}
|
||||
articles1 := that.Db.Table("article").
|
||||
Where("state", 0).
|
||||
Select("id,title,author")
|
||||
test1["result"] = len(articles1) >= 0
|
||||
test1["count"] = len(articles1)
|
||||
tests = append(tests, test1)
|
||||
|
||||
test2 := Map{"name": "链式 And 条件"}
|
||||
articles2 := that.Db.Table("article").
|
||||
Where("state", 0).
|
||||
And("click_num[>=]", 0).
|
||||
And("sort[>=]", 0).
|
||||
Select("id,title,click_num")
|
||||
test2["result"] = len(articles2) >= 0
|
||||
test2["count"] = len(articles2)
|
||||
tests = append(tests, test2)
|
||||
|
||||
test3 := Map{"name": "链式 Or 条件"}
|
||||
articles3 := that.Db.Table("article").
|
||||
Where("state", 0).
|
||||
Or(Map{
|
||||
"sort": 0,
|
||||
"click_num[>]": 10,
|
||||
}).
|
||||
Select("id,title,sort,click_num")
|
||||
test3["result"] = len(articles3) >= 0
|
||||
test3["count"] = len(articles3)
|
||||
tests = append(tests, test3)
|
||||
|
||||
test4 := Map{"name": "链式 Order 排序"}
|
||||
articles4 := that.Db.Table("article").
|
||||
Where("state", 0).
|
||||
Order("create_time DESC", "id ASC").
|
||||
Limit(0, 5).
|
||||
Select("id,title,create_time")
|
||||
test4["result"] = len(articles4) >= 0
|
||||
test4["count"] = len(articles4)
|
||||
tests = append(tests, test4)
|
||||
|
||||
test5 := Map{"name": "链式 Limit 限制"}
|
||||
articles5 := that.Db.Table("article").
|
||||
Where("state", 0).
|
||||
Limit(0, 3).
|
||||
Select("id,title")
|
||||
test5["result"] = len(articles5) <= 3
|
||||
test5["count"] = len(articles5)
|
||||
tests = append(tests, test5)
|
||||
|
||||
test6 := Map{"name": "链式 Get 获取单条"}
|
||||
article6 := that.Db.Table("article").
|
||||
Where("state", 0).
|
||||
Get("id,title,author")
|
||||
test6["result"] = article6 != nil || true
|
||||
test6["article"] = article6
|
||||
tests = append(tests, test6)
|
||||
|
||||
test7 := Map{"name": "链式 Count 统计"}
|
||||
count7 := that.Db.Table("article").
|
||||
Where("state", 0).
|
||||
Count()
|
||||
test7["result"] = count7 >= 0
|
||||
test7["count"] = count7
|
||||
tests = append(tests, test7)
|
||||
|
||||
test8 := Map{"name": "链式 Page 分页"}
|
||||
articles8 := that.Db.Table("article").
|
||||
Where("state", 0).
|
||||
Page(1, 5).
|
||||
Select("id,title")
|
||||
test8["result"] = len(articles8) <= 5
|
||||
test8["count"] = len(articles8)
|
||||
tests = append(tests, test8)
|
||||
|
||||
test9 := Map{"name": "链式 Group 分组"}
|
||||
stats9 := that.Db.Table("article").
|
||||
Where("state", 0).
|
||||
Group("ctg_id").
|
||||
Select("ctg_id, COUNT(*) as cnt")
|
||||
test9["result"] = len(stats9) >= 0
|
||||
test9["stats"] = stats9
|
||||
tests = append(tests, test9)
|
||||
|
||||
test10 := Map{"name": "链式 Update 更新"}
|
||||
testArticle := that.Db.Table("article").Where("state", 0).Get("id")
|
||||
if testArticle != nil {
|
||||
affected := that.Db.Table("article").
|
||||
Where("id", testArticle.GetInt64("id")).
|
||||
Update(Map{"modify_time[#]": nowFunc(that)})
|
||||
test10["result"] = affected >= 0
|
||||
test10["affected"] = affected
|
||||
} else {
|
||||
test10["result"] = true
|
||||
test10["note"] = "无可用测试数据"
|
||||
}
|
||||
tests = append(tests, test10)
|
||||
|
||||
result["tests"] = tests
|
||||
result["success"] = true
|
||||
return result
|
||||
}
|
||||
|
||||
func testJoinQuery(that *Context) Map {
|
||||
result := Map{"name": "JOIN查询测试", "tests": Slice{}}
|
||||
tests := Slice{}
|
||||
|
||||
test1 := Map{"name": "LEFT JOIN 链式查询"}
|
||||
articles1 := that.Db.Table("article").
|
||||
LeftJoin("ctg", "article.ctg_id = ctg.id").
|
||||
Where("article.state", 0).
|
||||
Limit(0, 5).
|
||||
Select("article.id, article.title, ctg.name as ctg_name")
|
||||
test1["result"] = len(articles1) >= 0
|
||||
test1["count"] = len(articles1)
|
||||
test1["data"] = articles1
|
||||
tests = append(tests, test1)
|
||||
|
||||
test2 := Map{"name": "传统 JOIN 语法"}
|
||||
articles2 := that.Db.Select("article",
|
||||
Slice{
|
||||
Map{"[>]ctg": "article.ctg_id = ctg.id"},
|
||||
},
|
||||
"article.id, article.title, ctg.name as ctg_name",
|
||||
Map{
|
||||
"article.state": 0,
|
||||
"LIMIT": 5,
|
||||
})
|
||||
test2["result"] = len(articles2) >= 0
|
||||
test2["count"] = len(articles2)
|
||||
test2["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, test2)
|
||||
|
||||
test3 := Map{"name": "多表 JOIN"}
|
||||
articles3 := that.Db.Table("article").
|
||||
LeftJoin("ctg", "article.ctg_id = ctg.id").
|
||||
LeftJoin("admin", "article.admin_id = admin.id").
|
||||
Where("article.state", 0).
|
||||
Limit(0, 5).
|
||||
Select("article.id, article.title, ctg.name as ctg_name, admin.name as admin_name")
|
||||
test3["result"] = len(articles3) >= 0
|
||||
test3["count"] = len(articles3)
|
||||
test3["data"] = articles3
|
||||
tests = append(tests, test3)
|
||||
|
||||
test4 := Map{"name": "INNER JOIN"}
|
||||
articles4 := that.Db.Table("article").
|
||||
InnerJoin("ctg", "article.ctg_id = ctg.id").
|
||||
Where("ctg.state", 0).
|
||||
Limit(0, 5).
|
||||
Select("article.id, article.title, ctg.name as ctg_name")
|
||||
test4["result"] = len(articles4) >= 0
|
||||
test4["count"] = len(articles4)
|
||||
tests = append(tests, test4)
|
||||
|
||||
result["tests"] = tests
|
||||
result["success"] = true
|
||||
return result
|
||||
}
|
||||
|
||||
func testAggregate(that *Context) Map {
|
||||
result := Map{"name": "聚合函数测试", "tests": Slice{}}
|
||||
tests := Slice{}
|
||||
|
||||
test1 := Map{"name": "Count 总数统计"}
|
||||
count1 := that.Db.Count("article")
|
||||
test1["result"] = count1 >= 0
|
||||
test1["count"] = count1
|
||||
tests = append(tests, test1)
|
||||
|
||||
test2 := Map{"name": "Count 条件统计"}
|
||||
count2 := that.Db.Count("article", Map{"state": 0})
|
||||
test2["result"] = count2 >= 0
|
||||
test2["count"] = count2
|
||||
tests = append(tests, test2)
|
||||
|
||||
test3 := Map{"name": "Sum 求和 (单字段名)"}
|
||||
sum3 := that.Db.Sum("article", "click_num", Map{"state": 0})
|
||||
test3["result"] = sum3 >= 0
|
||||
test3["sum"] = sum3
|
||||
test3["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, test3)
|
||||
|
||||
test4 := Map{"name": "Avg 平均值 (单字段名)"}
|
||||
avg4 := that.Db.Avg("article", "click_num", Map{"state": 0})
|
||||
test4["result"] = avg4 >= 0
|
||||
test4["avg"] = avg4
|
||||
test4["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, test4)
|
||||
|
||||
test5 := Map{"name": "Max 最大值 (单字段名)"}
|
||||
max5 := that.Db.Max("article", "click_num", Map{"state": 0})
|
||||
test5["result"] = max5 >= 0
|
||||
test5["max"] = max5
|
||||
test5["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, test5)
|
||||
|
||||
test6 := Map{"name": "Min 最小值 (单字段名)"}
|
||||
min6 := that.Db.Min("article", "sort", Map{"state": 0})
|
||||
test6["result"] = true
|
||||
test6["min"] = min6
|
||||
test6["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, test6)
|
||||
|
||||
test7 := Map{"name": "GROUP BY 分组统计"}
|
||||
stats7 := that.Db.Select("article",
|
||||
"ctg_id, COUNT(*) as article_count, AVG(click_num) as avg_clicks, SUM(click_num) as total_clicks",
|
||||
Map{
|
||||
"state": 0,
|
||||
"GROUP": "ctg_id",
|
||||
"ORDER": "article_count DESC",
|
||||
"LIMIT": 10,
|
||||
})
|
||||
test7["result"] = len(stats7) >= 0
|
||||
test7["stats"] = stats7
|
||||
tests = append(tests, test7)
|
||||
|
||||
test8 := Map{"name": "Sum 求和 (table.column 格式)"}
|
||||
sum8 := that.Db.Sum("article", "article.click_num", Map{"state": 0})
|
||||
test8["result"] = sum8 >= 0
|
||||
test8["sum"] = sum8
|
||||
test8["match_single_field"] = sum8 == sum3
|
||||
test8["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, test8)
|
||||
|
||||
test9 := Map{"name": "聚合函数一致性验证"}
|
||||
avg9 := that.Db.Avg("article", "article.click_num", Map{"state": 0})
|
||||
max10 := that.Db.Max("article", "article.click_num", Map{"state": 0})
|
||||
min11 := that.Db.Min("article", "article.sort", Map{"state": 0})
|
||||
allMatch := (sum8 == sum3) && (avg9 == avg4) && (max10 == max5) && (min11 == min6)
|
||||
test9["result"] = allMatch
|
||||
test9["summary"] = fmt.Sprintf("Sum: %v=%v, Avg: %v=%v, Max: %v=%v, Min: %v=%v",
|
||||
sum3, sum8, avg4, avg9, max5, max10, min6, min11)
|
||||
tests = append(tests, test9)
|
||||
|
||||
joinSlice := Slice{
|
||||
Map{"[>]ctg": "article.ctg_id = ctg.id"},
|
||||
}
|
||||
test10 := Map{"name": "Sum 带 JOIN (table.column 格式)"}
|
||||
sum10 := that.Db.Sum("article", "article.click_num", joinSlice, Map{"article.state": 0})
|
||||
test10["result"] = sum10 >= 0
|
||||
test10["sum"] = sum10
|
||||
test10["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, test10)
|
||||
|
||||
test11 := Map{"name": "Count 带 JOIN"}
|
||||
count11 := that.Db.Count("article", joinSlice, Map{"article.state": 0})
|
||||
test11["result"] = count11 >= 0
|
||||
test11["count"] = count11
|
||||
test11["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, test11)
|
||||
|
||||
result["tests"] = tests
|
||||
result["success"] = true
|
||||
return result
|
||||
}
|
||||
|
||||
func testPagination(that *Context) Map {
|
||||
result := Map{"name": "分页查询测试", "tests": Slice{}}
|
||||
tests := Slice{}
|
||||
|
||||
test1 := Map{"name": "PageSelect 分页查询"}
|
||||
articles1 := that.Db.Page(1, 5).PageSelect("article", "*", Map{
|
||||
"state": 0,
|
||||
"ORDER": "id DESC",
|
||||
})
|
||||
test1["result"] = len(articles1) <= 5
|
||||
test1["count"] = len(articles1)
|
||||
tests = append(tests, test1)
|
||||
|
||||
test2 := Map{"name": "PageSelect 第二页"}
|
||||
articles2 := that.Db.Page(2, 5).PageSelect("article", "*", Map{
|
||||
"state": 0,
|
||||
"ORDER": "id DESC",
|
||||
})
|
||||
test2["result"] = len(articles2) <= 5
|
||||
test2["count"] = len(articles2)
|
||||
tests = append(tests, test2)
|
||||
|
||||
test3 := Map{"name": "链式 Page 分页"}
|
||||
articles3 := that.Db.Table("article").
|
||||
Where("state", 0).
|
||||
Order("id DESC").
|
||||
Page(1, 3).
|
||||
Select("id,title,author")
|
||||
test3["result"] = len(articles3) <= 3
|
||||
test3["count"] = len(articles3)
|
||||
tests = append(tests, test3)
|
||||
|
||||
test4 := Map{"name": "Offset 偏移查询"}
|
||||
articles4 := that.Db.Table("article").
|
||||
Where("state", 0).
|
||||
Limit(3).
|
||||
Offset(2).
|
||||
Select("id,title")
|
||||
test4["result"] = len(articles4) <= 3
|
||||
test4["count"] = len(articles4)
|
||||
test4["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, test4)
|
||||
|
||||
result["tests"] = tests
|
||||
result["success"] = true
|
||||
return result
|
||||
}
|
||||
|
||||
func testInserts(that *Context) Map {
|
||||
result := Map{"name": "批量插入测试", "tests": Slice{}}
|
||||
tests := Slice{}
|
||||
now := nowFunc(that)
|
||||
|
||||
test1 := Map{"name": "Inserts 批量插入"}
|
||||
timestamp := time.Now().UnixNano()
|
||||
affected1 := that.Db.Inserts("test_batch", []Map{
|
||||
{"name": fmt.Sprintf("批量测试1_%d", timestamp), "title": "标题1", "state": 1},
|
||||
{"name": fmt.Sprintf("批量测试2_%d", timestamp), "title": "标题2", "state": 1},
|
||||
{"name": fmt.Sprintf("批量测试3_%d", timestamp), "title": "标题3", "state": 1},
|
||||
})
|
||||
test1["result"] = affected1 >= 0
|
||||
test1["affected"] = affected1
|
||||
test1["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, test1)
|
||||
|
||||
test2 := Map{"name": "Inserts 带 [#] 标记"}
|
||||
timestamp2 := time.Now().UnixNano()
|
||||
affected2 := that.Db.Inserts("test_batch", []Map{
|
||||
{"name": fmt.Sprintf("带时间测试1_%d", timestamp2), "title": "标题带时间1", "state": 1, "create_time[#]": now},
|
||||
{"name": fmt.Sprintf("带时间测试2_%d", timestamp2), "title": "标题带时间2", "state": 1, "create_time[#]": now},
|
||||
})
|
||||
test2["result"] = affected2 >= 0
|
||||
test2["affected"] = affected2
|
||||
tests = append(tests, test2)
|
||||
|
||||
that.Db.Delete("test_batch", Map{"name[~]": fmt.Sprintf("_%d", timestamp)})
|
||||
that.Db.Delete("test_batch", Map{"name[~]": fmt.Sprintf("_%d", timestamp2)})
|
||||
|
||||
result["tests"] = tests
|
||||
result["success"] = true
|
||||
return result
|
||||
}
|
||||
|
||||
func testUpsert(that *Context) Map {
|
||||
result := Map{"name": "Upsert测试", "tests": Slice{}}
|
||||
tests := Slice{}
|
||||
now := nowFunc(that)
|
||||
|
||||
timestamp := time.Now().Unix()
|
||||
testPhone := fmt.Sprintf("199%08d", timestamp%100000000)
|
||||
|
||||
test1 := Map{"name": "Upsert 插入新记录 (admin表)"}
|
||||
affected1 := that.Db.Upsert("admin",
|
||||
Map{
|
||||
"name": "Upsert测试管理员",
|
||||
"phone": testPhone,
|
||||
"state": 1,
|
||||
"password": "test123",
|
||||
"role_id": 1,
|
||||
"title": "测试职位",
|
||||
"create_time[#]": now,
|
||||
"modify_time[#]": now,
|
||||
},
|
||||
Slice{"phone"},
|
||||
Slice{"name", "state", "title", "modify_time"},
|
||||
)
|
||||
test1["result"] = affected1 >= 0
|
||||
test1["affected"] = affected1
|
||||
test1["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, test1)
|
||||
|
||||
test2 := Map{"name": "Upsert 更新已存在记录"}
|
||||
affected2 := that.Db.Upsert("admin",
|
||||
Map{
|
||||
"name": "Upsert更新后管理员",
|
||||
"phone": testPhone,
|
||||
"state": 1,
|
||||
"password": "updated123",
|
||||
"role_id": 2,
|
||||
"title": "更新后职位",
|
||||
"create_time[#]": now,
|
||||
"modify_time[#]": now,
|
||||
},
|
||||
Slice{"phone"},
|
||||
Slice{"name", "title", "role_id", "modify_time"},
|
||||
)
|
||||
test2["result"] = affected2 >= 0
|
||||
test2["affected"] = affected2
|
||||
tests = append(tests, test2)
|
||||
|
||||
updatedAdmin := that.Db.Get("admin", "*", Map{"phone": testPhone})
|
||||
test2["updatedAdmin"] = updatedAdmin
|
||||
|
||||
that.Db.Delete("admin", Map{"phone": testPhone})
|
||||
|
||||
result["tests"] = tests
|
||||
result["success"] = true
|
||||
return result
|
||||
}
|
||||
|
||||
func testTransaction(that *Context) Map {
|
||||
result := Map{"name": "事务测试", "tests": Slice{}}
|
||||
tests := Slice{}
|
||||
now := nowFunc(that)
|
||||
|
||||
timestamp := time.Now().Unix()
|
||||
testName1 := fmt.Sprintf("事务测试_%d", timestamp)
|
||||
|
||||
test1 := Map{"name": "事务成功提交"}
|
||||
success1 := that.Db.Action(func(tx HoTimeDB) bool {
|
||||
recordId := tx.Insert("test_batch", Map{
|
||||
"name": testName1,
|
||||
"title": "事务提交测试",
|
||||
"state": 1,
|
||||
"create_time[#]": now,
|
||||
})
|
||||
return recordId != 0
|
||||
})
|
||||
test1["result"] = success1
|
||||
checkRecord := that.Db.Get("test_batch", "*", Map{"name": testName1})
|
||||
test1["recordExists"] = checkRecord != nil
|
||||
tests = append(tests, test1)
|
||||
|
||||
test2 := Map{"name": "事务回滚"}
|
||||
testName2 := fmt.Sprintf("事务回滚测试_%d", timestamp)
|
||||
success2 := that.Db.Action(func(tx HoTimeDB) bool {
|
||||
_ = tx.Insert("test_batch", Map{
|
||||
"name": testName2,
|
||||
"title": "事务回滚测试",
|
||||
"state": 1,
|
||||
"create_time[#]": now,
|
||||
})
|
||||
return false
|
||||
})
|
||||
test2["result"] = !success2
|
||||
checkRecord2 := that.Db.Get("test_batch", "*", Map{"name": testName2})
|
||||
test2["recordRolledBack"] = checkRecord2 == nil
|
||||
tests = append(tests, test2)
|
||||
|
||||
that.Db.Delete("test_batch", Map{"name": testName1})
|
||||
|
||||
result["tests"] = tests
|
||||
result["success"] = true
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
var TestTest = CtrTest{
|
||||
// ======== 错误接口测试 ========
|
||||
"test": {Desc: "固定错误返回", Func: func(a *Api) {
|
||||
a.Get("固定返回错误码2", 2, "dsadasd")
|
||||
}},
|
||||
|
||||
// ======== ORM 全量测试 ========
|
||||
"all": {Desc: "运行全部通用测试", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result == nil {
|
||||
return fmt.Errorf("result 为 nil")
|
||||
}
|
||||
subTests := []string{
|
||||
"1_basic_crud", "2_condition_syntax", "3_chain_query",
|
||||
"4_join_query", "5_aggregate", "6_pagination",
|
||||
"7_batch_insert", "8_upsert", "9_transaction",
|
||||
}
|
||||
for _, key := range subTests {
|
||||
sub := result.GetMap(key)
|
||||
if sub == nil {
|
||||
return fmt.Errorf("子测试 %s 缺失", key)
|
||||
}
|
||||
if sub.GetString("name") == "" {
|
||||
return fmt.Errorf("子测试 %s 缺少 name 字段", key)
|
||||
}
|
||||
tests := sub.GetSlice("tests")
|
||||
if len(tests) == 0 {
|
||||
return fmt.Errorf("子测试 %s 的 tests 数组为空", key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}).Get("全部测试", 0, Map{
|
||||
"1_basic_crud": Map{
|
||||
"name": "sample", "success": true,
|
||||
"tests": Slice{Map{"name": "sample", "result": true}},
|
||||
},
|
||||
"2_condition_syntax": Map{
|
||||
"name": "sample", "success": true,
|
||||
"tests": Slice{Map{"name": "sample", "result": true}},
|
||||
},
|
||||
"3_chain_query": Map{
|
||||
"name": "sample", "success": true,
|
||||
"tests": Slice{Map{"name": "sample"}},
|
||||
},
|
||||
"4_join_query": Map{
|
||||
"name": "sample", "success": true,
|
||||
"tests": Slice{Map{"name": "sample"}},
|
||||
},
|
||||
"5_aggregate": Map{
|
||||
"name": "sample", "success": true,
|
||||
"tests": Slice{Map{"name": "sample"}},
|
||||
},
|
||||
"6_pagination": Map{
|
||||
"name": "sample", "success": true,
|
||||
"tests": Slice{Map{"name": "sample"}},
|
||||
},
|
||||
"7_batch_insert": Map{
|
||||
"name": "sample", "success": true,
|
||||
"tests": Slice{Map{"name": "sample"}},
|
||||
},
|
||||
"8_upsert": Map{
|
||||
"name": "sample", "success": true,
|
||||
"tests": Slice{Map{"name": "sample"}},
|
||||
},
|
||||
"9_transaction": Map{
|
||||
"name": "sample", "success": true,
|
||||
"tests": Slice{Map{"name": "sample"}},
|
||||
},
|
||||
})
|
||||
}},
|
||||
|
||||
// ======== 单项 ORM 测试 ========
|
||||
"crud": {Desc: "基础 CRUD 测试", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
return verifyOrmSubTest(a, "基础CRUD测试")
|
||||
}).Get("CRUD测试", 0, Map{
|
||||
"name": "sample", "success": true,
|
||||
"tests": Slice{Map{"name": "sample", "result": true}},
|
||||
})
|
||||
}},
|
||||
|
||||
"condition": {Desc: "条件查询语法测试", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
return verifyOrmSubTest(a, "条件查询语法测试")
|
||||
}).Get("条件查询", 0, Map{
|
||||
"name": "sample", "success": true,
|
||||
"tests": Slice{Map{"name": "sample", "result": true}},
|
||||
})
|
||||
}},
|
||||
|
||||
"chain": {Desc: "链式查询测试", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
return verifyOrmSubTest(a, "链式查询测试")
|
||||
}).Get("链式查询", 0, Map{
|
||||
"name": "sample", "success": true,
|
||||
"tests": Slice{Map{"name": "sample"}},
|
||||
})
|
||||
}},
|
||||
|
||||
"join": {Desc: "JOIN 查询测试", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
return verifyOrmSubTest(a, "JOIN查询测试")
|
||||
}).Get("JOIN查询", 0, Map{
|
||||
"name": "sample", "success": true,
|
||||
"tests": Slice{Map{"name": "sample"}},
|
||||
})
|
||||
}},
|
||||
|
||||
"aggregate": {Desc: "聚合函数测试", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
return verifyOrmSubTest(a, "聚合函数测试")
|
||||
}).Get("聚合函数", 0, Map{
|
||||
"name": "sample", "success": true,
|
||||
"tests": Slice{Map{"name": "sample"}},
|
||||
})
|
||||
}},
|
||||
|
||||
"pagination": {Desc: "分页查询测试", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
return verifyOrmSubTest(a, "分页查询测试")
|
||||
}).Get("分页查询", 0, Map{
|
||||
"name": "sample", "success": true,
|
||||
"tests": Slice{Map{"name": "sample"}},
|
||||
})
|
||||
}},
|
||||
|
||||
"batch": {Desc: "批量插入测试", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
return verifyOrmSubTest(a, "批量插入测试")
|
||||
}).Get("批量插入", 0, Map{
|
||||
"name": "sample", "success": true,
|
||||
"tests": Slice{Map{"name": "sample"}},
|
||||
})
|
||||
}},
|
||||
|
||||
"upsert": {Desc: "Upsert 测试", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
return verifyOrmSubTest(a, "Upsert测试")
|
||||
}).Get("Upsert测试", 0, Map{
|
||||
"name": "sample", "success": true,
|
||||
"tests": Slice{Map{"name": "sample"}},
|
||||
})
|
||||
}},
|
||||
|
||||
"transaction": {Desc: "事务测试", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
return verifyOrmSubTest(a, "事务测试")
|
||||
}).Get("事务测试", 0, Map{
|
||||
"name": "sample", "success": true,
|
||||
"tests": Slice{Map{"name": "sample"}},
|
||||
})
|
||||
}},
|
||||
}
|
||||
|
||||
// verifyOrmSubTest 校验 ORM 自测端点的结构完整性
|
||||
// 注意:这些 handler 是诊断工具,某些子项(如 table.column 格式聚合)在特定数据库下
|
||||
// 预期 result=false,因此不逐项检查 result 标志,只验证结构
|
||||
func verifyOrmSubTest(a *Api, testName string) error {
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result == nil {
|
||||
return fmt.Errorf("result 为 nil")
|
||||
}
|
||||
if result.GetString("name") == "" {
|
||||
return fmt.Errorf("%s 缺少 name 字段", testName)
|
||||
}
|
||||
tests := result.GetSlice("tests")
|
||||
if len(tests) == 0 {
|
||||
return fmt.Errorf("%s tests 数组为空", testName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"code.hoteas.com/golang/hotime"
|
||||
"code.hoteas.com/golang/hotime/cache"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
// TestBatchCacheOperations 测试所有批量缓存操作
|
||||
func TestBatchCacheOperations(app *hotime.Application) {
|
||||
fmt.Println("\n========== 批量缓存操作测试开始 ==========")
|
||||
|
||||
// 测试1: 测试 CacheMemory 批量操作
|
||||
fmt.Println("\n--- 测试1: CacheMemory 批量操作 ---")
|
||||
testCacheMemoryBatch(app)
|
||||
|
||||
// 测试2: 测试 CacheDb 批量操作
|
||||
fmt.Println("\n--- 测试2: CacheDb 批量操作 ---")
|
||||
testCacheDbBatch(app)
|
||||
|
||||
// 测试3: 测试 HoTimeCache 三级缓存批量操作
|
||||
fmt.Println("\n--- 测试3: HoTimeCache 三级缓存批量操作 ---")
|
||||
testHoTimeCacheBatch(app)
|
||||
|
||||
// 测试4: 测试 SessionIns 批量操作
|
||||
fmt.Println("\n--- 测试4: SessionIns 批量操作 ---")
|
||||
testSessionInsBatch(app)
|
||||
|
||||
// 测试5: 测试缓存反哺机制
|
||||
fmt.Println("\n--- 测试5: 缓存反哺机制测试 ---")
|
||||
testCacheBackfill(app)
|
||||
|
||||
// 测试6: 测试批量操作效率(一次性写入验证)
|
||||
fmt.Println("\n--- 测试6: 批量操作效率测试 ---")
|
||||
testBatchEfficiency(app)
|
||||
|
||||
fmt.Println("\n========== 批量缓存操作测试完成 ==========")
|
||||
}
|
||||
|
||||
// testCacheMemoryBatch 测试内存缓存批量操作
|
||||
func testCacheMemoryBatch(app *hotime.Application) {
|
||||
memCache := &cache.CacheMemory{TimeOut: 3600, DbSet: true, SessionSet: true}
|
||||
|
||||
// 测试 CachesSet
|
||||
testData := Map{
|
||||
"mem_key1": "value1",
|
||||
"mem_key2": "value2",
|
||||
"mem_key3": "value3",
|
||||
}
|
||||
memCache.CachesSet(testData)
|
||||
|
||||
// 测试 CachesGet
|
||||
keys := []string{"mem_key1", "mem_key2", "mem_key3", "mem_key_not_exist"}
|
||||
result := memCache.CachesGet(keys)
|
||||
|
||||
if len(result) != 3 {
|
||||
fmt.Printf(" [FAIL] CacheMemory.CachesGet: 期望3个结果,实际%d个\n", len(result))
|
||||
} else {
|
||||
fmt.Println(" [PASS] CacheMemory.CachesGet: 批量获取正确")
|
||||
}
|
||||
|
||||
// 测试 CachesDelete
|
||||
memCache.CachesDelete([]string{"mem_key1", "mem_key2"})
|
||||
result2 := memCache.CachesGet(keys)
|
||||
|
||||
if len(result2) != 1 || result2["mem_key3"] == nil {
|
||||
fmt.Printf(" [FAIL] CacheMemory.CachesDelete: 删除后期望1个结果,实际%d个\n", len(result2))
|
||||
} else {
|
||||
fmt.Println(" [PASS] CacheMemory.CachesDelete: 批量删除正确")
|
||||
}
|
||||
}
|
||||
|
||||
// testCacheDbBatch 测试数据库缓存批量操作
|
||||
func testCacheDbBatch(app *hotime.Application) {
|
||||
// 使用应用的数据库连接
|
||||
dbCache := &cache.CacheDb{
|
||||
TimeOut: 3600,
|
||||
DbSet: true,
|
||||
SessionSet: true,
|
||||
Mode: cache.CacheModeNew,
|
||||
Db: &app.Db,
|
||||
}
|
||||
|
||||
// 清理测试数据
|
||||
dbCache.CachesDelete([]string{"db_batch_key1", "db_batch_key2", "db_batch_key3"})
|
||||
|
||||
// 测试 CachesSet
|
||||
testData := Map{
|
||||
"db_batch_key1": "db_value1",
|
||||
"db_batch_key2": "db_value2",
|
||||
"db_batch_key3": "db_value3",
|
||||
}
|
||||
dbCache.CachesSet(testData)
|
||||
|
||||
// 测试 CachesGet
|
||||
keys := []string{"db_batch_key1", "db_batch_key2", "db_batch_key3", "db_not_exist"}
|
||||
result := dbCache.CachesGet(keys)
|
||||
|
||||
if len(result) != 3 {
|
||||
fmt.Printf(" [FAIL] CacheDb.CachesGet: 期望3个结果,实际%d个\n", len(result))
|
||||
} else {
|
||||
fmt.Println(" [PASS] CacheDb.CachesGet: 批量获取正确")
|
||||
}
|
||||
|
||||
// 验证值正确性
|
||||
if result["db_batch_key1"] != "db_value1" {
|
||||
fmt.Printf(" [FAIL] CacheDb.CachesGet: db_batch_key1 值不正确,期望 db_value1,实际 %v\n", result["db_batch_key1"])
|
||||
} else {
|
||||
fmt.Println(" [PASS] CacheDb.CachesGet: 值内容正确")
|
||||
}
|
||||
|
||||
// 测试 CachesDelete
|
||||
dbCache.CachesDelete([]string{"db_batch_key1", "db_batch_key2"})
|
||||
result2 := dbCache.CachesGet(keys)
|
||||
|
||||
if len(result2) != 1 {
|
||||
fmt.Printf(" [FAIL] CacheDb.CachesDelete: 删除后期望1个结果,实际%d个\n", len(result2))
|
||||
} else {
|
||||
fmt.Println(" [PASS] CacheDb.CachesDelete: 批量删除正确")
|
||||
}
|
||||
|
||||
// 清理
|
||||
dbCache.CachesDelete([]string{"db_batch_key3"})
|
||||
}
|
||||
|
||||
// testHoTimeCacheBatch 测试 HoTimeCache 三级缓存批量操作
|
||||
func testHoTimeCacheBatch(app *hotime.Application) {
|
||||
htCache := app.HoTimeCache
|
||||
|
||||
// 清理测试数据
|
||||
htCache.SessionsDelete([]string{
|
||||
hotime.HEAD_SESSION_ADD + "ht_batch_key1",
|
||||
hotime.HEAD_SESSION_ADD + "ht_batch_key2",
|
||||
hotime.HEAD_SESSION_ADD + "ht_batch_key3",
|
||||
})
|
||||
|
||||
// 测试 SessionsSet
|
||||
testData := Map{
|
||||
hotime.HEAD_SESSION_ADD + "ht_batch_key1": Map{"user": "test1", "role": "admin"},
|
||||
hotime.HEAD_SESSION_ADD + "ht_batch_key2": Map{"user": "test2", "role": "user"},
|
||||
hotime.HEAD_SESSION_ADD + "ht_batch_key3": Map{"user": "test3", "role": "guest"},
|
||||
}
|
||||
htCache.SessionsSet(testData)
|
||||
|
||||
// 测试 SessionsGet
|
||||
keys := []string{
|
||||
hotime.HEAD_SESSION_ADD + "ht_batch_key1",
|
||||
hotime.HEAD_SESSION_ADD + "ht_batch_key2",
|
||||
hotime.HEAD_SESSION_ADD + "ht_batch_key3",
|
||||
hotime.HEAD_SESSION_ADD + "ht_not_exist",
|
||||
}
|
||||
result := htCache.SessionsGet(keys)
|
||||
|
||||
if len(result) != 3 {
|
||||
fmt.Printf(" [FAIL] HoTimeCache.SessionsGet: 期望3个结果,实际%d个\n", len(result))
|
||||
} else {
|
||||
fmt.Println(" [PASS] HoTimeCache.SessionsGet: 批量获取正确")
|
||||
}
|
||||
|
||||
// 测试 SessionsDelete
|
||||
htCache.SessionsDelete([]string{
|
||||
hotime.HEAD_SESSION_ADD + "ht_batch_key1",
|
||||
hotime.HEAD_SESSION_ADD + "ht_batch_key2",
|
||||
})
|
||||
result2 := htCache.SessionsGet(keys)
|
||||
|
||||
if len(result2) != 1 {
|
||||
fmt.Printf(" [FAIL] HoTimeCache.SessionsDelete: 删除后期望1个结果,实际%d个\n", len(result2))
|
||||
} else {
|
||||
fmt.Println(" [PASS] HoTimeCache.SessionsDelete: 批量删除正确")
|
||||
}
|
||||
|
||||
// 清理
|
||||
htCache.SessionsDelete([]string{hotime.HEAD_SESSION_ADD + "ht_batch_key3"})
|
||||
}
|
||||
|
||||
// testSessionInsBatch 测试 SessionIns 批量操作
|
||||
func testSessionInsBatch(app *hotime.Application) {
|
||||
// 创建一个模拟的 SessionIns
|
||||
session := &hotime.SessionIns{
|
||||
SessionId: "test_batch_session_" + ObjToStr(time.Now().UnixNano()),
|
||||
}
|
||||
session.Init(app.HoTimeCache)
|
||||
|
||||
// 测试 SessionsSet
|
||||
testData := Map{
|
||||
"field1": "value1",
|
||||
"field2": 123,
|
||||
"field3": Map{"nested": "data"},
|
||||
}
|
||||
session.SessionsSet(testData)
|
||||
|
||||
// 测试 SessionsGet
|
||||
result := session.SessionsGet("field1", "field2", "field3", "not_exist")
|
||||
|
||||
if len(result) != 3 {
|
||||
fmt.Printf(" [FAIL] SessionIns.SessionsGet: 期望3个结果,实际%d个\n", len(result))
|
||||
} else {
|
||||
fmt.Println(" [PASS] SessionIns.SessionsGet: 批量获取正确")
|
||||
}
|
||||
|
||||
// 验证值类型
|
||||
if result["field1"] != "value1" {
|
||||
fmt.Printf(" [FAIL] SessionIns.SessionsGet: field1 值不正确\n")
|
||||
} else {
|
||||
fmt.Println(" [PASS] SessionIns.SessionsGet: 字符串值正确")
|
||||
}
|
||||
|
||||
var convErr Error
|
||||
if ObjToInt(result["field2"], &convErr) != 123 {
|
||||
fmt.Printf(" [FAIL] SessionIns.SessionsGet: field2 值不正确\n")
|
||||
} else {
|
||||
fmt.Println(" [PASS] SessionIns.SessionsGet: 数值类型正确")
|
||||
}
|
||||
|
||||
// 测试 SessionsDelete
|
||||
session.SessionsDelete("field1", "field2")
|
||||
result2 := session.SessionsGet("field1", "field2", "field3")
|
||||
|
||||
if len(result2) != 1 {
|
||||
fmt.Printf(" [FAIL] SessionIns.SessionsDelete: 删除后期望1个结果,实际%d个\n", len(result2))
|
||||
} else {
|
||||
fmt.Println(" [PASS] SessionIns.SessionsDelete: 批量删除正确")
|
||||
}
|
||||
}
|
||||
|
||||
// testCacheBackfill 测试缓存反哺机制
|
||||
func testCacheBackfill(app *hotime.Application) {
|
||||
htCache := app.HoTimeCache
|
||||
|
||||
// 直接写入数据库缓存(绕过 memory)模拟只有 db 有数据的情况
|
||||
dbCache := &cache.CacheDb{
|
||||
TimeOut: 3600,
|
||||
DbSet: true,
|
||||
SessionSet: true,
|
||||
Mode: cache.CacheModeNew,
|
||||
Db: &app.Db,
|
||||
}
|
||||
|
||||
testKey := "backfill_test_key_" + ObjToStr(time.Now().UnixNano())
|
||||
testValue := Map{"backfill": "test_data"}
|
||||
|
||||
// 直接写入 db
|
||||
dbCache.Cache(testKey, testValue)
|
||||
|
||||
// 通过 HoTimeCache 批量获取,应该触发反哺到 memory
|
||||
keys := []string{testKey}
|
||||
result := htCache.CachesGet(keys)
|
||||
|
||||
if len(result) != 1 || result[testKey] == nil {
|
||||
fmt.Println(" [FAIL] 缓存反哺: 从 DB 读取失败")
|
||||
} else {
|
||||
fmt.Println(" [PASS] 缓存反哺: 从 DB 读取成功")
|
||||
}
|
||||
|
||||
// 清理
|
||||
htCache.CachesDelete(keys)
|
||||
}
|
||||
|
||||
// testBatchEfficiency 测试批量操作效率
|
||||
func testBatchEfficiency(app *hotime.Application) {
|
||||
session := &hotime.SessionIns{
|
||||
SessionId: "efficiency_test_" + ObjToStr(time.Now().UnixNano()),
|
||||
}
|
||||
session.Init(app.HoTimeCache)
|
||||
|
||||
// 记录批量设置开始时间
|
||||
startTime := time.Now()
|
||||
|
||||
// 设置10个字段
|
||||
testData := Map{}
|
||||
for i := 0; i < 10; i++ {
|
||||
testData[fmt.Sprintf("eff_field_%d", i)] = fmt.Sprintf("value_%d", i)
|
||||
}
|
||||
session.SessionsSet(testData)
|
||||
|
||||
batchDuration := time.Since(startTime)
|
||||
|
||||
// 对比单个设置
|
||||
session2 := &hotime.SessionIns{
|
||||
SessionId: "efficiency_test_single_" + ObjToStr(time.Now().UnixNano()),
|
||||
}
|
||||
session2.Init(app.HoTimeCache)
|
||||
|
||||
startTime2 := time.Now()
|
||||
for i := 0; i < 10; i++ {
|
||||
session2.Session(fmt.Sprintf("single_field_%d", i), fmt.Sprintf("value_%d", i))
|
||||
}
|
||||
singleDuration := time.Since(startTime2)
|
||||
|
||||
fmt.Printf(" 批量设置10个字段耗时: %v\n", batchDuration)
|
||||
fmt.Printf(" 单个设置10个字段耗时: %v\n", singleDuration)
|
||||
|
||||
if batchDuration < singleDuration {
|
||||
fmt.Println(" [PASS] 批量操作效率: 批量操作更快")
|
||||
} else {
|
||||
fmt.Println(" [WARN] 批量操作效率: 批量操作未体现优势(可能数据量太小)")
|
||||
}
|
||||
|
||||
// 批量获取测试
|
||||
startTime3 := time.Now()
|
||||
keys := make([]string, 10)
|
||||
for i := 0; i < 10; i++ {
|
||||
keys[i] = fmt.Sprintf("eff_field_%d", i)
|
||||
}
|
||||
session.SessionsGet(keys...)
|
||||
batchGetDuration := time.Since(startTime3)
|
||||
|
||||
fmt.Printf(" 批量获取10个字段耗时: %v\n", batchGetDuration)
|
||||
}
|
||||
|
||||
// getMapKeys 获取 Map 的所有键
|
||||
func getMapKeys(m Map) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
+2
-890
@@ -2,11 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
. "code.hoteas.com/golang/hotime/db"
|
||||
"code.hoteas.com/golang/hotime/example/app"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -16,890 +12,6 @@ func main() {
|
||||
})
|
||||
|
||||
appIns.Run(Router{
|
||||
"app": {
|
||||
"test": {
|
||||
// 测试入口 - 运行所有测试
|
||||
"all": func(that *Context) {
|
||||
results := Map{}
|
||||
|
||||
// 初始化测试表
|
||||
initTestTables(that)
|
||||
|
||||
// 1. 基础 CRUD 测试
|
||||
results["1_basic_crud"] = testBasicCRUD(that)
|
||||
|
||||
// 2. 条件查询语法测试
|
||||
results["2_condition_syntax"] = testConditionSyntax(that)
|
||||
|
||||
// 3. 链式查询测试
|
||||
results["3_chain_query"] = testChainQuery(that)
|
||||
|
||||
// 4. JOIN 查询测试
|
||||
results["4_join_query"] = testJoinQuery(that)
|
||||
|
||||
// 5. 聚合函数测试
|
||||
results["5_aggregate"] = testAggregate(that)
|
||||
|
||||
// 6. 分页查询测试
|
||||
results["6_pagination"] = testPagination(that)
|
||||
|
||||
// 7. 批量插入测试
|
||||
results["7_batch_insert"] = testInserts(that)
|
||||
|
||||
// 8. Upsert 测试
|
||||
results["8_upsert"] = testUpsert(that)
|
||||
|
||||
// 9. 事务测试
|
||||
results["9_transaction"] = testTransaction(that)
|
||||
|
||||
// 10. 原生 SQL 测试
|
||||
results["10_raw_sql"] = testRawSQL(that)
|
||||
|
||||
that.Display(0, results)
|
||||
},
|
||||
|
||||
// 查询数据库表结构
|
||||
"tables": func(that *Context) {
|
||||
// 查询所有表
|
||||
tables := that.Db.Query("SHOW TABLES")
|
||||
that.Display(0, Map{"tables": tables})
|
||||
},
|
||||
|
||||
// 查询指定表的结构
|
||||
"describe": func(that *Context) {
|
||||
tableName := that.Req.FormValue("table")
|
||||
if tableName == "" {
|
||||
that.Display(1, "请提供 table 参数")
|
||||
return
|
||||
}
|
||||
// 查询表结构
|
||||
columns := that.Db.Query("DESCRIBE " + tableName)
|
||||
// 查询表数据(前10条)
|
||||
data := that.Db.Select(tableName, Map{"LIMIT": 10})
|
||||
that.Display(0, Map{"table": tableName, "columns": columns, "sample_data": data})
|
||||
},
|
||||
|
||||
// 单独测试入口
|
||||
"crud": func(that *Context) { that.Display(0, testBasicCRUD(that)) },
|
||||
"condition": func(that *Context) { that.Display(0, testConditionSyntax(that)) },
|
||||
"chain": func(that *Context) { that.Display(0, testChainQuery(that)) },
|
||||
"join": func(that *Context) { that.Display(0, testJoinQuery(that)) },
|
||||
"aggregate": func(that *Context) { that.Display(0, testAggregate(that)) },
|
||||
"pagination": func(that *Context) { that.Display(0, testPagination(that)) },
|
||||
"batch": func(that *Context) { that.Display(0, testInserts(that)) },
|
||||
"upsert": func(that *Context) { that.Display(0, testUpsert(that)) },
|
||||
"transaction": func(that *Context) { that.Display(0, testTransaction(that)) },
|
||||
"rawsql": func(that *Context) { that.Display(0, testRawSQL(that)) },
|
||||
},
|
||||
},
|
||||
"app": app.Project,
|
||||
})
|
||||
}
|
||||
|
||||
// initTestTables 初始化测试表(MySQL真实数据库)
|
||||
func initTestTables(that *Context) {
|
||||
// MySQL 真实数据库已有表,创建测试用的临时表
|
||||
// 创建测试用的批量插入表
|
||||
that.Db.Exec(`CREATE TABLE IF NOT EXISTS test_batch (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(100),
|
||||
title VARCHAR(200),
|
||||
state INT DEFAULT 0,
|
||||
create_time DATETIME
|
||||
)`)
|
||||
|
||||
// 检查 admin 表数据(确认表存在)
|
||||
_ = that.Db.Count("admin")
|
||||
_ = that.Db.Count("article")
|
||||
}
|
||||
|
||||
// ==================== 1. 基础 CRUD 测试 ====================
|
||||
func testBasicCRUD(that *Context) Map {
|
||||
result := Map{"name": "基础CRUD测试", "tests": Slice{}}
|
||||
tests := Slice{}
|
||||
|
||||
// 1.1 Insert 测试 - 使用 admin 表
|
||||
insertTest := Map{"name": "Insert 插入测试 (admin表)"}
|
||||
adminId := that.Db.Insert("admin", Map{
|
||||
"name": "测试管理员_" + fmt.Sprintf("%d", time.Now().Unix()),
|
||||
"phone": fmt.Sprintf("138%d", time.Now().Unix()%100000000),
|
||||
"state": 1,
|
||||
"password": "test123456",
|
||||
"role_id": 1,
|
||||
"title": "测试职位",
|
||||
"create_time[#]": "NOW()",
|
||||
"modify_time[#]": "NOW()",
|
||||
})
|
||||
insertTest["result"] = adminId > 0
|
||||
insertTest["adminId"] = adminId
|
||||
insertTest["lastQuery"] = that.Db.LastQuery
|
||||
tests = append(tests, insertTest)
|
||||
|
||||
// 1.2 Get 测试
|
||||
getTest := Map{"name": "Get 获取单条记录测试"}
|
||||
admin := that.Db.Get("admin", "*", Map{"id": adminId})
|
||||
getTest["result"] = admin != nil && admin.GetInt64("id") == adminId
|
||||
getTest["admin"] = admin
|
||||
tests = append(tests, getTest)
|
||||
|
||||
// 1.3 Select 测试 - 单条件
|
||||
selectTest1 := Map{"name": "Select 单条件查询测试"}
|
||||
admins1 := that.Db.Select("admin", "*", Map{"state": 1, "LIMIT": 5})
|
||||
selectTest1["result"] = len(admins1) >= 0 // 可能表中没有数据
|
||||
selectTest1["count"] = len(admins1)
|
||||
tests = append(tests, selectTest1)
|
||||
|
||||
// 1.4 Select 测试 - 多条件(自动 AND)
|
||||
selectTest2 := Map{"name": "Select 多条件自动AND测试"}
|
||||
admins2 := that.Db.Select("admin", "*", Map{
|
||||
"state": 1,
|
||||
"role_id[>]": 0,
|
||||
"ORDER": "id DESC",
|
||||
"LIMIT": 5,
|
||||
})
|
||||
selectTest2["result"] = true // 只要不报错就算成功
|
||||
selectTest2["count"] = len(admins2)
|
||||
selectTest2["lastQuery"] = that.Db.LastQuery
|
||||
tests = append(tests, selectTest2)
|
||||
|
||||
// 1.5 Update 测试
|
||||
updateTest := Map{"name": "Update 更新测试"}
|
||||
affected := that.Db.Update("admin", Map{
|
||||
"title": "更新后的职位",
|
||||
"modify_time[#]": "NOW()",
|
||||
}, Map{"id": adminId})
|
||||
updateTest["result"] = affected > 0
|
||||
updateTest["affected"] = affected
|
||||
tests = append(tests, updateTest)
|
||||
|
||||
// 1.6 Delete 测试 - 使用 test_batch 表
|
||||
deleteTest := Map{"name": "Delete 删除测试"}
|
||||
// 先创建一个临时记录用于删除
|
||||
tempId := that.Db.Insert("test_batch", Map{
|
||||
"name": "临时删除测试",
|
||||
"title": "测试标题",
|
||||
"state": 1,
|
||||
"create_time[#]": "NOW()",
|
||||
})
|
||||
deleteAffected := that.Db.Delete("test_batch", Map{"id": tempId})
|
||||
deleteTest["result"] = deleteAffected > 0
|
||||
deleteTest["affected"] = deleteAffected
|
||||
tests = append(tests, deleteTest)
|
||||
|
||||
result["tests"] = tests
|
||||
result["success"] = true
|
||||
return result
|
||||
}
|
||||
|
||||
// ==================== 2. 条件查询语法测试 ====================
|
||||
func testConditionSyntax(that *Context) Map {
|
||||
result := Map{"name": "条件查询语法测试", "tests": Slice{}}
|
||||
tests := Slice{}
|
||||
|
||||
// 2.1 等于 (=) - 使用 article 表
|
||||
test1 := Map{"name": "等于条件 (=)"}
|
||||
articles1 := that.Db.Select("article", "id,title", Map{"state": 0, "LIMIT": 3})
|
||||
test1["result"] = true
|
||||
test1["count"] = len(articles1)
|
||||
tests = append(tests, test1)
|
||||
|
||||
// 2.2 不等于 ([!])
|
||||
test2 := Map{"name": "不等于条件 ([!])"}
|
||||
articles2 := that.Db.Select("article", "id,title,state", Map{"state[!]": -1, "LIMIT": 3})
|
||||
test2["result"] = true
|
||||
test2["count"] = len(articles2)
|
||||
test2["lastQuery"] = that.Db.LastQuery
|
||||
tests = append(tests, test2)
|
||||
|
||||
// 2.3 大于 ([>]) 和 小于 ([<])
|
||||
test3 := Map{"name": "大于小于条件 ([>], [<])"}
|
||||
articles3 := that.Db.Select("article", "id,title,click_num", Map{
|
||||
"click_num[>]": 0,
|
||||
"click_num[<]": 100000,
|
||||
"LIMIT": 3,
|
||||
})
|
||||
test3["result"] = true
|
||||
test3["count"] = len(articles3)
|
||||
tests = append(tests, test3)
|
||||
|
||||
// 2.4 大于等于 ([>=]) 和 小于等于 ([<=])
|
||||
test4 := Map{"name": "大于等于小于等于条件 ([>=], [<=])"}
|
||||
articles4 := that.Db.Select("article", "id,title,sort", Map{
|
||||
"sort[>=]": 0,
|
||||
"sort[<=]": 100,
|
||||
"LIMIT": 3,
|
||||
})
|
||||
test4["result"] = true
|
||||
test4["count"] = len(articles4)
|
||||
tests = append(tests, test4)
|
||||
|
||||
// 2.5 LIKE 模糊查询 ([~])
|
||||
test5 := Map{"name": "LIKE 模糊查询 ([~])"}
|
||||
articles5 := that.Db.Select("article", "id,title", Map{"title[~]": "新闻", "LIMIT": 3})
|
||||
test5["result"] = true
|
||||
test5["count"] = len(articles5)
|
||||
test5["lastQuery"] = that.Db.LastQuery
|
||||
tests = append(tests, test5)
|
||||
|
||||
// 2.6 右模糊 ([~!])
|
||||
test6 := Map{"name": "右模糊查询 ([~!])"}
|
||||
articles6 := that.Db.Select("admin", "id,name", Map{"name[~!]": "管理", "LIMIT": 3})
|
||||
test6["result"] = true
|
||||
test6["count"] = len(articles6)
|
||||
tests = append(tests, test6)
|
||||
|
||||
// 2.7 BETWEEN ([<>])
|
||||
test7 := Map{"name": "BETWEEN 区间查询 ([<>])"}
|
||||
articles7 := that.Db.Select("article", "id,title,click_num", Map{"click_num[<>]": Slice{0, 1000}, "LIMIT": 3})
|
||||
test7["result"] = true
|
||||
test7["count"] = len(articles7)
|
||||
test7["lastQuery"] = that.Db.LastQuery
|
||||
tests = append(tests, test7)
|
||||
|
||||
// 2.8 NOT BETWEEN ([><])
|
||||
test8 := Map{"name": "NOT BETWEEN 查询 ([><])"}
|
||||
articles8 := that.Db.Select("article", "id,title,sort", Map{"sort[><]": Slice{-10, 0}, "LIMIT": 3})
|
||||
test8["result"] = true
|
||||
test8["count"] = len(articles8)
|
||||
tests = append(tests, test8)
|
||||
|
||||
// 2.9 IN 查询
|
||||
test9 := Map{"name": "IN 查询"}
|
||||
articles9 := that.Db.Select("article", "id,title", Map{"id": Slice{1, 2, 3, 4, 5}, "LIMIT": 5})
|
||||
test9["result"] = true
|
||||
test9["count"] = len(articles9)
|
||||
test9["lastQuery"] = that.Db.LastQuery
|
||||
tests = append(tests, test9)
|
||||
|
||||
// 2.10 NOT IN ([!])
|
||||
test10 := Map{"name": "NOT IN 查询 ([!])"}
|
||||
articles10 := that.Db.Select("article", "id,title", Map{"id[!]": Slice{1, 2, 3}, "LIMIT": 5})
|
||||
test10["result"] = true
|
||||
test10["count"] = len(articles10)
|
||||
tests = append(tests, test10)
|
||||
|
||||
// 2.11 IS NULL
|
||||
test11 := Map{"name": "IS NULL 查询"}
|
||||
articles11 := that.Db.Select("article", "id,title,img", Map{"img": nil, "LIMIT": 3})
|
||||
test11["result"] = true
|
||||
test11["count"] = len(articles11)
|
||||
tests = append(tests, test11)
|
||||
|
||||
// 2.12 IS NOT NULL ([!])
|
||||
test12 := Map{"name": "IS NOT NULL 查询 ([!])"}
|
||||
articles12 := that.Db.Select("article", "id,title,create_time", Map{"create_time[!]": nil, "LIMIT": 3})
|
||||
test12["result"] = true
|
||||
test12["count"] = len(articles12)
|
||||
tests = append(tests, test12)
|
||||
|
||||
// 2.13 直接 SQL ([##] 用于 SQL 片段)
|
||||
test13 := Map{"name": "直接 SQL 片段查询 ([##])"}
|
||||
articles13 := that.Db.Select("article", "id,title,create_time", Map{
|
||||
"[##]": "create_time > DATE_SUB(NOW(), INTERVAL 365 DAY)",
|
||||
"LIMIT": 3,
|
||||
})
|
||||
test13["result"] = true
|
||||
test13["count"] = len(articles13)
|
||||
test13["lastQuery"] = that.Db.LastQuery
|
||||
tests = append(tests, test13)
|
||||
|
||||
// 2.14 显式 AND 条件
|
||||
test14 := Map{"name": "显式 AND 条件"}
|
||||
articles14 := that.Db.Select("article", "id,title,state,click_num", Map{
|
||||
"AND": Map{
|
||||
"state": 0,
|
||||
"click_num[>=]": 0,
|
||||
},
|
||||
"LIMIT": 3,
|
||||
})
|
||||
test14["result"] = true
|
||||
test14["count"] = len(articles14)
|
||||
tests = append(tests, test14)
|
||||
|
||||
// 2.15 OR 条件
|
||||
test15 := Map{"name": "OR 条件"}
|
||||
articles15 := that.Db.Select("article", "id,title,sort,click_num", Map{
|
||||
"OR": Map{
|
||||
"sort": 0,
|
||||
"click_num[>]": 10,
|
||||
},
|
||||
"LIMIT": 5,
|
||||
})
|
||||
test15["result"] = true
|
||||
test15["count"] = len(articles15)
|
||||
tests = append(tests, test15)
|
||||
|
||||
// 2.16 嵌套 AND/OR 条件
|
||||
test16 := Map{"name": "嵌套 AND/OR 条件"}
|
||||
articles16 := that.Db.Select("article", "id,title,sort,state", Map{
|
||||
"AND": Map{
|
||||
"state": 0,
|
||||
"OR": Map{
|
||||
"sort[>=]": 0,
|
||||
"click_num[>]": 0,
|
||||
},
|
||||
},
|
||||
"LIMIT": 5,
|
||||
})
|
||||
test16["result"] = true
|
||||
test16["count"] = len(articles16)
|
||||
test16["lastQuery"] = that.Db.LastQuery
|
||||
tests = append(tests, test16)
|
||||
|
||||
result["tests"] = tests
|
||||
result["success"] = true
|
||||
return result
|
||||
}
|
||||
|
||||
// ==================== 3. 链式查询测试 ====================
|
||||
func testChainQuery(that *Context) Map {
|
||||
result := Map{"name": "链式查询测试", "tests": Slice{}}
|
||||
tests := Slice{}
|
||||
|
||||
// 3.1 基本链式查询 - 使用 article 表
|
||||
test1 := Map{"name": "基本链式查询 Table().Where().Select()"}
|
||||
articles1 := that.Db.Table("article").
|
||||
Where("state", 0).
|
||||
Select("id,title,author")
|
||||
test1["result"] = len(articles1) >= 0
|
||||
test1["count"] = len(articles1)
|
||||
tests = append(tests, test1)
|
||||
|
||||
// 3.2 链式 And 条件
|
||||
test2 := Map{"name": "链式 And 条件"}
|
||||
articles2 := that.Db.Table("article").
|
||||
Where("state", 0).
|
||||
And("click_num[>=]", 0).
|
||||
And("sort[>=]", 0).
|
||||
Select("id,title,click_num")
|
||||
test2["result"] = len(articles2) >= 0
|
||||
test2["count"] = len(articles2)
|
||||
tests = append(tests, test2)
|
||||
|
||||
// 3.3 链式 Or 条件
|
||||
test3 := Map{"name": "链式 Or 条件"}
|
||||
articles3 := that.Db.Table("article").
|
||||
Where("state", 0).
|
||||
Or(Map{
|
||||
"sort": 0,
|
||||
"click_num[>]": 10,
|
||||
}).
|
||||
Select("id,title,sort,click_num")
|
||||
test3["result"] = len(articles3) >= 0
|
||||
test3["count"] = len(articles3)
|
||||
tests = append(tests, test3)
|
||||
|
||||
// 3.4 链式 Order
|
||||
test4 := Map{"name": "链式 Order 排序"}
|
||||
articles4 := that.Db.Table("article").
|
||||
Where("state", 0).
|
||||
Order("create_time DESC", "id ASC").
|
||||
Limit(0, 5).
|
||||
Select("id,title,create_time")
|
||||
test4["result"] = len(articles4) >= 0
|
||||
test4["count"] = len(articles4)
|
||||
tests = append(tests, test4)
|
||||
|
||||
// 3.5 链式 Limit
|
||||
test5 := Map{"name": "链式 Limit 限制"}
|
||||
articles5 := that.Db.Table("article").
|
||||
Where("state", 0).
|
||||
Limit(0, 3).
|
||||
Select("id,title")
|
||||
test5["result"] = len(articles5) <= 3
|
||||
test5["count"] = len(articles5)
|
||||
tests = append(tests, test5)
|
||||
|
||||
// 3.6 链式 Get 单条
|
||||
test6 := Map{"name": "链式 Get 获取单条"}
|
||||
article6 := that.Db.Table("article").
|
||||
Where("state", 0).
|
||||
Get("id,title,author")
|
||||
test6["result"] = article6 != nil || true // 允许为空
|
||||
test6["article"] = article6
|
||||
tests = append(tests, test6)
|
||||
|
||||
// 3.7 链式 Count
|
||||
test7 := Map{"name": "链式 Count 统计"}
|
||||
count7 := that.Db.Table("article").
|
||||
Where("state", 0).
|
||||
Count()
|
||||
test7["result"] = count7 >= 0
|
||||
test7["count"] = count7
|
||||
tests = append(tests, test7)
|
||||
|
||||
// 3.8 链式 Page 分页
|
||||
test8 := Map{"name": "链式 Page 分页"}
|
||||
articles8 := that.Db.Table("article").
|
||||
Where("state", 0).
|
||||
Page(1, 5).
|
||||
Select("id,title")
|
||||
test8["result"] = len(articles8) <= 5
|
||||
test8["count"] = len(articles8)
|
||||
tests = append(tests, test8)
|
||||
|
||||
// 3.9 链式 Group 分组
|
||||
test9 := Map{"name": "链式 Group 分组"}
|
||||
stats9 := that.Db.Table("article").
|
||||
Where("state", 0).
|
||||
Group("ctg_id").
|
||||
Select("ctg_id, COUNT(*) as cnt")
|
||||
test9["result"] = len(stats9) >= 0
|
||||
test9["stats"] = stats9
|
||||
tests = append(tests, test9)
|
||||
|
||||
// 3.10 链式 Update
|
||||
test10 := Map{"name": "链式 Update 更新"}
|
||||
// 先获取一个文章ID
|
||||
testArticle := that.Db.Table("article").Where("state", 0).Get("id")
|
||||
if testArticle != nil {
|
||||
affected := that.Db.Table("article").
|
||||
Where("id", testArticle.GetInt64("id")).
|
||||
Update(Map{"modify_time[#]": "NOW()"})
|
||||
test10["result"] = affected >= 0
|
||||
test10["affected"] = affected
|
||||
} else {
|
||||
test10["result"] = true
|
||||
test10["note"] = "无可用测试数据"
|
||||
}
|
||||
tests = append(tests, test10)
|
||||
|
||||
result["tests"] = tests
|
||||
result["success"] = true
|
||||
return result
|
||||
}
|
||||
|
||||
// ==================== 4. JOIN 查询测试 ====================
|
||||
func testJoinQuery(that *Context) Map {
|
||||
result := Map{"name": "JOIN查询测试", "tests": Slice{}}
|
||||
tests := Slice{}
|
||||
|
||||
// 4.1 LEFT JOIN 链式 - article 关联 ctg
|
||||
test1 := Map{"name": "LEFT JOIN 链式查询"}
|
||||
articles1 := that.Db.Table("article").
|
||||
LeftJoin("ctg", "article.ctg_id = ctg.id").
|
||||
Where("article.state", 0).
|
||||
Limit(0, 5).
|
||||
Select("article.id, article.title, ctg.name as ctg_name")
|
||||
test1["result"] = len(articles1) >= 0
|
||||
test1["count"] = len(articles1)
|
||||
test1["data"] = articles1
|
||||
tests = append(tests, test1)
|
||||
|
||||
// 4.2 传统 JOIN 语法
|
||||
test2 := Map{"name": "传统 JOIN 语法"}
|
||||
articles2 := that.Db.Select("article",
|
||||
Slice{
|
||||
Map{"[>]ctg": "article.ctg_id = ctg.id"},
|
||||
},
|
||||
"article.id, article.title, ctg.name as ctg_name",
|
||||
Map{
|
||||
"article.state": 0,
|
||||
"LIMIT": 5,
|
||||
})
|
||||
test2["result"] = len(articles2) >= 0
|
||||
test2["count"] = len(articles2)
|
||||
test2["lastQuery"] = that.Db.LastQuery
|
||||
tests = append(tests, test2)
|
||||
|
||||
// 4.3 多表 JOIN - article 关联 ctg 和 admin
|
||||
test3 := Map{"name": "多表 JOIN"}
|
||||
articles3 := that.Db.Table("article").
|
||||
LeftJoin("ctg", "article.ctg_id = ctg.id").
|
||||
LeftJoin("admin", "article.admin_id = admin.id").
|
||||
Where("article.state", 0).
|
||||
Limit(0, 5).
|
||||
Select("article.id, article.title, ctg.name as ctg_name, admin.name as admin_name")
|
||||
test3["result"] = len(articles3) >= 0
|
||||
test3["count"] = len(articles3)
|
||||
test3["data"] = articles3
|
||||
tests = append(tests, test3)
|
||||
|
||||
// 4.4 INNER JOIN
|
||||
test4 := Map{"name": "INNER JOIN"}
|
||||
articles4 := that.Db.Table("article").
|
||||
InnerJoin("ctg", "article.ctg_id = ctg.id").
|
||||
Where("ctg.state", 0).
|
||||
Limit(0, 5).
|
||||
Select("article.id, article.title, ctg.name as ctg_name")
|
||||
test4["result"] = len(articles4) >= 0
|
||||
test4["count"] = len(articles4)
|
||||
tests = append(tests, test4)
|
||||
|
||||
result["tests"] = tests
|
||||
result["success"] = true
|
||||
return result
|
||||
}
|
||||
|
||||
// ==================== 5. 聚合函数测试 ====================
|
||||
func testAggregate(that *Context) Map {
|
||||
result := Map{"name": "聚合函数测试", "tests": Slice{}}
|
||||
tests := Slice{}
|
||||
|
||||
// 5.1 Count 总数
|
||||
test1 := Map{"name": "Count 总数统计"}
|
||||
count1 := that.Db.Count("article")
|
||||
test1["result"] = count1 >= 0
|
||||
test1["count"] = count1
|
||||
tests = append(tests, test1)
|
||||
|
||||
// 5.2 Count 带条件
|
||||
test2 := Map{"name": "Count 条件统计"}
|
||||
count2 := that.Db.Count("article", Map{"state": 0})
|
||||
test2["result"] = count2 >= 0
|
||||
test2["count"] = count2
|
||||
tests = append(tests, test2)
|
||||
|
||||
// 5.3 Sum 求和
|
||||
test3 := Map{"name": "Sum 求和"}
|
||||
sum3 := that.Db.Sum("article", "click_num", Map{"state": 0})
|
||||
test3["result"] = sum3 >= 0
|
||||
test3["sum"] = sum3
|
||||
tests = append(tests, test3)
|
||||
|
||||
// 5.4 Avg 平均值
|
||||
test4 := Map{"name": "Avg 平均值"}
|
||||
avg4 := that.Db.Avg("article", "click_num", Map{"state": 0})
|
||||
test4["result"] = avg4 >= 0
|
||||
test4["avg"] = avg4
|
||||
tests = append(tests, test4)
|
||||
|
||||
// 5.5 Max 最大值
|
||||
test5 := Map{"name": "Max 最大值"}
|
||||
max5 := that.Db.Max("article", "click_num", Map{"state": 0})
|
||||
test5["result"] = max5 >= 0
|
||||
test5["max"] = max5
|
||||
tests = append(tests, test5)
|
||||
|
||||
// 5.6 Min 最小值
|
||||
test6 := Map{"name": "Min 最小值"}
|
||||
min6 := that.Db.Min("article", "sort", Map{"state": 0})
|
||||
test6["result"] = true // sort 可能为 0
|
||||
test6["min"] = min6
|
||||
tests = append(tests, test6)
|
||||
|
||||
// 5.7 GROUP BY 分组统计
|
||||
test7 := Map{"name": "GROUP BY 分组统计"}
|
||||
stats7 := that.Db.Select("article",
|
||||
"ctg_id, COUNT(*) as article_count, AVG(click_num) as avg_clicks, SUM(click_num) as total_clicks",
|
||||
Map{
|
||||
"state": 0,
|
||||
"GROUP": "ctg_id",
|
||||
"ORDER": "article_count DESC",
|
||||
"LIMIT": 10,
|
||||
})
|
||||
test7["result"] = len(stats7) >= 0
|
||||
test7["stats"] = stats7
|
||||
tests = append(tests, test7)
|
||||
|
||||
result["tests"] = tests
|
||||
result["success"] = true
|
||||
return result
|
||||
}
|
||||
|
||||
// ==================== 6. 分页查询测试 ====================
|
||||
func testPagination(that *Context) Map {
|
||||
result := Map{"name": "分页查询测试", "tests": Slice{}}
|
||||
tests := Slice{}
|
||||
|
||||
// 6.1 PageSelect 分页查询
|
||||
test1 := Map{"name": "PageSelect 分页查询"}
|
||||
articles1 := that.Db.Page(1, 5).PageSelect("article", "*", Map{
|
||||
"state": 0,
|
||||
"ORDER": "id DESC",
|
||||
})
|
||||
test1["result"] = len(articles1) <= 5
|
||||
test1["count"] = len(articles1)
|
||||
tests = append(tests, test1)
|
||||
|
||||
// 6.2 第二页
|
||||
test2 := Map{"name": "PageSelect 第二页"}
|
||||
articles2 := that.Db.Page(2, 5).PageSelect("article", "*", Map{
|
||||
"state": 0,
|
||||
"ORDER": "id DESC",
|
||||
})
|
||||
test2["result"] = len(articles2) <= 5
|
||||
test2["count"] = len(articles2)
|
||||
tests = append(tests, test2)
|
||||
|
||||
// 6.3 链式分页
|
||||
test3 := Map{"name": "链式 Page 分页"}
|
||||
articles3 := that.Db.Table("article").
|
||||
Where("state", 0).
|
||||
Order("id DESC").
|
||||
Page(1, 3).
|
||||
Select("id,title,author")
|
||||
test3["result"] = len(articles3) <= 3
|
||||
test3["count"] = len(articles3)
|
||||
tests = append(tests, test3)
|
||||
|
||||
// 6.4 Offset 偏移
|
||||
test4 := Map{"name": "Offset 偏移查询"}
|
||||
articles4 := that.Db.Table("article").
|
||||
Where("state", 0).
|
||||
Limit(3).
|
||||
Offset(2).
|
||||
Select("id,title")
|
||||
test4["result"] = len(articles4) <= 3
|
||||
test4["count"] = len(articles4)
|
||||
test4["lastQuery"] = that.Db.LastQuery
|
||||
tests = append(tests, test4)
|
||||
|
||||
result["tests"] = tests
|
||||
result["success"] = true
|
||||
return result
|
||||
}
|
||||
|
||||
// ==================== 7. 批量插入测试 ====================
|
||||
func testInserts(that *Context) Map {
|
||||
result := Map{"name": "批量插入测试", "tests": Slice{}}
|
||||
tests := Slice{}
|
||||
|
||||
// 7.1 批量插入
|
||||
test1 := Map{"name": "Inserts 批量插入"}
|
||||
timestamp := time.Now().UnixNano()
|
||||
affected1 := that.Db.Inserts("test_batch", []Map{
|
||||
{"name": fmt.Sprintf("批量测试1_%d", timestamp), "title": "标题1", "state": 1},
|
||||
{"name": fmt.Sprintf("批量测试2_%d", timestamp), "title": "标题2", "state": 1},
|
||||
{"name": fmt.Sprintf("批量测试3_%d", timestamp), "title": "标题3", "state": 1},
|
||||
})
|
||||
test1["result"] = affected1 >= 0
|
||||
test1["affected"] = affected1
|
||||
test1["lastQuery"] = that.Db.LastQuery
|
||||
tests = append(tests, test1)
|
||||
|
||||
// 7.2 带 [#] 的批量插入
|
||||
test2 := Map{"name": "Inserts 带 [#] 标记"}
|
||||
timestamp2 := time.Now().UnixNano()
|
||||
affected2 := that.Db.Inserts("test_batch", []Map{
|
||||
{"name": fmt.Sprintf("带时间测试1_%d", timestamp2), "title": "标题带时间1", "state": 1, "create_time[#]": "NOW()"},
|
||||
{"name": fmt.Sprintf("带时间测试2_%d", timestamp2), "title": "标题带时间2", "state": 1, "create_time[#]": "NOW()"},
|
||||
})
|
||||
test2["result"] = affected2 >= 0
|
||||
test2["affected"] = affected2
|
||||
tests = append(tests, test2)
|
||||
|
||||
// 清理测试数据
|
||||
that.Db.Delete("test_batch", Map{"name[~]": fmt.Sprintf("_%d", timestamp)})
|
||||
that.Db.Delete("test_batch", Map{"name[~]": fmt.Sprintf("_%d", timestamp2)})
|
||||
|
||||
result["tests"] = tests
|
||||
result["success"] = true
|
||||
return result
|
||||
}
|
||||
|
||||
// ==================== 8. Upsert 测试 ====================
|
||||
func testUpsert(that *Context) Map {
|
||||
result := Map{"name": "Upsert测试", "tests": Slice{}}
|
||||
tests := Slice{}
|
||||
|
||||
// 使用 admin 表测试 Upsert(MySQL ON DUPLICATE KEY UPDATE)
|
||||
timestamp := time.Now().Unix()
|
||||
testPhone := fmt.Sprintf("199%08d", timestamp%100000000)
|
||||
|
||||
// 8.1 Upsert 插入新记录
|
||||
test1 := Map{"name": "Upsert 插入新记录 (admin表)"}
|
||||
affected1 := that.Db.Upsert("admin",
|
||||
Map{
|
||||
"name": "Upsert测试管理员",
|
||||
"phone": testPhone,
|
||||
"state": 1,
|
||||
"password": "test123",
|
||||
"role_id": 1,
|
||||
"title": "测试职位",
|
||||
"create_time[#]": "NOW()",
|
||||
"modify_time[#]": "NOW()",
|
||||
},
|
||||
Slice{"phone"},
|
||||
Slice{"name", "state", "title", "modify_time"},
|
||||
)
|
||||
test1["result"] = affected1 >= 0
|
||||
test1["affected"] = affected1
|
||||
test1["lastQuery"] = that.Db.LastQuery
|
||||
tests = append(tests, test1)
|
||||
|
||||
// 8.2 Upsert 更新已存在记录
|
||||
test2 := Map{"name": "Upsert 更新已存在记录"}
|
||||
affected2 := that.Db.Upsert("admin",
|
||||
Map{
|
||||
"name": "Upsert更新后管理员",
|
||||
"phone": testPhone,
|
||||
"state": 1,
|
||||
"password": "updated123",
|
||||
"role_id": 2,
|
||||
"title": "更新后职位",
|
||||
"create_time[#]": "NOW()",
|
||||
"modify_time[#]": "NOW()",
|
||||
},
|
||||
Slice{"phone"},
|
||||
Slice{"name", "title", "role_id", "modify_time"},
|
||||
)
|
||||
test2["result"] = affected2 >= 0
|
||||
test2["affected"] = affected2
|
||||
tests = append(tests, test2)
|
||||
|
||||
// 验证更新结果
|
||||
updatedAdmin := that.Db.Get("admin", "*", Map{"phone": testPhone})
|
||||
test2["updatedAdmin"] = updatedAdmin
|
||||
|
||||
// 清理测试数据
|
||||
that.Db.Delete("admin", Map{"phone": testPhone})
|
||||
|
||||
result["tests"] = tests
|
||||
result["success"] = true
|
||||
return result
|
||||
}
|
||||
|
||||
// ==================== 9. 事务测试 ====================
|
||||
func testTransaction(that *Context) Map {
|
||||
result := Map{"name": "事务测试", "tests": Slice{}}
|
||||
tests := Slice{}
|
||||
|
||||
// 9.1 事务成功提交
|
||||
test1 := Map{"name": "事务成功提交"}
|
||||
timestamp := time.Now().Unix()
|
||||
testName1 := fmt.Sprintf("事务测试_%d", timestamp)
|
||||
|
||||
success1 := that.Db.Action(func(tx HoTimeDB) bool {
|
||||
// 插入记录到 test_batch 表
|
||||
recordId := tx.Insert("test_batch", Map{
|
||||
"name": testName1,
|
||||
"title": "事务提交测试",
|
||||
"state": 1,
|
||||
"create_time[#]": "NOW()",
|
||||
})
|
||||
|
||||
return recordId != 0
|
||||
})
|
||||
|
||||
test1["result"] = success1
|
||||
// 验证数据是否存在
|
||||
checkRecord := that.Db.Get("test_batch", "*", Map{"name": testName1})
|
||||
test1["recordExists"] = checkRecord != nil
|
||||
tests = append(tests, test1)
|
||||
|
||||
// 9.2 事务回滚
|
||||
test2 := Map{"name": "事务回滚"}
|
||||
testName2 := fmt.Sprintf("事务回滚测试_%d", timestamp)
|
||||
|
||||
success2 := that.Db.Action(func(tx HoTimeDB) bool {
|
||||
// 插入记录
|
||||
_ = tx.Insert("test_batch", Map{
|
||||
"name": testName2,
|
||||
"title": "事务回滚测试",
|
||||
"state": 1,
|
||||
"create_time[#]": "NOW()",
|
||||
})
|
||||
|
||||
// 返回 false 触发回滚
|
||||
return false
|
||||
})
|
||||
|
||||
test2["result"] = !success2 // 期望回滚,所以 success2 应该为 false
|
||||
// 验证数据是否不存在(已回滚)
|
||||
checkRecord2 := that.Db.Get("test_batch", "*", Map{"name": testName2})
|
||||
test2["recordRolledBack"] = checkRecord2 == nil
|
||||
tests = append(tests, test2)
|
||||
|
||||
// 清理测试数据
|
||||
that.Db.Delete("test_batch", Map{"name": testName1})
|
||||
|
||||
result["tests"] = tests
|
||||
result["success"] = true
|
||||
return result
|
||||
}
|
||||
|
||||
// ==================== 10. 原生 SQL 测试 ====================
|
||||
func testRawSQL(that *Context) Map {
|
||||
result := Map{"name": "原生SQL测试", "tests": Slice{}}
|
||||
tests := Slice{}
|
||||
|
||||
// 10.1 Query 查询 - 使用真实的 article 表
|
||||
test1 := Map{"name": "Query 原生查询"}
|
||||
articles1 := that.Db.Query("SELECT id, title, author FROM `article` WHERE state = ? LIMIT ?", 0, 5)
|
||||
test1["result"] = len(articles1) >= 0
|
||||
test1["count"] = len(articles1)
|
||||
tests = append(tests, test1)
|
||||
|
||||
// 10.2 Exec 执行 - 使用 article 表
|
||||
test2 := Map{"name": "Exec 原生执行"}
|
||||
// 更新一条记录
|
||||
testArticle := that.Db.Get("article", "id", Map{"state": 0})
|
||||
if testArticle != nil {
|
||||
res, err := that.Db.Exec("UPDATE `article` SET modify_time = NOW() WHERE id = ?", testArticle.GetInt64("id"))
|
||||
if err.GetError() == nil && res != nil {
|
||||
affected, _ := res.RowsAffected()
|
||||
test2["result"] = affected >= 0
|
||||
test2["affected"] = affected
|
||||
} else {
|
||||
test2["result"] = false
|
||||
test2["error"] = err.GetError()
|
||||
}
|
||||
} else {
|
||||
test2["result"] = true
|
||||
test2["note"] = "无可用测试数据"
|
||||
}
|
||||
tests = append(tests, test2)
|
||||
|
||||
// ==================== IN/NOT IN 数组测试 ====================
|
||||
// H1: IN (?) 配合非空数组能正确展开
|
||||
test3 := Map{"name": "H1: IN (?) 非空数组展开"}
|
||||
articles3 := that.Db.Query("SELECT id, title FROM `article` WHERE id IN (?) LIMIT 10", []int{1, 2, 3, 4, 5})
|
||||
test3["result"] = len(articles3) >= 0
|
||||
test3["count"] = len(articles3)
|
||||
test3["lastQuery"] = that.Db.LastQuery
|
||||
tests = append(tests, test3)
|
||||
|
||||
// H2: IN (?) 配合空数组替换为 1=0
|
||||
test4 := Map{"name": "H2: IN (?) 空数组替换为1=0"}
|
||||
articles4 := that.Db.Query("SELECT id, title FROM `article` WHERE id IN (?) LIMIT 10", []int{})
|
||||
test4["result"] = len(articles4) == 0 // 空数组的IN应该返回0条
|
||||
test4["count"] = len(articles4)
|
||||
test4["lastQuery"] = that.Db.LastQuery
|
||||
test4["expected"] = "count=0, SQL应包含1=0"
|
||||
tests = append(tests, test4)
|
||||
|
||||
// H3: NOT IN (?) 配合空数组替换为 1=1
|
||||
test5 := Map{"name": "H3: NOT IN (?) 空数组替换为1=1"}
|
||||
articles5 := that.Db.Query("SELECT id, title FROM `article` WHERE id NOT IN (?) LIMIT 10", []int{})
|
||||
test5["result"] = len(articles5) > 0 // NOT IN空数组应该返回记录
|
||||
test5["count"] = len(articles5)
|
||||
test5["lastQuery"] = that.Db.LastQuery
|
||||
test5["expected"] = "count>0, SQL应包含1=1"
|
||||
tests = append(tests, test5)
|
||||
|
||||
// H4: NOT IN (?) 配合非空数组正常展开
|
||||
test6 := Map{"name": "H4: NOT IN (?) 非空数组展开"}
|
||||
articles6 := that.Db.Query("SELECT id, title FROM `article` WHERE id NOT IN (?) LIMIT 10", []int{1, 2, 3})
|
||||
test6["result"] = len(articles6) >= 0
|
||||
test6["count"] = len(articles6)
|
||||
test6["lastQuery"] = that.Db.LastQuery
|
||||
tests = append(tests, test6)
|
||||
|
||||
// H5: 普通 ? 占位符保持原有行为
|
||||
test7 := Map{"name": "H5: 普通?占位符不受影响"}
|
||||
articles7 := that.Db.Query("SELECT id, title FROM `article` WHERE state = ? LIMIT ?", 0, 5)
|
||||
test7["result"] = len(articles7) >= 0
|
||||
test7["count"] = len(articles7)
|
||||
test7["lastQuery"] = that.Db.LastQuery
|
||||
tests = append(tests, test7)
|
||||
|
||||
// 额外测试: Select ORM方法的空数组处理
|
||||
test8 := Map{"name": "ORM Select: IN空数组"}
|
||||
articles8 := that.Db.Select("article", "id,title", Map{"id": []int{}, "LIMIT": 10})
|
||||
test8["result"] = len(articles8) == 0
|
||||
test8["count"] = len(articles8)
|
||||
test8["lastQuery"] = that.Db.LastQuery
|
||||
tests = append(tests, test8)
|
||||
|
||||
// 额外测试: Select ORM方法的NOT IN空数组处理
|
||||
test9 := Map{"name": "ORM Select: NOT IN空数组"}
|
||||
articles9 := that.Db.Select("article", "id,title", Map{"id[!]": []int{}, "LIMIT": 10})
|
||||
test9["result"] = len(articles9) > 0 // NOT IN 空数组应返回记录
|
||||
test9["count"] = len(articles9)
|
||||
test9["lastQuery"] = that.Db.LastQuery
|
||||
tests = append(tests, test9)
|
||||
|
||||
result["tests"] = tests
|
||||
result["success"] = true
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,599 @@
|
||||
// migrate: MySQL 8.0 → 达梦 DM8 直连迁移工具
|
||||
// 用法:在 hotimev1.5 目录下执行
|
||||
// go run example/migrate/main.go --config d:/work/myst-go/config/config.json
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "gitee.com/chunanyong/dm"
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
// ─────────────────── 配置读取 ───────────────────
|
||||
|
||||
type DBConf struct {
|
||||
Host string `json:"host"`
|
||||
Port string `json:"port"`
|
||||
Name string `json:"name"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
Prefix string `json:"prefix"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
DB map[string]DBConf `json:"db"`
|
||||
}
|
||||
|
||||
func loadConfig(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw map[string]json.RawMessage
|
||||
if err = json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var dbRaw map[string]DBConf
|
||||
if err = json.Unmarshal(raw["db"], &dbRaw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Config{DB: dbRaw}, nil
|
||||
}
|
||||
|
||||
func getMySQLConf(cfg *Config) (DBConf, error) {
|
||||
// 1. 精确匹配 "mysql"(启用态)
|
||||
if v, ok := cfg.DB["mysql"]; ok {
|
||||
return v, nil
|
||||
}
|
||||
// 2. 迁移后改为 "mysql-" 的情况(原始主源)
|
||||
if v, ok := cfg.DB["mysql-"]; ok {
|
||||
log.Printf("[注意] MySQL 配置 key=mysql- 已禁用,仍用于迁移源(host=%s db=%s)", v.Host, v.Name)
|
||||
return v, nil
|
||||
}
|
||||
// 3. 其他以 mysql 开头且不以 _ 结尾的 key(排除 mysql_ 这类备用库)
|
||||
for k, v := range cfg.DB {
|
||||
if strings.HasPrefix(k, "mysql") && !strings.HasSuffix(k, "_") {
|
||||
log.Printf("[注意] 使用 MySQL 配置 key=%q (host=%s db=%s)", k, v.Host, v.Name)
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
return DBConf{}, fmt.Errorf("config.json 中未找到 mysql 配置(期望 key=mysql 或 mysql-)")
|
||||
}
|
||||
|
||||
func getDMConf(cfg *Config) (DBConf, error) {
|
||||
for k, v := range cfg.DB {
|
||||
if strings.HasPrefix(k, "dm") && !strings.HasSuffix(k, "-") {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
for k, v := range cfg.DB {
|
||||
if strings.HasPrefix(k, "dm") {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
return DBConf{}, fmt.Errorf("config.json 中未找到 dm 配置")
|
||||
}
|
||||
|
||||
// ─────────────────── 类型转换 ───────────────────
|
||||
|
||||
func mysqlTypeToDM(dataType string, charLen *int64, numPrec, numScale *int64) string {
|
||||
dt := strings.ToLower(dataType)
|
||||
switch dt {
|
||||
case "int", "integer", "mediumint", "smallint", "tinyint", "year", "bit":
|
||||
return "INT"
|
||||
case "bigint":
|
||||
return "BIGINT"
|
||||
case "float":
|
||||
return "FLOAT"
|
||||
case "double", "double precision", "real":
|
||||
return "DOUBLE"
|
||||
case "decimal", "numeric":
|
||||
p := int64(10)
|
||||
s := int64(0)
|
||||
if numPrec != nil {
|
||||
p = *numPrec
|
||||
}
|
||||
if numScale != nil {
|
||||
s = *numScale
|
||||
}
|
||||
return fmt.Sprintf("DECIMAL(%d,%d)", p, s)
|
||||
case "varchar", "nvarchar":
|
||||
l := int64(255)
|
||||
if charLen != nil {
|
||||
l = *charLen
|
||||
}
|
||||
if l > 32767 {
|
||||
return "CLOB"
|
||||
}
|
||||
return fmt.Sprintf("VARCHAR(%d)", l)
|
||||
case "char", "nchar":
|
||||
l := int64(1)
|
||||
if charLen != nil {
|
||||
l = *charLen
|
||||
}
|
||||
return fmt.Sprintf("CHAR(%d)", l)
|
||||
case "datetime", "timestamp":
|
||||
return "TIMESTAMP"
|
||||
case "date":
|
||||
return "DATE"
|
||||
case "time":
|
||||
return "TIME"
|
||||
case "text", "tinytext", "mediumtext", "longtext":
|
||||
return "CLOB"
|
||||
case "blob", "tinyblob", "mediumblob", "longblob", "binary", "varbinary":
|
||||
return "BLOB"
|
||||
case "json":
|
||||
return "CLOB"
|
||||
case "enum", "set":
|
||||
return "VARCHAR(100)"
|
||||
default:
|
||||
return "VARCHAR(255)"
|
||||
}
|
||||
}
|
||||
|
||||
// q 用双引号包裹标识符(小写)
|
||||
func q(name string) string {
|
||||
return `"` + strings.ToLower(name) + `"`
|
||||
}
|
||||
|
||||
// ─────────────────── 列信息 ───────────────────
|
||||
|
||||
type Column struct {
|
||||
Name string
|
||||
DataType string
|
||||
CharLen *int64
|
||||
NumPrec *int64
|
||||
NumScale *int64
|
||||
IsNullable bool
|
||||
Default *string
|
||||
Extra string
|
||||
Comment string
|
||||
ColKey string
|
||||
}
|
||||
|
||||
type Index struct {
|
||||
Name string
|
||||
IsUnique bool
|
||||
ColNames []string
|
||||
}
|
||||
|
||||
func getColumns(mysqlDB *sql.DB, dbName, tableName string) ([]Column, error) {
|
||||
rows, err := mysqlDB.Query(`
|
||||
SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH,
|
||||
NUMERIC_PRECISION, NUMERIC_SCALE,
|
||||
IS_NULLABLE, COLUMN_DEFAULT, EXTRA, COLUMN_COMMENT, COLUMN_KEY
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA=? AND TABLE_NAME=?
|
||||
ORDER BY ORDINAL_POSITION`, dbName, tableName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var cols []Column
|
||||
for rows.Next() {
|
||||
var c Column
|
||||
var nullable string
|
||||
err = rows.Scan(&c.Name, &c.DataType, &c.CharLen, &c.NumPrec, &c.NumScale,
|
||||
&nullable, &c.Default, &c.Extra, &c.Comment, &c.ColKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.IsNullable = (nullable == "YES")
|
||||
cols = append(cols, c)
|
||||
}
|
||||
return cols, rows.Err()
|
||||
}
|
||||
|
||||
func getIndexes(mysqlDB *sql.DB, dbName, tableName string) ([]Index, error) {
|
||||
rows, err := mysqlDB.Query(`
|
||||
SELECT INDEX_NAME, NON_UNIQUE, COLUMN_NAME, SEQ_IN_INDEX
|
||||
FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA=? AND TABLE_NAME=? AND INDEX_NAME!='PRIMARY'
|
||||
ORDER BY INDEX_NAME, SEQ_IN_INDEX`, dbName, tableName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
indexMap := map[string]*Index{}
|
||||
var order []string
|
||||
for rows.Next() {
|
||||
var idxName, colName string
|
||||
var nonUnique, seqInIndex int
|
||||
if err = rows.Scan(&idxName, &nonUnique, &colName, &seqInIndex); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok := indexMap[idxName]; !ok {
|
||||
indexMap[idxName] = &Index{Name: idxName, IsUnique: nonUnique == 0}
|
||||
order = append(order, idxName)
|
||||
}
|
||||
indexMap[idxName].ColNames = append(indexMap[idxName].ColNames, colName)
|
||||
}
|
||||
var result []Index
|
||||
for _, name := range order {
|
||||
result = append(result, *indexMap[name])
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// ─────────────────── DDL 生成 ───────────────────
|
||||
|
||||
func buildCreateTable(tableName string, cols []Column) string {
|
||||
var lines []string
|
||||
var pkCol string
|
||||
|
||||
for _, c := range cols {
|
||||
dmType := mysqlTypeToDM(c.DataType, c.CharLen, c.NumPrec, c.NumScale)
|
||||
isAuto := strings.Contains(strings.ToLower(c.Extra), "auto_increment")
|
||||
isPK := c.ColKey == "PRI"
|
||||
|
||||
var parts []string
|
||||
parts = append(parts, fmt.Sprintf(" %s %s", q(c.Name), dmType))
|
||||
|
||||
if isPK && isAuto {
|
||||
parts = append(parts, "IDENTITY(1,1) NOT NULL")
|
||||
pkCol = c.Name
|
||||
} else {
|
||||
if !c.IsNullable {
|
||||
parts = append(parts, "NOT NULL")
|
||||
}
|
||||
if c.Default != nil {
|
||||
dv := *c.Default
|
||||
upper := strings.ToUpper(dv)
|
||||
switch {
|
||||
case upper == "CURRENT_TIMESTAMP" || upper == "NOW()":
|
||||
parts = append(parts, "DEFAULT CURRENT_TIMESTAMP")
|
||||
case dmType == "INT" || dmType == "BIGINT" || dmType == "FLOAT" ||
|
||||
dmType == "DOUBLE" || strings.HasPrefix(dmType, "DECIMAL"):
|
||||
parts = append(parts, "DEFAULT "+dv)
|
||||
case dmType == "CLOB" || dmType == "BLOB":
|
||||
// 不设默认值
|
||||
default:
|
||||
safe := strings.ReplaceAll(dv, "'", "''")
|
||||
parts = append(parts, fmt.Sprintf("DEFAULT '%s'", safe))
|
||||
}
|
||||
}
|
||||
}
|
||||
lines = append(lines, strings.Join(parts, " "))
|
||||
}
|
||||
|
||||
// 主键约束
|
||||
if pkCol != "" {
|
||||
lines = append(lines, fmt.Sprintf(" PRIMARY KEY (%s)", q(pkCol)))
|
||||
} else {
|
||||
var pkCols []string
|
||||
for _, c := range cols {
|
||||
if c.ColKey == "PRI" {
|
||||
pkCols = append(pkCols, q(c.Name))
|
||||
}
|
||||
}
|
||||
if len(pkCols) > 0 {
|
||||
lines = append(lines, fmt.Sprintf(" PRIMARY KEY (%s)", strings.Join(pkCols, ", ")))
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf("CREATE TABLE %s (\n%s\n)", q(tableName), strings.Join(lines, ",\n"))
|
||||
}
|
||||
|
||||
// ─────────────────── DM8 表操作 ───────────────────
|
||||
|
||||
func tableExistsInDM(dmDB *sql.DB, tableName string) bool {
|
||||
var cnt int
|
||||
err := dmDB.QueryRow(fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE ROWNUM=1`, q(tableName))).Scan(&cnt)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func createTableInDM(dmDB *sql.DB, tableName string, cols []Column, indexes []Index,
|
||||
tableComment string) error {
|
||||
|
||||
// 如果已存在则 DROP
|
||||
if tableExistsInDM(dmDB, tableName) {
|
||||
log.Printf(" 表 %s 已存在,先删除 ...", tableName)
|
||||
_, err := dmDB.Exec(fmt.Sprintf(`DROP TABLE %s CASCADE`, q(tableName)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("DROP TABLE %s: %w", tableName, err)
|
||||
}
|
||||
}
|
||||
|
||||
ddl := buildCreateTable(tableName, cols)
|
||||
log.Printf(" CREATE TABLE %s ...", tableName)
|
||||
if _, err := dmDB.Exec(ddl); err != nil {
|
||||
return fmt.Errorf("CREATE TABLE %s: %w\nDDL:\n%s", tableName, err, ddl)
|
||||
}
|
||||
|
||||
// 表注释
|
||||
if tableComment != "" {
|
||||
safe := strings.ReplaceAll(tableComment, "'", "''")
|
||||
stmt := fmt.Sprintf("COMMENT ON TABLE %s IS '%s'", q(tableName), safe)
|
||||
if _, err := dmDB.Exec(stmt); err != nil {
|
||||
log.Printf(" [警告] 表注释失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 列注释
|
||||
for _, c := range cols {
|
||||
if c.Comment != "" {
|
||||
safe := strings.ReplaceAll(c.Comment, "'", "''")
|
||||
stmt := fmt.Sprintf("COMMENT ON COLUMN %s.%s IS '%s'", q(tableName), q(c.Name), safe)
|
||||
if _, err := dmDB.Exec(stmt); err != nil {
|
||||
log.Printf(" [警告] 列注释 %s.%s 失败: %v", tableName, c.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 索引
|
||||
for _, idx := range indexes {
|
||||
idxName := tableName + "_" + idx.Name
|
||||
uniqueKW := ""
|
||||
if idx.IsUnique {
|
||||
uniqueKW = "UNIQUE "
|
||||
}
|
||||
colList := make([]string, len(idx.ColNames))
|
||||
for i, cn := range idx.ColNames {
|
||||
colList[i] = q(cn)
|
||||
}
|
||||
stmt := fmt.Sprintf("CREATE %sINDEX %s ON %s (%s)",
|
||||
uniqueKW, q(idxName), q(tableName), strings.Join(colList, ", "))
|
||||
if _, err := dmDB.Exec(stmt); err != nil {
|
||||
log.Printf(" [警告] 创建索引 %s 失败: %v", idxName, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─────────────────── 数据迁移 ───────────────────
|
||||
|
||||
// hasIdentityCol 判断列表中是否有自增主键列
|
||||
func hasIdentityCol(cols []Column) bool {
|
||||
for _, c := range cols {
|
||||
if strings.Contains(strings.ToLower(c.Extra), "auto_increment") && c.ColKey == "PRI" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func migrateData(mysqlDB, dmDB *sql.DB, tableName string, cols []Column, batchSize int) (int64, error) {
|
||||
colNames := make([]string, len(cols))
|
||||
dmColNames := make([]string, len(cols))
|
||||
placeholders := make([]string, len(cols))
|
||||
for i, c := range cols {
|
||||
colNames[i] = "`" + c.Name + "`"
|
||||
dmColNames[i] = q(c.Name)
|
||||
placeholders[i] = "?"
|
||||
}
|
||||
|
||||
insertSQL := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)",
|
||||
q(tableName),
|
||||
strings.Join(dmColNames, ", "),
|
||||
strings.Join(placeholders, ", "))
|
||||
|
||||
// 统计总行数
|
||||
var total int64
|
||||
_ = mysqlDB.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM `%s`", tableName)).Scan(&total)
|
||||
|
||||
// DM8:有 IDENTITY 列时,需要 SET IDENTITY_INSERT ON 才能插入指定 ID
|
||||
if hasIdentityCol(cols) {
|
||||
if _, err := dmDB.Exec(fmt.Sprintf("SET IDENTITY_INSERT %s ON", q(tableName))); err != nil {
|
||||
log.Printf(" [警告] SET IDENTITY_INSERT ON 失败(将继续): %v", err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = dmDB.Exec(fmt.Sprintf("SET IDENTITY_INSERT %s OFF", q(tableName)))
|
||||
}()
|
||||
}
|
||||
|
||||
stmt, err := dmDB.Prepare(insertSQL)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("Prepare INSERT %s: %w", tableName, err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
var offset int64
|
||||
var migrated int64
|
||||
for {
|
||||
selectSQL := fmt.Sprintf("SELECT %s FROM `%s` LIMIT %d OFFSET %d",
|
||||
strings.Join(colNames, ", "), tableName, batchSize, offset)
|
||||
rows, err := mysqlDB.Query(selectSQL)
|
||||
if err != nil {
|
||||
return migrated, err
|
||||
}
|
||||
|
||||
vals := make([]interface{}, len(cols))
|
||||
valPtrs := make([]interface{}, len(cols))
|
||||
for i := range vals {
|
||||
valPtrs[i] = &vals[i]
|
||||
}
|
||||
|
||||
rowCount := 0
|
||||
for rows.Next() {
|
||||
if err = rows.Scan(valPtrs...); err != nil {
|
||||
rows.Close()
|
||||
return migrated, err
|
||||
}
|
||||
// 处理 []byte → string(DM8 CLOB 等)
|
||||
args := make([]interface{}, len(cols))
|
||||
for i, v := range vals {
|
||||
if b, ok := v.([]byte); ok {
|
||||
args[i] = string(b)
|
||||
} else {
|
||||
args[i] = v
|
||||
}
|
||||
}
|
||||
if _, err = stmt.Exec(args...); err != nil {
|
||||
rows.Close()
|
||||
return migrated, fmt.Errorf("INSERT row into %s: %w", tableName, err)
|
||||
}
|
||||
migrated++
|
||||
rowCount++
|
||||
}
|
||||
rows.Close()
|
||||
if err = rows.Err(); err != nil {
|
||||
return migrated, err
|
||||
}
|
||||
if rowCount == 0 {
|
||||
break
|
||||
}
|
||||
offset += int64(batchSize)
|
||||
|
||||
if total > 0 {
|
||||
pct := float64(migrated) * 100 / float64(total)
|
||||
fmt.Printf("\r 进度: %d/%d (%.1f%%)", migrated, total, pct)
|
||||
}
|
||||
}
|
||||
if total > 0 {
|
||||
fmt.Println()
|
||||
}
|
||||
return migrated, nil
|
||||
}
|
||||
|
||||
// ─────────────────── 主程序 ───────────────────
|
||||
|
||||
var skipTables = map[string]bool{
|
||||
"hotime_cache": true,
|
||||
"cached": true,
|
||||
}
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("config", "d:/work/myst-go/config/config.json", "配置文件路径")
|
||||
batchSize := flag.Int("batch", 500, "每批迁移行数")
|
||||
onlyTables := flag.String("only", "", "只迁移指定表(逗号分隔)")
|
||||
flag.Parse()
|
||||
|
||||
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
|
||||
log.SetPrefix("[migrate] ")
|
||||
|
||||
// 读配置
|
||||
log.Printf("读取配置: %s", *configPath)
|
||||
cfg, err := loadConfig(*configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("读取配置失败: %v", err)
|
||||
}
|
||||
|
||||
mysqlConf, err := getMySQLConf(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("%v", err)
|
||||
}
|
||||
dmConf, err := getDMConf(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("%v", err)
|
||||
}
|
||||
|
||||
log.Printf("源库 MySQL : %s:%s db=%s user=%s", mysqlConf.Host, mysqlConf.Port, mysqlConf.Name, mysqlConf.User)
|
||||
log.Printf("目标 DM8 : %s:%s schema=%s user=%s", dmConf.Host, dmConf.Port, dmConf.Name, dmConf.User)
|
||||
|
||||
// 连接 MySQL
|
||||
mysqlDSN := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||
mysqlConf.User, mysqlConf.Password, mysqlConf.Host, mysqlConf.Port, mysqlConf.Name)
|
||||
log.Printf("连接 MySQL ...")
|
||||
mysqlDB, err := sql.Open("mysql", mysqlDSN)
|
||||
if err != nil {
|
||||
log.Fatalf("sql.Open mysql: %v", err)
|
||||
}
|
||||
defer mysqlDB.Close()
|
||||
if err = mysqlDB.Ping(); err != nil {
|
||||
log.Fatalf("MySQL Ping 失败: %v\nDSN=%s", err, mysqlDSN)
|
||||
}
|
||||
log.Printf("MySQL 连接成功")
|
||||
|
||||
// 连接 DM8
|
||||
// DSN 格式: dm://user:password@host:port?schema=SCHEMA
|
||||
dmDSN := fmt.Sprintf("dm://%s:%s@%s:%s?schema=%s",
|
||||
dmConf.User, dmConf.Password, dmConf.Host, dmConf.Port, dmConf.Name)
|
||||
log.Printf("连接 DM8 ...")
|
||||
dmDB, err := sql.Open("dm", dmDSN)
|
||||
if err != nil {
|
||||
log.Fatalf("sql.Open dm: %v", err)
|
||||
}
|
||||
defer dmDB.Close()
|
||||
dmDB.SetConnMaxLifetime(5 * time.Minute)
|
||||
dmDB.SetMaxOpenConns(3)
|
||||
if err = dmDB.Ping(); err != nil {
|
||||
log.Fatalf("DM8 Ping 失败: %v\nDSN=%s", err, dmDSN)
|
||||
}
|
||||
log.Printf("DM8 连接成功")
|
||||
|
||||
// 枚举 MySQL 表
|
||||
tables, err := mysqlDB.Query(`
|
||||
SELECT TABLE_NAME, IFNULL(TABLE_COMMENT,'')
|
||||
FROM INFORMATION_SCHEMA.TABLES
|
||||
WHERE TABLE_SCHEMA=? AND TABLE_TYPE='BASE TABLE'
|
||||
ORDER BY TABLE_NAME`, mysqlConf.Name)
|
||||
if err != nil {
|
||||
log.Fatalf("枚举表失败: %v", err)
|
||||
}
|
||||
type tableInfo struct{ name, comment string }
|
||||
var allTables []tableInfo
|
||||
for tables.Next() {
|
||||
var ti tableInfo
|
||||
tables.Scan(&ti.name, &ti.comment)
|
||||
allTables = append(allTables, ti)
|
||||
}
|
||||
tables.Close()
|
||||
|
||||
// 过滤
|
||||
onlySet := map[string]bool{}
|
||||
if *onlyTables != "" {
|
||||
for _, t := range strings.Split(*onlyTables, ",") {
|
||||
onlySet[strings.TrimSpace(t)] = true
|
||||
}
|
||||
}
|
||||
var toMigrate []tableInfo
|
||||
for _, ti := range allTables {
|
||||
if skipTables[ti.name] {
|
||||
log.Printf("跳过(框架内置): %s", ti.name)
|
||||
continue
|
||||
}
|
||||
if len(onlySet) > 0 && !onlySet[ti.name] {
|
||||
log.Printf("跳过(未指定): %s", ti.name)
|
||||
continue
|
||||
}
|
||||
toMigrate = append(toMigrate, ti)
|
||||
}
|
||||
|
||||
log.Printf("共 %d 张表待迁移", len(toMigrate))
|
||||
fmt.Println(strings.Repeat("─", 60))
|
||||
|
||||
// 逐表迁移
|
||||
var totalRows int64
|
||||
start := time.Now()
|
||||
for i, ti := range toMigrate {
|
||||
log.Printf("[%d/%d] 迁移表: %s", i+1, len(toMigrate), ti.name)
|
||||
|
||||
cols, err := getColumns(mysqlDB, mysqlConf.Name, ti.name)
|
||||
if err != nil {
|
||||
log.Fatalf(" 读取列失败 %s: %v", ti.name, err)
|
||||
}
|
||||
indexes, err := getIndexes(mysqlDB, mysqlConf.Name, ti.name)
|
||||
if err != nil {
|
||||
log.Printf(" [警告] 读取索引失败 %s: %v", ti.name, err)
|
||||
}
|
||||
|
||||
// 建表
|
||||
if err = createTableInDM(dmDB, ti.name, cols, indexes, ti.comment); err != nil {
|
||||
log.Fatalf(" 建表失败 %s: %v", ti.name, err)
|
||||
}
|
||||
|
||||
// 迁移数据
|
||||
migrated, err := migrateData(mysqlDB, dmDB, ti.name, cols, *batchSize)
|
||||
if err != nil {
|
||||
log.Fatalf(" 数据迁移失败 %s: %v", ti.name, err)
|
||||
}
|
||||
totalRows += migrated
|
||||
log.Printf(" ✓ %s 完成,迁移 %d 行", ti.name, migrated)
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
fmt.Println(strings.Repeat("─", 60))
|
||||
log.Printf("迁移完成!共 %d 表,%d 行,耗时 %s", len(toMigrate), totalRows, elapsed.Round(time.Millisecond))
|
||||
log.Printf("下一步:确认数据无误后,config.json 中 mysql→mysql-,dm-→dm(已完成)")
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
-- 达梦数据库测试环境初始化脚本
|
||||
-- Schema: TEST(在 config.json 中配置 "dm": {"name": "TEST", ...})
|
||||
-- 执行前确保已切换到 TEST Schema,或以 SYSDBA 身份执行
|
||||
-- 连接串示例: dm://SYSDBA:SC_sysdba2026@172.31.18.249:5236?schema=TEST
|
||||
|
||||
-- ===================== DDL =====================
|
||||
|
||||
CREATE TABLE "admin" (
|
||||
"id" INT IDENTITY(1,1) PRIMARY KEY,
|
||||
"name" VARCHAR(100),
|
||||
"phone" VARCHAR(20),
|
||||
"state" INT DEFAULT 0,
|
||||
"password" VARCHAR(100),
|
||||
"role_id" INT DEFAULT 0,
|
||||
"title" VARCHAR(100),
|
||||
"create_time" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
"modify_time" TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE UNIQUE INDEX "uk_admin_phone" ON "admin" ("phone");
|
||||
|
||||
CREATE TABLE "ctg" (
|
||||
"id" INT IDENTITY(1,1) PRIMARY KEY,
|
||||
"name" VARCHAR(100),
|
||||
"state" INT DEFAULT 0,
|
||||
"create_time" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
"modify_time" TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE "article" (
|
||||
"id" INT IDENTITY(1,1) PRIMARY KEY,
|
||||
"title" VARCHAR(200),
|
||||
"author" VARCHAR(100),
|
||||
"content" CLOB,
|
||||
"state" INT DEFAULT 0,
|
||||
"click_num" INT DEFAULT 0,
|
||||
"sort" INT DEFAULT 0,
|
||||
"img" VARCHAR(500),
|
||||
"ctg_id" INT DEFAULT 0,
|
||||
"admin_id" INT DEFAULT 0,
|
||||
"create_time" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
"modify_time" TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE "test_batch" (
|
||||
"id" INT IDENTITY(1,1) PRIMARY KEY,
|
||||
"name" VARCHAR(100),
|
||||
"title" VARCHAR(200),
|
||||
"state" INT DEFAULT 0,
|
||||
"create_time" TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE "cached" (
|
||||
"id" INT IDENTITY(1,1) PRIMARY KEY,
|
||||
"key" VARCHAR(64),
|
||||
"value" CLOB,
|
||||
"endtime" BIGINT,
|
||||
"time" BIGINT
|
||||
);
|
||||
|
||||
CREATE TABLE "hotime_cache" (
|
||||
"id" INT IDENTITY(1,1) PRIMARY KEY,
|
||||
"key" VARCHAR(64) NOT NULL,
|
||||
"value" TEXT,
|
||||
"end_time" TIMESTAMP,
|
||||
"state" INT DEFAULT 0,
|
||||
"create_time" TIMESTAMP,
|
||||
"modify_time" TIMESTAMP,
|
||||
CONSTRAINT "uk_hotime_cache_key" UNIQUE ("key")
|
||||
);
|
||||
CREATE INDEX "idx_hotime_cache_end_time" ON "hotime_cache" ("end_time");
|
||||
|
||||
-- ===================== DML 种子数据 =====================
|
||||
|
||||
INSERT INTO "ctg" ("name","state","create_time","modify_time") VALUES ('新闻资讯', 0, NOW(), NOW());
|
||||
INSERT INTO "ctg" ("name","state","create_time","modify_time") VALUES ('技术文档', 0, NOW(), NOW());
|
||||
INSERT INTO "ctg" ("name","state","create_time","modify_time") VALUES ('公告通知', 0, NOW(), NOW());
|
||||
INSERT INTO "ctg" ("name","state","create_time","modify_time") VALUES ('产品介绍', 0, NOW(), NOW());
|
||||
INSERT INTO "ctg" ("name","state","create_time","modify_time") VALUES ('已归档', 1, NOW(), NOW());
|
||||
|
||||
INSERT INTO "admin" ("name","phone","state","password","role_id","title","create_time","modify_time") VALUES ('超级管理员','13800000001',1,'admin123', 1,'系统管理员',NOW(),NOW());
|
||||
INSERT INTO "admin" ("name","phone","state","password","role_id","title","create_time","modify_time") VALUES ('编辑员', '13800000002',1,'editor123',2,'内容编辑', NOW(),NOW());
|
||||
INSERT INTO "admin" ("name","phone","state","password","role_id","title","create_time","modify_time") VALUES ('审核员', '13800000003',1,'review123',3,'内容审核', NOW(),NOW());
|
||||
|
||||
INSERT INTO "article" ("title","author","content","state","click_num","sort","ctg_id","admin_id","create_time","modify_time") VALUES ('数据库入门指南', '管理员','国产关系型数据库管理系统入门...', 0, 128, 10,1,1,NOW(),NOW());
|
||||
INSERT INTO "article" ("title","author","content","state","click_num","sort","ctg_id","admin_id","create_time","modify_time") VALUES ('新闻发布系统上线公告', '编辑员','本系统已正式上线运行...', 0, 256, 5,1,2,NOW(),NOW());
|
||||
INSERT INTO "article" ("title","author","content","state","click_num","sort","ctg_id","admin_id","create_time","modify_time") VALUES ('Go语言最佳实践', '管理员','Go语言在后端开发中的最佳实践总结...',0, 512, 8,2,1,NOW(),NOW());
|
||||
INSERT INTO "article" ("title","author","content","state","click_num","sort","ctg_id","admin_id","create_time","modify_time") VALUES ('API接口设计规范', '审核员','RESTful API设计的基本规范和标准...', 0, 64, 15,2,3,NOW(),NOW());
|
||||
INSERT INTO "article" ("title","author","content","state","click_num","sort","ctg_id","admin_id","create_time","modify_time") VALUES ('系统维护通知', '管理员','系统将于本周日凌晨进行维护升级...', 0, 32, 1,3,1,NOW(),NOW());
|
||||
INSERT INTO "article" ("title","author","content","state","click_num","sort","ctg_id","admin_id","create_time","modify_time") VALUES ('产品功能更新说明', '编辑员','新版本增加了以下功能...', 0, 96, 12,4,2,NOW(),NOW());
|
||||
INSERT INTO "article" ("title","author","content","state","click_num","sort","ctg_id","admin_id","create_time","modify_time") VALUES ('数据库性能优化指南', '管理员','数据库性能优化的常见方法和技巧...', 0,1024, 3,2,1,NOW(),NOW());
|
||||
INSERT INTO "article" ("title","author","content","state","click_num","sort","ctg_id","admin_id","create_time","modify_time") VALUES ('缓存机制深入分析', '审核员','缓存在Web应用中的重要作用...', 0, 200, 7,2,3,NOW(),NOW());
|
||||
INSERT INTO "article" ("title","author","content","state","click_num","sort","ctg_id","admin_id","create_time","modify_time") VALUES ('旧版公告(已归档)', '管理员','此公告已过期归档...', 1, 10, 0,5,1,NOW(),NOW());
|
||||
INSERT INTO "article" ("title","author","content","state","click_num","sort","ctg_id","admin_id","create_time","modify_time") VALUES ('测试文章(隐藏)', '编辑员','测试用文章不公开显示...', 2, 0, 0,1,2,NOW(),NOW());
|
||||
INSERT INTO "article" ("title","author","content","state","click_num","sort","ctg_id","admin_id","create_time","modify_time") VALUES ('高点击量文章', '编辑员','这是一篇点击量很高的文章...', 0,9999, 2,1,2,NOW(),NOW());
|
||||
@@ -0,0 +1,104 @@
|
||||
-- MySQL 测试数据库初始化脚本
|
||||
-- 数据库: test(在 config.json 中配置 "mysql": {"name": "test", ...})
|
||||
-- 执行前确保已连接到 test 数据库:USE test;
|
||||
|
||||
-- ===================== DDL =====================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `admin` (
|
||||
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`name` varchar(100) DEFAULT NULL,
|
||||
`phone` varchar(20) DEFAULT NULL,
|
||||
`state` int(2) DEFAULT '0',
|
||||
`password` varchar(100) DEFAULT NULL,
|
||||
`role_id` int(11) DEFAULT '0',
|
||||
`title` varchar(100) DEFAULT NULL,
|
||||
`create_time` datetime DEFAULT NULL,
|
||||
`modify_time` datetime DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_phone` (`phone`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='管理员表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `ctg` (
|
||||
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`name` varchar(100) DEFAULT NULL,
|
||||
`state` int(2) DEFAULT '0',
|
||||
`create_time` datetime DEFAULT NULL,
|
||||
`modify_time` datetime DEFAULT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分类表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `article` (
|
||||
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`title` varchar(200) DEFAULT NULL,
|
||||
`author` varchar(100) DEFAULT NULL,
|
||||
`content` text,
|
||||
`state` int(2) DEFAULT '0' COMMENT '0正常 1归档 2隐藏',
|
||||
`click_num` int(11) DEFAULT '0',
|
||||
`sort` int(11) DEFAULT '0',
|
||||
`img` varchar(500) DEFAULT NULL,
|
||||
`ctg_id` int(11) DEFAULT '0',
|
||||
`admin_id` int(11) DEFAULT '0',
|
||||
`create_time` datetime DEFAULT NULL,
|
||||
`modify_time` datetime DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_state` (`state`),
|
||||
KEY `idx_ctg_id` (`ctg_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `test_batch` (
|
||||
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`name` varchar(100) DEFAULT NULL,
|
||||
`title` varchar(200) DEFAULT NULL,
|
||||
`state` int(2) DEFAULT '0',
|
||||
`create_time` datetime DEFAULT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='批量测试表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `cached` (
|
||||
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`key` varchar(64) DEFAULT NULL,
|
||||
`value` text,
|
||||
`endtime` bigint(20) DEFAULT NULL,
|
||||
`time` bigint(20) DEFAULT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='老版本缓存表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `hotime_cache` (
|
||||
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`key` varchar(64) NOT NULL COMMENT '缓存键',
|
||||
`value` text DEFAULT NULL COMMENT '缓存值',
|
||||
`end_time` datetime DEFAULT NULL COMMENT '过期时间',
|
||||
`state` int(2) DEFAULT '0',
|
||||
`create_time` datetime DEFAULT NULL,
|
||||
`modify_time` datetime DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_key` (`key`),
|
||||
KEY `idx_end_time` (`end_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='缓存管理';
|
||||
|
||||
-- ===================== DML 种子数据 =====================
|
||||
|
||||
INSERT IGNORE INTO `ctg` (`name`, `state`, `create_time`, `modify_time`) VALUES
|
||||
('新闻资讯', 0, NOW(), NOW()),
|
||||
('技术文档', 0, NOW(), NOW()),
|
||||
('公告通知', 0, NOW(), NOW()),
|
||||
('产品介绍', 0, NOW(), NOW()),
|
||||
('已归档', 1, NOW(), NOW());
|
||||
|
||||
INSERT IGNORE INTO `admin` (`name`, `phone`, `state`, `password`, `role_id`, `title`, `create_time`, `modify_time`) VALUES
|
||||
('超级管理员', '13800000001', 1, 'admin123', 1, '系统管理员', NOW(), NOW()),
|
||||
('编辑员', '13800000002', 1, 'editor123', 2, '内容编辑', NOW(), NOW()),
|
||||
('审核员', '13800000003', 1, 'review123', 3, '内容审核', NOW(), NOW());
|
||||
|
||||
INSERT INTO `article` (`title`, `author`, `content`, `state`, `click_num`, `sort`, `ctg_id`, `admin_id`, `create_time`, `modify_time`) VALUES
|
||||
('数据库入门指南', '管理员', '国产关系型数据库管理系统入门...', 0, 128, 10, 1, 1, NOW(), NOW()),
|
||||
('新闻发布系统上线公告', '编辑员', '本系统已正式上线运行...', 0, 256, 5, 1, 2, NOW(), NOW()),
|
||||
('Go语言最佳实践', '管理员', 'Go语言在后端开发中的最佳实践总结...', 0, 512, 8, 2, 1, NOW(), NOW()),
|
||||
('API接口设计规范', '审核员', 'RESTful API设计的基本规范和标准...', 0, 64, 15, 2, 3, NOW(), NOW()),
|
||||
('系统维护通知', '管理员', '系统将于本周日凌晨进行维护升级...', 0, 32, 1, 3, 1, NOW(), NOW()),
|
||||
('产品功能更新说明', '编辑员', '新版本增加了以下功能...', 0, 96, 12, 4, 2, NOW(), NOW()),
|
||||
('数据库性能优化指南', '管理员', '数据库性能优化的常见方法和技巧...', 0, 1024, 3, 2, 1, NOW(), NOW()),
|
||||
('缓存机制深入分析', '审核员', '缓存在Web应用中的重要作用...', 0, 200, 7, 2, 3, NOW(), NOW()),
|
||||
('旧版公告(已归档)', '管理员', '此公告已过期归档...', 1, 10, 0, 5, 1, NOW(), NOW()),
|
||||
('测试文章(隐藏)', '编辑员', '测试用文章不公开显示...', 2, 0, 0, 1, 2, NOW(), NOW()),
|
||||
('高点击量文章', '编辑员', '这是一篇点击量很高的文章...', 0, 9999, 2, 1, 2, NOW(), NOW());
|
||||
@@ -1,848 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
// 实际使用时请替换为正确的导入路径
|
||||
// "code.hoteas.com/golang/hotime/cache"
|
||||
// "code.hoteas.com/golang/hotime/common"
|
||||
// "code.hoteas.com/golang/hotime/db"
|
||||
"code.hoteas.com/golang/hotime/cache"
|
||||
"code.hoteas.com/golang/hotime/common"
|
||||
"code.hoteas.com/golang/hotime/db"
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// HoTimeDB使用示例代码集合 - 修正版
|
||||
// 本文件包含了各种常见场景的完整示例代码,所有条件查询语法已修正
|
||||
|
||||
// 示例1: 基本初始化和配置
|
||||
func Example1_BasicSetup() {
|
||||
// 创建数据库实例
|
||||
database := &db.HoTimeDB{
|
||||
Prefix: "app_", // 设置表前缀
|
||||
Mode: 2, // 开发模式,输出SQL日志
|
||||
Type: "mysql", // 数据库类型
|
||||
}
|
||||
|
||||
// 设置日志
|
||||
logger := logrus.New()
|
||||
database.Log = logger
|
||||
|
||||
// 设置连接函数
|
||||
database.SetConnect(func(err ...*common.Error) (master, slave *sql.DB) {
|
||||
// 主数据库连接
|
||||
master, dbErr := sql.Open("mysql", "root:password@tcp(localhost:3306)/testdb?charset=utf8&parseTime=true")
|
||||
if dbErr != nil {
|
||||
log.Fatal("数据库连接失败:", dbErr)
|
||||
}
|
||||
|
||||
// 从数据库连接(可选,用于读写分离)
|
||||
slave = master // 这里使用同一个连接,实际项目中可以连接到从库
|
||||
|
||||
return master, slave
|
||||
})
|
||||
|
||||
fmt.Println("数据库初始化完成")
|
||||
}
|
||||
|
||||
// 示例2: 基本CRUD操作(修正版)
|
||||
func Example2_BasicCRUD_Fixed(db *db.HoTimeDB) {
|
||||
// 创建用户
|
||||
fmt.Println("=== 创建用户 ===")
|
||||
userId := db.Insert("user", common.Map{
|
||||
"name": "张三",
|
||||
"email": "zhangsan@example.com",
|
||||
"age": 25,
|
||||
"status": 1,
|
||||
"balance": 1000.50,
|
||||
"created_time[#]": "NOW()",
|
||||
})
|
||||
fmt.Printf("新用户ID: %d\n", userId)
|
||||
|
||||
// 查询用户(单条件,可以不用AND)
|
||||
fmt.Println("\n=== 查询用户 ===")
|
||||
user := db.Get("user", "*", common.Map{
|
||||
"id": userId,
|
||||
})
|
||||
if user != nil {
|
||||
fmt.Printf("用户信息: %+v\n", user)
|
||||
}
|
||||
|
||||
// 更新用户(多条件必须用AND包装)
|
||||
fmt.Println("\n=== 更新用户 ===")
|
||||
affected := db.Update("user", common.Map{
|
||||
"name": "李四",
|
||||
"age": 26,
|
||||
"updated_time[#]": "NOW()",
|
||||
}, common.Map{
|
||||
"AND": common.Map{
|
||||
"id": userId,
|
||||
"status": 1, // 确保只更新正常状态的用户
|
||||
},
|
||||
})
|
||||
fmt.Printf("更新记录数: %d\n", affected)
|
||||
|
||||
// 软删除用户
|
||||
fmt.Println("\n=== 软删除用户 ===")
|
||||
affected = db.Update("user", common.Map{
|
||||
"deleted_at[#]": "NOW()",
|
||||
"status": 0,
|
||||
}, common.Map{
|
||||
"id": userId, // 单条件,不需要AND
|
||||
})
|
||||
fmt.Printf("软删除记录数: %d\n", affected)
|
||||
}
|
||||
|
||||
// 示例3: 条件查询语法(修正版)
|
||||
func Example3_ConditionQuery_Fixed(db *db.HoTimeDB) {
|
||||
fmt.Println("=== 条件查询语法示例 ===")
|
||||
|
||||
// ✅ 正确:单个条件
|
||||
users1 := db.Select("user", "*", common.Map{
|
||||
"status": 1,
|
||||
})
|
||||
fmt.Printf("活跃用户: %d个\n", len(users1))
|
||||
|
||||
// ✅ 正确:多个条件用AND包装
|
||||
users2 := db.Select("user", "*", common.Map{
|
||||
"AND": common.Map{
|
||||
"status": 1,
|
||||
"age[>]": 18,
|
||||
"age[<=]": 60,
|
||||
},
|
||||
})
|
||||
fmt.Printf("活跃的成年用户: %d个\n", len(users2))
|
||||
|
||||
// ✅ 正确:OR条件
|
||||
users3 := db.Select("user", "*", common.Map{
|
||||
"OR": common.Map{
|
||||
"level": "vip",
|
||||
"balance[>]": 5000,
|
||||
},
|
||||
})
|
||||
fmt.Printf("VIP或高余额用户: %d个\n", len(users3))
|
||||
|
||||
// ✅ 正确:条件 + 特殊参数
|
||||
users4 := db.Select("user", "*", common.Map{
|
||||
"AND": common.Map{
|
||||
"status": 1,
|
||||
"age[>=]": 18,
|
||||
},
|
||||
"ORDER": "created_time DESC",
|
||||
"LIMIT": 10,
|
||||
})
|
||||
fmt.Printf("最近的活跃成年用户: %d个\n", len(users4))
|
||||
|
||||
// ✅ 正确:复杂嵌套条件
|
||||
users5 := db.Select("user", "*", common.Map{
|
||||
"AND": common.Map{
|
||||
"status": 1,
|
||||
"OR": common.Map{
|
||||
"age[<]": 30,
|
||||
"level": "vip",
|
||||
},
|
||||
},
|
||||
"ORDER": []string{"level DESC", "created_time DESC"},
|
||||
"LIMIT": []int{0, 20},
|
||||
})
|
||||
fmt.Printf("年轻或VIP的活跃用户: %d个\n", len(users5))
|
||||
|
||||
// ✅ 正确:模糊查询
|
||||
users6 := db.Select("user", "*", common.Map{
|
||||
"AND": common.Map{
|
||||
"name[~]": "张", // 姓名包含"张"
|
||||
"email[~!]": "gmail", // 邮箱以gmail开头
|
||||
"status": 1,
|
||||
},
|
||||
})
|
||||
fmt.Printf("姓张的gmail活跃用户: %d个\n", len(users6))
|
||||
|
||||
// ✅ 正确:范围查询
|
||||
users7 := db.Select("user", "*", common.Map{
|
||||
"AND": common.Map{
|
||||
"age[<>]": []int{18, 35}, // 年龄在18-35之间
|
||||
"balance[><]": []float64{0, 100}, // 余额不在0-100之间
|
||||
"status": 1,
|
||||
},
|
||||
})
|
||||
fmt.Printf("18-35岁且余额>100的活跃用户: %d个\n", len(users7))
|
||||
|
||||
// ✅ 正确:IN查询
|
||||
users8 := db.Select("user", "*", common.Map{
|
||||
"AND": common.Map{
|
||||
"id": []int{1, 2, 3, 4, 5}, // ID在指定范围内
|
||||
"status[!]": []int{0, -1}, // 状态不为0或-1
|
||||
},
|
||||
})
|
||||
fmt.Printf("指定ID的活跃用户: %d个\n", len(users8))
|
||||
}
|
||||
|
||||
// 示例4: 链式查询操作(正确版)
|
||||
func Example4_ChainQuery_Fixed(db *db.HoTimeDB) {
|
||||
fmt.Println("=== 链式查询示例 ===")
|
||||
|
||||
// 链式查询(链式语法允许单独的Where,然后用And添加更多条件)
|
||||
users := db.Table("user").
|
||||
Where("status", 1). // 链式中可以单独Where
|
||||
And("age[>=]", 18). // 然后用And添加条件
|
||||
And("age[<=]", 60). // 再添加条件
|
||||
Or(common.Map{ // 或者用Or添加OR条件组
|
||||
"level": "vip",
|
||||
"balance[>]": 5000,
|
||||
}).
|
||||
Order("created_time DESC", "id ASC"). // 排序
|
||||
Limit(0, 10). // 限制结果
|
||||
Select("id,name,email,age,balance,level")
|
||||
|
||||
fmt.Printf("链式查询到 %d 个用户\n", len(users))
|
||||
for i, user := range users {
|
||||
fmt.Printf("用户%d: %s (年龄:%v, 余额:%v)\n",
|
||||
i+1,
|
||||
user.GetString("name"),
|
||||
user.Get("age"),
|
||||
user.Get("balance"))
|
||||
}
|
||||
|
||||
// 链式统计查询
|
||||
count := db.Table("user").
|
||||
Where("status", 1).
|
||||
And("age[>=]", 18).
|
||||
Count()
|
||||
fmt.Printf("符合条件的用户总数: %d\n", count)
|
||||
}
|
||||
|
||||
// 示例5: JOIN查询操作(修正版)
|
||||
func Example5_JoinQuery_Fixed(db *db.HoTimeDB) {
|
||||
fmt.Println("=== JOIN查询示例 ===")
|
||||
|
||||
// 链式JOIN查询
|
||||
orders := db.Table("order").
|
||||
LeftJoin("user", "order.user_id = user.id").
|
||||
LeftJoin("product", "order.product_id = product.id").
|
||||
Where("order.status", "paid"). // 链式中单个条件可以直接Where
|
||||
And("order.created_time[>]", "2023-01-01"). // 用And添加更多条件
|
||||
Order("order.created_time DESC").
|
||||
Select(`
|
||||
order.id as order_id,
|
||||
order.amount,
|
||||
order.status,
|
||||
order.created_time,
|
||||
user.name as user_name,
|
||||
user.email as user_email,
|
||||
product.title as product_title,
|
||||
product.price as product_price
|
||||
`)
|
||||
|
||||
fmt.Printf("链式JOIN查询到 %d 个订单\n", len(orders))
|
||||
for _, order := range orders {
|
||||
fmt.Printf("订单ID:%v, 用户:%s, 商品:%s, 金额:%v\n",
|
||||
order.Get("order_id"),
|
||||
order.GetString("user_name"),
|
||||
order.GetString("product_title"),
|
||||
order.Get("amount"))
|
||||
}
|
||||
|
||||
// 传统JOIN语法(多个条件必须用AND包装)
|
||||
orders2 := db.Select("order",
|
||||
common.Slice{
|
||||
common.Map{"[>]user": "order.user_id = user.id"},
|
||||
common.Map{"[>]product": "order.product_id = product.id"},
|
||||
},
|
||||
"order.*, user.name as user_name, product.title as product_title",
|
||||
common.Map{
|
||||
"AND": common.Map{
|
||||
"order.status": "paid",
|
||||
"order.created_time[>]": "2023-01-01",
|
||||
},
|
||||
})
|
||||
|
||||
fmt.Printf("传统JOIN语法查询到 %d 个订单\n", len(orders2))
|
||||
}
|
||||
|
||||
// 示例6: 分页查询(修正版)
|
||||
func Example6_PaginationQuery_Fixed(db *db.HoTimeDB) {
|
||||
fmt.Println("=== 分页查询示例 ===")
|
||||
|
||||
page := 2
|
||||
pageSize := 10
|
||||
|
||||
// 获取总数(单条件)
|
||||
total := db.Count("user", common.Map{
|
||||
"AND": common.Map{
|
||||
"status": 1,
|
||||
"deleted_at": nil,
|
||||
},
|
||||
})
|
||||
|
||||
// 分页数据(链式方式)
|
||||
users := db.Table("user").
|
||||
Where("status", 1).
|
||||
And("deleted_at", nil).
|
||||
Order("created_time DESC").
|
||||
Page(page, pageSize).
|
||||
Select("id,name,email,created_time")
|
||||
|
||||
// 使用传统方式的分页查询
|
||||
users2 := db.Page(page, pageSize).PageSelect("user", "*", common.Map{
|
||||
"AND": common.Map{
|
||||
"status": 1,
|
||||
"deleted_at": nil,
|
||||
},
|
||||
"ORDER": "created_time DESC",
|
||||
})
|
||||
|
||||
// 计算分页信息
|
||||
totalPages := (total + pageSize - 1) / pageSize
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
fmt.Printf("总记录数: %d\n", total)
|
||||
fmt.Printf("总页数: %d\n", totalPages)
|
||||
fmt.Printf("当前页: %d\n", page)
|
||||
fmt.Printf("每页大小: %d\n", pageSize)
|
||||
fmt.Printf("偏移量: %d\n", offset)
|
||||
fmt.Printf("链式查询当前页记录数: %d\n", len(users))
|
||||
fmt.Printf("传统查询当前页记录数: %d\n", len(users2))
|
||||
|
||||
for i, user := range users {
|
||||
fmt.Printf(" %d. %s (%s) - %v\n",
|
||||
offset+i+1,
|
||||
user.GetString("name"),
|
||||
user.GetString("email"),
|
||||
user.Get("created_time"))
|
||||
}
|
||||
}
|
||||
|
||||
// 示例7: 聚合函数查询(修正版)
|
||||
func Example7_AggregateQuery_Fixed(db *db.HoTimeDB) {
|
||||
fmt.Println("=== 聚合函数查询示例 ===")
|
||||
|
||||
// 基本统计
|
||||
userCount := db.Count("user")
|
||||
activeUserCount := db.Count("user", common.Map{"status": 1})
|
||||
totalBalance := db.Sum("user", "balance", common.Map{"status": 1})
|
||||
|
||||
fmt.Printf("总用户数: %d\n", userCount)
|
||||
fmt.Printf("活跃用户数: %d\n", activeUserCount)
|
||||
fmt.Printf("活跃用户总余额: %.2f\n", totalBalance)
|
||||
|
||||
// 分组统计(正确语法)
|
||||
stats := db.Select("user",
|
||||
"level, COUNT(*) as user_count, AVG(age) as avg_age, SUM(balance) as total_balance",
|
||||
common.Map{
|
||||
"status": 1, // 单条件,不需要AND
|
||||
"GROUP": "level",
|
||||
"ORDER": "user_count DESC",
|
||||
})
|
||||
|
||||
fmt.Println("\n按等级分组统计:")
|
||||
for _, stat := range stats {
|
||||
fmt.Printf("等级:%v, 用户数:%v, 平均年龄:%v, 总余额:%v\n",
|
||||
stat.Get("level"),
|
||||
stat.Get("user_count"),
|
||||
stat.Get("avg_age"),
|
||||
stat.Get("total_balance"))
|
||||
}
|
||||
|
||||
// 关联统计(修正版)
|
||||
orderStats := db.Select("order",
|
||||
common.Slice{
|
||||
common.Map{"[>]user": "order.user_id = user.id"},
|
||||
},
|
||||
"user.level, COUNT(order.id) as order_count, SUM(order.amount) as total_amount",
|
||||
common.Map{
|
||||
"AND": common.Map{
|
||||
"order.status": "paid",
|
||||
"order.created_time[>]": "2023-01-01",
|
||||
},
|
||||
"GROUP": "user.level",
|
||||
"ORDER": "total_amount DESC",
|
||||
})
|
||||
|
||||
fmt.Println("\n用户等级订单统计:")
|
||||
for _, stat := range orderStats {
|
||||
fmt.Printf("等级:%v, 订单数:%v, 总金额:%v\n",
|
||||
stat.Get("level"),
|
||||
stat.Get("order_count"),
|
||||
stat.Get("total_amount"))
|
||||
}
|
||||
}
|
||||
|
||||
// 示例8: 事务处理(修正版)
|
||||
func Example8_Transaction_Fixed(database *db.HoTimeDB) {
|
||||
fmt.Println("=== 事务处理示例 ===")
|
||||
|
||||
// 模拟转账操作
|
||||
fromUserId := int64(1)
|
||||
toUserId := int64(2)
|
||||
amount := 100.0
|
||||
|
||||
success := database.Action(func(tx db.HoTimeDB) bool {
|
||||
// 检查转出账户余额(单条件)
|
||||
fromUser := tx.Get("user", "balance", common.Map{"id": fromUserId})
|
||||
if fromUser == nil {
|
||||
fmt.Println("转出用户不存在")
|
||||
return false
|
||||
}
|
||||
|
||||
fromBalance := fromUser.GetFloat64("balance")
|
||||
if fromBalance < amount {
|
||||
fmt.Println("余额不足")
|
||||
return false
|
||||
}
|
||||
|
||||
// 扣减转出账户余额(多条件必须用AND)
|
||||
affected1 := tx.Update("user", common.Map{
|
||||
"balance[#]": fmt.Sprintf("balance - %.2f", amount),
|
||||
"updated_time[#]": "NOW()",
|
||||
}, common.Map{
|
||||
"AND": common.Map{
|
||||
"id": fromUserId,
|
||||
"balance[>=]": amount, // 再次确保余额足够
|
||||
},
|
||||
})
|
||||
|
||||
if affected1 == 0 {
|
||||
fmt.Println("扣减余额失败")
|
||||
return false
|
||||
}
|
||||
|
||||
// 增加转入账户余额(单条件)
|
||||
affected2 := tx.Update("user", common.Map{
|
||||
"balance[#]": fmt.Sprintf("balance + %.2f", amount),
|
||||
"updated_time[#]": "NOW()",
|
||||
}, common.Map{
|
||||
"id": toUserId,
|
||||
})
|
||||
|
||||
if affected2 == 0 {
|
||||
fmt.Println("增加余额失败")
|
||||
return false
|
||||
}
|
||||
|
||||
// 记录转账日志
|
||||
logId := tx.Insert("transfer_log", common.Map{
|
||||
"from_user_id": fromUserId,
|
||||
"to_user_id": toUserId,
|
||||
"amount": amount,
|
||||
"status": "success",
|
||||
"created_time[#]": "NOW()",
|
||||
})
|
||||
|
||||
if logId == 0 {
|
||||
fmt.Println("记录日志失败")
|
||||
return false
|
||||
}
|
||||
|
||||
fmt.Printf("转账成功: 用户%d -> 用户%d, 金额:%.2f\n", fromUserId, toUserId, amount)
|
||||
return true
|
||||
})
|
||||
|
||||
if success {
|
||||
fmt.Println("事务执行成功")
|
||||
} else {
|
||||
fmt.Println("事务回滚")
|
||||
if database.LastErr.GetError() != nil {
|
||||
fmt.Println("错误原因:", database.LastErr.GetError())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 示例9: 缓存机制(修正版)
|
||||
func Example9_CacheSystem_Fixed(database *db.HoTimeDB) {
|
||||
fmt.Println("=== 缓存机制示例 ===")
|
||||
|
||||
// 设置缓存(实际项目中需要配置缓存参数)
|
||||
database.HoTimeCache = &cache.HoTimeCache{}
|
||||
|
||||
// 第一次查询(会缓存结果)
|
||||
fmt.Println("第一次查询(会缓存)...")
|
||||
users1 := database.Select("user", "*", common.Map{
|
||||
"status": 1, // 单条件
|
||||
"LIMIT": 10,
|
||||
})
|
||||
fmt.Printf("查询到 %d 个用户\n", len(users1))
|
||||
|
||||
// 第二次相同查询(从缓存获取)
|
||||
fmt.Println("第二次相同查询(从缓存获取)...")
|
||||
users2 := database.Select("user", "*", common.Map{
|
||||
"status": 1, // 单条件
|
||||
"LIMIT": 10,
|
||||
})
|
||||
fmt.Printf("查询到 %d 个用户\n", len(users2))
|
||||
|
||||
// 更新操作会清除缓存
|
||||
fmt.Println("执行更新操作(会清除缓存)...")
|
||||
affected := database.Update("user", common.Map{
|
||||
"updated_time[#]": "NOW()",
|
||||
}, common.Map{
|
||||
"id": 1, // 单条件
|
||||
})
|
||||
fmt.Printf("更新 %d 条记录\n", affected)
|
||||
|
||||
// 再次查询(重新从数据库获取并缓存)
|
||||
fmt.Println("更新后再次查询(重新缓存)...")
|
||||
users3 := database.Select("user", "*", common.Map{
|
||||
"status": 1, // 单条件
|
||||
"LIMIT": 10,
|
||||
})
|
||||
fmt.Printf("查询到 %d 个用户\n", len(users3))
|
||||
}
|
||||
|
||||
// 示例10: 性能优化技巧(修正版)
|
||||
func Example10_PerformanceOptimization_Fixed(database *db.HoTimeDB) {
|
||||
fmt.Println("=== 性能优化技巧示例 ===")
|
||||
|
||||
// IN查询优化(连续数字自动转为BETWEEN)
|
||||
fmt.Println("IN查询优化示例...")
|
||||
users := database.Select("user", "*", common.Map{
|
||||
"id": []int{1, 2, 3, 4, 5, 10, 11, 12, 13, 20}, // 单个IN条件,会被优化
|
||||
})
|
||||
fmt.Printf("查询到 %d 个用户\n", len(users))
|
||||
fmt.Println("执行的SQL:", database.LastQuery)
|
||||
|
||||
// 批量插入(使用事务)
|
||||
fmt.Println("\n批量插入示例...")
|
||||
success := database.Action(func(tx db.HoTimeDB) bool {
|
||||
for i := 1; i <= 100; i++ {
|
||||
id := tx.Insert("user_batch", common.Map{
|
||||
"name": fmt.Sprintf("批量用户%d", i),
|
||||
"email": fmt.Sprintf("batch%d@example.com", i),
|
||||
"status": 1,
|
||||
"created_time[#]": "NOW()",
|
||||
})
|
||||
if id == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// 每10个用户输出一次进度
|
||||
if i%10 == 0 {
|
||||
fmt.Printf("已插入 %d 个用户\n", i)
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if success {
|
||||
fmt.Println("批量插入完成")
|
||||
} else {
|
||||
fmt.Println("批量插入失败")
|
||||
}
|
||||
|
||||
// 索引友好的查询(修正版)
|
||||
fmt.Println("\n索引友好的查询...")
|
||||
recentUsers := database.Select("user", "*", common.Map{
|
||||
"AND": common.Map{
|
||||
"created_time[>]": "2023-01-01", // 假设created_time有索引
|
||||
"status": 1, // 假设status有索引
|
||||
},
|
||||
"ORDER": "created_time DESC", // 利用索引排序
|
||||
"LIMIT": 20,
|
||||
})
|
||||
fmt.Printf("查询到 %d 个近期用户\n", len(recentUsers))
|
||||
}
|
||||
|
||||
// 完整的应用示例(修正版)
|
||||
func CompleteExample_Fixed() {
|
||||
fmt.Println("=== HoTimeDB完整应用示例(修正版) ===")
|
||||
|
||||
// 初始化数据库
|
||||
database := &db.HoTimeDB{
|
||||
Prefix: "app_",
|
||||
Mode: 1, // 测试模式
|
||||
Type: "mysql",
|
||||
}
|
||||
|
||||
// 设置连接
|
||||
database.SetConnect(func(err ...*common.Error) (master, slave *sql.DB) {
|
||||
// 这里使用实际的数据库连接字符串
|
||||
dsn := "root:password@tcp(localhost:3306)/testdb?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
master, dbErr := sql.Open("mysql", dsn)
|
||||
if dbErr != nil {
|
||||
log.Fatal("数据库连接失败:", dbErr)
|
||||
}
|
||||
return master, master
|
||||
})
|
||||
|
||||
// 用户管理系统示例
|
||||
fmt.Println("\n=== 用户管理系统 ===")
|
||||
|
||||
// 1. 创建用户
|
||||
userId := database.Insert("user", common.Map{
|
||||
"name": "示例用户",
|
||||
"email": "example@test.com",
|
||||
"password": "hashed_password",
|
||||
"age": 28,
|
||||
"status": 1,
|
||||
"level": "normal",
|
||||
"balance": 500.00,
|
||||
"created_time[#]": "NOW()",
|
||||
})
|
||||
fmt.Printf("创建用户成功,ID: %d\n", userId)
|
||||
|
||||
// 2. 用户登录更新(多条件用AND)
|
||||
database.Update("user", common.Map{
|
||||
"last_login[#]": "NOW()",
|
||||
"login_count[#]": "login_count + 1",
|
||||
}, common.Map{
|
||||
"AND": common.Map{
|
||||
"id": userId,
|
||||
"status": 1,
|
||||
},
|
||||
})
|
||||
|
||||
// 3. 创建订单
|
||||
orderId := database.Insert("order", common.Map{
|
||||
"user_id": userId,
|
||||
"amount": 299.99,
|
||||
"status": "pending",
|
||||
"created_time[#]": "NOW()",
|
||||
})
|
||||
fmt.Printf("创建订单成功,ID: %d\n", orderId)
|
||||
|
||||
// 4. 订单支付(使用事务,修正版)
|
||||
paymentSuccess := database.Action(func(tx db.HoTimeDB) bool {
|
||||
// 更新订单状态(单条件)
|
||||
affected1 := tx.Update("order", common.Map{
|
||||
"status": "paid",
|
||||
"paid_time[#]": "NOW()",
|
||||
}, common.Map{
|
||||
"id": orderId,
|
||||
})
|
||||
|
||||
if affected1 == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// 扣减用户余额(多条件用AND)
|
||||
affected2 := tx.Update("user", common.Map{
|
||||
"balance[#]": "balance - 299.99",
|
||||
}, common.Map{
|
||||
"AND": common.Map{
|
||||
"id": userId,
|
||||
"balance[>=]": 299.99, // 确保余额足够
|
||||
},
|
||||
})
|
||||
|
||||
if affected2 == 0 {
|
||||
fmt.Println("余额不足或用户不存在")
|
||||
return false
|
||||
}
|
||||
|
||||
// 记录支付日志
|
||||
logId := tx.Insert("payment_log", common.Map{
|
||||
"user_id": userId,
|
||||
"order_id": orderId,
|
||||
"amount": 299.99,
|
||||
"type": "order_payment",
|
||||
"status": "success",
|
||||
"created_time[#]": "NOW()",
|
||||
})
|
||||
|
||||
return logId > 0
|
||||
})
|
||||
|
||||
if paymentSuccess {
|
||||
fmt.Println("订单支付成功")
|
||||
} else {
|
||||
fmt.Println("订单支付失败")
|
||||
}
|
||||
|
||||
// 5. 查询用户订单列表(链式查询)
|
||||
userOrders := database.Table("order").
|
||||
LeftJoin("user", "order.user_id = user.id").
|
||||
Where("order.user_id", userId). // 链式中单个条件可以直接Where
|
||||
Order("order.created_time DESC").
|
||||
Select(`
|
||||
order.id,
|
||||
order.amount,
|
||||
order.status,
|
||||
order.created_time,
|
||||
order.paid_time,
|
||||
user.name as user_name
|
||||
`)
|
||||
|
||||
fmt.Printf("\n用户订单列表 (%d个订单):\n", len(userOrders))
|
||||
for _, order := range userOrders {
|
||||
fmt.Printf(" 订单ID:%v, 金额:%v, 状态:%s, 创建时间:%v\n",
|
||||
order.Get("id"),
|
||||
order.Get("amount"),
|
||||
order.GetString("status"),
|
||||
order.Get("created_time"))
|
||||
}
|
||||
|
||||
// 6. 生成统计报表(修正版)
|
||||
stats := database.Select("order",
|
||||
common.Slice{
|
||||
common.Map{"[>]user": "order.user_id = user.id"},
|
||||
},
|
||||
`
|
||||
DATE(order.created_time) as date,
|
||||
COUNT(order.id) as order_count,
|
||||
SUM(order.amount) as total_amount,
|
||||
AVG(order.amount) as avg_amount
|
||||
`,
|
||||
common.Map{
|
||||
"AND": common.Map{
|
||||
"order.status": "paid",
|
||||
"order.created_time[>]": "2023-01-01",
|
||||
},
|
||||
"GROUP": "DATE(order.created_time)",
|
||||
"ORDER": "date DESC",
|
||||
"LIMIT": 30,
|
||||
})
|
||||
|
||||
fmt.Printf("\n最近30天订单统计:\n")
|
||||
for _, stat := range stats {
|
||||
fmt.Printf("日期:%v, 订单数:%v, 总金额:%v, 平均金额:%v\n",
|
||||
stat.Get("date"),
|
||||
stat.Get("order_count"),
|
||||
stat.Get("total_amount"),
|
||||
stat.Get("avg_amount"))
|
||||
}
|
||||
|
||||
fmt.Println("\n示例执行完成!")
|
||||
}
|
||||
|
||||
// 语法对比示例
|
||||
func SyntaxComparison() {
|
||||
fmt.Println("=== HoTimeDB语法对比 ===")
|
||||
|
||||
// 模拟数据库对象
|
||||
var db *db.HoTimeDB
|
||||
|
||||
fmt.Println("❌ 错误语法示例(不支持):")
|
||||
fmt.Println(`
|
||||
// 这样写是错误的,多个条件不能直接放在根Map中
|
||||
wrongUsers := db.Select("user", "*", common.Map{
|
||||
"status": 1, // ❌ 错误
|
||||
"age[>]": 18, // ❌ 错误
|
||||
"ORDER": "id DESC",
|
||||
})
|
||||
`)
|
||||
|
||||
fmt.Println("✅ 正确语法示例:")
|
||||
fmt.Println(`
|
||||
// 单个条件可以直接写
|
||||
correctUsers1 := db.Select("user", "*", common.Map{
|
||||
"status": 1, // ✅ 正确,单个条件
|
||||
})
|
||||
|
||||
// 多个条件必须用AND包装
|
||||
correctUsers2 := db.Select("user", "*", common.Map{
|
||||
"AND": common.Map{ // ✅ 正确,多个条件用AND包装
|
||||
"status": 1,
|
||||
"age[>]": 18,
|
||||
},
|
||||
"ORDER": "id DESC", // ✅ 正确,特殊参数与条件同级
|
||||
})
|
||||
|
||||
// OR条件
|
||||
correctUsers3 := db.Select("user", "*", common.Map{
|
||||
"OR": common.Map{ // ✅ 正确,OR条件
|
||||
"level": "vip",
|
||||
"balance[>]": 1000,
|
||||
},
|
||||
})
|
||||
|
||||
// 嵌套条件
|
||||
correctUsers4 := db.Select("user", "*", common.Map{
|
||||
"AND": common.Map{ // ✅ 正确,嵌套条件
|
||||
"status": 1,
|
||||
"OR": common.Map{
|
||||
"age[<]": 30,
|
||||
"level": "vip",
|
||||
},
|
||||
},
|
||||
"ORDER": "created_time DESC",
|
||||
"LIMIT": 20,
|
||||
})
|
||||
`)
|
||||
|
||||
// 实际不执行查询,只是展示语法
|
||||
_ = db
|
||||
}
|
||||
|
||||
// InsertsExample 批量插入示例
|
||||
func InsertsExample(database *db.HoTimeDB) {
|
||||
fmt.Println("\n=== 批量插入示例 ===")
|
||||
|
||||
// 批量插入用户(使用 []Map 格式)
|
||||
affected := database.Inserts("user", []common.Map{
|
||||
{"name": "批量用户1", "email": "batch1@example.com", "age": 25, "status": 1},
|
||||
{"name": "批量用户2", "email": "batch2@example.com", "age": 30, "status": 1},
|
||||
{"name": "批量用户3", "email": "batch3@example.com", "age": 28, "status": 1},
|
||||
})
|
||||
fmt.Printf("批量插入了 %d 条用户记录\n", affected)
|
||||
|
||||
// 批量插入日志(使用 [#] 标记直接 SQL)
|
||||
logAffected := database.Inserts("log", []common.Map{
|
||||
{"user_id": 1, "action": "login", "ip": "192.168.1.1", "created_time[#]": "NOW()"},
|
||||
{"user_id": 2, "action": "logout", "ip": "192.168.1.2", "created_time[#]": "NOW()"},
|
||||
{"user_id": 3, "action": "view", "ip": "192.168.1.3", "created_time[#]": "NOW()"},
|
||||
})
|
||||
fmt.Printf("批量插入了 %d 条日志记录\n", logAffected)
|
||||
}
|
||||
|
||||
// UpsertExample Upsert(插入或更新)示例
|
||||
func UpsertExample(database *db.HoTimeDB) {
|
||||
fmt.Println("\n=== Upsert示例 ===")
|
||||
|
||||
// Upsert 用户数据(使用 Slice 格式)
|
||||
// 如果 id 存在则更新,不存在则插入
|
||||
affected := database.Upsert("user",
|
||||
common.Map{
|
||||
"id": 1,
|
||||
"name": "张三更新",
|
||||
"email": "zhang_updated@example.com",
|
||||
"age": 26,
|
||||
},
|
||||
common.Slice{"id"}, // 唯一键
|
||||
common.Slice{"name", "email", "age"}, // 冲突时更新的字段
|
||||
)
|
||||
fmt.Printf("Upsert 影响了 %d 行\n", affected)
|
||||
|
||||
// Upsert 使用 [#] 直接 SQL
|
||||
affected2 := database.Upsert("user_stats",
|
||||
common.Map{
|
||||
"user_id": 1,
|
||||
"login_count[#]": "login_count + 1",
|
||||
"last_login[#]": "NOW()",
|
||||
},
|
||||
common.Slice{"user_id"},
|
||||
common.Slice{"login_count", "last_login"},
|
||||
)
|
||||
fmt.Printf("统计 Upsert 影响了 %d 行\n", affected2)
|
||||
|
||||
// 也支持可变参数形式
|
||||
affected3 := database.Upsert("user",
|
||||
common.Map{"id": 2, "name": "李四", "status": 1},
|
||||
common.Slice{"id"},
|
||||
"name", "status", // 可变参数
|
||||
)
|
||||
fmt.Printf("可变参数 Upsert 影响了 %d 行\n", affected3)
|
||||
}
|
||||
|
||||
// 运行所有修正后的示例
|
||||
func RunAllFixedExamples() {
|
||||
fmt.Println("开始运行HoTimeDB所有修正后的示例...")
|
||||
fmt.Println("注意:实际运行时需要确保数据库连接正确,并且相关表存在")
|
||||
|
||||
// 展示语法对比
|
||||
SyntaxComparison()
|
||||
|
||||
fmt.Println("请根据实际环境配置数据库连接后运行相应示例")
|
||||
fmt.Println("所有示例代码已修正完毕,语法正确!")
|
||||
fmt.Println("")
|
||||
fmt.Println("新增功能示例说明:")
|
||||
fmt.Println(" - InsertsExample(db): 批量插入示例,使用 []Map 格式")
|
||||
fmt.Println(" - UpsertExample(db): 插入或更新示例")
|
||||
}
|
||||
|
||||
func main() {
|
||||
// 运行语法对比示例
|
||||
RunAllFixedExamples()
|
||||
}
|
||||
@@ -1,17 +1,41 @@
|
||||
module code.hoteas.com/golang/hotime
|
||||
|
||||
go 1.16
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
gitee.com/chunanyong/dm v1.8.22
|
||||
github.com/360EntSecGroup-Skylar/excelize v1.4.1
|
||||
github.com/garyburd/redigo v1.6.3
|
||||
github.com/go-pay/gopay v1.5.78
|
||||
github.com/go-sql-driver/mysql v1.6.0
|
||||
github.com/mattn/go-sqlite3 v1.14.12
|
||||
github.com/rs/zerolog v1.35.0
|
||||
github.com/silenceper/wechat/v2 v2.1.2
|
||||
github.com/sirupsen/logrus v1.8.1
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.364
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ocr v1.0.364
|
||||
go.mongodb.org/mongo-driver v1.10.1
|
||||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b // indirect
|
||||
github.com/fatih/structs v1.1.0 // indirect
|
||||
github.com/golang/snappy v0.0.1 // indirect
|
||||
github.com/gomodule/redigo v1.8.5 // indirect
|
||||
github.com/klauspost/compress v1.13.6 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/sirupsen/logrus v1.8.1 // indirect
|
||||
github.com/spf13/cast v1.3.1 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.1.1 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.3 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d // indirect
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
golang.org/x/text v0.3.7 // indirect
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
gitee.com/chunanyong/dm v1.8.22 h1:H7fsrnUIvEA0jlDWew7vwELry1ff+tLMIu2Fk2cIBSg=
|
||||
gitee.com/chunanyong/dm v1.8.22/go.mod h1:EPRJnuPFgbyOFgJ0TRYCTGzhq+ZT4wdyaj/GW/LLcNg=
|
||||
github.com/360EntSecGroup-Skylar/excelize v1.4.1 h1:l55mJb6rkkaUzOpSsgEeKYtS6/0gHwBYyfo5Jcjv/Ks=
|
||||
github.com/360EntSecGroup-Skylar/excelize v1.4.1/go.mod h1:vnax29X2usfl7HHkBrX5EvSCJcmH3dT9luvxzu8iGAE=
|
||||
github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b h1:L/QXpzIa3pOvUGt1D1lA5KjYhPBAN/3iWdP7xeFS9F0=
|
||||
@@ -18,6 +20,7 @@ github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
|
||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/gomodule/redigo v1.8.5 h1:nRAxCa+SVsyjSBrtZmG/cqb6VbTmuRzpg/PoTFlpumc=
|
||||
github.com/gomodule/redigo v1.8.5/go.mod h1:P9dn9mFrCBvWhGE1wpxx6fgq7BAeLBk+UUUzlpkBYO0=
|
||||
github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
|
||||
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
|
||||
@@ -27,6 +30,10 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.12 h1:TJ1bhYJPV44phC+IMu1u2K/i5RriLTPe+yc68XDJ1Z0=
|
||||
github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
||||
@@ -39,6 +46,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI=
|
||||
github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
||||
github.com/silenceper/wechat/v2 v2.1.2 h1:+QfIMiYfwST2ZloTwmYp0O0p5Y1LYRZxfLWfMuSE30k=
|
||||
github.com/silenceper/wechat/v2 v2.1.2/go.mod h1:0OprxYCCp2CZAKw06BBlnaczInTk2KxOLsKeiopshGg=
|
||||
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
|
||||
@@ -56,6 +65,7 @@ github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.364 h1:X1Jw
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.364/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ocr v1.0.364 h1:kbor60vo37v7Hu+i17gooox9Rw281fVHNna8zwtDG1w=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ocr v1.0.364/go.mod h1:LeIUBOLhc+Y5YCEpZrULPD9lgoXXV4/EmIcoEvmHz9c=
|
||||
github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4=
|
||||
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
@@ -69,7 +79,6 @@ go.mongodb.org/mongo-driver v1.10.1 h1:NujsPveKwHaWuKUer/ceo9DzEe7HIj1SlJ6uvXZG0
|
||||
go.mongodb.org/mongo-driver v1.10.1/go.mod h1:z4XpeoU6w+9Vht+jAFyLgVrD+jGSQQe0+CBWFHNiHt8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||
golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29 h1:tkVvjkPTB7pnW3jnid7kNyAMPVWllTNOf/qKDze4p9o=
|
||||
golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d h1:sK3txAijHtOK88l68nt020reeT1ZdKLIYetKl95FzVY=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
@@ -85,16 +94,18 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20210309074719-68d13333faf2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
@@ -102,6 +113,6 @@ gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8
|
||||
gopkg.in/h2non/gock.v1 v1.0.15 h1:SzLqcIlb/fDfg7UvukMpNcWsu7sI5tWwL+KCATZqks0=
|
||||
gopkg.in/h2non/gock.v1 v1.0.15/go.mod h1:sX4zAkdYX1TRGJ2JY156cFspQn4yRWn6p9EMdODlynE=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
package hotime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.hoteas.com/golang/hotime/log"
|
||||
)
|
||||
|
||||
// getFreePort 获取一个可用的随机端口
|
||||
func getFreePort() (int, error) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
ln.Close()
|
||||
return port, nil
|
||||
}
|
||||
|
||||
// newTestApp 创建一个用于测试的最小 Application,不依赖数据库和配置文件
|
||||
func newTestApp(port int, handler http.HandlerFunc) *Application {
|
||||
app := &Application{}
|
||||
app.Log = log.NewLogger(1, "", 10)
|
||||
app.Server = &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", port),
|
||||
Handler: app,
|
||||
}
|
||||
// 用自定义 handler 替代完整的路由系统
|
||||
app.Handler = handler
|
||||
return app
|
||||
}
|
||||
|
||||
// ServeHTTP 已经在 application.go 中定义,
|
||||
// 但 handler 方法依赖完整初始化,这里我们直接用 http.Handler 接口。
|
||||
// 因此测试 app 的 ServeHTTP 会先检查 shuttingDown,然后调用 that.handler()。
|
||||
// 但 that.handler() 需要完整的路由初始化,不适合单元测试。
|
||||
// 所以我们直接测试核心逻辑:shuttingDown 标志 + http.Server.Shutdown。
|
||||
|
||||
// TestServeHTTP_ShuttingDown 测试停机标志置为 true 后新请求返回 503
|
||||
func TestServeHTTP_ShuttingDown(t *testing.T) {
|
||||
port, err := getFreePort()
|
||||
if err != nil {
|
||||
t.Fatalf("获取空闲端口失败: %v", err)
|
||||
}
|
||||
|
||||
var requestHandled atomic.Int32
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestHandled.Add(1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("OK"))
|
||||
})
|
||||
|
||||
app := &Application{}
|
||||
app.Log = log.NewLogger(1, "", 10)
|
||||
app.Server = &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", port),
|
||||
}
|
||||
app.Server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if app.shuttingDown.Load() {
|
||||
w.Header().Set("Connection", "close")
|
||||
http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
handler.ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
go app.Server.ListenAndServe()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
baseURL := fmt.Sprintf("http://127.0.0.1:%d", port)
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
|
||||
// --- 正常请求应该返回 200 ---
|
||||
resp, err := client.Get(baseURL + "/test")
|
||||
if err != nil {
|
||||
t.Fatalf("正常请求失败: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
t.Logf("[PASS] 正常请求返回 %d", resp.StatusCode)
|
||||
|
||||
if requestHandled.Load() != 1 {
|
||||
t.Fatalf("期望 handler 被调用 1 次,实际 %d 次", requestHandled.Load())
|
||||
}
|
||||
|
||||
// --- 设置停机标志 ---
|
||||
app.shuttingDown.Store(true)
|
||||
t.Log("[INFO] shuttingDown 已置为 true")
|
||||
|
||||
// --- 停机后请求应该返回 503 ---
|
||||
resp2, err := client.Get(baseURL + "/test2")
|
||||
if err != nil {
|
||||
t.Fatalf("停机请求失败: %v", err)
|
||||
}
|
||||
if resp2.StatusCode != http.StatusServiceUnavailable {
|
||||
t.Fatalf("期望 503,实际 %d", resp2.StatusCode)
|
||||
}
|
||||
resp2.Body.Close()
|
||||
t.Logf("[PASS] 停机后请求返回 %d", resp2.StatusCode)
|
||||
|
||||
// handler 不应被再次调用
|
||||
if requestHandled.Load() != 1 {
|
||||
t.Fatalf("停机后 handler 不应被调用,但被调用了 %d 次", requestHandled.Load())
|
||||
}
|
||||
t.Log("[PASS] 停机后 handler 未被调用")
|
||||
|
||||
app.Server.Close()
|
||||
}
|
||||
|
||||
// TestGracefulShutdown_InFlightRequestCompletes 测试优雅停机等待在途请求完成
|
||||
func TestGracefulShutdown_InFlightRequestCompletes(t *testing.T) {
|
||||
port, err := getFreePort()
|
||||
if err != nil {
|
||||
t.Fatalf("获取空闲端口失败: %v", err)
|
||||
}
|
||||
|
||||
slowRequestStarted := make(chan struct{})
|
||||
slowRequestDone := make(chan struct{})
|
||||
|
||||
app := &Application{}
|
||||
app.Log = log.NewLogger(1, "", 10)
|
||||
app.Server = &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", port),
|
||||
}
|
||||
app.Server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if app.shuttingDown.Load() {
|
||||
w.Header().Set("Connection", "close")
|
||||
http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/slow" {
|
||||
close(slowRequestStarted)
|
||||
time.Sleep(2 * time.Second) // 模拟慢接口
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("SLOW_DONE"))
|
||||
close(slowRequestDone)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("OK"))
|
||||
})
|
||||
|
||||
go app.Server.ListenAndServe()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
baseURL := fmt.Sprintf("http://127.0.0.1:%d", port)
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
// --- 发起慢请求(模拟在途请求)---
|
||||
var slowResp *http.Response
|
||||
var slowErr error
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
slowResp, slowErr = client.Get(baseURL + "/slow")
|
||||
}()
|
||||
|
||||
// 等慢请求开始处理
|
||||
<-slowRequestStarted
|
||||
t.Log("[INFO] 慢请求已开始处理")
|
||||
|
||||
// --- 在慢请求进行中触发 Shutdown ---
|
||||
app.shuttingDown.Store(true)
|
||||
t.Log("[INFO] shuttingDown 已置为 true,开始 Shutdown")
|
||||
|
||||
shutdownDone := make(chan struct{})
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
app.Server.Shutdown(ctx)
|
||||
close(shutdownDone)
|
||||
}()
|
||||
|
||||
// --- 新请求应该返回 503(但因为 Shutdown 不接受新连接了,可能连接被拒绝)---
|
||||
// Shutdown 调用后,新的 TCP 连接也会被拒绝,所以这里验证连接级别的拒绝也OK
|
||||
resp, err := client.Get(baseURL + "/new")
|
||||
if err != nil {
|
||||
t.Logf("[PASS] Shutdown 后新请求连接被拒绝(预期行为): %v", err)
|
||||
} else {
|
||||
if resp.StatusCode == http.StatusServiceUnavailable {
|
||||
t.Logf("[PASS] Shutdown 后新请求返回 503")
|
||||
} else {
|
||||
t.Logf("[INFO] Shutdown 后新请求返回 %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// --- 等待慢请求完成 ---
|
||||
wg.Wait()
|
||||
if slowErr != nil {
|
||||
t.Fatalf("慢请求返回错误(不应中断在途请求): %v", slowErr)
|
||||
}
|
||||
body, _ := ioutil.ReadAll(slowResp.Body)
|
||||
slowResp.Body.Close()
|
||||
|
||||
if slowResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("慢请求期望 200,实际 %d", slowResp.StatusCode)
|
||||
}
|
||||
if string(body) != "SLOW_DONE" {
|
||||
t.Fatalf("慢请求期望 body='SLOW_DONE',实际 '%s'", string(body))
|
||||
}
|
||||
t.Logf("[PASS] 慢请求正常完成,状态=%d,body=%s", slowResp.StatusCode, string(body))
|
||||
|
||||
// --- 等 Shutdown 完成 ---
|
||||
<-shutdownDone
|
||||
t.Log("[PASS] Server.Shutdown() 在慢请求完成后返回")
|
||||
|
||||
// 确认慢请求确实跑完了
|
||||
select {
|
||||
case <-slowRequestDone:
|
||||
t.Log("[PASS] 慢请求 handler 完整执行完毕")
|
||||
default:
|
||||
t.Fatal("慢请求 handler 未完整执行")
|
||||
}
|
||||
}
|
||||
|
||||
// TestShutdownOnce_MultipleSignals 测试多次触发只执行一次
|
||||
func TestShutdownOnce_MultipleSignals(t *testing.T) {
|
||||
var callCount atomic.Int32
|
||||
app := &Application{}
|
||||
app.Log = log.NewLogger(1, "", 10)
|
||||
app.Server = &http.Server{}
|
||||
|
||||
// 为了避免 os.Exit(0) 导致测试进程退出,
|
||||
// 我们直接测试 shutdownOnce + shuttingDown 逻辑
|
||||
var once sync.Once
|
||||
var shuttingDown atomic.Bool
|
||||
|
||||
mockShutdown := func() {
|
||||
once.Do(func() {
|
||||
shuttingDown.Store(true)
|
||||
callCount.Add(1)
|
||||
})
|
||||
}
|
||||
|
||||
// 并发触发 10 次
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
mockShutdown()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if callCount.Load() != 1 {
|
||||
t.Fatalf("期望 shutdownOnce.Do 只执行 1 次,实际 %d 次", callCount.Load())
|
||||
}
|
||||
if !shuttingDown.Load() {
|
||||
t.Fatal("shuttingDown 应为 true")
|
||||
}
|
||||
t.Logf("[PASS] 10 次并发触发,shutdownOnce.Do 只执行了 %d 次", callCount.Load())
|
||||
}
|
||||
|
||||
// TestRecoverSkipsDuringShutdown 测试停机中的 panic 不触发重启
|
||||
func TestRecoverSkipsDuringShutdown(t *testing.T) {
|
||||
app := &Application{}
|
||||
app.Log = log.NewLogger(1, "", 10)
|
||||
|
||||
var restartCalled atomic.Bool
|
||||
|
||||
// 模拟 Run() 中的 defer recover 逻辑
|
||||
testRecover := func() {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
if app.shuttingDown.Load() {
|
||||
return // 停机中不重启
|
||||
}
|
||||
restartCalled.Store(true)
|
||||
}
|
||||
}()
|
||||
panic("test panic")
|
||||
}
|
||||
|
||||
// 未停机时,recover 应触发"重启"
|
||||
restartCalled.Store(false)
|
||||
testRecover()
|
||||
if !restartCalled.Load() {
|
||||
t.Fatal("未停机时 panic 应触发重启")
|
||||
}
|
||||
t.Log("[PASS] 未停机时 panic 正确触发重启逻辑")
|
||||
|
||||
// 停机时,recover 不应触发"重启"
|
||||
app.shuttingDown.Store(true)
|
||||
restartCalled.Store(false)
|
||||
testRecover()
|
||||
if restartCalled.Load() {
|
||||
t.Fatal("停机时 panic 不应触发重启")
|
||||
}
|
||||
t.Log("[PASS] 停机时 panic 正确跳过重启逻辑")
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
logrus "github.com/sirupsen/logrus"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// captureBuf 挂到 multiWriter,收集 EmitCaptured 写入的 JSON 行。
|
||||
type captureBuf struct {
|
||||
mu sync.Mutex
|
||||
lines []string
|
||||
}
|
||||
|
||||
func (c *captureBuf) Write(p []byte) (int, error) {
|
||||
c.mu.Lock()
|
||||
c.lines = append(c.lines, string(bytes.TrimSpace(p)))
|
||||
c.mu.Unlock()
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (c *captureBuf) lastJSON() map[string]interface{} {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if len(c.lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
var m map[string]interface{}
|
||||
_ = json.Unmarshal([]byte(c.lines[len(c.lines)-1]), &m)
|
||||
return m
|
||||
}
|
||||
|
||||
func newLoggerWithCapture(logLevel int) (*Logger, *captureBuf) {
|
||||
buf := &captureBuf{}
|
||||
l := NewLogger(logLevel, "", 0)
|
||||
l.mw.add(buf)
|
||||
return l, buf
|
||||
}
|
||||
|
||||
func startFakeSeqServer(t *testing.T, onBody func(string)) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/events/raw" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
onBody(string(body))
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}))
|
||||
}
|
||||
|
||||
func TestEmitCaptured_LogLevelZero(t *testing.T) {
|
||||
l, buf := newLoggerWithCapture(0)
|
||||
l.EmitCaptured(zerolog.InfoLevel, "stdout", "请求参数GetReqMap /api/test")
|
||||
m := buf.lastJSON()
|
||||
if m == nil {
|
||||
t.Fatal("expected captured line")
|
||||
}
|
||||
if m["source"] != "stdout" {
|
||||
t.Fatalf("source=%v want stdout", m["source"])
|
||||
}
|
||||
if m["level"] != "info" {
|
||||
t.Fatalf("level=%v want info", m["level"])
|
||||
}
|
||||
if !strings.Contains(m["message"].(string), "GetReqMap") {
|
||||
t.Fatalf("message=%v", m["message"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitCaptured_StderrSource(t *testing.T) {
|
||||
l, buf := newLoggerWithCapture(0)
|
||||
l.EmitCaptured(zerolog.ErrorLevel, "stderr", "driver error")
|
||||
m := buf.lastJSON()
|
||||
if m["source"] != "stderr" {
|
||||
t.Fatalf("source=%v want stderr", m["source"])
|
||||
}
|
||||
if m["level"] != "error" {
|
||||
t.Fatalf("level=%v want error", m["level"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestToClef_PlainText(t *testing.T) {
|
||||
sw := &SeqWriter{instance: "127.0.0.1:8081"}
|
||||
clef := sw.toClef([]byte("plain log line\n"))
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(clef, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m["@mt"] != "plain log line" {
|
||||
t.Fatalf("@mt=%v", m["@mt"])
|
||||
}
|
||||
if m["source"] != "stdout" {
|
||||
t.Fatalf("source=%v", m["source"])
|
||||
}
|
||||
if m["@l"] != "Information" {
|
||||
t.Fatalf("@l=%v", m["@l"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStdoutCapture_LongLine(t *testing.T) {
|
||||
l, buf := newLoggerWithCapture(1)
|
||||
pr, pw := io.Pipe()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
CaptureStream(l, zerolog.InfoLevel, "stdout", pr)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
longLine := strings.Repeat("x", 70*1024) + "\n"
|
||||
shortLine := "after-long-line\n"
|
||||
if _, err := pw.Write([]byte(longLine)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pw.Write([]byte(shortLine)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
buf.mu.Lock()
|
||||
n := len(buf.lines)
|
||||
buf.mu.Unlock()
|
||||
if n < 2 {
|
||||
t.Fatalf("expected >=2 captured lines, got %d", n)
|
||||
}
|
||||
last := buf.lastJSON()
|
||||
if last == nil || !strings.Contains(last["message"].(string), "after-long-line") {
|
||||
t.Fatalf("short line after long line not captured: %v", last)
|
||||
}
|
||||
_ = pw.Close()
|
||||
<-done
|
||||
}
|
||||
|
||||
func TestLogrusBridge_AfterRedirect(t *testing.T) {
|
||||
l, buf := newLoggerWithCapture(1)
|
||||
pr, pw := io.Pipe()
|
||||
go CaptureStream(l, zerolog.InfoLevel, "stdout", pr)
|
||||
|
||||
logrus.SetOutput(pw)
|
||||
logrus.SetLevel(logrus.InfoLevel)
|
||||
logrus.Info("wechat-sdk-log")
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
m := buf.lastJSON()
|
||||
if m == nil || !strings.Contains(m["message"].(string), "wechat-sdk-log") {
|
||||
t.Fatalf("logrus not captured: %v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeqWriter_FakeServer(t *testing.T) {
|
||||
var gotBody string
|
||||
var mu sync.Mutex
|
||||
srv := startFakeSeqServer(t, func(body string) {
|
||||
mu.Lock()
|
||||
gotBody = body
|
||||
mu.Unlock()
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
l := NewLogger(1, "", 0)
|
||||
l.SetSeqWriter(srv.URL, "", "test:8081")
|
||||
l.Info().Msg("seq-test-message")
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
mu.Lock()
|
||||
b := gotBody
|
||||
mu.Unlock()
|
||||
if strings.Contains(b, "seq-test-message") {
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("Seq did not receive message, body=%q", gotBody)
|
||||
}
|
||||
|
||||
func TestEmitRecoveredPanic_LogFields(t *testing.T) {
|
||||
l, buf := newLoggerWithCapture(0)
|
||||
l.EmitRecoveredPanic("test panic reason")
|
||||
m := buf.lastJSON()
|
||||
if m == nil {
|
||||
t.Fatal("no output")
|
||||
}
|
||||
if m["source"] != "panic" {
|
||||
t.Fatalf("source=%v", m["source"])
|
||||
}
|
||||
if m["level"] != "error" {
|
||||
t.Fatalf("level=%v", m["level"])
|
||||
}
|
||||
if !strings.Contains(m["message"].(string), "test panic reason") {
|
||||
t.Fatalf("message=%v", m["message"])
|
||||
}
|
||||
if stack, ok := m["stack"].(string); !ok || stack == "" {
|
||||
t.Fatalf("stack missing: %v", m["stack"])
|
||||
}
|
||||
}
|
||||
+755
@@ -0,0 +1,755 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
const maxCaptureLine = 10 << 20 // 10MB,支持 GetReqMap 等大 body 单行日志
|
||||
|
||||
var (
|
||||
origStderr io.Writer
|
||||
origStderrOnce sync.Once
|
||||
)
|
||||
|
||||
func saveOrigStderr() {
|
||||
origStderrOnce.Do(func() {
|
||||
origStderr = os.Stderr
|
||||
})
|
||||
}
|
||||
|
||||
func seqWriteStderr(format string, args ...interface{}) {
|
||||
if origStderr != nil {
|
||||
fmt.Fprintf(origStderr, format, args...)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// ErrorRecord 错误历史记录条目
|
||||
type ErrorRecord struct {
|
||||
Err error
|
||||
Msg string
|
||||
Time time.Time
|
||||
Caller string
|
||||
}
|
||||
|
||||
// Logger 日志核心结构体,封装 zerolog
|
||||
type Logger struct {
|
||||
zl zerolog.Logger
|
||||
mw *multiWriter // 动态多输出,支持运行时追加 SeqWriter 等
|
||||
logLevel int
|
||||
errors []ErrorRecord
|
||||
errIdx int // 环形缓冲写入位置
|
||||
errCount int // 实际写入总数(用于判断缓冲是否满)
|
||||
errorsMu sync.RWMutex
|
||||
maxErrors int
|
||||
}
|
||||
|
||||
// NewLogger 创建日志实例
|
||||
// logLevel: 0=仅 error,>=1=全部
|
||||
// logFile: 文件路径模板(如 "logs/20060102.txt"),空则不写文件
|
||||
// maxErrors: 错误历史最大条数,0 则不记录
|
||||
func NewLogger(logLevel int, logFile string, maxErrors int) *Logger {
|
||||
saveOrigStderr()
|
||||
zerolog.CallerMarshalFunc = callerMarshalFunc
|
||||
zerolog.TimeFieldFormat = "2006-01-02 15:04:05"
|
||||
|
||||
var level zerolog.Level
|
||||
if logLevel == 0 {
|
||||
level = zerolog.ErrorLevel
|
||||
} else {
|
||||
level = zerolog.DebugLevel
|
||||
}
|
||||
|
||||
consoleWriter := zerolog.ConsoleWriter{
|
||||
Out: os.Stderr,
|
||||
TimeFormat: "2006-01-02 15:04:05",
|
||||
NoColor: false,
|
||||
FormatLevel: formatLevelColor,
|
||||
FormatCaller: func(i interface{}) string {
|
||||
if i == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("[%s]", i)
|
||||
},
|
||||
}
|
||||
|
||||
var writers []io.Writer
|
||||
writers = append(writers, consoleWriter)
|
||||
|
||||
if logFile != "" {
|
||||
fw := &TemplateFileWriter{pathTemplate: logFile}
|
||||
writers = append(writers, fw)
|
||||
}
|
||||
|
||||
mw := &multiWriter{writers: writers}
|
||||
zl := zerolog.New(mw).
|
||||
Level(level).
|
||||
With().
|
||||
Timestamp().
|
||||
Caller().
|
||||
Logger()
|
||||
|
||||
l := &Logger{
|
||||
zl: zl,
|
||||
mw: mw,
|
||||
logLevel: logLevel,
|
||||
maxErrors: maxErrors,
|
||||
}
|
||||
if maxErrors > 0 {
|
||||
l.errors = make([]ErrorRecord, maxErrors)
|
||||
}
|
||||
|
||||
return l
|
||||
}
|
||||
|
||||
// NewLoggerNoCaller 创建不带自动 Caller 字段的日志实例(用于访问日志等 caller 无意义的场景)
|
||||
func NewLoggerNoCaller(logLevel int, logFile string, maxErrors int) *Logger {
|
||||
saveOrigStderr()
|
||||
zerolog.CallerMarshalFunc = callerMarshalFunc
|
||||
zerolog.TimeFieldFormat = "2006-01-02 15:04:05"
|
||||
|
||||
var level zerolog.Level
|
||||
if logLevel == 0 {
|
||||
level = zerolog.ErrorLevel
|
||||
} else {
|
||||
level = zerolog.DebugLevel
|
||||
}
|
||||
|
||||
consoleWriter := zerolog.ConsoleWriter{
|
||||
Out: os.Stderr,
|
||||
TimeFormat: "2006-01-02 15:04:05",
|
||||
NoColor: false,
|
||||
FormatLevel: formatLevelColor,
|
||||
}
|
||||
|
||||
var writers []io.Writer
|
||||
writers = append(writers, consoleWriter)
|
||||
|
||||
if logFile != "" {
|
||||
fw := &TemplateFileWriter{pathTemplate: logFile}
|
||||
writers = append(writers, fw)
|
||||
}
|
||||
|
||||
mw := &multiWriter{writers: writers}
|
||||
zl := zerolog.New(mw).
|
||||
Level(level).
|
||||
With().
|
||||
Timestamp().
|
||||
Logger()
|
||||
|
||||
l := &Logger{
|
||||
zl: zl,
|
||||
mw: mw,
|
||||
logLevel: logLevel,
|
||||
maxErrors: maxErrors,
|
||||
}
|
||||
if maxErrors > 0 {
|
||||
l.errors = make([]ErrorRecord, maxErrors)
|
||||
}
|
||||
|
||||
return l
|
||||
}
|
||||
|
||||
// NewTestLogger 创建用于测试的静默 Logger(输出到 io.Discard)
|
||||
func NewTestLogger() *Logger {
|
||||
zl := zerolog.New(io.Discard).Level(zerolog.Disabled)
|
||||
return &Logger{zl: zl, logLevel: 0}
|
||||
}
|
||||
|
||||
// SetOutput 设置日志输出(用于测试等场景)
|
||||
func (l *Logger) SetOutput(w io.Writer) {
|
||||
if w == io.Discard {
|
||||
l.zl = zerolog.New(w).Level(zerolog.Disabled)
|
||||
l.mw = nil
|
||||
return
|
||||
}
|
||||
consoleWriter := zerolog.ConsoleWriter{
|
||||
Out: w,
|
||||
TimeFormat: "2006-01-02 15:04:05",
|
||||
NoColor: true,
|
||||
FormatLevel: formatLevelPlain,
|
||||
FormatCaller: func(i interface{}) string {
|
||||
if i == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("[%s]", i)
|
||||
},
|
||||
}
|
||||
mw := &multiWriter{writers: []io.Writer{consoleWriter}}
|
||||
l.mw = mw
|
||||
l.zl = zerolog.New(mw).
|
||||
With().
|
||||
Timestamp().
|
||||
Caller().
|
||||
Logger()
|
||||
}
|
||||
|
||||
// GetLevel 获取当前日志等级
|
||||
func (l *Logger) GetLevel() int {
|
||||
return l.logLevel
|
||||
}
|
||||
|
||||
// EmitCaptured 将捕获的 stdout/stderr 等输出直写 multiWriter,绕过主 Logger 的 level 过滤。
|
||||
// 保证 logLevel=0 时 fmt.Println / log.Println 仍能进 Seq。
|
||||
func (l *Logger) EmitCaptured(level zerolog.Level, source, msg string) {
|
||||
if l == nil || l.mw == nil || msg == "" {
|
||||
return
|
||||
}
|
||||
zl := zerolog.New(l.mw)
|
||||
zl.WithLevel(level).Timestamp().Str("source", source).Msg(msg)
|
||||
}
|
||||
|
||||
// EmitRecoveredPanic 记录 recover 到的 panic:错误原因 + 调用栈,source=panic。
|
||||
func (l *Logger) EmitRecoveredPanic(err interface{}) {
|
||||
if l == nil || l.mw == nil || err == nil {
|
||||
return
|
||||
}
|
||||
zl := zerolog.New(l.mw)
|
||||
zl.WithLevel(zerolog.ErrorLevel).Timestamp().
|
||||
Str("source", "panic").
|
||||
Str("stack", string(debugStack())).
|
||||
Msg(fmt.Sprint(err))
|
||||
}
|
||||
|
||||
func debugStack() []byte {
|
||||
buf := make([]byte, 64*1024)
|
||||
n := runtime.Stack(buf, false)
|
||||
return buf[:n]
|
||||
}
|
||||
|
||||
// CaptureStream 从 reader 持续读取行并写入 Logger(用于 stdout/stderr 管道)。
|
||||
// 单行超过 maxCaptureLine 时截断并标注,读取出错后重启读取,避免永久失效。
|
||||
func CaptureStream(l *Logger, level zerolog.Level, source string, r io.Reader) {
|
||||
rd := bufio.NewReader(r)
|
||||
for {
|
||||
line, err := rd.ReadString('\n')
|
||||
if len(line) > 0 {
|
||||
msg := strings.TrimRight(line, "\r\n")
|
||||
if msg != "" {
|
||||
if len(msg) > maxCaptureLine {
|
||||
msg = msg[:maxCaptureLine] + "...(truncated)"
|
||||
}
|
||||
l.EmitCaptured(level, source, msg)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
if len(line) == 0 {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
l.EmitCaptured(zerolog.ErrorLevel, source+"-capture", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 链式调用 API ---
|
||||
// 以下方法兼容两种调用风格:
|
||||
// 无参数:返回 *zerolog.Event 用于链式调用,如 l.Error().Str("k","v").Msg("...")
|
||||
// 有参数:直接拼接并打印日志(logrus 兼容),返回 no-op Event
|
||||
|
||||
func (l *Logger) Debug(args ...interface{}) *zerolog.Event {
|
||||
if len(args) > 0 {
|
||||
l.zl.Debug().Msg(fmt.Sprint(args...))
|
||||
nop := zerolog.Nop()
|
||||
return nop.Debug()
|
||||
}
|
||||
return l.zl.Debug()
|
||||
}
|
||||
|
||||
func (l *Logger) Info(args ...interface{}) *zerolog.Event {
|
||||
if len(args) > 0 {
|
||||
l.zl.Info().Msg(fmt.Sprint(args...))
|
||||
nop := zerolog.Nop()
|
||||
return nop.Info()
|
||||
}
|
||||
return l.zl.Info()
|
||||
}
|
||||
|
||||
func (l *Logger) Warn(args ...interface{}) *zerolog.Event {
|
||||
if len(args) > 0 {
|
||||
l.zl.Warn().Msg(fmt.Sprint(args...))
|
||||
nop := zerolog.Nop()
|
||||
return nop.Warn()
|
||||
}
|
||||
return l.zl.Warn()
|
||||
}
|
||||
|
||||
func (l *Logger) Error(args ...interface{}) *zerolog.Event {
|
||||
if len(args) > 0 {
|
||||
msg := fmt.Sprint(args...)
|
||||
if l.maxErrors > 0 {
|
||||
l.recordError(msg, nil)
|
||||
}
|
||||
l.zl.Error().Msg(msg)
|
||||
nop := zerolog.Nop()
|
||||
return nop.Error()
|
||||
}
|
||||
if l.maxErrors > 0 {
|
||||
l.recordError("", nil)
|
||||
}
|
||||
return l.zl.Error()
|
||||
}
|
||||
|
||||
// RecordError 手动记录一条错误到历史(用于需要指定详情的场景)
|
||||
func (l *Logger) RecordError(err error, msg string, caller string) {
|
||||
if l.maxErrors <= 0 {
|
||||
return
|
||||
}
|
||||
l.errorsMu.Lock()
|
||||
defer l.errorsMu.Unlock()
|
||||
l.errors[l.errIdx] = ErrorRecord{
|
||||
Err: err,
|
||||
Msg: msg,
|
||||
Time: time.Now(),
|
||||
Caller: caller,
|
||||
}
|
||||
l.errIdx = (l.errIdx + 1) % l.maxErrors
|
||||
l.errCount++
|
||||
}
|
||||
|
||||
func (l *Logger) recordError(msg string, err error) {
|
||||
_, file, line, ok := runtime.Caller(2)
|
||||
caller := ""
|
||||
if ok {
|
||||
caller = formatCaller(file, line)
|
||||
}
|
||||
l.RecordError(err, msg, caller)
|
||||
}
|
||||
|
||||
// GetRecentErrors 获取最近 N 条错误(不传则返回全部已存储的)
|
||||
func (l *Logger) GetRecentErrors(n ...int) []ErrorRecord {
|
||||
if l.maxErrors <= 0 {
|
||||
return nil
|
||||
}
|
||||
l.errorsMu.RLock()
|
||||
defer l.errorsMu.RUnlock()
|
||||
|
||||
total := l.errCount
|
||||
if total > l.maxErrors {
|
||||
total = l.maxErrors
|
||||
}
|
||||
if total == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
want := total
|
||||
if len(n) > 0 && n[0] > 0 && n[0] < want {
|
||||
want = n[0]
|
||||
}
|
||||
|
||||
result := make([]ErrorRecord, 0, want)
|
||||
// 从最新的往前读
|
||||
for i := 0; i < want; i++ {
|
||||
idx := (l.errIdx - 1 - i + l.maxErrors) % l.maxErrors
|
||||
if l.errors[idx].Time.IsZero() {
|
||||
break
|
||||
}
|
||||
result = append(result, l.errors[idx])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// --- 格式化快捷方法 ---
|
||||
|
||||
func (l *Logger) Debugf(format string, v ...interface{}) {
|
||||
l.zl.Debug().Msgf(format, v...)
|
||||
}
|
||||
func (l *Logger) Infof(format string, v ...interface{}) {
|
||||
l.zl.Info().Msgf(format, v...)
|
||||
}
|
||||
func (l *Logger) Warnf(format string, v ...interface{}) {
|
||||
l.zl.Warn().Msgf(format, v...)
|
||||
}
|
||||
func (l *Logger) Errorf(format string, v ...interface{}) {
|
||||
if l.maxErrors > 0 {
|
||||
l.recordError(fmt.Sprintf(format, v...), nil)
|
||||
}
|
||||
l.zl.Error().Msgf(format, v...)
|
||||
}
|
||||
|
||||
// --- 日志级别颜色 ---
|
||||
|
||||
func formatLevelColor(i interface{}) string {
|
||||
level := strings.ToUpper(fmt.Sprintf("%s", i))
|
||||
switch level {
|
||||
case "DEBUG":
|
||||
return fmt.Sprintf("\x1b[36m|%s|\x1b[0m", level) // cyan
|
||||
case "INFO":
|
||||
return fmt.Sprintf("\x1b[32m|%s|\x1b[0m", level) // green
|
||||
case "WARN":
|
||||
return fmt.Sprintf("\x1b[33m|%s|\x1b[0m", level) // yellow
|
||||
case "ERROR":
|
||||
return fmt.Sprintf("\x1b[31m|%s|\x1b[0m", level) // red
|
||||
case "FATAL":
|
||||
return fmt.Sprintf("\x1b[35m|%s|\x1b[0m", level) // magenta
|
||||
default:
|
||||
return fmt.Sprintf("|%s|", level)
|
||||
}
|
||||
}
|
||||
|
||||
func formatLevelPlain(i interface{}) string {
|
||||
return fmt.Sprintf("|%s|", strings.ToUpper(fmt.Sprintf("%s", i)))
|
||||
}
|
||||
|
||||
// --- TemplateFileWriter ---
|
||||
|
||||
// TemplateFileWriter 按时间模板切换文件路径的 Writer
|
||||
type TemplateFileWriter struct {
|
||||
pathTemplate string
|
||||
mu sync.Mutex
|
||||
currentPath string
|
||||
file *os.File
|
||||
writer *bufio.Writer
|
||||
}
|
||||
|
||||
func (w *TemplateFileWriter) Write(p []byte) (n int, err error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
path := time.Now().Format(w.pathTemplate)
|
||||
if path != w.currentPath {
|
||||
if w.writer != nil {
|
||||
_ = w.writer.Flush()
|
||||
}
|
||||
if w.file != nil {
|
||||
_ = w.file.Close()
|
||||
}
|
||||
_ = os.MkdirAll(filepath.Dir(path), 0755)
|
||||
w.file, err = os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
w.writer = bufio.NewWriterSize(w.file, 4096)
|
||||
w.currentPath = path
|
||||
}
|
||||
n, err = w.writer.Write(p)
|
||||
_ = w.writer.Flush()
|
||||
return
|
||||
}
|
||||
|
||||
// --- 调用者智能过滤 ---
|
||||
|
||||
func callerMarshalFunc(_ uintptr, file string, line int) string {
|
||||
return findCaller(file, line)
|
||||
}
|
||||
|
||||
func findCaller(origFile string, origLine int) string {
|
||||
var lastInfraFile string
|
||||
var lastInfraLine int
|
||||
var primaryCaller string
|
||||
var appLayerCaller string
|
||||
|
||||
for i := 1; i < 20; i++ {
|
||||
_, file, line, ok := runtime.Caller(i)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
shortFile := shortenPath(file)
|
||||
|
||||
if isInfrastructureFile(shortFile) {
|
||||
lastInfraFile = shortFile
|
||||
lastInfraLine = line
|
||||
continue
|
||||
}
|
||||
|
||||
if primaryCaller == "" {
|
||||
primaryCaller = fmt.Sprintf("%s:%d", shortFile, line)
|
||||
}
|
||||
if appLayerCaller == "" && !strings.Contains(strings.ToLower(file), "hotime") {
|
||||
appLayerCaller = fmt.Sprintf("%s:%d", shortFile, line)
|
||||
}
|
||||
}
|
||||
|
||||
result := ""
|
||||
if primaryCaller != "" {
|
||||
result = primaryCaller
|
||||
} else if lastInfraFile != "" {
|
||||
result = fmt.Sprintf("%s:%d", lastInfraFile, lastInfraLine)
|
||||
} else {
|
||||
result = fmt.Sprintf("%s:%d", shortenPath(origFile), origLine)
|
||||
}
|
||||
|
||||
if appLayerCaller != "" && result != appLayerCaller {
|
||||
result += " <- " + appLayerCaller
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func formatCaller(file string, line int) string {
|
||||
return fmt.Sprintf("%s:%d", shortenPath(file), line)
|
||||
}
|
||||
|
||||
func shortenPath(file string) string {
|
||||
n := 0
|
||||
for i := len(file) - 1; i > 0; i-- {
|
||||
if file[i] == '/' || file[i] == '\\' {
|
||||
n++
|
||||
if n >= 2 {
|
||||
return file[i+1:]
|
||||
}
|
||||
}
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
// --- multiWriter: 支持运行时追加输出目标 ---
|
||||
|
||||
// multiWriter 实现 zerolog.LevelWriter,允许在 Logger 创建后动态追加新的输出(如 SeqWriter)
|
||||
type multiWriter struct {
|
||||
mu sync.RWMutex
|
||||
writers []io.Writer
|
||||
}
|
||||
|
||||
func (mw *multiWriter) Write(p []byte) (int, error) {
|
||||
mw.mu.RLock()
|
||||
defer mw.mu.RUnlock()
|
||||
for _, w := range mw.writers {
|
||||
_, _ = w.Write(p)
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// WriteLevel 实现 zerolog.LevelWriter,使 zerolog 的级别过滤正确传递给各子 Writer
|
||||
func (mw *multiWriter) WriteLevel(l zerolog.Level, p []byte) (int, error) {
|
||||
mw.mu.RLock()
|
||||
defer mw.mu.RUnlock()
|
||||
for _, w := range mw.writers {
|
||||
if lw, ok := w.(zerolog.LevelWriter); ok {
|
||||
_, _ = lw.WriteLevel(l, p)
|
||||
} else {
|
||||
_, _ = w.Write(p)
|
||||
}
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (mw *multiWriter) add(w io.Writer) {
|
||||
mw.mu.Lock()
|
||||
mw.writers = append(mw.writers, w)
|
||||
mw.mu.Unlock()
|
||||
}
|
||||
|
||||
// --- SeqWriter: 异步推送到 Seq(CLEF 格式)---
|
||||
|
||||
// SeqWriter 将 zerolog JSON 日志通过 channel 队列异步发送到 Seq。
|
||||
// Write() 仅做 channel <- bytes,O(1) 非阻塞,绝不阻塞 web 请求处理 goroutine。
|
||||
// channel 容量 10000,满时丢弃新条目(dropped 计数),不会影响主服务。
|
||||
type SeqWriter struct {
|
||||
seqUrl string
|
||||
apiKey string
|
||||
instance string
|
||||
queue chan []byte
|
||||
dropped int64
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func newSeqWriter(seqUrl, apiKey, instance string) *SeqWriter {
|
||||
w := &SeqWriter{
|
||||
seqUrl: strings.TrimRight(seqUrl, "/"),
|
||||
apiKey: apiKey,
|
||||
instance: instance,
|
||||
queue: make(chan []byte, 10000),
|
||||
client: &http.Client{Timeout: 5 * time.Second},
|
||||
}
|
||||
go w.run()
|
||||
return w
|
||||
}
|
||||
|
||||
// Write 将字节塞入 channel 即返回,由后台 goroutine 消费发送
|
||||
func (w *SeqWriter) Write(p []byte) (int, error) {
|
||||
clef := w.toClef(p)
|
||||
select {
|
||||
case w.queue <- clef:
|
||||
default:
|
||||
n := atomic.AddInt64(&w.dropped, 1)
|
||||
if n == 1 || n%100 == 0 {
|
||||
seqWriteStderr("[seq] queue full, dropped=%d\n", n)
|
||||
}
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// toClef 将 zerolog JSON 字段映射到 Seq CLEF 格式
|
||||
// zerolog: time/level/message(msg) → CLEF: @t/@l/@mt
|
||||
func (w *SeqWriter) toClef(p []byte) []byte {
|
||||
trimmed := bytes.TrimSpace(p)
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(trimmed, &m); err != nil {
|
||||
// 非 JSON(如 fmt.Println 直接输出),包装为纯文本日志
|
||||
return []byte(fmt.Sprintf(`{"@t":%q,"@l":"Information","@mt":%q,"source":"stdout","instance":%q}`,
|
||||
time.Now().Format(time.RFC3339), strings.TrimSpace(string(p)), w.instance))
|
||||
}
|
||||
|
||||
clef := make(map[string]interface{}, len(m)+3)
|
||||
for k, v := range m {
|
||||
clef[k] = v
|
||||
}
|
||||
|
||||
// time → @t(zerolog 格式 "2006-01-02 15:04:05" → ISO 8601)
|
||||
if t, ok := m["time"].(string); ok {
|
||||
delete(clef, "time")
|
||||
if pt, err := time.ParseInLocation("2006-01-02 15:04:05", t, time.Local); err == nil {
|
||||
clef["@t"] = pt.Format(time.RFC3339)
|
||||
} else {
|
||||
clef["@t"] = t
|
||||
}
|
||||
} else {
|
||||
clef["@t"] = time.Now().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// level → @l
|
||||
if lv, ok := m["level"].(string); ok {
|
||||
delete(clef, "level")
|
||||
switch lv {
|
||||
case "debug":
|
||||
clef["@l"] = "Debug"
|
||||
case "info":
|
||||
clef["@l"] = "Information"
|
||||
case "warn":
|
||||
clef["@l"] = "Warning"
|
||||
case "error":
|
||||
clef["@l"] = "Error"
|
||||
case "fatal":
|
||||
clef["@l"] = "Fatal"
|
||||
default:
|
||||
clef["@l"] = lv
|
||||
}
|
||||
}
|
||||
|
||||
// message/msg → @mt
|
||||
if msg, ok := m["message"].(string); ok {
|
||||
delete(clef, "message")
|
||||
clef["@mt"] = msg
|
||||
} else if msg, ok := m["msg"].(string); ok {
|
||||
delete(clef, "msg")
|
||||
clef["@mt"] = msg
|
||||
}
|
||||
|
||||
if w.instance != "" {
|
||||
clef["instance"] = w.instance
|
||||
}
|
||||
|
||||
b, _ := json.Marshal(clef)
|
||||
return b
|
||||
}
|
||||
|
||||
// run 后台 goroutine:每 100 条或 500ms 批量 POST 到 Seq
|
||||
func (w *SeqWriter) run() {
|
||||
batch := make([][]byte, 0, 100)
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case entry := <-w.queue:
|
||||
batch = append(batch, entry)
|
||||
if len(batch) >= 100 {
|
||||
w.flush(batch)
|
||||
batch = batch[:0]
|
||||
}
|
||||
case <-ticker.C:
|
||||
if len(batch) > 0 {
|
||||
w.flush(batch)
|
||||
batch = batch[:0]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *SeqWriter) flush(batch [][]byte) {
|
||||
var buf bytes.Buffer
|
||||
for _, entry := range batch {
|
||||
buf.Write(entry)
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
req, err := http.NewRequest("POST", w.seqUrl+"/api/events/raw?clef", &buf)
|
||||
if err != nil {
|
||||
seqWriteStderr("[seq] build request error: %v\n", err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/vnd.serilog.clef")
|
||||
if w.apiKey != "" {
|
||||
req.Header.Set("X-Seq-ApiKey", w.apiKey)
|
||||
}
|
||||
resp, err := w.client.Do(req)
|
||||
if err != nil {
|
||||
seqWriteStderr("[seq] send error: %v (dropped=%d)\n", err, atomic.LoadInt64(&w.dropped))
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
seqWriteStderr("[seq] server returned %d: %s\n", resp.StatusCode, string(body))
|
||||
}
|
||||
}
|
||||
|
||||
// SetSeqWriter 将 Seq HTTP 推送器附加到当前 Logger(不影响已有的控制台/文件输出)
|
||||
// seqUrl: Seq 服务地址,如 "http://127.0.0.1:5341"
|
||||
// apiKey: Seq API Key,免费单用户版留空
|
||||
// instance: 实例标识,建议用 config 的 port 字段区分同机多进程,如 "8085"
|
||||
func (l *Logger) SetSeqWriter(seqUrl, apiKey, instance string) {
|
||||
if seqUrl == "" || l.mw == nil {
|
||||
return
|
||||
}
|
||||
sw := newSeqWriter(seqUrl, apiKey, instance)
|
||||
l.mw.add(sw)
|
||||
}
|
||||
|
||||
// isInfrastructureFile 判断文件是否为基础设施(日志/DB/缓存/通用工具)管线,
|
||||
// 这些文件在调用栈中会被跳过,以显示真正的业务调用者。
|
||||
// context.go 是 Display/View 的纯中间层,也作为基础设施跳过。
|
||||
// application.go、code/makecode.go 等保留,会作为有意义的调用者返回。
|
||||
func isInfrastructureFile(file string) bool {
|
||||
if strings.HasPrefix(file, "zerolog/") || strings.HasPrefix(file, "zerolog@") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(file, "logrus/") || strings.HasPrefix(file, "logrus@") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(file, "runtime/") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(file, "log/") {
|
||||
return true
|
||||
}
|
||||
|
||||
infraPrefixes := []string{"db/", "cache/", "common/", "dri/"}
|
||||
for _, prefix := range infraPrefixes {
|
||||
if strings.HasPrefix(file, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
lowerFile := strings.ToLower(file)
|
||||
if strings.Contains(lowerFile, "hotime") {
|
||||
infraDirs := []string{"db/", "cache/", "common/", "log/", "dri/"}
|
||||
for _, dir := range infraDirs {
|
||||
if strings.Contains(file, dir) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if strings.HasSuffix(file, "/context.go") || strings.HasSuffix(file, "\\context.go") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
-194
@@ -1,194 +0,0 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func GetLog(path string, showCodeLine bool) *log.Logger {
|
||||
|
||||
hook := MyHook{
|
||||
Field: "line",
|
||||
Skip: 5,
|
||||
Path: path, ShowCodeLine: showCodeLine,
|
||||
}
|
||||
|
||||
loger := log.New()
|
||||
loger.SetFormatter(&log.TextFormatter{})
|
||||
loger.AddHook(&hook)
|
||||
return loger
|
||||
}
|
||||
|
||||
// MyHook ...
|
||||
type MyHook struct {
|
||||
Path string //存储日志的位置
|
||||
ShowCodeLine bool //输出代码文件名称和日志行
|
||||
Field string
|
||||
Skip int
|
||||
levels []log.Level
|
||||
}
|
||||
|
||||
// Levels 只定义 error 和 panic 等级的日志,其他日志等级不会触发 hook
|
||||
func (that *MyHook) Levels() []log.Level {
|
||||
return log.AllLevels
|
||||
}
|
||||
|
||||
// Fire 将异常日志写入到指定日志文件中
|
||||
func (that *MyHook) Fire(entry *log.Entry) error {
|
||||
if that.ShowCodeLine {
|
||||
entry.Data[that.Field] = findCaller(that.Skip)
|
||||
}
|
||||
//不需要存储到文件
|
||||
if that.Path == "" {
|
||||
return nil
|
||||
}
|
||||
//存储到文件
|
||||
logFilePath := time.Now().Format(that.Path)
|
||||
|
||||
err := os.MkdirAll(filepath.Dir(logFilePath), os.ModeAppend)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
//os.Create(logFilePath)
|
||||
f, err := os.OpenFile(logFilePath, os.O_APPEND|os.O_CREATE, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bte, _ := entry.Bytes()
|
||||
_, err = f.Write(bte)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = f.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 最大框架层数限制 - 超过这个层数后不再跳过,防止误过滤应用层
|
||||
const maxFrameworkDepth = 10
|
||||
|
||||
// isHoTimeFrameworkFile 判断是否是 HoTime 框架文件
|
||||
// 更精确的匹配:只有明确属于框架的文件才会被跳过
|
||||
func isHoTimeFrameworkFile(file string) bool {
|
||||
// 1. logrus 日志库内部文件
|
||||
if strings.HasPrefix(file, "logrus/") {
|
||||
return true
|
||||
}
|
||||
|
||||
// 2. Go 运行时文件
|
||||
if strings.HasPrefix(file, "runtime/") {
|
||||
return true
|
||||
}
|
||||
|
||||
// 3. HoTime 框架核心文件 - 通过包含 "hotime" 或框架特有文件名来识别
|
||||
// 检查路径中是否包含 hotime 框架标识
|
||||
lowerFile := strings.ToLower(file)
|
||||
if strings.Contains(lowerFile, "hotime") {
|
||||
// 是 hotime 框架的一部分,检查是否是核心模块
|
||||
frameworkDirs := []string{"/db/", "/common/", "/code/", "/cache/", "/log/", "/dri/"}
|
||||
for _, dir := range frameworkDirs {
|
||||
if strings.Contains(file, dir) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// 框架核心文件(在 hotime 根目录下的 .go 文件)
|
||||
if strings.HasSuffix(file, "application.go") ||
|
||||
strings.HasSuffix(file, "context.go") ||
|
||||
strings.HasSuffix(file, "session.go") ||
|
||||
strings.HasSuffix(file, "const.go") ||
|
||||
strings.HasSuffix(file, "type.go") ||
|
||||
strings.HasSuffix(file, "var.go") ||
|
||||
strings.HasSuffix(file, "mime.go") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 直接匹配框架核心目录(用于没有完整路径的情况)
|
||||
// 只匹配 "db/xxx.go" 这种在框架核心目录下的文件
|
||||
frameworkCoreDirs := []string{"db/", "common/", "code/", "cache/"}
|
||||
for _, dir := range frameworkCoreDirs {
|
||||
if strings.HasPrefix(file, dir) {
|
||||
// 额外检查:确保不是用户项目中同名目录
|
||||
// 框架文件通常有特定的文件名
|
||||
frameworkFiles := []string{
|
||||
"query.go", "crud.go", "where.go", "builder.go", "db.go",
|
||||
"dialect.go", "aggregate.go", "transaction.go", "identifier.go",
|
||||
"error.go", "func.go", "map.go", "obj.go", "slice.go",
|
||||
"makecode.go", "template.go", "config.go",
|
||||
"cache.go", "cache_db.go", "cache_memory.go", "cache_redis.go",
|
||||
}
|
||||
for _, f := range frameworkFiles {
|
||||
if strings.HasSuffix(file, f) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// 对caller进行递归查询, 直到找到非框架层产生的第一个调用.
|
||||
// 遍历调用栈,跳过框架层文件,找到应用层代码
|
||||
// 使用层数限制确保不会误过滤应用层同名目录
|
||||
func findCaller(skip int) string {
|
||||
frameworkCount := 0 // 连续框架层计数
|
||||
|
||||
// 遍历调用栈,找到第一个非框架文件
|
||||
for i := 0; i < 20; i++ {
|
||||
file, line := getCaller(skip + i)
|
||||
if file == "" {
|
||||
break
|
||||
}
|
||||
|
||||
if isHoTimeFrameworkFile(file) {
|
||||
frameworkCount++
|
||||
// 层数限制:如果已经跳过太多层,停止跳过
|
||||
if frameworkCount >= maxFrameworkDepth {
|
||||
return fmt.Sprintf("%s:%d", file, line)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 找到非框架文件,返回应用层代码位置
|
||||
return fmt.Sprintf("%s:%d", file, line)
|
||||
}
|
||||
|
||||
// 如果找不到应用层,返回最初的调用者
|
||||
file, line := getCaller(skip)
|
||||
return fmt.Sprintf("%s:%d", file, line)
|
||||
}
|
||||
|
||||
// 这里其实可以获取函数名称的: fnName := runtime.FuncForPC(pc).Name()
|
||||
// 但是我觉得有 文件名和行号就够定位问题, 因此忽略了caller返回的第一个值:pc
|
||||
// 在标准库log里面我们可以选择记录文件的全路径或者文件名, 但是在使用过程成并发最合适的,
|
||||
// 因为文件的全路径往往很长, 而文件名在多个包中往往有重复, 因此这里选择多取一层, 取到文件所在的上层目录那层.
|
||||
func getCaller(skip int) (string, int) {
|
||||
_, file, line, ok := runtime.Caller(skip)
|
||||
//fmt.Println(file)
|
||||
//fmt.Println(line)
|
||||
if !ok {
|
||||
return "", 0
|
||||
}
|
||||
n := 0
|
||||
for i := len(file) - 1; i > 0; i-- {
|
||||
if file[i] == '/' {
|
||||
n++
|
||||
if n >= 2 {
|
||||
file = file[i+1:]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return file, line
|
||||
}
|
||||
+1
-2
@@ -27,8 +27,7 @@ func Run() {
|
||||
}
|
||||
|
||||
appIns.Config["tpt"] = TptPath
|
||||
appIns.SetSession(hotime.CacheIns(&hotime.CacheMemory{}), hotime.CacheIns(&hotime.CacheMemory{}))
|
||||
appIns.SetCache(hotime.CacheIns(&hotime.CacheMemory{}))
|
||||
appIns.SetCache()
|
||||
|
||||
//快捷模式
|
||||
//appIns.SetDefault(func(err ...*hotime.Error) *sql.DB {
|
||||
|
||||
+77
-1
@@ -1,9 +1,10 @@
|
||||
package hotime
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
. "code.hoteas.com/golang/hotime/cache"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// session对象
|
||||
@@ -55,6 +56,81 @@ func (that *SessionIns) Session(key string, data ...interface{}) *Obj {
|
||||
return result
|
||||
}
|
||||
|
||||
// SessionsSet 批量设置session字段,只触发一次数据库写入
|
||||
// 用法:that.SessionsSet(Map{"key1": value1, "key2": value2, ...})
|
||||
// 性能优化:设置N个字段只触发1次数据库写入(而非N次)
|
||||
func (that *SessionIns) SessionsSet(data Map) {
|
||||
if len(data) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
that.mutex.Lock()
|
||||
if that.Map == nil {
|
||||
that.getWithoutLock()
|
||||
}
|
||||
|
||||
// 批量设置所有字段
|
||||
for key, value := range data {
|
||||
if value == nil {
|
||||
delete(that.Map, key)
|
||||
} else {
|
||||
that.Map[key] = value
|
||||
}
|
||||
}
|
||||
that.mutex.Unlock()
|
||||
|
||||
// 只触发一次数据库写入
|
||||
that.setWithCopy()
|
||||
}
|
||||
|
||||
// SessionsDelete 批量删除session字段,只触发一次数据库写入
|
||||
// 用法:that.SessionsDelete("key1", "key2", ...)
|
||||
func (that *SessionIns) SessionsDelete(keys ...string) {
|
||||
if len(keys) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
that.mutex.Lock()
|
||||
if that.Map == nil {
|
||||
that.getWithoutLock()
|
||||
}
|
||||
|
||||
// 批量删除所有字段
|
||||
for _, key := range keys {
|
||||
delete(that.Map, key)
|
||||
}
|
||||
that.mutex.Unlock()
|
||||
|
||||
// 只触发一次数据库写入
|
||||
that.setWithCopy()
|
||||
}
|
||||
|
||||
// SessionsGet 批量获取session字段
|
||||
// 用法:result := that.SessionsGet("key1", "key2", ...)
|
||||
// 返回 Map,key 为字段名,value 为字段值(不存在的 key 不包含在结果中)
|
||||
func (that *SessionIns) SessionsGet(keys ...string) Map {
|
||||
if len(keys) == 0 {
|
||||
return Map{}
|
||||
}
|
||||
|
||||
that.mutex.Lock()
|
||||
if that.Map == nil {
|
||||
that.getWithoutLock()
|
||||
}
|
||||
that.mutex.Unlock()
|
||||
|
||||
result := make(Map, len(keys))
|
||||
that.mutex.RLock()
|
||||
for _, key := range keys {
|
||||
if value, exists := that.Map[key]; exists {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
that.mutex.RUnlock()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// getWithoutLock 内部使用,调用前需要已持有锁
|
||||
func (that *SessionIns) getWithoutLock() {
|
||||
that.Map = that.HoTimeCache.Session(HEAD_SESSION_ADD + that.SessionId).ToMap()
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !windows
|
||||
|
||||
package hotime
|
||||
|
||||
// registerWindowsCloseHandler 在非 Windows 平台上是空实现。
|
||||
// Linux/macOS 通过 SIGTERM (kill) 或 SIGINT (Ctrl+C) 触发优雅停机。
|
||||
func (that *Application) registerWindowsCloseHandler() {}
|
||||
@@ -0,0 +1,31 @@
|
||||
//go:build windows
|
||||
|
||||
package hotime
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
var kernel32 = syscall.NewLazyDLL("kernel32.dll")
|
||||
var procSetConsoleCtrlHandler = kernel32.NewProc("SetConsoleCtrlHandler")
|
||||
|
||||
// registerWindowsCloseHandler 注册 Windows 控制台关闭事件处理器,
|
||||
// 捕获用户点击控制台 X 按钮、系统注销/关机等事件,触发优雅停机。
|
||||
//
|
||||
// Windows 在 CTRL_CLOSE_EVENT 后约 5 秒会强制结束进程,
|
||||
// 因此此路径使用更短的 drain(2s)+shutdown(3s),总计 <5s。
|
||||
// 对于需要更长时间的场景,请改用 taskkill /PID 或 Ctrl+C。
|
||||
func (that *Application) registerWindowsCloseHandler() {
|
||||
handler := func(ctrl uintptr) uintptr {
|
||||
switch ctrl {
|
||||
case 2, 5, 6: // CTRL_CLOSE_EVENT=2, CTRL_LOGOFF_EVENT=5, CTRL_SHUTDOWN_EVENT=6
|
||||
that.initiateGracefulShutdown(2*time.Second, 3*time.Second)
|
||||
time.Sleep(6 * time.Second)
|
||||
return 1 // TRUE, 已处理
|
||||
}
|
||||
return 0 // FALSE, 交给下一个 handler
|
||||
}
|
||||
|
||||
procSetConsoleCtrlHandler.Call(syscall.NewCallback(handler), 1)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user