fix(storage): 兼容消息中的 NUL 字符

This commit is contained in:
caoqianming 2026-07-31 14:43:10 +08:00
parent afdcf46577
commit cd6a76cba9
6 changed files with 105 additions and 5 deletions

View File

@ -5,6 +5,10 @@
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。 > 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
> 工程口径的完整记录见 `PROGRESS.md` / git log。 > 工程口径的完整记录见 `PROGRESS.md` / git log。
## 0.60.17 — 2026-07-31
- 修复读取论文、PDF 等外部内容时,个别不可见字符可能导致工具结果保存失败并中断对话的问题。
## 0.60.16 — 2026-07-31 ## 0.60.16 — 2026-07-31
- 从 platform 打开历史对话时会等待本次登录凭证签发完成后再加载,不再因浏览器缓存的旧登录状态一直停在对话和文件“加载中”。 - 从 platform 打开历史对话时会等待本次登录凭证签发完成后再加载,不再因浏览器缓存的旧登录状态一直停在对话和文件“加载中”。

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9` > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-07-31(platform 历史 task 稳定进入 + 新建任务名称可选,bump 0.60.16) 最后更新:2026-07-31(消息 JSONB NUL 字符兼容,bump 0.60.17)
--- ---
@ -23,6 +23,7 @@
### 2026-07-31 ### 2026-07-31
- **07-31 / 0.60.17 / 消息 JSONB NUL 字符兼容**:外部工具从论文、PDF 等来源提取的文本可能夹带真实 NULPython JSON 会编码为 PostgreSQL `jsonb` 无法转换成 text 的 `\u0000`,导致 tool message INSERT 回滚并中断 run。新增递归清洗并在 `Message.payload` ORM 写入边界统一守门,`Session.append` 同步清洗内存上下文,保留制表符、换行及其余 Unicode无 schema、migration 或存量数据改动。相关 6 项单元测试、Python 编译及 diff 格式检查通过;未设置 `ZCBOT_TEST_DB_URL`,未连接数据库。
- **07-31 / 0.60.16 / platform 历史 task 稳定进入 + 新建任务名称可选**:普通 platform iframe 不再复用 localStorage 缓存 JWT 抢跑,始终等待父页面本次 `zcbot-token` 后再进入初始 task消除旧 token 401 后界面永久停在“加载中”的时序;企微/App fragment 登录路径不变。`POST /v1/tasks` 省略/留空 name 且提供 working_dir 时自动以“新对话”占位并启用首条消息标题生成,旧 `auto_title` 语义继续兼容;快速新对话不再发送冗余字段,自定义创建统一为工作目录必填、任务名选填,并换用可搜索/可创建的目录组合框。27 项无 DB 测试、Python 编译、3 个 JavaScript 语法及 diff 格式检查通过DB 路由用例已补,未配置 `ZCBOT_TEST_DB_URL` 时安全跳过。 - **07-31 / 0.60.16 / platform 历史 task 稳定进入 + 新建任务名称可选**:普通 platform iframe 不再复用 localStorage 缓存 JWT 抢跑,始终等待父页面本次 `zcbot-token` 后再进入初始 task消除旧 token 401 后界面永久停在“加载中”的时序;企微/App fragment 登录路径不变。`POST /v1/tasks` 省略/留空 name 且提供 working_dir 时自动以“新对话”占位并启用首条消息标题生成,旧 `auto_title` 语义继续兼容;快速新对话不再发送冗余字段,自定义创建统一为工作目录必填、任务名选填,并换用可搜索/可创建的目录组合框。27 项无 DB 测试、Python 编译、3 个 JavaScript 语法及 diff 格式检查通过DB 路由用例已补,未配置 `ZCBOT_TEST_DB_URL` 时安全跳过。
- **07-31 / 0.60.15 / 嵌入初始 task 直接进入工作目录**:platform iframe、企业微信和 App 携带 `task_id` 首次进入时,由 `enterApp` 直接选择目标 task跳过新对话草稿及 user_root 文件面板初始化,随后沿既有 `selectTask` 链路同步 `working_dir`401 重签仍不重复定位普通登录空态不变。JavaScript 语法、7 项静态前端测试及 diff 格式检查通过;无 API、schema、migration 或依赖变化。 - **07-31 / 0.60.15 / 嵌入初始 task 直接进入工作目录**:platform iframe、企业微信和 App 携带 `task_id` 首次进入时,由 `enterApp` 直接选择目标 task跳过新对话草稿及 user_root 文件面板初始化,随后沿既有 `selectTask` 链路同步 `working_dir`401 重签仍不重复定位普通登录空态不变。JavaScript 语法、7 项静态前端测试及 diff 格式检查通过;无 API、schema、migration 或依赖变化。
- **07-31 / 0.60.14 / 清空对话后自动标题重生成**:tasks 新增 `title_source`auto/manual/fixed`auto_title_version`0024 migration快速新对话的自动标题在清空后重置为“新对话”下一条消息重新生成人工重命名及微信/企微/调度固定标题保持不变。版本闸阻止清空前在途标题跨轮次写回;历史任务无法可靠判定“自动命名后又人工改名”,除当前 pending 外保守回填 manual。相关 18 项无 DB 测试、Python 编译、Alembic 单 head 与 diff 格式检查通过DB 路由用例已补,未配置 `ZCBOT_TEST_DB_URL` 时按安全门控跳过。 - **07-31 / 0.60.14 / 清空对话后自动标题重生成**:tasks 新增 `title_source`auto/manual/fixed`auto_title_version`0024 migration快速新对话的自动标题在清空后重置为“新对话”下一条消息重新生成人工重命名及微信/企微/调度固定标题保持不变。版本闸阻止清空前在途标题跨轮次写回;历史任务无法可靠判定“自动命名后又人工改名”,除当前 pending 外保守回填 manual。相关 18 项无 DB 测试、Python 编译、Alembic 单 head 与 diff 格式检查通过DB 路由用例已补,未配置 `ZCBOT_TEST_DB_URL` 时按安全门控跳过。

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。 # 改版本只动这一行。
__version__ = "0.60.16" __version__ = "0.60.17"

View File

@ -17,7 +17,7 @@ from uuid import UUID
from sqlalchemy import delete, func, select from sqlalchemy import delete, func, select
from .storage import session_scope from .storage import session_scope
from .storage.models import Message, Task from .storage.models import Message, Task, sanitize_jsonb_nul
from .file_store import atomic_write_text from .file_store import atomic_write_text
@ -69,7 +69,9 @@ class Session:
返回值: system row message_id( loop usage_events 关联用); 返回值: system row message_id( loop usage_events 关联用);
system 消息不入库, None旧调用方忽略返回值不影响行为 system 消息不入库, None旧调用方忽略返回值不影响行为
""" """
msg_dict = _to_dict(msg) # 与 Message.payload 的 ORM 守门保持一致,使当前 run 的内存上下文和落库值
# 完全相同;外部工具提取文本偶尔会携带 PostgreSQL JSONB 不支持的 NUL。
msg_dict = sanitize_jsonb_nul(_to_dict(msg))
self.messages.append(msg_dict) self.messages.append(msg_dict)
if msg_dict.get("role") == "system": if msg_dict.get("role") == "system":
return None return None

View File

@ -31,7 +31,28 @@ from sqlalchemy import (
func, func,
) )
from sqlalchemy.dialects.postgresql import JSONB, UUID as PG_UUID from sqlalchemy.dialects.postgresql import JSONB, UUID as PG_UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, validates
def sanitize_jsonb_nul(value: Any) -> Any:
"""递归移除 PostgreSQL JSONB 无法表示的 NUL 字符。
Python JSON 编码器会把 ``\x00`` 写成 ``\u0000`` PostgreSQL
``jsonb`` 最终以数据库编码的 text 表示字符串因而拒绝该转义消息 payload
会包含外部工具提取的文本所以在统一 ORM 边界做无损于其余 Unicode 的清洗
"""
if isinstance(value, str):
return value.replace("\x00", "")
if isinstance(value, dict):
return {
sanitize_jsonb_nul(key): sanitize_jsonb_nul(item)
for key, item in value.items()
}
if isinstance(value, list):
return [sanitize_jsonb_nul(item) for item in value]
if isinstance(value, tuple):
return tuple(sanitize_jsonb_nul(item) for item in value)
return value
class Base(DeclarativeBase): class Base(DeclarativeBase):
@ -162,6 +183,11 @@ class Message(Base):
DateTime(timezone=True), server_default=func.now(), nullable=False DateTime(timezone=True), server_default=func.now(), nullable=False
) )
@validates("payload")
def _sanitize_payload_nul(self, _key: str, value: dict[str, Any]) -> dict[str, Any]:
"""保证所有 ORM 消息写入都满足 PostgreSQL JSONB 的字符约束。"""
return sanitize_jsonb_nul(value)
class UsageEvent(Base): class UsageEvent(Base):
"""per-event 用量记账(0006 v2 形态)。 """per-event 用量记账(0006 v2 形态)。

View File

@ -0,0 +1,67 @@
import unittest
from contextlib import contextmanager
from unittest.mock import patch
from uuid import uuid4
from core.session import Session
from core.storage.models import Message, sanitize_jsonb_nul
class MessagePayloadSanitizationTests(unittest.TestCase):
def test_recursively_removes_nul_and_preserves_other_text(self) -> None:
payload = {
"role": "tool",
"content": "In 10\tH \x00unnamed\n中文",
"nested\x00key": ["a\x00b", {"value": "c\x00d"}],
}
cleaned = sanitize_jsonb_nul(payload)
self.assertEqual(cleaned["content"], "In 10\tH unnamed\n中文")
self.assertEqual(cleaned["nestedkey"], ["ab", {"value": "cd"}])
self.assertEqual(payload["content"], "In 10\tH \x00unnamed\n中文")
def test_message_model_sanitizes_payload_on_assignment(self) -> None:
message = Message(
task_id=uuid4(),
idx=0,
payload={
"role": "tool",
"content": "before\x00after",
"tool_calls": ({"arguments": "x\x00y"},),
},
)
self.assertEqual(message.payload["content"], "beforeafter")
self.assertEqual(
message.payload["tool_calls"],
({"arguments": "xy"},),
)
def test_session_keeps_sanitized_value_in_memory_and_database_row(self) -> None:
class FakeDbSession:
row: Message
def add(self, row: Message) -> None:
self.row = row
def flush(self) -> None:
self.row.message_id = uuid4()
fake_db = FakeDbSession()
@contextmanager
def fake_session_scope():
yield fake_db
session = Session(task_id=uuid4())
with patch("core.session.session_scope", side_effect=fake_session_scope):
session.append({"role": "tool", "content": "before\x00after"})
expected = {"role": "tool", "content": "beforeafter"}
self.assertEqual(session.messages, [expected])
self.assertEqual(fake_db.row.payload, expected)
if __name__ == "__main__":
unittest.main()