Compare commits

..

No commits in common. "main" and "v1.5.15" have entirely different histories.

17 changed files with 960 additions and 819 deletions

883
.log.meta.json Normal file
View File

@ -0,0 +1,883 @@
{
"debug": [
{
"Index": 0,
"Name": "LogName",
"KeyName": "",
"AttachBefore": false,
"Color": "cyan",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": true
},
{
"Index": 1,
"Name": "LogType",
"KeyName": "",
"AttachBefore": false,
"Color": "magenta",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": true
},
{
"Index": 2,
"Name": "LogTime",
"KeyName": "",
"AttachBefore": false,
"Color": "",
"Format": "time",
"Precision": 0,
"WithoutKey": false,
"Hide": false
},
{
"Index": 3,
"Name": "TraceId",
"KeyName": "",
"AttachBefore": false,
"Color": "gray",
"Format": "",
"Precision": 0,
"WithoutKey": true,
"Hide": false
},
{
"Index": 4,
"Name": "Image",
"KeyName": "",
"AttachBefore": false,
"Color": "",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": true
},
{
"Index": 5,
"Name": "Server",
"KeyName": "",
"AttachBefore": false,
"Color": "",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": true
},
{
"Index": 6,
"Name": "Debug",
"KeyName": "",
"AttachBefore": false,
"Color": "",
"Format": "",
"Precision": 0,
"WithoutKey": true,
"Hide": false
},
{
"Index": 7,
"Name": "Extra",
"KeyName": "",
"AttachBefore": false,
"Color": "",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": false
}
],
"discover": [
{
"Index": 0,
"Name": "LogName",
"KeyName": "",
"AttachBefore": false,
"Color": "cyan",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": true
},
{
"Index": 1,
"Name": "LogType",
"KeyName": "",
"AttachBefore": false,
"Color": "magenta",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": true
},
{
"Index": 2,
"Name": "LogTime",
"KeyName": "",
"AttachBefore": false,
"Color": "",
"Format": "time",
"Precision": 0,
"WithoutKey": false,
"Hide": false
},
{
"Index": 3,
"Name": "TraceId",
"KeyName": "",
"AttachBefore": false,
"Color": "gray",
"Format": "",
"Precision": 0,
"WithoutKey": true,
"Hide": false
},
{
"Index": 4,
"Name": "Image",
"KeyName": "",
"AttachBefore": false,
"Color": "",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": true
},
{
"Index": 5,
"Name": "Server",
"KeyName": "",
"AttachBefore": false,
"Color": "",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": true
},
{
"Index": 6,
"Name": "App",
"KeyName": "",
"AttachBefore": false,
"Color": "cyan",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": false
},
{
"Index": 7,
"Name": "Method",
"KeyName": "",
"AttachBefore": false,
"Color": "magenta",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": false
},
{
"Index": 8,
"Name": "Path",
"KeyName": "",
"AttachBefore": false,
"Color": "blue",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": false
},
{
"Index": 9,
"Name": "Node",
"KeyName": "",
"AttachBefore": false,
"Color": "yellow",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": false
},
{
"Index": 10,
"Name": "Attempts",
"KeyName": "",
"AttachBefore": false,
"Color": "",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": false
},
{
"Index": 11,
"Name": "UsedTime",
"KeyName": "",
"AttachBefore": false,
"Color": "",
"Format": "%.2fms",
"Precision": 0,
"WithoutKey": false,
"Hide": false
},
{
"Index": 12,
"Name": "Error",
"KeyName": "",
"AttachBefore": false,
"Color": "red",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": false
},
{
"Index": 13,
"Name": "Extra",
"KeyName": "",
"AttachBefore": false,
"Color": "",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": false
}
],
"error": [
{
"Index": 0,
"Name": "LogName",
"KeyName": "",
"AttachBefore": false,
"Color": "cyan",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": true
},
{
"Index": 1,
"Name": "LogType",
"KeyName": "",
"AttachBefore": false,
"Color": "magenta",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": true
},
{
"Index": 2,
"Name": "LogTime",
"KeyName": "",
"AttachBefore": false,
"Color": "",
"Format": "time",
"Precision": 0,
"WithoutKey": false,
"Hide": false
},
{
"Index": 3,
"Name": "TraceId",
"KeyName": "",
"AttachBefore": false,
"Color": "gray",
"Format": "",
"Precision": 0,
"WithoutKey": true,
"Hide": false
},
{
"Index": 4,
"Name": "Image",
"KeyName": "",
"AttachBefore": false,
"Color": "",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": true
},
{
"Index": 5,
"Name": "Server",
"KeyName": "",
"AttachBefore": false,
"Color": "",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": true
},
{
"Index": 6,
"Name": "Error",
"KeyName": "",
"AttachBefore": false,
"Color": "red",
"Format": "",
"Precision": 0,
"WithoutKey": true,
"Hide": false
},
{
"Index": 7,
"Name": "Extra",
"KeyName": "",
"AttachBefore": false,
"Color": "",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": false
},
{
"Index": 8,
"Name": "CallStacks",
"KeyName": "",
"AttachBefore": false,
"Color": "",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": false
}
],
"info": [
{
"Index": 0,
"Name": "LogName",
"KeyName": "",
"AttachBefore": false,
"Color": "cyan",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": true
},
{
"Index": 1,
"Name": "LogType",
"KeyName": "",
"AttachBefore": false,
"Color": "magenta",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": true
},
{
"Index": 2,
"Name": "LogTime",
"KeyName": "",
"AttachBefore": false,
"Color": "",
"Format": "time",
"Precision": 0,
"WithoutKey": false,
"Hide": false
},
{
"Index": 3,
"Name": "TraceId",
"KeyName": "",
"AttachBefore": false,
"Color": "gray",
"Format": "",
"Precision": 0,
"WithoutKey": true,
"Hide": false
},
{
"Index": 4,
"Name": "Image",
"KeyName": "",
"AttachBefore": false,
"Color": "",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": true
},
{
"Index": 5,
"Name": "Server",
"KeyName": "",
"AttachBefore": false,
"Color": "",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": true
},
{
"Index": 6,
"Name": "Info",
"KeyName": "",
"AttachBefore": false,
"Color": "cyan",
"Format": "",
"Precision": 0,
"WithoutKey": true,
"Hide": false
},
{
"Index": 7,
"Name": "Extra",
"KeyName": "",
"AttachBefore": false,
"Color": "",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": false
}
],
"request": [
{
"Index": 0,
"Name": "LogName",
"KeyName": "",
"AttachBefore": false,
"Color": "cyan",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": true
},
{
"Index": 1,
"Name": "LogType",
"KeyName": "",
"AttachBefore": false,
"Color": "magenta",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": true
},
{
"Index": 2,
"Name": "LogTime",
"KeyName": "",
"AttachBefore": false,
"Color": "",
"Format": "time",
"Precision": 0,
"WithoutKey": false,
"Hide": false
},
{
"Index": 3,
"Name": "TraceId",
"KeyName": "",
"AttachBefore": false,
"Color": "gray",
"Format": "",
"Precision": 0,
"WithoutKey": true,
"Hide": false
},
{
"Index": 4,
"Name": "Image",
"KeyName": "",
"AttachBefore": false,
"Color": "",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": true
},
{
"Index": 5,
"Name": "Server",
"KeyName": "",
"AttachBefore": false,
"Color": "",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": true
},
{
"Index": 6,
"Name": "ServerId",
"KeyName": "",
"AttachBefore": false,
"Color": "",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": true
},
{
"Index": 7,
"Name": "App",
"KeyName": "App",
"AttachBefore": false,
"Color": "cyan",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": false
},
{
"Index": 8,
"Name": "Node",
"KeyName": "",
"AttachBefore": true,
"Color": "gray",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": false
},
{
"Index": 9,
"Name": "FromApp",
"KeyName": "From",
"AttachBefore": false,
"Color": "cyan",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": false
},
{
"Index": 10,
"Name": "FromNode",
"KeyName": "",
"AttachBefore": true,
"Color": "gray",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": false
},
{
"Index": 11,
"Name": "ClientIp",
"KeyName": "",
"AttachBefore": false,
"Color": "",
"Format": "",
"Precision": 0,
"WithoutKey": true,
"Hide": false
},
{
"Index": 12,
"Name": "ClientAppName",
"KeyName": "Client",
"AttachBefore": true,
"Color": "",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": false
},
{
"Index": 13,
"Name": "ClientAppVersion",
"KeyName": "",
"AttachBefore": true,
"Color": "",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": false
},
{
"Index": 14,
"Name": "UserId",
"KeyName": "User",
"AttachBefore": false,
"Color": "magenta",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": false
},
{
"Index": 15,
"Name": "DeviceId",
"KeyName": "Device",
"AttachBefore": false,
"Color": "gray",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": false
},
{
"Index": 16,
"Name": "SessionId",
"KeyName": "Session",
"AttachBefore": false,
"Color": "",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": false
},
{
"Index": 17,
"Name": "Host",
"KeyName": "",
"AttachBefore": false,
"Color": "gray",
"Format": "",
"Precision": 0,
"WithoutKey": true,
"Hide": false
},
{
"Index": 18,
"Name": "Method",
"KeyName": "",
"AttachBefore": false,
"Color": "gray",
"Format": "",
"Precision": 0,
"WithoutKey": true,
"Hide": false
},
{
"Index": 19,
"Name": "Path",
"KeyName": "",
"AttachBefore": false,
"Color": "cyan",
"Format": "",
"Precision": 0,
"WithoutKey": true,
"Hide": false
},
{
"Index": 20,
"Name": "Scheme",
"KeyName": "",
"AttachBefore": false,
"Color": "gray",
"Format": "",
"Precision": 0,
"WithoutKey": true,
"Hide": false
},
{
"Index": 21,
"Name": "Proto",
"KeyName": "",
"AttachBefore": false,
"Color": "gray",
"Format": "",
"Precision": 0,
"WithoutKey": true,
"Hide": false
},
{
"Index": 22,
"Name": "AuthLevel",
"KeyName": "",
"AttachBefore": false,
"Color": "green",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": false
},
{
"Index": 23,
"Name": "Priority",
"KeyName": "",
"AttachBefore": false,
"Color": "",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": true
},
{
"Index": 24,
"Name": "RequestData",
"KeyName": "Request",
"AttachBefore": false,
"Color": "cyan",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": false
},
{
"Index": 25,
"Name": "RequestHeaders",
"KeyName": "Headers",
"AttachBefore": false,
"Color": "cyan",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": false
},
{
"Index": 26,
"Name": "UsedTime",
"KeyName": "",
"AttachBefore": false,
"Color": "green",
"Format": "",
"Precision": 6,
"WithoutKey": false,
"Hide": false
},
{
"Index": 27,
"Name": "ResponseCode",
"KeyName": "Status",
"AttachBefore": false,
"Color": "magenta",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": false
},
{
"Index": 28,
"Name": "ResponseDataLength",
"KeyName": "ContentLength",
"AttachBefore": false,
"Color": "magenta",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": false
},
{
"Index": 29,
"Name": "ResponseData",
"KeyName": "Response",
"AttachBefore": false,
"Color": "magenta",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": false
},
{
"Index": 30,
"Name": "ResponseHeaders",
"KeyName": "Headers",
"AttachBefore": false,
"Color": "magenta",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": false
},
{
"Index": 31,
"Name": "Extra",
"KeyName": "",
"AttachBefore": false,
"Color": "",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": false
}
],
"warning": [
{
"Index": 0,
"Name": "LogName",
"KeyName": "",
"AttachBefore": false,
"Color": "cyan",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": true
},
{
"Index": 1,
"Name": "LogType",
"KeyName": "",
"AttachBefore": false,
"Color": "magenta",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": true
},
{
"Index": 2,
"Name": "LogTime",
"KeyName": "",
"AttachBefore": false,
"Color": "",
"Format": "time",
"Precision": 0,
"WithoutKey": false,
"Hide": false
},
{
"Index": 3,
"Name": "TraceId",
"KeyName": "",
"AttachBefore": false,
"Color": "gray",
"Format": "",
"Precision": 0,
"WithoutKey": true,
"Hide": false
},
{
"Index": 4,
"Name": "Image",
"KeyName": "",
"AttachBefore": false,
"Color": "",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": true
},
{
"Index": 5,
"Name": "Server",
"KeyName": "",
"AttachBefore": false,
"Color": "",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": true
},
{
"Index": 6,
"Name": "Warning",
"KeyName": "",
"AttachBefore": false,
"Color": "yellow",
"Format": "",
"Precision": 0,
"WithoutKey": true,
"Hide": false
},
{
"Index": 7,
"Name": "Extra",
"KeyName": "",
"AttachBefore": false,
"Color": "",
"Format": "",
"Precision": 0,
"WithoutKey": false,
"Hide": false
}
]
}

