From 412821684b9ea1cc2dc3fcf0effaf89337d14fb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A0=81=E5=86=9C=E9=98=BF=E6=A5=A0?= Date: Tue, 8 Sep 2026 17:32:57 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=96=B0=E5=A2=9E=E7=AC=AC=202=20?= =?UTF-8?q?=E8=BD=AE=E8=AF=84=E5=AE=A1=E6=8A=A5=E5=91=8A=E3=80=81D36=20?= =?UTF-8?q?=E5=86=B3=E7=AD=96=E8=AE=B0=E5=BD=95=E4=B8=8E=20serve=20?= =?UTF-8?q?=E6=97=A7=E8=AF=8D=E6=94=B6=E5=B0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit review-round2.md 全量修复清单;decisions.md 澄清 D6、新增 D36;design/acceptance 残留 serve → start(P2-14)。 --- docs/acceptance.md | 2 +- docs/decisions.md | 5 +- docs/design.md | 6 +- docs/review-round2.md | 128 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 135 insertions(+), 6 deletions(-) create mode 100644 docs/review-round2.md diff --git a/docs/acceptance.md b/docs/acceptance.md index c6d9fe6..b377e26 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -50,7 +50,7 @@ | 里程碑 | 验收门 | 状态 | | --- | --- | --- | -| M0 骨架 | `make build` 产出可运行二进制;serve 起服务;/api/health 200 | ✅ | +| M0 骨架 | `make build` 产出可运行二进制;start 起服务;/api/health 200 | ✅ | | M1 公开浏览 | 笔记/标签 API + 博客三页 + 渲染管线 + RSS/sitemap + meta 注入;公开侧可见性用例;构建冒烟 | ✅ | | M2 管理端 | 登录/会话/CSRF + CRUD + 回收站 + 图片上传 + 编辑器 + 改密;完整矩阵 + 各组用例随功能交付 | ✅ | | M3 安全加固 | §9.3 全项通过(见上表);CSP nonce 硬化按设计留 M3+ 待办 | ✅ | diff --git a/docs/decisions.md b/docs/decisions.md index 045a22c..5ee494b 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -18,7 +18,7 @@ | # | 决策 | 依据 | | --- | --- | --- | -| D6 | `backup` / `gc` 子命令**不执行迁移、不做 user_version 守卫**(只有 `serve`/`init` 迁移) | 维护命令应是纯数据操作:在陌生(更新)schema 上跑迁移反而危险。设计 §10.4 的守卫语境是「启动服务」 | +| D6 | `backup` / `gc` / `passwd` 子命令**不执行迁移、不做 user_version 守卫**(只有 `start`/`init` 迁移) | 维护命令应是纯数据操作:在陌生(更新)schema 上跑迁移反而危险。设计 §10.4 的守卫语境是「启动服务」 | | D7 | `init` 在口令已存在时**拒绝并提示走管理界面改密**(无 `--force`) | 防止误操作覆盖口令;单管理员场景下改密有专门界面(§7.1 POST /api/admin/password) | | D8 | `GET /api/site` 公开端点(返回 site_title/site_desc/page_size 白名单三键) | 设计 §8.1 首页要显示「站点标题」,但 §7.1 公共 API 无设置出口;`/api/admin/settings` 需要会话。新增白名单只读端点,**永不含 admin_password_hash**(测试守护) | | D9 | `GET /api/notes/{slug}` 响应含 `prev`/`next`(相邻公开笔记 slug+title) | §8.1 详情页要求「上一篇/下一篇」;放在详情响应里避免前端额外请求与分页边界问题。排序与公开列表一致(updated_at DESC, id DESC) | @@ -50,7 +50,7 @@ | # | 决策 | 依据 | | --- | --- | --- | | D27 | §13 各测试组全部落为 Go 集成测试(httptest + 临时目录真实 SQLite):可见性矩阵(主体 × 状态 × 出口表驱动)、迁移守卫、登录/改密/会话轮换、CSRF、上传、回收站+gc、slug、设置白名单;webui 用 fstest.MapFS 单测 meta 转义/缓存头/fallback | 设计「其他测试组」要求 httptest + 临时目录真实 SQLite | -| D28 | 构建冒烟 = `scripts/smoke.sh`(`make smoke`):真实构建 → init → serve → 断言 SPA script/link 200 + 正确 MIME、meta 注入、安全头、私有不可见 | §8.3-6「真实构建 → 启动二进制 → 请求任一公开 slug 页面」;比 Go test 内嵌前端产物更贴近 CI 语义 | +| D28 | 构建冒烟 = `scripts/smoke.sh`(`make smoke`):真实构建 → init → start → 断言 SPA script/link 200 + 正确 MIME、meta 注入、安全头、私有不可见 | §8.3-6「真实构建 → 启动二进制 → 请求任一公开 slug 页面」;比 Go test 内嵌前端产物更贴近 CI 语义 | | D29 | 测试注入限流器:`httpapi.NewWithLimiters` 允许测试替换高容量桶;登录限流测试单独用真实参数构造器 | 避免全局限流 429 干扰矩阵测试,同时保留限流本身的专项测试(两全) | | D30 | 夹具图片用「合法 PNG + IEND 后差异化尾部」绕开 sha256 去重合并——去重本身另用同字节上传断言 | 实测发现同字节图片被去重合并为同一行(正确行为,§14 已预告「去重会合并」),矩阵需要四张不同图 | @@ -63,3 +63,4 @@ | D33 | webui 资产缓存:`assets/`(内容 hash 命名)immutable 一年;其他静态文件 1h;index.html no-cache;缺失资产 404 不回退 HTML | §8.3-5 + 防止把 JS 404 伪装成 SPA 页面造成误判 | | D34 | 无内嵌 index.html(M0 占位)时 HTML 路径返回 503 占位说明页 | 部署缺产物属于配置错误,503 比 200 空页更诚实 | | D35 | `robots.txt` 额外 `Disallow: /admin` | §7.1 仅要求「允许全部 + sitemap 指向」;Disallow /admin 是 SEO 常规增强,不构成功能偏离 | +| D36 | 第 2 轮评审(review-round2.md)修复随附的实现决策:维护命令经 `store.OpenData` 打开(落实 D6,`--allow-newer` 相应从维护子命令移除);Origin 校验增加 scheme 比对(可信反代后采信 X-Forwarded-Proto);邻接查询纳入 pinned 排序键;上传解码前以 `DecodeConfig` 限制像素 ≤2²⁵;备份经 umask 收紧使创建即 0600;前端写操作后统一失效公共查询缓存 | review-round2.md P1-1/P1-5/P2-11/P2-12/P2-7/P2-15 | diff --git a/docs/design.md b/docs/design.md index a54be32..d31c1ca 100644 --- a/docs/design.md +++ b/docs/design.md @@ -564,7 +564,7 @@ example.com { } ``` -> 幂等启动:`serve` 时若 settings 无口令哈希则拒绝启动并提示先跑 `init`;`user_version` 越界同样拒绝(§10.4)。 +> 幂等启动:`start` 时若 settings 无口令哈希则拒绝启动并提示先跑 `init`;`user_version` 越界同样拒绝(§10.4)。 ### 10.3 备份与恢复 @@ -587,7 +587,7 @@ timer 触发 `pn-maint.service`:先 `pn gc --commit`,后 `pn backup /backup/ **要点** -- 备份进程独立于服务进程运行,不受 unit 沙箱约束;`VACUUM INTO` 在 WAL 下与 serve 并发安全(在线一致快照); +- 备份进程独立于服务进程运行,不受 unit 沙箱约束;`VACUUM INTO` 在 WAL 下与 start 并发安全(在线一致快照); - `/backup` 目录属主 `purenote`、权限 0700;备份文件**chmod 0600**(内含全部私有笔记、图片、口令哈希、会话哈希的明文副本); - 保留 30 天;**语义声明**:备份保留期内被删除的数据物理上仍存在于备份中; - 异地同步(可选):`rclone` 远端启用 **crypt**(或先 `age` 加密再上传),禁止明文上云; @@ -625,7 +625,7 @@ timer 触发 `pn-maint.service`:先 `pn gc --commit`,后 `pn backup /backup/ | 里程碑 | 内容 | 测试/验收门 | | --- | --- | --- | -| M0 骨架 | Go 模块 + SQLite 迁移(user_version 守卫)+ 配置子命令 + 中间件链 + embed 构链(拷贝 + 占位 dist)+ `make build`/`make dev` | `make build` 产出可运行二进制;`serve` 起服务;`/api/health` 200 | +| M0 骨架 | Go 模块 + SQLite 迁移(user_version 守卫)+ 配置子命令 + 中间件链 + embed 构链(拷贝 + 占位 dist)+ `make build`/`make dev` | `make build` 产出可运行二进制;`start` 起服务;`/api/health` 200 | | M1 公开浏览 | 笔记/标签 API + React 博客三页 + Markdown 渲染管线 + RSS/sitemap + 元信息注入(可见性规则) | 可见性矩阵**公开侧**用例起步;**构建冒烟测试**入 CI | | M2 管理端 | 登录/会话/CSRF(`/api/me` 重取)+ 笔记 CRUD + 回收站 + 图片上传(魔数/缓存头分流)+ 编辑器 + 改密 | **表驱动可见性矩阵完整**(§13)+ 限流/CSRF/魔数/回收站用例**随功能同 PR 交付** | | M3 安全加固 | 全量 §9.3 检查单、CSP 确认(记录 nonce 硬化待办)、CI 齐备(vet+vulncheck+测试+冒烟) | §9.3 全项通过 | diff --git a/docs/review-round2.md b/docs/review-round2.md new file mode 100644 index 0000000..c9e512b --- /dev/null +++ b/docs/review-round2.md @@ -0,0 +1,128 @@ +# 《Pure Note v1.1 实现》审评报告(第 2 轮) + +> 审评对象:docs/design.md v1.1 的完整实现(Go 后端 + React 前端 + 构建/部署链),含二进制/子命令改名(pure-note → pn)后的全量代码 +> 审评日期:2026-09-08 +> 审评方法:三路并行代码评审(store/数据层、httpapi/中间件、前端),关键发现全部人工复核源码与文档(decisions.md D6 等)确认后成稿 +> 基线:docs/review-round1.md(v1.0 设计评审)——其 P0/P1 修复要求已在 v1.1 设计中落地,本轮核对实现是否兑现 + +--- + +## 一、总体结论 + +**无 P0。** 默认配置下未发现远程可利用的越权或注入漏洞:SQL 全部参数化、可见性矩阵(列表/详情/标签/RSS/sitemap/图片/meta 注入)落实到位、CSRF 双保险(token + Origin/Referer 严格模式,覆盖 login/logout)、`__Host-` Cookie、PHC 口令哈希与会话摘要、meta 注入经 html/template 转义——round1 的 4 个 P0 均已在实现层封死。 + +但存在 **5 个 P1 功能缺陷**:其中 1 个导致前端功能完全不可用却被测试漏过(图片上传)、1 个邻接导航查询逻辑写反、1 个文档承诺(D6)与实现矛盾(升级/回滚场景备份不可用)。另有 10 项 P2 加固项。 + +| 级别 | 定义 | 数量 | +| --- | --- | --- | +| P0 | 远程可利用的越权/注入/私密内容泄露 | **0** | +| P1 | 功能缺陷/契约破坏/文档承诺与实现矛盾 | **5** | +| P2 | 加固与健壮性 | **10**(表内合并条目) | + +--- + +## 二、发现汇总 + +| # | 级别 | 位置 | 问题一句话 | +| --- | --- | --- | --- | +| 1 | P1 | internal/store/notes.go:188-210 | 上一篇/下一篇邻接查询 tie-break 写反且忽略 pinned,同秒更新漏链/错链,无测试覆盖 | +| 2 | P1 | web/src/lib/api.ts:39-41 | api() 对 FormData 强制 `Content-Type: application/json`,前端图片上传完全不可用 | +| 3 | P1 | internal/httpapi/server.go:102 | `/api/admin/*` 与 `/api/me` 缺 `Cache-Control: no-store`(中间件注释宣称已覆盖) | +| 4 | P1 | web/src/pages/AdminEdit.tsx:113 | 自动保存成功后 invalidate 连详情查询一起失效,refetch 回滚正在输入的内容并抑制下一轮自动保存 | +| 5 | P1 | cmd/pn/main.go:241/316/340 | 维护子命令总是迁移且带守卫、`--allow-newer` 被解析但忽略,与 D6 矛盾;回滚场景连备份都无法执行 | +| 6 | P2 | store/maint.go:36-98 | GC SELECT 与 DELETE 之间竞态:恢复/新引用的活数据会被物理删除 | +| 7 | P2 | store/maint.go:109-119 | 备份先以默认 umask 创建后 chmod 0600,私有内容短暂暴露窗口 | +| 8 | P2 | auth/auth.go:57-68 | VerifyPassword 不校验 PHC 参数上下界,畸形哈希可 panic/OOM | +| 9 | P2 | httpapi/public.go:161-174 | 图片数据行竞态缺失时 404 携带 public immutable 一年 | +| 10 | P2 | httpapi/feed.go:52-75 | 无公开笔记时 lastBuildDate 输出公元 1 年(非法 RSS 日期) | +| 11 | P2 | middleware/ratelimit.go:41 | sameOrigin 只比 host 不比 scheme | +| 12 | P2 | httpapi/admin.go:344-349 | image.Decode 无尺寸上限,<5MB 解压炸弹可 OOM 整站 | +| 13 | P2 | store/settings.go:64-67 | Sscanf("%d") 部分解析("10abc"→10),脏 page_size 被静默采用 | +| 14 | P2 | docs(design:567/590/628、acceptance:53、decisions D6/D28) | 改名后残留 5 处 `serve` 旧词;D6 内容与实现矛盾(见 P1-5) | +| 15 | P2 | web/src 多处 | Home 硬编码 page_size 使站点设置失效;auth.tsx 登录竞态覆盖 CSRF;401 无全局跳转;公共缓存失效缺失等 | + +--- + +## 三、P1 详细说明 + +### P1-1 上一篇/下一篇邻接查询 tie-break 写反(store/notes.go:188-210) + +**问题**:公开列表序为 `ORDER BY pinned DESC, updated_at DESC, id DESC`(notes.go:169)。邻接语义应在 (updated_at, id) 字典序上取紧邻项,但: + +- prev(列表中更早一篇)写 `(updated_at > ? OR (updated_at = ? AND id < ?)) ORDER BY updated_at ASC, id DESC`——`id` 比较方向与列表序(id DESC)相反;同秒组内 `id DESC` 取到的是组内顶端而非紧邻项; +- next 同理(`id > ?` + `id ASC`)。 + +**后果**:同一秒有两篇以上笔记更新/创建(个人站点连续保存极常见,updated_at 为秒级粒度)时 prev/next 漏链或错链;且查询完全不参与 pinned 排序,置顶笔记附近跳错条目。调用方 public.go:103-104 用 `_` 吞掉错误,静默失效。该函数**无任何测试覆盖**(已确认)。 + +**修复**:prev 改为 `(updated_at > ? OR (updated_at = ? AND id > ?)) ORDER BY updated_at ASC, id ASC LIMIT 1`;next 改为 `(updated_at < ? OR (updated_at = ? AND id < ?)) ORDER BY updated_at DESC, id DESC LIMIT 1`;补表驱动测试(含同秒多篇、置顶参与排序的语义确认,必要时把 pinned 纳入邻接定义)。 + +### P1-2 前端图片上传完全不可用(web/src/lib/api.ts:39-41) + +**问题**:`api()` 对所有带 body 的请求无条件补 `Content-Type: application/json`。Editor.tsx:22 的 `uploadImage` 用 FormData(注释明确「让浏览器自动设置 Content-Type(含 boundary)」),被此逻辑覆盖为 application/json → 后端 `ParseMultipartForm` 失败 → 400,粘贴/拖拽上传功能不可用。 + +**为什么测试全绿**:Go 集成测试直连 API 手工构造正确 multipart 头;验收浏览器走查未覆盖上传动作;前端无该路径的测试。 + +**修复**:`if (options.body && !(options.body instanceof FormData) && !headers.has('Content-Type'))`;补一个前端测试或至少把上传动作纳入浏览器走查清单。 + +### P1-3 /api/admin/* 与 /api/me 缺 no-store(server.go:102、public.go:21-28) + +**问题**:middleware.go:129-136 注释宣称 NoStore「为 /api/admin/* 与 /api/auth/* 响应统一附加」,实际接线仅 `/api/auth/`(server.go:86)挂了 NoStore,`mux.Handle("/api/admin/", s.requireAdmin(adminMux))`(server.go:102)没有。`/api/me`(已认证时返回 csrf_token)同样无。 + +**后果**:登出后经 bfcache/历史回退(共用设备场景)可回看管理数据(笔记全文、回收站列表、settings、CSRF token);与设计 §9.2 目标直接冲突。 + +**修复**:`mux.Handle("/api/admin/", middleware.NoStore(s.requireAdmin(adminMux)))`;`/api/me` 已认证分支响应前补 `Cache-Control: no-store`(或对 /api/me 整路由统一 no-store)。 + +### P1-4 自动保存竞态回滚用户输入(AdminEdit.tsx:108-116、62-76) + +**问题**:保存成功 `qc.invalidateQueries({ queryKey: ['admin'] })` 前缀匹配连当前编辑中的详情查询(key 形如 `['admin','note',id]`)一起失效 → refetch 返回服务端快照 → `useEffect([existing])` 执行 `setForm(...)` 把保存期间用户继续输入的内容回滚,并把 `dirtyRef.current = false`,抑制下一轮自动保存。每次自动保存都经历一次「输入被吞」窗口。 + +**修复**:onSuccess 仅 invalidate 列表键(如 `['admin','notes']`)与回收站键,不失效当前详情;或 refetch 落地时比较 `updated_at`/内容未变才允许重置 form。 + +### P1-5 维护子命令与 D6 矛盾:迁移与守卫始终执行、--allow-newer 被忽略(cmd/pn/main.go:241/316/340) + +**问题**:decisions.md D6 承诺「backup/gc 不执行迁移、不做 user_version 守卫(纯数据操作)」,但实现中 passwd/backup/gc 一律 `store.Open(cfg.DBPath(), false)`,而 `store.Open` 无条件 `migrate()`(store.go:105)且 `false` 关闭 `--allow-newer`。`config.ParseMaint` 解析了 `--allow-newer` 却从未使用。 + +**后果**: +1. 用户升级到更高 schema 后回滚旧二进制,**`pn backup` 直接被守卫拒绝**——与「升级 SOP 第一步先备份」(review-round1 P1-13 修复)的语义冲突; +2. 错误 `--data-dir` 会静默创建并迁移出一个新库(维护命令不再是无副作用的纯数据操作); +3. 传入 `--allow-newer` 无任何效果且无提示。 + +**修复**:按 D6 拆分「打开 + 迁移(serve/init)」与「仅数据操作打开(passwd/backup/gc,跳过迁移与守卫)」,或至少让三个子命令真正透传 `cfg.AllowNewer`;同步修订 D6/代码注释,消除文档与实现二选一的漂移。 + +--- + +## 四、P2 简述 + +| # | 位置 | 修复建议 | +| --- | --- | --- | +| 6 | store/maint.go:36-98 | GC commit 阶段 DELETE 带原条件复查:笔记 `WHERE id=? AND deleted_at < ?`,图片 `DELETE ... WHERE id=? AND NOT EXISTS(SELECT 1 FROM image_refs WHERE image_id=?)`(或整体单事务)——防 SELECT 与 DELETE 之间恢复/新引用导致活数据被物理删除 | +| 7 | store/maint.go:109-119 | 备份文件以 0600 创建(先 `os.OpenFile(O_CREATE|O_EXCL, 0600)` 占位或临时目录生成后 rename),消除「先以 umask 创建后 chmod」的暴露窗口与存在性竞态 | +| 8 | auth/auth.go:57-68 | 解析 PHC 后校验 `1 ≤ t ≤ 10`、`1 ≤ p ≤ 8`、`m ≤ 1<<20`(KiB)、`len(want) == 32`,不合法直接返回 false——防 t/p=0 触发 argon2 panic、m 巨大 OOM(库被篡改场景) | +| 9 | httpapi/public.go:161-174 | 缓存头移到 `GetImageData` 成功之后设置;数据行缺失的 404 不带 public immutable(防恢复后仍被缓存 404 一年) | +| 10 | httpapi/feed.go:52-75 | 无公开笔记时 lastBuildDate 用当前时间或省略字段,不输出 `Mon, 01 Jan 0001` | +| 11 | middleware/ratelimit.go:41 | `sameOrigin` 同时比较 scheme(或至少把 http/https 视为不同源);与 HSTS 形成双层防护 | +| 12 | httpapi/admin.go:344-349 | 解码校验前先 `image.DecodeConfig` 校验宽高/像素总数上限(如 ≤8192×8192 或总像素 ≤2^26),防 <5MB 高压缩图解压炸弹 OOM | +| 13 | store/settings.go:64-67 | `Sscanf("%d")` 后校验「串已完整消费」(如 `%d%c` 探尾或 strconv.Atoi 全文),拒 "10abc" 类脏值 | +| 14 | docs 多处 | 改名收尾:design.md:567/590/628、acceptance.md:53 的 `serve` → `start`;decisions.md D6/D28 同步(D28 的 `serve` 改 `start`;D6 待 P1-5 修复后按新行为重写) | +| 15 | web/src 多处 | Home.tsx:10 改用 `/api/site` 的 page_size;auth.tsx:26-45 给 refresh 加请求序号/登录态变化判定,丢弃迟到匿名响应(StrictMode 双挂载放大窗口);api.ts 401 回调接入 AuthContext 触发跳转;写操作成功后同步 invalidate `['notes']`/`['tags']`/`['note']` 公共键;TagPage 补 error 分支;上传路径加前端测试 | + +--- + +## 五、核实通过(维持不动) + +1. **可见性单一可信点**:列表/标签/RSS/sitemap 统一走 `PublicNoteFilter`(store/notes.go:152),详情/图片/meta 出口 status + deleted_at 双层裁决,私有与不存在统一 404 防枚举,id 越界/负数均 404; +2. **SQL 注入面**:全部 `?` 参数化;仅 `VACUUM INTO` 路径经 `escapeSQLString`(引号加倍,SQLite 语义正确)与内部常量 PRAGMA 两处拼接,均无注入面; +3. **认证与 CSRF**:`__Host-` + Secure + HttpOnly + Lax、登录重建会话防固定、Origin/Referer 严格模式(缺头即拒)覆盖 login/logout、token 仅内存、改密校验旧口令且会话保持; +4. **口令与令牌**:Argon2id PHC 串(参数随哈希走)、会话库存 SHA-256 摘要、常量时间比较; +5. **上传防线**:声明类型白名单 → 魔数(SVG 拒绝)→ PNG/JPEG/GIF 再解码 → ≤5MB 哨兵 → sha256 去重; +6. **输出转义**:meta 注入经 html/template、JSON 默认 HTML 转义、RSS 经 encoding/xml + goldmark(非 unsafe) + bluemonday 双层清洗; +7. **迁移与并发**:user_version 上界守卫、仅追加式、DDL 与版本号同事务幂等;WAL + busy_timeout(5000) + synchronous(NORMAL) + foreign_keys(1) + `SetMaxOpenConns(1)`; +8. **构建/部署链**:Vite `base:'/'` + sync-assets + 指纹缓存 + 冒烟脚本(19 项断言)全链路打通; +9. **重命名**:pure-note → pn 的代码/脚本/deploy/README 残留已清理干净,本轮仅剩 docs 中 5 处 `serve` 旧词(P2-14); +10. **测试资产**:可见性矩阵、CSRF、限流、上传、回收站/gc、slug、设置白名单、meta 转义、迁移守卫均有自动化用例;`go vet`/`go test`/vitest/`make smoke` 全绿。 + +--- + +## 六、结论 + +v1.1 实现的骨架与安全设计经受住了第二轮对抗性评审,round1 的全部 P0 修复要求已在实现层兑现,无新增 P0。需优先处理的是 5 个 P1:其中 P1-1(邻接导航)与 P1-2(图片上传)是用户可直接感知的功能性缺陷,P1-3(no-store)与 P1-5(维护命令契约)是安全/运维承诺的缺口,P1-4(自动保存竞态)影响核心编辑体验。P1 修复后建议随本轮把 P2 清单中低成本项(8/9/10/13/14)一并落地,并补上 P1-1 的邻接查询测试。