Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a254b62ec | |||
| 8fb6565d9f | |||
| de17ecbfd5 | |||
| 74ae7217e1 | |||
| 3ff3953dff | |||
| 68283b5242 | |||
| aa84bba69b | |||
| 8921aa3b1c | |||
| 6aa3417855 | |||
| ce30117bb2 | |||
| 4817be07de | |||
| 3ddb07a828 | |||
| 8d73953307 | |||
| ee5904b08b | |||
| d3da365b35 | |||
| b77e8d029d | |||
| 0991555c2d | |||
| 298dbcbcb1 | |||
| ee3db6cf20 | |||
| 955897192a | |||
| 2f1b05f3d7 | |||
| b151276eec | |||
| acc79a1e2d | |||
| d0d0ce38aa | |||
| 22790ec1c0 | |||
| ea8fbfbf7a | |||
| df6bf94b0b | |||
| 34365fdaa0 | |||
| 82cf5a14d2 | |||
| 355d19418b | |||
| 9c5a7630a4 | |||
| 808a782db5 | |||
| 411c647c1a | |||
| 5ad4e2e11b | |||
| b43f968b6c | |||
| 7ab803c5cc | |||
| 309f113a6f | |||
| 97a9f0964a | |||
| 3feaa69fe4 | |||
| 93596ad0dc | |||
| ff410b94c3 | |||
| c922023740 | |||
| 9949231d7c | |||
| 3fd0975427 | |||
| 3d83c41905 | |||
| 27300025f3 | |||
| 230dfc5a2b | |||
| f4760c5d3e | |||
| d1b905b780 | |||
| 8dac2aff66 | |||
| cf64276ab1 | |||
| 29a3b6095d | |||
| 650fafad1a | |||
| 6164dfe9bf | |||
| 5ba883cd6b | |||
| c2955d2500 | |||
| 0e1775f72b | |||
| 1a9e7e19b7 | |||
| 4828f3625c | |||
| 0318c95055 | |||
| f2f1fcc9aa | |||
| b755519fc6 | |||
| 3455fb0a1c | |||
| 5bb9ed77b8 | |||
| 678686fd48 | |||
| 9766648536 | |||
| 2cf20e7206 | |||
| 8cac1f5393 | |||
| 8337cdec0c | |||
| 46cecc47ea | |||
| 7535300107 | |||
| be41a70c76 | |||
| 5b407824a5 | |||
| 56f66fcaed | |||
| a298cb3d52 | |||
| 68f2c0fd8f |
@@ -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,249 @@
|
||||
---
|
||||
name: 多数据库方言与前缀支持
|
||||
overview: 为 HoTimeDB ORM 实现完整的多数据库(MySQL/PostgreSQL/SQLite)方言支持和自动表前缀功能,采用智能解析+辅助方法兜底的混合策略,保持完全向后兼容。
|
||||
todos:
|
||||
- id: dialect-interface
|
||||
content: 扩展 Dialect 接口,添加 QuoteIdentifier 和 QuoteChar 方法
|
||||
status: completed
|
||||
- id: identifier-processor
|
||||
content: 新建 identifier.go,实现 IdentifierProcessor 及智能解析逻辑
|
||||
status: completed
|
||||
- id: db-integration
|
||||
content: 在 db.go 中集成处理器,添加 T() 和 C() 辅助方法
|
||||
status: completed
|
||||
- id: crud-update
|
||||
content: 修改 crud.go 中 Select/Insert/Update/Delete/buildJoin 等方法
|
||||
status: completed
|
||||
- id: where-update
|
||||
content: 修改 where.go 中 varCond 等条件处理方法
|
||||
status: completed
|
||||
- id: builder-check
|
||||
content: 检查 builder.go 是否需要额外修改
|
||||
status: completed
|
||||
- id: testing
|
||||
content: 编写测试用例验证多数据库和前缀功能
|
||||
status: completed
|
||||
- id: todo-1769037903242-d7aip6nh1
|
||||
content: ""
|
||||
status: pending
|
||||
---
|
||||
|
||||
# HoTimeDB 多数据库方言与自动前缀支持计划(更新版)
|
||||
|
||||
## 目标
|
||||
|
||||
1. **多数据库方言支持**:MySQL、PostgreSQL、SQLite 标识符引号自动转换
|
||||
2. **自动表前缀**:主表、JOIN 表、ON/WHERE 条件中的表名自动添加前缀
|
||||
3. **完全向后兼容**:用户现有写法无需修改
|
||||
4. **辅助方法兜底**:边缘情况可用 `T()` / `C()` 精确控制
|
||||
|
||||
## 混合策略设计
|
||||
|
||||
### 各部分处理方式
|
||||
|
||||
| 位置 | 处理方式 | 准确度 | 说明 |
|
||||
|
||||
|------|---------|--------|------|
|
||||
|
||||
| 主表名 | 自动 | 100% | `Select("order")` 自动处理 |
|
||||
|
||||
| JOIN 表名 | 自动 | 100% | `[><]order` 中提取表名处理 |
|
||||
|
||||
| ON 条件字符串 | 智能解析 | ~95% | 正则匹配 `table.column` 模式 |
|
||||
|
||||
| WHERE 条件 Map | 自动 | 100% | Map 的 key 是结构化的 |
|
||||
|
||||
| SELECT 字段 | 智能解析 | ~95% | 同 ON 条件 |
|
||||
|
||||
### 辅助方法(兜底)
|
||||
|
||||
```go
|
||||
db.T("order") // 返回 "`app_order`" (MySQL) 或 "\"app_order\"" (PG)
|
||||
db.C("order", "name") // 返回 "`app_order`.`name`"
|
||||
db.C("order.name") // 同上,支持点号格式
|
||||
```
|
||||
|
||||
## 实现步骤
|
||||
|
||||
### 第1步:扩展 Dialect 接口([db/dialect.go](db/dialect.go))
|
||||
|
||||
添加新方法到 `Dialect` 接口:
|
||||
|
||||
```go
|
||||
// QuoteIdentifier 处理单个标识符(去除已有引号,添加正确引号)
|
||||
QuoteIdentifier(name string) string
|
||||
|
||||
// QuoteChar 返回引号字符
|
||||
QuoteChar() string
|
||||
```
|
||||
|
||||
三种方言实现:
|
||||
|
||||
- MySQL: 反引号 `` ` ``
|
||||
- PostgreSQL/SQLite: 双引号 `"`
|
||||
|
||||
### 第2步:添加标识符处理器([db/identifier.go](db/identifier.go) 新文件)
|
||||
|
||||
```go
|
||||
type IdentifierProcessor struct {
|
||||
dialect Dialect
|
||||
prefix string
|
||||
}
|
||||
|
||||
// ProcessTableName 处理表名(添加前缀+引号)
|
||||
// "order" → "`app_order`"
|
||||
func (p *IdentifierProcessor) ProcessTableName(name string) string
|
||||
|
||||
// ProcessColumn 处理 table.column 格式
|
||||
// "order.name" → "`app_order`.`name`"
|
||||
// "`order`.name" → "`app_order`.`name`"
|
||||
func (p *IdentifierProcessor) ProcessColumn(name string) string
|
||||
|
||||
// ProcessConditionString 智能解析条件字符串
|
||||
// "user.id = order.user_id" → "`app_user`.`id` = `app_order`.`user_id`"
|
||||
func (p *IdentifierProcessor) ProcessConditionString(condition string) string
|
||||
|
||||
// ProcessFieldList 处理字段列表字符串
|
||||
// "order.id, user.name AS uname" → "`app_order`.`id`, `app_user`.`name` AS uname"
|
||||
func (p *IdentifierProcessor) ProcessFieldList(fields string) string
|
||||
```
|
||||
|
||||
**智能解析正则**:
|
||||
|
||||
```go
|
||||
// 匹配 table.column 模式,排除已有引号、函数调用等
|
||||
// 模式: \b([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)\b
|
||||
// 排除: `table`.column, "table".column, FUNC(), 123.456
|
||||
```
|
||||
|
||||
### 第3步:在 HoTimeDB 中集成([db/db.go](db/db.go))
|
||||
|
||||
```go
|
||||
// processor 缓存
|
||||
var processorOnce sync.Once
|
||||
var processor *IdentifierProcessor
|
||||
|
||||
// GetProcessor 获取标识符处理器
|
||||
func (that *HoTimeDB) GetProcessor() *IdentifierProcessor
|
||||
|
||||
// T 辅助方法:获取带前缀和引号的表名
|
||||
func (that *HoTimeDB) T(table string) string
|
||||
|
||||
// C 辅助方法:获取带前缀和引号的 table.column
|
||||
func (that *HoTimeDB) C(args ...string) string
|
||||
```
|
||||
|
||||
### 第4步:修改 CRUD 方法([db/crud.go](db/crud.go))
|
||||
|
||||
**Select 方法改动**:
|
||||
|
||||
```go
|
||||
// L112-116 原代码
|
||||
if !strings.Contains(table, ".") && !strings.Contains(table, " AS ") {
|
||||
query += " FROM `" + that.Prefix + table + "` "
|
||||
} else {
|
||||
query += " FROM " + that.Prefix + table + " "
|
||||
}
|
||||
|
||||
// 改为
|
||||
query += " FROM " + that.GetProcessor().ProcessTableName(table) + " "
|
||||
|
||||
// 字段列表处理(L90-107)
|
||||
// 如果是字符串,调用 ProcessFieldList 处理
|
||||
```
|
||||
|
||||
**buildJoin 方法改动**(L156-222):
|
||||
|
||||
```go
|
||||
// 原代码 L186-190
|
||||
table := Substr(k, 3, len(k)-3)
|
||||
if !strings.Contains(table, " ") {
|
||||
table = "`" + table + "`"
|
||||
}
|
||||
query += " LEFT JOIN " + table + " ON " + v.(string) + " "
|
||||
|
||||
// 改为
|
||||
table := Substr(k, 3, len(k)-3)
|
||||
table = that.GetProcessor().ProcessTableName(table)
|
||||
onCondition := that.GetProcessor().ProcessConditionString(v.(string))
|
||||
query += " LEFT JOIN " + table + " ON " + onCondition + " "
|
||||
```
|
||||
|
||||
**Insert/Inserts/Update/Delete** 同样修改表名和字段名处理。
|
||||
|
||||
### 第5步:修改 WHERE 条件处理([db/where.go](db/where.go))
|
||||
|
||||
**varCond 方法改动**(多处):
|
||||
|
||||
```go
|
||||
// 原代码(多处出现)
|
||||
if !strings.Contains(k, ".") {
|
||||
k = "`" + k + "`"
|
||||
}
|
||||
|
||||
// 改为
|
||||
k = that.GetProcessor().ProcessColumn(k)
|
||||
```
|
||||
|
||||
需要修改的函数:
|
||||
|
||||
- `varCond` (L205-338)
|
||||
- `handleDefaultCondition` (L340-368)
|
||||
- `handlePlainField` (L370-400)
|
||||
|
||||
### 第6步:修改链式构建器([db/builder.go](db/builder.go))
|
||||
|
||||
**LeftJoin 等方法需要传递处理器**:
|
||||
|
||||
由于 builder 持有 HoTimeDB 引用,可以直接使用:
|
||||
|
||||
```go
|
||||
func (that *HotimeDBBuilder) LeftJoin(table, joinStr string) *HotimeDBBuilder {
|
||||
// 不在这里处理,让 buildJoin 统一处理
|
||||
that.Join(Map{"[>]" + table: joinStr})
|
||||
return that
|
||||
}
|
||||
```
|
||||
|
||||
JOIN 的实际处理在 `crud.go` 的 `buildJoin` 中完成。
|
||||
|
||||
## 智能解析的边界处理
|
||||
|
||||
### 会自动处理的情况
|
||||
|
||||
- `user.id = order.user_id` → 正确处理
|
||||
- `user.id=order.user_id` → 正确处理(无空格)
|
||||
- `` `user`.id = order.user_id `` → 正确处理(混合格式)
|
||||
- `user.id = order.user_id AND order.status = 1` → 正确处理
|
||||
|
||||
### 需要辅助方法的边缘情况
|
||||
|
||||
- 子查询中的表名
|
||||
- 复杂 CASE WHEN 表达式
|
||||
- 动态拼接的 SQL 片段
|
||||
|
||||
## 文件清单
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
|
||||
|------|------|------|
|
||||
|
||||
| [db/dialect.go](db/dialect.go) | 修改 | 扩展 Dialect 接口 |
|
||||
|
||||
| [db/identifier.go](db/identifier.go) | 新增 | IdentifierProcessor 实现 |
|
||||
|
||||
| [db/db.go](db/db.go) | 修改 | 集成处理器,添加 T()/C() 方法 |
|
||||
|
||||
| [db/crud.go](db/crud.go) | 修改 | 修改所有 CRUD 方法 |
|
||||
|
||||
| [db/where.go](db/where.go) | 修改 | 修改条件处理逻辑 |
|
||||
|
||||
| [db/builder.go](db/builder.go) | 检查 | 可能无需修改(buildJoin 统一处理)|
|
||||
|
||||
## 测试用例
|
||||
|
||||
1. **多数据库切换**:MySQL → PostgreSQL → SQLite
|
||||
2. **前缀场景**:有前缀 vs 无前缀
|
||||
3. **复杂 JOIN**:多表 JOIN + 复杂 ON 条件
|
||||
4. **混合写法**:`order.name` + `` `user`.id `` 混用
|
||||
5. **辅助方法**:`T()` 和 `C()` 正确性
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: 字段备注空格扩展
|
||||
overview: 在 hotime 的列注释解析中,除保留「首段为系统内 label」外,将「第一个空格之后、选项/花括号规则之前」的内容单独写入新字段(如 remark),供前端展示;并澄清:管理端若列来自已有配置或 JSON,可能未经过 DB 新列分支,故不会表现为「整段 label 被丢掉」。
|
||||
todos:
|
||||
- id: parse-makecode
|
||||
content: 在 makecode.go 中实现 SplitN + 空格拆分 + remark 字段,并统一 options/ps 解析顺序
|
||||
status: pending
|
||||
- id: ui-remark
|
||||
content: 在 myst-web Edit/Info 中展示 column.remark(可选样式)
|
||||
status: pending
|
||||
- id: docs-format
|
||||
content: 补充列注释格式说明文档
|
||||
status: pending
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# 表字段备注:空格后扩展备注(修正认知)
|
||||
|
||||
## 为何你能在管理端看到「完整」label
|
||||
|
||||
`[hotimev1.5/code/makecode.go](d:/work/hotimev1.5/code/makecode.go)` 中,**空格截断与 `:` 处理仅发生在 `if coloum == nil` 分支**(约 189–325 行),即:**仅当该列尚未在 `TableColumns` 中存在、完全由本次从库表结构推断新建列时**才会执行。
|
||||
|
||||
若列定义已来自 `[config/adminDB.json](d:/work/myst-go/config/adminDB.json)` 等已有配置,会走 `coloum != nil`,**不会执行**上述截断逻辑,管理端下发的 `column.label` 仍可能是你在 JSON 里写的整段字符串,因此**不会出现「整段在 UI 里被丢掉」的现象**。
|
||||
|
||||
你遇到的「断言/解析」问题更可能出在:**需要同时支持「系统内短 label」与「空格后的说明」两种语义**,而不是单纯丢失数据。
|
||||
|
||||
## 目标行为(约定)
|
||||
|
||||
对**数据库列注释**(以及与之对齐的生成逻辑)采用统一解析顺序,避免与现有 `:` 选项、`{...}` 的 `ps` 冲突:
|
||||
|
||||
1. 用 `**SplitN(raw, ":", 2)`** 拆出「主文案」与「选项串」(替代当前对整串 `strings.Split(..., ":")`,避免备注里出现冒号时误伤)。
|
||||
2. 在「主文案」中按 **第一个空格** 拆成:
|
||||
- **第一段**:`label`(系统内字段名/短标题,与现有一致)
|
||||
- **第二段及以后**(或仅第二段,按产品定):trim 后写入新字段,建议键名 `**remark`**(若你更倾向 `note` / `labelRemark` 可再定)
|
||||
3. `**{...}` → `ps`**:仍在**完整主文案或完整 raw** 上提取(与现有行为一致),避免破坏依赖 `ps` 的表单。
|
||||
4. **选项解析**:使用 `SplitN` 的第二段,保持 `1-男,2-女` 这类格式不变。
|
||||
|
||||
示例:`姓名 对外展示说明 :1-男,2-女` → `label=姓名`,`remark=对外展示说明`,options 从 `:1-男,2-女` 解析。
|
||||
|
||||
## 实现位置
|
||||
|
||||
|
||||
| 层级 | 文件 | 内容 |
|
||||
| --------- | ------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| 后端 | `[hotimev1.5/code/makecode.go](d:/work/hotimev1.5/code/makecode.go)` | 重构 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,144 @@
|
||||
---
|
||||
name: 缓存数据库表重构
|
||||
overview: 重构数据库缓存模块,将 cached 表替换为符合设计规范的 hotime_cache 表,支持 MySQL/SQLite/PostgreSQL,修复并发问题,支持可配置的历史记录功能和自动迁移。
|
||||
todos:
|
||||
- id: update-cache-db
|
||||
content: 重构 cache_db.go:新表、UPSERT、历史记录、自动迁移、问题修复
|
||||
status: completed
|
||||
- id: update-cache-go
|
||||
content: 修改 cache.go:添加 HistorySet 配置传递
|
||||
status: completed
|
||||
- id: update-makecode
|
||||
content: 修改 makecode.go:跳过新表名
|
||||
status: completed
|
||||
---
|
||||
|
||||
# 缓存数据库表重构计划
|
||||
|
||||
## 设计概要
|
||||
|
||||
将原 `cached` 表替换为 `hotime_cache` 表,遵循数据库设计规范,支持:
|
||||
|
||||
- 多数据库:MySQL、SQLite、PostgreSQL
|
||||
- 可配置的历史记录功能(仅日志,无读取接口)
|
||||
- 自动迁移旧 cached 表数据
|
||||
|
||||
## 文件改动清单
|
||||
|
||||
| 文件 | 改动 | 状态 |
|
||||
|
||||
|------|------|------|
|
||||
|
||||
| [cache/cache_db.go](cache/cache_db.go) | 完全重构:新表、UPSERT、历史记录、自动迁移 | 待完成 |
|
||||
|
||||
| [cache/cache.go](cache/cache.go) | 添加 `HistorySet: db.GetBool("history")` 配置传递 | 待完成 |
|
||||
|
||||
| [code/makecode.go](code/makecode.go) | 跳过 hotime_cache 和 hotime_cache_history | 已完成 |
|
||||
|
||||
## 表结构设计
|
||||
|
||||
### 主表 `hotime_cache`
|
||||
|
||||
```sql
|
||||
-- MySQL
|
||||
CREATE TABLE `hotime_cache` (
|
||||
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`key` varchar(64) NOT NULL COMMENT '缓存键',
|
||||
`value` text DEFAULT NULL COMMENT '缓存值',
|
||||
`end_time` datetime DEFAULT NULL COMMENT '过期时间',
|
||||
`state` int(2) DEFAULT '0' COMMENT '状态:0-正常,1-异常,2-隐藏',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建日期',
|
||||
`modify_time` datetime DEFAULT NULL COMMENT '变更时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_key` (`key`),
|
||||
KEY `idx_end_time` (`end_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='缓存管理';
|
||||
|
||||
-- SQLite
|
||||
CREATE TABLE "hotime_cache" (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"key" TEXT NOT NULL UNIQUE,
|
||||
"value" TEXT,
|
||||
"end_time" TEXT,
|
||||
"state" INTEGER DEFAULT 0,
|
||||
"create_time" TEXT,
|
||||
"modify_time" TEXT
|
||||
);
|
||||
|
||||
-- PostgreSQL
|
||||
CREATE TABLE "hotime_cache" (
|
||||
"id" SERIAL PRIMARY KEY,
|
||||
"key" VARCHAR(64) NOT NULL UNIQUE,
|
||||
"value" TEXT,
|
||||
"end_time" TIMESTAMP,
|
||||
"state" INTEGER DEFAULT 0,
|
||||
"create_time" TIMESTAMP,
|
||||
"modify_time" TIMESTAMP
|
||||
);
|
||||
CREATE INDEX "idx_hotime_cache_end_time" ON "hotime_cache" ("end_time");
|
||||
```
|
||||
|
||||
### 历史表 `hotime_cache_history`(配置开启时创建,创建后永不自动删除)
|
||||
|
||||
与主表结构相同,但 `id` 改为 `hotime_cache_id` 作为外键关联。
|
||||
|
||||
## 问题修复清单
|
||||
|
||||
| 问题 | 原状态 | 修复方案 |
|
||||
|
||||
|------|--------|----------|
|
||||
|
||||
| key 无索引 | 全表扫描 | 唯一索引 uk_key |
|
||||
|
||||
| 并发竞态 | Update+Insert 可能重复 | 使用 UPSERT 语法 |
|
||||
|
||||
| 时间字段混乱 | time(纳秒) + endtime(秒) | 统一 datetime 格式 |
|
||||
|
||||
| value 长度限制 | varchar(2000) | TEXT 类型 |
|
||||
|
||||
| TimeOut=0 立即过期 | 无默认值 | 默认 24 小时 |
|
||||
|
||||
| get 时删除过期数据 | 每次写操作 | 惰性删除,只返回 nil |
|
||||
|
||||
| 旧表 key 重复 | 无约束 | 迁移时取最后一条 |
|
||||
|
||||
| value 包装冗余 | `{"data": value}` | 直接存储 |
|
||||
|
||||
## 主要代码改动点
|
||||
|
||||
### cache_db.go 改动
|
||||
|
||||
1. 新增常量:表名、默认过期时间 24 小时
|
||||
2. 结构体添加 `HistorySet bool`
|
||||
3. 使用 common 包 `Time2Str(time.Now())` 格式化时间
|
||||
4. `initDbTable()`: 支持三种数据库、自动迁移、创建历史表
|
||||
5. `migrateFromCached()`: 去重迁移(取最后一条)、删除旧表
|
||||
6. `writeHistory()`: 查询数据写入历史表(仅日志)
|
||||
7. `set()`: 使用 UPSERT、调用 writeHistory
|
||||
8. `get()`: 惰性删除
|
||||
9. `Cache()`: TimeOut=0 时用默认值
|
||||
|
||||
### cache.go 改动
|
||||
|
||||
第 268 行添加:`HistorySet: db.GetBool("history")`
|
||||
|
||||
## 配置示例
|
||||
|
||||
```json
|
||||
{
|
||||
"cache": {
|
||||
"db": {
|
||||
"db": true,
|
||||
"session": true,
|
||||
"timeout": 72000,
|
||||
"history": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 历史记录逻辑
|
||||
|
||||
- 新增/修改后:查询完整数据,id 改为 hotime_cache_id,插入历史表
|
||||
- 删除:不记录历史
|
||||
- 历史表仅日志记录,无读取接口,人工在数据库操作
|
||||
@@ -0,0 +1,221 @@
|
||||
---
|
||||
name: 缓存表模式配置
|
||||
overview: 为 CacheDb 添加 mode 配置项,支持 "new"(默认,只用新表)和 "compatible"(写新读老)两种模式,并更新配置说明。
|
||||
todos:
|
||||
- id: add-mode-field
|
||||
content: 在 CacheDb 结构体中添加 Mode 字段
|
||||
status: completed
|
||||
- id: modify-init
|
||||
content: 修改 initDbTable,根据 Mode 决定是否迁移删除老表
|
||||
status: completed
|
||||
dependencies:
|
||||
- add-mode-field
|
||||
- id: add-legacy-get
|
||||
content: 添加 getLegacy 方法读取老表数据(unix时间戳格式)
|
||||
status: completed
|
||||
dependencies:
|
||||
- add-mode-field
|
||||
- id: modify-get
|
||||
content: 修改 get 方法,compatible 模式下回退读取老表
|
||||
status: completed
|
||||
dependencies:
|
||||
- add-legacy-get
|
||||
- id: modify-set
|
||||
content: 修改 set 方法,compatible 模式下写新表后删除老表同key记录
|
||||
status: completed
|
||||
dependencies:
|
||||
- add-mode-field
|
||||
- id: modify-delete
|
||||
content: 修改 delete 方法,compatible 模式下同时删除新表和老表
|
||||
status: completed
|
||||
dependencies:
|
||||
- add-mode-field
|
||||
- id: update-cache-init
|
||||
content: 在 cache.go Init 方法中读取 mode 配置
|
||||
status: completed
|
||||
dependencies:
|
||||
- add-mode-field
|
||||
- id: update-config-note
|
||||
content: 在 var.go ConfigNote 中添加 mode 配置说明
|
||||
status: completed
|
||||
- id: add-cache-test
|
||||
content: 在 example/main.go 中添加缓存测试路由
|
||||
status: completed
|
||||
dependencies:
|
||||
- modify-get
|
||||
- modify-set
|
||||
- modify-delete
|
||||
- update-cache-init
|
||||
- id: todo-1769763169689-k7t9twp5t
|
||||
content: |
|
||||
QUICKSTART.md 更新:缓存配置部分需要添加 mode 和 history 配置说明
|
||||
status: pending
|
||||
---
|
||||
|
||||
# 缓存表模式配置实现计划
|
||||
|
||||
## 需求概述
|
||||
|
||||
在 [`cache/cache_db.go`](cache/cache_db.go) 中实现两种缓存表模式:
|
||||
|
||||
- **new**(默认):只使用新的 `hotime_cache` 表,自动迁移老表数据
|
||||
- **compatible**:写入新表,读取时先查新表再查老表,老数据自然过期消亡
|
||||
|
||||
## 实现步骤
|
||||
|
||||
### 1. 修改 CacheDb 结构体
|
||||
|
||||
在 [`cache/cache_db.go`](cache/cache_db.go) 中添加 `Mode` 字段:
|
||||
|
||||
```go
|
||||
type CacheDb struct {
|
||||
TimeOut int64
|
||||
DbSet bool
|
||||
SessionSet bool
|
||||
HistorySet bool
|
||||
Mode string // "new"(默认) 或 "compatible"
|
||||
Db HoTimeDBInterface
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 修改初始化逻辑 initDbTable
|
||||
|
||||
- **new 模式**:创建新表、迁移老表数据,**但不删除老表**(删除交给用户手动操作,更安全)
|
||||
- **compatible 模式**:创建新表,不迁移也不删除老表
|
||||
|
||||
两种模式都不自动删除老表,避免自动删除造成数据丢失风险
|
||||
|
||||
### 3. 修改 get 方法
|
||||
|
||||
- **new 模式**:只从新表读取
|
||||
- **compatible 模式**:先从新表读取,如果没有再从老表读取
|
||||
|
||||
需要新增 `getLegacy` 方法来读取老表数据,该方法需要:
|
||||
|
||||
1. 查询老表数据
|
||||
2. 检查 `endtime`(unix 时间戳)是否过期
|
||||
3. 如果过期:删除该条记录,返回 nil
|
||||
4. 如果未过期:返回数据
|
||||
|
||||
### 4. 修改 set 方法(写新删老)
|
||||
|
||||
- **new 模式**:只写新表(老表保留但不再管理)
|
||||
- **compatible 模式**:写入新表 + 删除老表中相同 key 的记录
|
||||
|
||||
这样可以主动加速老数据消亡,而不是等自然过期。
|
||||
|
||||
### 5. 修改 delete 方法
|
||||
|
||||
- **new 模式**:只删除新表(老表保留但不再管理)
|
||||
- **compatible 模式**:同时删除新表和老表中的 key
|
||||
|
||||
这是必要的,否则删除新表后,下次读取会回退读到老表的数据,造成"删不掉"的问题。
|
||||
|
||||
需要新增 `deleteLegacy` 方法处理老表删除逻辑
|
||||
|
||||
### 6. 更新缓存初始化
|
||||
|
||||
在 [`cache/cache.go`](cache/cache.go) 的 `Init` 方法中读取 `mode` 配置:
|
||||
|
||||
```go
|
||||
that.dbCache = &CacheDb{
|
||||
// ...
|
||||
Mode: db.GetString("mode"), // 读取 mode 配置
|
||||
}
|
||||
```
|
||||
|
||||
### 7. 更新配置说明
|
||||
|
||||
在 [`var.go`](var.go) 的 `ConfigNote` 中添加 `mode` 配置说明:
|
||||
|
||||
```go
|
||||
"db": Map{
|
||||
// ...
|
||||
"mode": "默认new,非必须,new为只使用新表(自动迁移老数据),compatible为兼容模式(写新表读老表,老数据自然过期)",
|
||||
}
|
||||
```
|
||||
|
||||
### 8. 编写测试
|
||||
|
||||
在 [`example/main.go`](example/main.go) 中添加缓存测试路由,测试覆盖:
|
||||
|
||||
**new 模式测试:**
|
||||
|
||||
- 基础读写:set/get/delete 正常工作
|
||||
- 过期测试:设置短过期时间,验证过期后读取返回 nil
|
||||
- 数据迁移:验证老表数据能正确迁移到新表
|
||||
- 老表保留:验证迁移后老表仍存在(不自动删除)
|
||||
|
||||
**compatible 模式测试:**
|
||||
|
||||
- 新表读写:优先从新表读取
|
||||
- 老表回退:新表没有时从老表读取
|
||||
- 过期检测:读取老表过期数据时返回 nil 并删除该记录
|
||||
- 写新删老:写入新表后老表同 key 记录被删除
|
||||
- 删除双表:删除操作同时删除新表和老表记录
|
||||
- 通配删除:`key*` 格式删除测试
|
||||
|
||||
**边界情况测试:**
|
||||
|
||||
- 空值处理:nil 值的 set/get
|
||||
- 不存在的 key 读取
|
||||
- 重复 set 同一个 key
|
||||
- 超时时间参数测试(默认/自定义)
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Config[配置]
|
||||
ModeNew["mode: new (默认)"]
|
||||
ModeCompat["mode: compatible"]
|
||||
end
|
||||
|
||||
subgraph NewMode[new模式]
|
||||
N1[初始化] --> N2[创建新表]
|
||||
N2 --> N3{老表存在?}
|
||||
N3 -->|是| N4[迁移数据到新表]
|
||||
N4 --> N5[保留老表由人工删除]
|
||||
N3 -->|否| N6[完成]
|
||||
N5 --> N6
|
||||
|
||||
NR[读取] --> NR1[只查询新表]
|
||||
NW[写入] --> NW1[只写入新表]
|
||||
ND[删除] --> ND1[只删除新表记录]
|
||||
end
|
||||
|
||||
subgraph CompatMode[compatible模式]
|
||||
C1[初始化] --> C2[创建新表]
|
||||
C2 --> C3[保留老表]
|
||||
|
||||
CR[读取] --> CR1[查询新表]
|
||||
CR1 -->|未找到| CR2[查询老表]
|
||||
CR2 --> CR3{过期?}
|
||||
CR3 -->|是| CR4[删除老表记录]
|
||||
CR4 --> CR5[返回nil]
|
||||
CR3 -->|否| CR6[返回数据]
|
||||
|
||||
CW[写入] --> CW1[写入新表]
|
||||
CW1 --> CW2[删除老表同key]
|
||||
|
||||
CD[删除] --> CD1[删除新表记录]
|
||||
CD1 --> CD2[删除老表记录]
|
||||
end
|
||||
```
|
||||
|
||||
## 文件修改列表
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|
||||
|------|----------|
|
||||
|
||||
| [`cache/cache_db.go`](cache/cache_db.go) | 添加 Mode 字段、修改 initDbTable、添加 getLegacy(含过期检测删除)、修改 get、修改 set(写新删老)、修改 delete |
|
||||
|
||||
| [`cache/cache.go`](cache/cache.go) | 读取 mode 配置 |
|
||||
|
||||
| [`var.go`](var.go) | ConfigNote 添加 mode 说明 |
|
||||
|
||||
| [`example/main.go`](example/main.go) | 添加缓存测试路由 |
|
||||
|
||||
| [`example/config/config.json`](example/config/config.json) | 可选:添加 mode 配置示例 |
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
name: 补充 Note 方法示例
|
||||
overview: 在 Testing_API测试框架.md 的"第二步:编写测试用例"部分补充 Note 方法的使用示例和说明,同步更新 SKILL.md。
|
||||
todos:
|
||||
- id: doc-step2-example
|
||||
content: 文档:在第二步的完整订单示例中加入 Note 调用示范
|
||||
status: completed
|
||||
- id: doc-note-section
|
||||
content: 文档:在用例编写范式中新增 Note 使用说明(典型场景 + 示例 + 何时必须用)
|
||||
status: completed
|
||||
- id: doc-hotimev15-sync
|
||||
content: 同步修改 hotimev1.5 中的 Testing_API测试框架.md
|
||||
status: completed
|
||||
- id: skill-note-guide
|
||||
content: SKILL.md:补充 Note 使用指引和更多场景示例
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# 补充 Note 方法示例到文档和 Skill
|
||||
|
||||
## 现状分析
|
||||
|
||||
目前 `Note` 方法在文档中的存在情况:
|
||||
|
||||
- [链式 API 参考](d:\work\your-app\docs\Testing_API测试框架.md) 第 474、494 行:仅出现在 API 表格中,一句话描述
|
||||
- [API 速查表](d:\work\your-app\docs\Testing_API测试框架.md) 第 662-668 行:只有一个简短示例(`sign = MD5(...)`)
|
||||
- [SKILL.md](C:\Users\92597.cursor\skills\hotime-tdd-testing\SKILL.md) 第 195-197 行:一行示例
|
||||
|
||||
**缺失**:"第二步:编写测试用例"(第 66-149 行)的完整订单示例中完全没有出现 `Note`,而这是新手学习的第一个入口。
|
||||
|
||||
## 需要修改的内容
|
||||
|
||||
### 1. 文档:第二步示例中加入 Note 使用
|
||||
|
||||
在 [Testing_API测试框架.md](d:\work\your-app\docs\Testing_API测试框架.md) 第 80-148 行的订单创建示例中,在**正确请求**部分加入 `Note` 调用,展示以下几种典型场景:
|
||||
|
||||
- 在正确请求处用 `Note` 说明参数值含义(如 `quantity=3` 是故意的用于校验总价 `29.9*3=89.7`)
|
||||
|
||||
### 2. 文档:用例编写范式中新增 Note 独立小节
|
||||
|
||||
在"用例编写范式"部分(第 219 行之后),在"错误用例"之前或之后,新增一个关于 Note 的说明段落或小节,覆盖以下典型使用场景:
|
||||
|
||||
- **参数值参考**:说明某个参数值的来源或含义,如 `Note("status: 0=待付款, 1=已付款, 2=已发货, 3=已完成")`
|
||||
- **算法说明**:简要描述接口涉及的签名/加密算法,如 `Note("sign = MD5(smsProxyKey+timestamp)")`
|
||||
- **接口/用例说明**:补充 desc 无法涵盖的上下文,如 `Note("需先调用 /api/cart/add 加入购物车")` 或 `Note("此接口支持批量操作,最多100条")`
|
||||
- 在用例编写范式表格中增加 Note 的定位说明
|
||||
|
||||
### 3. 文档:同步更新 hotimev1.5 中的同名文件
|
||||
|
||||
[hotimev1.5/docs/Testing_API测试框架.md](d:\work\hotimev1.5\docs\Testing_API测试框架.md) 需要做相同的修改。
|
||||
|
||||
### 4. Skill 文件:补充 Note 指引
|
||||
|
||||
在 [SKILL.md](C:\Users\92597.cursor\skills\hotime-tdd-testing\SKILL.md) 中:
|
||||
|
||||
- 在 Phase 1 的 1.3 小节之后(或 1.2 和 1.3 之间),新增 Note 的使用指引
|
||||
- 说明何时应该使用 Note(有必要的场景),给出 2-3 个典型示例
|
||||
- 在 API 速查的"备注"部分补充更多场景示例
|
||||
|
||||
## 修改要点
|
||||
|
||||
- Note 不改变测试逻辑,只是给调试控制台和 api-spec.json 添加可读说明
|
||||
- Note 是可选的,但在以下场景**建议必须**使用:
|
||||
- 参数中有枚举值(如 status、type)需要说明各值含义
|
||||
- 涉及签名/加密等算法的接口
|
||||
- 接口有前置依赖或特殊调用顺序
|
||||
- Note 可以放在链式调用的任意位置(`a.Note(...)` 或 `c.Note(...)`)
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
---
|
||||
name: 规范文档创建
|
||||
overview: 创建两个规范文档:一个数据库设计规范文档,一个管理后台配置规范文档(包含admin.json和rule.json的配置说明),并在README.md中添加链接。
|
||||
todos:
|
||||
- id: create-db-doc
|
||||
content: 创建 docs/DatabaseDesign_数据库设计规范.md
|
||||
status: pending
|
||||
- id: create-admin-doc
|
||||
content: 创建 docs/AdminConfig_管理后台配置规范.md
|
||||
status: pending
|
||||
- id: update-readme
|
||||
content: 在 README.md 文档表格中添加两个新文档链接
|
||||
status: pending
|
||||
dependencies:
|
||||
- create-db-doc
|
||||
- create-admin-doc
|
||||
---
|
||||
|
||||
# 创建规范文档
|
||||
|
||||
## 任务概述
|
||||
|
||||
在 `D:\work\hotimev1.5\docs` 目录下创建两个规范文档,并更新 README.md 添加链接。
|
||||
|
||||
---
|
||||
|
||||
## 文档一:数据库设计规范
|
||||
|
||||
**文件**: [docs/DatabaseDesign_数据库设计规范.md](docs/DatabaseDesign_数据库设计规范.md)
|
||||
|
||||
### 内容结构
|
||||
|
||||
| 章节 | 内容 |
|
||||
|
||||
|------|------|
|
||||
|
||||
| 表命名规则 | 不加前缀、可用简称、关联表命名(主表_关联表) |
|
||||
|
||||
| 字段命名规则 | 主键id、外键表名_id、全局唯一性要求、层级字段(parent_id/parent_ids/level) |
|
||||
|
||||
| 注释规则 | select类型格式(`状态:0-正常,1-异常`)、时间用datetime |
|
||||
|
||||
| 必有字段 | state、create_time、modify_time |
|
||||
|
||||
| 示例 | 完整建表SQL示例 |
|
||||
|
||||
---
|
||||
|
||||
## 文档二:管理后台配置规范
|
||||
|
||||
**文件**: [docs/AdminConfig_管理后台配置规范.md](docs/AdminConfig_管理后台配置规范.md)
|
||||
|
||||
### 内容结构
|
||||
|
||||
| 章节 | 内容 |
|
||||
|
||||
|------|------|
|
||||
|
||||
| admin.json 配置 | |
|
||||
|
||||
| - flow配置 | 数据流控制,定义表间权限关系(sql条件、stop标志) |
|
||||
|
||||
| - labelConfig | 操作按钮标签(show/add/delete/edit/info/download) |
|
||||
|
||||
| - label | 菜单/表的显示名称 |
|
||||
|
||||
| - menus配置 | 菜单结构(嵌套menus、icon、table、name、auth) |
|
||||
|
||||
| - auth配置 | 权限数组(show/add/delete/edit/info/download) |
|
||||
|
||||
| - icon配置 | 菜单图标(如Setting) |
|
||||
|
||||
| - table/name配置 | table指定数据表,name用于分组标识 |
|
||||
|
||||
| - stop配置 | 不允许用户修改自身关联数据的表 |
|
||||
|
||||
| rule.json 配置 | |
|
||||
|
||||
| - 字段默认权限 | add/edit/info/list/must/strict/type 各字段含义 |
|
||||
|
||||
| - 内置字段规则 | id、parent_id、create_time、modify_time、password等 |
|
||||
|
||||
| - type类型说明 | select/time/image/file/password/textArea/auth/form等 |
|
||||
|
||||
---
|
||||
|
||||
## 更新 README.md
|
||||
|
||||
在文档表格中添加两个新链接:
|
||||
|
||||
```markdown
|
||||
| [数据库设计规范](docs/DatabaseDesign_数据库设计规范.md) | 表命名、字段命名、注释规则、必有字段 |
|
||||
| [管理后台配置规范](docs/AdminConfig_管理后台配置规范.md) | admin.json、rule.json 配置说明 |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键参考文件
|
||||
|
||||
- [`code/config.go`](code/config.go): RuleConfig 默认规则定义
|
||||
- [`code/makecode.go`](code/makecode.go): 外键自动关联逻辑
|
||||
- [`example/config/admin.json`](example/config/admin.json): 完整配置示例
|
||||
- [`example/config/rule.json`](example/config/rule.json): 字段规则示例
|
||||
@@ -0,0 +1,145 @@
|
||||
---
|
||||
name: 重写API测试框架文档
|
||||
overview: 重新编撰 Testing_API测试框架.md,以"错误用例先行 → 准备数据 → 正确请求(含响应断言+DB校验)"的完整流程为核心范式,让文档成为可直接照搬的编写指南。
|
||||
todos:
|
||||
- id: rewrite-doc
|
||||
content: 重写 docs/Testing_API测试框架.md:错误用例先行 → 模拟数据准备 → 正确请求(响应断言必须+DB校验) → 逐步讲解各环节 → API速查表 → 其余章节微调
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# 重写 API 测试框架文档
|
||||
|
||||
## 用户确认的编写规范
|
||||
|
||||
1. **顺序**:先写错误请求用例,再写正确请求用例
|
||||
2. **错误用例**:三个参数必须填满 `Post("描述", status, "具体错误消息")`,因为错误码是复用的(如 status=3 可能对应多种参数校验错误)
|
||||
3. **正确请求前**:用 `a.DB()` 模拟插入数据,既准备测试依赖数据,也可作为后续 DB 校验的对照
|
||||
4. **响应字段断言**:正确响应情况下**必须**断言(至少覆盖重要字段),不能只写 `Post("描述", 0)`
|
||||
5. **数据库校验**:只要接口改变了数据库状态,都**建议**用 Verify 校验结果
|
||||
|
||||
## 标准用例编写流程(范式)
|
||||
|
||||
```
|
||||
错误用例(参数校验、权限校验等)
|
||||
↓
|
||||
准备测试数据(a.DB().Insert/Update 模拟前置数据)
|
||||
↓
|
||||
正确请求 + 响应结构校验(ExpectResult / 第三参数 Map)
|
||||
↓
|
||||
响应字段断言(resp.GetBody() 检查关键业务字段)
|
||||
↓
|
||||
数据库状态校验(Verify 查库确认写入/更新结果)
|
||||
```
|
||||
|
||||
## 核心范例(贯穿文档的"创建订单"示例)
|
||||
|
||||
以下为快速开始中使用的完整范例,展示从业务代码到测试用例的映射:
|
||||
|
||||
```go
|
||||
// order_test.go
|
||||
var OrderTest = CtrTest{
|
||||
"create": {Desc: "创建订单", Func: func(a *Api) {
|
||||
// ======== 第一步:错误用例 ========
|
||||
// 错误码复用,三个参数都必须填满
|
||||
a.JSON(Map{"goods_id": int64(1), "quantity": 3, "address": "XX路1号"}).
|
||||
Post("未登录创建订单", 2, "请先登录")
|
||||
a.WithSession(Map{"user_id": int64(1)}).
|
||||
JSON(Map{"quantity": 3, "address": "XX路1号"}).
|
||||
Post("缺少商品ID", 3, "请选择商品")
|
||||
a.WithSession(Map{"user_id": int64(1)}).
|
||||
JSON(Map{"goods_id": int64(1), "address": "XX路1号"}).
|
||||
Post("缺少数量", 3, "请填写购买数量")
|
||||
a.WithSession(Map{"user_id": int64(1)}).
|
||||
JSON(Map{"goods_id": int64(999), "quantity": 3, "address": "XX路1号"}).
|
||||
Post("商品不存在", 4, "商品不存在")
|
||||
|
||||
// ======== 第二步:准备测试数据 ========
|
||||
// 插入一条商品数据,后面的正确请求会依赖它
|
||||
goodsId := a.DB().Insert("goods", Map{
|
||||
"name": "测试商品", "price": 29.9, "stock": 100, "state": 1,
|
||||
})
|
||||
|
||||
// ======== 第三步:正确请求 + 响应断言 + DB校验 ========
|
||||
resp := a.WithSession(Map{"user_id": int64(1)}).
|
||||
JSON(Map{"goods_id": goodsId, "quantity": 3, "address": "XX路1号"}).
|
||||
Verify(func(a *Api) error {
|
||||
row := a.DB().Get("order", "*", Map{"AND": Map{
|
||||
"user_id": int64(1), "goods_id": goodsId,
|
||||
}})
|
||||
if row == nil {
|
||||
return fmt.Errorf("order 表未写入订单记录")
|
||||
}
|
||||
if row.GetCeilInt64("state") != 0 {
|
||||
return fmt.Errorf("新订单 state 期望 0, 实际 %d", row.GetCeilInt64("state"))
|
||||
}
|
||||
if row.GetCeilFloat64("total_price") != 29.9*3 {
|
||||
return fmt.Errorf("total_price 期望 %.2f, 实际 %.2f", 29.9*3, row.GetCeilFloat64("total_price"))
|
||||
}
|
||||
// 校验库存扣减
|
||||
goods := a.DB().Get("goods", "stock", Map{"id": goodsId})
|
||||
if goods.GetCeilInt64("stock") != 97 {
|
||||
return fmt.Errorf("库存期望 97, 实际 %d", goods.GetCeilInt64("stock"))
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Post("正常创建订单", 0, Map{
|
||||
"id": int64(1), "sn": "sample", "total_price": float64(1.0),
|
||||
})
|
||||
|
||||
// 关键业务字段断言
|
||||
orderId := resp.GetBody().GetMap("result").GetCeilInt64("id")
|
||||
if orderId <= 0 {
|
||||
resp.Fail("返回的订单 ID 必须大于 0")
|
||||
}
|
||||
sn := resp.GetBody().GetMap("result").GetString("sn")
|
||||
if sn == "" {
|
||||
resp.Fail("返回的订单编号 sn 不能为空")
|
||||
}
|
||||
}},
|
||||
}
|
||||
```
|
||||
|
||||
## 文档结构
|
||||
|
||||
### 不变章节(微调措辞)
|
||||
|
||||
- 概述(能力表格)
|
||||
- 核心类型
|
||||
- 链式 API 参考(在 expect 参数规则部分强调:错误用例建议三参数写满)
|
||||
- 事务隔离机制
|
||||
- 覆盖率报告
|
||||
- API 调试控制台
|
||||
- 指定运行范围
|
||||
- 并发保护与缓存隔离
|
||||
- 框架改动说明
|
||||
|
||||
### 重写/新增章节
|
||||
|
||||
#### 1. "快速开始" — 使用上述完整范例
|
||||
|
||||
- 先展示业务代码(handler),再展示测试代码
|
||||
- 范例中完整体现五步流程
|
||||
- 目录结构、注册方式、运行方式保持原有说明
|
||||
|
||||
#### 2. "用例编写范式" — 新增章节,替代原"完整场景对照"
|
||||
|
||||
按顺序逐步讲解范例中的每个环节:
|
||||
|
||||
- **错误用例编写规范**:为什么三参数必须写满(错误码复用)、如何覆盖权限/参数/业务错误
|
||||
- **测试数据准备**:`a.DB().Insert()` 的使用场景和注意事项(事务内操作,自动回滚)
|
||||
- **正确请求 + 响应结构校验**:第三参数 Map 的类型样本规则、ExpectResult 后置校验
|
||||
- **响应字段断言**:`resp.GetBody()` / `resp.Fail()` 的用法、哪些字段必须断言
|
||||
- **数据库状态校验**:Verify 的使用时机(只要改了DB就建议校验)、校验主表+关联表+字段值
|
||||
- 包含"不好的写法 vs 推荐的写法"对比
|
||||
|
||||
#### 3. "API 速查表" — 精简版场景对照
|
||||
|
||||
保留各种参数组合(JSON/Form/Query/File/混合/Session 等)的快速参考,但每种只一行示例代码,不再是大段散乱的代码块。
|
||||
|
||||
## 改动范围
|
||||
|
||||
- 仅改动 `docs/Testing_API测试框架.md`,约 750-850 行
|
||||
- 不涉及 Go 源码
|
||||
- 不涉及其他文档
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
---
|
||||
name: 集成请求参数获取方法
|
||||
overview: 在 context.go 中为 Context 结构体添加五个请求参数获取方法:ReqData(统一获取)、ReqDataParams(URL参数)、ReqDataJson(JSON Body)、ReqDataForm(表单数据)、ReqFile(获取上传文件)。
|
||||
todos:
|
||||
- id: add-imports
|
||||
content: 在 context.go 中添加 bytes、io、mime/multipart 包的导入
|
||||
status: completed
|
||||
- id: impl-params
|
||||
content: 实现 ReqParam/ReqParams 方法(获取 URL 参数,返回 *Obj)
|
||||
status: completed
|
||||
dependencies:
|
||||
- add-imports
|
||||
- id: impl-form
|
||||
content: 实现 ReqForm/ReqForms 方法(获取表单数据,返回 *Obj)
|
||||
status: completed
|
||||
dependencies:
|
||||
- add-imports
|
||||
- id: impl-json
|
||||
content: 实现 ReqJson/ReqJsons 方法(获取 JSON Body,返回 *Obj)
|
||||
status: completed
|
||||
dependencies:
|
||||
- add-imports
|
||||
- id: impl-file
|
||||
content: 实现 ReqFile/ReqFiles 方法(获取上传文件)
|
||||
status: completed
|
||||
dependencies:
|
||||
- add-imports
|
||||
- id: impl-reqdata
|
||||
content: 实现 ReqData/ReqDatas 方法(统一获取,返回 *Obj)
|
||||
status: completed
|
||||
dependencies:
|
||||
- impl-params
|
||||
- impl-form
|
||||
- impl-json
|
||||
---
|
||||
|
||||
# 集成请求参数获取方法
|
||||
|
||||
## 实现位置
|
||||
|
||||
在 [`context.go`](context.go) 中添加请求参数获取方法,**风格与 `Session("key")` 保持一致,返回 `*Obj` 支持链式调用**。
|
||||
|
||||
## 新增方法
|
||||
|
||||
### 1. ReqParam - 获取 URL 查询参数(返回 *Obj)
|
||||
|
||||
```go
|
||||
// 获取单个参数,支持链式调用
|
||||
func (that *Context) ReqParam(key string) *Obj {
|
||||
// that.ReqParam("id").ToStr()
|
||||
// that.ReqParam("id").ToInt()
|
||||
}
|
||||
|
||||
// 获取所有 URL 参数
|
||||
func (that *Context) ReqParams() Map
|
||||
```
|
||||
|
||||
### 2. ReqForm - 获取表单数据(返回 *Obj)
|
||||
|
||||
```go
|
||||
// 获取单个表单字段
|
||||
func (that *Context) ReqForm(key string) *Obj {
|
||||
// that.ReqForm("name").ToStr()
|
||||
}
|
||||
|
||||
// 获取所有表单数据
|
||||
func (that *Context) ReqForms() Map
|
||||
```
|
||||
|
||||
### 3. ReqJson - 获取 JSON Body(返回 *Obj)
|
||||
|
||||
```go
|
||||
// 获取 JSON 中的单个字段
|
||||
func (that *Context) ReqJson(key string) *Obj {
|
||||
// that.ReqJson("data").ToMap()
|
||||
// that.ReqJson("count").ToInt()
|
||||
}
|
||||
|
||||
// 获取完整 JSON Body
|
||||
func (that *Context) ReqJsons() Map
|
||||
```
|
||||
|
||||
### 4. ReqFile - 获取上传文件
|
||||
|
||||
```go
|
||||
func (that *Context) ReqFile(name string) (multipart.File, *multipart.FileHeader, error)
|
||||
func (that *Context) ReqFiles(name string) ([]*multipart.FileHeader, error)
|
||||
```
|
||||
|
||||
### 5. ReqData - 统一获取参数(返回 *Obj)
|
||||
|
||||
```go
|
||||
// 统一获取(JSON > Form > URL),支持链式调用
|
||||
func (that *Context) ReqData(key string) *Obj {
|
||||
// that.ReqData("id").ToStr()
|
||||
}
|
||||
|
||||
// 获取所有合并后的参数
|
||||
func (that *Context) ReqDatas() Map
|
||||
```
|
||||
|
||||
## 需要的导入
|
||||
|
||||
```go
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
)
|
||||
```
|
||||
|
||||
## 关键实现细节
|
||||
|
||||
1. **Body 只能读取一次的问题**:读取 Body 后需要用 `io.NopCloser(bytes.NewBuffer(body))` 恢复,以便后续代码(如其他中间件)还能再次读取
|
||||
|
||||
2. **废弃 API 替换**:使用 `io.ReadAll` 替代已废弃的 `ioutil.ReadAll`
|
||||
|
||||
3. **多值参数处理**:当同一参数有多个值时(如 `?id=1&id=2`),存储为 `Slice`;单值则直接存储字符串
|
||||
|
||||
## 使用示例
|
||||
|
||||
```go
|
||||
appIns.Run(Router{
|
||||
"app": {
|
||||
"user": {
|
||||
"info": func(that *Context) {
|
||||
// 链式调用获取单个参数(类似 Session 风格)
|
||||
id := that.ReqData("id").ToInt() // 统一获取
|
||||
name := that.ReqParam("name").ToStr() // URL 参数
|
||||
age := that.ReqForm("age").ToCeilInt() // 表单参数
|
||||
data := that.ReqJson("profile").ToMap() // JSON 字段
|
||||
|
||||
// 获取所有参数(返回 Map)
|
||||
allParams := that.ReqDatas() // 合并后的所有参数
|
||||
urlParams := that.ReqParams() // 所有 URL 参数
|
||||
formData := that.ReqForms() // 所有表单数据
|
||||
jsonBody := that.ReqJsons() // 完整 JSON Body
|
||||
|
||||
that.Display(0, Map{"id": id, "name": name})
|
||||
},
|
||||
"upload": func(that *Context) {
|
||||
// 获取单个上传文件
|
||||
file, header, err := that.ReqFile("avatar")
|
||||
if err == nil {
|
||||
defer file.Close()
|
||||
// header.Filename - 文件名
|
||||
// header.Size - 文件大小
|
||||
}
|
||||
|
||||
// 获取多个同名上传文件
|
||||
files, err := that.ReqFiles("images")
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## 风格对比
|
||||
|
||||
| 旧方式(需要类型断言) | 新方式(链式调用) |
|
||||
|
||||
|------------------------|-------------------|
|
||||
|
||||
| `req["id"].(string) `| `that.ReqData("id").ToStr()` |
|
||||
|
||||
| `ObjToInt(req["id"]) `| `that.ReqData("id").ToInt()` |
|
||||
|
||||
| 需要手动处理 nil | `*Obj` 自动处理空值 |
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"setup-worktree": [
|
||||
"npm install"
|
||||
]
|
||||
}
|
||||
+12
-1
@@ -1,4 +1,15 @@
|
||||
/.idea/*
|
||||
.idea
|
||||
/example/config/app.json
|
||||
/example/tpt/demo/
|
||||
*.exe
|
||||
/example/config
|
||||
/.cursor/*.log
|
||||
*.sql
|
||||
*.py
|
||||
|
||||
# Cursor / agent debug NDJSON leftovers
|
||||
debug-*.log
|
||||
**/debug-*.log
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
@@ -1,6 +1,98 @@
|
||||
# hotime
|
||||
golang web服务框架
|
||||
支持数据库db:mysql、sqlite3
|
||||
支持缓存cache:redis,memory,数据库
|
||||
自带工具类,上下文,以及session等功能
|
||||
# HoTime
|
||||
|
||||
**高性能 Go Web 服务框架**
|
||||
|
||||
一个"小而全"的 Go Web 框架,内置 ORM、三级缓存、Session 管理,让你专注于业务逻辑。
|
||||
|
||||
## 核心特性
|
||||
|
||||
- **高性能** - 单机 10万+ QPS,支持百万级并发用户
|
||||
- **内置 ORM** - 类 Medoo 语法,链式查询,支持 MySQL/SQLite/PostgreSQL
|
||||
- **三级缓存** - Memory > Redis > DB,自动穿透与回填
|
||||
- **Session 管理** - 内置会话管理,支持多种存储后端
|
||||
- **代码生成** - 根据数据库表自动生成 CRUD 接口
|
||||
- **API 测试** - 内置接口测试框架,链式 API、事务自动回滚、覆盖率报告、交互式调试控制台
|
||||
- **优雅停机** - 跨平台(Windows/Linux)支持,停机时自动拒绝新请求并等待在途请求完成,配合 nginx 实现零停机滚动重启
|
||||
- **结构化日志 / Seq 集成** - zerolog 驱动,异步 channel 队列推送,零阻塞接入 Seq 集中搜索,自动捕获 fmt.Println 等全量输出,支持单机多进程实例区分
|
||||
- **开箱即用** - 微信支付/公众号/小程序、阿里云、腾讯云等 SDK 内置
|
||||
|
||||
## 文档
|
||||
|
||||
完整阅读路径见 [docs/README.md](docs/README.md)。
|
||||
|
||||
| 文档 | 说明 |
|
||||
|------|------|
|
||||
| [快速上手](docs/QuickStart_快速上手.md) | 5 分钟入门:安装配置、路由、中间件 |
|
||||
| [HoTimeDB 使用说明](docs/HoTimeDB_使用说明.md) | 完整数据库 ORM 教程 |
|
||||
| [HoTimeDB API 参考](docs/HoTimeDB_API参考.md) | 数据库 API 速查手册 |
|
||||
| [Common 工具类](docs/Common_工具类.md) | Map/Slice/Obj 类型、类型转换、工具函数 |
|
||||
| [数据库设计规范](docs/DatabaseDesign_数据库设计规范.md) | 表命名、字段命名、COMMENT、必有字段(CodeGen 前置) |
|
||||
| [代码生成](docs/CodeGen_代码生成.md) | 自动 CRUD 生成 + codeConfig / rule 配置 |
|
||||
| [API 测试框架](docs/Testing_API测试框架.md) | 接口测试、事务隔离、覆盖率、调试控制台 |
|
||||
| [优雅停机](docs/GracefulShutdown_优雅停机.md) | 跨平台优雅停机、nginx、滚动重启 |
|
||||
| [Seq 日志集成](docs/Seq_日志集成.md) | Seq 接入、异步队列、多实例区分 |
|
||||
| [改进规划](docs/ROADMAP_改进规划.md) | 待改进项与版本迭代规划 |
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
go get code.hoteas.com/golang/hotime
|
||||
```
|
||||
|
||||
## 性能
|
||||
|
||||
| 并发数 | QPS | 成功率 | 平均延迟 |
|
||||
|--------|-----|--------|----------|
|
||||
| 500 | 99,960 | 100% | 5.0ms |
|
||||
| **1000** | **102,489** | **100%** | **9.7ms** |
|
||||
| 2000 | 75,801 | 99.99% | 26.2ms |
|
||||
|
||||
> 测试环境:24 核 CPU,Windows 10,Go 1.19.3
|
||||
|
||||
### 并发用户估算
|
||||
|
||||
| 使用场景 | 请求频率 | 可支持用户数 |
|
||||
|----------|----------|--------------|
|
||||
| 高频交互 | 1次/秒 | ~10万 |
|
||||
| 活跃用户 | 1次/5秒 | ~50万 |
|
||||
| 普通浏览 | 1次/10秒 | ~100万 |
|
||||
|
||||
## 框架对比
|
||||
|
||||
| 特性 | HoTime | Gin | Echo | Fiber |
|
||||
|------|--------|-----|------|-------|
|
||||
| 性能 | 100K QPS | 70K QPS | 70K QPS | 100K QPS |
|
||||
| 内置ORM | ✅ | ❌ | ❌ | ❌ |
|
||||
| 内置缓存 | ✅ 三级缓存 | ❌ | ❌ | ❌ |
|
||||
| Session | ✅ 内置 | ❌ 需插件 | ❌ 需插件 | ❌ 需插件 |
|
||||
| 代码生成 | ✅ | ❌ | ❌ | ❌ |
|
||||
| API 测试框架 | ✅ 内置 | ❌ 需插件 | ❌ 需插件 | ❌ 需插件 |
|
||||
| 优雅停机 | ✅ 内置跨平台 | ❌ 需自行实现 | ❌ 需自行实现 | ✅ 内置 |
|
||||
| 微信/支付集成 | ✅ 内置 | ❌ | ❌ | ❌ |
|
||||
|
||||
## 适用场景
|
||||
|
||||
| 场景 | 推荐度 | 说明 |
|
||||
|------|--------|------|
|
||||
| 中小型后台系统 | ⭐⭐⭐⭐⭐ | 完美适配,开发效率最高 |
|
||||
| 微信小程序后端 | ⭐⭐⭐⭐⭐ | 内置微信 SDK |
|
||||
| 快速原型开发 | ⭐⭐⭐⭐⭐ | 代码生成 + 全功能集成 |
|
||||
| 高并发 API 服务 | ⭐⭐⭐⭐ | 性能足够 |
|
||||
| 大型微服务 | ⭐⭐⭐ | 建议用 Gin/Echo |
|
||||
|
||||
## 扩展功能
|
||||
|
||||
- 微信支付/公众号/小程序 - `dri/wechat/`
|
||||
- 阿里云服务 - `dri/aliyun/`
|
||||
- 腾讯云服务 - `dri/tencent/`
|
||||
- 文件上传下载 - `dri/upload/`, `dri/download/`
|
||||
- MongoDB - `dri/mongodb/`
|
||||
- RSA 加解密 - `dri/rsa/`
|
||||
|
||||
## License
|
||||
|
||||
Apache License 2.0
|
||||
|
||||
---
|
||||
|
||||
**HoTime** - 让 Go Web 开发更简单、更高效
|
||||
|
||||
+304
-99
@@ -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) {
|
||||
//如果没有设置配置自动生成配置
|
||||
@@ -68,18 +127,20 @@ func (that *Application) Run(router Router) {
|
||||
if that.Router == nil {
|
||||
that.Router = Router{}
|
||||
}
|
||||
for k, v := range router {
|
||||
for k, _ := range router {
|
||||
v := router[k]
|
||||
if that.Router[k] == nil {
|
||||
that.Router[k] = v
|
||||
}
|
||||
//直达接口层复用
|
||||
for k1, v1 := range v {
|
||||
for k1, _ := range v {
|
||||
v1 := v[k1]
|
||||
if that.Router[k][k1] == nil {
|
||||
that.Router[k][k1] = v1
|
||||
}
|
||||
|
||||
for k2, v2 := range v1 {
|
||||
|
||||
for k2, _ := range v1 {
|
||||
v2 := v1[k2]
|
||||
that.Router[k][k1][k2] = v2
|
||||
}
|
||||
}
|
||||
@@ -125,8 +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)
|
||||
}
|
||||
@@ -146,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
|
||||
}
|
||||
|
||||
}()
|
||||
}
|
||||
@@ -160,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 {
|
||||
@@ -199,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]
|
||||
}
|
||||
@@ -222,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则停止传输
|
||||
@@ -283,7 +378,7 @@ func (that *Application) SetConnectListener(lis func(that *Context) (isFinished
|
||||
//
|
||||
//}
|
||||
|
||||
//序列化链接
|
||||
// 序列化链接
|
||||
func (that *Application) urlSer(url string) (string, []string) {
|
||||
q := strings.Index(url, "?")
|
||||
if q == -1 {
|
||||
@@ -329,6 +424,9 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) {
|
||||
if len(token) == 32 {
|
||||
sessionId = token
|
||||
//没有token,则查阅session
|
||||
if cookie == nil || cookie.Value != sessionId {
|
||||
needSetCookie = sessionId
|
||||
}
|
||||
} else if err == nil && cookie.Value != "" {
|
||||
sessionId = cookie.Value
|
||||
//session也没有则判断是否创建cookie
|
||||
@@ -371,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)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -406,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] == '/' {
|
||||
@@ -435,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")
|
||||
@@ -443,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] != "" {
|
||||
|
||||
@@ -462,6 +563,7 @@ func (that *Application) crossDomain(context *Context, sessionId string) {
|
||||
if context.Config.GetString("crossDomain") == "" {
|
||||
if sessionId != "" {
|
||||
http.SetCookie(context.Resp, &http.Cookie{Name: that.Config.GetString("sessionName"), Value: sessionId, Path: "/"})
|
||||
//context.Resp.Header().Set("Set-Cookie", that.Config.GetString("sessionName")+"="+sessionId+"; Path=/; SameSite=None; Secure")
|
||||
}
|
||||
|
||||
return
|
||||
@@ -491,7 +593,7 @@ func (that *Application) crossDomain(context *Context, sessionId string) {
|
||||
header.Set("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE")
|
||||
header.Set("Access-Control-Allow-Credentials", "true")
|
||||
header.Set("Access-Control-Expose-Headers", "*")
|
||||
header.Set("Access-Control-Allow-Headers", "X-Requested-With,Content-Type,Access-Token")
|
||||
header.Set("Access-Control-Allow-Headers", "X-Requested-With,Content-Type,Access-Token,Authorization,Cookie,Set-Cookie")
|
||||
|
||||
if sessionId != "" {
|
||||
//跨域允许需要设置cookie的允许跨域https才有效果
|
||||
@@ -506,7 +608,8 @@ func (that *Application) crossDomain(context *Context, sessionId string) {
|
||||
if (origin != "" && strings.Contains(origin, remoteHost)) || strings.Contains(refer, remoteHost) {
|
||||
|
||||
if sessionId != "" {
|
||||
http.SetCookie(context.Resp, &http.Cookie{Name: that.Config.GetString("sessionName"), Value: sessionId, Path: "/"})
|
||||
//http.SetCookie(context.Resp, &http.Cookie{Name: that.Config.GetString("sessionName"), Value: sessionId, Path: "/"})
|
||||
context.Resp.Header().Set("Set-Cookie", that.Config.GetString("sessionName")+"="+sessionId+"; Path=/; SameSite=None; Secure")
|
||||
}
|
||||
|
||||
return
|
||||
@@ -537,7 +640,7 @@ func (that *Application) crossDomain(context *Context, sessionId string) {
|
||||
header.Set("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE")
|
||||
header.Set("Access-Control-Allow-Credentials", "true")
|
||||
header.Set("Access-Control-Expose-Headers", "*")
|
||||
header.Set("Access-Control-Allow-Headers", "X-Requested-With,Content-Type,Access-Token")
|
||||
header.Set("Access-Control-Allow-Headers", "X-Requested-With,Content-Type,Access-Token,Authorization,Cookie,Set-Cookie")
|
||||
|
||||
if sessionId != "" {
|
||||
//跨域允许需要设置cookie的允许跨域https才有效果
|
||||
@@ -546,7 +649,51 @@ func (that *Application) crossDomain(context *Context, sessionId string) {
|
||||
|
||||
}
|
||||
|
||||
//Init 初始化application
|
||||
// getLocalIP 通过 UDP dial 探测方式获取本机出口 IP(不发送任何数据,无副作用)。
|
||||
// 用于构建 Seq instance 字段,格式为 ip:port,支持跨机器集群识别。
|
||||
// 失败时返回 "unknown"。
|
||||
func getLocalIP() string {
|
||||
conn, err := net.Dial("udp", "8.8.8.8:80")
|
||||
if err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
defer conn.Close()
|
||||
return conn.LocalAddr().(*net.UDPAddr).IP.String()
|
||||
}
|
||||
|
||||
// redirectStdout 重定向 os.Stdout 和标准 log 包到 HoTime Logger。
|
||||
// 返回管道写端,供 logrus 等第三方库桥接。
|
||||
func redirectStdout(l *log.Logger) *os.File {
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
os.Stdout = w
|
||||
stdlog.SetOutput(w)
|
||||
go log.CaptureStream(l, zerolog.InfoLevel, "stdout", r)
|
||||
return w
|
||||
}
|
||||
|
||||
// bridgeThirdPartyStdout 将 import 时钉死原始 stdout 的第三方日志指到捕获管道。
|
||||
func bridgeThirdPartyStdout(w *os.File) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
logrus.SetOutput(w)
|
||||
}
|
||||
|
||||
// redirectStderr 重定向 os.Stderr 到 HoTime 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{}
|
||||
//手动模式,
|
||||
@@ -572,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)
|
||||
|
||||
//接入动态代码层
|
||||
@@ -582,15 +729,27 @@ func Init(config string) *Application {
|
||||
|
||||
//appIns.Router[codeMake.GetString("name")] = TptProject
|
||||
appIns.Router[codeMake.GetString("name")] = Proj{}
|
||||
|
||||
for k2, _ := range TptProject {
|
||||
appIns.Router[codeMake.GetString("name")][k2] = Ctr{}
|
||||
for k3, v3 := range TptProject[k2] {
|
||||
if appIns.Router[codeMake.GetString("name")][k2] == nil {
|
||||
appIns.Router[codeMake.GetString("name")][k2] = Ctr{}
|
||||
}
|
||||
for k3, _ := range TptProject[k2] {
|
||||
v3 := TptProject[k2][k3]
|
||||
appIns.Router[codeMake.GetString("name")][k2][k3] = v3
|
||||
}
|
||||
}
|
||||
|
||||
for k1, _ := range appIns.MakeCodeRouter[codeMake.GetString("name")].TableColumns {
|
||||
appIns.Router[codeMake.GetString("name")][k1] = appIns.Router[codeMake.GetString("name")]["hotimeCommon"]
|
||||
if appIns.Router[codeMake.GetString("name")][k1] == nil {
|
||||
appIns.Router[codeMake.GetString("name")][k1] = Ctr{}
|
||||
}
|
||||
|
||||
for k2, _ := range appIns.Router[codeMake.GetString("name")]["hotimeCommon"] {
|
||||
//golang毛病
|
||||
v2 := appIns.Router[codeMake.GetString("name")]["hotimeCommon"][k2]
|
||||
appIns.Router[codeMake.GetString("name")][k1][k2] = v2
|
||||
}
|
||||
}
|
||||
|
||||
setMakeCodeListener(codeMake.GetString("name"), &appIns)
|
||||
@@ -607,12 +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) {
|
||||
|
||||
@@ -620,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
|
||||
})
|
||||
}
|
||||
@@ -692,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) {
|
||||
@@ -728,6 +915,16 @@ func setMakeCodeListener(name string, appIns *Application) {
|
||||
context.Req.Method == "POST" {
|
||||
return isFinished
|
||||
}
|
||||
//分析
|
||||
if len(context.RouterString) == 3 && context.RouterString[2] == "analyse" &&
|
||||
context.Req.Method == "GET" {
|
||||
|
||||
if context.Router[context.RouterString[0]][context.RouterString[1]]["analyse"] == nil {
|
||||
return isFinished
|
||||
}
|
||||
|
||||
context.Router[context.RouterString[0]][context.RouterString[1]]["analyse"](context)
|
||||
}
|
||||
//查询单条
|
||||
if len(context.RouterString) == 3 &&
|
||||
context.Req.Method == "GET" {
|
||||
@@ -763,3 +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
+237
-44
@@ -2,13 +2,13 @@ package cache
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
"errors"
|
||||
"code.hoteas.com/golang/hotime/log"
|
||||
)
|
||||
|
||||
// HoTimeCache 可配置memory,db,redis,默认启用memory,默认优先级为memory>redis>db,memory与数据库缓存设置项一致,
|
||||
//缓存数据填充会自动反方向反哺,加入memory缓存过期将自动从redis更新,但memory永远不会更新redis,如果是集群建议不要开启memory,配置即启用
|
||||
// 缓存数据填充会自动反方向反哺,加入memory缓存过期将自动从redis更新,但memory永远不会更新redis,如果是集群建议不要开启memory,配置即启用
|
||||
type HoTimeCache struct {
|
||||
*Error
|
||||
Log *log.Logger
|
||||
dbCache *CacheDb
|
||||
redisCache *CacheRedis
|
||||
memoryCache *CacheMemory
|
||||
@@ -92,7 +92,7 @@ func (that *HoTimeCache) Db(key string, data ...interface{}) *Obj {
|
||||
}
|
||||
}
|
||||
|
||||
//redis缓存有
|
||||
//db缓存有
|
||||
if that.dbCache != nil && that.dbCache.DbSet {
|
||||
reData = that.dbCache.Cache(key, data...)
|
||||
if reData.Data != nil {
|
||||
@@ -119,7 +119,7 @@ func (that *HoTimeCache) Db(key string, data ...interface{}) *Obj {
|
||||
if that.redisCache != nil && that.redisCache.DbSet {
|
||||
reData = that.redisCache.Cache(key, data...)
|
||||
}
|
||||
//redis缓存有
|
||||
//db缓存有
|
||||
if that.dbCache != nil && that.dbCache.DbSet {
|
||||
reData = that.dbCache.Cache(key, data...)
|
||||
}
|
||||
@@ -132,7 +132,7 @@ func (that *HoTimeCache) Cache(key string, data ...interface{}) *Obj {
|
||||
//内存缓存有
|
||||
if that.memoryCache != nil {
|
||||
reData = that.memoryCache.Cache(key, data...)
|
||||
if reData != nil {
|
||||
if reData != nil && reData.Data != nil {
|
||||
return reData
|
||||
}
|
||||
}
|
||||
@@ -140,8 +140,7 @@ func (that *HoTimeCache) Cache(key string, data ...interface{}) *Obj {
|
||||
//redis缓存有
|
||||
if that.redisCache != nil {
|
||||
reData = that.redisCache.Cache(key, data...)
|
||||
if reData.Data != nil {
|
||||
|
||||
if reData != nil && reData.Data != nil {
|
||||
if that.memoryCache != nil {
|
||||
that.memoryCache.Cache(key, reData.Data)
|
||||
}
|
||||
@@ -149,10 +148,10 @@ func (that *HoTimeCache) Cache(key string, data ...interface{}) *Obj {
|
||||
}
|
||||
}
|
||||
|
||||
//redis缓存有
|
||||
//db缓存有
|
||||
if that.dbCache != nil {
|
||||
reData = that.dbCache.Cache(key, data...)
|
||||
if reData.Data != nil {
|
||||
if reData != nil && reData.Data != nil {
|
||||
if that.memoryCache != nil {
|
||||
that.memoryCache.Cache(key, reData.Data)
|
||||
}
|
||||
@@ -174,23 +173,222 @@ func (that *HoTimeCache) Cache(key string, data ...interface{}) *Obj {
|
||||
if that.redisCache != nil {
|
||||
reData = that.redisCache.Cache(key, data...)
|
||||
}
|
||||
//redis缓存有
|
||||
//db缓存有
|
||||
if that.dbCache != nil {
|
||||
reData = that.dbCache.Cache(key, data...)
|
||||
}
|
||||
return reData
|
||||
}
|
||||
|
||||
func (that *HoTimeCache) Init(config Map, hotimeDb HoTimeDBInterface, err ...*Error) {
|
||||
//防止空数据问题
|
||||
// SessionsGet 批量获取 Session 缓存
|
||||
// 返回 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,79 +397,74 @@ func (that *HoTimeCache) Init(config Map, hotimeDb HoTimeDBInterface, err ...*Er
|
||||
"sort": 0,
|
||||
"timeout": 60 * 60 * 2,
|
||||
}
|
||||
|
||||
}
|
||||
if memory.Get("db") == nil {
|
||||
memory["db"] = true
|
||||
}
|
||||
|
||||
if memory.Get("session") == nil {
|
||||
memory["session"] = true
|
||||
}
|
||||
|
||||
if memory.Get("timeout") == nil {
|
||||
memory["timeout"] = 60 * 60 * 2
|
||||
}
|
||||
that.Config["memory"] = memory
|
||||
that.memoryCache = &CacheMemory{TimeOut: memory.GetCeilInt64("timeout"),
|
||||
DbSet: memory.GetBool("db"), SessionSet: memory.GetBool("session")}
|
||||
if err[0] != nil {
|
||||
that.memoryCache.SetError(err[0])
|
||||
}
|
||||
DbSet: memory.GetBool("db"), SessionSet: memory.GetBool("session"),
|
||||
Log: logger}
|
||||
|
||||
//db配置初始化
|
||||
redis := that.Config.GetMap("redis")
|
||||
if redis != nil {
|
||||
if redis.GetString("host") == "" || redis.GetString("port") == "" {
|
||||
if err[0] != nil {
|
||||
err[0].SetError(errors.New("请检查redis配置host和port配置"))
|
||||
if logger != nil {
|
||||
logger.Error().Msg("请检查redis配置host和port配置")
|
||||
}
|
||||
return
|
||||
}
|
||||
if redis.Get("db") == nil {
|
||||
redis["db"] = true
|
||||
}
|
||||
|
||||
if redis.Get("session") == nil {
|
||||
redis["session"] = true
|
||||
}
|
||||
|
||||
if redis.Get("timeout") == nil {
|
||||
redis["timeout"] = 60 * 60 * 24 * 15
|
||||
}
|
||||
that.Config["redis"] = redis
|
||||
that.redisCache = &CacheRedis{TimeOut: redis.GetCeilInt64("timeout"),
|
||||
DbSet: redis.GetBool("db"), SessionSet: redis.GetBool("session"), Host: redis.GetString("host"),
|
||||
Pwd: redis.GetString("password"), Port: redis.GetCeilInt64("port")}
|
||||
|
||||
if err[0] != nil {
|
||||
that.redisCache.SetError(err[0])
|
||||
}
|
||||
Pwd: redis.GetString("password"), Port: redis.GetCeilInt64("port"),
|
||||
Log: logger}
|
||||
}
|
||||
|
||||
//db配置初始化
|
||||
db := that.Config.GetMap("db")
|
||||
if db != nil {
|
||||
|
||||
if db.Get("db") == nil {
|
||||
db["db"] = false
|
||||
}
|
||||
|
||||
if db.Get("session") == nil {
|
||||
db["session"] = true
|
||||
}
|
||||
if db.Get("timeout") == nil {
|
||||
db["timeout"] = 60 * 60 * 24 * 30
|
||||
}
|
||||
if db.Get("mode") == nil {
|
||||
db["mode"] = CacheModeCompatible
|
||||
}
|
||||
that.Config["db"] = db
|
||||
|
||||
that.dbCache = &CacheDb{TimeOut: db.GetCeilInt64("timeout"),
|
||||
DbSet: db.GetBool("db"), SessionSet: db.GetBool("session"),
|
||||
Db: hotimeDb}
|
||||
|
||||
if err[0] != nil {
|
||||
that.dbCache.SetError(err[0])
|
||||
that.dbCache = &CacheDb{
|
||||
TimeOut: db.GetCeilInt64("timeout"),
|
||||
DbSet: db.GetBool("db"),
|
||||
SessionSet: db.GetBool("session"),
|
||||
HistorySet: db.GetBool("history"),
|
||||
Mode: db.GetString("mode"),
|
||||
Db: hotimeDb,
|
||||
Log: logger,
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (that *HoTimeCache) DisableDbCache() {
|
||||
that.dbCache = nil
|
||||
that.redisCache = nil
|
||||
}
|
||||
|
||||
Vendored
+587
-87
@@ -1,17 +1,29 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
"code.hoteas.com/golang/hotime/log"
|
||||
)
|
||||
|
||||
// 表名常量
|
||||
const (
|
||||
CacheTableName = "hotime_cache"
|
||||
CacheHistoryTableName = "hotime_cache_history"
|
||||
LegacyCacheTableName = "cached" // 老版本缓存表名
|
||||
DefaultCacheTimeout = 24 * 60 * 60 // 默认过期时间 24 小时
|
||||
CacheModeNew = "new" // 新模式:只使用新表
|
||||
CacheModeCompatible = "compatible" // 兼容模式:写新读老
|
||||
)
|
||||
|
||||
type HoTimeDBInterface interface {
|
||||
GetPrefix() string
|
||||
Query(query string, args ...interface{}) []Map
|
||||
Exec(query string, args ...interface{}) (sql.Result, *Error)
|
||||
Exec(query string, args ...interface{}) (sql.Result, error)
|
||||
Get(table string, qu ...interface{}) Map
|
||||
Select(table string, qu ...interface{}) []Map
|
||||
Delete(table string, data map[string]interface{}) int64
|
||||
@@ -24,150 +36,638 @@ type CacheDb struct {
|
||||
TimeOut int64
|
||||
DbSet bool
|
||||
SessionSet bool
|
||||
HistorySet bool // 是否开启历史记录
|
||||
Mode string // 缓存模式:"new"(默认,只用新表) 或 "compatible"(写新读老)
|
||||
Db HoTimeDBInterface
|
||||
*Error
|
||||
Log *log.Logger
|
||||
ContextBase
|
||||
isInit bool
|
||||
}
|
||||
|
||||
func (that *CacheDb) GetError() *Error {
|
||||
|
||||
return that.Error
|
||||
|
||||
// getTableName 获取带前缀的表名
|
||||
func (that *CacheDb) getTableName() string {
|
||||
return that.Db.GetPrefix() + CacheTableName
|
||||
}
|
||||
|
||||
func (that *CacheDb) SetError(err *Error) {
|
||||
that.Error = err
|
||||
// getHistoryTableName 获取带前缀的历史表名
|
||||
func (that *CacheDb) getHistoryTableName() string {
|
||||
return that.Db.GetPrefix() + CacheHistoryTableName
|
||||
}
|
||||
|
||||
// getLegacyTableName 获取带前缀的老版本缓存表名
|
||||
func (that *CacheDb) getLegacyTableName() string {
|
||||
return that.Db.GetPrefix() + LegacyCacheTableName
|
||||
}
|
||||
|
||||
// isCompatibleMode 是否为兼容模式
|
||||
func (that *CacheDb) isCompatibleMode() bool {
|
||||
return that.Mode == CacheModeCompatible
|
||||
}
|
||||
|
||||
// getEffectiveMode 获取有效模式(默认为 new)
|
||||
func (that *CacheDb) getEffectiveMode() string {
|
||||
if that.Mode == CacheModeCompatible {
|
||||
return CacheModeCompatible
|
||||
}
|
||||
return CacheModeNew
|
||||
}
|
||||
|
||||
// initDbTable 初始化数据库表
|
||||
func (that *CacheDb) initDbTable() {
|
||||
if that.isInit {
|
||||
return
|
||||
}
|
||||
if that.Db.GetType() == "mysql" {
|
||||
|
||||
dbNames := that.Db.Query("SELECT DATABASE()")
|
||||
|
||||
if len(dbNames) == 0 {
|
||||
return
|
||||
}
|
||||
dbName := dbNames[0].GetString("DATABASE()")
|
||||
res := that.Db.Query("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA='" + dbName + "' AND TABLE_NAME='" + that.Db.GetPrefix() + "cached'")
|
||||
if len(res) != 0 {
|
||||
that.isInit = true
|
||||
return
|
||||
}
|
||||
|
||||
_, e := that.Db.Exec("CREATE TABLE `" + that.Db.GetPrefix() + "cached` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `key` varchar(60) DEFAULT NULL, `value` varchar(2000) DEFAULT NULL, `time` bigint(20) DEFAULT NULL, `endtime` bigint(20) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=198740 DEFAULT CHARSET=utf8")
|
||||
if e.GetError() == nil {
|
||||
that.isInit = true
|
||||
}
|
||||
dbType := that.Db.GetType()
|
||||
tableName := that.getTableName()
|
||||
historyTableName := that.getHistoryTableName()
|
||||
legacyTableName := that.getLegacyTableName()
|
||||
|
||||
// 检查并创建主表
|
||||
if !that.tableExists(tableName) {
|
||||
that.createMainTable(dbType, tableName)
|
||||
}
|
||||
|
||||
if that.Db.GetType() == "sqlite" {
|
||||
res := that.Db.Query(`select * from sqlite_master where type = 'table' and name = '` + that.Db.GetPrefix() + `cached'`)
|
||||
|
||||
if len(res) != 0 {
|
||||
that.isInit = true
|
||||
return
|
||||
}
|
||||
_, e := that.Db.Exec(`CREATE TABLE "` + that.Db.GetPrefix() + `cached" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"key" TEXT(60),
|
||||
"value" TEXT(2000),
|
||||
"time" integer,
|
||||
"endtime" integer
|
||||
);`)
|
||||
if e.GetError() == nil {
|
||||
that.isInit = true
|
||||
// 根据模式处理老表
|
||||
// new 模式:迁移老表数据到新表(不删除老表,由人工删除)
|
||||
// compatible 模式:不迁移,老表继续使用
|
||||
if that.getEffectiveMode() == CacheModeNew {
|
||||
if that.tableExists(legacyTableName) {
|
||||
that.migrateFromCached(dbType, legacyTableName, tableName)
|
||||
}
|
||||
}
|
||||
// compatible 模式不做任何处理,老表保留供读取
|
||||
|
||||
// 检查并创建历史表(开启历史记录时)
|
||||
if that.HistorySet && !that.tableExists(historyTableName) {
|
||||
that.createHistoryTable(dbType, historyTableName)
|
||||
}
|
||||
|
||||
that.isInit = true
|
||||
}
|
||||
|
||||
//获取Cache键只能为string类型
|
||||
func (that *CacheDb) get(key string) interface{} {
|
||||
// tableExists 检查表是否存在
|
||||
func (that *CacheDb) tableExists(tableName string) bool {
|
||||
dbType := that.Db.GetType()
|
||||
|
||||
cached := that.Db.Get("cached", "*", Map{"key": key})
|
||||
switch dbType {
|
||||
case "mysql":
|
||||
dbNames := that.Db.Query("SELECT DATABASE()")
|
||||
if len(dbNames) == 0 {
|
||||
return false
|
||||
}
|
||||
dbName := dbNames[0].GetString("DATABASE()")
|
||||
res := that.Db.Query("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA='" + dbName + "' AND TABLE_NAME='" + tableName + "'")
|
||||
return len(res) != 0
|
||||
|
||||
case "sqlite":
|
||||
res := that.Db.Query(`SELECT name FROM sqlite_master WHERE type='table' AND name='` + tableName + `'`)
|
||||
return len(res) != 0
|
||||
|
||||
case "postgres":
|
||||
res := that.Db.Query(`SELECT tablename FROM pg_tables WHERE schemaname='public' AND tablename='` + tableName + `'`)
|
||||
return len(res) != 0
|
||||
|
||||
case "dm", "dameng":
|
||||
// USER_TABLES 仅含当前登录用户(如 SYSDBA)拥有的表。
|
||||
// 当连接 schema(如 ZWBG)与登录用户不同时,ZWBG 的表不会出现在
|
||||
// USER_TABLES 中,导致 tableExists 误判为"不存在",进而触发重复建表失败。
|
||||
// 改为直接 SELECT COUNT(*) 探测当前 schema 下的可访问性:
|
||||
// 表存在则返回 1 行,表不存在则报错返回空结果(nil)。
|
||||
res := that.Db.Query(`SELECT COUNT(*) as cnt FROM "` + tableName + `"`)
|
||||
return len(res) > 0
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// createMainTable 创建主表
|
||||
func (that *CacheDb) createMainTable(dbType, tableName string) {
|
||||
var createSQL string
|
||||
|
||||
switch dbType {
|
||||
case "mysql":
|
||||
createSQL = "CREATE TABLE `" + tableName + "` (" +
|
||||
"`id` int(11) unsigned NOT NULL AUTO_INCREMENT," +
|
||||
"`key` varchar(64) NOT NULL COMMENT '缓存键'," +
|
||||
"`value` text DEFAULT NULL COMMENT '缓存值'," +
|
||||
"`end_time` datetime DEFAULT NULL COMMENT '过期时间'," +
|
||||
"`state` int(2) DEFAULT '0' COMMENT '状态:0-正常,1-异常,2-隐藏'," +
|
||||
"`create_time` datetime DEFAULT NULL COMMENT '创建日期'," +
|
||||
"`modify_time` datetime DEFAULT NULL COMMENT '变更时间'," +
|
||||
"PRIMARY KEY (`id`)," +
|
||||
"UNIQUE KEY `uk_key` (`key`)," +
|
||||
"KEY `idx_end_time` (`end_time`)" +
|
||||
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='缓存管理'"
|
||||
|
||||
case "sqlite":
|
||||
createSQL = `CREATE TABLE "` + tableName + `" (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"key" TEXT NOT NULL UNIQUE,
|
||||
"value" TEXT,
|
||||
"end_time" TEXT,
|
||||
"state" INTEGER DEFAULT 0,
|
||||
"create_time" TEXT,
|
||||
"modify_time" TEXT
|
||||
)`
|
||||
|
||||
case "postgres":
|
||||
createSQL = `CREATE TABLE "` + tableName + `" (
|
||||
"id" SERIAL PRIMARY KEY,
|
||||
"key" VARCHAR(64) NOT NULL UNIQUE,
|
||||
"value" TEXT,
|
||||
"end_time" TIMESTAMP,
|
||||
"state" INTEGER DEFAULT 0,
|
||||
"create_time" TIMESTAMP,
|
||||
"modify_time" TIMESTAMP
|
||||
)`
|
||||
that.Db.Exec(createSQL)
|
||||
that.Db.Exec(`CREATE INDEX "idx_` + tableName + `_end_time" ON "` + tableName + `" ("end_time")`)
|
||||
return
|
||||
|
||||
case "dm", "dameng":
|
||||
createSQL = `CREATE TABLE "` + tableName + `" (
|
||||
"id" INT IDENTITY(1,1) PRIMARY KEY,
|
||||
"key" VARCHAR(64) NOT NULL,
|
||||
"value" TEXT,
|
||||
"end_time" TIMESTAMP,
|
||||
"state" INT DEFAULT 0,
|
||||
"create_time" TIMESTAMP,
|
||||
"modify_time" TIMESTAMP,
|
||||
CONSTRAINT "uk_` + tableName + `_key" UNIQUE ("key")
|
||||
)`
|
||||
that.Db.Exec(createSQL)
|
||||
that.Db.Exec(`CREATE INDEX "idx_` + tableName + `_end_time" ON "` + tableName + `" ("end_time")`)
|
||||
return
|
||||
}
|
||||
|
||||
that.Db.Exec(createSQL)
|
||||
}
|
||||
|
||||
// createHistoryTable 创建历史表
|
||||
func (that *CacheDb) createHistoryTable(dbType, tableName string) {
|
||||
var createSQL string
|
||||
|
||||
switch dbType {
|
||||
case "mysql":
|
||||
createSQL = "CREATE TABLE `" + tableName + "` (" +
|
||||
"`id` int(11) unsigned NOT NULL AUTO_INCREMENT," +
|
||||
"`hotime_cache_id` int(11) unsigned DEFAULT NULL COMMENT '缓存ID'," +
|
||||
"`key` varchar(64) DEFAULT NULL COMMENT '缓存键'," +
|
||||
"`value` text DEFAULT NULL COMMENT '缓存值'," +
|
||||
"`end_time` datetime DEFAULT NULL COMMENT '过期时间'," +
|
||||
"`state` int(2) DEFAULT '0' COMMENT '状态:0-正常,1-异常,2-隐藏'," +
|
||||
"`create_time` datetime DEFAULT NULL COMMENT '创建日期'," +
|
||||
"`modify_time` datetime DEFAULT NULL COMMENT '变更时间'," +
|
||||
"PRIMARY KEY (`id`)," +
|
||||
"KEY `idx_hotime_cache_id` (`hotime_cache_id`)" +
|
||||
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='缓存历史'"
|
||||
|
||||
case "sqlite":
|
||||
createSQL = `CREATE TABLE "` + tableName + `" (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"hotime_cache_id" INTEGER,
|
||||
"key" TEXT,
|
||||
"value" TEXT,
|
||||
"end_time" TEXT,
|
||||
"state" INTEGER DEFAULT 0,
|
||||
"create_time" TEXT,
|
||||
"modify_time" TEXT
|
||||
)`
|
||||
|
||||
case "postgres":
|
||||
createSQL = `CREATE TABLE "` + tableName + `" (
|
||||
"id" SERIAL PRIMARY KEY,
|
||||
"hotime_cache_id" INTEGER,
|
||||
"key" VARCHAR(64),
|
||||
"value" TEXT,
|
||||
"end_time" TIMESTAMP,
|
||||
"state" INTEGER DEFAULT 0,
|
||||
"create_time" TIMESTAMP,
|
||||
"modify_time" TIMESTAMP
|
||||
)`
|
||||
that.Db.Exec(createSQL)
|
||||
that.Db.Exec(`CREATE INDEX "idx_` + tableName + `_cache_id" ON "` + tableName + `" ("hotime_cache_id")`)
|
||||
return
|
||||
|
||||
case "dm", "dameng":
|
||||
createSQL = `CREATE TABLE "` + tableName + `" (
|
||||
"id" INT IDENTITY(1,1) PRIMARY KEY,
|
||||
"hotime_cache_id" INT,
|
||||
"key" VARCHAR(64),
|
||||
"value" TEXT,
|
||||
"end_time" TIMESTAMP,
|
||||
"state" INT DEFAULT 0,
|
||||
"create_time" TIMESTAMP,
|
||||
"modify_time" TIMESTAMP
|
||||
)`
|
||||
that.Db.Exec(createSQL)
|
||||
that.Db.Exec(`CREATE INDEX "idx_` + tableName + `_cache_id" ON "` + tableName + `" ("hotime_cache_id")`)
|
||||
return
|
||||
}
|
||||
|
||||
that.Db.Exec(createSQL)
|
||||
}
|
||||
|
||||
// migrateFromCached 从旧 cached 表迁移数据(不删除老表,由人工删除)
|
||||
func (that *CacheDb) migrateFromCached(dbType, oldTableName, newTableName string) {
|
||||
var migrateSQL string
|
||||
|
||||
switch dbType {
|
||||
case "mysql":
|
||||
// 去重迁移:取每个 key 的最后一条记录(id 最大)
|
||||
// 使用 INSERT IGNORE 避免重复 key 冲突
|
||||
migrateSQL = "INSERT IGNORE INTO `" + newTableName + "` (`key`, `value`, `end_time`, `state`, `create_time`, `modify_time`) " +
|
||||
"SELECT c.`key`, c.`value`, FROM_UNIXTIME(c.`endtime`), 0, " +
|
||||
"FROM_UNIXTIME(c.`time` / 1000000000), FROM_UNIXTIME(c.`time` / 1000000000) " +
|
||||
"FROM `" + oldTableName + "` c " +
|
||||
"INNER JOIN (SELECT `key`, MAX(id) as max_id FROM `" + oldTableName + "` GROUP BY `key`) m " +
|
||||
"ON c.id = m.max_id"
|
||||
|
||||
case "sqlite":
|
||||
migrateSQL = `INSERT OR IGNORE INTO "` + newTableName + `" ("key", "value", "end_time", "state", "create_time", "modify_time") ` +
|
||||
`SELECT c."key", c."value", datetime(c."endtime", 'unixepoch'), 0, ` +
|
||||
`datetime(c."time" / 1000000000, 'unixepoch'), datetime(c."time" / 1000000000, 'unixepoch') ` +
|
||||
`FROM "` + oldTableName + `" c ` +
|
||||
`INNER JOIN (SELECT "key", MAX(id) as max_id FROM "` + oldTableName + `" GROUP BY "key") m ` +
|
||||
`ON c.id = m.max_id`
|
||||
|
||||
case "postgres":
|
||||
migrateSQL = `INSERT INTO "` + newTableName + `" ("key", "value", "end_time", "state", "create_time", "modify_time") ` +
|
||||
`SELECT c."key", c."value", to_timestamp(c."endtime"), 0, ` +
|
||||
`to_timestamp(c."time" / 1000000000), to_timestamp(c."time" / 1000000000) ` +
|
||||
`FROM "` + oldTableName + `" c ` +
|
||||
`INNER JOIN (SELECT "key", MAX(id) as max_id FROM "` + oldTableName + `" GROUP BY "key") m ` +
|
||||
`ON c.id = m.max_id ` +
|
||||
`ON CONFLICT ("key") DO NOTHING`
|
||||
|
||||
case "dm", "dameng":
|
||||
migrateSQL = `MERGE INTO "` + newTableName + `" t USING (` +
|
||||
`SELECT c."key", c."value", DATEADD(SECOND, c."endtime", TIMESTAMP '1970-01-01 00:00:00') AS "end_time", ` +
|
||||
`DATEADD(SECOND, c."time" / 1000000000, TIMESTAMP '1970-01-01 00:00:00') AS "create_time" ` +
|
||||
`FROM "` + oldTableName + `" c ` +
|
||||
`INNER JOIN (SELECT "key", MAX("id") AS max_id FROM "` + oldTableName + `" GROUP BY "key") m ` +
|
||||
`ON c."id" = m.max_id` +
|
||||
`) src ON (t."key" = src."key") ` +
|
||||
`WHEN NOT MATCHED THEN INSERT ("key", "value", "end_time", "state", "create_time", "modify_time") ` +
|
||||
`VALUES (src."key", src."value", src."end_time", 0, src."create_time", src."create_time")`
|
||||
}
|
||||
|
||||
that.Db.Exec(migrateSQL)
|
||||
}
|
||||
|
||||
// writeHistory 写入历史记录
|
||||
func (that *CacheDb) writeHistory(key string) {
|
||||
if !that.HistorySet {
|
||||
return
|
||||
}
|
||||
|
||||
tableName := that.getTableName()
|
||||
historyTableName := that.getHistoryTableName()
|
||||
|
||||
// 查询当前数据
|
||||
cached := that.Db.Get(tableName, "*", Map{"key": key})
|
||||
if cached == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 构建历史记录数据
|
||||
historyData := Map{
|
||||
"hotime_cache_id": cached.GetInt64("id"),
|
||||
"key": cached.GetString("key"),
|
||||
"value": cached.GetString("value"),
|
||||
"end_time": cached.GetString("end_time"),
|
||||
"state": cached.GetInt("state"),
|
||||
"create_time": cached.GetString("create_time"),
|
||||
"modify_time": cached.GetString("modify_time"),
|
||||
}
|
||||
|
||||
// 插入历史表
|
||||
that.Db.Insert(historyTableName, historyData)
|
||||
}
|
||||
|
||||
// getLegacy 从老表获取缓存(兼容模式使用)
|
||||
// 老表结构:key, value, endtime(unix秒), time(纳秒时间戳)
|
||||
func (that *CacheDb) getLegacy(key string) interface{} {
|
||||
legacyTableName := that.getLegacyTableName()
|
||||
|
||||
// 检查老表是否存在
|
||||
if !that.tableExists(legacyTableName) {
|
||||
return nil
|
||||
}
|
||||
|
||||
cached := that.Db.Get(legacyTableName, "*", Map{"key": key})
|
||||
if cached == nil {
|
||||
return nil
|
||||
}
|
||||
//data:=cacheMap[key];
|
||||
if cached.GetInt64("endtime") <= time.Now().Unix() {
|
||||
|
||||
that.Db.Delete("cached", Map{"id": cached.GetString("id")})
|
||||
// 检查过期时间(老表使用 unix 时间戳)
|
||||
endTime := cached.GetInt64("endtime")
|
||||
nowUnix := time.Now().Unix()
|
||||
if endTime > 0 && endTime <= nowUnix {
|
||||
// 已过期,删除该条记录
|
||||
that.deleteLegacy(key)
|
||||
return nil
|
||||
}
|
||||
|
||||
data := Map{}
|
||||
data.JsonToMap(cached.GetString("value"))
|
||||
|
||||
return data.Get("data")
|
||||
}
|
||||
|
||||
//key value ,时间为时间戳
|
||||
func (that *CacheDb) set(key string, value interface{}, tim int64) {
|
||||
|
||||
bte, _ := json.Marshal(Map{"data": value})
|
||||
|
||||
num := that.Db.Update("cached", Map{"value": string(bte), "time": time.Now().UnixNano(), "endtime": tim}, Map{"key": key})
|
||||
if num == int64(0) {
|
||||
that.Db.Insert("cached", Map{"value": string(bte), "time": time.Now().UnixNano(), "endtime": tim, "key": key})
|
||||
// 解析 value(老表 value 格式可能是 {"data": xxx} 或直接值)
|
||||
valueStr := cached.GetString("value")
|
||||
if valueStr == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
//随机执行删除命令
|
||||
if Rand(1000) > 950 {
|
||||
that.Db.Delete("cached", Map{"endtime[<]": time.Now().Unix()})
|
||||
data, err := JsonToObj(valueStr)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 兼容老版本 {"data": xxx} 包装格式
|
||||
if dataMap, ok := data.(map[string]interface{}); ok {
|
||||
if innerData, exists := dataMap["data"]; exists {
|
||||
return innerData
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
func (that *CacheDb) delete(key string) {
|
||||
// deleteLegacy 从老表删除缓存(兼容模式使用)
|
||||
func (that *CacheDb) deleteLegacy(key string) {
|
||||
legacyTableName := that.getLegacyTableName()
|
||||
|
||||
// 检查老表是否存在
|
||||
if !that.tableExists(legacyTableName) {
|
||||
return
|
||||
}
|
||||
|
||||
del := strings.Index(key, "*")
|
||||
//如果通配删除
|
||||
// 如果通配删除
|
||||
if del != -1 {
|
||||
key = Substr(key, 0, del)
|
||||
that.Db.Delete("cached", Map{"key": key + "%"})
|
||||
|
||||
keyPrefix := Substr(key, 0, del)
|
||||
that.Db.Delete(legacyTableName, Map{"key[~]": keyPrefix + "%"})
|
||||
} else {
|
||||
that.Db.Delete("cached", Map{"key": key})
|
||||
that.Db.Delete(legacyTableName, Map{"key": key})
|
||||
}
|
||||
}
|
||||
|
||||
func (that *CacheDb) Cache(key string, data ...interface{}) *Obj {
|
||||
// get 获取缓存
|
||||
func (that *CacheDb) get(key string) interface{} {
|
||||
tableName := that.getTableName()
|
||||
cached := that.Db.Get(tableName, "*", Map{"key": key})
|
||||
|
||||
if cached != nil {
|
||||
// 使用字符串比较判断过期(ISO 格式天然支持)
|
||||
endTime := cached.GetString("end_time")
|
||||
nowTime := Time2Str(time.Now())
|
||||
if endTime != "" && endTime <= nowTime {
|
||||
// 惰性删除:过期只返回 nil,不立即删除
|
||||
// 依赖随机清理批量删除过期数据
|
||||
// 继续检查老表(如果是兼容模式)
|
||||
} else {
|
||||
// 直接解析 value,不再需要 {"data": value} 包装
|
||||
valueStr := cached.GetString("value")
|
||||
if valueStr != "" {
|
||||
data, err := JsonToObj(valueStr)
|
||||
if err == nil {
|
||||
return data
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容模式:新表没有数据时,回退读取老表
|
||||
if that.isCompatibleMode() {
|
||||
return that.getLegacy(key)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// set 设置缓存
|
||||
func (that *CacheDb) set(key string, value interface{}, endTime time.Time) {
|
||||
// 直接序列化 value,不再包装
|
||||
bte, _ := json.Marshal(value)
|
||||
nowTime := Time2Str(time.Now())
|
||||
endTimeStr := Time2Str(endTime)
|
||||
|
||||
dbType := that.Db.GetType()
|
||||
tableName := that.getTableName()
|
||||
|
||||
// 使用 UPSERT 语法解决并发问题
|
||||
switch dbType {
|
||||
case "mysql":
|
||||
upsertSQL := "INSERT INTO `" + tableName + "` (`key`, `value`, `end_time`, `state`, `create_time`, `modify_time`) " +
|
||||
"VALUES (?, ?, ?, 0, ?, ?) " +
|
||||
"ON DUPLICATE KEY UPDATE `value`=VALUES(`value`), `end_time`=VALUES(`end_time`), `modify_time`=VALUES(`modify_time`)"
|
||||
that.Db.Exec(upsertSQL, key, string(bte), endTimeStr, nowTime, nowTime)
|
||||
|
||||
case "sqlite":
|
||||
// SQLite: INSERT OR REPLACE 会删除后插入,所以用 UPSERT 语法
|
||||
upsertSQL := `INSERT INTO "` + tableName + `" ("key", "value", "end_time", "state", "create_time", "modify_time") ` +
|
||||
`VALUES (?, ?, ?, 0, ?, ?) ` +
|
||||
`ON CONFLICT("key") DO UPDATE SET "value"=excluded."value", "end_time"=excluded."end_time", "modify_time"=excluded."modify_time"`
|
||||
that.Db.Exec(upsertSQL, key, string(bte), endTimeStr, nowTime, nowTime)
|
||||
|
||||
case "postgres":
|
||||
upsertSQL := `INSERT INTO "` + tableName + `" ("key", "value", "end_time", "state", "create_time", "modify_time") ` +
|
||||
`VALUES ($1, $2, $3, 0, $4, $5) ` +
|
||||
`ON CONFLICT ("key") DO UPDATE SET "value"=EXCLUDED."value", "end_time"=EXCLUDED."end_time", "modify_time"=EXCLUDED."modify_time"`
|
||||
that.Db.Exec(upsertSQL, key, string(bte), endTimeStr, nowTime, nowTime)
|
||||
|
||||
case "dm", "dameng":
|
||||
upsertSQL := `MERGE INTO "` + tableName + `" t USING (SELECT ? AS "key", ? AS "value", ? AS "end_time", ? AS "create_time", ? AS "modify_time") src ` +
|
||||
`ON (t."key" = src."key") ` +
|
||||
`WHEN MATCHED THEN UPDATE SET "value"=src."value", "end_time"=src."end_time", "modify_time"=src."modify_time" ` +
|
||||
`WHEN NOT MATCHED THEN INSERT ("key", "value", "end_time", "state", "create_time", "modify_time") ` +
|
||||
`VALUES (src."key", src."value", src."end_time", 0, src."create_time", src."modify_time")`
|
||||
that.Db.Exec(upsertSQL, key, string(bte), endTimeStr, nowTime, nowTime)
|
||||
|
||||
default:
|
||||
// 兼容其他数据库:使用 Update + Insert
|
||||
num := that.Db.Update(tableName, Map{
|
||||
"value": string(bte),
|
||||
"end_time": endTimeStr,
|
||||
"modify_time": nowTime,
|
||||
}, Map{"key": key})
|
||||
if num == 0 {
|
||||
that.Db.Insert(tableName, Map{
|
||||
"key": key,
|
||||
"value": string(bte),
|
||||
"end_time": endTimeStr,
|
||||
"state": 0,
|
||||
"create_time": nowTime,
|
||||
"modify_time": nowTime,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 写入历史记录
|
||||
that.writeHistory(key)
|
||||
|
||||
// 兼容模式:写新表后删除老表同 key 记录(加速老数据消亡)
|
||||
if that.isCompatibleMode() {
|
||||
that.deleteLegacy(key)
|
||||
}
|
||||
|
||||
// 随机执行删除过期数据命令(5% 概率)
|
||||
if Rand(1000) > 950 {
|
||||
nowTimeStr := Time2Str(time.Now())
|
||||
that.Db.Delete(tableName, Map{"end_time[<]": nowTimeStr})
|
||||
}
|
||||
}
|
||||
|
||||
// delete 删除缓存
|
||||
func (that *CacheDb) delete(key string) {
|
||||
tableName := that.getTableName()
|
||||
del := strings.Index(key, "*")
|
||||
// 如果通配删除
|
||||
if del != -1 {
|
||||
keyPrefix := Substr(key, 0, del)
|
||||
that.Db.Delete(tableName, Map{"key[~]": keyPrefix + "%"})
|
||||
} else {
|
||||
that.Db.Delete(tableName, Map{"key": key})
|
||||
}
|
||||
|
||||
// 兼容模式:同时删除老表中的 key(避免回退读到老数据)
|
||||
if that.isCompatibleMode() {
|
||||
that.deleteLegacy(key)
|
||||
}
|
||||
}
|
||||
|
||||
// Cache 缓存操作入口
|
||||
// 用法:
|
||||
// - Cache(key) - 获取缓存
|
||||
// - Cache(key, value) - 设置缓存(使用默认过期时间)
|
||||
// - Cache(key, value, timeout) - 设置缓存(指定过期时间,单位:秒)
|
||||
// - Cache(key, nil) - 删除缓存
|
||||
func (that *CacheDb) Cache(key string, data ...interface{}) *Obj {
|
||||
that.initDbTable()
|
||||
|
||||
// 获取缓存
|
||||
if len(data) == 0 {
|
||||
return &Obj{Data: that.get(key)}
|
||||
}
|
||||
tim := time.Now().Unix()
|
||||
|
||||
// 删除缓存
|
||||
if len(data) == 1 && data[0] == nil {
|
||||
that.delete(key)
|
||||
return &Obj{Data: nil}
|
||||
}
|
||||
|
||||
// 计算过期时间
|
||||
var timeout int64
|
||||
if len(data) == 1 {
|
||||
if that.TimeOut == 0 {
|
||||
//that.Time = Config.GetInt64("cacheLongTime")
|
||||
// 使用配置的 TimeOut,如果为 0 则使用默认值
|
||||
timeout = that.TimeOut
|
||||
if timeout == 0 {
|
||||
timeout = DefaultCacheTimeout
|
||||
}
|
||||
tim += that.TimeOut
|
||||
}
|
||||
if len(data) == 2 {
|
||||
that.SetError(nil)
|
||||
tempt := ObjToInt64(data[1], that.Error)
|
||||
|
||||
if tempt > tim {
|
||||
tim = tempt
|
||||
} else if that.GetError() == nil {
|
||||
|
||||
tim = tim + tempt
|
||||
} else if len(data) >= 2 {
|
||||
// 使用指定的超时时间
|
||||
var err Error
|
||||
tempTimeout := ObjToInt64(data[1], &err)
|
||||
if err.GetError() == nil && tempTimeout > 0 {
|
||||
timeout = tempTimeout
|
||||
} else {
|
||||
timeout = that.TimeOut
|
||||
if timeout == 0 {
|
||||
timeout = DefaultCacheTimeout
|
||||
}
|
||||
}
|
||||
}
|
||||
that.set(key, data[0], tim)
|
||||
|
||||
endTime := time.Now().Add(time.Duration(timeout) * time.Second)
|
||||
that.set(key, data[0], endTime)
|
||||
return &Obj{Data: nil}
|
||||
}
|
||||
|
||||
// CachesGet 批量获取缓存(使用 IN 查询优化)
|
||||
// 返回 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
+104
-119
@@ -2,157 +2,142 @@ package cache
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
"code.hoteas.com/golang/hotime/log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CacheMemory 基于 sync.Map 的缓存实现
|
||||
type CacheMemory struct {
|
||||
TimeOut int64
|
||||
DbSet bool
|
||||
SessionSet bool
|
||||
Map
|
||||
*Error
|
||||
ContextBase
|
||||
mutex *sync.RWMutex
|
||||
Log *log.Logger
|
||||
cache sync.Map
|
||||
}
|
||||
func (c *CacheMemory) get(key string) (res *Obj) {
|
||||
|
||||
func (that *CacheMemory) GetError() *Error {
|
||||
|
||||
return that.Error
|
||||
|
||||
}
|
||||
|
||||
func (that *CacheMemory) SetError(err *Error) {
|
||||
that.Error = err
|
||||
}
|
||||
|
||||
//获取Cache键只能为string类型
|
||||
func (that *CacheMemory) get(key string) interface{} {
|
||||
that.Error.SetError(nil)
|
||||
if that.Map == nil {
|
||||
that.Map = Map{}
|
||||
res = &Obj{}
|
||||
value, ok := c.cache.Load(key)
|
||||
if !ok {
|
||||
return res // 缓存不存在
|
||||
}
|
||||
|
||||
if that.Map[key] == nil {
|
||||
return nil
|
||||
}
|
||||
data := that.Map.Get(key, that.Error).(cacheData)
|
||||
if that.Error.GetError() != nil {
|
||||
return nil
|
||||
}
|
||||
data := value.(cacheData)
|
||||
|
||||
// 检查是否过期
|
||||
if data.time < time.Now().Unix() {
|
||||
delete(that.Map, key)
|
||||
return nil
|
||||
c.cache.Delete(key) // 删除过期缓存
|
||||
return res
|
||||
}
|
||||
return data.data
|
||||
res.Data = data.data
|
||||
return res
|
||||
}
|
||||
|
||||
func (that *CacheMemory) refreshMap() {
|
||||
|
||||
func (c *CacheMemory) set(key string, value interface{}, expireAt int64) {
|
||||
data := cacheData{
|
||||
data: value,
|
||||
time: expireAt,
|
||||
}
|
||||
c.cache.Store(key, data)
|
||||
}
|
||||
func (c *CacheMemory) delete(key string) {
|
||||
if strings.Contains(key, "*") {
|
||||
// 通配符删除
|
||||
prefix := strings.TrimSuffix(key, "*")
|
||||
c.cache.Range(func(k, v interface{}) bool {
|
||||
if strings.HasPrefix(k.(string), prefix) {
|
||||
c.cache.Delete(k)
|
||||
}
|
||||
return true
|
||||
})
|
||||
} else {
|
||||
// 精确删除
|
||||
c.cache.Delete(key)
|
||||
}
|
||||
}
|
||||
func (c *CacheMemory) refreshMap() {
|
||||
go func() {
|
||||
that.mutex.Lock()
|
||||
defer that.mutex.Unlock()
|
||||
for key, v := range that.Map {
|
||||
data := v.(cacheData)
|
||||
if data.time <= time.Now().Unix() {
|
||||
delete(that.Map, key)
|
||||
now := time.Now().Unix()
|
||||
c.cache.Range(func(key, value interface{}) bool {
|
||||
data := value.(cacheData)
|
||||
if data.time <= now {
|
||||
c.cache.Delete(key) // 删除过期缓存
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}()
|
||||
|
||||
}
|
||||
func (c *CacheMemory) Cache(key string, data ...interface{}) *Obj {
|
||||
now := time.Now().Unix()
|
||||
|
||||
//key value ,时间为时间戳
|
||||
func (that *CacheMemory) set(key string, value interface{}, time int64) {
|
||||
that.Error.SetError(nil)
|
||||
var data cacheData
|
||||
|
||||
if that.Map == nil {
|
||||
that.Map = Map{}
|
||||
// 随机触发刷新
|
||||
if x := RandX(1, 100000); x > 99950 {
|
||||
c.refreshMap()
|
||||
}
|
||||
|
||||
dd := that.Map[key]
|
||||
|
||||
if dd == nil {
|
||||
data = cacheData{}
|
||||
} else {
|
||||
data = dd.(cacheData)
|
||||
}
|
||||
|
||||
data.time = time
|
||||
data.data = value
|
||||
|
||||
that.Map.Put(key, data)
|
||||
}
|
||||
|
||||
func (that *CacheMemory) delete(key string) {
|
||||
del := strings.Index(key, "*")
|
||||
//如果通配删除
|
||||
if del != -1 {
|
||||
key = Substr(key, 0, del)
|
||||
for k, _ := range that.Map {
|
||||
if strings.Index(k, key) != -1 {
|
||||
delete(that.Map, k)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
delete(that.Map, key)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (that *CacheMemory) Cache(key string, data ...interface{}) *Obj {
|
||||
|
||||
x := RandX(1, 100000)
|
||||
if x > 99950 {
|
||||
that.refreshMap()
|
||||
}
|
||||
if that.mutex == nil {
|
||||
that.mutex = &sync.RWMutex{}
|
||||
}
|
||||
|
||||
reData := &Obj{Data: nil}
|
||||
|
||||
if len(data) == 0 {
|
||||
that.mutex.RLock()
|
||||
reData.Data = that.get(key)
|
||||
that.mutex.RUnlock()
|
||||
return reData
|
||||
// 读操作
|
||||
return c.get(key)
|
||||
}
|
||||
tim := time.Now().Unix()
|
||||
|
||||
if len(data) == 1 && data[0] == nil {
|
||||
that.mutex.Lock()
|
||||
that.delete(key)
|
||||
that.mutex.Unlock()
|
||||
return reData
|
||||
// 删除操作
|
||||
c.delete(key)
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(data) == 1 {
|
||||
|
||||
tim = tim + that.TimeOut
|
||||
|
||||
}
|
||||
// 写操作
|
||||
expireAt := now + c.TimeOut
|
||||
if len(data) == 2 {
|
||||
that.Error.SetError(nil)
|
||||
tempt := ObjToInt64(data[1], that.Error)
|
||||
|
||||
if tempt > tim {
|
||||
|
||||
tim = tempt
|
||||
} else if that.Error.GetError() == nil {
|
||||
|
||||
tim = tim + tempt
|
||||
if customExpire, ok := data[1].(int64); ok {
|
||||
if customExpire > now {
|
||||
expireAt = customExpire
|
||||
} else {
|
||||
expireAt = now + customExpire
|
||||
}
|
||||
}
|
||||
}
|
||||
that.mutex.Lock()
|
||||
that.set(key, data[0], tim)
|
||||
that.mutex.Unlock()
|
||||
return reData
|
||||
|
||||
c.set(key, data[0], expireAt)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CachesGet 批量获取缓存
|
||||
// 返回 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
+192
-80
@@ -2,8 +2,10 @@ package cache
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
"code.hoteas.com/golang/hotime/log"
|
||||
"github.com/garyburd/redigo/redis"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -14,23 +16,20 @@ type CacheRedis struct {
|
||||
Host string
|
||||
Pwd string
|
||||
Port int64
|
||||
conn redis.Conn
|
||||
pool *redis.Pool
|
||||
tag int64
|
||||
ContextBase
|
||||
*Error
|
||||
Log *log.Logger
|
||||
initOnce sync.Once
|
||||
}
|
||||
|
||||
func (that *CacheRedis) GetError() *Error {
|
||||
|
||||
return that.Error
|
||||
|
||||
func (that *CacheRedis) logErr(err error) {
|
||||
if err != nil && that.Log != nil {
|
||||
that.Log.Error().Err(err).Msg("redis cache error")
|
||||
}
|
||||
}
|
||||
|
||||
func (that *CacheRedis) SetError(err *Error) {
|
||||
that.Error = err
|
||||
}
|
||||
|
||||
//唯一标志
|
||||
// 唯一标志
|
||||
func (that *CacheRedis) GetTag() int64 {
|
||||
|
||||
if that.tag == int64(0) {
|
||||
@@ -39,83 +38,112 @@ func (that *CacheRedis) GetTag() int64 {
|
||||
return that.tag
|
||||
}
|
||||
|
||||
func (that *CacheRedis) reCon() bool {
|
||||
var err error
|
||||
that.conn, err = redis.Dial("tcp", that.Host+":"+ObjToStr(that.Port))
|
||||
if err != nil {
|
||||
that.conn = nil
|
||||
that.Error.SetError(err)
|
||||
return false
|
||||
}
|
||||
|
||||
if that.Pwd != "" {
|
||||
_, err = that.conn.Do("AUTH", that.Pwd)
|
||||
if err != nil {
|
||||
that.conn = nil
|
||||
that.Error.SetError(err)
|
||||
return false
|
||||
// initPool 初始化连接池(只执行一次)
|
||||
func (that *CacheRedis) initPool() {
|
||||
that.initOnce.Do(func() {
|
||||
that.pool = &redis.Pool{
|
||||
MaxIdle: 10, // 最大空闲连接数
|
||||
MaxActive: 100, // 最大活跃连接数,0表示无限制
|
||||
IdleTimeout: 5 * time.Minute, // 空闲连接超时时间
|
||||
Wait: true, // 当连接池耗尽时是否等待
|
||||
Dial: func() (redis.Conn, error) {
|
||||
conn, err := redis.Dial("tcp", that.Host+":"+ObjToStr(that.Port),
|
||||
redis.DialConnectTimeout(5*time.Second),
|
||||
redis.DialReadTimeout(3*time.Second),
|
||||
redis.DialWriteTimeout(3*time.Second),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if that.Pwd != "" {
|
||||
if _, err := conn.Do("AUTH", that.Pwd); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return conn, nil
|
||||
},
|
||||
TestOnBorrow: func(c redis.Conn, t time.Time) error {
|
||||
if time.Since(t) < time.Minute {
|
||||
return nil
|
||||
}
|
||||
_, err := c.Do("PING")
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// getConn 从连接池获取连接
|
||||
func (that *CacheRedis) getConn() redis.Conn {
|
||||
that.initPool()
|
||||
if that.pool == nil {
|
||||
return nil
|
||||
}
|
||||
return that.pool.Get()
|
||||
}
|
||||
|
||||
func (that *CacheRedis) del(key string) {
|
||||
conn := that.getConn()
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
del := strings.Index(key, "*")
|
||||
if del != -1 {
|
||||
val, err := redis.Strings(that.conn.Do("KEYS", key))
|
||||
val, err := redis.Strings(conn.Do("KEYS", key))
|
||||
if err != nil {
|
||||
that.logErr(err)
|
||||
return
|
||||
}
|
||||
that.conn.Send("MULTI")
|
||||
for i, _ := range val {
|
||||
that.conn.Send("DEL", val[i])
|
||||
if len(val) == 0 {
|
||||
return
|
||||
}
|
||||
that.conn.Do("EXEC")
|
||||
} else {
|
||||
_, err := that.conn.Do("DEL", key)
|
||||
conn.Send("MULTI")
|
||||
for i := range val {
|
||||
conn.Send("DEL", val[i])
|
||||
}
|
||||
_, err = conn.Do("EXEC")
|
||||
if err != nil {
|
||||
that.Error.SetError(err)
|
||||
_, err = that.conn.Do("PING")
|
||||
if err != nil {
|
||||
if that.reCon() {
|
||||
_, err = that.conn.Do("DEL", key)
|
||||
}
|
||||
}
|
||||
that.logErr(err)
|
||||
}
|
||||
} else {
|
||||
_, err := conn.Do("DEL", key)
|
||||
if err != nil {
|
||||
that.logErr(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//key value ,时间为时间戳
|
||||
func (that *CacheRedis) set(key string, value string, time int64) {
|
||||
_, err := that.conn.Do("SET", key, value, "EX", ObjToStr(time))
|
||||
if err != nil {
|
||||
// key value ,时间为时间戳
|
||||
func (that *CacheRedis) set(key string, value string, expireSeconds int64) {
|
||||
conn := that.getConn()
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
that.Error.SetError(err)
|
||||
_, err = that.conn.Do("PING")
|
||||
if err != nil {
|
||||
if that.reCon() {
|
||||
_, err = that.conn.Do("SET", key, value, "EX", ObjToStr(time))
|
||||
}
|
||||
}
|
||||
_, err := conn.Do("SET", key, value, "EX", ObjToStr(expireSeconds))
|
||||
if err != nil {
|
||||
that.logErr(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (that *CacheRedis) get(key string) *Obj {
|
||||
reData := &Obj{}
|
||||
conn := that.getConn()
|
||||
if conn == nil {
|
||||
return reData
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
var err error
|
||||
reData.Data, err = redis.String(that.conn.Do("GET", key))
|
||||
reData.Data, err = redis.String(conn.Do("GET", key))
|
||||
if err != nil {
|
||||
reData.Data = nil
|
||||
if !strings.Contains(err.Error(), "nil returned") {
|
||||
that.Error.SetError(err)
|
||||
_, err = that.conn.Do("PING")
|
||||
if err != nil {
|
||||
if that.reCon() {
|
||||
reData.Data, err = redis.String(that.conn.Do("GET", key))
|
||||
}
|
||||
}
|
||||
|
||||
return reData
|
||||
that.logErr(err)
|
||||
}
|
||||
}
|
||||
return reData
|
||||
@@ -123,19 +151,13 @@ func (that *CacheRedis) get(key string) *Obj {
|
||||
|
||||
func (that *CacheRedis) Cache(key string, data ...interface{}) *Obj {
|
||||
reData := &Obj{}
|
||||
if that.conn == nil {
|
||||
re := that.reCon()
|
||||
if !re {
|
||||
return reData
|
||||
}
|
||||
}
|
||||
|
||||
//查询缓存
|
||||
if len(data) == 0 {
|
||||
|
||||
reData = that.get(key)
|
||||
return reData
|
||||
|
||||
}
|
||||
|
||||
tim := int64(0)
|
||||
//删除缓存
|
||||
if len(data) == 1 && data[0] == nil {
|
||||
@@ -144,21 +166,16 @@ func (that *CacheRedis) Cache(key string, data ...interface{}) *Obj {
|
||||
}
|
||||
//添加缓存
|
||||
if len(data) == 1 {
|
||||
|
||||
if that.TimeOut == 0 {
|
||||
//that.Time = Config.GetInt64("cacheShortTime")
|
||||
}
|
||||
|
||||
tim += that.TimeOut
|
||||
}
|
||||
if len(data) == 2 {
|
||||
that.Error.SetError(nil)
|
||||
tempt := ObjToInt64(data[1], that.Error)
|
||||
tempt := ObjToInt64(data[1])
|
||||
if tempt > tim {
|
||||
|
||||
tim = tempt
|
||||
} else if that.GetError() == nil {
|
||||
|
||||
} else {
|
||||
tim = tim + tempt
|
||||
}
|
||||
}
|
||||
@@ -166,5 +183,100 @@ func (that *CacheRedis) Cache(key string, data ...interface{}) *Obj {
|
||||
that.set(key, ObjToStr(data[0]), tim)
|
||||
|
||||
return reData
|
||||
|
||||
}
|
||||
|
||||
// CachesGet 批量获取缓存(使用 Redis MGET 命令优化)
|
||||
// 返回 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
+4
-6
@@ -5,15 +5,13 @@ import (
|
||||
)
|
||||
|
||||
type CacheIns interface {
|
||||
//set(key string, value interface{}, time int64)
|
||||
//get(key string) interface{}
|
||||
//delete(key string)
|
||||
GetError() *Error
|
||||
SetError(err *Error)
|
||||
Cache(key string, data ...interface{}) *Obj
|
||||
CachesGet(keys []string) Map
|
||||
CachesSet(data Map, timeout ...int64)
|
||||
CachesDelete(keys []string)
|
||||
}
|
||||
|
||||
//单条缓存数据
|
||||
// 单条缓存数据
|
||||
type cacheData struct {
|
||||
time int64
|
||||
data interface{}
|
||||
|
||||
@@ -8,10 +8,14 @@ import (
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var adminLoginRateLimiter sync.Map // key: ip,value: *[]int64(1分钟内登录时间戳列表)
|
||||
|
||||
// Project 管理端项目
|
||||
var TptProject = Proj{
|
||||
//"user": UserCtr,
|
||||
@@ -22,16 +26,18 @@ var TptProject = Proj{
|
||||
tableName := that.RouterString[1]
|
||||
|
||||
data := that.Db.Get(fileConfig.GetString("table"), "*", Map{"id": that.Session(fileConfig.GetString("table") + "_id").ToCeilInt()})
|
||||
str, inData := that.MakeCodeRouter[hotimeName].Info(tableName, data, that.Db)
|
||||
//str, inData := that.MakeCodeRouter[hotimeName].Info(tableName, data, that.Db)
|
||||
str, _ := that.MakeCodeRouter[hotimeName].Info(tableName, data, that.Db)
|
||||
where := Map{"id": that.RouterString[2]}
|
||||
|
||||
if len(inData) == 1 {
|
||||
inData["id"] = where["id"]
|
||||
where = Map{"AND": inData}
|
||||
} else if len(inData) > 1 {
|
||||
where["OR"] = inData
|
||||
where = Map{"AND": where}
|
||||
}
|
||||
//权限控制,暂时取消
|
||||
//if len(inData) == 1 {
|
||||
// inData["id"] = where["id"]
|
||||
// where = Map{"AND": inData}
|
||||
//} else if len(inData) > 1 {
|
||||
// where["OR"] = inData
|
||||
// where = Map{"AND": where}
|
||||
//}
|
||||
|
||||
re := that.Db.Get(tableName, str, where)
|
||||
|
||||
@@ -684,6 +690,168 @@ var TptProject = Proj{
|
||||
|
||||
that.Display(0, Map{"count": count, "data": reData})
|
||||
},
|
||||
"analyse": func(that *Context) {
|
||||
hotimeName := that.RouterString[0]
|
||||
//hotimeName := "super"
|
||||
fileConfig := that.MakeCodeRouter[hotimeName].FileConfig
|
||||
tableName := that.RouterString[1]
|
||||
data := that.Db.Get(fileConfig.GetString("table"), "*", Map{"id": that.Session(fileConfig.GetString("table") + "_id").ToCeilInt()})
|
||||
where := Map{}
|
||||
|
||||
//树状结构不允许修改自身的属性,修改别人的可以
|
||||
btes, err := ioutil.ReadFile(fileConfig.GetString("config"))
|
||||
if err != nil {
|
||||
that.Display(4, "找不到权限配置文件")
|
||||
return
|
||||
}
|
||||
|
||||
conf := ObjToMap(string(btes))
|
||||
//相同则
|
||||
//if tableName!=fileConfig.GetString("table"){
|
||||
|
||||
flow := conf.GetMap("flow").GetMap(tableName)
|
||||
if flow != nil && flow.GetMap("sql") != nil {
|
||||
sql := ObjToMap(DeepCopyMap(flow.GetMap("sql")))
|
||||
for k, _ := range sql {
|
||||
//for uk,_:=range data{
|
||||
// if sql[uk]==nil{
|
||||
// continue
|
||||
// }
|
||||
// uv:=data.GetString(uk)
|
||||
//
|
||||
// tv:=strings.Replace(sql.GetString(uk),uk,uv,-1)
|
||||
// if tv!=uv{
|
||||
// where[k]=tv
|
||||
// break
|
||||
// }
|
||||
//}
|
||||
if k == "parent_ids[~]" && tableName == fileConfig.GetString("table") {
|
||||
where[k] = strings.Replace(sql.GetString(k), "id", data.GetString("id"), -1)
|
||||
continue
|
||||
}
|
||||
if k == "parent_ids[~]" && data[tableName+"_id"] != nil {
|
||||
where[k] = strings.Replace(sql.GetString(k), tableName+"_id", data.GetString(tableName+"_id"), -1)
|
||||
continue
|
||||
}
|
||||
if data[sql.GetString(k)] != nil {
|
||||
where[k] = data[sql.GetString(k)]
|
||||
}
|
||||
}
|
||||
}
|
||||
//}
|
||||
|
||||
_, where = that.MakeCodeRouter[hotimeName].Search(tableName, data, where, that.Req, that.Db)
|
||||
delete(where, "ORDER")
|
||||
//page := ObjToInt(that.Req.FormValue("page"))
|
||||
//pageSize := ObjToInt(that.Req.FormValue("pageSize"))
|
||||
|
||||
//download := ObjToInt(that.Req.FormValue("download"))
|
||||
//
|
||||
//if page < 1 {
|
||||
// page = 1
|
||||
//}
|
||||
//
|
||||
//if pageSize <= 0 {
|
||||
// pageSize = 20
|
||||
//}
|
||||
redData := Map{}
|
||||
|
||||
data["count"] = that.Db.Count(tableName, where)
|
||||
testQu := []string{}
|
||||
testQuData := that.MakeCodeRouter[hotimeName].TableColumns[tableName]
|
||||
for key := range testQuData {
|
||||
testQu = append(testQu, key)
|
||||
}
|
||||
sort.Strings(testQu)
|
||||
|
||||
// 第一步:扫描所有字段,构建聚合表达式列表和元数据
|
||||
type selectOptMeta struct {
|
||||
ops Slice
|
||||
vals []string
|
||||
}
|
||||
caseParts := []string{}
|
||||
selectMetas := map[string]*selectOptMeta{}
|
||||
sumFieldKeys := []string{}
|
||||
hasAreaId := false
|
||||
|
||||
for _, k := range testQu {
|
||||
v := testQuData[k]
|
||||
if v.GetBool("notUse") { continue }
|
||||
if strings.Contains(k, "state") { continue }
|
||||
if strings.Contains(k, "scope") { continue }
|
||||
if strings.Contains(k, "_code") { continue }
|
||||
|
||||
switch v.GetString("type") {
|
||||
case "select":
|
||||
ops := v.GetSlice("options")
|
||||
sm := &selectOptMeta{ops: ops}
|
||||
for k1 := range ops {
|
||||
v1 := ops.GetMap(k1)
|
||||
val := v1.GetString("value")
|
||||
sm.vals = append(sm.vals, val)
|
||||
safeVal := strings.ReplaceAll(val, "'", "''")
|
||||
alias := fmt.Sprintf("__sel__%s__%d", k, k1)
|
||||
caseParts = append(caseParts,
|
||||
fmt.Sprintf("SUM(CASE WHEN `%s`='%s' THEN 1 ELSE 0 END) AS `%s`", k, safeVal, alias))
|
||||
}
|
||||
selectMetas[k] = sm
|
||||
|
||||
case "number":
|
||||
if strings.Contains(k, "area_id") { hasAreaId = true; continue }
|
||||
if strings.Contains(k, "_id") { continue }
|
||||
if k == "id" { continue }
|
||||
if strings.Contains(k, "percent") { continue }
|
||||
if strings.Contains(k, "exp_") { continue }
|
||||
if strings.Contains(k, "side") { continue }
|
||||
if strings.Contains(k, "lat") { continue }
|
||||
if strings.Contains(k, "lng") { continue }
|
||||
if strings.Contains(k, "distance") { continue }
|
||||
alias := "__sum__" + k
|
||||
caseParts = append(caseParts, fmt.Sprintf("SUM(`%s`) AS `%s`", k, alias))
|
||||
sumFieldKeys = append(sumFieldKeys, k)
|
||||
}
|
||||
}
|
||||
|
||||
// 第二步:单次聚合查询替代原来 N×M+K 次串行查询
|
||||
if len(caseParts) > 0 {
|
||||
aggRow := that.Db.Get(tableName, strings.Join(caseParts, ","), where)
|
||||
if aggRow != nil {
|
||||
// 回填 select 字段各选项的 count
|
||||
for k, sm := range selectMetas {
|
||||
for k1 := range sm.ops {
|
||||
v1 := sm.ops.GetMap(k1)
|
||||
alias := fmt.Sprintf("__sel__%s__%d", k, k1)
|
||||
v1["count"] = aggRow.GetCeilInt64(alias)
|
||||
sm.ops[k1] = v1
|
||||
}
|
||||
redData[k] = sm.ops
|
||||
}
|
||||
// 回填 number 字段的 sum
|
||||
for _, k := range sumFieldKeys {
|
||||
alias := "__sum__" + k
|
||||
redData[k] = aggRow[alias]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 第三步:area 分析用单次 Select + Go 侧统计替代原来逐 area Count 循环
|
||||
if hasAreaId {
|
||||
areas := that.Db.Select("area", "id,name", Map{"parent_id": 533301})
|
||||
areaIdRows := that.Db.Select(tableName, "area_id", where)
|
||||
areaCountMap := map[int64]int64{}
|
||||
for _, row := range areaIdRows {
|
||||
areaCountMap[row.GetCeilInt64("area_id")]++
|
||||
}
|
||||
for ak, av := range areas {
|
||||
av["count"] = areaCountMap[av.GetCeilInt64("id")]
|
||||
areas[ak] = av
|
||||
}
|
||||
redData["area"] = areas
|
||||
}
|
||||
|
||||
that.Display(0, redData)
|
||||
|
||||
},
|
||||
},
|
||||
"hotime": Ctr{
|
||||
"file": func(that *Context) {
|
||||
@@ -794,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
|
||||
|
||||
@@ -847,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")
|
||||
@@ -875,11 +1063,12 @@ var TptProject = Proj{
|
||||
}
|
||||
|
||||
linkAuthMap := that.Db.Get(v.GetString("link"), "auth", Map{"id": user.GetCeilInt(v.GetString("name"))})
|
||||
linkAuth := linkAuthMap.GetMap("auth")
|
||||
oldLinkAuth := linkAuthMap.GetMap("auth")
|
||||
linkAuth := Map{}
|
||||
//conf := ObjToMap(string(btes))
|
||||
//menus := conf.GetSlice("menus")
|
||||
if linkAuth == nil {
|
||||
linkAuth = Map{}
|
||||
if oldLinkAuth != nil {
|
||||
linkAuth = oldLinkAuth
|
||||
}
|
||||
|
||||
for k1, _ := range menus {
|
||||
@@ -890,7 +1079,7 @@ var TptProject = Proj{
|
||||
}
|
||||
if v1["auth"] != nil {
|
||||
|
||||
if linkAuth[name] == nil {
|
||||
if oldLinkAuth == nil {
|
||||
linkAuth[name] = v1["auth"]
|
||||
} else {
|
||||
newAuth := Slice{}
|
||||
@@ -913,7 +1102,7 @@ var TptProject = Proj{
|
||||
}
|
||||
if v2["auth"] != nil {
|
||||
|
||||
if linkAuth[name] == nil {
|
||||
if oldLinkAuth == nil {
|
||||
linkAuth[name] = v2["auth"]
|
||||
|
||||
} else {
|
||||
@@ -935,32 +1124,36 @@ var TptProject = Proj{
|
||||
|
||||
for k1, _ := range menus {
|
||||
v1 := menus.GetMap(k1)
|
||||
//保证个人权限可用
|
||||
if fileConfig.GetString("table") == v1.GetString("table") {
|
||||
v1["auth"] = Slice{"info", "edit"}
|
||||
}
|
||||
|
||||
name := v1.GetString("name")
|
||||
if name == "" {
|
||||
name = v1.GetString("table")
|
||||
}
|
||||
|
||||
if linkAuth[name] != nil {
|
||||
if len(linkAuth.GetSlice(name)) != 0 {
|
||||
v1["auth"] = linkAuth[name]
|
||||
} else
|
||||
//保证个人权限可用
|
||||
if fileConfig.GetString("table") == v1.GetString("table") {
|
||||
v1["auth"] = Slice{"info", "edit"}
|
||||
} else {
|
||||
v1["auth"] = linkAuth[name]
|
||||
}
|
||||
|
||||
v1menus := v1.GetSlice("menus")
|
||||
for k2, _ := range v1menus {
|
||||
v2 := v1menus.GetMap(k2)
|
||||
//保证个人权限可用
|
||||
if fileConfig.GetString("table") == v2.GetString("table") {
|
||||
v2["auth"] = Slice{"info", "edit"}
|
||||
|
||||
}
|
||||
name := v2.GetString("name")
|
||||
if name == "" {
|
||||
name = v2.GetString("table")
|
||||
}
|
||||
if linkAuth[name] != nil {
|
||||
if len(linkAuth.GetSlice(name)) != 0 {
|
||||
v2["auth"] = linkAuth[name]
|
||||
} else if fileConfig.GetString("table") == v2.GetString("table") {
|
||||
v2["auth"] = Slice{"info", "edit"}
|
||||
|
||||
} else {
|
||||
v2["auth"] = linkAuth[name]
|
||||
}
|
||||
|
||||
|
||||
+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 {
|
||||
|
||||
+117
-45
@@ -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{}
|
||||
@@ -116,17 +116,28 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
|
||||
nowTables = db.Select("INFORMATION_SCHEMA.TABLES", "TABLE_NAME as name,TABLE_COMMENT as label", Map{"TABLE_SCHEMA": db.DBName})
|
||||
}
|
||||
if db.Type == "sqlite" {
|
||||
nowTables = db.Select("sqlite_sequence", "name")
|
||||
nowTables = db.Select("sqlite_master", "name", Map{"type": "table"})
|
||||
}
|
||||
if db.Type == "dm" || db.Type == "dameng" {
|
||||
nowTables = db.Query(`SELECT TABLE_NAME AS "name", COMMENTS AS "label" FROM ALL_TAB_COMMENTS WHERE TABLE_TYPE='TABLE' AND OWNER='` + db.DBName + `'`)
|
||||
}
|
||||
//idSlice=append(idSlice,nowTables)
|
||||
for _, v := range nowTables {
|
||||
if v.GetString("name") == "cached" {
|
||||
continue
|
||||
}
|
||||
// if v.GetString("name") == "cached" {
|
||||
// continue
|
||||
// }
|
||||
if that.TableConfig.GetMap(v.GetString("name")) == nil {
|
||||
if v.GetString("label") == "" {
|
||||
v["label"] = v.GetString("name")
|
||||
}
|
||||
if strings.HasSuffix(v.GetString("label"), "表") {
|
||||
withoutTable := strings.TrimSuffix(v.GetString("label"), "表")
|
||||
if len([]rune(withoutTable)) <= 3 {
|
||||
v["label"] = withoutTable + "管理"
|
||||
} else {
|
||||
v["label"] = withoutTable
|
||||
}
|
||||
}
|
||||
that.TableConfig[v.GetString("name")] = Map{
|
||||
"label": v.GetString("label"),
|
||||
"table": v.GetString("name"),
|
||||
@@ -158,11 +169,14 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
|
||||
|
||||
tableInfo := make([]Map, 0)
|
||||
if db.Type == "mysql" {
|
||||
tableInfo = db.Select("INFORMATION_SCHEMA.COLUMNS", "COLUMN_NAME AS name,COLUMN_TYPE AS type,COLUMN_COMMENT AS label", Map{"AND": Map{"TABLE_SCHEMA": db.DBName, "TABLE_NAME": v.GetString("name")}})
|
||||
tableInfo = db.Select("INFORMATION_SCHEMA.COLUMNS", "COLUMN_NAME AS name,COLUMN_TYPE AS type,COLUMN_COMMENT AS label,IS_NULLABLE AS must,COLUMN_DEFAULT AS dflt_value", Map{"AND": Map{"TABLE_SCHEMA": db.DBName, "TABLE_NAME": v.GetString("name")}, "ORDER": "ORDINAL_POSITION"})
|
||||
}
|
||||
if db.Type == "sqlite" {
|
||||
tableInfo = db.Query("pragma table_info([" + v.GetString("name") + "]);")
|
||||
}
|
||||
if db.Type == "dm" || db.Type == "dameng" {
|
||||
tableInfo = db.Query(`SELECT c.COLUMN_NAME AS "name", c.DATA_TYPE AS "type", m.COMMENTS AS "label", c.NULLABLE AS "must", c.DATA_DEFAULT AS "dflt_value" FROM ALL_TAB_COLUMNS c LEFT JOIN USER_COL_COMMENTS m ON c.TABLE_NAME=m.TABLE_NAME AND c.COLUMN_NAME=m.COLUMN_NAME WHERE c.TABLE_NAME='` + v.GetString("name") + `' AND c.OWNER='` + db.DBName + `' ORDER BY c.COLUMN_ID`)
|
||||
}
|
||||
|
||||
idSlice = append(idSlice, tableInfo)
|
||||
for _, info1 := range tableInfo {
|
||||
@@ -175,8 +189,9 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
|
||||
if coloum == nil {
|
||||
//根据类型判断真实类型
|
||||
for k, v1 := range ColumnDataType {
|
||||
if strings.Contains(info.GetString("name"), k) || strings.Contains(info.GetString("type"), k) {
|
||||
if strings.Contains(strings.ToLower(info.GetString("type")), k) {
|
||||
info["type"] = v1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,23 +203,39 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
|
||||
//"must": false,
|
||||
}
|
||||
|
||||
//备注以空格隔开,空格后的是其他备注
|
||||
indexNum := strings.Index(info.GetString("label"), " ")
|
||||
if indexNum > -1 {
|
||||
coloum["label"] = info.GetString("label")[:indexNum]
|
||||
}
|
||||
psStart := strings.Index(info.GetString("label"), "{")
|
||||
psEnd := strings.Index(info.GetString("label"), "}")
|
||||
if psStart != -1 && psEnd > psStart {
|
||||
coloum["ps"] = info.GetString("label")[psStart+1 : psEnd]
|
||||
if info.GetString("must") == "NO" {
|
||||
coloum["must"] = true
|
||||
}
|
||||
|
||||
//去除数据信息,是用:号分割的
|
||||
indexNum = strings.Index(coloum.GetString("label"), ":")
|
||||
if indexNum > -1 {
|
||||
coloum["label"] = coloum.GetString("label")[:indexNum]
|
||||
if info["dflt_value"] != nil {
|
||||
coloum["default"] = info["dflt_value"]
|
||||
}
|
||||
|
||||
if info["pk"] != nil && info.GetCeilInt64("pk") == 1 {
|
||||
coloum["must"] = true
|
||||
}
|
||||
|
||||
//提取括号内的提示信息:支持 ()、()、{}
|
||||
rawLabel := info.GetString("label")
|
||||
for _, pair := range [][2]string{{"(", ")"}, {"(", ")"}, {"{", "}"}} {
|
||||
start := strings.Index(rawLabel, pair[0])
|
||||
end := strings.Index(rawLabel, pair[1])
|
||||
if start != -1 && end > start {
|
||||
coloum["ps"] = rawLabel[start+len(pair[0]) : end]
|
||||
rawLabel = rawLabel[:start] + rawLabel[end+len(pair[1]):]
|
||||
break
|
||||
}
|
||||
}
|
||||
//空格截断,空格后的内容丢弃
|
||||
if idx := strings.Index(rawLabel, " "); idx > -1 {
|
||||
rawLabel = rawLabel[:idx]
|
||||
}
|
||||
//冒号截断,去除选项数据部分
|
||||
if idx := strings.Index(rawLabel, ":"); idx > -1 {
|
||||
rawLabel = rawLabel[:idx]
|
||||
}
|
||||
coloum["label"] = rawLabel
|
||||
|
||||
for _, ColumnName := range that.RuleConfig {
|
||||
if (ColumnName.GetBool("strict") && coloum.GetString("name") == ColumnName.GetString("name")) ||
|
||||
(!ColumnName.GetBool("strict") && strings.Contains(coloum.GetString("name"), ColumnName.GetString("name"))) ||
|
||||
@@ -218,7 +249,15 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
|
||||
coloum["edit"] = ColumnName.GetBool("edit")
|
||||
coloum["add"] = ColumnName["add"]
|
||||
coloum["list"] = ColumnName.GetBool("list")
|
||||
coloum["must"] = ColumnName.GetBool("must")
|
||||
//coloum["must"] = ColumnName.GetBool("must")
|
||||
|
||||
if coloum["must"] == nil {
|
||||
coloum["must"] = ColumnName.GetBool("must")
|
||||
} else {
|
||||
if ColumnName["must"] != nil {
|
||||
coloum["must"] = ColumnName.GetBool("must")
|
||||
}
|
||||
}
|
||||
|
||||
if ColumnName.GetBool("info") {
|
||||
delete(coloum, "info")
|
||||
@@ -284,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
|
||||
}
|
||||
|
||||
//暂时不关闭参数,保证表数据完全读取到
|
||||
@@ -302,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
|
||||
}
|
||||
@@ -539,7 +597,7 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
|
||||
}
|
||||
sql := flow.GetMap(fk).GetMap("sql")
|
||||
if k == "parent_id" {
|
||||
sql["parent_ids[~]"] = "%," + av.GetString("name") + ",%"
|
||||
sql["parent_ids[~]"] = "," + av.GetString("name") + ","
|
||||
} else {
|
||||
sql[k] = k
|
||||
}
|
||||
@@ -580,7 +638,7 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
|
||||
flow[fk] = Map{"table": fk, "stop": false, "sql": Map{}}
|
||||
}
|
||||
sql := flow.GetMap(fk).GetMap("sql")
|
||||
sql["parent_ids[~]"] = "%,id,%"
|
||||
sql["parent_ids[~]"] = ",id,"
|
||||
flow.GetMap(fk)["sql"] = sql
|
||||
}
|
||||
|
||||
@@ -598,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 失败")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -609,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"))
|
||||
|
||||
}
|
||||
|
||||
@@ -625,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("有新的代码生成,请重新运行")
|
||||
@@ -776,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
|
||||
}
|
||||
@@ -786,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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -835,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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1048,15 +1106,18 @@ func (that *MakeCode) Search(table string, userData Map, data Map, req *http.Req
|
||||
if len(reqValue) == 0 || reqValue[0] == "" {
|
||||
|
||||
if parent_idsStr != "" && userData[searchItem.GetString("name")] != nil {
|
||||
|
||||
if data[searchItemName] == nil {
|
||||
continue
|
||||
}
|
||||
where := Map{parent_idsStr: "," + ObjToStr(userData.GetCeilInt64(searchItem.GetString("name"))) + ","}
|
||||
r := db.Select(searchItem.GetString("link"), "id", where)
|
||||
reqValue = []string{}
|
||||
for _, v := range r {
|
||||
reqValue = append(reqValue, v.GetString("id"))
|
||||
}
|
||||
if data[searchItemName] != nil {
|
||||
data[searchItemName] = reqValue
|
||||
}
|
||||
|
||||
data[searchItemName] = reqValue
|
||||
|
||||
}
|
||||
|
||||
@@ -1112,11 +1173,13 @@ func (that *MakeCode) Search(table string, userData Map, data Map, req *http.Req
|
||||
if keywordTableStr == v.GetString("value") {
|
||||
childs := db.Select(v.GetString("link"), "id", Map{v.GetString("value") + "[~]": keywordStr})
|
||||
childIds := Slice{}
|
||||
|
||||
for _, cv := range childs {
|
||||
childIds = append(childIds, cv.GetString("id"))
|
||||
|
||||
childIds = append(childIds, cv.GetCeilInt64("id"))
|
||||
}
|
||||
if len(childIds) != 0 {
|
||||
data[v.GetString("link")+".id"] = childIds
|
||||
data[v.GetString("name")] = childIds
|
||||
}
|
||||
}
|
||||
continue
|
||||
@@ -1134,10 +1197,10 @@ func (that *MakeCode) Search(table string, userData Map, data Map, req *http.Req
|
||||
childs := db.Select(v.GetString("link"), "id", Map{v.GetString("value") + "[~]": keywordStr})
|
||||
childIds := Slice{}
|
||||
for _, cv := range childs {
|
||||
childIds = append(childIds, cv.GetString("id"))
|
||||
childIds = append(childIds, cv.GetCeilInt64("id"))
|
||||
}
|
||||
if len(childIds) != 0 {
|
||||
keyword[v.GetString("link")+".id"] = childIds
|
||||
keyword[v.GetString("name")] = childIds
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1146,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
|
||||
@@ -1237,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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ type ContextBase struct {
|
||||
tag string
|
||||
}
|
||||
|
||||
//唯一标志
|
||||
// 唯一标志
|
||||
func (that *ContextBase) GetTag() string {
|
||||
|
||||
if that.tag == "" {
|
||||
|
||||
+48
-16
@@ -1,27 +1,59 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"github.com/sirupsen/logrus"
|
||||
"code.hoteas.com/golang/hotime/log"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Error 框架层处理错误
|
||||
// Error 框架基础错误类型 —— 线程安全的 error 封装。
|
||||
// 通过组合(嵌入)方式在 Obj、DBError 等结构体中使用,提供统一的错误 API。
|
||||
// 也用于 ObjToXxx / Map.GetXxx 等工具函数的可选错误报告参数。
|
||||
// 设置 Logger 后,SetError 会自动将非 nil 错误写入日志。
|
||||
type Error struct {
|
||||
Logger *logrus.Logger
|
||||
error
|
||||
mu sync.RWMutex
|
||||
Logger *log.Logger
|
||||
}
|
||||
|
||||
func (that *Error) GetError() error {
|
||||
|
||||
return that.error
|
||||
|
||||
}
|
||||
|
||||
func (that *Error) SetError(err error) {
|
||||
that.error = err
|
||||
if that.Logger != nil && err != nil {
|
||||
//that.Logger=log.GetLog("",false)
|
||||
that.Logger.Warn(err)
|
||||
// Error 安全实现 error 接口 —— 内部 error 为 nil 时返回 "" 而非 panic
|
||||
func (that *Error) Error() string {
|
||||
that.mu.RLock()
|
||||
defer that.mu.RUnlock()
|
||||
if that.error == nil {
|
||||
return ""
|
||||
}
|
||||
return that.error.Error()
|
||||
}
|
||||
|
||||
// HasError 快捷判断是否存在错误
|
||||
func (that *Error) HasError() bool {
|
||||
that.mu.RLock()
|
||||
defer that.mu.RUnlock()
|
||||
return that.error != nil
|
||||
}
|
||||
|
||||
// Unwrap 支持 errors.Is / errors.As 标准错误链
|
||||
func (that *Error) Unwrap() error {
|
||||
that.mu.RLock()
|
||||
defer that.mu.RUnlock()
|
||||
return that.error
|
||||
}
|
||||
|
||||
// GetError 获取内部 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 err != nil && logger != nil {
|
||||
logger.Error().Err(err).Msg("")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
+1
-1
@@ -141,7 +141,7 @@ func Substr(str string, start int, length int) string {
|
||||
}
|
||||
|
||||
// IndexLastStr 获取最后出现字符串的下标
|
||||
//return 找不到返回 -1
|
||||
// return 找不到返回 -1
|
||||
func IndexLastStr(str, sep string) int {
|
||||
sepSlice := []rune(sep)
|
||||
strSlice := []rune(str)
|
||||
|
||||
+29
-16
@@ -1,6 +1,7 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
@@ -8,10 +9,10 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
//hotime的常用map
|
||||
// hotime的常用map
|
||||
type Map map[string]interface{}
|
||||
|
||||
//获取string
|
||||
// 获取string
|
||||
func (that Map) GetString(key string, err ...*Error) string {
|
||||
|
||||
if len(err) != 0 {
|
||||
@@ -26,7 +27,7 @@ func (that *Map) Pointer() *Map {
|
||||
return that
|
||||
}
|
||||
|
||||
//增加接口
|
||||
// 增加接口
|
||||
func (that Map) Put(key string, value interface{}) {
|
||||
//if that==nil{
|
||||
// that=Map{}
|
||||
@@ -34,13 +35,13 @@ func (that Map) Put(key string, value interface{}) {
|
||||
that[key] = value
|
||||
}
|
||||
|
||||
//删除接口
|
||||
// 删除接口
|
||||
func (that Map) Delete(key string) {
|
||||
delete(that, key)
|
||||
|
||||
}
|
||||
|
||||
//获取Int
|
||||
// 获取Int
|
||||
func (that Map) GetInt(key string, err ...*Error) int {
|
||||
v := ObjToInt((that)[key], err...)
|
||||
|
||||
@@ -48,35 +49,35 @@ func (that Map) GetInt(key string, err ...*Error) int {
|
||||
|
||||
}
|
||||
|
||||
//获取Int
|
||||
// 获取Int
|
||||
func (that Map) GetInt64(key string, err ...*Error) int64 {
|
||||
v := ObjToInt64((that)[key], err...)
|
||||
return v
|
||||
|
||||
}
|
||||
|
||||
//获取向上取整Int64
|
||||
// 获取向上取整Int64
|
||||
func (that Map) GetCeilInt64(key string, err ...*Error) int64 {
|
||||
v := ObjToCeilInt64((that)[key], err...)
|
||||
return v
|
||||
|
||||
}
|
||||
|
||||
//获取向上取整Int
|
||||
// 获取向上取整Int
|
||||
func (that Map) GetCeilInt(key string, err ...*Error) int {
|
||||
v := ObjToCeilInt((that)[key], err...)
|
||||
return v
|
||||
|
||||
}
|
||||
|
||||
//获取向上取整float64
|
||||
// 获取向上取整float64
|
||||
func (that Map) GetCeilFloat64(key string, err ...*Error) float64 {
|
||||
v := ObjToCeilFloat64((that)[key], err...)
|
||||
return v
|
||||
|
||||
}
|
||||
|
||||
//获取Float64
|
||||
// 获取Float64
|
||||
func (that Map) GetFloat64(key string, err ...*Error) float64 {
|
||||
|
||||
v := ObjToFloat64((that)[key], err...)
|
||||
@@ -102,7 +103,7 @@ func (that Map) GetBool(key string, err ...*Error) bool {
|
||||
|
||||
}
|
||||
|
||||
func (that Map) GetTime(key string, err ...*Error) time.Time {
|
||||
func (that Map) GetTime(key string, err ...*Error) *time.Time {
|
||||
|
||||
v := ObjToTime((that)[key], err...)
|
||||
return v
|
||||
@@ -149,7 +150,7 @@ func (that Map) Get(key string, err ...*Error) interface{} {
|
||||
return nil
|
||||
}
|
||||
|
||||
//请传递指针过来
|
||||
// 请传递指针过来
|
||||
func (that Map) ToStruct(stct interface{}) {
|
||||
|
||||
data := reflect.ValueOf(stct).Elem()
|
||||
@@ -181,9 +182,21 @@ func (that Map) ToJsonString() string {
|
||||
}
|
||||
|
||||
func (that Map) JsonToMap(jsonStr string, err ...*Error) {
|
||||
e := json.Unmarshal([]byte(jsonStr), &that)
|
||||
if e != nil && len(err) != 0 {
|
||||
err[0].SetError(e)
|
||||
dec := json.NewDecoder(bytes.NewReader([]byte(jsonStr)))
|
||||
dec.UseNumber()
|
||||
e := dec.Decode(&that)
|
||||
if e != nil {
|
||||
if len(err) != 0 {
|
||||
err[0].SetError(e)
|
||||
}
|
||||
return
|
||||
}
|
||||
for k, v := range that {
|
||||
that[k] = fixJsonNumbers(v)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// GetRoundFloat64 获取四舍五入到 precision 位小数的 float64
|
||||
func (that Map) GetRoundFloat64(key string, precision int, err ...*Error) float64 {
|
||||
return ObjToRoundFloat64((that)[key], precision, err...)
|
||||
}
|
||||
|
||||
+9
-4
@@ -2,7 +2,7 @@ package common
|
||||
|
||||
import "time"
|
||||
|
||||
//对象封装方便取用
|
||||
// 对象封装方便取用
|
||||
type Obj struct {
|
||||
Data interface{}
|
||||
Error
|
||||
@@ -20,7 +20,7 @@ func (that *Obj) ToInt(err ...Error) int {
|
||||
return ObjToInt(that.Data, &that.Error)
|
||||
}
|
||||
|
||||
func (that *Obj) ToTime(err ...Error) time.Time {
|
||||
func (that *Obj) ToTime(err ...Error) *time.Time {
|
||||
if len(err) != 0 {
|
||||
that.Error = err[0]
|
||||
}
|
||||
@@ -82,7 +82,7 @@ func (that *Obj) ToObj() interface{} {
|
||||
return that.Data
|
||||
}
|
||||
|
||||
//获取向上取整Int64
|
||||
// 获取向上取整Int64
|
||||
func (that *Obj) ToCeilInt64(err ...*Error) int64 {
|
||||
if len(err) != 0 {
|
||||
that.Error = *err[0]
|
||||
@@ -92,7 +92,7 @@ func (that *Obj) ToCeilInt64(err ...*Error) int64 {
|
||||
|
||||
}
|
||||
|
||||
//获取向上取整Int
|
||||
// 获取向上取整Int
|
||||
func (that *Obj) ToCeilInt(err ...*Error) int {
|
||||
if len(err) != 0 {
|
||||
that.Error = *err[0]
|
||||
@@ -101,3 +101,8 @@ func (that *Obj) ToCeilInt(err ...*Error) int {
|
||||
return v
|
||||
|
||||
}
|
||||
|
||||
// ToRoundFloat64 获取四舍五入到 precision 位小数的 float64
|
||||
func (that *Obj) ToRoundFloat64(precision int, err ...*Error) float64 {
|
||||
return ObjToRoundFloat64(that.Data, precision, err...)
|
||||
}
|
||||
|
||||
+272
-126
@@ -1,14 +1,16 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
//仅限于hotime.Slice
|
||||
// 仅限于hotime.Slice
|
||||
func ObjToMap(obj interface{}, e ...*Error) Map {
|
||||
var err error
|
||||
var v Map
|
||||
@@ -17,31 +19,40 @@ func ObjToMap(obj interface{}, e ...*Error) Map {
|
||||
v = nil
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
} else {
|
||||
switch obj.(type) {
|
||||
switch val := obj.(type) {
|
||||
case Map:
|
||||
v = obj.(Map)
|
||||
v = val
|
||||
case map[string]interface{}:
|
||||
v = obj.(map[string]interface{})
|
||||
v = Map(val)
|
||||
case string:
|
||||
v = Map{}
|
||||
e := json.Unmarshal([]byte(obj.(string)), &v)
|
||||
if e != nil {
|
||||
err = errors.New("没有合适的转换对象!" + e.Error())
|
||||
result, e2 := JsonToObj(val)
|
||||
if e2 != nil {
|
||||
err = errors.New("没有合适的转换对象!" + e2.Error())
|
||||
v = nil
|
||||
} else if m, ok := result.(map[string]interface{}); ok {
|
||||
v = m
|
||||
} else {
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
v = nil
|
||||
}
|
||||
default:
|
||||
data, err := json.Marshal(obj)
|
||||
var data []byte
|
||||
data, err = json.Marshal(obj)
|
||||
if err != nil {
|
||||
err = errors.New("没有合适的转换对象!" + err.Error())
|
||||
v = nil
|
||||
break
|
||||
}
|
||||
v = Map{}
|
||||
e := json.Unmarshal(data, &v)
|
||||
if e != nil {
|
||||
err = errors.New("没有合适的转换对象!" + e.Error())
|
||||
result, e2 := JsonToObj(string(data))
|
||||
if e2 != nil {
|
||||
err = errors.New("没有合适的转换对象!" + e2.Error())
|
||||
v = nil
|
||||
} else if m, ok := result.(map[string]interface{}); ok {
|
||||
v = m
|
||||
} else {
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
v = nil
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
if len(e) != 0 {
|
||||
@@ -52,14 +63,14 @@ func ObjToMap(obj interface{}, e ...*Error) Map {
|
||||
|
||||
func ObjToMapArray(obj interface{}, e ...*Error) []Map {
|
||||
s := ObjToSlice(obj, e...)
|
||||
res := []Map{}
|
||||
res := make([]Map, 0, len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
res = append(res, s.GetMap(i))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
//仅限于hotime.Slice
|
||||
// 仅限于hotime.Slice
|
||||
func ObjToSlice(obj interface{}, e ...*Error) Slice {
|
||||
var err error
|
||||
var v Slice
|
||||
@@ -68,25 +79,39 @@ func ObjToSlice(obj interface{}, e ...*Error) Slice {
|
||||
v = nil
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
} else {
|
||||
switch obj.(type) {
|
||||
switch val := obj.(type) {
|
||||
case Slice:
|
||||
v = obj.(Slice)
|
||||
v = val
|
||||
case []interface{}:
|
||||
v = obj.([]interface{})
|
||||
v = Slice(val)
|
||||
case []string:
|
||||
v = Slice{}
|
||||
for i := 0; i < len(obj.([]string)); i++ {
|
||||
v = append(v, obj.([]string)[i])
|
||||
v = make(Slice, len(val))
|
||||
for i, s := range val {
|
||||
v[i] = s
|
||||
}
|
||||
case string:
|
||||
v = Slice{}
|
||||
err = json.Unmarshal([]byte(obj.(string)), &v)
|
||||
|
||||
result, e2 := JsonToObj(val)
|
||||
if e2 != nil {
|
||||
err = e2
|
||||
} else if s, ok := result.([]interface{}); ok {
|
||||
v = s
|
||||
} else {
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
}
|
||||
default:
|
||||
v = Slice{}
|
||||
var data []byte
|
||||
data, err = json.Marshal(obj)
|
||||
err = json.Unmarshal(data, &v)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
result, e2 := JsonToObj(string(data))
|
||||
if e2 != nil {
|
||||
err = e2
|
||||
} else if s, ok := result.([]interface{}); ok {
|
||||
v = s
|
||||
} else {
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,66 +122,86 @@ func ObjToSlice(obj interface{}, e ...*Error) Slice {
|
||||
return v
|
||||
}
|
||||
|
||||
func ObjToTime(obj interface{}, e ...*Error) time.Time {
|
||||
func ObjToTime(obj interface{}, e ...*Error) *time.Time {
|
||||
|
||||
tInt := ObjToInt64(obj)
|
||||
//字符串类型,只支持标准mysql datetime格式
|
||||
if tInt == 0 {
|
||||
tStr := ObjToStr(obj)
|
||||
// 先分离日期和时间部分,再对日期各段补零,避免时间中的数字被误处理
|
||||
datePart := tStr
|
||||
timePart := ""
|
||||
if idx := strings.Index(tStr, " "); idx > 0 {
|
||||
datePart = tStr[:idx]
|
||||
timePart = tStr[idx:] // 含前导空格
|
||||
}
|
||||
dateSegs := strings.Split(datePart, "-")
|
||||
for i, seg := range dateSegs {
|
||||
if seg == "" {
|
||||
continue
|
||||
}
|
||||
if len(seg) == 1 {
|
||||
dateSegs[i] = "0" + seg
|
||||
}
|
||||
}
|
||||
tStr = strings.Join(dateSegs, "-") + timePart
|
||||
|
||||
if len(tStr) > 18 {
|
||||
t, e := time.Parse("2006-01-02 15:04:05", tStr)
|
||||
if e == nil {
|
||||
return t
|
||||
return &t
|
||||
}
|
||||
} else if len(tStr) > 15 {
|
||||
t, e := time.Parse("2006-01-02 15:04", tStr)
|
||||
if e == nil {
|
||||
return t
|
||||
return &t
|
||||
}
|
||||
} else if len(tStr) > 12 {
|
||||
t, e := time.Parse("2006-01-02 15", tStr)
|
||||
if e == nil {
|
||||
return t
|
||||
return &t
|
||||
}
|
||||
} else if len(tStr) > 9 {
|
||||
t, e := time.Parse("2006-01-02", tStr)
|
||||
if e == nil {
|
||||
return t
|
||||
return &t
|
||||
}
|
||||
} else if len(tStr) > 6 {
|
||||
t, e := time.Parse("2006-01", tStr)
|
||||
if e == nil {
|
||||
return t
|
||||
return &t
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 缓存字符串,避免重复调用
|
||||
tIntStr := ObjToStr(tInt)
|
||||
tIntLen := len(tIntStr)
|
||||
|
||||
//纳秒级别
|
||||
if len(ObjToStr(tInt)) > 16 {
|
||||
t := time.Time{}.Add(time.Nanosecond * time.Duration(tInt))
|
||||
return t
|
||||
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))
|
||||
return t
|
||||
} 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))
|
||||
return t
|
||||
} 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))
|
||||
return t
|
||||
} else if len(ObjToStr(tInt)) > 3 {
|
||||
t, e := time.Parse("2006", ObjToStr(tInt))
|
||||
} else if tIntLen > 9 {
|
||||
t := time.Unix(tInt, 0)
|
||||
return &t
|
||||
} else if tIntLen > 3 {
|
||||
t, e := time.Parse("2006", tIntStr)
|
||||
if e == nil {
|
||||
return t
|
||||
return &t
|
||||
}
|
||||
}
|
||||
|
||||
return time.Time{}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ObjToFloat64(obj interface{}, e ...*Error) float64 {
|
||||
@@ -164,37 +209,54 @@ func ObjToFloat64(obj interface{}, e ...*Error) float64 {
|
||||
v := float64(0)
|
||||
|
||||
if obj == nil {
|
||||
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
} else {
|
||||
|
||||
switch obj.(type) {
|
||||
switch val := obj.(type) {
|
||||
case int:
|
||||
v = float64(obj.(int))
|
||||
v = float64(val)
|
||||
case int8:
|
||||
v = float64(val)
|
||||
case int16:
|
||||
v = float64(val)
|
||||
case int32:
|
||||
v = float64(val)
|
||||
case int64:
|
||||
v = float64(obj.(int64))
|
||||
v = float64(val)
|
||||
case uint:
|
||||
v = float64(val)
|
||||
case uint8:
|
||||
v = float64(val)
|
||||
case uint16:
|
||||
v = float64(val)
|
||||
case uint32:
|
||||
v = float64(val)
|
||||
case uint64:
|
||||
v = float64(val)
|
||||
case bool:
|
||||
if val {
|
||||
v = 1
|
||||
}
|
||||
case string:
|
||||
value, e := strconv.ParseFloat(obj.(string), 64)
|
||||
if e != nil {
|
||||
v = float64(0)
|
||||
err = e
|
||||
value, e2 := strconv.ParseFloat(val, 64)
|
||||
if e2 != nil {
|
||||
err = e2
|
||||
} else {
|
||||
v = value
|
||||
}
|
||||
case float64:
|
||||
v = obj.(float64)
|
||||
v = val
|
||||
case float32:
|
||||
v = float64(obj.(float32))
|
||||
case uint8:
|
||||
value, e := strconv.ParseFloat(obj.(string), 64)
|
||||
if e != nil {
|
||||
v = float64(0)
|
||||
err = e
|
||||
// 通过 string 中转避免 float32→float64 精度膨胀(如 float32(1.2) 直接转得 1.200000047...)
|
||||
v, _ = strconv.ParseFloat(strconv.FormatFloat(float64(val), 'f', -1, 32), 64)
|
||||
case []byte:
|
||||
// 数据库 driver 有时以 []byte 返回数值字符串
|
||||
value, e2 := strconv.ParseFloat(string(val), 64)
|
||||
if e2 != nil {
|
||||
err = e2
|
||||
} else {
|
||||
v = value
|
||||
}
|
||||
default:
|
||||
v = float64(0)
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
}
|
||||
}
|
||||
@@ -214,25 +276,33 @@ func ObjToFloat64(obj interface{}, e ...*Error) float64 {
|
||||
return v
|
||||
}
|
||||
|
||||
//向上取整
|
||||
func ObjToCeilInt64(obj interface{}, e ...*Error) int64 {
|
||||
f := ObjToCeilFloat64(obj, e...)
|
||||
return ObjToInt64(math.Ceil(f))
|
||||
|
||||
// ObjToRoundFloat64 将 obj 转为 float64 后四舍五入到 precision 位小数。
|
||||
// precision < 0 时不做舍入,直接返回原始 float64。
|
||||
func ObjToRoundFloat64(obj interface{}, precision int, e ...*Error) float64 {
|
||||
f := ObjToFloat64(obj, e...)
|
||||
if precision < 0 {
|
||||
return f
|
||||
}
|
||||
pow := math.Pow10(precision)
|
||||
return math.Round(f*pow) / pow
|
||||
}
|
||||
|
||||
//向上取整
|
||||
// 向上取整
|
||||
func ObjToCeilInt64(obj interface{}, e ...*Error) int64 {
|
||||
f := ObjToCeilFloat64(obj, e...)
|
||||
return int64(f)
|
||||
}
|
||||
|
||||
// 向上取整
|
||||
func ObjToCeilFloat64(obj interface{}, e ...*Error) float64 {
|
||||
f := ObjToFloat64(obj, e...)
|
||||
return math.Ceil(f)
|
||||
|
||||
}
|
||||
|
||||
//向上取整
|
||||
// 向上取整
|
||||
func ObjToCeilInt(obj interface{}, e ...*Error) int {
|
||||
f := ObjToCeilFloat64(obj, e...)
|
||||
return ObjToInt(f)
|
||||
|
||||
}
|
||||
|
||||
func ObjToInt64(obj interface{}, e ...*Error) int64 {
|
||||
@@ -240,36 +310,63 @@ func ObjToInt64(obj interface{}, e ...*Error) int64 {
|
||||
v := int64(0)
|
||||
|
||||
if obj == nil {
|
||||
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
} else {
|
||||
switch obj.(type) {
|
||||
switch val := obj.(type) {
|
||||
case int:
|
||||
v = int64(obj.(int))
|
||||
v = int64(val)
|
||||
case int8:
|
||||
v = int64(val)
|
||||
case int16:
|
||||
v = int64(val)
|
||||
case int32:
|
||||
v = int64(val)
|
||||
case int64:
|
||||
v = obj.(int64)
|
||||
v = val
|
||||
case uint:
|
||||
v = int64(val)
|
||||
case uint8:
|
||||
v = int64(val)
|
||||
case uint16:
|
||||
v = int64(val)
|
||||
case uint32:
|
||||
v = int64(val)
|
||||
case uint64:
|
||||
v = int64(val)
|
||||
case bool:
|
||||
if val {
|
||||
v = 1
|
||||
}
|
||||
case string:
|
||||
value, e := StrToInt(obj.(string))
|
||||
if e != nil {
|
||||
v = int64(0)
|
||||
err = e
|
||||
value, e2 := StrToInt(val)
|
||||
if e2 != nil {
|
||||
// fallback:处理 "1.20" 这类带小数点的整数字符串
|
||||
if fv, fe := strconv.ParseFloat(val, 64); fe == nil {
|
||||
v = int64(fv)
|
||||
} else {
|
||||
err = e2
|
||||
}
|
||||
} else {
|
||||
v = int64(value)
|
||||
}
|
||||
case uint8:
|
||||
value, e := StrToInt(obj.(string))
|
||||
if e != nil {
|
||||
v = int64(0)
|
||||
err = e
|
||||
case []byte:
|
||||
// 数据库 driver 有时以 []byte 返回整数字符串
|
||||
value, e2 := StrToInt(string(val))
|
||||
if e2 != nil {
|
||||
// fallback:处理 "1.20" 这类带小数点的整数字符串
|
||||
if fv, fe := strconv.ParseFloat(string(val), 64); fe == nil {
|
||||
v = int64(fv)
|
||||
} else {
|
||||
err = e2
|
||||
}
|
||||
} else {
|
||||
v = int64(value)
|
||||
}
|
||||
case float64:
|
||||
v = int64(obj.(float64))
|
||||
v = int64(val)
|
||||
case float32:
|
||||
v = int64(obj.(float32))
|
||||
v = int64(val)
|
||||
default:
|
||||
v = int64(0)
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
}
|
||||
}
|
||||
@@ -289,18 +386,19 @@ func ObjToBool(obj interface{}, e ...*Error) bool {
|
||||
v := false
|
||||
|
||||
if obj == nil {
|
||||
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
} else {
|
||||
switch obj.(type) {
|
||||
switch val := obj.(type) {
|
||||
case bool:
|
||||
v = obj.(bool)
|
||||
v = val
|
||||
default:
|
||||
toInt := ObjToInt(obj)
|
||||
if toInt != 0 {
|
||||
var intErr Error
|
||||
toInt := ObjToInt(obj, &intErr)
|
||||
if intErr.GetError() != nil {
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
} else if toInt != 0 {
|
||||
v = true
|
||||
}
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
}
|
||||
}
|
||||
if len(e) != 0 {
|
||||
@@ -310,55 +408,107 @@ func ObjToBool(obj interface{}, e ...*Error) bool {
|
||||
}
|
||||
|
||||
func ObjToStr(obj interface{}) string {
|
||||
// fmt.Println(reflect.ValueOf(obj).Type().String() )
|
||||
str := ""
|
||||
if obj == nil {
|
||||
return str
|
||||
}
|
||||
switch obj.(type) {
|
||||
switch val := obj.(type) {
|
||||
case int:
|
||||
str = strconv.Itoa(obj.(int))
|
||||
str = strconv.Itoa(val)
|
||||
case int8:
|
||||
str = strconv.Itoa(int(val))
|
||||
case int16:
|
||||
str = strconv.Itoa(int(val))
|
||||
case int32:
|
||||
str = strconv.Itoa(int(val))
|
||||
case uint8:
|
||||
str = obj.(string)
|
||||
str = strconv.Itoa(int(val))
|
||||
case uint16:
|
||||
str = strconv.Itoa(int(val))
|
||||
case uint32:
|
||||
str = strconv.FormatUint(uint64(val), 10)
|
||||
case uint64:
|
||||
str = strconv.FormatUint(val, 10)
|
||||
case int64:
|
||||
str = strconv.FormatInt(obj.(int64), 10)
|
||||
str = strconv.FormatInt(val, 10)
|
||||
case []byte:
|
||||
str = string(obj.([]byte))
|
||||
str = string(val)
|
||||
case string:
|
||||
str = obj.(string)
|
||||
str = val
|
||||
case float64:
|
||||
str = strconv.FormatFloat(obj.(float64), 'f', -1, 64)
|
||||
str = strconv.FormatFloat(val, 'f', -1, 64)
|
||||
case float32:
|
||||
str = strconv.FormatFloat(float64(val), 'f', -1, 32)
|
||||
case bool:
|
||||
str = strconv.FormatBool(val)
|
||||
case time.Time:
|
||||
// DM8等数据库驱动直接返回time.Time,格式化为MySQL兼容的字符串
|
||||
str = val.Format("2006-01-02 15:04:05")
|
||||
default:
|
||||
strbte, err := json.MarshalIndent(obj, "", "\t")
|
||||
|
||||
if err == nil {
|
||||
str = string(strbte)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
//转换为Map
|
||||
func StrToMap(string string) Map {
|
||||
// JsonToObj 将 JSON 字符串反序列化为 interface{},保留数字原始类型。
|
||||
// 标准 json.Unmarshal 会把所有 JSON 数字变成 float64,本函数保留整数为 int64、小数为 float64。
|
||||
//
|
||||
// JsonToObj(`{"id":2,"price":1.20}`) → map{"id": int64(2), "price": float64(1.2)}
|
||||
// JsonToObj(`[1, 2.5, "a"]`) → []interface{}{int64(1), float64(2.5), "a"}
|
||||
func JsonToObj(jsonStr string) (interface{}, error) {
|
||||
dec := json.NewDecoder(bytes.NewReader([]byte(jsonStr)))
|
||||
dec.UseNumber()
|
||||
var result interface{}
|
||||
if err := dec.Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fixJsonNumbers(result), nil
|
||||
}
|
||||
|
||||
// fixJsonNumbers 递归将 json.Number 转为 int64 或 float64
|
||||
func fixJsonNumbers(v interface{}) interface{} {
|
||||
switch val := v.(type) {
|
||||
case json.Number:
|
||||
if n, err := val.Int64(); err == nil {
|
||||
return n
|
||||
}
|
||||
if f, err := val.Float64(); err == nil {
|
||||
return f
|
||||
}
|
||||
return string(val)
|
||||
case map[string]interface{}:
|
||||
for k, item := range val {
|
||||
val[k] = fixJsonNumbers(item)
|
||||
}
|
||||
return val
|
||||
case []interface{}:
|
||||
for i, item := range val {
|
||||
val[i] = fixJsonNumbers(item)
|
||||
}
|
||||
return val
|
||||
default:
|
||||
return val
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为Map
|
||||
func StrToMap(s string) Map {
|
||||
data := Map{}
|
||||
data.JsonToMap(string)
|
||||
|
||||
data.JsonToMap(s)
|
||||
return data
|
||||
}
|
||||
|
||||
//转换为Slice
|
||||
func StrToSlice(string string) Slice {
|
||||
|
||||
data := ObjToSlice(string)
|
||||
|
||||
return data
|
||||
// 转换为Slice
|
||||
func StrToSlice(s string) Slice {
|
||||
return ObjToSlice(s)
|
||||
}
|
||||
|
||||
//字符串数组: a1,a2,a3转["a1","a2","a3"]
|
||||
// 字符串数组: a1,a2,a3转["a1","a2","a3"]
|
||||
func StrArrayToJsonStr(a string) string {
|
||||
|
||||
if len(a) > 2 {
|
||||
if a[0] == ',' {
|
||||
a = Substr(a, 1, len(a)-1)
|
||||
@@ -366,7 +516,6 @@ func StrArrayToJsonStr(a string) string {
|
||||
if a[len(a)-1] == ',' {
|
||||
a = Substr(a, 0, len(a)-1)
|
||||
}
|
||||
//a = strings.Replace(a, ",", `,`, -1)
|
||||
a = `[` + a + `]`
|
||||
} else {
|
||||
a = "[]"
|
||||
@@ -374,19 +523,16 @@ func StrArrayToJsonStr(a string) string {
|
||||
return a
|
||||
}
|
||||
|
||||
//字符串数组: a1,a2,a3转["a1","a2","a3"]
|
||||
// 字符串数组: ["a1","a2","a3"]转,a1,a2,a3,
|
||||
func JsonStrToStrArray(a string) string {
|
||||
//a = strings.Replace(a, `"`, "", -1)
|
||||
if len(a) != 0 {
|
||||
a = Substr(a, 1, len(a)-2)
|
||||
}
|
||||
|
||||
return "," + a + ","
|
||||
}
|
||||
|
||||
//字符串转int
|
||||
// 字符串转int
|
||||
func StrToInt(s string) (int, error) {
|
||||
i, err := strconv.Atoi(s)
|
||||
return i, err
|
||||
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
+6
-1
@@ -15,7 +15,7 @@ func (that Slice) GetString(key int, err ...*Error) string {
|
||||
return ObjToStr((that)[key])
|
||||
}
|
||||
|
||||
func (that Slice) GetTime(key int, err ...*Error) time.Time {
|
||||
func (that Slice) GetTime(key int, err ...*Error) *time.Time {
|
||||
|
||||
v := ObjToTime((that)[key], err...)
|
||||
return v
|
||||
@@ -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
|
||||
|
||||
+108
-4
@@ -1,12 +1,17 @@
|
||||
package hotime
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
. "code.hoteas.com/golang/hotime/db"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
. "code.hoteas.com/golang/hotime/db"
|
||||
)
|
||||
|
||||
type Context struct {
|
||||
@@ -23,6 +28,10 @@ type Context struct {
|
||||
SessionIns
|
||||
DataSize int
|
||||
HandlerStr string //复写请求url
|
||||
|
||||
// 请求参数缓存
|
||||
reqJsonCache Map // JSON Body 缓存
|
||||
reqMu sync.Once // 确保 JSON 只解析一次
|
||||
}
|
||||
|
||||
// Mtd 唯一标志
|
||||
@@ -33,7 +42,7 @@ func (that *Context) Mtd(router [3]string) Map {
|
||||
return d
|
||||
}
|
||||
|
||||
//打印
|
||||
// 打印
|
||||
func (that *Context) Display(statu int, data interface{}) {
|
||||
|
||||
resp := Map{"status": statu}
|
||||
@@ -50,6 +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
|
||||
}
|
||||
@@ -100,5 +112,97 @@ func (that *Context) View() {
|
||||
that.DataSize = len(d)
|
||||
that.RespData = nil
|
||||
that.Resp.Write(d)
|
||||
return
|
||||
}
|
||||
|
||||
// ==================== 请求参数获取方法 ====================
|
||||
|
||||
// ReqParam 获取 URL 查询参数,返回 *Obj 支持链式调用
|
||||
// 用法: that.ReqParam("id").ToInt()
|
||||
func (that *Context) ReqParam(key string) *Obj {
|
||||
v := that.Req.URL.Query().Get(key)
|
||||
if v == "" {
|
||||
return &Obj{Data: nil}
|
||||
}
|
||||
return &Obj{Data: v}
|
||||
}
|
||||
|
||||
// ReqForm 获取表单参数,返回 *Obj 支持链式调用
|
||||
// 用法: that.ReqForm("name").ToStr()
|
||||
func (that *Context) ReqForm(key string) *Obj {
|
||||
v := that.Req.FormValue(key)
|
||||
if v == "" {
|
||||
return &Obj{Data: nil}
|
||||
}
|
||||
return &Obj{Data: v}
|
||||
}
|
||||
|
||||
// ReqJson 获取 JSON Body 中的字段,返回 *Obj 支持链式调用
|
||||
// 用法: that.ReqJson("data").ToMap()
|
||||
func (that *Context) ReqJson(key string) *Obj {
|
||||
that.parseJsonBody()
|
||||
if that.reqJsonCache == nil {
|
||||
return &Obj{Data: nil}
|
||||
}
|
||||
return &Obj{Data: that.reqJsonCache.Get(key)}
|
||||
}
|
||||
|
||||
// parseJsonBody 解析 JSON Body(只解析一次,并发安全)
|
||||
func (that *Context) parseJsonBody() {
|
||||
that.reqMu.Do(func() {
|
||||
if that.Req.Body == nil {
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(that.Req.Body)
|
||||
if err != nil || len(body) == 0 {
|
||||
return
|
||||
}
|
||||
// 恢复 Body 以便后续代码可以再次读取
|
||||
that.Req.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||
that.reqJsonCache = ObjToMap(string(body))
|
||||
})
|
||||
}
|
||||
|
||||
// ReqData 统一获取请求参数,优先级: JSON > Form > URL
|
||||
// 用法: that.ReqData("id").ToInt()
|
||||
func (that *Context) ReqData(key string) *Obj {
|
||||
// 1. 优先从 JSON Body 获取
|
||||
that.parseJsonBody()
|
||||
if that.reqJsonCache != nil {
|
||||
if v := that.reqJsonCache.Get(key); v != nil {
|
||||
return &Obj{Data: v}
|
||||
}
|
||||
}
|
||||
// 2. 其次从 Form 获取
|
||||
if v := that.Req.FormValue(key); v != "" {
|
||||
return &Obj{Data: v}
|
||||
}
|
||||
// 3. 最后从 URL 参数获取
|
||||
if v := that.Req.URL.Query().Get(key); v != "" {
|
||||
return &Obj{Data: v}
|
||||
}
|
||||
return &Obj{Data: nil}
|
||||
}
|
||||
|
||||
// ReqFile 获取单个上传文件
|
||||
// 用法: file, header, err := that.ReqFile("avatar")
|
||||
func (that *Context) ReqFile(name string) (multipart.File, *multipart.FileHeader, error) {
|
||||
return that.Req.FormFile(name)
|
||||
}
|
||||
|
||||
// ReqFiles 获取多个同名上传文件(批量上传场景)
|
||||
// 用法: files, err := that.ReqFiles("images")
|
||||
func (that *Context) ReqFiles(name string) ([]*multipart.FileHeader, error) {
|
||||
if that.Req.MultipartForm == nil {
|
||||
if err := that.Req.ParseMultipartForm(32 << 20); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if that.Req.MultipartForm == nil || that.Req.MultipartForm.File == nil {
|
||||
return nil, http.ErrMissingFile
|
||||
}
|
||||
files, ok := that.Req.MultipartForm.File[name]
|
||||
if !ok {
|
||||
return nil, http.ErrMissingFile
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
mode: set
|
||||
code.hoteas.com/golang/hotime/common/context_base.go:12.42,14.20 1 0
|
||||
code.hoteas.com/golang/hotime/common/context_base.go:14.20,16.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/context_base.go:17.2,17.17 1 0
|
||||
code.hoteas.com/golang/hotime/common/error.go:15.37,19.2 3 1
|
||||
code.hoteas.com/golang/hotime/common/error.go:21.40,26.38 4 1
|
||||
code.hoteas.com/golang/hotime/common/error.go:26.38,28.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:29.41,30.19 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:30.19,32.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:34.2,37.39 3 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:41.54,42.18 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:42.18,44.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:45.2,46.18 2 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:46.18,48.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:50.2,50.12 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:51.9,52.29 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:53.9,54.32 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:55.9,56.35 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:57.9,58.38 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:59.9,60.41 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:61.10,62.27 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:63.10,64.33 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:65.10,66.36 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:67.10,68.27 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:69.10,70.30 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:72.2,72.40 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:77.46,78.16 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:78.16,81.3 2 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:82.2,83.19 2 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:83.19,85.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:86.2,86.19 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:86.19,88.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:89.2,89.22 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:89.22,91.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:92.2,92.31 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:92.31,93.32 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:93.32,94.24 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:94.24,96.5 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:96.10,98.24 2 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:98.24,100.6 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:101.5,101.26 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:101.26,103.6 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:104.5,104.22 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:109.2,109.26 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:113.55,118.15 4 1
|
||||
code.hoteas.com/golang/hotime/common/func.go:118.15,120.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:121.2,123.17 2 1
|
||||
code.hoteas.com/golang/hotime/common/func.go:123.17,125.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:127.2,127.15 1 1
|
||||
code.hoteas.com/golang/hotime/common/func.go:127.15,129.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:130.2,130.16 1 1
|
||||
code.hoteas.com/golang/hotime/common/func.go:130.16,132.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:133.2,133.13 1 1
|
||||
code.hoteas.com/golang/hotime/common/func.go:133.13,135.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:136.2,136.14 1 1
|
||||
code.hoteas.com/golang/hotime/common/func.go:136.14,138.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:140.2,140.30 1 1
|
||||
code.hoteas.com/golang/hotime/common/func.go:145.40,148.35 3 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:148.35,150.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:152.2,154.42 2 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:154.42,156.14 2 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:156.14,158.22 2 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:158.22,161.18 3 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:161.18,162.11 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:165.4,165.13 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:165.13,167.5 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:171.2,171.11 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:175.29,180.2 4 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:183.26,185.29 2 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:185.29,187.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:188.2,188.22 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:190.23,193.26 3 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:193.26,195.28 2 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:195.28,198.9 2 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:200.3,200.14 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:203.2,203.10 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:208.36,211.18 2 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:211.18,213.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:215.2,215.6 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:215.6,217.19 2 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:217.19,218.9 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:221.2,221.12 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:260.49,261.37 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:261.37,263.30 2 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:263.30,265.4 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:267.3,267.16 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:268.8,268.56 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:268.56,270.32 2 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:270.32,272.4 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:274.3,274.18 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:275.8,275.63 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:275.63,277.30 2 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:277.30,279.4 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:281.3,281.16 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:283.8,283.48 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:283.48,285.32 2 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:285.32,287.4 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:289.3,289.18 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:292.2,292.14 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:319.38,322.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:15.61,17.19 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:17.19,19.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:20.2,20.30 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:24.33,27.2 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:30.52,35.2 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:38.36,41.2 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:44.55,49.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:52.59,56.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:59.63,63.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:66.59,70.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:73.67,77.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:80.63,86.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:88.59,95.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:96.57,103.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:105.63,110.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:112.80,115.27 2 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:115.27,118.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:119.2,120.27 2 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:120.27,122.9 2 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:122.9,124.4 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:129.55,136.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:138.60,140.30 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:140.30,142.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:143.2,145.19 2 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:145.19,148.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:149.2,149.12 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:153.44,156.25 2 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:156.25,159.22 3 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:159.22,160.12 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:162.3,162.31 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:163.14,164.33 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:165.16,166.47 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:167.18,168.49 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:169.17,170.48 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:171.22,172.32 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:178.39,181.2 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:183.58,185.31 2 1
|
||||
code.hoteas.com/golang/hotime/common/map.go:185.31,187.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:192.83,194.2 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:12.40,14.2 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:16.42,17.19 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:17.19,19.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:20.2,20.41 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:23.50,24.19 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:24.19,26.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:27.2,27.42 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:30.46,31.19 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:31.19,33.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:34.2,34.43 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:37.50,38.19 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:38.19,40.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:41.2,41.45 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:45.55,46.19 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:46.19,48.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:49.2,50.10 2 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:54.33,57.2 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:59.42,60.19 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:60.19,62.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:63.2,63.41 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:66.46,67.19 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:67.19,69.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:70.2,70.43 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:73.49,74.19 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:74.19,76.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:77.2,77.46 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:80.38,83.2 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:86.51,87.19 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:87.19,89.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:90.2,91.10 2 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:96.47,97.19 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:97.19,99.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:100.2,101.10 2 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:106.71,108.2 1 0
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:13.49,17.16 3 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:17.16,20.3 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:20.8,21.28 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:22.12,23.11 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:24.31,25.16 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:26.15,28.56 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:28.56,31.5 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:32.11,35.18 3 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:35.18,38.10 3 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:40.4,41.49 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:41.49,44.5 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:47.2,47.17 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:47.17,49.3 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:50.2,50.10 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:53.56,56.30 3 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:56.30,58.3 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:59.2,59.12 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:63.53,67.16 3 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:67.16,70.3 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:70.8,71.28 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:72.14,73.11 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:74.22,75.18 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:76.17,78.26 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:78.26,80.5 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:81.15,83.41 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:84.11,88.18 4 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:88.18,89.10 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:91.4,91.34 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:95.2,95.17 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:95.17,97.3 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:99.2,99.10 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:102.57,106.15 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:106.15,111.47 4 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:111.47,114.4 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:115.3,116.32 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:116.32,117.17 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:117.17,118.13 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:120.4,120.21 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:120.21,122.5 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:124.3,126.21 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:126.21,128.16 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:128.16,130.5 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:131.9,131.28 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:131.28,133.16 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:133.16,135.5 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:136.9,136.28 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:136.28,138.16 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:138.16,140.5 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:141.9,141.27 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:141.27,143.16 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:143.16,145.5 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:146.9,146.27 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:146.27,148.16 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:148.16,150.5 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:155.2,159.18 3 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:159.18,163.3 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:163.8,163.25 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:163.25,167.3 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:167.8,167.25 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:167.25,171.3 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:171.8,171.24 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:171.24,174.3 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:174.8,174.24 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:174.24,176.15 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:176.15,178.4 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:181.2,181.12 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:184.57,188.16 3 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:188.16,190.3 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:190.8,191.28 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:192.12,193.20 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:194.14,195.20 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:196.15,198.17 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:198.17,200.5 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:200.10,202.5 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:203.16,204.11 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:205.16,206.20 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:207.15,210.17 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:210.17,212.5 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:212.10,214.5 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:215.11,216.54 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:220.2,220.19 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:220.19,223.3 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:224.2,224.22 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:224.22,227.3 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:228.2,228.17 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:228.17,230.3 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:232.2,232.10 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:237.77,239.19 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:239.19,241.3 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:242.2,243.32 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:247.57,250.2 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:253.61,256.2 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:259.53,262.2 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:264.53,268.16 3 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:268.16,270.3 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:270.8,271.28 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:272.12,273.18 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:274.14,275.11 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:276.15,278.17 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:278.17,280.5 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:280.10,282.5 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:283.15,286.17 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:286.17,288.5 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:288.10,290.5 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:291.16,292.18 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:293.16,294.18 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:295.11,296.54 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:299.2,299.17 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:299.17,301.3 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:302.2,302.10 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:305.49,308.2 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:310.51,314.16 3 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:314.16,316.3 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:316.8,317.28 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:318.13,319.11 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:320.11,323.32 3 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:323.32,325.5 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:325.10,325.25 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:325.25,327.5 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:330.2,330.17 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:330.17,332.3 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:333.2,333.10 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:336.39,338.16 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:338.16,340.3 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:341.2,341.27 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:342.11,343.26 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:344.13,345.31 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:346.13,347.35 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:348.14,349.20 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:350.14,351.12 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:352.15,353.46 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:354.15,355.55 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:356.12,357.32 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:358.10,360.17 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:360.17,362.4 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:365.2,365.12 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:369.29,373.2 3 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:376.33,378.2 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:381.41,382.16 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:382.16,383.18 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:383.18,385.4 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:386.3,386.25 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:386.25,388.4 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:389.3,389.20 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:390.8,392.3 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:393.2,393.10 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:397.41,398.17 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:398.17,400.3 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:401.2,401.22 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:405.38,408.2 2 1
|
||||
code.hoteas.com/golang/hotime/common/slice.go:11.60,12.19 1 0
|
||||
code.hoteas.com/golang/hotime/common/slice.go:12.19,14.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/slice.go:15.2,15.30 1 0
|
||||
code.hoteas.com/golang/hotime/common/slice.go:18.62,23.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/slice.go:26.54,29.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/slice.go:32.58,36.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/slice.go:39.62,43.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/slice.go:46.58,50.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/slice.go:53.66,57.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/slice.go:60.62,64.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/slice.go:67.82,69.2 1 0
|
||||
code.hoteas.com/golang/hotime/common/slice.go:71.58,74.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/slice.go:76.56,83.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/slice.go:85.54,89.2 2 1
|
||||
code.hoteas.com/golang/hotime/common/slice.go:91.59,93.21 1 0
|
||||
code.hoteas.com/golang/hotime/common/slice.go:93.21,95.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/slice.go:96.2,97.19 2 0
|
||||
code.hoteas.com/golang/hotime/common/slice.go:97.19,99.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/slice.go:100.2,100.12 1 0
|
||||
code.hoteas.com/golang/hotime/common/slice.go:103.51,105.2 1 0
|
||||
code.hoteas.com/golang/hotime/common/slice.go:107.41,109.2 1 0
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
// Count 计数
|
||||
func (that *HoTimeDB) Count(table string, qu ...interface{}) int {
|
||||
var req = []interface{}{}
|
||||
if len(qu) == 2 {
|
||||
req = append(req, qu[0])
|
||||
req = append(req, "COUNT(*)")
|
||||
req = append(req, qu[1])
|
||||
} else {
|
||||
req = append(req, "COUNT(*)")
|
||||
req = append(req, qu...)
|
||||
}
|
||||
|
||||
data := that.Select(table, req...)
|
||||
if len(data) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
res := ObjToStr(data[0]["COUNT(*)"])
|
||||
count, _ := StrToInt(res)
|
||||
return count
|
||||
}
|
||||
|
||||
// Sum 求和
|
||||
func (that *HoTimeDB) Sum(table string, column string, qu ...interface{}) float64 {
|
||||
var req = []interface{}{}
|
||||
if len(qu) == 2 {
|
||||
req = append(req, qu[0])
|
||||
req = append(req, "SUM("+column+")")
|
||||
req = append(req, qu[1])
|
||||
} else {
|
||||
req = append(req, "SUM("+column+")")
|
||||
req = append(req, qu...)
|
||||
}
|
||||
|
||||
data := that.Select(table, req...)
|
||||
if len(data) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
res := ObjToStr(data[0]["SUM("+column+")"])
|
||||
sum := ObjToFloat64(res)
|
||||
return sum
|
||||
}
|
||||
|
||||
// Avg 平均值
|
||||
func (that *HoTimeDB) Avg(table string, column string, qu ...interface{}) float64 {
|
||||
var req = []interface{}{}
|
||||
if len(qu) == 2 {
|
||||
req = append(req, qu[0])
|
||||
req = append(req, "AVG("+column+")")
|
||||
req = append(req, qu[1])
|
||||
} else {
|
||||
req = append(req, "AVG("+column+")")
|
||||
req = append(req, qu...)
|
||||
}
|
||||
|
||||
data := that.Select(table, req...)
|
||||
if len(data) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
res := ObjToStr(data[0]["AVG("+column+")"])
|
||||
avg := ObjToFloat64(res)
|
||||
return avg
|
||||
}
|
||||
|
||||
// Max 最大值
|
||||
func (that *HoTimeDB) Max(table string, column string, qu ...interface{}) float64 {
|
||||
var req = []interface{}{}
|
||||
if len(qu) == 2 {
|
||||
req = append(req, qu[0])
|
||||
req = append(req, "MAX("+column+")")
|
||||
req = append(req, qu[1])
|
||||
} else {
|
||||
req = append(req, "MAX("+column+")")
|
||||
req = append(req, qu...)
|
||||
}
|
||||
|
||||
data := that.Select(table, req...)
|
||||
if len(data) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
res := ObjToStr(data[0]["MAX("+column+")"])
|
||||
max := ObjToFloat64(res)
|
||||
return max
|
||||
}
|
||||
|
||||
// Min 最小值
|
||||
func (that *HoTimeDB) Min(table string, column string, qu ...interface{}) float64 {
|
||||
var req = []interface{}{}
|
||||
if len(qu) == 2 {
|
||||
req = append(req, qu[0])
|
||||
req = append(req, "MIN("+column+")")
|
||||
req = append(req, qu[1])
|
||||
} else {
|
||||
req = append(req, "MIN("+column+")")
|
||||
req = append(req, qu...)
|
||||
}
|
||||
|
||||
data := that.Select(table, req...)
|
||||
if len(data) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
res := ObjToStr(data[0]["MIN("+column+")"])
|
||||
min := ObjToFloat64(res)
|
||||
return min
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// backupSave 保存备份
|
||||
// path: 备份文件路径
|
||||
// tt: 表名
|
||||
// code: 备份类型 0=全部, 1=仅数据, 2=仅DDL
|
||||
func (that *HoTimeDB) backupSave(path string, tt string, code int) {
|
||||
fd, _ := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644)
|
||||
defer fd.Close()
|
||||
|
||||
q := "`"
|
||||
if that.Dialect != nil {
|
||||
q = that.Dialect.QuoteChar()
|
||||
}
|
||||
|
||||
str := "\r\n"
|
||||
if code == 0 || code == 2 {
|
||||
str += that.backupDdl(tt)
|
||||
}
|
||||
|
||||
if code == 0 || code == 1 {
|
||||
str += "insert into " + q + tt + q + "\r\n\r\n("
|
||||
str += that.backupCol(tt)
|
||||
}
|
||||
|
||||
_, _ = fd.Write([]byte(str))
|
||||
}
|
||||
|
||||
// backupDdl 备份表结构(DDL)
|
||||
func (that *HoTimeDB) backupDdl(tt string) string {
|
||||
switch that.Type {
|
||||
case "dm", "dameng":
|
||||
return that.backupDdlDM(tt)
|
||||
default:
|
||||
data := that.Query("show create table " + tt)
|
||||
if len(data) == 0 {
|
||||
return ""
|
||||
}
|
||||
return ObjToStr(data[0]["Create Table"]) + ";\r\n\r\n"
|
||||
}
|
||||
}
|
||||
|
||||
// backupDdlDM 达梦数据库 DDL 导出
|
||||
func (that *HoTimeDB) backupDdlDM(tt string) string {
|
||||
cols := that.Query(`SELECT COLUMN_NAME, DATA_TYPE, DATA_LENGTH, DATA_PRECISION, DATA_SCALE, NULLABLE, DATA_DEFAULT FROM USER_TAB_COLUMNS WHERE TABLE_NAME='` + tt + `' ORDER BY COLUMN_ID`)
|
||||
if len(cols) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
ddl := "CREATE TABLE \"" + tt + "\" (\r\n"
|
||||
for i, col := range cols {
|
||||
name := col.GetString("COLUMN_NAME")
|
||||
dtype := col.GetString("DATA_TYPE")
|
||||
nullable := col.GetString("NULLABLE")
|
||||
defVal := col.GetString("DATA_DEFAULT")
|
||||
|
||||
colDef := " \"" + name + "\" " + dtype
|
||||
prec := col.GetInt("DATA_PRECISION")
|
||||
scale := col.GetInt("DATA_SCALE")
|
||||
length := col.GetInt("DATA_LENGTH")
|
||||
if prec > 0 {
|
||||
colDef += "(" + ObjToStr(prec)
|
||||
if scale > 0 {
|
||||
colDef += "," + ObjToStr(scale)
|
||||
}
|
||||
colDef += ")"
|
||||
} else if length > 0 && (strings.Contains(strings.ToUpper(dtype), "CHAR") || strings.Contains(strings.ToUpper(dtype), "BINARY")) {
|
||||
colDef += "(" + ObjToStr(length) + ")"
|
||||
}
|
||||
|
||||
if nullable == "N" {
|
||||
colDef += " NOT NULL"
|
||||
}
|
||||
if defVal != "" {
|
||||
colDef += " DEFAULT " + strings.TrimSpace(defVal)
|
||||
}
|
||||
|
||||
if i < len(cols)-1 {
|
||||
colDef += ","
|
||||
}
|
||||
ddl += colDef + "\r\n"
|
||||
}
|
||||
ddl += ");\r\n\r\n"
|
||||
return ddl
|
||||
}
|
||||
|
||||
// backupCol 备份表数据
|
||||
func (that *HoTimeDB) backupCol(tt string) string {
|
||||
q := "`"
|
||||
if that.Dialect != nil {
|
||||
q = that.Dialect.QuoteChar()
|
||||
}
|
||||
|
||||
str := ""
|
||||
data := that.Select(tt, "*")
|
||||
|
||||
lthData := len(data)
|
||||
|
||||
if lthData == 0 {
|
||||
return str
|
||||
}
|
||||
|
||||
lthCol := len(data[0])
|
||||
col := make([]string, lthCol)
|
||||
tempLthData := 0
|
||||
|
||||
for k := range data[0] {
|
||||
if tempLthData == lthCol-1 {
|
||||
str += q + k + q + ") "
|
||||
} else {
|
||||
str += q + k + q + ","
|
||||
}
|
||||
col[tempLthData] = k
|
||||
tempLthData++
|
||||
}
|
||||
|
||||
str += " values"
|
||||
|
||||
for j := 0; j < lthData; j++ {
|
||||
for m := 0; m < lthCol; m++ {
|
||||
if m == 0 {
|
||||
str += "("
|
||||
}
|
||||
|
||||
v := "NULL"
|
||||
if data[j][col[m]] != nil {
|
||||
v = "'" + strings.Replace(ObjToStr(data[j][col[m]]), "'", `\'`, -1) + "'"
|
||||
}
|
||||
|
||||
if m == lthCol-1 {
|
||||
str += v + ") "
|
||||
} else {
|
||||
str += v + ","
|
||||
}
|
||||
}
|
||||
|
||||
if j == lthData-1 {
|
||||
str += ";\r\n\r\n"
|
||||
} else {
|
||||
str += ",\r\n\r\n"
|
||||
}
|
||||
}
|
||||
|
||||
return str
|
||||
}
|
||||
+312
@@ -0,0 +1,312 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
// HotimeDBBuilder 链式查询构建器
|
||||
type HotimeDBBuilder struct {
|
||||
HoTimeDB *HoTimeDB
|
||||
table string
|
||||
selects []interface{}
|
||||
join Slice
|
||||
where Map
|
||||
lastWhere Map
|
||||
page int
|
||||
pageRow int
|
||||
}
|
||||
|
||||
// Table 创建链式查询构建器
|
||||
func (that *HoTimeDB) Table(table string) *HotimeDBBuilder {
|
||||
return &HotimeDBBuilder{HoTimeDB: that, table: table, where: Map{}}
|
||||
}
|
||||
|
||||
// Get 获取单条记录
|
||||
func (that *HotimeDBBuilder) Get(qu ...interface{}) Map {
|
||||
// 构建参数:根据是否有 JOIN 来决定参数结构
|
||||
var args []interface{}
|
||||
if len(that.join) > 0 {
|
||||
// 有 JOIN 时:join, fields, where
|
||||
if len(qu) > 0 {
|
||||
args = append(args, that.join, qu[0], that.where)
|
||||
} else {
|
||||
args = append(args, that.join, "*", that.where)
|
||||
}
|
||||
} else {
|
||||
// 无 JOIN 时:fields, where
|
||||
if len(qu) > 0 {
|
||||
args = append(args, qu[0], that.where)
|
||||
} else {
|
||||
args = append(args, "*", that.where)
|
||||
}
|
||||
}
|
||||
return that.HoTimeDB.Get(that.table, args...)
|
||||
}
|
||||
|
||||
// Count 统计数量
|
||||
func (that *HotimeDBBuilder) Count() int {
|
||||
// 构建参数:根据是否有 JOIN 来决定参数结构
|
||||
if len(that.join) > 0 {
|
||||
return that.HoTimeDB.Count(that.table, that.join, that.where)
|
||||
}
|
||||
return that.HoTimeDB.Count(that.table, that.where)
|
||||
}
|
||||
|
||||
// Page 设置分页
|
||||
func (that *HotimeDBBuilder) Page(page, pageRow int) *HotimeDBBuilder {
|
||||
that.page = page
|
||||
that.pageRow = pageRow
|
||||
return that
|
||||
}
|
||||
|
||||
// Select 查询多条记录
|
||||
func (that *HotimeDBBuilder) Select(qu ...interface{}) []Map {
|
||||
// 构建参数:根据是否有 JOIN 来决定参数结构
|
||||
var args []interface{}
|
||||
if len(that.join) > 0 {
|
||||
// 有 JOIN 时:join, fields, where
|
||||
if len(qu) > 0 {
|
||||
args = append(args, that.join, qu[0], that.where)
|
||||
} else {
|
||||
args = append(args, that.join, "*", that.where)
|
||||
}
|
||||
} else {
|
||||
// 无 JOIN 时:fields, where
|
||||
if len(qu) > 0 {
|
||||
args = append(args, qu[0], that.where)
|
||||
} else {
|
||||
args = append(args, "*", that.where)
|
||||
}
|
||||
}
|
||||
|
||||
if that.page != 0 {
|
||||
return that.HoTimeDB.Page(that.page, that.pageRow).PageSelect(that.table, args...)
|
||||
}
|
||||
return that.HoTimeDB.Select(that.table, args...)
|
||||
}
|
||||
|
||||
// Update 更新记录
|
||||
func (that *HotimeDBBuilder) Update(qu ...interface{}) int64 {
|
||||
lth := len(qu)
|
||||
if lth == 0 {
|
||||
return 0
|
||||
}
|
||||
data := Map{}
|
||||
if lth == 1 {
|
||||
data = ObjToMap(qu[0])
|
||||
}
|
||||
if lth > 1 {
|
||||
for k := 1; k < lth; k++ {
|
||||
data[ObjToStr(qu[k-1])] = qu[k]
|
||||
k++
|
||||
}
|
||||
}
|
||||
|
||||
return that.HoTimeDB.Update(that.table, data, that.where)
|
||||
}
|
||||
|
||||
// Delete 删除记录
|
||||
func (that *HotimeDBBuilder) Delete() int64 {
|
||||
return that.HoTimeDB.Delete(that.table, that.where)
|
||||
}
|
||||
|
||||
// LeftJoin 左连接
|
||||
func (that *HotimeDBBuilder) LeftJoin(table, joinStr string) *HotimeDBBuilder {
|
||||
that.Join(Map{"[>]" + table: joinStr})
|
||||
return that
|
||||
}
|
||||
|
||||
// RightJoin 右连接
|
||||
func (that *HotimeDBBuilder) RightJoin(table, joinStr string) *HotimeDBBuilder {
|
||||
that.Join(Map{"[<]" + table: joinStr})
|
||||
return that
|
||||
}
|
||||
|
||||
// InnerJoin 内连接
|
||||
func (that *HotimeDBBuilder) InnerJoin(table, joinStr string) *HotimeDBBuilder {
|
||||
that.Join(Map{"[><]" + table: joinStr})
|
||||
return that
|
||||
}
|
||||
|
||||
// FullJoin 全连接
|
||||
func (that *HotimeDBBuilder) FullJoin(table, joinStr string) *HotimeDBBuilder {
|
||||
that.Join(Map{"[<>]" + table: joinStr})
|
||||
return that
|
||||
}
|
||||
|
||||
// Join 通用连接
|
||||
func (that *HotimeDBBuilder) Join(qu ...interface{}) *HotimeDBBuilder {
|
||||
lth := len(qu)
|
||||
if lth == 0 {
|
||||
return that
|
||||
}
|
||||
data := Map{}
|
||||
if lth == 1 {
|
||||
data = ObjToMap(qu[0])
|
||||
}
|
||||
if lth > 1 {
|
||||
for k := 1; k < lth; k++ {
|
||||
data[ObjToStr(qu[k-1])] = qu[k]
|
||||
k++
|
||||
}
|
||||
}
|
||||
|
||||
if that.join == nil {
|
||||
that.join = Slice{}
|
||||
}
|
||||
if data == nil {
|
||||
return that
|
||||
}
|
||||
|
||||
that.join = append(that.join, data)
|
||||
return that
|
||||
}
|
||||
|
||||
// And 添加 AND 条件
|
||||
func (that *HotimeDBBuilder) And(qu ...interface{}) *HotimeDBBuilder {
|
||||
lth := len(qu)
|
||||
if lth == 0 {
|
||||
return that
|
||||
}
|
||||
var where Map
|
||||
if lth == 1 {
|
||||
where = ObjToMap(qu[0])
|
||||
}
|
||||
if lth > 1 {
|
||||
where = Map{}
|
||||
for k := 1; k < lth; k++ {
|
||||
where[ObjToStr(qu[k-1])] = qu[k]
|
||||
k++
|
||||
}
|
||||
}
|
||||
|
||||
if where == nil {
|
||||
return that
|
||||
}
|
||||
|
||||
if that.lastWhere != nil {
|
||||
that.lastWhere["AND"] = where
|
||||
that.lastWhere = where
|
||||
return that
|
||||
}
|
||||
that.lastWhere = where
|
||||
that.where = Map{"AND": where}
|
||||
|
||||
return that
|
||||
}
|
||||
|
||||
// Or 添加 OR 条件
|
||||
func (that *HotimeDBBuilder) Or(qu ...interface{}) *HotimeDBBuilder {
|
||||
lth := len(qu)
|
||||
if lth == 0 {
|
||||
return that
|
||||
}
|
||||
var where Map
|
||||
if lth == 1 {
|
||||
where = ObjToMap(qu[0])
|
||||
}
|
||||
if lth > 1 {
|
||||
where = Map{}
|
||||
for k := 1; k < lth; k++ {
|
||||
where[ObjToStr(qu[k-1])] = qu[k]
|
||||
k++
|
||||
}
|
||||
}
|
||||
if where == nil {
|
||||
return that
|
||||
}
|
||||
|
||||
if that.lastWhere != nil {
|
||||
that.lastWhere["OR"] = where
|
||||
that.lastWhere = where
|
||||
return that
|
||||
}
|
||||
that.lastWhere = where
|
||||
that.where = Map{"Or": where}
|
||||
|
||||
return that
|
||||
}
|
||||
|
||||
// Where 设置 WHERE 条件
|
||||
func (that *HotimeDBBuilder) Where(qu ...interface{}) *HotimeDBBuilder {
|
||||
lth := len(qu)
|
||||
if lth == 0 {
|
||||
return that
|
||||
}
|
||||
var where Map
|
||||
if lth == 1 {
|
||||
where = ObjToMap(qu[0])
|
||||
}
|
||||
if lth > 1 {
|
||||
where = Map{}
|
||||
for k := 1; k < lth; k++ {
|
||||
where[ObjToStr(qu[k-1])] = qu[k]
|
||||
k++
|
||||
}
|
||||
}
|
||||
|
||||
if where == nil {
|
||||
return that
|
||||
}
|
||||
|
||||
if that.lastWhere != nil {
|
||||
that.lastWhere["AND"] = where
|
||||
that.lastWhere = where
|
||||
return that
|
||||
}
|
||||
that.lastWhere = where
|
||||
that.where = Map{"AND": that.lastWhere}
|
||||
|
||||
return that
|
||||
}
|
||||
|
||||
// From 设置表名
|
||||
func (that *HotimeDBBuilder) From(table string) *HotimeDBBuilder {
|
||||
that.table = table
|
||||
return that
|
||||
}
|
||||
|
||||
// Order 设置排序
|
||||
func (that *HotimeDBBuilder) Order(qu ...interface{}) *HotimeDBBuilder {
|
||||
that.where["ORDER"] = ObjToSlice(qu)
|
||||
return that
|
||||
}
|
||||
|
||||
// Limit 设置限制
|
||||
func (that *HotimeDBBuilder) Limit(qu ...interface{}) *HotimeDBBuilder {
|
||||
that.where["LIMIT"] = ObjToSlice(qu)
|
||||
return that
|
||||
}
|
||||
|
||||
// Group 设置分组
|
||||
func (that *HotimeDBBuilder) Group(qu ...interface{}) *HotimeDBBuilder {
|
||||
that.where["GROUP"] = ObjToSlice(qu)
|
||||
return that
|
||||
}
|
||||
|
||||
// Having 设置 HAVING 条件
|
||||
func (that *HotimeDBBuilder) Having(qu ...interface{}) *HotimeDBBuilder {
|
||||
lth := len(qu)
|
||||
if lth == 0 {
|
||||
return that
|
||||
}
|
||||
var having Map
|
||||
if lth == 1 {
|
||||
having = ObjToMap(qu[0])
|
||||
}
|
||||
if lth > 1 {
|
||||
having = Map{}
|
||||
for k := 1; k < lth; k++ {
|
||||
having[ObjToStr(qu[k-1])] = qu[k]
|
||||
k++
|
||||
}
|
||||
}
|
||||
that.where["HAVING"] = having
|
||||
return that
|
||||
}
|
||||
|
||||
// Offset 设置偏移量
|
||||
func (that *HotimeDBBuilder) Offset(offset int) *HotimeDBBuilder {
|
||||
that.where["OFFSET"] = offset
|
||||
return that
|
||||
}
|
||||
+740
@@ -0,0 +1,740 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
func (that *HoTimeDB) isCacheTable(table string) bool {
|
||||
return table == "cached" || strings.HasPrefix(table, "hotime_cache")
|
||||
}
|
||||
|
||||
// Page 设置分页参数
|
||||
// page: 页码(从1开始)
|
||||
// pageRow: 每页数量
|
||||
func (that *HoTimeDB) Page(page, pageRow int) *HoTimeDB {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageRow < 1 {
|
||||
pageRow = 10
|
||||
}
|
||||
offset := (page - 1) * pageRow
|
||||
|
||||
that.limitMu.Lock()
|
||||
that.limit = Slice{offset, pageRow}
|
||||
that.limitMu.Unlock()
|
||||
return that
|
||||
}
|
||||
|
||||
// PageSelect 分页查询
|
||||
func (that *HoTimeDB) PageSelect(table string, qu ...interface{}) []Map {
|
||||
that.limitMu.Lock()
|
||||
limit := that.limit
|
||||
that.limit = nil // 使用后清空,避免影响下次调用
|
||||
that.limitMu.Unlock()
|
||||
|
||||
if limit == nil {
|
||||
return that.Select(table, qu...)
|
||||
}
|
||||
|
||||
// 根据参数数量处理 LIMIT 注入
|
||||
switch len(qu) {
|
||||
case 0:
|
||||
// PageSelect("user") -> 只有表名,添加 LIMIT
|
||||
qu = append(qu, "*", Map{"LIMIT": limit})
|
||||
case 1:
|
||||
// PageSelect("user", "*") 或 PageSelect("user", Map{...})
|
||||
if reflect.ValueOf(qu[0]).Kind() == reflect.Map {
|
||||
// 是 where 条件
|
||||
temp := DeepCopyMap(qu[0]).(Map)
|
||||
temp["LIMIT"] = limit
|
||||
qu[0] = temp
|
||||
} else {
|
||||
// 是字段选择
|
||||
qu = append(qu, Map{"LIMIT": limit})
|
||||
}
|
||||
case 2:
|
||||
// PageSelect("user", "*", Map{...}) 或 PageSelect("user", joinSlice, "*")
|
||||
if reflect.ValueOf(qu[1]).Kind() == reflect.Map {
|
||||
temp := DeepCopyMap(qu[1]).(Map)
|
||||
temp["LIMIT"] = limit
|
||||
qu[1] = temp
|
||||
} else {
|
||||
// join 模式,需要追加 where
|
||||
qu = append(qu, Map{"LIMIT": limit})
|
||||
}
|
||||
case 3:
|
||||
// PageSelect("user", joinSlice, "*", Map{...})
|
||||
temp := DeepCopyMap(qu[2]).(Map)
|
||||
temp["LIMIT"] = limit
|
||||
qu[2] = temp
|
||||
}
|
||||
|
||||
return that.Select(table, qu...)
|
||||
}
|
||||
|
||||
// Select 查询多条记录
|
||||
func (that *HoTimeDB) Select(table string, qu ...interface{}) []Map {
|
||||
query := "SELECT"
|
||||
where := Map{}
|
||||
qs := make([]interface{}, 0)
|
||||
intQs, intWhere := 0, 1
|
||||
join := false
|
||||
|
||||
if len(qu) == 3 {
|
||||
intQs = 1
|
||||
intWhere = 2
|
||||
join = true
|
||||
}
|
||||
|
||||
processor := that.GetProcessor()
|
||||
|
||||
if len(qu) > 0 {
|
||||
if reflect.ValueOf(qu[intQs]).Type().String() == "string" {
|
||||
// 字段列表字符串,使用处理器处理 table.column 格式
|
||||
fieldStr := qu[intQs].(string)
|
||||
if fieldStr != "*" {
|
||||
fieldStr = processor.ProcessFieldList(fieldStr)
|
||||
}
|
||||
query += " " + fieldStr
|
||||
} else {
|
||||
data := ObjToSlice(qu[intQs])
|
||||
for i := 0; i < len(data); i++ {
|
||||
k := data.GetString(i)
|
||||
if strings.Contains(k, " AS ") || strings.Contains(k, ".") {
|
||||
// 处理 table.column 格式
|
||||
query += " " + processor.ProcessFieldList(k) + " "
|
||||
} else {
|
||||
// 单独的列名
|
||||
query += " " + processor.ProcessColumnNoPrefix(k) + " "
|
||||
}
|
||||
|
||||
if i+1 != len(data) {
|
||||
query = query + ", "
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
query += " *"
|
||||
}
|
||||
|
||||
// 处理表名(添加前缀和正确的引号)
|
||||
query += " FROM " + processor.ProcessTableName(table) + " "
|
||||
|
||||
if join {
|
||||
query += that.buildJoin(qu[0])
|
||||
}
|
||||
|
||||
if len(qu) > 1 {
|
||||
where = qu[intWhere].(Map)
|
||||
}
|
||||
|
||||
temp, resWhere := that.where(where)
|
||||
|
||||
query += temp + ";"
|
||||
qs = append(qs, resWhere...)
|
||||
md5 := that.md5(query, qs...)
|
||||
|
||||
if that.testTx == nil && that.HoTimeCache != nil && !that.isCacheTable(table) {
|
||||
cacheData := that.HoTimeCache.Db(table + ":" + md5)
|
||||
if cacheData != nil && cacheData.Data != nil {
|
||||
return cacheData.ToMapArray()
|
||||
}
|
||||
}
|
||||
|
||||
res := that.Query(query, qs...)
|
||||
|
||||
if res == nil {
|
||||
res = []Map{}
|
||||
}
|
||||
|
||||
if that.testTx == nil && that.HoTimeCache != nil && !that.isCacheTable(table) {
|
||||
_ = that.HoTimeCache.Db(table+":"+md5, res)
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// buildJoin 构建 JOIN 语句
|
||||
func (that *HoTimeDB) buildJoin(joinData interface{}) string {
|
||||
query := ""
|
||||
var testQu = []string{}
|
||||
testQuData := Map{}
|
||||
processor := that.GetProcessor()
|
||||
|
||||
if reflect.ValueOf(joinData).Type().String() == "common.Map" {
|
||||
testQuData = joinData.(Map)
|
||||
for key := range testQuData {
|
||||
testQu = append(testQu, key)
|
||||
}
|
||||
}
|
||||
|
||||
if reflect.ValueOf(joinData).Type().String() == "common.Slice" || strings.Contains(reflect.ValueOf(joinData).Type().String(), "[]") {
|
||||
qu0 := ObjToSlice(joinData)
|
||||
for key := range qu0 {
|
||||
v := qu0.GetMap(key)
|
||||
for k1, v1 := range v {
|
||||
testQu = append(testQu, k1)
|
||||
testQuData[k1] = v1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(testQu)
|
||||
|
||||
for _, k := range testQu {
|
||||
v := testQuData[k]
|
||||
switch Substr(k, 0, 3) {
|
||||
case "[>]":
|
||||
func() {
|
||||
table := Substr(k, 3, len(k)-3)
|
||||
// 处理表名(添加前缀和正确的引号)
|
||||
table = processor.ProcessTableName(table)
|
||||
// 处理 ON 条件中的 table.column
|
||||
onCondition := processor.ProcessConditionString(v.(string))
|
||||
query += " LEFT JOIN " + table + " ON " + onCondition + " "
|
||||
}()
|
||||
case "[<]":
|
||||
func() {
|
||||
table := Substr(k, 3, len(k)-3)
|
||||
table = processor.ProcessTableName(table)
|
||||
onCondition := processor.ProcessConditionString(v.(string))
|
||||
query += " RIGHT JOIN " + table + " ON " + onCondition + " "
|
||||
}()
|
||||
}
|
||||
switch Substr(k, 0, 4) {
|
||||
case "[<>]":
|
||||
func() {
|
||||
table := Substr(k, 4, len(k)-4)
|
||||
table = processor.ProcessTableName(table)
|
||||
onCondition := processor.ProcessConditionString(v.(string))
|
||||
query += " FULL JOIN " + table + " ON " + onCondition + " "
|
||||
}()
|
||||
case "[><]":
|
||||
func() {
|
||||
table := Substr(k, 4, len(k)-4)
|
||||
table = processor.ProcessTableName(table)
|
||||
onCondition := processor.ProcessConditionString(v.(string))
|
||||
query += " INNER JOIN " + table + " ON " + onCondition + " "
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
// Get 获取单条记录
|
||||
func (that *HoTimeDB) Get(table string, qu ...interface{}) Map {
|
||||
if len(qu) == 0 {
|
||||
// 没有参数时,添加默认字段和 LIMIT
|
||||
qu = append(qu, "*", Map{"LIMIT": 1})
|
||||
} else if len(qu) == 1 {
|
||||
qu = append(qu, Map{"LIMIT": 1})
|
||||
} else if len(qu) == 2 {
|
||||
temp := qu[1].(Map)
|
||||
temp["LIMIT"] = 1
|
||||
qu[1] = temp
|
||||
} else if len(qu) == 3 {
|
||||
temp := qu[2].(Map)
|
||||
temp["LIMIT"] = 1
|
||||
qu[2] = temp
|
||||
}
|
||||
|
||||
data := that.Select(table, qu...)
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
return data[0]
|
||||
}
|
||||
|
||||
// Insert 插入新数据
|
||||
func (that *HoTimeDB) Insert(table string, data map[string]interface{}) int64 {
|
||||
values := make([]interface{}, 0)
|
||||
queryString := " ("
|
||||
valueString := " ("
|
||||
processor := that.GetProcessor()
|
||||
|
||||
lens := len(data)
|
||||
tempLen := 0
|
||||
|
||||
for k, v := range data {
|
||||
tempLen++
|
||||
|
||||
vstr := "?"
|
||||
if Substr(k, len(k)-3, 3) == "[#]" {
|
||||
k = strings.Replace(k, "[#]", "", -1)
|
||||
vstr = ObjToStr(v)
|
||||
if tempLen < lens {
|
||||
queryString += processor.ProcessColumnNoPrefix(k) + ","
|
||||
valueString += vstr + ","
|
||||
} else {
|
||||
queryString += processor.ProcessColumnNoPrefix(k) + ") "
|
||||
valueString += vstr + ");"
|
||||
}
|
||||
} else {
|
||||
values = append(values, v)
|
||||
if tempLen < lens {
|
||||
queryString += processor.ProcessColumnNoPrefix(k) + ","
|
||||
valueString += "?,"
|
||||
} else {
|
||||
queryString += processor.ProcessColumnNoPrefix(k) + ") "
|
||||
valueString += "?);"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query := "INSERT INTO " + processor.ProcessTableName(table) + " " + queryString + "VALUES" + valueString
|
||||
|
||||
id := int64(0)
|
||||
if that.Dialect != nil && !that.Dialect.SupportsLastInsertId() {
|
||||
returningClause := that.Dialect.ReturningClause("id")
|
||||
query = strings.TrimSuffix(query, ";") + returningClause
|
||||
rows := that.Query(query, values...)
|
||||
if len(rows) > 0 {
|
||||
id = rows[0].GetInt64("id")
|
||||
}
|
||||
} else {
|
||||
res, err := that.Exec(query, values...)
|
||||
if err == nil && res != nil {
|
||||
id1, e := res.LastInsertId()
|
||||
if e != nil {
|
||||
dbErr := &DBError{}
|
||||
dbErr.SetError(e)
|
||||
that.lastError.Store(dbErr)
|
||||
}
|
||||
id = id1
|
||||
}
|
||||
}
|
||||
|
||||
// 如果插入成功,删除缓存
|
||||
if id != 0 {
|
||||
if that.HoTimeCache != nil && !that.isCacheTable(table) {
|
||||
_ = that.HoTimeCache.Db(table+"*", nil)
|
||||
}
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
// Inserts 批量插入数据
|
||||
// table: 表名
|
||||
// dataList: 数据列表,每个元素是一个 Map
|
||||
// 返回受影响的行数
|
||||
//
|
||||
// 示例:
|
||||
//
|
||||
// affected := db.Inserts("user", []Map{
|
||||
// {"name": "张三", "age": 25, "email": "zhang@example.com"},
|
||||
// {"name": "李四", "age": 30, "email": "li@example.com"},
|
||||
// {"name": "王五", "age": 28, "email": "wang@example.com"},
|
||||
// })
|
||||
func (that *HoTimeDB) Inserts(table string, dataList []Map) int64 {
|
||||
if len(dataList) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// 从第一条数据提取所有列名(确保顺序一致)
|
||||
columns := make([]string, 0)
|
||||
rawValues := make(map[string]string) // 存储 [#] 标记的直接 SQL 值
|
||||
|
||||
for k := range dataList[0] {
|
||||
realKey := k
|
||||
if Substr(k, len(k)-3, 3) == "[#]" {
|
||||
realKey = strings.Replace(k, "[#]", "", -1)
|
||||
rawValues[realKey] = ObjToStr(dataList[0][k])
|
||||
}
|
||||
columns = append(columns, realKey)
|
||||
}
|
||||
|
||||
// 排序列名以确保一致性
|
||||
sort.Strings(columns)
|
||||
|
||||
processor := that.GetProcessor()
|
||||
|
||||
// 构建列名部分
|
||||
quotedCols := make([]string, len(columns))
|
||||
for i, col := range columns {
|
||||
quotedCols[i] = processor.ProcessColumnNoPrefix(col)
|
||||
}
|
||||
colStr := strings.Join(quotedCols, ", ")
|
||||
|
||||
// 构建每行的占位符和值
|
||||
placeholders := make([]string, len(dataList))
|
||||
values := make([]interface{}, 0, len(dataList)*len(columns))
|
||||
|
||||
for i, data := range dataList {
|
||||
rowPlaceholders := make([]string, len(columns))
|
||||
for j, col := range columns {
|
||||
// 检查是否有 [#] 标记
|
||||
rawKey := col + "[#]"
|
||||
if rawVal, ok := data[rawKey]; ok {
|
||||
// 直接 SQL 表达式
|
||||
rowPlaceholders[j] = ObjToStr(rawVal)
|
||||
} else if _, isRaw := rawValues[col]; isRaw && i == 0 {
|
||||
// 第一条数据中的 [#] 标记
|
||||
rowPlaceholders[j] = rawValues[col]
|
||||
} else if val, ok := data[col]; ok {
|
||||
// 普通值
|
||||
rowPlaceholders[j] = "?"
|
||||
values = append(values, val)
|
||||
} else {
|
||||
// 字段不存在,使用 NULL
|
||||
rowPlaceholders[j] = "NULL"
|
||||
}
|
||||
}
|
||||
placeholders[i] = "(" + strings.Join(rowPlaceholders, ", ") + ")"
|
||||
}
|
||||
|
||||
query := "INSERT INTO " + processor.ProcessTableName(table) + " (" + colStr + ") VALUES " + strings.Join(placeholders, ", ")
|
||||
|
||||
res, err := that.Exec(query, values...)
|
||||
|
||||
rows64 := int64(0)
|
||||
if err == nil && res != nil {
|
||||
rows64, _ = res.RowsAffected()
|
||||
}
|
||||
|
||||
// 如果插入成功,删除缓存
|
||||
if rows64 != 0 {
|
||||
if that.HoTimeCache != nil && !that.isCacheTable(table) {
|
||||
_ = that.HoTimeCache.Db(table+"*", nil)
|
||||
}
|
||||
}
|
||||
|
||||
return rows64
|
||||
}
|
||||
|
||||
// Upsert 插入或更新数据
|
||||
// table: 表名
|
||||
// data: 要插入的数据
|
||||
// uniqueKeys: 唯一键字段(用于冲突检测),支持 Slice{"id"} 或 Slice{"col1", "col2"}
|
||||
// updateColumns: 冲突时要更新的字段(如果为空,则更新所有非唯一键字段)
|
||||
// 返回受影响的行数
|
||||
//
|
||||
// 示例:
|
||||
//
|
||||
// affected := db.Upsert("user",
|
||||
// Map{"id": 1, "name": "张三", "email": "zhang@example.com"},
|
||||
// Slice{"id"}, // 唯一键
|
||||
// Slice{"name", "email"}, // 冲突时更新的字段
|
||||
// )
|
||||
func (that *HoTimeDB) Upsert(table string, data Map, uniqueKeys Slice, updateColumns ...interface{}) int64 {
|
||||
if len(data) == 0 || len(uniqueKeys) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// 转换 uniqueKeys 为 []string
|
||||
uniqueKeyStrs := make([]string, len(uniqueKeys))
|
||||
for i, uk := range uniqueKeys {
|
||||
uniqueKeyStrs[i] = ObjToStr(uk)
|
||||
}
|
||||
|
||||
// 转换 updateColumns 为 []string
|
||||
var updateColumnStrs []string
|
||||
if len(updateColumns) > 0 {
|
||||
// 支持两种调用方式:Upsert(table, data, Slice{"id"}, Slice{"name"}) 或 Upsert(table, data, Slice{"id"}, "name", "email")
|
||||
if slice, ok := updateColumns[0].(Slice); ok {
|
||||
updateColumnStrs = make([]string, len(slice))
|
||||
for i, col := range slice {
|
||||
updateColumnStrs[i] = ObjToStr(col)
|
||||
}
|
||||
} else {
|
||||
updateColumnStrs = make([]string, len(updateColumns))
|
||||
for i, col := range updateColumns {
|
||||
updateColumnStrs[i] = ObjToStr(col)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 收集列和值
|
||||
columns := make([]string, 0, len(data))
|
||||
values := make([]interface{}, 0, len(data))
|
||||
rawValues := make(map[string]string) // 存储 [#] 标记的直接 SQL 值
|
||||
|
||||
for k, v := range data {
|
||||
if Substr(k, len(k)-3, 3) == "[#]" {
|
||||
realKey := strings.Replace(k, "[#]", "", -1)
|
||||
columns = append(columns, realKey)
|
||||
rawValues[realKey] = ObjToStr(v)
|
||||
} else {
|
||||
columns = append(columns, k)
|
||||
values = append(values, v)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有指定更新字段,则更新所有非唯一键字段
|
||||
if len(updateColumnStrs) == 0 {
|
||||
uniqueKeySet := make(map[string]bool)
|
||||
for _, uk := range uniqueKeyStrs {
|
||||
uniqueKeySet[uk] = true
|
||||
}
|
||||
for _, col := range columns {
|
||||
if !uniqueKeySet[col] {
|
||||
updateColumnStrs = append(updateColumnStrs, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 构建 SQL
|
||||
var query string
|
||||
dbType := that.Type
|
||||
if dbType == "" {
|
||||
dbType = "mysql"
|
||||
}
|
||||
|
||||
switch dbType {
|
||||
case "postgres", "postgresql":
|
||||
query = that.buildPostgresUpsert(table, columns, uniqueKeyStrs, updateColumnStrs, rawValues)
|
||||
case "sqlite3", "sqlite":
|
||||
query = that.buildSQLiteUpsert(table, columns, uniqueKeyStrs, updateColumnStrs, rawValues)
|
||||
case "dm", "dameng":
|
||||
query = that.buildDMUpsert(table, columns, uniqueKeyStrs, updateColumnStrs, rawValues)
|
||||
default: // mysql
|
||||
query = that.buildMySQLUpsert(table, columns, uniqueKeyStrs, updateColumnStrs, rawValues)
|
||||
}
|
||||
|
||||
res, err := that.Exec(query, values...)
|
||||
|
||||
rows := int64(0)
|
||||
if err == nil && res != nil {
|
||||
rows, _ = res.RowsAffected()
|
||||
}
|
||||
|
||||
// 清除缓存
|
||||
if rows != 0 {
|
||||
if that.HoTimeCache != nil && !that.isCacheTable(table) {
|
||||
_ = that.HoTimeCache.Db(table+"*", nil)
|
||||
}
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
// buildMySQLUpsert 构建 MySQL 的 Upsert 语句
|
||||
func (that *HoTimeDB) buildMySQLUpsert(table string, columns []string, uniqueKeys []string, updateColumns []string, rawValues map[string]string) string {
|
||||
// INSERT INTO table (col1, col2) VALUES (?, ?)
|
||||
// ON DUPLICATE KEY UPDATE col1 = VALUES(col1), col2 = VALUES(col2)
|
||||
processor := that.GetProcessor()
|
||||
|
||||
quotedCols := make([]string, len(columns))
|
||||
valueParts := make([]string, len(columns))
|
||||
for i, col := range columns {
|
||||
quotedCols[i] = processor.ProcessColumnNoPrefix(col)
|
||||
if raw, ok := rawValues[col]; ok {
|
||||
valueParts[i] = raw
|
||||
} else {
|
||||
valueParts[i] = "?"
|
||||
}
|
||||
}
|
||||
|
||||
updateParts := make([]string, len(updateColumns))
|
||||
for i, col := range updateColumns {
|
||||
quotedCol := processor.ProcessColumnNoPrefix(col)
|
||||
if raw, ok := rawValues[col]; ok {
|
||||
updateParts[i] = quotedCol + " = " + raw
|
||||
} else {
|
||||
updateParts[i] = quotedCol + " = VALUES(" + quotedCol + ")"
|
||||
}
|
||||
}
|
||||
|
||||
return "INSERT INTO " + processor.ProcessTableName(table) + " (" + strings.Join(quotedCols, ", ") +
|
||||
") VALUES (" + strings.Join(valueParts, ", ") +
|
||||
") ON DUPLICATE KEY UPDATE " + strings.Join(updateParts, ", ")
|
||||
}
|
||||
|
||||
// buildPostgresUpsert 构建 PostgreSQL 的 Upsert 语句
|
||||
func (that *HoTimeDB) buildPostgresUpsert(table string, columns []string, uniqueKeys []string, updateColumns []string, rawValues map[string]string) string {
|
||||
// INSERT INTO table (col1, col2) VALUES ($1, $2)
|
||||
// ON CONFLICT (unique_key) DO UPDATE SET col1 = EXCLUDED.col1
|
||||
processor := that.GetProcessor()
|
||||
dialect := that.GetDialect()
|
||||
|
||||
quotedCols := make([]string, len(columns))
|
||||
valueParts := make([]string, len(columns))
|
||||
paramIndex := 1
|
||||
for i, col := range columns {
|
||||
quotedCols[i] = dialect.QuoteIdentifier(col)
|
||||
if raw, ok := rawValues[col]; ok {
|
||||
valueParts[i] = raw
|
||||
} else {
|
||||
valueParts[i] = "$" + ObjToStr(paramIndex)
|
||||
paramIndex++
|
||||
}
|
||||
}
|
||||
|
||||
quotedUniqueKeys := make([]string, len(uniqueKeys))
|
||||
for i, key := range uniqueKeys {
|
||||
quotedUniqueKeys[i] = dialect.QuoteIdentifier(key)
|
||||
}
|
||||
|
||||
updateParts := make([]string, len(updateColumns))
|
||||
for i, col := range updateColumns {
|
||||
quotedCol := dialect.QuoteIdentifier(col)
|
||||
if raw, ok := rawValues[col]; ok {
|
||||
updateParts[i] = quotedCol + " = " + raw
|
||||
} else {
|
||||
updateParts[i] = quotedCol + " = EXCLUDED." + quotedCol
|
||||
}
|
||||
}
|
||||
|
||||
return "INSERT INTO " + processor.ProcessTableName(table) + " (" + strings.Join(quotedCols, ", ") +
|
||||
") VALUES (" + strings.Join(valueParts, ", ") +
|
||||
") ON CONFLICT (" + strings.Join(quotedUniqueKeys, ", ") +
|
||||
") DO UPDATE SET " + strings.Join(updateParts, ", ")
|
||||
}
|
||||
|
||||
// buildSQLiteUpsert 构建 SQLite 的 Upsert 语句
|
||||
func (that *HoTimeDB) buildSQLiteUpsert(table string, columns []string, uniqueKeys []string, updateColumns []string, rawValues map[string]string) string {
|
||||
// INSERT INTO table (col1, col2) VALUES (?, ?)
|
||||
// ON CONFLICT (unique_key) DO UPDATE SET col1 = excluded.col1
|
||||
processor := that.GetProcessor()
|
||||
dialect := that.GetDialect()
|
||||
|
||||
quotedCols := make([]string, len(columns))
|
||||
valueParts := make([]string, len(columns))
|
||||
for i, col := range columns {
|
||||
quotedCols[i] = dialect.QuoteIdentifier(col)
|
||||
if raw, ok := rawValues[col]; ok {
|
||||
valueParts[i] = raw
|
||||
} else {
|
||||
valueParts[i] = "?"
|
||||
}
|
||||
}
|
||||
|
||||
quotedUniqueKeys := make([]string, len(uniqueKeys))
|
||||
for i, key := range uniqueKeys {
|
||||
quotedUniqueKeys[i] = dialect.QuoteIdentifier(key)
|
||||
}
|
||||
|
||||
updateParts := make([]string, len(updateColumns))
|
||||
for i, col := range updateColumns {
|
||||
quotedCol := dialect.QuoteIdentifier(col)
|
||||
if raw, ok := rawValues[col]; ok {
|
||||
updateParts[i] = quotedCol + " = " + raw
|
||||
} else {
|
||||
updateParts[i] = quotedCol + " = excluded." + quotedCol
|
||||
}
|
||||
}
|
||||
|
||||
return "INSERT INTO " + processor.ProcessTableName(table) + " (" + strings.Join(quotedCols, ", ") +
|
||||
") VALUES (" + strings.Join(valueParts, ", ") +
|
||||
") ON CONFLICT (" + strings.Join(quotedUniqueKeys, ", ") +
|
||||
") DO UPDATE SET " + strings.Join(updateParts, ", ")
|
||||
}
|
||||
|
||||
// buildDMUpsert 构建达梦的 Upsert 语句(MERGE INTO)
|
||||
func (that *HoTimeDB) buildDMUpsert(table string, columns []string, uniqueKeys []string, updateColumns []string, rawValues map[string]string) string {
|
||||
processor := that.GetProcessor()
|
||||
dialect := that.GetDialect()
|
||||
quotedTable := processor.ProcessTableName(table)
|
||||
|
||||
srcParts := make([]string, len(columns))
|
||||
for i, col := range columns {
|
||||
if raw, ok := rawValues[col]; ok {
|
||||
srcParts[i] = raw + " AS " + dialect.QuoteIdentifier(col)
|
||||
} else {
|
||||
srcParts[i] = "? AS " + dialect.QuoteIdentifier(col)
|
||||
}
|
||||
}
|
||||
|
||||
onParts := make([]string, len(uniqueKeys))
|
||||
for i, key := range uniqueKeys {
|
||||
qk := dialect.QuoteIdentifier(key)
|
||||
onParts[i] = quotedTable + "." + qk + " = src." + qk
|
||||
}
|
||||
|
||||
updateParts := make([]string, len(updateColumns))
|
||||
for i, col := range updateColumns {
|
||||
qc := dialect.QuoteIdentifier(col)
|
||||
if raw, ok := rawValues[col]; ok {
|
||||
updateParts[i] = qc + " = " + raw
|
||||
} else {
|
||||
updateParts[i] = qc + " = src." + qc
|
||||
}
|
||||
}
|
||||
|
||||
insertCols := make([]string, len(columns))
|
||||
insertVals := make([]string, len(columns))
|
||||
for i, col := range columns {
|
||||
qc := dialect.QuoteIdentifier(col)
|
||||
insertCols[i] = qc
|
||||
insertVals[i] = "src." + qc
|
||||
}
|
||||
|
||||
return "MERGE INTO " + quotedTable + " USING (SELECT " + strings.Join(srcParts, ", ") +
|
||||
") src ON (" + strings.Join(onParts, " AND ") +
|
||||
") WHEN MATCHED THEN UPDATE SET " + strings.Join(updateParts, ", ") +
|
||||
" WHEN NOT MATCHED THEN INSERT (" + strings.Join(insertCols, ", ") +
|
||||
") VALUES (" + strings.Join(insertVals, ", ") + ")"
|
||||
}
|
||||
|
||||
// Update 更新数据
|
||||
func (that *HoTimeDB) Update(table string, data Map, where Map) int64 {
|
||||
processor := that.GetProcessor()
|
||||
query := "UPDATE " + processor.ProcessTableName(table) + " SET "
|
||||
qs := make([]interface{}, 0)
|
||||
tp := len(data)
|
||||
|
||||
for k, v := range data {
|
||||
vstr := "?"
|
||||
if Substr(k, len(k)-3, 3) == "[#]" {
|
||||
k = strings.Replace(k, "[#]", "", -1)
|
||||
vstr = ObjToStr(v)
|
||||
} else {
|
||||
qs = append(qs, v)
|
||||
}
|
||||
query += processor.ProcessColumnNoPrefix(k) + "=" + vstr + " "
|
||||
if tp--; tp != 0 {
|
||||
query += ", "
|
||||
}
|
||||
}
|
||||
|
||||
temp, resWhere := that.where(where)
|
||||
|
||||
query += temp + ";"
|
||||
qs = append(qs, resWhere...)
|
||||
|
||||
res, err := that.Exec(query, qs...)
|
||||
|
||||
rows := int64(0)
|
||||
if err == nil && res != nil {
|
||||
rows, _ = res.RowsAffected()
|
||||
}
|
||||
|
||||
// 如果更新成功,则删除缓存
|
||||
if rows != 0 {
|
||||
if that.HoTimeCache != nil && !that.isCacheTable(table) {
|
||||
_ = that.HoTimeCache.Db(table+"*", nil)
|
||||
}
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
// Delete 删除数据
|
||||
func (that *HoTimeDB) Delete(table string, data map[string]interface{}) int64 {
|
||||
processor := that.GetProcessor()
|
||||
query := "DELETE FROM " + processor.ProcessTableName(table) + " "
|
||||
|
||||
temp, resWhere := that.where(data)
|
||||
query += temp + ";"
|
||||
|
||||
res, err := that.Exec(query, resWhere...)
|
||||
rows := int64(0)
|
||||
if err == nil && res != nil {
|
||||
rows, _ = res.RowsAffected()
|
||||
}
|
||||
|
||||
// 如果删除成功,删除对应缓存
|
||||
if rows != 0 {
|
||||
if that.HoTimeCache != nil && !that.isCacheTable(table) {
|
||||
_ = that.HoTimeCache.Db(table+"*", nil)
|
||||
}
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"code.hoteas.com/golang/hotime/cache"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
"code.hoteas.com/golang/hotime/log"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
_ "gitee.com/chunanyong/dm"
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// DBError 最后一次 SQL 操作的结构化快照
|
||||
// 嵌入框架 Error 类型,与 Obj 等保持一致的错误 API(HasError/GetError/Unwrap)
|
||||
type DBError struct {
|
||||
Error
|
||||
Query string
|
||||
Data []interface{}
|
||||
}
|
||||
|
||||
// HoTimeDB 数据库操作核心结构体
|
||||
type HoTimeDB struct {
|
||||
*sql.DB
|
||||
ContextBase
|
||||
DBName string
|
||||
*cache.HoTimeCache
|
||||
Log *log.Logger
|
||||
Type string // 数据库类型: mysql, sqlite3, postgres
|
||||
Prefix string
|
||||
ConnectFunc func() (*sql.DB, *sql.DB)
|
||||
lastError atomic.Pointer[DBError] // 无锁读写,始终记录最后一次 SQL 操作
|
||||
limit Slice
|
||||
*sql.Tx //事务对象
|
||||
SlaveDB *sql.DB // 从数据库
|
||||
limitMu sync.Mutex
|
||||
Dialect Dialect // 数据库方言适配器
|
||||
testTx *sql.Tx // 测试事务:设置后所有操作都在此事务内,测试结束回滚
|
||||
testMu *sync.Mutex // 保护 testTx 单连接不被并发访问
|
||||
testLogBuf *bytes.Buffer // 测试模式:SQL 日志写入此 buffer 而非直接打印,失败时才输出
|
||||
testLogBufMu sync.Mutex // 保护 testLogBuf 并发写入
|
||||
testSQLErrCount int32 // 测试模式下 SQL 错误计数(atomic)
|
||||
txFailed *bool // 事务内是否有SQL出错,出错则事务必须回滚(指针确保按值传递 HoTimeDB 时状态共享)
|
||||
}
|
||||
|
||||
// GetLast 返回最后一次 SQL 操作的完整快照(Query + Data + Error)
|
||||
func (that *HoTimeDB) GetLast() *DBError {
|
||||
return that.lastError.Load()
|
||||
}
|
||||
|
||||
// GetLastError 返回最后一次 SQL 的错误(nil = 成功)
|
||||
func (that *HoTimeDB) GetLastError() error {
|
||||
if e := that.lastError.Load(); e != nil {
|
||||
return e.GetError()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLastQuery 返回最后一次执行的 SQL 语句
|
||||
func (that *HoTimeDB) GetLastQuery() string {
|
||||
if e := that.lastError.Load(); e != nil {
|
||||
return e.Query
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetLastData 返回最后一次 SQL 的参数
|
||||
func (that *HoTimeDB) GetLastData() []interface{} {
|
||||
if e := that.lastError.Load(); e != nil {
|
||||
return e.Data
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetConnect 设置数据库配置连接
|
||||
func (that *HoTimeDB) SetConnect(connect func() (master, slave *sql.DB)) {
|
||||
that.ConnectFunc = connect
|
||||
that.InitDb()
|
||||
}
|
||||
|
||||
// InitDb 初始化数据库连接
|
||||
func (that *HoTimeDB) InitDb() {
|
||||
that.DB, that.SlaveDB = that.ConnectFunc()
|
||||
if that.DB == nil {
|
||||
return
|
||||
}
|
||||
e := that.DB.Ping()
|
||||
if e != nil {
|
||||
dbErr := &DBError{}
|
||||
dbErr.SetError(e)
|
||||
that.lastError.Store(dbErr)
|
||||
if that.Log != nil {
|
||||
that.Log.Error().Err(e).Msg("数据库连接失败")
|
||||
}
|
||||
}
|
||||
|
||||
if that.SlaveDB != nil {
|
||||
e := that.SlaveDB.Ping()
|
||||
if e != nil {
|
||||
dbErr := &DBError{}
|
||||
dbErr.SetError(e)
|
||||
that.lastError.Store(dbErr)
|
||||
if that.Log != nil {
|
||||
that.Log.Error().Err(e).Msg("从数据库连接失败")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if that.Dialect == nil {
|
||||
that.initDialect()
|
||||
}
|
||||
}
|
||||
|
||||
// initDialect 根据数据库类型初始化方言
|
||||
func (that *HoTimeDB) initDialect() {
|
||||
switch that.Type {
|
||||
case "postgres", "postgresql":
|
||||
that.Dialect = &PostgreSQLDialect{}
|
||||
case "sqlite3", "sqlite":
|
||||
that.Dialect = &SQLiteDialect{}
|
||||
case "dm", "dameng":
|
||||
that.Dialect = &DMDialect{}
|
||||
default:
|
||||
that.Dialect = &MySQLDialect{}
|
||||
}
|
||||
}
|
||||
|
||||
// GetDialect 获取当前方言适配器
|
||||
func (that *HoTimeDB) GetDialect() Dialect {
|
||||
if that.Dialect == nil {
|
||||
that.initDialect()
|
||||
}
|
||||
return that.Dialect
|
||||
}
|
||||
|
||||
// SetDialect 设置方言适配器
|
||||
func (that *HoTimeDB) SetDialect(dialect Dialect) {
|
||||
that.Dialect = dialect
|
||||
}
|
||||
|
||||
// GetType 获取数据库类型
|
||||
func (that *HoTimeDB) GetType() string {
|
||||
return that.Type
|
||||
}
|
||||
|
||||
// GetPrefix 获取表前缀
|
||||
func (that *HoTimeDB) GetPrefix() string {
|
||||
return that.Prefix
|
||||
}
|
||||
|
||||
// GetProcessor 获取标识符处理器
|
||||
// 用于处理表名、字段名的前缀添加和引号转换
|
||||
func (that *HoTimeDB) GetProcessor() *IdentifierProcessor {
|
||||
return NewIdentifierProcessor(that.GetDialect(), that.Prefix)
|
||||
}
|
||||
|
||||
// T 辅助方法:获取带前缀和引号的表名
|
||||
// 用于手动构建 SQL 时使用
|
||||
// 示例: db.T("order") 返回 "`app_order`" (MySQL) 或 "\"app_order\"" (PostgreSQL)
|
||||
func (that *HoTimeDB) T(table string) string {
|
||||
return that.GetProcessor().ProcessTableName(table)
|
||||
}
|
||||
|
||||
// C 辅助方法:获取带前缀和引号的 table.column
|
||||
// 支持两种调用方式:
|
||||
// - db.C("order", "name") 返回 "`app_order`.`name`"
|
||||
// - db.C("order.name") 返回 "`app_order`.`name`"
|
||||
func (that *HoTimeDB) C(args ...string) string {
|
||||
if len(args) == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(args) == 1 {
|
||||
return that.GetProcessor().ProcessColumn(args[0])
|
||||
}
|
||||
// 两个参数: table, column
|
||||
dialect := that.GetDialect()
|
||||
table := args[0]
|
||||
column := args[1]
|
||||
// 去除已有引号
|
||||
table = trimQuotes(table)
|
||||
column = trimQuotes(column)
|
||||
return dialect.QuoteIdentifier(that.Prefix+table) + "." + dialect.QuoteIdentifier(column)
|
||||
}
|
||||
|
||||
// trimQuotes 去除字符串两端的引号
|
||||
func trimQuotes(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) >= 2 {
|
||||
if (s[0] == '`' && s[len(s)-1] == '`') || (s[0] == '"' && s[len(s)-1] == '"') {
|
||||
return s[1 : len(s)-1]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// SetTestLogBuffer 设置测试 SQL 日志缓冲区,非 nil 时 SQL 日志写入 buffer 而非直接打印
|
||||
func (that *HoTimeDB) SetTestLogBuffer(buf *bytes.Buffer) {
|
||||
that.testLogBufMu.Lock()
|
||||
that.testLogBuf = buf
|
||||
that.testLogBufMu.Unlock()
|
||||
}
|
||||
|
||||
// FlushTestLog 取出并清空缓冲区中的 SQL 日志,返回累积的日志文本
|
||||
func (that *HoTimeDB) FlushTestLog() string {
|
||||
that.testLogBufMu.Lock()
|
||||
defer that.testLogBufMu.Unlock()
|
||||
if that.testLogBuf == nil {
|
||||
return ""
|
||||
}
|
||||
s := that.testLogBuf.String()
|
||||
that.testLogBuf.Reset()
|
||||
return s
|
||||
}
|
||||
|
||||
// TestSQLErrorCount 返回测试模式下的 SQL 错误计数
|
||||
func (that *HoTimeDB) TestSQLErrorCount() int32 {
|
||||
return atomic.LoadInt32(&that.testSQLErrCount)
|
||||
}
|
||||
|
||||
// ResetTestSQLErrorCount 重置测试 SQL 错误计数
|
||||
func (that *HoTimeDB) ResetTestSQLErrorCount() {
|
||||
atomic.StoreInt32(&that.testSQLErrCount, 0)
|
||||
}
|
||||
|
||||
// BeginTestTx 开启测试事务,设置后所有 Query/Exec/Action 都在此事务内执行
|
||||
func (that *HoTimeDB) BeginTestTx() error {
|
||||
tx, err := that.DB.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
that.testTx = tx
|
||||
that.testMu = &sync.Mutex{}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RollbackTestTx 回滚测试事务,清除 testTx 状态
|
||||
func (that *HoTimeDB) RollbackTestTx() error {
|
||||
if that.testTx != nil {
|
||||
err := that.testTx.Rollback()
|
||||
that.testTx = nil
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+382
@@ -0,0 +1,382 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Dialect 数据库方言接口
|
||||
// 用于处理不同数据库之间的语法差异
|
||||
type Dialect interface {
|
||||
// Quote 对表名/字段名添加引号
|
||||
// MySQL 使用反引号 `name`
|
||||
// PostgreSQL 使用双引号 "name"
|
||||
// SQLite 使用双引号或方括号 "name" 或 [name]
|
||||
Quote(name string) string
|
||||
|
||||
// QuoteIdentifier 处理单个标识符(去除已有引号,添加正确引号)
|
||||
// 输入可能带有反引号或双引号,会先去除再添加正确格式
|
||||
QuoteIdentifier(name string) string
|
||||
|
||||
// QuoteChar 返回引号字符
|
||||
// MySQL: `
|
||||
// PostgreSQL/SQLite: "
|
||||
QuoteChar() string
|
||||
|
||||
// Placeholder 生成占位符
|
||||
// MySQL/SQLite 使用 ?
|
||||
// PostgreSQL 使用 $1, $2, $3...
|
||||
Placeholder(index int) string
|
||||
|
||||
// Placeholders 生成多个占位符,用逗号分隔
|
||||
Placeholders(count int, startIndex int) string
|
||||
|
||||
// SupportsLastInsertId 是否支持 LastInsertId
|
||||
// PostgreSQL 不支持,需要使用 RETURNING
|
||||
SupportsLastInsertId() bool
|
||||
|
||||
// ReturningClause 生成 RETURNING 子句(用于 PostgreSQL)
|
||||
ReturningClause(column string) string
|
||||
|
||||
// UpsertSQL 生成 Upsert 语句
|
||||
// MySQL: INSERT ... ON DUPLICATE KEY UPDATE ...
|
||||
// PostgreSQL: INSERT ... ON CONFLICT ... DO UPDATE SET ...
|
||||
// SQLite: INSERT OR REPLACE / INSERT ... ON CONFLICT ...
|
||||
UpsertSQL(table string, columns []string, uniqueKeys []string, updateColumns []string) string
|
||||
|
||||
// GetName 获取方言名称
|
||||
GetName() string
|
||||
}
|
||||
|
||||
// MySQLDialect MySQL 方言实现
|
||||
type MySQLDialect struct{}
|
||||
|
||||
func (d *MySQLDialect) GetName() string {
|
||||
return "mysql"
|
||||
}
|
||||
|
||||
func (d *MySQLDialect) Quote(name string) string {
|
||||
// 如果已经包含点号(表.字段)或空格(别名),不添加引号
|
||||
if strings.Contains(name, ".") || strings.Contains(name, " ") {
|
||||
return name
|
||||
}
|
||||
return "`" + name + "`"
|
||||
}
|
||||
|
||||
func (d *MySQLDialect) QuoteIdentifier(name string) string {
|
||||
// 去除已有的引号(反引号和双引号)
|
||||
name = strings.Trim(name, "`\"")
|
||||
return "`" + name + "`"
|
||||
}
|
||||
|
||||
func (d *MySQLDialect) QuoteChar() string {
|
||||
return "`"
|
||||
}
|
||||
|
||||
func (d *MySQLDialect) Placeholder(index int) string {
|
||||
return "?"
|
||||
}
|
||||
|
||||
func (d *MySQLDialect) Placeholders(count int, startIndex int) string {
|
||||
if count <= 0 {
|
||||
return ""
|
||||
}
|
||||
placeholders := make([]string, count)
|
||||
for i := 0; i < count; i++ {
|
||||
placeholders[i] = "?"
|
||||
}
|
||||
return strings.Join(placeholders, ",")
|
||||
}
|
||||
|
||||
func (d *MySQLDialect) SupportsLastInsertId() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (d *MySQLDialect) ReturningClause(column string) string {
|
||||
return "" // MySQL 不支持 RETURNING
|
||||
}
|
||||
|
||||
func (d *MySQLDialect) UpsertSQL(table string, columns []string, uniqueKeys []string, updateColumns []string) string {
|
||||
// INSERT INTO table (col1, col2) VALUES (?, ?)
|
||||
// ON DUPLICATE KEY UPDATE col1 = VALUES(col1), col2 = VALUES(col2)
|
||||
quotedCols := make([]string, len(columns))
|
||||
for i, col := range columns {
|
||||
quotedCols[i] = d.Quote(col)
|
||||
}
|
||||
|
||||
placeholders := d.Placeholders(len(columns), 1)
|
||||
|
||||
updateParts := make([]string, len(updateColumns))
|
||||
for i, col := range updateColumns {
|
||||
// 检查是否是 [#] 标记的直接 SQL
|
||||
if strings.HasSuffix(col, "[#]") {
|
||||
// 这种情况在调用处处理
|
||||
updateParts[i] = col
|
||||
} else {
|
||||
quotedCol := d.Quote(col)
|
||||
updateParts[i] = quotedCol + " = VALUES(" + quotedCol + ")"
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s) ON DUPLICATE KEY UPDATE %s",
|
||||
d.Quote(table),
|
||||
strings.Join(quotedCols, ", "),
|
||||
placeholders,
|
||||
strings.Join(updateParts, ", "))
|
||||
}
|
||||
|
||||
// PostgreSQLDialect PostgreSQL 方言实现
|
||||
type PostgreSQLDialect struct{}
|
||||
|
||||
func (d *PostgreSQLDialect) GetName() string {
|
||||
return "postgres"
|
||||
}
|
||||
|
||||
func (d *PostgreSQLDialect) Quote(name string) string {
|
||||
// 如果已经包含点号(表.字段)或空格(别名),不添加引号
|
||||
if strings.Contains(name, ".") || strings.Contains(name, " ") {
|
||||
return name
|
||||
}
|
||||
return "\"" + name + "\""
|
||||
}
|
||||
|
||||
func (d *PostgreSQLDialect) QuoteIdentifier(name string) string {
|
||||
// 去除已有的引号(反引号和双引号)
|
||||
name = strings.Trim(name, "`\"")
|
||||
return "\"" + name + "\""
|
||||
}
|
||||
|
||||
func (d *PostgreSQLDialect) QuoteChar() string {
|
||||
return "\""
|
||||
}
|
||||
|
||||
func (d *PostgreSQLDialect) Placeholder(index int) string {
|
||||
return fmt.Sprintf("$%d", index)
|
||||
}
|
||||
|
||||
func (d *PostgreSQLDialect) Placeholders(count int, startIndex int) string {
|
||||
if count <= 0 {
|
||||
return ""
|
||||
}
|
||||
placeholders := make([]string, count)
|
||||
for i := 0; i < count; i++ {
|
||||
placeholders[i] = fmt.Sprintf("$%d", startIndex+i)
|
||||
}
|
||||
return strings.Join(placeholders, ",")
|
||||
}
|
||||
|
||||
func (d *PostgreSQLDialect) SupportsLastInsertId() bool {
|
||||
return false // PostgreSQL 需要使用 RETURNING
|
||||
}
|
||||
|
||||
func (d *PostgreSQLDialect) ReturningClause(column string) string {
|
||||
return " RETURNING " + d.Quote(column)
|
||||
}
|
||||
|
||||
func (d *PostgreSQLDialect) UpsertSQL(table string, columns []string, uniqueKeys []string, updateColumns []string) string {
|
||||
// INSERT INTO table (col1, col2) VALUES ($1, $2)
|
||||
// ON CONFLICT (unique_key) DO UPDATE SET col1 = EXCLUDED.col1, col2 = EXCLUDED.col2
|
||||
quotedCols := make([]string, len(columns))
|
||||
for i, col := range columns {
|
||||
quotedCols[i] = d.Quote(col)
|
||||
}
|
||||
|
||||
placeholders := d.Placeholders(len(columns), 1)
|
||||
|
||||
quotedUniqueKeys := make([]string, len(uniqueKeys))
|
||||
for i, key := range uniqueKeys {
|
||||
quotedUniqueKeys[i] = d.Quote(key)
|
||||
}
|
||||
|
||||
updateParts := make([]string, len(updateColumns))
|
||||
for i, col := range updateColumns {
|
||||
if strings.HasSuffix(col, "[#]") {
|
||||
updateParts[i] = col
|
||||
} else {
|
||||
quotedCol := d.Quote(col)
|
||||
updateParts[i] = quotedCol + " = EXCLUDED." + quotedCol
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s) ON CONFLICT (%s) DO UPDATE SET %s",
|
||||
d.Quote(table),
|
||||
strings.Join(quotedCols, ", "),
|
||||
placeholders,
|
||||
strings.Join(quotedUniqueKeys, ", "),
|
||||
strings.Join(updateParts, ", "))
|
||||
}
|
||||
|
||||
// DMDialect 达梦数据库方言实现
|
||||
type DMDialect struct{}
|
||||
|
||||
func (d *DMDialect) GetName() string {
|
||||
return "dm"
|
||||
}
|
||||
|
||||
func (d *DMDialect) Quote(name string) string {
|
||||
if strings.Contains(name, ".") || strings.Contains(name, " ") {
|
||||
return name
|
||||
}
|
||||
return "\"" + name + "\""
|
||||
}
|
||||
|
||||
func (d *DMDialect) QuoteIdentifier(name string) string {
|
||||
name = strings.Trim(name, "`\"")
|
||||
return "\"" + name + "\""
|
||||
}
|
||||
|
||||
func (d *DMDialect) QuoteChar() string {
|
||||
return "\""
|
||||
}
|
||||
|
||||
func (d *DMDialect) Placeholder(index int) string {
|
||||
return "?"
|
||||
}
|
||||
|
||||
func (d *DMDialect) Placeholders(count int, startIndex int) string {
|
||||
if count <= 0 {
|
||||
return ""
|
||||
}
|
||||
placeholders := make([]string, count)
|
||||
for i := 0; i < count; i++ {
|
||||
placeholders[i] = "?"
|
||||
}
|
||||
return strings.Join(placeholders, ",")
|
||||
}
|
||||
|
||||
func (d *DMDialect) SupportsLastInsertId() bool {
|
||||
return true // DM Go 驱动原生支持 IDENTITY 列的 LastInsertId
|
||||
}
|
||||
|
||||
func (d *DMDialect) ReturningClause(column string) string {
|
||||
return " RETURNING " + d.Quote(column)
|
||||
}
|
||||
|
||||
func (d *DMDialect) UpsertSQL(table string, columns []string, uniqueKeys []string, updateColumns []string) string {
|
||||
// MERGE INTO table USING (SELECT ? AS col1, ? AS col2) src
|
||||
// ON (table.uk = src.uk) WHEN MATCHED THEN UPDATE SET col1 = src.col1
|
||||
// WHEN NOT MATCHED THEN INSERT (col1, col2) VALUES (src.col1, src.col2)
|
||||
quotedTable := d.Quote(table)
|
||||
|
||||
srcParts := make([]string, len(columns))
|
||||
for i, col := range columns {
|
||||
if strings.HasSuffix(col, "[#]") {
|
||||
srcParts[i] = col
|
||||
} else {
|
||||
srcParts[i] = "? AS " + d.Quote(col)
|
||||
}
|
||||
}
|
||||
|
||||
onParts := make([]string, len(uniqueKeys))
|
||||
for i, key := range uniqueKeys {
|
||||
qk := d.Quote(key)
|
||||
onParts[i] = quotedTable + "." + qk + " = src." + qk
|
||||
}
|
||||
|
||||
updateParts := make([]string, len(updateColumns))
|
||||
for i, col := range updateColumns {
|
||||
if strings.HasSuffix(col, "[#]") {
|
||||
updateParts[i] = col
|
||||
} else {
|
||||
qc := d.Quote(col)
|
||||
updateParts[i] = qc + " = src." + qc
|
||||
}
|
||||
}
|
||||
|
||||
insertCols := make([]string, len(columns))
|
||||
insertVals := make([]string, len(columns))
|
||||
for i, col := range columns {
|
||||
qc := d.Quote(col)
|
||||
insertCols[i] = qc
|
||||
insertVals[i] = "src." + qc
|
||||
}
|
||||
|
||||
return fmt.Sprintf("MERGE INTO %s USING (SELECT %s) src ON (%s) WHEN MATCHED THEN UPDATE SET %s WHEN NOT MATCHED THEN INSERT (%s) VALUES (%s)",
|
||||
quotedTable,
|
||||
strings.Join(srcParts, ", "),
|
||||
strings.Join(onParts, " AND "),
|
||||
strings.Join(updateParts, ", "),
|
||||
strings.Join(insertCols, ", "),
|
||||
strings.Join(insertVals, ", "))
|
||||
}
|
||||
|
||||
// SQLiteDialect SQLite 方言实现
|
||||
type SQLiteDialect struct{}
|
||||
|
||||
func (d *SQLiteDialect) GetName() string {
|
||||
return "sqlite3"
|
||||
}
|
||||
|
||||
func (d *SQLiteDialect) Quote(name string) string {
|
||||
// 如果已经包含点号(表.字段)或空格(别名),不添加引号
|
||||
if strings.Contains(name, ".") || strings.Contains(name, " ") {
|
||||
return name
|
||||
}
|
||||
return "\"" + name + "\""
|
||||
}
|
||||
|
||||
func (d *SQLiteDialect) QuoteIdentifier(name string) string {
|
||||
// 去除已有的引号(反引号和双引号)
|
||||
name = strings.Trim(name, "`\"")
|
||||
return "\"" + name + "\""
|
||||
}
|
||||
|
||||
func (d *SQLiteDialect) QuoteChar() string {
|
||||
return "\""
|
||||
}
|
||||
|
||||
func (d *SQLiteDialect) Placeholder(index int) string {
|
||||
return "?"
|
||||
}
|
||||
|
||||
func (d *SQLiteDialect) Placeholders(count int, startIndex int) string {
|
||||
if count <= 0 {
|
||||
return ""
|
||||
}
|
||||
placeholders := make([]string, count)
|
||||
for i := 0; i < count; i++ {
|
||||
placeholders[i] = "?"
|
||||
}
|
||||
return strings.Join(placeholders, ",")
|
||||
}
|
||||
|
||||
func (d *SQLiteDialect) SupportsLastInsertId() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (d *SQLiteDialect) ReturningClause(column string) string {
|
||||
return "" // SQLite 3.35+ 支持 RETURNING,但为兼容性暂不使用
|
||||
}
|
||||
|
||||
func (d *SQLiteDialect) UpsertSQL(table string, columns []string, uniqueKeys []string, updateColumns []string) string {
|
||||
// INSERT INTO table (col1, col2) VALUES (?, ?)
|
||||
// ON CONFLICT (unique_key) DO UPDATE SET col1 = excluded.col1, col2 = excluded.col2
|
||||
quotedCols := make([]string, len(columns))
|
||||
for i, col := range columns {
|
||||
quotedCols[i] = d.Quote(col)
|
||||
}
|
||||
|
||||
placeholders := d.Placeholders(len(columns), 1)
|
||||
|
||||
quotedUniqueKeys := make([]string, len(uniqueKeys))
|
||||
for i, key := range uniqueKeys {
|
||||
quotedUniqueKeys[i] = d.Quote(key)
|
||||
}
|
||||
|
||||
updateParts := make([]string, len(updateColumns))
|
||||
for i, col := range updateColumns {
|
||||
if strings.HasSuffix(col, "[#]") {
|
||||
updateParts[i] = col
|
||||
} else {
|
||||
quotedCol := d.Quote(col)
|
||||
updateParts[i] = quotedCol + " = excluded." + quotedCol
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s) ON CONFLICT (%s) DO UPDATE SET %s",
|
||||
d.Quote(table),
|
||||
strings.Join(quotedCols, ", "),
|
||||
placeholders,
|
||||
strings.Join(quotedUniqueKeys, ", "),
|
||||
strings.Join(updateParts, ", "))
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDialectQuoteIdentifier 测试方言的 QuoteIdentifier 方法
|
||||
func TestDialectQuoteIdentifier(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dialect Dialect
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
// MySQL 方言测试
|
||||
{"MySQL simple", &MySQLDialect{}, "name", "`name`"},
|
||||
{"MySQL with backticks", &MySQLDialect{}, "`name`", "`name`"},
|
||||
{"MySQL with quotes", &MySQLDialect{}, "\"name\"", "`name`"},
|
||||
|
||||
// PostgreSQL 方言测试
|
||||
{"PostgreSQL simple", &PostgreSQLDialect{}, "name", "\"name\""},
|
||||
{"PostgreSQL with backticks", &PostgreSQLDialect{}, "`name`", "\"name\""},
|
||||
{"PostgreSQL with quotes", &PostgreSQLDialect{}, "\"name\"", "\"name\""},
|
||||
|
||||
// SQLite 方言测试
|
||||
{"SQLite simple", &SQLiteDialect{}, "name", "\"name\""},
|
||||
{"SQLite with backticks", &SQLiteDialect{}, "`name`", "\"name\""},
|
||||
{"SQLite with quotes", &SQLiteDialect{}, "\"name\"", "\"name\""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.dialect.QuoteIdentifier(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("QuoteIdentifier(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDialectQuoteChar 测试方言的 QuoteChar 方法
|
||||
func TestDialectQuoteChar(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dialect Dialect
|
||||
expected string
|
||||
}{
|
||||
{"MySQL", &MySQLDialect{}, "`"},
|
||||
{"PostgreSQL", &PostgreSQLDialect{}, "\""},
|
||||
{"SQLite", &SQLiteDialect{}, "\""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.dialect.QuoteChar()
|
||||
if result != tt.expected {
|
||||
t.Errorf("QuoteChar() = %q, want %q", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIdentifierProcessorTableName 测试表名处理
|
||||
func TestIdentifierProcessorTableName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dialect Dialect
|
||||
prefix string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
// MySQL 无前缀
|
||||
{"MySQL no prefix", &MySQLDialect{}, "", "order", "`order`"},
|
||||
{"MySQL no prefix with backticks", &MySQLDialect{}, "", "`order`", "`order`"},
|
||||
|
||||
// MySQL 有前缀
|
||||
{"MySQL with prefix", &MySQLDialect{}, "app_", "order", "`app_order`"},
|
||||
{"MySQL with prefix and backticks", &MySQLDialect{}, "app_", "`order`", "`app_order`"},
|
||||
|
||||
// PostgreSQL 无前缀
|
||||
{"PostgreSQL no prefix", &PostgreSQLDialect{}, "", "order", "\"order\""},
|
||||
|
||||
// PostgreSQL 有前缀
|
||||
{"PostgreSQL with prefix", &PostgreSQLDialect{}, "app_", "order", "\"app_order\""},
|
||||
{"PostgreSQL with prefix and quotes", &PostgreSQLDialect{}, "app_", "\"order\"", "\"app_order\""},
|
||||
|
||||
// SQLite 有前缀
|
||||
{"SQLite with prefix", &SQLiteDialect{}, "app_", "user", "\"app_user\""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
processor := NewIdentifierProcessor(tt.dialect, tt.prefix)
|
||||
result := processor.ProcessTableName(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("ProcessTableName(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIdentifierProcessorColumn 测试列名处理(包括 table.column 格式)
|
||||
func TestIdentifierProcessorColumn(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dialect Dialect
|
||||
prefix string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
// 单独列名
|
||||
{"MySQL simple column", &MySQLDialect{}, "", "name", "`name`"},
|
||||
{"MySQL simple column with prefix", &MySQLDialect{}, "app_", "name", "`name`"},
|
||||
|
||||
// table.column 格式
|
||||
{"MySQL table.column no prefix", &MySQLDialect{}, "", "order.name", "`order`.`name`"},
|
||||
{"MySQL table.column with prefix", &MySQLDialect{}, "app_", "order.name", "`app_order`.`name`"},
|
||||
{"MySQL table.column with backticks", &MySQLDialect{}, "app_", "`order`.name", "`app_order`.`name`"},
|
||||
|
||||
// PostgreSQL
|
||||
{"PostgreSQL table.column with prefix", &PostgreSQLDialect{}, "app_", "order.name", "\"app_order\".\"name\""},
|
||||
{"PostgreSQL table.column with quotes", &PostgreSQLDialect{}, "app_", "\"order\".name", "\"app_order\".\"name\""},
|
||||
|
||||
// SQLite
|
||||
{"SQLite table.column with prefix", &SQLiteDialect{}, "app_", "user.email", "\"app_user\".\"email\""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
processor := NewIdentifierProcessor(tt.dialect, tt.prefix)
|
||||
result := processor.ProcessColumn(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("ProcessColumn(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIdentifierProcessorConditionString 测试条件字符串处理
|
||||
func TestIdentifierProcessorConditionString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dialect Dialect
|
||||
prefix string
|
||||
input string
|
||||
contains []string // 结果应该包含这些字符串
|
||||
}{
|
||||
// MySQL 简单条件
|
||||
{
|
||||
"MySQL simple condition",
|
||||
&MySQLDialect{},
|
||||
"app_",
|
||||
"user.id = order.user_id",
|
||||
[]string{"`app_user`", "`app_order`"},
|
||||
},
|
||||
// MySQL 复杂条件
|
||||
{
|
||||
"MySQL complex condition",
|
||||
&MySQLDialect{},
|
||||
"app_",
|
||||
"user.id = order.user_id AND order.status = 1",
|
||||
[]string{"`app_user`", "`app_order`"},
|
||||
},
|
||||
// PostgreSQL
|
||||
{
|
||||
"PostgreSQL condition",
|
||||
&PostgreSQLDialect{},
|
||||
"app_",
|
||||
"user.id = order.user_id",
|
||||
[]string{"\"app_user\"", "\"app_order\""},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
processor := NewIdentifierProcessor(tt.dialect, tt.prefix)
|
||||
result := processor.ProcessConditionString(tt.input)
|
||||
for _, expected := range tt.contains {
|
||||
if !strings.Contains(result, expected) {
|
||||
t.Errorf("ProcessConditionString(%q) = %q, should contain %q", tt.input, result, expected)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHoTimeDBHelperMethods 测试 HoTimeDB 的辅助方法 T() 和 C()
|
||||
func TestHoTimeDBHelperMethods(t *testing.T) {
|
||||
// 创建 MySQL 数据库实例
|
||||
mysqlDB := &HoTimeDB{
|
||||
Type: "mysql",
|
||||
Prefix: "app_",
|
||||
}
|
||||
mysqlDB.initDialect()
|
||||
|
||||
// 测试 T() 方法
|
||||
t.Run("MySQL T() method", func(t *testing.T) {
|
||||
result := mysqlDB.T("order")
|
||||
expected := "`app_order`"
|
||||
if result != expected {
|
||||
t.Errorf("T(\"order\") = %q, want %q", result, expected)
|
||||
}
|
||||
})
|
||||
|
||||
// 测试 C() 方法(两个参数)
|
||||
t.Run("MySQL C() method with two args", func(t *testing.T) {
|
||||
result := mysqlDB.C("order", "name")
|
||||
expected := "`app_order`.`name`"
|
||||
if result != expected {
|
||||
t.Errorf("C(\"order\", \"name\") = %q, want %q", result, expected)
|
||||
}
|
||||
})
|
||||
|
||||
// 测试 C() 方法(一个参数,点号格式)
|
||||
t.Run("MySQL C() method with dot notation", func(t *testing.T) {
|
||||
result := mysqlDB.C("order.name")
|
||||
expected := "`app_order`.`name`"
|
||||
if result != expected {
|
||||
t.Errorf("C(\"order.name\") = %q, want %q", result, expected)
|
||||
}
|
||||
})
|
||||
|
||||
// 创建 PostgreSQL 数据库实例
|
||||
pgDB := &HoTimeDB{
|
||||
Type: "postgres",
|
||||
Prefix: "app_",
|
||||
}
|
||||
pgDB.initDialect()
|
||||
|
||||
// 测试 PostgreSQL 的 T() 方法
|
||||
t.Run("PostgreSQL T() method", func(t *testing.T) {
|
||||
result := pgDB.T("order")
|
||||
expected := "\"app_order\""
|
||||
if result != expected {
|
||||
t.Errorf("T(\"order\") = %q, want %q", result, expected)
|
||||
}
|
||||
})
|
||||
|
||||
// 测试 PostgreSQL 的 C() 方法
|
||||
t.Run("PostgreSQL C() method", func(t *testing.T) {
|
||||
result := pgDB.C("order", "name")
|
||||
expected := "\"app_order\".\"name\""
|
||||
if result != expected {
|
||||
t.Errorf("C(\"order\", \"name\") = %q, want %q", result, expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestWhereWithORCondition 测试 OR 条件处理是否正确添加括号
|
||||
func TestWhereWithORCondition(t *testing.T) {
|
||||
// 创建 MySQL 数据库实例
|
||||
mysqlDB := &HoTimeDB{
|
||||
Type: "mysql",
|
||||
Prefix: "",
|
||||
}
|
||||
mysqlDB.initDialect()
|
||||
|
||||
// 测试 OR 与普通条件组合 (假设 A: 顺序问题)
|
||||
t.Run("OR with normal condition", func(t *testing.T) {
|
||||
data := Map{
|
||||
"OR": Map{
|
||||
"username": "test",
|
||||
"phone": "123",
|
||||
},
|
||||
"state": 0,
|
||||
}
|
||||
|
||||
where, params := mysqlDB.where(data)
|
||||
fmt.Println("Test 1 - OR with normal condition:")
|
||||
fmt.Println(" Generated WHERE:", where)
|
||||
fmt.Println(" Params count:", len(params))
|
||||
|
||||
// 检查 OR 条件是否被括号包裹
|
||||
if !strings.Contains(where, "(") || !strings.Contains(where, ")") {
|
||||
t.Errorf("OR condition should be wrapped with parentheses, got: %s", where)
|
||||
}
|
||||
|
||||
// 检查是否有 AND 连接
|
||||
if !strings.Contains(where, "AND") {
|
||||
t.Errorf("OR condition and normal condition should be connected with AND, got: %s", where)
|
||||
}
|
||||
})
|
||||
|
||||
// 测试纯 OR 条件(无其他普通条件)
|
||||
t.Run("Pure OR condition", func(t *testing.T) {
|
||||
data := Map{
|
||||
"OR": Map{
|
||||
"username": "test",
|
||||
"phone": "123",
|
||||
},
|
||||
}
|
||||
|
||||
where, params := mysqlDB.where(data)
|
||||
fmt.Println("Test 2 - Pure OR condition:")
|
||||
fmt.Println(" Generated WHERE:", where)
|
||||
fmt.Println(" Params count:", len(params))
|
||||
|
||||
// 检查 OR 条件内部应该用 OR 连接
|
||||
if !strings.Contains(where, "OR") {
|
||||
t.Errorf("OR condition should contain OR keyword, got: %s", where)
|
||||
}
|
||||
})
|
||||
|
||||
// 测试多个普通条件与 OR 组合 (假设 A)
|
||||
t.Run("OR with multiple normal conditions", func(t *testing.T) {
|
||||
data := Map{
|
||||
"OR": Map{
|
||||
"username": "test",
|
||||
"phone": "123",
|
||||
},
|
||||
"state": 0,
|
||||
"status": 1,
|
||||
}
|
||||
|
||||
where, params := mysqlDB.where(data)
|
||||
fmt.Println("Test 3 - OR with multiple normal conditions:")
|
||||
fmt.Println(" Generated WHERE:", where)
|
||||
fmt.Println(" Params count:", len(params))
|
||||
|
||||
// 应该有括号
|
||||
if !strings.Contains(where, "(") {
|
||||
t.Errorf("OR condition should be wrapped with parentheses, got: %s", where)
|
||||
}
|
||||
})
|
||||
|
||||
// 测试嵌套 AND/OR 条件 (假设 B, E)
|
||||
t.Run("Nested AND/OR conditions", func(t *testing.T) {
|
||||
data := Map{
|
||||
"OR": Map{
|
||||
"username": "test",
|
||||
"AND": Map{
|
||||
"phone": "123",
|
||||
"status": 1,
|
||||
},
|
||||
},
|
||||
"state": 0,
|
||||
}
|
||||
|
||||
where, params := mysqlDB.where(data)
|
||||
fmt.Println("Test 4 - Nested AND/OR conditions:")
|
||||
fmt.Println(" Generated WHERE:", where)
|
||||
fmt.Println(" Params count:", len(params))
|
||||
})
|
||||
|
||||
// 测试空 OR 条件 (假设 C)
|
||||
t.Run("Empty OR condition", func(t *testing.T) {
|
||||
data := Map{
|
||||
"OR": Map{},
|
||||
"state": 0,
|
||||
}
|
||||
|
||||
where, params := mysqlDB.where(data)
|
||||
fmt.Println("Test 5 - Empty OR condition:")
|
||||
fmt.Println(" Generated WHERE:", where)
|
||||
fmt.Println(" Params count:", len(params))
|
||||
})
|
||||
|
||||
// 测试 OR 与 LIMIT, ORDER 组合 (假设 D)
|
||||
t.Run("OR with LIMIT and ORDER", func(t *testing.T) {
|
||||
data := Map{
|
||||
"OR": Map{
|
||||
"username": "test",
|
||||
"phone": "123",
|
||||
},
|
||||
"state": 0,
|
||||
"ORDER": "id DESC",
|
||||
"LIMIT": 10,
|
||||
}
|
||||
|
||||
where, params := mysqlDB.where(data)
|
||||
fmt.Println("Test 6 - OR with LIMIT and ORDER:")
|
||||
fmt.Println(" Generated WHERE:", where)
|
||||
fmt.Println(" Params count:", len(params))
|
||||
})
|
||||
|
||||
// 测试同时有 OR 和 AND 关键字 (假设 E)
|
||||
t.Run("Both OR and AND keywords", func(t *testing.T) {
|
||||
data := Map{
|
||||
"OR": Map{
|
||||
"username": "test",
|
||||
"phone": "123",
|
||||
},
|
||||
"AND": Map{
|
||||
"type": 1,
|
||||
"source": "web",
|
||||
},
|
||||
"state": 0,
|
||||
}
|
||||
|
||||
where, params := mysqlDB.where(data)
|
||||
fmt.Println("Test 7 - Both OR and AND keywords:")
|
||||
fmt.Println(" Generated WHERE:", where)
|
||||
fmt.Println(" Params count:", len(params))
|
||||
})
|
||||
|
||||
// 测试普通条件在 OR 之前(排序后)(假设 A)
|
||||
t.Run("Normal condition before OR alphabetically", func(t *testing.T) {
|
||||
data := Map{
|
||||
"OR": Map{
|
||||
"username": "test",
|
||||
"phone": "123",
|
||||
},
|
||||
"active": 1, // 'a' 在 'O' 之前
|
||||
}
|
||||
|
||||
where, params := mysqlDB.where(data)
|
||||
fmt.Println("Test 8 - Normal condition before OR (alphabetically):")
|
||||
fmt.Println(" Generated WHERE:", where)
|
||||
fmt.Println(" Params count:", len(params))
|
||||
})
|
||||
}
|
||||
|
||||
// 打印测试结果(用于调试)
|
||||
func ExampleIdentifierProcessor() {
|
||||
// MySQL 示例
|
||||
mysqlProcessor := NewIdentifierProcessor(&MySQLDialect{}, "app_")
|
||||
fmt.Println("MySQL:")
|
||||
fmt.Println(" Table:", mysqlProcessor.ProcessTableName("order"))
|
||||
fmt.Println(" Column:", mysqlProcessor.ProcessColumn("order.name"))
|
||||
fmt.Println(" Condition:", mysqlProcessor.ProcessConditionString("user.id = order.user_id"))
|
||||
|
||||
// PostgreSQL 示例
|
||||
pgProcessor := NewIdentifierProcessor(&PostgreSQLDialect{}, "app_")
|
||||
fmt.Println("PostgreSQL:")
|
||||
fmt.Println(" Table:", pgProcessor.ProcessTableName("order"))
|
||||
fmt.Println(" Column:", pgProcessor.ProcessColumn("order.name"))
|
||||
fmt.Println(" Condition:", pgProcessor.ProcessConditionString("user.id = order.user_id"))
|
||||
|
||||
// Output:
|
||||
// MySQL:
|
||||
// Table: `app_order`
|
||||
// Column: `app_order`.`name`
|
||||
// Condition: `app_user`.`id` = `app_order`.`user_id`
|
||||
// PostgreSQL:
|
||||
// Table: "app_order"
|
||||
// Column: "app_order"."name"
|
||||
// Condition: "app_user"."id" = "app_order"."user_id"
|
||||
}
|
||||
-1372
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,260 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IdentifierProcessor 标识符处理器
|
||||
// 用于处理表名、字段名的前缀添加和引号转换
|
||||
type IdentifierProcessor struct {
|
||||
dialect Dialect
|
||||
prefix string
|
||||
}
|
||||
|
||||
// NewIdentifierProcessor 创建标识符处理器
|
||||
func NewIdentifierProcessor(dialect Dialect, prefix string) *IdentifierProcessor {
|
||||
return &IdentifierProcessor{
|
||||
dialect: dialect,
|
||||
prefix: prefix,
|
||||
}
|
||||
}
|
||||
|
||||
// 系统数据库列表,这些数据库不添加前缀
|
||||
var systemDatabases = map[string]bool{
|
||||
"INFORMATION_SCHEMA": true,
|
||||
"information_schema": true,
|
||||
"mysql": true,
|
||||
"performance_schema": true,
|
||||
"sys": true,
|
||||
"pg_catalog": true,
|
||||
"pg_toast": true,
|
||||
}
|
||||
|
||||
// ProcessTableName 处理表名(添加前缀+引号)
|
||||
// 输入: "order" 或 "`order`" 或 "\"order\"" 或 "INFORMATION_SCHEMA.TABLES"
|
||||
// 输出: "`app_order`" (MySQL) 或 "\"app_order\"" (PostgreSQL/SQLite)
|
||||
// 对于 database.table 格式,会分别处理,系统数据库不添加前缀
|
||||
func (p *IdentifierProcessor) ProcessTableName(name string) string {
|
||||
// 去除已有的引号
|
||||
name = p.stripQuotes(name)
|
||||
|
||||
// 检查是否包含空格(别名情况,如 "order AS o")
|
||||
if strings.Contains(name, " ") {
|
||||
// 处理别名情况
|
||||
parts := strings.SplitN(name, " ", 2)
|
||||
tableName := p.stripQuotes(parts[0])
|
||||
alias := parts[1]
|
||||
// 递归处理表名部分(可能包含点号)
|
||||
return p.ProcessTableName(tableName) + " " + alias
|
||||
}
|
||||
|
||||
// 检查是否包含点号(database.table 格式)
|
||||
if strings.Contains(name, ".") {
|
||||
parts := p.splitTableColumn(name)
|
||||
if len(parts) == 2 {
|
||||
dbName := p.stripQuotes(parts[0])
|
||||
tableName := p.stripQuotes(parts[1])
|
||||
// 系统数据库不添加前缀
|
||||
if systemDatabases[dbName] {
|
||||
return p.dialect.QuoteIdentifier(dbName) + "." + p.dialect.QuoteIdentifier(tableName)
|
||||
}
|
||||
// 非系统数据库,只给表名添加前缀
|
||||
return p.dialect.QuoteIdentifier(dbName) + "." + p.dialect.QuoteIdentifier(p.prefix+tableName)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加前缀和引号
|
||||
return p.dialect.QuoteIdentifier(p.prefix + name)
|
||||
}
|
||||
|
||||
// ProcessTableNameNoPrefix 处理表名(只添加引号,不添加前缀)
|
||||
// 用于已经包含前缀的情况
|
||||
func (p *IdentifierProcessor) ProcessTableNameNoPrefix(name string) string {
|
||||
name = p.stripQuotes(name)
|
||||
if strings.Contains(name, " ") {
|
||||
parts := strings.SplitN(name, " ", 2)
|
||||
tableName := p.stripQuotes(parts[0])
|
||||
alias := parts[1]
|
||||
return p.dialect.QuoteIdentifier(tableName) + " " + alias
|
||||
}
|
||||
return p.dialect.QuoteIdentifier(name)
|
||||
}
|
||||
|
||||
// ProcessColumn 处理 table.column 格式
|
||||
// 输入: "name" 或 "order.name" 或 "`order`.name" 或 "`order`.`name`"
|
||||
// 输出: "`name`" 或 "`app_order`.`name`" (MySQL) / "\"app_order\".\"name\"" (DM/PG)
|
||||
func (p *IdentifierProcessor) ProcessColumn(name string) string {
|
||||
if !strings.Contains(name, ".") {
|
||||
return p.dialect.QuoteIdentifier(p.stripQuotes(name))
|
||||
}
|
||||
|
||||
parts := p.splitTableColumn(name)
|
||||
if len(parts) == 2 {
|
||||
tableName := p.stripQuotes(parts[0])
|
||||
columnName := p.stripQuotes(parts[1])
|
||||
return p.dialect.QuoteIdentifier(p.prefix+tableName) + "." + p.dialect.QuoteIdentifier(columnName)
|
||||
}
|
||||
|
||||
return p.convertQuotes(name)
|
||||
}
|
||||
|
||||
// ProcessColumnNoPrefix 处理 table.column 格式(不添加前缀)
|
||||
func (p *IdentifierProcessor) ProcessColumnNoPrefix(name string) string {
|
||||
if !strings.Contains(name, ".") {
|
||||
return p.dialect.QuoteIdentifier(p.stripQuotes(name))
|
||||
}
|
||||
|
||||
parts := p.splitTableColumn(name)
|
||||
if len(parts) == 2 {
|
||||
tableName := p.stripQuotes(parts[0])
|
||||
columnName := p.stripQuotes(parts[1])
|
||||
return p.dialect.QuoteIdentifier(tableName) + "." + p.dialect.QuoteIdentifier(columnName)
|
||||
}
|
||||
|
||||
return p.convertQuotes(name)
|
||||
}
|
||||
|
||||
// ProcessConditionString 智能解析条件字符串(如 ON 条件)
|
||||
// 输入: "user.id = order.user_id AND order.status = 1"
|
||||
// 输出: "`app_user`.`id` = `app_order`.`user_id` AND `app_order`.`status` = 1" (MySQL)
|
||||
// 输出: "\"app_user\".\"id\" = \"app_order\".\"user_id\" AND \"app_order\".\"status\" = 1" (DM/PG)
|
||||
func (p *IdentifierProcessor) ProcessConditionString(condition string) string {
|
||||
if condition == "" {
|
||||
return condition
|
||||
}
|
||||
|
||||
result := condition
|
||||
|
||||
// 首先处理已有完整引号的情况 `table`.`column` 或 "table"."column"
|
||||
fullyQuotedPattern := regexp.MustCompile("[`\"]([a-zA-Z_][a-zA-Z0-9_]*)[`\"]\\.[`\"]([a-zA-Z_][a-zA-Z0-9_]*)[`\"]")
|
||||
result = fullyQuotedPattern.ReplaceAllStringFunc(result, func(match string) string {
|
||||
parts := fullyQuotedPattern.FindStringSubmatch(match)
|
||||
if len(parts) == 3 {
|
||||
tableName := parts[1]
|
||||
colName := parts[2]
|
||||
return p.dialect.QuoteIdentifier(p.prefix+tableName) + "." + p.dialect.QuoteIdentifier(colName)
|
||||
}
|
||||
return match
|
||||
})
|
||||
|
||||
// 然后处理部分引号的情况 `table`.column 或 "table".column
|
||||
quotedTablePattern := regexp.MustCompile("[`\"]([a-zA-Z_][a-zA-Z0-9_]*)[`\"]\\.([a-zA-Z_][a-zA-Z0-9_]*)(?:[^`\"]|$)")
|
||||
result = quotedTablePattern.ReplaceAllStringFunc(result, func(match string) string {
|
||||
parts := quotedTablePattern.FindStringSubmatch(match)
|
||||
if len(parts) >= 3 {
|
||||
tableName := parts[1]
|
||||
colName := parts[2]
|
||||
suffix := ""
|
||||
if len(match) > len(parts[0])-1 {
|
||||
lastChar := match[len(match)-1]
|
||||
if lastChar != '`' && lastChar != '"' && !isIdentChar(lastChar) {
|
||||
suffix = string(lastChar)
|
||||
}
|
||||
}
|
||||
return p.dialect.QuoteIdentifier(p.prefix+tableName) + "." + p.dialect.QuoteIdentifier(colName) + suffix
|
||||
}
|
||||
return match
|
||||
})
|
||||
|
||||
// 最后处理无引号的情况 table.column
|
||||
unquotedPattern := regexp.MustCompile(`([^` + "`" + `"\w]|^)([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)([^` + "`" + `"\w(]|$)`)
|
||||
result = unquotedPattern.ReplaceAllStringFunc(result, func(match string) string {
|
||||
parts := unquotedPattern.FindStringSubmatch(match)
|
||||
if len(parts) >= 5 {
|
||||
prefix := parts[1]
|
||||
tableName := parts[2]
|
||||
colName := parts[3]
|
||||
suffix := parts[4]
|
||||
return prefix + p.dialect.QuoteIdentifier(p.prefix+tableName) + "." + p.dialect.QuoteIdentifier(colName) + suffix
|
||||
}
|
||||
return match
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// isIdentChar 判断是否是标识符字符
|
||||
func isIdentChar(c byte) bool {
|
||||
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'
|
||||
}
|
||||
|
||||
// ProcessFieldList 处理字段列表字符串
|
||||
// 输入: "order.id, user.name AS uname, COUNT(*)"
|
||||
// 输出: "`app_order`.`id`, `app_user`.`name` AS uname, COUNT(*)" (MySQL)
|
||||
func (p *IdentifierProcessor) ProcessFieldList(fields string) string {
|
||||
if fields == "" || fields == "*" {
|
||||
return fields
|
||||
}
|
||||
|
||||
result := p.ProcessConditionString(fields)
|
||||
result = p.convertQuotes(result)
|
||||
return result
|
||||
}
|
||||
|
||||
// stripQuotes 去除标识符两端的引号(反引号或双引号)
|
||||
func (p *IdentifierProcessor) stripQuotes(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
// 去除反引号
|
||||
if strings.HasPrefix(name, "`") && strings.HasSuffix(name, "`") {
|
||||
return name[1 : len(name)-1]
|
||||
}
|
||||
// 去除双引号
|
||||
if strings.HasPrefix(name, "\"") && strings.HasSuffix(name, "\"") {
|
||||
return name[1 : len(name)-1]
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// splitTableColumn 分割 table.column 格式
|
||||
// 支持: table.column, `table`.column, `table`.`column`, "table".column 等
|
||||
func (p *IdentifierProcessor) splitTableColumn(name string) []string {
|
||||
// 先尝试按点号分割
|
||||
dotIndex := -1
|
||||
|
||||
// 查找不在引号内的点号
|
||||
inQuote := false
|
||||
quoteChar := byte(0)
|
||||
for i := 0; i < len(name); i++ {
|
||||
c := name[i]
|
||||
if c == '`' || c == '"' {
|
||||
if !inQuote {
|
||||
inQuote = true
|
||||
quoteChar = c
|
||||
} else if c == quoteChar {
|
||||
inQuote = false
|
||||
}
|
||||
} else if c == '.' && !inQuote {
|
||||
dotIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if dotIndex == -1 {
|
||||
return []string{name}
|
||||
}
|
||||
|
||||
return []string{name[:dotIndex], name[dotIndex+1:]}
|
||||
}
|
||||
|
||||
// convertQuotes 将已有的引号转换为当前方言的引号格式
|
||||
func (p *IdentifierProcessor) convertQuotes(name string) string {
|
||||
quoteChar := p.dialect.QuoteChar()
|
||||
// 替换反引号
|
||||
name = strings.ReplaceAll(name, "`", quoteChar)
|
||||
// 如果目标是反引号,需要替换双引号
|
||||
if quoteChar == "`" {
|
||||
name = strings.ReplaceAll(name, "\"", quoteChar)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// GetDialect 获取方言
|
||||
func (p *IdentifierProcessor) GetDialect() Dialect {
|
||||
return p.dialect
|
||||
}
|
||||
|
||||
// GetPrefix 获取前缀
|
||||
func (p *IdentifierProcessor) GetPrefix() string {
|
||||
return p.prefix
|
||||
}
|
||||
+615
@@ -0,0 +1,615 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
// md5 生成查询的 MD5 哈希(用于缓存)
|
||||
func (that *HoTimeDB) md5(query string, args ...interface{}) string {
|
||||
strByte, _ := json.Marshal(args)
|
||||
str := Md5(query + ":" + string(strByte))
|
||||
return str
|
||||
}
|
||||
|
||||
// Query 执行查询 SQL
|
||||
func (that *HoTimeDB) Query(query string, args ...interface{}) []Map {
|
||||
return that.queryWithRetry(query, false, args...)
|
||||
}
|
||||
|
||||
// queryWithRetry 内部查询方法,支持重试标记
|
||||
func (that *HoTimeDB) queryWithRetry(query string, retried bool, args ...interface{}) []Map {
|
||||
query, args = that.expandArrayPlaceholder(query, args)
|
||||
|
||||
var sqlErr error
|
||||
|
||||
defer func() {
|
||||
e := &DBError{Query: query, Data: args}
|
||||
e.SetError(sqlErr)
|
||||
that.lastError.Store(e)
|
||||
that.logSQL(query, args, sqlErr)
|
||||
}()
|
||||
|
||||
var resl *sql.Rows
|
||||
|
||||
db := that.DB
|
||||
if that.SlaveDB != nil {
|
||||
db = that.SlaveDB
|
||||
}
|
||||
|
||||
if db == nil {
|
||||
sqlErr = errors.New("没有初始化数据库")
|
||||
return nil
|
||||
}
|
||||
|
||||
processedArgs := that.processArgs(args)
|
||||
|
||||
if that.testTx != nil {
|
||||
if that.testMu != nil {
|
||||
that.testMu.Lock()
|
||||
}
|
||||
resl, sqlErr = that.testTx.Query(query, processedArgs...)
|
||||
if sqlErr != nil {
|
||||
if that.txFailed != nil {
|
||||
*that.txFailed = true
|
||||
}
|
||||
if that.testMu != nil {
|
||||
that.testMu.Unlock()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
result := that.Row(resl)
|
||||
if that.testMu != nil {
|
||||
that.testMu.Unlock()
|
||||
}
|
||||
return result
|
||||
} else if that.Tx != nil {
|
||||
resl, sqlErr = that.Tx.Query(query, processedArgs...)
|
||||
} else {
|
||||
resl, sqlErr = db.Query(query, processedArgs...)
|
||||
}
|
||||
|
||||
if sqlErr != nil {
|
||||
if that.Tx != nil {
|
||||
if that.txFailed != nil {
|
||||
*that.txFailed = true
|
||||
}
|
||||
}
|
||||
if !retried && that.Tx == nil {
|
||||
if pingErr := db.Ping(); pingErr == nil {
|
||||
sqlErr = nil // 重置,让 retry 的 defer 覆盖
|
||||
return that.queryWithRetry(query, true, args...)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return that.Row(resl)
|
||||
}
|
||||
|
||||
// Exec 执行非查询 SQL
|
||||
func (that *HoTimeDB) Exec(query string, args ...interface{}) (sql.Result, error) {
|
||||
return that.execWithRetry(query, false, args...)
|
||||
}
|
||||
|
||||
// execWithRetry 内部执行方法,支持重试标记
|
||||
func (that *HoTimeDB) execWithRetry(query string, retried bool, args ...interface{}) (sql.Result, error) {
|
||||
query, args = that.expandArrayPlaceholder(query, args)
|
||||
|
||||
var sqlErr error
|
||||
var resl sql.Result
|
||||
|
||||
defer func() {
|
||||
e := &DBError{Query: query, Data: args}
|
||||
e.SetError(sqlErr)
|
||||
that.lastError.Store(e)
|
||||
that.logSQL(query, args, sqlErr)
|
||||
}()
|
||||
|
||||
if that.DB == nil {
|
||||
sqlErr = errors.New("没有初始化数据库")
|
||||
return nil, sqlErr
|
||||
}
|
||||
|
||||
processedArgs := that.processArgs(args)
|
||||
|
||||
if that.testTx != nil {
|
||||
if that.testMu != nil {
|
||||
that.testMu.Lock()
|
||||
}
|
||||
resl, sqlErr = that.testTx.Exec(query, processedArgs...)
|
||||
if that.testMu != nil {
|
||||
that.testMu.Unlock()
|
||||
}
|
||||
} else if that.Tx != nil {
|
||||
resl, sqlErr = that.Tx.Exec(query, processedArgs...)
|
||||
} else {
|
||||
resl, sqlErr = that.DB.Exec(query, processedArgs...)
|
||||
}
|
||||
|
||||
if sqlErr != nil {
|
||||
if that.testTx != nil {
|
||||
if that.txFailed != nil {
|
||||
*that.txFailed = true
|
||||
}
|
||||
return resl, sqlErr
|
||||
}
|
||||
if that.Tx != nil {
|
||||
if that.txFailed != nil {
|
||||
*that.txFailed = true
|
||||
}
|
||||
return resl, sqlErr
|
||||
}
|
||||
if !retried {
|
||||
if pingErr := that.DB.Ping(); pingErr == nil {
|
||||
sqlErr = nil
|
||||
return that.execWithRetry(query, true, args...)
|
||||
}
|
||||
}
|
||||
return resl, sqlErr
|
||||
}
|
||||
|
||||
return resl, sqlErr
|
||||
}
|
||||
|
||||
// logSQL 统一 SQL 日志输出(仅此一处打印,消除重复)
|
||||
func (that *HoTimeDB) logSQL(query string, args []interface{}, err error) {
|
||||
if err != nil {
|
||||
msg := fmt.Sprintf("SQL: %s DATA: %v ERROR: %v", query, args, err)
|
||||
that.testLogBufMu.Lock()
|
||||
if that.testLogBuf != nil {
|
||||
that.testLogBuf.WriteString(msg + "\n")
|
||||
atomic.AddInt32(&that.testSQLErrCount, 1)
|
||||
} else if that.Log != nil {
|
||||
that.Log.Error().Msg(msg)
|
||||
}
|
||||
that.testLogBufMu.Unlock()
|
||||
return
|
||||
}
|
||||
// 无错误时只在 logLevel > 0 时打印 DEBUG 级别
|
||||
if that.Log != nil && that.Log.GetLevel() > 0 {
|
||||
msg := fmt.Sprintf("SQL: %s DATA: %v", query, args)
|
||||
that.testLogBufMu.Lock()
|
||||
if that.testLogBuf != nil {
|
||||
that.testLogBuf.WriteString(msg + "\n")
|
||||
} else {
|
||||
that.Log.Debug().Msg(msg)
|
||||
}
|
||||
that.testLogBufMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// processArgs 处理参数中的 slice 类型
|
||||
func (that *HoTimeDB) processArgs(args []interface{}) []interface{} {
|
||||
processedArgs := make([]interface{}, len(args))
|
||||
copy(processedArgs, args)
|
||||
for key := range processedArgs {
|
||||
arg := processedArgs[key]
|
||||
if arg == nil {
|
||||
continue
|
||||
}
|
||||
argType := reflect.ValueOf(arg).Type().String()
|
||||
if strings.Contains(argType, "[]") || strings.Contains(argType, "Slice") {
|
||||
argLis := ObjToSlice(arg)
|
||||
// 将slice转为逗号分割字符串
|
||||
argStr := ""
|
||||
for i := 0; i < len(argLis); i++ {
|
||||
if i == len(argLis)-1 {
|
||||
argStr += ObjToStr(argLis[i])
|
||||
} else {
|
||||
argStr += ObjToStr(argLis[i]) + ","
|
||||
}
|
||||
}
|
||||
processedArgs[key] = argStr
|
||||
}
|
||||
}
|
||||
return processedArgs
|
||||
}
|
||||
|
||||
// expandArrayPlaceholder 展开 IN (?) / NOT IN (?) 中的数组参数
|
||||
// 自动识别 IN/NOT IN (?) 模式,当参数是数组时展开为多个 ?
|
||||
//
|
||||
// 示例:
|
||||
//
|
||||
// db.Query("SELECT * FROM user WHERE id IN (?)", []int{1, 2, 3})
|
||||
// // 展开为: SELECT * FROM user WHERE id IN (?, ?, ?) 参数: [1, 2, 3]
|
||||
//
|
||||
// db.Query("SELECT * FROM user WHERE id IN (?)", []int{})
|
||||
// // 展开为: SELECT * FROM user WHERE 1=0 参数: [] (空集合的IN永假)
|
||||
//
|
||||
// db.Query("SELECT * FROM user WHERE id NOT IN (?)", []int{})
|
||||
// // 展开为: SELECT * FROM user WHERE 1=1 参数: [] (空集合的NOT IN永真)
|
||||
//
|
||||
// db.Query("SELECT * FROM user WHERE id = ?", 1)
|
||||
// // 保持不变: SELECT * FROM user WHERE id = ? 参数: [1]
|
||||
func (that *HoTimeDB) expandArrayPlaceholder(query string, args []interface{}) (string, []interface{}) {
|
||||
if len(args) == 0 || !strings.Contains(query, "?") {
|
||||
return query, args
|
||||
}
|
||||
|
||||
// 检查是否有数组参数
|
||||
hasArray := false
|
||||
for _, arg := range args {
|
||||
if arg == nil {
|
||||
continue
|
||||
}
|
||||
argType := reflect.ValueOf(arg).Type().String()
|
||||
if strings.Contains(argType, "[]") || strings.Contains(argType, "Slice") {
|
||||
hasArray = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasArray {
|
||||
return query, args
|
||||
}
|
||||
|
||||
newArgs := make([]interface{}, 0, len(args))
|
||||
result := strings.Builder{}
|
||||
argIndex := 0
|
||||
|
||||
for i := 0; i < len(query); i++ {
|
||||
if query[i] == '?' && argIndex < len(args) {
|
||||
arg := args[argIndex]
|
||||
argIndex++
|
||||
|
||||
if arg == nil {
|
||||
result.WriteByte('?')
|
||||
newArgs = append(newArgs, nil)
|
||||
continue
|
||||
}
|
||||
|
||||
argType := reflect.ValueOf(arg).Type().String()
|
||||
if strings.Contains(argType, "[]") || strings.Contains(argType, "Slice") {
|
||||
// 是数组参数,检查是否在 IN (...) 或 NOT IN (...) 中
|
||||
prevPart := result.String()
|
||||
prevUpper := strings.ToUpper(prevPart)
|
||||
|
||||
// 查找最近的 NOT IN ( 模式
|
||||
notInIndex := strings.LastIndex(prevUpper, " NOT IN (")
|
||||
notInIndex2 := strings.LastIndex(prevUpper, " NOT IN(")
|
||||
if notInIndex2 > notInIndex {
|
||||
notInIndex = notInIndex2
|
||||
}
|
||||
|
||||
// 查找最近的 IN ( 模式(但要排除 NOT IN 的情况)
|
||||
inIndex := strings.LastIndex(prevUpper, " IN (")
|
||||
inIndex2 := strings.LastIndex(prevUpper, " IN(")
|
||||
if inIndex2 > inIndex {
|
||||
inIndex = inIndex2
|
||||
}
|
||||
|
||||
// 判断是 NOT IN 还是 IN
|
||||
// 注意:" NOT IN (" 包含 " IN (",所以如果找到的 IN 位置在 NOT IN 范围内,应该优先判断为 NOT IN
|
||||
isNotIn := false
|
||||
matchIndex := -1
|
||||
if notInIndex != -1 {
|
||||
// 检查 inIndex 是否在 notInIndex 范围内(即 NOT IN 的 IN 部分)
|
||||
// NOT IN ( 的 IN ( 部分从 notInIndex + 4 开始
|
||||
if inIndex != -1 && inIndex >= notInIndex && inIndex <= notInIndex+5 {
|
||||
// inIndex 是 NOT IN 的一部分,使用 NOT IN
|
||||
isNotIn = true
|
||||
matchIndex = notInIndex
|
||||
} else if inIndex == -1 || notInIndex > inIndex {
|
||||
// 没有独立的 IN,或 NOT IN 在 IN 之后
|
||||
isNotIn = true
|
||||
matchIndex = notInIndex
|
||||
} else {
|
||||
// 有独立的 IN 且在 NOT IN 之后
|
||||
matchIndex = inIndex
|
||||
}
|
||||
} else if inIndex != -1 {
|
||||
matchIndex = inIndex
|
||||
}
|
||||
|
||||
// 检查 IN ( 后面是否只有空格(即当前 ? 紧跟在 IN ( 后面)
|
||||
isInPattern := false
|
||||
if matchIndex != -1 {
|
||||
afterIn := prevPart[matchIndex:]
|
||||
// 找到 ( 的位置
|
||||
parenIdx := strings.Index(afterIn, "(")
|
||||
if parenIdx != -1 {
|
||||
afterParen := strings.TrimSpace(afterIn[parenIdx+1:])
|
||||
if afterParen == "" {
|
||||
isInPattern = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if isInPattern {
|
||||
// 在 IN (...) 或 NOT IN (...) 模式中
|
||||
argList := ObjToSlice(arg)
|
||||
if len(argList) == 0 {
|
||||
// 空数组处理:需要找到字段名的开始位置
|
||||
// 往前找最近的 AND/OR/WHERE/(,以确定条件的开始位置
|
||||
truncateIndex := matchIndex
|
||||
searchPart := prevUpper[:matchIndex]
|
||||
|
||||
// 找最近的分隔符位置
|
||||
andIdx := strings.LastIndex(searchPart, " AND ")
|
||||
orIdx := strings.LastIndex(searchPart, " OR ")
|
||||
whereIdx := strings.LastIndex(searchPart, " WHERE ")
|
||||
parenIdx := strings.LastIndex(searchPart, "(")
|
||||
|
||||
// 取最靠后的分隔符
|
||||
sepIndex := -1
|
||||
sepLen := 0
|
||||
if andIdx > sepIndex {
|
||||
sepIndex = andIdx
|
||||
sepLen = 5 // " AND "
|
||||
}
|
||||
if orIdx > sepIndex {
|
||||
sepIndex = orIdx
|
||||
sepLen = 4 // " OR "
|
||||
}
|
||||
if whereIdx > sepIndex {
|
||||
sepIndex = whereIdx
|
||||
sepLen = 7 // " WHERE "
|
||||
}
|
||||
if parenIdx > sepIndex {
|
||||
sepIndex = parenIdx
|
||||
sepLen = 1 // "("
|
||||
}
|
||||
|
||||
if sepIndex != -1 {
|
||||
truncateIndex = sepIndex + sepLen
|
||||
}
|
||||
|
||||
result.Reset()
|
||||
result.WriteString(prevPart[:truncateIndex])
|
||||
if isNotIn {
|
||||
// NOT IN 空集合 = 永真
|
||||
result.WriteString(" 1=1 ")
|
||||
} else {
|
||||
// IN 空集合 = 永假
|
||||
result.WriteString(" 1=0 ")
|
||||
}
|
||||
// 跳过后面的 )
|
||||
for j := i + 1; j < len(query); j++ {
|
||||
if query[j] == ')' {
|
||||
i = j
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if len(argList) == 1 {
|
||||
// 单元素数组
|
||||
result.WriteByte('?')
|
||||
newArgs = append(newArgs, argList[0])
|
||||
} else {
|
||||
// 多元素数组,展开为多个 ?
|
||||
for j := 0; j < len(argList); j++ {
|
||||
if j > 0 {
|
||||
result.WriteString(", ")
|
||||
}
|
||||
result.WriteByte('?')
|
||||
newArgs = append(newArgs, argList[j])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 不在 IN 模式中,保持原有行为(数组会被 processArgs 转为逗号字符串)
|
||||
result.WriteByte('?')
|
||||
newArgs = append(newArgs, arg)
|
||||
}
|
||||
} else {
|
||||
// 非数组参数
|
||||
result.WriteByte('?')
|
||||
newArgs = append(newArgs, arg)
|
||||
}
|
||||
} else {
|
||||
result.WriteByte(query[i])
|
||||
}
|
||||
}
|
||||
|
||||
return result.String(), newArgs
|
||||
}
|
||||
|
||||
const (
|
||||
dbTypeUnknown = iota
|
||||
dbTypeInteger
|
||||
dbTypeFloat
|
||||
dbTypeDecimal
|
||||
)
|
||||
|
||||
// classifyDBType 将数据库类型名分类,覆盖 MySQL/SQLite/PostgreSQL
|
||||
func classifyDBType(typeName string) int {
|
||||
t := strings.ToUpper(strings.TrimSpace(typeName))
|
||||
t = strings.TrimSuffix(strings.TrimSuffix(t, " UNSIGNED"), " SIGNED")
|
||||
t = strings.TrimSpace(t)
|
||||
base := t
|
||||
if idx := strings.IndexByte(t, '('); idx > 0 {
|
||||
base = t[:idx]
|
||||
}
|
||||
switch base {
|
||||
case "INT", "INTEGER", "BIGINT", "TINYINT", "SMALLINT", "MEDIUMINT",
|
||||
"INT2", "INT4", "INT8",
|
||||
"SERIAL", "BIGSERIAL", "SMALLSERIAL",
|
||||
"YEAR", "BOOL", "BOOLEAN", "OID":
|
||||
return dbTypeInteger
|
||||
case "DECIMAL", "NUMERIC", "NEWDECIMAL", "NUMBER":
|
||||
return dbTypeDecimal
|
||||
case "FLOAT", "DOUBLE", "REAL", "FLOAT4", "FLOAT8", "DOUBLE PRECISION":
|
||||
return dbTypeFloat
|
||||
}
|
||||
if strings.Contains(base, "INT") {
|
||||
return dbTypeInteger
|
||||
}
|
||||
if strings.Contains(base, "DECIMAL") || strings.Contains(base, "NUMERIC") {
|
||||
return dbTypeDecimal
|
||||
}
|
||||
if strings.Contains(base, "DOUBLE") || strings.Contains(base, "FLOAT") {
|
||||
return dbTypeFloat
|
||||
}
|
||||
return dbTypeUnknown
|
||||
}
|
||||
|
||||
// getDecimalScale 提取 DECIMAL/NUMERIC 精度,失败返回 -1
|
||||
func getDecimalScale(colType *sql.ColumnType, typeName string) int {
|
||||
_, scale, ok := colType.DecimalSize()
|
||||
if ok && scale >= 0 {
|
||||
return int(scale)
|
||||
}
|
||||
// SQLite 不支持 DecimalSize,从类型名解析 "DECIMAL(10,2)" → 2
|
||||
upper := strings.ToUpper(typeName)
|
||||
if idx := strings.Index(upper, ","); idx > 0 {
|
||||
rest := upper[idx+1:]
|
||||
if end := strings.IndexByte(rest, ')'); end > 0 {
|
||||
if s, err := strconv.Atoi(strings.TrimSpace(rest[:end])); err == nil {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// getFloatScale 提取 FLOAT/DOUBLE 显式精度,无显式精度返回 -1 表示不截断
|
||||
func getFloatScale(colType *sql.ColumnType, typeName string) int {
|
||||
_, scale, ok := colType.DecimalSize()
|
||||
if ok && scale > 0 && scale < 20 {
|
||||
return int(scale)
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// fixFloatValue 对 float64 值做类型感知精度修正
|
||||
func fixFloatValue(f float64, category int, scale int) interface{} {
|
||||
if math.IsNaN(f) || math.IsInf(f, 0) {
|
||||
return float64(0)
|
||||
}
|
||||
switch category {
|
||||
case dbTypeInteger:
|
||||
return int64(math.Round(f))
|
||||
case dbTypeDecimal:
|
||||
return roundFloat(f, scale)
|
||||
case dbTypeFloat:
|
||||
return roundFloat(f, scale)
|
||||
default:
|
||||
return f
|
||||
}
|
||||
}
|
||||
|
||||
// roundFloat 使用 strconv 精确舍入,避免 f*pow 中间值产生精度误差
|
||||
func roundFloat(f float64, scale int) interface{} {
|
||||
if math.IsNaN(f) || math.IsInf(f, 0) {
|
||||
return float64(0)
|
||||
}
|
||||
if scale == 0 {
|
||||
// DECIMAL(10,0) 明确是整数
|
||||
return math.Round(f)
|
||||
}
|
||||
if scale < 0 {
|
||||
// 精度未知,不修正,原样返回
|
||||
return f
|
||||
}
|
||||
s := strconv.FormatFloat(f, 'f', scale, 64)
|
||||
v, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return f
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// convertBytes 对 []byte 类型的列做类型感知解析
|
||||
// MySQL 文本协议和 PG 的 DECIMAL/NUMERIC 以 []byte 返回,需要按列类型解析为正确 Go 类型
|
||||
func convertBytes(raw []byte, category int, scale int) interface{} {
|
||||
s := string(raw)
|
||||
switch category {
|
||||
case dbTypeInteger:
|
||||
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
return n
|
||||
}
|
||||
if f, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
return int64(math.Round(f))
|
||||
}
|
||||
return s
|
||||
case dbTypeDecimal:
|
||||
if f, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
return roundFloat(f, scale)
|
||||
}
|
||||
return s
|
||||
case dbTypeFloat:
|
||||
if f, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
return roundFloat(f, scale)
|
||||
}
|
||||
return s
|
||||
default:
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
// Row 数据库数据解析
|
||||
func (that *HoTimeDB) Row(resl *sql.Rows) []Map {
|
||||
defer resl.Close()
|
||||
|
||||
dest := make([]Map, 0)
|
||||
strs, _ := resl.Columns()
|
||||
colTypes, _ := resl.ColumnTypes()
|
||||
|
||||
colCount := len(strs)
|
||||
categories := make([]int, colCount)
|
||||
scales := make([]int, colCount)
|
||||
if colTypes != nil {
|
||||
for j := 0; j < len(colTypes) && j < colCount; j++ {
|
||||
typeName := colTypes[j].DatabaseTypeName()
|
||||
categories[j] = classifyDBType(typeName)
|
||||
switch categories[j] {
|
||||
case dbTypeDecimal:
|
||||
scales[j] = getDecimalScale(colTypes[j], typeName)
|
||||
case dbTypeFloat:
|
||||
scales[j] = getFloatScale(colTypes[j], typeName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for resl.Next() {
|
||||
lis := make(Map, colCount)
|
||||
a := make([]interface{}, colCount)
|
||||
b := make([]interface{}, colCount)
|
||||
for j := 0; j < colCount; j++ {
|
||||
b[j] = &a[j]
|
||||
}
|
||||
if err := resl.Scan(b...); err != nil {
|
||||
dbErr := &DBError{}
|
||||
dbErr.SetError(err)
|
||||
that.lastError.Store(dbErr)
|
||||
return nil
|
||||
}
|
||||
for j := 0; j < colCount; j++ {
|
||||
colKey := strs[j]
|
||||
val := a[j]
|
||||
if val == nil {
|
||||
lis[colKey] = nil
|
||||
continue
|
||||
}
|
||||
switch v := val.(type) {
|
||||
case []byte:
|
||||
lis[colKey] = convertBytes(v, categories[j], scales[j])
|
||||
case float64:
|
||||
lis[colKey] = fixFloatValue(v, categories[j], scales[j])
|
||||
case float32:
|
||||
lis[colKey] = fixFloatValue(float64(v), categories[j], scales[j])
|
||||
case time.Time:
|
||||
// DM8驱动直接返回time.Time,格式化为MySQL兼容字符串
|
||||
lis[colKey] = v.Format("2006-01-02 15:04:05")
|
||||
default:
|
||||
lis[colKey] = val
|
||||
}
|
||||
}
|
||||
|
||||
dest = append(dest, lis)
|
||||
}
|
||||
if err := resl.Err(); err != nil {
|
||||
dbErr := &DBError{}
|
||||
dbErr.SetError(err)
|
||||
that.lastError.Store(dbErr)
|
||||
}
|
||||
return dest
|
||||
}
|
||||
+245
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
var savepointCounter uint64
|
||||
|
||||
// Action 事务操作
|
||||
// 如果 action 返回 true 则提交事务;返回 false 则回滚
|
||||
// 测试模式下(testTx != nil),使用 SAVEPOINT 代替真事务,确保嵌套事务不会绕过外层测试事务
|
||||
func (that *HoTimeDB) Action(action func(db HoTimeDB) (isSuccess bool)) (isSuccess bool) {
|
||||
that.testLogBufMu.Lock()
|
||||
logBuf := that.testLogBuf
|
||||
that.testLogBufMu.Unlock()
|
||||
db := HoTimeDB{
|
||||
DB: that.DB,
|
||||
ContextBase: that.ContextBase,
|
||||
DBName: that.DBName,
|
||||
HoTimeCache: that.HoTimeCache,
|
||||
Log: that.Log,
|
||||
Type: that.Type,
|
||||
Prefix: that.Prefix,
|
||||
ConnectFunc: that.ConnectFunc,
|
||||
limit: that.limit,
|
||||
Tx: that.Tx,
|
||||
SlaveDB: that.SlaveDB,
|
||||
Dialect: that.Dialect,
|
||||
limitMu: sync.Mutex{},
|
||||
testTx: that.testTx,
|
||||
testMu: that.testMu,
|
||||
testLogBuf: logBuf,
|
||||
}
|
||||
|
||||
txFailed := false
|
||||
db.txFailed = &txFailed
|
||||
|
||||
if that.testTx != nil {
|
||||
spName := fmt.Sprintf("sp_%d", atomic.AddUint64(&savepointCounter, 1))
|
||||
if that.testMu != nil {
|
||||
that.testMu.Lock()
|
||||
}
|
||||
_, _ = that.testTx.Exec("SAVEPOINT " + spName)
|
||||
if that.testMu != nil {
|
||||
that.testMu.Unlock()
|
||||
}
|
||||
db.Tx = that.testTx
|
||||
isSuccess = action(db)
|
||||
if txFailed || !isSuccess {
|
||||
if that.testMu != nil {
|
||||
that.testMu.Lock()
|
||||
}
|
||||
_, _ = that.testTx.Exec("ROLLBACK TO SAVEPOINT " + spName)
|
||||
if that.testMu != nil {
|
||||
that.testMu.Unlock()
|
||||
}
|
||||
return false
|
||||
}
|
||||
if that.testMu != nil {
|
||||
that.testMu.Lock()
|
||||
}
|
||||
_, _ = that.testTx.Exec("RELEASE SAVEPOINT " + spName)
|
||||
if that.testMu != nil {
|
||||
that.testMu.Unlock()
|
||||
}
|
||||
return isSuccess
|
||||
}
|
||||
|
||||
tx, err := db.Begin()
|
||||
|
||||
if err != nil {
|
||||
dbErr := &DBError{}
|
||||
dbErr.SetError(err)
|
||||
that.lastError.Store(dbErr)
|
||||
return isSuccess
|
||||
}
|
||||
|
||||
db.Tx = tx
|
||||
|
||||
isSuccess = action(db)
|
||||
|
||||
if txFailed {
|
||||
_ = db.Tx.Rollback()
|
||||
return false
|
||||
}
|
||||
|
||||
if !isSuccess {
|
||||
err = db.Tx.Rollback()
|
||||
if err != nil {
|
||||
dbErr := &DBError{}
|
||||
dbErr.SetError(err)
|
||||
that.lastError.Store(dbErr)
|
||||
return isSuccess
|
||||
}
|
||||
return isSuccess
|
||||
}
|
||||
|
||||
err = db.Tx.Commit()
|
||||
if err != nil {
|
||||
dbErr := &DBError{}
|
||||
dbErr.SetError(err)
|
||||
that.lastError.Store(dbErr)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
"code.hoteas.com/golang/hotime/log"
|
||||
"database/sql"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestDB(t *testing.T) *HoTimeDB {
|
||||
t.Helper()
|
||||
sqlDB, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db := &HoTimeDB{
|
||||
DB: sqlDB,
|
||||
Type: "sqlite3",
|
||||
Dialect: &SQLiteDialect{},
|
||||
Log: log.NewTestLogger(),
|
||||
}
|
||||
_, execErr := sqlDB.Exec("CREATE TABLE test_item (id INTEGER PRIMARY KEY, name TEXT, value INTEGER)")
|
||||
if execErr != nil {
|
||||
t.Fatal(execErr)
|
||||
}
|
||||
_, execErr = sqlDB.Exec("INSERT INTO test_item (id, name, value) VALUES (1, 'foo', 100)")
|
||||
if execErr != nil {
|
||||
t.Fatal(execErr)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func newTestDBWithTestTx(t *testing.T) *HoTimeDB {
|
||||
t.Helper()
|
||||
db := newTestDB(t)
|
||||
tx, err := db.DB.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.testTx = tx
|
||||
db.testMu = &sync.Mutex{}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestAction_TxFailedForceRollback(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
defer db.DB.Close()
|
||||
|
||||
result := db.Action(func(tx HoTimeDB) (isSuccess bool) {
|
||||
tx.Update("test_item", Map{"value": 999}, Map{"id": 1})
|
||||
|
||||
tx.Exec("THIS IS INVALID SQL THAT WILL FAIL")
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
if result != false {
|
||||
t.Fatalf("Action should return false when SQL error occurred, got true")
|
||||
}
|
||||
|
||||
row := db.Get("test_item", "value", Map{"id": 1})
|
||||
if row == nil {
|
||||
t.Fatal("failed to read test_item")
|
||||
}
|
||||
val := row.GetCeilInt64("value")
|
||||
if val != 100 {
|
||||
t.Fatalf("value should be rolled back to 100, got %d", val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAction_NormalCommit(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
defer db.DB.Close()
|
||||
|
||||
result := db.Action(func(tx HoTimeDB) (isSuccess bool) {
|
||||
tx.Update("test_item", Map{"value": 200}, Map{"id": 1})
|
||||
return true
|
||||
})
|
||||
|
||||
if result != true {
|
||||
t.Fatalf("Action should return true on success, got false")
|
||||
}
|
||||
|
||||
row := db.Get("test_item", "value", Map{"id": 1})
|
||||
if row == nil {
|
||||
t.Fatal("failed to read test_item")
|
||||
}
|
||||
val := row.GetCeilInt64("value")
|
||||
if val != 200 {
|
||||
t.Fatalf("value should be committed to 200, got %d", val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAction_NormalRollback(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
defer db.DB.Close()
|
||||
|
||||
result := db.Action(func(tx HoTimeDB) (isSuccess bool) {
|
||||
tx.Update("test_item", Map{"value": 300}, Map{"id": 1})
|
||||
return false
|
||||
})
|
||||
|
||||
if result != false {
|
||||
t.Fatalf("Action should return false, got true")
|
||||
}
|
||||
|
||||
row := db.Get("test_item", "value", Map{"id": 1})
|
||||
if row == nil {
|
||||
t.Fatal("failed to read test_item")
|
||||
}
|
||||
val := row.GetCeilInt64("value")
|
||||
if val != 100 {
|
||||
t.Fatalf("value should be rolled back to 100, got %d", val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAction_SqlErrorThenReturnTrue_MustRollback(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
defer db.DB.Close()
|
||||
|
||||
result := db.Action(func(tx HoTimeDB) (isSuccess bool) {
|
||||
tx.Update("test_item", Map{"value": 500}, Map{"id": 1})
|
||||
|
||||
tx.Exec("INSERT INTO nonexistent_table (x) VALUES (1)")
|
||||
|
||||
tx.Update("test_item", Map{"value": 600}, Map{"id": 1})
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
if result != false {
|
||||
t.Fatalf("Action should return false when SQL error occurred mid-transaction, got true")
|
||||
}
|
||||
|
||||
row := db.Get("test_item", "value", Map{"id": 1})
|
||||
if row == nil {
|
||||
t.Fatal("failed to read test_item")
|
||||
}
|
||||
val := row.GetCeilInt64("value")
|
||||
if val != 100 {
|
||||
t.Fatalf("value should be rolled back to 100 (original), got %d", val)
|
||||
}
|
||||
}
|
||||
|
||||
// --- testTx (SAVEPOINT) 模式测试 ---
|
||||
|
||||
func TestAction_TestTx_SqlErrorForceRollbackSavepoint(t *testing.T) {
|
||||
db := newTestDBWithTestTx(t)
|
||||
defer db.testTx.Rollback()
|
||||
defer db.DB.Close()
|
||||
|
||||
db.testTx.Exec("UPDATE test_item SET value = 100 WHERE id = 1")
|
||||
|
||||
result := db.Action(func(tx HoTimeDB) (isSuccess bool) {
|
||||
tx.Update("test_item", Map{"value": 888}, Map{"id": 1})
|
||||
|
||||
tx.Exec("THIS IS INVALID SQL")
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
if result != false {
|
||||
t.Fatalf("Action (testTx mode) should return false when SQL error occurred, got true")
|
||||
}
|
||||
|
||||
db.testMu.Lock()
|
||||
rows, err := db.testTx.Query("SELECT value FROM test_item WHERE id = 1")
|
||||
db.testMu.Unlock()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
t.Fatal("no row found")
|
||||
}
|
||||
var val int64
|
||||
rows.Scan(&val)
|
||||
if val != 100 {
|
||||
t.Fatalf("value should be rolled back to 100 via SAVEPOINT, got %d", val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAction_TestTx_NormalCommitSavepoint(t *testing.T) {
|
||||
db := newTestDBWithTestTx(t)
|
||||
defer db.testTx.Rollback()
|
||||
defer db.DB.Close()
|
||||
|
||||
result := db.Action(func(tx HoTimeDB) (isSuccess bool) {
|
||||
tx.Update("test_item", Map{"value": 777}, Map{"id": 1})
|
||||
return true
|
||||
})
|
||||
|
||||
if result != true {
|
||||
t.Fatalf("Action (testTx mode) should return true on success, got false")
|
||||
}
|
||||
|
||||
db.testMu.Lock()
|
||||
rows, err := db.testTx.Query("SELECT value FROM test_item WHERE id = 1")
|
||||
db.testMu.Unlock()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
t.Fatal("no row found")
|
||||
}
|
||||
var val int64
|
||||
rows.Scan(&val)
|
||||
if val != 777 {
|
||||
t.Fatalf("value should be 777 after RELEASE SAVEPOINT, got %d", val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAction_TestTx_NormalRollbackSavepoint(t *testing.T) {
|
||||
db := newTestDBWithTestTx(t)
|
||||
defer db.testTx.Rollback()
|
||||
defer db.DB.Close()
|
||||
|
||||
db.testTx.Exec("UPDATE test_item SET value = 100 WHERE id = 1")
|
||||
|
||||
result := db.Action(func(tx HoTimeDB) (isSuccess bool) {
|
||||
tx.Update("test_item", Map{"value": 666}, Map{"id": 1})
|
||||
return false
|
||||
})
|
||||
|
||||
if result != false {
|
||||
t.Fatalf("Action (testTx mode) should return false, got true")
|
||||
}
|
||||
|
||||
db.testMu.Lock()
|
||||
rows, err := db.testTx.Query("SELECT value FROM test_item WHERE id = 1")
|
||||
db.testMu.Unlock()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
t.Fatal("no row found")
|
||||
}
|
||||
var val int64
|
||||
rows.Scan(&val)
|
||||
if val != 100 {
|
||||
t.Fatalf("value should be rolled back to 100 via SAVEPOINT, got %d", val)
|
||||
}
|
||||
}
|
||||
+555
@@ -0,0 +1,555 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 条件关键字
|
||||
var condition = []string{"AND", "OR"}
|
||||
|
||||
// 特殊关键字(支持大小写)
|
||||
var vcond = []string{"GROUP", "ORDER", "LIMIT", "DISTINCT", "HAVING", "OFFSET"}
|
||||
|
||||
// normalizeKey 标准化关键字(转大写)
|
||||
func normalizeKey(k string) string {
|
||||
upper := strings.ToUpper(k)
|
||||
for _, v := range vcond {
|
||||
if upper == v {
|
||||
return v
|
||||
}
|
||||
}
|
||||
for _, v := range condition {
|
||||
if upper == v {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// isConditionKey 判断是否是条件关键字
|
||||
func isConditionKey(k string) bool {
|
||||
upper := strings.ToUpper(k)
|
||||
for _, v := range condition {
|
||||
if upper == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isVcondKey 判断是否是特殊关键字
|
||||
func isVcondKey(k string) bool {
|
||||
upper := strings.ToUpper(k)
|
||||
for _, v := range vcond {
|
||||
if upper == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// where 语句解析
|
||||
func (that *HoTimeDB) where(data Map) (string, []interface{}) {
|
||||
where := ""
|
||||
res := make([]interface{}, 0)
|
||||
|
||||
// 标准化 Map 的 key(大小写兼容)
|
||||
normalizedData := Map{}
|
||||
for k, v := range data {
|
||||
normalizedData[normalizeKey(k)] = v
|
||||
}
|
||||
data = normalizedData
|
||||
|
||||
// 收集所有 key 并排序
|
||||
testQu := []string{}
|
||||
for key := range data {
|
||||
testQu = append(testQu, key)
|
||||
}
|
||||
sort.Strings(testQu)
|
||||
|
||||
// 追踪条件数量,用于自动添加 AND
|
||||
condCount := 0
|
||||
|
||||
for _, k := range testQu {
|
||||
v := data[k]
|
||||
|
||||
// 检查是否是 AND/OR 条件关键字
|
||||
if isConditionKey(k) {
|
||||
tw, ts := that.cond(strings.ToUpper(k), v.(Map))
|
||||
if tw != "" && strings.TrimSpace(tw) != "" {
|
||||
// 与前面的条件用 AND 连接
|
||||
if condCount > 0 {
|
||||
where += " AND "
|
||||
}
|
||||
// 用括号包裹 OR/AND 组条件
|
||||
where += "(" + strings.TrimSpace(tw) + ")"
|
||||
condCount++
|
||||
res = append(res, ts...)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否是特殊关键字(GROUP, ORDER, LIMIT 等)
|
||||
if isVcondKey(k) {
|
||||
continue // 特殊关键字在后面单独处理
|
||||
}
|
||||
|
||||
// 处理普通条件字段
|
||||
// 空切片的 IN 条件应该生成永假条件(1=0),而不是跳过
|
||||
if v != nil && reflect.ValueOf(v).Type().String() == "common.Slice" && len(v.(Slice)) == 0 {
|
||||
// 检查是否是 NOT IN(带 [!] 后缀)- NOT IN 空数组永真,跳过即可
|
||||
if !strings.HasSuffix(k, "[!]") {
|
||||
// IN 空数组 -> 生成永假条件
|
||||
if condCount > 0 {
|
||||
where += " AND "
|
||||
}
|
||||
where += "1=0 "
|
||||
condCount++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if v != nil && strings.Contains(reflect.ValueOf(v).Type().String(), "[]") && len(ObjToSlice(v)) == 0 {
|
||||
// 检查是否是 NOT IN(带 [!] 后缀)- NOT IN 空数组永真,跳过即可
|
||||
if !strings.HasSuffix(k, "[!]") {
|
||||
// IN 空数组 -> 生成永假条件
|
||||
if condCount > 0 {
|
||||
where += " AND "
|
||||
}
|
||||
where += "1=0 "
|
||||
condCount++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
tv, vv := that.varCond(k, v)
|
||||
if tv != "" {
|
||||
// 自动添加 AND 连接符
|
||||
if condCount > 0 {
|
||||
where += " AND "
|
||||
}
|
||||
where += tv
|
||||
condCount++
|
||||
res = append(res, vv...)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加 WHERE 关键字
|
||||
// 先去除首尾空格,检查是否有实际条件内容
|
||||
trimmedWhere := strings.TrimSpace(where)
|
||||
if len(trimmedWhere) != 0 {
|
||||
hasWhere := true
|
||||
for _, v := range vcond {
|
||||
if strings.Index(trimmedWhere, v) == 0 {
|
||||
hasWhere = false
|
||||
}
|
||||
}
|
||||
|
||||
if hasWhere {
|
||||
where = " WHERE " + trimmedWhere + " "
|
||||
}
|
||||
} else {
|
||||
// 没有实际条件内容,重置 where
|
||||
where = ""
|
||||
}
|
||||
|
||||
// 处理特殊字符(按固定顺序:GROUP, HAVING, ORDER, LIMIT, OFFSET)
|
||||
specialOrder := []string{"GROUP", "HAVING", "ORDER", "LIMIT", "OFFSET", "DISTINCT"}
|
||||
for _, vcondKey := range specialOrder {
|
||||
v, exists := data[vcondKey]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
switch vcondKey {
|
||||
case "GROUP":
|
||||
where += " GROUP BY "
|
||||
where += that.formatVcondValue(v)
|
||||
case "HAVING":
|
||||
// HAVING 条件处理
|
||||
if havingMap, ok := v.(Map); ok {
|
||||
havingWhere, havingRes := that.cond("AND", havingMap)
|
||||
if havingWhere != "" {
|
||||
where += " HAVING " + strings.TrimSpace(havingWhere) + " "
|
||||
res = append(res, havingRes...)
|
||||
}
|
||||
}
|
||||
case "ORDER":
|
||||
where += " ORDER BY "
|
||||
where += that.formatVcondValue(v)
|
||||
case "LIMIT":
|
||||
where += " LIMIT "
|
||||
where += that.formatVcondValue(v)
|
||||
case "OFFSET":
|
||||
where += " OFFSET " + ObjToStr(v) + " "
|
||||
case "DISTINCT":
|
||||
// DISTINCT 通常在 SELECT 中处理,这里暂时忽略
|
||||
}
|
||||
}
|
||||
|
||||
return where, res
|
||||
}
|
||||
|
||||
// formatVcondValue 格式化特殊关键字的值
|
||||
func (that *HoTimeDB) formatVcondValue(v interface{}) string {
|
||||
result := ""
|
||||
if reflect.ValueOf(v).Type().String() == "common.Slice" || strings.Contains(reflect.ValueOf(v).Type().String(), "[]") {
|
||||
vs := ObjToSlice(v)
|
||||
for i := 0; i < len(vs); i++ {
|
||||
result += " " + vs.GetString(i) + " "
|
||||
if len(vs) != i+1 {
|
||||
result += ", "
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result += " " + ObjToStr(v) + " "
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// varCond 变量条件解析
|
||||
func (that *HoTimeDB) varCond(k string, v interface{}) (string, []interface{}) {
|
||||
where := ""
|
||||
res := make([]interface{}, 0)
|
||||
length := len(k)
|
||||
processor := that.GetProcessor()
|
||||
|
||||
if k == "[#]" {
|
||||
k = strings.Replace(k, "[#]", "", -1)
|
||||
where += " " + ObjToStr(v) + " "
|
||||
} else if k == "[##]" {
|
||||
// 直接添加 SQL 片段(key 为 [##] 时)
|
||||
where += " " + ObjToStr(v) + " "
|
||||
} else if length > 0 && strings.Contains(k, "[") && k[length-1] == ']' {
|
||||
def := false
|
||||
|
||||
switch Substr(k, length-3, 3) {
|
||||
case "[>]":
|
||||
k = strings.Replace(k, "[>]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where += k + ">? "
|
||||
res = append(res, v)
|
||||
case "[<]":
|
||||
k = strings.Replace(k, "[<]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where += k + "<? "
|
||||
res = append(res, v)
|
||||
case "[!]":
|
||||
k = strings.Replace(k, "[!]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where, res = that.notIn(k, v, where, res)
|
||||
case "[#]":
|
||||
k = strings.Replace(k, "[#]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where += " " + k + "=" + ObjToStr(v) + " "
|
||||
case "[~]":
|
||||
k = strings.Replace(k, "[~]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where += k + " LIKE ? "
|
||||
v = "%" + ObjToStr(v) + "%"
|
||||
res = append(res, v)
|
||||
default:
|
||||
def = true
|
||||
}
|
||||
|
||||
if def {
|
||||
switch Substr(k, length-4, 4) {
|
||||
case "[>=]":
|
||||
k = strings.Replace(k, "[>=]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where += k + ">=? "
|
||||
res = append(res, v)
|
||||
case "[<=]":
|
||||
k = strings.Replace(k, "[<=]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where += k + "<=? "
|
||||
res = append(res, v)
|
||||
case "[><]":
|
||||
k = strings.Replace(k, "[><]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where += k + " NOT BETWEEN ? AND ? "
|
||||
vs := ObjToSlice(v)
|
||||
res = append(res, vs[0])
|
||||
res = append(res, vs[1])
|
||||
case "[<>]":
|
||||
k = strings.Replace(k, "[<>]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where += k + " BETWEEN ? AND ? "
|
||||
vs := ObjToSlice(v)
|
||||
res = append(res, vs[0])
|
||||
res = append(res, vs[1])
|
||||
case "[##]":
|
||||
where += " " + ObjToStr(v)
|
||||
case "[#!]":
|
||||
k = strings.Replace(k, "[#!]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where += " " + k + "!=" + ObjToStr(v) + " "
|
||||
case "[!#]":
|
||||
k = strings.Replace(k, "[!#]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where += " " + k + "!=" + ObjToStr(v) + " "
|
||||
case "[!~]": // 左边任意
|
||||
k = strings.Replace(k, "[!~]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where += k + " LIKE ? "
|
||||
v = "%" + ObjToStr(v) + ""
|
||||
res = append(res, v)
|
||||
case "[~!]": // 右边任意
|
||||
k = strings.Replace(k, "[~!]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where += k + " LIKE ? "
|
||||
v = ObjToStr(v) + "%"
|
||||
res = append(res, v)
|
||||
case "[~~]": // 手动任意
|
||||
k = strings.Replace(k, "[~~]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where += k + " LIKE ? "
|
||||
res = append(res, v)
|
||||
default:
|
||||
where, res = that.handleDefaultCondition(k, v, where, res)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
where, res = that.handlePlainField(k, v, where, res)
|
||||
}
|
||||
|
||||
return where, res
|
||||
}
|
||||
|
||||
// handleDefaultCondition 处理默认条件(带方括号但不是特殊操作符)
|
||||
func (that *HoTimeDB) handleDefaultCondition(k string, v interface{}, where string, res []interface{}) (string, []interface{}) {
|
||||
processor := that.GetProcessor()
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
|
||||
if reflect.ValueOf(v).Type().String() == "common.Slice" || strings.Contains(reflect.ValueOf(v).Type().String(), "[]") {
|
||||
vs := ObjToSlice(v)
|
||||
if len(vs) == 0 {
|
||||
// IN 空数组 -> 生成永假条件
|
||||
where += "1=0 "
|
||||
return where, res
|
||||
}
|
||||
|
||||
if len(vs) == 1 {
|
||||
where += k + "=? "
|
||||
res = append(res, vs[0])
|
||||
return where, res
|
||||
}
|
||||
|
||||
// IN 优化:连续整数转为 BETWEEN
|
||||
where, res = that.optimizeInCondition(k, vs, where, res)
|
||||
} else {
|
||||
where += k + "=? "
|
||||
res = append(res, v)
|
||||
}
|
||||
|
||||
return where, res
|
||||
}
|
||||
|
||||
// handlePlainField 处理普通字段(无方括号)
|
||||
func (that *HoTimeDB) handlePlainField(k string, v interface{}, where string, res []interface{}) (string, []interface{}) {
|
||||
processor := that.GetProcessor()
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
|
||||
if v == nil {
|
||||
where += k + " IS NULL "
|
||||
} else if reflect.ValueOf(v).Type().String() == "common.Slice" || strings.Contains(reflect.ValueOf(v).Type().String(), "[]") {
|
||||
vs := ObjToSlice(v)
|
||||
if len(vs) == 0 {
|
||||
// IN 空数组 -> 生成永假条件
|
||||
where += "1=0 "
|
||||
return where, res
|
||||
}
|
||||
|
||||
if len(vs) == 1 {
|
||||
where += k + "=? "
|
||||
res = append(res, vs[0])
|
||||
return where, res
|
||||
}
|
||||
|
||||
// IN 优化
|
||||
where, res = that.optimizeInCondition(k, vs, where, res)
|
||||
} else {
|
||||
where += k + "=? "
|
||||
res = append(res, v)
|
||||
}
|
||||
|
||||
return where, res
|
||||
}
|
||||
|
||||
// optimizeInCondition 优化 IN 条件(连续整数转为 BETWEEN)
|
||||
func (that *HoTimeDB) optimizeInCondition(k string, vs Slice, where string, res []interface{}) (string, []interface{}) {
|
||||
min := int64(0)
|
||||
isMin := true
|
||||
IsRange := true
|
||||
num := int64(0)
|
||||
isNum := true
|
||||
|
||||
where1 := ""
|
||||
res1 := Slice{}
|
||||
where2 := k + " IN ("
|
||||
res2 := Slice{}
|
||||
|
||||
for kvs := 0; kvs <= len(vs); kvs++ {
|
||||
vsv := int64(0)
|
||||
if kvs < len(vs) {
|
||||
vsv = vs.GetCeilInt64(kvs)
|
||||
// 确保是全部是int类型
|
||||
if ObjToStr(vsv) != vs.GetString(kvs) {
|
||||
IsRange = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if isNum {
|
||||
isNum = false
|
||||
num = vsv
|
||||
} else {
|
||||
num++
|
||||
}
|
||||
if isMin {
|
||||
isMin = false
|
||||
min = vsv
|
||||
}
|
||||
// 不等于则到了分路口
|
||||
if num != vsv {
|
||||
// between
|
||||
if num-min > 1 {
|
||||
if where1 != "" {
|
||||
where1 += " OR " + k + " BETWEEN ? AND ? "
|
||||
} else {
|
||||
where1 += k + " BETWEEN ? AND ? "
|
||||
}
|
||||
res1 = append(res1, min)
|
||||
res1 = append(res1, num-1)
|
||||
} else {
|
||||
where2 += "?,"
|
||||
res2 = append(res2, min)
|
||||
}
|
||||
min = vsv
|
||||
num = vsv
|
||||
}
|
||||
}
|
||||
|
||||
if IsRange {
|
||||
where3 := ""
|
||||
|
||||
if where1 != "" {
|
||||
where3 += where1
|
||||
res = append(res, res1...)
|
||||
}
|
||||
|
||||
if len(res2) == 1 {
|
||||
if where3 == "" {
|
||||
where3 += k + " = ? "
|
||||
} else {
|
||||
where3 += " OR " + k + " = ? "
|
||||
}
|
||||
res = append(res, res2...)
|
||||
} else if len(res2) > 1 {
|
||||
where2 = where2[:len(where2)-1]
|
||||
if where3 == "" {
|
||||
where3 += where2 + ")"
|
||||
} else {
|
||||
where3 += " OR " + where2 + ")"
|
||||
}
|
||||
res = append(res, res2...)
|
||||
}
|
||||
|
||||
if where3 != "" {
|
||||
where += "(" + where3 + ")"
|
||||
}
|
||||
|
||||
return where, res
|
||||
}
|
||||
|
||||
// 非连续整数,使用普通 IN
|
||||
where += k + " IN ("
|
||||
res = append(res, vs...)
|
||||
|
||||
for i := 0; i < len(vs); i++ {
|
||||
if i+1 != len(vs) {
|
||||
where += "?,"
|
||||
} else {
|
||||
where += "?) "
|
||||
}
|
||||
}
|
||||
|
||||
return where, res
|
||||
}
|
||||
|
||||
// notIn NOT IN 条件处理
|
||||
func (that *HoTimeDB) notIn(k string, v interface{}, where string, res []interface{}) (string, []interface{}) {
|
||||
if v == nil {
|
||||
where += k + " IS NOT NULL "
|
||||
} else if reflect.ValueOf(v).Type().String() == "common.Slice" || strings.Contains(reflect.ValueOf(v).Type().String(), "[]") {
|
||||
vs := ObjToSlice(v)
|
||||
if len(vs) == 0 {
|
||||
return where, res
|
||||
}
|
||||
where += k + " NOT IN ("
|
||||
res = append(res, vs...)
|
||||
|
||||
for i := 0; i < len(vs); i++ {
|
||||
if i+1 != len(vs) {
|
||||
where += "?,"
|
||||
} else {
|
||||
where += "?) "
|
||||
}
|
||||
}
|
||||
} else {
|
||||
where += k + " !=? "
|
||||
res = append(res, v)
|
||||
}
|
||||
|
||||
return where, res
|
||||
}
|
||||
|
||||
// cond 条件组合处理
|
||||
func (that *HoTimeDB) cond(tag string, data Map) (string, []interface{}) {
|
||||
where := " "
|
||||
res := make([]interface{}, 0)
|
||||
lens := len(data)
|
||||
|
||||
testQu := []string{}
|
||||
for key := range data {
|
||||
testQu = append(testQu, key)
|
||||
}
|
||||
sort.Strings(testQu)
|
||||
|
||||
for _, k := range testQu {
|
||||
v := data[k]
|
||||
x := 0
|
||||
for i := 0; i < len(condition); i++ {
|
||||
if condition[i] == strings.ToUpper(k) {
|
||||
tw, ts := that.cond(strings.ToUpper(k), v.(Map))
|
||||
if lens--; lens <= 0 {
|
||||
where += "(" + tw + ") "
|
||||
} else {
|
||||
where += "(" + tw + ") " + tag + " "
|
||||
}
|
||||
|
||||
res = append(res, ts...)
|
||||
break
|
||||
}
|
||||
x++
|
||||
}
|
||||
|
||||
if x == len(condition) {
|
||||
tv, vv := that.varCond(k, v)
|
||||
if tv == "" {
|
||||
lens--
|
||||
continue
|
||||
}
|
||||
res = append(res, vv...)
|
||||
if lens--; lens <= 0 {
|
||||
where += tv + ""
|
||||
} else {
|
||||
where += tv + " " + tag + " "
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return where, res
|
||||
}
|
||||
@@ -0,0 +1,870 @@
|
||||
# HoTime 代码生成
|
||||
|
||||
前置依赖:生成前请先满足 [数据库设计规范](DatabaseDesign_数据库设计规范.md)(表命名、外键、COMMENT、必有字段)。代码生成器依赖规范的表结构与字段备注,才能正确识别类型、选项、外键关联与显示标签。
|
||||
|
||||
`code` 包提供了 HoTime 框架的自动代码生成功能,能够根据数据库表结构自动生成 CRUD 接口代码和配置文件。
|
||||
|
||||
## 目录
|
||||
|
||||
- [一、功能概述](#一功能概述)
|
||||
- [二、使用方法](#二使用方法)
|
||||
- [三、配置体系](#三配置体系)
|
||||
- [3.1 codeConfig](#31-codeconfig)
|
||||
- [3.2 菜单权限配置(admin.json)](#32-菜单权限配置adminjson)
|
||||
- [3.3 字段规则配置(rule.json)](#33-字段规则配置rulejson)
|
||||
- [3.4 配置检查清单](#34-配置检查清单)
|
||||
- [四、生成规则与产物](#四生成规则与产物)
|
||||
- [4.1 默认字段规则](#41-默认字段规则)
|
||||
- [4.2 数据类型映射](#42-数据类型映射)
|
||||
- [4.3 字段备注解析](#43-字段备注解析)
|
||||
- [4.4 外键关联](#44-外键关联)
|
||||
- [4.5 生成的代码结构](#45-生成的代码结构)
|
||||
- [4.6 多库与备注注意事项](#46-多库与备注注意事项)
|
||||
- [4.7 最佳实践](#47-最佳实践)
|
||||
- [五、相关文档](#五相关文档)
|
||||
|
||||
---
|
||||
|
||||
## 一、功能概述
|
||||
|
||||
代码生成器可以:
|
||||
|
||||
1. **自动读取数据库表结构** - 支持 MySQL、SQLite 和达梦 DM8
|
||||
2. **生成 CRUD 接口** - 增删改查、搜索、分页
|
||||
3. **生成配置文件** - 表字段配置、菜单配置、权限配置
|
||||
4. **智能字段识别** - 根据字段名自动识别类型和权限
|
||||
5. **支持表关联** - 自动识别外键关系
|
||||
|
||||
配置体系概览:
|
||||
|
||||
```
|
||||
config.json
|
||||
└── codeConfig[] # 代码生成配置数组(支持多套)
|
||||
├── config # 菜单权限配置文件路径(如 admin.json)
|
||||
├── configDB # 数据库生成的完整配置
|
||||
├── rule # 字段规则配置文件路径(如 rule.json)
|
||||
├── table # 管理员表名
|
||||
├── name # 生成代码的包名
|
||||
└── mode # 生成模式
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、使用方法
|
||||
|
||||
### 2.1 基础配置
|
||||
|
||||
在 `config.json` 中配置开发模式与代码生成:
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": 2,
|
||||
"codeConfig": [
|
||||
{
|
||||
"table": "admin",
|
||||
"config": "config/admin.json",
|
||||
"rule": "config/rule.json",
|
||||
"mode": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 启动应用
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
func main() {
|
||||
app := Init("config/config.json")
|
||||
|
||||
// 代码生成器在 Init 时自动执行
|
||||
// 会读取数据库结构并生成配置
|
||||
|
||||
app.Run(Router{
|
||||
// 路由配置
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 开发模式
|
||||
|
||||
在 `config.json` 中设置 `"mode": 2`(开发模式)时:
|
||||
|
||||
- 自动读取数据库表结构
|
||||
- 自动生成/更新配置文件
|
||||
- 自动生成代码(如果 `codeConfig.mode=1`)
|
||||
|
||||
### 2.4 推荐开发流程
|
||||
|
||||
1. 设置 `config.json` 中 `mode: 2`(开发模式)
|
||||
2. 按 [数据库设计规范](DatabaseDesign_数据库设计规范.md) 设计表结构并添加字段备注
|
||||
3. 启动应用,自动生成配置
|
||||
4. 检查生成的配置文件,按需调整
|
||||
5. 生产环境改为 `mode: 0`
|
||||
|
||||
---
|
||||
|
||||
## 三、配置体系
|
||||
|
||||
### 3.1 codeConfig
|
||||
|
||||
#### 配置结构
|
||||
|
||||
```json
|
||||
{
|
||||
"codeConfig": [
|
||||
{
|
||||
"config": "config/admin.json",
|
||||
"configDB": "config/adminDB.json",
|
||||
"mode": 0,
|
||||
"name": "",
|
||||
"rule": "config/rule.json",
|
||||
"table": "admin"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 配置项说明
|
||||
|
||||
| 字段 | 类型 | 必须 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `table` | string | ✅ | 管理员/用户表名,用于身份验证和权限控制 |
|
||||
| `config` | string | ✅ | 菜单权限配置文件路径,用于定义菜单结构、权限控制 |
|
||||
| `configDB` | string | ❌ | 代码生成器输出的完整配置文件(有则每次自动生成) |
|
||||
| `rule` | string | ❌ | 字段规则配置文件路径,无则使用默认规则 |
|
||||
| `name` | string | ❌ | 生成的代码包名和目录名,为空则不生成独立代码文件 |
|
||||
| `mode` | int | ❌ | 生成模式:0=仅配置不生成代码(内嵌模式),非 0=生成代码文件 |
|
||||
|
||||
#### 运行模式
|
||||
|
||||
- **mode=0(内嵌模式)**:不生成独立代码文件,使用框架内置的通用控制器
|
||||
- **mode≠0(生成模式)**:为每张表生成独立的 Go 控制器文件
|
||||
|
||||
#### 多配置支持
|
||||
|
||||
可以配置多个独立的代码生成实例,适用于多端场景:
|
||||
|
||||
```json
|
||||
{
|
||||
"codeConfig": [
|
||||
{
|
||||
"config": "config/admin.json",
|
||||
"table": "admin",
|
||||
"rule": "config/rule.json"
|
||||
},
|
||||
{
|
||||
"config": "config/user.json",
|
||||
"table": "user",
|
||||
"rule": "config/rule.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.2 菜单权限配置(admin.json)
|
||||
|
||||
#### 配置文件更新机制
|
||||
|
||||
- **首次运行**:代码生成器会根据数据库表结构自动创建配置文件(如 admin.json)
|
||||
- **后续运行**:配置文件**不会自动更新**,避免覆盖手动修改的内容
|
||||
- **重新生成**:如需重新生成,删除配置文件后重新运行即可
|
||||
- **参考更新**:可参考 `configDB` 指定的文件(如 adminDB.json)查看最新的数据库结构变化,手动调整配置
|
||||
|
||||
#### 完整配置结构
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "唯一标识(自动生成)",
|
||||
"name": "admin",
|
||||
"label": "管理平台名称",
|
||||
"labelConfig": { ... },
|
||||
"menus": [ ... ],
|
||||
"flow": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
#### label / labelConfig
|
||||
|
||||
```json
|
||||
{
|
||||
"label": "HoTime管理平台",
|
||||
"labelConfig": {
|
||||
"show": "开启",
|
||||
"add": "添加",
|
||||
"delete": "删除",
|
||||
"edit": "编辑",
|
||||
"info": "查看详情",
|
||||
"download": "下载清单"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 操作 | 说明 |
|
||||
|------|------|
|
||||
| show | 显示/查看列表权限 |
|
||||
| add | 添加数据权限 |
|
||||
| delete | 删除数据权限 |
|
||||
| edit | 编辑数据权限 |
|
||||
| info | 查看详情权限 |
|
||||
| download | 下载/导出权限 |
|
||||
|
||||
#### menus 配置
|
||||
|
||||
定义菜单结构,支持多级嵌套(目前只支持**两级菜单**):
|
||||
|
||||
```json
|
||||
{
|
||||
"menus": [
|
||||
{
|
||||
"label": "系统管理",
|
||||
"name": "sys",
|
||||
"icon": "Setting",
|
||||
"auth": ["show"],
|
||||
"menus": [
|
||||
{
|
||||
"label": "用户管理",
|
||||
"table": "user",
|
||||
"auth": ["show", "add", "delete", "edit", "info", "download"]
|
||||
},
|
||||
{
|
||||
"label": "角色管理",
|
||||
"table": "role",
|
||||
"auth": ["show", "add", "delete", "edit", "info"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "文章管理",
|
||||
"table": "article",
|
||||
"icon": "Document",
|
||||
"auth": ["show", "add", "edit", "info"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| label | string | 菜单显示名称 |
|
||||
| name | string | 菜单标识(用于分组,不绑定表时使用) |
|
||||
| table | string | 绑定的数据表名(用于自动生成 CRUD) |
|
||||
| icon | string | 菜单图标名称(Element Plus 图标名,如 `Setting`、`User`、`Document`) |
|
||||
| auth | array | 权限数组,定义该菜单/表拥有的操作权限 |
|
||||
| menus | array | 子菜单数组(支持嵌套) |
|
||||
|
||||
**name vs table**:
|
||||
|
||||
- **table**:绑定数据表,拥有该表的增删查改等权限,自动生成 CRUD 接口
|
||||
- **name**:自定义功能标识,不绑定表,前端根据 name 和 auth 显示自定义内容(如首页 home、仪表盘 dashboard)
|
||||
- 配置了 `menus` 子菜单 → 作为分组
|
||||
- 没有 `menus` → 作为独立自定义功能入口
|
||||
|
||||
```json
|
||||
// 使用 table:绑定数据表
|
||||
{ "label": "用户管理", "table": "user", "auth": ["show", "add", "edit", "delete"] }
|
||||
|
||||
// 使用 name:自定义功能(无子菜单)
|
||||
{ "label": "首页", "name": "home", "icon": "House", "auth": ["show"] }
|
||||
|
||||
// 使用 name:分组功能(有子菜单)
|
||||
{ "label": "系统管理", "name": "sys", "icon": "Setting", "auth": ["show"], "menus": [...] }
|
||||
```
|
||||
|
||||
**自动分组规则**:代码生成器会根据表名的 `_` 分词自动分组:
|
||||
|
||||
- 表名 `sys_logs`、`sys_menus`、`sys_config` → 归入 `sys` 分组
|
||||
- 表名 `article`、`article_tag` → 归入 `article` 分组
|
||||
|
||||
分组的显示名称(label)使用该分组下**第一张表的名字**,如果表有备注则使用备注名。
|
||||
|
||||
#### auth 配置
|
||||
|
||||
| 权限 | 对应接口 | 说明 |
|
||||
|------|----------|------|
|
||||
| show | /search | 列表查询 |
|
||||
| add | /add | 新增数据 |
|
||||
| delete | /remove | 删除数据 |
|
||||
| edit | /update | 编辑数据 |
|
||||
| info | /info | 查看详情 |
|
||||
| download | /search?download=1 | 导出数据 |
|
||||
|
||||
auth 数组**可以自由增删**。新增的权限项会在前端菜单/功能中显示,并在角色管理(role)的权限设置中供管理员分配:
|
||||
|
||||
```json
|
||||
{
|
||||
"label": "文章管理",
|
||||
"table": "article",
|
||||
"auth": ["show", "add", "edit", "delete", "info", "publish", "audit", "top"]
|
||||
}
|
||||
```
|
||||
|
||||
上例中 `publish`(发布)、`audit`(审核)、`top`(置顶)为自定义权限。
|
||||
|
||||
#### flow 配置(数据权限)
|
||||
|
||||
flow 用于限制用户只能操作自己权限范围内的数据。
|
||||
|
||||
```json
|
||||
{
|
||||
"flow": {
|
||||
"role": {
|
||||
"table": "role",
|
||||
"stop": true,
|
||||
"sql": {
|
||||
"id": "role_id"
|
||||
}
|
||||
},
|
||||
"article": {
|
||||
"table": "article",
|
||||
"stop": false,
|
||||
"sql": {
|
||||
"admin_id": "id"
|
||||
}
|
||||
},
|
||||
"org": {
|
||||
"table": "org",
|
||||
"stop": false,
|
||||
"sql": {
|
||||
"parent_ids[~]": ",org_id,"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| table | string | 表名 |
|
||||
| stop | bool | 是否禁止修改自身关联的数据 |
|
||||
| sql | object | 数据过滤条件,自动填充查询/操作条件 |
|
||||
|
||||
**stop**:防止用户修改自己当前关联的敏感数据。例如当前用户 `role_id = 1`,配置 `stop: true` 后,不能修改 role 表中 `id = 1` 的记录,但可修改其他 role(若有权限)。典型用途:防止用户提升自己的角色权限、修改自己所属组织。
|
||||
|
||||
**sql**:格式为 `{ "目标表字段": "当前用户字段" }`。
|
||||
|
||||
| 格式 | 含义 | SQL 等价 |
|
||||
|------|------|----------|
|
||||
| `"field": "user_field"` | 精确匹配 | `field = 用户.user_field` |
|
||||
| `"field[~]": ",value,"` | 模糊匹配 | `field LIKE '%,value,%'` |
|
||||
|
||||
示例效果(当前用户 `{ "id": 5, "role_id": 2, "org_id": 10 }`):
|
||||
|
||||
| 表 | 查询条件 | stop 效果 |
|
||||
|-----|----------|-----------|
|
||||
| role | `WHERE id = 2` | 不能修改 id=2 的角色 |
|
||||
| article | `WHERE admin_id = 5` | 可以修改自己的文章 |
|
||||
| org | `WHERE parent_ids LIKE '%,10,%'` | 可以修改下级组织 |
|
||||
|
||||
#### 完整 admin.json 示例
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "74a8a59407fa7d6c7fcdc85742dbae57",
|
||||
"name": "admin",
|
||||
"label": "后台管理系统",
|
||||
"labelConfig": {
|
||||
"show": "开启",
|
||||
"add": "添加",
|
||||
"delete": "删除",
|
||||
"edit": "编辑",
|
||||
"info": "查看详情",
|
||||
"download": "下载清单"
|
||||
},
|
||||
"menus": [
|
||||
{
|
||||
"label": "系统管理",
|
||||
"name": "sys",
|
||||
"icon": "Setting",
|
||||
"auth": ["show"],
|
||||
"menus": [
|
||||
{
|
||||
"label": "日志管理",
|
||||
"table": "logs",
|
||||
"auth": ["show", "download"]
|
||||
},
|
||||
{
|
||||
"label": "角色管理",
|
||||
"table": "role",
|
||||
"auth": ["show", "add", "delete", "edit", "info"]
|
||||
},
|
||||
{
|
||||
"label": "组织管理",
|
||||
"table": "org",
|
||||
"auth": ["show", "add", "delete", "edit", "info"]
|
||||
},
|
||||
{
|
||||
"label": "员工管理",
|
||||
"table": "admin",
|
||||
"auth": ["show", "add", "delete", "edit", "info", "download"]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"flow": {
|
||||
"admin": {
|
||||
"table": "admin",
|
||||
"stop": false,
|
||||
"sql": { "role_id": "role_id" }
|
||||
},
|
||||
"role": {
|
||||
"table": "role",
|
||||
"stop": true,
|
||||
"sql": { "admin_id": "id", "id": "role_id" }
|
||||
},
|
||||
"org": {
|
||||
"table": "org",
|
||||
"stop": false,
|
||||
"sql": { "admin_id": "id" }
|
||||
},
|
||||
"logs": {
|
||||
"table": "logs",
|
||||
"stop": false,
|
||||
"sql": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 说明:生成器还会在配置中产生 `tables`(字段列、搜索项等)。首次生成可参考 `configDB`(如 adminDB.json)中的结构;持久化自定义请写入 `config` 指定的文件(如 admin.json),因 configDB 会在每次启动时重新生成。
|
||||
|
||||
---
|
||||
|
||||
### 3.3 字段规则配置(rule.json)
|
||||
|
||||
定义字段在增删改查操作中的默认行为。
|
||||
|
||||
#### 配置结构
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "id",
|
||||
"add": false,
|
||||
"edit": false,
|
||||
"info": true,
|
||||
"list": true,
|
||||
"must": false,
|
||||
"strict": true,
|
||||
"type": ""
|
||||
},
|
||||
{
|
||||
"name": "status",
|
||||
"list": true,
|
||||
"add": true,
|
||||
"edit": true,
|
||||
"info": true,
|
||||
"must": false,
|
||||
"strict": false,
|
||||
"type": "select"
|
||||
},
|
||||
{
|
||||
"name": "user.special_field",
|
||||
"list": true,
|
||||
"add": true,
|
||||
"edit": true,
|
||||
"info": true,
|
||||
"type": "text",
|
||||
"strict": true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
#### 字段属性说明
|
||||
|
||||
| 属性 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| name | string | 字段名或关键词;支持 `表名.字段名` 精确匹配 |
|
||||
| add | bool | 新增时是否显示 |
|
||||
| edit | bool | 编辑时是否显示 |
|
||||
| info | bool | 详情页是否显示 |
|
||||
| list | bool | 列表页是否显示 |
|
||||
| must | bool | 是否必填(见下方) |
|
||||
| strict | bool | 是否严格匹配字段名(true=完全匹配,false=包含匹配) |
|
||||
| type | string | 字段类型(影响前端控件和数据处理) |
|
||||
|
||||
#### must 必填字段规则
|
||||
|
||||
**自动识别**:
|
||||
|
||||
1. **MySQL**:字段为 `NOT NULL`(`IS_NULLABLE='NO'`)时自动 `must=true`
|
||||
2. **SQLite**:字段为主键(`pk=1`)时自动 `must=true`
|
||||
|
||||
**规则覆盖**:`rule.json` 中的 `must` 会覆盖数据库自动识别结果。
|
||||
|
||||
**效果**:
|
||||
|
||||
- 前端:`must=true` 显示必填标记(*),提交时校验
|
||||
- 后端:新增时若 `must=true` 字段为空,返回「请求参数不足」
|
||||
|
||||
#### type 类型说明
|
||||
|
||||
| 类型 | 说明 | 前端控件 |
|
||||
|------|------|----------|
|
||||
| (空) | 普通文本 | 文本输入框 |
|
||||
| text | 文本 | 文本输入框 |
|
||||
| number | 数字 | 数字输入框 |
|
||||
| select | 选择 | 下拉选择框(根据注释自动生成选项) |
|
||||
| time | 时间(datetime) | 日期时间选择器 |
|
||||
| unixTime | 时间戳 | 日期时间选择器(存储为 Unix 时间戳) |
|
||||
| password | 密码 | 密码输入框(自动 MD5 加密) |
|
||||
| textArea | 多行文本 | 文本域 |
|
||||
| image | 图片 | 图片上传 |
|
||||
| file | 文件 | 文件上传 |
|
||||
| money | 金额 | 金额输入框 |
|
||||
| auth | 权限 | 权限树选择器 |
|
||||
| form | 表单 | 动态表单 |
|
||||
| index | 索引 | 隐藏字段(用于 parent_ids 等) |
|
||||
| tree | 树形选择 | 树形选择器 |
|
||||
| table | 动态表 | 表名选择器 |
|
||||
| table_id | 动态表ID | 根据 table 字段动态关联 |
|
||||
|
||||
#### 内置字段规则(可在 rule.json 中覆盖)
|
||||
|
||||
**主键和索引**:
|
||||
|
||||
```json
|
||||
{"name": "id", "add": false, "list": true, "edit": false, "info": true, "strict": true}
|
||||
{"name": "sn", "add": false, "list": true, "edit": false, "info": true}
|
||||
{"name": "parent_ids", "add": false, "list": false, "edit": false, "info": false, "type": "index", "strict": true}
|
||||
{"name": "index", "add": false, "list": false, "edit": false, "info": false, "type": "index", "strict": true}
|
||||
```
|
||||
|
||||
**层级关系**:
|
||||
|
||||
```json
|
||||
{"name": "parent_id", "add": true, "list": true, "edit": true, "info": true}
|
||||
{"name": "level", "add": false, "list": false, "edit": false, "info": true}
|
||||
```
|
||||
|
||||
**时间字段**:
|
||||
|
||||
```json
|
||||
{"name": "create_time", "add": false, "list": false, "edit": false, "info": true, "type": "time", "strict": true}
|
||||
{"name": "modify_time", "add": false, "list": true, "edit": false, "info": true, "type": "time", "strict": true}
|
||||
{"name": "time", "add": true, "list": true, "edit": true, "info": true, "type": "time"}
|
||||
```
|
||||
|
||||
**状态字段**:
|
||||
|
||||
```json
|
||||
{"name": "status", "add": true, "list": true, "edit": true, "info": true, "type": "select"}
|
||||
{"name": "state", "add": true, "list": true, "edit": true, "info": true, "type": "select"}
|
||||
{"name": "sex", "add": true, "list": true, "edit": true, "info": true, "type": "select"}
|
||||
```
|
||||
|
||||
**敏感字段**:
|
||||
|
||||
```json
|
||||
{"name": "password", "add": true, "list": false, "edit": true, "info": false, "type": "password"}
|
||||
{"name": "pwd", "add": true, "list": false, "edit": true, "info": false, "type": "password"}
|
||||
{"name": "delete", "add": false, "list": false, "edit": false, "info": false}
|
||||
{"name": "version", "add": false, "list": false, "edit": false, "info": false}
|
||||
```
|
||||
|
||||
**媒体字段**:
|
||||
|
||||
```json
|
||||
{"name": "image", "add": true, "list": false, "edit": true, "info": true, "type": "image"}
|
||||
{"name": "img", "add": true, "list": false, "edit": true, "info": true, "type": "image"}
|
||||
{"name": "avatar", "add": true, "list": false, "edit": true, "info": true, "type": "image"}
|
||||
{"name": "icon", "add": true, "list": false, "edit": true, "info": true, "type": "image"}
|
||||
{"name": "file", "add": true, "list": false, "edit": true, "info": true, "type": "file"}
|
||||
```
|
||||
|
||||
**文本字段**:
|
||||
|
||||
```json
|
||||
{"name": "info", "add": true, "list": false, "edit": true, "info": true, "type": "textArea"}
|
||||
{"name": "content", "add": true, "list": false, "edit": true, "info": true, "type": "textArea"}
|
||||
{"name": "description", "add": true, "list": false, "edit": true, "info": true}
|
||||
{"name": "note", "add": true, "list": false, "edit": true, "info": true}
|
||||
{"name": "address", "add": true, "list": true, "edit": true, "info": true}
|
||||
```
|
||||
|
||||
**特殊字段**:
|
||||
|
||||
```json
|
||||
{"name": "amount", "add": true, "list": true, "edit": true, "info": true, "type": "money", "strict": true}
|
||||
{"name": "auth", "add": true, "list": false, "edit": true, "info": true, "type": "auth", "strict": true}
|
||||
{"name": "rule", "add": true, "list": true, "edit": true, "info": true, "type": "form"}
|
||||
{"name": "table", "add": false, "list": true, "edit": false, "info": true, "type": "table"}
|
||||
{"name": "table_id", "add": false, "list": true, "edit": false, "info": true, "type": "table_id"}
|
||||
```
|
||||
|
||||
#### 自定义字段规则
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "company_name",
|
||||
"add": true,
|
||||
"edit": true,
|
||||
"info": true,
|
||||
"list": true,
|
||||
"must": true,
|
||||
"strict": true,
|
||||
"type": ""
|
||||
},
|
||||
{
|
||||
"name": "user.nickname",
|
||||
"add": true,
|
||||
"edit": true,
|
||||
"info": true,
|
||||
"list": true,
|
||||
"strict": true,
|
||||
"type": ""
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.4 配置检查清单
|
||||
|
||||
#### codeConfig
|
||||
|
||||
- [ ] config 文件路径正确
|
||||
- [ ] rule 文件路径正确
|
||||
- [ ] table 指定的管理员表存在
|
||||
|
||||
#### 菜单权限配置
|
||||
|
||||
- [ ] 所有 table 指向的表在数据库中存在
|
||||
- [ ] auth 数组包含需要的权限
|
||||
- [ ] menus 结构正确(有子菜单用 name,无子菜单用 table)
|
||||
- [ ] flow 配置的 sql 条件字段存在
|
||||
|
||||
#### 字段规则配置
|
||||
|
||||
- [ ] strict=true 的规则字段名完全匹配
|
||||
- [ ] type 类型与前端控件需求一致
|
||||
- [ ] 敏感字段(password 等)的 list 和 info 为 false
|
||||
|
||||
---
|
||||
|
||||
## 四、生成规则与产物
|
||||
|
||||
### 4.1 默认字段规则
|
||||
|
||||
代码生成器内置的字段识别一览(完整内置规则见 [3.3](#33-字段规则配置rulejson)):
|
||||
|
||||
| 字段名 | 列表显示 | 新增 | 编辑 | 详情 | 类型 |
|
||||
|--------|----------|------|------|------|------|
|
||||
| `id` | ✅ | ❌ | ❌ | ✅ | number |
|
||||
| `name` | ✅ | ✅ | ✅ | ✅ | text |
|
||||
| `status` | ✅ | ✅ | ✅ | ✅ | select |
|
||||
| `create_time` | ❌ | ❌ | ❌ | ✅ | time |
|
||||
| `modify_time` | ✅ | ❌ | ❌ | ✅ | time |
|
||||
| `password` | ❌ | ✅ | ✅ | ❌ | password |
|
||||
| `image/img/avatar` | ❌ | ✅ | ✅ | ✅ | image |
|
||||
| `file` | ❌ | ✅ | ✅ | ✅ | file |
|
||||
| `content/info` | ❌ | ✅ | ✅ | ✅ | textArea |
|
||||
| `parent_id` | ✅ | ✅ | ✅ | ✅ | number |
|
||||
| `parent_ids/index` | ❌ | ❌ | ❌ | ❌ | index |
|
||||
| `delete` | ❌ | ❌ | ❌ | ❌ | - |
|
||||
|
||||
### 4.2 数据类型映射
|
||||
|
||||
| 数据库类型 | 生成类型 |
|
||||
|------------|----------|
|
||||
| `int`, `integer`, `float`, `double`, `decimal` | number |
|
||||
| `char`, `varchar`, `text`, `blob` | text |
|
||||
| `date`, `datetime`, `time`, `timestamp`, `year` | time |
|
||||
|
||||
### 4.3 字段备注解析
|
||||
|
||||
字段 COMMENT 的完整语法、选项写法与设计约定,以 [数据库设计规范](DatabaseDesign_数据库设计规范.md) 为准。此处仅说明生成器如何消费备注。
|
||||
|
||||
支持从数据库字段备注中提取标签、提示与选项(括号支持 `()`、`()`、`{}`):
|
||||
|
||||
```sql
|
||||
-- 格式: 标签名(提示信息):选项1-名称1,选项2-名称2
|
||||
status TINYINT COMMENT '状态(用户账号状态):0-禁用,1-启用'
|
||||
parent_id INT COMMENT '父级ID(顶级为NULL)'
|
||||
phone VARCHAR(20) COMMENT '手机号(请输入11位手机号)'
|
||||
```
|
||||
|
||||
生成的配置示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "status",
|
||||
"label": "状态",
|
||||
"type": "select",
|
||||
"ps": "用户账号状态",
|
||||
"options": [
|
||||
{"name": "禁用", "value": "0"},
|
||||
{"name": "启用", "value": "1"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`ps` 字段在前端的展示效果:
|
||||
|
||||
- 编辑/新增页面:输入框右侧灰色小字提示
|
||||
- 表格/详情页面:鼠标悬停字段名时气泡提示
|
||||
|
||||
### 4.4 外键关联
|
||||
|
||||
代码生成器会自动识别 `_id` 结尾的字段作为外键:
|
||||
|
||||
```sql
|
||||
CREATE TABLE user (
|
||||
id INT PRIMARY KEY,
|
||||
name VARCHAR(50),
|
||||
role_id INT, -- 自动关联 role 表
|
||||
org_id INT -- 自动关联 org 表
|
||||
);
|
||||
```
|
||||
|
||||
生成的配置会包含 `link` 和 `value` 字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "role_id",
|
||||
"type": "number",
|
||||
"label": "角色",
|
||||
"link": "role",
|
||||
"value": "name"
|
||||
}
|
||||
```
|
||||
|
||||
`parent_id` 会被识别为树形结构的父级关联:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "parent_id",
|
||||
"type": "number",
|
||||
"label": "上级",
|
||||
"link": "org",
|
||||
"value": "name"
|
||||
}
|
||||
```
|
||||
|
||||
表命名、外键命名与 COMMENT 约定见 [数据库设计规范](DatabaseDesign_数据库设计规范.md)。
|
||||
|
||||
### 4.5 生成的代码结构
|
||||
|
||||
#### 内嵌模式 (mode=0)
|
||||
|
||||
不生成代码文件,使用框架内置控制器,只生成配置文件:
|
||||
|
||||
```
|
||||
config/
|
||||
├── admin.json # 接口/菜单权限配置
|
||||
├── adminDB.json # 数据库结构配置(可选)
|
||||
└── rule.json # 字段规则
|
||||
```
|
||||
|
||||
#### 生成模式 (mode≠0)
|
||||
|
||||
生成独立的控制器代码:
|
||||
|
||||
```
|
||||
admin/ # 生成的包目录(由 codeConfig.name 决定)
|
||||
├── init.go # 包初始化和路由注册
|
||||
├── user.go # user 表控制器
|
||||
├── role.go # role 表控制器
|
||||
└── ... # 其他表控制器
|
||||
```
|
||||
|
||||
#### 生成的控制器结构
|
||||
|
||||
```go
|
||||
package admin
|
||||
|
||||
var userCtr = Ctr{
|
||||
"info": func(that *Context) {
|
||||
// 查询单条记录
|
||||
},
|
||||
"add": func(that *Context) {
|
||||
// 新增记录
|
||||
},
|
||||
"update": func(that *Context) {
|
||||
// 更新记录
|
||||
},
|
||||
"remove": func(that *Context) {
|
||||
// 删除记录
|
||||
},
|
||||
"search": func(that *Context) {
|
||||
// 搜索列表(分页)
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 4.6 多库与备注注意事项
|
||||
|
||||
#### 达梦 DM8
|
||||
|
||||
代码生成器已原生支持达梦 DM8,会自动读取表结构和字段备注。注意:
|
||||
|
||||
- 表注释通过 `COMMENT ON TABLE` 单独设置,建表后需额外执行
|
||||
- 字段备注通过 `COMMENT ON COLUMN` 设置
|
||||
- 已对 DM 的 `USER_TAB_COLUMNS` 与 MySQL `information_schema` 差异做兼容
|
||||
|
||||
```sql
|
||||
COMMENT ON TABLE "user" IS '用户管理';
|
||||
COMMENT ON COLUMN "user"."state" IS '状态:0-正常,1-异常,2-隐藏';
|
||||
COMMENT ON COLUMN "user"."name" IS '用户名';
|
||||
```
|
||||
|
||||
#### SQLite 备注替代方案
|
||||
|
||||
SQLite 不支持表/字段 COMMENT,生成器会使用表名/字段名作为默认显示名称。可利用配置覆盖机制手动设置:
|
||||
|
||||
1. 首次运行生成配置文件(如 admin.json)
|
||||
2. 在菜单中设置表的 `label`
|
||||
3. 在 `tables.表名.columns` 中设置字段 `label`、`ps`、`options` 等
|
||||
|
||||
```json
|
||||
{
|
||||
"tables": {
|
||||
"user": {
|
||||
"label": "用户管理",
|
||||
"columns": [
|
||||
{
|
||||
"name": "name",
|
||||
"label": "用户名",
|
||||
"ps": "请输入用户名",
|
||||
"must": true
|
||||
},
|
||||
{
|
||||
"name": "status",
|
||||
"label": "状态",
|
||||
"type": "select",
|
||||
"options": [
|
||||
{"name": "正常", "value": "0"},
|
||||
{"name": "禁用", "value": "1"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**注意**:`configDB` 文件每次启动会重新生成。持久化修改应写入 `config` 指定的文件(如 admin.json)。
|
||||
|
||||
### 4.7 最佳实践
|
||||
|
||||
1. **字段命名**:使用规范字段名(`id`、`name`、`status`、`create_time`、`modify_time`、`xxx_id`、`parent_id`、`avatar`、`content` 等),便于自动识别。完整约定见 [数据库设计规范](DatabaseDesign_数据库设计规范.md)。
|
||||
2. **自定义扩展**:默认规则不足时,可修改 `rule.json`、使用生成模式后手动改代码,或直接调整配置文件中的字段属性。
|
||||
3. **生产环境**:开发用 `mode: 2`,上线改为 `mode: 0`,避免运行时反复改写配置/代码。
|
||||
|
||||
---
|
||||
|
||||
## 五、相关文档
|
||||
|
||||
- [数据库设计规范](DatabaseDesign_数据库设计规范.md)(代码生成前置依赖)
|
||||
- [HoTimeDB 使用说明](HoTimeDB_使用说明.md)
|
||||
- [快速上手](QuickStart_快速上手.md)
|
||||
- [Common 工具类](Common_工具类.md)
|
||||
@@ -0,0 +1,485 @@
|
||||
# HoTime Common 工具类使用说明
|
||||
|
||||
`common` 包提供了 HoTime 框架的核心数据类型和工具函数,包括 `Map`、`Slice`、`Obj` 类型及丰富的类型转换函数。
|
||||
|
||||
## 目录
|
||||
|
||||
- [核心数据类型](#核心数据类型)
|
||||
- [Map 类型](#map-类型)
|
||||
- [Slice 类型](#slice-类型)
|
||||
- [Obj 类型](#obj-类型)
|
||||
- [类型转换函数](#类型转换函数)
|
||||
- [工具函数](#工具函数)
|
||||
- [错误处理](#错误处理)
|
||||
|
||||
---
|
||||
|
||||
## 核心数据类型
|
||||
|
||||
### Map 类型
|
||||
|
||||
`Map` 是 `map[string]interface{}` 的别名,提供了丰富的链式调用方法。
|
||||
|
||||
```go
|
||||
import . "code.hoteas.com/golang/hotime/common"
|
||||
|
||||
// 创建 Map
|
||||
data := Map{
|
||||
"name": "张三",
|
||||
"age": 25,
|
||||
"score": 98.5,
|
||||
"active": true,
|
||||
"tags": Slice{"Go", "Web"},
|
||||
}
|
||||
```
|
||||
|
||||
#### 获取值方法
|
||||
|
||||
```go
|
||||
// 获取字符串
|
||||
name := data.GetString("name") // "张三"
|
||||
|
||||
// 获取整数
|
||||
age := data.GetInt("age") // 25
|
||||
age64 := data.GetInt64("age") // int64(25)
|
||||
|
||||
// 获取浮点数
|
||||
score := data.GetFloat64("score") // 98.5
|
||||
|
||||
// 获取布尔值
|
||||
active := data.GetBool("active") // true
|
||||
|
||||
// 获取嵌套 Map
|
||||
info := data.GetMap("info") // 返回 Map 类型
|
||||
|
||||
// 获取 Slice
|
||||
tags := data.GetSlice("tags") // 返回 Slice 类型
|
||||
|
||||
// 获取时间
|
||||
createTime := data.GetTime("create_time") // 返回 *time.Time
|
||||
|
||||
// 获取原始值
|
||||
raw := data.Get("name") // interface{}
|
||||
```
|
||||
|
||||
#### 向上取整方法
|
||||
|
||||
```go
|
||||
// 向上取整获取整数
|
||||
ceilInt := data.GetCeilInt("score") // 99
|
||||
ceilInt64 := data.GetCeilInt64("score") // int64(99)
|
||||
ceilFloat := data.GetCeilFloat64("score") // 99.0
|
||||
```
|
||||
|
||||
#### 操作方法
|
||||
|
||||
```go
|
||||
// 添加/修改值
|
||||
data.Put("email", "test@example.com")
|
||||
|
||||
// 删除值
|
||||
data.Delete("email")
|
||||
|
||||
// 转换为 JSON 字符串
|
||||
jsonStr := data.ToJsonString()
|
||||
|
||||
// 从 JSON 字符串解析
|
||||
data.JsonToMap(`{"key": "value"}`)
|
||||
```
|
||||
|
||||
#### 有序遍历
|
||||
|
||||
```go
|
||||
// 按 key 字母顺序遍历
|
||||
data.RangeSort(func(k string, v interface{}) bool {
|
||||
fmt.Printf("%s: %v\n", k, v)
|
||||
return false // 返回 true 则终止遍历
|
||||
})
|
||||
```
|
||||
|
||||
#### 转换为结构体
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
Name string
|
||||
Age int64
|
||||
Score float64
|
||||
}
|
||||
|
||||
var user User
|
||||
data.ToStruct(&user) // 传入指针,字段名首字母大写匹配
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Slice 类型
|
||||
|
||||
`Slice` 是 `[]interface{}` 的别名,提供类似 Map 的链式调用方法。
|
||||
|
||||
```go
|
||||
// 创建 Slice
|
||||
list := Slice{
|
||||
Map{"id": 1, "name": "Alice"},
|
||||
Map{"id": 2, "name": "Bob"},
|
||||
"text",
|
||||
123,
|
||||
}
|
||||
```
|
||||
|
||||
#### 获取值方法
|
||||
|
||||
```go
|
||||
// 按索引获取值(类型转换)
|
||||
str := list.GetString(2) // "text"
|
||||
num := list.GetInt(3) // 123
|
||||
num64 := list.GetInt64(3) // int64(123)
|
||||
f := list.GetFloat64(3) // 123.0
|
||||
b := list.GetBool(3) // true (非0为true)
|
||||
|
||||
// 获取嵌套类型
|
||||
item := list.GetMap(0) // Map{"id": 1, "name": "Alice"}
|
||||
subList := list.GetSlice(0) // 尝试转换为 Slice
|
||||
|
||||
// 获取原始值
|
||||
raw := list.Get(0) // interface{}
|
||||
|
||||
// 获取时间
|
||||
t := list.GetTime(0) // *time.Time
|
||||
```
|
||||
|
||||
#### 向上取整方法
|
||||
|
||||
```go
|
||||
ceilInt := list.GetCeilInt(3)
|
||||
ceilInt64 := list.GetCeilInt64(3)
|
||||
ceilFloat := list.GetCeilFloat64(3)
|
||||
```
|
||||
|
||||
#### 操作方法
|
||||
|
||||
```go
|
||||
// 修改指定位置的值
|
||||
list.Put(0, "new value")
|
||||
|
||||
// 转换为 JSON 字符串
|
||||
jsonStr := list.ToJsonString()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Obj 类型
|
||||
|
||||
`Obj` 是一个通用的对象包装器,用于链式类型转换,常用于 `Context` 方法的返回值。
|
||||
|
||||
```go
|
||||
type Obj struct {
|
||||
Data interface{} // 原始数据
|
||||
Error // 错误信息
|
||||
}
|
||||
```
|
||||
|
||||
#### 使用示例
|
||||
|
||||
```go
|
||||
obj := &Obj{Data: "123"}
|
||||
|
||||
// 链式类型转换
|
||||
i := obj.ToInt() // 123
|
||||
i64 := obj.ToInt64() // int64(123)
|
||||
f := obj.ToFloat64() // 123.0
|
||||
s := obj.ToStr() // "123"
|
||||
b := obj.ToBool() // true
|
||||
|
||||
// 复杂类型转换
|
||||
m := obj.ToMap() // 尝试转换为 Map
|
||||
sl := obj.ToSlice() // 尝试转换为 Slice
|
||||
arr := obj.ToMapArray() // 转换为 []Map
|
||||
|
||||
// 获取原始值
|
||||
raw := obj.ToObj() // interface{}
|
||||
|
||||
// 获取时间
|
||||
t := obj.ToTime() // *time.Time
|
||||
|
||||
// 向上取整
|
||||
ceil := obj.ToCeilInt()
|
||||
ceil64 := obj.ToCeilInt64()
|
||||
ceilF := obj.ToCeilFloat64()
|
||||
```
|
||||
|
||||
#### 在 Context 中的应用
|
||||
|
||||
```go
|
||||
func handler(that *Context) {
|
||||
// ReqData 返回 *Obj,支持链式调用
|
||||
userId := that.ReqData("user_id").ToInt()
|
||||
name := that.ReqData("name").ToStr()
|
||||
|
||||
// Session 也返回 *Obj
|
||||
adminId := that.Session("admin_id").ToInt64()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 类型转换函数
|
||||
|
||||
`common` 包提供了一系列全局类型转换函数。
|
||||
|
||||
### 基础转换
|
||||
|
||||
```go
|
||||
// 转字符串
|
||||
str := ObjToStr(123) // "123"
|
||||
str := ObjToStr(3.14) // "3.14"
|
||||
str := ObjToStr(Map{"a": 1}) // JSON 格式字符串
|
||||
|
||||
// 转整数
|
||||
i := ObjToInt("123") // 123
|
||||
i64 := ObjToInt64("123") // int64(123)
|
||||
|
||||
// 转浮点数
|
||||
f := ObjToFloat64("3.14") // 3.14
|
||||
|
||||
// 转布尔
|
||||
b := ObjToBool(1) // true
|
||||
b := ObjToBool(0) // false
|
||||
|
||||
// 转 Map
|
||||
m := ObjToMap(`{"a": 1}`) // Map{"a": 1}
|
||||
m := ObjToMap(someStruct) // 结构体转 Map
|
||||
|
||||
// 转 Slice
|
||||
s := ObjToSlice(`[1, 2, 3]`) // Slice{1, 2, 3}
|
||||
|
||||
// 转 []Map
|
||||
arr := ObjToMapArray(slice) // []Map
|
||||
```
|
||||
|
||||
### 向上取整转换
|
||||
|
||||
```go
|
||||
// 向上取整后转整数
|
||||
ceil := ObjToCeilInt(3.2) // 4
|
||||
ceil64 := ObjToCeilInt64(3.2) // int64(4)
|
||||
ceilF := ObjToCeilFloat64(3.2) // 4.0
|
||||
```
|
||||
|
||||
### 时间转换
|
||||
|
||||
```go
|
||||
// 自动识别多种格式
|
||||
t := ObjToTime("2024-01-15 10:30:00") // *time.Time
|
||||
t := ObjToTime("2024-01-15") // *time.Time
|
||||
t := ObjToTime(1705298400) // Unix 秒
|
||||
t := ObjToTime(1705298400000) // Unix 毫秒
|
||||
t := ObjToTime(1705298400000000) // Unix 微秒
|
||||
```
|
||||
|
||||
### 字符串转换
|
||||
|
||||
```go
|
||||
// 字符串转 Map
|
||||
m := StrToMap(`{"key": "value"}`)
|
||||
|
||||
// 字符串转 Slice
|
||||
s := StrToSlice(`[1, 2, 3]`)
|
||||
|
||||
// 字符串转 int
|
||||
i, err := StrToInt("123")
|
||||
|
||||
// 字符串数组格式转换
|
||||
jsonArr := StrArrayToJsonStr("a1,a2,a3") // "[a1,a2,a3]"
|
||||
strArr := JsonStrToStrArray("[a1,a2,a3]") // ",a1,a2,a3,"
|
||||
```
|
||||
|
||||
### 错误处理
|
||||
|
||||
所有转换函数支持可选的错误参数:
|
||||
|
||||
```go
|
||||
var e Error
|
||||
i := ObjToInt("abc", &e)
|
||||
if e.GetError() != nil {
|
||||
// 处理转换错误
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 工具函数
|
||||
|
||||
### 字符串处理
|
||||
|
||||
```go
|
||||
// 字符串截取(支持中文)
|
||||
str := Substr("Hello世界", 0, 7) // "Hello世"
|
||||
str := Substr("Hello", -2, 2) // "lo" (负数从末尾计算)
|
||||
|
||||
// 首字母大写
|
||||
upper := StrFirstToUpper("hello") // "Hello"
|
||||
|
||||
// 查找最后出现位置
|
||||
idx := IndexLastStr("a.b.c", ".") // 3
|
||||
|
||||
// 字符串相似度(Levenshtein 距离)
|
||||
dist := StrLd("hello", "hallo", true) // 1 (忽略大小写)
|
||||
```
|
||||
|
||||
### 时间处理
|
||||
|
||||
```go
|
||||
// 时间转字符串
|
||||
str := Time2Str(time.Now()) // "2024-01-15 10:30:00"
|
||||
str := Time2Str(time.Now(), 1) // "2024-01"
|
||||
str := Time2Str(time.Now(), 2) // "2024-01-15"
|
||||
str := Time2Str(time.Now(), 3) // "2024-01-15 10"
|
||||
str := Time2Str(time.Now(), 4) // "2024-01-15 10:30"
|
||||
str := Time2Str(time.Now(), 5) // "2024-01-15 10:30:00"
|
||||
|
||||
// 特殊格式
|
||||
str := Time2Str(time.Now(), 12) // "01-15"
|
||||
str := Time2Str(time.Now(), 14) // "01-15 10:30"
|
||||
str := Time2Str(time.Now(), 34) // "10:30"
|
||||
str := Time2Str(time.Now(), 35) // "10:30:00"
|
||||
```
|
||||
|
||||
### 加密与随机
|
||||
|
||||
```go
|
||||
// MD5 加密
|
||||
hash := Md5("password") // 32位小写MD5
|
||||
|
||||
// 随机数
|
||||
r := Rand(3) // 3位随机数 (0-999)
|
||||
r := RandX(10, 100) // 10-100之间的随机数
|
||||
```
|
||||
|
||||
### 数学计算
|
||||
|
||||
```go
|
||||
// 四舍五入保留小数
|
||||
f := Round(3.14159, 2) // 3.14
|
||||
f := Round(3.145, 2) // 3.15
|
||||
```
|
||||
|
||||
### 深拷贝
|
||||
|
||||
```go
|
||||
// 深拷贝 Map/Slice(递归复制)
|
||||
original := Map{"a": Map{"b": 1}}
|
||||
copied := DeepCopyMap(original).(Map)
|
||||
|
||||
// 修改副本不影响原始数据
|
||||
copied.GetMap("a")["b"] = 2
|
||||
// original["a"]["b"] 仍然是 1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 错误处理
|
||||
|
||||
### Error 类型
|
||||
|
||||
```go
|
||||
type Error struct {
|
||||
Logger *logrus.Logger // 可选的日志记录器
|
||||
error // 内嵌错误
|
||||
}
|
||||
```
|
||||
|
||||
### 使用示例
|
||||
|
||||
```go
|
||||
var e Error
|
||||
|
||||
// 设置错误
|
||||
e.SetError(errors.New("something wrong"))
|
||||
|
||||
// 获取错误
|
||||
if err := e.GetError(); err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
|
||||
// 配合日志自动记录
|
||||
e.Logger = logrusLogger
|
||||
e.SetError(errors.New("will be logged"))
|
||||
```
|
||||
|
||||
### 在类型转换中使用
|
||||
|
||||
```go
|
||||
var e Error
|
||||
data := Map{"count": "abc"}
|
||||
|
||||
count := data.GetInt("count", &e)
|
||||
if e.GetError() != nil {
|
||||
// 转换失败,count = 0
|
||||
fmt.Println("转换失败:", e.GetError())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 链式调用处理请求数据
|
||||
|
||||
```go
|
||||
func handler(that *Context) {
|
||||
// 推荐:使用 Obj 链式调用
|
||||
userId := that.ReqData("user_id").ToInt()
|
||||
page := that.ReqData("page").ToInt()
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
// 处理 Map 数据
|
||||
user := that.Db.Get("user", "*", Map{"id": userId})
|
||||
if user != nil {
|
||||
name := user.GetString("name")
|
||||
age := user.GetInt("age")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 安全的类型转换
|
||||
|
||||
```go
|
||||
// 带错误检查的转换
|
||||
var e Error
|
||||
data := someMap.GetInt("key", &e)
|
||||
if e.GetError() != nil {
|
||||
// 使用默认值
|
||||
data = 0
|
||||
}
|
||||
|
||||
// 简单场景直接转换(失败返回零值)
|
||||
data := someMap.GetInt("key") // 失败返回 0
|
||||
```
|
||||
|
||||
### 3. 处理数据库查询结果
|
||||
|
||||
```go
|
||||
// 查询返回 Map
|
||||
user := that.Db.Get("user", "*", Map{"id": 1})
|
||||
if user != nil {
|
||||
name := user.GetString("name")
|
||||
createTime := user.GetTime("create_time")
|
||||
}
|
||||
|
||||
// 查询返回 []Map
|
||||
users := that.Db.Select("user", "*", Map{"status": 1})
|
||||
for _, u := range users {
|
||||
fmt.Printf("ID: %d, Name: %s\n", u.GetInt("id"), u.GetString("name"))
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [快速上手](QuickStart_快速上手.md)
|
||||
- [HoTimeDB 使用说明](HoTimeDB_使用说明.md)
|
||||
- [代码生成](CodeGen_代码生成.md)
|
||||
- [文档索引](README.md)
|
||||
@@ -0,0 +1,497 @@
|
||||
# 数据库设计规范
|
||||
|
||||
本文档是 HoTime **CodeGen 代码生成器的输入契约**:表/字段命名、外键、注释与公共字段等规则直接决定生成结果。请严格按本文设计库表,再配合 [CodeGen_代码生成.md](CodeGen_代码生成.md) 使用代码生成。
|
||||
|
||||
遵循这些规范可确保代码生成器正确识别表关系、自动生成 CRUD 接口和管理后台。
|
||||
|
||||
---
|
||||
|
||||
## 表命名规则
|
||||
|
||||
### 1. 关于表名前缀
|
||||
|
||||
一般情况下**没必要强行添加前缀分组**,直接使用业务含义命名即可:
|
||||
|
||||
```
|
||||
推荐:user、org、role、article
|
||||
```
|
||||
|
||||
**可以使用前缀的场景**:当项目较大、表较多时,可以用前缀进行模块分组(如 `sys_`、`cms_`),代码生成器会根据 `_` 分词自动将同前缀的表归为一组。
|
||||
|
||||
```
|
||||
sys_user、sys_role、sys_org → 自动归入 sys 分组
|
||||
cms_article、cms_category → 自动归入 cms 分组
|
||||
```
|
||||
|
||||
**使用前缀的注意事项**:
|
||||
- 必须遵循外键全局唯一性规则(见下文)
|
||||
- 前缀分组后,关联 ID 命名会更复杂,容易产生重复
|
||||
- **外键名不允许重复指向不同的表**
|
||||
|
||||
### 2. 可使用简称
|
||||
|
||||
较长的表名可以使用常见简称:
|
||||
|
||||
| 全称 | 简称 |
|
||||
|------|------|
|
||||
| organization | org |
|
||||
| category | ctg |
|
||||
| configuration | config |
|
||||
| administrator | admin |
|
||||
|
||||
### 3. 关联表命名
|
||||
|
||||
多对多关联表使用 `主表_关联表` 格式:
|
||||
|
||||
```
|
||||
user_org -- 用户与组织的关联
|
||||
user_role -- 用户与角色的关联
|
||||
article_tag -- 文章与标签的关联
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 字段命名规则
|
||||
|
||||
### 1. 主键字段
|
||||
|
||||
所有表的主键统一命名为 `id`,使用自增整数。
|
||||
|
||||
```sql
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT
|
||||
```
|
||||
|
||||
### 2. 外键字段
|
||||
|
||||
外键使用 `完整表名_id` 格式:
|
||||
|
||||
```
|
||||
user_id -- 指向 user 表
|
||||
org_id -- 指向 org 表
|
||||
role_id -- 指向 role 表
|
||||
app_category_id -- 指向 app_category 表
|
||||
```
|
||||
|
||||
### 3. 关联表引用
|
||||
|
||||
引用关联表时使用 `关联表名_id`:
|
||||
|
||||
```
|
||||
user_org_id -- 指向 user_org 表
|
||||
user_role_id -- 指向 user_role 表
|
||||
```
|
||||
|
||||
### 4. 外键全局唯一性(重要)
|
||||
|
||||
**每个 `xxx_id` 字段名必须全局唯一指向一张表**,代码生成器通过 `_id` 后缀自动识别外键关系。
|
||||
|
||||
```
|
||||
✅ 正确设计:
|
||||
- user_id 只能指向 user 表
|
||||
- org_id 只能指向 org 表
|
||||
- user_org_id 只能指向 user_org 表
|
||||
- sys_user_id 只能指向 sys_user 表(如果使用前缀分组)
|
||||
|
||||
❌ 错误设计:
|
||||
- dd_id 作为"钉钉外部系统ID",但系统中存在 dd 表
|
||||
→ 代码生成器会误判为指向 dd 表的外键
|
||||
- 同时存在 user 表和 sys_user 表,都使用 user_id
|
||||
→ 外键名重复,代码生成器无法正确识别
|
||||
```
|
||||
|
||||
**使用前缀分组时的外键命名**:
|
||||
|
||||
如果使用了表名前缀(如 `sys_user`),外键应使用完整表名:
|
||||
|
||||
| 表名 | 外键命名 |
|
||||
|------|----------|
|
||||
| sys_user | sys_user_id |
|
||||
| sys_org | sys_org_id |
|
||||
| cms_article | cms_article_id |
|
||||
|
||||
**非外键业务标识字段**:避免使用 `xxx_id` 格式
|
||||
|
||||
| 业务含义 | 错误命名 | 正确命名 |
|
||||
|----------|----------|----------|
|
||||
| 设备唯一标识 | device_id | device_uuid / device_sn |
|
||||
| 钉钉用户ID | dingtalk_user_id | dt_user_id / dingtalk_uid |
|
||||
| 微信OpenID | wechat_id | wechat_openid |
|
||||
| 外部系统编号 | external_id | external_code / external_sn |
|
||||
|
||||
### 5. 外键智能匹配机制
|
||||
|
||||
代码生成器会按以下优先级自动匹配外键关联的表:
|
||||
|
||||
1. **完全匹配**:`user_id` → 查找 `user` 表
|
||||
2. **带前缀的完全匹配**:如果没找到,尝试 `user_id` → 查找 `sys_user` 表(默认前缀)
|
||||
3. **去除字段前缀匹配**:如果字段有前缀且找不到对应表,会去掉前缀匹配
|
||||
|
||||
**示例**:`admin_user_id` 字段的匹配过程:
|
||||
1. 先查找 `admin_user` 表 → 如果存在,关联到 `admin_user` 表
|
||||
2. 如果不存在,去掉前缀查找 `user` 表 → 如果存在,关联到 `user` 表
|
||||
|
||||
**使用场景**:当一张表需要记录多个用户(如创建人、审核人、处理人)时:
|
||||
|
||||
```sql
|
||||
CREATE TABLE `order` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int(11) COMMENT '下单用户',
|
||||
`audit_user_id` int(11) COMMENT '审核人',
|
||||
`handle_user_id` int(11) COMMENT '处理人',
|
||||
...
|
||||
);
|
||||
```
|
||||
|
||||
如果系统中没有 `audit_user` 和 `handle_user` 表,代码生成器会自动将 `audit_user_id` 和 `handle_user_id` 识别为指向 `user` 表的外键。
|
||||
|
||||
### 6. 关联表冗余外键
|
||||
|
||||
关联表应包含必要的冗余外键便于查询:
|
||||
|
||||
```sql
|
||||
-- user_app 表(用户与应用的关联)
|
||||
CREATE TABLE user_app (
|
||||
id int(11) NOT NULL AUTO_INCREMENT,
|
||||
user_id int(11) DEFAULT NULL COMMENT '用户ID',
|
||||
app_id int(11) DEFAULT NULL COMMENT '应用ID',
|
||||
app_category_id int(11) DEFAULT NULL COMMENT '应用分类ID(冗余,便于按分类查询)',
|
||||
...
|
||||
);
|
||||
```
|
||||
|
||||
### 7. 层级关系字段
|
||||
|
||||
树形结构的表使用以下字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| parent_id | int | 父级ID,顶级为 NULL 或 0 |
|
||||
| parent_ids | varchar(255) | 完整层级路径,格式:`,1,3,5,`(从根到当前,逗号分隔) |
|
||||
| level | int | 层级深度,从 0 开始 |
|
||||
|
||||
**示例**:
|
||||
|
||||
```
|
||||
id=1, parent_id=NULL, parent_ids=",1,", level=0 -- 顶级
|
||||
id=3, parent_id=1, parent_ids=",1,3,", level=1 -- 一级
|
||||
id=5, parent_id=3, parent_ids=",1,3,5,", level=2 -- 二级
|
||||
```
|
||||
|
||||
`parent_ids` 的设计便于快速查询所有子级:
|
||||
```sql
|
||||
SELECT * FROM org WHERE parent_ids LIKE '%,3,%' -- 查询id=3的所有子级
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 时间字段类型
|
||||
|
||||
不同数据库使用对应的时间类型:
|
||||
|
||||
| 数据库 | 类型 | 示例 |
|
||||
|--------|------|------|
|
||||
| MySQL | datetime | `2024-01-15 10:30:00` |
|
||||
| SQLite | TEXT | `2024-01-15 10:30:00` |
|
||||
| PostgreSQL | timestamp | `2024-01-15 10:30:00` |
|
||||
| 达梦 DM8 | TIMESTAMP | `2024-01-15 10:30:00` |
|
||||
|
||||
---
|
||||
|
||||
## 表注释规则
|
||||
|
||||
### 表备注命名建议
|
||||
|
||||
表备注建议使用"XX管理"格式,便于在管理后台显示:
|
||||
|
||||
```sql
|
||||
-- MySQL 表备注示例
|
||||
CREATE TABLE `user` (
|
||||
...
|
||||
) COMMENT='用户管理';
|
||||
|
||||
CREATE TABLE `article` (
|
||||
...
|
||||
) COMMENT='文章管理';
|
||||
|
||||
CREATE TABLE `order` (
|
||||
...
|
||||
) COMMENT='订单管理';
|
||||
```
|
||||
|
||||
### SQLite 表备注
|
||||
|
||||
SQLite 不支持表备注,代码生成器会使用表名作为默认显示名称。如需自定义,可在配置文件中手动设置 `label`(详见 [CodeGen_代码生成.md](CodeGen_代码生成.md))。
|
||||
|
||||
---
|
||||
|
||||
## 字段注释规则
|
||||
|
||||
### 注释语法格式
|
||||
|
||||
字段注释支持以下格式组合:
|
||||
|
||||
```
|
||||
显示名称(前端提示):选项值 附加备注
|
||||
显示名称(前端提示):选项值 附加备注
|
||||
显示名称{前端提示}:选项值 附加备注
|
||||
```
|
||||
|
||||
| 部分 | 分隔符 | 用途 |
|
||||
|------|--------|------|
|
||||
| 显示名称 | 无 | 前端表单/列表的字段标签 |
|
||||
| 前端提示 | `()`、`()` 或 `{}` | 存储到 `ps` 字段,前端用于字段说明提示(编辑页输入框右侧、表格/详情鼠标悬停气泡) |
|
||||
| 选项值 | `:` 冒号 | select 类型的下拉选项(格式:`值-名称,值-名称`) |
|
||||
| 附加备注 | 空格 | 仅在数据库中查看,不传递给前端 |
|
||||
|
||||
### 选择类型字段
|
||||
|
||||
使用 `标签:值-名称,值-名称` 格式,代码生成器会自动解析为下拉选项:
|
||||
|
||||
```sql
|
||||
`state` int(2) DEFAULT '0' COMMENT '状态:0-正常,1-异常,2-隐藏'
|
||||
`sex` int(1) DEFAULT '0' COMMENT '性别:0-未知,1-男,2-女'
|
||||
`status` int(2) DEFAULT '0' COMMENT '审核状态:0-待审核,1-已通过,2-已拒绝'
|
||||
```
|
||||
|
||||
### 普通字段
|
||||
|
||||
直接使用中文说明:
|
||||
|
||||
```sql
|
||||
`name` varchar(50) DEFAULT NULL COMMENT '名称'
|
||||
`phone` varchar(20) DEFAULT NULL COMMENT '手机号'
|
||||
`email` varchar(100) DEFAULT NULL COMMENT '邮箱'
|
||||
```
|
||||
|
||||
### 附加备注(空格后)
|
||||
|
||||
空格后的内容**不会传递给前端**,仅供数据库设计时参考:
|
||||
|
||||
```sql
|
||||
`user_id` int(11) COMMENT '用户ID 关联user表的主键'
|
||||
`amount` decimal(10,2) COMMENT '金额 单位为元,精确到分'
|
||||
```
|
||||
|
||||
### 前端提示(括号内)
|
||||
|
||||
`()`、`()` 或 `{}` 中的内容存储到 `ps` 字段,前端展示为字段说明提示。推荐使用 `()` 或 `()`,`{}` 为兼容旧格式保留。
|
||||
|
||||
```sql
|
||||
`parent_id` int(11) COMMENT '父级ID(顶级为NULL)'
|
||||
`status` int(2) COMMENT '状态(当前审核状态):0-待审,1-通过,2-驳回'
|
||||
`phone` varchar(20) COMMENT '手机号(请输入11位手机号)'
|
||||
`email` varchar(100) COMMENT '邮箱{格式:xxx@xxx.com}'
|
||||
```
|
||||
|
||||
前端展示效果:
|
||||
- **编辑/新增页面**:输入框右侧显示灰色小字提示
|
||||
- **表格列表/详情页面**:鼠标悬停字段名时显示气泡提示
|
||||
|
||||
### 组合使用
|
||||
|
||||
```sql
|
||||
-- 推荐写法:使用圆括号
|
||||
`status` int(2) DEFAULT '0' COMMENT '状态(选择当前审核状态):0-待审核,1-已通过,2-已拒绝'
|
||||
`parent_id` int(11) DEFAULT NULL COMMENT '父级ID(顶级为NULL)'
|
||||
|
||||
-- 兼容写法:使用花括号
|
||||
`status` int(2) DEFAULT '0' COMMENT '状态{选择当前审核状态}:0-待审核,1-已通过,2-已拒绝'
|
||||
```
|
||||
|
||||
解析结果(以第一行为例):
|
||||
- `label` = "状态"
|
||||
- `ps` = "选择当前审核状态"
|
||||
- `options` = [{name:"待审核", value:"0"}, {name:"已通过", value:"1"}, {name:"已拒绝", value:"2"}]
|
||||
|
||||
三种括号 `()`、`()`、`{}` 功能相同,优先匹配顺序为:`()` > `()` > `{}`,同一注释中只取第一个匹配到的括号对。
|
||||
|
||||
### SQLite 字段备注
|
||||
|
||||
SQLite 不支持字段备注(COMMENT),代码生成器会使用字段名作为默认显示名称。如需自定义,可在配置文件中手动设置(详见 [CodeGen_代码生成.md](CodeGen_代码生成.md))。
|
||||
|
||||
---
|
||||
|
||||
## 必有字段规则
|
||||
|
||||
每张业务表必须包含以下三个字段:
|
||||
|
||||
### MySQL
|
||||
|
||||
```sql
|
||||
`state` int(2) DEFAULT '0' COMMENT '状态:0-正常,1-异常,2-隐藏',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建日期',
|
||||
`modify_time` datetime DEFAULT NULL COMMENT '变更时间',
|
||||
```
|
||||
|
||||
### SQLite
|
||||
|
||||
```sql
|
||||
"state" INTEGER DEFAULT 0, -- 状态:0-正常,1-异常,2-隐藏
|
||||
"create_time" TEXT DEFAULT NULL, -- 创建日期
|
||||
"modify_time" TEXT DEFAULT NULL, -- 变更时间
|
||||
```
|
||||
|
||||
### PostgreSQL
|
||||
|
||||
```sql
|
||||
"state" INTEGER DEFAULT 0, -- 状态:0-正常,1-异常,2-隐藏
|
||||
"create_time" TIMESTAMP DEFAULT NULL, -- 创建日期
|
||||
"modify_time" TIMESTAMP DEFAULT NULL, -- 变更时间
|
||||
```
|
||||
|
||||
### 达梦 DM8
|
||||
|
||||
```sql
|
||||
"state" INT DEFAULT 0, -- 状态:0-正常,1-异常,2-隐藏
|
||||
"create_time" TIMESTAMP DEFAULT NULL, -- 创建日期
|
||||
"modify_time" TIMESTAMP DEFAULT NULL, -- 变更时间
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完整建表示例
|
||||
|
||||
### MySQL 示例
|
||||
|
||||
```sql
|
||||
-- 用户表
|
||||
CREATE TABLE `user` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`name` varchar(50) DEFAULT NULL COMMENT '用户名',
|
||||
`phone` varchar(20) DEFAULT NULL COMMENT '手机号',
|
||||
`password` varchar(64) DEFAULT NULL COMMENT '密码',
|
||||
`org_id` int(11) DEFAULT NULL COMMENT '组织ID',
|
||||
`role_id` int(11) DEFAULT NULL COMMENT '角色ID',
|
||||
`avatar` varchar(255) DEFAULT NULL COMMENT '头像',
|
||||
`sex` int(1) DEFAULT '0' COMMENT '性别:0-未知,1-男,2-女',
|
||||
`state` int(2) DEFAULT '0' COMMENT '状态:0-正常,1-异常,2-隐藏',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建日期',
|
||||
`modify_time` datetime DEFAULT NULL COMMENT '变更时间',
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
|
||||
|
||||
-- 组织表(树形结构)
|
||||
CREATE TABLE `org` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`name` varchar(100) DEFAULT NULL COMMENT '组织名称',
|
||||
`parent_id` int(11) DEFAULT NULL COMMENT '父级ID',
|
||||
`parent_ids` varchar(255) DEFAULT NULL COMMENT '层级路径',
|
||||
`level` int(2) DEFAULT '0' COMMENT '层级深度',
|
||||
`sort` int(5) DEFAULT '0' COMMENT '排序',
|
||||
`state` int(2) DEFAULT '0' COMMENT '状态:0-正常,1-异常,2-隐藏',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建日期',
|
||||
`modify_time` datetime DEFAULT NULL COMMENT '变更时间',
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='组织表';
|
||||
|
||||
-- 用户组织关联表
|
||||
CREATE TABLE `user_org` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`user_id` int(11) DEFAULT NULL COMMENT '用户ID',
|
||||
`org_id` int(11) DEFAULT NULL COMMENT '组织ID',
|
||||
`state` int(2) DEFAULT '0' COMMENT '状态:0-正常,1-异常,2-隐藏',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建日期',
|
||||
`modify_time` datetime DEFAULT NULL COMMENT '变更时间',
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户组织关联表';
|
||||
```
|
||||
|
||||
### SQLite 示例
|
||||
|
||||
```sql
|
||||
-- 用户表
|
||||
CREATE TABLE "user" (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"name" TEXT DEFAULT NULL,
|
||||
"phone" TEXT DEFAULT NULL,
|
||||
"password" TEXT DEFAULT NULL,
|
||||
"org_id" INTEGER DEFAULT NULL,
|
||||
"role_id" INTEGER DEFAULT NULL,
|
||||
"avatar" TEXT DEFAULT NULL,
|
||||
"sex" INTEGER DEFAULT 0,
|
||||
"state" INTEGER DEFAULT 0,
|
||||
"create_time" TEXT DEFAULT NULL,
|
||||
"modify_time" TEXT DEFAULT NULL
|
||||
);
|
||||
```
|
||||
|
||||
### 达梦 DM8 示例
|
||||
|
||||
达梦建表有以下关键差异:
|
||||
- 自增主键使用 `IDENTITY(1,1)`(对应 MySQL 的 `AUTO_INCREMENT`)
|
||||
- 长文本使用 `CLOB`(对应 MySQL 的 `LONGTEXT`)
|
||||
- 标识符建议用**双引号包裹小写名称**,与框架保持一致
|
||||
- `admin`、`user`、`order` 等是 DM 保留字,必须加双引号
|
||||
|
||||
```sql
|
||||
-- 用户表
|
||||
CREATE TABLE "user" (
|
||||
"id" INT IDENTITY(1,1) PRIMARY KEY,
|
||||
"name" VARCHAR(50) DEFAULT NULL,
|
||||
"phone" VARCHAR(20) DEFAULT NULL,
|
||||
"password" VARCHAR(64) DEFAULT NULL,
|
||||
"org_id" INT DEFAULT NULL,
|
||||
"role_id" INT DEFAULT NULL,
|
||||
"avatar" VARCHAR(255) DEFAULT NULL,
|
||||
"sex" INT DEFAULT 0,
|
||||
"state" INT DEFAULT 0,
|
||||
"create_time" TIMESTAMP DEFAULT NULL,
|
||||
"modify_time" TIMESTAMP DEFAULT NULL
|
||||
);
|
||||
|
||||
-- 组织表(树形结构)
|
||||
CREATE TABLE "org" (
|
||||
"id" INT IDENTITY(1,1) PRIMARY KEY,
|
||||
"name" VARCHAR(100) DEFAULT NULL,
|
||||
"parent_id" INT DEFAULT NULL,
|
||||
"parent_ids" VARCHAR(255) DEFAULT NULL,
|
||||
"level" INT DEFAULT 0,
|
||||
"sort" INT DEFAULT 0,
|
||||
"state" INT DEFAULT 0,
|
||||
"create_time" TIMESTAMP DEFAULT NULL,
|
||||
"modify_time" TIMESTAMP DEFAULT NULL
|
||||
);
|
||||
|
||||
-- 用户组织关联表
|
||||
CREATE TABLE "user_org" (
|
||||
"id" INT IDENTITY(1,1) PRIMARY KEY,
|
||||
"user_id" INT DEFAULT NULL,
|
||||
"org_id" INT DEFAULT NULL,
|
||||
"state" INT DEFAULT 0,
|
||||
"create_time" TIMESTAMP DEFAULT NULL,
|
||||
"modify_time" TIMESTAMP DEFAULT NULL
|
||||
);
|
||||
```
|
||||
|
||||
> **注意**:达梦中 DDL 的表名/列名大小写敏感(双引号包裹时),建议统一使用小写,与框架自动生成的 SQL 保持一致。
|
||||
|
||||
---
|
||||
|
||||
## 规范检查清单
|
||||
|
||||
在设计数据库时,请确认:
|
||||
|
||||
- [ ] 表名无系统前缀
|
||||
- [ ] 主键统一为 `id`
|
||||
- [ ] 外键格式为 `表名_id`
|
||||
- [ ] 所有 `xxx_id` 字段都指向实际存在的表
|
||||
- [ ] 非外键业务标识不使用 `xxx_id` 格式
|
||||
- [ ] 树形表包含 parent_id、parent_ids、level
|
||||
- [ ] 所有表包含 state、create_time、modify_time
|
||||
- [ ] 选择字段注释格式正确(`标签:值-名称`)
|
||||
|
||||
### 达梦 DM8 额外检查项
|
||||
|
||||
- [ ] 建表时标识符使用**双引号包裹小写名称**(如 `"user"`、`"id"`)
|
||||
- [ ] 保留字(`admin`、`user`、`order`、`key` 等)必须加双引号
|
||||
- [ ] 自增主键使用 `IDENTITY(1,1)` 而非 `AUTO_INCREMENT`
|
||||
- [ ] 长文本字段使用 `CLOB` 而非 `LONGTEXT`
|
||||
- [ ] 检测表是否存在时用 `COUNT(*)` 查询,不用 `USER_TABLES`(存在 schema 错配问题)
|
||||
|
||||
---
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [CodeGen_代码生成.md](CodeGen_代码生成.md) — 按本规范建表后运行代码生成
|
||||
- [HoTimeDB_使用说明.md](HoTimeDB_使用说明.md) — 运行时 ORM(与建表规范分开)
|
||||
- [文档索引](README.md)
|
||||
@@ -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()`。
|
||||
@@ -0,0 +1,214 @@
|
||||
# HoTimeDB API 快速参考
|
||||
|
||||
完整教程见 [HoTimeDB 使用说明](HoTimeDB_使用说明.md)。本文仅收录条件运算符与方法签名速查。
|
||||
|
||||
---
|
||||
|
||||
## 条件运算符
|
||||
|
||||
多条件默认 AND;关键字大小写均可(`ORDER`/`order`)。
|
||||
|
||||
| 写法 | SQL | 说明 |
|
||||
|------|-----|------|
|
||||
| `"field": value` | `field = ?` | 等于 |
|
||||
| `"field[!]": value` | `field != ?` | 不等于 |
|
||||
| `"field[>]": value` | `field > ?` | 大于 |
|
||||
| `"field[>=]": value` | `field >= ?` | 大于等于 |
|
||||
| `"field[<]": value` | `field < ?` | 小于 |
|
||||
| `"field[<=]": value` | `field <= ?` | 小于等于 |
|
||||
| `"field[~]": "kw"` | `LIKE '%kw%'` | 包含 |
|
||||
| `"field[~!]": "kw"` | `LIKE 'kw%'` | 开头 |
|
||||
| `"field[!~]": "kw"` | `LIKE '%kw'` | 结尾 |
|
||||
| `"field[~~]": "%kw%"` | `LIKE '%kw%'` | 手动 LIKE |
|
||||
| `"field[<>]": [min,max]` | `BETWEEN ? AND ?` | 区间内 |
|
||||
| `"field[><]": [min,max]` | `NOT BETWEEN` | 区间外 |
|
||||
| `"field": [v1,v2]` | `IN (?,?)` | 集合 |
|
||||
| `"field[!]": [v1,v2]` | `NOT IN` | 非集合 |
|
||||
| `"field": nil` | `IS NULL` | 空 |
|
||||
| `"field[!]": nil` | `IS NOT NULL` | 非空 |
|
||||
| `"field[#]": "balance+1"` | `field = balance+1` | 直接表达式 |
|
||||
| `"[##]": "a > b"` | `a > b` | 直接 SQL 片段 |
|
||||
| `"field[#!]": "1"` | `field != 1` | 非参数化不等 |
|
||||
|
||||
时间字段推荐 Go 侧传值:`"create_time": Time2Str(time.Now())`(勿用 `NOW()`)。
|
||||
|
||||
### 逻辑与特殊关键字
|
||||
|
||||
```go
|
||||
Map{"status": 1, "age[>]": 18} // 自动 AND
|
||||
Map{"AND": Map{...}} / Map{"OR": Map{...}} // 显式逻辑
|
||||
Map{"AND": Map{"status": 1, "OR": Map{...}}} // 嵌套
|
||||
Map{"ORDER": "id DESC"} / []string{"a DESC","b"} // ORDER BY
|
||||
Map{"GROUP": "dept"} / []string{"a","b"} // GROUP BY
|
||||
Map{"HAVING": Map{"COUNT(*).[>]": 5}} // HAVING
|
||||
Map{"LIMIT": 20} / []int{10, 20} // LIMIT / offset+limit
|
||||
Map{"OFFSET": 20} // 独立 OFFSET
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 方法签名
|
||||
|
||||
### 连接与元信息
|
||||
|
||||
| 方法 | 签名 | 说明 |
|
||||
|------|------|------|
|
||||
| SetConnect | `SetConnect(func() (master, slave *sql.DB))` | 设置连接并 InitDb |
|
||||
| InitDb | `InitDb()` | 初始化连接与方言 |
|
||||
| GetPrefix | `GetPrefix() string` | 表前缀 |
|
||||
| GetType | `GetType() string` | 库类型 |
|
||||
| GetDialect | `GetDialect() Dialect` | 方言适配器 |
|
||||
| GetLast | `GetLast() *DBError` | 最近一次 SQL 快照 |
|
||||
| GetLastError | `GetLastError() error` | 最近错误 |
|
||||
| GetLastQuery | `GetLastQuery() string` | 最近 SQL |
|
||||
| GetLastData | `GetLastData() []interface{}` | 最近参数 |
|
||||
|
||||
`Type`:`mysql` / `sqlite3` / `postgres` / `dm`(或 `dameng`)。
|
||||
|
||||
### CRUD
|
||||
|
||||
| 方法 | 签名 | 返回 |
|
||||
|------|------|------|
|
||||
| Select | `Select(table string, qu ...interface{})` | `[]Map` |
|
||||
| Get | `Get(table string, qu ...interface{})` | `Map`(自动 LIMIT 1) |
|
||||
| Insert | `Insert(table string, data map[string]interface{})` | `int64` 新 ID |
|
||||
| Inserts | `Inserts(table string, dataList []Map)` | `int64` 影响行数 |
|
||||
| Update | `Update(table string, data Map, where Map)` | `int64` |
|
||||
| Upsert | `Upsert(table string, data Map, uniqueKeys Slice, updateColumns ...interface{})` | `int64` |
|
||||
| Delete | `Delete(table string, data map[string]interface{})` | `int64` |
|
||||
| Page | `Page(page, pageRow int) *HoTimeDB` | 链式分页 |
|
||||
| PageSelect | `PageSelect(table string, qu ...interface{})` | `[]Map` |
|
||||
|
||||
`Select`/`Get`/`PageSelect` 的 `qu` 常见形态:字段;字段+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()
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,576 @@
|
||||
# HoTime 快速上手指南
|
||||
|
||||
5 分钟入门 HoTime 框架。完整文档索引见 [README.md](README.md)。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
go get code.hoteas.com/golang/hotime
|
||||
```
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
func main() {
|
||||
appIns := Init("config/config.json")
|
||||
|
||||
appIns.Run(Router{
|
||||
"app": {
|
||||
"test": {
|
||||
"hello": func(that *Context) {
|
||||
that.Display(0, Map{"message": "Hello World"})
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
访问: `http://localhost:8081/app/test/hello`
|
||||
|
||||
## 配置文件
|
||||
|
||||
创建 `config/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"port": "8081",
|
||||
"mode": 2,
|
||||
"sessionName": "HOTIME",
|
||||
"tpt": "tpt",
|
||||
"defFile": ["index.html", "index.htm"],
|
||||
"db": {
|
||||
"mysql": {
|
||||
"host": "localhost",
|
||||
"port": "3306",
|
||||
"name": "your_database",
|
||||
"user": "root",
|
||||
"password": "your_password",
|
||||
"prefix": ""
|
||||
}
|
||||
},
|
||||
"cache": {
|
||||
"memory": {
|
||||
"db": true,
|
||||
"session": true,
|
||||
"timeout": 7200
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 配置项说明
|
||||
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
|--------|--------|------|
|
||||
| `port` | 80 | HTTP 服务端口,0 为不启用 |
|
||||
| `tlsPort` | - | HTTPS 端口,需配合 tlsCert/tlsKey |
|
||||
| `tlsCert` | - | HTTPS 证书路径 |
|
||||
| `tlsKey` | - | HTTPS 密钥路径 |
|
||||
| `mode` | 0 | 0=生产, 1=测试, 2=开发(输出SQL) |
|
||||
| `tpt` | tpt | 静态文件目录 |
|
||||
| `sessionName` | HOTIME | Session Cookie 名称 |
|
||||
| `modeRouterStrict` | false | 路由大小写敏感,false=忽略大小写 |
|
||||
| `crossDomain` | - | 跨域设置,空=不开启,auto=智能开启,或指定域名 |
|
||||
| `logFile` | - | 日志文件路径,如 `logs/20060102.txt` |
|
||||
| `logLevel` | 0 | 日志等级,0=关闭,1=打印 |
|
||||
| `webConnectLogShow` | true | 是否显示访问日志 |
|
||||
| `defFile` | ["index.html"] | 目录默认访问文件 |
|
||||
|
||||
### 数据库配置
|
||||
|
||||
支持的数据库类型:`mysql`、`sqlite`、`postgres`(PostgreSQL)、`dm`(达梦 DM8)。
|
||||
|
||||
```json
|
||||
{
|
||||
"db": {
|
||||
"mysql": {
|
||||
"host": "127.0.0.1",
|
||||
"port": "3306",
|
||||
"name": "database_name",
|
||||
"user": "root",
|
||||
"password": "password",
|
||||
"prefix": "app_",
|
||||
"slave": {
|
||||
"host": "127.0.0.1",
|
||||
"port": "3306",
|
||||
"name": "database_name",
|
||||
"user": "root",
|
||||
"password": "password"
|
||||
}
|
||||
},
|
||||
"sqlite": {
|
||||
"path": "config/data.db",
|
||||
"prefix": ""
|
||||
},
|
||||
"dm": {
|
||||
"host": "127.0.0.1",
|
||||
"port": "5236",
|
||||
"name": "TEST",
|
||||
"user": "SYSDBA",
|
||||
"password": "your_password",
|
||||
"prefix": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> MySQL 配置 `slave` 项即启用主从读写分离
|
||||
> 达梦 DM8 的 `name` 字段对应 schema(搜索路径),驱动已内置于 `vendor/gitee.com/chunanyong/dm`,无需额外安装
|
||||
|
||||
### 缓存配置
|
||||
|
||||
```json
|
||||
{
|
||||
"cache": {
|
||||
"memory": {
|
||||
"db": true,
|
||||
"session": true,
|
||||
"timeout": 7200
|
||||
},
|
||||
"redis": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 6379,
|
||||
"password": "",
|
||||
"db": true,
|
||||
"session": true,
|
||||
"timeout": 1296000
|
||||
},
|
||||
"db": {
|
||||
"db": true,
|
||||
"session": true,
|
||||
"timeout": 2592000,
|
||||
"history": false,
|
||||
"mode": "compatible"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
缓存优先级: **Memory > Redis > DB**,自动穿透与回填
|
||||
|
||||
#### DB 缓存配置说明
|
||||
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
|--------|--------|------|
|
||||
| `db` | false | 是否缓存数据库查询 |
|
||||
| `session` | true | 是否缓存 Session |
|
||||
| `timeout` | 2592000 | 过期时间(秒) |
|
||||
| `history` | false | 是否记录缓存历史,开启后每次新增/修改缓存都会记录到历史表 |
|
||||
| `mode` | compatible | 缓存表模式,见下表 |
|
||||
|
||||
**缓存表模式 (mode)**:
|
||||
|
||||
| 模式 | 说明 |
|
||||
|------|------|
|
||||
| `compatible` | **默认**。兼容模式:写新表,新表无数据时回退读老表;写入时自动删除老表同 key 记录;删除时同时删两表。适合从老版本平滑升级,老数据自然过期消亡 |
|
||||
| `new` | 只使用新表 `hotime_cache`,启动时自动迁移老表 `cached` 数据。老表保留由人工删除,不再被读写 |
|
||||
|
||||
> **升级建议**:从老版本升级时,建议先使用 `compatible` 模式运行一段时间(让老数据自然过期),确认无问题后再切换到 `new` 模式
|
||||
|
||||
> 从老版本升级时,建议使用 `compatible` 模式平滑过渡,待老表数据消亡后切换到 `new` 模式
|
||||
|
||||
### 错误码配置
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"1": "内部系统异常",
|
||||
"2": "访问权限异常",
|
||||
"3": "请求参数异常",
|
||||
"4": "数据处理异常",
|
||||
"5": "数据结果异常"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 自定义错误码建议从 10 开始
|
||||
|
||||
## 路由系统
|
||||
|
||||
HoTime 使用三层路由结构:`模块/控制器/方法`
|
||||
|
||||
```go
|
||||
appIns.Run(Router{
|
||||
"模块名": {
|
||||
"控制器名": {
|
||||
"方法名": func(that *Context) {
|
||||
// 处理逻辑
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### 路由路径
|
||||
|
||||
```go
|
||||
// 获取路由信息
|
||||
module := that.RouterString[0] // 模块
|
||||
controller := that.RouterString[1] // 控制器
|
||||
action := that.RouterString[2] // 方法
|
||||
|
||||
// 完整请求路径
|
||||
fullPath := that.HandlerStr // 如 /app/user/login
|
||||
```
|
||||
|
||||
## 请求参数获取
|
||||
|
||||
### 新版推荐方法(支持链式调用)
|
||||
|
||||
```go
|
||||
// 获取 URL 查询参数 (?id=1)
|
||||
id := that.ReqParam("id").ToInt()
|
||||
name := that.ReqParam("name").ToStr()
|
||||
|
||||
// 获取表单参数 (POST form-data / x-www-form-urlencoded)
|
||||
username := that.ReqForm("username").ToStr()
|
||||
age := that.ReqForm("age").ToInt()
|
||||
|
||||
// 获取 JSON Body 参数 (POST application/json)
|
||||
data := that.ReqJson("data").ToMap()
|
||||
items := that.ReqJson("items").ToSlice()
|
||||
|
||||
// 统一获取(自动判断来源,优先级: JSON > Form > URL)
|
||||
userId := that.ReqData("user_id").ToInt()
|
||||
status := that.ReqData("status").ToStr()
|
||||
```
|
||||
|
||||
### 类型转换方法
|
||||
|
||||
```go
|
||||
obj := that.ReqData("key")
|
||||
|
||||
obj.ToStr() // 转字符串
|
||||
obj.ToInt() // 转 int
|
||||
obj.ToInt64() // 转 int64
|
||||
obj.ToFloat64() // 转 float64
|
||||
obj.ToBool() // 转 bool
|
||||
obj.ToMap() // 转 Map
|
||||
obj.ToSlice() // 转 Slice
|
||||
obj.Data // 获取原始值(interface{})
|
||||
```
|
||||
|
||||
### 文件上传
|
||||
|
||||
```go
|
||||
// 单文件上传
|
||||
file, header, err := that.ReqFile("avatar")
|
||||
if err == nil {
|
||||
defer file.Close()
|
||||
// header.Filename - 文件名
|
||||
// header.Size - 文件大小
|
||||
}
|
||||
|
||||
// 多文件上传(批量)
|
||||
files, err := that.ReqFiles("images")
|
||||
if err == nil {
|
||||
for _, fh := range files {
|
||||
file, _ := fh.Open()
|
||||
defer file.Close()
|
||||
// 处理每个文件
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 传统方法(兼容)
|
||||
|
||||
```go
|
||||
// GET/POST 参数
|
||||
name := that.Req.FormValue("name")
|
||||
|
||||
// URL 参数
|
||||
id := that.Req.URL.Query().Get("id")
|
||||
|
||||
// 请求头
|
||||
token := that.Req.Header.Get("Authorization")
|
||||
```
|
||||
|
||||
## 响应数据
|
||||
|
||||
### Display 方法
|
||||
|
||||
```go
|
||||
// 成功响应 (status=0)
|
||||
that.Display(0, Map{"user": user, "token": token})
|
||||
// 输出: {"status":0, "result":{"user":..., "token":...}}
|
||||
|
||||
// 错误响应 (status>0)
|
||||
that.Display(1, "系统内部错误")
|
||||
// 输出: {"status":1, "result":{"type":"内部系统异常", "msg":"系统内部错误"}, "error":{...}}
|
||||
|
||||
that.Display(2, "请先登录")
|
||||
// 输出: {"status":2, "result":{"type":"访问权限异常", "msg":"请先登录"}, "error":{...}}
|
||||
|
||||
that.Display(3, "参数不能为空")
|
||||
// 输出: {"status":3, "result":{"type":"请求参数异常", "msg":"参数不能为空"}, "error":{...}}
|
||||
```
|
||||
|
||||
### 错误码含义
|
||||
|
||||
| 错误码 | 类型 | 使用场景 |
|
||||
|--------|------|----------|
|
||||
| 0 | 成功 | 请求成功 |
|
||||
| 1 | 内部系统异常 | 环境配置、文件权限等基础运行环境错误 |
|
||||
| 2 | 访问权限异常 | 未登录或登录异常 |
|
||||
| 3 | 请求参数异常 | 参数不足、类型错误等 |
|
||||
| 4 | 数据处理异常 | 数据库操作或第三方请求返回异常 |
|
||||
| 5 | 数据结果异常 | 无法返回要求的格式 |
|
||||
|
||||
### 自定义响应
|
||||
|
||||
```go
|
||||
// 自定义 Header
|
||||
that.Resp.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// 直接写入
|
||||
that.Resp.Write([]byte("raw data"))
|
||||
|
||||
// 自定义响应函数
|
||||
that.RespFunc = func() {
|
||||
// 自定义响应逻辑
|
||||
}
|
||||
```
|
||||
|
||||
## 中间件
|
||||
|
||||
```go
|
||||
// 全局中间件(请求拦截)
|
||||
appIns.SetConnectListener(func(that *Context) bool {
|
||||
// 放行登录接口
|
||||
if len(that.RouterString) >= 3 && that.RouterString[2] == "login" {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查登录状态
|
||||
if that.Session("user_id").Data == nil {
|
||||
that.Display(2, "请先登录")
|
||||
return true // 返回 true 终止请求
|
||||
}
|
||||
return false // 返回 false 继续处理
|
||||
})
|
||||
```
|
||||
|
||||
## Session 与缓存
|
||||
|
||||
```go
|
||||
// Session 操作
|
||||
that.Session("user_id", 123) // 设置
|
||||
userId := that.Session("user_id") // 获取 *Obj
|
||||
that.Session("user_id", nil) // 删除
|
||||
|
||||
// 链式获取
|
||||
id := that.Session("user_id").ToInt64()
|
||||
name := that.Session("username").ToStr()
|
||||
|
||||
// 通用缓存
|
||||
that.Cache("key", "value") // 设置
|
||||
data := that.Cache("key") // 获取
|
||||
that.Cache("key", nil) // 删除
|
||||
```
|
||||
|
||||
### 批量操作(性能优化)
|
||||
|
||||
当需要同时操作多个 Session 字段时,使用批量操作可显著提升性能:
|
||||
|
||||
```go
|
||||
// SessionsSet - 批量设置(N个字段只触发1次数据库写入)
|
||||
that.SessionsSet(Map{
|
||||
"user_id": userId,
|
||||
"username": "张三",
|
||||
"login_time": time.Now().Unix(),
|
||||
"role": "admin",
|
||||
})
|
||||
|
||||
// SessionsGet - 批量获取(1次调用获取多个字段)
|
||||
result := that.SessionsGet("user_id", "username", "role")
|
||||
// result = Map{"user_id": 123, "username": "张三", "role": "admin"}
|
||||
|
||||
userId := ObjToInt64(result["user_id"], nil)
|
||||
username := ObjToStr(result["username"])
|
||||
|
||||
// SessionsDelete - 批量删除(N个字段只触发1次数据库写入)
|
||||
that.SessionsDelete("token", "temp_code", "verify_expire")
|
||||
```
|
||||
|
||||
**性能对比**:
|
||||
|
||||
| 操作方式 | 设置10个字段 | 数据库写入次数 |
|
||||
|----------|-------------|---------------|
|
||||
| 逐个调用 `Session()` | 10次调用 | **10次** |
|
||||
| 使用 `SessionsSet()` | 1次调用 | **1次** |
|
||||
|
||||
| 操作方式 | 获取10个字段 | 缓存查询次数 |
|
||||
|----------|-------------|--------------|
|
||||
| 逐个调用 `Session()` | 10次调用 | **10次** |
|
||||
| 使用 `SessionsGet()` | 1次调用 | **1次** |
|
||||
|
||||
> 💡 **最佳实践**:当一次性操作 3 个以上字段时,建议使用批量操作
|
||||
|
||||
三级缓存自动运作:**Memory → Redis → Database**
|
||||
|
||||
## 数据库操作(简要)
|
||||
|
||||
```go
|
||||
users := that.Db.Select("user", "*", Map{"status": 1})
|
||||
user := that.Db.Get("user", "*", Map{"id": 1})
|
||||
id := that.Db.Insert("user", Map{"name": "test", "age": 18})
|
||||
that.Db.Update("user", Map{"name": "new"}, Map{"id": 1})
|
||||
that.Db.Delete("user", Map{"id": 1})
|
||||
|
||||
users = that.Db.Table("user").
|
||||
Where("status", 1).And("age[>]", 18).
|
||||
Order("id DESC").Page(1, 10).Select("*")
|
||||
```
|
||||
|
||||
条件运算符(`[>]` / `[~]` / IN 等)、事务、缓存与方言差异见:
|
||||
|
||||
- 教程:[HoTimeDB 使用说明](HoTimeDB_使用说明.md)
|
||||
- 速查:[HoTimeDB API 参考](HoTimeDB_API参考.md)
|
||||
|
||||
## 日志:业务 Log 表 vs Seq
|
||||
|
||||
| 能力 | 用途 | 文档 |
|
||||
|------|------|------|
|
||||
| `that.Log = Map{...}` | 写入业务 `logs` 表(操作审计) | 见下方示例 |
|
||||
| `seqUrl` + zerolog | 结构化日志推送到 Seq 集中搜索 | [Seq 日志集成](Seq_日志集成.md) |
|
||||
|
||||
```go
|
||||
// 业务操作日志(自动插入 logs 表;框架会补 time、admin_id/user_id、ip 等)
|
||||
that.Log = Map{
|
||||
"type": "login",
|
||||
"action": "用户登录",
|
||||
"data": Map{"phone": phone},
|
||||
}
|
||||
```
|
||||
|
||||
## 扩展功能
|
||||
|
||||
| 功能 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| 微信支付/公众号/小程序 | `dri/wechat/` | 微信全套 SDK |
|
||||
| 阿里云服务 | `dri/aliyun/` | 企业认证等 |
|
||||
| 腾讯云服务 | `dri/tencent/` | 企业认证等 |
|
||||
| 文件上传 | `dri/upload/` | 文件上传处理 |
|
||||
| 文件下载 | `dri/download/` | 文件下载处理 |
|
||||
| MongoDB | `dri/mongodb/` | MongoDB 驱动 |
|
||||
| RSA 加解密 | `dri/rsa/` | RSA 加解密工具 |
|
||||
|
||||
## 完整示例
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
func main() {
|
||||
appIns := Init("config/config.json")
|
||||
|
||||
// 登录检查中间件
|
||||
appIns.SetConnectListener(func(that *Context) bool {
|
||||
// 放行登录接口
|
||||
if len(that.RouterString) >= 3 && that.RouterString[2] == "login" {
|
||||
return false
|
||||
}
|
||||
if that.Session("user_id").Data == nil {
|
||||
that.Display(2, "请先登录")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
appIns.Run(Router{
|
||||
"api": {
|
||||
"user": {
|
||||
"login": func(that *Context) {
|
||||
phone := that.ReqData("phone").ToStr()
|
||||
password := that.ReqData("password").ToStr()
|
||||
|
||||
if phone == "" || password == "" {
|
||||
that.Display(3, "手机号和密码不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
user := that.Db.Get("user", "*", Map{
|
||||
"phone": phone,
|
||||
"password": Md5(password),
|
||||
})
|
||||
|
||||
if user == nil {
|
||||
that.Display(3, "账号或密码错误")
|
||||
return
|
||||
}
|
||||
|
||||
// 使用批量设置,一次写入多个字段
|
||||
that.SessionsSet(Map{
|
||||
"user_id": user.GetInt64("id"),
|
||||
"username": user.GetString("name"),
|
||||
"login_time": time.Now().Unix(),
|
||||
})
|
||||
that.Display(0, Map{"user": user})
|
||||
},
|
||||
|
||||
"info": func(that *Context) {
|
||||
// 使用批量获取,一次读取多个字段
|
||||
sess := that.SessionsGet("user_id", "username", "login_time")
|
||||
userId := ObjToInt64(sess["user_id"], nil)
|
||||
|
||||
user := that.Db.Get("user", "*", Map{"id": userId})
|
||||
that.Display(0, Map{
|
||||
"user": user,
|
||||
"login_time": sess["login_time"],
|
||||
})
|
||||
},
|
||||
|
||||
"list": func(that *Context) {
|
||||
page := that.ReqData("page").ToInt()
|
||||
if page == 0 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
users := that.Db.Table("user").
|
||||
Where("status", 1).
|
||||
Order("id DESC").
|
||||
Page(page, 10).
|
||||
Select("id,name,phone,created_at")
|
||||
|
||||
total := that.Db.Count("user", Map{"status": 1})
|
||||
|
||||
that.Display(0, Map{
|
||||
"list": users,
|
||||
"total": total,
|
||||
"page": page,
|
||||
})
|
||||
},
|
||||
|
||||
"logout": func(that *Context) {
|
||||
// 使用批量删除,一次清除多个字段
|
||||
that.SessionsDelete("user_id", "username", "login_time")
|
||||
that.Display(0, "退出成功")
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**下一步**:
|
||||
- [HoTimeDB 使用说明](HoTimeDB_使用说明.md) — ORM 教程
|
||||
- [HoTimeDB API 参考](HoTimeDB_API参考.md) — API 速查
|
||||
- [Common 工具类](Common_工具类.md) — Map/Slice/Obj
|
||||
- [API 测试框架](Testing_API测试框架.md) — 接口测试(优先 `-run` 单测)
|
||||
- [文档索引](README.md) — 完整阅读路径
|
||||
@@ -0,0 +1,34 @@
|
||||
# HoTime 文档索引
|
||||
|
||||
推荐按路径阅读,避免同一概念在多份文档里来回找。
|
||||
|
||||
## 阅读路径
|
||||
|
||||
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 测试;优先 `-run`,全量沉底 |
|
||||
| GracefulShutdown_优雅停机 | 优雅停机与滚动重启 |
|
||||
| Seq_日志集成 | Seq 结构化日志 |
|
||||
| ROADMAP_改进规划 | 待改进项 |
|
||||
|
||||
## 易混淆点
|
||||
|
||||
- **业务 `that.Log`**:写 `logs` 表;**Seq**:集中式结构化日志,见 Seq 文档。
|
||||
- **HoTimeDB vs DatabaseDesign**:前者是运行时查库;后者是建表给代码生成用。
|
||||
- **测试**:开发阶段不要默认 `go test ./app/...` 全量,见 Testing 文首铁律。
|
||||
@@ -0,0 +1,281 @@
|
||||
# HoTime 改进规划
|
||||
|
||||
> 相关规范:[DatabaseDesign](DatabaseDesign_数据库设计规范.md) · [CodeGen](CodeGen_代码生成.md) · [Testing](Testing_API测试框架.md) · [文档索引](README.md)
|
||||
|
||||
本文档记录 HoTime 框架的待改进项和设计思考,供后续版本迭代参考。
|
||||
|
||||
---
|
||||
|
||||
## 一、备注语法优化
|
||||
|
||||
### 现状
|
||||
|
||||
当前字段备注语法使用空格、冒号、大括号组合:
|
||||
|
||||
```sql
|
||||
`status` int COMMENT '状态:0-正常,1-异常 这是数据库备注{这是前端提示}'
|
||||
```
|
||||
|
||||
### 问题
|
||||
|
||||
- 多种分隔符混用,不够直观
|
||||
- 显示名称如需包含特殊字符可能冲突
|
||||
|
||||
### 改进方向
|
||||
|
||||
使用 `|` 作为统一分隔符,保持干净清爽:
|
||||
|
||||
```sql
|
||||
-- 方案 A:简洁直观
|
||||
`status` int COMMENT '状态|0-正常,1-异常|请选择状态'
|
||||
-- 解析:显示名称|选项|提示
|
||||
|
||||
-- 方案 B:保持兼容,空格后内容仍作为数据库备注
|
||||
`status` int COMMENT '状态|0-正常,1-异常|请选择状态 这是数据库备注'
|
||||
```
|
||||
|
||||
### 待确认
|
||||
|
||||
- [ ] 是否需要保留空格后的数据库备注功能
|
||||
- [ ] 如果只有提示没有选项,如何表示(如 `名称||请输入名称`)
|
||||
- [ ] 是否向后兼容旧语法
|
||||
|
||||
---
|
||||
|
||||
## 二、SQLite 备注支持
|
||||
|
||||
### 现状
|
||||
|
||||
SQLite 不支持表/字段备注,需要手动在配置文件中设置。
|
||||
|
||||
### 问题
|
||||
|
||||
SQLite 项目配置工作量大,不够"快速开发"。
|
||||
|
||||
### 待探索方案
|
||||
|
||||
1. **配置文件方案(当前)**:通过 admin.json 手动配置,学习成本可接受
|
||||
2. **轻量级方案**:考虑是否有更简单的替代方案,但不增加复杂度
|
||||
|
||||
### 暂时结论
|
||||
|
||||
当前方案虽不完美,但符合"简单好用"原则,暂不改动。
|
||||
|
||||
---
|
||||
|
||||
## 三、软删除支持
|
||||
|
||||
### 现状
|
||||
|
||||
框架暂不支持软删除。
|
||||
|
||||
### 设计考量
|
||||
|
||||
软删除存在以下问题:
|
||||
- 数据安全性:软删除的数据仍可被恢复或误用
|
||||
- 查询复杂度:所有查询需要额外过滤条件
|
||||
- 存储膨胀:删除的数据持续占用空间
|
||||
|
||||
### 待探索方案
|
||||
|
||||
1. **可选软删除**:通过配置决定某张表是否启用软删除
|
||||
2. **日志表方案**:独立的操作日志表,只写不删不改
|
||||
- 记录所有增删改操作
|
||||
- 原表正常物理删除
|
||||
- 需要时可从日志恢复
|
||||
3. **归档表方案**:删除时移动到归档表
|
||||
|
||||
### 倾向方案
|
||||
|
||||
日志表方案更符合数据安全和审计需求:
|
||||
|
||||
```sql
|
||||
CREATE TABLE `_operation_log` (
|
||||
`id` int AUTO_INCREMENT,
|
||||
`table_name` varchar(100) COMMENT '操作表名',
|
||||
`record_id` int COMMENT '记录ID',
|
||||
`operation` varchar(20) COMMENT '操作类型:insert,update,delete',
|
||||
`old_data` text COMMENT '操作前数据(JSON)',
|
||||
`new_data` text COMMENT '操作后数据(JSON)',
|
||||
`operator_id` int COMMENT '操作人ID',
|
||||
`operator_table` varchar(100) COMMENT '操作人表名',
|
||||
`create_time` datetime COMMENT '操作时间',
|
||||
PRIMARY KEY (`id`)
|
||||
) COMMENT='操作日志';
|
||||
```
|
||||
|
||||
### 待确认
|
||||
|
||||
- [ ] 是否所有表都记录日志
|
||||
- [ ] 日志保留策略(永久/定期归档)
|
||||
- [ ] 是否提供恢复接口
|
||||
|
||||
---
|
||||
|
||||
## 四、多对多关联表增强
|
||||
|
||||
### 现状
|
||||
|
||||
关联表(如 `user_role`)按普通表处理,生成标准 CRUD。
|
||||
|
||||
### 可能的增强
|
||||
|
||||
1. **自动识别**:只有两个 `_id` 外键的表识别为关联表
|
||||
2. **专用接口**:生成关联管理接口(批量绑定/解绑)
|
||||
3. **级联查询**:自动生成带关联数据的查询
|
||||
|
||||
### 待确认
|
||||
|
||||
- [ ] 是否需要此功能
|
||||
- [ ] 如何保持简单性
|
||||
|
||||
---
|
||||
|
||||
## 五、自动填充字段扩展
|
||||
|
||||
### 现状
|
||||
|
||||
自动填充:
|
||||
- `create_time`:新增时自动填充当前时间
|
||||
- `modify_time`:新增/编辑时自动填充当前时间
|
||||
|
||||
### 可能的扩展
|
||||
|
||||
| 字段 | 填充时机 | 填充内容 |
|
||||
|------|----------|----------|
|
||||
| create_by | 新增 | 当前用户ID |
|
||||
| modify_by | 新增/编辑 | 当前用户ID |
|
||||
| create_ip | 新增 | 客户端IP |
|
||||
|
||||
### 待确认
|
||||
|
||||
- [ ] 是否需要扩展
|
||||
- [ ] 字段命名规范
|
||||
|
||||
---
|
||||
|
||||
## 六、版本控制/乐观锁
|
||||
|
||||
### 现状
|
||||
|
||||
`version` 字段规则存在但未启用。
|
||||
|
||||
### 待确认
|
||||
|
||||
- [ ] 是否需要支持乐观锁
|
||||
- [ ] 如果不需要,是否从默认规则中移除
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 七、测试框架:响应字段断言(部分匹配)
|
||||
|
||||
### 现状
|
||||
|
||||
`Post` / `Get` 等终端方法目前只支持断言响应体中的 `status` 和 `msg` 字段:
|
||||
|
||||
```go
|
||||
a.JSON(Map{"phone": phone, "password": "123456"}).Post("正常登录", 0)
|
||||
a.JSON(Map{"phone": phone, "password": "123456"}).Post("密码为空", 4, "用户名或密码不能为空")
|
||||
```
|
||||
|
||||
如需验证响应 `result` 内部的具体字段,只能手动写:
|
||||
|
||||
```go
|
||||
resp := a.JSON(Map{...}).Post("查询列表", 0)
|
||||
if resp.GetBody().GetMap("result").GetInt("total") != 10 {
|
||||
resp.Fail("total 应为 10")
|
||||
}
|
||||
```
|
||||
|
||||
这种写法冗长、可读性差,且失败时无法显示期望值与实际值的对比。
|
||||
|
||||
### 改进方向
|
||||
|
||||
在终端方法签名中增加**可选的响应字段部分匹配参数**:
|
||||
|
||||
```go
|
||||
// 现有签名(不变)
|
||||
func (c *ApiCase) Post(desc string, status int, msg ...string) *ApiResponse
|
||||
|
||||
// 新增重载或扩展参数,支持传入期望的 result 字段(Map 类型)
|
||||
a.JSON(Map{...}).Post("查询列表", 0, Map{"total": 10})
|
||||
a.JSON(Map{...}).Post("查询列表", 0, Map{"total": 10, "list": notEmpty})
|
||||
a.JSON(Map{...}).Post("正常登录", 0, Map{"token": notEmpty})
|
||||
```
|
||||
|
||||
### 核心设计原则
|
||||
|
||||
**部分匹配,而非精确匹配**:
|
||||
|
||||
- 只验证期望 Map 中列出的字段,响应中额外的字段不报错
|
||||
- 避免因响应新增字段导致测试频繁失败,降低维护成本
|
||||
- 精确匹配适合快照测试场景,但与"快速精准"目标相悖,不引入
|
||||
|
||||
### 支持的断言值类型
|
||||
|
||||
| 断言值 | 含义 | 示例 |
|
||||
|--------|------|------|
|
||||
| 具体值(string/int/bool) | 字段等于该值 | `Map{"total": 10}` |
|
||||
| `notEmpty`(特殊常量) | 字段存在且非空/非零 | `Map{"token": notEmpty}` |
|
||||
| 嵌套 Map | 递归部分匹配子对象 | `Map{"user": Map{"id": 1}}` |
|
||||
|
||||
### 预期写法
|
||||
|
||||
```go
|
||||
// 断言 total 值
|
||||
a.Query(Map{"page": "1"}).Get("查询列表", 0, Map{"total": 5})
|
||||
|
||||
// 断言 token 字段存在且非空(不关心具体值)
|
||||
resp := a.JSON(Map{"phone": "138...", "password": "123456"}).Post("正常登录", 0, Map{"token": notEmpty})
|
||||
|
||||
// 组合:status + msg + result 字段三合一
|
||||
a.JSON(Map{"phone": ""}).Post("手机号为空", 4, "手机号不能为空") // 只验 status+msg
|
||||
a.JSON(Map{"phone": "138..."}).Post("正常注册", 0, Map{"id": notEmpty}) // 验 status + result.id
|
||||
```
|
||||
|
||||
### 失败输出格式
|
||||
|
||||
失败时应显示期望值和实际值对比,便于快速定位:
|
||||
|
||||
```
|
||||
--- FAIL: TestApi/app/user/login/正常登录
|
||||
期望 result.token 非空,实际值为 ""
|
||||
期望 result.total = 10,实际值为 0
|
||||
```
|
||||
|
||||
### 实现要点
|
||||
|
||||
- 参数类型可设计为 `...interface{}`,兼容现有 `msg ...string` 的调用方式,通过类型断言区分
|
||||
- `notEmpty` 可定义为框架内的特殊常量(如 `var notEmpty = struct{}{}`)
|
||||
- 断言逻辑复用 `ApiResponse` 已有的 `GetBody()` 链路,递归遍历期望 Map
|
||||
|
||||
### 待确认
|
||||
|
||||
- [ ] 终端方法签名如何兼容:新增第四参数 `result Map`,还是利用 `...interface{}` 统一
|
||||
- [ ] `notEmpty` 常量的导出名称和定义位置
|
||||
- [ ] 嵌套 Map 递归断言是否纳入首批实现范围
|
||||
- [ ] 失败输出是否需要完整响应体 dump
|
||||
|
||||
---
|
||||
|
||||
## 改进优先级
|
||||
|
||||
| 优先级 | 改进项 | 状态 |
|
||||
|--------|--------|------|
|
||||
| 高 | 备注语法优化(`\|` 分隔符) | 待设计 |
|
||||
| 高 | 测试框架:响应字段部分匹配断言 | 待实现 |
|
||||
| 中 | 软删除/日志表支持 | 待设计 |
|
||||
| 低 | 多对多关联表增强 | 待评估 |
|
||||
| 低 | 自动填充字段扩展 | 待评估 |
|
||||
|
||||
---
|
||||
|
||||
## 更新记录
|
||||
|
||||
| 日期 | 内容 |
|
||||
|------|------|
|
||||
| 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)
|
||||
@@ -0,0 +1,939 @@
|
||||
# HoTime API 测试框架使用说明
|
||||
|
||||
不启动 HTTP 服务、自动事务回滚、链式 API、覆盖率报告、API 调试控制台生成。
|
||||
|
||||
> **测试执行铁律(日常开发 / AI 辅助必读)**
|
||||
>
|
||||
> - **只允许**用 `-run` 精确跑当前接口或当前控制器
|
||||
> - **禁止**把 `go test ./app/... -v`(不带 `-run`)当作默认命令
|
||||
> - 全量回归与覆盖率报告见文末「全量回归与覆盖率」,仅 CI / 发版前使用
|
||||
|
||||
## 目录
|
||||
|
||||
- [概述](#概述)
|
||||
- [快速开始](#快速开始)
|
||||
- [运行测试(推荐:-run 单测)](#运行测试推荐-run-单测)
|
||||
- [用例编写范式](#用例编写范式)
|
||||
- [错误用例编写规范](#1-错误用例编写规范)
|
||||
- [测试数据准备](#2-测试数据准备)
|
||||
- [正确请求与响应结构校验](#3-正确请求与响应结构校验)
|
||||
- [Verify 统一校验](#4-verify-统一校验)
|
||||
- [简写支持说明](#简写支持说明)
|
||||
- [核心类型](#核心类型)
|
||||
- [链式 API 参考](#链式-api-参考)
|
||||
- [事务隔离机制](#事务隔离机制)
|
||||
- [API 调试控制台](#api-调试控制台)
|
||||
- [并发保护与缓存隔离](#并发保护与缓存隔离)
|
||||
- [二进制与非 JSON 响应校验](#二进制与非-json-响应校验)
|
||||
- [全量回归与覆盖率(仅 CI / 发版前)](#全量回归与覆盖率仅-ci--发版前)
|
||||
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
HoTime 内置 API 测试框架,核心能力:
|
||||
|
||||
| 能力 | 说明 |
|
||||
|------|------|
|
||||
| **零启动** | 基于 `net/http/httptest`,不需要启动 HTTP 服务器 |
|
||||
| **事务回滚** | 每个接口方法独立开启数据库事务,测试结束自动回滚,数据库不留任何痕迹 |
|
||||
| **SAVEPOINT** | 业务代码中的 `that.Db.Action()` 在测试模式下自动用 SAVEPOINT 替代真事务,嵌套事务完整可用 |
|
||||
| **并发保护** | `testMu` 互斥锁自动保护 `testTx` 单连接,业务代码中的 `go func()` 协程不会导致 `busy buffer` |
|
||||
| **缓存隔离** | 测试启动时自动禁用 DB/Redis 缓存,避免缓存操作与测试事务锁冲突 |
|
||||
| **链式 API** | `a.JSON(...).Post("描述", 期望status)` 一行完成:数据组装 + 请求 + 断言 |
|
||||
| **覆盖率** | 自动对比路由注册表与测试定义,输出哪些接口已覆盖、哪些未覆盖 |
|
||||
| **调试控制台** | 按模块生成独立的交互式 API 调试控制台,支持在线测试、认证管理、参数编辑 |
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
本节通过一个完整的"创建订单"接口,展示从业务代码到测试用例的全流程。
|
||||
|
||||
### 第一步:业务代码
|
||||
|
||||
假设有一个"创建订单"接口 `POST /api/order/create`,业务逻辑如下:
|
||||
|
||||
```go
|
||||
// app/order.go — 业务代码(简化示意)
|
||||
var OrderCtr = Ctr{
|
||||
"create": func(that *Context) {
|
||||
// 1. 权限校验:未登录 → Display(2, "请先登录")
|
||||
// 2. 参数校验:goods_id/quantity/address 缺失 → Display(3, "请选择商品"/"请填写购买数量"/"请填写收货地址")
|
||||
// 3. 业务校验:商品不存在 → Display(4, "商品不存在")
|
||||
// 4. 正常逻辑:计算总价、生成订单号、插入 order 表、扣减 goods 库存
|
||||
// 5. 返回:Display(0, Map{"id": orderId, "sn": sn, "total_price": totalPrice})
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 第二步:编写测试用例
|
||||
|
||||
**推荐方式**:为每个 handler 创建对应的 `_test.go` 文件,测试定义与业务代码分离。
|
||||
|
||||
```go
|
||||
// app/order_test.go — 测试定义
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
var OrderTest = CtrTest{
|
||||
"create": {Desc: "创建订单", Func: func(a *Api) {
|
||||
|
||||
// ======== 第一步:错误用例(先行) ========
|
||||
// 错误码是复用的,三个参数必须填满:desc, status, msg
|
||||
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(1), "quantity": 3}).
|
||||
Post("缺少收货地址", 3, "请填写收货地址")
|
||||
|
||||
a.WithSession(Map{"user_id": int64(1)}).
|
||||
JSON(Map{"goods_id": int64(999), "quantity": 3, "address": "XX路1号"}).
|
||||
Post("商品不存在", 4, "商品不存在")
|
||||
|
||||
// ======== 第二步:准备测试数据 ========
|
||||
// 插入商品数据,既作为正确请求的依赖,也作为后续 DB 校验的对照源
|
||||
goodsId := a.DB().Insert("goods", Map{
|
||||
"name": "测试商品", "price": 29.9, "stock": 100, "state": 1,
|
||||
})
|
||||
|
||||
// ======== 第三步:正确请求 + Verify 统一校验 ========
|
||||
a.WithSession(Map{"user_id": int64(1)}).
|
||||
Note("quantity=3 用于校验总价: 29.9*3=89.7; 新订单 state=0(待付款)").
|
||||
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"))
|
||||
}
|
||||
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),
|
||||
})
|
||||
}},
|
||||
}
|
||||
```
|
||||
|
||||
> **为什么要分离?** `_test.go` 文件仅在 `go test` 时编译,不会进入生产二进制,保持业务代码干净。同时符合 Go 语言惯例:测试相关代码放在 `_test.go` 文件中。
|
||||
|
||||
### 第三步:注册路由和测试
|
||||
|
||||
路由在 `init.go` 中注册,测试注册放在 `init_test.go` 中(因为 `_test.go` 中的变量只在测试编译时可见):
|
||||
|
||||
```go
|
||||
// app/init.go — 只注册路由
|
||||
var Project = Proj{
|
||||
"order": OrderCtr,
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// app/init_test.go — 注册测试 + 运行入口
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
)
|
||||
|
||||
var ProjectTest = ProjTest{
|
||||
"order": OrderTest,
|
||||
}
|
||||
|
||||
var testApp *TestApp
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
os.Chdir("..") // 确保工作目录为项目根目录
|
||||
testApp = NewTestApp("config/config.json", TestProj{
|
||||
"app": {Proj: Project, Tests: ProjectTest},
|
||||
})
|
||||
code := m.Run()
|
||||
// 以下两行适合 CI / 全量回归;日常用 -run 单测时也会执行,但覆盖率主要在全量时有意义
|
||||
testApp.PrintCoverage()
|
||||
testApp.GenerateSwagger("My API", "1.0.0", testApp.Config.GetString("tpt"))
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func TestApi(t *testing.T) {
|
||||
testApp.RunTests(t)
|
||||
}
|
||||
```
|
||||
|
||||
### 推荐目录结构
|
||||
|
||||
```
|
||||
app/
|
||||
├── order.go ← 业务代码 (OrderCtr)
|
||||
├── order_test.go ← 测试定义 (OrderTest)
|
||||
├── user.go ← 业务代码 (UserCtr)
|
||||
├── user_test.go ← 测试定义 (UserTest)
|
||||
├── init.go ← 路由注册 (Project)
|
||||
└── init_test.go ← 测试注册 (ProjectTest) + TestMain + TestApi
|
||||
```
|
||||
|
||||
### 运行测试(推荐:`-run` 单测)
|
||||
|
||||
`RunTests` 使用 Go 标准 `t.Run()` 子测试,**日常开发请始终加 `-run`**:
|
||||
|
||||
```bash
|
||||
# 只跑 order/create 接口(推荐,开发默认)
|
||||
go test ./app/ -v -run TestApi/app/order/create
|
||||
|
||||
# 只跑 order 控制器
|
||||
go test ./app/ -v -run TestApi/app/order
|
||||
|
||||
# 只跑某一条用例
|
||||
go test ./app/ -v -run "TestApi/app/order/create/正常创建订单"
|
||||
|
||||
# 同时跑 user 和 order
|
||||
go test ./app/ -v -run "TestApi/app/(user|order)"
|
||||
|
||||
# 跑所有控制器中包含「未登录」的用例
|
||||
go test ./app/ -v -run "TestApi/.*/.*未登录"
|
||||
```
|
||||
|
||||
> **注意**:`TestMain` 中的 `os.Chdir("..")` 确保 `go test ./app/` 的工作目录为项目根目录,使配置文件路径和模板输出路径正确。
|
||||
>
|
||||
> 全量 `go test ./app/... -v`(不带 `-run`)会跑完所有用例并触发覆盖率扫描,项目变大后很慢且容易牵连无关失败。**仅在 CI / 发版前使用**,说明见文末。
|
||||
|
||||
---
|
||||
|
||||
## 用例编写范式
|
||||
|
||||
每个接口的测试用例应按照以下流程编写:
|
||||
|
||||
```
|
||||
错误用例(参数校验、权限校验、业务校验)
|
||||
↓
|
||||
准备测试数据(a.DB().Insert / Update 模拟前置数据)
|
||||
↓
|
||||
正确请求 + 响应结构校验(第三参数自动校验 result 的类型和结构)
|
||||
↓
|
||||
Verify 统一校验(响应值断言 + 数据库状态校验,集中在一个回调中完成)
|
||||
```
|
||||
|
||||
| 环节 | 要求 | 说明 |
|
||||
|------|------|------|
|
||||
| 错误用例 | **必须** | 覆盖权限、参数、业务逻辑等异常路径 |
|
||||
| 测试数据准备 | 按需 | 接口依赖的前置数据用 `a.DB()` 插入 |
|
||||
| Note 备注 | 按需 | 用 `.Note(...)` 补充参数含义、枚举值说明、算法描述等上下文 |
|
||||
| 响应结构校验 | **必须** | 正确请求必须用第三参数校验 result 的类型和结构 |
|
||||
| Verify 校验 | **必须** | 在 Verify 内用 `a.Resp()` 断言响应值 + 用 `a.DB()` 校验数据库状态 |
|
||||
|
||||
### 1. 错误用例编写规范
|
||||
|
||||
**错误用例必须写在最前面**,在正确请求之前,按接口错误码顺序依次覆盖:权限校验(status 1/2)→ 参数校验(status 3)→ 业务校验(status 4)。
|
||||
|
||||
```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. 测试数据准备
|
||||
|
||||
正确请求通常依赖前置数据(如创建订单需要先有商品)。用 `a.DB()` 直接操作数据库插入测试数据:
|
||||
|
||||
```go
|
||||
// 插入前置数据
|
||||
goodsId := a.DB().Insert("goods", Map{
|
||||
"name": "测试商品", "price": 29.9, "stock": 100, "state": 1,
|
||||
})
|
||||
|
||||
// 后续正确请求使用 goodsId
|
||||
resp := a.WithSession(Map{"user_id": int64(1)}).
|
||||
JSON(Map{"goods_id": goodsId, "quantity": 3, "address": "XX路1号"}).
|
||||
Post("正常创建订单", 0, Map{"id": int64(1), "sn": "sample"})
|
||||
```
|
||||
|
||||
**要点**:
|
||||
|
||||
- `a.DB()` 返回的是测试事务内的数据库实例,插入的数据对同一方法内的所有后续用例可见
|
||||
- 测试结束后自动回滚,无需手动清理
|
||||
- 插入的数据同时可作为 Verify 校验的**对照源**(如校验库存从 100 扣减到 97)
|
||||
|
||||
### 3. 正确请求与响应结构校验
|
||||
|
||||
第三参数作为**类型样本**,框架自动校验 result 的类型是否匹配,**不比较具体值**。第三参数支持所有 JSON 兼容类型:
|
||||
|
||||
| 样本写法 | 校验效果 |
|
||||
|----------|----------|
|
||||
| `int64(1)` | result 是整数类型 |
|
||||
| `float64(1.0)` | result 是浮点类型 |
|
||||
| `"sample"` | result 是字符串类型 |
|
||||
| `true` | result 是布尔类型 |
|
||||
| `Map{"id": int64(1), ...}` | result 是对象,递归校验字段名+类型 |
|
||||
| `Slice{Map{"id": int64(1)}}` | result 是数组,校验首元素结构 |
|
||||
|
||||
```go
|
||||
// result 是 Map 时:值只是类型样本,不比较具体值
|
||||
a.Post("正常创建", 0, Map{
|
||||
"id": int64(1), "sn": "sample", "total_price": float64(1.0),
|
||||
})
|
||||
|
||||
// result 是原始类型时:直接传对应类型的样本值
|
||||
a.Get("获取数量", 0, int64(1)) // 校验 result 是整数
|
||||
a.Get("操作结果", 0, "sample") // 校验 result 是字符串
|
||||
a.Get("获取金额", 0, float64(1.0)) // 校验 result 是浮点数
|
||||
a.Get("是否可用", 0, true) // 校验 result 是布尔
|
||||
|
||||
// result 直接是数组时
|
||||
a.Get("获取标签", 0, Slice{Map{"id": int64(1), "name": "sample"}})
|
||||
|
||||
// 嵌套对象:递归校验子字段
|
||||
a.Get("获取详情", 0, Map{
|
||||
"id": int64(1),
|
||||
"customer": Map{"id": int64(1), "name": "sample", "phone": "sample"},
|
||||
})
|
||||
|
||||
// Map 中包含数组字段(校验首元素结构)
|
||||
a.Post("获取列表", 0, Map{
|
||||
"total": int64(1),
|
||||
"data": Slice{Map{"id": int64(1), "name": "sample", "price": float64(1.0)}},
|
||||
})
|
||||
```
|
||||
|
||||
> 结构校验只保证返回格式不变(字段名存在、类型匹配),具体值的校验交给 Verify。非 JSON 响应(二进制文件、纯文本等)不适用结构校验,省略 expect 参数,在 Verify 中通过 `GetRawBody()` 校验(详见 [二进制与非 JSON 响应校验](#二进制与非-json-响应校验))。
|
||||
|
||||
### 4. Verify 统一校验
|
||||
|
||||
Verify 是正确请求的**核心校验环节**,在一个回调中统一完成**响应值断言**和**数据库状态校验**:
|
||||
|
||||
- 通过 `a.Resp()` 获取当前请求的响应,对关键业务字段断言具体值
|
||||
- 通过 `a.DB()` 查库校验数据库状态变更
|
||||
- 返回 `nil` 表示通过,返回 `error` 则用例失败
|
||||
|
||||
```go
|
||||
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"))
|
||||
}
|
||||
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"))
|
||||
}
|
||||
|
||||
// 校验关联表:订单明细是否生成
|
||||
count := a.DB().Count("order_goods", Map{"order_id": row.GetCeilInt64("id")})
|
||||
if count < 1 {
|
||||
return fmt.Errorf("order_goods 期望至少 1 条, 实际 %d 条", count)
|
||||
}
|
||||
|
||||
return nil
|
||||
}).
|
||||
Post("正常创建订单", 0, Map{"id": int64(1), "sn": "sample", "total_price": float64(1.0)})
|
||||
```
|
||||
|
||||
**Verify 内可用的方法:**
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `a.Resp()` | 获取当前请求的响应,用于值断言 |
|
||||
| `a.Resp().GetBody()` | 获取完整响应体(Map) |
|
||||
| `a.Resp().GetBody().GetMap("result")` | 获取 result 对象 |
|
||||
| `a.DB()` | 获取数据库实例(在测试事务内),用于查库校验 |
|
||||
|
||||
**Verify 校验要点:**
|
||||
|
||||
| 校验对象 | 说明 | 示例 |
|
||||
|----------|------|------|
|
||||
| 响应关键值 | 业务字段的具体值(ID > 0、编号非空等) | `a.Resp().GetBody().GetMap("result").GetCeilInt64("id")` |
|
||||
| 主表记录 | 核心数据是否写入、字段值是否正确 | `a.DB().Get("order", ...)` |
|
||||
| 关联表 | 关联数据是否同步生成 | `a.DB().Count("order_goods", ...)` |
|
||||
| 副作用 | 库存扣减、余额变化、状态流转 | `goods.GetCeilInt64("stock") != 97` |
|
||||
|
||||
**适用场景对照:**
|
||||
|
||||
| 接口类型 | Verify 内容 |
|
||||
|----------|-------------|
|
||||
| 创建类(Insert) | 响应值断言 + 校验记录写入 + 关联表 |
|
||||
| 更新类(Update) | 响应值断言 + 校验字段变更 + 状态流转 |
|
||||
| 删除类(Delete) | 响应值断言 + 校验记录已删除/软删除 |
|
||||
| 纯查询(Select) | 响应值断言(无需 DB 校验) |
|
||||
|
||||
### 5. Note 备注
|
||||
|
||||
`Note` 用于为测试用例附加说明文字,备注会显示在 API 调试控制台的用例详情中,也会写入 `api-spec.json`。Note 不影响测试逻辑,只提升可读性。
|
||||
|
||||
**典型使用场景:**
|
||||
|
||||
```go
|
||||
// 场景一:枚举值/状态码说明 — 当参数或校验涉及枚举值时,备注各值含义
|
||||
a.WithSession(Map{"user_id": int64(1)}).
|
||||
Note("status: 0=待付款, 1=已付款, 2=已发货, 3=已完成, 4=已取消").
|
||||
Query(Map{"status": "1"}).
|
||||
Get("按状态查询订单", 0, Map{"total": int64(1), "data": Slice{Map{"id": int64(1)}}})
|
||||
|
||||
// 场景二:算法/签名说明 — 接口涉及加密或计算逻辑时,简述算法
|
||||
a.Note("sign = MD5(smsProxyKey + timestamp), 有效期5分钟").
|
||||
Form(Map{"phone": "13800138000", "sign": "abc123", "timestamp": "1700000000"}).
|
||||
Post("发送验证码", 0, Map{"expire": int64(1)})
|
||||
|
||||
// 场景三:参数值含义 — 说明测试数据的选取理由或计算依据
|
||||
a.WithSession(Map{"user_id": int64(1)}).
|
||||
Note("quantity=3, 单价29.9, 预期总价: 29.9*3=89.7").
|
||||
JSON(Map{"goods_id": goodsId, "quantity": 3}).
|
||||
Post("创建订单", 0, Map{"id": int64(1), "total_price": float64(1.0)})
|
||||
|
||||
// 场景四:前置依赖/调用顺序说明
|
||||
a.WithSession(Map{"user_id": int64(1)}).
|
||||
Note("需先调用 /api/cart/add 加入购物车, cart_id 来自上一步返回").
|
||||
JSON(Map{"cart_id": cartId}).
|
||||
Post("购物车结算", 0, Map{"order_id": int64(1)})
|
||||
|
||||
// 场景五:接口特殊行为说明
|
||||
a.WithSession(Map{"admin_id": int64(1)}).
|
||||
Note("批量操作,单次最多100条; 部分失败不回滚,返回逐条结果").
|
||||
JSON(Map{"ids": Slice{int64(1), int64(2), int64(3)}}).
|
||||
Post("批量审核", 0, Map{"success": int64(1), "fail": int64(1)})
|
||||
```
|
||||
|
||||
**建议使用 Note 的场景:**
|
||||
|
||||
| 场景 | 示例 |
|
||||
|------|------|
|
||||
| 参数含枚举值(status/type/state 等) | `Note("type: 1=个人, 2=企业")` |
|
||||
| 涉及签名、加密、哈希等算法 | `Note("sign = MD5(appKey+timestamp+nonce)")` |
|
||||
| 测试数据有特定计算依据 | `Note("quantity=3, price=29.9, 预期 total=89.7")` |
|
||||
| 接口有调用顺序或前置依赖 | `Note("需先调用 /api/order/create 创建订单")` |
|
||||
| 接口有特殊行为(批量/异步/幂等等) | `Note("幂等接口,重复调用返回相同结果")` |
|
||||
|
||||
> Note 是可选的,但在涉及枚举值、算法、前置依赖等场景时建议使用。好的备注能让其他开发者快速理解用例意图,也让调试控制台成为自文档化的 API 手册。
|
||||
|
||||
### 简写支持说明
|
||||
|
||||
框架也支持以下简写用法:
|
||||
|
||||
**省略错误消息(第三参数)**:`a.Post("请先登录", 2)` — 当 `status != 0` 且未传第三参数时,框架自动用 `desc`(第一参数)作为期望的错误消息。适合 desc 与实际 msg 完全一致的场景。
|
||||
|
||||
**省略 result 结构校验(expect)**:`a.Post("创建成功", 0)` — 仅断言 `status=0`,不校验 result 的内容和结构。适合无需关注返回结构的场景,也适用于非 JSON 响应(二进制文件、纯文本等),此时在 Verify 中通过 `GetRawBody()` 做内容校验。
|
||||
|
||||
**省略 Verify**:不设置 Verify 回调时,不会执行响应值断言和数据库状态校验。适合纯查询、无副作用的接口。
|
||||
|
||||
> 对于涉及数据写入、状态变更、副作用的接口,测试不充分等于充分不测试。
|
||||
|
||||
---
|
||||
|
||||
## 核心类型
|
||||
|
||||
### 注册类型
|
||||
|
||||
```go
|
||||
// 顶层:多项目集合(项目名 → 定义)
|
||||
type TestProj map[string]TestProjDef
|
||||
|
||||
// 单项目:路由 + 测试绑定在一起
|
||||
type TestProjDef struct {
|
||||
Proj Proj // 路由定义(与 Run 传入的 Router 保持一致)
|
||||
Tests ProjTest // 测试定义
|
||||
}
|
||||
|
||||
// 控制器级测试(控制器名 → 控制器测试集合)
|
||||
type ProjTest map[string]CtrTest
|
||||
|
||||
// 方法级测试(方法名 → 单条用例定义)
|
||||
type CtrTest map[string]ApiTestDef
|
||||
|
||||
// 单个接口的用例定义
|
||||
type ApiTestDef struct {
|
||||
Desc string // 接口描述,用于 Swagger 说明和 t.Run 层级名称
|
||||
Func func(a *Api) // 测试函数体
|
||||
}
|
||||
```
|
||||
|
||||
### TestApp
|
||||
|
||||
```go
|
||||
// NewTestApp 创建测试应用
|
||||
// configPath: 配置文件路径
|
||||
// projects: TestProj 注册所有项目的路由和测试
|
||||
// listeners: 可选,与 SetConnectListener 相同的请求拦截器
|
||||
func NewTestApp(configPath string, projects TestProj, listeners ...func(*Context) bool) *TestApp
|
||||
|
||||
// RunTests 运行所有注册的测试(每个方法级别自动事务隔离)
|
||||
func (app *TestApp) RunTests(t *testing.T)
|
||||
|
||||
// PrintCoverage 输出覆盖率报告,返回报告结构体
|
||||
func (app *TestApp) PrintCoverage() CoverageReport
|
||||
|
||||
// GenerateSwagger 生成 API 调试控制台(api-spec.json + index.html)
|
||||
func (app *TestApp) GenerateSwagger(title, version, outputDir string) error
|
||||
|
||||
// DB 获取测试应用的数据库实例(在事务内)
|
||||
func (app *TestApp) DB() *HoTimeDB
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 链式 API 参考
|
||||
|
||||
测试函数接收 `*Api` 作为入口,调用数据设置方法返回 `*ApiCase`,再调用终端方法发出请求并断言。
|
||||
|
||||
### Api — 测试入口
|
||||
|
||||
| 方法 | 说明 | 返回 |
|
||||
|------|------|------|
|
||||
| `a.JSON(body)` | 设置 JSON body | `*ApiCase` |
|
||||
| `a.Query(params)` | 设置 URL 查询参数 | `*ApiCase` |
|
||||
| `a.Form(body)` | 设置 form 表单 body | `*ApiCase` |
|
||||
| `a.File(field, name, content)` | 设置上传文件 | `*ApiCase` |
|
||||
| `a.Note(note)` | 为下一条用例设置备注,备注显示在调试控制台 | `*ApiCase` |
|
||||
| `a.Verify(fn)` | 设置请求后的校验函数(等同于先创建 ApiCase 再 Verify,适合纯 GET 无数据场景) | `*ApiCase` |
|
||||
| `a.WithSession(s)` | 返回携带 session 的新 Api | `*Api` |
|
||||
| `a.Get(desc, status, expect...)` | 直接 GET 请求(无数据场景) | `*ApiResponse` |
|
||||
| `a.Post(desc, status, expect...)` | 直接 POST 请求(无数据场景) | `*ApiResponse` |
|
||||
| `a.Put(desc, status, expect...)` | 直接 PUT 请求 | `*ApiResponse` |
|
||||
| `a.Delete(desc, status, expect...)` | 直接 DELETE 请求 | `*ApiResponse` |
|
||||
| `a.DB()` | 获取数据库实例(在事务内,可用于准备/校验数据) | `*HoTimeDB` |
|
||||
| `a.Resp()` | 获取最近一次请求的响应(在 Verify 回调中用于值断言) | `*ApiResponse` |
|
||||
|
||||
### ApiCase — 链式构建器
|
||||
|
||||
`Api` 的数据设置方法返回 `*ApiCase`,支持继续叠加数据:
|
||||
|
||||
| 方法 | 说明 | 返回 |
|
||||
|------|------|------|
|
||||
| `c.JSON(body)` | 追加 JSON body | `*ApiCase` |
|
||||
| `c.Query(params)` | 追加 URL 查询参数 | `*ApiCase` |
|
||||
| `c.Form(body)` | 追加 form 字段 | `*ApiCase` |
|
||||
| `c.File(field, name, content)` | 设置上传文件 | `*ApiCase` |
|
||||
| `c.Note(note)` | 为本条用例添加可选备注,备注显示在调试控制台用例详情中 | `*ApiCase` |
|
||||
| `c.WithSession(s)` | 覆盖本次请求的 session | `*ApiCase` |
|
||||
| `c.Verify(fn)` | 设置请求后的统一校验函数,可用 `a.Resp()` 断言响应值 + `a.DB()` 校验数据库(详见 [Verify 统一校验](#4-verify-统一校验)) | `*ApiCase` |
|
||||
| `c.Get(desc, status, expect...)` | 终端:发送 GET 请求并断言 | `*ApiResponse` |
|
||||
| `c.Post(desc, status, expect...)` | 终端:发送 POST 请求并断言 | `*ApiResponse` |
|
||||
| `c.Put(desc, status, expect...)` | 终端:发送 PUT 请求并断言 | `*ApiResponse` |
|
||||
| `c.Delete(desc, status, expect...)` | 终端:发送 DELETE 请求并断言 | `*ApiResponse` |
|
||||
|
||||
### 终端方法参数说明
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `desc` | string | 用例描述,作为 `t.Run` 的子测试名称 |
|
||||
| `status` | int | 期望的 JSON 响应体中 `status` 字段值(0=成功) |
|
||||
| `expect` | ...interface{} | 可选,行为取决于 status(见下方说明) |
|
||||
|
||||
**expect 参数解析规则:**
|
||||
|
||||
- `status == 0`(成功):
|
||||
- **不传 expect** → 仅校验 status=0,不校验 result 内容
|
||||
- 传任意类型 → result 类型/结构校验(因为成功响应没有 msg)
|
||||
- `status != 0`(错误):
|
||||
- 传 `string` → 用该字符串校验错误消息
|
||||
- **不传 expect** → 自动用 `desc`(第一参数)作为期望的错误消息(简写模式)
|
||||
- 传非 string 类型 → 按 status=0 的规则做 result 结构校验(罕见但支持)
|
||||
|
||||
> **编写建议**:错误用例建议始终填满三个参数 `Post("场景描述", status, "具体错误消息")`,因为错误码是复用的,省略第三参数会降低可读性。
|
||||
|
||||
**result 类型/结构校验:**
|
||||
|
||||
expect 参数作为**类型样本**,只比较类型大类,不比较具体值。result 不一定是 Map,可以是任意类型:
|
||||
|
||||
| 样本值类型 | 匹配的实际类型 | 说明 |
|
||||
|-----------|--------------|------|
|
||||
| `int64` / `int` 等整数 | 整数类型 | number(整数) |
|
||||
| `float64` / `float32` | 浮点类型 | float(小数) |
|
||||
| `string` | `string` | 字符串 |
|
||||
| `bool` | `bool` | 布尔值 |
|
||||
| `Map{...}` | `Map` / `map[string]interface{}` | 对象,递归校验子字段 |
|
||||
| `Slice{...}` | `Slice` / `[]interface{}` | 数组,非空时校验首元素结构 |
|
||||
|
||||
校验规则:
|
||||
- result 是原始类型(string、int、float 等)→ 直接比较类型大类
|
||||
- result 是 Map → **多了**字段不管,**少了**预设字段报错「字段缺失」
|
||||
- 字段存在但**类型不一致** → 报错「类型不匹配」
|
||||
- Map 类型的值 → 递归验证子字段
|
||||
- Slice 类型的值且样本非空 → 取实际首元素与样本首元素递归验证
|
||||
|
||||
### ApiResponse — 响应对象
|
||||
|
||||
终端方法返回 `*ApiResponse`,同时也可以在 Verify 回调中通过 `a.Resp()` 获取。
|
||||
|
||||
在 Verify 内通过 `a.Resp()` 做值断言(与 DB 校验集中在一处):
|
||||
|
||||
```go
|
||||
a.JSON(Map{"name": phone, "password": "123456"}).
|
||||
Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result.GetString("token") == "" {
|
||||
return fmt.Errorf("登录成功但缺少 token")
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Post("正常登录", 0, Map{"token": "sample", "user_id": int64(1)})
|
||||
```
|
||||
|
||||
也可以通过返回值在外部断言(适合不需要 DB 校验的简单场景):
|
||||
|
||||
```go
|
||||
resp := a.Get("获取配置", 0)
|
||||
resp.ExpectResult(Map{"version": "sample", "features": Slice{}})
|
||||
```
|
||||
|
||||
**ApiResponse 可用方法:**
|
||||
|
||||
| 方法 | 返回类型 | 说明 |
|
||||
|------|---------|------|
|
||||
| `resp.GetStatus()` | `int` | 业务 status |
|
||||
| `resp.GetMsg()` | `string` | 业务 msg |
|
||||
| `resp.GetResult()` | `interface{}` | result 字段原始值 |
|
||||
| `resp.GetBody()` | `Map` | 完整 JSON 响应体,可链式 `GetString("key")`、`GetMap("result")` 等 |
|
||||
| `resp.GetRawBody()` | `[]byte` | 原始响应字节(非 JSON 响应如文件下载、二进制流) |
|
||||
| `resp.RawBody` | `[]byte` | 同上,公开字段直接访问 |
|
||||
| `resp.ExpectResult(sample)` | `*ApiResponse` | 后置结构校验(类型样本) |
|
||||
| `resp.Fail(msg)` | — | 手动标记测试失败 |
|
||||
|
||||
### 常用写法速查
|
||||
|
||||
```go
|
||||
// POST JSON / Form / GET / PUT / DELETE
|
||||
a.JSON(Map{"name": "test"}).Post("描述", 0, Map{...})
|
||||
a.Form(Map{"shop_id": "1"}).Post("表单提交", 0, Map{...})
|
||||
a.Query(Map{"page": "1"}).Get("查询列表", 0, Map{...})
|
||||
a.Get("无参 GET", 0, Map{...})
|
||||
a.JSON(Map{"name": "新名字"}).Put("更新", 0, Map{...})
|
||||
a.Query(Map{"id": "5"}).Delete("删除", 0, "操作成功")
|
||||
|
||||
// 登录态
|
||||
a.WithSession(Map{"user_id": int64(1)}).JSON(Map{...}).Post("更新", 0, Map{...})
|
||||
|
||||
// 混合参数 / 上传
|
||||
a.Query(Map{"token": "abc"}).JSON(Map{"phone": "138"}).Post("混合传参", 0, Map{...})
|
||||
a.File("file", "photo.png", imgBytes).Form(Map{"type": "avatar"}).Post("上传头像", 0, Map{...})
|
||||
|
||||
// Verify + 备注
|
||||
a.Note("sign = MD5(key+timestamp)").
|
||||
Form(Map{"phone": "138"}).
|
||||
Verify(func(a *Api) error { /* a.Resp() / a.DB() */ return nil }).
|
||||
Post("正常发送", 0, Map{...})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 事务隔离机制
|
||||
|
||||
### 工作原理
|
||||
|
||||
`RunTests` 在每个**方法级**测试前开启一个数据库事务(`testTx`),测试结束后无论成功与否一律回滚:
|
||||
|
||||
```
|
||||
TestApi
|
||||
└── app(项目名)
|
||||
└── order(控制器名)
|
||||
└── create(方法名)← 在这一级 BEGIN → 运行测试用例 → ROLLBACK
|
||||
└── 未登录创建订单(用例)
|
||||
└── 缺少商品ID(用例)
|
||||
└── 正常创建订单(用例)
|
||||
```
|
||||
|
||||
同一方法内的所有测试用例共享同一个 `testTx`,因此前面用例插入的数据(如 `a.DB().Insert("goods", ...)`)对后续用例可见,模拟了真实的连续操作场景。
|
||||
|
||||
### 嵌套事务 (SAVEPOINT)
|
||||
|
||||
业务代码中常见的 `that.Db.Action()` 在测试模式下自动切换为 SAVEPOINT 机制,不会脱离外层测试事务:
|
||||
|
||||
```go
|
||||
// 业务代码(无需修改)
|
||||
that.Db.Action(func(db HoTimeDB) bool {
|
||||
db.Insert("order", orderData) // 在 SAVEPOINT sp_N 内执行
|
||||
db.Update("customer", balanceData, where)
|
||||
return true // → RELEASE SAVEPOINT sp_N(提交到外层 testTx)
|
||||
// 返回 false → ROLLBACK TO SAVEPOINT sp_N(只回滚这一层)
|
||||
})
|
||||
// 测试结束 → ROLLBACK testTx(回滚一切)
|
||||
```
|
||||
|
||||
| 机制 | 生产模式 | 测试模式 |
|
||||
|------|----------|----------|
|
||||
| `Action()` | `BEGIN` / `COMMIT` / `ROLLBACK` | `SAVEPOINT` / `RELEASE` / `ROLLBACK TO` |
|
||||
| 数据持久化 | 持久化到数据库 | 测试结束全部回滚 |
|
||||
| 对现有代码影响 | 无 | 无(`testTx` 默认 nil) |
|
||||
|
||||
---
|
||||
|
||||
## API 调试控制台
|
||||
|
||||
`GenerateSwagger` 按模块生成独立的交互式 API 调试控制台(暗色高对比度主题),每个模块一个子目录,支持独立分发。
|
||||
|
||||
```go
|
||||
testApp.GenerateSwagger(
|
||||
"My API", // 文档标题
|
||||
"2.1.0", // 版本号
|
||||
testApp.Config.GetString("tpt"), // 输出目录(如 "tpt")
|
||||
)
|
||||
```
|
||||
|
||||
### 生成目录结构
|
||||
|
||||
每个模块(`TestProj` 中的项目名)生成独立子目录,同时自动生成根导航页:
|
||||
|
||||
```
|
||||
{outputDir}/swagger/
|
||||
├── index.html ← 根导航页(API 文档中心,卡片式链接到各模块)
|
||||
├── api/
|
||||
│ ├── api-spec.json ← api 模块的 API 规范(路由、测试用例、请求/响应数据)
|
||||
│ └── index.html ← api 模块的调试控制台
|
||||
├── admin/
|
||||
│ ├── api-spec.json
|
||||
│ └── index.html
|
||||
└── portal/
|
||||
├── api-spec.json
|
||||
└── index.html
|
||||
```
|
||||
|
||||
访问地址:`http://localhost:8081/swagger/`(根导航页),`http://localhost:8081/swagger/api/`(api 模块)
|
||||
|
||||
> **独立分发**:每个模块目录可以单独拷贝和部署,不依赖其他模块。根导航页自动扫描子目录生成链接。
|
||||
|
||||
控制台支持三级导航、关键字搜索、用例预填、全局认证(Header/URL/Cookie)、cURL 生成、覆盖率可视化等功能。
|
||||
|
||||
---
|
||||
|
||||
## 并发保护与缓存隔离
|
||||
|
||||
框架自动处理以下问题,编写测试时无需关心:
|
||||
|
||||
- **并发保护**:`testTx` 是单连接,框架通过 `testMu` 互斥锁自动序列化所有数据库操作,业务代码中的 `go func()` 协程不会导致 `busy buffer` 错误
|
||||
- **缓存隔离**:`NewTestApp` 启动时自动禁用 DB/Redis 缓存,避免与测试事务产生锁冲突;Session 通过 `WithSession()` 直接注入 Memory 缓存
|
||||
|
||||
### 一次性数据的处理
|
||||
|
||||
对于需要随机数据的测试(如注册),直接用 `RandX` 生成随机值即可,**无需手动清理**:
|
||||
|
||||
```go
|
||||
"create": {Desc: "用户注册", Func: func(a *Api) {
|
||||
// 错误用例
|
||||
a.JSON(Map{"phone": "", "password": "123456"}).Post("手机号为空", 3, "请填写手机号")
|
||||
a.JSON(Map{"phone": "1380013", "password": "123456"}).Post("手机号格式错误", 3, "手机号码格式错误")
|
||||
|
||||
// 准备数据 + 正确请求
|
||||
phone := "138" + ObjToStr(RandX(10000000, 99999999))
|
||||
a.JSON(Map{"phone": phone, "password": "123456"}).
|
||||
Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result.GetCeilInt64("user_id") <= 0 {
|
||||
return fmt.Errorf("注册成功但 user_id 无效")
|
||||
}
|
||||
row := a.DB().Get("user", "id,phone", Map{"phone": phone})
|
||||
if row == nil {
|
||||
return fmt.Errorf("user 表未写入注册记录")
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Post("正常注册", 0, Map{"user_id": int64(1), "token": "sample"})
|
||||
|
||||
// 重复注册
|
||||
a.JSON(Map{"phone": phone, "password": "123456"}).Post("重复注册", 3, "该手机号已被注册")
|
||||
}},
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二进制与非 JSON 响应校验
|
||||
|
||||
并非所有接口都返回 JSON。文件下载、图片导出、PDF 生成等场景会直接输出二进制流。框架通过 `RawBody` 完整捕获响应体原始字节,可在 Verify 中校验:
|
||||
|
||||
### 文件下载校验
|
||||
|
||||
```go
|
||||
"export": {Desc: "导出 Excel", Func: func(a *Api) {
|
||||
a.Query(Map{"type": "xlsx"}).
|
||||
Verify(func(a *Api) error {
|
||||
raw := a.Resp().GetRawBody()
|
||||
if len(raw) == 0 {
|
||||
return fmt.Errorf("响应体为空")
|
||||
}
|
||||
// XLSX 文件的 Magic Bytes: PK (50 4B 03 04)
|
||||
if len(raw) < 4 || raw[0] != 0x50 || raw[1] != 0x4B {
|
||||
return fmt.Errorf("不是有效的 XLSX 文件,前4字节: %x", raw[:4])
|
||||
}
|
||||
// 校验文件大小在合理范围
|
||||
if len(raw) < 1024 {
|
||||
return fmt.Errorf("文件过小: %d bytes,可能为空文件", len(raw))
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Get("导出报表", 0)
|
||||
}},
|
||||
```
|
||||
|
||||
### 非 JSON 纯文本响应
|
||||
|
||||
当接口返回纯文本(如 CSV、日志)时,`Body`(Map)为空但 `RawBody` 包含完整内容:
|
||||
|
||||
```go
|
||||
a.Verify(func(a *Api) error {
|
||||
text := string(a.Resp().GetRawBody())
|
||||
if !strings.Contains(text, "id,name,phone") {
|
||||
return fmt.Errorf("CSV 缺少表头")
|
||||
}
|
||||
lines := strings.Split(text, "\n")
|
||||
if len(lines) < 2 {
|
||||
return fmt.Errorf("CSV 无数据行,仅 %d 行", len(lines))
|
||||
}
|
||||
return nil
|
||||
}).Get("导出 CSV", 0)
|
||||
```
|
||||
|
||||
### RawBody vs Body 的关系
|
||||
|
||||
| 场景 | `Body (Map)` | `RawBody ([]byte)` |
|
||||
|------|-------------|-------------------|
|
||||
| JSON 响应 | 已解析的结构化 Map | 原始 JSON 字节 |
|
||||
| 二进制文件(XLSX/PNG/PDF) | 空 Map(解析失败) | 完整二进制内容 |
|
||||
| 纯文本(CSV/TXT/HTML) | 空 Map(非 JSON) | 完整文本字节,可 `string()` 转换 |
|
||||
| 空响应 | 空 Map | 空 `[]byte` |
|
||||
|
||||
> **注意**:二进制/纯文本响应不走 status 校验(JSON 解析失败后 `Body` 为空 Map,`status` 默认 0),
|
||||
> 所以 `Get("描述", 0)` 只是形式上通过。**真正的校验逻辑必须写在 Verify 中**,对 `RawBody` 做内容断言。
|
||||
|
||||
## 全量回归与覆盖率(仅 CI / 发版前)
|
||||
|
||||
> **本节仅适用于 CI 流水线或发版前人工回归。** 日常开发与 AI 辅助请使用文首的 `-run` 单测命令,不要执行本节命令。
|
||||
|
||||
### 全量命令
|
||||
|
||||
```bash
|
||||
# 跑完当前包(或 ./app/...)下全部 API 测试——慢,且任一无关失败都会打断你
|
||||
go test ./app/... -v
|
||||
```
|
||||
|
||||
`TestMain` 里常见的收尾逻辑(全量跑完后才有意义):
|
||||
|
||||
```go
|
||||
func TestMain(m *testing.M) {
|
||||
// ...
|
||||
code := m.Run()
|
||||
testApp.PrintCoverage() // 全量跑完后输出覆盖率
|
||||
testApp.GenerateSwagger("My API", "1.0.0", testApp.Config.GetString("tpt"))
|
||||
os.Exit(code)
|
||||
}
|
||||
```
|
||||
|
||||
### 覆盖率报告
|
||||
|
||||
`PrintCoverage()` 自动对比 `Proj`(所有路由)和 `ProjTest`(有测试的路由):
|
||||
|
||||
**输出示例(全部通过):**
|
||||
|
||||
```
|
||||
========== API 测试覆盖率报告 ==========
|
||||
总接口: 42 | 已覆盖: 6 | 覆盖率: 14.3%
|
||||
总用例: 16 | 通过: 16 | 未通过: 0 | 通过率: 100.0%
|
||||
|
||||
---------- 通过的接口 ----------
|
||||
✓ /api/user/login 4 用例 (4 通过, 0 失败) 12ms
|
||||
...
|
||||
|
||||
未覆盖的接口: (36 个)
|
||||
- api/goods/list
|
||||
- ...
|
||||
========================================
|
||||
```
|
||||
|
||||
**输出示例(有失败):**
|
||||
|
||||
```
|
||||
========== API 测试覆盖率报告 ==========
|
||||
总接口: 42 | 已覆盖: 6 | 覆盖率: 14.3%
|
||||
总用例: 16 | 通过: 14 | 未通过: 2 | 通过率: 87.5%
|
||||
|
||||
---------- 未通过的接口 (1个) ----------
|
||||
✗ /api/order/create 5 用例 (3 通过, 2 失败) 25ms
|
||||
[失败] 正常创建订单 — Verify: order 表未写入订单记录
|
||||
...
|
||||
========================================
|
||||
```
|
||||
|
||||
报告分为四个区域:
|
||||
- **汇总区**:接口覆盖率 + 用例通过率
|
||||
- **未通过的接口**:失败接口单独置顶,附带失败原因
|
||||
- **通过的接口**:正常通过的接口列表
|
||||
- **未覆盖的接口**:尚未编写测试的接口
|
||||
|
||||
### api-spec.json 覆盖率字段说明
|
||||
|
||||
`GenerateSwagger` 生成的每个 `api-spec.json` 中,每条 `endpoint` 可含:
|
||||
|
||||
```json
|
||||
{
|
||||
"path": "/api/user/login",
|
||||
"tested": true,
|
||||
"cases": [
|
||||
{
|
||||
"name": "正常登录",
|
||||
"passed": true,
|
||||
"note": "备注(可选)",
|
||||
"json": { "name": "13800138000", "password": "123456" },
|
||||
"response": { "status": 0, "msg": "ok", "data": {} }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -1,6 +1,7 @@
|
||||
package baidu
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
@@ -21,6 +22,93 @@ func (that *baiduMap) Init(Ak string) {
|
||||
//query
|
||||
}
|
||||
|
||||
// from 源坐标类型:
|
||||
// 1:GPS标准坐标;
|
||||
// 2:搜狗地图坐标;
|
||||
// 3:火星坐标(gcj02),即高德地图、腾讯地图和MapABC等地图使用的坐标;
|
||||
// 4:3中列举的地图坐标对应的墨卡托平面坐标;
|
||||
// 5:百度地图采用的经纬度坐标(bd09ll);
|
||||
// 6:百度地图采用的墨卡托平面坐标(bd09mc);
|
||||
// 7:图吧地图坐标;
|
||||
// 8:51地图坐标;
|
||||
// int 1 1 否
|
||||
// to
|
||||
// 目标坐标类型:
|
||||
// 3:火星坐标(gcj02),即高德地图、腾讯地图及MapABC等地图使用的坐标;
|
||||
// 5:百度地图采用的经纬度坐标(bd09ll);
|
||||
// 6:百度地图采用的墨卡托平面坐标(bd09mc);
|
||||
func (that *baiduMap) Geoconv(latlngs []Map, from, to int) (Slice, error) {
|
||||
|
||||
client := &http.Client{}
|
||||
latlngsStr := ""
|
||||
for _, v := range latlngs {
|
||||
if latlngsStr != "" {
|
||||
latlngsStr = latlngsStr + ";" + v.GetString("lng") + "," + v.GetString("lat")
|
||||
} else {
|
||||
latlngsStr = v.GetString("lng") + "," + v.GetString("lat")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
url := "https://api.map.baidu.com/geoconv/v1/?from=" + ObjToStr(from) + "&to=" + ObjToStr(to) + "&ak=" + that.Ak + "&coords=" + latlngsStr
|
||||
reqest, err := http.NewRequest("GET", url, nil)
|
||||
|
||||
if err != nil {
|
||||
fmt.Println("Fatal error ", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
response, err := client.Do(reqest)
|
||||
defer response.Body.Close()
|
||||
|
||||
if err != nil {
|
||||
fmt.Println("Fatal error ", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, err := ioutil.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
//fmt.Println(string(body))
|
||||
data := ObjToMap(string(body))
|
||||
if data.GetCeilInt64("status") != 0 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return data.GetSlice("result"), err
|
||||
}
|
||||
|
||||
func (that *baiduMap) GetAddress(lat string, lng string) (string, error) {
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
url := "https://api.map.baidu.com/reverse_geocoding/v3/?ak=" + that.Ak + "&output=json&location=" + lat + "," + lng
|
||||
reqest, err := http.NewRequest("GET", url, nil)
|
||||
|
||||
if err != nil {
|
||||
fmt.Println("Fatal error ", err.Error())
|
||||
return "", err
|
||||
}
|
||||
response, err := client.Do(reqest)
|
||||
defer response.Body.Close()
|
||||
|
||||
if err != nil {
|
||||
fmt.Println("Fatal error ", err.Error())
|
||||
return "", err
|
||||
}
|
||||
|
||||
body, err := ioutil.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
//fmt.Println(string(body))
|
||||
|
||||
return string(body), err
|
||||
|
||||
}
|
||||
|
||||
// GetPosition 获取定位列表
|
||||
func (that *baiduMap) GetPosition(name string, region string) (string, error) {
|
||||
|
||||
|
||||
+2
-2
@@ -49,7 +49,7 @@ func (that *dingdongyun) SendTz(umoblie []string, tpt string, data map[string]st
|
||||
return that.send(that.TzUrl, umobleStr, tpt)
|
||||
}
|
||||
|
||||
//发送短信
|
||||
// 发送短信
|
||||
func (that *dingdongyun) send(mUrl string, umoblie string, content string) (bool, error) {
|
||||
|
||||
data_send_sms_yzm := url.Values{"apikey": {that.ApiKey}, "mobile": {umoblie}, "content": {content}}
|
||||
@@ -74,7 +74,7 @@ func (that *dingdongyun) send(mUrl string, umoblie string, content string) (bool
|
||||
return true, nil
|
||||
}
|
||||
|
||||
//调用url发送短信的连接
|
||||
// 调用url发送短信的连接
|
||||
func (that *dingdongyun) httpsPostForm(url string, data url.Values) (string, error) {
|
||||
resp, err := http.PostForm(url, data)
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"github.com/silenceper/wechat/v2/officialaccount/oauth"
|
||||
)
|
||||
|
||||
//基于此文档开发
|
||||
//https://github.com/silenceper/wechat/blob/v2/doc/api/officialaccount.md
|
||||
// 基于此文档开发
|
||||
// https://github.com/silenceper/wechat/blob/v2/doc/api/officialaccount.md
|
||||
type h5Program struct {
|
||||
Memory *cache.Memory
|
||||
Config *h5config.Config
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
//基于此文档开发
|
||||
//https://github.com/silenceper/wechat/blob/v2/doc/api/officialaccount.md
|
||||
// 基于此文档开发
|
||||
// https://github.com/silenceper/wechat/blob/v2/doc/api/officialaccount.md
|
||||
type wxpay struct {
|
||||
client *wechat.ClientV3
|
||||
ctx context.Context
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
)
|
||||
|
||||
var ProjectTest = ProjTest{
|
||||
"test": TestTest,
|
||||
"mysql": MysqlTest,
|
||||
"dmdb": DmdbTest,
|
||||
"cache": CacheTest,
|
||||
"expect_demo": ExpectDemoTest,
|
||||
}
|
||||
|
||||
var testApp *TestApp
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// Go test 的 CWD 是包目录 example/app/,切回 example/ 使相对路径正确
|
||||
_ = os.Chdir("..")
|
||||
|
||||
testApp = NewTestApp("config/config.json",
|
||||
TestProj{
|
||||
"app": {
|
||||
Proj: Project,
|
||||
Tests: ProjectTest,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// 在测试前初始化各数据库的测试表和种子数据
|
||||
SetupDatabase(&testApp.Db)
|
||||
|
||||
code := m.Run()
|
||||
testApp.PrintCoverage()
|
||||
testApp.GenerateSwagger("My API", "1.0.0", testApp.Config.GetString("tpt"))
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func TestApi(t *testing.T) {
|
||||
testApp.RunTests(t)
|
||||
}
|
||||
@@ -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,255 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
var ExpectDemoTest = CtrTest{
|
||||
|
||||
// ─── 基础类型结构校验 + Verify 值断言 ──────────────────
|
||||
|
||||
"string_result": {Desc: "返回字符串-结构+值校验", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetString("result")
|
||||
if result != "操作成功" {
|
||||
return fmt.Errorf("result 期望 '操作成功', 实际 '%s'", result)
|
||||
}
|
||||
return nil
|
||||
}).Get("result是字符串", 0, "样本")
|
||||
}},
|
||||
|
||||
"number_result": {Desc: "返回整数-结构+值校验", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetCeilInt64("result")
|
||||
if result != 42 {
|
||||
return fmt.Errorf("result 期望 42, 实际 %d", result)
|
||||
}
|
||||
return nil
|
||||
}).Get("result是整数", 0, int64(1))
|
||||
}},
|
||||
|
||||
"float_result": {Desc: "返回小数-结构+值校验", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetFloat64("result")
|
||||
if result != 3.14 {
|
||||
return fmt.Errorf("result 期望 3.14, 实际 %f", result)
|
||||
}
|
||||
return nil
|
||||
}).Get("result是小数", 0, float64(1.0))
|
||||
}},
|
||||
|
||||
"bool_result": {Desc: "返回布尔-结构+值校验", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetBool("result")
|
||||
if !result {
|
||||
return fmt.Errorf("result 期望 true, 实际 false")
|
||||
}
|
||||
return nil
|
||||
}).Get("result是布尔", 0, true)
|
||||
}},
|
||||
|
||||
"map_result": {Desc: "返回对象-结构+值校验", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result == nil {
|
||||
return fmt.Errorf("result 为 nil")
|
||||
}
|
||||
if result.GetCeilInt64("id") != 1 {
|
||||
return fmt.Errorf("id 期望 1, 实际 %d", result.GetCeilInt64("id"))
|
||||
}
|
||||
if result.GetString("name") != "示例商品" {
|
||||
return fmt.Errorf("name 期望 '示例商品', 实际 '%s'", result.GetString("name"))
|
||||
}
|
||||
if !result.GetBool("in_stock") {
|
||||
return fmt.Errorf("in_stock 期望 true")
|
||||
}
|
||||
return nil
|
||||
}).Get("result是Map-校验字段名+类型+值", 0, Map{
|
||||
"id": int64(1), "name": "sample", "price": float64(1.0), "in_stock": true,
|
||||
})
|
||||
}},
|
||||
|
||||
"nested_result": {Desc: "返回嵌套对象-结构+值校验", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result == nil {
|
||||
return fmt.Errorf("result 为 nil")
|
||||
}
|
||||
if result.GetCeilInt64("id") != 100 {
|
||||
return fmt.Errorf("id 期望 100, 实际 %d", result.GetCeilInt64("id"))
|
||||
}
|
||||
if result.GetString("title") != "测试订单" {
|
||||
return fmt.Errorf("title 期望 '测试订单', 实际 '%s'", result.GetString("title"))
|
||||
}
|
||||
customer := result.GetMap("customer")
|
||||
if customer == nil {
|
||||
return fmt.Errorf("customer 为 nil")
|
||||
}
|
||||
if customer.GetString("name") != "张三" {
|
||||
return fmt.Errorf("customer.name 期望 '张三', 实际 '%s'", customer.GetString("name"))
|
||||
}
|
||||
if customer.GetString("phone") != "13800138000" {
|
||||
return fmt.Errorf("customer.phone 期望 '13800138000', 实际 '%s'", customer.GetString("phone"))
|
||||
}
|
||||
items := result.GetSlice("items")
|
||||
if len(items) != 2 {
|
||||
return fmt.Errorf("items 期望 2 条, 实际 %d 条", len(items))
|
||||
}
|
||||
firstItem := items.GetMap(0)
|
||||
if firstItem.GetString("goods_name") != "苹果" {
|
||||
return fmt.Errorf("items[0].goods_name 期望 '苹果', 实际 '%s'", firstItem.GetString("goods_name"))
|
||||
}
|
||||
delivery := result.GetMap("extra").GetMap("delivery")
|
||||
if delivery == nil {
|
||||
return fmt.Errorf("extra.delivery 为 nil")
|
||||
}
|
||||
if delivery.GetString("address") != "XX路1号" {
|
||||
return fmt.Errorf("extra.delivery.address 期望 'XX路1号', 实际 '%s'", delivery.GetString("address"))
|
||||
}
|
||||
return nil
|
||||
}).Get("深层嵌套Map+Slice+Map", 0, Map{
|
||||
"id": int64(1), "title": "sample",
|
||||
"customer": Map{"id": int64(1), "name": "sample", "phone": "sample"},
|
||||
"items": Slice{Map{
|
||||
"id": int64(1), "goods_name": "sample", "quantity": int64(1), "price": float64(1.0),
|
||||
}},
|
||||
"extra": Map{
|
||||
"delivery": Map{"address": "sample", "fee": int64(1)},
|
||||
},
|
||||
})
|
||||
}},
|
||||
|
||||
"slice_result": {Desc: "返回数组-结构+值校验", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetSlice("result")
|
||||
if len(result) != 2 {
|
||||
return fmt.Errorf("result 期望 2 条, 实际 %d 条", len(result))
|
||||
}
|
||||
first := result.GetMap(0)
|
||||
if first.GetString("name") != "标签A" {
|
||||
return fmt.Errorf("result[0].name 期望 '标签A', 实际 '%s'", first.GetString("name"))
|
||||
}
|
||||
second := result.GetMap(1)
|
||||
if second.GetString("name") != "标签B" {
|
||||
return fmt.Errorf("result[1].name 期望 '标签B', 实际 '%s'", second.GetString("name"))
|
||||
}
|
||||
return nil
|
||||
}).Get("result直接是Slice", 0, Slice{Map{"id": int64(1), "name": "sample"}})
|
||||
}},
|
||||
|
||||
// ─── 错误先行 + 正确请求完整范式 ─────────────────────
|
||||
|
||||
"error_demo": {Desc: "错误先行+正确请求完整校验", Func: func(a *Api) {
|
||||
// ======== 第一步:错误用例(先行) ========
|
||||
a.Form(Map{"name": ""}).Post("名称为空-缺少必填字段", 3, "名称不能为空")
|
||||
|
||||
// ======== 第二步:正确请求 + 结构校验 + Verify 值断言 ========
|
||||
a.Form(Map{"name": "测试商品"}).
|
||||
Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result == nil {
|
||||
return fmt.Errorf("result 为 nil")
|
||||
}
|
||||
if result.GetCeilInt64("id") != 1 {
|
||||
return fmt.Errorf("id 期望 1, 实际 %d", result.GetCeilInt64("id"))
|
||||
}
|
||||
if result.GetString("name") != "测试商品" {
|
||||
return fmt.Errorf("name 期望 '测试商品', 实际 '%s'", result.GetString("name"))
|
||||
}
|
||||
if !result.GetBool("created") {
|
||||
return fmt.Errorf("created 期望 true")
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Post("名称正确-结构+值校验", 0, Map{
|
||||
"id": int64(1), "name": "sample", "created": true,
|
||||
})
|
||||
}},
|
||||
|
||||
"no_expect": {Desc: "最小正确用例-仍需值断言", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result == nil {
|
||||
return fmt.Errorf("result 为 nil")
|
||||
}
|
||||
if result.GetCeilInt64("id") != 1 {
|
||||
return fmt.Errorf("id 期望 1, 实际 %d", result.GetCeilInt64("id"))
|
||||
}
|
||||
if result.GetString("data") != "无预期校验" {
|
||||
return fmt.Errorf("data 期望 '无预期校验', 实际 '%s'", result.GetString("data"))
|
||||
}
|
||||
return nil
|
||||
}).Get("有Verify值断言", 0, Map{"id": int64(1), "data": "sample"})
|
||||
}},
|
||||
|
||||
// ─── Verify 完整范式:错误 → 数据准备 → 正确请求 + 响应断言 + DB 校验 ─
|
||||
|
||||
"verify_demo": {Desc: "Verify完整范式演示", Func: func(a *Api) {
|
||||
// ======== 第一步:错误用例 ========
|
||||
a.Form(Map{"name": ""}).Post("名称为空", 3, "名称不能为空")
|
||||
|
||||
// ======== 第二步:准备测试数据 ========
|
||||
// 先插入一条已有记录,用于后续 DB 校验时确认"新增"行为
|
||||
a.DB().Insert("test_batch", Map{
|
||||
"name": "existing_record", "title": "verify_demo", "state": 1,
|
||||
})
|
||||
|
||||
// ======== 第三步:正确请求 + Verify 统一校验 ========
|
||||
name := "verify_test"
|
||||
a.Form(Map{"name": name}).
|
||||
Verify(func(a *Api) error {
|
||||
// 响应值断言
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result == nil {
|
||||
return fmt.Errorf("result 为 nil")
|
||||
}
|
||||
if result.GetCeilInt64("id") <= 0 {
|
||||
return fmt.Errorf("返回的 id 必须大于 0, 实际 %d", result.GetCeilInt64("id"))
|
||||
}
|
||||
if result.GetString("name") != name {
|
||||
return fmt.Errorf("name 期望 '%s', 实际 '%s'", name, result.GetString("name"))
|
||||
}
|
||||
|
||||
// 数据库状态校验
|
||||
row := a.DB().Get("test_batch", "*", Map{"AND": Map{"name": name, "title": "verify_demo"}})
|
||||
if row == nil {
|
||||
return fmt.Errorf("test_batch 表未找到 name=%s 的记录", name)
|
||||
}
|
||||
if row.GetCeilInt64("state") != 0 {
|
||||
return fmt.Errorf("test_batch.state 期望 0, 实际 %d", row.GetCeilInt64("state"))
|
||||
}
|
||||
|
||||
// 校验之前插入的记录仍然存在(验证不会误删)
|
||||
existing := a.DB().Get("test_batch", "*", Map{"AND": Map{"name": "existing_record", "title": "verify_demo"}})
|
||||
if existing == nil {
|
||||
return fmt.Errorf("之前插入的 existing_record 丢失")
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Post("写入并校验DB+响应", 0, Map{"id": int64(1), "name": "sample"})
|
||||
|
||||
// ======== 演示故意失败的 Verify ========
|
||||
a.Form(Map{"name": "verify_fail"}).
|
||||
Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result == nil {
|
||||
return fmt.Errorf("result 为 nil")
|
||||
}
|
||||
if result.GetCeilInt64("id") <= 0 {
|
||||
return fmt.Errorf("返回的 id 必须大于 0, 实际 %d", result.GetCeilInt64("id"))
|
||||
}
|
||||
row := a.DB().Get("test_batch", "*", Map{"AND": Map{"name": "verify_fail", "title": "verify_demo"}})
|
||||
if row == nil {
|
||||
return fmt.Errorf("test_batch 表未找到 name=verify_fail 的记录")
|
||||
}
|
||||
if row.GetCeilInt64("state") != 99 {
|
||||
return fmt.Errorf("test_batch.state 期望 99, 实际 %d(接口写入 0,故意校验 99 演示失败效果)", row.GetCeilInt64("state"))
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Post("写入并校验DB-故意失败", 0, Map{"id": int64(1), "name": "sample"})
|
||||
}},
|
||||
}
|
||||
@@ -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,808 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
. "code.hoteas.com/golang/hotime/db"
|
||||
)
|
||||
|
||||
// initTestTables 根据数据库类型初始化测试表
|
||||
func initTestTables(that *Context) {
|
||||
switch that.Db.Type {
|
||||
case "dm", "dameng":
|
||||
// 用 COUNT(*) 直接探查表可访问性,而非 USER_TABLES(后者受 schema 错配影响)
|
||||
exists := that.Db.Query(`SELECT COUNT(*) as cnt FROM "test_batch"`)
|
||||
if len(exists) == 0 {
|
||||
that.Db.Exec(`CREATE TABLE "test_batch" (
|
||||
"id" INT IDENTITY(1,1) PRIMARY KEY,
|
||||
"name" VARCHAR(100),
|
||||
"title" VARCHAR(200),
|
||||
"state" INT DEFAULT 0,
|
||||
"create_time" TIMESTAMP
|
||||
)`)
|
||||
}
|
||||
default:
|
||||
that.Db.Exec(`CREATE TABLE IF NOT EXISTS test_batch (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(100),
|
||||
title VARCHAR(200),
|
||||
state INT DEFAULT 0,
|
||||
create_time DATETIME
|
||||
)`)
|
||||
}
|
||||
|
||||
_ = that.Db.Count("admin")
|
||||
_ = that.Db.Count("article")
|
||||
}
|
||||
|
||||
// nowFunc 返回当前数据库的 NOW() 函数表达式
|
||||
func nowFunc(that *Context) string {
|
||||
return "NOW()"
|
||||
}
|
||||
|
||||
// ExpectDemoCtr 用于展示 result 结构校验的各种场景
|
||||
var ExpectDemoCtr = Ctr{
|
||||
"string_result": func(that *Context) {
|
||||
that.Display(0, "操作成功")
|
||||
},
|
||||
"number_result": func(that *Context) {
|
||||
that.Display(0, 42)
|
||||
},
|
||||
"float_result": func(that *Context) {
|
||||
that.Display(0, 3.14)
|
||||
},
|
||||
"bool_result": func(that *Context) {
|
||||
that.Display(0, true)
|
||||
},
|
||||
"map_result": func(that *Context) {
|
||||
that.Display(0, Map{
|
||||
"id": 1, "name": "示例商品", "price": 19.9, "in_stock": true,
|
||||
})
|
||||
},
|
||||
"nested_result": func(that *Context) {
|
||||
that.Display(0, Map{
|
||||
"id": 100, "title": "测试订单",
|
||||
"customer": Map{"id": 1, "name": "张三", "phone": "13800138000"},
|
||||
"items": Slice{
|
||||
Map{"id": 1, "goods_name": "苹果", "quantity": 5, "price": 3.5},
|
||||
Map{"id": 2, "goods_name": "香蕉", "quantity": 10, "price": 2.0},
|
||||
},
|
||||
"extra": Map{
|
||||
"delivery": Map{"address": "XX路1号", "fee": 5},
|
||||
},
|
||||
})
|
||||
},
|
||||
"slice_result": func(that *Context) {
|
||||
that.Display(0, Slice{
|
||||
Map{"id": 1, "name": "标签A"},
|
||||
Map{"id": 2, "name": "标签B"},
|
||||
})
|
||||
},
|
||||
"error_demo": func(that *Context) {
|
||||
name := that.Req.FormValue("name")
|
||||
if name == "" {
|
||||
that.Display(3, "名称不能为空")
|
||||
return
|
||||
}
|
||||
that.Display(0, Map{"id": 1, "name": name, "created": true})
|
||||
},
|
||||
"no_expect": func(that *Context) {
|
||||
that.Display(0, Map{"id": 1, "data": "无预期校验"})
|
||||
},
|
||||
"verify_demo": func(that *Context) {
|
||||
name := that.Req.FormValue("name")
|
||||
if name == "" {
|
||||
that.Display(3, "名称不能为空")
|
||||
return
|
||||
}
|
||||
initTestTables(that)
|
||||
id := that.Db.Insert("test_batch", Map{"name": name, "title": "verify_demo", "state": 0})
|
||||
that.Display(0, Map{"id": id, "name": name})
|
||||
},
|
||||
}
|
||||
|
||||
// TestCtr 通用 ORM 测试控制器(数据库无关)
|
||||
var TestCtr = Ctr{
|
||||
"test": func(that *Context) {
|
||||
that.Display(2, "dsadasd")
|
||||
},
|
||||
|
||||
"all": func(that *Context) {
|
||||
results := Map{}
|
||||
initTestTables(that)
|
||||
|
||||
results["1_basic_crud"] = testBasicCRUD(that)
|
||||
results["2_condition_syntax"] = testConditionSyntax(that)
|
||||
results["3_chain_query"] = testChainQuery(that)
|
||||
results["4_join_query"] = testJoinQuery(that)
|
||||
results["5_aggregate"] = testAggregate(that)
|
||||
results["6_pagination"] = testPagination(that)
|
||||
results["7_batch_insert"] = testInserts(that)
|
||||
results["8_upsert"] = testUpsert(that)
|
||||
results["9_transaction"] = testTransaction(that)
|
||||
|
||||
that.Display(0, results)
|
||||
},
|
||||
|
||||
"crud": func(that *Context) { initTestTables(that); that.Display(0, testBasicCRUD(that)) },
|
||||
"condition": func(that *Context) { that.Display(0, testConditionSyntax(that)) },
|
||||
"chain": func(that *Context) { that.Display(0, testChainQuery(that)) },
|
||||
"join": func(that *Context) { that.Display(0, testJoinQuery(that)) },
|
||||
"aggregate": func(that *Context) { that.Display(0, testAggregate(that)) },
|
||||
"pagination": func(that *Context) { that.Display(0, testPagination(that)) },
|
||||
"batch": func(that *Context) { initTestTables(that); that.Display(0, testInserts(that)) },
|
||||
"upsert": func(that *Context) { that.Display(0, testUpsert(that)) },
|
||||
"transaction": func(that *Context) { initTestTables(that); that.Display(0, testTransaction(that)) },
|
||||
}
|
||||
|
||||
func testBasicCRUD(that *Context) Map {
|
||||
result := Map{"name": "基础CRUD测试", "tests": Slice{}}
|
||||
tests := Slice{}
|
||||
|
||||
now := nowFunc(that)
|
||||
|
||||
insertTest := Map{"name": "Insert 插入测试 (admin表)"}
|
||||
adminId := that.Db.Insert("admin", Map{
|
||||
"name": "测试管理员_" + fmt.Sprintf("%d", time.Now().Unix()),
|
||||
"phone": fmt.Sprintf("138%d", time.Now().Unix()%100000000),
|
||||
"state": 1,
|
||||
"password": "test123456",
|
||||
"role_id": 1,
|
||||
"title": "测试职位",
|
||||
"create_time[#]": now,
|
||||
"modify_time[#]": now,
|
||||
})
|
||||
insertTest["result"] = adminId > 0
|
||||
insertTest["adminId"] = adminId
|
||||
insertTest["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, insertTest)
|
||||
|
||||
getTest := Map{"name": "Get 获取单条记录测试"}
|
||||
admin := that.Db.Get("admin", "*", Map{"id": adminId})
|
||||
getTest["result"] = admin != nil && admin.GetInt64("id") == adminId
|
||||
getTest["admin"] = admin
|
||||
tests = append(tests, getTest)
|
||||
|
||||
selectTest1 := Map{"name": "Select 单条件查询测试"}
|
||||
admins1 := that.Db.Select("admin", "*", Map{"state": 1, "LIMIT": 5})
|
||||
selectTest1["result"] = len(admins1) >= 0
|
||||
selectTest1["count"] = len(admins1)
|
||||
tests = append(tests, selectTest1)
|
||||
|
||||
selectTest2 := Map{"name": "Select 多条件自动AND测试"}
|
||||
admins2 := that.Db.Select("admin", "*", Map{
|
||||
"state": 1,
|
||||
"role_id[>]": 0,
|
||||
"ORDER": "id DESC",
|
||||
"LIMIT": 5,
|
||||
})
|
||||
selectTest2["result"] = true
|
||||
selectTest2["count"] = len(admins2)
|
||||
selectTest2["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, selectTest2)
|
||||
|
||||
updateTest := Map{"name": "Update 更新测试"}
|
||||
affected := that.Db.Update("admin", Map{
|
||||
"title": "更新后的职位",
|
||||
"modify_time[#]": now,
|
||||
}, Map{"id": adminId})
|
||||
updateTest["result"] = affected > 0
|
||||
updateTest["affected"] = affected
|
||||
tests = append(tests, updateTest)
|
||||
|
||||
deleteTest := Map{"name": "Delete 删除测试"}
|
||||
tempId := that.Db.Insert("test_batch", Map{
|
||||
"name": "临时删除测试",
|
||||
"title": "测试标题",
|
||||
"state": 1,
|
||||
"create_time[#]": now,
|
||||
})
|
||||
deleteAffected := that.Db.Delete("test_batch", Map{"id": tempId})
|
||||
deleteTest["result"] = deleteAffected > 0
|
||||
deleteTest["affected"] = deleteAffected
|
||||
tests = append(tests, deleteTest)
|
||||
|
||||
result["tests"] = tests
|
||||
result["success"] = true
|
||||
return result
|
||||
}
|
||||
|
||||
func testConditionSyntax(that *Context) Map {
|
||||
result := Map{"name": "条件查询语法测试", "tests": Slice{}}
|
||||
tests := Slice{}
|
||||
|
||||
test1 := Map{"name": "等于条件 (=)"}
|
||||
articles1 := that.Db.Select("article", "id,title", Map{"state": 0, "LIMIT": 3})
|
||||
test1["result"] = true
|
||||
test1["count"] = len(articles1)
|
||||
tests = append(tests, test1)
|
||||
|
||||
test2 := Map{"name": "不等于条件 ([!])"}
|
||||
articles2 := that.Db.Select("article", "id,title,state", Map{"state[!]": -1, "LIMIT": 3})
|
||||
test2["result"] = true
|
||||
test2["count"] = len(articles2)
|
||||
test2["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, test2)
|
||||
|
||||
test3 := Map{"name": "大于小于条件 ([>], [<])"}
|
||||
articles3 := that.Db.Select("article", "id,title,click_num", Map{
|
||||
"click_num[>]": 0,
|
||||
"click_num[<]": 100000,
|
||||
"LIMIT": 3,
|
||||
})
|
||||
test3["result"] = true
|
||||
test3["count"] = len(articles3)
|
||||
tests = append(tests, test3)
|
||||
|
||||
test4 := Map{"name": "大于等于小于等于条件 ([>=], [<=])"}
|
||||
articles4 := that.Db.Select("article", "id,title,sort", Map{
|
||||
"sort[>=]": 0,
|
||||
"sort[<=]": 100,
|
||||
"LIMIT": 3,
|
||||
})
|
||||
test4["result"] = true
|
||||
test4["count"] = len(articles4)
|
||||
tests = append(tests, test4)
|
||||
|
||||
test5 := Map{"name": "LIKE 模糊查询 ([~])"}
|
||||
articles5 := that.Db.Select("article", "id,title", Map{"title[~]": "新闻", "LIMIT": 3})
|
||||
test5["result"] = true
|
||||
test5["count"] = len(articles5)
|
||||
test5["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, test5)
|
||||
|
||||
test6 := Map{"name": "右模糊查询 ([~!])"}
|
||||
articles6 := that.Db.Select("admin", "id,name", Map{"name[~!]": "管理", "LIMIT": 3})
|
||||
test6["result"] = true
|
||||
test6["count"] = len(articles6)
|
||||
tests = append(tests, test6)
|
||||
|
||||
test7 := Map{"name": "BETWEEN 区间查询 ([<>])"}
|
||||
articles7 := that.Db.Select("article", "id,title,click_num", Map{"click_num[<>]": Slice{0, 1000}, "LIMIT": 3})
|
||||
test7["result"] = true
|
||||
test7["count"] = len(articles7)
|
||||
test7["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, test7)
|
||||
|
||||
test8 := Map{"name": "NOT BETWEEN 查询 ([><])"}
|
||||
articles8 := that.Db.Select("article", "id,title,sort", Map{"sort[><]": Slice{-10, 0}, "LIMIT": 3})
|
||||
test8["result"] = true
|
||||
test8["count"] = len(articles8)
|
||||
tests = append(tests, test8)
|
||||
|
||||
test9 := Map{"name": "IN 查询"}
|
||||
articles9 := that.Db.Select("article", "id,title", Map{"id": Slice{1, 2, 3, 4, 5}, "LIMIT": 5})
|
||||
test9["result"] = true
|
||||
test9["count"] = len(articles9)
|
||||
test9["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, test9)
|
||||
|
||||
test10 := Map{"name": "NOT IN 查询 ([!])"}
|
||||
articles10 := that.Db.Select("article", "id,title", Map{"id[!]": Slice{1, 2, 3}, "LIMIT": 5})
|
||||
test10["result"] = true
|
||||
test10["count"] = len(articles10)
|
||||
tests = append(tests, test10)
|
||||
|
||||
test11 := Map{"name": "IS NULL 查询"}
|
||||
articles11 := that.Db.Select("article", "id,title,img", Map{"img": nil, "LIMIT": 3})
|
||||
test11["result"] = true
|
||||
test11["count"] = len(articles11)
|
||||
tests = append(tests, test11)
|
||||
|
||||
test12 := Map{"name": "IS NOT NULL 查询 ([!])"}
|
||||
articles12 := that.Db.Select("article", "id,title,create_time", Map{"create_time[!]": nil, "LIMIT": 3})
|
||||
test12["result"] = true
|
||||
test12["count"] = len(articles12)
|
||||
tests = append(tests, test12)
|
||||
|
||||
// SQL 片段查询根据数据库类型适配
|
||||
test13 := Map{"name": "直接 SQL 片段查询 ([##])"}
|
||||
var sqlFragment string
|
||||
switch that.Db.Type {
|
||||
case "dm", "dameng":
|
||||
sqlFragment = "\"create_time\" > DATEADD(DAY, -365, NOW())"
|
||||
default:
|
||||
sqlFragment = "create_time > DATE_SUB(NOW(), INTERVAL 365 DAY)"
|
||||
}
|
||||
articles13 := that.Db.Select("article", "id,title,create_time", Map{
|
||||
"[##]": sqlFragment,
|
||||
"LIMIT": 3,
|
||||
})
|
||||
test13["result"] = true
|
||||
test13["count"] = len(articles13)
|
||||
test13["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, test13)
|
||||
|
||||
test14 := Map{"name": "显式 AND 条件"}
|
||||
articles14 := that.Db.Select("article", "id,title,state,click_num", Map{
|
||||
"AND": Map{
|
||||
"state": 0,
|
||||
"click_num[>=]": 0,
|
||||
},
|
||||
"LIMIT": 3,
|
||||
})
|
||||
test14["result"] = true
|
||||
test14["count"] = len(articles14)
|
||||
tests = append(tests, test14)
|
||||
|
||||
test15 := Map{"name": "OR 条件"}
|
||||
articles15 := that.Db.Select("article", "id,title,sort,click_num", Map{
|
||||
"OR": Map{
|
||||
"sort": 0,
|
||||
"click_num[>]": 10,
|
||||
},
|
||||
"LIMIT": 5,
|
||||
})
|
||||
test15["result"] = true
|
||||
test15["count"] = len(articles15)
|
||||
tests = append(tests, test15)
|
||||
|
||||
test16 := Map{"name": "嵌套 AND/OR 条件"}
|
||||
articles16 := that.Db.Select("article", "id,title,sort,state", Map{
|
||||
"AND": Map{
|
||||
"state": 0,
|
||||
"OR": Map{
|
||||
"sort[>=]": 0,
|
||||
"click_num[>]": 0,
|
||||
},
|
||||
},
|
||||
"LIMIT": 5,
|
||||
})
|
||||
test16["result"] = true
|
||||
test16["count"] = len(articles16)
|
||||
test16["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, test16)
|
||||
|
||||
result["tests"] = tests
|
||||
result["success"] = true
|
||||
return result
|
||||
}
|
||||
|
||||
func testChainQuery(that *Context) Map {
|
||||
result := Map{"name": "链式查询测试", "tests": Slice{}}
|
||||
tests := Slice{}
|
||||
|
||||
test1 := Map{"name": "基本链式查询 Table().Where().Select()"}
|
||||
articles1 := that.Db.Table("article").
|
||||
Where("state", 0).
|
||||
Select("id,title,author")
|
||||
test1["result"] = len(articles1) >= 0
|
||||
test1["count"] = len(articles1)
|
||||
tests = append(tests, test1)
|
||||
|
||||
test2 := Map{"name": "链式 And 条件"}
|
||||
articles2 := that.Db.Table("article").
|
||||
Where("state", 0).
|
||||
And("click_num[>=]", 0).
|
||||
And("sort[>=]", 0).
|
||||
Select("id,title,click_num")
|
||||
test2["result"] = len(articles2) >= 0
|
||||
test2["count"] = len(articles2)
|
||||
tests = append(tests, test2)
|
||||
|
||||
test3 := Map{"name": "链式 Or 条件"}
|
||||
articles3 := that.Db.Table("article").
|
||||
Where("state", 0).
|
||||
Or(Map{
|
||||
"sort": 0,
|
||||
"click_num[>]": 10,
|
||||
}).
|
||||
Select("id,title,sort,click_num")
|
||||
test3["result"] = len(articles3) >= 0
|
||||
test3["count"] = len(articles3)
|
||||
tests = append(tests, test3)
|
||||
|
||||
test4 := Map{"name": "链式 Order 排序"}
|
||||
articles4 := that.Db.Table("article").
|
||||
Where("state", 0).
|
||||
Order("create_time DESC", "id ASC").
|
||||
Limit(0, 5).
|
||||
Select("id,title,create_time")
|
||||
test4["result"] = len(articles4) >= 0
|
||||
test4["count"] = len(articles4)
|
||||
tests = append(tests, test4)
|
||||
|
||||
test5 := Map{"name": "链式 Limit 限制"}
|
||||
articles5 := that.Db.Table("article").
|
||||
Where("state", 0).
|
||||
Limit(0, 3).
|
||||
Select("id,title")
|
||||
test5["result"] = len(articles5) <= 3
|
||||
test5["count"] = len(articles5)
|
||||
tests = append(tests, test5)
|
||||
|
||||
test6 := Map{"name": "链式 Get 获取单条"}
|
||||
article6 := that.Db.Table("article").
|
||||
Where("state", 0).
|
||||
Get("id,title,author")
|
||||
test6["result"] = article6 != nil || true
|
||||
test6["article"] = article6
|
||||
tests = append(tests, test6)
|
||||
|
||||
test7 := Map{"name": "链式 Count 统计"}
|
||||
count7 := that.Db.Table("article").
|
||||
Where("state", 0).
|
||||
Count()
|
||||
test7["result"] = count7 >= 0
|
||||
test7["count"] = count7
|
||||
tests = append(tests, test7)
|
||||
|
||||
test8 := Map{"name": "链式 Page 分页"}
|
||||
articles8 := that.Db.Table("article").
|
||||
Where("state", 0).
|
||||
Page(1, 5).
|
||||
Select("id,title")
|
||||
test8["result"] = len(articles8) <= 5
|
||||
test8["count"] = len(articles8)
|
||||
tests = append(tests, test8)
|
||||
|
||||
test9 := Map{"name": "链式 Group 分组"}
|
||||
stats9 := that.Db.Table("article").
|
||||
Where("state", 0).
|
||||
Group("ctg_id").
|
||||
Select("ctg_id, COUNT(*) as cnt")
|
||||
test9["result"] = len(stats9) >= 0
|
||||
test9["stats"] = stats9
|
||||
tests = append(tests, test9)
|
||||
|
||||
test10 := Map{"name": "链式 Update 更新"}
|
||||
testArticle := that.Db.Table("article").Where("state", 0).Get("id")
|
||||
if testArticle != nil {
|
||||
affected := that.Db.Table("article").
|
||||
Where("id", testArticle.GetInt64("id")).
|
||||
Update(Map{"modify_time[#]": nowFunc(that)})
|
||||
test10["result"] = affected >= 0
|
||||
test10["affected"] = affected
|
||||
} else {
|
||||
test10["result"] = true
|
||||
test10["note"] = "无可用测试数据"
|
||||
}
|
||||
tests = append(tests, test10)
|
||||
|
||||
result["tests"] = tests
|
||||
result["success"] = true
|
||||
return result
|
||||
}
|
||||
|
||||
func testJoinQuery(that *Context) Map {
|
||||
result := Map{"name": "JOIN查询测试", "tests": Slice{}}
|
||||
tests := Slice{}
|
||||
|
||||
test1 := Map{"name": "LEFT JOIN 链式查询"}
|
||||
articles1 := that.Db.Table("article").
|
||||
LeftJoin("ctg", "article.ctg_id = ctg.id").
|
||||
Where("article.state", 0).
|
||||
Limit(0, 5).
|
||||
Select("article.id, article.title, ctg.name as ctg_name")
|
||||
test1["result"] = len(articles1) >= 0
|
||||
test1["count"] = len(articles1)
|
||||
test1["data"] = articles1
|
||||
tests = append(tests, test1)
|
||||
|
||||
test2 := Map{"name": "传统 JOIN 语法"}
|
||||
articles2 := that.Db.Select("article",
|
||||
Slice{
|
||||
Map{"[>]ctg": "article.ctg_id = ctg.id"},
|
||||
},
|
||||
"article.id, article.title, ctg.name as ctg_name",
|
||||
Map{
|
||||
"article.state": 0,
|
||||
"LIMIT": 5,
|
||||
})
|
||||
test2["result"] = len(articles2) >= 0
|
||||
test2["count"] = len(articles2)
|
||||
test2["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, test2)
|
||||
|
||||
test3 := Map{"name": "多表 JOIN"}
|
||||
articles3 := that.Db.Table("article").
|
||||
LeftJoin("ctg", "article.ctg_id = ctg.id").
|
||||
LeftJoin("admin", "article.admin_id = admin.id").
|
||||
Where("article.state", 0).
|
||||
Limit(0, 5).
|
||||
Select("article.id, article.title, ctg.name as ctg_name, admin.name as admin_name")
|
||||
test3["result"] = len(articles3) >= 0
|
||||
test3["count"] = len(articles3)
|
||||
test3["data"] = articles3
|
||||
tests = append(tests, test3)
|
||||
|
||||
test4 := Map{"name": "INNER JOIN"}
|
||||
articles4 := that.Db.Table("article").
|
||||
InnerJoin("ctg", "article.ctg_id = ctg.id").
|
||||
Where("ctg.state", 0).
|
||||
Limit(0, 5).
|
||||
Select("article.id, article.title, ctg.name as ctg_name")
|
||||
test4["result"] = len(articles4) >= 0
|
||||
test4["count"] = len(articles4)
|
||||
tests = append(tests, test4)
|
||||
|
||||
result["tests"] = tests
|
||||
result["success"] = true
|
||||
return result
|
||||
}
|
||||
|
||||
func testAggregate(that *Context) Map {
|
||||
result := Map{"name": "聚合函数测试", "tests": Slice{}}
|
||||
tests := Slice{}
|
||||
|
||||
test1 := Map{"name": "Count 总数统计"}
|
||||
count1 := that.Db.Count("article")
|
||||
test1["result"] = count1 >= 0
|
||||
test1["count"] = count1
|
||||
tests = append(tests, test1)
|
||||
|
||||
test2 := Map{"name": "Count 条件统计"}
|
||||
count2 := that.Db.Count("article", Map{"state": 0})
|
||||
test2["result"] = count2 >= 0
|
||||
test2["count"] = count2
|
||||
tests = append(tests, test2)
|
||||
|
||||
test3 := Map{"name": "Sum 求和 (单字段名)"}
|
||||
sum3 := that.Db.Sum("article", "click_num", Map{"state": 0})
|
||||
test3["result"] = sum3 >= 0
|
||||
test3["sum"] = sum3
|
||||
test3["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, test3)
|
||||
|
||||
test4 := Map{"name": "Avg 平均值 (单字段名)"}
|
||||
avg4 := that.Db.Avg("article", "click_num", Map{"state": 0})
|
||||
test4["result"] = avg4 >= 0
|
||||
test4["avg"] = avg4
|
||||
test4["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, test4)
|
||||
|
||||
test5 := Map{"name": "Max 最大值 (单字段名)"}
|
||||
max5 := that.Db.Max("article", "click_num", Map{"state": 0})
|
||||
test5["result"] = max5 >= 0
|
||||
test5["max"] = max5
|
||||
test5["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, test5)
|
||||
|
||||
test6 := Map{"name": "Min 最小值 (单字段名)"}
|
||||
min6 := that.Db.Min("article", "sort", Map{"state": 0})
|
||||
test6["result"] = true
|
||||
test6["min"] = min6
|
||||
test6["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, test6)
|
||||
|
||||
test7 := Map{"name": "GROUP BY 分组统计"}
|
||||
stats7 := that.Db.Select("article",
|
||||
"ctg_id, COUNT(*) as article_count, AVG(click_num) as avg_clicks, SUM(click_num) as total_clicks",
|
||||
Map{
|
||||
"state": 0,
|
||||
"GROUP": "ctg_id",
|
||||
"ORDER": "article_count DESC",
|
||||
"LIMIT": 10,
|
||||
})
|
||||
test7["result"] = len(stats7) >= 0
|
||||
test7["stats"] = stats7
|
||||
tests = append(tests, test7)
|
||||
|
||||
test8 := Map{"name": "Sum 求和 (table.column 格式)"}
|
||||
sum8 := that.Db.Sum("article", "article.click_num", Map{"state": 0})
|
||||
test8["result"] = sum8 >= 0
|
||||
test8["sum"] = sum8
|
||||
test8["match_single_field"] = sum8 == sum3
|
||||
test8["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, test8)
|
||||
|
||||
test9 := Map{"name": "聚合函数一致性验证"}
|
||||
avg9 := that.Db.Avg("article", "article.click_num", Map{"state": 0})
|
||||
max10 := that.Db.Max("article", "article.click_num", Map{"state": 0})
|
||||
min11 := that.Db.Min("article", "article.sort", Map{"state": 0})
|
||||
allMatch := (sum8 == sum3) && (avg9 == avg4) && (max10 == max5) && (min11 == min6)
|
||||
test9["result"] = allMatch
|
||||
test9["summary"] = fmt.Sprintf("Sum: %v=%v, Avg: %v=%v, Max: %v=%v, Min: %v=%v",
|
||||
sum3, sum8, avg4, avg9, max5, max10, min6, min11)
|
||||
tests = append(tests, test9)
|
||||
|
||||
joinSlice := Slice{
|
||||
Map{"[>]ctg": "article.ctg_id = ctg.id"},
|
||||
}
|
||||
test10 := Map{"name": "Sum 带 JOIN (table.column 格式)"}
|
||||
sum10 := that.Db.Sum("article", "article.click_num", joinSlice, Map{"article.state": 0})
|
||||
test10["result"] = sum10 >= 0
|
||||
test10["sum"] = sum10
|
||||
test10["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, test10)
|
||||
|
||||
test11 := Map{"name": "Count 带 JOIN"}
|
||||
count11 := that.Db.Count("article", joinSlice, Map{"article.state": 0})
|
||||
test11["result"] = count11 >= 0
|
||||
test11["count"] = count11
|
||||
test11["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, test11)
|
||||
|
||||
result["tests"] = tests
|
||||
result["success"] = true
|
||||
return result
|
||||
}
|
||||
|
||||
func testPagination(that *Context) Map {
|
||||
result := Map{"name": "分页查询测试", "tests": Slice{}}
|
||||
tests := Slice{}
|
||||
|
||||
test1 := Map{"name": "PageSelect 分页查询"}
|
||||
articles1 := that.Db.Page(1, 5).PageSelect("article", "*", Map{
|
||||
"state": 0,
|
||||
"ORDER": "id DESC",
|
||||
})
|
||||
test1["result"] = len(articles1) <= 5
|
||||
test1["count"] = len(articles1)
|
||||
tests = append(tests, test1)
|
||||
|
||||
test2 := Map{"name": "PageSelect 第二页"}
|
||||
articles2 := that.Db.Page(2, 5).PageSelect("article", "*", Map{
|
||||
"state": 0,
|
||||
"ORDER": "id DESC",
|
||||
})
|
||||
test2["result"] = len(articles2) <= 5
|
||||
test2["count"] = len(articles2)
|
||||
tests = append(tests, test2)
|
||||
|
||||
test3 := Map{"name": "链式 Page 分页"}
|
||||
articles3 := that.Db.Table("article").
|
||||
Where("state", 0).
|
||||
Order("id DESC").
|
||||
Page(1, 3).
|
||||
Select("id,title,author")
|
||||
test3["result"] = len(articles3) <= 3
|
||||
test3["count"] = len(articles3)
|
||||
tests = append(tests, test3)
|
||||
|
||||
test4 := Map{"name": "Offset 偏移查询"}
|
||||
articles4 := that.Db.Table("article").
|
||||
Where("state", 0).
|
||||
Limit(3).
|
||||
Offset(2).
|
||||
Select("id,title")
|
||||
test4["result"] = len(articles4) <= 3
|
||||
test4["count"] = len(articles4)
|
||||
test4["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, test4)
|
||||
|
||||
result["tests"] = tests
|
||||
result["success"] = true
|
||||
return result
|
||||
}
|
||||
|
||||
func testInserts(that *Context) Map {
|
||||
result := Map{"name": "批量插入测试", "tests": Slice{}}
|
||||
tests := Slice{}
|
||||
now := nowFunc(that)
|
||||
|
||||
test1 := Map{"name": "Inserts 批量插入"}
|
||||
timestamp := time.Now().UnixNano()
|
||||
affected1 := that.Db.Inserts("test_batch", []Map{
|
||||
{"name": fmt.Sprintf("批量测试1_%d", timestamp), "title": "标题1", "state": 1},
|
||||
{"name": fmt.Sprintf("批量测试2_%d", timestamp), "title": "标题2", "state": 1},
|
||||
{"name": fmt.Sprintf("批量测试3_%d", timestamp), "title": "标题3", "state": 1},
|
||||
})
|
||||
test1["result"] = affected1 >= 0
|
||||
test1["affected"] = affected1
|
||||
test1["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, test1)
|
||||
|
||||
test2 := Map{"name": "Inserts 带 [#] 标记"}
|
||||
timestamp2 := time.Now().UnixNano()
|
||||
affected2 := that.Db.Inserts("test_batch", []Map{
|
||||
{"name": fmt.Sprintf("带时间测试1_%d", timestamp2), "title": "标题带时间1", "state": 1, "create_time[#]": now},
|
||||
{"name": fmt.Sprintf("带时间测试2_%d", timestamp2), "title": "标题带时间2", "state": 1, "create_time[#]": now},
|
||||
})
|
||||
test2["result"] = affected2 >= 0
|
||||
test2["affected"] = affected2
|
||||
tests = append(tests, test2)
|
||||
|
||||
that.Db.Delete("test_batch", Map{"name[~]": fmt.Sprintf("_%d", timestamp)})
|
||||
that.Db.Delete("test_batch", Map{"name[~]": fmt.Sprintf("_%d", timestamp2)})
|
||||
|
||||
result["tests"] = tests
|
||||
result["success"] = true
|
||||
return result
|
||||
}
|
||||
|
||||
func testUpsert(that *Context) Map {
|
||||
result := Map{"name": "Upsert测试", "tests": Slice{}}
|
||||
tests := Slice{}
|
||||
now := nowFunc(that)
|
||||
|
||||
timestamp := time.Now().Unix()
|
||||
testPhone := fmt.Sprintf("199%08d", timestamp%100000000)
|
||||
|
||||
test1 := Map{"name": "Upsert 插入新记录 (admin表)"}
|
||||
affected1 := that.Db.Upsert("admin",
|
||||
Map{
|
||||
"name": "Upsert测试管理员",
|
||||
"phone": testPhone,
|
||||
"state": 1,
|
||||
"password": "test123",
|
||||
"role_id": 1,
|
||||
"title": "测试职位",
|
||||
"create_time[#]": now,
|
||||
"modify_time[#]": now,
|
||||
},
|
||||
Slice{"phone"},
|
||||
Slice{"name", "state", "title", "modify_time"},
|
||||
)
|
||||
test1["result"] = affected1 >= 0
|
||||
test1["affected"] = affected1
|
||||
test1["lastQuery"] = that.Db.GetLastQuery()
|
||||
tests = append(tests, test1)
|
||||
|
||||
test2 := Map{"name": "Upsert 更新已存在记录"}
|
||||
affected2 := that.Db.Upsert("admin",
|
||||
Map{
|
||||
"name": "Upsert更新后管理员",
|
||||
"phone": testPhone,
|
||||
"state": 1,
|
||||
"password": "updated123",
|
||||
"role_id": 2,
|
||||
"title": "更新后职位",
|
||||
"create_time[#]": now,
|
||||
"modify_time[#]": now,
|
||||
},
|
||||
Slice{"phone"},
|
||||
Slice{"name", "title", "role_id", "modify_time"},
|
||||
)
|
||||
test2["result"] = affected2 >= 0
|
||||
test2["affected"] = affected2
|
||||
tests = append(tests, test2)
|
||||
|
||||
updatedAdmin := that.Db.Get("admin", "*", Map{"phone": testPhone})
|
||||
test2["updatedAdmin"] = updatedAdmin
|
||||
|
||||
that.Db.Delete("admin", Map{"phone": testPhone})
|
||||
|
||||
result["tests"] = tests
|
||||
result["success"] = true
|
||||
return result
|
||||
}
|
||||
|
||||
func testTransaction(that *Context) Map {
|
||||
result := Map{"name": "事务测试", "tests": Slice{}}
|
||||
tests := Slice{}
|
||||
now := nowFunc(that)
|
||||
|
||||
timestamp := time.Now().Unix()
|
||||
testName1 := fmt.Sprintf("事务测试_%d", timestamp)
|
||||
|
||||
test1 := Map{"name": "事务成功提交"}
|
||||
success1 := that.Db.Action(func(tx HoTimeDB) bool {
|
||||
recordId := tx.Insert("test_batch", Map{
|
||||
"name": testName1,
|
||||
"title": "事务提交测试",
|
||||
"state": 1,
|
||||
"create_time[#]": now,
|
||||
})
|
||||
return recordId != 0
|
||||
})
|
||||
test1["result"] = success1
|
||||
checkRecord := that.Db.Get("test_batch", "*", Map{"name": testName1})
|
||||
test1["recordExists"] = checkRecord != nil
|
||||
tests = append(tests, test1)
|
||||
|
||||
test2 := Map{"name": "事务回滚"}
|
||||
testName2 := fmt.Sprintf("事务回滚测试_%d", timestamp)
|
||||
success2 := that.Db.Action(func(tx HoTimeDB) bool {
|
||||
_ = tx.Insert("test_batch", Map{
|
||||
"name": testName2,
|
||||
"title": "事务回滚测试",
|
||||
"state": 1,
|
||||
"create_time[#]": now,
|
||||
})
|
||||
return false
|
||||
})
|
||||
test2["result"] = !success2
|
||||
checkRecord2 := that.Db.Get("test_batch", "*", Map{"name": testName2})
|
||||
test2["recordRolledBack"] = checkRecord2 == nil
|
||||
tests = append(tests, test2)
|
||||
|
||||
that.Db.Delete("test_batch", Map{"name": testName1})
|
||||
|
||||
result["tests"] = tests
|
||||
result["success"] = true
|
||||
return result
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 压测配置
|
||||
type BenchConfig struct {
|
||||
URL string
|
||||
Concurrency int
|
||||
Duration time.Duration
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// 压测结果
|
||||
type BenchResult struct {
|
||||
TotalRequests int64
|
||||
SuccessRequests int64
|
||||
FailedRequests int64
|
||||
TotalDuration time.Duration
|
||||
MinLatency int64 // 纳秒
|
||||
MaxLatency int64
|
||||
AvgLatency int64
|
||||
P50Latency int64 // 50分位
|
||||
P90Latency int64 // 90分位
|
||||
P99Latency int64 // 99分位
|
||||
QPS float64
|
||||
}
|
||||
|
||||
// 延迟收集器
|
||||
type LatencyCollector struct {
|
||||
latencies []int64
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (lc *LatencyCollector) Add(latency int64) {
|
||||
lc.mu.Lock()
|
||||
lc.latencies = append(lc.latencies, latency)
|
||||
lc.mu.Unlock()
|
||||
}
|
||||
|
||||
func (lc *LatencyCollector) GetPercentile(p float64) int64 {
|
||||
lc.mu.Lock()
|
||||
defer lc.mu.Unlock()
|
||||
|
||||
if len(lc.latencies) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// 简单排序取百分位
|
||||
n := len(lc.latencies)
|
||||
idx := int(float64(n) * p)
|
||||
if idx >= n {
|
||||
idx = n - 1
|
||||
}
|
||||
|
||||
// 部分排序找第idx个元素
|
||||
return quickSelect(lc.latencies, idx)
|
||||
}
|
||||
|
||||
func quickSelect(arr []int64, k int) int64 {
|
||||
if len(arr) == 1 {
|
||||
return arr[0]
|
||||
}
|
||||
|
||||
pivot := arr[len(arr)/2]
|
||||
var left, right, equal []int64
|
||||
|
||||
for _, v := range arr {
|
||||
if v < pivot {
|
||||
left = append(left, v)
|
||||
} else if v > pivot {
|
||||
right = append(right, v)
|
||||
} else {
|
||||
equal = append(equal, v)
|
||||
}
|
||||
}
|
||||
|
||||
if k < len(left) {
|
||||
return quickSelect(left, k)
|
||||
} else if k < len(left)+len(equal) {
|
||||
return pivot
|
||||
}
|
||||
return quickSelect(right, k-len(left)-len(equal))
|
||||
}
|
||||
|
||||
func main() {
|
||||
// 最大化利用CPU
|
||||
runtime.GOMAXPROCS(runtime.NumCPU())
|
||||
|
||||
fmt.Println("==========================================")
|
||||
fmt.Println(" 🔥 HoTime 极限压力测试 🔥")
|
||||
fmt.Println("==========================================")
|
||||
fmt.Printf("CPU 核心数: %d\n", runtime.NumCPU())
|
||||
fmt.Println()
|
||||
|
||||
// 极限测试配置
|
||||
configs := []BenchConfig{
|
||||
{URL: "http://127.0.0.1:8081/app/test/hello", Concurrency: 500, Duration: 15 * time.Second, Timeout: 10 * time.Second},
|
||||
{URL: "http://127.0.0.1:8081/app/test/hello", Concurrency: 1000, Duration: 15 * time.Second, Timeout: 10 * time.Second},
|
||||
{URL: "http://127.0.0.1:8081/app/test/hello", Concurrency: 2000, Duration: 15 * time.Second, Timeout: 10 * time.Second},
|
||||
{URL: "http://127.0.0.1:8081/app/test/hello", Concurrency: 5000, Duration: 15 * time.Second, Timeout: 10 * time.Second},
|
||||
{URL: "http://127.0.0.1:8081/app/test/hello", Concurrency: 10000, Duration: 15 * time.Second, Timeout: 10 * time.Second},
|
||||
}
|
||||
|
||||
// 检查服务
|
||||
fmt.Println("正在检查服务是否可用...")
|
||||
if !checkService(configs[0].URL) {
|
||||
fmt.Println("❌ 服务不可用,请先启动示例应用")
|
||||
return
|
||||
}
|
||||
fmt.Println("✅ 服务已就绪")
|
||||
fmt.Println()
|
||||
|
||||
// 预热
|
||||
fmt.Println("🔄 预热中 (5秒)...")
|
||||
warmup(configs[0].URL, 100, 5*time.Second)
|
||||
fmt.Println("✅ 预热完成")
|
||||
fmt.Println()
|
||||
|
||||
var maxQPS float64
|
||||
var maxConcurrency int
|
||||
|
||||
// 执行极限测试
|
||||
for i, config := range configs {
|
||||
fmt.Printf("══════════════════════════════════════════\n")
|
||||
fmt.Printf("【极限测试 %d】并发数: %d, 持续时间: %v\n", i+1, config.Concurrency, config.Duration)
|
||||
fmt.Printf("══════════════════════════════════════════\n")
|
||||
|
||||
result := runBenchmark(config)
|
||||
printResult(result)
|
||||
|
||||
if result.QPS > maxQPS {
|
||||
maxQPS = result.QPS
|
||||
maxConcurrency = config.Concurrency
|
||||
}
|
||||
|
||||
// 检查是否达到瓶颈
|
||||
successRate := float64(result.SuccessRequests) / float64(result.TotalRequests) * 100
|
||||
if successRate < 95 {
|
||||
fmt.Println("\n⚠️ 成功率低于95%,已达到服务极限!")
|
||||
break
|
||||
}
|
||||
|
||||
if result.AvgLatency > int64(100*time.Millisecond) {
|
||||
fmt.Println("\n⚠️ 平均延迟超过100ms,已达到服务极限!")
|
||||
break
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
|
||||
// 测试间隔
|
||||
if i < len(configs)-1 {
|
||||
fmt.Println("冷却 5 秒...")
|
||||
time.Sleep(5 * time.Second)
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
// 最终报告
|
||||
fmt.Println()
|
||||
fmt.Println("══════════════════════════════════════════")
|
||||
fmt.Println(" 📊 极限测试总结")
|
||||
fmt.Println("══════════════════════════════════════════")
|
||||
fmt.Printf("最高 QPS: %.2f 请求/秒\n", maxQPS)
|
||||
fmt.Printf("最佳并发数: %d\n", maxConcurrency)
|
||||
fmt.Println()
|
||||
|
||||
// 并发用户估算
|
||||
estimateUsers(maxQPS)
|
||||
}
|
||||
|
||||
func checkService(url string) bool {
|
||||
client := &http.Client{Timeout: 3 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return resp.StatusCode == 200
|
||||
}
|
||||
|
||||
func warmup(url string, concurrency int, duration time.Duration) {
|
||||
client := &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: concurrency * 2,
|
||||
MaxIdleConnsPerHost: concurrency * 2,
|
||||
},
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < concurrency; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
resp, err := client.Get(url)
|
||||
if err == nil {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
time.Sleep(duration)
|
||||
close(done)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func runBenchmark(config BenchConfig) BenchResult {
|
||||
var (
|
||||
totalRequests int64
|
||||
successRequests int64
|
||||
failedRequests int64
|
||||
totalLatency int64
|
||||
minLatency int64 = int64(time.Hour)
|
||||
maxLatency int64
|
||||
mu sync.Mutex
|
||||
)
|
||||
|
||||
collector := &LatencyCollector{
|
||||
latencies: make([]int64, 0, 100000),
|
||||
}
|
||||
|
||||
// 高性能HTTP客户端
|
||||
client := &http.Client{
|
||||
Timeout: config.Timeout,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: config.Concurrency * 2,
|
||||
MaxIdleConnsPerHost: config.Concurrency * 2,
|
||||
MaxConnsPerHost: config.Concurrency * 2,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
DisableKeepAlives: false,
|
||||
DisableCompression: true,
|
||||
},
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// 启动并发
|
||||
for i := 0; i < config.Concurrency; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
reqStart := time.Now()
|
||||
success := makeRequest(client, config.URL)
|
||||
latency := time.Since(reqStart).Nanoseconds()
|
||||
|
||||
atomic.AddInt64(&totalRequests, 1)
|
||||
atomic.AddInt64(&totalLatency, latency)
|
||||
|
||||
if success {
|
||||
atomic.AddInt64(&successRequests, 1)
|
||||
} else {
|
||||
atomic.AddInt64(&failedRequests, 1)
|
||||
}
|
||||
|
||||
// 采样收集延迟(每100个请求采样1个,减少内存开销)
|
||||
if atomic.LoadInt64(&totalRequests)%100 == 0 {
|
||||
collector.Add(latency)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
if latency < minLatency {
|
||||
minLatency = latency
|
||||
}
|
||||
if latency > maxLatency {
|
||||
maxLatency = latency
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
time.Sleep(config.Duration)
|
||||
close(done)
|
||||
wg.Wait()
|
||||
|
||||
totalDuration := time.Since(startTime)
|
||||
|
||||
result := BenchResult{
|
||||
TotalRequests: totalRequests,
|
||||
SuccessRequests: successRequests,
|
||||
FailedRequests: failedRequests,
|
||||
TotalDuration: totalDuration,
|
||||
MinLatency: minLatency,
|
||||
MaxLatency: maxLatency,
|
||||
P50Latency: collector.GetPercentile(0.50),
|
||||
P90Latency: collector.GetPercentile(0.90),
|
||||
P99Latency: collector.GetPercentile(0.99),
|
||||
}
|
||||
|
||||
if totalRequests > 0 {
|
||||
result.AvgLatency = totalLatency / totalRequests
|
||||
result.QPS = float64(totalRequests) / totalDuration.Seconds()
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func makeRequest(client *http.Client, url string) bool {
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return false
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if status, ok := result["status"].(float64); !ok || status != 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func printResult(result BenchResult) {
|
||||
successRate := float64(result.SuccessRequests) / float64(result.TotalRequests) * 100
|
||||
|
||||
fmt.Printf("总请求数: %d\n", result.TotalRequests)
|
||||
fmt.Printf("成功请求: %d\n", result.SuccessRequests)
|
||||
fmt.Printf("失败请求: %d\n", result.FailedRequests)
|
||||
fmt.Printf("成功率: %.2f%%\n", successRate)
|
||||
fmt.Printf("总耗时: %v\n", result.TotalDuration.Round(time.Millisecond))
|
||||
fmt.Printf("QPS: %.2f 请求/秒\n", result.QPS)
|
||||
fmt.Println("------------------------------------------")
|
||||
fmt.Printf("最小延迟: %v\n", time.Duration(result.MinLatency).Round(time.Microsecond))
|
||||
fmt.Printf("平均延迟: %v\n", time.Duration(result.AvgLatency).Round(time.Microsecond))
|
||||
fmt.Printf("P50延迟: %v\n", time.Duration(result.P50Latency).Round(time.Microsecond))
|
||||
fmt.Printf("P90延迟: %v\n", time.Duration(result.P90Latency).Round(time.Microsecond))
|
||||
fmt.Printf("P99延迟: %v\n", time.Duration(result.P99Latency).Round(time.Microsecond))
|
||||
fmt.Printf("最大延迟: %v\n", time.Duration(result.MaxLatency).Round(time.Microsecond))
|
||||
|
||||
// 性能评级
|
||||
fmt.Print("\n性能评级: ")
|
||||
switch {
|
||||
case result.QPS >= 200000:
|
||||
fmt.Println("🏆 卓越 (QPS >= 200K)")
|
||||
case result.QPS >= 100000:
|
||||
fmt.Println("🚀 优秀 (QPS >= 100K)")
|
||||
case result.QPS >= 50000:
|
||||
fmt.Println("⭐ 良好 (QPS >= 50K)")
|
||||
case result.QPS >= 20000:
|
||||
fmt.Println("👍 中上 (QPS >= 20K)")
|
||||
case result.QPS >= 10000:
|
||||
fmt.Println("📊 中等 (QPS >= 10K)")
|
||||
default:
|
||||
fmt.Println("⚠️ 一般 (QPS < 10K)")
|
||||
}
|
||||
}
|
||||
|
||||
func estimateUsers(maxQPS float64) {
|
||||
fmt.Println("📈 并发用户数估算(基于不同使用场景):")
|
||||
fmt.Println("------------------------------------------")
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
requestInterval float64 // 用户平均请求间隔(秒)
|
||||
}{
|
||||
{"高频交互(每秒1次请求)", 1},
|
||||
{"活跃用户(每5秒1次请求)", 5},
|
||||
{"普通浏览(每10秒1次请求)", 10},
|
||||
{"低频访问(每30秒1次请求)", 30},
|
||||
{"偶尔访问(每60秒1次请求)", 60},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
users := maxQPS * s.requestInterval
|
||||
fmt.Printf("%-30s ~%d 用户\n", s.name, int(users))
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("💡 实际生产环境建议保留 30-50% 性能余量")
|
||||
fmt.Printf(" 安全并发用户数: %d - %d (普通浏览场景)\n",
|
||||
int(maxQPS*10*0.5), int(maxQPS*10*0.7))
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
runtime.GOMAXPROCS(runtime.NumCPU())
|
||||
|
||||
fmt.Println("==========================================")
|
||||
fmt.Println(" 🔥 HoTime 极限压力测试 🔥")
|
||||
fmt.Println("==========================================")
|
||||
fmt.Printf("CPU 核心数: %d\n", runtime.NumCPU())
|
||||
fmt.Println()
|
||||
|
||||
baseURL := "http://127.0.0.1:8081/app/test/hello"
|
||||
|
||||
// 检查服务
|
||||
fmt.Println("正在检查服务是否可用...")
|
||||
client := &http.Client{Timeout: 3 * time.Second}
|
||||
resp, err := client.Get(baseURL)
|
||||
if err != nil || resp.StatusCode != 200 {
|
||||
fmt.Println("❌ 服务不可用,请先启动示例应用")
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
fmt.Println("✅ 服务已就绪")
|
||||
fmt.Println()
|
||||
|
||||
// 预热
|
||||
fmt.Println("🔄 预热中 (5秒)...")
|
||||
doWarmup(baseURL, 100, 5*time.Second)
|
||||
fmt.Println("✅ 预热完成")
|
||||
fmt.Println()
|
||||
|
||||
// 极限测试配置
|
||||
concurrencyLevels := []int{500, 1000, 2000, 5000, 10000}
|
||||
testDuration := 15 * time.Second
|
||||
|
||||
var maxQPS float64
|
||||
var maxConcurrency int
|
||||
|
||||
for i, concurrency := range concurrencyLevels {
|
||||
fmt.Printf("══════════════════════════════════════════\n")
|
||||
fmt.Printf("【极限测试 %d】并发数: %d, 持续时间: %v\n", i+1, concurrency, testDuration)
|
||||
fmt.Printf("══════════════════════════════════════════\n")
|
||||
|
||||
total, success, failed, qps, minLat, avgLat, maxLat, p50, p90, p99 := doBenchmark(baseURL, concurrency, testDuration)
|
||||
|
||||
successRate := float64(success) / float64(total) * 100
|
||||
|
||||
fmt.Printf("总请求数: %d\n", total)
|
||||
fmt.Printf("成功请求: %d\n", success)
|
||||
fmt.Printf("失败请求: %d\n", failed)
|
||||
fmt.Printf("成功率: %.2f%%\n", successRate)
|
||||
fmt.Printf("QPS: %.2f 请求/秒\n", qps)
|
||||
fmt.Println("------------------------------------------")
|
||||
fmt.Printf("最小延迟: %v\n", time.Duration(minLat))
|
||||
fmt.Printf("平均延迟: %v\n", time.Duration(avgLat))
|
||||
fmt.Printf("P50延迟: %v\n", time.Duration(p50))
|
||||
fmt.Printf("P90延迟: %v\n", time.Duration(p90))
|
||||
fmt.Printf("P99延迟: %v\n", time.Duration(p99))
|
||||
fmt.Printf("最大延迟: %v\n", time.Duration(maxLat))
|
||||
|
||||
// 性能评级
|
||||
fmt.Print("\n性能评级: ")
|
||||
switch {
|
||||
case qps >= 200000:
|
||||
fmt.Println("🏆 卓越 (QPS >= 200K)")
|
||||
case qps >= 100000:
|
||||
fmt.Println("🚀 优秀 (QPS >= 100K)")
|
||||
case qps >= 50000:
|
||||
fmt.Println("⭐ 良好 (QPS >= 50K)")
|
||||
case qps >= 20000:
|
||||
fmt.Println("👍 中上 (QPS >= 20K)")
|
||||
case qps >= 10000:
|
||||
fmt.Println("📊 中等 (QPS >= 10K)")
|
||||
default:
|
||||
fmt.Println("⚠️ 一般 (QPS < 10K)")
|
||||
}
|
||||
|
||||
if qps > maxQPS {
|
||||
maxQPS = qps
|
||||
maxConcurrency = concurrency
|
||||
}
|
||||
|
||||
// 检查是否达到瓶颈
|
||||
if successRate < 95 {
|
||||
fmt.Println("\n⚠️ 成功率低于95%,已达到服务极限!")
|
||||
break
|
||||
}
|
||||
if avgLat > int64(100*time.Millisecond) {
|
||||
fmt.Println("\n⚠️ 平均延迟超过100ms,已达到服务极限!")
|
||||
break
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
if i < len(concurrencyLevels)-1 {
|
||||
fmt.Println("冷却 5 秒...")
|
||||
time.Sleep(5 * time.Second)
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
// 最终报告
|
||||
fmt.Println()
|
||||
fmt.Println("══════════════════════════════════════════")
|
||||
fmt.Println(" 📊 极限测试总结")
|
||||
fmt.Println("══════════════════════════════════════════")
|
||||
fmt.Printf("最高 QPS: %.2f 请求/秒\n", maxQPS)
|
||||
fmt.Printf("最佳并发数: %d\n", maxConcurrency)
|
||||
fmt.Println()
|
||||
|
||||
// 并发用户估算
|
||||
fmt.Println("📈 并发用户数估算:")
|
||||
fmt.Println("------------------------------------------")
|
||||
fmt.Printf("高频交互(1秒/次): ~%d 用户\n", int(maxQPS*1))
|
||||
fmt.Printf("活跃用户(5秒/次): ~%d 用户\n", int(maxQPS*5))
|
||||
fmt.Printf("普通浏览(10秒/次): ~%d 用户\n", int(maxQPS*10))
|
||||
fmt.Printf("低频访问(30秒/次): ~%d 用户\n", int(maxQPS*30))
|
||||
fmt.Println()
|
||||
fmt.Println("💡 生产环境建议保留 30-50% 性能余量")
|
||||
fmt.Printf(" 安全并发用户数: %d - %d (普通浏览场景)\n",
|
||||
int(maxQPS*10*0.5), int(maxQPS*10*0.7))
|
||||
}
|
||||
|
||||
func doWarmup(url string, concurrency int, duration time.Duration) {
|
||||
client := &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: concurrency * 2,
|
||||
MaxIdleConnsPerHost: concurrency * 2,
|
||||
},
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < concurrency; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
resp, err := client.Get(url)
|
||||
if err == nil {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
time.Sleep(duration)
|
||||
close(done)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func doBenchmark(url string, concurrency int, duration time.Duration) (total, success, failed int64, qps float64, minLat, avgLat, maxLat, p50, p90, p99 int64) {
|
||||
var totalLatency int64
|
||||
minLat = int64(time.Hour)
|
||||
var mu sync.Mutex
|
||||
|
||||
// 采样收集器
|
||||
latencies := make([]int64, 0, 50000)
|
||||
var latMu sync.Mutex
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: concurrency * 2,
|
||||
MaxIdleConnsPerHost: concurrency * 2,
|
||||
MaxConnsPerHost: concurrency * 2,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
DisableCompression: true,
|
||||
},
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
startTime := time.Now()
|
||||
|
||||
for i := 0; i < concurrency; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
reqStart := time.Now()
|
||||
ok := doRequest(client, url)
|
||||
lat := time.Since(reqStart).Nanoseconds()
|
||||
|
||||
atomic.AddInt64(&total, 1)
|
||||
atomic.AddInt64(&totalLatency, lat)
|
||||
|
||||
if ok {
|
||||
atomic.AddInt64(&success, 1)
|
||||
} else {
|
||||
atomic.AddInt64(&failed, 1)
|
||||
}
|
||||
|
||||
// 采样 (每50个采1个)
|
||||
if atomic.LoadInt64(&total)%50 == 0 {
|
||||
latMu.Lock()
|
||||
latencies = append(latencies, lat)
|
||||
latMu.Unlock()
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
if lat < minLat {
|
||||
minLat = lat
|
||||
}
|
||||
if lat > maxLat {
|
||||
maxLat = lat
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
time.Sleep(duration)
|
||||
close(done)
|
||||
wg.Wait()
|
||||
|
||||
totalDuration := time.Since(startTime)
|
||||
|
||||
if total > 0 {
|
||||
avgLat = totalLatency / total
|
||||
qps = float64(total) / totalDuration.Seconds()
|
||||
}
|
||||
|
||||
// 计算百分位
|
||||
if len(latencies) > 0 {
|
||||
p50 = getPercentile(latencies, 0.50)
|
||||
p90 = getPercentile(latencies, 0.90)
|
||||
p99 = getPercentile(latencies, 0.99)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func doRequest(client *http.Client, url string) bool {
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return false
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if status, ok := result["status"].(float64); !ok || status != 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func getPercentile(arr []int64, p float64) int64 {
|
||||
n := len(arr)
|
||||
if n == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// 复制一份避免修改原数组
|
||||
tmp := make([]int64, n)
|
||||
copy(tmp, arr)
|
||||
|
||||
idx := int(float64(n) * p)
|
||||
if idx >= n {
|
||||
idx = n - 1
|
||||
}
|
||||
|
||||
return quickSelect(tmp, idx)
|
||||
}
|
||||
|
||||
func quickSelect(arr []int64, k int) int64 {
|
||||
if len(arr) == 1 {
|
||||
return arr[0]
|
||||
}
|
||||
|
||||
pivot := arr[len(arr)/2]
|
||||
var left, right, equal []int64
|
||||
|
||||
for _, v := range arr {
|
||||
if v < pivot {
|
||||
left = append(left, v)
|
||||
} else if v > pivot {
|
||||
right = append(right, v)
|
||||
} else {
|
||||
equal = append(equal, v)
|
||||
}
|
||||
}
|
||||
|
||||
if k < len(left) {
|
||||
return quickSelect(left, k)
|
||||
} else if k < len(left)+len(equal) {
|
||||
return pivot
|
||||
}
|
||||
return quickSelect(right, k-len(left)-len(equal))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user