View File

@ -1,55 +1,5 @@
# CHANGELOG - go/service
## v1.5.20 (2026-06-22)
- **Client Key 应答头条件化**:
- `Device-Id` / `Session-Id` 仅当请求头未携带时才写入应答头,避免客户端已持有 ID 时重复返回。
- 静态文件和 WebSocket 升级应答中不再写入 `Device-Id` / `Session-Id` 头,仅通过 Cookie 维护身份(浏览器 WebSocket API 不支持自定义请求头)。
- **Client App 头命名规范化**:
- 客户端上报键名从 `AppName`/`AppVersion` 改为 `App-Name`/`App-Version`(与 `Device-Id`/`Session-Id` 一致使用破折号)。
- **Breaking**: 旧版客户端需同步修改请求头名称。
- **配置字段重命名**:
- `NoLogHeaders``NoLogRequestHeaders`(请求头排除列表)。
- `NoLogOutputFields``NoLogResponseFields`(响应体字段排除)。
- 新增 `NoLogResponseHeaders`(响应头排除列表,用户可追加自定义字段)。
- **Breaking**: 使用了旧字段名的配置需同步修改。
- **动态排除列表**:
- `NoLogRequestHeaders` 默认值动态构建(包含内部标准头 + 当前配置的 client key 名),用户只需追加自己关心的额外字段。
- 移除 `init()` 中的硬编码默认值。
- **Cookie 头智能过滤**:
- `sanitizeLogHeaders``Cookie` 头不再整体排除,而是解析 Cookie 内容,仅剔除 key 命中排除列表的键值对(如 `Device-Id`/`Session-Id`),保留业务 Cookie。
- 排除列表同时适用于 Header 名匹配和 Cookie key 匹配。
- **日志应答头排除**:
- 响应头日志捕获改为使用 `effectiveNoLogResponseHeaders()`,替代之前的硬编码空字符串。
## v1.5.19 (2026-06-22)
- **依赖更新**:
- 升级依赖 `id``v1.5.6``redis``v1.5.10`
## v1.5.18 (2026-06-21)
- **依赖更新**:
- 升级依赖 `id``v1.5.5``redis``v1.5.9`(同步修复旧版本 ID 未打乱的 Bug
## v1.5.17 (2026-06-21)
- **Session 增强**:
- `Save` 改为可变参数 `Save(args ...map[string]any) error`,支持传入 map 批量设置后保存。
- 新增 `Load(keys []string) map[string]any`,支持批量读取,空参数返回全部数据。
- `Remove` 改为可变参数 `Remove(keys ...string)`,支持一次删除多个 key。
- **日志脱敏引擎**:
- 新增 `sanitizeLogData` 递归脱敏函数,基于 size 预算、字符串截断、数组/对象元素限制构建日志安全对象,不影响原始数据。
- 新增 `sanitizeLogHeaders` 统一处理请求/响应头过滤。
- 新增 `sanitizeRespBody` 自动 JSON 解析后走对象脱敏,非 JSON 按字符串截断。
- **日志配置新增**:
- `NoLogInput`/`NoLogOutput`/`NoLogAllHeaders` — 高并发加速开关。
- `LogInputObjectNum`/`LogOutputObjectNum` — 对象最多记录 key 数(默认 10/5
- 各配置默认值: fieldSize=20, arrayNum(In=5/Out=3), maxSize=200, NoLogHeaders 默认过滤内部头。
- **响应体捕获修复**:
- `response.Write` 改为始终调用 `keepBody` 缓冲 body限制大小 LogOutputMaxSize修复 200 响应无日志输出的问题。
- `outputResult` 先调 `keepBody` 再分路径写出,修复 dev 模式hasOutFilter下 PhysicalWrite 绕过 body 缓冲的问题。
- **客户端 Key 默认值**: `Session-ID`/`Device-ID`/`App`,配合 HTTP header canonicalize 显示为 `Session-Id`/`Device-Id`
- **调试清理**: 移除 handler.go 中 `fmt.Println`/`shell.BMagenta` 调试残留。
- **日志格式优化**: RequestHeaders 颜色 blueResponseDataLength key 为 SizeResponseData/ResponseHeaders 颜色 yellow。
- **依赖更新**: 升级 `js``v1.5.6`
## v1.5.15 (2026-06-21)
- **错误堆栈重构**:
- 重构 `js_export.go`,将匿名占位工厂函数改写为包级具名函数。

14
TEST.md
View File

@ -1,9 +1,9 @@
# Service Module Test Report
## 性能测试 (Benchmark)
- 测试日期: 2026-06-22
- 版本: v1.5.20
- 指标: `BenchmarkRouting`: **5394 ns/op**
- 测试日期: 2026-06-21
- 版本: v1.0.4
- 指标: `BenchmarkRouting`: 2791 ns/op
- 环境: Darwin / Intel(R) Core(TM) i9-9980HK CPU @ 2.40GHz
## 单元测试覆盖 (Unit Test)
@ -24,13 +24,7 @@
- [x] `TestGetDefaultName`: 自动应用名识别
- [x] `TestGetServerIp`: 自动 IP 探测
- [x] `TestSmartStartup`: 零配置智能启动与 Discover 注册
- [x] `TestSanitizeScalars` ~ `TestSanitizeMixedSlice`: 日志脱敏 10 个测试(标量/对象/数组/嵌套/预算/Unicode
- [x] `TestSessionLogic`: Session Save/Load/Remove 及 AuthFuncs
- [x] `TestSessionInjection`: Session HTTP 注入流程
- [x] Logging Filters: NoLogInput/NoLogOutput/NoLogAllHeaders/NoLogGets/NoLogRequestHeaders/NoLogResponseHeaders/NoLogResponseFields
- [x] Client Keys: Device-Id/Session-Id 应答头条件化(请求有则不应答)、静态文件/WebSocket 仅 Cookie
- [x] Cookie 头智能过滤: 排除列表中 key 从 Cookie 内容中剔除,保留业务 Cookie
- [x] Response Body: 200 响应和 dev 模式下 keepBody 捕获
- [x] **Logging Filters**: 已手动验证 `NoLogGets`, `NoLogHeaders` 等过滤逻辑。
## 基础设施对齐验证
- [x] 成功集成 `apigo.cc/go/cast` 用于参数解析与类型强转。

View File

@ -23,19 +23,12 @@ type ServiceConfig struct {
Listen string // 监听端口(|隔开多个监听)(,隔开多个选项),例如 80,http|443|443:h2|127.0.0.1:8080,h2c
SSL map[string]*CertSet // SSL 证书配置key 为域名
NoLogGets bool // 不记录 GET 请求的日志
NoLogInput bool // 不记录请求输入
NoLogOutput bool // 不记录响应输出
NoLogAllHeaders bool // 不记录所有请求/响应头
NoLogRequestHeaders string // 不记录请求头中包含的这些字段(追加到动态默认列表),多个字段用逗号分隔
NoLogResponseHeaders string // 不记录响应头中包含的这些字段(追加到动态默认列表),多个字段用逗号分隔
LogInputObjectNum int // 请求对象中最多记录的 key 数
LogInputArrayNum int // 请求数组中最多记录的元素数
LogInputFieldSize int // 请求单个字段的字符串截断长度
NoLogResponseFields string // 不记录响应字段中包含的这些字段
LogOutputObjectNum int // 响应对象中最多记录的 key 数
LogOutputArrayNum int // 响应数组中最多记录的元素数
LogOutputFieldSize int // 响应单个字段的字符串截断长度
LogOutputMaxSize int // 非对象响应内容的最大记录长度
NoLogHeaders string // 不记录请求头中包含的这些字段,多个字段用逗号分隔
LogInputArrayNum int // 请求字段中容器类型在日志打印个数限制
LogInputFieldSize int // 请求字段中单个字段在日志打印长度限制
NoLogOutputFields string // 不记录响应字段中包含的这些字段
LogOutputArrayNum int // 响应字段中容器类型在日志打印个数限制
LogOutputFieldSize int // 响应字段中单个字段在日志打印长度限制
Compress bool // 是否启用压缩
CompressMinSize int // 启用压缩的最小长度
CompressMaxSize int // 启用压缩的最大长度

6
go.mod
View File

@ -8,10 +8,10 @@ require (
apigo.cc/go/discover v1.5.3
apigo.cc/go/file v1.5.5
apigo.cc/go/http v1.5.3
apigo.cc/go/id v1.5.6
apigo.cc/go/id v1.5.4
apigo.cc/go/jsmod v1.5.3
apigo.cc/go/log v1.5.8
apigo.cc/go/redis v1.5.10
apigo.cc/go/redis v1.5.6
apigo.cc/go/safe v1.5.2
apigo.cc/go/starter v1.5.5
apigo.cc/go/timer v1.5.0
@ -24,7 +24,7 @@ require (
apigo.cc/go/crypto v1.5.3 // indirect
apigo.cc/go/encoding v1.5.4 // indirect
apigo.cc/go/rand v1.5.3 // indirect
apigo.cc/go/shell v1.5.4 // indirect
apigo.cc/go/shell v1.5.3 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/gomodule/redigo v2.0.0+incompatible // indirect

12
go.sum
View File

@ -12,20 +12,20 @@ apigo.cc/go/file v1.5.5 h1:/+HmDumLu6Qk2KuQL63M9lpgzHTDL+QJ8dStOl7e9gs=
apigo.cc/go/file v1.5.5/go.mod h1:xRVNhctvqOKeBemmcRW/BQfgkc3B+vT/UZVdSc7duUo=
apigo.cc/go/http v1.5.3 h1:nvJh9bqPPcPRv6p8WEw7bJAd0UC+r2zvQA8/QioVLTQ=
apigo.cc/go/http v1.5.3/go.mod h1:cFrPK61y9f1PrsNSJscZT/QVOgkT15o9OP7O8cuMb8Q=
apigo.cc/go/id v1.5.5 h1:fQXfb2WZ4hEtzXkpb9w9o8AOcTZ44fYQfTV6iZ49l8o=
apigo.cc/go/id v1.5.5/go.mod h1:hCTQq+KC1ALWe1FpPERf+W4B6FSulg9FAgOUJDDySiY=
apigo.cc/go/id v1.5.4 h1:D1Zx9gEZhOgdTgZ4SdmPImhpc9xGiOA33Y+j2MkstzQ=
apigo.cc/go/id v1.5.4/go.mod h1:hCTQq+KC1ALWe1FpPERf+W4B6FSulg9FAgOUJDDySiY=
apigo.cc/go/jsmod v1.5.3 h1:S3W317bH0QV2NMeRO1E0v6ySIBOfMWYv/NuQJbvqKWU=
apigo.cc/go/jsmod v1.5.3/go.mod h1:bmyeZtOAP/j5am+YRnaiM89smysK24K7ebk0koFtsSw=
apigo.cc/go/log v1.5.8 h1:/IYtGPWhRjT3OayylDIphkWZIQbpLjqVeSnFEiD3Dy0=
apigo.cc/go/log v1.5.8/go.mod h1:HfFPANMYxJx197SSTXB21Pgxcz/gGqPP8nlSErgd5WE=
apigo.cc/go/rand v1.5.3 h1:O4bPIwyaOWEBCr0nL9A4G4qG48AqiGTCzfPeckm3Ius=
apigo.cc/go/rand v1.5.3/go.mod h1:q1BTFkY/cXE229dDD5Q22lF7T0DoKPV6xAu+6bCrDH4=
apigo.cc/go/redis v1.5.9 h1:h77XPjQWcIDgxrtwjtS/gwT6PS5nSk6/x785t0OMlbY=
apigo.cc/go/redis v1.5.9/go.mod h1:YZRDLA3gWw9LAc4Z/4GrDEy8eMDMdDPDA0iqSEAu1fE=
apigo.cc/go/redis v1.5.6 h1:Lzo8M2binfqdQdVVp31Z/Max4qT8D82QdZjLlLQsrIY=
apigo.cc/go/redis v1.5.6/go.mod h1:HmqSh2Ll7/b2zFXDi2Ap13YOuMCVniuZNbwtxkbIYII=
apigo.cc/go/safe v1.5.2 h1:EnuEOW/SGwf/5A0nw9LnqfKJE071+TIc6ez8HI9R9Lg=
apigo.cc/go/safe v1.5.2/go.mod h1:2GqCCLLGex4OAhdET3iBWm1R+LIYtmTrvHP8W0iESSw=
apigo.cc/go/shell v1.5.4 h1:Kn6lP6I6d9U0hbyUjpKKFdFZ8RPo4vi4V6AYW8YFzrc=
apigo.cc/go/shell v1.5.4/go.mod h1:FdZWUrcXHGJXo725oSyHqAeFoX0E9yY3PDhrz9hujgY=
apigo.cc/go/shell v1.5.3 h1:pI+u12sy6upoygq+1XXqUlvUboBfH4Q52jRpoJFv56A=
apigo.cc/go/shell v1.5.3/go.mod h1:FdZWUrcXHGJXo725oSyHqAeFoX0E9yY3PDhrz9hujgY=
apigo.cc/go/starter v1.5.5 h1:4ST02o4qP8IIekxtd9Jhx5RHTrSGXtVQUguSIXV0iWc=
apigo.cc/go/starter v1.5.5/go.mod h1:WAGhdtmZdpP1Jn/z0pCqHwpTbqqaFhm5OqH7QVtcanY=
apigo.cc/go/timer v1.5.0 h1:iPo/IQn+iuhBRI1/MR1txwZnamef/RBBfOiIlBiqkgk=

View File

@ -1,6 +1,10 @@
package service
import (
"apigo.cc/go/cast"
"apigo.cc/go/discover"
"apigo.cc/go/log"
"apigo.cc/go/timer"
"io"
"net/http"
"net/url"
@ -9,11 +13,6 @@ import (
"strings"
"sync/atomic"
"time"
"apigo.cc/go/cast"
"apigo.cc/go/discover"
"apigo.cc/go/log"
"apigo.cc/go/timer"
)
type RouteHandler struct {
@ -72,33 +71,43 @@ func (rh *RouteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
usedTime := float32(tracker.Stop().Seconds())
// 请求头
var reqHeaders map[string]string
if !ws.Config.NoLogAllHeaders {
reqHeaders = sanitizeLogHeaders(r.Header, ws.effectiveNoLogRequestHeaders())
// 过滤请求头
reqHeaders := make(map[string]string)
noLogHeaders := strings.Split(ws.Config.NoLogHeaders, ",")
for k, v := range r.Header {
skip := false
for _, nl := range noLogHeaders {
if nl != "" && strings.EqualFold(k, strings.TrimSpace(nl)) {
skip = true
break
}
}
if !skip {
reqHeaders[k] = strings.Join(v, ", ")
}
}
// 响应头
var respHeaders map[string]string
if !ws.Config.NoLogAllHeaders {
respHeaders = sanitizeLogHeaders(response.Header().H, ws.effectiveNoLogResponseHeaders())
// 过滤响应头
respHeaders := make(map[string]string)
for k, v := range response.Header().H {
respHeaders[k] = strings.Join(v, ", ")
}
// 请求输入脱敏
var reqData any
if !ws.Config.NoLogInput && args != nil {
reqData = sanitizeLogData(args, sanitizeOpts{
maxSize: 200,
fieldSize: ws.Config.LogInputFieldSize,
arrayNum: ws.Config.LogInputArrayNum,
objectNum: ws.Config.LogInputObjectNum,
})
}
// 响应输出脱敏
// 处理响应内容截断
var respData any
if !ws.Config.NoLogOutput && len(response.body) > 0 {
respData = sanitizeRespBody(response.body, &ws.Config)
if response.Code != 200 {
if len(response.body) < 1024 {
respData = string(response.body)
} else {
respData = string(response.body[:1024]) + "..."
}
} else if ws.Config.NoLogOutputFields != "" {
// 简单的字段过滤逻辑 (如果是 JSON 对象)
// 这里可以根据 Config.NoLogOutputFields, LogOutputArrayNum, LogOutputFieldSize 进行更复杂的处理
// 暂按字符串截断处理
if len(response.body) > 0 {
respData = "[content hidden or truncated]"
}
}
LogRequest(requestLogger, func(entry *RequestLog) {
@ -119,7 +128,7 @@ func (rh *RouteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
entry.AuthLevel = authLevel
entry.Priority = priority
entry.RequestHeaders = reqHeaders
entry.RequestData = reqData
entry.RequestData = args
entry.ResponseCode = response.Code
entry.UsedTime = usedTime
entry.ResponseHeaders = respHeaders
@ -424,7 +433,6 @@ func outputResult(response *Response, result any) {
response.Header().Set("Content-Type", contentType)
}
response.keepBody(data)
if response.server != nil && response.server.hasOutFilter {
response.PhysicalWrite(data)
} else {
@ -434,11 +442,8 @@ func outputResult(response *Response, result any) {
func (ws *WebServer) handleClientKeys(request *Request, response *Response) {
// SessionId
if ws.usedSessionIdKey != "" {
hasRequestSessionId := false
sessionId := request.Header().Get(ws.usedSessionIdKey)
if sessionId != "" {
hasRequestSessionId = true
} else if !ws.Config.SessionWithoutCookie {
if sessionId == "" && !ws.Config.SessionWithoutCookie {
if ck := request.GetCookie(ws.usedSessionIdKey); ck != nil {
sessionId = ck.Value
}
@ -459,18 +464,13 @@ func (ws *WebServer) handleClientKeys(request *Request, response *Response) {
}
}
request.Request.Header.Set(discover.HeaderSessionID, sessionId)
if !hasRequestSessionId {
response.Header().Set(ws.usedSessionIdKey, sessionId)
}
response.Header().Set(ws.usedSessionIdKey, sessionId)
}
// DeviceId
if ws.usedDeviceIdKey != "" {
hasRequestDeviceId := false
deviceId := request.Header().Get(ws.usedDeviceIdKey)
if deviceId != "" {
hasRequestDeviceId = true
} else if !ws.Config.DeviceWithoutCookie {
if deviceId == "" && !ws.Config.DeviceWithoutCookie {
if ck := request.GetCookie(ws.usedDeviceIdKey); ck != nil {
deviceId = ck.Value
}
@ -488,41 +488,6 @@ func (ws *WebServer) handleClientKeys(request *Request, response *Response) {
}
}
request.Request.Header.Set(discover.HeaderDeviceID, deviceId)
if !hasRequestDeviceId {
response.Header().Set(ws.usedDeviceIdKey, deviceId)
}
}
// App-Name / App-Version客户端上报注入内部标准头供下游微服务使用
if ws.usedClientAppKey != "" {
if appName := request.Header().Get(ws.usedClientAppKey + "-Name"); appName != "" {
request.Request.Header.Set(discover.HeaderClientAppName, appName)
}
if appVersion := request.Header().Get(ws.usedClientAppKey + "-Version"); appVersion != "" {
request.Request.Header.Set(discover.HeaderClientAppVersion, appVersion)
}
response.Header().Set(ws.usedDeviceIdKey, deviceId)
}
}
// effectiveNoLogRequestHeaders 返回请求头排除列表(动态默认值 + 用户配置追加)
func (ws *WebServer) effectiveNoLogRequestHeaders() string {
parts := []string{"X-Request-Id", "X-Device-Id", "X-Session-Id"}
if ws.usedDeviceIdKey != "" {
parts = append(parts, ws.usedDeviceIdKey)
}
if ws.usedSessionIdKey != "" {
parts = append(parts, ws.usedSessionIdKey)
}
if ws.usedClientAppKey != "" {
parts = append(parts, ws.usedClientAppKey+"-Name", ws.usedClientAppKey+"-Version")
}
if ws.Config.NoLogRequestHeaders != "" {
parts = append(parts, ws.Config.NoLogRequestHeaders)
}
return strings.Join(parts, ",")
}
// effectiveNoLogResponseHeaders 返回响应头排除列表(用户配置追加)
func (ws *WebServer) effectiveNoLogResponseHeaders() string {
return ws.Config.NoLogResponseHeaders
}

View File

@ -32,7 +32,7 @@ func jsUpgrade(response *Response, request *Request) (*WebSocketConn, error) {
return conn, nil
}
// jsUploadFile 包装 UploadFile 以隐藏敏感方法(如 Open
// jsUploadFile 包装 UploadFile 以隐藏敏感方法
type jsUploadFile struct {
f *UploadFile
}

6
log.go
View File

@ -25,12 +25,12 @@ type RequestLog struct {
AuthLevel int `log:"pos:22,color:green"`
Priority int `log:"pos:23,hide:true"`
RequestData any `log:"pos:24,color:cyan,keyname:Request"`
RequestHeaders map[string]string `log:"pos:25,color:blue,keyname:Headers"`
RequestHeaders map[string]string `log:"pos:25,color:cyan,keyname:Headers"`
UsedTime float32 `log:"pos:26,color:green,precision:6"`
ResponseCode int `log:"pos:27,color:magenta,keyname:Status"`
ResponseDataLength uint `log:"pos:28,color:magenta,keyname:Size"`
ResponseDataLength uint `log:"pos:28,color:magenta,keyname:ContentLength"`
ResponseData any `log:"pos:29,color:magenta,keyname:Response"`
ResponseHeaders map[string]string `log:"pos:30,color:yellow,keyname:Headers"`
ResponseHeaders map[string]string `log:"pos:30,color:magenta,keyname:Headers"`
}
func (l *RequestLog) Reset() {

View File

@ -1,242 +0,0 @@
package service
import (
"net/http"
"sort"
"strings"
"apigo.cc/go/cast"
)
// sanitizeOpts 日志脱敏配置
type sanitizeOpts struct {
maxSize int // 整体尺寸上限(内容字符估算)
fieldSize int // 单字符串截断长度
arrayNum int // 数组最多保留元素数
objectNum int // 对象最多保留 key 数
}
// sanitizeLogData 递归脱敏,返回新建的对象,不影响原始数据
func sanitizeLogData(v any, opts sanitizeOpts) any {
budget := opts.maxSize
return sanitizeRecursive(v, opts, &budget)
}
func sanitizeRecursive(v any, opts sanitizeOpts, budget *int) any {
if v == nil {
return nil
}
switch val := v.(type) {
case string:
return sanitizeString(val, opts, budget)
case bool:
return sanitizeScalar(val, 5, budget)
case float64:
return sanitizeScalar(val, 8, budget)
case map[string]any:
return sanitizeMapContent(val, opts, budget)
case []any:
return sanitizeSliceContent(val, opts, budget)
default:
// int 等各种数值类型
return sanitizeScalar(val, 8, budget)
}
}
func sanitizeString(s string, opts sanitizeOpts, budget *int) string {
if len([]rune(s)) > opts.fieldSize {
s = string([]rune(s)[:opts.fieldSize])
}
if *budget < len(s) {
*budget = 0
return s // 即使超出预算也返回截断后的内容
}
*budget -= len(s)
return s
}
func sanitizeScalar(v any, cost int, budget *int) any {
if *budget < cost {
*budget = 0
return nil
}
*budget -= cost
return v
}
func sanitizeMapContent(m map[string]any, opts sanitizeOpts, budget *int) map[string]any {
// 空 map 占 2 预算
if *budget < 2 {
*budget = 0
return map[string]any{}
}
*budget -= 2
result := make(map[string]any)
// 排序 key 保证遍历顺序确定性
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
count := 0
for _, k := range keys {
if count >= opts.objectNum || *budget <= 0 {
break
}
// 扣 key 长度
keyCost := len(k)
if *budget < keyCost {
break
}
budgetBefore := *budget
*budget -= keyCost
// 递归处理 value
processed := sanitizeRecursive(m[k], opts, budget)
// 检查是否因预算不足返回了 nil仅当原值非 nil 时)
if processed == nil && m[k] != nil {
*budget = budgetBefore
break
}
result[k] = processed
count++
}
return result
}
func sanitizeSliceContent(s []any, opts sanitizeOpts, budget *int) []any {
// 空 slice 占 2 预算
if *budget < 2 {
*budget = 0
return []any{}
}
*budget -= 2
result := make([]any, 0, min(len(s), opts.arrayNum))
count := 0
for _, v := range s {
if count >= opts.arrayNum || *budget <= 0 {
break
}
// 值预算由递归处理
processed := sanitizeRecursive(v, opts, budget)
if processed == nil && v != nil {
break
}
result = append(result, processed)
count++
}
return result
}
// sanitizeLogHeaders 过滤请求/响应头,排除 noLogHeaders 中指定的字段。
// 对于 Cookie 头,不整体排除,而是从 Cookie 内容中剔除 key 命中排除列表的键值对。
func sanitizeLogHeaders(h http.Header, noLogHeaders string) map[string]string {
result := make(map[string]string)
excludes := splitAndTrim(noLogHeaders)
for k, v := range h {
if k == "Cookie" && len(excludes) > 0 {
if filtered := sanitizeCookieValue(v, excludes); filtered != "" {
result[k] = filtered
}
continue
}
skip := false
for _, ex := range excludes {
if strings.EqualFold(k, ex) {
skip = true
break
}
}
if !skip {
result[k] = strings.Join(v, ", ")
}
}
return result
}
// sanitizeCookieValue 从 Cookie 原始值中剔除 key 命中排除列表的键值对。
// 输入为原始 Cookie 头值(可能多个 key=value 以 "; " 或 ";" 分隔)。
func sanitizeCookieValue(values []string, excludes []string) string {
raw := strings.Join(values, "; ")
pairs := strings.Split(raw, ";")
var kept []string
for _, pair := range pairs {
pair = strings.TrimSpace(pair)
if pair == "" {
continue
}
key, _, _ := strings.Cut(pair, "=")
key = strings.TrimSpace(key)
excluded := false
for _, ex := range excludes {
if strings.EqualFold(key, ex) {
excluded = true
break
}
}
if !excluded {
kept = append(kept, pair)
}
}
return strings.Join(kept, "; ")
}
func splitAndTrim(s string) []string {
if s == "" {
return nil
}
parts := strings.Split(s, ",")
for i, p := range parts {
parts[i] = strings.TrimSpace(p)
}
return parts
}
// sanitizeRespBody 对响应体进行脱敏:尝试 JSON 解析后走对象脱敏,失败则按字符串截断
func sanitizeRespBody(body []byte, cfg *ServiceConfig) any {
// 尝试解析为 JSON 对象
var parsed any
if err := cast.UnmarshalJSON(body, &parsed); err == nil && parsed != nil {
// 排除敏感字段
if cfg.NoLogResponseFields != "" {
parsed = stripFields(parsed, cfg.NoLogResponseFields)
}
return sanitizeLogData(parsed, sanitizeOpts{
maxSize: 200,
fieldSize: cfg.LogOutputFieldSize,
arrayNum: cfg.LogOutputArrayNum,
objectNum: cfg.LogOutputObjectNum,
})
}
// 非 JSON 内容,按字符串截断
if len(body) > cfg.LogOutputMaxSize {
return string(body[:cfg.LogOutputMaxSize]) + "..."
}
return string(body)
}
// stripFields 从对象中删除指定字段(仅处理顶层 map
func stripFields(v any, fields string) any {
m, ok := v.(map[string]any)
if !ok {
return v
}
excludes := strings.Split(fields, ",")
for _, f := range excludes {
f = strings.TrimSpace(f)
if f != "" {
delete(m, f)
}
}
return m
}

View File

@ -1,250 +0,0 @@
package service
import (
"reflect"
"testing"
)
func TestSanitizeScalars(t *testing.T) {
opts := sanitizeOpts{maxSize: 200, fieldSize: 20, arrayNum: 3, objectNum: 10}
tests := []struct {
name string
input any
expected any
}{
{"nil", nil, nil},
{"int", 42, 42},
{"float", 3.14, 3.14},
{"bool_true", true, true},
{"bool_false", false, false},
{"string_short", "hello", "hello"},
{"string_exact", "12345678901234567890", "12345678901234567890"},
{"string_over", "123456789012345678901234567890", "12345678901234567890"},
{"string_unicode", "你好世界你好世界你好世界你好世界你好世界你好世界你好世界", "你好世界你好世界你好世界你好世界你好世界"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := sanitizeLogData(tt.input, opts)
if !reflect.DeepEqual(got, tt.expected) {
t.Errorf("got %v, want %v", got, tt.expected)
}
})
}
}
func TestSanitizeMapBasic(t *testing.T) {
opts := sanitizeOpts{maxSize: 200, fieldSize: 20, arrayNum: 3, objectNum: 10}
input := map[string]any{
"name": "John",
"bio": "A very long biography that should be truncated at twenty",
"status": "active",
}
expected := map[string]any{
"name": "John",
"bio": "A very long biograph",
"status": "active",
}
got := sanitizeLogData(input, opts)
if !reflect.DeepEqual(got, expected) {
t.Errorf("got %v, want %v", got, expected)
}
}
func TestSanitizeMapObjectNum(t *testing.T) {
opts := sanitizeOpts{maxSize: 200, fieldSize: 20, arrayNum: 3, objectNum: 3}
input := map[string]any{
"k1": "v1",
"k2": "v2",
"k3": "v3",
"k4": "v4",
"k5": "v5",
}
expected := map[string]any{
"k1": "v1",
"k2": "v2",
"k3": "v3",
}
got := sanitizeLogData(input, opts)
if !reflect.DeepEqual(got, expected) {
t.Errorf("got %v, want %v", got, expected)
}
}
func TestSanitizeSlice(t *testing.T) {
opts := sanitizeOpts{maxSize: 200, fieldSize: 20, arrayNum: 3, objectNum: 10}
input := []any{1, 2, 3, 4, 5, 6, 7}
expected := []any{1, 2, 3}
got := sanitizeLogData(input, opts)
if !reflect.DeepEqual(got, expected) {
t.Errorf("got %v, want %v", got, expected)
}
}
func TestSanitizeSliceWithStrings(t *testing.T) {
opts := sanitizeOpts{maxSize: 200, fieldSize: 20, arrayNum: 3, objectNum: 10}
input := []any{
"short",
"this string is definitely way too long for twenty characters",
"ok",
"this would be fourth but arrayNum is 3",
}
expected := []any{
"short",
"this string is defin",
"ok",
}
got := sanitizeLogData(input, opts)
if !reflect.DeepEqual(got, expected) {
t.Errorf("got %v, want %v", got, expected)
}
}
func TestSanitizeNested(t *testing.T) {
opts := sanitizeOpts{maxSize: 200, fieldSize: 20, arrayNum: 3, objectNum: 10}
input := map[string]any{
"user": map[string]any{
"name": "John Doe",
"bio": "A very long description that should be truncated at twenty chars",
"tags": []any{"go", "javascript-long-name", "rust", "python", "java", "c++"},
},
"score": 100,
}
expected := map[string]any{
"user": map[string]any{
"name": "John Doe",
"bio": "A very long descript",
"tags": []any{"go", "javascript-long-name", "rust"},
},
"score": 100,
}
got := sanitizeLogData(input, opts)
if !reflect.DeepEqual(got, expected) {
t.Errorf("got %v, want %v", got, expected)
}
}
func TestSanitizeTopLevelArray(t *testing.T) {
opts := sanitizeOpts{maxSize: 200, fieldSize: 20, arrayNum: 3, objectNum: 10}
input := []any{
map[string]any{"id": 1, "name": "Alice"},
map[string]any{"id": 2, "name": "Bob"},
map[string]any{"id": 3, "name": "Charlie"},
map[string]any{"id": 4, "name": "Diana"},
map[string]any{"id": 5, "name": "Eve"},
}
expected := []any{
map[string]any{"id": 1, "name": "Alice"},
map[string]any{"id": 2, "name": "Bob"},
map[string]any{"id": 3, "name": "Charlie"},
}
got := sanitizeLogData(input, opts)
if !reflect.DeepEqual(got, expected) {
t.Errorf("got %v, want %v", got, expected)
}
}
func TestSanitizeBudgetExhausted(t *testing.T) {
opts := sanitizeOpts{maxSize: 22, fieldSize: 20, arrayNum: 10, objectNum: 10}
input := map[string]any{
"a": "1234567890",
"b": "abcdefghij",
"c": "should-be-dropped",
}
expected := map[string]any{
"a": "1234567890",
"b": "abcdefghij",
}
got := sanitizeLogData(input, opts)
if !reflect.DeepEqual(got, expected) {
t.Errorf("got %v, want %v", got, expected)
}
}
func TestSanitizeNestedBudgetExhausted(t *testing.T) {
opts := sanitizeOpts{maxSize: 30, fieldSize: 20, arrayNum: 10, objectNum: 10}
input := map[string]any{
"small": "hi",
"nested": map[string]any{
"field1": "1234567890",
"field2": "abcdefghij",
},
"after": "no",
}
// 按字母序: after(7) → nested(22) → small(被跳过)
expected := map[string]any{
"after": "no",
"nested": map[string]any{
"field1": "1234567890",
},
}
got := sanitizeLogData(input, opts)
if !reflect.DeepEqual(got, expected) {
t.Errorf("got %v, want %v", got, expected)
}
}
func TestSanitizeEmptyContainers(t *testing.T) {
opts := sanitizeOpts{maxSize: 200, fieldSize: 20, arrayNum: 3, objectNum: 10}
tests := []struct {
name string
input any
expected any
}{
{"empty_map", map[string]any{}, map[string]any{}},
{"empty_slice", []any{}, []any{}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := sanitizeLogData(tt.input, opts)
if !reflect.DeepEqual(got, tt.expected) {
t.Errorf("got %v, want %v", got, tt.expected)
}
})
}
}
func TestSanitizeMixedSlice(t *testing.T) {
opts := sanitizeOpts{maxSize: 200, fieldSize: 20, arrayNum: 10, objectNum: 10}
input := []any{
"ok",
42,
true,
nil,
map[string]any{"nested": "value"},
"this one is too long and will be trimmed to twenty chars",
nil,
}
expected := []any{
"ok",
42,
true,
nil,
map[string]any{"nested": "value"},
"this one is too long",
nil,
}
got := sanitizeLogData(input, opts)
if !reflect.DeepEqual(got, expected) {
t.Errorf("got %v, want %v", got, expected)
}
}

View File

@ -67,8 +67,10 @@ func (r *Response) Write(bytes []byte) (int, error) {
return len(bytes), nil
}
// 缓冲 body 用于日志记录
r.keepBody(bytes)
// 即使没有过滤器,非 200 状态码也进行缓冲以便日志记录
if r.Code != http.StatusOK {
r.body = append(r.body, bytes...)
}
if r.ProxyHeader != nil {
r.copyProxyHeader()
@ -80,22 +82,6 @@ func (r *Response) Write(bytes []byte) (int, error) {
return n, nil
}
// keepBody 缓冲数据用于日志记录,限制大小防止内存问题
func (r *Response) keepBody(bytes []byte) {
maxBuf := 200
if r.server != nil && r.server.Config.LogOutputMaxSize > 0 {
maxBuf = r.server.Config.LogOutputMaxSize
}
if len(r.body) < maxBuf {
space := maxBuf - len(r.body)
if len(bytes) <= space {
r.body = append(r.body, bytes...)
} else {
r.body = append(r.body, bytes[:space]...)
}
}
}
// PhysicalWrite 物理写入网线,绕过过滤器缓冲逻辑
func (r *Response) PhysicalWrite(bytes []byte) (int, error) {
r.checkWriteHeader()

View File

@ -110,17 +110,6 @@ var DefaultServer = NewWebServer()
// Config 全局配置对象 (指向 DefaultServer.Config)
var Config = &DefaultServer.Config
func init() {
// NoLogRequestHeaders / NoLogResponseHeaders 的默认值在日志捕获时动态构建
Config.LogInputObjectNum = 10
Config.LogInputArrayNum = 5
Config.LogInputFieldSize = 20
Config.LogOutputObjectNum = 5
Config.LogOutputArrayNum = 3
Config.LogOutputFieldSize = 20
Config.LogOutputMaxSize = 200
}
func NewWebServer() *WebServer {
ws := &WebServer{
webServices: make(map[string]map[string]*webServiceType),
@ -145,10 +134,6 @@ func NewWebServer() *WebServer {
webAuthCheckers: make(map[int]func(int, *log.Logger, *string, map[string]any, *Request, *Response, *WebServiceOptions) (pass bool, object any)),
injectObjects: make(map[reflect.Type]any),
injectFunctions: make(map[reflect.Type]func() any),
usedSessionIdKey: "Session-ID",
usedDeviceIdKey: "Device-ID",
usedClientAppKey: "App",
}
return ws
}

View File

@ -1,15 +1,14 @@
package service
import (
"errors"
"strings"
"sync"
"time"
"apigo.cc/go/cast"
"apigo.cc/go/jsmod"
"apigo.cc/go/log"
"apigo.cc/go/redis"
"errors"
"strings"
"sync"
"time"
)
// Session 会话对象
@ -75,35 +74,11 @@ func (s *Session) Get(key string) any {
return s.data[key]
}
// Load 批量读取会话数据keys 为空时返回全部数据
func (s *Session) Load(keys []string) map[string]any {
s.lock.RLock()
defer s.lock.RUnlock()
if len(keys) == 0 {
result := make(map[string]any, len(s.data))
for k, v := range s.data {
result[k] = v
}
return result
}
result := make(map[string]any, len(keys))
for _, key := range keys {
if v, ok := s.data[key]; ok {
result[key] = v
}
}
return result
}
// Remove 移除会话数据,支持传入多个 key
func (s *Session) Remove(keys ...string) {
// Remove 移除会话数据
func (s *Session) Remove(key string) {
s.lock.Lock()
defer s.lock.Unlock()
for _, key := range keys {
delete(s.data, key)
}
delete(s.data, key)
}
// SetAuthLevel 设置鉴权级别
@ -116,17 +91,11 @@ func (s *Session) GetAuthLevel() int {
return cast.Int(s.Get("_authLevel"))
}
// Save 保存会话数据,可选传入 map 用于批量设置后保存
func (s *Session) Save(args ...map[string]any) error {
// Save 保存会话数据
func (s *Session) Save() error {
s.lock.Lock()
defer s.lock.Unlock()
if len(args) > 0 && args[0] != nil {
for k, v := range args[0] {
s.data[k] = v
}
}
timeout := Config.SessionTimeout
if timeout <= 0 {
timeout = 3600

View File

@ -27,89 +27,6 @@ func TestSessionLogic(t *testing.T) {
t.Errorf("Expected value1 in new session instance, got %v", sess2.Get("key1"))
}
// 1.1 测试 Save 批量设置
sess3 := NewSession("test_batch", nil)
m := map[string]any{"a": 1, "b": "hello", "c": true}
if err := sess3.Save(m); err != nil {
t.Errorf("Save with map failed: %v", err)
}
sess4 := NewSession("test_batch", nil)
if sess4.Get("a") != 1 {
t.Errorf("Expected a=1, got %v", sess4.Get("a"))
}
if sess4.Get("b") != "hello" {
t.Errorf("Expected b=hello, got %v", sess4.Get("b"))
}
if sess4.Get("c") != true {
t.Errorf("Expected c=true, got %v", sess4.Get("c"))
}
// 1.2 测试 Save 无参数仍然正常工作
sess5 := NewSession("test_noarg", nil)
sess5.Set("x", "y")
if err := sess5.Save(); err != nil {
t.Errorf("Save without args failed: %v", err)
}
sess6 := NewSession("test_noarg", nil)
if sess6.Get("x") != "y" {
t.Errorf("Expected x=y, got %v", sess6.Get("x"))
}
// 1.3 测试 Load 批量读取
sess7 := NewSession("test_load", nil)
sess7.Set("k1", "v1")
sess7.Set("k2", "v2")
sess7.Set("k3", "v3")
_ = sess7.Save()
sess8 := NewSession("test_load", nil)
result := sess8.Load([]string{"k1", "k3"})
if len(result) != 2 {
t.Errorf("Expected 2 keys, got %d", len(result))
}
if result["k1"] != "v1" {
t.Errorf("Expected k1=v1, got %v", result["k1"])
}
if result["k3"] != "v3" {
t.Errorf("Expected k3=v3, got %v", result["k3"])
}
if _, ok := result["k2"]; ok {
t.Error("k2 should not be in partial Load result")
}
// 1.4 测试 Load 空参数返回全部数据
allData := sess8.Load(nil)
if len(allData) < 3 {
t.Errorf("Expected at least 3 keys in full Load, got %d", len(allData))
}
// 1.5 测试 Remove 多 key
sess9 := NewSession("test_remove", nil)
sess9.Set("a", 1)
sess9.Set("b", 2)
sess9.Set("c", 3)
sess9.Remove("a", "c")
_ = sess9.Save()
sess10 := NewSession("test_remove", nil)
if sess10.Get("a") != nil {
t.Errorf("Expected a removed, got %v", sess10.Get("a"))
}
if sess10.Get("b") != 2 {
t.Errorf("Expected b=2, got %v", sess10.Get("b"))
}
if sess10.Get("c") != nil {
t.Errorf("Expected c removed, got %v", sess10.Get("c"))
}
// 1.6 测试 Remove 无参数(安全无操作)
sess9.Remove()
_ = sess9.Save()
sess11 := NewSession("test_remove", nil)
if sess11.Get("b") != 2 {
t.Errorf("Expected b=2 after no-arg Remove, got %v", sess11.Get("b"))
}
// 2. 测试 AuthFuncs 逻辑
sess.Set("funcs", []string{"user.read", "user.write", "system.admin"})

View File

@ -131,10 +131,6 @@ func (ws *WebServer) processStatic(requestPath string, request *Request, respons
return false
}
// 静态文件通过 Cookie 维护 ID不应答 Device-Id / Session-Id 头
response.Header().Del(ws.usedDeviceIdKey)
response.Header().Del(ws.usedSessionIdKey)
// 检查 304
if ifModifiedSince := request.Header().Get("If-Modified-Since"); ifModifiedSince != "" {
if t, err := time.Parse(http.TimeFormat, ifModifiedSince); err == nil {

View File

@ -78,11 +78,6 @@ func Upgrade(response *Response, request *Request) (*WebSocketConn, error) {
}
func (ws *WebServer) doWebsocketService(wsc *websocketServiceType, request *Request, response *Response, logger *log.Logger, object any) {
// WebSocket 浏览器 API 不支持自定义请求头,只能通过 Cookie 传递身份标识,
// 因此不应在升级应答中返回 Device-Id / Session-Id 头。
response.Header().Del(ws.usedDeviceIdKey)
response.Header().Del(ws.usedSessionIdKey)
wsConn, err := Upgrade(response, request)
if err != nil {
logger.Error("websocket upgrade failed", "error", err.Error())