Compare commits
120 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| d314891126 | |||
| c0ae31f499 | |||
| 25edb6b5b7 | |||
| ead64dc776 | |||
| 876e3b1f45 | |||
| d6e940803d | |||
| 203e72f8a2 | |||
| 541c82410e | |||
| b680112fdb | |||
| 4ab5ab9ff4 | |||
| b425024359 | |||
| 78337c14ec | |||
| cdba8af129 | |||
| 9a7426180d | |||
| b7131603c4 | |||
| 2f3a5a0a59 | |||
| c2468a7389 | |||
| b6ba3486d6 | |||
| f59e909d30 | |||
| 3bb47de45a | |||
| c71973850e | |||
| 65f062b860 | |||
| 7b502fed36 | |||
| fd6b15bdaf | |||
| f31bf24ec2 | |||
| 30edb4d9fa | |||
| a32c3a1589 | |||
| 127e027940 | |||
| 2ee0838d8a | |||
| 72c23499f2 | |||
| 96d8868694 | |||
| 1533a57cb8 | |||
| 615f52d2e4 | |||
| f7dfd4ec77 | |||
| 2276aa12ef | |||
| 6505594fd0 | |||
| c55e26021b | |||
| 78420a692f | |||
| 6d9af149ad | |||
| 253cad35ef | |||
| 47c8736f34 | |||
| dcce8ace9e | |||
| 125ccc5c3b | |||
| 432d59d483 | |||
| 60f222b011 | |||
| 7a8b86ed12 | |||
| b940afa6b0 | |||
| 8afd00ff86 | |||
| 8992fd6d3a |
@@ -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("小帮菜 API", "2.1.0", testApp.Config.GetString("tpt"))
|
||||||
|
os.Exit(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApi(t *testing.T) {
|
||||||
|
testApp.RunTests(t)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 五、Swagger:多项目合并为一份文档
|
||||||
|
|
||||||
|
`GenerateSwagger` 遍历 `TestProj` 中所有项目的所有测试数据,生成**一份合并的 OpenAPI JSON**。每个项目作为 Swagger 的 tag 分组:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tags": [
|
||||||
|
{"name": "app/user", "description": "用户接口"},
|
||||||
|
{"name": "app/goods", "description": "货品接口"},
|
||||||
|
{"name": "customer/customer", "description": "客户端接口"}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"/app/user/login": { ... },
|
||||||
|
"/app/user/info": { ... },
|
||||||
|
"/customer/customer/bindPhone": { ... }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
多项目不会覆盖,全部汇聚在同一个文件中,按 tag 分组展示。
|
||||||
|
|
||||||
|
## 六、指定运行范围
|
||||||
|
|
||||||
|
Go 的 `-run` 参数支持按子测试层级过滤(`/` 分隔正则匹配):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 全部
|
||||||
|
go test ./app/... -v
|
||||||
|
|
||||||
|
# 只跑 user 模块的所有接口
|
||||||
|
go test ./app/... -v -run TestApi/user
|
||||||
|
|
||||||
|
# 只跑 user 模块的 login 接口
|
||||||
|
go test ./app/... -v -run TestApi/user/login
|
||||||
|
|
||||||
|
# 只跑 login 接口的"密码为空"用例
|
||||||
|
go test ./app/... -v -run TestApi/user/login/密码为空
|
||||||
|
|
||||||
|
# 跑 user 和 goods 两个模块
|
||||||
|
go test ./app/... -v -run "TestApi/(user|goods)"
|
||||||
|
|
||||||
|
# 只跑所有模块中包含"未登录"的用例
|
||||||
|
go test ./app/... -v -run "TestApi/.*/.*未登录"
|
||||||
|
```
|
||||||
|
|
||||||
|
**不需要框架做任何额外处理**,Go 的 `t.Run()` 子测试机制天然支持。
|
||||||
|
|
||||||
|
## 七、框架类型设计
|
||||||
|
|
||||||
|
```go
|
||||||
|
// === 注册类型 ===
|
||||||
|
type TestProj map[string]TestProjDef
|
||||||
|
type TestProjDef struct { Proj Proj; Tests ProjTest }
|
||||||
|
type ProjTest map[string]CtrTest
|
||||||
|
type CtrTest map[string]ApiTestDef
|
||||||
|
type ApiTestDef struct { Desc string; Func func(a *Api) }
|
||||||
|
|
||||||
|
// === Api:起点,数据设置 ===
|
||||||
|
type Api struct {
|
||||||
|
app *TestApp
|
||||||
|
path string
|
||||||
|
t interface{}
|
||||||
|
desc string
|
||||||
|
session Map
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数据设置(返回 *ApiCase,开始构建)
|
||||||
|
func (a *Api) JSON(body interface{}) *ApiCase
|
||||||
|
func (a *Api) Query(params Map) *ApiCase
|
||||||
|
func (a *Api) Form(body Map) *ApiCase
|
||||||
|
func (a *Api) File(field, name string, content []byte) *ApiCase
|
||||||
|
|
||||||
|
// Session(返回新 *Api,后续调用都带此 Session)
|
||||||
|
func (a *Api) WithSession(s Map) *Api
|
||||||
|
|
||||||
|
// 直接终端(无数据时直接调用)
|
||||||
|
func (a *Api) Get(desc string, status int, msg ...string) *ApiResponse
|
||||||
|
func (a *Api) Post(desc string, status int, msg ...string) *ApiResponse
|
||||||
|
func (a *Api) Put(desc string, status int, msg ...string) *ApiResponse
|
||||||
|
func (a *Api) Delete(desc string, status int, msg ...string) *ApiResponse
|
||||||
|
|
||||||
|
// 数据库
|
||||||
|
func (a *Api) DB() *HoTimeDB
|
||||||
|
|
||||||
|
// === ApiCase:链式构建器 ===
|
||||||
|
type ApiCase struct { /* api, session, query, jsonBody, formBody, file... */ }
|
||||||
|
|
||||||
|
// 继续叠加数据
|
||||||
|
func (c *ApiCase) JSON(body interface{}) *ApiCase
|
||||||
|
func (c *ApiCase) Query(params Map) *ApiCase
|
||||||
|
func (c *ApiCase) Form(body Map) *ApiCase
|
||||||
|
func (c *ApiCase) File(field, name string, content []byte) *ApiCase
|
||||||
|
func (c *ApiCase) WithSession(s Map) *ApiCase
|
||||||
|
|
||||||
|
// 终端操作(HTTP 方法 + 用例名 + 断言)
|
||||||
|
func (c *ApiCase) Get(desc string, status int, msg ...string) *ApiResponse
|
||||||
|
func (c *ApiCase) Post(desc string, status int, msg ...string) *ApiResponse
|
||||||
|
func (c *ApiCase) Put(desc string, status int, msg ...string) *ApiResponse
|
||||||
|
func (c *ApiCase) Delete(desc string, status int, msg ...string) *ApiResponse
|
||||||
|
|
||||||
|
// === ApiResponse ===
|
||||||
|
type ApiResponse struct { StatusCode int; Body Map; RawBody []byte }
|
||||||
|
func (r *ApiResponse) GetStatus() int
|
||||||
|
func (r *ApiResponse) GetResult() interface{}
|
||||||
|
func (r *ApiResponse) GetMsg() string
|
||||||
|
func (r *ApiResponse) GetBody() Map
|
||||||
|
func (r *ApiResponse) Fail(msg string) // 自定义断言失败
|
||||||
|
```
|
||||||
|
|
||||||
|
## 八、文件清单
|
||||||
|
|
||||||
|
- **新增** `d:/work/hotimev1.5/testing_helper.go`
|
||||||
|
- **新增** `d:/work/hotimev1.5/testing_api.go`
|
||||||
|
- **新增** `d:/work/hotimev1.5/testing_swagger.go`
|
||||||
|
- **修改** `d:/work/xbc/app/user.go` -- 末尾添加 UserTest
|
||||||
|
- **修改** `d:/work/xbc/app/init.go` -- 添加 ProjectTest
|
||||||
|
- **新增** `d:/work/xbc/app/app_test.go`
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
---
|
||||||
|
name: API测试框架完整实现
|
||||||
|
overview: 在 hotimev1.5 框架中实现完整的 API 测试基础设施:链式测试 API、事务回滚隔离、覆盖率追踪、Swagger 文档自动生成。
|
||||||
|
todos:
|
||||||
|
- id: db-testtx
|
||||||
|
content: db/db.go:加 testTx 字段 + BeginTestTx/RollbackTestTx 方法
|
||||||
|
status: completed
|
||||||
|
- id: db-query
|
||||||
|
content: db/query.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. 修改 [xbc/app/user.go](d:/work/xbc/app/user.go) -- 末尾添加 `var UserTest = CtrTest{...}`
|
||||||
|
|
||||||
|
```go
|
||||||
|
var UserTest = CtrTest{
|
||||||
|
"login": {"用户登录", func(a *Api) {
|
||||||
|
a.JSON(Map{"name": "138", "password": "123456"}).Post("正常登录", 0)
|
||||||
|
// ...
|
||||||
|
}},
|
||||||
|
"create": {"用户注册", func(a *Api) {
|
||||||
|
phone := "138" + ObjToStr(RandX(10000000, 99999999))
|
||||||
|
// 不需要 defer 清理,事务自动回滚
|
||||||
|
a.JSON(Map{"phone": phone, "password": "123456"}).Post("正常注册", 0)
|
||||||
|
a.JSON(Map{"phone": phone, "password": "123456"}).Post("重复注册", 3, "该手机号已被注册")
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. 修改 [xbc/app/init.go](d:/work/xbc/app/init.go) -- 添加 `var ProjectTest = ProjTest{...}`
|
||||||
|
|
||||||
|
### 9. 新增 [xbc/app/app_test.go](d:/work/xbc/app/app_test.go)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
testApp = NewTestApp("../config/config.json", TestProj{
|
||||||
|
"app": {Project, ProjectTest},
|
||||||
|
})
|
||||||
|
code := m.Run()
|
||||||
|
testApp.PrintCoverage()
|
||||||
|
testApp.GenerateSwagger("小帮菜 API", "2.1.0", testApp.Config.GetString("tpt"))
|
||||||
|
os.Exit(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApi(t *testing.T) {
|
||||||
|
testApp.RunTests(t)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 五、文件改动汇总
|
||||||
|
|
||||||
|
### hotimev1.5 框架层
|
||||||
|
- **修改** `db/db.go` -- 加 testTx + testMu 字段,BeginTestTx(初始化 mutex)/RollbackTestTx
|
||||||
|
- **修改** `db/query.go` -- Query/Exec 各加 testTx 判断 + testMu 互斥锁保护 + 跳过重试逻辑
|
||||||
|
- **修改** `db/transaction.go` -- Action 加 SAVEPOINT 分支 + testTx/testMu 传递 + SAVEPOINT 操作加锁
|
||||||
|
- **修改** `db/crud.go` -- Select 中 testTx 激活时跳过缓存逻辑
|
||||||
|
- **修改** `cache/cache.go` -- 新增 DisableDbCache() 方法 + 新增 SessionsGet/SessionsSet/SessionsDelete 批量操作
|
||||||
|
- **修改** `session.go` -- 对接批量 Session 缓存操作
|
||||||
|
- **修改** `testing_helper.go` -- NewTestApp 中调用 DisableDbCache()
|
||||||
|
- **新增** `testing_helper.go` -- TestApp、RunTests、覆盖率
|
||||||
|
- **新增** `testing_api.go` -- 链式 API + TestCollector
|
||||||
|
- **新增** `testing_swagger.go` -- Swagger 生成
|
||||||
|
|
||||||
|
### xbc 业务层
|
||||||
|
- **修改** `app/user.go` -- 末尾添加 UserTest
|
||||||
|
- **修改** `app/init.go` -- 添加 ProjectTest
|
||||||
|
- **新增** `app/app_test.go` -- 测试入口(含 Swagger 生成)
|
||||||
|
- **新增** `app/*_test.go` -- 30+ 个控制器测试文件
|
||||||
|
- **新增** `wx/wx_test.go` + `wx/*_test.go` -- 微信端测试入口 + 7 个控制器测试
|
||||||
|
- **新增** `dd/dd_test.go` + `dd/*_test.go` -- 钉钉端测试入口 + 6 个控制器测试
|
||||||
|
- **新增** `customer/customer_h5_test.go` + `customer/*_test.go` -- H5 客户端测试
|
||||||
|
- **新增** `sms/sms_test.go` + `sms/sms_ctr_test.go` -- 短信服务测试
|
||||||
|
- **修改** `app/wechat.go` -- 修复 log.Println -> log.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/xbc/docs/Testing_API测试框架.md)
|
||||||
|
|
||||||
|
**SQL 日志行为(框架自动处理)**
|
||||||
|
|
||||||
|
- 任何粒度运行:通过的用例零 SQL 输出,失败的用例自动输出完整 SQL 链路
|
||||||
|
- 无需手动控制,框架通过 buffer 机制自动按 pass/fail 决定输出
|
||||||
|
|
||||||
|
### Skill 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.cursor/skills/hotime-tdd-testing/
|
||||||
|
SKILL.md -- 主文件(TDD 流程 + API 速查 + 运行指令)
|
||||||
|
```
|
||||||
|
|
||||||
|
直接在 SKILL.md 内嵌入精简的 API 参考(不分离文件),保持在 500 行以内,因为测试框架文档已经在项目 docs 目录中。
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
---
|
||||||
|
name: JSON Path 查询支持
|
||||||
|
overview: 在现有 Dialect 接口和 WHERE 条件解析引擎上扩展,使 MySQL 和 SQLite 支持类似 PgSQL 的 `data["a"]["b"]["c"]` JSON 路径查询与操作语法,所有三种数据库共享统一的上层 API。
|
||||||
|
todos:
|
||||||
|
- id: dialect-json-interface
|
||||||
|
content: 在 db/dialect.go 的 Dialect 接口新增 JSONExtract / JSONSet / JSONContains / JSONArrayLength 四个方法
|
||||||
|
status: pending
|
||||||
|
- id: dialect-json-mysql
|
||||||
|
content: 实现 MySQLDialect 的三个 JSON 方法,处理 JSON_EXTRACT / JSON_SET / JSON_UNQUOTE 等
|
||||||
|
status: pending
|
||||||
|
- id: dialect-json-sqlite
|
||||||
|
content: 实现 SQLiteDialect 的三个 JSON 方法,使用小写 json_extract / json_set
|
||||||
|
status: pending
|
||||||
|
- id: dialect-json-pgsql
|
||||||
|
content: "实现 PostgreSQLDialect 的三个 JSON 方法,使用 -> / #>> / jsonb_set 语法"
|
||||||
|
status: pending
|
||||||
|
- id: where-json-parse
|
||||||
|
content: 新增 parseJSONPathKey()/parseJSONPathFromName() 工具函数,同时支持 col["key"] 和 col['key'] 两种引号格式,以及数字索引 [0],解析出列名、[]string 路径键、操作符后缀
|
||||||
|
status: pending
|
||||||
|
- id: where-json-cond
|
||||||
|
content: 在 db/where.go 的 varCond() 开头最优先调用 parseJSONPathKey,分发到 jsonPathCond();jsonPathCond 对现有操作符复用比较逻辑,新增 [?]/[!?]/[@]/[!@]/[len]/[len>] 等分支
|
||||||
|
status: pending
|
||||||
|
- id: crud-json-update
|
||||||
|
content: 在 db/crud.go 的 Update() SET 构建循环中检测 JSON 路径列名生成 JSONSet 表达式,在 Select() 的 Slice 字段循环中检测 JSON 路径生成 JSONExtract 表达式(修复两处 ProcessColumnNoPrefix 误处理)
|
||||||
|
status: pending
|
||||||
|
isProject: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# MySQL/SQLite JSON 路径查询支持方案
|
||||||
|
|
||||||
|
## 背景与现状
|
||||||
|
|
||||||
|
现有 ORM 已有 `Dialect` 接口抽象三种数据库差异,`where.go` 用 `[operator]` 后缀语法解析条件。扩展的核心思路:**在解析 key 时识别 JSON 路径记法,委托给 Dialect 生成对应函数调用**。
|
||||||
|
|
||||||
|
## 三种数据库 JSON 能力深度对比
|
||||||
|
|
||||||
|
### 1. JSON 值提取(WHERE 比较场景)
|
||||||
|
|
||||||
|
|
||||||
|
| | MySQL 5.7+ | SQLite 3.9+ | PostgreSQL |
|
||||||
|
| ---------- | ------------------------------------------------------- | ------------------------------ | ----------------- |
|
||||||
|
| 单层提取(返回文本) | `JSON_UNQUOTE(JSON_EXTRACT(col,'$.a'))` 或 `col->>'$.a'` | `json_extract(col,'$.a')` | `col->>'a'` |
|
||||||
|
| 多层嵌套(返回文本) | `JSON_UNQUOTE(JSON_EXTRACT(col,'$.a.b'))` | `json_extract(col,'$.a.b')` | `col#>>'{a,b}'` |
|
||||||
|
| 数组索引 | `JSON_EXTRACT(col,'$.arr[0]')` | `json_extract(col,'$.arr[0]')` | `col#>>'{arr,0}'` |
|
||||||
|
| 路径格式 | `$.a.b` | `$.a.b`(与MySQL相同) | `{a,b}`(#>> 方式) |
|
||||||
|
|
||||||
|
|
||||||
|
**关键差异**:MySQL 的 `JSON_EXTRACT` 对字符串返回值带外层双引号(如 `"北京"` 而非 `北京`),与普通字符串 `= ?` 比较会失败,**必须包一层 `JSON_UNQUOTE()`**。SQLite 和 PostgreSQL(`->>`/`#>>`)则直接返回文本,无此问题。
|
||||||
|
|
||||||
|
### 2. JSON 局部更新(UPDATE SET 场景)
|
||||||
|
|
||||||
|
|
||||||
|
| | MySQL 5.7+ | SQLite 3.9+ | PostgreSQL |
|
||||||
|
| ----- | ------------------------- | ------------------------- | ------------------------------------------ |
|
||||||
|
| 语法 | `JSON_SET(col,'$.a.b',?)` | `json_set(col,'$.a.b',?)` | `jsonb_set(col,'{a,b}',to_jsonb(?::text))` |
|
||||||
|
| 路径格式 | `$.a.b` | `$.a.b`(与MySQL相同) | `'{a,b}'` 数组字面量 |
|
||||||
|
| 值的类型 | 直接绑定参数 | 直接绑定参数 | 字符串需 `to_jsonb(?::text)`,数字可 `?::jsonb` |
|
||||||
|
| 列类型限制 | 无(TEXT 也行) | 无(TEXT 列) | **必须是 `jsonb` 列**,`json` 列无 `jsonb_set` |
|
||||||
|
|
||||||
|
|
||||||
|
**关键差异**:MySQL 与 SQLite 的语法几乎完全一致(函数名大小写不同而已)。PostgreSQL 只有 `jsonb_set`,要求列类型为 `jsonb`,且字符串值必须转为 JSON 格式(`to_jsonb(?::text)` 或 `'"value"'::jsonb`)。
|
||||||
|
|
||||||
|
### 3. JSON 数组包含(`[@]` 操作符)
|
||||||
|
|
||||||
|
|
||||||
|
| | MySQL 5.7+ | SQLite 3.9+ | PostgreSQL |
|
||||||
|
| ------ | --------------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------ |
|
||||||
|
| SQL 形式 | `JSON_CONTAINS(JSON_EXTRACT(col,'$.path'),JSON_QUOTE(?))` | `EXISTS(SELECT 1 FROM json_each(col,'$.path') WHERE value=?)` | `col#>'{path}' @> to_jsonb(?::text)` |
|
||||||
|
| 结构类型 | 函数调用(内联) | **EXISTS 子查询**(结构不同) | 中缀操作符(内联) |
|
||||||
|
|
||||||
|
|
||||||
|
**最大兼容难点**:SQLite 没有内联的包含函数,必须生成 EXISTS 子查询,SQL 结构与另外两种完全不同。`JSONContains()` 方法在 SQLite 下返回的是一段 `EXISTS(...)` 表达式。
|
||||||
|
|
||||||
|
### 4. 路径格式统一转换规则
|
||||||
|
|
||||||
|
ORM 内部统一使用 `[]string{"a","b","c"}` 表示路径键(数字索引用字符串 `"0"` 表示),各 Dialect 自行转换:
|
||||||
|
|
||||||
|
```
|
||||||
|
内部路径: ["address","city"]
|
||||||
|
→ MySQL/SQLite: $.address.city
|
||||||
|
→ PostgreSQL: {address,city}
|
||||||
|
|
||||||
|
内部路径: ["tags","0"](数组索引)
|
||||||
|
→ MySQL/SQLite: $.tags[0]
|
||||||
|
→ PostgreSQL: {tags,0}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 统一 API 语法设计
|
||||||
|
|
||||||
|
### Key 写法(单引号/双引号两种都支持)
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 双引号风格(JSON 标准,推荐,需用反引号字符串)
|
||||||
|
common.Map{ `profile["addr"]["city"]`: "北京" }
|
||||||
|
|
||||||
|
// 单引号风格(普通字符串也能写)
|
||||||
|
common.Map{ "profile['addr']['city']": "北京" }
|
||||||
|
```
|
||||||
|
|
||||||
|
两种写法解析结果完全一致,内部统一转为 `$.addr.city`。
|
||||||
|
|
||||||
|
### 操作符设计:复用现有 + 新增 JSON 专属
|
||||||
|
|
||||||
|
**所有现有操作符均可接在 JSON 路径后直接使用**(`=` / `>` / `<` / `>=` / `<=` / `!=` / `LIKE` / `IN` / `NOT IN` / `BETWEEN` / `IS NULL`):
|
||||||
|
|
||||||
|
```go
|
||||||
|
db.Select("user", common.Map{
|
||||||
|
`profile["age"]`: 18, // = 18
|
||||||
|
`profile["age"][>]`: 18, // > 18
|
||||||
|
`profile["age"][<>]`: []int{18, 30}, // BETWEEN
|
||||||
|
`profile["name"][~]`: "张", // LIKE %张%
|
||||||
|
`profile["tags"][0]`: "vip", // 数组第一个元素 = vip
|
||||||
|
`profile["addr"]["city"]`: "北京", // 多级嵌套
|
||||||
|
`profile["score"]`: nil, // IS NULL
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**新增 JSON 专属操作符**(现有体系无对应):
|
||||||
|
|
||||||
|
|
||||||
|
| 操作符 | 含义 | MySQL | SQLite | PgSQL |
|
||||||
|
| --------- | ----------- | -------------------------------- | ------------------------ | ------------------------- |
|
||||||
|
| `[?]` | 路径存在(非NULL) | `IS NOT NULL` | `IS NOT NULL` | `? ?` |
|
||||||
|
| `[!?]` | 路径不存在 | `IS NULL` | `IS NULL` | `NOT (? ?)` |
|
||||||
|
| `[@]` | 数组/对象包含某值 | `JSON_CONTAINS(col,?,'$.path')` | `json_each` 子查询 | `col @> ?::jsonb` |
|
||||||
|
| `[!@]` | 不包含 | `NOT JSON_CONTAINS(...)` | 子查询 NOT EXISTS | `NOT (col @> ?)` |
|
||||||
|
| `[len]` | 数组长度 = ? | `JSON_LENGTH(JSON_EXTRACT(...))` | `json_array_length(...)` | `jsonb_array_length(...)` |
|
||||||
|
| `[len>]` | 数组长度 > ? | 同上 | 同上 | 同上 |
|
||||||
|
| `[len<]` | 数组长度 < ? | 同上 | 同上 | 同上 |
|
||||||
|
| `[len>=]` | 数组长度 >= ? | 同上 | 同上 | 同上 |
|
||||||
|
| `[len<=]` | 数组长度 <= ? | 同上 | 同上 | 同上 |
|
||||||
|
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 新操作符使用示例
|
||||||
|
db.Select("user", common.Map{
|
||||||
|
`profile["vip"][?]`: nil, // vip 字段存在
|
||||||
|
`profile["tags"][@]`: "admin", // tags 数组包含 "admin"
|
||||||
|
`profile["friends"][len>]`: 5, // friends 数组长度 > 5
|
||||||
|
`profile["items"][len]`: 3, // items 数组长度 = 3
|
||||||
|
})
|
||||||
|
|
||||||
|
// UPDATE 局部更新 JSON 字段
|
||||||
|
db.Update("user", common.Map{
|
||||||
|
`profile["age"]`: 20,
|
||||||
|
`profile["addr"]["city"]`: "上海",
|
||||||
|
}, common.Map{"id": 1})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 与现有代码的兼容性分析
|
||||||
|
|
||||||
|
### 三处具体冲突点
|
||||||
|
|
||||||
|
**冲突 1 — WHERE `varCond` 的 `[...]` 分支**(`[db/where.go:225](db/where.go)`)
|
||||||
|
|
||||||
|
现有逻辑:只要 key 含 `[` 且末尾是 `]` 就进分支,取末尾 3/4 字符匹配已知操作符。
|
||||||
|
|
||||||
|
```
|
||||||
|
profile["age"] → 末尾3字符 e"] → 无匹配 → handleDefaultCondition
|
||||||
|
→ ProcessColumn("profile[\"age\"]") → 被加引号 → WRONG SQL
|
||||||
|
|
||||||
|
profile["age"][>] → 末尾3字符 [>] → 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\xbc.cursor\plans\销量支持小数方案_92094ec9.plan.md)第二步要求,需在本文件新增 `ObjToRoundFloat64`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func ObjToRoundFloat64(obj interface{}, precision int, e ...*Error) float64 {
|
||||||
|
f := ObjToFloat64(obj, e...)
|
||||||
|
if precision < 0 {
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
pow := math.Pow10(precision)
|
||||||
|
return math.Round(f*pow) / pow
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
同时需在 [common/map.go](d:\work\hotimev1.5\common\map.go) 新增 `GetRoundFloat64`,在 [common/obj.go](d:\work\hotimev1.5\common\obj.go) 新增 `ToRoundFloat64`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、修正摘要
|
||||||
|
|
||||||
|
|
||||||
|
| # | 类型 | 位置 | 问题 | 修正 |
|
||||||
|
| --- | ---- | ------------------- | ------------------------- | ---------------------------- |
|
||||||
|
| 1 | 类型混淆 | ObjToFloat64 uint8 | case uint8 应为 case []byte | 改为 case []byte + string() 解析 |
|
||||||
|
| 2 | 类型混淆 | ObjToInt64 uint8 | case uint8 应为 case []byte | 改为 case []byte + string() 解析 |
|
||||||
|
| 3 | 类型混淆 | ObjToStr uint8 | uint8 断言为 string 会 panic | 改为 strconv.Itoa(int(...)) |
|
||||||
|
| 4 | 逻辑 | ObjToMap default | := 遮蔽外部 err,v 赋值逻辑错乱 | 用 = 并加 break |
|
||||||
|
| 5 | 逻辑 | ObjToSlice default | Marshal 错误被 Unmarshal 覆盖 | 失败时 break |
|
||||||
|
| 6 | 逻辑 | ObjToBool default | 成功转换也报错 | 转换成功不报错 |
|
||||||
|
| 7 | 逻辑 | ObjToTime 日期补零 | 单位数日+时间时补不到零 | 分离日期时间部分再补零 |
|
||||||
|
| 8 | 逻辑 | ObjToCeilInt64 | 双重 Ceil + 多余 type switch | 改为 int64(f) |
|
||||||
|
| 9 | 性能 | ObjToTime | ObjToStr 重复调用 5 次 | 缓存结果 |
|
||||||
|
| 10 | 性能 | ObjToSlice []string | 循环内重复类型断言 | 断言一次复用 |
|
||||||
|
| 11 | 性能 | 所有函数 | 未用 switch v := obj.(type) | 改用惯用写法消除冗余断言 |
|
||||||
|
| 12 | 性能 | ObjToStr | 缺少 float32/bool 等常见类型 | 补充直接转换 case |
|
||||||
|
| 13 | 性能 | ObjToMapArray | 切片未预分配容量 | make([]Map, 0, len(s)) |
|
||||||
|
| 14 | 规范 | StrToMap/StrToSlice | 参数名 string 遮蔽内置类型 | 改为 s 或 jsonStr |
|
||||||
|
| 15 | 新增 | ObjToRoundFloat64 | 销量方案需要 | 新增函数 |
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
---
|
||||||
|
name: Row方法方案审查与修正
|
||||||
|
overview: 修改 4 个文件:common/objtoobj.go(新增 JsonToObj + 增强 ObjToMap/ObjToSlice)、common/map.go(增强 JsonToMap)、db/query.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_xbc.cmd 检测正常退出不重启。
|
||||||
|
todos:
|
||||||
|
- id: framework-core
|
||||||
|
content: hotimev1.5/application.go:新增 shuttingDown/shutdownOnce/DrainTimeout/ShutdownTimeout 字段,改 ServeHTTP,改 Run() 信号 goroutine,改 recover 保护,提取 initiateGracefulShutdown()
|
||||||
|
status: completed
|
||||||
|
- id: framework-windows
|
||||||
|
content: hotimev1.5/signal_windows.go(新建):SetConsoleCtrlHandler 处理 CTRL_CLOSE_EVENT(关窗口)
|
||||||
|
status: completed
|
||||||
|
- id: framework-other
|
||||||
|
content: hotimev1.5/signal_other.go(新建):非 Windows 平台 registerWindowsCloseHandler() 空实现
|
||||||
|
status: completed
|
||||||
|
- id: nginx-config
|
||||||
|
content: Nginx location 块加 proxy_next_upstream error timeout http_503 和 proxy_next_upstream_tries 2
|
||||||
|
status: completed
|
||||||
|
- id: cmd-exit-check
|
||||||
|
content: run_xbc.cmd:检测 EXIT_CODE == 0 时不重启直接退出
|
||||||
|
status: completed
|
||||||
|
- id: docs
|
||||||
|
content: 新建 hotimev1.5/docs/graceful-shutdown.md 文档
|
||||||
|
status: completed
|
||||||
|
isProject: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# 优雅停机方案
|
||||||
|
|
||||||
|
## 整体流程
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Op as 运维
|
||||||
|
participant OS as 操作系统
|
||||||
|
participant App as Go 进程
|
||||||
|
participant Nginx as Nginx
|
||||||
|
|
||||||
|
Op->>OS: Ctrl+C / kill / taskkill / 关窗口
|
||||||
|
OS->>App: SIGINT / SIGTERM / CTRL_CLOSE_EVENT
|
||||||
|
App->>App: shuttingDown = 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_xbc.cmd 检测 exit 0 → 不重启
|
||||||
|
```
|
||||||
|
|
||||||
|
## 信号触发场景对照表
|
||||||
|
|
||||||
|
| 操作方式 | 平台 | 信号/事件 | 是否可拦截 | 备注 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Ctrl+C | Windows/Linux | `os.Interrupt` | ✅ | 标准中断 |
|
||||||
|
| `kill <pid>` 或 `kill -15 <pid>` | Linux | `syscall.SIGTERM` | ✅ | 推荐生产关闭方式 |
|
||||||
|
| `taskkill /PID <pid>`(无 /F) | Windows | `os.Interrupt` | ✅ | 等价于 Ctrl+C |
|
||||||
|
| 关闭控制台窗口 X 按钮 | Windows | `CTRL_CLOSE_EVENT` | ✅(≤5s 限制)| Windows 给 ~5s 后强杀 |
|
||||||
|
| `taskkill /F /PID <pid>` | Windows | 强制 SIGKILL | ❌ | 无法拦截,强杀 |
|
||||||
|
| `kill -9 <pid>` | Linux | SIGKILL | ❌ | 无法拦截,强杀 |
|
||||||
|
|
||||||
|
> **关窗口说明**:Windows 在触发 `CTRL_CLOSE_EVENT` 后约 5 秒会强制结束进程。框架对此事件使用更短的 drain(默认 2s)+ shutdown(默认 3s),总计 <5s,可完成正在跑的短接口;长接口(>3s)会被截断。推荐用 `taskkill /PID` 代替关窗口做计划性停机。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 需要改动/新建的文件
|
||||||
|
|
||||||
|
### 1. [`hotimev1.5/application.go`](d:/work/hotimev1.5/application.go)
|
||||||
|
|
||||||
|
**`Application` struct** 新增字段:
|
||||||
|
```go
|
||||||
|
type Application struct {
|
||||||
|
// ...原有字段...
|
||||||
|
shuttingDown atomic.Bool
|
||||||
|
shutdownOnce sync.Once // 保证多路触发只执行一次
|
||||||
|
DrainTimeout time.Duration // 等 nginx 切流,默认 5s
|
||||||
|
ShutdownTimeout time.Duration // 等在途请求,默认 30s
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`ServeHTTP`** 新增停机检测:
|
||||||
|
```go
|
||||||
|
func (that *Application) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||||
|
if that.shuttingDown.Load() {
|
||||||
|
w.Header().Set("Connection", "close")
|
||||||
|
http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
that.handler(w, req)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**提取 `initiateGracefulShutdown(drain, shutdown time.Duration)` 方法**(被信号 goroutine 和 Windows 关窗 handler 共同调用):
|
||||||
|
```go
|
||||||
|
func (that *Application) initiateGracefulShutdown(drain, shutdown time.Duration) {
|
||||||
|
that.shutdownOnce.Do(func() {
|
||||||
|
that.shuttingDown.Store(true)
|
||||||
|
that.Log.Infof("优雅停机:等 %v 让 nginx 切流...", drain)
|
||||||
|
time.Sleep(drain)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), shutdown)
|
||||||
|
defer cancel()
|
||||||
|
that.Log.Infof("等进行中请求完成(最多 %v)...", shutdown)
|
||||||
|
if err := that.Server.Shutdown(ctx); err != nil {
|
||||||
|
that.Log.Errorf("优雅停机超时: %v", err)
|
||||||
|
}
|
||||||
|
that.Log.Infof("服务已安全关闭")
|
||||||
|
os.Exit(0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`Run()`** 加信号监听 goroutine(在启动 ListenAndServe 之后):
|
||||||
|
```go
|
||||||
|
// 注册 Windows 关窗口处理(Linux 上是空实现)
|
||||||
|
that.registerWindowsCloseHandler()
|
||||||
|
|
||||||
|
// 跨平台:Ctrl+C / SIGTERM / kill
|
||||||
|
go func() {
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||||
|
<-sigCh
|
||||||
|
drain := that.DrainTimeout
|
||||||
|
if drain == 0 { drain = 5 * time.Second }
|
||||||
|
shutdown := that.ShutdownTimeout
|
||||||
|
if shutdown == 0 { shutdown = 30 * time.Second }
|
||||||
|
that.initiateGracefulShutdown(drain, shutdown)
|
||||||
|
}()
|
||||||
|
```
|
||||||
|
|
||||||
|
**`defer recover`** 加停机保护:
|
||||||
|
```go
|
||||||
|
defer func() {
|
||||||
|
if err := recover(); err != nil {
|
||||||
|
if that.shuttingDown.Load() {
|
||||||
|
return // 停机中的 panic 不触发重启
|
||||||
|
}
|
||||||
|
that.Log.Warnf("%v", err)
|
||||||
|
that.Run(router)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
```
|
||||||
|
|
||||||
|
新增 import:`"context"`, `"os/signal"`, `"sync"`, `"sync/atomic"`, `"syscall"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. `hotimev1.5/signal_windows.go`(新建)
|
||||||
|
|
||||||
|
```go
|
||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package hotime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (that *Application) registerWindowsCloseHandler() {
|
||||||
|
windows.SetConsoleCtrlHandler(func(ctrl uint32) bool {
|
||||||
|
// CTRL_CLOSE_EVENT=2 CTRL_LOGOFF_EVENT=5 CTRL_SHUTDOWN_EVENT=6
|
||||||
|
if ctrl == 2 || ctrl == 5 || ctrl == 6 {
|
||||||
|
that.initiateGracefulShutdown(2*time.Second, 3*time.Second)
|
||||||
|
// 阻塞此回调,让 initiateGracefulShutdown 内的 os.Exit(0) 执行
|
||||||
|
// Windows 约 5s 后强杀,这里最多等 6s 兜底
|
||||||
|
time.Sleep(6 * time.Second)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}, true)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> `golang.org/x/sys` 已在 `xbc/go.mod` 中作为间接依赖存在(v0.29.0),无需新增依赖。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. `hotimev1.5/signal_other.go`(新建)
|
||||||
|
|
||||||
|
```go
|
||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package hotime
|
||||||
|
|
||||||
|
func (that *Application) registerWindowsCloseHandler() {}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Nginx 配置(在已有 location 块中追加)
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# 让后端返回 503 时自动切换到另一台(优雅停机核心)
|
||||||
|
proxy_next_upstream error timeout http_503;
|
||||||
|
proxy_next_upstream_tries 2;
|
||||||
|
```
|
||||||
|
|
||||||
|
已有的 `max_fails=1 fail_timeout=3s` 负责连接级失败(强杀后的端口拒绝),两者互补。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. [`run_xbc.cmd`](d:/work/xbc/run_xbc.cmd)
|
||||||
|
|
||||||
|
在 `goto run_app` 前插入 exit code 判断:
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
:: 优雅退出 (exit 0) 不重启
|
||||||
|
if %EXIT_CODE% EQU 0 (
|
||||||
|
echo [%date% %time%] Graceful shutdown. Not restarting. >> "%LOG_FILE%"
|
||||||
|
echo.
|
||||||
|
echo [优雅停机] 服务已安全关闭,不再自动重启。
|
||||||
|
pause
|
||||||
|
exit /b 0
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. `hotimev1.5/docs/graceful-shutdown.md`(新建)
|
||||||
|
|
||||||
|
包含:原理说明、场景对照表、nginx 配置、滚动重启步骤、参数说明(DrainTimeout/ShutdownTimeout)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 滚动重启操作步骤(Windows)
|
||||||
|
|
||||||
|
```
|
||||||
|
# 1. 找到端口 9998 的进程 PID
|
||||||
|
netstat -ano | findstr 9998
|
||||||
|
|
||||||
|
# 2. 优雅关闭该实例(不加 /F)
|
||||||
|
taskkill /PID <pid>
|
||||||
|
|
||||||
|
# 3. 等日志出现 "服务已安全关闭" 后再部署新版并启动
|
||||||
|
# 4. 再对 9999 执行同样操作
|
||||||
|
```
|
||||||
|
|
||||||
|
## 滚动重启操作步骤(Linux)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 找 PID
|
||||||
|
pgrep xbc # 或 ps aux | grep xbc
|
||||||
|
|
||||||
|
# 优雅关闭
|
||||||
|
kill <pid> # 发 SIGTERM,触发优雅停机
|
||||||
|
# 或
|
||||||
|
kill -2 <pid> # 发 SIGINT,等同 Ctrl+C
|
||||||
|
|
||||||
|
# 等日志出现 "服务已安全关闭" 再部署新版
|
||||||
|
```
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
name: 修复DM列注释读取
|
||||||
|
overview: 修复达梦数据库列注释(COMMENT)未被正确读取的问题,导致管理端所有字段显示为数据库字段名而非中文备注。
|
||||||
|
todos:
|
||||||
|
- id: fix-dm-col-comments
|
||||||
|
content: 修改 makecode.go 列信息查询,列注释改用 USER_COL_COMMENTS 替代 ALL_COL_COMMENTS
|
||||||
|
status: completed
|
||||||
|
isProject: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# 修复达梦数据库列注释读取失败
|
||||||
|
|
||||||
|
## 问题分析
|
||||||
|
|
||||||
|
通过 MCP 查询 `adminDB.json` 确认:所有表的字段 label 全部等于字段名本身(如 `"id"`, `"name"`, `"phone"`),没有中文备注。但表级 label 正常(如"消息主表"、"用户表")。
|
||||||
|
|
||||||
|
用户确认数据库字段确实有备注。
|
||||||
|
|
||||||
|
### 根因
|
||||||
|
|
||||||
|
[makecode.go](d:\work\hotimev1.5\code\makecode.go) 第 170 行当前的列查询 SQL:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
FROM ALL_TAB_COLUMNS c
|
||||||
|
LEFT JOIN ALL_COL_COMMENTS m
|
||||||
|
ON c.TABLE_NAME=m.TABLE_NAME AND c.COLUMN_NAME=m.COLUMN_NAME AND c.OWNER=m.OWNER
|
||||||
|
WHERE c.TABLE_NAME='...' AND c.OWNER='ZWBG'
|
||||||
|
```
|
||||||
|
|
||||||
|
`LEFT JOIN` 条件中 `c.OWNER=m.OWNER` 要求 `ALL_COL_COMMENTS` 的 `OWNER` 也等于 `'ZWBG'`。但在 DM 中,`ALL_COL_COMMENTS` 的 `OWNER` 值可能与 `ALL_TAB_COLUMNS` 不一致(DM 的 Oracle 兼容视图在 OWNER 语义上可能存在差异),导致 JOIN 匹配不上,`m.COMMENTS` 全部为 NULL。
|
||||||
|
|
||||||
|
对比:表注释用的 `ALL_TAB_COMMENTS WHERE OWNER='ZWBG'` 能正确返回中文备注,说明表级视图的 OWNER 一致,但列级视图可能不同。
|
||||||
|
|
||||||
|
## 修复方案
|
||||||
|
|
||||||
|
修改 [makecode.go](d:\work\hotimev1.5\code\makecode.go) 第 169-171 行,列注释改用 `USER_COL_COMMENTS`(自动使用当前 schema 上下文 ZWBG,无需 OWNER 过滤),列元数据继续用 `ALL_TAB_COLUMNS`(保留 OWNER 过滤防止系统表):
|
||||||
|
|
||||||
|
```go
|
||||||
|
if db.Type == "dm" || db.Type == "dameng" {
|
||||||
|
tableInfo = db.Query(`SELECT c.COLUMN_NAME AS "name", c.DATA_TYPE AS "type", m.COMMENTS AS "label", c.NULLABLE AS "must", c.DATA_DEFAULT AS "dflt_value" FROM ALL_TAB_COLUMNS c LEFT JOIN USER_COL_COMMENTS m ON c.TABLE_NAME=m.TABLE_NAME AND c.COLUMN_NAME=m.COLUMN_NAME WHERE c.TABLE_NAME='` + v.GetString("name") + `' AND c.OWNER='` + strings.ToUpper(db.DBName) + `' ORDER BY c.COLUMN_ID`)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
关键变化:
|
||||||
|
|
||||||
|
- `ALL_COL_COMMENTS` 改为 `USER_COL_COMMENTS`
|
||||||
|
- 去掉 JOIN 中的 `AND c.OWNER=m.OWNER`(`USER_COL_COMMENTS` 没有 OWNER 列)
|
||||||
|
- WHERE 条件 `c.OWNER='ZWBG'` 保留(作用于 `ALL_TAB_COLUMNS`,防止系统表列混入)
|
||||||
|
|
||||||
|
原理:框架连接 DM 用 `?schema=ZWBG`,`USER_COL_COMMENTS` 自动返回当前 schema(ZWBG)的列注释,绕开了 OWNER 不一致问题。
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
---
|
||||||
|
name: 修复DM类型映射大小写
|
||||||
|
overview: DM 返回的列类型为大写(BIGINT、VARCHAR、INT、TIMESTAMP),但 ColumnDataType 的匹配键是小写且 strings.Contains 区分大小写,导致类型映射全部失败。
|
||||||
|
todos:
|
||||||
|
- id: fix-type-case
|
||||||
|
content: 修改 makecode.go 类型比较加 strings.ToLower 忽略大小写
|
||||||
|
status: completed
|
||||||
|
- id: add-dm-types
|
||||||
|
content: 在 config.go 的 ColumnDataType 中追加 DM 特有数据类型映射
|
||||||
|
status: completed
|
||||||
|
isProject: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# 修复达梦数据库列类型映射失败
|
||||||
|
|
||||||
|
## 问题分析
|
||||||
|
|
||||||
|
通过 MCP 查询 `adminDB.json` 确认:大量字段 type 仍为 DM 原始类型(`BIGINT`、`VARCHAR`、`INT`、`TIMESTAMP`、`DECIMAL`),未映射到框架类型(`number`、`text`、`time`),导致前端编辑页无法识别控件类型。
|
||||||
|
|
||||||
|
### 根因
|
||||||
|
|
||||||
|
[config.go](d:\work\hotimev1.5\code\config.go) 第 43-59 行定义的 `ColumnDataType` 映射表键全部为小写:
|
||||||
|
|
||||||
|
```go
|
||||||
|
var ColumnDataType = map[string]string{
|
||||||
|
"int": "number",
|
||||||
|
"char": "text",
|
||||||
|
"text": "text",
|
||||||
|
"time": "time",
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
[makecode.go](d:\work\hotimev1.5\code\makecode.go) 第 191-194 行的匹配逻辑使用 `strings.Contains`,**区分大小写**:
|
||||||
|
|
||||||
|
```go
|
||||||
|
for k, v1 := range ColumnDataType {
|
||||||
|
if strings.Contains(info.GetString("type"), k) { // "BIGINT" 不包含 "int"
|
||||||
|
info["type"] = v1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
DM 返回的类型全部为大写(`INT`、`VARCHAR`、`BIGINT`),而键为小写(`int`、`char`),`strings.Contains("BIGINT", "int")` 返回 `false`,映射全部跳过。
|
||||||
|
|
||||||
|
## 修复方案
|
||||||
|
|
||||||
|
### 修改 1:类型比较忽略大小写 - [makecode.go](d:\work\hotimev1.5\code\makecode.go)
|
||||||
|
|
||||||
|
第 192 行,将 `info.GetString("type")` 转为小写再匹配:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if strings.Contains(strings.ToLower(info.GetString("type")), k) {
|
||||||
|
```
|
||||||
|
|
||||||
|
效果验证:
|
||||||
|
|
||||||
|
- `"BIGINT"` -> `"bigint"` -> contains `"int"` -> `"number"`
|
||||||
|
- `"VARCHAR"` -> `"varchar"` -> contains `"char"` -> `"text"`
|
||||||
|
- `"INT"` -> `"int"` -> contains `"int"` -> `"number"`
|
||||||
|
- `"TIMESTAMP"` -> `"timestamp"` -> contains `"time"` -> `"time"`
|
||||||
|
- `"DECIMAL"` -> `"decimal"` -> contains `"decimal"` -> `"number"`
|
||||||
|
|
||||||
|
### 修改 2:补充 DM 特有类型 - [config.go](d:\work\hotimev1.5\code\config.go)
|
||||||
|
|
||||||
|
在 `ColumnDataType` 中追加 DM 特有的数据类型,确保 CLOB、NUMBER 等也能被识别:
|
||||||
|
|
||||||
|
```go
|
||||||
|
var ColumnDataType = map[string]string{
|
||||||
|
// 现有 MySQL/SQLite 类型...
|
||||||
|
// DM 特有类型
|
||||||
|
"clob": "text",
|
||||||
|
"number": "number",
|
||||||
|
"numeric": "number",
|
||||||
|
"binary": "text",
|
||||||
|
"boolean": "number",
|
||||||
|
"bit": "number",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
---
|
||||||
|
name: 修复Table和Edit组件
|
||||||
|
overview: 修复 Table.vue 的重复请求和搜索防抖问题,以及 Edit.vue 密码改为弹窗独立修改。
|
||||||
|
todos:
|
||||||
|
- id: fix-double-request
|
||||||
|
content: "Table.vue: 添加 _skipFormWatch 标志,修复 mounted 和 watcher 导致的重复请求"
|
||||||
|
status: completed
|
||||||
|
- id: fix-keyword-search
|
||||||
|
content: "Table.vue: 关键字搜索改为点击按钮/Enter触发,不再实时触发"
|
||||||
|
status: completed
|
||||||
|
- id: fix-password-dialog
|
||||||
|
content: "Edit.vue: 密码字段改为弹窗独立修改,主表单提交排除密码数据"
|
||||||
|
status: completed
|
||||||
|
isProject: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# 修复 Table 重复请求、搜索防抖、Edit 密码弹窗
|
||||||
|
|
||||||
|
## 1. 修复重复请求 - [Table.vue](d:\work\myst-web\src\components\Table.vue)
|
||||||
|
|
||||||
|
**根因**:`mounted()` 中既修改了 form 属性(触发 deep watcher),又显式调用了 `formDebounce`,两者可能各触发一次请求。
|
||||||
|
|
||||||
|
**方案**:在 data 中增加 `_skipFormWatch: true` 标志,mounted 中所有 form 初始化完成后调用一次 `formDebounce`,然后在 `$nextTick` 中将标志设为 false。watcher 中检查该标志,初始化期间跳过。
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// data 中
|
||||||
|
_skipFormWatch: true,
|
||||||
|
|
||||||
|
// watcher 中
|
||||||
|
form: {
|
||||||
|
handler() {
|
||||||
|
if (this._skipFormWatch) return;
|
||||||
|
this.formDebounce(this);
|
||||||
|
},
|
||||||
|
deep: true
|
||||||
|
}
|
||||||
|
|
||||||
|
// mounted 末尾
|
||||||
|
this.formDebounce(this);
|
||||||
|
this.$nextTick(() => { this._skipFormWatch = false; });
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 搜索改为点击触发 - [Table.vue](d:\work\myst-web\src\components\Table.vue)
|
||||||
|
|
||||||
|
**方案**:将关键字搜索从实时触发改为点击搜索按钮触发。
|
||||||
|
|
||||||
|
- 新增 `searchKeyword: ""` data 属性,输入框 `v-model` 改为绑定 `searchKeyword`
|
||||||
|
- 搜索按钮添加 `@click="doSearch"` 事件
|
||||||
|
- `doSearch` 方法将 `searchKeyword` 赋值给 `form.keyword`(触发 watcher 进行搜索)
|
||||||
|
- 输入框支持 Enter 键触发搜索(`@keyup.enter`)
|
||||||
|
- 重置按钮也同步清空 `searchKeyword`
|
||||||
|
|
||||||
|
关键模板改动:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<el-input v-model="searchKeyword" @keyup.enter="doSearch" ...>
|
||||||
|
<template #append>
|
||||||
|
<el-button class="search-btn" @click="doSearch">
|
||||||
|
<el-icon><Search/></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 密码改为弹窗独立修改 - [Edit.vue](d:\work\myst-web\src\components\Edit.vue)
|
||||||
|
|
||||||
|
**方案**:
|
||||||
|
|
||||||
|
- 主表单中隐藏 password 类型字段,改为显示"修改密码"按钮
|
||||||
|
- 新增密码修改弹窗(`el-dialog`),包含新密码 + 确认密码两个输入
|
||||||
|
- 弹窗提交时校验两次输入一致,然后仅发送密码字段调用 `edit` API
|
||||||
|
- 主表单 `onSubmit` 提交数据时过滤掉 password 类型字段
|
||||||
|
|
||||||
|
关键改动点:
|
||||||
|
|
||||||
|
**模板** - 密码字段区域(约第55行):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- 原密码输入改为修改密码按钮 -->
|
||||||
|
<el-button v-if="column.type=='password'" type="warning" @click="showPasswordDialog = true">
|
||||||
|
修改密码
|
||||||
|
</el-button>
|
||||||
|
<!-- 其他非密码字段保持原样 -->
|
||||||
|
<el-input v-model="form[column.value]" show-password v-else-if="..." />
|
||||||
|
```
|
||||||
|
|
||||||
|
**data** 新增:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
showPasswordDialog: false,
|
||||||
|
passwordForm: { password: '', confirmPassword: '' },
|
||||||
|
passwordColumn: null,
|
||||||
|
```
|
||||||
|
|
||||||
|
**methods** 新增 `submitPassword` 和修改 `onSubmit`:
|
||||||
|
|
||||||
|
- `submitPassword`:校验密码一致性,调用 `edit` API 仅提交密码字段
|
||||||
|
- `onSubmit`:提交时从 form 中排除 password 类型字段的数据
|
||||||
|
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
name: 修复路径和测试数据
|
||||||
|
overview: 修复测试时 swagger/config 生成到错误目录的问题(根因是 Go test CWD 在 app/ 下),并为 DM 和 MySQL 两个测试数据库创建完善的测试数据表和种子数据。
|
||||||
|
todos:
|
||||||
|
- id: fix-cwd
|
||||||
|
content: 修复 app_test.go 中的工作目录问题:添加 os.Chdir("..") 并修改 config path
|
||||||
|
status: completed
|
||||||
|
- id: cleanup-wrong-dirs
|
||||||
|
content: 清理错误生成的 example/app/tpt/ 和 example/app/config/ 目录
|
||||||
|
status: completed
|
||||||
|
- id: setup-mysql
|
||||||
|
content: 创建 setup_mysql.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\xbc\docs\Testing_API测试框架.md) 第 474、494 行:仅出现在 API 表格中,一句话描述
|
||||||
|
- [API 速查表](d:\work\xbc\docs\Testing_API测试框架.md) 第 662-668 行:只有一个简短示例(`sign = MD5(...)`)
|
||||||
|
- [SKILL.md](C:\Users\92597.cursor\skills\hotime-tdd-testing\SKILL.md) 第 195-197 行:一行示例
|
||||||
|
|
||||||
|
**缺失**:"第二步:编写测试用例"(第 66-149 行)的完整订单示例中完全没有出现 `Note`,而这是新手学习的第一个入口。
|
||||||
|
|
||||||
|
## 需要修改的内容
|
||||||
|
|
||||||
|
### 1. 文档:第二步示例中加入 Note 使用
|
||||||
|
|
||||||
|
在 [Testing_API测试框架.md](d:\work\xbc\docs\Testing_API测试框架.md) 第 80-148 行的订单创建示例中,在**正确请求**部分加入 `Note` 调用,展示以下几种典型场景:
|
||||||
|
|
||||||
|
- 在正确请求处用 `Note` 说明参数值含义(如 `quantity=3` 是故意的用于校验总价 `29.9*3=89.7`)
|
||||||
|
|
||||||
|
### 2. 文档:用例编写范式中新增 Note 独立小节
|
||||||
|
|
||||||
|
在"用例编写范式"部分(第 219 行之后),在"错误用例"之前或之后,新增一个关于 Note 的说明段落或小节,覆盖以下典型使用场景:
|
||||||
|
|
||||||
|
- **参数值参考**:说明某个参数值的来源或含义,如 `Note("status: 0=待付款, 1=已付款, 2=已发货, 3=已完成")`
|
||||||
|
- **算法说明**:简要描述接口涉及的签名/加密算法,如 `Note("sign = MD5(smsProxyKey+timestamp)")`
|
||||||
|
- **接口/用例说明**:补充 desc 无法涵盖的上下文,如 `Note("需先调用 /api/cart/add 加入购物车")` 或 `Note("此接口支持批量操作,最多100条")`
|
||||||
|
- 在用例编写范式表格中增加 Note 的定位说明
|
||||||
|
|
||||||
|
### 3. 文档:同步更新 hotimev1.5 中的同名文件
|
||||||
|
|
||||||
|
[hotimev1.5/docs/Testing_API测试框架.md](d:\work\hotimev1.5\docs\Testing_API测试框架.md) 需要做相同的修改。
|
||||||
|
|
||||||
|
### 4. Skill 文件:补充 Note 指引
|
||||||
|
|
||||||
|
在 [SKILL.md](C:\Users\92597.cursor\skills\hotime-tdd-testing\SKILL.md) 中:
|
||||||
|
|
||||||
|
- 在 Phase 1 的 1.3 小节之后(或 1.2 和 1.3 之间),新增 Note 的使用指引
|
||||||
|
- 说明何时应该使用 Note(有必要的场景),给出 2-3 个典型示例
|
||||||
|
- 在 API 速查的"备注"部分补充更多场景示例
|
||||||
|
|
||||||
|
## 修改要点
|
||||||
|
|
||||||
|
- Note 不改变测试逻辑,只是给调试控制台和 api-spec.json 添加可读说明
|
||||||
|
- Note 是可选的,但在以下场景**建议必须**使用:
|
||||||
|
- 参数中有枚举值(如 status、type)需要说明各值含义
|
||||||
|
- 涉及签名/加密等算法的接口
|
||||||
|
- 接口有前置依赖或特殊调用顺序
|
||||||
|
- Note 可以放在链式调用的任意位置(`a.Note(...)` 或 `c.Note(...)`)
|
||||||
|
|
||||||
@@ -0,0 +1,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"
|
||||||
|
]
|
||||||
|
}
|
||||||
+8
-1
@@ -1,4 +1,11 @@
|
|||||||
/.idea/*
|
/.idea/*
|
||||||
.idea
|
.idea
|
||||||
/example/config/app.json
|
|
||||||
/example/tpt/demo/
|
/example/tpt/demo/
|
||||||
|
*.exe
|
||||||
|
/example/config
|
||||||
|
/.cursor/*.log
|
||||||
|
*.sql
|
||||||
|
*.py
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|||||||
@@ -1,6 +1,102 @@
|
|||||||
# hotime
|
# 小帮菜后端
|
||||||
golang web服务框架
|
测试方法
|
||||||
支持数据库db:mysql、sqlite3
|
D:\app\go1.23.1\bin\go.exe test ./dd/... -v
|
||||||
支持缓存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/QUICKSTART.md) | 5 分钟入门,安装配置、路由、中间件、基础数据库操作 |
|
||||||
|
| [HoTimeDB 使用说明](docs/HoTimeDB_使用说明.md) | 完整数据库 ORM 教程 |
|
||||||
|
| [HoTimeDB API 参考](docs/HoTimeDB_API参考.md) | 数据库 API 速查手册 |
|
||||||
|
| [Common 工具类](docs/Common_工具类使用说明.md) | Map/Slice/Obj 类型、类型转换、工具函数 |
|
||||||
|
| [代码生成器](docs/CodeGen_使用说明.md) | 自动 CRUD 代码生成、配置规则 |
|
||||||
|
| [数据库设计规范](docs/DatabaseDesign_数据库设计规范.md) | 表命名、字段命名、时间类型、必有字段规范 |
|
||||||
|
| [代码生成配置规范](docs/CodeConfig_代码生成配置规范.md) | codeConfig、菜单权限、字段规则配置说明 |
|
||||||
|
| [API 测试框架](docs/Testing_API测试框架.md) | 接口测试、事务隔离、覆盖率追踪、API 调试控制台生成 |
|
||||||
|
| [优雅停机](docs/graceful-shutdown.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
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**HoTime** - 让 Go Web 开发更简单、更高效
|
||||||
|
|||||||
+345
-124
@@ -1,47 +1,105 @@
|
|||||||
package hotime
|
package hotime
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
stdlog "log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
. "code.hoteas.com/golang/hotime/cache"
|
. "code.hoteas.com/golang/hotime/cache"
|
||||||
"code.hoteas.com/golang/hotime/code"
|
"code.hoteas.com/golang/hotime/code"
|
||||||
. "code.hoteas.com/golang/hotime/common"
|
. "code.hoteas.com/golang/hotime/common"
|
||||||
. "code.hoteas.com/golang/hotime/db"
|
. "code.hoteas.com/golang/hotime/db"
|
||||||
. "code.hoteas.com/golang/hotime/log"
|
"code.hoteas.com/golang/hotime/log"
|
||||||
"database/sql"
|
mysql "github.com/go-sql-driver/mysql"
|
||||||
"fmt"
|
|
||||||
"github.com/sirupsen/logrus"
|
|
||||||
"io/ioutil"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Application struct {
|
type Application struct {
|
||||||
MakeCodeRouter map[string]*code.MakeCode
|
MakeCodeRouter map[string]*code.MakeCode
|
||||||
MethodRouter
|
MethodRouter
|
||||||
Router
|
Router
|
||||||
Error
|
Log *log.Logger
|
||||||
Log *logrus.Logger
|
WebConnectLog *log.Logger
|
||||||
WebConnectLog *logrus.Logger
|
|
||||||
Port string //端口号
|
Port string //端口号
|
||||||
TLSPort string //ssl访问端口号
|
TLSPort string //ssl访问端口号
|
||||||
connectListener []func(that *Context) bool //所有的访问监听,true按原计划继续使用,false表示有监听器处理
|
connectListener []func(that *Context) bool //所有的访问监听,true按原计划继续使用,false表示有监听器处理
|
||||||
connectDbFunc func(err ...*Error) (master, slave *sql.DB)
|
connectDbFunc func() (master, slave *sql.DB)
|
||||||
configPath string
|
configPath string
|
||||||
Config Map
|
Config Map
|
||||||
Db HoTimeDB
|
Db HoTimeDB
|
||||||
*HoTimeCache
|
*HoTimeCache
|
||||||
*http.Server
|
*http.Server
|
||||||
http.Handler
|
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) {
|
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)
|
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 启动实例
|
// Run 启动实例
|
||||||
func (that *Application) Run(router Router) {
|
func (that *Application) Run(router Router) {
|
||||||
//如果没有设置配置自动生成配置
|
//如果没有设置配置自动生成配置
|
||||||
@@ -68,18 +126,20 @@ func (that *Application) Run(router Router) {
|
|||||||
if that.Router == nil {
|
if that.Router == nil {
|
||||||
that.Router = Router{}
|
that.Router = Router{}
|
||||||
}
|
}
|
||||||
for k, v := range router {
|
for k, _ := range router {
|
||||||
|
v := router[k]
|
||||||
if that.Router[k] == nil {
|
if that.Router[k] == nil {
|
||||||
that.Router[k] = v
|
that.Router[k] = v
|
||||||
}
|
}
|
||||||
//直达接口层复用
|
//直达接口层复用
|
||||||
for k1, v1 := range v {
|
for k1, _ := range v {
|
||||||
|
v1 := v[k1]
|
||||||
if that.Router[k][k1] == nil {
|
if that.Router[k][k1] == nil {
|
||||||
that.Router[k][k1] = v1
|
that.Router[k][k1] = v1
|
||||||
}
|
}
|
||||||
|
|
||||||
for k2, v2 := range v1 {
|
for k2, _ := range v1 {
|
||||||
|
v2 := v1[k2]
|
||||||
that.Router[k][k1][k2] = v2
|
that.Router[k][k1][k2] = v2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,8 +185,11 @@ func (that *Application) Run(router Router) {
|
|||||||
//异常处理
|
//异常处理
|
||||||
defer func() {
|
defer func() {
|
||||||
if err := recover(); err != nil {
|
if err := recover(); err != nil {
|
||||||
|
if that.shuttingDown.Load() {
|
||||||
|
return // 停机过程中的 panic 不触发重启
|
||||||
|
}
|
||||||
//that.SetError(errors.New(fmt.Sprint(err)), LOG_FMT)
|
//that.SetError(errors.New(fmt.Sprint(err)), LOG_FMT)
|
||||||
that.Log.Warn(err)
|
that.Log.Warnf("%v", err)
|
||||||
|
|
||||||
that.Run(router)
|
that.Run(router)
|
||||||
}
|
}
|
||||||
@@ -146,8 +209,10 @@ func (that *Application) Run(router Router) {
|
|||||||
//启动服务
|
//启动服务
|
||||||
that.Server.Addr = ":" + that.Port
|
that.Server.Addr = ":" + that.Port
|
||||||
err := that.Server.ListenAndServe()
|
err := that.Server.ListenAndServe()
|
||||||
that.Log.Error(err)
|
if err != nil && err != http.ErrServerClosed {
|
||||||
ch <- 1
|
that.Log.Error().Err(err).Msg("HTTP 服务退出")
|
||||||
|
ch <- 1
|
||||||
|
}
|
||||||
|
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
@@ -160,33 +225,54 @@ func (that *Application) Run(router Router) {
|
|||||||
//启动服务
|
//启动服务
|
||||||
that.Server.Addr = ":" + that.TLSPort
|
that.Server.Addr = ":" + that.TLSPort
|
||||||
err := that.Server.ListenAndServeTLS(that.Config.GetString("tlsCert"), that.Config.GetString("tlsKey"))
|
err := that.Server.ListenAndServeTLS(that.Config.GetString("tlsCert"), that.Config.GetString("tlsKey"))
|
||||||
that.Log.Error(err)
|
if err != nil && err != http.ErrServerClosed {
|
||||||
ch <- 2
|
that.Log.Error().Err(err).Msg("HTTPS 服务退出")
|
||||||
|
ch <- 2
|
||||||
|
}
|
||||||
|
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
if ObjToCeilInt(that.Port) == 0 && ObjToCeilInt(that.TLSPort) == 0 {
|
if ObjToCeilInt(that.Port) == 0 && ObjToCeilInt(that.TLSPort) == 0 {
|
||||||
that.Log.Error("没有端口启用")
|
that.Log.Errorf("没有端口启用")
|
||||||
return
|
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
|
value := <-ch
|
||||||
|
|
||||||
that.Log.Error("启动服务失败 : " + ObjToStr(value))
|
that.Log.Errorf("启动服务失败 : %s", ObjToStr(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetConnectDB 启动实例
|
// 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.connectDbFunc = connect
|
||||||
|
|
||||||
that.Db.SetConnect(that.connectDbFunc, &that.Error)
|
that.Db.SetConnect(that.connectDbFunc)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetDefault 默认配置缓存和session实现
|
// 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()
|
that.SetConfig()
|
||||||
|
|
||||||
if connect != nil {
|
if connect != nil {
|
||||||
@@ -199,19 +285,15 @@ func (that *Application) SetDefault(connect func(err ...*Error) (*sql.DB, *sql.D
|
|||||||
// SetCache 设置配置文件路径全路径或者相对路径
|
// SetCache 设置配置文件路径全路径或者相对路径
|
||||||
func (that *Application) SetCache() {
|
func (that *Application) SetCache() {
|
||||||
cacheIns := HoTimeCache{}
|
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
|
that.HoTimeCache = &cacheIns
|
||||||
//mode生产模式开启的时候才开启数据库缓存,防止调试出问题
|
that.Db.HoTimeCache = &cacheIns
|
||||||
if that.Config.GetInt("mode") == 0 {
|
|
||||||
that.Db.HoTimeCache = &cacheIns
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetConfig 设置配置文件路径全路径或者相对路径
|
// SetConfig 设置配置文件路径全路径或者相对路径
|
||||||
func (that *Application) SetConfig(configPath ...string) {
|
func (that *Application) SetConfig(configPath ...string) {
|
||||||
|
|
||||||
that.Log = GetLog("", true)
|
that.Log = log.NewLogger(1, "", 100)
|
||||||
that.Error = Error{Logger: that.Log}
|
|
||||||
if len(configPath) != 0 {
|
if len(configPath) != 0 {
|
||||||
that.configPath = configPath[0]
|
that.configPath = configPath[0]
|
||||||
}
|
}
|
||||||
@@ -222,55 +304,66 @@ func (that *Application) SetConfig(configPath ...string) {
|
|||||||
//加载配置文件
|
//加载配置文件
|
||||||
btes, err := ioutil.ReadFile(that.configPath)
|
btes, err := ioutil.ReadFile(that.configPath)
|
||||||
that.Config = DeepCopyMap(Config).(Map)
|
that.Config = DeepCopyMap(Config).(Map)
|
||||||
|
configErr := false
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
||||||
cmap := Map{}
|
cmap := Map{}
|
||||||
//文件是否损坏
|
cmap.JsonToMap(string(btes))
|
||||||
cmap.JsonToMap(string(btes), &that.Error)
|
|
||||||
|
|
||||||
for k, v := range cmap {
|
for k, v := range cmap {
|
||||||
that.Config[k] = v //程序配置
|
that.Config[k] = v
|
||||||
Config[k] = v //系统配置
|
Config[k] = v
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
that.Log.Error("配置文件不存在,或者配置出错,使用缺省默认配置")
|
that.Log.Errorf("配置文件不存在,或者配置出错,使用缺省默认配置")
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if that.Error.GetError() != nil {
|
if !configErr {
|
||||||
fmt.Println(that.Error.GetError().Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
//文件如果损坏则不写入配置防止配置文件数据丢失
|
|
||||||
if that.Error.GetError() == nil {
|
|
||||||
//var configByte bytes.Buffer
|
|
||||||
|
|
||||||
//判断配置文件是否序列有变化,有则修改配置,无则不变
|
|
||||||
//fmt.Println(len(btes))
|
|
||||||
configStr := that.Config.ToJsonString()
|
configStr := that.Config.ToJsonString()
|
||||||
if len(btes) != 0 && configStr == string(btes) {
|
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)
|
logLevel := that.Config.GetCeilInt("logLevel")
|
||||||
that.Error = Error{Logger: that.Log}
|
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") {
|
if that.Config.Get("webConnectLogShow") == nil || that.Config.GetBool("webConnectLogShow") {
|
||||||
that.WebConnectLog = GetLog(that.Config.GetString("webConnectLogFile"), false)
|
that.WebConnectLog = log.NewLoggerNoCaller(1, that.Config.GetString("webConnectLogFile"), 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 重定向 os.Stdout 和标准 log 包,捕获 fmt.Println 等绕过 HoTime Logger 的输出
|
||||||
|
redirectStdout(that.Log)
|
||||||
|
// 重定向 os.Stderr,捕获 panic 栈、MySQL driver errLog 等写 stderr 的输出
|
||||||
|
// 注意:Logger 在此之前已创建,ConsoleWriter.Out 已绑定到原始 os.Stderr,无循环风险
|
||||||
|
redirectStderr(that.Log)
|
||||||
|
|
||||||
|
// 接入 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则停止传输
|
// SetConnectListener 连接判断,返回false继续传输至控制层,true则停止传输
|
||||||
@@ -283,7 +376,7 @@ func (that *Application) SetConnectListener(lis func(that *Context) (isFinished
|
|||||||
//
|
//
|
||||||
//}
|
//}
|
||||||
|
|
||||||
//序列化链接
|
// 序列化链接
|
||||||
func (that *Application) urlSer(url string) (string, []string) {
|
func (that *Application) urlSer(url string) (string, []string) {
|
||||||
q := strings.Index(url, "?")
|
q := strings.Index(url, "?")
|
||||||
if q == -1 {
|
if q == -1 {
|
||||||
@@ -329,6 +422,9 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) {
|
|||||||
if len(token) == 32 {
|
if len(token) == 32 {
|
||||||
sessionId = token
|
sessionId = token
|
||||||
//没有token,则查阅session
|
//没有token,则查阅session
|
||||||
|
if cookie == nil || cookie.Value != sessionId {
|
||||||
|
needSetCookie = sessionId
|
||||||
|
}
|
||||||
} else if err == nil && cookie.Value != "" {
|
} else if err == nil && cookie.Value != "" {
|
||||||
sessionId = cookie.Value
|
sessionId = cookie.Value
|
||||||
//session也没有则判断是否创建cookie
|
//session也没有则判断是否创建cookie
|
||||||
@@ -357,32 +453,41 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) {
|
|||||||
defer func() {
|
defer func() {
|
||||||
//是否展示日志
|
//是否展示日志
|
||||||
if that.WebConnectLog != nil {
|
if that.WebConnectLog != nil {
|
||||||
ipStr := Substr(context.Req.RemoteAddr, 0, strings.Index(context.Req.RemoteAddr, ":"))
|
|
||||||
//负载均衡优化
|
//负载均衡优化
|
||||||
if ipStr == "127.0.0.1" {
|
ipStr := ""
|
||||||
if req.Header.Get("X-Forwarded-For") != "" {
|
if req.Header.Get("X-Forwarded-For") != "" {
|
||||||
ipStr = req.Header.Get("X-Forwarded-For")
|
ipStr = req.Header.Get("X-Forwarded-For")
|
||||||
} else if req.Header.Get("X-Real-IP") != "" {
|
} else if req.Header.Get("X-Real-IP") != "" {
|
||||||
ipStr = req.Header.Get("X-Real-IP")
|
ipStr = req.Header.Get("X-Real-IP")
|
||||||
}
|
}
|
||||||
|
//负载均衡优化
|
||||||
|
if ipStr == "" {
|
||||||
|
//RemoteAddr := that.Req.RemoteAddr
|
||||||
|
ipStr = Substr(context.Req.RemoteAddr, 0, strings.Index(context.Req.RemoteAddr, ":"))
|
||||||
}
|
}
|
||||||
|
|
||||||
that.WebConnectLog.Infoln(ipStr, context.Req.Method,
|
that.WebConnectLog.Info().
|
||||||
"time cost:", ObjToFloat64(time.Now().UnixNano()-nowUnixTime.UnixNano())/1000000.00, "ms",
|
Str("ip", ipStr).
|
||||||
"data length:", ObjToFloat64(context.DataSize)/1000.00, "KB", context.HandlerStr)
|
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)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
//访问拦截true继续false暂停
|
//访问拦截true继续false暂停
|
||||||
connectListenerLen := len(that.connectListener)
|
connectListenerLen := len(that.connectListener) - 1
|
||||||
|
|
||||||
for i := connectListenerLen - 1; i >= 0; i-- {
|
|
||||||
|
|
||||||
if that.connectListener[i](&context) {
|
|
||||||
|
|
||||||
|
for true {
|
||||||
|
if connectListenerLen < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if that.connectListener[connectListenerLen](&context) {
|
||||||
context.View()
|
context.View()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
connectListenerLen--
|
||||||
}
|
}
|
||||||
|
|
||||||
//接口服务
|
//接口服务
|
||||||
@@ -400,8 +505,8 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
//url赋值
|
//url赋值,静态文件必须使用原始路径(Linux文件系统大小写敏感)
|
||||||
path := that.Config.GetString("tpt") + tempHandlerStr
|
path := that.Config.GetString("tpt") + context.HandlerStr
|
||||||
|
|
||||||
//判断是否为默认
|
//判断是否为默认
|
||||||
if path[len(path)-1] == '/' {
|
if path[len(path)-1] == '/' {
|
||||||
@@ -429,7 +534,7 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) {
|
|||||||
|
|
||||||
//设置header
|
//设置header
|
||||||
delete(header, "Content-Type")
|
delete(header, "Content-Type")
|
||||||
if that.Config.GetInt("mode") == 0 {
|
if that.Config.GetCeilInt("logLevel") == 0 {
|
||||||
header.Set("Cache-Control", "public")
|
header.Set("Cache-Control", "public")
|
||||||
} else {
|
} else {
|
||||||
header.Set("Cache-Control", "no-cache")
|
header.Set("Cache-Control", "no-cache")
|
||||||
@@ -437,7 +542,7 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) {
|
|||||||
|
|
||||||
t := strings.LastIndex(path, ".")
|
t := strings.LastIndex(path, ".")
|
||||||
if t != -1 {
|
if t != -1 {
|
||||||
tt := path[t:]
|
tt := strings.ToLower(path[t:])
|
||||||
|
|
||||||
if MimeMaps[tt] != "" {
|
if MimeMaps[tt] != "" {
|
||||||
|
|
||||||
@@ -456,6 +561,7 @@ func (that *Application) crossDomain(context *Context, sessionId string) {
|
|||||||
if context.Config.GetString("crossDomain") == "" {
|
if context.Config.GetString("crossDomain") == "" {
|
||||||
if sessionId != "" {
|
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
|
return
|
||||||
@@ -485,7 +591,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-Methods", "GET,POST,OPTIONS,PUT,DELETE")
|
||||||
header.Set("Access-Control-Allow-Credentials", "true")
|
header.Set("Access-Control-Allow-Credentials", "true")
|
||||||
header.Set("Access-Control-Expose-Headers", "*")
|
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 != "" {
|
if sessionId != "" {
|
||||||
//跨域允许需要设置cookie的允许跨域https才有效果
|
//跨域允许需要设置cookie的允许跨域https才有效果
|
||||||
@@ -500,7 +606,8 @@ func (that *Application) crossDomain(context *Context, sessionId string) {
|
|||||||
if (origin != "" && strings.Contains(origin, remoteHost)) || strings.Contains(refer, remoteHost) {
|
if (origin != "" && strings.Contains(origin, remoteHost)) || strings.Contains(refer, remoteHost) {
|
||||||
|
|
||||||
if sessionId != "" {
|
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
|
return
|
||||||
@@ -531,7 +638,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-Methods", "GET,POST,OPTIONS,PUT,DELETE")
|
||||||
header.Set("Access-Control-Allow-Credentials", "true")
|
header.Set("Access-Control-Allow-Credentials", "true")
|
||||||
header.Set("Access-Control-Expose-Headers", "*")
|
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 != "" {
|
if sessionId != "" {
|
||||||
//跨域允许需要设置cookie的允许跨域https才有效果
|
//跨域允许需要设置cookie的允许跨域https才有效果
|
||||||
@@ -540,7 +647,68 @@ 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。
|
||||||
|
// 捕获 fmt.Println/fmt.Printf 等绕过 HoTime 日志系统的输出,
|
||||||
|
// 以 INFO 级别 + source="stdout" 字段写入,后续经 SeqWriter 推送到 Seq。
|
||||||
|
func redirectStdout(l *log.Logger) {
|
||||||
|
r, w, err := os.Pipe()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
os.Stdout = w
|
||||||
|
stdlog.SetOutput(w)
|
||||||
|
go func() {
|
||||||
|
scanner := bufio.NewScanner(r)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if line != "" {
|
||||||
|
l.Info().Str("source", "stdout").Msg(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
l.Errorf("[stdout redirect] scanner error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// redirectStderr 重定向 os.Stderr 到 HoTime Logger(level=error, source=stderr)。
|
||||||
|
// 捕获 Go runtime panic 栈、MySQL driver errLog 等直写 stderr 的输出。
|
||||||
|
// 必须在 Logger 创建之后调用:ConsoleWriter.Out 已绑定原始 os.Stderr 文件指针,
|
||||||
|
// 重定向后 ConsoleWriter 仍写真实终端,不会形成循环。
|
||||||
|
func redirectStderr(l *log.Logger) {
|
||||||
|
r, w, err := os.Pipe()
|
||||||
|
if err != nil {
|
||||||
|
l.Warnf("[redirectStderr] create pipe failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
os.Stderr = w
|
||||||
|
go func() {
|
||||||
|
scanner := bufio.NewScanner(r)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if line != "" {
|
||||||
|
l.Error().Str("source", "stderr").Msg(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
l.Warnf("[redirectStderr] scanner error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init 初始化application
|
||||||
func Init(config string) *Application {
|
func Init(config string) *Application {
|
||||||
appIns := Application{}
|
appIns := Application{}
|
||||||
//手动模式,
|
//手动模式,
|
||||||
@@ -566,14 +734,9 @@ func Init(config string) *Application {
|
|||||||
codeMake["name"] = codeMake.GetString("table")
|
codeMake["name"] = codeMake.GetString("table")
|
||||||
}
|
}
|
||||||
|
|
||||||
if appIns.Config.GetInt("mode") > 0 {
|
appIns.MakeCodeRouter[codeMake.GetString("name")] = &code.MakeCode{Log: appIns.Log}
|
||||||
appIns.MakeCodeRouter[codeMake.GetString("name")] = &code.MakeCode{Error: appIns.Error}
|
appIns.MakeCodeRouter[codeMake.GetString("name")].Db2JSON(&appIns.Db, codeMake)
|
||||||
|
|
||||||
appIns.MakeCodeRouter[codeMake.GetString("name")].Db2JSON(&appIns.Db, codeMake)
|
|
||||||
} else {
|
|
||||||
appIns.MakeCodeRouter[codeMake.GetString("name")] = &code.MakeCode{Error: appIns.Error}
|
|
||||||
appIns.MakeCodeRouter[codeMake.GetString("name")].Db2JSON(nil, codeMake)
|
|
||||||
}
|
|
||||||
//接入动态代码层
|
//接入动态代码层
|
||||||
if appIns.Router == nil {
|
if appIns.Router == nil {
|
||||||
appIns.Router = Router{}
|
appIns.Router = Router{}
|
||||||
@@ -581,20 +744,30 @@ func Init(config string) *Application {
|
|||||||
|
|
||||||
//appIns.Router[codeMake.GetString("name")] = TptProject
|
//appIns.Router[codeMake.GetString("name")] = TptProject
|
||||||
appIns.Router[codeMake.GetString("name")] = Proj{}
|
appIns.Router[codeMake.GetString("name")] = Proj{}
|
||||||
|
|
||||||
for k2, _ := range TptProject {
|
for k2, _ := range TptProject {
|
||||||
appIns.Router[codeMake.GetString("name")][k2] = Ctr{}
|
if appIns.Router[codeMake.GetString("name")][k2] == nil {
|
||||||
for k3, v3 := range TptProject[k2] {
|
appIns.Router[codeMake.GetString("name")][k2] = Ctr{}
|
||||||
|
}
|
||||||
|
for k3, _ := range TptProject[k2] {
|
||||||
|
v3 := TptProject[k2][k3]
|
||||||
appIns.Router[codeMake.GetString("name")][k2][k3] = v3
|
appIns.Router[codeMake.GetString("name")][k2][k3] = v3
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for k1, _ := range appIns.MakeCodeRouter[codeMake.GetString("name")].TableColumns {
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
setMakeCodeListener(codeMake.GetString("name"), &appIns)
|
||||||
setMakeCodeLintener(codeMake.GetString("name"), &appIns)
|
|
||||||
}()
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -608,12 +781,16 @@ func SetDB(appIns *Application) {
|
|||||||
db := appIns.Config.GetMap("db")
|
db := appIns.Config.GetMap("db")
|
||||||
dbSqlite := db.GetMap("sqlite")
|
dbSqlite := db.GetMap("sqlite")
|
||||||
dbMysql := db.GetMap("mysql")
|
dbMysql := db.GetMap("mysql")
|
||||||
|
dbDm := db.GetMap("dm")
|
||||||
if db != nil && dbSqlite != nil {
|
if db != nil && dbSqlite != nil {
|
||||||
SetSqliteDB(appIns, dbSqlite)
|
SetSqliteDB(appIns, dbSqlite)
|
||||||
}
|
}
|
||||||
if db != nil && dbMysql != nil {
|
if db != nil && dbMysql != nil {
|
||||||
SetMysqlDB(appIns, dbMysql)
|
SetMysqlDB(appIns, dbMysql)
|
||||||
}
|
}
|
||||||
|
if db != nil && dbDm != nil {
|
||||||
|
SetDmDB(appIns, dbDm)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
func SetMysqlDB(appIns *Application, config Map) {
|
func SetMysqlDB(appIns *Application, config Map) {
|
||||||
|
|
||||||
@@ -621,50 +798,71 @@ func SetMysqlDB(appIns *Application, config Map) {
|
|||||||
appIns.Db.DBName = config.GetString("name")
|
appIns.Db.DBName = config.GetString("name")
|
||||||
appIns.Db.Prefix = config.GetString("prefix")
|
appIns.Db.Prefix = config.GetString("prefix")
|
||||||
appIns.Db.Log = appIns.Log
|
appIns.Db.Log = appIns.Log
|
||||||
appIns.Db.Mode = appIns.Config.GetCeilInt("mode")
|
appIns.SetConnectDB(func() (master, slave *sql.DB) {
|
||||||
appIns.SetConnectDB(func(err ...*Error) (master, slave *sql.DB) {
|
|
||||||
//master数据库配置
|
|
||||||
query := config.GetString("user") + ":" + config.GetString("password") +
|
query := config.GetString("user") + ":" + config.GetString("password") +
|
||||||
"@tcp(" + config.GetString("host") + ":" + config.GetString("port") + ")/" + config.GetString("name") + "?charset=utf8"
|
"@tcp(" + config.GetString("host") + ":" + config.GetString("port") + ")/" + config.GetString("name") + "?charset=utf8"
|
||||||
DB, e := sql.Open("mysql", query)
|
DB, e := sql.Open("mysql", query)
|
||||||
if e != nil && len(err) != 0 {
|
if e != nil {
|
||||||
err[0].SetError(e)
|
appIns.Log.Error().Err(e).Msg("MySQL 主库连接失败")
|
||||||
}
|
}
|
||||||
master = DB
|
master = DB
|
||||||
//slave数据库配置
|
|
||||||
configSlave := config.GetMap("slave")
|
configSlave := config.GetMap("slave")
|
||||||
if configSlave != nil {
|
if configSlave != nil {
|
||||||
query := configSlave.GetString("user") + ":" + configSlave.GetString("password") +
|
query := configSlave.GetString("user") + ":" + configSlave.GetString("password") +
|
||||||
"@tcp(" + config.GetString("host") + ":" + configSlave.GetString("port") + ")/" + configSlave.GetString("name") + "?charset=utf8"
|
"@tcp(" + config.GetString("host") + ":" + configSlave.GetString("port") + ")/" + configSlave.GetString("name") + "?charset=utf8"
|
||||||
DB1, e := sql.Open("mysql", query)
|
DB1, e := sql.Open("mysql", query)
|
||||||
if e != nil && len(err) != 0 {
|
if e != nil {
|
||||||
err[0].SetError(e)
|
appIns.Log.Error().Err(e).Msg("MySQL 从库连接失败")
|
||||||
}
|
}
|
||||||
slave = DB1
|
slave = DB1
|
||||||
}
|
}
|
||||||
|
|
||||||
return master, slave
|
return master, slave
|
||||||
//return DB
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
func SetSqliteDB(appIns *Application, config Map) {
|
func SetSqliteDB(appIns *Application, config Map) {
|
||||||
|
|
||||||
appIns.Db.Type = "sqlite"
|
appIns.Db.Type = "sqlite"
|
||||||
appIns.Db.Prefix = config.GetString("prefix")
|
appIns.Db.Prefix = config.GetString("prefix")
|
||||||
appIns.Db.Mode = appIns.Config.GetCeilInt("mode")
|
|
||||||
appIns.Db.Log = appIns.Log
|
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"))
|
db, e := sql.Open("sqlite3", config.GetString("path"))
|
||||||
if e != nil && len(err) != 0 {
|
if e != nil {
|
||||||
err[0].SetError(e)
|
appIns.Log.Error().Err(e).Msg("SQLite 连接失败")
|
||||||
}
|
}
|
||||||
master = db
|
master = db
|
||||||
|
|
||||||
return master, slave
|
return master, slave
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func setMakeCodeLintener(name string, appIns *Application) {
|
func SetDmDB(appIns *Application, config Map) {
|
||||||
|
appIns.Db.Type = "dm"
|
||||||
|
appIns.Db.DBName = config.GetString("name")
|
||||||
|
appIns.Db.Prefix = config.GetString("prefix")
|
||||||
|
appIns.Db.Log = appIns.Log
|
||||||
|
appIns.SetConnectDB(func() (master, slave *sql.DB) {
|
||||||
|
query := "dm://" + config.GetString("user") + ":" + config.GetString("password") +
|
||||||
|
"@" + config.GetString("host") + ":" + config.GetString("port") + "?schema=" + config.GetString("name")
|
||||||
|
DB, e := sql.Open("dm", query)
|
||||||
|
if e != nil {
|
||||||
|
appIns.Log.Error().Err(e).Msg("达梦主库连接失败")
|
||||||
|
}
|
||||||
|
master = DB
|
||||||
|
configSlave := config.GetMap("slave")
|
||||||
|
if configSlave != nil {
|
||||||
|
querySlave := "dm://" + configSlave.GetString("user") + ":" + configSlave.GetString("password") +
|
||||||
|
"@" + configSlave.GetString("host") + ":" + configSlave.GetString("port") + "?schema=" + configSlave.GetString("name")
|
||||||
|
DB1, e := sql.Open("dm", querySlave)
|
||||||
|
if e != nil {
|
||||||
|
appIns.Log.Error().Err(e).Msg("达梦从库连接失败")
|
||||||
|
}
|
||||||
|
slave = DB1
|
||||||
|
}
|
||||||
|
return master, slave
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func setMakeCodeListener(name string, appIns *Application) {
|
||||||
appIns.SetConnectListener(func(context *Context) (isFinished bool) {
|
appIns.SetConnectListener(func(context *Context) (isFinished bool) {
|
||||||
|
|
||||||
codeIns := appIns.MakeCodeRouter[name]
|
codeIns := appIns.MakeCodeRouter[name]
|
||||||
@@ -684,13 +882,18 @@ func setMakeCodeLintener(name string, appIns *Application) {
|
|||||||
if context.RouterString[1] == "hotime" && context.RouterString[2] == "config" {
|
if context.RouterString[1] == "hotime" && context.RouterString[2] == "config" {
|
||||||
return isFinished
|
return isFinished
|
||||||
}
|
}
|
||||||
|
if context.RouterString[1] == "hotime" && context.RouterString[2] == "wallpaper" {
|
||||||
|
return isFinished
|
||||||
|
}
|
||||||
if context.Session(codeIns.FileConfig.GetString("table")+"_id").Data == nil {
|
if context.Session(codeIns.FileConfig.GetString("table")+"_id").Data == nil {
|
||||||
context.Display(2, "你还没有登录")
|
context.Display(2, "你还没有登录")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if context.RouterString[0] != name {
|
||||||
|
return isFinished
|
||||||
|
}
|
||||||
if len(context.RouterString) < 2 || len(context.RouterString) > 3 ||
|
if len(context.RouterString) < 2 || len(context.RouterString) > 3 ||
|
||||||
!(context.Router[context.RouterString[0]] != nil &&
|
!(context.Router[context.RouterString[0]] != nil &&
|
||||||
context.Router[context.RouterString[0]][context.RouterString[1]] != nil) {
|
context.Router[context.RouterString[0]][context.RouterString[1]] != nil) {
|
||||||
@@ -727,6 +930,16 @@ func setMakeCodeLintener(name string, appIns *Application) {
|
|||||||
context.Req.Method == "POST" {
|
context.Req.Method == "POST" {
|
||||||
return isFinished
|
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 &&
|
if len(context.RouterString) == 3 &&
|
||||||
context.Req.Method == "GET" {
|
context.Req.Method == "GET" {
|
||||||
@@ -758,7 +971,15 @@ func setMakeCodeLintener(name string, appIns *Application) {
|
|||||||
context.Router[context.RouterString[0]][context.RouterString[1]]["remove"](context)
|
context.Router[context.RouterString[0]][context.RouterString[1]]["remove"](context)
|
||||||
}
|
}
|
||||||
|
|
||||||
context.View()
|
//context.View()
|
||||||
return true
|
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 (
|
import (
|
||||||
. "code.hoteas.com/golang/hotime/common"
|
. "code.hoteas.com/golang/hotime/common"
|
||||||
"errors"
|
"code.hoteas.com/golang/hotime/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HoTimeCache 可配置memory,db,redis,默认启用memory,默认优先级为memory>redis>db,memory与数据库缓存设置项一致,
|
// HoTimeCache 可配置memory,db,redis,默认启用memory,默认优先级为memory>redis>db,memory与数据库缓存设置项一致,
|
||||||
//缓存数据填充会自动反方向反哺,加入memory缓存过期将自动从redis更新,但memory永远不会更新redis,如果是集群建议不要开启memory,配置即启用
|
// 缓存数据填充会自动反方向反哺,加入memory缓存过期将自动从redis更新,但memory永远不会更新redis,如果是集群建议不要开启memory,配置即启用
|
||||||
type HoTimeCache struct {
|
type HoTimeCache struct {
|
||||||
*Error
|
Log *log.Logger
|
||||||
dbCache *CacheDb
|
dbCache *CacheDb
|
||||||
redisCache *CacheRedis
|
redisCache *CacheRedis
|
||||||
memoryCache *CacheMemory
|
memoryCache *CacheMemory
|
||||||
@@ -92,7 +92,7 @@ func (that *HoTimeCache) Db(key string, data ...interface{}) *Obj {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//redis缓存有
|
//db缓存有
|
||||||
if that.dbCache != nil && that.dbCache.DbSet {
|
if that.dbCache != nil && that.dbCache.DbSet {
|
||||||
reData = that.dbCache.Cache(key, data...)
|
reData = that.dbCache.Cache(key, data...)
|
||||||
if reData.Data != nil {
|
if reData.Data != nil {
|
||||||
@@ -119,7 +119,7 @@ func (that *HoTimeCache) Db(key string, data ...interface{}) *Obj {
|
|||||||
if that.redisCache != nil && that.redisCache.DbSet {
|
if that.redisCache != nil && that.redisCache.DbSet {
|
||||||
reData = that.redisCache.Cache(key, data...)
|
reData = that.redisCache.Cache(key, data...)
|
||||||
}
|
}
|
||||||
//redis缓存有
|
//db缓存有
|
||||||
if that.dbCache != nil && that.dbCache.DbSet {
|
if that.dbCache != nil && that.dbCache.DbSet {
|
||||||
reData = that.dbCache.Cache(key, data...)
|
reData = that.dbCache.Cache(key, data...)
|
||||||
}
|
}
|
||||||
@@ -132,7 +132,7 @@ func (that *HoTimeCache) Cache(key string, data ...interface{}) *Obj {
|
|||||||
//内存缓存有
|
//内存缓存有
|
||||||
if that.memoryCache != nil {
|
if that.memoryCache != nil {
|
||||||
reData = that.memoryCache.Cache(key, data...)
|
reData = that.memoryCache.Cache(key, data...)
|
||||||
if reData != nil {
|
if reData != nil && reData.Data != nil {
|
||||||
return reData
|
return reData
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,8 +140,7 @@ func (that *HoTimeCache) Cache(key string, data ...interface{}) *Obj {
|
|||||||
//redis缓存有
|
//redis缓存有
|
||||||
if that.redisCache != nil {
|
if that.redisCache != nil {
|
||||||
reData = that.redisCache.Cache(key, data...)
|
reData = that.redisCache.Cache(key, data...)
|
||||||
if reData.Data != nil {
|
if reData != nil && reData.Data != nil {
|
||||||
|
|
||||||
if that.memoryCache != nil {
|
if that.memoryCache != nil {
|
||||||
that.memoryCache.Cache(key, reData.Data)
|
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 {
|
if that.dbCache != nil {
|
||||||
reData = that.dbCache.Cache(key, data...)
|
reData = that.dbCache.Cache(key, data...)
|
||||||
if reData.Data != nil {
|
if reData != nil && reData.Data != nil {
|
||||||
if that.memoryCache != nil {
|
if that.memoryCache != nil {
|
||||||
that.memoryCache.Cache(key, reData.Data)
|
that.memoryCache.Cache(key, reData.Data)
|
||||||
}
|
}
|
||||||
@@ -174,23 +173,222 @@ func (that *HoTimeCache) Cache(key string, data ...interface{}) *Obj {
|
|||||||
if that.redisCache != nil {
|
if that.redisCache != nil {
|
||||||
reData = that.redisCache.Cache(key, data...)
|
reData = that.redisCache.Cache(key, data...)
|
||||||
}
|
}
|
||||||
//redis缓存有
|
//db缓存有
|
||||||
if that.dbCache != nil {
|
if that.dbCache != nil {
|
||||||
reData = that.dbCache.Cache(key, data...)
|
reData = that.dbCache.Cache(key, data...)
|
||||||
}
|
}
|
||||||
return reData
|
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 {
|
if config == nil {
|
||||||
config = Map{}
|
config = Map{}
|
||||||
}
|
}
|
||||||
if err[0] != nil {
|
that.Log = logger
|
||||||
that.Error = err[0]
|
|
||||||
}
|
|
||||||
that.Config = config
|
that.Config = config
|
||||||
//memory配置初始化
|
|
||||||
memory := that.Config.GetMap("memory")
|
memory := that.Config.GetMap("memory")
|
||||||
if memory == nil {
|
if memory == nil {
|
||||||
memory = Map{
|
memory = Map{
|
||||||
@@ -199,79 +397,74 @@ func (that *HoTimeCache) Init(config Map, hotimeDb HoTimeDBInterface, err ...*Er
|
|||||||
"sort": 0,
|
"sort": 0,
|
||||||
"timeout": 60 * 60 * 2,
|
"timeout": 60 * 60 * 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
if memory.Get("db") == nil {
|
if memory.Get("db") == nil {
|
||||||
memory["db"] = true
|
memory["db"] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if memory.Get("session") == nil {
|
if memory.Get("session") == nil {
|
||||||
memory["session"] = true
|
memory["session"] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if memory.Get("timeout") == nil {
|
if memory.Get("timeout") == nil {
|
||||||
memory["timeout"] = 60 * 60 * 2
|
memory["timeout"] = 60 * 60 * 2
|
||||||
}
|
}
|
||||||
that.Config["memory"] = memory
|
that.Config["memory"] = memory
|
||||||
that.memoryCache = &CacheMemory{TimeOut: memory.GetCeilInt64("timeout"),
|
that.memoryCache = &CacheMemory{TimeOut: memory.GetCeilInt64("timeout"),
|
||||||
DbSet: memory.GetBool("db"), SessionSet: memory.GetBool("session")}
|
DbSet: memory.GetBool("db"), SessionSet: memory.GetBool("session"),
|
||||||
if err[0] != nil {
|
Log: logger}
|
||||||
that.memoryCache.SetError(err[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
//db配置初始化
|
|
||||||
redis := that.Config.GetMap("redis")
|
redis := that.Config.GetMap("redis")
|
||||||
if redis != nil {
|
if redis != nil {
|
||||||
if redis.GetString("host") == "" || redis.GetString("port") == "" {
|
if redis.GetString("host") == "" || redis.GetString("port") == "" {
|
||||||
if err[0] != nil {
|
if logger != nil {
|
||||||
err[0].SetError(errors.New("请检查redis配置host和port配置"))
|
logger.Error().Msg("请检查redis配置host和port配置")
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if redis.Get("db") == nil {
|
if redis.Get("db") == nil {
|
||||||
redis["db"] = true
|
redis["db"] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if redis.Get("session") == nil {
|
if redis.Get("session") == nil {
|
||||||
redis["session"] = true
|
redis["session"] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if redis.Get("timeout") == nil {
|
if redis.Get("timeout") == nil {
|
||||||
redis["timeout"] = 60 * 60 * 24 * 15
|
redis["timeout"] = 60 * 60 * 24 * 15
|
||||||
}
|
}
|
||||||
that.Config["redis"] = redis
|
that.Config["redis"] = redis
|
||||||
that.redisCache = &CacheRedis{TimeOut: redis.GetCeilInt64("timeout"),
|
that.redisCache = &CacheRedis{TimeOut: redis.GetCeilInt64("timeout"),
|
||||||
DbSet: redis.GetBool("db"), SessionSet: redis.GetBool("session"), Host: redis.GetString("host"),
|
DbSet: redis.GetBool("db"), SessionSet: redis.GetBool("session"), Host: redis.GetString("host"),
|
||||||
Pwd: redis.GetString("password"), Port: redis.GetCeilInt64("port")}
|
Pwd: redis.GetString("password"), Port: redis.GetCeilInt64("port"),
|
||||||
|
Log: logger}
|
||||||
if err[0] != nil {
|
|
||||||
that.redisCache.SetError(err[0])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//db配置初始化
|
|
||||||
db := that.Config.GetMap("db")
|
db := that.Config.GetMap("db")
|
||||||
if db != nil {
|
if db != nil {
|
||||||
|
|
||||||
if db.Get("db") == nil {
|
if db.Get("db") == nil {
|
||||||
db["db"] = false
|
db["db"] = false
|
||||||
}
|
}
|
||||||
|
|
||||||
if db.Get("session") == nil {
|
if db.Get("session") == nil {
|
||||||
db["session"] = true
|
db["session"] = true
|
||||||
}
|
}
|
||||||
if db.Get("timeout") == nil {
|
if db.Get("timeout") == nil {
|
||||||
db["timeout"] = 60 * 60 * 24 * 30
|
db["timeout"] = 60 * 60 * 24 * 30
|
||||||
}
|
}
|
||||||
|
if db.Get("mode") == nil {
|
||||||
|
db["mode"] = CacheModeCompatible
|
||||||
|
}
|
||||||
that.Config["db"] = db
|
that.Config["db"] = db
|
||||||
|
|
||||||
that.dbCache = &CacheDb{TimeOut: db.GetCeilInt64("timeout"),
|
that.dbCache = &CacheDb{
|
||||||
DbSet: db.GetBool("db"), SessionSet: db.GetBool("session"),
|
TimeOut: db.GetCeilInt64("timeout"),
|
||||||
Db: hotimeDb}
|
DbSet: db.GetBool("db"),
|
||||||
|
SessionSet: db.GetBool("session"),
|
||||||
if err[0] != nil {
|
HistorySet: db.GetBool("history"),
|
||||||
that.dbCache.SetError(err[0])
|
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
|
package cache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
. "code.hoteas.com/golang/hotime/common"
|
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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 {
|
type HoTimeDBInterface interface {
|
||||||
GetPrefix() string
|
GetPrefix() string
|
||||||
Query(query string, args ...interface{}) []Map
|
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
|
Get(table string, qu ...interface{}) Map
|
||||||
Select(table string, qu ...interface{}) []Map
|
Select(table string, qu ...interface{}) []Map
|
||||||
Delete(table string, data map[string]interface{}) int64
|
Delete(table string, data map[string]interface{}) int64
|
||||||
@@ -24,150 +36,638 @@ type CacheDb struct {
|
|||||||
TimeOut int64
|
TimeOut int64
|
||||||
DbSet bool
|
DbSet bool
|
||||||
SessionSet bool
|
SessionSet bool
|
||||||
|
HistorySet bool // 是否开启历史记录
|
||||||
|
Mode string // 缓存模式:"new"(默认,只用新表) 或 "compatible"(写新读老)
|
||||||
Db HoTimeDBInterface
|
Db HoTimeDBInterface
|
||||||
*Error
|
Log *log.Logger
|
||||||
ContextBase
|
ContextBase
|
||||||
isInit bool
|
isInit bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (that *CacheDb) GetError() *Error {
|
// getTableName 获取带前缀的表名
|
||||||
|
func (that *CacheDb) getTableName() string {
|
||||||
return that.Error
|
return that.Db.GetPrefix() + CacheTableName
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (that *CacheDb) SetError(err *Error) {
|
// getHistoryTableName 获取带前缀的历史表名
|
||||||
that.Error = err
|
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() {
|
func (that *CacheDb) initDbTable() {
|
||||||
if that.isInit {
|
if that.isInit {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if that.Db.GetType() == "mysql" {
|
|
||||||
|
|
||||||
dbNames := that.Db.Query("SELECT DATABASE()")
|
dbType := that.Db.GetType()
|
||||||
|
tableName := that.getTableName()
|
||||||
if len(dbNames) == 0 {
|
historyTableName := that.getHistoryTableName()
|
||||||
return
|
legacyTableName := that.getLegacyTableName()
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 检查并创建主表
|
||||||
|
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'`)
|
// new 模式:迁移老表数据到新表(不删除老表,由人工删除)
|
||||||
|
// compatible 模式:不迁移,老表继续使用
|
||||||
if len(res) != 0 {
|
if that.getEffectiveMode() == CacheModeNew {
|
||||||
that.isInit = true
|
if that.tableExists(legacyTableName) {
|
||||||
return
|
that.migrateFromCached(dbType, legacyTableName, tableName)
|
||||||
}
|
|
||||||
_, 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
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// compatible 模式不做任何处理,老表保留供读取
|
||||||
|
|
||||||
|
// 检查并创建历史表(开启历史记录时)
|
||||||
|
if that.HistorySet && !that.tableExists(historyTableName) {
|
||||||
|
that.createHistoryTable(dbType, historyTableName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
that.isInit = true
|
||||||
}
|
}
|
||||||
|
|
||||||
//获取Cache键只能为string类型
|
// tableExists 检查表是否存在
|
||||||
func (that *CacheDb) get(key string) interface{} {
|
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 {
|
if cached == nil {
|
||||||
return 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
data := Map{}
|
// 解析 value(老表 value 格式可能是 {"data": xxx} 或直接值)
|
||||||
data.JsonToMap(cached.GetString("value"))
|
valueStr := cached.GetString("value")
|
||||||
|
if valueStr == "" {
|
||||||
return data.Get("data")
|
return nil
|
||||||
}
|
|
||||||
|
|
||||||
//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})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//随机执行删除命令
|
data, err := JsonToObj(valueStr)
|
||||||
if Rand(1000) > 950 {
|
if err != nil {
|
||||||
that.Db.Delete("cached", Map{"endtime[<]": time.Now().Unix()})
|
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, "*")
|
del := strings.Index(key, "*")
|
||||||
//如果通配删除
|
// 如果通配删除
|
||||||
if del != -1 {
|
if del != -1 {
|
||||||
key = Substr(key, 0, del)
|
keyPrefix := Substr(key, 0, del)
|
||||||
that.Db.Delete("cached", Map{"key": key + "%"})
|
that.Db.Delete(legacyTableName, Map{"key[~]": keyPrefix + "%"})
|
||||||
|
|
||||||
} else {
|
} 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()
|
that.initDbTable()
|
||||||
|
|
||||||
|
// 获取缓存
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
return &Obj{Data: that.get(key)}
|
return &Obj{Data: that.get(key)}
|
||||||
}
|
}
|
||||||
tim := time.Now().Unix()
|
|
||||||
|
|
||||||
|
// 删除缓存
|
||||||
if len(data) == 1 && data[0] == nil {
|
if len(data) == 1 && data[0] == nil {
|
||||||
that.delete(key)
|
that.delete(key)
|
||||||
return &Obj{Data: nil}
|
return &Obj{Data: nil}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 计算过期时间
|
||||||
|
var timeout int64
|
||||||
if len(data) == 1 {
|
if len(data) == 1 {
|
||||||
if that.TimeOut == 0 {
|
// 使用配置的 TimeOut,如果为 0 则使用默认值
|
||||||
//that.Time = Config.GetInt64("cacheLongTime")
|
timeout = that.TimeOut
|
||||||
|
if timeout == 0 {
|
||||||
|
timeout = DefaultCacheTimeout
|
||||||
}
|
}
|
||||||
tim += that.TimeOut
|
} else if len(data) >= 2 {
|
||||||
}
|
// 使用指定的超时时间
|
||||||
if len(data) == 2 {
|
var err Error
|
||||||
that.SetError(nil)
|
tempTimeout := ObjToInt64(data[1], &err)
|
||||||
tempt := ObjToInt64(data[1], that.Error)
|
if err.GetError() == nil && tempTimeout > 0 {
|
||||||
|
timeout = tempTimeout
|
||||||
if tempt > tim {
|
} else {
|
||||||
tim = tempt
|
timeout = that.TimeOut
|
||||||
} else if that.GetError() == nil {
|
if timeout == 0 {
|
||||||
|
timeout = DefaultCacheTimeout
|
||||||
tim = tim + tempt
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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}
|
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 (
|
import (
|
||||||
. "code.hoteas.com/golang/hotime/common"
|
. "code.hoteas.com/golang/hotime/common"
|
||||||
|
"code.hoteas.com/golang/hotime/log"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// CacheMemory 基于 sync.Map 的缓存实现
|
||||||
type CacheMemory struct {
|
type CacheMemory struct {
|
||||||
TimeOut int64
|
TimeOut int64
|
||||||
DbSet bool
|
DbSet bool
|
||||||
SessionSet bool
|
SessionSet bool
|
||||||
Map
|
Log *log.Logger
|
||||||
*Error
|
cache sync.Map
|
||||||
ContextBase
|
|
||||||
mutex *sync.RWMutex
|
|
||||||
}
|
}
|
||||||
|
func (c *CacheMemory) get(key string) (res *Obj) {
|
||||||
|
|
||||||
func (that *CacheMemory) GetError() *Error {
|
res = &Obj{}
|
||||||
|
value, ok := c.cache.Load(key)
|
||||||
return that.Error
|
if !ok {
|
||||||
|
return res // 缓存不存在
|
||||||
}
|
|
||||||
|
|
||||||
func (that *CacheMemory) SetError(err *Error) {
|
|
||||||
that.Error = err
|
|
||||||
}
|
|
||||||
|
|
||||||
//获取Cache键只能为string类型
|
|
||||||
func (that *CacheMemory) get(key string) interface{} {
|
|
||||||
that.Error.SetError(nil)
|
|
||||||
if that.Map == nil {
|
|
||||||
that.Map = Map{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if that.Map[key] == nil {
|
data := value.(cacheData)
|
||||||
return nil
|
|
||||||
}
|
|
||||||
data := that.Map.Get(key, that.Error).(cacheData)
|
|
||||||
if that.Error.GetError() != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 检查是否过期
|
||||||
if data.time < time.Now().Unix() {
|
if data.time < time.Now().Unix() {
|
||||||
delete(that.Map, key)
|
c.cache.Delete(key) // 删除过期缓存
|
||||||
return nil
|
return res
|
||||||
}
|
}
|
||||||
return data.data
|
res.Data = data.data
|
||||||
|
return res
|
||||||
}
|
}
|
||||||
|
func (c *CacheMemory) set(key string, value interface{}, expireAt int64) {
|
||||||
func (that *CacheMemory) refreshMap() {
|
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() {
|
go func() {
|
||||||
that.mutex.Lock()
|
now := time.Now().Unix()
|
||||||
defer that.mutex.Unlock()
|
c.cache.Range(func(key, value interface{}) bool {
|
||||||
for key, v := range that.Map {
|
data := value.(cacheData)
|
||||||
data := v.(cacheData)
|
if data.time <= now {
|
||||||
if data.time <= time.Now().Unix() {
|
c.cache.Delete(key) // 删除过期缓存
|
||||||
delete(that.Map, 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) {
|
if x := RandX(1, 100000); x > 99950 {
|
||||||
that.Error.SetError(nil)
|
c.refreshMap()
|
||||||
var data cacheData
|
|
||||||
|
|
||||||
if that.Map == nil {
|
|
||||||
that.Map = Map{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dd := that.Map[key]
|
|
||||||
|
|
||||||
if dd == nil {
|
|
||||||
data = cacheData{}
|
|
||||||
} else {
|
|
||||||
data = dd.(cacheData)
|
|
||||||
}
|
|
||||||
|
|
||||||
data.time = time
|
|
||||||
data.data = value
|
|
||||||
|
|
||||||
that.Map.Put(key, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (that *CacheMemory) delete(key string) {
|
|
||||||
del := strings.Index(key, "*")
|
|
||||||
//如果通配删除
|
|
||||||
if del != -1 {
|
|
||||||
key = Substr(key, 0, del)
|
|
||||||
for k, _ := range that.Map {
|
|
||||||
if strings.Index(k, key) != -1 {
|
|
||||||
delete(that.Map, k)
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} else {
|
|
||||||
delete(that.Map, key)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func (that *CacheMemory) Cache(key string, data ...interface{}) *Obj {
|
|
||||||
|
|
||||||
x := RandX(1, 100000)
|
|
||||||
if x > 99950 {
|
|
||||||
that.refreshMap()
|
|
||||||
}
|
|
||||||
if that.mutex == nil {
|
|
||||||
that.mutex = &sync.RWMutex{}
|
|
||||||
}
|
|
||||||
|
|
||||||
reData := &Obj{Data: nil}
|
|
||||||
|
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
that.mutex.RLock()
|
// 读操作
|
||||||
reData.Data = that.get(key)
|
return c.get(key)
|
||||||
that.mutex.RUnlock()
|
|
||||||
return reData
|
|
||||||
}
|
}
|
||||||
tim := time.Now().Unix()
|
|
||||||
|
|
||||||
if len(data) == 1 && data[0] == nil {
|
if len(data) == 1 && data[0] == nil {
|
||||||
that.mutex.Lock()
|
// 删除操作
|
||||||
that.delete(key)
|
c.delete(key)
|
||||||
that.mutex.Unlock()
|
return nil
|
||||||
return reData
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(data) == 1 {
|
// 写操作
|
||||||
|
expireAt := now + c.TimeOut
|
||||||
tim = tim + that.TimeOut
|
|
||||||
|
|
||||||
}
|
|
||||||
if len(data) == 2 {
|
if len(data) == 2 {
|
||||||
that.Error.SetError(nil)
|
if customExpire, ok := data[1].(int64); ok {
|
||||||
tempt := ObjToInt64(data[1], that.Error)
|
if customExpire > now {
|
||||||
|
expireAt = customExpire
|
||||||
if tempt > tim {
|
} else {
|
||||||
|
expireAt = now + customExpire
|
||||||
tim = tempt
|
}
|
||||||
} else if that.Error.GetError() == nil {
|
|
||||||
|
|
||||||
tim = tim + tempt
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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 (
|
import (
|
||||||
. "code.hoteas.com/golang/hotime/common"
|
. "code.hoteas.com/golang/hotime/common"
|
||||||
|
"code.hoteas.com/golang/hotime/log"
|
||||||
"github.com/garyburd/redigo/redis"
|
"github.com/garyburd/redigo/redis"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,23 +16,20 @@ type CacheRedis struct {
|
|||||||
Host string
|
Host string
|
||||||
Pwd string
|
Pwd string
|
||||||
Port int64
|
Port int64
|
||||||
conn redis.Conn
|
pool *redis.Pool
|
||||||
tag int64
|
tag int64
|
||||||
ContextBase
|
ContextBase
|
||||||
*Error
|
Log *log.Logger
|
||||||
|
initOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
func (that *CacheRedis) GetError() *Error {
|
func (that *CacheRedis) logErr(err error) {
|
||||||
|
if err != nil && that.Log != nil {
|
||||||
return that.Error
|
that.Log.Error().Err(err).Msg("redis cache error")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (that *CacheRedis) SetError(err *Error) {
|
// 唯一标志
|
||||||
that.Error = err
|
|
||||||
}
|
|
||||||
|
|
||||||
//唯一标志
|
|
||||||
func (that *CacheRedis) GetTag() int64 {
|
func (that *CacheRedis) GetTag() int64 {
|
||||||
|
|
||||||
if that.tag == int64(0) {
|
if that.tag == int64(0) {
|
||||||
@@ -39,83 +38,112 @@ func (that *CacheRedis) GetTag() int64 {
|
|||||||
return that.tag
|
return that.tag
|
||||||
}
|
}
|
||||||
|
|
||||||
func (that *CacheRedis) reCon() bool {
|
// initPool 初始化连接池(只执行一次)
|
||||||
var err error
|
func (that *CacheRedis) initPool() {
|
||||||
that.conn, err = redis.Dial("tcp", that.Host+":"+ObjToStr(that.Port))
|
that.initOnce.Do(func() {
|
||||||
if err != nil {
|
that.pool = &redis.Pool{
|
||||||
that.conn = nil
|
MaxIdle: 10, // 最大空闲连接数
|
||||||
that.Error.SetError(err)
|
MaxActive: 100, // 最大活跃连接数,0表示无限制
|
||||||
return false
|
IdleTimeout: 5 * time.Minute, // 空闲连接超时时间
|
||||||
}
|
Wait: true, // 当连接池耗尽时是否等待
|
||||||
|
Dial: func() (redis.Conn, error) {
|
||||||
if that.Pwd != "" {
|
conn, err := redis.Dial("tcp", that.Host+":"+ObjToStr(that.Port),
|
||||||
_, err = that.conn.Do("AUTH", that.Pwd)
|
redis.DialConnectTimeout(5*time.Second),
|
||||||
if err != nil {
|
redis.DialReadTimeout(3*time.Second),
|
||||||
that.conn = nil
|
redis.DialWriteTimeout(3*time.Second),
|
||||||
that.Error.SetError(err)
|
)
|
||||||
return false
|
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) {
|
func (that *CacheRedis) del(key string) {
|
||||||
|
conn := that.getConn()
|
||||||
|
if conn == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
del := strings.Index(key, "*")
|
del := strings.Index(key, "*")
|
||||||
if del != -1 {
|
if del != -1 {
|
||||||
val, err := redis.Strings(that.conn.Do("KEYS", key))
|
val, err := redis.Strings(conn.Do("KEYS", key))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
that.logErr(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
that.conn.Send("MULTI")
|
if len(val) == 0 {
|
||||||
for i, _ := range val {
|
return
|
||||||
that.conn.Send("DEL", val[i])
|
|
||||||
}
|
}
|
||||||
that.conn.Do("EXEC")
|
conn.Send("MULTI")
|
||||||
} else {
|
for i := range val {
|
||||||
_, err := that.conn.Do("DEL", key)
|
conn.Send("DEL", val[i])
|
||||||
|
}
|
||||||
|
_, err = conn.Do("EXEC")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
that.Error.SetError(err)
|
that.logErr(err)
|
||||||
_, err = that.conn.Do("PING")
|
}
|
||||||
if err != nil {
|
} else {
|
||||||
if that.reCon() {
|
_, err := conn.Do("DEL", key)
|
||||||
_, err = that.conn.Do("DEL", key)
|
if err != nil {
|
||||||
}
|
that.logErr(err)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//key value ,时间为时间戳
|
// key value ,时间为时间戳
|
||||||
func (that *CacheRedis) set(key string, value string, time int64) {
|
func (that *CacheRedis) set(key string, value string, expireSeconds int64) {
|
||||||
_, err := that.conn.Do("SET", key, value, "EX", ObjToStr(time))
|
conn := that.getConn()
|
||||||
if err != nil {
|
if conn == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
that.Error.SetError(err)
|
_, err := conn.Do("SET", key, value, "EX", ObjToStr(expireSeconds))
|
||||||
_, err = that.conn.Do("PING")
|
if err != nil {
|
||||||
if err != nil {
|
that.logErr(err)
|
||||||
if that.reCon() {
|
|
||||||
_, err = that.conn.Do("SET", key, value, "EX", ObjToStr(time))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (that *CacheRedis) get(key string) *Obj {
|
func (that *CacheRedis) get(key string) *Obj {
|
||||||
reData := &Obj{}
|
reData := &Obj{}
|
||||||
|
conn := that.getConn()
|
||||||
|
if conn == nil {
|
||||||
|
return reData
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
var err error
|
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 {
|
if err != nil {
|
||||||
reData.Data = nil
|
reData.Data = nil
|
||||||
if !strings.Contains(err.Error(), "nil returned") {
|
if !strings.Contains(err.Error(), "nil returned") {
|
||||||
that.Error.SetError(err)
|
that.logErr(err)
|
||||||
_, err = that.conn.Do("PING")
|
|
||||||
if err != nil {
|
|
||||||
if that.reCon() {
|
|
||||||
reData.Data, err = redis.String(that.conn.Do("GET", key))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return reData
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return reData
|
return reData
|
||||||
@@ -123,19 +151,13 @@ func (that *CacheRedis) get(key string) *Obj {
|
|||||||
|
|
||||||
func (that *CacheRedis) Cache(key string, data ...interface{}) *Obj {
|
func (that *CacheRedis) Cache(key string, data ...interface{}) *Obj {
|
||||||
reData := &Obj{}
|
reData := &Obj{}
|
||||||
if that.conn == nil {
|
|
||||||
re := that.reCon()
|
|
||||||
if !re {
|
|
||||||
return reData
|
|
||||||
}
|
|
||||||
}
|
|
||||||
//查询缓存
|
//查询缓存
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
|
|
||||||
reData = that.get(key)
|
reData = that.get(key)
|
||||||
return reData
|
return reData
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tim := int64(0)
|
tim := int64(0)
|
||||||
//删除缓存
|
//删除缓存
|
||||||
if len(data) == 1 && data[0] == nil {
|
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 len(data) == 1 {
|
||||||
|
|
||||||
if that.TimeOut == 0 {
|
if that.TimeOut == 0 {
|
||||||
//that.Time = Config.GetInt64("cacheShortTime")
|
//that.Time = Config.GetInt64("cacheShortTime")
|
||||||
}
|
}
|
||||||
|
|
||||||
tim += that.TimeOut
|
tim += that.TimeOut
|
||||||
}
|
}
|
||||||
if len(data) == 2 {
|
if len(data) == 2 {
|
||||||
that.Error.SetError(nil)
|
tempt := ObjToInt64(data[1])
|
||||||
tempt := ObjToInt64(data[1], that.Error)
|
|
||||||
if tempt > tim {
|
if tempt > tim {
|
||||||
|
|
||||||
tim = tempt
|
tim = tempt
|
||||||
} else if that.GetError() == nil {
|
} else {
|
||||||
|
|
||||||
tim = tim + tempt
|
tim = tim + tempt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,5 +183,100 @@ func (that *CacheRedis) Cache(key string, data ...interface{}) *Obj {
|
|||||||
that.set(key, ObjToStr(data[0]), tim)
|
that.set(key, ObjToStr(data[0]), tim)
|
||||||
|
|
||||||
return reData
|
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 {
|
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
|
Cache(key string, data ...interface{}) *Obj
|
||||||
|
CachesGet(keys []string) Map
|
||||||
|
CachesSet(data Map, timeout ...int64)
|
||||||
|
CachesDelete(keys []string)
|
||||||
}
|
}
|
||||||
|
|
||||||
//单条缓存数据
|
// 单条缓存数据
|
||||||
type cacheData struct {
|
type cacheData struct {
|
||||||
time int64
|
time int64
|
||||||
data interface{}
|
data interface{}
|
||||||
|
|||||||
@@ -6,11 +6,16 @@ import (
|
|||||||
"github.com/360EntSecGroup-Skylar/excelize"
|
"github.com/360EntSecGroup-Skylar/excelize"
|
||||||
"io"
|
"io"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var adminLoginRateLimiter sync.Map // key: ip,value: *[]int64(1分钟内登录时间戳列表)
|
||||||
|
|
||||||
// Project 管理端项目
|
// Project 管理端项目
|
||||||
var TptProject = Proj{
|
var TptProject = Proj{
|
||||||
//"user": UserCtr,
|
//"user": UserCtr,
|
||||||
@@ -21,16 +26,18 @@ var TptProject = Proj{
|
|||||||
tableName := that.RouterString[1]
|
tableName := that.RouterString[1]
|
||||||
|
|
||||||
data := that.Db.Get(fileConfig.GetString("table"), "*", Map{"id": that.Session(fileConfig.GetString("table") + "_id").ToCeilInt()})
|
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]}
|
where := Map{"id": that.RouterString[2]}
|
||||||
|
|
||||||
if len(inData) == 1 {
|
//权限控制,暂时取消
|
||||||
inData["id"] = where["id"]
|
//if len(inData) == 1 {
|
||||||
where = Map{"AND": inData}
|
// inData["id"] = where["id"]
|
||||||
} else if len(inData) > 1 {
|
// where = Map{"AND": inData}
|
||||||
where["OR"] = inData
|
//} else if len(inData) > 1 {
|
||||||
where = Map{"AND": where}
|
// where["OR"] = inData
|
||||||
}
|
// where = Map{"AND": where}
|
||||||
|
//}
|
||||||
|
|
||||||
re := that.Db.Get(tableName, str, where)
|
re := that.Db.Get(tableName, str, where)
|
||||||
|
|
||||||
@@ -45,7 +52,7 @@ var TptProject = Proj{
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (column["list"] == nil || column.GetBool("list")) && column.GetString("link") != "" {
|
if (column["info"] == nil || column.GetBool("info")) && column.GetString("link") != "" {
|
||||||
seStr := "id," + column.GetString("value")
|
seStr := "id," + column.GetString("value")
|
||||||
if that.MakeCodeRouter[hotimeName].TableColumns[column.GetString("link")]["phone"] != nil {
|
if that.MakeCodeRouter[hotimeName].TableColumns[column.GetString("link")]["phone"] != nil {
|
||||||
seStr = seStr + ",phone"
|
seStr = seStr + ",phone"
|
||||||
@@ -96,12 +103,12 @@ var TptProject = Proj{
|
|||||||
|
|
||||||
for _, v := range that.MakeCodeRouter[hotimeName].TableColumns[fileConfig.GetString("table")] {
|
for _, v := range that.MakeCodeRouter[hotimeName].TableColumns[fileConfig.GetString("table")] {
|
||||||
if v.GetString("link") != "" {
|
if v.GetString("link") != "" {
|
||||||
|
//与用户信息有关联的parent_id自填充
|
||||||
if v.GetString("link") == tableName && that.MakeCodeRouter[hotimeName].TableColumns[v.GetString("link")]["parent_id"] != nil {
|
if v.GetString("link") == tableName && that.MakeCodeRouter[hotimeName].TableColumns[v.GetString("link")]["parent_id"] != nil {
|
||||||
if inData["parent_id"] == nil && data[v.GetString("name")] != nil {
|
if inData["parent_id"] == nil && data[v.GetString("name")] != nil {
|
||||||
inData["parent_id"] = data.GetCeilInt64(v.GetString("name"))
|
inData["parent_id"] = data.GetCeilInt64(v.GetString("name"))
|
||||||
pre := that.Db.Get(v.GetString("link"), "parent_ids", Map{"id": data.GetCeilInt64(v.GetString("name"))})
|
//pre := that.Db.Get(v.GetString("link"), "parent_ids", Map{"id": data.GetCeilInt64(v.GetString("name"))})
|
||||||
inData["parent_ids"] = pre.GetString("parent_ids")
|
//inData["parent_ids"] = pre.GetString("parent_ids")+ObjToStr(data.GetCeilInt64(v.GetString("name")))+","
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -128,15 +135,18 @@ var TptProject = Proj{
|
|||||||
name = v1.GetString("table")
|
name = v1.GetString("table")
|
||||||
}
|
}
|
||||||
if v1["auth"] != nil {
|
if v1["auth"] != nil {
|
||||||
linkAuth[name] = v1["auth"]
|
|
||||||
|
|
||||||
}
|
if linkAuth[name] == nil {
|
||||||
if v1["auth"] == nil {
|
linkAuth[name] = v1["auth"]
|
||||||
table := that.MakeCodeRouter[hotimeName].TableConfig.GetMap(name)
|
|
||||||
if table != nil {
|
|
||||||
linkAuth[name] = that.MakeCodeRouter[hotimeName].TableConfig.GetMap(name)["auth"]
|
|
||||||
} else {
|
} else {
|
||||||
linkAuth[name] = Slice{"show"}
|
newAuth := Slice{}
|
||||||
|
for k2, _ := range linkAuth.GetSlice(name) {
|
||||||
|
v2 := linkAuth.GetSlice(name).GetString(k2)
|
||||||
|
if strings.Contains(v1.GetString("auth"), v2) {
|
||||||
|
newAuth = append(newAuth, v2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
linkAuth[name] = newAuth
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,15 +158,22 @@ var TptProject = Proj{
|
|||||||
name = v2.GetString("table")
|
name = v2.GetString("table")
|
||||||
}
|
}
|
||||||
if v2["auth"] != nil {
|
if v2["auth"] != nil {
|
||||||
linkAuth[name] = v2["auth"]
|
|
||||||
continue
|
if linkAuth[name] == nil {
|
||||||
}
|
linkAuth[name] = v2["auth"]
|
||||||
if v2["auth"] == nil {
|
|
||||||
table := that.MakeCodeRouter[hotimeName].TableConfig.GetMap(name)
|
|
||||||
if table != nil {
|
|
||||||
linkAuth[name] = that.MakeCodeRouter[hotimeName].TableConfig.GetMap(name)["auth"]
|
|
||||||
} else {
|
} else {
|
||||||
linkAuth[name] = Slice{"show"}
|
|
||||||
|
newAuth := Slice{}
|
||||||
|
for k3, _ := range linkAuth.GetSlice(name) {
|
||||||
|
v3 := linkAuth.GetSlice(name).GetString(k3)
|
||||||
|
if strings.Contains(v2.GetString("auth"), v3) {
|
||||||
|
newAuth = append(newAuth, v3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
linkAuth[name] = newAuth
|
||||||
|
continue
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -199,14 +216,19 @@ var TptProject = Proj{
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
//索引管理,便于检索以及权限
|
//索引管理,便于检索以及权限
|
||||||
if inData.Get("parent_id") != nil && inData.GetString("parent_ids") != "" {
|
if inData.Get("parent_id") != nil {
|
||||||
index := that.Db.Get(tableName, "`parent_ids`", Map{"id": inData.Get("parent_id")})
|
index := that.Db.Get(tableName, "`parent_ids`", Map{"id": inData.Get("parent_id")})
|
||||||
inData["parent_ids"] = index.GetString("parent_ids") + ObjToStr(re) + ","
|
parent_ids := index.GetString("parent_ids")
|
||||||
|
if parent_ids == "" {
|
||||||
|
parent_ids = ","
|
||||||
|
}
|
||||||
|
inData["parent_ids"] = parent_ids + ObjToStr(re) + ","
|
||||||
that.Db.Update(tableName, Map{"parent_ids": inData["parent_ids"]}, Map{"id": re})
|
that.Db.Update(tableName, Map{"parent_ids": inData["parent_ids"]}, Map{"id": re})
|
||||||
} else if inData.GetString("parent_ids") != "" {
|
} else {
|
||||||
inData["parent_ids"] = "," + ObjToStr(re) + ","
|
inData["parent_ids"] = "," + ObjToStr(re) + ","
|
||||||
that.Db.Update(tableName, Map{"parent_ids": inData["parent_ids"]}, Map{"id": re})
|
that.Db.Update(tableName, Map{"parent_ids": inData["parent_ids"]}, Map{"id": re})
|
||||||
}
|
}
|
||||||
|
|
||||||
that.Log["table_id"] = re
|
that.Log["table_id"] = re
|
||||||
that.Display(0, re)
|
that.Display(0, re)
|
||||||
},
|
},
|
||||||
@@ -234,20 +256,28 @@ var TptProject = Proj{
|
|||||||
}
|
}
|
||||||
|
|
||||||
conf := ObjToMap(string(btes))
|
conf := ObjToMap(string(btes))
|
||||||
|
//相同则
|
||||||
stop := conf.GetSlice("stop")
|
if tableName == fileConfig.GetString("table") {
|
||||||
for k, _ := range stop {
|
flows := conf.GetMap("flow")
|
||||||
v := stop.GetString(k)
|
for k, _ := range flows {
|
||||||
//不能改对应行数据
|
v := flows.GetMap(k)
|
||||||
if tableName == v && data.GetCeilInt64(v+"_id") == ObjToCeilInt64(that.RouterString[2]) {
|
if v.GetBool("stop") {
|
||||||
that.Display(4, "你没有权限修改当前数据")
|
//不能改自身对应的数据
|
||||||
return
|
if inData[k+"_id"] != nil {
|
||||||
|
//fmt.Println(inData[k+"_id"])
|
||||||
|
delete(inData, k+"_id")
|
||||||
|
//fmt.Println(inData[k+"_id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
//不能改自身对应的数据
|
} else {
|
||||||
if tableName == fileConfig.GetString("table") && inData[v+"_id"] != nil {
|
flow := conf.GetMap("flow").GetMap(tableName)
|
||||||
delete(inData, v+"_id")
|
if flow != nil && flow.GetBool("stop") {
|
||||||
|
if data.GetCeilInt64(tableName+"_id") == ObjToCeilInt64(that.RouterString[2]) {
|
||||||
|
that.Display(4, "你没有权限修改当前数据")
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if inData["auth"] != nil {
|
if inData["auth"] != nil {
|
||||||
@@ -257,48 +287,59 @@ var TptProject = Proj{
|
|||||||
if v.GetString("link") != "" && that.MakeCodeRouter[hotimeName].TableColumns[v.GetString("link")]["auth"] != nil {
|
if v.GetString("link") != "" && that.MakeCodeRouter[hotimeName].TableColumns[v.GetString("link")]["auth"] != nil {
|
||||||
linkAuthMap := that.Db.Get(v.GetString("link"), "auth", Map{"id": data.GetCeilInt(v.GetString("name"))})
|
linkAuthMap := that.Db.Get(v.GetString("link"), "auth", Map{"id": data.GetCeilInt(v.GetString("name"))})
|
||||||
linkAuth := linkAuthMap.GetMap("auth")
|
linkAuth := linkAuthMap.GetMap("auth")
|
||||||
|
//conf := ObjToMap(string(btes))
|
||||||
|
//menus := conf.GetSlice("menus")
|
||||||
if linkAuth == nil {
|
if linkAuth == nil {
|
||||||
|
|
||||||
linkAuth = Map{}
|
linkAuth = Map{}
|
||||||
for k1, _ := range menus {
|
}
|
||||||
v1 := menus.GetMap(k1)
|
|
||||||
name := v1.GetString("name")
|
|
||||||
if name == "" {
|
|
||||||
name = v1.GetString("table")
|
|
||||||
}
|
|
||||||
if v1["auth"] != nil {
|
|
||||||
linkAuth[name] = v1["auth"]
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if v1["auth"] == nil {
|
|
||||||
|
|
||||||
table := that.MakeCodeRouter[hotimeName].TableConfig.GetMap(name)
|
for k1, _ := range menus {
|
||||||
if table != nil {
|
v1 := menus.GetMap(k1)
|
||||||
linkAuth[name] = that.MakeCodeRouter[hotimeName].TableConfig.GetMap(name)["auth"]
|
name := v1.GetString("name")
|
||||||
} else {
|
if name == "" {
|
||||||
linkAuth[name] = Slice{"show"}
|
name = v1.GetString("table")
|
||||||
}
|
}
|
||||||
}
|
if v1["auth"] != nil {
|
||||||
menusChild := v1.GetSlice("menus")
|
|
||||||
for k2, _ := range menusChild {
|
if linkAuth[name] == nil {
|
||||||
v2 := menusChild.GetMap(k2)
|
linkAuth[name] = v1["auth"]
|
||||||
name := v2.GetString("name")
|
} else {
|
||||||
if name == "" {
|
newAuth := Slice{}
|
||||||
name = v2.GetString("table")
|
for k2, _ := range linkAuth.GetSlice(name) {
|
||||||
}
|
v2 := linkAuth.GetSlice(name).GetString(k2)
|
||||||
if v2["auth"] != nil {
|
if strings.Contains(v1.GetString("auth"), v2) {
|
||||||
linkAuth[name] = v2["auth"]
|
newAuth = append(newAuth, v2)
|
||||||
continue
|
|
||||||
}
|
|
||||||
if v2["auth"] == nil {
|
|
||||||
table := that.MakeCodeRouter[hotimeName].TableConfig.GetMap(name)
|
|
||||||
if table != nil {
|
|
||||||
linkAuth[name] = that.MakeCodeRouter[hotimeName].TableConfig.GetMap(name)["auth"]
|
|
||||||
} else {
|
|
||||||
linkAuth[name] = Slice{"show"}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
linkAuth[name] = newAuth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
menusChild := v1.GetSlice("menus")
|
||||||
|
for k2, _ := range menusChild {
|
||||||
|
v2 := menusChild.GetMap(k2)
|
||||||
|
name := v2.GetString("name")
|
||||||
|
if name == "" {
|
||||||
|
name = v2.GetString("table")
|
||||||
|
}
|
||||||
|
if v2["auth"] != nil {
|
||||||
|
|
||||||
|
if linkAuth[name] == nil {
|
||||||
|
linkAuth[name] = v2["auth"]
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
newAuth := Slice{}
|
||||||
|
for k3, _ := range linkAuth.GetSlice(name) {
|
||||||
|
v3 := linkAuth.GetSlice(name).GetString(k3)
|
||||||
|
if strings.Contains(v2.GetString("auth"), v3) {
|
||||||
|
newAuth = append(newAuth, v3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
linkAuth[name] = newAuth
|
||||||
|
continue
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -333,21 +374,29 @@ var TptProject = Proj{
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//索引管理,便于检索以及权限
|
if _, ok := inData["parent_id"]; ok {
|
||||||
if inData.GetString("parent_ids") != "" {
|
//1
|
||||||
if inData.Get("parent_id") != nil {
|
//12 62
|
||||||
Index := that.Db.Get(tableName, "`index`", Map{"id": that.RouterString[2]})
|
//123 623
|
||||||
parentIndex := that.Db.Get(tableName, "`index`", Map{"id": inData.Get("parent_id")})
|
|
||||||
inData["parent_ids"] = parentIndex.GetString("parent_ids") + that.RouterString[2] + ","
|
|
||||||
|
|
||||||
childNodes := that.Db.Select(tableName, "id,`index``", Map{"index[~]": "," + that.RouterString[2] + ","})
|
if inData.GetCeilInt64("parent_id") == 0 {
|
||||||
|
that.Db.Update(tableName, Map{"parent_ids": "," + that.RouterString[2] + ","}, Map{"id": that.RouterString[2]})
|
||||||
for _, v := range childNodes {
|
|
||||||
v["parent_ids"] = strings.Replace(v.GetString("parent_ids"), Index.GetString("parent_ids"), inData.GetString("parent_ids"), -1)
|
|
||||||
that.Db.Update(tableName, Map{"parent_ids": v["parent_ids"]}, Map{"id": v.GetCeilInt("id")})
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
delete(inData, "parent_ids")
|
|
||||||
|
Index := that.Db.Get(tableName, "parent_id,`parent_ids`", Map{"id": that.RouterString[2]})
|
||||||
|
if inData.GetCeilInt64("parent_id") != Index.GetCeilInt64("parent_id") {
|
||||||
|
parentIndex := that.Db.Get(tableName, "`parent_ids`", Map{"id": inData.Get("parent_id")})
|
||||||
|
if strings.Contains(parentIndex.GetString("parent_ids"), ","+that.RouterString[2]+",") {
|
||||||
|
that.Display(4, "不能将子级设置为父级")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parent_ids := parentIndex.GetString("parent_ids") + that.RouterString[2] + ","
|
||||||
|
childNodes := that.Db.Select(tableName, "id,`parent_ids`", Map{"parent_ids[~]": "," + that.RouterString[2] + ","})
|
||||||
|
for _, v := range childNodes {
|
||||||
|
v["parent_ids"] = strings.Replace(v.GetString("parent_ids"), Index.GetString("parent_ids"), parent_ids, -1)
|
||||||
|
that.Db.Update(tableName, Map{"parent_ids": v["parent_ids"]}, Map{"id": v.GetCeilInt("id")})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -359,21 +408,44 @@ var TptProject = Proj{
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//索引管理,便于检索以及权限
|
||||||
that.Display(0, re)
|
that.Display(0, re)
|
||||||
},
|
},
|
||||||
"remove": func(that *Context) {
|
"remove": func(that *Context) {
|
||||||
tableName := that.RouterString[1]
|
tableName := that.RouterString[1]
|
||||||
that.Log = Map{"table": tableName, "type": 3, "table_id": that.RouterString[2]}
|
that.Log = Map{"table": tableName, "type": 3, "table_id": that.RouterString[2]}
|
||||||
hotimeName := that.RouterString[0]
|
hotimeName := that.RouterString[0]
|
||||||
inData := that.MakeCodeRouter[hotimeName].Delete(tableName, that.Req)
|
fileConfig := that.MakeCodeRouter[hotimeName].FileConfig
|
||||||
if inData == nil {
|
|
||||||
that.Display(3, "请求参数不足")
|
//树状结构不允许修改自身的属性,修改别人的可以
|
||||||
|
btes, err := ioutil.ReadFile(fileConfig.GetString("config"))
|
||||||
|
if err != nil {
|
||||||
|
that.Display(4, "找不到权限配置文件")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
conf := ObjToMap(string(btes))
|
||||||
|
data := that.Db.Get(fileConfig.GetString("table"), "*", Map{"id": that.Session(fileConfig.GetString("table") + "_id").ToCeilInt()})
|
||||||
|
//相同则
|
||||||
|
if tableName == fileConfig.GetString("table") {
|
||||||
|
|
||||||
|
if data.GetCeilInt64("id") == ObjToCeilInt64(that.RouterString[2]) {
|
||||||
|
that.Display(4, "你不能删除自己")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
flow := conf.GetMap("flow").GetMap(tableName)
|
||||||
|
if flow != nil && flow.GetBool("stop") {
|
||||||
|
if data.GetCeilInt64(tableName+"_id") == ObjToCeilInt64(that.RouterString[2]) {
|
||||||
|
that.Display(4, "你没有权限修改当前数据")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
re := int64(0)
|
re := int64(0)
|
||||||
//索引管理,便于检索以及权限
|
//有索引则删除对应的参数
|
||||||
if inData.Get("parent_id") != nil && inData.GetSlice("parent_ids") != nil {
|
if that.MakeCodeRouter[hotimeName].TableColumns[tableName]["parent_id"] != nil {
|
||||||
re = that.Db.Delete(tableName, Map{"index[~]": "," + that.RouterString[2] + ","})
|
re = that.Db.Delete(tableName, Map{"parent_ids[~]": "," + that.RouterString[2] + ","})
|
||||||
} else {
|
} else {
|
||||||
re = that.Db.Delete(tableName, Map{"id": that.RouterString[2]})
|
re = that.Db.Delete(tableName, Map{"id": that.RouterString[2]})
|
||||||
}
|
}
|
||||||
@@ -389,8 +461,51 @@ var TptProject = Proj{
|
|||||||
fileConfig := that.MakeCodeRouter[hotimeName].FileConfig
|
fileConfig := that.MakeCodeRouter[hotimeName].FileConfig
|
||||||
tableName := that.RouterString[1]
|
tableName := that.RouterString[1]
|
||||||
data := that.Db.Get(fileConfig.GetString("table"), "*", Map{"id": that.Session(fileConfig.GetString("table") + "_id").ToCeilInt()})
|
data := that.Db.Get(fileConfig.GetString("table"), "*", Map{"id": that.Session(fileConfig.GetString("table") + "_id").ToCeilInt()})
|
||||||
|
where := Map{}
|
||||||
|
|
||||||
columnStr, leftJoin, where := that.MakeCodeRouter[hotimeName].Search(tableName, data, that.Req, that.Db)
|
//树状结构不允许修改自身的属性,修改别人的可以
|
||||||
|
btes, err := ioutil.ReadFile(fileConfig.GetString("config"))
|
||||||
|
if err != nil {
|
||||||
|
that.Display(4, "找不到权限配置文件")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
conf := ObjToMap(string(btes))
|
||||||
|
//相同则
|
||||||
|
//if tableName!=fileConfig.GetString("table"){
|
||||||
|
|
||||||
|
flow := conf.GetMap("flow").GetMap(tableName)
|
||||||
|
if flow != nil && flow.GetMap("sql") != nil {
|
||||||
|
sql := ObjToMap(DeepCopyMap(flow.GetMap("sql")))
|
||||||
|
for k, _ := range sql {
|
||||||
|
//for uk,_:=range data{
|
||||||
|
// if sql[uk]==nil{
|
||||||
|
// continue
|
||||||
|
// }
|
||||||
|
// uv:=data.GetString(uk)
|
||||||
|
//
|
||||||
|
// tv:=strings.Replace(sql.GetString(uk),uk,uv,-1)
|
||||||
|
// if tv!=uv{
|
||||||
|
// where[k]=tv
|
||||||
|
// break
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
if k == "parent_ids[~]" && tableName == fileConfig.GetString("table") {
|
||||||
|
where[tableName+"."+k] = strings.Replace(sql.GetString(k), "id", data.GetString("id"), -1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if k == "parent_ids[~]" && data[tableName+"_id"] != nil {
|
||||||
|
where[tableName+"."+k] = strings.Replace(sql.GetString(k), tableName+"_id", data.GetString(tableName+"_id"), -1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if data[sql.GetString(k)] != nil {
|
||||||
|
where[tableName+"."+k] = data[sql.GetString(k)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//}
|
||||||
|
|
||||||
|
columnStr, where := that.MakeCodeRouter[hotimeName].Search(tableName, data, where, that.Req, that.Db)
|
||||||
|
|
||||||
page := ObjToInt(that.Req.FormValue("page"))
|
page := ObjToInt(that.Req.FormValue("page"))
|
||||||
pageSize := ObjToInt(that.Req.FormValue("pageSize"))
|
pageSize := ObjToInt(that.Req.FormValue("pageSize"))
|
||||||
@@ -409,12 +524,12 @@ var TptProject = Proj{
|
|||||||
var reData []Map
|
var reData []Map
|
||||||
//是否下载
|
//是否下载
|
||||||
if download == 0 {
|
if download == 0 {
|
||||||
count = that.Db.Count(tableName, leftJoin, where)
|
count = that.Db.Count(tableName, where)
|
||||||
reData = that.Db.Page(page, pageSize).
|
reData = that.Db.Page(page, pageSize).
|
||||||
PageSelect(tableName, leftJoin, columnStr, where)
|
PageSelect(tableName, columnStr, where)
|
||||||
}
|
}
|
||||||
if download == 1 {
|
if download == 1 {
|
||||||
reData = that.Db.Select(tableName, leftJoin, columnStr, where)
|
reData = that.Db.Select(tableName, columnStr, where)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, v := range reData {
|
for _, v := range reData {
|
||||||
@@ -446,11 +561,11 @@ var TptProject = Proj{
|
|||||||
|
|
||||||
column := that.MakeCodeRouter[hotimeName].TableColumns[tableName][k]
|
column := that.MakeCodeRouter[hotimeName].TableColumns[tableName][k]
|
||||||
|
|
||||||
if column == nil {
|
if column == nil || column["link"] == nil || !(column["list"] == nil || column["list"] == true) || v.GetCeilInt64(column.GetString("name")) == 0 {
|
||||||
break
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (column["list"] == nil || column["list"] == true) && column["name"] == "parent_id" && column.GetString("link") != "" {
|
if column["name"] == "parent_id" {
|
||||||
|
|
||||||
parentC := that.Db.Get(column.GetString("link"), column.GetString("value"), Map{"id": v.GetCeilInt(k)})
|
parentC := that.Db.Get(column.GetString("link"), column.GetString("value"), Map{"id": v.GetCeilInt(k)})
|
||||||
v[column.GetString("link")+"_"+column.GetString("name")+"_"+column.GetString("value")] = ""
|
v[column.GetString("link")+"_"+column.GetString("name")+"_"+column.GetString("value")] = ""
|
||||||
@@ -459,6 +574,29 @@ var TptProject = Proj{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if column.GetString("name") != "parent_id" {
|
||||||
|
reStr := ""
|
||||||
|
|
||||||
|
if column.GetString("value") == "name" && that.MakeCodeRouter[hotimeName].TableColumns[column.GetString("link")]["nickname"] != nil {
|
||||||
|
reStr += "nickname,"
|
||||||
|
}
|
||||||
|
|
||||||
|
if that.MakeCodeRouter[hotimeName].TableColumns[column.GetString("link")]["phone"] != nil {
|
||||||
|
reStr += "phone,"
|
||||||
|
}
|
||||||
|
reStr += column.GetString("value")
|
||||||
|
|
||||||
|
joinData := that.Db.Get(column.GetString("link"), reStr, Map{"id": v.GetCeilInt64(column.GetString("name"))})
|
||||||
|
|
||||||
|
if joinData != nil {
|
||||||
|
for kj, vj := range joinData {
|
||||||
|
v[column.GetString("link")+"_"+column.GetString("name")+"_"+kj] = vj
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
//return isEnd
|
//return isEnd
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -552,6 +690,168 @@ var TptProject = Proj{
|
|||||||
|
|
||||||
that.Display(0, Map{"count": count, "data": reData})
|
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{
|
"hotime": Ctr{
|
||||||
"file": func(that *Context) {
|
"file": func(that *Context) {
|
||||||
@@ -662,6 +962,26 @@ var TptProject = Proj{
|
|||||||
that.Display(0, re)
|
that.Display(0, re)
|
||||||
},
|
},
|
||||||
"login": func(that *Context) {
|
"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]
|
hotimeName := that.RouterString[0]
|
||||||
fileConfig := that.MakeCodeRouter[hotimeName].FileConfig
|
fileConfig := that.MakeCodeRouter[hotimeName].FileConfig
|
||||||
|
|
||||||
@@ -675,11 +995,15 @@ var TptProject = Proj{
|
|||||||
where := Map{"name": name}
|
where := Map{"name": name}
|
||||||
if that.MakeCodeRouter[hotimeName].TableColumns[fileConfig.GetString("table")]["phone"] != nil {
|
if that.MakeCodeRouter[hotimeName].TableColumns[fileConfig.GetString("table")]["phone"] != nil {
|
||||||
where["phone"] = name
|
where["phone"] = name
|
||||||
where = Map{"AND": Map{"OR": where, "password": Md5(password)}}
|
where = Map{"OR": where, "password": Md5(password)}
|
||||||
} else {
|
} else {
|
||||||
where["password"] = Md5(password)
|
where["password"] = Md5(password)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(where) > 1 {
|
||||||
|
where = Map{"AND": where}
|
||||||
|
}
|
||||||
|
|
||||||
user := that.Db.Get(fileConfig.GetString("table"), "*", where)
|
user := that.Db.Get(fileConfig.GetString("table"), "*", where)
|
||||||
|
|
||||||
if user == nil {
|
if user == nil {
|
||||||
@@ -711,15 +1035,15 @@ var TptProject = Proj{
|
|||||||
that.Display(4, "找不到配置文件")
|
that.Display(4, "找不到配置文件")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if that.Session(fileConfig.GetString("table")+"_id").Data == nil {
|
if that.Session(fileConfig.GetString("table")+"_id").Data == nil {
|
||||||
|
conf := ObjToMap(string(btes))
|
||||||
conf := ObjToMap(string(btes))
|
//未登录只返回前端登录页所需的最少字段,避免泄露表结构等敏感信息
|
||||||
delete(conf, "menus")
|
that.Display(0, Map{
|
||||||
delete(conf, "tables")
|
"label": conf["label"],
|
||||||
//没有登录只需要返回这些信息
|
"name": conf["name"],
|
||||||
that.Display(0, conf)
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
//可读写配置
|
//可读写配置
|
||||||
conf := ObjToMap(string(btes))
|
conf := ObjToMap(string(btes))
|
||||||
menus := conf.GetSlice("menus")
|
menus := conf.GetSlice("menus")
|
||||||
@@ -739,11 +1063,12 @@ var TptProject = Proj{
|
|||||||
}
|
}
|
||||||
|
|
||||||
linkAuthMap := that.Db.Get(v.GetString("link"), "auth", Map{"id": user.GetCeilInt(v.GetString("name"))})
|
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))
|
//conf := ObjToMap(string(btes))
|
||||||
//menus := conf.GetSlice("menus")
|
//menus := conf.GetSlice("menus")
|
||||||
if linkAuth == nil {
|
if oldLinkAuth != nil {
|
||||||
linkAuth = Map{}
|
linkAuth = oldLinkAuth
|
||||||
}
|
}
|
||||||
|
|
||||||
for k1, _ := range menus {
|
for k1, _ := range menus {
|
||||||
@@ -754,7 +1079,7 @@ var TptProject = Proj{
|
|||||||
}
|
}
|
||||||
if v1["auth"] != nil {
|
if v1["auth"] != nil {
|
||||||
|
|
||||||
if linkAuth[name] == nil {
|
if oldLinkAuth == nil {
|
||||||
linkAuth[name] = v1["auth"]
|
linkAuth[name] = v1["auth"]
|
||||||
} else {
|
} else {
|
||||||
newAuth := Slice{}
|
newAuth := Slice{}
|
||||||
@@ -776,47 +1101,59 @@ var TptProject = Proj{
|
|||||||
name = v2.GetString("table")
|
name = v2.GetString("table")
|
||||||
}
|
}
|
||||||
if v2["auth"] != nil {
|
if v2["auth"] != nil {
|
||||||
newAuth := Slice{}
|
|
||||||
for k3, _ := range linkAuth.GetSlice(name) {
|
|
||||||
v3 := linkAuth.GetSlice(name).GetString(k3)
|
|
||||||
if strings.Contains(v2.GetString("auth"), v3) {
|
|
||||||
newAuth = append(newAuth, v3)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
linkAuth[name] = newAuth
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if oldLinkAuth == nil {
|
||||||
|
linkAuth[name] = v2["auth"]
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
newAuth := Slice{}
|
||||||
|
for k3, _ := range linkAuth.GetSlice(name) {
|
||||||
|
v3 := linkAuth.GetSlice(name).GetString(k3)
|
||||||
|
if strings.Contains(v2.GetString("auth"), v3) {
|
||||||
|
newAuth = append(newAuth, v3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
linkAuth[name] = newAuth
|
||||||
|
continue
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for k1, _ := range menus {
|
for k1, _ := range menus {
|
||||||
v1 := menus.GetMap(k1)
|
v1 := menus.GetMap(k1)
|
||||||
//保证个人权限可用
|
|
||||||
if fileConfig.GetString("table") == v1.GetString("table") {
|
|
||||||
v1["auth"] = Slice{"info", "edit"}
|
|
||||||
}
|
|
||||||
name := v1.GetString("name")
|
name := v1.GetString("name")
|
||||||
if name == "" {
|
if name == "" {
|
||||||
name = v1.GetString("table")
|
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]
|
v1["auth"] = linkAuth[name]
|
||||||
}
|
}
|
||||||
|
|
||||||
v1menus := v1.GetSlice("menus")
|
v1menus := v1.GetSlice("menus")
|
||||||
for k2, _ := range v1menus {
|
for k2, _ := range v1menus {
|
||||||
v2 := v1menus.GetMap(k2)
|
v2 := v1menus.GetMap(k2)
|
||||||
//保证个人权限可用
|
//保证个人权限可用
|
||||||
if fileConfig.GetString("table") == v2.GetString("table") {
|
|
||||||
v2["auth"] = Slice{"info", "edit"}
|
|
||||||
|
|
||||||
}
|
|
||||||
name := v2.GetString("name")
|
name := v2.GetString("name")
|
||||||
if name == "" {
|
if name == "" {
|
||||||
name = v2.GetString("table")
|
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]
|
v2["auth"] = linkAuth[name]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -837,7 +1174,7 @@ var TptProject = Proj{
|
|||||||
v1 := menus.GetMap(k1)
|
v1 := menus.GetMap(k1)
|
||||||
if config.GetMap("tables").GetMap(v1.GetString("table")) != nil && len(v1.GetSlice("auth")) != 0 {
|
if config.GetMap("tables").GetMap(v1.GetString("table")) != nil && len(v1.GetSlice("auth")) != 0 {
|
||||||
config.GetMap("tables").GetMap(v1.GetString("table"))["auth"] = Slice{}
|
config.GetMap("tables").GetMap(v1.GetString("table"))["auth"] = Slice{}
|
||||||
newTables[v1.GetString("name")] = config.GetMap("tables").GetMap(v1.GetString("table"))
|
newTables[v1.GetString("table")] = config.GetMap("tables").GetMap(v1.GetString("table"))
|
||||||
}
|
}
|
||||||
v1menus := v1.GetSlice("menus")
|
v1menus := v1.GetSlice("menus")
|
||||||
for k2, _ := range v1.GetSlice("menus") {
|
for k2, _ := range v1.GetSlice("menus") {
|
||||||
@@ -873,6 +1210,53 @@ var TptProject = Proj{
|
|||||||
|
|
||||||
that.Display(0, conf)
|
that.Display(0, conf)
|
||||||
},
|
},
|
||||||
|
//壁纸
|
||||||
|
"wallpaper": func(that *Context) {
|
||||||
|
random := ObjToCeilInt(that.Req.FormValue("random")) //壁纸随机,0为默认,1,为随机
|
||||||
|
|
||||||
|
tp := ObjToCeilInt(that.Req.FormValue("type")) //返回类型,0,json,1,302跳转到图片
|
||||||
|
|
||||||
|
images := that.Application.Cache("wallpaper").ToSlice()
|
||||||
|
if images == nil || len(images) == 0 {
|
||||||
|
|
||||||
|
url := "https://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n=7&mkt=zh-CN"
|
||||||
|
res, e := http.Get(url)
|
||||||
|
if e != nil {
|
||||||
|
that.Display(4, "无法取得数据0")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
b, err := ioutil.ReadAll(res.Body)
|
||||||
|
if err != nil {
|
||||||
|
that.Display(4, "无法取得数据1")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w := ObjToMap(string(b))
|
||||||
|
if len(w) == 0 {
|
||||||
|
that.Display(4, "无法取得数据2")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
images = w.GetSlice("images")
|
||||||
|
that.Application.Cache("wallpaper", images)
|
||||||
|
}
|
||||||
|
if random == 1 {
|
||||||
|
random = RandX(0, len(images)-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
img := images.GetMap(random)
|
||||||
|
if !strings.Contains(img.GetString("url"), "http") {
|
||||||
|
img["url"] = "https://cn.bing.com" + img.GetString("url")
|
||||||
|
}
|
||||||
|
if tp == 0 {
|
||||||
|
that.Display(0, img)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
that.Resp.Header().Set("Location", img.GetString("url"))
|
||||||
|
that.Resp.WriteHeader(302)
|
||||||
|
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+136
-54
@@ -42,79 +42,161 @@ var DefaultMenuParentName = "sys"
|
|||||||
|
|
||||||
var ColumnDataType = map[string]string{
|
var ColumnDataType = map[string]string{
|
||||||
//sqlite专有类型
|
//sqlite专有类型
|
||||||
"real": "number",
|
"real": "number",
|
||||||
//mysql数据类型宽泛类型
|
"integer": "number",
|
||||||
|
//mysql数据类型
|
||||||
"int": "number",
|
"int": "number",
|
||||||
"float": "number",
|
"float": "number",
|
||||||
"double": "number",
|
"double": "number",
|
||||||
"decimal": "number",
|
"decimal": "number",
|
||||||
"integer": "number", //sqlite3
|
|
||||||
"char": "text",
|
"char": "text",
|
||||||
"text": "text",
|
"text": "text",
|
||||||
"blob": "text",
|
"blob": "text",
|
||||||
"date": "time",
|
"date": "time",
|
||||||
"time": "time",
|
"time": "time",
|
||||||
"year": "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 {
|
type ColumnShow struct {
|
||||||
Name string
|
Name string //名称
|
||||||
List bool
|
|
||||||
Edit bool
|
List bool //列表权限
|
||||||
Info bool
|
Edit bool //新增和编辑权限
|
||||||
Must bool
|
Info bool //详情权限
|
||||||
|
Must bool //字段全匹配
|
||||||
Type string //空字符串表示
|
Type string //空字符串表示
|
||||||
Strict bool //name严格匹配必须是这个词才行
|
Strict bool //name严格匹配必须是这个词才行
|
||||||
}
|
}
|
||||||
|
|
||||||
var ColumnNameType = []ColumnShow{
|
var RuleConfig = []Map{
|
||||||
//通用
|
{"name": "idcard", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
|
||||||
{"idcard", false, true, true, false, "", false},
|
{"name": "id", "add": false, "list": true, "edit": false, "info": true, "must": false, "strict": true, "type": ""},
|
||||||
{"id", true, false, true, false, "", true},
|
{"name": "sn", "add": false, "list": true, "edit": false, "info": true, "must": false, "strict": false, "type": ""},
|
||||||
{"sn", true, false, true, false, "", false},
|
{"name": "parent_ids", "add": false, "list": false, "edit": false, "info": false, "must": false, "strict": true, "type": "index"},
|
||||||
{"parent_ids", false, false, false, false, "index", true},
|
{"name": "index", "add": false, "list": false, "edit": false, "info": false, "must": false, "strict": true, "type": "index"},
|
||||||
{"parent_id", true, true, true, false, "", true},
|
|
||||||
{"amount", true, true, true, false, "money", true},
|
|
||||||
{"info", false, true, true, false, "textArea", false},
|
|
||||||
//"sn"{true,true,true,""},
|
|
||||||
{"status", true, true, true, false, "select", false},
|
|
||||||
{"state", true, true, true, false, "select", false},
|
|
||||||
{"sex", true, true, true, false, "select", false},
|
|
||||||
{"delete", false, false, false, false, "", false},
|
|
||||||
|
|
||||||
{"lat", false, true, true, false, "", false},
|
{"name": "parent_id", "add": true, "list": true, "edit": true, "info": true, "must": false, "true": false, "type": ""},
|
||||||
{"lng", false, true, true, false, "", false},
|
|
||||||
{"latitude", false, true, true, false, "", false},
|
|
||||||
{"longitude", false, true, true, false, "", false},
|
|
||||||
|
|
||||||
{"index", false, false, false, false, "index", false},
|
{"name": "amount", "add": true, "list": true, "edit": true, "info": true, "must": false, "strict": true, "type": "money"},
|
||||||
|
|
||||||
{"password", false, true, false, false, "password", false},
|
{"name": "info", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": "textArea"},
|
||||||
{"pwd", false, true, false, false, "password", false},
|
|
||||||
|
|
||||||
{"version", false, false, false, false, "", false},
|
{"name": "status", "add": true, "list": true, "edit": true, "info": true, "must": false, "strict": false, "type": "select"},
|
||||||
{"seq", false, true, true, false, "", false},
|
{"name": "state", "add": true, "list": true, "edit": true, "info": true, "must": false, "strict": false, "type": "select"},
|
||||||
{"sort", false, true, true, false, "", false},
|
{"name": "sex", "add": true, "list": true, "edit": true, "info": true, "must": false, "strict": false, "type": "select"},
|
||||||
{"note", false, true, true, false, "", false},
|
|
||||||
{"description", false, true, true, false, "", false},
|
{"name": "delete", "add": false, "list": false, "edit": false, "info": false, "must": false, "strict": false, "type": ""},
|
||||||
{"abstract", false, true, true, false, "", false},
|
|
||||||
{"content", false, true, true, false, "textArea", false},
|
{"name": "lat", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
|
||||||
{"address", true, true, true, false, "", false},
|
{"name": "lng", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
|
||||||
{"full_name", false, true, true, false, "", false},
|
|
||||||
{"create_time", false, false, true, false, "time", true},
|
{"name": "latitude", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
|
||||||
{"modify_time", true, false, true, false, "time", true},
|
{"name": "longitude", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
|
||||||
{"image", false, true, true, false, "image", false},
|
|
||||||
{"img", false, true, true, false, "image", false},
|
{"name": "password", "add": true, "list": false, "edit": true, "info": false, "must": false, "strict": false, "type": "password"},
|
||||||
{"icon", false, true, true, false, "image", false},
|
{"name": "pwd", "add": true, "list": false, "edit": true, "info": false, "must": false, "strict": false, "type": "password"},
|
||||||
{"avatar", false, true, true, false, "image", false},
|
|
||||||
{"file", false, true, true, false, "file", false},
|
{"name": "version", "add": false, "list": false, "edit": false, "info": false, "must": false, "strict": false, "type": ""},
|
||||||
{"age", false, true, true, false, "", false},
|
{"name": "seq", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
|
||||||
{"email", false, true, true, false, "", false},
|
|
||||||
{"time", true, true, true, false, "time", false},
|
{"name": "sort", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
|
||||||
{"level", false, false, true, false, "", false},
|
{"name": "note", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
|
||||||
{"rule", true, true, true, false, "form", false},
|
|
||||||
{"auth", false, true, true, false, "auth", true},
|
{"name": "description", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
|
||||||
{"table", true, false, true, false, "table", false},
|
|
||||||
{"table_id", true, false, true, false, "table_id", false},
|
{"name": "abstract", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
|
||||||
|
|
||||||
|
{"name": "content", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": "textArea"},
|
||||||
|
|
||||||
|
{"name": "address", "add": true, "list": true, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
|
||||||
|
{"name": "full_name", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
|
||||||
|
|
||||||
|
{"name": "create_time", "add": false, "list": false, "edit": false, "info": true, "must": false, "strict": true, "type": "time"},
|
||||||
|
{"name": "modify_time", "add": false, "list": true, "edit": false, "info": true, "must": false, "strict": true, "type": "time"},
|
||||||
|
|
||||||
|
{"name": "image", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": "image"},
|
||||||
|
|
||||||
|
{"name": "img", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": "image"},
|
||||||
|
{"name": "avatar", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": "image"},
|
||||||
|
{"name": "icon", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": "image"},
|
||||||
|
|
||||||
|
{"name": "file", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": "file"},
|
||||||
|
|
||||||
|
{"name": "age", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
|
||||||
|
{"name": "email", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
|
||||||
|
{"name": "time", "add": true, "list": true, "edit": true, "info": true, "must": false, "strict": false, "type": "time"},
|
||||||
|
|
||||||
|
{"name": "level", "add": false, "list": false, "edit": false, "info": true, "must": false, "strict": false, "type": ""},
|
||||||
|
{"name": "rule", "add": true, "list": true, "edit": true, "info": true, "must": false, "strict": false, "type": "form"},
|
||||||
|
|
||||||
|
{"name": "auth", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": "auth"},
|
||||||
|
|
||||||
|
{"name": "table", "add": false, "list": true, "edit": false, "info": true, "must": false, "strict": false, "type": "table"},
|
||||||
|
{"name": "table_id", "add": false, "list": true, "edit": false, "info": true, "must": false, "strict": false, "type": "table_id"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//var ColumnNameType = []ColumnShow{
|
||||||
|
// //通用
|
||||||
|
// {"idcard", false, true, true, false, "", false},
|
||||||
|
// {"id", true, false, true, false, "", true},
|
||||||
|
// {"sn", true, false, true, false, "", false},
|
||||||
|
// {"parent_ids", false, false, false, false, "index", true},
|
||||||
|
// {"parent_id", true, true, true, false, "", true},
|
||||||
|
// {"amount", true, true, true, false, "money", true},
|
||||||
|
// {"info", false, true, true, false, "textArea", false},
|
||||||
|
// //"sn"{true,true,true,""},
|
||||||
|
// {"status", true, true, true, false, "select", false},
|
||||||
|
// {"state", true, true, true, false, "select", false},
|
||||||
|
// {"sex", true, true, true, false, "select", false},
|
||||||
|
// {"delete", false, false, false, false, "", false},
|
||||||
|
//
|
||||||
|
// {"lat", false, true, true, false, "", false},
|
||||||
|
// {"lng", false, true, true, false, "", false},
|
||||||
|
// {"latitude", false, true, true, false, "", false},
|
||||||
|
// {"longitude", false, true, true, false, "", false},
|
||||||
|
//
|
||||||
|
// {"index", false, false, false, false, "index", false},
|
||||||
|
//
|
||||||
|
// {"password", false, true, false, false, "password", false},
|
||||||
|
// {"pwd", false, true, false, false, "password", false},
|
||||||
|
//
|
||||||
|
// {"version", false, false, false, false, "", false},
|
||||||
|
// {"seq", false, true, true, false, "", false},
|
||||||
|
// {"sort", false, true, true, false, "", false},
|
||||||
|
// {"note", false, true, true, false, "", false},
|
||||||
|
// {"description", false, true, true, false, "", false},
|
||||||
|
// {"abstract", false, true, true, false, "", false},
|
||||||
|
// {"content", false, true, true, false, "textArea", false},
|
||||||
|
// {"address", true, true, true, false, "", false},
|
||||||
|
// {"full_name", false, true, true, false, "", false},
|
||||||
|
// {"create_time", false, false, true, false, "time", true},
|
||||||
|
// {"modify_time", true, false, true, false, "time", true},
|
||||||
|
// {"image", false, true, true, false, "image", false},
|
||||||
|
// {"img", false, true, true, false, "image", false},
|
||||||
|
// {"icon", false, true, true, false, "image", false},
|
||||||
|
// {"avatar", false, true, true, false, "image", false},
|
||||||
|
// {"file", false, true, true, false, "file", false},
|
||||||
|
// {"age", false, true, true, false, "", false},
|
||||||
|
// {"email", false, true, true, false, "", false},
|
||||||
|
// {"time", true, true, true, false, "time", false},
|
||||||
|
// {"level", false, false, true, false, "", false},
|
||||||
|
// {"rule", true, true, true, false, "form", false},
|
||||||
|
// {"auth", false, true, true, false, "auth", true},
|
||||||
|
// {"table", true, false, true, false, "table", false},
|
||||||
|
// {"table_id", true, false, true, false, "table_id", false},
|
||||||
|
//}
|
||||||
|
|||||||
+621
-391
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ type ContextBase struct {
|
|||||||
tag string
|
tag string
|
||||||
}
|
}
|
||||||
|
|
||||||
//唯一标志
|
// 唯一标志
|
||||||
func (that *ContextBase) GetTag() string {
|
func (that *ContextBase) GetTag() string {
|
||||||
|
|
||||||
if that.tag == "" {
|
if that.tag == "" {
|
||||||
|
|||||||
+48
-16
@@ -1,27 +1,59 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
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 {
|
type Error struct {
|
||||||
Logger *logrus.Logger
|
|
||||||
error
|
error
|
||||||
|
mu sync.RWMutex
|
||||||
|
Logger *log.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func (that *Error) GetError() error {
|
// Error 安全实现 error 接口 —— 内部 error 为 nil 时返回 "" 而非 panic
|
||||||
|
func (that *Error) Error() string {
|
||||||
return that.error
|
that.mu.RLock()
|
||||||
|
defer that.mu.RUnlock()
|
||||||
}
|
if that.error == nil {
|
||||||
|
return ""
|
||||||
func (that *Error) SetError(err error) {
|
}
|
||||||
that.error = err
|
return that.error.Error()
|
||||||
if that.Logger != nil && err != nil {
|
}
|
||||||
//that.Logger=log.GetLog("",false)
|
|
||||||
that.Logger.Warn(err)
|
// 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
|
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-1
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"math"
|
"math"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
//安全锁
|
//安全锁
|
||||||
@@ -36,6 +37,42 @@ func StrFirstToUpper(str string) string {
|
|||||||
return strings.ToUpper(first) + other
|
return strings.ToUpper(first) + other
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 时间转字符串,第二个参数支持1-5对应显示年月日时分秒
|
||||||
|
func Time2Str(t time.Time, qu ...interface{}) string {
|
||||||
|
if t.Unix() < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
tp := 5
|
||||||
|
if len(qu) != 0 {
|
||||||
|
tp = (qu[0]).(int)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch tp {
|
||||||
|
case 1:
|
||||||
|
return t.Format("2006-01")
|
||||||
|
case 2:
|
||||||
|
return t.Format("2006-01-02")
|
||||||
|
case 3:
|
||||||
|
return t.Format("2006-01-02 15")
|
||||||
|
case 4:
|
||||||
|
return t.Format("2006-01-02 15:04")
|
||||||
|
case 5:
|
||||||
|
return t.Format("2006-01-02 15:04:05")
|
||||||
|
case 12:
|
||||||
|
return t.Format("01-02")
|
||||||
|
case 14:
|
||||||
|
return t.Format("01-02 15:04")
|
||||||
|
case 15:
|
||||||
|
return t.Format("01-02 15:04:05")
|
||||||
|
case 34:
|
||||||
|
return t.Format("15:04")
|
||||||
|
case 35:
|
||||||
|
return t.Format("15:04:05")
|
||||||
|
}
|
||||||
|
return t.Format("2006-01-02 15:04:05")
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
// StrLd 相似度计算 ld compares two strings and returns the levenshtein distance between them.
|
// StrLd 相似度计算 ld compares two strings and returns the levenshtein distance between them.
|
||||||
func StrLd(s, t string, ignoreCase bool) int {
|
func StrLd(s, t string, ignoreCase bool) int {
|
||||||
if ignoreCase {
|
if ignoreCase {
|
||||||
@@ -104,7 +141,7 @@ func Substr(str string, start int, length int) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// IndexLastStr 获取最后出现字符串的下标
|
// IndexLastStr 获取最后出现字符串的下标
|
||||||
//return 找不到返回 -1
|
// return 找不到返回 -1
|
||||||
func IndexLastStr(str, sep string) int {
|
func IndexLastStr(str, sep string) int {
|
||||||
sepSlice := []rune(sep)
|
sepSlice := []rune(sep)
|
||||||
strSlice := []rune(str)
|
strSlice := []rune(str)
|
||||||
|
|||||||
+28
-15
@@ -1,6 +1,7 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"reflect"
|
"reflect"
|
||||||
@@ -8,10 +9,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
//hotime的常用map
|
// hotime的常用map
|
||||||
type Map map[string]interface{}
|
type Map map[string]interface{}
|
||||||
|
|
||||||
//获取string
|
// 获取string
|
||||||
func (that Map) GetString(key string, err ...*Error) string {
|
func (that Map) GetString(key string, err ...*Error) string {
|
||||||
|
|
||||||
if len(err) != 0 {
|
if len(err) != 0 {
|
||||||
@@ -26,7 +27,7 @@ func (that *Map) Pointer() *Map {
|
|||||||
return that
|
return that
|
||||||
}
|
}
|
||||||
|
|
||||||
//增加接口
|
// 增加接口
|
||||||
func (that Map) Put(key string, value interface{}) {
|
func (that Map) Put(key string, value interface{}) {
|
||||||
//if that==nil{
|
//if that==nil{
|
||||||
// that=Map{}
|
// that=Map{}
|
||||||
@@ -34,13 +35,13 @@ func (that Map) Put(key string, value interface{}) {
|
|||||||
that[key] = value
|
that[key] = value
|
||||||
}
|
}
|
||||||
|
|
||||||
//删除接口
|
// 删除接口
|
||||||
func (that Map) Delete(key string) {
|
func (that Map) Delete(key string) {
|
||||||
delete(that, key)
|
delete(that, key)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//获取Int
|
// 获取Int
|
||||||
func (that Map) GetInt(key string, err ...*Error) int {
|
func (that Map) GetInt(key string, err ...*Error) int {
|
||||||
v := ObjToInt((that)[key], err...)
|
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 {
|
func (that Map) GetInt64(key string, err ...*Error) int64 {
|
||||||
v := ObjToInt64((that)[key], err...)
|
v := ObjToInt64((that)[key], err...)
|
||||||
return v
|
return v
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//获取向上取整Int64
|
// 获取向上取整Int64
|
||||||
func (that Map) GetCeilInt64(key string, err ...*Error) int64 {
|
func (that Map) GetCeilInt64(key string, err ...*Error) int64 {
|
||||||
v := ObjToCeilInt64((that)[key], err...)
|
v := ObjToCeilInt64((that)[key], err...)
|
||||||
return v
|
return v
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//获取向上取整Int
|
// 获取向上取整Int
|
||||||
func (that Map) GetCeilInt(key string, err ...*Error) int {
|
func (that Map) GetCeilInt(key string, err ...*Error) int {
|
||||||
v := ObjToCeilInt((that)[key], err...)
|
v := ObjToCeilInt((that)[key], err...)
|
||||||
return v
|
return v
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//获取向上取整float64
|
// 获取向上取整float64
|
||||||
func (that Map) GetCeilFloat64(key string, err ...*Error) float64 {
|
func (that Map) GetCeilFloat64(key string, err ...*Error) float64 {
|
||||||
v := ObjToCeilFloat64((that)[key], err...)
|
v := ObjToCeilFloat64((that)[key], err...)
|
||||||
return v
|
return v
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//获取Float64
|
// 获取Float64
|
||||||
func (that Map) GetFloat64(key string, err ...*Error) float64 {
|
func (that Map) GetFloat64(key string, err ...*Error) float64 {
|
||||||
|
|
||||||
v := ObjToFloat64((that)[key], err...)
|
v := ObjToFloat64((that)[key], err...)
|
||||||
@@ -149,7 +150,7 @@ func (that Map) Get(key string, err ...*Error) interface{} {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
//请传递指针过来
|
// 请传递指针过来
|
||||||
func (that Map) ToStruct(stct interface{}) {
|
func (that Map) ToStruct(stct interface{}) {
|
||||||
|
|
||||||
data := reflect.ValueOf(stct).Elem()
|
data := reflect.ValueOf(stct).Elem()
|
||||||
@@ -181,9 +182,21 @@ func (that Map) ToJsonString() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (that Map) JsonToMap(jsonStr string, err ...*Error) {
|
func (that Map) JsonToMap(jsonStr string, err ...*Error) {
|
||||||
e := json.Unmarshal([]byte(jsonStr), &that)
|
dec := json.NewDecoder(bytes.NewReader([]byte(jsonStr)))
|
||||||
if e != nil && len(err) != 0 {
|
dec.UseNumber()
|
||||||
err[0].SetError(e)
|
e := dec.Decode(&that)
|
||||||
|
if e != nil {
|
||||||
|
if len(err) != 0 {
|
||||||
|
err[0].SetError(e)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for k, v := range that {
|
||||||
|
that[k] = fixJsonNumbers(v)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRoundFloat64 获取四舍五入到 precision 位小数的 float64
|
||||||
|
func (that Map) GetRoundFloat64(key string, precision int, err ...*Error) float64 {
|
||||||
|
return ObjToRoundFloat64((that)[key], precision, err...)
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-3
@@ -2,7 +2,7 @@ package common
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
//对象封装方便取用
|
// 对象封装方便取用
|
||||||
type Obj struct {
|
type Obj struct {
|
||||||
Data interface{}
|
Data interface{}
|
||||||
Error
|
Error
|
||||||
@@ -82,7 +82,7 @@ func (that *Obj) ToObj() interface{} {
|
|||||||
return that.Data
|
return that.Data
|
||||||
}
|
}
|
||||||
|
|
||||||
//获取向上取整Int64
|
// 获取向上取整Int64
|
||||||
func (that *Obj) ToCeilInt64(err ...*Error) int64 {
|
func (that *Obj) ToCeilInt64(err ...*Error) int64 {
|
||||||
if len(err) != 0 {
|
if len(err) != 0 {
|
||||||
that.Error = *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 {
|
func (that *Obj) ToCeilInt(err ...*Error) int {
|
||||||
if len(err) != 0 {
|
if len(err) != 0 {
|
||||||
that.Error = *err[0]
|
that.Error = *err[0]
|
||||||
@@ -101,3 +101,8 @@ func (that *Obj) ToCeilInt(err ...*Error) int {
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ToRoundFloat64 获取四舍五入到 precision 位小数的 float64
|
||||||
|
func (that *Obj) ToRoundFloat64(precision int, err ...*Error) float64 {
|
||||||
|
return ObjToRoundFloat64(that.Data, precision, err...)
|
||||||
|
}
|
||||||
|
|||||||
+260
-114
@@ -1,14 +1,16 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"math"
|
"math"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
//仅限于hotime.Slice
|
// 仅限于hotime.Slice
|
||||||
func ObjToMap(obj interface{}, e ...*Error) Map {
|
func ObjToMap(obj interface{}, e ...*Error) Map {
|
||||||
var err error
|
var err error
|
||||||
var v Map
|
var v Map
|
||||||
@@ -17,31 +19,40 @@ func ObjToMap(obj interface{}, e ...*Error) Map {
|
|||||||
v = nil
|
v = nil
|
||||||
err = errors.New("没有合适的转换对象!")
|
err = errors.New("没有合适的转换对象!")
|
||||||
} else {
|
} else {
|
||||||
switch obj.(type) {
|
switch val := obj.(type) {
|
||||||
case Map:
|
case Map:
|
||||||
v = obj.(Map)
|
v = val
|
||||||
case map[string]interface{}:
|
case map[string]interface{}:
|
||||||
v = obj.(map[string]interface{})
|
v = Map(val)
|
||||||
case string:
|
case string:
|
||||||
v = Map{}
|
result, e2 := JsonToObj(val)
|
||||||
e := json.Unmarshal([]byte(obj.(string)), &v)
|
if e2 != nil {
|
||||||
if e != nil {
|
err = errors.New("没有合适的转换对象!" + e2.Error())
|
||||||
err = errors.New("没有合适的转换对象!" + e.Error())
|
v = nil
|
||||||
|
} else if m, ok := result.(map[string]interface{}); ok {
|
||||||
|
v = m
|
||||||
|
} else {
|
||||||
|
err = errors.New("没有合适的转换对象!")
|
||||||
v = nil
|
v = nil
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
data, err := json.Marshal(obj)
|
var data []byte
|
||||||
|
data, err = json.Marshal(obj)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err = errors.New("没有合适的转换对象!" + err.Error())
|
err = errors.New("没有合适的转换对象!" + err.Error())
|
||||||
v = nil
|
v = nil
|
||||||
|
break
|
||||||
}
|
}
|
||||||
v = Map{}
|
result, e2 := JsonToObj(string(data))
|
||||||
e := json.Unmarshal(data, &v)
|
if e2 != nil {
|
||||||
if e != nil {
|
err = errors.New("没有合适的转换对象!" + e2.Error())
|
||||||
err = errors.New("没有合适的转换对象!" + e.Error())
|
v = nil
|
||||||
|
} else if m, ok := result.(map[string]interface{}); ok {
|
||||||
|
v = m
|
||||||
|
} else {
|
||||||
|
err = errors.New("没有合适的转换对象!")
|
||||||
v = nil
|
v = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(e) != 0 {
|
if len(e) != 0 {
|
||||||
@@ -52,14 +63,14 @@ func ObjToMap(obj interface{}, e ...*Error) Map {
|
|||||||
|
|
||||||
func ObjToMapArray(obj interface{}, e ...*Error) []Map {
|
func ObjToMapArray(obj interface{}, e ...*Error) []Map {
|
||||||
s := ObjToSlice(obj, e...)
|
s := ObjToSlice(obj, e...)
|
||||||
res := []Map{}
|
res := make([]Map, 0, len(s))
|
||||||
for i := 0; i < len(s); i++ {
|
for i := 0; i < len(s); i++ {
|
||||||
res = append(res, s.GetMap(i))
|
res = append(res, s.GetMap(i))
|
||||||
}
|
}
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
//仅限于hotime.Slice
|
// 仅限于hotime.Slice
|
||||||
func ObjToSlice(obj interface{}, e ...*Error) Slice {
|
func ObjToSlice(obj interface{}, e ...*Error) Slice {
|
||||||
var err error
|
var err error
|
||||||
var v Slice
|
var v Slice
|
||||||
@@ -68,25 +79,39 @@ func ObjToSlice(obj interface{}, e ...*Error) Slice {
|
|||||||
v = nil
|
v = nil
|
||||||
err = errors.New("没有合适的转换对象!")
|
err = errors.New("没有合适的转换对象!")
|
||||||
} else {
|
} else {
|
||||||
switch obj.(type) {
|
switch val := obj.(type) {
|
||||||
case Slice:
|
case Slice:
|
||||||
v = obj.(Slice)
|
v = val
|
||||||
case []interface{}:
|
case []interface{}:
|
||||||
v = obj.([]interface{})
|
v = Slice(val)
|
||||||
case []string:
|
case []string:
|
||||||
v = Slice{}
|
v = make(Slice, len(val))
|
||||||
for i := 0; i < len(obj.([]string)); i++ {
|
for i, s := range val {
|
||||||
v = append(v, obj.([]string)[i])
|
v[i] = s
|
||||||
}
|
}
|
||||||
case string:
|
case string:
|
||||||
v = Slice{}
|
result, e2 := JsonToObj(val)
|
||||||
err = json.Unmarshal([]byte(obj.(string)), &v)
|
if e2 != nil {
|
||||||
|
err = e2
|
||||||
|
} else if s, ok := result.([]interface{}); ok {
|
||||||
|
v = s
|
||||||
|
} else {
|
||||||
|
err = errors.New("没有合适的转换对象!")
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
v = Slice{}
|
|
||||||
var data []byte
|
var data []byte
|
||||||
data, err = json.Marshal(obj)
|
data, err = json.Marshal(obj)
|
||||||
err = json.Unmarshal(data, &v)
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
result, e2 := JsonToObj(string(data))
|
||||||
|
if e2 != nil {
|
||||||
|
err = e2
|
||||||
|
} else if s, ok := result.([]interface{}); ok {
|
||||||
|
v = s
|
||||||
|
} else {
|
||||||
|
err = errors.New("没有合适的转换对象!")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +128,23 @@ func ObjToTime(obj interface{}, e ...*Error) *time.Time {
|
|||||||
//字符串类型,只支持标准mysql datetime格式
|
//字符串类型,只支持标准mysql datetime格式
|
||||||
if tInt == 0 {
|
if tInt == 0 {
|
||||||
tStr := ObjToStr(obj)
|
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 {
|
if len(tStr) > 18 {
|
||||||
t, e := time.Parse("2006-01-02 15:04:05", tStr)
|
t, e := time.Parse("2006-01-02 15:04:05", tStr)
|
||||||
@@ -130,27 +172,30 @@ func ObjToTime(obj interface{}, e ...*Error) *time.Time {
|
|||||||
return &t
|
return &t
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 缓存字符串,避免重复调用
|
||||||
|
tIntStr := ObjToStr(tInt)
|
||||||
|
tIntLen := len(tIntStr)
|
||||||
|
|
||||||
//纳秒级别
|
//纳秒级别
|
||||||
if len(ObjToStr(tInt)) > 16 {
|
if tIntLen > 16 {
|
||||||
t := time.Time{}.Add(time.Nanosecond * time.Duration(tInt))
|
t := time.UnixMicro(tInt / 1000)
|
||||||
return &t
|
return &t
|
||||||
//微秒级别
|
//微秒级别
|
||||||
} else if len(ObjToStr(tInt)) > 13 {
|
} else if tIntLen > 13 {
|
||||||
t := time.Time{}.Add(time.Microsecond * time.Duration(tInt))
|
t := time.UnixMicro(tInt)
|
||||||
return &t
|
return &t
|
||||||
//毫秒级别
|
//毫秒级别
|
||||||
} else if len(ObjToStr(tInt)) > 10 {
|
} else if tIntLen > 10 {
|
||||||
t := time.Time{}.Add(time.Millisecond * time.Duration(tInt))
|
t := time.UnixMilli(tInt)
|
||||||
return &t
|
return &t
|
||||||
//秒级别
|
//秒级别
|
||||||
} else if len(ObjToStr(tInt)) > 9 {
|
} else if tIntLen > 9 {
|
||||||
t := time.Time{}.Add(time.Second * time.Duration(tInt))
|
t := time.Unix(tInt, 0)
|
||||||
return &t
|
return &t
|
||||||
} else if len(ObjToStr(tInt)) > 3 {
|
} else if tIntLen > 3 {
|
||||||
t, e := time.Parse("2006", ObjToStr(tInt))
|
t, e := time.Parse("2006", tIntStr)
|
||||||
if e == nil {
|
if e == nil {
|
||||||
return &t
|
return &t
|
||||||
}
|
}
|
||||||
@@ -164,37 +209,54 @@ func ObjToFloat64(obj interface{}, e ...*Error) float64 {
|
|||||||
v := float64(0)
|
v := float64(0)
|
||||||
|
|
||||||
if obj == nil {
|
if obj == nil {
|
||||||
|
|
||||||
err = errors.New("没有合适的转换对象!")
|
err = errors.New("没有合适的转换对象!")
|
||||||
} else {
|
} else {
|
||||||
|
switch val := obj.(type) {
|
||||||
switch obj.(type) {
|
|
||||||
case int:
|
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:
|
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:
|
case string:
|
||||||
value, e := strconv.ParseFloat(obj.(string), 64)
|
value, e2 := strconv.ParseFloat(val, 64)
|
||||||
if e != nil {
|
if e2 != nil {
|
||||||
v = float64(0)
|
err = e2
|
||||||
err = e
|
|
||||||
} else {
|
} else {
|
||||||
v = value
|
v = value
|
||||||
}
|
}
|
||||||
case float64:
|
case float64:
|
||||||
v = obj.(float64)
|
v = val
|
||||||
case float32:
|
case float32:
|
||||||
v = float64(obj.(float32))
|
// 通过 string 中转避免 float32→float64 精度膨胀(如 float32(1.2) 直接转得 1.200000047...)
|
||||||
case uint8:
|
v, _ = strconv.ParseFloat(strconv.FormatFloat(float64(val), 'f', -1, 32), 64)
|
||||||
value, e := strconv.ParseFloat(obj.(string), 64)
|
case []byte:
|
||||||
if e != nil {
|
// 数据库 driver 有时以 []byte 返回数值字符串
|
||||||
v = float64(0)
|
value, e2 := strconv.ParseFloat(string(val), 64)
|
||||||
err = e
|
if e2 != nil {
|
||||||
|
err = e2
|
||||||
} else {
|
} else {
|
||||||
v = value
|
v = value
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
v = float64(0)
|
|
||||||
err = errors.New("没有合适的转换对象!")
|
err = errors.New("没有合适的转换对象!")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,25 +276,33 @@ func ObjToFloat64(obj interface{}, e ...*Error) float64 {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
//向上取整
|
// ObjToRoundFloat64 将 obj 转为 float64 后四舍五入到 precision 位小数。
|
||||||
func ObjToCeilInt64(obj interface{}, e ...*Error) int64 {
|
// precision < 0 时不做舍入,直接返回原始 float64。
|
||||||
f := ObjToCeilFloat64(obj, e...)
|
func ObjToRoundFloat64(obj interface{}, precision int, e ...*Error) float64 {
|
||||||
return ObjToInt64(math.Ceil(f))
|
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 {
|
func ObjToCeilFloat64(obj interface{}, e ...*Error) float64 {
|
||||||
f := ObjToFloat64(obj, e...)
|
f := ObjToFloat64(obj, e...)
|
||||||
return math.Ceil(f)
|
return math.Ceil(f)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//向上取整
|
// 向上取整
|
||||||
func ObjToCeilInt(obj interface{}, e ...*Error) int {
|
func ObjToCeilInt(obj interface{}, e ...*Error) int {
|
||||||
f := ObjToCeilFloat64(obj, e...)
|
f := ObjToCeilFloat64(obj, e...)
|
||||||
return ObjToInt(f)
|
return ObjToInt(f)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ObjToInt64(obj interface{}, e ...*Error) int64 {
|
func ObjToInt64(obj interface{}, e ...*Error) int64 {
|
||||||
@@ -240,36 +310,63 @@ func ObjToInt64(obj interface{}, e ...*Error) int64 {
|
|||||||
v := int64(0)
|
v := int64(0)
|
||||||
|
|
||||||
if obj == nil {
|
if obj == nil {
|
||||||
|
|
||||||
err = errors.New("没有合适的转换对象!")
|
err = errors.New("没有合适的转换对象!")
|
||||||
} else {
|
} else {
|
||||||
switch obj.(type) {
|
switch val := obj.(type) {
|
||||||
case int:
|
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:
|
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:
|
case string:
|
||||||
value, e := StrToInt(obj.(string))
|
value, e2 := StrToInt(val)
|
||||||
if e != nil {
|
if e2 != nil {
|
||||||
v = int64(0)
|
// fallback:处理 "1.20" 这类带小数点的整数字符串
|
||||||
err = e
|
if fv, fe := strconv.ParseFloat(val, 64); fe == nil {
|
||||||
|
v = int64(fv)
|
||||||
|
} else {
|
||||||
|
err = e2
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
v = int64(value)
|
v = int64(value)
|
||||||
}
|
}
|
||||||
case uint8:
|
case []byte:
|
||||||
value, e := StrToInt(obj.(string))
|
// 数据库 driver 有时以 []byte 返回整数字符串
|
||||||
if e != nil {
|
value, e2 := StrToInt(string(val))
|
||||||
v = int64(0)
|
if e2 != nil {
|
||||||
err = e
|
// fallback:处理 "1.20" 这类带小数点的整数字符串
|
||||||
|
if fv, fe := strconv.ParseFloat(string(val), 64); fe == nil {
|
||||||
|
v = int64(fv)
|
||||||
|
} else {
|
||||||
|
err = e2
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
v = int64(value)
|
v = int64(value)
|
||||||
}
|
}
|
||||||
case float64:
|
case float64:
|
||||||
v = int64(obj.(float64))
|
v = int64(val)
|
||||||
case float32:
|
case float32:
|
||||||
v = int64(obj.(float32))
|
v = int64(val)
|
||||||
default:
|
default:
|
||||||
v = int64(0)
|
|
||||||
err = errors.New("没有合适的转换对象!")
|
err = errors.New("没有合适的转换对象!")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -289,18 +386,19 @@ func ObjToBool(obj interface{}, e ...*Error) bool {
|
|||||||
v := false
|
v := false
|
||||||
|
|
||||||
if obj == nil {
|
if obj == nil {
|
||||||
|
|
||||||
err = errors.New("没有合适的转换对象!")
|
err = errors.New("没有合适的转换对象!")
|
||||||
} else {
|
} else {
|
||||||
switch obj.(type) {
|
switch val := obj.(type) {
|
||||||
case bool:
|
case bool:
|
||||||
v = obj.(bool)
|
v = val
|
||||||
default:
|
default:
|
||||||
toInt := ObjToInt(obj)
|
var intErr Error
|
||||||
if toInt != 0 {
|
toInt := ObjToInt(obj, &intErr)
|
||||||
|
if intErr.GetError() != nil {
|
||||||
|
err = errors.New("没有合适的转换对象!")
|
||||||
|
} else if toInt != 0 {
|
||||||
v = true
|
v = true
|
||||||
}
|
}
|
||||||
err = errors.New("没有合适的转换对象!")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(e) != 0 {
|
if len(e) != 0 {
|
||||||
@@ -310,55 +408,107 @@ func ObjToBool(obj interface{}, e ...*Error) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ObjToStr(obj interface{}) string {
|
func ObjToStr(obj interface{}) string {
|
||||||
// fmt.Println(reflect.ValueOf(obj).Type().String() )
|
|
||||||
str := ""
|
str := ""
|
||||||
if obj == nil {
|
if obj == nil {
|
||||||
return str
|
return str
|
||||||
}
|
}
|
||||||
switch obj.(type) {
|
switch val := obj.(type) {
|
||||||
case int:
|
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:
|
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:
|
case int64:
|
||||||
str = strconv.FormatInt(obj.(int64), 10)
|
str = strconv.FormatInt(val, 10)
|
||||||
case []byte:
|
case []byte:
|
||||||
str = string(obj.([]byte))
|
str = string(val)
|
||||||
case string:
|
case string:
|
||||||
str = obj.(string)
|
str = val
|
||||||
case float64:
|
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:
|
default:
|
||||||
strbte, err := json.MarshalIndent(obj, "", "\t")
|
strbte, err := json.MarshalIndent(obj, "", "\t")
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
str = string(strbte)
|
str = string(strbte)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return str
|
return str
|
||||||
}
|
}
|
||||||
|
|
||||||
//转换为Map
|
// JsonToObj 将 JSON 字符串反序列化为 interface{},保留数字原始类型。
|
||||||
func StrToMap(string string) Map {
|
// 标准 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 := Map{}
|
||||||
data.JsonToMap(string)
|
data.JsonToMap(s)
|
||||||
|
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
//转换为Slice
|
// 转换为Slice
|
||||||
func StrToSlice(string string) Slice {
|
func StrToSlice(s string) Slice {
|
||||||
|
return ObjToSlice(s)
|
||||||
data := ObjToSlice(string)
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//字符串数组: a1,a2,a3转["a1","a2","a3"]
|
// 字符串数组: a1,a2,a3转["a1","a2","a3"]
|
||||||
func StrArrayToJsonStr(a string) string {
|
func StrArrayToJsonStr(a string) string {
|
||||||
|
|
||||||
if len(a) > 2 {
|
if len(a) > 2 {
|
||||||
if a[0] == ',' {
|
if a[0] == ',' {
|
||||||
a = Substr(a, 1, len(a)-1)
|
a = Substr(a, 1, len(a)-1)
|
||||||
@@ -366,7 +516,6 @@ func StrArrayToJsonStr(a string) string {
|
|||||||
if a[len(a)-1] == ',' {
|
if a[len(a)-1] == ',' {
|
||||||
a = Substr(a, 0, len(a)-1)
|
a = Substr(a, 0, len(a)-1)
|
||||||
}
|
}
|
||||||
//a = strings.Replace(a, ",", `,`, -1)
|
|
||||||
a = `[` + a + `]`
|
a = `[` + a + `]`
|
||||||
} else {
|
} else {
|
||||||
a = "[]"
|
a = "[]"
|
||||||
@@ -374,19 +523,16 @@ func StrArrayToJsonStr(a string) string {
|
|||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
//字符串数组: a1,a2,a3转["a1","a2","a3"]
|
// 字符串数组: ["a1","a2","a3"]转,a1,a2,a3,
|
||||||
func JsonStrToStrArray(a string) string {
|
func JsonStrToStrArray(a string) string {
|
||||||
//a = strings.Replace(a, `"`, "", -1)
|
|
||||||
if len(a) != 0 {
|
if len(a) != 0 {
|
||||||
a = Substr(a, 1, len(a)-2)
|
a = Substr(a, 1, len(a)-2)
|
||||||
}
|
}
|
||||||
|
|
||||||
return "," + a + ","
|
return "," + a + ","
|
||||||
}
|
}
|
||||||
|
|
||||||
//字符串转int
|
// 字符串转int
|
||||||
func StrToInt(s string) (int, error) {
|
func StrToInt(s string) (int, error) {
|
||||||
i, err := strconv.Atoi(s)
|
i, err := strconv.Atoi(s)
|
||||||
return i, err
|
return i, err
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,917 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// 辅助:检查 Error 是否携带错误
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
func hasErr(e *Error) bool {
|
||||||
|
return e.GetError() != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newErr() *Error {
|
||||||
|
return &Error{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// ObjToMap
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
func TestObjToMap(t *testing.T) {
|
||||||
|
t.Run("nil returns nil with error", func(t *testing.T) {
|
||||||
|
e := newErr()
|
||||||
|
v := ObjToMap(nil, e)
|
||||||
|
if v != nil || !hasErr(e) {
|
||||||
|
t.Errorf("nil: got %v err=%v", v, e.GetError())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Map passthrough", func(t *testing.T) {
|
||||||
|
input := Map{"a": 1}
|
||||||
|
v := ObjToMap(input)
|
||||||
|
if v == nil || v["a"] != 1 {
|
||||||
|
t.Errorf("Map passthrough failed: %v", v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("map[string]interface{} cast", func(t *testing.T) {
|
||||||
|
input := map[string]interface{}{"b": 2}
|
||||||
|
v := ObjToMap(input)
|
||||||
|
if v == nil || v["b"] != 2 {
|
||||||
|
t.Errorf("map[string]interface{} cast failed: %v", v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid JSON string - integer preserved as int64", func(t *testing.T) {
|
||||||
|
v := ObjToMap(`{"x":42}`)
|
||||||
|
if v == nil || v["x"] != int64(42) {
|
||||||
|
t.Errorf("JSON string: got %v (type %T), want int64(42)", v["x"], v["x"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid JSON string returns nil with error", func(t *testing.T) {
|
||||||
|
e := newErr()
|
||||||
|
v := ObjToMap(`not json`, e)
|
||||||
|
if v != nil || !hasErr(e) {
|
||||||
|
t.Errorf("invalid JSON: got %v err=%v", v, e.GetError())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("struct default branch marshal+unmarshal", func(t *testing.T) {
|
||||||
|
type S struct{ Name string }
|
||||||
|
v := ObjToMap(S{Name: "hello"})
|
||||||
|
if v == nil || v["Name"] != "hello" {
|
||||||
|
t.Errorf("struct default: %v", v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("marshal fail (channel) returns nil with error", func(t *testing.T) {
|
||||||
|
e := newErr()
|
||||||
|
ch := make(chan int)
|
||||||
|
v := ObjToMap(ch, e)
|
||||||
|
if v != nil || !hasErr(e) {
|
||||||
|
t.Errorf("channel marshal: got %v err=%v", v, e.GetError())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("default marshal OK but unmarshal to map fails (int)", func(t *testing.T) {
|
||||||
|
// json.Marshal(123) = "123", which can't unmarshal to Map
|
||||||
|
e := newErr()
|
||||||
|
v := ObjToMap(123, e)
|
||||||
|
if v != nil || !hasErr(e) {
|
||||||
|
t.Errorf("int default: got %v err=%v", v, e.GetError())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// ObjToMapArray
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
func TestObjToMapArray(t *testing.T) {
|
||||||
|
t.Run("nil returns empty", func(t *testing.T) {
|
||||||
|
e := newErr()
|
||||||
|
v := ObjToMapArray(nil, e)
|
||||||
|
if len(v) != 0 || !hasErr(e) {
|
||||||
|
t.Errorf("nil: got %v err=%v", v, e.GetError())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("JSON array of maps", func(t *testing.T) {
|
||||||
|
v := ObjToMapArray(`[{"a":1},{"b":2}]`)
|
||||||
|
if len(v) != 2 {
|
||||||
|
t.Errorf("expected 2 elements, got %d", len(v))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// ObjToSlice
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
func TestObjToSlice(t *testing.T) {
|
||||||
|
t.Run("nil returns nil with error", func(t *testing.T) {
|
||||||
|
e := newErr()
|
||||||
|
v := ObjToSlice(nil, e)
|
||||||
|
if v != nil || !hasErr(e) {
|
||||||
|
t.Errorf("nil: got %v err=%v", v, e.GetError())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Slice passthrough", func(t *testing.T) {
|
||||||
|
input := Slice{1, 2, 3}
|
||||||
|
v := ObjToSlice(input)
|
||||||
|
if len(v) != 3 {
|
||||||
|
t.Errorf("Slice passthrough: %v", v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("[]interface{} cast", func(t *testing.T) {
|
||||||
|
input := []interface{}{4, 5}
|
||||||
|
v := ObjToSlice(input)
|
||||||
|
if len(v) != 2 || v[0] != 4 {
|
||||||
|
t.Errorf("[]interface{} cast: %v", v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("[]string conversion", func(t *testing.T) {
|
||||||
|
input := []string{"a", "b", "c"}
|
||||||
|
v := ObjToSlice(input)
|
||||||
|
if len(v) != 3 || v[0] != "a" {
|
||||||
|
t.Errorf("[]string: %v", v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid JSON array string", func(t *testing.T) {
|
||||||
|
v := ObjToSlice(`[1,2,3]`)
|
||||||
|
if len(v) != 3 {
|
||||||
|
t.Errorf("JSON array: %v", v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid JSON string returns error", func(t *testing.T) {
|
||||||
|
e := newErr()
|
||||||
|
v := ObjToSlice(`not json`, e)
|
||||||
|
if !hasErr(e) {
|
||||||
|
t.Errorf("invalid JSON: got %v err=%v", v, e.GetError())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("default: []int marshals to JSON array", func(t *testing.T) {
|
||||||
|
v := ObjToSlice([]int{10, 20, 30})
|
||||||
|
if len(v) != 3 {
|
||||||
|
t.Errorf("[]int default: %v", v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("default: marshal fail (channel) returns error", func(t *testing.T) {
|
||||||
|
e := newErr()
|
||||||
|
ch := make(chan int)
|
||||||
|
ObjToSlice(ch, e)
|
||||||
|
if !hasErr(e) {
|
||||||
|
t.Errorf("channel marshal: expected error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// ObjToTime
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
func TestObjToTime(t *testing.T) {
|
||||||
|
mustParse := func(layout, s string) time.Time {
|
||||||
|
tt, err := time.Parse(layout, s)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return tt
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("full datetime string", func(t *testing.T) {
|
||||||
|
ts := ObjToTime("2006-01-02 15:04:05")
|
||||||
|
want := mustParse("2006-01-02 15:04:05", "2006-01-02 15:04:05")
|
||||||
|
if ts == nil || !ts.Equal(want) {
|
||||||
|
t.Errorf("full datetime: %v", ts)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("single digit day+time (bug fix)", func(t *testing.T) {
|
||||||
|
ts := ObjToTime("2006-1-2 15:04:05")
|
||||||
|
want := mustParse("2006-01-02 15:04:05", "2006-01-02 15:04:05")
|
||||||
|
if ts == nil || !ts.Equal(want) {
|
||||||
|
t.Errorf("single digit day+time: got %v, want %v", ts, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("datetime without seconds", func(t *testing.T) {
|
||||||
|
ts := ObjToTime("2006-01-02 15:04")
|
||||||
|
want := mustParse("2006-01-02 15:04", "2006-01-02 15:04")
|
||||||
|
if ts == nil || !ts.Equal(want) {
|
||||||
|
t.Errorf("datetime without seconds: %v", ts)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("date+hour only", func(t *testing.T) {
|
||||||
|
ts := ObjToTime("2006-01-02 15")
|
||||||
|
want := mustParse("2006-01-02 15", "2006-01-02 15")
|
||||||
|
if ts == nil || !ts.Equal(want) {
|
||||||
|
t.Errorf("date+hour: %v", ts)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("date only", func(t *testing.T) {
|
||||||
|
ts := ObjToTime("2006-01-02")
|
||||||
|
want := mustParse("2006-01-02", "2006-01-02")
|
||||||
|
if ts == nil || !ts.Equal(want) {
|
||||||
|
t.Errorf("date only: %v", ts)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("month only", func(t *testing.T) {
|
||||||
|
ts := ObjToTime("2006-01")
|
||||||
|
want := mustParse("2006-01", "2006-01")
|
||||||
|
if ts == nil || !ts.Equal(want) {
|
||||||
|
t.Errorf("month only: %v", ts)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid string returns nil", func(t *testing.T) {
|
||||||
|
ts := ObjToTime("not-a-date")
|
||||||
|
if ts != nil {
|
||||||
|
t.Errorf("invalid string: expected nil, got %v", ts)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unix second timestamp (10 digits)", func(t *testing.T) {
|
||||||
|
ts := ObjToTime(int64(1700000000)) // 10 digits
|
||||||
|
if ts == nil {
|
||||||
|
t.Error("second timestamp: got nil")
|
||||||
|
}
|
||||||
|
want := time.Unix(1700000000, 0)
|
||||||
|
if !ts.Equal(want) {
|
||||||
|
t.Errorf("second: %v vs %v", ts, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unix millisecond timestamp (13 digits)", func(t *testing.T) {
|
||||||
|
ts := ObjToTime(int64(1700000000000)) // 13 digits
|
||||||
|
if ts == nil {
|
||||||
|
t.Error("millisecond timestamp: got nil")
|
||||||
|
}
|
||||||
|
want := time.UnixMilli(1700000000000)
|
||||||
|
if !ts.Equal(want) {
|
||||||
|
t.Errorf("millisecond: %v vs %v", ts, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unix microsecond timestamp (16 digits)", func(t *testing.T) {
|
||||||
|
ts := ObjToTime(int64(1700000000000000)) // 16 digits
|
||||||
|
if ts == nil {
|
||||||
|
t.Error("microsecond timestamp: got nil")
|
||||||
|
}
|
||||||
|
want := time.UnixMicro(1700000000000000)
|
||||||
|
if !ts.Equal(want) {
|
||||||
|
t.Errorf("microsecond: %v vs %v", ts, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nanosecond timestamp (19 digits)", func(t *testing.T) {
|
||||||
|
ts := ObjToTime(int64(1700000000000000000)) // 19 digits -> nano branch
|
||||||
|
if ts == nil {
|
||||||
|
t.Error("nanosecond timestamp: got nil")
|
||||||
|
}
|
||||||
|
want := time.UnixMicro(1700000000000000000 / 1000)
|
||||||
|
if !ts.Equal(want) {
|
||||||
|
t.Errorf("nanosecond: %v vs %v", ts, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("year int (4 digits)", func(t *testing.T) {
|
||||||
|
ts := ObjToTime(int64(2024))
|
||||||
|
if ts == nil {
|
||||||
|
t.Error("year int: got nil")
|
||||||
|
}
|
||||||
|
want := mustParse("2006", "2024")
|
||||||
|
if !ts.Equal(want) {
|
||||||
|
t.Errorf("year: %v vs %v", ts, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("small int returns nil", func(t *testing.T) {
|
||||||
|
ts := ObjToTime(int64(1))
|
||||||
|
if ts != nil {
|
||||||
|
t.Errorf("small int: expected nil, got %v", ts)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nil returns nil", func(t *testing.T) {
|
||||||
|
ts := ObjToTime(nil)
|
||||||
|
if ts != nil {
|
||||||
|
t.Errorf("nil: expected nil, got %v", ts)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// ObjToFloat64
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
func TestObjToFloat64(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
input interface{}
|
||||||
|
want float64
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"nil", nil, 0, true},
|
||||||
|
{"int", 42, 42.0, false},
|
||||||
|
{"int64", int64(100), 100.0, false},
|
||||||
|
{"string valid", "3.14", 3.14, false},
|
||||||
|
{"string invalid", "abc", 0, true},
|
||||||
|
{"float64", float64(1.5), 1.5, false},
|
||||||
|
// Fix4: float32 通过 strconv 转换,消除精度膨胀噪声
|
||||||
|
{"float32 no precision inflation", float32(1.2), 1.2, false},
|
||||||
|
{"[]byte valid", []byte("2.71"), 2.71, false},
|
||||||
|
{"[]byte invalid", []byte("xyz"), 0, true},
|
||||||
|
{"unknown type", struct{}{}, 0, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
e := newErr()
|
||||||
|
got := ObjToFloat64(c.input, e)
|
||||||
|
if math.Abs(got-c.want) > 1e-9 {
|
||||||
|
t.Errorf("value: got %v, want %v", got, c.want)
|
||||||
|
}
|
||||||
|
if hasErr(e) != c.wantErr {
|
||||||
|
t.Errorf("error: got hasErr=%v, wantErr=%v (err=%v)", hasErr(e), c.wantErr, e.GetError())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("NaN is caught and zeroed", func(t *testing.T) {
|
||||||
|
e := newErr()
|
||||||
|
v := ObjToFloat64(math.NaN(), e)
|
||||||
|
if v != 0 || !hasErr(e) {
|
||||||
|
t.Errorf("NaN: got %v err=%v", v, e.GetError())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Inf is caught and zeroed", func(t *testing.T) {
|
||||||
|
e := newErr()
|
||||||
|
v := ObjToFloat64(math.Inf(1), e)
|
||||||
|
if v != 0 || !hasErr(e) {
|
||||||
|
t.Errorf("Inf: got %v err=%v", v, e.GetError())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("-Inf is caught and zeroed", func(t *testing.T) {
|
||||||
|
e := newErr()
|
||||||
|
v := ObjToFloat64(math.Inf(-1), e)
|
||||||
|
if v != 0 || !hasErr(e) {
|
||||||
|
t.Errorf("-Inf: got %v err=%v", v, e.GetError())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// ObjToRoundFloat64
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
func TestObjToRoundFloat64(t *testing.T) {
|
||||||
|
t.Run("precision 2 rounds correctly", func(t *testing.T) {
|
||||||
|
v := ObjToRoundFloat64(3.5000001, 2)
|
||||||
|
if v != 3.5 {
|
||||||
|
t.Errorf("got %v, want 3.5", v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("precision 0 rounds to integer", func(t *testing.T) {
|
||||||
|
v := ObjToRoundFloat64(2.7, 0)
|
||||||
|
if v != 3.0 {
|
||||||
|
t.Errorf("got %v, want 3.0", v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("precision -1 returns raw float", func(t *testing.T) {
|
||||||
|
v := ObjToRoundFloat64(1.23456789, -1)
|
||||||
|
if math.Abs(v-1.23456789) > 1e-9 {
|
||||||
|
t.Errorf("got %v, want ~1.23456789", v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("string input with float noise", func(t *testing.T) {
|
||||||
|
v := ObjToRoundFloat64("3.15000001", 2)
|
||||||
|
if v != 3.15 {
|
||||||
|
t.Errorf("got %v, want 3.15", v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// ObjToCeil*
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
func TestObjToCeil(t *testing.T) {
|
||||||
|
t.Run("ObjToCeilInt64 rounds up", func(t *testing.T) {
|
||||||
|
v := ObjToCeilInt64(1.1)
|
||||||
|
if v != 2 {
|
||||||
|
t.Errorf("got %d, want 2", v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ObjToCeilInt64 already integer", func(t *testing.T) {
|
||||||
|
v := ObjToCeilInt64(float64(3))
|
||||||
|
if v != 3 {
|
||||||
|
t.Errorf("got %d, want 3", v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ObjToCeilInt64 no double ceil (bug fix)", func(t *testing.T) {
|
||||||
|
// 3.0 -> Ceil(3.0)=3.0 -> int64(3), NOT Ceil(3.0) again
|
||||||
|
v := ObjToCeilInt64(float64(3))
|
||||||
|
if v != 3 {
|
||||||
|
t.Errorf("double ceil: got %d, want 3", v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ObjToCeilFloat64", func(t *testing.T) {
|
||||||
|
v := ObjToCeilFloat64(1.1)
|
||||||
|
if v != 2.0 {
|
||||||
|
t.Errorf("got %v, want 2.0", v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ObjToCeilInt", func(t *testing.T) {
|
||||||
|
v := ObjToCeilInt(2.3)
|
||||||
|
if v != 3 {
|
||||||
|
t.Errorf("got %d, want 3", v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// ObjToInt64
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
func TestObjToInt64(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
input interface{}
|
||||||
|
want int64
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"nil", nil, 0, true},
|
||||||
|
{"int", 7, 7, false},
|
||||||
|
{"int64", int64(99), 99, false},
|
||||||
|
{"string valid int", "42", 42, false},
|
||||||
|
// Fix3 fallback: "1.20" 原来报错,现在截断得 1
|
||||||
|
{"string float-like", "1.20", 1, false},
|
||||||
|
{"string invalid", "abc", 0, true},
|
||||||
|
{"[]byte valid int", []byte("123"), 123, false},
|
||||||
|
// Fix3 fallback: []byte "2.70" 截断得 2
|
||||||
|
{"[]byte float-like", []byte("2.70"), 2, false},
|
||||||
|
{"[]byte invalid", []byte("xyz"), 0, true},
|
||||||
|
{"float64 truncate", float64(5.9), 5, false},
|
||||||
|
{"float32 truncate", float32(3.1), 3, false},
|
||||||
|
{"unknown type", struct{}{}, 0, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
e := newErr()
|
||||||
|
got := ObjToInt64(c.input, e)
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("value: got %d, want %d", got, c.want)
|
||||||
|
}
|
||||||
|
if hasErr(e) != c.wantErr {
|
||||||
|
t.Errorf("error: got hasErr=%v, wantErr=%v (err=%v)", hasErr(e), c.wantErr, e.GetError())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// ObjToInt
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
func TestObjToInt(t *testing.T) {
|
||||||
|
v := ObjToInt(10)
|
||||||
|
if v != 10 {
|
||||||
|
t.Errorf("got %d, want 10", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// ObjToBool
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
func TestObjToBool(t *testing.T) {
|
||||||
|
t.Run("nil returns false with error", func(t *testing.T) {
|
||||||
|
e := newErr()
|
||||||
|
v := ObjToBool(nil, e)
|
||||||
|
if v || !hasErr(e) {
|
||||||
|
t.Errorf("nil: got %v err=%v", v, e.GetError())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("bool true", func(t *testing.T) {
|
||||||
|
e := newErr()
|
||||||
|
v := ObjToBool(true, e)
|
||||||
|
if !v || hasErr(e) {
|
||||||
|
t.Errorf("bool true: got %v err=%v", v, e.GetError())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("bool false", func(t *testing.T) {
|
||||||
|
e := newErr()
|
||||||
|
v := ObjToBool(false, e)
|
||||||
|
if v || hasErr(e) {
|
||||||
|
t.Errorf("bool false: got %v err=%v", v, e.GetError())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("int 1 -> true, no error (bug fix)", func(t *testing.T) {
|
||||||
|
e := newErr()
|
||||||
|
v := ObjToBool(1, e)
|
||||||
|
if !v || hasErr(e) {
|
||||||
|
t.Errorf("int 1: got v=%v err=%v", v, e.GetError())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("int 0 -> false, no error", func(t *testing.T) {
|
||||||
|
e := newErr()
|
||||||
|
v := ObjToBool(0, e)
|
||||||
|
if v || hasErr(e) {
|
||||||
|
t.Errorf("int 0: got v=%v err=%v", v, e.GetError())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unknown type returns false with error", func(t *testing.T) {
|
||||||
|
e := newErr()
|
||||||
|
v := ObjToBool(struct{}{}, e)
|
||||||
|
if v || !hasErr(e) {
|
||||||
|
t.Errorf("struct: got v=%v err=%v", v, e.GetError())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// ObjToStr
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
func TestObjToStr(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
input interface{}
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"nil", nil, ""},
|
||||||
|
{"int", 42, "42"},
|
||||||
|
{"uint8", uint8(65), "65"},
|
||||||
|
{"int64", int64(1234567890), "1234567890"},
|
||||||
|
{"[]byte", []byte("hello"), "hello"},
|
||||||
|
{"string", "world", "world"},
|
||||||
|
{"float64", float64(3.14), "3.14"},
|
||||||
|
{"float32", float32(2.5), "2.5"},
|
||||||
|
{"bool true", true, "true"},
|
||||||
|
{"bool false", false, "false"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
got := ObjToStr(c.input)
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("got %q, want %q", got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("default (struct) marshals to JSON", func(t *testing.T) {
|
||||||
|
type S struct{ X int }
|
||||||
|
got := ObjToStr(S{X: 1})
|
||||||
|
if got == "" {
|
||||||
|
t.Error("struct: expected non-empty JSON string")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// StrToMap
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
func TestStrToMap(t *testing.T) {
|
||||||
|
t.Run("valid JSON", func(t *testing.T) {
|
||||||
|
v := StrToMap(`{"k":"v"}`)
|
||||||
|
if v["k"] != "v" {
|
||||||
|
t.Errorf("StrToMap: %v", v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid JSON returns empty map", func(t *testing.T) {
|
||||||
|
v := StrToMap(`not json`)
|
||||||
|
if v == nil {
|
||||||
|
t.Error("StrToMap invalid: expected empty map, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// StrToSlice
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
func TestStrToSlice(t *testing.T) {
|
||||||
|
v := StrToSlice(`[1,2,3]`)
|
||||||
|
if len(v) != 3 {
|
||||||
|
t.Errorf("StrToSlice: expected 3 elements, got %d", len(v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// StrArrayToJsonStr
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
func TestStrArrayToJsonStr(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"empty returns []", "", "[]"},
|
||||||
|
{"short (<=2) returns []", "a", "[]"},
|
||||||
|
{"no commas", `"a","b"`, `["a","b"]`},
|
||||||
|
{"leading comma stripped", `,"a","b"`, `["a","b"]`},
|
||||||
|
{"trailing comma stripped", `"a","b",`, `["a","b"]`},
|
||||||
|
{"both commas stripped", `,"a","b",`, `["a","b"]`},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
got := StrArrayToJsonStr(c.input)
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("got %q, want %q", got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// JsonStrToStrArray
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
func TestJsonStrToStrArray(t *testing.T) {
|
||||||
|
t.Run("non-empty strips brackets", func(t *testing.T) {
|
||||||
|
got := JsonStrToStrArray(`["a","b"]`)
|
||||||
|
if got != `,"a","b",` {
|
||||||
|
t.Errorf("got %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty string", func(t *testing.T) {
|
||||||
|
got := JsonStrToStrArray("")
|
||||||
|
if got != ",," {
|
||||||
|
t.Errorf("got %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// StrToInt
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
func TestStrToInt(t *testing.T) {
|
||||||
|
t.Run("valid", func(t *testing.T) {
|
||||||
|
v, err := StrToInt("123")
|
||||||
|
if v != 123 || err != nil {
|
||||||
|
t.Errorf("got %d %v", v, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid", func(t *testing.T) {
|
||||||
|
_, err := StrToInt("abc")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// JsonToObj
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
func TestJsonToObj(t *testing.T) {
|
||||||
|
t.Run("integer preserved as int64", func(t *testing.T) {
|
||||||
|
v, err := JsonToObj(`{"id":2}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
m, ok := v.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected map, got %T", v)
|
||||||
|
}
|
||||||
|
if m["id"] != int64(2) {
|
||||||
|
t.Errorf("got %v (type %T), want int64(2)", m["id"], m["id"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("decimal preserved as float64 with correct precision", func(t *testing.T) {
|
||||||
|
v, err := JsonToObj(`{"price":1.20}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
m := v.(map[string]interface{})
|
||||||
|
price, ok := m["price"].(float64)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected float64, got %T", m["price"])
|
||||||
|
}
|
||||||
|
if price != 1.2 {
|
||||||
|
t.Errorf("got %v, want 1.2", price)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("large integer preserved as int64", func(t *testing.T) {
|
||||||
|
v, err := JsonToObj(`{"num":9999999}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
m := v.(map[string]interface{})
|
||||||
|
if m["num"] != int64(9999999) {
|
||||||
|
t.Errorf("got %v (type %T), want int64(9999999)", m["num"], m["num"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("array with mixed types", func(t *testing.T) {
|
||||||
|
v, err := JsonToObj(`[1, 2.5, "hello", true]`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
arr := v.([]interface{})
|
||||||
|
if len(arr) != 4 {
|
||||||
|
t.Fatalf("expected 4 elements, got %d", len(arr))
|
||||||
|
}
|
||||||
|
if arr[0] != int64(1) {
|
||||||
|
t.Errorf("arr[0]: got %v (%T), want int64(1)", arr[0], arr[0])
|
||||||
|
}
|
||||||
|
if arr[1] != float64(2.5) {
|
||||||
|
t.Errorf("arr[1]: got %v (%T), want float64(2.5)", arr[1], arr[1])
|
||||||
|
}
|
||||||
|
if arr[2] != "hello" {
|
||||||
|
t.Errorf("arr[2]: got %v, want hello", arr[2])
|
||||||
|
}
|
||||||
|
if arr[3] != true {
|
||||||
|
t.Errorf("arr[3]: got %v, want true", arr[3])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nested object integers preserved", func(t *testing.T) {
|
||||||
|
v, err := JsonToObj(`{"order":{"id":5,"num":3,"price":1.20}}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
m := v.(map[string]interface{})
|
||||||
|
order := m["order"].(map[string]interface{})
|
||||||
|
if order["id"] != int64(5) {
|
||||||
|
t.Errorf("nested id: got %v (%T), want int64(5)", order["id"], order["id"])
|
||||||
|
}
|
||||||
|
if order["num"] != int64(3) {
|
||||||
|
t.Errorf("nested num: got %v (%T), want int64(3)", order["num"], order["num"])
|
||||||
|
}
|
||||||
|
if order["price"] != float64(1.2) {
|
||||||
|
t.Errorf("nested price: got %v, want 1.2", order["price"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid JSON returns error", func(t *testing.T) {
|
||||||
|
_, err := JsonToObj(`not json`)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for invalid JSON")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty object", func(t *testing.T) {
|
||||||
|
v, err := JsonToObj(`{}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
m, ok := v.(map[string]interface{})
|
||||||
|
if !ok || len(m) != 0 {
|
||||||
|
t.Errorf("expected empty map, got %v", v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("null value", func(t *testing.T) {
|
||||||
|
v, err := JsonToObj(`{"x":null}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
m := v.(map[string]interface{})
|
||||||
|
if m["x"] != nil {
|
||||||
|
t.Errorf("expected nil, got %v", m["x"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("zero integer", func(t *testing.T) {
|
||||||
|
v, err := JsonToObj(`{"count":0}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
m := v.(map[string]interface{})
|
||||||
|
if m["count"] != int64(0) {
|
||||||
|
t.Errorf("got %v (%T), want int64(0)", m["count"], m["count"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// ObjToMap 数字类型保留(新行为)
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
func TestObjToMapNumberPreservation(t *testing.T) {
|
||||||
|
t.Run("JSON integer -> int64", func(t *testing.T) {
|
||||||
|
v := ObjToMap(`{"id":2,"num":0}`)
|
||||||
|
if v["id"] != int64(2) {
|
||||||
|
t.Errorf("id: got %v (%T), want int64(2)", v["id"], v["id"])
|
||||||
|
}
|
||||||
|
if v["num"] != int64(0) {
|
||||||
|
t.Errorf("num: got %v (%T), want int64(0)", v["num"], v["num"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("JSON decimal -> float64", func(t *testing.T) {
|
||||||
|
v := ObjToMap(`{"price":1.20,"rate":0.5}`)
|
||||||
|
if v["price"] != float64(1.2) {
|
||||||
|
t.Errorf("price: got %v (%T), want float64(1.2)", v["price"], v["price"])
|
||||||
|
}
|
||||||
|
if v["rate"] != float64(0.5) {
|
||||||
|
t.Errorf("rate: got %v (%T), want float64(0.5)", v["rate"], v["rate"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("struct with int field -> int64", func(t *testing.T) {
|
||||||
|
type S struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
v := ObjToMap(S{Count: 3, Name: "test"})
|
||||||
|
if v == nil {
|
||||||
|
t.Fatal("got nil")
|
||||||
|
}
|
||||||
|
if v["count"] != int64(3) {
|
||||||
|
t.Errorf("count: got %v (%T), want int64(3)", v["count"], v["count"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// ObjToSlice 数字类型保留(新行为)
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
func TestObjToSliceNumberPreservation(t *testing.T) {
|
||||||
|
t.Run("JSON integer array -> int64 elements", func(t *testing.T) {
|
||||||
|
v := ObjToSlice(`[1, 2, 3]`)
|
||||||
|
if len(v) != 3 {
|
||||||
|
t.Fatalf("expected 3, got %d", len(v))
|
||||||
|
}
|
||||||
|
if v[0] != int64(1) {
|
||||||
|
t.Errorf("v[0]: got %v (%T), want int64(1)", v[0], v[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("JSON mixed array - integer and float", func(t *testing.T) {
|
||||||
|
v := ObjToSlice(`[2, 1.5, "hello"]`)
|
||||||
|
if v[0] != int64(2) {
|
||||||
|
t.Errorf("v[0]: got %v (%T), want int64(2)", v[0], v[0])
|
||||||
|
}
|
||||||
|
if v[1] != float64(1.5) {
|
||||||
|
t.Errorf("v[1]: got %v (%T), want float64(1.5)", v[1], v[1])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Map.JsonToMap 数字类型保留(新行为)
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
func TestMapJsonToMapNumberPreservation(t *testing.T) {
|
||||||
|
t.Run("integer -> int64", func(t *testing.T) {
|
||||||
|
m := Map{}
|
||||||
|
m.JsonToMap(`{"id":5,"count":0}`)
|
||||||
|
if m["id"] != int64(5) {
|
||||||
|
t.Errorf("id: got %v (%T), want int64(5)", m["id"], m["id"])
|
||||||
|
}
|
||||||
|
if m["count"] != int64(0) {
|
||||||
|
t.Errorf("count: got %v (%T), want int64(0)", m["count"], m["count"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("decimal -> float64", func(t *testing.T) {
|
||||||
|
m := Map{}
|
||||||
|
m.JsonToMap(`{"price":1.20}`)
|
||||||
|
if m["price"] != float64(1.2) {
|
||||||
|
t.Errorf("price: got %v (%T), want float64(1.2)", m["price"], m["price"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("StrToMap integer -> int64", func(t *testing.T) {
|
||||||
|
m := StrToMap(`{"total":100,"amount":1.50}`)
|
||||||
|
if m["total"] != int64(100) {
|
||||||
|
t.Errorf("total: got %v (%T), want int64(100)", m["total"], m["total"])
|
||||||
|
}
|
||||||
|
if m["amount"] != float64(1.5) {
|
||||||
|
t.Errorf("amount: got %v (%T), want float64(1.5)", m["amount"], m["amount"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid JSON sets error", func(t *testing.T) {
|
||||||
|
e := newErr()
|
||||||
|
m := Map{}
|
||||||
|
m.JsonToMap(`not json`, e)
|
||||||
|
if !hasErr(e) {
|
||||||
|
t.Error("expected error for invalid JSON")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -63,6 +63,11 @@ func (that Slice) GetFloat64(key int, err ...*Error) float64 {
|
|||||||
return v
|
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 {
|
func (that Slice) GetSlice(key int, err ...*Error) Slice {
|
||||||
v := ObjToSlice((that)[key], err...)
|
v := ObjToSlice((that)[key], err...)
|
||||||
return v
|
return v
|
||||||
|
|||||||
+123
-5
@@ -1,11 +1,17 @@
|
|||||||
package hotime
|
package hotime
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
. "code.hoteas.com/golang/hotime/common"
|
. "code.hoteas.com/golang/hotime/common"
|
||||||
. "code.hoteas.com/golang/hotime/db"
|
. "code.hoteas.com/golang/hotime/db"
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Context struct {
|
type Context struct {
|
||||||
@@ -22,6 +28,10 @@ type Context struct {
|
|||||||
SessionIns
|
SessionIns
|
||||||
DataSize int
|
DataSize int
|
||||||
HandlerStr string //复写请求url
|
HandlerStr string //复写请求url
|
||||||
|
|
||||||
|
// 请求参数缓存
|
||||||
|
reqJsonCache Map // JSON Body 缓存
|
||||||
|
reqMu sync.Once // 确保 JSON 只解析一次
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mtd 唯一标志
|
// Mtd 唯一标志
|
||||||
@@ -32,7 +42,7 @@ func (that *Context) Mtd(router [3]string) Map {
|
|||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
//打印
|
// 打印
|
||||||
func (that *Context) Display(statu int, data interface{}) {
|
func (that *Context) Display(statu int, data interface{}) {
|
||||||
|
|
||||||
resp := Map{"status": statu}
|
resp := Map{"status": statu}
|
||||||
@@ -49,6 +59,9 @@ func (that *Context) Display(statu int, data interface{}) {
|
|||||||
resp["result"] = temp
|
resp["result"] = temp
|
||||||
//兼容android等需要json转对象的服务
|
//兼容android等需要json转对象的服务
|
||||||
resp["error"] = temp
|
resp["error"] = temp
|
||||||
|
|
||||||
|
that.Application.Log.Warn().Int("status", statu).Msg(resp.ToJsonString())
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
resp["result"] = data
|
resp["result"] = data
|
||||||
}
|
}
|
||||||
@@ -74,6 +87,19 @@ func (that *Context) View() {
|
|||||||
if that.Session("user_id").Data != nil {
|
if that.Session("user_id").Data != nil {
|
||||||
that.Log["user_id"] = that.Session("user_id").ToCeilInt()
|
that.Log["user_id"] = that.Session("user_id").ToCeilInt()
|
||||||
}
|
}
|
||||||
|
//负载均衡优化
|
||||||
|
ipStr := ""
|
||||||
|
if that.Req.Header.Get("X-Forwarded-For") != "" {
|
||||||
|
ipStr = that.Req.Header.Get("X-Forwarded-For")
|
||||||
|
} else if that.Req.Header.Get("X-Real-IP") != "" {
|
||||||
|
ipStr = that.Req.Header.Get("X-Real-IP")
|
||||||
|
}
|
||||||
|
//负载均衡优化
|
||||||
|
if ipStr == "" {
|
||||||
|
//RemoteAddr := that.Req.RemoteAddr
|
||||||
|
ipStr = Substr(that.Req.RemoteAddr, 0, strings.Index(that.Req.RemoteAddr, ":"))
|
||||||
|
}
|
||||||
|
that.Log["ip"] = ipStr
|
||||||
that.Db.Insert("logs", that.Log)
|
that.Db.Insert("logs", that.Log)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,5 +112,97 @@ func (that *Context) View() {
|
|||||||
that.DataSize = len(d)
|
that.DataSize = len(d)
|
||||||
that.RespData = nil
|
that.RespData = nil
|
||||||
that.Resp.Write(d)
|
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"
|
||||||
|
}
|
||||||
-1287
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,757 @@
|
|||||||
|
# 代码生成配置规范
|
||||||
|
|
||||||
|
本文档详细说明 HoTime 框架代码生成器的配置体系,包括 `config.json` 中的 `codeConfig`、菜单权限配置和字段规则配置。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 配置体系概览
|
||||||
|
|
||||||
|
```
|
||||||
|
config.json
|
||||||
|
└── codeConfig[] # 代码生成配置数组(支持多套)
|
||||||
|
├── config # 菜单权限配置文件路径(如 admin.json)
|
||||||
|
├── configDB # 数据库生成的完整配置
|
||||||
|
├── rule # 字段规则配置文件路径(如 rule.json)
|
||||||
|
├── table # 管理员表名
|
||||||
|
├── name # 生成代码的包名
|
||||||
|
└── mode # 生成模式
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、config.json 中的 codeConfig
|
||||||
|
|
||||||
|
### 配置结构
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"codeConfig": [
|
||||||
|
{
|
||||||
|
"config": "config/admin.json",
|
||||||
|
"configDB": "config/adminDB.json",
|
||||||
|
"mode": 0,
|
||||||
|
"name": "",
|
||||||
|
"rule": "config/rule.json",
|
||||||
|
"table": "admin"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 配置项说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| config | string | 菜单权限配置文件路径,用于定义菜单结构、权限控制 |
|
||||||
|
| configDB | string | 代码生成器输出的完整配置文件(自动生成) |
|
||||||
|
| rule | string | 字段规则配置文件路径,定义字段在增删改查中的行为 |
|
||||||
|
| table | string | 管理员/用户表名,用于身份验证和权限控制 |
|
||||||
|
| name | string | 生成的代码包名,为空则不生成代码文件 |
|
||||||
|
| mode | int | 生成模式:0-仅配置不生成代码,非0-生成代码文件 |
|
||||||
|
|
||||||
|
### 多配置支持
|
||||||
|
|
||||||
|
可以配置多个独立的代码生成实例,适用于多端场景:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"codeConfig": [
|
||||||
|
{
|
||||||
|
"config": "config/admin.json",
|
||||||
|
"table": "admin",
|
||||||
|
"rule": "config/rule.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"config": "config/user.json",
|
||||||
|
"table": "user",
|
||||||
|
"rule": "config/rule.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、菜单权限配置(如 admin.json)
|
||||||
|
|
||||||
|
### 配置文件更新机制
|
||||||
|
|
||||||
|
- **首次运行**:代码生成器会根据数据库表结构自动创建配置文件(如 admin.json)
|
||||||
|
- **后续运行**:配置文件**不会自动更新**,避免覆盖手动修改的内容
|
||||||
|
- **重新生成**:如需重新生成,删除配置文件后重新运行即可
|
||||||
|
- **参考更新**:可参考 `configDB` 指定的文件(如 adminDB.json)查看最新的数据库结构变化,手动调整配置
|
||||||
|
|
||||||
|
### 完整配置结构
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "唯一标识(自动生成)",
|
||||||
|
"name": "admin",
|
||||||
|
"label": "管理平台名称",
|
||||||
|
"labelConfig": { ... },
|
||||||
|
"menus": [ ... ],
|
||||||
|
"flow": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.1 label 配置
|
||||||
|
|
||||||
|
定义系统显示名称:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"label": "HoTime管理平台"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 labelConfig 配置
|
||||||
|
|
||||||
|
定义权限的显示文字:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"labelConfig": {
|
||||||
|
"show": "开启",
|
||||||
|
"add": "添加",
|
||||||
|
"delete": "删除",
|
||||||
|
"edit": "编辑",
|
||||||
|
"info": "查看详情",
|
||||||
|
"download": "下载清单"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 操作 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| show | 显示/查看列表权限 |
|
||||||
|
| add | 添加数据权限 |
|
||||||
|
| delete | 删除数据权限 |
|
||||||
|
| edit | 编辑数据权限 |
|
||||||
|
| info | 查看详情权限 |
|
||||||
|
| download | 下载/导出权限 |
|
||||||
|
|
||||||
|
### 2.3 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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### menus 字段说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| label | string | 菜单显示名称 |
|
||||||
|
| name | string | 菜单标识(用于分组,不绑定表时使用) |
|
||||||
|
| table | string | 绑定的数据表名(用于自动生成 CRUD) |
|
||||||
|
| icon | string | 菜单图标名称 |
|
||||||
|
| 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)使用该分组下**第一张表的名字**,如果表有备注则使用备注名。
|
||||||
|
|
||||||
|
### 2.4 auth 配置
|
||||||
|
|
||||||
|
权限数组定义菜单/表拥有的操作权限:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"auth": ["show", "add", "delete", "edit", "info", "download"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 内置权限
|
||||||
|
|
||||||
|
| 权限 | 对应接口 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 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`(置顶)为自定义权限,前端可根据这些权限控制对应按钮的显示和操作。
|
||||||
|
|
||||||
|
### 2.5 icon 配置
|
||||||
|
|
||||||
|
菜单图标,使用 Element Plus 图标名称:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "icon": "Setting" } // 设置图标
|
||||||
|
{ "icon": "User" } // 用户图标
|
||||||
|
{ "icon": "Document" } // 文档图标
|
||||||
|
{ "icon": "Folder" } // 文件夹图标
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.6 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,"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### flow 字段说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| table | string | 表名 |
|
||||||
|
| stop | bool | 是否禁止修改自身关联的数据 |
|
||||||
|
| sql | object | 数据过滤条件,自动填充查询/操作条件 |
|
||||||
|
|
||||||
|
#### stop 配置详解
|
||||||
|
|
||||||
|
`stop` 用于防止用户修改自己当前关联的敏感数据。
|
||||||
|
|
||||||
|
**场景示例**:当前登录用户是 admin 表的用户,其 `role_id = 1`
|
||||||
|
|
||||||
|
```json
|
||||||
|
"role": {
|
||||||
|
"table": "role",
|
||||||
|
"stop": true,
|
||||||
|
"sql": { "id": "role_id" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**效果**:
|
||||||
|
- 用户**不能修改** role 表中 `id = 1` 的这行数据(自己的角色)
|
||||||
|
- 用户**可以修改**其他 role 记录(如果有权限的话)
|
||||||
|
|
||||||
|
**典型用途**:
|
||||||
|
- 防止用户提升自己的角色权限
|
||||||
|
- 防止用户修改自己所属的组织
|
||||||
|
|
||||||
|
#### sql 配置详解
|
||||||
|
|
||||||
|
`sql` 用于自动填充数据过滤条件,实现数据隔离。
|
||||||
|
|
||||||
|
**格式**:`{ "目标表字段": "当前用户字段" }`
|
||||||
|
|
||||||
|
**示例 1:精确匹配**
|
||||||
|
|
||||||
|
```json
|
||||||
|
"article": {
|
||||||
|
"sql": { "admin_id": "id" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**效果**:查询/操作 article 表时,自动添加条件 `WHERE admin_id = 当前用户.id`
|
||||||
|
|
||||||
|
即:用户只能看到/操作自己创建的文章。
|
||||||
|
|
||||||
|
**示例 2:角色关联**
|
||||||
|
|
||||||
|
```json
|
||||||
|
"role": {
|
||||||
|
"sql": { "id": "role_id" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**效果**:查询/操作 role 表时,自动添加条件 `WHERE id = 当前用户.role_id`
|
||||||
|
|
||||||
|
即:用户只能看到自己的角色。
|
||||||
|
|
||||||
|
**示例 3:树形结构(模糊匹配)**
|
||||||
|
|
||||||
|
```json
|
||||||
|
"org": {
|
||||||
|
"sql": { "parent_ids[~]": ",org_id," }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**效果**:查询/操作 org 表时,自动添加条件 `WHERE parent_ids LIKE '%,用户.org_id,%'`
|
||||||
|
|
||||||
|
即:用户只能看到自己组织及其下级组织。
|
||||||
|
|
||||||
|
#### sql 条件语法
|
||||||
|
|
||||||
|
| 格式 | 含义 | SQL 等价 |
|
||||||
|
|------|------|----------|
|
||||||
|
| `"field": "user_field"` | 精确匹配 | `field = 用户.user_field` |
|
||||||
|
| `"field[~]": ",value,"` | 模糊匹配 | `field LIKE '%,value,%'` |
|
||||||
|
|
||||||
|
#### 完整示例
|
||||||
|
|
||||||
|
假设当前登录用户数据:
|
||||||
|
```json
|
||||||
|
{ "id": 5, "role_id": 2, "org_id": 10 }
|
||||||
|
```
|
||||||
|
|
||||||
|
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," }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**效果**:
|
||||||
|
|
||||||
|
| 表 | 查询条件 | stop 效果 |
|
||||||
|
|-----|----------|-----------|
|
||||||
|
| role | `WHERE id = 2` | 不能修改 id=2 的角色 |
|
||||||
|
| article | `WHERE admin_id = 5` | 可以修改自己的文章 |
|
||||||
|
| org | `WHERE parent_ids LIKE '%,10,%'` | 可以修改下级组织 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、字段规则配置(rule.json)
|
||||||
|
|
||||||
|
定义字段在增删改查操作中的默认行为。
|
||||||
|
|
||||||
|
### 配置结构
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"add": false,
|
||||||
|
"edit": false,
|
||||||
|
"info": true,
|
||||||
|
"list": true,
|
||||||
|
"must": false,
|
||||||
|
"strict": true,
|
||||||
|
"type": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 字段属性说明
|
||||||
|
|
||||||
|
| 属性 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| name | string | 字段名或字段名包含的关键词 |
|
||||||
|
| add | bool | 新增时是否显示该字段 |
|
||||||
|
| edit | bool | 编辑时是否显示该字段 |
|
||||||
|
| info | bool | 详情页是否显示该字段 |
|
||||||
|
| list | bool | 列表页是否显示该字段 |
|
||||||
|
| must | bool | 是否必填(详见下方说明) |
|
||||||
|
| strict | bool | 是否严格匹配字段名(true=完全匹配,false=包含匹配) |
|
||||||
|
| type | string | 字段类型(影响前端控件和数据处理) |
|
||||||
|
|
||||||
|
### must 必填字段规则
|
||||||
|
|
||||||
|
`must` 字段用于控制前端表单的必填验证:
|
||||||
|
|
||||||
|
**自动识别规则**:
|
||||||
|
1. **MySQL**:如果字段设置为 `NOT NULL`(即 `IS_NULLABLE='NO'`),自动设为 `must=true`
|
||||||
|
2. **SQLite**:如果字段是主键(`pk=1`),自动设为 `must=true`
|
||||||
|
|
||||||
|
**规则配置覆盖**:
|
||||||
|
- `rule.json` 中的 `must` 设置会覆盖数据库的自动识别结果
|
||||||
|
- 可以将数据库中 NOT NULL 的字段在规则中设为 `must=false`,反之亦然
|
||||||
|
|
||||||
|
**前端效果**:
|
||||||
|
- `must=true` 的字段在新增/编辑表单中显示必填标记(*)
|
||||||
|
- 提交时前端会验证必填字段
|
||||||
|
|
||||||
|
**后端验证**:
|
||||||
|
- 新增操作时,如果 `must=true` 的字段为空,返回"请求参数不足"
|
||||||
|
|
||||||
|
### type 类型说明
|
||||||
|
|
||||||
|
| 类型 | 说明 | 前端控件 |
|
||||||
|
|------|------|----------|
|
||||||
|
| (空) | 普通文本 | 文本输入框 |
|
||||||
|
| text | 文本 | 文本输入框 |
|
||||||
|
| number | 数字 | 数字输入框 |
|
||||||
|
| select | 选择 | 下拉选择框(根据注释自动生成选项) |
|
||||||
|
| time | 时间(datetime) | 日期时间选择器 |
|
||||||
|
| unixTime | 时间戳 | 日期时间选择器(存储为 Unix 时间戳) |
|
||||||
|
| password | 密码 | 密码输入框(自动 MD5 加密) |
|
||||||
|
| textArea | 多行文本 | 文本域 |
|
||||||
|
| image | 图片 | 图片上传 |
|
||||||
|
| file | 文件 | 文件上传 |
|
||||||
|
| money | 金额 | 金额输入框 |
|
||||||
|
| auth | 权限 | 权限树选择器 |
|
||||||
|
| form | 表单 | 动态表单 |
|
||||||
|
| index | 索引 | 隐藏字段(用于 parent_ids 等) |
|
||||||
|
| 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"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 自定义字段规则
|
||||||
|
|
||||||
|
在 `rule.json` 中添加项目特定的字段规则:
|
||||||
|
|
||||||
|
```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": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、配置示例
|
||||||
|
|
||||||
|
### 完整的 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、SQLite 备注替代方案
|
||||||
|
|
||||||
|
SQLite 数据库不支持表备注(TABLE COMMENT)和字段备注(COLUMN COMMENT),代码生成器会使用表名/字段名作为默认显示名称。
|
||||||
|
|
||||||
|
### 通过配置文件设置备注
|
||||||
|
|
||||||
|
利用 HoTime 的配置覆盖机制,可以在配置文件中手动设置显示名称和提示:
|
||||||
|
|
||||||
|
**步骤**:
|
||||||
|
1. 首次运行,生成配置文件(如 admin.json)
|
||||||
|
2. 编辑配置文件中的 `tables` 部分
|
||||||
|
|
||||||
|
### 设置表显示名称
|
||||||
|
|
||||||
|
在菜单配置中设置 `label`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"menus": [
|
||||||
|
{
|
||||||
|
"label": "用户管理", // 手动设置表显示名称
|
||||||
|
"table": "user",
|
||||||
|
"auth": ["show", "add", "edit", "delete"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 设置字段显示名称和提示
|
||||||
|
|
||||||
|
在 `configDB` 文件(如 adminDB.json)中的 `tables.表名.columns` 部分设置:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tables": {
|
||||||
|
"user": {
|
||||||
|
"label": "用户管理",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"label": "ID",
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"label": "用户名",
|
||||||
|
"ps": "请输入用户名", // 前端输入提示
|
||||||
|
"must": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "phone",
|
||||||
|
"label": "手机号",
|
||||||
|
"ps": "请输入11位手机号"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "status",
|
||||||
|
"label": "状态",
|
||||||
|
"type": "select",
|
||||||
|
"options": [
|
||||||
|
{"name": "正常", "value": "0"},
|
||||||
|
{"name": "禁用", "value": "1"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**:直接编辑的是 `configDB` 指定的文件(如 adminDB.json),该文件会在每次启动时重新生成。如需持久化修改,应将自定义的 columns 配置放入 `config` 指定的文件(如 admin.json)中。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、配置检查清单
|
||||||
|
|
||||||
|
### codeConfig 检查
|
||||||
|
|
||||||
|
- [ ] config 文件路径正确
|
||||||
|
- [ ] rule 文件路径正确
|
||||||
|
- [ ] table 指定的管理员表存在
|
||||||
|
|
||||||
|
### 菜单权限配置检查
|
||||||
|
|
||||||
|
- [ ] 所有 table 指向的表在数据库中存在
|
||||||
|
- [ ] auth 数组包含需要的权限
|
||||||
|
- [ ] menus 结构正确(有子菜单用 name,无子菜单用 table)
|
||||||
|
- [ ] flow 配置的 sql 条件字段存在
|
||||||
|
|
||||||
|
### 字段规则配置检查
|
||||||
|
|
||||||
|
- [ ] strict=true 的规则字段名完全匹配
|
||||||
|
- [ ] type 类型与前端控件需求一致
|
||||||
|
- [ ] 敏感字段(password等)的 list 和 info 为 false
|
||||||
@@ -0,0 +1,492 @@
|
|||||||
|
# HoTime 代码生成器使用说明
|
||||||
|
|
||||||
|
`code` 包提供了 HoTime 框架的自动代码生成功能,能够根据数据库表结构自动生成 CRUD 接口代码和配置文件。
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- [功能概述](#功能概述)
|
||||||
|
- [配置说明](#配置说明)
|
||||||
|
- [使用方法](#使用方法)
|
||||||
|
- [生成规则](#生成规则)
|
||||||
|
- [自定义规则](#自定义规则)
|
||||||
|
- [生成的代码结构](#生成的代码结构)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 功能概述
|
||||||
|
|
||||||
|
代码生成器可以:
|
||||||
|
|
||||||
|
1. **自动读取数据库表结构** - 支持 MySQL、SQLite 和达梦 DM8
|
||||||
|
2. **生成 CRUD 接口** - 增删改查、搜索、分页
|
||||||
|
3. **生成配置文件** - 表字段配置、菜单配置、权限配置
|
||||||
|
4. **智能字段识别** - 根据字段名自动识别类型和权限
|
||||||
|
5. **支持表关联** - 自动识别外键关系
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 配置说明
|
||||||
|
|
||||||
|
在 `config.json` 中配置代码生成:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"codeConfig": [
|
||||||
|
{
|
||||||
|
"table": "admin",
|
||||||
|
"config": "config/admin.json",
|
||||||
|
"configDB": "config/adminDB.json",
|
||||||
|
"rule": "config/rule.json",
|
||||||
|
"name": "",
|
||||||
|
"mode": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 配置项说明
|
||||||
|
|
||||||
|
| 配置项 | 必须 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `table` | ✅ | 用户表名,用于权限控制的基准表 |
|
||||||
|
| `config` | ✅ | 接口描述配置文件路径 |
|
||||||
|
| `configDB` | ❌ | 数据库结构配置输出路径,有则每次自动生成 |
|
||||||
|
| `rule` | ❌ | 字段规则配置文件,无则使用默认规则 |
|
||||||
|
| `name` | ❌ | 生成代码的包名和目录名,空则使用内嵌模式 |
|
||||||
|
| `mode` | ❌ | 0=内嵌代码模式,1=生成代码模式 |
|
||||||
|
|
||||||
|
### 运行模式
|
||||||
|
|
||||||
|
- **mode=0(内嵌模式)**:不生成独立代码文件,使用框架内置的通用控制器
|
||||||
|
- **mode=1(生成模式)**:为每张表生成独立的 Go 控制器文件
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
### 1. 基础配置
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mode": 2,
|
||||||
|
"codeConfig": [
|
||||||
|
{
|
||||||
|
"table": "admin",
|
||||||
|
"config": "config/admin.json",
|
||||||
|
"rule": "config/rule.json",
|
||||||
|
"mode": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 启动应用
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
. "code.hoteas.com/golang/hotime"
|
||||||
|
. "code.hoteas.com/golang/hotime/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
app := Init("config/config.json")
|
||||||
|
|
||||||
|
// 代码生成器在 Init 时自动执行
|
||||||
|
// 会读取数据库结构并生成配置
|
||||||
|
|
||||||
|
app.Run(Router{
|
||||||
|
// 路由配置
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 开发模式
|
||||||
|
|
||||||
|
在 `config.json` 中设置 `"mode": 2`(开发模式)时:
|
||||||
|
|
||||||
|
- 自动读取数据库表结构
|
||||||
|
- 自动生成/更新配置文件
|
||||||
|
- 自动生成代码(如果 codeConfig.mode=1)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 生成规则
|
||||||
|
|
||||||
|
### 默认字段规则
|
||||||
|
|
||||||
|
代码生成器内置了一套默认的字段识别规则:
|
||||||
|
|
||||||
|
| 字段名 | 列表显示 | 新增 | 编辑 | 详情 | 类型 |
|
||||||
|
|--------|----------|------|------|------|------|
|
||||||
|
| `id` | ✅ | ❌ | ❌ | ✅ | number |
|
||||||
|
| `name` | ✅ | ✅ | ✅ | ✅ | text |
|
||||||
|
| `status` | ✅ | ✅ | ✅ | ✅ | select |
|
||||||
|
| `create_time` | ❌ | ❌ | ❌ | ✅ | time |
|
||||||
|
| `modify_time` | ✅ | ❌ | ❌ | ✅ | time |
|
||||||
|
| `password` | ❌ | ✅ | ✅ | ❌ | password |
|
||||||
|
| `image/img/avatar` | ❌ | ✅ | ✅ | ✅ | image |
|
||||||
|
| `file` | ❌ | ✅ | ✅ | ✅ | file |
|
||||||
|
| `content/info` | ❌ | ✅ | ✅ | ✅ | textArea |
|
||||||
|
| `parent_id` | ✅ | ✅ | ✅ | ✅ | number |
|
||||||
|
| `parent_ids/index` | ❌ | ❌ | ❌ | ❌ | index |
|
||||||
|
| `delete` | ❌ | ❌ | ❌ | ❌ | - |
|
||||||
|
|
||||||
|
### 数据类型映射
|
||||||
|
|
||||||
|
数据库字段类型自动映射:
|
||||||
|
|
||||||
|
| 数据库类型 | 生成类型 |
|
||||||
|
|------------|----------|
|
||||||
|
| `int`, `integer`, `float`, `double`, `decimal` | number |
|
||||||
|
| `char`, `varchar`, `text`, `blob` | text |
|
||||||
|
| `date`, `datetime`, `time`, `timestamp`, `year` | time |
|
||||||
|
|
||||||
|
### 字段备注解析
|
||||||
|
|
||||||
|
支持从数据库字段备注中提取信息:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 字段备注格式: 标签名(提示信息):选项1-名称1,选项2-名称2
|
||||||
|
-- 括号支持三种写法:()、()、{}
|
||||||
|
-- 例如:
|
||||||
|
status TINYINT COMMENT '状态(用户账号状态):0-禁用,1-启用'
|
||||||
|
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` 字段在前端的展示效果:
|
||||||
|
- 编辑/新增页面:输入框右侧灰色小字提示
|
||||||
|
- 表格/详情页面:鼠标悬停字段名时气泡提示
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 自定义规则
|
||||||
|
|
||||||
|
### rule.json 配置
|
||||||
|
|
||||||
|
创建 `config/rule.json` 自定义字段规则:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"list": true,
|
||||||
|
"add": false,
|
||||||
|
"edit": false,
|
||||||
|
"info": true,
|
||||||
|
"must": false,
|
||||||
|
"strict": true,
|
||||||
|
"type": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "status",
|
||||||
|
"list": true,
|
||||||
|
"add": true,
|
||||||
|
"edit": true,
|
||||||
|
"info": true,
|
||||||
|
"must": false,
|
||||||
|
"strict": false,
|
||||||
|
"type": "select"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "user.special_field",
|
||||||
|
"list": true,
|
||||||
|
"add": true,
|
||||||
|
"edit": true,
|
||||||
|
"info": true,
|
||||||
|
"type": "text",
|
||||||
|
"strict": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 规则字段说明
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `name` | 字段名,支持 `表名.字段名` 格式精确匹配 |
|
||||||
|
| `list` | 是否在列表中显示 |
|
||||||
|
| `add` | 是否在新增表单中显示 |
|
||||||
|
| `edit` | 是否在编辑表单中显示 |
|
||||||
|
| `info` | 是否在详情中显示 |
|
||||||
|
| `must` | 是否必填 |
|
||||||
|
| `strict` | 是否严格匹配字段名(false 则模糊匹配) |
|
||||||
|
| `type` | 字段类型(覆盖自动识别) |
|
||||||
|
|
||||||
|
### 字段类型
|
||||||
|
|
||||||
|
| 类型 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `text` | 普通文本输入 |
|
||||||
|
| `textArea` | 多行文本 |
|
||||||
|
| `number` | 数字输入 |
|
||||||
|
| `select` | 下拉选择 |
|
||||||
|
| `time` | 时间选择器 |
|
||||||
|
| `unixTime` | Unix 时间戳 |
|
||||||
|
| `image` | 图片上传 |
|
||||||
|
| `file` | 文件上传 |
|
||||||
|
| `password` | 密码输入 |
|
||||||
|
| `money` | 金额(带格式化) |
|
||||||
|
| `index` | 索引字段(不显示) |
|
||||||
|
| `tree` | 树形选择 |
|
||||||
|
| `form` | 表单配置 |
|
||||||
|
| `auth` | 权限配置 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 生成的代码结构
|
||||||
|
|
||||||
|
### 内嵌模式 (mode=0)
|
||||||
|
|
||||||
|
不生成代码文件,使用框架内置控制器,只生成配置文件:
|
||||||
|
|
||||||
|
```
|
||||||
|
config/
|
||||||
|
├── admin.json # 接口配置
|
||||||
|
├── adminDB.json # 数据库结构配置(可选)
|
||||||
|
└── rule.json # 字段规则
|
||||||
|
```
|
||||||
|
|
||||||
|
### 生成模式 (mode=1)
|
||||||
|
|
||||||
|
生成独立的控制器代码:
|
||||||
|
|
||||||
|
```
|
||||||
|
admin/ # 生成的包目录
|
||||||
|
├── init.go # 包初始化和路由注册
|
||||||
|
├── user.go # user 表控制器
|
||||||
|
├── role.go # role 表控制器
|
||||||
|
└── ... # 其他表控制器
|
||||||
|
```
|
||||||
|
|
||||||
|
### 生成的控制器结构
|
||||||
|
|
||||||
|
```go
|
||||||
|
package admin
|
||||||
|
|
||||||
|
var userCtr = Ctr{
|
||||||
|
"info": func(that *Context) {
|
||||||
|
// 查询单条记录
|
||||||
|
},
|
||||||
|
"add": func(that *Context) {
|
||||||
|
// 新增记录
|
||||||
|
},
|
||||||
|
"update": func(that *Context) {
|
||||||
|
// 更新记录
|
||||||
|
},
|
||||||
|
"remove": func(that *Context) {
|
||||||
|
// 删除记录
|
||||||
|
},
|
||||||
|
"search": func(that *Context) {
|
||||||
|
// 搜索列表(分页)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 配置文件结构
|
||||||
|
|
||||||
|
### admin.json 示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "admin",
|
||||||
|
"label": "管理平台",
|
||||||
|
"menus": [
|
||||||
|
{
|
||||||
|
"label": "系统管理",
|
||||||
|
"name": "sys",
|
||||||
|
"icon": "Setting",
|
||||||
|
"menus": [
|
||||||
|
{
|
||||||
|
"label": "用户管理",
|
||||||
|
"table": "user",
|
||||||
|
"auth": ["show", "add", "delete", "edit", "info", "download"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "角色管理",
|
||||||
|
"table": "role",
|
||||||
|
"auth": ["show", "add", "delete", "edit", "info"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tables": {
|
||||||
|
"user": {
|
||||||
|
"label": "用户",
|
||||||
|
"table": "user",
|
||||||
|
"auth": ["show", "add", "delete", "edit", "info", "download"],
|
||||||
|
"columns": [
|
||||||
|
{"name": "id", "type": "number", "label": "ID"},
|
||||||
|
{"name": "name", "type": "text", "label": "用户名"},
|
||||||
|
{"name": "status", "type": "select", "label": "状态",
|
||||||
|
"options": [{"name": "禁用", "value": "0"}, {"name": "启用", "value": "1"}]}
|
||||||
|
],
|
||||||
|
"search": [
|
||||||
|
{"type": "search", "name": "keyword", "label": "请输入关键词"},
|
||||||
|
{"type": "search", "name": "daterange", "label": "时间段"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 外键关联
|
||||||
|
|
||||||
|
### 自动识别
|
||||||
|
|
||||||
|
代码生成器会自动识别 `_id` 结尾的字段作为外键:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- user 表
|
||||||
|
CREATE TABLE user (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
name VARCHAR(50),
|
||||||
|
role_id INT, -- 自动关联 role 表
|
||||||
|
org_id INT -- 自动关联 org 表
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
生成的配置会包含 `link` 和 `value` 字段:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "role_id",
|
||||||
|
"type": "number",
|
||||||
|
"label": "角色",
|
||||||
|
"link": "role",
|
||||||
|
"value": "name"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 树形结构
|
||||||
|
|
||||||
|
`parent_id` 字段会被识别为树形结构的父级关联:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "parent_id",
|
||||||
|
"type": "number",
|
||||||
|
"label": "上级",
|
||||||
|
"link": "org",
|
||||||
|
"value": "name"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 权限控制
|
||||||
|
|
||||||
|
### 数据权限
|
||||||
|
|
||||||
|
配置 `flow` 实现数据权限控制:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"flow": {
|
||||||
|
"order": {
|
||||||
|
"table": "order",
|
||||||
|
"stop": false,
|
||||||
|
"sql": {
|
||||||
|
"user_id": "id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `stop`: 是否禁止修改该表
|
||||||
|
- `sql`: 数据过滤条件,`user_id = 当前用户.id`
|
||||||
|
|
||||||
|
### 操作权限
|
||||||
|
|
||||||
|
每张表可配置的权限:
|
||||||
|
|
||||||
|
| 权限 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `show` | 查看列表 |
|
||||||
|
| `add` | 新增 |
|
||||||
|
| `edit` | 编辑 |
|
||||||
|
| `delete` | 删除 |
|
||||||
|
| `info` | 查看详情 |
|
||||||
|
| `download` | 下载导出 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
### 1. 开发流程
|
||||||
|
|
||||||
|
1. 设置 `config.json` 中 `mode: 2`(开发模式)
|
||||||
|
2. 设计数据库表结构,添加字段备注
|
||||||
|
3. 启动应用,自动生成配置
|
||||||
|
4. 检查生成的配置文件,按需调整
|
||||||
|
5. 生产环境改为 `mode: 0`
|
||||||
|
|
||||||
|
### 2. 字段命名规范
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 推荐的命名方式
|
||||||
|
id -- 主键
|
||||||
|
name -- 名称
|
||||||
|
status -- 状态(自动识别为 select)
|
||||||
|
create_time -- 创建时间
|
||||||
|
modify_time -- 修改时间
|
||||||
|
xxx_id -- 外键关联
|
||||||
|
parent_id -- 树形结构父级
|
||||||
|
avatar -- 头像(自动识别为 image)
|
||||||
|
content -- 内容(自动识别为 textArea)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 自定义扩展
|
||||||
|
|
||||||
|
如果默认规则不满足需求,可以:
|
||||||
|
|
||||||
|
1. 修改 `rule.json` 添加自定义规则
|
||||||
|
2. 使用 `mode=1` 生成代码后手动修改
|
||||||
|
3. 在生成的配置文件中直接调整字段属性
|
||||||
|
|
||||||
|
### 4. 达梦 DM8 使用注意
|
||||||
|
|
||||||
|
代码生成器已原生支持达梦 DM8,会自动读取 DM 数据库的表结构和字段备注。使用时注意:
|
||||||
|
|
||||||
|
- 表注释(COMMENT)在 DM 中通过 `COMMENT ON TABLE` 语句单独设置,建表后需额外执行
|
||||||
|
- 字段备注同样通过 `COMMENT ON COLUMN` 设置
|
||||||
|
- 由于 DM 的 `USER_TAB_COLUMNS` 视图字段名称与 MySQL `information_schema` 不同,代码生成器已做兼容处理
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- DM8 设置表备注(替代 MySQL 的 COMMENT='...' 语法)
|
||||||
|
COMMENT ON TABLE "user" IS '用户管理';
|
||||||
|
|
||||||
|
-- DM8 设置字段备注
|
||||||
|
COMMENT ON COLUMN "user"."state" IS '状态:0-正常,1-异常,2-隐藏';
|
||||||
|
COMMENT ON COLUMN "user"."name" IS '用户名';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [快速上手指南](QUICKSTART.md)
|
||||||
|
- [HoTimeDB 使用说明](HoTimeDB_使用说明.md)
|
||||||
|
- [Common 工具类使用说明](Common_工具类使用说明.md)
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
# 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)
|
||||||
@@ -0,0 +1,487 @@
|
|||||||
|
# 数据库设计规范
|
||||||
|
|
||||||
|
本文档定义了 HoTime 框架代码生成器所依赖的数据库设计规范。遵循这些规范可以确保代码生成器正确识别表关系、自动生成 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`(详见代码生成配置规范)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 字段注释规则
|
||||||
|
|
||||||
|
### 注释语法格式
|
||||||
|
|
||||||
|
字段注释支持以下格式组合:
|
||||||
|
|
||||||
|
```
|
||||||
|
显示名称(前端提示):选项值 附加备注
|
||||||
|
显示名称(前端提示):选项值 附加备注
|
||||||
|
显示名称{前端提示}:选项值 附加备注
|
||||||
|
```
|
||||||
|
|
||||||
|
| 部分 | 分隔符 | 用途 |
|
||||||
|
|------|--------|------|
|
||||||
|
| 显示名称 | 无 | 前端表单/列表的字段标签 |
|
||||||
|
| 前端提示 | `()`、`()` 或 `{}` | 存储到 `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),代码生成器会使用字段名作为默认显示名称。如需自定义,可在配置文件中手动设置(详见代码生成配置规范)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 必有字段规则
|
||||||
|
|
||||||
|
每张业务表必须包含以下三个字段:
|
||||||
|
|
||||||
|
### 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 错配问题)
|
||||||
@@ -0,0 +1,582 @@
|
|||||||
|
# HoTimeDB API 快速参考
|
||||||
|
|
||||||
|
## 条件查询语法规则
|
||||||
|
|
||||||
|
**新版本改进:**
|
||||||
|
- 多条件自动用 AND 连接,无需手动包装
|
||||||
|
- 关键字支持大小写(如 `LIMIT` 和 `limit` 都有效)
|
||||||
|
- 新增 `HAVING` 和独立 `OFFSET` 支持
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ✅ 推荐:简化语法(多条件自动 AND)
|
||||||
|
Map{"status": 1, "age[>]": 18}
|
||||||
|
// 生成: WHERE `status`=? AND `age`>?
|
||||||
|
|
||||||
|
// ✅ 仍然支持:显式 AND 包装(向后兼容)
|
||||||
|
Map{
|
||||||
|
"AND": Map{
|
||||||
|
"status": 1,
|
||||||
|
"age[>]": 18,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 混合条件和特殊关键字
|
||||||
|
Map{
|
||||||
|
"status": 1,
|
||||||
|
"age[>]": 18,
|
||||||
|
"ORDER": "id DESC", // 或 "order": "id DESC"
|
||||||
|
"LIMIT": 10, // 或 "limit": 10
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 基本方法
|
||||||
|
|
||||||
|
### 数据库连接
|
||||||
|
```go
|
||||||
|
database.SetConnect(func() (master, slave *sql.DB) { ... })
|
||||||
|
database.InitDb()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 链式查询构建器
|
||||||
|
```go
|
||||||
|
// 创建查询构建器
|
||||||
|
builder := database.Table("tablename")
|
||||||
|
|
||||||
|
// 设置条件
|
||||||
|
builder.Where(key, value)
|
||||||
|
builder.And(key, value) 或 builder.And(map)
|
||||||
|
builder.Or(key, value) 或 builder.Or(map)
|
||||||
|
|
||||||
|
// JOIN操作
|
||||||
|
builder.LeftJoin(table, condition)
|
||||||
|
builder.RightJoin(table, condition)
|
||||||
|
builder.InnerJoin(table, condition)
|
||||||
|
builder.FullJoin(table, condition)
|
||||||
|
builder.Join(map) // 通用JOIN
|
||||||
|
|
||||||
|
// 排序和分组
|
||||||
|
builder.Order(fields...)
|
||||||
|
builder.Group(fields...)
|
||||||
|
builder.Limit(args...)
|
||||||
|
builder.Having(map) // 新增
|
||||||
|
|
||||||
|
// 分页
|
||||||
|
builder.Page(page, pageSize)
|
||||||
|
builder.Offset(offset) // 新增
|
||||||
|
|
||||||
|
// 执行查询
|
||||||
|
builder.Select(fields...) // 返回 []Map
|
||||||
|
builder.Get(fields...) // 返回 Map
|
||||||
|
builder.Count() // 返回 int
|
||||||
|
builder.Update(data) // 返回 int64
|
||||||
|
builder.Delete() // 返回 int64
|
||||||
|
```
|
||||||
|
|
||||||
|
## CRUD 操作
|
||||||
|
|
||||||
|
### 查询 (Select)
|
||||||
|
```go
|
||||||
|
// 基本查询
|
||||||
|
data := database.Select("table")
|
||||||
|
data := database.Select("table", "field1,field2")
|
||||||
|
data := database.Select("table", []string{"field1", "field2"})
|
||||||
|
data := database.Select("table", "*", whereMap)
|
||||||
|
|
||||||
|
// 带JOIN查询
|
||||||
|
data := database.Select("table", joinSlice, "fields", whereMap)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 获取单条 (Get)
|
||||||
|
```go
|
||||||
|
// 自动添加 LIMIT 1
|
||||||
|
row := database.Get("table", "fields", whereMap)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 插入 (Insert)
|
||||||
|
```go
|
||||||
|
id := database.Insert("table", dataMap)
|
||||||
|
// 返回新插入记录的ID
|
||||||
|
```
|
||||||
|
|
||||||
|
### 批量插入 (Inserts) - 新增
|
||||||
|
```go
|
||||||
|
// 使用 []Map 格式,更直观简洁
|
||||||
|
affected := database.Inserts("table", []Map{
|
||||||
|
{"col1": "val1", "col2": "val2", "col3": "val3"},
|
||||||
|
{"col1": "val4", "col2": "val5", "col3": "val6"},
|
||||||
|
})
|
||||||
|
// 返回受影响的行数
|
||||||
|
|
||||||
|
// 推荐:使用服务器时间(Time2Str),避免数据库时区差异
|
||||||
|
now := Time2Str(time.Now())
|
||||||
|
affected := database.Inserts("log", []Map{
|
||||||
|
{"user_id": 1, "created_time": now},
|
||||||
|
{"user_id": 2, "created_time": now},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 更新 (Update)
|
||||||
|
```go
|
||||||
|
affected := database.Update("table", dataMap, whereMap)
|
||||||
|
// 返回受影响的行数
|
||||||
|
```
|
||||||
|
|
||||||
|
### Upsert - 新增
|
||||||
|
```go
|
||||||
|
// 使用 Slice 格式
|
||||||
|
affected := database.Upsert("table",
|
||||||
|
dataMap, // 插入数据
|
||||||
|
Slice{"unique_key"}, // 唯一键
|
||||||
|
Slice{"col1", "col2"}, // 冲突时更新的字段
|
||||||
|
)
|
||||||
|
|
||||||
|
// 也支持可变参数
|
||||||
|
affected := database.Upsert("table", dataMap, Slice{"id"}, "col1", "col2")
|
||||||
|
// 返回受影响的行数
|
||||||
|
```
|
||||||
|
|
||||||
|
### 删除 (Delete)
|
||||||
|
```go
|
||||||
|
affected := database.Delete("table", whereMap)
|
||||||
|
// 返回删除的行数
|
||||||
|
```
|
||||||
|
|
||||||
|
## 聚合函数
|
||||||
|
|
||||||
|
### 计数
|
||||||
|
```go
|
||||||
|
count := database.Count("table")
|
||||||
|
count := database.Count("table", whereMap)
|
||||||
|
count := database.Count("table", joinSlice, whereMap)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 求和
|
||||||
|
```go
|
||||||
|
sum := database.Sum("table", "column")
|
||||||
|
sum := database.Sum("table", "column", whereMap)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 平均值 - 新增
|
||||||
|
```go
|
||||||
|
avg := database.Avg("table", "column")
|
||||||
|
avg := database.Avg("table", "column", whereMap)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 最大值 - 新增
|
||||||
|
```go
|
||||||
|
max := database.Max("table", "column")
|
||||||
|
max := database.Max("table", "column", whereMap)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 最小值 - 新增
|
||||||
|
```go
|
||||||
|
min := database.Min("table", "column")
|
||||||
|
min := database.Min("table", "column", whereMap)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 分页查询
|
||||||
|
```go
|
||||||
|
// 设置分页
|
||||||
|
database.Page(page, pageSize)
|
||||||
|
|
||||||
|
// 分页查询
|
||||||
|
data := database.Page(page, pageSize).PageSelect("table", "fields", whereMap)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 条件语法参考
|
||||||
|
|
||||||
|
### 比较操作符
|
||||||
|
| 写法 | SQL | 说明 |
|
||||||
|
|------|-----|------|
|
||||||
|
| `"field": value` | `field = ?` | 等于 |
|
||||||
|
| `"field[!]": value` | `field != ?` | 不等于 |
|
||||||
|
| `"field[>]": value` | `field > ?` | 大于 |
|
||||||
|
| `"field[>=]": value` | `field >= ?` | 大于等于 |
|
||||||
|
| `"field[<]": value` | `field < ?` | 小于 |
|
||||||
|
| `"field[<=]": value` | `field <= ?` | 小于等于 |
|
||||||
|
|
||||||
|
### 模糊查询
|
||||||
|
| 写法 | SQL | 说明 |
|
||||||
|
|------|-----|------|
|
||||||
|
| `"field[~]": "keyword"` | `field LIKE '%keyword%'` | 包含 |
|
||||||
|
| `"field[~!]": "keyword"` | `field LIKE 'keyword%'` | 以...开头 |
|
||||||
|
| `"field[!~]": "keyword"` | `field LIKE '%keyword'` | 以...结尾 |
|
||||||
|
| `"field[~~]": "%keyword%"` | `field LIKE '%keyword%'` | 手动LIKE |
|
||||||
|
|
||||||
|
### 范围查询
|
||||||
|
| 写法 | SQL | 说明 |
|
||||||
|
|------|-----|------|
|
||||||
|
| `"field[<>]": [min, max]` | `field BETWEEN ? AND ?` | 区间内 |
|
||||||
|
| `"field[><]": [min, max]` | `field NOT BETWEEN ? AND ?` | 区间外 |
|
||||||
|
|
||||||
|
### 集合查询
|
||||||
|
| 写法 | SQL | 说明 |
|
||||||
|
|------|-----|------|
|
||||||
|
| `"field": [v1, v2, v3]` | `field IN (?, ?, ?)` | 在集合中 |
|
||||||
|
| `"field[!]": [v1, v2, v3]` | `field NOT IN (?, ?, ?)` | 不在集合中 |
|
||||||
|
|
||||||
|
### NULL查询
|
||||||
|
| 写法 | SQL | 说明 |
|
||||||
|
|------|-----|------|
|
||||||
|
| `"field": nil` | `field IS NULL` | 为空 |
|
||||||
|
| `"field[!]": nil` | `field IS NOT NULL` | 不为空 |
|
||||||
|
|
||||||
|
### 直接SQL
|
||||||
|
| 写法 | SQL | 说明 |
|
||||||
|
|------|-----|------|
|
||||||
|
| `"field[#]": "balance + 1"` | `field = balance + 1` | 直接SQL表达式(数值运算等) |
|
||||||
|
| `"[##]": "a > b"` | `a > b` | 直接SQL片段 |
|
||||||
|
| `"field[#!]": "1"` | `field != 1` | 不等于(不参数化) |
|
||||||
|
|
||||||
|
> **时间字段推荐写法**:不要用 `"[#]": "NOW()"`,改用 Go 侧传值:
|
||||||
|
> ```go
|
||||||
|
> // 赋值当前时间
|
||||||
|
> "create_time": Time2Str(time.Now())
|
||||||
|
>
|
||||||
|
> // 时间范围查询(一天前)
|
||||||
|
> "create_time[>]": Time2Str(time.Now().AddDate(0, 0, -1))
|
||||||
|
> ```
|
||||||
|
> 原因:`NOW()` 使用数据库时区;`Time2Str(time.Now())` 使用应用服务器时区,行为稳定且跨数据库兼容。
|
||||||
|
|
||||||
|
## 逻辑连接符
|
||||||
|
|
||||||
|
### AND 条件
|
||||||
|
```go
|
||||||
|
// 简化语法(推荐)
|
||||||
|
whereMap := Map{
|
||||||
|
"status": 1,
|
||||||
|
"age[>]": 18,
|
||||||
|
}
|
||||||
|
// 生成: WHERE `status`=? AND `age`>?
|
||||||
|
|
||||||
|
// 显式 AND(向后兼容)
|
||||||
|
whereMap := Map{
|
||||||
|
"AND": Map{
|
||||||
|
"status": 1,
|
||||||
|
"age[>]": 18,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### OR 条件
|
||||||
|
```go
|
||||||
|
whereMap := Map{
|
||||||
|
"OR": Map{
|
||||||
|
"status": 1,
|
||||||
|
"type": 2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 嵌套条件
|
||||||
|
```go
|
||||||
|
whereMap := Map{
|
||||||
|
"AND": Map{
|
||||||
|
"status": 1,
|
||||||
|
"OR": Map{
|
||||||
|
"age[<]": 30,
|
||||||
|
"level[>]": 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## JOIN 语法
|
||||||
|
|
||||||
|
### 传统语法
|
||||||
|
```go
|
||||||
|
joinSlice := Slice{
|
||||||
|
Map{"[>]profile": "user.id = profile.user_id"}, // LEFT JOIN
|
||||||
|
Map{"[<]department": "user.dept_id = department.id"}, // RIGHT JOIN
|
||||||
|
Map{"[><]role": "user.role_id = role.id"}, // INNER JOIN
|
||||||
|
Map{"[<>]group": "user.group_id = group.id"}, // FULL JOIN
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 链式语法
|
||||||
|
```go
|
||||||
|
builder.LeftJoin("profile", "user.id = profile.user_id")
|
||||||
|
builder.RightJoin("department", "user.dept_id = department.id")
|
||||||
|
builder.InnerJoin("role", "user.role_id = role.id")
|
||||||
|
builder.FullJoin("group", "user.group_id = group.id")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 特殊字段语法
|
||||||
|
|
||||||
|
### ORDER BY
|
||||||
|
```go
|
||||||
|
Map{
|
||||||
|
"ORDER": []string{"created_time DESC", "id ASC"},
|
||||||
|
}
|
||||||
|
// 或
|
||||||
|
Map{
|
||||||
|
"order": "created_time DESC", // 支持小写
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GROUP BY
|
||||||
|
```go
|
||||||
|
Map{
|
||||||
|
"GROUP": []string{"department", "level"},
|
||||||
|
}
|
||||||
|
// 或
|
||||||
|
Map{
|
||||||
|
"group": "department", // 支持小写
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### HAVING - 新增
|
||||||
|
```go
|
||||||
|
Map{
|
||||||
|
"GROUP": "dept_id",
|
||||||
|
"HAVING": Map{
|
||||||
|
"COUNT(*).[>]": 5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### LIMIT
|
||||||
|
```go
|
||||||
|
Map{
|
||||||
|
"LIMIT": []int{10, 20}, // offset 10, limit 20
|
||||||
|
}
|
||||||
|
// 或
|
||||||
|
Map{
|
||||||
|
"limit": 20, // limit 20,支持小写
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### OFFSET - 新增
|
||||||
|
```go
|
||||||
|
Map{
|
||||||
|
"LIMIT": 10,
|
||||||
|
"OFFSET": 20, // 独立的 OFFSET
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 事务处理
|
||||||
|
```go
|
||||||
|
success := database.Action(func(tx HoTimeDB) bool {
|
||||||
|
// 在这里执行数据库操作
|
||||||
|
// 返回 true 提交事务
|
||||||
|
// 返回 false 回滚事务
|
||||||
|
|
||||||
|
id := tx.Insert("table", data)
|
||||||
|
if id == 0 {
|
||||||
|
return false // 回滚
|
||||||
|
}
|
||||||
|
|
||||||
|
affected := tx.Update("table2", data2, where2)
|
||||||
|
if affected == 0 {
|
||||||
|
return false // 回滚
|
||||||
|
}
|
||||||
|
|
||||||
|
return true // 提交
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 原生SQL执行
|
||||||
|
|
||||||
|
### 查询
|
||||||
|
```go
|
||||||
|
results := database.Query("SELECT * FROM user WHERE age > ?", 18)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 执行
|
||||||
|
```go
|
||||||
|
result, err := database.Exec("UPDATE user SET status = ? WHERE id = ?", 1, 100)
|
||||||
|
affected, _ := result.RowsAffected()
|
||||||
|
```
|
||||||
|
|
||||||
|
## PostgreSQL 支持 - 新增
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 配置 PostgreSQL
|
||||||
|
database := &db.HoTimeDB{
|
||||||
|
Type: "postgres", // 设置类型
|
||||||
|
}
|
||||||
|
|
||||||
|
// 框架自动处理差异:
|
||||||
|
// - 占位符: ? -> $1, $2, $3...
|
||||||
|
// - 引号: `name` -> "name"
|
||||||
|
// - Upsert: ON DUPLICATE KEY -> ON CONFLICT
|
||||||
|
```
|
||||||
|
|
||||||
|
## 达梦数据库(DM8)支持
|
||||||
|
|
||||||
|
### 连接配置
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
_ "gitee.com/chunanyong/dm" // vendor 已内置,无需额外安装
|
||||||
|
)
|
||||||
|
|
||||||
|
database := &db.HoTimeDB{
|
||||||
|
Type: "dm", // 或 "dameng"
|
||||||
|
}
|
||||||
|
|
||||||
|
database.SetConnect(func(err ...*common.Error) (master, slave *sql.DB) {
|
||||||
|
// schema= 指定当前会话默认搜索 schema
|
||||||
|
dsn := "dm://SYSDBA:password@127.0.0.1:5236?schema=TEST"
|
||||||
|
master, _ = sql.Open("dm", dsn)
|
||||||
|
return master, master
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 各数据库差异对比
|
||||||
|
|
||||||
|
| 特性 | MySQL | PostgreSQL | 达梦 DM8 |
|
||||||
|
|------|-------|------------|---------|
|
||||||
|
| 标识符引号 | \`name\` | "name" | "name" |
|
||||||
|
| 占位符 | ? | $1, $2... | ? |
|
||||||
|
| Upsert | ON DUPLICATE KEY UPDATE | ON CONFLICT DO UPDATE | MERGE INTO...USING |
|
||||||
|
| 自增列 | AUTO_INCREMENT | SERIAL | IDENTITY(1,1) |
|
||||||
|
| 分页 | LIMIT m, n | LIMIT n OFFSET m | LIMIT m, n 或 LIMIT n OFFSET m |
|
||||||
|
|
||||||
|
框架自动处理所有差异,业务代码无需修改。
|
||||||
|
|
||||||
|
### 达梦注意事项速查
|
||||||
|
|
||||||
|
| 场景 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 标识符大小写 | 双引号内大小写敏感,框架统一使用小写 |
|
||||||
|
| 保留字 | `admin`/`user`/`order` 等框架自动加双引号,原生SQL需手动处理 |
|
||||||
|
| 表存在检测 | 用 `COUNT(*)` 代替 `USER_TABLES`(schema 错配问题) |
|
||||||
|
| `Insert` 返回ID | 原生驱动已支持 `LastInsertId()`,与 MySQL 一致 |
|
||||||
|
| 长文本类型 | DM 用 `CLOB`(对应 MySQL 的 `LONGTEXT`) |
|
||||||
|
| 时间赋值 | **推荐统一用** `Time2Str(time.Now())` 传服务器时间,避免 `NOW()` 时区差异及跨库函数不兼容 |
|
||||||
|
|
||||||
|
### 原生 SQL 中的保留字
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 达梦中 admin、user 等是保留字,原生 SQL 必须加双引号
|
||||||
|
results := database.Query(
|
||||||
|
`SELECT "id", "name" FROM "admin" WHERE "state" = ?`, 1)
|
||||||
|
|
||||||
|
// ORM 方法无需处理,框架自动加引号
|
||||||
|
results := database.Select("admin", "*", common.Map{"state": 1})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
```go
|
||||||
|
// 检查最后的错误
|
||||||
|
if database.LastErr.GetError() != nil {
|
||||||
|
fmt.Println("错误:", database.LastErr.GetError())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查看最后执行的SQL
|
||||||
|
fmt.Println("SQL:", database.LastQuery)
|
||||||
|
fmt.Println("参数:", database.LastData)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 工具方法
|
||||||
|
|
||||||
|
### 数据库信息
|
||||||
|
```go
|
||||||
|
prefix := database.GetPrefix() // 获取表前缀
|
||||||
|
dbType := database.GetType() // 获取数据库类型
|
||||||
|
dialect := database.GetDialect() // 获取方言适配器
|
||||||
|
```
|
||||||
|
|
||||||
|
### 设置模式
|
||||||
|
```go
|
||||||
|
database.Mode = 0 // 生产模式
|
||||||
|
database.Mode = 1 // 测试模式
|
||||||
|
database.Mode = 2 // 开发模式(输出SQL日志)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常用查询模式
|
||||||
|
|
||||||
|
### 分页列表查询
|
||||||
|
```go
|
||||||
|
// 获取总数
|
||||||
|
total := database.Count("user", Map{"status": 1})
|
||||||
|
|
||||||
|
// 分页数据
|
||||||
|
users := database.Table("user").
|
||||||
|
Where("status", 1).
|
||||||
|
Order("created_time DESC").
|
||||||
|
Page(page, pageSize).
|
||||||
|
Select("id,name,email,created_time")
|
||||||
|
|
||||||
|
// 计算分页信息
|
||||||
|
totalPages := (total + pageSize - 1) / pageSize
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关联查询
|
||||||
|
```go
|
||||||
|
orders := database.Table("order").
|
||||||
|
LeftJoin("user", "order.user_id = user.id").
|
||||||
|
LeftJoin("product", "order.product_id = product.id").
|
||||||
|
Where("order.status", "paid").
|
||||||
|
Select(`
|
||||||
|
order.*,
|
||||||
|
user.name as user_name,
|
||||||
|
product.title as product_title
|
||||||
|
`)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 统计查询
|
||||||
|
```go
|
||||||
|
stats := database.Select("order",
|
||||||
|
"user_id, COUNT(*) as order_count, SUM(amount) as total_amount",
|
||||||
|
Map{
|
||||||
|
"status": "paid",
|
||||||
|
"created_time[>]": "2023-01-01",
|
||||||
|
"GROUP": "user_id",
|
||||||
|
"ORDER": "total_amount DESC",
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 批量操作
|
||||||
|
```go
|
||||||
|
// 批量插入(使用 []Map 格式)
|
||||||
|
affected := database.Inserts("user", []Map{
|
||||||
|
{"name": "用户1", "email": "user1@example.com", "status": 1},
|
||||||
|
{"name": "用户2", "email": "user2@example.com", "status": 1},
|
||||||
|
{"name": "用户3", "email": "user3@example.com", "status": 1},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Upsert(插入或更新,使用 Slice 格式)
|
||||||
|
affected := database.Upsert("user",
|
||||||
|
Map{"id": 1, "name": "新名称", "email": "new@example.com"},
|
||||||
|
Slice{"id"},
|
||||||
|
Slice{"name", "email"},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 链式调用完整示例
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 复杂查询链式调用
|
||||||
|
result := database.Table("order").
|
||||||
|
LeftJoin("user", "order.user_id = user.id").
|
||||||
|
LeftJoin("product", "order.product_id = product.id").
|
||||||
|
Where("order.status", "paid").
|
||||||
|
And("order.created_time[>]", "2023-01-01").
|
||||||
|
And(Map{
|
||||||
|
"OR": Map{
|
||||||
|
"user.level": "vip",
|
||||||
|
"order.amount[>]": 1000,
|
||||||
|
},
|
||||||
|
}).
|
||||||
|
Group("user.id").
|
||||||
|
Having(Map{"total_amount[>]": 500}).
|
||||||
|
Order("total_amount DESC").
|
||||||
|
Page(1, 20).
|
||||||
|
Select(`
|
||||||
|
user.id,
|
||||||
|
user.name,
|
||||||
|
user.email,
|
||||||
|
COUNT(order.id) as order_count,
|
||||||
|
SUM(order.amount) as total_amount
|
||||||
|
`)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*快速参考版本: 2.1*
|
||||||
|
*更新日期: 2026年3月*
|
||||||
|
|
||||||
|
**详细说明:**
|
||||||
|
- [HoTimeDB 使用说明](HoTimeDB_使用说明.md) - 完整教程
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,615 @@
|
|||||||
|
# HoTime 快速上手指南
|
||||||
|
|
||||||
|
5 分钟入门 HoTime 框架。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
```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**
|
||||||
|
|
||||||
|
## 数据库操作(简要)
|
||||||
|
|
||||||
|
### 基础 CRUD
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 查询列表
|
||||||
|
users := that.Db.Select("user", "*", Map{"status": 1})
|
||||||
|
|
||||||
|
// 查询单条
|
||||||
|
user := that.Db.Get("user", "*", Map{"id": 1})
|
||||||
|
|
||||||
|
// 插入
|
||||||
|
id := that.Db.Insert("user", Map{"name": "test", "age": 18})
|
||||||
|
|
||||||
|
// 批量插入
|
||||||
|
affected := that.Db.Inserts("user", []Map{
|
||||||
|
{"name": "user1", "age": 20},
|
||||||
|
{"name": "user2", "age": 25},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新
|
||||||
|
rows := that.Db.Update("user", Map{"name": "new"}, Map{"id": 1})
|
||||||
|
|
||||||
|
// 删除
|
||||||
|
rows := that.Db.Delete("user", Map{"id": 1})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 链式查询
|
||||||
|
|
||||||
|
```go
|
||||||
|
users := that.Db.Table("user").
|
||||||
|
LeftJoin("order", "user.id=order.user_id").
|
||||||
|
Where("status", 1).
|
||||||
|
And("age[>]", 18).
|
||||||
|
Order("id DESC").
|
||||||
|
Page(1, 10).
|
||||||
|
Select("*")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 条件语法速查
|
||||||
|
|
||||||
|
| 语法 | 说明 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| `key` | 等于 | `"id": 1` |
|
||||||
|
| `key[>]` | 大于 | `"age[>]": 18` |
|
||||||
|
| `key[<]` | 小于 | `"age[<]": 60` |
|
||||||
|
| `key[>=]` | 大于等于 | `"age[>=]": 18` |
|
||||||
|
| `key[<=]` | 小于等于 | `"age[<=]": 60` |
|
||||||
|
| `key[!]` | 不等于 | `"status[!]": 0` |
|
||||||
|
| `key[~]` | LIKE | `"name[~]": "test"` |
|
||||||
|
| `key[<>]` | BETWEEN | `"age[<>]": Slice{18, 60}` |
|
||||||
|
| `key` | IN | `"id": Slice{1, 2, 3}` |
|
||||||
|
|
||||||
|
### 事务
|
||||||
|
|
||||||
|
```go
|
||||||
|
success := that.Db.Action(func(tx db.HoTimeDB) bool {
|
||||||
|
tx.Update("user", Map{"balance[#]": "balance - 100"}, Map{"id": 1})
|
||||||
|
tx.Insert("order", Map{"user_id": 1, "amount": 100})
|
||||||
|
return true // 返回 true 提交,false 回滚
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
> **更多数据库操作**:参见 [HoTimeDB 使用说明](HoTimeDB_使用说明.md)
|
||||||
|
|
||||||
|
## 日志记录
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 创建操作日志(自动插入 logs 表)
|
||||||
|
that.Log = Map{
|
||||||
|
"type": "login",
|
||||||
|
"action": "用户登录",
|
||||||
|
"data": Map{"phone": phone},
|
||||||
|
}
|
||||||
|
// 框架会自动添加 time, admin_id/user_id, ip 等字段
|
||||||
|
```
|
||||||
|
|
||||||
|
## 扩展功能
|
||||||
|
|
||||||
|
| 功能 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 微信支付/公众号/小程序 | `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) - 完整数据库教程
|
||||||
|
- [HoTimeDB API 参考](HoTimeDB_API参考.md) - API 速查手册
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
# HoTime 改进规划
|
||||||
|
|
||||||
|
本文档记录 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 适配 |
|
||||||
+121
@@ -0,0 +1,121 @@
|
|||||||
|
# Seq 日志集成
|
||||||
|
|
||||||
|
HoTime 框架内置 Seq 日志推送支持。通过在 `config.json` 填写 `seqUrl` 即可激活,
|
||||||
|
所有框架日志(含 `fmt.Println` 等绕过 Logger 的输出)实时推送到 Seq,
|
||||||
|
通过 Seq Web UI 实现关键词、日期范围、日志级别、结构化字段等多维度搜索。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
在 `config.json` 中添加:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"seqUrl": "http://127.0.0.1:5341",
|
||||||
|
"seqApiKey": ""
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 默认值 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `seqUrl` | 空(不激活) | Seq 服务地址,空值时功能静默不生效 |
|
||||||
|
| `seqApiKey` | 空 | API Key,免费单用户版留空 |
|
||||||
|
|
||||||
|
`instance` 字段由框架自动拼接为 `ip:port`(如 `192.168.1.10:8085`),无需手动填写,同机多进程靠端口区分,跨服务器靠 IP 区分,支持未来集群扩展。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 架构原理
|
||||||
|
|
||||||
|
```
|
||||||
|
业务代码
|
||||||
|
│ l.Info().Msg("...") ← HoTime Logger 正常调用路径
|
||||||
|
│ fmt.Println("...") ← 被 redirectStdout 捕获后转入同一路径
|
||||||
|
↓
|
||||||
|
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 全量捕获
|
||||||
|
|
||||||
|
框架启动时(`SetConfig()` 中)自动调用 `redirectStdout()`,
|
||||||
|
将 `os.Stdout` 和标准 `log` 包重定向到 HoTime Logger:
|
||||||
|
|
||||||
|
- `fmt.Println("xxx")` → 以 `INFO` 级别、`source=stdout` 字段推送到 Seq
|
||||||
|
- `log.Printf("xxx")` → 同上
|
||||||
|
- 控制台仍然能看到这些输出(输出管道由 goroutine 实时转发)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Seq 安装
|
||||||
|
|
||||||
|
Seq 提供 Windows MSI 安装包和 Docker 镜像,单机免费,无外部数据库依赖:
|
||||||
|
|
||||||
|
- Windows:[https://datalust.co/download/seq](https://datalust.co/download/seq),安装后自动注册为 Windows 服务
|
||||||
|
- Docker:`docker run -d --restart always --name seq -p 5341:80 -e ACCEPT_EULA=Y datalust/seq`
|
||||||
|
- 访问 `http://localhost:5341` 使用 Web UI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 搜索语法速查
|
||||||
|
|
||||||
|
| 目标 | 查询语句 |
|
||||||
|
|---|---|
|
||||||
|
| 关键词搜索 | 直接输入,如 `支付失败` |
|
||||||
|
| 日志级别 | `@l = 'Error'` |
|
||||||
|
| 特定实例 | `instance = '8085'` |
|
||||||
|
| stdout 来源 | `source = 'stdout'` |
|
||||||
|
| 调用位置 | `caller like '%order.go%'` |
|
||||||
|
| 组合查询 | `@l = 'Error' and instance = '8086' and @mt like '%超时%'` |
|
||||||
|
| 日期范围 | 右上角时间选择器,支持精确到秒 |
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,143 @@
|
|||||||
|
# HoTime 框架优雅停机
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
HoTime 框架内置优雅停机支持,收到关闭信号后:
|
||||||
|
|
||||||
|
1. **立即**对所有新请求返回 `503 Service Unavailable`,触发 nginx 把流量切到其他实例
|
||||||
|
2. **等待** `DrainTimeout`(默认 5 秒)让 nginx 完成流量切换
|
||||||
|
3. **等待**所有正在处理的请求完成(最多 `ShutdownTimeout`,默认 30 秒)
|
||||||
|
4. 以 `exit(0)` 正常退出,`run_xbc.cmd` 检测到正常退出后不自动重启
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 触发方式对照表
|
||||||
|
|
||||||
|
| 操作 | 平台 | 是否可优雅停机 | 说明 |
|
||||||
|
|---|---|:---:|---|
|
||||||
|
| `Ctrl+C` | Windows / Linux | ✅ | 发送 SIGINT / os.Interrupt |
|
||||||
|
| `kill <pid>` 或 `kill -15 <pid>` | Linux | ✅ | **推荐**,发送 SIGTERM |
|
||||||
|
| `kill -2 <pid>` | Linux | ✅ | 等同 Ctrl+C,发送 SIGINT |
|
||||||
|
| `taskkill /PID <pid>`(不加 /F) | Windows | ✅ | **推荐**,等同发送 Ctrl+C |
|
||||||
|
| 关闭控制台/exe 窗口 X 按钮 | Windows | ✅(≤5s) | CTRL_CLOSE_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_xbc.cmd 检测到 exit(0) 后不会自动重启
|
||||||
|
|
||||||
|
:: 4. 对端口 9999 重复以上步骤
|
||||||
|
netstat -ano | findstr :9999
|
||||||
|
taskkill /PID <pid2>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 查找进程 PID
|
||||||
|
pgrep -a xbc # 或
|
||||||
|
ps aux | grep xbc
|
||||||
|
|
||||||
|
# 2. 优雅关闭(发送 SIGTERM)
|
||||||
|
kill <pid>
|
||||||
|
|
||||||
|
# 3. 观察日志确认关闭完成
|
||||||
|
tail -f logs/xbc.log # 等待"服务已安全关闭"
|
||||||
|
|
||||||
|
# 4. 部署新版并启动,再对另一个实例重复
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 工作原理
|
||||||
|
|
||||||
|
```
|
||||||
|
收到信号 / 关窗口
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
shuttingDown = true
|
||||||
|
│
|
||||||
|
▼ DrainTimeout(默认 5s)
|
||||||
|
新请求 → 503 ──────────────────────► nginx 切流到另一台
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
server.Shutdown(ShutdownTimeout=30s)
|
||||||
|
等待所有进行中请求跑完
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
os.Exit(0) → run_xbc.cmd 检测到 exit(0),不重启
|
||||||
|
```
|
||||||
|
|
||||||
|
### 为何在框架层而非应用层实现
|
||||||
|
|
||||||
|
通过 `appIns.Run(Router{...})` 启动服务,信号处理与 `http.Server` 生命周期强绑定,必须在框架的 `Run()` 方法内才能正确管理。应用层无需任何修改,升级框架即自动获得优雅停机能力。
|
||||||
|
|
||||||
|
### `sync.Once` 保证幂等性
|
||||||
|
|
||||||
|
框架同时监听多路触发源(信号 goroutine + Windows 关窗口 handler),`sync.Once` 保证无论哪路先触发,`initiateGracefulShutdown` 只执行一次,不会重复调用 `Shutdown()`。
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package baidu
|
package baidu
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
. "code.hoteas.com/golang/hotime/common"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -21,6 +22,93 @@ func (that *baiduMap) Init(Ak string) {
|
|||||||
//query
|
//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 获取定位列表
|
// GetPosition 获取定位列表
|
||||||
func (that *baiduMap) GetPosition(name string, region string) (string, error) {
|
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)
|
return that.send(that.TzUrl, umobleStr, tpt)
|
||||||
}
|
}
|
||||||
|
|
||||||
//发送短信
|
// 发送短信
|
||||||
func (that *dingdongyun) send(mUrl string, umoblie string, content string) (bool, error) {
|
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}}
|
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
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
//调用url发送短信的连接
|
// 调用url发送短信的连接
|
||||||
func (that *dingdongyun) httpsPostForm(url string, data url.Values) (string, error) {
|
func (that *dingdongyun) httpsPostForm(url string, data url.Values) (string, error) {
|
||||||
resp, err := http.PostForm(url, data)
|
resp, err := http.PostForm(url, data)
|
||||||
|
|
||||||
|
|||||||
@@ -18,13 +18,13 @@ func Down(url, path, name string, e ...*Error) bool {
|
|||||||
}
|
}
|
||||||
out, err := os.Create(path + name)
|
out, err := os.Create(path + name)
|
||||||
|
|
||||||
if err != nil && e[0] != nil {
|
if err != nil && len(e) != 0 {
|
||||||
e[0].SetError(err)
|
e[0].SetError(err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
defer out.Close()
|
defer out.Close()
|
||||||
resp, err := http.Get(url)
|
resp, err := http.Get(url)
|
||||||
if err != nil && e[0] != nil {
|
if err != nil && len(e) != 0 {
|
||||||
e[0].SetError(err)
|
e[0].SetError(err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -32,7 +32,7 @@ func Down(url, path, name string, e ...*Error) bool {
|
|||||||
|
|
||||||
pix, err := ioutil.ReadAll(resp.Body)
|
pix, err := ioutil.ReadAll(resp.Body)
|
||||||
_, err = io.Copy(out, bytes.NewReader(pix))
|
_, err = io.Copy(out, bytes.NewReader(pix))
|
||||||
if err != nil && e[0] != nil {
|
if err != nil && len(e) != 0 {
|
||||||
e[0].SetError(err)
|
e[0].SetError(err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package mongodb
|
||||||
|
|
||||||
|
import (
|
||||||
|
. "code.hoteas.com/golang/hotime/common"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo/options"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MongoDb struct {
|
||||||
|
Client *mongo.Client
|
||||||
|
Ctx context.Context
|
||||||
|
DataBase *mongo.Database
|
||||||
|
Connect *mongo.Collection
|
||||||
|
LastErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetMongoDb(database, url string) (*MongoDb, error) {
|
||||||
|
db := MongoDb{}
|
||||||
|
clientOptions := options.Client().ApplyURI(url)
|
||||||
|
|
||||||
|
db.Ctx = context.TODO()
|
||||||
|
// Connect to MongoDb
|
||||||
|
var err error
|
||||||
|
db.Client, err = mongo.Connect(db.Ctx, clientOptions)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Check the connection
|
||||||
|
err = db.Client.Ping(db.Ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
fmt.Println("Connected to MongoDb!")
|
||||||
|
//databases, err := db.Client.ListDatabaseNames(db.Ctx, bson.M{})
|
||||||
|
//if err != nil {
|
||||||
|
// return nil, err
|
||||||
|
//}
|
||||||
|
//fmt.Println(databases)
|
||||||
|
db.DataBase = db.Client.Database(database)
|
||||||
|
return &db, nil
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (that *MongoDb) Insert(table string, data interface{}) string {
|
||||||
|
collection := that.DataBase.Collection(table)
|
||||||
|
re, err := collection.InsertOne(that.Ctx, data)
|
||||||
|
if err != nil {
|
||||||
|
that.LastErr = err
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return ObjToStr(re.InsertedID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (that *MongoDb) InsertMany(table string, data ...interface{}) Slice {
|
||||||
|
collection := that.DataBase.Collection(table)
|
||||||
|
re, err := collection.InsertMany(that.Ctx, data)
|
||||||
|
if err != nil {
|
||||||
|
that.LastErr = err
|
||||||
|
return Slice{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ObjToSlice(re.InsertedIDs)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (that *MongoDb) Update(table string, data Map, where Map) int64 {
|
||||||
|
collection := that.DataBase.Collection(table)
|
||||||
|
re, err := collection.UpdateMany(that.Ctx, where, data)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
that.LastErr = err
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return re.ModifiedCount
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (that *MongoDb) Delete(table string, where Map) int64 {
|
||||||
|
collection := that.DataBase.Collection(table)
|
||||||
|
re, err := collection.DeleteMany(that.Ctx, where)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
that.LastErr = err
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return re.DeletedCount
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (that *MongoDb) Get(table string, where Map) Map {
|
||||||
|
results := []Map{}
|
||||||
|
var cursor *mongo.Cursor
|
||||||
|
var err error
|
||||||
|
collection := that.DataBase.Collection(table)
|
||||||
|
if cursor, err = collection.Find(that.Ctx, where, options.Find().SetSkip(0), options.Find().SetLimit(2)); err != nil {
|
||||||
|
that.LastErr = err
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
//延迟关闭游标
|
||||||
|
defer func() {
|
||||||
|
if err := cursor.Close(that.Ctx); err != nil {
|
||||||
|
that.LastErr = err
|
||||||
|
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
//这里的结果遍历可以使用另外一种更方便的方式:
|
||||||
|
if err = cursor.All(that.Ctx, &results); err != nil {
|
||||||
|
that.LastErr = err
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(results) > 0 {
|
||||||
|
|
||||||
|
return results[0]
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (that MongoDb) Select(table string, where Map, page, pageRow int64) []Map {
|
||||||
|
page = (page - 1) * pageRow
|
||||||
|
if page < 0 {
|
||||||
|
page = 0
|
||||||
|
}
|
||||||
|
results := []Map{}
|
||||||
|
var cursor *mongo.Cursor
|
||||||
|
var err error
|
||||||
|
collection := that.DataBase.Collection(table)
|
||||||
|
if cursor, err = collection.Find(that.Ctx, where, options.Find().SetSkip(page), options.Find().SetLimit(pageRow)); err != nil {
|
||||||
|
that.LastErr = err
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
//延迟关闭游标
|
||||||
|
defer func() {
|
||||||
|
if err := cursor.Close(that.Ctx); err != nil {
|
||||||
|
that.LastErr = err
|
||||||
|
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
//这里的结果遍历可以使用另外一种更方便的方式:
|
||||||
|
|
||||||
|
if err = cursor.All(that.Ctx, &results); err != nil {
|
||||||
|
that.LastErr = err
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
"github.com/silenceper/wechat/v2/officialaccount/oauth"
|
"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 {
|
type h5Program struct {
|
||||||
Memory *cache.Memory
|
Memory *cache.Memory
|
||||||
Config *h5config.Config
|
Config *h5config.Config
|
||||||
|
|||||||
+2
-2
@@ -9,8 +9,8 @@ import (
|
|||||||
"time"
|
"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 {
|
type wxpay struct {
|
||||||
client *wechat.ClientV3
|
client *wechat.ClientV3
|
||||||
ctx context.Context
|
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)
|
||||||
|
}
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
. "code.hoteas.com/golang/hotime"
|
|
||||||
. "code.hoteas.com/golang/hotime/common"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
var ArticleCtr = Ctr{
|
|
||||||
"info": func(that *Context) {
|
|
||||||
sn := that.Req.FormValue("sn")
|
|
||||||
article := that.Db.Get("article", Map{"[><]ctg_article": "article.id=ctg_article.article_id"}, "article.*,ctg_article.ctg_id AS sctg_id", Map{"ctg_article.sn": sn})
|
|
||||||
if article == nil {
|
|
||||||
that.Display(4, "找不到对应数据")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctgId := article.GetCeilInt64("sctg_id")
|
|
||||||
ctg := that.Db.Get("ctg", "*", Map{"id": ctgId})
|
|
||||||
parents := []Map{}
|
|
||||||
parentId := ctg.GetCeilInt64("parent_id")
|
|
||||||
article["tongji"] = that.Db.Select("ctg", "sn,name,img,parent_id", Map{"parent_id": parentId})
|
|
||||||
for true {
|
|
||||||
|
|
||||||
if parentId == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
parent := that.Db.Get("ctg", "sn,name,img,parent_id", Map{"id": parentId})
|
|
||||||
if parent == nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
parents = append(parents, parent)
|
|
||||||
parentId = parent.GetCeilInt64("parent_id")
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
ctg["parents"] = parents
|
|
||||||
|
|
||||||
article["ctg"] = ctg
|
|
||||||
that.Display(0, article)
|
|
||||||
},
|
|
||||||
"list": func(that *Context) {
|
|
||||||
sn := that.Req.FormValue("ctg_sn") //ctgsn
|
|
||||||
page := ObjToInt(that.Req.FormValue("page"))
|
|
||||||
pageSize := ObjToInt(that.Req.FormValue("pageSize"))
|
|
||||||
|
|
||||||
if page == 0 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
if pageSize == 0 {
|
|
||||||
pageSize = 10
|
|
||||||
}
|
|
||||||
|
|
||||||
keywords := that.Req.FormValue("keywords")
|
|
||||||
|
|
||||||
lunbo := ObjToInt(that.Req.FormValue("lunbo"))
|
|
||||||
|
|
||||||
sort := that.Req.FormValue("sort")
|
|
||||||
|
|
||||||
where := Map{"article.push_time[<]": time.Now().Format("2006-01-02 15:04"), "article.state": 0}
|
|
||||||
if sn != "" {
|
|
||||||
ctg := that.Db.Get("ctg", "id", Map{"sn": sn})
|
|
||||||
if ctg != nil {
|
|
||||||
where["ctg_article.ctg_id"] = ctg.GetCeilInt("id")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
startTime := that.Req.FormValue("start_time") //ctgsn
|
|
||||||
finishTime := that.Req.FormValue("finish_time") //ctgsn
|
|
||||||
|
|
||||||
if lunbo != 0 {
|
|
||||||
where["article.lunbo"] = lunbo
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(startTime) > 5 {
|
|
||||||
where["article.push_time[>=]"] = startTime
|
|
||||||
}
|
|
||||||
if len(finishTime) > 5 {
|
|
||||||
where["article.push_time[<=]"] = finishTime
|
|
||||||
}
|
|
||||||
|
|
||||||
if keywords != "" {
|
|
||||||
where["OR"] = Map{"article.title[~]": keywords, "article.description[~]": keywords, "article.author[~]": keywords, "article.sn[~]": keywords, "article.origin[~]": keywords, "article.url[~]": keywords}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(where) > 1 {
|
|
||||||
where = Map{"AND": where}
|
|
||||||
}
|
|
||||||
|
|
||||||
if sort == "" {
|
|
||||||
where["ORDER"] = Slice{"article.sort DESC", "article.push_time DESC"}
|
|
||||||
}
|
|
||||||
|
|
||||||
if sort == "time" {
|
|
||||||
where["ORDER"] = "article.push_time DESC"
|
|
||||||
}
|
|
||||||
count := that.Db.Count("article", Map{"[><]ctg_article": "article.id=ctg_article.article_id"}, where)
|
|
||||||
article := that.Db.Page(page, pageSize).PageSelect("article", Map{"[><]ctg_article": "article.id=ctg_article.article_id"}, "ctg_article.sn,article.img,article.title,article.description,article.push_time,article.lunbo,article.author,article.origin,article.url", where)
|
|
||||||
|
|
||||||
that.Display(0, Map{"count": count, "data": article})
|
|
||||||
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -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",
|
||||||
|
})
|
||||||
|
}},
|
||||||
|
}
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
. "code.hoteas.com/golang/hotime"
|
|
||||||
. "code.hoteas.com/golang/hotime/common"
|
|
||||||
)
|
|
||||||
|
|
||||||
var CtgCtr = Ctr{
|
|
||||||
"info": func(that *Context) {
|
|
||||||
sn := that.Req.FormValue("sn")
|
|
||||||
ctg := that.Db.Get("ctg", "*", Map{"sn": sn})
|
|
||||||
parents := []Map{}
|
|
||||||
parentId := ctg.GetCeilInt64("parent_id")
|
|
||||||
ctg["tongji"] = that.Db.Select("ctg", "sn,name,img,parent_id", Map{"parent_id": parentId})
|
|
||||||
for true {
|
|
||||||
|
|
||||||
if parentId == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
parent := that.Db.Get("ctg", "sn,name,img,parent_id", Map{"id": parentId})
|
|
||||||
if parent == nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
parents = append(parents, parent)
|
|
||||||
parentId = parent.GetCeilInt64("parent_id")
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
if ctg.GetCeilInt64("article_id") != 0 {
|
|
||||||
ctg["article"] = that.Db.Get("article", "*", Map{"id": ctg.GetCeilInt64("article_id")})
|
|
||||||
}
|
|
||||||
|
|
||||||
ctg["parents"] = parents
|
|
||||||
that.Display(0, ctg)
|
|
||||||
},
|
|
||||||
"list": func(that *Context) {
|
|
||||||
sn := that.Req.FormValue("sn") //ctgsn
|
|
||||||
page := ObjToInt(that.Req.FormValue("page"))
|
|
||||||
pageSize := ObjToInt(that.Req.FormValue("pageSize"))
|
|
||||||
|
|
||||||
if page == 0 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
if pageSize == 0 {
|
|
||||||
pageSize = 50
|
|
||||||
}
|
|
||||||
|
|
||||||
keywords := that.Req.FormValue("keywords")
|
|
||||||
|
|
||||||
//sort:=that.Req.FormValue("sort")
|
|
||||||
|
|
||||||
where := Map{"state": 0}
|
|
||||||
if sn != "" {
|
|
||||||
ctg := that.Db.Get("ctg", "id", Map{"sn": sn})
|
|
||||||
if ctg != nil {
|
|
||||||
where["parent_id"] = ctg.GetCeilInt("id")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if keywords != "" {
|
|
||||||
where["OR"] = Map{"name[~]": keywords, "url[~]": keywords, "sn[~]": keywords}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(where) > 1 {
|
|
||||||
where = Map{"AND": where}
|
|
||||||
}
|
|
||||||
|
|
||||||
where["ORDER"] = Slice{"sort DESC", "id DESC"}
|
|
||||||
|
|
||||||
article := that.Db.Page(page, pageSize).PageSelect("ctg", "name,sn,sort,url,img", where)
|
|
||||||
|
|
||||||
that.Display(0, article)
|
|
||||||
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -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"})
|
||||||
|
}},
|
||||||
|
}
|
||||||
+6
-120
@@ -2,126 +2,12 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
. "code.hoteas.com/golang/hotime"
|
. "code.hoteas.com/golang/hotime"
|
||||||
. "code.hoteas.com/golang/hotime/common"
|
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var AppProj = Proj{
|
var Project = Proj{
|
||||||
"article": ArticleCtr,
|
"test": TestCtr,
|
||||||
"org": OrgCtr,
|
"mysql": MysqlCtr,
|
||||||
"ctg": CtgCtr,
|
"dmdb": DmdbCtr,
|
||||||
"mail": MailCtr,
|
"cache": CacheCtr,
|
||||||
"test": {
|
"expect_demo": ExpectDemoCtr,
|
||||||
"test": func(that *Context) {
|
|
||||||
//data:=that.Db.Table("admin").Order("id DESC").Select("*")
|
|
||||||
//data1:=that.Db.Table("admin").Where(Map{"name[~]":"m"}).Order("id DESC").Select("*")
|
|
||||||
//
|
|
||||||
//data3:=that.Db.Select("admin","*",Map{"name[~]":"m"})
|
|
||||||
data2 := that.Db.Table("article").Where(Map{"title[~]": "m"}).Order("id DESC").Page(1, 10).Select("*")
|
|
||||||
c := that.Db.Table("article").Where(Map{"title[~]": "m"}).Order("id DESC").Group("title").Select("*")
|
|
||||||
//that.Display(0,Slice{data1,data,data3,data2})
|
|
||||||
that.Display(0, Slice{data2, c})
|
|
||||||
},
|
|
||||||
"res": func(that *Context) {
|
|
||||||
ebw_res := that.Db.Select("ebw_res", "*")
|
|
||||||
|
|
||||||
for _, v := range ebw_res {
|
|
||||||
data := Map{"id": v.GetCeilInt("id"), "name": v.GetString("name"),
|
|
||||||
"parent_id": v.GetCeilInt64("pid"),
|
|
||||||
"sn": v.GetString("url"), "create_time": time.Now().Format("2006-01-02 15:04"),
|
|
||||||
"modify_time": time.Now().Format("2006-01-02 15:04"), "admin_id": 1}
|
|
||||||
if data.GetCeilInt("parent_id") == 0 {
|
|
||||||
data["parent_id"] = nil
|
|
||||||
}
|
|
||||||
that.Db.Insert("ctg", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
that.Db.Exec("UPDATE ctg SET parent_id =NULL WHERE parent_id=id")
|
|
||||||
|
|
||||||
ss(0, that)
|
|
||||||
|
|
||||||
that.Display(0, len(ebw_res))
|
|
||||||
|
|
||||||
},
|
|
||||||
"news": func(that *Context) {
|
|
||||||
ebw_news := that.Db.Select("ebw_news", "*")
|
|
||||||
|
|
||||||
for _, v := range ebw_news {
|
|
||||||
ctg := that.Db.Get("ctg", "*", Map{"sn": v.GetString("type")})
|
|
||||||
data := Map{"sn": v.GetString("id"), "title": v.GetString("title"),
|
|
||||||
"content": v.GetString("content"), "push_time": v.GetString("timedate"),
|
|
||||||
"author": v.GetString("owner"), "origin": v.GetString("source"), "click_num": v.GetString("readtime"),
|
|
||||||
"sort": v.GetCeilInt("zhiding"), "create_time": time.Now().Format("2006-01-02 15:04"),
|
|
||||||
"modify_time": time.Now().Format("2006-01-02 15:04"), "admin_id": 1}
|
|
||||||
if ctg != nil {
|
|
||||||
data["ctg_id"] = ctg.GetCeilInt("id")
|
|
||||||
}
|
|
||||||
that.Db.Insert("article", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
that.Display(0, len(ebw_news))
|
|
||||||
|
|
||||||
},
|
|
||||||
"res2news": func(that *Context) {
|
|
||||||
ebw_news_addition_res := that.Db.Select("ebw_news_addition_res", "*")
|
|
||||||
|
|
||||||
for _, v := range ebw_news_addition_res {
|
|
||||||
ctg := that.Db.Get("ctg", "*", Map{"sn": v.GetString("fk_res")})
|
|
||||||
article := that.Db.Get("article", "*", Map{"sn": v.GetString("fk_newsid")})
|
|
||||||
data := Map{"sn": Md5(ObjToStr(time.Now().UnixNano()) + ObjToStr(RandX(10000, 100000))),
|
|
||||||
"create_time": time.Now().Format("2006-01-02 15:04"),
|
|
||||||
"modify_time": time.Now().Format("2006-01-02 15:04"), "admin_id": 1}
|
|
||||||
if ctg != nil {
|
|
||||||
data["ctg_id"] = ctg.GetCeilInt("id")
|
|
||||||
}
|
|
||||||
if article != nil {
|
|
||||||
data["article_id"] = article.GetCeilInt("id")
|
|
||||||
}
|
|
||||||
that.Db.Insert("ctg_article", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
that.Display(0, len(ebw_news_addition_res))
|
|
||||||
},
|
|
||||||
//将文章没有关联的ctg_article进行关联
|
|
||||||
"article": func(that *Context) {
|
|
||||||
articles := that.Db.Select("article", "id,ctg_id")
|
|
||||||
for _, v := range articles {
|
|
||||||
ctg_article := that.Db.Get("ctg_article", "id", Map{"article_id": v.GetCeilInt("id")})
|
|
||||||
if ctg_article == nil {
|
|
||||||
|
|
||||||
data := Map{"sn": Md5(ObjToStr(time.Now().UnixNano()) + ObjToStr(RandX(10000, 100000))),
|
|
||||||
"create_time": time.Now().Format("2006-01-02 15:04"),
|
|
||||||
"modify_time": time.Now().Format("2006-01-02 15:04"), "admin_id": 1}
|
|
||||||
if v.GetCeilInt("ctg_id") == 0 || v.GetCeilInt("id") == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
data["ctg_id"] = v.GetCeilInt("ctg_id")
|
|
||||||
data["article_id"] = v.GetCeilInt("id")
|
|
||||||
that.Db.Insert("ctg_article", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
that.Display(0, len(articles))
|
|
||||||
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func ss(parent_id int, that *Context) {
|
|
||||||
var ctgs []Map
|
|
||||||
ctg := that.Db.Get("ctg", "*", Map{"id": parent_id})
|
|
||||||
if parent_id == 0 {
|
|
||||||
ctgs = that.Db.Select("ctg", "*", Map{"parent_id": nil})
|
|
||||||
} else {
|
|
||||||
ctgs = that.Db.Select("ctg", "*", Map{"parent_id": parent_id})
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, v := range ctgs {
|
|
||||||
if ctg == nil {
|
|
||||||
ctg = Map{"parent_ids": ","}
|
|
||||||
}
|
|
||||||
ids := ctg.GetString("parent_ids") + ObjToStr(v.GetCeilInt("id")) + ","
|
|
||||||
that.Db.Update("ctg", Map{"parent_ids": ids}, Map{"id": v.GetCeilInt("id")})
|
|
||||||
ss(v.GetCeilInt("id"), that)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
. "code.hoteas.com/golang/hotime"
|
|
||||||
. "code.hoteas.com/golang/hotime/common"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
var MailCtr = Ctr{
|
|
||||||
"add": func(that *Context) {
|
|
||||||
title := that.Req.FormValue("title")
|
|
||||||
name := that.Req.FormValue("name")
|
|
||||||
phone := that.Req.FormValue("phone")
|
|
||||||
content := that.Req.FormValue("content")
|
|
||||||
|
|
||||||
tp := ObjToInt(that.Req.FormValue("type"))
|
|
||||||
show := ObjToInt(that.Req.FormValue("show"))
|
|
||||||
|
|
||||||
if len(title) < 5 {
|
|
||||||
that.Display(3, "标题过短")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(name) < 2 {
|
|
||||||
that.Display(3, "姓名错误")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(phone) < 8 {
|
|
||||||
that.Display(3, "联系方式错误")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(content) < 10 {
|
|
||||||
that.Display(3, "内容过短")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
data := Map{
|
|
||||||
"sn": Md5(ObjToStr(time.Now().UnixNano()) + ObjToStr(RandX(10000, 100000))),
|
|
||||||
"name": name, "title": title, "phone": phone, "content": content, "type": tp, "show": show,
|
|
||||||
"modify_time[#]": "NOW()", "create_time[#]": "NOW()",
|
|
||||||
}
|
|
||||||
id := that.Db.Insert("mail", data)
|
|
||||||
if id == 0 {
|
|
||||||
that.Display(4, "创建失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
that.Display(0, "成功")
|
|
||||||
return
|
|
||||||
|
|
||||||
},
|
|
||||||
"info": func(that *Context) {
|
|
||||||
sn := that.Req.FormValue("sn")
|
|
||||||
|
|
||||||
mail := that.Db.Get("mail", "*", Map{"sn": sn})
|
|
||||||
|
|
||||||
that.Display(0, mail)
|
|
||||||
},
|
|
||||||
"list": func(that *Context) {
|
|
||||||
page := ObjToInt(that.Req.FormValue("page"))
|
|
||||||
pageSize := ObjToInt(that.Req.FormValue("pageSize"))
|
|
||||||
|
|
||||||
if page == 0 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
if pageSize == 0 {
|
|
||||||
pageSize = 10
|
|
||||||
}
|
|
||||||
|
|
||||||
//keywords:=that.Req.FormValue("keywords")
|
|
||||||
|
|
||||||
//sort:=that.Req.FormValue("sort")
|
|
||||||
|
|
||||||
where := Map{"state": 0, "show": 1}
|
|
||||||
|
|
||||||
//if keywords!=""{
|
|
||||||
// where["OR"]=Map{"title[~]":keywords,"description[~]":keywords,"author[~]":keywords,"sn[~]":keywords,"origin[~]":keywords,"url[~]":keywords}
|
|
||||||
//}
|
|
||||||
|
|
||||||
if len(where) > 1 {
|
|
||||||
where = Map{"AND": where}
|
|
||||||
}
|
|
||||||
|
|
||||||
//if sort==""{
|
|
||||||
// where["ORDER"]=Slice{"sort DESC","push_time DESC"}
|
|
||||||
//}
|
|
||||||
//
|
|
||||||
//if sort=="time"{
|
|
||||||
where["ORDER"] = "create_time DESC"
|
|
||||||
//}
|
|
||||||
count := that.Db.Count("mail", where)
|
|
||||||
mail := that.Db.Page(page, pageSize).PageSelect("mail", "*", where)
|
|
||||||
|
|
||||||
that.Display(0, Map{"count": count, "data": mail})
|
|
||||||
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -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}},
|
||||||
|
})
|
||||||
|
}},
|
||||||
|
}
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
. "code.hoteas.com/golang/hotime"
|
|
||||||
. "code.hoteas.com/golang/hotime/common"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
var OrgCtr = Ctr{
|
|
||||||
"info": func(that *Context) {
|
|
||||||
sn := that.Req.FormValue("sn")
|
|
||||||
article := that.Db.Get("article", "*", Map{"sn": sn})
|
|
||||||
that.Display(0, article)
|
|
||||||
},
|
|
||||||
"list": func(that *Context) {
|
|
||||||
sn := that.Req.FormValue("sn") //orgsn
|
|
||||||
page := ObjToInt(that.Req.FormValue("page"))
|
|
||||||
pageSize := ObjToInt(that.Req.FormValue("pageSize"))
|
|
||||||
|
|
||||||
if page == 0 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
if pageSize == 0 {
|
|
||||||
pageSize = 50
|
|
||||||
}
|
|
||||||
|
|
||||||
keywords := that.Req.FormValue("keywords")
|
|
||||||
|
|
||||||
sort := that.Req.FormValue("sort")
|
|
||||||
|
|
||||||
where := Map{"push_time[<=]": time.Now().Format("2006-01-02 15:04"), "state": 0}
|
|
||||||
if sn != "" {
|
|
||||||
org := that.Db.Get("org", "id", Map{"sn": sn})
|
|
||||||
if org != nil {
|
|
||||||
where["org_id"] = org.GetCeilInt("id")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if keywords != "" {
|
|
||||||
where["OR"] = Map{"title[~]": keywords, "description[~]": keywords, "author[~]": keywords, "sn[~]": keywords, "origin[~]": keywords, "url[~]": keywords}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(where) > 1 {
|
|
||||||
where = Map{"AND": where}
|
|
||||||
}
|
|
||||||
|
|
||||||
if sort == "" {
|
|
||||||
where["ORDER"] = Slice{"sort DESC", "id DESC"}
|
|
||||||
}
|
|
||||||
|
|
||||||
if sort == "time" {
|
|
||||||
where["ORDER"] = "push_time DESC"
|
|
||||||
}
|
|
||||||
|
|
||||||
article := that.Db.Page(page, pageSize).PageSelect("article", "sn,title,description,push_time,lunbo,author,origin,url", where)
|
|
||||||
|
|
||||||
that.Display(0, article)
|
|
||||||
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user