Compare commits

...

4 Commits

Author SHA1 Message Date
hoteas 412f08a048 Merge branch 'master' of https://code.hoteas.com/golang/hotime 2026-07-22 04:41:30 +08:00
hoteas ec9d25fef6 feat(testing): 新增 AnyMsg 方法以跳过错误消息断言
- 在 Api 和 ApiCase 中新增 AnyMsg 方法,允许在状态不为 0 时跳过错误消息的断言,适用于动态内容的用例。
- 更新文档说明,介绍 AnyMsg 的用法及其链式调用示例。
- 在示例测试中添加 AnyMsg 的使用案例,展示其在处理动态错误消息时的便利性。
2026-07-22 04:38:38 +08:00
hoteas a600fa8a15 fix(mysql): SetMysqlDB 默认 charset 改为 utf8mb4
与表字符集对齐,兼容 MySQL 8.0/8.4,避免连接层 utf8mb3。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 04:16:22 +08:00
hoteas f53d406110 refactor(application): 更新 Cache-Control 逻辑与文档说明
- 简化 application.go 中的 Cache-Control 设置,固定为 public,移除 logLevel 对其的控制。
- 更新 var.go 中 logLevel 的说明,明确其不再影响静态资源的 Cache-Control。
- 修改 QuickStart 文档,反映 logLevel 的新行为与静态资源缓存策略。
2026-07-15 23:02:12 +08:00
8 changed files with 83 additions and 12 deletions
+4 -8
View File
@@ -534,13 +534,9 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) {
return
}
//设置header
// 静态资源可缓存;logLevel 只控制日志/SQL,不再改 Cache-Control
delete(header, "Content-Type")
if that.Config.GetCeilInt("logLevel") == 0 {
header.Set("Cache-Control", "public")
} else {
header.Set("Cache-Control", "no-cache")
}
header.Set("Cache-Control", "public")
t := strings.LastIndex(path, ".")
if t != -1 {
@@ -785,7 +781,7 @@ func SetMysqlDB(appIns *Application, config Map) {
appIns.Db.Log = appIns.Log
appIns.SetConnectDB(func() (master, slave *sql.DB) {
query := config.GetString("user") + ":" + config.GetString("password") +
"@tcp(" + config.GetString("host") + ":" + config.GetString("port") + ")/" + config.GetString("name") + "?charset=utf8"
"@tcp(" + config.GetString("host") + ":" + config.GetString("port") + ")/" + config.GetString("name") + "?charset=utf8mb4"
DB, e := sql.Open("mysql", query)
if e != nil {
appIns.Log.Error().Err(e).Msg("MySQL 主库连接失败")
@@ -794,7 +790,7 @@ func SetMysqlDB(appIns *Application, config Map) {
configSlave := config.GetMap("slave")
if configSlave != nil {
query := configSlave.GetString("user") + ":" + configSlave.GetString("password") +
"@tcp(" + config.GetString("host") + ":" + configSlave.GetString("port") + ")/" + configSlave.GetString("name") + "?charset=utf8"
"@tcp(" + config.GetString("host") + ":" + configSlave.GetString("port") + ")/" + configSlave.GetString("name") + "?charset=utf8mb4"
DB1, e := sql.Open("mysql", query)
if e != nil {
appIns.Log.Error().Err(e).Msg("MySQL 从库连接失败")
+3 -1
View File
@@ -33,6 +33,8 @@ HoTimeDB是一个基于Golang实现的轻量级ORM框架,参考PHP Medoo设计
### 初始化数据库连接
`Application.SetMysqlDB` 默认 DSN 带 `charset=utf8mb4`(兼容 MySQL 8.0 / 8.4,与表字符集对齐)。
```go
import (
"code.hoteas.com/golang/hotime/db"
@@ -43,7 +45,7 @@ import (
// 创建连接函数
func createConnection() (master, slave *sql.DB) {
master, _ = sql.Open("mysql", "user:password@tcp(localhost:3306)/database")
master, _ = sql.Open("mysql", "user:password@tcp(localhost:3306)/database?charset=utf8mb4")
// slave是可选的,用于读写分离
slave = master // 或者连接到从数据库
return
+1 -1
View File
@@ -80,7 +80,7 @@ func main() {
| `modeRouterStrict` | false | 路由大小写敏感,false=忽略大小写 |
| `crossDomain` | - | 跨域设置,空=不开启,auto=智能开启,或指定域名 |
| `logFile` | - | 日志文件路径,如 `logs/20060102.txt` |
| `logLevel` | 0 | 日志等级0=关闭,1=打印 |
| `logLevel` | 1 | 日志等级0=仅错误,>=1=全部;同时控制 SQL 日志。**不**控制静态 `Cache-Control`(静态固定 `public` |
| `webConnectLogShow` | true | 是否显示访问日志 |
| `defFile` | ["index.html"] | 目录默认访问文件 |
+2
View File
@@ -487,6 +487,8 @@ a.WithSession(Map{"admin_id": int64(1)}).
**省略错误消息(第三参数)**`a.Post("请先登录", 2)` — 当 `status != 0` 且未传第三参数时,框架自动用 `desc`(第一参数)作为期望的错误消息。适合 desc 与实际 msg 完全一致的场景。
**跳过错误消息断言(AnyMsg**`a.AnyMsg().Post("无效授权码", 1)` — 当 `status != 0` 但错误 msg 含动态内容(网络拨号错误、第三方 API 回传文案等)无法全等断言时,用 `AnyMsg()` 仅断言 status、跳过 msg 校验(同时也不再用 desc 兜底匹配)。`Api``ApiCase` 均可链式调用,如 `a.JSON(...).AnyMsg().Post(...)`。样例见 `example/app/expect_demo_test.go``error_demo`
**省略 result 结构校验(expect**`a.Post("创建成功", 0)` — 仅断言 `status=0`,不校验 result 的内容和结构。适合无需关注返回结构的场景,也适用于非 JSON 响应(二进制文件、纯文本等),此时在 Verify 中通过 `GetRawBody()` 做内容校验。
**省略 Verify**:不设置 Verify 回调时,不会执行响应值断言和数据库状态校验。适合纯查询、无副作用的接口。
+4
View File
@@ -146,6 +146,10 @@ var ExpectDemoTest = CtrTest{
// ======== 第一步:错误用例(先行) ========
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 {
+53
View File
@@ -0,0 +1,53 @@
package hotime
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
. "code.hoteas.com/golang/hotime/cache"
. "code.hoteas.com/golang/hotime/common"
"code.hoteas.com/golang/hotime/log"
)
// TestStaticFile_CacheControlPublic_WithLogLevel1
// 静态文件在 logLevel>=1 时仍应 Cache-Control: public(与日志级别解耦)。
func TestStaticFile_CacheControlPublic_WithLogLevel1(t *testing.T) {
dir := t.TempDir()
body := []byte("<!doctype html><title>static-cache</title>")
if err := os.WriteFile(filepath.Join(dir, "hello.html"), body, 0o644); err != nil {
t.Fatalf("写静态文件失败: %v", err)
}
app := &Application{
Config: Map{
"logLevel": 1,
"tpt": dir,
"sessionName": "HOTIME",
"defFile": Slice{"index.html"},
"crossDomain": "",
},
Log: log.NewLogger(1, "", 0),
MethodRouter: MethodRouter{},
HoTimeCache: &HoTimeCache{},
}
req := httptest.NewRequest(http.MethodGet, "/hello.html", nil)
req.RequestURI = "/hello.html"
w := httptest.NewRecorder()
app.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("期望 200,实际 %d loc=%q cc=%q body=%q", w.Code, w.Header().Get("Location"), w.Header().Get("Cache-Control"), w.Body.String())
}
cc := w.Header().Get("Cache-Control")
if !strings.EqualFold(cc, "public") {
t.Fatalf("期望 Cache-Control=public,实际 %q", cc)
}
if !strings.Contains(w.Body.String(), "static-cache") {
t.Fatalf("响应体未包含静态内容: %q", w.Body.String())
}
}
+15 -1
View File
@@ -189,6 +189,13 @@ func (a *Api) Note(note string) *ApiCase {
return c
}
// AnyMsg 跳过错误 msg 断言(仅断言 status),用于响应 msg 含网络错误等动态内容的用例
func (a *Api) AnyMsg() *ApiCase {
c := a.newCase()
c.skipMsg = true
return c
}
// DB 获取数据库实例(在测试事务内)
func (a *Api) DB() *HoTimeDB {
return &a.app.Db
@@ -221,6 +228,7 @@ type ApiCase struct {
note string
verifyFn func(a *Api) error
bindCase string // 绑定的单接口验收用例名(FromCase)
skipMsg bool // AnyMsg:跳过错误 msg 断言(用于网络错误等动态消息)
}
// FromCase 在已有 ApiCase 上绑定验收用例名(若尚未加载模板则加载)
@@ -300,6 +308,12 @@ func (c *ApiCase) Note(note string) *ApiCase {
return c
}
// AnyMsg 跳过错误 msg 断言(仅断言 status),可继续链式
func (c *ApiCase) AnyMsg() *ApiCase {
c.skipMsg = true
return c
}
// Verify 设置请求后的自定义校验函数(如查库验证数据状态)
// 函数返回 nil 表示校验通过,返回 error 则用例失败并记录错误原因
func (c *ApiCase) Verify(fn func(a *Api) error) *ApiCase {
@@ -343,7 +357,7 @@ func (c *ApiCase) execute(method, desc string, expectStatus int, expectArgs ...i
hasExpectResult = true
}
}
if expectStatus != 0 && expectMsg == "" {
if expectStatus != 0 && expectMsg == "" && !c.skipMsg {
expectMsg = desc
}
+1 -1
View File
@@ -56,7 +56,7 @@ var Config = Map{
}
var ConfigNote = Map{
"logLevel": "默认1,必须,0=仅打印错误日志,>=1=打印全部日志(debug/info/warn/error),同时控制SQL日志Cache-Control0=public>=1=no-cache",
"logLevel": "默认1,必须,0=仅打印错误日志,>=1=打印全部日志(debug/info/warn/error),同时控制 SQL 日志;不控制静态 Cache-Control静态固定 public",
"logFile": "无默认,非必须,如果需要存储日志文件时使用,保存格式为:a/b/c/20060102150405.txt,将生成:a/b/c/年月日时分秒.txt,按需设置",
"logHistory": "默认100,非必须,内存中保留最近N条错误日志,用于调试调阅,通过 Log.GetRecentErrors() 获取",
"webConnectLogShow": "默认true,非必须,访问日志如果需要web访问链接、访问ip、访问时间打印,false为关闭true开启此功能",