Compare commits
83 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8265e24add | |||
| 283985df52 | |||
| 95adca04bd | |||
| 87039524a7 | |||
| 66cd21c35f | |||
| 7e64131931 | |||
| e30d40cfbb | |||
| 751ed0003d | |||
| 6d9f89a1d4 | |||
| bc329be03b | |||
| 4597a0a50d | |||
| 3dda86d170 | |||
| d847da7591 | |||
| e1eca90835 | |||
| 9283b8c284 | |||
| d8d9afa4d2 | |||
| 4d3829c345 | |||
| a23037a437 | |||
| 181295e54e | |||
| 8cc2499e21 | |||
| a2c49452b4 | |||
| ebf0df5ca8 | |||
| 73bf691ccc | |||
| b2e9701826 | |||
| 789b0a14d1 | |||
| 3ee64fcd8c | |||
| 527503b0d9 | |||
| a95b2fccd7 | |||
| 7e2abb43e3 | |||
| 48fd753a96 | |||
| 136232dd57 | |||
| 008f19355c | |||
| 1fc0a5bbbc | |||
| ce099b8fc8 | |||
| 9c00ac6ba1 | |||
| a5c002bbce | |||
| cf50a699a6 | |||
| 662003c0ca | |||
| f24de2562a | |||
| b84bd7e16f | |||
| 2fb6abf53a | |||
| 2eab29f428 | |||
| 84ee0d259f | |||
| eccab42fc8 | |||
| 1465ba36d3 | |||
| 8380b097b2 | |||
| 6b8f901163 | |||
| bb16f0a75b | |||
| 84dbc5d71e | |||
| 166eeee64c | |||
| 7a649fd652 | |||
| 94ae568526 | |||
| 9ec597f26c | |||
| 388bc5006d | |||
| 0e33c2ad9d | |||
| af726fbcfb | |||
| 1e82a5964d | |||
| d62ba8b0cd | |||
| 320ebe25ec | |||
| 68257d1742 | |||
| 80d167cb1b | |||
| cd911e8fa4 | |||
| faa6fcb00c | |||
| 836c1f461f | |||
| 97b2f54383 | |||
| 9d5f8438c5 | |||
| 3f7963e7cf | |||
| 269df85d27 | |||
| d28c6863e4 | |||
| bba4990ccc | |||
| c57afd6a5d | |||
| 25f355c8d0 | |||
| b1b0673e69 | |||
| 72c086d726 | |||
| 3810ba6c0b | |||
| 5c775b4539 | |||
| deb3e54cc3 | |||
| d1a8f6c668 | |||
| 2291ba7402 | |||
| 20390091f2 | |||
| 123d819b0f | |||
| 30e275a27d | |||
| 1a53f587e8 |
@@ -1,299 +0,0 @@
|
||||
---
|
||||
name: API接口测试方案
|
||||
overview: hotime 框架新增链式测试 API:数据在前方法在后,Post/Get 一步完成用例+断言,TestProj 合并注册,自动生成 Swagger UI。
|
||||
todos:
|
||||
- id: framework-testing-helper
|
||||
content: hotimev1.5/testing_helper.go:TestApp、NewTestApp、SetupForTest、TestRequest/WithSession、TestResponse
|
||||
status: pending
|
||||
- id: framework-testing-api
|
||||
content: hotimev1.5/testing_api.go:TestProj/ProjTest/CtrTest、Api(JSON/Query/Form/File/WithSession→Post/Get)、RunTests
|
||||
status: pending
|
||||
- id: framework-testing-swagger
|
||||
content: hotimev1.5/testing_swagger.go:GenerateSwagger 合并所有项目生成一份 Swagger
|
||||
status: pending
|
||||
- id: xbc-user-test
|
||||
content: xbc/app/user.go 末尾添加 UserTest,init.go 添加 ProjectTest,新增 app_test.go
|
||||
status: pending
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# API 接口测试 + Swagger 文档自动生成(最终版)
|
||||
|
||||
## 一、最终链式设计:数据在前,方法在后
|
||||
|
||||
每条链以 `Post/Get/Put/Delete("描述", 期望status, [期望msg])` 结尾,这个终端调用同时完成:命名用例 + 发送请求 + 校验结果 + 收集文档。
|
||||
|
||||
```go
|
||||
// 数据 → 方法(终端操作)
|
||||
a.JSON(Map{"name": "138", "password": "123"}).Post("正常登录", 0)
|
||||
a.JSON(Map{"name": "138", "password": ""}).Post("密码为空", 4, "用户名或密码不能为空")
|
||||
a.Get("未登录", 2, "请先登录")
|
||||
a.Query(Map{"shop_id": "1"}).Get("查询列表", 0)
|
||||
a.WithSession(Map{"user_id": int64(1)}).Get("已登录", 0)
|
||||
a.Query(Map{"token": "abc"}).JSON(Map{"phone": "138"}).Post("混合传参", 3, "异常")
|
||||
a.File("file", "a.png", imgBytes).Post("上传文件", 0)
|
||||
```
|
||||
|
||||
### 对比之前的写法
|
||||
|
||||
```
|
||||
之前(三步):a.PostCase("正常登录").JSON(Map{...}).Test(0)
|
||||
之前(两步):a.Post("正常登录", Map{...}).Test(0)
|
||||
现在(一步):a.JSON(Map{...}).Post("正常登录", 0) ← 终端操作只有一个
|
||||
现在(零数据):a.Get("未登录", 2, "请先登录") ← 最简一行
|
||||
```
|
||||
|
||||
## 二、完整场景对照
|
||||
|
||||
```go
|
||||
// POST JSON(最常见)
|
||||
a.JSON(Map{"name": "138", "password": "123"}).Post("正常登录", 0)
|
||||
|
||||
// GET 无参数
|
||||
a.Get("未登录", 2, "请先登录")
|
||||
|
||||
// GET + URL 参数
|
||||
a.Query(Map{"shop_id": "1", "page": "1"}).Get("查询列表", 0)
|
||||
|
||||
// 需登录 + GET
|
||||
a.WithSession(Map{"user_id": int64(1)}).Get("已登录", 0)
|
||||
|
||||
// 需登录 + GET + 参数
|
||||
a.WithSession(Map{"user_id": int64(1)}).Query(Map{"shop_id": "1"}).Get("查询", 0)
|
||||
|
||||
// 需登录 + POST JSON
|
||||
a.WithSession(Map{"user_id": int64(1)}).JSON(Map{"name": "new"}).Post("更新", 0)
|
||||
|
||||
// 混合:URL 参数 + JSON body
|
||||
a.Query(Map{"token": "abc"}).JSON(Map{"phone": "138"}).Post("混合传参", 3, "异常")
|
||||
|
||||
// POST Form 表单
|
||||
a.Form(Map{"shop_id": "1", "id": "1"}).Post("表单提交", 0)
|
||||
|
||||
// 文件上传
|
||||
a.File("file", "a.png", imgBytes).Post("上传文件", 0)
|
||||
|
||||
// 文件 + 表单字段
|
||||
a.File("file", "a.png", imgBytes).Form(Map{"type": "avatar"}).Post("上传+表单", 0)
|
||||
|
||||
// PUT JSON
|
||||
a.JSON(Map{"name": "新名字"}).Put("更新信息", 0)
|
||||
|
||||
// DELETE + 参数
|
||||
a.Query(Map{"id": "1"}).Delete("删除", 0)
|
||||
|
||||
// 校验响应数据(status=0 时)
|
||||
resp := a.JSON(Map{"name": "138", "password": "123"}).Post("正常登录", 0)
|
||||
if resp.GetBody().GetMap("result").GetString("token") == "" {
|
||||
resp.Fail("缺少 token")
|
||||
}
|
||||
```
|
||||
|
||||
## 三、完整示例:user.go
|
||||
|
||||
```go
|
||||
var UserCtr = Ctr{
|
||||
"login": func(that *Context) { /* ... */ },
|
||||
"info": func(that *Context) { /* ... */ },
|
||||
"token": func(that *Context) { /* ... */ },
|
||||
"create": func(that *Context) { /* ... */ },
|
||||
"file": func(that *Context) { /* ... */ },
|
||||
}
|
||||
|
||||
// ========= 接口测试 =========
|
||||
var UserTest = CtrTest{
|
||||
"login": {"用户账号密码登录", func(a *Api) {
|
||||
a.JSON(Map{"name": "13800138000", "password": "123456"}).Post("正常登录", 0)
|
||||
a.JSON(Map{"name": "13800138000", "password": ""}).Post("密码为空", 4, "用户名或密码不能为空")
|
||||
a.JSON(Map{"name": "1380013", "password": "123456"}).Post("手机号格式错误", 4, "手机号码格式错误")
|
||||
a.JSON(Map{"name": "13800138000", "password": "wrong"}).Post("密码错误", 4, "用户名或密码错误")
|
||||
}},
|
||||
"info": {"获取用户信息", func(a *Api) {
|
||||
a.Get("未登录", 2, "请先登录")
|
||||
a.WithSession(Map{"user_id": int64(1)}).Get("已登录", 0)
|
||||
}},
|
||||
"token": {"获取会话Token", func(a *Api) {
|
||||
a.JSON(Map{"phone": "13800138000"}).Post("正确手机号", 0)
|
||||
a.JSON(Map{"phone": "138"}).Post("手机号格式错误", 3, "请输入正确的手机号")
|
||||
}},
|
||||
"create": {"用户注册", func(a *Api) {
|
||||
phone := "138" + ObjToStr(RandX(10000000, 99999999))
|
||||
defer a.DB().Delete("user", Map{"phone": phone})
|
||||
|
||||
a.JSON(Map{"phone": phone, "password": "123456"}).Post("正常注册", 0)
|
||||
a.JSON(Map{"phone": phone, "password": "123456"}).Post("重复注册", 3, "该手机号已被注册")
|
||||
a.JSON(Map{"phone": "138", "password": "123456"}).Post("手机号格式错误", 3, "请输入正确的手机号")
|
||||
a.JSON(Map{"phone": "13899999999", "password": "12"}).Post("密码太短", 3, "密码不能少于6位")
|
||||
}},
|
||||
"forget": {"忘记密码", func(a *Api) {
|
||||
a.JSON(Map{"phone": "13800138000"}).Post("无token", 3, "异常")
|
||||
a.Query(Map{"token": "abc"}).JSON(Map{"phone": "138", "code": "1234"}).Post("手机号错误", 3, "请输入正确的手机号")
|
||||
}},
|
||||
"file": {"文件上传", func(a *Api) {
|
||||
a.File("file", "test.txt", []byte("hello")).Post("未登录上传", 2, "你还没有登录")
|
||||
a.WithSession(Map{"user_id": int64(1)}).File("file", "test.txt", []byte("hello")).Post("已登录上传", 0)
|
||||
}},
|
||||
}
|
||||
```
|
||||
|
||||
## 四、TestProj 注册 + app_test.go
|
||||
|
||||
### init.go
|
||||
|
||||
```go
|
||||
var Project = Proj{
|
||||
"user": UserCtr,
|
||||
"goods": GoodsCtr,
|
||||
// ...
|
||||
}
|
||||
|
||||
var ProjectTest = ProjTest{
|
||||
"user": UserTest,
|
||||
"goods": GoodsTest,
|
||||
}
|
||||
```
|
||||
|
||||
### app_test.go
|
||||
|
||||
```go
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
)
|
||||
|
||||
var testApp *TestApp
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
testApp = NewTestApp("../config/config.json", TestProj{
|
||||
"app": {Project, ProjectTest},
|
||||
})
|
||||
code := m.Run()
|
||||
testApp.GenerateSwagger("HoTime API", "2.1.0", testApp.Config.GetString("tpt"))
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func TestApi(t *testing.T) {
|
||||
testApp.RunTests(t)
|
||||
}
|
||||
```
|
||||
|
||||
## 五、Swagger:多项目合并为一份文档
|
||||
|
||||
`GenerateSwagger` 遍历 `TestProj` 中所有项目的所有测试数据,生成**一份合并的 OpenAPI JSON**。每个项目作为 Swagger 的 tag 分组:
|
||||
|
||||
```json
|
||||
{
|
||||
"tags": [
|
||||
{"name": "app/user", "description": "用户接口"},
|
||||
{"name": "app/goods", "description": "货品接口"},
|
||||
{"name": "customer/customer", "description": "客户端接口"}
|
||||
],
|
||||
"paths": {
|
||||
"/app/user/login": { ... },
|
||||
"/app/user/info": { ... },
|
||||
"/customer/customer/bindPhone": { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
多项目不会覆盖,全部汇聚在同一个文件中,按 tag 分组展示。
|
||||
|
||||
## 六、指定运行范围
|
||||
|
||||
Go 的 `-run` 参数支持按子测试层级过滤(`/` 分隔正则匹配):
|
||||
|
||||
```bash
|
||||
# 全部
|
||||
go test ./app/... -v
|
||||
|
||||
# 只跑 user 模块的所有接口
|
||||
go test ./app/... -v -run TestApi/user
|
||||
|
||||
# 只跑 user 模块的 login 接口
|
||||
go test ./app/... -v -run TestApi/user/login
|
||||
|
||||
# 只跑 login 接口的"密码为空"用例
|
||||
go test ./app/... -v -run TestApi/user/login/密码为空
|
||||
|
||||
# 跑 user 和 goods 两个模块
|
||||
go test ./app/... -v -run "TestApi/(user|goods)"
|
||||
|
||||
# 只跑所有模块中包含"未登录"的用例
|
||||
go test ./app/... -v -run "TestApi/.*/.*未登录"
|
||||
```
|
||||
|
||||
**不需要框架做任何额外处理**,Go 的 `t.Run()` 子测试机制天然支持。
|
||||
|
||||
## 七、框架类型设计
|
||||
|
||||
```go
|
||||
// === 注册类型 ===
|
||||
type TestProj map[string]TestProjDef
|
||||
type TestProjDef struct { Proj Proj; Tests ProjTest }
|
||||
type ProjTest map[string]CtrTest
|
||||
type CtrTest map[string]ApiTestDef
|
||||
type ApiTestDef struct { Desc string; Func func(a *Api) }
|
||||
|
||||
// === Api:起点,数据设置 ===
|
||||
type Api struct {
|
||||
app *TestApp
|
||||
path string
|
||||
t interface{}
|
||||
desc string
|
||||
session Map
|
||||
}
|
||||
|
||||
// 数据设置(返回 *ApiCase,开始构建)
|
||||
func (a *Api) JSON(body interface{}) *ApiCase
|
||||
func (a *Api) Query(params Map) *ApiCase
|
||||
func (a *Api) Form(body Map) *ApiCase
|
||||
func (a *Api) File(field, name string, content []byte) *ApiCase
|
||||
|
||||
// Session(返回新 *Api,后续调用都带此 Session)
|
||||
func (a *Api) WithSession(s Map) *Api
|
||||
|
||||
// 直接终端(无数据时直接调用)
|
||||
func (a *Api) Get(desc string, status int, msg ...string) *ApiResponse
|
||||
func (a *Api) Post(desc string, status int, msg ...string) *ApiResponse
|
||||
func (a *Api) Put(desc string, status int, msg ...string) *ApiResponse
|
||||
func (a *Api) Delete(desc string, status int, msg ...string) *ApiResponse
|
||||
|
||||
// 数据库
|
||||
func (a *Api) DB() *HoTimeDB
|
||||
|
||||
// === ApiCase:链式构建器 ===
|
||||
type ApiCase struct { /* api, session, query, jsonBody, formBody, file... */ }
|
||||
|
||||
// 继续叠加数据
|
||||
func (c *ApiCase) JSON(body interface{}) *ApiCase
|
||||
func (c *ApiCase) Query(params Map) *ApiCase
|
||||
func (c *ApiCase) Form(body Map) *ApiCase
|
||||
func (c *ApiCase) File(field, name string, content []byte) *ApiCase
|
||||
func (c *ApiCase) WithSession(s Map) *ApiCase
|
||||
|
||||
// 终端操作(HTTP 方法 + 用例名 + 断言)
|
||||
func (c *ApiCase) Get(desc string, status int, msg ...string) *ApiResponse
|
||||
func (c *ApiCase) Post(desc string, status int, msg ...string) *ApiResponse
|
||||
func (c *ApiCase) Put(desc string, status int, msg ...string) *ApiResponse
|
||||
func (c *ApiCase) Delete(desc string, status int, msg ...string) *ApiResponse
|
||||
|
||||
// === ApiResponse ===
|
||||
type ApiResponse struct { StatusCode int; Body Map; RawBody []byte }
|
||||
func (r *ApiResponse) GetStatus() int
|
||||
func (r *ApiResponse) GetResult() interface{}
|
||||
func (r *ApiResponse) GetMsg() string
|
||||
func (r *ApiResponse) GetBody() Map
|
||||
func (r *ApiResponse) Fail(msg string) // 自定义断言失败
|
||||
```
|
||||
|
||||
## 八、文件清单
|
||||
|
||||
- **新增** `d:/work/hotimev1.5/testing_helper.go`
|
||||
- **新增** `d:/work/hotimev1.5/testing_api.go`
|
||||
- **新增** `d:/work/hotimev1.5/testing_swagger.go`
|
||||
- **修改** `d:/work/your-app/app/user.go` -- 末尾添加 UserTest
|
||||
- **修改** `d:/work/your-app/app/init.go` -- 添加 ProjectTest
|
||||
- **新增** `d:/work/your-app/app/app_test.go`
|
||||
@@ -1,291 +0,0 @@
|
||||
---
|
||||
name: API测试框架完整实现
|
||||
overview: 在 hotimev1.5 框架中实现完整的 API 测试基础设施:链式测试 API、事务回滚隔离、覆盖率追踪、Swagger 文档自动生成。
|
||||
todos:
|
||||
- id: db-testtx
|
||||
content: db/db.go:加 testTx 字段 + BeginTestTx/RollbackTestTx 方法
|
||||
status: completed
|
||||
- id: db-query
|
||||
content: db/query.go:Query(行64) 和 Exec(行120) 加 testTx 优先判断
|
||||
status: completed
|
||||
- id: db-transaction
|
||||
content: db/transaction.go:Action 加 testTx 传递 + SAVEPOINT 分支
|
||||
status: completed
|
||||
- id: testing-helper
|
||||
content: 新增 testing_helper.go:TestApp、NewTestApp、SetupForTest、RunTests(事务隔离)、PrintCoverage
|
||||
status: completed
|
||||
- id: testing-api
|
||||
content: 新增 testing_api.go:注册类型 + Api/ApiCase/ApiResponse 链式 API + TestCollector
|
||||
status: completed
|
||||
- id: testing-swagger
|
||||
content: 新增 testing_swagger.go:GenerateSwagger 合并所有项目生成 Swagger
|
||||
status: completed
|
||||
- id: xbc-integration
|
||||
content: xbc 业务层:user.go 加 UserTest、init.go 加 ProjectTest、新增 app_test.go
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# API 测试框架完整实现
|
||||
|
||||
## 一、事务回滚隔离(db 层改造)
|
||||
|
||||
核心思路:给 `HoTimeDB` 增加 `testTx` 字段,优先级 `testTx > Tx > DB`。测试模式下 `Action()` 用 MySQL `SAVEPOINT` 代替真事务,保证嵌套事务都在外层测试事务内。
|
||||
|
||||
### 1. [db/db.go](d:/work/hotimev1.5/db/db.go) -- 加字段 + 方法
|
||||
|
||||
```go
|
||||
// HoTimeDB 结构体新增字段
|
||||
testTx *sql.Tx // 测试事务:设置后所有操作都在此事务内
|
||||
|
||||
// 新增两个方法
|
||||
func (that *HoTimeDB) BeginTestTx() error
|
||||
func (that *HoTimeDB) RollbackTestTx() error
|
||||
```
|
||||
|
||||
### 2. [db/query.go](d:/work/hotimev1.5/db/query.go) -- Query(行64) 和 Exec(行120) 各加一个判断
|
||||
|
||||
```go
|
||||
// 原:if that.Tx != nil { ... } else { ... }
|
||||
// 改:
|
||||
if that.testTx != nil {
|
||||
resl, err = that.testTx.Query(query, processedArgs...)
|
||||
} else if that.Tx != nil {
|
||||
resl, err = that.Tx.Query(query, processedArgs...)
|
||||
} else {
|
||||
resl, err = db.Query(query, processedArgs...)
|
||||
}
|
||||
// Exec 同理
|
||||
```
|
||||
|
||||
### 3. [db/transaction.go](d:/work/hotimev1.5/db/transaction.go) -- Action 加 SAVEPOINT 分支
|
||||
|
||||
```go
|
||||
func (that *HoTimeDB) Action(action func(db HoTimeDB) (isSuccess bool)) (isSuccess bool) {
|
||||
db := HoTimeDB{
|
||||
// ...现有字段拷贝...
|
||||
testTx: that.testTx, // 新增:传递 testTx
|
||||
}
|
||||
// 新增:测试模式用 SAVEPOINT
|
||||
if that.testTx != nil {
|
||||
spName := "sp_" + Md5(ObjToStr(RandX(100000, 999999)))[:8]
|
||||
_, _ = that.testTx.Exec("SAVEPOINT " + spName)
|
||||
db.Tx = that.testTx
|
||||
isSuccess = action(db)
|
||||
if !isSuccess {
|
||||
_, _ = that.testTx.Exec("ROLLBACK TO SAVEPOINT " + spName)
|
||||
} else {
|
||||
_, _ = that.testTx.Exec("RELEASE SAVEPOINT " + spName)
|
||||
}
|
||||
return isSuccess
|
||||
}
|
||||
// 以下原有逻辑不变...
|
||||
}
|
||||
```
|
||||
|
||||
### 3.1 testTx 并发保护(调试发现的关键问题)
|
||||
|
||||
**问题现象:** 测试运行时出现 `[mysql] busy buffer`、`bad connection` 错误,导致测试挂起。
|
||||
|
||||
**根因分析:** `testTx` 是单个 `*sql.Tx` 连接,MySQL 驱动不允许在同一连接上并发执行查询。业务代码中存在 `go func()` 启动的 goroutine(如 `syncGoodsToYst`、`stats.go` 中的统计协程),这些 goroutine 会并发访问 `testTx`,导致驱动层的 `busy buffer` 错误。
|
||||
|
||||
**解决方案:** 在 `HoTimeDB` 中新增 `testMu *sync.Mutex`,在 `BeginTestTx()` 时初始化。所有通过 `testTx` 执行的 Query、Exec、SAVEPOINT 操作都通过 `testMu` 加锁保护,确保串行化访问。
|
||||
|
||||
```go
|
||||
// db/db.go - HoTimeDB 新增字段
|
||||
testMu *sync.Mutex // 保护 testTx 单连接不被并发访问
|
||||
|
||||
// db/db.go - BeginTestTx 中初始化
|
||||
that.testMu = &sync.Mutex{}
|
||||
|
||||
// db/query.go - Query 中加锁
|
||||
if that.testTx != nil {
|
||||
if that.testMu != nil { that.testMu.Lock() }
|
||||
resl, err = that.testTx.Query(query, processedArgs...)
|
||||
// ... 消费 result set ...
|
||||
if that.testMu != nil { that.testMu.Unlock() }
|
||||
}
|
||||
|
||||
// db/query.go - Exec 中加锁
|
||||
if that.testTx != nil {
|
||||
if that.testMu != nil { that.testMu.Lock() }
|
||||
resl, e = that.testTx.Exec(query, processedArgs...)
|
||||
if that.testMu != nil { that.testMu.Unlock() }
|
||||
}
|
||||
|
||||
// db/transaction.go - SAVEPOINT 操作加锁
|
||||
if that.testMu != nil { that.testMu.Lock() }
|
||||
_, _ = that.testTx.Exec("SAVEPOINT " + spName)
|
||||
if that.testMu != nil { that.testMu.Unlock() }
|
||||
```
|
||||
|
||||
**注意事项:**
|
||||
- 生产环境不受影响(`testMu` 默认 nil,所有锁检查都有 nil 保护)
|
||||
- 互斥锁确保 goroutine 中的 DB 操作排队执行而非并发冲突
|
||||
- Query 锁的范围包括结果集消费(`that.Row(resl)`),防止未读完数据就发起新查询
|
||||
|
||||
**改动量:** 3 个文件,约 50 行核心改动 + mutex 保护约 30 行,生产代码零影响(testTx/testMu 默认 nil)。
|
||||
|
||||
### 3.2 测试模式下禁用 DB/Redis 缓存(调试发现的关键问题)
|
||||
|
||||
**问题现象:** 测试运行时出现 `Error 1205: Lock wait timeout exceeded` 错误,指向 `DELETE FROM cached` 操作。
|
||||
|
||||
**根因分析:** 框架的 `HoTimeCache` 支持三级缓存(memory > redis > db)。在测试模式下,DB 缓存的读写操作(如 `DELETE FROM cached`)会尝试使用独立连接,而 `testTx` 持有事务锁,导致 `Lock wait timeout`。即使走 `testTx`,缓存的并发读写也与事务隔离产生冲突。
|
||||
|
||||
**解决方案:**
|
||||
|
||||
1. 在 `cache/cache.go` 中新增 `DisableDbCache()` 方法:
|
||||
```go
|
||||
func (that *HoTimeCache) DisableDbCache() {
|
||||
that.dbCache = nil
|
||||
that.redisCache = nil
|
||||
}
|
||||
```
|
||||
|
||||
2. 在 `testing_helper.go` 的 `NewTestApp` 中调用:
|
||||
```go
|
||||
if app.HoTimeCache != nil {
|
||||
app.HoTimeCache.DisableDbCache()
|
||||
}
|
||||
```
|
||||
|
||||
3. 在 `db/crud.go` 的 `Select` 方法中,当 `testTx` 激活时跳过缓存逻辑:
|
||||
```go
|
||||
if that.testTx == nil {
|
||||
// 原有缓存读写逻辑
|
||||
}
|
||||
```
|
||||
|
||||
**影响范围:** 测试期间仅使用 memory 缓存,不影响生产环境。Session 在测试中通过 `WithSession()` 直接注入,不依赖 DB/Redis 缓存。
|
||||
|
||||
### 3.3 测试模式下 Query 的重试策略调整
|
||||
|
||||
**问题:** `queryWithRetry` 和 `execWithRetry` 在遇到连接错误时会调用 `db.Ping()` 尝试重连,但 `testTx` 是单连接事务,Ping 会创建新连接导致 `busy buffer`。
|
||||
|
||||
**解决:** 当 `testTx != nil` 时,跳过 `db.Ping()` 和重试逻辑,直接返回错误。
|
||||
|
||||
## 二、链式测试 API
|
||||
|
||||
### 4. 新增 [testing_helper.go](d:/work/hotimev1.5/testing_helper.go)
|
||||
|
||||
- `TestApp` 结构体(包含 `*Application`、`projs TestProj`、`collector *TestCollector`)
|
||||
- `NewTestApp(configPath, TestProj, listeners...)` -- 初始化 Application、注册路由,不启动 HTTP 服务
|
||||
- `SetupForTest(router Router)` -- 复用 `Run()` 的路由初始化逻辑
|
||||
- `TestRequest(method, path, body) TestResponse` / `TestRequestWithSession(...)` -- 通过 `httptest` 发请求
|
||||
- `TestResponse` 结构体
|
||||
- `RunTests(t)` -- 遍历 TestProj,每个方法级别 `BeginTestTx() + defer RollbackTestTx()`
|
||||
|
||||
### 5. 新增 [testing_api.go](d:/work/hotimev1.5/testing_api.go)
|
||||
|
||||
注册类型:
|
||||
|
||||
- `TestProj map[string]TestProjDef`
|
||||
- `TestProjDef struct { Proj Proj; Tests ProjTest }`
|
||||
- `ProjTest map[string]CtrTest`
|
||||
- `CtrTest map[string]ApiTestDef`
|
||||
- `ApiTestDef struct { Desc string; Func func(a *Api) }`
|
||||
|
||||
链式 API:
|
||||
|
||||
- `Api` -- 起点,提供 `JSON/Query/Form/File/WithSession` 数据设置和 `Get/Post/Put/Delete` 直接终端
|
||||
- `ApiCase` -- 链式构建器,支持叠加多种数据类型,终端操作 `Get/Post/Put/Delete(desc, status, msg...)`
|
||||
- `ApiResponse` -- 响应对象,提供 `GetStatus/GetResult/GetMsg/GetBody/Fail`
|
||||
- 每个终端方法内自动记录 `TestRecord` 到 `TestCollector`
|
||||
|
||||
### 6. 新增 [testing_swagger.go](d:/work/hotimev1.5/testing_swagger.go)
|
||||
|
||||
- `GenerateSwagger(title, version, outputDir)` -- 遍历 TestProj 生成合并的 OpenAPI 3.0 JSON + Swagger UI HTML
|
||||
- 利用覆盖率数据,未覆盖接口在文档中标注"暂无测试用例"
|
||||
|
||||
## 三、覆盖率追踪
|
||||
|
||||
集成在 `testing_helper.go` 中:
|
||||
|
||||
- `CoverageReport` / `MethodCoverage` / `TestRecord` / `TestCollector` 类型
|
||||
- `PrintCoverage()` -- 对比 `Proj`(所有路由)和 `ProjTest`(有测试的路由),输出覆盖率报告
|
||||
- 运行时追踪:每个 `Post/Get/Put/Delete` 终端方法自动收集 `TestRecord`(路径、用例名、通过/失败、耗时)
|
||||
- 最终输出:总接口数、已覆盖数、覆盖率百分比、每个接口的用例数和通过/失败计数
|
||||
|
||||
## 四、业务层接入(xbc 示例)
|
||||
|
||||
### 7. 修改 [app/app/user.go](d:/work/your-app/app/user.go) -- 末尾添加 `var UserTest = CtrTest{...}`
|
||||
|
||||
```go
|
||||
var UserTest = CtrTest{
|
||||
"login": {"用户登录", func(a *Api) {
|
||||
a.JSON(Map{"name": "138", "password": "123456"}).Post("正常登录", 0)
|
||||
// ...
|
||||
}},
|
||||
"create": {"用户注册", func(a *Api) {
|
||||
phone := "138" + ObjToStr(RandX(10000000, 99999999))
|
||||
// 不需要 defer 清理,事务自动回滚
|
||||
a.JSON(Map{"phone": phone, "password": "123456"}).Post("正常注册", 0)
|
||||
a.JSON(Map{"phone": phone, "password": "123456"}).Post("重复注册", 3, "该手机号已被注册")
|
||||
}},
|
||||
}
|
||||
```
|
||||
|
||||
### 8. 修改 [app/app/init.go](d:/work/your-app/app/init.go) -- 添加 `var ProjectTest = ProjTest{...}`
|
||||
|
||||
### 9. 新增 [app/app/app_test.go](d:/work/your-app/app/app_test.go)
|
||||
|
||||
```go
|
||||
func TestMain(m *testing.M) {
|
||||
testApp = NewTestApp("../config/config.json", TestProj{
|
||||
"app": {Project, ProjectTest},
|
||||
})
|
||||
code := m.Run()
|
||||
testApp.PrintCoverage()
|
||||
testApp.GenerateSwagger("HoTime API", "2.1.0", testApp.Config.GetString("tpt"))
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func TestApi(t *testing.T) {
|
||||
testApp.RunTests(t)
|
||||
}
|
||||
```
|
||||
|
||||
## 五、文件改动汇总
|
||||
|
||||
### hotimev1.5 框架层
|
||||
- **修改** `db/db.go` -- 加 testTx + testMu 字段,BeginTestTx(初始化 mutex)/RollbackTestTx
|
||||
- **修改** `db/query.go` -- Query/Exec 各加 testTx 判断 + testMu 互斥锁保护 + 跳过重试逻辑
|
||||
- **修改** `db/transaction.go` -- Action 加 SAVEPOINT 分支 + testTx/testMu 传递 + SAVEPOINT 操作加锁
|
||||
- **修改** `db/crud.go` -- Select 中 testTx 激活时跳过缓存逻辑
|
||||
- **修改** `cache/cache.go` -- 新增 DisableDbCache() 方法 + 新增 SessionsGet/SessionsSet/SessionsDelete 批量操作
|
||||
- **修改** `session.go` -- 对接批量 Session 缓存操作
|
||||
- **修改** `testing_helper.go` -- NewTestApp 中调用 DisableDbCache()
|
||||
- **新增** `testing_helper.go` -- TestApp、RunTests、覆盖率
|
||||
- **新增** `testing_api.go` -- 链式 API + TestCollector
|
||||
- **新增** `testing_swagger.go` -- Swagger 生成
|
||||
|
||||
### xbc 业务层
|
||||
- **修改** `app/user.go` -- 末尾添加 UserTest
|
||||
- **修改** `app/init.go` -- 添加 ProjectTest
|
||||
- **新增** `app/app_test.go` -- 测试入口(含 Swagger 生成)
|
||||
- **新增** `app/*_test.go` -- 30+ 个控制器测试文件
|
||||
- **新增** `wx/wx_test.go` + `wx/*_test.go` -- 微信端测试入口 + 7 个控制器测试
|
||||
- **新增** `dd/dd_test.go` + `dd/*_test.go` -- 钉钉端测试入口 + 6 个控制器测试
|
||||
- **新增** `customer/customer_h5_test.go` + `customer/*_test.go` -- H5 客户端测试
|
||||
- **新增** `sms/sms_test.go` + `sms/sms_ctr_test.go` -- 短信服务测试
|
||||
- **修改** `app/wechat.go` -- 修复 log.Println -> log.Printf(go vet 警告)
|
||||
- **修改** `customer/init.go` -- 修复 log.Println -> log.Printf(go vet 警告)
|
||||
|
||||
## 六、已知问题与调试经验
|
||||
|
||||
### 问题一:busy buffer / bad connection
|
||||
- **现象:** 测试运行数秒后出现 `[mysql] busy buffer`,随后 `bad connection`,测试挂起
|
||||
- **根因:** 业务代码中 `go func()` 启动的 goroutine(如 `app/goods.go:syncGoodsToYst`、`app/stats.go` 中的统计协程)并发访问 `testTx` 单连接
|
||||
- **解决:** 在 `HoTimeDB` 中新增 `testMu *sync.Mutex`,所有 testTx 的 Query/Exec/SAVEPOINT 操作加互斥锁
|
||||
- **验证方式:** 通过在 query.go 中插入带时间戳和 goroutine 计数的日志,观察到操作严格串行化,无重叠
|
||||
|
||||
### 问题二:Lock wait timeout
|
||||
- **现象:** `Error 1205: Lock wait timeout exceeded`,指向 `DELETE FROM cached`
|
||||
- **根因:** `HoTimeCache` 的 DB 缓存通过独立连接操作 `cached` 表,与 `testTx` 事务锁冲突
|
||||
- **解决:** 测试启动时调用 `DisableDbCache()` 禁用 DB/Redis 缓存,仅保留 memory 缓存
|
||||
|
||||
### 问题三:go vet 编译失败
|
||||
- **现象:** `go test` 因 `go vet` 警告 `log.Println call has possible Printf formatting directive %v` 而拒绝运行
|
||||
- **根因:** `app/wechat.go` 和 `customer/init.go` 中误用 `log.Println` 传入 `%v` 格式化指令
|
||||
- **解决:** 改为 `log.Printf`
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
---
|
||||
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` — 同上
|
||||
@@ -1,139 +0,0 @@
|
||||
---
|
||||
name: Fix caller detection
|
||||
overview: 修复三个日志/测试相关 bug:(1) 调用者智能识别漏掉 log/ 包自身 (2) 测试模式 SQL 出错不会导致用例失败 (3) 测试模式 WebConnectLog 写入已关闭的 NUL 文件
|
||||
todos:
|
||||
- id: fix-framework-detection
|
||||
content: 在 isHoTimeFrameworkFile 中 runtime/ 检查之后,添加 log/ 前缀检查
|
||||
status: completed
|
||||
- id: fix-sql-error-test-fail
|
||||
content: 测试模式 SQL 出错时自动让当前用例失败(db 增加错误计数 + execute 中检查)
|
||||
status: completed
|
||||
- id: fix-zerolog-nul-closed
|
||||
content: NewTestApp 中 Init 完成后将 WebConnectLog 置为 nil,避免写入已关闭的 NUL 文件
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# 修复日志/测试三个 Bug
|
||||
|
||||
---
|
||||
|
||||
## Bug 1:调用者智能识别始终显示 log/logger.go:318
|
||||
|
||||
### 根因
|
||||
|
||||
[log/logger.go](log/logger.go) 中的 `isHoTimeFrameworkFile` 函数对 `log/` 包自身存在识别盲区。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["应用代码 或 db/query.go 调用 Logger.Debug()"] --> B["zerolog Event.Msg()"]
|
||||
B --> C["callerMarshalFunc (log/logger.go:317)"]
|
||||
C --> D["findCaller 通过 runtime.Caller(i) 逐帧遍历调用栈"]
|
||||
D --> E{"isHoTimeFrameworkFile?"}
|
||||
E -->|"是框架文件 -> 跳过,继续向上"| D
|
||||
E -->|"非框架文件 -> 作为调用者返回"| F["显示在日志输出中"]
|
||||
```
|
||||
|
||||
`findCaller` 在 `i=1` 时拿到 `log/logger.go:318`。经过 `shortenPath` 截断后变成 `log/logger.go`。两条检测路径都漏掉了它:
|
||||
|
||||
- **路径一(第 392 行)**:`frameworkDirs` 包含 `"log/"`,但要求路径含 `"hotime"`。`shortenPath` 只保留 2 段路径,`log/logger.go` 中没有 `"hotime"`。**未命中。**
|
||||
- **路径二(第 410 行)**:`frameworkCoreDirs` 只有 `db/`、`common/`、`code/`、`cache/`,**没有 `"log/"`**。**未命中。**
|
||||
|
||||
### 修复
|
||||
|
||||
在 [log/logger.go](log/logger.go) 第 387-389 行 `runtime/` 检查之后加入 `log/` 前缀检查:
|
||||
|
||||
```go
|
||||
if strings.HasPrefix(file, "runtime/") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(file, "log/") {
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bug 2:测试模式 SQL 执行失败不会导致用例失败
|
||||
|
||||
### 根因
|
||||
|
||||
[db/query.go](db/query.go) `logSQL` 在测试模式下只是把 SQL 错误写入 `testLogBuf`,但 [testing_api.go](testing_api.go) 的 `execute` 中只在断言失败时才打印这些日志,**从不检查是否有 SQL 错误**。即使请求处理过程中数据库出错,只要 HTTP 响应的 status/msg 符合预期,测试就通过了。
|
||||
|
||||
当前流程:
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["ServeHTTP"] --> B["检查 status/msg/result"]
|
||||
B -->|"通过"| C["FlushTestLog 丢弃"]
|
||||
B -->|"失败"| D["FlushTestLog 打印"]
|
||||
```
|
||||
|
||||
### 修复
|
||||
|
||||
在 [db/db.go](db/db.go) 增加一个测试 SQL 错误计数器:
|
||||
|
||||
```go
|
||||
testSQLErrCount int32 // 测试模式下 SQL 错误计数(atomic)
|
||||
```
|
||||
|
||||
在 [db/query.go](db/query.go) `logSQL` 中,当 `err != nil` 且 `testLogBuf != nil` 时,`atomic.AddInt32(&that.testSQLErrCount, 1)`。
|
||||
|
||||
在 [db/db.go](db/db.go) 增加两个方法:
|
||||
|
||||
```go
|
||||
func (that *HoTimeDB) TestSQLErrorCount() int32 {
|
||||
return atomic.LoadInt32(&that.testSQLErrCount)
|
||||
}
|
||||
func (that *HoTimeDB) ResetTestSQLErrorCount() {
|
||||
atomic.StoreInt32(&that.testSQLErrCount, 0)
|
||||
}
|
||||
```
|
||||
|
||||
在 [testing_api.go](testing_api.go) `execute` 中,`ServeHTTP` 之后、断言之前,检查计数:
|
||||
|
||||
```go
|
||||
c.api.app.Db.FlushTestLog() // 先清空请求前的残留
|
||||
// ... ServeHTTP ...
|
||||
sqlErrCount := c.api.app.Db.TestSQLErrorCount()
|
||||
c.api.app.Db.ResetTestSQLErrorCount()
|
||||
if sqlErrCount > 0 {
|
||||
sqlLog := c.api.app.Db.FlushTestLog()
|
||||
t.Fatalf("请求处理中有 %d 次 SQL 错误:\n%s", sqlErrCount, sqlLog)
|
||||
}
|
||||
```
|
||||
|
||||
修复后流程:
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["ServeHTTP"] --> B{"SQL 出错?"}
|
||||
B -->|"是"| C["t.Fatalf 用例直接失败"]
|
||||
B -->|"否"| D["正常断言 status/msg/result"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bug 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 即可安全跳过。
|
||||
@@ -1,127 +0,0 @@
|
||||
---
|
||||
name: Fix caller detection v2
|
||||
overview: 重新设计 findCaller 的调用者过滤逻辑:(1) 删除 maxFrameworkDepth 提前退出 (2) 收窄 isHoTimeFrameworkFile 定义为仅跳过日志/DB执行管线,让框架业务文件(code/makecode.go、application.go、context.go 等)作为有意义的调用者返回。
|
||||
todos:
|
||||
- id: rewrite-findcaller
|
||||
content: 重写 findCaller:删除 maxFrameworkDepth、applicationFile 跟踪,简化为跳过基础设施 + 返回第一个业务文件
|
||||
status: completed
|
||||
- id: rewrite-framework-check
|
||||
content: 重命名 isHoTimeFrameworkFile 为 isInfrastructureFile,收窄到只匹配 log/db/cache/common/dri,移除 code/ 和 application.go 等
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# 修复调用者智能识别(v2)
|
||||
|
||||
## 根因分析
|
||||
|
||||
用户看到 `[db/crud.go:149]`,实际调用者是 `code/makecode.go`。两个问题叠加:
|
||||
|
||||
**问题 A**: `maxFrameworkDepth = 10` 过低。zerolog 内部帧(~5) + log/logger.go(1) + db/query.go(3-4) + db/crud.go(1) 轻松达到 10,触发提前返回,根本没走到 `code/makecode.go`。
|
||||
|
||||
**问题 B**: 即使去掉 `maxFrameworkDepth`,`code/makecode.go` 在 `frameworkCoreDirs` 中被 `"code/"` + `"makecode.go"` 命中,也会被跳过。最终回退到 `applicationFile`(application.go)或 `lastFrameworkFile`,都不是用户想要的。
|
||||
|
||||
## 设计思路
|
||||
|
||||
用户原话:"应用层触发的就打印应用层的,如果是框架层的业务造成的就打印框架层对应位置的,但肯定不是 log/logger.go"。
|
||||
|
||||
核心区分:
|
||||
- **基础设施文件**(应跳过):日志管线(log/、zerolog/)、DB 执行管线(db/\*)、缓存中间件(cache/\*)、Go runtime、通用工具(common/\*)、数据库驱动(dri/\*)
|
||||
- **业务文件**(应返回):`code/makecode.go`、`application.go`、`context.go`、`session.go`、所有应用层代码
|
||||
|
||||
跳过基础设施,返回第一个业务文件。无论这个业务文件是应用层还是框架层。
|
||||
|
||||
## 具体修改
|
||||
|
||||
### 1. [log/logger.go](log/logger.go) -- `findCaller` 简化
|
||||
|
||||
- **删除** `maxFrameworkDepth` 常量(第 315 行)和 `frameworkCount >= maxFrameworkDepth` 提前退出(第 343-345 行)。`i < 20` 循环上限已足够保护。
|
||||
- **删除** `applicationFile` / `applicationLine` 跟踪及其回退逻辑(不再需要 application.go 特殊处理)。
|
||||
- **删除** `frameworkCount` 计数器(不再需要)。
|
||||
- 简化后的 `findCaller`:
|
||||
|
||||
```go
|
||||
func findCaller(origFile string, origLine int) string {
|
||||
var lastInfraFile string
|
||||
var lastInfraLine int
|
||||
|
||||
for i := 1; i < 20; i++ {
|
||||
_, file, line, ok := runtime.Caller(i)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
shortFile := shortenPath(file)
|
||||
|
||||
if isInfrastructureFile(shortFile) {
|
||||
lastInfraFile = shortFile
|
||||
lastInfraLine = line
|
||||
continue
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s:%d", shortFile, line)
|
||||
}
|
||||
|
||||
if lastInfraFile != "" {
|
||||
return fmt.Sprintf("%s:%d", lastInfraFile, lastInfraLine)
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", shortenPath(origFile), origLine)
|
||||
}
|
||||
```
|
||||
|
||||
### 2. [log/logger.go](log/logger.go) -- 重命名 + 收窄 `isHoTimeFrameworkFile`
|
||||
|
||||
重命名为 `isInfrastructureFile`,仅匹配日志/DB/缓存/通用工具等"管道"文件:
|
||||
|
||||
```go
|
||||
func isInfrastructureFile(file string) bool {
|
||||
// 第三方日志库
|
||||
if strings.HasPrefix(file, "zerolog/") || strings.HasPrefix(file, "zerolog@") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(file, "logrus/") || strings.HasPrefix(file, "logrus@") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(file, "runtime/") {
|
||||
return true
|
||||
}
|
||||
// HoTime 日志包
|
||||
if strings.HasPrefix(file, "log/") {
|
||||
return true
|
||||
}
|
||||
|
||||
// DB 执行管线(db/ 下全部文件)
|
||||
infraPrefixes := []string{"db/", "cache/", "common/", "dri/"}
|
||||
for _, prefix := range infraPrefixes {
|
||||
if strings.HasPrefix(file, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容路径含 hotime 的场景(module 全路径未被 shortenPath 完全截断时)
|
||||
lowerFile := strings.ToLower(file)
|
||||
if strings.Contains(lowerFile, "hotime") {
|
||||
infraDirs := []string{"db/", "cache/", "common/", "log/", "dri/"}
|
||||
for _, dir := range infraDirs {
|
||||
if strings.Contains(file, dir) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
```
|
||||
|
||||
关键变更:
|
||||
- `"code/"` 从所有列表中移除 -- `code/makecode.go` 不再被跳过
|
||||
- `application.go`、`context.go`、`session.go` 等单文件匹配全部移除 -- 框架业务文件不再被跳过
|
||||
- `frameworkCoreDirs` 中的 `"code/"` 及其文件名(makecode.go、template.go、config.go)移除
|
||||
|
||||
## 修复后效果
|
||||
|
||||
| 调用链 | 显示结果 |
|
||||
|--------|---------|
|
||||
| app/user.go -> db.GetMap -> logSQL | `app/user.go:XX` |
|
||||
| code/makecode.go -> db.GetList -> logSQL | `code/makecode.go:XX` |
|
||||
| application.go SetConfig -> db -> logSQL | `application.go:XX` |
|
||||
| context.go session -> db -> logSQL | `context.go:XX` |
|
||||
@@ -1,220 +0,0 @@
|
||||
---
|
||||
name: HoTime TDD Skill
|
||||
overview: 创建一个 HoTime 框架的 TDD(测试驱动开发)Skill,指导 AI 按照"先写测试、再写实现、运行直到通过"的流程开发接口;同时在框架层面增加测试模式下的 SQL 日志智能控制,解决 AI 因日志过多无法继续执行的问题。
|
||||
todos:
|
||||
- id: sql-log-buffer
|
||||
content: "db/db.go: 添加 testLogBuf *bytes.Buffer + testLogBufMu sync.Mutex 字段"
|
||||
status: completed
|
||||
- id: sql-log-query
|
||||
content: "db/query.go: 修改 Query/Exec 两处 defer SQL 日志块,有 buffer 写 buffer、无 buffer 照旧"
|
||||
status: completed
|
||||
- id: sql-log-execute
|
||||
content: "testing_api.go execute(): 请求前创建 buffer、请求后根据 pass/fail 决定 t.Log 或丢弃"
|
||||
status: completed
|
||||
- id: sql-log-init
|
||||
content: "testing_helper.go NewTestApp(): Init 后设 Db.Mode=0 静默启动阶段日志,再恢复原 Mode 供运行时 buffer 使用"
|
||||
status: completed
|
||||
- id: create-skill
|
||||
content: 创建 ~/.cursor/skills/hotime-tdd-testing/SKILL.md:TDD 工作流 + 测试编写范式 + API 速查 + 运行命令
|
||||
status: completed
|
||||
- id: verify-test
|
||||
content: 运行单接口测试验证:通过时无 SQL 日志,失败时输出 SQL 日志
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# HoTime TDD API 测试 Skill 与 SQL 日志控制
|
||||
|
||||
## 一、SQL 日志智能控制(框架修改)-- 失败才打印
|
||||
|
||||
### 问题分析
|
||||
|
||||
SQL 日志由 `Db.Mode` 控制([db/query.go](d:/work/hotimev1.5/db/query.go) 第 40 行 `if that.Mode != 0`),`Log` 是 `*logrus.Logger`([db/db.go](d:/work/hotimev1.5/db/db.go) 第 22 行)。当前 config `"mode": 3`,每条 SQL 都实时打印。日志来源:
|
||||
|
||||
- 启动阶段 `Init()` -> `SetDB()` 中的 DB 初始化/表扫描
|
||||
- 测试执行中每个接口的所有 SQL
|
||||
- 批量运行时日志量指数级增长,导致 AI 输出被截断
|
||||
|
||||
### 方案:Buffer + Flush on Failure
|
||||
|
||||
**策略**:SQL 日志不直接打印,写入 per-case buffer。测试通过则丢弃,失败则通过 `t.Log()` 输出(Go 的 `t.Log` 只在失败或 `-v` 时显示)。
|
||||
|
||||
#### Step 1: db/db.go -- 添加 buffer 字段
|
||||
|
||||
```go
|
||||
type HoTimeDB struct {
|
||||
// ... 现有字段不变 ...
|
||||
testLogBuf *bytes.Buffer // 测试模式:SQL 日志写入此 buffer 而非直接打印
|
||||
testLogBufMu sync.Mutex // 保护 testLogBuf 并发写入
|
||||
}
|
||||
```
|
||||
|
||||
新增 helper 方法:
|
||||
|
||||
```go
|
||||
func (that *HoTimeDB) SetTestLogBuffer(buf *bytes.Buffer) {
|
||||
that.testLogBufMu.Lock()
|
||||
that.testLogBuf = buf
|
||||
that.testLogBufMu.Unlock()
|
||||
}
|
||||
|
||||
func (that *HoTimeDB) FlushTestLog() string {
|
||||
that.testLogBufMu.Lock()
|
||||
defer that.testLogBufMu.Unlock()
|
||||
if that.testLogBuf == nil {
|
||||
return ""
|
||||
}
|
||||
s := that.testLogBuf.String()
|
||||
that.testLogBuf.Reset()
|
||||
return s
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 2: db/query.go -- 修改两处 defer SQL 日志
|
||||
|
||||
`queryWithRetry` 第 39-45 行和 `execWithRetry` 第 117-123 行的 defer 块统一改为:
|
||||
|
||||
```go
|
||||
defer func() {
|
||||
if that.Mode != 0 {
|
||||
that.mu.RLock()
|
||||
msg := fmt.Sprintf("SQL:%s DATA:%v ERROR:%v", that.LastQuery, that.LastData, that.LastErr.GetError())
|
||||
that.mu.RUnlock()
|
||||
that.testLogBufMu.Lock()
|
||||
if that.testLogBuf != nil {
|
||||
that.testLogBuf.WriteString(msg + "\n")
|
||||
} else {
|
||||
that.Log.Info(msg)
|
||||
}
|
||||
that.testLogBufMu.Unlock()
|
||||
}
|
||||
}()
|
||||
```
|
||||
|
||||
#### Step 3: testing_api.go execute() -- 管理 buffer 生命周期
|
||||
|
||||
在 `execute()` 函数的 `t.Run(desc, func(t *testing.T) { ... })` 内部:
|
||||
|
||||
```go
|
||||
t.Run(desc, func(t *testing.T) {
|
||||
// 启用 SQL 日志缓冲
|
||||
buf := &bytes.Buffer{}
|
||||
c.api.app.Db.SetTestLogBuffer(buf)
|
||||
|
||||
start := time.Now()
|
||||
req := c.buildRequest(method)
|
||||
w := httptest.NewRecorder()
|
||||
c.api.app.Application.ServeHTTP(w, req)
|
||||
duration := time.Since(start)
|
||||
|
||||
// ... 原有的断言逻辑 ...
|
||||
|
||||
// 停止缓冲
|
||||
sqlLog := c.api.app.Db.FlushTestLog()
|
||||
c.api.app.Db.SetTestLogBuffer(nil)
|
||||
|
||||
// 失败时输出 SQL 日志
|
||||
if !passed && sqlLog != "" {
|
||||
t.Logf("SQL 日志:\n%s", sqlLog)
|
||||
}
|
||||
// ... 原有的 record 收集逻辑 ...
|
||||
})
|
||||
```
|
||||
|
||||
#### Step 4: testing_helper.go NewTestApp() -- 静默启动阶段日志
|
||||
|
||||
`Init()` 内部调用 `SetDB()` 时 Mode 已从 config 读取(值为 3),会打印大量启动 SQL。在 `NewTestApp` 中 `Init()` 之后立即处理:
|
||||
|
||||
```go
|
||||
func NewTestApp(configPath string, projects TestProj, ...) *TestApp {
|
||||
app := Init(configPath)
|
||||
|
||||
// 启动阶段产生的 SQL 日志已经打印完毕,现在保存原始 Mode
|
||||
// Mode 保持原值(非 0)让运行时 SQL 仍走 defer 日志逻辑,但会被 buffer 拦截
|
||||
// 启动阶段的日志无法追溯拦截,但它们是一次性的固定输出,量可控
|
||||
|
||||
// ... 后续逻辑不变 ...
|
||||
}
|
||||
```
|
||||
|
||||
> 启动阶段的 SQL 日志(表扫描等)是 `Init()` 内部产生的,此时 buffer 尚未设置,会直接打印。这些是一次性的固定日志,量相对可控。如果仍然太多,可在 `Init()` 调用前临时设 `app.Db.Mode = 0`,但 `Init` 返回的 app 还没有 Db 对象——所以更实际的做法是在 `NewTestApp` 中 `Init()` 之后、`SetupForTest()` 之前设置 `Db.Mode = 0` 静默后续初始化 SQL,然后在 `SetupForTest()` 完成后恢复原始 Mode。
|
||||
|
||||
实际代码:
|
||||
|
||||
```go
|
||||
func NewTestApp(configPath string, projects TestProj, ...) *TestApp {
|
||||
app := Init(configPath)
|
||||
|
||||
// 保存原始 Mode,静默后续初始化阶段的 SQL 日志
|
||||
origMode := app.Db.Mode
|
||||
app.Db.Mode = 0
|
||||
|
||||
// ... listener / router / SetupForTest / DisableDbCache 等初始化 ...
|
||||
|
||||
// 恢复 Mode,运行时 SQL 日志由 testLogBuf 接管
|
||||
app.Db.Mode = origMode
|
||||
|
||||
return &TestApp{ ... }
|
||||
}
|
||||
```
|
||||
|
||||
### 效果
|
||||
|
||||
- `go test ./app/... -v` -- 全量运行:通过的接口零 SQL 输出,失败的接口自动输出完整 SQL 链路
|
||||
- `go test -run TestApi/app/order/create -v` -- 单接口:同上,通过无输出、失败有输出
|
||||
- 任何粒度都自动适配,无需手动控制
|
||||
|
||||
### 涉及文件
|
||||
|
||||
- [db/db.go](d:/work/hotimev1.5/db/db.go) -- 添加 `testLogBuf` / `testLogBufMu` 字段 + helper 方法
|
||||
- [db/query.go](d:/work/hotimev1.5/db/query.go) -- 修改 `queryWithRetry` 和 `execWithRetry` 的 defer 日志块
|
||||
- [testing_api.go](d:/work/hotimev1.5/testing_api.go) -- `execute()` 中管理 buffer 生命周期
|
||||
- [testing_helper.go](d:/work/hotimev1.5/testing_helper.go) -- `NewTestApp()` 中静默初始化阶段 + 恢复 Mode
|
||||
|
||||
---
|
||||
|
||||
## 二、TDD Skill 创建
|
||||
|
||||
### 存储位置
|
||||
|
||||
个人 Skill:`~/.cursor/skills/hotime-tdd-testing/SKILL.md`(跨项目通用)
|
||||
|
||||
### Skill 核心内容
|
||||
|
||||
指导 AI 按以下 TDD 工作流开发 HoTime 接口:
|
||||
|
||||
**Phase 1: 编写测试用例(先于实现)**
|
||||
|
||||
- 在 `xxx_test.go` 中定义 `CtrTest`,包含:
|
||||
- 错误用例(权限/参数/业务校验,覆盖所有 status != 0 路径)
|
||||
- 测试数据准备(`a.DB().Insert`)
|
||||
- 正确请求 + 响应结构校验(第三参数类型样本)
|
||||
- Verify 统一校验(响应值断言 + 数据库状态校验)
|
||||
|
||||
**Phase 2: 实现接口**
|
||||
|
||||
- 在对应的 `.go` 文件中编写 handler
|
||||
|
||||
**Phase 3: 运行测试直到通过**
|
||||
|
||||
- 运行命令:`go test ./app/... -v -run TestApi/app/{ctr}/{method}`
|
||||
- 修复失败用例,循环直到全部通过
|
||||
|
||||
**关键参考文档**
|
||||
|
||||
- Skill 内直接嵌入精简版的测试 API 速查表(链式 API、Verify 用法、DB 操作等)
|
||||
- 详细文档指向 [Testing_API测试框架.md](d:/work/your-app/docs/Testing_API测试框架.md)
|
||||
|
||||
**SQL 日志行为(框架自动处理)**
|
||||
|
||||
- 任何粒度运行:通过的用例零 SQL 输出,失败的用例自动输出完整 SQL 链路
|
||||
- 无需手动控制,框架通过 buffer 机制自动按 pass/fail 决定输出
|
||||
|
||||
### Skill 文件结构
|
||||
|
||||
```
|
||||
~/.cursor/skills/hotime-tdd-testing/
|
||||
SKILL.md -- 主文件(TDD 流程 + API 速查 + 运行指令)
|
||||
```
|
||||
|
||||
直接在 SKILL.md 内嵌入精简的 API 参考(不分离文件),保持在 500 行以内,因为测试框架文档已经在项目 docs 目录中。
|
||||
@@ -1,331 +0,0 @@
|
||||
---
|
||||
name: JSON Path 查询支持
|
||||
overview: 在现有 Dialect 接口和 WHERE 条件解析引擎上扩展,使 MySQL 和 SQLite 支持类似 PgSQL 的 `data["a"]["b"]["c"]` JSON 路径查询与操作语法,所有三种数据库共享统一的上层 API。
|
||||
todos:
|
||||
- id: dialect-json-interface
|
||||
content: 在 db/dialect.go 的 Dialect 接口新增 JSONExtract / JSONSet / JSONContains / JSONArrayLength 四个方法
|
||||
status: pending
|
||||
- id: dialect-json-mysql
|
||||
content: 实现 MySQLDialect 的三个 JSON 方法,处理 JSON_EXTRACT / JSON_SET / JSON_UNQUOTE 等
|
||||
status: pending
|
||||
- id: dialect-json-sqlite
|
||||
content: 实现 SQLiteDialect 的三个 JSON 方法,使用小写 json_extract / json_set
|
||||
status: pending
|
||||
- id: dialect-json-pgsql
|
||||
content: "实现 PostgreSQLDialect 的三个 JSON 方法,使用 -> / #>> / jsonb_set 语法"
|
||||
status: pending
|
||||
- id: where-json-parse
|
||||
content: 新增 parseJSONPathKey()/parseJSONPathFromName() 工具函数,同时支持 col["key"] 和 col['key'] 两种引号格式,以及数字索引 [0],解析出列名、[]string 路径键、操作符后缀
|
||||
status: pending
|
||||
- id: where-json-cond
|
||||
content: 在 db/where.go 的 varCond() 开头最优先调用 parseJSONPathKey,分发到 jsonPathCond();jsonPathCond 对现有操作符复用比较逻辑,新增 [?]/[!?]/[@]/[!@]/[len]/[len>] 等分支
|
||||
status: pending
|
||||
- id: crud-json-update
|
||||
content: 在 db/crud.go 的 Update() SET 构建循环中检测 JSON 路径列名生成 JSONSet 表达式,在 Select() 的 Slice 字段循环中检测 JSON 路径生成 JSONExtract 表达式(修复两处 ProcessColumnNoPrefix 误处理)
|
||||
status: pending
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# MySQL/SQLite JSON 路径查询支持方案
|
||||
|
||||
## 背景与现状
|
||||
|
||||
现有 ORM 已有 `Dialect` 接口抽象三种数据库差异,`where.go` 用 `[operator]` 后缀语法解析条件。扩展的核心思路:**在解析 key 时识别 JSON 路径记法,委托给 Dialect 生成对应函数调用**。
|
||||
|
||||
## 三种数据库 JSON 能力深度对比
|
||||
|
||||
### 1. JSON 值提取(WHERE 比较场景)
|
||||
|
||||
|
||||
| | MySQL 5.7+ | SQLite 3.9+ | PostgreSQL |
|
||||
| ---------- | ------------------------------------------------------- | ------------------------------ | ----------------- |
|
||||
| 单层提取(返回文本) | `JSON_UNQUOTE(JSON_EXTRACT(col,'$.a'))` 或 `col->>'$.a'` | `json_extract(col,'$.a')` | `col->>'a'` |
|
||||
| 多层嵌套(返回文本) | `JSON_UNQUOTE(JSON_EXTRACT(col,'$.a.b'))` | `json_extract(col,'$.a.b')` | `col#>>'{a,b}'` |
|
||||
| 数组索引 | `JSON_EXTRACT(col,'$.arr[0]')` | `json_extract(col,'$.arr[0]')` | `col#>>'{arr,0}'` |
|
||||
| 路径格式 | `$.a.b` | `$.a.b`(与MySQL相同) | `{a,b}`(#>> 方式) |
|
||||
|
||||
|
||||
**关键差异**:MySQL 的 `JSON_EXTRACT` 对字符串返回值带外层双引号(如 `"北京"` 而非 `北京`),与普通字符串 `= ?` 比较会失败,**必须包一层 `JSON_UNQUOTE()`**。SQLite 和 PostgreSQL(`->>`/`#>>`)则直接返回文本,无此问题。
|
||||
|
||||
### 2. JSON 局部更新(UPDATE SET 场景)
|
||||
|
||||
|
||||
| | MySQL 5.7+ | SQLite 3.9+ | PostgreSQL |
|
||||
| ----- | ------------------------- | ------------------------- | ------------------------------------------ |
|
||||
| 语法 | `JSON_SET(col,'$.a.b',?)` | `json_set(col,'$.a.b',?)` | `jsonb_set(col,'{a,b}',to_jsonb(?::text))` |
|
||||
| 路径格式 | `$.a.b` | `$.a.b`(与MySQL相同) | `'{a,b}'` 数组字面量 |
|
||||
| 值的类型 | 直接绑定参数 | 直接绑定参数 | 字符串需 `to_jsonb(?::text)`,数字可 `?::jsonb` |
|
||||
| 列类型限制 | 无(TEXT 也行) | 无(TEXT 列) | **必须是 `jsonb` 列**,`json` 列无 `jsonb_set` |
|
||||
|
||||
|
||||
**关键差异**:MySQL 与 SQLite 的语法几乎完全一致(函数名大小写不同而已)。PostgreSQL 只有 `jsonb_set`,要求列类型为 `jsonb`,且字符串值必须转为 JSON 格式(`to_jsonb(?::text)` 或 `'"value"'::jsonb`)。
|
||||
|
||||
### 3. JSON 数组包含(`[@]` 操作符)
|
||||
|
||||
|
||||
| | MySQL 5.7+ | SQLite 3.9+ | PostgreSQL |
|
||||
| ------ | --------------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------ |
|
||||
| SQL 形式 | `JSON_CONTAINS(JSON_EXTRACT(col,'$.path'),JSON_QUOTE(?))` | `EXISTS(SELECT 1 FROM json_each(col,'$.path') WHERE value=?)` | `col#>'{path}' @> to_jsonb(?::text)` |
|
||||
| 结构类型 | 函数调用(内联) | **EXISTS 子查询**(结构不同) | 中缀操作符(内联) |
|
||||
|
||||
|
||||
**最大兼容难点**:SQLite 没有内联的包含函数,必须生成 EXISTS 子查询,SQL 结构与另外两种完全不同。`JSONContains()` 方法在 SQLite 下返回的是一段 `EXISTS(...)` 表达式。
|
||||
|
||||
### 4. 路径格式统一转换规则
|
||||
|
||||
ORM 内部统一使用 `[]string{"a","b","c"}` 表示路径键(数字索引用字符串 `"0"` 表示),各 Dialect 自行转换:
|
||||
|
||||
```
|
||||
内部路径: ["address","city"]
|
||||
→ MySQL/SQLite: $.address.city
|
||||
→ PostgreSQL: {address,city}
|
||||
|
||||
内部路径: ["tags","0"](数组索引)
|
||||
→ MySQL/SQLite: $.tags[0]
|
||||
→ PostgreSQL: {tags,0}
|
||||
```
|
||||
|
||||
## 统一 API 语法设计
|
||||
|
||||
### Key 写法(单引号/双引号两种都支持)
|
||||
|
||||
```go
|
||||
// 双引号风格(JSON 标准,推荐,需用反引号字符串)
|
||||
common.Map{ `profile["addr"]["city"]`: "北京" }
|
||||
|
||||
// 单引号风格(普通字符串也能写)
|
||||
common.Map{ "profile['addr']['city']": "北京" }
|
||||
```
|
||||
|
||||
两种写法解析结果完全一致,内部统一转为 `$.addr.city`。
|
||||
|
||||
### 操作符设计:复用现有 + 新增 JSON 专属
|
||||
|
||||
**所有现有操作符均可接在 JSON 路径后直接使用**(`=` / `>` / `<` / `>=` / `<=` / `!=` / `LIKE` / `IN` / `NOT IN` / `BETWEEN` / `IS NULL`):
|
||||
|
||||
```go
|
||||
db.Select("user", common.Map{
|
||||
`profile["age"]`: 18, // = 18
|
||||
`profile["age"][>]`: 18, // > 18
|
||||
`profile["age"][<>]`: []int{18, 30}, // BETWEEN
|
||||
`profile["name"][~]`: "张", // LIKE %张%
|
||||
`profile["tags"][0]`: "vip", // 数组第一个元素 = vip
|
||||
`profile["addr"]["city"]`: "北京", // 多级嵌套
|
||||
`profile["score"]`: nil, // IS NULL
|
||||
})
|
||||
```
|
||||
|
||||
**新增 JSON 专属操作符**(现有体系无对应):
|
||||
|
||||
|
||||
| 操作符 | 含义 | MySQL | SQLite | PgSQL |
|
||||
| --------- | ----------- | -------------------------------- | ------------------------ | ------------------------- |
|
||||
| `[?]` | 路径存在(非NULL) | `IS NOT NULL` | `IS NOT NULL` | `? ?` |
|
||||
| `[!?]` | 路径不存在 | `IS NULL` | `IS NULL` | `NOT (? ?)` |
|
||||
| `[@]` | 数组/对象包含某值 | `JSON_CONTAINS(col,?,'$.path')` | `json_each` 子查询 | `col @> ?::jsonb` |
|
||||
| `[!@]` | 不包含 | `NOT JSON_CONTAINS(...)` | 子查询 NOT EXISTS | `NOT (col @> ?)` |
|
||||
| `[len]` | 数组长度 = ? | `JSON_LENGTH(JSON_EXTRACT(...))` | `json_array_length(...)` | `jsonb_array_length(...)` |
|
||||
| `[len>]` | 数组长度 > ? | 同上 | 同上 | 同上 |
|
||||
| `[len<]` | 数组长度 < ? | 同上 | 同上 | 同上 |
|
||||
| `[len>=]` | 数组长度 >= ? | 同上 | 同上 | 同上 |
|
||||
| `[len<=]` | 数组长度 <= ? | 同上 | 同上 | 同上 |
|
||||
|
||||
|
||||
```go
|
||||
// 新操作符使用示例
|
||||
db.Select("user", common.Map{
|
||||
`profile["vip"][?]`: nil, // vip 字段存在
|
||||
`profile["tags"][@]`: "admin", // tags 数组包含 "admin"
|
||||
`profile["friends"][len>]`: 5, // friends 数组长度 > 5
|
||||
`profile["items"][len]`: 3, // items 数组长度 = 3
|
||||
})
|
||||
|
||||
// UPDATE 局部更新 JSON 字段
|
||||
db.Update("user", common.Map{
|
||||
`profile["age"]`: 20,
|
||||
`profile["addr"]["city"]`: "上海",
|
||||
}, common.Map{"id": 1})
|
||||
```
|
||||
|
||||
## 与现有代码的兼容性分析
|
||||
|
||||
### 三处具体冲突点
|
||||
|
||||
**冲突 1 — WHERE `varCond` 的 `[...]` 分支**(`[db/where.go:225](db/where.go)`)
|
||||
|
||||
现有逻辑:只要 key 含 `[` 且末尾是 `]` 就进分支,取末尾 3/4 字符匹配已知操作符。
|
||||
|
||||
```
|
||||
profile["age"] → 末尾3字符 e"] → 无匹配 → handleDefaultCondition
|
||||
→ ProcessColumn("profile[\"age\"]") → 被加引号 → WRONG SQL
|
||||
|
||||
profile["age"][>] → 末尾3字符 [>] → 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 路径支持
|
||||
|
||||
@@ -1,424 +0,0 @@
|
||||
---
|
||||
name: Logging System Redesign
|
||||
overview: Replace logrus with zerolog, remove mode config, redesign logLevel/logFile, simplify Error to pure storage, add DBState for atomic DB state access, and provide a clean application-layer logging API.
|
||||
todos:
|
||||
- id: add-zerolog-dep
|
||||
content: 添加 zerolog 依赖到 go.mod,移除 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,不受影响
|
||||
@@ -1,305 +0,0 @@
|
||||
---
|
||||
name: objtoobj 问题修正
|
||||
overview: 对 objtoobj.go 中发现的 3 个运行时 panic bug、2 个错误处理 bug、多处性能问题进行修正,同时新增销量方案所需的 ObjToRoundFloat64 函数。
|
||||
todos:
|
||||
- id: fix-uint8-panics
|
||||
content: 修复 ObjToFloat64、ObjToInt64、ObjToStr 三处 uint8 分支的 panic bug
|
||||
status: completed
|
||||
- id: fix-objtomap-shadow
|
||||
content: 修复 ObjToMap default 分支变量遮蔽和赋值逻辑错误
|
||||
status: completed
|
||||
- id: fix-objtoslice-default
|
||||
content: 修复 ObjToSlice default 分支 Marshal 错误被覆盖的问题
|
||||
status: completed
|
||||
- id: fix-objtobool-error
|
||||
content: 修复 ObjToBool 成功转换时仍报错的问题
|
||||
status: completed
|
||||
- id: fix-objtotime-padding
|
||||
content: 修复 ObjToTime 单位数日+时间组合时补零失败的 bug
|
||||
status: completed
|
||||
- id: fix-objtoceilint64-double
|
||||
content: 修复 ObjToCeilInt64 双重 Ceil 和多余 ObjToInt64 调用
|
||||
status: completed
|
||||
- id: perf-type-switch-idiom
|
||||
content: 所有函数改用 switch v := obj.(type) 惯用写法,消除重复类型断言
|
||||
status: completed
|
||||
- id: perf-objtotime
|
||||
content: ObjToTime 中 ObjToStr(tInt) 只调用一次并缓存
|
||||
status: completed
|
||||
- id: perf-objtoslice-string
|
||||
content: ObjToSlice []string 分支类型断言只做一次
|
||||
status: completed
|
||||
- id: perf-objtostr-types
|
||||
content: ObjToStr 补充 float32、bool 等常见类型的直接转换
|
||||
status: completed
|
||||
- id: perf-objtomaparray-cap
|
||||
content: ObjToMapArray 预分配切片容量
|
||||
status: completed
|
||||
- id: add-roundfloat64
|
||||
content: 新增 ObjToRoundFloat64,并在 map.go、obj.go 中新增对应 Get/To 方法
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# objtoobj.go 问题审查与修正计划
|
||||
|
||||
修改文件:[common/objtoobj.go](d:\work\hotimev1.5\common\objtoobj.go)
|
||||
|
||||
---
|
||||
|
||||
## 一、严重 Bug(uint8 vs []byte 类型混淆,可能 panic)
|
||||
|
||||
开发者原意是处理数据库 driver 返回的 `[]uint8`(即 `[]byte`),但 Go 中 `uint8`(单字节)和 `[]uint8`/`[]byte`(字节数组)是完全不同的类型,type switch 不会互相匹配。
|
||||
|
||||
当前 `Row()` 方法(query.go:380)已经用 reflect 将 `[]uint8` 转为 `string`,所以正常 DB 流程不会触发这些分支。但其他非 DB 数据源可能传入 `[]byte`,此时会走 `default` 分支而非预期的 `uint8` 分支。
|
||||
|
||||
### Bug 1: `ObjToFloat64` — `case uint8` 应为 `case []byte`
|
||||
|
||||
```208:215:d:\work\hotimev1.5\common\objtoobj.go
|
||||
case uint8:
|
||||
value, e := strconv.ParseFloat(obj.(string), 64)
|
||||
```
|
||||
|
||||
**修正**:改为 `case []byte`,用 `string(v)` 转换后再 ParseFloat:
|
||||
|
||||
```go
|
||||
case []byte:
|
||||
value, e := strconv.ParseFloat(string(obj.([]byte)), 64)
|
||||
if e != nil {
|
||||
v = float64(0)
|
||||
err = e
|
||||
} else {
|
||||
v = value
|
||||
}
|
||||
```
|
||||
|
||||
### Bug 2: `ObjToInt64` — `case uint8` 应为 `case []byte`
|
||||
|
||||
```279:286:d:\work\hotimev1.5\common\objtoobj.go
|
||||
case uint8:
|
||||
value, e := StrToInt(obj.(string))
|
||||
```
|
||||
|
||||
**修正**:改为 `case []byte`,用 `string(v)` 转换后再 StrToInt:
|
||||
|
||||
```go
|
||||
case []byte:
|
||||
value, e := StrToInt(string(obj.([]byte)))
|
||||
if e != nil {
|
||||
v = int64(0)
|
||||
err = e
|
||||
} else {
|
||||
v = int64(value)
|
||||
}
|
||||
```
|
||||
|
||||
### Bug 3: `ObjToStr` — `case uint8` 应改为正确处理单字节
|
||||
|
||||
```341:342:d:\work\hotimev1.5\common\objtoobj.go
|
||||
case uint8:
|
||||
str = obj.(string)
|
||||
```
|
||||
|
||||
`ObjToStr` 已有 `case []byte` 分支正确处理字节数组。这里的 `case uint8` 应正确处理单字节值:
|
||||
|
||||
**修正**:改为 `str = strconv.Itoa(int(obj.(uint8)))`
|
||||
|
||||
> 这三处 bug 目前未爆发,因为 `Row()` 已将 `[]uint8` 转为 `string`。但修正后能覆盖非 DB 数据源直接传入 `[]byte` 的场景,更加健壮。
|
||||
|
||||
---
|
||||
|
||||
## 二、逻辑 Bug
|
||||
|
||||
### Bug 4: `ObjToMap` default 分支 — 变量遮蔽导致错误丢失
|
||||
|
||||
```33:44:d:\work\hotimev1.5\common\objtoobj.go
|
||||
default:
|
||||
data, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
err = errors.New("没有合适的转换对象!" + err.Error())
|
||||
v = nil
|
||||
}
|
||||
v = Map{}
|
||||
e := json.Unmarshal(data, &v)
|
||||
```
|
||||
|
||||
第 34 行的 `data, err := json.Marshal(obj)` 使用了 `:=`,创建了一个**新的局部变量 `err`**,遮蔽了第 14 行声明的外部 `err`。这意味着:
|
||||
|
||||
- Marshal 失败时,外部 `err` 仍然是 nil,错误信息丢失
|
||||
- 第 38 行设 `v = nil`,但第 39 行又立刻把 `v` 重新赋值为 `Map{}`,然后用坏数据尝试 Unmarshal
|
||||
|
||||
**修正**:用 `=` 代替 `:=`,并在 Marshal 失败时 break。
|
||||
|
||||
### Bug 5: `ObjToSlice` default 分支 — Marshal 错误被覆盖
|
||||
|
||||
```86:91:d:\work\hotimev1.5\common\objtoobj.go
|
||||
default:
|
||||
v = Slice{}
|
||||
var data []byte
|
||||
data, err = json.Marshal(obj)
|
||||
err = json.Unmarshal(data, &v)
|
||||
```
|
||||
|
||||
第 89 行 `err = json.Marshal(obj)` 即使失败,第 90 行也立刻用 `err = json.Unmarshal(data, &v)` 覆盖了前一个错误。当 Marshal 失败时 `data` 为空,Unmarshal 也会失败,但丢失了原始的错误信息。
|
||||
|
||||
**修正**:Marshal 失败时直接 break,不再尝试 Unmarshal。
|
||||
|
||||
### Bug 6: `ObjToBool` — 成功转换时也报错
|
||||
|
||||
```307:330:d:\work\hotimev1.5\common\objtoobj.go
|
||||
default:
|
||||
toInt := ObjToInt(obj)
|
||||
if toInt != 0 {
|
||||
v = true
|
||||
}
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
```
|
||||
|
||||
当传入 `1`(int 类型)时,`ObjToInt` 成功返回 1,`v = true`,但仍然设置了 `err`。逻辑上这不应该报错。
|
||||
|
||||
**修正**:只在 `ObjToInt` 也无法转换时才报错。
|
||||
|
||||
---
|
||||
|
||||
### Bug 7: `ObjToTime` 日期补零逻辑缺陷
|
||||
|
||||
```108:121:d:\work\hotimev1.5\common\objtoobj.go
|
||||
timeNewStrs := strings.Split(tStr, "-")
|
||||
for _, v := range timeNewStrs {
|
||||
if len(v) == 1 {
|
||||
v = "0" + v
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
按 `"-"` 分割 `"2006-1-2 15:04:05"` 得到 `["2006", "1", "2 15:04:05"]`。第三段 `"2 15:04:05"` 长度 > 1 不会补零,最终得到 `"2006-01-2 15:04:05"`,`time.Parse` 会失败。
|
||||
|
||||
**修正**:分割前先按空格分离日期和时间部分,分别处理后再拼接。或者改用 `strings.TrimLeft` 对每段的纯日期部分补零。
|
||||
|
||||
### Bug 8: `ObjToCeilInt64` 双重 Ceil + 多余 ObjToInt64
|
||||
|
||||
```238:241:d:\work\hotimev1.5\common\objtoobj.go
|
||||
func ObjToCeilInt64(obj interface{}, e ...*Error) int64 {
|
||||
f := ObjToCeilFloat64(obj, e...) // 内部已 math.Ceil
|
||||
return ObjToInt64(math.Ceil(f)) // 又 Ceil + 走完整 type switch
|
||||
}
|
||||
```
|
||||
|
||||
**修正**:改为 `return int64(f)`,因为 `ObjToCeilFloat64` 已经返回了 Ceil 后的值。
|
||||
|
||||
---
|
||||
|
||||
## 三、性能问题
|
||||
|
||||
### P1: `ObjToTime` 重复调用 `ObjToStr(tInt)` 多达 5 次
|
||||
|
||||
```152:177:d:\work\hotimev1.5\common\objtoobj.go
|
||||
if len(ObjToStr(tInt)) > 16 {
|
||||
// ...
|
||||
} else if len(ObjToStr(tInt)) > 13 {
|
||||
// ...
|
||||
} else if len(ObjToStr(tInt)) > 10 {
|
||||
// ...
|
||||
} else if len(ObjToStr(tInt)) > 9 {
|
||||
// ...
|
||||
} else if len(ObjToStr(tInt)) > 3 {
|
||||
t, e := time.Parse("2006", ObjToStr(tInt))
|
||||
```
|
||||
|
||||
每次 `ObjToStr(tInt)` 都会执行 `strconv.FormatInt` + 分配新字符串。应在循环外只调用一次,存入变量复用。
|
||||
|
||||
### P2: `ObjToSlice` 的 `[]string` 分支重复类型断言
|
||||
|
||||
```78:81:d:\work\hotimev1.5\common\objtoobj.go
|
||||
case []string:
|
||||
v = Slice{}
|
||||
for i := 0; i < len(obj.([]string)); i++ {
|
||||
v = append(v, obj.([]string)[i])
|
||||
}
|
||||
```
|
||||
|
||||
循环中每次 `obj.([]string)` 都做了类型断言。应先断言一次存入变量:`ss := obj.([]string)`。
|
||||
|
||||
### P3: `ObjToStr` 缺少 `float32`、`bool` 等常见类型直接转换
|
||||
|
||||
`float32`、`bool`、`int32` 等类型会走 `default` 分支用 `json.MarshalIndent`,比直接转换慢 100 倍以上。
|
||||
|
||||
**修正**:补充常见类型的 case:
|
||||
|
||||
```go
|
||||
case float32:
|
||||
str = strconv.FormatFloat(float64(obj.(float32)), 'f', -1, 32)
|
||||
case bool:
|
||||
str = strconv.FormatBool(obj.(bool))
|
||||
```
|
||||
|
||||
### P4: `ObjToStr` default 分支使用 `json.MarshalIndent`
|
||||
|
||||
`MarshalIndent` 比 `Marshal` 慢 20-30%,且产生更大的输出。但由于此改动可能影响现有依赖格式化输出的调用方,标记为**可选优化**。
|
||||
|
||||
### P5: 所有函数应改用 `switch v := obj.(type)` 惯用写法
|
||||
|
||||
当前每个 case 都重复做类型断言(如 `obj.(int)`),改为 Go 惯用写法可消除所有冗余断言:
|
||||
|
||||
```go
|
||||
// 改前
|
||||
switch obj.(type) {
|
||||
case int:
|
||||
v = int64(obj.(int))
|
||||
// 改后
|
||||
switch val := obj.(type) {
|
||||
case int:
|
||||
v = int64(val)
|
||||
```
|
||||
|
||||
### P6: `ObjToMapArray` 未预分配切片容量
|
||||
|
||||
`res := []Map{}` 应改为 `res := make([]Map, 0, len(s))`。
|
||||
|
||||
### P7: `StrToMap` / `StrToSlice` 参数名 `string` 遮蔽内置类型
|
||||
|
||||
参数名应改为 `s` 或 `jsonStr`,避免遮蔽 Go 内置 `string` 类型。
|
||||
|
||||
---
|
||||
|
||||
## 四、结合销量方案需新增的函数
|
||||
|
||||
根据[销量支持小数方案](d:\work\your-app.cursor\plans\销量支持小数方案_92094ec9.plan.md)第二步要求,需在本文件新增 `ObjToRoundFloat64`:
|
||||
|
||||
```go
|
||||
func ObjToRoundFloat64(obj interface{}, precision int, e ...*Error) float64 {
|
||||
f := ObjToFloat64(obj, e...)
|
||||
if precision < 0 {
|
||||
return f
|
||||
}
|
||||
pow := math.Pow10(precision)
|
||||
return math.Round(f*pow) / pow
|
||||
}
|
||||
```
|
||||
|
||||
同时需在 [common/map.go](d:\work\hotimev1.5\common\map.go) 新增 `GetRoundFloat64`,在 [common/obj.go](d:\work\hotimev1.5\common\obj.go) 新增 `ToRoundFloat64`。
|
||||
|
||||
---
|
||||
|
||||
## 五、修正摘要
|
||||
|
||||
|
||||
| # | 类型 | 位置 | 问题 | 修正 |
|
||||
| --- | ---- | ------------------- | ------------------------- | ---------------------------- |
|
||||
| 1 | 类型混淆 | ObjToFloat64 uint8 | case uint8 应为 case []byte | 改为 case []byte + string() 解析 |
|
||||
| 2 | 类型混淆 | ObjToInt64 uint8 | case uint8 应为 case []byte | 改为 case []byte + string() 解析 |
|
||||
| 3 | 类型混淆 | ObjToStr uint8 | uint8 断言为 string 会 panic | 改为 strconv.Itoa(int(...)) |
|
||||
| 4 | 逻辑 | ObjToMap default | := 遮蔽外部 err,v 赋值逻辑错乱 | 用 = 并加 break |
|
||||
| 5 | 逻辑 | ObjToSlice default | Marshal 错误被 Unmarshal 覆盖 | 失败时 break |
|
||||
| 6 | 逻辑 | ObjToBool default | 成功转换也报错 | 转换成功不报错 |
|
||||
| 7 | 逻辑 | ObjToTime 日期补零 | 单位数日+时间时补不到零 | 分离日期时间部分再补零 |
|
||||
| 8 | 逻辑 | ObjToCeilInt64 | 双重 Ceil + 多余 type switch | 改为 int64(f) |
|
||||
| 9 | 性能 | ObjToTime | ObjToStr 重复调用 5 次 | 缓存结果 |
|
||||
| 10 | 性能 | ObjToSlice []string | 循环内重复类型断言 | 断言一次复用 |
|
||||
| 11 | 性能 | 所有函数 | 未用 switch v := obj.(type) | 改用惯用写法消除冗余断言 |
|
||||
| 12 | 性能 | ObjToStr | 缺少 float32/bool 等常见类型 | 补充直接转换 case |
|
||||
| 13 | 性能 | ObjToMapArray | 切片未预分配容量 | make([]Map, 0, len(s)) |
|
||||
| 14 | 规范 | StrToMap/StrToSlice | 参数名 string 遮蔽内置类型 | 改为 s 或 jsonStr |
|
||||
| 15 | 新增 | ObjToRoundFloat64 | 销量方案需要 | 新增函数 |
|
||||
|
||||
|
||||
@@ -1,311 +0,0 @@
|
||||
---
|
||||
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。
|
||||
@@ -1,106 +0,0 @@
|
||||
---
|
||||
name: Verify统一断言改造
|
||||
overview: 在 Api 上新增 Resp() 方法让 Verify 回调内可以同时做响应值断言和数据库校验,然后更新文档将统一的 Verify 模式作为推荐范式。
|
||||
todos:
|
||||
- id: add-resp-method
|
||||
content: "testing_api.go: Api 新增 lastResp 字段 + Resp() 方法,execute 中调 verifyFn 前设置 lastResp"
|
||||
status: completed
|
||||
- id: update-doc
|
||||
content: "docs/Testing_API测试框架.md: 将 Verify 统一断言作为推荐范式,resp 外部断言降为兼容用法"
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# Verify 统一断言改造
|
||||
|
||||
## 问题
|
||||
|
||||
当前 `Verify(func(a *Api) error)` 的回调只能通过 `a.DB()` 做数据库校验,无法访问响应体。导致值断言必须在外部用 `resp.GetBody()` + `resp.Fail()` 完成,逻辑分散在两处。
|
||||
|
||||
## 方案:Api 上新增 Resp() 方法
|
||||
|
||||
在 `execute` 调用 `verifyFn` 之前,把 `resp` 存到 `Api` 上,然后通过 `a.Resp()` 暴露给回调。
|
||||
|
||||
### 代码改动([testing_api.go](testing_api.go))
|
||||
|
||||
**1. Api 结构体新增字段**(第 46-51 行附近):
|
||||
|
||||
```go
|
||||
type Api struct {
|
||||
app *TestApp
|
||||
path string
|
||||
t *testing.T
|
||||
session Map
|
||||
lastResp *ApiResponse // 新增:最近一次请求的响应
|
||||
}
|
||||
```
|
||||
|
||||
**2. 新增 Resp() 方法**:
|
||||
|
||||
```go
|
||||
// Resp 获取最近一次请求的响应(在 Verify 回调中使用)
|
||||
func (a *Api) Resp() *ApiResponse {
|
||||
return a.lastResp
|
||||
}
|
||||
```
|
||||
|
||||
**3. execute 中调用 verifyFn 前设置 lastResp**(第 296 行附近):
|
||||
|
||||
在 `if passed && c.verifyFn != nil` 之前加一行:
|
||||
|
||||
```go
|
||||
c.api.lastResp = resp
|
||||
```
|
||||
|
||||
**4. WithSession 中传递 lastResp**(可选,因为 WithSession 创建新实例时 lastResp 默认 nil 即可,Verify 内不会跨实例调用)。
|
||||
|
||||
### 改动影响
|
||||
|
||||
- Verify 的签名 `func(a *Api) error` 不变,完全向后兼容
|
||||
- 现有 Verify 回调代码无需修改
|
||||
- 新的 Verify 回调可以用 `a.Resp()` 访问响应体做值断言
|
||||
- 外部 `resp.GetBody()` + `resp.Fail()` 仍然可用,作为兼容方式保留
|
||||
|
||||
### 文档改动([docs/Testing_API测试框架.md](docs/Testing_API测试框架.md))
|
||||
|
||||
将"用例编写范式"中的推荐模式从"Verify 做 DB 校验 + 外部 resp 做值断言"改为**统一在 Verify 内完成**:
|
||||
|
||||
```go
|
||||
resp := a.WithSession(Map{"user_id": int64(1)}).
|
||||
JSON(Map{"goods_id": goodsId, "quantity": 3, "address": "XX路1号"}).
|
||||
Verify(func(a *Api) error {
|
||||
// 响应值断言
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result.GetCeilInt64("id") <= 0 {
|
||||
return fmt.Errorf("订单 ID 必须大于 0")
|
||||
}
|
||||
if result.GetString("sn") == "" {
|
||||
return fmt.Errorf("订单编号 sn 不能为空")
|
||||
}
|
||||
|
||||
// 数据库状态校验
|
||||
row := a.DB().Get("order", "*", Map{"AND": Map{
|
||||
"user_id": int64(1), "goods_id": goodsId,
|
||||
}})
|
||||
if row == nil {
|
||||
return fmt.Errorf("order 表未写入订单记录")
|
||||
}
|
||||
if row.GetCeilInt64("state") != 0 {
|
||||
return fmt.Errorf("新订单 state 期望 0, 实际 %d", row.GetCeilInt64("state"))
|
||||
}
|
||||
// ...
|
||||
return nil
|
||||
}).
|
||||
Post("正常创建订单", 0, Map{
|
||||
"id": int64(1), "sn": "sample", "total_price": float64(1.0),
|
||||
})
|
||||
```
|
||||
|
||||
文档中需要调整的位置:
|
||||
|
||||
- 快速开始的完整范例
|
||||
- 用例编写范式的流程图和表格
|
||||
- "正确请求与响应断言"小节(合并为"正确请求与 Verify 校验")
|
||||
- "不好的写法 vs 推荐的写法"对比
|
||||
- 保留 `resp.GetBody()` + `resp.Fail()` 的说明但标注为兼容用法
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
---
|
||||
name: 优雅停机方案
|
||||
overview: 在 HoTime 框架层实现优雅停机,跨平台(Windows/Linux)支持。收到信号后新请求立即返回 503 让 nginx 切流,等排空后调用 http.Server.Shutdown 等在途请求完成,exit(0) 退出,run_app.cmd 检测正常退出不重启。
|
||||
todos:
|
||||
- id: framework-core
|
||||
content: hotimev1.5/application.go:新增 shuttingDown/shutdownOnce/DrainTimeout/ShutdownTimeout 字段,改 ServeHTTP,改 Run() 信号 goroutine,改 recover 保护,提取 initiateGracefulShutdown()
|
||||
status: completed
|
||||
- id: framework-windows
|
||||
content: hotimev1.5/signal_windows.go(新建):SetConsoleCtrlHandler 处理 CTRL_CLOSE_EVENT(关窗口)
|
||||
status: completed
|
||||
- id: framework-other
|
||||
content: hotimev1.5/signal_other.go(新建):非 Windows 平台 registerWindowsCloseHandler() 空实现
|
||||
status: completed
|
||||
- id: nginx-config
|
||||
content: Nginx location 块加 proxy_next_upstream error timeout http_503 和 proxy_next_upstream_tries 2
|
||||
status: completed
|
||||
- id: cmd-exit-check
|
||||
content: run_app.cmd:检测 EXIT_CODE == 0 时不重启直接退出
|
||||
status: completed
|
||||
- id: docs
|
||||
content: 新建 hotimev1.5/docs/graceful-shutdown.md 文档
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# 优雅停机方案
|
||||
|
||||
## 整体流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Op as 运维
|
||||
participant OS as 操作系统
|
||||
participant App as Go 进程
|
||||
participant Nginx as Nginx
|
||||
|
||||
Op->>OS: Ctrl+C / kill / taskkill / 关窗口
|
||||
OS->>App: SIGINT / SIGTERM / CTRL_CLOSE_EVENT
|
||||
App->>App: shuttingDown = true(sync.Once 保证只执行一次)
|
||||
Note over App,Nginx: 新请求立即返回 503 + Connection:close
|
||||
Nginx->>App: 新请求
|
||||
App-->>Nginx: 503 Service Unavailable
|
||||
Nginx->>Nginx: proxy_next_upstream http_503 → 转另一台
|
||||
Note over App: 等 DrainTimeout(默认5s,让nginx完成切流)
|
||||
App->>App: server.Shutdown(ShutdownTimeout=30s)
|
||||
Note over App: 等所有在途请求跑完
|
||||
App->>OS: os.Exit(0)
|
||||
Note over Op: run_app.cmd 检测 exit 0 → 不重启
|
||||
```
|
||||
|
||||
## 信号触发场景对照表
|
||||
|
||||
| 操作方式 | 平台 | 信号/事件 | 是否可拦截 | 备注 |
|
||||
|---|---|---|---|---|
|
||||
| Ctrl+C | Windows/Linux | `os.Interrupt` | ✅ | 标准中断 |
|
||||
| `kill <pid>` 或 `kill -15 <pid>` | Linux | `syscall.SIGTERM` | ✅ | 推荐生产关闭方式 |
|
||||
| `taskkill /PID <pid>`(无 /F) | Windows | `os.Interrupt` | ✅ | 等价于 Ctrl+C |
|
||||
| 关闭控制台窗口 X 按钮 | Windows | `CTRL_CLOSE_EVENT` | ✅(≤5s 限制)| Windows 给 ~5s 后强杀 |
|
||||
| `taskkill /F /PID <pid>` | Windows | 强制 SIGKILL | ❌ | 无法拦截,强杀 |
|
||||
| `kill -9 <pid>` | Linux | SIGKILL | ❌ | 无法拦截,强杀 |
|
||||
|
||||
> **关窗口说明**:Windows 在触发 `CTRL_CLOSE_EVENT` 后约 5 秒会强制结束进程。框架对此事件使用更短的 drain(默认 2s)+ shutdown(默认 3s),总计 <5s,可完成正在跑的短接口;长接口(>3s)会被截断。推荐用 `taskkill /PID` 代替关窗口做计划性停机。
|
||||
|
||||
---
|
||||
|
||||
## 需要改动/新建的文件
|
||||
|
||||
### 1. [`hotimev1.5/application.go`](d:/work/hotimev1.5/application.go)
|
||||
|
||||
**`Application` struct** 新增字段:
|
||||
```go
|
||||
type Application struct {
|
||||
// ...原有字段...
|
||||
shuttingDown atomic.Bool
|
||||
shutdownOnce sync.Once // 保证多路触发只执行一次
|
||||
DrainTimeout time.Duration // 等 nginx 切流,默认 5s
|
||||
ShutdownTimeout time.Duration // 等在途请求,默认 30s
|
||||
}
|
||||
```
|
||||
|
||||
**`ServeHTTP`** 新增停机检测:
|
||||
```go
|
||||
func (that *Application) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
if that.shuttingDown.Load() {
|
||||
w.Header().Set("Connection", "close")
|
||||
http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
that.handler(w, req)
|
||||
}
|
||||
```
|
||||
|
||||
**提取 `initiateGracefulShutdown(drain, shutdown time.Duration)` 方法**(被信号 goroutine 和 Windows 关窗 handler 共同调用):
|
||||
```go
|
||||
func (that *Application) initiateGracefulShutdown(drain, shutdown time.Duration) {
|
||||
that.shutdownOnce.Do(func() {
|
||||
that.shuttingDown.Store(true)
|
||||
that.Log.Infof("优雅停机:等 %v 让 nginx 切流...", drain)
|
||||
time.Sleep(drain)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shutdown)
|
||||
defer cancel()
|
||||
that.Log.Infof("等进行中请求完成(最多 %v)...", shutdown)
|
||||
if err := that.Server.Shutdown(ctx); err != nil {
|
||||
that.Log.Errorf("优雅停机超时: %v", err)
|
||||
}
|
||||
that.Log.Infof("服务已安全关闭")
|
||||
os.Exit(0)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**`Run()`** 加信号监听 goroutine(在启动 ListenAndServe 之后):
|
||||
```go
|
||||
// 注册 Windows 关窗口处理(Linux 上是空实现)
|
||||
that.registerWindowsCloseHandler()
|
||||
|
||||
// 跨平台:Ctrl+C / SIGTERM / kill
|
||||
go func() {
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||
<-sigCh
|
||||
drain := that.DrainTimeout
|
||||
if drain == 0 { drain = 5 * time.Second }
|
||||
shutdown := that.ShutdownTimeout
|
||||
if shutdown == 0 { shutdown = 30 * time.Second }
|
||||
that.initiateGracefulShutdown(drain, shutdown)
|
||||
}()
|
||||
```
|
||||
|
||||
**`defer recover`** 加停机保护:
|
||||
```go
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
if that.shuttingDown.Load() {
|
||||
return // 停机中的 panic 不触发重启
|
||||
}
|
||||
that.Log.Warnf("%v", err)
|
||||
that.Run(router)
|
||||
}
|
||||
}()
|
||||
```
|
||||
|
||||
新增 import:`"context"`, `"os/signal"`, `"sync"`, `"sync/atomic"`, `"syscall"`
|
||||
|
||||
---
|
||||
|
||||
### 2. `hotimev1.5/signal_windows.go`(新建)
|
||||
|
||||
```go
|
||||
//go:build windows
|
||||
|
||||
package hotime
|
||||
|
||||
import (
|
||||
"time"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func (that *Application) registerWindowsCloseHandler() {
|
||||
windows.SetConsoleCtrlHandler(func(ctrl uint32) bool {
|
||||
// CTRL_CLOSE_EVENT=2 CTRL_LOGOFF_EVENT=5 CTRL_SHUTDOWN_EVENT=6
|
||||
if ctrl == 2 || ctrl == 5 || ctrl == 6 {
|
||||
that.initiateGracefulShutdown(2*time.Second, 3*time.Second)
|
||||
// 阻塞此回调,让 initiateGracefulShutdown 内的 os.Exit(0) 执行
|
||||
// Windows 约 5s 后强杀,这里最多等 6s 兜底
|
||||
time.Sleep(6 * time.Second)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}, true)
|
||||
}
|
||||
```
|
||||
|
||||
> `golang.org/x/sys` 已在 `xbc/go.mod` 中作为间接依赖存在(v0.29.0),无需新增依赖。
|
||||
|
||||
---
|
||||
|
||||
### 3. `hotimev1.5/signal_other.go`(新建)
|
||||
|
||||
```go
|
||||
//go:build !windows
|
||||
|
||||
package hotime
|
||||
|
||||
func (that *Application) registerWindowsCloseHandler() {}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Nginx 配置(在已有 location 块中追加)
|
||||
|
||||
```nginx
|
||||
# 让后端返回 503 时自动切换到另一台(优雅停机核心)
|
||||
proxy_next_upstream error timeout http_503;
|
||||
proxy_next_upstream_tries 2;
|
||||
```
|
||||
|
||||
已有的 `max_fails=1 fail_timeout=3s` 负责连接级失败(强杀后的端口拒绝),两者互补。
|
||||
|
||||
---
|
||||
|
||||
### 5. [`run_app.cmd`](d:/work/your-app/run_app.cmd)
|
||||
|
||||
在 `goto run_app` 前插入 exit code 判断:
|
||||
|
||||
```cmd
|
||||
:: 优雅退出 (exit 0) 不重启
|
||||
if %EXIT_CODE% EQU 0 (
|
||||
echo [%date% %time%] Graceful shutdown. Not restarting. >> "%LOG_FILE%"
|
||||
echo.
|
||||
echo [优雅停机] 服务已安全关闭,不再自动重启。
|
||||
pause
|
||||
exit /b 0
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. `hotimev1.5/docs/graceful-shutdown.md`(新建)
|
||||
|
||||
包含:原理说明、场景对照表、nginx 配置、滚动重启步骤、参数说明(DrainTimeout/ShutdownTimeout)。
|
||||
|
||||
---
|
||||
|
||||
## 滚动重启操作步骤(Windows)
|
||||
|
||||
```
|
||||
# 1. 找到端口 9998 的进程 PID
|
||||
netstat -ano | findstr 9998
|
||||
|
||||
# 2. 优雅关闭该实例(不加 /F)
|
||||
taskkill /PID <pid>
|
||||
|
||||
# 3. 等日志出现 "服务已安全关闭" 后再部署新版并启动
|
||||
# 4. 再对 9999 执行同样操作
|
||||
```
|
||||
|
||||
## 滚动重启操作步骤(Linux)
|
||||
|
||||
```bash
|
||||
# 找 PID
|
||||
pgrep xbc # 或 ps aux | grep xbc
|
||||
|
||||
# 优雅关闭
|
||||
kill <pid> # 发 SIGTERM,触发优雅停机
|
||||
# 或
|
||||
kill -2 <pid> # 发 SIGINT,等同 Ctrl+C
|
||||
|
||||
# 等日志出现 "服务已安全关闭" 再部署新版
|
||||
```
|
||||
@@ -1,50 +0,0 @@
|
||||
---
|
||||
name: 修复DM列注释读取
|
||||
overview: 修复达梦数据库列注释(COMMENT)未被正确读取的问题,导致管理端所有字段显示为数据库字段名而非中文备注。
|
||||
todos:
|
||||
- id: fix-dm-col-comments
|
||||
content: 修改 makecode.go 列信息查询,列注释改用 USER_COL_COMMENTS 替代 ALL_COL_COMMENTS
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# 修复达梦数据库列注释读取失败
|
||||
|
||||
## 问题分析
|
||||
|
||||
通过 MCP 查询 `adminDB.json` 确认:所有表的字段 label 全部等于字段名本身(如 `"id"`, `"name"`, `"phone"`),没有中文备注。但表级 label 正常(如"消息主表"、"用户表")。
|
||||
|
||||
用户确认数据库字段确实有备注。
|
||||
|
||||
### 根因
|
||||
|
||||
[makecode.go](d:\work\hotimev1.5\code\makecode.go) 第 170 行当前的列查询 SQL:
|
||||
|
||||
```sql
|
||||
FROM ALL_TAB_COLUMNS c
|
||||
LEFT JOIN ALL_COL_COMMENTS m
|
||||
ON c.TABLE_NAME=m.TABLE_NAME AND c.COLUMN_NAME=m.COLUMN_NAME AND c.OWNER=m.OWNER
|
||||
WHERE c.TABLE_NAME='...' AND c.OWNER='ZWBG'
|
||||
```
|
||||
|
||||
`LEFT JOIN` 条件中 `c.OWNER=m.OWNER` 要求 `ALL_COL_COMMENTS` 的 `OWNER` 也等于 `'ZWBG'`。但在 DM 中,`ALL_COL_COMMENTS` 的 `OWNER` 值可能与 `ALL_TAB_COLUMNS` 不一致(DM 的 Oracle 兼容视图在 OWNER 语义上可能存在差异),导致 JOIN 匹配不上,`m.COMMENTS` 全部为 NULL。
|
||||
|
||||
对比:表注释用的 `ALL_TAB_COMMENTS WHERE OWNER='ZWBG'` 能正确返回中文备注,说明表级视图的 OWNER 一致,但列级视图可能不同。
|
||||
|
||||
## 修复方案
|
||||
|
||||
修改 [makecode.go](d:\work\hotimev1.5\code\makecode.go) 第 169-171 行,列注释改用 `USER_COL_COMMENTS`(自动使用当前 schema 上下文 ZWBG,无需 OWNER 过滤),列元数据继续用 `ALL_TAB_COLUMNS`(保留 OWNER 过滤防止系统表):
|
||||
|
||||
```go
|
||||
if db.Type == "dm" || db.Type == "dameng" {
|
||||
tableInfo = db.Query(`SELECT c.COLUMN_NAME AS "name", c.DATA_TYPE AS "type", m.COMMENTS AS "label", c.NULLABLE AS "must", c.DATA_DEFAULT AS "dflt_value" FROM ALL_TAB_COLUMNS c LEFT JOIN USER_COL_COMMENTS m ON c.TABLE_NAME=m.TABLE_NAME AND c.COLUMN_NAME=m.COLUMN_NAME WHERE c.TABLE_NAME='` + v.GetString("name") + `' AND c.OWNER='` + strings.ToUpper(db.DBName) + `' ORDER BY c.COLUMN_ID`)
|
||||
}
|
||||
```
|
||||
|
||||
关键变化:
|
||||
|
||||
- `ALL_COL_COMMENTS` 改为 `USER_COL_COMMENTS`
|
||||
- 去掉 JOIN 中的 `AND c.OWNER=m.OWNER`(`USER_COL_COMMENTS` 没有 OWNER 列)
|
||||
- WHERE 条件 `c.OWNER='ZWBG'` 保留(作用于 `ALL_TAB_COLUMNS`,防止系统表列混入)
|
||||
|
||||
原理:框架连接 DM 用 `?schema=ZWBG`,`USER_COL_COMMENTS` 自动返回当前 schema(ZWBG)的列注释,绕开了 OWNER 不一致问题。
|
||||
@@ -1,81 +0,0 @@
|
||||
---
|
||||
name: 修复DM类型映射大小写
|
||||
overview: DM 返回的列类型为大写(BIGINT、VARCHAR、INT、TIMESTAMP),但 ColumnDataType 的匹配键是小写且 strings.Contains 区分大小写,导致类型映射全部失败。
|
||||
todos:
|
||||
- id: fix-type-case
|
||||
content: 修改 makecode.go 类型比较加 strings.ToLower 忽略大小写
|
||||
status: completed
|
||||
- id: add-dm-types
|
||||
content: 在 config.go 的 ColumnDataType 中追加 DM 特有数据类型映射
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# 修复达梦数据库列类型映射失败
|
||||
|
||||
## 问题分析
|
||||
|
||||
通过 MCP 查询 `adminDB.json` 确认:大量字段 type 仍为 DM 原始类型(`BIGINT`、`VARCHAR`、`INT`、`TIMESTAMP`、`DECIMAL`),未映射到框架类型(`number`、`text`、`time`),导致前端编辑页无法识别控件类型。
|
||||
|
||||
### 根因
|
||||
|
||||
[config.go](d:\work\hotimev1.5\code\config.go) 第 43-59 行定义的 `ColumnDataType` 映射表键全部为小写:
|
||||
|
||||
```go
|
||||
var ColumnDataType = map[string]string{
|
||||
"int": "number",
|
||||
"char": "text",
|
||||
"text": "text",
|
||||
"time": "time",
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
[makecode.go](d:\work\hotimev1.5\code\makecode.go) 第 191-194 行的匹配逻辑使用 `strings.Contains`,**区分大小写**:
|
||||
|
||||
```go
|
||||
for k, v1 := range ColumnDataType {
|
||||
if strings.Contains(info.GetString("type"), k) { // "BIGINT" 不包含 "int"
|
||||
info["type"] = v1
|
||||
break
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
DM 返回的类型全部为大写(`INT`、`VARCHAR`、`BIGINT`),而键为小写(`int`、`char`),`strings.Contains("BIGINT", "int")` 返回 `false`,映射全部跳过。
|
||||
|
||||
## 修复方案
|
||||
|
||||
### 修改 1:类型比较忽略大小写 - [makecode.go](d:\work\hotimev1.5\code\makecode.go)
|
||||
|
||||
第 192 行,将 `info.GetString("type")` 转为小写再匹配:
|
||||
|
||||
```go
|
||||
if strings.Contains(strings.ToLower(info.GetString("type")), k) {
|
||||
```
|
||||
|
||||
效果验证:
|
||||
|
||||
- `"BIGINT"` -> `"bigint"` -> contains `"int"` -> `"number"`
|
||||
- `"VARCHAR"` -> `"varchar"` -> contains `"char"` -> `"text"`
|
||||
- `"INT"` -> `"int"` -> contains `"int"` -> `"number"`
|
||||
- `"TIMESTAMP"` -> `"timestamp"` -> contains `"time"` -> `"time"`
|
||||
- `"DECIMAL"` -> `"decimal"` -> contains `"decimal"` -> `"number"`
|
||||
|
||||
### 修改 2:补充 DM 特有类型 - [config.go](d:\work\hotimev1.5\code\config.go)
|
||||
|
||||
在 `ColumnDataType` 中追加 DM 特有的数据类型,确保 CLOB、NUMBER 等也能被识别:
|
||||
|
||||
```go
|
||||
var ColumnDataType = map[string]string{
|
||||
// 现有 MySQL/SQLite 类型...
|
||||
// DM 特有类型
|
||||
"clob": "text",
|
||||
"number": "number",
|
||||
"numeric": "number",
|
||||
"binary": "text",
|
||||
"boolean": "number",
|
||||
"bit": "number",
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
---
|
||||
name: 修复Table和Edit组件
|
||||
overview: 修复 Table.vue 的重复请求和搜索防抖问题,以及 Edit.vue 密码改为弹窗独立修改。
|
||||
todos:
|
||||
- id: fix-double-request
|
||||
content: "Table.vue: 添加 _skipFormWatch 标志,修复 mounted 和 watcher 导致的重复请求"
|
||||
status: completed
|
||||
- id: fix-keyword-search
|
||||
content: "Table.vue: 关键字搜索改为点击按钮/Enter触发,不再实时触发"
|
||||
status: completed
|
||||
- id: fix-password-dialog
|
||||
content: "Edit.vue: 密码字段改为弹窗独立修改,主表单提交排除密码数据"
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# 修复 Table 重复请求、搜索防抖、Edit 密码弹窗
|
||||
|
||||
## 1. 修复重复请求 - [Table.vue](d:\work\myst-web\src\components\Table.vue)
|
||||
|
||||
**根因**:`mounted()` 中既修改了 form 属性(触发 deep watcher),又显式调用了 `formDebounce`,两者可能各触发一次请求。
|
||||
|
||||
**方案**:在 data 中增加 `_skipFormWatch: true` 标志,mounted 中所有 form 初始化完成后调用一次 `formDebounce`,然后在 `$nextTick` 中将标志设为 false。watcher 中检查该标志,初始化期间跳过。
|
||||
|
||||
```javascript
|
||||
// data 中
|
||||
_skipFormWatch: true,
|
||||
|
||||
// watcher 中
|
||||
form: {
|
||||
handler() {
|
||||
if (this._skipFormWatch) return;
|
||||
this.formDebounce(this);
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
|
||||
// mounted 末尾
|
||||
this.formDebounce(this);
|
||||
this.$nextTick(() => { this._skipFormWatch = false; });
|
||||
```
|
||||
|
||||
## 2. 搜索改为点击触发 - [Table.vue](d:\work\myst-web\src\components\Table.vue)
|
||||
|
||||
**方案**:将关键字搜索从实时触发改为点击搜索按钮触发。
|
||||
|
||||
- 新增 `searchKeyword: ""` data 属性,输入框 `v-model` 改为绑定 `searchKeyword`
|
||||
- 搜索按钮添加 `@click="doSearch"` 事件
|
||||
- `doSearch` 方法将 `searchKeyword` 赋值给 `form.keyword`(触发 watcher 进行搜索)
|
||||
- 输入框支持 Enter 键触发搜索(`@keyup.enter`)
|
||||
- 重置按钮也同步清空 `searchKeyword`
|
||||
|
||||
关键模板改动:
|
||||
|
||||
```html
|
||||
<el-input v-model="searchKeyword" @keyup.enter="doSearch" ...>
|
||||
<template #append>
|
||||
<el-button class="search-btn" @click="doSearch">
|
||||
<el-icon><Search/></el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
```
|
||||
|
||||
## 3. 密码改为弹窗独立修改 - [Edit.vue](d:\work\myst-web\src\components\Edit.vue)
|
||||
|
||||
**方案**:
|
||||
|
||||
- 主表单中隐藏 password 类型字段,改为显示"修改密码"按钮
|
||||
- 新增密码修改弹窗(`el-dialog`),包含新密码 + 确认密码两个输入
|
||||
- 弹窗提交时校验两次输入一致,然后仅发送密码字段调用 `edit` API
|
||||
- 主表单 `onSubmit` 提交数据时过滤掉 password 类型字段
|
||||
|
||||
关键改动点:
|
||||
|
||||
**模板** - 密码字段区域(约第55行):
|
||||
|
||||
```html
|
||||
<!-- 原密码输入改为修改密码按钮 -->
|
||||
<el-button v-if="column.type=='password'" type="warning" @click="showPasswordDialog = true">
|
||||
修改密码
|
||||
</el-button>
|
||||
<!-- 其他非密码字段保持原样 -->
|
||||
<el-input v-model="form[column.value]" show-password v-else-if="..." />
|
||||
```
|
||||
|
||||
**data** 新增:
|
||||
|
||||
```javascript
|
||||
showPasswordDialog: false,
|
||||
passwordForm: { password: '', confirmPassword: '' },
|
||||
passwordColumn: null,
|
||||
```
|
||||
|
||||
**methods** 新增 `submitPassword` 和修改 `onSubmit`:
|
||||
|
||||
- `submitPassword`:校验密码一致性,调用 `edit` API 仅提交密码字段
|
||||
- `onSubmit`:提交时从 form 中排除 password 类型字段的数据
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
---
|
||||
name: 修复路径和测试数据
|
||||
overview: 修复测试时 swagger/config 生成到错误目录的问题(根因是 Go test CWD 在 app/ 下),并为 DM 和 MySQL 两个测试数据库创建完善的测试数据表和种子数据。
|
||||
todos:
|
||||
- id: fix-cwd
|
||||
content: 修复 app_test.go 中的工作目录问题:添加 os.Chdir("..") 并修改 config path
|
||||
status: completed
|
||||
- id: cleanup-wrong-dirs
|
||||
content: 清理错误生成的 example/app/tpt/ 和 example/app/config/ 目录
|
||||
status: completed
|
||||
- id: setup-mysql
|
||||
content: 创建 setup_mysql.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-"`。
|
||||
@@ -1,110 +0,0 @@
|
||||
---
|
||||
name: 修复达梦数据库越界和引号
|
||||
overview: 修复 HoTime 框架代码生成器的两个达梦(DM)数据库问题:(1) 获取表列表时越界包含了系统表;(2) SQL 查询使用了 MySQL 反引号语法导致达梦解析失败。
|
||||
todos:
|
||||
- id: fix-dm-table-filter
|
||||
content: 修改 code/makecode.go 的达梦表列表和列信息查询,使用 ALL_TAB_COMMENTS + OWNER 过滤
|
||||
status: completed
|
||||
- id: fix-backtick-convert
|
||||
content: 修改 db/identifier.go 的 ProcessFieldList,增加 convertQuotes 调用转换残留反引号
|
||||
status: completed
|
||||
- id: regen-admin-json
|
||||
content: 删除/清理 myst-go/config/admin.json 中的系统表条目,重新生成配置
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# 修复达梦数据库越界获取系统表和反引号语法错误
|
||||
|
||||
## 问题分析
|
||||
|
||||
### 问题 1:越界获取非 ZWBG 数据库的表
|
||||
|
||||
[code/makecode.go](d:\work\hotimev1.5\code\makecode.go) 第 121-122 行:
|
||||
|
||||
```go
|
||||
nowTables = db.Query(`SELECT TABLE_NAME AS "name", COMMENTS AS "label" FROM USER_TAB_COMMENTS WHERE TABLE_TYPE='TABLE'`)
|
||||
```
|
||||
|
||||
使用 `SYSDBA` 用户连接达梦时,`USER_TAB_COMMENTS` 返回该用户下**所有表**,包括达梦系统表(`SYSJOBS`、`SYSJOBSCHEDULES`、`SYSALERTS`、`##HISTOGRAMS_TABLE` 等),导致 [admin.json](d:\work\myst-go\config\admin.json) 中混入了大量不属于 ZWBG 业务的系统表。
|
||||
|
||||
同样的问题存在于第 169-170 行的列信息查询(`USER_TAB_COLUMNS` / `USER_COL_COMMENTS`)。
|
||||
|
||||
### 问题 2:反引号导致 SQL 语法错误(遗留问题)
|
||||
|
||||
`makecode.go` 中 `Info`(第 725 行)和 `Search`(第 958 行)使用反引号拼接列名字符串:``col1`,`col2`,...`。
|
||||
|
||||
`crud.go` 的 `Select` 方法有**两种字段处理路径**:
|
||||
|
||||
- **Slice 路径**(第 100-109 行):每个列名走 `ProcessColumnNoPrefix` -> `QuoteIdentifier`,能正确转换引号
|
||||
- **String 路径**(第 93-99 行):整个字符串走 `ProcessFieldList` -> `ProcessConditionString`,三个正则都只匹配 `table.column` 格式(含 `.`),不处理单独的反引号列名
|
||||
|
||||
`makecode.go` 传的是拼接好的**字符串**,走的是 String 路径,所以反引号不会被自动转换,导致达梦报语法错误。
|
||||
|
||||
---
|
||||
|
||||
## 修复方案
|
||||
|
||||
### 修改 1:过滤达梦系统表 - [code/makecode.go](d:\work\hotimev1.5\code\makecode.go)
|
||||
|
||||
将 `USER_TAB_COMMENTS` / `USER_TAB_COLUMNS` 改为 `ALL_TAB_COMMENTS` / `ALL_TAB_COLUMNS`,并通过 `OWNER` 过滤为 `db.DBName`(即 `ZWBG`),确保只获取目标 schema 的业务表。
|
||||
|
||||
**第 121-122 行**,表列表查询改为:
|
||||
|
||||
```go
|
||||
if db.Type == "dm" || db.Type == "dameng" {
|
||||
nowTables = db.Query(`SELECT TABLE_NAME AS "name", COMMENTS AS "label" FROM ALL_TAB_COMMENTS WHERE TABLE_TYPE='TABLE' AND OWNER='` + strings.ToUpper(db.DBName) + `'`)
|
||||
}
|
||||
```
|
||||
|
||||
**第 169-170 行**,列信息查询改为:
|
||||
|
||||
```go
|
||||
if db.Type == "dm" || db.Type == "dameng" {
|
||||
tableInfo = db.Query(`SELECT c.COLUMN_NAME AS "name", c.DATA_TYPE AS "type", m.COMMENTS AS "label", c.NULLABLE AS "must", c.DATA_DEFAULT AS "dflt_value" FROM ALL_TAB_COLUMNS c LEFT JOIN ALL_COL_COMMENTS m ON c.TABLE_NAME=m.TABLE_NAME AND c.COLUMN_NAME=m.COLUMN_NAME AND c.OWNER=m.OWNER WHERE c.TABLE_NAME='` + v.GetString("name") + `' AND c.OWNER='` + strings.ToUpper(db.DBName) + `' ORDER BY c.COLUMN_ID`)
|
||||
}
|
||||
```
|
||||
|
||||
### 修改 2:修复反引号转换 - [db/identifier.go](d:\work\hotimev1.5\db\identifier.go)
|
||||
|
||||
`makecode.go` 的反引号属于遗留代码,理论上 `Select` 的 Slice 路径已能自动处理引号,但 `makecode.go` 传的是字符串。最小改动:在 `ProcessFieldList` 末尾加 `convertQuotes` 调用,将 `ProcessConditionString` 未能匹配到的残留反引号统一转为当前方言引号:
|
||||
|
||||
```go
|
||||
func (p *IdentifierProcessor) ProcessFieldList(fields string) string {
|
||||
if fields == "" || fields == "*" {
|
||||
return fields
|
||||
}
|
||||
result := p.ProcessConditionString(fields)
|
||||
result = p.convertQuotes(result)
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
`convertQuotes`(第 240-248 行)已实现引号转换:达梦将反引号转为 `"`,MySQL 保持不变(no-op)。这样无需修改 `makecode.go` 中的遗留反引号代码,所有走 `ProcessFieldList` 的字符串都能被正确处理。
|
||||
|
||||
### 修改 3:重新生成 admin.json - [config/admin.json](d:\work\myst-go\config\admin.json)
|
||||
|
||||
代码修复后,需要删除当前 `admin.json`(已包含系统表污染)并重启应用让其自动重新生成。需要注意的是:
|
||||
|
||||
- 如果 `admin.json` 中有手工定制的菜单/权限配置,**需先备份**再删除
|
||||
- 或者手动从 `admin.json` 中删除以下系统表条目:`SYSJOBSCHEDULES`、`SYSJOBSTEPS`、`SYSJOBHISTORIES`、`SYSJOBHISTORIES2`、`SYSSTEPHISTORIES2`、`SYSALERTHISTORIES`、`SYSALERTNOTIFICATIONS`、`SYSALERTS`、`SYSMAILINFO`、`SYSOPERATORS`、`SYSJOBS`、`##HISTOGRAMS_TABLE`、`cached`、`hotime_cache`、`test_batch`
|
||||
|
||||
---
|
||||
|
||||
## 数据流概览
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph code_gen [代码生成 - makecode.go]
|
||||
A["Db2JSON()"] -->|"USER_TAB_COMMENTS (修复前)"| B["获取所有表含系统表"]
|
||||
A -->|"ALL_TAB_COMMENTS + OWNER (修复后)"| C["仅获取ZWBG业务表"]
|
||||
end
|
||||
subgraph query [查询执行 - code.go + crud.go]
|
||||
D["Info/Search 拼字段"] -->|反引号列名| E["ProcessFieldList"]
|
||||
E -->|"ProcessConditionString (不匹配)"| F["反引号保留 -> DM报错"]
|
||||
E -->|"+ convertQuotes (修复后)"| G["转为双引号 -> DM正常"]
|
||||
end
|
||||
```
|
||||
|
||||
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
---
|
||||
name: 多数据库方言与前缀支持
|
||||
overview: 为 HoTimeDB ORM 实现完整的多数据库(MySQL/PostgreSQL/SQLite)方言支持和自动表前缀功能,采用智能解析+辅助方法兜底的混合策略,保持完全向后兼容。
|
||||
todos:
|
||||
- id: dialect-interface
|
||||
content: 扩展 Dialect 接口,添加 QuoteIdentifier 和 QuoteChar 方法
|
||||
status: completed
|
||||
- id: identifier-processor
|
||||
content: 新建 identifier.go,实现 IdentifierProcessor 及智能解析逻辑
|
||||
status: completed
|
||||
- id: db-integration
|
||||
content: 在 db.go 中集成处理器,添加 T() 和 C() 辅助方法
|
||||
status: completed
|
||||
- id: crud-update
|
||||
content: 修改 crud.go 中 Select/Insert/Update/Delete/buildJoin 等方法
|
||||
status: completed
|
||||
- id: where-update
|
||||
content: 修改 where.go 中 varCond 等条件处理方法
|
||||
status: completed
|
||||
- id: builder-check
|
||||
content: 检查 builder.go 是否需要额外修改
|
||||
status: completed
|
||||
- id: testing
|
||||
content: 编写测试用例验证多数据库和前缀功能
|
||||
status: completed
|
||||
- id: todo-1769037903242-d7aip6nh1
|
||||
content: ""
|
||||
status: pending
|
||||
---
|
||||
|
||||
# HoTimeDB 多数据库方言与自动前缀支持计划(更新版)
|
||||
|
||||
## 目标
|
||||
|
||||
1. **多数据库方言支持**:MySQL、PostgreSQL、SQLite 标识符引号自动转换
|
||||
2. **自动表前缀**:主表、JOIN 表、ON/WHERE 条件中的表名自动添加前缀
|
||||
3. **完全向后兼容**:用户现有写法无需修改
|
||||
4. **辅助方法兜底**:边缘情况可用 `T()` / `C()` 精确控制
|
||||
|
||||
## 混合策略设计
|
||||
|
||||
### 各部分处理方式
|
||||
|
||||
| 位置 | 处理方式 | 准确度 | 说明 |
|
||||
|
||||
|------|---------|--------|------|
|
||||
|
||||
| 主表名 | 自动 | 100% | `Select("order")` 自动处理 |
|
||||
|
||||
| JOIN 表名 | 自动 | 100% | `[><]order` 中提取表名处理 |
|
||||
|
||||
| ON 条件字符串 | 智能解析 | ~95% | 正则匹配 `table.column` 模式 |
|
||||
|
||||
| WHERE 条件 Map | 自动 | 100% | Map 的 key 是结构化的 |
|
||||
|
||||
| SELECT 字段 | 智能解析 | ~95% | 同 ON 条件 |
|
||||
|
||||
### 辅助方法(兜底)
|
||||
|
||||
```go
|
||||
db.T("order") // 返回 "`app_order`" (MySQL) 或 "\"app_order\"" (PG)
|
||||
db.C("order", "name") // 返回 "`app_order`.`name`"
|
||||
db.C("order.name") // 同上,支持点号格式
|
||||
```
|
||||
|
||||
## 实现步骤
|
||||
|
||||
### 第1步:扩展 Dialect 接口([db/dialect.go](db/dialect.go))
|
||||
|
||||
添加新方法到 `Dialect` 接口:
|
||||
|
||||
```go
|
||||
// QuoteIdentifier 处理单个标识符(去除已有引号,添加正确引号)
|
||||
QuoteIdentifier(name string) string
|
||||
|
||||
// QuoteChar 返回引号字符
|
||||
QuoteChar() string
|
||||
```
|
||||
|
||||
三种方言实现:
|
||||
|
||||
- MySQL: 反引号 `` ` ``
|
||||
- PostgreSQL/SQLite: 双引号 `"`
|
||||
|
||||
### 第2步:添加标识符处理器([db/identifier.go](db/identifier.go) 新文件)
|
||||
|
||||
```go
|
||||
type IdentifierProcessor struct {
|
||||
dialect Dialect
|
||||
prefix string
|
||||
}
|
||||
|
||||
// ProcessTableName 处理表名(添加前缀+引号)
|
||||
// "order" → "`app_order`"
|
||||
func (p *IdentifierProcessor) ProcessTableName(name string) string
|
||||
|
||||
// ProcessColumn 处理 table.column 格式
|
||||
// "order.name" → "`app_order`.`name`"
|
||||
// "`order`.name" → "`app_order`.`name`"
|
||||
func (p *IdentifierProcessor) ProcessColumn(name string) string
|
||||
|
||||
// ProcessConditionString 智能解析条件字符串
|
||||
// "user.id = order.user_id" → "`app_user`.`id` = `app_order`.`user_id`"
|
||||
func (p *IdentifierProcessor) ProcessConditionString(condition string) string
|
||||
|
||||
// ProcessFieldList 处理字段列表字符串
|
||||
// "order.id, user.name AS uname" → "`app_order`.`id`, `app_user`.`name` AS uname"
|
||||
func (p *IdentifierProcessor) ProcessFieldList(fields string) string
|
||||
```
|
||||
|
||||
**智能解析正则**:
|
||||
|
||||
```go
|
||||
// 匹配 table.column 模式,排除已有引号、函数调用等
|
||||
// 模式: \b([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)\b
|
||||
// 排除: `table`.column, "table".column, FUNC(), 123.456
|
||||
```
|
||||
|
||||
### 第3步:在 HoTimeDB 中集成([db/db.go](db/db.go))
|
||||
|
||||
```go
|
||||
// processor 缓存
|
||||
var processorOnce sync.Once
|
||||
var processor *IdentifierProcessor
|
||||
|
||||
// GetProcessor 获取标识符处理器
|
||||
func (that *HoTimeDB) GetProcessor() *IdentifierProcessor
|
||||
|
||||
// T 辅助方法:获取带前缀和引号的表名
|
||||
func (that *HoTimeDB) T(table string) string
|
||||
|
||||
// C 辅助方法:获取带前缀和引号的 table.column
|
||||
func (that *HoTimeDB) C(args ...string) string
|
||||
```
|
||||
|
||||
### 第4步:修改 CRUD 方法([db/crud.go](db/crud.go))
|
||||
|
||||
**Select 方法改动**:
|
||||
|
||||
```go
|
||||
// L112-116 原代码
|
||||
if !strings.Contains(table, ".") && !strings.Contains(table, " AS ") {
|
||||
query += " FROM `" + that.Prefix + table + "` "
|
||||
} else {
|
||||
query += " FROM " + that.Prefix + table + " "
|
||||
}
|
||||
|
||||
// 改为
|
||||
query += " FROM " + that.GetProcessor().ProcessTableName(table) + " "
|
||||
|
||||
// 字段列表处理(L90-107)
|
||||
// 如果是字符串,调用 ProcessFieldList 处理
|
||||
```
|
||||
|
||||
**buildJoin 方法改动**(L156-222):
|
||||
|
||||
```go
|
||||
// 原代码 L186-190
|
||||
table := Substr(k, 3, len(k)-3)
|
||||
if !strings.Contains(table, " ") {
|
||||
table = "`" + table + "`"
|
||||
}
|
||||
query += " LEFT JOIN " + table + " ON " + v.(string) + " "
|
||||
|
||||
// 改为
|
||||
table := Substr(k, 3, len(k)-3)
|
||||
table = that.GetProcessor().ProcessTableName(table)
|
||||
onCondition := that.GetProcessor().ProcessConditionString(v.(string))
|
||||
query += " LEFT JOIN " + table + " ON " + onCondition + " "
|
||||
```
|
||||
|
||||
**Insert/Inserts/Update/Delete** 同样修改表名和字段名处理。
|
||||
|
||||
### 第5步:修改 WHERE 条件处理([db/where.go](db/where.go))
|
||||
|
||||
**varCond 方法改动**(多处):
|
||||
|
||||
```go
|
||||
// 原代码(多处出现)
|
||||
if !strings.Contains(k, ".") {
|
||||
k = "`" + k + "`"
|
||||
}
|
||||
|
||||
// 改为
|
||||
k = that.GetProcessor().ProcessColumn(k)
|
||||
```
|
||||
|
||||
需要修改的函数:
|
||||
|
||||
- `varCond` (L205-338)
|
||||
- `handleDefaultCondition` (L340-368)
|
||||
- `handlePlainField` (L370-400)
|
||||
|
||||
### 第6步:修改链式构建器([db/builder.go](db/builder.go))
|
||||
|
||||
**LeftJoin 等方法需要传递处理器**:
|
||||
|
||||
由于 builder 持有 HoTimeDB 引用,可以直接使用:
|
||||
|
||||
```go
|
||||
func (that *HotimeDBBuilder) LeftJoin(table, joinStr string) *HotimeDBBuilder {
|
||||
// 不在这里处理,让 buildJoin 统一处理
|
||||
that.Join(Map{"[>]" + table: joinStr})
|
||||
return that
|
||||
}
|
||||
```
|
||||
|
||||
JOIN 的实际处理在 `crud.go` 的 `buildJoin` 中完成。
|
||||
|
||||
## 智能解析的边界处理
|
||||
|
||||
### 会自动处理的情况
|
||||
|
||||
- `user.id = order.user_id` → 正确处理
|
||||
- `user.id=order.user_id` → 正确处理(无空格)
|
||||
- `` `user`.id = order.user_id `` → 正确处理(混合格式)
|
||||
- `user.id = order.user_id AND order.status = 1` → 正确处理
|
||||
|
||||
### 需要辅助方法的边缘情况
|
||||
|
||||
- 子查询中的表名
|
||||
- 复杂 CASE WHEN 表达式
|
||||
- 动态拼接的 SQL 片段
|
||||
|
||||
## 文件清单
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
|
||||
|------|------|------|
|
||||
|
||||
| [db/dialect.go](db/dialect.go) | 修改 | 扩展 Dialect 接口 |
|
||||
|
||||
| [db/identifier.go](db/identifier.go) | 新增 | IdentifierProcessor 实现 |
|
||||
|
||||
| [db/db.go](db/db.go) | 修改 | 集成处理器,添加 T()/C() 方法 |
|
||||
|
||||
| [db/crud.go](db/crud.go) | 修改 | 修改所有 CRUD 方法 |
|
||||
|
||||
| [db/where.go](db/where.go) | 修改 | 修改条件处理逻辑 |
|
||||
|
||||
| [db/builder.go](db/builder.go) | 检查 | 可能无需修改(buildJoin 统一处理)|
|
||||
|
||||
## 测试用例
|
||||
|
||||
1. **多数据库切换**:MySQL → PostgreSQL → SQLite
|
||||
2. **前缀场景**:有前缀 vs 无前缀
|
||||
3. **复杂 JOIN**:多表 JOIN + 复杂 ON 条件
|
||||
4. **混合写法**:`order.name` + `` `user`.id `` 混用
|
||||
5. **辅助方法**:`T()` 和 `C()` 正确性
|
||||
@@ -1,72 +0,0 @@
|
||||
---
|
||||
name: 字段备注空格扩展
|
||||
overview: 在 hotime 的列注释解析中,除保留「首段为系统内 label」外,将「第一个空格之后、选项/花括号规则之前」的内容单独写入新字段(如 remark),供前端展示;并澄清:管理端若列来自已有配置或 JSON,可能未经过 DB 新列分支,故不会表现为「整段 label 被丢掉」。
|
||||
todos:
|
||||
- id: parse-makecode
|
||||
content: 在 makecode.go 中实现 SplitN + 空格拆分 + remark 字段,并统一 options/ps 解析顺序
|
||||
status: pending
|
||||
- id: ui-remark
|
||||
content: 在 myst-web Edit/Info 中展示 column.remark(可选样式)
|
||||
status: pending
|
||||
- id: docs-format
|
||||
content: 补充列注释格式说明文档
|
||||
status: pending
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# 表字段备注:空格后扩展备注(修正认知)
|
||||
|
||||
## 为何你能在管理端看到「完整」label
|
||||
|
||||
`[hotimev1.5/code/makecode.go](d:/work/hotimev1.5/code/makecode.go)` 中,**空格截断与 `:` 处理仅发生在 `if coloum == nil` 分支**(约 189–325 行),即:**仅当该列尚未在 `TableColumns` 中存在、完全由本次从库表结构推断新建列时**才会执行。
|
||||
|
||||
若列定义已来自 `[config/adminDB.json](d:/work/myst-go/config/adminDB.json)` 等已有配置,会走 `coloum != nil`,**不会执行**上述截断逻辑,管理端下发的 `column.label` 仍可能是你在 JSON 里写的整段字符串,因此**不会出现「整段在 UI 里被丢掉」的现象**。
|
||||
|
||||
你遇到的「断言/解析」问题更可能出在:**需要同时支持「系统内短 label」与「空格后的说明」两种语义**,而不是单纯丢失数据。
|
||||
|
||||
## 目标行为(约定)
|
||||
|
||||
对**数据库列注释**(以及与之对齐的生成逻辑)采用统一解析顺序,避免与现有 `:` 选项、`{...}` 的 `ps` 冲突:
|
||||
|
||||
1. 用 `**SplitN(raw, ":", 2)`** 拆出「主文案」与「选项串」(替代当前对整串 `strings.Split(..., ":")`,避免备注里出现冒号时误伤)。
|
||||
2. 在「主文案」中按 **第一个空格** 拆成:
|
||||
- **第一段**:`label`(系统内字段名/短标题,与现有一致)
|
||||
- **第二段及以后**(或仅第二段,按产品定):trim 后写入新字段,建议键名 `**remark`**(若你更倾向 `note` / `labelRemark` 可再定)
|
||||
3. `**{...}` → `ps`**:仍在**完整主文案或完整 raw** 上提取(与现有行为一致),避免破坏依赖 `ps` 的表单。
|
||||
4. **选项解析**:使用 `SplitN` 的第二段,保持 `1-男,2-女` 这类格式不变。
|
||||
|
||||
示例:`姓名 对外展示说明 :1-男,2-女` → `label=姓名`,`remark=对外展示说明`,options 从 `:1-男,2-女` 解析。
|
||||
|
||||
## 实现位置
|
||||
|
||||
|
||||
| 层级 | 文件 | 内容 |
|
||||
| --------- | ------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| 后端 | `[hotimev1.5/code/makecode.go](d:/work/hotimev1.5/code/makecode.go)` | 重构 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 花括号"]
|
||||
```
|
||||
|
||||
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
---
|
||||
name: 字段提示括号扩展
|
||||
overview: 在后端解析数据库列注释中的 `()` 内容作为字段提示(ps),修复空格截断 bug,并在前端 Table/Info/Edit 组件中展示提示信息。
|
||||
todos:
|
||||
- id: backend-parse
|
||||
content: 后端 makecode.go:新增 () 解析,调整解析顺序(先提取括号内容,再空格/冒号截断),存入 ps 字段
|
||||
status: completed
|
||||
- id: backend-fix-space
|
||||
content: 后端 makecode.go:修复 coloum != nil 分支中 label 未截断空格的问题
|
||||
status: completed
|
||||
- id: frontend-edit
|
||||
content: 前端 Edit.vue / Add.vue:输入框右侧显示 column.ps 提示文字
|
||||
status: completed
|
||||
- id: frontend-table
|
||||
content: 前端 Table.vue:列头加 el-tooltip 展示 td.ps
|
||||
status: completed
|
||||
- id: frontend-info
|
||||
content: 前端 Info.vue:详情字段名加 tooltip 展示 column.ps
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# 字段提示:支持 () 括号 + 前端展示
|
||||
|
||||
## 现状问题
|
||||
|
||||
1. 后端已经能从 `{}` 提取 `ps`,但前端从未使用 `ps` 字段
|
||||
2. `{}` 写法不直观,用户希望用更常见的 `()` 来写字段提示
|
||||
3. 管理端某些列的 label 包含了空格后面的文字(未经截断)
|
||||
|
||||
## 数据库注释格式约定(举例)
|
||||
|
||||
|
||||
| 注释写法 | label | ps | options |
|
||||
| --------------------------- | ----- | ------- | -------------- |
|
||||
| `姓名` | 姓名 | 无 | 无 |
|
||||
| `状态(当前审核状态)` | 状态 | 当前审核状态 | 无 |
|
||||
| `性别:1-男,2-女` | 性别 | 无 | 男/女 |
|
||||
| `状态(当前审核状态):1-待审,2-通过,3-驳回` | 状态 | 当前审核状态 | 待审/通过/驳回 |
|
||||
| `备注{请输入详细说明}` | 备注 | 请输入详细说明 | 无 |
|
||||
| `姓名 xxx说明` | 姓名 | 无 | 无(空格后丢弃,同现有行为) |
|
||||
|
||||
|
||||
`()` 和 `{}` 都提取到同一个 `ps` 字段(功能一致,只是写法不同)。
|
||||
|
||||
## 后端改动
|
||||
|
||||
文件:[hotimev1.5/code/makecode.go](d:/work/hotimev1.5/code/makecode.go) 约 218-233 行
|
||||
|
||||
当前解析顺序是:空格截断 -> 花括号提取ps -> 冒号截断。需要调整为:
|
||||
|
||||
1. **新增 `()` 解析**:在原有 `{}` 提取 ps 的逻辑旁边,增加对 `(` 和 `)` 的检测,提取内容同样存入 `coloum["ps"]`
|
||||
2. **调整解析顺序**:先提取 `()` / `{}`(从原始注释中),再做空格截断和冒号截断,避免截断后丢失括号内容
|
||||
3. **提取后清理 label**:将 `(...)` 或 `{...}` 从 label 中移除,确保 label 干净
|
||||
|
||||
改后伪逻辑(自然语言):
|
||||
|
||||
```
|
||||
原始注释 = "状态(当前审核状态):1-待审,2-通过,3-驳回"
|
||||
|
||||
第一步:从原始注释中找 () 或 {},提取内容 → ps = "当前审核状态"
|
||||
第二步:把 (...) 或 {...} 从注释中去掉 → "状态:1-待审,2-通过,3-驳回"
|
||||
第三步:空格截断(取第一个空格前的部分)
|
||||
第四步:冒号截断 label → label = "状态"
|
||||
第五步:冒号后面解析选项 → options
|
||||
```
|
||||
|
||||
## 前端改动
|
||||
|
||||
### 1. Edit.vue / Add.vue — 输入框右侧显示提示
|
||||
|
||||
在 `el-form-item` 内、输入控件后面追加一个小字提示:
|
||||
|
||||
```html
|
||||
<span v-if="column.ps" class="field-hint">{{ column.ps }}</span>
|
||||
```
|
||||
|
||||
样式为灰色小字,紧贴输入框右侧。
|
||||
|
||||
### 2. Table.vue — 列头 tooltip
|
||||
|
||||
给 `el-table-column` 加 header 插槽,当 `td.ps` 存在时用 `el-tooltip` 包裹列头文字:
|
||||
|
||||
```html
|
||||
<template #header v-if="td.ps">
|
||||
<el-tooltip :content="td.ps" placement="top">
|
||||
<span>{{ td.label }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
```
|
||||
|
||||
无 `ps` 的列保持原样。
|
||||
|
||||
### 3. Info.vue — 字段名 tooltip
|
||||
|
||||
类似地,在详情页的 `el-descriptions-item` 的 label 上加 tooltip 展示 `column.ps`。
|
||||
|
||||
## 关于"管理端空格后 label 未截断"的 bug
|
||||
|
||||
这个问题出在:当列已存在于配置(`coloum != nil` 分支),不会执行空格截断逻辑。如果 adminDB.json 里的 label 本身就包含空格后的文字,它会原样显示。
|
||||
|
||||
修复方式:在 makecode.go 的 `coloum != nil` 分支(即已有配置的列),也对 label 做同样的空格截断和 `()` / `{}` 提取处理。或者确认 adminDB.json 中的 label 是否需要手动清理。需要先确认具体是哪些列出现了这个问题。
|
||||
@@ -1,328 +0,0 @@
|
||||
---
|
||||
name: 支持达梦数据库
|
||||
overview: 在 HoTime 框架的现有 Dialect 方言体系下,新增达梦(DM8)数据库方言实现,涉及驱动引入、方言适配、连接配置、缓存层/代码生成/备份模块的 DM 分支补充。
|
||||
todos:
|
||||
- id: dm-driver
|
||||
content: 引入达梦 Go 驱动依赖(go.mod + import),确定驱动引入方式
|
||||
status: pending
|
||||
- id: dm-dialect
|
||||
content: 在 db/dialect.go 中实现 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 一致
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
---
|
||||
name: 整理推荐写法风格
|
||||
overview: 将全文散落的"推荐/不推荐"对比块统一清理,文档全程只陈述正确做法,在「用例编写范式」末尾新增「简写支持说明」小节,介绍框架支持的简写行为,并以一句忠告收尾。
|
||||
todos:
|
||||
- id: clean-error-case-block
|
||||
content: 错误用例代码块:去掉 `// 推荐:` 前缀和整个「不推荐」代码段(第 246-256 行)
|
||||
status: completed
|
||||
- id: replace-bad-vs-good
|
||||
content: 「不好的写法 vs 推荐的写法」整节替换为「简写支持说明」,说明框架支持的简写行为+场景,以忠告句收尾(第 412-458 行)
|
||||
status: completed
|
||||
- id: clean-apiresponse-labels
|
||||
content: ApiResponse 去掉「推荐方式」标签,「兼容方式」改为自然描述(第 595-613 行)
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# 整理推荐写法风格
|
||||
|
||||
目标文件:[docs/Testing_API测试框架.md](docs/Testing_API测试框架.md)
|
||||
|
||||
## 三处改动
|
||||
|
||||
### 1. 第 246-256 行 — 错误用例编写规范代码块
|
||||
|
||||
当前:代码块里有 `// 推荐:...` 和 `// 不推荐:...` 两段
|
||||
|
||||
改后:只保留完整写法的代码,去掉 `// 推荐:` 注释前缀和整个"不推荐"代码段。
|
||||
|
||||
```go
|
||||
a.Post("未登录访问", 2, "请先登录")
|
||||
a.JSON(Map{"quantity": 3}).Post("缺少商品ID", 3, "请选择商品")
|
||||
a.JSON(Map{"goods_id": int64(1)}).Post("缺少数量", 3, "请填写购买数量")
|
||||
a.JSON(Map{"goods_id": int64(999)}).Post("商品不存在", 4, "商品不存在")
|
||||
```
|
||||
|
||||
### 2. 第 412-458 行 — 「不好的写法 vs 推荐的写法」整节
|
||||
|
||||
当前:三组 bad/good 对比块(省略 msg、只校验 status、硬编码 ID)
|
||||
|
||||
改后:整节替换为「简写支持说明」,结构如下:
|
||||
|
||||
- **标题**:`### 简写支持说明`
|
||||
- **说明框架支持的三种简写**,每种说明行为(不是批评,是告知机制):
|
||||
- 省略第三参数:`a.Post("请先登录", 2)` — desc 自动作为期望 msg,适合 desc 与 msg 完全一致的场景
|
||||
- 省略 expect:`a.Post("创建成功", 0)` — 仅断言 status=0,不校验 result 内容或数据库状态
|
||||
- 省略 Verify:适合纯查询、无副作用的接口,只需验证格式即可
|
||||
- **忠告**(一行):`> 对于涉及数据写入、状态变更、副作用的接口,省略过程校验等于充分不测试。`
|
||||
|
||||
### 3. 第 595-613 行 — ApiResponse「推荐方式 / 兼容方式」标签
|
||||
|
||||
当前:用「推荐方式」和「兼容方式」两个标签
|
||||
|
||||
改后:去掉「推荐方式 —」标签,直接展示 Verify 用法;「兼容方式」改为:`也可以通过返回值在外部断言(适合不需要 DB 校验的简单场景):`
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
name: 测试框架文档编写
|
||||
overview: 在 docs/ 目录新增 API 测试框架文档,并在 README.md 文档表格中增加入口链接,保持与现有文档风格一致。
|
||||
todos:
|
||||
- id: docs-testing
|
||||
content: 新增 docs/Testing_API测试框架.md 文档
|
||||
status: completed
|
||||
- id: readme-entry
|
||||
content: README.md 文档表格增加测试框架入口
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# API 测试框架文档
|
||||
|
||||
## 改动文件
|
||||
|
||||
### 1. 新增 `docs/Testing_API测试框架.md`
|
||||
|
||||
参照现有文档风格(如 QUICKSTART.md、HoTimeDB_API参考.md),包含以下章节:
|
||||
|
||||
- 概述(事务回滚隔离 + 链式 API + 覆盖率 + Swagger)
|
||||
- 快速开始(3 步接入示例)
|
||||
- 核心类型(TestProj/TestProjDef/ProjTest/CtrTest/ApiTestDef)
|
||||
- 链式 API 参考(Api/ApiCase/ApiResponse 的所有方法)
|
||||
- 完整场景对照表(GET/POST/JSON/Form/File/混合/WithSession)
|
||||
- 事务回滚机制说明(testTx + SAVEPOINT 原理)
|
||||
- 覆盖率报告(PrintCoverage 输出示例)
|
||||
- Swagger 文档生成(GenerateSwagger 用法)
|
||||
- 指定运行范围(go test -run 用法)
|
||||
- 框架改动说明(db 层 3 文件改动概述)
|
||||
|
||||
### 2. 修改 [README.md](d:/work/hotimev1.5/README.md)
|
||||
|
||||
在文档表格(第 18-27 行)中增加一行:
|
||||
|
||||
```markdown
|
||||
| [API 测试框架](docs/Testing_API测试框架.md) | 接口测试、事务隔离、覆盖率追踪、Swagger 文档生成 |
|
||||
```
|
||||
|
||||
插入位置:在"改进规划"行之前,作为文档表格的倒数第二行。
|
||||
@@ -1,70 +0,0 @@
|
||||
---
|
||||
name: 精简测试框架文档
|
||||
overview: 将 Testing_API测试框架.md 从 1210 行精简到约 750-800 行,删除与"编写测试用例"无关的冗余内容,同时保留/强化全过程验证示例,防止 AI 写出只验证接口通不通的浅层测试。
|
||||
todos:
|
||||
- id: simplify-biz-code
|
||||
content: 「快速开始 → 第一步:业务代码」改成极简示例(3-5行的伪代码),不再展开完整 OrderCtr,节约约40行
|
||||
status: completed
|
||||
- id: delete-framework-changes
|
||||
content: 删除「框架改动说明」整节(约25行)
|
||||
status: completed
|
||||
- id: delete-login-compare
|
||||
content: 删除「不好的写法 vs 推荐的写法」中的登录完整对比示例(约45行),前面各小节已有对比,不需要再重复一遍
|
||||
status: completed
|
||||
- id: trim-swagger-console
|
||||
content: 缩减「API 调试控制台」,只保留 GenerateSwagger 调用方式和输出目录结构,删除14行功能特性表和侧边栏示意图(约-30行)
|
||||
status: completed
|
||||
- id: trim-api-spec-fields
|
||||
content: 删除「api-spec.json 覆盖率字段说明」中的字段详解大表和徽章规则(约-30行),只保留 JSON 结构示例
|
||||
status: completed
|
||||
- id: trim-concurrency
|
||||
content: 精简「并发保护与缓存隔离」,删除逐操作加锁保护表,只保留结论性描述(约-20行)
|
||||
status: completed
|
||||
- id: trim-apiresponse
|
||||
content: 删除 ApiResponse GetBody vs Obj 的解释性注释块(约-10行)
|
||||
status: completed
|
||||
- id: trim-run-range
|
||||
content: 删除「指定运行范围」末尾重复的子测试层级树(约-10行)
|
||||
status: completed
|
||||
- id: trim-binary
|
||||
content: 精简「二进制与非JSON响应校验」,删除图片下载示例(与xlsx示例高度重复,约-30行)
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# 精简测试框架文档
|
||||
|
||||
目标:[docs/Testing_API测试框架.md](docs/Testing_API测试框架.md) 从 1210 行 → 约 750-800 行
|
||||
|
||||
## 核心原则调整(与初稿的区别)
|
||||
|
||||
- **快速开始必须完整**:第一步「业务代码」不删除,改为极简示意(3-5行伪代码),让读者明白"这里有个接口"即可,不展开完整逻辑
|
||||
- **全过程验证示例必须保留并强调**:Verify + DB 校验 + 响应值断言的示例是文档核心,AI 容易偷懒只写 `Post("xxx", 0)` 不做任何校验,这种浅层用例要明确标为"不好的写法"
|
||||
- **其余按实用性裁剪**
|
||||
|
||||
## 删除/精简内容(约 -400 行)
|
||||
|
||||
- **精简「第一步:业务代码」**(-40 行):OrderCtr 有50行,改成极简伪代码 + 注释说明接口做什么,让后续测试示例有上下文即可
|
||||
- **删除「框架改动说明」整节**(-25 行):内部实现历史,对写测试无任何指导价值
|
||||
- **删除「不好的写法 → 登录完整对比」**(-45 行):前面3个对比子节已充分说明问题,第4个只是换了场景重复
|
||||
- **缩减「API 调试控制台」**(-30 行):只保留 `GenerateSwagger` 调用 + 输出目录结构,删除14行功能表和侧边栏结构图
|
||||
- **删除「api-spec.json 字段详解表」**(-30 行):与写测试无关
|
||||
- **精简「并发保护与缓存隔离」**(-20 行):删除逐操作加锁表,保留结论
|
||||
- **删除 ApiResponse GetBody vs Obj 解释块**(-10 行)
|
||||
- **删除「指定运行范围」末尾层级树**(-10 行):bash 示例中注释已足够
|
||||
- **精简「二进制响应校验」**(-30 行):只保留 xlsx + CSV,删除重复的图片示例
|
||||
|
||||
## 保留/强化内容
|
||||
|
||||
- 概述能力表格
|
||||
- **快速开始(完整4步,业务代码改极简)**
|
||||
- **用例编写范式(全部4节 + 不好vs推荐对比的前3节)** ← 核心,不裁
|
||||
- 核心类型
|
||||
- 链式 API 参考(三张表)
|
||||
- API 速查表
|
||||
- 事务隔离机制
|
||||
- 覆盖率报告(保留 PrintCoverage 输出示例)
|
||||
- 指定运行范围(bash 示例)
|
||||
- 并发保护与缓存隔离(精简版)
|
||||
- 二进制响应校验(精简版)
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
---
|
||||
name: 缓存数据库表重构
|
||||
overview: 重构数据库缓存模块,将 cached 表替换为符合设计规范的 hotime_cache 表,支持 MySQL/SQLite/PostgreSQL,修复并发问题,支持可配置的历史记录功能和自动迁移。
|
||||
todos:
|
||||
- id: update-cache-db
|
||||
content: 重构 cache_db.go:新表、UPSERT、历史记录、自动迁移、问题修复
|
||||
status: completed
|
||||
- id: update-cache-go
|
||||
content: 修改 cache.go:添加 HistorySet 配置传递
|
||||
status: completed
|
||||
- id: update-makecode
|
||||
content: 修改 makecode.go:跳过新表名
|
||||
status: completed
|
||||
---
|
||||
|
||||
# 缓存数据库表重构计划
|
||||
|
||||
## 设计概要
|
||||
|
||||
将原 `cached` 表替换为 `hotime_cache` 表,遵循数据库设计规范,支持:
|
||||
|
||||
- 多数据库:MySQL、SQLite、PostgreSQL
|
||||
- 可配置的历史记录功能(仅日志,无读取接口)
|
||||
- 自动迁移旧 cached 表数据
|
||||
|
||||
## 文件改动清单
|
||||
|
||||
| 文件 | 改动 | 状态 |
|
||||
|
||||
|------|------|------|
|
||||
|
||||
| [cache/cache_db.go](cache/cache_db.go) | 完全重构:新表、UPSERT、历史记录、自动迁移 | 待完成 |
|
||||
|
||||
| [cache/cache.go](cache/cache.go) | 添加 `HistorySet: db.GetBool("history")` 配置传递 | 待完成 |
|
||||
|
||||
| [code/makecode.go](code/makecode.go) | 跳过 hotime_cache 和 hotime_cache_history | 已完成 |
|
||||
|
||||
## 表结构设计
|
||||
|
||||
### 主表 `hotime_cache`
|
||||
|
||||
```sql
|
||||
-- MySQL
|
||||
CREATE TABLE `hotime_cache` (
|
||||
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`key` varchar(64) NOT NULL COMMENT '缓存键',
|
||||
`value` text DEFAULT NULL COMMENT '缓存值',
|
||||
`end_time` datetime DEFAULT NULL COMMENT '过期时间',
|
||||
`state` int(2) DEFAULT '0' COMMENT '状态:0-正常,1-异常,2-隐藏',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建日期',
|
||||
`modify_time` datetime DEFAULT NULL COMMENT '变更时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_key` (`key`),
|
||||
KEY `idx_end_time` (`end_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='缓存管理';
|
||||
|
||||
-- SQLite
|
||||
CREATE TABLE "hotime_cache" (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"key" TEXT NOT NULL UNIQUE,
|
||||
"value" TEXT,
|
||||
"end_time" TEXT,
|
||||
"state" INTEGER DEFAULT 0,
|
||||
"create_time" TEXT,
|
||||
"modify_time" TEXT
|
||||
);
|
||||
|
||||
-- PostgreSQL
|
||||
CREATE TABLE "hotime_cache" (
|
||||
"id" SERIAL PRIMARY KEY,
|
||||
"key" VARCHAR(64) NOT NULL UNIQUE,
|
||||
"value" TEXT,
|
||||
"end_time" TIMESTAMP,
|
||||
"state" INTEGER DEFAULT 0,
|
||||
"create_time" TIMESTAMP,
|
||||
"modify_time" TIMESTAMP
|
||||
);
|
||||
CREATE INDEX "idx_hotime_cache_end_time" ON "hotime_cache" ("end_time");
|
||||
```
|
||||
|
||||
### 历史表 `hotime_cache_history`(配置开启时创建,创建后永不自动删除)
|
||||
|
||||
与主表结构相同,但 `id` 改为 `hotime_cache_id` 作为外键关联。
|
||||
|
||||
## 问题修复清单
|
||||
|
||||
| 问题 | 原状态 | 修复方案 |
|
||||
|
||||
|------|--------|----------|
|
||||
|
||||
| key 无索引 | 全表扫描 | 唯一索引 uk_key |
|
||||
|
||||
| 并发竞态 | Update+Insert 可能重复 | 使用 UPSERT 语法 |
|
||||
|
||||
| 时间字段混乱 | time(纳秒) + endtime(秒) | 统一 datetime 格式 |
|
||||
|
||||
| value 长度限制 | varchar(2000) | TEXT 类型 |
|
||||
|
||||
| TimeOut=0 立即过期 | 无默认值 | 默认 24 小时 |
|
||||
|
||||
| get 时删除过期数据 | 每次写操作 | 惰性删除,只返回 nil |
|
||||
|
||||
| 旧表 key 重复 | 无约束 | 迁移时取最后一条 |
|
||||
|
||||
| value 包装冗余 | `{"data": value}` | 直接存储 |
|
||||
|
||||
## 主要代码改动点
|
||||
|
||||
### cache_db.go 改动
|
||||
|
||||
1. 新增常量:表名、默认过期时间 24 小时
|
||||
2. 结构体添加 `HistorySet bool`
|
||||
3. 使用 common 包 `Time2Str(time.Now())` 格式化时间
|
||||
4. `initDbTable()`: 支持三种数据库、自动迁移、创建历史表
|
||||
5. `migrateFromCached()`: 去重迁移(取最后一条)、删除旧表
|
||||
6. `writeHistory()`: 查询数据写入历史表(仅日志)
|
||||
7. `set()`: 使用 UPSERT、调用 writeHistory
|
||||
8. `get()`: 惰性删除
|
||||
9. `Cache()`: TimeOut=0 时用默认值
|
||||
|
||||
### cache.go 改动
|
||||
|
||||
第 268 行添加:`HistorySet: db.GetBool("history")`
|
||||
|
||||
## 配置示例
|
||||
|
||||
```json
|
||||
{
|
||||
"cache": {
|
||||
"db": {
|
||||
"db": true,
|
||||
"session": true,
|
||||
"timeout": 72000,
|
||||
"history": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 历史记录逻辑
|
||||
|
||||
- 新增/修改后:查询完整数据,id 改为 hotime_cache_id,插入历史表
|
||||
- 删除:不记录历史
|
||||
- 历史表仅日志记录,无读取接口,人工在数据库操作
|
||||
@@ -1,221 +0,0 @@
|
||||
---
|
||||
name: 缓存表模式配置
|
||||
overview: 为 CacheDb 添加 mode 配置项,支持 "new"(默认,只用新表)和 "compatible"(写新读老)两种模式,并更新配置说明。
|
||||
todos:
|
||||
- id: add-mode-field
|
||||
content: 在 CacheDb 结构体中添加 Mode 字段
|
||||
status: completed
|
||||
- id: modify-init
|
||||
content: 修改 initDbTable,根据 Mode 决定是否迁移删除老表
|
||||
status: completed
|
||||
dependencies:
|
||||
- add-mode-field
|
||||
- id: add-legacy-get
|
||||
content: 添加 getLegacy 方法读取老表数据(unix时间戳格式)
|
||||
status: completed
|
||||
dependencies:
|
||||
- add-mode-field
|
||||
- id: modify-get
|
||||
content: 修改 get 方法,compatible 模式下回退读取老表
|
||||
status: completed
|
||||
dependencies:
|
||||
- add-legacy-get
|
||||
- id: modify-set
|
||||
content: 修改 set 方法,compatible 模式下写新表后删除老表同key记录
|
||||
status: completed
|
||||
dependencies:
|
||||
- add-mode-field
|
||||
- id: modify-delete
|
||||
content: 修改 delete 方法,compatible 模式下同时删除新表和老表
|
||||
status: completed
|
||||
dependencies:
|
||||
- add-mode-field
|
||||
- id: update-cache-init
|
||||
content: 在 cache.go Init 方法中读取 mode 配置
|
||||
status: completed
|
||||
dependencies:
|
||||
- add-mode-field
|
||||
- id: update-config-note
|
||||
content: 在 var.go ConfigNote 中添加 mode 配置说明
|
||||
status: completed
|
||||
- id: add-cache-test
|
||||
content: 在 example/main.go 中添加缓存测试路由
|
||||
status: completed
|
||||
dependencies:
|
||||
- modify-get
|
||||
- modify-set
|
||||
- modify-delete
|
||||
- update-cache-init
|
||||
- id: todo-1769763169689-k7t9twp5t
|
||||
content: |
|
||||
QUICKSTART.md 更新:缓存配置部分需要添加 mode 和 history 配置说明
|
||||
status: pending
|
||||
---
|
||||
|
||||
# 缓存表模式配置实现计划
|
||||
|
||||
## 需求概述
|
||||
|
||||
在 [`cache/cache_db.go`](cache/cache_db.go) 中实现两种缓存表模式:
|
||||
|
||||
- **new**(默认):只使用新的 `hotime_cache` 表,自动迁移老表数据
|
||||
- **compatible**:写入新表,读取时先查新表再查老表,老数据自然过期消亡
|
||||
|
||||
## 实现步骤
|
||||
|
||||
### 1. 修改 CacheDb 结构体
|
||||
|
||||
在 [`cache/cache_db.go`](cache/cache_db.go) 中添加 `Mode` 字段:
|
||||
|
||||
```go
|
||||
type CacheDb struct {
|
||||
TimeOut int64
|
||||
DbSet bool
|
||||
SessionSet bool
|
||||
HistorySet bool
|
||||
Mode string // "new"(默认) 或 "compatible"
|
||||
Db HoTimeDBInterface
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 修改初始化逻辑 initDbTable
|
||||
|
||||
- **new 模式**:创建新表、迁移老表数据,**但不删除老表**(删除交给用户手动操作,更安全)
|
||||
- **compatible 模式**:创建新表,不迁移也不删除老表
|
||||
|
||||
两种模式都不自动删除老表,避免自动删除造成数据丢失风险
|
||||
|
||||
### 3. 修改 get 方法
|
||||
|
||||
- **new 模式**:只从新表读取
|
||||
- **compatible 模式**:先从新表读取,如果没有再从老表读取
|
||||
|
||||
需要新增 `getLegacy` 方法来读取老表数据,该方法需要:
|
||||
|
||||
1. 查询老表数据
|
||||
2. 检查 `endtime`(unix 时间戳)是否过期
|
||||
3. 如果过期:删除该条记录,返回 nil
|
||||
4. 如果未过期:返回数据
|
||||
|
||||
### 4. 修改 set 方法(写新删老)
|
||||
|
||||
- **new 模式**:只写新表(老表保留但不再管理)
|
||||
- **compatible 模式**:写入新表 + 删除老表中相同 key 的记录
|
||||
|
||||
这样可以主动加速老数据消亡,而不是等自然过期。
|
||||
|
||||
### 5. 修改 delete 方法
|
||||
|
||||
- **new 模式**:只删除新表(老表保留但不再管理)
|
||||
- **compatible 模式**:同时删除新表和老表中的 key
|
||||
|
||||
这是必要的,否则删除新表后,下次读取会回退读到老表的数据,造成"删不掉"的问题。
|
||||
|
||||
需要新增 `deleteLegacy` 方法处理老表删除逻辑
|
||||
|
||||
### 6. 更新缓存初始化
|
||||
|
||||
在 [`cache/cache.go`](cache/cache.go) 的 `Init` 方法中读取 `mode` 配置:
|
||||
|
||||
```go
|
||||
that.dbCache = &CacheDb{
|
||||
// ...
|
||||
Mode: db.GetString("mode"), // 读取 mode 配置
|
||||
}
|
||||
```
|
||||
|
||||
### 7. 更新配置说明
|
||||
|
||||
在 [`var.go`](var.go) 的 `ConfigNote` 中添加 `mode` 配置说明:
|
||||
|
||||
```go
|
||||
"db": Map{
|
||||
// ...
|
||||
"mode": "默认new,非必须,new为只使用新表(自动迁移老数据),compatible为兼容模式(写新表读老表,老数据自然过期)",
|
||||
}
|
||||
```
|
||||
|
||||
### 8. 编写测试
|
||||
|
||||
在 [`example/main.go`](example/main.go) 中添加缓存测试路由,测试覆盖:
|
||||
|
||||
**new 模式测试:**
|
||||
|
||||
- 基础读写:set/get/delete 正常工作
|
||||
- 过期测试:设置短过期时间,验证过期后读取返回 nil
|
||||
- 数据迁移:验证老表数据能正确迁移到新表
|
||||
- 老表保留:验证迁移后老表仍存在(不自动删除)
|
||||
|
||||
**compatible 模式测试:**
|
||||
|
||||
- 新表读写:优先从新表读取
|
||||
- 老表回退:新表没有时从老表读取
|
||||
- 过期检测:读取老表过期数据时返回 nil 并删除该记录
|
||||
- 写新删老:写入新表后老表同 key 记录被删除
|
||||
- 删除双表:删除操作同时删除新表和老表记录
|
||||
- 通配删除:`key*` 格式删除测试
|
||||
|
||||
**边界情况测试:**
|
||||
|
||||
- 空值处理:nil 值的 set/get
|
||||
- 不存在的 key 读取
|
||||
- 重复 set 同一个 key
|
||||
- 超时时间参数测试(默认/自定义)
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Config[配置]
|
||||
ModeNew["mode: new (默认)"]
|
||||
ModeCompat["mode: compatible"]
|
||||
end
|
||||
|
||||
subgraph NewMode[new模式]
|
||||
N1[初始化] --> N2[创建新表]
|
||||
N2 --> N3{老表存在?}
|
||||
N3 -->|是| N4[迁移数据到新表]
|
||||
N4 --> N5[保留老表由人工删除]
|
||||
N3 -->|否| N6[完成]
|
||||
N5 --> N6
|
||||
|
||||
NR[读取] --> NR1[只查询新表]
|
||||
NW[写入] --> NW1[只写入新表]
|
||||
ND[删除] --> ND1[只删除新表记录]
|
||||
end
|
||||
|
||||
subgraph CompatMode[compatible模式]
|
||||
C1[初始化] --> C2[创建新表]
|
||||
C2 --> C3[保留老表]
|
||||
|
||||
CR[读取] --> CR1[查询新表]
|
||||
CR1 -->|未找到| CR2[查询老表]
|
||||
CR2 --> CR3{过期?}
|
||||
CR3 -->|是| CR4[删除老表记录]
|
||||
CR4 --> CR5[返回nil]
|
||||
CR3 -->|否| CR6[返回数据]
|
||||
|
||||
CW[写入] --> CW1[写入新表]
|
||||
CW1 --> CW2[删除老表同key]
|
||||
|
||||
CD[删除] --> CD1[删除新表记录]
|
||||
CD1 --> CD2[删除老表记录]
|
||||
end
|
||||
```
|
||||
|
||||
## 文件修改列表
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|
||||
|------|----------|
|
||||
|
||||
| [`cache/cache_db.go`](cache/cache_db.go) | 添加 Mode 字段、修改 initDbTable、添加 getLegacy(含过期检测删除)、修改 get、修改 set(写新删老)、修改 delete |
|
||||
|
||||
| [`cache/cache.go`](cache/cache.go) | 读取 mode 配置 |
|
||||
|
||||
| [`var.go`](var.go) | ConfigNote 添加 mode 说明 |
|
||||
|
||||
| [`example/main.go`](example/main.go) | 添加缓存测试路由 |
|
||||
|
||||
| [`example/config/config.json`](example/config/config.json) | 可选:添加 mode 配置示例 |
|
||||
@@ -1,69 +0,0 @@
|
||||
---
|
||||
name: 补充 Note 方法示例
|
||||
overview: 在 Testing_API测试框架.md 的"第二步:编写测试用例"部分补充 Note 方法的使用示例和说明,同步更新 SKILL.md。
|
||||
todos:
|
||||
- id: doc-step2-example
|
||||
content: 文档:在第二步的完整订单示例中加入 Note 调用示范
|
||||
status: completed
|
||||
- id: doc-note-section
|
||||
content: 文档:在用例编写范式中新增 Note 使用说明(典型场景 + 示例 + 何时必须用)
|
||||
status: completed
|
||||
- id: doc-hotimev15-sync
|
||||
content: 同步修改 hotimev1.5 中的 Testing_API测试框架.md
|
||||
status: completed
|
||||
- id: skill-note-guide
|
||||
content: SKILL.md:补充 Note 使用指引和更多场景示例
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# 补充 Note 方法示例到文档和 Skill
|
||||
|
||||
## 现状分析
|
||||
|
||||
目前 `Note` 方法在文档中的存在情况:
|
||||
|
||||
- [链式 API 参考](d:\work\your-app\docs\Testing_API测试框架.md) 第 474、494 行:仅出现在 API 表格中,一句话描述
|
||||
- [API 速查表](d:\work\your-app\docs\Testing_API测试框架.md) 第 662-668 行:只有一个简短示例(`sign = MD5(...)`)
|
||||
- [SKILL.md](C:\Users\92597.cursor\skills\hotime-tdd-testing\SKILL.md) 第 195-197 行:一行示例
|
||||
|
||||
**缺失**:"第二步:编写测试用例"(第 66-149 行)的完整订单示例中完全没有出现 `Note`,而这是新手学习的第一个入口。
|
||||
|
||||
## 需要修改的内容
|
||||
|
||||
### 1. 文档:第二步示例中加入 Note 使用
|
||||
|
||||
在 [Testing_API测试框架.md](d:\work\your-app\docs\Testing_API测试框架.md) 第 80-148 行的订单创建示例中,在**正确请求**部分加入 `Note` 调用,展示以下几种典型场景:
|
||||
|
||||
- 在正确请求处用 `Note` 说明参数值含义(如 `quantity=3` 是故意的用于校验总价 `29.9*3=89.7`)
|
||||
|
||||
### 2. 文档:用例编写范式中新增 Note 独立小节
|
||||
|
||||
在"用例编写范式"部分(第 219 行之后),在"错误用例"之前或之后,新增一个关于 Note 的说明段落或小节,覆盖以下典型使用场景:
|
||||
|
||||
- **参数值参考**:说明某个参数值的来源或含义,如 `Note("status: 0=待付款, 1=已付款, 2=已发货, 3=已完成")`
|
||||
- **算法说明**:简要描述接口涉及的签名/加密算法,如 `Note("sign = MD5(smsProxyKey+timestamp)")`
|
||||
- **接口/用例说明**:补充 desc 无法涵盖的上下文,如 `Note("需先调用 /api/cart/add 加入购物车")` 或 `Note("此接口支持批量操作,最多100条")`
|
||||
- 在用例编写范式表格中增加 Note 的定位说明
|
||||
|
||||
### 3. 文档:同步更新 hotimev1.5 中的同名文件
|
||||
|
||||
[hotimev1.5/docs/Testing_API测试框架.md](d:\work\hotimev1.5\docs\Testing_API测试框架.md) 需要做相同的修改。
|
||||
|
||||
### 4. Skill 文件:补充 Note 指引
|
||||
|
||||
在 [SKILL.md](C:\Users\92597.cursor\skills\hotime-tdd-testing\SKILL.md) 中:
|
||||
|
||||
- 在 Phase 1 的 1.3 小节之后(或 1.2 和 1.3 之间),新增 Note 的使用指引
|
||||
- 说明何时应该使用 Note(有必要的场景),给出 2-3 个典型示例
|
||||
- 在 API 速查的"备注"部分补充更多场景示例
|
||||
|
||||
## 修改要点
|
||||
|
||||
- Note 不改变测试逻辑,只是给调试控制台和 api-spec.json 添加可读说明
|
||||
- Note 是可选的,但在以下场景**建议必须**使用:
|
||||
- 参数中有枚举值(如 status、type)需要说明各值含义
|
||||
- 涉及签名/加密等算法的接口
|
||||
- 接口有前置依赖或特殊调用顺序
|
||||
- Note 可以放在链式调用的任意位置(`a.Note(...)` 或 `c.Note(...)`)
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
---
|
||||
name: 规范文档创建
|
||||
overview: 创建两个规范文档:一个数据库设计规范文档,一个管理后台配置规范文档(包含admin.json和rule.json的配置说明),并在README.md中添加链接。
|
||||
todos:
|
||||
- id: create-db-doc
|
||||
content: 创建 docs/DatabaseDesign_数据库设计规范.md
|
||||
status: pending
|
||||
- id: create-admin-doc
|
||||
content: 创建 docs/AdminConfig_管理后台配置规范.md
|
||||
status: pending
|
||||
- id: update-readme
|
||||
content: 在 README.md 文档表格中添加两个新文档链接
|
||||
status: pending
|
||||
dependencies:
|
||||
- create-db-doc
|
||||
- create-admin-doc
|
||||
---
|
||||
|
||||
# 创建规范文档
|
||||
|
||||
## 任务概述
|
||||
|
||||
在 `D:\work\hotimev1.5\docs` 目录下创建两个规范文档,并更新 README.md 添加链接。
|
||||
|
||||
---
|
||||
|
||||
## 文档一:数据库设计规范
|
||||
|
||||
**文件**: [docs/DatabaseDesign_数据库设计规范.md](docs/DatabaseDesign_数据库设计规范.md)
|
||||
|
||||
### 内容结构
|
||||
|
||||
| 章节 | 内容 |
|
||||
|
||||
|------|------|
|
||||
|
||||
| 表命名规则 | 不加前缀、可用简称、关联表命名(主表_关联表) |
|
||||
|
||||
| 字段命名规则 | 主键id、外键表名_id、全局唯一性要求、层级字段(parent_id/parent_ids/level) |
|
||||
|
||||
| 注释规则 | select类型格式(`状态:0-正常,1-异常`)、时间用datetime |
|
||||
|
||||
| 必有字段 | state、create_time、modify_time |
|
||||
|
||||
| 示例 | 完整建表SQL示例 |
|
||||
|
||||
---
|
||||
|
||||
## 文档二:管理后台配置规范
|
||||
|
||||
**文件**: [docs/AdminConfig_管理后台配置规范.md](docs/AdminConfig_管理后台配置规范.md)
|
||||
|
||||
### 内容结构
|
||||
|
||||
| 章节 | 内容 |
|
||||
|
||||
|------|------|
|
||||
|
||||
| admin.json 配置 | |
|
||||
|
||||
| - flow配置 | 数据流控制,定义表间权限关系(sql条件、stop标志) |
|
||||
|
||||
| - labelConfig | 操作按钮标签(show/add/delete/edit/info/download) |
|
||||
|
||||
| - label | 菜单/表的显示名称 |
|
||||
|
||||
| - menus配置 | 菜单结构(嵌套menus、icon、table、name、auth) |
|
||||
|
||||
| - auth配置 | 权限数组(show/add/delete/edit/info/download) |
|
||||
|
||||
| - icon配置 | 菜单图标(如Setting) |
|
||||
|
||||
| - table/name配置 | table指定数据表,name用于分组标识 |
|
||||
|
||||
| - stop配置 | 不允许用户修改自身关联数据的表 |
|
||||
|
||||
| rule.json 配置 | |
|
||||
|
||||
| - 字段默认权限 | add/edit/info/list/must/strict/type 各字段含义 |
|
||||
|
||||
| - 内置字段规则 | id、parent_id、create_time、modify_time、password等 |
|
||||
|
||||
| - type类型说明 | select/time/image/file/password/textArea/auth/form等 |
|
||||
|
||||
---
|
||||
|
||||
## 更新 README.md
|
||||
|
||||
在文档表格中添加两个新链接:
|
||||
|
||||
```markdown
|
||||
| [数据库设计规范](docs/DatabaseDesign_数据库设计规范.md) | 表命名、字段命名、注释规则、必有字段 |
|
||||
| [管理后台配置规范](docs/AdminConfig_管理后台配置规范.md) | admin.json、rule.json 配置说明 |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键参考文件
|
||||
|
||||
- [`code/config.go`](code/config.go): RuleConfig 默认规则定义
|
||||
- [`code/makecode.go`](code/makecode.go): 外键自动关联逻辑
|
||||
- [`example/config/admin.json`](example/config/admin.json): 完整配置示例
|
||||
- [`example/config/rule.json`](example/config/rule.json): 字段规则示例
|
||||
@@ -1,145 +0,0 @@
|
||||
---
|
||||
name: 重写API测试框架文档
|
||||
overview: 重新编撰 Testing_API测试框架.md,以"错误用例先行 → 准备数据 → 正确请求(含响应断言+DB校验)"的完整流程为核心范式,让文档成为可直接照搬的编写指南。
|
||||
todos:
|
||||
- id: rewrite-doc
|
||||
content: 重写 docs/Testing_API测试框架.md:错误用例先行 → 模拟数据准备 → 正确请求(响应断言必须+DB校验) → 逐步讲解各环节 → API速查表 → 其余章节微调
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# 重写 API 测试框架文档
|
||||
|
||||
## 用户确认的编写规范
|
||||
|
||||
1. **顺序**:先写错误请求用例,再写正确请求用例
|
||||
2. **错误用例**:三个参数必须填满 `Post("描述", status, "具体错误消息")`,因为错误码是复用的(如 status=3 可能对应多种参数校验错误)
|
||||
3. **正确请求前**:用 `a.DB()` 模拟插入数据,既准备测试依赖数据,也可作为后续 DB 校验的对照
|
||||
4. **响应字段断言**:正确响应情况下**必须**断言(至少覆盖重要字段),不能只写 `Post("描述", 0)`
|
||||
5. **数据库校验**:只要接口改变了数据库状态,都**建议**用 Verify 校验结果
|
||||
|
||||
## 标准用例编写流程(范式)
|
||||
|
||||
```
|
||||
错误用例(参数校验、权限校验等)
|
||||
↓
|
||||
准备测试数据(a.DB().Insert/Update 模拟前置数据)
|
||||
↓
|
||||
正确请求 + 响应结构校验(ExpectResult / 第三参数 Map)
|
||||
↓
|
||||
响应字段断言(resp.GetBody() 检查关键业务字段)
|
||||
↓
|
||||
数据库状态校验(Verify 查库确认写入/更新结果)
|
||||
```
|
||||
|
||||
## 核心范例(贯穿文档的"创建订单"示例)
|
||||
|
||||
以下为快速开始中使用的完整范例,展示从业务代码到测试用例的映射:
|
||||
|
||||
```go
|
||||
// order_test.go
|
||||
var OrderTest = CtrTest{
|
||||
"create": {Desc: "创建订单", Func: func(a *Api) {
|
||||
// ======== 第一步:错误用例 ========
|
||||
// 错误码复用,三个参数都必须填满
|
||||
a.JSON(Map{"goods_id": int64(1), "quantity": 3, "address": "XX路1号"}).
|
||||
Post("未登录创建订单", 2, "请先登录")
|
||||
a.WithSession(Map{"user_id": int64(1)}).
|
||||
JSON(Map{"quantity": 3, "address": "XX路1号"}).
|
||||
Post("缺少商品ID", 3, "请选择商品")
|
||||
a.WithSession(Map{"user_id": int64(1)}).
|
||||
JSON(Map{"goods_id": int64(1), "address": "XX路1号"}).
|
||||
Post("缺少数量", 3, "请填写购买数量")
|
||||
a.WithSession(Map{"user_id": int64(1)}).
|
||||
JSON(Map{"goods_id": int64(999), "quantity": 3, "address": "XX路1号"}).
|
||||
Post("商品不存在", 4, "商品不存在")
|
||||
|
||||
// ======== 第二步:准备测试数据 ========
|
||||
// 插入一条商品数据,后面的正确请求会依赖它
|
||||
goodsId := a.DB().Insert("goods", Map{
|
||||
"name": "测试商品", "price": 29.9, "stock": 100, "state": 1,
|
||||
})
|
||||
|
||||
// ======== 第三步:正确请求 + 响应断言 + DB校验 ========
|
||||
resp := a.WithSession(Map{"user_id": int64(1)}).
|
||||
JSON(Map{"goods_id": goodsId, "quantity": 3, "address": "XX路1号"}).
|
||||
Verify(func(a *Api) error {
|
||||
row := a.DB().Get("order", "*", Map{"AND": Map{
|
||||
"user_id": int64(1), "goods_id": goodsId,
|
||||
}})
|
||||
if row == nil {
|
||||
return fmt.Errorf("order 表未写入订单记录")
|
||||
}
|
||||
if row.GetCeilInt64("state") != 0 {
|
||||
return fmt.Errorf("新订单 state 期望 0, 实际 %d", row.GetCeilInt64("state"))
|
||||
}
|
||||
if row.GetCeilFloat64("total_price") != 29.9*3 {
|
||||
return fmt.Errorf("total_price 期望 %.2f, 实际 %.2f", 29.9*3, row.GetCeilFloat64("total_price"))
|
||||
}
|
||||
// 校验库存扣减
|
||||
goods := a.DB().Get("goods", "stock", Map{"id": goodsId})
|
||||
if goods.GetCeilInt64("stock") != 97 {
|
||||
return fmt.Errorf("库存期望 97, 实际 %d", goods.GetCeilInt64("stock"))
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Post("正常创建订单", 0, Map{
|
||||
"id": int64(1), "sn": "sample", "total_price": float64(1.0),
|
||||
})
|
||||
|
||||
// 关键业务字段断言
|
||||
orderId := resp.GetBody().GetMap("result").GetCeilInt64("id")
|
||||
if orderId <= 0 {
|
||||
resp.Fail("返回的订单 ID 必须大于 0")
|
||||
}
|
||||
sn := resp.GetBody().GetMap("result").GetString("sn")
|
||||
if sn == "" {
|
||||
resp.Fail("返回的订单编号 sn 不能为空")
|
||||
}
|
||||
}},
|
||||
}
|
||||
```
|
||||
|
||||
## 文档结构
|
||||
|
||||
### 不变章节(微调措辞)
|
||||
|
||||
- 概述(能力表格)
|
||||
- 核心类型
|
||||
- 链式 API 参考(在 expect 参数规则部分强调:错误用例建议三参数写满)
|
||||
- 事务隔离机制
|
||||
- 覆盖率报告
|
||||
- API 调试控制台
|
||||
- 指定运行范围
|
||||
- 并发保护与缓存隔离
|
||||
- 框架改动说明
|
||||
|
||||
### 重写/新增章节
|
||||
|
||||
#### 1. "快速开始" — 使用上述完整范例
|
||||
|
||||
- 先展示业务代码(handler),再展示测试代码
|
||||
- 范例中完整体现五步流程
|
||||
- 目录结构、注册方式、运行方式保持原有说明
|
||||
|
||||
#### 2. "用例编写范式" — 新增章节,替代原"完整场景对照"
|
||||
|
||||
按顺序逐步讲解范例中的每个环节:
|
||||
|
||||
- **错误用例编写规范**:为什么三参数必须写满(错误码复用)、如何覆盖权限/参数/业务错误
|
||||
- **测试数据准备**:`a.DB().Insert()` 的使用场景和注意事项(事务内操作,自动回滚)
|
||||
- **正确请求 + 响应结构校验**:第三参数 Map 的类型样本规则、ExpectResult 后置校验
|
||||
- **响应字段断言**:`resp.GetBody()` / `resp.Fail()` 的用法、哪些字段必须断言
|
||||
- **数据库状态校验**:Verify 的使用时机(只要改了DB就建议校验)、校验主表+关联表+字段值
|
||||
- 包含"不好的写法 vs 推荐的写法"对比
|
||||
|
||||
#### 3. "API 速查表" — 精简版场景对照
|
||||
|
||||
保留各种参数组合(JSON/Form/Query/File/混合/Session 等)的快速参考,但每种只一行示例代码,不再是大段散乱的代码块。
|
||||
|
||||
## 改动范围
|
||||
|
||||
- 仅改动 `docs/Testing_API测试框架.md`,约 750-850 行
|
||||
- 不涉及 Go 源码
|
||||
- 不涉及其他文档
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
---
|
||||
name: 集成请求参数获取方法
|
||||
overview: 在 context.go 中为 Context 结构体添加五个请求参数获取方法:ReqData(统一获取)、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` 自动处理空值 |
|
||||
@@ -1,32 +0,0 @@
|
||||
---
|
||||
description: HoTime Doc-Driven + TDD 总门禁
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# HoTime 工作流
|
||||
|
||||
**不可跳过。** Doc-Driven 与 TDD 缺一不可。日常五步:①定位 → ②修改 → ③补/改行为测试并针对性跑至绿 → ④边界 → ⑤回写文档。未测绿或未回写 = **未完成**。中文回复。
|
||||
|
||||
## 定位
|
||||
|
||||
- 先锁约 **3~4 个文件**(文档 1~2 + 源码 1~2);跨模块扩到合计约 **4~6**,禁止散改全仓。本次新增/修改的**测试文件计入锁定**。先读相关 docs 再改。口径不清 **先问**。
|
||||
- 入口:`docs/README.md`、仓根 `README.md`;测试细则 → `docs/Testing_API测试框架.md` / skill `hotime-tdd-testing`。
|
||||
|
||||
## 改与测
|
||||
|
||||
- 改接口、ctr、业务逻辑或测试框架:对应 `*_test` **无则补、有则改**,跑通至绿;**禁**只改实现不补测;**禁全量**;编译不算测完。
|
||||
- 样板(`example` 根):`go test ./app/ -count=1 -run 'TestApi/app/<ctr>/<action>'`。可收窄到子用例;**改哪测哪**。用户要求全量或以编译代替行为测时,**明确拒绝**并仍按本条。
|
||||
|
||||
## 文档
|
||||
|
||||
- **有旧改旧**;无篇目且确有缺口才新建。禁不查就建、禁同一入口重复建档。旧文只补受影响节;只写最终态;对照源码,禁臆测。
|
||||
- 路径用相对 Markdown 链接;**禁** `D:\`、`file:///`、`~/`。新建须回写 `docs/README.md`。
|
||||
|
||||
## 回复
|
||||
|
||||
- 先结论;摘要 + 文档路径 + **改动定位(文件·位置·原因)**;禁大段复述 docs。
|
||||
- 凡改代码或报完成:末尾必须一行 `【门禁】锁定:… | 补测并跑绿:是/否 | 文档回写:是/否/无 | 新建or旧文:…`;缺一不可。闲聊不要求。
|
||||
|
||||
## 批量补文档(仅整包)
|
||||
|
||||
一文一题;最少 2 轮自检。日常小改不适用。
|
||||
+1
-24
@@ -1,26 +1,3 @@
|
||||
/.idea/*
|
||||
.idea
|
||||
/example/tpt/demo/
|
||||
*.exe
|
||||
*.test
|
||||
*.out
|
||||
/cover
|
||||
/coverage.out
|
||||
/coverage.html
|
||||
/example/config
|
||||
/config/
|
||||
/tpt/
|
||||
example/tpt/swagger/
|
||||
**/swagger/
|
||||
/.cursor/*.log
|
||||
*.sql
|
||||
*.py
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Cursor / agent debug NDJSON leftovers
|
||||
debug-*.log
|
||||
**/debug-*.log
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
/example/config/app.json
|
||||
@@ -7,17 +7,17 @@ AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
|
||||
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction, and distribution
|
||||
as defined by Sections 1 through 9 of that document.
|
||||
|
||||
|
||||
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by the copyright
|
||||
owner that is granting the License.
|
||||
|
||||
|
||||
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all other entities
|
||||
that control, are controlled by, or are under common control with that entity.
|
||||
@@ -26,31 +26,31 @@ or indirect, to cause the direction or management of such entity, whether
|
||||
by contract or otherwise, or (ii) ownership of fifty percent (50%) or more
|
||||
of the outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
|
||||
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions
|
||||
granted by that License.
|
||||
|
||||
|
||||
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications, including
|
||||
but not limited to software source code, documentation source, and configuration
|
||||
files.
|
||||
|
||||
|
||||
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical transformation
|
||||
or translation of a Source form, including but not limited to compiled object
|
||||
code, generated documentation, and conversions to other media types.
|
||||
|
||||
|
||||
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or Object form,
|
||||
made available under the License, as indicated by a copyright notice that
|
||||
is included in or attached to the work (an example is provided in the Appendix
|
||||
below).
|
||||
|
||||
|
||||
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object form,
|
||||
that is based on (or derived from) the Work and for which the editorial revisions,
|
||||
@@ -59,7 +59,7 @@ original work of authorship. For the purposes of that License, Derivative
|
||||
Works shall not include works that remain separable from, or merely link (or
|
||||
bind by name) to the interfaces of, the Work and Derivative Works thereof.
|
||||
|
||||
|
||||
|
||||
|
||||
"Contribution" shall mean any work of authorship, including the original version
|
||||
of the Work and any modifications or additions to that Work or Derivative
|
||||
@@ -74,7 +74,7 @@ for the purpose of discussing and improving the Work, but excluding communicatio
|
||||
that is conspicuously marked or otherwise designated in writing by the copyright
|
||||
owner as "Not a Contribution."
|
||||
|
||||
|
||||
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf
|
||||
of whom a Contribution has been received by Licensor and subsequently incorporated
|
||||
@@ -142,7 +142,7 @@ any additional terms or conditions. Notwithstanding the above, nothing herein
|
||||
shall supersede or modify the terms of any separate license agreement you
|
||||
may have executed with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. that License does not grant permission to use the trade names,
|
||||
6. Trademarks. This License does not grant permission to use the trade names,
|
||||
trademarks, service marks, or product names of the Licensor, except as required
|
||||
for reasonable and customary use in describing the origin of the Work and
|
||||
reproducing the content of the NOTICE file.
|
||||
|
||||
@@ -1,98 +1,6 @@
|
||||
# HoTime
|
||||
# hotime
|
||||
golang web服务框架
|
||||
支持数据库db:mysql、sqlite3
|
||||
支持缓存cache:redis,memory,数据库
|
||||
自带工具类,上下文,以及session等功能
|
||||
|
||||
**高性能 Go Web 服务框架**
|
||||
|
||||
一个"小而全"的 Go Web 框架,内置 ORM、三级缓存、Session 管理,让你专注于业务逻辑。
|
||||
|
||||
## 核心特性
|
||||
|
||||
- **高性能** - 单机 10万+ QPS,支持百万级并发用户
|
||||
- **内置 ORM** - 类 Medoo 语法,链式查询,支持 MySQL/SQLite/PostgreSQL
|
||||
- **三级缓存** - Memory > Redis > DB,自动穿透与回填
|
||||
- **Session 管理** - 内置会话管理,支持多种存储后端
|
||||
- **代码生成** - 根据数据库表自动生成 CRUD 接口
|
||||
- **API 测试** - 内置接口测试框架,链式 API、事务自动回滚、覆盖率报告、交互式调试控制台
|
||||
- **优雅停机** - 跨平台(Windows/Linux)支持,停机时自动拒绝新请求并等待在途请求完成,配合 nginx 实现零停机滚动重启
|
||||
- **结构化日志 / Seq 集成** - zerolog 驱动,异步 channel 队列推送,零阻塞接入 Seq 集中搜索,自动捕获 fmt.Println 等全量输出,支持单机多进程实例区分
|
||||
- **开箱即用** - 微信支付/公众号/小程序、阿里云、腾讯云等 SDK 内置
|
||||
|
||||
## 文档
|
||||
|
||||
完整阅读路径见 [docs/README.md](docs/README.md)。
|
||||
|
||||
| 文档 | 说明 |
|
||||
|------|------|
|
||||
| [快速上手](docs/QuickStart_快速上手.md) | 5 分钟入门:安装配置、路由、中间件 |
|
||||
| [HoTimeDB 使用说明](docs/HoTimeDB_使用说明.md) | 完整数据库 ORM 教程 |
|
||||
| [HoTimeDB API 参考](docs/HoTimeDB_API参考.md) | 数据库 API 速查手册 |
|
||||
| [Common 工具类](docs/Common_工具类.md) | Map/Slice/Obj 类型、类型转换、工具函数 |
|
||||
| [数据库设计规范](docs/DatabaseDesign_数据库设计规范.md) | 表命名、字段命名、COMMENT、必有字段(CodeGen 前置) |
|
||||
| [代码生成](docs/CodeGen_代码生成.md) | 自动 CRUD 生成 + codeConfig / rule 配置 |
|
||||
| [API 测试框架](docs/Testing_API测试框架.md) | 接口测试、事务隔离、覆盖率、调试控制台 |
|
||||
| [优雅停机](docs/GracefulShutdown_优雅停机.md) | 跨平台优雅停机、nginx、滚动重启 |
|
||||
| [Seq 日志集成](docs/Seq_日志集成.md) | Seq 接入、异步队列、多实例区分 |
|
||||
| [改进规划](docs/ROADMAP_改进规划.md) | 待改进项与版本迭代规划 |
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
go get code.hoteas.com/golang/hotime
|
||||
```
|
||||
|
||||
## 性能
|
||||
|
||||
| 并发数 | QPS | 成功率 | 平均延迟 |
|
||||
|--------|-----|--------|----------|
|
||||
| 500 | 99,960 | 100% | 5.0ms |
|
||||
| **1000** | **102,489** | **100%** | **9.7ms** |
|
||||
| 2000 | 75,801 | 99.99% | 26.2ms |
|
||||
|
||||
> 测试环境:24 核 CPU,Windows 10,Go 1.19.3
|
||||
|
||||
### 并发用户估算
|
||||
|
||||
| 使用场景 | 请求频率 | 可支持用户数 |
|
||||
|----------|----------|--------------|
|
||||
| 高频交互 | 1次/秒 | ~10万 |
|
||||
| 活跃用户 | 1次/5秒 | ~50万 |
|
||||
| 普通浏览 | 1次/10秒 | ~100万 |
|
||||
|
||||
## 框架对比
|
||||
|
||||
| 特性 | HoTime | Gin | Echo | Fiber |
|
||||
|------|--------|-----|------|-------|
|
||||
| 性能 | 100K QPS | 70K QPS | 70K QPS | 100K QPS |
|
||||
| 内置ORM | ✅ | ❌ | ❌ | ❌ |
|
||||
| 内置缓存 | ✅ 三级缓存 | ❌ | ❌ | ❌ |
|
||||
| Session | ✅ 内置 | ❌ 需插件 | ❌ 需插件 | ❌ 需插件 |
|
||||
| 代码生成 | ✅ | ❌ | ❌ | ❌ |
|
||||
| API 测试框架 | ✅ 内置 | ❌ 需插件 | ❌ 需插件 | ❌ 需插件 |
|
||||
| 优雅停机 | ✅ 内置跨平台 | ❌ 需自行实现 | ❌ 需自行实现 | ✅ 内置 |
|
||||
| 微信/支付集成 | ✅ 内置 | ❌ | ❌ | ❌ |
|
||||
|
||||
## 适用场景
|
||||
|
||||
| 场景 | 推荐度 | 说明 |
|
||||
|------|--------|------|
|
||||
| 中小型后台系统 | ⭐⭐⭐⭐⭐ | 完美适配,开发效率最高 |
|
||||
| 微信小程序后端 | ⭐⭐⭐⭐⭐ | 内置微信 SDK |
|
||||
| 快速原型开发 | ⭐⭐⭐⭐⭐ | 代码生成 + 全功能集成 |
|
||||
| 高并发 API 服务 | ⭐⭐⭐⭐ | 性能足够 |
|
||||
| 大型微服务 | ⭐⭐⭐ | 建议用 Gin/Echo |
|
||||
|
||||
## 扩展功能
|
||||
|
||||
- 微信支付/公众号/小程序 - `dri/wechat/`
|
||||
- 阿里云服务 - `dri/aliyun/`
|
||||
- 腾讯云服务 - `dri/tencent/`
|
||||
- 文件上传下载 - `dri/upload/`, `dri/download/`
|
||||
- MongoDB - `dri/mongodb/`
|
||||
- RSA 加解密 - `dri/rsa/`
|
||||
|
||||
## License
|
||||
|
||||
Apache License 2.0
|
||||
|
||||
---
|
||||
|
||||
**HoTime** - 让 Go Web 开发更简单、更高效
|
||||
|
||||
+195
-536
File diff suppressed because it is too large
Load Diff
Vendored
+44
-237
@@ -1,14 +1,14 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
"code.hoteas.com/golang/hotime/log"
|
||||
. "../common"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// HoTimeCache 可配置memory,db,redis,默认启用memory,默认优先级为memory>redis>db,memory与数据库缓存设置项一致,
|
||||
// 缓存数据填充会自动反方向反哺,加入memory缓存过期将自动从redis更新,但memory永远不会更新redis,如果是集群建议不要开启memory,配置即启用
|
||||
//缓存数据填充会自动反方向反哺,加入memory缓存过期将自动从redis更新,但memory永远不会更新redis,如果是集群建议不要开启memory,配置即启用
|
||||
type HoTimeCache struct {
|
||||
Log *log.Logger
|
||||
*Error
|
||||
dbCache *CacheDb
|
||||
redisCache *CacheRedis
|
||||
memoryCache *CacheMemory
|
||||
@@ -92,7 +92,7 @@ func (that *HoTimeCache) Db(key string, data ...interface{}) *Obj {
|
||||
}
|
||||
}
|
||||
|
||||
//db缓存有
|
||||
//redis缓存有
|
||||
if that.dbCache != nil && that.dbCache.DbSet {
|
||||
reData = that.dbCache.Cache(key, data...)
|
||||
if reData.Data != nil {
|
||||
@@ -119,7 +119,7 @@ func (that *HoTimeCache) Db(key string, data ...interface{}) *Obj {
|
||||
if that.redisCache != nil && that.redisCache.DbSet {
|
||||
reData = that.redisCache.Cache(key, data...)
|
||||
}
|
||||
//db缓存有
|
||||
//redis缓存有
|
||||
if that.dbCache != nil && that.dbCache.DbSet {
|
||||
reData = that.dbCache.Cache(key, data...)
|
||||
}
|
||||
@@ -132,7 +132,7 @@ func (that *HoTimeCache) Cache(key string, data ...interface{}) *Obj {
|
||||
//内存缓存有
|
||||
if that.memoryCache != nil {
|
||||
reData = that.memoryCache.Cache(key, data...)
|
||||
if reData != nil && reData.Data != nil {
|
||||
if reData != nil {
|
||||
return reData
|
||||
}
|
||||
}
|
||||
@@ -140,7 +140,8 @@ func (that *HoTimeCache) Cache(key string, data ...interface{}) *Obj {
|
||||
//redis缓存有
|
||||
if that.redisCache != nil {
|
||||
reData = that.redisCache.Cache(key, data...)
|
||||
if reData != nil && reData.Data != nil {
|
||||
if reData.Data != nil {
|
||||
|
||||
if that.memoryCache != nil {
|
||||
that.memoryCache.Cache(key, reData.Data)
|
||||
}
|
||||
@@ -148,10 +149,10 @@ func (that *HoTimeCache) Cache(key string, data ...interface{}) *Obj {
|
||||
}
|
||||
}
|
||||
|
||||
//db缓存有
|
||||
//redis缓存有
|
||||
if that.dbCache != nil {
|
||||
reData = that.dbCache.Cache(key, data...)
|
||||
if reData != nil && reData.Data != nil {
|
||||
if reData.Data != nil {
|
||||
if that.memoryCache != nil {
|
||||
that.memoryCache.Cache(key, reData.Data)
|
||||
}
|
||||
@@ -173,222 +174,23 @@ func (that *HoTimeCache) Cache(key string, data ...interface{}) *Obj {
|
||||
if that.redisCache != nil {
|
||||
reData = that.redisCache.Cache(key, data...)
|
||||
}
|
||||
//db缓存有
|
||||
//redis缓存有
|
||||
if that.dbCache != nil {
|
||||
reData = that.dbCache.Cache(key, data...)
|
||||
}
|
||||
return reData
|
||||
}
|
||||
|
||||
// SessionsGet 批量获取 Session 缓存
|
||||
// 返回 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) {
|
||||
func (that *HoTimeCache) Init(config Map, hotimeDb HoTimeDBInterface, err ...*Error) {
|
||||
//防止空数据问题
|
||||
if config == nil {
|
||||
config = Map{}
|
||||
}
|
||||
that.Log = logger
|
||||
if err[0] != nil {
|
||||
that.Error = err[0]
|
||||
}
|
||||
that.Config = config
|
||||
|
||||
//memory配置初始化
|
||||
memory := that.Config.GetMap("memory")
|
||||
if memory == nil {
|
||||
memory = Map{
|
||||
@@ -397,74 +199,79 @@ func (that *HoTimeCache) Init(config Map, hotimeDb HoTimeDBInterface, logger *lo
|
||||
"sort": 0,
|
||||
"timeout": 60 * 60 * 2,
|
||||
}
|
||||
|
||||
}
|
||||
if memory.Get("db") == nil {
|
||||
memory["db"] = true
|
||||
}
|
||||
|
||||
if memory.Get("session") == nil {
|
||||
memory["session"] = true
|
||||
}
|
||||
|
||||
if memory.Get("timeout") == nil {
|
||||
memory["timeout"] = 60 * 60 * 2
|
||||
}
|
||||
that.Config["memory"] = memory
|
||||
that.memoryCache = &CacheMemory{TimeOut: memory.GetCeilInt64("timeout"),
|
||||
DbSet: memory.GetBool("db"), SessionSet: memory.GetBool("session"),
|
||||
Log: logger}
|
||||
DbSet: memory.GetBool("db"), SessionSet: memory.GetBool("session")}
|
||||
if err[0] != nil {
|
||||
that.memoryCache.SetError(err[0])
|
||||
}
|
||||
|
||||
//db配置初始化
|
||||
redis := that.Config.GetMap("redis")
|
||||
if redis != nil {
|
||||
if redis.GetString("host") == "" || redis.GetString("port") == "" {
|
||||
if logger != nil {
|
||||
logger.Error().Msg("请检查redis配置host和port配置")
|
||||
if err[0] != nil {
|
||||
err[0].SetError(errors.New("请检查redis配置host和port配置"))
|
||||
}
|
||||
return
|
||||
}
|
||||
if redis.Get("db") == nil {
|
||||
redis["db"] = true
|
||||
}
|
||||
|
||||
if redis.Get("session") == nil {
|
||||
redis["session"] = true
|
||||
}
|
||||
|
||||
if redis.Get("timeout") == nil {
|
||||
redis["timeout"] = 60 * 60 * 24 * 15
|
||||
}
|
||||
that.Config["redis"] = redis
|
||||
that.redisCache = &CacheRedis{TimeOut: redis.GetCeilInt64("timeout"),
|
||||
DbSet: redis.GetBool("db"), SessionSet: redis.GetBool("session"), Host: redis.GetString("host"),
|
||||
Pwd: redis.GetString("password"), Port: redis.GetCeilInt64("port"),
|
||||
Log: logger}
|
||||
Pwd: redis.GetString("password"), Port: redis.GetCeilInt64("port")}
|
||||
|
||||
if err[0] != nil {
|
||||
that.redisCache.SetError(err[0])
|
||||
}
|
||||
}
|
||||
|
||||
//db配置初始化
|
||||
db := that.Config.GetMap("db")
|
||||
if db != nil {
|
||||
|
||||
if db.Get("db") == nil {
|
||||
db["db"] = false
|
||||
}
|
||||
|
||||
if db.Get("session") == nil {
|
||||
db["session"] = true
|
||||
}
|
||||
if db.Get("timeout") == nil {
|
||||
db["timeout"] = 60 * 60 * 24 * 30
|
||||
}
|
||||
if db.Get("mode") == nil {
|
||||
db["mode"] = CacheModeCompatible
|
||||
}
|
||||
that.Config["db"] = db
|
||||
|
||||
that.dbCache = &CacheDb{
|
||||
TimeOut: db.GetCeilInt64("timeout"),
|
||||
DbSet: db.GetBool("db"),
|
||||
SessionSet: db.GetBool("session"),
|
||||
HistorySet: db.GetBool("history"),
|
||||
Mode: db.GetString("mode"),
|
||||
Db: hotimeDb,
|
||||
Log: logger,
|
||||
that.dbCache = &CacheDb{TimeOut: db.GetCeilInt64("timeout"),
|
||||
DbSet: db.GetBool("db"), SessionSet: db.GetBool("session"),
|
||||
Db: hotimeDb}
|
||||
|
||||
if err[0] != nil {
|
||||
that.dbCache.SetError(err[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (that *HoTimeCache) DisableDbCache() {
|
||||
that.dbCache = nil
|
||||
that.redisCache = nil
|
||||
}
|
||||
|
||||
Vendored
+88
-588
@@ -1,29 +1,17 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
. "../common"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
"code.hoteas.com/golang/hotime/log"
|
||||
)
|
||||
|
||||
// 表名常量
|
||||
const (
|
||||
CacheTableName = "hotime_cache"
|
||||
CacheHistoryTableName = "hotime_cache_history"
|
||||
LegacyCacheTableName = "cached" // 老版本缓存表名
|
||||
DefaultCacheTimeout = 24 * 60 * 60 // 默认过期时间 24 小时
|
||||
CacheModeNew = "new" // 新模式:只使用新表
|
||||
CacheModeCompatible = "compatible" // 兼容模式:写新读老
|
||||
)
|
||||
|
||||
type HoTimeDBInterface interface {
|
||||
GetPrefix() string
|
||||
Query(query string, args ...interface{}) []Map
|
||||
Exec(query string, args ...interface{}) (sql.Result, error)
|
||||
Exec(query string, args ...interface{}) (sql.Result, *Error)
|
||||
Get(table string, qu ...interface{}) Map
|
||||
Select(table string, qu ...interface{}) []Map
|
||||
Delete(table string, data map[string]interface{}) int64
|
||||
@@ -36,638 +24,150 @@ type CacheDb struct {
|
||||
TimeOut int64
|
||||
DbSet bool
|
||||
SessionSet bool
|
||||
HistorySet bool // 是否开启历史记录
|
||||
Mode string // 缓存模式:"new"(默认,只用新表) 或 "compatible"(写新读老)
|
||||
Db HoTimeDBInterface
|
||||
Log *log.Logger
|
||||
*Error
|
||||
ContextBase
|
||||
isInit bool
|
||||
}
|
||||
|
||||
// getTableName 获取带前缀的表名
|
||||
func (that *CacheDb) getTableName() string {
|
||||
return that.Db.GetPrefix() + CacheTableName
|
||||
func (this *CacheDb) GetError() *Error {
|
||||
|
||||
return this.Error
|
||||
|
||||
}
|
||||
|
||||
// getHistoryTableName 获取带前缀的历史表名
|
||||
func (that *CacheDb) getHistoryTableName() string {
|
||||
return that.Db.GetPrefix() + CacheHistoryTableName
|
||||
func (this *CacheDb) SetError(err *Error) {
|
||||
this.Error = err
|
||||
}
|
||||
|
||||
// getLegacyTableName 获取带前缀的老版本缓存表名
|
||||
func (that *CacheDb) getLegacyTableName() string {
|
||||
return that.Db.GetPrefix() + LegacyCacheTableName
|
||||
}
|
||||
|
||||
// isCompatibleMode 是否为兼容模式
|
||||
func (that *CacheDb) isCompatibleMode() bool {
|
||||
return that.Mode == CacheModeCompatible
|
||||
}
|
||||
|
||||
// getEffectiveMode 获取有效模式(默认为 new)
|
||||
func (that *CacheDb) getEffectiveMode() string {
|
||||
if that.Mode == CacheModeCompatible {
|
||||
return CacheModeCompatible
|
||||
}
|
||||
return CacheModeNew
|
||||
}
|
||||
|
||||
// initDbTable 初始化数据库表
|
||||
func (that *CacheDb) initDbTable() {
|
||||
if that.isInit {
|
||||
return
|
||||
}
|
||||
if that.Db.GetType() == "mysql" {
|
||||
|
||||
dbType := that.Db.GetType()
|
||||
tableName := that.getTableName()
|
||||
historyTableName := that.getHistoryTableName()
|
||||
legacyTableName := that.getLegacyTableName()
|
||||
|
||||
// 检查并创建主表
|
||||
if !that.tableExists(tableName) {
|
||||
that.createMainTable(dbType, tableName)
|
||||
}
|
||||
|
||||
// 根据模式处理老表
|
||||
// new 模式:迁移老表数据到新表(不删除老表,由人工删除)
|
||||
// compatible 模式:不迁移,老表继续使用
|
||||
if that.getEffectiveMode() == CacheModeNew {
|
||||
if that.tableExists(legacyTableName) {
|
||||
that.migrateFromCached(dbType, legacyTableName, tableName)
|
||||
}
|
||||
}
|
||||
// compatible 模式不做任何处理,老表保留供读取
|
||||
|
||||
// 检查并创建历史表(开启历史记录时)
|
||||
if that.HistorySet && !that.tableExists(historyTableName) {
|
||||
that.createHistoryTable(dbType, historyTableName)
|
||||
}
|
||||
|
||||
that.isInit = true
|
||||
}
|
||||
|
||||
// tableExists 检查表是否存在
|
||||
func (that *CacheDb) tableExists(tableName string) bool {
|
||||
dbType := that.Db.GetType()
|
||||
|
||||
switch dbType {
|
||||
case "mysql":
|
||||
dbNames := that.Db.Query("SELECT DATABASE()")
|
||||
|
||||
if len(dbNames) == 0 {
|
||||
return false
|
||||
return
|
||||
}
|
||||
dbName := dbNames[0].GetString("DATABASE()")
|
||||
res := that.Db.Query("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA='" + dbName + "' AND TABLE_NAME='" + tableName + "'")
|
||||
return len(res) != 0
|
||||
|
||||
case "sqlite":
|
||||
res := that.Db.Query(`SELECT name FROM sqlite_master WHERE type='table' AND name='` + tableName + `'`)
|
||||
return len(res) != 0
|
||||
|
||||
case "postgres":
|
||||
res := that.Db.Query(`SELECT tablename FROM pg_tables WHERE schemaname='public' AND tablename='` + tableName + `'`)
|
||||
return len(res) != 0
|
||||
|
||||
case "dm", "dameng":
|
||||
// USER_TABLES 仅含当前登录用户(如 SYSDBA)拥有的表。
|
||||
// 当连接 schema(如 ZWBG)与登录用户不同时,ZWBG 的表不会出现在
|
||||
// USER_TABLES 中,导致 tableExists 误判为"不存在",进而触发重复建表失败。
|
||||
// 改为直接 SELECT COUNT(*) 探测当前 schema 下的可访问性:
|
||||
// 表存在则返回 1 行,表不存在则报错返回空结果(nil)。
|
||||
res := that.Db.Query(`SELECT COUNT(*) as cnt FROM "` + tableName + `"`)
|
||||
return len(res) > 0
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// createMainTable 创建主表
|
||||
func (that *CacheDb) createMainTable(dbType, tableName string) {
|
||||
var createSQL string
|
||||
|
||||
switch dbType {
|
||||
case "mysql":
|
||||
createSQL = "CREATE TABLE `" + tableName + "` (" +
|
||||
"`id` int(11) unsigned NOT NULL AUTO_INCREMENT," +
|
||||
"`key` varchar(64) NOT NULL COMMENT '缓存键'," +
|
||||
"`value` text DEFAULT NULL COMMENT '缓存值'," +
|
||||
"`end_time` datetime DEFAULT NULL COMMENT '过期时间'," +
|
||||
"`state` int(2) DEFAULT '0' COMMENT '状态:0-正常,1-异常,2-隐藏'," +
|
||||
"`create_time` datetime DEFAULT NULL COMMENT '创建日期'," +
|
||||
"`modify_time` datetime DEFAULT NULL COMMENT '变更时间'," +
|
||||
"PRIMARY KEY (`id`)," +
|
||||
"UNIQUE KEY `uk_key` (`key`)," +
|
||||
"KEY `idx_end_time` (`end_time`)" +
|
||||
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='缓存管理'"
|
||||
|
||||
case "sqlite":
|
||||
createSQL = `CREATE TABLE "` + tableName + `" (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"key" TEXT NOT NULL UNIQUE,
|
||||
"value" TEXT,
|
||||
"end_time" TEXT,
|
||||
"state" INTEGER DEFAULT 0,
|
||||
"create_time" TEXT,
|
||||
"modify_time" TEXT
|
||||
)`
|
||||
|
||||
case "postgres":
|
||||
createSQL = `CREATE TABLE "` + tableName + `" (
|
||||
"id" SERIAL PRIMARY KEY,
|
||||
"key" VARCHAR(64) NOT NULL UNIQUE,
|
||||
"value" TEXT,
|
||||
"end_time" TIMESTAMP,
|
||||
"state" INTEGER DEFAULT 0,
|
||||
"create_time" TIMESTAMP,
|
||||
"modify_time" TIMESTAMP
|
||||
)`
|
||||
that.Db.Exec(createSQL)
|
||||
that.Db.Exec(`CREATE INDEX "idx_` + tableName + `_end_time" ON "` + tableName + `" ("end_time")`)
|
||||
return
|
||||
|
||||
case "dm", "dameng":
|
||||
createSQL = `CREATE TABLE "` + tableName + `" (
|
||||
"id" INT IDENTITY(1,1) PRIMARY KEY,
|
||||
"key" VARCHAR(64) NOT NULL,
|
||||
"value" TEXT,
|
||||
"end_time" TIMESTAMP,
|
||||
"state" INT DEFAULT 0,
|
||||
"create_time" TIMESTAMP,
|
||||
"modify_time" TIMESTAMP,
|
||||
CONSTRAINT "uk_` + tableName + `_key" UNIQUE ("key")
|
||||
)`
|
||||
that.Db.Exec(createSQL)
|
||||
that.Db.Exec(`CREATE INDEX "idx_` + tableName + `_end_time" ON "` + tableName + `" ("end_time")`)
|
||||
return
|
||||
}
|
||||
|
||||
that.Db.Exec(createSQL)
|
||||
}
|
||||
|
||||
// createHistoryTable 创建历史表
|
||||
func (that *CacheDb) createHistoryTable(dbType, tableName string) {
|
||||
var createSQL string
|
||||
|
||||
switch dbType {
|
||||
case "mysql":
|
||||
createSQL = "CREATE TABLE `" + tableName + "` (" +
|
||||
"`id` int(11) unsigned NOT NULL AUTO_INCREMENT," +
|
||||
"`hotime_cache_id` int(11) unsigned DEFAULT NULL COMMENT '缓存ID'," +
|
||||
"`key` varchar(64) DEFAULT NULL COMMENT '缓存键'," +
|
||||
"`value` text DEFAULT NULL COMMENT '缓存值'," +
|
||||
"`end_time` datetime DEFAULT NULL COMMENT '过期时间'," +
|
||||
"`state` int(2) DEFAULT '0' COMMENT '状态:0-正常,1-异常,2-隐藏'," +
|
||||
"`create_time` datetime DEFAULT NULL COMMENT '创建日期'," +
|
||||
"`modify_time` datetime DEFAULT NULL COMMENT '变更时间'," +
|
||||
"PRIMARY KEY (`id`)," +
|
||||
"KEY `idx_hotime_cache_id` (`hotime_cache_id`)" +
|
||||
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='缓存历史'"
|
||||
|
||||
case "sqlite":
|
||||
createSQL = `CREATE TABLE "` + tableName + `" (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"hotime_cache_id" INTEGER,
|
||||
"key" TEXT,
|
||||
"value" TEXT,
|
||||
"end_time" TEXT,
|
||||
"state" INTEGER DEFAULT 0,
|
||||
"create_time" TEXT,
|
||||
"modify_time" TEXT
|
||||
)`
|
||||
|
||||
case "postgres":
|
||||
createSQL = `CREATE TABLE "` + tableName + `" (
|
||||
"id" SERIAL PRIMARY KEY,
|
||||
"hotime_cache_id" INTEGER,
|
||||
"key" VARCHAR(64),
|
||||
"value" TEXT,
|
||||
"end_time" TIMESTAMP,
|
||||
"state" INTEGER DEFAULT 0,
|
||||
"create_time" TIMESTAMP,
|
||||
"modify_time" TIMESTAMP
|
||||
)`
|
||||
that.Db.Exec(createSQL)
|
||||
that.Db.Exec(`CREATE INDEX "idx_` + tableName + `_cache_id" ON "` + tableName + `" ("hotime_cache_id")`)
|
||||
return
|
||||
|
||||
case "dm", "dameng":
|
||||
createSQL = `CREATE TABLE "` + tableName + `" (
|
||||
"id" INT IDENTITY(1,1) PRIMARY KEY,
|
||||
"hotime_cache_id" INT,
|
||||
"key" VARCHAR(64),
|
||||
"value" TEXT,
|
||||
"end_time" TIMESTAMP,
|
||||
"state" INT DEFAULT 0,
|
||||
"create_time" TIMESTAMP,
|
||||
"modify_time" TIMESTAMP
|
||||
)`
|
||||
that.Db.Exec(createSQL)
|
||||
that.Db.Exec(`CREATE INDEX "idx_` + tableName + `_cache_id" ON "` + tableName + `" ("hotime_cache_id")`)
|
||||
return
|
||||
}
|
||||
|
||||
that.Db.Exec(createSQL)
|
||||
}
|
||||
|
||||
// migrateFromCached 从旧 cached 表迁移数据(不删除老表,由人工删除)
|
||||
func (that *CacheDb) migrateFromCached(dbType, oldTableName, newTableName string) {
|
||||
var migrateSQL string
|
||||
|
||||
switch dbType {
|
||||
case "mysql":
|
||||
// 去重迁移:取每个 key 的最后一条记录(id 最大)
|
||||
// 使用 INSERT IGNORE 避免重复 key 冲突
|
||||
migrateSQL = "INSERT IGNORE INTO `" + newTableName + "` (`key`, `value`, `end_time`, `state`, `create_time`, `modify_time`) " +
|
||||
"SELECT c.`key`, c.`value`, FROM_UNIXTIME(c.`endtime`), 0, " +
|
||||
"FROM_UNIXTIME(c.`time` / 1000000000), FROM_UNIXTIME(c.`time` / 1000000000) " +
|
||||
"FROM `" + oldTableName + "` c " +
|
||||
"INNER JOIN (SELECT `key`, MAX(id) as max_id FROM `" + oldTableName + "` GROUP BY `key`) m " +
|
||||
"ON c.id = m.max_id"
|
||||
|
||||
case "sqlite":
|
||||
migrateSQL = `INSERT OR IGNORE INTO "` + newTableName + `" ("key", "value", "end_time", "state", "create_time", "modify_time") ` +
|
||||
`SELECT c."key", c."value", datetime(c."endtime", 'unixepoch'), 0, ` +
|
||||
`datetime(c."time" / 1000000000, 'unixepoch'), datetime(c."time" / 1000000000, 'unixepoch') ` +
|
||||
`FROM "` + oldTableName + `" c ` +
|
||||
`INNER JOIN (SELECT "key", MAX(id) as max_id FROM "` + oldTableName + `" GROUP BY "key") m ` +
|
||||
`ON c.id = m.max_id`
|
||||
|
||||
case "postgres":
|
||||
migrateSQL = `INSERT INTO "` + newTableName + `" ("key", "value", "end_time", "state", "create_time", "modify_time") ` +
|
||||
`SELECT c."key", c."value", to_timestamp(c."endtime"), 0, ` +
|
||||
`to_timestamp(c."time" / 1000000000), to_timestamp(c."time" / 1000000000) ` +
|
||||
`FROM "` + oldTableName + `" c ` +
|
||||
`INNER JOIN (SELECT "key", MAX(id) as max_id FROM "` + oldTableName + `" GROUP BY "key") m ` +
|
||||
`ON c.id = m.max_id ` +
|
||||
`ON CONFLICT ("key") DO NOTHING`
|
||||
|
||||
case "dm", "dameng":
|
||||
migrateSQL = `MERGE INTO "` + newTableName + `" t USING (` +
|
||||
`SELECT c."key", c."value", DATEADD(SECOND, c."endtime", TIMESTAMP '1970-01-01 00:00:00') AS "end_time", ` +
|
||||
`DATEADD(SECOND, c."time" / 1000000000, TIMESTAMP '1970-01-01 00:00:00') AS "create_time" ` +
|
||||
`FROM "` + oldTableName + `" c ` +
|
||||
`INNER JOIN (SELECT "key", MAX("id") AS max_id FROM "` + oldTableName + `" GROUP BY "key") m ` +
|
||||
`ON c."id" = m.max_id` +
|
||||
`) src ON (t."key" = src."key") ` +
|
||||
`WHEN NOT MATCHED THEN INSERT ("key", "value", "end_time", "state", "create_time", "modify_time") ` +
|
||||
`VALUES (src."key", src."value", src."end_time", 0, src."create_time", src."create_time")`
|
||||
}
|
||||
|
||||
that.Db.Exec(migrateSQL)
|
||||
}
|
||||
|
||||
// writeHistory 写入历史记录
|
||||
func (that *CacheDb) writeHistory(key string) {
|
||||
if !that.HistorySet {
|
||||
return
|
||||
}
|
||||
|
||||
tableName := that.getTableName()
|
||||
historyTableName := that.getHistoryTableName()
|
||||
|
||||
// 查询当前数据
|
||||
cached := that.Db.Get(tableName, "*", Map{"key": key})
|
||||
if cached == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 构建历史记录数据
|
||||
historyData := Map{
|
||||
"hotime_cache_id": cached.GetInt64("id"),
|
||||
"key": cached.GetString("key"),
|
||||
"value": cached.GetString("value"),
|
||||
"end_time": cached.GetString("end_time"),
|
||||
"state": cached.GetInt("state"),
|
||||
"create_time": cached.GetString("create_time"),
|
||||
"modify_time": cached.GetString("modify_time"),
|
||||
}
|
||||
|
||||
// 插入历史表
|
||||
that.Db.Insert(historyTableName, historyData)
|
||||
}
|
||||
|
||||
// getLegacy 从老表获取缓存(兼容模式使用)
|
||||
// 老表结构:key, value, endtime(unix秒), time(纳秒时间戳)
|
||||
func (that *CacheDb) getLegacy(key string) interface{} {
|
||||
legacyTableName := that.getLegacyTableName()
|
||||
|
||||
// 检查老表是否存在
|
||||
if !that.tableExists(legacyTableName) {
|
||||
return nil
|
||||
}
|
||||
|
||||
cached := that.Db.Get(legacyTableName, "*", Map{"key": key})
|
||||
if cached == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 检查过期时间(老表使用 unix 时间戳)
|
||||
endTime := cached.GetInt64("endtime")
|
||||
nowUnix := time.Now().Unix()
|
||||
if endTime > 0 && endTime <= nowUnix {
|
||||
// 已过期,删除该条记录
|
||||
that.deleteLegacy(key)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 解析 value(老表 value 格式可能是 {"data": xxx} 或直接值)
|
||||
valueStr := cached.GetString("value")
|
||||
if valueStr == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := JsonToObj(valueStr)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 兼容老版本 {"data": xxx} 包装格式
|
||||
if dataMap, ok := data.(map[string]interface{}); ok {
|
||||
if innerData, exists := dataMap["data"]; exists {
|
||||
return innerData
|
||||
res := that.Db.Query("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA='" + dbName + "' AND TABLE_NAME='" + that.Db.GetPrefix() + "cached'")
|
||||
if len(res) != 0 {
|
||||
that.isInit = true
|
||||
return
|
||||
}
|
||||
|
||||
_, e := that.Db.Exec("CREATE TABLE `" + that.Db.GetPrefix() + "cached` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `ckey` varchar(60) DEFAULT NULL, `cvalue` varchar(2000) DEFAULT NULL, `time` bigint(20) DEFAULT NULL, `endtime` bigint(20) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=198740 DEFAULT CHARSET=utf8")
|
||||
if e.GetError() == nil {
|
||||
that.isInit = true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if that.Db.GetType() == "sqlite" {
|
||||
res := that.Db.Query(`select * from sqlite_master where type = 'table' and name = '` + that.Db.GetPrefix() + `cached'`)
|
||||
|
||||
if len(res) != 0 {
|
||||
that.isInit = true
|
||||
return
|
||||
}
|
||||
_, e := that.Db.Exec(`CREATE TABLE "` + that.Db.GetPrefix() + `cached" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"ckey" TEXT(60),
|
||||
"cvalue" TEXT(2000),
|
||||
"time" integer,
|
||||
"endtime" integer
|
||||
);`)
|
||||
if e.GetError() == nil {
|
||||
that.isInit = true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// deleteLegacy 从老表删除缓存(兼容模式使用)
|
||||
func (that *CacheDb) deleteLegacy(key string) {
|
||||
legacyTableName := that.getLegacyTableName()
|
||||
|
||||
// 检查老表是否存在
|
||||
if !that.tableExists(legacyTableName) {
|
||||
return
|
||||
}
|
||||
|
||||
del := strings.Index(key, "*")
|
||||
// 如果通配删除
|
||||
if del != -1 {
|
||||
keyPrefix := Substr(key, 0, del)
|
||||
that.Db.Delete(legacyTableName, Map{"key[~]": keyPrefix + "%"})
|
||||
} else {
|
||||
that.Db.Delete(legacyTableName, Map{"key": key})
|
||||
}
|
||||
}
|
||||
|
||||
// get 获取缓存
|
||||
//获取Cache键只能为string类型
|
||||
func (that *CacheDb) get(key string) interface{} {
|
||||
tableName := that.getTableName()
|
||||
cached := that.Db.Get(tableName, "*", Map{"key": key})
|
||||
|
||||
if cached != nil {
|
||||
// 使用字符串比较判断过期(ISO 格式天然支持)
|
||||
endTime := cached.GetString("end_time")
|
||||
nowTime := Time2Str(time.Now())
|
||||
if endTime != "" && endTime <= nowTime {
|
||||
// 惰性删除:过期只返回 nil,不立即删除
|
||||
// 依赖随机清理批量删除过期数据
|
||||
// 继续检查老表(如果是兼容模式)
|
||||
} else {
|
||||
// 直接解析 value,不再需要 {"data": value} 包装
|
||||
valueStr := cached.GetString("value")
|
||||
if valueStr != "" {
|
||||
data, err := JsonToObj(valueStr)
|
||||
if err == nil {
|
||||
return data
|
||||
}
|
||||
}
|
||||
}
|
||||
cached := that.Db.Get("cached", "*", Map{"ckey": key})
|
||||
|
||||
if cached == nil {
|
||||
return nil
|
||||
}
|
||||
//data:=cacheMap[key];
|
||||
if cached.GetInt64("endtime") <= time.Now().Unix() {
|
||||
|
||||
that.Db.Delete("cached", Map{"id": cached.GetString("id")})
|
||||
return nil
|
||||
}
|
||||
|
||||
// 兼容模式:新表没有数据时,回退读取老表
|
||||
if that.isCompatibleMode() {
|
||||
return that.getLegacy(key)
|
||||
}
|
||||
data := Map{}
|
||||
data.JsonToMap(cached.GetString("cvalue"))
|
||||
|
||||
return nil
|
||||
return data.Get("data")
|
||||
}
|
||||
|
||||
// set 设置缓存
|
||||
func (that *CacheDb) set(key string, value interface{}, endTime time.Time) {
|
||||
// 直接序列化 value,不再包装
|
||||
bte, _ := json.Marshal(value)
|
||||
nowTime := Time2Str(time.Now())
|
||||
endTimeStr := Time2Str(endTime)
|
||||
//key value ,时间为时间戳
|
||||
func (that *CacheDb) set(key string, value interface{}, tim int64) {
|
||||
|
||||
dbType := that.Db.GetType()
|
||||
tableName := that.getTableName()
|
||||
bte, _ := json.Marshal(Map{"data": value})
|
||||
|
||||
// 使用 UPSERT 语法解决并发问题
|
||||
switch dbType {
|
||||
case "mysql":
|
||||
upsertSQL := "INSERT INTO `" + tableName + "` (`key`, `value`, `end_time`, `state`, `create_time`, `modify_time`) " +
|
||||
"VALUES (?, ?, ?, 0, ?, ?) " +
|
||||
"ON DUPLICATE KEY UPDATE `value`=VALUES(`value`), `end_time`=VALUES(`end_time`), `modify_time`=VALUES(`modify_time`)"
|
||||
that.Db.Exec(upsertSQL, key, string(bte), endTimeStr, nowTime, nowTime)
|
||||
|
||||
case "sqlite":
|
||||
// SQLite: INSERT OR REPLACE 会删除后插入,所以用 UPSERT 语法
|
||||
upsertSQL := `INSERT INTO "` + tableName + `" ("key", "value", "end_time", "state", "create_time", "modify_time") ` +
|
||||
`VALUES (?, ?, ?, 0, ?, ?) ` +
|
||||
`ON CONFLICT("key") DO UPDATE SET "value"=excluded."value", "end_time"=excluded."end_time", "modify_time"=excluded."modify_time"`
|
||||
that.Db.Exec(upsertSQL, key, string(bte), endTimeStr, nowTime, nowTime)
|
||||
|
||||
case "postgres":
|
||||
upsertSQL := `INSERT INTO "` + tableName + `" ("key", "value", "end_time", "state", "create_time", "modify_time") ` +
|
||||
`VALUES ($1, $2, $3, 0, $4, $5) ` +
|
||||
`ON CONFLICT ("key") DO UPDATE SET "value"=EXCLUDED."value", "end_time"=EXCLUDED."end_time", "modify_time"=EXCLUDED."modify_time"`
|
||||
that.Db.Exec(upsertSQL, key, string(bte), endTimeStr, nowTime, nowTime)
|
||||
|
||||
case "dm", "dameng":
|
||||
upsertSQL := `MERGE INTO "` + tableName + `" t USING (SELECT ? AS "key", ? AS "value", ? AS "end_time", ? AS "create_time", ? AS "modify_time") src ` +
|
||||
`ON (t."key" = src."key") ` +
|
||||
`WHEN MATCHED THEN UPDATE SET "value"=src."value", "end_time"=src."end_time", "modify_time"=src."modify_time" ` +
|
||||
`WHEN NOT MATCHED THEN INSERT ("key", "value", "end_time", "state", "create_time", "modify_time") ` +
|
||||
`VALUES (src."key", src."value", src."end_time", 0, src."create_time", src."modify_time")`
|
||||
that.Db.Exec(upsertSQL, key, string(bte), endTimeStr, nowTime, nowTime)
|
||||
|
||||
default:
|
||||
// 兼容其他数据库:使用 Update + Insert
|
||||
num := that.Db.Update(tableName, Map{
|
||||
"value": string(bte),
|
||||
"end_time": endTimeStr,
|
||||
"modify_time": nowTime,
|
||||
}, Map{"key": key})
|
||||
if num == 0 {
|
||||
that.Db.Insert(tableName, Map{
|
||||
"key": key,
|
||||
"value": string(bte),
|
||||
"end_time": endTimeStr,
|
||||
"state": 0,
|
||||
"create_time": nowTime,
|
||||
"modify_time": nowTime,
|
||||
})
|
||||
}
|
||||
num := that.Db.Update("cached", Map{"cvalue": string(bte), "time": time.Now().UnixNano(), "endtime": tim}, Map{"ckey": key})
|
||||
if num == int64(0) {
|
||||
that.Db.Insert("cached", Map{"cvalue": string(bte), "time": time.Now().UnixNano(), "endtime": tim, "ckey": key})
|
||||
}
|
||||
|
||||
// 写入历史记录
|
||||
that.writeHistory(key)
|
||||
|
||||
// 兼容模式:写新表后删除老表同 key 记录(加速老数据消亡)
|
||||
if that.isCompatibleMode() {
|
||||
that.deleteLegacy(key)
|
||||
}
|
||||
|
||||
// 随机执行删除过期数据命令(5% 概率)
|
||||
//随机执行删除命令
|
||||
if Rand(1000) > 950 {
|
||||
nowTimeStr := Time2Str(time.Now())
|
||||
that.Db.Delete(tableName, Map{"end_time[<]": nowTimeStr})
|
||||
that.Db.Delete("cached", Map{"endtime[<]": time.Now().Unix()})
|
||||
}
|
||||
}
|
||||
|
||||
// delete 删除缓存
|
||||
func (that *CacheDb) delete(key string) {
|
||||
tableName := that.getTableName()
|
||||
del := strings.Index(key, "*")
|
||||
// 如果通配删除
|
||||
if del != -1 {
|
||||
keyPrefix := Substr(key, 0, del)
|
||||
that.Db.Delete(tableName, Map{"key[~]": keyPrefix + "%"})
|
||||
} else {
|
||||
that.Db.Delete(tableName, Map{"key": key})
|
||||
}
|
||||
|
||||
// 兼容模式:同时删除老表中的 key(避免回退读到老数据)
|
||||
if that.isCompatibleMode() {
|
||||
that.deleteLegacy(key)
|
||||
del := strings.Index(key, "*")
|
||||
//如果通配删除
|
||||
if del != -1 {
|
||||
key = Substr(key, 0, del)
|
||||
that.Db.Delete("cached", Map{"ckey": key + "%"})
|
||||
|
||||
} else {
|
||||
that.Db.Delete("cached", Map{"ckey": key})
|
||||
}
|
||||
}
|
||||
|
||||
// Cache 缓存操作入口
|
||||
// 用法:
|
||||
// - Cache(key) - 获取缓存
|
||||
// - Cache(key, value) - 设置缓存(使用默认过期时间)
|
||||
// - Cache(key, value, timeout) - 设置缓存(指定过期时间,单位:秒)
|
||||
// - Cache(key, nil) - 删除缓存
|
||||
func (that *CacheDb) Cache(key string, data ...interface{}) *Obj {
|
||||
|
||||
that.initDbTable()
|
||||
|
||||
// 获取缓存
|
||||
if len(data) == 0 {
|
||||
return &Obj{Data: that.get(key)}
|
||||
}
|
||||
tim := time.Now().Unix()
|
||||
|
||||
// 删除缓存
|
||||
if len(data) == 1 && data[0] == nil {
|
||||
that.delete(key)
|
||||
return &Obj{Data: nil}
|
||||
}
|
||||
|
||||
// 计算过期时间
|
||||
var timeout int64
|
||||
if len(data) == 1 {
|
||||
// 使用配置的 TimeOut,如果为 0 则使用默认值
|
||||
timeout = that.TimeOut
|
||||
if timeout == 0 {
|
||||
timeout = DefaultCacheTimeout
|
||||
if that.TimeOut == 0 {
|
||||
//that.Time = Config.GetInt64("cacheLongTime")
|
||||
}
|
||||
} else if len(data) >= 2 {
|
||||
// 使用指定的超时时间
|
||||
var err Error
|
||||
tempTimeout := ObjToInt64(data[1], &err)
|
||||
if err.GetError() == nil && tempTimeout > 0 {
|
||||
timeout = tempTimeout
|
||||
} else {
|
||||
timeout = that.TimeOut
|
||||
if timeout == 0 {
|
||||
timeout = DefaultCacheTimeout
|
||||
}
|
||||
tim += that.TimeOut
|
||||
}
|
||||
if len(data) == 2 {
|
||||
that.SetError(nil)
|
||||
tempt := ObjToInt64(data[1], that.Error)
|
||||
|
||||
if tempt > tim {
|
||||
tim = tempt
|
||||
} else if that.GetError() == nil {
|
||||
|
||||
tim = tim + tempt
|
||||
}
|
||||
}
|
||||
|
||||
endTime := time.Now().Add(time.Duration(timeout) * time.Second)
|
||||
that.set(key, data[0], endTime)
|
||||
that.set(key, data[0], tim)
|
||||
return &Obj{Data: nil}
|
||||
}
|
||||
|
||||
// CachesGet 批量获取缓存(使用 IN 查询优化)
|
||||
// 返回 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
+125
-110
@@ -1,143 +1,158 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
"code.hoteas.com/golang/hotime/log"
|
||||
. "../common"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CacheMemory 基于 sync.Map 的缓存实现
|
||||
type CacheMemory struct {
|
||||
TimeOut int64
|
||||
DbSet bool
|
||||
SessionSet bool
|
||||
Log *log.Logger
|
||||
cache sync.Map
|
||||
Map
|
||||
*Error
|
||||
ContextBase
|
||||
mutex *sync.RWMutex
|
||||
}
|
||||
func (c *CacheMemory) get(key string) (res *Obj) {
|
||||
|
||||
res = &Obj{}
|
||||
value, ok := c.cache.Load(key)
|
||||
if !ok {
|
||||
return res // 缓存不存在
|
||||
}
|
||||
func (this *CacheMemory) GetError() *Error {
|
||||
|
||||
data := value.(cacheData)
|
||||
return this.Error
|
||||
|
||||
// 检查是否过期
|
||||
if data.time < time.Now().Unix() {
|
||||
c.cache.Delete(key) // 删除过期缓存
|
||||
return res
|
||||
}
|
||||
res.Data = data.data
|
||||
return res
|
||||
}
|
||||
func (c *CacheMemory) set(key string, value interface{}, expireAt int64) {
|
||||
data := cacheData{
|
||||
data: value,
|
||||
time: expireAt,
|
||||
}
|
||||
c.cache.Store(key, data)
|
||||
}
|
||||
func (c *CacheMemory) delete(key string) {
|
||||
if strings.Contains(key, "*") {
|
||||
// 通配符删除
|
||||
prefix := strings.TrimSuffix(key, "*")
|
||||
c.cache.Range(func(k, v interface{}) bool {
|
||||
if strings.HasPrefix(k.(string), prefix) {
|
||||
c.cache.Delete(k)
|
||||
}
|
||||
return true
|
||||
})
|
||||
} else {
|
||||
// 精确删除
|
||||
c.cache.Delete(key)
|
||||
}
|
||||
}
|
||||
func (c *CacheMemory) refreshMap() {
|
||||
go func() {
|
||||
now := time.Now().Unix()
|
||||
c.cache.Range(func(key, value interface{}) bool {
|
||||
data := value.(cacheData)
|
||||
if data.time <= now {
|
||||
c.cache.Delete(key) // 删除过期缓存
|
||||
}
|
||||
return true
|
||||
})
|
||||
}()
|
||||
}
|
||||
func (c *CacheMemory) Cache(key string, data ...interface{}) *Obj {
|
||||
now := time.Now().Unix()
|
||||
|
||||
// 随机触发刷新
|
||||
if x := RandX(1, 100000); x > 99950 {
|
||||
c.refreshMap()
|
||||
func (this *CacheMemory) SetError(err *Error) {
|
||||
this.Error = err
|
||||
}
|
||||
|
||||
//获取Cache键只能为string类型
|
||||
func (this *CacheMemory) get(key string) interface{} {
|
||||
this.Error.SetError(nil)
|
||||
if this.Map == nil {
|
||||
this.Map = Map{}
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
// 读操作
|
||||
return c.get(key)
|
||||
if this.Map[key] == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(data) == 1 && data[0] == nil {
|
||||
// 删除操作
|
||||
c.delete(key)
|
||||
data := this.Map.Get(key, this.Error).(cacheData)
|
||||
if this.Error.GetError() != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 写操作
|
||||
expireAt := now + c.TimeOut
|
||||
if len(data) == 2 {
|
||||
if customExpire, ok := data[1].(int64); ok {
|
||||
if customExpire > now {
|
||||
expireAt = customExpire
|
||||
} else {
|
||||
expireAt = now + customExpire
|
||||
if data.time < time.Now().Unix() {
|
||||
delete(this.Map, key)
|
||||
return nil
|
||||
}
|
||||
return data.data
|
||||
}
|
||||
|
||||
func (this *CacheMemory) refreshMap() {
|
||||
|
||||
go func() {
|
||||
this.mutex.Lock()
|
||||
defer this.mutex.Unlock()
|
||||
for key, v := range this.Map {
|
||||
data := v.(cacheData)
|
||||
if data.time <= time.Now().Unix() {
|
||||
delete(this.Map, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
//key value ,时间为时间戳
|
||||
func (this *CacheMemory) set(key string, value interface{}, time int64) {
|
||||
this.Error.SetError(nil)
|
||||
var data cacheData
|
||||
|
||||
if this.Map == nil {
|
||||
this.Map = Map{}
|
||||
}
|
||||
|
||||
dd := this.Map[key]
|
||||
|
||||
if dd == nil {
|
||||
data = cacheData{}
|
||||
} else {
|
||||
data = dd.(cacheData)
|
||||
}
|
||||
|
||||
data.time = time
|
||||
data.data = value
|
||||
|
||||
this.Map.Put(key, data)
|
||||
}
|
||||
|
||||
func (this *CacheMemory) delete(key string) {
|
||||
del := strings.Index(key, "*")
|
||||
//如果通配删除
|
||||
if del != -1 {
|
||||
key = Substr(key, 0, del)
|
||||
for k, _ := range this.Map {
|
||||
if strings.Index(k, key) != -1 {
|
||||
delete(this.Map, k)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
delete(this.Map, key)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (this *CacheMemory) Cache(key string, data ...interface{}) *Obj {
|
||||
|
||||
x := RandX(1, 100000)
|
||||
if x > 99950 {
|
||||
this.refreshMap()
|
||||
}
|
||||
if this.mutex == nil {
|
||||
this.mutex = &sync.RWMutex{}
|
||||
}
|
||||
|
||||
reData := &Obj{Data: nil}
|
||||
|
||||
if len(data) == 0 {
|
||||
this.mutex.RLock()
|
||||
reData.Data = this.get(key)
|
||||
this.mutex.RUnlock()
|
||||
return reData
|
||||
}
|
||||
tim := time.Now().Unix()
|
||||
|
||||
if len(data) == 1 && data[0] == nil {
|
||||
this.mutex.Lock()
|
||||
this.delete(key)
|
||||
this.mutex.Unlock()
|
||||
return reData
|
||||
}
|
||||
|
||||
if len(data) == 1 {
|
||||
|
||||
tim = tim + this.TimeOut
|
||||
|
||||
}
|
||||
if len(data) == 2 {
|
||||
this.Error.SetError(nil)
|
||||
tempt := ObjToInt64(data[1], this.Error)
|
||||
|
||||
if tempt > tim {
|
||||
|
||||
tim = tempt
|
||||
} else if this.Error.GetError() == nil {
|
||||
|
||||
tim = tim + tempt
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
this.mutex.Lock()
|
||||
this.set(key, data[0], tim)
|
||||
this.mutex.Unlock()
|
||||
return reData
|
||||
|
||||
// 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
+94
-206
@@ -1,11 +1,9 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
"code.hoteas.com/golang/hotime/log"
|
||||
. "../common"
|
||||
"github.com/garyburd/redigo/redis"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -16,267 +14,157 @@ type CacheRedis struct {
|
||||
Host string
|
||||
Pwd string
|
||||
Port int64
|
||||
pool *redis.Pool
|
||||
conn redis.Conn
|
||||
tag int64
|
||||
ContextBase
|
||||
Log *log.Logger
|
||||
initOnce sync.Once
|
||||
*Error
|
||||
}
|
||||
|
||||
func (that *CacheRedis) logErr(err error) {
|
||||
if err != nil && that.Log != nil {
|
||||
that.Log.Error().Err(err).Msg("redis cache error")
|
||||
func (this *CacheRedis) GetError() *Error {
|
||||
|
||||
return this.Error
|
||||
|
||||
}
|
||||
|
||||
func (this *CacheRedis) SetError(err *Error) {
|
||||
this.Error = err
|
||||
}
|
||||
|
||||
//唯一标志
|
||||
func (this *CacheRedis) GetTag() int64 {
|
||||
|
||||
if this.tag == int64(0) {
|
||||
this.tag = time.Now().UnixNano()
|
||||
}
|
||||
return this.tag
|
||||
}
|
||||
|
||||
// 唯一标志
|
||||
func (that *CacheRedis) GetTag() int64 {
|
||||
|
||||
if that.tag == int64(0) {
|
||||
that.tag = time.Now().UnixNano()
|
||||
func (this *CacheRedis) reCon() bool {
|
||||
var err error
|
||||
this.conn, err = redis.Dial("tcp", this.Host+":"+ObjToStr(this.Port))
|
||||
if err != nil {
|
||||
this.conn = nil
|
||||
this.Error.SetError(err)
|
||||
return false
|
||||
}
|
||||
return that.tag
|
||||
}
|
||||
|
||||
// initPool 初始化连接池(只执行一次)
|
||||
func (that *CacheRedis) initPool() {
|
||||
that.initOnce.Do(func() {
|
||||
that.pool = &redis.Pool{
|
||||
MaxIdle: 10, // 最大空闲连接数
|
||||
MaxActive: 100, // 最大活跃连接数,0表示无限制
|
||||
IdleTimeout: 5 * time.Minute, // 空闲连接超时时间
|
||||
Wait: true, // 当连接池耗尽时是否等待
|
||||
Dial: func() (redis.Conn, error) {
|
||||
conn, err := redis.Dial("tcp", that.Host+":"+ObjToStr(that.Port),
|
||||
redis.DialConnectTimeout(5*time.Second),
|
||||
redis.DialReadTimeout(3*time.Second),
|
||||
redis.DialWriteTimeout(3*time.Second),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if that.Pwd != "" {
|
||||
if _, err := conn.Do("AUTH", that.Pwd); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return conn, nil
|
||||
},
|
||||
TestOnBorrow: func(c redis.Conn, t time.Time) error {
|
||||
if time.Since(t) < time.Minute {
|
||||
return nil
|
||||
}
|
||||
_, err := c.Do("PING")
|
||||
return err
|
||||
},
|
||||
if this.Pwd != "" {
|
||||
_, err = this.conn.Do("AUTH", this.Pwd)
|
||||
if err != nil {
|
||||
this.conn = nil
|
||||
this.Error.SetError(err)
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// getConn 从连接池获取连接
|
||||
func (that *CacheRedis) getConn() redis.Conn {
|
||||
that.initPool()
|
||||
if that.pool == nil {
|
||||
return nil
|
||||
}
|
||||
return that.pool.Get()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (that *CacheRedis) del(key string) {
|
||||
conn := that.getConn()
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
func (this *CacheRedis) del(key string) {
|
||||
del := strings.Index(key, "*")
|
||||
if del != -1 {
|
||||
val, err := redis.Strings(conn.Do("KEYS", key))
|
||||
val, err := redis.Strings(this.conn.Do("KEYS", key))
|
||||
if err != nil {
|
||||
that.logErr(err)
|
||||
return
|
||||
}
|
||||
if len(val) == 0 {
|
||||
return
|
||||
}
|
||||
conn.Send("MULTI")
|
||||
for i := range val {
|
||||
conn.Send("DEL", val[i])
|
||||
}
|
||||
_, err = conn.Do("EXEC")
|
||||
if err != nil {
|
||||
that.logErr(err)
|
||||
this.conn.Send("MULTI")
|
||||
for i, _ := range val {
|
||||
this.conn.Send("DEL", val[i])
|
||||
}
|
||||
this.conn.Do("EXEC")
|
||||
} else {
|
||||
_, err := conn.Do("DEL", key)
|
||||
_, err := this.conn.Do("DEL", key)
|
||||
if err != nil {
|
||||
that.logErr(err)
|
||||
this.Error.SetError(err)
|
||||
_, err = this.conn.Do("PING")
|
||||
if err != nil {
|
||||
if this.reCon() {
|
||||
_, err = this.conn.Do("DEL", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// key value ,时间为时间戳
|
||||
func (that *CacheRedis) set(key string, value string, expireSeconds int64) {
|
||||
conn := that.getConn()
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
_, err := conn.Do("SET", key, value, "EX", ObjToStr(expireSeconds))
|
||||
//key value ,时间为时间戳
|
||||
func (this *CacheRedis) set(key string, value string, time int64) {
|
||||
_, err := this.conn.Do("SET", key, value, "EX", ObjToStr(time))
|
||||
if err != nil {
|
||||
that.logErr(err)
|
||||
|
||||
this.Error.SetError(err)
|
||||
_, err = this.conn.Do("PING")
|
||||
if err != nil {
|
||||
if this.reCon() {
|
||||
_, err = this.conn.Do("SET", key, value, "EX", ObjToStr(time))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (that *CacheRedis) get(key string) *Obj {
|
||||
func (this *CacheRedis) get(key string) *Obj {
|
||||
reData := &Obj{}
|
||||
conn := that.getConn()
|
||||
if conn == nil {
|
||||
return reData
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
var err error
|
||||
reData.Data, err = redis.String(conn.Do("GET", key))
|
||||
reData.Data, err = redis.String(this.conn.Do("GET", key))
|
||||
if err != nil {
|
||||
reData.Data = nil
|
||||
if !strings.Contains(err.Error(), "nil returned") {
|
||||
that.logErr(err)
|
||||
this.Error.SetError(err)
|
||||
_, err = this.conn.Do("PING")
|
||||
if err != nil {
|
||||
if this.reCon() {
|
||||
reData.Data, err = redis.String(this.conn.Do("GET", key))
|
||||
}
|
||||
}
|
||||
|
||||
return reData
|
||||
}
|
||||
}
|
||||
return reData
|
||||
}
|
||||
|
||||
func (that *CacheRedis) Cache(key string, data ...interface{}) *Obj {
|
||||
func (this *CacheRedis) Cache(key string, data ...interface{}) *Obj {
|
||||
reData := &Obj{}
|
||||
|
||||
if this.conn == nil {
|
||||
re := this.reCon()
|
||||
if !re {
|
||||
return reData
|
||||
}
|
||||
}
|
||||
//查询缓存
|
||||
if len(data) == 0 {
|
||||
reData = that.get(key)
|
||||
return reData
|
||||
}
|
||||
|
||||
reData = this.get(key)
|
||||
return reData
|
||||
|
||||
}
|
||||
tim := int64(0)
|
||||
//删除缓存
|
||||
if len(data) == 1 && data[0] == nil {
|
||||
that.del(key)
|
||||
this.del(key)
|
||||
return reData
|
||||
}
|
||||
//添加缓存
|
||||
if len(data) == 1 {
|
||||
if that.TimeOut == 0 {
|
||||
//that.Time = Config.GetInt64("cacheShortTime")
|
||||
|
||||
if this.TimeOut == 0 {
|
||||
//this.Time = Config.GetInt64("cacheShortTime")
|
||||
}
|
||||
tim += that.TimeOut
|
||||
|
||||
tim += this.TimeOut
|
||||
}
|
||||
if len(data) == 2 {
|
||||
tempt := ObjToInt64(data[1])
|
||||
this.Error.SetError(nil)
|
||||
tempt := ObjToInt64(data[1], this.Error)
|
||||
if tempt > tim {
|
||||
|
||||
tim = tempt
|
||||
} else {
|
||||
} else if this.GetError() == nil {
|
||||
|
||||
tim = tim + tempt
|
||||
}
|
||||
}
|
||||
|
||||
that.set(key, ObjToStr(data[0]), tim)
|
||||
this.set(key, ObjToStr(data[0]), tim)
|
||||
|
||||
return reData
|
||||
}
|
||||
|
||||
// CachesGet 批量获取缓存(使用 Redis MGET 命令优化)
|
||||
// 返回 Map,key 为缓存键,value 为缓存值(不存在的 key 不包含在结果中)
|
||||
func (that *CacheRedis) CachesGet(keys []string) Map {
|
||||
result := make(Map, len(keys))
|
||||
if len(keys) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
conn := that.getConn()
|
||||
if conn == nil {
|
||||
return result
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// 构建 MGET 参数
|
||||
args := make([]interface{}, len(keys))
|
||||
for i, key := range keys {
|
||||
args[i] = key
|
||||
}
|
||||
|
||||
values, err := redis.Strings(conn.Do("MGET", args...))
|
||||
if err != nil {
|
||||
if !strings.Contains(err.Error(), "nil returned") {
|
||||
that.logErr(err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// 将结果映射回 Map
|
||||
for i, value := range values {
|
||||
if value != "" {
|
||||
result[keys[i]] = value
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// CachesSet 批量设置缓存(使用 Redis pipeline 优化)
|
||||
// data: Map,key 为缓存键,value 为缓存值
|
||||
// timeout: 可选过期时间(秒),不传则使用默认超时时间
|
||||
func (that *CacheRedis) CachesSet(data Map, timeout ...int64) {
|
||||
if len(data) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
conn := that.getConn()
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
tim := that.TimeOut
|
||||
if len(timeout) > 0 && timeout[0] > 0 {
|
||||
if timeout[0] > tim {
|
||||
tim = timeout[0]
|
||||
} else {
|
||||
tim = tim + timeout[0]
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 pipeline 批量设置
|
||||
conn.Send("MULTI")
|
||||
for key, value := range data {
|
||||
conn.Send("SET", key, ObjToStr(value), "EX", ObjToStr(tim))
|
||||
}
|
||||
_, err := conn.Do("EXEC")
|
||||
if err != nil {
|
||||
that.logErr(err)
|
||||
}
|
||||
}
|
||||
|
||||
// CachesDelete 批量删除缓存(使用 Redis DEL 命令批量删除)
|
||||
func (that *CacheRedis) CachesDelete(keys []string) {
|
||||
if len(keys) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
conn := that.getConn()
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// 构建 DEL 参数
|
||||
args := make([]interface{}, len(keys))
|
||||
for i, key := range keys {
|
||||
args[i] = key
|
||||
}
|
||||
|
||||
_, err := conn.Do("DEL", args...)
|
||||
if err != nil {
|
||||
that.logErr(err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Vendored
+7
-5
@@ -1,17 +1,19 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
. "../common"
|
||||
)
|
||||
|
||||
type CacheIns interface {
|
||||
//set(key string, value interface{}, time int64)
|
||||
//get(key string) interface{}
|
||||
//delete(key string)
|
||||
GetError() *Error
|
||||
SetError(err *Error)
|
||||
Cache(key string, data ...interface{}) *Obj
|
||||
CachesGet(keys []string) Map
|
||||
CachesSet(data Map, timeout ...int64)
|
||||
CachesDelete(keys []string)
|
||||
}
|
||||
|
||||
// 单条缓存数据
|
||||
//单条缓存数据
|
||||
type cacheData struct {
|
||||
time int64
|
||||
data interface{}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
package hotime
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveClientIP_EOPreferred(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("EO-Connecting-IP", "171.1.2.3")
|
||||
req.Header.Set("X-Real-IP", "43.1.2.3")
|
||||
req.Header.Set("X-Forwarded-For", "171.1.2.3, 43.1.2.3")
|
||||
req.RemoteAddr = "127.0.0.1:12345"
|
||||
|
||||
ip, chain := resolveClientIP(req)
|
||||
if ip != "171.1.2.3" {
|
||||
t.Fatalf("ip=%q want 171.1.2.3", ip)
|
||||
}
|
||||
// 去重:171 只出现一次
|
||||
if chain != "171.1.2.3,43.1.2.3,127.0.0.1" {
|
||||
t.Fatalf("chain=%q", chain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveClientIP_SkipLoopbackXReal(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("X-Real-IP", "127.0.0.1")
|
||||
req.Header.Set("X-Forwarded-For", "171.1.2.3, 43.1.2.3")
|
||||
req.RemoteAddr = "127.0.0.1:8080"
|
||||
|
||||
ip, chain := resolveClientIP(req)
|
||||
if ip != "171.1.2.3" {
|
||||
t.Fatalf("ip=%q want 171.1.2.3", ip)
|
||||
}
|
||||
if chain != "127.0.0.1,171.1.2.3,43.1.2.3" {
|
||||
t.Fatalf("chain=%q", chain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveClientIP_XFFBeforeMisconfiguredXReal(t *testing.T) {
|
||||
// 线上常见:X-Real-IP=CDN,XFF=客户端,CDN
|
||||
req, _ := http.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("X-Real-IP", "43.1.2.3")
|
||||
req.Header.Set("X-Forwarded-For", "171.1.2.3, 43.1.2.3")
|
||||
req.RemoteAddr = "127.0.0.1:8080"
|
||||
|
||||
ip, chain := resolveClientIP(req)
|
||||
if ip != "171.1.2.3" {
|
||||
t.Fatalf("ip=%q want 171.1.2.3", ip)
|
||||
}
|
||||
if chain != "43.1.2.3,171.1.2.3,127.0.0.1" {
|
||||
t.Fatalf("chain=%q", chain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveClientIP_XRealWhenNoXFF(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("X-Real-IP", "203.0.113.9")
|
||||
req.RemoteAddr = "10.0.0.1:80"
|
||||
|
||||
ip, chain := resolveClientIP(req)
|
||||
if ip != "203.0.113.9" {
|
||||
t.Fatalf("ip=%q want 203.0.113.9", ip)
|
||||
}
|
||||
if chain != "203.0.113.9,10.0.0.1" {
|
||||
t.Fatalf("chain=%q", chain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveClientIP_DirectRemoteAddr(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", "/", nil)
|
||||
req.RemoteAddr = "192.168.1.8:5555"
|
||||
|
||||
ip, chain := resolveClientIP(req)
|
||||
if ip != "192.168.1.8" {
|
||||
t.Fatalf("ip=%q want 192.168.1.8", ip)
|
||||
}
|
||||
if chain != "192.168.1.8" {
|
||||
t.Fatalf("chain=%q", chain)
|
||||
}
|
||||
if clientIP(req) != ip {
|
||||
t.Fatalf("clientIP mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveClientIP_NilReq(t *testing.T) {
|
||||
ip, chain := resolveClientIP(nil)
|
||||
if ip != "" || chain != "" {
|
||||
t.Fatalf("want empty, got %q %q", ip, chain)
|
||||
}
|
||||
}
|
||||
+52
-151
@@ -1,24 +1,16 @@
|
||||
package code
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
. "../common"
|
||||
)
|
||||
|
||||
var Config = Map{
|
||||
"name": "HoTimeDashBoard",
|
||||
//"id": "2f92h3herh23rh2y8",
|
||||
"name": "HoTimeDashBoard",
|
||||
"id": "2f92h3herh23rh2y8",
|
||||
"label": "HoTime管理平台",
|
||||
"stop": Slice{"role", "org"}, //不更新的,同时不允许修改用户自身对应的表数据
|
||||
"labelConfig": Map{
|
||||
"show": "开启",
|
||||
"add": "添加",
|
||||
"delete": "删除",
|
||||
"edit": "编辑",
|
||||
"info": "查看详情",
|
||||
"download": "下载清单",
|
||||
},
|
||||
|
||||
"menus": []Map{
|
||||
//{"label": "平台首页", "name": "HelloWorld", "icon": "el-icon-s-home"},
|
||||
{"label": "平台首页", "name": "HelloWorld", "icon": "el-icon-s-home"},
|
||||
//{"label": "测试表格", "table": "table", "icon": "el-icon-suitcase"},
|
||||
//{"label": "系统管理", "name": "setting", "icon": "el-icon-setting",
|
||||
// "menus": []Map{
|
||||
@@ -42,9 +34,8 @@ var DefaultMenuParentName = "sys"
|
||||
|
||||
var ColumnDataType = map[string]string{
|
||||
//sqlite专有类型
|
||||
"real": "number",
|
||||
"integer": "number",
|
||||
//mysql数据类型
|
||||
"real": "number",
|
||||
//mysql数据类型宽泛类型
|
||||
"int": "number",
|
||||
"float": "number",
|
||||
"double": "number",
|
||||
@@ -55,148 +46,58 @@ var ColumnDataType = map[string]string{
|
||||
"date": "time",
|
||||
"time": "time",
|
||||
"year": "time",
|
||||
"bit": "number",
|
||||
"binary": "text",
|
||||
"geometry": "gis",
|
||||
//postgresql数据类型
|
||||
"numeric": "number",
|
||||
"boolean": "number",
|
||||
"serial": "number",
|
||||
"json": "text",
|
||||
"uuid": "text",
|
||||
"bytea": "text",
|
||||
"interval": "text",
|
||||
"money": "number",
|
||||
//达梦(DM)/Oracle数据类型
|
||||
"clob": "text",
|
||||
"number": "number",
|
||||
"geometry": "gis", //不建议使用gis类型,建议使用其他代替
|
||||
}
|
||||
|
||||
type ColumnShow struct {
|
||||
Name string //名称
|
||||
|
||||
List bool //列表权限
|
||||
Edit bool //新增和编辑权限
|
||||
Info bool //详情权限
|
||||
Must bool //字段全匹配
|
||||
Name string
|
||||
List bool
|
||||
Edit bool
|
||||
Info bool
|
||||
Must bool
|
||||
Type string //空字符串表示
|
||||
Strict bool //name严格匹配必须是这个词才行
|
||||
}
|
||||
|
||||
var RuleConfig = []Map{
|
||||
{"name": "idcard", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
|
||||
{"name": "id", "add": false, "list": true, "edit": false, "info": true, "must": false, "strict": true, "type": ""},
|
||||
{"name": "sn", "add": false, "list": true, "edit": false, "info": true, "must": false, "strict": false, "type": ""},
|
||||
{"name": "parent_ids", "add": false, "list": false, "edit": false, "info": false, "must": false, "strict": true, "type": "index"},
|
||||
{"name": "index", "add": false, "list": false, "edit": false, "info": false, "must": false, "strict": true, "type": "index"},
|
||||
var ColumnNameType = []ColumnShow{
|
||||
//通用
|
||||
{"idcard", false, true, true, false, "", false},
|
||||
{"id", true, false, true, false, "", true},
|
||||
{"parent_id", true, true, true, false, "", true},
|
||||
//"sn"{true,true,true,""},
|
||||
{"status", true, true, true, false, "select", false},
|
||||
{"state", true, true, true, false, "select", false},
|
||||
{"sex", true, true, true, false, "select", false},
|
||||
{"delete", false, false, false, false, "", false},
|
||||
|
||||
{"name": "parent_id", "add": true, "list": true, "edit": true, "info": true, "must": false, "true": false, "type": ""},
|
||||
{"lat", false, true, true, false, "", false},
|
||||
{"lng", false, true, true, false, "", false},
|
||||
{"latitude", false, true, true, false, "", false},
|
||||
{"longitude", false, true, true, false, "", false},
|
||||
|
||||
{"name": "amount", "add": true, "list": true, "edit": true, "info": true, "must": false, "strict": true, "type": "money"},
|
||||
|
||||
{"name": "info", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": "textArea"},
|
||||
|
||||
{"name": "status", "add": true, "list": true, "edit": true, "info": true, "must": false, "strict": false, "type": "select"},
|
||||
{"name": "state", "add": true, "list": true, "edit": true, "info": true, "must": false, "strict": false, "type": "select"},
|
||||
{"name": "sex", "add": true, "list": true, "edit": true, "info": true, "must": false, "strict": false, "type": "select"},
|
||||
|
||||
{"name": "delete", "add": false, "list": false, "edit": false, "info": false, "must": false, "strict": false, "type": ""},
|
||||
|
||||
{"name": "lat", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
|
||||
{"name": "lng", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
|
||||
|
||||
{"name": "latitude", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
|
||||
{"name": "longitude", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
|
||||
|
||||
{"name": "password", "add": true, "list": false, "edit": true, "info": false, "must": false, "strict": false, "type": "password"},
|
||||
{"name": "pwd", "add": true, "list": false, "edit": true, "info": false, "must": false, "strict": false, "type": "password"},
|
||||
|
||||
{"name": "version", "add": false, "list": false, "edit": false, "info": false, "must": false, "strict": false, "type": ""},
|
||||
{"name": "seq", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
|
||||
|
||||
{"name": "sort", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
|
||||
{"name": "note", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
|
||||
|
||||
{"name": "description", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
|
||||
|
||||
{"name": "abstract", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
|
||||
|
||||
{"name": "content", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": "textArea"},
|
||||
|
||||
{"name": "address", "add": true, "list": true, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
|
||||
{"name": "full_name", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
|
||||
|
||||
{"name": "create_time", "add": false, "list": false, "edit": false, "info": true, "must": false, "strict": true, "type": "time"},
|
||||
{"name": "modify_time", "add": false, "list": true, "edit": false, "info": true, "must": false, "strict": true, "type": "time"},
|
||||
|
||||
{"name": "image", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": "image"},
|
||||
|
||||
{"name": "img", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": "image"},
|
||||
{"name": "avatar", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": "image"},
|
||||
{"name": "icon", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": "image"},
|
||||
|
||||
{"name": "file", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": "file"},
|
||||
|
||||
{"name": "age", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
|
||||
{"name": "email", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": ""},
|
||||
{"name": "time", "add": true, "list": true, "edit": true, "info": true, "must": false, "strict": false, "type": "time"},
|
||||
|
||||
{"name": "level", "add": false, "list": false, "edit": false, "info": true, "must": false, "strict": false, "type": ""},
|
||||
{"name": "rule", "add": true, "list": true, "edit": true, "info": true, "must": false, "strict": false, "type": "form"},
|
||||
|
||||
{"name": "auth", "add": true, "list": false, "edit": true, "info": true, "must": false, "strict": false, "type": "auth"},
|
||||
|
||||
{"name": "table", "add": false, "list": true, "edit": false, "info": true, "must": false, "strict": false, "type": "table"},
|
||||
{"name": "table_id", "add": false, "list": true, "edit": false, "info": true, "must": false, "strict": false, "type": "table_id"},
|
||||
{"index", false, false, false, false, "index", false},
|
||||
{"password", false, true, false, false, "password", false},
|
||||
{"pwd", false, true, false, false, "password", false},
|
||||
{"info", false, true, true, false, "", false},
|
||||
{"version", false, false, false, false, "", false},
|
||||
{"seq", false, true, true, false, "", false},
|
||||
{"sort", false, true, true, false, "", false},
|
||||
{"note", false, true, true, false, "", false},
|
||||
{"description", false, true, true, false, "", false},
|
||||
{"abstract", false, true, true, false, "", false},
|
||||
{"content", false, true, true, false, "", false},
|
||||
{"address", false, true, true, false, "", false},
|
||||
{"full_name", false, true, true, false, "", false},
|
||||
{"create_time", false, false, true, false, "time", true},
|
||||
{"modify_time", true, false, true, false, "time", true},
|
||||
{"image", false, true, true, false, "image", false},
|
||||
{"img", false, true, true, false, "image", false},
|
||||
{"icon", false, true, true, false, "image", false},
|
||||
{"avatar", false, true, true, false, "image", false},
|
||||
{"file", false, true, true, false, "file", false},
|
||||
{"age", false, true, true, false, "", false},
|
||||
{"email", false, true, true, false, "", false},
|
||||
{"time", true, true, true, true, "time", false},
|
||||
{"level", false, false, true, false, "", false},
|
||||
{"rule", true, true, true, false, "form", false},
|
||||
}
|
||||
|
||||
//var ColumnNameType = []ColumnShow{
|
||||
// //通用
|
||||
// {"idcard", false, true, true, false, "", false},
|
||||
// {"id", true, false, true, false, "", true},
|
||||
// {"sn", true, false, true, false, "", false},
|
||||
// {"parent_ids", false, false, false, false, "index", true},
|
||||
// {"parent_id", true, true, true, false, "", true},
|
||||
// {"amount", true, true, true, false, "money", true},
|
||||
// {"info", false, true, true, false, "textArea", false},
|
||||
// //"sn"{true,true,true,""},
|
||||
// {"status", true, true, true, false, "select", false},
|
||||
// {"state", true, true, true, false, "select", false},
|
||||
// {"sex", true, true, true, false, "select", false},
|
||||
// {"delete", false, false, false, false, "", false},
|
||||
//
|
||||
// {"lat", false, true, true, false, "", false},
|
||||
// {"lng", false, true, true, false, "", false},
|
||||
// {"latitude", false, true, true, false, "", false},
|
||||
// {"longitude", false, true, true, false, "", false},
|
||||
//
|
||||
// {"index", false, false, false, false, "index", false},
|
||||
//
|
||||
// {"password", false, true, false, false, "password", false},
|
||||
// {"pwd", false, true, false, false, "password", false},
|
||||
//
|
||||
// {"version", false, false, false, false, "", false},
|
||||
// {"seq", false, true, true, false, "", false},
|
||||
// {"sort", false, true, true, false, "", false},
|
||||
// {"note", false, true, true, false, "", false},
|
||||
// {"description", false, true, true, false, "", false},
|
||||
// {"abstract", false, true, true, false, "", false},
|
||||
// {"content", false, true, true, false, "textArea", false},
|
||||
// {"address", true, true, true, false, "", false},
|
||||
// {"full_name", false, true, true, false, "", false},
|
||||
// {"create_time", false, false, true, false, "time", true},
|
||||
// {"modify_time", true, false, true, false, "time", true},
|
||||
// {"image", false, true, true, false, "image", false},
|
||||
// {"img", false, true, true, false, "image", false},
|
||||
// {"icon", false, true, true, false, "image", false},
|
||||
// {"avatar", false, true, true, false, "image", false},
|
||||
// {"file", false, true, true, false, "file", false},
|
||||
// {"age", false, true, true, false, "", false},
|
||||
// {"email", false, true, true, false, "", false},
|
||||
// {"time", true, true, true, false, "time", false},
|
||||
// {"level", false, false, true, false, "", false},
|
||||
// {"rule", true, true, true, false, "form", false},
|
||||
// {"auth", false, true, true, false, "auth", true},
|
||||
// {"table", true, false, true, false, "table", false},
|
||||
// {"table_id", true, false, true, false, "table_id", false},
|
||||
//}
|
||||
|
||||
+374
-873
File diff suppressed because it is too large
Load Diff
+27
-33
@@ -3,8 +3,8 @@ package code
|
||||
var InitTpt = `package {{name}}
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
. "../../../hotime"
|
||||
. "../../../hotime/common"
|
||||
)
|
||||
|
||||
var ID = "{{id}}"
|
||||
@@ -14,32 +14,30 @@ var Project = Proj{
|
||||
//"user": UserCtr,
|
||||
{{tablesCtr}}
|
||||
"hotime":Ctr{
|
||||
"login": func(that *Context) {
|
||||
|
||||
name := that.Req.FormValue("name")
|
||||
password := that.Req.FormValue("password")
|
||||
"login": func(this *Context) {
|
||||
name := this.Req.FormValue("name")
|
||||
password := this.Req.FormValue("password")
|
||||
if name == "" || password == "" {
|
||||
that.Display(3, "参数不足")
|
||||
this.Display(3, "参数不足")
|
||||
return
|
||||
}
|
||||
user := that.Db.Get("admin", "*", Map{"AND": Map{"OR":Map{"name": name,"phone":name}, "password": Md5(password)}})
|
||||
user := this.Db.Get("admin", "*", Map{"AND": Map{"OR":Map{"name": name,"phone":name}, "password": Md5(password)}})
|
||||
if user == nil {
|
||||
that.Display(5, "登录失败")
|
||||
this.Display(5, "登录失败")
|
||||
return
|
||||
}
|
||||
that.Session("admin_id", user.GetCeilInt("id"))
|
||||
that.Session("admin_name", name)
|
||||
that.Display(0, that.SessionId)
|
||||
this.Session("admin_id", user.GetCeilInt("id"))
|
||||
this.Session("admin_name", name)
|
||||
this.Display(0, this.SessionId)
|
||||
},
|
||||
"logout": func(that *Context) {
|
||||
that.Session("admin_id", nil)
|
||||
that.Session("admin_name", nil)
|
||||
that.Display(0, "退出登录成功")
|
||||
"logout": func(this *Context) {
|
||||
this.Session("admin_id", nil)
|
||||
this.Session("admin_name", nil)
|
||||
this.Display(0, "退出登录成功")
|
||||
},
|
||||
"info": func(that *Context) {
|
||||
hotimeName := that.RouterString[0]
|
||||
data := that.Db.Get("admin", "*", Map{"id": that.Session("admin_id").ToCeilInt()})
|
||||
str, inData := that.MakeCodeRouter[hotimeName].Info("admin", data, that.Db)
|
||||
str, inData := that.MakeCode.Info("admin", data, that.Db)
|
||||
where := Map{"id": that.Session("admin_id").ToCeilInt()}
|
||||
if len(inData) ==1 {
|
||||
inData["id"] =where["id"]
|
||||
@@ -54,7 +52,7 @@ var Project = Proj{
|
||||
return
|
||||
}
|
||||
for k, v := range re {
|
||||
column := that.MakeCodeRouter[hotimeName].TableColumns["admin"][k]
|
||||
column := that.MakeCode.TableColumns["admin"][k]
|
||||
if column == nil {
|
||||
continue
|
||||
}
|
||||
@@ -71,16 +69,15 @@ var Project = Proj{
|
||||
var CtrTpt = `package {{name}}
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
. "../../../hotime"
|
||||
. "../../../hotime/common"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var {{table}}Ctr = Ctr{
|
||||
"info": func(that *Context) {
|
||||
hotimeName := that.RouterString[0]
|
||||
data := that.Db.Get("admin", "*", Map{"id": that.Session("admin_id").ToCeilInt()})
|
||||
str, inData := that.MakeCodeRouter[hotimeName].Info(that.RouterString[1], data, that.Db)
|
||||
str, inData := that.MakeCode.Info(that.RouterString[1], data, that.Db)
|
||||
where := Map{"id": that.RouterString[2]}
|
||||
|
||||
if len(inData) ==1 {
|
||||
@@ -99,7 +96,7 @@ var {{table}}Ctr = Ctr{
|
||||
}
|
||||
|
||||
for k, v := range re {
|
||||
column := that.MakeCodeRouter[hotimeName].TableColumns[that.RouterString[1]][k]
|
||||
column := that.MakeCode.TableColumns[that.RouterString[1]][k]
|
||||
if column == nil {
|
||||
continue
|
||||
}
|
||||
@@ -111,8 +108,7 @@ var {{table}}Ctr = Ctr{
|
||||
that.Display(0, re)
|
||||
},
|
||||
"add": func(that *Context) {
|
||||
hotimeName := that.RouterString[0]
|
||||
inData := that.MakeCodeRouter[hotimeName].Add(that.RouterString[1], that.Req)
|
||||
inData := that.MakeCode.Add(that.RouterString[1], that.Req)
|
||||
if inData == nil {
|
||||
that.Display(3, "请求参数不足")
|
||||
return
|
||||
@@ -138,8 +134,7 @@ var {{table}}Ctr = Ctr{
|
||||
that.Display(0, re)
|
||||
},
|
||||
"update": func(that *Context) {
|
||||
hotimeName := that.RouterString[0]
|
||||
inData := that.MakeCodeRouter[hotimeName].Edit(that.RouterString[1], that.Req)
|
||||
inData := that.MakeCode.Edit(that.RouterString[1], that.Req)
|
||||
if inData == nil {
|
||||
that.Display(3, "没有找到要更新的数据")
|
||||
return
|
||||
@@ -170,8 +165,7 @@ var {{table}}Ctr = Ctr{
|
||||
that.Display(0, re)
|
||||
},
|
||||
"remove": func(that *Context) {
|
||||
hotimeName := that.RouterString[0]
|
||||
inData := that.MakeCodeRouter[hotimeName].Delete(that.RouterString[1], that.Req)
|
||||
inData := that.MakeCode.Delete(that.RouterString[1], that.Req)
|
||||
if inData == nil {
|
||||
that.Display(3, "请求参数不足")
|
||||
return
|
||||
@@ -192,10 +186,10 @@ var {{table}}Ctr = Ctr{
|
||||
},
|
||||
|
||||
"search": func(that *Context) {
|
||||
hotimeName := that.RouterString[0]
|
||||
|
||||
data := that.Db.Get("admin", "*", Map{"id": that.Session("admin_id").ToCeilInt()})
|
||||
|
||||
columnStr, leftJoin, where := that.MakeCodeRouter[hotimeName].Search(that.RouterString[1], data, that.Req, that.Db)
|
||||
columnStr, leftJoin, where := that.MakeCode.Search(that.RouterString[1], data, that.Req, that.Db)
|
||||
|
||||
page := ObjToInt(that.Req.FormValue("page"))
|
||||
pageSize := ObjToInt(that.Req.FormValue("pageSize"))
|
||||
@@ -214,7 +208,7 @@ var {{table}}Ctr = Ctr{
|
||||
|
||||
for _, v := range reData {
|
||||
for k, _ := range v {
|
||||
column := that.MakeCodeRouter[hotimeName].TableColumns[that.RouterString[1]][k]
|
||||
column := that.MakeCode.TableColumns[that.RouterString[1]][k]
|
||||
if column == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -8,11 +8,11 @@ type ContextBase struct {
|
||||
tag string
|
||||
}
|
||||
|
||||
// 唯一标志
|
||||
func (that *ContextBase) GetTag() string {
|
||||
//唯一标志
|
||||
func (this *ContextBase) GetTag() string {
|
||||
|
||||
if that.tag == "" {
|
||||
that.tag = ObjToStr(time.Now().Unix()) + ":" + ObjToStr(Random())
|
||||
if this.tag == "" {
|
||||
this.tag = ObjToStr(time.Now().Unix()) + ":" + ObjToStr(Random())
|
||||
}
|
||||
return that.tag
|
||||
return this.tag
|
||||
}
|
||||
|
||||
+10
-42
@@ -1,59 +1,27 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"code.hoteas.com/golang/hotime/log"
|
||||
"sync"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Error 框架基础错误类型 —— 线程安全的 error 封装。
|
||||
// 通过组合(嵌入)方式在 Obj、DBError 等结构体中使用,提供统一的错误 API。
|
||||
// 也用于 ObjToXxx / Map.GetXxx 等工具函数的可选错误报告参数。
|
||||
// 设置 Logger 后,SetError 会自动将非 nil 错误写入日志。
|
||||
// Error 框架层处理错误
|
||||
type Error struct {
|
||||
Logger *logrus.Logger
|
||||
error
|
||||
mu sync.RWMutex
|
||||
Logger *log.Logger
|
||||
}
|
||||
|
||||
// Error 安全实现 error 接口 —— 内部 error 为 nil 时返回 "" 而非 panic
|
||||
func (that *Error) Error() string {
|
||||
that.mu.RLock()
|
||||
defer that.mu.RUnlock()
|
||||
if that.error == nil {
|
||||
return ""
|
||||
}
|
||||
return that.error.Error()
|
||||
}
|
||||
|
||||
// HasError 快捷判断是否存在错误
|
||||
func (that *Error) HasError() bool {
|
||||
that.mu.RLock()
|
||||
defer that.mu.RUnlock()
|
||||
return that.error != nil
|
||||
}
|
||||
|
||||
// Unwrap 支持 errors.Is / errors.As 标准错误链
|
||||
func (that *Error) Unwrap() error {
|
||||
that.mu.RLock()
|
||||
defer that.mu.RUnlock()
|
||||
return that.error
|
||||
}
|
||||
|
||||
// GetError 获取内部 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("")
|
||||
if that.Logger != nil && err != nil {
|
||||
//that.Logger=log.GetLog("",false)
|
||||
that.Logger.Warn(err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
+6
-43
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/hex"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
//安全锁
|
||||
@@ -37,43 +36,7 @@ func StrFirstToUpper(str string) string {
|
||||
return strings.ToUpper(first) + other
|
||||
}
|
||||
|
||||
// 时间转字符串,第二个参数支持1-5对应显示年月日时分秒
|
||||
func Time2Str(t time.Time, qu ...interface{}) string {
|
||||
if t.Unix() < 0 {
|
||||
return ""
|
||||
}
|
||||
tp := 5
|
||||
if len(qu) != 0 {
|
||||
tp = (qu[0]).(int)
|
||||
}
|
||||
|
||||
switch tp {
|
||||
case 1:
|
||||
return t.Format("2006-01")
|
||||
case 2:
|
||||
return t.Format("2006-01-02")
|
||||
case 3:
|
||||
return t.Format("2006-01-02 15")
|
||||
case 4:
|
||||
return t.Format("2006-01-02 15:04")
|
||||
case 5:
|
||||
return t.Format("2006-01-02 15:04:05")
|
||||
case 12:
|
||||
return t.Format("01-02")
|
||||
case 14:
|
||||
return t.Format("01-02 15:04")
|
||||
case 15:
|
||||
return t.Format("01-02 15:04:05")
|
||||
case 34:
|
||||
return t.Format("15:04")
|
||||
case 35:
|
||||
return t.Format("15:04:05")
|
||||
}
|
||||
return t.Format("2006-01-02 15:04:05")
|
||||
|
||||
}
|
||||
|
||||
// StrLd 相似度计算 ld compares two strings and returns the levenshtein distance between them.
|
||||
//相似度计算 ld compares two strings and returns the levenshtein distance between them.
|
||||
func StrLd(s, t string, ignoreCase bool) int {
|
||||
if ignoreCase {
|
||||
s = strings.ToLower(s)
|
||||
@@ -141,7 +104,7 @@ func Substr(str string, start int, length int) string {
|
||||
}
|
||||
|
||||
// IndexLastStr 获取最后出现字符串的下标
|
||||
// return 找不到返回 -1
|
||||
//return 找不到返回 -1
|
||||
func IndexLastStr(str, sep string) int {
|
||||
sepSlice := []rune(sep)
|
||||
strSlice := []rune(str)
|
||||
@@ -179,7 +142,7 @@ func Md5(req string) string {
|
||||
return hex.EncodeToString(cipherStr)
|
||||
}
|
||||
|
||||
// Rand 随机数
|
||||
//随机数
|
||||
func Rand(count int) int {
|
||||
res := Random()
|
||||
for i := 0; i < count; i++ {
|
||||
@@ -204,7 +167,7 @@ func Random() float64 {
|
||||
|
||||
}
|
||||
|
||||
// RandX 随机数范围
|
||||
//随机数范围
|
||||
func RandX(small int, max int) int {
|
||||
res := 0
|
||||
//随机对象
|
||||
@@ -256,7 +219,7 @@ func RandX(small int, max int) int {
|
||||
// GetDb()
|
||||
//}
|
||||
|
||||
// DeepCopyMap 复制返回数组
|
||||
//复制返回数组
|
||||
func DeepCopyMap(value interface{}) interface{} {
|
||||
if valueMap, ok := value.(Map); ok {
|
||||
newMap := make(Map)
|
||||
@@ -315,7 +278,7 @@ func DeepCopyMap(value interface{}) interface{} {
|
||||
// }
|
||||
//}
|
||||
|
||||
// Round 浮点数四舍五入保留小数
|
||||
//浮点数四舍五入保留小数
|
||||
func Round(f float64, n int) float64 {
|
||||
pow10_n := math.Pow10(n)
|
||||
return math.Trunc((f+0.5/pow10_n)*pow10_n) / pow10_n
|
||||
|
||||
+53
-92
@@ -1,144 +1,117 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// hotime的常用map
|
||||
//hotime的常用map
|
||||
type Map map[string]interface{}
|
||||
|
||||
// 获取string
|
||||
func (that Map) GetString(key string, err ...*Error) string {
|
||||
//获取string
|
||||
func (this Map) GetString(key string, err ...*Error) string {
|
||||
|
||||
if len(err) != 0 {
|
||||
err[0].SetError(nil)
|
||||
}
|
||||
return ObjToStr((that)[key])
|
||||
return ObjToStr((this)[key])
|
||||
|
||||
}
|
||||
|
||||
func (that *Map) Pointer() *Map {
|
||||
func (this *Map) Pointer() *Map {
|
||||
|
||||
return that
|
||||
return this
|
||||
}
|
||||
|
||||
// 增加接口
|
||||
func (that Map) Put(key string, value interface{}) {
|
||||
//if that==nil{
|
||||
// that=Map{}
|
||||
//增加接口
|
||||
func (this Map) Put(key string, value interface{}) {
|
||||
//if this==nil{
|
||||
// this=Map{}
|
||||
//}
|
||||
that[key] = value
|
||||
this[key] = value
|
||||
}
|
||||
|
||||
// 删除接口
|
||||
func (that Map) Delete(key string) {
|
||||
delete(that, key)
|
||||
//删除接口
|
||||
func (this Map) Delete(key string) {
|
||||
delete(this, key)
|
||||
|
||||
}
|
||||
|
||||
// 获取Int
|
||||
func (that Map) GetInt(key string, err ...*Error) int {
|
||||
v := ObjToInt((that)[key], err...)
|
||||
//获取Int
|
||||
func (this Map) GetInt(key string, err ...*Error) int {
|
||||
v := ObjToInt((this)[key], err...)
|
||||
|
||||
return v
|
||||
|
||||
}
|
||||
|
||||
// 获取Int
|
||||
func (that Map) GetInt64(key string, err ...*Error) int64 {
|
||||
v := ObjToInt64((that)[key], err...)
|
||||
//获取Int
|
||||
func (this Map) GetInt64(key string, err ...*Error) int64 {
|
||||
v := ObjToInt64((this)[key], err...)
|
||||
return v
|
||||
|
||||
}
|
||||
|
||||
// 获取向上取整Int64
|
||||
func (that Map) GetCeilInt64(key string, err ...*Error) int64 {
|
||||
v := ObjToCeilInt64((that)[key], err...)
|
||||
//获取向上取整Int64
|
||||
func (this Map) GetCeilInt64(key string, err ...*Error) int64 {
|
||||
v := ObjToCeilInt64((this)[key], err...)
|
||||
return v
|
||||
|
||||
}
|
||||
|
||||
// 获取向上取整Int
|
||||
func (that Map) GetCeilInt(key string, err ...*Error) int {
|
||||
v := ObjToCeilInt((that)[key], err...)
|
||||
//获取向上取整Int
|
||||
func (this Map) GetCeilInt(key string, err ...*Error) int {
|
||||
v := ObjToCeilInt((this)[key], err...)
|
||||
return v
|
||||
|
||||
}
|
||||
|
||||
// 获取向上取整float64
|
||||
func (that Map) GetCeilFloat64(key string, err ...*Error) float64 {
|
||||
v := ObjToCeilFloat64((that)[key], err...)
|
||||
//获取向上取整float64
|
||||
func (this Map) GetCeilFloat64(key string, err ...*Error) float64 {
|
||||
v := ObjToCeilFloat64((this)[key], err...)
|
||||
return v
|
||||
|
||||
}
|
||||
|
||||
// 获取Float64
|
||||
func (that Map) GetFloat64(key string, err ...*Error) float64 {
|
||||
//获取Float64
|
||||
func (this Map) GetFloat64(key string, err ...*Error) float64 {
|
||||
|
||||
v := ObjToFloat64((that)[key], err...)
|
||||
v := ObjToFloat64((this)[key], err...)
|
||||
|
||||
return v
|
||||
|
||||
}
|
||||
|
||||
func (that Map) GetSlice(key string, err ...*Error) Slice {
|
||||
func (this Map) GetSlice(key string, err ...*Error) Slice {
|
||||
|
||||
//var v Slice
|
||||
v := ObjToSlice((that)[key], err...)
|
||||
v := ObjToSlice((this)[key], err...)
|
||||
|
||||
return v
|
||||
|
||||
}
|
||||
func (that Map) GetBool(key string, err ...*Error) bool {
|
||||
func (this Map) GetBool(key string, err ...*Error) bool {
|
||||
|
||||
//var v Slice
|
||||
v := ObjToBool((that)[key], err...)
|
||||
v := ObjToBool((this)[key], err...)
|
||||
|
||||
return v
|
||||
|
||||
}
|
||||
|
||||
func (that Map) GetTime(key string, err ...*Error) *time.Time {
|
||||
|
||||
v := ObjToTime((that)[key], err...)
|
||||
return v
|
||||
|
||||
}
|
||||
|
||||
func (that Map) RangeSort(callback func(k string, v interface{}) (isEnd bool)) {
|
||||
testQu := []string{}
|
||||
//testQuData:= qu[0].(Map)
|
||||
for key, _ := range that {
|
||||
//fmt.Println(key, ":", value)
|
||||
testQu = append(testQu, key)
|
||||
}
|
||||
sort.Strings(testQu)
|
||||
for _, k := range testQu {
|
||||
re := callback(k, that[k])
|
||||
if re {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (that Map) GetMap(key string, err ...*Error) Map {
|
||||
func (this Map) GetMap(key string, err ...*Error) Map {
|
||||
//var data Slice
|
||||
|
||||
v := ObjToMap((that)[key], err...)
|
||||
v := ObjToMap((this)[key], err...)
|
||||
|
||||
return v
|
||||
|
||||
}
|
||||
|
||||
func (that Map) Get(key string, err ...*Error) interface{} {
|
||||
func (this Map) Get(key string, err ...*Error) interface{} {
|
||||
|
||||
if v, ok := (that)[key]; ok {
|
||||
if v, ok := (this)[key]; ok {
|
||||
return v
|
||||
}
|
||||
e := errors.New("没有存储key及对应的数据")
|
||||
@@ -150,11 +123,11 @@ func (that Map) Get(key string, err ...*Error) interface{} {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 请传递指针过来
|
||||
func (that Map) ToStruct(stct interface{}) {
|
||||
//请传递指针过来
|
||||
func (this Map) ToStruct(stct interface{}) {
|
||||
|
||||
data := reflect.ValueOf(stct).Elem()
|
||||
for k, v := range that {
|
||||
for k, v := range this {
|
||||
ks := StrFirstToUpper(k)
|
||||
dkey := data.FieldByName(ks)
|
||||
if !dkey.IsValid() {
|
||||
@@ -162,13 +135,13 @@ func (that Map) ToStruct(stct interface{}) {
|
||||
}
|
||||
switch dkey.Type().String() {
|
||||
case "int":
|
||||
dkey.SetInt(that.GetInt64(k))
|
||||
dkey.SetInt(this.GetInt64(k))
|
||||
case "int64":
|
||||
dkey.Set(reflect.ValueOf(that.GetInt64(k)))
|
||||
dkey.Set(reflect.ValueOf(this.GetInt64(k)))
|
||||
case "float64":
|
||||
dkey.Set(reflect.ValueOf(that.GetFloat64(k)))
|
||||
dkey.Set(reflect.ValueOf(this.GetFloat64(k)))
|
||||
case "string":
|
||||
dkey.Set(reflect.ValueOf(that.GetString(k)))
|
||||
dkey.Set(reflect.ValueOf(this.GetString(k)))
|
||||
case "interface{}":
|
||||
dkey.Set(reflect.ValueOf(v))
|
||||
}
|
||||
@@ -176,27 +149,15 @@ func (that Map) ToStruct(stct interface{}) {
|
||||
|
||||
}
|
||||
|
||||
func (that Map) ToJsonString() string {
|
||||
return ObjToStr(that)
|
||||
func (this Map) ToJsonString() string {
|
||||
return ObjToStr(this)
|
||||
|
||||
}
|
||||
|
||||
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
|
||||
func (this Map) JsonToMap(jsonStr string, err ...*Error) {
|
||||
e := json.Unmarshal([]byte(jsonStr), &this)
|
||||
if e != nil && len(err) != 0 {
|
||||
err[0].SetError(e)
|
||||
}
|
||||
for k, v := range that {
|
||||
that[k] = fixJsonNumbers(v)
|
||||
}
|
||||
}
|
||||
|
||||
// GetRoundFloat64 获取四舍五入到 precision 位小数的 float64
|
||||
func (that Map) GetRoundFloat64(key string, precision int, err ...*Error) float64 {
|
||||
return ObjToRoundFloat64((that)[key], precision, err...)
|
||||
}
|
||||
|
||||
+3
-17
@@ -1,8 +1,6 @@
|
||||
package common
|
||||
|
||||
import "time"
|
||||
|
||||
// 对象封装方便取用
|
||||
//对象封装方便取用
|
||||
type Obj struct {
|
||||
Data interface{}
|
||||
Error
|
||||
@@ -20,13 +18,6 @@ func (that *Obj) ToInt(err ...Error) int {
|
||||
return ObjToInt(that.Data, &that.Error)
|
||||
}
|
||||
|
||||
func (that *Obj) ToTime(err ...Error) *time.Time {
|
||||
if len(err) != 0 {
|
||||
that.Error = err[0]
|
||||
}
|
||||
return ObjToTime(that.Data, &that.Error)
|
||||
}
|
||||
|
||||
func (that *Obj) ToInt64(err ...Error) int64 {
|
||||
if len(err) != 0 {
|
||||
that.Error = err[0]
|
||||
@@ -82,7 +73,7 @@ func (that *Obj) ToObj() interface{} {
|
||||
return that.Data
|
||||
}
|
||||
|
||||
// 获取向上取整Int64
|
||||
//获取向上取整Int64
|
||||
func (that *Obj) ToCeilInt64(err ...*Error) int64 {
|
||||
if len(err) != 0 {
|
||||
that.Error = *err[0]
|
||||
@@ -92,7 +83,7 @@ func (that *Obj) ToCeilInt64(err ...*Error) int64 {
|
||||
|
||||
}
|
||||
|
||||
// 获取向上取整Int
|
||||
//获取向上取整Int
|
||||
func (that *Obj) ToCeilInt(err ...*Error) int {
|
||||
if len(err) != 0 {
|
||||
that.Error = *err[0]
|
||||
@@ -101,8 +92,3 @@ func (that *Obj) ToCeilInt(err ...*Error) int {
|
||||
return v
|
||||
|
||||
}
|
||||
|
||||
// ToRoundFloat64 获取四舍五入到 precision 位小数的 float64
|
||||
func (that *Obj) ToRoundFloat64(precision int, err ...*Error) float64 {
|
||||
return ObjToRoundFloat64(that.Data, precision, err...)
|
||||
}
|
||||
|
||||
+101
-310
@@ -1,16 +1,13 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 仅限于hotime.Slice
|
||||
//仅限于hotime.Slice
|
||||
func ObjToMap(obj interface{}, e ...*Error) Map {
|
||||
var err error
|
||||
var v Map
|
||||
@@ -19,40 +16,31 @@ func ObjToMap(obj interface{}, e ...*Error) Map {
|
||||
v = nil
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
} else {
|
||||
switch val := obj.(type) {
|
||||
switch obj.(type) {
|
||||
case Map:
|
||||
v = val
|
||||
v = obj.(Map)
|
||||
case map[string]interface{}:
|
||||
v = Map(val)
|
||||
v = obj.(map[string]interface{})
|
||||
case string:
|
||||
result, e2 := JsonToObj(val)
|
||||
if e2 != nil {
|
||||
err = errors.New("没有合适的转换对象!" + e2.Error())
|
||||
v = nil
|
||||
} else if m, ok := result.(map[string]interface{}); ok {
|
||||
v = m
|
||||
} else {
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
v = Map{}
|
||||
e := json.Unmarshal([]byte(obj.(string)), &v)
|
||||
if e != nil {
|
||||
err = errors.New("没有合适的转换对象!" + e.Error())
|
||||
v = nil
|
||||
}
|
||||
default:
|
||||
var data []byte
|
||||
data, err = json.Marshal(obj)
|
||||
data, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
err = errors.New("没有合适的转换对象!" + err.Error())
|
||||
v = nil
|
||||
break
|
||||
}
|
||||
result, e2 := JsonToObj(string(data))
|
||||
if e2 != nil {
|
||||
err = errors.New("没有合适的转换对象!" + e2.Error())
|
||||
v = nil
|
||||
} else if m, ok := result.(map[string]interface{}); ok {
|
||||
v = m
|
||||
} else {
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
v = Map{}
|
||||
e := json.Unmarshal(data, &v)
|
||||
if e != nil {
|
||||
err = errors.New("没有合适的转换对象!" + e.Error())
|
||||
v = nil
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
if len(e) != 0 {
|
||||
@@ -63,14 +51,14 @@ func ObjToMap(obj interface{}, e ...*Error) Map {
|
||||
|
||||
func ObjToMapArray(obj interface{}, e ...*Error) []Map {
|
||||
s := ObjToSlice(obj, e...)
|
||||
res := make([]Map, 0, len(s))
|
||||
res := []Map{}
|
||||
for i := 0; i < len(s); i++ {
|
||||
res = append(res, s.GetMap(i))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// 仅限于hotime.Slice
|
||||
//仅限于hotime.Slice
|
||||
func ObjToSlice(obj interface{}, e ...*Error) Slice {
|
||||
var err error
|
||||
var v Slice
|
||||
@@ -79,39 +67,25 @@ func ObjToSlice(obj interface{}, e ...*Error) Slice {
|
||||
v = nil
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
} else {
|
||||
switch val := obj.(type) {
|
||||
switch obj.(type) {
|
||||
case Slice:
|
||||
v = val
|
||||
v = obj.(Slice)
|
||||
case []interface{}:
|
||||
v = Slice(val)
|
||||
v = obj.([]interface{})
|
||||
case []string:
|
||||
v = make(Slice, len(val))
|
||||
for i, s := range val {
|
||||
v[i] = s
|
||||
v = Slice{}
|
||||
for i := 0; i < len(obj.([]string)); i++ {
|
||||
v = append(v, obj.([]string)[i])
|
||||
}
|
||||
case string:
|
||||
result, e2 := JsonToObj(val)
|
||||
if e2 != nil {
|
||||
err = e2
|
||||
} else if s, ok := result.([]interface{}); ok {
|
||||
v = s
|
||||
} else {
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
}
|
||||
v = Slice{}
|
||||
err = json.Unmarshal([]byte(obj.(string)), &v)
|
||||
|
||||
default:
|
||||
v = Slice{}
|
||||
var data []byte
|
||||
data, err = json.Marshal(obj)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
result, e2 := JsonToObj(string(data))
|
||||
if e2 != nil {
|
||||
err = e2
|
||||
} else if s, ok := result.([]interface{}); ok {
|
||||
v = s
|
||||
} else {
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
}
|
||||
err = json.Unmarshal(data, &v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,141 +96,42 @@ func ObjToSlice(obj interface{}, e ...*Error) Slice {
|
||||
return v
|
||||
}
|
||||
|
||||
func ObjToTime(obj interface{}, e ...*Error) *time.Time {
|
||||
|
||||
tInt := ObjToInt64(obj)
|
||||
//字符串类型,只支持标准mysql datetime格式
|
||||
if tInt == 0 {
|
||||
tStr := ObjToStr(obj)
|
||||
// 先分离日期和时间部分,再对日期各段补零,避免时间中的数字被误处理
|
||||
datePart := tStr
|
||||
timePart := ""
|
||||
if idx := strings.Index(tStr, " "); idx > 0 {
|
||||
datePart = tStr[:idx]
|
||||
timePart = tStr[idx:] // 含前导空格
|
||||
}
|
||||
dateSegs := strings.Split(datePart, "-")
|
||||
for i, seg := range dateSegs {
|
||||
if seg == "" {
|
||||
continue
|
||||
}
|
||||
if len(seg) == 1 {
|
||||
dateSegs[i] = "0" + seg
|
||||
}
|
||||
}
|
||||
tStr = strings.Join(dateSegs, "-") + timePart
|
||||
|
||||
if len(tStr) > 18 {
|
||||
t, e := time.Parse("2006-01-02 15:04:05", tStr)
|
||||
if e == nil {
|
||||
return &t
|
||||
}
|
||||
} else if len(tStr) > 15 {
|
||||
t, e := time.Parse("2006-01-02 15:04", tStr)
|
||||
if e == nil {
|
||||
return &t
|
||||
}
|
||||
} else if len(tStr) > 12 {
|
||||
t, e := time.Parse("2006-01-02 15", tStr)
|
||||
if e == nil {
|
||||
return &t
|
||||
}
|
||||
} else if len(tStr) > 9 {
|
||||
t, e := time.Parse("2006-01-02", tStr)
|
||||
if e == nil {
|
||||
return &t
|
||||
}
|
||||
} else if len(tStr) > 6 {
|
||||
t, e := time.Parse("2006-01", tStr)
|
||||
if e == nil {
|
||||
return &t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存字符串,避免重复调用
|
||||
tIntStr := ObjToStr(tInt)
|
||||
tIntLen := len(tIntStr)
|
||||
|
||||
//纳秒级别
|
||||
if tIntLen > 16 {
|
||||
t := time.UnixMicro(tInt / 1000)
|
||||
return &t
|
||||
//微秒级别
|
||||
} else if tIntLen > 13 {
|
||||
t := time.UnixMicro(tInt)
|
||||
return &t
|
||||
//毫秒级别
|
||||
} else if tIntLen > 10 {
|
||||
t := time.UnixMilli(tInt)
|
||||
return &t
|
||||
//秒级别
|
||||
} else if tIntLen > 9 {
|
||||
t := time.Unix(tInt, 0)
|
||||
return &t
|
||||
} else if tIntLen > 3 {
|
||||
t, e := time.Parse("2006", tIntStr)
|
||||
if e == nil {
|
||||
return &t
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ObjToFloat64(obj interface{}, e ...*Error) float64 {
|
||||
var err error
|
||||
v := float64(0)
|
||||
|
||||
if obj == nil {
|
||||
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
} else {
|
||||
switch val := obj.(type) {
|
||||
|
||||
switch obj.(type) {
|
||||
case int:
|
||||
v = float64(val)
|
||||
case int8:
|
||||
v = float64(val)
|
||||
case int16:
|
||||
v = float64(val)
|
||||
case int32:
|
||||
v = float64(val)
|
||||
v = float64(obj.(int))
|
||||
case int64:
|
||||
v = float64(val)
|
||||
case uint:
|
||||
v = float64(val)
|
||||
case uint8:
|
||||
v = float64(val)
|
||||
case uint16:
|
||||
v = float64(val)
|
||||
case uint32:
|
||||
v = float64(val)
|
||||
case uint64:
|
||||
v = float64(val)
|
||||
case bool:
|
||||
if val {
|
||||
v = 1
|
||||
}
|
||||
v = float64(obj.(int64))
|
||||
case string:
|
||||
value, e2 := strconv.ParseFloat(val, 64)
|
||||
if e2 != nil {
|
||||
err = e2
|
||||
value, e := strconv.ParseFloat(obj.(string), 64)
|
||||
if e != nil {
|
||||
v = float64(0)
|
||||
err = e
|
||||
} else {
|
||||
v = value
|
||||
}
|
||||
case float64:
|
||||
v = val
|
||||
v = obj.(float64)
|
||||
case float32:
|
||||
// 通过 string 中转避免 float32→float64 精度膨胀(如 float32(1.2) 直接转得 1.200000047...)
|
||||
v, _ = strconv.ParseFloat(strconv.FormatFloat(float64(val), 'f', -1, 32), 64)
|
||||
case []byte:
|
||||
// 数据库 driver 有时以 []byte 返回数值字符串
|
||||
value, e2 := strconv.ParseFloat(string(val), 64)
|
||||
if e2 != nil {
|
||||
err = e2
|
||||
v = float64(obj.(float32))
|
||||
case uint8:
|
||||
value, e := strconv.ParseFloat(obj.(string), 64)
|
||||
if e != nil {
|
||||
v = float64(0)
|
||||
err = e
|
||||
} else {
|
||||
v = value
|
||||
}
|
||||
default:
|
||||
v = float64(0)
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
}
|
||||
}
|
||||
@@ -276,33 +151,25 @@ func ObjToFloat64(obj interface{}, e ...*Error) float64 {
|
||||
return v
|
||||
}
|
||||
|
||||
// ObjToRoundFloat64 将 obj 转为 float64 后四舍五入到 precision 位小数。
|
||||
// precision < 0 时不做舍入,直接返回原始 float64。
|
||||
func ObjToRoundFloat64(obj interface{}, precision int, e ...*Error) float64 {
|
||||
f := ObjToFloat64(obj, e...)
|
||||
if precision < 0 {
|
||||
return f
|
||||
}
|
||||
pow := math.Pow10(precision)
|
||||
return math.Round(f*pow) / pow
|
||||
}
|
||||
|
||||
// 向上取整
|
||||
//向上取整
|
||||
func ObjToCeilInt64(obj interface{}, e ...*Error) int64 {
|
||||
f := ObjToCeilFloat64(obj, e...)
|
||||
return int64(f)
|
||||
return ObjToInt64(math.Ceil(f))
|
||||
|
||||
}
|
||||
|
||||
// 向上取整
|
||||
//向上取整
|
||||
func ObjToCeilFloat64(obj interface{}, e ...*Error) float64 {
|
||||
f := ObjToFloat64(obj, e...)
|
||||
return math.Ceil(f)
|
||||
|
||||
}
|
||||
|
||||
// 向上取整
|
||||
//向上取整
|
||||
func ObjToCeilInt(obj interface{}, e ...*Error) int {
|
||||
f := ObjToCeilFloat64(obj, e...)
|
||||
return ObjToInt(f)
|
||||
|
||||
}
|
||||
|
||||
func ObjToInt64(obj interface{}, e ...*Error) int64 {
|
||||
@@ -310,63 +177,36 @@ func ObjToInt64(obj interface{}, e ...*Error) int64 {
|
||||
v := int64(0)
|
||||
|
||||
if obj == nil {
|
||||
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
} else {
|
||||
switch val := obj.(type) {
|
||||
switch obj.(type) {
|
||||
case int:
|
||||
v = int64(val)
|
||||
case int8:
|
||||
v = int64(val)
|
||||
case int16:
|
||||
v = int64(val)
|
||||
case int32:
|
||||
v = int64(val)
|
||||
v = int64(obj.(int))
|
||||
case int64:
|
||||
v = val
|
||||
case uint:
|
||||
v = int64(val)
|
||||
case uint8:
|
||||
v = int64(val)
|
||||
case uint16:
|
||||
v = int64(val)
|
||||
case uint32:
|
||||
v = int64(val)
|
||||
case uint64:
|
||||
v = int64(val)
|
||||
case bool:
|
||||
if val {
|
||||
v = 1
|
||||
}
|
||||
v = obj.(int64)
|
||||
case string:
|
||||
value, e2 := StrToInt(val)
|
||||
if e2 != nil {
|
||||
// fallback:处理 "1.20" 这类带小数点的整数字符串
|
||||
if fv, fe := strconv.ParseFloat(val, 64); fe == nil {
|
||||
v = int64(fv)
|
||||
} else {
|
||||
err = e2
|
||||
}
|
||||
value, e := StrToInt(obj.(string))
|
||||
if e != nil {
|
||||
v = int64(0)
|
||||
err = e
|
||||
} else {
|
||||
v = int64(value)
|
||||
}
|
||||
case []byte:
|
||||
// 数据库 driver 有时以 []byte 返回整数字符串
|
||||
value, e2 := StrToInt(string(val))
|
||||
if e2 != nil {
|
||||
// fallback:处理 "1.20" 这类带小数点的整数字符串
|
||||
if fv, fe := strconv.ParseFloat(string(val), 64); fe == nil {
|
||||
v = int64(fv)
|
||||
} else {
|
||||
err = e2
|
||||
}
|
||||
case uint8:
|
||||
value, e := StrToInt(obj.(string))
|
||||
if e != nil {
|
||||
v = int64(0)
|
||||
err = e
|
||||
} else {
|
||||
v = int64(value)
|
||||
}
|
||||
case float64:
|
||||
v = int64(val)
|
||||
v = int64(obj.(float64))
|
||||
case float32:
|
||||
v = int64(val)
|
||||
v = int64(obj.(float32))
|
||||
default:
|
||||
v = int64(0)
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
}
|
||||
}
|
||||
@@ -386,19 +226,18 @@ func ObjToBool(obj interface{}, e ...*Error) bool {
|
||||
v := false
|
||||
|
||||
if obj == nil {
|
||||
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
} else {
|
||||
switch val := obj.(type) {
|
||||
switch obj.(type) {
|
||||
case bool:
|
||||
v = val
|
||||
v = obj.(bool)
|
||||
default:
|
||||
var intErr Error
|
||||
toInt := ObjToInt(obj, &intErr)
|
||||
if intErr.GetError() != nil {
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
} else if toInt != 0 {
|
||||
toInt := ObjToInt(obj)
|
||||
if toInt != 0 {
|
||||
v = true
|
||||
}
|
||||
err = errors.New("没有合适的转换对象!")
|
||||
}
|
||||
}
|
||||
if len(e) != 0 {
|
||||
@@ -408,107 +247,55 @@ func ObjToBool(obj interface{}, e ...*Error) bool {
|
||||
}
|
||||
|
||||
func ObjToStr(obj interface{}) string {
|
||||
// fmt.Println(reflect.ValueOf(obj).Type().String() )
|
||||
str := ""
|
||||
if obj == nil {
|
||||
return str
|
||||
}
|
||||
switch val := obj.(type) {
|
||||
switch obj.(type) {
|
||||
case int:
|
||||
str = strconv.Itoa(val)
|
||||
case int8:
|
||||
str = strconv.Itoa(int(val))
|
||||
case int16:
|
||||
str = strconv.Itoa(int(val))
|
||||
case int32:
|
||||
str = strconv.Itoa(int(val))
|
||||
str = strconv.Itoa(obj.(int))
|
||||
case uint8:
|
||||
str = strconv.Itoa(int(val))
|
||||
case uint16:
|
||||
str = strconv.Itoa(int(val))
|
||||
case uint32:
|
||||
str = strconv.FormatUint(uint64(val), 10)
|
||||
case uint64:
|
||||
str = strconv.FormatUint(val, 10)
|
||||
str = obj.(string)
|
||||
case int64:
|
||||
str = strconv.FormatInt(val, 10)
|
||||
str = strconv.FormatInt(obj.(int64), 10)
|
||||
case []byte:
|
||||
str = string(val)
|
||||
str = string(obj.([]byte))
|
||||
case string:
|
||||
str = val
|
||||
str = obj.(string)
|
||||
case float64:
|
||||
str = strconv.FormatFloat(val, 'f', -1, 64)
|
||||
case float32:
|
||||
str = strconv.FormatFloat(float64(val), 'f', -1, 32)
|
||||
case bool:
|
||||
str = strconv.FormatBool(val)
|
||||
case time.Time:
|
||||
// DM8等数据库驱动直接返回time.Time,格式化为MySQL兼容的字符串
|
||||
str = val.Format("2006-01-02 15:04:05")
|
||||
str = strconv.FormatFloat(obj.(float64), 'f', -1, 64)
|
||||
default:
|
||||
strbte, err := json.MarshalIndent(obj, "", "\t")
|
||||
|
||||
if err == nil {
|
||||
str = string(strbte)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
// JsonToObj 将 JSON 字符串反序列化为 interface{},保留数字原始类型。
|
||||
// 标准 json.Unmarshal 会把所有 JSON 数字变成 float64,本函数保留整数为 int64、小数为 float64。
|
||||
//
|
||||
// JsonToObj(`{"id":2,"price":1.20}`) → map{"id": int64(2), "price": float64(1.2)}
|
||||
// JsonToObj(`[1, 2.5, "a"]`) → []interface{}{int64(1), float64(2.5), "a"}
|
||||
func JsonToObj(jsonStr string) (interface{}, error) {
|
||||
dec := json.NewDecoder(bytes.NewReader([]byte(jsonStr)))
|
||||
dec.UseNumber()
|
||||
var result interface{}
|
||||
if err := dec.Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fixJsonNumbers(result), nil
|
||||
}
|
||||
|
||||
// fixJsonNumbers 递归将 json.Number 转为 int64 或 float64
|
||||
func fixJsonNumbers(v interface{}) interface{} {
|
||||
switch val := v.(type) {
|
||||
case json.Number:
|
||||
if n, err := val.Int64(); err == nil {
|
||||
return n
|
||||
}
|
||||
if f, err := val.Float64(); err == nil {
|
||||
return f
|
||||
}
|
||||
return string(val)
|
||||
case map[string]interface{}:
|
||||
for k, item := range val {
|
||||
val[k] = fixJsonNumbers(item)
|
||||
}
|
||||
return val
|
||||
case []interface{}:
|
||||
for i, item := range val {
|
||||
val[i] = fixJsonNumbers(item)
|
||||
}
|
||||
return val
|
||||
default:
|
||||
return val
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为Map
|
||||
func StrToMap(s string) Map {
|
||||
//转换为Map
|
||||
func StrToMap(string string) Map {
|
||||
data := Map{}
|
||||
data.JsonToMap(s)
|
||||
data.JsonToMap(string)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// 转换为Slice
|
||||
func StrToSlice(s string) Slice {
|
||||
return ObjToSlice(s)
|
||||
//转换为Slice
|
||||
func StrToSlice(string string) Slice {
|
||||
|
||||
data := ObjToSlice(string)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// 字符串数组: a1,a2,a3转["a1","a2","a3"]
|
||||
//字符串数组: a1,a2,a3转["a1","a2","a3"]
|
||||
func StrArrayToJsonStr(a string) string {
|
||||
|
||||
if len(a) > 2 {
|
||||
if a[0] == ',' {
|
||||
a = Substr(a, 1, len(a)-1)
|
||||
@@ -516,6 +303,7 @@ func StrArrayToJsonStr(a string) string {
|
||||
if a[len(a)-1] == ',' {
|
||||
a = Substr(a, 0, len(a)-1)
|
||||
}
|
||||
//a = strings.Replace(a, ",", `,`, -1)
|
||||
a = `[` + a + `]`
|
||||
} else {
|
||||
a = "[]"
|
||||
@@ -523,16 +311,19 @@ func StrArrayToJsonStr(a string) string {
|
||||
return a
|
||||
}
|
||||
|
||||
// 字符串数组: ["a1","a2","a3"]转,a1,a2,a3,
|
||||
//字符串数组: a1,a2,a3转["a1","a2","a3"]
|
||||
func JsonStrToStrArray(a string) string {
|
||||
//a = strings.Replace(a, `"`, "", -1)
|
||||
if len(a) != 0 {
|
||||
a = Substr(a, 1, len(a)-2)
|
||||
}
|
||||
|
||||
return "," + a + ","
|
||||
}
|
||||
|
||||
// 字符串转int
|
||||
//字符串转int
|
||||
func StrToInt(s string) (int, error) {
|
||||
i, err := strconv.Atoi(s)
|
||||
return i, err
|
||||
|
||||
}
|
||||
|
||||
@@ -1,917 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 辅助:检查 Error 是否携带错误
|
||||
// ─────────────────────────────────────────────
|
||||
func hasErr(e *Error) bool {
|
||||
return e.GetError() != nil
|
||||
}
|
||||
|
||||
func newErr() *Error {
|
||||
return &Error{}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// ObjToMap
|
||||
// ─────────────────────────────────────────────
|
||||
func TestObjToMap(t *testing.T) {
|
||||
t.Run("nil returns nil with error", func(t *testing.T) {
|
||||
e := newErr()
|
||||
v := ObjToMap(nil, e)
|
||||
if v != nil || !hasErr(e) {
|
||||
t.Errorf("nil: got %v err=%v", v, e.GetError())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Map passthrough", func(t *testing.T) {
|
||||
input := Map{"a": 1}
|
||||
v := ObjToMap(input)
|
||||
if v == nil || v["a"] != 1 {
|
||||
t.Errorf("Map passthrough failed: %v", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("map[string]interface{} cast", func(t *testing.T) {
|
||||
input := map[string]interface{}{"b": 2}
|
||||
v := ObjToMap(input)
|
||||
if v == nil || v["b"] != 2 {
|
||||
t.Errorf("map[string]interface{} cast failed: %v", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid JSON string - integer preserved as int64", func(t *testing.T) {
|
||||
v := ObjToMap(`{"x":42}`)
|
||||
if v == nil || v["x"] != int64(42) {
|
||||
t.Errorf("JSON string: got %v (type %T), want int64(42)", v["x"], v["x"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid JSON string returns nil with error", func(t *testing.T) {
|
||||
e := newErr()
|
||||
v := ObjToMap(`not json`, e)
|
||||
if v != nil || !hasErr(e) {
|
||||
t.Errorf("invalid JSON: got %v err=%v", v, e.GetError())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("struct default branch marshal+unmarshal", func(t *testing.T) {
|
||||
type S struct{ Name string }
|
||||
v := ObjToMap(S{Name: "hello"})
|
||||
if v == nil || v["Name"] != "hello" {
|
||||
t.Errorf("struct default: %v", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("marshal fail (channel) returns nil with error", func(t *testing.T) {
|
||||
e := newErr()
|
||||
ch := make(chan int)
|
||||
v := ObjToMap(ch, e)
|
||||
if v != nil || !hasErr(e) {
|
||||
t.Errorf("channel marshal: got %v err=%v", v, e.GetError())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default marshal OK but unmarshal to map fails (int)", func(t *testing.T) {
|
||||
// json.Marshal(123) = "123", which can't unmarshal to Map
|
||||
e := newErr()
|
||||
v := ObjToMap(123, e)
|
||||
if v != nil || !hasErr(e) {
|
||||
t.Errorf("int default: got %v err=%v", v, e.GetError())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// ObjToMapArray
|
||||
// ─────────────────────────────────────────────
|
||||
func TestObjToMapArray(t *testing.T) {
|
||||
t.Run("nil returns empty", func(t *testing.T) {
|
||||
e := newErr()
|
||||
v := ObjToMapArray(nil, e)
|
||||
if len(v) != 0 || !hasErr(e) {
|
||||
t.Errorf("nil: got %v err=%v", v, e.GetError())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("JSON array of maps", func(t *testing.T) {
|
||||
v := ObjToMapArray(`[{"a":1},{"b":2}]`)
|
||||
if len(v) != 2 {
|
||||
t.Errorf("expected 2 elements, got %d", len(v))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// ObjToSlice
|
||||
// ─────────────────────────────────────────────
|
||||
func TestObjToSlice(t *testing.T) {
|
||||
t.Run("nil returns nil with error", func(t *testing.T) {
|
||||
e := newErr()
|
||||
v := ObjToSlice(nil, e)
|
||||
if v != nil || !hasErr(e) {
|
||||
t.Errorf("nil: got %v err=%v", v, e.GetError())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Slice passthrough", func(t *testing.T) {
|
||||
input := Slice{1, 2, 3}
|
||||
v := ObjToSlice(input)
|
||||
if len(v) != 3 {
|
||||
t.Errorf("Slice passthrough: %v", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("[]interface{} cast", func(t *testing.T) {
|
||||
input := []interface{}{4, 5}
|
||||
v := ObjToSlice(input)
|
||||
if len(v) != 2 || v[0] != 4 {
|
||||
t.Errorf("[]interface{} cast: %v", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("[]string conversion", func(t *testing.T) {
|
||||
input := []string{"a", "b", "c"}
|
||||
v := ObjToSlice(input)
|
||||
if len(v) != 3 || v[0] != "a" {
|
||||
t.Errorf("[]string: %v", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid JSON array string", func(t *testing.T) {
|
||||
v := ObjToSlice(`[1,2,3]`)
|
||||
if len(v) != 3 {
|
||||
t.Errorf("JSON array: %v", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid JSON string returns error", func(t *testing.T) {
|
||||
e := newErr()
|
||||
v := ObjToSlice(`not json`, e)
|
||||
if !hasErr(e) {
|
||||
t.Errorf("invalid JSON: got %v err=%v", v, e.GetError())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default: []int marshals to JSON array", func(t *testing.T) {
|
||||
v := ObjToSlice([]int{10, 20, 30})
|
||||
if len(v) != 3 {
|
||||
t.Errorf("[]int default: %v", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default: marshal fail (channel) returns error", func(t *testing.T) {
|
||||
e := newErr()
|
||||
ch := make(chan int)
|
||||
ObjToSlice(ch, e)
|
||||
if !hasErr(e) {
|
||||
t.Errorf("channel marshal: expected error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// ObjToTime
|
||||
// ─────────────────────────────────────────────
|
||||
func TestObjToTime(t *testing.T) {
|
||||
mustParse := func(layout, s string) time.Time {
|
||||
tt, err := time.Parse(layout, s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return tt
|
||||
}
|
||||
|
||||
t.Run("full datetime string", func(t *testing.T) {
|
||||
ts := ObjToTime("2006-01-02 15:04:05")
|
||||
want := mustParse("2006-01-02 15:04:05", "2006-01-02 15:04:05")
|
||||
if ts == nil || !ts.Equal(want) {
|
||||
t.Errorf("full datetime: %v", ts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single digit day+time (bug fix)", func(t *testing.T) {
|
||||
ts := ObjToTime("2006-1-2 15:04:05")
|
||||
want := mustParse("2006-01-02 15:04:05", "2006-01-02 15:04:05")
|
||||
if ts == nil || !ts.Equal(want) {
|
||||
t.Errorf("single digit day+time: got %v, want %v", ts, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("datetime without seconds", func(t *testing.T) {
|
||||
ts := ObjToTime("2006-01-02 15:04")
|
||||
want := mustParse("2006-01-02 15:04", "2006-01-02 15:04")
|
||||
if ts == nil || !ts.Equal(want) {
|
||||
t.Errorf("datetime without seconds: %v", ts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("date+hour only", func(t *testing.T) {
|
||||
ts := ObjToTime("2006-01-02 15")
|
||||
want := mustParse("2006-01-02 15", "2006-01-02 15")
|
||||
if ts == nil || !ts.Equal(want) {
|
||||
t.Errorf("date+hour: %v", ts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("date only", func(t *testing.T) {
|
||||
ts := ObjToTime("2006-01-02")
|
||||
want := mustParse("2006-01-02", "2006-01-02")
|
||||
if ts == nil || !ts.Equal(want) {
|
||||
t.Errorf("date only: %v", ts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("month only", func(t *testing.T) {
|
||||
ts := ObjToTime("2006-01")
|
||||
want := mustParse("2006-01", "2006-01")
|
||||
if ts == nil || !ts.Equal(want) {
|
||||
t.Errorf("month only: %v", ts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid string returns nil", func(t *testing.T) {
|
||||
ts := ObjToTime("not-a-date")
|
||||
if ts != nil {
|
||||
t.Errorf("invalid string: expected nil, got %v", ts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unix second timestamp (10 digits)", func(t *testing.T) {
|
||||
ts := ObjToTime(int64(1700000000)) // 10 digits
|
||||
if ts == nil {
|
||||
t.Error("second timestamp: got nil")
|
||||
}
|
||||
want := time.Unix(1700000000, 0)
|
||||
if !ts.Equal(want) {
|
||||
t.Errorf("second: %v vs %v", ts, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unix millisecond timestamp (13 digits)", func(t *testing.T) {
|
||||
ts := ObjToTime(int64(1700000000000)) // 13 digits
|
||||
if ts == nil {
|
||||
t.Error("millisecond timestamp: got nil")
|
||||
}
|
||||
want := time.UnixMilli(1700000000000)
|
||||
if !ts.Equal(want) {
|
||||
t.Errorf("millisecond: %v vs %v", ts, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unix microsecond timestamp (16 digits)", func(t *testing.T) {
|
||||
ts := ObjToTime(int64(1700000000000000)) // 16 digits
|
||||
if ts == nil {
|
||||
t.Error("microsecond timestamp: got nil")
|
||||
}
|
||||
want := time.UnixMicro(1700000000000000)
|
||||
if !ts.Equal(want) {
|
||||
t.Errorf("microsecond: %v vs %v", ts, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nanosecond timestamp (19 digits)", func(t *testing.T) {
|
||||
ts := ObjToTime(int64(1700000000000000000)) // 19 digits -> nano branch
|
||||
if ts == nil {
|
||||
t.Error("nanosecond timestamp: got nil")
|
||||
}
|
||||
want := time.UnixMicro(1700000000000000000 / 1000)
|
||||
if !ts.Equal(want) {
|
||||
t.Errorf("nanosecond: %v vs %v", ts, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("year int (4 digits)", func(t *testing.T) {
|
||||
ts := ObjToTime(int64(2024))
|
||||
if ts == nil {
|
||||
t.Error("year int: got nil")
|
||||
}
|
||||
want := mustParse("2006", "2024")
|
||||
if !ts.Equal(want) {
|
||||
t.Errorf("year: %v vs %v", ts, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("small int returns nil", func(t *testing.T) {
|
||||
ts := ObjToTime(int64(1))
|
||||
if ts != nil {
|
||||
t.Errorf("small int: expected nil, got %v", ts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil returns nil", func(t *testing.T) {
|
||||
ts := ObjToTime(nil)
|
||||
if ts != nil {
|
||||
t.Errorf("nil: expected nil, got %v", ts)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// ObjToFloat64
|
||||
// ─────────────────────────────────────────────
|
||||
func TestObjToFloat64(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input interface{}
|
||||
want float64
|
||||
wantErr bool
|
||||
}{
|
||||
{"nil", nil, 0, true},
|
||||
{"int", 42, 42.0, false},
|
||||
{"int64", int64(100), 100.0, false},
|
||||
{"string valid", "3.14", 3.14, false},
|
||||
{"string invalid", "abc", 0, true},
|
||||
{"float64", float64(1.5), 1.5, false},
|
||||
// Fix4: float32 通过 strconv 转换,消除精度膨胀噪声
|
||||
{"float32 no precision inflation", float32(1.2), 1.2, false},
|
||||
{"[]byte valid", []byte("2.71"), 2.71, false},
|
||||
{"[]byte invalid", []byte("xyz"), 0, true},
|
||||
{"unknown type", struct{}{}, 0, true},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
e := newErr()
|
||||
got := ObjToFloat64(c.input, e)
|
||||
if math.Abs(got-c.want) > 1e-9 {
|
||||
t.Errorf("value: got %v, want %v", got, c.want)
|
||||
}
|
||||
if hasErr(e) != c.wantErr {
|
||||
t.Errorf("error: got hasErr=%v, wantErr=%v (err=%v)", hasErr(e), c.wantErr, e.GetError())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("NaN is caught and zeroed", func(t *testing.T) {
|
||||
e := newErr()
|
||||
v := ObjToFloat64(math.NaN(), e)
|
||||
if v != 0 || !hasErr(e) {
|
||||
t.Errorf("NaN: got %v err=%v", v, e.GetError())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Inf is caught and zeroed", func(t *testing.T) {
|
||||
e := newErr()
|
||||
v := ObjToFloat64(math.Inf(1), e)
|
||||
if v != 0 || !hasErr(e) {
|
||||
t.Errorf("Inf: got %v err=%v", v, e.GetError())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("-Inf is caught and zeroed", func(t *testing.T) {
|
||||
e := newErr()
|
||||
v := ObjToFloat64(math.Inf(-1), e)
|
||||
if v != 0 || !hasErr(e) {
|
||||
t.Errorf("-Inf: got %v err=%v", v, e.GetError())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// ObjToRoundFloat64
|
||||
// ─────────────────────────────────────────────
|
||||
func TestObjToRoundFloat64(t *testing.T) {
|
||||
t.Run("precision 2 rounds correctly", func(t *testing.T) {
|
||||
v := ObjToRoundFloat64(3.5000001, 2)
|
||||
if v != 3.5 {
|
||||
t.Errorf("got %v, want 3.5", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("precision 0 rounds to integer", func(t *testing.T) {
|
||||
v := ObjToRoundFloat64(2.7, 0)
|
||||
if v != 3.0 {
|
||||
t.Errorf("got %v, want 3.0", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("precision -1 returns raw float", func(t *testing.T) {
|
||||
v := ObjToRoundFloat64(1.23456789, -1)
|
||||
if math.Abs(v-1.23456789) > 1e-9 {
|
||||
t.Errorf("got %v, want ~1.23456789", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("string input with float noise", func(t *testing.T) {
|
||||
v := ObjToRoundFloat64("3.15000001", 2)
|
||||
if v != 3.15 {
|
||||
t.Errorf("got %v, want 3.15", v)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// ObjToCeil*
|
||||
// ─────────────────────────────────────────────
|
||||
func TestObjToCeil(t *testing.T) {
|
||||
t.Run("ObjToCeilInt64 rounds up", func(t *testing.T) {
|
||||
v := ObjToCeilInt64(1.1)
|
||||
if v != 2 {
|
||||
t.Errorf("got %d, want 2", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ObjToCeilInt64 already integer", func(t *testing.T) {
|
||||
v := ObjToCeilInt64(float64(3))
|
||||
if v != 3 {
|
||||
t.Errorf("got %d, want 3", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ObjToCeilInt64 no double ceil (bug fix)", func(t *testing.T) {
|
||||
// 3.0 -> Ceil(3.0)=3.0 -> int64(3), NOT Ceil(3.0) again
|
||||
v := ObjToCeilInt64(float64(3))
|
||||
if v != 3 {
|
||||
t.Errorf("double ceil: got %d, want 3", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ObjToCeilFloat64", func(t *testing.T) {
|
||||
v := ObjToCeilFloat64(1.1)
|
||||
if v != 2.0 {
|
||||
t.Errorf("got %v, want 2.0", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ObjToCeilInt", func(t *testing.T) {
|
||||
v := ObjToCeilInt(2.3)
|
||||
if v != 3 {
|
||||
t.Errorf("got %d, want 3", v)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// ObjToInt64
|
||||
// ─────────────────────────────────────────────
|
||||
func TestObjToInt64(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input interface{}
|
||||
want int64
|
||||
wantErr bool
|
||||
}{
|
||||
{"nil", nil, 0, true},
|
||||
{"int", 7, 7, false},
|
||||
{"int64", int64(99), 99, false},
|
||||
{"string valid int", "42", 42, false},
|
||||
// Fix3 fallback: "1.20" 原来报错,现在截断得 1
|
||||
{"string float-like", "1.20", 1, false},
|
||||
{"string invalid", "abc", 0, true},
|
||||
{"[]byte valid int", []byte("123"), 123, false},
|
||||
// Fix3 fallback: []byte "2.70" 截断得 2
|
||||
{"[]byte float-like", []byte("2.70"), 2, false},
|
||||
{"[]byte invalid", []byte("xyz"), 0, true},
|
||||
{"float64 truncate", float64(5.9), 5, false},
|
||||
{"float32 truncate", float32(3.1), 3, false},
|
||||
{"unknown type", struct{}{}, 0, true},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
e := newErr()
|
||||
got := ObjToInt64(c.input, e)
|
||||
if got != c.want {
|
||||
t.Errorf("value: got %d, want %d", got, c.want)
|
||||
}
|
||||
if hasErr(e) != c.wantErr {
|
||||
t.Errorf("error: got hasErr=%v, wantErr=%v (err=%v)", hasErr(e), c.wantErr, e.GetError())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// ObjToInt
|
||||
// ─────────────────────────────────────────────
|
||||
func TestObjToInt(t *testing.T) {
|
||||
v := ObjToInt(10)
|
||||
if v != 10 {
|
||||
t.Errorf("got %d, want 10", v)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// ObjToBool
|
||||
// ─────────────────────────────────────────────
|
||||
func TestObjToBool(t *testing.T) {
|
||||
t.Run("nil returns false with error", func(t *testing.T) {
|
||||
e := newErr()
|
||||
v := ObjToBool(nil, e)
|
||||
if v || !hasErr(e) {
|
||||
t.Errorf("nil: got %v err=%v", v, e.GetError())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bool true", func(t *testing.T) {
|
||||
e := newErr()
|
||||
v := ObjToBool(true, e)
|
||||
if !v || hasErr(e) {
|
||||
t.Errorf("bool true: got %v err=%v", v, e.GetError())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bool false", func(t *testing.T) {
|
||||
e := newErr()
|
||||
v := ObjToBool(false, e)
|
||||
if v || hasErr(e) {
|
||||
t.Errorf("bool false: got %v err=%v", v, e.GetError())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("int 1 -> true, no error (bug fix)", func(t *testing.T) {
|
||||
e := newErr()
|
||||
v := ObjToBool(1, e)
|
||||
if !v || hasErr(e) {
|
||||
t.Errorf("int 1: got v=%v err=%v", v, e.GetError())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("int 0 -> false, no error", func(t *testing.T) {
|
||||
e := newErr()
|
||||
v := ObjToBool(0, e)
|
||||
if v || hasErr(e) {
|
||||
t.Errorf("int 0: got v=%v err=%v", v, e.GetError())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown type returns false with error", func(t *testing.T) {
|
||||
e := newErr()
|
||||
v := ObjToBool(struct{}{}, e)
|
||||
if v || !hasErr(e) {
|
||||
t.Errorf("struct: got v=%v err=%v", v, e.GetError())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// ObjToStr
|
||||
// ─────────────────────────────────────────────
|
||||
func TestObjToStr(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input interface{}
|
||||
want string
|
||||
}{
|
||||
{"nil", nil, ""},
|
||||
{"int", 42, "42"},
|
||||
{"uint8", uint8(65), "65"},
|
||||
{"int64", int64(1234567890), "1234567890"},
|
||||
{"[]byte", []byte("hello"), "hello"},
|
||||
{"string", "world", "world"},
|
||||
{"float64", float64(3.14), "3.14"},
|
||||
{"float32", float32(2.5), "2.5"},
|
||||
{"bool true", true, "true"},
|
||||
{"bool false", false, "false"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := ObjToStr(c.input)
|
||||
if got != c.want {
|
||||
t.Errorf("got %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("default (struct) marshals to JSON", func(t *testing.T) {
|
||||
type S struct{ X int }
|
||||
got := ObjToStr(S{X: 1})
|
||||
if got == "" {
|
||||
t.Error("struct: expected non-empty JSON string")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// StrToMap
|
||||
// ─────────────────────────────────────────────
|
||||
func TestStrToMap(t *testing.T) {
|
||||
t.Run("valid JSON", func(t *testing.T) {
|
||||
v := StrToMap(`{"k":"v"}`)
|
||||
if v["k"] != "v" {
|
||||
t.Errorf("StrToMap: %v", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid JSON returns empty map", func(t *testing.T) {
|
||||
v := StrToMap(`not json`)
|
||||
if v == nil {
|
||||
t.Error("StrToMap invalid: expected empty map, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// StrToSlice
|
||||
// ─────────────────────────────────────────────
|
||||
func TestStrToSlice(t *testing.T) {
|
||||
v := StrToSlice(`[1,2,3]`)
|
||||
if len(v) != 3 {
|
||||
t.Errorf("StrToSlice: expected 3 elements, got %d", len(v))
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// StrArrayToJsonStr
|
||||
// ─────────────────────────────────────────────
|
||||
func TestStrArrayToJsonStr(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"empty returns []", "", "[]"},
|
||||
{"short (<=2) returns []", "a", "[]"},
|
||||
{"no commas", `"a","b"`, `["a","b"]`},
|
||||
{"leading comma stripped", `,"a","b"`, `["a","b"]`},
|
||||
{"trailing comma stripped", `"a","b",`, `["a","b"]`},
|
||||
{"both commas stripped", `,"a","b",`, `["a","b"]`},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := StrArrayToJsonStr(c.input)
|
||||
if got != c.want {
|
||||
t.Errorf("got %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// JsonStrToStrArray
|
||||
// ─────────────────────────────────────────────
|
||||
func TestJsonStrToStrArray(t *testing.T) {
|
||||
t.Run("non-empty strips brackets", func(t *testing.T) {
|
||||
got := JsonStrToStrArray(`["a","b"]`)
|
||||
if got != `,"a","b",` {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty string", func(t *testing.T) {
|
||||
got := JsonStrToStrArray("")
|
||||
if got != ",," {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// StrToInt
|
||||
// ─────────────────────────────────────────────
|
||||
func TestStrToInt(t *testing.T) {
|
||||
t.Run("valid", func(t *testing.T) {
|
||||
v, err := StrToInt("123")
|
||||
if v != 123 || err != nil {
|
||||
t.Errorf("got %d %v", v, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid", func(t *testing.T) {
|
||||
_, err := StrToInt("abc")
|
||||
if err == nil {
|
||||
t.Error("expected error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// JsonToObj
|
||||
// ─────────────────────────────────────────────
|
||||
func TestJsonToObj(t *testing.T) {
|
||||
t.Run("integer preserved as int64", func(t *testing.T) {
|
||||
v, err := JsonToObj(`{"id":2}`)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
m, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected map, got %T", v)
|
||||
}
|
||||
if m["id"] != int64(2) {
|
||||
t.Errorf("got %v (type %T), want int64(2)", m["id"], m["id"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decimal preserved as float64 with correct precision", func(t *testing.T) {
|
||||
v, err := JsonToObj(`{"price":1.20}`)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
m := v.(map[string]interface{})
|
||||
price, ok := m["price"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("expected float64, got %T", m["price"])
|
||||
}
|
||||
if price != 1.2 {
|
||||
t.Errorf("got %v, want 1.2", price)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("large integer preserved as int64", func(t *testing.T) {
|
||||
v, err := JsonToObj(`{"num":9999999}`)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
m := v.(map[string]interface{})
|
||||
if m["num"] != int64(9999999) {
|
||||
t.Errorf("got %v (type %T), want int64(9999999)", m["num"], m["num"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("array with mixed types", func(t *testing.T) {
|
||||
v, err := JsonToObj(`[1, 2.5, "hello", true]`)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
arr := v.([]interface{})
|
||||
if len(arr) != 4 {
|
||||
t.Fatalf("expected 4 elements, got %d", len(arr))
|
||||
}
|
||||
if arr[0] != int64(1) {
|
||||
t.Errorf("arr[0]: got %v (%T), want int64(1)", arr[0], arr[0])
|
||||
}
|
||||
if arr[1] != float64(2.5) {
|
||||
t.Errorf("arr[1]: got %v (%T), want float64(2.5)", arr[1], arr[1])
|
||||
}
|
||||
if arr[2] != "hello" {
|
||||
t.Errorf("arr[2]: got %v, want hello", arr[2])
|
||||
}
|
||||
if arr[3] != true {
|
||||
t.Errorf("arr[3]: got %v, want true", arr[3])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nested object integers preserved", func(t *testing.T) {
|
||||
v, err := JsonToObj(`{"order":{"id":5,"num":3,"price":1.20}}`)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
m := v.(map[string]interface{})
|
||||
order := m["order"].(map[string]interface{})
|
||||
if order["id"] != int64(5) {
|
||||
t.Errorf("nested id: got %v (%T), want int64(5)", order["id"], order["id"])
|
||||
}
|
||||
if order["num"] != int64(3) {
|
||||
t.Errorf("nested num: got %v (%T), want int64(3)", order["num"], order["num"])
|
||||
}
|
||||
if order["price"] != float64(1.2) {
|
||||
t.Errorf("nested price: got %v, want 1.2", order["price"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid JSON returns error", func(t *testing.T) {
|
||||
_, err := JsonToObj(`not json`)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid JSON")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty object", func(t *testing.T) {
|
||||
v, err := JsonToObj(`{}`)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
m, ok := v.(map[string]interface{})
|
||||
if !ok || len(m) != 0 {
|
||||
t.Errorf("expected empty map, got %v", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("null value", func(t *testing.T) {
|
||||
v, err := JsonToObj(`{"x":null}`)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
m := v.(map[string]interface{})
|
||||
if m["x"] != nil {
|
||||
t.Errorf("expected nil, got %v", m["x"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("zero integer", func(t *testing.T) {
|
||||
v, err := JsonToObj(`{"count":0}`)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
m := v.(map[string]interface{})
|
||||
if m["count"] != int64(0) {
|
||||
t.Errorf("got %v (%T), want int64(0)", m["count"], m["count"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// ObjToMap 数字类型保留(新行为)
|
||||
// ─────────────────────────────────────────────
|
||||
func TestObjToMapNumberPreservation(t *testing.T) {
|
||||
t.Run("JSON integer -> int64", func(t *testing.T) {
|
||||
v := ObjToMap(`{"id":2,"num":0}`)
|
||||
if v["id"] != int64(2) {
|
||||
t.Errorf("id: got %v (%T), want int64(2)", v["id"], v["id"])
|
||||
}
|
||||
if v["num"] != int64(0) {
|
||||
t.Errorf("num: got %v (%T), want int64(0)", v["num"], v["num"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("JSON decimal -> float64", func(t *testing.T) {
|
||||
v := ObjToMap(`{"price":1.20,"rate":0.5}`)
|
||||
if v["price"] != float64(1.2) {
|
||||
t.Errorf("price: got %v (%T), want float64(1.2)", v["price"], v["price"])
|
||||
}
|
||||
if v["rate"] != float64(0.5) {
|
||||
t.Errorf("rate: got %v (%T), want float64(0.5)", v["rate"], v["rate"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("struct with int field -> int64", func(t *testing.T) {
|
||||
type S struct {
|
||||
Count int `json:"count"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
v := ObjToMap(S{Count: 3, Name: "test"})
|
||||
if v == nil {
|
||||
t.Fatal("got nil")
|
||||
}
|
||||
if v["count"] != int64(3) {
|
||||
t.Errorf("count: got %v (%T), want int64(3)", v["count"], v["count"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// ObjToSlice 数字类型保留(新行为)
|
||||
// ─────────────────────────────────────────────
|
||||
func TestObjToSliceNumberPreservation(t *testing.T) {
|
||||
t.Run("JSON integer array -> int64 elements", func(t *testing.T) {
|
||||
v := ObjToSlice(`[1, 2, 3]`)
|
||||
if len(v) != 3 {
|
||||
t.Fatalf("expected 3, got %d", len(v))
|
||||
}
|
||||
if v[0] != int64(1) {
|
||||
t.Errorf("v[0]: got %v (%T), want int64(1)", v[0], v[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("JSON mixed array - integer and float", func(t *testing.T) {
|
||||
v := ObjToSlice(`[2, 1.5, "hello"]`)
|
||||
if v[0] != int64(2) {
|
||||
t.Errorf("v[0]: got %v (%T), want int64(2)", v[0], v[0])
|
||||
}
|
||||
if v[1] != float64(1.5) {
|
||||
t.Errorf("v[1]: got %v (%T), want float64(1.5)", v[1], v[1])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Map.JsonToMap 数字类型保留(新行为)
|
||||
// ─────────────────────────────────────────────
|
||||
func TestMapJsonToMapNumberPreservation(t *testing.T) {
|
||||
t.Run("integer -> int64", func(t *testing.T) {
|
||||
m := Map{}
|
||||
m.JsonToMap(`{"id":5,"count":0}`)
|
||||
if m["id"] != int64(5) {
|
||||
t.Errorf("id: got %v (%T), want int64(5)", m["id"], m["id"])
|
||||
}
|
||||
if m["count"] != int64(0) {
|
||||
t.Errorf("count: got %v (%T), want int64(0)", m["count"], m["count"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decimal -> float64", func(t *testing.T) {
|
||||
m := Map{}
|
||||
m.JsonToMap(`{"price":1.20}`)
|
||||
if m["price"] != float64(1.2) {
|
||||
t.Errorf("price: got %v (%T), want float64(1.2)", m["price"], m["price"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("StrToMap integer -> int64", func(t *testing.T) {
|
||||
m := StrToMap(`{"total":100,"amount":1.50}`)
|
||||
if m["total"] != int64(100) {
|
||||
t.Errorf("total: got %v (%T), want int64(100)", m["total"], m["total"])
|
||||
}
|
||||
if m["amount"] != float64(1.5) {
|
||||
t.Errorf("amount: got %v (%T), want float64(1.5)", m["amount"], m["amount"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid JSON sets error", func(t *testing.T) {
|
||||
e := newErr()
|
||||
m := Map{}
|
||||
m.JsonToMap(`not json`, e)
|
||||
if !hasErr(e) {
|
||||
t.Error("expected error for invalid JSON")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Slice []interface{}
|
||||
@@ -15,13 +14,6 @@ func (that Slice) GetString(key int, err ...*Error) string {
|
||||
return ObjToStr((that)[key])
|
||||
}
|
||||
|
||||
func (that Slice) GetTime(key int, err ...*Error) *time.Time {
|
||||
|
||||
v := ObjToTime((that)[key], err...)
|
||||
return v
|
||||
|
||||
}
|
||||
|
||||
// GetInt 获取Int
|
||||
func (that Slice) GetInt(key int, err ...*Error) int {
|
||||
v := ObjToInt((that)[key], err...)
|
||||
@@ -63,11 +55,6 @@ func (that Slice) GetFloat64(key int, err ...*Error) float64 {
|
||||
return v
|
||||
}
|
||||
|
||||
// GetRoundFloat64 获取四舍五入到 precision 位小数的 float64
|
||||
func (that Slice) GetRoundFloat64(key int, precision int, err ...*Error) float64 {
|
||||
return ObjToRoundFloat64((that)[key], precision, err...)
|
||||
}
|
||||
|
||||
func (that Slice) GetSlice(key int, err ...*Error) Slice {
|
||||
v := ObjToSlice((that)[key], err...)
|
||||
return v
|
||||
|
||||
+7
-159
@@ -1,38 +1,25 @@
|
||||
package hotime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
. "./cache"
|
||||
. "./common"
|
||||
. "./db"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
. "code.hoteas.com/golang/hotime/db"
|
||||
htlog "code.hoteas.com/golang/hotime/log"
|
||||
)
|
||||
|
||||
type Context struct {
|
||||
*Application
|
||||
Resp http.ResponseWriter
|
||||
Req *http.Request
|
||||
Log Map //业务 logs 表字段(有则写入),与 Logger 不同
|
||||
Logger *htlog.Logger // 请求级日志(带 sid/request_id),业务与 SQL 共用
|
||||
RouterString []string
|
||||
Config Map
|
||||
Db *HoTimeDB
|
||||
RespData Map
|
||||
RespFunc func()
|
||||
//CacheIns
|
||||
CacheIns
|
||||
SessionIns
|
||||
DataSize int
|
||||
HandlerStr string //复写请求url
|
||||
|
||||
// 请求参数缓存
|
||||
reqJsonCache Map // JSON Body 缓存
|
||||
reqMu sync.Once // 确保 JSON 只解析一次
|
||||
}
|
||||
|
||||
// Mtd 唯一标志
|
||||
@@ -43,7 +30,7 @@ func (that *Context) Mtd(router [3]string) Map {
|
||||
return d
|
||||
}
|
||||
|
||||
// 打印
|
||||
//打印
|
||||
func (that *Context) Display(statu int, data interface{}) {
|
||||
|
||||
resp := Map{"status": statu}
|
||||
@@ -60,9 +47,6 @@ func (that *Context) Display(statu int, data interface{}) {
|
||||
resp["result"] = temp
|
||||
//兼容android等需要json转对象的服务
|
||||
resp["error"] = temp
|
||||
|
||||
that.reqLogger().Warn().Int("status", statu).Msg(resp.ToJsonString())
|
||||
|
||||
} else {
|
||||
resp["result"] = data
|
||||
}
|
||||
@@ -72,153 +56,17 @@ func (that *Context) Display(statu int, data interface{}) {
|
||||
//that.Data=d;
|
||||
}
|
||||
|
||||
// reqLogger 返回请求级 Logger,nil 时回退 Application.Log
|
||||
func (that *Context) reqLogger() *htlog.Logger {
|
||||
if that.Logger != nil {
|
||||
return that.Logger
|
||||
}
|
||||
if that.Application != nil {
|
||||
return that.Application.Log
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LogBind 为本请求追加自定义日志字段(如 user_id),后续业务日志与 SQL 日志均携带。
|
||||
// 供 main.go 的 connectListener 调用;nil 安全。
|
||||
func (that *Context) LogBind(key string, value interface{}) {
|
||||
if that == nil || key == "" {
|
||||
return
|
||||
}
|
||||
base := that.reqLogger()
|
||||
if base == nil {
|
||||
return
|
||||
}
|
||||
that.Logger = base.WithFields(key, ObjToStr(value))
|
||||
if that.Db != nil {
|
||||
that.Db.Log = that.Logger
|
||||
}
|
||||
}
|
||||
|
||||
func (that *Context) View() {
|
||||
if that.RespFunc != nil {
|
||||
that.RespFunc()
|
||||
}
|
||||
|
||||
if that.RespData == nil {
|
||||
return
|
||||
}
|
||||
//创建日志
|
||||
if that.Log != nil {
|
||||
that.Log["time"] = time.Now().Format("2006-01-02 15:04")
|
||||
if that.Session("admin_id").Data != nil {
|
||||
that.Log["admin_id"] = that.Session("admin_id").ToCeilInt()
|
||||
}
|
||||
if that.Session("user_id").Data != nil {
|
||||
that.Log["user_id"] = that.Session("user_id").ToCeilInt()
|
||||
}
|
||||
that.Log["ip"] = clientIP(that.Req)
|
||||
that.Db.Insert("logs", that.Log)
|
||||
}
|
||||
|
||||
d, err := json.Marshal(that.RespData)
|
||||
if err != nil {
|
||||
that.Display(1, err.Error())
|
||||
that.View()
|
||||
return
|
||||
}
|
||||
that.DataSize = len(d)
|
||||
that.RespData = nil
|
||||
that.Resp.Write(d)
|
||||
}
|
||||
|
||||
// ==================== 请求参数获取方法 ====================
|
||||
|
||||
// ReqParam 获取 URL 查询参数,返回 *Obj 支持链式调用
|
||||
// 用法: that.ReqParam("id").ToInt()
|
||||
func (that *Context) ReqParam(key string) *Obj {
|
||||
v := that.Req.URL.Query().Get(key)
|
||||
if v == "" {
|
||||
return &Obj{Data: nil}
|
||||
}
|
||||
return &Obj{Data: v}
|
||||
}
|
||||
|
||||
// ReqForm 获取表单参数,返回 *Obj 支持链式调用
|
||||
// 用法: that.ReqForm("name").ToStr()
|
||||
func (that *Context) ReqForm(key string) *Obj {
|
||||
v := that.Req.FormValue(key)
|
||||
if v == "" {
|
||||
return &Obj{Data: nil}
|
||||
}
|
||||
return &Obj{Data: v}
|
||||
}
|
||||
|
||||
// ReqJson 获取 JSON Body 中的字段,返回 *Obj 支持链式调用
|
||||
// 用法: that.ReqJson("data").ToMap()
|
||||
func (that *Context) ReqJson(key string) *Obj {
|
||||
that.parseJsonBody()
|
||||
if that.reqJsonCache == nil {
|
||||
return &Obj{Data: nil}
|
||||
}
|
||||
return &Obj{Data: that.reqJsonCache.Get(key)}
|
||||
}
|
||||
|
||||
// parseJsonBody 解析 JSON Body(只解析一次,并发安全)
|
||||
func (that *Context) parseJsonBody() {
|
||||
that.reqMu.Do(func() {
|
||||
if that.Req.Body == nil {
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(that.Req.Body)
|
||||
if err != nil || len(body) == 0 {
|
||||
return
|
||||
}
|
||||
// 恢复 Body 以便后续代码可以再次读取
|
||||
that.Req.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||
that.reqJsonCache = ObjToMap(string(body))
|
||||
})
|
||||
}
|
||||
|
||||
// ReqData 统一获取请求参数,优先级: JSON > Form > URL
|
||||
// 用法: that.ReqData("id").ToInt()
|
||||
func (that *Context) ReqData(key string) *Obj {
|
||||
// 1. 优先从 JSON Body 获取
|
||||
that.parseJsonBody()
|
||||
if that.reqJsonCache != nil {
|
||||
if v := that.reqJsonCache.Get(key); v != nil {
|
||||
return &Obj{Data: v}
|
||||
}
|
||||
}
|
||||
// 2. 其次从 Form 获取
|
||||
if v := that.Req.FormValue(key); v != "" {
|
||||
return &Obj{Data: v}
|
||||
}
|
||||
// 3. 最后从 URL 参数获取
|
||||
if v := that.Req.URL.Query().Get(key); v != "" {
|
||||
return &Obj{Data: v}
|
||||
}
|
||||
return &Obj{Data: nil}
|
||||
}
|
||||
|
||||
// ReqFile 获取单个上传文件
|
||||
// 用法: file, header, err := that.ReqFile("avatar")
|
||||
func (that *Context) ReqFile(name string) (multipart.File, *multipart.FileHeader, error) {
|
||||
return that.Req.FormFile(name)
|
||||
}
|
||||
|
||||
// ReqFiles 获取多个同名上传文件(批量上传场景)
|
||||
// 用法: files, err := that.ReqFiles("images")
|
||||
func (that *Context) ReqFiles(name string) ([]*multipart.FileHeader, error) {
|
||||
if that.Req.MultipartForm == nil {
|
||||
if err := that.Req.ParseMultipartForm(32 << 20); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if that.Req.MultipartForm == nil || that.Req.MultipartForm.File == nil {
|
||||
return nil, http.ErrMissingFile
|
||||
}
|
||||
files, ok := that.Req.MultipartForm.File[name]
|
||||
if !ok {
|
||||
return nil, http.ErrMissingFile
|
||||
}
|
||||
return files, nil
|
||||
return
|
||||
}
|
||||
|
||||
-115
@@ -1,115 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
// Count 计数
|
||||
func (that *HoTimeDB) Count(table string, qu ...interface{}) int {
|
||||
var req = []interface{}{}
|
||||
if len(qu) == 2 {
|
||||
req = append(req, qu[0])
|
||||
req = append(req, "COUNT(*)")
|
||||
req = append(req, qu[1])
|
||||
} else {
|
||||
req = append(req, "COUNT(*)")
|
||||
req = append(req, qu...)
|
||||
}
|
||||
|
||||
data := that.Select(table, req...)
|
||||
if len(data) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
res := ObjToStr(data[0]["COUNT(*)"])
|
||||
count, _ := StrToInt(res)
|
||||
return count
|
||||
}
|
||||
|
||||
// Sum 求和
|
||||
func (that *HoTimeDB) Sum(table string, column string, qu ...interface{}) float64 {
|
||||
var req = []interface{}{}
|
||||
if len(qu) == 2 {
|
||||
req = append(req, qu[0])
|
||||
req = append(req, "SUM("+column+")")
|
||||
req = append(req, qu[1])
|
||||
} else {
|
||||
req = append(req, "SUM("+column+")")
|
||||
req = append(req, qu...)
|
||||
}
|
||||
|
||||
data := that.Select(table, req...)
|
||||
if len(data) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
res := ObjToStr(data[0]["SUM("+column+")"])
|
||||
sum := ObjToFloat64(res)
|
||||
return sum
|
||||
}
|
||||
|
||||
// Avg 平均值
|
||||
func (that *HoTimeDB) Avg(table string, column string, qu ...interface{}) float64 {
|
||||
var req = []interface{}{}
|
||||
if len(qu) == 2 {
|
||||
req = append(req, qu[0])
|
||||
req = append(req, "AVG("+column+")")
|
||||
req = append(req, qu[1])
|
||||
} else {
|
||||
req = append(req, "AVG("+column+")")
|
||||
req = append(req, qu...)
|
||||
}
|
||||
|
||||
data := that.Select(table, req...)
|
||||
if len(data) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
res := ObjToStr(data[0]["AVG("+column+")"])
|
||||
avg := ObjToFloat64(res)
|
||||
return avg
|
||||
}
|
||||
|
||||
// Max 最大值
|
||||
func (that *HoTimeDB) Max(table string, column string, qu ...interface{}) float64 {
|
||||
var req = []interface{}{}
|
||||
if len(qu) == 2 {
|
||||
req = append(req, qu[0])
|
||||
req = append(req, "MAX("+column+")")
|
||||
req = append(req, qu[1])
|
||||
} else {
|
||||
req = append(req, "MAX("+column+")")
|
||||
req = append(req, qu...)
|
||||
}
|
||||
|
||||
data := that.Select(table, req...)
|
||||
if len(data) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
res := ObjToStr(data[0]["MAX("+column+")"])
|
||||
max := ObjToFloat64(res)
|
||||
return max
|
||||
}
|
||||
|
||||
// Min 最小值
|
||||
func (that *HoTimeDB) Min(table string, column string, qu ...interface{}) float64 {
|
||||
var req = []interface{}{}
|
||||
if len(qu) == 2 {
|
||||
req = append(req, qu[0])
|
||||
req = append(req, "MIN("+column+")")
|
||||
req = append(req, qu[1])
|
||||
} else {
|
||||
req = append(req, "MIN("+column+")")
|
||||
req = append(req, qu...)
|
||||
}
|
||||
|
||||
data := that.Select(table, req...)
|
||||
if len(data) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
res := ObjToStr(data[0]["MIN("+column+")"])
|
||||
min := ObjToFloat64(res)
|
||||
return min
|
||||
}
|
||||
-151
@@ -1,151 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// backupSave 保存备份
|
||||
// path: 备份文件路径
|
||||
// tt: 表名
|
||||
// code: 备份类型 0=全部, 1=仅数据, 2=仅DDL
|
||||
func (that *HoTimeDB) backupSave(path string, tt string, code int) {
|
||||
fd, _ := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644)
|
||||
defer fd.Close()
|
||||
|
||||
q := "`"
|
||||
if that.Dialect != nil {
|
||||
q = that.Dialect.QuoteChar()
|
||||
}
|
||||
|
||||
str := "\r\n"
|
||||
if code == 0 || code == 2 {
|
||||
str += that.backupDdl(tt)
|
||||
}
|
||||
|
||||
if code == 0 || code == 1 {
|
||||
str += "insert into " + q + tt + q + "\r\n\r\n("
|
||||
str += that.backupCol(tt)
|
||||
}
|
||||
|
||||
_, _ = fd.Write([]byte(str))
|
||||
}
|
||||
|
||||
// backupDdl 备份表结构(DDL)
|
||||
func (that *HoTimeDB) backupDdl(tt string) string {
|
||||
switch that.Type {
|
||||
case "dm", "dameng":
|
||||
return that.backupDdlDM(tt)
|
||||
default:
|
||||
data := that.Query("show create table " + tt)
|
||||
if len(data) == 0 {
|
||||
return ""
|
||||
}
|
||||
return ObjToStr(data[0]["Create Table"]) + ";\r\n\r\n"
|
||||
}
|
||||
}
|
||||
|
||||
// backupDdlDM 达梦数据库 DDL 导出
|
||||
func (that *HoTimeDB) backupDdlDM(tt string) string {
|
||||
cols := that.Query(`SELECT COLUMN_NAME, DATA_TYPE, DATA_LENGTH, DATA_PRECISION, DATA_SCALE, NULLABLE, DATA_DEFAULT FROM USER_TAB_COLUMNS WHERE TABLE_NAME='` + tt + `' ORDER BY COLUMN_ID`)
|
||||
if len(cols) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
ddl := "CREATE TABLE \"" + tt + "\" (\r\n"
|
||||
for i, col := range cols {
|
||||
name := col.GetString("COLUMN_NAME")
|
||||
dtype := col.GetString("DATA_TYPE")
|
||||
nullable := col.GetString("NULLABLE")
|
||||
defVal := col.GetString("DATA_DEFAULT")
|
||||
|
||||
colDef := " \"" + name + "\" " + dtype
|
||||
prec := col.GetInt("DATA_PRECISION")
|
||||
scale := col.GetInt("DATA_SCALE")
|
||||
length := col.GetInt("DATA_LENGTH")
|
||||
if prec > 0 {
|
||||
colDef += "(" + ObjToStr(prec)
|
||||
if scale > 0 {
|
||||
colDef += "," + ObjToStr(scale)
|
||||
}
|
||||
colDef += ")"
|
||||
} else if length > 0 && (strings.Contains(strings.ToUpper(dtype), "CHAR") || strings.Contains(strings.ToUpper(dtype), "BINARY")) {
|
||||
colDef += "(" + ObjToStr(length) + ")"
|
||||
}
|
||||
|
||||
if nullable == "N" {
|
||||
colDef += " NOT NULL"
|
||||
}
|
||||
if defVal != "" {
|
||||
colDef += " DEFAULT " + strings.TrimSpace(defVal)
|
||||
}
|
||||
|
||||
if i < len(cols)-1 {
|
||||
colDef += ","
|
||||
}
|
||||
ddl += colDef + "\r\n"
|
||||
}
|
||||
ddl += ");\r\n\r\n"
|
||||
return ddl
|
||||
}
|
||||
|
||||
// backupCol 备份表数据
|
||||
func (that *HoTimeDB) backupCol(tt string) string {
|
||||
q := "`"
|
||||
if that.Dialect != nil {
|
||||
q = that.Dialect.QuoteChar()
|
||||
}
|
||||
|
||||
str := ""
|
||||
data := that.Select(tt, "*")
|
||||
|
||||
lthData := len(data)
|
||||
|
||||
if lthData == 0 {
|
||||
return str
|
||||
}
|
||||
|
||||
lthCol := len(data[0])
|
||||
col := make([]string, lthCol)
|
||||
tempLthData := 0
|
||||
|
||||
for k := range data[0] {
|
||||
if tempLthData == lthCol-1 {
|
||||
str += q + k + q + ") "
|
||||
} else {
|
||||
str += q + k + q + ","
|
||||
}
|
||||
col[tempLthData] = k
|
||||
tempLthData++
|
||||
}
|
||||
|
||||
str += " values"
|
||||
|
||||
for j := 0; j < lthData; j++ {
|
||||
for m := 0; m < lthCol; m++ {
|
||||
if m == 0 {
|
||||
str += "("
|
||||
}
|
||||
|
||||
v := "NULL"
|
||||
if data[j][col[m]] != nil {
|
||||
v = "'" + strings.Replace(ObjToStr(data[j][col[m]]), "'", `\'`, -1) + "'"
|
||||
}
|
||||
|
||||
if m == lthCol-1 {
|
||||
str += v + ") "
|
||||
} else {
|
||||
str += v + ","
|
||||
}
|
||||
}
|
||||
|
||||
if j == lthData-1 {
|
||||
str += ";\r\n\r\n"
|
||||
} else {
|
||||
str += ",\r\n\r\n"
|
||||
}
|
||||
}
|
||||
|
||||
return str
|
||||
}
|
||||
-312
@@ -1,312 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
// HotimeDBBuilder 链式查询构建器
|
||||
type HotimeDBBuilder struct {
|
||||
HoTimeDB *HoTimeDB
|
||||
table string
|
||||
selects []interface{}
|
||||
join Slice
|
||||
where Map
|
||||
lastWhere Map
|
||||
page int
|
||||
pageRow int
|
||||
}
|
||||
|
||||
// Table 创建链式查询构建器
|
||||
func (that *HoTimeDB) Table(table string) *HotimeDBBuilder {
|
||||
return &HotimeDBBuilder{HoTimeDB: that, table: table, where: Map{}}
|
||||
}
|
||||
|
||||
// Get 获取单条记录
|
||||
func (that *HotimeDBBuilder) Get(qu ...interface{}) Map {
|
||||
// 构建参数:根据是否有 JOIN 来决定参数结构
|
||||
var args []interface{}
|
||||
if len(that.join) > 0 {
|
||||
// 有 JOIN 时:join, fields, where
|
||||
if len(qu) > 0 {
|
||||
args = append(args, that.join, qu[0], that.where)
|
||||
} else {
|
||||
args = append(args, that.join, "*", that.where)
|
||||
}
|
||||
} else {
|
||||
// 无 JOIN 时:fields, where
|
||||
if len(qu) > 0 {
|
||||
args = append(args, qu[0], that.where)
|
||||
} else {
|
||||
args = append(args, "*", that.where)
|
||||
}
|
||||
}
|
||||
return that.HoTimeDB.Get(that.table, args...)
|
||||
}
|
||||
|
||||
// Count 统计数量
|
||||
func (that *HotimeDBBuilder) Count() int {
|
||||
// 构建参数:根据是否有 JOIN 来决定参数结构
|
||||
if len(that.join) > 0 {
|
||||
return that.HoTimeDB.Count(that.table, that.join, that.where)
|
||||
}
|
||||
return that.HoTimeDB.Count(that.table, that.where)
|
||||
}
|
||||
|
||||
// Page 设置分页
|
||||
func (that *HotimeDBBuilder) Page(page, pageRow int) *HotimeDBBuilder {
|
||||
that.page = page
|
||||
that.pageRow = pageRow
|
||||
return that
|
||||
}
|
||||
|
||||
// Select 查询多条记录
|
||||
func (that *HotimeDBBuilder) Select(qu ...interface{}) []Map {
|
||||
// 构建参数:根据是否有 JOIN 来决定参数结构
|
||||
var args []interface{}
|
||||
if len(that.join) > 0 {
|
||||
// 有 JOIN 时:join, fields, where
|
||||
if len(qu) > 0 {
|
||||
args = append(args, that.join, qu[0], that.where)
|
||||
} else {
|
||||
args = append(args, that.join, "*", that.where)
|
||||
}
|
||||
} else {
|
||||
// 无 JOIN 时:fields, where
|
||||
if len(qu) > 0 {
|
||||
args = append(args, qu[0], that.where)
|
||||
} else {
|
||||
args = append(args, "*", that.where)
|
||||
}
|
||||
}
|
||||
|
||||
if that.page != 0 {
|
||||
return that.HoTimeDB.Page(that.page, that.pageRow).PageSelect(that.table, args...)
|
||||
}
|
||||
return that.HoTimeDB.Select(that.table, args...)
|
||||
}
|
||||
|
||||
// Update 更新记录
|
||||
func (that *HotimeDBBuilder) Update(qu ...interface{}) int64 {
|
||||
lth := len(qu)
|
||||
if lth == 0 {
|
||||
return 0
|
||||
}
|
||||
data := Map{}
|
||||
if lth == 1 {
|
||||
data = ObjToMap(qu[0])
|
||||
}
|
||||
if lth > 1 {
|
||||
for k := 1; k < lth; k++ {
|
||||
data[ObjToStr(qu[k-1])] = qu[k]
|
||||
k++
|
||||
}
|
||||
}
|
||||
|
||||
return that.HoTimeDB.Update(that.table, data, that.where)
|
||||
}
|
||||
|
||||
// Delete 删除记录
|
||||
func (that *HotimeDBBuilder) Delete() int64 {
|
||||
return that.HoTimeDB.Delete(that.table, that.where)
|
||||
}
|
||||
|
||||
// LeftJoin 左连接
|
||||
func (that *HotimeDBBuilder) LeftJoin(table, joinStr string) *HotimeDBBuilder {
|
||||
that.Join(Map{"[>]" + table: joinStr})
|
||||
return that
|
||||
}
|
||||
|
||||
// RightJoin 右连接
|
||||
func (that *HotimeDBBuilder) RightJoin(table, joinStr string) *HotimeDBBuilder {
|
||||
that.Join(Map{"[<]" + table: joinStr})
|
||||
return that
|
||||
}
|
||||
|
||||
// InnerJoin 内连接
|
||||
func (that *HotimeDBBuilder) InnerJoin(table, joinStr string) *HotimeDBBuilder {
|
||||
that.Join(Map{"[><]" + table: joinStr})
|
||||
return that
|
||||
}
|
||||
|
||||
// FullJoin 全连接
|
||||
func (that *HotimeDBBuilder) FullJoin(table, joinStr string) *HotimeDBBuilder {
|
||||
that.Join(Map{"[<>]" + table: joinStr})
|
||||
return that
|
||||
}
|
||||
|
||||
// Join 通用连接
|
||||
func (that *HotimeDBBuilder) Join(qu ...interface{}) *HotimeDBBuilder {
|
||||
lth := len(qu)
|
||||
if lth == 0 {
|
||||
return that
|
||||
}
|
||||
data := Map{}
|
||||
if lth == 1 {
|
||||
data = ObjToMap(qu[0])
|
||||
}
|
||||
if lth > 1 {
|
||||
for k := 1; k < lth; k++ {
|
||||
data[ObjToStr(qu[k-1])] = qu[k]
|
||||
k++
|
||||
}
|
||||
}
|
||||
|
||||
if that.join == nil {
|
||||
that.join = Slice{}
|
||||
}
|
||||
if data == nil {
|
||||
return that
|
||||
}
|
||||
|
||||
that.join = append(that.join, data)
|
||||
return that
|
||||
}
|
||||
|
||||
// And 添加 AND 条件
|
||||
func (that *HotimeDBBuilder) And(qu ...interface{}) *HotimeDBBuilder {
|
||||
lth := len(qu)
|
||||
if lth == 0 {
|
||||
return that
|
||||
}
|
||||
var where Map
|
||||
if lth == 1 {
|
||||
where = ObjToMap(qu[0])
|
||||
}
|
||||
if lth > 1 {
|
||||
where = Map{}
|
||||
for k := 1; k < lth; k++ {
|
||||
where[ObjToStr(qu[k-1])] = qu[k]
|
||||
k++
|
||||
}
|
||||
}
|
||||
|
||||
if where == nil {
|
||||
return that
|
||||
}
|
||||
|
||||
if that.lastWhere != nil {
|
||||
that.lastWhere["AND"] = where
|
||||
that.lastWhere = where
|
||||
return that
|
||||
}
|
||||
that.lastWhere = where
|
||||
that.where = Map{"AND": where}
|
||||
|
||||
return that
|
||||
}
|
||||
|
||||
// Or 添加 OR 条件
|
||||
func (that *HotimeDBBuilder) Or(qu ...interface{}) *HotimeDBBuilder {
|
||||
lth := len(qu)
|
||||
if lth == 0 {
|
||||
return that
|
||||
}
|
||||
var where Map
|
||||
if lth == 1 {
|
||||
where = ObjToMap(qu[0])
|
||||
}
|
||||
if lth > 1 {
|
||||
where = Map{}
|
||||
for k := 1; k < lth; k++ {
|
||||
where[ObjToStr(qu[k-1])] = qu[k]
|
||||
k++
|
||||
}
|
||||
}
|
||||
if where == nil {
|
||||
return that
|
||||
}
|
||||
|
||||
if that.lastWhere != nil {
|
||||
that.lastWhere["OR"] = where
|
||||
that.lastWhere = where
|
||||
return that
|
||||
}
|
||||
that.lastWhere = where
|
||||
that.where = Map{"Or": where}
|
||||
|
||||
return that
|
||||
}
|
||||
|
||||
// Where 设置 WHERE 条件
|
||||
func (that *HotimeDBBuilder) Where(qu ...interface{}) *HotimeDBBuilder {
|
||||
lth := len(qu)
|
||||
if lth == 0 {
|
||||
return that
|
||||
}
|
||||
var where Map
|
||||
if lth == 1 {
|
||||
where = ObjToMap(qu[0])
|
||||
}
|
||||
if lth > 1 {
|
||||
where = Map{}
|
||||
for k := 1; k < lth; k++ {
|
||||
where[ObjToStr(qu[k-1])] = qu[k]
|
||||
k++
|
||||
}
|
||||
}
|
||||
|
||||
if where == nil {
|
||||
return that
|
||||
}
|
||||
|
||||
if that.lastWhere != nil {
|
||||
that.lastWhere["AND"] = where
|
||||
that.lastWhere = where
|
||||
return that
|
||||
}
|
||||
that.lastWhere = where
|
||||
that.where = Map{"AND": that.lastWhere}
|
||||
|
||||
return that
|
||||
}
|
||||
|
||||
// From 设置表名
|
||||
func (that *HotimeDBBuilder) From(table string) *HotimeDBBuilder {
|
||||
that.table = table
|
||||
return that
|
||||
}
|
||||
|
||||
// Order 设置排序
|
||||
func (that *HotimeDBBuilder) Order(qu ...interface{}) *HotimeDBBuilder {
|
||||
that.where["ORDER"] = ObjToSlice(qu)
|
||||
return that
|
||||
}
|
||||
|
||||
// Limit 设置限制
|
||||
func (that *HotimeDBBuilder) Limit(qu ...interface{}) *HotimeDBBuilder {
|
||||
that.where["LIMIT"] = ObjToSlice(qu)
|
||||
return that
|
||||
}
|
||||
|
||||
// Group 设置分组
|
||||
func (that *HotimeDBBuilder) Group(qu ...interface{}) *HotimeDBBuilder {
|
||||
that.where["GROUP"] = ObjToSlice(qu)
|
||||
return that
|
||||
}
|
||||
|
||||
// Having 设置 HAVING 条件
|
||||
func (that *HotimeDBBuilder) Having(qu ...interface{}) *HotimeDBBuilder {
|
||||
lth := len(qu)
|
||||
if lth == 0 {
|
||||
return that
|
||||
}
|
||||
var having Map
|
||||
if lth == 1 {
|
||||
having = ObjToMap(qu[0])
|
||||
}
|
||||
if lth > 1 {
|
||||
having = Map{}
|
||||
for k := 1; k < lth; k++ {
|
||||
having[ObjToStr(qu[k-1])] = qu[k]
|
||||
k++
|
||||
}
|
||||
}
|
||||
that.where["HAVING"] = having
|
||||
return that
|
||||
}
|
||||
|
||||
// Offset 设置偏移量
|
||||
func (that *HotimeDBBuilder) Offset(offset int) *HotimeDBBuilder {
|
||||
that.where["OFFSET"] = offset
|
||||
return that
|
||||
}
|
||||
-740
@@ -1,740 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
func (that *HoTimeDB) isCacheTable(table string) bool {
|
||||
return table == "cached" || strings.HasPrefix(table, "hotime_cache")
|
||||
}
|
||||
|
||||
// Page 设置分页参数
|
||||
// page: 页码(从1开始)
|
||||
// pageRow: 每页数量
|
||||
func (that *HoTimeDB) Page(page, pageRow int) *HoTimeDB {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageRow < 1 {
|
||||
pageRow = 10
|
||||
}
|
||||
offset := (page - 1) * pageRow
|
||||
|
||||
that.limitMu.Lock()
|
||||
that.limit = Slice{offset, pageRow}
|
||||
that.limitMu.Unlock()
|
||||
return that
|
||||
}
|
||||
|
||||
// PageSelect 分页查询
|
||||
func (that *HoTimeDB) PageSelect(table string, qu ...interface{}) []Map {
|
||||
that.limitMu.Lock()
|
||||
limit := that.limit
|
||||
that.limit = nil // 使用后清空,避免影响下次调用
|
||||
that.limitMu.Unlock()
|
||||
|
||||
if limit == nil {
|
||||
return that.Select(table, qu...)
|
||||
}
|
||||
|
||||
// 根据参数数量处理 LIMIT 注入
|
||||
switch len(qu) {
|
||||
case 0:
|
||||
// PageSelect("user") -> 只有表名,添加 LIMIT
|
||||
qu = append(qu, "*", Map{"LIMIT": limit})
|
||||
case 1:
|
||||
// PageSelect("user", "*") 或 PageSelect("user", Map{...})
|
||||
if reflect.ValueOf(qu[0]).Kind() == reflect.Map {
|
||||
// 是 where 条件
|
||||
temp := DeepCopyMap(qu[0]).(Map)
|
||||
temp["LIMIT"] = limit
|
||||
qu[0] = temp
|
||||
} else {
|
||||
// 是字段选择
|
||||
qu = append(qu, Map{"LIMIT": limit})
|
||||
}
|
||||
case 2:
|
||||
// PageSelect("user", "*", Map{...}) 或 PageSelect("user", joinSlice, "*")
|
||||
if reflect.ValueOf(qu[1]).Kind() == reflect.Map {
|
||||
temp := DeepCopyMap(qu[1]).(Map)
|
||||
temp["LIMIT"] = limit
|
||||
qu[1] = temp
|
||||
} else {
|
||||
// join 模式,需要追加 where
|
||||
qu = append(qu, Map{"LIMIT": limit})
|
||||
}
|
||||
case 3:
|
||||
// PageSelect("user", joinSlice, "*", Map{...})
|
||||
temp := DeepCopyMap(qu[2]).(Map)
|
||||
temp["LIMIT"] = limit
|
||||
qu[2] = temp
|
||||
}
|
||||
|
||||
return that.Select(table, qu...)
|
||||
}
|
||||
|
||||
// Select 查询多条记录
|
||||
func (that *HoTimeDB) Select(table string, qu ...interface{}) []Map {
|
||||
query := "SELECT"
|
||||
where := Map{}
|
||||
qs := make([]interface{}, 0)
|
||||
intQs, intWhere := 0, 1
|
||||
join := false
|
||||
|
||||
if len(qu) == 3 {
|
||||
intQs = 1
|
||||
intWhere = 2
|
||||
join = true
|
||||
}
|
||||
|
||||
processor := that.GetProcessor()
|
||||
|
||||
if len(qu) > 0 {
|
||||
if reflect.ValueOf(qu[intQs]).Type().String() == "string" {
|
||||
// 字段列表字符串,使用处理器处理 table.column 格式
|
||||
fieldStr := qu[intQs].(string)
|
||||
if fieldStr != "*" {
|
||||
fieldStr = processor.ProcessFieldList(fieldStr)
|
||||
}
|
||||
query += " " + fieldStr
|
||||
} else {
|
||||
data := ObjToSlice(qu[intQs])
|
||||
for i := 0; i < len(data); i++ {
|
||||
k := data.GetString(i)
|
||||
if strings.Contains(k, " AS ") || strings.Contains(k, ".") {
|
||||
// 处理 table.column 格式
|
||||
query += " " + processor.ProcessFieldList(k) + " "
|
||||
} else {
|
||||
// 单独的列名
|
||||
query += " " + processor.ProcessColumnNoPrefix(k) + " "
|
||||
}
|
||||
|
||||
if i+1 != len(data) {
|
||||
query = query + ", "
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
query += " *"
|
||||
}
|
||||
|
||||
// 处理表名(添加前缀和正确的引号)
|
||||
query += " FROM " + processor.ProcessTableName(table) + " "
|
||||
|
||||
if join {
|
||||
query += that.buildJoin(qu[0])
|
||||
}
|
||||
|
||||
if len(qu) > 1 {
|
||||
where = qu[intWhere].(Map)
|
||||
}
|
||||
|
||||
temp, resWhere := that.where(where)
|
||||
|
||||
query += temp + ";"
|
||||
qs = append(qs, resWhere...)
|
||||
md5 := that.md5(query, qs...)
|
||||
|
||||
if that.testTx == nil && that.HoTimeCache != nil && !that.isCacheTable(table) {
|
||||
cacheData := that.HoTimeCache.Db(table + ":" + md5)
|
||||
if cacheData != nil && cacheData.Data != nil {
|
||||
return cacheData.ToMapArray()
|
||||
}
|
||||
}
|
||||
|
||||
res := that.Query(query, qs...)
|
||||
|
||||
if res == nil {
|
||||
res = []Map{}
|
||||
}
|
||||
|
||||
if that.testTx == nil && that.HoTimeCache != nil && !that.isCacheTable(table) {
|
||||
_ = that.HoTimeCache.Db(table+":"+md5, res)
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// buildJoin 构建 JOIN 语句
|
||||
func (that *HoTimeDB) buildJoin(joinData interface{}) string {
|
||||
query := ""
|
||||
var testQu = []string{}
|
||||
testQuData := Map{}
|
||||
processor := that.GetProcessor()
|
||||
|
||||
if reflect.ValueOf(joinData).Type().String() == "common.Map" {
|
||||
testQuData = joinData.(Map)
|
||||
for key := range testQuData {
|
||||
testQu = append(testQu, key)
|
||||
}
|
||||
}
|
||||
|
||||
if reflect.ValueOf(joinData).Type().String() == "common.Slice" || strings.Contains(reflect.ValueOf(joinData).Type().String(), "[]") {
|
||||
qu0 := ObjToSlice(joinData)
|
||||
for key := range qu0 {
|
||||
v := qu0.GetMap(key)
|
||||
for k1, v1 := range v {
|
||||
testQu = append(testQu, k1)
|
||||
testQuData[k1] = v1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(testQu)
|
||||
|
||||
for _, k := range testQu {
|
||||
v := testQuData[k]
|
||||
switch Substr(k, 0, 3) {
|
||||
case "[>]":
|
||||
func() {
|
||||
table := Substr(k, 3, len(k)-3)
|
||||
// 处理表名(添加前缀和正确的引号)
|
||||
table = processor.ProcessTableName(table)
|
||||
// 处理 ON 条件中的 table.column
|
||||
onCondition := processor.ProcessConditionString(v.(string))
|
||||
query += " LEFT JOIN " + table + " ON " + onCondition + " "
|
||||
}()
|
||||
case "[<]":
|
||||
func() {
|
||||
table := Substr(k, 3, len(k)-3)
|
||||
table = processor.ProcessTableName(table)
|
||||
onCondition := processor.ProcessConditionString(v.(string))
|
||||
query += " RIGHT JOIN " + table + " ON " + onCondition + " "
|
||||
}()
|
||||
}
|
||||
switch Substr(k, 0, 4) {
|
||||
case "[<>]":
|
||||
func() {
|
||||
table := Substr(k, 4, len(k)-4)
|
||||
table = processor.ProcessTableName(table)
|
||||
onCondition := processor.ProcessConditionString(v.(string))
|
||||
query += " FULL JOIN " + table + " ON " + onCondition + " "
|
||||
}()
|
||||
case "[><]":
|
||||
func() {
|
||||
table := Substr(k, 4, len(k)-4)
|
||||
table = processor.ProcessTableName(table)
|
||||
onCondition := processor.ProcessConditionString(v.(string))
|
||||
query += " INNER JOIN " + table + " ON " + onCondition + " "
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
// Get 获取单条记录
|
||||
func (that *HoTimeDB) Get(table string, qu ...interface{}) Map {
|
||||
if len(qu) == 0 {
|
||||
// 没有参数时,添加默认字段和 LIMIT
|
||||
qu = append(qu, "*", Map{"LIMIT": 1})
|
||||
} else if len(qu) == 1 {
|
||||
qu = append(qu, Map{"LIMIT": 1})
|
||||
} else if len(qu) == 2 {
|
||||
temp := qu[1].(Map)
|
||||
temp["LIMIT"] = 1
|
||||
qu[1] = temp
|
||||
} else if len(qu) == 3 {
|
||||
temp := qu[2].(Map)
|
||||
temp["LIMIT"] = 1
|
||||
qu[2] = temp
|
||||
}
|
||||
|
||||
data := that.Select(table, qu...)
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
return data[0]
|
||||
}
|
||||
|
||||
// Insert 插入新数据
|
||||
func (that *HoTimeDB) Insert(table string, data map[string]interface{}) int64 {
|
||||
values := make([]interface{}, 0)
|
||||
queryString := " ("
|
||||
valueString := " ("
|
||||
processor := that.GetProcessor()
|
||||
|
||||
lens := len(data)
|
||||
tempLen := 0
|
||||
|
||||
for k, v := range data {
|
||||
tempLen++
|
||||
|
||||
vstr := "?"
|
||||
if Substr(k, len(k)-3, 3) == "[#]" {
|
||||
k = strings.Replace(k, "[#]", "", -1)
|
||||
vstr = ObjToStr(v)
|
||||
if tempLen < lens {
|
||||
queryString += processor.ProcessColumnNoPrefix(k) + ","
|
||||
valueString += vstr + ","
|
||||
} else {
|
||||
queryString += processor.ProcessColumnNoPrefix(k) + ") "
|
||||
valueString += vstr + ");"
|
||||
}
|
||||
} else {
|
||||
values = append(values, v)
|
||||
if tempLen < lens {
|
||||
queryString += processor.ProcessColumnNoPrefix(k) + ","
|
||||
valueString += "?,"
|
||||
} else {
|
||||
queryString += processor.ProcessColumnNoPrefix(k) + ") "
|
||||
valueString += "?);"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query := "INSERT INTO " + processor.ProcessTableName(table) + " " + queryString + "VALUES" + valueString
|
||||
|
||||
id := int64(0)
|
||||
if that.Dialect != nil && !that.Dialect.SupportsLastInsertId() {
|
||||
returningClause := that.Dialect.ReturningClause("id")
|
||||
query = strings.TrimSuffix(query, ";") + returningClause
|
||||
rows := that.Query(query, values...)
|
||||
if len(rows) > 0 {
|
||||
id = rows[0].GetInt64("id")
|
||||
}
|
||||
} else {
|
||||
res, err := that.Exec(query, values...)
|
||||
if err == nil && res != nil {
|
||||
id1, e := res.LastInsertId()
|
||||
if e != nil {
|
||||
dbErr := &DBError{}
|
||||
dbErr.SetError(e)
|
||||
that.lastError.Store(dbErr)
|
||||
}
|
||||
id = id1
|
||||
}
|
||||
}
|
||||
|
||||
// 如果插入成功,删除缓存
|
||||
if id != 0 {
|
||||
if that.HoTimeCache != nil && !that.isCacheTable(table) {
|
||||
_ = that.HoTimeCache.Db(table+"*", nil)
|
||||
}
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
// Inserts 批量插入数据
|
||||
// table: 表名
|
||||
// dataList: 数据列表,每个元素是一个 Map
|
||||
// 返回受影响的行数
|
||||
//
|
||||
// 示例:
|
||||
//
|
||||
// affected := db.Inserts("user", []Map{
|
||||
// {"name": "张三", "age": 25, "email": "zhang@example.com"},
|
||||
// {"name": "李四", "age": 30, "email": "li@example.com"},
|
||||
// {"name": "王五", "age": 28, "email": "wang@example.com"},
|
||||
// })
|
||||
func (that *HoTimeDB) Inserts(table string, dataList []Map) int64 {
|
||||
if len(dataList) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// 从第一条数据提取所有列名(确保顺序一致)
|
||||
columns := make([]string, 0)
|
||||
rawValues := make(map[string]string) // 存储 [#] 标记的直接 SQL 值
|
||||
|
||||
for k := range dataList[0] {
|
||||
realKey := k
|
||||
if Substr(k, len(k)-3, 3) == "[#]" {
|
||||
realKey = strings.Replace(k, "[#]", "", -1)
|
||||
rawValues[realKey] = ObjToStr(dataList[0][k])
|
||||
}
|
||||
columns = append(columns, realKey)
|
||||
}
|
||||
|
||||
// 排序列名以确保一致性
|
||||
sort.Strings(columns)
|
||||
|
||||
processor := that.GetProcessor()
|
||||
|
||||
// 构建列名部分
|
||||
quotedCols := make([]string, len(columns))
|
||||
for i, col := range columns {
|
||||
quotedCols[i] = processor.ProcessColumnNoPrefix(col)
|
||||
}
|
||||
colStr := strings.Join(quotedCols, ", ")
|
||||
|
||||
// 构建每行的占位符和值
|
||||
placeholders := make([]string, len(dataList))
|
||||
values := make([]interface{}, 0, len(dataList)*len(columns))
|
||||
|
||||
for i, data := range dataList {
|
||||
rowPlaceholders := make([]string, len(columns))
|
||||
for j, col := range columns {
|
||||
// 检查是否有 [#] 标记
|
||||
rawKey := col + "[#]"
|
||||
if rawVal, ok := data[rawKey]; ok {
|
||||
// 直接 SQL 表达式
|
||||
rowPlaceholders[j] = ObjToStr(rawVal)
|
||||
} else if _, isRaw := rawValues[col]; isRaw && i == 0 {
|
||||
// 第一条数据中的 [#] 标记
|
||||
rowPlaceholders[j] = rawValues[col]
|
||||
} else if val, ok := data[col]; ok {
|
||||
// 普通值
|
||||
rowPlaceholders[j] = "?"
|
||||
values = append(values, val)
|
||||
} else {
|
||||
// 字段不存在,使用 NULL
|
||||
rowPlaceholders[j] = "NULL"
|
||||
}
|
||||
}
|
||||
placeholders[i] = "(" + strings.Join(rowPlaceholders, ", ") + ")"
|
||||
}
|
||||
|
||||
query := "INSERT INTO " + processor.ProcessTableName(table) + " (" + colStr + ") VALUES " + strings.Join(placeholders, ", ")
|
||||
|
||||
res, err := that.Exec(query, values...)
|
||||
|
||||
rows64 := int64(0)
|
||||
if err == nil && res != nil {
|
||||
rows64, _ = res.RowsAffected()
|
||||
}
|
||||
|
||||
// 如果插入成功,删除缓存
|
||||
if rows64 != 0 {
|
||||
if that.HoTimeCache != nil && !that.isCacheTable(table) {
|
||||
_ = that.HoTimeCache.Db(table+"*", nil)
|
||||
}
|
||||
}
|
||||
|
||||
return rows64
|
||||
}
|
||||
|
||||
// Upsert 插入或更新数据
|
||||
// table: 表名
|
||||
// data: 要插入的数据
|
||||
// uniqueKeys: 唯一键字段(用于冲突检测),支持 Slice{"id"} 或 Slice{"col1", "col2"}
|
||||
// updateColumns: 冲突时要更新的字段(如果为空,则更新所有非唯一键字段)
|
||||
// 返回受影响的行数
|
||||
//
|
||||
// 示例:
|
||||
//
|
||||
// affected := db.Upsert("user",
|
||||
// Map{"id": 1, "name": "张三", "email": "zhang@example.com"},
|
||||
// Slice{"id"}, // 唯一键
|
||||
// Slice{"name", "email"}, // 冲突时更新的字段
|
||||
// )
|
||||
func (that *HoTimeDB) Upsert(table string, data Map, uniqueKeys Slice, updateColumns ...interface{}) int64 {
|
||||
if len(data) == 0 || len(uniqueKeys) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// 转换 uniqueKeys 为 []string
|
||||
uniqueKeyStrs := make([]string, len(uniqueKeys))
|
||||
for i, uk := range uniqueKeys {
|
||||
uniqueKeyStrs[i] = ObjToStr(uk)
|
||||
}
|
||||
|
||||
// 转换 updateColumns 为 []string
|
||||
var updateColumnStrs []string
|
||||
if len(updateColumns) > 0 {
|
||||
// 支持两种调用方式:Upsert(table, data, Slice{"id"}, Slice{"name"}) 或 Upsert(table, data, Slice{"id"}, "name", "email")
|
||||
if slice, ok := updateColumns[0].(Slice); ok {
|
||||
updateColumnStrs = make([]string, len(slice))
|
||||
for i, col := range slice {
|
||||
updateColumnStrs[i] = ObjToStr(col)
|
||||
}
|
||||
} else {
|
||||
updateColumnStrs = make([]string, len(updateColumns))
|
||||
for i, col := range updateColumns {
|
||||
updateColumnStrs[i] = ObjToStr(col)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 收集列和值
|
||||
columns := make([]string, 0, len(data))
|
||||
values := make([]interface{}, 0, len(data))
|
||||
rawValues := make(map[string]string) // 存储 [#] 标记的直接 SQL 值
|
||||
|
||||
for k, v := range data {
|
||||
if Substr(k, len(k)-3, 3) == "[#]" {
|
||||
realKey := strings.Replace(k, "[#]", "", -1)
|
||||
columns = append(columns, realKey)
|
||||
rawValues[realKey] = ObjToStr(v)
|
||||
} else {
|
||||
columns = append(columns, k)
|
||||
values = append(values, v)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有指定更新字段,则更新所有非唯一键字段
|
||||
if len(updateColumnStrs) == 0 {
|
||||
uniqueKeySet := make(map[string]bool)
|
||||
for _, uk := range uniqueKeyStrs {
|
||||
uniqueKeySet[uk] = true
|
||||
}
|
||||
for _, col := range columns {
|
||||
if !uniqueKeySet[col] {
|
||||
updateColumnStrs = append(updateColumnStrs, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 构建 SQL
|
||||
var query string
|
||||
dbType := that.Type
|
||||
if dbType == "" {
|
||||
dbType = "mysql"
|
||||
}
|
||||
|
||||
switch dbType {
|
||||
case "postgres", "postgresql":
|
||||
query = that.buildPostgresUpsert(table, columns, uniqueKeyStrs, updateColumnStrs, rawValues)
|
||||
case "sqlite3", "sqlite":
|
||||
query = that.buildSQLiteUpsert(table, columns, uniqueKeyStrs, updateColumnStrs, rawValues)
|
||||
case "dm", "dameng":
|
||||
query = that.buildDMUpsert(table, columns, uniqueKeyStrs, updateColumnStrs, rawValues)
|
||||
default: // mysql
|
||||
query = that.buildMySQLUpsert(table, columns, uniqueKeyStrs, updateColumnStrs, rawValues)
|
||||
}
|
||||
|
||||
res, err := that.Exec(query, values...)
|
||||
|
||||
rows := int64(0)
|
||||
if err == nil && res != nil {
|
||||
rows, _ = res.RowsAffected()
|
||||
}
|
||||
|
||||
// 清除缓存
|
||||
if rows != 0 {
|
||||
if that.HoTimeCache != nil && !that.isCacheTable(table) {
|
||||
_ = that.HoTimeCache.Db(table+"*", nil)
|
||||
}
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
// buildMySQLUpsert 构建 MySQL 的 Upsert 语句
|
||||
func (that *HoTimeDB) buildMySQLUpsert(table string, columns []string, uniqueKeys []string, updateColumns []string, rawValues map[string]string) string {
|
||||
// INSERT INTO table (col1, col2) VALUES (?, ?)
|
||||
// ON DUPLICATE KEY UPDATE col1 = VALUES(col1), col2 = VALUES(col2)
|
||||
processor := that.GetProcessor()
|
||||
|
||||
quotedCols := make([]string, len(columns))
|
||||
valueParts := make([]string, len(columns))
|
||||
for i, col := range columns {
|
||||
quotedCols[i] = processor.ProcessColumnNoPrefix(col)
|
||||
if raw, ok := rawValues[col]; ok {
|
||||
valueParts[i] = raw
|
||||
} else {
|
||||
valueParts[i] = "?"
|
||||
}
|
||||
}
|
||||
|
||||
updateParts := make([]string, len(updateColumns))
|
||||
for i, col := range updateColumns {
|
||||
quotedCol := processor.ProcessColumnNoPrefix(col)
|
||||
if raw, ok := rawValues[col]; ok {
|
||||
updateParts[i] = quotedCol + " = " + raw
|
||||
} else {
|
||||
updateParts[i] = quotedCol + " = VALUES(" + quotedCol + ")"
|
||||
}
|
||||
}
|
||||
|
||||
return "INSERT INTO " + processor.ProcessTableName(table) + " (" + strings.Join(quotedCols, ", ") +
|
||||
") VALUES (" + strings.Join(valueParts, ", ") +
|
||||
") ON DUPLICATE KEY UPDATE " + strings.Join(updateParts, ", ")
|
||||
}
|
||||
|
||||
// buildPostgresUpsert 构建 PostgreSQL 的 Upsert 语句
|
||||
func (that *HoTimeDB) buildPostgresUpsert(table string, columns []string, uniqueKeys []string, updateColumns []string, rawValues map[string]string) string {
|
||||
// INSERT INTO table (col1, col2) VALUES ($1, $2)
|
||||
// ON CONFLICT (unique_key) DO UPDATE SET col1 = EXCLUDED.col1
|
||||
processor := that.GetProcessor()
|
||||
dialect := that.GetDialect()
|
||||
|
||||
quotedCols := make([]string, len(columns))
|
||||
valueParts := make([]string, len(columns))
|
||||
paramIndex := 1
|
||||
for i, col := range columns {
|
||||
quotedCols[i] = dialect.QuoteIdentifier(col)
|
||||
if raw, ok := rawValues[col]; ok {
|
||||
valueParts[i] = raw
|
||||
} else {
|
||||
valueParts[i] = "$" + ObjToStr(paramIndex)
|
||||
paramIndex++
|
||||
}
|
||||
}
|
||||
|
||||
quotedUniqueKeys := make([]string, len(uniqueKeys))
|
||||
for i, key := range uniqueKeys {
|
||||
quotedUniqueKeys[i] = dialect.QuoteIdentifier(key)
|
||||
}
|
||||
|
||||
updateParts := make([]string, len(updateColumns))
|
||||
for i, col := range updateColumns {
|
||||
quotedCol := dialect.QuoteIdentifier(col)
|
||||
if raw, ok := rawValues[col]; ok {
|
||||
updateParts[i] = quotedCol + " = " + raw
|
||||
} else {
|
||||
updateParts[i] = quotedCol + " = EXCLUDED." + quotedCol
|
||||
}
|
||||
}
|
||||
|
||||
return "INSERT INTO " + processor.ProcessTableName(table) + " (" + strings.Join(quotedCols, ", ") +
|
||||
") VALUES (" + strings.Join(valueParts, ", ") +
|
||||
") ON CONFLICT (" + strings.Join(quotedUniqueKeys, ", ") +
|
||||
") DO UPDATE SET " + strings.Join(updateParts, ", ")
|
||||
}
|
||||
|
||||
// buildSQLiteUpsert 构建 SQLite 的 Upsert 语句
|
||||
func (that *HoTimeDB) buildSQLiteUpsert(table string, columns []string, uniqueKeys []string, updateColumns []string, rawValues map[string]string) string {
|
||||
// INSERT INTO table (col1, col2) VALUES (?, ?)
|
||||
// ON CONFLICT (unique_key) DO UPDATE SET col1 = excluded.col1
|
||||
processor := that.GetProcessor()
|
||||
dialect := that.GetDialect()
|
||||
|
||||
quotedCols := make([]string, len(columns))
|
||||
valueParts := make([]string, len(columns))
|
||||
for i, col := range columns {
|
||||
quotedCols[i] = dialect.QuoteIdentifier(col)
|
||||
if raw, ok := rawValues[col]; ok {
|
||||
valueParts[i] = raw
|
||||
} else {
|
||||
valueParts[i] = "?"
|
||||
}
|
||||
}
|
||||
|
||||
quotedUniqueKeys := make([]string, len(uniqueKeys))
|
||||
for i, key := range uniqueKeys {
|
||||
quotedUniqueKeys[i] = dialect.QuoteIdentifier(key)
|
||||
}
|
||||
|
||||
updateParts := make([]string, len(updateColumns))
|
||||
for i, col := range updateColumns {
|
||||
quotedCol := dialect.QuoteIdentifier(col)
|
||||
if raw, ok := rawValues[col]; ok {
|
||||
updateParts[i] = quotedCol + " = " + raw
|
||||
} else {
|
||||
updateParts[i] = quotedCol + " = excluded." + quotedCol
|
||||
}
|
||||
}
|
||||
|
||||
return "INSERT INTO " + processor.ProcessTableName(table) + " (" + strings.Join(quotedCols, ", ") +
|
||||
") VALUES (" + strings.Join(valueParts, ", ") +
|
||||
") ON CONFLICT (" + strings.Join(quotedUniqueKeys, ", ") +
|
||||
") DO UPDATE SET " + strings.Join(updateParts, ", ")
|
||||
}
|
||||
|
||||
// buildDMUpsert 构建达梦的 Upsert 语句(MERGE INTO)
|
||||
func (that *HoTimeDB) buildDMUpsert(table string, columns []string, uniqueKeys []string, updateColumns []string, rawValues map[string]string) string {
|
||||
processor := that.GetProcessor()
|
||||
dialect := that.GetDialect()
|
||||
quotedTable := processor.ProcessTableName(table)
|
||||
|
||||
srcParts := make([]string, len(columns))
|
||||
for i, col := range columns {
|
||||
if raw, ok := rawValues[col]; ok {
|
||||
srcParts[i] = raw + " AS " + dialect.QuoteIdentifier(col)
|
||||
} else {
|
||||
srcParts[i] = "? AS " + dialect.QuoteIdentifier(col)
|
||||
}
|
||||
}
|
||||
|
||||
onParts := make([]string, len(uniqueKeys))
|
||||
for i, key := range uniqueKeys {
|
||||
qk := dialect.QuoteIdentifier(key)
|
||||
onParts[i] = quotedTable + "." + qk + " = src." + qk
|
||||
}
|
||||
|
||||
updateParts := make([]string, len(updateColumns))
|
||||
for i, col := range updateColumns {
|
||||
qc := dialect.QuoteIdentifier(col)
|
||||
if raw, ok := rawValues[col]; ok {
|
||||
updateParts[i] = qc + " = " + raw
|
||||
} else {
|
||||
updateParts[i] = qc + " = src." + qc
|
||||
}
|
||||
}
|
||||
|
||||
insertCols := make([]string, len(columns))
|
||||
insertVals := make([]string, len(columns))
|
||||
for i, col := range columns {
|
||||
qc := dialect.QuoteIdentifier(col)
|
||||
insertCols[i] = qc
|
||||
insertVals[i] = "src." + qc
|
||||
}
|
||||
|
||||
return "MERGE INTO " + quotedTable + " USING (SELECT " + strings.Join(srcParts, ", ") +
|
||||
") src ON (" + strings.Join(onParts, " AND ") +
|
||||
") WHEN MATCHED THEN UPDATE SET " + strings.Join(updateParts, ", ") +
|
||||
" WHEN NOT MATCHED THEN INSERT (" + strings.Join(insertCols, ", ") +
|
||||
") VALUES (" + strings.Join(insertVals, ", ") + ")"
|
||||
}
|
||||
|
||||
// Update 更新数据
|
||||
func (that *HoTimeDB) Update(table string, data Map, where Map) int64 {
|
||||
processor := that.GetProcessor()
|
||||
query := "UPDATE " + processor.ProcessTableName(table) + " SET "
|
||||
qs := make([]interface{}, 0)
|
||||
tp := len(data)
|
||||
|
||||
for k, v := range data {
|
||||
vstr := "?"
|
||||
if Substr(k, len(k)-3, 3) == "[#]" {
|
||||
k = strings.Replace(k, "[#]", "", -1)
|
||||
vstr = ObjToStr(v)
|
||||
} else {
|
||||
qs = append(qs, v)
|
||||
}
|
||||
query += processor.ProcessColumnNoPrefix(k) + "=" + vstr + " "
|
||||
if tp--; tp != 0 {
|
||||
query += ", "
|
||||
}
|
||||
}
|
||||
|
||||
temp, resWhere := that.where(where)
|
||||
|
||||
query += temp + ";"
|
||||
qs = append(qs, resWhere...)
|
||||
|
||||
res, err := that.Exec(query, qs...)
|
||||
|
||||
rows := int64(0)
|
||||
if err == nil && res != nil {
|
||||
rows, _ = res.RowsAffected()
|
||||
}
|
||||
|
||||
// 如果更新成功,则删除缓存
|
||||
if rows != 0 {
|
||||
if that.HoTimeCache != nil && !that.isCacheTable(table) {
|
||||
_ = that.HoTimeCache.Db(table+"*", nil)
|
||||
}
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
// Delete 删除数据
|
||||
func (that *HoTimeDB) Delete(table string, data map[string]interface{}) int64 {
|
||||
processor := that.GetProcessor()
|
||||
query := "DELETE FROM " + processor.ProcessTableName(table) + " "
|
||||
|
||||
temp, resWhere := that.where(data)
|
||||
query += temp + ";"
|
||||
|
||||
res, err := that.Exec(query, resWhere...)
|
||||
rows := int64(0)
|
||||
if err == nil && res != nil {
|
||||
rows, _ = res.RowsAffected()
|
||||
}
|
||||
|
||||
// 如果删除成功,删除对应缓存
|
||||
if rows != 0 {
|
||||
if that.HoTimeCache != nil && !that.isCacheTable(table) {
|
||||
_ = that.HoTimeCache.Db(table+"*", nil)
|
||||
}
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"code.hoteas.com/golang/hotime/cache"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
"code.hoteas.com/golang/hotime/log"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
_ "gitee.com/chunanyong/dm"
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// DBError 最后一次 SQL 操作的结构化快照
|
||||
// 嵌入框架 Error 类型,与 Obj 等保持一致的错误 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
@@ -1,382 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Dialect 数据库方言接口
|
||||
// 用于处理不同数据库之间的语法差异
|
||||
type Dialect interface {
|
||||
// Quote 对表名/字段名添加引号
|
||||
// MySQL 使用反引号 `name`
|
||||
// PostgreSQL 使用双引号 "name"
|
||||
// SQLite 使用双引号或方括号 "name" 或 [name]
|
||||
Quote(name string) string
|
||||
|
||||
// QuoteIdentifier 处理单个标识符(去除已有引号,添加正确引号)
|
||||
// 输入可能带有反引号或双引号,会先去除再添加正确格式
|
||||
QuoteIdentifier(name string) string
|
||||
|
||||
// QuoteChar 返回引号字符
|
||||
// MySQL: `
|
||||
// PostgreSQL/SQLite: "
|
||||
QuoteChar() string
|
||||
|
||||
// Placeholder 生成占位符
|
||||
// MySQL/SQLite 使用 ?
|
||||
// PostgreSQL 使用 $1, $2, $3...
|
||||
Placeholder(index int) string
|
||||
|
||||
// Placeholders 生成多个占位符,用逗号分隔
|
||||
Placeholders(count int, startIndex int) string
|
||||
|
||||
// SupportsLastInsertId 是否支持 LastInsertId
|
||||
// PostgreSQL 不支持,需要使用 RETURNING
|
||||
SupportsLastInsertId() bool
|
||||
|
||||
// ReturningClause 生成 RETURNING 子句(用于 PostgreSQL)
|
||||
ReturningClause(column string) string
|
||||
|
||||
// UpsertSQL 生成 Upsert 语句
|
||||
// MySQL: INSERT ... ON DUPLICATE KEY UPDATE ...
|
||||
// PostgreSQL: INSERT ... ON CONFLICT ... DO UPDATE SET ...
|
||||
// SQLite: INSERT OR REPLACE / INSERT ... ON CONFLICT ...
|
||||
UpsertSQL(table string, columns []string, uniqueKeys []string, updateColumns []string) string
|
||||
|
||||
// GetName 获取方言名称
|
||||
GetName() string
|
||||
}
|
||||
|
||||
// MySQLDialect MySQL 方言实现
|
||||
type MySQLDialect struct{}
|
||||
|
||||
func (d *MySQLDialect) GetName() string {
|
||||
return "mysql"
|
||||
}
|
||||
|
||||
func (d *MySQLDialect) Quote(name string) string {
|
||||
// 如果已经包含点号(表.字段)或空格(别名),不添加引号
|
||||
if strings.Contains(name, ".") || strings.Contains(name, " ") {
|
||||
return name
|
||||
}
|
||||
return "`" + name + "`"
|
||||
}
|
||||
|
||||
func (d *MySQLDialect) QuoteIdentifier(name string) string {
|
||||
// 去除已有的引号(反引号和双引号)
|
||||
name = strings.Trim(name, "`\"")
|
||||
return "`" + name + "`"
|
||||
}
|
||||
|
||||
func (d *MySQLDialect) QuoteChar() string {
|
||||
return "`"
|
||||
}
|
||||
|
||||
func (d *MySQLDialect) Placeholder(index int) string {
|
||||
return "?"
|
||||
}
|
||||
|
||||
func (d *MySQLDialect) Placeholders(count int, startIndex int) string {
|
||||
if count <= 0 {
|
||||
return ""
|
||||
}
|
||||
placeholders := make([]string, count)
|
||||
for i := 0; i < count; i++ {
|
||||
placeholders[i] = "?"
|
||||
}
|
||||
return strings.Join(placeholders, ",")
|
||||
}
|
||||
|
||||
func (d *MySQLDialect) SupportsLastInsertId() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (d *MySQLDialect) ReturningClause(column string) string {
|
||||
return "" // MySQL 不支持 RETURNING
|
||||
}
|
||||
|
||||
func (d *MySQLDialect) UpsertSQL(table string, columns []string, uniqueKeys []string, updateColumns []string) string {
|
||||
// INSERT INTO table (col1, col2) VALUES (?, ?)
|
||||
// ON DUPLICATE KEY UPDATE col1 = VALUES(col1), col2 = VALUES(col2)
|
||||
quotedCols := make([]string, len(columns))
|
||||
for i, col := range columns {
|
||||
quotedCols[i] = d.Quote(col)
|
||||
}
|
||||
|
||||
placeholders := d.Placeholders(len(columns), 1)
|
||||
|
||||
updateParts := make([]string, len(updateColumns))
|
||||
for i, col := range updateColumns {
|
||||
// 检查是否是 [#] 标记的直接 SQL
|
||||
if strings.HasSuffix(col, "[#]") {
|
||||
// 这种情况在调用处处理
|
||||
updateParts[i] = col
|
||||
} else {
|
||||
quotedCol := d.Quote(col)
|
||||
updateParts[i] = quotedCol + " = VALUES(" + quotedCol + ")"
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s) ON DUPLICATE KEY UPDATE %s",
|
||||
d.Quote(table),
|
||||
strings.Join(quotedCols, ", "),
|
||||
placeholders,
|
||||
strings.Join(updateParts, ", "))
|
||||
}
|
||||
|
||||
// PostgreSQLDialect PostgreSQL 方言实现
|
||||
type PostgreSQLDialect struct{}
|
||||
|
||||
func (d *PostgreSQLDialect) GetName() string {
|
||||
return "postgres"
|
||||
}
|
||||
|
||||
func (d *PostgreSQLDialect) Quote(name string) string {
|
||||
// 如果已经包含点号(表.字段)或空格(别名),不添加引号
|
||||
if strings.Contains(name, ".") || strings.Contains(name, " ") {
|
||||
return name
|
||||
}
|
||||
return "\"" + name + "\""
|
||||
}
|
||||
|
||||
func (d *PostgreSQLDialect) QuoteIdentifier(name string) string {
|
||||
// 去除已有的引号(反引号和双引号)
|
||||
name = strings.Trim(name, "`\"")
|
||||
return "\"" + name + "\""
|
||||
}
|
||||
|
||||
func (d *PostgreSQLDialect) QuoteChar() string {
|
||||
return "\""
|
||||
}
|
||||
|
||||
func (d *PostgreSQLDialect) Placeholder(index int) string {
|
||||
return fmt.Sprintf("$%d", index)
|
||||
}
|
||||
|
||||
func (d *PostgreSQLDialect) Placeholders(count int, startIndex int) string {
|
||||
if count <= 0 {
|
||||
return ""
|
||||
}
|
||||
placeholders := make([]string, count)
|
||||
for i := 0; i < count; i++ {
|
||||
placeholders[i] = fmt.Sprintf("$%d", startIndex+i)
|
||||
}
|
||||
return strings.Join(placeholders, ",")
|
||||
}
|
||||
|
||||
func (d *PostgreSQLDialect) SupportsLastInsertId() bool {
|
||||
return false // PostgreSQL 需要使用 RETURNING
|
||||
}
|
||||
|
||||
func (d *PostgreSQLDialect) ReturningClause(column string) string {
|
||||
return " RETURNING " + d.Quote(column)
|
||||
}
|
||||
|
||||
func (d *PostgreSQLDialect) UpsertSQL(table string, columns []string, uniqueKeys []string, updateColumns []string) string {
|
||||
// INSERT INTO table (col1, col2) VALUES ($1, $2)
|
||||
// ON CONFLICT (unique_key) DO UPDATE SET col1 = EXCLUDED.col1, col2 = EXCLUDED.col2
|
||||
quotedCols := make([]string, len(columns))
|
||||
for i, col := range columns {
|
||||
quotedCols[i] = d.Quote(col)
|
||||
}
|
||||
|
||||
placeholders := d.Placeholders(len(columns), 1)
|
||||
|
||||
quotedUniqueKeys := make([]string, len(uniqueKeys))
|
||||
for i, key := range uniqueKeys {
|
||||
quotedUniqueKeys[i] = d.Quote(key)
|
||||
}
|
||||
|
||||
updateParts := make([]string, len(updateColumns))
|
||||
for i, col := range updateColumns {
|
||||
if strings.HasSuffix(col, "[#]") {
|
||||
updateParts[i] = col
|
||||
} else {
|
||||
quotedCol := d.Quote(col)
|
||||
updateParts[i] = quotedCol + " = EXCLUDED." + quotedCol
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s) ON CONFLICT (%s) DO UPDATE SET %s",
|
||||
d.Quote(table),
|
||||
strings.Join(quotedCols, ", "),
|
||||
placeholders,
|
||||
strings.Join(quotedUniqueKeys, ", "),
|
||||
strings.Join(updateParts, ", "))
|
||||
}
|
||||
|
||||
// DMDialect 达梦数据库方言实现
|
||||
type DMDialect struct{}
|
||||
|
||||
func (d *DMDialect) GetName() string {
|
||||
return "dm"
|
||||
}
|
||||
|
||||
func (d *DMDialect) Quote(name string) string {
|
||||
if strings.Contains(name, ".") || strings.Contains(name, " ") {
|
||||
return name
|
||||
}
|
||||
return "\"" + name + "\""
|
||||
}
|
||||
|
||||
func (d *DMDialect) QuoteIdentifier(name string) string {
|
||||
name = strings.Trim(name, "`\"")
|
||||
return "\"" + name + "\""
|
||||
}
|
||||
|
||||
func (d *DMDialect) QuoteChar() string {
|
||||
return "\""
|
||||
}
|
||||
|
||||
func (d *DMDialect) Placeholder(index int) string {
|
||||
return "?"
|
||||
}
|
||||
|
||||
func (d *DMDialect) Placeholders(count int, startIndex int) string {
|
||||
if count <= 0 {
|
||||
return ""
|
||||
}
|
||||
placeholders := make([]string, count)
|
||||
for i := 0; i < count; i++ {
|
||||
placeholders[i] = "?"
|
||||
}
|
||||
return strings.Join(placeholders, ",")
|
||||
}
|
||||
|
||||
func (d *DMDialect) SupportsLastInsertId() bool {
|
||||
return true // DM Go 驱动原生支持 IDENTITY 列的 LastInsertId
|
||||
}
|
||||
|
||||
func (d *DMDialect) ReturningClause(column string) string {
|
||||
return " RETURNING " + d.Quote(column)
|
||||
}
|
||||
|
||||
func (d *DMDialect) UpsertSQL(table string, columns []string, uniqueKeys []string, updateColumns []string) string {
|
||||
// MERGE INTO table USING (SELECT ? AS col1, ? AS col2) src
|
||||
// ON (table.uk = src.uk) WHEN MATCHED THEN UPDATE SET col1 = src.col1
|
||||
// WHEN NOT MATCHED THEN INSERT (col1, col2) VALUES (src.col1, src.col2)
|
||||
quotedTable := d.Quote(table)
|
||||
|
||||
srcParts := make([]string, len(columns))
|
||||
for i, col := range columns {
|
||||
if strings.HasSuffix(col, "[#]") {
|
||||
srcParts[i] = col
|
||||
} else {
|
||||
srcParts[i] = "? AS " + d.Quote(col)
|
||||
}
|
||||
}
|
||||
|
||||
onParts := make([]string, len(uniqueKeys))
|
||||
for i, key := range uniqueKeys {
|
||||
qk := d.Quote(key)
|
||||
onParts[i] = quotedTable + "." + qk + " = src." + qk
|
||||
}
|
||||
|
||||
updateParts := make([]string, len(updateColumns))
|
||||
for i, col := range updateColumns {
|
||||
if strings.HasSuffix(col, "[#]") {
|
||||
updateParts[i] = col
|
||||
} else {
|
||||
qc := d.Quote(col)
|
||||
updateParts[i] = qc + " = src." + qc
|
||||
}
|
||||
}
|
||||
|
||||
insertCols := make([]string, len(columns))
|
||||
insertVals := make([]string, len(columns))
|
||||
for i, col := range columns {
|
||||
qc := d.Quote(col)
|
||||
insertCols[i] = qc
|
||||
insertVals[i] = "src." + qc
|
||||
}
|
||||
|
||||
return fmt.Sprintf("MERGE INTO %s USING (SELECT %s) src ON (%s) WHEN MATCHED THEN UPDATE SET %s WHEN NOT MATCHED THEN INSERT (%s) VALUES (%s)",
|
||||
quotedTable,
|
||||
strings.Join(srcParts, ", "),
|
||||
strings.Join(onParts, " AND "),
|
||||
strings.Join(updateParts, ", "),
|
||||
strings.Join(insertCols, ", "),
|
||||
strings.Join(insertVals, ", "))
|
||||
}
|
||||
|
||||
// SQLiteDialect SQLite 方言实现
|
||||
type SQLiteDialect struct{}
|
||||
|
||||
func (d *SQLiteDialect) GetName() string {
|
||||
return "sqlite3"
|
||||
}
|
||||
|
||||
func (d *SQLiteDialect) Quote(name string) string {
|
||||
// 如果已经包含点号(表.字段)或空格(别名),不添加引号
|
||||
if strings.Contains(name, ".") || strings.Contains(name, " ") {
|
||||
return name
|
||||
}
|
||||
return "\"" + name + "\""
|
||||
}
|
||||
|
||||
func (d *SQLiteDialect) QuoteIdentifier(name string) string {
|
||||
// 去除已有的引号(反引号和双引号)
|
||||
name = strings.Trim(name, "`\"")
|
||||
return "\"" + name + "\""
|
||||
}
|
||||
|
||||
func (d *SQLiteDialect) QuoteChar() string {
|
||||
return "\""
|
||||
}
|
||||
|
||||
func (d *SQLiteDialect) Placeholder(index int) string {
|
||||
return "?"
|
||||
}
|
||||
|
||||
func (d *SQLiteDialect) Placeholders(count int, startIndex int) string {
|
||||
if count <= 0 {
|
||||
return ""
|
||||
}
|
||||
placeholders := make([]string, count)
|
||||
for i := 0; i < count; i++ {
|
||||
placeholders[i] = "?"
|
||||
}
|
||||
return strings.Join(placeholders, ",")
|
||||
}
|
||||
|
||||
func (d *SQLiteDialect) SupportsLastInsertId() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (d *SQLiteDialect) ReturningClause(column string) string {
|
||||
return "" // SQLite 3.35+ 支持 RETURNING,但为兼容性暂不使用
|
||||
}
|
||||
|
||||
func (d *SQLiteDialect) UpsertSQL(table string, columns []string, uniqueKeys []string, updateColumns []string) string {
|
||||
// INSERT INTO table (col1, col2) VALUES (?, ?)
|
||||
// ON CONFLICT (unique_key) DO UPDATE SET col1 = excluded.col1, col2 = excluded.col2
|
||||
quotedCols := make([]string, len(columns))
|
||||
for i, col := range columns {
|
||||
quotedCols[i] = d.Quote(col)
|
||||
}
|
||||
|
||||
placeholders := d.Placeholders(len(columns), 1)
|
||||
|
||||
quotedUniqueKeys := make([]string, len(uniqueKeys))
|
||||
for i, key := range uniqueKeys {
|
||||
quotedUniqueKeys[i] = d.Quote(key)
|
||||
}
|
||||
|
||||
updateParts := make([]string, len(updateColumns))
|
||||
for i, col := range updateColumns {
|
||||
if strings.HasSuffix(col, "[#]") {
|
||||
updateParts[i] = col
|
||||
} else {
|
||||
quotedCol := d.Quote(col)
|
||||
updateParts[i] = quotedCol + " = excluded." + quotedCol
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s) ON CONFLICT (%s) DO UPDATE SET %s",
|
||||
d.Quote(table),
|
||||
strings.Join(quotedCols, ", "),
|
||||
placeholders,
|
||||
strings.Join(quotedUniqueKeys, ", "),
|
||||
strings.Join(updateParts, ", "))
|
||||
}
|
||||
@@ -1,441 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDialectQuoteIdentifier 测试方言的 QuoteIdentifier 方法
|
||||
func TestDialectQuoteIdentifier(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dialect Dialect
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
// MySQL 方言测试
|
||||
{"MySQL simple", &MySQLDialect{}, "name", "`name`"},
|
||||
{"MySQL with backticks", &MySQLDialect{}, "`name`", "`name`"},
|
||||
{"MySQL with quotes", &MySQLDialect{}, "\"name\"", "`name`"},
|
||||
|
||||
// PostgreSQL 方言测试
|
||||
{"PostgreSQL simple", &PostgreSQLDialect{}, "name", "\"name\""},
|
||||
{"PostgreSQL with backticks", &PostgreSQLDialect{}, "`name`", "\"name\""},
|
||||
{"PostgreSQL with quotes", &PostgreSQLDialect{}, "\"name\"", "\"name\""},
|
||||
|
||||
// SQLite 方言测试
|
||||
{"SQLite simple", &SQLiteDialect{}, "name", "\"name\""},
|
||||
{"SQLite with backticks", &SQLiteDialect{}, "`name`", "\"name\""},
|
||||
{"SQLite with quotes", &SQLiteDialect{}, "\"name\"", "\"name\""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.dialect.QuoteIdentifier(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("QuoteIdentifier(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDialectQuoteChar 测试方言的 QuoteChar 方法
|
||||
func TestDialectQuoteChar(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dialect Dialect
|
||||
expected string
|
||||
}{
|
||||
{"MySQL", &MySQLDialect{}, "`"},
|
||||
{"PostgreSQL", &PostgreSQLDialect{}, "\""},
|
||||
{"SQLite", &SQLiteDialect{}, "\""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.dialect.QuoteChar()
|
||||
if result != tt.expected {
|
||||
t.Errorf("QuoteChar() = %q, want %q", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIdentifierProcessorTableName 测试表名处理
|
||||
func TestIdentifierProcessorTableName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dialect Dialect
|
||||
prefix string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
// MySQL 无前缀
|
||||
{"MySQL no prefix", &MySQLDialect{}, "", "order", "`order`"},
|
||||
{"MySQL no prefix with backticks", &MySQLDialect{}, "", "`order`", "`order`"},
|
||||
|
||||
// MySQL 有前缀
|
||||
{"MySQL with prefix", &MySQLDialect{}, "app_", "order", "`app_order`"},
|
||||
{"MySQL with prefix and backticks", &MySQLDialect{}, "app_", "`order`", "`app_order`"},
|
||||
|
||||
// PostgreSQL 无前缀
|
||||
{"PostgreSQL no prefix", &PostgreSQLDialect{}, "", "order", "\"order\""},
|
||||
|
||||
// PostgreSQL 有前缀
|
||||
{"PostgreSQL with prefix", &PostgreSQLDialect{}, "app_", "order", "\"app_order\""},
|
||||
{"PostgreSQL with prefix and quotes", &PostgreSQLDialect{}, "app_", "\"order\"", "\"app_order\""},
|
||||
|
||||
// SQLite 有前缀
|
||||
{"SQLite with prefix", &SQLiteDialect{}, "app_", "user", "\"app_user\""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
processor := NewIdentifierProcessor(tt.dialect, tt.prefix)
|
||||
result := processor.ProcessTableName(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("ProcessTableName(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIdentifierProcessorColumn 测试列名处理(包括 table.column 格式)
|
||||
func TestIdentifierProcessorColumn(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dialect Dialect
|
||||
prefix string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
// 单独列名
|
||||
{"MySQL simple column", &MySQLDialect{}, "", "name", "`name`"},
|
||||
{"MySQL simple column with prefix", &MySQLDialect{}, "app_", "name", "`name`"},
|
||||
|
||||
// table.column 格式
|
||||
{"MySQL table.column no prefix", &MySQLDialect{}, "", "order.name", "`order`.`name`"},
|
||||
{"MySQL table.column with prefix", &MySQLDialect{}, "app_", "order.name", "`app_order`.`name`"},
|
||||
{"MySQL table.column with backticks", &MySQLDialect{}, "app_", "`order`.name", "`app_order`.`name`"},
|
||||
|
||||
// PostgreSQL
|
||||
{"PostgreSQL table.column with prefix", &PostgreSQLDialect{}, "app_", "order.name", "\"app_order\".\"name\""},
|
||||
{"PostgreSQL table.column with quotes", &PostgreSQLDialect{}, "app_", "\"order\".name", "\"app_order\".\"name\""},
|
||||
|
||||
// SQLite
|
||||
{"SQLite table.column with prefix", &SQLiteDialect{}, "app_", "user.email", "\"app_user\".\"email\""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
processor := NewIdentifierProcessor(tt.dialect, tt.prefix)
|
||||
result := processor.ProcessColumn(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("ProcessColumn(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIdentifierProcessorConditionString 测试条件字符串处理
|
||||
func TestIdentifierProcessorConditionString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dialect Dialect
|
||||
prefix string
|
||||
input string
|
||||
contains []string // 结果应该包含这些字符串
|
||||
}{
|
||||
// MySQL 简单条件
|
||||
{
|
||||
"MySQL simple condition",
|
||||
&MySQLDialect{},
|
||||
"app_",
|
||||
"user.id = order.user_id",
|
||||
[]string{"`app_user`", "`app_order`"},
|
||||
},
|
||||
// MySQL 复杂条件
|
||||
{
|
||||
"MySQL complex condition",
|
||||
&MySQLDialect{},
|
||||
"app_",
|
||||
"user.id = order.user_id AND order.status = 1",
|
||||
[]string{"`app_user`", "`app_order`"},
|
||||
},
|
||||
// PostgreSQL
|
||||
{
|
||||
"PostgreSQL condition",
|
||||
&PostgreSQLDialect{},
|
||||
"app_",
|
||||
"user.id = order.user_id",
|
||||
[]string{"\"app_user\"", "\"app_order\""},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
processor := NewIdentifierProcessor(tt.dialect, tt.prefix)
|
||||
result := processor.ProcessConditionString(tt.input)
|
||||
for _, expected := range tt.contains {
|
||||
if !strings.Contains(result, expected) {
|
||||
t.Errorf("ProcessConditionString(%q) = %q, should contain %q", tt.input, result, expected)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHoTimeDBHelperMethods 测试 HoTimeDB 的辅助方法 T() 和 C()
|
||||
func TestHoTimeDBHelperMethods(t *testing.T) {
|
||||
// 创建 MySQL 数据库实例
|
||||
mysqlDB := &HoTimeDB{
|
||||
Type: "mysql",
|
||||
Prefix: "app_",
|
||||
}
|
||||
mysqlDB.initDialect()
|
||||
|
||||
// 测试 T() 方法
|
||||
t.Run("MySQL T() method", func(t *testing.T) {
|
||||
result := mysqlDB.T("order")
|
||||
expected := "`app_order`"
|
||||
if result != expected {
|
||||
t.Errorf("T(\"order\") = %q, want %q", result, expected)
|
||||
}
|
||||
})
|
||||
|
||||
// 测试 C() 方法(两个参数)
|
||||
t.Run("MySQL C() method with two args", func(t *testing.T) {
|
||||
result := mysqlDB.C("order", "name")
|
||||
expected := "`app_order`.`name`"
|
||||
if result != expected {
|
||||
t.Errorf("C(\"order\", \"name\") = %q, want %q", result, expected)
|
||||
}
|
||||
})
|
||||
|
||||
// 测试 C() 方法(一个参数,点号格式)
|
||||
t.Run("MySQL C() method with dot notation", func(t *testing.T) {
|
||||
result := mysqlDB.C("order.name")
|
||||
expected := "`app_order`.`name`"
|
||||
if result != expected {
|
||||
t.Errorf("C(\"order.name\") = %q, want %q", result, expected)
|
||||
}
|
||||
})
|
||||
|
||||
// 创建 PostgreSQL 数据库实例
|
||||
pgDB := &HoTimeDB{
|
||||
Type: "postgres",
|
||||
Prefix: "app_",
|
||||
}
|
||||
pgDB.initDialect()
|
||||
|
||||
// 测试 PostgreSQL 的 T() 方法
|
||||
t.Run("PostgreSQL T() method", func(t *testing.T) {
|
||||
result := pgDB.T("order")
|
||||
expected := "\"app_order\""
|
||||
if result != expected {
|
||||
t.Errorf("T(\"order\") = %q, want %q", result, expected)
|
||||
}
|
||||
})
|
||||
|
||||
// 测试 PostgreSQL 的 C() 方法
|
||||
t.Run("PostgreSQL C() method", func(t *testing.T) {
|
||||
result := pgDB.C("order", "name")
|
||||
expected := "\"app_order\".\"name\""
|
||||
if result != expected {
|
||||
t.Errorf("C(\"order\", \"name\") = %q, want %q", result, expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestWhereWithORCondition 测试 OR 条件处理是否正确添加括号
|
||||
func TestWhereWithORCondition(t *testing.T) {
|
||||
// 创建 MySQL 数据库实例
|
||||
mysqlDB := &HoTimeDB{
|
||||
Type: "mysql",
|
||||
Prefix: "",
|
||||
}
|
||||
mysqlDB.initDialect()
|
||||
|
||||
// 测试 OR 与普通条件组合 (假设 A: 顺序问题)
|
||||
t.Run("OR with normal condition", func(t *testing.T) {
|
||||
data := Map{
|
||||
"OR": Map{
|
||||
"username": "test",
|
||||
"phone": "123",
|
||||
},
|
||||
"state": 0,
|
||||
}
|
||||
|
||||
where, params := mysqlDB.where(data)
|
||||
fmt.Println("Test 1 - OR with normal condition:")
|
||||
fmt.Println(" Generated WHERE:", where)
|
||||
fmt.Println(" Params count:", len(params))
|
||||
|
||||
// 检查 OR 条件是否被括号包裹
|
||||
if !strings.Contains(where, "(") || !strings.Contains(where, ")") {
|
||||
t.Errorf("OR condition should be wrapped with parentheses, got: %s", where)
|
||||
}
|
||||
|
||||
// 检查是否有 AND 连接
|
||||
if !strings.Contains(where, "AND") {
|
||||
t.Errorf("OR condition and normal condition should be connected with AND, got: %s", where)
|
||||
}
|
||||
})
|
||||
|
||||
// 测试纯 OR 条件(无其他普通条件)
|
||||
t.Run("Pure OR condition", func(t *testing.T) {
|
||||
data := Map{
|
||||
"OR": Map{
|
||||
"username": "test",
|
||||
"phone": "123",
|
||||
},
|
||||
}
|
||||
|
||||
where, params := mysqlDB.where(data)
|
||||
fmt.Println("Test 2 - Pure OR condition:")
|
||||
fmt.Println(" Generated WHERE:", where)
|
||||
fmt.Println(" Params count:", len(params))
|
||||
|
||||
// 检查 OR 条件内部应该用 OR 连接
|
||||
if !strings.Contains(where, "OR") {
|
||||
t.Errorf("OR condition should contain OR keyword, got: %s", where)
|
||||
}
|
||||
})
|
||||
|
||||
// 测试多个普通条件与 OR 组合 (假设 A)
|
||||
t.Run("OR with multiple normal conditions", func(t *testing.T) {
|
||||
data := Map{
|
||||
"OR": Map{
|
||||
"username": "test",
|
||||
"phone": "123",
|
||||
},
|
||||
"state": 0,
|
||||
"status": 1,
|
||||
}
|
||||
|
||||
where, params := mysqlDB.where(data)
|
||||
fmt.Println("Test 3 - OR with multiple normal conditions:")
|
||||
fmt.Println(" Generated WHERE:", where)
|
||||
fmt.Println(" Params count:", len(params))
|
||||
|
||||
// 应该有括号
|
||||
if !strings.Contains(where, "(") {
|
||||
t.Errorf("OR condition should be wrapped with parentheses, got: %s", where)
|
||||
}
|
||||
})
|
||||
|
||||
// 测试嵌套 AND/OR 条件 (假设 B, E)
|
||||
t.Run("Nested AND/OR conditions", func(t *testing.T) {
|
||||
data := Map{
|
||||
"OR": Map{
|
||||
"username": "test",
|
||||
"AND": Map{
|
||||
"phone": "123",
|
||||
"status": 1,
|
||||
},
|
||||
},
|
||||
"state": 0,
|
||||
}
|
||||
|
||||
where, params := mysqlDB.where(data)
|
||||
fmt.Println("Test 4 - Nested AND/OR conditions:")
|
||||
fmt.Println(" Generated WHERE:", where)
|
||||
fmt.Println(" Params count:", len(params))
|
||||
})
|
||||
|
||||
// 测试空 OR 条件 (假设 C)
|
||||
t.Run("Empty OR condition", func(t *testing.T) {
|
||||
data := Map{
|
||||
"OR": Map{},
|
||||
"state": 0,
|
||||
}
|
||||
|
||||
where, params := mysqlDB.where(data)
|
||||
fmt.Println("Test 5 - Empty OR condition:")
|
||||
fmt.Println(" Generated WHERE:", where)
|
||||
fmt.Println(" Params count:", len(params))
|
||||
})
|
||||
|
||||
// 测试 OR 与 LIMIT, ORDER 组合 (假设 D)
|
||||
t.Run("OR with LIMIT and ORDER", func(t *testing.T) {
|
||||
data := Map{
|
||||
"OR": Map{
|
||||
"username": "test",
|
||||
"phone": "123",
|
||||
},
|
||||
"state": 0,
|
||||
"ORDER": "id DESC",
|
||||
"LIMIT": 10,
|
||||
}
|
||||
|
||||
where, params := mysqlDB.where(data)
|
||||
fmt.Println("Test 6 - OR with LIMIT and ORDER:")
|
||||
fmt.Println(" Generated WHERE:", where)
|
||||
fmt.Println(" Params count:", len(params))
|
||||
})
|
||||
|
||||
// 测试同时有 OR 和 AND 关键字 (假设 E)
|
||||
t.Run("Both OR and AND keywords", func(t *testing.T) {
|
||||
data := Map{
|
||||
"OR": Map{
|
||||
"username": "test",
|
||||
"phone": "123",
|
||||
},
|
||||
"AND": Map{
|
||||
"type": 1,
|
||||
"source": "web",
|
||||
},
|
||||
"state": 0,
|
||||
}
|
||||
|
||||
where, params := mysqlDB.where(data)
|
||||
fmt.Println("Test 7 - Both OR and AND keywords:")
|
||||
fmt.Println(" Generated WHERE:", where)
|
||||
fmt.Println(" Params count:", len(params))
|
||||
})
|
||||
|
||||
// 测试普通条件在 OR 之前(排序后)(假设 A)
|
||||
t.Run("Normal condition before OR alphabetically", func(t *testing.T) {
|
||||
data := Map{
|
||||
"OR": Map{
|
||||
"username": "test",
|
||||
"phone": "123",
|
||||
},
|
||||
"active": 1, // 'a' 在 'O' 之前
|
||||
}
|
||||
|
||||
where, params := mysqlDB.where(data)
|
||||
fmt.Println("Test 8 - Normal condition before OR (alphabetically):")
|
||||
fmt.Println(" Generated WHERE:", where)
|
||||
fmt.Println(" Params count:", len(params))
|
||||
})
|
||||
}
|
||||
|
||||
// 打印测试结果(用于调试)
|
||||
func ExampleIdentifierProcessor() {
|
||||
// MySQL 示例
|
||||
mysqlProcessor := NewIdentifierProcessor(&MySQLDialect{}, "app_")
|
||||
fmt.Println("MySQL:")
|
||||
fmt.Println(" Table:", mysqlProcessor.ProcessTableName("order"))
|
||||
fmt.Println(" Column:", mysqlProcessor.ProcessColumn("order.name"))
|
||||
fmt.Println(" Condition:", mysqlProcessor.ProcessConditionString("user.id = order.user_id"))
|
||||
|
||||
// PostgreSQL 示例
|
||||
pgProcessor := NewIdentifierProcessor(&PostgreSQLDialect{}, "app_")
|
||||
fmt.Println("PostgreSQL:")
|
||||
fmt.Println(" Table:", pgProcessor.ProcessTableName("order"))
|
||||
fmt.Println(" Column:", pgProcessor.ProcessColumn("order.name"))
|
||||
fmt.Println(" Condition:", pgProcessor.ProcessConditionString("user.id = order.user_id"))
|
||||
|
||||
// Output:
|
||||
// MySQL:
|
||||
// Table: `app_order`
|
||||
// Column: `app_order`.`name`
|
||||
// Condition: `app_user`.`id` = `app_order`.`user_id`
|
||||
// PostgreSQL:
|
||||
// Table: "app_order"
|
||||
// Column: "app_order"."name"
|
||||
// Condition: "app_user"."id" = "app_order"."user_id"
|
||||
}
|
||||
+1032
File diff suppressed because it is too large
Load Diff
@@ -1,260 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IdentifierProcessor 标识符处理器
|
||||
// 用于处理表名、字段名的前缀添加和引号转换
|
||||
type IdentifierProcessor struct {
|
||||
dialect Dialect
|
||||
prefix string
|
||||
}
|
||||
|
||||
// NewIdentifierProcessor 创建标识符处理器
|
||||
func NewIdentifierProcessor(dialect Dialect, prefix string) *IdentifierProcessor {
|
||||
return &IdentifierProcessor{
|
||||
dialect: dialect,
|
||||
prefix: prefix,
|
||||
}
|
||||
}
|
||||
|
||||
// 系统数据库列表,这些数据库不添加前缀
|
||||
var systemDatabases = map[string]bool{
|
||||
"INFORMATION_SCHEMA": true,
|
||||
"information_schema": true,
|
||||
"mysql": true,
|
||||
"performance_schema": true,
|
||||
"sys": true,
|
||||
"pg_catalog": true,
|
||||
"pg_toast": true,
|
||||
}
|
||||
|
||||
// ProcessTableName 处理表名(添加前缀+引号)
|
||||
// 输入: "order" 或 "`order`" 或 "\"order\"" 或 "INFORMATION_SCHEMA.TABLES"
|
||||
// 输出: "`app_order`" (MySQL) 或 "\"app_order\"" (PostgreSQL/SQLite)
|
||||
// 对于 database.table 格式,会分别处理,系统数据库不添加前缀
|
||||
func (p *IdentifierProcessor) ProcessTableName(name string) string {
|
||||
// 去除已有的引号
|
||||
name = p.stripQuotes(name)
|
||||
|
||||
// 检查是否包含空格(别名情况,如 "order AS o")
|
||||
if strings.Contains(name, " ") {
|
||||
// 处理别名情况
|
||||
parts := strings.SplitN(name, " ", 2)
|
||||
tableName := p.stripQuotes(parts[0])
|
||||
alias := parts[1]
|
||||
// 递归处理表名部分(可能包含点号)
|
||||
return p.ProcessTableName(tableName) + " " + alias
|
||||
}
|
||||
|
||||
// 检查是否包含点号(database.table 格式)
|
||||
if strings.Contains(name, ".") {
|
||||
parts := p.splitTableColumn(name)
|
||||
if len(parts) == 2 {
|
||||
dbName := p.stripQuotes(parts[0])
|
||||
tableName := p.stripQuotes(parts[1])
|
||||
// 系统数据库不添加前缀
|
||||
if systemDatabases[dbName] {
|
||||
return p.dialect.QuoteIdentifier(dbName) + "." + p.dialect.QuoteIdentifier(tableName)
|
||||
}
|
||||
// 非系统数据库,只给表名添加前缀
|
||||
return p.dialect.QuoteIdentifier(dbName) + "." + p.dialect.QuoteIdentifier(p.prefix+tableName)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加前缀和引号
|
||||
return p.dialect.QuoteIdentifier(p.prefix + name)
|
||||
}
|
||||
|
||||
// ProcessTableNameNoPrefix 处理表名(只添加引号,不添加前缀)
|
||||
// 用于已经包含前缀的情况
|
||||
func (p *IdentifierProcessor) ProcessTableNameNoPrefix(name string) string {
|
||||
name = p.stripQuotes(name)
|
||||
if strings.Contains(name, " ") {
|
||||
parts := strings.SplitN(name, " ", 2)
|
||||
tableName := p.stripQuotes(parts[0])
|
||||
alias := parts[1]
|
||||
return p.dialect.QuoteIdentifier(tableName) + " " + alias
|
||||
}
|
||||
return p.dialect.QuoteIdentifier(name)
|
||||
}
|
||||
|
||||
// ProcessColumn 处理 table.column 格式
|
||||
// 输入: "name" 或 "order.name" 或 "`order`.name" 或 "`order`.`name`"
|
||||
// 输出: "`name`" 或 "`app_order`.`name`" (MySQL) / "\"app_order\".\"name\"" (DM/PG)
|
||||
func (p *IdentifierProcessor) ProcessColumn(name string) string {
|
||||
if !strings.Contains(name, ".") {
|
||||
return p.dialect.QuoteIdentifier(p.stripQuotes(name))
|
||||
}
|
||||
|
||||
parts := p.splitTableColumn(name)
|
||||
if len(parts) == 2 {
|
||||
tableName := p.stripQuotes(parts[0])
|
||||
columnName := p.stripQuotes(parts[1])
|
||||
return p.dialect.QuoteIdentifier(p.prefix+tableName) + "." + p.dialect.QuoteIdentifier(columnName)
|
||||
}
|
||||
|
||||
return p.convertQuotes(name)
|
||||
}
|
||||
|
||||
// ProcessColumnNoPrefix 处理 table.column 格式(不添加前缀)
|
||||
func (p *IdentifierProcessor) ProcessColumnNoPrefix(name string) string {
|
||||
if !strings.Contains(name, ".") {
|
||||
return p.dialect.QuoteIdentifier(p.stripQuotes(name))
|
||||
}
|
||||
|
||||
parts := p.splitTableColumn(name)
|
||||
if len(parts) == 2 {
|
||||
tableName := p.stripQuotes(parts[0])
|
||||
columnName := p.stripQuotes(parts[1])
|
||||
return p.dialect.QuoteIdentifier(tableName) + "." + p.dialect.QuoteIdentifier(columnName)
|
||||
}
|
||||
|
||||
return p.convertQuotes(name)
|
||||
}
|
||||
|
||||
// ProcessConditionString 智能解析条件字符串(如 ON 条件)
|
||||
// 输入: "user.id = order.user_id AND order.status = 1"
|
||||
// 输出: "`app_user`.`id` = `app_order`.`user_id` AND `app_order`.`status` = 1" (MySQL)
|
||||
// 输出: "\"app_user\".\"id\" = \"app_order\".\"user_id\" AND \"app_order\".\"status\" = 1" (DM/PG)
|
||||
func (p *IdentifierProcessor) ProcessConditionString(condition string) string {
|
||||
if condition == "" {
|
||||
return condition
|
||||
}
|
||||
|
||||
result := condition
|
||||
|
||||
// 首先处理已有完整引号的情况 `table`.`column` 或 "table"."column"
|
||||
fullyQuotedPattern := regexp.MustCompile("[`\"]([a-zA-Z_][a-zA-Z0-9_]*)[`\"]\\.[`\"]([a-zA-Z_][a-zA-Z0-9_]*)[`\"]")
|
||||
result = fullyQuotedPattern.ReplaceAllStringFunc(result, func(match string) string {
|
||||
parts := fullyQuotedPattern.FindStringSubmatch(match)
|
||||
if len(parts) == 3 {
|
||||
tableName := parts[1]
|
||||
colName := parts[2]
|
||||
return p.dialect.QuoteIdentifier(p.prefix+tableName) + "." + p.dialect.QuoteIdentifier(colName)
|
||||
}
|
||||
return match
|
||||
})
|
||||
|
||||
// 然后处理部分引号的情况 `table`.column 或 "table".column
|
||||
quotedTablePattern := regexp.MustCompile("[`\"]([a-zA-Z_][a-zA-Z0-9_]*)[`\"]\\.([a-zA-Z_][a-zA-Z0-9_]*)(?:[^`\"]|$)")
|
||||
result = quotedTablePattern.ReplaceAllStringFunc(result, func(match string) string {
|
||||
parts := quotedTablePattern.FindStringSubmatch(match)
|
||||
if len(parts) >= 3 {
|
||||
tableName := parts[1]
|
||||
colName := parts[2]
|
||||
suffix := ""
|
||||
if len(match) > len(parts[0])-1 {
|
||||
lastChar := match[len(match)-1]
|
||||
if lastChar != '`' && lastChar != '"' && !isIdentChar(lastChar) {
|
||||
suffix = string(lastChar)
|
||||
}
|
||||
}
|
||||
return p.dialect.QuoteIdentifier(p.prefix+tableName) + "." + p.dialect.QuoteIdentifier(colName) + suffix
|
||||
}
|
||||
return match
|
||||
})
|
||||
|
||||
// 最后处理无引号的情况 table.column
|
||||
unquotedPattern := regexp.MustCompile(`([^` + "`" + `"\w]|^)([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)([^` + "`" + `"\w(]|$)`)
|
||||
result = unquotedPattern.ReplaceAllStringFunc(result, func(match string) string {
|
||||
parts := unquotedPattern.FindStringSubmatch(match)
|
||||
if len(parts) >= 5 {
|
||||
prefix := parts[1]
|
||||
tableName := parts[2]
|
||||
colName := parts[3]
|
||||
suffix := parts[4]
|
||||
return prefix + p.dialect.QuoteIdentifier(p.prefix+tableName) + "." + p.dialect.QuoteIdentifier(colName) + suffix
|
||||
}
|
||||
return match
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// isIdentChar 判断是否是标识符字符
|
||||
func isIdentChar(c byte) bool {
|
||||
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'
|
||||
}
|
||||
|
||||
// ProcessFieldList 处理字段列表字符串
|
||||
// 输入: "order.id, user.name AS uname, COUNT(*)"
|
||||
// 输出: "`app_order`.`id`, `app_user`.`name` AS uname, COUNT(*)" (MySQL)
|
||||
func (p *IdentifierProcessor) ProcessFieldList(fields string) string {
|
||||
if fields == "" || fields == "*" {
|
||||
return fields
|
||||
}
|
||||
|
||||
result := p.ProcessConditionString(fields)
|
||||
result = p.convertQuotes(result)
|
||||
return result
|
||||
}
|
||||
|
||||
// stripQuotes 去除标识符两端的引号(反引号或双引号)
|
||||
func (p *IdentifierProcessor) stripQuotes(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
// 去除反引号
|
||||
if strings.HasPrefix(name, "`") && strings.HasSuffix(name, "`") {
|
||||
return name[1 : len(name)-1]
|
||||
}
|
||||
// 去除双引号
|
||||
if strings.HasPrefix(name, "\"") && strings.HasSuffix(name, "\"") {
|
||||
return name[1 : len(name)-1]
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// splitTableColumn 分割 table.column 格式
|
||||
// 支持: table.column, `table`.column, `table`.`column`, "table".column 等
|
||||
func (p *IdentifierProcessor) splitTableColumn(name string) []string {
|
||||
// 先尝试按点号分割
|
||||
dotIndex := -1
|
||||
|
||||
// 查找不在引号内的点号
|
||||
inQuote := false
|
||||
quoteChar := byte(0)
|
||||
for i := 0; i < len(name); i++ {
|
||||
c := name[i]
|
||||
if c == '`' || c == '"' {
|
||||
if !inQuote {
|
||||
inQuote = true
|
||||
quoteChar = c
|
||||
} else if c == quoteChar {
|
||||
inQuote = false
|
||||
}
|
||||
} else if c == '.' && !inQuote {
|
||||
dotIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if dotIndex == -1 {
|
||||
return []string{name}
|
||||
}
|
||||
|
||||
return []string{name[:dotIndex], name[dotIndex+1:]}
|
||||
}
|
||||
|
||||
// convertQuotes 将已有的引号转换为当前方言的引号格式
|
||||
func (p *IdentifierProcessor) convertQuotes(name string) string {
|
||||
quoteChar := p.dialect.QuoteChar()
|
||||
// 替换反引号
|
||||
name = strings.ReplaceAll(name, "`", quoteChar)
|
||||
// 如果目标是反引号,需要替换双引号
|
||||
if quoteChar == "`" {
|
||||
name = strings.ReplaceAll(name, "\"", quoteChar)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// GetDialect 获取方言
|
||||
func (p *IdentifierProcessor) GetDialect() Dialect {
|
||||
return p.dialect
|
||||
}
|
||||
|
||||
// GetPrefix 获取前缀
|
||||
func (p *IdentifierProcessor) GetPrefix() string {
|
||||
return p.prefix
|
||||
}
|
||||
-615
@@ -1,615 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
// md5 生成查询的 MD5 哈希(用于缓存)
|
||||
func (that *HoTimeDB) md5(query string, args ...interface{}) string {
|
||||
strByte, _ := json.Marshal(args)
|
||||
str := Md5(query + ":" + string(strByte))
|
||||
return str
|
||||
}
|
||||
|
||||
// Query 执行查询 SQL
|
||||
func (that *HoTimeDB) Query(query string, args ...interface{}) []Map {
|
||||
return that.queryWithRetry(query, false, args...)
|
||||
}
|
||||
|
||||
// queryWithRetry 内部查询方法,支持重试标记
|
||||
func (that *HoTimeDB) queryWithRetry(query string, retried bool, args ...interface{}) []Map {
|
||||
query, args = that.expandArrayPlaceholder(query, args)
|
||||
|
||||
var sqlErr error
|
||||
|
||||
defer func() {
|
||||
e := &DBError{Query: query, Data: args}
|
||||
e.SetError(sqlErr)
|
||||
that.lastError.Store(e)
|
||||
that.logSQL(query, args, sqlErr)
|
||||
}()
|
||||
|
||||
var resl *sql.Rows
|
||||
|
||||
db := that.DB
|
||||
if that.SlaveDB != nil {
|
||||
db = that.SlaveDB
|
||||
}
|
||||
|
||||
if db == nil {
|
||||
sqlErr = errors.New("没有初始化数据库")
|
||||
return nil
|
||||
}
|
||||
|
||||
processedArgs := that.processArgs(args)
|
||||
|
||||
if that.testTx != nil {
|
||||
if that.testMu != nil {
|
||||
that.testMu.Lock()
|
||||
}
|
||||
resl, sqlErr = that.testTx.Query(query, processedArgs...)
|
||||
if sqlErr != nil {
|
||||
if that.txFailed != nil {
|
||||
*that.txFailed = true
|
||||
}
|
||||
if that.testMu != nil {
|
||||
that.testMu.Unlock()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
result := that.Row(resl)
|
||||
if that.testMu != nil {
|
||||
that.testMu.Unlock()
|
||||
}
|
||||
return result
|
||||
} else if that.Tx != nil {
|
||||
resl, sqlErr = that.Tx.Query(query, processedArgs...)
|
||||
} else {
|
||||
resl, sqlErr = db.Query(query, processedArgs...)
|
||||
}
|
||||
|
||||
if sqlErr != nil {
|
||||
if that.Tx != nil {
|
||||
if that.txFailed != nil {
|
||||
*that.txFailed = true
|
||||
}
|
||||
}
|
||||
if !retried && that.Tx == nil {
|
||||
if pingErr := db.Ping(); pingErr == nil {
|
||||
sqlErr = nil // 重置,让 retry 的 defer 覆盖
|
||||
return that.queryWithRetry(query, true, args...)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return that.Row(resl)
|
||||
}
|
||||
|
||||
// Exec 执行非查询 SQL
|
||||
func (that *HoTimeDB) Exec(query string, args ...interface{}) (sql.Result, error) {
|
||||
return that.execWithRetry(query, false, args...)
|
||||
}
|
||||
|
||||
// execWithRetry 内部执行方法,支持重试标记
|
||||
func (that *HoTimeDB) execWithRetry(query string, retried bool, args ...interface{}) (sql.Result, error) {
|
||||
query, args = that.expandArrayPlaceholder(query, args)
|
||||
|
||||
var sqlErr error
|
||||
var resl sql.Result
|
||||
|
||||
defer func() {
|
||||
e := &DBError{Query: query, Data: args}
|
||||
e.SetError(sqlErr)
|
||||
that.lastError.Store(e)
|
||||
that.logSQL(query, args, sqlErr)
|
||||
}()
|
||||
|
||||
if that.DB == nil {
|
||||
sqlErr = errors.New("没有初始化数据库")
|
||||
return nil, sqlErr
|
||||
}
|
||||
|
||||
processedArgs := that.processArgs(args)
|
||||
|
||||
if that.testTx != nil {
|
||||
if that.testMu != nil {
|
||||
that.testMu.Lock()
|
||||
}
|
||||
resl, sqlErr = that.testTx.Exec(query, processedArgs...)
|
||||
if that.testMu != nil {
|
||||
that.testMu.Unlock()
|
||||
}
|
||||
} else if that.Tx != nil {
|
||||
resl, sqlErr = that.Tx.Exec(query, processedArgs...)
|
||||
} else {
|
||||
resl, sqlErr = that.DB.Exec(query, processedArgs...)
|
||||
}
|
||||
|
||||
if sqlErr != nil {
|
||||
if that.testTx != nil {
|
||||
if that.txFailed != nil {
|
||||
*that.txFailed = true
|
||||
}
|
||||
return resl, sqlErr
|
||||
}
|
||||
if that.Tx != nil {
|
||||
if that.txFailed != nil {
|
||||
*that.txFailed = true
|
||||
}
|
||||
return resl, sqlErr
|
||||
}
|
||||
if !retried {
|
||||
if pingErr := that.DB.Ping(); pingErr == nil {
|
||||
sqlErr = nil
|
||||
return that.execWithRetry(query, true, args...)
|
||||
}
|
||||
}
|
||||
return resl, sqlErr
|
||||
}
|
||||
|
||||
return resl, sqlErr
|
||||
}
|
||||
|
||||
// logSQL 统一 SQL 日志输出(仅此一处打印,消除重复)
|
||||
func (that *HoTimeDB) logSQL(query string, args []interface{}, err error) {
|
||||
if err != nil {
|
||||
msg := fmt.Sprintf("SQL: %s DATA: %v ERROR: %v", query, args, err)
|
||||
that.testLogBufMu.Lock()
|
||||
if that.testLogBuf != nil {
|
||||
that.testLogBuf.WriteString(msg + "\n")
|
||||
atomic.AddInt32(&that.testSQLErrCount, 1)
|
||||
} else if that.Log != nil {
|
||||
that.Log.Error().Msg(msg)
|
||||
}
|
||||
that.testLogBufMu.Unlock()
|
||||
return
|
||||
}
|
||||
// 无错误时只在 logLevel > 0 时打印 DEBUG 级别
|
||||
if that.Log != nil && that.Log.GetLevel() > 0 {
|
||||
msg := fmt.Sprintf("SQL: %s DATA: %v", query, args)
|
||||
that.testLogBufMu.Lock()
|
||||
if that.testLogBuf != nil {
|
||||
that.testLogBuf.WriteString(msg + "\n")
|
||||
} else {
|
||||
that.Log.Debug().Msg(msg)
|
||||
}
|
||||
that.testLogBufMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// processArgs 处理参数中的 slice 类型
|
||||
func (that *HoTimeDB) processArgs(args []interface{}) []interface{} {
|
||||
processedArgs := make([]interface{}, len(args))
|
||||
copy(processedArgs, args)
|
||||
for key := range processedArgs {
|
||||
arg := processedArgs[key]
|
||||
if arg == nil {
|
||||
continue
|
||||
}
|
||||
argType := reflect.ValueOf(arg).Type().String()
|
||||
if strings.Contains(argType, "[]") || strings.Contains(argType, "Slice") {
|
||||
argLis := ObjToSlice(arg)
|
||||
// 将slice转为逗号分割字符串
|
||||
argStr := ""
|
||||
for i := 0; i < len(argLis); i++ {
|
||||
if i == len(argLis)-1 {
|
||||
argStr += ObjToStr(argLis[i])
|
||||
} else {
|
||||
argStr += ObjToStr(argLis[i]) + ","
|
||||
}
|
||||
}
|
||||
processedArgs[key] = argStr
|
||||
}
|
||||
}
|
||||
return processedArgs
|
||||
}
|
||||
|
||||
// expandArrayPlaceholder 展开 IN (?) / NOT IN (?) 中的数组参数
|
||||
// 自动识别 IN/NOT IN (?) 模式,当参数是数组时展开为多个 ?
|
||||
//
|
||||
// 示例:
|
||||
//
|
||||
// db.Query("SELECT * FROM user WHERE id IN (?)", []int{1, 2, 3})
|
||||
// // 展开为: SELECT * FROM user WHERE id IN (?, ?, ?) 参数: [1, 2, 3]
|
||||
//
|
||||
// db.Query("SELECT * FROM user WHERE id IN (?)", []int{})
|
||||
// // 展开为: SELECT * FROM user WHERE 1=0 参数: [] (空集合的IN永假)
|
||||
//
|
||||
// db.Query("SELECT * FROM user WHERE id NOT IN (?)", []int{})
|
||||
// // 展开为: SELECT * FROM user WHERE 1=1 参数: [] (空集合的NOT IN永真)
|
||||
//
|
||||
// db.Query("SELECT * FROM user WHERE id = ?", 1)
|
||||
// // 保持不变: SELECT * FROM user WHERE id = ? 参数: [1]
|
||||
func (that *HoTimeDB) expandArrayPlaceholder(query string, args []interface{}) (string, []interface{}) {
|
||||
if len(args) == 0 || !strings.Contains(query, "?") {
|
||||
return query, args
|
||||
}
|
||||
|
||||
// 检查是否有数组参数
|
||||
hasArray := false
|
||||
for _, arg := range args {
|
||||
if arg == nil {
|
||||
continue
|
||||
}
|
||||
argType := reflect.ValueOf(arg).Type().String()
|
||||
if strings.Contains(argType, "[]") || strings.Contains(argType, "Slice") {
|
||||
hasArray = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasArray {
|
||||
return query, args
|
||||
}
|
||||
|
||||
newArgs := make([]interface{}, 0, len(args))
|
||||
result := strings.Builder{}
|
||||
argIndex := 0
|
||||
|
||||
for i := 0; i < len(query); i++ {
|
||||
if query[i] == '?' && argIndex < len(args) {
|
||||
arg := args[argIndex]
|
||||
argIndex++
|
||||
|
||||
if arg == nil {
|
||||
result.WriteByte('?')
|
||||
newArgs = append(newArgs, nil)
|
||||
continue
|
||||
}
|
||||
|
||||
argType := reflect.ValueOf(arg).Type().String()
|
||||
if strings.Contains(argType, "[]") || strings.Contains(argType, "Slice") {
|
||||
// 是数组参数,检查是否在 IN (...) 或 NOT IN (...) 中
|
||||
prevPart := result.String()
|
||||
prevUpper := strings.ToUpper(prevPart)
|
||||
|
||||
// 查找最近的 NOT IN ( 模式
|
||||
notInIndex := strings.LastIndex(prevUpper, " NOT IN (")
|
||||
notInIndex2 := strings.LastIndex(prevUpper, " NOT IN(")
|
||||
if notInIndex2 > notInIndex {
|
||||
notInIndex = notInIndex2
|
||||
}
|
||||
|
||||
// 查找最近的 IN ( 模式(但要排除 NOT IN 的情况)
|
||||
inIndex := strings.LastIndex(prevUpper, " IN (")
|
||||
inIndex2 := strings.LastIndex(prevUpper, " IN(")
|
||||
if inIndex2 > inIndex {
|
||||
inIndex = inIndex2
|
||||
}
|
||||
|
||||
// 判断是 NOT IN 还是 IN
|
||||
// 注意:" NOT IN (" 包含 " IN (",所以如果找到的 IN 位置在 NOT IN 范围内,应该优先判断为 NOT IN
|
||||
isNotIn := false
|
||||
matchIndex := -1
|
||||
if notInIndex != -1 {
|
||||
// 检查 inIndex 是否在 notInIndex 范围内(即 NOT IN 的 IN 部分)
|
||||
// NOT IN ( 的 IN ( 部分从 notInIndex + 4 开始
|
||||
if inIndex != -1 && inIndex >= notInIndex && inIndex <= notInIndex+5 {
|
||||
// inIndex 是 NOT IN 的一部分,使用 NOT IN
|
||||
isNotIn = true
|
||||
matchIndex = notInIndex
|
||||
} else if inIndex == -1 || notInIndex > inIndex {
|
||||
// 没有独立的 IN,或 NOT IN 在 IN 之后
|
||||
isNotIn = true
|
||||
matchIndex = notInIndex
|
||||
} else {
|
||||
// 有独立的 IN 且在 NOT IN 之后
|
||||
matchIndex = inIndex
|
||||
}
|
||||
} else if inIndex != -1 {
|
||||
matchIndex = inIndex
|
||||
}
|
||||
|
||||
// 检查 IN ( 后面是否只有空格(即当前 ? 紧跟在 IN ( 后面)
|
||||
isInPattern := false
|
||||
if matchIndex != -1 {
|
||||
afterIn := prevPart[matchIndex:]
|
||||
// 找到 ( 的位置
|
||||
parenIdx := strings.Index(afterIn, "(")
|
||||
if parenIdx != -1 {
|
||||
afterParen := strings.TrimSpace(afterIn[parenIdx+1:])
|
||||
if afterParen == "" {
|
||||
isInPattern = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if isInPattern {
|
||||
// 在 IN (...) 或 NOT IN (...) 模式中
|
||||
argList := ObjToSlice(arg)
|
||||
if len(argList) == 0 {
|
||||
// 空数组处理:需要找到字段名的开始位置
|
||||
// 往前找最近的 AND/OR/WHERE/(,以确定条件的开始位置
|
||||
truncateIndex := matchIndex
|
||||
searchPart := prevUpper[:matchIndex]
|
||||
|
||||
// 找最近的分隔符位置
|
||||
andIdx := strings.LastIndex(searchPart, " AND ")
|
||||
orIdx := strings.LastIndex(searchPart, " OR ")
|
||||
whereIdx := strings.LastIndex(searchPart, " WHERE ")
|
||||
parenIdx := strings.LastIndex(searchPart, "(")
|
||||
|
||||
// 取最靠后的分隔符
|
||||
sepIndex := -1
|
||||
sepLen := 0
|
||||
if andIdx > sepIndex {
|
||||
sepIndex = andIdx
|
||||
sepLen = 5 // " AND "
|
||||
}
|
||||
if orIdx > sepIndex {
|
||||
sepIndex = orIdx
|
||||
sepLen = 4 // " OR "
|
||||
}
|
||||
if whereIdx > sepIndex {
|
||||
sepIndex = whereIdx
|
||||
sepLen = 7 // " WHERE "
|
||||
}
|
||||
if parenIdx > sepIndex {
|
||||
sepIndex = parenIdx
|
||||
sepLen = 1 // "("
|
||||
}
|
||||
|
||||
if sepIndex != -1 {
|
||||
truncateIndex = sepIndex + sepLen
|
||||
}
|
||||
|
||||
result.Reset()
|
||||
result.WriteString(prevPart[:truncateIndex])
|
||||
if isNotIn {
|
||||
// NOT IN 空集合 = 永真
|
||||
result.WriteString(" 1=1 ")
|
||||
} else {
|
||||
// IN 空集合 = 永假
|
||||
result.WriteString(" 1=0 ")
|
||||
}
|
||||
// 跳过后面的 )
|
||||
for j := i + 1; j < len(query); j++ {
|
||||
if query[j] == ')' {
|
||||
i = j
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if len(argList) == 1 {
|
||||
// 单元素数组
|
||||
result.WriteByte('?')
|
||||
newArgs = append(newArgs, argList[0])
|
||||
} else {
|
||||
// 多元素数组,展开为多个 ?
|
||||
for j := 0; j < len(argList); j++ {
|
||||
if j > 0 {
|
||||
result.WriteString(", ")
|
||||
}
|
||||
result.WriteByte('?')
|
||||
newArgs = append(newArgs, argList[j])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 不在 IN 模式中,保持原有行为(数组会被 processArgs 转为逗号字符串)
|
||||
result.WriteByte('?')
|
||||
newArgs = append(newArgs, arg)
|
||||
}
|
||||
} else {
|
||||
// 非数组参数
|
||||
result.WriteByte('?')
|
||||
newArgs = append(newArgs, arg)
|
||||
}
|
||||
} else {
|
||||
result.WriteByte(query[i])
|
||||
}
|
||||
}
|
||||
|
||||
return result.String(), newArgs
|
||||
}
|
||||
|
||||
const (
|
||||
dbTypeUnknown = iota
|
||||
dbTypeInteger
|
||||
dbTypeFloat
|
||||
dbTypeDecimal
|
||||
)
|
||||
|
||||
// classifyDBType 将数据库类型名分类,覆盖 MySQL/SQLite/PostgreSQL
|
||||
func classifyDBType(typeName string) int {
|
||||
t := strings.ToUpper(strings.TrimSpace(typeName))
|
||||
t = strings.TrimSuffix(strings.TrimSuffix(t, " UNSIGNED"), " SIGNED")
|
||||
t = strings.TrimSpace(t)
|
||||
base := t
|
||||
if idx := strings.IndexByte(t, '('); idx > 0 {
|
||||
base = t[:idx]
|
||||
}
|
||||
switch base {
|
||||
case "INT", "INTEGER", "BIGINT", "TINYINT", "SMALLINT", "MEDIUMINT",
|
||||
"INT2", "INT4", "INT8",
|
||||
"SERIAL", "BIGSERIAL", "SMALLSERIAL",
|
||||
"YEAR", "BOOL", "BOOLEAN", "OID":
|
||||
return dbTypeInteger
|
||||
case "DECIMAL", "NUMERIC", "NEWDECIMAL", "NUMBER":
|
||||
return dbTypeDecimal
|
||||
case "FLOAT", "DOUBLE", "REAL", "FLOAT4", "FLOAT8", "DOUBLE PRECISION":
|
||||
return dbTypeFloat
|
||||
}
|
||||
if strings.Contains(base, "INT") {
|
||||
return dbTypeInteger
|
||||
}
|
||||
if strings.Contains(base, "DECIMAL") || strings.Contains(base, "NUMERIC") {
|
||||
return dbTypeDecimal
|
||||
}
|
||||
if strings.Contains(base, "DOUBLE") || strings.Contains(base, "FLOAT") {
|
||||
return dbTypeFloat
|
||||
}
|
||||
return dbTypeUnknown
|
||||
}
|
||||
|
||||
// getDecimalScale 提取 DECIMAL/NUMERIC 精度,失败返回 -1
|
||||
func getDecimalScale(colType *sql.ColumnType, typeName string) int {
|
||||
_, scale, ok := colType.DecimalSize()
|
||||
if ok && scale >= 0 {
|
||||
return int(scale)
|
||||
}
|
||||
// SQLite 不支持 DecimalSize,从类型名解析 "DECIMAL(10,2)" → 2
|
||||
upper := strings.ToUpper(typeName)
|
||||
if idx := strings.Index(upper, ","); idx > 0 {
|
||||
rest := upper[idx+1:]
|
||||
if end := strings.IndexByte(rest, ')'); end > 0 {
|
||||
if s, err := strconv.Atoi(strings.TrimSpace(rest[:end])); err == nil {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// getFloatScale 提取 FLOAT/DOUBLE 显式精度,无显式精度返回 -1 表示不截断
|
||||
func getFloatScale(colType *sql.ColumnType, typeName string) int {
|
||||
_, scale, ok := colType.DecimalSize()
|
||||
if ok && scale > 0 && scale < 20 {
|
||||
return int(scale)
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// fixFloatValue 对 float64 值做类型感知精度修正
|
||||
func fixFloatValue(f float64, category int, scale int) interface{} {
|
||||
if math.IsNaN(f) || math.IsInf(f, 0) {
|
||||
return float64(0)
|
||||
}
|
||||
switch category {
|
||||
case dbTypeInteger:
|
||||
return int64(math.Round(f))
|
||||
case dbTypeDecimal:
|
||||
return roundFloat(f, scale)
|
||||
case dbTypeFloat:
|
||||
return roundFloat(f, scale)
|
||||
default:
|
||||
return f
|
||||
}
|
||||
}
|
||||
|
||||
// roundFloat 使用 strconv 精确舍入,避免 f*pow 中间值产生精度误差
|
||||
func roundFloat(f float64, scale int) interface{} {
|
||||
if math.IsNaN(f) || math.IsInf(f, 0) {
|
||||
return float64(0)
|
||||
}
|
||||
if scale == 0 {
|
||||
// DECIMAL(10,0) 明确是整数
|
||||
return math.Round(f)
|
||||
}
|
||||
if scale < 0 {
|
||||
// 精度未知,不修正,原样返回
|
||||
return f
|
||||
}
|
||||
s := strconv.FormatFloat(f, 'f', scale, 64)
|
||||
v, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return f
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// convertBytes 对 []byte 类型的列做类型感知解析
|
||||
// MySQL 文本协议和 PG 的 DECIMAL/NUMERIC 以 []byte 返回,需要按列类型解析为正确 Go 类型
|
||||
func convertBytes(raw []byte, category int, scale int) interface{} {
|
||||
s := string(raw)
|
||||
switch category {
|
||||
case dbTypeInteger:
|
||||
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
return n
|
||||
}
|
||||
if f, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
return int64(math.Round(f))
|
||||
}
|
||||
return s
|
||||
case dbTypeDecimal:
|
||||
if f, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
return roundFloat(f, scale)
|
||||
}
|
||||
return s
|
||||
case dbTypeFloat:
|
||||
if f, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
return roundFloat(f, scale)
|
||||
}
|
||||
return s
|
||||
default:
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
// Row 数据库数据解析
|
||||
func (that *HoTimeDB) Row(resl *sql.Rows) []Map {
|
||||
defer resl.Close()
|
||||
|
||||
dest := make([]Map, 0)
|
||||
strs, _ := resl.Columns()
|
||||
colTypes, _ := resl.ColumnTypes()
|
||||
|
||||
colCount := len(strs)
|
||||
categories := make([]int, colCount)
|
||||
scales := make([]int, colCount)
|
||||
if colTypes != nil {
|
||||
for j := 0; j < len(colTypes) && j < colCount; j++ {
|
||||
typeName := colTypes[j].DatabaseTypeName()
|
||||
categories[j] = classifyDBType(typeName)
|
||||
switch categories[j] {
|
||||
case dbTypeDecimal:
|
||||
scales[j] = getDecimalScale(colTypes[j], typeName)
|
||||
case dbTypeFloat:
|
||||
scales[j] = getFloatScale(colTypes[j], typeName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for resl.Next() {
|
||||
lis := make(Map, colCount)
|
||||
a := make([]interface{}, colCount)
|
||||
b := make([]interface{}, colCount)
|
||||
for j := 0; j < colCount; j++ {
|
||||
b[j] = &a[j]
|
||||
}
|
||||
if err := resl.Scan(b...); err != nil {
|
||||
dbErr := &DBError{}
|
||||
dbErr.SetError(err)
|
||||
that.lastError.Store(dbErr)
|
||||
return nil
|
||||
}
|
||||
for j := 0; j < colCount; j++ {
|
||||
colKey := strs[j]
|
||||
val := a[j]
|
||||
if val == nil {
|
||||
lis[colKey] = nil
|
||||
continue
|
||||
}
|
||||
switch v := val.(type) {
|
||||
case []byte:
|
||||
lis[colKey] = convertBytes(v, categories[j], scales[j])
|
||||
case float64:
|
||||
lis[colKey] = fixFloatValue(v, categories[j], scales[j])
|
||||
case float32:
|
||||
lis[colKey] = fixFloatValue(float64(v), categories[j], scales[j])
|
||||
case time.Time:
|
||||
// DM8驱动直接返回time.Time,格式化为MySQL兼容字符串
|
||||
lis[colKey] = v.Format("2006-01-02 15:04:05")
|
||||
default:
|
||||
lis[colKey] = val
|
||||
}
|
||||
}
|
||||
|
||||
dest = append(dest, lis)
|
||||
}
|
||||
if err := resl.Err(); err != nil {
|
||||
dbErr := &DBError{}
|
||||
dbErr.SetError(err)
|
||||
that.lastError.Store(dbErr)
|
||||
}
|
||||
return dest
|
||||
}
|
||||
-245
@@ -1,245 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// classifyDBType
|
||||
// ─────────────────────────────────────────────
|
||||
func TestClassifyDBType(t *testing.T) {
|
||||
cases := []struct {
|
||||
typeName string
|
||||
want int
|
||||
}{
|
||||
// MySQL 整数类型
|
||||
{"INT", dbTypeInteger},
|
||||
{"TINYINT", dbTypeInteger},
|
||||
{"SMALLINT", dbTypeInteger},
|
||||
{"MEDIUMINT", dbTypeInteger},
|
||||
{"BIGINT", dbTypeInteger},
|
||||
{"INT UNSIGNED", dbTypeInteger},
|
||||
{"BIGINT UNSIGNED", dbTypeInteger},
|
||||
{"TINYINT(1)", dbTypeInteger},
|
||||
{"YEAR", dbTypeInteger},
|
||||
{"BOOL", dbTypeInteger},
|
||||
{"BOOLEAN", dbTypeInteger},
|
||||
|
||||
// PostgreSQL 整数类型
|
||||
{"INT2", dbTypeInteger},
|
||||
{"INT4", dbTypeInteger},
|
||||
{"INT8", dbTypeInteger},
|
||||
{"SERIAL", dbTypeInteger},
|
||||
{"BIGSERIAL", dbTypeInteger},
|
||||
{"SMALLSERIAL", dbTypeInteger},
|
||||
{"OID", dbTypeInteger},
|
||||
|
||||
// DECIMAL / NUMERIC
|
||||
{"DECIMAL", dbTypeDecimal},
|
||||
{"DECIMAL(10,2)", dbTypeDecimal},
|
||||
{"NUMERIC", dbTypeDecimal},
|
||||
{"NUMERIC(8,4)", dbTypeDecimal},
|
||||
{"NEWDECIMAL", dbTypeDecimal},
|
||||
|
||||
// FLOAT / DOUBLE
|
||||
{"FLOAT", dbTypeFloat},
|
||||
{"DOUBLE", dbTypeFloat},
|
||||
{"REAL", dbTypeFloat},
|
||||
{"FLOAT4", dbTypeFloat},
|
||||
{"FLOAT8", dbTypeFloat},
|
||||
{"DOUBLE PRECISION", dbTypeFloat},
|
||||
|
||||
// 未知类型
|
||||
{"VARCHAR", dbTypeUnknown},
|
||||
{"TEXT", dbTypeUnknown},
|
||||
{"DATETIME", dbTypeUnknown},
|
||||
{"TIMESTAMP", dbTypeUnknown},
|
||||
{"BLOB", dbTypeUnknown},
|
||||
{"", dbTypeUnknown},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.typeName, func(t *testing.T) {
|
||||
got := classifyDBType(c.typeName)
|
||||
if got != c.want {
|
||||
t.Errorf("classifyDBType(%q) = %d, want %d", c.typeName, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// fixFloatValue
|
||||
// ─────────────────────────────────────────────
|
||||
func TestFixFloatValue(t *testing.T) {
|
||||
t.Run("integer category: round to int64", func(t *testing.T) {
|
||||
// 这是核心场景:float64 精度噪声导致整数变为 1.9999...
|
||||
got := fixFloatValue(1.9999999999999998, dbTypeInteger, 0)
|
||||
if got != int64(2) {
|
||||
t.Errorf("got %v (%T), want int64(2)", got, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("integer category: 2.0000000000001 rounds to 2", func(t *testing.T) {
|
||||
got := fixFloatValue(2.0000000000001, dbTypeInteger, 0)
|
||||
if got != int64(2) {
|
||||
t.Errorf("got %v (%T), want int64(2)", got, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decimal category: scale=2 roundtrip precision", func(t *testing.T) {
|
||||
got := fixFloatValue(1.1999999999999999, dbTypeDecimal, 2)
|
||||
f, ok := got.(float64)
|
||||
if !ok {
|
||||
t.Fatalf("expected float64, got %T", got)
|
||||
}
|
||||
if f != 1.2 {
|
||||
t.Errorf("got %v, want 1.2", f)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decimal category: scale=2, 1.20000000001 -> 1.2", func(t *testing.T) {
|
||||
got := fixFloatValue(1.20000000001, dbTypeDecimal, 2)
|
||||
f := got.(float64)
|
||||
if f != 1.2 {
|
||||
t.Errorf("got %v, want 1.2", f)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decimal category: scale=-1 (unknown), no modification", func(t *testing.T) {
|
||||
got := fixFloatValue(1.23456789, dbTypeDecimal, -1)
|
||||
if got != 1.23456789 {
|
||||
t.Errorf("got %v, want 1.23456789", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decimal category: scale=0, round to whole number", func(t *testing.T) {
|
||||
got := fixFloatValue(2.7, dbTypeDecimal, 0)
|
||||
if got != float64(3) {
|
||||
t.Errorf("got %v (%T), want float64(3)", got, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("float category: scale=-1 no modification", func(t *testing.T) {
|
||||
got := fixFloatValue(3.14159265, dbTypeFloat, -1)
|
||||
if got != 3.14159265 {
|
||||
t.Errorf("got %v, want 3.14159265", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown category: passthrough", func(t *testing.T) {
|
||||
got := fixFloatValue(99.9, dbTypeUnknown, -1)
|
||||
if got != 99.9 {
|
||||
t.Errorf("got %v, want 99.9", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NaN -> 0", func(t *testing.T) {
|
||||
got := fixFloatValue(math.NaN(), dbTypeInteger, 0)
|
||||
if got != float64(0) {
|
||||
t.Errorf("got %v, want 0", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("+Inf -> 0", func(t *testing.T) {
|
||||
got := fixFloatValue(math.Inf(1), dbTypeDecimal, 2)
|
||||
if got != float64(0) {
|
||||
t.Errorf("got %v, want 0", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// roundFloat
|
||||
// ─────────────────────────────────────────────
|
||||
func TestRoundFloat(t *testing.T) {
|
||||
t.Run("scale=2: 1.19999... -> 1.2", func(t *testing.T) {
|
||||
got := roundFloat(1.1999999999999999, 2)
|
||||
if got != float64(1.2) {
|
||||
t.Errorf("got %v, want 1.2", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("scale=2: 1.20000...1 -> 1.2", func(t *testing.T) {
|
||||
got := roundFloat(1.20000000001, 2)
|
||||
if got != float64(1.2) {
|
||||
t.Errorf("got %v, want 1.2", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("scale=0: round to integer float", func(t *testing.T) {
|
||||
got := roundFloat(2.5, 0)
|
||||
if got != float64(3) {
|
||||
t.Errorf("got %v, want 3.0", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("scale=-1: passthrough unchanged", func(t *testing.T) {
|
||||
got := roundFloat(1.23456789, -1)
|
||||
if got != 1.23456789 {
|
||||
t.Errorf("got %v, want 1.23456789", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NaN -> 0", func(t *testing.T) {
|
||||
got := roundFloat(math.NaN(), 2)
|
||||
if got != float64(0) {
|
||||
t.Errorf("got %v, want 0", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// convertBytes
|
||||
// ─────────────────────────────────────────────
|
||||
func TestConvertBytes(t *testing.T) {
|
||||
t.Run("integer category: '2' -> int64(2)", func(t *testing.T) {
|
||||
got := convertBytes([]byte("2"), dbTypeInteger, 0)
|
||||
if got != int64(2) {
|
||||
t.Errorf("got %v (%T), want int64(2)", got, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("integer category: '2.00' -> int64(2)", func(t *testing.T) {
|
||||
got := convertBytes([]byte("2.00"), dbTypeInteger, 0)
|
||||
if got != int64(2) {
|
||||
t.Errorf("got %v (%T), want int64(2)", got, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decimal category: '1.20' scale=2 -> float64(1.2)", func(t *testing.T) {
|
||||
got := convertBytes([]byte("1.20"), dbTypeDecimal, 2)
|
||||
if got != float64(1.2) {
|
||||
t.Errorf("got %v (%T), want float64(1.2)", got, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decimal category: '0.00' scale=2 -> float64(0)", func(t *testing.T) {
|
||||
got := convertBytes([]byte("0.00"), dbTypeDecimal, 2)
|
||||
if got != float64(0) {
|
||||
t.Errorf("got %v (%T), want float64(0)", got, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("float category: '3.14' scale=-1 -> float64(3.14)", func(t *testing.T) {
|
||||
got := convertBytes([]byte("3.14"), dbTypeFloat, -1)
|
||||
if got != float64(3.14) {
|
||||
t.Errorf("got %v (%T), want float64(3.14)", got, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown category: passthrough as string", func(t *testing.T) {
|
||||
got := convertBytes([]byte("hello"), dbTypeUnknown, 0)
|
||||
if got != "hello" {
|
||||
t.Errorf("got %v (%T), want 'hello'", got, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("integer category: invalid bytes -> string fallback", func(t *testing.T) {
|
||||
got := convertBytes([]byte("abc"), dbTypeInteger, 0)
|
||||
if got != "abc" {
|
||||
t.Errorf("got %v (%T), want 'abc'", got, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
var savepointCounter uint64
|
||||
|
||||
// Action 事务操作
|
||||
// 如果 action 返回 true 则提交事务;返回 false 则回滚
|
||||
// 测试模式下(testTx != nil),使用 SAVEPOINT 代替真事务,确保嵌套事务不会绕过外层测试事务
|
||||
func (that *HoTimeDB) Action(action func(db HoTimeDB) (isSuccess bool)) (isSuccess bool) {
|
||||
that.testLogBufMu.Lock()
|
||||
logBuf := that.testLogBuf
|
||||
that.testLogBufMu.Unlock()
|
||||
db := HoTimeDB{
|
||||
DB: that.DB,
|
||||
ContextBase: that.ContextBase,
|
||||
DBName: that.DBName,
|
||||
HoTimeCache: that.HoTimeCache,
|
||||
Log: that.Log,
|
||||
Type: that.Type,
|
||||
Prefix: that.Prefix,
|
||||
ConnectFunc: that.ConnectFunc,
|
||||
limit: that.limit,
|
||||
Tx: that.Tx,
|
||||
SlaveDB: that.SlaveDB,
|
||||
Dialect: that.Dialect,
|
||||
limitMu: sync.Mutex{},
|
||||
testTx: that.testTx,
|
||||
testMu: that.testMu,
|
||||
testLogBuf: logBuf,
|
||||
}
|
||||
|
||||
txFailed := false
|
||||
db.txFailed = &txFailed
|
||||
|
||||
if that.testTx != nil {
|
||||
spName := fmt.Sprintf("sp_%d", atomic.AddUint64(&savepointCounter, 1))
|
||||
if that.testMu != nil {
|
||||
that.testMu.Lock()
|
||||
}
|
||||
_, _ = that.testTx.Exec("SAVEPOINT " + spName)
|
||||
if that.testMu != nil {
|
||||
that.testMu.Unlock()
|
||||
}
|
||||
db.Tx = that.testTx
|
||||
isSuccess = action(db)
|
||||
if txFailed || !isSuccess {
|
||||
if that.testMu != nil {
|
||||
that.testMu.Lock()
|
||||
}
|
||||
_, _ = that.testTx.Exec("ROLLBACK TO SAVEPOINT " + spName)
|
||||
if that.testMu != nil {
|
||||
that.testMu.Unlock()
|
||||
}
|
||||
return false
|
||||
}
|
||||
if that.testMu != nil {
|
||||
that.testMu.Lock()
|
||||
}
|
||||
_, _ = that.testTx.Exec("RELEASE SAVEPOINT " + spName)
|
||||
if that.testMu != nil {
|
||||
that.testMu.Unlock()
|
||||
}
|
||||
return isSuccess
|
||||
}
|
||||
|
||||
tx, err := db.Begin()
|
||||
|
||||
if err != nil {
|
||||
dbErr := &DBError{}
|
||||
dbErr.SetError(err)
|
||||
that.lastError.Store(dbErr)
|
||||
return isSuccess
|
||||
}
|
||||
|
||||
db.Tx = tx
|
||||
|
||||
isSuccess = action(db)
|
||||
|
||||
if txFailed {
|
||||
_ = db.Tx.Rollback()
|
||||
return false
|
||||
}
|
||||
|
||||
if !isSuccess {
|
||||
err = db.Tx.Rollback()
|
||||
if err != nil {
|
||||
dbErr := &DBError{}
|
||||
dbErr.SetError(err)
|
||||
that.lastError.Store(dbErr)
|
||||
return isSuccess
|
||||
}
|
||||
return isSuccess
|
||||
}
|
||||
|
||||
err = db.Tx.Commit()
|
||||
if err != nil {
|
||||
dbErr := &DBError{}
|
||||
dbErr.SetError(err)
|
||||
that.lastError.Store(dbErr)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
"code.hoteas.com/golang/hotime/log"
|
||||
"database/sql"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestDB(t *testing.T) *HoTimeDB {
|
||||
t.Helper()
|
||||
sqlDB, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db := &HoTimeDB{
|
||||
DB: sqlDB,
|
||||
Type: "sqlite3",
|
||||
Dialect: &SQLiteDialect{},
|
||||
Log: log.NewTestLogger(),
|
||||
}
|
||||
_, execErr := sqlDB.Exec("CREATE TABLE test_item (id INTEGER PRIMARY KEY, name TEXT, value INTEGER)")
|
||||
if execErr != nil {
|
||||
t.Fatal(execErr)
|
||||
}
|
||||
_, execErr = sqlDB.Exec("INSERT INTO test_item (id, name, value) VALUES (1, 'foo', 100)")
|
||||
if execErr != nil {
|
||||
t.Fatal(execErr)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func newTestDBWithTestTx(t *testing.T) *HoTimeDB {
|
||||
t.Helper()
|
||||
db := newTestDB(t)
|
||||
tx, err := db.DB.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.testTx = tx
|
||||
db.testMu = &sync.Mutex{}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestAction_TxFailedForceRollback(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
defer db.DB.Close()
|
||||
|
||||
result := db.Action(func(tx HoTimeDB) (isSuccess bool) {
|
||||
tx.Update("test_item", Map{"value": 999}, Map{"id": 1})
|
||||
|
||||
tx.Exec("THIS IS INVALID SQL THAT WILL FAIL")
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
if result != false {
|
||||
t.Fatalf("Action should return false when SQL error occurred, got true")
|
||||
}
|
||||
|
||||
row := db.Get("test_item", "value", Map{"id": 1})
|
||||
if row == nil {
|
||||
t.Fatal("failed to read test_item")
|
||||
}
|
||||
val := row.GetCeilInt64("value")
|
||||
if val != 100 {
|
||||
t.Fatalf("value should be rolled back to 100, got %d", val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAction_NormalCommit(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
defer db.DB.Close()
|
||||
|
||||
result := db.Action(func(tx HoTimeDB) (isSuccess bool) {
|
||||
tx.Update("test_item", Map{"value": 200}, Map{"id": 1})
|
||||
return true
|
||||
})
|
||||
|
||||
if result != true {
|
||||
t.Fatalf("Action should return true on success, got false")
|
||||
}
|
||||
|
||||
row := db.Get("test_item", "value", Map{"id": 1})
|
||||
if row == nil {
|
||||
t.Fatal("failed to read test_item")
|
||||
}
|
||||
val := row.GetCeilInt64("value")
|
||||
if val != 200 {
|
||||
t.Fatalf("value should be committed to 200, got %d", val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAction_NormalRollback(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
defer db.DB.Close()
|
||||
|
||||
result := db.Action(func(tx HoTimeDB) (isSuccess bool) {
|
||||
tx.Update("test_item", Map{"value": 300}, Map{"id": 1})
|
||||
return false
|
||||
})
|
||||
|
||||
if result != false {
|
||||
t.Fatalf("Action should return false, got true")
|
||||
}
|
||||
|
||||
row := db.Get("test_item", "value", Map{"id": 1})
|
||||
if row == nil {
|
||||
t.Fatal("failed to read test_item")
|
||||
}
|
||||
val := row.GetCeilInt64("value")
|
||||
if val != 100 {
|
||||
t.Fatalf("value should be rolled back to 100, got %d", val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAction_SqlErrorThenReturnTrue_MustRollback(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
defer db.DB.Close()
|
||||
|
||||
result := db.Action(func(tx HoTimeDB) (isSuccess bool) {
|
||||
tx.Update("test_item", Map{"value": 500}, Map{"id": 1})
|
||||
|
||||
tx.Exec("INSERT INTO nonexistent_table (x) VALUES (1)")
|
||||
|
||||
tx.Update("test_item", Map{"value": 600}, Map{"id": 1})
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
if result != false {
|
||||
t.Fatalf("Action should return false when SQL error occurred mid-transaction, got true")
|
||||
}
|
||||
|
||||
row := db.Get("test_item", "value", Map{"id": 1})
|
||||
if row == nil {
|
||||
t.Fatal("failed to read test_item")
|
||||
}
|
||||
val := row.GetCeilInt64("value")
|
||||
if val != 100 {
|
||||
t.Fatalf("value should be rolled back to 100 (original), got %d", val)
|
||||
}
|
||||
}
|
||||
|
||||
// --- testTx (SAVEPOINT) 模式测试 ---
|
||||
|
||||
func TestAction_TestTx_SqlErrorForceRollbackSavepoint(t *testing.T) {
|
||||
db := newTestDBWithTestTx(t)
|
||||
defer db.testTx.Rollback()
|
||||
defer db.DB.Close()
|
||||
|
||||
db.testTx.Exec("UPDATE test_item SET value = 100 WHERE id = 1")
|
||||
|
||||
result := db.Action(func(tx HoTimeDB) (isSuccess bool) {
|
||||
tx.Update("test_item", Map{"value": 888}, Map{"id": 1})
|
||||
|
||||
tx.Exec("THIS IS INVALID SQL")
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
if result != false {
|
||||
t.Fatalf("Action (testTx mode) should return false when SQL error occurred, got true")
|
||||
}
|
||||
|
||||
db.testMu.Lock()
|
||||
rows, err := db.testTx.Query("SELECT value FROM test_item WHERE id = 1")
|
||||
db.testMu.Unlock()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
t.Fatal("no row found")
|
||||
}
|
||||
var val int64
|
||||
rows.Scan(&val)
|
||||
if val != 100 {
|
||||
t.Fatalf("value should be rolled back to 100 via SAVEPOINT, got %d", val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAction_TestTx_NormalCommitSavepoint(t *testing.T) {
|
||||
db := newTestDBWithTestTx(t)
|
||||
defer db.testTx.Rollback()
|
||||
defer db.DB.Close()
|
||||
|
||||
result := db.Action(func(tx HoTimeDB) (isSuccess bool) {
|
||||
tx.Update("test_item", Map{"value": 777}, Map{"id": 1})
|
||||
return true
|
||||
})
|
||||
|
||||
if result != true {
|
||||
t.Fatalf("Action (testTx mode) should return true on success, got false")
|
||||
}
|
||||
|
||||
db.testMu.Lock()
|
||||
rows, err := db.testTx.Query("SELECT value FROM test_item WHERE id = 1")
|
||||
db.testMu.Unlock()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
t.Fatal("no row found")
|
||||
}
|
||||
var val int64
|
||||
rows.Scan(&val)
|
||||
if val != 777 {
|
||||
t.Fatalf("value should be 777 after RELEASE SAVEPOINT, got %d", val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAction_TestTx_NormalRollbackSavepoint(t *testing.T) {
|
||||
db := newTestDBWithTestTx(t)
|
||||
defer db.testTx.Rollback()
|
||||
defer db.DB.Close()
|
||||
|
||||
db.testTx.Exec("UPDATE test_item SET value = 100 WHERE id = 1")
|
||||
|
||||
result := db.Action(func(tx HoTimeDB) (isSuccess bool) {
|
||||
tx.Update("test_item", Map{"value": 666}, Map{"id": 1})
|
||||
return false
|
||||
})
|
||||
|
||||
if result != false {
|
||||
t.Fatalf("Action (testTx mode) should return false, got true")
|
||||
}
|
||||
|
||||
db.testMu.Lock()
|
||||
rows, err := db.testTx.Query("SELECT value FROM test_item WHERE id = 1")
|
||||
db.testMu.Unlock()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
t.Fatal("no row found")
|
||||
}
|
||||
var val int64
|
||||
rows.Scan(&val)
|
||||
if val != 100 {
|
||||
t.Fatalf("value should be rolled back to 100 via SAVEPOINT, got %d", val)
|
||||
}
|
||||
}
|
||||
-555
@@ -1,555 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 条件关键字
|
||||
var condition = []string{"AND", "OR"}
|
||||
|
||||
// 特殊关键字(支持大小写)
|
||||
var vcond = []string{"GROUP", "ORDER", "LIMIT", "DISTINCT", "HAVING", "OFFSET"}
|
||||
|
||||
// normalizeKey 标准化关键字(转大写)
|
||||
func normalizeKey(k string) string {
|
||||
upper := strings.ToUpper(k)
|
||||
for _, v := range vcond {
|
||||
if upper == v {
|
||||
return v
|
||||
}
|
||||
}
|
||||
for _, v := range condition {
|
||||
if upper == v {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// isConditionKey 判断是否是条件关键字
|
||||
func isConditionKey(k string) bool {
|
||||
upper := strings.ToUpper(k)
|
||||
for _, v := range condition {
|
||||
if upper == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isVcondKey 判断是否是特殊关键字
|
||||
func isVcondKey(k string) bool {
|
||||
upper := strings.ToUpper(k)
|
||||
for _, v := range vcond {
|
||||
if upper == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// where 语句解析
|
||||
func (that *HoTimeDB) where(data Map) (string, []interface{}) {
|
||||
where := ""
|
||||
res := make([]interface{}, 0)
|
||||
|
||||
// 标准化 Map 的 key(大小写兼容)
|
||||
normalizedData := Map{}
|
||||
for k, v := range data {
|
||||
normalizedData[normalizeKey(k)] = v
|
||||
}
|
||||
data = normalizedData
|
||||
|
||||
// 收集所有 key 并排序
|
||||
testQu := []string{}
|
||||
for key := range data {
|
||||
testQu = append(testQu, key)
|
||||
}
|
||||
sort.Strings(testQu)
|
||||
|
||||
// 追踪条件数量,用于自动添加 AND
|
||||
condCount := 0
|
||||
|
||||
for _, k := range testQu {
|
||||
v := data[k]
|
||||
|
||||
// 检查是否是 AND/OR 条件关键字
|
||||
if isConditionKey(k) {
|
||||
tw, ts := that.cond(strings.ToUpper(k), v.(Map))
|
||||
if tw != "" && strings.TrimSpace(tw) != "" {
|
||||
// 与前面的条件用 AND 连接
|
||||
if condCount > 0 {
|
||||
where += " AND "
|
||||
}
|
||||
// 用括号包裹 OR/AND 组条件
|
||||
where += "(" + strings.TrimSpace(tw) + ")"
|
||||
condCount++
|
||||
res = append(res, ts...)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否是特殊关键字(GROUP, ORDER, LIMIT 等)
|
||||
if isVcondKey(k) {
|
||||
continue // 特殊关键字在后面单独处理
|
||||
}
|
||||
|
||||
// 处理普通条件字段
|
||||
// 空切片的 IN 条件应该生成永假条件(1=0),而不是跳过
|
||||
if v != nil && reflect.ValueOf(v).Type().String() == "common.Slice" && len(v.(Slice)) == 0 {
|
||||
// 检查是否是 NOT IN(带 [!] 后缀)- NOT IN 空数组永真,跳过即可
|
||||
if !strings.HasSuffix(k, "[!]") {
|
||||
// IN 空数组 -> 生成永假条件
|
||||
if condCount > 0 {
|
||||
where += " AND "
|
||||
}
|
||||
where += "1=0 "
|
||||
condCount++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if v != nil && strings.Contains(reflect.ValueOf(v).Type().String(), "[]") && len(ObjToSlice(v)) == 0 {
|
||||
// 检查是否是 NOT IN(带 [!] 后缀)- NOT IN 空数组永真,跳过即可
|
||||
if !strings.HasSuffix(k, "[!]") {
|
||||
// IN 空数组 -> 生成永假条件
|
||||
if condCount > 0 {
|
||||
where += " AND "
|
||||
}
|
||||
where += "1=0 "
|
||||
condCount++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
tv, vv := that.varCond(k, v)
|
||||
if tv != "" {
|
||||
// 自动添加 AND 连接符
|
||||
if condCount > 0 {
|
||||
where += " AND "
|
||||
}
|
||||
where += tv
|
||||
condCount++
|
||||
res = append(res, vv...)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加 WHERE 关键字
|
||||
// 先去除首尾空格,检查是否有实际条件内容
|
||||
trimmedWhere := strings.TrimSpace(where)
|
||||
if len(trimmedWhere) != 0 {
|
||||
hasWhere := true
|
||||
for _, v := range vcond {
|
||||
if strings.Index(trimmedWhere, v) == 0 {
|
||||
hasWhere = false
|
||||
}
|
||||
}
|
||||
|
||||
if hasWhere {
|
||||
where = " WHERE " + trimmedWhere + " "
|
||||
}
|
||||
} else {
|
||||
// 没有实际条件内容,重置 where
|
||||
where = ""
|
||||
}
|
||||
|
||||
// 处理特殊字符(按固定顺序:GROUP, HAVING, ORDER, LIMIT, OFFSET)
|
||||
specialOrder := []string{"GROUP", "HAVING", "ORDER", "LIMIT", "OFFSET", "DISTINCT"}
|
||||
for _, vcondKey := range specialOrder {
|
||||
v, exists := data[vcondKey]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
switch vcondKey {
|
||||
case "GROUP":
|
||||
where += " GROUP BY "
|
||||
where += that.formatVcondValue(v)
|
||||
case "HAVING":
|
||||
// HAVING 条件处理
|
||||
if havingMap, ok := v.(Map); ok {
|
||||
havingWhere, havingRes := that.cond("AND", havingMap)
|
||||
if havingWhere != "" {
|
||||
where += " HAVING " + strings.TrimSpace(havingWhere) + " "
|
||||
res = append(res, havingRes...)
|
||||
}
|
||||
}
|
||||
case "ORDER":
|
||||
where += " ORDER BY "
|
||||
where += that.formatVcondValue(v)
|
||||
case "LIMIT":
|
||||
where += " LIMIT "
|
||||
where += that.formatVcondValue(v)
|
||||
case "OFFSET":
|
||||
where += " OFFSET " + ObjToStr(v) + " "
|
||||
case "DISTINCT":
|
||||
// DISTINCT 通常在 SELECT 中处理,这里暂时忽略
|
||||
}
|
||||
}
|
||||
|
||||
return where, res
|
||||
}
|
||||
|
||||
// formatVcondValue 格式化特殊关键字的值
|
||||
func (that *HoTimeDB) formatVcondValue(v interface{}) string {
|
||||
result := ""
|
||||
if reflect.ValueOf(v).Type().String() == "common.Slice" || strings.Contains(reflect.ValueOf(v).Type().String(), "[]") {
|
||||
vs := ObjToSlice(v)
|
||||
for i := 0; i < len(vs); i++ {
|
||||
result += " " + vs.GetString(i) + " "
|
||||
if len(vs) != i+1 {
|
||||
result += ", "
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result += " " + ObjToStr(v) + " "
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// varCond 变量条件解析
|
||||
func (that *HoTimeDB) varCond(k string, v interface{}) (string, []interface{}) {
|
||||
where := ""
|
||||
res := make([]interface{}, 0)
|
||||
length := len(k)
|
||||
processor := that.GetProcessor()
|
||||
|
||||
if k == "[#]" {
|
||||
k = strings.Replace(k, "[#]", "", -1)
|
||||
where += " " + ObjToStr(v) + " "
|
||||
} else if k == "[##]" {
|
||||
// 直接添加 SQL 片段(key 为 [##] 时)
|
||||
where += " " + ObjToStr(v) + " "
|
||||
} else if length > 0 && strings.Contains(k, "[") && k[length-1] == ']' {
|
||||
def := false
|
||||
|
||||
switch Substr(k, length-3, 3) {
|
||||
case "[>]":
|
||||
k = strings.Replace(k, "[>]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where += k + ">? "
|
||||
res = append(res, v)
|
||||
case "[<]":
|
||||
k = strings.Replace(k, "[<]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where += k + "<? "
|
||||
res = append(res, v)
|
||||
case "[!]":
|
||||
k = strings.Replace(k, "[!]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where, res = that.notIn(k, v, where, res)
|
||||
case "[#]":
|
||||
k = strings.Replace(k, "[#]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where += " " + k + "=" + ObjToStr(v) + " "
|
||||
case "[~]":
|
||||
k = strings.Replace(k, "[~]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where += k + " LIKE ? "
|
||||
v = "%" + ObjToStr(v) + "%"
|
||||
res = append(res, v)
|
||||
default:
|
||||
def = true
|
||||
}
|
||||
|
||||
if def {
|
||||
switch Substr(k, length-4, 4) {
|
||||
case "[>=]":
|
||||
k = strings.Replace(k, "[>=]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where += k + ">=? "
|
||||
res = append(res, v)
|
||||
case "[<=]":
|
||||
k = strings.Replace(k, "[<=]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where += k + "<=? "
|
||||
res = append(res, v)
|
||||
case "[><]":
|
||||
k = strings.Replace(k, "[><]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where += k + " NOT BETWEEN ? AND ? "
|
||||
vs := ObjToSlice(v)
|
||||
res = append(res, vs[0])
|
||||
res = append(res, vs[1])
|
||||
case "[<>]":
|
||||
k = strings.Replace(k, "[<>]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where += k + " BETWEEN ? AND ? "
|
||||
vs := ObjToSlice(v)
|
||||
res = append(res, vs[0])
|
||||
res = append(res, vs[1])
|
||||
case "[##]":
|
||||
where += " " + ObjToStr(v)
|
||||
case "[#!]":
|
||||
k = strings.Replace(k, "[#!]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where += " " + k + "!=" + ObjToStr(v) + " "
|
||||
case "[!#]":
|
||||
k = strings.Replace(k, "[!#]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where += " " + k + "!=" + ObjToStr(v) + " "
|
||||
case "[!~]": // 左边任意
|
||||
k = strings.Replace(k, "[!~]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where += k + " LIKE ? "
|
||||
v = "%" + ObjToStr(v) + ""
|
||||
res = append(res, v)
|
||||
case "[~!]": // 右边任意
|
||||
k = strings.Replace(k, "[~!]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where += k + " LIKE ? "
|
||||
v = ObjToStr(v) + "%"
|
||||
res = append(res, v)
|
||||
case "[~~]": // 手动任意
|
||||
k = strings.Replace(k, "[~~]", "", -1)
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
where += k + " LIKE ? "
|
||||
res = append(res, v)
|
||||
default:
|
||||
where, res = that.handleDefaultCondition(k, v, where, res)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
where, res = that.handlePlainField(k, v, where, res)
|
||||
}
|
||||
|
||||
return where, res
|
||||
}
|
||||
|
||||
// handleDefaultCondition 处理默认条件(带方括号但不是特殊操作符)
|
||||
func (that *HoTimeDB) handleDefaultCondition(k string, v interface{}, where string, res []interface{}) (string, []interface{}) {
|
||||
processor := that.GetProcessor()
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
|
||||
if reflect.ValueOf(v).Type().String() == "common.Slice" || strings.Contains(reflect.ValueOf(v).Type().String(), "[]") {
|
||||
vs := ObjToSlice(v)
|
||||
if len(vs) == 0 {
|
||||
// IN 空数组 -> 生成永假条件
|
||||
where += "1=0 "
|
||||
return where, res
|
||||
}
|
||||
|
||||
if len(vs) == 1 {
|
||||
where += k + "=? "
|
||||
res = append(res, vs[0])
|
||||
return where, res
|
||||
}
|
||||
|
||||
// IN 优化:连续整数转为 BETWEEN
|
||||
where, res = that.optimizeInCondition(k, vs, where, res)
|
||||
} else {
|
||||
where += k + "=? "
|
||||
res = append(res, v)
|
||||
}
|
||||
|
||||
return where, res
|
||||
}
|
||||
|
||||
// handlePlainField 处理普通字段(无方括号)
|
||||
func (that *HoTimeDB) handlePlainField(k string, v interface{}, where string, res []interface{}) (string, []interface{}) {
|
||||
processor := that.GetProcessor()
|
||||
k = processor.ProcessColumn(k) + " "
|
||||
|
||||
if v == nil {
|
||||
where += k + " IS NULL "
|
||||
} else if reflect.ValueOf(v).Type().String() == "common.Slice" || strings.Contains(reflect.ValueOf(v).Type().String(), "[]") {
|
||||
vs := ObjToSlice(v)
|
||||
if len(vs) == 0 {
|
||||
// IN 空数组 -> 生成永假条件
|
||||
where += "1=0 "
|
||||
return where, res
|
||||
}
|
||||
|
||||
if len(vs) == 1 {
|
||||
where += k + "=? "
|
||||
res = append(res, vs[0])
|
||||
return where, res
|
||||
}
|
||||
|
||||
// IN 优化
|
||||
where, res = that.optimizeInCondition(k, vs, where, res)
|
||||
} else {
|
||||
where += k + "=? "
|
||||
res = append(res, v)
|
||||
}
|
||||
|
||||
return where, res
|
||||
}
|
||||
|
||||
// optimizeInCondition 优化 IN 条件(连续整数转为 BETWEEN)
|
||||
func (that *HoTimeDB) optimizeInCondition(k string, vs Slice, where string, res []interface{}) (string, []interface{}) {
|
||||
min := int64(0)
|
||||
isMin := true
|
||||
IsRange := true
|
||||
num := int64(0)
|
||||
isNum := true
|
||||
|
||||
where1 := ""
|
||||
res1 := Slice{}
|
||||
where2 := k + " IN ("
|
||||
res2 := Slice{}
|
||||
|
||||
for kvs := 0; kvs <= len(vs); kvs++ {
|
||||
vsv := int64(0)
|
||||
if kvs < len(vs) {
|
||||
vsv = vs.GetCeilInt64(kvs)
|
||||
// 确保是全部是int类型
|
||||
if ObjToStr(vsv) != vs.GetString(kvs) {
|
||||
IsRange = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if isNum {
|
||||
isNum = false
|
||||
num = vsv
|
||||
} else {
|
||||
num++
|
||||
}
|
||||
if isMin {
|
||||
isMin = false
|
||||
min = vsv
|
||||
}
|
||||
// 不等于则到了分路口
|
||||
if num != vsv {
|
||||
// between
|
||||
if num-min > 1 {
|
||||
if where1 != "" {
|
||||
where1 += " OR " + k + " BETWEEN ? AND ? "
|
||||
} else {
|
||||
where1 += k + " BETWEEN ? AND ? "
|
||||
}
|
||||
res1 = append(res1, min)
|
||||
res1 = append(res1, num-1)
|
||||
} else {
|
||||
where2 += "?,"
|
||||
res2 = append(res2, min)
|
||||
}
|
||||
min = vsv
|
||||
num = vsv
|
||||
}
|
||||
}
|
||||
|
||||
if IsRange {
|
||||
where3 := ""
|
||||
|
||||
if where1 != "" {
|
||||
where3 += where1
|
||||
res = append(res, res1...)
|
||||
}
|
||||
|
||||
if len(res2) == 1 {
|
||||
if where3 == "" {
|
||||
where3 += k + " = ? "
|
||||
} else {
|
||||
where3 += " OR " + k + " = ? "
|
||||
}
|
||||
res = append(res, res2...)
|
||||
} else if len(res2) > 1 {
|
||||
where2 = where2[:len(where2)-1]
|
||||
if where3 == "" {
|
||||
where3 += where2 + ")"
|
||||
} else {
|
||||
where3 += " OR " + where2 + ")"
|
||||
}
|
||||
res = append(res, res2...)
|
||||
}
|
||||
|
||||
if where3 != "" {
|
||||
where += "(" + where3 + ")"
|
||||
}
|
||||
|
||||
return where, res
|
||||
}
|
||||
|
||||
// 非连续整数,使用普通 IN
|
||||
where += k + " IN ("
|
||||
res = append(res, vs...)
|
||||
|
||||
for i := 0; i < len(vs); i++ {
|
||||
if i+1 != len(vs) {
|
||||
where += "?,"
|
||||
} else {
|
||||
where += "?) "
|
||||
}
|
||||
}
|
||||
|
||||
return where, res
|
||||
}
|
||||
|
||||
// notIn NOT IN 条件处理
|
||||
func (that *HoTimeDB) notIn(k string, v interface{}, where string, res []interface{}) (string, []interface{}) {
|
||||
if v == nil {
|
||||
where += k + " IS NOT NULL "
|
||||
} else if reflect.ValueOf(v).Type().String() == "common.Slice" || strings.Contains(reflect.ValueOf(v).Type().String(), "[]") {
|
||||
vs := ObjToSlice(v)
|
||||
if len(vs) == 0 {
|
||||
return where, res
|
||||
}
|
||||
where += k + " NOT IN ("
|
||||
res = append(res, vs...)
|
||||
|
||||
for i := 0; i < len(vs); i++ {
|
||||
if i+1 != len(vs) {
|
||||
where += "?,"
|
||||
} else {
|
||||
where += "?) "
|
||||
}
|
||||
}
|
||||
} else {
|
||||
where += k + " !=? "
|
||||
res = append(res, v)
|
||||
}
|
||||
|
||||
return where, res
|
||||
}
|
||||
|
||||
// cond 条件组合处理
|
||||
func (that *HoTimeDB) cond(tag string, data Map) (string, []interface{}) {
|
||||
where := " "
|
||||
res := make([]interface{}, 0)
|
||||
lens := len(data)
|
||||
|
||||
testQu := []string{}
|
||||
for key := range data {
|
||||
testQu = append(testQu, key)
|
||||
}
|
||||
sort.Strings(testQu)
|
||||
|
||||
for _, k := range testQu {
|
||||
v := data[k]
|
||||
x := 0
|
||||
for i := 0; i < len(condition); i++ {
|
||||
if condition[i] == strings.ToUpper(k) {
|
||||
tw, ts := that.cond(strings.ToUpper(k), v.(Map))
|
||||
if lens--; lens <= 0 {
|
||||
where += "(" + tw + ") "
|
||||
} else {
|
||||
where += "(" + tw + ") " + tag + " "
|
||||
}
|
||||
|
||||
res = append(res, ts...)
|
||||
break
|
||||
}
|
||||
x++
|
||||
}
|
||||
|
||||
if x == len(condition) {
|
||||
tv, vv := that.varCond(k, v)
|
||||
if tv == "" {
|
||||
lens--
|
||||
continue
|
||||
}
|
||||
res = append(res, vv...)
|
||||
if lens--; lens <= 0 {
|
||||
where += tv + ""
|
||||
} else {
|
||||
where += tv + " " + tag + " "
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return where, res
|
||||
}
|
||||
@@ -1,894 +0,0 @@
|
||||
# HoTime 代码生成
|
||||
|
||||
前置依赖:生成前请先满足 [数据库设计规范](DatabaseDesign_数据库设计规范.md)(表命名、外键、COMMENT、必有字段)。代码生成器依赖规范的表结构与字段备注,才能正确识别类型、选项、外键关联与显示标签。
|
||||
|
||||
`code` 包提供了 HoTime 框架的自动代码生成功能,能够根据数据库表结构自动生成 CRUD 接口代码和配置文件。
|
||||
|
||||
## 目录
|
||||
|
||||
- [一、功能概述](#一功能概述)
|
||||
- [二、使用方法](#二使用方法)
|
||||
- [三、配置体系](#三配置体系)
|
||||
- [3.1 codeConfig](#31-codeconfig)
|
||||
- [3.2 菜单权限配置(admin.json)](#32-菜单权限配置adminjson)
|
||||
- [3.3 字段规则配置(rule.json)](#33-字段规则配置rulejson)
|
||||
- [3.4 配置检查清单](#34-配置检查清单)
|
||||
- [四、生成规则与产物](#四生成规则与产物)
|
||||
- [4.1 默认字段规则](#41-默认字段规则)
|
||||
- [4.2 数据类型映射](#42-数据类型映射)
|
||||
- [4.3 字段备注解析](#43-字段备注解析)
|
||||
- [4.4 外键关联](#44-外键关联)
|
||||
- [4.5 生成的代码结构](#45-生成的代码结构)
|
||||
- [4.6 多库与备注注意事项](#46-多库与备注注意事项)
|
||||
- [4.7 最佳实践](#47-最佳实践)
|
||||
- [五、相关文档](#五相关文档)
|
||||
|
||||
---
|
||||
|
||||
## 一、功能概述
|
||||
|
||||
代码生成器可以:
|
||||
|
||||
1. **自动读取数据库表结构** - 支持 MySQL、SQLite 和达梦 DM8
|
||||
2. **生成 CRUD 接口** - 增删改查、搜索、分页
|
||||
3. **生成配置文件** - 表字段配置、菜单配置、权限配置
|
||||
4. **智能字段识别** - 根据字段名自动识别类型和权限
|
||||
5. **支持表关联** - 自动识别外键关系
|
||||
|
||||
配置体系概览:
|
||||
|
||||
```
|
||||
config.json
|
||||
└── codeConfig[] # 代码生成配置数组(支持多套)
|
||||
├── config # 菜单权限配置文件路径(如 admin.json)
|
||||
├── configDB # 数据库生成的完整配置
|
||||
├── rule # 字段规则配置文件路径(如 rule.json)
|
||||
├── table # 管理员表名
|
||||
├── name # 生成代码的包名
|
||||
└── mode # 生成模式
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、使用方法
|
||||
|
||||
### 2.1 基础配置
|
||||
|
||||
在 `config.json` 中配置开发模式与代码生成:
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": 2,
|
||||
"codeConfig": [
|
||||
{
|
||||
"table": "admin",
|
||||
"config": "config/admin.json",
|
||||
"rule": "config/rule.json",
|
||||
"mode": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 启动应用
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
func main() {
|
||||
app := Init("config/config.json")
|
||||
|
||||
// 代码生成器在 Init 时自动执行
|
||||
// 会读取数据库结构并生成配置
|
||||
|
||||
app.Run(Router{
|
||||
// 路由配置
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 开发模式
|
||||
|
||||
在 `config.json` 中设置 `"mode": 2`(开发模式)时:
|
||||
|
||||
- 自动读取数据库表结构
|
||||
- 自动生成/更新配置文件
|
||||
- 自动生成代码(如果 `codeConfig.mode=1`)
|
||||
|
||||
### 2.4 推荐开发流程
|
||||
|
||||
1. 设置 `config.json` 中 `mode: 2`(开发模式)
|
||||
2. 按 [数据库设计规范](DatabaseDesign_数据库设计规范.md) 设计表结构并添加字段备注
|
||||
3. 启动应用,自动生成配置
|
||||
4. 检查生成的配置文件,按需调整
|
||||
5. 生产环境改为 `mode: 0`
|
||||
|
||||
---
|
||||
|
||||
## 三、配置体系
|
||||
|
||||
### 3.1 codeConfig
|
||||
|
||||
#### 配置结构
|
||||
|
||||
```json
|
||||
{
|
||||
"codeConfig": [
|
||||
{
|
||||
"config": "config/admin.json",
|
||||
"configDB": "config/adminDB.json",
|
||||
"mode": 0,
|
||||
"name": "",
|
||||
"rule": "config/rule.json",
|
||||
"table": "admin"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 配置项说明
|
||||
|
||||
| 字段 | 类型 | 必须 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `table` | string | ✅ | 管理员/用户表名,用于身份验证和权限控制 |
|
||||
| `config` | string | ✅ | 菜单权限配置文件路径,用于定义菜单结构、权限控制 |
|
||||
| `configDB` | string | ❌ | 代码生成器输出的完整配置文件(有则每次自动生成) |
|
||||
| `rule` | string | ❌ | 字段规则配置文件路径,无则使用默认规则 |
|
||||
| `name` | string | ❌ | 生成的代码包名和目录名,为空则不生成独立代码文件 |
|
||||
| `mode` | int | ❌ | 生成模式:0=仅配置不生成代码(内嵌模式),非 0=生成代码文件 |
|
||||
|
||||
#### 运行模式
|
||||
|
||||
- **mode=0(内嵌模式)**:不生成独立代码文件,使用框架内置的通用控制器
|
||||
- **mode≠0(生成模式)**:为每张表生成独立的 Go 控制器文件
|
||||
|
||||
#### 多配置支持
|
||||
|
||||
可以配置多个独立的代码生成实例,适用于多端场景:
|
||||
|
||||
```json
|
||||
{
|
||||
"codeConfig": [
|
||||
{
|
||||
"config": "config/admin.json",
|
||||
"table": "admin",
|
||||
"rule": "config/rule.json"
|
||||
},
|
||||
{
|
||||
"config": "config/user.json",
|
||||
"table": "user",
|
||||
"rule": "config/rule.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.2 菜单权限配置(admin.json)
|
||||
|
||||
#### 配置文件更新机制
|
||||
|
||||
- **首次运行**:代码生成器会根据数据库表结构自动创建配置文件(如 admin.json)
|
||||
- **后续运行**:配置文件**不会自动更新**,避免覆盖手动修改的内容
|
||||
- **重新生成**:如需重新生成,删除配置文件后重新运行即可
|
||||
- **参考更新**:可参考 `configDB` 指定的文件(如 adminDB.json)查看最新的数据库结构变化,手动调整配置
|
||||
|
||||
#### 完整配置结构
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "唯一标识(自动生成)",
|
||||
"name": "admin",
|
||||
"label": "管理平台名称",
|
||||
"labelConfig": { ... },
|
||||
"menus": [ ... ],
|
||||
"flow": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
#### label / labelConfig
|
||||
|
||||
```json
|
||||
{
|
||||
"label": "HoTime管理平台",
|
||||
"labelConfig": {
|
||||
"show": "开启",
|
||||
"add": "添加",
|
||||
"delete": "删除",
|
||||
"edit": "编辑",
|
||||
"info": "查看详情",
|
||||
"download": "下载清单"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 操作 | 说明 |
|
||||
|------|------|
|
||||
| show | 显示/查看列表权限 |
|
||||
| add | 添加数据权限 |
|
||||
| delete | 删除数据权限 |
|
||||
| edit | 编辑数据权限 |
|
||||
| info | 查看详情权限 |
|
||||
| download | 下载/导出权限 |
|
||||
|
||||
#### menus 配置
|
||||
|
||||
定义菜单结构,支持多级嵌套(目前只支持**两级菜单**):
|
||||
|
||||
```json
|
||||
{
|
||||
"menus": [
|
||||
{
|
||||
"label": "系统管理",
|
||||
"name": "sys",
|
||||
"icon": "Setting",
|
||||
"auth": ["show"],
|
||||
"menus": [
|
||||
{
|
||||
"label": "用户管理",
|
||||
"table": "user",
|
||||
"auth": ["show", "add", "delete", "edit", "info", "download"]
|
||||
},
|
||||
{
|
||||
"label": "角色管理",
|
||||
"table": "role",
|
||||
"auth": ["show", "add", "delete", "edit", "info"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "文章管理",
|
||||
"table": "article",
|
||||
"icon": "Document",
|
||||
"auth": ["show", "add", "edit", "info"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| label | string | 菜单显示名称 |
|
||||
| name | string | 菜单标识(用于分组,不绑定表时使用) |
|
||||
| table | string | 绑定的数据表名(用于自动生成 CRUD) |
|
||||
| icon | string | 菜单图标名称(Element Plus 图标名,如 `Setting`、`User`、`Document`) |
|
||||
| auth | array | 权限数组,定义该菜单/表拥有的操作权限 |
|
||||
| menus | array | 子菜单数组(支持嵌套) |
|
||||
|
||||
**name vs table**:
|
||||
|
||||
- **table**:绑定数据表,拥有该表的增删查改等权限,自动生成 CRUD 接口
|
||||
- **name**:自定义功能标识,不绑定表,前端根据 name 和 auth 显示自定义内容(如首页 home、仪表盘 dashboard)
|
||||
- 配置了 `menus` 子菜单 → 作为分组
|
||||
- 没有 `menus` → 作为独立自定义功能入口
|
||||
|
||||
```json
|
||||
// 使用 table:绑定数据表
|
||||
{ "label": "用户管理", "table": "user", "auth": ["show", "add", "edit", "delete"] }
|
||||
|
||||
// 使用 name:自定义功能(无子菜单)
|
||||
{ "label": "首页", "name": "home", "icon": "House", "auth": ["show"] }
|
||||
|
||||
// 使用 name:分组功能(有子菜单)
|
||||
{ "label": "系统管理", "name": "sys", "icon": "Setting", "auth": ["show"], "menus": [...] }
|
||||
```
|
||||
|
||||
**自动分组规则**:代码生成器会根据表名的 `_` 分词自动分组:
|
||||
|
||||
- 表名 `sys_logs`、`sys_menus`、`sys_config` → 归入 `sys` 分组
|
||||
- 表名 `article`、`article_tag` → 归入 `article` 分组
|
||||
|
||||
分组的显示名称(label)使用该分组下**第一张表的名字**,如果表有备注则使用备注名。
|
||||
|
||||
#### auth 配置
|
||||
|
||||
| 权限 | 对应接口 | 说明 |
|
||||
|------|----------|------|
|
||||
| show | /search | 列表查询 |
|
||||
| add | /add | 新增数据 |
|
||||
| delete | /remove | 删除数据 |
|
||||
| edit | /update | 编辑数据 |
|
||||
| info | /info | 查看详情 |
|
||||
| download | /search?download=1 | 导出数据 |
|
||||
|
||||
auth 数组**可以自由增删**。新增的权限项会在前端菜单/功能中显示,并在角色管理(role)的权限设置中供管理员分配:
|
||||
|
||||
```json
|
||||
{
|
||||
"label": "文章管理",
|
||||
"table": "article",
|
||||
"auth": ["show", "add", "edit", "delete", "info", "publish", "audit", "top"]
|
||||
}
|
||||
```
|
||||
|
||||
上例中 `publish`(发布)、`audit`(审核)、`top`(置顶)为自定义权限。
|
||||
|
||||
#### flow 配置(数据权限)
|
||||
|
||||
flow 用于限制用户只能操作自己权限范围内的数据。
|
||||
|
||||
```json
|
||||
{
|
||||
"flow": {
|
||||
"role": {
|
||||
"table": "role",
|
||||
"stop": true,
|
||||
"sql": {
|
||||
"id": "role_id"
|
||||
}
|
||||
},
|
||||
"article": {
|
||||
"table": "article",
|
||||
"stop": false,
|
||||
"sql": {
|
||||
"admin_id": "id"
|
||||
}
|
||||
},
|
||||
"org": {
|
||||
"table": "org",
|
||||
"stop": false,
|
||||
"sql": {
|
||||
"parent_ids[~]": ",org_id,"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| table | string | 表名 |
|
||||
| stop | bool | 是否禁止修改自身关联的数据 |
|
||||
| sql | object | 数据过滤条件,自动填充查询/操作条件 |
|
||||
|
||||
**stop**:防止用户修改自己当前关联的敏感数据。例如当前用户 `role_id = 1`,配置 `stop: true` 后,不能修改 role 表中 `id = 1` 的记录,但可修改其他 role(若有权限)。典型用途:防止用户提升自己的角色权限、修改自己所属组织。
|
||||
|
||||
**sql**:格式为 `{ "目标表字段": "当前用户字段" }`。
|
||||
|
||||
| 格式 | 含义 | SQL 等价 |
|
||||
|------|------|----------|
|
||||
| `"field": "user_field"` | 精确匹配 | `field = 用户.user_field` |
|
||||
| `"field[~]": ",value,"` | 模糊匹配 | `field LIKE '%,value,%'` |
|
||||
|
||||
示例效果(当前用户 `{ "id": 5, "role_id": 2, "org_id": 10 }`):
|
||||
|
||||
| 表 | 查询条件 | stop 效果 |
|
||||
|-----|----------|-----------|
|
||||
| role | `WHERE id = 2` | 不能修改 id=2 的角色 |
|
||||
| article | `WHERE admin_id = 5` | 可以修改自己的文章 |
|
||||
| org | `WHERE parent_ids LIKE '%,10,%'` | 可以修改下级组织 |
|
||||
|
||||
#### 完整 admin.json 示例
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "74a8a59407fa7d6c7fcdc85742dbae57",
|
||||
"name": "admin",
|
||||
"label": "后台管理系统",
|
||||
"labelConfig": {
|
||||
"show": "开启",
|
||||
"add": "添加",
|
||||
"delete": "删除",
|
||||
"edit": "编辑",
|
||||
"info": "查看详情",
|
||||
"download": "下载清单"
|
||||
},
|
||||
"menus": [
|
||||
{
|
||||
"label": "系统管理",
|
||||
"name": "sys",
|
||||
"icon": "Setting",
|
||||
"auth": ["show"],
|
||||
"menus": [
|
||||
{
|
||||
"label": "日志管理",
|
||||
"table": "logs",
|
||||
"auth": ["show", "download"]
|
||||
},
|
||||
{
|
||||
"label": "角色管理",
|
||||
"table": "role",
|
||||
"auth": ["show", "add", "delete", "edit", "info"]
|
||||
},
|
||||
{
|
||||
"label": "组织管理",
|
||||
"table": "org",
|
||||
"auth": ["show", "add", "delete", "edit", "info"]
|
||||
},
|
||||
{
|
||||
"label": "员工管理",
|
||||
"table": "admin",
|
||||
"auth": ["show", "add", "delete", "edit", "info", "download"]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"flow": {
|
||||
"admin": {
|
||||
"table": "admin",
|
||||
"stop": false,
|
||||
"sql": { "role_id": "role_id" }
|
||||
},
|
||||
"role": {
|
||||
"table": "role",
|
||||
"stop": true,
|
||||
"sql": { "admin_id": "id", "id": "role_id" }
|
||||
},
|
||||
"org": {
|
||||
"table": "org",
|
||||
"stop": false,
|
||||
"sql": { "admin_id": "id" }
|
||||
},
|
||||
"logs": {
|
||||
"table": "logs",
|
||||
"stop": false,
|
||||
"sql": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 说明:生成器还会在配置中产生 `tables`(字段列、搜索项等)。首次生成可参考 `configDB`(如 adminDB.json)中的结构;持久化自定义请写入 `config` 指定的文件(如 admin.json),因 configDB 会在每次启动时重新生成。
|
||||
|
||||
---
|
||||
|
||||
### 3.3 字段规则配置(rule.json)
|
||||
|
||||
定义字段在增删改查操作中的默认行为。
|
||||
|
||||
#### 配置结构
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "id",
|
||||
"add": false,
|
||||
"edit": false,
|
||||
"info": true,
|
||||
"list": true,
|
||||
"must": false,
|
||||
"strict": true,
|
||||
"type": ""
|
||||
},
|
||||
{
|
||||
"name": "status",
|
||||
"list": true,
|
||||
"add": true,
|
||||
"edit": true,
|
||||
"info": true,
|
||||
"must": false,
|
||||
"strict": false,
|
||||
"type": "select"
|
||||
},
|
||||
{
|
||||
"name": "user.special_field",
|
||||
"list": true,
|
||||
"add": true,
|
||||
"edit": true,
|
||||
"info": true,
|
||||
"type": "text",
|
||||
"strict": true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
#### 字段属性说明
|
||||
|
||||
| 属性 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| name | string | 字段名或关键词;支持 `表名.字段名` 精确匹配 |
|
||||
| add | bool | 新增时是否显示 |
|
||||
| edit | bool | 编辑时是否显示 |
|
||||
| info | bool | 详情页是否显示 |
|
||||
| list | bool | 列表页是否显示 |
|
||||
| must | bool | 是否必填(见下方) |
|
||||
| strict | bool | 是否严格匹配字段名(true=完全匹配,false=包含匹配) |
|
||||
| type | string | 字段类型(影响前端控件和数据处理) |
|
||||
|
||||
#### must 必填字段规则
|
||||
|
||||
**自动识别**:
|
||||
|
||||
1. **MySQL**:字段为 `NOT NULL`(`IS_NULLABLE='NO'`)时自动 `must=true`
|
||||
2. **SQLite**:字段为主键(`pk=1`)时自动 `must=true`
|
||||
|
||||
**规则覆盖**:`rule.json` 中的 `must` 会覆盖数据库自动识别结果。
|
||||
|
||||
**效果**:
|
||||
|
||||
- 前端:`must=true` 显示必填标记(*),提交时校验
|
||||
- 后端:新增时若 `must=true` 字段为空,返回「请求参数不足」
|
||||
|
||||
#### type 类型说明
|
||||
|
||||
| 类型 | 说明 | 前端控件 |
|
||||
|------|------|----------|
|
||||
| (空) | 普通文本 | 文本输入框 |
|
||||
| text | 文本 | 文本输入框 |
|
||||
| number | 数字 | 数字输入框 |
|
||||
| select | 选择 | 下拉选择框(根据注释自动生成选项) |
|
||||
| time | 时间(datetime) | 日期时间选择器 |
|
||||
| unixTime | 时间戳 | 日期时间选择器(存储为 Unix 时间戳) |
|
||||
| password | 密码 | 密码输入框(自动 MD5 加密) |
|
||||
| textArea | 多行文本 | 文本域 |
|
||||
| image | 图片 | 图片上传 |
|
||||
| file | 文件 | 文件上传 |
|
||||
| money | 金额 | 金额输入框 |
|
||||
| auth | 权限 | 权限树选择器 |
|
||||
| form | 表单 | 动态表单 |
|
||||
| index | 索引 | 隐藏字段(用于 parent_ids 等) |
|
||||
| tree | 树形选择 | 树形选择器 |
|
||||
| table | 动态表 | 表名选择器 |
|
||||
| table_id | 动态表ID | 根据 table 字段动态关联 |
|
||||
|
||||
#### 内置字段规则(可在 rule.json 中覆盖)
|
||||
|
||||
**主键和索引**:
|
||||
|
||||
```json
|
||||
{"name": "id", "add": false, "list": true, "edit": false, "info": true, "strict": true}
|
||||
{"name": "sn", "add": false, "list": true, "edit": false, "info": true}
|
||||
{"name": "parent_ids", "add": false, "list": false, "edit": false, "info": false, "type": "index", "strict": true}
|
||||
{"name": "index", "add": false, "list": false, "edit": false, "info": false, "type": "index", "strict": true}
|
||||
```
|
||||
|
||||
**层级关系**:
|
||||
|
||||
```json
|
||||
{"name": "parent_id", "add": true, "list": true, "edit": true, "info": true}
|
||||
{"name": "level", "add": false, "list": false, "edit": false, "info": true}
|
||||
```
|
||||
|
||||
**时间字段**:
|
||||
|
||||
```json
|
||||
{"name": "create_time", "add": false, "list": false, "edit": false, "info": true, "type": "time", "strict": true}
|
||||
{"name": "modify_time", "add": false, "list": true, "edit": false, "info": true, "type": "time", "strict": true}
|
||||
{"name": "time", "add": true, "list": true, "edit": true, "info": true, "type": "time"}
|
||||
```
|
||||
|
||||
**状态字段**:
|
||||
|
||||
```json
|
||||
{"name": "status", "add": true, "list": true, "edit": true, "info": true, "type": "select"}
|
||||
{"name": "state", "add": true, "list": true, "edit": true, "info": true, "type": "select"}
|
||||
{"name": "sex", "add": true, "list": true, "edit": true, "info": true, "type": "select"}
|
||||
```
|
||||
|
||||
**敏感字段**:
|
||||
|
||||
```json
|
||||
{"name": "password", "add": true, "list": false, "edit": true, "info": false, "type": "password"}
|
||||
{"name": "pwd", "add": true, "list": false, "edit": true, "info": false, "type": "password"}
|
||||
{"name": "delete", "add": false, "list": false, "edit": false, "info": false}
|
||||
{"name": "version", "add": false, "list": false, "edit": false, "info": false}
|
||||
```
|
||||
|
||||
**媒体字段**:
|
||||
|
||||
```json
|
||||
{"name": "image", "add": true, "list": false, "edit": true, "info": true, "type": "image"}
|
||||
{"name": "img", "add": true, "list": false, "edit": true, "info": true, "type": "image"}
|
||||
{"name": "avatar", "add": true, "list": false, "edit": true, "info": true, "type": "image"}
|
||||
{"name": "icon", "add": true, "list": false, "edit": true, "info": true, "type": "image"}
|
||||
{"name": "file", "add": true, "list": false, "edit": true, "info": true, "type": "file"}
|
||||
```
|
||||
|
||||
**文本字段**:
|
||||
|
||||
```json
|
||||
{"name": "info", "add": true, "list": false, "edit": true, "info": true, "type": "textArea"}
|
||||
{"name": "content", "add": true, "list": false, "edit": true, "info": true, "type": "textArea"}
|
||||
{"name": "description", "add": true, "list": false, "edit": true, "info": true}
|
||||
{"name": "note", "add": true, "list": false, "edit": true, "info": true}
|
||||
{"name": "address", "add": true, "list": true, "edit": true, "info": true}
|
||||
```
|
||||
|
||||
**特殊字段**:
|
||||
|
||||
```json
|
||||
{"name": "amount", "add": true, "list": true, "edit": true, "info": true, "type": "money", "strict": true}
|
||||
{"name": "auth", "add": true, "list": false, "edit": true, "info": true, "type": "auth", "strict": true}
|
||||
{"name": "rule", "add": true, "list": true, "edit": true, "info": true, "type": "form"}
|
||||
{"name": "table", "add": false, "list": true, "edit": false, "info": true, "type": "table"}
|
||||
{"name": "table_id", "add": false, "list": true, "edit": false, "info": true, "type": "table_id"}
|
||||
```
|
||||
|
||||
#### 自定义字段规则
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "company_name",
|
||||
"add": true,
|
||||
"edit": true,
|
||||
"info": true,
|
||||
"list": true,
|
||||
"must": true,
|
||||
"strict": true,
|
||||
"type": ""
|
||||
},
|
||||
{
|
||||
"name": "user.nickname",
|
||||
"add": true,
|
||||
"edit": true,
|
||||
"info": true,
|
||||
"list": true,
|
||||
"strict": true,
|
||||
"type": ""
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.4 配置检查清单
|
||||
|
||||
#### codeConfig
|
||||
|
||||
- [ ] config 文件路径正确
|
||||
- [ ] rule 文件路径正确
|
||||
- [ ] table 指定的管理员表存在
|
||||
|
||||
#### 菜单权限配置
|
||||
|
||||
- [ ] 所有 table 指向的表在数据库中存在
|
||||
- [ ] auth 数组包含需要的权限
|
||||
- [ ] menus 结构正确(有子菜单用 name,无子菜单用 table)
|
||||
- [ ] flow 配置的 sql 条件字段存在
|
||||
|
||||
#### 字段规则配置
|
||||
|
||||
- [ ] strict=true 的规则字段名完全匹配
|
||||
- [ ] type 类型与前端控件需求一致
|
||||
- [ ] 敏感字段(password 等)的 list 和 info 为 false
|
||||
|
||||
---
|
||||
|
||||
## 四、生成规则与产物
|
||||
|
||||
### 4.1 默认字段规则
|
||||
|
||||
代码生成器内置的字段识别一览(完整内置规则见 [3.3](#33-字段规则配置rulejson)):
|
||||
|
||||
| 字段名 | 列表显示 | 新增 | 编辑 | 详情 | 类型 |
|
||||
|--------|----------|------|------|------|------|
|
||||
| `id` | ✅ | ❌ | ❌ | ✅ | number |
|
||||
| `name` | ✅ | ✅ | ✅ | ✅ | text |
|
||||
| `status` | ✅ | ✅ | ✅ | ✅ | select |
|
||||
| `create_time` | ❌ | ❌ | ❌ | ✅ | time |
|
||||
| `modify_time` | ✅ | ❌ | ❌ | ✅ | time |
|
||||
| `password` | ❌ | ✅ | ✅ | ❌ | password |
|
||||
| `image/img/avatar` | ❌ | ✅ | ✅ | ✅ | image |
|
||||
| `file` | ❌ | ✅ | ✅ | ✅ | file |
|
||||
| `content/info` | ❌ | ✅ | ✅ | ✅ | textArea |
|
||||
| `parent_id` | ✅ | ✅ | ✅ | ✅ | number |
|
||||
| `parent_ids/index` | ❌ | ❌ | ❌ | ❌ | index |
|
||||
| `delete` | ❌ | ❌ | ❌ | ❌ | - |
|
||||
|
||||
### 4.2 数据类型映射
|
||||
|
||||
| 数据库类型 | 生成类型 |
|
||||
|------------|----------|
|
||||
| `int`, `integer`, `float`, `double`, `decimal` | number |
|
||||
| `char`, `varchar`, `text`, `blob` | text |
|
||||
| `date`, `datetime`, `time`, `timestamp`, `year` | time |
|
||||
|
||||
### 4.3 字段备注解析
|
||||
|
||||
字段 COMMENT 的完整语法、选项写法与设计约定,以 [数据库设计规范](DatabaseDesign_数据库设计规范.md) 为准。此处仅说明生成器如何消费备注。
|
||||
|
||||
支持从数据库字段备注中提取标签、提示与选项(括号支持 `()`、`()`、`{}`):
|
||||
|
||||
```sql
|
||||
-- 格式: 标签名(提示信息):选项1-名称1,选项2-名称2
|
||||
status TINYINT COMMENT '状态(用户账号状态):0-禁用,1-启用'
|
||||
parent_id INT COMMENT '父级ID(顶级为NULL)'
|
||||
phone VARCHAR(20) COMMENT '手机号(请输入11位手机号)'
|
||||
```
|
||||
|
||||
生成的配置示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "status",
|
||||
"label": "状态",
|
||||
"type": "select",
|
||||
"ps": "用户账号状态",
|
||||
"options": [
|
||||
{"name": "禁用", "value": "0"},
|
||||
{"name": "启用", "value": "1"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`ps` 字段在前端的展示效果:
|
||||
|
||||
- 编辑/新增页面:输入框右侧灰色小字提示
|
||||
- 表格/详情页面:鼠标悬停字段名时气泡提示
|
||||
|
||||
### 4.4 外键关联
|
||||
|
||||
代码生成器会自动识别 `_id` 结尾的字段作为外键:
|
||||
|
||||
```sql
|
||||
CREATE TABLE user (
|
||||
id INT PRIMARY KEY,
|
||||
name VARCHAR(50),
|
||||
role_id INT, -- 自动关联 role 表
|
||||
org_id INT -- 自动关联 org 表
|
||||
);
|
||||
```
|
||||
|
||||
生成的配置会包含 `link` 和 `value` 字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "role_id",
|
||||
"type": "number",
|
||||
"label": "角色",
|
||||
"link": "role",
|
||||
"value": "name"
|
||||
}
|
||||
```
|
||||
|
||||
`parent_id` 会被识别为树形结构的父级关联:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "parent_id",
|
||||
"type": "number",
|
||||
"label": "上级",
|
||||
"link": "org",
|
||||
"value": "name"
|
||||
}
|
||||
```
|
||||
|
||||
表命名、外键命名与 COMMENT 约定见 [数据库设计规范](DatabaseDesign_数据库设计规范.md)。
|
||||
|
||||
#### 树形表查询参数(parent_id / showself / showall)
|
||||
|
||||
带 `parent_id` 列的表,通用 CRUD 的 `search` 接口支持树查询参数,语义如下(`code/makecode.go` 树节点分支):
|
||||
|
||||
| 请求参数组合 | WHERE 语义 | 典型场景 |
|
||||
| --- | --- | --- |
|
||||
| 不传 `parent_id` 或 `parent_id=0` | `id=当前用户锚点 OR parent_id IS NULL`(返回根层) | 树侧栏根节点加载 |
|
||||
| `parent_id=X` | `parent_id=X`,仅直接子级 | 树节点懒加载展开 |
|
||||
| `parent_id=X&showall=1` | `parent_ids LIKE '%,X,%'`,X 及其全部子孙(X 自身的 parent_ids 含 `,X,`) | 列表按树节点过滤 |
|
||||
| `parent_id=X&showall=1&showself=1` | 在 showall 基础上再 `OR id=X`,显式保证含 X 自身 | 管理端列表树筛选(Table.vue) |
|
||||
|
||||
**规则**:`showself=1` 仅在 `showall=1` 时生效。普通子级查询(仅 `parent_id=X`)**不会**把 id=X 的行本身混入结果——否则前端树会把节点当作自己的子级,造成无限嵌套(历史缺陷,已修复;回归用例见 `example/app/makecode_tree_test.go`,`go test ./app/ -count=1 -run 'TestApi/admin/department/search'`)。
|
||||
|
||||
#### 通用列表字段筛选语义(text 类型只走模糊匹配)
|
||||
|
||||
通用 CRUD 的 `search` 接口为每个 `list` 可见字段自动生成筛选条件(`code/makecode.go` `(*MakeCode).Search`),按字段最终生效的 `type`(见 [4.2 数据类型映射](#42-数据类型映射),可被 [3.3 字段规则配置(rule.json)](#33-字段规则配置rulejson) 覆盖)分两种语义:
|
||||
|
||||
| 字段 type | 请求 `?字段名=值` 的 WHERE 语义 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 含 `"text"` 子串(`text`/`textArea`) | `字段名 LIKE '%值%'`(模糊,仅此一条) | 前端筛选框允许只填部分关键词 |
|
||||
| 其他(`number`/`select`/`time`/`money`… 等非 text) | `字段名 = 值`(精确等值) | 维持现状不变 |
|
||||
|
||||
**规则**:文本类型字段**只生成模糊匹配条件,不再叠加同名等值条件**。历史缺陷:曾对所有 list 字段先生成等值条件 `字段名=值`,再对 text 类型**额外**叠加模糊条件 `字段名[~]=值`,两者在 where 里以 AND 叠加——前端筛选框只传部分关键词时等值条件必不命中,AND 之后整条查询恒为空(下游"文本列筛选查不出数据"的通用根因)。已修复;`keyword`+`keywordtable` 全局关键词搜索逻辑不受影响。回归用例见 `example/app/makecode_tree_test.go` 的「文本字段部分关键词命中模糊匹配」「非文本字段等值筛选行为不变」两个用例,`go test ./app/ -count=1 -run 'TestApi/admin/department/search'`。
|
||||
|
||||
### 4.5 生成的代码结构
|
||||
|
||||
#### 内嵌模式 (mode=0)
|
||||
|
||||
不生成代码文件,使用框架内置控制器,只生成配置文件:
|
||||
|
||||
```
|
||||
config/
|
||||
├── admin.json # 接口/菜单权限配置
|
||||
├── adminDB.json # 数据库结构配置(可选)
|
||||
└── rule.json # 字段规则
|
||||
```
|
||||
|
||||
#### 生成模式 (mode≠0)
|
||||
|
||||
生成独立的控制器代码:
|
||||
|
||||
```
|
||||
admin/ # 生成的包目录(由 codeConfig.name 决定)
|
||||
├── init.go # 包初始化和路由注册
|
||||
├── user.go # user 表控制器
|
||||
├── role.go # role 表控制器
|
||||
└── ... # 其他表控制器
|
||||
```
|
||||
|
||||
#### 生成的控制器结构
|
||||
|
||||
```go
|
||||
package admin
|
||||
|
||||
var userCtr = Ctr{
|
||||
"info": func(that *Context) {
|
||||
// 查询单条记录
|
||||
},
|
||||
"add": func(that *Context) {
|
||||
// 新增记录
|
||||
},
|
||||
"update": func(that *Context) {
|
||||
// 更新记录
|
||||
},
|
||||
"remove": func(that *Context) {
|
||||
// 删除记录
|
||||
},
|
||||
"search": func(that *Context) {
|
||||
// 搜索列表(分页)
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 4.6 多库与备注注意事项
|
||||
|
||||
#### 达梦 DM8
|
||||
|
||||
代码生成器已原生支持达梦 DM8,会自动读取表结构和字段备注。注意:
|
||||
|
||||
- 表注释通过 `COMMENT ON TABLE` 单独设置,建表后需额外执行
|
||||
- 字段备注通过 `COMMENT ON COLUMN` 设置
|
||||
- 已对 DM 的 `USER_TAB_COLUMNS` 与 MySQL `information_schema` 差异做兼容
|
||||
|
||||
```sql
|
||||
COMMENT ON TABLE "user" IS '用户管理';
|
||||
COMMENT ON COLUMN "user"."state" IS '状态:0-正常,1-异常,2-隐藏';
|
||||
COMMENT ON COLUMN "user"."name" IS '用户名';
|
||||
```
|
||||
|
||||
#### SQLite 备注替代方案
|
||||
|
||||
SQLite 不支持表/字段 COMMENT,生成器会使用表名/字段名作为默认显示名称。可利用配置覆盖机制手动设置:
|
||||
|
||||
1. 首次运行生成配置文件(如 admin.json)
|
||||
2. 在菜单中设置表的 `label`
|
||||
3. 在 `tables.表名.columns` 中设置字段 `label`、`ps`、`options` 等
|
||||
|
||||
```json
|
||||
{
|
||||
"tables": {
|
||||
"user": {
|
||||
"label": "用户管理",
|
||||
"columns": [
|
||||
{
|
||||
"name": "name",
|
||||
"label": "用户名",
|
||||
"ps": "请输入用户名",
|
||||
"must": true
|
||||
},
|
||||
{
|
||||
"name": "status",
|
||||
"label": "状态",
|
||||
"type": "select",
|
||||
"options": [
|
||||
{"name": "正常", "value": "0"},
|
||||
{"name": "禁用", "value": "1"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**注意**:`configDB` 文件每次启动会重新生成。持久化修改应写入 `config` 指定的文件(如 admin.json)。
|
||||
|
||||
### 4.7 最佳实践
|
||||
|
||||
1. **字段命名**:使用规范字段名(`id`、`name`、`status`、`create_time`、`modify_time`、`xxx_id`、`parent_id`、`avatar`、`content` 等),便于自动识别。完整约定见 [数据库设计规范](DatabaseDesign_数据库设计规范.md)。
|
||||
2. **自定义扩展**:默认规则不足时,可修改 `rule.json`、使用生成模式后手动改代码,或直接调整配置文件中的字段属性。
|
||||
3. **生产环境**:开发用 `mode: 2`,上线改为 `mode: 0`,避免运行时反复改写配置/代码。
|
||||
|
||||
---
|
||||
|
||||
## 五、相关文档
|
||||
|
||||
- [数据库设计规范](DatabaseDesign_数据库设计规范.md)(代码生成前置依赖)
|
||||
- [HoTimeDB 使用说明](HoTimeDB_使用说明.md)
|
||||
- [快速上手](QuickStart_快速上手.md)
|
||||
- [Common 工具类](Common_工具类.md)
|
||||
@@ -1,485 +0,0 @@
|
||||
# HoTime Common 工具类使用说明
|
||||
|
||||
`common` 包提供了 HoTime 框架的核心数据类型和工具函数,包括 `Map`、`Slice`、`Obj` 类型及丰富的类型转换函数。
|
||||
|
||||
## 目录
|
||||
|
||||
- [核心数据类型](#核心数据类型)
|
||||
- [Map 类型](#map-类型)
|
||||
- [Slice 类型](#slice-类型)
|
||||
- [Obj 类型](#obj-类型)
|
||||
- [类型转换函数](#类型转换函数)
|
||||
- [工具函数](#工具函数)
|
||||
- [错误处理](#错误处理)
|
||||
|
||||
---
|
||||
|
||||
## 核心数据类型
|
||||
|
||||
### Map 类型
|
||||
|
||||
`Map` 是 `map[string]interface{}` 的别名,提供了丰富的链式调用方法。
|
||||
|
||||
```go
|
||||
import . "code.hoteas.com/golang/hotime/common"
|
||||
|
||||
// 创建 Map
|
||||
data := Map{
|
||||
"name": "张三",
|
||||
"age": 25,
|
||||
"score": 98.5,
|
||||
"active": true,
|
||||
"tags": Slice{"Go", "Web"},
|
||||
}
|
||||
```
|
||||
|
||||
#### 获取值方法
|
||||
|
||||
```go
|
||||
// 获取字符串
|
||||
name := data.GetString("name") // "张三"
|
||||
|
||||
// 获取整数
|
||||
age := data.GetInt("age") // 25
|
||||
age64 := data.GetInt64("age") // int64(25)
|
||||
|
||||
// 获取浮点数
|
||||
score := data.GetFloat64("score") // 98.5
|
||||
|
||||
// 获取布尔值
|
||||
active := data.GetBool("active") // true
|
||||
|
||||
// 获取嵌套 Map
|
||||
info := data.GetMap("info") // 返回 Map 类型
|
||||
|
||||
// 获取 Slice
|
||||
tags := data.GetSlice("tags") // 返回 Slice 类型
|
||||
|
||||
// 获取时间
|
||||
createTime := data.GetTime("create_time") // 返回 *time.Time
|
||||
|
||||
// 获取原始值
|
||||
raw := data.Get("name") // interface{}
|
||||
```
|
||||
|
||||
#### 向上取整方法
|
||||
|
||||
```go
|
||||
// 向上取整获取整数
|
||||
ceilInt := data.GetCeilInt("score") // 99
|
||||
ceilInt64 := data.GetCeilInt64("score") // int64(99)
|
||||
ceilFloat := data.GetCeilFloat64("score") // 99.0
|
||||
```
|
||||
|
||||
#### 操作方法
|
||||
|
||||
```go
|
||||
// 添加/修改值
|
||||
data.Put("email", "test@example.com")
|
||||
|
||||
// 删除值
|
||||
data.Delete("email")
|
||||
|
||||
// 转换为 JSON 字符串
|
||||
jsonStr := data.ToJsonString()
|
||||
|
||||
// 从 JSON 字符串解析
|
||||
data.JsonToMap(`{"key": "value"}`)
|
||||
```
|
||||
|
||||
#### 有序遍历
|
||||
|
||||
```go
|
||||
// 按 key 字母顺序遍历
|
||||
data.RangeSort(func(k string, v interface{}) bool {
|
||||
fmt.Printf("%s: %v\n", k, v)
|
||||
return false // 返回 true 则终止遍历
|
||||
})
|
||||
```
|
||||
|
||||
#### 转换为结构体
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
Name string
|
||||
Age int64
|
||||
Score float64
|
||||
}
|
||||
|
||||
var user User
|
||||
data.ToStruct(&user) // 传入指针,字段名首字母大写匹配
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Slice 类型
|
||||
|
||||
`Slice` 是 `[]interface{}` 的别名,提供类似 Map 的链式调用方法。
|
||||
|
||||
```go
|
||||
// 创建 Slice
|
||||
list := Slice{
|
||||
Map{"id": 1, "name": "Alice"},
|
||||
Map{"id": 2, "name": "Bob"},
|
||||
"text",
|
||||
123,
|
||||
}
|
||||
```
|
||||
|
||||
#### 获取值方法
|
||||
|
||||
```go
|
||||
// 按索引获取值(类型转换)
|
||||
str := list.GetString(2) // "text"
|
||||
num := list.GetInt(3) // 123
|
||||
num64 := list.GetInt64(3) // int64(123)
|
||||
f := list.GetFloat64(3) // 123.0
|
||||
b := list.GetBool(3) // true (非0为true)
|
||||
|
||||
// 获取嵌套类型
|
||||
item := list.GetMap(0) // Map{"id": 1, "name": "Alice"}
|
||||
subList := list.GetSlice(0) // 尝试转换为 Slice
|
||||
|
||||
// 获取原始值
|
||||
raw := list.Get(0) // interface{}
|
||||
|
||||
// 获取时间
|
||||
t := list.GetTime(0) // *time.Time
|
||||
```
|
||||
|
||||
#### 向上取整方法
|
||||
|
||||
```go
|
||||
ceilInt := list.GetCeilInt(3)
|
||||
ceilInt64 := list.GetCeilInt64(3)
|
||||
ceilFloat := list.GetCeilFloat64(3)
|
||||
```
|
||||
|
||||
#### 操作方法
|
||||
|
||||
```go
|
||||
// 修改指定位置的值
|
||||
list.Put(0, "new value")
|
||||
|
||||
// 转换为 JSON 字符串
|
||||
jsonStr := list.ToJsonString()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Obj 类型
|
||||
|
||||
`Obj` 是一个通用的对象包装器,用于链式类型转换,常用于 `Context` 方法的返回值。
|
||||
|
||||
```go
|
||||
type Obj struct {
|
||||
Data interface{} // 原始数据
|
||||
Error // 错误信息
|
||||
}
|
||||
```
|
||||
|
||||
#### 使用示例
|
||||
|
||||
```go
|
||||
obj := &Obj{Data: "123"}
|
||||
|
||||
// 链式类型转换
|
||||
i := obj.ToInt() // 123
|
||||
i64 := obj.ToInt64() // int64(123)
|
||||
f := obj.ToFloat64() // 123.0
|
||||
s := obj.ToStr() // "123"
|
||||
b := obj.ToBool() // true
|
||||
|
||||
// 复杂类型转换
|
||||
m := obj.ToMap() // 尝试转换为 Map
|
||||
sl := obj.ToSlice() // 尝试转换为 Slice
|
||||
arr := obj.ToMapArray() // 转换为 []Map
|
||||
|
||||
// 获取原始值
|
||||
raw := obj.ToObj() // interface{}
|
||||
|
||||
// 获取时间
|
||||
t := obj.ToTime() // *time.Time
|
||||
|
||||
// 向上取整
|
||||
ceil := obj.ToCeilInt()
|
||||
ceil64 := obj.ToCeilInt64()
|
||||
ceilF := obj.ToCeilFloat64()
|
||||
```
|
||||
|
||||
#### 在 Context 中的应用
|
||||
|
||||
```go
|
||||
func handler(that *Context) {
|
||||
// ReqData 返回 *Obj,支持链式调用
|
||||
userId := that.ReqData("user_id").ToInt()
|
||||
name := that.ReqData("name").ToStr()
|
||||
|
||||
// Session 也返回 *Obj
|
||||
adminId := that.Session("admin_id").ToInt64()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 类型转换函数
|
||||
|
||||
`common` 包提供了一系列全局类型转换函数。
|
||||
|
||||
### 基础转换
|
||||
|
||||
```go
|
||||
// 转字符串
|
||||
str := ObjToStr(123) // "123"
|
||||
str := ObjToStr(3.14) // "3.14"
|
||||
str := ObjToStr(Map{"a": 1}) // JSON 格式字符串
|
||||
|
||||
// 转整数
|
||||
i := ObjToInt("123") // 123
|
||||
i64 := ObjToInt64("123") // int64(123)
|
||||
|
||||
// 转浮点数
|
||||
f := ObjToFloat64("3.14") // 3.14
|
||||
|
||||
// 转布尔
|
||||
b := ObjToBool(1) // true
|
||||
b := ObjToBool(0) // false
|
||||
|
||||
// 转 Map
|
||||
m := ObjToMap(`{"a": 1}`) // Map{"a": 1}
|
||||
m := ObjToMap(someStruct) // 结构体转 Map
|
||||
|
||||
// 转 Slice
|
||||
s := ObjToSlice(`[1, 2, 3]`) // Slice{1, 2, 3}
|
||||
|
||||
// 转 []Map
|
||||
arr := ObjToMapArray(slice) // []Map
|
||||
```
|
||||
|
||||
### 向上取整转换
|
||||
|
||||
```go
|
||||
// 向上取整后转整数
|
||||
ceil := ObjToCeilInt(3.2) // 4
|
||||
ceil64 := ObjToCeilInt64(3.2) // int64(4)
|
||||
ceilF := ObjToCeilFloat64(3.2) // 4.0
|
||||
```
|
||||
|
||||
### 时间转换
|
||||
|
||||
```go
|
||||
// 自动识别多种格式
|
||||
t := ObjToTime("2024-01-15 10:30:00") // *time.Time
|
||||
t := ObjToTime("2024-01-15") // *time.Time
|
||||
t := ObjToTime(1705298400) // Unix 秒
|
||||
t := ObjToTime(1705298400000) // Unix 毫秒
|
||||
t := ObjToTime(1705298400000000) // Unix 微秒
|
||||
```
|
||||
|
||||
### 字符串转换
|
||||
|
||||
```go
|
||||
// 字符串转 Map
|
||||
m := StrToMap(`{"key": "value"}`)
|
||||
|
||||
// 字符串转 Slice
|
||||
s := StrToSlice(`[1, 2, 3]`)
|
||||
|
||||
// 字符串转 int
|
||||
i, err := StrToInt("123")
|
||||
|
||||
// 字符串数组格式转换
|
||||
jsonArr := StrArrayToJsonStr("a1,a2,a3") // "[a1,a2,a3]"
|
||||
strArr := JsonStrToStrArray("[a1,a2,a3]") // ",a1,a2,a3,"
|
||||
```
|
||||
|
||||
### 错误处理
|
||||
|
||||
所有转换函数支持可选的错误参数:
|
||||
|
||||
```go
|
||||
var e Error
|
||||
i := ObjToInt("abc", &e)
|
||||
if e.GetError() != nil {
|
||||
// 处理转换错误
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 工具函数
|
||||
|
||||
### 字符串处理
|
||||
|
||||
```go
|
||||
// 字符串截取(支持中文)
|
||||
str := Substr("Hello世界", 0, 7) // "Hello世"
|
||||
str := Substr("Hello", -2, 2) // "lo" (负数从末尾计算)
|
||||
|
||||
// 首字母大写
|
||||
upper := StrFirstToUpper("hello") // "Hello"
|
||||
|
||||
// 查找最后出现位置
|
||||
idx := IndexLastStr("a.b.c", ".") // 3
|
||||
|
||||
// 字符串相似度(Levenshtein 距离)
|
||||
dist := StrLd("hello", "hallo", true) // 1 (忽略大小写)
|
||||
```
|
||||
|
||||
### 时间处理
|
||||
|
||||
```go
|
||||
// 时间转字符串
|
||||
str := Time2Str(time.Now()) // "2024-01-15 10:30:00"
|
||||
str := Time2Str(time.Now(), 1) // "2024-01"
|
||||
str := Time2Str(time.Now(), 2) // "2024-01-15"
|
||||
str := Time2Str(time.Now(), 3) // "2024-01-15 10"
|
||||
str := Time2Str(time.Now(), 4) // "2024-01-15 10:30"
|
||||
str := Time2Str(time.Now(), 5) // "2024-01-15 10:30:00"
|
||||
|
||||
// 特殊格式
|
||||
str := Time2Str(time.Now(), 12) // "01-15"
|
||||
str := Time2Str(time.Now(), 14) // "01-15 10:30"
|
||||
str := Time2Str(time.Now(), 34) // "10:30"
|
||||
str := Time2Str(time.Now(), 35) // "10:30:00"
|
||||
```
|
||||
|
||||
### 加密与随机
|
||||
|
||||
```go
|
||||
// MD5 加密
|
||||
hash := Md5("password") // 32位小写MD5
|
||||
|
||||
// 随机数
|
||||
r := Rand(3) // 3位随机数 (0-999)
|
||||
r := RandX(10, 100) // 10-100之间的随机数
|
||||
```
|
||||
|
||||
### 数学计算
|
||||
|
||||
```go
|
||||
// 四舍五入保留小数
|
||||
f := Round(3.14159, 2) // 3.14
|
||||
f := Round(3.145, 2) // 3.15
|
||||
```
|
||||
|
||||
### 深拷贝
|
||||
|
||||
```go
|
||||
// 深拷贝 Map/Slice(递归复制)
|
||||
original := Map{"a": Map{"b": 1}}
|
||||
copied := DeepCopyMap(original).(Map)
|
||||
|
||||
// 修改副本不影响原始数据
|
||||
copied.GetMap("a")["b"] = 2
|
||||
// original["a"]["b"] 仍然是 1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 错误处理
|
||||
|
||||
### Error 类型
|
||||
|
||||
```go
|
||||
type Error struct {
|
||||
Logger *logrus.Logger // 可选的日志记录器
|
||||
error // 内嵌错误
|
||||
}
|
||||
```
|
||||
|
||||
### 使用示例
|
||||
|
||||
```go
|
||||
var e Error
|
||||
|
||||
// 设置错误
|
||||
e.SetError(errors.New("something wrong"))
|
||||
|
||||
// 获取错误
|
||||
if err := e.GetError(); err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
|
||||
// 配合日志自动记录
|
||||
e.Logger = logrusLogger
|
||||
e.SetError(errors.New("will be logged"))
|
||||
```
|
||||
|
||||
### 在类型转换中使用
|
||||
|
||||
```go
|
||||
var e Error
|
||||
data := Map{"count": "abc"}
|
||||
|
||||
count := data.GetInt("count", &e)
|
||||
if e.GetError() != nil {
|
||||
// 转换失败,count = 0
|
||||
fmt.Println("转换失败:", e.GetError())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 链式调用处理请求数据
|
||||
|
||||
```go
|
||||
func handler(that *Context) {
|
||||
// 推荐:使用 Obj 链式调用
|
||||
userId := that.ReqData("user_id").ToInt()
|
||||
page := that.ReqData("page").ToInt()
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
// 处理 Map 数据
|
||||
user := that.Db.Get("user", "*", Map{"id": userId})
|
||||
if user != nil {
|
||||
name := user.GetString("name")
|
||||
age := user.GetInt("age")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 安全的类型转换
|
||||
|
||||
```go
|
||||
// 带错误检查的转换
|
||||
var e Error
|
||||
data := someMap.GetInt("key", &e)
|
||||
if e.GetError() != nil {
|
||||
// 使用默认值
|
||||
data = 0
|
||||
}
|
||||
|
||||
// 简单场景直接转换(失败返回零值)
|
||||
data := someMap.GetInt("key") // 失败返回 0
|
||||
```
|
||||
|
||||
### 3. 处理数据库查询结果
|
||||
|
||||
```go
|
||||
// 查询返回 Map
|
||||
user := that.Db.Get("user", "*", Map{"id": 1})
|
||||
if user != nil {
|
||||
name := user.GetString("name")
|
||||
createTime := user.GetTime("create_time")
|
||||
}
|
||||
|
||||
// 查询返回 []Map
|
||||
users := that.Db.Select("user", "*", Map{"status": 1})
|
||||
for _, u := range users {
|
||||
fmt.Printf("ID: %d, Name: %s\n", u.GetInt("id"), u.GetString("name"))
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [快速上手](QuickStart_快速上手.md)
|
||||
- [HoTimeDB 使用说明](HoTimeDB_使用说明.md)
|
||||
- [代码生成](CodeGen_代码生成.md)
|
||||
- [文档索引](README.md)
|
||||
@@ -1,497 +0,0 @@
|
||||
# 数据库设计规范
|
||||
|
||||
本文档是 HoTime **CodeGen 代码生成器的输入契约**:表/字段命名、外键、注释与公共字段等规则直接决定生成结果。请严格按本文设计库表,再配合 [CodeGen_代码生成.md](CodeGen_代码生成.md) 使用代码生成。
|
||||
|
||||
遵循这些规范可确保代码生成器正确识别表关系、自动生成 CRUD 接口和管理后台。
|
||||
|
||||
---
|
||||
|
||||
## 表命名规则
|
||||
|
||||
### 1. 关于表名前缀
|
||||
|
||||
一般情况下**没必要强行添加前缀分组**,直接使用业务含义命名即可:
|
||||
|
||||
```
|
||||
推荐:user、org、role、article
|
||||
```
|
||||
|
||||
**可以使用前缀的场景**:当项目较大、表较多时,可以用前缀进行模块分组(如 `sys_`、`cms_`),代码生成器会根据 `_` 分词自动将同前缀的表归为一组。
|
||||
|
||||
```
|
||||
sys_user、sys_role、sys_org → 自动归入 sys 分组
|
||||
cms_article、cms_category → 自动归入 cms 分组
|
||||
```
|
||||
|
||||
**使用前缀的注意事项**:
|
||||
- 必须遵循外键全局唯一性规则(见下文)
|
||||
- 前缀分组后,关联 ID 命名会更复杂,容易产生重复
|
||||
- **外键名不允许重复指向不同的表**
|
||||
|
||||
### 2. 可使用简称
|
||||
|
||||
较长的表名可以使用常见简称:
|
||||
|
||||
| 全称 | 简称 |
|
||||
|------|------|
|
||||
| organization | org |
|
||||
| category | ctg |
|
||||
| configuration | config |
|
||||
| administrator | admin |
|
||||
|
||||
### 3. 关联表命名
|
||||
|
||||
多对多关联表使用 `主表_关联表` 格式:
|
||||
|
||||
```
|
||||
user_org -- 用户与组织的关联
|
||||
user_role -- 用户与角色的关联
|
||||
article_tag -- 文章与标签的关联
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 字段命名规则
|
||||
|
||||
### 1. 主键字段
|
||||
|
||||
所有表的主键统一命名为 `id`,使用自增整数。
|
||||
|
||||
```sql
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT
|
||||
```
|
||||
|
||||
### 2. 外键字段
|
||||
|
||||
外键使用 `完整表名_id` 格式:
|
||||
|
||||
```
|
||||
user_id -- 指向 user 表
|
||||
org_id -- 指向 org 表
|
||||
role_id -- 指向 role 表
|
||||
app_category_id -- 指向 app_category 表
|
||||
```
|
||||
|
||||
### 3. 关联表引用
|
||||
|
||||
引用关联表时使用 `关联表名_id`:
|
||||
|
||||
```
|
||||
user_org_id -- 指向 user_org 表
|
||||
user_role_id -- 指向 user_role 表
|
||||
```
|
||||
|
||||
### 4. 外键全局唯一性(重要)
|
||||
|
||||
**每个 `xxx_id` 字段名必须全局唯一指向一张表**,代码生成器通过 `_id` 后缀自动识别外键关系。
|
||||
|
||||
```
|
||||
✅ 正确设计:
|
||||
- user_id 只能指向 user 表
|
||||
- org_id 只能指向 org 表
|
||||
- user_org_id 只能指向 user_org 表
|
||||
- sys_user_id 只能指向 sys_user 表(如果使用前缀分组)
|
||||
|
||||
❌ 错误设计:
|
||||
- dd_id 作为"钉钉外部系统ID",但系统中存在 dd 表
|
||||
→ 代码生成器会误判为指向 dd 表的外键
|
||||
- 同时存在 user 表和 sys_user 表,都使用 user_id
|
||||
→ 外键名重复,代码生成器无法正确识别
|
||||
```
|
||||
|
||||
**使用前缀分组时的外键命名**:
|
||||
|
||||
如果使用了表名前缀(如 `sys_user`),外键应使用完整表名:
|
||||
|
||||
| 表名 | 外键命名 |
|
||||
|------|----------|
|
||||
| sys_user | sys_user_id |
|
||||
| sys_org | sys_org_id |
|
||||
| cms_article | cms_article_id |
|
||||
|
||||
**非外键业务标识字段**:避免使用 `xxx_id` 格式
|
||||
|
||||
| 业务含义 | 错误命名 | 正确命名 |
|
||||
|----------|----------|----------|
|
||||
| 设备唯一标识 | device_id | device_uuid / device_sn |
|
||||
| 钉钉用户ID | dingtalk_user_id | dt_user_id / dingtalk_uid |
|
||||
| 微信OpenID | wechat_id | wechat_openid |
|
||||
| 外部系统编号 | external_id | external_code / external_sn |
|
||||
|
||||
### 5. 外键智能匹配机制
|
||||
|
||||
代码生成器会按以下优先级自动匹配外键关联的表:
|
||||
|
||||
1. **完全匹配**:`user_id` → 查找 `user` 表
|
||||
2. **带前缀的完全匹配**:如果没找到,尝试 `user_id` → 查找 `sys_user` 表(默认前缀)
|
||||
3. **去除字段前缀匹配**:如果字段有前缀且找不到对应表,会去掉前缀匹配
|
||||
|
||||
**示例**:`admin_user_id` 字段的匹配过程:
|
||||
1. 先查找 `admin_user` 表 → 如果存在,关联到 `admin_user` 表
|
||||
2. 如果不存在,去掉前缀查找 `user` 表 → 如果存在,关联到 `user` 表
|
||||
|
||||
**使用场景**:当一张表需要记录多个用户(如创建人、审核人、处理人)时:
|
||||
|
||||
```sql
|
||||
CREATE TABLE `order` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int(11) COMMENT '下单用户',
|
||||
`audit_user_id` int(11) COMMENT '审核人',
|
||||
`handle_user_id` int(11) COMMENT '处理人',
|
||||
...
|
||||
);
|
||||
```
|
||||
|
||||
如果系统中没有 `audit_user` 和 `handle_user` 表,代码生成器会自动将 `audit_user_id` 和 `handle_user_id` 识别为指向 `user` 表的外键。
|
||||
|
||||
### 6. 关联表冗余外键
|
||||
|
||||
关联表应包含必要的冗余外键便于查询:
|
||||
|
||||
```sql
|
||||
-- user_app 表(用户与应用的关联)
|
||||
CREATE TABLE user_app (
|
||||
id int(11) NOT NULL AUTO_INCREMENT,
|
||||
user_id int(11) DEFAULT NULL COMMENT '用户ID',
|
||||
app_id int(11) DEFAULT NULL COMMENT '应用ID',
|
||||
app_category_id int(11) DEFAULT NULL COMMENT '应用分类ID(冗余,便于按分类查询)',
|
||||
...
|
||||
);
|
||||
```
|
||||
|
||||
### 7. 层级关系字段
|
||||
|
||||
树形结构的表使用以下字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| parent_id | int | 父级ID,顶级为 NULL 或 0 |
|
||||
| parent_ids | varchar(255) | 完整层级路径,格式:`,1,3,5,`(从根到当前,逗号分隔) |
|
||||
| level | int | 层级深度,从 0 开始 |
|
||||
|
||||
**示例**:
|
||||
|
||||
```
|
||||
id=1, parent_id=NULL, parent_ids=",1,", level=0 -- 顶级
|
||||
id=3, parent_id=1, parent_ids=",1,3,", level=1 -- 一级
|
||||
id=5, parent_id=3, parent_ids=",1,3,5,", level=2 -- 二级
|
||||
```
|
||||
|
||||
`parent_ids` 的设计便于快速查询所有子级:
|
||||
```sql
|
||||
SELECT * FROM org WHERE parent_ids LIKE '%,3,%' -- 查询id=3的所有子级
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 时间字段类型
|
||||
|
||||
不同数据库使用对应的时间类型:
|
||||
|
||||
| 数据库 | 类型 | 示例 |
|
||||
|--------|------|------|
|
||||
| MySQL | datetime | `2024-01-15 10:30:00` |
|
||||
| SQLite | TEXT | `2024-01-15 10:30:00` |
|
||||
| PostgreSQL | timestamp | `2024-01-15 10:30:00` |
|
||||
| 达梦 DM8 | TIMESTAMP | `2024-01-15 10:30:00` |
|
||||
|
||||
---
|
||||
|
||||
## 表注释规则
|
||||
|
||||
### 表备注命名建议
|
||||
|
||||
表备注建议使用"XX管理"格式,便于在管理后台显示:
|
||||
|
||||
```sql
|
||||
-- MySQL 表备注示例
|
||||
CREATE TABLE `user` (
|
||||
...
|
||||
) COMMENT='用户管理';
|
||||
|
||||
CREATE TABLE `article` (
|
||||
...
|
||||
) COMMENT='文章管理';
|
||||
|
||||
CREATE TABLE `order` (
|
||||
...
|
||||
) COMMENT='订单管理';
|
||||
```
|
||||
|
||||
### SQLite 表备注
|
||||
|
||||
SQLite 不支持表备注,代码生成器会使用表名作为默认显示名称。如需自定义,可在配置文件中手动设置 `label`(详见 [CodeGen_代码生成.md](CodeGen_代码生成.md))。
|
||||
|
||||
---
|
||||
|
||||
## 字段注释规则
|
||||
|
||||
### 注释语法格式
|
||||
|
||||
字段注释支持以下格式组合:
|
||||
|
||||
```
|
||||
显示名称(前端提示):选项值 附加备注
|
||||
显示名称(前端提示):选项值 附加备注
|
||||
显示名称{前端提示}:选项值 附加备注
|
||||
```
|
||||
|
||||
| 部分 | 分隔符 | 用途 |
|
||||
|------|--------|------|
|
||||
| 显示名称 | 无 | 前端表单/列表的字段标签 |
|
||||
| 前端提示 | `()`、`()` 或 `{}` | 存储到 `ps` 字段,前端用于字段说明提示(编辑页输入框右侧、表格/详情鼠标悬停气泡) |
|
||||
| 选项值 | `:` 冒号 | select 类型的下拉选项(格式:`值-名称,值-名称`) |
|
||||
| 附加备注 | 空格 | 仅在数据库中查看,不传递给前端 |
|
||||
|
||||
### 选择类型字段
|
||||
|
||||
使用 `标签:值-名称,值-名称` 格式,代码生成器会自动解析为下拉选项:
|
||||
|
||||
```sql
|
||||
`state` int(2) DEFAULT '0' COMMENT '状态:0-正常,1-异常,2-隐藏'
|
||||
`sex` int(1) DEFAULT '0' COMMENT '性别:0-未知,1-男,2-女'
|
||||
`status` int(2) DEFAULT '0' COMMENT '审核状态:0-待审核,1-已通过,2-已拒绝'
|
||||
```
|
||||
|
||||
### 普通字段
|
||||
|
||||
直接使用中文说明:
|
||||
|
||||
```sql
|
||||
`name` varchar(50) DEFAULT NULL COMMENT '名称'
|
||||
`phone` varchar(20) DEFAULT NULL COMMENT '手机号'
|
||||
`email` varchar(100) DEFAULT NULL COMMENT '邮箱'
|
||||
```
|
||||
|
||||
### 附加备注(空格后)
|
||||
|
||||
空格后的内容**不会传递给前端**,仅供数据库设计时参考:
|
||||
|
||||
```sql
|
||||
`user_id` int(11) COMMENT '用户ID 关联user表的主键'
|
||||
`amount` decimal(10,2) COMMENT '金额 单位为元,精确到分'
|
||||
```
|
||||
|
||||
### 前端提示(括号内)
|
||||
|
||||
`()`、`()` 或 `{}` 中的内容存储到 `ps` 字段,前端展示为字段说明提示。推荐使用 `()` 或 `()`,`{}` 为兼容旧格式保留。
|
||||
|
||||
```sql
|
||||
`parent_id` int(11) COMMENT '父级ID(顶级为NULL)'
|
||||
`status` int(2) COMMENT '状态(当前审核状态):0-待审,1-通过,2-驳回'
|
||||
`phone` varchar(20) COMMENT '手机号(请输入11位手机号)'
|
||||
`email` varchar(100) COMMENT '邮箱{格式:xxx@xxx.com}'
|
||||
```
|
||||
|
||||
前端展示效果:
|
||||
- **编辑/新增页面**:输入框右侧显示灰色小字提示
|
||||
- **表格列表/详情页面**:鼠标悬停字段名时显示气泡提示
|
||||
|
||||
### 组合使用
|
||||
|
||||
```sql
|
||||
-- 推荐写法:使用圆括号
|
||||
`status` int(2) DEFAULT '0' COMMENT '状态(选择当前审核状态):0-待审核,1-已通过,2-已拒绝'
|
||||
`parent_id` int(11) DEFAULT NULL COMMENT '父级ID(顶级为NULL)'
|
||||
|
||||
-- 兼容写法:使用花括号
|
||||
`status` int(2) DEFAULT '0' COMMENT '状态{选择当前审核状态}:0-待审核,1-已通过,2-已拒绝'
|
||||
```
|
||||
|
||||
解析结果(以第一行为例):
|
||||
- `label` = "状态"
|
||||
- `ps` = "选择当前审核状态"
|
||||
- `options` = [{name:"待审核", value:"0"}, {name:"已通过", value:"1"}, {name:"已拒绝", value:"2"}]
|
||||
|
||||
三种括号 `()`、`()`、`{}` 功能相同,优先匹配顺序为:`()` > `()` > `{}`,同一注释中只取第一个匹配到的括号对。
|
||||
|
||||
### SQLite 字段备注
|
||||
|
||||
SQLite 不支持字段备注(COMMENT),代码生成器会使用字段名作为默认显示名称。如需自定义,可在配置文件中手动设置(详见 [CodeGen_代码生成.md](CodeGen_代码生成.md))。
|
||||
|
||||
---
|
||||
|
||||
## 必有字段规则
|
||||
|
||||
每张业务表必须包含以下三个字段:
|
||||
|
||||
### MySQL
|
||||
|
||||
```sql
|
||||
`state` int(2) DEFAULT '0' COMMENT '状态:0-正常,1-异常,2-隐藏',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建日期',
|
||||
`modify_time` datetime DEFAULT NULL COMMENT '变更时间',
|
||||
```
|
||||
|
||||
### SQLite
|
||||
|
||||
```sql
|
||||
"state" INTEGER DEFAULT 0, -- 状态:0-正常,1-异常,2-隐藏
|
||||
"create_time" TEXT DEFAULT NULL, -- 创建日期
|
||||
"modify_time" TEXT DEFAULT NULL, -- 变更时间
|
||||
```
|
||||
|
||||
### PostgreSQL
|
||||
|
||||
```sql
|
||||
"state" INTEGER DEFAULT 0, -- 状态:0-正常,1-异常,2-隐藏
|
||||
"create_time" TIMESTAMP DEFAULT NULL, -- 创建日期
|
||||
"modify_time" TIMESTAMP DEFAULT NULL, -- 变更时间
|
||||
```
|
||||
|
||||
### 达梦 DM8
|
||||
|
||||
```sql
|
||||
"state" INT DEFAULT 0, -- 状态:0-正常,1-异常,2-隐藏
|
||||
"create_time" TIMESTAMP DEFAULT NULL, -- 创建日期
|
||||
"modify_time" TIMESTAMP DEFAULT NULL, -- 变更时间
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完整建表示例
|
||||
|
||||
### MySQL 示例
|
||||
|
||||
```sql
|
||||
-- 用户表
|
||||
CREATE TABLE `user` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`name` varchar(50) DEFAULT NULL COMMENT '用户名',
|
||||
`phone` varchar(20) DEFAULT NULL COMMENT '手机号',
|
||||
`password` varchar(64) DEFAULT NULL COMMENT '密码',
|
||||
`org_id` int(11) DEFAULT NULL COMMENT '组织ID',
|
||||
`role_id` int(11) DEFAULT NULL COMMENT '角色ID',
|
||||
`avatar` varchar(255) DEFAULT NULL COMMENT '头像',
|
||||
`sex` int(1) DEFAULT '0' COMMENT '性别:0-未知,1-男,2-女',
|
||||
`state` int(2) DEFAULT '0' COMMENT '状态:0-正常,1-异常,2-隐藏',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建日期',
|
||||
`modify_time` datetime DEFAULT NULL COMMENT '变更时间',
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
|
||||
|
||||
-- 组织表(树形结构)
|
||||
CREATE TABLE `org` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`name` varchar(100) DEFAULT NULL COMMENT '组织名称',
|
||||
`parent_id` int(11) DEFAULT NULL COMMENT '父级ID',
|
||||
`parent_ids` varchar(255) DEFAULT NULL COMMENT '层级路径',
|
||||
`level` int(2) DEFAULT '0' COMMENT '层级深度',
|
||||
`sort` int(5) DEFAULT '0' COMMENT '排序',
|
||||
`state` int(2) DEFAULT '0' COMMENT '状态:0-正常,1-异常,2-隐藏',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建日期',
|
||||
`modify_time` datetime DEFAULT NULL COMMENT '变更时间',
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='组织表';
|
||||
|
||||
-- 用户组织关联表
|
||||
CREATE TABLE `user_org` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`user_id` int(11) DEFAULT NULL COMMENT '用户ID',
|
||||
`org_id` int(11) DEFAULT NULL COMMENT '组织ID',
|
||||
`state` int(2) DEFAULT '0' COMMENT '状态:0-正常,1-异常,2-隐藏',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建日期',
|
||||
`modify_time` datetime DEFAULT NULL COMMENT '变更时间',
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户组织关联表';
|
||||
```
|
||||
|
||||
### SQLite 示例
|
||||
|
||||
```sql
|
||||
-- 用户表
|
||||
CREATE TABLE "user" (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"name" TEXT DEFAULT NULL,
|
||||
"phone" TEXT DEFAULT NULL,
|
||||
"password" TEXT DEFAULT NULL,
|
||||
"org_id" INTEGER DEFAULT NULL,
|
||||
"role_id" INTEGER DEFAULT NULL,
|
||||
"avatar" TEXT DEFAULT NULL,
|
||||
"sex" INTEGER DEFAULT 0,
|
||||
"state" INTEGER DEFAULT 0,
|
||||
"create_time" TEXT DEFAULT NULL,
|
||||
"modify_time" TEXT DEFAULT NULL
|
||||
);
|
||||
```
|
||||
|
||||
### 达梦 DM8 示例
|
||||
|
||||
达梦建表有以下关键差异:
|
||||
- 自增主键使用 `IDENTITY(1,1)`(对应 MySQL 的 `AUTO_INCREMENT`)
|
||||
- 长文本使用 `CLOB`(对应 MySQL 的 `LONGTEXT`)
|
||||
- 标识符建议用**双引号包裹小写名称**,与框架保持一致
|
||||
- `admin`、`user`、`order` 等是 DM 保留字,必须加双引号
|
||||
|
||||
```sql
|
||||
-- 用户表
|
||||
CREATE TABLE "user" (
|
||||
"id" INT IDENTITY(1,1) PRIMARY KEY,
|
||||
"name" VARCHAR(50) DEFAULT NULL,
|
||||
"phone" VARCHAR(20) DEFAULT NULL,
|
||||
"password" VARCHAR(64) DEFAULT NULL,
|
||||
"org_id" INT DEFAULT NULL,
|
||||
"role_id" INT DEFAULT NULL,
|
||||
"avatar" VARCHAR(255) DEFAULT NULL,
|
||||
"sex" INT DEFAULT 0,
|
||||
"state" INT DEFAULT 0,
|
||||
"create_time" TIMESTAMP DEFAULT NULL,
|
||||
"modify_time" TIMESTAMP DEFAULT NULL
|
||||
);
|
||||
|
||||
-- 组织表(树形结构)
|
||||
CREATE TABLE "org" (
|
||||
"id" INT IDENTITY(1,1) PRIMARY KEY,
|
||||
"name" VARCHAR(100) DEFAULT NULL,
|
||||
"parent_id" INT DEFAULT NULL,
|
||||
"parent_ids" VARCHAR(255) DEFAULT NULL,
|
||||
"level" INT DEFAULT 0,
|
||||
"sort" INT DEFAULT 0,
|
||||
"state" INT DEFAULT 0,
|
||||
"create_time" TIMESTAMP DEFAULT NULL,
|
||||
"modify_time" TIMESTAMP DEFAULT NULL
|
||||
);
|
||||
|
||||
-- 用户组织关联表
|
||||
CREATE TABLE "user_org" (
|
||||
"id" INT IDENTITY(1,1) PRIMARY KEY,
|
||||
"user_id" INT DEFAULT NULL,
|
||||
"org_id" INT DEFAULT NULL,
|
||||
"state" INT DEFAULT 0,
|
||||
"create_time" TIMESTAMP DEFAULT NULL,
|
||||
"modify_time" TIMESTAMP DEFAULT NULL
|
||||
);
|
||||
```
|
||||
|
||||
> **注意**:达梦中 DDL 的表名/列名大小写敏感(双引号包裹时),建议统一使用小写,与框架自动生成的 SQL 保持一致。
|
||||
|
||||
---
|
||||
|
||||
## 规范检查清单
|
||||
|
||||
在设计数据库时,请确认:
|
||||
|
||||
- [ ] 表名无系统前缀
|
||||
- [ ] 主键统一为 `id`
|
||||
- [ ] 外键格式为 `表名_id`
|
||||
- [ ] 所有 `xxx_id` 字段都指向实际存在的表
|
||||
- [ ] 非外键业务标识不使用 `xxx_id` 格式
|
||||
- [ ] 树形表包含 parent_id、parent_ids、level
|
||||
- [ ] 所有表包含 state、create_time、modify_time
|
||||
- [ ] 选择字段注释格式正确(`标签:值-名称`)
|
||||
|
||||
### 达梦 DM8 额外检查项
|
||||
|
||||
- [ ] 建表时标识符使用**双引号包裹小写名称**(如 `"user"`、`"id"`)
|
||||
- [ ] 保留字(`admin`、`user`、`order`、`key` 等)必须加双引号
|
||||
- [ ] 自增主键使用 `IDENTITY(1,1)` 而非 `AUTO_INCREMENT`
|
||||
- [ ] 长文本字段使用 `CLOB` 而非 `LONGTEXT`
|
||||
- [ ] 检测表是否存在时用 `COUNT(*)` 查询,不用 `USER_TABLES`(存在 schema 错配问题)
|
||||
|
||||
---
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [CodeGen_代码生成.md](CodeGen_代码生成.md) — 按本规范建表后运行代码生成
|
||||
- [HoTimeDB_使用说明.md](HoTimeDB_使用说明.md) — 运行时 ORM(与建表规范分开)
|
||||
- [文档索引](README.md)
|
||||
@@ -1,145 +0,0 @@
|
||||
# HoTime 框架优雅停机
|
||||
|
||||
## 概述
|
||||
|
||||
HoTime 框架内置优雅停机支持,收到关闭信号后:
|
||||
|
||||
1. **立即**对所有新请求返回 `503 Service Unavailable`,触发 nginx 把流量切到其他实例
|
||||
2. **等待** `DrainTimeout`(默认 5 秒)让 nginx 完成流量切换
|
||||
3. **等待**所有正在处理的请求完成(最多 `ShutdownTimeout`,默认 30 秒)
|
||||
4. 以 `exit(0)` 正常退出;若使用进程守护脚本(如 `run_app.cmd`),可检测正常退出后不自动重启
|
||||
|
||||
---
|
||||
|
||||
## 触发方式对照表
|
||||
|
||||
| 操作 | 平台 | 是否可优雅停机 | 说明 |
|
||||
|---|---|:---:|---|
|
||||
| `Ctrl+C` | Windows / Linux | ✅ | 发送 SIGINT / os.Interrupt |
|
||||
| `kill <pid>` 或 `kill -15 <pid>` | Linux | ✅ | **推荐**,发送 SIGTERM |
|
||||
| `kill -2 <pid>` | Linux | ✅ | 等同 Ctrl+C,发送 SIGINT |
|
||||
| `taskkill /PID <pid>`(不加 /F) | Windows | ✅ | **推荐**,等同发送 Ctrl+C |
|
||||
| 关闭控制台/exe 窗口 X 按钮 | Windows | ✅(≤5s) | CTRL_CLOSE_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;
|
||||
# EdgeOne 场景优先 EO-Connecting-IP;空则框架再降级到 XFF / RemoteAddr
|
||||
proxy_set_header X-Real-IP $http_eo_connecting_ip;
|
||||
proxy_set_header EO-Connecting-IP $http_eo_connecting_ip;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `max_fails=1 fail_timeout=3s`:连接级失败(如强杀后端口拒绝)时标记下线 3s
|
||||
- `proxy_next_upstream http_503`:应用级 503 时 nginx 自动重试下一台
|
||||
|
||||
---
|
||||
|
||||
## 滚动重启步骤
|
||||
|
||||
### Windows
|
||||
|
||||
```cmd
|
||||
:: 1. 查找端口 9998 的进程 PID
|
||||
netstat -ano | findstr :9998
|
||||
|
||||
:: 2. 优雅关闭该实例(不加 /F,发送 Ctrl+C 信号)
|
||||
taskkill /PID <pid>
|
||||
|
||||
:: 3. 观察日志,出现"服务已安全关闭"后,部署新版并启动
|
||||
:: 若使用 run_app.cmd 守护,检测到 exit(0) 后不会自动重启
|
||||
|
||||
:: 4. 对端口 9999 重复以上步骤
|
||||
netstat -ano | findstr :9999
|
||||
taskkill /PID <pid2>
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
```bash
|
||||
# 1. 查找进程 PID(按你的二进制名替换)
|
||||
pgrep -a <your-app> # 或
|
||||
ps aux | grep <your-app>
|
||||
|
||||
# 2. 优雅关闭(发送 SIGTERM)
|
||||
kill <pid>
|
||||
|
||||
# 3. 观察日志确认关闭完成
|
||||
tail -f logs/app.log # 等待"服务已安全关闭"
|
||||
|
||||
# 4. 部署新版并启动,再对另一个实例重复
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 工作原理
|
||||
|
||||
```
|
||||
收到信号 / 关窗口
|
||||
│
|
||||
▼
|
||||
shuttingDown = true
|
||||
│
|
||||
▼ DrainTimeout(默认 5s)
|
||||
新请求 → 503 ──────────────────────► nginx 切流到另一台
|
||||
│
|
||||
▼
|
||||
server.Shutdown(ShutdownTimeout=30s)
|
||||
等待所有进行中请求跑完
|
||||
│
|
||||
▼
|
||||
os.Exit(0) → 守护脚本(如 run_app.cmd)可检测 exit(0) 后不重启
|
||||
```
|
||||
|
||||
### 为何在框架层而非应用层实现
|
||||
|
||||
通过 `appIns.Run(Router{...})` 启动服务,信号处理与 `http.Server` 生命周期强绑定,必须在框架的 `Run()` 方法内才能正确管理。应用层无需任何修改,升级框架即自动获得优雅停机能力。
|
||||
|
||||
### `sync.Once` 保证幂等性
|
||||
|
||||
框架同时监听多路触发源(信号 goroutine + Windows 关窗口 handler),`sync.Once` 保证无论哪路先触发,`initiateGracefulShutdown` 只执行一次,不会重复调用 `Shutdown()`。
|
||||
@@ -1,214 +0,0 @@
|
||||
# HoTimeDB API 快速参考
|
||||
|
||||
完整教程见 [HoTimeDB 使用说明](HoTimeDB_使用说明.md)。本文仅收录条件运算符与方法签名速查。
|
||||
|
||||
---
|
||||
|
||||
## 条件运算符
|
||||
|
||||
多条件默认 AND;关键字大小写均可(`ORDER`/`order`)。
|
||||
|
||||
| 写法 | SQL | 说明 |
|
||||
|------|-----|------|
|
||||
| `"field": value` | `field = ?` | 等于 |
|
||||
| `"field[!]": value` | `field != ?` | 不等于 |
|
||||
| `"field[>]": value` | `field > ?` | 大于 |
|
||||
| `"field[>=]": value` | `field >= ?` | 大于等于 |
|
||||
| `"field[<]": value` | `field < ?` | 小于 |
|
||||
| `"field[<=]": value` | `field <= ?` | 小于等于 |
|
||||
| `"field[~]": "kw"` | `LIKE '%kw%'` | 包含 |
|
||||
| `"field[~!]": "kw"` | `LIKE 'kw%'` | 开头 |
|
||||
| `"field[!~]": "kw"` | `LIKE '%kw'` | 结尾 |
|
||||
| `"field[~~]": "%kw%"` | `LIKE '%kw%'` | 手动 LIKE |
|
||||
| `"field[<>]": [min,max]` | `BETWEEN ? AND ?` | 区间内 |
|
||||
| `"field[><]": [min,max]` | `NOT BETWEEN` | 区间外 |
|
||||
| `"field": [v1,v2]` | `IN (?,?)` | 集合 |
|
||||
| `"field[!]": [v1,v2]` | `NOT IN` | 非集合 |
|
||||
| `"field": nil` | `IS NULL` | 空 |
|
||||
| `"field[!]": nil` | `IS NOT NULL` | 非空 |
|
||||
| `"field[#]": "balance+1"` | `field = balance+1` | 直接表达式 |
|
||||
| `"[##]": "a > b"` | `a > b` | 直接 SQL 片段 |
|
||||
| `"field[#!]": "1"` | `field != 1` | 非参数化不等 |
|
||||
|
||||
时间字段推荐 Go 侧传值:`"create_time": Time2Str(time.Now())`(勿用 `NOW()`)。
|
||||
|
||||
### 逻辑与特殊关键字
|
||||
|
||||
```go
|
||||
Map{"status": 1, "age[>]": 18} // 自动 AND
|
||||
Map{"AND": Map{...}} / Map{"OR": Map{...}} // 显式逻辑
|
||||
Map{"AND": Map{"status": 1, "OR": Map{...}}} // 嵌套
|
||||
Map{"ORDER": "id DESC"} / []string{"a DESC","b"} // ORDER BY
|
||||
Map{"GROUP": "dept"} / []string{"a","b"} // GROUP BY
|
||||
Map{"HAVING": Map{"COUNT(*).[>]": 5}} // HAVING
|
||||
Map{"LIMIT": 20} / []int{10, 20} // LIMIT / offset+limit
|
||||
Map{"OFFSET": 20} // 独立 OFFSET
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 方法签名
|
||||
|
||||
### 连接与元信息
|
||||
|
||||
| 方法 | 签名 | 说明 |
|
||||
|------|------|------|
|
||||
| SetConnect | `SetConnect(func() (master, slave *sql.DB))` | 设置连接并 InitDb |
|
||||
| InitDb | `InitDb()` | 初始化连接与方言 |
|
||||
| GetPrefix | `GetPrefix() string` | 表前缀 |
|
||||
| GetType | `GetType() string` | 库类型 |
|
||||
| GetDialect | `GetDialect() Dialect` | 方言适配器 |
|
||||
| GetLast | `GetLast() *DBError` | 最近一次 SQL 快照 |
|
||||
| GetLastError | `GetLastError() error` | 最近错误 |
|
||||
| GetLastQuery | `GetLastQuery() string` | 最近 SQL |
|
||||
| GetLastData | `GetLastData() []interface{}` | 最近参数 |
|
||||
|
||||
`Type`:`mysql` / `sqlite3` / `postgres` / `dm`(或 `dameng`)。
|
||||
|
||||
### CRUD
|
||||
|
||||
| 方法 | 签名 | 返回 |
|
||||
|------|------|------|
|
||||
| Select | `Select(table string, qu ...interface{})` | `[]Map` |
|
||||
| Get | `Get(table string, qu ...interface{})` | `Map`(自动 LIMIT 1) |
|
||||
| Insert | `Insert(table string, data map[string]interface{})` | `int64` 新 ID |
|
||||
| Inserts | `Inserts(table string, dataList []Map)` | `int64` 影响行数 |
|
||||
| Update | `Update(table string, data Map, where Map)` | `int64` |
|
||||
| Upsert | `Upsert(table string, data Map, uniqueKeys Slice, updateColumns ...interface{})` | `int64` |
|
||||
| Delete | `Delete(table string, data map[string]interface{})` | `int64` |
|
||||
| Page | `Page(page, pageRow int) *HoTimeDB` | 链式分页 |
|
||||
| PageSelect | `PageSelect(table string, qu ...interface{})` | `[]Map` |
|
||||
|
||||
`Select`/`Get`/`PageSelect` 的 `qu` 常见形态:字段;字段+where;join+字段+where。
|
||||
|
||||
```go
|
||||
database.Select("user")
|
||||
database.Select("user", "id,name")
|
||||
database.Select("user", "*", where)
|
||||
database.Select("user", joinSlice, "fields", where)
|
||||
database.Get("user", "fields", where)
|
||||
database.Insert("user", Map{"name": "a"})
|
||||
database.Inserts("user", []Map{{"name": "a"}, {"name": "b"}})
|
||||
database.Update("user", Map{"name": "b"}, Map{"id": 1})
|
||||
database.Upsert("user", data, Slice{"id"}, Slice{"name", "email"})
|
||||
database.Upsert("user", data, Slice{"id"}, "name", "email")
|
||||
database.Delete("user", Map{"id": 1})
|
||||
database.Page(1, 20).PageSelect("user", "id,name", where)
|
||||
```
|
||||
|
||||
### 聚合
|
||||
|
||||
| 方法 | 签名 | 返回 |
|
||||
|------|------|------|
|
||||
| Count | `Count(table string, qu ...interface{})` | `int` |
|
||||
| Sum | `Sum(table string, column string, qu ...interface{})` | `float64` |
|
||||
| Avg | `Avg(table string, column string, qu ...interface{})` | `float64` |
|
||||
| Max | `Max(table string, column string, qu ...interface{})` | `float64` |
|
||||
| Min | `Min(table string, column string, qu ...interface{})` | `float64` |
|
||||
|
||||
`qu` 可为 where,或 join+where。
|
||||
|
||||
```go
|
||||
database.Count("user")
|
||||
database.Count("user", where)
|
||||
database.Count("user", join, where)
|
||||
database.Sum("order", "amount", where)
|
||||
database.Avg("order", "amount", where)
|
||||
database.Max("order", "amount", where)
|
||||
database.Min("order", "amount", where)
|
||||
```
|
||||
|
||||
### 链式构建器
|
||||
|
||||
```go
|
||||
b := database.Table("tablename") // *HotimeDBBuilder
|
||||
```
|
||||
|
||||
| 方法 | 签名 | 说明 |
|
||||
|------|------|------|
|
||||
| Where / And / Or | `(qu ...interface{}) *HotimeDBBuilder` | 键值或 Map |
|
||||
| LeftJoin / RightJoin / InnerJoin / FullJoin | `(table, condition string)` | JOIN |
|
||||
| Join | `(qu ...interface{})` | 通用 JOIN Map |
|
||||
| Order / Group / Limit | `(qu ...interface{})` | 排序/分组/限制 |
|
||||
| Having | `(qu ...interface{})` | HAVING |
|
||||
| Page | `(page, pageRow int)` | 分页 |
|
||||
| Offset | `(offset int)` | 偏移 |
|
||||
| From | `(table string)` | 换表 |
|
||||
| Select | `(qu ...interface{}) []Map` | 查询 |
|
||||
| Get | `(qu ...interface{}) Map` | 单条 |
|
||||
| Count | `() int` | 计数 |
|
||||
| Update | `(qu ...interface{}) int64` | 更新 |
|
||||
| Delete | `() int64` | 删除 |
|
||||
|
||||
```go
|
||||
database.Table("user").Where("status", 1).Order("id DESC").Page(1, 20).Select("id,name")
|
||||
database.Table("order").LeftJoin("user", "order.user_id = user.id").Where("order.status", "paid").Select("order.*, user.name")
|
||||
```
|
||||
|
||||
### JOIN Map 写法
|
||||
|
||||
| 写法 | 类型 |
|
||||
|------|------|
|
||||
| `Map{"[>]profile": "user.id = profile.user_id"}` | LEFT |
|
||||
| `Map{"[<]department": "user.dept_id = department.id"}` | RIGHT |
|
||||
| `Map{"[><]role": "user.role_id = role.id"}` | INNER |
|
||||
| `Map{"[<>]group": "user.group_id = group.id"}` | FULL |
|
||||
|
||||
### 事务与原生 SQL
|
||||
|
||||
| 方法 | 签名 | 说明 |
|
||||
|------|------|------|
|
||||
| Action | `Action(func(db HoTimeDB) bool) bool` | `true` 提交 / `false` 回滚 |
|
||||
| Query | `Query(query string, args ...interface{}) []Map` | 原生查询 |
|
||||
| Exec | `Exec(query string, args ...interface{}) (sql.Result, error)` | 原生执行 |
|
||||
|
||||
```go
|
||||
ok := database.Action(func(tx HoTimeDB) bool {
|
||||
if tx.Insert("a", data) == 0 { return false }
|
||||
return true
|
||||
})
|
||||
database.Query("SELECT * FROM user WHERE age > ?", 18)
|
||||
database.Exec("UPDATE user SET status = ? WHERE id = ?", 1, 100)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 方言差异速查
|
||||
|
||||
| 特性 | MySQL | PostgreSQL | 达梦 DM8 |
|
||||
|------|-------|------------|---------|
|
||||
| 标识符引号 | \`name\` | `"name"` | `"name"` |
|
||||
| 占位符 | `?` | `$1,$2...` | `?` |
|
||||
| Upsert | ON DUPLICATE KEY | ON CONFLICT | MERGE INTO |
|
||||
| 自增 | AUTO_INCREMENT | SERIAL | IDENTITY(1,1) |
|
||||
| 分页 | LIMIT m,n | LIMIT n OFFSET m | 两者皆可 |
|
||||
|
||||
框架自动适配,业务代码通常无需按库分支。达梦:保留字(`admin`/`user`/`order`)ORM 自动加引号,原生 SQL 需手动双引号;时间统一用 `Time2Str(time.Now())`。
|
||||
|
||||
```go
|
||||
// Type: "postgres" | "dm" | "dameng" | "mysql" | "sqlite3"
|
||||
database := &db.HoTimeDB{Type: "dm"}
|
||||
database.SetConnect(func() (master, slave *sql.DB) {
|
||||
master, _ = sql.Open("dm", "dm://SYSDBA:pwd@127.0.0.1:5236?schema=TEST")
|
||||
return master, master
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常用一行模式
|
||||
|
||||
```go
|
||||
// 分页列表
|
||||
total := database.Count("user", Map{"status": 1})
|
||||
rows := database.Table("user").Where("status", 1).Order("id DESC").Page(page, size).Select("id,name")
|
||||
|
||||
// 分组统计
|
||||
database.Select("order", "user_id, COUNT(*) c, SUM(amount) s", Map{
|
||||
"status": "paid", "GROUP": "user_id", "ORDER": "s DESC",
|
||||
})
|
||||
|
||||
// 查错
|
||||
if err := database.GetLastError(); err != nil { /* ... */ }
|
||||
_ = database.GetLastQuery(); _ = database.GetLastData()
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,576 +0,0 @@
|
||||
# HoTime 快速上手指南
|
||||
|
||||
5 分钟入门 HoTime 框架。完整文档索引见 [README.md](README.md)。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
go get code.hoteas.com/golang/hotime
|
||||
```
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
func main() {
|
||||
appIns := Init("config/config.json")
|
||||
|
||||
appIns.Run(Router{
|
||||
"app": {
|
||||
"test": {
|
||||
"hello": func(that *Context) {
|
||||
that.Display(0, Map{"message": "Hello World"})
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
访问: `http://localhost:8081/app/test/hello`
|
||||
|
||||
## 配置文件
|
||||
|
||||
创建 `config/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"port": "8081",
|
||||
"mode": 2,
|
||||
"sessionName": "HOTIME",
|
||||
"tpt": "tpt",
|
||||
"defFile": ["index.html", "index.htm"],
|
||||
"db": {
|
||||
"mysql": {
|
||||
"host": "localhost",
|
||||
"port": "3306",
|
||||
"name": "your_database",
|
||||
"user": "root",
|
||||
"password": "your_password",
|
||||
"prefix": ""
|
||||
}
|
||||
},
|
||||
"cache": {
|
||||
"memory": {
|
||||
"db": true,
|
||||
"session": true,
|
||||
"timeout": 7200
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 配置项说明
|
||||
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
|--------|--------|------|
|
||||
| `port` | 80 | HTTP 服务端口,0 为不启用 |
|
||||
| `tlsPort` | - | HTTPS 端口,需配合 tlsCert/tlsKey |
|
||||
| `tlsCert` | - | HTTPS 证书路径 |
|
||||
| `tlsKey` | - | HTTPS 密钥路径 |
|
||||
| `mode` | 0 | 0=生产, 1=测试, 2=开发(输出SQL) |
|
||||
| `tpt` | tpt | 静态文件目录 |
|
||||
| `sessionName` | HOTIME | Session Cookie 名称 |
|
||||
| `modeRouterStrict` | false | 路由大小写敏感,false=忽略大小写 |
|
||||
| `crossDomain` | - | 跨域设置,空=不开启,auto=智能开启,或指定域名 |
|
||||
| `logFile` | - | 日志文件路径,如 `logs/20060102.txt` |
|
||||
| `logLevel` | 1 | 日志等级:0=仅错误,>=1=全部;同时控制 SQL 日志。**不**控制静态 `Cache-Control`(静态固定 `public`) |
|
||||
| `webConnectLogShow` | true | 是否显示访问日志 |
|
||||
| `defFile` | ["index.html"] | 目录默认访问文件 |
|
||||
|
||||
### 数据库配置
|
||||
|
||||
支持的数据库类型:`mysql`、`sqlite`、`postgres`(PostgreSQL)、`dm`(达梦 DM8)。
|
||||
|
||||
```json
|
||||
{
|
||||
"db": {
|
||||
"mysql": {
|
||||
"host": "127.0.0.1",
|
||||
"port": "3306",
|
||||
"name": "database_name",
|
||||
"user": "root",
|
||||
"password": "password",
|
||||
"prefix": "app_",
|
||||
"slave": {
|
||||
"host": "127.0.0.1",
|
||||
"port": "3306",
|
||||
"name": "database_name",
|
||||
"user": "root",
|
||||
"password": "password"
|
||||
}
|
||||
},
|
||||
"sqlite": {
|
||||
"path": "config/data.db",
|
||||
"prefix": ""
|
||||
},
|
||||
"dm": {
|
||||
"host": "127.0.0.1",
|
||||
"port": "5236",
|
||||
"name": "TEST",
|
||||
"user": "SYSDBA",
|
||||
"password": "your_password",
|
||||
"prefix": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> MySQL 配置 `slave` 项即启用主从读写分离
|
||||
> 达梦 DM8 的 `name` 字段对应 schema(搜索路径),驱动已内置于 `vendor/gitee.com/chunanyong/dm`,无需额外安装
|
||||
|
||||
### 缓存配置
|
||||
|
||||
```json
|
||||
{
|
||||
"cache": {
|
||||
"memory": {
|
||||
"db": true,
|
||||
"session": true,
|
||||
"timeout": 7200
|
||||
},
|
||||
"redis": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 6379,
|
||||
"password": "",
|
||||
"db": true,
|
||||
"session": true,
|
||||
"timeout": 1296000
|
||||
},
|
||||
"db": {
|
||||
"db": true,
|
||||
"session": true,
|
||||
"timeout": 2592000,
|
||||
"history": false,
|
||||
"mode": "compatible"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
缓存优先级: **Memory > Redis > DB**,自动穿透与回填
|
||||
|
||||
#### DB 缓存配置说明
|
||||
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
|--------|--------|------|
|
||||
| `db` | false | 是否缓存数据库查询 |
|
||||
| `session` | true | 是否缓存 Session |
|
||||
| `timeout` | 2592000 | 过期时间(秒) |
|
||||
| `history` | false | 是否记录缓存历史,开启后每次新增/修改缓存都会记录到历史表 |
|
||||
| `mode` | compatible | 缓存表模式,见下表 |
|
||||
|
||||
**缓存表模式 (mode)**:
|
||||
|
||||
| 模式 | 说明 |
|
||||
|------|------|
|
||||
| `compatible` | **默认**。兼容模式:写新表,新表无数据时回退读老表;写入时自动删除老表同 key 记录;删除时同时删两表。适合从老版本平滑升级,老数据自然过期消亡 |
|
||||
| `new` | 只使用新表 `hotime_cache`,启动时自动迁移老表 `cached` 数据。老表保留由人工删除,不再被读写 |
|
||||
|
||||
> **升级建议**:从老版本升级时,建议先使用 `compatible` 模式运行一段时间(让老数据自然过期),确认无问题后再切换到 `new` 模式
|
||||
|
||||
> 从老版本升级时,建议使用 `compatible` 模式平滑过渡,待老表数据消亡后切换到 `new` 模式
|
||||
|
||||
### 错误码配置
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"1": "内部系统异常",
|
||||
"2": "访问权限异常",
|
||||
"3": "请求参数异常",
|
||||
"4": "数据处理异常",
|
||||
"5": "数据结果异常"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 自定义错误码建议从 10 开始
|
||||
|
||||
## 路由系统
|
||||
|
||||
HoTime 使用三层路由结构:`模块/控制器/方法`
|
||||
|
||||
```go
|
||||
appIns.Run(Router{
|
||||
"模块名": {
|
||||
"控制器名": {
|
||||
"方法名": func(that *Context) {
|
||||
// 处理逻辑
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### 路由路径
|
||||
|
||||
```go
|
||||
// 获取路由信息
|
||||
module := that.RouterString[0] // 模块
|
||||
controller := that.RouterString[1] // 控制器
|
||||
action := that.RouterString[2] // 方法
|
||||
|
||||
// 完整请求路径
|
||||
fullPath := that.HandlerStr // 如 /app/user/login
|
||||
```
|
||||
|
||||
## 请求参数获取
|
||||
|
||||
### 新版推荐方法(支持链式调用)
|
||||
|
||||
```go
|
||||
// 获取 URL 查询参数 (?id=1)
|
||||
id := that.ReqParam("id").ToInt()
|
||||
name := that.ReqParam("name").ToStr()
|
||||
|
||||
// 获取表单参数 (POST form-data / x-www-form-urlencoded)
|
||||
username := that.ReqForm("username").ToStr()
|
||||
age := that.ReqForm("age").ToInt()
|
||||
|
||||
// 获取 JSON Body 参数 (POST application/json)
|
||||
data := that.ReqJson("data").ToMap()
|
||||
items := that.ReqJson("items").ToSlice()
|
||||
|
||||
// 统一获取(自动判断来源,优先级: JSON > Form > URL)
|
||||
userId := that.ReqData("user_id").ToInt()
|
||||
status := that.ReqData("status").ToStr()
|
||||
```
|
||||
|
||||
### 类型转换方法
|
||||
|
||||
```go
|
||||
obj := that.ReqData("key")
|
||||
|
||||
obj.ToStr() // 转字符串
|
||||
obj.ToInt() // 转 int
|
||||
obj.ToInt64() // 转 int64
|
||||
obj.ToFloat64() // 转 float64
|
||||
obj.ToBool() // 转 bool
|
||||
obj.ToMap() // 转 Map
|
||||
obj.ToSlice() // 转 Slice
|
||||
obj.Data // 获取原始值(interface{})
|
||||
```
|
||||
|
||||
### 文件上传
|
||||
|
||||
```go
|
||||
// 单文件上传
|
||||
file, header, err := that.ReqFile("avatar")
|
||||
if err == nil {
|
||||
defer file.Close()
|
||||
// header.Filename - 文件名
|
||||
// header.Size - 文件大小
|
||||
}
|
||||
|
||||
// 多文件上传(批量)
|
||||
files, err := that.ReqFiles("images")
|
||||
if err == nil {
|
||||
for _, fh := range files {
|
||||
file, _ := fh.Open()
|
||||
defer file.Close()
|
||||
// 处理每个文件
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 传统方法(兼容)
|
||||
|
||||
```go
|
||||
// GET/POST 参数
|
||||
name := that.Req.FormValue("name")
|
||||
|
||||
// URL 参数
|
||||
id := that.Req.URL.Query().Get("id")
|
||||
|
||||
// 请求头
|
||||
token := that.Req.Header.Get("Authorization")
|
||||
```
|
||||
|
||||
## 响应数据
|
||||
|
||||
### Display 方法
|
||||
|
||||
```go
|
||||
// 成功响应 (status=0)
|
||||
that.Display(0, Map{"user": user, "token": token})
|
||||
// 输出: {"status":0, "result":{"user":..., "token":...}}
|
||||
|
||||
// 错误响应 (status>0)
|
||||
that.Display(1, "系统内部错误")
|
||||
// 输出: {"status":1, "result":{"type":"内部系统异常", "msg":"系统内部错误"}, "error":{...}}
|
||||
|
||||
that.Display(2, "请先登录")
|
||||
// 输出: {"status":2, "result":{"type":"访问权限异常", "msg":"请先登录"}, "error":{...}}
|
||||
|
||||
that.Display(3, "参数不能为空")
|
||||
// 输出: {"status":3, "result":{"type":"请求参数异常", "msg":"参数不能为空"}, "error":{...}}
|
||||
```
|
||||
|
||||
### 错误码含义
|
||||
|
||||
| 错误码 | 类型 | 使用场景 |
|
||||
|--------|------|----------|
|
||||
| 0 | 成功 | 请求成功 |
|
||||
| 1 | 内部系统异常 | 环境配置、文件权限等基础运行环境错误 |
|
||||
| 2 | 访问权限异常 | 未登录或登录异常 |
|
||||
| 3 | 请求参数异常 | 参数不足、类型错误等 |
|
||||
| 4 | 数据处理异常 | 数据库操作或第三方请求返回异常 |
|
||||
| 5 | 数据结果异常 | 无法返回要求的格式 |
|
||||
|
||||
### 自定义响应
|
||||
|
||||
```go
|
||||
// 自定义 Header
|
||||
that.Resp.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// 直接写入
|
||||
that.Resp.Write([]byte("raw data"))
|
||||
|
||||
// 自定义响应函数
|
||||
that.RespFunc = func() {
|
||||
// 自定义响应逻辑
|
||||
}
|
||||
```
|
||||
|
||||
## 中间件
|
||||
|
||||
```go
|
||||
// 全局中间件(请求拦截)
|
||||
appIns.SetConnectListener(func(that *Context) bool {
|
||||
// 放行登录接口
|
||||
if len(that.RouterString) >= 3 && that.RouterString[2] == "login" {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查登录状态
|
||||
if that.Session("user_id").Data == nil {
|
||||
that.Display(2, "请先登录")
|
||||
return true // 返回 true 终止请求
|
||||
}
|
||||
return false // 返回 false 继续处理
|
||||
})
|
||||
```
|
||||
|
||||
## Session 与缓存
|
||||
|
||||
```go
|
||||
// Session 操作
|
||||
that.Session("user_id", 123) // 设置
|
||||
userId := that.Session("user_id") // 获取 *Obj
|
||||
that.Session("user_id", nil) // 删除
|
||||
|
||||
// 链式获取
|
||||
id := that.Session("user_id").ToInt64()
|
||||
name := that.Session("username").ToStr()
|
||||
|
||||
// 通用缓存
|
||||
that.Cache("key", "value") // 设置
|
||||
data := that.Cache("key") // 获取
|
||||
that.Cache("key", nil) // 删除
|
||||
```
|
||||
|
||||
### 批量操作(性能优化)
|
||||
|
||||
当需要同时操作多个 Session 字段时,使用批量操作可显著提升性能:
|
||||
|
||||
```go
|
||||
// SessionsSet - 批量设置(N个字段只触发1次数据库写入)
|
||||
that.SessionsSet(Map{
|
||||
"user_id": userId,
|
||||
"username": "张三",
|
||||
"login_time": time.Now().Unix(),
|
||||
"role": "admin",
|
||||
})
|
||||
|
||||
// SessionsGet - 批量获取(1次调用获取多个字段)
|
||||
result := that.SessionsGet("user_id", "username", "role")
|
||||
// result = Map{"user_id": 123, "username": "张三", "role": "admin"}
|
||||
|
||||
userId := ObjToInt64(result["user_id"], nil)
|
||||
username := ObjToStr(result["username"])
|
||||
|
||||
// SessionsDelete - 批量删除(N个字段只触发1次数据库写入)
|
||||
that.SessionsDelete("token", "temp_code", "verify_expire")
|
||||
```
|
||||
|
||||
**性能对比**:
|
||||
|
||||
| 操作方式 | 设置10个字段 | 数据库写入次数 |
|
||||
|----------|-------------|---------------|
|
||||
| 逐个调用 `Session()` | 10次调用 | **10次** |
|
||||
| 使用 `SessionsSet()` | 1次调用 | **1次** |
|
||||
|
||||
| 操作方式 | 获取10个字段 | 缓存查询次数 |
|
||||
|----------|-------------|--------------|
|
||||
| 逐个调用 `Session()` | 10次调用 | **10次** |
|
||||
| 使用 `SessionsGet()` | 1次调用 | **1次** |
|
||||
|
||||
> 💡 **最佳实践**:当一次性操作 3 个以上字段时,建议使用批量操作
|
||||
|
||||
三级缓存自动运作:**Memory → Redis → Database**
|
||||
|
||||
## 数据库操作(简要)
|
||||
|
||||
```go
|
||||
users := that.Db.Select("user", "*", Map{"status": 1})
|
||||
user := that.Db.Get("user", "*", Map{"id": 1})
|
||||
id := that.Db.Insert("user", Map{"name": "test", "age": 18})
|
||||
that.Db.Update("user", Map{"name": "new"}, Map{"id": 1})
|
||||
that.Db.Delete("user", Map{"id": 1})
|
||||
|
||||
users = that.Db.Table("user").
|
||||
Where("status", 1).And("age[>]", 18).
|
||||
Order("id DESC").Page(1, 10).Select("*")
|
||||
```
|
||||
|
||||
条件运算符(`[>]` / `[~]` / IN 等)、事务、缓存与方言差异见:
|
||||
|
||||
- 教程:[HoTimeDB 使用说明](HoTimeDB_使用说明.md)
|
||||
- 速查:[HoTimeDB API 参考](HoTimeDB_API参考.md)
|
||||
|
||||
## 日志:业务 Log 表 vs Seq
|
||||
|
||||
| 能力 | 用途 | 文档 |
|
||||
|------|------|------|
|
||||
| `that.Log = Map{...}` | 写入业务 `logs` 表(操作审计) | 见下方示例 |
|
||||
| `seqUrl` + zerolog | 结构化日志推送到 Seq 集中搜索 | [Seq 日志集成](Seq_日志集成.md) |
|
||||
|
||||
```go
|
||||
// 业务操作日志(自动插入 logs 表;框架会补 time、admin_id/user_id、ip 等)
|
||||
that.Log = Map{
|
||||
"type": "login",
|
||||
"action": "用户登录",
|
||||
"data": Map{"phone": phone},
|
||||
}
|
||||
```
|
||||
|
||||
## 扩展功能
|
||||
|
||||
| 功能 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| 微信支付/公众号/小程序 | `dri/wechat/` | 微信全套 SDK |
|
||||
| 阿里云服务 | `dri/aliyun/` | 企业认证等 |
|
||||
| 腾讯云服务 | `dri/tencent/` | 企业认证等 |
|
||||
| 文件上传 | `dri/upload/` | 文件上传处理 |
|
||||
| 文件下载 | `dri/download/` | 文件下载处理 |
|
||||
| MongoDB | `dri/mongodb/` | MongoDB 驱动 |
|
||||
| RSA 加解密 | `dri/rsa/` | RSA 加解密工具 |
|
||||
|
||||
## 完整示例
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
func main() {
|
||||
appIns := Init("config/config.json")
|
||||
|
||||
// 登录检查中间件
|
||||
appIns.SetConnectListener(func(that *Context) bool {
|
||||
// 放行登录接口
|
||||
if len(that.RouterString) >= 3 && that.RouterString[2] == "login" {
|
||||
return false
|
||||
}
|
||||
if that.Session("user_id").Data == nil {
|
||||
that.Display(2, "请先登录")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
appIns.Run(Router{
|
||||
"api": {
|
||||
"user": {
|
||||
"login": func(that *Context) {
|
||||
phone := that.ReqData("phone").ToStr()
|
||||
password := that.ReqData("password").ToStr()
|
||||
|
||||
if phone == "" || password == "" {
|
||||
that.Display(3, "手机号和密码不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
user := that.Db.Get("user", "*", Map{
|
||||
"phone": phone,
|
||||
"password": Md5(password),
|
||||
})
|
||||
|
||||
if user == nil {
|
||||
that.Display(3, "账号或密码错误")
|
||||
return
|
||||
}
|
||||
|
||||
// 使用批量设置,一次写入多个字段
|
||||
that.SessionsSet(Map{
|
||||
"user_id": user.GetInt64("id"),
|
||||
"username": user.GetString("name"),
|
||||
"login_time": time.Now().Unix(),
|
||||
})
|
||||
that.Display(0, Map{"user": user})
|
||||
},
|
||||
|
||||
"info": func(that *Context) {
|
||||
// 使用批量获取,一次读取多个字段
|
||||
sess := that.SessionsGet("user_id", "username", "login_time")
|
||||
userId := ObjToInt64(sess["user_id"], nil)
|
||||
|
||||
user := that.Db.Get("user", "*", Map{"id": userId})
|
||||
that.Display(0, Map{
|
||||
"user": user,
|
||||
"login_time": sess["login_time"],
|
||||
})
|
||||
},
|
||||
|
||||
"list": func(that *Context) {
|
||||
page := that.ReqData("page").ToInt()
|
||||
if page == 0 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
users := that.Db.Table("user").
|
||||
Where("status", 1).
|
||||
Order("id DESC").
|
||||
Page(page, 10).
|
||||
Select("id,name,phone,created_at")
|
||||
|
||||
total := that.Db.Count("user", Map{"status": 1})
|
||||
|
||||
that.Display(0, Map{
|
||||
"list": users,
|
||||
"total": total,
|
||||
"page": page,
|
||||
})
|
||||
},
|
||||
|
||||
"logout": func(that *Context) {
|
||||
// 使用批量删除,一次清除多个字段
|
||||
that.SessionsDelete("user_id", "username", "login_time")
|
||||
that.Display(0, "退出成功")
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**下一步**:
|
||||
- [HoTimeDB 使用说明](HoTimeDB_使用说明.md) — ORM 教程
|
||||
- [HoTimeDB API 参考](HoTimeDB_API参考.md) — API 速查
|
||||
- [Common 工具类](Common_工具类.md) — Map/Slice/Obj
|
||||
- [API 测试框架](Testing_API测试框架.md) — 接口测试(优先 `-run` 单测)
|
||||
- [文档索引](README.md) — 完整阅读路径
|
||||
@@ -1,36 +0,0 @@
|
||||
# HoTime 文档索引
|
||||
|
||||
本仓采用 **Doc-Driven + TDD**:行为一变必须回写 docs;改接口必须用 `-run` 精确测试验证(见 [Testing_API测试框架.md](Testing_API测试框架.md) 文首铁律与仓规则 `hotime-doc-tdd`)。
|
||||
|
||||
推荐按路径阅读,避免同一概念在多份文档里来回找。
|
||||
|
||||
## 阅读路径
|
||||
|
||||
1. **入门** → [QuickStart_快速上手.md](QuickStart_快速上手.md)
|
||||
2. **类型与工具** → [Common_工具类.md](Common_工具类.md)
|
||||
3. **数据库 ORM** → [HoTimeDB_使用说明.md](HoTimeDB_使用说明.md)(教程)→ [HoTimeDB_API参考.md](HoTimeDB_API参考.md)(速查)
|
||||
4. **开表 / 代码生成** → [DatabaseDesign_数据库设计规范.md](DatabaseDesign_数据库设计规范.md)(前置)→ [CodeGen_代码生成.md](CodeGen_代码生成.md)
|
||||
5. **接口测试** → [Testing_API测试框架.md](Testing_API测试框架.md)(日常用 `-run` 单测;局限见文末;全量见文末)
|
||||
6. **运维** → [GracefulShutdown_优雅停机.md](GracefulShutdown_优雅停机.md)、[Seq_日志集成.md](Seq_日志集成.md)
|
||||
7. **规划** → [ROADMAP_改进规划.md](ROADMAP_改进规划.md)
|
||||
|
||||
## 文档一览
|
||||
|
||||
| 文档 | 职责 |
|
||||
|------|------|
|
||||
| QuickStart_快速上手 | 安装、配置、路由、中间件、最小示例 |
|
||||
| Common_工具类 | Map / Slice / Obj 与工具函数 |
|
||||
| HoTimeDB_使用说明 | ORM 完整教程 |
|
||||
| HoTimeDB_API参考 | ORM 方法与条件语法速查 |
|
||||
| DatabaseDesign_数据库设计规范 | 表/字段/COMMENT 规范(CodeGen 输入契约) |
|
||||
| CodeGen_代码生成 | 生成器用法 + codeConfig / rule 配置 |
|
||||
| Testing_API测试框架 | API 测试、Flows、控制台;优先 `-run`;已知局限 |
|
||||
| GracefulShutdown_优雅停机 | 优雅停机与滚动重启 |
|
||||
| Seq_日志集成 | Seq 结构化日志 |
|
||||
| ROADMAP_改进规划 | 待改进项 |
|
||||
|
||||
## 易混淆点
|
||||
|
||||
- **业务 `that.Log`**:写 `logs` 表;**Seq**:集中式结构化日志,见 Seq 文档。
|
||||
- **HoTimeDB vs DatabaseDesign**:前者是运行时查库;后者是建表给代码生成用。
|
||||
- **测试**:开发阶段不要默认 `go test ./app/...` 全量,见 Testing 文首铁律。
|
||||
@@ -1,283 +0,0 @@
|
||||
# HoTime 改进规划
|
||||
|
||||
> 相关规范:[DatabaseDesign](DatabaseDesign_数据库设计规范.md) · [CodeGen](CodeGen_代码生成.md) · [Testing](Testing_API测试框架.md) · [文档索引](README.md)
|
||||
|
||||
本文档记录 HoTime 框架的待改进项和设计思考,供后续版本迭代参考。
|
||||
|
||||
---
|
||||
|
||||
## 一、备注语法优化
|
||||
|
||||
### 现状
|
||||
|
||||
当前字段备注语法使用空格、冒号、大括号组合:
|
||||
|
||||
```sql
|
||||
`status` int COMMENT '状态:0-正常,1-异常 这是数据库备注{这是前端提示}'
|
||||
```
|
||||
|
||||
### 问题
|
||||
|
||||
- 多种分隔符混用,不够直观
|
||||
- 显示名称如需包含特殊字符可能冲突
|
||||
|
||||
### 改进方向
|
||||
|
||||
使用 `|` 作为统一分隔符,保持干净清爽:
|
||||
|
||||
```sql
|
||||
-- 方案 A:简洁直观
|
||||
`status` int COMMENT '状态|0-正常,1-异常|请选择状态'
|
||||
-- 解析:显示名称|选项|提示
|
||||
|
||||
-- 方案 B:保持兼容,空格后内容仍作为数据库备注
|
||||
`status` int COMMENT '状态|0-正常,1-异常|请选择状态 这是数据库备注'
|
||||
```
|
||||
|
||||
### 待确认
|
||||
|
||||
- [ ] 是否需要保留空格后的数据库备注功能
|
||||
- [ ] 如果只有提示没有选项,如何表示(如 `名称||请输入名称`)
|
||||
- [ ] 是否向后兼容旧语法
|
||||
|
||||
---
|
||||
|
||||
## 二、SQLite 备注支持
|
||||
|
||||
### 现状
|
||||
|
||||
SQLite 不支持表/字段备注,需要手动在配置文件中设置。
|
||||
|
||||
### 问题
|
||||
|
||||
SQLite 项目配置工作量大,不够"快速开发"。
|
||||
|
||||
### 待探索方案
|
||||
|
||||
1. **配置文件方案(当前)**:通过 admin.json 手动配置,学习成本可接受
|
||||
2. **轻量级方案**:考虑是否有更简单的替代方案,但不增加复杂度
|
||||
|
||||
### 暂时结论
|
||||
|
||||
当前方案虽不完美,但符合"简单好用"原则,暂不改动。
|
||||
|
||||
---
|
||||
|
||||
## 三、软删除支持
|
||||
|
||||
### 现状
|
||||
|
||||
框架暂不支持软删除。
|
||||
|
||||
### 设计考量
|
||||
|
||||
软删除存在以下问题:
|
||||
- 数据安全性:软删除的数据仍可被恢复或误用
|
||||
- 查询复杂度:所有查询需要额外过滤条件
|
||||
- 存储膨胀:删除的数据持续占用空间
|
||||
|
||||
### 待探索方案
|
||||
|
||||
1. **可选软删除**:通过配置决定某张表是否启用软删除
|
||||
2. **日志表方案**:独立的操作日志表,只写不删不改
|
||||
- 记录所有增删改操作
|
||||
- 原表正常物理删除
|
||||
- 需要时可从日志恢复
|
||||
3. **归档表方案**:删除时移动到归档表
|
||||
|
||||
### 倾向方案
|
||||
|
||||
日志表方案更符合数据安全和审计需求:
|
||||
|
||||
```sql
|
||||
CREATE TABLE `_operation_log` (
|
||||
`id` int AUTO_INCREMENT,
|
||||
`table_name` varchar(100) COMMENT '操作表名',
|
||||
`record_id` int COMMENT '记录ID',
|
||||
`operation` varchar(20) COMMENT '操作类型:insert,update,delete',
|
||||
`old_data` text COMMENT '操作前数据(JSON)',
|
||||
`new_data` text COMMENT '操作后数据(JSON)',
|
||||
`operator_id` int COMMENT '操作人ID',
|
||||
`operator_table` varchar(100) COMMENT '操作人表名',
|
||||
`create_time` datetime COMMENT '操作时间',
|
||||
PRIMARY KEY (`id`)
|
||||
) COMMENT='操作日志';
|
||||
```
|
||||
|
||||
### 待确认
|
||||
|
||||
- [ ] 是否所有表都记录日志
|
||||
- [ ] 日志保留策略(永久/定期归档)
|
||||
- [ ] 是否提供恢复接口
|
||||
|
||||
---
|
||||
|
||||
## 四、多对多关联表增强
|
||||
|
||||
### 现状
|
||||
|
||||
关联表(如 `user_role`)按普通表处理,生成标准 CRUD。
|
||||
|
||||
### 可能的增强
|
||||
|
||||
1. **自动识别**:只有两个 `_id` 外键的表识别为关联表
|
||||
2. **专用接口**:生成关联管理接口(批量绑定/解绑)
|
||||
3. **级联查询**:自动生成带关联数据的查询
|
||||
|
||||
### 待确认
|
||||
|
||||
- [ ] 是否需要此功能
|
||||
- [ ] 如何保持简单性
|
||||
|
||||
---
|
||||
|
||||
## 五、自动填充字段扩展
|
||||
|
||||
### 现状
|
||||
|
||||
自动填充:
|
||||
- `create_time`:新增时自动填充当前时间
|
||||
- `modify_time`:新增/编辑时自动填充当前时间
|
||||
|
||||
### 可能的扩展
|
||||
|
||||
| 字段 | 填充时机 | 填充内容 |
|
||||
|------|----------|----------|
|
||||
| create_by | 新增 | 当前用户ID |
|
||||
| modify_by | 新增/编辑 | 当前用户ID |
|
||||
| create_ip | 新增 | 客户端IP |
|
||||
|
||||
### 待确认
|
||||
|
||||
- [ ] 是否需要扩展
|
||||
- [ ] 字段命名规范
|
||||
|
||||
---
|
||||
|
||||
## 六、版本控制/乐观锁
|
||||
|
||||
### 现状
|
||||
|
||||
`version` 字段规则存在但未启用。
|
||||
|
||||
### 待确认
|
||||
|
||||
- [ ] 是否需要支持乐观锁
|
||||
- [ ] 如果不需要,是否从默认规则中移除
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 七、测试框架:响应字段断言(部分匹配)
|
||||
|
||||
### 现状
|
||||
|
||||
`Post` / `Get` 等终端方法目前只支持断言响应体中的 `status` 和 `msg` 字段:
|
||||
|
||||
```go
|
||||
a.JSON(Map{"phone": phone, "password": "123456"}).Post("正常登录", 0)
|
||||
a.JSON(Map{"phone": phone, "password": "123456"}).Post("密码为空", 4, "用户名或密码不能为空")
|
||||
```
|
||||
|
||||
如需验证响应 `result` 内部的具体字段,只能手动写:
|
||||
|
||||
```go
|
||||
resp := a.JSON(Map{...}).Post("查询列表", 0)
|
||||
if resp.GetBody().GetMap("result").GetInt("total") != 10 {
|
||||
resp.Fail("total 应为 10")
|
||||
}
|
||||
```
|
||||
|
||||
这种写法冗长、可读性差,且失败时无法显示期望值与实际值的对比。
|
||||
|
||||
### 改进方向
|
||||
|
||||
在终端方法签名中增加**可选的响应字段部分匹配参数**:
|
||||
|
||||
```go
|
||||
// 现有签名(不变)
|
||||
func (c *ApiCase) Post(desc string, status int, msg ...string) *ApiResponse
|
||||
|
||||
// 新增重载或扩展参数,支持传入期望的 result 字段(Map 类型)
|
||||
a.JSON(Map{...}).Post("查询列表", 0, Map{"total": 10})
|
||||
a.JSON(Map{...}).Post("查询列表", 0, Map{"total": 10, "list": notEmpty})
|
||||
a.JSON(Map{...}).Post("正常登录", 0, Map{"token": notEmpty})
|
||||
```
|
||||
|
||||
### 核心设计原则
|
||||
|
||||
**部分匹配,而非精确匹配**:
|
||||
|
||||
- 只验证期望 Map 中列出的字段,响应中额外的字段不报错
|
||||
- 避免因响应新增字段导致测试频繁失败,降低维护成本
|
||||
- 精确匹配适合快照测试场景,但与"快速精准"目标相悖,不引入
|
||||
|
||||
### 支持的断言值类型
|
||||
|
||||
| 断言值 | 含义 | 示例 |
|
||||
|--------|------|------|
|
||||
| 具体值(string/int/bool) | 字段等于该值 | `Map{"total": 10}` |
|
||||
| `notEmpty`(特殊常量) | 字段存在且非空/非零 | `Map{"token": notEmpty}` |
|
||||
| 嵌套 Map | 递归部分匹配子对象 | `Map{"user": Map{"id": 1}}` |
|
||||
|
||||
### 预期写法
|
||||
|
||||
```go
|
||||
// 断言 total 值
|
||||
a.Query(Map{"page": "1"}).Get("查询列表", 0, Map{"total": 5})
|
||||
|
||||
// 断言 token 字段存在且非空(不关心具体值)
|
||||
resp := a.JSON(Map{"phone": "138...", "password": "123456"}).Post("正常登录", 0, Map{"token": notEmpty})
|
||||
|
||||
// 组合:status + msg + result 字段三合一
|
||||
a.JSON(Map{"phone": ""}).Post("手机号为空", 4, "手机号不能为空") // 只验 status+msg
|
||||
a.JSON(Map{"phone": "138..."}).Post("正常注册", 0, Map{"id": notEmpty}) // 验 status + result.id
|
||||
```
|
||||
|
||||
### 失败输出格式
|
||||
|
||||
失败时应显示期望值和实际值对比,便于快速定位:
|
||||
|
||||
```
|
||||
--- FAIL: TestApi/app/user/login/正常登录
|
||||
期望 result.token 非空,实际值为 ""
|
||||
期望 result.total = 10,实际值为 0
|
||||
```
|
||||
|
||||
### 实现要点
|
||||
|
||||
- 参数类型可设计为 `...interface{}`,兼容现有 `msg ...string` 的调用方式,通过类型断言区分
|
||||
- `notEmpty` 可定义为框架内的特殊常量(如 `var notEmpty = struct{}{}`)
|
||||
- 断言逻辑复用 `ApiResponse` 已有的 `GetBody()` 链路,递归遍历期望 Map
|
||||
|
||||
### 待确认
|
||||
|
||||
- [ ] 终端方法签名如何兼容:新增第四参数 `result Map`,还是利用 `...interface{}` 统一
|
||||
- [ ] `notEmpty` 常量的导出名称和定义位置
|
||||
- [ ] 嵌套 Map 递归断言是否纳入首批实现范围
|
||||
- [ ] 失败输出是否需要完整响应体 dump
|
||||
|
||||
---
|
||||
|
||||
## 改进优先级
|
||||
|
||||
| 优先级 | 改进项 | 状态 |
|
||||
|--------|--------|------|
|
||||
| 高 | 备注语法优化(`\|` 分隔符) | 待设计 |
|
||||
| 高 | 测试框架:响应字段部分匹配断言 | 待实现 |
|
||||
| 高 | 测试框架其它缺口(DB 断言、FromCase 无模板失败、Header API 等) | 见 [Testing 已知局限](Testing_API测试框架.md#已知局限与后续) |
|
||||
| 中 | 软删除/日志表支持 | 待设计 |
|
||||
| 低 | 多对多关联表增强 | 待评估 |
|
||||
| 低 | 自动填充字段扩展 | 待评估 |
|
||||
|
||||
---
|
||||
|
||||
## 更新记录
|
||||
|
||||
| 日期 | 内容 |
|
||||
|------|------|
|
||||
| 2026-07-13 | 测试框架文档补齐 Flows/控制台;缺口清单链到 Testing |
|
||||
| 2026-01-24 | 初始版本,记录分析结果和待改进项 |
|
||||
| 2026-03-15 | 新增测试框架响应字段断言改进规划(七) |
|
||||
| 2026-03-20 | 完成达梦(DM8)数据库完整支持:Dialect 适配(MERGE INTO Upsert、双引号标识符)、LastInsertId、保留字自动引号、schema 正确处理、代码生成器 DM 兼容、缓存表自动建表、测试框架 DM 适配 |
|
||||
@@ -1,170 +0,0 @@
|
||||
# Seq 日志集成
|
||||
|
||||
HoTime 框架内置 Seq 日志推送支持。通过在 `config.json` 填写 `seqUrl` 即可激活。
|
||||
**目标:用 Seq 集中检索替代翻 `logs/*.txt`**;本地 `logFile` 可并行保留。
|
||||
|
||||
---
|
||||
|
||||
## 配置项
|
||||
|
||||
在 `config.json` 中添加:
|
||||
|
||||
```json
|
||||
"seqUrl": "http://127.0.0.1:5341",
|
||||
"seqApiKey": ""
|
||||
```
|
||||
|
||||
| 字段 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `seqUrl` | 空(不激活) | Seq 服务地址,空值时功能静默不生效 |
|
||||
| `seqApiKey` | 空 | API Key,免费单用户版留空 |
|
||||
|
||||
`instance` 字段由框架自动拼接为 `ip:port`(如 `192.168.1.10:8085`),无需手动填写。
|
||||
|
||||
激活后每个 HTTP 请求会自动:
|
||||
|
||||
- 生成 `request_id`(12 位 hex),回写响应头 `X-Request-Id`
|
||||
- 将 `sessionId` 前 12 位作为 `sid` 写入请求级日志(脱敏,避免把登录凭据写进 Seq)
|
||||
- 业务日志、SQL 日志、访问日志均携带 `sid` / `request_id`
|
||||
- **控制台自动降噪**(零额外配置):业务日志(`that.Log`)控制台放宽到 **Info+**(Debug/SQL 不上控制台),访问日志(`that.WebConnectLog`)控制台保持 **Warn+**(量大不上控制台);Debug/SQL/访问日志(Info 级)仍全量进 Seq 与文件
|
||||
|
||||
---
|
||||
|
||||
## 会话追踪与 LogBind
|
||||
|
||||
框架在 `handler` 入口派生请求级 Logger,并浅拷贝 `Db` 将其 `Log` 指向同一 Logger,因此 **SQL 日志自动带会话字段**。字段会出现在同条日志的控制台/文件/Seq 出口上。
|
||||
|
||||
业务在 `SetConnectListener` **入口处统一绑定**(xbc `main.go`,置于各模块守卫之前,未登录被拒的 Warning 也能带上 shop_id/platform 等维度):
|
||||
|
||||
```go
|
||||
// 仅对 API 路由(3 段且非静态资源)生效
|
||||
if len(context.RouterString) == 3 && !strings.Contains(context.RouterString[2], ".") {
|
||||
if v := context.Session("user_id").ToCeilInt64(); v > 0 {
|
||||
context.LogBind("user_id", v)
|
||||
}
|
||||
// wechat_id / worker_id / admin_id 同理,session 有则绑
|
||||
if shopId := context.ReqData("shop_id").ToCeilInt64(); shopId != 0 {
|
||||
context.LogBind("shop_id", shopId)
|
||||
}
|
||||
// 设备维度(UA / 自定义 header 解析),非空才绑
|
||||
// context.LogBind("platform", platform) / context.LogBind("device_model", deviceModel)
|
||||
}
|
||||
```
|
||||
|
||||
`LogBind` 后,本请求后续业务日志与 SQL 日志都会带上该字段。session 在 context 内有缓存,多次 `Session()` 只查一次库。
|
||||
|
||||
**不带会话字段的边界:**
|
||||
|
||||
- `fmt.Println` / stdout 捕获、panic、MySQL driver、定时任务等无请求上下文的日志
|
||||
- 直接写 `that.Application.Log` 的旧代码(应改用 `that.Logger` 或 `that.LogBind` 后的请求级 Logger)
|
||||
|
||||
---
|
||||
|
||||
## 控制台降噪(配了 seqUrl)
|
||||
|
||||
| Logger | 控制台行为 |
|
||||
|---|---|
|
||||
| 业务日志(`that.Log`) | Info+(Info / Warn / Error,含 `Display` 非 0 的 Warn;Debug/SQL 不上控制台) |
|
||||
| 访问日志(`that.WebConnectLog`) | Warn+(访问日志量大,Info 级不上控制台) |
|
||||
|
||||
| 出口 | 行为 |
|
||||
|---|---|
|
||||
| Seq | 按 `logLevel` 全量(Info/Debug/SQL/访问日志等) |
|
||||
| 本地文件 | 与原先一致,不受控制台过滤影响 |
|
||||
|
||||
未配置 `seqUrl` 时控制台仍按 `logLevel` 全打。
|
||||
|
||||
框架不新增配置项;如需自定义某个 Logger 的控制台门槛,可在挂 Seq 后调用 `Logger.SetConsoleMinLevel(level zerolog.Level)`(仅影响该 Logger 的控制台出口,不影响 Seq/文件)。
|
||||
|
||||
---
|
||||
|
||||
## 客户端 IP 与地域
|
||||
|
||||
零配置。`ip` 选取优先级(跳过空值与回环 `127.0.0.1` / `::1`):
|
||||
|
||||
1. `EO-Connecting-IP`(EdgeOne 真实建连 IP)
|
||||
2. `X-Forwarded-For` **从左到右第一个非回环**(反代误把 CDN 写入 `X-Real-IP` 时仍能落到客户端)
|
||||
3. `X-Real-IP`(非回环)
|
||||
4. `RemoteAddr`
|
||||
|
||||
访问日志另附 `ip_chain`:上述源头出现过的 IP 按**首次出现**顺序逗号拼接,**同一 IP 不重复**;若与 `ip` 相同则省略该字段。
|
||||
|
||||
请求头有 `EO-Client-IPCountry` 时,访问日志追加 `ip_country`(两位国家码);没有则不记。
|
||||
|
||||
---
|
||||
|
||||
## 架构原理
|
||||
|
||||
```
|
||||
业务代码
|
||||
│ that.Logger.Info().Msg("...") ← 请求级(含 sid/request_id)
|
||||
│ Db.Query → SQL 日志 ← 同一请求级 Logger
|
||||
│ fmt.Println("...") ← 捕获后无 sid
|
||||
↓
|
||||
multiWriter(hotimev1.5/log/logger.go)
|
||||
├─ Console(挂 Seq 后:业务 Info+ / 访问日志 Warn+)→ 终端
|
||||
├─ FileWriter → 本地文件(按需,全量)
|
||||
└─ SeqWriter → Seq(全量)
|
||||
│ Write() 只做 channel <- bytes,O(1) 非阻塞
|
||||
↓
|
||||
channel(容量 10000)
|
||||
↓ 后台 goroutine
|
||||
批量打包(100 条 或 500ms)
|
||||
↓ HTTP POST(失败重试 1 次)
|
||||
Seq 服务(CLEF 格式)
|
||||
```
|
||||
|
||||
优雅停机时调用 `CloseSeq()` 冲刷残留批次,避免停机前后日志丢失。
|
||||
|
||||
---
|
||||
|
||||
## 字段映射(zerolog → CLEF)
|
||||
|
||||
| zerolog 字段 | Seq CLEF 字段 | 说明 |
|
||||
|---|---|---|
|
||||
| `time` | `@t` | 毫秒精度 ISO 8601 |
|
||||
| `level` | `@l` | Debug/Information/Warning/Error/Fatal |
|
||||
| `message` / `msg` | `@m` | 消息正文(不用 `@mt`,避免 `{xxx}` 被当模板) |
|
||||
| `caller` | `caller` | 调用位置 |
|
||||
| `sid` | `sid` | sessionId 前 12 位 |
|
||||
| `request_id` | `request_id` | 单次请求 id |
|
||||
| `ip` | `ip` | 客户端最佳 IP |
|
||||
| `ip_chain` | `ip_chain` | 多源去重链路(与 `ip` 不同时才有) |
|
||||
| `ip_country` | `ip_country` | 有 `EO-Client-IPCountry` 时 |
|
||||
| `ua` | `ua` | 访问日志携带,User-Agent 原文截断 200 字符(按 rune 安全截断) |
|
||||
| 其余自定义字段 | 原字段名 | `LogBind` 追加的字段原样保留 |
|
||||
| — | `instance` | `ip:port` |
|
||||
| — | `source` | stdout / stderr / panic 等 |
|
||||
|
||||
---
|
||||
|
||||
## 搜索语法速查
|
||||
|
||||
| 目标 | 查询语句 |
|
||||
|---|---|
|
||||
| 按会话追踪 | `sid = 'abc123def456'` |
|
||||
| 按单次请求 | `request_id = 'fedcba987654'` |
|
||||
| 关键词 | 直接输入,如 `支付失败` |
|
||||
| 日志级别 | `@l = 'Error'` |
|
||||
| 特定实例 | `instance = '192.168.1.10:8085'` |
|
||||
| 地域 | `ip_country = 'CN'` |
|
||||
| stdout | `source = 'stdout'` |
|
||||
| panic | `source = 'panic'` |
|
||||
| 组合 | `@l = 'Error' and sid = 'abc123def456'` |
|
||||
| 关键字组合其他条件 | `@Message like '%支付失败%' and @l = 'Warning' and instance = '192.168.1.10:8085'` |
|
||||
|
||||
---
|
||||
|
||||
## Seq 安装
|
||||
|
||||
- Windows:[https://datalust.co/download/seq](https://datalust.co/download/seq)
|
||||
- Docker:`docker run -d --restart always --name seq -p 5341:80 -e ACCEPT_EULA=Y datalust/seq`
|
||||
- 访问 `http://localhost:5341`
|
||||
|
||||
---
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [QuickStart_快速上手.md](QuickStart_快速上手.md) — 其中的 `that.Log` 是业务 logs 表,与本文 Seq 推送不同
|
||||
- [GracefulShutdown_优雅停机.md](GracefulShutdown_优雅停机.md)
|
||||
- [文档索引](README.md)
|
||||
File diff suppressed because it is too large
Load Diff
+37
-41
@@ -1,55 +1,53 @@
|
||||
package aliyun
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
. "../../common"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
//"fmt"
|
||||
)
|
||||
|
||||
type company struct {
|
||||
type Company struct {
|
||||
ApiCode string
|
||||
Url string
|
||||
}
|
||||
|
||||
var Company = company{}
|
||||
|
||||
func (that *company) Init(apiCode string) {
|
||||
func (this *Company) Init(apiCode string) {
|
||||
//"06c6a07e89dd45c88de040ee1489eef7"
|
||||
that.ApiCode = apiCode
|
||||
that.Url = "http://api.81api.com"
|
||||
this.ApiCode = apiCode
|
||||
this.Url = "http://api.81api.com"
|
||||
}
|
||||
|
||||
// GetCompanyOtherAll 获取企业基础信息
|
||||
func (that *company) GetCompanyOtherAll(name string) Map {
|
||||
// GetCompanyBaseInfo 获取企业基础信息
|
||||
func (this *Company) GetCompanyOtherAll(name string) Map {
|
||||
|
||||
res := Map{}
|
||||
data, e := that.GetCompanyPatentsInfo(name) //获取专利信息
|
||||
data, e := this.GetCompanyPatentsInfo(name) //获取专利信息
|
||||
if e != nil {
|
||||
fmt.Println(e)
|
||||
} else {
|
||||
res["PatentsInfo"] = data.GetMap("data")
|
||||
}
|
||||
data, e = that.GetCompanyOtherCopyrightsInfo(name) //获取其他专利
|
||||
data, e = this.GetCompanyOtherCopyrightsInfo(name) //获取其他专利
|
||||
if e != nil {
|
||||
fmt.Println(e)
|
||||
} else {
|
||||
res["OtherCopyrightsInfo"] = data.GetMap("data")
|
||||
}
|
||||
data, e = that.GetCompanyTrademarksInfo(name) //获取商标
|
||||
data, e = this.GetCompanyTrademarksInfo(name) //获取商标
|
||||
if e != nil {
|
||||
fmt.Println(e)
|
||||
} else {
|
||||
res["TrademarksInfo"] = data.GetMap("data")
|
||||
}
|
||||
data, e = that.GetCompanySoftwareCopyrightsInfo(name) //获取软著
|
||||
data, e = this.GetCompanySoftwareCopyrightsInfo(name) //获取软著
|
||||
if e != nil {
|
||||
fmt.Println(e)
|
||||
} else {
|
||||
res["SoftwareCopyrightsInfo"] = data.GetMap("data")
|
||||
}
|
||||
data, e = that.GetCompanyProfileTags(name) //获取大数据标签
|
||||
data, e = this.GetCompanyProfileTags(name) //获取大数据标签
|
||||
if e != nil {
|
||||
fmt.Println(e)
|
||||
} else {
|
||||
@@ -59,71 +57,63 @@ func (that *company) GetCompanyOtherAll(name string) Map {
|
||||
}
|
||||
|
||||
// GetCompanyBaseInfo 获取企业基础信息
|
||||
func (that *company) GetCompanyList(name string) (Map, error) {
|
||||
url := "/fuzzyQueryCompanyInfo/"
|
||||
|
||||
body, err := that.basePost(url, name)
|
||||
return ObjToMap(body), err
|
||||
}
|
||||
|
||||
// GetCompanyBaseInfo 获取企业基础信息
|
||||
func (that *company) GetCompanyBaseInfo(name string) (Map, error) {
|
||||
func (this *Company) GetCompanyBaseInfo(name string) (Map, error) {
|
||||
url := "/getCompanyBaseInfo/"
|
||||
|
||||
body, err := that.basePost(url, name)
|
||||
body, err := this.basePost(url, name)
|
||||
return ObjToMap(body), err
|
||||
}
|
||||
|
||||
// GetCompanyPatentsInfo 获取专利信息
|
||||
func (that *company) GetCompanyPatentsInfo(name string) (Map, error) {
|
||||
func (this *Company) GetCompanyPatentsInfo(name string) (Map, error) {
|
||||
url := "/getCompanyPatentsInfo/"
|
||||
|
||||
body, err := that.basePost(url, name)
|
||||
body, err := this.basePost(url, name)
|
||||
return ObjToMap(body), err
|
||||
}
|
||||
|
||||
// GetCompanyTrademarksInfo 获取商标信息
|
||||
func (that *company) GetCompanyTrademarksInfo(name string) (Map, error) {
|
||||
// 获取商标信息
|
||||
func (this *Company) GetCompanyTrademarksInfo(name string) (Map, error) {
|
||||
url := "/getCompanyTrademarksInfo/"
|
||||
|
||||
body, err := that.basePost(url, name)
|
||||
body, err := this.basePost(url, name)
|
||||
return ObjToMap(body), err
|
||||
}
|
||||
|
||||
// GetCompanySoftwareCopyrightsInfo 获取软著信息
|
||||
func (that *company) GetCompanySoftwareCopyrightsInfo(name string) (Map, error) {
|
||||
// 获取软著信息
|
||||
func (this *Company) GetCompanySoftwareCopyrightsInfo(name string) (Map, error) {
|
||||
url := "/getCompanySoftwareCopyrightsInfo/"
|
||||
|
||||
body, err := that.basePost(url, name)
|
||||
body, err := this.basePost(url, name)
|
||||
return ObjToMap(body), err
|
||||
}
|
||||
|
||||
// GetCompanyOtherCopyrightsInfo 获取其他著作信息
|
||||
func (that *company) GetCompanyOtherCopyrightsInfo(name string) (Map, error) {
|
||||
// 获取其他著作信息
|
||||
func (this *Company) GetCompanyOtherCopyrightsInfo(name string) (Map, error) {
|
||||
url := "/getCompanyOtherCopyrightsInfo/"
|
||||
|
||||
body, err := that.basePost(url, name)
|
||||
body, err := this.basePost(url, name)
|
||||
return ObjToMap(body), err
|
||||
}
|
||||
|
||||
// GetCompanyProfileTags 获取大数据标签
|
||||
func (that *company) GetCompanyProfileTags(name string) (Map, error) {
|
||||
// 获取大数据标签
|
||||
func (this *Company) GetCompanyProfileTags(name string) (Map, error) {
|
||||
url := "/getCompanyProfileTags/"
|
||||
body, err := that.basePost(url, name)
|
||||
body, err := this.basePost(url, name)
|
||||
return ObjToMap(body), err
|
||||
}
|
||||
func (that *company) basePost(url string, name string) (string, error) {
|
||||
func (this *Company) basePost(url string, name string) (string, error) {
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
reqest, err := http.NewRequest("GET", that.Url+url+name+"/?isRaiseErrorCode=1", nil)
|
||||
reqest, err := http.NewRequest("GET", this.Url+url+name+"/?isRaiseErrorCode=1", nil)
|
||||
|
||||
if err != nil {
|
||||
fmt.Println("Fatal error ", err.Error())
|
||||
return "", err
|
||||
}
|
||||
|
||||
reqest.Header.Add("Authorization", "APPCODE "+that.ApiCode)
|
||||
reqest.Header.Add("Authorization", "APPCODE "+this.ApiCode)
|
||||
response, err := client.Do(reqest)
|
||||
defer response.Body.Close()
|
||||
|
||||
@@ -140,3 +130,9 @@ func (that *company) basePost(url string, name string) (string, error) {
|
||||
fmt.Println(res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
var DefaultCompany Company
|
||||
|
||||
func init() {
|
||||
DefaultCompany = Company{}
|
||||
}
|
||||
|
||||
+12
-96
@@ -1,122 +1,32 @@
|
||||
package baidu
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type baiduMap struct {
|
||||
type BaiduMap struct {
|
||||
Ak string
|
||||
Url string
|
||||
}
|
||||
|
||||
var BaiDuMap = baiduMap{}
|
||||
|
||||
func (that *baiduMap) Init(Ak string) {
|
||||
func (this *BaiduMap) Init(Ak string) {
|
||||
//"ak=ZeT902EZvVgIoGVWEFK3osUm"
|
||||
that.Ak = Ak
|
||||
that.Url = "https://api.map.baidu.com/place/v2/suggestion?output=json" + "&ak=" + Ak
|
||||
this.Ak = Ak
|
||||
this.Url = "https://api.map.baidu.com/place/v2/suggestion?output=json" + "&ak=" + Ak
|
||||
//query
|
||||
}
|
||||
|
||||
// from 源坐标类型:
|
||||
// 1:GPS标准坐标;
|
||||
// 2:搜狗地图坐标;
|
||||
// 3:火星坐标(gcj02),即高德地图、腾讯地图和MapABC等地图使用的坐标;
|
||||
// 4:3中列举的地图坐标对应的墨卡托平面坐标;
|
||||
// 5:百度地图采用的经纬度坐标(bd09ll);
|
||||
// 6:百度地图采用的墨卡托平面坐标(bd09mc);
|
||||
// 7:图吧地图坐标;
|
||||
// 8:51地图坐标;
|
||||
// int 1 1 否
|
||||
// to
|
||||
// 目标坐标类型:
|
||||
// 3:火星坐标(gcj02),即高德地图、腾讯地图及MapABC等地图使用的坐标;
|
||||
// 5:百度地图采用的经纬度坐标(bd09ll);
|
||||
// 6:百度地图采用的墨卡托平面坐标(bd09mc);
|
||||
func (that *baiduMap) Geoconv(latlngs []Map, from, to int) (Slice, error) {
|
||||
|
||||
client := &http.Client{}
|
||||
latlngsStr := ""
|
||||
for _, v := range latlngs {
|
||||
if latlngsStr != "" {
|
||||
latlngsStr = latlngsStr + ";" + v.GetString("lng") + "," + v.GetString("lat")
|
||||
} else {
|
||||
latlngsStr = v.GetString("lng") + "," + v.GetString("lat")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
url := "https://api.map.baidu.com/geoconv/v1/?from=" + ObjToStr(from) + "&to=" + ObjToStr(to) + "&ak=" + that.Ak + "&coords=" + latlngsStr
|
||||
reqest, err := http.NewRequest("GET", url, nil)
|
||||
|
||||
if err != nil {
|
||||
fmt.Println("Fatal error ", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
response, err := client.Do(reqest)
|
||||
defer response.Body.Close()
|
||||
|
||||
if err != nil {
|
||||
fmt.Println("Fatal error ", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, err := ioutil.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
//fmt.Println(string(body))
|
||||
data := ObjToMap(string(body))
|
||||
if data.GetCeilInt64("status") != 0 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return data.GetSlice("result"), err
|
||||
}
|
||||
|
||||
func (that *baiduMap) GetAddress(lat string, lng string) (string, error) {
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
url := "https://api.map.baidu.com/reverse_geocoding/v3/?ak=" + that.Ak + "&output=json&location=" + lat + "," + lng
|
||||
reqest, err := http.NewRequest("GET", url, nil)
|
||||
|
||||
if err != nil {
|
||||
fmt.Println("Fatal error ", err.Error())
|
||||
return "", err
|
||||
}
|
||||
response, err := client.Do(reqest)
|
||||
defer response.Body.Close()
|
||||
|
||||
if err != nil {
|
||||
fmt.Println("Fatal error ", err.Error())
|
||||
return "", err
|
||||
}
|
||||
|
||||
body, err := ioutil.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
//fmt.Println(string(body))
|
||||
|
||||
return string(body), err
|
||||
|
||||
}
|
||||
|
||||
// GetPosition 获取定位列表
|
||||
func (that *baiduMap) GetPosition(name string, region string) (string, error) {
|
||||
func (this *BaiduMap) GetPosition(name string, region string) (string, error) {
|
||||
|
||||
client := &http.Client{}
|
||||
if region == "" {
|
||||
region = "全国"
|
||||
}
|
||||
reqest, err := http.NewRequest("GET", that.Url+"&query="+url.PathEscape(name)+"®ion="+url.PathEscape(region), nil)
|
||||
reqest, err := http.NewRequest("GET", this.Url+"&query="+url.PathEscape(name)+"®ion="+url.PathEscape(region), nil)
|
||||
|
||||
if err != nil {
|
||||
fmt.Println("Fatal error ", err.Error())
|
||||
@@ -140,3 +50,9 @@ func (that *baiduMap) GetPosition(name string, region string) (string, error) {
|
||||
return string(body), err
|
||||
|
||||
}
|
||||
|
||||
var DefaultBaiDuMap BaiduMap
|
||||
|
||||
func init() {
|
||||
DefaultBaiDuMap = BaiduMap{}
|
||||
}
|
||||
|
||||
+23
-19
@@ -10,31 +10,29 @@ import (
|
||||
//"fmt"
|
||||
)
|
||||
|
||||
type dingdongyun struct {
|
||||
type DDY struct {
|
||||
ApiKey string
|
||||
YzmUrl string
|
||||
TzUrl string
|
||||
}
|
||||
|
||||
var DDY = dingdongyun{}
|
||||
|
||||
func (that *dingdongyun) Init(apikey string) {
|
||||
that.ApiKey = apikey
|
||||
that.YzmUrl = "https://api.dingdongcloud.com/v2/sms/captcha/send.json"
|
||||
that.TzUrl = "https://api.dingdongcloud.com/v2/sms/notice/send.json"
|
||||
func (this *DDY) Init(apikey string) {
|
||||
this.ApiKey = apikey
|
||||
this.YzmUrl = "https://api.dingdongcloud.com/v2/sms/captcha/send.json"
|
||||
this.TzUrl = "https://api.dingdongcloud.com/v2/sms/notice/send.json"
|
||||
}
|
||||
|
||||
// SendYZM 发送短信验证码 code验证码如:123456 返回true表示发送成功flase表示发送失败
|
||||
func (that *dingdongyun) SendYZM(umoblie string, tpt string, data map[string]string) (bool, error) {
|
||||
//发送短信验证码 code验证码如:123456 返回true表示发送成功flase表示发送失败
|
||||
func (this *DDY) SendYZM(umoblie string, tpt string, data map[string]string) (bool, error) {
|
||||
for k, v := range data {
|
||||
tpt = strings.Replace(tpt, "{"+k+"}", v, -1)
|
||||
}
|
||||
|
||||
return that.send(that.YzmUrl, umoblie, tpt)
|
||||
return this.send(this.YzmUrl, umoblie, tpt)
|
||||
}
|
||||
|
||||
// SendTz 发送通知
|
||||
func (that *dingdongyun) SendTz(umoblie []string, tpt string, data map[string]string) (bool, error) {
|
||||
//发送通知
|
||||
func (this *DDY) SendTz(umoblie []string, tpt string, data map[string]string) (bool, error) {
|
||||
for k, v := range data {
|
||||
tpt = strings.Replace(tpt, "{"+k+"}", v, -1)
|
||||
}
|
||||
@@ -46,14 +44,14 @@ func (that *dingdongyun) SendTz(umoblie []string, tpt string, data map[string]st
|
||||
}
|
||||
umobleStr += "," + v
|
||||
}
|
||||
return that.send(that.TzUrl, umobleStr, tpt)
|
||||
return this.send(this.TzUrl, umobleStr, tpt)
|
||||
}
|
||||
|
||||
// 发送短信
|
||||
func (that *dingdongyun) send(mUrl string, umoblie string, content string) (bool, error) {
|
||||
//发送短信
|
||||
func (this *DDY) send(mUrl string, umoblie string, content string) (bool, error) {
|
||||
|
||||
data_send_sms_yzm := url.Values{"apikey": {that.ApiKey}, "mobile": {umoblie}, "content": {content}}
|
||||
res, err := that.httpsPostForm(mUrl, data_send_sms_yzm)
|
||||
data_send_sms_yzm := url.Values{"apikey": {this.ApiKey}, "mobile": {umoblie}, "content": {content}}
|
||||
res, err := this.httpsPostForm(mUrl, data_send_sms_yzm)
|
||||
if err != nil && res == "" {
|
||||
return false, errors.New("连接错误")
|
||||
}
|
||||
@@ -74,8 +72,8 @@ func (that *dingdongyun) send(mUrl string, umoblie string, content string) (bool
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// 调用url发送短信的连接
|
||||
func (that *dingdongyun) httpsPostForm(url string, data url.Values) (string, error) {
|
||||
//调用url发送短信的连接
|
||||
func (this *DDY) httpsPostForm(url string, data url.Values) (string, error) {
|
||||
resp, err := http.PostForm(url, data)
|
||||
|
||||
if err != nil {
|
||||
@@ -91,3 +89,9 @@ func (that *dingdongyun) httpsPostForm(url string, data url.Values) (string, err
|
||||
return string(body), nil
|
||||
|
||||
}
|
||||
|
||||
var DefaultDDY DDY
|
||||
|
||||
func init() {
|
||||
DefaultDDY = DDY{}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
. "../../common"
|
||||
"bytes"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Down 下载文件
|
||||
//下载文件
|
||||
func Down(url, path, name string, e ...*Error) bool {
|
||||
|
||||
os.MkdirAll(path, os.ModeDir)
|
||||
@@ -18,13 +18,13 @@ func Down(url, path, name string, e ...*Error) bool {
|
||||
}
|
||||
out, err := os.Create(path + name)
|
||||
|
||||
if err != nil && len(e) != 0 {
|
||||
if err != nil && e[0] != nil {
|
||||
e[0].SetError(err)
|
||||
return false
|
||||
}
|
||||
defer out.Close()
|
||||
resp, err := http.Get(url)
|
||||
if err != nil && len(e) != 0 {
|
||||
if err != nil && e[0] != nil {
|
||||
e[0].SetError(err)
|
||||
return false
|
||||
}
|
||||
@@ -32,7 +32,7 @@ func Down(url, path, name string, e ...*Error) bool {
|
||||
|
||||
pix, err := ioutil.ReadAll(resp.Body)
|
||||
_, err = io.Copy(out, bytes.NewReader(pix))
|
||||
if err != nil && len(e) != 0 {
|
||||
if err != nil && e[0] != nil {
|
||||
e[0].SetError(err)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
package mongodb
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
"context"
|
||||
"fmt"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
type MongoDb struct {
|
||||
Client *mongo.Client
|
||||
Ctx context.Context
|
||||
DataBase *mongo.Database
|
||||
Connect *mongo.Collection
|
||||
LastErr error
|
||||
}
|
||||
|
||||
func GetMongoDb(database, url string) (*MongoDb, error) {
|
||||
db := MongoDb{}
|
||||
clientOptions := options.Client().ApplyURI(url)
|
||||
|
||||
db.Ctx = context.TODO()
|
||||
// Connect to MongoDb
|
||||
var err error
|
||||
db.Client, err = mongo.Connect(db.Ctx, clientOptions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Check the connection
|
||||
err = db.Client.Ping(db.Ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fmt.Println("Connected to MongoDb!")
|
||||
//databases, err := db.Client.ListDatabaseNames(db.Ctx, bson.M{})
|
||||
//if err != nil {
|
||||
// return nil, err
|
||||
//}
|
||||
//fmt.Println(databases)
|
||||
db.DataBase = db.Client.Database(database)
|
||||
return &db, nil
|
||||
|
||||
}
|
||||
|
||||
func (that *MongoDb) Insert(table string, data interface{}) string {
|
||||
collection := that.DataBase.Collection(table)
|
||||
re, err := collection.InsertOne(that.Ctx, data)
|
||||
if err != nil {
|
||||
that.LastErr = err
|
||||
return ""
|
||||
}
|
||||
return ObjToStr(re.InsertedID)
|
||||
}
|
||||
|
||||
func (that *MongoDb) InsertMany(table string, data ...interface{}) Slice {
|
||||
collection := that.DataBase.Collection(table)
|
||||
re, err := collection.InsertMany(that.Ctx, data)
|
||||
if err != nil {
|
||||
that.LastErr = err
|
||||
return Slice{}
|
||||
}
|
||||
|
||||
return ObjToSlice(re.InsertedIDs)
|
||||
|
||||
}
|
||||
|
||||
func (that *MongoDb) Update(table string, data Map, where Map) int64 {
|
||||
collection := that.DataBase.Collection(table)
|
||||
re, err := collection.UpdateMany(that.Ctx, where, data)
|
||||
|
||||
if err != nil {
|
||||
that.LastErr = err
|
||||
return 0
|
||||
}
|
||||
|
||||
return re.ModifiedCount
|
||||
|
||||
}
|
||||
|
||||
func (that *MongoDb) Delete(table string, where Map) int64 {
|
||||
collection := that.DataBase.Collection(table)
|
||||
re, err := collection.DeleteMany(that.Ctx, where)
|
||||
|
||||
if err != nil {
|
||||
that.LastErr = err
|
||||
return 0
|
||||
}
|
||||
|
||||
return re.DeletedCount
|
||||
|
||||
}
|
||||
|
||||
func (that *MongoDb) Get(table string, where Map) Map {
|
||||
results := []Map{}
|
||||
var cursor *mongo.Cursor
|
||||
var err error
|
||||
collection := that.DataBase.Collection(table)
|
||||
if cursor, err = collection.Find(that.Ctx, where, options.Find().SetSkip(0), options.Find().SetLimit(2)); err != nil {
|
||||
that.LastErr = err
|
||||
return nil
|
||||
}
|
||||
//延迟关闭游标
|
||||
defer func() {
|
||||
if err := cursor.Close(that.Ctx); err != nil {
|
||||
that.LastErr = err
|
||||
|
||||
}
|
||||
}()
|
||||
//这里的结果遍历可以使用另外一种更方便的方式:
|
||||
if err = cursor.All(that.Ctx, &results); err != nil {
|
||||
that.LastErr = err
|
||||
return nil
|
||||
}
|
||||
if len(results) > 0 {
|
||||
|
||||
return results[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (that MongoDb) Select(table string, where Map, page, pageRow int64) []Map {
|
||||
page = (page - 1) * pageRow
|
||||
if page < 0 {
|
||||
page = 0
|
||||
}
|
||||
results := []Map{}
|
||||
var cursor *mongo.Cursor
|
||||
var err error
|
||||
collection := that.DataBase.Collection(table)
|
||||
if cursor, err = collection.Find(that.Ctx, where, options.Find().SetSkip(page), options.Find().SetLimit(pageRow)); err != nil {
|
||||
that.LastErr = err
|
||||
return results
|
||||
}
|
||||
//延迟关闭游标
|
||||
defer func() {
|
||||
if err := cursor.Close(that.Ctx); err != nil {
|
||||
that.LastErr = err
|
||||
|
||||
}
|
||||
}()
|
||||
//这里的结果遍历可以使用另外一种更方便的方式:
|
||||
|
||||
if err = cursor.All(that.Ctx, &results); err != nil {
|
||||
that.LastErr = err
|
||||
return results
|
||||
}
|
||||
return results
|
||||
}
|
||||
@@ -23,7 +23,7 @@ func FileGet(path string) []byte {
|
||||
return buf
|
||||
}
|
||||
|
||||
// RSA_Encrypt RSA加密
|
||||
//RSA加密
|
||||
// plainText 要加密的数据
|
||||
// path 公钥匙文件地址
|
||||
func RSA_Encrypt(plainText []byte, buf []byte) []byte {
|
||||
@@ -46,7 +46,7 @@ func RSA_Encrypt(plainText []byte, buf []byte) []byte {
|
||||
return cipherText
|
||||
}
|
||||
|
||||
// RSA_Decrypt RSA解密
|
||||
//RSA解密
|
||||
// cipherText 需要解密的byte数据
|
||||
// path 私钥文件路径
|
||||
func RSA_Decrypt(cipherText []byte, buf []byte) []byte {
|
||||
@@ -97,7 +97,7 @@ func Demo() {
|
||||
fmt.Println(string(decrypt))
|
||||
}
|
||||
|
||||
// GenerateRSAKey 生成RSA私钥和公钥,保存到文件中
|
||||
//生成RSA私钥和公钥,保存到文件中
|
||||
// bits 证书大小
|
||||
func GenerateRSAKey(bits int, path string) {
|
||||
//GenerateKey函数使用随机数据生成器random生成一对具有指定字位数的RSA密钥
|
||||
|
||||
+9
-25
@@ -1,7 +1,7 @@
|
||||
package tencent
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
. "../../common"
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
@@ -15,40 +15,24 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type company struct {
|
||||
secretId string
|
||||
secretKey string
|
||||
}
|
||||
|
||||
var Company = company{}
|
||||
|
||||
func (that *company) Init(secretId, secretKey string) {
|
||||
// 云市场分配的密钥Id
|
||||
//secretId := "xxxx"
|
||||
//// 云市场分配的密钥Key
|
||||
//secretKey := "xxxx"
|
||||
that.secretId = secretId
|
||||
that.secretKey = secretKey
|
||||
}
|
||||
|
||||
func (that *company) calcAuthorization(source string) (auth string, datetime string, err error) {
|
||||
func calcAuthorization(source string, secretId string, secretKey string) (auth string, datetime string, err error) {
|
||||
|
||||
timeLocation, _ := time.LoadLocation("Etc/GMT")
|
||||
datetime = time.Now().In(timeLocation).Format("Mon, 02 Jan 2006 15:04:05 GMT")
|
||||
signStr := fmt.Sprintf("x-date: %s\nx-source: %s", datetime, source)
|
||||
|
||||
// hmac-sha1
|
||||
mac := hmac.New(sha1.New, []byte(that.secretKey))
|
||||
mac := hmac.New(sha1.New, []byte(secretKey))
|
||||
mac.Write([]byte(signStr))
|
||||
sign := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
|
||||
auth = fmt.Sprintf("hmac id=\"%s\", algorithm=\"hmac-sha1\", headers=\"x-date x-source\", signature=\"%s\"",
|
||||
that.secretId, sign)
|
||||
secretId, sign)
|
||||
|
||||
return auth, datetime, nil
|
||||
}
|
||||
|
||||
func (that *company) urlencode(params map[string]string) string {
|
||||
func urlencode(params map[string]string) string {
|
||||
var p = gourl.Values{}
|
||||
for k, v := range params {
|
||||
p.Add(k, v)
|
||||
@@ -56,7 +40,7 @@ func (that *company) urlencode(params map[string]string) string {
|
||||
return p.Encode()
|
||||
}
|
||||
|
||||
func (that *company) GetCompany(name string) Map {
|
||||
func GetCompany(secretId, secretKey, name string) Map {
|
||||
// 云市场分配的密钥Id
|
||||
//secretId := "xxxx"
|
||||
//// 云市场分配的密钥Key
|
||||
@@ -64,7 +48,7 @@ func (that *company) GetCompany(name string) Map {
|
||||
source := "market"
|
||||
|
||||
// 签名
|
||||
auth, datetime, _ := that.calcAuthorization(source)
|
||||
auth, datetime, _ := calcAuthorization(source, secretId, secretKey)
|
||||
|
||||
// 请求方法
|
||||
method := "GET"
|
||||
@@ -80,13 +64,13 @@ func (that *company) GetCompany(name string) Map {
|
||||
// url参数拼接
|
||||
url := "https://service-3jnh3ku8-1256140209.gz.apigw.tencentcs.com/release/business4/geet"
|
||||
if len(queryParams) > 0 {
|
||||
url = fmt.Sprintf("%s?%s", url, that.urlencode(queryParams))
|
||||
url = fmt.Sprintf("%s?%s", url, urlencode(queryParams))
|
||||
}
|
||||
|
||||
bodyMethods := map[string]bool{"POST": true, "PUT": true, "PATCH": true}
|
||||
var body io.Reader = nil
|
||||
if bodyMethods[method] {
|
||||
body = strings.NewReader(that.urlencode(bodyParams))
|
||||
body = strings.NewReader(urlencode(bodyParams))
|
||||
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
||||
}
|
||||
|
||||
|
||||
+10
-26
@@ -8,32 +8,16 @@ import (
|
||||
ocr "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ocr/v20181119"
|
||||
)
|
||||
|
||||
type tencent struct {
|
||||
secretId string
|
||||
secretKey string
|
||||
credential *common.Credential
|
||||
}
|
||||
var credential = common.NewCredential(
|
||||
"AKIDOgT8cKCQksnY7yKATaYO7j9ORJzSYohP",
|
||||
"GNXgjdN4czA9ya0FNMApVJzTmsmU0KSN",
|
||||
)
|
||||
|
||||
var Tencent = tencent{}
|
||||
|
||||
func (that *tencent) Init(secretId, secretKey string) {
|
||||
// 云市场分配的密钥Id
|
||||
//secretId := "xxxx"
|
||||
//// 云市场分配的密钥Key
|
||||
//secretKey := "xxxx"
|
||||
that.secretId = secretId
|
||||
that.secretKey = secretKey
|
||||
that.credential = common.NewCredential(
|
||||
that.secretId,
|
||||
that.secretKey,
|
||||
)
|
||||
}
|
||||
|
||||
func (that *tencent) OCRCOMPANY(base64Str string) string {
|
||||
func OCRCOMPANY(base64Str string) string {
|
||||
|
||||
cpf := profile.NewClientProfile()
|
||||
cpf.HttpProfile.Endpoint = "ocr.tencentcloudapi.com"
|
||||
client, _ := ocr.NewClient(that.credential, "ap-guangzhou", cpf)
|
||||
client, _ := ocr.NewClient(credential, "ap-guangzhou", cpf)
|
||||
|
||||
request := ocr.NewBizLicenseOCRRequest()
|
||||
|
||||
@@ -54,11 +38,11 @@ func (that *tencent) OCRCOMPANY(base64Str string) string {
|
||||
return response.ToJsonString()
|
||||
}
|
||||
|
||||
func (that *tencent) OCR(base64Str string) string {
|
||||
func OCR(base64Str string) string {
|
||||
|
||||
cpf := profile.NewClientProfile()
|
||||
cpf.HttpProfile.Endpoint = "ocr.tencentcloudapi.com"
|
||||
client, _ := ocr.NewClient(that.credential, "ap-guangzhou", cpf)
|
||||
client, _ := ocr.NewClient(credential, "ap-guangzhou", cpf)
|
||||
|
||||
request := ocr.NewGeneralAccurateOCRRequest()
|
||||
|
||||
@@ -79,11 +63,11 @@ func (that *tencent) OCR(base64Str string) string {
|
||||
return response.ToJsonString()
|
||||
}
|
||||
|
||||
func (that *tencent) Qrcode(base64Str string) string {
|
||||
func Qrcode(base64Str string) string {
|
||||
|
||||
cpf := profile.NewClientProfile()
|
||||
cpf.HttpProfile.Endpoint = "ocr.tencentcloudapi.com"
|
||||
client, _ := ocr.NewClient(that.credential, "ap-guangzhou", cpf)
|
||||
client, _ := ocr.NewClient(credential, "ap-guangzhou", cpf)
|
||||
|
||||
request := ocr.NewQrcodeOCRRequest()
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package upload
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
. "../../common"
|
||||
"errors"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
@@ -15,7 +15,7 @@ type Upload struct {
|
||||
Path string
|
||||
}
|
||||
|
||||
func (that *Upload) UpFile(Request *http.Request, fieldName, savefilepath, savePath string) (string, error) {
|
||||
func (this *Upload) UpFile(Request *http.Request, fieldName, savefilepath, savePath string) (string, error) {
|
||||
Request.ParseMultipartForm(32 << 20)
|
||||
var filePath string
|
||||
files := Request.MultipartForm.File
|
||||
@@ -36,7 +36,7 @@ func (that *Upload) UpFile(Request *http.Request, fieldName, savefilepath, saveP
|
||||
data := time.Unix(int64(t), 0).Format("2006-01")
|
||||
path := ""
|
||||
if strings.EqualFold(savefilepath, "") {
|
||||
path = that.Path + data
|
||||
path = this.Path + data
|
||||
} else {
|
||||
path = savefilepath + data
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func (that *Upload) UpFile(Request *http.Request, fieldName, savefilepath, saveP
|
||||
}
|
||||
}
|
||||
filename := time.Unix(int64(t), 0).Format("2006-01-02-15-22-25") + ObjToStr(Rand(6))
|
||||
filePath = path + "/" + filename + that.Path
|
||||
filePath = path + "/" + filename + this.Path
|
||||
} else {
|
||||
filePath = savePath
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
package wechat
|
||||
|
||||
import (
|
||||
"github.com/silenceper/wechat/v2"
|
||||
"github.com/silenceper/wechat/v2/cache"
|
||||
"github.com/silenceper/wechat/v2/officialaccount"
|
||||
h5config "github.com/silenceper/wechat/v2/officialaccount/config"
|
||||
"github.com/silenceper/wechat/v2/officialaccount/js"
|
||||
"github.com/silenceper/wechat/v2/officialaccount/oauth"
|
||||
)
|
||||
|
||||
// 基于此文档开发
|
||||
// https://github.com/silenceper/wechat/blob/v2/doc/api/officialaccount.md
|
||||
type h5Program struct {
|
||||
Memory *cache.Memory
|
||||
Config *h5config.Config
|
||||
*officialaccount.OfficialAccount
|
||||
weixin *wechat.Wechat //微信登录实例
|
||||
}
|
||||
|
||||
var H5Program = h5Program{}
|
||||
|
||||
// Init 初始化
|
||||
func (that *h5Program) Init(appid string, appsecret string) {
|
||||
that.weixin = wechat.NewWechat()
|
||||
that.Memory = cache.NewMemory()
|
||||
that.Config = &h5config.Config{
|
||||
AppID: appid,
|
||||
AppSecret: appsecret,
|
||||
//Token: "xxx",
|
||||
//EncodingAESKey: "xxxx",
|
||||
Cache: that.Memory,
|
||||
}
|
||||
that.OfficialAccount = that.weixin.GetOfficialAccount(that.Config)
|
||||
}
|
||||
|
||||
// GetUserInfo 获取用户信息
|
||||
func (that *h5Program) GetUserInfo(code string) (appid string, resToken oauth.ResAccessToken, userInfo oauth.UserInfo, err error) {
|
||||
auth := that.GetOauth()
|
||||
//weixin.GetOpenPlatform()
|
||||
resToken, err = auth.GetUserAccessToken(code)
|
||||
if err != nil {
|
||||
|
||||
return auth.AppID, resToken, userInfo, err
|
||||
}
|
||||
|
||||
//getUserInfo
|
||||
userInfo, err = auth.GetUserInfo(resToken.AccessToken, resToken.OpenID, "")
|
||||
if err != nil {
|
||||
return auth.AppID, resToken, userInfo, err
|
||||
}
|
||||
|
||||
return auth.AppID, resToken, userInfo, err
|
||||
}
|
||||
|
||||
// GetSignUrl js url签名
|
||||
func (that *h5Program) GetSignUrl(signUrl string) (*js.Config, error) {
|
||||
|
||||
js := that.OfficialAccount.GetJs()
|
||||
cfg1, e := js.GetConfig(signUrl)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
|
||||
return cfg1, nil
|
||||
}
|
||||
|
||||
// GetSignUrl js url签名
|
||||
//func (that *h5Program) GetJsPay(signUrl string) (*js.Config, error) {
|
||||
// //
|
||||
// //js := that.OfficialAccount().GetJs()
|
||||
// //
|
||||
// //cfg1, e := js.GetConfig(signUrl)
|
||||
// //if e != nil {
|
||||
// // return nil, e
|
||||
// //}
|
||||
//
|
||||
// return cfg1, nil
|
||||
//}
|
||||
@@ -1,66 +0,0 @@
|
||||
package wechat
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/silenceper/wechat/v2"
|
||||
"github.com/silenceper/wechat/v2/cache"
|
||||
"github.com/silenceper/wechat/v2/miniprogram"
|
||||
"github.com/silenceper/wechat/v2/miniprogram/auth"
|
||||
"github.com/silenceper/wechat/v2/miniprogram/config"
|
||||
"github.com/silenceper/wechat/v2/miniprogram/encryptor"
|
||||
)
|
||||
|
||||
type miniProgram struct {
|
||||
Memory *cache.Memory
|
||||
Config *config.Config
|
||||
weixin *wechat.Wechat //微信登录实例
|
||||
*miniprogram.MiniProgram
|
||||
}
|
||||
|
||||
var MiniProgram = miniProgram{}
|
||||
|
||||
// Init 初始化
|
||||
func (that *miniProgram) Init(appid string, appsecret string) {
|
||||
|
||||
that.weixin = wechat.NewWechat()
|
||||
that.Memory = cache.NewMemory()
|
||||
that.Config = &config.Config{
|
||||
AppID: appid,
|
||||
AppSecret: appsecret,
|
||||
//Token: "xxx",
|
||||
//EncodingAESKey: "xxxx",
|
||||
Cache: that.Memory,
|
||||
}
|
||||
that.MiniProgram = that.weixin.GetMiniProgram(that.Config)
|
||||
}
|
||||
|
||||
func (that *miniProgram) GetBaseUserInfo(code string) (appid string, re auth.ResCode2Session, err error) {
|
||||
appid = that.Config.AppID
|
||||
a := that.GetAuth()
|
||||
re, err = a.Code2Session(code)
|
||||
|
||||
if err != nil {
|
||||
return appid, re, err
|
||||
}
|
||||
|
||||
return appid, re, err
|
||||
}
|
||||
|
||||
func (that *miniProgram) GetPhoneNumber(sessionkey, encryptedData, iv string) (appid string, re *encryptor.PlainData, err error) {
|
||||
appid = that.Config.AppID
|
||||
|
||||
if sessionkey == "" || encryptedData == "" || iv == "" {
|
||||
return appid, re, errors.New("参数不足")
|
||||
}
|
||||
|
||||
eny := that.GetEncryptor()
|
||||
|
||||
re, err = eny.Decrypt(sessionkey, encryptedData, iv)
|
||||
|
||||
if err != nil {
|
||||
return appid, re, err
|
||||
}
|
||||
|
||||
return appid, re, err
|
||||
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
package wechat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/go-pay/gopay"
|
||||
"github.com/go-pay/gopay/wechat/v3"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 基于此文档开发
|
||||
// https://github.com/silenceper/wechat/blob/v2/doc/api/officialaccount.md
|
||||
type wxpay struct {
|
||||
client *wechat.ClientV3
|
||||
ctx context.Context
|
||||
apiV3Key string
|
||||
MchId string
|
||||
}
|
||||
|
||||
var WxPay = wxpay{}
|
||||
|
||||
// Init 初始化
|
||||
func (that *wxpay) Init(MchId, SerialNo, APIv3Key, PrivateKey string) {
|
||||
client, err := wechat.NewClientV3(MchId, SerialNo, APIv3Key, PrivateKey)
|
||||
if err != nil {
|
||||
//xlog.Error(err)
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
that.client = client
|
||||
that.apiV3Key = APIv3Key
|
||||
that.MchId = MchId
|
||||
// 设置微信平台API证书和序列号(如开启自动验签,请忽略此步骤)
|
||||
//client.SetPlatformCert([]byte(""), "")
|
||||
that.ctx = context.Background()
|
||||
// 启用自动同步返回验签,并定时更新微信平台API证书(开启自动验签时,无需单独设置微信平台API证书和序列号)
|
||||
err = client.AutoVerifySign()
|
||||
if err != nil {
|
||||
//xlog.Error(err)
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
// 打开Debug开关,输出日志,默认是关闭的
|
||||
client.DebugSwitch = gopay.DebugOn
|
||||
}
|
||||
|
||||
// GetUserInfo 获取用户信息
|
||||
func (that *wxpay) GetJsOrder(money int64, appid, openid, name, tradeNo, notifyUrl string) (jsApiParams *wechat.JSAPIPayParams, err error) {
|
||||
fmt.Println("dasdas", money, appid, name, tradeNo, notifyUrl)
|
||||
PrepayId, err := that.getPrepayId(money, appid, that.MchId, openid, name, tradeNo, notifyUrl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
//小程序
|
||||
jsapi, err := that.client.PaySignOfJSAPI(appid, PrepayId)
|
||||
return jsapi, err
|
||||
}
|
||||
|
||||
func (that *wxpay) CallbackJsOrder(req *http.Request) (*wechat.V3DecryptResult, error) {
|
||||
|
||||
notifyReq, err := wechat.V3ParseNotify(req)
|
||||
if err != nil {
|
||||
//xlog.Error(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// wxPublicKey 通过 client.WxPublicKey() 获取
|
||||
err = notifyReq.VerifySignByPK(that.client.WxPublicKey())
|
||||
if err != nil {
|
||||
//xlog.Error(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// ========异步通知敏感信息解密========
|
||||
// 普通支付通知解密
|
||||
result, err := notifyReq.DecryptCipherText(that.apiV3Key)
|
||||
|
||||
//that.client.V3TransactionQueryOrder(that.ctx,result.BankType,result.OR)
|
||||
|
||||
return result, err
|
||||
|
||||
// 合单支付通知解密
|
||||
//result, err := notifyReq.DecryptCombineCipherText(apiV3Key)
|
||||
//// 退款通知解密
|
||||
//result, err := notifyReq.DecryptRefundCipherText(apiV3Key)
|
||||
|
||||
// ========异步通知应答========
|
||||
// 退款通知http应答码为200且返回状态码为SUCCESS才会当做商户接收成功,否则会重试。
|
||||
// 注意:重试过多会导致微信支付端积压过多通知而堵塞,影响其他正常通知。
|
||||
|
||||
// 此写法是 gin 框架返回微信的写法
|
||||
//c.JSON(http.StatusOK, &wechat.V3NotifyRsp{Code: gopay.SUCCESS, Message: "成功"})
|
||||
//
|
||||
//// 此写法是 echo 框架返回微信的写法
|
||||
//return c.JSON(http.StatusOK, &wechat.V3NotifyRsp{Code: gopay.SUCCESS, Message: "成功"})
|
||||
}
|
||||
|
||||
// GetUserInfo 获取用户信息
|
||||
//func (that *wxpay) GetMiniOrder(money int64,appid,name,tradeNo,notifyUrl string) (jsApiParams *wechat.AppletParams,err error){
|
||||
//
|
||||
// PrepayId,err:=that.getPrepayId(money,name,tradeNo,notifyUrl)
|
||||
// if err!=nil{
|
||||
// return nil,err
|
||||
// }
|
||||
//
|
||||
// //小程序
|
||||
// applet, err := that.client.PaySignOfApplet(appid,PrepayId)
|
||||
//
|
||||
// return applet,err
|
||||
//}
|
||||
|
||||
func (that *wxpay) getPrepayId(money int64, appid, mchid, openid, name, tradeNo, notifyUrl string) (prepayid string, err error) {
|
||||
expire := time.Now().Add(10 * time.Minute).Format(time.RFC3339)
|
||||
// 初始化 BodyMap
|
||||
bm := make(gopay.BodyMap)
|
||||
bm.Set("appid", appid).
|
||||
Set("mchid", mchid).
|
||||
//Set("sub_mchid", "sub_mchid").
|
||||
Set("description", name).
|
||||
Set("out_trade_no", tradeNo).
|
||||
Set("time_expire", expire).
|
||||
Set("notify_url", notifyUrl).
|
||||
SetBodyMap("amount", func(bm gopay.BodyMap) {
|
||||
bm.Set("total", money).
|
||||
Set("currency", "CNY")
|
||||
}).
|
||||
SetBodyMap("payer", func(bm gopay.BodyMap) {
|
||||
bm.Set("openid", openid)
|
||||
})
|
||||
//ctx:=context.Context()
|
||||
|
||||
wxRsp, err := that.client.V3TransactionJsapi(that.ctx, bm)
|
||||
fmt.Println("获取PrepayId", wxRsp, err)
|
||||
if err != nil {
|
||||
//xlog.Error(err)
|
||||
return "", err
|
||||
}
|
||||
return wxRsp.Response.PrepayId, nil
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
)
|
||||
|
||||
var ProjectTest = ProjTest{
|
||||
"test": TestTest,
|
||||
"mysql": MysqlTest,
|
||||
"dmdb": DmdbTest,
|
||||
"cache": CacheTest,
|
||||
"expect_demo": ExpectDemoTest,
|
||||
}
|
||||
|
||||
var testApp *TestApp
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
testApp = Test("config/config.json").
|
||||
WorkDir("..").
|
||||
Listener(func(ctx *Context) bool {
|
||||
return false // 空实现:继续进控制器;需要鉴权/拦截时在此写
|
||||
}).
|
||||
Swagger("My API", "1.0.0").
|
||||
Proj(TestProj{
|
||||
"app": {
|
||||
Proj: Project,
|
||||
Tests: ProjectTest,
|
||||
},
|
||||
// 通用 CRUD(codeConfig=admin)路由由 Init 时按库表生成,这里仅挂测试
|
||||
"admin": {
|
||||
Proj: Proj{},
|
||||
Tests: AdminDepartmentTest,
|
||||
},
|
||||
}).
|
||||
Flows(DemoFlows)
|
||||
|
||||
// 仅本 example:空库时灌演示数据。须连专用测试库,勿对生产库调用。
|
||||
SetupDatabase(&testApp.Db)
|
||||
|
||||
os.Exit(testApp.Run(m))
|
||||
}
|
||||
|
||||
func TestApi(t *testing.T) {
|
||||
testApp.RunTests(t)
|
||||
}
|
||||
|
||||
// TestListenerChain 验证链式 Listener 在请求路径上被调用。
|
||||
// 注意:TestMain 已 Chdir 到 example/,此处不再 WorkDir。
|
||||
func TestListenerChain(t *testing.T) {
|
||||
hit := false
|
||||
app := Test("config/config.json").
|
||||
Listener(func(ctx *Context) bool {
|
||||
hit = true
|
||||
return false
|
||||
}).
|
||||
Proj(TestProj{
|
||||
"app": {
|
||||
Proj: Project,
|
||||
Tests: ProjTest{
|
||||
"expect_demo": CtrTest{
|
||||
"string_result": {Desc: "触发 listener", Func: func(a *Api) {
|
||||
ExpectDemoTest["string_result"].Func(a)
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
SetupDatabase(&app.Db)
|
||||
app.RunTests(t)
|
||||
|
||||
if !hit {
|
||||
t.Fatal("Listener 未被调用")
|
||||
}
|
||||
_ = app.Db.RollbackTestTx()
|
||||
}
|
||||
|
||||
// TestInterruptStop 模拟提前中断:string_result 跑完后 RequestStop,zzz_should_skip 应 Skip;
|
||||
// swagger 中 string_result 的 cases 应保留。注意:TestMain 已 Chdir 到 example/,此处不再 WorkDir。
|
||||
func TestInterruptStop(t *testing.T) {
|
||||
var app *TestApp
|
||||
secondRan := false
|
||||
|
||||
app = Test("config/config.json").
|
||||
Swagger("Interrupt Demo", "1.0.0").
|
||||
Proj(TestProj{
|
||||
"app": {
|
||||
Proj: Project,
|
||||
Tests: ProjTest{
|
||||
"expect_demo": CtrTest{
|
||||
"string_result": {Desc: "先跑并停测", Func: func(a *Api) {
|
||||
ExpectDemoTest["string_result"].Func(a)
|
||||
app.RequestStop()
|
||||
}},
|
||||
"zzz_should_skip": {Desc: "应被跳过", Func: func(a *Api) {
|
||||
secondRan = true
|
||||
t.Error("zzz_should_skip 在 RequestStop 后不应执行")
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
SetupDatabase(&app.Db)
|
||||
app.RunTests(t)
|
||||
|
||||
if secondRan {
|
||||
t.Fatal("停测后仍执行了后续接口")
|
||||
}
|
||||
_ = app.Db.RollbackTestTx()
|
||||
|
||||
// 收尾写盘(中断路径下可能未跑满 GenerateSwagger,显式补一次增量合并)
|
||||
if err := app.GenerateSwagger("Interrupt Demo", "1.0.0", app.Config.GetString("tpt")); err != nil {
|
||||
t.Fatalf("GenerateSwagger: %v", err)
|
||||
}
|
||||
|
||||
specPath := filepath.Join(app.Config.GetString("tpt"), "swagger", "app", "api-spec.json")
|
||||
data, err := os.ReadFile(specPath)
|
||||
if err != nil {
|
||||
t.Fatalf("应已生成 swagger %s: %v", specPath, err)
|
||||
}
|
||||
var spec struct {
|
||||
Endpoints []struct {
|
||||
Path string `json:"path"`
|
||||
Cases json.RawMessage `json:"cases"`
|
||||
} `json:"endpoints"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &spec); err != nil {
|
||||
t.Fatalf("解析 api-spec 失败: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, ep := range spec.Endpoints {
|
||||
if ep.Path != "/app/expect_demo/string_result" {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if !strings.Contains(string(ep.Cases), `"name"`) {
|
||||
t.Fatalf("string_result 应保留测试 cases,实际: %s", string(ep.Cases))
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("swagger 中应包含已跑完的 /app/expect_demo/string_result")
|
||||
}
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
// CacheCtr 缓存测试控制器(数据库无关)
|
||||
var CacheCtr = Ctr{
|
||||
"all": func(that *Context) { that.Display(0, testCacheAll(that)) },
|
||||
"compat": func(that *Context) { that.Display(0, testCacheCompatible(that)) },
|
||||
"batch": func(that *Context) {
|
||||
that.Display(0, Map{"message": "批量缓存测试完成,请查看控制台输出和日志文件"})
|
||||
},
|
||||
}
|
||||
|
||||
func testCacheAll(that *Context) Map {
|
||||
result := Map{"name": "数据库缓存测试", "tests": Slice{}}
|
||||
tests := Slice{}
|
||||
|
||||
cacheMode := "unknown"
|
||||
if that.Application.HoTimeCache != nil && that.Application.HoTimeCache.Config != nil {
|
||||
dbConfig := that.Application.HoTimeCache.Config.GetMap("db")
|
||||
if dbConfig != nil {
|
||||
cacheMode = dbConfig.GetString("mode")
|
||||
if cacheMode == "" {
|
||||
cacheMode = "new"
|
||||
}
|
||||
}
|
||||
}
|
||||
result["cache_mode"] = cacheMode
|
||||
|
||||
testPrefix := fmt.Sprintf("cache_test_%d_", time.Now().UnixNano())
|
||||
|
||||
// 1. 基础读写测试
|
||||
test1 := Map{"name": "1. 基础 set/get 测试"}
|
||||
testKey1 := testPrefix + "basic"
|
||||
testValue1 := Map{"name": "测试数据", "count": 123, "active": true}
|
||||
that.Application.Cache(testKey1, testValue1)
|
||||
cached1 := that.Application.Cache(testKey1)
|
||||
if cached1.Data != nil {
|
||||
cachedMap := cached1.ToMap()
|
||||
test1["result"] = cachedMap.GetString("name") == "测试数据" && cachedMap.GetInt("count") == 123
|
||||
test1["cached_value"] = cachedMap
|
||||
} else {
|
||||
test1["result"] = false
|
||||
test1["error"] = "缓存读取返回 nil"
|
||||
}
|
||||
tests = append(tests, test1)
|
||||
|
||||
// 2. 删除缓存测试
|
||||
test2 := Map{"name": "2. delete 删除缓存测试"}
|
||||
testKey2 := testPrefix + "delete"
|
||||
that.Application.Cache(testKey2, "删除测试值")
|
||||
before := that.Application.Cache(testKey2)
|
||||
beforeExists := before.Data != nil
|
||||
that.Application.Cache(testKey2, nil)
|
||||
after := that.Application.Cache(testKey2)
|
||||
afterExists := after.Data != nil
|
||||
test2["result"] = beforeExists && !afterExists
|
||||
test2["before_exists"] = beforeExists
|
||||
test2["after_exists"] = afterExists
|
||||
tests = append(tests, test2)
|
||||
|
||||
// 3. 过期时间测试
|
||||
test3 := Map{"name": "3. 过期时间测试(短超时)"}
|
||||
testKey3 := testPrefix + "expire"
|
||||
that.Application.Cache(testKey3, "短期数据", 2)
|
||||
immediate := that.Application.Cache(testKey3)
|
||||
immediateExists := immediate.Data != nil
|
||||
test3["result"] = immediateExists
|
||||
test3["immediate_exists"] = immediateExists
|
||||
test3["note"] = "设置了2秒过期,可等待后再次访问验证过期"
|
||||
tests = append(tests, test3)
|
||||
|
||||
// 4. 不存在的 key 读取测试
|
||||
test4 := Map{"name": "4. 不存在的 key 读取测试"}
|
||||
nonExistKey := testPrefix + "non_exist_key_" + fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
nonExist := that.Application.Cache(nonExistKey)
|
||||
test4["result"] = nonExist.Data == nil
|
||||
test4["value"] = nonExist.Data
|
||||
tests = append(tests, test4)
|
||||
|
||||
// 5. 重复 set 同一个 key 测试
|
||||
test5 := Map{"name": "5. 重复 set 同一个 key 测试"}
|
||||
testKey5 := testPrefix + "repeat"
|
||||
that.Application.Cache(testKey5, "第一次值")
|
||||
first := that.Application.Cache(testKey5).ToStr()
|
||||
that.Application.Cache(testKey5, "第二次值")
|
||||
second := that.Application.Cache(testKey5).ToStr()
|
||||
that.Application.Cache(testKey5, Map{"version": 3})
|
||||
third := that.Application.Cache(testKey5).ToMap()
|
||||
test5["result"] = first == "第一次值" && second == "第二次值" && third.GetInt("version") == 3
|
||||
test5["first"] = first
|
||||
test5["second"] = second
|
||||
test5["third"] = third
|
||||
tests = append(tests, test5)
|
||||
|
||||
// 6. 通配删除测试
|
||||
test6 := Map{"name": "6. 通配删除测试 (key*)"}
|
||||
wildcardPrefix := testPrefix + "wildcard_"
|
||||
that.Application.Cache(wildcardPrefix+"a", "值A")
|
||||
that.Application.Cache(wildcardPrefix+"b", "值B")
|
||||
that.Application.Cache(wildcardPrefix+"c", "值C")
|
||||
aExists := that.Application.Cache(wildcardPrefix+"a").Data != nil
|
||||
bExists := that.Application.Cache(wildcardPrefix+"b").Data != nil
|
||||
cExists := that.Application.Cache(wildcardPrefix+"c").Data != nil
|
||||
allExistBefore := aExists && bExists && cExists
|
||||
that.Application.Cache(wildcardPrefix+"*", nil)
|
||||
aAfter := that.Application.Cache(wildcardPrefix+"a").Data != nil
|
||||
bAfter := that.Application.Cache(wildcardPrefix+"b").Data != nil
|
||||
cAfter := that.Application.Cache(wildcardPrefix+"c").Data != nil
|
||||
allDeletedAfter := !aAfter && !bAfter && !cAfter
|
||||
test6["result"] = allExistBefore && allDeletedAfter
|
||||
test6["before"] = Map{"a": aExists, "b": bExists, "c": cExists}
|
||||
test6["after"] = Map{"a": aAfter, "b": bAfter, "c": cAfter}
|
||||
tests = append(tests, test6)
|
||||
|
||||
// 7. 不同数据类型测试
|
||||
test7 := Map{"name": "7. 不同数据类型存储测试"}
|
||||
that.Application.Cache(testPrefix+"type_string", "字符串值")
|
||||
typeString := that.Application.Cache(testPrefix + "type_string").ToStr()
|
||||
that.Application.Cache(testPrefix+"type_int", 12345)
|
||||
typeInt := that.Application.Cache(testPrefix + "type_int").ToInt()
|
||||
that.Application.Cache(testPrefix+"type_float", 3.14159)
|
||||
typeFloat := that.Application.Cache(testPrefix + "type_float").ToFloat64()
|
||||
that.Application.Cache(testPrefix+"type_bool", true)
|
||||
typeBoolData := that.Application.Cache(testPrefix + "type_bool").Data
|
||||
typeBool := typeBoolData == true || typeBoolData == "true" || typeBoolData == 1.0
|
||||
that.Application.Cache(testPrefix+"type_map", Map{"key": "value", "num": 100})
|
||||
typeMap := that.Application.Cache(testPrefix + "type_map").ToMap()
|
||||
that.Application.Cache(testPrefix+"type_slice", Slice{1, 2, 3, "four", Map{"five": 5}})
|
||||
typeSlice := that.Application.Cache(testPrefix + "type_slice").ToSlice()
|
||||
test7["result"] = typeString == "字符串值" &&
|
||||
typeInt == 12345 &&
|
||||
typeFloat > 3.14 && typeFloat < 3.15 &&
|
||||
typeBool == true &&
|
||||
typeMap.GetString("key") == "value" &&
|
||||
len(typeSlice) == 5
|
||||
test7["string"] = typeString
|
||||
test7["int"] = typeInt
|
||||
test7["float"] = typeFloat
|
||||
test7["bool"] = typeBool
|
||||
test7["map"] = typeMap
|
||||
test7["slice"] = typeSlice
|
||||
tests = append(tests, test7)
|
||||
|
||||
// 8. 自定义超时时间测试
|
||||
test8 := Map{"name": "8. 自定义超时时间参数测试"}
|
||||
testKey8 := testPrefix + "custom_timeout"
|
||||
that.Application.Cache(testKey8, "长期数据", 3600)
|
||||
longTerm := that.Application.Cache(testKey8)
|
||||
test8["result"] = longTerm.Data != nil
|
||||
test8["value"] = longTerm.ToStr()
|
||||
tests = append(tests, test8)
|
||||
|
||||
// 9. 查询缓存表状态
|
||||
test9 := Map{"name": "9. 缓存表状态查询"}
|
||||
prefix := that.Db.GetPrefix()
|
||||
newTableName := prefix + "hotime_cache"
|
||||
newCount := that.Db.Count(newTableName)
|
||||
test9["new_table_count"] = newCount
|
||||
test9["new_table_name"] = newTableName
|
||||
test9["result"] = newCount >= 0
|
||||
tests = append(tests, test9)
|
||||
|
||||
that.Application.Cache(testPrefix+"*", nil)
|
||||
|
||||
result["tests"] = tests
|
||||
result["success"] = true
|
||||
result["cleanup"] = "已清理所有测试缓存数据"
|
||||
return result
|
||||
}
|
||||
|
||||
func testCacheCompatible(that *Context) Map {
|
||||
result := Map{
|
||||
"test_name": "兼容模式白盒测试",
|
||||
"timestamp": time.Now().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
|
||||
prefix := that.Db.GetPrefix()
|
||||
newTableName := prefix + "hotime_cache"
|
||||
legacyTableName := prefix + "cached"
|
||||
|
||||
tests := Slice{}
|
||||
|
||||
test1 := Map{"name": "1. 查询当前缓存模式"}
|
||||
cacheConfig := that.Application.Config.GetMap("cache")
|
||||
dbConfig := cacheConfig.GetMap("db")
|
||||
mode := dbConfig.GetString("mode")
|
||||
if mode == "" {
|
||||
mode = "默认(compatible)"
|
||||
}
|
||||
test1["mode"] = mode
|
||||
test1["result"] = true
|
||||
tests = append(tests, test1)
|
||||
|
||||
// 根据数据库类型使用不同的引号
|
||||
q := "`"
|
||||
if that.Db.Dialect != nil {
|
||||
q = that.Db.Dialect.QuoteChar()
|
||||
}
|
||||
|
||||
test2 := Map{"name": "2. 查询老表cached现有数据"}
|
||||
legacyData := that.Db.Query("SELECT * FROM " + q + legacyTableName + q + " LIMIT 5")
|
||||
test2["legacy_table"] = legacyTableName
|
||||
test2["count"] = len(legacyData)
|
||||
test2["data"] = legacyData
|
||||
test2["result"] = true
|
||||
tests = append(tests, test2)
|
||||
|
||||
test3 := Map{"name": "3. 查询新表hotime_cache现有数据"}
|
||||
newData := that.Db.Query("SELECT * FROM " + q + newTableName + q + " LIMIT 5")
|
||||
test3["new_table"] = newTableName
|
||||
test3["count"] = len(newData)
|
||||
test3["data"] = newData
|
||||
test3["result"] = true
|
||||
tests = append(tests, test3)
|
||||
|
||||
test4 := Map{"name": "4. 测试兼容模式老表回退读取"}
|
||||
testKey4 := "test_compat_fallback_" + ObjToStr(time.Now().UnixNano())
|
||||
testValue4 := Map{"admin_id": 999, "admin_name": "测试老数据"}
|
||||
testValueJson4 := ObjToStr(Map{"data": testValue4})
|
||||
that.Db.Insert(legacyTableName, Map{
|
||||
"key": testKey4,
|
||||
"value": testValueJson4,
|
||||
"endtime": time.Now().Unix() + 3600,
|
||||
"time": time.Now().UnixNano(),
|
||||
})
|
||||
test4["test_key"] = testKey4
|
||||
newExists := that.Db.Get(newTableName, "*", Map{"key": testKey4})
|
||||
test4["key_in_new_table"] = newExists != nil
|
||||
cacheValue := that.Application.Cache(testKey4)
|
||||
test4["cache_api_result"] = cacheValue.Data
|
||||
test4["result"] = newExists == nil && cacheValue.Data != nil
|
||||
tests = append(tests, test4)
|
||||
|
||||
test5 := Map{"name": "5. 测试兼容模式写新删老"}
|
||||
testKey5 := "test_compat_write_" + ObjToStr(time.Now().UnixNano())
|
||||
testValue5 := "兼容模式测试数据"
|
||||
that.Db.Insert(legacyTableName, Map{
|
||||
"key": testKey5,
|
||||
"value": `{"data":"老表原始数据"}`,
|
||||
"endtime": time.Now().Unix() + 3600,
|
||||
"time": time.Now().UnixNano(),
|
||||
})
|
||||
legacyBefore := that.Db.Get(legacyTableName, "*", Map{"key": testKey5})
|
||||
test5["step1_legacy_before"] = legacyBefore != nil
|
||||
that.Application.Cache(testKey5, testValue5)
|
||||
newAfter := that.Db.Get(newTableName, "*", Map{"key": testKey5})
|
||||
test5["step2_new_after"] = newAfter != nil
|
||||
if newAfter != nil {
|
||||
test5["new_value"] = newAfter.GetString("value")
|
||||
}
|
||||
legacyAfter := that.Db.Get(legacyTableName, "*", Map{"key": testKey5})
|
||||
test5["step3_legacy_after_deleted"] = legacyAfter == nil
|
||||
test5["result"] = legacyBefore != nil && newAfter != nil && legacyAfter == nil
|
||||
tests = append(tests, test5)
|
||||
|
||||
test6 := Map{"name": "6. 测试兼容模式删除(删除两表)"}
|
||||
testKey6 := "test_compat_delete_" + ObjToStr(time.Now().UnixNano())
|
||||
that.Db.Insert(legacyTableName, Map{
|
||||
"key": testKey6,
|
||||
"value": `{"data":"待删除老数据"}`,
|
||||
"endtime": time.Now().Unix() + 3600,
|
||||
"time": time.Now().UnixNano(),
|
||||
})
|
||||
that.Db.Insert(newTableName, Map{
|
||||
"key": testKey6,
|
||||
"value": `"待删除新数据"`,
|
||||
"end_time": time.Now().Add(time.Hour).Format("2006-01-02 15:04:05"),
|
||||
"state": 0,
|
||||
"create_time": time.Now().Format("2006-01-02 15:04:05"),
|
||||
"modify_time": time.Now().Format("2006-01-02 15:04:05"),
|
||||
})
|
||||
test6["before_legacy"] = that.Db.Get(legacyTableName, "*", Map{"key": testKey6}) != nil
|
||||
test6["before_new"] = that.Db.Get(newTableName, "*", Map{"key": testKey6}) != nil
|
||||
that.Application.Cache(testKey6, nil)
|
||||
test6["after_legacy_deleted"] = that.Db.Get(legacyTableName, "*", Map{"key": testKey6}) == nil
|
||||
test6["after_new_deleted"] = that.Db.Get(newTableName, "*", Map{"key": testKey6}) == nil
|
||||
test6["result"] = test6.GetBool("before_legacy") && test6.GetBool("before_new") &&
|
||||
test6.GetBool("after_legacy_deleted") && test6.GetBool("after_new_deleted")
|
||||
tests = append(tests, test6)
|
||||
|
||||
that.Db.Delete(newTableName, Map{"key[~]": "test_compat_%"})
|
||||
that.Db.Delete(legacyTableName, Map{"key[~]": "test_compat_%"})
|
||||
|
||||
result["tests"] = tests
|
||||
result["success"] = true
|
||||
return result
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
var CacheTest = CtrTest{
|
||||
"all": {Desc: "缓存全部测试", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result == nil {
|
||||
return fmt.Errorf("result 为 nil")
|
||||
}
|
||||
if !result.GetBool("success") {
|
||||
return fmt.Errorf("缓存测试未全部通过")
|
||||
}
|
||||
if result.GetString("cache_mode") == "" {
|
||||
return fmt.Errorf("cache_mode 字段缺失")
|
||||
}
|
||||
tests := result.GetSlice("tests")
|
||||
if len(tests) == 0 {
|
||||
return fmt.Errorf("tests 数组为空")
|
||||
}
|
||||
for i := 0; i < len(tests); i++ {
|
||||
item := tests.GetMap(i)
|
||||
if item != nil && !item.GetBool("result") {
|
||||
return fmt.Errorf("子项 '%s' 失败", item.GetString("name"))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}).Get("缓存全部测试", 0, Map{
|
||||
"name": "sample",
|
||||
"cache_mode": "sample",
|
||||
"success": true,
|
||||
"cleanup": "sample",
|
||||
"tests": Slice{Map{
|
||||
"name": "sample", "result": true,
|
||||
}},
|
||||
})
|
||||
}},
|
||||
|
||||
// 兼容模式下的老表回退/写新删老操作在测试事务隔离下已知异常(cache API
|
||||
// 使用独立连接,不在 testTx 事务内),因此只校验结构完整性
|
||||
"compat": {Desc: "缓存兼容模式测试", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result == nil {
|
||||
return fmt.Errorf("result 为 nil")
|
||||
}
|
||||
if result.GetString("test_name") == "" {
|
||||
return fmt.Errorf("test_name 字段缺失")
|
||||
}
|
||||
if result.GetString("timestamp") == "" {
|
||||
return fmt.Errorf("timestamp 字段缺失")
|
||||
}
|
||||
tests := result.GetSlice("tests")
|
||||
if len(tests) == 0 {
|
||||
return fmt.Errorf("tests 数组为空")
|
||||
}
|
||||
return nil
|
||||
}).Get("兼容模式测试", 0, Map{
|
||||
"test_name": "sample",
|
||||
"timestamp": "sample",
|
||||
"success": true,
|
||||
"tests": Slice{Map{
|
||||
"name": "sample", "result": true,
|
||||
}},
|
||||
})
|
||||
}},
|
||||
|
||||
"batch": {Desc: "批量缓存测试", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result == nil {
|
||||
return fmt.Errorf("result 为 nil")
|
||||
}
|
||||
msg := result.GetString("message")
|
||||
if msg == "" {
|
||||
return fmt.Errorf("message 字段为空")
|
||||
}
|
||||
return nil
|
||||
}).Get("批量缓存测试", 0, Map{
|
||||
"message": "sample",
|
||||
})
|
||||
}},
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
// DmdbCtr 达梦数据库特有测试控制器
|
||||
var DmdbCtr = Ctr{
|
||||
"tables": func(that *Context) {
|
||||
if that.Db.Type != "dm" && that.Db.Type != "dameng" {
|
||||
that.Display(0, Map{"skip": true, "reason": "当前非达梦数据库"})
|
||||
return
|
||||
}
|
||||
tables := that.Db.Query(`SELECT TABLE_NAME AS "name", COMMENTS AS "label" FROM USER_TAB_COMMENTS WHERE TABLE_TYPE='TABLE'`)
|
||||
that.Display(0, Map{"tables": tables})
|
||||
},
|
||||
|
||||
"describe": func(that *Context) {
|
||||
if that.Db.Type != "dm" && that.Db.Type != "dameng" {
|
||||
that.Display(0, Map{"skip": true, "reason": "当前非达梦数据库"})
|
||||
return
|
||||
}
|
||||
tableName := that.Req.FormValue("table")
|
||||
if tableName == "" {
|
||||
that.Display(1, "请提供 table 参数")
|
||||
return
|
||||
}
|
||||
columns := that.Db.Query(`SELECT c.COLUMN_NAME AS "name", c.DATA_TYPE AS "type", m.COMMENTS AS "label", c.NULLABLE AS "must" FROM USER_TAB_COLUMNS c LEFT JOIN USER_COL_COMMENTS m ON c.TABLE_NAME=m.TABLE_NAME AND c.COLUMN_NAME=m.COLUMN_NAME WHERE c.TABLE_NAME='` + tableName + `' ORDER BY c.COLUMN_ID`)
|
||||
data := that.Db.Select(tableName, "*", Map{"LIMIT": 10})
|
||||
that.Display(0, Map{"table": tableName, "columns": columns, "sample_data": data})
|
||||
},
|
||||
|
||||
"rawsql": func(that *Context) {
|
||||
if that.Db.Type != "dm" && that.Db.Type != "dameng" {
|
||||
that.Display(0, Map{"skip": true, "reason": "当前非达梦数据库"})
|
||||
return
|
||||
}
|
||||
result := testDmRawSQL(that)
|
||||
that.Display(0, result)
|
||||
},
|
||||
}
|
||||
|
||||
func testDmRawSQL(that *Context) Map {
|
||||
result := Map{"name": "达梦原生SQL测试", "tests": Slice{}}
|
||||
tests := Slice{}
|
||||
|
||||
test1 := Map{"name": "Query 原生查询 (双引号)"}
|
||||
articles1 := that.Db.Query(`SELECT "id", "title", "author" FROM "article" WHERE "state" = ? LIMIT ?`, 0, 5)
|
||||
test1["result"] = len(articles1) >= 0
|
||||
test1["count"] = len(articles1)
|
||||
tests = append(tests, test1)
|
||||
|
||||
test2 := Map{"name": "Exec 原生执行 (双引号)"}
|
||||
testArticle := that.Db.Get("article", "id", Map{"state": 0})
|
||||
if testArticle != nil {
|
||||
res, err := that.Db.Exec(`UPDATE "article" SET "modify_time" = NOW() WHERE "id" = ?`, testArticle.GetInt64("id"))
|
||||
if err == nil && res != nil {
|
||||
affected, _ := res.RowsAffected()
|
||||
test2["result"] = affected >= 0
|
||||
test2["affected"] = affected
|
||||
} else {
|
||||
test2["result"] = false
|
||||
test2["error"] = err
|
||||
}
|
||||
} else {
|
||||
test2["result"] = true
|
||||
test2["note"] = "无可用测试数据"
|
||||
}
|
||||
tests = append(tests, test2)
|
||||
|
||||
test3 := Map{"name": "USER_TABLES 查询"}
|
||||
tables := that.Db.Query(`SELECT TABLE_NAME FROM USER_TABLES`)
|
||||
test3["result"] = len(tables) >= 0
|
||||
test3["count"] = len(tables)
|
||||
tests = append(tests, test3)
|
||||
|
||||
test4 := Map{"name": "USER_TAB_COLUMNS 查询"}
|
||||
cols := that.Db.Query(`SELECT COLUMN_NAME, DATA_TYPE FROM USER_TAB_COLUMNS WHERE TABLE_NAME='admin' ORDER BY COLUMN_ID`)
|
||||
test4["result"] = len(cols) >= 0
|
||||
test4["count"] = len(cols)
|
||||
test4["columns"] = cols
|
||||
tests = append(tests, test4)
|
||||
|
||||
result["tests"] = tests
|
||||
result["success"] = true
|
||||
return result
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
var DmdbTest = CtrTest{
|
||||
"tables": {Desc: "查询达梦所有表", Func: func(a *Api) {
|
||||
if a.DB().Type != "dm" && a.DB().Type != "dameng" {
|
||||
return
|
||||
}
|
||||
a.Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result == nil {
|
||||
return fmt.Errorf("result 为 nil")
|
||||
}
|
||||
tables := result.GetSlice("tables")
|
||||
if tables == nil {
|
||||
return fmt.Errorf("tables 字段缺失")
|
||||
}
|
||||
if len(tables) == 0 {
|
||||
return fmt.Errorf("达梦表列表为空,至少应有测试表")
|
||||
}
|
||||
return nil
|
||||
}).Get("USER_TABLES 查询", 0, Map{
|
||||
"tables": Slice{Map{"name": "sample"}},
|
||||
})
|
||||
}},
|
||||
|
||||
"describe": {Desc: "查询达梦表结构", Func: func(a *Api) {
|
||||
if a.DB().Type != "dm" && a.DB().Type != "dameng" {
|
||||
return
|
||||
}
|
||||
|
||||
// ======== 错误用例 ========
|
||||
a.Get("不传table参数", 1, "请提供 table 参数")
|
||||
|
||||
// ======== 正确请求 + 结构校验 + Verify ========
|
||||
a.Query(Map{"table": "admin"}).
|
||||
Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result == nil {
|
||||
return fmt.Errorf("result 为 nil")
|
||||
}
|
||||
if result.GetString("table") != "admin" {
|
||||
return fmt.Errorf("table 期望 'admin', 实际 '%s'", result.GetString("table"))
|
||||
}
|
||||
columns := result.GetSlice("columns")
|
||||
if len(columns) == 0 {
|
||||
return fmt.Errorf("columns 为空,admin 表应有字段定义")
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Get("USER_TAB_COLUMNS 查询", 0, Map{
|
||||
"table": "sample", "columns": Slice{Map{"name": "sample", "type": "sample"}}, "sample_data": Slice{},
|
||||
})
|
||||
}},
|
||||
|
||||
"rawsql": {Desc: "达梦原生 SQL 测试", Func: func(a *Api) {
|
||||
if a.DB().Type != "dm" && a.DB().Type != "dameng" {
|
||||
return
|
||||
}
|
||||
a.Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result == nil {
|
||||
return fmt.Errorf("result 为 nil")
|
||||
}
|
||||
if !result.GetBool("success") {
|
||||
return fmt.Errorf("达梦原生 SQL 测试未通过")
|
||||
}
|
||||
tests := result.GetSlice("tests")
|
||||
if len(tests) == 0 {
|
||||
return fmt.Errorf("tests 数组为空")
|
||||
}
|
||||
for i := 0; i < len(tests); i++ {
|
||||
item := tests.GetMap(i)
|
||||
if item != nil && !item.GetBool("result") {
|
||||
return fmt.Errorf("子项 '%s' 失败", item.GetString("name"))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}).Get("达梦原生SQL", 0, Map{
|
||||
"name": "sample", "success": true,
|
||||
"tests": Slice{Map{"name": "sample", "result": true}},
|
||||
})
|
||||
}},
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
var ExpectDemoTest = CtrTest{
|
||||
|
||||
// ─── 基础类型结构校验 + Verify 值断言 ──────────────────
|
||||
|
||||
"string_result": {Desc: "返回字符串-结构+值校验", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetString("result")
|
||||
if result != "操作成功" {
|
||||
return fmt.Errorf("result 期望 '操作成功', 实际 '%s'", result)
|
||||
}
|
||||
return nil
|
||||
}).Get("result是字符串", 0, "样本")
|
||||
}},
|
||||
|
||||
"number_result": {Desc: "返回整数-结构+值校验", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetCeilInt64("result")
|
||||
if result != 42 {
|
||||
return fmt.Errorf("result 期望 42, 实际 %d", result)
|
||||
}
|
||||
return nil
|
||||
}).Get("result是整数", 0, int64(1))
|
||||
}},
|
||||
|
||||
"float_result": {Desc: "返回小数-结构+值校验", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetFloat64("result")
|
||||
if result != 3.14 {
|
||||
return fmt.Errorf("result 期望 3.14, 实际 %f", result)
|
||||
}
|
||||
return nil
|
||||
}).Get("result是小数", 0, float64(1.0))
|
||||
}},
|
||||
|
||||
"bool_result": {Desc: "返回布尔-结构+值校验", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetBool("result")
|
||||
if !result {
|
||||
return fmt.Errorf("result 期望 true, 实际 false")
|
||||
}
|
||||
return nil
|
||||
}).Get("result是布尔", 0, true)
|
||||
}},
|
||||
|
||||
"map_result": {Desc: "返回对象-结构+值校验", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result == nil {
|
||||
return fmt.Errorf("result 为 nil")
|
||||
}
|
||||
if result.GetCeilInt64("id") != 1 {
|
||||
return fmt.Errorf("id 期望 1, 实际 %d", result.GetCeilInt64("id"))
|
||||
}
|
||||
if result.GetString("name") != "示例商品" {
|
||||
return fmt.Errorf("name 期望 '示例商品', 实际 '%s'", result.GetString("name"))
|
||||
}
|
||||
if !result.GetBool("in_stock") {
|
||||
return fmt.Errorf("in_stock 期望 true")
|
||||
}
|
||||
return nil
|
||||
}).Get("result是Map-校验字段名+类型+值", 0, Map{
|
||||
"id": int64(1), "name": "sample", "price": float64(1.0), "in_stock": true,
|
||||
})
|
||||
}},
|
||||
|
||||
"nested_result": {Desc: "返回嵌套对象-结构+值校验", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result == nil {
|
||||
return fmt.Errorf("result 为 nil")
|
||||
}
|
||||
if result.GetCeilInt64("id") != 100 {
|
||||
return fmt.Errorf("id 期望 100, 实际 %d", result.GetCeilInt64("id"))
|
||||
}
|
||||
if result.GetString("title") != "测试订单" {
|
||||
return fmt.Errorf("title 期望 '测试订单', 实际 '%s'", result.GetString("title"))
|
||||
}
|
||||
customer := result.GetMap("customer")
|
||||
if customer == nil {
|
||||
return fmt.Errorf("customer 为 nil")
|
||||
}
|
||||
if customer.GetString("name") != "张三" {
|
||||
return fmt.Errorf("customer.name 期望 '张三', 实际 '%s'", customer.GetString("name"))
|
||||
}
|
||||
if customer.GetString("phone") != "13800138000" {
|
||||
return fmt.Errorf("customer.phone 期望 '13800138000', 实际 '%s'", customer.GetString("phone"))
|
||||
}
|
||||
items := result.GetSlice("items")
|
||||
if len(items) != 2 {
|
||||
return fmt.Errorf("items 期望 2 条, 实际 %d 条", len(items))
|
||||
}
|
||||
firstItem := items.GetMap(0)
|
||||
if firstItem.GetString("goods_name") != "苹果" {
|
||||
return fmt.Errorf("items[0].goods_name 期望 '苹果', 实际 '%s'", firstItem.GetString("goods_name"))
|
||||
}
|
||||
delivery := result.GetMap("extra").GetMap("delivery")
|
||||
if delivery == nil {
|
||||
return fmt.Errorf("extra.delivery 为 nil")
|
||||
}
|
||||
if delivery.GetString("address") != "XX路1号" {
|
||||
return fmt.Errorf("extra.delivery.address 期望 'XX路1号', 实际 '%s'", delivery.GetString("address"))
|
||||
}
|
||||
return nil
|
||||
}).Get("深层嵌套Map+Slice+Map", 0, Map{
|
||||
"id": int64(1), "title": "sample",
|
||||
"customer": Map{"id": int64(1), "name": "sample", "phone": "sample"},
|
||||
"items": Slice{Map{
|
||||
"id": int64(1), "goods_name": "sample", "quantity": int64(1), "price": float64(1.0),
|
||||
}},
|
||||
"extra": Map{
|
||||
"delivery": Map{"address": "sample", "fee": int64(1)},
|
||||
},
|
||||
})
|
||||
}},
|
||||
|
||||
"slice_result": {Desc: "返回数组-结构+值校验", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetSlice("result")
|
||||
if len(result) != 2 {
|
||||
return fmt.Errorf("result 期望 2 条, 实际 %d 条", len(result))
|
||||
}
|
||||
first := result.GetMap(0)
|
||||
if first.GetString("name") != "标签A" {
|
||||
return fmt.Errorf("result[0].name 期望 '标签A', 实际 '%s'", first.GetString("name"))
|
||||
}
|
||||
second := result.GetMap(1)
|
||||
if second.GetString("name") != "标签B" {
|
||||
return fmt.Errorf("result[1].name 期望 '标签B', 实际 '%s'", second.GetString("name"))
|
||||
}
|
||||
return nil
|
||||
}).Get("result直接是Slice", 0, Slice{Map{"id": int64(1), "name": "sample"}})
|
||||
}},
|
||||
|
||||
// ─── 错误先行 + 正确请求完整范式 ─────────────────────
|
||||
|
||||
"error_demo": {Desc: "错误先行+正确请求完整校验", Func: func(a *Api) {
|
||||
// ======== 第一步:错误用例(先行) ========
|
||||
a.Form(Map{"name": ""}).Post("名称为空-缺少必填字段", 3, "名称不能为空")
|
||||
|
||||
// AnyMsg:跳过错误 msg 断言(仅断言 status)。用于响应 msg 含网络错误等
|
||||
// 动态内容的用例——desc 与实际 msg 不一致时,无 AnyMsg 会因 desc 兜底匹配而失败
|
||||
a.Form(Map{"name": ""}).AnyMsg().Post("AnyMsg跳过msg断言-desc与实际msg不一致", 3)
|
||||
|
||||
// ======== 第二步:正确请求 + 结构校验 + Verify 值断言 ========
|
||||
a.Form(Map{"name": "测试商品"}).
|
||||
Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result == nil {
|
||||
return fmt.Errorf("result 为 nil")
|
||||
}
|
||||
if result.GetCeilInt64("id") != 1 {
|
||||
return fmt.Errorf("id 期望 1, 实际 %d", result.GetCeilInt64("id"))
|
||||
}
|
||||
if result.GetString("name") != "测试商品" {
|
||||
return fmt.Errorf("name 期望 '测试商品', 实际 '%s'", result.GetString("name"))
|
||||
}
|
||||
if !result.GetBool("created") {
|
||||
return fmt.Errorf("created 期望 true")
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Post("名称正确-结构+值校验", 0, Map{
|
||||
"id": int64(1), "name": "sample", "created": true,
|
||||
})
|
||||
}},
|
||||
|
||||
"no_expect": {Desc: "最小正确用例-仍需值断言", Func: func(a *Api) {
|
||||
a.Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result == nil {
|
||||
return fmt.Errorf("result 为 nil")
|
||||
}
|
||||
if result.GetCeilInt64("id") != 1 {
|
||||
return fmt.Errorf("id 期望 1, 实际 %d", result.GetCeilInt64("id"))
|
||||
}
|
||||
if result.GetString("data") != "无预期校验" {
|
||||
return fmt.Errorf("data 期望 '无预期校验', 实际 '%s'", result.GetString("data"))
|
||||
}
|
||||
return nil
|
||||
}).Get("有Verify值断言", 0, Map{"id": int64(1), "data": "sample"})
|
||||
}},
|
||||
|
||||
// ─── Verify 完整范式:错误 → 数据准备 → 正确请求 + 响应断言 + DB 校验 ─
|
||||
|
||||
"verify_demo": {Desc: "Verify完整范式演示", Func: func(a *Api) {
|
||||
// ======== 第一步:错误用例 ========
|
||||
a.Form(Map{"name": ""}).Post("名称为空", 3, "名称不能为空")
|
||||
|
||||
// ======== 第二步:准备测试数据 ========
|
||||
// 先插入一条已有记录,用于后续 DB 校验时确认"新增"行为
|
||||
a.DB().Insert("test_batch", Map{
|
||||
"name": "existing_record", "title": "verify_demo", "state": 1,
|
||||
})
|
||||
|
||||
// ======== 第三步:正确请求 + Verify 统一校验 ========
|
||||
name := "verify_test"
|
||||
a.Form(Map{"name": name}).
|
||||
Verify(func(a *Api) error {
|
||||
// 响应值断言
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result == nil {
|
||||
return fmt.Errorf("result 为 nil")
|
||||
}
|
||||
if result.GetCeilInt64("id") <= 0 {
|
||||
return fmt.Errorf("返回的 id 必须大于 0, 实际 %d", result.GetCeilInt64("id"))
|
||||
}
|
||||
if result.GetString("name") != name {
|
||||
return fmt.Errorf("name 期望 '%s', 实际 '%s'", name, result.GetString("name"))
|
||||
}
|
||||
|
||||
// 数据库状态校验
|
||||
row := a.DB().Get("test_batch", "*", Map{"AND": Map{"name": name, "title": "verify_demo"}})
|
||||
if row == nil {
|
||||
return fmt.Errorf("test_batch 表未找到 name=%s 的记录", name)
|
||||
}
|
||||
if row.GetCeilInt64("state") != 0 {
|
||||
return fmt.Errorf("test_batch.state 期望 0, 实际 %d", row.GetCeilInt64("state"))
|
||||
}
|
||||
|
||||
// 校验之前插入的记录仍然存在(验证不会误删)
|
||||
existing := a.DB().Get("test_batch", "*", Map{"AND": Map{"name": "existing_record", "title": "verify_demo"}})
|
||||
if existing == nil {
|
||||
return fmt.Errorf("之前插入的 existing_record 丢失")
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Post("写入并校验DB+响应", 0, Map{"id": int64(1), "name": "sample"})
|
||||
|
||||
// Verify 返回 error 会使该用例失败并写入 failReason(见覆盖率报告「未通过的接口」)
|
||||
}},
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
// DemoFlows 多接口业务流程联调示例(整条 Flow 共用一次测试事务,结束回滚)
|
||||
var DemoFlows = FlowTest{
|
||||
"expect_chain": {
|
||||
Group: "expect_demo",
|
||||
Desc: "expect_demo 多接口联调",
|
||||
Prep: "依次调用 string_result 与 map_result。\n步骤通过 FromCase 复用单接口验收用例的请求模板,跑通后回写该用例的响应与通过状态。",
|
||||
Func: func(f *Flow) {
|
||||
f.Step("字符串结果", "/app/expect_demo/string_result").
|
||||
FromCase("result是字符串").
|
||||
Note("校验字符串 result:期望返回「操作成功」。").
|
||||
Verify(func(a *Api) error {
|
||||
if a.Resp().GetBody().GetString("result") != "操作成功" {
|
||||
return fmt.Errorf("result 期望 操作成功")
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Get("步骤1-字符串", 0, "样本")
|
||||
|
||||
f.Step("Map结果", "/app/expect_demo/map_result").
|
||||
FromCase("result是Map-校验字段名+类型+值").
|
||||
Note("校验对象 result:至少包含 id=1,用于串联后续步骤前的结构确认。").
|
||||
Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result == nil {
|
||||
return fmt.Errorf("result 为 nil")
|
||||
}
|
||||
if result.GetCeilInt64("id") != 1 {
|
||||
return fmt.Errorf("id 期望 1")
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Get("步骤2-Map", 0, Map{"id": int64(1), "name": "sample", "price": float64(1.0), "in_stock": true})
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
)
|
||||
|
||||
var Project = Proj{
|
||||
"test": TestCtr,
|
||||
"mysql": MysqlCtr,
|
||||
"dmdb": DmdbCtr,
|
||||
"cache": CacheCtr,
|
||||
"expect_demo": ExpectDemoCtr,
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
package app
|
||||
|
||||
// 通用 CRUD 树表查询(MakeCode.Search 的 parent_id / showself / showall 语义)行为测试。
|
||||
// 回归背景:parent_id=X 且 showself=1 时旧逻辑拼出 OR(parent_id=X, id=X),
|
||||
// 把节点 X 自己当作 X 的子级返回,前端树无限嵌套(无限套娃)。
|
||||
// 依赖 department 树表(表名避开 admin.json 遗留 flow 对 org 的 admin_id 注入;
|
||||
// 见 setup_mysql.go;表需在 Init 前已存在于测试库)。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
// resultIds 提取 result.data 的 id 集合
|
||||
func resultIds(a *Api) map[int64]bool {
|
||||
ids := map[int64]bool{}
|
||||
data := a.Resp().GetBody().GetMap("result").GetSlice("data")
|
||||
for k := range data {
|
||||
ids[data.GetMap(k).GetCeilInt64("id")] = true
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
var AdminDepartmentTest = ProjTest{
|
||||
"department": CtrTest{
|
||||
"search": {Desc: "树表通用查询:parent_id 子级懒加载不得返回自身", Func: func(a *Api) {
|
||||
// ======== 错误用例 ========
|
||||
a.Query(Map{"parent_id": "1", "showself": "1"}).
|
||||
Get("未登录访问", 2, "你还没有登录")
|
||||
|
||||
// ======== 准备树数据:root → nodeX → child ========
|
||||
adminId := a.DB().Insert("admin", Map{
|
||||
"name": "树测管理员", "phone": "139" + ObjToStr(RandX(10000000, 99999999)),
|
||||
"state": 1, "password": Md5("tree123"), "role_id": 1,
|
||||
"create_time[#]": "NOW()", "modify_time[#]": "NOW()",
|
||||
})
|
||||
session := Map{"admin_id": adminId}
|
||||
|
||||
rootId := a.DB().Insert("department", Map{
|
||||
"name": "树测根部门", "state": 0,
|
||||
"create_time[#]": "NOW()", "modify_time[#]": "NOW()",
|
||||
})
|
||||
a.DB().Update("department", Map{"parent_ids": "," + ObjToStr(rootId) + ","}, Map{"id": rootId})
|
||||
|
||||
nodeX := a.DB().Insert("department", Map{
|
||||
"name": "树测节点X", "parent_id": rootId, "state": 0,
|
||||
"create_time[#]": "NOW()", "modify_time[#]": "NOW()",
|
||||
})
|
||||
a.DB().Update("department", Map{"parent_ids": "," + ObjToStr(rootId) + "," + ObjToStr(nodeX) + ","}, Map{"id": nodeX})
|
||||
|
||||
childId := a.DB().Insert("department", Map{
|
||||
"name": "树测子节点", "parent_id": nodeX, "state": 0,
|
||||
"create_time[#]": "NOW()", "modify_time[#]": "NOW()",
|
||||
})
|
||||
a.DB().Update("department", Map{"parent_ids": "," + ObjToStr(rootId) + "," + ObjToStr(nodeX) + "," + ObjToStr(childId) + ","}, Map{"id": childId})
|
||||
|
||||
rowSample := Map{"count": int64(1), "data": Slice{Map{"id": int64(1), "name": "sample"}}}
|
||||
|
||||
// ======== 核心回归:子级懒加载带 showself 不得包含节点自身 ========
|
||||
a.WithSession(session).
|
||||
Note("旧缺陷:OR(parent_id=X, id=X) 把 X 自己当作 X 的子级返回,树无限嵌套").
|
||||
Query(Map{"parent_id": nodeX, "showself": "1", "pageSize": "50"}).
|
||||
Verify(func(a *Api) error {
|
||||
ids := resultIds(a)
|
||||
if ids[nodeX] {
|
||||
return fmt.Errorf("子级查询结果不应包含节点自身 id=%d", nodeX)
|
||||
}
|
||||
if !ids[childId] {
|
||||
return fmt.Errorf("子级查询结果应包含直接子级 id=%d", childId)
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Get("子级懒加载showself不返回自身", 0, rowSample)
|
||||
|
||||
// ======== 不传 showself:同样只返回直接子级 ========
|
||||
a.WithSession(session).
|
||||
Query(Map{"parent_id": nodeX, "pageSize": "50"}).
|
||||
Verify(func(a *Api) error {
|
||||
ids := resultIds(a)
|
||||
if ids[nodeX] || !ids[childId] {
|
||||
return fmt.Errorf("普通子级查询应只含直接子级,实际: %v", ids)
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Get("普通子级查询只返回子级", 0, rowSample)
|
||||
|
||||
// ======== showall+showself(管理端列表按树筛选场景):含自身与全部子孙 ========
|
||||
a.WithSession(session).
|
||||
Note("Table.vue 树筛选场景:showall=1 展示选中节点及全部子孙,showself=1 保留自身").
|
||||
Query(Map{"parent_id": nodeX, "showself": "1", "showall": "1", "pageSize": "50"}).
|
||||
Verify(func(a *Api) error {
|
||||
ids := resultIds(a)
|
||||
if !ids[nodeX] {
|
||||
return fmt.Errorf("showall+showself 应包含节点自身 id=%d", nodeX)
|
||||
}
|
||||
if !ids[childId] {
|
||||
return fmt.Errorf("showall+showself 应包含子孙 id=%d", childId)
|
||||
}
|
||||
if ids[rootId] {
|
||||
return fmt.Errorf("showall+showself 不应包含父级 id=%d", rootId)
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Get("showall加showself含自身与子孙", 0, rowSample)
|
||||
|
||||
// ======== 修复回归:文本字段部分关键词模糊匹配 + 非文本字段等值筛选不受影响 ========
|
||||
// 背景 bug(code/makecode.go Search):文本类字段(如 name)曾同时生成等值条件
|
||||
// name=值 与模糊条件 name[~]=值,二者在 where 中以 AND 叠加;前端筛选框传入
|
||||
// 部分关键词时等值条件必不命中,AND 之后查询恒为空。
|
||||
// 修复后:文本字段只走模糊匹配;非文本字段(如 state,规则覆盖为 select 类型)维持等值匹配现状。
|
||||
textId := a.DB().Insert("department", Map{
|
||||
"name": "综合行政部", "state": 5, "parent_id": nodeX,
|
||||
"create_time[#]": "NOW()", "modify_time[#]": "NOW()",
|
||||
})
|
||||
a.DB().Update("department", Map{"parent_ids": "," + ObjToStr(rootId) + "," + ObjToStr(nodeX) + "," + ObjToStr(textId) + ","}, Map{"id": textId})
|
||||
|
||||
a.WithSession(session).
|
||||
Note("修复前:name 等值(综合行政部≠行政)与模糊[~]同时AND叠加,传部分关键词恒查不到;修复后应命中模糊匹配").
|
||||
Query(Map{"parent_id": rootId, "showself": "1", "showall": "1", "pageSize": "50", "name": "行政"}).
|
||||
Verify(func(a *Api) error {
|
||||
ids := resultIds(a)
|
||||
if !ids[textId] {
|
||||
return fmt.Errorf("文本字段部分关键词模糊查询应命中 id=%d, 实际: %v", textId, ids)
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Get("文本字段部分关键词命中模糊匹配", 0, rowSample)
|
||||
|
||||
a.WithSession(session).
|
||||
Note("state 规则覆盖为 select 类型(非text),应维持等值匹配现状,只精确命中 state=5 的记录").
|
||||
Query(Map{"parent_id": rootId, "showself": "1", "showall": "1", "pageSize": "50", "state": "5"}).
|
||||
Verify(func(a *Api) error {
|
||||
ids := resultIds(a)
|
||||
if !ids[textId] {
|
||||
return fmt.Errorf("非文本字段等值筛选应命中 id=%d, 实际: %v", textId, ids)
|
||||
}
|
||||
for id := range ids {
|
||||
if id != textId {
|
||||
return fmt.Errorf("非文本字段等值筛选不应包含其它 state 的记录,实际额外命中: id=%d", id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Get("非文本字段等值筛选行为不变", 0, rowSample)
|
||||
}},
|
||||
},
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user