Compare commits

..

3 Commits

Author SHA1 Message Date
hoteas 853940c795 fix(makecode): Search 文本字段只走模糊匹配,避免等值叠加导致空结果
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 06:15:07 +08:00
hoteas b5b764e3f9 docs: 优雅停机示例透传 EO-Connecting-IP
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 06:05:00 +08:00
hoteas 25faa93724 feat(log): 多源 clientIP 降级与去重 ip_chain
按 EO→XFF非回环→X-Real→RemoteAddr 选取 ip;访问日志附带去重 ip_chain;登录限流改用 clientIP。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 06:04:43 +08:00
9 changed files with 248 additions and 21 deletions
+87 -13
View File
@@ -108,20 +108,24 @@ func (that *Application) initiateGracefulShutdown(drain, shutdown time.Duration)
})
}
// clientIP 取客户端真实 IPX-Real-IP → X-Forwarded-For 第一段 → RemoteAddr
func clientIP(req *http.Request) string {
if req == nil {
// isLoopbackIP 判断是否为回环地址(空串视为不可用)
func isLoopbackIP(ip string) bool {
ip = strings.TrimSpace(ip)
if ip == "" {
return true
}
parsed := net.ParseIP(ip)
if parsed == nil {
return false
}
return parsed.IsLoopback()
}
// remoteHost 从 RemoteAddr 取出 host(去掉端口)
func remoteHost(req *http.Request) string {
if req == nil || req.RemoteAddr == "" {
return ""
}
if ip := strings.TrimSpace(req.Header.Get("X-Real-IP")); ip != "" {
return ip
}
if xff := req.Header.Get("X-Forwarded-For"); xff != "" {
if i := strings.Index(xff, ","); i >= 0 {
return strings.TrimSpace(xff[:i])
}
return strings.TrimSpace(xff)
}
host, _, err := net.SplitHostPort(req.RemoteAddr)
if err == nil && host != "" {
return host
@@ -129,6 +133,72 @@ func clientIP(req *http.Request) string {
return req.RemoteAddr
}
// resolveClientIP 多源识别客户端 IP。
// bestEO-Connecting-IP → XFF 第一个非回环 → 非回环 X-Real-IP → RemoteAddr
// XFF 先于 X-Real:反代误把 CDN 节点写进 X-Real-IP 时仍能落到客户端)
// chain:各源头 IP 按首次出现顺序逗号拼接,同一 IP 只保留一次
func resolveClientIP(req *http.Request) (best string, chain string) {
if req == nil {
return "", ""
}
eo := strings.TrimSpace(req.Header.Get("EO-Connecting-IP"))
xri := strings.TrimSpace(req.Header.Get("X-Real-IP"))
xffRaw := req.Header.Get("X-Forwarded-For")
ra := remoteHost(req)
var xffParts []string
if xffRaw != "" {
for _, p := range strings.Split(xffRaw, ",") {
if p = strings.TrimSpace(p); p != "" {
xffParts = append(xffParts, p)
}
}
}
seen := make(map[string]struct{}, 4)
var ordered []string
add := func(ip string) {
ip = strings.TrimSpace(ip)
if ip == "" {
return
}
if _, ok := seen[ip]; ok {
return
}
seen[ip] = struct{}{}
ordered = append(ordered, ip)
}
add(eo)
add(xri)
for _, p := range xffParts {
add(p)
}
add(ra)
chain = strings.Join(ordered, ",")
if eo != "" && !isLoopbackIP(eo) {
return eo, chain
}
for _, p := range xffParts {
if !isLoopbackIP(p) {
return p, chain
}
}
if xri != "" && !isLoopbackIP(xri) {
return xri, chain
}
if ra != "" {
return ra, chain
}
return "", chain
}
// clientIP 取客户端真实 IP(见 resolveClientIP
func clientIP(req *http.Request) string {
ip, _ := resolveClientIP(req)
return ip
}
// shortID 生成 n 字节随机 hexn=6 → 12 位)
func shortID(n int) string {
b := make([]byte, n)
@@ -504,13 +574,17 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) {
defer func() {
//是否展示日志
if that.WebConnectLog != nil {
ip, ipChain := resolveClientIP(req)
evt := that.WebConnectLog.Info().
Str("ip", clientIP(req)).
Str("ip", ip).
Str("method", context.Req.Method).
Str("sid", sid).
Str("request_id", requestId).
Float64("cost_ms", ObjToFloat64(time.Now().UnixNano()-nowUnixTime.UnixNano())/1000000.00).
Float64("size_kb", ObjToFloat64(context.DataSize)/1000.00)
if ipChain != "" && ipChain != ip {
evt = evt.Str("ip_chain", ipChain)
}
if country := req.Header.Get("EO-Client-IPCountry"); country != "" {
evt = evt.Str("ip_country", country)
}
+91
View File
@@ -0,0 +1,91 @@
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=CDNXFF=客户端,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)
}
}
+1 -1
View File
@@ -963,7 +963,7 @@ var TptProject = Proj{
},
"login": func(that *Context) {
// 限速:同一 IP 1分钟内最多尝试10次
ip := that.Req.Header.Get("X-Forwarded-For")
ip := clientIP(that.Req)
if ip == "" {
ip = that.Req.RemoteAddr
}
+5 -1
View File
@@ -980,7 +980,11 @@ func (that *MakeCode) Search(table string, userData Map, data Map, req *http.Req
// }
//
reqValue := req.FormValue(v.GetString("name"))
if v.GetString("name") != "parent_id" && reqValue != "" {
// text 类字段只走下方模糊匹配([~]),此处不再生成等值条件,
// 否则等值(必不命中部分关键词)与模糊匹配在 where 中以 AND 叠加,
// 查询恒为空(见 docs/CodeGen_代码生成.md 通用 Search 筛选说明)。
if v.GetString("name") != "parent_id" && reqValue != "" &&
!strings.Contains(v.GetString("type"), "text") {
data[v.GetString("name")] = reqValue
}
//
+11
View File
@@ -765,6 +765,17 @@ CREATE TABLE user (
**规则**`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)
+3 -1
View File
@@ -67,7 +67,9 @@ server {
# 其他常规配置...
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# 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;
}
}
+9 -4
View File
@@ -67,11 +67,14 @@ context.LogBind("admin_id", context.Session("admin_id").ToCeilInt64())
## 客户端 IP 与地域
零配置,优先级
零配置`ip` 选取优先级(跳过空值与回环 `127.0.0.1` / `::1`
1. `X-Real-IP`EdgeOne / 反代透传的真实 IP
2. `X-Forwarded-For` **第一段**
3. `RemoteAddr`
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`(两位国家码);没有则不记。
@@ -112,6 +115,8 @@ multiWriterhotimev1.5/log/logger.go
| `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` 时 |
| 其余自定义字段 | 原字段名 | `LogBind` 追加的字段原样保留 |
| — | `instance` | `ip:port` |
+40
View File
@@ -104,6 +104,46 @@ var AdminDepartmentTest = ProjTest{
return nil
}).
Get("showall加showself含自身与子孙", 0, rowSample)
// ======== 修复回归:文本字段部分关键词模糊匹配 + 非文本字段等值筛选不受影响 ========
// 背景 bugcode/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)
}},
},
}
+1 -1
View File
@@ -61,7 +61,7 @@ var ConfigNote = Map{
"logHistory": "默认100,非必须,内存中保留最近N条错误日志,用于调试调阅,通过 Log.GetRecentErrors() 获取",
"webConnectLogShow": "默认true,非必须,访问日志如果需要web访问链接、访问ip、访问时间打印,false为关闭true开启此功能",
"webConnectLogFile": "无默认,非必须,webConnectLogShow开启之后才能使用,如果需要存储日志文件时使用,保存格式为:a/b/c/20060102150405.txt,将生成:a/b/c/年月日时分秒.txt,按需设置",
"seqUrl": "无默认,非必须,Seq 日志平台地址,如 http://127.0.0.1:5341,填写后自动将所有日志通过异步 channel 队列推送到 Seq,空值则不激活;同时自动将 fmt.Println/标准log 包的输出也一并捕获推送;instance 字段自动拼接为 ip:port;请求日志自动带 sidsessionId 前12位脱敏)与 request_id(并回写 X-Request-Id),可用 that.LogBind 追加 user_id 等;访问日志优先取 X-Real-IP,有 EO-Client-IPCountry 时记 ip_country;配置后控制台自动仅打 Warn/ErrorSeq 与文件仍按 logLevel 全量",
"seqUrl": "无默认,非必须,Seq 日志平台地址,如 http://127.0.0.1:5341,填写后自动将所有日志通过异步 channel 队列推送到 Seq,空值则不激活;同时自动将 fmt.Println/标准log 包的输出也一并捕获推送;instance 字段自动拼接为 ip:port;请求日志自动带 sidsessionId 前12位脱敏)与 request_id(并回写 X-Request-Id),可用 that.LogBind 追加 user_id 等;访问日志 ip 按 EO-Connecting-IP→XFF非回环→X-Real-IP→RemoteAddr 选取,多源去重记 ip_chain,有 EO-Client-IPCountry 时记 ip_country;配置后控制台自动仅打 Warn/ErrorSeq 与文件仍按 logLevel 全量",
"seqApiKey": "无默认,非必须,Seq API Key,单机免费版留空即可,多用户或有认证要求时填写",
//"codeConfig": Map{
// "注释": "配置即启用,非必须,默认无",